diff --git a/seo_mode/seo_mode/app/package.json b/seo_mode/seo_mode/app/package.json index e59e1b6..e0732c8 100644 --- a/seo_mode/seo_mode/app/package.json +++ b/seo_mode/seo_mode/app/package.json @@ -4,9 +4,9 @@ "private": true, "type": "module", "scripts": { - "dev": "vite --host 127.0.0.1", + "dev": "vite --host 127.0.0.1 --port 5177 --strictPort", "build": "tsc -p tsconfig.json && vite build", - "preview": "vite preview --host 127.0.0.1", + "preview": "vite preview --host 127.0.0.1 --port 5177 --strictPort", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index ce285e7..f9399fc 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -30,7 +30,7 @@ import { UserCircle, X } from "lucide-react"; -import type { CSSProperties, ChangeEvent, DragEvent, FormEvent } from "react"; +import type { CSSProperties, ChangeEvent, DragEvent, FormEvent, PointerEvent as ReactPointerEvent } from "react"; import { Fragment, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { FunnelChart as BklitFunnelChart } from "@bklitui/charts/funnel-chart"; @@ -96,6 +96,7 @@ import { runStrategySynthesis, runSeoContextReview, runSeoModelTask, + saveAnchorReviewDecisions, savePageSemanticBlocks, saveContentFieldDraft, savePageWorkspace, @@ -103,6 +104,8 @@ import { updateProjectOntologyApproval, updateProjectOntologyConfirmations, updateProjectPageSelection, + type AnchorReviewContract, + type AnchorReviewDecisionInput, type BrowserFolderFileInput, type ContentFieldDraft, type ExternalServiceSettings, @@ -110,6 +113,8 @@ import { type HealthResponse, type KeywordCleaningContract, type KeywordMapContract, + type KeywordMapRole, + type KeywordMapRoleOverrideInput, type MaterializationActionInput, type MaterializationContract, type MarketEnrichmentContract, @@ -146,8 +151,8 @@ const pipeline = [ "Источник проекта", "Конфигурация проекта", "Рабочая зона страницы", - "Контекст сайта", - "Рыночный спрос", + "Контекст dev", + "Ключи и спрос", "Стратегия", "План правок", "Проверка качества", @@ -158,6 +163,38 @@ type WorkspaceMode = "auto" | "dev"; type ExternalService = ExternalServiceSettings["services"][number]; type ExternalServiceDraft = ExternalServiceUpdateInput; +const keywordCurationColumns: Array<{ + description: string; + role: KeywordCurationRole; + title: string; +}> = [ + { + description: "предложенные, спорные или ещё не отнесённые запросы", + role: "validate", + title: "Неразобранные" + }, + { + description: "главные запросы, под которые строится посадочная логика", + role: "primary", + title: "Посадочное ядро" + }, + { + description: "запросы, раскрывающие основной интент и структуру страницы", + role: "secondary", + title: "Поддерживающее ядро" + }, + { + description: "дифференцирующие, продуктовые и уточняющие формулировки", + role: "differentiator", + title: "Уточняющие / УТП" + }, + { + description: "низкочастотные хвосты, FAQ, подписи и внутренние связи", + role: "support", + title: "Длинный хвост" + } +]; + const stageMeta = [ { title: "Источник проекта", @@ -175,14 +212,14 @@ const stageMeta = [ description: "Открываем preview, секции страницы и patch-черновик без изменения исходников сайта." }, { - title: "Контекст сайта", - sectionTitle: "Анализ контекста", - description: "Собираем, что сайт реально говорит, где уверенность слабая и какие фразы можно проверять рынком." + title: "Контекст dev", + sectionTitle: "Внутренний контекст", + description: "Технический паспорт анализа: evidence, gaps, hints и JSON-контекст для следующих этапов." }, { - title: "Рыночный спрос", - sectionTitle: "Рыночный спрос", - description: "Проверяем, есть ли внешний спрос по темам сайта, и отделяем подтвержденные фразы от шума." + title: "Ключи и спрос", + sectionTitle: "Анализ ключей", + description: "Выбираем смысловые якоря, запускаем Wordstat только по ручной очереди и готовим карту ключей." }, { title: "Стратегия и карта посадочных", @@ -472,10 +509,14 @@ type SeoMatrixDatum = { }>; }; -type SeoNormalizationSeedView = SeoNormalizationContract["seedStrategy"][number]; +type AnchorReviewAnchorView = AnchorReviewContract["anchors"][number]; +type AnchorReviewFilter = "all" | "approved" | "disabled" | "pending" | "proposed"; type KeywordCleaningItemView = KeywordCleaningContract["items"][number]; type KeywordMapItemView = KeywordMapContract["items"][number]; type KeywordPagePlanStatus = "blocked" | "ready" | "review"; +type KeywordCurationRole = Exclude; +type KeywordCurationRoleOverrides = Record>; +type KeywordWorkflowStep = "analysis" | "anchors" | "curation" | "none"; type StrategyTargetAction = "create" | "defer" | "keep" | "partial" | "reject" | "strengthen"; type StrategyPageRole = "commercial_landing" | "docs" | "home" | "info_article" | "support"; type StrategyKeywordDecisionRole = @@ -767,18 +808,6 @@ function getChartToneColor(tone: SeoChartTone | undefined) { 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 [ { @@ -867,11 +896,19 @@ function getMarketSignalCount(market: MarketEnrichmentContract) { return (market.wordstat.summary.exactFrequencySeedCount ?? 0) + (market.wordstat.summary.relatedOnlySeedCount ?? 0); } +function isMarketWordstatOutdated(market: MarketEnrichmentContract) { + return ( + market.readiness.seedCount > 0 && + market.wordstat.latestJob?.status === "done" && + market.wordstat.summary.collectedSeedCount < market.readiness.seedCount + ); +} + 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; + const reviewCount = market.readiness.anchorPendingCount; if (!market.readiness.ontologyReadyForMarket) { return { @@ -884,9 +921,11 @@ function getMarketDecision(market: MarketEnrichmentContract) { if (approvedCount === 0) { return { - action: "Нужна модельная или ручная проверка стартовых фраз: сейчас нет фраз, которые можно безопасно отправить во внешний рынок.", - label: "нет очереди", - summary: "Market Demand заблокирован до нормализации.", + action: market.readiness.anchorReviewReady + ? "Проверка якорей зафиксирована, но очередь Wordstat пустая: нужно явно разрешить хотя бы часть смысловых якорей." + : "Подтвердить смысловые якоря: автопредложение нормализации не отправляется во внешний рынок без ручного решения.", + label: market.readiness.anchorReviewReady ? "очередь пустая" : "якоря не подтверждены", + summary: "Спрос заблокирован до проверки якорей.", tone: "problem" as const }; } @@ -932,8 +971,8 @@ function getMarketDecision(market: MarketEnrichmentContract) { function getMarketEvidenceCards(market: MarketEnrichmentContract): SeoChartDatum[] { return [ { - detail: "подтверждённая очередь", - label: "К проверке", + detail: "подтверждённые якоря", + label: "Очередь", tone: "neutral", value: market.readiness.seedCount }, @@ -944,7 +983,7 @@ function getMarketEvidenceCards(market: MarketEnrichmentContract): SeoChartDatum value: market.wordstat.summary.collectedSeedCount }, { - detail: "точный или related signal", + detail: "точный или похожий сигнал", label: "Спрос", tone: getMarketSignalCount(market) > 0 ? "good" : "problem", value: getMarketSignalCount(market) @@ -956,10 +995,10 @@ function getMarketEvidenceCards(market: MarketEnrichmentContract): SeoChartDatum value: market.wordstat.summary.noSignalSeedCount ?? 0 }, { - detail: "не пускать дальше без модели", - label: "Review", - tone: market.normalization.readiness.needsHumanReviewCount > 0 ? "warning" : "neutral", - value: market.normalization.readiness.needsHumanReviewCount + detail: "якоря ждут решения", + label: "На разборе", + tone: market.readiness.anchorPendingCount > 0 ? "warning" : "neutral", + value: market.readiness.anchorPendingCount } ]; } @@ -983,7 +1022,7 @@ function getMarketDemandComposition(market: MarketEnrichmentContract): SeoChartD value: noSignalCount }, { - detail: "approved, но ещё без job", + detail: "в очереди, но ещё без сбора", label: "Дособрать", tone: pendingCount > 0 ? "warning" : "neutral", value: pendingCount @@ -993,8 +1032,8 @@ function getMarketDemandComposition(market: MarketEnrichmentContract): SeoChartD function getMarketReadinessRadar(market: MarketEnrichmentContract): SeoChartDatum[] { const normalizedCount = market.normalization.readiness.normalizedSeedCount; - const wordstatApprovedCount = market.normalization.readiness.wordstatApprovedCount; - const reviewCount = market.normalization.readiness.needsHumanReviewCount; + const wordstatApprovedCount = market.readiness.anchorWordstatQueueCount; + const reviewCount = market.readiness.anchorPendingCount; const seedCount = market.readiness.seedCount; const signalCount = getMarketSignalCount(market); const wordstatProvider = market.providers.find((provider) => provider.id === "wordstat"); @@ -1002,19 +1041,19 @@ function getMarketReadinessRadar(market: MarketEnrichmentContract): SeoChartDatu return [ { - detail: "approved ontology", + detail: "подтверждённый контекст", label: "Контекст", tone: market.readiness.ontologyReadyForMarket ? "good" : "problem", value: market.readiness.ontologyReadyForMarket ? 100 : 0 }, { - detail: "Wordstat queue", + detail: "ручная очередь якорей", label: "Очередь", tone: wordstatApprovedCount > 0 ? "good" : "warning", value: getChartPercent(wordstatApprovedCount, Math.max(normalizedCount, 1)) }, { - detail: "собрано по approved", + detail: "собрано по очереди", label: "Сбор", tone: market.wordstat.summary.collectedSeedCount >= seedCount && seedCount > 0 ? "good" : "warning", value: getChartPercent(market.wordstat.summary.collectedSeedCount, Math.max(seedCount, 1)) @@ -1026,8 +1065,8 @@ function getMarketReadinessRadar(market: MarketEnrichmentContract): SeoChartDatu value: getChartPercent(signalCount, Math.max(seedCount, 1)) }, { - detail: "меньше спорных фраз", - label: "Review clear", + detail: "меньше якорей на разборе", + label: "Разбор", tone: reviewCount === 0 ? "good" : "warning", value: 100 - getChartPercent(reviewCount, Math.max(normalizedCount, 1)) }, @@ -1581,6 +1620,12 @@ function getRewriteReadinessFunnelData( tone: rewritePlan.readiness.rewriteCandidateCount > 0 ? "good" : "warning", value: rewritePlan.readiness.rewriteCandidateCount }, + { + detail: "будущие посадочные без rewrite", + label: "Gaps", + tone: rewritePlan.readiness.futureLandingGapCount > 0 ? "warning" : "neutral", + value: rewritePlan.readiness.futureLandingGapCount + }, { detail: "поля с текущим текстом", label: "Поля", @@ -1640,6 +1685,12 @@ function getMaterializationBars(materialization: MaterializationContract): SeoCh tone: "neutral", value: materialization.readiness.placeholderTargetCount }, + { + detail: "будущие посадочные", + label: "Gaps", + tone: "warning", + value: materialization.readiness.futureLandingGapCount + }, { detail: "не пойдёт в правки", label: "Отложено", @@ -4061,40 +4112,156 @@ function SeoHeatmapChart({ ); } -function SeoNormalizationReviewQueue({ - emptyLabel, - seeds, - subtitle, - title +function SeoAnchorReviewWorkspace({ + actionKey, + activeFilter, + anchorReview, + isBulkApproving, + onDecision, + onFilterChange, + stageProjectId, + sectionId }: { - emptyLabel: string; - seeds: SeoNormalizationSeedView[]; - subtitle: string; - title: string; + actionKey: string | null; + activeFilter: AnchorReviewFilter; + anchorReview: AnchorReviewContract; + isBulkApproving: boolean; + onDecision: (anchor: AnchorReviewAnchorView, decision: AnchorReviewDecisionInput) => void; + onFilterChange: (filter: AnchorReviewFilter) => void; + stageProjectId?: string; + sectionId?: string; }) { + const filters: AnchorReviewFilter[] = ["all", "proposed", "approved", "pending", "disabled"]; + const anchors = filterAnchorReviewAnchors(anchorReview.anchors, activeFilter); + const filterCounts: Record = { + all: anchorReview.anchors.length, + approved: anchorReview.readiness.approvedAnchorCount, + disabled: anchorReview.readiness.disabledAnchorCount, + pending: anchorReview.readiness.pendingAnchorCount, + proposed: anchorReview.readiness.proposedWordstatCount + }; + return ( -
-
- {title} - {seeds.length} +
+
+ +
+ Этап 1 · Фиксация ключей + Выбираем смысловые якоря и вручную решаем, что отправлять в очередь Wordstat. + + {getAnchorReviewStateLabel(anchorReview.state)} · очередь Wordstat {anchorReview.readiness.wordstatQueueCount} · + нормализовано: {anchorReview.readiness.normalizedAnchorCount} · предложено:{" "} + {anchorReview.readiness.proposedWordstatCount} · на разборе: {anchorReview.readiness.pendingAnchorCount} · скрыто:{" "} + {anchorReview.readiness.disabledAnchorCount} + +
- {subtitle} -
- {seeds.length > 0 ? ( - seeds.slice(0, 3).map((seed) => ( -

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

- )) + +
+ {filters.map((filter) => ( + + ))} +
+ +
+ {anchors.length > 0 ? ( + anchors.map((anchor) => { + const approveActionKey = `${anchor.id}:approved`; + const disableActionKey = `${anchor.id}:disabled`; + const pendingActionKey = `${anchor.id}:pending`; + const isApproveBusy = actionKey === approveActionKey; + const isDisableBusy = actionKey === disableActionKey; + const isPendingBusy = actionKey === pendingActionKey; + + return ( +
+
+ {getAnchorDecisionStatusLabel(anchor.decision.status)} + {anchor.phrase} + + {getSeoMarketRoleLabel(anchor.marketRole)} · {anchor.clusterTitle} · {Math.round(anchor.confidence * 100)}% + +

{getAnchorReviewReason(anchor)}

+
+
+ {anchor.proposal.sendToWordstat ? "Wordstat предложен" : "без Wordstat"} + {anchor.proposal.sendToSerp ? "SERP предложен" : "SERP нет"} + {anchor.normalizedFrom.slice(0, 2).join(", ") || anchor.source} +
+
+ + + +
+
+ ); + }) ) : ( -

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

+
+ В этом фильтре пусто + Переключи фильтр или верни anchors из другого статуса. +
)}
@@ -4140,6 +4307,304 @@ function SeoKeywordCleaningLane({ ); } +function KeywordCurationCard({ + item, + role +}: { + item: KeywordMapItemView; + role: KeywordCurationRole; +}) { + return ( +
+ {getKeywordCurationRoleLabel(role)} + {item.phrase} + + {formatCount(item.frequency)} · {getMarketEvidenceStatusLabel(item.evidenceStatus)} · {item.targetPath ?? item.relatedLanding ?? "без страницы"} + +

{item.reason}

+
+ ); +} + +function DraggableKeywordCurationCard({ + isDragging, + item, + onPointerDragEnd, + onPointerDragMove, + onPointerDragStart, + role +}: { + isDragging: boolean; + item: KeywordMapItemView; + onPointerDragEnd: (itemId: string, x: number, y: number) => void; + onPointerDragMove: (itemId: string, x: number, y: number) => void; + onPointerDragStart: (itemId: string, pointerId: number, x: number, y: number) => void; + role: KeywordCurationRole; +}) { + function handlePointerDown(event: ReactPointerEvent) { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + onPointerDragStart(item.id, event.pointerId, event.clientX, event.clientY); + } + + function handlePointerMove(event: ReactPointerEvent) { + if (!isDragging) { + return; + } + + event.preventDefault(); + onPointerDragMove(item.id, event.clientX, event.clientY); + } + + function handlePointerEnd(event: ReactPointerEvent) { + if (!isDragging) { + return; + } + + event.preventDefault(); + + try { + event.currentTarget.releasePointerCapture(event.pointerId); + } catch { + // Pointer capture can already be released by the browser on fast drags. + } + + onPointerDragEnd(item.id, event.clientX, event.clientY); + } + + return ( +
+ +
+ ); +} + +function KeywordCurationColumn({ + activeItemId, + isDraggingOver, + items, + onPointerDragEnd, + onPointerDragMove, + onPointerDragStart, + role +}: { + activeItemId: string | null; + isDraggingOver: boolean; + items: KeywordMapItemView[]; + onPointerDragEnd: (itemId: string, x: number, y: number) => void; + onPointerDragMove: (itemId: string, x: number, y: number) => void; + onPointerDragStart: (itemId: string, pointerId: number, x: number, y: number) => void; + role: KeywordCurationRole; +}) { + const column = keywordCurationColumns.find((item) => item.role === role); + + return ( +
+
+
+ {column?.title ?? getKeywordMapRoleLabel(role)} + {column?.description} +
+ {items.length} +
+
+ {items.length > 0 ? ( + items.map((item) => ( + + )) + ) : ( +
Перетащи сюда запросы
+ )} +
+
+ ); +} + +function KeywordCurationBoard({ + isSubmitting, + keywordMap, + onReconfigure, + onRoleChange, + onSubmit, + overrides, + stageProjectId +}: { + isSubmitting: boolean; + keywordMap: KeywordMapContract; + onReconfigure: () => void; + onRoleChange: (itemId: string, role: KeywordCurationRole) => void; + onSubmit: () => void; + overrides: Record; + stageProjectId?: string; +}) { + const groups = getKeywordCurationGroups(keywordMap, overrides); + const [activeItemId, setActiveItemId] = useState(null); + const [pointerDrag, setPointerDrag] = useState<{ + itemId: string; + pointerId: number; + x: number; + y: number; + } | null>(null); + const [dropRole, setDropRole] = useState(null); + const activeItem = activeItemId ? keywordMap.items.find((item) => item.id === activeItemId) ?? null : null; + const activeRole = activeItem ? getKeywordCurationRoleForItem(activeItem, overrides) : "validate"; + const persistableCount = getKeywordCurationPersistableCount(keywordMap, overrides); + const submitDisabled = isSubmitting || keywordMap.state !== "draft" || persistableCount === 0; + + function getDropRoleAtPoint(x: number, y: number) { + const element = document.elementFromPoint(x, y)?.closest("[data-keyword-drop-role]"); + const role = element?.dataset.keywordDropRole as KeywordCurationRole | undefined; + if (!role) { + return null; + } + + return keywordCurationColumns.some((column) => column.role === role) ? role : null; + } + + function handleKeywordDragStart(itemId: string, pointerId: number, x: number, y: number) { + setActiveItemId(itemId); + setPointerDrag({ itemId, pointerId, x, y }); + setDropRole(getDropRoleAtPoint(x, y)); + } + + function handleKeywordDragMove(itemId: string, x: number, y: number) { + if (pointerDrag?.itemId !== itemId) { + return; + } + + setPointerDrag((currentDrag) => (currentDrag?.itemId === itemId ? { ...currentDrag, x, y } : currentDrag)); + setDropRole(getDropRoleAtPoint(x, y)); + } + + function handleKeywordDragEnd(itemId: string, x: number, y: number) { + const activeDragItem = keywordMap.items.find((item) => item.id === itemId); + const nextRole = getDropRoleAtPoint(x, y); + + setActiveItemId(null); + setPointerDrag(null); + setDropRole(null); + + if (!activeDragItem || !nextRole || getKeywordCurationRoleForItem(activeDragItem, overrides) === nextRole) { + return; + } + + onRoleChange(itemId, nextRole); + } + + useEffect(() => { + if (!pointerDrag) { + return; + } + + const activeDrag = pointerDrag; + + function handleGlobalDragMove(event: MouseEvent | PointerEvent) { + setPointerDrag((currentDrag) => + currentDrag?.itemId === activeDrag.itemId ? { ...currentDrag, x: event.clientX, y: event.clientY } : currentDrag + ); + setDropRole(getDropRoleAtPoint(event.clientX, event.clientY)); + } + + function handleGlobalDragEnd(event: MouseEvent | PointerEvent) { + handleKeywordDragEnd(activeDrag.itemId, event.clientX, event.clientY); + } + + window.addEventListener("pointermove", handleGlobalDragMove); + window.addEventListener("pointerup", handleGlobalDragEnd); + window.addEventListener("pointercancel", handleGlobalDragEnd); + window.addEventListener("mousemove", handleGlobalDragMove); + window.addEventListener("mouseup", handleGlobalDragEnd); + + return () => { + window.removeEventListener("pointermove", handleGlobalDragMove); + window.removeEventListener("pointerup", handleGlobalDragEnd); + window.removeEventListener("pointercancel", handleGlobalDragEnd); + window.removeEventListener("mousemove", handleGlobalDragMove); + window.removeEventListener("mouseup", handleGlobalDragEnd); + }; + }, [pointerDrag, keywordMap.items, overrides]); + + return ( +
+
+
+ +
+ Этап 3 · Распределение ключей + Перетащи запросы между колонками, чтобы поправить модельную сортировку перед стратегией. +
+
+ +
+
+ {keywordCurationColumns.map((column) => ( + + ))} +
+ {pointerDrag && activeItem + ? createPortal( +
+ +
, + document.body + ) + : null} +
+ + {keywordMap.state === "draft" + ? `В стратегию уйдут ${persistableCount} фраз с подтверждённым Wordstat evidence; всё в колонке “Неразобранные” останется вне rewrite.` + : "Карта ключей заблокирована backend-гейтами; сначала нужно закрыть блокеры контекста и доказательств."} + + +
+
+ ); +} + function SeoScatterChart({ emptyLabel, points, @@ -4744,6 +5209,77 @@ function getMarketStateLabel(state: MarketEnrichmentContract["state"]) { return labels[state]; } +function getAnchorReviewStateLabel(state: AnchorReviewContract["state"]) { + const labels = { + approved: "зафиксировано", + empty: "пусто", + needs_review: "нужно подтвердить", + not_ready: "не готово" + }; + + return labels[state]; +} + +function getAnchorReviewFilterLabel(filter: AnchorReviewFilter) { + const labels = { + all: "Все", + approved: "В Wordstat", + disabled: "Скрыто", + pending: "На разборе", + proposed: "Предложено" + }; + + return labels[filter]; +} + +function getAnchorDecisionStatusLabel(status: AnchorReviewAnchorView["decision"]["status"]) { + const labels = { + approved: "в Wordstat", + disabled: "скрыто", + pending: "на разборе" + }; + + return labels[status]; +} + +function getAnchorReviewReason(anchor: AnchorReviewAnchorView) { + const reason = anchor.decision.reason || anchor.proposal.reason || anchor.reason; + + if (reason.includes("Human approved proposed Wordstat anchor queue")) { + return "Пользователь подтвердил предложенный якорь для очереди Wordstat."; + } + + if (reason.includes("Human approved anchor") || reason.includes("Human-approved anchor")) { + return "Пользователь добавил якорь в очередь Wordstat."; + } + + if (reason.includes("Human disabled")) { + return "Пользователь скрыл якорь перед сбором Wordstat."; + } + + if (reason.includes("Human left")) { + return "Пользователь оставил якорь на ручном разборе."; + } + + if (reason.includes("Normalization rejected")) { + return "Нормализация предложила скрыть этот якорь до ручного решения."; + } + + return reason; +} + +function filterAnchorReviewAnchors(anchors: AnchorReviewAnchorView[], filter: AnchorReviewFilter) { + if (filter === "all") { + return anchors; + } + + if (filter === "proposed") { + return anchors.filter((anchor) => anchor.proposal.sendToWordstat); + } + + return anchors.filter((anchor) => anchor.decision.status === filter); +} + function getMarketProviderStatusLabel(status: MarketEnrichmentContract["providers"][number]["status"]) { const labels = { connected: "подключено", @@ -4821,12 +5357,65 @@ function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role" return labels[role as keyof typeof labels] ?? role; } +function normalizeKeywordCurationRole(role: KeywordMapRole | KeywordCurationRole): KeywordCurationRole { + return role === "secondary_candidate" ? "validate" : role; +} + +function getKeywordCurationRoleLabel(role: KeywordCurationRole) { + return keywordCurationColumns.find((column) => column.role === role)?.title ?? getKeywordMapRoleLabel(role); +} + +function getKeywordCurationRoleForItem(item: KeywordMapItemView, overrides: Record) { + return overrides[item.id] ?? normalizeKeywordCurationRole(item.role); +} + +function getKeywordCurationGroups(keywordMap: KeywordMapContract, overrides: Record) { + const groups = new Map(); + + keywordCurationColumns.forEach((column) => groups.set(column.role, [])); + keywordMap.items.forEach((item) => { + const role = getKeywordCurationRoleForItem(item, overrides); + const currentItems = groups.get(role) ?? []; + currentItems.push(item); + groups.set(role, currentItems); + }); + + return groups; +} + +function getKeywordCurationRoleOverrides( + keywordMap: KeywordMapContract, + overrides: Record +): KeywordMapRoleOverrideInput[] { + return keywordMap.items + .map((item) => ({ + itemId: item.id, + role: getKeywordCurationRoleForItem(item, overrides) + })) + .filter((override) => override.role !== normalizeKeywordCurationRole(keywordMap.items.find((item) => item.id === override.itemId)?.role ?? "validate")); +} + +function getKeywordCurationPersistableCount(keywordMap: KeywordMapContract, overrides: Record) { + return keywordMap.items.filter((item) => { + const role = getKeywordCurationRoleForItem(item, overrides); + + return ( + role !== "validate" && + item.evidenceStatus === "collected" && + item.frequency !== null && + Boolean(item.projectOntologyVersionId) && + Boolean(item.wordstatResultId) + ); + }).length; +} + function getRewritePlanStateLabel(state: RewritePlanContract["state"]) { const labels = { blocked: "заблокировано", materialization_required: "нужны посадочные", partial_ready: "частично готово", - ready_for_diff: "готово к правкам" + ready_for_diff: "готово к правкам", + strategy_gap_only: "только strategy gaps" }; return labels[state]; @@ -4859,8 +5448,10 @@ function getMaterializationStatusLabel(status: MaterializationContract["targets" awaiting_source: "source approved", can_link_existing: "можно связать", deferred: "отложено", + future_landing_gap: "future gap", linked_existing: "связано", needs_materialization: "нужна страница", + needs_user_mapping: "нужен mapping", planned_placeholder: "плановая", rejected: "отклонено" }; @@ -6547,9 +7138,12 @@ function getSemanticBlockOverridesFromModels( ]); const geometry = sanitizeSemanticBlockGeometry(block.geometryCache); const blockKey = block.blockKey || block.id; - const targetIds = Array.from(new Set(block.targetIds.filter(Boolean))); + const anchorTargetIds = sanitizeSemanticBlockAnchors(anchors) + .map((anchor) => anchor.targetId) + .filter((targetId): targetId is string => Boolean(targetId)); + const targetIds = Array.from(new Set([...block.targetIds.filter(Boolean), ...anchorTargetIds])); - return blockKey && (targetIds.length > 0 || capturedEntities.length > 0 || anchors.length > 0) + return blockKey && targetIds.length > 0 ? [ { anchors, @@ -7866,6 +8460,11 @@ export function App() { const [scanningProjectId, setScanningProjectId] = useState(null); const [analyzingProjectId, setAnalyzingProjectId] = useState(null); const [reviewingContextProjectId, setReviewingContextProjectId] = useState(null); + const [approvingAnchorReviewProjectId, setApprovingAnchorReviewProjectId] = useState(null); + const [anchorReviewActionKey, setAnchorReviewActionKey] = useState(null); + const [anchorReviewFilters, setAnchorReviewFilters] = useState>({}); + const [keywordCurationRoleOverrides, setKeywordCurationRoleOverrides] = useState({}); + const [activeKeywordWorkflowSteps, setActiveKeywordWorkflowSteps] = useState>({}); const [approvingOntologyKey, setApprovingOntologyKey] = useState(null); const [confirmingOntologyKey, setConfirmingOntologyKey] = useState(null); const [enrichingMarketProjectId, setEnrichingMarketProjectId] = useState(null); @@ -9518,6 +10117,98 @@ export function App() { } } + async function refreshAfterAnchorReviewDecision(project: ProjectSummary) { + 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); + + return marketResult.marketEnrichment.anchorReview; + } + + async function handleAnchorReviewApproval(project: ProjectSummary) { + setApprovingAnchorReviewProjectId(project.id); + setAnchorReviewActionKey(`${project.id}:bulk`); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await saveAnchorReviewDecisions(project.id); + if (result.anchorReview.readiness.wordstatQueueCount > 0) { + setEnrichingMarketProjectId(project.id); + await runMarketEnrichment(project.id); + } + await refreshAfterAnchorReviewDecision(project); + setProjectActionMessage( + `Якоря сохранены: в Wordstat ${result.anchorReview.readiness.approvedAnchorCount}, очередь ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}. Wordstat обновлён по доступной очереди.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось сохранить anchor review.")); + } finally { + setApprovingAnchorReviewProjectId(null); + setEnrichingMarketProjectId(null); + setAnchorReviewActionKey(null); + } + } + + async function handleAnchorReviewDecision(project: ProjectSummary, decision: AnchorReviewDecisionInput) { + const decisionKey = `${decision.anchorId}:${decision.status}`; + + setAnchorReviewActionKey(decisionKey); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await saveAnchorReviewDecisions(project.id, [decision]); + await refreshAfterAnchorReviewDecision(project); + setProjectActionMessage( + `Якорь обновлён: ${getAnchorDecisionStatusLabel(decision.status)}. Очередь Wordstat ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}. После ручных изменений обнови Wordstat.` + ); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось обновить anchor review.")); + } finally { + setAnchorReviewActionKey(null); + } + } + async function handleSeoModelTaskRun(project: ProjectSummary, taskType: SeoModelTaskType) { const taskKey = `${project.id}:${taskType}`; setRunningModelTaskKey(taskKey); @@ -9882,14 +10573,36 @@ export function App() { } } - async function handleApproveKeywordMapDecisions(project: ProjectSummary) { + function handleKeywordCurationRoleChange(projectId: string, itemId: string, role: KeywordCurationRole) { + setKeywordCurationRoleOverrides((currentOverrides) => ({ + ...currentOverrides, + [projectId]: { + ...(currentOverrides[projectId] ?? {}), + [itemId]: role + } + })); + } + + function handleKeywordReconfigure(projectId: string) { + document.getElementById(`keyword-anchor-review:${projectId}`)?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + async function handleApproveKeywordMapDecisions( + project: ProjectSummary, + input?: { + jumpToStrategy?: boolean; + roleOverrides?: KeywordMapRoleOverrideInput[]; + } + ) { setPersistingKeywordMapProjectId(project.id); setProjectActionError(null); setProjectActionMessage(null); setProjectActionProjectId(project.id); try { - const result = await approveKeywordMapDecisions(project.id); + const result = await approveKeywordMapDecisions(project.id, { + roleOverrides: input?.roleOverrides + }); const [strategyResult, rewritePlanResult, materializationResult, rewriteDiffResult] = await Promise.all([ fetchLatestSeoStrategy(project.id), fetchLatestRewritePlan(project.id), @@ -9919,8 +10632,15 @@ export function App() { })); invalidateStrategyQualityReview(project.id); setProjectActionMessage( - `Keyword decisions сохранены: ${result.savedDecisionCount}, map items: ${result.savedMapItemCount}, skipped: ${result.skippedItemCount}.` + `Распределение ключей сохранено: решений ${result.savedDecisionCount}, привязок ${result.savedMapItemCount}, пропущено ${result.skippedItemCount}.` ); + setKeywordCurationRoleOverrides((currentOverrides) => ({ + ...currentOverrides, + [project.id]: {} + })); + if (input?.jumpToStrategy) { + setActiveStageIndex(5); + } } catch (error: unknown) { setProjectActionError(getErrorMessage(error, "Не удалось сохранить keyword decisions.")); } finally { @@ -10075,26 +10795,33 @@ export function App() { scopeItem }); const targets = getWorkspaceTargetsForRewriteRuntime(rawTargets, overrides); - const blocks = overrides.map((override) => { + const blocks = overrides.flatMap((override) => { const sourceBindings = buildSemanticBlockSourceBindings(override, targets); - return { - anchors: override.anchors ?? [], - blockKey: override.id, - capturedEntities: override.capturedEntities ?? [], - geometryCache: override.geometry ?? {}, - identity: buildSemanticBlockIdentity(override, sourceBindings), - mediaContext: buildSemanticBlockMediaContext(sourceBindings), - rewriteState: { - status: "draft" - }, - role: "content", - source: "manual" as const, - sourceBindings, - targetIds: override.targetIds, - title: override.title - }; + if (sourceBindings.length === 0) { + return []; + } + + return [ + { + anchors: override.anchors ?? [], + blockKey: override.id, + capturedEntities: override.capturedEntities ?? [], + geometryCache: override.geometry ?? {}, + identity: buildSemanticBlockIdentity(override, sourceBindings), + mediaContext: buildSemanticBlockMediaContext(sourceBindings), + rewriteState: { + status: "draft" + }, + role: "content", + source: "manual" as const, + sourceBindings, + targetIds: override.targetIds, + title: override.title + } + ]; }); + const skippedSemanticBlockCount = Math.max(0, overrides.length - blocks.length); try { const result = await savePageSemanticBlocks(projectId, workspace.pageId, { @@ -10110,7 +10837,11 @@ export function App() { [workspaceKey]: persistedOverrides })); setProjectActionError(null); - setProjectActionMessage(`Семантические блоки сохранены в backend: ${persistedOverrides.length}. Нужна новая генерация.`); + setProjectActionMessage( + `Семантические блоки сохранены в backend: ${persistedOverrides.length}${ + skippedSemanticBlockCount > 0 ? ` · без текстовой привязки пропущено: ${skippedSemanticBlockCount}` : "" + }. Нужна новая генерация.` + ); setProjectActionProjectId(projectId); } catch (error: unknown) { setProjectActionError(getErrorMessage(error, "Не удалось сохранить semantic blocks в backend.")); @@ -11605,13 +12336,88 @@ export function App() { }; }, [activeProject, activeProjectScanSummary, activeStageIndex, activeWorkspaceScopeIds, busyWorkspaceKey, pageWorkspaces]); + useEffect(() => { + if (!activeProject || activeStageIndex !== 4) { + return; + } + + let animationFrame = 0; + const selector = `[data-keyword-stage-project="${activeProject.id}"]`; + + const updateActiveKeywordStep = () => { + window.cancelAnimationFrame(animationFrame); + animationFrame = window.requestAnimationFrame(() => { + const sections = Array.from(document.querySelectorAll(selector)); + + if (sections.length === 0) { + return; + } + + const scrollRoot = + document.querySelector(".seo-work-panel .project-setup") ?? + document.querySelector(".seo-work-panel"); + const rootRect = scrollRoot?.getBoundingClientRect() ?? { + bottom: window.innerHeight, + height: window.innerHeight, + top: 0 + }; + const activeCandidate = + sections + .map((section) => { + const rect = section.getBoundingClientRect(); + const visibleHeight = Math.max(0, Math.min(rect.bottom, rootRect.bottom) - Math.max(rect.top, rootRect.top)); + + return { + coverage: visibleHeight / Math.max(rootRect.height, 1), + section + }; + }) + .sort((first, second) => second.coverage - first.coverage)[0]; + const nextStep = + activeCandidate && activeCandidate.coverage >= 0.5 + ? (activeCandidate.section.dataset.keywordStage as KeywordWorkflowStep | undefined) + : "none"; + + if (!nextStep) { + return; + } + + setActiveKeywordWorkflowSteps((currentSteps) => + currentSteps[activeProject.id] === nextStep + ? currentSteps + : { + ...currentSteps, + [activeProject.id]: nextStep + } + ); + }); + }; + + const workPanel = document.querySelector(".seo-work-panel"); + const workPanelScrollRoot = document.querySelector(".seo-work-panel .project-setup"); + + updateActiveKeywordStep(); + window.addEventListener("scroll", updateActiveKeywordStep, { passive: true }); + workPanel?.addEventListener("scroll", updateActiveKeywordStep, { passive: true }); + workPanelScrollRoot?.addEventListener("scroll", updateActiveKeywordStep, { passive: true }); + window.addEventListener("resize", updateActiveKeywordStep); + + return () => { + window.cancelAnimationFrame(animationFrame); + window.removeEventListener("scroll", updateActiveKeywordStep); + workPanel?.removeEventListener("scroll", updateActiveKeywordStep); + workPanelScrollRoot?.removeEventListener("scroll", updateActiveKeywordStep); + window.removeEventListener("resize", updateActiveKeywordStep); + }; + }, [activeProject, activeStageIndex, keywordMaps, marketEnrichments]); + 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 - ? { + const topbarHeading = + activeProject && activeStageIndex === 1 + ? { title: "Подтвердить конфигурацию проекта", subtitle: "Выбранные посадочные попадут в рабочую зону, контекстный анализ, спрос и стратегию." } @@ -11621,12 +12427,39 @@ export function App() { 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 === 6 && activeProjectWorkspaceScopeItem ? ( -
+
+ + ) : activeProject && activeStageIndex === 4 ? ( +
+ {activeKeywordWorkflowStep === "curation" && activeKeywordMap ? ( + + ) : activeKeywordWorkflowStep === "analysis" && activeAnchorReviewReady ? ( + activeKeywordWordstatFresh ? ( + + ) : ( + + ) + ) : activeKeywordWorkflowStep === "anchors" && activeAnchorReview ? ( + + ) : null} +
+ ) : activeProject && activeStageIndex === 6 && activeProjectWorkspaceScopeItem ? ( +
-
- - {semanticAnalysis && !activeContextReview ? ( + {activeStageIndex === 4 ? ( +
+ {semanticAnalysis && !activeContextReview ? (
Перед рыночным спросом нужен Context Review @@ -15777,14 +16645,46 @@ export function App() {
) : null} - {semanticAnalysis && marketEnrichment ? ( - <> -
+ {semanticAnalysis && marketEnrichment ? ( + <> + {anchorReview ? ( + void handleAnchorReviewDecision(project, decision)} + onFilterChange={(filter) => + setAnchorReviewFilters((currentFilters) => ({ + ...currentFilters, + [project.id]: filter + })) + } + stageProjectId={project.id} + sectionId={`keyword-anchor-review:${project.id}`} + /> + ) : null} + + {showKeywordDemandAnalysis ? ( +
+
+ +
+ Этап 2 · Анализ выбранных ключей + Wordstat и внутренняя нормализация показывают, какие фразы подтверждены спросом. +
+
+
{getMarketStateLabel(marketEnrichment.state)} - Темы: {marketEnrichment.readiness.seedCount} · Посадочные гипотезы:{" "} - {marketEnrichment.readiness.briefCount} + Очередь: {marketEnrichment.readiness.seedCount} · anchors:{" "} + {marketEnrichment.readiness.anchorApprovedCount}/{marketEnrichment.anchorReview.readiness.normalizedAnchorCount} · + посадочные гипотезы: {marketEnrichment.readiness.briefCount} Проверено: {formatCount(marketEnrichment.wordstat.summary.collectedSeedCount)} · спрос:{" "} @@ -15803,6 +16703,8 @@ export function App() {

{!marketEnrichment.readiness.ontologyReadyForMarket ? "Сначала нужно подтвердить контекст проекта, чтобы спрос не собирался по ошибочной семантике." + : !marketEnrichment.readiness.anchorReviewReady + ? "Сначала нужно вручную зафиксировать смысловые якоря; автопредложение не отправляется в Wordstat." : marketEnrichment.state === "not_configured" ? "Контекст готов, но сбор рыночного спроса ещё не настроен." : "Спрос отделён от внутреннего анализа: видно, какие фразы подтверждены, а какие остаются гипотезой."} @@ -15830,25 +16732,25 @@ export function App() {

- SEO-нормализация - {marketEnrichment.normalization.summary} + Анализ ключей + + {marketEnrichment.normalization.readiness.sourceSeedCount} исходных фраз →{" "} + {marketEnrichment.normalization.readiness.normalizedSeedCount} смысловых якорей + - Сырых фраз: {marketEnrichment.normalization.readiness.sourceSeedCount} · нормализовано:{" "} - {marketEnrichment.normalization.readiness.normalizedSeedCount} · групп интента:{" "} - {marketEnrichment.normalization.readiness.intentGroupCount} · ручная проверка:{" "} - {marketEnrichment.normalization.readiness.needsHumanReviewCount} + Внутренний разбор сайта перед Wordstat: система выделяет смысловые ветки, предлагает кандидаты, + а пользователь утверждает рабочую очередь ниже.
- {marketEnrichment.normalization.provider.mode === "codex_manual" ? "модель" : "резерв"} + {marketEnrichment.normalization.provider.mode === "codex_manual" ? "модель" : "резерв"} - {marketEnrichment.normalization.analystReview.status} - Авто {marketEnrichment.normalization.readiness.autoApprovedCount} - Wordstat {marketEnrichment.normalization.readiness.wordstatApprovedCount} - Выдача {marketEnrichment.normalization.readiness.serpApprovedCount} - Отклонено {marketEnrichment.normalization.readiness.rejectedSeedCount} - - {marketEnrichment.normalization.modelTask ? "задача модели готова" : "задача модели ждёт"} + групп интента {marketEnrichment.normalization.readiness.intentGroupCount} + предложено Wordstat {marketEnrichment.normalization.readiness.wordstatApprovedCount} + на разборе {marketEnrichment.normalization.readiness.needsHumanReviewCount} + скрыто {marketEnrichment.normalization.readiness.rejectedSeedCount} + + {marketEnrichment.normalization.modelTask ? "маршрут модели готов" : "маршрут модели ждёт"}
@@ -15872,69 +16774,68 @@ export function App() { ) : null}
- {normalizationReviewQueues ? ( -
- - - - -
- ) : null} +
+ ) : null} -
- - - - -
+ {showKeywordAnalysisPending ? ( +
+
+ 2 этап · анализ выбранных ключей + + Собрано {marketEnrichment.wordstat.summary.collectedSeedCount}/ + {marketEnrichment.readiness.seedCount} фраз из текущей очереди. + + + {isEnrichingMarket + ? "Готовим свежую аналитику: Wordstat, графики спроса и очистка появятся здесь после завершения." + : "Очередь якорей изменилась или ещё не собиралась; графики и очистка появятся только после свежего Wordstat."} + +
+
+ ) : null} -
+ {showKeywordDemandAnalysis ? ( +
+ + + + +
+ ) : null} + + {showKeywordDemandAnalysis ? ( + <> +
- Сигналы подтверждённой очереди + Сигналы последнего Wordstat {marketEnrichment.wordstat.latestJob - ? `${marketEnrichment.wordstat.latestJob.status} · ${marketEnrichment.wordstat.latestJob.requestedPhraseCount} фраз` + ? `${getScanStatusLabel(marketEnrichment.wordstat.latestJob.status)} · ${marketEnrichment.wordstat.latestJob.requestedPhraseCount} фраз` : "задача ещё не создавалась"}
@@ -15957,7 +16858,7 @@ export function App() { ) : null}
- {keywordCleaning ? ( + {keywordCleaning && !wordstatOutdated ? ( <>
@@ -16097,11 +16998,35 @@ export function App() {

))}
-
- ) : null} +
+ ) : null} - - ) : semanticAnalysis ? ( + {keywordMap && keywordMap.items.length > 0 ? ( + handleKeywordReconfigure(project.id)} + onRoleChange={(itemId, role) => handleKeywordCurationRoleChange(project.id, itemId, role)} + onSubmit={() => + void handleApproveKeywordMapDecisions(project, { + jumpToStrategy: true, + roleOverrides: getKeywordCurationRoleOverrides(keywordMap, keywordCurationOverrides) + }) + } + overrides={keywordCurationOverrides} + stageProjectId={project.id} + /> + ) : ( +
+ + Карта ключей пока не собрана. Проверь очистку фраз и повтори сбор спроса. +
+ )} + + + ) : null} + + ) : semanticAnalysis ? (
Контракт спроса ещё не загружен. Проверь providers, чтобы получить текущий статус @@ -18166,7 +19091,8 @@ export function App() { Существующие страницы: {rewritePlan.readiness.existingPageTargetCount} · плановые:{" "} - {rewritePlan.readiness.plannedLandingTargetCount} · блокеры:{" "} + {rewritePlan.readiness.plannedLandingTargetCount} · future gaps:{" "} + {rewritePlan.readiness.futureLandingGapCount} · блокеры:{" "} {rewritePlan.readiness.blockerCount} @@ -18180,6 +19106,8 @@ export function App() {

{rewritePlan.state === "materialization_required" ? "План правок есть, но часть посадочных ещё не существует в скане. Сначала нужно связать или отложить плановые страницы." + : rewritePlan.state === "strategy_gap_only" + ? "Утвержденные фразы сейчас указывают только на future landing gaps. Они остаются в стратегии и не блокируют правки существующих секций." : rewritePlan.state === "ready_for_diff" || rewritePlan.state === "partial_ready" ? "Есть привязки к существующим страницам; следующий слой может собрать правки для ручного review." : "Model review ждёт зафиксированный план правок."} @@ -18294,13 +19222,16 @@ export function App() { {materialization.readiness.placeholderTargetCount} · отложено/отклонено:{" "} {materialization.readiness.rejectedOrDeferredCount} - Ждёт источника: {materialization.readiness.awaitingSourceCount} + + Future gaps: {materialization.readiness.futureLandingGapCount} · ждёт источника:{" "} + {materialization.readiness.awaitingSourceCount} + План правок: {getRewritePlanStateLabel(materialization.rewritePlanState)}

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

) : null} @@ -18322,7 +19253,7 @@ export function App() { {target.pageTitle ?? target.urlPath ?? target.sourcePath ?? "страница не связана"} - Сейчас это аналитический блокер, а не задача на создание или правку файла сайта. + Сейчас это strategy gap или materialization context, а не автоматическая команда на создание файла сайта.
@@ -18351,6 +19282,7 @@ export function App() { {rewriteDiff.readiness.materializationTargetCount} · блокеры:{" "} {rewriteDiff.readiness.blockerCount} + Future gaps: {rewriteDiff.readiness.futureLandingGapCount}

Контракт готовит текущие значения полей для проверки, но новый текст появится только после diff --git a/seo_mode/seo_mode/app/src/api.ts b/seo_mode/seo_mode/app/src/api.ts index 54f2f9c..67231dd 100644 --- a/seo_mode/seo_mode/app/src/api.ts +++ b/seo_mode/seo_mode/app/src/api.ts @@ -730,6 +730,97 @@ export type SeoNormalizationContract = { nextActions: string[]; }; +export type AnchorReviewDecision = { + decidedAt: string | null; + decidedBy: string | null; + reason: string; + sendToSerp: boolean; + sendToWordstat: boolean; + source: "human" | "model" | "system"; + status: "approved" | "disabled" | "pending"; +}; + +export type AnchorReviewContract = { + schemaVersion: "anchor-review.v1"; + projectId: string; + semanticRunId: string | null; + projectOntologyVersionId: string | null; + generatedAt: string; + state: "approved" | "empty" | "needs_review" | "not_ready"; + source: { + normalizationGeneratedAt: string | null; + normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null; + normalizationState: SeoNormalizationContract["state"]; + normalizationSummary: string; + }; + analystReview: { + status: "approved" | "needs_changes" | "not_reviewed" | "rejected"; + reviewer: "human" | "system"; + reviewedAt: string | null; + notes: string[]; + }; + readiness: { + sourceSeedCount: number; + normalizedAnchorCount: number; + proposedWordstatCount: number; + approvedAnchorCount: number; + disabledAnchorCount: number; + pendingAnchorCount: number; + wordstatQueueCount: number; + needsHumanReviewCount: number; + manualDecisionCount: number; + }; + anchors: Array<{ + id: string; + key: string; + phrase: string; + normalizedFrom: string[]; + clusterId: string; + clusterTitle: string; + intentGroupId: string; + intentType: SeoNormalizationContract["intentGroups"][number]["intentType"]; + marketRole: SeoNormalizationContract["seedStrategy"][number]["marketRole"]; + priority: "high" | "medium" | "low"; + source: "detected" | "ontology"; + confidence: number; + reason: string; + evidenceRefs: string[]; + proposal: { + blockers: string[]; + reason: string; + sendToSerp: boolean; + sendToWordstat: boolean; + status: SeoNormalizationContract["seedStrategy"][number]["review"]["status"]; + }; + decision: AnchorReviewDecision; + }>; + wordstatQueue: Array<{ + id: string; + anchorId: string; + phrase: string; + clusterId: string; + clusterTitle: string; + intentGroupId: string; + intentType: SeoNormalizationContract["intentGroups"][number]["intentType"]; + marketRole: SeoNormalizationContract["seedStrategy"][number]["marketRole"]; + normalizedFrom: string[]; + priority: "high" | "medium" | "low"; + source: "detected" | "ontology"; + confidence: number; + reason: string; + sendToSerp: boolean; + }>; + nextActions: string[]; +}; + +export type AnchorReviewDecisionInput = { + anchorId: string; + reason?: string; + sendToSerp?: boolean; + sendToWordstat?: boolean; + status: AnchorReviewDecision["status"]; +}; + export type MarketEnrichmentContract = { schemaVersion: "market-enrichment.v1"; projectId: string; @@ -746,7 +837,12 @@ export type MarketEnrichmentContract = { generatedAt: string; state: "not_ready" | "not_collected" | "not_configured" | "partial_ready"; normalization: SeoNormalizationContract; + anchorReview: AnchorReviewContract; readiness: { + anchorApprovedCount: number; + anchorPendingCount: number; + anchorReviewReady: boolean; + anchorWordstatQueueCount: number; canCollectWordstat: boolean; canCollectSerp: boolean; ontologyReadyForMarket: boolean; @@ -1286,6 +1382,13 @@ export type KeywordMapContract = { nextActions: string[]; }; +export type KeywordMapRole = KeywordMapContract["items"][number]["role"]; + +export type KeywordMapRoleOverrideInput = { + itemId: string; + role: KeywordMapRole; +}; + export type SeoStrategyContract = { schemaVersion: "seo-strategy.v1"; projectId: string; @@ -1643,16 +1746,18 @@ export type RewritePlanContract = { semanticRunId: string | null; projectOntologyVersionId: string | null; generatedAt: string; - state: "blocked" | "materialization_required" | "partial_ready" | "ready_for_diff"; + state: "blocked" | "materialization_required" | "partial_ready" | "ready_for_diff" | "strategy_gap_only"; readiness: { approvedDecisionCount: number; existingPageTargetCount: number; + futureLandingGapCount: number; plannedLandingTargetCount: number; rewriteCandidateCount: number; blockerCount: number; }; blockers: string[]; existingPageTargets: RewritePlanTarget[]; + futureLandingTargets: RewritePlanTarget[]; materializationQueue: RewritePlanTarget[]; nextActions: string[]; }; @@ -1664,7 +1769,7 @@ export type RewritePlanTarget = { pageTitle: string | null; sourcePath: string | null; urlPath: string | null; - status: "needs_materialization" | "planned_placeholder" | "ready_for_diff"; + status: "future_landing_gap" | "needs_materialization" | "needs_user_mapping" | "planned_placeholder" | "ready_for_diff"; blocker: string | null; keywordBindings: Array<{ decisionId: string; @@ -1695,6 +1800,7 @@ export type MaterializationContract = { readiness: { awaitingSourceCount: number; blockerCount: number; + futureLandingGapCount: number; linkedTargetCount: number; pendingTargetCount: number; placeholderTargetCount: number; @@ -1711,8 +1817,10 @@ export type MaterializationContract = { | "awaiting_source" | "can_link_existing" | "deferred" + | "future_landing_gap" | "linked_existing" | "needs_materialization" + | "needs_user_mapping" | "planned_placeholder" | "rejected"; pageId: string | null; @@ -1795,6 +1903,7 @@ export type RewriteDiffContract = { state: "blocked" | "materialization_required" | "partial_ready" | "ready_for_model_review"; readiness: { blockerCount: number; + futureLandingGapCount: number; materializationTargetCount: number; modelReadySlotCount: number; semanticBlockCount: number; @@ -2722,6 +2831,32 @@ export async function saveManualSeoNormalization(projectId: string, output: Reco return response.json() as Promise<{ normalization: SeoNormalizationContract }>; } +export async function fetchLatestAnchorReview(projectId: string) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/anchor-review/latest`); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось загрузить anchor review: ${response.status}`)); + } + + return response.json() as Promise<{ anchorReview: AnchorReviewContract }>; +} + +export async function saveAnchorReviewDecisions(projectId: string, decisions?: AnchorReviewDecisionInput[]) { + const response = await fetch(`${apiBaseUrl}/projects/${projectId}/anchor-review/decisions`, { + body: JSON.stringify({ decisions }), + headers: { + "Content-Type": "application/json" + }, + method: "POST" + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось сохранить anchor review: ${response.status}`)); + } + + return response.json() as Promise<{ anchorReview: AnchorReviewContract }>; +} + export async function fetchLatestKeywordCleaning(projectId: string) { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-cleaning/latest`); @@ -2872,13 +3007,19 @@ export async function runStrategyQualityReview(projectId: string) { return response.json() as Promise<{ strategyQualityReview: StrategyQualityReviewContract }>; } -export async function approveKeywordMapDecisions(projectId: string, itemIds?: string[]) { +export async function approveKeywordMapDecisions( + projectId: string, + input?: { + itemIds?: string[]; + roleOverrides?: KeywordMapRoleOverrideInput[]; + } +) { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-map/decisions`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ itemIds }) + body: JSON.stringify(input ?? {}) }); if (!response.ok) { diff --git a/seo_mode/seo_mode/app/src/styles.css b/seo_mode/seo_mode/app/src/styles.css index 6efb487..9e60193 100644 --- a/seo_mode/seo_mode/app/src/styles.css +++ b/seo_mode/seo_mode/app/src/styles.css @@ -2794,14 +2794,440 @@ input { gap: 8px; } +.stage-content-body { + display: grid; + gap: 10px; + min-width: 0; +} + +.anchor-review-card, .keyword-cleaning-card { align-items: center; } +.anchor-review-card > button, .keyword-cleaning-card > button { flex: 0 0 auto; } +.keyword-stage-section { + display: grid; + gap: 12px; + min-width: 0; + padding: 1rem; + background: #fff; + border: 0; + border-radius: var(--launcher-radius-card, 1.35rem); + box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055); +} + +.keyword-stage-heading { + display: flex; + align-items: flex-start; + gap: 10px; + min-width: 0; +} + +.keyword-stage-heading > svg { + flex: 0 0 auto; + margin-top: 2px; + color: var(--ink); +} + +.keyword-stage-heading > div { + display: grid; + gap: 5px; + min-width: 0; +} + +.keyword-stage-heading strong { + min-width: 0; + color: var(--ink); + font-size: 18px; + font-weight: 920; + line-height: 1.08; + overflow-wrap: anywhere; +} + +.keyword-stage-heading small { + min-width: 0; + color: var(--muted); + font-size: 12px; + font-weight: 740; + line-height: 1.38; + overflow-wrap: anywhere; +} + +.anchor-review-workspace { + background: #fff; +} + +.anchor-review-row > div:first-child span { + justify-self: start; + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 9px; + color: #2e5a67; + background: rgba(46, 90, 103, 0.08); + border: 1px solid rgba(46, 90, 103, 0.14); + border-radius: 999px; + font-size: 11px; + font-weight: 900; +} + +.anchor-review-row strong, +.anchor-review-empty strong { + min-width: 0; + color: var(--ink); + font-size: 13px; + overflow-wrap: anywhere; +} + +.anchor-review-row small, +.anchor-review-row p, +.anchor-review-empty small { + min-width: 0; + margin: 0; + color: var(--muted); + font-size: 12px; + font-weight: 740; + line-height: 1.38; + overflow-wrap: anywhere; +} + +.keyword-curation-board { + display: grid; + gap: 12px; + min-width: 0; + padding: 1rem; + background: #fff; + border: 0; + border-radius: var(--launcher-radius-card, 1.35rem); + box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055); +} + +.keyword-curation-board-head, +.keyword-curation-footer { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.keyword-curation-board-head > .keyword-stage-heading { + flex: 1 1 auto; +} + +.keyword-curation-card > span { + justify-self: start; + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 9px; + color: #2e5a67; + background: rgba(46, 90, 103, 0.08); + border: 1px solid rgba(46, 90, 103, 0.14); + border-radius: 999px; + font-size: 11px; + font-weight: 900; +} + +.keyword-curation-column-head strong, +.keyword-curation-card strong { + min-width: 0; + color: var(--ink); + font-size: 13px; + overflow-wrap: anywhere; +} + +.keyword-curation-column-head small, +.keyword-curation-card small, +.keyword-curation-card p, +.keyword-curation-footer small { + min-width: 0; + margin: 0; + color: var(--muted); + font-size: 12px; + font-weight: 740; + line-height: 1.38; + overflow-wrap: anywhere; +} + +.keyword-curation-columns { + display: grid; + grid-auto-columns: minmax(280px, 1fr); + grid-auto-flow: column; + gap: 8px; + min-width: 0; + overflow-x: auto; + padding-bottom: 2px; +} + +.keyword-curation-column { + display: grid; + grid-template-rows: auto minmax(180px, 1fr); + gap: 8px; + min-width: 280px; + max-height: min(640px, 58vh); + padding: 8px; + background: rgba(24, 32, 29, 0.035); + border: 1px solid rgba(24, 32, 29, 0.07); + border-radius: 8px; + transition: + background-color 140ms ease, + border-color 140ms ease; +} + +.keyword-curation-column.over { + background: rgba(46, 90, 103, 0.07); + border-color: rgba(46, 90, 103, 0.22); +} + +.keyword-curation-column-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.keyword-curation-column-head > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.keyword-curation-column-head > span { + flex: 0 0 auto; + min-width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--ink); + background: rgba(255, 255, 255, 0.82); + border: 1px solid rgba(24, 32, 29, 0.08); + border-radius: 999px; + font-size: 12px; + font-weight: 900; +} + +.keyword-curation-stack { + display: grid; + align-content: start; + gap: 8px; + min-width: 0; + overflow: auto; + padding-right: 2px; +} + +.keyword-curation-draggable { + cursor: grab; + touch-action: none; + user-select: none; +} + +.keyword-curation-draggable.dragging { + cursor: grabbing; + opacity: 0.28; + position: relative; + z-index: 3; +} + +.keyword-curation-card { + display: grid; + gap: 5px; + min-width: 0; + padding: 10px; + background: rgba(255, 255, 255, 0.92); + border: 1px solid rgba(24, 32, 29, 0.08); + border-radius: 8px; + box-shadow: 0 10px 24px rgba(24, 32, 29, 0.08); + user-select: none; +} + +.keyword-curation-drag-preview { + position: fixed; + left: var(--keyword-drag-x); + top: var(--keyword-drag-y); + width: 260px; + pointer-events: none; + transform: translate(-50%, -24px) rotate(-0.6deg); + z-index: 9999; +} + +.keyword-curation-drag-preview .keyword-curation-card { + background: #fff; + border-color: rgba(232, 62, 140, 0.28); + box-shadow: 0 24px 54px rgba(24, 32, 29, 0.22); +} + +.keyword-curation-empty { + min-height: 88px; + display: grid; + place-items: center; + padding: 10px; + color: var(--muted); + background: rgba(255, 255, 255, 0.52); + border: 1px dashed rgba(24, 32, 29, 0.14); + border-radius: 8px; + font-size: 12px; + font-weight: 820; + text-align: center; +} + +.keyword-curation-footer { + align-items: center; + padding-top: 2px; +} + +.anchor-review-filterbar { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 6px; +} + +.anchor-review-filterbar button { + min-width: 0; + min-height: 38px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px; + color: var(--muted); + background: rgba(24, 32, 29, 0.035); + border: 1px solid rgba(24, 32, 29, 0.07); + border-radius: 999px; + font: inherit; + cursor: pointer; +} + +.anchor-review-filterbar button.active { + color: #fff; + background: #202124; + border-color: #202124; +} + +.anchor-review-filterbar button span { + min-width: 0; + overflow: hidden; + font-size: 12px; + font-weight: 880; + text-overflow: ellipsis; + white-space: nowrap; +} + +.anchor-review-filterbar button strong { + flex: 0 0 auto; + color: inherit; + font-size: 12px; + font-weight: 900; +} + +.anchor-review-workspace-list { + display: grid; + gap: 8px; + max-height: min(560px, 52vh); + min-width: 0; + overflow: auto; + padding-right: 2px; +} + +.anchor-review-row { + display: grid; + grid-template-columns: minmax(260px, 1fr) minmax(180px, 0.4fr) auto; + gap: 10px; + align-items: center; + min-width: 0; + padding: 10px; + background: rgba(24, 32, 29, 0.032); + border: 1px solid rgba(24, 32, 29, 0.055); + border-left: 4px solid rgba(46, 90, 103, 0.18); + border-radius: 8px; +} + +.anchor-review-row.approved { + border-left-color: #2f6b4f; +} + +.anchor-review-row.disabled { + border-left-color: #9b3b3b; + opacity: 0.72; +} + +.anchor-review-row.pending { + border-left-color: #b27a16; +} + +.anchor-review-row > div:first-child { + display: grid; + gap: 4px; + min-width: 0; +} + +.anchor-review-row-meta, +.anchor-review-row-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-width: 0; +} + +.anchor-review-row-meta span { + min-height: 24px; + display: inline-flex; + align-items: center; + max-width: 100%; + padding: 0 8px; + color: #2e4960; + background: rgba(46, 73, 96, 0.06); + border: 1px solid rgba(46, 73, 96, 0.08); + border-radius: 999px; + font-size: 11px; + font-weight: 850; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.anchor-review-row-actions { + justify-content: flex-end; +} + +.anchor-review-row-actions .tiny-action { + flex: 0 0 auto; + width: 36px; + min-width: 36px; + height: 36px; + min-height: 36px; + justify-content: center; + padding: 0; + border-radius: 999px; + white-space: nowrap; +} + +.anchor-review-row-actions .anchor-action-button.active, +.anchor-review-row-actions .anchor-action-button.active:disabled { + color: #fff; + background: #e83e8c; + border-color: #e83e8c; + opacity: 1 !important; + box-shadow: 0 12px 28px rgba(232, 62, 140, 0.22); +} + +.anchor-review-empty { + display: grid; + gap: 4px; + min-width: 0; + padding: 16px; + background: rgba(24, 32, 29, 0.032); + border: 1px dashed rgba(24, 32, 29, 0.14); + border-radius: 8px; +} + .keyword-cleaning-review { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -11723,6 +12149,28 @@ textarea:focus { grid-template-columns: 1fr; } + .keyword-curation-board-head, + .keyword-curation-footer { + flex-direction: column; + } + + .keyword-curation-columns { + grid-auto-columns: minmax(260px, 86vw); + } + + .anchor-review-filterbar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .anchor-review-row { + grid-template-columns: 1fr; + align-items: start; + } + + .anchor-review-row-actions { + justify-content: flex-start; + } + .market-decision-board, .market-decision-metrics { grid-template-columns: 1fr; @@ -13840,8 +14288,18 @@ body:has(.seo-launcher-shell) { .seo-launcher-shell.project-open .seo-work-panel { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.66)), - rgba(255, 255, 255, 0.76) !important; + linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(255, 255, 255, 0.46)), + rgba(255, 255, 255, 0.58) !important; +} + +.seo-launcher-shell.project-open .workspace.stage-5 .market-panel { + padding: 1rem !important; + background: #fff !important; + border: 0 !important; + border-radius: var(--launcher-radius-card) !important; + box-shadow: none !important; + backdrop-filter: none !important; + -webkit-backdrop-filter: none !important; } .seo-launcher-shell .workspace.stage-2 .project-scope-flow-controls { @@ -14288,6 +14746,15 @@ body:has(.seo-launcher-shell) { border-color: var(--ink); } +.anchor-review-row-actions .tiny-action.anchor-action-button.active, +.anchor-review-row-actions .tiny-action.anchor-action-button.active:disabled { + color: #ffffff !important; + background: #e83e8c !important; + border-color: #e83e8c !important; + opacity: 1 !important; + box-shadow: 0 12px 28px rgba(232, 62, 140, 0.22) !important; +} + @keyframes semantic-generation-pulse { 0% { box-shadow: 0 0 0 0 rgba(232, 45, 134, 0.36); diff --git a/seo_mode/seo_mode/package.json b/seo_mode/seo_mode/package.json index f17b74f..ca93080 100644 --- a/seo_mode/seo_mode/package.json +++ b/seo_mode/seo_mode/package.json @@ -13,6 +13,7 @@ "dev:server": "npm run dev -w server", "build": "npm run build -w server && npm run build -w app", "check:demand-coverage": "npm run check:demand-coverage -w server", + "check:semantic-block-runtime": "npm run check:semantic-block-runtime -w server", "check:semantic-blocks": "npm run check:semantic-blocks -w server", "check:source-models": "npm run check:source-models -w server", "typecheck": "npm run typecheck -w server && npm run typecheck -w app", diff --git a/seo_mode/seo_mode/server/package.json b/seo_mode/seo_mode/server/package.json index d7cb240..fe00767 100644 --- a/seo_mode/seo_mode/server/package.json +++ b/seo_mode/seo_mode/server/package.json @@ -9,6 +9,7 @@ "start": "node dist/index.js", "typecheck": "tsc --noEmit -p tsconfig.json", "check:demand-coverage": "tsx src/scripts/checkDemandCoverage.ts", + "check:semantic-block-runtime": "tsx src/scripts/checkSemanticBlockRuntimeCoverage.ts", "check:semantic-blocks": "tsx src/scripts/checkSemanticBlockUniversality.ts", "check:source-models": "tsx src/scripts/checkSourceModelUniversality.ts", "check:ontology-universality": "tsx src/scripts/checkOntologyUniversality.ts", diff --git a/seo_mode/seo_mode/server/src/keywords/anchorReview.ts b/seo_mode/seo_mode/server/src/keywords/anchorReview.ts new file mode 100644 index 0000000..4baaaa0 --- /dev/null +++ b/seo_mode/seo_mode/server/src/keywords/anchorReview.ts @@ -0,0 +1,489 @@ +import { pool } from "../db/client.js"; +import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js"; + +type AnchorDecisionStatus = "approved" | "disabled" | "pending"; +type AnchorDecisionSource = "human" | "model" | "system"; +type AnchorReviewState = "approved" | "empty" | "needs_review" | "not_ready"; +type SeoNormalizationSeed = SeoNormalizationContract["seedStrategy"][number]; + +export type AnchorReviewDecisionInput = { + anchorId: string; + reason?: string; + sendToSerp?: boolean; + sendToWordstat?: boolean; + status: AnchorDecisionStatus; +}; + +export type AnchorReviewDecision = { + decidedAt: string | null; + decidedBy: string | null; + reason: string; + sendToSerp: boolean; + sendToWordstat: boolean; + source: AnchorDecisionSource; + status: AnchorDecisionStatus; +}; + +export type AnchorReviewAnchor = { + id: string; + key: string; + phrase: string; + normalizedFrom: string[]; + clusterId: string; + clusterTitle: string; + intentGroupId: string; + intentType: SeoNormalizationSeed["intentType"]; + marketRole: SeoNormalizationSeed["marketRole"]; + priority: SeoNormalizationSeed["priority"]; + source: SeoNormalizationSeed["source"]; + confidence: number; + reason: string; + evidenceRefs: string[]; + proposal: { + blockers: string[]; + reason: string; + sendToSerp: boolean; + sendToWordstat: boolean; + status: SeoNormalizationSeed["review"]["status"]; + }; + decision: AnchorReviewDecision; +}; + +export type AnchorReviewWordstatQueueItem = { + id: string; + anchorId: string; + phrase: string; + clusterId: string; + clusterTitle: string; + intentGroupId: string; + intentType: SeoNormalizationSeed["intentType"]; + marketRole: SeoNormalizationSeed["marketRole"]; + normalizedFrom: string[]; + priority: SeoNormalizationSeed["priority"]; + source: SeoNormalizationSeed["source"]; + confidence: number; + reason: string; + sendToSerp: boolean; +}; + +export type AnchorReviewContract = { + schemaVersion: "anchor-review.v1"; + projectId: string; + semanticRunId: string | null; + projectOntologyVersionId: string | null; + generatedAt: string; + state: AnchorReviewState; + source: { + normalizationGeneratedAt: string | null; + normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null; + normalizationState: SeoNormalizationContract["state"]; + normalizationSummary: string; + }; + analystReview: { + status: "approved" | "needs_changes" | "not_reviewed" | "rejected"; + reviewer: "human" | "system"; + reviewedAt: string | null; + notes: string[]; + }; + readiness: { + sourceSeedCount: number; + normalizedAnchorCount: number; + proposedWordstatCount: number; + approvedAnchorCount: number; + disabledAnchorCount: number; + pendingAnchorCount: number; + wordstatQueueCount: number; + needsHumanReviewCount: number; + manualDecisionCount: number; + }; + anchors: AnchorReviewAnchor[]; + wordstatQueue: AnchorReviewWordstatQueueItem[]; + nextActions: string[]; +}; + +type AnchorReviewRunRow = { + id: string; + output: AnchorReviewContract | null; +}; + +type DecisionSnapshot = { + id: string; + key: string; + decision: AnchorReviewDecision; +}; + +function normalizePhrase(value: string) { + return value.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function getAnchorKey(seed: Pick) { + return `${seed.clusterId}:${normalizePhrase(seed.phrase)}`; +} + +function getDefaultDecision(seed: SeoNormalizationSeed): AnchorReviewDecision { + if (seed.review.status === "rejected" || seed.marketRole === "exclude") { + return { + decidedAt: null, + decidedBy: null, + reason: seed.review.reason || "Normalization rejected this anchor before market routing.", + sendToSerp: false, + sendToWordstat: false, + source: "system", + status: "disabled" + }; + } + + return { + decidedAt: null, + decidedBy: null, + reason: "Ожидает human approval: auto proposal не является разрешением на внешний сбор.", + sendToSerp: false, + sendToWordstat: false, + source: "system", + status: "pending" + }; +} + +function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorReviewDecision): AnchorReviewDecision { + if (decision.status === "approved") { + return { + decidedAt: decision.decidedAt, + decidedBy: decision.decidedBy, + reason: decision.reason || seed.review.reason || seed.reason, + sendToSerp: Boolean(decision.sendToSerp), + sendToWordstat: Boolean(decision.sendToWordstat), + source: decision.source, + status: decision.sendToWordstat || decision.sendToSerp ? "approved" : "pending" + }; + } + + if (decision.status === "disabled") { + return { + decidedAt: decision.decidedAt, + decidedBy: decision.decidedBy, + reason: decision.reason || "Anchor disabled by review.", + sendToSerp: false, + sendToWordstat: false, + source: decision.source, + status: "disabled" + }; + } + + return { + decidedAt: decision.decidedAt, + decidedBy: decision.decidedBy, + reason: decision.reason || "Ожидает human approval.", + sendToSerp: false, + sendToWordstat: false, + source: decision.source, + status: "pending" + }; +} + +function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] { + return (savedOutput?.anchors ?? []).map((anchor) => ({ + decision: anchor.decision, + id: anchor.id, + key: anchor.key + })); +} + +function buildAnchor( + seed: SeoNormalizationSeed, + index: number, + decisionSnapshotsById: Map, + decisionSnapshotsByKey: Map +): AnchorReviewAnchor { + const key = getAnchorKey(seed); + const savedDecision = decisionSnapshotsById.get(seed.id)?.decision ?? decisionSnapshotsByKey.get(key)?.decision; + const decision = savedDecision ? normalizeSavedDecision(seed, savedDecision) : getDefaultDecision(seed); + + return { + clusterId: seed.clusterId, + clusterTitle: seed.clusterTitle, + confidence: seed.confidence, + decision, + evidenceRefs: [`normalization.seedStrategy:${index}`, `normalization.intentGroup:${seed.intentGroupId}`], + id: seed.id, + intentGroupId: seed.intentGroupId, + intentType: seed.intentType, + key, + marketRole: seed.marketRole, + normalizedFrom: seed.normalizedFrom, + phrase: seed.phrase, + priority: seed.priority, + proposal: { + blockers: seed.review.blockers, + reason: seed.review.reason, + sendToSerp: seed.review.sendToSerp, + sendToWordstat: seed.review.sendToWordstat, + status: seed.review.status + }, + reason: seed.reason, + source: seed.source + }; +} + +function buildWordstatQueue(anchors: AnchorReviewAnchor[]): AnchorReviewWordstatQueueItem[] { + return anchors + .filter((anchor) => anchor.decision.status === "approved" && anchor.decision.sendToWordstat) + .map((anchor) => ({ + anchorId: anchor.id, + clusterId: anchor.clusterId, + clusterTitle: anchor.clusterTitle, + confidence: anchor.confidence, + id: `anchor-wordstat:${anchor.id}`, + intentGroupId: anchor.intentGroupId, + intentType: anchor.intentType, + marketRole: anchor.marketRole, + normalizedFrom: anchor.normalizedFrom, + phrase: anchor.phrase, + priority: anchor.priority, + reason: anchor.decision.reason || anchor.reason, + sendToSerp: anchor.decision.sendToSerp, + source: anchor.source + })); +} + +function getState(normalization: SeoNormalizationContract, anchors: AnchorReviewAnchor[], wordstatQueueCount: number): AnchorReviewState { + if (normalization.state !== "ready" || !normalization.semanticRunId) { + return "not_ready"; + } + + if (anchors.length === 0) { + return "empty"; + } + + return wordstatQueueCount > 0 ? "approved" : "needs_review"; +} + +function buildNextActions(contract: Omit) { + const actions: string[] = []; + + if (contract.state === "not_ready") { + actions.push("Сначала нужен Context Review и SEO normalization: anchor review не должен работать от сырых фраз сайта."); + return actions; + } + + if (contract.readiness.wordstatQueueCount === 0) { + actions.push("Подтвердить смысловые якоря перед Wordstat: auto proposal не является разрешением на внешний сбор."); + } else { + actions.push( + `Wordstat queue зафиксирована вручную: ${contract.readiness.wordstatQueueCount} anchors можно отправлять во внешний сбор.` + ); + } + + if (contract.readiness.pendingAnchorCount > 0) { + actions.push( + `Осталось разобрать ${contract.readiness.pendingAnchorCount} anchors: отключить шум или явно разрешить market routing.` + ); + } + + if (contract.readiness.needsHumanReviewCount > 0) { + actions.push( + `${contract.readiness.needsHumanReviewCount} anchors помечены normalization как спорные; они не идут в Wordstat без отдельного решения.` + ); + } + + actions.push("После Wordstat related/top строки должны попасть в keyword curation board, а не напрямую в rewrite."); + + return actions; +} + +function buildContract( + projectId: string, + normalization: SeoNormalizationContract, + decisionSnapshots: DecisionSnapshot[] = [] +): AnchorReviewContract { + const decisionSnapshotsById = new Map(decisionSnapshots.map((snapshot) => [snapshot.id, snapshot])); + const decisionSnapshotsByKey = new Map(decisionSnapshots.map((snapshot) => [snapshot.key, snapshot])); + const anchors = normalization.seedStrategy.map((seed, index) => + buildAnchor(seed, index, decisionSnapshotsById, decisionSnapshotsByKey) + ); + const wordstatQueue = buildWordstatQueue(anchors); + const pendingAnchorCount = anchors.filter((anchor) => anchor.decision.status === "pending").length; + const approvedAnchorCount = anchors.filter((anchor) => anchor.decision.status === "approved").length; + const disabledAnchorCount = anchors.filter((anchor) => anchor.decision.status === "disabled").length; + const manualDecisionCount = anchors.filter((anchor) => anchor.decision.decidedAt).length; + const contractWithoutActions: Omit = { + schemaVersion: "anchor-review.v1", + projectId, + semanticRunId: normalization.semanticRunId, + projectOntologyVersionId: normalization.projectOntologyVersionId, + generatedAt: new Date().toISOString(), + state: getState(normalization, anchors, wordstatQueue.length), + source: { + normalizationGeneratedAt: normalization.generatedAt, + normalizationProviderMode: normalization.provider.mode, + normalizationState: normalization.state, + normalizationSummary: normalization.summary + }, + analystReview: { + notes: + wordstatQueue.length > 0 + ? ["Human-approved anchor queue exists. Market collection must use only wordstatQueue."] + : ["Anchor queue awaits human approval before market collection."], + reviewedAt: manualDecisionCount > 0 ? anchors.find((anchor) => anchor.decision.decidedAt)?.decision.decidedAt ?? null : null, + reviewer: manualDecisionCount > 0 ? "human" : "system", + status: wordstatQueue.length > 0 ? "approved" : "not_reviewed" + }, + readiness: { + approvedAnchorCount, + disabledAnchorCount, + manualDecisionCount, + needsHumanReviewCount: anchors.filter((anchor) => anchor.proposal.status === "needs_human").length, + normalizedAnchorCount: anchors.length, + pendingAnchorCount, + proposedWordstatCount: anchors.filter((anchor) => anchor.proposal.sendToWordstat).length, + sourceSeedCount: normalization.readiness.sourceSeedCount, + wordstatQueueCount: wordstatQueue.length + }, + anchors, + wordstatQueue + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} + +export async function getLatestAlignedAnchorReviewOutput( + projectId: string, + semanticRunId: string +): Promise { + const result = await pool.query( + ` + select id, output + from runs + where project_id = $1 + and run_type = 'anchor_review' + and output->>'semanticRunId' = $2 + order by created_at desc + limit 1; + `, + [projectId, semanticRunId] + ); + + const output = result.rows[0]?.output ?? null; + + return output?.schemaVersion === "anchor-review.v1" ? output : null; +} + +export async function getAnchorReviewContract(projectId: string): Promise { + const normalization = await getSeoNormalizationContract(projectId); + const savedOutput = normalization.semanticRunId + ? await getLatestAlignedAnchorReviewOutput(projectId, normalization.semanticRunId) + : null; + + return buildContract(projectId, normalization, getSavedDecisionSnapshots(savedOutput)); +} + +function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecisionInput, decidedAt: string): AnchorReviewDecision { + const defaultReason = + input.status === "approved" + ? "Human-approved anchor for market collection." + : input.status === "disabled" + ? "Human disabled this anchor before market collection." + : "Human left this anchor pending."; + + if (input.status === "approved") { + const sendToSerp = input.sendToSerp ?? anchor.proposal.sendToSerp; + const sendToWordstat = input.sendToWordstat ?? anchor.proposal.sendToWordstat; + + return { + decidedAt, + decidedBy: "dev-mode", + reason: input.reason?.trim() || defaultReason, + sendToSerp, + sendToWordstat, + source: "human", + status: sendToWordstat || sendToSerp ? "approved" : "pending" + }; + } + + if (input.status === "disabled") { + return { + decidedAt, + decidedBy: "dev-mode", + reason: input.reason?.trim() || defaultReason, + sendToSerp: false, + sendToWordstat: false, + source: "human", + status: "disabled" + }; + } + + return { + decidedAt, + decidedBy: "dev-mode", + reason: input.reason?.trim() || defaultReason, + sendToSerp: false, + sendToWordstat: false, + source: "human", + status: "pending" + }; +} + +export async function saveAnchorReviewDecisions( + projectId: string, + decisions: AnchorReviewDecisionInput[] = [] +): Promise { + const current = await getAnchorReviewContract(projectId); + + if (!current.semanticRunId || current.state === "not_ready") { + throw new Error("Anchor review не готов: сначала нужен semantic analysis, context review и normalization."); + } + + const decidedAt = new Date().toISOString(); + const anchorsById = new Map(current.anchors.map((anchor) => [anchor.id, anchor])); + const nextDecisionById = new Map(current.anchors.map((anchor) => [anchor.id, anchor.decision])); + const inputs = + decisions.length > 0 + ? decisions + : current.anchors + .filter((anchor) => anchor.proposal.sendToWordstat && anchor.proposal.status !== "rejected") + .map((anchor) => ({ + anchorId: anchor.id, + reason: "Human approved proposed Wordstat anchor queue.", + sendToSerp: anchor.proposal.sendToSerp, + sendToWordstat: true, + status: "approved" as const + })); + + for (const input of inputs) { + const anchor = anchorsById.get(input.anchorId); + + if (!anchor) { + continue; + } + + nextDecisionById.set(anchor.id, applyDecisionInput(anchor, input, decidedAt)); + } + + const decisionSnapshots: DecisionSnapshot[] = current.anchors.map((anchor) => ({ + decision: nextDecisionById.get(anchor.id) ?? anchor.decision, + id: anchor.id, + key: anchor.key + })); + const normalization = await getSeoNormalizationContract(projectId); + const output = buildContract(projectId, normalization, decisionSnapshots); + const result = await pool.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'anchor_review', 'done', $2::jsonb, $3::jsonb, now(), now()) + returning id, output; + `, + [ + projectId, + JSON.stringify({ + decisionCount: inputs.length, + schemaVersion: "anchor-review-input.v1", + semanticRunId: output.semanticRunId + }), + JSON.stringify(output) + ] + ); + + return result.rows[0]?.output ?? output; +} diff --git a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts index dbff57f..8b2bdc4 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts @@ -4,6 +4,7 @@ import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keyw type KeywordMapState = "blocked" | "draft"; type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate"; +const keywordMapRoles: KeywordMapRole[] = ["differentiator", "primary", "secondary", "secondary_candidate", "support", "validate"]; export type KeywordMapContract = { schemaVersion: "keyword-map.v1"; @@ -82,6 +83,18 @@ export type KeywordMapPersistResult = { type KeywordMapItem = KeywordMapContract["items"][number]; type KeywordCleaningItem = KeywordCleaningContract["items"][number]; +export type KeywordMapApproveInput = { + itemIds?: string[]; + roleOverrides?: Array<{ + itemId: string; + role: KeywordMapRole; + }>; +}; + +function isKeywordMapRole(value: string): value is KeywordMapRole { + return keywordMapRoles.includes(value as KeywordMapRole); +} + function normalizePhrase(value: string) { return value.trim().replace(/\s+/g, " ").toLowerCase(); } @@ -337,6 +350,24 @@ function isPersistableKeywordMapItem(item: KeywordMapItem) { ); } +function applyKeywordMapRoleOverride(item: KeywordMapItem, role: KeywordMapRole): KeywordMapItem { + if (item.role === role) { + return item; + } + + const nonRewriteRole = role === "validate" || role === "secondary_candidate"; + const targetPath = nonRewriteRole ? null : (item.targetPath ?? item.relatedLanding); + const relatedLanding = nonRewriteRole ? (item.relatedLanding ?? item.targetPath) : item.relatedLanding; + + return { + ...item, + reason: `Пользователь перенёс фразу в роль «${role}». ${item.reason}`, + relatedLanding, + role, + targetPath + }; +} + function getWorkspaceSectionIdForRole(role: KeywordMapRole) { if (role === "primary") { return "seo-title"; @@ -629,13 +660,22 @@ export async function getKeywordMapContract(projectId: string): Promise { const keywordMap = await getKeywordMapContract(projectId); const itemIdFilter = input.itemIds && input.itemIds.length > 0 ? new Set(input.itemIds) : null; - const selectedItems = keywordMap.items.filter((item) => !itemIdFilter || itemIdFilter.has(item.id)); + const roleOverrideByItemId = new Map( + (input.roleOverrides ?? []) + .filter((override) => isKeywordMapRole(override.role)) + .map((override) => [override.itemId, override.role] as const) + ); + const selectedItems = keywordMap.items + .filter((item) => !itemIdFilter || itemIdFilter.has(item.id)) + .map((item) => { + const roleOverride = roleOverrideByItemId.get(item.id); + + return roleOverride ? applyKeywordMapRoleOverride(item, roleOverride) : item; + }); const persistableItems = selectedItems.filter(isPersistableKeywordMapItem); if (keywordMap.blockers.length > 0 || !keywordMap.semanticRunId || !keywordMap.projectOntologyVersion) { diff --git a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts index 75eabc2..e132727 100644 --- a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts +++ b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts @@ -11,6 +11,11 @@ import { type WordstatEvidence, type WordstatSeedCandidate } from "./wordstatRepository.js"; +import { + getAnchorReviewContract, + type AnchorReviewContract, + type AnchorReviewWordstatQueueItem +} from "../keywords/anchorReview.js"; import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js"; import { createWordstatProvider } from "./wordstatProvider.js"; @@ -56,7 +61,12 @@ export type MarketEnrichmentContract = { generatedAt: string; state: MarketEnrichmentState; normalization: SeoNormalizationContract; + anchorReview: AnchorReviewContract; readiness: { + anchorApprovedCount: number; + anchorPendingCount: number; + anchorReviewReady: boolean; + anchorWordstatQueueCount: number; canCollectWordstat: boolean; canCollectSerp: boolean; ontologyReadyForMarket: boolean; @@ -277,7 +287,7 @@ function getSeedEvidenceStatus( } function buildSeedQueue( - normalization: SeoNormalizationContract, + anchorReview: AnchorReviewContract, providers: MarketProviderDescriptor[], wordstat: WordstatEvidence, projectOntologyVersion: MarketProjectOntologyVersion | null @@ -285,7 +295,7 @@ function buildSeedQueue( const wordstatResults = buildWordstatSeedMap(wordstat); const ontologyReady = isOntologyReadyForMarket(projectOntologyVersion); - return normalization.seedStrategy.filter((seed) => seed.review.sendToWordstat).slice(0, 120).map((seed) => { + return anchorReview.wordstatQueue.slice(0, 120).map((seed) => { const targetProviders: MarketProviderId[] = ["wordstat"]; const result = wordstatResults.get(normalizePhrase(seed.phrase)); const evidenceStatus = ontologyReady ? getSeedEvidenceStatus(providers, wordstat, result) : "not_collected"; @@ -301,11 +311,11 @@ function buildSeedQueue( intentGroupId: seed.intentGroupId, intentType: seed.intentType, marketRole: seed.marketRole, - needsHumanReview: seed.needsHumanReview, + needsHumanReview: false, normalizedFrom: seed.normalizedFrom, - reviewReason: seed.review.reason, - reviewStatus: seed.review.status, - sendToSerp: seed.review.sendToSerp + reviewReason: seed.reason, + reviewStatus: "auto_approved" as const, + sendToSerp: seed.sendToSerp }, evidenceStatus, targetProviders, @@ -356,6 +366,7 @@ function getEnrichmentState( providers: MarketProviderDescriptor[], seedCount: number, wordstat: WordstatEvidence, + anchorReview: AnchorReviewContract, projectOntologyVersion: MarketProjectOntologyVersion | null ): MarketEnrichmentState { if (!analysis) { @@ -366,6 +377,10 @@ function getEnrichmentState( return "not_ready"; } + if (anchorReview.state !== "approved") { + return "not_ready"; + } + if (seedCount === 0) { return "not_ready"; } @@ -407,6 +422,13 @@ function buildNextActions(contract: Omit provider.id === "wordstat"); const serp = contract.providers.find((provider) => provider.id === "serp"); @@ -450,11 +472,10 @@ export async function getMarketEnrichmentContract(projectId: string): Promise seed.review.sendToWordstat) - .map((seed) => seed.phrase); + const anchorReview = await getAnchorReviewContract(projectId); + const approvedWordstatPhrases = anchorReview.wordstatQueue.map((seed) => seed.phrase); const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null, approvedWordstatPhrases); - const seedQueue = analysis ? buildSeedQueue(normalization, providers, wordstat, projectOntologyVersion) : []; + const seedQueue = analysis ? buildSeedQueue(anchorReview, providers, wordstat, projectOntologyVersion) : []; const briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : []; const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length; const ontologyReadyForMarket = isOntologyReadyForMarket(projectOntologyVersion); @@ -464,11 +485,17 @@ export async function getMarketEnrichmentContract(projectId: string): Promise 0 && wordstat.latestJob?.status !== "running", @@ -495,9 +522,8 @@ export async function runMarketEnrichment(projectId: string): Promise seed.review.sendToWordstat) + const anchorReview = await getAnchorReviewContract(projectId); + const normalizedSeeds: WordstatSeedCandidate[] = anchorReview.wordstatQueue .map((seed) => ({ clusterId: seed.clusterId, clusterTitle: seed.clusterTitle, @@ -511,6 +537,7 @@ export async function runMarketEnrichment(projectId: string): Promise { - const [analysis, normalization, keywordCleaning, yandexEvidence, strategy, serpInterpretation, strategySynthesis] = await Promise.all([ + const [analysis, normalization, anchorReview, keywordCleaning, yandexEvidence, strategy, serpInterpretation, strategySynthesis] = await Promise.all([ getLatestSemanticAnalysis(projectId), getSeoNormalizationContract(projectId), + getAnchorReviewContract(projectId), getKeywordCleaningContract(projectId), getLatestYandexEvidenceContract(projectId), getSeoStrategyContract(projectId), @@ -270,7 +272,7 @@ export async function getSeoModelContextContract(projectId: string): Promise 0, - canRunMarketCollection: normalization.seedStrategy.some((seed) => seed.review.sendToWordstat) + canRunMarketCollection: anchorReview.state === "approved" && anchorReview.wordstatQueue.length > 0 }, skills, modelProvider, @@ -341,7 +343,7 @@ export async function getSeoModelContextContract(projectId: string): Promise { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + anchorReview: await getAnchorReviewContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить anchor review проекта."); + } +}); + +projectsRouter.post("/:projectId/anchor-review/decisions", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = anchorReviewDecisionsSchema.parse(request.body ?? {}); + + response.status(201).json({ + anchorReview: await saveAnchorReviewDecisions(projectId, input.decisions ?? []) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь anchor review decisions."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить anchor review."); + } +}); + projectsRouter.get("/:projectId/keyword-cleaning/latest", async (request, response) => { try { const projectId = projectIdSchema.parse(request.params.projectId); diff --git a/seo_mode/seo_mode/server/src/rewrite/materialization.ts b/seo_mode/seo_mode/server/src/rewrite/materialization.ts index 2ad526f..ebf8f4f 100644 --- a/seo_mode/seo_mode/server/src/rewrite/materialization.ts +++ b/seo_mode/seo_mode/server/src/rewrite/materialization.ts @@ -6,8 +6,10 @@ type MaterializationStatus = | "awaiting_source" | "can_link_existing" | "deferred" + | "future_landing_gap" | "linked_existing" | "needs_materialization" + | "needs_user_mapping" | "planned_placeholder" | "rejected"; type MaterializationState = "blocked" | "complete" | "partial" | "pending"; @@ -50,6 +52,7 @@ export type MaterializationContract = { readiness: { awaitingSourceCount: number; blockerCount: number; + futureLandingGapCount: number; linkedTargetCount: number; pendingTargetCount: number; placeholderTargetCount: number; @@ -423,12 +426,23 @@ function getDecisionSourcePlan(decision: MaterializationDecisionRow | null) { function getTargetStatus(input: { decision: MaterializationDecisionRow | null; linkCandidates: ReturnType; - target: RewritePlanContract["materializationQueue"][number] | RewritePlanContract["existingPageTargets"][number]; + target: + | RewritePlanContract["existingPageTargets"][number] + | RewritePlanContract["futureLandingTargets"][number] + | RewritePlanContract["materializationQueue"][number]; }) { if (input.target.status === "ready_for_diff") { return "linked_existing" satisfies MaterializationStatus; } + if (input.target.status === "future_landing_gap") { + return "future_landing_gap" satisfies MaterializationStatus; + } + + if (input.target.status === "needs_user_mapping") { + return "needs_user_mapping" satisfies MaterializationStatus; + } + if (input.decision?.status === "awaiting_source") { return "awaiting_source" satisfies MaterializationStatus; } @@ -456,7 +470,10 @@ function buildTarget(input: { candidates: LatestPageCandidateRow[]; decision: MaterializationDecisionRow | null; sourceContext: ProjectSourceContextRow | null; - target: RewritePlanContract["materializationQueue"][number] | RewritePlanContract["existingPageTargets"][number]; + target: + | RewritePlanContract["existingPageTargets"][number] + | RewritePlanContract["futureLandingTargets"][number] + | RewritePlanContract["materializationQueue"][number]; }): MaterializationTarget { const normalizedTargetPath = normalizeTargetPath(input.target.targetPath); const linkCandidates = buildLinkCandidates(input.candidates, input.target.targetPath); @@ -514,6 +531,7 @@ function buildContractState(targets: MaterializationTarget[], blockers: string[] target.status === "awaiting_source" || target.status === "needs_materialization" || target.status === "can_link_existing" || + target.status === "needs_user_mapping" || target.status === "planned_placeholder" ); @@ -543,7 +561,13 @@ function buildNextActions(contract: Omit if (contract.readiness.placeholderTargetCount > 0) { actions.push( - `Для ${contract.readiness.placeholderTargetCount} targets нет реальной страницы в scan; это блокирует apply, но не блокирует стратегическую аналитику.` + `Для ${contract.readiness.placeholderTargetCount} targets нет реальной страницы в scan; это блокирует apply только после явного promotion.` + ); + } + + if (contract.readiness.futureLandingGapCount > 0) { + actions.push( + `${contract.readiness.futureLandingGapCount} future landing targets остаются strategy gap и не блокируют rewrite существующих секций.` ); } @@ -572,7 +596,11 @@ export async function getMaterializationContract(projectId: string): Promise [decision.target_key, decision])); - const targets = [...rewritePlan.materializationQueue, ...rewritePlan.existingPageTargets].map((target) => { + const targets = [ + ...rewritePlan.materializationQueue, + ...rewritePlan.futureLandingTargets, + ...rewritePlan.existingPageTargets + ].map((target) => { const targetKey = getTargetKey(target.targetPath); return buildTarget({ @@ -594,6 +622,9 @@ export async function getMaterializationContract(projectId: string): Promise target.status === "awaiting_source").length, blockerCount: blockers.length, + futureLandingGapCount: targets.filter( + (target) => target.status === "future_landing_gap" || target.status === "needs_user_mapping" + ).length, linkedTargetCount: targets.filter((target) => target.status === "linked_existing").length, pendingTargetCount: targets.filter( (target) => target.status === "needs_materialization" || target.status === "can_link_existing" @@ -833,7 +864,11 @@ export async function applyMaterializationAction(projectId: string, input: Mater throw new MaterializationError("Materialization requires approved keyword decisions with semantic run and ontology version."); } - const currentTarget = [...rewritePlan.materializationQueue, ...rewritePlan.existingPageTargets].find( + const currentTarget = [ + ...rewritePlan.materializationQueue, + ...rewritePlan.futureLandingTargets, + ...rewritePlan.existingPageTargets + ].find( (target) => getTargetKey(target.targetPath) === getTargetKey(input.targetPath) ); diff --git a/seo_mode/seo_mode/server/src/rewrite/rewriteDiff.ts b/seo_mode/seo_mode/server/src/rewrite/rewriteDiff.ts index d9be120..bd74639 100644 --- a/seo_mode/seo_mode/server/src/rewrite/rewriteDiff.ts +++ b/seo_mode/seo_mode/server/src/rewrite/rewriteDiff.ts @@ -42,6 +42,7 @@ export type RewriteDiffContract = { state: RewriteDiffState; readiness: { blockerCount: number; + futureLandingGapCount: number; materializationTargetCount: number; modelReadySlotCount: number; semanticBlockCount: number; @@ -496,6 +497,12 @@ function buildNextActions(contract: Omit) { ); } + if (contract.readiness.futureLandingGapCount > 0 && contract.readiness.targetCount === 0) { + actions.push( + `${contract.readiness.futureLandingGapCount} targets сейчас являются future landing gaps; они не блокируют rewrite существующего сайта, но требуют human landing/section mapping перед model review.` + ); + } + if (contract.readiness.sourcePendingSlotCount > 0) { actions.push(`Открыть page workspace / source snapshot для ${contract.readiness.sourcePendingSlotCount} diff slots.`); } @@ -519,6 +526,7 @@ function buildNextActions(contract: Omit) { function getState(input: { blockerCount: number; + futureLandingGapCount: number; materializationTargetCount: number; modelReadySlotCount: number; sourcePendingSlotCount: number; @@ -764,6 +772,7 @@ export async function getRewriteDiffContract(projectId: string): Promise; return priority[right] > priority[left] ? right : left; @@ -272,6 +287,14 @@ function getRewriteTargetBlocker(status: RewriteTarget["status"]) { return null; } + if (status === "future_landing_gap") { + return null; + } + + if (status === "needs_user_mapping") { + return "Keyword group needs explicit human page/section mapping before rewrite."; + } + if (status === "planned_placeholder") { return "Planned page placeholder exists, but there is no page version/source snapshot yet; create source or link an existing scanned page before diff/apply."; } @@ -323,16 +346,24 @@ function buildRewriteTargets(rows: ApprovedKeywordBindingRow[]) { return Array.from(targets.values()); } -function getRewriteState(existingPageTargets: RewriteTarget[], materializationQueue: RewriteTarget[]): RewritePlanState { - if (existingPageTargets.length === 0 && materializationQueue.length === 0) { +function getRewriteState(input: { + existingPageTargets: RewriteTarget[]; + futureLandingTargets: RewriteTarget[]; + materializationQueue: RewriteTarget[]; +}): RewritePlanState { + if (input.existingPageTargets.length === 0 && input.materializationQueue.length === 0 && input.futureLandingTargets.length === 0) { return "blocked"; } - if (existingPageTargets.length === 0 && materializationQueue.length > 0) { + if (input.existingPageTargets.length === 0 && input.materializationQueue.length === 0 && input.futureLandingTargets.length > 0) { + return "strategy_gap_only"; + } + + if (input.existingPageTargets.length === 0 && input.materializationQueue.length > 0) { return "materialization_required"; } - if (existingPageTargets.length > 0 && materializationQueue.length > 0) { + if (input.existingPageTargets.length > 0 && input.materializationQueue.length > 0) { return "partial_ready"; } @@ -352,6 +383,12 @@ function buildNextActions(contract: Omit) { ); } + if (contract.futureLandingTargets.length > 0) { + actions.push( + `${contract.futureLandingTargets.length} future landing targets зафиксированы как strategy gap; они не блокируют rewrite существующего сайта без явного promotion.` + ); + } + if (contract.existingPageTargets.length > 0) { actions.push( `Собрать rewrite diff для ${contract.existingPageTargets.length} существующих страниц с approved keyword bindings и human review.` @@ -377,23 +414,36 @@ export async function getRewritePlanContract(projectId: string): Promise target.status === "ready_for_diff"); - const materializationQueue = targets.filter((target) => target.status !== "ready_for_diff"); + const futureLandingTargets = targets.filter( + (target) => target.status === "future_landing_gap" || target.status === "needs_user_mapping" + ); + const materializationQueue = targets.filter( + (target) => target.status === "needs_materialization" || target.status === "planned_placeholder" + ); const contractWithoutActions: Omit = { schemaVersion: "rewrite-plan.v1", projectId, semanticRunId: context?.semantic_run_id ?? null, projectOntologyVersionId: context?.project_ontology_version_id ?? null, generatedAt: new Date().toISOString(), - state: blockers.length > 0 ? "blocked" : getRewriteState(existingPageTargets, materializationQueue), + state: blockers.length > 0 + ? "blocked" + : getRewriteState({ + existingPageTargets, + futureLandingTargets, + materializationQueue + }), readiness: { approvedDecisionCount: rows.length, blockerCount: blockers.length + materializationQueue.length, existingPageTargetCount: existingPageTargets.length, + futureLandingGapCount: futureLandingTargets.length, plannedLandingTargetCount: materializationQueue.length, rewriteCandidateCount: existingPageTargets.reduce((sum, target) => sum + target.keywordBindings.length, 0) }, blockers, existingPageTargets, + futureLandingTargets, materializationQueue }; diff --git a/seo_mode/seo_mode/server/src/scripts/checkDemandCoverage.ts b/seo_mode/seo_mode/server/src/scripts/checkDemandCoverage.ts index b505bac..f92dd9c 100644 --- a/seo_mode/seo_mode/server/src/scripts/checkDemandCoverage.ts +++ b/seo_mode/seo_mode/server/src/scripts/checkDemandCoverage.ts @@ -7,8 +7,12 @@ function getNumericConstant(source: string, name: string) { } const wordstatRepositorySource = await readFile(new URL("../market/wordstatRepository.ts", import.meta.url), "utf8"); +const marketEnrichmentSource = await readFile(new URL("../market/marketEnrichment.ts", import.meta.url), "utf8"); +const anchorReviewSource = await readFile(new URL("../keywords/anchorReview.ts", import.meta.url), "utf8"); const yandexEvidenceSource = await readFile(new URL("../evidence/yandexEvidence.ts", import.meta.url), "utf8"); const projectRoutesSource = await readFile(new URL("../projects/routes.ts", import.meta.url), "utf8"); +const materializationSource = await readFile(new URL("../rewrite/materialization.ts", import.meta.url), "utf8"); +const rewritePlanSource = await readFile(new URL("../rewrite/rewritePlan.ts", import.meta.url), "utf8"); const appApiSource = await readFile(new URL("../../../app/src/api.ts", import.meta.url), "utf8"); const appSource = await readFile(new URL("../../../app/src/App.tsx", import.meta.url), "utf8"); @@ -41,5 +45,37 @@ assert.ok( yandexEvidenceSource.includes("bucketKey") && yandexEvidenceSource.includes("candidatesByBucket"), "Yandex Evidence phrase selection must preserve semantic/landing coverage buckets." ); +assert.ok( + rewritePlanSource.includes("futureLandingTargets") && + rewritePlanSource.includes("future_landing_gap") && + rewritePlanSource.includes("strategy_gap_only"), + "Future landing candidates must stay visible as strategy gaps instead of forcing materialization." +); +assert.ok( + rewritePlanSource.includes("materializationQueue = targets.filter") && + rewritePlanSource.includes('target.status === "needs_materialization"') && + rewritePlanSource.includes('target.status === "planned_placeholder"'), + "Rewrite plan materialization queue must include only explicit blocking materialization targets." +); +assert.ok( + materializationSource.includes("futureLandingGapCount") && appApiSource.includes("futureLandingTargets"), + "Materialization/API contracts must expose future landing gaps as non-blocking strategy context." +); +assert.ok( + anchorReviewSource.includes('schemaVersion: "anchor-review.v1"') && + anchorReviewSource.includes("wordstatQueue") && + anchorReviewSource.includes("auto proposal не является разрешением"), + "Anchor review must preserve a human approval gate before Wordstat collection." +); +assert.ok( + marketEnrichmentSource.includes("getAnchorReviewContract") && + marketEnrichmentSource.includes("anchorReview.wordstatQueue") && + !/normalization\.seedStrategy\s*[\s\S]{0,80}\.filter\(\(seed\)\s*=>\s*seed\.review\.sendToWordstat\)/.test(marketEnrichmentSource), + "Market enrichment must collect Wordstat only from anchorReview.wordstatQueue, not normalization auto proposals." +); +assert.ok( + appApiSource.includes("AnchorReviewContract") && appSource.includes("handleAnchorReviewApproval"), + "Frontend/API must expose anchor review approval before market demand collection." +); console.info("Demand coverage check passed."); diff --git a/seo_mode/seo_mode/server/src/scripts/checkSemanticBlockRuntimeCoverage.ts b/seo_mode/seo_mode/server/src/scripts/checkSemanticBlockRuntimeCoverage.ts new file mode 100644 index 0000000..05aaf0a --- /dev/null +++ b/seo_mode/seo_mode/server/src/scripts/checkSemanticBlockRuntimeCoverage.ts @@ -0,0 +1,80 @@ +import { pool } from "../db/client.js"; + +type SemanticBlockSummaryRow = { + active_blocks: number; + project_id: string; + scope_count: number; + source_binding_count: number; + target_ref_count: number; + workspace_count: number; +}; + +type ZeroBindingRow = { + block_key: string; + captured_entities: number; + page_id: string; + project_id: string; + scope_id: string; + source_bindings: number; + target_ids: number; + title: string; + workspace_key: string; +}; + +const projectId = process.argv[2] || process.env.SEMANTIC_BLOCK_RUNTIME_PROJECT_ID || null; +const projectFilterSql = projectId ? "and project_id = $1" : ""; +const params = projectId ? [projectId] : []; + +try { + const [summaryResult, zeroBindingResult] = await Promise.all([ + pool.query( + ` + select + project_id, + count(*)::int as active_blocks, + count(distinct workspace_key)::int as workspace_count, + count(distinct scope_id)::int as scope_count, + coalesce(sum(cardinality(target_ids)), 0)::int as target_ref_count, + coalesce(sum(jsonb_array_length(source_bindings)), 0)::int as source_binding_count + from semantic_blocks + where archived_at is null + ${projectFilterSql} + group by project_id + order by project_id; + `, + params + ), + pool.query( + ` + select + project_id, + page_id, + workspace_key, + scope_id, + block_key, + title, + coalesce(cardinality(target_ids), 0)::int as target_ids, + coalesce(jsonb_array_length(source_bindings), 0)::int as source_bindings, + coalesce(jsonb_array_length(captured_entities), 0)::int as captured_entities + from semantic_blocks + where archived_at is null + ${projectFilterSql} + and coalesce(cardinality(target_ids), 0) = 0 + and coalesce(jsonb_array_length(source_bindings), 0) = 0 + order by project_id, workspace_key, block_key; + `, + params + ) + ]); + + if (zeroBindingResult.rows.length > 0) { + console.error("Semantic block runtime coverage failed: active blocks without target/source bindings."); + console.error(JSON.stringify(zeroBindingResult.rows, null, 2)); + process.exitCode = 1; + } else { + console.info("Semantic block runtime coverage passed."); + console.info(JSON.stringify(summaryResult.rows, null, 2)); + } +} finally { + await pool.end(); +} diff --git a/seo_mode/seo_mode/server/src/scripts/checkSemanticBlockUniversality.ts b/seo_mode/seo_mode/server/src/scripts/checkSemanticBlockUniversality.ts index 78b45c3..b05f34d 100644 --- a/seo_mode/seo_mode/server/src/scripts/checkSemanticBlockUniversality.ts +++ b/seo_mode/seo_mode/server/src/scripts/checkSemanticBlockUniversality.ts @@ -144,10 +144,25 @@ assert.ok( semanticBlockRepositorySource.includes("saveSemanticBlocksForWorkspace") && semanticBlockRepositorySource.includes("client.release()"), "Semantic block saves must go through an atomic backend repository transaction." ); +assert.ok( + semanticBlockRepositorySource.includes("hasSemanticBlockRuntimeBinding") && + semanticBlockRepositorySource.includes("sourceBindings.length > 0"), + "Semantic block repository must not persist captured-only visual boxes without text target/source bindings." +); assert.ok( appSource.includes("hydratePersistedWorkspaceSemanticBlocks") && appSource.includes("savePageSemanticBlocks"), "Stage 07 semantic block overrides must reload from and save to backend." ); +assert.ok( + appSource.includes("skippedSemanticBlockCount") && + appSource.includes("sourceBindings.length === 0") && + appSource.includes("без текстовой привязки"), + "Stage 07 must skip semantic block saves that have no runtime text source binding." +); +assert.ok( + appSource.includes("anchorTargetIds") && /return blockKey && targetIds\.length > 0/.test(appSource), + "Persisted semantic block hydration must require target-bound anchors or targetIds." +); assert.ok( rewriteDiffSource.includes("listSemanticBlocksForPages") && rewriteDiffSource.includes("semanticBlock: semanticBlock ? mapRewriteDiffSemanticBlock") && diff --git a/seo_mode/seo_mode/server/src/workspace/semanticBlocks.ts b/seo_mode/seo_mode/server/src/workspace/semanticBlocks.ts index 39d9ece..fc3c559 100644 --- a/seo_mode/seo_mode/server/src/workspace/semanticBlocks.ts +++ b/seo_mode/seo_mode/server/src/workspace/semanticBlocks.ts @@ -98,6 +98,10 @@ function normalizeTargetIds(value: string[] | undefined) { ).slice(0, 200); } +function hasSemanticBlockRuntimeBinding(block: { sourceBindings: JsonArray; targetIds: string[] }) { + return block.targetIds.length > 0 || block.sourceBindings.length > 0; +} + function mapSemanticBlockRow(row: SemanticBlockRow): SemanticBlockModel { return { anchors: normalizeArray(row.anchors), @@ -191,20 +195,22 @@ export async function saveSemanticBlocksForWorkspace(projectId: string, pageId: throw new SemanticBlockWorkspaceError("Нужны workspaceKey и scopeId для semantic blocks."); } - const normalizedBlocks = input.blocks.map((block) => ({ - anchors: normalizeArray(block.anchors), - blockKey: cleanText(block.blockKey, 260), - capturedEntities: normalizeArray(block.capturedEntities), - geometryCache: normalizeRecord(block.geometryCache), - identity: normalizeRecord(block.identity), - mediaContext: normalizeRecord(block.mediaContext), - rewriteState: normalizeRecord(block.rewriteState), - role: cleanText(block.role ?? "content", 80) || "content", - source: block.source ?? "manual", - sourceBindings: normalizeArray(block.sourceBindings), - targetIds: normalizeTargetIds(block.targetIds), - title: cleanText(block.title, 260) || "Semantic block" - })).filter((block) => block.blockKey); + const normalizedBlocks = input.blocks + .map((block) => ({ + anchors: normalizeArray(block.anchors), + blockKey: cleanText(block.blockKey, 260), + capturedEntities: normalizeArray(block.capturedEntities), + geometryCache: normalizeRecord(block.geometryCache), + identity: normalizeRecord(block.identity), + mediaContext: normalizeRecord(block.mediaContext), + rewriteState: normalizeRecord(block.rewriteState), + role: cleanText(block.role ?? "content", 80) || "content", + source: block.source ?? "manual", + sourceBindings: normalizeArray(block.sourceBindings), + targetIds: normalizeTargetIds(block.targetIds), + title: cleanText(block.title, 260) || "Semantic block" + })) + .filter((block) => block.blockKey && hasSemanticBlockRuntimeBinding(block)); const blockKeys = normalizedBlocks.map((block) => block.blockKey); const client = await pool.connect();