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,
|
"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
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
@ -358,8 +360,8 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
||||||
"После 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.",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.");
|
||||||
|
|
|
||||||
|
|
@ -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()"),
|
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") &&
|
||||||
|
|
|
||||||
|
|
@ -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,20 +195,22 @@ 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
|
||||||
anchors: normalizeArray(block.anchors),
|
.map((block) => ({
|
||||||
blockKey: cleanText(block.blockKey, 260),
|
anchors: normalizeArray(block.anchors),
|
||||||
capturedEntities: normalizeArray(block.capturedEntities),
|
blockKey: cleanText(block.blockKey, 260),
|
||||||
geometryCache: normalizeRecord(block.geometryCache),
|
capturedEntities: normalizeArray(block.capturedEntities),
|
||||||
identity: normalizeRecord(block.identity),
|
geometryCache: normalizeRecord(block.geometryCache),
|
||||||
mediaContext: normalizeRecord(block.mediaContext),
|
identity: normalizeRecord(block.identity),
|
||||||
rewriteState: normalizeRecord(block.rewriteState),
|
mediaContext: normalizeRecord(block.mediaContext),
|
||||||
role: cleanText(block.role ?? "content", 80) || "content",
|
rewriteState: normalizeRecord(block.rewriteState),
|
||||||
source: block.source ?? "manual",
|
role: cleanText(block.role ?? "content", 80) || "content",
|
||||||
sourceBindings: normalizeArray(block.sourceBindings),
|
source: block.source ?? "manual",
|
||||||
targetIds: normalizeTargetIds(block.targetIds),
|
sourceBindings: normalizeArray(block.sourceBindings),
|
||||||
title: cleanText(block.title, 260) || "Semantic block"
|
targetIds: normalizeTargetIds(block.targetIds),
|
||||||
})).filter((block) => block.blockKey);
|
title: cleanText(block.title, 260) || "Semantic block"
|
||||||
|
}))
|
||||||
|
.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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue