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

View File

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

View File

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