fix(seo): block stale keyword strategy gates
This commit is contained in:
parent
43d2af454d
commit
a5a680bf00
|
|
@ -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: частотность не перекрывает потерю смысла.`
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue