Refine SEO phase strategy guardrails

This commit is contained in:
DCCONSTRUCTIONS 2026-06-30 15:18:36 +03:00
parent 0279b1de47
commit a785dffdf7
3 changed files with 282 additions and 256 deletions

View File

@ -278,8 +278,8 @@ function getOpportunityTiming(
action: "do_now", action: "do_now",
currentPhase: siteMaturity.phase, currentPhase: siteMaturity.phase,
earliestPhase, earliestPhase,
promotionSignal: "Сохранить evidence refs и подтвердить интент перед human approval.", promotionSignal: "Сохранить ссылки на доказательства и подтвердить интент перед ручным утверждением.",
reason: "Фраза соответствует текущей зрелости сайта и не требует более поздней authority-фазы." reason: "Фраза соответствует текущей зрелости сайта и не требует более поздней фазы авторитета."
}; };
} }
@ -288,8 +288,8 @@ function getOpportunityTiming(
action: "prepare", action: "prepare",
currentPhase: siteMaturity.phase, currentPhase: siteMaturity.phase,
earliestPhase, earliestPhase,
promotionSignal: "Дождаться индексации, первых impressions/CTR или подтверждённого target-fit.", promotionSignal: "Дождаться индексации, первых показов/CTR или подтверждённого соответствия целевой странице.",
reason: "Фраза перспективна, но её лучше готовить как следующий слой после первых site signals." reason: "Фраза перспективна, но её лучше готовить как следующий слой после первых сигналов сайта."
}; };
} }
@ -297,8 +297,8 @@ function getOpportunityTiming(
action: "defer", action: "defer",
currentPhase: siteMaturity.phase, currentPhase: siteMaturity.phase,
earliestPhase, earliestPhase,
promotionSignal: "Нужны index/traction/authority signals: показы, клики, поведение, external references или брендовые сигналы.", promotionSignal: "Нужны сигналы индексации, первых результатов и авторитета: показы, клики, поведение, внешние ссылки или брендовые сигналы.",
reason: "Для текущей зрелости сайта это слишком конкурентный target; высокий спрос не равен immediate priority." reason: "Для текущей зрелости сайта это слишком конкурентная цель; высокий спрос не равен текущему приоритету."
}; };
} }
@ -324,31 +324,31 @@ function buildSiteMaturity(input: {
return { return {
allowedNow: [ allowedNow: [
"Собирать low-competition exact intent и long-tail кластеры.", "Собирать низкоконкурентный точный интент и длинные уточняющие кластеры.",
"Держать семантику понятной для индексации: один смысловой слой на посадочную.", "Держать семантику понятной для индексации: один смысловой слой на посадочную.",
"Готовить SERP/evidence backlog без попытки сразу забрать head terms." "Готовить backlog по выдаче и доказательствам без попытки сразу забрать широкие запросы."
], ],
confidence: hasPerformanceEvidence ? "medium" : "high", confidence: hasPerformanceEvidence ? "medium" : "high",
guardrails: [ guardrails: [
"Head/top keywords нельзя считать immediate action для zero-site/new-domain.", "Широкие и самые частотные запросы нельзя считать текущим действием для нового сайта.",
"External demand не является site traction.", "Внешний спрос не является доказательством первых сигналов сайта.",
"Переход фазы требует индексации, impressions/clicks или authority evidence." "Переход фазы требует индексации, показов/кликов или доказательств авторитета."
], ],
holdUntil: [ holdUntil: [
"Mid/head keywords держать как future targets до появления индексации и traction.", "Средние и широкие запросы держать как будущие цели до появления индексации и первых сигналов.",
"Не строить стратегию на top demand без Webmaster/Метрики/Search Console-style evidence.", "Не строить стратегию на верхнем спросе без данных Webmaster/Метрики/Search Console-style.",
"Не расширять core business route без отдельной user-approved expansion branch." "Не расширять основной бизнес-маршрут без отдельного подтверждения пользователем."
], ],
phase, phase,
promotionSignals: [ promotionSignals: [
"Страницы появились в индексе.", "Страницы появились в индексе.",
"Есть первые impressions/clicks/CTR по bootstrap-кластерам.", "Есть первые показы/клики/CTR по стартовым кластерам.",
"SERP target-fit подтверждён и нет critical mismatch.", "Соответствие SERP целевой странице подтверждено и нет критичных расхождений.",
"Появились поведенческие, брендовые или внешние authority signals." "Появились поведенческие, брендовые или внешние сигналы авторитета."
], ],
reason: hasPerformanceEvidence reason: hasPerformanceEvidence
? "Есть внешние site-performance источники; фазу можно повышать после анализа индексации и traction." ? "Есть внешние источники эффективности сайта; фазу можно повышать после анализа индексации и первых сигналов."
: "Нет Webmaster/Метрики/Search Console-style evidence: рынок виден, но сам сайт ещё не доказал индексацию и traction.", : "Нет данных Webmaster/Метрики/Search Console-style: рынок виден, но сам сайт ещё не доказал индексацию и первые сигналы.",
schemaVersion: "site-maturity.v1", schemaVersion: "site-maturity.v1",
signals: { signals: {
approvedKeywordCount: input.keywordMap.persisted.approvedDecisionCount, approvedKeywordCount: input.keywordMap.persisted.approvedDecisionCount,
@ -366,40 +366,40 @@ function buildSiteMaturity(input: {
function buildPhasePlan(siteMaturity: SeoStrategyContract["siteMaturity"]): SeoStrategyContract["phasePlan"] { function buildPhasePlan(siteMaturity: SeoStrategyContract["siteMaturity"]): SeoStrategyContract["phasePlan"] {
const phases: Array<Omit<SeoStrategyContract["phasePlan"][number], "status">> = [ const phases: Array<Omit<SeoStrategyContract["phasePlan"][number], "status">> = [
{ {
allowedTargets: ["точные low-competition фразы", "long-tail формулировки", "понятные product/category terms"], allowedTargets: ["точные низкоконкурентные фразы", "длинные уточняющие формулировки", "понятные продуктовые и категорийные запросы"],
deferredTargets: ["top/head keywords", "широкие конкурентные кластеры", "агрессивное расширение рынка"], deferredTargets: ["самые частотные широкие запросы", "широкие конкурентные кластеры", "агрессивное расширение рынка"],
goal: "Дать поисковику ясный контекст сайта и получить базовую индексацию.", goal: "Дать поисковику ясный контекст сайта и получить базовую индексацию.",
phase: "bootstrap_indexing", phase: "bootstrap_indexing",
promotionSignals: ["страницы в индексе", "первые impressions", "нет critical intent mismatch"], promotionSignals: ["страницы в индексе", "первые показы", "нет критичного расхождения интента"],
risk: "Если сразу идти в head terms, сайт не получает понятного стартового релевантного сигнала.", risk: "Если сразу идти в широкие запросы, сайт не получает понятного стартового релевантного сигнала.",
title: "Bootstrap / indexing" title: "Стартовая индексация"
}, },
{ {
allowedTargets: ["точные mid-tail фразы", "смежные support-кластеры", "страницы с первым demand evidence"], allowedTargets: ["точные среднечастотные фразы", "смежные поддерживающие кластеры", "страницы с первым доказанным спросом"],
deferredTargets: ["самые конкурентные head terms", "большие market expansion ветки"], deferredTargets: ["самые конкурентные широкие запросы", "большие ветки расширения рынка"],
goal: "Расширять охват после первых признаков индексации и релевантности.", goal: "Расширять охват после первых признаков индексации и релевантности.",
phase: "traction", phase: "traction",
promotionSignals: ["impressions/clicks", "CTR не провален", "страницы стабильно индексируются"], promotionSignals: ["показы/клики", "CTR не провален", "страницы стабильно индексируются"],
risk: "Без traction средние кластеры могут размазать фокус и не дать роста.", risk: "Без первых сигналов средние кластеры могут размазать фокус и не дать роста.",
title: "Traction" title: "Первые сигналы"
}, },
{ {
allowedTargets: ["mid-competition clusters", "supporting pages/sections", "internal linking между подтверждёнными кластерами"], allowedTargets: ["кластеры средней конкуренции", "поддерживающие страницы и секции", "внутренние связи между подтверждёнными кластерами"],
deferredTargets: ["самые дорогие head terms без authority signals"], deferredTargets: ["самые дорогие широкие запросы без сигналов авторитета"],
goal: "Усиливать посадочные и контентные подпорки на базе подтверждённых сигналов.", goal: "Усиливать посадочные и контентные подпорки на базе подтверждённых сигналов.",
phase: "growth", phase: "growth",
promotionSignals: ["рост impressions/clicks", "есть ranking movement", "SERP fit подтверждён"], promotionSignals: ["рост показов/кликов", "есть движение позиций", "соответствие SERP подтверждено"],
risk: "Рост без quality/relevance контроля может привести к переспаму и слабому поведенческому сигналу.", risk: "Рост без контроля качества и релевантности может привести к переспаму и слабому поведенческому сигналу.",
title: "Growth" title: "Рост"
}, },
{ {
allowedTargets: ["head terms", "широкие конкурентные категории", "authority-level landing/content clusters"], allowedTargets: ["широкие запросы", "широкие конкурентные категории", "кластеры посадочных и контента уровня авторитета"],
deferredTargets: ["новые рынки без отдельной strategy branch"], deferredTargets: ["новые рынки без отдельной ветки стратегии"],
goal: "Атаковать top keywords только после накопления доверия, истории и authority evidence.", goal: "Атаковать самые частотные запросы только после накопления доверия, истории и доказательств авторитета.",
phase: "authority_head_terms", phase: "authority_head_terms",
promotionSignals: ["стабильный органический трафик", "брендовые/внешние сигналы", "поведенческие метрики выдерживают конкуренцию"], promotionSignals: ["стабильный органический трафик", "брендовые/внешние сигналы", "поведенческие метрики выдерживают конкуренцию"],
risk: "Head terms без authority дают ложный фокус и съедают ресурс стратегии.", risk: "Широкие запросы без авторитета дают ложный фокус и съедают ресурс стратегии.",
title: "Authority / head terms" title: "Широкие запросы"
} }
]; ];
const currentRank = getPhaseRank(siteMaturity.phase); const currentRank = getPhaseRank(siteMaturity.phase);
@ -419,32 +419,49 @@ function getPhaseAllowedTiers(phase: SeoMaturityPhase): SeoCompetitionTier[] {
} }
if (phase === "traction") { if (phase === "traction") {
return ["long_tail", "mid", "unknown"]; return ["long_tail", "unknown"];
} }
if (phase === "growth") { if (phase === "growth") {
return ["long_tail", "mid", "head", "unknown"]; return ["mid"];
} }
return ["head", "mid", "long_tail", "unknown"]; return ["head"];
}
function dedupePhaseOpportunities(
opportunities: SeoStrategyContract["opportunities"]
): SeoStrategyContract["opportunities"] {
const seen = new Set<string>();
return opportunities.filter((opportunity) => {
const key = (opportunity.demand.topPhrase ?? opportunity.title).toLocaleLowerCase("ru-RU").trim();
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
} }
function getPhaseSummary(phase: SeoMaturityPhase, opportunities: SeoStrategyContract["opportunities"]) { function getPhaseSummary(phase: SeoMaturityPhase, opportunities: SeoStrategyContract["opportunities"]) {
const phaseOpportunityCount = opportunities.length; const phaseOpportunityCount = opportunities.length;
if (phase === "bootstrap_indexing") { if (phase === "bootstrap_indexing") {
return `Стартовая стратегия для нового/нулевого сайта: закрепить понятный индексируемый смысл, выбрать low-competition фразы и не расходовать ресурс на head terms. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`; return `Стартовая стратегия для нового/нулевого сайта: закрепить понятный индексируемый смысл, выбрать низкоконкурентные фразы и не расходовать ресурс на широкие запросы. Подходящих возможностей сейчас: ${phaseOpportunityCount}.`;
} }
if (phase === "traction") { if (phase === "traction") {
return `Стратегия первых сигналов: расширять только те кластеры, где уже виден target-fit, индексируемость и первые признаки спроса. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`; return `Стратегия первых сигналов: расширять только те кластеры, где уже видно соответствие целевой странице, индексируемость и первые признаки спроса. Подходящих возможностей сейчас: ${phaseOpportunityCount}.`;
} }
if (phase === "growth") { if (phase === "growth") {
return `Стратегия роста: усиливать подтверждённые посадочные, добавлять support-контент и готовить конкурентные кластеры через SERP evidence. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`; return `Стратегия роста: усиливать подтверждённые посадочные, добавлять поддерживающий контент и готовить конкурентные кластеры через доказательства SERP. Подходящих возможностей сейчас: ${phaseOpportunityCount}.`;
} }
return `Стратегия authority/head terms: атаковать широкие конкурентные запросы только после индексации, traction, external trust и performance signals. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`; return `Стратегия широких запросов: атаковать конкурентные запросы только после индексации, первых сигналов, внешнего доверия и данных эффективности. Подходящих возможностей сейчас: ${phaseOpportunityCount}.`;
} }
function getPhaseDefaultTasks( function getPhaseDefaultTasks(
@ -459,22 +476,22 @@ function getPhaseDefaultTasks(
if (phase === "bootstrap_indexing") { if (phase === "bootstrap_indexing") {
return { return {
doNow: [ doNow: [
"Собрать индексируемое long-tail ядро по понятному offer/intent.", "Собрать индексируемое ядро длинных уточняющих фраз по понятному предложению и интенту.",
"Удержать один основной смысл на посадочную или секцию.", "Удержать один основной смысл на посадочную или секцию.",
"Закрыть technical/indexability blockers перед любым rewrite." "Закрыть технические и индексируемые блокеры перед любыми правками."
], ],
hold: [ hold: [
"Не атаковать head/top keywords как immediate route.", "Не атаковать широкие и самые частотные запросы как текущий маршрут.",
"Не расширять бизнес-направление без отдельной expansion branch.", "Не расширять бизнес-направление без отдельной ветки расширения.",
"Не считать Wordstat demand доказательством traction сайта." "Не считать спрос Wordstat доказательством первых сигналов сайта."
], ],
nextEvidenceTasks: highPriorityEvidence.length nextEvidenceTasks: highPriorityEvidence.length
? highPriorityEvidence ? highPriorityEvidence
: ["SERP top-10 для стартовых фраз", "Webmaster/indexation после deploy", "первые impressions/CTR после индексации"], : ["топ-10 SERP для стартовых фраз", "Webmaster/индексация после деплоя", "первые показы/CTR после индексации"],
prepareNext: [ prepareNext: [
"Подготовить mid-tail backlog для traction.", "Подготовить backlog среднечастотных фраз для фазы первых сигналов.",
"Собрать SERP page type evidence по будущим growth clusters.", "Собрать доказательства типов страниц SERP по будущим кластерам роста.",
"Зафиксировать promotion signals для перехода к traction." "Зафиксировать сигналы перехода к фазе первых сигналов."
] ]
}; };
} }
@ -482,22 +499,22 @@ function getPhaseDefaultTasks(
if (phase === "traction") { if (phase === "traction") {
return { return {
doNow: [ doNow: [
"Расширить exact/mid-tail кластеры с подтверждённым target-fit.", "Расширить точные и среднечастотные кластеры с подтверждённым соответствием целевой странице.",
"Связать запросы с посадочными и evidence refs.", "Связать запросы с посадочными и ссылками на доказательства.",
"Проверить, что CTR/impressions не конфликтуют с выбранным интентом." "Проверить, что CTR и показы не конфликтуют с выбранным интентом."
], ],
hold: [ hold: [
"Не включать самые конкурентные head terms без authority signals.", "Не включать самые конкурентные широкие запросы без сигналов авторитета.",
"Не плодить посадочные без SERP page-type evidence.", "Не плодить посадочные без доказательств типа страницы в SERP.",
"Не переписывать контент без approved page/field plan." "Не переписывать контент без утверждённого плана страниц и полей."
], ],
nextEvidenceTasks: highPriorityEvidence.length nextEvidenceTasks: highPriorityEvidence.length
? highPriorityEvidence ? highPriorityEvidence
: ["SERP intent для mid-tail", "первые query/page signals", "CTR по стартовым кластерам"], : ["интент SERP для среднечастотных фраз", "первые сигналы запрос/страница", "CTR по стартовым кластерам"],
prepareNext: [ prepareNext: [
"Готовить support-контент для growth clusters.", "Готовить поддерживающий контент для кластеров роста.",
"Уточнить internal linking между подтверждёнными посадочными.", "Уточнить внутренние связи между подтверждёнными посадочными.",
"Накопить данные для uplift в growth." "Накопить данные для перехода в фазу роста."
] ]
}; };
} }
@ -505,44 +522,44 @@ function getPhaseDefaultTasks(
if (phase === "growth") { if (phase === "growth") {
return { return {
doNow: [ doNow: [
"Усилить посадочные с доказанным спросом и target-fit.", "Усилить посадочные с доказанным спросом и соответствием целевой странице.",
"Добавить support layers вокруг core clusters.", "Добавить поддерживающие слои вокруг основных кластеров.",
"Использовать competitor gaps только после SERP interpretation." "Использовать конкурентные пробелы только после интерпретации SERP."
], ],
hold: [ hold: [
"Не идти в authority/head terms без устойчивой динамики.", "Не идти в широкие запросы без устойчивой динамики и авторитета.",
"Не смешивать core SEO route и optional business expansion.", "Не смешивать основной SEO-маршрут и дополнительное расширение бизнеса.",
"Не добавлять страницы ради частотности без page role." "Не добавлять страницы ради частотности без роли страницы."
], ],
nextEvidenceTasks: highPriorityEvidence.length nextEvidenceTasks: highPriorityEvidence.length
? highPriorityEvidence ? highPriorityEvidence
: ["competitor gap evidence", "ranking movement", "поведенческие/конверсионные signals"], : ["доказательства конкурентных пробелов", "движение позиций", "поведенческие/конверсионные сигналы"],
prepareNext: [ prepareNext: [
"Готовить authority content и trust signals.", "Готовить авторитетный контент и сигналы доверия.",
"Выделить head-term candidates как отдельный runway.", "Выделить кандидаты широких запросов как отдельный маршрут.",
"Проверить quality/overseo перед масштабированием." "Проверить качество и риск переспама перед масштабированием."
] ]
}; };
} }
return { return {
doNow: [ doNow: [
"Работать с head terms только при наличии authority/performance evidence.", "Работать с широкими запросами только при наличии доказательств авторитета и эффективности.",
"Сравнивать себя с top competitors по page type, depth, trust and snippet fit.", "Сравнивать себя с сильными конкурентами по типу страницы, глубине, доверию и соответствию сниппета.",
"Масштабировать только validated clusters." "Масштабировать только проверенные кластеры."
], ],
hold: [ hold: [
"Не считать authority-фазу доступной для zero-site.", "Не считать фазу авторитета доступной для нового сайта.",
"Не заменять доказанные core clusters широкими запросами.", "Не заменять доказанные основные кластеры широкими запросами.",
"Не запускать programmatic expansion без validation/quality gate." "Не запускать программное расширение без проверки и контроля качества."
], ],
nextEvidenceTasks: highPriorityEvidence.length nextEvidenceTasks: highPriorityEvidence.length
? highPriorityEvidence ? highPriorityEvidence
: ["authority signals", "стабильный organic traffic", "top competitor gap evidence"], : ["сигналы авторитета", "стабильный органический трафик", "доказательства пробелов у сильных конкурентов"],
prepareNext: [ prepareNext: [
"Собрать authority-level landing/content clusters.", "Собрать кластеры посадочных и контента уровня авторитета.",
"Подготовить branded/external trust план.", "Подготовить брендовый и внешний план доверия.",
"Проверить масштабирование через quality review." "Проверить масштабирование через проверку качества."
] ]
}; };
} }
@ -557,20 +574,17 @@ function buildPhaseStrategies(input: {
return input.phasePlan.map((phasePlanItem) => { return input.phasePlan.map((phasePlanItem) => {
const allowedTiers = getPhaseAllowedTiers(phasePlanItem.phase); const allowedTiers = getPhaseAllowedTiers(phasePlanItem.phase);
const phaseRank = getPhaseRank(phasePlanItem.phase); const phaseRank = getPhaseRank(phasePlanItem.phase);
const phaseOpportunities = input.opportunities const phaseOpportunities = dedupePhaseOpportunities(
.filter((opportunity) => { input.opportunities.filter((opportunity) => {
const earliestRank = getPhaseRank(opportunity.timing.earliestPhase); const earliestRank = getPhaseRank(opportunity.timing.earliestPhase);
if (phasePlanItem.phase === "authority_head_terms") { if (phasePlanItem.phase === "authority_head_terms") {
return true; return opportunity.competitionTier === "head";
}
if (phasePlanItem.phase === "growth") {
return earliestRank <= phaseRank + 1;
} }
return earliestRank <= phaseRank && allowedTiers.includes(opportunity.competitionTier); return earliestRank <= phaseRank && allowedTiers.includes(opportunity.competitionTier);
}) })
)
.slice(0, 8); .slice(0, 8);
const topOpportunity = phaseOpportunities[0] ?? input.opportunities[0] ?? null; const topOpportunity = phaseOpportunities[0] ?? input.opportunities[0] ?? null;
const evidenceRefs = Array.from( const evidenceRefs = Array.from(
@ -620,7 +634,7 @@ function buildPhaseStrategies(input: {
phaseOpportunities.length > 0 phaseOpportunities.length > 0
? phaseOpportunities.slice(0, 5).map((opportunity) => ({ ? phaseOpportunities.slice(0, 5).map((opportunity) => ({
competitionTier: opportunity.competitionTier, competitionTier: opportunity.competitionTier,
description: `${opportunity.demand.totalFrequency} demand · ${opportunity.timing.reason}`, description: `${opportunity.demand.totalFrequency} спрос · ${opportunity.timing.reason}`,
evidenceRefs: opportunity.evidenceRefs.slice(0, 6), evidenceRefs: opportunity.evidenceRefs.slice(0, 6),
label: opportunity.demand.topPhrase ?? opportunity.title, label: opportunity.demand.topPhrase ?? opportunity.title,
priority: opportunity.priority priority: opportunity.priority
@ -628,7 +642,7 @@ function buildPhaseStrategies(input: {
: [ : [
{ {
competitionTier: allowedTiers[0] ?? "unknown", competitionTier: allowedTiers[0] ?? "unknown",
description: "Недостаточно очищенных фраз для этой фазы: сначала нужен keyword cleaning/SERP evidence.", description: "Недостаточно очищенных фраз для этой фазы: сначала нужны очистка фраз и доказательства SERP.",
evidenceRefs: [], evidenceRefs: [],
label: phasePlanItem.allowedTargets[0] ?? "нет фраз", label: phasePlanItem.allowedTargets[0] ?? "нет фраз",
priority: "medium" priority: "medium"
@ -649,7 +663,7 @@ function buildPhaseStrategies(input: {
action: "prepare", action: "prepare",
evidenceRefs: [], evidenceRefs: [],
priority: "medium", priority: "medium",
reason: "Нужно сначала получить очищенную карту ключей и SERP target-fit.", reason: "Нужно сначала получить очищенную карту ключей и соответствие SERP целевой странице.",
targetPath: null, targetPath: null,
title: "Посадочные пока не доказаны" title: "Посадочные пока не доказаны"
} }
@ -673,7 +687,7 @@ function buildPhaseStrategies(input: {
function getProviderNextStep(provider: EvidenceProvider) { function getProviderNextStep(provider: EvidenceProvider) {
if (provider.status === "connected") { if (provider.status === "connected") {
return "Использовать как evidence source в стратегии и показывать дату/объём сбора."; return "Использовать как источник доказательств в стратегии и показывать дату/объём сбора.";
} }
if (provider.id === "serp") { if (provider.id === "serp") {
@ -681,14 +695,14 @@ function getProviderNextStep(provider: EvidenceProvider) {
} }
if (provider.id === "model") { if (provider.id === "model") {
return "Описать structured model task для strategy synthesis и gap analysis."; return "Описать структурированную задачу модели для синтеза стратегии и анализа пробелов.";
} }
if (provider.id === "text_quality") { if (provider.id === "text_quality") {
return "Добавить локальный quality checker до любых будущих текстовых правок."; return "Добавить локальный quality checker до любых будущих текстовых правок.";
} }
return "Подключить платформенный provider без ручной настройки пользователем."; return "Подключить платформенный провайдер без ручной настройки пользователем.";
} }
function mapProviders(providers: EvidenceProvider[]): SeoStrategyContract["evidence"]["providers"] { function mapProviders(providers: EvidenceProvider[]): SeoStrategyContract["evidence"]["providers"] {
@ -720,9 +734,9 @@ function buildContextEvidence(
if (!contextReview) { if (!contextReview) {
return { return {
gaps: ["Нет context review: стратегия не должна строиться только на raw scan/semantic output."], gaps: ["Нет проверки контекста: стратегия не должна строиться только на сыром скане или семантическом выводе."],
status, status,
summary: "Контекст сайта ещё не подтверждён AI/human review." summary: "Контекст сайта ещё не подтверждён проверкой AI/человека."
}; };
} }
@ -773,8 +787,8 @@ function buildYandexEvidenceSummary(yandexEvidence: YandexEvidenceContract): Seo
competitorDomainCount: yandexEvidence.summary.competitorDomainCount, competitorDomainCount: yandexEvidence.summary.competitorDomainCount,
nextStep: nextStep:
yandexEvidence.state === "ready" yandexEvidence.state === "ready"
? "Use Yandex evidence for SERP interpretation and competitor gap model task." ? "Использовать данные Yandex для интерпретации SERP и модельной задачи по конкурентным пробелам."
: "Run Yandex evidence collection before strategy approval.", : "Собрать данные Yandex перед утверждением стратегии.",
runId: yandexEvidence.runId, runId: yandexEvidence.runId,
serpDocCount: yandexEvidence.summary.serpDocCount, serpDocCount: yandexEvidence.summary.serpDocCount,
state: yandexEvidence.state, state: yandexEvidence.state,
@ -792,8 +806,8 @@ function buildSerpInterpretationSummary(
mismatchCount: serpInterpretation.readiness.mismatchCount, mismatchCount: serpInterpretation.readiness.mismatchCount,
nextStep: nextStep:
serpInterpretation.state === "ready" serpInterpretation.state === "ready"
? "Use interpreted SERP signals inside opportunity map, risks and strategy options." ? "Использовать интерпретированные сигналы SERP в карте возможностей, рисках и вариантах стратегии."
: "Run SERP interpretation after Yandex evidence collection.", : "Запустить интерпретацию SERP после сбора данных Yandex.",
riskCount: serpInterpretation.readiness.riskCount, riskCount: serpInterpretation.readiness.riskCount,
runId: serpInterpretation.runId, runId: serpInterpretation.runId,
state: serpInterpretation.state, state: serpInterpretation.state,
@ -864,21 +878,21 @@ function buildOpportunities(
id: `opportunity:${targetPath}`, id: `opportunity:${targetPath}`,
nextDataNeeded: nextDataNeeded:
totalFrequency > 0 totalFrequency > 0
? ["SERP top-10 по основным фразам", "конкурентные snippets", "показы/CTR из Webmaster/Метрики"] ? ["топ-10 SERP по основным фразам", "конкурентные сниппеты", "показы/CTR из Webmaster/Метрики"]
: ["точная частотность", "SERP intent", "проверка формулировки моделью"], : ["точная частотность", "интент SERP", "проверка формулировки моделью"],
priority, priority,
recommendation: hasPrimary recommendation: hasPrimary
? timing.action === "defer" ? timing.action === "defer"
? "Держать как будущий strategic target: для текущей зрелости сайта нельзя атаковать этот спрос сразу." ? "Держать как будущую стратегическую цель: для текущей зрелости сайта нельзя атаковать этот спрос сразу."
: "Держать как основной стратегический кластер, но не считать финальным решением до SERP/competitor проверки." : "Держать как основной стратегический кластер, но не считать финальным решением до проверки SERP и конкурентов."
: "Оставить как supporting opportunity до подтверждения интента и спроса.", : "Оставить как поддерживающую возможность до подтверждения интента и спроса.",
targetPath: cleanTargetPath, targetPath: cleanTargetPath,
timing, timing,
title: topItem?.phrase ?? cleanTargetPath ?? "Неразмеченная возможность", title: topItem?.phrase ?? cleanTargetPath ?? "Неразмеченная возможность",
whyItMatters: whyItMatters:
totalFrequency > 0 totalFrequency > 0
? `Есть подтверждённый спрос и ${items.length} связанных решений; это может стать понятной точкой роста.` ? `Есть подтверждённый спрос и ${items.length} связанных решений; это может стать понятной точкой роста.`
: "Есть смысловая связь с сайтом, но market evidence ещё недостаточно сильный." : "Есть смысловая связь с сайтом, но рыночные доказательства ещё недостаточно сильные."
}; };
}) })
.sort((left, right) => { .sort((left, right) => {
@ -922,12 +936,12 @@ function buildSerpOpportunities(
priority, priority,
recommendation: recommendation:
timing.action === "defer" timing.action === "defer"
? "Оставить как поздний target: высокий спрос/сложность не должен идти в immediate route для текущей зрелости сайта." ? "Оставить как позднюю цель: высокий спрос/сложность не должны идти в текущий маршрут для текущей зрелости сайта."
: phrase.recommendedRole === "article_backlog" : phrase.recommendedRole === "article_backlog"
? "Не тащить в коммерческий rewrite: оформить как контентную/support гипотезу и проверить вручную." ? "Не тащить в коммерческую правку: оформить как контентную или поддерживающую гипотезу и проверить вручную."
: phrase.recommendedRole === "core_landing" : phrase.recommendedRole === "core_landing"
? "Держать как SERP-confirmed core opportunity перед strategy review." ? "Держать как подтверждённую SERP основную возможность перед проверкой стратегии."
: "Использовать как supporting opportunity до финальной привязки страницы.", : "Использовать как поддерживающую возможность до финальной привязки страницы.",
targetPath: phrase.targetPath, targetPath: phrase.targetPath,
timing, timing,
title: phrase.phrase, title: phrase.phrase,
@ -969,27 +983,27 @@ function buildMissingEvidence(
impact: "Без подтверждённого контекста стратегия рискует оптимизировать не тот смысл сайта.", impact: "Без подтверждённого контекста стратегия рискует оптимизировать не тот смысл сайта.",
owner: "product_review", owner: "product_review",
priority: "high", priority: "high",
title: "Context review требует финальной проверки" title: "Проверка контекста требует финального подтверждения"
}); });
} }
if (yandexEvidence.state === "not_collected") { if (yandexEvidence.state === "not_collected") {
missing.push({ missing.push({
id: "external:yandex_evidence", id: "external:yandex_evidence",
impact: "Без Yandex evidence стратегия не видит SERP-конкурентов, сезонность и региональный спрос.", impact: "Без данных Yandex стратегия не видит конкурентов в SERP, сезонность и региональный спрос.",
owner: "external_service", owner: "external_service",
priority: "high", priority: "high",
title: "Yandex evidence ещё не собран" title: "Данные Yandex ещё не собраны"
}); });
} }
if (yandexEvidence.state === "ready" && serpInterpretation.state !== "ready") { if (yandexEvidence.state === "ready" && serpInterpretation.state !== "ready") {
missing.push({ missing.push({
id: "ai:serp_interpretation", id: "ai:serp_interpretation",
impact: "Без интерпретации SERP стратегия видит цифры и домены, но не понимает target-fit, page type и конкурентные углы.", impact: "Без интерпретации SERP стратегия видит цифры и домены, но не понимает соответствие целевой странице, тип страницы и конкурентные углы.",
owner: "ai_layer", owner: "ai_layer",
priority: "high", priority: "high",
title: "SERP interpretation ещё не собран" title: "Интерпретация SERP ещё не собрана"
}); });
} }
@ -1013,8 +1027,8 @@ function buildRisks(input: {
risks.push({ risks.push({
id: "risk:no_serp", id: "risk:no_serp",
level: "high", level: "high",
message: "Сейчас есть Wordstat-сигналы, но нет проверки типа выдачи, конкурентов и intent fit.", message: "Сейчас есть Wordstat-сигналы, но нет проверки типа выдачи, конкурентов и соответствия интента.",
mitigation: "Подключить SERP evidence before strategy approval.", mitigation: "Подключить доказательства SERP перед утверждением стратегии.",
title: "Нет конкурентного SERP слоя" title: "Нет конкурентного SERP слоя"
}); });
} }
@ -1023,8 +1037,8 @@ function buildRisks(input: {
risks.push({ risks.push({
id: "risk:too_many_risky_phrases", id: "risk:too_many_risky_phrases",
level: "medium", level: "medium",
message: `${input.cleaning.readiness.riskyCount} фраз остаются risky; это сигнал, что seed strategy ещё узкая или грязная.`, message: `${input.cleaning.readiness.riskyCount} фраз остаются спорными; это сигнал, что стартовая стратегия ещё узкая или грязная.`,
mitigation: "Отдать risky lane в model/human cleaning и расширить seed acquisition.", mitigation: "Отдать спорный поток в очистку моделью/человеком и расширить сбор стартовых фраз.",
title: "Много спорных фраз" title: "Много спорных фраз"
}); });
} }
@ -1033,8 +1047,8 @@ function buildRisks(input: {
risks.push({ risks.push({
id: "risk:no_approved_keywords", id: "risk:no_approved_keywords",
level: "critical", level: "critical",
message: "Нет подтверждённых keyword decisions, поэтому стратегия не имеет опорной карты спроса.", message: "Нет подтверждённых решений по фразам, поэтому стратегия не имеет опорной карты спроса.",
mitigation: "Сначала сохранить evidence-confirmed keyword map decisions.", mitigation: "Сначала сохранить решения карты фраз, подтверждённые доказательствами.",
title: "Нет утверждённой карты решений" title: "Нет утверждённой карты решений"
}); });
} }
@ -1043,8 +1057,8 @@ function buildRisks(input: {
risks.push({ risks.push({
id: "risk:forbidden_claims", id: "risk:forbidden_claims",
level: "medium", level: "medium",
message: `Context review нашёл ${input.contextReview.forbiddenClaims.length} claim guardrails.`, message: `Проверка контекста нашла ${input.contextReview.forbiddenClaims.length} ограничений по утверждениям.`,
mitigation: "Держать forbidden claims как hard guardrails для всех AI strategy/rewrite tasks.", mitigation: "Держать запрещённые утверждения как жёсткие ограничения для всех AI-задач стратегии и будущих правок.",
title: "Есть ограничения по утверждениям" title: "Есть ограничения по утверждениям"
}); });
} }
@ -1053,8 +1067,8 @@ function buildRisks(input: {
risks.push({ risks.push({
id: "risk:weak_seed_market_fit", id: "risk:weak_seed_market_fit",
level: "medium", level: "medium",
message: "Пустых Wordstat-сигналов больше, чем точных. Нужна нормализация формулировок и расширение seed strategy.", message: "Пустых Wordstat-сигналов больше, чем точных. Нужна нормализация формулировок и расширение стартовой стратегии.",
mitigation: "Добавить model SERP/keyword expansion task и отделить support/article/backlog lanes.", mitigation: "Добавить модельную задачу расширения SERP/фраз и отделить поддерживающие, статьевые и backlog-потоки.",
title: "Слабый market fit части seed-ов" title: "Слабый market fit части seed-ов"
}); });
} }
@ -1069,9 +1083,9 @@ function buildRisks(input: {
risks.push({ risks.push({
id: "risk:zero_site_head_terms", id: "risk:zero_site_head_terms",
level: "high", level: "high",
message: `${deferredCompetitiveTargets.length} конкурентных targets нельзя вести как immediate action: сайт ещё в bootstrap/indexing фазе.`, message: `${deferredCompetitiveTargets.length} конкурентных целей нельзя вести как текущее действие: сайт ещё в стартовой фазе индексации.`,
mitigation: "Сначала собрать low-competition индексируемое ядро, дождаться index/traction signals и только потом повышать фазу.", mitigation: "Сначала собрать низкоконкурентное индексируемое ядро, дождаться сигналов индексации/первых результатов и только потом повышать фазу.",
title: "Нулевой сайт не должен сразу атаковать head terms" title: "Нулевой сайт не должен сразу атаковать широкие запросы"
}); });
} }
@ -1095,8 +1109,8 @@ function buildRisks(input: {
id: "risk:serp_intent_mismatch", id: "risk:serp_intent_mismatch",
level: "medium", level: "medium",
message: `${informationalMismatchCount} фраз имеют информационную выдачу при наличии целевой страницы. Нельзя автоматически вести их как коммерческие.`, message: `${informationalMismatchCount} фраз имеют информационную выдачу при наличии целевой страницы. Нельзя автоматически вести их как коммерческие.`,
mitigation: "Отдать эти фразы в model task `serp_interpretation` и решить: статья, support-секция или коммерческий лендинг.", mitigation: "Отдать эти фразы в модельную задачу `serp_interpretation` и решить: статья, поддерживающая секция или коммерческий лендинг.",
title: "Возможен mismatch интента выдачи" title: "Возможно расхождение интента выдачи"
}); });
} }
} }
@ -1125,9 +1139,9 @@ function buildStrategyOptions(input: {
expectedImpact: "medium", expectedImpact: "medium",
id: "option:bootstrap_runway", id: "option:bootstrap_runway",
rationale: rationale:
"Для zero-site сначала нужен phased runway: индексируемое long-tail ядро, понятный смысл сайта и сигналы, которые откроют следующие фазы.", "Для нового сайта сначала нужен фазовый маршрут: индексируемое ядро длинных уточняющих фраз, понятный смысл сайта и сигналы, которые откроют следующие фазы.",
requiredEvidence: input.siteMaturity.promotionSignals, requiredEvidence: input.siteMaturity.promotionSignals,
title: "Начать с bootstrap/indexing стратегии" title: "Начать со стратегии стартовой индексации"
}, },
{ {
confidence: immediateOpportunityCount > 0 ? "medium" : "low", confidence: immediateOpportunityCount > 0 ? "medium" : "low",
@ -1135,8 +1149,8 @@ function buildStrategyOptions(input: {
expectedImpact: immediateOpportunityCount > 0 ? "medium" : "low", expectedImpact: immediateOpportunityCount > 0 ? "medium" : "low",
id: "option:stabilize_core", id: "option:stabilize_core",
rationale: rationale:
"Удержать только targets, допустимые для текущей зрелости сайта, и не раздувать охват до index/traction signals.", "Удержать только цели, допустимые для текущей зрелости сайта, и не раздувать охват до сигналов индексации и первых результатов.",
requiredEvidence: hasSerpGap ? ["SERP evidence по top opportunity phrases"] : ["human strategy review"], requiredEvidence: hasSerpGap ? ["доказательства SERP по главным фразам возможностей"] : ["ручная проверка стратегии"],
title: "Усилить подтверждённое ядро спроса" title: "Усилить подтверждённое ядро спроса"
}, },
{ {
@ -1146,10 +1160,10 @@ function buildStrategyOptions(input: {
id: "option:market_gap_research", id: "option:market_gap_research",
rationale: rationale:
deferredHeadCount > 0 deferredHeadCount > 0
? "Конкурентные gap-сигналы видны, но для текущей зрелости сайта это future runway, а не immediate route." ? "Конкурентные пробелы видны, но для текущей зрелости сайта это будущий маршрут, а не текущий путь."
: "Найти соседние рынки и конкурентные углы только после внешней проверки выдачи, а не по догадкам из текущего текста сайта.", : "Найти соседние рынки и конкурентные углы только после внешней проверки выдачи, а не по догадкам из текущего текста сайта.",
requiredEvidence: ["SERP competitors", "snippets", "page type distribution", "CTR/impression data"], requiredEvidence: ["конкуренты SERP", "сниппеты", "распределение типов страниц", "данные CTR/показов"],
title: "Подготовить расширение через конкурентные gap-сигналы" title: "Подготовить расширение через конкурентные пробелы"
}, },
{ {
confidence: input.risks.some((risk) => risk.level === "critical") ? "low" : "medium", confidence: input.risks.some((risk) => risk.level === "critical") ? "low" : "medium",
@ -1227,28 +1241,28 @@ function buildNextActions(contract: Omit<SeoStrategyContract, "nextActions">) {
const actions: string[] = []; const actions: string[] = [];
if (contract.missingEvidence.length > 0) { if (contract.missingEvidence.length > 0) {
actions.push(`Закрыть evidence gaps: ${contract.missingEvidence.slice(0, 3).map((item) => item.title).join("; ")}.`); actions.push(`Закрыть пробелы доказательств: ${contract.missingEvidence.slice(0, 3).map((item) => item.title).join("; ")}.`);
} }
if (contract.opportunities.length > 0) { if (contract.opportunities.length > 0) {
actions.push("Показать opportunity map как основной UX: спрос, риск, confidence, следующий evidence."); actions.push("Показать карту возможностей как основную витрину: спрос, риск, доверие и следующий слой доказательств.");
} }
actions.push( actions.push(
contract.siteMaturity.phase === "bootstrap_indexing" contract.siteMaturity.phase === "bootstrap_indexing"
? "Собрать bootstrap/indexing roadmap: do-now long-tail, prepare mid-tail, defer head terms до traction/authority signals." ? "Собрать маршрут стартовой индексации: сейчас длинные уточняющие фразы, дальше среднечастотные, широкие запросы отложить до первых сигналов и авторитета."
: `Держать strategy roadmap в фазе ${contract.siteMaturity.phase}: повышать targets только после promotion signals.` : `Держать маршрут стратегии в фазе ${contract.siteMaturity.phase}: повышать цели только после сигналов перехода.`
); );
if (contract.evidence.yandex.state === "not_collected") { if (contract.evidence.yandex.state === "not_collected") {
actions.push("Запустить Yandex evidence collection: Wordstat regions/dynamics + SERP competitors по top фразам."); actions.push("Запустить сбор данных Yandex: регионы/динамика Wordstat и конкуренты SERP по главным фразам.");
} else if (contract.evidence.serpInterpretation.state !== "ready" && contract.evidence.yandex.serpDocCount > 0) { } else if (contract.evidence.serpInterpretation.state !== "ready" && contract.evidence.yandex.serpDocCount > 0) {
actions.push("Отдать Yandex SERP evidence в model task для interpretation: intent fit, competitor gaps, page type distribution."); actions.push("Отдать данные Yandex/SERP в модельную задачу интерпретации: соответствие интента, конкурентные пробелы, распределение типов страниц.");
} else if (contract.evidence.serpInterpretation.state === "ready") { } else if (contract.evidence.serpInterpretation.state === "ready") {
actions.push("Использовать SERP interpretation как основу для strategy synthesis: target-fit, mismatch, competitor angles."); actions.push("Использовать интерпретацию SERP как основу для синтеза стратегии: соответствие целевой странице, расхождения, конкурентные углы.");
} }
actions.push("Не переходить к изменениям сайта, пока seo-strategy.v1 не получит external evidence и human strategy approval."); actions.push("Не переходить к изменениям сайта, пока seo-strategy.v1 не получит внешние доказательства и ручное утверждение стратегии.");
return actions; return actions;
} }
@ -1317,16 +1331,16 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
verdict: { verdict: {
confidence: getConfidence(strategyConfidenceScore), confidence: getConfidence(strategyConfidenceScore),
primaryConstraint: primaryConstraint:
missingEvidence[0]?.title ?? "Нужен human strategy review перед любыми изменениями сайта.", missingEvidence[0]?.title ?? "Нужна ручная проверка стратегии перед любыми изменениями сайта.",
summary: summary:
opportunities.length > 0 opportunities.length > 0
? `Есть ${opportunities.length} opportunity groups, но стратегия ещё зависит от внешних evidence и проверки рисков.` ? `Есть ${opportunities.length} групп возможностей, но стратегия ещё зависит от внешних доказательств и проверки рисков.`
: "Пока нет достаточной opportunity map: нужно усилить market evidence и очистку фраз.", : "Пока нет достаточной карты возможностей: нужно усилить рыночные доказательства и очистку фраз.",
title: title:
strategyConfidenceScore >= 75 strategyConfidenceScore >= 75
? "Стратегия почти готова к review" ? "Стратегия почти готова к проверке"
: strategyConfidenceScore >= 45 : strategyConfidenceScore >= 45
? "Есть рабочая аналитическая база, но не хватает evidence" ? "Есть рабочая аналитическая база, но не хватает доказательств"
: "Стратегия пока не доказана" : "Стратегия пока не доказана"
}, },
readiness: { readiness: {

View File

@ -134,13 +134,13 @@ export function getEmptyStrategyQualityReviewContract(projectId: string): Strate
return { return {
analystReview: getDefaultAnalystReview(), analystReview: getDefaultAnalystReview(),
approvedSignals: [], approvedSignals: [],
blockers: ["Strategy quality review ещё не собран."], blockers: ["Проверка качества стратегии ещё не собрана."],
checks: [], checks: [],
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
nextActions: ["Собрать seo-strategy-synthesis.v1, затем запустить quality review."], nextActions: ["Собрать seo-strategy-synthesis.v1, затем запустить проверку качества."],
projectId, projectId,
provider: { provider: {
message: "Strategy quality review не готов: нет проверенного synthesis.", message: "Проверка качества стратегии не готова: нет проверенного синтеза.",
mode: "deterministic_fallback", mode: "deterministic_fallback",
modelRequired: true modelRequired: true
}, },
@ -165,14 +165,14 @@ export function getEmptyStrategyQualityReviewContract(projectId: string): Strate
sourceStrategySynthesisRunId: null, sourceStrategySynthesisRunId: null,
sourceTaskId: null, sourceTaskId: null,
state: "not_ready", state: "not_ready",
summary: "Strategy quality review ещё не собран.", summary: "Проверка качества стратегии ещё не собрана.",
verdict: { verdict: {
confidence: "low", confidence: "low",
primaryBlocker: "Нет quality review.", primaryBlocker: "Нет проверки качества.",
score: 0, score: 0,
status: "blocked", status: "blocked",
summary: "Нельзя доверять стратегии без отдельного quality gate.", summary: "Нельзя доверять стратегии без отдельной проверки качества.",
title: "Quality gate не готов" title: "Проверка качества не готова"
}, },
warnings: [] warnings: []
}; };
@ -218,9 +218,11 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis
(opportunity) => opportunity.timing.action === "defer" && opportunity.competitionTier === "head" (opportunity) => opportunity.timing.action === "defer" && opportunity.competitionTier === "head"
); );
const evidenceRefIssues = getEvidenceRefIssues(strategy, synthesis); const evidenceRefIssues = getEvidenceRefIssues(strategy, synthesis);
const missingHeadHold = !synthesis.blockedActions.some((action) => action.toLocaleLowerCase("ru-RU").includes("head")); const missingHeadHold = !synthesis.blockedActions.some((action) =>
/head|широк|конкурент|частотн/i.test(action.toLocaleLowerCase("ru-RU"))
);
const hasApplyLeak = [...synthesis.recommendedPath.doNow, ...synthesis.nextActions].some((action) => const hasApplyLeak = [...synthesis.recommendedPath.doNow, ...synthesis.nextActions].some((action) =>
/запустить rewrite|запустить apply|apply changes|source mutation|создать правк|применить/i.test(action) /запустить rewrite|запустить apply|apply changes|source mutation|создать правк|применить|изменить исходник/i.test(action)
); );
const hasExpansionLeak = synthesis.decisionOptions.some( const hasExpansionLeak = synthesis.decisionOptions.some(
(option) => option.scenario === "serp_expansion" && option.confidence !== "low" && strategy.siteMaturity.phase === "bootstrap_indexing" (option) => option.scenario === "serp_expansion" && option.confidence !== "low" && strategy.siteMaturity.phase === "bootstrap_indexing"
@ -232,95 +234,95 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis
makeCheck({ makeCheck({
action: action:
immediateHeadTargets.length > 0 immediateHeadTargets.length > 0
? "Пересобрать strategy/synthesis: head/mid targets должны уйти в defer/prepare до maturity signals." ? "Пересобрать стратегию: широкие и средние запросы должны уйти в отложенные цели до появления зрелости сайта."
: "Сохранить phase-aware timing как hard guardrail.", : "Оставить фазовые ограничения как обязательное правило.",
category: "phase_guardrail", category: "phase_guardrail",
id: "phase:no_immediate_head_terms", id: "phase:no_immediate_head_terms",
message: message:
immediateHeadTargets.length > 0 immediateHeadTargets.length > 0
? `${immediateHeadTargets.length} competitive targets ошибочно попали в immediate action.` ? `${immediateHeadTargets.length} конкурентных целей ошибочно попали в текущие действия.`
: `${deferredHeadTargets.length} head targets корректно отложены до поздней фазы.`, : `${deferredHeadTargets.length} широких целей корректно отложены до поздней фазы.`,
severity: immediateHeadTargets.length > 0 ? "critical" : "low", severity: immediateHeadTargets.length > 0 ? "critical" : "low",
status: immediateHeadTargets.length > 0 ? "fail" : "pass", status: immediateHeadTargets.length > 0 ? "fail" : "pass",
title: "Zero-site/head terms guardrail" title: "Стартовая фаза и широкие запросы"
}), }),
makeCheck({ makeCheck({
action: hasPhaseDecision ? "Оставить phaseDecision обязательной частью strategy synthesis." : "Пересобрать synthesis с phaseDecision.", action: hasPhaseDecision ? "Оставить фазовое решение обязательной частью синтеза." : "Пересобрать синтез с фазовым решением.",
category: "phase_guardrail", category: "phase_guardrail",
id: "phase:decision_present", id: "phase:decision_present",
message: hasPhaseDecision message: hasPhaseDecision
? "Synthesis содержит phaseDecision, current phase и hold-ограничения." ? "Синтез содержит текущую фазу и ограничения."
: "Synthesis не отражает текущую maturity phase или не содержит hold-ограничения.", : "Синтез не отражает текущую фазу зрелости или не содержит ограничений.",
severity: hasPhaseDecision ? "low" : "high", severity: hasPhaseDecision ? "low" : "high",
status: hasPhaseDecision ? "pass" : "fail", status: hasPhaseDecision ? "pass" : "fail",
title: "Phase decision completeness" title: "Фазовое решение заполнено"
}), }),
makeCheck({ makeCheck({
action: action:
evidenceRefIssues.count > 0 evidenceRefIssues.count > 0
? `Добавить evidence refs или пометить как data gap: ${evidenceRefIssues.sample.join("; ")}.` ? `Добавить ссылки на доказательства или пометить как пробел данных: ${evidenceRefIssues.sample.join("; ")}.`
: "Продолжать требовать refs для каждого claim.", : "Продолжать требовать ссылки на доказательства для каждого важного вывода.",
category: "evidence_integrity", category: "evidence_integrity",
id: "evidence:refs_present", id: "evidence:refs_present",
message: message:
evidenceRefIssues.count > 0 evidenceRefIssues.count > 0
? `${evidenceRefIssues.count} claims/options не имеют evidence refs.` ? `${evidenceRefIssues.count} выводов или вариантов не имеют ссылок на доказательства.`
: "Opportunities, evidence narrative и options имеют evidence refs или допустимый bootstrap exception.", : "Возможности, доказательная сводка и варианты имеют ссылки на доказательства.",
severity: evidenceRefIssues.count > 0 ? "high" : "low", severity: evidenceRefIssues.count > 0 ? "high" : "low",
status: evidenceRefIssues.count > 0 ? "fail" : "pass", status: evidenceRefIssues.count > 0 ? "fail" : "pass",
title: "Evidence refs" title: "Ссылки на доказательства"
}), }),
makeCheck({ makeCheck({
action: strategy.siteMaturity.signals.hasAuthorityEvidence action: strategy.siteMaturity.signals.hasAuthorityEvidence
? "Authority evidence можно использовать в будущих head-term checks." ? "Данные авторитета можно использовать в будущих проверках широких запросов."
: "Не утверждать authority/head-term readiness без Webmaster/Метрики/Search Console-style данных.", : "Не утверждать готовность к широким запросам без Webmaster/Метрики/Search Console-style данных.",
category: "hallucination_guardrail", category: "hallucination_guardrail",
id: "hallucination:no_authority_claim", id: "hallucination:no_authority_claim",
message: strategy.siteMaturity.signals.hasAuthorityEvidence message: strategy.siteMaturity.signals.hasAuthorityEvidence
? "Authority evidence присутствует." ? "Данные авторитета присутствуют."
: "Authority evidence отсутствует, стратегия должна явно держать head terms в hold/defer.", : "Данные авторитета отсутствуют, стратегия должна явно держать широкие запросы в запрете или отложенной фазе.",
severity: strategy.siteMaturity.signals.hasAuthorityEvidence || !missingHeadHold ? "low" : "high", severity: strategy.siteMaturity.signals.hasAuthorityEvidence || !missingHeadHold ? "low" : "high",
status: strategy.siteMaturity.signals.hasAuthorityEvidence || !missingHeadHold ? "pass" : "fail", status: strategy.siteMaturity.signals.hasAuthorityEvidence || !missingHeadHold ? "pass" : "fail",
title: "No invented authority" title: "Нет выдуманного авторитета"
}), }),
makeCheck({ makeCheck({
action: hasExpansionLeak action: hasExpansionLeak
? "Понизить confidence у expansion option или вынести её в separate user-approved branch." ? "Понизить доверие у ветки расширения или вынести её в отдельную ветку после подтверждения пользователем."
: "Оставить expansion только как low-confidence/future branch.", : "Оставить расширение только как будущую ветку с низким доверием.",
category: "business_drift", category: "business_drift",
id: "business:no_core_expansion_leak", id: "business:no_core_expansion_leak",
message: hasExpansionLeak message: hasExpansionLeak
? "Expansion option выглядит как core route для bootstrap сайта." ? "Ветка расширения выглядит как основной маршрут для стартовой фазы."
: "Business expansion не смешан с immediate core SEO route.", : "Расширение бизнеса не смешано с текущим основным SEO-маршрутом.",
severity: hasExpansionLeak ? "high" : "low", severity: hasExpansionLeak ? "high" : "low",
status: hasExpansionLeak ? "fail" : "pass", status: hasExpansionLeak ? "fail" : "pass",
title: "Business drift guardrail" title: "Нет смешения с расширением бизнеса"
}), }),
makeCheck({ makeCheck({
action: hasApplyLeak action: hasApplyLeak
? "Убрать rewrite/apply/source wording из immediate actions quality stage." ? "Убрать намёки на правки, применение или изменение исходников из текущих действий."
: "Продолжать держать quality review read-only.", : "Продолжать держать проверку качества в режиме только для чтения.",
category: "source_safety", category: "source_safety",
id: "source:no_mutation_route", id: "source:no_mutation_route",
message: hasApplyLeak message: hasApplyLeak
? "В doNow/nextActions есть ранний намёк на rewrite/apply/source mutation." ? "В текущих или следующих действиях есть ранний намёк на правки сайта."
: "Quality review не открывает rewrite/apply/source mutation.", : "Проверка качества не открывает правки, применение или изменение исходников.",
severity: hasApplyLeak ? "critical" : "low", severity: hasApplyLeak ? "critical" : "low",
status: hasApplyLeak ? "fail" : "pass", status: hasApplyLeak ? "fail" : "pass",
title: "No site mutation" title: "Сайт не меняем"
}), }),
makeCheck({ makeCheck({
action: hasReadableNextStep action: hasReadableNextStep
? "Показывать пользователю do-now/hold/next evidence вместо сырых таблиц." ? "Показывать пользователю текущие действия, запреты и следующие данные вместо сырых таблиц."
: "Пересобрать synthesis: нужен понятный doNow/hold для пользователя.", : "Пересобрать синтез: пользователю нужны понятные действия и запреты.",
category: "ux_clarity", category: "ux_clarity",
id: "ux:decision_surface", id: "ux:decision_surface",
message: hasReadableNextStep message: hasReadableNextStep
? "Recommended path содержит doNow и hold; это можно показывать как decision surface." ? "Рекомендованный путь содержит текущие действия и запреты."
: "Recommended path не объясняет пользователю, что делать сейчас и что запрещено.", : "Рекомендованный путь не объясняет пользователю, что делать сейчас и что запрещено.",
severity: hasReadableNextStep ? "low" : "medium", severity: hasReadableNextStep ? "low" : "medium",
status: hasReadableNextStep ? "pass" : "warning", status: hasReadableNextStep ? "pass" : "warning",
title: "Human-readable decision surface" title: "Понятная витрина решения"
}) })
]; ];
} }
@ -365,19 +367,19 @@ function getVerdict(
primaryBlocker: criticalBlocker.title, primaryBlocker: criticalBlocker.title,
score, score,
status: "blocked", status: "blocked",
summary: `Strategy quality blocked: ${criticalBlocker.message}`, summary: `Стратегия заблокирована проверкой качества: ${criticalBlocker.message}`,
title: "Стратегия заблокирована quality gate" title: "Стратегия заблокирована проверкой качества"
}; };
} }
if (failedChecks.length > 0 || score < 80) { if (failedChecks.length > 0 || score < 80) {
return { return {
confidence: "medium", confidence: "medium",
primaryBlocker: failedChecks[0]?.title ?? "Есть warnings до approval.", primaryBlocker: failedChecks[0]?.title ?? "Есть предупреждения до утверждения.",
score, score,
status: "needs_changes", status: "needs_changes",
summary: "Стратегия понятна, но требует исправлений перед human approval.", summary: "Стратегия понятна, но требует исправлений перед ручным утверждением.",
title: "Нужны правки strategy quality" title: "Нужны правки проверки качества"
}; };
} }
@ -386,23 +388,33 @@ function getVerdict(
primaryBlocker: null, primaryBlocker: null,
score, score,
status: "approved", status: "approved",
summary: "Strategy quality gate пройден как read-only analytics layer. Это не разрешение на rewrite/apply.", summary: "Проверка качества пройдена как аналитический слой только для чтения. Это не разрешение на правки или применение.",
title: "Стратегия готова к human review" title: "Стратегия готова к ручной проверке"
}; };
} }
function getVerdictStatusSummary(status: StrategyQualityReviewVerdictStatus) {
const labels: Record<StrategyQualityReviewVerdictStatus, string> = {
approved: "готово к проверке",
blocked: "заблокировано",
needs_changes: "нужны правки"
};
return labels[status];
}
function buildNextActions(contract: Omit<StrategyQualityReviewContract, "nextActions">) { function buildNextActions(contract: Omit<StrategyQualityReviewContract, "nextActions">) {
const actions: string[] = []; const actions: string[] = [];
if (contract.verdict.status === "approved") { if (contract.verdict.status === "approved") {
actions.push("Отдать strategy quality result в human review: подтвердить phased roadmap и blocked actions."); actions.push("Отдать результат проверки качества на ручное подтверждение: проверить фазовый маршрут и запреты.");
actions.push("Следующий аналитический слой: добрать external Webmaster/Метрика/Search Console-style evidence после деплоя."); actions.push("Следующий аналитический слой: добрать внешние данные Webmaster/Метрики/Search Console-style после деплоя.");
} else { } else {
actions.push(`Закрыть blockers: ${contract.blockers.slice(0, 3).join("; ")}.`); actions.push(`Закрыть блокеры: ${contract.blockers.slice(0, 3).join("; ")}.`);
actions.push("Пересобрать seo-strategy.v1 / seo-strategy-synthesis.v1 после исправлений."); actions.push("Пересобрать seo-strategy.v1 / seo-strategy-synthesis.v1 после исправлений.");
} }
actions.push("Rewrite/apply остаётся заблокированным: quality review проверяет стратегию, а не генерирует правки сайта."); actions.push("Правки и применение остаются заблокированы: проверка качества оценивает стратегию, а не генерирует изменения сайта.");
return actions; return actions;
} }
@ -434,7 +446,7 @@ function buildStrategyQualityReviewOutput(
projectId, projectId,
provider: { provider: {
message: message:
"Strategy quality review сохранён deterministic fallback-ом. Следующий слой может заменить judgement ModelProvider-ом, сохранив этот JSON contract.", "Проверка качества стратегии сохранена детерминированным fallback-слоем. Следующий слой может заменить оценку ModelProvider-ом, сохранив этот JSON-контракт.",
mode: "deterministic_fallback", mode: "deterministic_fallback",
modelRequired: true modelRequired: true
}, },
@ -459,7 +471,7 @@ function buildStrategyQualityReviewOutput(
sourceStrategySynthesisRunId: synthesis.runId, sourceStrategySynthesisRunId: synthesis.runId,
sourceTaskId: task.taskId, sourceTaskId: task.taskId,
state: "ready", state: "ready",
summary: `${verdict.status}: ${checks.length} checks, ${approvedSignals.length} passed, ${blockers.length} blockers, ${warnings.length} warnings.`, summary: `${getVerdictStatusSummary(verdict.status)}: проверок ${checks.length}, пройдено ${approvedSignals.length}, блокеров ${blockers.length}, предупреждений ${warnings.length}.`,
verdict, verdict,
warnings warnings
}; };
@ -580,7 +592,7 @@ export async function saveStrategyQualityReviewManual(
projectId, projectId,
output, output,
"codex_manual", "codex_manual",
"Codex manual dev-loop: текущий Codex-сеанс выполнил seo.strategy_quality_review task по правилам, без внешнего provider adapter." "Ручной цикл Codex: текущий сеанс выполнил задачу seo.strategy_quality_review по правилам, без внешнего адаптера провайдера."
); );
const result = await pool.query<StrategyQualityReviewRunRow>( const result = await pool.query<StrategyQualityReviewRunRow>(
` `

View File

@ -139,13 +139,13 @@ function getDefaultPhaseDecision(): StrategySynthesisContract["phaseDecision"] {
return { return {
allowedNow: [], allowedNow: [],
currentPhase: "bootstrap_indexing", currentPhase: "bootstrap_indexing",
hold: ["Не переходить к head terms без site maturity evidence."], hold: ["Не переходить к широким запросам без доказанной зрелости сайта."],
phaseRoadmap: [], phaseRoadmap: [],
prepareNext: [], prepareNext: [],
promotionSignals: ["Собрать strategy и SERP interpretation."], promotionSignals: ["Собрать strategy и SERP interpretation."],
schemaVersion: "seo-phase-decision.v1", schemaVersion: "seo-phase-decision.v1",
summary: "Фазовый roadmap ещё не собран.", summary: "Фазовый roadmap ещё не собран.",
title: "SEO runway не готов" title: "Маршрут SEO не готов"
}; };
} }
@ -181,24 +181,24 @@ function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesi
export function getEmptyStrategySynthesisContract(projectId: string): StrategySynthesisContract { export function getEmptyStrategySynthesisContract(projectId: string): StrategySynthesisContract {
return { return {
analystReview: getDefaultAnalystReview(), analystReview: getDefaultAnalystReview(),
blockedActions: ["rewrite/apply заблокирован до strategy synthesis, quality review и human approval."], blockedActions: ["Правки и применение заблокированы до синтеза стратегии, проверки качества и ручного утверждения."],
dataGaps: [], dataGaps: [],
decisionOptions: [], decisionOptions: [],
evidenceNarrative: [], evidenceNarrative: [],
executiveSummary: { executiveSummary: {
confidence: "low", confidence: "low",
primaryConstraint: "Нет готового strategy synthesis.", primaryConstraint: "Нет готового синтеза стратегии.",
title: "Strategy synthesis ещё не собран", title: "Синтез стратегии ещё не собран",
verdict: "Недостаточно аналитического вывода поверх evidence.", verdict: "Недостаточно аналитического вывода поверх доказательств.",
whyNow: "Сначала нужен seo-strategy.v1 и SERP interpretation." whyNow: "Сначала нужен seo-strategy.v1 и интерпретация SERP."
}, },
phaseDecision: getDefaultPhaseDecision(), phaseDecision: getDefaultPhaseDecision(),
phaseStrategies: [], phaseStrategies: [],
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
nextActions: ["Собрать seo-strategy.v1 и SERP interpretation, затем запустить strategy synthesis."], nextActions: ["Собрать seo-strategy.v1 и интерпретацию SERP, затем запустить синтез стратегии."],
projectId, projectId,
provider: { provider: {
message: "Strategy synthesis не готов: нет полного evidence stack.", message: "Синтез стратегии не готов: нет полного набора доказательств.",
mode: "deterministic_fallback", mode: "deterministic_fallback",
modelRequired: true modelRequired: true
}, },
@ -229,7 +229,7 @@ export function getEmptyStrategySynthesisContract(projectId: string): StrategySy
sourceStrategyGeneratedAt: null, sourceStrategyGeneratedAt: null,
sourceTaskId: null, sourceTaskId: null,
state: "not_ready", state: "not_ready",
summary: "Strategy synthesis ещё не собран." summary: "Синтез стратегии ещё не собран."
}; };
} }
@ -302,11 +302,11 @@ function buildDecisionOptions(strategy: SeoStrategyContract): StrategySynthesisC
id: `synthesis:${option.id}`, id: `synthesis:${option.id}`,
nextStep: nextStep:
option.id === "option:bootstrap_runway" option.id === "option:bootstrap_runway"
? "Собрать phased SEO runway: do-now long-tail, prepare mid-tail, defer head terms до traction/authority." ? "Собрать фазовый маршрут SEO: сейчас низкоконкурентные точные запросы, дальше средние запросы, широкие запросы отложить до появления первых сигналов и авторитета."
: option.id === "option:analytics_first" : option.id === "option:analytics_first"
? "Закрыть data gaps и сделать результат понятным в UX до любых site changes." ? "Закрыть пробелы данных и сделать результат понятным в интерфейсе до любых изменений сайта."
: option.id === "option:market_gap_research" : option.id === "option:market_gap_research"
? "Проверить competitor angles и отделить core SEO от optional expansion branch." ? "Проверить конкурентные углы и отделить основной SEO-маршрут от дополнительной ветки расширения."
: "Стабилизировать подтверждённое ядро и проверить оставшиеся риски.", : "Стабилизировать подтверждённое ядро и проверить оставшиеся риски.",
priority: option.expectedImpact === "high" ? "high" : option.confidence === "low" ? "low" : "medium", priority: option.expectedImpact === "high" ? "high" : option.confidence === "low" ? "low" : "medium",
rationale: option.rationale, rationale: option.rationale,
@ -358,10 +358,10 @@ function buildDataGaps(strategy: SeoStrategyContract): StrategySynthesisContract
id: gap.id, id: gap.id,
nextStep: nextStep:
gap.owner === "ai_layer" gap.owner === "ai_layer"
? "Закрыть через следующий structured model task/manual review." ? "Закрыть через следующую структурированную задачу модели или ручную проверку."
: gap.owner === "external_service" : gap.owner === "external_service"
? "Подключить или собрать внешний evidence source." ? "Подключить или собрать внешний источник доказательств."
: "Отдать на human/product approval.", : "Отдать на ручное или продуктовое утверждение.",
owner: gap.owner, owner: gap.owner,
priority: gap.priority, priority: gap.priority,
title: gap.title, title: gap.title,
@ -389,12 +389,12 @@ function buildPhaseDecision(strategy: SeoStrategyContract): StrategySynthesisCon
schemaVersion: "seo-phase-decision.v1", schemaVersion: "seo-phase-decision.v1",
summary: summary:
strategy.siteMaturity.phase === "bootstrap_indexing" strategy.siteMaturity.phase === "bootstrap_indexing"
? "Сайт в bootstrap/indexing фазе: immediate route должен начинаться с индексируемого long-tail ядра; head terms держим как будущий runway." ? "Сайт в стартовой фазе индексации: текущий маршрут должен начинаться с индексируемого точного ядра; широкие запросы остаются будущей фазой."
: strategy.siteMaturity.reason, : strategy.siteMaturity.reason,
title: title:
strategy.siteMaturity.phase === "bootstrap_indexing" strategy.siteMaturity.phase === "bootstrap_indexing"
? "Сначала bootstrap/indexing, потом рост" ? "Сначала индексация, потом рост"
: "Фаза сайта определяет допустимые targets" : "Фаза сайта определяет допустимые цели"
}; };
} }
@ -412,17 +412,17 @@ function buildRecommendedPath(
confidence: strategy.verdict.confidence, confidence: strategy.verdict.confidence,
doNow: [ doNow: [
strategy.siteMaturity.phase === "bootstrap_indexing" strategy.siteMaturity.phase === "bootstrap_indexing"
? "Собрать bootstrap/indexing runway и не атаковать head terms как immediate action." ? "Собрать стартовый маршрут индексации и не атаковать широкие запросы как текущее действие."
: "Закрепить аналитический вывод как strategy synthesis draft.", : "Закрепить аналитический вывод как черновик синтеза стратегии.",
"Показать пользователю opportunity/risk/data gap без перехода к site changes.", "Показать пользователю возможности, риски и пробелы данных без перехода к изменениям сайта.",
...(highPriorityGaps.length > 0 ? ["Закрыть high-priority gaps до human strategy approval."] : []) ...(highPriorityGaps.length > 0 ? ["Закрыть высокоприоритетные пробелы до ручного утверждения стратегии."] : [])
], ],
expectedImpact: topOption?.expectedImpact ?? "medium", expectedImpact: topOption?.expectedImpact ?? "medium",
hold: [ hold: [
"Не запускать rewrite/apply.", "Не запускать правки или применение.",
"Не считать top/head keywords immediate route без maturity signals.", "Не считать широкие запросы текущим маршрутом без сигналов зрелости.",
"Не добавлять business expansion в core SEO route.", "Не добавлять расширение бизнеса в основной SEO-маршрут.",
"Не считать risky phrases approved без model/human review." "Не считать рискованные фразы утверждёнными без проверки модели или человека."
], ],
id: topOption?.id ?? "path:analytics_first", id: topOption?.id ?? "path:analytics_first",
needsEvidence: strategy.missingEvidence.slice(0, 5).map((gap) => gap.title), needsEvidence: strategy.missingEvidence.slice(0, 5).map((gap) => gap.title),
@ -437,16 +437,16 @@ function buildNextActions(contract: Omit<StrategySynthesisContract, "nextActions
actions.push( actions.push(
contract.phaseDecision.currentPhase === "bootstrap_indexing" contract.phaseDecision.currentPhase === "bootstrap_indexing"
? "Держать immediate strategy в bootstrap/indexing фазе: low-competition exact intent, deferred head terms, promotion signals." ? "Держать текущую стратегию в фазе стартовой индексации: низкоконкурентный точный интент, отложенные широкие запросы, сигналы для перехода дальше."
: `Проверить phase roadmap перед approval: текущая фаза ${contract.phaseDecision.currentPhase}.` : `Проверить фазовый маршрут перед утверждением: текущая фаза ${contract.phaseDecision.currentPhase}.`
); );
if (contract.dataGaps.length > 0) { if (contract.dataGaps.length > 0) {
actions.push(`Закрыть gaps перед approval: ${contract.dataGaps.slice(0, 3).map((gap) => gap.title).join("; ")}.`); actions.push(`Закрыть пробелы перед утверждением: ${contract.dataGaps.slice(0, 3).map((gap) => gap.title).join("; ")}.`);
} }
actions.push("Проверить synthesis как SEO-аналитик: понятность вывода, evidence refs, отсутствие business drift."); actions.push("Проверить синтез как SEO-аналитик: понятность вывода, ссылки на доказательства, отсутствие ухода в расширение бизнеса.");
actions.push("После synthesis переходить к model-review контракту качества, а не к правке сайта."); actions.push("После синтеза переходить к контракту проверки качества, а не к правке сайта.");
return actions; return actions;
} }
@ -471,10 +471,10 @@ function buildStrategySynthesisOutput(
const contractWithoutActions: Omit<StrategySynthesisContract, "nextActions"> = { const contractWithoutActions: Omit<StrategySynthesisContract, "nextActions"> = {
analystReview: getDefaultAnalystReview(), analystReview: getDefaultAnalystReview(),
blockedActions: [ blockedActions: [
"source/write/rewrite/apply недоступны до отдельного diff contract и human approval.", "Изменение исходников, генерация правок и применение недоступны до отдельного diff-контракта и ручного утверждения.",
"head/top keywords недоступны как immediate action для bootstrap/indexing сайта.", "Широкие и самые частотные запросы недоступны как текущее действие для сайта в стартовой фазе индексации.",
"business expansion не входит в core strategy route без отдельной user-approved branch.", "Расширение бизнеса не входит в основной маршрут стратегии без отдельного подтверждения пользователем.",
"model output не может менять Wordstat/SERP факты или придумывать спрос." "Вывод модели не может менять факты Wordstat/SERP или придумывать спрос."
], ],
dataGaps, dataGaps,
decisionOptions, decisionOptions,
@ -483,17 +483,17 @@ function buildStrategySynthesisOutput(
confidence: strategy.verdict.confidence, confidence: strategy.verdict.confidence,
primaryConstraint: primaryConstraint:
strategy.siteMaturity.phase === "bootstrap_indexing" strategy.siteMaturity.phase === "bootstrap_indexing"
? "Нельзя атаковать head/top keywords до индексации, traction и authority signals." ? "Нельзя атаковать широкие и самые частотные запросы до индексации, первых сигналов и авторитета."
: strategy.verdict.primaryConstraint, : strategy.verdict.primaryConstraint,
title: title:
strategy.readiness.strategyConfidenceScore >= 75 strategy.readiness.strategyConfidenceScore >= 75
? "Есть сильная аналитическая база для strategy review" ? "Есть сильная аналитическая база для проверки стратегии"
: "Стратегия требует усиления evidence", : "Стратегия требует усиления доказательств",
verdict: strategy.verdict.summary, verdict: strategy.verdict.summary,
whyNow: whyNow:
strategy.siteMaturity.phase === "bootstrap_indexing" strategy.siteMaturity.phase === "bootstrap_indexing"
? "Рынок уже виден, но сайт ещё не доказал индексацию/traction; нужен phased roadmap вместо немедленной атаки на высокий спрос." ? "Рынок уже виден, но сайт ещё не доказал индексацию и первые сигналы; нужен фазовый маршрут вместо немедленной атаки на высокий спрос."
: "Контекст, спрос, Yandex evidence и SERP interpretation уже собраны в read-only слой; теперь нужен понятный synthesis вместо сырой выдачи." : "Контекст, спрос, данные Yandex и интерпретация SERP уже собраны в слой только для чтения; теперь нужен понятный синтез вместо сырой выдачи."
}, },
phaseDecision, phaseDecision,
phaseStrategies: strategy.phaseStrategies, phaseStrategies: strategy.phaseStrategies,
@ -501,7 +501,7 @@ function buildStrategySynthesisOutput(
projectId, projectId,
provider: { provider: {
message: message:
"Strategy synthesis сохранён deterministic fallback-ом. Следующий слой должен заменить judgement ModelProvider-ом, но сохранить этот JSON contract.", "Синтез стратегии сохранён детерминированным fallback-слоем. Следующий слой должен заменить оценку ModelProvider-ом, но сохранить этот JSON-контракт.",
mode: "deterministic_fallback", mode: "deterministic_fallback",
modelRequired: true modelRequired: true
}, },
@ -522,7 +522,7 @@ function buildStrategySynthesisOutput(
sourceStrategyGeneratedAt: strategy.generatedAt, sourceStrategyGeneratedAt: strategy.generatedAt,
sourceTaskId: task.taskId, sourceTaskId: task.taskId,
state: "ready", state: "ready",
summary: `${decisionOptions.length} strategy options, ${evidenceNarrative.length} evidence narratives, ${riskPosture.length} risks, ${dataGaps.length} data gaps.` summary: `Вариантов стратегии: ${decisionOptions.length}, доказательных выводов: ${evidenceNarrative.length}, рисков: ${riskPosture.length}, пробелов данных: ${dataGaps.length}.`
}; };
return { return {
@ -589,7 +589,7 @@ export async function saveStrategySynthesisManual(
projectId, projectId,
output, output,
"codex_manual", "codex_manual",
"Codex manual dev-loop: текущий Codex-сеанс выполнил seo.strategy_synthesis task по правилам, без внешнего provider adapter." "Ручной цикл Codex: текущий сеанс выполнил задачу seo.strategy_synthesis по правилам, без внешнего адаптера провайдера."
); );
const result = await pool.query<StrategySynthesisRunRow>( const result = await pool.query<StrategySynthesisRunRow>(
` `