diff --git a/seo_mode/seo_mode/SEO-farm/STAGE_5_8_UX_CONTRACT.md b/seo_mode/seo_mode/SEO-farm/STAGE_5_8_UX_CONTRACT.md new file mode 100644 index 0000000..b2c77c6 --- /dev/null +++ b/seo_mode/seo_mode/SEO-farm/STAGE_5_8_UX_CONTRACT.md @@ -0,0 +1,79 @@ +# NDC SEO mod: UX contract стадий 5-8 + +Этот документ фиксирует продуктовый смысл середины SEO-pipeline до model review. Цель: оставить Dev Mode доказательным, но не превращать GUI в сырую консоль SEO-инженера. + +## Stage 5: Рыночный спрос + +Вопрос пользователя: есть ли реальный поисковый спрос по темам сайта? + +Основная ценность: +- показать, какие semantic seeds подтверждены внешним market evidence; +- показать, какие фразы слабые, шумные, related-only или пустые; +- отделить deterministic site meaning от реального спроса; +- подготовить только evidence-backed inputs для SEO-плана страниц. + +Dev-details могут показывать Wordstat job, provider readiness, raw rows и external-service gaps. Основной экран не должен выглядеть так, будто строки Wordstat уже являются финальными SEO-решениями. + +Выход этапа: +- подтвержденные сигналы спроса; +- фразы, требующие проверки; +- фразы, которые нельзя двигать дальше; +- недостающие evidence для уверенного решения. + +## Stage 6: SEO-план страниц + +Вопрос пользователя: что именно SEO-модуль будет улучшать, где и почему? + +Основная ценность: +- разложить approved phrases по страницам, секциям и полям; +- определить роль: главный интент, дополнительный, поддержка или проверка; +- задать лимиты естественного использования; +- сохранить причину и evidence-link для каждого решения; +- собрать контролируемый contract перед diff/model review. + +Это не ручная таблица ключей. Пользователь подтверждает или корректирует предложенные назначения. + +Выход этапа: +- page-level SEO plan; +- field-level bindings: title, description, H1, body section; +- materialization queue для planned pages, которых нет в scan; +- blockers перед rewrite diff. + +## Stage 7: Model review + +Вопрос пользователя: корректны ли предложенные изменения текста, не уехал ли смысл и есть ли польза? + +Основная ценность: +- ревьюить model-generated variants только после approved SEO-плана; +- показывать before/after diff по полям и секциям; +- объяснять, какой intent или keyword закрывает каждое изменение; +- держать в контексте forbidden claims, project ontology, tone и style limits; +- требовать human acceptance перед validation/apply. + +Выбор модели, provider, plugins и runtime намеренно не решается здесь. Это обсуждаем отдельно только когда diff contract и review interface будут готовы. + +Выход этапа: +- accepted/rejected/edited rewrite variants; +- unresolved model risks; +- final working draft candidates для validation. + +## Stage 8: Quality gate + +Вопрос пользователя: можно ли безопасно применять эти изменения? + +Основная ценность: +- блокировать apply на hard SEO, meaning, style или safety errors; +- отделять soft warnings от blockers; +- проверять, что не появились unsupported claims или legacy context; +- проверять anti-spam, ru-text, structure и pending diff state; +- давать ясный final apply readiness verdict. + +Выход этапа: +- can apply / cannot apply; +- hard blockers; +- accepted warnings; +- changeset candidate для dry-run/apply. + +## Product rule + +Dev Mode может показывать raw data, но каждый видимый stage сначала должен отвечать на пользовательское решение. Raw evidence уходит в drill-down details, а не становится главной ценностью экрана. diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index bc4535d..e003687 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -15,6 +15,8 @@ import { Image, KeyRound, Loader2, + Maximize2, + Minimize2, Plug, Plus, RefreshCw, @@ -24,41 +26,104 @@ import { Server, Trash2, UploadCloud, + UserCircle, X } from "lucide-react"; -import type { ChangeEvent, DragEvent, FormEvent } from "react"; +import type { CSSProperties, ChangeEvent, DragEvent, FormEvent } from "react"; import { useEffect, useRef, useState } from "react"; +import { FunnelChart as BklitFunnelChart } from "@bklitui/charts/funnel-chart"; +import { Grid as BklitGrid } from "@bklitui/charts/grid"; +import { HeatmapCells as BklitHeatmapCells } from "@bklitui/charts/heatmap/heatmap-cells"; +import { HeatmapChart as BklitHeatmapChart } from "@bklitui/charts/heatmap/heatmap-chart"; import { + HeatmapInteractionBoundary as BklitHeatmapInteractionBoundary, + HeatmapInteractionProvider as BklitHeatmapInteractionProvider +} from "@bklitui/charts/heatmap/heatmap-context"; +import { RadarArea as BklitRadarArea } from "@bklitui/charts/radar-area"; +import { RadarAxis as BklitRadarAxis } from "@bklitui/charts/radar-axis"; +import { RadarChart as BklitRadarChart } from "@bklitui/charts/radar-chart"; +import { RadarGrid as BklitRadarGrid } from "@bklitui/charts/radar-grid"; +import { RadarLabels as BklitRadarLabels } from "@bklitui/charts/radar-labels"; +import { Ring as BklitRing } from "@bklitui/charts/ring"; +import { RingCenter as BklitRingCenter } from "@bklitui/charts/ring-center"; +import { RingChart as BklitRingChart } from "@bklitui/charts/ring-chart"; +import { Scatter as BklitScatter } from "@bklitui/charts/scatter"; +import { ScatterChart as BklitScatterChart } from "@bklitui/charts/scatter-chart"; +import { XAxis as BklitXAxis } from "@bklitui/charts/x-axis"; +import { + applyMaterializationAction, + approveKeywordMapDecisions, copyProject, deleteProject, downloadSemanticAnalysisExport, fetchExternalServiceSettings, + fetchModelProviderStatus, fetchHealth, + fetchLatestKeywordCleaning, + fetchLatestKeywordMap, + fetchLatestMaterialization, fetchLatestMarketEnrichment, + fetchLatestProjectOntology, + fetchLatestRewriteDiff, + fetchLatestRewritePlan, fetchLatestSemanticAnalysis, + fetchLatestSeoContextReview, fetchLatestProjectScan, + fetchLatestSeoStrategy, + fetchLatestSerpInterpretation, + fetchLatestStrategyQualityReview, + fetchLatestStrategySynthesis, + fetchLatestYandexEvidence, fetchProjects, getPreviewPageUrl, importBrowserFolder, openPageWorkspace, + probeYandexAiStudioCredential, renameProject, resetPageWorkspace, runMarketEnrichment, runProjectScan, runSemanticAnalysis, + runYandexEvidence, + runSerpInterpretation, + runStrategyQualityReview, + runStrategySynthesis, + runSeoContextReview, + runSeoModelTask, savePageWorkspace, updateExternalServiceSettings, + updateProjectOntologyApproval, + updateProjectOntologyConfirmations, updateProjectPageSelection, type BrowserFolderFileInput, type ExternalServiceSettings, type ExternalServiceUpdateInput, type HealthResponse, + type KeywordCleaningContract, + type KeywordMapContract, + type MaterializationActionInput, + type MaterializationContract, type MarketEnrichmentContract, type PageWorkspace, + type YandexAiStudioProbe, type ProjectScanSummary, type ProjectSummary, + type ProjectOntologyApprovalStatus, + type ProjectOntologyVersion, + type RewriteDiffContract, + type RewritePlanContract, + type SeoContextReviewRun, + type SeoStrategyContract, + type SeoModelProviderStatusContract, + type SeoModelTaskRun, + type SeoModelTaskType, + type SeoNormalizationContract, + type SerpInterpretationContract, type SemanticAnalysisRun, - type SemanticExportFormat + type SemanticExportFormat, + type StrategyQualityReviewContract, + type StrategySynthesisContract, + type YandexEvidenceContract } from "./api"; import ProjectScopeFlow from "./ProjectScopeFlow"; import "./styles.css"; @@ -67,11 +132,11 @@ const pipeline = [ "Источник проекта", "Конфигурация проекта", "Рабочая зона страницы", - "Семантика", - "Сбор частотности", - "Карта ключей", - "Рерайт", - "Валидация", + "Контекст сайта", + "Рыночный спрос", + "SEO-план", + "Стратегия", + "Проверка качества", "Применение" ]; @@ -96,37 +161,69 @@ const stageMeta = [ description: "Открываем preview, секции страницы и draft-редактор без изменения исходного сайта." }, { - title: "Семантический анализ", - sectionTitle: "SEO-аналитика", - description: "Считаем покрытие продуктовой онтологии NodeDC, legacy-мусор, gaps и стартовые сиды." + title: "Контекст сайта", + sectionTitle: "Анализ контекста", + description: "Собираем, что сайт реально говорит, где уверенность слабая и какие фразы можно проверять рынком." }, { - title: "Сбор частотности", - sectionTitle: "Сбор частотности", - description: "Будущий этап проверки сидов и ключей через частотность." + title: "Рыночный спрос", + sectionTitle: "Рыночный спрос", + description: "Проверяем, есть ли внешний спрос по темам сайта, и отделяем подтвержденные фразы от шума." }, { - title: "Карта ключей", - sectionTitle: "Карта ключей", - description: "Будущий этап распределения ключей по страницам и секциям." + title: "SEO-план страниц", + sectionTitle: "SEO-план страниц", + description: "Фиксируем, что улучшать на страницах: поле, роль фразы, лимит и причину решения." }, { - title: "Рерайт", - sectionTitle: "Рерайт", - description: "Будущий этап подготовки правок текста и meta-полей." + title: "Стратегия", + sectionTitle: "SEO-стратегия", + description: "Собираем доказанную карту возможностей, рисков и недостающих внешних данных без правки сайта." }, { - title: "Валидация", - sectionTitle: "Валидация", - description: "Будущий этап проверки качества изменений перед применением." + title: "Проверка качества", + sectionTitle: "Проверка качества", + description: "Финальный контроль перед применением: смысл, переспам, стиль, факты, правки и блокеры." }, { title: "Применение", sectionTitle: "Применение", - description: "Будущий безопасный Apply с diff и контролем конфликтов." + description: "Будущее безопасное применение правок с контролем конфликтов." } ]; +const stageDecisionFrames: Partial< + Record< + number, + { + gate: string; + question: string; + result: string; + } + > +> = { + 4: { + gate: "Дальше идут только фразы с доказательствами.", + question: "Есть ли реальный спрос по темам сайта?", + result: "Подтвержденные фразы, слабые сигналы, шум и недостающие проверки." + }, + 5: { + gate: "До стратегии нужен зафиксированный план страниц, спроса и evidence.", + question: "Что именно улучшать на страницах и почему?", + result: "Страница, роль фразы, причина, доказательства и ограничения по данным." + }, + 6: { + gate: "Сайт не меняем: сначала доказываем полезность стратегии.", + question: "Где есть рост, где риск, каких данных не хватает?", + result: "Opportunity map, evidence gaps, риски и варианты следующей SEO-стратегии." + }, + 7: { + gate: "Применение невозможно без зелёного вердикта проверки.", + question: "Можно ли применять будущие изменения?", + result: "Критичные блокеры, предупреждения, проверка смысла/стиля и готовность к применению." + } +}; + const AUDIT_ISSUE_PAGE_SIZE = 8; const NOTICE_AUTO_HIDE_MS = 8000; @@ -148,6 +245,1790 @@ function formatCount(value: number | null) { return value === null ? "n/a" : numberFormatter.format(value); } +type SeoChartTone = "good" | "neutral" | "problem" | "warning"; + +type SeoChartDatum = { + label: string; + value: number; + detail?: string; + tone?: SeoChartTone; +}; + +type SeoScatterPoint = { + detail?: string; + id: string; + label: string; + size?: number; + tone?: SeoChartTone; + x: number; + y: number; +}; + +type SeoMatrixDatum = { + columns: Array<{ key: string; label: string }>; + rows: Array<{ + cells: Record; + detail?: string; + key: string; + label: string; + }>; +}; + +type SeoNormalizationSeedView = SeoNormalizationContract["seedStrategy"][number]; +type KeywordCleaningItemView = KeywordCleaningContract["items"][number]; +type KeywordMapItemView = KeywordMapContract["items"][number]; +type KeywordPagePlanStatus = "blocked" | "ready" | "review"; + +function clampChartValue(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min)); +} + +function getChartPercent(value: number, max: number) { + if (max <= 0) { + return 0; + } + + return clampChartValue(Math.round((value / max) * 100), 0, 100); +} + +function getChartToneClass(tone: SeoChartTone | undefined) { + return `tone-${tone ?? "neutral"}`; +} + +function getChartToneColor(tone: SeoChartTone | undefined) { + if (tone === "good") return "#2f6b4f"; + if (tone === "warning") return "#b2771f"; + if (tone === "problem") return "#8b2f2f"; + return "#536879"; +} + +function getSeoSeedReviewShortLabel(seed: SeoNormalizationSeedView) { + if (seed.review.status === "auto_approved") { + return seed.review.sendToSerp ? "approved · Wordstat + SERP" : "approved · Wordstat"; + } + + if (seed.review.status === "rejected") { + return "отсечено"; + } + + return "нужна проверка"; +} + +function getSemanticScoreBars(analysis: SemanticAnalysisRun): SeoChartDatum[] { + return [ + { + detail: "общая готовность", + label: "Покрытие", + tone: analysis.summary.coverageScore >= 70 ? "good" : analysis.summary.coverageScore >= 45 ? "warning" : "problem", + value: analysis.summary.coverageScore + }, + { + detail: "лексика", + label: "Лексика", + tone: + (analysis.summary.lexicalCoverageScore ?? analysis.summary.coverageScore) >= 70 + ? "good" + : (analysis.summary.lexicalCoverageScore ?? analysis.summary.coverageScore) >= 45 + ? "warning" + : "problem", + value: analysis.summary.lexicalCoverageScore ?? analysis.summary.coverageScore + }, + { + detail: "доказательность", + label: "Опора", + tone: + (analysis.summary.evidenceScore ?? analysis.summary.coverageScore) >= 70 + ? "good" + : (analysis.summary.evidenceScore ?? analysis.summary.coverageScore) >= 45 + ? "warning" + : "problem", + value: analysis.summary.evidenceScore ?? analysis.summary.coverageScore + }, + { + detail: "интенты", + label: "Интенты", + tone: + (analysis.summary.intentCoverageScore ?? analysis.summary.coverageScore) >= 70 + ? "good" + : (analysis.summary.intentCoverageScore ?? analysis.summary.coverageScore) >= 45 + ? "warning" + : "problem", + value: analysis.summary.intentCoverageScore ?? analysis.summary.coverageScore + }, + { + detail: "коммерческий слой", + label: "Коммерция", + tone: + (analysis.summary.commercialReadinessScore ?? analysis.summary.coverageScore) >= 70 + ? "good" + : (analysis.summary.commercialReadinessScore ?? analysis.summary.coverageScore) >= 45 + ? "warning" + : "problem", + value: analysis.summary.commercialReadinessScore ?? analysis.summary.coverageScore + } + ]; +} + +function getSemanticClusterBars(analysis: SemanticAnalysisRun): SeoChartDatum[] { + return [ + { + detail: "уже читается", + label: "Покрыто", + tone: "good", + value: analysis.summary.coveredClusters + }, + { + detail: "требует усиления", + label: "Слабо", + tone: "warning", + value: analysis.summary.weakClusters + }, + { + detail: "нет достаточного контента", + label: "Пусто", + tone: "problem", + value: analysis.summary.missingClusters + }, + { + detail: "seed phrases", + label: "Seeds", + tone: "neutral", + value: analysis.summary.seedCandidates + } + ]; +} + +function getMarketSignalCount(market: MarketEnrichmentContract) { + return (market.wordstat.summary.exactFrequencySeedCount ?? 0) + (market.wordstat.summary.relatedOnlySeedCount ?? 0); +} + +function getMarketDecision(market: MarketEnrichmentContract) { + const approvedCount = market.readiness.seedCount; + const collectedCount = market.wordstat.summary.collectedSeedCount; + const signalCount = getMarketSignalCount(market); + const reviewCount = market.normalization.readiness.needsHumanReviewCount; + + if (!market.readiness.ontologyReadyForMarket) { + return { + action: "Сначала закрепить project ontology, иначе market evidence будет привязан к неподтверждённому смыслу сайта.", + label: "контекст не готов", + summary: "Рыночный спрос пока нельзя считать рабочим evidence.", + tone: "problem" as const + }; + } + + if (approvedCount === 0) { + return { + action: "Нужен model/human review seed strategy: сейчас нет фраз, которые можно безопасно отправить во внешний рынок.", + label: "нет очереди", + summary: "Market Demand заблокирован до нормализации.", + tone: "problem" as const + }; + } + + if (collectedCount === 0) { + return { + action: `Запустить Wordstat collection по ${approvedCount} approved фразам, потом решать keyword map.`, + label: "готово к сбору", + summary: "Контекст уже сужен до approved queue, но внешнего evidence ещё нет.", + tone: "warning" as const + }; + } + + if (collectedCount < approvedCount) { + return { + action: `Запустить Wordstat collection для оставшихся ${approvedCount - collectedCount} approved фраз, затем решать SEO-план.`, + label: "сбор неполный", + summary: `${collectedCount}/${approvedCount} approved фраз имеют Wordstat evidence.`, + tone: "warning" as const + }; + } + + if (signalCount === 0) { + return { + action: `Не строить SEO-план на текущих фразах. Сначала отдать ${reviewCount} спорных фраз в model/human review и расширить seed strategy.`, + label: "спрос не доказан", + summary: `${collectedCount} approved фраз проверены, но Wordstat не дал точного или related сигнала.`, + tone: "problem" as const + }; + } + + return { + action: + reviewCount > 0 + ? `Двигать в SEO-план только ${signalCount} подтверждённых фраз, остальные ${reviewCount} оставить в review.` + : "Можно передавать подтверждённые фразы в SEO-план и готовить SERP evidence.", + label: "есть спрос", + summary: `${signalCount}/${approvedCount} approved фраз имеют рыночный сигнал.`, + tone: "good" as const + }; +} + +function getMarketEvidenceCards(market: MarketEnrichmentContract): SeoChartDatum[] { + return [ + { + detail: "approved queue", + label: "К проверке", + tone: "neutral", + value: market.readiness.seedCount + }, + { + detail: "получили Wordstat row", + label: "Проверено", + tone: market.wordstat.summary.collectedSeedCount > 0 ? "good" : "warning", + value: market.wordstat.summary.collectedSeedCount + }, + { + detail: "точный или related signal", + label: "Спрос", + tone: getMarketSignalCount(market) > 0 ? "good" : "problem", + value: getMarketSignalCount(market) + }, + { + detail: "не подтвердились", + label: "Пусто", + tone: (market.wordstat.summary.noSignalSeedCount ?? 0) > 0 ? "problem" : "neutral", + value: market.wordstat.summary.noSignalSeedCount ?? 0 + }, + { + detail: "не пускать дальше без модели", + label: "Review", + tone: market.normalization.readiness.needsHumanReviewCount > 0 ? "warning" : "neutral", + value: market.normalization.readiness.needsHumanReviewCount + } + ]; +} + +function getMarketDemandComposition(market: MarketEnrichmentContract): SeoChartDatum[] { + const signalCount = getMarketSignalCount(market); + const noSignalCount = market.wordstat.summary.noSignalSeedCount ?? 0; + const pendingCount = Math.max(0, market.readiness.seedCount - market.wordstat.summary.collectedSeedCount); + + return [ + { + detail: "точный или related спрос", + label: "Сигнал", + tone: signalCount > 0 ? "good" : "neutral", + value: signalCount + }, + { + detail: "проверено без спроса", + label: "Пусто", + tone: noSignalCount > 0 ? "problem" : "neutral", + value: noSignalCount + }, + { + detail: "approved, но ещё без job", + label: "Дособрать", + tone: pendingCount > 0 ? "warning" : "neutral", + value: pendingCount + } + ]; +} + +function getMarketReadinessRadar(market: MarketEnrichmentContract): SeoChartDatum[] { + const normalizedCount = market.normalization.readiness.normalizedSeedCount; + const wordstatApprovedCount = market.normalization.readiness.wordstatApprovedCount; + const reviewCount = market.normalization.readiness.needsHumanReviewCount; + const seedCount = market.readiness.seedCount; + const signalCount = getMarketSignalCount(market); + const wordstatProvider = market.providers.find((provider) => provider.id === "wordstat"); + const serpProvider = market.providers.find((provider) => provider.id === "serp"); + + return [ + { + detail: "approved ontology", + label: "Контекст", + tone: market.readiness.ontologyReadyForMarket ? "good" : "problem", + value: market.readiness.ontologyReadyForMarket ? 100 : 0 + }, + { + detail: "Wordstat queue", + label: "Очередь", + tone: wordstatApprovedCount > 0 ? "good" : "warning", + value: getChartPercent(wordstatApprovedCount, Math.max(normalizedCount, 1)) + }, + { + detail: "собрано по approved", + label: "Сбор", + tone: market.wordstat.summary.collectedSeedCount >= seedCount && seedCount > 0 ? "good" : "warning", + value: getChartPercent(market.wordstat.summary.collectedSeedCount, Math.max(seedCount, 1)) + }, + { + detail: "подтвержденный спрос", + label: "Сигнал", + tone: signalCount > 0 ? "good" : "problem", + value: getChartPercent(signalCount, Math.max(seedCount, 1)) + }, + { + detail: "меньше спорных фраз", + label: "Review clear", + tone: reviewCount === 0 ? "good" : "warning", + value: 100 - getChartPercent(reviewCount, Math.max(normalizedCount, 1)) + }, + { + detail: "внешние providers", + label: "Providers", + tone: wordstatProvider?.status === "connected" || serpProvider?.status === "connected" ? "good" : "warning", + value: + (wordstatProvider?.status === "connected" ? 50 : 0) + + (serpProvider?.status === "connected" ? 50 : 0) + } + ]; +} + +function getMarketFunnelData( + market: MarketEnrichmentContract, + keywordMap: KeywordMapContract | null | undefined +): SeoChartDatum[] { + return [ + { + detail: "из контекста сайта", + label: "Фразы", + tone: "neutral", + value: market.readiness.seedCount + }, + { + detail: "прошли сбор", + label: "Проверено", + tone: market.wordstat.summary.collectedSeedCount > 0 ? "good" : "warning", + value: market.wordstat.summary.collectedSeedCount + }, + { + detail: "точный или смежный сигнал", + label: "Спрос", + tone: getMarketSignalCount(market) > 0 ? "good" : "warning", + value: getMarketSignalCount(market) + }, + { + detail: "попало в SEO-план", + label: "Назначено", + tone: (keywordMap?.readiness.mappedItemCount ?? 0) > 0 ? "good" : "neutral", + value: keywordMap?.readiness.mappedItemCount ?? 0 + } + ]; +} + +function getMarketFrequencyBars(market: MarketEnrichmentContract): SeoChartDatum[] { + return [ + { + detail: "верхний спрос", + label: "Сильный", + tone: "good", + value: market.wordstat.summary.highFrequencyCount + }, + { + detail: "средний спрос", + label: "Средний", + tone: "warning", + value: market.wordstat.summary.midFrequencyCount + }, + { + detail: "низкий спрос", + label: "Низкий", + tone: "neutral", + value: market.wordstat.summary.lowFrequencyCount + }, + { + detail: "без частотности", + label: "Пусто", + tone: "problem", + value: market.wordstat.summary.noSignalSeedCount ?? 0 + } + ]; +} + +function getNormalizationReviewFunnelData(market: MarketEnrichmentContract): SeoChartDatum[] { + return [ + { + detail: "после semantic cleanup", + label: "Нормализовано", + tone: "neutral", + value: market.normalization.readiness.normalizedSeedCount + }, + { + detail: "можно собирать без модели", + label: "Auto", + tone: "good", + value: market.normalization.readiness.autoApprovedCount + }, + { + detail: "уйдёт во внешний спрос", + label: "Wordstat", + tone: market.normalization.readiness.wordstatApprovedCount > 0 ? "good" : "warning", + value: market.normalization.readiness.wordstatApprovedCount + }, + { + detail: "требует model/human review", + label: "Review", + tone: market.normalization.readiness.needsHumanReviewCount > 0 ? "warning" : "neutral", + value: market.normalization.readiness.needsHumanReviewCount + }, + { + detail: "не пускать в рынок", + label: "Отсечено", + tone: market.normalization.readiness.rejectedSeedCount > 0 ? "problem" : "neutral", + value: market.normalization.readiness.rejectedSeedCount + } + ]; +} + +function getMarketEvidenceTone(status: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"]): SeoChartTone { + if (status === "collected") return "good"; + if (status === "collected_related_only" || status === "ready_for_collection") return "warning"; + if (status === "collection_failed" || status === "not_configured") return "problem"; + return "neutral"; +} + +function getPriorityScore(priority: "high" | "medium" | "low") { + if (priority === "high") return 90; + if (priority === "medium") return 58; + return 30; +} + +function getMarketOpportunityPoints(market: MarketEnrichmentContract): SeoScatterPoint[] { + return market.seedQueue + .filter((seed) => seed.frequency !== null || seed.relatedCount > 0) + .slice(0, 24) + .map((seed) => { + const demand = seed.frequency ?? seed.relatedCount; + const evidenceBonus = seed.evidenceStatus === "collected" ? 10 : seed.evidenceStatus === "collected_related_only" ? 5 : 0; + + return { + detail: `${seed.clusterTitle} · ${formatCount(seed.frequency)} · related ${seed.relatedCount}`, + id: `${seed.clusterId}:${seed.phrase}`, + label: seed.phrase, + size: seed.relatedCount, + tone: getMarketEvidenceTone(seed.evidenceStatus), + x: Math.max(1, demand), + y: clampChartValue(getPriorityScore(seed.priority) + evidenceBonus, 0, 100) + }; + }); +} + +function getKeywordCleaningDecisionLabel(decision: KeywordCleaningItemView["decision"]) { + const labels = { + article: "контент", + risky: "проверить", + support: "поддержка", + trash: "мусор", + use: "в план" + }; + + return labels[decision]; +} + +function getKeywordCleaningSourceLabel(source: KeywordCleaningItemView["source"]) { + const labels = { + manual: "manual", + normalization_seed: "seed", + serp: "SERP", + wordstat_related: "related", + wordstat_seed: "Wordstat" + }; + + return labels[source]; +} + +function getKeywordCleaningTone(decision: KeywordCleaningItemView["decision"]): SeoChartTone { + if (decision === "use" || decision === "support") return "good"; + if (decision === "article" || decision === "risky") return "warning"; + return "problem"; +} + +function getKeywordCleaningFunnelData(cleaning: KeywordCleaningContract): SeoChartDatum[] { + return [ + { + detail: "market + Wordstat", + label: "Источник", + tone: "neutral", + value: cleaning.readiness.sourcePhraseCount + }, + { + detail: "можно в карту", + label: "Use/support", + tone: cleaning.readiness.keywordMapCandidateCount > 0 ? "good" : "warning", + value: cleaning.readiness.keywordMapCandidateCount + }, + { + detail: "контентный backlog", + label: "Контент", + tone: cleaning.readiness.articleCount > 0 ? "warning" : "neutral", + value: cleaning.readiness.articleCount + }, + { + detail: "нужна проверка", + label: "Risky", + tone: cleaning.readiness.riskyCount > 0 ? "warning" : "neutral", + value: cleaning.readiness.riskyCount + }, + { + detail: "не пускать дальше", + label: "Trash", + tone: cleaning.readiness.trashCount > 0 ? "problem" : "neutral", + value: cleaning.readiness.trashCount + } + ]; +} + +function getKeywordCleaningDemandPoints(cleaning: KeywordCleaningContract): SeoScatterPoint[] { + return cleaning.items + .filter((item) => item.frequency !== null || item.relatedCount > 0) + .slice(0, 28) + .map((item) => ({ + detail: `${getKeywordCleaningDecisionLabel(item.decision)} · ${item.targetPath ?? "без страницы"} · ${item.reason}`, + id: item.id, + label: item.phrase, + size: item.relatedCount, + tone: getKeywordCleaningTone(item.decision), + x: Math.max(1, item.frequency ?? item.relatedCount), + y: clampChartValue(Math.round(item.confidence * 100), 0, 100) + })); +} + +function getKeywordFunnelData(keywordMap: KeywordMapContract): SeoChartDatum[] { + return [ + { + detail: "из рыночной проверки", + label: "Фразы", + tone: "neutral", + value: keywordMap.readiness.seedCount + }, + { + detail: "имеют страницу", + label: "Назначено", + tone: keywordMap.readiness.mappedItemCount > 0 ? "good" : "warning", + value: keywordMap.readiness.mappedItemCount + }, + { + detail: "с рыночным сигналом", + label: "Спрос", + tone: keywordMap.readiness.marketEvidenceCount > 0 ? "good" : "warning", + value: keywordMap.readiness.marketEvidenceCount + }, + { + detail: "готово к фиксации", + label: "Готово", + tone: keywordMap.readiness.persistableItemCount > 0 ? "good" : "neutral", + value: keywordMap.readiness.persistableItemCount + }, + { + detail: "approved decisions", + label: "Зафиксировано", + tone: keywordMap.persisted.approvedDecisionCount > 0 ? "good" : "neutral", + value: keywordMap.persisted.approvedDecisionCount + } + ]; +} + +function getKeywordRoleBars(keywordMap: KeywordMapContract): SeoChartDatum[] { + const counts = keywordMap.items.reduce>((accumulator, item) => { + accumulator[item.role] = (accumulator[item.role] ?? 0) + 1; + return accumulator; + }, {}); + + return (["primary", "secondary", "support", "validate"] as const).map((role) => ({ + detail: getKeywordMapRoleLabel(role), + label: getKeywordMapRoleLabel(role), + tone: role === "primary" ? "good" : role === "validate" ? "warning" : "neutral", + value: counts[role] ?? 0 + })); +} + +function getKeywordTargetBars(keywordMap: KeywordMapContract): SeoChartDatum[] { + const counts = keywordMap.items.reduce>((accumulator, item) => { + const key = item.targetPath ?? "target pending"; + accumulator[key] = (accumulator[key] ?? 0) + 1; + return accumulator; + }, {}); + + return Object.entries(counts) + .sort((left, right) => right[1] - left[1]) + .slice(0, 6) + .map(([label, value]) => ({ + detail: "привязки к страницам", + label: label === "target pending" ? "без страницы" : label, + tone: label === "target pending" ? "warning" : "good", + value + })); +} + +function getKeywordRoleMatrix(keywordMap: KeywordMapContract): SeoMatrixDatum { + const shortRoleLabels = { + primary: "главн.", + secondary: "доп.", + support: "подд.", + validate: "пров." + }; + const columns = (["primary", "secondary", "support", "validate"] as const).map((role) => ({ + key: role, + label: shortRoleLabels[role] + })); + const targetRows = keywordMap.items.reduce>>((accumulator, item) => { + const target = item.targetPath ?? "target pending"; + accumulator[target] ??= {}; + accumulator[target][item.role] = (accumulator[target][item.role] ?? 0) + 1; + return accumulator; + }, {}); + + const rows = Object.entries(targetRows) + .sort((left, right) => { + const leftTotal = Object.values(left[1]).reduce((sum, value) => sum + value, 0); + const rightTotal = Object.values(right[1]).reduce((sum, value) => sum + value, 0); + return rightTotal - leftTotal; + }) + .slice(0, 7) + .map(([target, cells]) => ({ + cells, + detail: target === "target pending" ? "нет page binding" : "page binding", + key: target, + label: target === "target pending" ? "без страницы" : target + })); + + return { columns, rows }; +} + +function isKeywordPlanItemPersistable(item: KeywordMapItemView) { + return ( + item.role !== "validate" && + item.evidenceStatus === "collected" && + item.frequency !== null && + Boolean(item.projectOntologyVersionId) && + Boolean(item.wordstatResultId) + ); +} + +function getKeywordRoleFieldLabel(role: KeywordMapItemView["role"]) { + const labels = { + primary: "title / H1", + secondary: "meta description", + support: "body section", + validate: "review lane" + }; + + return labels[role]; +} + +function getKeywordPlanDecision(keywordMap: KeywordMapContract) { + const reviewCount = keywordMap.items.filter((item) => !isKeywordPlanItemPersistable(item)).length; + const targetlessCount = keywordMap.items.filter((item) => !item.targetPath).length; + const exactEvidenceCount = keywordMap.items.filter((item) => item.evidenceStatus === "collected" && item.frequency !== null).length; + const safePageCount = new Set( + keywordMap.items.filter(isKeywordPlanItemPersistable).map((item) => item.targetPath).filter(Boolean) + ).size; + + if (keywordMap.state === "blocked" || !keywordMap.readiness.reviewedOntology) { + return { + action: + "Сначала закрываем blockers и reviewed ontology. До этого keyword map нельзя считать доказанной базой стратегии.", + label: "нельзя фиксировать", + metrics: [ + { detail: "контекст не подтверждён", label: "Ontology", tone: "problem" as SeoChartTone, value: 0 }, + { detail: "page binding", label: "Страниц", tone: "neutral" as SeoChartTone, value: safePageCount }, + { detail: "exact Wordstat", label: "Evidence", tone: "neutral" as SeoChartTone, value: exactEvidenceCount }, + { detail: "ручная очередь", label: "Review", tone: "warning" as SeoChartTone, value: reviewCount } + ], + summary: + "Сейчас это диагностический черновик: он показывает, где фразы могли бы лечь, но не имеет права закреплять решения.", + title: "План заблокирован контекстом", + tone: "problem" as SeoChartTone + }; + } + + if (keywordMap.readiness.persistableItemCount > 0) { + return { + action: + "Фиксируем только exact Wordstat + page binding; validate/no-signal остаются вне rewrite и ждут ручной проверки.", + label: "можно фиксировать", + metrics: [ + { + detail: "approved decisions", + label: "Фиксировать", + tone: "good" as SeoChartTone, + value: keywordMap.readiness.persistableItemCount + }, + { detail: "страницы с безопасными решениями", label: "Страниц", tone: "good" as SeoChartTone, value: safePageCount }, + { detail: "останется в ручной проверке", label: "Review", tone: "warning" as SeoChartTone, value: reviewCount }, + { detail: "без page binding", label: "Без страницы", tone: "warning" as SeoChartTone, value: targetlessCount } + ], + summary: `${keywordMap.readiness.persistableItemCount} из ${keywordMap.readiness.seedCount} фраз можно закрепить как безопасные решения. Остальное не должно автоматически попадать в текст.`, + title: `Можно закрепить ${keywordMap.readiness.persistableItemCount} SEO-решения`, + tone: "good" as SeoChartTone + }; + } + + return { + action: + "Дальше нужно усилить Wordstat/страничные привязки: без exact evidence план остаётся картой проверки, а не заданием на rewrite.", + label: "только review", + metrics: [ + { + detail: "exact Wordstat", + label: "Evidence", + tone: (exactEvidenceCount > 0 ? "warning" : "problem") as SeoChartTone, + value: exactEvidenceCount + }, + { + detail: "page binding", + label: "Назначено", + tone: (keywordMap.readiness.mappedItemCount > 0 ? "warning" : "problem") as SeoChartTone, + value: keywordMap.readiness.mappedItemCount + }, + { detail: "ручная очередь", label: "Review", tone: "warning" as SeoChartTone, value: reviewCount }, + { detail: "без page binding", label: "Без страницы", tone: "warning" as SeoChartTone, value: targetlessCount } + ], + summary: + "План уже показывает направления по страницам, но пока не даёт безопасных approved decisions для следующего этапа.", + title: "Фиксация пока не готова", + tone: "warning" as SeoChartTone + }; +} + +function getKeywordPlanPageSummaries(keywordMap: KeywordMapContract) { + const roleRank: Record = { + primary: 0, + secondary: 1, + support: 2, + validate: 3 + }; + const groups = keywordMap.items.reduce>((accumulator, item) => { + const key = item.targetPath ?? "target pending"; + const group = accumulator.get(key) ?? []; + group.push(item); + accumulator.set(key, group); + return accumulator; + }, new Map()); + + return Array.from(groups.entries()) + .map(([targetPath, items]) => { + const counts = items.reduce>( + (accumulator, item) => { + accumulator[item.role] += 1; + return accumulator; + }, + { primary: 0, secondary: 0, support: 0, validate: 0 } + ); + const persistableItems = items.filter(isKeywordPlanItemPersistable); + const reviewItems = items.filter((item) => !isKeywordPlanItemPersistable(item)); + const evidenceCount = items.filter((item) => item.evidenceStatus === "collected").length; + const topItems = [...items].sort((left, right) => { + const persistableDelta = Number(isKeywordPlanItemPersistable(right)) - Number(isKeywordPlanItemPersistable(left)); + + if (persistableDelta !== 0) { + return persistableDelta; + } + + const roleDelta = roleRank[left.role] - roleRank[right.role]; + + if (roleDelta !== 0) { + return roleDelta; + } + + return (right.frequency ?? 0) - (left.frequency ?? 0); + }); + const primaryItem = topItems.find((item) => item.role === "primary") ?? topItems[0] ?? null; + const readableTarget = targetPath === "target pending" ? "без страницы" : targetPath; + const status: KeywordPagePlanStatus = + targetPath === "target pending" + ? "blocked" + : persistableItems.length > 0 + ? "ready" + : evidenceCount > 0 + ? "review" + : "blocked"; + const statusLabel = + status === "ready" ? "можно фиксировать" : status === "review" ? "ручная проверка" : "не отдавать в rewrite"; + const action = + status === "ready" + ? `Закрепить ${persistableItems.length} подтверждённые фразы и не тащить review-хвост в rewrite.` + : targetPath === "target pending" + ? "Сначала выбрать посадочную или создать страницу, потом пересобрать привязку." + : "Проверить формулировки и market evidence перед закреплением."; + + return { + action, + counts, + evidenceCount, + key: targetPath, + primaryItem, + readableTarget, + reviewItems, + status, + statusLabel, + targetPath: targetPath === "target pending" ? null : targetPath, + topItems + }; + }) + .sort((left, right) => { + const statusRank: Record = { ready: 0, review: 1, blocked: 2 }; + const statusDelta = statusRank[left.status] - statusRank[right.status]; + + if (statusDelta !== 0) { + return statusDelta; + } + + return right.topItems.length - left.topItems.length; + }) + .slice(0, 8); +} + +function getKeywordPlanReviewItems(keywordMap: KeywordMapContract) { + return keywordMap.items + .filter((item) => !isKeywordPlanItemPersistable(item)) + .sort((left, right) => { + const frequencyDelta = (right.frequency ?? 0) - (left.frequency ?? 0); + + if (frequencyDelta !== 0) { + return frequencyDelta; + } + + return left.phrase.localeCompare(right.phrase, "ru"); + }); +} + +function getRewriteReadinessFunnelData( + rewritePlan: RewritePlanContract, + rewriteDiff: RewriteDiffContract | null | undefined +): SeoChartDatum[] { + return [ + { + detail: "зафиксированные решения", + label: "План", + tone: "neutral", + value: rewritePlan.readiness.approvedDecisionCount + }, + { + detail: "есть pageId", + label: "Страницы", + tone: rewritePlan.readiness.existingPageTargetCount > 0 ? "good" : "warning", + value: rewritePlan.readiness.existingPageTargetCount + }, + { + detail: "кандидаты для правок", + label: "Кандидаты", + tone: rewritePlan.readiness.rewriteCandidateCount > 0 ? "good" : "warning", + value: rewritePlan.readiness.rewriteCandidateCount + }, + { + detail: "поля с текущим текстом", + label: "Поля", + tone: (rewriteDiff?.readiness.slotCount ?? 0) > 0 ? "good" : "neutral", + value: rewriteDiff?.readiness.slotCount ?? 0 + }, + { + detail: "можно отдавать модели", + label: "Готово модели", + tone: (rewriteDiff?.readiness.modelReadySlotCount ?? 0) > 0 ? "good" : "warning", + value: rewriteDiff?.readiness.modelReadySlotCount ?? 0 + } + ]; +} + +function getRewriteFieldBars(rewritePlan: RewritePlanContract): SeoChartDatum[] { + const counts = rewritePlan.existingPageTargets + .flatMap((target) => target.keywordBindings) + .reduce>( + (accumulator, binding) => { + accumulator[binding.field] = (accumulator[binding.field] ?? 0) + 1; + return accumulator; + }, + { + body_section: 0, + h1: 0, + meta_description: 0, + title: 0 + } + ); + + return (["title", "meta_description", "h1", "body_section"] as const).map((field) => ({ + detail: "привязки полей", + label: getRewriteFieldLabel(field), + tone: field === "body_section" ? "neutral" : "good", + value: counts[field] + })); +} + +function getMaterializationBars(materialization: MaterializationContract): SeoChartDatum[] { + return [ + { + detail: "связано со страницами", + label: "Связано", + tone: "good", + value: materialization.readiness.linkedTargetCount + }, + { + detail: "ожидает решения", + label: "Ожидает", + tone: "warning", + value: materialization.readiness.pendingTargetCount + }, + { + detail: "плановые страницы", + label: "План", + tone: "neutral", + value: materialization.readiness.placeholderTargetCount + }, + { + detail: "не пойдёт в правки", + label: "Отложено", + tone: "problem", + value: materialization.readiness.rejectedOrDeferredCount + } + ]; +} + +function getSeoStrategyStateLabel(state: SeoStrategyContract["state"]) { + const labels: Record = { + blocked: "заблокировано", + evidence_gap: "не хватает данных", + ready_for_review: "готово к review", + strategy_draft: "черновик стратегии" + }; + + return labels[state]; +} + +function getSeoStrategyStateClass(state: SeoStrategyContract["state"]) { + const classes: Record = { + blocked: "not_configured", + evidence_gap: "not_collected", + ready_for_review: "partial_ready", + strategy_draft: "partial_ready" + }; + + return classes[state]; +} + +function getStrategyTone(score: number): SeoChartTone { + if (score >= 75) { + return "good"; + } + + if (score >= 45) { + return "warning"; + } + + return "problem"; +} + +function getSeoStrategyConfidenceLabel(confidence: "high" | "low" | "medium") { + const labels = { + high: "высокая", + low: "низкая", + medium: "средняя" + }; + + return labels[confidence]; +} + +function getSeoStrategyPriorityLabel(priority: "high" | "low" | "medium") { + const labels = { + high: "высокий", + low: "низкий", + medium: "средний" + }; + + return labels[priority]; +} + +function getSeoMaturityPhaseLabel(phase: SeoStrategyContract["siteMaturity"]["phase"]) { + const labels: Record = { + authority_head_terms: "Authority / head terms", + bootstrap_indexing: "Bootstrap / indexing", + growth: "Growth", + traction: "Traction" + }; + + return labels[phase]; +} + +function getSeoPhaseStatusLabel(status: SeoStrategyContract["phasePlan"][number]["status"]) { + const labels: Record = { + current: "сейчас", + future: "следующая", + locked: "позже", + ready: "пройдено" + }; + + return labels[status]; +} + +function getSeoOpportunityActionLabel(action: SeoStrategyContract["opportunities"][number]["timing"]["action"]) { + const labels: Record = { + defer: "отложить", + do_now: "сейчас", + prepare: "готовить" + }; + + return labels[action]; +} + +function getSeoCompetitionTierLabel(tier: SeoStrategyContract["opportunities"][number]["competitionTier"]) { + const labels: Record = { + head: "head", + long_tail: "long-tail", + mid: "mid", + unknown: "unknown" + }; + + return labels[tier]; +} + +function getSeoStrategyOwnerLabel(owner: SeoStrategyContract["missingEvidence"][number]["owner"]) { + const labels: Record = { + ai_layer: "AI layer", + external_service: "внешний сервис", + product_review: "product review" + }; + + return labels[owner]; +} + +function getSeoStrategyRiskLevelLabel(level: SeoStrategyContract["risks"][number]["level"]) { + const labels: Record = { + critical: "critical", + high: "high", + medium: "medium" + }; + + return labels[level]; +} + +function getStrategySynthesisStateLabel(state: StrategySynthesisContract["state"]) { + const labels: Record = { + not_ready: "не собрано", + ready: "готово" + }; + + return labels[state]; +} + +function getStrategySynthesisScenarioLabel( + scenario: StrategySynthesisContract["decisionOptions"][number]["scenario"] +) { + const labels: Record = { + analytics_first: "сначала данные", + bootstrap_runway: "bootstrap runway", + core_growth: "рост ядра", + risk_reduction: "снижение риска", + serp_expansion: "SERP расширение" + }; + + return labels[scenario]; +} + +function getStrategySynthesisOwnerLabel(owner: StrategySynthesisContract["dataGaps"][number]["owner"]) { + const labels: Record = { + ai_layer: "AI layer", + external_service: "внешний сервис", + product_review: "product review" + }; + + return labels[owner]; +} + +function getStrategyQualityVerdictLabel(status: StrategyQualityReviewContract["verdict"]["status"]) { + const labels: Record = { + approved: "готово к review", + blocked: "заблокировано", + needs_changes: "нужны правки" + }; + + return labels[status]; +} + +function getStrategyQualityVerdictClass(status: StrategyQualityReviewContract["verdict"]["status"]) { + const classes: Record = { + approved: "partial_ready", + blocked: "not_ready", + needs_changes: "not_collected" + }; + + return classes[status]; +} + +function getStrategyQualityCheckLabel(status: StrategyQualityReviewContract["checks"][number]["status"]) { + const labels: Record = { + fail: "fail", + pass: "pass", + warning: "warning" + }; + + return labels[status]; +} + +function getStrategyQualityCheckClass(status: StrategyQualityReviewContract["checks"][number]["status"]) { + const classes: Record = { + fail: "not_configured", + pass: "collected", + warning: "ready_for_collection" + }; + + return classes[status]; +} + +function getStrategyQualityCategoryLabel(category: StrategyQualityReviewContract["checks"][number]["category"]) { + const labels: Record = { + business_drift: "business drift", + evidence_integrity: "evidence refs", + hallucination_guardrail: "hallucination", + phase_guardrail: "phase runway", + source_safety: "source safety", + ux_clarity: "UX clarity" + }; + + return labels[category]; +} + +function getYandexEvidenceStateLabel(state: YandexEvidenceContract["state"]) { + const labels: Record = { + failed: "ошибка", + not_collected: "не собрано", + not_configured: "не настроено", + ready: "собрано" + }; + + return labels[state]; +} + +function getYandexEvidenceIntentLabel(intent: YandexEvidenceContract["phrases"][number]["interpretation"]["serpIntent"]) { + const labels: Record = { + commercial: "commercial", + informational: "info", + mixed: "mixed", + unknown: "unknown" + }; + + return labels[intent]; +} + +function getSerpTargetFitLabel(targetFit: SerpInterpretationContract["phraseInterpretations"][number]["targetFit"]) { + const labels: Record = { + mismatch: "mismatch", + partial_fit: "частичный fit", + strong_fit: "сильный fit", + unknown: "неясно" + }; + + return labels[targetFit]; +} + +function getSerpRecommendedRoleLabel(role: SerpInterpretationContract["phraseInterpretations"][number]["recommendedRole"]) { + const labels: Record = { + article_backlog: "контент", + core_landing: "ядро лендинга", + human_review: "ручная проверка", + support_section: "support-секция" + }; + + return labels[role]; +} + +function getYandexEvidenceSeasonalityLabel( + seasonality: YandexEvidenceContract["phrases"][number]["interpretation"]["seasonality"] +) { + const labels: Record = { + falling: "falling", + insufficient: "нет данных", + rising: "rising", + stable: "stable", + volatile: "volatile" + }; + + return labels[seasonality]; +} + +function getSeoStrategyReadinessBars(strategy: SeoStrategyContract): SeoChartDatum[] { + return [ + { + detail: "strategy confidence", + label: "Score", + tone: + strategy.readiness.strategyConfidenceScore >= 75 + ? "good" + : strategy.readiness.strategyConfidenceScore >= 45 + ? "warning" + : "problem", + value: strategy.readiness.strategyConfidenceScore + }, + { + detail: "approved keyword decisions", + label: "Решения", + tone: strategy.readiness.approvedKeywordCount > 0 ? "good" : "problem", + value: strategy.readiness.approvedKeywordCount + }, + { + detail: "external evidence", + label: "Evidence", + tone: strategy.readiness.marketEvidenceCount > 0 ? "good" : "warning", + value: strategy.readiness.marketEvidenceCount + }, + { + detail: "opportunity groups", + label: "Возможности", + tone: strategy.readiness.opportunityCount > 0 ? "good" : "warning", + value: strategy.readiness.opportunityCount + }, + { + detail: "нужно закрыть", + label: "Gaps", + tone: strategy.readiness.evidenceGapCount > 0 ? "warning" : "good", + value: strategy.readiness.evidenceGapCount + } + ]; +} + +function getSeoStrategyDemandBars(strategy: SeoStrategyContract): SeoChartDatum[] { + return [ + { + detail: "точные Wordstat seed", + label: "Exact", + tone: strategy.evidence.demand.exactSeedCount > 0 ? "good" : "warning", + value: strategy.evidence.demand.exactSeedCount + }, + { + detail: "use/support candidates", + label: "Useful", + tone: strategy.evidence.demand.usefulCandidateCount > 0 ? "good" : "warning", + value: strategy.evidence.demand.usefulCandidateCount + }, + { + detail: "требуют проверки", + label: "Risky", + tone: strategy.evidence.demand.riskyCandidateCount > 0 ? "warning" : "good", + value: strategy.evidence.demand.riskyCandidateCount + }, + { + detail: "отфильтрованный шум", + label: "Trash", + tone: strategy.evidence.demand.rejectedNoiseCount > 0 ? "problem" : "neutral", + value: strategy.evidence.demand.rejectedNoiseCount + } + ]; +} + +function getSeoStrategyProviderBars(strategy: SeoStrategyContract): SeoChartDatum[] { + const connectedCount = strategy.evidence.providers.filter((provider) => provider.status === "connected").length; + const plannedCount = strategy.evidence.providers.filter((provider) => provider.status !== "connected").length; + + return [ + { + detail: "уже дают evidence", + label: "Connected", + tone: connectedCount > 0 ? "good" : "warning", + value: connectedCount + }, + { + detail: "нужны для зрелой стратегии", + label: "Gaps", + tone: plannedCount > 0 ? "warning" : "good", + value: plannedCount + } + ]; +} + +function getStrategySynthesisMetrics(strategySynthesis: StrategySynthesisContract): SeoChartDatum[] { + return [ + { + detail: "0-100, по evidence stack", + label: "Score", + tone: getStrategyTone(strategySynthesis.readiness.confidenceScore), + value: strategySynthesis.readiness.confidenceScore + }, + { + detail: "варианты решения", + label: "Опции", + tone: strategySynthesis.readiness.decisionOptionCount > 0 ? "good" : "warning", + value: strategySynthesis.readiness.decisionOptionCount + }, + { + detail: "чего не хватает", + label: "Gaps", + tone: strategySynthesis.readiness.dataGapCount > 0 ? "warning" : "good", + value: strategySynthesis.readiness.dataGapCount + }, + { + detail: "риск-позиция", + label: "Риски", + tone: strategySynthesis.readiness.riskCount > 0 ? "warning" : "good", + value: strategySynthesis.readiness.riskCount + } + ]; +} + +function getStrategyQualityMetrics(review: StrategyQualityReviewContract): SeoChartDatum[] { + return [ + { + detail: "quality gate score", + label: "Score", + tone: getStrategyTone(review.readiness.score), + value: review.readiness.score + }, + { + detail: "checks passed", + label: "Pass", + tone: review.readiness.passedCount > 0 ? "good" : "warning", + value: review.readiness.passedCount + }, + { + detail: "must fix", + label: "Blockers", + tone: review.readiness.blockerCount > 0 ? "problem" : "good", + value: review.readiness.blockerCount + }, + { + detail: "needs attention", + label: "Warnings", + tone: review.readiness.warningCount > 0 ? "warning" : "good", + value: review.readiness.warningCount + } + ]; +} + +function SeoFunnelChart({ items, subtitle, title }: { items: SeoChartDatum[]; subtitle?: string; title: string }) { + const maxValue = Math.max(...items.map((item) => item.value), 1); + + return ( +
+
+ {title} + {subtitle ? {subtitle} : null} +
+
+ {items.map((item) => { + const width = Math.max(7, getChartPercent(item.value, maxValue)); + + return ( +
+
+ {item.label} + {formatCount(item.value)} +
+ + {item.detail ? {item.detail} : null} +
+ ); + })} +
+
+ ); +} + +function SeoBarChart({ items, subtitle, title }: { items: SeoChartDatum[]; subtitle?: string; title: string }) { + const maxValue = Math.max(...items.map((item) => item.value), 1); + + return ( +
+
+ {title} + {subtitle ? {subtitle} : null} +
+
+ {items.map((item) => { + const width = Math.max(item.value > 0 ? 5 : 0, getChartPercent(item.value, maxValue)); + + return ( +
+
+ {item.label} + {formatCount(item.value)} +
+ + {item.detail ? {item.detail} : null} +
+ ); + })} +
+
+ ); +} + +function SeoShapeFunnelChart({ items, subtitle, title }: { items: SeoChartDatum[]; subtitle?: string; title: string }) { + const maxValue = Math.max(...items.map((item) => item.value), 0); + const data = [...items].sort((left, right) => right.value - left.value).map((item) => ({ + color: getChartToneColor(item.tone), + displayValue: formatCount(item.value), + label: item.label, + value: maxValue > 0 ? item.value : 1 + })); + + return ( +
+
+ {title} + {subtitle ? {subtitle} : null} +
+
+
+ +
+
+ {items.map((item) => ( +
+ + + {item.label} + + {formatCount(item.value)} + {item.detail ? {item.detail} : null} +
+ ))} +
+
+
+ ); +} + +function SeoRingChart({ + centerLabel, + centerValue, + items, + subtitle, + title +}: { + centerLabel?: string; + centerValue?: number; + items: SeoChartDatum[]; + subtitle?: string; + title: string; +}) { + const total = items.reduce((sum, item) => sum + Math.max(0, item.value), 0); + const maxValue = Math.max(centerValue ?? total, total, 1); + const data = items.map((item) => ({ + color: getChartToneColor(item.tone), + label: item.label, + maxValue, + value: item.value + })); + + return ( +
+
+ {title} + {subtitle ? {subtitle} : null} +
+
+
+ + {data.map((item, index) => ( + + ))} + + +
+
+ {items.map((item) => ( +
+ + + {item.label} + + {formatCount(item.value)} + {item.detail ? {item.detail} : null} +
+ ))} +
+
+
+ ); +} + +function SeoRadarChart({ items, subtitle, title }: { items: SeoChartDatum[]; subtitle?: string; title: string }) { + const metrics = items.map((item) => ({ key: item.label, label: item.label })); + const data = [ + { + color: "#2f6b4f", + label: title, + values: items.reduce>((accumulator, item) => { + accumulator[item.label] = clampChartValue(item.value, 0, 100); + return accumulator; + }, {}) + } + ]; + + return ( +
+
+ {title} + {subtitle ? {subtitle} : null} +
+
+
+ + + + + + +
+
+ {items.map((item) => ( +
+ {item.label} + {formatCount(item.value)} + {item.detail ? {item.detail} : null} +
+ ))} +
+
+
+ ); +} + +function SeoHeatmapChart({ + emptyLabel, + matrix, + subtitle, + title +}: { + emptyLabel: string; + matrix: SeoMatrixDatum; + subtitle?: string; + title: string; +}) { + const maxValue = Math.max( + ...matrix.rows.flatMap((row) => matrix.columns.map((column) => row.cells[column.key] ?? 0)), + 1 + ); + const heatmapData = matrix.columns.map((column, columnIndex) => ({ + bin: columnIndex, + bins: matrix.rows.map((row, rowIndex) => ({ + bin: rowIndex, + count: row.cells[column.key] ?? 0, + date: new Date(2026, 0, columnIndex + 1) + })) + })); + const heatmapColor = (count: number | null | undefined) => { + if (!count) return "rgba(24, 32, 29, 0.05)"; + const opacity = 0.2 + getChartPercent(count, maxValue) * 0.006; + return `rgba(47, 107, 79, ${opacity})`; + }; + const heatmapLevelColors = [ + "rgba(24, 32, 29, 0.06)", + "rgba(47, 107, 79, 0.24)", + "rgba(47, 107, 79, 0.38)", + "rgba(47, 107, 79, 0.54)", + "rgba(47, 107, 79, 0.72)" + ] as const; + + return ( +
+
+ {title} + {subtitle ? {subtitle} : null} +
+ {matrix.rows.length > 0 ? ( +
+
+ + {matrix.columns.map((column) => ( + {column.label} + ))} +
+
+
+ {matrix.rows.map((row) => ( + + {row.label} + + ))} +
+ + + + + + + +
+
+ ) : ( +

{emptyLabel}

+ )} +
+ ); +} + +function SeoNormalizationReviewQueue({ + emptyLabel, + seeds, + subtitle, + title +}: { + emptyLabel: string; + seeds: SeoNormalizationSeedView[]; + subtitle: string; + title: string; +}) { + return ( +
+
+ {title} + {seeds.length} +
+ {subtitle} +
+ {seeds.length > 0 ? ( + seeds.slice(0, 3).map((seed) => ( +

+ {seed.phrase} + + {getSeoMarketRoleLabel(seed.marketRole)} · {Math.round(seed.confidence * 100)}% ·{" "} + {getSeoSeedReviewShortLabel(seed)} + +

+ )) + ) : ( +

+ {emptyLabel} + Очередь пустая. +

+ )} +
+
+ ); +} + +function SeoKeywordCleaningLane({ + emptyLabel, + items, + subtitle, + title +}: { + emptyLabel: string; + items: KeywordCleaningItemView[]; + subtitle: string; + title: string; +}) { + return ( +
+
+ {title} + {items.length} +
+ {subtitle} +
+ {items.length > 0 ? ( + items.slice(0, 3).map((item) => ( +

+ {item.phrase} + + {item.targetPath ?? "без страницы"} · {formatCount(item.frequency)} · {getKeywordCleaningSourceLabel(item.source)} + +

+ )) + ) : ( +

+ {emptyLabel} + Очередь пустая. +

+ )} +
+
+ ); +} + +function SeoScatterChart({ + emptyLabel, + points, + subtitle, + title, + xLabel, + yLabel +}: { + emptyLabel: string; + points: SeoScatterPoint[]; + subtitle?: string; + title: string; + xLabel: string; + yLabel: string; +}) { + const maxX = Math.max(...points.map((point) => point.x), 1); + const maxY = Math.max(...points.map((point) => point.y), 1); + const visiblePoints = points.slice(0, 24); + const chartData = + visiblePoints.length > 0 + ? visiblePoints.map((point, index) => ({ + date: new Date(2026, 0, index + 1), + demand: point.x, + priority: point.y + })) + : []; + + return ( +
+
+ {title} + {subtitle ? {subtitle} : null} +
+ {visiblePoints.length > 0 ? ( + <> +
+ + + + + + + {yLabel} + {xLabel} +
+
+ {visiblePoints.slice(0, 5).map((point) => ( + + {point.label} + + ))} +
+ + ) : ( +

{emptyLabel}

+ )} +
+ ); +} + function normalizeProjectName(value: string) { return value.trim().toLocaleLowerCase("ru-RU"); } @@ -392,6 +2273,103 @@ function getProjectScanStatusMessage(scan: ProjectScanSummary) { return `Скан запускался: ${getScanStatusLabel(scan.status)} · страниц: ${scan.counts.pages}, ассетов: ${scan.counts.assets}${completedAt}.`; } +function isScanReadyForFlow(scan: ProjectScanSummary | null | undefined) { + return Boolean(scan && scan.status === "done" && scan.pages.length > 0); +} + +function getPipelineStageAccess( + stageIndex: number, + input: { + contextReview: SeoContextReviewRun | null; + hasProject: boolean; + keywordMap: KeywordMapContract | null; + marketEnrichment: MarketEnrichmentContract | null; + scan: ProjectScanSummary | null | undefined; + semanticAnalysis: SemanticAnalysisRun | null; + seoStrategy: SeoStrategyContract | null; + strategyQualityReview: StrategyQualityReviewContract | null; + strategySynthesis: StrategySynthesisContract | null; + } +) { + const scanReady = isScanReadyForFlow(input.scan); + const contextReady = Boolean(input.semanticAnalysis && input.contextReview); + const marketReady = Boolean(input.marketEnrichment && input.marketEnrichment.state !== "not_ready"); + const keywordReady = Boolean(input.keywordMap && input.keywordMap.readiness.mappedItemCount > 0); + const strategyReady = Boolean(input.seoStrategy && input.seoStrategy.state !== "blocked"); + const synthesisReady = input.strategySynthesis?.state === "ready"; + const qualityReady = input.strategyQualityReview?.state === "ready"; + + if (stageIndex === 0) { + return { reason: "Импортируй сайт или выбери уже созданный проект.", status: "open", unlocked: true }; + } + + if (!input.hasProject) { + return { reason: "Сначала выбери проект на экране источника.", status: "locked", unlocked: false }; + } + + if (stageIndex === 1) { + return { + reason: scanReady ? "Проект инициализирован, scope можно подтверждать." : "Инициализируем проект: сканируем страницы, ассеты и SEO-разметку.", + status: scanReady ? "ready" : "setup", + unlocked: true + }; + } + + if (stageIndex === 2) { + return { + reason: scanReady ? "Рабочая зона доступна по выбранным страницам." : "Сначала нужен успешный скан и scope проекта.", + status: scanReady ? "ready" : "locked", + unlocked: scanReady + }; + } + + if (stageIndex === 3) { + return { + reason: scanReady ? "Можно запускать анализ контекста сайта." : "Контекст недоступен без файлов и базового аудита.", + status: scanReady ? "ready" : "locked", + unlocked: scanReady + }; + } + + if (stageIndex === 4) { + return { + reason: contextReady ? "Контекст собран, можно проверять внешний спрос." : "Сначала запусти анализ контекста и Context Review.", + status: contextReady ? "ready" : "locked", + unlocked: contextReady + }; + } + + if (stageIndex === 5) { + return { + reason: marketReady && keywordReady ? "Есть спрос и карта фраз, можно смотреть SEO-план." : "Сначала нужен рыночный спрос и keyword map.", + status: marketReady && keywordReady ? "ready" : "locked", + unlocked: marketReady && keywordReady + }; + } + + if (stageIndex === 6) { + return { + reason: strategyReady ? "Стратегия доступна: можно выбирать фазу старта." : "Сначала нужен SEO-план и evidence base.", + status: strategyReady ? "ready" : "locked", + unlocked: strategyReady + }; + } + + if (stageIndex === 7) { + return { + reason: synthesisReady ? "Strategy synthesis собран, можно проверять качество." : "Сначала собери strategy synthesis.", + status: synthesisReady ? "ready" : "locked", + unlocked: synthesisReady + }; + } + + return { + reason: qualityReady ? "Quality gate пройден; apply всё ещё остаётся будущим контролируемым контуром." : "Применение заблокировано до quality review.", + status: qualityReady ? "future" : "locked", + unlocked: qualityReady + }; +} + function getAuditIssueCounts(issues: AuditIssue[]) { return issues.reduce( (counts, issue) => { @@ -464,6 +2442,27 @@ function getSemanticQualityLabel(level: SemanticAnalysisRun["qualitySignals"][nu return labels[level]; } +function getProjectOntologySourceLabel(source: NonNullable["source"]) { + const labels = { + extracted: "из сайта", + generic_fallback: "базовая", + seed_fixture: "seed fixture" + }; + + return labels[source]; +} + +function getProjectOntologyApprovalLabel(status: ProjectOntologyApprovalStatus) { + const labels = { + approved: "утверждена", + draft: "черновик", + needs_review: "нужна проверка", + rejected: "отклонена" + }; + + return labels[status]; +} + function getSemanticVerdictConfidenceLabel( confidence: NonNullable["confidence"] ) { @@ -540,9 +2539,9 @@ type SemanticBriefQuality = NonNullable; function getBriefQualityLevelLabel(level: SemanticBriefQuality["level"]) { const labels = { - blocked: "blocked", - needs_work: "needs work", - ready: "ready" + blocked: "заблокировано", + needs_work: "нужно доработать", + ready: "готово" }; return labels[level]; @@ -550,9 +2549,9 @@ function getBriefQualityLevelLabel(level: SemanticBriefQuality["level"]) { function getBriefQualityCheckLabel(status: SemanticBriefQuality["checks"][number]["status"]) { const labels = { - fail: "fail", - pass: "pass", - warn: "warn" + fail: "ошибка", + pass: "ок", + warn: "риск" }; return labels[status]; @@ -571,10 +2570,10 @@ function getMarketStateLabel(state: MarketEnrichmentContract["state"]) { function getMarketProviderStatusLabel(status: MarketEnrichmentContract["providers"][number]["status"]) { const labels = { - connected: "connected", - disabled: "disabled", - not_configured: "not configured", - not_implemented: "not implemented" + connected: "подключено", + disabled: "выключено", + not_configured: "не настроено", + not_implemented: "планируется" }; return labels[status]; @@ -582,14 +2581,163 @@ function getMarketProviderStatusLabel(status: MarketEnrichmentContract["provider function getMarketEvidenceStatusLabel(status: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"]) { const labels = { - collection_failed: "failed", - collected: "collected", - collected_no_signal: "no signal", - collected_related_only: "related only", - deterministic_only: "deterministic", - not_collected: "not collected", + collection_failed: "ошибка сбора", + collected: "собрано", + collected_no_signal: "нет сигнала", + collected_related_only: "только смежное", + deterministic_only: "из контекста", + not_collected: "не собрано", + not_configured: "не настроено", + ready_for_collection: "готово к сбору" + }; + + return labels[status]; +} + +function getSeoIntentTypeLabel(intentType: SeoNormalizationContract["intentGroups"][number]["intentType"]) { + const labels = { + commercial: "коммерция", + deployment_security: "развёртывание", + enterprise: "enterprise", + informational: "инфо", + integration: "интеграции", + problem: "проблема", + product: "продукт", + support: "поддержка" + }; + + return labels[intentType]; +} + +function getSeoMarketRoleLabel(role: SeoNormalizationContract["seedStrategy"][number]["marketRole"]) { + const labels = { + commercial: "коммерция", + core: "ядро", + exclude: "отсечь", + integration: "интеграции", + problem: "проблема", + support: "поддержка" + }; + + return labels[role]; +} + +function getKeywordMapStateLabel(state: KeywordMapContract["state"]) { + const labels = { + blocked: "заблокировано", + draft: "черновик" + }; + + return labels[state]; +} + +function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) { + const labels = { + primary: "главный", + secondary: "доп.", + support: "поддержка", + validate: "проверить" + }; + + return labels[role as keyof typeof labels] ?? role; +} + +function getRewritePlanStateLabel(state: RewritePlanContract["state"]) { + const labels = { + blocked: "заблокировано", + materialization_required: "нужны посадочные", + partial_ready: "частично готово", + ready_for_diff: "готово к правкам" + }; + + return labels[state]; +} + +function getRewriteFieldLabel(field: RewritePlanContract["existingPageTargets"][number]["keywordBindings"][number]["field"]) { + const labels = { + body_section: "body", + h1: "H1", + meta_description: "description", + title: "title" + }; + + return labels[field]; +} + +function getMaterializationStateLabel(state: MaterializationContract["state"]) { + const labels = { + blocked: "заблокировано", + complete: "готово", + partial: "частично готово", + pending: "ожидает решений" + }; + + return labels[state]; +} + +function getMaterializationStatusLabel(status: MaterializationContract["targets"][number]["status"]) { + const labels = { + awaiting_source: "source approved", + can_link_existing: "можно связать", + deferred: "отложено", + linked_existing: "связано", + needs_materialization: "нужна страница", + planned_placeholder: "плановая", + rejected: "отклонено" + }; + + return labels[status]; +} + +function getRewriteDiffStateLabel(state: RewriteDiffContract["state"]) { + const labels = { + blocked: "заблокировано", + materialization_required: "нужны страницы", + partial_ready: "частично готово", + ready_for_model_review: "готово" + }; + + return labels[state]; +} + +function getRewriteDiffSlotStatusLabel(status: RewriteDiffContract["targets"][number]["slots"][number]["status"]) { + const labels = { + ready_for_model_review: "готово", + source_pending: "ждёт источник" + }; + + return labels[status]; +} + +function getModelTaskTypeLabel(taskType: SeoModelTaskType) { + const labels = { + "seo.context_review": "Context Review", + "seo.keyword_cleaning": "Keyword Cleaning", + "seo.normalization": "Normalization", + "seo.serp_interpretation": "SERP Interpretation", + "seo.strategy_quality_review": "Strategy Quality", + "seo.strategy_synthesis": "Strategy Synthesis" + }; + + return labels[taskType]; +} + +function getModelTaskRunStatusLabel(status: SeoModelTaskRun["status"]) { + const labels = { + contract_ready: "contract ready", + provider_not_configured: "provider не подключён", + task_not_found: "task не найден", + validation_failed: "ошибка контракта" + }; + + return labels[status]; +} + +function getModelProviderStatusLabel(status: SeoModelProviderStatusContract["catalog"]["providers"][number]["status"]) { + const labels = { + active: "active", not_configured: "not configured", - ready_for_collection: "ready" + planned: "planned" }; return labels[status]; @@ -608,7 +2756,7 @@ function getExternalServiceStatusLabel(status: ExternalService["status"]) { function getExternalServiceShortName(serviceId: ExternalService["id"]) { const labels = { - yandex_cloud: "Yandex Cloud credentials", + yandex_cloud: "Yandex AI Studio / Cloud API key", yandex_metrica: "Yandex Metrica", yandex_search_serp: "Yandex SERP", yandex_webmaster: "Yandex Webmaster", @@ -620,10 +2768,10 @@ function getExternalServiceShortName(serviceId: ExternalService["id"]) { function getExternalMissingFieldLabel(fieldId: string) { const labels: Record = { - apiKey: "API key", + apiKey: "Yandex AI Studio API key", folderId: "folderId", iamToken: "IAM token", - yandex_cloud: "Yandex Cloud credentials" + yandex_cloud: "Yandex AI Studio / Cloud API key" }; return labels[fieldId] ?? fieldId; @@ -1479,7 +3627,23 @@ export function App() { const [activeProjectId, setActiveProjectId] = useState(null); const [scanSummaries, setScanSummaries] = useState>({}); const [semanticAnalyses, setSemanticAnalyses] = useState>({}); + const [contextReviews, setContextReviews] = useState>({}); + const [projectOntologies, setProjectOntologies] = useState>({}); const [marketEnrichments, setMarketEnrichments] = useState>({}); + const [keywordCleanings, setKeywordCleanings] = useState>({}); + const [keywordMaps, setKeywordMaps] = useState>({}); + const [seoStrategies, setSeoStrategies] = useState>({}); + const [yandexEvidences, setYandexEvidences] = useState>({}); + const [serpInterpretations, setSerpInterpretations] = useState>({}); + const [strategySyntheses, setStrategySyntheses] = useState>({}); + const [strategyQualityReviews, setStrategyQualityReviews] = useState>({}); + const [selectedStrategyPhaseIds, setSelectedStrategyPhaseIds] = useState< + Record + >({}); + const [rewritePlans, setRewritePlans] = useState>({}); + const [materializationContracts, setMaterializationContracts] = useState>({}); + const [rewriteDiffContracts, setRewriteDiffContracts] = useState>({}); + const [modelProviderStatuses, setModelProviderStatuses] = useState>({}); const [activeSemanticClusterIds, setActiveSemanticClusterIds] = useState>({}); const [projectsError, setProjectsError] = useState(null); const [projectsLoading, setProjectsLoading] = useState(true); @@ -1489,13 +3653,28 @@ export function App() { const [isSubmitting, setIsSubmitting] = useState(false); const [isFolderDragActive, setIsFolderDragActive] = useState(false); const [folderPickerMessage, setFolderPickerMessage] = useState(null); + const [pendingProjectName, setPendingProjectName] = useState(""); + const [isProjectNameModalOpen, setIsProjectNameModalOpen] = useState(false); + const [isProjectMenuOpen, setIsProjectMenuOpen] = useState(false); + const [isWorkPanelOpen, setIsWorkPanelOpen] = useState(false); + const [isWorkPanelFullscreen, setIsWorkPanelFullscreen] = useState(false); const [editingProjectId, setEditingProjectId] = useState(null); const [editingProjectName, setEditingProjectName] = useState(""); const [busyProjectId, setBusyProjectId] = useState(null); const [pendingDeleteProjectId, setPendingDeleteProjectId] = useState(null); const [scanningProjectId, setScanningProjectId] = useState(null); const [analyzingProjectId, setAnalyzingProjectId] = useState(null); + const [reviewingContextProjectId, setReviewingContextProjectId] = useState(null); + const [approvingOntologyKey, setApprovingOntologyKey] = useState(null); + const [confirmingOntologyKey, setConfirmingOntologyKey] = useState(null); const [enrichingMarketProjectId, setEnrichingMarketProjectId] = useState(null); + const [collectingYandexEvidenceProjectId, setCollectingYandexEvidenceProjectId] = useState(null); + const [interpretingSerpProjectId, setInterpretingSerpProjectId] = useState(null); + const [synthesizingStrategyProjectId, setSynthesizingStrategyProjectId] = useState(null); + const [reviewingStrategyQualityProjectId, setReviewingStrategyQualityProjectId] = useState(null); + const [persistingKeywordMapProjectId, setPersistingKeywordMapProjectId] = useState(null); + const [materializingTargetKey, setMaterializingTargetKey] = useState(null); + const [runningModelTaskKey, setRunningModelTaskKey] = useState(null); const [exportingSemanticKey, setExportingSemanticKey] = useState(null); const [selectingPagesProjectId, setSelectingPagesProjectId] = useState(null); const [auditIssueLimits, setAuditIssueLimits] = useState>({}); @@ -1520,6 +3699,8 @@ export function App() { const [externalServiceDrafts, setExternalServiceDrafts] = useState>({}); const [externalServicesLoading, setExternalServicesLoading] = useState(false); const [savingExternalServiceId, setSavingExternalServiceId] = useState(null); + const [probingExternalServiceId, setProbingExternalServiceId] = useState(null); + const [yandexAiStudioProbe, setYandexAiStudioProbe] = useState(null); const [externalServicesError, setExternalServicesError] = useState(null); const [externalServicesMessage, setExternalServicesMessage] = useState(null); @@ -1553,6 +3734,36 @@ export function App() { setSemanticAnalyses(Object.fromEntries(analysisEntries)); } + async function loadContextReviews(projectList: ProjectSummary[]) { + const contextReviewEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestSeoContextReview(project.id); + return [project.id, result.contextReview] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setContextReviews(Object.fromEntries(contextReviewEntries)); + } + + async function loadProjectOntologies(projectList: ProjectSummary[]) { + const ontologyEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestProjectOntology(project.id); + return [project.id, result.ontology] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setProjectOntologies(Object.fromEntries(ontologyEntries)); + } + async function loadMarketEnrichments(projectList: ProjectSummary[]) { const marketEntries = await Promise.all( projectList.map(async (project) => { @@ -1568,6 +3779,171 @@ export function App() { setMarketEnrichments(Object.fromEntries(marketEntries)); } + async function loadKeywordCleanings(projectList: ProjectSummary[]) { + const keywordCleaningEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestKeywordCleaning(project.id); + return [project.id, result.keywordCleaning] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setKeywordCleanings(Object.fromEntries(keywordCleaningEntries)); + } + + async function loadKeywordMaps(projectList: ProjectSummary[]) { + const keywordMapEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestKeywordMap(project.id); + return [project.id, result.keywordMap] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setKeywordMaps(Object.fromEntries(keywordMapEntries)); + } + + async function loadSeoStrategies(projectList: ProjectSummary[]) { + const strategyEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestSeoStrategy(project.id); + return [project.id, result.strategy] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setSeoStrategies(Object.fromEntries(strategyEntries)); + } + + async function loadYandexEvidences(projectList: ProjectSummary[]) { + const evidenceEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestYandexEvidence(project.id); + return [project.id, result.yandexEvidence] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setYandexEvidences(Object.fromEntries(evidenceEntries)); + } + + async function loadSerpInterpretations(projectList: ProjectSummary[]) { + const interpretationEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestSerpInterpretation(project.id); + return [project.id, result.serpInterpretation] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setSerpInterpretations(Object.fromEntries(interpretationEntries)); + } + + async function loadStrategySyntheses(projectList: ProjectSummary[]) { + const synthesisEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestStrategySynthesis(project.id); + return [project.id, result.strategySynthesis] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setStrategySyntheses(Object.fromEntries(synthesisEntries)); + } + + async function loadStrategyQualityReviews(projectList: ProjectSummary[]) { + const reviewEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestStrategyQualityReview(project.id); + return [project.id, result.strategyQualityReview] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setStrategyQualityReviews(Object.fromEntries(reviewEntries)); + } + + async function loadRewritePlans(projectList: ProjectSummary[]) { + const rewritePlanEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestRewritePlan(project.id); + return [project.id, result.rewritePlan] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setRewritePlans(Object.fromEntries(rewritePlanEntries)); + } + + async function loadMaterializationContracts(projectList: ProjectSummary[]) { + const materializationEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestMaterialization(project.id); + return [project.id, result.materialization] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setMaterializationContracts(Object.fromEntries(materializationEntries)); + } + + async function loadRewriteDiffContracts(projectList: ProjectSummary[]) { + const rewriteDiffEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchLatestRewriteDiff(project.id); + return [project.id, result.rewriteDiff] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setRewriteDiffContracts(Object.fromEntries(rewriteDiffEntries)); + } + + async function loadModelProviderStatuses(projectList: ProjectSummary[]) { + const statusEntries = await Promise.all( + projectList.map(async (project) => { + try { + const result = await fetchModelProviderStatus(project.id); + return [project.id, result.modelProvider] as const; + } catch { + return [project.id, null] as const; + } + }) + ); + + setModelProviderStatuses(Object.fromEntries(statusEntries)); + } + async function loadExternalServices() { setExternalServicesLoading(true); setExternalServicesError(null); @@ -1587,6 +3963,7 @@ export function App() { function openExternalServicesModal() { setIsExternalServicesOpen(true); + setYandexAiStudioProbe(null); void loadExternalServices(); } @@ -1644,7 +4021,15 @@ export function App() { setExternalServicesMessage(`${result.service.label}: настройки сохранены.`); if (service.id === "yandex_wordstat") { - await loadMarketEnrichments(projects); + await Promise.all([ + loadMarketEnrichments(projects), + loadKeywordCleanings(projects), + loadKeywordMaps(projects), + loadSeoStrategies(projects), + loadRewritePlans(projects), + loadMaterializationContracts(projects), + loadRewriteDiffContracts(projects) + ]); } } catch (error: unknown) { setExternalServicesError(getErrorMessage(error, "Не удалось сохранить внешний сервис.")); @@ -1653,6 +4038,25 @@ export function App() { } } + async function handleProbeYandexAiStudioCredential() { + setProbingExternalServiceId("yandex_cloud"); + setExternalServicesError(null); + setExternalServicesMessage(null); + + try { + const result = await probeYandexAiStudioCredential(); + + setYandexAiStudioProbe(result.probe); + setExternalServicesMessage( + `Yandex AI Studio: ${result.probe.capabilities.filter((capability) => capability.ok).length}/${result.probe.capabilities.length} checks ok.` + ); + } catch (error: unknown) { + setExternalServicesError(getErrorMessage(error, "Не удалось проверить Yandex AI Studio.")); + } finally { + setProbingExternalServiceId(null); + } + } + async function loadProjects() { setProjectsLoading(true); setProjectsError(null); @@ -1660,7 +4064,24 @@ export function App() { try { const result = await fetchProjects(); setProjects(result.projects); - await Promise.all([loadScanSummaries(result.projects), loadSemanticAnalyses(result.projects), loadMarketEnrichments(result.projects)]); + await Promise.all([ + loadScanSummaries(result.projects), + loadSemanticAnalyses(result.projects), + loadContextReviews(result.projects), + loadProjectOntologies(result.projects), + loadMarketEnrichments(result.projects), + loadKeywordCleanings(result.projects), + loadKeywordMaps(result.projects), + loadYandexEvidences(result.projects), + loadSerpInterpretations(result.projects), + loadSeoStrategies(result.projects), + loadStrategySyntheses(result.projects), + loadStrategyQualityReviews(result.projects), + loadRewritePlans(result.projects), + loadMaterializationContracts(result.projects), + loadRewriteDiffContracts(result.projects), + loadModelProviderStatuses(result.projects) + ]); } catch (error: unknown) { setProjectsError(getErrorMessage(error, "Не удалось загрузить проекты.")); } finally { @@ -1668,6 +4089,13 @@ export function App() { } } + function invalidateStrategyQualityReview(projectId: string) { + setStrategyQualityReviews((currentReviews) => ({ + ...currentReviews, + [projectId]: null + })); + } + useEffect(() => { let cancelled = false; @@ -1891,11 +4319,9 @@ export function App() { return; } - if (activeProjectId && projects.some((project) => project.id === activeProjectId)) { - return; + if (activeProjectId && !projects.some((project) => project.id === activeProjectId)) { + setActiveProjectId(null); } - - setActiveProjectId(projects[0].id); }, [activeProjectId, projects]); useEffect(() => { @@ -1953,6 +4379,8 @@ export function App() { const stats = getFolderStats(folder); setPickedFolder(folder); + setPendingProjectName(folder.rootName); + setIsProjectNameModalOpen(true); setFolderPickerMessage( `Выбрана папка «${folder.rootName}»: ${stats.fileCount} файлов, ${formatBytes(stats.totalBytes)}.` ); @@ -1962,6 +4390,8 @@ export function App() { setPickedFolder(null); setFolderPickerMessage(null); setCreateError(null); + setPendingProjectName(""); + setIsProjectNameModalOpen(false); if (folderInputRef.current) { folderInputRef.current.value = ""; @@ -2017,12 +4447,23 @@ export function App() { } } - async function handleCreateProject(event: FormEvent) { - event.preventDefault(); + async function createProjectFromFolder(folder: PickedFolder, projectName: string) { setCreateError(null); setCreateMessage(null); - if (!pickedFolder) { + const normalizedProjectName = projectName.trim(); + + if (!normalizedProjectName) { + setCreateError("Название проекта не должно быть пустым."); + return; + } + + if (hasProjectNameConflict(normalizedProjectName)) { + setCreateError(`Проект с названием «${normalizedProjectName}» уже есть.`); + return; + } + + if (!folder) { setCreateError("Выбери папку сайта или перетащи её в область импорта."); return; } @@ -2031,13 +4472,17 @@ export function App() { try { const result = await importBrowserFolder({ - name: pickedFolder.rootName, - rootName: pickedFolder.rootName, - files: await serializeFolderFiles(pickedFolder) + name: normalizedProjectName, + rootName: folder.rootName, + files: await serializeFolderFiles(folder) }); setPickedFolder(null); setFolderPickerMessage(null); + setPendingProjectName(""); + setIsProjectNameModalOpen(false); + setIsProjectMenuOpen(false); + setIsWorkPanelFullscreen(false); setActiveProjectId(result.project.id); setProjects((currentProjects) => [ result.project, @@ -2047,10 +4492,30 @@ export function App() { ...currentSummaries, [result.project.id]: null })); + setProjectOntologies((currentOntologies) => ({ + ...currentOntologies, + [result.project.id]: null + })); setMarketEnrichments((currentEnrichments) => ({ ...currentEnrichments, [result.project.id]: null })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [result.project.id]: null + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [result.project.id]: null + })); + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [result.project.id]: null + })); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [result.project.id]: null + })); setCreateMessage( `Проект «${result.project.name}» создан, папка импортирована в хранилище.\nПервичный скан запущен автоматически.` ); @@ -2063,6 +4528,17 @@ export function App() { } } + async function handleCreateProject(event: FormEvent) { + event.preventDefault(); + + if (!pickedFolder) { + setCreateError("Выбери папку сайта или перетащи её в область импорта."); + return; + } + + await createProjectFromFolder(pickedFolder, pendingProjectName || pickedFolder.rootName); + } + function startProjectRename(project: ProjectSummary) { renameCancelRef.current = false; setEditingProjectId(project.id); @@ -2180,10 +4656,30 @@ export function App() { ...currentAnalyses, [result.project.id]: null })); + setProjectOntologies((currentOntologies) => ({ + ...currentOntologies, + [result.project.id]: null + })); setMarketEnrichments((currentEnrichments) => ({ ...currentEnrichments, [result.project.id]: null })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [result.project.id]: null + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [result.project.id]: null + })); + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [result.project.id]: null + })); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [result.project.id]: null + })); setProjectActionProjectId(result.project.id); setProjectActionMessage(`Создана копия «${result.project.name}».`); } catch (error: unknown) { @@ -2210,10 +4706,35 @@ export function App() { ...currentAnalyses, [project.id]: null })); + setProjectOntologies((currentOntologies) => ({ + ...currentOntologies, + [project.id]: null + })); setMarketEnrichments((currentEnrichments) => ({ ...currentEnrichments, [project.id]: null })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: null + })); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: null + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [project.id]: null + })); + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: null + })); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: null + })); + invalidateStrategyQualityReview(project.id); if (!options.hideSuccessMessage) { setProjectActionMessage(getProjectScanActionMessage(result.scan)); } @@ -2237,15 +4758,67 @@ export function App() { ...currentAnalyses, [project.id]: result.analysis })); + setContextReviews((currentReviews) => ({ + ...currentReviews, + [project.id]: null + })); setActiveSemanticClusterIds((currentIds) => ({ ...currentIds, [project.id]: result.analysis.clusters[0]?.id ?? "" })); - const marketResult = await fetchLatestMarketEnrichment(project.id); + const [ + ontologyResult, + marketResult, + keywordCleaningResult, + keywordMapResult, + strategyResult, + rewritePlanResult, + materializationResult, + rewriteDiffResult + ] = + await Promise.all([ + fetchLatestProjectOntology(project.id), + fetchLatestMarketEnrichment(project.id), + fetchLatestKeywordCleaning(project.id), + fetchLatestKeywordMap(project.id), + fetchLatestSeoStrategy(project.id), + fetchLatestRewritePlan(project.id), + fetchLatestMaterialization(project.id), + fetchLatestRewriteDiff(project.id) + ]); + setProjectOntologies((currentOntologies) => ({ + ...currentOntologies, + [project.id]: ontologyResult.ontology + })); setMarketEnrichments((currentEnrichments) => ({ ...currentEnrichments, [project.id]: marketResult.marketEnrichment })); + setKeywordCleanings((currentKeywordCleanings) => ({ + ...currentKeywordCleanings, + [project.id]: keywordCleaningResult.keywordCleaning + })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: keywordMapResult.keywordMap + })); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: strategyResult.strategy + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [project.id]: rewritePlanResult.rewritePlan + })); + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: materializationResult.materialization + })); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: rewriteDiffResult.rewriteDiff + })); + invalidateStrategyQualityReview(project.id); setProjectActionMessage(getSemanticAnalysisMessage(result.analysis)); } catch (error: unknown) { setProjectActionError(getErrorMessage(error, "Не удалось выполнить semantic analysis.")); @@ -2279,6 +4852,252 @@ export function App() { } } + async function handleSeoContextReview(project: ProjectSummary) { + setReviewingContextProjectId(project.id); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await runSeoContextReview(project.id); + const [marketResult, keywordCleaningResult, keywordMapResult, strategyResult, rewritePlanResult, materializationResult, rewriteDiffResult] = await Promise.all([ + fetchLatestMarketEnrichment(project.id), + fetchLatestKeywordCleaning(project.id), + fetchLatestKeywordMap(project.id), + fetchLatestSeoStrategy(project.id), + fetchLatestRewritePlan(project.id), + fetchLatestMaterialization(project.id), + fetchLatestRewriteDiff(project.id) + ]); + + setContextReviews((currentReviews) => ({ + ...currentReviews, + [project.id]: result.contextReview + })); + setMarketEnrichments((currentEnrichments) => ({ + ...currentEnrichments, + [project.id]: marketResult.marketEnrichment + })); + setKeywordCleanings((currentKeywordCleanings) => ({ + ...currentKeywordCleanings, + [project.id]: keywordCleaningResult.keywordCleaning + })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: keywordMapResult.keywordMap + })); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: strategyResult.strategy + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [project.id]: rewritePlanResult.rewritePlan + })); + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: materializationResult.materialization + })); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: rewriteDiffResult.rewriteDiff + })); + invalidateStrategyQualityReview(project.id); + setProjectActionMessage( + `Context Review сохранён: ${result.contextReview.readiness.normalizationHintCount} hints, ${result.contextReview.readiness.forbiddenClaimCount} forbidden claims.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось сохранить context review.")); + } finally { + setReviewingContextProjectId(null); + } + } + + async function handleSeoModelTaskRun(project: ProjectSummary, taskType: SeoModelTaskType) { + const taskKey = `${project.id}:${taskType}`; + setRunningModelTaskKey(taskKey); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await runSeoModelTask(project.id, { + providerId: "codex_manual", + taskType + }); + const statusResult = await fetchModelProviderStatus(project.id); + + setModelProviderStatuses((currentStatuses) => ({ + ...currentStatuses, + [project.id]: statusResult.modelProvider + })); + setProjectActionMessage( + `${getModelTaskTypeLabel(taskType)} Codex task route: ${getModelTaskRunStatusLabel(result.modelTaskRun.status)} · ~${ + result.modelTaskRun.estimate.inputTokenEstimate + } input tokens.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось подготовить Codex task route.")); + } finally { + setRunningModelTaskKey(null); + } + } + + async function handleProjectOntologyApproval( + project: ProjectSummary, + ontology: ProjectOntologyVersion, + status: ProjectOntologyApprovalStatus + ) { + const approvalKey = `${project.id}:${ontology.id}:${status}`; + setApprovingOntologyKey(approvalKey); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await updateProjectOntologyApproval(project.id, ontology.id, status); + + setProjectOntologies((currentOntologies) => ({ + ...currentOntologies, + [project.id]: result.ontology + })); + setSemanticAnalyses((currentAnalyses) => { + const currentAnalysis = currentAnalyses[project.id]; + + if (!currentAnalysis?.projectOntology || currentAnalysis.projectOntology.versionId !== result.ontology.id) { + return currentAnalyses; + } + + return { + ...currentAnalyses, + [project.id]: { + ...currentAnalysis, + projectOntology: { + ...currentAnalysis.projectOntology, + instance: currentAnalysis.projectOntology.instance + ? { + ...currentAnalysis.projectOntology.instance, + approvalStatus: result.ontology.approvalStatus + } + : currentAnalysis.projectOntology.instance + } + } + }; + }); + const [marketResult, keywordCleaningResult, keywordMapResult, strategyResult, rewritePlanResult, materializationResult, rewriteDiffResult] = await Promise.all([ + fetchLatestMarketEnrichment(project.id), + fetchLatestKeywordCleaning(project.id), + fetchLatestKeywordMap(project.id), + fetchLatestSeoStrategy(project.id), + fetchLatestRewritePlan(project.id), + fetchLatestMaterialization(project.id), + fetchLatestRewriteDiff(project.id) + ]); + setMarketEnrichments((currentEnrichments) => ({ + ...currentEnrichments, + [project.id]: marketResult.marketEnrichment + })); + setKeywordCleanings((currentKeywordCleanings) => ({ + ...currentKeywordCleanings, + [project.id]: keywordCleaningResult.keywordCleaning + })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: keywordMapResult.keywordMap + })); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: strategyResult.strategy + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [project.id]: rewritePlanResult.rewritePlan + })); + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: materializationResult.materialization + })); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: rewriteDiffResult.rewriteDiff + })); + invalidateStrategyQualityReview(project.id); + setProjectActionMessage(`Project ontology v${result.ontology.version}: ${getProjectOntologyApprovalLabel(result.ontology.approvalStatus)}.`); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось обновить project ontology.")); + } finally { + setApprovingOntologyKey(null); + } + } + + async function handleProjectOntologyConfirmation( + project: ProjectSummary, + ontology: ProjectOntologyVersion, + input: { + aliasesConfirmed?: boolean; + brandConfirmed?: boolean; + } + ) { + const confirmationType = typeof input.brandConfirmed === "boolean" ? "brand" : "aliases"; + const confirmationKey = `${project.id}:${ontology.id}:${confirmationType}`; + setConfirmingOntologyKey(confirmationKey); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await updateProjectOntologyConfirmations(project.id, ontology.id, input); + const [marketResult, keywordCleaningResult, keywordMapResult, strategyResult, rewritePlanResult, materializationResult, rewriteDiffResult] = await Promise.all([ + fetchLatestMarketEnrichment(project.id), + fetchLatestKeywordCleaning(project.id), + fetchLatestKeywordMap(project.id), + fetchLatestSeoStrategy(project.id), + fetchLatestRewritePlan(project.id), + fetchLatestMaterialization(project.id), + fetchLatestRewriteDiff(project.id) + ]); + + setProjectOntologies((currentOntologies) => ({ + ...currentOntologies, + [project.id]: result.ontology + })); + setMarketEnrichments((currentEnrichments) => ({ + ...currentEnrichments, + [project.id]: marketResult.marketEnrichment + })); + setKeywordCleanings((currentKeywordCleanings) => ({ + ...currentKeywordCleanings, + [project.id]: keywordCleaningResult.keywordCleaning + })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: keywordMapResult.keywordMap + })); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: strategyResult.strategy + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [project.id]: rewritePlanResult.rewritePlan + })); + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: materializationResult.materialization + })); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: rewriteDiffResult.rewriteDiff + })); + invalidateStrategyQualityReview(project.id); + setProjectActionMessage(`Project ontology v${result.ontology.version}: confirmations обновлены.`); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось обновить confirmations project ontology.")); + } finally { + setConfirmingOntologyKey(null); + } + } + async function handleMarketEnrichment(project: ProjectSummary) { setEnrichingMarketProjectId(project.id); setProjectActionError(null); @@ -2292,6 +5111,39 @@ export function App() { ...currentEnrichments, [project.id]: result.marketEnrichment })); + const [keywordCleaningResult, keywordMapResult, strategyResult, rewritePlanResult, materializationResult, rewriteDiffResult] = await Promise.all([ + fetchLatestKeywordCleaning(project.id), + fetchLatestKeywordMap(project.id), + fetchLatestSeoStrategy(project.id), + fetchLatestRewritePlan(project.id), + fetchLatestMaterialization(project.id), + fetchLatestRewriteDiff(project.id) + ]); + setKeywordCleanings((currentKeywordCleanings) => ({ + ...currentKeywordCleanings, + [project.id]: keywordCleaningResult.keywordCleaning + })); + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: keywordMapResult.keywordMap + })); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: strategyResult.strategy + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [project.id]: rewritePlanResult.rewritePlan + })); + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: materializationResult.materialization + })); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: rewriteDiffResult.rewriteDiff + })); + invalidateStrategyQualityReview(project.id); setProjectActionMessage( result.marketEnrichment.state === "not_configured" ? "Market enrichment готов к запуску, но платформенный provider ещё не настроен." @@ -2304,6 +5156,204 @@ export function App() { } } + async function handleYandexEvidence(project: ProjectSummary) { + setCollectingYandexEvidenceProjectId(project.id); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await runYandexEvidence(project.id, 5); + const strategyResult = await fetchLatestSeoStrategy(project.id); + + setYandexEvidences((currentEvidences) => ({ + ...currentEvidences, + [project.id]: result.yandexEvidence + })); + setSerpInterpretations((currentInterpretations) => ({ + ...currentInterpretations, + [project.id]: null + })); + setStrategySyntheses((currentSyntheses) => ({ + ...currentSyntheses, + [project.id]: null + })); + invalidateStrategyQualityReview(project.id); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: strategyResult.strategy + })); + setProjectActionMessage( + `Yandex evidence собрано: ${result.yandexEvidence.summary.checkedPhraseCount} фраз, ${formatCount( + result.yandexEvidence.summary.totalDemand + )} спрос, ${result.yandexEvidence.summary.serpDocCount} SERP docs.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось собрать Yandex evidence.")); + } finally { + setCollectingYandexEvidenceProjectId(null); + } + } + + async function handleSerpInterpretation(project: ProjectSummary) { + setInterpretingSerpProjectId(project.id); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await runSerpInterpretation(project.id); + const strategyResult = await fetchLatestSeoStrategy(project.id); + + setSerpInterpretations((currentInterpretations) => ({ + ...currentInterpretations, + [project.id]: result.serpInterpretation + })); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: strategyResult.strategy + })); + setStrategySyntheses((currentSyntheses) => ({ + ...currentSyntheses, + [project.id]: null + })); + invalidateStrategyQualityReview(project.id); + setProjectActionMessage( + `SERP interpretation: ${result.serpInterpretation.readiness.interpretedPhraseCount} фраз, ${result.serpInterpretation.readiness.competitorGapCount} competitor gaps, ${result.serpInterpretation.readiness.mismatchCount} mismatch.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось собрать SERP interpretation.")); + } finally { + setInterpretingSerpProjectId(null); + } + } + + async function handleStrategySynthesis(project: ProjectSummary) { + setSynthesizingStrategyProjectId(project.id); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await runStrategySynthesis(project.id); + + setStrategySyntheses((currentSyntheses) => ({ + ...currentSyntheses, + [project.id]: result.strategySynthesis + })); + invalidateStrategyQualityReview(project.id); + setProjectActionMessage( + `Strategy synthesis: ${result.strategySynthesis.readiness.decisionOptionCount} опции, ${result.strategySynthesis.readiness.dataGapCount} gaps, score ${result.strategySynthesis.readiness.confidenceScore}.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось собрать strategy synthesis.")); + } finally { + setSynthesizingStrategyProjectId(null); + } + } + + async function handleStrategyQualityReview(project: ProjectSummary) { + setReviewingStrategyQualityProjectId(project.id); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await runStrategyQualityReview(project.id); + + setStrategyQualityReviews((currentReviews) => ({ + ...currentReviews, + [project.id]: result.strategyQualityReview + })); + setProjectActionMessage( + `Strategy quality: ${getStrategyQualityVerdictLabel(result.strategyQualityReview.verdict.status)}, score ${ + result.strategyQualityReview.readiness.score + }, blockers ${result.strategyQualityReview.readiness.blockerCount}.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось собрать strategy quality review.")); + } finally { + setReviewingStrategyQualityProjectId(null); + } + } + + async function handleApproveKeywordMapDecisions(project: ProjectSummary) { + setPersistingKeywordMapProjectId(project.id); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await approveKeywordMapDecisions(project.id); + const [strategyResult, rewritePlanResult, materializationResult, rewriteDiffResult] = await Promise.all([ + fetchLatestSeoStrategy(project.id), + fetchLatestRewritePlan(project.id), + fetchLatestMaterialization(project.id), + fetchLatestRewriteDiff(project.id) + ]); + + setKeywordMaps((currentKeywordMaps) => ({ + ...currentKeywordMaps, + [project.id]: result.keywordMap + })); + setSeoStrategies((currentStrategies) => ({ + ...currentStrategies, + [project.id]: strategyResult.strategy + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [project.id]: rewritePlanResult.rewritePlan + })); + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: materializationResult.materialization + })); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: rewriteDiffResult.rewriteDiff + })); + invalidateStrategyQualityReview(project.id); + setProjectActionMessage( + `Keyword decisions сохранены: ${result.savedDecisionCount}, map items: ${result.savedMapItemCount}, skipped: ${result.skippedItemCount}.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось сохранить keyword decisions.")); + } finally { + setPersistingKeywordMapProjectId(null); + } + } + + async function handleMaterializationAction(project: ProjectSummary, input: MaterializationActionInput) { + const targetKey = `${project.id}:${input.targetPath}:${input.action}`; + setMaterializingTargetKey(targetKey); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await applyMaterializationAction(project.id, input); + + setMaterializationContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: result.materialization + })); + setRewritePlans((currentRewritePlans) => ({ + ...currentRewritePlans, + [project.id]: result.rewritePlan + })); + const rewriteDiffResult = await fetchLatestRewriteDiff(project.id); + setRewriteDiffContracts((currentContracts) => ({ + ...currentContracts, + [project.id]: rewriteDiffResult.rewriteDiff + })); + setProjectActionMessage(`Очередь посадочных обновлена: ${result.updatedMapItemCount} привязок фраз.`); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось применить решение по посадочной.")); + } finally { + setMaterializingTargetKey(null); + } + } + async function handleProjectPageSelection(project: ProjectSummary, pageIds: string[], selected: boolean) { if (pageIds.length === 0) { return; @@ -2732,6 +5782,31 @@ export function App() { delete nextAnalyses[project.id]; return nextAnalyses; }); + setProjectOntologies((currentOntologies) => { + const nextOntologies = { ...currentOntologies }; + delete nextOntologies[project.id]; + return nextOntologies; + }); + setMarketEnrichments((currentEnrichments) => { + const nextEnrichments = { ...currentEnrichments }; + delete nextEnrichments[project.id]; + return nextEnrichments; + }); + setKeywordCleanings((currentKeywordCleanings) => { + const nextKeywordCleanings = { ...currentKeywordCleanings }; + delete nextKeywordCleanings[project.id]; + return nextKeywordCleanings; + }); + setKeywordMaps((currentKeywordMaps) => { + const nextKeywordMaps = { ...currentKeywordMaps }; + delete nextKeywordMaps[project.id]; + return nextKeywordMaps; + }); + setRewritePlans((currentRewritePlans) => { + const nextRewritePlans = { ...currentRewritePlans }; + delete nextRewritePlans[project.id]; + return nextRewritePlans; + }); setActiveSemanticClusterIds((currentIds) => { const nextIds = { ...currentIds }; delete nextIds[project.id]; @@ -2801,13 +5876,78 @@ export function App() { }); } - const activeProject = - (activeProjectId ? projects.find((project) => project.id === activeProjectId) : null) ?? projects[0] ?? null; + const activeProject = activeProjectId ? (projects.find((project) => project.id === activeProjectId) ?? null) : null; const activeProjectScanSummary = activeProject ? scanSummaries[activeProject.id] : null; + const activeSemanticAnalysis = activeProject ? (semanticAnalyses[activeProject.id] ?? null) : null; + const activeContextReviewRaw = activeProject ? (contextReviews[activeProject.id] ?? null) : null; + const activeContextReview = + activeSemanticAnalysis && activeContextReviewRaw?.semanticRunId === activeSemanticAnalysis.runId + ? activeContextReviewRaw + : null; + const activeMarketEnrichment = activeProject ? (marketEnrichments[activeProject.id] ?? null) : null; + const activeKeywordMap = activeProject ? (keywordMaps[activeProject.id] ?? null) : null; + const activeSeoStrategy = activeProject ? (seoStrategies[activeProject.id] ?? null) : null; + const activeStrategySynthesis = activeProject ? (strategySyntheses[activeProject.id] ?? null) : null; + const activeStrategyQualityReview = activeProject ? (strategyQualityReviews[activeProject.id] ?? null) : null; + const activeProjectScanScopeCounts = activeProjectScanSummary ? getScanScopeCounts(activeProjectScanSummary) : null; + const activeProjectSelectedWorkspaceScopeItems = activeProjectScanSummary + ? getSelectedWorkspaceScopeItems(activeProjectScanSummary) + : []; + const activeProjectWorkspaceScopeId = + activeProject && activeProjectScanSummary + ? (activeWorkspaceScopeIds[activeProject.id] ?? activeProjectSelectedWorkspaceScopeItems[0]?.scopeId ?? null) + : null; + const activeProjectWorkspaceScopeItem = + activeProjectSelectedWorkspaceScopeItems.find((item) => item.scopeId === activeProjectWorkspaceScopeId) ?? + activeProjectSelectedWorkspaceScopeItems[0] ?? + null; + const activeProjectWorkspaceKey = + activeProject && activeProjectWorkspaceScopeItem + ? getWorkspaceKey(activeProject.id, activeProjectWorkspaceScopeItem.scopeId) + : null; + const activeProjectWorkspace = activeProjectWorkspaceKey ? pageWorkspaces[activeProjectWorkspaceKey] : null; + const activeProjectWorkspaceIssues = getPageIssues(activeProjectScanSummary, activeProjectWorkspaceScopeItem?.pageId ?? null); + const activeProjectWorkspaceTargets = activeProjectWorkspace + ? buildWorkspaceTargets(activeProjectWorkspace, activeProjectWorkspaceIssues) + : []; + const activeProjectWorkspacePreviewMode = activeProjectWorkspaceKey + ? (workspacePreviewModes[activeProjectWorkspaceKey] ?? "desktop") + : "desktop"; + const activeProjectWorkspaceDrawers = activeProject + ? WORKSPACE_DRAWER_ORDER.filter((drawer) => (workspaceDrawers[activeProject.id] ?? []).includes(drawer)) + : []; + const activeProjectWorkspacePageIndex = Math.max( + 0, + activeProjectSelectedWorkspaceScopeItems.findIndex((item) => item.scopeId === activeProjectWorkspaceScopeItem?.scopeId) + ); + const activeProjectSiteTitle = getProjectSiteTitle(activeProjectScanSummary); + const isActiveProjectSelectingPages = activeProject ? selectingPagesProjectId === activeProject.id : false; + const isActiveProjectScanning = activeProject ? scanningProjectId === activeProject.id : false; + const canConfirmActiveProjectScope = Boolean( + activeProject && + activeProjectScanSummary && + !isActiveProjectSelectingPages && + !isActiveProjectScanning && + (activeProjectScanScopeCounts?.selectedIndexable ?? 0) > 0 + ); const hasActiveProjectScanEntry = activeProject ? Object.prototype.hasOwnProperty.call(scanSummaries, activeProject.id) : false; const visibleProjects = activeStageIndex === 0 ? projects : activeProject ? [activeProject] : []; + const stageAccessList = pipeline.map((_, stageIndex) => + getPipelineStageAccess(stageIndex, { + contextReview: activeContextReview, + hasProject: Boolean(activeProject), + keywordMap: activeKeywordMap, + marketEnrichment: activeMarketEnrichment, + scan: activeProjectScanSummary, + semanticAnalysis: activeSemanticAnalysis, + seoStrategy: activeSeoStrategy, + strategyQualityReview: activeStrategyQualityReview, + strategySynthesis: activeStrategySynthesis + }) + ); + const activeStageAccess = stageAccessList[activeStageIndex] ?? stageAccessList[0]; useEffect(() => { if ( @@ -2866,9 +6006,86 @@ export function App() { } }, [activeWorkspaceScopeIds, busyWorkspaceKey, pageWorkspaces, projects, scanSummaries, workspacePreviewScopeIds]); - const createNameConflict = pickedFolder ? hasProjectNameConflict(pickedFolder.rootName) : false; - const canCreateProject = Boolean(pickedFolder) && !isSubmitting && !createNameConflict; + const createName = pendingProjectName.trim(); + const createNameConflict = Boolean(createName) && hasProjectNameConflict(createName); + const canCreateProject = Boolean(pickedFolder && createName) && !isSubmitting && !createNameConflict; const activeStage = stageMeta[activeStageIndex] ?? stageMeta[3]; + const topbarHeading = + activeProject && activeStageIndex === 1 + ? { + title: "Подтвердить конфигурацию проекта", + subtitle: "Выбранные посадочные попадут в рабочую зону, контекстный анализ, спрос и стратегию." + } + : activeProject && activeStageIndex === 2 && activeProjectWorkspaceScopeItem + ? { + title: activeProjectWorkspaceScopeItem.title, + subtitle: activeProjectWorkspace?.page.title ?? activeProjectSiteTitle + } + : { + title: workspaceMode === "dev" ? activeStage.title : "Автоматический SEO-процесс", + subtitle: workspaceMode === "dev" ? `Dev Mode · этап ${activeStageIndex + 1}` : "Auto Mode · целевой слой" + }; + const topbarStageActions = + activeProject && activeProjectScanSummary && activeStageIndex === 1 ? ( + + ) : activeProject && activeStageIndex === 2 && activeProjectWorkspaceScopeItem ? ( +
+ + +
+ {WORKSPACE_PREVIEW_MODES.map((mode) => ( + + ))} +
+
+ ) : null; function openProjectScanStage(project: ProjectSummary) { if (editingProjectId === project.id || pendingDeleteProjectId === project.id) { @@ -2877,102 +6094,197 @@ export function App() { setActiveProjectId(project.id); setActiveStageIndex(1); + setIsProjectMenuOpen(false); + setIsWorkPanelOpen(true); + setIsWorkPanelFullscreen(false); window.requestAnimationFrame(() => { document.querySelector(".topbar")?.scrollIntoView({ behavior: "smooth", block: "start" }); }); } + function openProjectFromMenu(project: ProjectSummary) { + const nextStageIndex = activeProjectId === project.id ? activeStageIndex : 1; + + setActiveProjectId(project.id); + setActiveStageIndex(nextStageIndex); + setIsProjectMenuOpen(false); + setIsWorkPanelOpen(false); + setIsWorkPanelFullscreen(false); + } + + function closeActiveProject() { + setActiveProjectId(null); + setActiveStageIndex(0); + setIsProjectMenuOpen(false); + setIsWorkPanelOpen(false); + setIsWorkPanelFullscreen(false); + } + + function closeWorkPanel() { + setIsWorkPanelOpen(false); + setIsWorkPanelFullscreen(false); + } + + function handlePipelineStageClick(stageIndex: number) { + const access = stageAccessList[stageIndex]; + + if (!access?.unlocked) { + setProjectActionProjectId(null); + setProjectActionError(null); + setProjectActionMessage(access?.reason ?? "Этот этап пока недоступен."); + return; + } + + setActiveStageIndex(stageIndex); + setIsWorkPanelOpen(true); + } + + function confirmProjectConfiguration(project: ProjectSummary, scan: ProjectScanSummary) { + const scopeCounts = getScanScopeCounts(scan); + + if (!isScanReadyForFlow(scan) || scopeCounts.selectedIndexable === 0) { + setProjectActionProjectId(project.id); + setProjectActionError("Сначала нужен успешный скан и хотя бы одна выбранная посадочная страница."); + setProjectActionMessage(null); + return; + } + + setProjectActionProjectId(project.id); + setProjectActionError(null); + setProjectActionMessage( + `Конфигурация подтверждена: ${scopeCounts.selectedIndexable}/${scopeCounts.indexable} посадочных идут в SEO-пайплайн.` + ); + setActiveStageIndex(2); + } + return ( -
-
-
-
sm
-
-

seo_mode

-

SEO workspace

+
+
+
+
+
+ + NODE.DC + +
+ +
+
+ + + {isProjectMenuOpen ? ( +
+
+ SEO Mode + {activeProject ? activeProject.name : "Выберите проект"} +
+ {projectsLoading ? ( +
+ + Загружаю проекты... +
+ ) : projects.length === 0 ? ( +
+ + Проектов пока нет +
+ ) : ( +
+ {projects.map((project) => { + const scanSummary = scanSummaries[project.id]; + const isCurrent = activeProjectId === project.id; + + return ( + + ); + })} +
+ )} + +
+ ) : null} +
+ +
+ + +
+
+ +
+
+ +
+ + Профиль + + +
+
+
- - - -
- - - -
-
- - {health ? "Бэкенд работает" : "Бэкенд ждёт запуска"} -
- -
- -
-

Бэкенд

-

{health ? "Сервер seo_mode отвечает" : (healthError ?? "Ждём ответ от /health")}

-
-
-
- -
-

Postgres

-

Состояние проекта, версии и решения

-
-
-
- -
-

MinIO/S3

-

Артефакты, снимки, отчёты и экспорты

-
-
-
- - - Ближайший кусок сборки - -
-
- 01 -

Рабочая папка, TypeScript, проверка бэкенда, React-каркас

-
-
- 02 -

Миграции Postgres и модель источника проекта

-
-
- 03 -

Создание, переименование, копирование и удаление проекта

-
-
- 04 -

Версия скана, список страниц и ассетов, сверка изменений

-
-
- 05 -

projectIndex.json и базовый аудит title/description/headings/media

-
-
-
-
-
{isExternalServicesOpen ? ( @@ -2994,8 +6306,9 @@ export function App() {

Внешние сервисы

- Сейчас нужен один Yandex Cloud credential для Wordstat. SERP позже использует его же; Webmaster и - Metrica остаются отдельными OAuth-слоями. + Сейчас нужен один Yandex AI Studio / Cloud credential: folderId и API key. Wordstat, SERP/WebSearch + и будущий model layer используют его по ролям service account; Webmaster и Metrica подключаются после + деплоя сайта.

+ ) : null} +
+ + {createNameConflict ? ( +

+ + Проект с названием «{createName}» уже есть. +

+ ) : null} + {createError ? ( +

+ + {createError} +

+ ) : null} +
+ + +
+ +
+ ) : null} +
+
+
+ +
+ NODE.DC + SEO Mode +
+
+
+ + {activeProject ? ( + + ) : null} + + {!activeProject || isWorkPanelOpen ? ( +
-
-

- {workspaceMode === "dev" ? `Dev Mode · этап ${activeStageIndex + 1}` : "Auto Mode · целевой слой"} -

-

{workspaceMode === "dev" ? activeStage.title : "Автоматический SEO-процесс"}

-
-
- - +
+

{topbarHeading.title}

+

{topbarHeading.subtitle}

+ {activeProject ? ( +
+ {topbarStageActions} +
+ + +
+
+ ) : null}
+ {workspaceMode === "dev" ? ( +
+
+ {activeStageAccess.unlocked ? "этап доступен" : "этап закрыт"} + {activeProject ? activeProject.name : "Проект не выбран"} + {activeStageAccess.reason} +
+ {activeProject ? ( +
+ {activeProjectScanSummary ? "инициализирован" : "ждёт инициализации"} + + {activeProjectScanSummary + ? `${activeProjectScanSummary.counts.pages} страниц · ${activeProjectScanSummary.counts.assets} ассетов` + : "нет scan contract"} + + {activeSeoStrategy ? getSeoMaturityPhaseLabel(activeSeoStrategy.siteMaturity.phase) : "стратегия не собрана"} +
+ ) : ( + + )} +
+ ) : null} + {workspaceMode === "auto" ? (
@@ -3254,58 +6736,72 @@ export function App() { ) : null}
-
- -
-

{activeStage.sectionTitle}

- {activeStageIndex === 1 && activeProject ? ( - <> -
- {editingProjectId === activeProject.id ? ( - void commitProjectRename(activeProject)} - onChange={(event) => setEditingProjectName(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void commitProjectRename(activeProject); - } + {activeStageIndex === 1 ? null : ( +
+ +
+

{activeStage.sectionTitle}

+ {activeStageIndex === 1 && activeProject ? ( + <> +
+ {editingProjectId === activeProject.id ? ( + void commitProjectRename(activeProject)} + onChange={(event) => setEditingProjectName(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void commitProjectRename(activeProject); + } - if (event.key === "Escape") { - event.preventDefault(); - cancelProjectRename(); - } - }} - ref={titleInputRef} - value={editingProjectName} - /> - ) : ( - - )} - {busyProjectId === activeProject.id && editingProjectId === activeProject.id ? ( - + if (event.key === "Escape") { + event.preventDefault(); + cancelProjectRename(); + } + }} + ref={titleInputRef} + value={editingProjectName} + /> + ) : ( + + )} + {busyProjectId === activeProject.id && editingProjectId === activeProject.id ? ( + + ) : null} +
+ {editingProjectId === activeProject.id && editingProjectName.trim().length === 0 ? ( + Название не должно быть пустым. ) : null} + {editingProjectId === activeProject.id && hasProjectNameConflict(editingProjectName, activeProject.id) ? ( + Проект с таким названием уже есть. + ) : null} + + ) : null} +

{activeStage.description}

+ {activeProject ? ( +
+ {activeStageAccess.unlocked ? "этап доступен" : "этап закрыт"} + {activeProjectScanSummary ? "инициализирован" : "ждёт инициализации"} + {activeProjectScanSummary ? ( + + {activeProjectScanSummary.counts.pages} страниц · {activeProjectScanSummary.counts.assets} ассетов + + ) : null} + {activeSeoStrategy ? getSeoMaturityPhaseLabel(activeSeoStrategy.siteMaturity.phase) : "Bootstrap / indexing"}
- {editingProjectId === activeProject.id && editingProjectName.trim().length === 0 ? ( - Название не должно быть пустым. - ) : null} - {editingProjectId === activeProject.id && hasProjectNameConflict(editingProjectName, activeProject.id) ? ( - Проект с таким названием уже есть. - ) : null} - - ) : null} -

{activeStage.description}

+ ) : null} +
-
+ )}
@@ -3328,9 +6824,9 @@ export function App() { /> - или перетащи папку сюда, чтобы импортировать файлы + перетащи папку или архив сюда, чтобы импортировать файлы
{isSubmitting ? (
@@ -3433,7 +6929,43 @@ export function App() { {visibleProjects.map((project) => { const scanSummary = scanSummaries[project.id]; const semanticAnalysis = semanticAnalyses[project.id] ?? null; + const contextReview = contextReviews[project.id] ?? null; + const isContextReviewAligned = Boolean( + semanticAnalysis && contextReview && contextReview.semanticRunId === semanticAnalysis.runId + ); + const activeContextReview = isContextReviewAligned ? contextReview : null; + const projectOntology = projectOntologies[project.id] ?? null; const marketEnrichment = marketEnrichments[project.id] ?? null; + const keywordCleaning = keywordCleanings[project.id] ?? null; + const keywordMap = keywordMaps[project.id] ?? null; + const seoStrategy = seoStrategies[project.id] ?? null; + const yandexEvidence = yandexEvidences[project.id] ?? null; + const serpInterpretation = serpInterpretations[project.id] ?? null; + const strategySynthesis = strategySyntheses[project.id] ?? null; + const strategyQualityReview = strategyQualityReviews[project.id] ?? null; + const rewritePlan = rewritePlans[project.id] ?? null; + const materialization = materializationContracts[project.id] ?? null; + const rewriteDiff = rewriteDiffContracts[project.id] ?? null; + const stageDecisionFrame = stageDecisionFrames[activeStageIndex] ?? null; + const marketDecision = marketEnrichment ? getMarketDecision(marketEnrichment) : null; + const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null; + const keywordPagePlans = keywordMap ? getKeywordPlanPageSummaries(keywordMap) : []; + const keywordReviewItems = keywordMap ? getKeywordPlanReviewItems(keywordMap) : []; + const normalizationReviewQueues = marketEnrichment + ? { + human: marketEnrichment.normalization.seedStrategy.filter( + (seed) => seed.review.status === "needs_human" + ), + rejected: marketEnrichment.normalization.seedStrategy.filter( + (seed) => seed.review.status === "rejected" + ), + serp: marketEnrichment.normalization.seedStrategy.filter((seed) => seed.review.sendToSerp), + wordstat: marketEnrichment.normalization.seedStrategy.filter((seed) => seed.review.sendToWordstat) + } + : null; + const ontologyConfirmations = projectOntology?.evidenceSummary.confirmations; + const isBrandConfirmed = ontologyConfirmations?.brand.confirmed ?? false; + const isAliasesConfirmed = ontologyConfirmations?.aliases.confirmed ?? false; const activeSemanticClusterId = semanticAnalysis ? (activeSemanticClusterIds[project.id] ?? semanticAnalysis.clusters[0]?.id ?? "") : ""; const activeSemanticCluster = @@ -3442,7 +6974,44 @@ export function App() { null; const isScanning = scanningProjectId === project.id; const isAnalyzing = analyzingProjectId === project.id; + const isReviewingContext = reviewingContextProjectId === project.id; const isEnrichingMarket = enrichingMarketProjectId === project.id; + const isInterpretingSerp = interpretingSerpProjectId === project.id; + const isSynthesizingStrategy = synthesizingStrategyProjectId === project.id; + const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id; + const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id; + const modelProviderStatus = modelProviderStatuses[project.id] ?? null; + const defaultModelProvider = + modelProviderStatus?.catalog.providers.find( + (provider) => provider.id === modelProviderStatus.catalog.defaultProviderId + ) ?? + modelProviderStatus?.catalog.providers[0] ?? + null; + const latestModelTaskRun = modelProviderStatus?.latestRuns[0] ?? null; + const isRunningContextModelTask = runningModelTaskKey === `${project.id}:seo.context_review`; + const isRunningNormalizationModelTask = runningModelTaskKey === `${project.id}:seo.normalization`; + const isRunningKeywordCleaningModelTask = runningModelTaskKey === `${project.id}:seo.keyword_cleaning`; + const isRunningSerpInterpretationModelTask = + runningModelTaskKey === `${project.id}:seo.serp_interpretation`; + const isRunningStrategySynthesisModelTask = + runningModelTaskKey === `${project.id}:seo.strategy_synthesis`; + const isRunningStrategyQualityModelTask = + runningModelTaskKey === `${project.id}:seo.strategy_quality_review`; + const canPrepareStrategySynthesis = Boolean( + seoStrategy && + serpInterpretation?.state === "ready" && + seoStrategy.evidence.serpInterpretation.state === "ready" && + seoStrategy.opportunities.length > 0 + ); + const canPrepareStrategyQualityReview = Boolean( + strategySynthesis?.state === "ready" && + strategySynthesis.decisionOptions.length > 0 && + seoStrategy?.phasePlan.length + ); + const selectedStrategyPhaseId = seoStrategy + ? (selectedStrategyPhaseIds[project.id] ?? seoStrategy.siteMaturity.phase) + : null; + const selectedStrategyPhase = seoStrategy?.phasePlan.find((phase) => phase.phase === selectedStrategyPhaseId) ?? null; const isExportingSemantic = exportingSemanticKey?.startsWith(`${project.id}:`) ?? false; const isSelectingPages = selectingPagesProjectId === project.id; const selectedPagesCount = scanSummary ? getSelectedPagesCount(scanSummary) : 0; @@ -3610,6 +7179,19 @@ export function App() { ? getProjectScanStatusMessage(scanSummary) : "Скан ещё не запускался."}
+ {activeStageIndex === 0 ? ( +
+ +
+ ) : null} {activeStageIndex === 1 ? (
@@ -3829,7 +7411,7 @@ export function App() {
- Semantic analysis NodeDC + Анализ контекста {getSemanticRunDate(semanticAnalysis)}
@@ -3864,6 +7446,167 @@ export function App() {
{semanticAnalysis ? ( <> +
+
+ Контекст сайта + + {semanticAnalysis.projectOntology?.brandName ?? + projectOntology?.brandName ?? + project.name} + + + {semanticAnalysis.analysisVerdict?.summary ?? + "Детерминированный анализ собрал текст, meta, headings, JSON-контент и первичные intent clusters."} + +
+ +
+ Уверенность + + {semanticAnalysis.analysisVerdict + ? getSemanticVerdictConfidenceLabel(semanticAnalysis.analysisVerdict.confidence) + : `${semanticAnalysis.summary.coverageScore}% готовности`} + + + Доказательность {semanticAnalysis.summary.evidenceScore ?? semanticAnalysis.summary.coverageScore}% · + интенты {semanticAnalysis.summary.intentCoverageScore ?? semanticAnalysis.summary.coverageScore}% · + рынок{" "} + {semanticAnalysis.analysisVerdict + ? getSemanticVerdictObjectivityLabel(semanticAnalysis.analysisVerdict.marketObjectivity) + : "не проверен"} + +
+ +
+ Что найдено + + {semanticAnalysis.summary.coveredClusters} покрыто · {semanticAnalysis.summary.weakClusters} слабо ·{" "} + {semanticAnalysis.summary.missingClusters} пусто + + + Страницы {semanticAnalysis.pageScope.indexablePages} · JSON{" "} + {semanticAnalysis.pageScope.contentSources} · seed phrases{" "} + {semanticAnalysis.summary.seedCandidates} + +
+ +
0 || semanticAnalysis.summary.legacyFindings > 0 + ? "problem" + : semanticAnalysis.summary.qualityWarnings > 0 + ? "warning" + : "ok" + }`} + > + Риски + + {semanticAnalysis.summary.qualityProblems + semanticAnalysis.summary.legacyFindings > 0 + ? "Есть blockers" + : semanticAnalysis.summary.qualityWarnings > 0 + ? "Есть warnings" + : "Критичных рисков нет"} + + + Legacy {semanticAnalysis.summary.legacyFindings} · problems{" "} + {semanticAnalysis.summary.qualityProblems} · warnings {semanticAnalysis.summary.qualityWarnings} + +
+
+ +
+
+ Что сайт сейчас говорит + + Короткая выжимка из видимого текста, headings, meta и JSON-контента. Это основа для + проверки спроса, а не финальный SEO-план. + +
+
+ {semanticAnalysis.styleProfile.topTerms.slice(0, 8).map((term) => ( + + {term.term} · {term.count} + + ))} + {semanticAnalysis.clusters.slice(0, 5).map((cluster) => ( + + {cluster.title} + + ))} +
+
+ +
+
+ AI Context Review + + {activeContextReview + ? activeContextReview.needsHumanReview + ? "Сохранён, нужна проверка" + : "Сохранён" + : contextReview + ? "Устарел после нового анализа" + : "Не сохранён"} + + + {activeContextReview + ? `${formatDate(activeContextReview.generatedAt)} · ${activeContextReview.summary}` + : "Сохранённый review нужен перед рыночным спросом: forbidden claims, ambiguity и normalization hints."} + +
+
+ {activeContextReview?.provider.mode === "codex_manual" ? "Codex" : "Fallback"} + {activeContextReview?.analystReview.status ?? "not_reviewed"} + Hints {activeContextReview?.readiness.normalizationHintCount ?? 0} + Forbidden {activeContextReview?.readiness.forbiddenClaimCount ?? 0} + Gaps {activeContextReview?.readiness.semanticGapCount ?? 0} + Review {activeContextReview?.readiness.needsHumanReview ? "yes" : "no"} +
+ +
+ +
+ + +
+ +
+ + Технические детали + Открыть диагностику контекста + Кластеры, snippets, источники, gaps, briefs и seed candidates. + +
{semanticAnalysis.summary.coverageScore}% @@ -3900,6 +7643,169 @@ export function App() {
+ {semanticAnalysis.projectOntology ? ( + <> +
+
+ Project ontology + + {projectOntology?.brandName ?? semanticAnalysis.projectOntology.brandName} ·{" "} + {getProjectOntologySourceLabel( + projectOntology?.source ?? semanticAnalysis.projectOntology.source + )}{" "} + · seo@ + {projectOntology?.domainPackage.version ?? + semanticAnalysis.projectOntology.domainPackage.version} + +
+ + Version:{" "} + {projectOntology + ? `v${projectOntology.version}` + : semanticAnalysis.projectOntology.version + ? `v${semanticAnalysis.projectOntology.version}` + : "not saved"} + + + Status:{" "} + {projectOntology + ? getProjectOntologyApprovalLabel(projectOntology.approvalStatus) + : semanticAnalysis.projectOntology.instance + ? getProjectOntologyApprovalLabel( + semanticAnalysis.projectOntology.instance.approvalStatus + ) + : "none"} + + + Pages:{" "} + {projectOntology?.evidenceSummary.pageCount ?? + semanticAnalysis.projectOntology.instance?.pageCount ?? + 0} + + + Intents:{" "} + {projectOntology?.evidenceSummary.intentClusterCount ?? + semanticAnalysis.projectOntology.instance?.intentClusterCount ?? + 0} + +
+ + {projectOntology ? ( +
+ Evidence и approval для project ontology +
+ {(["approved", "needs_review", "rejected"] as const).map((status) => { + const approvalKey = `${project.id}:${projectOntology.id}:${status}`; + + return ( + + ); + })} +
+
+ + +
+

+ Instance: {projectOntology.schemaVersion} · source {projectOntology.source} · scan{" "} + {projectOntology.scanVersionId ?? "none"} · run{" "} + {projectOntology.semanticRunId ?? "none"} +

+ + Evidence: {projectOntology.evidenceSummary.evidenceCount} · Sections:{" "} + {projectOntology.evidenceSummary.sectionCount} + + {(projectOntology.evidenceSummary.reviewItems ?? []).length > 0 ? ( +
+ {(projectOntology.evidenceSummary.reviewItems ?? []).map((item) => ( +
+ {getSemanticQualityLabel(item.level)} +
+ {item.title} + {item.message} +
+
+ ))} +
+ ) : null} + {projectOntology.evidenceSummary.intentClusters.length > 0 ? ( +
+ {projectOntology.evidenceSummary.intentClusters.slice(0, 6).map((cluster) => ( + + {cluster.title} · {cluster.priority} + + ))} +
+ ) : null} + {projectOntology.evidenceSummary.evidence.length > 0 ? ( +
+ {projectOntology.evidenceSummary.evidence.slice(0, 4).map((evidence) => ( +
+ {evidence.sourceType} +
+ {evidence.summary} + {evidence.ref} +
+
+ ))} +
+ ) : null} +
+ ) : null} + + ) : null} +
Scope анализа @@ -4244,7 +8150,7 @@ export function App() {
План посадочных - Очередь страниц: что создать, что усилить, что отдать на Wordstat/SERP. + Очередь возможностей: что усилить, что проверить через Wordstat/SERP, что оставить гипотезой.
{(semanticAnalysis.landingPagePlan ?? []).slice(0, 6).map((page) => (
@@ -4421,128 +8327,358 @@ export function App() { ))}
+
+
-
- Следующие действия - {semanticAnalysis.nextActions.map((action) => ( -

{action}

- ))} -
) : (
- Запусти semantic analysis: модуль прочитает HTML, JSON-контент, посчитает покрытие - NodeDC-онтологии, gaps, legacy-блокеры и стартовые сиды для Wordstat. + Запусти Context Review: модуль прочитает HTML, JSON-контент, meta и headings, чтобы + собрать смысл сайта, риски, gaps и seed phrases для проверки спроса.
)}
) : null} + {stageDecisionFrame ? ( +
+ Решение этапа +
+ {stageDecisionFrame.question} + {stageDecisionFrame.result} +
+

{stageDecisionFrame.gate}

+
+ ) : null} + {activeStageIndex === 4 ? (
- Market enrichment - - Платформенный слой для Wordstat/SERP evidence. Пользователь не настраивает MCP и - внешние аккаунты вручную. - + Рыночный спрос + Какие темы сайта подтверждены внешним спросом, а какие пока остаются гипотезой.
+ {semanticAnalysis && !activeContextReview ? ( +
+
+ Перед рыночным спросом нужен Context Review + + Сначала зафиксируй forbidden claims и normalization hints, чтобы Wordstat не получал сырые + внутренние формулировки сайта. + +
+ +
+ ) : null} + {semanticAnalysis && marketEnrichment ? ( <>
{getMarketStateLabel(marketEnrichment.state)} - Seeds: {marketEnrichment.readiness.seedCount} · Briefs:{" "} + Темы: {marketEnrichment.readiness.seedCount} · Посадочные гипотезы:{" "} {marketEnrichment.readiness.briefCount} - Provider connected: {marketEnrichment.readiness.configuredProviderCount}/ - {marketEnrichment.providers.length} · user setup:{" "} - {marketEnrichment.readiness.userSetupRequired ? "required" : "not required"} + Проверено: {formatCount(marketEnrichment.wordstat.summary.collectedSeedCount)} · спрос:{" "} + {formatCount(getMarketSignalCount(marketEnrichment))} · пусто:{" "} + {formatCount(marketEnrichment.wordstat.summary.noSignalSeedCount ?? null)} + + + Контекст:{" "} + {marketEnrichment.projectOntologyVersion + ? `v${marketEnrichment.projectOntologyVersion.version} · ${getProjectOntologyApprovalLabel( + marketEnrichment.projectOntologyVersion.approvalStatus + )}` + : "not saved"}

- {marketEnrichment.state === "not_configured" - ? "Семантика готова к сбору рынка, но backend-провайдеры ещё не подключены." - : "Внешние evidence будут прикрепляться к seed, brief и page contract отдельно от deterministic-анализа."} + {!marketEnrichment.readiness.ontologyReadyForMarket + ? "Сначала нужно подтвердить контекст проекта, чтобы спрос не собирался по ошибочной семантике." + : marketEnrichment.state === "not_configured" + ? "Контекст готов, но сбор рыночного спроса ещё не настроен." + : "Спрос отделён от внутреннего анализа: видно, какие фразы подтверждены, а какие остаются гипотезой."}

+ {marketDecision ? ( +
+
+ {marketDecision.label} + {marketDecision.summary} +

{marketDecision.action}

+
+
+ {getMarketEvidenceCards(marketEnrichment).map((item) => ( +
+ {item.label} + {formatCount(item.value)} + {item.detail} +
+ ))} +
+
+ ) : null} + +
+
+ SEO-нормализация + {marketEnrichment.normalization.summary} + + Сырых seeds: {marketEnrichment.normalization.readiness.sourceSeedCount} · нормализовано:{" "} + {marketEnrichment.normalization.readiness.normalizedSeedCount} · intent groups:{" "} + {marketEnrichment.normalization.readiness.intentGroupCount} · review:{" "} + {marketEnrichment.normalization.readiness.needsHumanReviewCount} + +
+ + {marketEnrichment.normalization.provider.mode === "codex_manual" ? "Codex" : "Fallback"} + + {marketEnrichment.normalization.analystReview.status} + Auto {marketEnrichment.normalization.readiness.autoApprovedCount} + Wordstat {marketEnrichment.normalization.readiness.wordstatApprovedCount} + SERP {marketEnrichment.normalization.readiness.serpApprovedCount} + Rejected {marketEnrichment.normalization.readiness.rejectedSeedCount} + + {marketEnrichment.normalization.modelTask ? "Model task ready" : "Model task waiting"} + +
+
+
+ {marketEnrichment.normalization.intentGroups.slice(0, 5).map((group) => ( +
+ {getSeoIntentTypeLabel(group.intentType)} + {group.title.replace(/^.*? · /, "")} + {group.wordstatQueries.slice(0, 3).join(", ")} +
+ ))} +
+ {marketEnrichment.normalization.rejectedSeeds.length > 0 ? ( +

+ Отсечено широких/мусорных seeds: {marketEnrichment.normalization.rejectedSeeds.length}.{" "} + {marketEnrichment.normalization.rejectedSeeds + .slice(0, 3) + .map((seed) => seed.phrase) + .join(", ")} +

+ ) : null} +
+ + {normalizationReviewQueues ? ( +
+ + + + +
+ ) : null} + +
+ + + + +
+
- Wordstat job + Сигналы approved queue {marketEnrichment.wordstat.latestJob - ? `${marketEnrichment.wordstat.latestJob.status} · ${marketEnrichment.wordstat.latestJob.provider}/${marketEnrichment.wordstat.latestJob.mode} · ${marketEnrichment.wordstat.latestJob.requestedPhraseCount} seeds` + ? `${marketEnrichment.wordstat.latestJob.status} · ${marketEnrichment.wordstat.latestJob.requestedPhraseCount} фраз` : "job ещё не создавался"}
- {formatCount(marketEnrichment.wordstat.summary.collectedSeedCount)} checked + {formatCount(marketEnrichment.wordstat.summary.collectedSeedCount)} проверено - {formatCount(marketEnrichment.wordstat.summary.exactFrequencySeedCount ?? null)} exact + {formatCount(marketEnrichment.wordstat.summary.exactFrequencySeedCount ?? null)} точные - {formatCount(marketEnrichment.wordstat.summary.relatedOnlySeedCount ?? null)} related + {formatCount(marketEnrichment.wordstat.summary.relatedOnlySeedCount ?? null)} рядом - {formatCount(marketEnrichment.wordstat.summary.noSignalSeedCount ?? null)} empty + {formatCount(marketEnrichment.wordstat.summary.noSignalSeedCount ?? null)} пусто - {formatCount(marketEnrichment.wordstat.summary.resultCount)} rows - {formatCount(marketEnrichment.wordstat.summary.totalFrequency)} total + {formatCount(marketEnrichment.wordstat.summary.resultCount)} строк + {formatCount(marketEnrichment.wordstat.summary.totalFrequency)} суммарно
{marketEnrichment.wordstat.latestJob?.errorMessage ? (

{marketEnrichment.wordstat.latestJob.errorMessage}

) : null}
-
- {marketEnrichment.providers.map((provider) => ( -
- {getMarketProviderStatusLabel(provider.status)} + {keywordCleaning ? ( + <> +
- {provider.label} - {provider.role} -

{provider.message}

+ Keyword cleaning + {keywordCleaning.summary} + + После Wordstat/SERP: что можно пустить в SEO-план, что оставить как backlog, что + проверить и что выкинуть. + +
+ {keywordCleaning.provider.mode === "codex_manual" ? "Codex" : "Fallback"} + {keywordCleaning.analystReview.status} + Use {keywordCleaning.readiness.useCount} + Support {keywordCleaning.readiness.supportCount} + Article {keywordCleaning.readiness.articleCount} + Risky {keywordCleaning.readiness.riskyCount} + Trash {keywordCleaning.readiness.trashCount} +
-
- ))} -
+ +
+ +
+ + + + +
+ +
+ + +
+ + ) : null}
- Seed queue - Фразы готовы к Wordstat, но не собраны без provider. + Спрос по фразам + Точные, смежные и пустые сигналы не смешиваются в одно решение.
{marketEnrichment.seedQueue.slice(0, 12).map((seed) => (
- {getMarketEvidenceStatusLabel(seed.evidenceStatus)} + + {seed.normalization + ? getSeoMarketRoleLabel(seed.normalization.marketRole) + : getMarketEvidenceStatusLabel(seed.evidenceStatus)} + {seed.phrase} {seed.frequency !== null ? `${formatCount(seed.frequency)} · ${seed.frequencyGroup ?? "group n/a"} · related ${seed.relatedCount}` : seed.relatedCount > 0 ? `${seed.clusterTitle} · related ${seed.relatedCount}` - : seed.clusterTitle} + : `${seed.clusterTitle}${ + seed.normalization?.needsHumanReview ? " · нужна проверка формулировки" : "" + }`}
))} @@ -4551,16 +8687,17 @@ export function App() {
- Brief evidence - Куда будут пришиты Wordstat/SERP/competitor evidence. + Посадочные для проверки + Куда может лечь подтвержденный спрос.
{marketEnrichment.briefEvidence.slice(0, 8).map((brief) => (
- {getMarketEvidenceStatusLabel(brief.evidenceStatus)} + {brief.action} {brief.path} - quality {brief.qualityScore ?? "n/a"} · {brief.missingEvidence.slice(0, 2).join("; ")} + {brief.seedPhrases.slice(0, 3).join(", ") || + (brief.qualityScore !== null ? `качество ${brief.qualityScore}` : "ожидает проверки")}
))} @@ -4570,7 +8707,7 @@ export function App() { {marketEnrichment.wordstat.topResults.length > 0 ? (
- Top Wordstat rows + Raw Wordstat rows
{marketEnrichment.wordstat.topResults.slice(0, 10).map((result) => (

@@ -4582,24 +8719,1754 @@ export function App() {

) : null} -
- Следующие platform actions - {marketEnrichment.nextActions.map((action) => ( -

{action}

- ))} -
) : semanticAnalysis ? (
- Market contract ещё не загружен. Нажми проверку providers, чтобы получить текущий - статус платформенных интеграций. + Контракт спроса ещё не загружен. Проверь providers, чтобы получить текущий статус + market evidence.
) : (
- Сначала нужен semantic analysis: он подготовит seed candidates и SEO briefs для - market enrichment. + Сначала нужен semantic analysis: он подготовит темы, посадочные гипотезы и seed phrases + для проверки спроса. +
+ )} +
+ ) : null} + {activeStageIndex === 5 ? ( +
+
+
+ SEO-план страниц + Что улучшать на страницах: фраза, поле, роль, лимит и причина решения. +
+ +
+ + {keywordMap ? ( + <> + {keywordPlanDecision ? ( +
+
+ {keywordPlanDecision.label} + {keywordPlanDecision.title} +

{keywordPlanDecision.summary}

+

{keywordPlanDecision.action}

+ + keyword-map.v1 · {getKeywordMapStateLabel(keywordMap.state)} · ontology{" "} + {keywordMap.projectOntologyVersion + ? `v${keywordMap.projectOntologyVersion.version} / ${getProjectOntologyApprovalLabel( + keywordMap.projectOntologyVersion.approvalStatus + )}` + : "не сохранена"} + +
+
+ {keywordPlanDecision.metrics.map((metric) => ( +
+ {metric.label} + {metric.value} + {metric.detail} +
+ ))} +
+
+ ) : null} + +
+
+ Источник SEO-плана + + {keywordMap.cleaning.schemaVersion} ·{" "} + {keywordMap.cleaning.providerMode === "codex_manual" ? "Codex" : "Fallback"} ·{" "} + {keywordMap.cleaning.analystStatus} + +
+
+ use {keywordMap.cleaning.useCount} + support {keywordMap.cleaning.supportCount} + article {keywordMap.cleaning.articleCount} + risky {keywordMap.cleaning.riskyCount} + trash {keywordMap.cleaning.trashCount} +
+
+ +
+ + + +
+ + {keywordMap.persisted.approvedDecisionCount > 0 ? ( +
+
+ Зафиксированный план + + {keywordMap.persisted.latestApprovedAt + ? formatDate(keywordMap.persisted.latestApprovedAt) + : "timestamp n/a"} + +
+
+ {keywordMap.persisted.approvedDecisionCount} решений + {keywordMap.persisted.mapItemCount} привязок +
+
+ {keywordMap.persisted.items.slice(0, 4).map((item) => ( +
+ {getKeywordMapRoleLabel(item.role)} + {item.phrase} + + {item.targetPath ?? "без страницы"} · {item.workspaceSectionId ?? "секция не найдена"} + +
+ ))} +
+
+ ) : null} + +
+ Аналитическая база перед стратегией +

+ Сайт не меняем. Этот этап должен доказать ценность анализа: контекст, спрос, evidence, + риски и понятные стратегические варианты. +

+
+

+ Уже есть: {keywordMap.persisted.approvedDecisionCount} подтверждённых решений,{" "} + {keywordMap.readiness.marketEvidenceCount} фраз с market evidence,{" "} + {keywordMap.cleaning.trashCount} мусорных фраз отфильтровано. +

+

+ Не хватает: SERP/competitor evidence, Yandex Webmaster/Метрика/Search Console слой, + прогнозы трафика и понятная карта возможностей. +

+

+ Следующий UX должен отвечать не “куда писать текст”, а “что мы поняли, где спрос, + где риск, какую стратегию выбрать”. +

+
+
+ + {keywordPagePlans.length > 0 ? ( +
+
+
+ Что можно сделать по страницам + + Не таблица ключей, а карта решений: фиксировать, проверить или не пускать дальше. + +
+ {keywordPagePlans.length} групп +
+
+ {keywordPagePlans.map((pagePlan) => ( +
+
+ {pagePlan.statusLabel} +
+ {pagePlan.readableTarget} + + {pagePlan.primaryItem + ? `${pagePlan.primaryItem.clusterTitle} · ${getKeywordRoleFieldLabel( + pagePlan.primaryItem.role + )}` + : "нет привязанного интента"} + +
+
+

{pagePlan.action}

+
+ главн. {pagePlan.counts.primary} + доп. {pagePlan.counts.secondary} + подд. {pagePlan.counts.support} + review {pagePlan.reviewItems.length} +
+
+ {pagePlan.topItems.slice(0, 4).map((item) => ( +
+ + {getKeywordMapRoleLabel(item.role)} · {getKeywordRoleFieldLabel(item.role)} + + {item.phrase} + + {getMarketEvidenceStatusLabel(item.evidenceStatus)} + {item.frequency !== null ? ` · ${formatCount(item.frequency)}` : ""} ·{" "} + {item.reason} + +
+ ))} +
+
+ ))} +
+
+ ) : null} + +
+
+
+ Что нельзя пускать в rewrite + Фразы без безопасного exact evidence, page binding или с ролью validate. +
+
+ {keywordReviewItems.slice(0, 8).map((item) => ( +
+ {getMarketEvidenceStatusLabel(item.evidenceStatus)} + {item.phrase} + + {item.targetPath ?? "без страницы"} · {getKeywordMapRoleLabel(item.role)} ·{" "} + {item.reason} + +
+ ))} + {keywordReviewItems.length === 0 ? ( +
+ ok + Review-очередь пуста + Все текущие назначения можно рассматривать как безопасные. +
+ ) : null} +
+
+ +
+
+ Следующее действие + Что нужно сделать перед stage 07 стратегией. +
+
+ {keywordMap.nextActions.map((action) => ( +

{action}

+ ))} +
+
+
+ + {keywordMap.blockers.length > 0 ? ( +
+ Блокеры + {keywordMap.blockers.map((action) => ( +

{action}

+ ))} +
+ ) : null} + + {keywordMap.items.length > 0 ? ( +
+ + DEV DETAILS + Raw keyword-map items + Сырой список фраз, ролей, страниц и evidence для проверки пайплайна. + +
+ {keywordMap.items.map((item) => ( +
+ {getKeywordMapRoleLabel(item.role)} + {item.phrase} + + {item.targetPath ?? "без страницы"} · {item.clusterTitle} ·{" "} + {getMarketEvidenceStatusLabel(item.evidenceStatus)} + {item.frequency !== null ? ` · ${formatCount(item.frequency)}` : ""} + +
+ ))} +
+
+ ) : null} + + ) : ( +
+ + Сначала нужны semantic analysis и рыночный спрос: SEO-план появится как draft поверх + reviewed ontology и evidence. +
+ )} +
+ ) : null} + {activeStageIndex === 6 ? ( +
+
+
+ SEO-стратегия + + Read-only слой: доказываем возможности роста, риски и недостающие данные. Сайт на этом этапе не + меняем. + +
+ +
+ +
+
+ yandex-evidence.v1 + + {yandexEvidence + ? getYandexEvidenceStateLabel(yandexEvidence.state) + : "внешний evidence ещё не загружен"} + +

+ Wordstat top/regions/dynamics и SERP конкурентов собираются отдельным read-only run. Это источник + фактов для стратегии, а не команда на rewrite. +

+
+ + + +
+
+
+
+ Фразы + {yandexEvidence?.summary.checkedPhraseCount ?? 0} + проверено +
+
+ Спрос + {formatCount(yandexEvidence?.summary.totalDemand ?? 0)} + Wordstat total +
+
+ SERP + {yandexEvidence?.summary.serpDocCount ?? 0} + docs +
+
+ Домены + {yandexEvidence?.summary.competitorDomainCount ?? 0} + competitors +
+
+
+ +
+
+ seo-serp-interpretation.v1 + + {serpInterpretation?.state === "ready" + ? "интерпретация собрана" + : "SERP facts ещё не интерпретированы"} + +

+ {serpInterpretation?.state === "ready" + ? serpInterpretation.summary + : "Нужен отдельный слой, который объяснит target-fit, mismatch, конкурентные углы и риски. Сырые домены сами по себе не являются стратегией."} +

+
+
+
+ Фразы + {serpInterpretation?.readiness.interpretedPhraseCount ?? 0} + interpreted +
+
+ Mismatch + {serpInterpretation?.readiness.mismatchCount ?? 0} + intent fit +
+
+ Gaps + {serpInterpretation?.readiness.competitorGapCount ?? 0} + competitors +
+
+ Риски + {serpInterpretation?.readiness.riskCount ?? 0} + до approval +
+
+
+ + {seoStrategy ? ( + <> +
+
+ {getSeoStrategyStateLabel(seoStrategy.state)} + {seoStrategy.verdict.title} + + Confidence: {getSeoStrategyConfidenceLabel(seoStrategy.verdict.confidence)} · score{" "} + {seoStrategy.readiness.strategyConfidenceScore}/100 ·{" "} + {seoStrategy.projectOntologyVersion?.id + ? `ontology ${seoStrategy.projectOntologyVersion.id.slice(0, 8)}` + : "ontology не подтверждена"} + + Главное ограничение: {seoStrategy.verdict.primaryConstraint} +
+

{seoStrategy.verdict.summary}

+
+ +
+
+ site-maturity.v1 + {getSeoMaturityPhaseLabel(seoStrategy.siteMaturity.phase)} +

{seoStrategy.siteMaturity.reason}

+
+
+
+ Индекс + {seoStrategy.siteMaturity.signals.hasIndexEvidence ? "есть" : "нет"} + site evidence +
+
+ Traction + {seoStrategy.siteMaturity.signals.hasTractionEvidence ? "есть" : "нет"} + impressions/clicks +
+
+ SERP + {seoStrategy.siteMaturity.signals.hasSerpInterpretation ? "OK" : "нет"} + market view +
+
+ Authority + {seoStrategy.siteMaturity.signals.hasAuthorityEvidence ? "есть" : "нет"} + head terms gate +
+
+
+ +
+
+
+ выбор стартовой стратегии + + {seoStrategy.siteMaturity.phase === "bootstrap_indexing" + ? "Сайт выглядит как нулевой: начинаем с индексации и long-tail" + : `Рекомендован старт с фазы ${getSeoMaturityPhaseLabel(seoStrategy.siteMaturity.phase)}`} + + + Можно открыть любую фазу и понять, что она даст, но immediate route должен соответствовать + доказанной зрелости сайта. + +
+
+
+ {seoStrategy.phasePlan.map((phase) => { + const isSelected = selectedStrategyPhaseId === phase.phase; + const isCurrent = seoStrategy.siteMaturity.phase === phase.phase; + + return ( + + ); + })} +
+ {selectedStrategyPhase ? ( +
+
+ {getSeoPhaseStatusLabel(selectedStrategyPhase.status)} + {selectedStrategyPhase.title} +

{selectedStrategyPhase.goal}

+
+
+
+ можно делать + {selectedStrategyPhase.allowedTargets.slice(0, 4).map((target) => ( + {target} + ))} +
+
+ не сейчас + {selectedStrategyPhase.deferredTargets.slice(0, 4).map((target) => ( + {target} + ))} +
+
+ переход дальше + {selectedStrategyPhase.promotionSignals.slice(0, 4).map((signal) => ( + {signal} + ))} +
+
+

{selectedStrategyPhase.risk}

+
+ ) : null} +
+ +
+
+
+ SEO runway + Фазы роста: что можно сейчас и что откроется после сигналов. +
+
+ {seoStrategy.phasePlan.map((phase) => ( +
+ + {getSeoPhaseStatusLabel(phase.status)} + + {phase.title} + {phase.goal} + Можно: {phase.allowedTargets.slice(0, 2).join(" · ")} + Не сейчас: {phase.deferredTargets.slice(0, 2).join(" · ")} +
+ ))} +
+
+ +
+
+ Переход в следующую фазу + Какие сигналы нужны, чтобы повышать конкуренцию ключей. +
+
+ {seoStrategy.siteMaturity.promotionSignals.map((signal) => ( +
+ signal + {signal} +
+ ))} + {seoStrategy.siteMaturity.guardrails.map((guardrail) => ( +
+ guardrail + {guardrail} +
+ ))} +
+
+
+ +
+
+ strategy.v1 + Понятная база перед любыми изменениями сайта +

+ Сейчас ценность этапа в том, чтобы показать, где спрос уже подтверждён, где аналитика слабая и + какие сервисы надо подключить для зрелого решения. +

+
+ + +
+
+
+
+ Контекст + {seoStrategy.readiness.contextReady ? "OK" : "Review"} + {seoStrategy.evidence.context.status} +
+
0 ? "tone-good" : "tone-warning"}> + Возможности + {seoStrategy.readiness.opportunityCount} + opportunity groups +
+
0 ? "tone-warning" : "tone-good"}> + Gaps + {seoStrategy.readiness.evidenceGapCount} + данных не хватает +
+
0 ? "tone-warning" : "tone-good"}> + Риски + {seoStrategy.readiness.riskCount} + до approval +
+
+
+ +
+ + + +
+ +
+
+ seo-strategy-synthesis.v1 + + {strategySynthesis + ? getStrategySynthesisStateLabel(strategySynthesis.state) + : "strategy synthesis не собран"} + +

+ {strategySynthesis?.state === "ready" + ? strategySynthesis.summary + : "Нужен верхний AI/MCP слой, который превратит evidence, SERP и keyword map в понятные варианты решения, риски и следующий аналитический шаг."} +

+
+
+ {(strategySynthesis + ? getStrategySynthesisMetrics(strategySynthesis) + : [ + { + detail: "0-100, по evidence stack", + label: "Score", + tone: "warning" as const, + value: 0 + }, + { + detail: "варианты решения", + label: "Опции", + tone: "warning" as const, + value: 0 + }, + { + detail: "чего не хватает", + label: "Gaps", + tone: "warning" as const, + value: seoStrategy.readiness.evidenceGapCount + }, + { + detail: "риск-позиция", + label: "Риски", + tone: "warning" as const, + value: seoStrategy.readiness.riskCount + } + ]).map((metric) => ( +
+ {metric.label} + {metric.value} + {metric.detail} +
+ ))} +
+
+ + {strategySynthesis?.state === "ready" ? ( + <> +
+ {strategySynthesis.executiveSummary.title} +

{strategySynthesis.executiveSummary.verdict}

+

{strategySynthesis.executiveSummary.whyNow}

+

Ограничение: {strategySynthesis.executiveSummary.primaryConstraint}

+
+ +
+
+
+ {strategySynthesis.phaseDecision.title} + {getSeoMaturityPhaseLabel(strategySynthesis.phaseDecision.currentPhase)} +
+
+
+ phase + {strategySynthesis.phaseDecision.summary} +
+ {strategySynthesis.phaseDecision.allowedNow.slice(0, 4).map((action) => ( +
+ now + {action} +
+ ))} +
+
+ +
+
+ Не сейчас + Что нельзя выдавать как immediate route до зрелости сайта. +
+
+ {strategySynthesis.phaseDecision.prepareNext.slice(0, 3).map((action) => ( +
+ prepare + {action} +
+ ))} + {strategySynthesis.phaseDecision.hold.slice(0, 5).map((action) => ( +
+ hold + {action} +
+ ))} +
+
+
+ +
+
+
+ Рекомендованный путь + + Confidence {getSeoStrategyConfidenceLabel(strategySynthesis.recommendedPath.confidence)} · + impact {getSeoStrategyPriorityLabel(strategySynthesis.recommendedPath.expectedImpact)} + +
+
+
+ + {getSeoStrategyPriorityLabel(strategySynthesis.recommendedPath.priority)} + + {strategySynthesis.recommendedPath.title} + {strategySynthesis.recommendedPath.rationale} +
+ {strategySynthesis.recommendedPath.doNow.slice(0, 4).map((action) => ( +
+ now + {action} +
+ ))} + {strategySynthesis.recommendedPath.hold.slice(0, 3).map((action) => ( +
+ hold + {action} +
+ ))} +
+
+ +
+
+ Варианты решения + Что пользователь должен выбрать или отправить на дополнительный анализ. +
+
+ {strategySynthesis.decisionOptions.slice(0, 4).map((option) => ( +
+ + {getStrategySynthesisScenarioLabel(option.scenario)} + + {option.title} + + impact {getSeoStrategyPriorityLabel(option.expectedImpact)} · effort{" "} + {getSeoStrategyPriorityLabel(option.effort)} · confidence{" "} + {getSeoStrategyConfidenceLabel(option.confidence)} + + {option.rationale} + Дальше: {option.nextStep} +
+ ))} +
+
+
+ +
+
+
+ Что уже доказано + Краткая evidence narrative, без выдуманного спроса и домыслов. +
+
+ {strategySynthesis.evidenceNarrative.slice(0, 6).map((evidence) => ( +
+ {getSeoStrategyConfidenceLabel(evidence.confidence)} + {evidence.title} + {evidence.whatWeKnow} + {evidence.whyItMatters} +
+ ))} +
+
+ +
+
+ Что блокирует уверенное решение + Недостающие данные и действия, которые нельзя заменить модельной фантазией. +
+
+ {strategySynthesis.dataGaps.slice(0, 6).map((gap) => ( +
+ + {getStrategySynthesisOwnerLabel(gap.owner)} + + {gap.title} + {gap.whyItBlocks} + Дальше: {gap.nextStep} +
+ ))} + {strategySynthesis.blockedActions.slice(0, 4).map((action) => ( +
+ blocked + {action} +
+ ))} +
+
+
+ + ) : null} + +
+
+
+ Yandex evidence + Факты внешнего рынка: спрос, SERP intent, конкуренты и сезонность. +
+
+ {yandexEvidence?.phrases.slice(0, 6).map((phrase) => ( +
+ + {getYandexEvidenceIntentLabel(phrase.interpretation.serpIntent)} + + {phrase.phrase} + + {formatCount(phrase.wordstat.totalCount ?? 0)} спрос · difficulty{" "} + {phrase.interpretation.difficultyScore}/100 ·{" "} + {getYandexEvidenceSeasonalityLabel(phrase.interpretation.seasonality)} + + {phrase.serp.topDomains.slice(0, 4).join(" · ") || "SERP domains не собраны"} +
+ ))} + {!yandexEvidence || yandexEvidence.phrases.length === 0 ? ( +
+ empty + Yandex evidence ещё не собран + Запусти сбор после Wordstat/keyword-map, чтобы стратегия увидела рынок. +
+ ) : null} +
+
+ +
+
+ Конкурентные домены + Кто уже занимает выдачу по проверенным фразам. +
+
+ {yandexEvidence?.competitorDomains.slice(0, 8).map((domain) => ( +
+ {domain.count} + {domain.domain} + {domain.sampleTitle} +
+ ))} + {!yandexEvidence || yandexEvidence.competitorDomains.length === 0 ? ( +
+ empty + Конкуренты ещё не сняты + Нужен WebSearch evidence run. +
+ ) : null} +
+
+
+ + {serpInterpretation?.state === "ready" ? ( +
+
+
+ SERP interpretation + Интент выдачи, fit с целевой страницей и роль фразы в стратегии. +
+
+ {serpInterpretation.phraseInterpretations.slice(0, 6).map((phrase) => ( +
+ + {getSerpTargetFitLabel(phrase.targetFit)} + + {phrase.phrase} + + {getYandexEvidenceIntentLabel(phrase.intent)} ·{" "} + {getSerpRecommendedRoleLabel(phrase.recommendedRole)} · confidence{" "} + {getSeoStrategyConfidenceLabel(phrase.confidence)} + + {phrase.opportunity} + {phrase.risk ? {phrase.risk} : null} +
+ ))} +
+
+ +
+
+ Competitor angles + Не список доменов, а гипотезы: чем занята выдача. +
+
+ {serpInterpretation.competitorGaps.slice(0, 6).map((gap) => ( +
+ {gap.count} + {gap.domain} + {gap.observedAngle} + {gap.strategyImplication} +
+ ))} +
+
+
+ ) : null} + +
+
+
+ Возможности роста + Не “слова ради слов”, а группы, где есть спрос, страница и следующий evidence. +
+
+ {seoStrategy.opportunities.slice(0, 8).map((opportunity) => ( +
+ + {getSeoOpportunityActionLabel(opportunity.timing.action)} + + {opportunity.title} + + {opportunity.targetPath ?? "страница не выбрана"} ·{" "} + {formatCount(opportunity.demand.totalFrequency)} спрос · confidence{" "} + {getSeoStrategyConfidenceLabel(opportunity.confidence)} ·{" "} + {getSeoCompetitionTierLabel(opportunity.competitionTier)} + + {opportunity.whyItMatters} + {opportunity.timing.reason} + {opportunity.recommendation} +
+ ))} + {seoStrategy.opportunities.length === 0 ? ( +
+ empty + Возможности ещё не доказаны + Нужны market evidence, SERP и нормализация фраз. +
+ ) : null} +
+
+ +
+
+ Evidence gaps + Что мешает назвать стратегию зрелой и полезной. +
+
+ {seoStrategy.missingEvidence.slice(0, 8).map((evidence) => ( +
+ + {getSeoStrategyOwnerLabel(evidence.owner)} + + {evidence.title} + {evidence.impact} +
+ ))} + {seoStrategy.missingEvidence.length === 0 ? ( +
+ OK + Критичных gaps нет + Можно готовить human strategy approval. +
+ ) : null} +
+
+
+ +
+
+
+ Контекст сайта + Смысловая рамка, от которой нельзя отрываться при расширении охвата. +
+
+
+ {seoStrategy.evidence.context.status} + {seoStrategy.evidence.context.summary} + + {seoStrategy.evidence.context.gaps.length > 0 + ? seoStrategy.evidence.context.gaps.slice(0, 4).join(" · ") + : "Явных context gaps в strategy contract нет."} + +
+
+
+ +
+
+ Top demand signals + Фразы с подтверждённой частотностью, которые реально влияют на стратегию. +
+
+ {seoStrategy.evidence.demand.topSignals.slice(0, 8).map((signal) => ( +
+ {formatCount(signal.frequency ?? 0)} + {signal.phrase} + {signal.targetPath ?? "страница не выбрана"} +
+ ))} + {seoStrategy.evidence.demand.topSignals.length === 0 ? ( +
+ empty + Нет сильных demand signals + Нужно расширить сбор фраз и внешние источники. +
+ ) : null} +
+
+
+ +
+
+
+ Варианты стратегии + Пока это аналитические сценарии, не задачи на переписывание сайта. +
+
+ {seoStrategy.strategyOptions.map((option) => ( +
+ {option.expectedImpact} impact + {option.title} + + effort {option.effort} · confidence {getSeoStrategyConfidenceLabel(option.confidence)} + + {option.rationale} +
+ ))} +
+
+ +
+
+ Риски + Почему стратегия может быть ошибочной без следующих проверок. +
+
+ {seoStrategy.risks.slice(0, 8).map((risk) => ( +
+ + {getSeoStrategyRiskLevelLabel(risk.level)} + + {risk.title} + {risk.message} + {risk.mitigation} +
+ ))} + {seoStrategy.risks.length === 0 ? ( +
+ OK + Критичных рисков нет + Остаётся закрыть недостающие evidence sources. +
+ ) : null} +
+
+
+ +
+ Следующий production шаг + {seoStrategy.nextActions.map((action) => ( +

{action}

+ ))} +
+ + ) : ( +
+ + Сначала нужны context review, market enrichment и keyword map: стратегия не строится только на raw + scan. +
+ )} +
+ ) : null} + {activeStageIndex === -1 ? ( +
+
+
+ Подготовка к model review + Что уже можно отдать на правку модели, а что сначала требует страницы или привязки. +
+ +
+ +
+
+ Model provider + + {defaultModelProvider + ? `${defaultModelProvider.title} · ${getModelProviderStatusLabel(defaultModelProvider.status)}` + : "Provider status не загружен"} + + + {defaultModelProvider + ? defaultModelProvider.summary + : "Статус подтянется после обновления проекта."} + +
+
+ {latestModelTaskRun ? ( + <> + {getModelTaskRunStatusLabel(latestModelTaskRun.status)} + + {latestModelTaskRun.task.taskType + ? getModelTaskTypeLabel(latestModelTaskRun.task.taskType) + : "model task"}{" "} + · {latestModelTaskRun.estimate.inputTokenEstimate} tokens + + {formatDate(latestModelTaskRun.createdAt)} + + ) : ( + <> + codex task + Маршрут ещё не проверяли + Task route фиксируется отдельно; результат анализа сохраняется manual evidence. + + )} +
+
+ + + +
+
+ + {rewritePlan ? ( + <> +
+
+ {getRewritePlanStateLabel(rewritePlan.state)} + + Зафиксировано: {rewritePlan.readiness.approvedDecisionCount} · кандидатов на правки:{" "} + {rewritePlan.readiness.rewriteCandidateCount} + + + Существующие страницы: {rewritePlan.readiness.existingPageTargetCount} · плановые:{" "} + {rewritePlan.readiness.plannedLandingTargetCount} · блокеры:{" "} + {rewritePlan.readiness.blockerCount} + + + Онтология:{" "} + {rewritePlan.projectOntologyVersionId + ? rewritePlan.projectOntologyVersionId.slice(0, 8) + : "нет"}{" "} + · анализ {rewritePlan.semanticRunId ? rewritePlan.semanticRunId.slice(0, 8) : "нет"} + +
+

+ {rewritePlan.state === "materialization_required" + ? "SEO-план есть, но часть посадочных ещё не существует в скане. Сначала нужно связать или отложить плановые страницы." + : rewritePlan.state === "ready_for_diff" || rewritePlan.state === "partial_ready" + ? "Есть привязки к существующим страницам; следующий слой может собрать правки для ручного review." + : "Model review ждёт зафиксированный SEO-план страниц."} +

+
+ +
+ + + {materialization ? ( + + ) : null} +
+ + {rewritePlan.existingPageTargets.length > 0 ? ( +
+
+
+ Готово к правкам + Существующие страницы, где уже понятны поле и секция. +
+
+ {rewritePlan.existingPageTargets.slice(0, 8).map((target) => ( +
+ {target.keywordBindings.length} привязок + {target.urlPath ?? target.targetPath ?? target.pageTitle ?? "page"} + {target.sourcePath ?? target.pageId} +
+ ))} +
+
+ +
+
+ Инструкции для правок + Рамки правок по конкретным полям страницы. +
+
+ {rewritePlan.existingPageTargets + .flatMap((target) => target.keywordBindings) + .slice(0, 8) + .map((binding) => ( +
+ {getRewriteFieldLabel(binding.field)} + {binding.phrase} + {binding.instruction} +
+ ))} +
+
+
+ ) : null} + + {materialization ? ( +
+
+ {getMaterializationStateLabel(materialization.state)} + + Цели: {materialization.readiness.targetCount} · связано:{" "} + {materialization.readiness.linkedTargetCount} + + + Ждёт решения: {materialization.readiness.pendingTargetCount} · плановых:{" "} + {materialization.readiness.placeholderTargetCount} · отложено/отклонено:{" "} + {materialization.readiness.rejectedOrDeferredCount} + + Source approved: {materialization.readiness.awaitingSourceCount} + План правок: {getRewritePlanStateLabel(materialization.rewritePlanState)} +
+

+ {materialization.state === "complete" + ? "Очередь посадочных закрыта для текущего плана review." + : "Здесь фиксируются решения по плановым посадочным; исходники сайта пока не меняются."} +

+
+ ) : null} + + {materialization && materialization.targets.length > 0 ? ( +
+ Почему model review пока рано +
+ {materialization.targets.slice(0, 8).map((target) => { + const targetPath = target.normalizedTargetPath ?? target.targetPath ?? ""; + + return ( +
+
+ {getMaterializationStatusLabel(target.status)} + {targetPath || "без страницы"} + + {target.keywordBindings.length} фраз ·{" "} + {target.pageTitle ?? target.urlPath ?? target.sourcePath ?? "страница не связана"} + + + Сейчас это аналитический blocker, а не задача на создание или правку файла сайта. + +
+
+ ); + })} +
+
+ ) : null} + + {rewriteDiff ? ( +
+
+ {getRewriteDiffStateLabel(rewriteDiff.state)} + + Поля: {rewriteDiff.readiness.slotCount} · готовы:{" "} + {rewriteDiff.readiness.modelReadySlotCount} + + + Ждут источника: {rewriteDiff.readiness.sourcePendingSlotCount} · посадочные:{" "} + {rewriteDiff.readiness.materializationTargetCount} · блокеры:{" "} + {rewriteDiff.readiness.blockerCount} + +
+

+ Контракт готовит текущие значения полей для review, но новый текст появится только после + выбора модели и правил промпта. +

+
+ ) : null} + + {rewriteDiff && rewriteDiff.targets.length > 0 ? ( +
+
+
+ Поля перед правкой + В review идут только поля, где найден текущий текст. +
+
+ {rewriteDiff.targets + .flatMap((target) => + target.slots.map((slot) => ({ + ...slot, + pageLabel: target.urlPath ?? target.sourcePath ?? target.pageId + })) + ) + .slice(0, 8) + .map((slot) => ( +
+ {getRewriteDiffSlotStatusLabel(slot.status)} + + {getRewriteFieldLabel(slot.field)} · {slot.pageLabel} + + + {slot.source} · {slot.currentValue ? `${slot.currentValue.slice(0, 120)}...` : "нет значения"} + +
+ ))} +
+
+ +
+
+ Блокеры правок + Что нужно закрыть до model review. +
+
+ {rewriteDiff.targets + .flatMap((target) => target.slots) + .filter((slot) => slot.blockers.length > 0) + .slice(0, 8) + .map((slot) => ( +
+ {getRewriteFieldLabel(slot.field)} + {slot.workspaceSectionId ?? "секция не найдена"} + {slot.blockers.join(" ")} +
+ ))} + {rewriteDiff.targets.flatMap((target) => target.slots).every((slot) => slot.blockers.length === 0) ? ( +
+ готово + Блокеры не найдены + Следующий шаг — обсуждение model provider и review policy. +
+ ) : null} +
+
+
+ ) : null} + + {rewritePlan.blockers.length > 0 || (rewriteDiff?.blockers.length ?? 0) > 0 ? ( +
+ Блокеры + {[...rewritePlan.blockers, ...(rewriteDiff?.blockers ?? [])].map((action) => ( +

{action}

+ ))} +
+ ) : null} + + ) : ( +
+ + Сначала нужен зафиксированный SEO-план: model review не создаёт правки без привязки страницы и секции. +
+ )} +
+ ) : null} + {activeStageIndex === 7 ? ( +
+
+
+ Проверка качества + Quality gate стратегии: phase runway, evidence refs, hallucination guardrail и business drift. Сайт не меняем. +
+ +
+ +
+
+ + {strategyQualityReview?.state === "ready" + ? getStrategyQualityVerdictLabel(strategyQualityReview.verdict.status) + : "не собрано"} + + + {strategyQualityReview?.state === "ready" + ? strategyQualityReview.verdict.title + : "Сначала нужна стратегия с phaseDecision и decision options"} + + + {strategyQualityReview?.state === "ready" + ? `score ${strategyQualityReview.readiness.score} · checks ${strategyQualityReview.readiness.passedCount}/${strategyQualityReview.readiness.checkCount} · blockers ${strategyQualityReview.readiness.blockerCount}` + : "Этот gate проверяет аналитику, а не готовит source/apply route."} + +
+

+ {strategyQualityReview?.state === "ready" + ? strategyQualityReview.verdict.summary + : "Здесь фиксируем, можно ли доверять выбранной SEO-стратегии: нет ли преждевременных head terms, выдуманного спроса, расширения бизнеса без решения пользователя и мутных выводов."} +

+
+ +
+
+ seo-strategy-quality-review.v1 + Read-only validation layer +

+ Проверяем зрелость стратегии перед любыми будущими rewrite/apply действиями: фазовость, evidence, + hallucination guardrail, бизнесовую рамку и понятность следующего шага. +

+
+ + +
+
+
+ {(strategyQualityReview?.state === "ready" + ? getStrategyQualityMetrics(strategyQualityReview) + : [ + { + detail: "quality gate score", + label: "Score", + tone: "warning" as const, + value: 0 + }, + { + detail: "checks passed", + label: "Pass", + tone: "warning" as const, + value: 0 + }, + { + detail: "must fix", + label: "Blockers", + tone: "warning" as const, + value: 0 + }, + { + detail: "needs attention", + label: "Warnings", + tone: "warning" as const, + value: 0 + } + ]).map((metric) => ( +
+ {metric.label} + {metric.value} + {metric.detail} +
+ ))} +
+
+ + {strategyQualityReview?.state === "ready" ? ( + <> +
+ + +
+ +
+
+
+ Проверки + Что именно удерживает или блокирует стратегию. +
+
+ {strategyQualityReview.checks.slice(0, 8).map((check) => ( +
+ + {getStrategyQualityCheckLabel(check.status)} + + + {getStrategyQualityCategoryLabel(check.category)} · {check.title} + + {check.message} + {check.action} + {check.evidenceRefs.length > 0 ? ( + {check.evidenceRefs.slice(0, 3).join(" · ")} + ) : null} +
+ ))} +
+
+ +
+
+ Блокеры и warnings + Короткий список того, что нельзя пропустить перед следующим аналитическим шагом. +
+
+ {strategyQualityReview.blockers.slice(0, 6).map((blocker) => ( +
+ blocker + {blocker} +
+ ))} + {strategyQualityReview.warnings.slice(0, 6).map((warning) => ( +
+ warning + {warning} +
+ ))} + {strategyQualityReview.blockers.length === 0 && strategyQualityReview.warnings.length === 0 ? ( +
+ OK + Критичных замечаний нет + Можно переходить к следующему аналитическому слою, не к правке сайта. +
+ ) : null} +
+
+
+ +
+
+
+ Approved signals + Каким частям стратегии сейчас можно доверять. +
+
+ {strategyQualityReview.approvedSignals.slice(0, 8).map((signal) => ( +
+ signal + {signal} +
+ ))} +
+
+ +
+
+ Следующий аналитический шаг + Не action plan для сайта, а маршрут усиления доказательной базы. +
+
+ {strategyQualityReview.nextActions.map((action) => ( +
+ next + {action} +
+ ))} +
+
+
+ + ) : ( +
+ + Сначала собери `seo-strategy-synthesis.v1`: quality review проверяет принятую стратегию, phase runway и + evidence refs, а не raw keyword table.
)}
@@ -4732,11 +10599,8 @@ export function App() {
- - Активная страница {activeWorkspacePageIndex + 1}/{selectedWorkspaceScopeItems.length} - + {activeWorkspace.page.title ?? projectSiteTitle} {activeWorkspaceScopeItem.title} - {activeWorkspaceScopeItem.pathLabel}
0 ? "drawer-open" : ""}`}> @@ -5284,7 +11148,7 @@ export function App() {
- {workspaceMode === "dev" && activeStageIndex > 3 ? ( + {workspaceMode === "dev" && activeStageIndex > 6 ? (
@@ -5300,6 +11164,8 @@ export function App() {
) : null} +
+ ) : null}