From 5be9002f6e954b05d3123675e2244728415d50f8 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 14 Jul 2026 16:54:37 +0300 Subject: [PATCH] Expose Dev Mode SEO review workflow --- seo_mode/seo_mode/app/src/App.tsx | 4297 +++++++++++++++++++++++--- seo_mode/seo_mode/app/src/api.ts | 1715 +++++++++- seo_mode/seo_mode/app/src/styles.css | 2266 +++++++++++++- seo_mode/seo_mode/app/vite.config.ts | 7 + 4 files changed, 7852 insertions(+), 433 deletions(-) diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index a58fa16..f74aca4 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -32,7 +32,7 @@ import { UserCircle, X } from "lucide-react"; -import type { CSSProperties, ChangeEvent, DragEvent, FormEvent, PointerEvent as ReactPointerEvent } from "react"; +import type { CSSProperties, ChangeEvent, DragEvent, FormEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react"; import { Fragment, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { NdcGlassSelect, type NdcGlassSelectOption } from "./NdcGlassSelect"; @@ -58,14 +58,23 @@ import { XAxis as BklitXAxis } from "@bklitui/charts/x-axis"; import { applyMaterializationAction, addAnchorReviewManualAnchor, + editAnchorReviewAnchor, buildPatchArtifact, approveKeywordMapDecisions, + approveSeoContextOpportunityMap, copyProject, + collectObservedLanguage, + collectMarketExpansion, + createSeoAnalyticsModelExecutor, createSeoAnalyticsModelSetupCommand, + deleteContextDevSnapshot, deleteKeywordContextSnapshot, deleteProject, - downloadSemanticAnalysisExport, + downloadProjectContextExport, fetchExternalServiceSettings, + fetchSeoAnalyticsModelExecutors, + fetchContextDevSnapshot, + fetchContextDevSnapshots, fetchKeywordContextSnapshot, fetchKeywordContextSnapshots, fetchLatestKeywordAnalysisWorkflow, @@ -75,12 +84,16 @@ import { fetchLatestKeywordMap, fetchLatestMaterialization, fetchLatestMarketEnrichment, + fetchPostWordstatEvidence, fetchLatestPatchArtifact, fetchLatestProjectOntology, fetchLatestRewriteDiff, fetchLatestRewritePlan, fetchLatestSemanticAnalysis, + fetchLatestSeoBusinessSynthesis, fetchLatestSeoContextReview, + fetchSeoContextOpportunityMap, + fetchSeoProjectContext, fetchLatestProjectScan, fetchLatestSeoStrategy, fetchLatestSerpInterpretation, @@ -95,9 +108,11 @@ import { probeSeoAnalyticsModel, probeYandexAiStudioCredential, renameProject, + renameContextDevSnapshot, renameKeywordContextSnapshot, resetPageWorkspace, runMarketEnrichment, + runPostWordstatBatch2, runProjectScan, runSemanticAnalysis, runPatchDryRun, @@ -107,12 +122,25 @@ import { runStrategySynthesis, runSeoContextReview, runSeoModelTask, + reviewSeoContextReview, saveAnchorReviewDecisions, + saveManualQueryCandidate, + saveManualQueryCandidateDecision, + saveQueryClusterDecision, + saveExpansionSurfaceDecision, + savePostWordstatCandidateDecision, + savePostWordstatClusterDecision, + saveSeoStrategyClusterDecision, + saveQueryProbeDecision, + saveOwnerStrategy, + saveSearchScope, + saveContextDevSnapshot, saveManualKeywordCleaning, savePageSemanticBlocks, saveContentFieldDraft, saveKeywordContextSnapshot, savePageWorkspace, + saveSeoContextOpportunitySelection, startKeywordAnalysisWorkflow, updateExternalServiceSettings, updateKeywordContextSnapshotCuration, @@ -122,6 +150,9 @@ import { type AnchorReviewContract, type AnchorReviewDecisionInput, type BrowserFolderFileInput, + type ContextDevSnapshotDetail, + type ContextDevSnapshotSummary, + type ContextDevSubstage, type ContentFieldDraft, type DemandCollectionProfile, type ExternalServiceSettings, @@ -147,10 +178,16 @@ import { type ProjectSummary, type ProjectOntologyApprovalStatus, type ProjectOntologyVersion, + type QueryDiscoveryContract, + type SeoOwnerStrategyDraft, type RewriteDiffContract, type RewritePlanContract, type SeoContextReviewRun, + type SeoBusinessSynthesisRun, + type SeoContextOpportunityMap, + type SeoProjectContextContract, type SeoAnalyticsModelProbe, + type SeoAnalyticsModelExecutor, type SeoAnalyticsModelSetupCommand, type SeoStrategyContract, type SeoModelProviderStatusContract, @@ -174,7 +211,7 @@ const pipeline = [ "Конфигурация проекта", "Рабочая зона страницы", "Контекст dev", - "Ключи и спрос", + "Поисковые направления", "Стратегия", "План правок", "Проверка качества", @@ -185,6 +222,31 @@ type WorkspaceMode = "auto" | "dev"; type ExternalService = ExternalServiceSettings["services"][number]; type ExternalServiceDraft = ExternalServiceUpdateInput; +const EXTERNAL_SERVICES_SESSION_CACHE_KEY = "nodedc-seo:external-services:v1"; + +function readCachedExternalServices(): ExternalServiceSettings | null { + try { + const cached = window.sessionStorage.getItem(EXTERNAL_SERVICES_SESSION_CACHE_KEY); + const parsed = cached ? JSON.parse(cached) as ExternalServiceSettings : null; + return parsed?.schemaVersion === "external-service-settings.v1" ? parsed : null; + } catch { + return null; + } +} + +function cacheExternalServices(settings: ExternalServiceSettings) { + try { + window.sessionStorage.setItem(EXTERNAL_SERVICES_SESSION_CACHE_KEY, JSON.stringify(settings)); + } catch { + // Cache is an optional startup optimization; the API remains authoritative. + } +} + +function isSeoSetupCommandFresh(command: SeoAnalyticsModelSetupCommand | null) { + const expiresAt = command?.setupCode.expiresAt; + return Boolean(expiresAt && new Date(expiresAt).getTime() > Date.now() + 5000); +} + function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } @@ -265,9 +327,9 @@ const stageMeta = [ description: "Технический паспорт анализа: evidence, gaps, hints и JSON-контекст для следующих этапов." }, { - title: "Ключи и спрос", - sectionTitle: "Анализ ключей", - description: "Выбираем смысловые якоря, собираем рыночную базу Wordstat и готовим карту ключей." + title: "Поисковые направления", + sectionTitle: "Язык рынка", + description: "Выбираем доказанные направления, затем собираем наблюдаемый язык рынка и только после этого — ключи." }, { title: "Стратегия и карта посадочных", @@ -613,6 +675,7 @@ type SeoMatrixDatum = { }; type AnchorReviewAnchorView = AnchorReviewContract["anchors"][number]; +type QueryCandidateView = QueryDiscoveryContract["candidates"][number]; type AnchorReviewFilter = "all" | "approved" | "disabled" | "pending" | "proposed"; type AnchorReviewDraftDecisionMap = Record; type KeywordCleaningItemView = KeywordCleaningContract["items"][number]; @@ -629,6 +692,19 @@ type PendingAnchorDelete = { anchorId: string; projectId: string; }; +type QueryCandidateDecisionStatus = "pending" | "kept" | "hidden" | "deleted" | "wordstat"; +type PendingQueryCandidateDelete = { + candidateId: string; + projectId: string; +}; + +function getSavedQueryCandidateDecisionStatus(candidate: QueryCandidateView): QueryCandidateDecisionStatus { + if (candidate.status === "approved_probe" || candidate.status === "ready_for_wordstat") return "wordstat"; + if (candidate.status === "approved_for_collection") return "kept"; + if (candidate.status === "hidden") return "hidden"; + if (candidate.status === "deleted") return "deleted"; + return "pending"; +} type PendingKeywordContextSnapshotDialog = | { mode: "save"; @@ -647,6 +723,24 @@ type PendingKeywordContextSnapshotDialog = snapshotId: string; value: string; }; +type PendingContextDevSnapshotDialog = + | { + mode: "save"; + projectId: string; + value: string; + } + | { + mode: "rename"; + projectId: string; + snapshotId: string; + value: string; + } + | { + mode: "delete"; + projectId: string; + snapshotId: string; + value: string; + }; type KeywordWorkflowStep = "analysis" | "anchors" | "curation" | "none"; type CollapsibleKeywordStage = Exclude; type StrategyTargetAction = "create" | "defer" | "keep" | "partial" | "reject" | "strengthen"; @@ -1561,7 +1655,7 @@ function getKeywordFunnelData(keywordMap: KeywordMapContract): SeoChartDatum[] { { detail: "подтверждённые решения", label: "Зафиксировано", - tone: keywordMap.persisted.approvedDecisionCount > 0 ? "good" : "neutral", + tone: keywordMap.review.state === "approved_current_revision" ? "good" : keywordMap.review.state === "stale" ? "warning" : "neutral", value: keywordMap.persisted.approvedDecisionCount } ]; @@ -2211,6 +2305,18 @@ function getSerpRecommendedRoleLabel(role: SerpInterpretationContract["phraseInt return labels[role]; } +function getStrategyClusterTargetKindLabel(kind: SeoStrategyContract["strategyClusters"]["clusters"][number]["targetKind"]) { + const labels: Record = { + adjacent_research: "исследование", + content_backlog: "контент", + existing_page: "существующая страница", + human_review: "нужна привязка", + new_page: "новая посадочная", + support_section: "поддерживающий раздел" + }; + return labels[kind]; +} + function getStrategyKeywordRoleLabel(role: StrategyKeywordDecisionRole) { const labels: Record = { article: "в статью", @@ -2446,17 +2552,30 @@ function getStrategyMostCommonValue(values: T[]) { function getStrategyKeywordRows( keywordMap: KeywordMapContract | null, - serpInterpretation: SerpInterpretationContract | null + serpInterpretation: SerpInterpretationContract | null, + seoStrategy: SeoStrategyContract | null = null ): StrategyKeywordDecisionRow[] { const serpByPhrase = new Map((serpInterpretation?.phraseInterpretations ?? []).map((phrase) => [phrase.phrase, phrase])); + const strategyDecisionByPhrase = new Map(); + + for (const cluster of seoStrategy?.strategyClusters.clusters ?? []) { + if (!cluster.decision || !cluster.targetPath || (cluster.targetKind !== "existing_page" && cluster.targetKind !== "new_page")) { + continue; + } + + for (const phrase of [cluster.canonicalPhrase, ...cluster.phrases]) { + strategyDecisionByPhrase.set(normalizeKeywordCurationPhrase(phrase), cluster); + } + } return (keywordMap?.items ?? []) .map((item) => { const serpPhrase = serpByPhrase.get(item.phrase); + const strategyCluster = strategyDecisionByPhrase.get(normalizeKeywordCurationPhrase(item.phrase)); const role = getStrategyKeywordDecisionRole(item, serpPhrase); const reason = formatStrategyDisplayText(serpPhrase?.risk ?? serpPhrase?.opportunity ?? item.reason); const usesDirectTarget = role !== "article" && role !== "defer" && role !== "secondary_candidate"; - const targetPath = usesDirectTarget ? (item.targetPath ?? null) : item.targetPath; + const targetPath = usesDirectTarget ? (item.targetPath ?? strategyCluster?.targetPath ?? null) : item.targetPath; const relatedLanding = item.relatedLanding ?? (!targetPath ? (serpPhrase?.targetPath ?? null) : null); return { @@ -3104,7 +3223,7 @@ function buildStrategyExportSnapshot(input: { serpInterpretation: SerpInterpretationContract | null; yandexEvidence: YandexEvidenceContract | null; }): StrategyExportSnapshot { - const keywordRows = getStrategyKeywordRows(input.keywordMap, input.serpInterpretation); + const keywordRows = getStrategyKeywordRows(input.keywordMap, input.serpInterpretation, input.seoStrategy); const primaryTargetPath = getStrategyPrimaryTargetPath(keywordRows); const rowsForLanding = primaryTargetPath ? keywordRows.filter((row) => row.targetPath === primaryTargetPath) : keywordRows; const decisionRows = rowsForLanding.filter((row) => { @@ -4553,6 +4672,830 @@ function KeywordStageToggleButton({ ); } +function ContextDevStageSection({ + children, + isActive, + isAvailable, + number, + onOpen, + projectId, + substage, + subtitle, + title +}: { + children: ReactNode; + isActive: boolean; + isAvailable: boolean; + number: string; + onOpen: () => void; + projectId: string; + substage: ContextDevSubstage; + subtitle: string; + title: string; +}) { + const [isExpanded, setIsExpanded] = useState(isActive); + + useEffect(() => { + if (isActive) { + setIsExpanded(true); + } + }, [isActive]); + + function toggleExpanded() { + setIsExpanded((current) => { + const next = !current; + + if (next) { + onOpen(); + } + + return next; + }); + } + + return ( +
+
+
+ {number} +
+ Этап {number} · {title} + {subtitle} +
+
+
+ +
+
+ {isExpanded ?
{children}
: null} +
+ ); +} + +function SeoQueryDiscoveryWorkspace({ + discovery, + draftDecisions, + isCollectingObservedLanguage, + isSavingDraft, + isCollectingMarketExpansion, + isSavingPositioning, + savingClusterId, + savingExpansionSurfaceId, + isSavingScope, + onCandidateDecision, + onCollectObservedLanguage, + onClusterDecision, + onExpansionSurfaceDecision, + onCollectMarketExpansion, + onDeleteRequest, + onManualAdd, + onRunDecomposition, + onSaveOwnerStrategy, + onSaveDraft, + onSaveSearchScope +}: { + discovery: QueryDiscoveryContract; + draftDecisions: Record; + isCollectingObservedLanguage: boolean; + isSavingDraft: boolean; + isCollectingMarketExpansion: boolean; + isSavingPositioning: boolean; + savingClusterId: string | null; + savingExpansionSurfaceId: string | null; + isSavingScope: boolean; + onCandidateDecision: ( + candidate: QueryDiscoveryContract["candidates"][number], + decisionStatus: "pending" | "kept" | "hidden" | "deleted" | "wordstat" + ) => void; + onCollectObservedLanguage: () => void; + onClusterDecision: ( + cluster: QueryDiscoveryContract["observedLanguageReview"]["clusters"][number], + decisionStatus: "pending" | "approved" | "informational" | "rerouted" | "hidden" | "raw_only" + ) => void; + onExpansionSurfaceDecision: ( + surface: QueryDiscoveryContract["marketExpansion"]["surfaces"][number], + decisionStatus: "suggested" | "selected_for_exploration" | "rejected" + ) => void; + onCollectMarketExpansion: () => void; + onDeleteRequest: (candidate: QueryDiscoveryContract["candidates"][number]) => void; + onManualAdd: () => void; + onRunDecomposition: () => void; + onSaveOwnerStrategy: (input: SeoOwnerStrategyDraft) => void; + onSaveDraft: () => void; + onSaveSearchScope: (selectedVectorIds: string[]) => void; +}) { + const ready = discovery.state === "ready"; + const scope = discovery.searchScope; + const scopeSignature = scope?.selectedVectorIds.join("\u0000") ?? ""; + const [isCollapsed, setIsCollapsed] = useState(false); + const [isScopeExpanded, setIsScopeExpanded] = useState(!ready); + const [isPositioningExpanded, setIsPositioningExpanded] = useState(false); + const [isExpansionExpanded, setIsExpansionExpanded] = useState(false); + const [selectedScopeIds, setSelectedScopeIds] = useState(scope?.selectedVectorIds ?? []); + const [strategicStatementDraft, setStrategicStatementDraft] = useState(discovery.positioningThesis.ownerStrategy?.strategicStatement ?? ""); + const [priorityThemesDraft, setPriorityThemesDraft] = useState((discovery.positioningThesis.ownerStrategy?.priorityThemes ?? []).join("\n")); + const [categoryExclusionsDraft, setCategoryExclusionsDraft] = useState((discovery.positioningThesis.ownerStrategy?.categoryExclusions ?? []).join("\n")); + const [referenceAnaloguesDraft, setReferenceAnaloguesDraft] = useState( + (discovery.positioningThesis.ownerStrategy?.referenceAnalogues ?? []) + .map((analogue) => `${analogue.label} | ${analogue.transferableTraits.join(", ")} | ${analogue.caveat}`) + .join("\n") + ); + const [positioningNotesDraft, setPositioningNotesDraft] = useState(discovery.positioningThesis.ownerStrategy?.notes ?? ""); + const [expansionPolicyDraft, setExpansionPolicyDraft] = useState( + discovery.positioningThesis.ownerStrategy?.expansionPolicy ?? "balanced" + ); + const [filter, setFilter] = useState<"all" | "pending" | "kept" | "wordstat" | "hidden">("all"); + const draftCount = discovery.candidates.filter((candidate) => candidate.generationMethod === "pattern_draft").length; + const draftChangeCount = Object.keys(draftDecisions).length; + const hasBlockingQualityIssue = discovery.diagnostics.candidateQuality.warnings.some((warning) => warning.severity === "error"); + const rejectedFacetCount = discovery.topicDecomposition.rejectedFacets.length; + const getDecisionStatus = (candidate: QueryCandidateView) => draftDecisions[candidate.id] ?? getSavedQueryCandidateDecisionStatus(candidate); + const qualityHiddenCount = discovery.candidates.filter((candidate) => candidate.quality?.status === "hidden_by_quality_gate").length; + const clusterDecisionById = new Map(discovery.observedLanguageReview.decisions.map((decision) => [decision.clusterId, decision])); + const visibleCandidatePool = discovery.candidates + .filter((candidate) => candidate.generationMethod !== "observed_query") + .filter((candidate) => candidate.quality?.status !== "hidden_by_quality_gate") + .filter((candidate) => getDecisionStatus(candidate) !== "deleted"); + const filterCounts = { + all: visibleCandidatePool.length, + pending: visibleCandidatePool.filter((candidate) => getDecisionStatus(candidate) === "pending").length, + kept: visibleCandidatePool.filter((candidate) => getDecisionStatus(candidate) === "kept").length, + wordstat: visibleCandidatePool.filter((candidate) => getDecisionStatus(candidate) === "wordstat").length, + hidden: visibleCandidatePool.filter((candidate) => getDecisionStatus(candidate) === "hidden").length + }; + const keptCount = discovery.candidates.filter((candidate) => { + const status = getDecisionStatus(candidate); + return status === "kept"; + }).length; + const visibleCandidates = visibleCandidatePool + .filter((candidate) => { + const status = getDecisionStatus(candidate); + if (filter === "pending") return status === "pending"; + if (filter === "kept") return status === "kept"; + if (filter === "wordstat") return status === "wordstat"; + if (filter === "hidden") return status === "hidden"; + return true; + }) + .slice(0, 180); + + useEffect(() => { + setSelectedScopeIds(scope?.selectedVectorIds ?? []); + }, [scopeSignature]); + + useEffect(() => { + const ownerStrategy = discovery.positioningThesis.ownerStrategy; + setStrategicStatementDraft(ownerStrategy?.strategicStatement ?? ""); + setPriorityThemesDraft((ownerStrategy?.priorityThemes ?? []).join("\n")); + setCategoryExclusionsDraft((ownerStrategy?.categoryExclusions ?? []).join("\n")); + setReferenceAnaloguesDraft( + (ownerStrategy?.referenceAnalogues ?? []) + .map((analogue) => `${analogue.label} | ${analogue.transferableTraits.join(", ")} | ${analogue.caveat}`) + .join("\n") + ); + setPositioningNotesDraft(ownerStrategy?.notes ?? ""); + setExpansionPolicyDraft(ownerStrategy?.expansionPolicy ?? "balanced"); + }, [discovery.positioningThesis.ownerStrategy?.updatedAt]); + + useEffect(() => { + if (!ready) { + setIsScopeExpanded(true); + } + }, [ready]); + + function getScopeModeLabel(mode: NonNullable["mode"]) { + if (mode === "balanced_auto") return "сбалансированное ядро"; + if (mode === "human_selected") return "выбрано вручную"; + if (mode === "portfolio_review") return "на разборе portfolio"; + return "legacy-ядро"; + } + + function getScopeVectorLabel(source: NonNullable["vectors"][number]["source"]) { + if (source === "core_facet") return "фасет ядра"; + if (source === "current_application") return "текущее применение — ручной выбор"; + if (source === "expansion_hypothesis") return "гипотеза расширения — выключена"; + if (source === "core_pillar") return "фактический pillar"; + if (source === "opportunity") return "portfolio — ручной выбор"; + return "legacy core offer"; + } + + function getCandidateLabel(candidate: QueryCandidateView, decisionStatus: QueryCandidateDecisionStatus) { + if (candidate.generationMethod === "observed_query") return "наблюдённый язык рынка"; + if (decisionStatus === "wordstat") return "в очереди Wordstat"; + if (decisionStatus === "kept") return "оставлено для сбора"; + if (decisionStatus === "hidden") return "временно скрыто"; + if (candidate.generationMethod === "manual") return "добавлено вручную"; + return candidate.generationMethod === "pattern_draft" ? "черновик поисковой поверхности" : "черновик базовой поисковой поверхности"; + } + + function getEvidenceLabel(candidate: QueryDiscoveryContract["candidates"][number]) { + if (candidate.generationMethod === "observed_query") return "market evidence"; + if (candidate.generationMethod === "manual") return "manual input"; + return candidate.generationMethod === "base_entity" ? "core evidence" : "pattern evidence"; + } + + function isActive(decisionStatus: QueryCandidateDecisionStatus, action: "pending" | "kept" | "hidden" | "wordstat") { + return decisionStatus === action; + } + + function getClusterDecisionStatus(clusterId: string) { + return clusterDecisionById.get(clusterId)?.decisionStatus ?? "pending"; + } + + function getObservedIntentLabel(intent: QueryDiscoveryContract["observedLanguageReview"]["clusters"][number]["intent"]) { + if (intent === "commercial") return "коммерческий"; + if (intent === "transactional") return "транзакционный"; + if (intent === "informational") return "информационный"; + if (intent === "integration") return "интеграционный"; + if (intent === "comparison") return "сравнение"; + return "географический"; + } + + function getExpansionRelationshipLabel(value: QueryDiscoveryContract["marketExpansion"]["surfaces"][number]["relationshipToCore"]) { + if (value === "direct_core") return "core"; + if (value === "near_core") return "near-core"; + if (value === "application") return "application"; + if (value === "adjacent") return "adjacent"; + return "speculative"; + } + + function splitPositioningLines(value: string) { + return [...new Set(value.split(/[\n,;]/).map((item) => item.trim()).filter(Boolean))]; + } + + function savePositioningDraft() { + const referenceAnalogues = referenceAnaloguesDraft + .split("\n") + .map((line) => { + const [label = "", traits = "", caveat = ""] = line.split("|").map((item) => item.trim()); + return { caveat, label, transferableTraits: splitPositioningLines(traits) }; + }) + .filter((analogue) => analogue.label || analogue.transferableTraits.length > 0); + + onSaveOwnerStrategy({ + categoryExclusions: splitPositioningLines(categoryExclusionsDraft), + expansionPolicy: expansionPolicyDraft, + notes: positioningNotesDraft.trim() || null, + priorityThemes: splitPositioningLines(priorityThemesDraft), + referenceAnalogues, + strategicStatement: strategicStatementDraft.trim() + }); + } + + return ( +
+
+
+ +
+ Шаг 5.1 · Поисковые направления + Сначала выбираем evidence-backed фасеты продукта. Черновики ниже не являются ключами или спросом. + + Черновиков: {filterCounts.all} · на разборе: {filterCounts.pending} · оставлено: {keptCount} · очередь Wordstat: {filterCounts.wordstat} · скрыто: {filterCounts.hidden} + + + Направлений: {scope?.selectedVectorIds.length ?? 0} · topic cards: {discovery.readiness.topicCount} · market seeds: {discovery.readiness.marketSeedCount} · observed: {discovery.readiness.observedQueryCount} · review: {discovery.readiness.humanReviewCount} · clusters: {discovery.readiness.canonicalClusterCount}{qualityHiddenCount > 0 ? ` · quality gate: ${qualityHiddenCount}` : ""} + +
+
+
+ setIsCollapsed((current) => !current)} /> + + {!ready ? ( + + ) : null} + + +
+
+ {!isCollapsed ? ( +
+
+ {ready ? "Query drafts · до observed language" : "Нужен свежий semantic portfolio"} + {ready ? "Отберите полезные поисковые поверхности для сбора языка. Это ещё не ключи и не спрос." : discovery.topicDecomposition.summary} + + {ready + ? "Wordstat здесь не запускается. Следующий источник языка — suggest, SERP или first-party данные." + : discovery.nextActions[0] ?? "Ожидание следующего действия."} + +
+
+ + {isPositioningExpanded ? ( +
+
+ Два разных типа правды + + Факты ниже извлечены из сайта. Стратегия владельца влияет на приоритет гипотез, но не превращается в доказательство и не подменяет Wordstat/SERP. + +
+
+
+ Фактическая категория + {discovery.positioningThesis.factualIdentity.productCategory?.text ?? "Не подтверждена"} + {discovery.positioningThesis.factualIdentity.coreOffer?.text ?? "Demand Research Brief ещё не готов."} +
+
+ Отличающий смысл + {discovery.positioningThesis.factualIdentity.centralThesis?.text ?? "Не подтверждён"} + {discovery.positioningThesis.categoryArchitecture.differentiatedThemes.slice(0, 4).map((item) => item.text).join(" · ") || "Нет evidence-backed themes."} +
+
+
+ site evidence {discovery.positioningThesis.readiness.provenanceCounts.site_evidence} + owner strategy {discovery.positioningThesis.readiness.provenanceCounts.owner_strategy} + model inference {discovery.positioningThesis.readiness.provenanceCounts.model_inference} + market evidence {discovery.positioningThesis.readiness.provenanceCounts.market_evidence} +
+
+