diff --git a/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts b/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts index db0f6a7..2e68c2d 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts @@ -541,7 +541,7 @@ function enforceKeywordCleaningSemanticGuard( return { ...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), decision: "risky", reason: `${semanticDowngradeReason} Backend guard понизил фразу в risky: частотность не перекрывает потерю смысла.` diff --git a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts index 8ac38fb..8186fac 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts @@ -369,6 +369,12 @@ function getKeywordPriorityWeight(priority: KeywordCleaningItem["priority"]) { 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) { const semanticFit = guard ? getSemanticFitAssessment(item, guard) : null; @@ -404,7 +410,7 @@ function getKeywordRole(item: KeywordCleaningItem, indexInTarget: number, guard: return "validate"; } - if (indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) { + if (item.decision === "use" && indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) { return "primary"; } @@ -470,6 +476,13 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin 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 rightPriorityWeight = getKeywordPriorityWeight(right.priority); diff --git a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts index 0190a25..2876a6e 100644 --- a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts +++ b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts @@ -307,6 +307,7 @@ function getOpportunityTiming( } function buildSiteMaturity(input: { + activeApprovedKeywordCount: number; keywordMap: KeywordMapContract; market: MarketEnrichmentContract; serpInterpretation: SerpInterpretationContract; @@ -355,7 +356,7 @@ function buildSiteMaturity(input: { : "Нет данных Webmaster/Метрики/Search Console-style: рынок виден, но сам сайт ещё не доказал индексацию и первые сигналы.", schemaVersion: "site-maturity.v1", signals: { - approvedKeywordCount: input.keywordMap.persisted.approvedDecisionCount, + approvedKeywordCount: input.activeApprovedKeywordCount, hasAuthorityEvidence, hasIndexEvidence, hasPerformanceEvidence, @@ -813,9 +814,11 @@ function buildDemandEvidence( cleaning: KeywordCleaningContract, keywordMap: KeywordMapContract ): 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 - .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)) .slice(0, 8) .map((item) => ({ @@ -872,7 +875,7 @@ function getCleaningItemByPhrase(cleaning: KeywordCleaningContract) { const itemsByPhrase = new Map(); for (const item of cleaning.items) { - const key = item.phrase.toLocaleLowerCase("ru-RU"); + const key = normalizePhraseKey(item.phrase); const currentItem = itemsByPhrase.get(key); if (!currentItem || (item.frequency ?? 0) > (currentItem.frequency ?? 0)) { @@ -883,6 +886,30 @@ function getCleaningItemByPhrase(cleaning: KeywordCleaningContract) { 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( cleaning: KeywordCleaningContract, keywordMap: KeywordMapContract, @@ -891,7 +918,7 @@ function buildOpportunities( const cleaningByPhrase = getCleaningItemByPhrase(cleaning); const groups = new Map(); - for (const item of keywordMap.persisted.items) { + for (const item of getActiveStrategyKeywordMapItems(cleaning, keywordMap)) { const key = normalizeKey(item.targetPath); const group = groups.get(key) ?? []; @@ -956,6 +983,7 @@ function buildOpportunities( } function buildSerpOpportunities( + cleaning: KeywordCleaningContract, serpInterpretation: SerpInterpretationContract, keywordMap: KeywordMapContract, siteMaturity: SeoStrategyContract["siteMaturity"] @@ -964,7 +992,7 @@ function buildSerpOpportunities( 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 .filter( @@ -1072,6 +1100,7 @@ function buildMissingEvidence( } function buildRisks(input: { + activeApprovedKeywordCount: number; cleaning: KeywordCleaningContract; contextReview: Awaited>; keywordMap: KeywordMapContract; @@ -1104,13 +1133,13 @@ function buildRisks(input: { }); } - if (input.keywordMap.persisted.approvedDecisionCount === 0) { + if (input.activeApprovedKeywordCount === 0) { risks.push({ id: "risk:no_approved_keywords", level: "critical", - message: "Нет подтверждённых решений по фразам, поэтому стратегия не имеет опорной карты спроса.", - mitigation: "Сначала сохранить решения карты фраз, подтверждённые доказательствами.", - title: "Нет утверждённой карты решений" + message: "Нет активных подтверждённых решений по фразам: сохранённые ранее решения устарели или не проходят текущий semantic/evidence guard.", + mitigation: "Сначала сохранить актуальные решения карты фраз, подтверждённые текущим cleaning и Stage 3.", + title: "Нет актуальной карты решений" }); } @@ -1239,6 +1268,7 @@ function buildStrategyOptions(input: { } function getStrategyScore(input: { + activeApprovedKeywordCount: number; contextReady: boolean; keywordMap: KeywordMapContract; market: MarketEnrichmentContract; @@ -1253,7 +1283,7 @@ function getStrategyScore(input: { score += 20; } - if (input.keywordMap.persisted.approvedDecisionCount > 0) { + if (input.activeApprovedKeywordCount > 0) { score += 20; } @@ -1282,8 +1312,12 @@ function getStrategyScore(input: { return Math.max(0, Math.min(100, score)); } -function getState(score: number, keywordMap: KeywordMapContract, missingEvidence: SeoStrategyContract["missingEvidence"]): SeoStrategyState { - if (keywordMap.persisted.approvedDecisionCount === 0) { +function getState( + score: number, + activeApprovedKeywordCount: number, + missingEvidence: SeoStrategyContract["missingEvidence"] +): SeoStrategyState { + if (activeApprovedKeywordCount === 0) { return "blocked"; } @@ -1343,15 +1377,24 @@ export async function getSeoStrategyContract(projectId: string): Promise option.confidence !== "low" || option.priority !== "low" || option.expectedImpact !== "low"); 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 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 [ + 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({ action: immediateHeadTargets.length > 0 @@ -711,6 +743,17 @@ function normalizeOutput( } export async function getLatestStrategyQualityReviewContract(projectId: string): Promise { + 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( ` select id, status, input, output, error_message, started_at, completed_at, created_at