feat(seo-mode): stabilize keyword demand workflow
- add manual anchor review gating before Wordstat demand collection - add keyword curation board with pointer drag/drop and strategy handoff - tighten semantic block/source coverage checks and runtime verifier - keep dev server pinned to 127.0.0.1:5177
This commit is contained in:
parent
214b714873
commit
75fd57b036
|
|
@ -4,9 +4,9 @@
|
|||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"dev": "vite --host 127.0.0.1 --port 5177 --strictPort",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"preview": "vite preview --host 127.0.0.1 --port 5177 --strictPort",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -730,6 +730,97 @@ export type SeoNormalizationContract = {
|
|||
nextActions: string[];
|
||||
};
|
||||
|
||||
export type AnchorReviewDecision = {
|
||||
decidedAt: string | null;
|
||||
decidedBy: string | null;
|
||||
reason: string;
|
||||
sendToSerp: boolean;
|
||||
sendToWordstat: boolean;
|
||||
source: "human" | "model" | "system";
|
||||
status: "approved" | "disabled" | "pending";
|
||||
};
|
||||
|
||||
export type AnchorReviewContract = {
|
||||
schemaVersion: "anchor-review.v1";
|
||||
projectId: string;
|
||||
semanticRunId: string | null;
|
||||
projectOntologyVersionId: string | null;
|
||||
generatedAt: string;
|
||||
state: "approved" | "empty" | "needs_review" | "not_ready";
|
||||
source: {
|
||||
normalizationGeneratedAt: string | null;
|
||||
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
|
||||
normalizationState: SeoNormalizationContract["state"];
|
||||
normalizationSummary: string;
|
||||
};
|
||||
analystReview: {
|
||||
status: "approved" | "needs_changes" | "not_reviewed" | "rejected";
|
||||
reviewer: "human" | "system";
|
||||
reviewedAt: string | null;
|
||||
notes: string[];
|
||||
};
|
||||
readiness: {
|
||||
sourceSeedCount: number;
|
||||
normalizedAnchorCount: number;
|
||||
proposedWordstatCount: number;
|
||||
approvedAnchorCount: number;
|
||||
disabledAnchorCount: number;
|
||||
pendingAnchorCount: number;
|
||||
wordstatQueueCount: number;
|
||||
needsHumanReviewCount: number;
|
||||
manualDecisionCount: number;
|
||||
};
|
||||
anchors: Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
phrase: string;
|
||||
normalizedFrom: string[];
|
||||
clusterId: string;
|
||||
clusterTitle: string;
|
||||
intentGroupId: string;
|
||||
intentType: SeoNormalizationContract["intentGroups"][number]["intentType"];
|
||||
marketRole: SeoNormalizationContract["seedStrategy"][number]["marketRole"];
|
||||
priority: "high" | "medium" | "low";
|
||||
source: "detected" | "ontology";
|
||||
confidence: number;
|
||||
reason: string;
|
||||
evidenceRefs: string[];
|
||||
proposal: {
|
||||
blockers: string[];
|
||||
reason: string;
|
||||
sendToSerp: boolean;
|
||||
sendToWordstat: boolean;
|
||||
status: SeoNormalizationContract["seedStrategy"][number]["review"]["status"];
|
||||
};
|
||||
decision: AnchorReviewDecision;
|
||||
}>;
|
||||
wordstatQueue: Array<{
|
||||
id: string;
|
||||
anchorId: string;
|
||||
phrase: string;
|
||||
clusterId: string;
|
||||
clusterTitle: string;
|
||||
intentGroupId: string;
|
||||
intentType: SeoNormalizationContract["intentGroups"][number]["intentType"];
|
||||
marketRole: SeoNormalizationContract["seedStrategy"][number]["marketRole"];
|
||||
normalizedFrom: string[];
|
||||
priority: "high" | "medium" | "low";
|
||||
source: "detected" | "ontology";
|
||||
confidence: number;
|
||||
reason: string;
|
||||
sendToSerp: boolean;
|
||||
}>;
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
export type AnchorReviewDecisionInput = {
|
||||
anchorId: string;
|
||||
reason?: string;
|
||||
sendToSerp?: boolean;
|
||||
sendToWordstat?: boolean;
|
||||
status: AnchorReviewDecision["status"];
|
||||
};
|
||||
|
||||
export type MarketEnrichmentContract = {
|
||||
schemaVersion: "market-enrichment.v1";
|
||||
projectId: string;
|
||||
|
|
@ -746,7 +837,12 @@ export type MarketEnrichmentContract = {
|
|||
generatedAt: string;
|
||||
state: "not_ready" | "not_collected" | "not_configured" | "partial_ready";
|
||||
normalization: SeoNormalizationContract;
|
||||
anchorReview: AnchorReviewContract;
|
||||
readiness: {
|
||||
anchorApprovedCount: number;
|
||||
anchorPendingCount: number;
|
||||
anchorReviewReady: boolean;
|
||||
anchorWordstatQueueCount: number;
|
||||
canCollectWordstat: boolean;
|
||||
canCollectSerp: boolean;
|
||||
ontologyReadyForMarket: boolean;
|
||||
|
|
@ -1286,6 +1382,13 @@ export type KeywordMapContract = {
|
|||
nextActions: string[];
|
||||
};
|
||||
|
||||
export type KeywordMapRole = KeywordMapContract["items"][number]["role"];
|
||||
|
||||
export type KeywordMapRoleOverrideInput = {
|
||||
itemId: string;
|
||||
role: KeywordMapRole;
|
||||
};
|
||||
|
||||
export type SeoStrategyContract = {
|
||||
schemaVersion: "seo-strategy.v1";
|
||||
projectId: string;
|
||||
|
|
@ -1643,16 +1746,18 @@ export type RewritePlanContract = {
|
|||
semanticRunId: string | null;
|
||||
projectOntologyVersionId: string | null;
|
||||
generatedAt: string;
|
||||
state: "blocked" | "materialization_required" | "partial_ready" | "ready_for_diff";
|
||||
state: "blocked" | "materialization_required" | "partial_ready" | "ready_for_diff" | "strategy_gap_only";
|
||||
readiness: {
|
||||
approvedDecisionCount: number;
|
||||
existingPageTargetCount: number;
|
||||
futureLandingGapCount: number;
|
||||
plannedLandingTargetCount: number;
|
||||
rewriteCandidateCount: number;
|
||||
blockerCount: number;
|
||||
};
|
||||
blockers: string[];
|
||||
existingPageTargets: RewritePlanTarget[];
|
||||
futureLandingTargets: RewritePlanTarget[];
|
||||
materializationQueue: RewritePlanTarget[];
|
||||
nextActions: string[];
|
||||
};
|
||||
|
|
@ -1664,7 +1769,7 @@ export type RewritePlanTarget = {
|
|||
pageTitle: string | null;
|
||||
sourcePath: string | null;
|
||||
urlPath: string | null;
|
||||
status: "needs_materialization" | "planned_placeholder" | "ready_for_diff";
|
||||
status: "future_landing_gap" | "needs_materialization" | "needs_user_mapping" | "planned_placeholder" | "ready_for_diff";
|
||||
blocker: string | null;
|
||||
keywordBindings: Array<{
|
||||
decisionId: string;
|
||||
|
|
@ -1695,6 +1800,7 @@ export type MaterializationContract = {
|
|||
readiness: {
|
||||
awaitingSourceCount: number;
|
||||
blockerCount: number;
|
||||
futureLandingGapCount: number;
|
||||
linkedTargetCount: number;
|
||||
pendingTargetCount: number;
|
||||
placeholderTargetCount: number;
|
||||
|
|
@ -1711,8 +1817,10 @@ export type MaterializationContract = {
|
|||
| "awaiting_source"
|
||||
| "can_link_existing"
|
||||
| "deferred"
|
||||
| "future_landing_gap"
|
||||
| "linked_existing"
|
||||
| "needs_materialization"
|
||||
| "needs_user_mapping"
|
||||
| "planned_placeholder"
|
||||
| "rejected";
|
||||
pageId: string | null;
|
||||
|
|
@ -1795,6 +1903,7 @@ export type RewriteDiffContract = {
|
|||
state: "blocked" | "materialization_required" | "partial_ready" | "ready_for_model_review";
|
||||
readiness: {
|
||||
blockerCount: number;
|
||||
futureLandingGapCount: number;
|
||||
materializationTargetCount: number;
|
||||
modelReadySlotCount: number;
|
||||
semanticBlockCount: number;
|
||||
|
|
@ -2722,6 +2831,32 @@ export async function saveManualSeoNormalization(projectId: string, output: Reco
|
|||
return response.json() as Promise<{ normalization: SeoNormalizationContract }>;
|
||||
}
|
||||
|
||||
export async function fetchLatestAnchorReview(projectId: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/anchor-review/latest`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось загрузить anchor review: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ anchorReview: AnchorReviewContract }>;
|
||||
}
|
||||
|
||||
export async function saveAnchorReviewDecisions(projectId: string, decisions?: AnchorReviewDecisionInput[]) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/anchor-review/decisions`, {
|
||||
body: JSON.stringify({ decisions }),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось сохранить anchor review: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ anchorReview: AnchorReviewContract }>;
|
||||
}
|
||||
|
||||
export async function fetchLatestKeywordCleaning(projectId: string) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-cleaning/latest`);
|
||||
|
||||
|
|
@ -2872,13 +3007,19 @@ export async function runStrategyQualityReview(projectId: string) {
|
|||
return response.json() as Promise<{ strategyQualityReview: StrategyQualityReviewContract }>;
|
||||
}
|
||||
|
||||
export async function approveKeywordMapDecisions(projectId: string, itemIds?: string[]) {
|
||||
export async function approveKeywordMapDecisions(
|
||||
projectId: string,
|
||||
input?: {
|
||||
itemIds?: string[];
|
||||
roleOverrides?: KeywordMapRoleOverrideInput[];
|
||||
}
|
||||
) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-map/decisions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ itemIds })
|
||||
body: JSON.stringify(input ?? {})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -2794,14 +2794,440 @@ input {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.stage-content-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anchor-review-card,
|
||||
.keyword-cleaning-card {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.anchor-review-card > button,
|
||||
.keyword-cleaning-card > button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.keyword-stage-section {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-card, 1.35rem);
|
||||
box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055);
|
||||
}
|
||||
|
||||
.keyword-stage-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyword-stage-heading > svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.keyword-stage-heading > div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyword-stage-heading strong {
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-size: 18px;
|
||||
font-weight: 920;
|
||||
line-height: 1.08;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.keyword-stage-heading small {
|
||||
min-width: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 740;
|
||||
line-height: 1.38;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.anchor-review-workspace {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.anchor-review-row > div:first-child span {
|
||||
justify-self: start;
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
color: #2e5a67;
|
||||
background: rgba(46, 90, 103, 0.08);
|
||||
border: 1px solid rgba(46, 90, 103, 0.14);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.anchor-review-row strong,
|
||||
.anchor-review-empty strong {
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.anchor-review-row small,
|
||||
.anchor-review-row p,
|
||||
.anchor-review-empty small {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 740;
|
||||
line-height: 1.38;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.keyword-curation-board {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
border-radius: var(--launcher-radius-card, 1.35rem);
|
||||
box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055);
|
||||
}
|
||||
|
||||
.keyword-curation-board-head,
|
||||
.keyword-curation-footer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyword-curation-board-head > .keyword-stage-heading {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.keyword-curation-card > span {
|
||||
justify-self: start;
|
||||
min-height: 22px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 9px;
|
||||
color: #2e5a67;
|
||||
background: rgba(46, 90, 103, 0.08);
|
||||
border: 1px solid rgba(46, 90, 103, 0.14);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.keyword-curation-column-head strong,
|
||||
.keyword-curation-card strong {
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.keyword-curation-column-head small,
|
||||
.keyword-curation-card small,
|
||||
.keyword-curation-card p,
|
||||
.keyword-curation-footer small {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 740;
|
||||
line-height: 1.38;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.keyword-curation-columns {
|
||||
display: grid;
|
||||
grid-auto-columns: minmax(280px, 1fr);
|
||||
grid-auto-flow: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.keyword-curation-column {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(180px, 1fr);
|
||||
gap: 8px;
|
||||
min-width: 280px;
|
||||
max-height: min(640px, 58vh);
|
||||
padding: 8px;
|
||||
background: rgba(24, 32, 29, 0.035);
|
||||
border: 1px solid rgba(24, 32, 29, 0.07);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease;
|
||||
}
|
||||
|
||||
.keyword-curation-column.over {
|
||||
background: rgba(46, 90, 103, 0.07);
|
||||
border-color: rgba(46, 90, 103, 0.22);
|
||||
}
|
||||
|
||||
.keyword-curation-column-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyword-curation-column-head > div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.keyword-curation-column-head > span {
|
||||
flex: 0 0 auto;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.keyword-curation-stack {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.keyword-curation-draggable {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.keyword-curation-draggable.dragging {
|
||||
cursor: grabbing;
|
||||
opacity: 0.28;
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.keyword-curation-card {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 24px rgba(24, 32, 29, 0.08);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.keyword-curation-drag-preview {
|
||||
position: fixed;
|
||||
left: var(--keyword-drag-x);
|
||||
top: var(--keyword-drag-y);
|
||||
width: 260px;
|
||||
pointer-events: none;
|
||||
transform: translate(-50%, -24px) rotate(-0.6deg);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.keyword-curation-drag-preview .keyword-curation-card {
|
||||
background: #fff;
|
||||
border-color: rgba(232, 62, 140, 0.28);
|
||||
box-shadow: 0 24px 54px rgba(24, 32, 29, 0.22);
|
||||
}
|
||||
|
||||
.keyword-curation-empty {
|
||||
min-height: 88px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 10px;
|
||||
color: var(--muted);
|
||||
background: rgba(255, 255, 255, 0.52);
|
||||
border: 1px dashed rgba(24, 32, 29, 0.14);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 820;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.keyword-curation-footer {
|
||||
align-items: center;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.anchor-review-filterbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anchor-review-filterbar button {
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 0 10px;
|
||||
color: var(--muted);
|
||||
background: rgba(24, 32, 29, 0.035);
|
||||
border: 1px solid rgba(24, 32, 29, 0.07);
|
||||
border-radius: 999px;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anchor-review-filterbar button.active {
|
||||
color: #fff;
|
||||
background: #202124;
|
||||
border-color: #202124;
|
||||
}
|
||||
|
||||
.anchor-review-filterbar button span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 880;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anchor-review-filterbar button strong {
|
||||
flex: 0 0 auto;
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.anchor-review-workspace-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: min(560px, 52vh);
|
||||
min-width: 0;
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.anchor-review-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1fr) minmax(180px, 0.4fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
background: rgba(24, 32, 29, 0.032);
|
||||
border: 1px solid rgba(24, 32, 29, 0.055);
|
||||
border-left: 4px solid rgba(46, 90, 103, 0.18);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.anchor-review-row.approved {
|
||||
border-left-color: #2f6b4f;
|
||||
}
|
||||
|
||||
.anchor-review-row.disabled {
|
||||
border-left-color: #9b3b3b;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.anchor-review-row.pending {
|
||||
border-left-color: #b27a16;
|
||||
}
|
||||
|
||||
.anchor-review-row > div:first-child {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anchor-review-row-meta,
|
||||
.anchor-review-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anchor-review-row-meta span {
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 0 8px;
|
||||
color: #2e4960;
|
||||
background: rgba(46, 73, 96, 0.06);
|
||||
border: 1px solid rgba(46, 73, 96, 0.08);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anchor-review-row-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.anchor-review-row-actions .tiny-action {
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
height: 36px;
|
||||
min-height: 36px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anchor-review-row-actions .anchor-action-button.active,
|
||||
.anchor-review-row-actions .anchor-action-button.active:disabled {
|
||||
color: #fff;
|
||||
background: #e83e8c;
|
||||
border-color: #e83e8c;
|
||||
opacity: 1 !important;
|
||||
box-shadow: 0 12px 28px rgba(232, 62, 140, 0.22);
|
||||
}
|
||||
|
||||
.anchor-review-empty {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
background: rgba(24, 32, 29, 0.032);
|
||||
border: 1px dashed rgba(24, 32, 29, 0.14);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.keyword-cleaning-review {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
|
@ -11723,6 +12149,28 @@ textarea:focus {
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.keyword-curation-board-head,
|
||||
.keyword-curation-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.keyword-curation-columns {
|
||||
grid-auto-columns: minmax(260px, 86vw);
|
||||
}
|
||||
|
||||
.anchor-review-filterbar {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.anchor-review-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.anchor-review-row-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.market-decision-board,
|
||||
.market-decision-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
|
|
@ -13840,8 +14288,18 @@ body:has(.seo-launcher-shell) {
|
|||
|
||||
.seo-launcher-shell.project-open .seo-work-panel {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.66)),
|
||||
rgba(255, 255, 255, 0.76) !important;
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0.46)),
|
||||
rgba(255, 255, 255, 0.58) !important;
|
||||
}
|
||||
|
||||
.seo-launcher-shell.project-open .workspace.stage-5 .market-panel {
|
||||
padding: 1rem !important;
|
||||
background: #fff !important;
|
||||
border: 0 !important;
|
||||
border-radius: var(--launcher-radius-card) !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
-webkit-backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
.seo-launcher-shell .workspace.stage-2 .project-scope-flow-controls {
|
||||
|
|
@ -14288,6 +14746,15 @@ body:has(.seo-launcher-shell) {
|
|||
border-color: var(--ink);
|
||||
}
|
||||
|
||||
.anchor-review-row-actions .tiny-action.anchor-action-button.active,
|
||||
.anchor-review-row-actions .tiny-action.anchor-action-button.active:disabled {
|
||||
color: #ffffff !important;
|
||||
background: #e83e8c !important;
|
||||
border-color: #e83e8c !important;
|
||||
opacity: 1 !important;
|
||||
box-shadow: 0 12px 28px rgba(232, 62, 140, 0.22) !important;
|
||||
}
|
||||
|
||||
@keyframes semantic-generation-pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(232, 45, 134, 0.36);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"dev:server": "npm run dev -w server",
|
||||
"build": "npm run build -w server && npm run build -w app",
|
||||
"check:demand-coverage": "npm run check:demand-coverage -w server",
|
||||
"check:semantic-block-runtime": "npm run check:semantic-block-runtime -w server",
|
||||
"check:semantic-blocks": "npm run check:semantic-blocks -w server",
|
||||
"check:source-models": "npm run check:source-models -w server",
|
||||
"typecheck": "npm run typecheck -w server && npm run typecheck -w app",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"check:demand-coverage": "tsx src/scripts/checkDemandCoverage.ts",
|
||||
"check:semantic-block-runtime": "tsx src/scripts/checkSemanticBlockRuntimeCoverage.ts",
|
||||
"check:semantic-blocks": "tsx src/scripts/checkSemanticBlockUniversality.ts",
|
||||
"check:source-models": "tsx src/scripts/checkSourceModelUniversality.ts",
|
||||
"check:ontology-universality": "tsx src/scripts/checkOntologyUniversality.ts",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,489 @@
|
|||
import { pool } from "../db/client.js";
|
||||
import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
||||
|
||||
type AnchorDecisionStatus = "approved" | "disabled" | "pending";
|
||||
type AnchorDecisionSource = "human" | "model" | "system";
|
||||
type AnchorReviewState = "approved" | "empty" | "needs_review" | "not_ready";
|
||||
type SeoNormalizationSeed = SeoNormalizationContract["seedStrategy"][number];
|
||||
|
||||
export type AnchorReviewDecisionInput = {
|
||||
anchorId: string;
|
||||
reason?: string;
|
||||
sendToSerp?: boolean;
|
||||
sendToWordstat?: boolean;
|
||||
status: AnchorDecisionStatus;
|
||||
};
|
||||
|
||||
export type AnchorReviewDecision = {
|
||||
decidedAt: string | null;
|
||||
decidedBy: string | null;
|
||||
reason: string;
|
||||
sendToSerp: boolean;
|
||||
sendToWordstat: boolean;
|
||||
source: AnchorDecisionSource;
|
||||
status: AnchorDecisionStatus;
|
||||
};
|
||||
|
||||
export type AnchorReviewAnchor = {
|
||||
id: string;
|
||||
key: string;
|
||||
phrase: string;
|
||||
normalizedFrom: string[];
|
||||
clusterId: string;
|
||||
clusterTitle: string;
|
||||
intentGroupId: string;
|
||||
intentType: SeoNormalizationSeed["intentType"];
|
||||
marketRole: SeoNormalizationSeed["marketRole"];
|
||||
priority: SeoNormalizationSeed["priority"];
|
||||
source: SeoNormalizationSeed["source"];
|
||||
confidence: number;
|
||||
reason: string;
|
||||
evidenceRefs: string[];
|
||||
proposal: {
|
||||
blockers: string[];
|
||||
reason: string;
|
||||
sendToSerp: boolean;
|
||||
sendToWordstat: boolean;
|
||||
status: SeoNormalizationSeed["review"]["status"];
|
||||
};
|
||||
decision: AnchorReviewDecision;
|
||||
};
|
||||
|
||||
export type AnchorReviewWordstatQueueItem = {
|
||||
id: string;
|
||||
anchorId: string;
|
||||
phrase: string;
|
||||
clusterId: string;
|
||||
clusterTitle: string;
|
||||
intentGroupId: string;
|
||||
intentType: SeoNormalizationSeed["intentType"];
|
||||
marketRole: SeoNormalizationSeed["marketRole"];
|
||||
normalizedFrom: string[];
|
||||
priority: SeoNormalizationSeed["priority"];
|
||||
source: SeoNormalizationSeed["source"];
|
||||
confidence: number;
|
||||
reason: string;
|
||||
sendToSerp: boolean;
|
||||
};
|
||||
|
||||
export type AnchorReviewContract = {
|
||||
schemaVersion: "anchor-review.v1";
|
||||
projectId: string;
|
||||
semanticRunId: string | null;
|
||||
projectOntologyVersionId: string | null;
|
||||
generatedAt: string;
|
||||
state: AnchorReviewState;
|
||||
source: {
|
||||
normalizationGeneratedAt: string | null;
|
||||
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
|
||||
normalizationState: SeoNormalizationContract["state"];
|
||||
normalizationSummary: string;
|
||||
};
|
||||
analystReview: {
|
||||
status: "approved" | "needs_changes" | "not_reviewed" | "rejected";
|
||||
reviewer: "human" | "system";
|
||||
reviewedAt: string | null;
|
||||
notes: string[];
|
||||
};
|
||||
readiness: {
|
||||
sourceSeedCount: number;
|
||||
normalizedAnchorCount: number;
|
||||
proposedWordstatCount: number;
|
||||
approvedAnchorCount: number;
|
||||
disabledAnchorCount: number;
|
||||
pendingAnchorCount: number;
|
||||
wordstatQueueCount: number;
|
||||
needsHumanReviewCount: number;
|
||||
manualDecisionCount: number;
|
||||
};
|
||||
anchors: AnchorReviewAnchor[];
|
||||
wordstatQueue: AnchorReviewWordstatQueueItem[];
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
type AnchorReviewRunRow = {
|
||||
id: string;
|
||||
output: AnchorReviewContract | null;
|
||||
};
|
||||
|
||||
type DecisionSnapshot = {
|
||||
id: string;
|
||||
key: string;
|
||||
decision: AnchorReviewDecision;
|
||||
};
|
||||
|
||||
function normalizePhrase(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
||||
function getAnchorKey(seed: Pick<SeoNormalizationSeed, "clusterId" | "phrase">) {
|
||||
return `${seed.clusterId}:${normalizePhrase(seed.phrase)}`;
|
||||
}
|
||||
|
||||
function getDefaultDecision(seed: SeoNormalizationSeed): AnchorReviewDecision {
|
||||
if (seed.review.status === "rejected" || seed.marketRole === "exclude") {
|
||||
return {
|
||||
decidedAt: null,
|
||||
decidedBy: null,
|
||||
reason: seed.review.reason || "Normalization rejected this anchor before market routing.",
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: "system",
|
||||
status: "disabled"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
decidedAt: null,
|
||||
decidedBy: null,
|
||||
reason: "Ожидает human approval: auto proposal не является разрешением на внешний сбор.",
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: "system",
|
||||
status: "pending"
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorReviewDecision): AnchorReviewDecision {
|
||||
if (decision.status === "approved") {
|
||||
return {
|
||||
decidedAt: decision.decidedAt,
|
||||
decidedBy: decision.decidedBy,
|
||||
reason: decision.reason || seed.review.reason || seed.reason,
|
||||
sendToSerp: Boolean(decision.sendToSerp),
|
||||
sendToWordstat: Boolean(decision.sendToWordstat),
|
||||
source: decision.source,
|
||||
status: decision.sendToWordstat || decision.sendToSerp ? "approved" : "pending"
|
||||
};
|
||||
}
|
||||
|
||||
if (decision.status === "disabled") {
|
||||
return {
|
||||
decidedAt: decision.decidedAt,
|
||||
decidedBy: decision.decidedBy,
|
||||
reason: decision.reason || "Anchor disabled by review.",
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: decision.source,
|
||||
status: "disabled"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
decidedAt: decision.decidedAt,
|
||||
decidedBy: decision.decidedBy,
|
||||
reason: decision.reason || "Ожидает human approval.",
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: decision.source,
|
||||
status: "pending"
|
||||
};
|
||||
}
|
||||
|
||||
function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] {
|
||||
return (savedOutput?.anchors ?? []).map((anchor) => ({
|
||||
decision: anchor.decision,
|
||||
id: anchor.id,
|
||||
key: anchor.key
|
||||
}));
|
||||
}
|
||||
|
||||
function buildAnchor(
|
||||
seed: SeoNormalizationSeed,
|
||||
index: number,
|
||||
decisionSnapshotsById: Map<string, DecisionSnapshot>,
|
||||
decisionSnapshotsByKey: Map<string, DecisionSnapshot>
|
||||
): AnchorReviewAnchor {
|
||||
const key = getAnchorKey(seed);
|
||||
const savedDecision = decisionSnapshotsById.get(seed.id)?.decision ?? decisionSnapshotsByKey.get(key)?.decision;
|
||||
const decision = savedDecision ? normalizeSavedDecision(seed, savedDecision) : getDefaultDecision(seed);
|
||||
|
||||
return {
|
||||
clusterId: seed.clusterId,
|
||||
clusterTitle: seed.clusterTitle,
|
||||
confidence: seed.confidence,
|
||||
decision,
|
||||
evidenceRefs: [`normalization.seedStrategy:${index}`, `normalization.intentGroup:${seed.intentGroupId}`],
|
||||
id: seed.id,
|
||||
intentGroupId: seed.intentGroupId,
|
||||
intentType: seed.intentType,
|
||||
key,
|
||||
marketRole: seed.marketRole,
|
||||
normalizedFrom: seed.normalizedFrom,
|
||||
phrase: seed.phrase,
|
||||
priority: seed.priority,
|
||||
proposal: {
|
||||
blockers: seed.review.blockers,
|
||||
reason: seed.review.reason,
|
||||
sendToSerp: seed.review.sendToSerp,
|
||||
sendToWordstat: seed.review.sendToWordstat,
|
||||
status: seed.review.status
|
||||
},
|
||||
reason: seed.reason,
|
||||
source: seed.source
|
||||
};
|
||||
}
|
||||
|
||||
function buildWordstatQueue(anchors: AnchorReviewAnchor[]): AnchorReviewWordstatQueueItem[] {
|
||||
return anchors
|
||||
.filter((anchor) => anchor.decision.status === "approved" && anchor.decision.sendToWordstat)
|
||||
.map((anchor) => ({
|
||||
anchorId: anchor.id,
|
||||
clusterId: anchor.clusterId,
|
||||
clusterTitle: anchor.clusterTitle,
|
||||
confidence: anchor.confidence,
|
||||
id: `anchor-wordstat:${anchor.id}`,
|
||||
intentGroupId: anchor.intentGroupId,
|
||||
intentType: anchor.intentType,
|
||||
marketRole: anchor.marketRole,
|
||||
normalizedFrom: anchor.normalizedFrom,
|
||||
phrase: anchor.phrase,
|
||||
priority: anchor.priority,
|
||||
reason: anchor.decision.reason || anchor.reason,
|
||||
sendToSerp: anchor.decision.sendToSerp,
|
||||
source: anchor.source
|
||||
}));
|
||||
}
|
||||
|
||||
function getState(normalization: SeoNormalizationContract, anchors: AnchorReviewAnchor[], wordstatQueueCount: number): AnchorReviewState {
|
||||
if (normalization.state !== "ready" || !normalization.semanticRunId) {
|
||||
return "not_ready";
|
||||
}
|
||||
|
||||
if (anchors.length === 0) {
|
||||
return "empty";
|
||||
}
|
||||
|
||||
return wordstatQueueCount > 0 ? "approved" : "needs_review";
|
||||
}
|
||||
|
||||
function buildNextActions(contract: Omit<AnchorReviewContract, "nextActions">) {
|
||||
const actions: string[] = [];
|
||||
|
||||
if (contract.state === "not_ready") {
|
||||
actions.push("Сначала нужен Context Review и SEO normalization: anchor review не должен работать от сырых фраз сайта.");
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (contract.readiness.wordstatQueueCount === 0) {
|
||||
actions.push("Подтвердить смысловые якоря перед Wordstat: auto proposal не является разрешением на внешний сбор.");
|
||||
} else {
|
||||
actions.push(
|
||||
`Wordstat queue зафиксирована вручную: ${contract.readiness.wordstatQueueCount} anchors можно отправлять во внешний сбор.`
|
||||
);
|
||||
}
|
||||
|
||||
if (contract.readiness.pendingAnchorCount > 0) {
|
||||
actions.push(
|
||||
`Осталось разобрать ${contract.readiness.pendingAnchorCount} anchors: отключить шум или явно разрешить market routing.`
|
||||
);
|
||||
}
|
||||
|
||||
if (contract.readiness.needsHumanReviewCount > 0) {
|
||||
actions.push(
|
||||
`${contract.readiness.needsHumanReviewCount} anchors помечены normalization как спорные; они не идут в Wordstat без отдельного решения.`
|
||||
);
|
||||
}
|
||||
|
||||
actions.push("После Wordstat related/top строки должны попасть в keyword curation board, а не напрямую в rewrite.");
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function buildContract(
|
||||
projectId: string,
|
||||
normalization: SeoNormalizationContract,
|
||||
decisionSnapshots: DecisionSnapshot[] = []
|
||||
): AnchorReviewContract {
|
||||
const decisionSnapshotsById = new Map(decisionSnapshots.map((snapshot) => [snapshot.id, snapshot]));
|
||||
const decisionSnapshotsByKey = new Map(decisionSnapshots.map((snapshot) => [snapshot.key, snapshot]));
|
||||
const anchors = normalization.seedStrategy.map((seed, index) =>
|
||||
buildAnchor(seed, index, decisionSnapshotsById, decisionSnapshotsByKey)
|
||||
);
|
||||
const wordstatQueue = buildWordstatQueue(anchors);
|
||||
const pendingAnchorCount = anchors.filter((anchor) => anchor.decision.status === "pending").length;
|
||||
const approvedAnchorCount = anchors.filter((anchor) => anchor.decision.status === "approved").length;
|
||||
const disabledAnchorCount = anchors.filter((anchor) => anchor.decision.status === "disabled").length;
|
||||
const manualDecisionCount = anchors.filter((anchor) => anchor.decision.decidedAt).length;
|
||||
const contractWithoutActions: Omit<AnchorReviewContract, "nextActions"> = {
|
||||
schemaVersion: "anchor-review.v1",
|
||||
projectId,
|
||||
semanticRunId: normalization.semanticRunId,
|
||||
projectOntologyVersionId: normalization.projectOntologyVersionId,
|
||||
generatedAt: new Date().toISOString(),
|
||||
state: getState(normalization, anchors, wordstatQueue.length),
|
||||
source: {
|
||||
normalizationGeneratedAt: normalization.generatedAt,
|
||||
normalizationProviderMode: normalization.provider.mode,
|
||||
normalizationState: normalization.state,
|
||||
normalizationSummary: normalization.summary
|
||||
},
|
||||
analystReview: {
|
||||
notes:
|
||||
wordstatQueue.length > 0
|
||||
? ["Human-approved anchor queue exists. Market collection must use only wordstatQueue."]
|
||||
: ["Anchor queue awaits human approval before market collection."],
|
||||
reviewedAt: manualDecisionCount > 0 ? anchors.find((anchor) => anchor.decision.decidedAt)?.decision.decidedAt ?? null : null,
|
||||
reviewer: manualDecisionCount > 0 ? "human" : "system",
|
||||
status: wordstatQueue.length > 0 ? "approved" : "not_reviewed"
|
||||
},
|
||||
readiness: {
|
||||
approvedAnchorCount,
|
||||
disabledAnchorCount,
|
||||
manualDecisionCount,
|
||||
needsHumanReviewCount: anchors.filter((anchor) => anchor.proposal.status === "needs_human").length,
|
||||
normalizedAnchorCount: anchors.length,
|
||||
pendingAnchorCount,
|
||||
proposedWordstatCount: anchors.filter((anchor) => anchor.proposal.sendToWordstat).length,
|
||||
sourceSeedCount: normalization.readiness.sourceSeedCount,
|
||||
wordstatQueueCount: wordstatQueue.length
|
||||
},
|
||||
anchors,
|
||||
wordstatQueue
|
||||
};
|
||||
|
||||
return {
|
||||
...contractWithoutActions,
|
||||
nextActions: buildNextActions(contractWithoutActions)
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLatestAlignedAnchorReviewOutput(
|
||||
projectId: string,
|
||||
semanticRunId: string
|
||||
): Promise<AnchorReviewContract | null> {
|
||||
const result = await pool.query<AnchorReviewRunRow>(
|
||||
`
|
||||
select id, output
|
||||
from runs
|
||||
where project_id = $1
|
||||
and run_type = 'anchor_review'
|
||||
and output->>'semanticRunId' = $2
|
||||
order by created_at desc
|
||||
limit 1;
|
||||
`,
|
||||
[projectId, semanticRunId]
|
||||
);
|
||||
|
||||
const output = result.rows[0]?.output ?? null;
|
||||
|
||||
return output?.schemaVersion === "anchor-review.v1" ? output : null;
|
||||
}
|
||||
|
||||
export async function getAnchorReviewContract(projectId: string): Promise<AnchorReviewContract> {
|
||||
const normalization = await getSeoNormalizationContract(projectId);
|
||||
const savedOutput = normalization.semanticRunId
|
||||
? await getLatestAlignedAnchorReviewOutput(projectId, normalization.semanticRunId)
|
||||
: null;
|
||||
|
||||
return buildContract(projectId, normalization, getSavedDecisionSnapshots(savedOutput));
|
||||
}
|
||||
|
||||
function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecisionInput, decidedAt: string): AnchorReviewDecision {
|
||||
const defaultReason =
|
||||
input.status === "approved"
|
||||
? "Human-approved anchor for market collection."
|
||||
: input.status === "disabled"
|
||||
? "Human disabled this anchor before market collection."
|
||||
: "Human left this anchor pending.";
|
||||
|
||||
if (input.status === "approved") {
|
||||
const sendToSerp = input.sendToSerp ?? anchor.proposal.sendToSerp;
|
||||
const sendToWordstat = input.sendToWordstat ?? anchor.proposal.sendToWordstat;
|
||||
|
||||
return {
|
||||
decidedAt,
|
||||
decidedBy: "dev-mode",
|
||||
reason: input.reason?.trim() || defaultReason,
|
||||
sendToSerp,
|
||||
sendToWordstat,
|
||||
source: "human",
|
||||
status: sendToWordstat || sendToSerp ? "approved" : "pending"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.status === "disabled") {
|
||||
return {
|
||||
decidedAt,
|
||||
decidedBy: "dev-mode",
|
||||
reason: input.reason?.trim() || defaultReason,
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: "human",
|
||||
status: "disabled"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
decidedAt,
|
||||
decidedBy: "dev-mode",
|
||||
reason: input.reason?.trim() || defaultReason,
|
||||
sendToSerp: false,
|
||||
sendToWordstat: false,
|
||||
source: "human",
|
||||
status: "pending"
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveAnchorReviewDecisions(
|
||||
projectId: string,
|
||||
decisions: AnchorReviewDecisionInput[] = []
|
||||
): Promise<AnchorReviewContract> {
|
||||
const current = await getAnchorReviewContract(projectId);
|
||||
|
||||
if (!current.semanticRunId || current.state === "not_ready") {
|
||||
throw new Error("Anchor review не готов: сначала нужен semantic analysis, context review и normalization.");
|
||||
}
|
||||
|
||||
const decidedAt = new Date().toISOString();
|
||||
const anchorsById = new Map(current.anchors.map((anchor) => [anchor.id, anchor]));
|
||||
const nextDecisionById = new Map(current.anchors.map((anchor) => [anchor.id, anchor.decision]));
|
||||
const inputs =
|
||||
decisions.length > 0
|
||||
? decisions
|
||||
: current.anchors
|
||||
.filter((anchor) => anchor.proposal.sendToWordstat && anchor.proposal.status !== "rejected")
|
||||
.map((anchor) => ({
|
||||
anchorId: anchor.id,
|
||||
reason: "Human approved proposed Wordstat anchor queue.",
|
||||
sendToSerp: anchor.proposal.sendToSerp,
|
||||
sendToWordstat: true,
|
||||
status: "approved" as const
|
||||
}));
|
||||
|
||||
for (const input of inputs) {
|
||||
const anchor = anchorsById.get(input.anchorId);
|
||||
|
||||
if (!anchor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextDecisionById.set(anchor.id, applyDecisionInput(anchor, input, decidedAt));
|
||||
}
|
||||
|
||||
const decisionSnapshots: DecisionSnapshot[] = current.anchors.map((anchor) => ({
|
||||
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
|
||||
id: anchor.id,
|
||||
key: anchor.key
|
||||
}));
|
||||
const normalization = await getSeoNormalizationContract(projectId);
|
||||
const output = buildContract(projectId, normalization, decisionSnapshots);
|
||||
const result = await pool.query<AnchorReviewRunRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
values ($1, 'anchor_review', 'done', $2::jsonb, $3::jsonb, now(), now())
|
||||
returning id, output;
|
||||
`,
|
||||
[
|
||||
projectId,
|
||||
JSON.stringify({
|
||||
decisionCount: inputs.length,
|
||||
schemaVersion: "anchor-review-input.v1",
|
||||
semanticRunId: output.semanticRunId
|
||||
}),
|
||||
JSON.stringify(output)
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0]?.output ?? output;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keyw
|
|||
|
||||
type KeywordMapState = "blocked" | "draft";
|
||||
type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
|
||||
const keywordMapRoles: KeywordMapRole[] = ["differentiator", "primary", "secondary", "secondary_candidate", "support", "validate"];
|
||||
|
||||
export type KeywordMapContract = {
|
||||
schemaVersion: "keyword-map.v1";
|
||||
|
|
@ -82,6 +83,18 @@ export type KeywordMapPersistResult = {
|
|||
type KeywordMapItem = KeywordMapContract["items"][number];
|
||||
type KeywordCleaningItem = KeywordCleaningContract["items"][number];
|
||||
|
||||
export type KeywordMapApproveInput = {
|
||||
itemIds?: string[];
|
||||
roleOverrides?: Array<{
|
||||
itemId: string;
|
||||
role: KeywordMapRole;
|
||||
}>;
|
||||
};
|
||||
|
||||
function isKeywordMapRole(value: string): value is KeywordMapRole {
|
||||
return keywordMapRoles.includes(value as KeywordMapRole);
|
||||
}
|
||||
|
||||
function normalizePhrase(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||
}
|
||||
|
|
@ -337,6 +350,24 @@ function isPersistableKeywordMapItem(item: KeywordMapItem) {
|
|||
);
|
||||
}
|
||||
|
||||
function applyKeywordMapRoleOverride(item: KeywordMapItem, role: KeywordMapRole): KeywordMapItem {
|
||||
if (item.role === role) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const nonRewriteRole = role === "validate" || role === "secondary_candidate";
|
||||
const targetPath = nonRewriteRole ? null : (item.targetPath ?? item.relatedLanding);
|
||||
const relatedLanding = nonRewriteRole ? (item.relatedLanding ?? item.targetPath) : item.relatedLanding;
|
||||
|
||||
return {
|
||||
...item,
|
||||
reason: `Пользователь перенёс фразу в роль «${role}». ${item.reason}`,
|
||||
relatedLanding,
|
||||
role,
|
||||
targetPath
|
||||
};
|
||||
}
|
||||
|
||||
function getWorkspaceSectionIdForRole(role: KeywordMapRole) {
|
||||
if (role === "primary") {
|
||||
return "seo-title";
|
||||
|
|
@ -629,13 +660,22 @@ export async function getKeywordMapContract(projectId: string): Promise<KeywordM
|
|||
|
||||
export async function approveKeywordMapDecisions(
|
||||
projectId: string,
|
||||
input: {
|
||||
itemIds?: string[];
|
||||
} = {}
|
||||
input: KeywordMapApproveInput = {}
|
||||
): Promise<KeywordMapPersistResult> {
|
||||
const keywordMap = await getKeywordMapContract(projectId);
|
||||
const itemIdFilter = input.itemIds && input.itemIds.length > 0 ? new Set(input.itemIds) : null;
|
||||
const selectedItems = keywordMap.items.filter((item) => !itemIdFilter || itemIdFilter.has(item.id));
|
||||
const roleOverrideByItemId = new Map(
|
||||
(input.roleOverrides ?? [])
|
||||
.filter((override) => isKeywordMapRole(override.role))
|
||||
.map((override) => [override.itemId, override.role] as const)
|
||||
);
|
||||
const selectedItems = keywordMap.items
|
||||
.filter((item) => !itemIdFilter || itemIdFilter.has(item.id))
|
||||
.map((item) => {
|
||||
const roleOverride = roleOverrideByItemId.get(item.id);
|
||||
|
||||
return roleOverride ? applyKeywordMapRoleOverride(item, roleOverride) : item;
|
||||
});
|
||||
const persistableItems = selectedItems.filter(isPersistableKeywordMapItem);
|
||||
|
||||
if (keywordMap.blockers.length > 0 || !keywordMap.semanticRunId || !keywordMap.projectOntologyVersion) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import {
|
|||
type WordstatEvidence,
|
||||
type WordstatSeedCandidate
|
||||
} from "./wordstatRepository.js";
|
||||
import {
|
||||
getAnchorReviewContract,
|
||||
type AnchorReviewContract,
|
||||
type AnchorReviewWordstatQueueItem
|
||||
} from "../keywords/anchorReview.js";
|
||||
import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
||||
import { createWordstatProvider } from "./wordstatProvider.js";
|
||||
|
||||
|
|
@ -56,7 +61,12 @@ export type MarketEnrichmentContract = {
|
|||
generatedAt: string;
|
||||
state: MarketEnrichmentState;
|
||||
normalization: SeoNormalizationContract;
|
||||
anchorReview: AnchorReviewContract;
|
||||
readiness: {
|
||||
anchorApprovedCount: number;
|
||||
anchorPendingCount: number;
|
||||
anchorReviewReady: boolean;
|
||||
anchorWordstatQueueCount: number;
|
||||
canCollectWordstat: boolean;
|
||||
canCollectSerp: boolean;
|
||||
ontologyReadyForMarket: boolean;
|
||||
|
|
@ -277,7 +287,7 @@ function getSeedEvidenceStatus(
|
|||
}
|
||||
|
||||
function buildSeedQueue(
|
||||
normalization: SeoNormalizationContract,
|
||||
anchorReview: AnchorReviewContract,
|
||||
providers: MarketProviderDescriptor[],
|
||||
wordstat: WordstatEvidence,
|
||||
projectOntologyVersion: MarketProjectOntologyVersion | null
|
||||
|
|
@ -285,7 +295,7 @@ function buildSeedQueue(
|
|||
const wordstatResults = buildWordstatSeedMap(wordstat);
|
||||
const ontologyReady = isOntologyReadyForMarket(projectOntologyVersion);
|
||||
|
||||
return normalization.seedStrategy.filter((seed) => seed.review.sendToWordstat).slice(0, 120).map((seed) => {
|
||||
return anchorReview.wordstatQueue.slice(0, 120).map((seed) => {
|
||||
const targetProviders: MarketProviderId[] = ["wordstat"];
|
||||
const result = wordstatResults.get(normalizePhrase(seed.phrase));
|
||||
const evidenceStatus = ontologyReady ? getSeedEvidenceStatus(providers, wordstat, result) : "not_collected";
|
||||
|
|
@ -301,11 +311,11 @@ function buildSeedQueue(
|
|||
intentGroupId: seed.intentGroupId,
|
||||
intentType: seed.intentType,
|
||||
marketRole: seed.marketRole,
|
||||
needsHumanReview: seed.needsHumanReview,
|
||||
needsHumanReview: false,
|
||||
normalizedFrom: seed.normalizedFrom,
|
||||
reviewReason: seed.review.reason,
|
||||
reviewStatus: seed.review.status,
|
||||
sendToSerp: seed.review.sendToSerp
|
||||
reviewReason: seed.reason,
|
||||
reviewStatus: "auto_approved" as const,
|
||||
sendToSerp: seed.sendToSerp
|
||||
},
|
||||
evidenceStatus,
|
||||
targetProviders,
|
||||
|
|
@ -356,6 +366,7 @@ function getEnrichmentState(
|
|||
providers: MarketProviderDescriptor[],
|
||||
seedCount: number,
|
||||
wordstat: WordstatEvidence,
|
||||
anchorReview: AnchorReviewContract,
|
||||
projectOntologyVersion: MarketProjectOntologyVersion | null
|
||||
): MarketEnrichmentState {
|
||||
if (!analysis) {
|
||||
|
|
@ -366,6 +377,10 @@ function getEnrichmentState(
|
|||
return "not_ready";
|
||||
}
|
||||
|
||||
if (anchorReview.state !== "approved") {
|
||||
return "not_ready";
|
||||
}
|
||||
|
||||
if (seedCount === 0) {
|
||||
return "not_ready";
|
||||
}
|
||||
|
|
@ -407,6 +422,13 @@ function buildNextActions(contract: Omit<MarketEnrichmentContract, "nextActions"
|
|||
return actions;
|
||||
}
|
||||
|
||||
if (!contract.readiness.anchorReviewReady) {
|
||||
actions.push(
|
||||
`Подтвердить anchor review перед Wordstat: предложено ${contract.anchorReview.readiness.proposedWordstatCount}, approved queue ${contract.readiness.anchorWordstatQueueCount}.`
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
const wordstat = contract.providers.find((provider) => provider.id === "wordstat");
|
||||
const serp = contract.providers.find((provider) => provider.id === "serp");
|
||||
|
||||
|
|
@ -450,11 +472,10 @@ export async function getMarketEnrichmentContract(projectId: string): Promise<Ma
|
|||
const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis));
|
||||
const providers = await buildProviderRegistry();
|
||||
const normalization = await getSeoNormalizationContract(projectId);
|
||||
const approvedWordstatPhrases = normalization.seedStrategy
|
||||
.filter((seed) => seed.review.sendToWordstat)
|
||||
.map((seed) => seed.phrase);
|
||||
const anchorReview = await getAnchorReviewContract(projectId);
|
||||
const approvedWordstatPhrases = anchorReview.wordstatQueue.map((seed) => seed.phrase);
|
||||
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null, approvedWordstatPhrases);
|
||||
const seedQueue = analysis ? buildSeedQueue(normalization, providers, wordstat, projectOntologyVersion) : [];
|
||||
const seedQueue = analysis ? buildSeedQueue(anchorReview, providers, wordstat, projectOntologyVersion) : [];
|
||||
const briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : [];
|
||||
const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length;
|
||||
const ontologyReadyForMarket = isOntologyReadyForMarket(projectOntologyVersion);
|
||||
|
|
@ -464,11 +485,17 @@ export async function getMarketEnrichmentContract(projectId: string): Promise<Ma
|
|||
semanticRunId: analysis?.runId ?? null,
|
||||
projectOntologyVersion,
|
||||
generatedAt: new Date().toISOString(),
|
||||
state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat, projectOntologyVersion),
|
||||
state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat, anchorReview, projectOntologyVersion),
|
||||
normalization,
|
||||
anchorReview,
|
||||
readiness: {
|
||||
anchorApprovedCount: anchorReview.readiness.approvedAnchorCount,
|
||||
anchorPendingCount: anchorReview.readiness.pendingAnchorCount,
|
||||
anchorReviewReady: anchorReview.state === "approved",
|
||||
anchorWordstatQueueCount: anchorReview.readiness.wordstatQueueCount,
|
||||
canCollectWordstat:
|
||||
ontologyReadyForMarket &&
|
||||
anchorReview.state === "approved" &&
|
||||
getProviderStatus(providers, "wordstat") === "connected" &&
|
||||
seedQueue.length > 0 &&
|
||||
wordstat.latestJob?.status !== "running",
|
||||
|
|
@ -495,9 +522,8 @@ export async function runMarketEnrichment(projectId: string): Promise<MarketEnri
|
|||
const analysis = await getLatestSemanticAnalysis(projectId);
|
||||
const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis));
|
||||
const providers = await buildProviderRegistry();
|
||||
const normalization = await getSeoNormalizationContract(projectId);
|
||||
const normalizedSeeds: WordstatSeedCandidate[] = normalization.seedStrategy
|
||||
.filter((seed) => seed.review.sendToWordstat)
|
||||
const anchorReview = await getAnchorReviewContract(projectId);
|
||||
const normalizedSeeds: WordstatSeedCandidate[] = anchorReview.wordstatQueue
|
||||
.map((seed) => ({
|
||||
clusterId: seed.clusterId,
|
||||
clusterTitle: seed.clusterTitle,
|
||||
|
|
@ -511,6 +537,7 @@ export async function runMarketEnrichment(projectId: string): Promise<MarketEnri
|
|||
if (
|
||||
!analysis ||
|
||||
!isOntologyReadyForMarket(projectOntologyVersion) ||
|
||||
anchorReview.state !== "approved" ||
|
||||
getProviderStatus(providers, "wordstat") !== "connected" ||
|
||||
normalizedSeeds.length === 0
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js";
|
|||
import { getLatestAlignedSeoContextReview } from "../contextReview/seoContextReview.js";
|
||||
import { buildSerpInterpretationModelTask, type SerpInterpretationModelTaskContract } from "../evidence/serpInterpretationTask.js";
|
||||
import { getLatestYandexEvidenceContract } from "../evidence/yandexEvidence.js";
|
||||
import { getAnchorReviewContract } from "../keywords/anchorReview.js";
|
||||
import {
|
||||
getSeoModelProviderCatalog,
|
||||
type SeoModelProviderCatalog
|
||||
|
|
@ -153,9 +154,10 @@ function uniqueCapabilities(capabilities: SeoModelCapability[]) {
|
|||
}
|
||||
|
||||
export async function getSeoModelContextContract(projectId: string): Promise<SeoModelContextContract> {
|
||||
const [analysis, normalization, keywordCleaning, yandexEvidence, strategy, serpInterpretation, strategySynthesis] = await Promise.all([
|
||||
const [analysis, normalization, anchorReview, keywordCleaning, yandexEvidence, strategy, serpInterpretation, strategySynthesis] = await Promise.all([
|
||||
getLatestSemanticAnalysis(projectId),
|
||||
getSeoNormalizationContract(projectId),
|
||||
getAnchorReviewContract(projectId),
|
||||
getKeywordCleaningContract(projectId),
|
||||
getLatestYandexEvidenceContract(projectId),
|
||||
getSeoStrategyContract(projectId),
|
||||
|
|
@ -270,7 +272,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
strategySynthesisTaskReady: Boolean(strategySynthesisTask),
|
||||
strategyQualityReviewTaskReady: Boolean(strategyQualityReviewTask),
|
||||
modelTaskReady: modelTasks.length > 0,
|
||||
canRunMarketCollection: normalization.seedStrategy.some((seed) => seed.review.sendToWordstat)
|
||||
canRunMarketCollection: anchorReview.state === "approved" && anchorReview.wordstatQueue.length > 0
|
||||
},
|
||||
skills,
|
||||
modelProvider,
|
||||
|
|
@ -341,7 +343,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
"Активный SEO Skill Pack определяет allowed actions, output schema, blockers и forbidden actions для каждого AI task.",
|
||||
"Context Review запускается до рыночного спроса и нужен для forbidden claims, ambiguity и normalization hints.",
|
||||
"Любой соседний рынок или business expansion должен возвращаться как proposal, а не как auto-applied rewrite.",
|
||||
"Wordstat/SERP маршрутизация допускается только для фраз с source evidence, approved ontology или human review.",
|
||||
"Wordstat/SERP маршрутизация допускается только для фраз из human-approved anchor_review.wordstatQueue.",
|
||||
"SERP interpretation работает только по сохранённому yandex-evidence.v1 и не придумывает конкурентов или спрос.",
|
||||
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
|
||||
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
|
||||
|
|
@ -358,8 +360,8 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
||||
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
|
||||
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
|
||||
"После Wordstat прогонять seo.keyword_cleaning и только use/support передавать в keyword map.",
|
||||
"Не запускать external market collection без review/approval для needsHumanReview фраз."
|
||||
"После Wordstat прогонять seo.keyword_cleaning и только use/support передавать в keyword map.",
|
||||
"Не запускать external market collection без anchor review approval."
|
||||
]
|
||||
: [
|
||||
"Сохранить seo.context_review draft evidence через POST /projects/:projectId/context-review.",
|
||||
|
|
|
|||
|
|
@ -90,6 +90,10 @@ import {
|
|||
saveKeywordCleaningManual,
|
||||
type KeywordCleaningContract
|
||||
} from "../keywords/keywordCleaning.js";
|
||||
import {
|
||||
getAnchorReviewContract,
|
||||
saveAnchorReviewDecisions
|
||||
} from "../keywords/anchorReview.js";
|
||||
import {
|
||||
getLatestProjectOntologyVersion,
|
||||
ProjectOntologyApprovalError,
|
||||
|
|
@ -222,7 +226,15 @@ const ontologyConfirmationsSchema = z
|
|||
message: "Нужно передать brandConfirmed или aliasesConfirmed."
|
||||
});
|
||||
const keywordMapApproveSchema = z.object({
|
||||
itemIds: z.array(z.string().min(1)).optional()
|
||||
itemIds: z.array(z.string().min(1)).optional(),
|
||||
roleOverrides: z
|
||||
.array(
|
||||
z.object({
|
||||
itemId: z.string().min(1),
|
||||
role: z.enum(["differentiator", "primary", "secondary", "secondary_candidate", "support", "validate"])
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
const contextReviewManualSchema = z.object({
|
||||
output: z.record(z.string(), z.unknown())
|
||||
|
|
@ -233,6 +245,16 @@ const seoNormalizationManualSchema = z.object({
|
|||
const keywordCleaningManualSchema = z.object({
|
||||
output: z.record(z.string(), z.unknown())
|
||||
});
|
||||
const anchorReviewDecisionSchema = z.object({
|
||||
anchorId: z.string().trim().min(1).max(260),
|
||||
reason: z.string().trim().max(1000).optional(),
|
||||
sendToSerp: z.boolean().optional(),
|
||||
sendToWordstat: z.boolean().optional(),
|
||||
status: z.enum(["approved", "disabled", "pending"])
|
||||
});
|
||||
const anchorReviewDecisionsSchema = z.object({
|
||||
decisions: z.array(anchorReviewDecisionSchema).max(200).optional()
|
||||
});
|
||||
const serpInterpretationManualSchema = z.object({
|
||||
output: z.record(z.string(), z.unknown())
|
||||
});
|
||||
|
|
@ -776,6 +798,43 @@ projectsRouter.post("/:projectId/seo-normalization/manual", async (request, resp
|
|||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/anchor-review/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
anchorReview: await getAnchorReviewContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить anchor review проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/anchor-review/decisions", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = anchorReviewDecisionsSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
anchorReview: await saveAnchorReviewDecisions(projectId, input.decisions ?? [])
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь anchor review decisions.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить anchor review.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/keyword-cleaning/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ type MaterializationStatus =
|
|||
| "awaiting_source"
|
||||
| "can_link_existing"
|
||||
| "deferred"
|
||||
| "future_landing_gap"
|
||||
| "linked_existing"
|
||||
| "needs_materialization"
|
||||
| "needs_user_mapping"
|
||||
| "planned_placeholder"
|
||||
| "rejected";
|
||||
type MaterializationState = "blocked" | "complete" | "partial" | "pending";
|
||||
|
|
@ -50,6 +52,7 @@ export type MaterializationContract = {
|
|||
readiness: {
|
||||
awaitingSourceCount: number;
|
||||
blockerCount: number;
|
||||
futureLandingGapCount: number;
|
||||
linkedTargetCount: number;
|
||||
pendingTargetCount: number;
|
||||
placeholderTargetCount: number;
|
||||
|
|
@ -423,12 +426,23 @@ function getDecisionSourcePlan(decision: MaterializationDecisionRow | null) {
|
|||
function getTargetStatus(input: {
|
||||
decision: MaterializationDecisionRow | null;
|
||||
linkCandidates: ReturnType<typeof buildLinkCandidates>;
|
||||
target: RewritePlanContract["materializationQueue"][number] | RewritePlanContract["existingPageTargets"][number];
|
||||
target:
|
||||
| RewritePlanContract["existingPageTargets"][number]
|
||||
| RewritePlanContract["futureLandingTargets"][number]
|
||||
| RewritePlanContract["materializationQueue"][number];
|
||||
}) {
|
||||
if (input.target.status === "ready_for_diff") {
|
||||
return "linked_existing" satisfies MaterializationStatus;
|
||||
}
|
||||
|
||||
if (input.target.status === "future_landing_gap") {
|
||||
return "future_landing_gap" satisfies MaterializationStatus;
|
||||
}
|
||||
|
||||
if (input.target.status === "needs_user_mapping") {
|
||||
return "needs_user_mapping" satisfies MaterializationStatus;
|
||||
}
|
||||
|
||||
if (input.decision?.status === "awaiting_source") {
|
||||
return "awaiting_source" satisfies MaterializationStatus;
|
||||
}
|
||||
|
|
@ -456,7 +470,10 @@ function buildTarget(input: {
|
|||
candidates: LatestPageCandidateRow[];
|
||||
decision: MaterializationDecisionRow | null;
|
||||
sourceContext: ProjectSourceContextRow | null;
|
||||
target: RewritePlanContract["materializationQueue"][number] | RewritePlanContract["existingPageTargets"][number];
|
||||
target:
|
||||
| RewritePlanContract["existingPageTargets"][number]
|
||||
| RewritePlanContract["futureLandingTargets"][number]
|
||||
| RewritePlanContract["materializationQueue"][number];
|
||||
}): MaterializationTarget {
|
||||
const normalizedTargetPath = normalizeTargetPath(input.target.targetPath);
|
||||
const linkCandidates = buildLinkCandidates(input.candidates, input.target.targetPath);
|
||||
|
|
@ -514,6 +531,7 @@ function buildContractState(targets: MaterializationTarget[], blockers: string[]
|
|||
target.status === "awaiting_source" ||
|
||||
target.status === "needs_materialization" ||
|
||||
target.status === "can_link_existing" ||
|
||||
target.status === "needs_user_mapping" ||
|
||||
target.status === "planned_placeholder"
|
||||
);
|
||||
|
||||
|
|
@ -543,7 +561,13 @@ function buildNextActions(contract: Omit<MaterializationContract, "nextActions">
|
|||
|
||||
if (contract.readiness.placeholderTargetCount > 0) {
|
||||
actions.push(
|
||||
`Для ${contract.readiness.placeholderTargetCount} targets нет реальной страницы в scan; это блокирует apply, но не блокирует стратегическую аналитику.`
|
||||
`Для ${contract.readiness.placeholderTargetCount} targets нет реальной страницы в scan; это блокирует apply только после явного promotion.`
|
||||
);
|
||||
}
|
||||
|
||||
if (contract.readiness.futureLandingGapCount > 0) {
|
||||
actions.push(
|
||||
`${contract.readiness.futureLandingGapCount} future landing targets остаются strategy gap и не блокируют rewrite существующих секций.`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -572,7 +596,11 @@ export async function getMaterializationContract(projectId: string): Promise<Mat
|
|||
]);
|
||||
const decisions = await getMaterializationDecisions(projectId, rewritePlan.projectOntologyVersionId);
|
||||
const decisionByTargetKey = new Map(decisions.map((decision) => [decision.target_key, decision]));
|
||||
const targets = [...rewritePlan.materializationQueue, ...rewritePlan.existingPageTargets].map((target) => {
|
||||
const targets = [
|
||||
...rewritePlan.materializationQueue,
|
||||
...rewritePlan.futureLandingTargets,
|
||||
...rewritePlan.existingPageTargets
|
||||
].map((target) => {
|
||||
const targetKey = getTargetKey(target.targetPath);
|
||||
|
||||
return buildTarget({
|
||||
|
|
@ -594,6 +622,9 @@ export async function getMaterializationContract(projectId: string): Promise<Mat
|
|||
readiness: {
|
||||
awaitingSourceCount: targets.filter((target) => target.status === "awaiting_source").length,
|
||||
blockerCount: blockers.length,
|
||||
futureLandingGapCount: targets.filter(
|
||||
(target) => target.status === "future_landing_gap" || target.status === "needs_user_mapping"
|
||||
).length,
|
||||
linkedTargetCount: targets.filter((target) => target.status === "linked_existing").length,
|
||||
pendingTargetCount: targets.filter(
|
||||
(target) => target.status === "needs_materialization" || target.status === "can_link_existing"
|
||||
|
|
@ -833,7 +864,11 @@ export async function applyMaterializationAction(projectId: string, input: Mater
|
|||
throw new MaterializationError("Materialization requires approved keyword decisions with semantic run and ontology version.");
|
||||
}
|
||||
|
||||
const currentTarget = [...rewritePlan.materializationQueue, ...rewritePlan.existingPageTargets].find(
|
||||
const currentTarget = [
|
||||
...rewritePlan.materializationQueue,
|
||||
...rewritePlan.futureLandingTargets,
|
||||
...rewritePlan.existingPageTargets
|
||||
].find(
|
||||
(target) => getTargetKey(target.targetPath) === getTargetKey(input.targetPath)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export type RewriteDiffContract = {
|
|||
state: RewriteDiffState;
|
||||
readiness: {
|
||||
blockerCount: number;
|
||||
futureLandingGapCount: number;
|
||||
materializationTargetCount: number;
|
||||
modelReadySlotCount: number;
|
||||
semanticBlockCount: number;
|
||||
|
|
@ -496,6 +497,12 @@ function buildNextActions(contract: Omit<RewriteDiffContract, "nextActions">) {
|
|||
);
|
||||
}
|
||||
|
||||
if (contract.readiness.futureLandingGapCount > 0 && contract.readiness.targetCount === 0) {
|
||||
actions.push(
|
||||
`${contract.readiness.futureLandingGapCount} targets сейчас являются future landing gaps; они не блокируют rewrite существующего сайта, но требуют human landing/section mapping перед model review.`
|
||||
);
|
||||
}
|
||||
|
||||
if (contract.readiness.sourcePendingSlotCount > 0) {
|
||||
actions.push(`Открыть page workspace / source snapshot для ${contract.readiness.sourcePendingSlotCount} diff slots.`);
|
||||
}
|
||||
|
|
@ -519,6 +526,7 @@ function buildNextActions(contract: Omit<RewriteDiffContract, "nextActions">) {
|
|||
|
||||
function getState(input: {
|
||||
blockerCount: number;
|
||||
futureLandingGapCount: number;
|
||||
materializationTargetCount: number;
|
||||
modelReadySlotCount: number;
|
||||
sourcePendingSlotCount: number;
|
||||
|
|
@ -764,6 +772,7 @@ export async function getRewriteDiffContract(projectId: string): Promise<Rewrite
|
|||
generatedAt: new Date().toISOString(),
|
||||
state: getState({
|
||||
blockerCount: blockers.length,
|
||||
futureLandingGapCount: rewritePlan.futureLandingTargets.length,
|
||||
materializationTargetCount: rewritePlan.materializationQueue.length,
|
||||
modelReadySlotCount,
|
||||
sourcePendingSlotCount,
|
||||
|
|
@ -771,6 +780,7 @@ export async function getRewriteDiffContract(projectId: string): Promise<Rewrite
|
|||
}),
|
||||
readiness: {
|
||||
blockerCount: blockers.length,
|
||||
futureLandingGapCount: rewritePlan.futureLandingTargets.length,
|
||||
materializationTargetCount: rewritePlan.materializationQueue.length,
|
||||
modelReadySlotCount,
|
||||
semanticBlockCount,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
import { pool } from "../db/client.js";
|
||||
|
||||
type RewritePlanState = "blocked" | "materialization_required" | "partial_ready" | "ready_for_diff";
|
||||
type RewritePlanState = "blocked" | "materialization_required" | "partial_ready" | "ready_for_diff" | "strategy_gap_only";
|
||||
type RewriteField = "body_section" | "h1" | "meta_description" | "title";
|
||||
type RewriteTargetStatus =
|
||||
| "future_landing_gap"
|
||||
| "needs_materialization"
|
||||
| "needs_user_mapping"
|
||||
| "planned_placeholder"
|
||||
| "ready_for_diff";
|
||||
|
||||
type ApprovedKeywordBindingRow = {
|
||||
decision_id: string;
|
||||
|
|
@ -37,12 +43,14 @@ export type RewritePlanContract = {
|
|||
readiness: {
|
||||
approvedDecisionCount: number;
|
||||
existingPageTargetCount: number;
|
||||
futureLandingGapCount: number;
|
||||
plannedLandingTargetCount: number;
|
||||
rewriteCandidateCount: number;
|
||||
blockerCount: number;
|
||||
};
|
||||
blockers: string[];
|
||||
existingPageTargets: RewriteTarget[];
|
||||
futureLandingTargets: RewriteTarget[];
|
||||
materializationQueue: RewriteTarget[];
|
||||
nextActions: string[];
|
||||
};
|
||||
|
|
@ -71,7 +79,7 @@ type RewriteTarget = {
|
|||
pageTitle: string | null;
|
||||
sourcePath: string | null;
|
||||
urlPath: string | null;
|
||||
status: "needs_materialization" | "planned_placeholder" | "ready_for_diff";
|
||||
status: RewriteTargetStatus;
|
||||
blocker: string | null;
|
||||
keywordBindings: RewriteKeywordBinding[];
|
||||
};
|
||||
|
|
@ -250,18 +258,25 @@ function getRewriteTargetStatus(row: ApprovedKeywordBindingRow): RewriteTarget["
|
|||
|
||||
const materializationStatus = getMapMaterializationStatus(row.map_payload);
|
||||
|
||||
if (materializationStatus === "planned_placeholder" || (row.page_id && !row.page_source_path)) {
|
||||
return "planned_placeholder";
|
||||
if (
|
||||
materializationStatus === "awaiting_source" ||
|
||||
materializationStatus === "planned_placeholder" ||
|
||||
row.page_id ||
|
||||
row.target_path
|
||||
) {
|
||||
return "future_landing_gap";
|
||||
}
|
||||
|
||||
return "needs_materialization";
|
||||
return "needs_user_mapping";
|
||||
}
|
||||
|
||||
function getMergedRewriteTargetStatus(left: RewriteTarget["status"], right: RewriteTarget["status"]) {
|
||||
const priority = {
|
||||
needs_materialization: 0,
|
||||
planned_placeholder: 1,
|
||||
ready_for_diff: 2
|
||||
needs_user_mapping: 0,
|
||||
future_landing_gap: 1,
|
||||
needs_materialization: 2,
|
||||
planned_placeholder: 3,
|
||||
ready_for_diff: 4
|
||||
} satisfies Record<RewriteTarget["status"], number>;
|
||||
|
||||
return priority[right] > priority[left] ? right : left;
|
||||
|
|
@ -272,6 +287,14 @@ function getRewriteTargetBlocker(status: RewriteTarget["status"]) {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (status === "future_landing_gap") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === "needs_user_mapping") {
|
||||
return "Keyword group needs explicit human page/section mapping before rewrite.";
|
||||
}
|
||||
|
||||
if (status === "planned_placeholder") {
|
||||
return "Planned page placeholder exists, but there is no page version/source snapshot yet; create source or link an existing scanned page before diff/apply.";
|
||||
}
|
||||
|
|
@ -323,16 +346,24 @@ function buildRewriteTargets(rows: ApprovedKeywordBindingRow[]) {
|
|||
return Array.from(targets.values());
|
||||
}
|
||||
|
||||
function getRewriteState(existingPageTargets: RewriteTarget[], materializationQueue: RewriteTarget[]): RewritePlanState {
|
||||
if (existingPageTargets.length === 0 && materializationQueue.length === 0) {
|
||||
function getRewriteState(input: {
|
||||
existingPageTargets: RewriteTarget[];
|
||||
futureLandingTargets: RewriteTarget[];
|
||||
materializationQueue: RewriteTarget[];
|
||||
}): RewritePlanState {
|
||||
if (input.existingPageTargets.length === 0 && input.materializationQueue.length === 0 && input.futureLandingTargets.length === 0) {
|
||||
return "blocked";
|
||||
}
|
||||
|
||||
if (existingPageTargets.length === 0 && materializationQueue.length > 0) {
|
||||
if (input.existingPageTargets.length === 0 && input.materializationQueue.length === 0 && input.futureLandingTargets.length > 0) {
|
||||
return "strategy_gap_only";
|
||||
}
|
||||
|
||||
if (input.existingPageTargets.length === 0 && input.materializationQueue.length > 0) {
|
||||
return "materialization_required";
|
||||
}
|
||||
|
||||
if (existingPageTargets.length > 0 && materializationQueue.length > 0) {
|
||||
if (input.existingPageTargets.length > 0 && input.materializationQueue.length > 0) {
|
||||
return "partial_ready";
|
||||
}
|
||||
|
||||
|
|
@ -352,6 +383,12 @@ function buildNextActions(contract: Omit<RewritePlanContract, "nextActions">) {
|
|||
);
|
||||
}
|
||||
|
||||
if (contract.futureLandingTargets.length > 0) {
|
||||
actions.push(
|
||||
`${contract.futureLandingTargets.length} future landing targets зафиксированы как strategy gap; они не блокируют rewrite существующего сайта без явного promotion.`
|
||||
);
|
||||
}
|
||||
|
||||
if (contract.existingPageTargets.length > 0) {
|
||||
actions.push(
|
||||
`Собрать rewrite diff для ${contract.existingPageTargets.length} существующих страниц с approved keyword bindings и human review.`
|
||||
|
|
@ -377,23 +414,36 @@ export async function getRewritePlanContract(projectId: string): Promise<Rewrite
|
|||
: [];
|
||||
const targets = buildRewriteTargets(rows);
|
||||
const existingPageTargets = targets.filter((target) => target.status === "ready_for_diff");
|
||||
const materializationQueue = targets.filter((target) => target.status !== "ready_for_diff");
|
||||
const futureLandingTargets = targets.filter(
|
||||
(target) => target.status === "future_landing_gap" || target.status === "needs_user_mapping"
|
||||
);
|
||||
const materializationQueue = targets.filter(
|
||||
(target) => target.status === "needs_materialization" || target.status === "planned_placeholder"
|
||||
);
|
||||
const contractWithoutActions: Omit<RewritePlanContract, "nextActions"> = {
|
||||
schemaVersion: "rewrite-plan.v1",
|
||||
projectId,
|
||||
semanticRunId: context?.semantic_run_id ?? null,
|
||||
projectOntologyVersionId: context?.project_ontology_version_id ?? null,
|
||||
generatedAt: new Date().toISOString(),
|
||||
state: blockers.length > 0 ? "blocked" : getRewriteState(existingPageTargets, materializationQueue),
|
||||
state: blockers.length > 0
|
||||
? "blocked"
|
||||
: getRewriteState({
|
||||
existingPageTargets,
|
||||
futureLandingTargets,
|
||||
materializationQueue
|
||||
}),
|
||||
readiness: {
|
||||
approvedDecisionCount: rows.length,
|
||||
blockerCount: blockers.length + materializationQueue.length,
|
||||
existingPageTargetCount: existingPageTargets.length,
|
||||
futureLandingGapCount: futureLandingTargets.length,
|
||||
plannedLandingTargetCount: materializationQueue.length,
|
||||
rewriteCandidateCount: existingPageTargets.reduce((sum, target) => sum + target.keywordBindings.length, 0)
|
||||
},
|
||||
blockers,
|
||||
existingPageTargets,
|
||||
futureLandingTargets,
|
||||
materializationQueue
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,12 @@ function getNumericConstant(source: string, name: string) {
|
|||
}
|
||||
|
||||
const wordstatRepositorySource = await readFile(new URL("../market/wordstatRepository.ts", import.meta.url), "utf8");
|
||||
const marketEnrichmentSource = await readFile(new URL("../market/marketEnrichment.ts", import.meta.url), "utf8");
|
||||
const anchorReviewSource = await readFile(new URL("../keywords/anchorReview.ts", import.meta.url), "utf8");
|
||||
const yandexEvidenceSource = await readFile(new URL("../evidence/yandexEvidence.ts", import.meta.url), "utf8");
|
||||
const projectRoutesSource = await readFile(new URL("../projects/routes.ts", import.meta.url), "utf8");
|
||||
const materializationSource = await readFile(new URL("../rewrite/materialization.ts", import.meta.url), "utf8");
|
||||
const rewritePlanSource = await readFile(new URL("../rewrite/rewritePlan.ts", import.meta.url), "utf8");
|
||||
const appApiSource = await readFile(new URL("../../../app/src/api.ts", import.meta.url), "utf8");
|
||||
const appSource = await readFile(new URL("../../../app/src/App.tsx", import.meta.url), "utf8");
|
||||
|
||||
|
|
@ -41,5 +45,37 @@ assert.ok(
|
|||
yandexEvidenceSource.includes("bucketKey") && yandexEvidenceSource.includes("candidatesByBucket"),
|
||||
"Yandex Evidence phrase selection must preserve semantic/landing coverage buckets."
|
||||
);
|
||||
assert.ok(
|
||||
rewritePlanSource.includes("futureLandingTargets") &&
|
||||
rewritePlanSource.includes("future_landing_gap") &&
|
||||
rewritePlanSource.includes("strategy_gap_only"),
|
||||
"Future landing candidates must stay visible as strategy gaps instead of forcing materialization."
|
||||
);
|
||||
assert.ok(
|
||||
rewritePlanSource.includes("materializationQueue = targets.filter") &&
|
||||
rewritePlanSource.includes('target.status === "needs_materialization"') &&
|
||||
rewritePlanSource.includes('target.status === "planned_placeholder"'),
|
||||
"Rewrite plan materialization queue must include only explicit blocking materialization targets."
|
||||
);
|
||||
assert.ok(
|
||||
materializationSource.includes("futureLandingGapCount") && appApiSource.includes("futureLandingTargets"),
|
||||
"Materialization/API contracts must expose future landing gaps as non-blocking strategy context."
|
||||
);
|
||||
assert.ok(
|
||||
anchorReviewSource.includes('schemaVersion: "anchor-review.v1"') &&
|
||||
anchorReviewSource.includes("wordstatQueue") &&
|
||||
anchorReviewSource.includes("auto proposal не является разрешением"),
|
||||
"Anchor review must preserve a human approval gate before Wordstat collection."
|
||||
);
|
||||
assert.ok(
|
||||
marketEnrichmentSource.includes("getAnchorReviewContract") &&
|
||||
marketEnrichmentSource.includes("anchorReview.wordstatQueue") &&
|
||||
!/normalization\.seedStrategy\s*[\s\S]{0,80}\.filter\(\(seed\)\s*=>\s*seed\.review\.sendToWordstat\)/.test(marketEnrichmentSource),
|
||||
"Market enrichment must collect Wordstat only from anchorReview.wordstatQueue, not normalization auto proposals."
|
||||
);
|
||||
assert.ok(
|
||||
appApiSource.includes("AnchorReviewContract") && appSource.includes("handleAnchorReviewApproval"),
|
||||
"Frontend/API must expose anchor review approval before market demand collection."
|
||||
);
|
||||
|
||||
console.info("Demand coverage check passed.");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
import { pool } from "../db/client.js";
|
||||
|
||||
type SemanticBlockSummaryRow = {
|
||||
active_blocks: number;
|
||||
project_id: string;
|
||||
scope_count: number;
|
||||
source_binding_count: number;
|
||||
target_ref_count: number;
|
||||
workspace_count: number;
|
||||
};
|
||||
|
||||
type ZeroBindingRow = {
|
||||
block_key: string;
|
||||
captured_entities: number;
|
||||
page_id: string;
|
||||
project_id: string;
|
||||
scope_id: string;
|
||||
source_bindings: number;
|
||||
target_ids: number;
|
||||
title: string;
|
||||
workspace_key: string;
|
||||
};
|
||||
|
||||
const projectId = process.argv[2] || process.env.SEMANTIC_BLOCK_RUNTIME_PROJECT_ID || null;
|
||||
const projectFilterSql = projectId ? "and project_id = $1" : "";
|
||||
const params = projectId ? [projectId] : [];
|
||||
|
||||
try {
|
||||
const [summaryResult, zeroBindingResult] = await Promise.all([
|
||||
pool.query<SemanticBlockSummaryRow>(
|
||||
`
|
||||
select
|
||||
project_id,
|
||||
count(*)::int as active_blocks,
|
||||
count(distinct workspace_key)::int as workspace_count,
|
||||
count(distinct scope_id)::int as scope_count,
|
||||
coalesce(sum(cardinality(target_ids)), 0)::int as target_ref_count,
|
||||
coalesce(sum(jsonb_array_length(source_bindings)), 0)::int as source_binding_count
|
||||
from semantic_blocks
|
||||
where archived_at is null
|
||||
${projectFilterSql}
|
||||
group by project_id
|
||||
order by project_id;
|
||||
`,
|
||||
params
|
||||
),
|
||||
pool.query<ZeroBindingRow>(
|
||||
`
|
||||
select
|
||||
project_id,
|
||||
page_id,
|
||||
workspace_key,
|
||||
scope_id,
|
||||
block_key,
|
||||
title,
|
||||
coalesce(cardinality(target_ids), 0)::int as target_ids,
|
||||
coalesce(jsonb_array_length(source_bindings), 0)::int as source_bindings,
|
||||
coalesce(jsonb_array_length(captured_entities), 0)::int as captured_entities
|
||||
from semantic_blocks
|
||||
where archived_at is null
|
||||
${projectFilterSql}
|
||||
and coalesce(cardinality(target_ids), 0) = 0
|
||||
and coalesce(jsonb_array_length(source_bindings), 0) = 0
|
||||
order by project_id, workspace_key, block_key;
|
||||
`,
|
||||
params
|
||||
)
|
||||
]);
|
||||
|
||||
if (zeroBindingResult.rows.length > 0) {
|
||||
console.error("Semantic block runtime coverage failed: active blocks without target/source bindings.");
|
||||
console.error(JSON.stringify(zeroBindingResult.rows, null, 2));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.info("Semantic block runtime coverage passed.");
|
||||
console.info(JSON.stringify(summaryResult.rows, null, 2));
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
|
@ -144,10 +144,25 @@ assert.ok(
|
|||
semanticBlockRepositorySource.includes("saveSemanticBlocksForWorkspace") && semanticBlockRepositorySource.includes("client.release()"),
|
||||
"Semantic block saves must go through an atomic backend repository transaction."
|
||||
);
|
||||
assert.ok(
|
||||
semanticBlockRepositorySource.includes("hasSemanticBlockRuntimeBinding") &&
|
||||
semanticBlockRepositorySource.includes("sourceBindings.length > 0"),
|
||||
"Semantic block repository must not persist captured-only visual boxes without text target/source bindings."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("hydratePersistedWorkspaceSemanticBlocks") && appSource.includes("savePageSemanticBlocks"),
|
||||
"Stage 07 semantic block overrides must reload from and save to backend."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("skippedSemanticBlockCount") &&
|
||||
appSource.includes("sourceBindings.length === 0") &&
|
||||
appSource.includes("без текстовой привязки"),
|
||||
"Stage 07 must skip semantic block saves that have no runtime text source binding."
|
||||
);
|
||||
assert.ok(
|
||||
appSource.includes("anchorTargetIds") && /return blockKey && targetIds\.length > 0/.test(appSource),
|
||||
"Persisted semantic block hydration must require target-bound anchors or targetIds."
|
||||
);
|
||||
assert.ok(
|
||||
rewriteDiffSource.includes("listSemanticBlocksForPages") &&
|
||||
rewriteDiffSource.includes("semanticBlock: semanticBlock ? mapRewriteDiffSemanticBlock") &&
|
||||
|
|
|
|||
|
|
@ -98,6 +98,10 @@ function normalizeTargetIds(value: string[] | undefined) {
|
|||
).slice(0, 200);
|
||||
}
|
||||
|
||||
function hasSemanticBlockRuntimeBinding(block: { sourceBindings: JsonArray; targetIds: string[] }) {
|
||||
return block.targetIds.length > 0 || block.sourceBindings.length > 0;
|
||||
}
|
||||
|
||||
function mapSemanticBlockRow(row: SemanticBlockRow): SemanticBlockModel {
|
||||
return {
|
||||
anchors: normalizeArray(row.anchors),
|
||||
|
|
@ -191,20 +195,22 @@ export async function saveSemanticBlocksForWorkspace(projectId: string, pageId:
|
|||
throw new SemanticBlockWorkspaceError("Нужны workspaceKey и scopeId для semantic blocks.");
|
||||
}
|
||||
|
||||
const normalizedBlocks = input.blocks.map((block) => ({
|
||||
anchors: normalizeArray(block.anchors),
|
||||
blockKey: cleanText(block.blockKey, 260),
|
||||
capturedEntities: normalizeArray(block.capturedEntities),
|
||||
geometryCache: normalizeRecord(block.geometryCache),
|
||||
identity: normalizeRecord(block.identity),
|
||||
mediaContext: normalizeRecord(block.mediaContext),
|
||||
rewriteState: normalizeRecord(block.rewriteState),
|
||||
role: cleanText(block.role ?? "content", 80) || "content",
|
||||
source: block.source ?? "manual",
|
||||
sourceBindings: normalizeArray(block.sourceBindings),
|
||||
targetIds: normalizeTargetIds(block.targetIds),
|
||||
title: cleanText(block.title, 260) || "Semantic block"
|
||||
})).filter((block) => block.blockKey);
|
||||
const normalizedBlocks = input.blocks
|
||||
.map((block) => ({
|
||||
anchors: normalizeArray(block.anchors),
|
||||
blockKey: cleanText(block.blockKey, 260),
|
||||
capturedEntities: normalizeArray(block.capturedEntities),
|
||||
geometryCache: normalizeRecord(block.geometryCache),
|
||||
identity: normalizeRecord(block.identity),
|
||||
mediaContext: normalizeRecord(block.mediaContext),
|
||||
rewriteState: normalizeRecord(block.rewriteState),
|
||||
role: cleanText(block.role ?? "content", 80) || "content",
|
||||
source: block.source ?? "manual",
|
||||
sourceBindings: normalizeArray(block.sourceBindings),
|
||||
targetIds: normalizeTargetIds(block.targetIds),
|
||||
title: cleanText(block.title, 260) || "Semantic block"
|
||||
}))
|
||||
.filter((block) => block.blockKey && hasSemanticBlockRuntimeBinding(block));
|
||||
const blockKeys = normalizedBlocks.map((block) => block.blockKey);
|
||||
|
||||
const client = await pool.connect();
|
||||
|
|
|
|||
Loading…
Reference in New Issue