fix(seo): block stale keyword strategy gates

This commit is contained in:
DCCONSTRUCTIONS 2026-07-03 09:21:26 +03:00
parent 43d2af454d
commit a5a680bf00
4 changed files with 119 additions and 19 deletions

View File

@ -541,7 +541,7 @@ function enforceKeywordCleaningSemanticGuard(
return { return {
...item, ...item,
blockers: unique([...item.blockers, "semantic_facet_loss"]), blockers: unique([...(Array.isArray(item.blockers) ? item.blockers : []), "semantic_facet_loss"]),
confidence: Math.min(item.confidence, 0.62), confidence: Math.min(item.confidence, 0.62),
decision: "risky", decision: "risky",
reason: `${semanticDowngradeReason} Backend guard понизил фразу в risky: частотность не перекрывает потерю смысла.` reason: `${semanticDowngradeReason} Backend guard понизил фразу в risky: частотность не перекрывает потерю смысла.`

View File

@ -369,6 +369,12 @@ function getKeywordPriorityWeight(priority: KeywordCleaningItem["priority"]) {
return 1; return 1;
} }
function getKeywordDecisionRouteWeight(decision: KeywordCleaningItem["decision"]) {
if (decision === "use") return 2;
if (decision === "support") return 1;
return 0;
}
function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGuard) { function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGuard) {
const semanticFit = guard ? getSemanticFitAssessment(item, guard) : null; const semanticFit = guard ? getSemanticFitAssessment(item, guard) : null;
@ -404,7 +410,7 @@ function getKeywordRole(item: KeywordCleaningItem, indexInTarget: number, guard:
return "validate"; return "validate";
} }
if (indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) { if (item.decision === "use" && indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) {
return "primary"; return "primary";
} }
@ -470,6 +476,13 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
return rightRouteWeight - leftRouteWeight; return rightRouteWeight - leftRouteWeight;
} }
const leftDecisionWeight = getKeywordDecisionRouteWeight(left.decision);
const rightDecisionWeight = getKeywordDecisionRouteWeight(right.decision);
if (leftDecisionWeight !== rightDecisionWeight) {
return rightDecisionWeight - leftDecisionWeight;
}
const leftPriorityWeight = getKeywordPriorityWeight(left.priority); const leftPriorityWeight = getKeywordPriorityWeight(left.priority);
const rightPriorityWeight = getKeywordPriorityWeight(right.priority); const rightPriorityWeight = getKeywordPriorityWeight(right.priority);

View File

