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:
DCCONSTRUCTIONS 2026-07-02 18:06:01 +03:00
parent 214b714873
commit 75fd57b036
18 changed files with 2694 additions and 303 deletions

View File

@ -4,9 +4,9 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "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", "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" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {

File diff suppressed because it is too large Load Diff

View File

@ -730,6 +730,97 @@ export type SeoNormalizationContract = {
nextActions: string[]; 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 = { export type MarketEnrichmentContract = {
schemaVersion: "market-enrichment.v1"; schemaVersion: "market-enrichment.v1";
projectId: string; projectId: string;
@ -746,7 +837,12 @@ export type MarketEnrichmentContract = {
generatedAt: string; generatedAt: string;
state: "not_ready" | "not_collected" | "not_configured" | "partial_ready"; state: "not_ready" | "not_collected" | "not_configured" | "partial_ready";
normalization: SeoNormalizationContract; normalization: SeoNormalizationContract;
anchorReview: AnchorReviewContract;
readiness: { readiness: {
anchorApprovedCount: number;
anchorPendingCount: number;
anchorReviewReady: boolean;
anchorWordstatQueueCount: number;
canCollectWordstat: boolean; canCollectWordstat: boolean;
canCollectSerp: boolean; canCollectSerp: boolean;
ontologyReadyForMarket: boolean; ontologyReadyForMarket: boolean;
@ -1286,6 +1382,13 @@ export type KeywordMapContract = {
nextActions: string[]; nextActions: string[];
}; };
export type KeywordMapRole = KeywordMapContract["items"][number]["role"];
export type KeywordMapRoleOverrideInput = {
itemId: string;
role: KeywordMapRole;
};
export type SeoStrategyContract = { export type SeoStrategyContract = {
schemaVersion: "seo-strategy.v1"; schemaVersion: "seo-strategy.v1";
projectId: string; projectId: string;
@ -1643,16 +1746,18 @@ export type RewritePlanContract = {
semanticRunId: string | null; semanticRunId: string | null;
projectOntologyVersionId: string | null; projectOntologyVersionId: string | null;
generatedAt: string; generatedAt: string;
state: "blocked" | "materialization_required" | "partial_ready" | "ready_for_diff"; state: "blocked" | "materialization_required" | "partial_ready" | "ready_for_diff" | "strategy_gap_only";
readiness: { readiness: {
approvedDecisionCount: number; approvedDecisionCount: number;
existingPageTargetCount: number; existingPageTargetCount: number;
futureLandingGapCount: number;
plannedLandingTargetCount: number; plannedLandingTargetCount: number;
rewriteCandidateCount: number; rewriteCandidateCount: number;
blockerCount: number; blockerCount: number;
}; };
blockers: string[]; blockers: string[];
existingPageTargets: RewritePlanTarget[]; existingPageTargets: RewritePlanTarget[];
futureLandingTargets: RewritePlanTarget[];
materializationQueue: RewritePlanTarget[]; materializationQueue: RewritePlanTarget[];
nextActions: string[]; nextActions: string[];
}; };
@ -1664,7 +1769,7 @@ export type RewritePlanTarget = {
pageTitle: string | null; pageTitle: string | null;
sourcePath: string | null; sourcePath: string | null;
urlPath: 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; blocker: string | null;
keywordBindings: Array<{ keywordBindings: Array<{
decisionId: string; decisionId: string;
@ -1695,6 +1800,7 @@ export type MaterializationContract = {
readiness: { readiness: {
awaitingSourceCount: number; awaitingSourceCount: number;
blockerCount: number; blockerCount: number;
futureLandingGapCount: number;
linkedTargetCount: number; linkedTargetCount: number;
pendingTargetCount: number; pendingTargetCount: number;
placeholderTargetCount: number; placeholderTargetCount: number;
@ -1711,8 +1817,10 @@ export type MaterializationContract = {
| "awaiting_source" | "awaiting_source"
| "can_link_existing" | "can_link_existing"
| "deferred" | "deferred"
| "future_landing_gap"
| "linked_existing" | "linked_existing"
| "needs_materialization" | "needs_materialization"
| "needs_user_mapping"
| "planned_placeholder" | "planned_placeholder"
| "rejected"; | "rejected";
pageId: string | null; pageId: string | null;
@ -1795,6 +1903,7 @@ export type RewriteDiffContract = {
state: "blocked" | "materialization_required" | "partial_ready" | "ready_for_model_review"; state: "blocked" | "materialization_required" | "partial_ready" | "ready_for_model_review";
readiness: { readiness: {
blockerCount: number; blockerCount: number;
futureLandingGapCount: number;
materializationTargetCount: number; materializationTargetCount: number;
modelReadySlotCount: number; modelReadySlotCount: number;
semanticBlockCount: number; semanticBlockCount: number;
@ -2722,6 +2831,32 @@ export async function saveManualSeoNormalization(projectId: string, output: Reco
return response.json() as Promise<{ normalization: SeoNormalizationContract }>; 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) { export async function fetchLatestKeywordCleaning(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-cleaning/latest`); 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 }>; 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`, { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-map/decisions`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
body: JSON.stringify({ itemIds }) body: JSON.stringify(input ?? {})
}); });
if (!response.ok) { if (!response.ok) {

View File

@ -2794,14 +2794,440 @@ input {
gap: 8px; gap: 8px;
} }
.stage-content-body {
display: grid;
gap: 10px;
min-width: 0;
}
.anchor-review-card,
.keyword-cleaning-card { .keyword-cleaning-card {
align-items: center; align-items: center;
} }
.anchor-review-card > button,
.keyword-cleaning-card > button { .keyword-cleaning-card > button {
flex: 0 0 auto; 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 { .keyword-cleaning-review {
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
} }
@ -11723,6 +12149,28 @@ textarea:focus {
grid-template-columns: 1fr; 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-board,
.market-decision-metrics { .market-decision-metrics {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -13840,8 +14288,18 @@ body:has(.seo-launcher-shell) {
.seo-launcher-shell.project-open .seo-work-panel { .seo-launcher-shell.project-open .seo-work-panel {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.66)), linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0.46)),
rgba(255, 255, 255, 0.76) !important; 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 { .seo-launcher-shell .workspace.stage-2 .project-scope-flow-controls {
@ -14288,6 +14746,15 @@ body:has(.seo-launcher-shell) {
border-color: var(--ink); 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 { @keyframes semantic-generation-pulse {
0% { 0% {
box-shadow: 0 0 0 0 rgba(232, 45, 134, 0.36); box-shadow: 0 0 0 0 rgba(232, 45, 134, 0.36);

View File

@ -13,6 +13,7 @@
"dev:server": "npm run dev -w server", "dev:server": "npm run dev -w server",
"build": "npm run build -w server && npm run build -w app", "build": "npm run build -w server && npm run build -w app",
"check:demand-coverage": "npm run check:demand-coverage -w server", "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:semantic-blocks": "npm run check:semantic-blocks -w server",
"check:source-models": "npm run check:source-models -w server", "check:source-models": "npm run check:source-models -w server",
"typecheck": "npm run typecheck -w server && npm run typecheck -w app", "typecheck": "npm run typecheck -w server && npm run typecheck -w app",

View File

@ -9,6 +9,7 @@
"start": "node dist/index.js", "start": "node dist/index.js",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"check:demand-coverage": "tsx src/scripts/checkDemandCoverage.ts", "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:semantic-blocks": "tsx src/scripts/checkSemanticBlockUniversality.ts",
"check:source-models": "tsx src/scripts/checkSourceModelUniversality.ts", "check:source-models": "tsx src/scripts/checkSourceModelUniversality.ts",
"check:ontology-universality": "tsx src/scripts/checkOntologyUniversality.ts", "check:ontology-universality": "tsx src/scripts/checkOntologyUniversality.ts",

View File

@ -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;
}

View File

@ -4,6 +4,7 @@ import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keyw
type KeywordMapState = "blocked" | "draft"; type KeywordMapState = "blocked" | "draft";
type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate"; type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
const keywordMapRoles: KeywordMapRole[] = ["differentiator", "primary", "secondary", "secondary_candidate", "support", "validate"];
export type KeywordMapContract = { export type KeywordMapContract = {
schemaVersion: "keyword-map.v1"; schemaVersion: "keyword-map.v1";
@ -82,6 +83,18 @@ export type KeywordMapPersistResult = {
type KeywordMapItem = KeywordMapContract["items"][number]; type KeywordMapItem = KeywordMapContract["items"][number];
type KeywordCleaningItem = KeywordCleaningContract["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) { function normalizePhrase(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase(); 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) { function getWorkspaceSectionIdForRole(role: KeywordMapRole) {
if (role === "primary") { if (role === "primary") {
return "seo-title"; return "seo-title";
@ -629,13 +660,22 @@ export async function getKeywordMapContract(projectId: string): Promise<KeywordM
export async function approveKeywordMapDecisions( export async function approveKeywordMapDecisions(
projectId: string, projectId: string,
input: { input: KeywordMapApproveInput = {}
itemIds?: string[];
} = {}
): Promise<KeywordMapPersistResult> { ): Promise<KeywordMapPersistResult> {
const keywordMap = await getKeywordMapContract(projectId); const keywordMap = await getKeywordMapContract(projectId);
const itemIdFilter = input.itemIds && input.itemIds.length > 0 ? new Set(input.itemIds) : null; 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); const persistableItems = selectedItems.filter(isPersistableKeywordMapItem);
if (keywordMap.blockers.length > 0 || !keywordMap.semanticRunId || !keywordMap.projectOntologyVersion) { if (keywordMap.blockers.length > 0 || !keywordMap.semanticRunId || !keywordMap.projectOntologyVersion) {

View File

@ -11,6 +11,11 @@ import {
type WordstatEvidence, type WordstatEvidence,
type WordstatSeedCandidate type WordstatSeedCandidate
} from "./wordstatRepository.js"; } from "./wordstatRepository.js";
import {
getAnchorReviewContract,
type AnchorReviewContract,
type AnchorReviewWordstatQueueItem
} from "../keywords/anchorReview.js";
import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js"; import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
import { createWordstatProvider } from "./wordstatProvider.js"; import { createWordstatProvider } from "./wordstatProvider.js";
@ -56,7 +61,12 @@ export type MarketEnrichmentContract = {
generatedAt: string; generatedAt: string;
state: MarketEnrichmentState; state: MarketEnrichmentState;
normalization: SeoNormalizationContract; normalization: SeoNormalizationContract;
anchorReview: AnchorReviewContract;
readiness: { readiness: {
anchorApprovedCount: number;
anchorPendingCount: number;
anchorReviewReady: boolean;
anchorWordstatQueueCount: number;
canCollectWordstat: boolean; canCollectWordstat: boolean;
canCollectSerp: boolean; canCollectSerp: boolean;
ontologyReadyForMarket: boolean; ontologyReadyForMarket: boolean;
@ -277,7 +287,7 @@ function getSeedEvidenceStatus(
} }
function buildSeedQueue( function buildSeedQueue(
normalization: SeoNormalizationContract, anchorReview: AnchorReviewContract,
providers: MarketProviderDescriptor[], providers: MarketProviderDescriptor[],
wordstat: WordstatEvidence, wordstat: WordstatEvidence,
projectOntologyVersion: MarketProjectOntologyVersion | null projectOntologyVersion: MarketProjectOntologyVersion | null
@ -285,7 +295,7 @@ function buildSeedQueue(
const wordstatResults = buildWordstatSeedMap(wordstat); const wordstatResults = buildWordstatSeedMap(wordstat);
const ontologyReady = isOntologyReadyForMarket(projectOntologyVersion); 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 targetProviders: MarketProviderId[] = ["wordstat"];
const result = wordstatResults.get(normalizePhrase(seed.phrase)); const result = wordstatResults.get(normalizePhrase(seed.phrase));
const evidenceStatus = ontologyReady ? getSeedEvidenceStatus(providers, wordstat, result) : "not_collected"; const evidenceStatus = ontologyReady ? getSeedEvidenceStatus(providers, wordstat, result) : "not_collected";
@ -301,11 +311,11 @@ function buildSeedQueue(
intentGroupId: seed.intentGroupId, intentGroupId: seed.intentGroupId,
intentType: seed.intentType, intentType: seed.intentType,
marketRole: seed.marketRole, marketRole: seed.marketRole,
needsHumanReview: seed.needsHumanReview, needsHumanReview: false,
normalizedFrom: seed.normalizedFrom, normalizedFrom: seed.normalizedFrom,
reviewReason: seed.review.reason, reviewReason: seed.reason,
reviewStatus: seed.review.status, reviewStatus: "auto_approved" as const,
sendToSerp: seed.review.sendToSerp sendToSerp: seed.sendToSerp
}, },
evidenceStatus, evidenceStatus,
targetProviders, targetProviders,
@ -356,6 +366,7 @@ function getEnrichmentState(
providers: MarketProviderDescriptor[], providers: MarketProviderDescriptor[],
seedCount: number, seedCount: number,
wordstat: WordstatEvidence, wordstat: WordstatEvidence,
anchorReview: AnchorReviewContract,
projectOntologyVersion: MarketProjectOntologyVersion | null projectOntologyVersion: MarketProjectOntologyVersion | null
): MarketEnrichmentState { ): MarketEnrichmentState {
if (!analysis) { if (!analysis) {
@ -366,6 +377,10 @@ function getEnrichmentState(
return "not_ready"; return "not_ready";
} }
if (anchorReview.state !== "approved") {
return "not_ready";
}
if (seedCount === 0) { if (seedCount === 0) {
return "not_ready"; return "not_ready";
} }
@ -407,6 +422,13 @@ function buildNextActions(contract: Omit<MarketEnrichmentContract, "nextActions"
return actions; 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 wordstat = contract.providers.find((provider) => provider.id === "wordstat");
const serp = contract.providers.find((provider) => provider.id === "serp"); 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 projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis));
const providers = await buildProviderRegistry(); const providers = await buildProviderRegistry();
const normalization = await getSeoNormalizationContract(projectId); const normalization = await getSeoNormalizationContract(projectId);
const approvedWordstatPhrases = normalization.seedStrategy const anchorReview = await getAnchorReviewContract(projectId);
.filter((seed) => seed.review.sendToWordstat) const approvedWordstatPhrases = anchorReview.wordstatQueue.map((seed) => seed.phrase);
.map((seed) => seed.phrase);
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null, approvedWordstatPhrases); 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 briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : [];
const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length; const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length;
const ontologyReadyForMarket = isOntologyReadyForMarket(projectOntologyVersion); const ontologyReadyForMarket = isOntologyReadyForMarket(projectOntologyVersion);
@ -464,11 +485,17 @@ export async function getMarketEnrichmentContract(projectId: string): Promise<Ma
semanticRunId: analysis?.runId ?? null, semanticRunId: analysis?.runId ?? null,
projectOntologyVersion, projectOntologyVersion,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat, projectOntologyVersion), state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat, anchorReview, projectOntologyVersion),
normalization, normalization,
anchorReview,
readiness: { readiness: {
anchorApprovedCount: anchorReview.readiness.approvedAnchorCount,
anchorPendingCount: anchorReview.readiness.pendingAnchorCount,
anchorReviewReady: anchorReview.state === "approved",
anchorWordstatQueueCount: anchorReview.readiness.wordstatQueueCount,
canCollectWordstat: canCollectWordstat:
ontologyReadyForMarket && ontologyReadyForMarket &&
anchorReview.state === "approved" &&
getProviderStatus(providers, "wordstat") === "connected" && getProviderStatus(providers, "wordstat") === "connected" &&
seedQueue.length > 0 && seedQueue.length > 0 &&
wordstat.latestJob?.status !== "running", wordstat.latestJob?.status !== "running",
@ -495,9 +522,8 @@ export async function runMarketEnrichment(projectId: string): Promise<MarketEnri
const analysis = await getLatestSemanticAnalysis(projectId); const analysis = await getLatestSemanticAnalysis(projectId);
const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis)); const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis));
const providers = await buildProviderRegistry(); const providers = await buildProviderRegistry();
const normalization = await getSeoNormalizationContract(projectId); const anchorReview = await getAnchorReviewContract(projectId);
const normalizedSeeds: WordstatSeedCandidate[] = normalization.seedStrategy const normalizedSeeds: WordstatSeedCandidate[] = anchorReview.wordstatQueue
.filter((seed) => seed.review.sendToWordstat)
.map((seed) => ({ .map((seed) => ({
clusterId: seed.clusterId, clusterId: seed.clusterId,
clusterTitle: seed.clusterTitle, clusterTitle: seed.clusterTitle,
@ -511,6 +537,7 @@ export async function runMarketEnrichment(projectId: string): Promise<MarketEnri
if ( if (
!analysis || !analysis ||
!isOntologyReadyForMarket(projectOntologyVersion) || !isOntologyReadyForMarket(projectOntologyVersion) ||
anchorReview.state !== "approved" ||
getProviderStatus(providers, "wordstat") !== "connected" || getProviderStatus(providers, "wordstat") !== "connected" ||
normalizedSeeds.length === 0 normalizedSeeds.length === 0
) { ) {

View File

@ -2,6 +2,7 @@ import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js";
import { getLatestAlignedSeoContextReview } from "../contextReview/seoContextReview.js"; import { getLatestAlignedSeoContextReview } from "../contextReview/seoContextReview.js";
import { buildSerpInterpretationModelTask, type SerpInterpretationModelTaskContract } from "../evidence/serpInterpretationTask.js"; import { buildSerpInterpretationModelTask, type SerpInterpretationModelTaskContract } from "../evidence/serpInterpretationTask.js";
import { getLatestYandexEvidenceContract } from "../evidence/yandexEvidence.js"; import { getLatestYandexEvidenceContract } from "../evidence/yandexEvidence.js";
import { getAnchorReviewContract } from "../keywords/anchorReview.js";
import { import {
getSeoModelProviderCatalog, getSeoModelProviderCatalog,
type SeoModelProviderCatalog type SeoModelProviderCatalog
@ -153,9 +154,10 @@ function uniqueCapabilities(capabilities: SeoModelCapability[]) {
} }
export async function getSeoModelContextContract(projectId: string): Promise<SeoModelContextContract> { 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), getLatestSemanticAnalysis(projectId),
getSeoNormalizationContract(projectId), getSeoNormalizationContract(projectId),
getAnchorReviewContract(projectId),
getKeywordCleaningContract(projectId), getKeywordCleaningContract(projectId),
getLatestYandexEvidenceContract(projectId), getLatestYandexEvidenceContract(projectId),
getSeoStrategyContract(projectId), getSeoStrategyContract(projectId),
@ -270,7 +272,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
strategySynthesisTaskReady: Boolean(strategySynthesisTask), strategySynthesisTaskReady: Boolean(strategySynthesisTask),
strategyQualityReviewTaskReady: Boolean(strategyQualityReviewTask), strategyQualityReviewTaskReady: Boolean(strategyQualityReviewTask),
modelTaskReady: modelTasks.length > 0, modelTaskReady: modelTasks.length > 0,
canRunMarketCollection: normalization.seedStrategy.some((seed) => seed.review.sendToWordstat) canRunMarketCollection: anchorReview.state === "approved" && anchorReview.wordstatQueue.length > 0
}, },
skills, skills,
modelProvider, 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.", "Активный SEO Skill Pack определяет allowed actions, output schema, blockers и forbidden actions для каждого AI task.",
"Context Review запускается до рыночного спроса и нужен для forbidden claims, ambiguity и normalization hints.", "Context Review запускается до рыночного спроса и нужен для forbidden claims, ambiguity и normalization hints.",
"Любой соседний рынок или business expansion должен возвращаться как proposal, а не как auto-applied rewrite.", "Любой соседний рынок или 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 и не придумывает конкурентов или спрос.", "SERP interpretation работает только по сохранённому yandex-evidence.v1 и не придумывает конкурентов или спрос.",
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.", "Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.", "Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
@ -359,7 +361,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.", "После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.", "После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
"После Wordstat прогонять seo.keyword_cleaning и только use/support передавать в keyword map.", "После Wordstat прогонять seo.keyword_cleaning и только use/support передавать в keyword map.",
"Не запускать external market collection без review/approval для needsHumanReview фраз." "Не запускать external market collection без anchor review approval."
] ]
: [ : [
"Сохранить seo.context_review draft evidence через POST /projects/:projectId/context-review.", "Сохранить seo.context_review draft evidence через POST /projects/:projectId/context-review.",

View File

@ -90,6 +90,10 @@ import {
saveKeywordCleaningManual, saveKeywordCleaningManual,
type KeywordCleaningContract type KeywordCleaningContract
} from "../keywords/keywordCleaning.js"; } from "../keywords/keywordCleaning.js";
import {
getAnchorReviewContract,
saveAnchorReviewDecisions
} from "../keywords/anchorReview.js";
import { import {
getLatestProjectOntologyVersion, getLatestProjectOntologyVersion,
ProjectOntologyApprovalError, ProjectOntologyApprovalError,
@ -222,7 +226,15 @@ const ontologyConfirmationsSchema = z
message: "Нужно передать brandConfirmed или aliasesConfirmed." message: "Нужно передать brandConfirmed или aliasesConfirmed."
}); });
const keywordMapApproveSchema = z.object({ 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({ const contextReviewManualSchema = z.object({
output: z.record(z.string(), z.unknown()) output: z.record(z.string(), z.unknown())
@ -233,6 +245,16 @@ const seoNormalizationManualSchema = z.object({
const keywordCleaningManualSchema = z.object({ const keywordCleaningManualSchema = z.object({
output: z.record(z.string(), z.unknown()) 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({ const serpInterpretationManualSchema = z.object({
output: z.record(z.string(), z.unknown()) 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) => { projectsRouter.get("/:projectId/keyword-cleaning/latest", async (request, response) => {
try { try {
const projectId = projectIdSchema.parse(request.params.projectId); const projectId = projectIdSchema.parse(request.params.projectId);

View File

@ -6,8 +6,10 @@ type MaterializationStatus =
| "awaiting_source" | "awaiting_source"
| "can_link_existing" | "can_link_existing"
| "deferred" | "deferred"
| "future_landing_gap"
| "linked_existing" | "linked_existing"
| "needs_materialization" | "needs_materialization"
| "needs_user_mapping"
| "planned_placeholder" | "planned_placeholder"
| "rejected"; | "rejected";
type MaterializationState = "blocked" | "complete" | "partial" | "pending"; type MaterializationState = "blocked" | "complete" | "partial" | "pending";
@ -50,6 +52,7 @@ export type MaterializationContract = {
readiness: { readiness: {
awaitingSourceCount: number; awaitingSourceCount: number;
blockerCount: number; blockerCount: number;
futureLandingGapCount: number;
linkedTargetCount: number; linkedTargetCount: number;
pendingTargetCount: number; pendingTargetCount: number;
placeholderTargetCount: number; placeholderTargetCount: number;
@ -423,12 +426,23 @@ function getDecisionSourcePlan(decision: MaterializationDecisionRow | null) {
function getTargetStatus(input: { function getTargetStatus(input: {
decision: MaterializationDecisionRow | null; decision: MaterializationDecisionRow | null;
linkCandidates: ReturnType<typeof buildLinkCandidates>; 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") { if (input.target.status === "ready_for_diff") {
return "linked_existing" satisfies MaterializationStatus; 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") { if (input.decision?.status === "awaiting_source") {
return "awaiting_source" satisfies MaterializationStatus; return "awaiting_source" satisfies MaterializationStatus;
} }
@ -456,7 +470,10 @@ function buildTarget(input: {
candidates: LatestPageCandidateRow[]; candidates: LatestPageCandidateRow[];
decision: MaterializationDecisionRow | null; decision: MaterializationDecisionRow | null;
sourceContext: ProjectSourceContextRow | null; sourceContext: ProjectSourceContextRow | null;
target: RewritePlanContract["materializationQueue"][number] | RewritePlanContract["existingPageTargets"][number]; target:
| RewritePlanContract["existingPageTargets"][number]
| RewritePlanContract["futureLandingTargets"][number]
| RewritePlanContract["materializationQueue"][number];
}): MaterializationTarget { }): MaterializationTarget {
const normalizedTargetPath = normalizeTargetPath(input.target.targetPath); const normalizedTargetPath = normalizeTargetPath(input.target.targetPath);
const linkCandidates = buildLinkCandidates(input.candidates, 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 === "awaiting_source" ||
target.status === "needs_materialization" || target.status === "needs_materialization" ||
target.status === "can_link_existing" || target.status === "can_link_existing" ||
target.status === "needs_user_mapping" ||
target.status === "planned_placeholder" target.status === "planned_placeholder"
); );
@ -543,7 +561,13 @@ function buildNextActions(contract: Omit<MaterializationContract, "nextActions">
if (contract.readiness.placeholderTargetCount > 0) { if (contract.readiness.placeholderTargetCount > 0) {
actions.push( 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 decisions = await getMaterializationDecisions(projectId, rewritePlan.projectOntologyVersionId);
const decisionByTargetKey = new Map(decisions.map((decision) => [decision.target_key, decision])); 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); const targetKey = getTargetKey(target.targetPath);
return buildTarget({ return buildTarget({
@ -594,6 +622,9 @@ export async function getMaterializationContract(projectId: string): Promise<Mat
readiness: { readiness: {
awaitingSourceCount: targets.filter((target) => target.status === "awaiting_source").length, awaitingSourceCount: targets.filter((target) => target.status === "awaiting_source").length,
blockerCount: blockers.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, linkedTargetCount: targets.filter((target) => target.status === "linked_existing").length,
pendingTargetCount: targets.filter( pendingTargetCount: targets.filter(
(target) => target.status === "needs_materialization" || target.status === "can_link_existing" (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."); 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) (target) => getTargetKey(target.targetPath) === getTargetKey(input.targetPath)
); );

View File

@ -42,6 +42,7 @@ export type RewriteDiffContract = {
state: RewriteDiffState; state: RewriteDiffState;
readiness: { readiness: {
blockerCount: number; blockerCount: number;
futureLandingGapCount: number;
materializationTargetCount: number; materializationTargetCount: number;
modelReadySlotCount: number; modelReadySlotCount: number;
semanticBlockCount: 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) { if (contract.readiness.sourcePendingSlotCount > 0) {
actions.push(`Открыть page workspace / source snapshot для ${contract.readiness.sourcePendingSlotCount} diff slots.`); actions.push(`Открыть page workspace / source snapshot для ${contract.readiness.sourcePendingSlotCount} diff slots.`);
} }
@ -519,6 +526,7 @@ function buildNextActions(contract: Omit<RewriteDiffContract, "nextActions">) {
function getState(input: { function getState(input: {
blockerCount: number; blockerCount: number;
futureLandingGapCount: number;
materializationTargetCount: number; materializationTargetCount: number;
modelReadySlotCount: number; modelReadySlotCount: number;
sourcePendingSlotCount: number; sourcePendingSlotCount: number;
@ -764,6 +772,7 @@ export async function getRewriteDiffContract(projectId: string): Promise<Rewrite
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
state: getState({ state: getState({
blockerCount: blockers.length, blockerCount: blockers.length,
futureLandingGapCount: rewritePlan.futureLandingTargets.length,
materializationTargetCount: rewritePlan.materializationQueue.length, materializationTargetCount: rewritePlan.materializationQueue.length,
modelReadySlotCount, modelReadySlotCount,
sourcePendingSlotCount, sourcePendingSlotCount,
@ -771,6 +780,7 @@ export async function getRewriteDiffContract(projectId: string): Promise<Rewrite
}), }),
readiness: { readiness: {
blockerCount: blockers.length, blockerCount: blockers.length,
futureLandingGapCount: rewritePlan.futureLandingTargets.length,
materializationTargetCount: rewritePlan.materializationQueue.length, materializationTargetCount: rewritePlan.materializationQueue.length,
modelReadySlotCount, modelReadySlotCount,
semanticBlockCount, semanticBlockCount,

View File

@ -1,7 +1,13 @@
import { pool } from "../db/client.js"; 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 RewriteField = "body_section" | "h1" | "meta_description" | "title";
type RewriteTargetStatus =
| "future_landing_gap"
| "needs_materialization"
| "needs_user_mapping"
| "planned_placeholder"
| "ready_for_diff";
type ApprovedKeywordBindingRow = { type ApprovedKeywordBindingRow = {
decision_id: string; decision_id: string;
@ -37,12 +43,14 @@ export type RewritePlanContract = {
readiness: { readiness: {
approvedDecisionCount: number; approvedDecisionCount: number;
existingPageTargetCount: number; existingPageTargetCount: number;
futureLandingGapCount: number;
plannedLandingTargetCount: number; plannedLandingTargetCount: number;
rewriteCandidateCount: number; rewriteCandidateCount: number;
blockerCount: number; blockerCount: number;
}; };
blockers: string[]; blockers: string[];
existingPageTargets: RewriteTarget[]; existingPageTargets: RewriteTarget[];
futureLandingTargets: RewriteTarget[];
materializationQueue: RewriteTarget[]; materializationQueue: RewriteTarget[];
nextActions: string[]; nextActions: string[];
}; };
@ -71,7 +79,7 @@ type RewriteTarget = {
pageTitle: string | null; pageTitle: string | null;
sourcePath: string | null; sourcePath: string | null;
urlPath: string | null; urlPath: string | null;
status: "needs_materialization" | "planned_placeholder" | "ready_for_diff"; status: RewriteTargetStatus;
blocker: string | null; blocker: string | null;
keywordBindings: RewriteKeywordBinding[]; keywordBindings: RewriteKeywordBinding[];
}; };
@ -250,18 +258,25 @@ function getRewriteTargetStatus(row: ApprovedKeywordBindingRow): RewriteTarget["
const materializationStatus = getMapMaterializationStatus(row.map_payload); const materializationStatus = getMapMaterializationStatus(row.map_payload);
if (materializationStatus === "planned_placeholder" || (row.page_id && !row.page_source_path)) { if (
return "planned_placeholder"; 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"]) { function getMergedRewriteTargetStatus(left: RewriteTarget["status"], right: RewriteTarget["status"]) {
const priority = { const priority = {
needs_materialization: 0, needs_user_mapping: 0,
planned_placeholder: 1, future_landing_gap: 1,
ready_for_diff: 2 needs_materialization: 2,
planned_placeholder: 3,
ready_for_diff: 4
} satisfies Record<RewriteTarget["status"], number>; } satisfies Record<RewriteTarget["status"], number>;
return priority[right] > priority[left] ? right : left; return priority[right] > priority[left] ? right : left;
@ -272,6 +287,14 @@ function getRewriteTargetBlocker(status: RewriteTarget["status"]) {
return null; 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") { 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."; 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()); return Array.from(targets.values());
} }
function getRewriteState(existingPageTargets: RewriteTarget[], materializationQueue: RewriteTarget[]): RewritePlanState { function getRewriteState(input: {
if (existingPageTargets.length === 0 && materializationQueue.length === 0) { existingPageTargets: RewriteTarget[];
futureLandingTargets: RewriteTarget[];
materializationQueue: RewriteTarget[];
}): RewritePlanState {
if (input.existingPageTargets.length === 0 && input.materializationQueue.length === 0 && input.futureLandingTargets.length === 0) {
return "blocked"; 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"; return "materialization_required";
} }
if (existingPageTargets.length > 0 && materializationQueue.length > 0) { if (input.existingPageTargets.length > 0 && input.materializationQueue.length > 0) {
return "partial_ready"; 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) { if (contract.existingPageTargets.length > 0) {
actions.push( actions.push(
`Собрать rewrite diff для ${contract.existingPageTargets.length} существующих страниц с approved keyword bindings и human review.` `Собрать 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 targets = buildRewriteTargets(rows);
const existingPageTargets = targets.filter((target) => target.status === "ready_for_diff"); 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"> = { const contractWithoutActions: Omit<RewritePlanContract, "nextActions"> = {
schemaVersion: "rewrite-plan.v1", schemaVersion: "rewrite-plan.v1",
projectId, projectId,
semanticRunId: context?.semantic_run_id ?? null, semanticRunId: context?.semantic_run_id ?? null,
projectOntologyVersionId: context?.project_ontology_version_id ?? null, projectOntologyVersionId: context?.project_ontology_version_id ?? null,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
state: blockers.length > 0 ? "blocked" : getRewriteState(existingPageTargets, materializationQueue), state: blockers.length > 0
? "blocked"
: getRewriteState({
existingPageTargets,
futureLandingTargets,
materializationQueue
}),
readiness: { readiness: {
approvedDecisionCount: rows.length, approvedDecisionCount: rows.length,
blockerCount: blockers.length + materializationQueue.length, blockerCount: blockers.length + materializationQueue.length,
existingPageTargetCount: existingPageTargets.length, existingPageTargetCount: existingPageTargets.length,
futureLandingGapCount: futureLandingTargets.length,
plannedLandingTargetCount: materializationQueue.length, plannedLandingTargetCount: materializationQueue.length,
rewriteCandidateCount: existingPageTargets.reduce((sum, target) => sum + target.keywordBindings.length, 0) rewriteCandidateCount: existingPageTargets.reduce((sum, target) => sum + target.keywordBindings.length, 0)
}, },
blockers, blockers,
existingPageTargets, existingPageTargets,
futureLandingTargets,
materializationQueue materializationQueue
}; };

View File

@ -7,8 +7,12 @@ function getNumericConstant(source: string, name: string) {
} }
const wordstatRepositorySource = await readFile(new URL("../market/wordstatRepository.ts", import.meta.url), "utf8"); 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 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 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 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"); 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"), yandexEvidenceSource.includes("bucketKey") && yandexEvidenceSource.includes("candidatesByBucket"),
"Yandex Evidence phrase selection must preserve semantic/landing coverage buckets." "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."); console.info("Demand coverage check passed.");

View File

@ -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();
}

View File

@ -144,10 +144,25 @@ assert.ok(
semanticBlockRepositorySource.includes("saveSemanticBlocksForWorkspace") && semanticBlockRepositorySource.includes("client.release()"), semanticBlockRepositorySource.includes("saveSemanticBlocksForWorkspace") && semanticBlockRepositorySource.includes("client.release()"),
"Semantic block saves must go through an atomic backend repository transaction." "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( assert.ok(
appSource.includes("hydratePersistedWorkspaceSemanticBlocks") && appSource.includes("savePageSemanticBlocks"), appSource.includes("hydratePersistedWorkspaceSemanticBlocks") && appSource.includes("savePageSemanticBlocks"),
"Stage 07 semantic block overrides must reload from and save to backend." "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( assert.ok(
rewriteDiffSource.includes("listSemanticBlocksForPages") && rewriteDiffSource.includes("listSemanticBlocksForPages") &&
rewriteDiffSource.includes("semanticBlock: semanticBlock ? mapRewriteDiffSemanticBlock") && rewriteDiffSource.includes("semanticBlock: semanticBlock ? mapRewriteDiffSemanticBlock") &&

View File

@ -98,6 +98,10 @@ function normalizeTargetIds(value: string[] | undefined) {
).slice(0, 200); ).slice(0, 200);
} }
function hasSemanticBlockRuntimeBinding(block: { sourceBindings: JsonArray; targetIds: string[] }) {
return block.targetIds.length > 0 || block.sourceBindings.length > 0;
}
function mapSemanticBlockRow(row: SemanticBlockRow): SemanticBlockModel { function mapSemanticBlockRow(row: SemanticBlockRow): SemanticBlockModel {
return { return {
anchors: normalizeArray(row.anchors), anchors: normalizeArray(row.anchors),
@ -191,7 +195,8 @@ export async function saveSemanticBlocksForWorkspace(projectId: string, pageId:
throw new SemanticBlockWorkspaceError("Нужны workspaceKey и scopeId для semantic blocks."); throw new SemanticBlockWorkspaceError("Нужны workspaceKey и scopeId для semantic blocks.");
} }
const normalizedBlocks = input.blocks.map((block) => ({ const normalizedBlocks = input.blocks
.map((block) => ({
anchors: normalizeArray(block.anchors), anchors: normalizeArray(block.anchors),
blockKey: cleanText(block.blockKey, 260), blockKey: cleanText(block.blockKey, 260),
capturedEntities: normalizeArray(block.capturedEntities), capturedEntities: normalizeArray(block.capturedEntities),
@ -204,7 +209,8 @@ export async function saveSemanticBlocksForWorkspace(projectId: string, pageId:
sourceBindings: normalizeArray(block.sourceBindings), sourceBindings: normalizeArray(block.sourceBindings),
targetIds: normalizeTargetIds(block.targetIds), targetIds: normalizeTargetIds(block.targetIds),
title: cleanText(block.title, 260) || "Semantic block" title: cleanText(block.title, 260) || "Semantic block"
})).filter((block) => block.blockKey); }))
.filter((block) => block.blockKey && hasSemanticBlockRuntimeBinding(block));
const blockKeys = normalizedBlocks.map((block) => block.blockKey); const blockKeys = normalizedBlocks.map((block) => block.blockKey);
const client = await pool.connect(); const client = await pool.connect();