Add phase-specific SEO strategy runway
This commit is contained in:
parent
54398f86d7
commit
8df64918f8
|
|
@ -7012,6 +7012,11 @@ export function App() {
|
|||
? (selectedStrategyPhaseIds[project.id] ?? seoStrategy.siteMaturity.phase)
|
||||
: null;
|
||||
const selectedStrategyPhase = seoStrategy?.phasePlan.find((phase) => phase.phase === selectedStrategyPhaseId) ?? null;
|
||||
const selectedPhaseStrategy =
|
||||
seoStrategy?.phaseStrategies.find((phase) => phase.phase === selectedStrategyPhaseId) ?? null;
|
||||
const selectedSynthesisPhaseStrategy =
|
||||
strategySynthesis?.phaseStrategies.find((phase) => phase.phase === selectedStrategyPhaseId) ?? null;
|
||||
const selectedStrategyNarrative = selectedSynthesisPhaseStrategy ?? selectedPhaseStrategy;
|
||||
const isExportingSemantic = exportingSemanticKey?.startsWith(`${project.id}:`) ?? false;
|
||||
const isSelectingPages = selectingPagesProjectId === project.id;
|
||||
const selectedPagesCount = scanSummary ? getSelectedPagesCount(scanSummary) : 0;
|
||||
|
|
@ -9248,29 +9253,35 @@ export function App() {
|
|||
<div>
|
||||
<span>{getSeoPhaseStatusLabel(selectedStrategyPhase.status)}</span>
|
||||
<strong>{selectedStrategyPhase.title}</strong>
|
||||
<p>{selectedStrategyPhase.goal}</p>
|
||||
<p>{selectedPhaseStrategy?.summary ?? selectedStrategyPhase.goal}</p>
|
||||
{selectedPhaseStrategy ? (
|
||||
<small>
|
||||
impact {getSeoStrategyPriorityLabel(selectedPhaseStrategy.expectedImpact)} · confidence{" "}
|
||||
{getSeoStrategyConfidenceLabel(selectedPhaseStrategy.confidence)}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="strategy-phase-detail-grid">
|
||||
<article>
|
||||
<span>можно делать</span>
|
||||
{selectedStrategyPhase.allowedTargets.slice(0, 4).map((target) => (
|
||||
{(selectedPhaseStrategy?.doNow ?? selectedStrategyPhase.allowedTargets).slice(0, 4).map((target) => (
|
||||
<strong key={`allowed:${selectedStrategyPhase.phase}:${target}`}>{target}</strong>
|
||||
))}
|
||||
</article>
|
||||
<article>
|
||||
<span>не сейчас</span>
|
||||
{selectedStrategyPhase.deferredTargets.slice(0, 4).map((target) => (
|
||||
{(selectedPhaseStrategy?.hold ?? selectedStrategyPhase.deferredTargets).slice(0, 4).map((target) => (
|
||||
<strong key={`deferred:${selectedStrategyPhase.phase}:${target}`}>{target}</strong>
|
||||
))}
|
||||
</article>
|
||||
<article>
|
||||
<span>переход дальше</span>
|
||||
{selectedStrategyPhase.promotionSignals.slice(0, 4).map((signal) => (
|
||||
{(selectedPhaseStrategy?.prepareNext ?? selectedStrategyPhase.promotionSignals).slice(0, 4).map((signal) => (
|
||||
<strong key={`signal:${selectedStrategyPhase.phase}:${signal}`}>{signal}</strong>
|
||||
))}
|
||||
</article>
|
||||
</div>
|
||||
<p>{selectedStrategyPhase.risk}</p>
|
||||
<p>{selectedPhaseStrategy?.risks[0] ?? selectedStrategyPhase.risk}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
|
@ -9278,19 +9289,30 @@ export function App() {
|
|||
<div className="market-columns">
|
||||
<section>
|
||||
<div>
|
||||
<strong>SEO runway</strong>
|
||||
<small>Фазы роста: что можно сейчас и что откроется после сигналов.</small>
|
||||
<strong>SEO runway · {selectedStrategyPhase?.title ?? "фаза не выбрана"}</strong>
|
||||
<small>
|
||||
{selectedPhaseStrategy
|
||||
? selectedPhaseStrategy.goal
|
||||
: "Выбери фазу, чтобы увидеть отдельный маршрут действий."}
|
||||
</small>
|
||||
</div>
|
||||
<div className="market-brief-list">
|
||||
{seoStrategy.phasePlan.map((phase) => (
|
||||
<div key={phase.phase}>
|
||||
<span className={phase.status === "current" ? "collected" : "ready_for_collection"}>
|
||||
{getSeoPhaseStatusLabel(phase.status)}
|
||||
</span>
|
||||
<strong>{phase.title}</strong>
|
||||
<small>{phase.goal}</small>
|
||||
<small>Можно: {phase.allowedTargets.slice(0, 2).join(" · ")}</small>
|
||||
<small>Не сейчас: {phase.deferredTargets.slice(0, 2).join(" · ")}</small>
|
||||
{(selectedPhaseStrategy?.doNow ?? selectedStrategyPhase?.allowedTargets ?? []).slice(0, 5).map((action) => (
|
||||
<div key={`phase-runway-now:${selectedStrategyPhaseId}:${action}`}>
|
||||
<span className="collected">now</span>
|
||||
<strong>{action}</strong>
|
||||
</div>
|
||||
))}
|
||||
{(selectedPhaseStrategy?.prepareNext ?? []).slice(0, 4).map((action) => (
|
||||
<div key={`phase-runway-prepare:${selectedStrategyPhaseId}:${action}`}>
|
||||
<span>prepare</span>
|
||||
<strong>{action}</strong>
|
||||
</div>
|
||||
))}
|
||||
{(selectedPhaseStrategy?.hold ?? selectedStrategyPhase?.deferredTargets ?? []).slice(0, 4).map((action) => (
|
||||
<div key={`phase-runway-hold:${selectedStrategyPhaseId}:${action}`}>
|
||||
<span className="not_configured">hold</span>
|
||||
<strong>{action}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -9298,20 +9320,30 @@ export function App() {
|
|||
|
||||
<section>
|
||||
<div>
|
||||
<strong>Переход в следующую фазу</strong>
|
||||
<small>Какие сигналы нужны, чтобы повышать конкуренцию ключей.</small>
|
||||
<strong>Ключи и посадочные</strong>
|
||||
<small>Что именно проверяем в выбранной фазе, без общей таблицы ключей.</small>
|
||||
</div>
|
||||
<div className="market-brief-list">
|
||||
{seoStrategy.siteMaturity.promotionSignals.map((signal) => (
|
||||
<div key={`promotion:${signal}`}>
|
||||
<span>signal</span>
|
||||
<strong>{signal}</strong>
|
||||
{(selectedPhaseStrategy?.keywordMix ?? []).slice(0, 4).map((item) => (
|
||||
<div key={`phase-keyword:${selectedStrategyPhaseId}:${item.label}`}>
|
||||
<span className={item.priority === "high" ? "collected" : "ready_for_collection"}>
|
||||
{getSeoCompetitionTierLabel(item.competitionTier)}
|
||||
</span>
|
||||
<strong>{item.label}</strong>
|
||||
<small>{item.description}</small>
|
||||
</div>
|
||||
))}
|
||||
{seoStrategy.siteMaturity.guardrails.map((guardrail) => (
|
||||
<div key={`guardrail:${guardrail}`}>
|
||||
<span className="not_configured">guardrail</span>
|
||||
<strong>{guardrail}</strong>
|
||||
{(selectedPhaseStrategy?.landingPlan ?? []).slice(0, 4).map((item) => (
|
||||
<div key={`phase-landing:${selectedStrategyPhaseId}:${item.title}:${item.targetPath ?? "none"}`}>
|
||||
<span>{getSeoOpportunityActionLabel(item.action)}</span>
|
||||
<strong>{item.targetPath ?? item.title}</strong>
|
||||
<small>{item.reason}</small>
|
||||
</div>
|
||||
))}
|
||||
{(selectedPhaseStrategy?.nextEvidenceTasks ?? []).slice(0, 3).map((task) => (
|
||||
<div key={`phase-evidence-task:${selectedStrategyPhaseId}:${task}`}>
|
||||
<span className="not_configured">evidence</span>
|
||||
<strong>{task}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -9463,15 +9495,19 @@ export function App() {
|
|||
<div className="market-columns">
|
||||
<section>
|
||||
<div>
|
||||
<strong>{strategySynthesis.phaseDecision.title}</strong>
|
||||
<small>{getSeoMaturityPhaseLabel(strategySynthesis.phaseDecision.currentPhase)}</small>
|
||||
<strong>{selectedStrategyNarrative?.title ?? strategySynthesis.phaseDecision.title}</strong>
|
||||
<small>
|
||||
{selectedStrategyNarrative
|
||||
? getSeoMaturityPhaseLabel(selectedStrategyNarrative.phase)
|
||||
: getSeoMaturityPhaseLabel(strategySynthesis.phaseDecision.currentPhase)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="market-brief-list">
|
||||
<div>
|
||||
<span className="collected">phase</span>
|
||||
<strong>{strategySynthesis.phaseDecision.summary}</strong>
|
||||
<strong>{selectedStrategyNarrative?.summary ?? strategySynthesis.phaseDecision.summary}</strong>
|
||||
</div>
|
||||
{strategySynthesis.phaseDecision.allowedNow.slice(0, 4).map((action) => (
|
||||
{(selectedStrategyNarrative?.doNow ?? strategySynthesis.phaseDecision.allowedNow).slice(0, 4).map((action) => (
|
||||
<div key={`phase-now:${action}`}>
|
||||
<span>now</span>
|
||||
<strong>{action}</strong>
|
||||
|
|
@ -9486,18 +9522,24 @@ export function App() {
|
|||
<small>Что нельзя выдавать как immediate route до зрелости сайта.</small>
|
||||
</div>
|
||||
<div className="market-brief-list">
|
||||
{strategySynthesis.phaseDecision.prepareNext.slice(0, 3).map((action) => (
|
||||
{(selectedStrategyNarrative?.prepareNext ?? strategySynthesis.phaseDecision.prepareNext).slice(0, 3).map((action) => (
|
||||
<div key={`phase-prepare:${action}`}>
|
||||
<span>prepare</span>
|
||||
<strong>{action}</strong>
|
||||
</div>
|
||||
))}
|
||||
{strategySynthesis.phaseDecision.hold.slice(0, 5).map((action) => (
|
||||
{(selectedStrategyNarrative?.hold ?? strategySynthesis.phaseDecision.hold).slice(0, 5).map((action) => (
|
||||
<div key={`phase-hold:${action}`}>
|
||||
<span className="not_configured">hold</span>
|
||||
<strong>{action}</strong>
|
||||
</div>
|
||||
))}
|
||||
{(selectedStrategyNarrative?.nextEvidenceTasks ?? []).slice(0, 3).map((action) => (
|
||||
<div key={`phase-synthesis-evidence:${action}`}>
|
||||
<span className="not_configured">evidence</span>
|
||||
<strong>{action}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1318,6 +1318,36 @@ export type SeoStrategyContract = {
|
|||
promotionSignals: string[];
|
||||
risk: string;
|
||||
}>;
|
||||
phaseStrategies: Array<{
|
||||
phase: SeoStrategyContract["siteMaturity"]["phase"];
|
||||
title: string;
|
||||
status: "current" | "future" | "locked" | "ready";
|
||||
goal: string;
|
||||
summary: string;
|
||||
expectedImpact: "high" | "low" | "medium";
|
||||
confidence: "high" | "low" | "medium";
|
||||
keywordMix: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
competitionTier: "head" | "long_tail" | "mid" | "unknown";
|
||||
evidenceRefs: string[];
|
||||
priority: "high" | "low" | "medium";
|
||||
}>;
|
||||
landingPlan: Array<{
|
||||
title: string;
|
||||
targetPath: string | null;
|
||||
action: "defer" | "do_now" | "prepare";
|
||||
reason: string;
|
||||
evidenceRefs: string[];
|
||||
priority: "high" | "low" | "medium";
|
||||
}>;
|
||||
doNow: string[];
|
||||
prepareNext: string[];
|
||||
hold: string[];
|
||||
evidenceRefs: string[];
|
||||
risks: string[];
|
||||
nextEvidenceTasks: string[];
|
||||
}>;
|
||||
evidence: {
|
||||
context: {
|
||||
status: "missing" | "needs_review" | "ready";
|
||||
|
|
@ -1465,6 +1495,7 @@ export type StrategySynthesisContract = {
|
|||
promotionSignals: string[];
|
||||
phaseRoadmap: SeoStrategyContract["phasePlan"];
|
||||
};
|
||||
phaseStrategies: SeoStrategyContract["phaseStrategies"];
|
||||
recommendedPath: {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -73,6 +73,36 @@ export type SeoStrategyContract = {
|
|||
promotionSignals: string[];
|
||||
risk: string;
|
||||
}>;
|
||||
phaseStrategies: Array<{
|
||||
phase: SeoMaturityPhase;
|
||||
title: string;
|
||||
status: "current" | "future" | "locked" | "ready";
|
||||
goal: string;
|
||||
summary: string;
|
||||
expectedImpact: SeoStrategyPriority;
|
||||
confidence: SeoStrategyConfidence;
|
||||
keywordMix: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
competitionTier: SeoCompetitionTier;
|
||||
evidenceRefs: string[];
|
||||
priority: SeoStrategyPriority;
|
||||
}>;
|
||||
landingPlan: Array<{
|
||||
title: string;
|
||||
targetPath: string | null;
|
||||
action: SeoOpportunityAction;
|
||||
reason: string;
|
||||
evidenceRefs: string[];
|
||||
priority: SeoStrategyPriority;
|
||||
}>;
|
||||
doNow: string[];
|
||||
prepareNext: string[];
|
||||
hold: string[];
|
||||
evidenceRefs: string[];
|
||||
risks: string[];
|
||||
nextEvidenceTasks: string[];
|
||||
}>;
|
||||
evidence: {
|
||||
context: {
|
||||
status: "missing" | "needs_review" | "ready";
|
||||
|
|
@ -383,6 +413,264 @@ function buildPhasePlan(siteMaturity: SeoStrategyContract["siteMaturity"]): SeoS
|
|||
});
|
||||
}
|
||||
|
||||
function getPhaseAllowedTiers(phase: SeoMaturityPhase): SeoCompetitionTier[] {
|
||||
if (phase === "bootstrap_indexing") {
|
||||
return ["long_tail", "unknown"];
|
||||
}
|
||||
|
||||
if (phase === "traction") {
|
||||
return ["long_tail", "mid", "unknown"];
|
||||
}
|
||||
|
||||
if (phase === "growth") {
|
||||
return ["long_tail", "mid", "head", "unknown"];
|
||||
}
|
||||
|
||||
return ["head", "mid", "long_tail", "unknown"];
|
||||
}
|
||||
|
||||
function getPhaseSummary(phase: SeoMaturityPhase, opportunities: SeoStrategyContract["opportunities"]) {
|
||||
const phaseOpportunityCount = opportunities.length;
|
||||
|
||||
if (phase === "bootstrap_indexing") {
|
||||
return `Стартовая стратегия для нового/нулевого сайта: закрепить понятный индексируемый смысл, выбрать low-competition фразы и не расходовать ресурс на head terms. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`;
|
||||
}
|
||||
|
||||
if (phase === "traction") {
|
||||
return `Стратегия первых сигналов: расширять только те кластеры, где уже виден target-fit, индексируемость и первые признаки спроса. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`;
|
||||
}
|
||||
|
||||
if (phase === "growth") {
|
||||
return `Стратегия роста: усиливать подтверждённые посадочные, добавлять support-контент и готовить конкурентные кластеры через SERP evidence. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`;
|
||||
}
|
||||
|
||||
return `Стратегия authority/head terms: атаковать широкие конкурентные запросы только после индексации, traction, external trust и performance signals. Подходящих opportunity сейчас: ${phaseOpportunityCount}.`;
|
||||
}
|
||||
|
||||
function getPhaseDefaultTasks(
|
||||
phase: SeoMaturityPhase,
|
||||
missingEvidence: SeoStrategyContract["missingEvidence"]
|
||||
): Pick<SeoStrategyContract["phaseStrategies"][number], "doNow" | "hold" | "nextEvidenceTasks" | "prepareNext"> {
|
||||
const highPriorityEvidence = missingEvidence
|
||||
.filter((evidence) => evidence.priority === "high")
|
||||
.slice(0, 3)
|
||||
.map((evidence) => evidence.title);
|
||||
|
||||
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 сайта."
|
||||
],
|
||||
nextEvidenceTasks: highPriorityEvidence.length
|
||||
? highPriorityEvidence
|
||||
: ["SERP top-10 для стартовых фраз", "Webmaster/indexation после deploy", "первые impressions/CTR после индексации"],
|
||||
prepareNext: [
|
||||
"Подготовить mid-tail backlog для traction.",
|
||||
"Собрать SERP page type evidence по будущим growth clusters.",
|
||||
"Зафиксировать promotion signals для перехода к traction."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (phase === "traction") {
|
||||
return {
|
||||
doNow: [
|
||||
"Расширить exact/mid-tail кластеры с подтверждённым target-fit.",
|
||||
"Связать запросы с посадочными и evidence refs.",
|
||||
"Проверить, что CTR/impressions не конфликтуют с выбранным интентом."
|
||||
],
|
||||
hold: [
|
||||
"Не включать самые конкурентные head terms без authority signals.",
|
||||
"Не плодить посадочные без SERP page-type evidence.",
|
||||
"Не переписывать контент без approved page/field plan."
|
||||
],
|
||||
nextEvidenceTasks: highPriorityEvidence.length
|
||||
? highPriorityEvidence
|
||||
: ["SERP intent для mid-tail", "первые query/page signals", "CTR по стартовым кластерам"],
|
||||
prepareNext: [
|
||||
"Готовить support-контент для growth clusters.",
|
||||
"Уточнить internal linking между подтверждёнными посадочными.",
|
||||
"Накопить данные для uplift в growth."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
if (phase === "growth") {
|
||||
return {
|
||||
doNow: [
|
||||
"Усилить посадочные с доказанным спросом и target-fit.",
|
||||
"Добавить support layers вокруг core clusters.",
|
||||
"Использовать competitor gaps только после SERP interpretation."
|
||||
],
|
||||
hold: [
|
||||
"Не идти в authority/head terms без устойчивой динамики.",
|
||||
"Не смешивать core SEO route и optional business expansion.",
|
||||
"Не добавлять страницы ради частотности без page role."
|
||||
],
|
||||
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."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function buildPhaseStrategies(input: {
|
||||
missingEvidence: SeoStrategyContract["missingEvidence"];
|
||||
opportunities: SeoStrategyContract["opportunities"];
|
||||
phasePlan: SeoStrategyContract["phasePlan"];
|
||||
risks: SeoStrategyContract["risks"];
|
||||
siteMaturity: SeoStrategyContract["siteMaturity"];
|
||||
}): SeoStrategyContract["phaseStrategies"] {
|
||||
return input.phasePlan.map((phasePlanItem) => {
|
||||
const allowedTiers = getPhaseAllowedTiers(phasePlanItem.phase);
|
||||
const phaseRank = getPhaseRank(phasePlanItem.phase);
|
||||
const phaseOpportunities = 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 earliestRank <= phaseRank && allowedTiers.includes(opportunity.competitionTier);
|
||||
})
|
||||
.slice(0, 8);
|
||||
const topOpportunity = phaseOpportunities[0] ?? input.opportunities[0] ?? null;
|
||||
const evidenceRefs = Array.from(
|
||||
new Set(phaseOpportunities.flatMap((opportunity) => opportunity.evidenceRefs).slice(0, 16))
|
||||
);
|
||||
const defaultTasks = getPhaseDefaultTasks(phasePlanItem.phase, input.missingEvidence);
|
||||
const phaseRisks = input.risks
|
||||
.filter((risk) => {
|
||||
if (phasePlanItem.phase === "bootstrap_indexing") {
|
||||
return risk.id.includes("zero_site") || risk.id.includes("serp") || risk.level === "critical";
|
||||
}
|
||||
|
||||
if (phasePlanItem.phase === "authority_head_terms") {
|
||||
return risk.id.includes("head") || risk.id.includes("authority") || risk.level !== "medium";
|
||||
}
|
||||
|
||||
return risk.level !== "medium" || risk.id.includes("serp");
|
||||
})
|
||||
.slice(0, 5)
|
||||
.map((risk) => `${risk.title}: ${risk.mitigation}`);
|
||||
|
||||
return {
|
||||
confidence: topOpportunity?.confidence ?? input.siteMaturity.confidence,
|
||||
doNow: [
|
||||
...defaultTasks.doNow,
|
||||
...phaseOpportunities
|
||||
.filter((opportunity) => opportunity.timing.action === "do_now")
|
||||
.slice(0, 3)
|
||||
.map((opportunity) => `${opportunity.title}: ${opportunity.recommendation}`)
|
||||
].slice(0, 6),
|
||||
evidenceRefs,
|
||||
expectedImpact:
|
||||
phasePlanItem.phase === "authority_head_terms"
|
||||
? "high"
|
||||
: phasePlanItem.phase === "growth" || phasePlanItem.phase === "traction"
|
||||
? "medium"
|
||||
: "low",
|
||||
goal: phasePlanItem.goal,
|
||||
hold: [
|
||||
...defaultTasks.hold,
|
||||
...phaseOpportunities
|
||||
.filter((opportunity) => opportunity.timing.action === "defer")
|
||||
.slice(0, 3)
|
||||
.map((opportunity) => `${opportunity.title}: ${opportunity.timing.reason}`)
|
||||
].slice(0, 7),
|
||||
keywordMix:
|
||||
phaseOpportunities.length > 0
|
||||
? phaseOpportunities.slice(0, 5).map((opportunity) => ({
|
||||
competitionTier: opportunity.competitionTier,
|
||||
description: `${opportunity.demand.totalFrequency} demand · ${opportunity.timing.reason}`,
|
||||
evidenceRefs: opportunity.evidenceRefs.slice(0, 6),
|
||||
label: opportunity.demand.topPhrase ?? opportunity.title,
|
||||
priority: opportunity.priority
|
||||
}))
|
||||
: [
|
||||
{
|
||||
competitionTier: allowedTiers[0] ?? "unknown",
|
||||
description: "Недостаточно очищенных фраз для этой фазы: сначала нужен keyword cleaning/SERP evidence.",
|
||||
evidenceRefs: [],
|
||||
label: phasePlanItem.allowedTargets[0] ?? "нет фраз",
|
||||
priority: "medium"
|
||||
}
|
||||
],
|
||||
landingPlan:
|
||||
phaseOpportunities.length > 0
|
||||
? phaseOpportunities.slice(0, 5).map((opportunity) => ({
|
||||
action: opportunity.timing.action,
|
||||
evidenceRefs: opportunity.evidenceRefs.slice(0, 6),
|
||||
priority: opportunity.priority,
|
||||
reason: opportunity.whyItMatters,
|
||||
targetPath: opportunity.targetPath,
|
||||
title: opportunity.title
|
||||
}))
|
||||
: [
|
||||
{
|
||||
action: "prepare",
|
||||
evidenceRefs: [],
|
||||
priority: "medium",
|
||||
reason: "Нужно сначала получить очищенную карту ключей и SERP target-fit.",
|
||||
targetPath: null,
|
||||
title: "Посадочные пока не доказаны"
|
||||
}
|
||||
],
|
||||
nextEvidenceTasks: defaultTasks.nextEvidenceTasks.slice(0, 5),
|
||||
phase: phasePlanItem.phase,
|
||||
prepareNext: [
|
||||
...defaultTasks.prepareNext,
|
||||
...phaseOpportunities
|
||||
.filter((opportunity) => opportunity.timing.action === "prepare")
|
||||
.slice(0, 3)
|
||||
.map((opportunity) => `${opportunity.title}: ${opportunity.timing.promotionSignal}`)
|
||||
].slice(0, 6),
|
||||
risks: phaseRisks.length > 0 ? phaseRisks : [phasePlanItem.risk],
|
||||
status: phasePlanItem.status,
|
||||
summary: getPhaseSummary(phasePlanItem.phase, phaseOpportunities),
|
||||
title: phasePlanItem.title
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getProviderNextStep(provider: EvidenceProvider) {
|
||||
if (provider.status === "connected") {
|
||||
return "Использовать как evidence source в стратегии и показывать дату/объём сбора.";
|
||||
|
|
@ -999,6 +1287,13 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
|||
siteMaturity,
|
||||
yandexEvidence
|
||||
});
|
||||
const phaseStrategies = buildPhaseStrategies({
|
||||
missingEvidence,
|
||||
opportunities,
|
||||
phasePlan,
|
||||
risks,
|
||||
siteMaturity
|
||||
});
|
||||
const strategyConfidenceScore = getStrategyScore({
|
||||
contextReady,
|
||||
keywordMap,
|
||||
|
|
@ -1047,6 +1342,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
|||
},
|
||||
siteMaturity,
|
||||
phasePlan,
|
||||
phaseStrategies,
|
||||
evidence: {
|
||||
context,
|
||||
demand,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export type StrategyQualityReviewModelTaskContract = {
|
|||
primaryConstraint: string;
|
||||
siteMaturity: SeoStrategyContract["siteMaturity"];
|
||||
phasePlan: SeoStrategyContract["phasePlan"];
|
||||
phaseStrategies: SeoStrategyContract["phaseStrategies"];
|
||||
topOpportunities: SeoStrategyContract["opportunities"];
|
||||
risks: SeoStrategyContract["risks"];
|
||||
missingEvidence: SeoStrategyContract["missingEvidence"];
|
||||
|
|
@ -32,6 +33,7 @@ export type StrategyQualityReviewModelTaskContract = {
|
|||
state: StrategySynthesisContract["state"];
|
||||
runId: string | null;
|
||||
phaseDecision: StrategySynthesisContract["phaseDecision"];
|
||||
phaseStrategies: StrategySynthesisContract["phaseStrategies"];
|
||||
recommendedPath: StrategySynthesisContract["recommendedPath"];
|
||||
decisionOptions: StrategySynthesisContract["decisionOptions"];
|
||||
evidenceNarrative: StrategySynthesisContract["evidenceNarrative"];
|
||||
|
|
@ -117,6 +119,7 @@ export function buildStrategyQualityReviewModelTask(
|
|||
missingEvidence: strategy.missingEvidence.slice(0, 12),
|
||||
nextActions: strategy.nextActions.slice(0, 10),
|
||||
phasePlan: strategy.phasePlan,
|
||||
phaseStrategies: strategy.phaseStrategies,
|
||||
primaryConstraint: strategy.verdict.primaryConstraint,
|
||||
risks: strategy.risks.slice(0, 12),
|
||||
schemaVersion: "seo-strategy.v1",
|
||||
|
|
@ -132,6 +135,7 @@ export function buildStrategyQualityReviewModelTask(
|
|||
evidenceNarrative: strategySynthesis.evidenceNarrative.slice(0, 10),
|
||||
nextActions: strategySynthesis.nextActions.slice(0, 10),
|
||||
phaseDecision: strategySynthesis.phaseDecision,
|
||||
phaseStrategies: strategySynthesis.phaseStrategies,
|
||||
recommendedPath: strategySynthesis.recommendedPath,
|
||||
riskPosture: strategySynthesis.riskPosture.slice(0, 10),
|
||||
runId: strategySynthesis.runId,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export type StrategySynthesisContract = {
|
|||
promotionSignals: string[];
|
||||
phaseRoadmap: SeoStrategyContract["phasePlan"];
|
||||
};
|
||||
phaseStrategies: SeoStrategyContract["phaseStrategies"];
|
||||
recommendedPath: {
|
||||
id: string;
|
||||
title: string;
|
||||
|
|
@ -172,7 +173,8 @@ function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesi
|
|||
errorMessage: row.error_message,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
analystReview: row.output.analystReview ?? getDefaultAnalystReview(),
|
||||
phaseDecision: row.output.phaseDecision ?? getDefaultPhaseDecision()
|
||||
phaseDecision: row.output.phaseDecision ?? getDefaultPhaseDecision(),
|
||||
phaseStrategies: row.output.phaseStrategies ?? []
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +193,7 @@ export function getEmptyStrategySynthesisContract(projectId: string): StrategySy
|
|||
whyNow: "Сначала нужен seo-strategy.v1 и SERP interpretation."
|
||||
},
|
||||
phaseDecision: getDefaultPhaseDecision(),
|
||||
phaseStrategies: [],
|
||||
generatedAt: new Date().toISOString(),
|
||||
nextActions: ["Собрать seo-strategy.v1 и SERP interpretation, затем запустить strategy synthesis."],
|
||||
projectId,
|
||||
|
|
@ -265,6 +268,7 @@ function normalizeOutput(
|
|||
mode: providerMode,
|
||||
modelRequired: true
|
||||
},
|
||||
phaseStrategies: output.phaseStrategies ?? [],
|
||||
readiness: {
|
||||
confidenceScore: output.readiness.confidenceScore,
|
||||
dataGapCount: output.dataGaps.length,
|
||||
|
|
@ -492,6 +496,7 @@ function buildStrategySynthesisOutput(
|
|||
: "Контекст, спрос, Yandex evidence и SERP interpretation уже собраны в read-only слой; теперь нужен понятный synthesis вместо сырой выдачи."
|
||||
},
|
||||
phaseDecision,
|
||||
phaseStrategies: strategy.phaseStrategies,
|
||||
generatedAt: new Date().toISOString(),
|
||||
projectId,
|
||||
provider: {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export type StrategySynthesisModelTaskContract = {
|
|||
primaryConstraint: string;
|
||||
siteMaturity: SeoStrategyContract["siteMaturity"];
|
||||
phasePlan: SeoStrategyContract["phasePlan"];
|
||||
phaseStrategies: SeoStrategyContract["phaseStrategies"];
|
||||
missingEvidence: SeoStrategyContract["missingEvidence"];
|
||||
topOpportunities: SeoStrategyContract["opportunities"];
|
||||
risks: SeoStrategyContract["risks"];
|
||||
|
|
@ -106,6 +107,7 @@ export function buildStrategySynthesisModelTask(
|
|||
strategy: {
|
||||
confidence: strategy.verdict.confidence,
|
||||
phasePlan: strategy.phasePlan,
|
||||
phaseStrategies: strategy.phaseStrategies,
|
||||
missingEvidence: strategy.missingEvidence.slice(0, 10),
|
||||
nextActions: strategy.nextActions.slice(0, 8),
|
||||
primaryConstraint: strategy.verdict.primaryConstraint,
|
||||
|
|
@ -126,6 +128,7 @@ export function buildStrategySynthesisModelTask(
|
|||
"projectId",
|
||||
"sourceStrategyGeneratedAt",
|
||||
"phaseDecision",
|
||||
"phaseStrategies",
|
||||
"executiveSummary",
|
||||
"recommendedPath",
|
||||
"decisionOptions",
|
||||
|
|
|
|||
Loading…
Reference in New Issue