@ -307,6 +307,7 @@ function getOpportunityTiming(
} }
function buildSiteMaturity(input: { function buildSiteMaturity(input: {
activeApprovedKeywordCount: number;
keywordMap: KeywordMapContract; keywordMap: KeywordMapContract;
market: MarketEnrichmentContract; market: MarketEnrichmentContract;
serpInterpretation: SerpInterpretationContract; serpInterpretation: SerpInterpretationContract;
@ -355,7 +356,7 @@ function buildSiteMaturity(input: {
: "Нет данных Webmaster/Метрики/Search Console-style: рынок виден, но сам сайт ещё не доказал индексацию и первые сигналы.", : "Нет данных Webmaster/Метрики/Search Console-style: рынок виден, но сам сайт ещё не доказал индексацию и первые сигналы.",
schemaVersion: "site-maturity.v1", schemaVersion: "site-maturity.v1",
signals: { signals: {
approvedKeywordCount: input.keywordMap.persisted.approvedDecisionCount, approvedKeywordCount: input.activeApprovedKeywordCount,
hasAuthorityEvidence, hasAuthorityEvidence,
hasIndexEvidence, hasIndexEvidence,
hasPerformanceEvidence, hasPerformanceEvidence,
@ -813,9 +814,11 @@ function buildDemandEvidence(
cleaning: KeywordCleaningContract, cleaning: KeywordCleaningContract,
keywordMap: KeywordMapContract keywordMap: KeywordMapContract
): SeoStrategyContract["evidence"]["demand"] { ): SeoStrategyContract["evidence"]["demand"] {
const persistedPhraseSet = new Set(keywordMap.persisted.items.map((item) => item.phrase.toLocaleLowerCase("ru-RU"))); const persistedPhraseSet = new Set(
getActiveStrategyKeywordMapItems(cleaning, keywordMap).map((item) => normalizePhraseKey(item.phrase))
);
const topSignals = cleaning.items const topSignals = cleaning.items
.filter((item) => persistedPhraseSet.has(item.phrase.toLocaleLowerCase("ru-RU")) && item.frequency !== null) .filter((item) => persistedPhraseSet.has(normalizePhraseKey(item.phrase)) && item.frequency !== null)
.sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0)) .sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))
.slice(0, 8) .slice(0, 8)
.map((item) => ({ .map((item) => ({
@ -872,7 +875,7 @@ function getCleaningItemByPhrase(cleaning: KeywordCleaningContract) {
const itemsByPhrase = new Map<string, KeywordCleaningContract["items"][number]>(); const itemsByPhrase = new Map<string, KeywordCleaningContract["items"][number]>();
for (const item of cleaning.items) { for (const item of cleaning.items) {
const key = item.phrase.toLocaleLowerCase("ru-RU"); const key = normalizePhraseKey(item.phrase);
const currentItem = itemsByPhrase.get(key); const currentItem = itemsByPhrase.get(key);
if (!currentItem || (item.frequency ?? 0) > (currentItem.frequency ?? 0)) { if (!currentItem || (item.frequency ?? 0) > (currentItem.frequency ?? 0)) {
@ -883,6 +886,30 @@ function getCleaningItemByPhrase(cleaning: KeywordCleaningContract) {
return itemsByPhrase; return itemsByPhrase;
} }
function getActiveStrategyKeywordMapItems(cleaning: KeywordCleaningContract, keywordMap: KeywordMapContract) {
const cleaningByPhrase = getCleaningItemByPhrase(cleaning);
const currentRoutableByPhrase = new Map(
keywordMap.items
.filter((item) => item.role !== "validate" && item.role !== "secondary_candidate")
.map((item) => [normalizePhraseKey(item.phrase), item])
);
return keywordMap.persisted.items.filter((item) => {
const cleaningItem = cleaningByPhrase.get(normalizePhraseKey(item.phrase));
const currentMapItem = currentRoutableByPhrase.get(normalizePhraseKey(item.phrase));
return (
Boolean(currentMapItem) &&
Boolean(cleaningItem) &&
(cleaningItem?.decision === "use" || cleaningItem?.decision === "support") &&
cleaningItem?.evidenceStatus === "collected" &&
cleaningItem?.frequency !== null &&
currentMapItem?.role === item.role &&
normalizeKey(currentMapItem?.targetPath ?? null) === normalizeKey(item.targetPath)
);
});
}
function buildOpportunities( function buildOpportunities(
cleaning: KeywordCleaningContract, cleaning: KeywordCleaningContract,
keywordMap: KeywordMapContract, keywordMap: KeywordMapContract,
@ -891,7 +918,7 @@ function buildOpportunities(
const cleaningByPhrase = getCleaningItemByPhrase(cleaning); const cleaningByPhrase = getCleaningItemByPhrase(cleaning);
const groups = new Map<string, KeywordMapPersistedItem[]>(); const groups = new Map<string, KeywordMapPersistedItem[]>();
for (const item of keywordMap.persisted.items) { for (const item of getActiveStrategyKeywordMapItems(cleaning, keywordMap)) {
const key = normalizeKey(item.targetPath); const key = normalizeKey(item.targetPath);
const group = groups.get(key) ?? []; const group = groups.get(key) ?? [];
@ -956,6 +983,7 @@ function buildOpportunities(
} }
function buildSerpOpportunities( function buildSerpOpportunities(
cleaning: KeywordCleaningContract,
serpInterpretation: SerpInterpretationContract, serpInterpretation: SerpInterpretationContract,
keywordMap: KeywordMapContract, keywordMap: KeywordMapContract,
siteMaturity: SeoStrategyContract["siteMaturity"] siteMaturity: SeoStrategyContract["siteMaturity"]
@ -964,7 +992,7 @@ function buildSerpOpportunities(
return []; return [];
} }
const approvedPhraseSet = new Set(keywordMap.persisted.items.map((item) => normalizePhraseKey(item.phrase))); const approvedPhraseSet = new Set(getActiveStrategyKeywordMapItems(cleaning, keywordMap).map((item) => normalizePhraseKey(item.phrase)));
return serpInterpretation.phraseInterpretations return serpInterpretation.phraseInterpretations
.filter( .filter(
@ -1072,6 +1100,7 @@ function buildMissingEvidence(
} }
function buildRisks(input: { function buildRisks(input: {
activeApprovedKeywordCount: number;
cleaning: KeywordCleaningContract; cleaning: KeywordCleaningContract;
contextReview: Awaited<ReturnType<typeof getLatestSeoContextReview>>; contextReview: Awaited<ReturnType<typeof getLatestSeoContextReview>>;
keywordMap: KeywordMapContract; keywordMap: KeywordMapContract;
@ -1104,13 +1133,13 @@ function buildRisks(input: {
}); });
} }
if (input.keywordMap.persisted.approvedDecisionCount === 0) { if (input.activeApprovedKeywordCount === 0) {
risks.push({ risks.push({
id: "risk:no_approved_keywords", id: "risk:no_approved_keywords",
level: "critical", level: "critical",
message: "Нет подтверждённых решений по фразам, поэтому стратегия не имеет опорной карты спроса.", message: "Нет активных подтверждённых решений по фразам: сохранённые ранее решения устарели или не проходят текущий semantic/evidence guard.",
mitigation: "Сначала сохранить решения карты фраз, подтверждённые доказательствами.", mitigation: "Сначала сохранить актуальные решения карты фраз, подтверждённые текущим cleaning и Stage 3.",
title: "Нет утверждённой карты решений" title: "Нет актуальной карты решений"
}); });
} }
@ -1239,6 +1268,7 @@ function buildStrategyOptions(input: {
} }
function getStrategyScore(input: { function getStrategyScore(input: {
activeApprovedKeywordCount: number;
contextReady: boolean; contextReady: boolean;
keywordMap: KeywordMapContract; keywordMap: KeywordMapContract;
market: MarketEnrichmentContract; market: MarketEnrichmentContract;
@ -1253,7 +1283,7 @@ function getStrategyScore(input: {
score += 20; score += 20;
} }
if (input.keywordMap.persisted.approvedDecisionCount > 0) { if (input.activeApprovedKeywordCount > 0) {
score += 20; score += 20;
} }
@ -1282,8 +1312,12 @@ function getStrategyScore(input: {
return Math.max(0, Math.min(100, score)); return Math.max(0, Math.min(100, score));
} }
function getState(score: number, keywordMap: KeywordMapContract, missingEvidence: SeoStrategyContract["missingEvidence"]): SeoStrategyState { function getState(
if (keywordMap.persisted.approvedDecisionCount === 0) { score: number,
activeApprovedKeywordCount: number,
missingEvidence: SeoStrategyContract["missingEvidence"]
): SeoStrategyState {
if (activeApprovedKeywordCount === 0) {
return "blocked"; return "blocked";
} }
@ -1343,15 +1377,24 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
: getEmptySerpInterpretationContract(projectId); : getEmptySerpInterpretationContract(projectId);
const context = buildContextEvidence(contextReview); const context = buildContextEvidence(contextReview);
const contextReady = context.status === "ready"; const contextReady = context.status === "ready";
const activeStrategyKeywordMapItems = getActiveStrategyKeywordMapItems(cleaning, keywordMap);
const activeApprovedKeywordCount = activeStrategyKeywordMapItems.length;
const demand = buildDemandEvidence(market, cleaning, keywordMap); const demand = buildDemandEvidence(market, cleaning, keywordMap);
const siteMaturity = buildSiteMaturity({ keywordMap, market, serpInterpretation, yandexEvidence }); const siteMaturity = buildSiteMaturity({
activeApprovedKeywordCount,
keywordMap,
market,
serpInterpretation,
yandexEvidence
});
const phasePlan = buildPhasePlan(siteMaturity); const phasePlan = buildPhasePlan(siteMaturity);
const opportunities = [ const opportunities = [
...buildOpportunities(cleaning, keywordMap, siteMaturity), ...buildOpportunities(cleaning, keywordMap, siteMaturity),
...buildSerpOpportunities(serpInterpretation, keywordMap, siteMaturity) ...buildSerpOpportunities(cleaning, serpInterpretation, keywordMap, siteMaturity)
]; ];
const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation); const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation);
const risks = buildRisks({ const risks = buildRisks({
activeApprovedKeywordCount,
cleaning, cleaning,
contextReview, contextReview,
keywordMap, keywordMap,
@ -1370,6 +1413,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
siteMaturity siteMaturity
}); });
const strategyConfidenceScore = getStrategyScore({ const strategyConfidenceScore = getStrategyScore({
activeApprovedKeywordCount,
contextReady, contextReady,
keywordMap, keywordMap,
market, market,
@ -1388,7 +1432,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
semanticRunId: market.semanticRunId, semanticRunId: market.semanticRunId,
projectOntologyVersion: market.projectOntologyVersion, projectOntologyVersion: market.projectOntologyVersion,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
state: getState(strategyConfidenceScore, keywordMap, missingEvidence), state: getState(strategyConfidenceScore, activeApprovedKeywordCount, missingEvidence),
verdict: { verdict: {
confidence: getConfidence(strategyConfidenceScore), confidence: getConfidence(strategyConfidenceScore),
primaryConstraint: primaryConstraint:
@ -1405,7 +1449,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
: "Стратегия пока не доказана" : "Стратегия пока не доказана"
}, },
readiness: { readiness: {
approvedKeywordCount: keywordMap.persisted.approvedDecisionCount, approvedKeywordCount: activeApprovedKeywordCount,
contextReady, contextReady,
evidenceGapCount: missingEvidence.length, evidenceGapCount: missingEvidence.length,
externalProviderConnectedCount, externalProviderConnectedCount,

View File

@ -393,8 +393,40 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis
expansionOptions.some((option) => option.confidence !== "low" || option.priority !== "low" || option.expectedImpact !== "low"); expansionOptions.some((option) => option.confidence !== "low" || option.priority !== "low" || option.expectedImpact !== "low");
const hasPhaseDecision = synthesis.phaseDecision.currentPhase === strategy.siteMaturity.phase && synthesis.phaseDecision.hold.length > 0; const hasPhaseDecision = synthesis.phaseDecision.currentPhase === strategy.siteMaturity.phase && synthesis.phaseDecision.hold.length > 0;
const hasReadableNextStep = synthesis.recommendedPath.doNow.length > 0 && synthesis.recommendedPath.hold.length > 0; const hasReadableNextStep = synthesis.recommendedPath.doNow.length > 0 && synthesis.recommendedPath.hold.length > 0;
const hasActiveKeywordMap = strategy.readiness.approvedKeywordCount > 0 && strategy.opportunities.length > 0;
const synthesisHasActionableRoute =
synthesis.recommendedPath.doNow.length > 0 ||
synthesis.decisionOptions.some((option) => option.scenario !== "analytics_first" && option.scenario !== "bootstrap_runway");
return [ return [
makeCheck({
action: hasActiveKeywordMap
? "Продолжать синтез только из актуальной сохранённой keyword map."
: "Сначала пересохранить Stage 3 после текущего keyword cleaning: stale approved decisions не должны кормить стратегию.",
category: "evidence_integrity",
id: "evidence:active_keyword_map",
message: hasActiveKeywordMap
? `${strategy.readiness.approvedKeywordCount} активных keyword decisions доступны стратегии.`
: "Нет активных keyword decisions: текущий cleaning/Stage 3 не подтверждает сохранённую ранее карту.",
severity: hasActiveKeywordMap ? "low" : "critical",
status: hasActiveKeywordMap ? "pass" : "fail",
title: "Актуальная карта ключей"
}),
makeCheck({
action:
strategy.state === "blocked" && synthesisHasActionableRoute
? "Пересобрать synthesis после актуализации keyword map или оставить только data-gap/bootstrap действия."
: "Не давать synthesis текущие SEO-действия, если базовая strategy заблокирована.",
category: "phase_guardrail",
id: "phase:no_actionable_synthesis_when_strategy_blocked",
message:
strategy.state === "blocked" && synthesisHasActionableRoute
? "Strategy заблокирована, но synthesis всё ещё содержит action route/decision options."
: "Synthesis не обходит заблокированное состояние базовой strategy.",
severity: strategy.state === "blocked" && synthesisHasActionableRoute ? "critical" : "low",
status: strategy.state === "blocked" && synthesisHasActionableRoute ? "fail" : "pass",
title: "Synthesis не обходит strategy gate"
}),
makeCheck({ makeCheck({
action: action:
immediateHeadTargets.length > 0 immediateHeadTargets.length > 0
@ -711,6 +743,17 @@ function normalizeOutput(
} }
export async function getLatestStrategyQualityReviewContract(projectId: string): Promise<StrategyQualityReviewContract> { export async function getLatestStrategyQualityReviewContract(projectId: string): Promise<StrategyQualityReviewContract> {
const [strategy, synthesis, serpInterpretation] = await Promise.all([
getSeoStrategyContract(projectId),
getLatestStrategySynthesisContract(projectId),
getLatestSerpInterpretationContract(projectId)
]);
const currentBackendReview = buildStrategyQualityReviewOutput(projectId, strategy, synthesis, serpInterpretation);
if (currentBackendReview) {
return currentBackendReview;
}
const result = await pool.query<StrategyQualityReviewRunRow>( const result = await pool.query<StrategyQualityReviewRunRow>(
` `
select id, status, input, output, error_message, started_at, completed_at, created_at select id, status, input, output, error_message, started_at, completed_at, created_at