fix(seo): block stale keyword strategy gates
This commit is contained in:
parent
43d2af454d
commit
a5a680bf00
|
|
@ -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: частотность не перекрывает потерю смысла.`
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, KeywordCleaningContract["items"][number]>();
|
||||
|
||||
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<string, KeywordMapPersistedItem[]>();
|
||||
|
||||
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<ReturnType<typeof getLatestSeoContextReview>>;
|
||||
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<SeoStra
|
|||
: getEmptySerpInterpretationContract(projectId);
|
||||
const context = buildContextEvidence(contextReview);
|
||||
const contextReady = context.status === "ready";
|
||||
const activeStrategyKeywordMapItems = getActiveStrategyKeywordMapItems(cleaning, keywordMap);
|
||||
const activeApprovedKeywordCount = activeStrategyKeywordMapItems.length;
|
||||
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 opportunities = [
|
||||
...buildOpportunities(cleaning, keywordMap, siteMaturity),
|
||||
...buildSerpOpportunities(serpInterpretation, keywordMap, siteMaturity)
|
||||
...buildSerpOpportunities(cleaning, serpInterpretation, keywordMap, siteMaturity)
|
||||
];
|
||||
const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation);
|
||||
const risks = buildRisks({
|
||||
activeApprovedKeywordCount,
|
||||
cleaning,
|
||||
contextReview,
|
||||
keywordMap,
|
||||
|
|
@ -1370,6 +1413,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
|||
siteMaturity
|
||||
});
|
||||
const strategyConfidenceScore = getStrategyScore({
|
||||
activeApprovedKeywordCount,
|
||||
contextReady,
|
||||
keywordMap,
|
||||
market,
|
||||
|
|
@ -1388,7 +1432,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
|||
semanticRunId: market.semanticRunId,
|
||||
projectOntologyVersion: market.projectOntologyVersion,
|
||||
generatedAt: new Date().toISOString(),
|
||||
state: getState(strategyConfidenceScore, keywordMap, missingEvidence),
|
||||
state: getState(strategyConfidenceScore, activeApprovedKeywordCount, missingEvidence),
|
||||
verdict: {
|
||||
confidence: getConfidence(strategyConfidenceScore),
|
||||
primaryConstraint:
|
||||
|
|
@ -1405,7 +1449,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
|||
: "Стратегия пока не доказана"
|
||||
},
|
||||
readiness: {
|
||||
approvedKeywordCount: keywordMap.persisted.approvedDecisionCount,
|
||||
approvedKeywordCount: activeApprovedKeywordCount,
|
||||
contextReady,
|
||||
evidenceGapCount: missingEvidence.length,
|
||||
externalProviderConnectedCount,
|
||||
|
|
|
|||
|
|
@ -393,8 +393,40 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis
|
|||
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 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<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>(
|
||||
`
|
||||
select id, status, input, output, error_message, started_at, completed_at, created_at
|
||||
|
|
|
|||
Loading…
Reference in New Issue