From bec2f57ae18a4b4028bf931b18a7948f511e0279 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 14 Jul 2026 16:54:08 +0300 Subject: [PATCH] Build evidence-gated SEO discovery pipeline --- seo_mode/seo_mode/package.json | 4 + .../server/migrations/011_query_discovery.sql | 40 + .../012_query_candidate_decisions.sql | 29 + .../server/migrations/013_semantic_facets.sql | 20 + .../migrations/014_pre_wordstat_pipeline.sql | 92 + .../migrations/015_market_expansion.sql | 55 + .../016_post_wordstat_product_fit.sql | 13 + .../017_post_wordstat_cluster_decisions.sql | 14 + seo_mode/seo_mode/server/package.json | 5 + .../server/src/analysis/semanticAnalysis.ts | 131 +- .../src/business/seoBusinessSynthesis.ts | 60 +- .../src/commercial/seoCommercialDemand.ts | 193 +- .../contextReview/contextOpportunityMap.ts | 618 +++++ .../src/contextReview/contextSnapshots.ts | 230 ++ .../coreSemanticPortfolioRouter.ts | 707 ++++++ .../src/contextReview/demandFamilyMapper.ts | 45 + .../src/contextReview/demandResearchBrief.ts | 702 ++++++ .../src/contextReview/demandScopeGuard.ts | 161 ++ .../server/src/contextReview/ownerStrategy.ts | 104 + .../src/contextReview/positioningThesis.ts | 557 +++++ .../src/contextReview/seoContextReview.ts | 973 +++++++- seo_mode/seo_mode/server/src/db/client.ts | 2 + .../server/src/evidence/serpInterpretation.ts | 136 +- .../src/evidence/serpInterpretationTask.ts | 31 +- .../server/src/evidence/yandexEvidence.ts | 99 +- .../server/src/keywords/anchorReview.ts | 160 ++ .../src/keywords/keywordAnalysisWorkflow.ts | 116 +- .../server/src/keywords/keywordCleaning.ts | 117 +- .../server/src/keywords/keywordMap.ts | 346 ++- .../server/src/market/marketEnrichment.ts | 594 ++++- .../src/market/marketFrontierDiscovery.ts | 359 +-- .../src/modelContext/seoModelContext.ts | 104 +- .../src/modelProvider/aiWorkspaceBridge.ts | 167 +- .../modelProvider/modelProviderRegistry.ts | 3 + .../src/modelProvider/seoModelTaskRuns.ts | 111 +- .../src/normalization/seoNormalization.ts | 486 +++- .../server/src/ontology/projectOntology.ts | 13 +- .../src/opportunity/seoOpportunityCritic.ts | 536 +++++ .../opportunity/seoOpportunityDiscovery.ts | 848 +++++++ .../seo_mode/server/src/preview/routes.ts | 53 +- .../seo_mode/server/src/projects/routes.ts | 1104 ++++++++- .../applicabilityMatrixBuilder.ts | 58 + .../queryDiscovery/candidateQualityGate.ts | 167 ++ .../queryDiscovery/canonicalQueryCluster.ts | 95 + .../src/queryDiscovery/demandCoreReadModel.ts | 217 ++ .../domainAgnosticityClassifier.ts | 68 + .../expansionObservedLanguageCollector.ts | 111 + .../expansionObservedRelevanceGate.ts | 196 ++ .../queryDiscovery/expansionSeedGenerator.ts | 128 ++ .../queryDiscovery/expansionSurfaceBuilder.ts | 270 +++ .../marketExpansionRepository.ts | 109 + .../src/queryDiscovery/marketSeedGenerator.ts | 293 +++ .../observedLanguageCollector.ts | 196 ++ .../queryDiscovery/observedQueryNormalizer.ts | 183 ++ .../queryDiscovery/observedRelevanceGate.ts | 273 +++ .../postWordstatBatch2Planner.ts | 251 ++ .../postWordstatExpansionPlanner.ts | 138 ++ .../postWordstatProductFitGate.ts | 549 +++++ .../queryDiscovery/postWordstatRepository.ts | 163 ++ .../queryDiscovery/preWordstatRepository.ts | 148 ++ .../src/queryDiscovery/queryDiscovery.ts | 2021 +++++++++++++++++ .../recursiveSuggestExpansion.ts | 57 + .../queryDiscovery/universalPatternLibrary.ts | 104 + .../queryDiscovery/wordstatQueueBuilder.ts | 83 + .../src/scripts/checkContextBusinessValue.ts | 531 +++++ .../src/scripts/checkOntologyUniversality.ts | 40 + .../scripts/checkPositioningIntelligence.ts | 226 ++ .../scripts/checkPostWordstatArchitecture.ts | 145 ++ .../src/scripts/checkPreWordstatPipeline.ts | 280 +++ .../checkSemanticPortfolioUniversality.ts | 105 + .../src/settings/externalServiceSettings.ts | 49 +- .../seo_mode/server/src/settings/routes.ts | 28 + .../src/settings/seoAnalyticsModelBridge.ts | 80 +- .../server/src/skills/seoSkillRegistry.ts | 310 ++- .../server/src/strategy/seoStrategy.ts | 355 ++- .../src/strategy/strategyClusterDecisions.ts | 107 + .../src/strategy/strategyQualityReview.ts | 68 +- .../src/strategy/strategyQualityReviewTask.ts | 24 +- .../server/src/strategy/strategySynthesis.ts | 186 +- .../src/strategy/strategySynthesisTask.ts | 59 +- 80 files changed, 18009 insertions(+), 600 deletions(-) create mode 100644 seo_mode/seo_mode/server/migrations/011_query_discovery.sql create mode 100644 seo_mode/seo_mode/server/migrations/012_query_candidate_decisions.sql create mode 100644 seo_mode/seo_mode/server/migrations/013_semantic_facets.sql create mode 100644 seo_mode/seo_mode/server/migrations/014_pre_wordstat_pipeline.sql create mode 100644 seo_mode/seo_mode/server/migrations/015_market_expansion.sql create mode 100644 seo_mode/seo_mode/server/migrations/016_post_wordstat_product_fit.sql create mode 100644 seo_mode/seo_mode/server/migrations/017_post_wordstat_cluster_decisions.sql create mode 100644 seo_mode/seo_mode/server/src/contextReview/contextOpportunityMap.ts create mode 100644 seo_mode/seo_mode/server/src/contextReview/contextSnapshots.ts create mode 100644 seo_mode/seo_mode/server/src/contextReview/coreSemanticPortfolioRouter.ts create mode 100644 seo_mode/seo_mode/server/src/contextReview/demandFamilyMapper.ts create mode 100644 seo_mode/seo_mode/server/src/contextReview/demandResearchBrief.ts create mode 100644 seo_mode/seo_mode/server/src/contextReview/demandScopeGuard.ts create mode 100644 seo_mode/seo_mode/server/src/contextReview/ownerStrategy.ts create mode 100644 seo_mode/seo_mode/server/src/contextReview/positioningThesis.ts create mode 100644 seo_mode/seo_mode/server/src/opportunity/seoOpportunityCritic.ts create mode 100644 seo_mode/seo_mode/server/src/opportunity/seoOpportunityDiscovery.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/applicabilityMatrixBuilder.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/candidateQualityGate.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/canonicalQueryCluster.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/demandCoreReadModel.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/domainAgnosticityClassifier.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/expansionObservedLanguageCollector.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/expansionObservedRelevanceGate.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/expansionSeedGenerator.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/expansionSurfaceBuilder.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/marketExpansionRepository.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/marketSeedGenerator.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/observedLanguageCollector.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/observedQueryNormalizer.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/observedRelevanceGate.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/postWordstatBatch2Planner.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/postWordstatExpansionPlanner.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/postWordstatProductFitGate.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/postWordstatRepository.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/preWordstatRepository.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/queryDiscovery.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/recursiveSuggestExpansion.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/universalPatternLibrary.ts create mode 100644 seo_mode/seo_mode/server/src/queryDiscovery/wordstatQueueBuilder.ts create mode 100644 seo_mode/seo_mode/server/src/scripts/checkContextBusinessValue.ts create mode 100644 seo_mode/seo_mode/server/src/scripts/checkPositioningIntelligence.ts create mode 100644 seo_mode/seo_mode/server/src/scripts/checkPostWordstatArchitecture.ts create mode 100644 seo_mode/seo_mode/server/src/scripts/checkPreWordstatPipeline.ts create mode 100644 seo_mode/seo_mode/server/src/scripts/checkSemanticPortfolioUniversality.ts create mode 100644 seo_mode/seo_mode/server/src/strategy/strategyClusterDecisions.ts diff --git a/seo_mode/seo_mode/package.json b/seo_mode/seo_mode/package.json index b5ff07a..0d10aa9 100644 --- a/seo_mode/seo_mode/package.json +++ b/seo_mode/seo_mode/package.json @@ -17,6 +17,10 @@ "check:semantic-blocks": "npm run check:semantic-blocks -w server", "check:source-models": "npm run check:source-models -w server", "check:ontology-universality": "npm run check:ontology-universality -w server", + "check:context-business-value": "npm run check:context-business-value -w server", + "check:pre-wordstat": "npm run check:pre-wordstat -w server", + "check:post-wordstat": "npm run check:post-wordstat -w server", + "check:positioning-intelligence": "npm run check:positioning-intelligence -w server", "typecheck": "npm run typecheck -w server && npm run typecheck -w app", "db:migrate": "npm run db:migrate -w server" }, diff --git a/seo_mode/seo_mode/server/migrations/011_query_discovery.sql b/seo_mode/seo_mode/server/migrations/011_query_discovery.sql new file mode 100644 index 0000000..9e77669 --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/011_query_discovery.sql @@ -0,0 +1,40 @@ +create table query_observations ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete set null, + topic_id text not null, + probe_id text, + pattern_id text, + query text not null, + normalized_query text not null, + source text not null, + source_seed text, + geo text, + language text, + raw_artifact_key text, + metadata jsonb not null default '{}'::jsonb, + observed_at timestamptz not null default now(), + created_at timestamptz not null default now(), + unique(project_id, topic_id, normalized_query, source) +); + +create index query_observations_project_semantic_idx + on query_observations(project_id, semantic_run_id, observed_at desc); + +create index query_observations_project_topic_idx + on query_observations(project_id, topic_id, observed_at desc); + +create table query_probe_decisions ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete set null, + topic_id text not null, + probe_id text not null, + approved boolean not null, + reason text not null default '', + decided_at timestamptz not null default now(), + unique(project_id, topic_id, probe_id) +); + +create index query_probe_decisions_project_semantic_idx + on query_probe_decisions(project_id, semantic_run_id, decided_at desc); diff --git a/seo_mode/seo_mode/server/migrations/012_query_candidate_decisions.sql b/seo_mode/seo_mode/server/migrations/012_query_candidate_decisions.sql new file mode 100644 index 0000000..b5a4d3c --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/012_query_candidate_decisions.sql @@ -0,0 +1,29 @@ +alter table query_probe_decisions + add column if not exists decision_status text; + +update query_probe_decisions +set decision_status = case when approved then 'wordstat' else 'deleted' end +where decision_status is null; + +alter table query_probe_decisions + alter column decision_status set default 'pending'; + +alter table query_probe_decisions + alter column decision_status set not null; + +create table if not exists query_manual_candidates ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete set null, + topic_id text not null, + query text not null, + normalized_query text not null, + decision_status text not null default 'pending', + reason text not null default '', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique(project_id, semantic_run_id, topic_id, normalized_query) +); + +create index if not exists query_manual_candidates_project_semantic_idx + on query_manual_candidates(project_id, semantic_run_id, updated_at desc); diff --git a/seo_mode/seo_mode/server/migrations/013_semantic_facets.sql b/seo_mode/seo_mode/server/migrations/013_semantic_facets.sql new file mode 100644 index 0000000..a39963f --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/013_semantic_facets.sql @@ -0,0 +1,20 @@ +create table if not exists seo_semantic_facets ( + project_id uuid not null references projects(id) on delete cascade, + context_hash text not null, + facet_id text not null, + relationship_to_core text not null, + facet_kind text not null, + status text not null, + default_active boolean not null default false, + selected boolean not null default false, + facet jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (project_id, context_hash, facet_id) +); + +create index if not exists seo_semantic_facets_project_context_idx + on seo_semantic_facets(project_id, context_hash, selected, updated_at desc); + +create index if not exists seo_semantic_facets_project_kind_idx + on seo_semantic_facets(project_id, facet_kind, relationship_to_core); diff --git a/seo_mode/seo_mode/server/migrations/014_pre_wordstat_pipeline.sql b/seo_mode/seo_mode/server/migrations/014_pre_wordstat_pipeline.sql new file mode 100644 index 0000000..7879556 --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/014_pre_wordstat_pipeline.sql @@ -0,0 +1,92 @@ +create table if not exists query_market_seeds ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete cascade, + seed_id text not null, + facet_id text not null, + topic_id text, + normalized_seed text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, seed_id) +); + +create index if not exists query_market_seeds_project_facet_idx + on query_market_seeds(project_id, semantic_run_id, facet_id); + +create table if not exists query_normalized_observations ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete cascade, + observation_id uuid not null references query_observations(id) on delete cascade, + facet_id text, + topic_id text, + normalized_query text not null, + contamination text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, observation_id) +); + +create index if not exists query_normalized_observations_project_facet_idx + on query_normalized_observations(project_id, semantic_run_id, facet_id, contamination); + +create table if not exists query_observed_gate_results ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete cascade, + observation_id uuid not null references query_observations(id) on delete cascade, + action text not null, + intent text not null, + score numeric, + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, observation_id) +); + +create table if not exists query_canonical_clusters ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete cascade, + cluster_id text not null, + facet_id text not null, + topic_id text, + normalized_canonical text not null, + intent text not null, + stage text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, cluster_id) +); + +create index if not exists query_canonical_clusters_project_stage_idx + on query_canonical_clusters(project_id, semantic_run_id, stage, intent); + +create table if not exists query_cluster_decisions ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete cascade, + cluster_id text not null, + decision_status text not null default 'pending', + target_facet_id text, + reason text not null default '', + decided_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, cluster_id) +); + +create table if not exists query_wordstat_queue_items ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete cascade, + queue_item_id text not null, + cluster_id text not null, + facet_id text not null, + topic_id text, + normalized_phrase text not null, + status text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, queue_item_id) +); + +create index if not exists query_wordstat_queue_items_project_status_idx + on query_wordstat_queue_items(project_id, semantic_run_id, status, facet_id); diff --git a/seo_mode/seo_mode/server/migrations/015_market_expansion.sql b/seo_mode/seo_mode/server/migrations/015_market_expansion.sql new file mode 100644 index 0000000..f8d4052 --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/015_market_expansion.sql @@ -0,0 +1,55 @@ +create table if not exists query_expansion_surfaces ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete cascade, + surface_id text not null, + surface_kind text not null, + relationship_to_core text not null, + status text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, surface_id) +); + +create table if not exists query_expansion_surface_decisions ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete cascade, + surface_id text not null, + decision_status text not null default 'suggested', + reason text not null default '', + decided_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, surface_id) +); + +create table if not exists query_expansion_seeds ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete cascade, + seed_id text not null, + surface_id text not null, + normalized_phrase text not null, + payload jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, seed_id) +); + +create table if not exists query_expansion_observations ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid references runs(id) on delete cascade, + surface_id text not null, + seed_id text not null, + query text not null, + normalized_query text not null, + source text not null, + source_probe text not null, + depth integer not null default 1, + metadata jsonb not null default '{}'::jsonb, + observed_at timestamptz not null default now(), + unique(project_id, semantic_run_id, surface_id, normalized_query, source) +); + +create index if not exists query_expansion_surfaces_project_kind_idx + on query_expansion_surfaces(project_id, semantic_run_id, surface_kind, status); +create index if not exists query_expansion_observations_project_surface_idx + on query_expansion_observations(project_id, semantic_run_id, surface_id, observed_at desc); diff --git a/seo_mode/seo_mode/server/migrations/016_post_wordstat_product_fit.sql b/seo_mode/seo_mode/server/migrations/016_post_wordstat_product_fit.sql new file mode 100644 index 0000000..09c6b6d --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/016_post_wordstat_product_fit.sql @@ -0,0 +1,13 @@ +create table if not exists post_wordstat_candidate_decisions ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid not null references runs(id) on delete cascade, + candidate_id text not null, + normalized_phrase text not null, + decision_status text not null default 'pending', + reason text not null default '', + decided_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, candidate_id) +); + +create index if not exists post_wordstat_candidate_decisions_project_status_idx + on post_wordstat_candidate_decisions(project_id, semantic_run_id, decision_status, decided_at desc); diff --git a/seo_mode/seo_mode/server/migrations/017_post_wordstat_cluster_decisions.sql b/seo_mode/seo_mode/server/migrations/017_post_wordstat_cluster_decisions.sql new file mode 100644 index 0000000..f00e46b --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/017_post_wordstat_cluster_decisions.sql @@ -0,0 +1,14 @@ +create table if not exists post_wordstat_cluster_decisions ( + project_id uuid not null references projects(id) on delete cascade, + semantic_run_id uuid not null references runs(id) on delete cascade, + cluster_id text not null, + canonical_phrase text not null, + decision_status text not null default 'pending', + apply_to_variants boolean not null default true, + reason text not null default '', + decided_at timestamptz not null default now(), + primary key (project_id, semantic_run_id, cluster_id) +); + +create index if not exists post_wordstat_cluster_decisions_project_status_idx + on post_wordstat_cluster_decisions(project_id, semantic_run_id, decision_status, decided_at desc); diff --git a/seo_mode/seo_mode/server/package.json b/seo_mode/seo_mode/server/package.json index fe00767..3b00257 100644 --- a/seo_mode/seo_mode/server/package.json +++ b/seo_mode/seo_mode/server/package.json @@ -13,6 +13,11 @@ "check:semantic-blocks": "tsx src/scripts/checkSemanticBlockUniversality.ts", "check:source-models": "tsx src/scripts/checkSourceModelUniversality.ts", "check:ontology-universality": "tsx src/scripts/checkOntologyUniversality.ts", + "check:semantic-portfolio": "tsx src/scripts/checkSemanticPortfolioUniversality.ts", + "check:context-business-value": "tsx src/scripts/checkContextBusinessValue.ts", + "check:pre-wordstat": "tsx src/scripts/checkPreWordstatPipeline.ts", + "check:post-wordstat": "tsx src/scripts/checkPostWordstatArchitecture.ts", + "check:positioning-intelligence": "tsx src/scripts/checkPositioningIntelligence.ts", "db:migrate": "tsx src/scripts/migrate.ts" }, "dependencies": { diff --git a/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts b/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts index 9f686b2..49f26dc 100644 --- a/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts +++ b/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts @@ -235,6 +235,13 @@ export type SemanticAnalysisOutput = { sectionCount: number; intentClusterCount: number; evidenceCount: number; + sections: Array<{ + id: string; + pageId: string; + title: string; + meaning: string; + evidenceIds: string[]; + }>; } | null; }; qualitySignals: AnalysisSignal[]; @@ -676,13 +683,20 @@ function decodeHtmlEntities(value: string) { function visibleTextFromHtml(html: string) { const body = /]*>([\s\S]*?)<\/body>/i.exec(html)?.[1] ?? html; + const mainFragments = Array.from(body.matchAll(/]*>([\s\S]*?)<\/main>/gi)) + .map((match) => match[1]?.trim() ?? "") + .filter(Boolean); + const semanticRoot = mainFragments.join("\n").replace(/<[^>]+>/g, " ").trim().length >= 160 + ? mainFragments.join("\n") + : body; return decodeHtmlEntities( - body + semanticRoot .replace(//gi, " ") .replace(//gi, " ") .replace(//gi, " ") .replace(//gi, " ") + .replace(/<(nav|aside|footer)\b[\s\S]*?<\/\1>/gi, " ") .replace(/<(br|hr)\b[^>]*>/gi, "\n") .replace(/<\/(article|aside|blockquote|dd|details|dialog|div|dl|dt|figcaption|figure|footer|form|h[1-6]|header|li|main|nav|ol|p|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)>/gi, "\n") .replace(/<[^>]+>/g, " ") @@ -694,6 +708,56 @@ function visibleTextFromHtml(html: string) { .trim(); } +function removeSharedPageBoilerplate(documents: ContentDocument[]) { + const selectedIndexableDocuments = documents.filter( + (document) => document.kind === "page" && document.selected && document.scope === "indexable_page" + ); + + if (selectedIndexableDocuments.length < 3) { + return documents; + } + + const lineFrequency = new Map(); + + for (const document of selectedIndexableDocuments) { + const uniqueLines = new Set( + document.text + .split(/\n+/) + .map((line) => normalizeSearchText(line)) + .filter((line) => line.length >= 2 && line.length <= 240) + ); + + for (const line of uniqueLines) { + lineFrequency.set(line, (lineFrequency.get(line) ?? 0) + 1); + } + } + + const repeatedThreshold = Math.max(3, Math.ceil(selectedIndexableDocuments.length * 0.35)); + const sharedLines = new Set( + Array.from(lineFrequency.entries()) + .filter(([, frequency]) => frequency >= repeatedThreshold) + .map(([line]) => line) + ); + + return documents.map((document) => { + if (document.kind !== "page" || !document.selected || document.scope !== "indexable_page") { + return document; + } + + const text = document.text + .split(/\n+/) + .filter((line) => !sharedLines.has(normalizeSearchText(line))) + .join("\n") + .trim(); + + return { + ...document, + text, + wordCount: countWords(text) + }; + }); +} + function splitTextCorpusChunks(text: string, maxChars = TEXT_CORPUS_CHUNK_CHARS) { const chunks: Array<{ charEnd: number; charStart: number; text: string }> = []; let start = 0; @@ -882,9 +946,12 @@ function getClusterIntentMatches(cluster: SemanticClusterDefinition, documents: function getEvidenceLevel( score: number, landingEvidenceUnits: number, - matchedIntentGroups: number + matchedIntentGroups: number, + expectedIntentGroups: number ): SemanticAnalysisOutput["clusters"][number]["evidence"]["level"] { - if (score >= 78 && landingEvidenceUnits > 0 && matchedIntentGroups >= 2) { + const requiredIntentGroups = Math.max(1, Math.min(2, expectedIntentGroups)); + + if (score >= 88 && landingEvidenceUnits > 0 && matchedIntentGroups >= requiredIntentGroups) { return "strong"; } @@ -925,7 +992,11 @@ function buildEvidenceSnippet(document: ContentDocument, terms: string[]) { const termIndex = firstTerm ? normalizedFlatText.indexOf(normalizeSearchText(firstTerm)) : -1; const snippetStart = termIndex >= 0 ? Math.max(0, termIndex - 110) : 0; const snippetEnd = termIndex >= 0 && firstTerm ? Math.min(flatText.length, termIndex + firstTerm.length + 170) : 260; - const rawSnippet = flatText.slice(snippetStart, snippetEnd).trim(); + const rawSnippet = flatText + .slice(snippetStart, snippetEnd) + .replace(snippetStart > 0 ? /^\S+\s+/ : /$^/, "") + .replace(snippetEnd < flatText.length ? /\s+\S+$/ : /$^/, "") + .trim(); const normalizedSnippet = normalizeSearchText(rawSnippet); const matchedTerms = terms.filter((term) => countTermHits(normalizedSnippet, term) > 0); const text = `${snippetStart > 0 ? "... " : ""}${rawSnippet}${snippetEnd < flatText.length ? " ..." : ""}`; @@ -1081,7 +1152,7 @@ async function buildPageDocument(source: LatestScanRow, page: PageRow): Promise< selected: page.selected, scope: pageScope.scope, scopeReason: pageScope.reason, - usedForCoverage: page.selected && (pageScope.scope === "indexable_page" || pageScope.scope === "template_block"), + usedForCoverage: page.selected && pageScope.scope === "indexable_page", title, sourcePath: page.source_path, urlPath: page.url_path, @@ -1251,7 +1322,6 @@ function analyzeClusters(documents: ContentDocument[], ontology: ProjectOntology ); const contentSourceEvidence = docHits.filter((item) => item.document.scope === "content_source"); const landingPageHits = indexableEvidence.reduce((sum, item) => sum + item.hits, 0); - const meaningfulLandingPageHits = meaningfulIndexableEvidence.reduce((sum, item) => sum + item.hits, 0); const contentSourceHits = contentSourceEvidence.reduce((sum, item) => sum + item.hits, 0); const uniqueSources = new Set(docHits.map((item) => item.document.id)).size; const intentMatches = getClusterIntentMatches(cluster, hitDocuments); @@ -1262,14 +1332,18 @@ function analyzeClusters(documents: ContentDocument[], ontology: ProjectOntology const strongSnippetCount = docHits.filter( (item) => item.matchedTerms.length >= 2 && countWords(buildEvidenceSnippet(item.document, item.matchedTerms).text) >= 14 ).length; - const directLexicalScore = Math.min(42, lexicalScore * 0.42); + const strongestLandingHits = meaningfulIndexableEvidence.reduce( + (maximum, item) => Math.max(maximum, item.hits), + 0 + ); + const directLexicalScore = Math.min(36, lexicalScore * 0.36); const landingEvidenceScore = Math.min( 24, - meaningfulIndexableEvidence.length * 9 + targetPageEvidence.length * 8 + Math.min(meaningfulLandingPageHits, 8) * 0.9 + strongestLandingHits * 0.8 + Math.min(meaningfulIndexableEvidence.length, 4) * 2 + (targetPageEvidence.length > 0 ? 4 : 0) ); - const contentEvidenceScore = Math.min(10, contentSourceEvidence.length * 4 + Math.min(contentSourceHits, 8) * 0.75); - const intentSupportScore = Math.min(18, intentScore * 0.18); - const depthScore = Math.min(6, strongSnippetCount * 3 + Math.max(0, uniqueSources - 1)); + const contentEvidenceScore = Math.min(6, contentSourceEvidence.length + Math.sqrt(contentSourceHits) * 0.55); + const intentSupportScore = Math.min(14, intentScore * 0.14); + const depthScore = Math.min(8, strongSnippetCount * 2 + Math.min(2, Math.max(0, uniqueSources - 1))); const supportSignals: string[] = []; const penalties: string[] = []; @@ -1313,7 +1387,10 @@ function analyzeClusters(documents: ContentDocument[], ontology: ProjectOntology penalties.push("нет intent-сигналов вокруг кластера"); } - if (cluster.priority === "high" && intentMatches.matchedIntentGroups.length < 2) { + if ( + cluster.priority === "high" && + intentMatches.matchedIntentGroups.length < Math.max(1, Math.min(2, cluster.intentGroups.length)) + ) { penalties.push("high-priority кластеру нужен минимум 2 intent-сигнала"); } @@ -1346,7 +1423,12 @@ function analyzeClusters(documents: ContentDocument[], ontology: ProjectOntology }, 0); const score = hits === 0 ? 0 : clampScore(directLexicalScore + landingEvidenceScore + contentEvidenceScore + intentSupportScore + depthScore - penaltyScore); const landingEvidenceUnits = targetPageEvidence.length > 0 ? targetPageEvidence.length : meaningfulIndexableEvidence.length - 1; - const evidenceLevel = getEvidenceLevel(score, Math.max(0, landingEvidenceUnits), intentMatches.matchedIntentGroups.length); + const evidenceLevel = getEvidenceLevel( + score, + Math.max(0, landingEvidenceUnits), + intentMatches.matchedIntentGroups.length, + cluster.intentGroups.length + ); const status: SemanticAnalysisOutput["clusters"][number]["status"] = evidenceLevel === "strong" ? "covered" : hits > 0 ? "weak" : "missing"; @@ -1498,7 +1580,7 @@ function isSemanticBasisPhrase(value: string, brandName: string) { const normalizedBrand = normalizeSearchText(brandName); const brandWords = new Set(getSeedPhraseWords(normalizedBrand)); - if (words.length < 3 || words.length > 8) { + if (words.length < 2 || words.length > 8) { return false; } @@ -1516,8 +1598,9 @@ function isSemanticBasisPhrase(value: string, brandName: string) { } const meaningfulWords = words.filter((word) => !RUSSIAN_STOP_WORDS.has(word) && !brandWords.has(word) && word.length > 1); + const uniqueWordRatio = new Set(words).size / Math.max(1, words.length); - return meaningfulWords.length >= 2; + return meaningfulWords.length >= 2 && uniqueWordRatio >= 0.65; } function cleanSeedPhrase(value: string) { @@ -1525,7 +1608,8 @@ function cleanSeedPhrase(value: string) { .replace(/^\.\.\.\s*/, "") .replace(/\s*\.\.\.$/, "") .replace(/[“”«»"]/g, "") - .replace(/[|;:…]+/g, " ") + .replace(/^[+\-–—]+\s*/, "") + .replace(/[|;:,…]+/g, " ") .replace(/\s+/g, " ") .trim(); } @@ -1593,7 +1677,7 @@ function extractEvidenceSeedPhrases( function buildClusterSeedPhrases(cluster: ReturnType[number], brandName: string) { return Array.from( new Set( - cluster.queryExamples + [...cluster.queryExamples, ...extractEvidenceSeedPhrases(cluster, brandName)] .map(cleanSeedPhrase) .filter((phrase) => isSemanticBasisPhrase(phrase, brandName)) .map((phrase) => phrase.toLocaleLowerCase("ru-RU")) @@ -2787,7 +2871,9 @@ async function buildAnalysisOutput(projectId: string): Promise<{ ontology: Proje } const [pages, dataAssets] = await Promise.all([getScanPages(scan.id), getScanDataAssets(scan.id)]); - const pageDocuments = await Promise.all(pages.map((page) => buildPageDocument(scan, page))); + const pageDocuments = removeSharedPageBoilerplate( + await Promise.all(pages.map((page) => buildPageDocument(scan, page))) + ); const contentDocuments = ( await Promise.all(dataAssets.map((asset) => buildContentJsonDocument(scan, asset.source_path))) ).filter((document): document is ContentDocument => Boolean(document)); @@ -2884,7 +2970,14 @@ async function buildAnalysisOutput(projectId: string): Promise<{ ontology: Proje pageCount: ontology.projectOntologyInstance.pages.length, sectionCount: ontology.projectOntologyInstance.sections.length, intentClusterCount: ontology.projectOntologyInstance.intentClusters.length, - evidenceCount: ontology.projectOntologyInstance.evidence.length + evidenceCount: ontology.projectOntologyInstance.evidence.length, + sections: ontology.projectOntologyInstance.sections.slice(0, 240).map((section) => ({ + id: section.id, + pageId: section.pageId, + title: section.title, + meaning: section.meaning, + evidenceIds: section.evidenceIds + })) } : null }, diff --git a/seo_mode/seo_mode/server/src/business/seoBusinessSynthesis.ts b/seo_mode/seo_mode/server/src/business/seoBusinessSynthesis.ts index 2ea38cd..1ead6eb 100644 --- a/seo_mode/seo_mode/server/src/business/seoBusinessSynthesis.ts +++ b/seo_mode/seo_mode/server/src/business/seoBusinessSynthesis.ts @@ -261,6 +261,8 @@ function pickBusinessArray(record: Record, keys: string[]): str function pickEvidence(record: Record) { return pickBusinessArray(record, [ "evidenceRefs", + "evidenceIds", + "explicitEvidenceIds", "evidence", "evidenceBasis", "sourceRefs", @@ -311,6 +313,21 @@ function unique(values: string[]) { return result; } +function uniqueOriginal(values: string[]) { + const seen = new Set(); + + return values.filter((value) => { + const normalizedValue = normalizeText(value); + + if (!normalizedValue || seen.has(normalizedValue)) { + return false; + } + + seen.add(normalizedValue); + return true; + }); +} + function getWords(value: string) { return normalizeText(value).match(/[a-zа-яё0-9-]{3,}/giu) ?? []; } @@ -555,7 +572,7 @@ function normalizeCorePillarRole( function normalizeApplicationVectorRelationship( record: Record ): SeoBusinessSynthesisContract["semanticHierarchy"]["applicationVectors"][number]["relationshipToCore"] { - const exactRelationship = asString(record.relationshipToCore); + const exactRelationship = asString(record.relationshipToCore) ?? asString(record.status); if ( exactRelationship === "application_of_core_platform" || @@ -665,6 +682,7 @@ function normalizeBusinessSynthesisShape( asString(rawProductFrame.centralThesis) ?? asString(rawProductFrame.summary) ?? asString(rawProductFrame.description) ?? + asString(centralOffer.reading) ?? asString(centralOffer.description) ?? asString(centralOffer.summary) ?? asBusinessLabel(rawProductFrame.positioning) ?? @@ -734,6 +752,8 @@ function normalizeBusinessSynthesisShape( }); const applicationVectors: SeoBusinessSynthesisContract["semanticHierarchy"]["applicationVectors"] = applicationVectorItems.map((item, index) => { const record = asRecord(item); + const evidenceRefs = pickEvidence(record); + const relationshipToCore = normalizeApplicationVectorRelationship(record); const title = asString(record.title) ?? asBusinessLabel(record.label) ?? @@ -741,8 +761,13 @@ function normalizeBusinessSynthesisShape( `Сценарий применения ${index + 1}`; return { - evidenceRefs: pickEvidence(record), - grounding: normalizeGrounding(record.grounding ?? record.claimType ?? record.status ?? record.type), + evidenceRefs, + grounding: + relationshipToCore === "risky_expansion" + ? "hypothesis" + : relationshipToCore === "possible_vertical" + ? "inferred" + : normalizeGrounding(record.grounding ?? record.claimType, evidenceRefs.length > 0 ? "explicit" : "inferred"), id: asString(record.id) ?? `vector.${index + 1}`, priority: normalizePriority(record.priority ?? record.confidence), queryDirection: @@ -750,9 +775,10 @@ function normalizeBusinessSynthesisShape( asString(record.demandDirection) ?? asString(record.seoDirection) ?? asString(record.intent) ?? + asString(record.whyNotCore) ?? asString(record.description) ?? "Проверить как дочерний сценарий применения, не как ядро продукта.", - relationshipToCore: normalizeApplicationVectorRelationship(record), + relationshipToCore, title }; }); @@ -767,6 +793,7 @@ function normalizeBusinessSynthesisShape( ...asStringArray(record.phrases), ...asStringArray(record.corePhrases), ...asStringArray(record.queryExamples), + ...asStringArray(record.queryPatterns), ...asStringArray(record.seedPhrases), ...asStringArray(record.wordstatQueries), title @@ -780,6 +807,7 @@ function normalizeBusinessSynthesisShape( rationale: asString(record.rationale) ?? asString(record.reason) ?? + asString(record.coreNeed) ?? asString(record.intent) ?? asString(record.humanReviewNotes) ?? "Demand family proposed from business synthesis.", @@ -811,13 +839,16 @@ function normalizeBusinessSynthesisShape( analystReview: output.analystReview ?? getDefaultAnalystReview(), demandFamilies: fallbackDemandFamilies, generatedAt: asString(rawOutput.generatedAt) ?? new Date().toISOString(), - guardrails: - asStringArray(rawOutput.guardrails).length > 0 - ? asStringArray(rawOutput.guardrails) - : [ - "Не превращать дочерние vertical/application vectors в core product category.", - "Все demand families остаются proposal до ручной фиксации semantic basis." - ], + guardrails: uniqueOriginal([ + ...asStringArray(rawOutput.guardrails), + ...asStringArray(rawProductFrame.forbiddenOrWeakClaims), + ...asArray(rawHierarchy.riskyExpansion).map((item) => { + const record = asRecord(item); + return asString(record.reason) ?? asBusinessLabel(record) ?? ""; + }), + "Не превращать дочерние vertical/application vectors в core product category.", + "Все demand families остаются proposal до ручной фиксации semantic basis." + ]), nextActions: asStringArray(rawOutput.nextActions).length > 0 ? asStringArray(rawOutput.nextActions) @@ -831,13 +862,16 @@ function normalizeBusinessSynthesisShape( mode: "codex_workspace", modelRequired: true }, - risks: asArray(rawOutput.risks).map((risk, index) => { + risks: [ + ...asArray(rawOutput.risks), + ...asArray(rawHierarchy.riskyExpansion) + ].map((risk, index) => { const record = asRecord(risk); return { id: asString(record.id) ?? `risk.${index + 1}`, message: asString(record.message) ?? asString(record.reason) ?? "Проверить риск перед нормализацией спроса.", - title: asString(record.title) ?? `Риск ${index + 1}` + title: asString(record.title) ?? asBusinessLabel(record.label) ?? `Риск ${index + 1}` }; }), schemaVersion: "seo-business-synthesis.v1", diff --git a/seo_mode/seo_mode/server/src/commercial/seoCommercialDemand.ts b/seo_mode/seo_mode/server/src/commercial/seoCommercialDemand.ts index 7b20846..e7476b1 100644 --- a/seo_mode/seo_mode/server/src/commercial/seoCommercialDemand.ts +++ b/seo_mode/seo_mode/server/src/commercial/seoCommercialDemand.ts @@ -4,6 +4,15 @@ import { getSeoBusinessSynthesisContract, type SeoBusinessSynthesisContract } from "../business/seoBusinessSynthesis.js"; +import { + getDemandScopeGuardSummary, + getRejectedDemandScopeReason, + getRejectedDemandScopeReasonFromGuard +} from "../contextReview/demandScopeGuard.js"; +import { + getLatestSeoDemandResearchBrief, + type SeoDemandResearchBriefContract +} from "../contextReview/demandResearchBrief.js"; import { pool } from "../db/client.js"; type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; @@ -52,6 +61,7 @@ export type SeoCommercialDemandModelTaskContract = { guardrails: string[]; summary: string; }; + demandScope: ReturnType; }; allowedActions: Array< | "identify_buyer_context" @@ -72,6 +82,7 @@ export type SeoCommercialDemandContract = { projectId: string; semanticRunId: string; projectOntologyVersionId: string | null; + demandResearchBriefHash: string | null; generatedAt: string; provider: { mode: ProviderMode; @@ -319,12 +330,13 @@ function mapSeoCommercialDemandRun(row: SeoCommercialDemandRunRow): SeoCommercia export function buildSeoCommercialDemandTask( projectId: string, - businessSynthesis: SeoBusinessSynthesisContract + businessSynthesis: SeoBusinessSynthesisContract, + demandResearchBrief: SeoDemandResearchBriefContract | null = null ): SeoCommercialDemandModelTaskContract { return { taskType: "seo.commercial_demand", schemaVersion: "seo-commercial-demand-task.v1", - taskId: `${businessSynthesis.semanticRunId}:seo-commercial-demand:v1`, + taskId: `${businessSynthesis.semanticRunId}:seo-commercial-demand:${demandResearchBrief?.contextHash.slice(0, 12) ?? "unapproved"}:v2`, projectId, semanticRunId: businessSynthesis.semanticRunId, projectOntologyVersionId: businessSynthesis.projectOntologyVersionId, @@ -338,11 +350,26 @@ export function buildSeoCommercialDemandTask( businessSynthesis: { productFrame: businessSynthesis.productFrame, corePillars: businessSynthesis.semanticHierarchy.corePillars.slice(0, 12), - applicationVectors: businessSynthesis.semanticHierarchy.applicationVectors.slice(0, 18), - demandFamilies: businessSynthesis.demandFamilies.slice(0, 20), + applicationVectors: businessSynthesis.semanticHierarchy.applicationVectors + .filter((vector) => !getRejectedDemandScopeReason( + `${vector.title} ${vector.queryDirection} ${vector.relationshipToCore}`, + demandResearchBrief + )) + .slice(0, 18), + demandFamilies: businessSynthesis.demandFamilies + .filter((family) => !getRejectedDemandScopeReason( + `${family.title} ${family.rationale} ${family.phrases.join(" ")}`, + demandResearchBrief + )) + .map((family) => ({ + ...family, + phrases: family.phrases.filter((phrase) => !getRejectedDemandScopeReason(phrase, demandResearchBrief)) + })) + .slice(0, 20), guardrails: businessSynthesis.guardrails.slice(0, 20), summary: businessSynthesis.summary - } + }, + demandScope: getDemandScopeGuardSummary(demandResearchBrief) }, allowedActions: [ "identify_buyer_context", @@ -364,6 +391,9 @@ export function buildSeoCommercialDemandTask( "Every query pattern must express buyer intent, business value, implementation intent, vendor selection, automation, replacement, integration, pilot, or commercial evaluation.", "Preserve the site's core differentiator from productFrame/corePillars; do not detach verticals into unrelated markets.", "If an application vector only makes sense with the core platform differentiator, include that qualifier in query patterns.", + "Demand Scope is authoritative: never return an active commercial family, angle or query pattern from rejectedDirections.", + "Return buyerIntentFamilies with canonical fields title, buyerIntent, commercialAngle, sourceMeaning, coreQualifier, queryPatterns, excludedInformationalPatterns and evidenceRefs.", + "Return applicationCommercialAngles with canonical fields id, sourceVector, relationshipToCore, grounding, commercialReframe, coreQualifier, queryPatterns and evidenceRefs; keep every human-selected demandScope.selectedDirectionIds item represented exactly once.", "Do not call Wordstat/SERP or claim market demand is proven." ] }; @@ -378,12 +408,17 @@ function getEvidenceRefs(record: Record) { ]).slice(0, 12); } +function getNestedEvidenceRefs(value: unknown) { + return unique(asArray(value).flatMap((item) => getEvidenceRefs(asRecord(item)))).slice(0, 20); +} + function normalizeCommercialDemandShape( projectId: string, output: SeoCommercialDemandContract, fallback: SeoCommercialDemandModelProviderFallback ): SeoCommercialDemandContract { const rawOutput = asRecord(output); + const demandScope = fallback.modelTask.input.demandScope; const rawCore = asRecord(rawOutput.commercialCore); const fallbackProductFrame = fallback.modelTask.input.businessSynthesis.productFrame; const coreDifferentiator = @@ -407,6 +442,7 @@ function normalizeCommercialDemandShape( coreCommercialValue: asString(rawCore.coreCommercialValue) ?? asString(rawCore.commercialValue) ?? + asString(rawCore.commercialPositioning) ?? asString(rawCore.value) ?? fallbackProductFrame.coreOffer, coreDifferentiator, @@ -414,43 +450,80 @@ function normalizeCommercialDemandShape( asString(rawCore.decisionContext) ?? asString(rawCore.context) ?? "Выбор, внедрение или пилот решения внутри бизнес/операционного контура.", - evidenceRefs: getEvidenceRefs(rawCore) + evidenceRefs: unique([ + ...getEvidenceRefs(rawCore), + ...getNestedEvidenceRefs(rawCore.commercialValueClaims) + ]).slice(0, 20) }; const buyerIntentFamilies = asArray(rawOutput.buyerIntentFamilies).map((item, index) => { const record = asRecord(item); - const title = asString(record.title) ?? asLabel(record.name) ?? `Коммерческий intent ${index + 1}`; + const title = + asString(record.title) ?? + asLabel(record.name) ?? + asString(record.commercialNeed) ?? + asString(record.buyerContext) ?? + `Коммерческий intent ${index + 1}`; const queryPatterns = unique([ ...asStringArray(record.queryPatterns), ...asStringArray(record.queries), ...asStringArray(record.corePhrases), ...asStringArray(record.phrases) - ]).slice(0, 12); + ]) + .filter((phrase) => !getRejectedDemandScopeReasonFromGuard(phrase, demandScope)) + .slice(0, 12); return { - buyerIntent: normalizeBuyerIntent(record.buyerIntent ?? record.intent, `${title} ${queryPatterns.join(" ")}`), + buyerIntent: normalizeBuyerIntent( + record.buyerIntent ?? record.intent ?? record.intentType, + `${title} ${queryPatterns.join(" ")}` + ), commercialAngle: asString(record.commercialAngle) ?? asString(record.angle) ?? + asString(record.commercialNeed) ?? asString(record.rationale) ?? "Покупатель оценивает практическую бизнес-ценность решения.", - coreQualifier: asString(record.coreQualifier) ?? asString(record.qualifier) ?? coreDifferentiator, + coreQualifier: + asString(record.coreQualifier) ?? + asString(record.qualifier) ?? + asString(record.commercialQualifier) ?? + coreDifferentiator, evidenceRefs: getEvidenceRefs(record), excludedInformationalPatterns: unique([ ...asStringArray(record.excludedInformationalPatterns), ...asStringArray(record.excludedPatterns), - ...asStringArray(record.nonCommercialPatterns) + ...asStringArray(record.nonCommercialPatterns), + ...asStringArray(record.avoidAsInformational) ]).slice(0, 10), grounding: normalizeGrounding(record.grounding ?? record.claimType, "inferred"), id: asString(record.id) ?? `buyer.${toId(title) || index + 1}`, priority: normalizePriority(record.priority), queryPatterns, - sourceMeaning: asString(record.sourceMeaning) ?? asString(record.meaning) ?? title, + sourceMeaning: + asString(record.sourceMeaning) ?? + asString(record.meaning) ?? + asString(record.buyerContext) ?? + asString(record.commercialNeed) ?? + title, title }; - }); + }).filter((family) => family.queryPatterns.length > 0); + const selectedDirectionIds = new Set(demandScope.selectedDirectionIds); + const fallbackApplicationVectors = new Map( + fallback.modelTask.input.businessSynthesis.applicationVectors.map((vector) => [vector.id, vector]) + ); const applicationCommercialAngles = asArray(rawOutput.applicationCommercialAngles).map((item, index) => { const record = asRecord(item); - const sourceVector = asString(record.sourceVector) ?? asString(record.title) ?? asLabel(record.name) ?? `Application vector ${index + 1}`; + const id = asString(record.id) ?? `angle.${index + 1}`; + const fallbackVector = fallbackApplicationVectors.get(id); + const sourceVector = + asString(record.sourceVector) ?? + asString(record.title) ?? + asLabel(record.name) ?? + fallbackVector?.title ?? + asString(record.commercialAngle) ?? + id; + const isHumanSelectedDirection = selectedDirectionIds.has(id); return { commercialReframe: @@ -458,20 +531,37 @@ function normalizeCommercialDemandShape( asString(record.reframe) ?? asString(record.commercialAngle) ?? "Переформулировать как покупательский сценарий, связанный с ядром продукта.", - coreQualifier: asString(record.coreQualifier) ?? asString(record.qualifier) ?? coreDifferentiator, - evidenceRefs: getEvidenceRefs(record), - grounding: normalizeGrounding(record.grounding ?? record.claimType, "inferred"), - id: asString(record.id) ?? `angle.${toId(sourceVector) || index + 1}`, - priority: normalizePriority(record.priority), + coreQualifier: + asString(record.coreQualifier) ?? + asString(record.qualifier) ?? + asString(record.guardrail) ?? + coreDifferentiator, + evidenceRefs: unique([ + ...getEvidenceRefs(record), + ...(fallbackVector?.evidenceRefs ?? []) + ]).slice(0, 12), + grounding: normalizeGrounding(record.grounding ?? record.claimType ?? record.status, fallbackVector?.grounding ?? "inferred"), + id, + priority: normalizePriority(record.priority ?? fallbackVector?.priority), queryPatterns: unique([ ...asStringArray(record.queryPatterns), ...asStringArray(record.queries), ...asStringArray(record.phrases) - ]).slice(0, 10), - relationshipToCore: asString(record.relationshipToCore) ?? asString(record.relationship) ?? "application_of_core_platform", - sourceVector + ]) + .filter((phrase) => isHumanSelectedDirection || !getRejectedDemandScopeReasonFromGuard(phrase, demandScope)) + .slice(0, 10), + relationshipToCore: + asString(record.relationshipToCore) ?? + asString(record.relationship) ?? + fallbackVector?.relationshipToCore ?? + "application_of_core_platform", + sourceVector, + isHumanSelectedDirection }; - }); + }).filter((angle) => angle.isHumanSelectedDirection || !getRejectedDemandScopeReasonFromGuard( + `${angle.sourceVector} ${angle.commercialReframe} ${angle.queryPatterns.join(" ")}`, + demandScope + )).map(({ isHumanSelectedDirection: _isHumanSelectedDirection, ...angle }) => angle); const fallbackBuyerIntentFamilies = buyerIntentFamilies.length > 0 ? buyerIntentFamilies @@ -497,8 +587,14 @@ function normalizeCommercialDemandShape( commercialCore, generatedAt: asString(rawOutput.generatedAt) ?? new Date().toISOString(), guardrails: - asStringArray(rawOutput.guardrails).length > 0 - ? asStringArray(rawOutput.guardrails) + unique([ + ...asStringArray(rawOutput.guardrails), + ...asStringArray(rawCore.nonCommercialBoundaries) + ]).length > 0 + ? unique([ + ...asStringArray(rawOutput.guardrails), + ...asStringArray(rawCore.nonCommercialBoundaries) + ]) : [ "Не превращать архитектурный тезис в запрос без покупательской ценности.", "Прикладной сценарий должен оставаться связанным с core differentiator продукта.", @@ -511,6 +607,7 @@ function normalizeCommercialDemandShape( projectId: asString(rawOutput.projectId) ?? projectId, projectOntologyVersionId: asString(rawOutput.projectOntologyVersionId) ?? fallback.projectOntologyVersionId, + demandResearchBriefHash: demandScope.contextHash, provider: { message: asString(asRecord(rawOutput.provider).message) ?? "Commercial demand normalized by backend.", mode: "codex_workspace", @@ -531,6 +628,7 @@ function normalizeCommercialDemandShape( sourceTaskId: asString(rawOutput.sourceTaskId) ?? fallback.modelTask.taskId, summary: asString(rawOutput.summary) ?? + asString(rawCore.commercialPositioning) ?? `${commercialCore.coreBuyerProblem} ${commercialCore.coreCommercialValue}`.trim() }; } @@ -569,9 +667,10 @@ function normalizeCommercialDemandOutput( function buildFallbackCommercialDemand( projectId: string, - businessSynthesis: SeoBusinessSynthesisContract + businessSynthesis: SeoBusinessSynthesisContract, + demandResearchBrief: SeoDemandResearchBriefContract | null = null ): SeoCommercialDemandContract { - const task = buildSeoCommercialDemandTask(projectId, businessSynthesis); + const task = buildSeoCommercialDemandTask(projectId, businessSynthesis, demandResearchBrief); const coreDifferentiator = businessSynthesis.productFrame.coreOffer; return { @@ -579,6 +678,7 @@ function buildFallbackCommercialDemand( projectId, semanticRunId: businessSynthesis.semanticRunId, projectOntologyVersionId: businessSynthesis.projectOntologyVersionId, + demandResearchBriefHash: demandResearchBrief?.contextHash ?? null, generatedAt: new Date().toISOString(), provider: { mode: "deterministic_fallback", @@ -596,7 +696,12 @@ function buildFallbackCommercialDemand( decisionContext: "Выбор, внедрение или пилот решения.", evidenceRefs: businessSynthesis.productFrame.evidenceRefs.slice(0, 8) }, - buyerIntentFamilies: businessSynthesis.demandFamilies.slice(0, 10).map((family, index) => ({ + buyerIntentFamilies: businessSynthesis.demandFamilies + .filter((family) => !getRejectedDemandScopeReason( + `${family.title} ${family.rationale} ${family.phrases.join(" ")}`, + demandResearchBrief + )) + .slice(0, 10).map((family, index) => ({ buyerIntent: normalizeBuyerIntent(family.role, family.title), commercialAngle: family.rationale, coreQualifier: coreDifferentiator, @@ -605,11 +710,16 @@ function buildFallbackCommercialDemand( grounding: family.grounding, id: `buyer.${family.id || index + 1}`, priority: family.priority, - queryPatterns: family.phrases.slice(0, 8), + queryPatterns: family.phrases.filter((phrase) => !getRejectedDemandScopeReason(phrase, demandResearchBrief)).slice(0, 8), sourceMeaning: family.title, title: family.title })), - applicationCommercialAngles: businessSynthesis.semanticHierarchy.applicationVectors.slice(0, 10).map((vector, index) => ({ + applicationCommercialAngles: businessSynthesis.semanticHierarchy.applicationVectors + .filter((vector) => !getRejectedDemandScopeReason( + `${vector.title} ${vector.queryDirection} ${vector.relationshipToCore}`, + demandResearchBrief + )) + .slice(0, 10).map((vector, index) => ({ commercialReframe: vector.queryDirection, coreQualifier: coreDifferentiator, evidenceRefs: vector.evidenceRefs.slice(0, 8), @@ -657,15 +767,21 @@ export async function getSeoCommercialDemandContract(projectId: string): Promise return null; } - const aligned = await getLatestAlignedSeoCommercialDemand(projectId, analysis.runId); + const [businessSynthesis, demandResearchBrief] = await Promise.all([ + getSeoBusinessSynthesisContract(projectId), + getLatestSeoDemandResearchBrief(projectId, analysis.runId) + ]); - if (aligned) { - return aligned; + if (!businessSynthesis) { + return null; } - const businessSynthesis = await getSeoBusinessSynthesisContract(projectId); + const expectedTask = buildSeoCommercialDemandTask(projectId, businessSynthesis, demandResearchBrief); + const aligned = await getLatestAlignedSeoCommercialDemand(projectId, analysis.runId); - return businessSynthesis ? buildFallbackCommercialDemand(projectId, businessSynthesis) : null; + return aligned?.sourceTaskId === expectedTask.taskId + ? aligned + : buildFallbackCommercialDemand(projectId, businessSynthesis, demandResearchBrief); } export async function buildLatestSeoCommercialDemandTask(projectId: string): Promise { @@ -675,9 +791,12 @@ export async function buildLatestSeoCommercialDemandTask(projectId: string): Pro return null; } - const businessSynthesis = await getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId); + const [businessSynthesis, demandResearchBrief] = await Promise.all([ + getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId), + getLatestSeoDemandResearchBrief(projectId, analysis.runId) + ]); - return businessSynthesis ? buildSeoCommercialDemandTask(projectId, businessSynthesis) : null; + return businessSynthesis ? buildSeoCommercialDemandTask(projectId, businessSynthesis, demandResearchBrief) : null; } export async function saveSeoCommercialDemandFromModelProvider( diff --git a/seo_mode/seo_mode/server/src/contextReview/contextOpportunityMap.ts b/seo_mode/seo_mode/server/src/contextReview/contextOpportunityMap.ts new file mode 100644 index 0000000..765728e --- /dev/null +++ b/seo_mode/seo_mode/server/src/contextReview/contextOpportunityMap.ts @@ -0,0 +1,618 @@ +import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js"; +import { + getLatestAlignedSeoBusinessSynthesis, + type SeoBusinessSynthesisRun +} from "../business/seoBusinessSynthesis.js"; +import { pool, type DatabaseExecutor } from "../db/client.js"; +import { + getSeoOpportunityDiscoveryContract, + type SeoOpportunityDiscoveryCandidate, + type SeoOpportunityDiscoveryContract +} from "../opportunity/seoOpportunityDiscovery.js"; +import { + applySeoOpportunityCritic, + getSeoOpportunityCriticContract, + type SeoOpportunityCriticContract +} from "../opportunity/seoOpportunityCritic.js"; +import { + getLatestAlignedSeoContextReview, + reviewSeoContextReview, + type SeoContextReviewRun +} from "./seoContextReview.js"; + +type OpportunityRelationship = "adjacent" | "core" | "core_extension" | "speculative"; +type OpportunityDecision = "rejected" | "selected"; + +type ContextOpportunityDecisionRow = { + id: string; + input: Record; + output: ContextOpportunityDecisionOutput | null; + created_at: Date; +}; + +type ContextOpportunityDecisionOutput = { + schemaVersion: "seo-context-opportunity-decision.v1"; + projectId: string; + semanticRunId: string; + contextReviewRunId: string; + businessSynthesisRunId: string; + opportunityDiscoveryRunId: string | null; + opportunityCriticRunId: string | null; + generatedAt: string; + status: "approved" | "draft"; + selectedIds: string[]; + rejectedIds: string[]; +}; + +export type SeoContextOpportunity = { + id: string; + title: string; + relationship: OpportunityRelationship; + relationshipLabel: string; + confidence: "high" | "low" | "medium"; + priority: "high" | "low" | "medium"; + grounding: "explicit" | "hypothesis" | "inferred"; + rationale: string; + evidenceRefs: string[]; + assumptions: string[]; + risk: string; + recommended: boolean; + decision: OpportunityDecision; + sourceKind: "current_application" | "discovered_opportunity"; + targetSector: string | null; + buyer: string | null; + jobToBeDone: string | null; + reusedCapabilityIds: string[]; + requiredAdaptations: string[]; + commercialHypothesis: string | null; + expectedSearchLanguage: string[]; + demandStatus: "not_applicable" | "unverified"; + scorecard: SeoOpportunityDiscoveryCandidate["scorecard"] | null; + critic: SeoOpportunityDiscoveryCandidate["critic"] | null; +}; + +export type SeoContextOpportunityMap = { + schemaVersion: "seo-context-opportunity-map.v1"; + projectId: string; + generatedAt: string; + semanticRunId: string | null; + contextReviewRunId: string | null; + businessSynthesisRunId: string | null; + opportunityDiscoveryRunId: string | null; + state: "approved" | "missing_context" | "missing_opportunities" | "ready_to_approve" | "selection_required"; + source: { + providerMode: "codex_manual" | "codex_workspace" | "deterministic_fallback" | null; + summary: string | null; + }; + discovery: { + state: SeoOpportunityDiscoveryContract["state"] | "missing"; + summary: string | null; + shortlistCount: number; + deferredCount: number; + rejectedCount: number; + criticRunId: string | null; + criticState: SeoOpportunityCriticContract["state"] | "missing"; + criticStatus: SeoOpportunityCriticContract["portfolioVerdict"]["status"] | "missing"; + criticSummary: string | null; + }; + opportunities: SeoContextOpportunity[]; + selection: { + selectedIds: string[]; + rejectedIds: string[]; + selectedCount: number; + totalCount: number; + status: "approved" | "draft"; + }; + approval: { + allowed: boolean; + blockers: string[]; + }; +}; + +export class SeoContextOpportunityApprovalError extends Error {} + +function unique(values: string[]) { + return [...new Set(values.filter(Boolean))]; +} + +function getApprovalBlockers( + discovery: SeoContextOpportunityMap["discovery"], + opportunities: SeoContextOpportunity[], + selectedIds: string[] +) { + const selectedIdSet = new Set(selectedIds); + const selectedExpansionHypotheses = opportunities.filter( + (item) => item.sourceKind === "discovered_opportunity" && selectedIdSet.has(item.id) + ); + const unreviewedExpansionHypotheses = selectedExpansionHypotheses.filter((item) => !item.critic); + + return [ + ...(discovery.state !== "ready" ? ["Бизнес-аналитик и Opportunity Critic ещё формируют новые коридоры 4.2."] : []), + ...(discovery.criticState !== "ready" + ? ["Нужен отдельный model run независимого Opportunity Critic."] + : discovery.criticStatus !== "approved" + ? ["Independent Opportunity Critic пока не разрешил человеческий gate."] + : []), + ...(selectedIds.length === 0 ? ["Выберите хотя бы одно направление для следующего этапа."] : []), + ...(unreviewedExpansionHypotheses.length > 0 + ? ["В следующий этап нельзя передать гипотезу, которую ещё не проверил Independent Critic."] + : []) + ]; +} + +function getRelationship( + value: SeoBusinessSynthesisRun["semanticHierarchy"]["applicationVectors"][number]["relationshipToCore"] +): OpportunityRelationship { + if (value === "application_of_core_platform" || value === "explicit_module") { + return "core_extension"; + } + + if (value === "possible_vertical") { + return "adjacent"; + } + + if (value === "risky_expansion") { + return "speculative"; + } + + return "core"; +} + +function getRelationshipLabel(value: OpportunityRelationship) { + if (value === "core") { + return "Ядро"; + } + + if (value === "core_extension") { + return "Расширение ядра"; + } + + if (value === "adjacent") { + return "Смежное направление"; + } + + return "Гипотеза"; +} + +function getOpportunityConfidence(grounding: SeoContextOpportunity["grounding"]): SeoContextOpportunity["confidence"] { + return grounding === "explicit" ? "high" : grounding === "inferred" ? "medium" : "low"; +} + +function buildOpportunityRows( + synthesis: SeoBusinessSynthesisRun, + selectedIds: Set +): SeoContextOpportunity[] { + const vectorRows = synthesis.semanticHierarchy.applicationVectors.map((vector) => { + const relationship = getRelationship(vector.relationshipToCore); + + return { + assumptions: + vector.grounding === "explicit" + ? [] + : [ + vector.grounding === "hypothesis" + ? "Направление требует отдельной проверки спроса и продуктовой готовности." + : "Связь с продуктовым ядром выведена моделью и должна быть подтверждена пользователем." + ], + confidence: getOpportunityConfidence(vector.grounding), + decision: selectedIds.has(vector.id) ? "selected" : "rejected", + evidenceRefs: unique(vector.evidenceRefs), + grounding: vector.grounding, + id: vector.id, + priority: vector.priority, + rationale: vector.queryDirection || "Проверить направление как отдельную ветку будущего спроса.", + recommended: vector.grounding === "explicit" && relationship !== "speculative", + relationship, + relationshipLabel: getRelationshipLabel(relationship), + risk: + relationship === "speculative" + ? "Высокий риск подменить фактический контекст соседним рынком." + : relationship === "adjacent" + ? "Нужна отдельная проверка бизнес-релевантности и спроса." + : "Сохранять связь с общим продуктовым ядром и не выдавать модуль за отдельную категорию без подтверждения.", + title: vector.title, + sourceKind: "current_application", + targetSector: null, + buyer: null, + jobToBeDone: null, + reusedCapabilityIds: [], + requiredAdaptations: [], + commercialHypothesis: null, + expectedSearchLanguage: [], + demandStatus: "not_applicable", + scorecard: null, + critic: null + } satisfies SeoContextOpportunity; + }); + + if (vectorRows.length > 0) { + return vectorRows; + } + + return synthesis.demandFamilies.slice(0, 12).map((family) => { + const relationship: OpportunityRelationship = family.role === "core" ? "core" : family.role === "vertical" ? "adjacent" : "core_extension"; + + return { + assumptions: family.grounding === "explicit" ? [] : ["Направление выведено из смысловой иерархии и требует подтверждения."], + confidence: getOpportunityConfidence(family.grounding), + decision: selectedIds.has(family.id) ? "selected" : "rejected", + evidenceRefs: [], + grounding: family.grounding, + id: family.id, + priority: family.priority, + rationale: family.rationale, + recommended: family.grounding === "explicit" && relationship !== "adjacent", + relationship, + relationshipLabel: getRelationshipLabel(relationship), + risk: + relationship === "adjacent" + ? "Нужна отдельная проверка рынка." + : "Сохранять связь с общим продуктовым ядром и не выдавать модуль за отдельную категорию без подтверждения.", + title: family.title, + sourceKind: "current_application" as const, + targetSector: null, + buyer: null, + jobToBeDone: null, + reusedCapabilityIds: [], + requiredAdaptations: [], + commercialHypothesis: null, + expectedSearchLanguage: [], + demandStatus: "not_applicable" as const, + scorecard: null, + critic: null + }; + }); +} + +function buildDiscoveredOpportunityRows( + discovery: SeoOpportunityDiscoveryContract, + selectedIds: Set +): SeoContextOpportunity[] { + const visibleOpportunities = [...discovery.opportunities, ...discovery.deferredOpportunities] + .filter((opportunity, index, items) => items.findIndex((item) => item.id === opportunity.id) === index); + + return visibleOpportunities.map((opportunity) => ({ + assumptions: opportunity.assumptions, + confidence: opportunity.critic.confidence, + decision: selectedIds.has(opportunity.id) ? "selected" : "rejected", + evidenceRefs: opportunity.evidenceRefs, + grounding: "hypothesis", + id: opportunity.id, + priority: opportunity.scorecard.overall >= 72 ? "high" : opportunity.scorecard.overall >= 55 ? "medium" : "low", + rationale: opportunity.commercialHypothesis, + recommended: + opportunity.critic.verdict === "shortlist" && + opportunity.scorecard.overall >= 72 && + opportunity.critic.confidence !== "low", + relationship: opportunity.tier === "experimental" ? "speculative" : "adjacent", + relationshipLabel: opportunity.tier === "experimental" ? "Экспериментальная возможность" : "Бизнес-возможность", + risk: [opportunity.critic.reason, ...opportunity.critic.fatalRisks].filter(Boolean).join(" · "), + title: opportunity.title, + sourceKind: "discovered_opportunity", + targetSector: opportunity.targetSector, + buyer: opportunity.buyer, + jobToBeDone: opportunity.jobToBeDone, + reusedCapabilityIds: opportunity.reusedCapabilityIds, + requiredAdaptations: opportunity.requiredAdaptations, + commercialHypothesis: opportunity.commercialHypothesis, + expectedSearchLanguage: opportunity.expectedSearchLanguage, + demandStatus: "unverified", + scorecard: opportunity.scorecard, + critic: opportunity.critic + })); +} + +async function getLatestDecision( + projectId: string, + businessSynthesisRunId: string, + opportunityDiscoveryRunId: string | null, + opportunityCriticRunId: string | null, + executor: DatabaseExecutor = pool +) { + const result = await executor.query( + ` + select id, input, output, created_at + from runs + where project_id = $1 + and run_type = 'seo_context_opportunity_map' + and status = 'done' + and output->>'businessSynthesisRunId' = $2 + and coalesce(output->>'opportunityDiscoveryRunId', '') = coalesce($3, '') + and coalesce(output->>'opportunityCriticRunId', '') = coalesce($4, '') + order by created_at desc + limit 1; + `, + [projectId, businessSynthesisRunId, opportunityDiscoveryRunId, opportunityCriticRunId] + ); + + return result.rows[0] ?? null; +} + +async function getAlignedInputs(projectId: string): Promise<{ + analysisRunId: string | null; + contextReview: SeoContextReviewRun | null; + synthesis: SeoBusinessSynthesisRun | null; +}> { + const analysis = await getLatestSemanticAnalysis(projectId); + + if (!analysis) { + return { analysisRunId: null, contextReview: null, synthesis: null }; + } + + const [contextReview, synthesis] = await Promise.all([ + getLatestAlignedSeoContextReview(projectId, analysis.runId), + getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId) + ]); + + return { analysisRunId: analysis.runId, contextReview, synthesis }; +} + +export async function getSeoContextOpportunityMap( + projectId: string, + executor: DatabaseExecutor = pool +): Promise { + const { analysisRunId, contextReview, synthesis } = await getAlignedInputs(projectId); + + if (!analysisRunId || !contextReview) { + return { + schemaVersion: "seo-context-opportunity-map.v1", + projectId, + generatedAt: new Date().toISOString(), + semanticRunId: analysisRunId, + contextReviewRunId: contextReview?.runId ?? null, + businessSynthesisRunId: null, + opportunityDiscoveryRunId: null, + state: "missing_context", + source: { providerMode: null, summary: null }, + discovery: { + state: "missing", + summary: null, + shortlistCount: 0, + deferredCount: 0, + rejectedCount: 0, + criticRunId: null, + criticState: "missing", + criticStatus: "missing", + criticSummary: null + }, + opportunities: [], + selection: { rejectedIds: [], selectedCount: 0, selectedIds: [], status: "draft", totalCount: 0 }, + approval: { allowed: false, blockers: ["Сначала нужен модельный контекст проекта 4.1."] } + }; + } + + if (!synthesis || synthesis.provider.mode === "deterministic_fallback") { + return { + schemaVersion: "seo-context-opportunity-map.v1", + projectId, + generatedAt: new Date().toISOString(), + semanticRunId: analysisRunId, + contextReviewRunId: contextReview.runId, + businessSynthesisRunId: synthesis?.runId ?? null, + opportunityDiscoveryRunId: null, + state: "missing_opportunities", + source: { providerMode: synthesis?.provider.mode ?? null, summary: synthesis?.summary ?? null }, + discovery: { + state: "missing", + summary: null, + shortlistCount: 0, + deferredCount: 0, + rejectedCount: 0, + criticRunId: null, + criticState: "missing", + criticStatus: "missing", + criticSummary: null + }, + opportunities: [], + selection: { rejectedIds: [], selectedCount: 0, selectedIds: [], status: "draft", totalCount: 0 }, + approval: { allowed: false, blockers: ["Нужна модельная карта возможностей 4.2."] } + }; + } + + const sourceDiscovery = await getSeoOpportunityDiscoveryContract(projectId); + const critic = sourceDiscovery.state === "ready" ? await getSeoOpportunityCriticContract(projectId) : null; + const discovery = critic ? applySeoOpportunityCritic(sourceDiscovery, critic) : sourceDiscovery; + const decision = ( + await getLatestDecision(projectId, synthesis.runId, sourceDiscovery.runId, critic?.runId ?? null, executor) + )?.output ?? null; + const defaultSelectedIds = synthesis.semanticHierarchy.applicationVectors + .filter((vector) => vector.grounding === "explicit" && vector.relationshipToCore !== "risky_expansion") + .map((vector) => vector.id); + const selectedIds = new Set(decision?.selectedIds ?? defaultSelectedIds); + const opportunities = [ + ...buildOpportunityRows(synthesis, selectedIds), + ...buildDiscoveredOpportunityRows(discovery, selectedIds) + ]; + const validIds = new Set(opportunities.map((item) => item.id)); + const normalizedSelectedIds = [...selectedIds].filter((id) => validIds.has(id)); + const rejectedIds = opportunities.map((item) => item.id).filter((id) => !selectedIds.has(id)); + const status = decision?.status ?? "draft"; + const discoverySummary: SeoContextOpportunityMap["discovery"] = { + state: discovery.state, + summary: discovery.summary, + shortlistCount: discovery.portfolio.shortlistCount, + deferredCount: discovery.portfolio.deferredCount, + rejectedCount: discovery.portfolio.rejectedCount, + criticRunId: critic?.runId ?? null, + criticState: critic?.state ?? "missing", + criticStatus: critic?.portfolioVerdict.status ?? "missing", + criticSummary: critic?.summary ?? null + }; + const blockers = getApprovalBlockers(discoverySummary, opportunities, normalizedSelectedIds); + + return { + schemaVersion: "seo-context-opportunity-map.v1", + projectId, + generatedAt: new Date().toISOString(), + semanticRunId: analysisRunId, + contextReviewRunId: contextReview.runId, + businessSynthesisRunId: synthesis.runId, + opportunityDiscoveryRunId: sourceDiscovery.runId, + state: + status === "approved" && blockers.length === 0 + ? "approved" + : blockers.length === 0 + ? "ready_to_approve" + : "selection_required", + source: { providerMode: synthesis.provider.mode, summary: synthesis.summary }, + discovery: discoverySummary, + opportunities, + selection: { + rejectedIds, + selectedCount: normalizedSelectedIds.length, + selectedIds: normalizedSelectedIds, + status, + totalCount: opportunities.length + }, + approval: { allowed: blockers.length === 0, blockers } + }; +} + +async function persistDecision( + projectId: string, + selectedIds: string[], + status: "approved" | "draft", + executor: DatabaseExecutor = pool +): Promise<{ + opportunityDecisionRunId: string; + opportunityMap: SeoContextOpportunityMap; + reviewedContextReview: SeoContextReviewRun | null; +}> { + const current = await getSeoContextOpportunityMap(projectId, executor); + + if (!current.businessSynthesisRunId || !current.contextReviewRunId || !current.semanticRunId) { + throw new SeoContextOpportunityApprovalError("Карта возможностей ещё не собрана моделью."); + } + + const validIds = new Set(current.opportunities.map((item) => item.id)); + const normalizedSelectedIds = unique(selectedIds).filter((id) => validIds.has(id)); + + if (normalizedSelectedIds.length === 0) { + throw new SeoContextOpportunityApprovalError("Выберите хотя бы одно направление."); + } + + if (status === "approved") { + const approvalBlockers = getApprovalBlockers(current.discovery, current.opportunities, normalizedSelectedIds); + + if (approvalBlockers.length > 0) { + throw new SeoContextOpportunityApprovalError( + approvalBlockers.join(" ") || "Карта возможностей ещё не готова к утверждению." + ); + } + } + + const isSameApprovedSelection = + status === "approved" && + current.selection.status === "approved" && + [...current.selection.selectedIds].sort().join("\n") === [...normalizedSelectedIds].sort().join("\n"); + + if (isSameApprovedSelection) { + const [existingDecision, alignedInputs] = await Promise.all([ + getLatestDecision( + projectId, + current.businessSynthesisRunId, + current.opportunityDiscoveryRunId, + current.discovery.criticRunId, + executor + ), + getAlignedInputs(projectId) + ]); + + if (!existingDecision?.id || !alignedInputs.contextReview) { + throw new Error("Не удалось восстановить существующее утверждение карты возможностей."); + } + + return { + opportunityDecisionRunId: existingDecision.id, + opportunityMap: current, + reviewedContextReview: alignedInputs.contextReview + }; + } + + const output: ContextOpportunityDecisionOutput = { + schemaVersion: "seo-context-opportunity-decision.v1", + projectId, + semanticRunId: current.semanticRunId, + contextReviewRunId: current.contextReviewRunId, + businessSynthesisRunId: current.businessSynthesisRunId, + opportunityDiscoveryRunId: current.opportunityDiscoveryRunId, + opportunityCriticRunId: current.discovery.criticRunId, + generatedAt: new Date().toISOString(), + status, + selectedIds: normalizedSelectedIds, + rejectedIds: current.opportunities.map((item) => item.id).filter((id) => !normalizedSelectedIds.includes(id)) + }; + + const inserted = await executor.query<{ id: string }>( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_context_opportunity_map', 'done', $2::jsonb, $3::jsonb, now(), now()) + returning id; + `, + [ + projectId, + JSON.stringify({ + schemaVersion: "seo-context-opportunity-decision-input.v1", + businessSynthesisRunId: current.businessSynthesisRunId, + opportunityDiscoveryRunId: current.opportunityDiscoveryRunId, + opportunityCriticRunId: current.discovery.criticRunId, + status + }), + JSON.stringify(output) + ] + ); + + const opportunityDecisionRunId = inserted.rows[0]?.id; + + if (!opportunityDecisionRunId) { + throw new Error("Не удалось сохранить решение по карте возможностей."); + } + + let reviewedContextReview: SeoContextReviewRun | null = null; + + if (status === "approved") { + const selectedTitles = current.opportunities + .filter((item) => normalizedSelectedIds.includes(item.id)) + .map((item) => item.title); + reviewedContextReview = await reviewSeoContextReview( + projectId, + current.contextReviewRunId, + "approved", + [`Карта возможностей утверждена: ${selectedTitles.join(" · ")}`], + executor + ); + } + + return { + opportunityDecisionRunId, + opportunityMap: await getSeoContextOpportunityMap(projectId, executor), + reviewedContextReview + }; +} + +export async function saveSeoContextOpportunitySelection(projectId: string, selectedIds: string[]) { + const result = await persistDecision(projectId, selectedIds, "draft"); + return result.opportunityMap; +} + +export async function approveSeoContextOpportunityMap( + projectId: string, + selectedIds: string[], + executor: DatabaseExecutor = pool +): Promise<{ + opportunityDecisionRunId: string; + opportunityMap: SeoContextOpportunityMap; + reviewedContextReview: SeoContextReviewRun; +}> { + const result = await persistDecision(projectId, selectedIds, "approved", executor); + + if (!result.reviewedContextReview) { + throw new Error("Не удалось утвердить Context Review вместе с картой возможностей."); + } + + return { + opportunityDecisionRunId: result.opportunityDecisionRunId, + opportunityMap: result.opportunityMap, + reviewedContextReview: result.reviewedContextReview + }; +} diff --git a/seo_mode/seo_mode/server/src/contextReview/contextSnapshots.ts b/seo_mode/seo_mode/server/src/contextReview/contextSnapshots.ts new file mode 100644 index 0000000..0fd32b4 --- /dev/null +++ b/seo_mode/seo_mode/server/src/contextReview/contextSnapshots.ts @@ -0,0 +1,230 @@ +import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js"; +import { getSeoBusinessSynthesisContract } from "../business/seoBusinessSynthesis.js"; +import { pool } from "../db/client.js"; +import { getSeoOpportunityDiscoveryContract } from "../opportunity/seoOpportunityDiscovery.js"; +import { getSeoOpportunityCriticContract } from "../opportunity/seoOpportunityCritic.js"; +import { getSeoContextOpportunityMap } from "./contextOpportunityMap.js"; +import { getLatestAlignedSeoContextReview, getSeoProjectContextContract } from "./seoContextReview.js"; + +export type ContextDevSubstage = "mechanical" | "opportunities" | "semantic"; + +export type ContextDevSnapshotSummary = { + id: string; + label: string; + createdAt: string; + substage: ContextDevSubstage; + semanticRunId: string | null; + contextReviewRunId: string | null; + businessSynthesisRunId: string | null; + opportunityDiscoveryRunId: string | null; + opportunityCriticRunId: string | null; + selectedOpportunityCount: number; +}; + +export type ContextDevSnapshotOutput = { + schemaVersion: "context-dev-snapshot.v1"; + projectId: string; + createdAt: string; + label: string; + note: string | null; + substage: ContextDevSubstage; + semanticAnalysis: unknown | null; + contextReview: unknown | null; + businessSynthesis: unknown | null; + opportunityDiscovery: unknown | null; + opportunityCritic: unknown | null; + opportunityMap: unknown | null; + projectContext: unknown | null; +}; + +export type ContextDevSnapshotDetail = { + summary: ContextDevSnapshotSummary; + snapshot: ContextDevSnapshotOutput; +}; + +type ContextDevSnapshotRow = { + id: string; + input: { label?: string; substage?: ContextDevSubstage }; + output: ContextDevSnapshotOutput | null; + created_at: Date; +}; + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function getString(value: unknown) { + return typeof value === "string" && value.trim() ? value : null; +} + +function getSelectedOpportunityCount(snapshot: ContextDevSnapshotOutput | null) { + const opportunityMap = asRecord(snapshot?.opportunityMap); + const selection = asRecord(opportunityMap.selection); + const count = Number(selection.selectedCount); + return Number.isFinite(count) ? count : 0; +} + +function getRunId(value: unknown) { + return getString(asRecord(value).runId); +} + +function getSemanticRunId(value: unknown) { + return getString(asRecord(value).runId) ?? getString(asRecord(value).semanticRunId); +} + +function mapSnapshotRow(row: ContextDevSnapshotRow): ContextDevSnapshotSummary { + return { + id: row.id, + label: row.input.label || row.output?.label || `Контекст dev · ${row.created_at.toLocaleString("ru-RU")}`, + createdAt: row.created_at.toISOString(), + substage: row.input.substage ?? row.output?.substage ?? "mechanical", + semanticRunId: getSemanticRunId(row.output?.semanticAnalysis), + contextReviewRunId: getRunId(row.output?.contextReview), + businessSynthesisRunId: getRunId(row.output?.businessSynthesis), + opportunityDiscoveryRunId: getRunId(row.output?.opportunityDiscovery), + opportunityCriticRunId: getRunId(row.output?.opportunityCritic), + selectedOpportunityCount: getSelectedOpportunityCount(row.output) + }; +} + +export async function listContextDevSnapshots(projectId: string): Promise { + const result = await pool.query( + ` + select id, input, output, created_at + from runs + where project_id = $1 + and run_type = 'context_dev_snapshot' + and status = 'done' + order by created_at desc + limit 30; + `, + [projectId] + ); + + return result.rows.map(mapSnapshotRow); +} + +export async function getContextDevSnapshot(projectId: string, snapshotId: string): Promise { + const result = await pool.query( + ` + select id, input, output, created_at + from runs + where project_id = $1 + and id = $2 + and run_type = 'context_dev_snapshot' + and status = 'done' + limit 1; + `, + [projectId, snapshotId] + ); + const row = result.rows[0]; + + if (!row?.output) { + throw new Error("Snapshot контекста не найден."); + } + + return { summary: mapSnapshotRow(row), snapshot: row.output }; +} + +export async function saveContextDevSnapshot( + projectId: string, + input: { label?: string; note?: string; substage?: ContextDevSubstage } +): Promise { + const semanticAnalysis = await getLatestSemanticAnalysis(projectId); + const [contextReview, businessSynthesis, opportunityDiscovery, opportunityCritic, opportunityMap, projectContext] = await Promise.all([ + semanticAnalysis ? getLatestAlignedSeoContextReview(projectId, semanticAnalysis.runId) : Promise.resolve(null), + getSeoBusinessSynthesisContract(projectId), + getSeoOpportunityDiscoveryContract(projectId), + getSeoOpportunityCriticContract(projectId), + getSeoContextOpportunityMap(projectId), + getSeoProjectContextContract(projectId) + ]); + const label = input.label?.trim() || `Контекст dev · ${new Date().toLocaleString("ru-RU")}`; + const substage = input.substage ?? "mechanical"; + const snapshot: ContextDevSnapshotOutput = { + schemaVersion: "context-dev-snapshot.v1", + projectId, + createdAt: new Date().toISOString(), + label, + note: input.note?.trim() || null, + substage, + semanticAnalysis, + contextReview, + businessSynthesis, + opportunityDiscovery, + opportunityCritic, + opportunityMap, + projectContext + }; + const result = await pool.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'context_dev_snapshot', 'done', $2::jsonb, $3::jsonb, now(), now()) + returning id, input, output, created_at; + `, + [ + projectId, + JSON.stringify({ label, schemaVersion: "context-dev-snapshot-input.v1", substage }), + JSON.stringify(snapshot) + ] + ); + const row = result.rows[0]; + + if (!row) { + throw new Error("Не удалось сохранить snapshot контекста."); + } + + return mapSnapshotRow(row); +} + +export async function renameContextDevSnapshot( + projectId: string, + snapshotId: string, + label: string +): Promise { + const normalizedLabel = label.trim(); + + if (!normalizedLabel) { + throw new Error("Нужно указать название snapshot."); + } + + const result = await pool.query( + ` + update runs + set input = jsonb_set(coalesce(input, '{}'::jsonb), '{label}', to_jsonb($3::text), true), + output = jsonb_set(coalesce(output, '{}'::jsonb), '{label}', to_jsonb($3::text), true), + completed_at = now() + where id = $2 + and project_id = $1 + and run_type = 'context_dev_snapshot' + and status = 'done' + returning id, input, output, created_at; + `, + [projectId, snapshotId, normalizedLabel] + ); + const row = result.rows[0]; + + if (!row) { + throw new Error("Snapshot контекста не найден."); + } + + return mapSnapshotRow(row); +} + +export async function deleteContextDevSnapshot(projectId: string, snapshotId: string) { + const result = await pool.query( + ` + update runs + set status = 'cancelled', completed_at = now() + where id = $2 + and project_id = $1 + and run_type = 'context_dev_snapshot' + and status = 'done'; + `, + [projectId, snapshotId] + ); + + if (result.rowCount === 0) { + throw new Error("Snapshot контекста не найден."); + } +} diff --git a/seo_mode/seo_mode/server/src/contextReview/coreSemanticPortfolioRouter.ts b/seo_mode/seo_mode/server/src/contextReview/coreSemanticPortfolioRouter.ts new file mode 100644 index 0000000..ba27209 --- /dev/null +++ b/seo_mode/seo_mode/server/src/contextReview/coreSemanticPortfolioRouter.ts @@ -0,0 +1,707 @@ +import crypto from "node:crypto"; +import type { SeoBusinessSynthesisContract } from "../business/seoBusinessSynthesis.js"; +import type { SeoContextOpportunity } from "./contextOpportunityMap.js"; +import { mapDemandFamiliesToFacetSeeds } from "./demandFamilyMapper.js"; + +export type DeliveryModel = + | "software" + | "service" + | "consulting" + | "agency" + | "marketplace" + | "ecommerce" + | "physical_product" + | "local_service" + | "content" + | "education" + | "platform" + | "infrastructure" + | "hybrid"; + +export type SalesMotion = + | "self_serve" + | "sales_led" + | "enterprise_sales" + | "local_booking" + | "lead_generation" + | "catalog_purchase" + | "request_quote" + | "subscription" + | "project_based"; + +export type PurchaseComplexity = "low" | "medium" | "high" | "enterprise" | "unknown"; +export type GeoDependency = "none" | "low" | "medium" | "high" | "unknown"; +export type ImplementationDepth = "none" | "setup" | "integration" | "custom_project" | "ongoing_operations" | "unknown"; +export type RegulationLevel = "none" | "low" | "medium" | "high" | "unknown"; + +export type SemanticFacetRelationship = "core_facet" | "current_application" | "expansion_hypothesis"; +export type SemanticFacetKind = + | "product_category" + | "service_action" + | "problem_solution" + | "process_automation" + | "use_case" + | "audience_role" + | "asset_object" + | "integration_system" + | "comparison" + | "price_transaction" + | "local_geo" + | "informational_education" + | "implementation" + | "compliance_trust"; + +export type SemanticPatternGroup = + | "base_entity" + | "category_process" + | "entity_process" + | "problem_process" + | "service" + | "implementation" + | "integration" + | "informational" + | "comparison" + | "local_geo" + | "price_transaction"; + +export type SemanticSlotValue = { + value: string; + normalized: string; + source: "site" | "model" | "user" | "observed" | "pattern"; + evidenceRefs: string[]; + confidence: number; + isInternalTerm?: boolean; + isMarketTerm?: boolean; + isWeakModifier?: boolean; +}; + +export type SemanticPortfolioFacet = { + id: string; + title: string; + description: string; + relationshipToCore: SemanticFacetRelationship; + facetKind: SemanticFacetKind; + priority: "high" | "medium" | "low"; + marketSurface: { + plainLanguage: string; + searchLanguageHints: string[]; + doNotTreatAsFinalQueries: true; + }; + slotBundle: { + entities: SemanticSlotValue[]; + actions: SemanticSlotValue[]; + processes: SemanticSlotValue[]; + problems: SemanticSlotValue[]; + outcomes: SemanticSlotValue[]; + audiences: SemanticSlotValue[]; + assets: SemanticSlotValue[]; + systems: SemanticSlotValue[]; + locations: SemanticSlotValue[]; + modifiers: SemanticSlotValue[]; + }; + businessFit: { + deliveryModels: DeliveryModel[]; + salesMotion: SalesMotion[]; + purchaseComplexity: PurchaseComplexity; + geoDependency: GeoDependency; + }; + patternEligibility: { + allowedPatternGroups: SemanticPatternGroup[]; + blockedPatternGroups: SemanticPatternGroup[]; + reason: string; + }; + activation: { + defaultActive: boolean; + defaultActiveScore: number; + requiresHumanApproval: boolean; + reason: string; + }; + evidence: Array<{ + source: "site_corpus" | "context_review" | "business_synthesis" | "demand_family" | "opportunity_portfolio" | "user_selected"; + refs: string[]; + confidence: number; + }>; + risks: Array<{ + type: "too_generic" | "wrong_competitive_category" | "unsupported_claim" | "regulated" | "speculative_expansion" | "ambiguous_intent" | "thin_evidence"; + message: string; + }>; + negativeInterpretations: string[]; + status: "draft" | "needs_human_scope_selection" | "selected" | "rejected" | "hidden_by_quality_gate"; +}; + +export type UniversalOfferMap = { + version: "seo-offer-map.v3"; + plainLanguageCoreOffer: string; + marketCategories: string[]; + coreCapabilities: Array<{ id: string; title: string; evidenceRefs: string[] }>; + differentiators: string[]; + buyerRoles: string[]; + deployment: string[]; + doNotOverweightTerms: string[]; + marketLanguageRule: string; + evidenceRefs: string[]; + siteIdentity: { + brandName: string; + language: string; + geoHints: string[]; + }; + coreOffer: { + plainSummary: string; + factualSummary: string; + evidenceRefs: string[]; + confidence: number; + }; + businessModelSignals: { + deliveryModels: DeliveryModel[]; + salesMotion: SalesMotion[]; + monetization: string[]; + purchaseComplexity: PurchaseComplexity; + geoDependency: GeoDependency; + implementationDepth: ImplementationDepth; + regulationLevel: RegulationLevel; + isB2B: boolean | null; + isB2C: boolean | null; + isLocal: boolean | null; + isDigital: boolean | null; + isPhysical: boolean | null; + }; + capabilities: Array<{ id: string; title: string; evidenceRefs: string[]; confidence: number }>; + productsOrServices: Array<{ id: string; title: string; evidenceRefs: string[]; confidence: number }>; + processes: Array<{ id: string; title: string; evidenceRefs: string[]; confidence: number }>; + audiences: Array<{ id: string; title: string; evidenceRefs: string[]; confidence: number }>; + assetsOrObjects: Array<{ id: string; title: string; evidenceRefs: string[]; confidence: number }>; + systemsOrIntegrations: Array<{ id: string; title: string; evidenceRefs: string[]; confidence: number }>; + locations: Array<{ id: string; title: string; evidenceRefs: string[]; confidence: number }>; + proofSignals: Array<{ id: string; title: string; evidenceRefs: string[]; confidence: number }>; + guardrails: string[]; +}; + +export type BalancedScopeDiagnostics = { + totalFacets: number; + selectedFacets: number; + relationshipCounts: Record; + facetKindCounts: Partial>; + selectionReasons: string[]; + warnings: string[]; +}; + +export type PortfolioRouterDiagnostics = { + totalFacets: number; + relationshipCounts: Record; + facetKindCounts: Partial>; + demandFamilyFacetCount: number; + warnings: string[]; +}; + +type FixedCorePillar = { + id: string; + title: string; + role: string; + grounding: string; + whyItMatters: string; + evidenceRefs: string[]; +}; + +type CoreContext = { + brandName: string; + coreOffer: string; + centralThesis: string; + audience: string; +}; + +function stableId(prefix: string, value: string) { + return `${prefix}:${crypto.createHash("sha256").update(value).digest("hex").slice(0, 20)}`; +} + +function normalize(value: string) { + return value.trim().toLocaleLowerCase().replace(/\s+/g, " "); +} + +function unique(values: string[]) { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + +function confidenceFromGrounding(grounding: string) { + if (grounding === "explicit") return 0.9; + if (grounding === "inferred") return 0.65; + return 0.42; +} + +function priorityFromScore(score: number): "high" | "medium" | "low" { + return score >= 0.8 ? "high" : score >= 0.6 ? "medium" : "low"; +} + +function facetKindFromPillarRole(role: string): SemanticFacetKind { + if (role === "workflow_layer") return "process_automation"; + if (role === "governance_layer") return "compliance_trust"; + if (role === "supporting_capability") return "use_case"; + return "product_category"; +} + +function allowedPatternsForFacetKind(input: { + kind: SemanticFacetKind; + deliveryModels: DeliveryModel[]; + salesMotion: SalesMotion[]; +}): SemanticPatternGroup[] { + const serviceDelivery = input.deliveryModels.some((model) => model === "service" || model === "consulting" || model === "agency" || model === "local_service"); + const transactionMotion = input.salesMotion.some((motion) => motion === "catalog_purchase" || motion === "request_quote" || motion === "local_booking"); + const kind = input.kind; + + if (kind === "service_action") return serviceDelivery + ? ["base_entity", "service", "implementation", "informational"] + : ["base_entity", "informational"]; + if (kind === "process_automation") return ["base_entity", "category_process", "entity_process", "problem_process", "informational"]; + if (kind === "integration_system") return ["base_entity", "integration", "informational"]; + if (kind === "local_geo") return ["base_entity", "local_geo", "informational"]; + if (kind === "price_transaction") return transactionMotion + ? ["base_entity", "price_transaction", "informational"] + : ["base_entity", "informational"]; + if (kind === "comparison") return ["base_entity", "comparison", "informational"]; + if (kind === "implementation") return ["base_entity", "implementation", "service", "informational"]; + return ["base_entity", "category_process", "entity_process", "informational", "comparison"]; +} + +function emptySlotBundle() { + return { + actions: [] as SemanticSlotValue[], + assets: [] as SemanticSlotValue[], + audiences: [] as SemanticSlotValue[], + entities: [] as SemanticSlotValue[], + locations: [] as SemanticSlotValue[], + modifiers: [] as SemanticSlotValue[], + outcomes: [] as SemanticSlotValue[], + problems: [] as SemanticSlotValue[], + processes: [] as SemanticSlotValue[], + systems: [] as SemanticSlotValue[] + }; +} + +function slot(value: string, evidenceRefs: string[], confidence: number, source: SemanticSlotValue["source"]): SemanticSlotValue { + return { + confidence, + evidenceRefs, + isMarketTerm: false, + normalized: normalize(value), + source, + value + }; +} + +function buildFacet(input: { + id: string; + title: string; + description: string; + relationshipToCore: SemanticFacetRelationship; + facetKind: SemanticFacetKind; + priority: "high" | "medium" | "low"; + evidenceSource: SemanticPortfolioFacet["evidence"][number]["source"]; + evidenceRefs: string[]; + confidence: number; + searchLanguageHints?: string[]; + risks?: SemanticPortfolioFacet["risks"]; + defaultActive?: boolean; + defaultActiveScore?: number; + deliveryModels: DeliveryModel[]; + salesMotion: SalesMotion[]; + purchaseComplexity: PurchaseComplexity; + geoDependency: GeoDependency; + requiresHumanApproval?: boolean; +}) : SemanticPortfolioFacet { + const defaultActive = input.defaultActive ?? false; + const score = input.defaultActiveScore ?? input.confidence; + const slotBundle = emptySlotBundle(); + slotBundle.entities.push(slot(input.title, input.evidenceRefs, input.confidence, input.evidenceSource === "demand_family" ? "model" : "site")); + + return { + activation: { + defaultActive, + defaultActiveScore: score, + reason: defaultActive + ? "Фасет относится к фактическому ядру и имеет достаточную evidence-опору." + : "Фасет сохранён для ручного выбора и не активируется автоматически.", + requiresHumanApproval: input.requiresHumanApproval ?? !defaultActive + }, + businessFit: { + deliveryModels: input.deliveryModels, + geoDependency: input.geoDependency, + purchaseComplexity: input.purchaseComplexity, + salesMotion: input.salesMotion + }, + description: input.description, + evidence: [{ confidence: input.confidence, refs: unique(input.evidenceRefs), source: input.evidenceSource }], + facetKind: input.facetKind, + id: input.id, + marketSurface: { + doNotTreatAsFinalQueries: true, + plainLanguage: input.title, + searchLanguageHints: unique(input.searchLanguageHints ?? []) + }, + negativeInterpretations: [], + patternEligibility: { + allowedPatternGroups: allowedPatternsForFacetKind({ + deliveryModels: input.deliveryModels, + kind: input.facetKind, + salesMotion: input.salesMotion + }), + blockedPatternGroups: input.relationshipToCore === "expansion_hypothesis" ? ["comparison", "price_transaction"] : [], + reason: "Паттерны выбираются по роли фасета и бизнес-сигналам, а не по доменным словам." + }, + priority: input.priority, + relationshipToCore: input.relationshipToCore, + risks: input.risks ?? [], + slotBundle, + status: input.relationshipToCore === "core_facet" ? "draft" : "needs_human_scope_selection", + title: input.title + }; +} + +function deliveryModelsFromSynthesis(synthesis: SeoBusinessSynthesisContract): DeliveryModel[] { + const roles = synthesis.semanticHierarchy.corePillars.map((pillar) => pillar.role); + const models: DeliveryModel[] = []; + + if (roles.includes("core_platform") || roles.includes("platform_layer")) models.push("platform"); + if (synthesis.semanticHierarchy.applicationVectors.length > 0) models.push("hybrid"); + return unique(models) as DeliveryModel[]; +} + +function salesMotionFromSynthesis(synthesis: SeoBusinessSynthesisContract): SalesMotion[] { + const hasCommercialFamily = synthesis.demandFamilies.some((family) => family.role === "commercial"); + return hasCommercialFamily ? ["lead_generation"] : []; +} + +export function buildUniversalOfferMap(input: { + contextReview: { audience: { primary: string }; siteIdentity: { brandName: string }; forbiddenClaims: Array<{ claim: string }> }; + businessSynthesis: SeoBusinessSynthesisContract; + language: string; +}): UniversalOfferMap { + const { businessSynthesis, contextReview } = input; + const deliveryModels = deliveryModelsFromSynthesis(businessSynthesis); + const salesMotion = salesMotionFromSynthesis(businessSynthesis); + const coreCapabilities = businessSynthesis.semanticHierarchy.corePillars.map((pillar) => ({ + evidenceRefs: pillar.evidenceRefs, + id: pillar.id, + title: pillar.title + })); + const applicationVectors = businessSynthesis.semanticHierarchy.applicationVectors; + const audience = contextReview.audience.primary; + const audienceKnown = audience.trim() && !/требует ручной проверки/i.test(audience); + + return { + assetsOrObjects: [], + audiences: audienceKnown ? [{ confidence: 0.6, evidenceRefs: [], id: stableId("audience", audience), title: audience }] : [], + buyerRoles: audienceKnown ? [audience] : [], + businessModelSignals: { + deliveryModels, + geoDependency: "unknown", + implementationDepth: deliveryModels.includes("platform") ? "integration" : "unknown", + isB2B: null, + isB2C: null, + isDigital: null, + isLocal: null, + isPhysical: null, + monetization: [], + purchaseComplexity: deliveryModels.includes("platform") ? "high" : "unknown", + regulationLevel: "unknown", + salesMotion + }, + capabilities: coreCapabilities.map((capability) => ({ ...capability, confidence: 0.85 })), + coreCapabilities, + coreOffer: { + confidence: businessSynthesis.productFrame.confidence === "high" ? 0.9 : businessSynthesis.productFrame.confidence === "medium" ? 0.65 : 0.42, + evidenceRefs: businessSynthesis.productFrame.evidenceRefs, + factualSummary: businessSynthesis.productFrame.centralThesis, + plainSummary: businessSynthesis.productFrame.coreOffer + }, + deployment: [], + differentiators: unique([ + businessSynthesis.productFrame.centralThesis, + ...businessSynthesis.semanticHierarchy.corePillars.map((pillar) => pillar.whyItMatters) + ]), + doNotOverweightTerms: [], + evidenceRefs: unique([ + ...businessSynthesis.productFrame.evidenceRefs, + ...businessSynthesis.semanticHierarchy.corePillars.flatMap((pillar) => pillar.evidenceRefs) + ]), + guardrails: contextReview.forbiddenClaims.map((claim) => claim.claim), + locations: [], + marketCategories: unique([businessSynthesis.productFrame.productCategory]), + marketLanguageRule: "Внутренние названия модулей, слоёв и экранов не считаются языком рынка без внешнего evidence.", + plainLanguageCoreOffer: businessSynthesis.productFrame.coreOffer, + processes: businessSynthesis.demandFamilies + .filter((family) => family.role === "supporting" || family.role === "use_case") + .map((family) => ({ confidence: confidenceFromGrounding(family.grounding), evidenceRefs: [], id: family.id, title: family.title })), + productsOrServices: [{ + confidence: businessSynthesis.productFrame.confidence === "high" ? 0.9 : 0.65, + evidenceRefs: businessSynthesis.productFrame.evidenceRefs, + id: "core_offer", + title: businessSynthesis.productFrame.coreOffer + }], + proofSignals: [], + siteIdentity: { brandName: contextReview.siteIdentity.brandName, geoHints: [], language: input.language }, + systemsOrIntegrations: applicationVectors + .filter((vector) => vector.relationshipToCore === "explicit_module") + .map((vector) => ({ confidence: confidenceFromGrounding(vector.grounding), evidenceRefs: vector.evidenceRefs, id: vector.id, title: vector.title })), + version: "seo-offer-map.v3" + }; +} + +function facetsFromPillars(input: { + corePillars: FixedCorePillar[]; + offerMap: UniversalOfferMap; +}): SemanticPortfolioFacet[] { + return input.corePillars.map((pillar, index) => { + const confidence = confidenceFromGrounding(pillar.grounding); + const score = Math.max(0.35, confidence - index * 0.015); + return buildFacet({ + confidence, + defaultActive: confidence >= 0.6, + defaultActiveScore: score, + deliveryModels: input.offerMap.businessModelSignals.deliveryModels, + description: pillar.whyItMatters || pillar.title, + evidenceRefs: pillar.evidenceRefs, + evidenceSource: "business_synthesis", + facetKind: facetKindFromPillarRole(pillar.role), + geoDependency: input.offerMap.businessModelSignals.geoDependency, + id: `core_facet:${pillar.id}`, + priority: priorityFromScore(score), + purchaseComplexity: input.offerMap.businessModelSignals.purchaseComplexity, + relationshipToCore: "core_facet", + salesMotion: input.offerMap.businessModelSignals.salesMotion, + title: pillar.title + }); + }); +} + +function facetsFromDemandFamilies(input: { + demandFamilies: SeoBusinessSynthesisContract["demandFamilies"]; + offerMap: UniversalOfferMap; +}): SemanticPortfolioFacet[] { + return mapDemandFamiliesToFacetSeeds({ demandFamilies: input.demandFamilies }).map((family) => { + return buildFacet({ + confidence: family.confidence, + defaultActive: false, + defaultActiveScore: family.confidence * 0.55, + deliveryModels: input.offerMap.businessModelSignals.deliveryModels, + description: family.description, + evidenceRefs: input.offerMap.evidenceRefs, + evidenceSource: "demand_family", + facetKind: family.facetKind, + geoDependency: input.offerMap.businessModelSignals.geoDependency, + id: family.id, + priority: family.priority, + purchaseComplexity: input.offerMap.businessModelSignals.purchaseComplexity, + relationshipToCore: family.relationshipToCore, + requiresHumanApproval: true, + risks: [{ + message: "Demand family — concept evidence. Его search hints не являются финальными запросами и требуют выбора scope.", + type: "ambiguous_intent" + }], + salesMotion: input.offerMap.businessModelSignals.salesMotion, + searchLanguageHints: family.searchLanguageHints, + title: family.title + }); + }); +} + +function facetsFromOpportunities(input: { + opportunities: SeoContextOpportunity[]; + offerMap: UniversalOfferMap; +}): SemanticPortfolioFacet[] { + return input.opportunities.map((opportunity) => { + const relationshipToCore: SemanticFacetRelationship = opportunity.sourceKind === "current_application" + ? "current_application" + : "expansion_hypothesis"; + const confidence = opportunity.confidence === "high" ? 0.85 : opportunity.confidence === "medium" ? 0.6 : 0.38; + return buildFacet({ + confidence, + defaultActive: false, + defaultActiveScore: confidence * 0.4, + deliveryModels: input.offerMap.businessModelSignals.deliveryModels, + description: [opportunity.jobToBeDone, opportunity.commercialHypothesis, opportunity.rationale].filter(Boolean).join(". "), + evidenceRefs: opportunity.evidenceRefs, + evidenceSource: "opportunity_portfolio", + facetKind: relationshipToCore === "current_application" ? "use_case" : "problem_solution", + geoDependency: input.offerMap.businessModelSignals.geoDependency, + id: `${relationshipToCore}:${opportunity.id}`, + priority: opportunity.priority, + purchaseComplexity: input.offerMap.businessModelSignals.purchaseComplexity, + relationshipToCore, + requiresHumanApproval: true, + risks: [{ + message: opportunity.risk || "Фасет из portfolio требует ручного решения перед включением в search scope.", + type: relationshipToCore === "expansion_hypothesis" ? "speculative_expansion" : "ambiguous_intent" + }], + salesMotion: input.offerMap.businessModelSignals.salesMotion, + searchLanguageHints: opportunity.expectedSearchLanguage, + title: opportunity.title + }); + }); +} + +function mergeExactFacets(facets: SemanticPortfolioFacet[]) { + const bySurface = new Map(); + + for (const facet of facets) { + const key = normalize(facet.marketSurface.plainLanguage); + const existing = bySurface.get(key); + if (!existing) { + bySurface.set(key, facet); + continue; + } + + existing.evidence.push(...facet.evidence); + existing.marketSurface.searchLanguageHints = unique([ + ...existing.marketSurface.searchLanguageHints, + ...facet.marketSurface.searchLanguageHints + ]); + existing.activation.defaultActive = existing.activation.defaultActive || facet.activation.defaultActive; + existing.activation.defaultActiveScore = Math.max(existing.activation.defaultActiveScore, facet.activation.defaultActiveScore); + } + + return [...bySurface.values()]; +} + +export function buildCoreSemanticPortfolio(input: { + businessSynthesis: SeoBusinessSynthesisContract; + corePillars: FixedCorePillar[]; + offerMap: UniversalOfferMap; + opportunities: SeoContextOpportunity[]; +}): { facets: SemanticPortfolioFacet[]; diagnostics: PortfolioRouterDiagnostics } { + const facets = mergeExactFacets([ + ...facetsFromPillars({ corePillars: input.corePillars, offerMap: input.offerMap }), + ...facetsFromDemandFamilies({ demandFamilies: input.businessSynthesis.demandFamilies, offerMap: input.offerMap }), + ...facetsFromOpportunities({ opportunities: input.opportunities, offerMap: input.offerMap }) + ]).sort((left, right) => right.activation.defaultActiveScore - left.activation.defaultActiveScore || left.title.localeCompare(right.title, "ru")); + + const relationshipCounts: Record = { + core_facet: facets.filter((facet) => facet.relationshipToCore === "core_facet").length, + current_application: facets.filter((facet) => facet.relationshipToCore === "current_application").length, + expansion_hypothesis: facets.filter((facet) => facet.relationshipToCore === "expansion_hypothesis").length + }; + const facetKindCounts = facets.reduce>>((result, facet) => { + result[facet.facetKind] = (result[facet.facetKind] ?? 0) + 1; + return result; + }, {}); + + return { + diagnostics: { + demandFamilyFacetCount: facets.filter((facet) => facet.id.startsWith("demand_family:")).length, + facetKindCounts, + relationshipCounts, + totalFacets: facets.length, + warnings: relationshipCounts.core_facet < 3 + ? ["Недостаточно доказанных core facets для balanced scope; нужен human review фактического Offer Map."] + : [] + }, + facets + }; +} + +function countByRelationship(facets: SemanticPortfolioFacet[]) { + return { + core_facet: facets.filter((facet) => facet.relationshipToCore === "core_facet").length, + current_application: facets.filter((facet) => facet.relationshipToCore === "current_application").length, + expansion_hypothesis: facets.filter((facet) => facet.relationshipToCore === "expansion_hypothesis").length + } satisfies Record; +} + +export function selectBalancedCoreFacets(facets: SemanticPortfolioFacet[], maxFacets = 4) { + const kindCounts = new Map(); + const selected: SemanticPortfolioFacet[] = []; + const eligible = facets + .filter((facet) => facet.relationshipToCore === "core_facet") + .filter((facet) => facet.activation.defaultActive) + .filter((facet) => facet.evidence.some((evidence) => evidence.refs.length > 0 && evidence.confidence >= 0.6)) + .sort((left, right) => right.activation.defaultActiveScore - left.activation.defaultActiveScore); + + for (const facet of eligible) { + if (selected.length >= maxFacets) break; + const sameKind = kindCounts.get(facet.facetKind) ?? 0; + if (sameKind >= 2) continue; + + selected.push(facet); + kindCounts.set(facet.facetKind, sameKind + 1); + } + + return selected; +} + +export function buildBalancedScopeDiagnostics(facets: SemanticPortfolioFacet[], selected: SemanticPortfolioFacet[]): BalancedScopeDiagnostics { + const facetKindCounts = selected.reduce>>((result, facet) => { + result[facet.facetKind] = (result[facet.facetKind] ?? 0) + 1; + return result; + }, {}); + const distinctKinds = Object.keys(facetKindCounts).length; + const warnings = [ + ...(selected.length < 3 ? ["Balanced scope содержит меньше трёх evidence-backed core facets."] : []), + ...(distinctKinds < Math.min(3, selected.length) ? ["Balanced scope недостаточно разнообразен по facet kind."] : []) + ]; + + return { + facetKindCounts, + relationshipCounts: countByRelationship(facets), + selectedFacets: selected.length, + selectionReasons: selected.map((facet) => `${facet.title}: ${facet.activation.reason}`), + totalFacets: facets.length, + warnings + }; +} + +export function buildLegacySemanticPortfolio(input: { + coreContext: CoreContext; + fixedCorePillars: FixedCorePillar[]; + opportunities: SeoContextOpportunity[]; + language: string; +}): { offerMap: UniversalOfferMap; facets: SemanticPortfolioFacet[]; diagnostics: PortfolioRouterDiagnostics } { + const coreEvidence = unique(input.fixedCorePillars.flatMap((pillar) => pillar.evidenceRefs)); + const offerMap: UniversalOfferMap = { + assetsOrObjects: [], + audiences: [], + buyerRoles: [], + businessModelSignals: { + deliveryModels: [], + geoDependency: "unknown", + implementationDepth: "unknown", + isB2B: null, + isB2C: null, + isDigital: null, + isLocal: null, + isPhysical: null, + monetization: [], + purchaseComplexity: "unknown", + regulationLevel: "unknown", + salesMotion: [] + }, + capabilities: input.fixedCorePillars.map((pillar) => ({ + confidence: confidenceFromGrounding(pillar.grounding), + evidenceRefs: pillar.evidenceRefs, + id: pillar.id, + title: pillar.title + })), + coreCapabilities: input.fixedCorePillars.map((pillar) => ({ id: pillar.id, title: pillar.title, evidenceRefs: pillar.evidenceRefs })), + coreOffer: { confidence: 0.65, evidenceRefs: coreEvidence, factualSummary: input.coreContext.centralThesis, plainSummary: input.coreContext.coreOffer }, + deployment: [], + differentiators: unique([input.coreContext.centralThesis, ...input.fixedCorePillars.map((pillar) => pillar.whyItMatters)]), + doNotOverweightTerms: [], + evidenceRefs: coreEvidence, + guardrails: [], + locations: [], + marketCategories: [input.coreContext.coreOffer], + marketLanguageRule: "Legacy migration: внутренние термины не считаются language of market без observed evidence.", + plainLanguageCoreOffer: input.coreContext.coreOffer, + processes: [], + productsOrServices: [{ confidence: 0.65, evidenceRefs: coreEvidence, id: "core_offer", title: input.coreContext.coreOffer }], + proofSignals: [], + siteIdentity: { brandName: input.coreContext.brandName, geoHints: [], language: input.language }, + systemsOrIntegrations: [], + version: "seo-offer-map.v3" + }; + const portfolio = buildCoreSemanticPortfolio({ + businessSynthesis: { demandFamilies: [] } as unknown as SeoBusinessSynthesisContract, + corePillars: input.fixedCorePillars, + offerMap, + opportunities: input.opportunities + }); + + return { offerMap, ...portfolio }; +} diff --git a/seo_mode/seo_mode/server/src/contextReview/demandFamilyMapper.ts b/seo_mode/seo_mode/server/src/contextReview/demandFamilyMapper.ts new file mode 100644 index 0000000..aab9335 --- /dev/null +++ b/seo_mode/seo_mode/server/src/contextReview/demandFamilyMapper.ts @@ -0,0 +1,45 @@ +import type { SeoBusinessSynthesisContract } from "../business/seoBusinessSynthesis.js"; +import type { SemanticFacetKind, SemanticFacetRelationship } from "./coreSemanticPortfolioRouter.js"; + +export type DemandFamilyFacetSeed = { + id: string; + title: string; + description: string; + relationshipToCore: SemanticFacetRelationship; + facetKind: SemanticFacetKind; + priority: "high" | "medium" | "low"; + confidence: number; + searchLanguageHints: string[]; +}; + +function confidenceFromGrounding(grounding: "explicit" | "inferred" | "hypothesis") { + if (grounding === "explicit") return 0.9; + if (grounding === "inferred") return 0.65; + return 0.42; +} + +function facetKindFromRole(role: SeoBusinessSynthesisContract["demandFamilies"][number]["role"]): SemanticFacetKind { + if (role === "commercial") return "price_transaction"; + if (role === "vertical" || role === "use_case") return "use_case"; + if (role === "supporting") return "process_automation"; + return "product_category"; +} + +/** + * Converts business-language families into evidence-backed semantic facets. + * It intentionally does not create queries, probes, or default activation. + */ +export function mapDemandFamiliesToFacetSeeds(input: { + demandFamilies: SeoBusinessSynthesisContract["demandFamilies"]; +}): DemandFamilyFacetSeed[] { + return input.demandFamilies.map((family) => ({ + confidence: confidenceFromGrounding(family.grounding), + description: family.rationale, + facetKind: facetKindFromRole(family.role), + id: `demand_family:${family.id}`, + priority: family.priority, + relationshipToCore: family.role === "vertical" ? "current_application" : "core_facet", + searchLanguageHints: family.phrases, + title: family.title + })); +} diff --git a/seo_mode/seo_mode/server/src/contextReview/demandResearchBrief.ts b/seo_mode/seo_mode/server/src/contextReview/demandResearchBrief.ts new file mode 100644 index 0000000..daf16b4 --- /dev/null +++ b/seo_mode/seo_mode/server/src/contextReview/demandResearchBrief.ts @@ -0,0 +1,702 @@ +import crypto from "node:crypto"; +import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js"; +import { getLatestAlignedSeoBusinessSynthesis } from "../business/seoBusinessSynthesis.js"; +import { pool, type DatabaseExecutor } from "../db/client.js"; +import { + buildBalancedScopeDiagnostics, + buildCoreSemanticPortfolio, + buildLegacySemanticPortfolio, + buildUniversalOfferMap, + selectBalancedCoreFacets, + type BalancedScopeDiagnostics, + type SemanticFacetKind, + type SemanticFacetRelationship, + type SemanticPortfolioFacet, + type UniversalOfferMap +} from "./coreSemanticPortfolioRouter.js"; +import { type SeoContextOpportunity, type SeoContextOpportunityMap } from "./contextOpportunityMap.js"; +import { getLatestAlignedSeoContextReview, type SeoContextReviewRun } from "./seoContextReview.js"; + +type DemandResearchBriefRunRow = { + id: string; + output: SeoDemandResearchBriefContract | null; + created_at: Date; +}; + +type SearchScopeRunRow = { + output: { + schemaVersion?: string; + contextHash?: string; + scope?: SeoSearchScope; + } | null; +}; + +type SemanticFacetLedgerRow = { + facet_id: string; + facet: SemanticPortfolioFacet; + selected: boolean; + updated_at: Date; +}; + +export type SeoOfferMap = UniversalOfferMap; + +export type SeoSearchScopeVector = { + id: string; + title: string; + description: string; + source: "core_offer" | "core_pillar" | "core_facet" | "current_application" | "expansion_hypothesis" | "opportunity"; + priority: "high" | "medium" | "low"; + evidenceRefs: string[]; + facetId?: string; + facetKind?: SemanticFacetKind; + relationshipToCore?: SemanticFacetRelationship; + allowedPatternGroups?: string[]; + blockedPatternGroups?: string[]; + searchLanguageHints?: string[]; +}; + +export type SeoSearchScope = { + mode: "core_default" | "balanced_auto" | "human_selected" | "portfolio_review"; + selectedVectorIds: string[]; + vectors: SeoSearchScopeVector[]; + availableVectors: SeoSearchScopeVector[]; + rationale: string; + diagnostics?: BalancedScopeDiagnostics; +}; + +export type SeoDemandResearchBriefContract = { + schemaVersion: "seo-demand-research-brief.v1" | "seo-demand-research-brief.v2" | "seo-demand-research-brief.v3"; + projectId: string; + generatedAt: string; + contextHash: string; + state: "approved" | "missing_context"; + sourceRuns: { + semanticRunId: string; + contextReviewRunId: string; + businessSynthesisRunId: string; + opportunityDecisionRunId: string | null; + opportunityDiscoveryRunId: string | null; + opportunityCriticRunId: string | null; + }; + coreContext: { + brandName: string; + identity: string; + productCategory: string; + coreOffer: string; + centralThesis: string; + audience: string; + audienceReason: string; + summary: string; + }; + fixedCorePillars: Array<{ + id: string; + title: string; + role: string; + grounding: string; + whyItMatters: string; + evidenceRefs: string[]; + }>; + /** + * Product meaning that may generate search language. This is deliberately + * independent from the opportunity portfolio below. + */ + offerMap?: SeoOfferMap; + /** + * Search-facing facets with evidence and risk metadata. A facet is not a + * keyword and cannot enter Wordstat without later observed/human gates. + */ + semanticPortfolio?: { + schemaVersion: "seo-semantic-portfolio.v1"; + facets: SemanticPortfolioFacet[]; + diagnostics: { + totalFacets: number; + relationshipCounts: Record; + facetKindCounts: Partial>; + demandFamilyFacetCount: number; + warnings: string[]; + }; + }; + /** + * The only directions allowed to enter query discovery. v1 briefs fall + * back to the factual core; opportunity rows never become a default scope. + */ + searchScope?: SeoSearchScope; + /** + * A portfolio for product and market exploration. It is preserved for + * strategy work but is not a keyword-generation authority. + */ + opportunityPortfolio?: { + selectedIds: string[]; + selectedCurrentApplications: SeoContextOpportunity[]; + selectedExpansionHypotheses: SeoContextOpportunity[]; + }; + /** @deprecated Use opportunityPortfolio. Kept for downstream compatibility. */ + selectedCurrentApplications: SeoContextOpportunity[]; + /** @deprecated Use opportunityPortfolio. Kept for downstream compatibility. */ + selectedExpansionHypotheses: SeoContextOpportunity[]; + rejectedDirections: SeoContextOpportunity[]; + forbiddenClaims: string[]; + semanticGaps: Array<{ id: string; title: string; reason: string }>; + guardrails: string[]; + projectProfile: { + language: string; + region: string | null; + businessGoal: string | null; + demandCollectionProfile: "contextual" | "market_wide"; + }; + approval: { + selectedCount: number; + currentApplicationCount: number; + expansionHypothesisCount: number; + approvedAt: string; + }; +}; + +export type SeoSemanticFacetLedgerEntry = { + facetId: string; + facet: SemanticPortfolioFacet; + selected: boolean; + updatedAt: string; +}; + +function unique(values: string[]) { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + +function getBuyerRoles(audience: string) { + const normalized = audience.trim(); + + return normalized && !/требует ручной проверки/i.test(normalized) ? [normalized] : []; +} + +function asSearchScopePriority(value: unknown): SeoSearchScopeVector["priority"] { + return value === "high" || value === "low" ? value : "medium"; +} + +function getPortfolioDirections(brief: SeoDemandResearchBriefContract) { + return brief.opportunityPortfolio ?? { + selectedIds: [...brief.selectedCurrentApplications, ...brief.selectedExpansionHypotheses].map((item) => item.id), + selectedCurrentApplications: brief.selectedCurrentApplications, + selectedExpansionHypotheses: brief.selectedExpansionHypotheses + }; +} + +function getOpportunityScopeVectors(brief: SeoDemandResearchBriefContract): SeoSearchScopeVector[] { + const portfolio = getPortfolioDirections(brief); + + return [...portfolio.selectedCurrentApplications, ...portfolio.selectedExpansionHypotheses].map((direction) => ({ + id: `opportunity:${direction.id}`, + title: direction.title, + description: [direction.jobToBeDone, direction.commercialHypothesis, direction.targetSector].filter(Boolean).join(". "), + source: "opportunity", + priority: asSearchScopePriority(direction.priority), + evidenceRefs: direction.evidenceRefs + })); +} + +function buildOfferMap(input: { + businessSynthesis: NonNullable>>; + contextReview: SeoContextReviewRun; +}): SeoOfferMap { + return buildUniversalOfferMap({ + businessSynthesis: input.businessSynthesis, + contextReview: input.contextReview, + language: "unknown" + }); +} + +function facetToSearchScopeVector(facet: SemanticPortfolioFacet): SeoSearchScopeVector { + return { + description: facet.description, + evidenceRefs: unique(facet.evidence.flatMap((evidence) => evidence.refs)), + facetId: facet.id, + facetKind: facet.facetKind, + allowedPatternGroups: facet.patternEligibility.allowedPatternGroups, + blockedPatternGroups: facet.patternEligibility.blockedPatternGroups, + id: facet.id, + priority: facet.priority, + relationshipToCore: facet.relationshipToCore, + searchLanguageHints: facet.marketSurface.searchLanguageHints, + source: facet.relationshipToCore, + title: facet.title + }; +} + +function buildDefaultSearchScope(input: { facets: SemanticPortfolioFacet[] }): SeoSearchScope { + const selectedFacets = selectBalancedCoreFacets(input.facets); + const vectors = selectedFacets.map(facetToSearchScopeVector); + + return { + availableVectors: input.facets.map(facetToSearchScopeVector), + diagnostics: buildBalancedScopeDiagnostics(input.facets, selectedFacets), + mode: "balanced_auto", + rationale: + "Balanced auto scope включает несколько evidence-backed core facets. Current applications и expansion hypotheses видимы, но требуют явного human selection.", + selectedVectorIds: vectors.map((vector) => vector.id), + vectors + }; +} + +async function persistSemanticFacetLedger(input: { + projectId: string; + contextHash: string; + facets: SemanticPortfolioFacet[]; + selectedFacetIds: string[]; + executor: DatabaseExecutor; +}) { + const selected = new Set(input.selectedFacetIds); + + await input.executor.query( + `delete from seo_semantic_facets where project_id = $1 and context_hash = $2`, + [input.projectId, input.contextHash] + ); + + for (const facet of input.facets) { + await input.executor.query( + ` + insert into seo_semantic_facets ( + project_id, context_hash, facet_id, relationship_to_core, facet_kind, status, + default_active, selected, facet, updated_at + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, now()) + `, + [ + input.projectId, + input.contextHash, + facet.id, + facet.relationshipToCore, + facet.facetKind, + facet.status, + facet.activation.defaultActive, + selected.has(facet.id), + JSON.stringify(facet) + ] + ); + } +} + +export async function listSeoSemanticFacetLedger(projectId: string, contextHash?: string | null): Promise { + const brief = contextHash ? null : await getLatestSeoDemandResearchBrief(projectId); + const resolvedContextHash = contextHash ?? brief?.contextHash; + if (!resolvedContextHash) return []; + + const result = await pool.query( + ` + select facet_id, facet, selected, updated_at + from seo_semantic_facets + where project_id = $1 and context_hash = $2 + order by selected desc, updated_at desc, facet_id asc + `, + [projectId, resolvedContextHash] + ); + + return result.rows.map((row) => ({ + facetId: row.facet_id, + facet: row.facet, + selected: row.selected, + updatedAt: row.updated_at.toISOString() + })); +} + +export function buildSeoDemandResearchContextHash( + value: Omit +) { + const hashBasis = { + approval: { + currentApplicationCount: value.approval.currentApplicationCount, + expansionHypothesisCount: value.approval.expansionHypothesisCount, + selectedCount: value.approval.selectedCount + }, + coreContext: value.coreContext, + fixedCorePillars: value.fixedCorePillars, + forbiddenClaims: value.forbiddenClaims, + guardrails: value.guardrails, + projectId: value.projectId, + projectProfile: value.projectProfile, + rejectedDirections: value.rejectedDirections, + schemaVersion: value.schemaVersion, + offerMap: value.offerMap ?? null, + semanticPortfolio: value.semanticPortfolio ?? null, + searchScope: value.searchScope ?? null, + opportunityPortfolio: value.opportunityPortfolio ?? null, + selectedCurrentApplications: value.selectedCurrentApplications, + selectedExpansionHypotheses: value.selectedExpansionHypotheses, + semanticGaps: value.semanticGaps, + state: value.state + }; + + return crypto.createHash("sha256").update(JSON.stringify(hashBasis)).digest("hex"); +} + +async function getLatestOpportunityDecisionRunId( + projectId: string, + businessSynthesisRunId: string, + executor: DatabaseExecutor = pool +) { + const result = await executor.query<{ id: string }>( + ` + select id + from runs + where project_id = $1 + and run_type = 'seo_context_opportunity_map' + and status = 'done' + and output->>'businessSynthesisRunId' = $2 + and output->>'status' = 'approved' + order by created_at desc + limit 1; + `, + [projectId, businessSynthesisRunId] + ); + + return result.rows[0]?.id ?? null; +} + +export async function buildSeoDemandResearchBrief( + projectId: string, + opportunityMap: SeoContextOpportunityMap, + options: { + approvedAt?: string; + contextReview?: SeoContextReviewRun; + executor?: DatabaseExecutor; + opportunityDecisionRunId?: string; + } = {} +): Promise { + const executor = options.executor ?? pool; + const analysis = await getLatestSemanticAnalysis(projectId); + + if (!analysis) { + throw new Error("Сначала нужен внутренний evidence-слой проекта."); + } + + const [loadedContextReview, businessSynthesis] = await Promise.all([ + options.contextReview + ? Promise.resolve(options.contextReview) + : getLatestAlignedSeoContextReview(projectId, analysis.runId), + getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId) + ]); + const contextReview = loadedContextReview; + + if (!contextReview || !businessSynthesis || opportunityMap.selection.status !== "approved") { + throw new Error("Demand Research Brief создаётся только из утверждённого Context Dev."); + } + + const selectedIds = new Set(opportunityMap.selection.selectedIds); + const selected = opportunityMap.opportunities.filter((item) => selectedIds.has(item.id)); + const selectedCurrentApplications = selected.filter( + (item) => item.sourceKind === "current_application" + ); + const selectedExpansionHypotheses = selected.filter( + (item) => item.sourceKind === "discovered_opportunity" + ); + const rejectedDirections = opportunityMap.opportunities.filter((item) => !selectedIds.has(item.id)); + const opportunityDecisionRunId = + options.opportunityDecisionRunId ?? + (opportunityMap.businessSynthesisRunId + ? await getLatestOpportunityDecisionRunId(projectId, opportunityMap.businessSynthesisRunId, executor) + : null); + const approvedAt = options.approvedAt ?? new Date().toISOString(); + const coreContext = { + brandName: contextReview.siteIdentity.brandName, + identity: contextReview.siteIdentity.description, + productCategory: businessSynthesis.productFrame.productCategory, + coreOffer: businessSynthesis.productFrame.coreOffer, + centralThesis: businessSynthesis.productFrame.centralThesis, + audience: contextReview.audience.primary, + audienceReason: contextReview.audience.reason, + summary: contextReview.summary + }; + const fixedCorePillars = businessSynthesis.semanticHierarchy.corePillars.map((pillar) => ({ + id: pillar.id, + title: pillar.title, + role: pillar.role, + grounding: pillar.grounding, + whyItMatters: pillar.whyItMatters, + evidenceRefs: pillar.evidenceRefs + })); + const offerMap = buildOfferMap({ businessSynthesis, contextReview }); + offerMap.siteIdentity.language = analysis.styleProfile.language; + const semanticPortfolio = buildCoreSemanticPortfolio({ + businessSynthesis, + corePillars: fixedCorePillars, + offerMap, + opportunities: selected + }); + const withoutGeneratedFields: Omit = { + schemaVersion: "seo-demand-research-brief.v3", + projectId, + state: "approved", + sourceRuns: { + semanticRunId: analysis.runId, + contextReviewRunId: contextReview.runId, + businessSynthesisRunId: businessSynthesis.runId, + opportunityDecisionRunId, + opportunityDiscoveryRunId: opportunityMap.opportunityDiscoveryRunId, + opportunityCriticRunId: opportunityMap.discovery.criticRunId + }, + coreContext, + fixedCorePillars, + offerMap, + semanticPortfolio: { + schemaVersion: "seo-semantic-portfolio.v1", + facets: semanticPortfolio.facets, + diagnostics: semanticPortfolio.diagnostics + }, + searchScope: buildDefaultSearchScope({ facets: semanticPortfolio.facets }), + opportunityPortfolio: { + selectedIds: selected.map((item) => item.id), + selectedCurrentApplications, + selectedExpansionHypotheses + }, + selectedCurrentApplications, + selectedExpansionHypotheses, + rejectedDirections, + forbiddenClaims: contextReview.forbiddenClaims.map((claim) => claim.claim), + semanticGaps: contextReview.semanticGaps.map((gap) => ({ id: gap.id, reason: gap.reason, title: gap.title })), + guardrails: unique([ + ...businessSynthesis.guardrails, + ...contextReview.forbiddenClaims.map((claim) => `${claim.claim}: ${claim.reason}`) + ]), + projectProfile: { + language: analysis.styleProfile.language, + region: null, + businessGoal: null, + demandCollectionProfile: "contextual" + }, + approval: { + selectedCount: selected.length, + currentApplicationCount: selectedCurrentApplications.length, + expansionHypothesisCount: selectedExpansionHypotheses.length, + approvedAt + } + }; + + return { + ...withoutGeneratedFields, + generatedAt: approvedAt, + contextHash: buildSeoDemandResearchContextHash(withoutGeneratedFields) + }; +} + +export async function saveSeoDemandResearchBrief( + projectId: string, + opportunityMap: SeoContextOpportunityMap, + options: { + approvedAt?: string; + contextReview?: SeoContextReviewRun; + executor?: DatabaseExecutor; + opportunityDecisionRunId?: string; + } = {} +): Promise { + const executor = options.executor ?? pool; + const brief = await buildSeoDemandResearchBrief(projectId, opportunityMap, options); + const existing = await executor.query( + ` + select id, output, created_at + from runs + where project_id = $1 + and run_type = 'seo_demand_research_brief' + and status = 'done' + and output->>'contextHash' = $2 + and coalesce(output->'sourceRuns'->>'opportunityDecisionRunId', '') = coalesce($3, '') + order by created_at desc + limit 1; + `, + [projectId, brief.contextHash, brief.sourceRuns.opportunityDecisionRunId] + ); + + if (existing.rows[0]?.output) { + const existingBrief = existing.rows[0].output; + if (existingBrief.semanticPortfolio && existingBrief.searchScope) { + await persistSemanticFacetLedger({ + contextHash: existingBrief.contextHash, + executor, + facets: existingBrief.semanticPortfolio.facets, + projectId, + selectedFacetIds: existingBrief.searchScope.selectedVectorIds + }); + } + return existingBrief; + } + + await executor.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_demand_research_brief', 'done', $2::jsonb, $3::jsonb, now(), now()); + `, + [ + projectId, + JSON.stringify({ + schemaVersion: "seo-demand-research-brief-input.v1", + semanticRunId: brief.sourceRuns.semanticRunId, + contextHash: brief.contextHash + }), + JSON.stringify(brief) + ] + ); + + if (brief.semanticPortfolio && brief.searchScope) { + await persistSemanticFacetLedger({ + contextHash: brief.contextHash, + executor, + facets: brief.semanticPortfolio.facets, + projectId, + selectedFacetIds: brief.searchScope.selectedVectorIds + }); + } + + return brief; +} + +function buildSearchScopeFallback(brief: SeoDemandResearchBriefContract): SeoSearchScope { + if (brief.searchScope) { + const availableVectors = brief.searchScope.availableVectors?.length + ? brief.searchScope.availableVectors + : uniqueSearchScopeVectors([ + ...brief.searchScope.vectors, + ...getOpportunityScopeVectors(brief) + ]); + + return { + ...brief.searchScope, + availableVectors + }; + } + + if (brief.semanticPortfolio?.facets.length) { + return buildDefaultSearchScope({ facets: brief.semanticPortfolio.facets }); + } + + // Legacy v1/v2 brief migration mode: derive a visible, balanced portfolio + // from evidence-backed pillars and the already approved portfolio. The + // result is explicit in API output and no longer silently collapses to a + // single core_offer. + const legacyPortfolio = buildLegacySemanticPortfolio({ + coreContext: brief.coreContext, + fixedCorePillars: brief.fixedCorePillars, + language: brief.projectProfile.language, + opportunities: [...getPortfolioDirections(brief).selectedCurrentApplications, ...getPortfolioDirections(brief).selectedExpansionHypotheses] + }); + + return buildDefaultSearchScope({ facets: legacyPortfolio.facets }); +} + +function uniqueSearchScopeVectors(vectors: SeoSearchScopeVector[]) { + const seen = new Set(); + + return vectors.filter((vector) => { + if (seen.has(vector.id)) return false; + seen.add(vector.id); + return true; + }); +} + +export async function getSeoSearchScope( + projectId: string, + demandResearchBrief: SeoDemandResearchBriefContract | null = null +): Promise { + const brief = demandResearchBrief ?? await getLatestSeoDemandResearchBrief(projectId); + if (!brief || brief.state !== "approved") return null; + + const result = await pool.query( + ` + select output + from runs + where project_id = $1 + and run_type = 'seo_search_scope' + and status = 'done' + and output->>'contextHash' = $2 + order by created_at desc + limit 1 + `, + [projectId, brief.contextHash] + ); + const savedScope = result.rows[0]?.output; + + if (savedScope?.schemaVersion === "seo-search-scope.v1" && savedScope.scope?.vectors?.length) { + return savedScope.scope; + } + + return buildSearchScopeFallback(brief); +} + +export async function saveSeoSearchScope(projectId: string, selectedVectorIds: string[]) { + const brief = await getLatestSeoDemandResearchBrief(projectId); + if (!brief || brief.state !== "approved") { + throw new Error("Сначала нужен утверждённый Demand Research Brief."); + } + + const fallback = buildSearchScopeFallback(brief); + const selectedIds = unique(selectedVectorIds); + const availableById = new Map(fallback.availableVectors.map((vector) => [vector.id, vector])); + const vectors = selectedIds.map((id) => availableById.get(id)).filter((vector): vector is SeoSearchScopeVector => Boolean(vector)); + + if (vectors.length === 0) { + throw new Error("Выберите хотя бы один вектор Search Scope."); + } + + const scope: SeoSearchScope = { + mode: "human_selected", + selectedVectorIds: vectors.map((vector) => vector.id), + vectors, + availableVectors: fallback.availableVectors, + rationale: "Search Scope выбран человеком. Opportunity Portfolio попадает в query discovery только через это явное решение." + }; + const output = { + schemaVersion: "seo-search-scope.v1", + projectId, + contextHash: brief.contextHash, + semanticRunId: brief.sourceRuns.semanticRunId, + generatedAt: new Date().toISOString(), + scope + }; + + await pool.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_search_scope', 'done', $2::jsonb, $3::jsonb, now(), now()) + `, + [ + projectId, + JSON.stringify({ schemaVersion: "seo-search-scope-input.v1", contextHash: brief.contextHash, selectedVectorIds: scope.selectedVectorIds }), + JSON.stringify(output) + ] + ); + + await pool.query( + ` + update seo_semantic_facets + set selected = facet_id = any($3::text[]), updated_at = now() + where project_id = $1 and context_hash = $2 + `, + [projectId, brief.contextHash, scope.selectedVectorIds] + ); + + return scope; +} + +export async function getLatestSeoDemandResearchBrief( + projectId: string, + semanticRunId?: string | null +): Promise { + const params: string[] = [projectId]; + const semanticFilter = semanticRunId ? "and output->'sourceRuns'->>'semanticRunId' = $2" : ""; + + if (semanticRunId) { + params.push(semanticRunId); + } + + const result = await pool.query( + ` + select id, output, created_at + from runs + where project_id = $1 + and run_type = 'seo_demand_research_brief' + and status = 'done' + ${semanticFilter} + order by created_at desc + limit 1; + `, + params + ); + + return result.rows[0]?.output ?? null; +} diff --git a/seo_mode/seo_mode/server/src/contextReview/demandScopeGuard.ts b/seo_mode/seo_mode/server/src/contextReview/demandScopeGuard.ts new file mode 100644 index 0000000..d07499b --- /dev/null +++ b/seo_mode/seo_mode/server/src/contextReview/demandScopeGuard.ts @@ -0,0 +1,161 @@ +import type { SeoContextOpportunity } from "./contextOpportunityMap.js"; +import type { SeoDemandResearchBriefContract } from "./demandResearchBrief.js"; + +const DEMAND_SCOPE_TOKEN_STOP_WORDS = new Set([ + "автоматизация", + "бизнес", + "данные", + "задачи", + "видео", + "инфраструктура", + "интерфейс", + "интеллект", + "искусственный", + "контроль", + "контур", + "маршрут", + "объект", + "оператор", + "платформа", + "предприятие", + "процесс", + "решение", + "сервис", + "событие", + "система", + "территория", + "технология", + "управление", + "цифровой", + "workflow" +]); + +const DEMAND_SCOPE_RUSSIAN_ENDINGS = + /(иями|ями|ами|ого|его|ому|ему|ыми|ими|иях|ях|ах|ией|иям|ям|ам|овать|ировать|изация|изации|ация|ации|ение|ения|енный|енная|енные|ного|ному|ными|ов|ев|ей|ой|ый|ий|ая|яя|ое|ее|ые|ие|ую|юю|ом|ем|а|я|ы|и|у|ю|е|о)$/iu; + +function normalizeDemandScopePhrase(value: string) { + return value + .trim() + .toLocaleLowerCase("ru-RU") + .replace(/ё/g, "е") + .replace(/[“”«»"]/g, "") + .replace(/[^a-zа-я0-9]+/gi, " ") + .replace(/\s+/g, " "); +} + +function getDemandScopeToken(value: string) { + const normalized = normalizeDemandScopePhrase(value); + + if (normalized.length < 2 || (normalized.length < 5 && !/\d/u.test(normalized))) { + return null; + } + + const stemmed = /[а-я]/iu.test(normalized) + ? normalized.replace(DEMAND_SCOPE_RUSSIAN_ENDINGS, "") + : normalized.replace(/(ations|ation|ments|ment|ingly|edly|ing|ed|ers|er|ies|s)$/iu, ""); + + if (stemmed.length < 2) return normalized; + return stemmed.slice(0, 12); +} + +function getDemandScopeTokens(values: Array) { + const stopTokens = new Set( + [...DEMAND_SCOPE_TOKEN_STOP_WORDS] + .map(getDemandScopeToken) + .filter((token): token is string => Boolean(token)) + ); + + return new Set( + values + .flatMap((value) => normalizeDemandScopePhrase(value ?? "").match(/[a-zа-я0-9]+/gi) ?? []) + .map(getDemandScopeToken) + .filter((token): token is string => token !== null && !stopTokens.has(token)) + ); +} + +export function getOpportunityDemandScopePhrases(opportunity: SeoContextOpportunity) { + return [ + opportunity.title, + opportunity.targetSector, + opportunity.buyer, + opportunity.jobToBeDone, + opportunity.commercialHypothesis, + ...opportunity.expectedSearchLanguage + ]; +} + +export function getRejectedDemandScopeReason( + phrase: string, + demandResearchBrief: SeoDemandResearchBriefContract | null | undefined +) { + if (!demandResearchBrief || demandResearchBrief.state !== "approved" || demandResearchBrief.rejectedDirections.length === 0) { + return null; + } + + return getRejectedDemandScopeReasonFromGuard(phrase, getDemandScopeGuardSummary(demandResearchBrief)); +} + +export function getRejectedDemandScopeReasonFromGuard( + phrase: string, + demandScope: ReturnType | null | undefined +) { + if (!demandScope?.approved || demandScope.rejectedDirections.length === 0) { + return null; + } + + const selectedTokens = getDemandScopeTokens(demandScope.selectedPhrases); + const phraseTokens = getDemandScopeTokens([phrase]); + const normalizedPhrase = normalizeDemandScopePhrase(phrase); + + for (const rejectedDirection of demandScope.rejectedDirections) { + const distinctiveRejectedTokens = [...getDemandScopeTokens(rejectedDirection.phrases)].filter( + (token) => !selectedTokens.has(token) + ); + const matchedTokens = distinctiveRejectedTokens.filter((token) => phraseTokens.has(token)); + const exactRejectedPhrase = rejectedDirection.phrases + .map((value) => normalizeDemandScopePhrase(value ?? "")) + .filter(Boolean) + .some((rejectedPhrase) => rejectedPhrase === normalizedPhrase); + + // A rejected opportunity describes a semantic branch, not a blacklist of + // isolated words. One fuzzy token (especially an inflected core term) is + // insufficient. Exact rejected language or a conjunction of at least two + // distinctive roots is required; selected core roots have already been + // removed above and therefore always take precedence. + if (exactRejectedPhrase || matchedTokens.length >= 2) { + return `Hard scope guard: фраза восстанавливает отклонённое направление «${rejectedDirection.title}» (${matchedTokens.join(", ")}).`; + } + } + + return null; +} + +export function filterApprovedDemandScopePhrases( + phrases: string[], + demandResearchBrief: SeoDemandResearchBriefContract | null | undefined +) { + return phrases.filter((phrase) => !getRejectedDemandScopeReason(phrase, demandResearchBrief)); +} + +export function getDemandScopeGuardSummary(demandResearchBrief: SeoDemandResearchBriefContract | null | undefined) { + return { + contextHash: demandResearchBrief?.contextHash ?? null, + approved: demandResearchBrief?.state === "approved", + selectedDirectionIds: [ + ...(demandResearchBrief?.selectedCurrentApplications ?? []), + ...(demandResearchBrief?.selectedExpansionHypotheses ?? []) + ].map((item) => item.id), + selectedPhrases: [ + ...(demandResearchBrief?.fixedCorePillars ?? []).flatMap((pillar) => [pillar.title, pillar.role, pillar.grounding]), + ...(demandResearchBrief?.selectedCurrentApplications ?? []).flatMap(getOpportunityDemandScopePhrases), + ...(demandResearchBrief?.selectedExpansionHypotheses ?? []).flatMap(getOpportunityDemandScopePhrases) + ], + rejectedDirections: (demandResearchBrief?.rejectedDirections ?? []).map((item) => ({ + id: item.id, + title: item.title, + targetSector: item.targetSector, + expectedSearchLanguage: item.expectedSearchLanguage, + phrases: getOpportunityDemandScopePhrases(item) + })) + }; +} diff --git a/seo_mode/seo_mode/server/src/contextReview/ownerStrategy.ts b/seo_mode/seo_mode/server/src/contextReview/ownerStrategy.ts new file mode 100644 index 0000000..3b960b2 --- /dev/null +++ b/seo_mode/seo_mode/server/src/contextReview/ownerStrategy.ts @@ -0,0 +1,104 @@ +import { pool, type DatabaseExecutor } from "../db/client.js"; + +export type PositioningExpansionPolicy = "core_only" | "near_core" | "balanced" | "frontier"; + +export type SeoOwnerStrategyInput = { + schemaVersion: "seo-owner-strategy.v1"; + projectId: string; + updatedAt: string; + strategicStatement: string; + referenceAnalogues: Array<{ + label: string; + transferableTraits: string[]; + caveat: string; + }>; + priorityThemes: string[]; + categoryExclusions: string[]; + expansionPolicy: PositioningExpansionPolicy; + notes: string | null; +}; + +export type SeoOwnerStrategyDraft = Omit; + +type OwnerStrategyRunRow = { + output: SeoOwnerStrategyInput | null; +}; + +const EMPTY_OWNER_STRATEGY: SeoOwnerStrategyDraft = { + categoryExclusions: [], + expansionPolicy: "balanced", + notes: null, + priorityThemes: [], + referenceAnalogues: [], + strategicStatement: "" +}; + +function unique(values: string[]) { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + +function compactOwnerDraft(input: SeoOwnerStrategyDraft): SeoOwnerStrategyDraft { + return { + categoryExclusions: unique(input.categoryExclusions).slice(0, 30), + expansionPolicy: input.expansionPolicy, + notes: input.notes?.trim() || null, + priorityThemes: unique(input.priorityThemes).slice(0, 30), + referenceAnalogues: input.referenceAnalogues + .map((analogue) => ({ + caveat: analogue.caveat.trim(), + label: analogue.label.trim(), + transferableTraits: unique(analogue.transferableTraits).slice(0, 16) + })) + .filter((analogue) => analogue.label || analogue.transferableTraits.length > 0) + .slice(0, 10), + strategicStatement: input.strategicStatement.trim() + }; +} + +export async function getLatestSeoOwnerStrategyInput( + projectId: string, + executor: DatabaseExecutor = pool +): Promise { + const result = await executor.query( + ` + select output + from runs + where project_id = $1 + and run_type = 'seo_owner_strategy' + and status = 'done' + order by created_at desc + limit 1 + `, + [projectId] + ); + + return result.rows[0]?.output ?? null; +} + +export async function saveSeoOwnerStrategyInput( + projectId: string, + draft: SeoOwnerStrategyDraft, + executor: DatabaseExecutor = pool +) { + const compact = compactOwnerDraft({ ...EMPTY_OWNER_STRATEGY, ...draft }); + const output: SeoOwnerStrategyInput = { + ...compact, + projectId, + schemaVersion: "seo-owner-strategy.v1", + updatedAt: new Date().toISOString() + }; + + await executor.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_owner_strategy', 'done', $2::jsonb, $3::jsonb, now(), now()) + `, + [ + projectId, + JSON.stringify({ schemaVersion: "seo-owner-strategy-input.v1" }), + JSON.stringify(output) + ] + ); + + return output; +} diff --git a/seo_mode/seo_mode/server/src/contextReview/positioningThesis.ts b/seo_mode/seo_mode/server/src/contextReview/positioningThesis.ts new file mode 100644 index 0000000..50ed980 --- /dev/null +++ b/seo_mode/seo_mode/server/src/contextReview/positioningThesis.ts @@ -0,0 +1,557 @@ +import crypto from "node:crypto"; +import { getLatestSeoDemandResearchBrief, type SeoDemandResearchBriefContract } from "./demandResearchBrief.js"; +import { + getLatestSeoOwnerStrategyInput, + type PositioningExpansionPolicy, + type SeoOwnerStrategyInput +} from "./ownerStrategy.js"; + +export { + getLatestSeoOwnerStrategyInput, + saveSeoOwnerStrategyInput, + type SeoOwnerStrategyDraft, + type SeoOwnerStrategyInput +} from "./ownerStrategy.js"; + +export const POSITIONING_PROVENANCE_TYPES = [ + "site_evidence", + "owner_strategy", + "model_inference", + "market_evidence" +] as const; + +export type PositioningProvenance = (typeof POSITIONING_PROVENANCE_TYPES)[number]; +export type { PositioningExpansionPolicy } from "./ownerStrategy.js"; +export type PositioningPortfolioLane = + | "category_core" + | "differentiated_core" + | "product_mechanism" + | "competitor_comparison" + | "expansion_hypothesis" + | "content_support" + | "unclassified"; + +export type PositioningClaim = { + id: string; + text: string; + provenance: PositioningProvenance; + evidenceRefs: string[]; + confidence: number; +}; + +export type SeoPositioningThesisContract = { + schemaVersion: "seo-positioning-thesis.v1"; + projectId: string; + revision: string; + contextHash: string | null; + generatedAt: string; + state: "missing_context" | "ready"; + ownerStrategy: SeoOwnerStrategyInput | null; + factualIdentity: { + brandName: string; + identity: PositioningClaim | null; + productCategory: PositioningClaim | null; + coreOffer: PositioningClaim | null; + centralThesis: PositioningClaim | null; + }; + strategicIntent: { + statement: PositioningClaim | null; + referenceAnalogues: Array<{ + label: string; + transferableTraits: PositioningClaim[]; + caveat: string; + }>; + priorityThemes: PositioningClaim[]; + expansionPolicy: PositioningExpansionPolicy; + note: string; + }; + categoryArchitecture: { + currentCategories: PositioningClaim[]; + differentiatedThemes: PositioningClaim[]; + productMechanisms: PositioningClaim[]; + excludedCategories: PositioningClaim[]; + }; + portfolios: { + currentDemand: { + categoryAnchors: PositioningClaim[]; + differentiatedAnchors: PositioningClaim[]; + mechanismAnchors: PositioningClaim[]; + }; + expansionOpportunities: Array<{ + id: string; + title: string; + capabilityIds: string[]; + buyerJob: string | null; + marketContext: string; + queryHypotheses: string[]; + requiredAdaptations: string[]; + evidenceRefs: string[]; + provenance: "model_inference"; + }>; + }; + guardPolicy: { + selectedCorePrecedence: true; + rejectedDirectionMatch: "exact_phrase_or_semantic_conjunction"; + rejectOnMetadataOnly: false; + ownerStrategyIsFactualEvidence: false; + competitorCategoryRequiresExplicitRelation: true; + }; + readiness: { + hasFactualCore: boolean; + hasOwnerStrategy: boolean; + currentDemandAnchorCount: number; + expansionOpportunityCount: number; + excludedCategoryCount: number; + provenanceCounts: Record; + warnings: string[]; + }; +}; + +export type PhrasePositioningAssessment = { + lane: PositioningPortfolioLane; + categoryFit: number; + distinctiveCategoryFit: number; + differentiationFit: number; + expansionFit: number; + mechanismFit: number; + strategicFit: number; + competitorCategoryRisk: number; + matchedCategoryClaims: string[]; + matchedDifferentiatorClaims: string[]; + matchedExpansionClaims: string[]; + matchedMechanismClaims: string[]; + matchedExcludedCategories: string[]; + reasons: string[]; +}; + +const POSITIONING_FUNCTION_WORDS = new Set([ + "a", "an", "and", "by", "for", "from", "in", "of", "on", "or", "the", "to", "with", + "без", "в", "для", "до", "и", "из", "или", "как", "на", "о", "об", "от", "по", "под", "при", "с", "со", "у" +]); + +function unique(values: string[]) { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + +function stableId(prefix: string, value: string) { + return `${prefix}:${crypto.createHash("sha256").update(value).digest("hex").slice(0, 18)}`; +} + +function claim(input: { + prefix: string; + text: string | null | undefined; + provenance: PositioningProvenance; + evidenceRefs?: string[]; + confidence: number; +}): PositioningClaim | null { + const text = input.text?.trim() ?? ""; + if (!text) return null; + + return { + confidence: Math.max(0, Math.min(1, input.confidence)), + evidenceRefs: unique(input.evidenceRefs ?? []), + id: stableId(input.prefix, `${input.provenance}\u0000${text}`), + provenance: input.provenance, + text + }; +} + +function getPortfolioDirections(brief: SeoDemandResearchBriefContract) { + return brief.opportunityPortfolio ?? { + selectedIds: [...brief.selectedCurrentApplications, ...brief.selectedExpansionHypotheses].map((item) => item.id), + selectedCurrentApplications: brief.selectedCurrentApplications, + selectedExpansionHypotheses: brief.selectedExpansionHypotheses + }; +} + +export function getSeoPositioningRevision( + projectId: string, + brief: SeoDemandResearchBriefContract | null, + ownerStrategy: SeoOwnerStrategyInput | null +) { + const portfolio = brief ? getPortfolioDirections(brief) : null; + const ownerRevisionPayload = ownerStrategy + ? { + categoryExclusions: ownerStrategy.categoryExclusions, + expansionPolicy: ownerStrategy.expansionPolicy, + notes: ownerStrategy.notes, + priorityThemes: ownerStrategy.priorityThemes, + referenceAnalogues: ownerStrategy.referenceAnalogues, + strategicStatement: ownerStrategy.strategicStatement + } + : null; + const payload = { + briefContextHash: brief?.contextHash ?? null, + briefState: brief?.state ?? null, + ownerStrategy: ownerRevisionPayload, + projectId, + selectedCurrentApplicationIds: portfolio?.selectedCurrentApplications.map((item) => item.id) ?? [], + selectedExpansionHypothesisIds: portfolio?.selectedExpansionHypotheses.map((item) => item.id) ?? [], + version: "seo-positioning-revision.v1" + }; + + return `positioning:${crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex")}`; +} + +export function buildSeoPositioningThesis( + projectId: string, + brief: SeoDemandResearchBriefContract | null, + ownerStrategy: SeoOwnerStrategyInput | null +): SeoPositioningThesisContract { + const generatedAt = new Date().toISOString(); + const revision = getSeoPositioningRevision(projectId, brief, ownerStrategy); + const provenanceCounts: Record = { + market_evidence: 0, + model_inference: 0, + owner_strategy: 0, + site_evidence: 0 + }; + + if (!brief || brief.state !== "approved") { + return { + categoryArchitecture: { currentCategories: [], differentiatedThemes: [], excludedCategories: [], productMechanisms: [] }, + contextHash: null, + factualIdentity: { brandName: "", centralThesis: null, coreOffer: null, identity: null, productCategory: null }, + generatedAt, + guardPolicy: { + competitorCategoryRequiresExplicitRelation: true, + ownerStrategyIsFactualEvidence: false, + rejectedDirectionMatch: "exact_phrase_or_semantic_conjunction", + rejectOnMetadataOnly: false, + selectedCorePrecedence: true + }, + ownerStrategy, + portfolios: { + currentDemand: { categoryAnchors: [], differentiatedAnchors: [], mechanismAnchors: [] }, + expansionOpportunities: [] + }, + projectId, + revision, + readiness: { + currentDemandAnchorCount: 0, + excludedCategoryCount: ownerStrategy?.categoryExclusions.length ?? 0, + expansionOpportunityCount: 0, + hasFactualCore: false, + hasOwnerStrategy: Boolean(ownerStrategy), + provenanceCounts, + warnings: ["Positioning Thesis ждёт approved Demand Research Brief."] + }, + schemaVersion: "seo-positioning-thesis.v1", + state: "missing_context", + strategicIntent: { + expansionPolicy: ownerStrategy?.expansionPolicy ?? "balanced", + note: "Owner strategy хранится отдельно от factual evidence.", + priorityThemes: [], + referenceAnalogues: [], + statement: null + } + }; + } + + const coreEvidenceRefs = unique([ + ...(brief.offerMap?.evidenceRefs ?? []), + ...brief.fixedCorePillars.flatMap((pillar) => pillar.evidenceRefs) + ]); + const identity = claim({ confidence: 0.82, evidenceRefs: coreEvidenceRefs, prefix: "identity", provenance: "site_evidence", text: brief.coreContext.identity }); + const productCategory = claim({ confidence: 0.82, evidenceRefs: coreEvidenceRefs, prefix: "category", provenance: "site_evidence", text: brief.coreContext.productCategory }); + const coreOffer = claim({ confidence: 0.86, evidenceRefs: coreEvidenceRefs, prefix: "offer", provenance: "site_evidence", text: brief.coreContext.coreOffer }); + const centralThesis = claim({ confidence: 0.86, evidenceRefs: coreEvidenceRefs, prefix: "thesis", provenance: "site_evidence", text: brief.coreContext.centralThesis }); + const currentCategories = unique([ + brief.coreContext.productCategory, + brief.coreContext.coreOffer, + ...(brief.offerMap?.marketCategories ?? []), + ...(brief.searchScope?.vectors ?? []) + .filter((vector) => vector.relationshipToCore === "core_facet" || vector.source === "core_offer" || vector.source === "core_pillar") + .map((vector) => vector.title) + ]).map((text) => claim({ confidence: 0.82, evidenceRefs: coreEvidenceRefs, prefix: "current-category", provenance: "site_evidence", text })) + .filter((item): item is PositioningClaim => Boolean(item)); + const differentiatedThemes = unique([ + brief.coreContext.centralThesis, + ...(brief.offerMap?.differentiators ?? []), + ...brief.fixedCorePillars.map((pillar) => pillar.whyItMatters) + ]).map((text) => claim({ confidence: 0.8, evidenceRefs: coreEvidenceRefs, prefix: "differentiator", provenance: "site_evidence", text })) + .filter((item): item is PositioningClaim => Boolean(item)); + const productMechanisms = brief.fixedCorePillars + .map((pillar) => claim({ + confidence: pillar.grounding === "explicit" ? 0.9 : pillar.grounding === "inferred" ? 0.65 : 0.42, + evidenceRefs: pillar.evidenceRefs, + prefix: "mechanism", + provenance: "site_evidence", + text: pillar.title + })) + .filter((item): item is PositioningClaim => Boolean(item)); + const excludedCategories = (ownerStrategy?.categoryExclusions ?? []) + .map((text) => claim({ confidence: 1, prefix: "excluded-category", provenance: "owner_strategy", text })) + .filter((item): item is PositioningClaim => Boolean(item)); + const priorityThemes = (ownerStrategy?.priorityThemes ?? []) + .map((text) => claim({ confidence: 1, prefix: "priority-theme", provenance: "owner_strategy", text })) + .filter((item): item is PositioningClaim => Boolean(item)); + const statement = claim({ confidence: 1, prefix: "strategic-statement", provenance: "owner_strategy", text: ownerStrategy?.strategicStatement }); + const referenceAnalogues = (ownerStrategy?.referenceAnalogues ?? []).map((analogue) => ({ + caveat: analogue.caveat, + label: analogue.label, + transferableTraits: analogue.transferableTraits + .map((text) => claim({ confidence: 1, prefix: `analogue:${analogue.label}`, provenance: "owner_strategy", text })) + .filter((item): item is PositioningClaim => Boolean(item)) + })); + const expansionOpportunities = getPortfolioDirections(brief).selectedExpansionHypotheses.map((direction) => ({ + buyerJob: direction.jobToBeDone, + capabilityIds: direction.reusedCapabilityIds, + evidenceRefs: unique(direction.evidenceRefs), + id: direction.id, + marketContext: [direction.targetSector, direction.buyer].filter(Boolean).join(" · "), + provenance: "model_inference" as const, + queryHypotheses: unique(direction.expectedSearchLanguage), + requiredAdaptations: unique(direction.requiredAdaptations), + title: direction.title + })); + + for (const item of [identity, productCategory, coreOffer, centralThesis, ...currentCategories, ...differentiatedThemes, ...productMechanisms, ...excludedCategories, ...priorityThemes, statement, ...referenceAnalogues.flatMap((analogue) => analogue.transferableTraits)]) { + if (item) provenanceCounts[item.provenance] += 1; + } + provenanceCounts.model_inference += expansionOpportunities.length; + + const currentDemandAnchorCount = currentCategories.length + differentiatedThemes.length + productMechanisms.length; + const warnings = [ + ...(!ownerStrategy ? ["Стратегическое намерение владельца не задано; система опирается только на factual core и model hypotheses."] : []), + ...(currentCategories.length === 0 ? ["Не сформирована рыночная категория текущего продукта."] : []), + ...(differentiatedThemes.length === 0 ? ["Не сформированы доказанные дифференциаторы текущего продукта."] : []) + ]; + + return { + categoryArchitecture: { currentCategories, differentiatedThemes, excludedCategories, productMechanisms }, + contextHash: brief.contextHash, + factualIdentity: { + brandName: brief.coreContext.brandName, + centralThesis, + coreOffer, + identity, + productCategory + }, + generatedAt, + guardPolicy: { + competitorCategoryRequiresExplicitRelation: true, + ownerStrategyIsFactualEvidence: false, + rejectedDirectionMatch: "exact_phrase_or_semantic_conjunction", + rejectOnMetadataOnly: false, + selectedCorePrecedence: true + }, + ownerStrategy, + portfolios: { + currentDemand: { + categoryAnchors: currentCategories, + differentiatedAnchors: differentiatedThemes, + mechanismAnchors: productMechanisms + }, + expansionOpportunities + }, + projectId, + revision, + readiness: { + currentDemandAnchorCount, + excludedCategoryCount: excludedCategories.length, + expansionOpportunityCount: expansionOpportunities.length, + hasFactualCore: currentDemandAnchorCount > 0, + hasOwnerStrategy: Boolean(ownerStrategy), + provenanceCounts, + warnings + }, + schemaVersion: "seo-positioning-thesis.v1", + state: "ready", + strategicIntent: { + expansionPolicy: ownerStrategy?.expansionPolicy ?? "balanced", + note: "Owner strategy задаёт направление и приоритет гипотез, но не считается site/market evidence.", + priorityThemes, + referenceAnalogues, + statement + } + }; +} + +export async function getSeoPositioningThesis(projectId: string) { + const [brief, ownerStrategy] = await Promise.all([ + getLatestSeoDemandResearchBrief(projectId), + getLatestSeoOwnerStrategyInput(projectId) + ]); + + return buildSeoPositioningThesis(projectId, brief, ownerStrategy); +} + +function normalizeText(value: string) { + return value + .trim() + .toLocaleLowerCase("ru-RU") + .replace(/ё/g, "е") + .replace(/[^a-zа-я0-9]+/gi, " ") + .replace(/\s+/g, " "); +} + +function stemToken(value: string) { + const normalized = normalizeText(value); + if (!normalized || POSITIONING_FUNCTION_WORDS.has(normalized)) return null; + if (normalized === "ai" || normalized === "ии") return "ai"; + if (normalized.length <= 3) return normalized; + + return normalized + .replace(/(иями|ями|ами|ого|его|ому|ему|ыми|ими|иях|ях|ах|ией|иям|ям|ам|овать|ировать|изация|изации|ация|ации|ение|ения|енный|енная|енные|ного|ному|ными|ов|ев|ей|ой|ый|ий|ая|яя|ое|ее|ые|ие|ую|юю|ом|ем|а|я|ы|и|у|ю|е|о)$/iu, "") + .slice(0, 12); +} + +function roots(value: string) { + return new Set( + (normalizeText(value).match(/[a-zа-я0-9]+/gi) ?? []) + .map(stemToken) + .filter((token): token is string => Boolean(token && token.length >= 2)) + ); +} + +const GENERIC_POSITIONING_ROOTS = [ + "автоматиз", "бизнес", "данн", "задач", "компан", "организац", "предприят", "процесс", "программ", "платформ", "решен", "сервис", "систем", "технолог", "управлен", + "ai", "automation", "business", "company", "data", "enterprise", "management", "platform", "process", "service", "solution", "system", "technology" +]; + +function isGenericPositioningRoot(value: string) { + return GENERIC_POSITIONING_ROOTS.some((root) => value.startsWith(root) || root.startsWith(value)); +} + +function claimMatches( + phraseRoots: Set, + phraseText: string, + candidate: PositioningClaim, + requireDistinctiveOverlap = false +) { + const candidateText = normalizeText(candidate.text); + if (!candidateText) return false; + if (phraseText.includes(candidateText)) return true; + const candidateRoots = roots(candidate.text); + if (candidateRoots.size === 0) return false; + const overlappingRoots = [...candidateRoots].filter((root) => phraseRoots.has(root)); + const overlap = overlappingRoots.length; + const required = candidateRoots.size === 1 ? 1 : Math.min(2, candidateRoots.size); + return overlap >= required && (!requireDistinctiveOverlap || overlappingRoots.some((root) => !isGenericPositioningRoot(root))); +} + +function scoreMatches( + phraseRoots: Set, + phraseText: string, + claims: PositioningClaim[], + requireDistinctiveOverlap = false +) { + const matched = claims.filter((item) => claimMatches(phraseRoots, phraseText, item, requireDistinctiveOverlap)); + if (claims.length === 0 || matched.length === 0) return { matched, score: 0 }; + const confidence = Math.max(...matched.map((item) => item.confidence)); + return { matched, score: Math.min(1, 0.52 + matched.length * 0.14 + confidence * 0.2) }; +} + +export function assessPhrasePositioning( + phrase: string, + thesis: SeoPositioningThesisContract | null | undefined +): PhrasePositioningAssessment { + if (!thesis || thesis.state !== "ready") { + return { + categoryFit: 0, + competitorCategoryRisk: 0, + distinctiveCategoryFit: 0, + differentiationFit: 0, + expansionFit: 0, + lane: "unclassified", + matchedCategoryClaims: [], + matchedDifferentiatorClaims: [], + matchedExpansionClaims: [], + matchedExcludedCategories: [], + matchedMechanismClaims: [], + mechanismFit: 0, + reasons: ["Positioning Thesis недоступен."], + strategicFit: 0 + }; + } + + const phraseText = normalizeText(phrase); + const phraseRoots = roots(phrase); + const category = scoreMatches(phraseRoots, phraseText, thesis.categoryArchitecture.currentCategories); + const distinctiveCategory = scoreMatches(phraseRoots, phraseText, thesis.categoryArchitecture.currentCategories, true); + const differentiator = scoreMatches(phraseRoots, phraseText, thesis.categoryArchitecture.differentiatedThemes, true); + const broadDifferentiator = scoreMatches(phraseRoots, phraseText, thesis.categoryArchitecture.differentiatedThemes); + const mechanism = scoreMatches(phraseRoots, phraseText, thesis.categoryArchitecture.productMechanisms); + const strategicClaims = [ + ...thesis.strategicIntent.priorityThemes, + ...thesis.strategicIntent.referenceAnalogues.flatMap((analogue) => analogue.transferableTraits) + ]; + const strategic = scoreMatches(phraseRoots, phraseText, strategicClaims, true); + const broadStrategic = scoreMatches(phraseRoots, phraseText, strategicClaims); + const expansionClaims = thesis.portfolios.expansionOpportunities + .flatMap((opportunity) => + unique([ + opportunity.title, + opportunity.buyerJob ?? "", + opportunity.marketContext, + ...opportunity.queryHypotheses + ]).map((text) => + claim({ + confidence: 0.58, + evidenceRefs: opportunity.evidenceRefs, + prefix: `expansion:${opportunity.id}`, + provenance: "model_inference", + text + }) + ) + ) + .filter((item): item is PositioningClaim => Boolean(item)); + const expansion = scoreMatches(phraseRoots, phraseText, expansionClaims, true); + const broadExpansion = scoreMatches(phraseRoots, phraseText, expansionClaims); + const excluded = scoreMatches(phraseRoots, phraseText, thesis.categoryArchitecture.excludedCategories); + let lane: PositioningPortfolioLane = "unclassified"; + + const groundedStrategicDifferentiation = strategic.score > 0 && (distinctiveCategory.score > 0 || mechanism.score > 0); + + if (excluded.score > 0) lane = "competitor_comparison"; + else if (differentiator.score > 0 || groundedStrategicDifferentiation) lane = "differentiated_core"; + else if (mechanism.score > 0) lane = "product_mechanism"; + else if (category.score > 0) lane = "category_core"; + else if (expansion.score > 0 || strategic.score > 0) lane = "expansion_hypothesis"; + else if (broadDifferentiator.score > 0 || broadStrategic.score > 0 || broadExpansion.score > 0) lane = "content_support"; + + const reasons = [ + ...(category.matched.length > 0 ? [`Совпадает с текущей категорией: ${category.matched.map((item) => item.text).join("; ")}.`] : []), + ...(differentiator.matched.length > 0 ? [`Сохраняет дифференциатор: ${differentiator.matched.map((item) => item.text).join("; ")}.`] : []), + ...(mechanism.matched.length > 0 ? [`Опирается на продуктовый механизм: ${mechanism.matched.map((item) => item.text).join("; ")}.`] : []), + ...(strategic.matched.length > 0 ? [`Поддерживает owner strategy: ${strategic.matched.map((item) => item.text).join("; ")}.`] : []), + ...(groundedStrategicDifferentiation ? ["Стратегический приоритет опирается на доказанную текущую категорию или продуктовый механизм; это Current Demand, а не owner-only aspiration."] : []), + ...(expansion.matched.length > 0 ? [`Совпадает с выбранной expansion-гипотезой: ${expansion.matched.map((item) => item.text).join("; ")}.`] : []), + ...(lane === "expansion_hypothesis" ? [ + thesis.strategicIntent.expansionPolicy === "core_only" + ? "Гипотеза распознана, но core_only policy запрещает её автоматическое продвижение в Current Demand." + : "Owner/model hypothesis остаётся отдельным Expansion Opportunity до evidence и human gate." + ] : []), + ...(lane === "content_support" ? ["Совпадение опирается только на общие категорийные слова; сохраняем как supporting demand, не как дифференциатор."] : []), + ...(excluded.matched.length > 0 ? [`Попадает в исключённую/конкурирующую категорию: ${excluded.matched.map((item) => item.text).join("; ")}.`] : []), + ...(lane === "unclassified" ? ["Фраза не получила достаточной опоры в Positioning Thesis."] : []) + ]; + + return { + categoryFit: Number(category.score.toFixed(2)), + competitorCategoryRisk: Number(excluded.score.toFixed(2)), + distinctiveCategoryFit: Number(distinctiveCategory.score.toFixed(2)), + differentiationFit: Number(differentiator.score.toFixed(2)), + expansionFit: Number(Math.max(expansion.score, strategic.score).toFixed(2)), + lane, + matchedCategoryClaims: category.matched.map((item) => item.text), + matchedDifferentiatorClaims: differentiator.matched.map((item) => item.text), + matchedExpansionClaims: expansion.matched.map((item) => item.text), + matchedExcludedCategories: excluded.matched.map((item) => item.text), + matchedMechanismClaims: mechanism.matched.map((item) => item.text), + mechanismFit: Number(mechanism.score.toFixed(2)), + reasons, + strategicFit: Number(strategic.score.toFixed(2)) + }; +} + +export function hasExplicitCompetitorRelation( + phrase: string, + assessment: Pick +) { + if (assessment.matchedExcludedCategories.length === 0) return false; + + const normalized = normalizeText(phrase); + return /(?:^|\s)(?:альтернатив\p{L}*|аналог\p{L}*|вместо|миграци\p{L}*|переход\p{L}*|против|сравнен\p{L}*|совместим\p{L}*|интеграц\p{L}*|интегрир\p{L}*|подключен\p{L}*|compare|comparison|integration|migration|versus|vs)(?:\s|$)/iu.test(normalized); +} diff --git a/seo_mode/seo_mode/server/src/contextReview/seoContextReview.ts b/seo_mode/seo_mode/server/src/contextReview/seoContextReview.ts index 1be76bf..e691693 100644 --- a/seo_mode/seo_mode/server/src/contextReview/seoContextReview.ts +++ b/seo_mode/seo_mode/server/src/contextReview/seoContextReview.ts @@ -1,6 +1,8 @@ -import { pool } from "../db/client.js"; +import { pool, type DatabaseExecutor } from "../db/client.js"; import { getLatestSemanticAnalysis, type SemanticAnalysisRun } from "../analysis/semanticAnalysis.js"; import { buildSeoContextReviewTask } from "../skills/seoSkillRegistry.js"; +import type { SeoOpportunityDiscoveryContract } from "../opportunity/seoOpportunityDiscovery.js"; +import type { SeoOpportunityCriticContract } from "../opportunity/seoOpportunityCritic.js"; type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; @@ -142,6 +144,66 @@ export type SeoContextReviewRun = SeoContextReviewOutput & { createdAt: string; }; +export type SeoProjectContextContract = { + schemaVersion: "seo-project-context.v1"; + projectId: string; + generatedAt: string; + state: "missing_analysis" | "missing_review" | "stale_review" | "needs_review" | "ready"; + semanticRun: { + runId: string; + scanVersionId: string; + generatedAt: string; + } | null; + contextReview: { + runId: string; + semanticRunId: string; + providerMode: SeoContextReviewProviderMode; + analystStatus: SeoContextReviewOutput["analystReview"]["status"]; + generatedAt: string; + } | null; + evidenceIntegrity: { + canonicalRefCount: number; + referencedRefCount: number; + validRefCount: number; + invalidRefs: string[]; + confirmedClaimCount: number; + groundedClaimCount: number; + invalidPageRoleIds: string[]; + siteIdentityGrounded: boolean; + }; + nextStage: { + allowed: boolean; + blockers: Array<{ + code: + | "analysis_missing" + | "review_missing" + | "review_stale" + | "model_review_missing" + | "analyst_approval_missing" + | "evidence_invalid" + | "page_roles_invalid" + | "claims_ungrounded"; + message: string; + }>; + }; +}; + +export class SeoContextReviewError extends Error { + constructor(message: string) { + super(message); + this.name = "SeoContextReviewError"; + } +} + +export function isSeoContextReviewApproved(contextReview: SeoContextReviewRun | null | undefined) { + return Boolean( + contextReview && + contextReview.provider.mode !== "deterministic_fallback" && + contextReview.analystReview.status === "approved" && + !contextReview.needsHumanReview + ); +} + function toIsoDate(value: Date | null) { return value ? value.toISOString() : null; } @@ -185,6 +247,24 @@ function asString(value: unknown): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } +function asStringValue(value: unknown): string | null { + const direct = asString(value); + + if (direct) { + return direct; + } + + const record = asRecord(value); + const recordValue = asString(record.value); + + if (recordValue) { + return recordValue; + } + + const arrayValue = asStringArray(record.value); + return arrayValue.length > 0 ? arrayValue.join(" · ") : null; +} + function asStringArray(value: unknown): string[] { return asArray(value).map(asString).filter((item): item is string => Boolean(item)); } @@ -217,7 +297,7 @@ function normalizeEvidenceRefs(value: unknown): string[] { } const record = asRecord(item); - const sourcePath = asString(record.sourcePath) ?? asString(record.ref) ?? asString(record.id); + const sourcePath = asString(record.id) ?? asString(record.ref) ?? asString(record.sourcePath); const reason = asString(record.reason) ?? asString(record.title); return [sourcePath, reason].filter(Boolean).join(" · ") || `evidence:${index + 1}`; @@ -226,6 +306,10 @@ function normalizeEvidenceRefs(value: unknown): string[] { .slice(0, 24); } +function mergeEvidenceRefs(...values: unknown[]): string[] { + return [...new Set(values.flatMap((value) => normalizeEvidenceRefs(value)))].slice(0, 24); +} + function normalizeContextReviewShape( projectId: string, output: SeoContextReviewOutput, @@ -235,20 +319,68 @@ function normalizeContextReviewShape( const rawSiteIdentity = asRecord(rawOutput.siteIdentity); const rawOffer = asRecord(rawOutput.offer); const rawAudience = asRecord(rawOutput.audience); - const summary = asString(rawOutput.summary) ?? "Context Review model output сохранен после backend normalization."; + const rawSummary = asRecord(rawOutput.summary); + const rawBusinessType = asRecord(rawSiteIdentity.businessType); + const rawCoreSubject = asRecord(rawSiteIdentity.coreSubject); + const rawPrimaryOffer = asRecord(rawOffer.primaryOffer); + const rawCommercialAction = asRecord(rawOffer.commercialAction); + const rawPrimaryAudiences = asArray(rawAudience.primaryAudience).map(asRecord); + const rawHumanReviewItems = asArray(rawOutput.needsHumanReview).map(asRecord); + const summary = + asString(rawOutput.summary) ?? + asString(rawSummary.projectContext) ?? + asString(rawSummary.criticConclusion) ?? + "Context Review model output сохранен после backend normalization."; + const identityParts = [asStringValue(rawBusinessType), asStringValue(rawCoreSubject)].filter( + (item): item is string => Boolean(item) + ); const siteIdentityDescription = - asString(rawSiteIdentity.description) ?? asString(rawSiteIdentity.identitySummary) ?? summary; - const offerSummary = asString(rawOffer.summary) ?? asString(rawOffer.primaryOffer) ?? summary; + asString(rawSiteIdentity.description) ?? + asString(rawSiteIdentity.identitySummary) ?? + (identityParts.length > 0 ? identityParts.join(". ") : null) ?? + summary; + const offerSummary = asString(rawOffer.summary) ?? asStringValue(rawPrimaryOffer) ?? summary; + const primaryAudienceSegments = rawPrimaryAudiences + .map((audience) => asString(audience.segment) ?? asString(audience.value) ?? asString(audience.title)) + .filter((item): item is string => Boolean(item)); const audiencePrimary = asString(rawAudience.primary) ?? asStringArray(rawAudience.primaryAudiences)[0] ?? asStringArray(rawAudience.decisionMakers)[0] ?? + primaryAudienceSegments[0] ?? "Целевая аудитория требует ручной проверки."; const providerReadiness = asRecord(rawOutput.readiness); - const needsHumanReview = asBoolean( - rawOutput.needsHumanReview, - asBoolean(providerReadiness.needsHumanReview, true) - ); + const needsHumanReview = Array.isArray(rawOutput.needsHumanReview) + ? rawHumanReviewItems.length > 0 + : asBoolean(rawOutput.needsHumanReview, asBoolean(providerReadiness.needsHumanReview, true)); + const primaryOfferValue = asStringValue(rawPrimaryOffer); + const primaryOfferClaim = primaryOfferValue + ? [{ + confidence: normalizeConfidence(rawPrimaryOffer.confidence, "medium"), + evidenceRefs: normalizeEvidenceRefs(rawPrimaryOffer.evidenceRefs), + id: asString(rawPrimaryOffer.id) ?? "claim.primary_offer", + title: primaryOfferValue + }] + : []; + const platformLayerClaims = asArray(rawOffer.platformLayers) + .map((layer, index) => { + const record = asRecord(layer); + const name = asString(record.name) ?? asString(record.title); + const role = asString(record.role) ?? asString(record.value); + + if (!name && !role) { + return null; + } + + return { + confidence: normalizeConfidence(record.confidence, "medium"), + evidenceRefs: normalizeEvidenceRefs(record.evidenceRefs), + id: asString(record.id) ?? `claim.platform_layer.${index + 1}`, + title: [name, role].filter(Boolean).join(": ") + }; + }) + .filter((claim): claim is NonNullable => Boolean(claim)) + .slice(0, 8); const confirmedClaims = asArray(rawOffer.confirmedClaims).length > 0 ? asArray(rawOffer.confirmedClaims).map((claim, index) => { @@ -261,22 +393,24 @@ function normalizeContextReviewShape( title: asString(record.title) ?? asString(record.claim) ?? `Подтвержденный смысл ${index + 1}` }; }) - : [ - { - confidence: normalizeConfidence(rawOffer.confidence, "medium"), - evidenceRefs: normalizeEvidenceRefs(rawOffer.evidenceRefs), - id: "claim.primary_offer", - title: offerSummary - }, - ...asStringArray(rawOffer.supportingCapabilities) - .slice(0, 8) - .map((title, index) => ({ + : primaryOfferClaim.length > 0 || platformLayerClaims.length > 0 + ? [...primaryOfferClaim, ...platformLayerClaims] + : [ + { confidence: normalizeConfidence(rawOffer.confidence, "medium"), evidenceRefs: normalizeEvidenceRefs(rawOffer.evidenceRefs), - id: `claim.capability.${index + 1}`, - title - })) - ]; + id: "claim.primary_offer", + title: offerSummary + }, + ...asStringArray(rawOffer.supportingCapabilities) + .slice(0, 8) + .map((title, index) => ({ + confidence: normalizeConfidence(rawOffer.confidence, "medium"), + evidenceRefs: normalizeEvidenceRefs(rawOffer.evidenceRefs), + id: `claim.capability.${index + 1}`, + title + })) + ]; const pageRoles = asArray(rawOutput.pageRoles).map((role, index) => { const record = asRecord(role); const rawRole = asString(record.role); @@ -295,10 +429,10 @@ function normalizeContextReviewShape( evidenceRefs: normalizeEvidenceRefs(record.evidenceRefs), pageId: asString(record.pageId) ?? asString(record.id) ?? `page.${index + 1}`, path: asString(record.path) ?? asString(record.urlPath), - reason: asString(record.reason) ?? asString(record.risk) ?? "Model provider page role.", + reason: asString(record.reason) ?? asString(record.risk) ?? asString(record.role) ?? "Model provider page role.", role: normalizedRole, scope: asString(record.scope) ?? "unknown", - title: asString(record.title) ?? `Страница ${index + 1}` + title: asString(record.title) ?? asString(record.urlPath) ?? `Страница ${index + 1}` }; }); const sectionFindings = asArray(rawOutput.sectionFindings).map((finding, index) => { @@ -314,9 +448,13 @@ function normalizeContextReviewShape( const normalizedStatus: SeoContextReviewOutput["sectionFindings"][number]["status"] = status === "covered" || status === "weak" || status === "missing" ? status - : asBoolean(record.needsHumanReview) + : status === "supported_with_caution" || status === "needs_human_review" ? "weak" - : "covered"; + : status === "supported" + ? "covered" + : asBoolean(record.needsHumanReview) + ? "weak" + : "covered"; return { confidence: normalizeConfidence(record.confidence), @@ -338,7 +476,11 @@ function normalizeContextReviewShape( id: asString(record.id) ?? `gap.${index + 1}`, priority: normalizedPriority, reason: asString(record.reason) ?? asString(record.description) ?? "Нужна ручная проверка.", - suggestedReview: asString(record.suggestedReview) ?? "Проверить перед отправкой в спрос.", + suggestedReview: + asString(record.suggestedReview) ?? + (asString(record.status) === "needs_human_review" + ? "Подтвердить вручную перед отправкой в спрос." + : "Проверить перед отправкой в спрос."), title: asString(record.title) ?? `Семантический пробел ${index + 1}` }; }); @@ -357,6 +499,7 @@ function normalizeContextReviewShape( }); const normalizationHints = asArray(rawOutput.normalizationHints).map((hint, index) => { const record = asRecord(hint); + const normalizedValue = asStringValue(record); return { clusterId: asString(record.clusterId) ?? asString(record.id) ?? `hint.${index + 1}`, @@ -366,8 +509,8 @@ function normalizeContextReviewShape( reviewRequired: asBoolean(record.reviewRequired, true), sourcePhrases: asStringArray(record.sourcePhrases), stopPhrases: asStringArray(record.stopPhrases), - targetIntent: asString(record.targetIntent) ?? asString(record.title) ?? "Проверить нормализацию.", - title: asString(record.title) ?? `Подсказка нормализации ${index + 1}` + targetIntent: asString(record.targetIntent) ?? normalizedValue ?? "Проверить нормализацию.", + title: asString(record.title) ?? asString(record.type) ?? `Подсказка нормализации ${index + 1}` }; }); const risks = asArray(rawOutput.risks).map((risk, index) => { @@ -376,7 +519,7 @@ function normalizeContextReviewShape( return { id: asString(record.id) ?? `risk.${index + 1}`, level: normalizeReviewLevel(record.level), - message: asString(record.message) ?? asString(record.reason) ?? "Risk from model provider.", + message: asString(record.message) ?? asString(record.description) ?? asString(record.reason) ?? "Risk from model provider.", title: asString(record.title) ?? `Риск ${index + 1}` }; }); @@ -385,10 +528,12 @@ function normalizeContextReviewShape( ...output, analystReview: output.analystReview ?? getDefaultAnalystReview(), audience: { - confidence: normalizeConfidence(rawAudience.confidence), + confidence: normalizeConfidence(rawAudience.confidence ?? rawPrimaryAudiences[0]?.confidence), primary: audiencePrimary, - reason: asString(rawAudience.reason) ?? "Audience normalized from model provider output.", - secondary: asStringArray(rawAudience.secondary) + reason: + asString(rawAudience.reason) ?? + (asStringArray(rawAudience.audienceCautions).join(" ") || "Audience normalized from model provider output."), + secondary: [...new Set([...asStringArray(rawAudience.secondary), ...primaryAudienceSegments.slice(1)])].slice(0, 8) }, forbiddenClaims, needsHumanReview, @@ -396,21 +541,45 @@ function normalizeContextReviewShape( offer: { confirmedClaims, summary: offerSummary, - weakClaims: asArray(rawOffer.weakClaims).map((claim, index) => { - const record = asRecord(claim); + weakClaims: [ + ...asArray(rawOffer.weakClaims).map((claim, index) => { + const record = asRecord(claim); - return { - id: asString(record.id) ?? `weak_claim.${index + 1}`, - reason: asString(record.reason) ?? "Нужна проверка доказательности.", - title: asString(record.title) ?? asString(record.claim) ?? `Слабое утверждение ${index + 1}` - }; - }) + return { + id: asString(record.id) ?? `weak_claim.${index + 1}`, + reason: asString(record.reason) ?? "Нужна проверка доказательности.", + title: asString(record.title) ?? asString(record.claim) ?? `Слабое утверждение ${index + 1}` + }; + }), + ...(asStringValue(rawCommercialAction) && asString(rawCommercialAction.status) !== "supported" + ? [{ + id: asString(rawCommercialAction.id) ?? "weak_claim.commercial_action", + reason: "Коммерческое действие описано, но условия требуют ручного подтверждения.", + title: asStringValue(rawCommercialAction) as string + }] + : []) + ] }, pageRoles, projectId: asString(rawOutput.projectId) ?? projectId, - projectOntologyVersionId: asString(rawOutput.projectOntologyVersionId) ?? fallback?.projectOntologyVersionId ?? null, + projectOntologyVersionId: + asString(rawOutput.projectOntologyVersionId) ?? + asString(asRecord(rawOutput.source).projectOntologyVersionId) ?? + fallback?.projectOntologyVersionId ?? + null, readiness: { - evidenceRefCount: Number(providerReadiness.evidenceRefCount) || normalizeEvidenceRefs(rawSiteIdentity.evidenceRefs).length, + evidenceRefCount: + Number(providerReadiness.evidenceRefCount) || + mergeEvidenceRefs( + rawSiteIdentity.evidenceRefs, + rawBusinessType.evidenceRefs, + rawCoreSubject.evidenceRefs, + rawPrimaryOffer.evidenceRefs, + rawSummary.keyEvidenceRefs, + ...confirmedClaims.map((claim) => claim.evidenceRefs), + ...pageRoles.map((role) => role.evidenceRefs), + ...sectionFindings.map((finding) => finding.evidenceRefs) + ).length, forbiddenClaimCount: forbiddenClaims.length, needsHumanReview, normalizationHintCount: normalizationHints.length, @@ -425,12 +594,27 @@ function normalizeContextReviewShape( semanticRunId: asString(rawOutput.semanticRunId) ?? fallback?.semanticRunId ?? "", siteIdentity: { brandName: asString(rawSiteIdentity.brandName) ?? "Проект", - confidence: normalizeConfidence(rawSiteIdentity.confidence), + confidence: normalizeConfidence(rawSiteIdentity.confidence ?? rawCoreSubject.confidence ?? rawBusinessType.confidence), description: siteIdentityDescription, - evidenceRefs: normalizeEvidenceRefs(rawSiteIdentity.evidenceRefs) + evidenceRefs: mergeEvidenceRefs( + rawSiteIdentity.evidenceRefs, + rawBusinessType.evidenceRefs, + rawCoreSubject.evidenceRefs, + rawSummary.keyEvidenceRefs + ) }, - sourceTaskId: asString(rawOutput.sourceTaskId) ?? fallback?.sourceTaskId ?? "", - summary + sourceTaskId: + asString(rawOutput.sourceTaskId) ?? + asString(asRecord(rawOutput.modelTask).sourceTaskId) ?? + fallback?.sourceTaskId ?? + "", + summary, + nextActions: [ + ...asStringArray(rawOutput.nextActions), + ...rawHumanReviewItems + .map((item) => asString(item.question) ?? asString(item.title)) + .filter((item): item is string => Boolean(item)) + ] }; } @@ -475,14 +659,15 @@ function normalizeContextReviewOutput( mode: providerMode, modelRequired: true }, - analystReview: normalizedShape.analystReview ?? getDefaultAnalystReview(), + analystReview: getDefaultAnalystReview(), + needsHumanReview: true, readiness: { ...normalizedShape.readiness, forbiddenClaimCount: normalizedShape.forbiddenClaims.length, normalizationHintCount: normalizedShape.normalizationHints.length, pageRoleCount: normalizedShape.pageRoles.length, semanticGapCount: normalizedShape.semanticGaps.length, - needsHumanReview: normalizedShape.needsHumanReview + needsHumanReview: true } }; } @@ -523,6 +708,73 @@ function uniq(values: string[]) { return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); } +function getContextAllowedEvidenceRefs(analysis: SemanticAnalysisRun) { + const task = buildSeoContextReviewTask(analysis.projectId, analysis); + + return new Set([ + ...task.input.evidenceRefs.map((ref) => ref.id), + ...task.input.siteTextEvidence.chunks.map((chunk) => chunk.id), + ...task.input.knownRisks.map((risk) => risk.id), + ...task.input.semanticSignals.map((signal) => signal.id), + ...task.input.sectionSignals.map((section) => section.id), + ...task.input.evidenceHierarchy.groups.map((group) => group.id), + ...analysis.pages.flatMap((page) => [page.pageId, `page:${page.pageId}`]), + ...analysis.pages.flatMap((page) => page.matchedClusters.map((clusterId) => `${clusterId}:${page.pageId}`)), + ...analysis.contentSources.flatMap((source) => [source.id, `content:${source.id}`]), + ...analysis.clusters.map((cluster) => `cluster:${cluster.id}`) + ]); +} + +function sanitizeContextReviewEvidence( + output: SeoContextReviewOutput, + analysis: SemanticAnalysisRun +): SeoContextReviewOutput { + const allowedRefs = getContextAllowedEvidenceRefs(analysis); + const sanitize = (refs: string[]) => uniq(refs).filter((ref) => allowedRefs.has(ref)); + const pageRoleEvidence = (role: SeoContextReviewOutput["pageRoles"][number]) => { + const sanitized = sanitize(role.evidenceRefs); + const pageFallback = `page:${role.pageId}`; + + return sanitized.length > 0 || !allowedRefs.has(pageFallback) ? sanitized : [pageFallback]; + }; + const sanitizedOutput: SeoContextReviewOutput = { + ...output, + siteIdentity: { + ...output.siteIdentity, + evidenceRefs: sanitize(output.siteIdentity.evidenceRefs) + }, + offer: { + ...output.offer, + confirmedClaims: output.offer.confirmedClaims.map((claim) => ({ + ...claim, + evidenceRefs: sanitize(claim.evidenceRefs) + })) + }, + pageRoles: output.pageRoles.map((role) => ({ + ...role, + evidenceRefs: pageRoleEvidence(role) + })), + sectionFindings: output.sectionFindings.map((finding) => ({ + ...finding, + evidenceRefs: sanitize(finding.evidenceRefs) + })) + }; + const referencedRefs = uniq([ + ...sanitizedOutput.siteIdentity.evidenceRefs, + ...sanitizedOutput.offer.confirmedClaims.flatMap((claim) => claim.evidenceRefs), + ...sanitizedOutput.pageRoles.flatMap((role) => role.evidenceRefs), + ...sanitizedOutput.sectionFindings.flatMap((finding) => finding.evidenceRefs) + ]); + + return { + ...sanitizedOutput, + readiness: { + ...sanitizedOutput.readiness, + evidenceRefCount: referencedRefs.length + } + }; +} + function buildContextReviewOutput(projectId: string, analysis: SemanticAnalysisRun): SeoContextReviewOutput { const task = buildSeoContextReviewTask(projectId, analysis); const highPriorityClusters = analysis.clusters @@ -627,12 +879,13 @@ function buildContextReviewOutput(projectId: string, analysis: SemanticAnalysisR confidence: getConfidence(cluster.evidence.score), evidenceRefs: getEvidenceIds(cluster) })); - const needsHumanReview = + const hasDetectedReviewRisk = analysis.analysisVerdict.confidence !== "high" || weakClaims.length > 0 || risks.some((risk) => risk.level === "problem") || forbiddenClaims.length > 2 || normalizationHints.some((hint) => hint.reviewRequired); + const needsHumanReview = true; return { schemaVersion: "seo-context-review.v1", @@ -696,9 +949,9 @@ function buildContextReviewOutput(projectId: string, analysis: SemanticAnalysisR normalizationHints, risks, needsHumanReview, - summary: needsHumanReview + summary: hasDetectedReviewRisk ? "Context Review сохранен как draft evidence: есть неоднозначности, которые нельзя отправлять в Wordstat/rewrite без review." - : "Context Review сохранен как draft evidence: критичных semantic blockers fallback не нашел.", + : "Context Review сохранен как deterministic draft: критичных blockers не найдено, но model/human review всё равно обязателен.", nextActions: [ "Передать `seo.context_review` task в ModelProvider и сравнить с fallback output.", "Использовать forbiddenClaims и normalizationHints как вход `seo.semantic_normalization` перед Wordstat.", @@ -742,6 +995,605 @@ export async function getLatestAlignedSeoContextReview( return result.rows[0] ? mapSeoContextReviewRun(result.rows[0]) : null; } +function buildContextEvidenceIntegrity( + analysis: SemanticAnalysisRun, + contextReview: SeoContextReviewRun | null +): SeoProjectContextContract["evidenceIntegrity"] { + const allowedRefs = getContextAllowedEvidenceRefs(analysis); + const allowedPageRoleIds = new Set([ + ...analysis.pages.map((page) => page.pageId), + ...analysis.contentSources.map((source) => source.id) + ]); + + if (!contextReview) { + return { + canonicalRefCount: allowedRefs.size, + referencedRefCount: 0, + validRefCount: 0, + invalidRefs: [], + confirmedClaimCount: 0, + groundedClaimCount: 0, + invalidPageRoleIds: [], + siteIdentityGrounded: false + }; + } + + const referencedRefs = uniq([ + ...contextReview.siteIdentity.evidenceRefs, + ...contextReview.offer.confirmedClaims.flatMap((claim) => claim.evidenceRefs), + ...contextReview.pageRoles.flatMap((role) => role.evidenceRefs), + ...contextReview.sectionFindings.flatMap((finding) => finding.evidenceRefs) + ]); + const invalidRefs = referencedRefs.filter((ref) => !allowedRefs.has(ref)); + const groundedClaimCount = contextReview.offer.confirmedClaims.filter((claim) => + claim.evidenceRefs.some((ref) => allowedRefs.has(ref)) + ).length; + + return { + canonicalRefCount: allowedRefs.size, + referencedRefCount: referencedRefs.length, + validRefCount: referencedRefs.filter((ref) => allowedRefs.has(ref)).length, + invalidRefs: invalidRefs.slice(0, 24), + confirmedClaimCount: contextReview.offer.confirmedClaims.length, + groundedClaimCount, + invalidPageRoleIds: uniq( + contextReview.pageRoles + .filter((role) => !allowedPageRoleIds.has(role.pageId)) + .map((role) => role.pageId) + ).slice(0, 24), + siteIdentityGrounded: contextReview.siteIdentity.evidenceRefs.some((ref) => allowedRefs.has(ref)) + }; +} + +export async function getSeoProjectContextContract(projectId: string): Promise { + const analysis = await getLatestSemanticAnalysis(projectId); + const latestReview = await getLatestSeoContextReview(projectId); + const alignedReview = analysis ? await getLatestAlignedSeoContextReview(projectId, analysis.runId) : null; + const contextReview = alignedReview; + const evidenceIntegrity = analysis + ? buildContextEvidenceIntegrity(analysis, contextReview) + : { + canonicalRefCount: 0, + referencedRefCount: 0, + validRefCount: 0, + invalidRefs: [], + confirmedClaimCount: 0, + groundedClaimCount: 0, + invalidPageRoleIds: [], + siteIdentityGrounded: false + }; + const blockers: SeoProjectContextContract["nextStage"]["blockers"] = []; + + if (!analysis) { + blockers.push({ code: "analysis_missing", message: "Сначала нужен semantic analysis текущего scan scope." }); + } else if (!contextReview) { + blockers.push( + latestReview + ? { code: "review_stale", message: "Context Review относится к предыдущему semantic run и должен быть пересобран." } + : { code: "review_missing", message: "Контекст ещё не собран и не привязан к текущему semantic run." } + ); + } else { + if (contextReview.provider.mode === "deterministic_fallback") { + blockers.push({ + code: "model_review_missing", + message: "Deterministic fallback годится для диагностики, но не для смыслового утверждения контекста." + }); + } + + if (contextReview.analystReview.status !== "approved" || contextReview.needsHumanReview) { + blockers.push({ + code: "analyst_approval_missing", + message: "Контекст должен быть явно утверждён человеком после проверки оффера, аудитории и ограничений." + }); + } + + if (evidenceIntegrity.invalidRefs.length > 0) { + blockers.push({ + code: "evidence_invalid", + message: "Часть выводов ссылается на свободный текст или несуществующие evidence ID." + }); + } + + if (evidenceIntegrity.invalidPageRoleIds.length > 0) { + blockers.push({ + code: "page_roles_invalid", + message: "В ролях страниц есть идентификаторы, которых нет в текущем scan scope." + }); + } + + if ( + !evidenceIntegrity.siteIdentityGrounded || + evidenceIntegrity.groundedClaimCount < evidenceIntegrity.confirmedClaimCount + ) { + blockers.push({ + code: "claims_ungrounded", + message: "Идентичность сайта или подтверждённые claims не имеют валидного source evidence." + }); + } + } + + const state: SeoProjectContextContract["state"] = !analysis + ? "missing_analysis" + : !contextReview + ? latestReview + ? "stale_review" + : "missing_review" + : blockers.length > 0 + ? "needs_review" + : "ready"; + + return { + schemaVersion: "seo-project-context.v1", + projectId, + generatedAt: new Date().toISOString(), + state, + semanticRun: analysis + ? { + runId: analysis.runId, + scanVersionId: analysis.scanVersionId, + generatedAt: analysis.generatedAt + } + : null, + contextReview: contextReview + ? { + runId: contextReview.runId, + semanticRunId: contextReview.semanticRunId, + providerMode: contextReview.provider.mode, + analystStatus: contextReview.analystReview.status, + generatedAt: contextReview.generatedAt + } + : null, + evidenceIntegrity, + nextStage: { + allowed: blockers.length === 0, + blockers + } + }; +} + +export type SeoProjectContextExportFormat = "csv" | "json" | "markdown"; + +export type SeoProjectContextExportLayers = { + businessSynthesis: { + summary: string; + productFrame: { + coreOffer: string; + productCategory: string; + centralThesis: string; + }; + } | null; + opportunityMap: { + state: string; + opportunities: Array<{ + id: string; + title: string; + relationshipLabel: string; + grounding: string; + confidence: string; + rationale: string; + evidenceRefs: string[]; + risk: string; + }>; + selection: { + selectedIds: string[]; + selectedCount: number; + totalCount: number; + status: string; + }; + } | null; + opportunityDiscovery: Pick< + SeoOpportunityDiscoveryContract, + "capabilityPrimitives" | "deferredOpportunities" | "rejectedCandidates" | "portfolio" | "provider" | "runId" | "state" | "summary" + > | null; + opportunityCritic: Pick< + SeoOpportunityCriticContract, + "portfolioVerdict" | "provider" | "reviews" | "runId" | "state" | "summary" + > | null; +}; + +function projectContextCsvCell(value: unknown) { + const normalized = Array.isArray(value) ? value.join(" | ") : String(value ?? ""); + return `"${normalized.replaceAll('"', '""')}"`; +} + +function getProjectContextExportPrefix(analysis: SemanticAnalysisRun) { + const brand = analysis.projectOntology.brandName + .toLocaleLowerCase("ru-RU") + .replace(/[^a-zа-я0-9]+/giu, "-") + .replace(/^-|-$/g, ""); + + return `${brand || "project"}-context-${analysis.runId.slice(0, 8)}`; +} + +function buildProjectContextMarkdownExport( + analysis: SemanticAnalysisRun, + contextReview: SeoContextReviewRun | null, + projectContext: SeoProjectContextContract, + layers: SeoProjectContextExportLayers +) { + const lines = [ + `# ${analysis.projectOntology.brandName} · Project Context`, + "", + `Exported: ${new Date().toISOString()}`, + `Semantic run: ${analysis.runId}`, + `Context state: ${projectContext.state}`, + "", + "## Технический evidence-слой", + "", + `- Выбранные индексируемые страницы: ${analysis.pageScope.selectedIndexablePages}`, + `- Текстовые документы: ${analysis.textCorpus?.extraction.documentCount ?? 0}`, + `- Текстовые фрагменты: ${analysis.textCorpus?.extraction.chunkCount ?? 0}`, + `- Покрытие: ${analysis.summary.coverageScore}%`, + `- Evidence: ${analysis.summary.evidenceScore}%`, + `- Смысловые кластеры: ${analysis.summary.coveredClusters}/${analysis.clusters.length}`, + "" + ]; + + if (!contextReview) { + lines.push("## 4.1 · Контекст проекта", "", "Модельный Context Review ещё не сохранён.", ""); + } else { + lines.push( + "## 4.1 · Контекст проекта", + "", + `Provider: ${contextReview.provider.mode}`, + `Analyst status: ${contextReview.analystReview.status}`, + "", + "### Кто", + "", + `**${contextReview.siteIdentity.brandName}** — ${contextReview.siteIdentity.description}`, + "", + "### Что", + "", + contextReview.offer.summary, + "", + "### Для кого", + "", + contextReview.audience.primary, + contextReview.audience.secondary.length > 0 + ? `Дополнительные аудитории: ${contextReview.audience.secondary.join(" · ")}` + : "", + "", + "### Подтверждённые выводы", + "" + ); + + for (const claim of contextReview.offer.confirmedClaims) { + lines.push(`- ${claim.title} — ${claim.confidence}; evidence: ${claim.evidenceRefs.join(", ") || "нет"}`); + } + + lines.push("", "### Пробелы и ограничения", ""); + + for (const gap of contextReview.semanticGaps) { + lines.push(`- ${gap.title}: ${gap.reason}`); + } + + for (const claim of contextReview.forbiddenClaims) { + lines.push(`- Нельзя утверждать: ${claim.claim} — ${claim.reason}`); + } + + if (contextReview.nextActions.length > 0) { + lines.push("", "### Вопросы на подтверждение", ""); + for (const action of contextReview.nextActions) { + lines.push(`- [ ] ${action}`); + } + } + + lines.push("", "### Итог модели", "", contextReview.summary, ""); + } + + lines.push("## 4.2 · Карта возможностей", ""); + + if (!layers.opportunityMap?.opportunities.length) { + lines.push("Модельная карта возможностей ещё не сохранена.", ""); + } else { + if (layers.businessSynthesis) { + lines.push( + `Категория: ${layers.businessSynthesis.productFrame.productCategory}`, + `Ядро: ${layers.businessSynthesis.productFrame.coreOffer}`, + `Тезис: ${layers.businessSynthesis.productFrame.centralThesis}`, + "" + ); + } + + const selectedIds = new Set(layers.opportunityMap.selection.selectedIds); + for (const opportunity of layers.opportunityMap.opportunities) { + lines.push( + `- [${selectedIds.has(opportunity.id) ? "x" : " "}] ${opportunity.title} · ${opportunity.relationshipLabel} · ${opportunity.grounding}`, + ` ${opportunity.rationale}`, + ` Риск: ${opportunity.risk}`, + ` Evidence: ${opportunity.evidenceRefs.join(", ") || "нет"}` + ); + } + + lines.push( + "", + `Выбрано: ${layers.opportunityMap.selection.selectedCount}/${layers.opportunityMap.selection.totalCount}`, + `Статус выбора: ${layers.opportunityMap.selection.status}`, + "" + ); + } + + if (layers.opportunityDiscovery?.state === "ready") { + lines.push("## Business Opportunity Analyst audit", ""); + lines.push(`Provider: ${layers.opportunityDiscovery.provider.mode}`, `Run: ${layers.opportunityDiscovery.runId ?? "—"}`, ""); + lines.push("### Переносимые capabilities", ""); + for (const capability of layers.opportunityDiscovery.capabilityPrimitives) { + lines.push(`- ${capability.title} · transferability ${capability.transferability}`, ` ${capability.description}`); + } + lines.push("", "### Отложено Critic", ""); + for (const opportunity of layers.opportunityDiscovery.deferredOpportunities) { + lines.push(`- ${opportunity.title} · score ${opportunity.scorecard.overall}`, ` ${opportunity.critic.reason}`); + } + lines.push("", "### Отклонено Critic", ""); + for (const candidate of layers.opportunityDiscovery.rejectedCandidates) { + lines.push(`- ${candidate.title}: ${candidate.reason}`); + } + lines.push(""); + } + + if (layers.opportunityCritic?.state === "ready") { + lines.push("## Independent Opportunity Critic", ""); + lines.push( + `Provider: ${layers.opportunityCritic.provider.mode}`, + `Run: ${layers.opportunityCritic.runId ?? "—"}`, + `Portfolio: ${layers.opportunityCritic.portfolioVerdict.status}`, + layers.opportunityCritic.summary, + "" + ); + for (const review of layers.opportunityCritic.reviews) { + lines.push(`- ${review.candidateId} · ${review.verdict} · ${review.confidence}`, ` ${review.reason}`); + } + lines.push(""); + } + + lines.push( + "## Gate следующего этапа", + "", + `Allowed: ${projectContext.nextStage.allowed ? "yes" : "no"}`, + `Evidence: ${projectContext.evidenceIntegrity.validRefCount}/${projectContext.evidenceIntegrity.referencedRefCount}` + ); + + for (const blocker of projectContext.nextStage.blockers) { + lines.push(`- ${blocker.code}: ${blocker.message}`); + } + + return `${lines.join("\n")}\n`; +} + +function buildProjectContextCsvExport( + analysis: SemanticAnalysisRun, + contextReview: SeoContextReviewRun | null, + projectContext: SeoProjectContextContract, + layers: SeoProjectContextExportLayers +) { + const rows: unknown[][] = [ + ["layer", "kind", "status", "title", "detail", "evidence_refs"], + ["mechanical", "scope", "ready", "selected_indexable_pages", analysis.pageScope.selectedIndexablePages, ""], + ["mechanical", "metric", "ready", "coverage_score", `${analysis.summary.coverageScore}%`, ""], + ["mechanical", "metric", "ready", "evidence_score", `${analysis.summary.evidenceScore}%`, ""], + [ + "gate", + "next_stage", + projectContext.nextStage.allowed ? "allowed" : "blocked", + projectContext.state, + projectContext.nextStage.blockers.map((blocker) => blocker.message), + "" + ] + ]; + + if (contextReview) { + rows.push( + ["semantic", "identity", contextReview.siteIdentity.confidence, contextReview.siteIdentity.brandName, contextReview.siteIdentity.description, contextReview.siteIdentity.evidenceRefs], + ["semantic", "offer", "review", "primary_offer", contextReview.offer.summary, ""], + ["semantic", "audience", contextReview.audience.confidence, "primary_audience", contextReview.audience.primary, ""] + ); + + for (const claim of contextReview.offer.confirmedClaims) { + rows.push(["semantic", "claim", claim.confidence, claim.id, claim.title, claim.evidenceRefs]); + } + + for (const gap of contextReview.semanticGaps) { + rows.push(["semantic", "gap", gap.priority, gap.id, `${gap.title}: ${gap.reason}`, ""]); + } + + for (const claim of contextReview.forbiddenClaims) { + rows.push(["semantic", "forbidden_claim", claim.source, claim.id, `${claim.claim}: ${claim.reason}`, ""]); + } + + for (const action of contextReview.nextActions) { + rows.push(["semantic", "human_review", "pending", "question", action, ""]); + } + } + + if (layers.opportunityMap) { + const selectedIds = new Set(layers.opportunityMap.selection.selectedIds); + for (const opportunity of layers.opportunityMap.opportunities) { + rows.push([ + "opportunities", + opportunity.relationshipLabel, + selectedIds.has(opportunity.id) ? "selected" : "rejected", + opportunity.title, + `${opportunity.rationale} Risk: ${opportunity.risk}`, + opportunity.evidenceRefs + ]); + } + } + + if (layers.opportunityDiscovery?.state === "ready") { + for (const opportunity of layers.opportunityDiscovery.deferredOpportunities) { + rows.push([ + "opportunity_critic", + "deferred", + opportunity.critic.confidence, + opportunity.title, + opportunity.critic.reason, + opportunity.evidenceRefs + ]); + } + + for (const candidate of layers.opportunityDiscovery.rejectedCandidates) { + rows.push(["opportunity_critic", "rejected", "model", candidate.title, candidate.reason, candidate.duplicateOf ?? ""]); + } + } + + if (layers.opportunityCritic?.state === "ready") { + for (const review of layers.opportunityCritic.reviews) { + rows.push([ + "independent_opportunity_critic", + review.verdict, + review.confidence, + review.candidateId, + review.reason, + [...review.fatalRisks, ...review.missingEvidence] + ]); + } + } + + return `\uFEFF${rows.map((row) => row.map(projectContextCsvCell).join(",")).join("\n")}\n`; +} + +export async function getSeoProjectContextExport( + projectId: string, + format: SeoProjectContextExportFormat, + layers: SeoProjectContextExportLayers = { + businessSynthesis: null, + opportunityCritic: null, + opportunityDiscovery: null, + opportunityMap: null + } +) { + const analysis = await getLatestSemanticAnalysis(projectId); + + if (!analysis) { + return null; + } + + const [contextReview, projectContext] = await Promise.all([ + getLatestAlignedSeoContextReview(projectId, analysis.runId), + getSeoProjectContextContract(projectId) + ]); + const fileBaseName = getProjectContextExportPrefix(analysis); + + if (format === "csv") { + return { + body: buildProjectContextCsvExport(analysis, contextReview, projectContext, layers), + contentType: "text/csv; charset=utf-8", + filename: `${fileBaseName}.csv` + }; + } + + if (format === "markdown") { + return { + body: buildProjectContextMarkdownExport(analysis, contextReview, projectContext, layers), + contentType: "text/markdown; charset=utf-8", + filename: `${fileBaseName}.md` + }; + } + + return { + body: `${JSON.stringify({ + exportVersion: "project-context-export.v1", + exportedAt: new Date().toISOString(), + mechanicalAnalysis: analysis, + semanticContext: contextReview, + businessSynthesis: layers.businessSynthesis, + opportunityDiscovery: layers.opportunityDiscovery, + opportunityCritic: layers.opportunityCritic, + opportunityMap: layers.opportunityMap, + gate: projectContext + }, null, 2)}\n`, + contentType: "application/json; charset=utf-8", + filename: `${fileBaseName}.json` + }; +} + +export async function reviewSeoContextReview( + projectId: string, + runId: string, + decision: "approved" | "needs_changes" | "rejected", + notes: string[], + executor: DatabaseExecutor = pool +): Promise { + const result = await executor.query( + ` + select id, status, input, output, error_message, started_at, completed_at, created_at + from runs + where id = $1 and project_id = $2 and run_type = 'seo_context_review' + limit 1; + `, + [runId, projectId] + ); + const row = result.rows[0]; + const currentReview = row ? mapSeoContextReviewRun(row) : null; + const analysis = await getLatestSemanticAnalysis(projectId); + + if (!row?.output || !currentReview) { + throw new SeoContextReviewError("Context Review не найден."); + } + + if (!analysis || currentReview.semanticRunId !== analysis.runId) { + throw new SeoContextReviewError("Нельзя утверждать устаревший Context Review: сначала пересоберите текущий контекст."); + } + + if (decision === "approved") { + const integrity = buildContextEvidenceIntegrity(analysis, currentReview); + + if (currentReview.provider.mode === "deterministic_fallback") { + throw new SeoContextReviewError("Сначала нужна модельная проверка: deterministic fallback нельзя утверждать как финальный контекст."); + } + + if ( + integrity.invalidRefs.length > 0 || + integrity.invalidPageRoleIds.length > 0 || + !integrity.siteIdentityGrounded || + integrity.groundedClaimCount < integrity.confirmedClaimCount + ) { + throw new SeoContextReviewError("Контекст нельзя утвердить: исправьте невалидные evidence refs, page roles и неподтверждённые claims."); + } + } + + const needsHumanReview = decision !== "approved"; + const reviewedOutput: SeoContextReviewOutput = { + ...row.output, + analystReview: { + status: decision, + reviewer: "human", + reviewedAt: new Date().toISOString(), + notes: uniq(notes).slice(0, 12) + }, + needsHumanReview, + readiness: { + ...row.output.readiness, + needsHumanReview + } + }; + const inserted = await executor.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_context_review', 'done', $2::jsonb, $3::jsonb, now(), now()) + returning id, status, input, output, error_message, started_at, completed_at, created_at; + `, + [ + projectId, + JSON.stringify({ + schemaVersion: "seo-context-review-decision.v1", + sourceContextReviewRunId: runId, + semanticRunId: currentReview.semanticRunId, + decision, + reviewer: "human" + }), + JSON.stringify(reviewedOutput) + ] + ); + const reviewedRun = inserted.rows[0] ? mapSeoContextReviewRun(inserted.rows[0]) : null; + + if (!reviewedRun) { + throw new SeoContextReviewError("Не удалось сохранить решение по Context Review."); + } + + return reviewedRun; +} + export async function runSeoContextReviewDraft(projectId: string): Promise { const analysis = await getLatestSemanticAnalysis(projectId); @@ -826,6 +1678,13 @@ export async function saveSeoContextReviewFromModelProvider( "AI Workspace Codex provider вернул seo.context_review task как structured JSON; backend schema validation passed.", fallback ); + const analysis = await getLatestSemanticAnalysis(projectId); + + if (!analysis || analysis.runId !== normalizedOutput.semanticRunId) { + throw new Error("Нельзя сохранить Context Review без совпадающего semantic analysis."); + } + + const sanitizedOutput = sanitizeContextReviewEvidence(normalizedOutput, analysis); const result = await pool.query( ` insert into runs (project_id, run_type, status, input, output, started_at, completed_at) @@ -836,14 +1695,14 @@ export async function saveSeoContextReviewFromModelProvider( projectId, JSON.stringify({ providerMode: "codex_workspace", - scanVersionId: normalizedOutput.scanVersionId, + scanVersionId: sanitizedOutput.scanVersionId, schemaVersion: "seo-context-review-model-provider-input.v1", - semanticRunId: normalizedOutput.semanticRunId, + semanticRunId: sanitizedOutput.semanticRunId, sourceModelTaskRunId, - sourceTaskId: normalizedOutput.sourceTaskId, + sourceTaskId: sanitizedOutput.sourceTaskId, taskType: "seo.context_review" }), - JSON.stringify(normalizedOutput) + JSON.stringify(sanitizedOutput) ] ); const run = result.rows[0] ? mapSeoContextReviewRun(result.rows[0]) : null; diff --git a/seo_mode/seo_mode/server/src/db/client.ts b/seo_mode/seo_mode/server/src/db/client.ts index 2ae7325..c791441 100644 --- a/seo_mode/seo_mode/server/src/db/client.ts +++ b/seo_mode/seo_mode/server/src/db/client.ts @@ -1,6 +1,8 @@ import pg from "pg"; import { env } from "../config/env.js"; +export type DatabaseExecutor = Pick; + export const pool = new pg.Pool({ connectionString: env.databaseUrl }); diff --git a/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts b/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts index a2991d3..3bc496b 100644 --- a/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts +++ b/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts @@ -1,9 +1,11 @@ import { pool } from "../db/client.js"; +import { getRejectedDemandScopeReason } from "../contextReview/demandScopeGuard.js"; +import { getLatestSeoDemandResearchBrief } from "../contextReview/demandResearchBrief.js"; import { buildSerpInterpretationModelTask } from "./serpInterpretationTask.js"; import { getLatestYandexEvidenceContract, type YandexEvidenceContract } from "./yandexEvidence.js"; type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; -type SerpInterpretationProviderMode = "codex_manual" | "deterministic_fallback"; +type SerpInterpretationProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback"; type SerpInterpretationState = "not_ready" | "ready"; type SerpInterpretationConfidence = "high" | "low" | "medium"; type SerpTargetFit = "mismatch" | "partial_fit" | "strong_fit" | "unknown"; @@ -526,8 +528,12 @@ function buildNextActions(contract: Omit> | null = null +): SerpInterpretationContract | null { + const task = buildSerpInterpretationModelTask(projectId, evidence, demandResearchBrief); if (!task) { return null; @@ -591,6 +597,13 @@ export async function getLatestSerpInterpretationContract(projectId: string): Pr return getEmptySerpInterpretationContract(projectId); } + const demandResearchBrief = await getLatestSeoDemandResearchBrief(projectId, evidence.semanticRunId); + const currentTask = buildSerpInterpretationModelTask(projectId, evidence, demandResearchBrief); + + if (!currentTask) { + return getEmptySerpInterpretationContract(projectId); + } + const result = await pool.query( ` select id, status, input, output, error_message, started_at, completed_at, created_at @@ -599,10 +612,11 @@ export async function getLatestSerpInterpretationContract(projectId: string): Pr and run_type = 'seo_serp_interpretation' and output->>'semanticRunId' = $2 and output->>'yandexEvidenceRunId' = $3 + and output->>'sourceTaskId' = $4 order by created_at desc limit 1; `, - [projectId, evidence.semanticRunId, evidence.runId] + [projectId, evidence.semanticRunId, evidence.runId, currentTask.taskId] ); const run = result.rows[0] ? mapSerpInterpretationRun(result.rows[0]) : null; @@ -611,7 +625,10 @@ export async function getLatestSerpInterpretationContract(projectId: string): Pr export async function runSerpInterpretationDraft(projectId: string): Promise { const evidence = await getLatestYandexEvidenceContract(projectId); - const output = buildSerpInterpretationOutput(projectId, evidence); + const demandResearchBrief = evidence.semanticRunId + ? await getLatestSeoDemandResearchBrief(projectId, evidence.semanticRunId) + : null; + const output = buildSerpInterpretationOutput(projectId, evidence, demandResearchBrief); if (!output) { return null; @@ -675,3 +692,112 @@ export async function saveSerpInterpretationManual( return run; } + +export async function saveSerpInterpretationFromModelProvider( + projectId: string, + output: SerpInterpretationContract, + sourceModelTaskRunId: string +): Promise { + const evidence = await getLatestYandexEvidenceContract(projectId); + const demandResearchBrief = evidence.semanticRunId + ? await getLatestSeoDemandResearchBrief(projectId, evidence.semanticRunId) + : null; + const task = buildSerpInterpretationModelTask(projectId, evidence, demandResearchBrief); + + if (!task || !evidence.runId || evidence.state !== "ready") { + throw new Error("SERP interpretation нельзя promoted без актуального ready yandex-evidence.v1."); + } + + const normalizePhrase = (value: string) => value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU"); + const evidencePhraseByKey = new Map(evidence.phrases + .map((phrase, index) => ({ phrase, index })) + .filter(({ phrase }) => !getRejectedDemandScopeReason(phrase.phrase, demandResearchBrief)) + .map(({ phrase, index }) => [normalizePhrase(phrase.phrase), { phrase, index }])); + const allowedDomains = new Set(evidence.competitorDomains.map((item) => item.domain.toLocaleLowerCase("ru-RU"))); + const phraseInterpretations = (Array.isArray(output.phraseInterpretations) ? output.phraseInterpretations : []) + .map((interpretation) => { + const source = evidencePhraseByKey.get(normalizePhrase(interpretation.phrase)); + + if (!source) { + return null; + } + + const sourcePhrase = source.phrase; + const evidenceRefs = [...new Set([ + ...(Array.isArray(interpretation.evidenceRefs) ? interpretation.evidenceRefs : []), + ...getPhraseEvidenceRefs(sourcePhrase, source.index) + ])]; + + return { + ...interpretation, + phrase: sourcePhrase.phrase, + targetPath: sourcePhrase.targetPath, + totalDemand: sourcePhrase.wordstat.totalCount, + demandTier: sourcePhrase.interpretation.demandTier, + seasonality: sourcePhrase.interpretation.seasonality, + difficultyScore: Math.max(0, Math.min(100, Number.isFinite(interpretation.difficultyScore) + ? interpretation.difficultyScore + : sourcePhrase.interpretation.difficultyScore)), + evidenceRefs, + topDomains: [...new Set(sourcePhrase.serp.docs.map((doc) => doc.domain).filter(Boolean))].slice(0, 8), + nextEvidenceNeeded: Array.isArray(interpretation.nextEvidenceNeeded) ? interpretation.nextEvidenceNeeded : [] + }; + }) + .filter((interpretation): interpretation is SerpInterpretationContract["phraseInterpretations"][number] => Boolean(interpretation)); + const competitorGaps = (Array.isArray(output.competitorGaps) ? output.competitorGaps : []) + .filter((gap) => allowedDomains.has(gap.domain.toLocaleLowerCase("ru-RU"))) + .map((gap) => ({ + ...gap, + evidenceRefs: Array.isArray(gap.evidenceRefs) ? gap.evidenceRefs : [] + })); + const normalizedOutput = normalizeOutput( + projectId, + { + ...output, + analystReview: output.analystReview ?? getDefaultAnalystReview(), + competitorGaps, + phraseInterpretations, + projectId, + semanticRunId: evidence.semanticRunId, + sourceTaskId: task.taskId, + strategySignals: Array.isArray(output.strategySignals) ? output.strategySignals : [], + risks: Array.isArray(output.risks) ? output.risks : [], + yandexEvidence: { + checkedPhraseCount: evidence.summary.checkedPhraseCount, + competitorDomainCount: evidence.summary.competitorDomainCount, + runId: evidence.runId, + serpDocCount: evidence.summary.serpDocCount, + totalDemand: evidence.summary.totalDemand + }, + yandexEvidenceRunId: evidence.runId + }, + "codex_workspace", + "AI Workspace Codex provider выполнил seo.serp_interpretation; backend сохранил только evidence-aligned phrases, domains и фактический demand." + ); + const result = await pool.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_serp_interpretation', 'done', $2::jsonb, $3::jsonb, now(), now()) + returning id, status, input, output, error_message, started_at, completed_at, created_at; + `, + [ + projectId, + JSON.stringify({ + providerMode: "codex_workspace", + schemaVersion: "seo-serp-interpretation-model-provider-input.v1", + sourceModelTaskRunId, + sourceTaskId: task.taskId, + taskType: "seo.serp_interpretation", + yandexEvidenceRunId: evidence.runId + }), + JSON.stringify(normalizedOutput) + ] + ); + const run = result.rows[0] ? mapSerpInterpretationRun(result.rows[0]) : null; + + if (!run) { + throw new Error("Не удалось сохранить AI Workspace SERP interpretation."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/evidence/serpInterpretationTask.ts b/seo_mode/seo_mode/server/src/evidence/serpInterpretationTask.ts index fe5dace..09b18bc 100644 --- a/seo_mode/seo_mode/server/src/evidence/serpInterpretationTask.ts +++ b/seo_mode/seo_mode/server/src/evidence/serpInterpretationTask.ts @@ -1,4 +1,9 @@ import type { YandexEvidenceContract } from "./yandexEvidence.js"; +import { + getDemandScopeGuardSummary, + getRejectedDemandScopeReason +} from "../contextReview/demandScopeGuard.js"; +import type { SeoDemandResearchBriefContract } from "../contextReview/demandResearchBrief.js"; export type SerpInterpretationModelTaskContract = { taskType: "seo.serp_interpretation"; @@ -24,7 +29,7 @@ export type SerpInterpretationModelTaskContract = { phrase: string; targetPath: string | null; priority: "high" | "low" | "medium"; - source: "keyword_map" | "market_seed" | "top_demand"; + source: "keyword_map" | "market_seed" | "post_wordstat_batch2" | "top_demand"; totalDemand: number | null; demandTier: "high" | "low" | "mid" | "no_signal"; seasonality: "falling" | "insufficient" | "rising" | "stable" | "volatile"; @@ -50,6 +55,7 @@ export type SerpInterpretationModelTaskContract = { sampleTitle: string; sampleUrl: string; }>; + demandScope: ReturnType; }; allowedActions: Array< | "classify_serp_intent" @@ -100,7 +106,8 @@ function buildPhraseEvidenceRefs(phrase: YandexEvidenceContract["phrases"][numbe export function buildSerpInterpretationModelTask( projectId: string, - evidence: YandexEvidenceContract + evidence: YandexEvidenceContract, + demandResearchBrief: SeoDemandResearchBriefContract | null = null ): SerpInterpretationModelTaskContract | null { if (evidence.state !== "ready" || !evidence.runId || evidence.summary.serpDocCount === 0) { return null; @@ -125,7 +132,22 @@ export function buildSerpInterpretationModelTask( }, input: { competitors: evidence.competitorDomains.slice(0, 12), - phrases: evidence.phrases.slice(0, 8).map((phrase, index) => ({ + demandScope: getDemandScopeGuardSummary(demandResearchBrief), + phrases: evidence.phrases + .filter((phrase) => !getRejectedDemandScopeReason(phrase.phrase, demandResearchBrief)) + .sort((left, right) => { + const sourceScore = (source: YandexEvidenceContract["phrases"][number]["source"]) => + source === "keyword_map" ? 4 : source === "post_wordstat_batch2" ? 3 : source === "top_demand" ? 2 : 1; + const priorityScore = (priority: YandexEvidenceContract["phrases"][number]["priority"]) => + priority === "high" ? 3 : priority === "medium" ? 2 : 1; + + return ( + sourceScore(right.source) - sourceScore(left.source) || + priorityScore(right.priority) - priorityScore(left.priority) || + (right.wordstat.totalCount ?? 0) - (left.wordstat.totalCount ?? 0) + ); + }) + .slice(0, 12).map((phrase, index) => ({ associations: phrase.wordstat.associations.slice(0, 6), demandTier: phrase.interpretation.demandTier, deterministicOpportunity: phrase.interpretation.opportunity, @@ -194,9 +216,10 @@ export function buildSerpInterpretationModelTask( "Нет yandex-evidence.v1 в состоянии ready.", "SERP docs не собраны или пусты: модель не должна угадывать выдачу.", "Нужно принять решение о rewrite/apply: этот task только аналитический и read-only.", + "Rejected directions из Demand Scope нельзя возвращать как active SERP opportunity, даже если в старом evidence сохранились документы.", "Фраза требует business expansion за пределами текущего сайта: вернуть proposal/risk, не менять стратегию автоматически." ], - taskId: `${evidence.runId}:serp-interpretation:v1`, + taskId: `${evidence.runId}:serp-interpretation:${demandResearchBrief?.contextHash.slice(0, 12) ?? "unapproved"}:v3`, taskType: "seo.serp_interpretation", yandexEvidenceRunId: evidence.runId }; diff --git a/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts b/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts index b2b7f9e..c67bcfe 100644 --- a/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts +++ b/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts @@ -2,11 +2,11 @@ import { pool } from "../db/client.js"; import { getKeywordCleaningContract, type KeywordCleaningContract } from "../keywords/keywordCleaning.js"; import { getKeywordMapContract, type KeywordMapContract } from "../keywords/keywordMap.js"; import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js"; -import { getWordstatRuntimeConfig } from "../settings/externalServiceSettings.js"; +import { getSerpRuntimeConfig, getWordstatRuntimeConfig, type SerpRuntimeConfig } from "../settings/externalServiceSettings.js"; type YandexEvidenceState = "failed" | "not_collected" | "not_configured" | "ready"; type YandexEvidencePriority = "high" | "low" | "medium"; -type YandexEvidencePhraseSource = "keyword_map" | "market_seed" | "top_demand"; +type YandexEvidencePhraseSource = "keyword_map" | "market_seed" | "post_wordstat_batch2" | "top_demand"; type YandexEvidenceDemandTier = "high" | "low" | "mid" | "no_signal"; type YandexEvidenceSeasonality = "falling" | "insufficient" | "rising" | "stable" | "volatile"; type YandexEvidenceSerpIntent = "commercial" | "informational" | "mixed" | "unknown"; @@ -115,7 +115,6 @@ export type RunYandexEvidenceInput = { phraseLimit?: number; }; -const WEB_SEARCH_ENDPOINT = "https://searchapi.api.cloud.yandex.net/v2/web/search"; const DEFAULT_PHRASE_LIMIT = 18; const MAX_PHRASE_LIMIT = 48; @@ -123,6 +122,49 @@ function normalizePhrase(value: string) { return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU"); } +const TARGET_BINDING_STOP_WORDS = new Set(["ai", "ии", "бизнес", "для", "и", "на", "по", "процесс", "процессы", "с", "система", "системы", "платформа", "платформы"]); +const TARGET_BINDING_GENERIC_ROOTS = [ + "автоматизац", "бизнес", "интеграц", "компан", "организац", "предприят", "процесс", "решен", "сервис", "систем", "технолог", "управлен", "платформ", + "automation", "business", "company", "enterprise", "integration", "management", "platform", "process", "service", "solution", "system", "technology" +]; + +function targetBindingRoots(value: string) { + return new Set( + (normalizePhrase(value).match(/[\p{L}\p{N}]+/gu) ?? []) + .filter((token) => token.length > 2 && !TARGET_BINDING_STOP_WORDS.has(token)) + .map((token) => token.replace(/(иями|ями|ами|ого|его|ому|ему|ыми|ими|иях|ях|ах|ией|иям|ям|ам|ов|ев|ей|ой|ый|ий|ая|яя|ое|ее|ые|ие|ую|юю|ом|ем|а|я|ы|и|у|ю|е|о)$/iu, "")) + .filter((token) => token.length > 2) + ); +} + +function isGenericTargetBindingRoot(value: string) { + return TARGET_BINDING_GENERIC_ROOTS.some((root) => value.startsWith(root) || root.startsWith(value)); +} + +function getPostWordstatTargetPath( + market: MarketEnrichmentContract, + item: MarketEnrichmentContract["wordstatBatch2"]["serpReviewQueue"][number] +) { + const surface = item.targetSurfaceId + ? market.queryDiscovery.marketExpansion.surfaces.find((candidate) => candidate.id === item.targetSurfaceId) ?? null + : null; + const phraseRoots = targetBindingRoots([ + item.phrase, + surface?.title ?? "", + ...(surface ? Object.values(surface.slotBundle).flat() : []) + ].join(" ")); + if (phraseRoots.size === 0) return null; + return market.briefEvidence + .map((brief) => { + const briefRoots = targetBindingRoots(brief.seedPhrases.join(" ")); + const matchedRoots = [...phraseRoots].filter((root) => briefRoots.has(root)); + const distinctiveOverlap = matchedRoots.filter((root) => !isGenericTargetBindingRoot(root)).length; + return { brief, distinctiveOverlap, overlap: matchedRoots.length }; + }) + .filter(({ distinctiveOverlap, overlap }) => overlap >= 2 && distinctiveOverlap >= 1) + .sort((left, right) => right.distinctiveOverlap - left.distinctiveOverlap || right.overlap - left.overlap || left.brief.path.localeCompare(right.brief.path, "ru"))[0]?.brief.path ?? null; +} + function getPriorityScore(priority: YandexEvidencePriority) { return priority === "high" ? 300 : priority === "medium" ? 200 : 100; } @@ -343,6 +385,24 @@ function selectPhrases(input: { }); } + // Human-approved ProductFit candidates own the next SERP validation pass. + // Register them before legacy cleaning/map candidates so dedupe cannot + // silently replace the post-Wordstat review queue with an older batch. + for (const item of input.market.wordstatBatch2.serpReviewQueue) { + const targetPath = getPostWordstatTargetPath(input.market, item); + addCandidate( + { + phrase: item.phrase, + priority: "high", + reason: `ProductFitGate SERP check; ${item.expectedIntent}; ${item.expectedPageType}; human-approved batch #2 candidate.`, + source: "post_wordstat_batch2", + targetPath + }, + targetPath ?? item.clusterId ?? `post_wordstat_batch2:${normalizePhrase(item.phrase)}`, + 20_000 + getPriorityScore("high") + ); + } + for (const item of input.keywordMap.persisted.items) { const priority = item.role === "primary" ? "high" : item.role === "secondary" || item.role === "differentiator" ? "medium" : "low"; @@ -480,6 +540,7 @@ async function collectPhraseEvidence(input: { region: string; token: string; wordstatBase: string; + serpConfig: Extract | null; }): Promise { const errors: string[] = []; const wordstat = { @@ -579,9 +640,10 @@ async function collectPhraseEvidence(input: { } try { + if (!input.serpConfig) throw new Error("SERP provider disabled"); const serpPayload = await postJson({ authorization: input.authorization, - endpoint: WEB_SEARCH_ENDPOINT, + endpoint: input.serpConfig.apiBaseUrl, token: input.token, body: { folderId: input.folderId, @@ -593,7 +655,7 @@ async function collectPhraseEvidence(input: { query: { familyMode: "FAMILY_MODE_NONE", queryText: input.phrase.phrase, - searchType: "SEARCH_TYPE_RU" + searchType: input.serpConfig.searchType }, responseFormat: "FORMAT_XML", sortSpec: { @@ -787,11 +849,12 @@ function emptyContract(projectId: string, state: YandexEvidenceState, phraseLimi } async function buildConfiguredContract(projectId: string, runId: string, phraseLimit: number): Promise { - const [market, cleaning, keywordMap, config] = await Promise.all([ + const [market, cleaning, keywordMap, config, serpConfig] = await Promise.all([ getMarketEnrichmentContract(projectId), getKeywordCleaningContract(projectId), getKeywordMapContract(projectId), - getWordstatRuntimeConfig() + getWordstatRuntimeConfig(), + getSerpRuntimeConfig() ]); if (config.provider !== "yandex_api") { @@ -813,7 +876,8 @@ async function buildConfiguredContract(projectId: string, runId: string, phraseL phrase, region: config.regionIds[0] ?? "225", token: config.apiToken, - wordstatBase: config.apiBaseUrl.replace(/\/+$/, "") + wordstatBase: config.apiBaseUrl.replace(/\/+$/, ""), + serpConfig: serpConfig.provider === "yandex_api" ? serpConfig : null }) ); } @@ -922,7 +986,7 @@ export async function runYandexEvidenceCollection( } } -export async function getLatestYandexEvidenceContract(projectId: string): Promise { +async function buildLatestYandexEvidenceContract(projectId: string): Promise { const market = await getMarketEnrichmentContract(projectId); if (!market.semanticRunId) { @@ -954,3 +1018,20 @@ export async function getLatestYandexEvidenceContract(projectId: string): Promis return emptyContract(projectId, "not_collected"); } + +const latestYandexEvidenceInFlight = new Map>(); + +export async function getLatestYandexEvidenceContract(projectId: string): Promise { + const existing = latestYandexEvidenceInFlight.get(projectId); + if (existing) return existing; + + const pending = buildLatestYandexEvidenceContract(projectId); + latestYandexEvidenceInFlight.set(projectId, pending); + try { + return await pending; + } finally { + if (latestYandexEvidenceInFlight.get(projectId) === pending) { + latestYandexEvidenceInFlight.delete(projectId); + } + } +} diff --git a/seo_mode/seo_mode/server/src/keywords/anchorReview.ts b/seo_mode/seo_mode/server/src/keywords/anchorReview.ts index 39840e3..f4772ca 100644 --- a/seo_mode/seo_mode/server/src/keywords/anchorReview.ts +++ b/seo_mode/seo_mode/server/src/keywords/anchorReview.ts @@ -24,6 +24,12 @@ export type AnchorReviewManualAnchorInput = { sendToWordstat?: boolean; }; +export type AnchorReviewEditAnchorInput = { + anchorId: string; + phrase: string; + reason?: string; +}; + export type AnchorReviewDecision = { decidedAt: string | null; decidedBy: string | null; @@ -904,3 +910,157 @@ export async function addAnchorReviewManualAnchor( return result.rows[0]?.output ?? output; } + +export function applyAnchorReviewEdit( + current: AnchorReviewContract, + input: AnchorReviewEditAnchorInput, + decidedAt = new Date().toISOString() +) { + if (!current.semanticRunId || current.state === "not_ready") { + throw new Error("Anchor review не готов: сначала нужен semantic analysis, context review и normalization."); + } + + const sourceAnchor = current.anchors.find((anchor) => anchor.id === input.anchorId); + const phrase = input.phrase.trim().replace(/\s+/g, " "); + + if (!sourceAnchor) { + throw new Error("Редактируемый якорь не найден в текущем семантическом базисе."); + } + + if (!phrase) { + throw new Error("Новая формулировка якоря не может быть пустой."); + } + + const normalizedPhrase = normalizePhrase(phrase); + + if (normalizedPhrase === normalizePhrase(sourceAnchor.phrase)) { + return { + anchorReview: current, + mergedIntoAnchorId: null, + phrase, + sourceAnchorId: sourceAnchor.id + }; + } + + const reason = input.reason?.trim() || `Пользователь изменил формулировку «${sourceAnchor.phrase}» → «${phrase}».`; + const deletedSource: AnchorReviewAnchor = { + ...sourceAnchor, + decision: { + decidedAt, + decidedBy: "dev-mode", + reason, + sendToSerp: false, + sendToWordstat: false, + source: "human", + status: "deleted" + } + }; + const duplicate = current.anchors.find( + (anchor) => anchor.id !== sourceAnchor.id && normalizePhrase(anchor.phrase) === normalizedPhrase + ); + let mergedIntoAnchorId: string | null = null; + let nextAnchors: AnchorReviewAnchor[]; + + if (duplicate) { + mergedIntoAnchorId = duplicate.id; + const shouldApprove = sourceAnchor.decision.status === "approved" || duplicate.decision.status === "approved"; + const mergedDecision: AnchorReviewDecision = shouldApprove + ? { + decidedAt, + decidedBy: "dev-mode", + reason: `${reason} Совпадающий якорь объединён без дублирования.`, + sendToSerp: sourceAnchor.decision.sendToSerp || duplicate.decision.sendToSerp, + sendToWordstat: sourceAnchor.decision.sendToWordstat || duplicate.decision.sendToWordstat, + source: "human", + status: "approved" + } + : { + ...duplicate.decision, + decidedAt, + decidedBy: "dev-mode", + reason: `${reason} Совпадающий якорь объединён без дублирования.`, + source: "human" + }; + + nextAnchors = current.anchors.map((anchor) => { + if (anchor.id === sourceAnchor.id) { + return deletedSource; + } + + if (anchor.id !== duplicate.id) { + return anchor; + } + + return { + ...anchor, + confidence: Math.max(anchor.confidence, sourceAnchor.confidence), + decision: mergedDecision, + evidenceRefs: [...new Set([...anchor.evidenceRefs, ...sourceAnchor.evidenceRefs, "manual.anchor_merge"])], + normalizedFrom: [...new Set([...anchor.normalizedFrom, ...sourceAnchor.normalizedFrom, sourceAnchor.phrase])] + }; + }); + } else { + const existingIds = new Set(current.anchors.map((anchor) => anchor.id)); + const replacementId = getManualAnchorId(phrase, existingIds); + const replacement: AnchorReviewAnchor = { + ...sourceAnchor, + id: replacementId, + key: `manual_semantic_basis:${normalizedPhrase}`, + phrase, + normalizedFrom: [...new Set([...sourceAnchor.normalizedFrom, sourceAnchor.phrase, phrase])], + evidenceRefs: [...new Set([...sourceAnchor.evidenceRefs, "manual.anchor_edit"])], + decision: { + ...sourceAnchor.decision, + decidedAt, + decidedBy: "dev-mode", + reason, + source: "human" + }, + proposal: { + ...sourceAnchor.proposal, + reason: "Отредактированный пользователем якорь семантического базиса." + }, + reason: "Отредактированный пользователем якорь семантического базиса." + }; + + nextAnchors = [ + ...current.anchors.map((anchor) => anchor.id === sourceAnchor.id ? deletedSource : anchor), + replacement + ]; + } + + return { + anchorReview: rebuildContractWithAnchors(current, nextAnchors), + mergedIntoAnchorId, + phrase, + sourceAnchorId: sourceAnchor.id + }; +} + +export async function editAnchorReviewAnchor( + projectId: string, + input: AnchorReviewEditAnchorInput +): Promise { + const edit = applyAnchorReviewEdit(await getAnchorReviewContract(projectId), input); + const output = edit.anchorReview; + 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({ + anchorId: edit.sourceAnchorId, + mergedIntoAnchorId: edit.mergedIntoAnchorId, + phrase: edit.phrase, + schemaVersion: "anchor-review-edit-anchor-input.v1", + semanticRunId: output.semanticRunId + }), + JSON.stringify(output) + ] + ); + + return result.rows[0]?.output ?? output; +} diff --git a/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts b/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts index f13b136..272028a 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordAnalysisWorkflow.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; import { getLatestAlignedSeoBusinessSynthesis } from "../business/seoBusinessSynthesis.js"; -import { getLatestAlignedSeoCommercialDemand } from "../commercial/seoCommercialDemand.js"; +import { getSeoCommercialDemandContract } from "../commercial/seoCommercialDemand.js"; import { pool } from "../db/client.js"; import { getAnchorReviewContract } from "./anchorReview.js"; import { getKeywordCleaningByModelTaskRun, getKeywordCleaningContract } from "./keywordCleaning.js"; @@ -24,6 +24,13 @@ type KeywordAnalysisWorkflowStepId = | "keyword_cleaning" | "keyword_proposal"; type KeywordAnalysisWorkflowStepStatus = "blocked" | "completed" | "failed" | "pending" | "running" | "waiting"; +type DemandCollectionPhase = + | "not_started" + | "exploratory_wordstat" + | "query_language_fingerprint" + | "model_frontier" + | "deep_wordstat" + | "completed"; export type KeywordAnalysisWorkflowStep = { id: KeywordAnalysisWorkflowStepId; @@ -57,6 +64,10 @@ export type KeywordAnalysisWorkflowContract = { marketFrontierRunId: string | null; modelTaskRunId: string | null; wordstatJobId: string | null; + demandCollectionPhase: DemandCollectionPhase; + exploratoryWordstatJobId: string | null; + deepWordstatJobId: string | null; + queryLanguageFingerprintSummary: string | null; }; nextActions: string[]; }; @@ -173,7 +184,11 @@ function buildInitialWorkflow(projectId: string, runId: string): KeywordAnalysis marketGeneratedAt: null, marketFrontierRunId: null, modelTaskRunId: null, - wordstatJobId: null + wordstatJobId: null, + demandCollectionPhase: "not_started", + exploratoryWordstatJobId: null, + deepWordstatJobId: null, + queryLanguageFingerprintSummary: null }, nextActions: [] }; @@ -193,7 +208,11 @@ function normalizeWorkflowContract(contract: KeywordAnalysisWorkflowContract): K marketGeneratedAt: contract.artifacts.marketGeneratedAt ?? null, marketFrontierRunId: contract.artifacts.marketFrontierRunId ?? null, modelTaskRunId: contract.artifacts.modelTaskRunId ?? null, - wordstatJobId: contract.artifacts.wordstatJobId ?? null + wordstatJobId: contract.artifacts.wordstatJobId ?? null, + demandCollectionPhase: contract.artifacts.demandCollectionPhase ?? "not_started", + exploratoryWordstatJobId: contract.artifacts.exploratoryWordstatJobId ?? null, + deepWordstatJobId: contract.artifacts.deepWordstatJobId ?? null, + queryLanguageFingerprintSummary: contract.artifacts.queryLanguageFingerprintSummary ?? null }, steps: WORKFLOW_STEPS.map((step) => { const existing = stepById.get(step.id); @@ -474,7 +493,7 @@ async function ensureCommercialDemandReady( return { contract: completed, ready: true }; } - const currentCommercialDemand = await getLatestAlignedSeoCommercialDemand(projectId, semanticRunId); + const currentCommercialDemand = await getSeoCommercialDemandContract(projectId); if (currentCommercialDemand && currentCommercialDemand.provider.mode !== "deterministic_fallback") { const ready = { @@ -485,7 +504,7 @@ async function ensureCommercialDemandReady( }), artifacts: { ...contract.artifacts, - commercialDemandRunId: currentCommercialDemand.runId + commercialDemandRunId: (currentCommercialDemand as typeof currentCommercialDemand & { runId?: string }).runId ?? null } }; await saveWorkflow(ready, "running"); @@ -813,6 +832,61 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string) return; } + if ( + anchorReview.demandCollectionProfile === "market_wide" && + market.wordstat.summary.collectedSeedCount === 0 + ) { + const exploratoryJobIdBefore = market.wordstat.latestJob?.id ?? null; + contract = { + ...setStepStatus(contract, "demand_collection", "running", { + detail: "Exploratory Wordstat: проверяем короткий пакет human-approved anchors до model frontier." + }), + artifacts: { + ...contract.artifacts, + demandCollectionPhase: "exploratory_wordstat" + } + }; + await saveWorkflow(contract, "running"); + market = await runMarketEnrichment(projectId); + const exploratoryJob = market.wordstat.latestJob; + + if (!exploratoryJob || exploratoryJob.id === exploratoryJobIdBefore || exploratoryJob.status === "failed") { + if (exploratoryJob?.status === "failed") { + await failWorkflow( + contract, + "demand_collection", + exploratoryJob.errorMessage ?? "Exploratory Wordstat завершился ошибкой провайдера." + ); + } else { + await blockWorkflow( + contract, + "demand_collection", + "Exploratory Wordstat не создал свежий job; model frontier нельзя строить без нового языка рынка.", + market.nextActions + ); + } + return; + } + + const exploratoryDiscovery = await getMarketFrontierDiscoveryContract(projectId); + contract = { + ...setStepStatus(contract, "demand_collection", "running", { + detail: + `Exploratory Wordstat готов: ${market.wordstat.summary.collectedSeedCount} seeds. ` + + "Строим Query Language Fingerprint и новый model frontier." + }), + artifacts: { + ...contract.artifacts, + demandCollectionPhase: "query_language_fingerprint", + exploratoryWordstatJobId: exploratoryJob.id, + marketGeneratedAt: market.generatedAt, + queryLanguageFingerprintSummary: exploratoryDiscovery.queryLanguageFingerprint.summary, + wordstatJobId: exploratoryJob.id + } + }; + await saveWorkflow(contract, "running"); + } + const marketFrontierDiscovery = await ensureMarketFrontierDiscoveryReady( contract, projectId, @@ -824,13 +898,45 @@ async function executeKeywordAnalysisWorkflow(runId: string, projectId: string) return; } + if (anchorReview.demandCollectionProfile === "market_wide") { + const discovery = await getMarketFrontierDiscoveryContract(projectId); + contract = { + ...setStepStatus(contract, "demand_collection", "running", { + detail: + `Model frontier готов: ${discovery.frontiers.length} frontiers, ${discovery.probeSeeds.length} deep probes. ` + + "Запускаем глубокий Wordstat-добор." + }), + artifacts: { + ...contract.artifacts, + demandCollectionPhase: "model_frontier", + queryLanguageFingerprintSummary: discovery.queryLanguageFingerprint.summary + } + }; + await saveWorkflow(contract, "running"); + } + const wordstatJobIdBeforeCollection = market.wordstat.latestJob?.id ?? null; + contract = { + ...contract, + artifacts: { + ...contract.artifacts, + demandCollectionPhase: + anchorReview.demandCollectionProfile === "market_wide" ? "deep_wordstat" : "exploratory_wordstat" + } + }; + await saveWorkflow(contract, "running"); + market = await runMarketEnrichment(projectId); contract = { ...contract, artifacts: { ...contract.artifacts, + demandCollectionPhase: "completed", + deepWordstatJobId: + anchorReview.demandCollectionProfile === "market_wide" + ? market.wordstat.latestJob?.id ?? contract.artifacts.deepWordstatJobId + : contract.artifacts.deepWordstatJobId, marketGeneratedAt: market.generatedAt, wordstatJobId: market.wordstat.latestJob?.id ?? null } diff --git a/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts b/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts index 196b801..38cbb13 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts @@ -1,4 +1,12 @@ import { pool } from "../db/client.js"; +import { + getDemandScopeGuardSummary, + getRejectedDemandScopeReason +} from "../contextReview/demandScopeGuard.js"; +import { + getLatestSeoDemandResearchBrief, + type SeoDemandResearchBriefContract +} from "../contextReview/demandResearchBrief.js"; import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js"; import { hasEducationIntentMismatch } from "./keywordIntentGuards.js"; @@ -95,6 +103,7 @@ export type KeywordCleaningModelTaskContract = { driftExamples: string[]; strictFitExamples: string[]; }>; + demandScope: ReturnType; }; outputSchema: { schemaVersion: "keyword-cleaning.v1"; @@ -1029,14 +1038,31 @@ function enforceKeywordCleaningNoiseGuard(items: KeywordCleaningItem[]): Keyword function enforceKeywordCleaningBackendGuards( market: MarketEnrichmentContract, - items: KeywordCleaningItem[] + items: KeywordCleaningItem[], + demandResearchBrief: SeoDemandResearchBriefContract | null = null ): KeywordCleaningItem[] { - return enforceKeywordCleaningNoiseGuard( + const guardedItems = enforceKeywordCleaningNoiseGuard( enforceWordstatSourceGroupCritic( market, promoteExactDemandItems(market, rescueExactTrashItems(market, enforceKeywordCleaningSemanticGuard(market, items))) ) ); + + return guardedItems.map((item) => { + const scopeReason = getRejectedDemandScopeReason(item.phrase, demandResearchBrief); + + if (!scopeReason) { + return item; + } + + return { + ...item, + blockers: unique([...(Array.isArray(item.blockers) ? item.blockers : []), "demand_scope_rejected"]), + confidence: Math.max(item.confidence, 0.98), + decision: "trash", + reason: `${scopeReason} Backend guard физически исключил ветку из keyword map и стратегии.` + }; + }); } function getKeywordCleaningMergeKeys(item: KeywordCleaningItem) { @@ -1382,20 +1408,28 @@ function getKeywordCleaningModelTopResultScore(result: WordstatTopResult) { ); } -function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): KeywordCleaningModelTaskContract["input"] { +function buildKeywordCleaningModelInput( + contract: MarketEnrichmentContract, + demandResearchBrief: SeoDemandResearchBriefContract | null +): KeywordCleaningModelTaskContract["input"] { const briefPhraseMap = buildBriefPhraseMap(contract); const briefClusterMap = buildBriefClusterMap(contract); const modelSeedQueue = contract.seedQueue + .filter((seed) => !getRejectedDemandScopeReason(seed.phrase, demandResearchBrief)) .map((seed, index) => ({ index, score: getKeywordCleaningModelSeedScore(seed), seed })) .sort((left, right) => right.score - left.score || left.index - right.index) .slice(0, KEYWORD_CLEANING_MODEL_SEED_QUEUE_LIMIT) .map((item) => item.seed); const modelTopResults = contract.wordstat.topResults + .filter((result) => !getRejectedDemandScopeReason(result.phrase, demandResearchBrief)) .map((result, index) => ({ index, result, score: getKeywordCleaningModelTopResultScore(result) })) .sort((left, right) => right.score - left.score || left.index - right.index) .slice(0, KEYWORD_CLEANING_MODEL_TOP_RESULT_LIMIT) .map((item) => item.result); - const modelSourceGroupCritics = [...buildWordstatSourceGroupCritics(contract, getBaseItems(contract)).values()] + const modelSourceGroupCritics = [...buildWordstatSourceGroupCritics( + contract, + getBaseItems(contract).filter((item) => !getRejectedDemandScopeReason(item.phrase, demandResearchBrief)) + ).values()] .filter((critic) => critic.verdict !== "clean") .sort( (left, right) => @@ -1433,6 +1467,7 @@ function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): Key title: truncateTaskText(group.title), wordstatQueries: group.wordstatQueries.slice(0, 6).map((query) => truncateTaskText(query)) })), + demandScope: getDemandScopeGuardSummary(demandResearchBrief), seedQueue: modelSeedQueue.map((seed) => ({ clusterId: seed.clusterId, clusterTitle: truncateTaskText(seed.clusterTitle), @@ -1718,7 +1753,8 @@ function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningCont function buildKeywordCleaningModelTask( projectId: string, - contract: MarketEnrichmentContract + contract: MarketEnrichmentContract, + demandResearchBrief: SeoDemandResearchBriefContract | null ): KeywordCleaningModelTaskContract | null { if (!contract.semanticRunId) { return null; @@ -1749,7 +1785,7 @@ function buildKeywordCleaningModelTask( wordstatJobStatus: contract.wordstat.latestJob?.status ?? null, wordstatResultCount: contract.wordstat.summary.resultCount }, - input: buildKeywordCleaningModelInput(contract), + input: buildKeywordCleaningModelInput(contract, demandResearchBrief), outputSchema: { decisionValues: KEYWORD_CLEANING_DECISIONS, itemRequiredKeys: [ @@ -1797,9 +1833,10 @@ function buildKeywordCleaningModelTask( "Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря; глобальный доминантный термин проекта не должен запрещать отдельные подтверждённые ветки спроса.", "Проверять sourceGroupCritics после Wordstat: source_drift-группы не отправлять в keyword map, mixed-группы повышать только при строгом product/source fit у конкретной фразы.", "Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.", + "Никогда не повышать rejectedDirections из Demand Scope: даже частотный related обязан остаться trash и не может попасть в keyword map или strategy.", "Не сохранять rewrite/apply decisions." ], - taskId: `${contract.semanticRunId}:keyword-cleaning:v1`, + taskId: `${contract.semanticRunId}:keyword-cleaning:${demandResearchBrief?.contextHash.slice(0, 12) ?? "unapproved"}:v2`, taskType: "seo.keyword_cleaning" }; } @@ -1838,9 +1875,17 @@ function buildNextActions(contract: Omit return actions; } -function buildFallbackContract(projectId: string, market: MarketEnrichmentContract): KeywordCleaningContract { - const modelTask = buildKeywordCleaningModelTask(projectId, market); - const items = enforceKeywordCleaningBackendGuards(market, classifyItems(market, getBaseItems(market))); +function buildFallbackContract( + projectId: string, + market: MarketEnrichmentContract, + demandResearchBrief: SeoDemandResearchBriefContract | null +): KeywordCleaningContract { + const modelTask = buildKeywordCleaningModelTask(projectId, market, demandResearchBrief); + const items = enforceKeywordCleaningBackendGuards( + market, + classifyItems(market, getBaseItems(market)), + demandResearchBrief + ); const lanes = buildLanes(items); const readiness = buildReadiness(items, lanes); const contractWithoutActions: Omit = { @@ -2019,9 +2064,12 @@ export async function getKeywordCleaningByModelTaskRun( return row && keywordCleaning ? { keywordCleaning, runId: row.id } : null; } -export async function getKeywordCleaningContract(projectId: string): Promise { +async function buildKeywordCleaningContract(projectId: string): Promise { const market = await getMarketEnrichmentContract(projectId); - const fallbackContract = buildFallbackContract(projectId, market); + const demandResearchBrief = market.semanticRunId + ? await getLatestSeoDemandResearchBrief(projectId, market.semanticRunId) + : null; + const fallbackContract = buildFallbackContract(projectId, market, demandResearchBrief); if (!market.semanticRunId) { return fallbackContract; @@ -2029,11 +2077,15 @@ export async function getKeywordCleaningContract(projectId: string): Promise>(); + +export async function getKeywordCleaningContract(projectId: string): Promise { + const existing = keywordCleaningInFlight.get(projectId); + if (existing) return existing; + + const pending = buildKeywordCleaningContract(projectId); + keywordCleaningInFlight.set(projectId, pending); + try { + return await pending; + } finally { + if (keywordCleaningInFlight.get(projectId) === pending) { + keywordCleaningInFlight.delete(projectId); + } + } +} + export async function saveKeywordCleaningManual( projectId: string, output: KeywordCleaningContract ): Promise { + const market = await getMarketEnrichmentContract(projectId); + const demandResearchBrief = market.semanticRunId + ? await getLatestSeoDemandResearchBrief(projectId, market.semanticRunId) + : null; + const modelTask = buildKeywordCleaningModelTask(projectId, market, demandResearchBrief); const normalizedOutput = normalizeKeywordCleaningOutput( projectId, - output, + { + ...output, + items: enforceKeywordCleaningBackendGuards(market, output.items, demandResearchBrief), + modelTask, + sourceTaskId: modelTask?.taskId ?? null + }, "codex_manual", "Codex manual dev-loop: текущий Codex-сеанс выполнил seo.keyword_cleaning task по contract evidence, без внешнего provider adapter." ); @@ -2103,7 +2182,10 @@ export async function saveKeywordCleaningFromModelProvider( sourceModelTaskRunId: string ): Promise { const market = await getMarketEnrichmentContract(projectId); - const modelTask = buildKeywordCleaningModelTask(projectId, market); + const demandResearchBrief = market.semanticRunId + ? await getLatestSeoDemandResearchBrief(projectId, market.semanticRunId) + : null; + const modelTask = buildKeywordCleaningModelTask(projectId, market, demandResearchBrief); if (market.semanticRunId && output.semanticRunId && output.semanticRunId !== market.semanticRunId) { throw new Error("Keyword cleaning output semanticRunId не совпадает с текущим market enrichment."); @@ -2113,7 +2195,8 @@ export async function saveKeywordCleaningFromModelProvider( ...output, items: enforceKeywordCleaningBackendGuards( market, - mergeMissingExactEvidenceItems(market, mergeModelProviderItemsWithMarketEvidence(output.items, market)) + mergeMissingExactEvidenceItems(market, mergeModelProviderItemsWithMarketEvidence(output.items, market)), + demandResearchBrief ), modelTask, projectOntologyVersion: market.projectOntologyVersion, diff --git a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts index 1afbc33..073bdbb 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts @@ -1,15 +1,27 @@ +import crypto from "node:crypto"; import { pool } from "../db/client.js"; +import { + assessPhrasePositioning, + getSeoPositioningThesis, + hasExplicitCompetitorRelation, + type PhrasePositioningAssessment, + type PositioningPortfolioLane, + type SeoPositioningThesisContract +} from "../contextReview/positioningThesis.js"; import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js"; import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js"; import { hasEducationIntentMismatch } from "./keywordIntentGuards.js"; type KeywordMapState = "blocked" | "draft"; +export type KeywordMapReviewState = "blocked" | "unreviewed" | "partially_reviewed" | "approved_current_revision" | "stale"; 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"; projectId: string; + revision: string; + positioningRevision: string; semanticRunId: string | null; projectOntologyVersion: MarketEnrichmentContract["projectOntologyVersion"]; generatedAt: string; @@ -35,6 +47,14 @@ export type KeywordMapContract = { keywordMapCandidateCount: number; }; blockers: string[]; + review: { + state: KeywordMapReviewState; + keywordMapRevision: string; + positioningRevision: string; + approvedAt: string | null; + approvedItemCount: number; + staleApprovedDecisionCount: number; + }; persisted: KeywordMapPersistenceSummary; items: Array<{ id: string; @@ -51,6 +71,8 @@ export type KeywordMapContract = { frequencyGroup: string | null; projectOntologyVersionId: string | null; wordstatResultId: string | null; + portfolioLane: PositioningPortfolioLane; + positioning: PhrasePositioningAssessment; reason: string; }>; nextActions: string[]; @@ -58,6 +80,7 @@ export type KeywordMapContract = { export type KeywordMapPersistenceSummary = { approvedDecisionCount: number; + staleApprovedDecisionCount: number; mapItemCount: number; latestApprovedAt: string | null; items: Array<{ @@ -71,6 +94,8 @@ export type KeywordMapPersistenceSummary = { workspaceSectionId: string | null; pageId: string | null; approvedAt: string; + positioningRevision: string; + keywordMapRevision: string; }>; }; @@ -91,6 +116,7 @@ type PersistedKeywordMapLayout = { }; type KeywordRoleContext = { market: MarketEnrichmentContract; + positioningThesis: SeoPositioningThesisContract; }; export type KeywordMapApproveInput = { @@ -408,6 +434,16 @@ function getKeywordDecisionRouteWeight(decision: KeywordCleaningItem["decision"] return 0; } +function getPositioningRouteWeight(assessment: PhrasePositioningAssessment) { + if (assessment.lane === "differentiated_core") return 6; + if (assessment.lane === "product_mechanism") return 5; + if (assessment.lane === "category_core") return 4; + if (assessment.lane === "content_support") return 3; + if (assessment.lane === "expansion_hypothesis") return 2; + if (assessment.lane === "competitor_comparison") return 1; + return 0; +} + function isBackendMissingExactBackfillItem(item: KeywordCleaningItem) { return item.evidenceRefs.some((ref) => ref === "backend:missing-exact-evidence-backfill"); } @@ -529,8 +565,27 @@ function getKeywordRole( return "validate"; } + const positioning = assessPhrasePositioning(item.phrase, roleContext.positioningThesis); + if (positioning.lane === "competitor_comparison") { + return hasExplicitCompetitorRelation(item.phrase, positioning) ? "support" : "validate"; + } + + if (positioning.lane === "content_support") { + return "support"; + } + + if (positioning.lane === "expansion_hypothesis") { + return "validate"; + } + + const canBecomePrimaryFromPositioning = + (positioning.lane === "differentiated_core" && + (positioning.differentiationFit > 0 || positioning.categoryFit > 0 || positioning.mechanismFit > 0)) || + ((positioning.lane === "category_core" || positioning.lane === "product_mechanism") && Boolean(item.targetPath)); + if ( indexInTarget === 0 && + canBecomePrimaryFromPositioning && (item.decision === "use" || item.decision === "support") && (item.priority === "high" || item.priority === "medium") && !isLongTailKeywordCandidate(item) && @@ -597,10 +652,15 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea return blockers; } -function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) { +function buildItems( + contract: MarketEnrichmentContract, + cleaning: KeywordCleaningContract, + reviewedOntology: boolean, + positioningThesis: SeoPositioningThesisContract +) { const targetCounts = new Map(); const semanticFacetGuard = buildSemanticFacetGuard(contract); - const roleContext: KeywordRoleContext = { market: contract }; + const roleContext: KeywordRoleContext = { market: contract, positioningThesis }; const seenPhrases = new Set(); const sourceItems = cleaning.items .filter((item) => isKeywordMapDisplayCandidate(item, semanticFacetGuard, roleContext)) @@ -626,6 +686,13 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin return rightPriorityWeight - leftPriorityWeight; } + const leftPositioningWeight = getPositioningRouteWeight(assessPhrasePositioning(left.phrase, positioningThesis)); + const rightPositioningWeight = getPositioningRouteWeight(assessPhrasePositioning(right.phrase, positioningThesis)); + + if (leftPositioningWeight !== rightPositioningWeight) { + return rightPositioningWeight - leftPositioningWeight; + } + const leftSemanticFit = getSemanticFitAssessment(left, semanticFacetGuard).score; const rightSemanticFit = getSemanticFitAssessment(right, semanticFacetGuard).score; @@ -659,6 +726,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin const targetKey = rawTargetPath ?? item.clusterId; const indexInTarget = targetCounts.get(targetKey) ?? 0; const role = getKeywordRole(item, indexInTarget, semanticFacetGuard, roleContext); + const positioning = assessPhrasePositioning(item.phrase, positioningThesis); const targetPath = role === "secondary_candidate" ? null : rawTargetPath; const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null; @@ -681,20 +749,62 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin frequencyGroup: item.frequencyGroup, projectOntologyVersionId: item.projectOntologyVersionId, wordstatResultId: item.wordstatResultId, - reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology, semanticFacetGuard) + portfolioLane: positioning.lane, + positioning, + reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology, semanticFacetGuard, positioning) }; }); } +export function buildKeywordMapRevision(input: { + items: KeywordMapItem[]; + positioningRevision: string; + projectOntologyVersionId: string | null; + semanticRunId: string | null; +}) { + const payload = { + items: input.items + .map((item) => ({ + evidenceStatus: item.evidenceStatus, + frequency: item.frequency, + id: item.id, + portfolioLane: item.portfolioLane, + role: item.role, + targetPath: item.targetPath, + wordstatResultId: item.wordstatResultId + })) + .sort((left, right) => left.id.localeCompare(right.id, "en")), + positioningRevision: input.positioningRevision, + projectOntologyVersionId: input.projectOntologyVersionId, + semanticRunId: input.semanticRunId, + version: "seo-keyword-map-revision.v1" + }; + + return `keyword-map:${crypto.createHash("sha256").update(JSON.stringify(payload)).digest("hex")}`; +} + function getKeywordMapItemReason( item: KeywordCleaningItem, role: KeywordMapRole, targetPath: string | null, reviewedOntology: boolean, - semanticFacetGuard: SemanticFacetGuard + semanticFacetGuard: SemanticFacetGuard, + positioning: PhrasePositioningAssessment ) { if (!reviewedOntology) { - return "Модель предварительно распределила фразу, но фиксация ждёт reviewed ontology."; + return "Система предварительно распределила фразу, но фиксация ждёт reviewed ontology."; + } + + if (positioning.lane === "competitor_comparison" && !hasExplicitCompetitorRelation(item.phrase, positioning)) { + return `Фраза относится к исключённой/конкурирующей категории (${positioning.matchedExcludedCategories.join(", ")}) без явного сравнения или интеграции; оставляем на ручной разбор, а не в продуктовом ядре.`; + } + + if (positioning.lane === "competitor_comparison") { + return `Есть явный comparison/integration intent с категорией ${positioning.matchedExcludedCategories.join(", ")}; используем только как supporting route, не как product primary.`; + } + + if (positioning.lane === "expansion_hypothesis") { + return "Фраза поддерживает owner/model expansion hypothesis, но не доказанную текущую способность продукта; требуется отдельное human/evidence решение."; } if (role === "secondary_candidate") { @@ -707,16 +817,16 @@ function getKeywordMapItemReason( if (role === "primary") { return targetPath - ? "Модель поставила фразу в посадочное ядро Stage 5: это strongest exact-запрос для спроса, а финальную посадочную подтвердит стратегия." - : "Модель поставила фразу в посадочное ядро Stage 5: есть exact Wordstat evidence, но посадочную ещё должна выбрать стратегия."; + ? "Система поставила фразу в посадочное ядро Stage 5: это strongest exact-запрос для спроса, а финальную посадочную подтвердит стратегия." + : "Система поставила фразу в посадочное ядро Stage 5: есть exact Wordstat evidence, но посадочную ещё должна выбрать стратегия."; } if (role === "secondary") { - return "Модель поставила фразу в поддерживающее ядро Stage 5: она раскрывает главный спрос и будущую структуру стратегии."; + return "Система поставила фразу в поддерживающее ядро Stage 5: она раскрывает главный спрос и будущую структуру стратегии."; } if (role === "support") { - return "Модель поставила фразу в длинный хвост Stage 5: использовать в FAQ, подписях, внутренних связях или вторичных блоках после стратегии."; + return "Система поставила фразу в длинный хвост Stage 5: использовать в FAQ, подписях, внутренних связях или вторичных блоках после стратегии."; } if (role === "validate" && hasSemanticFacetLoss(item, semanticFacetGuard)) { @@ -728,13 +838,13 @@ function getKeywordMapItemReason( if (item.decision === "article") { return role === "validate" ? "Keyword cleaning отнесла фразу в article/backlog: оставляем на ручной разбор до стратегии." - : "Модель предварительно распределила article/backlog-фразу; пользователь должен подтвердить, что она нужна общей стратегии."; + : "Система предварительно распределила article/backlog-фразу; пользователь должен подтвердить, что она нужна общей стратегии."; } if (item.decision === "risky") { return role === "validate" ? item.reason - : `Модель предварительно распределила спорную фразу; проверь перед фиксацией. ${item.reason}`; + : `Система предварительно распределила спорную фразу; проверь перед фиксацией. ${item.reason}`; } if (item.evidenceStatus === "collected_no_signal") { @@ -870,6 +980,8 @@ function normalizePersistedKeywordMapItem(input: { mapItemId: string | null; pageId: string | null; phrase: string; + keywordMapRevision: string; + positioningRevision: string; relatedLanding: string | null; role: string; sourceItemId: string | null; @@ -882,6 +994,8 @@ function normalizePersistedKeywordMapItem(input: { mapItemId: input.mapItemId, pageId: input.pageId, phrase: input.phrase, + keywordMapRevision: input.keywordMapRevision, + positioningRevision: input.positioningRevision, relatedLanding: input.relatedLanding, role: input.role, sourceItemId: input.sourceItemId, @@ -893,14 +1007,17 @@ function normalizePersistedKeywordMapItem(input: { async function getKeywordMapPersistenceSummary( projectId: string, semanticRunId: string | null, - projectOntologyVersionId: string | null + projectOntologyVersionId: string | null, + keywordMapRevision: string, + positioningRevision: string ): Promise { if (!semanticRunId || !projectOntologyVersionId) { return { approvedDecisionCount: 0, items: [], latestApprovedAt: null, - mapItemCount: 0 + mapItemCount: 0, + staleApprovedDecisionCount: 0 }; } @@ -915,6 +1032,8 @@ async function getKeywordMapPersistenceSummary( workspace_section_id: string | null; page_id: string | null; approved_at: Date; + positioning_revision: string | null; + keyword_map_revision: string | null; }>( ` select @@ -927,6 +1046,8 @@ async function getKeywordMapPersistenceSummary( coalesce(kmi.payload->>'relatedLanding', kd.payload->>'relatedLanding') as related_landing, kmi.workspace_section_id, kmi.page_id, + kd.payload->>'keywordMapRevision' as keyword_map_revision, + kd.payload->>'positioningRevision' as positioning_revision, kd.updated_at as approved_at from keyword_decisions kd left join keyword_map_items kmi on kmi.keyword_decision_id = kd.id and kmi.project_id = kd.project_id and kmi.approved = true @@ -938,21 +1059,27 @@ async function getKeywordMapPersistenceSummary( `, [projectId, semanticRunId, projectOntologyVersionId] ); - const decisionIds = new Set(result.rows.map((row) => row.decision_id)); - const mapItemIds = new Set(result.rows.map((row) => row.map_item_id).filter(Boolean)); - const latestApprovedAt = result.rows[0]?.approved_at?.toISOString() ?? null; + const currentRows = result.rows.filter((row) => row.keyword_map_revision === keywordMapRevision); + const staleRows = result.rows.filter((row) => row.keyword_map_revision !== keywordMapRevision); + const decisionIds = new Set(currentRows.map((row) => row.decision_id)); + const staleDecisionIds = new Set(staleRows.map((row) => row.decision_id)); + const mapItemIds = new Set(currentRows.map((row) => row.map_item_id).filter(Boolean)); + const latestApprovedAt = currentRows[0]?.approved_at?.toISOString() ?? null; return { approvedDecisionCount: decisionIds.size, mapItemCount: mapItemIds.size, latestApprovedAt, - items: result.rows.map((row) => + staleApprovedDecisionCount: staleDecisionIds.size, + items: currentRows.map((row) => normalizePersistedKeywordMapItem({ approvedAt: row.approved_at.toISOString(), decisionId: row.decision_id, mapItemId: row.map_item_id, pageId: row.page_id, phrase: row.phrase, + keywordMapRevision, + positioningRevision, relatedLanding: row.related_landing, role: row.role ?? "unknown", sourceItemId: row.source_item_id, @@ -963,6 +1090,97 @@ async function getKeywordMapPersistenceSummary( }; } +type KeywordMapReviewRun = { + input: { + semanticRunId?: string | null; + projectOntologyVersionId?: string | null; + keywordMapRevision?: string | null; + positioningRevision?: string | null; + } | null; + output: { + approvedItemCount?: number; + reviewState?: string; + } | null; + completed_at: Date | null; +}; + +export function resolveKeywordMapReviewState(input: { + blockerCount: number; + currentApprovedDecisionCount: number; + currentReviewApproved: boolean; + staleApprovedDecisionCount: number; + staleReviewExists: boolean; +}): KeywordMapReviewState { + if (input.blockerCount > 0) return "blocked"; + if (input.currentReviewApproved) return "approved_current_revision"; + if (input.staleReviewExists || input.staleApprovedDecisionCount > 0) return "stale"; + if (input.currentApprovedDecisionCount > 0) return "partially_reviewed"; + return "unreviewed"; +} + +async function getKeywordMapReview( + projectId: string, + semanticRunId: string | null, + projectOntologyVersionId: string | null, + keywordMapRevision: string, + positioningRevision: string, + blockerCount: number, + persisted: KeywordMapPersistenceSummary +): Promise { + if (!semanticRunId || !projectOntologyVersionId) { + return { + approvedAt: null, + approvedItemCount: 0, + keywordMapRevision, + positioningRevision, + staleApprovedDecisionCount: persisted.staleApprovedDecisionCount, + state: "blocked" + }; + } + + const result = await pool.query( + ` + select input, output, completed_at + from runs + where project_id = $1 + and run_type = 'seo_keyword_map_review' + and status = 'done' + order by created_at desc + limit 50; + `, + [projectId] + ); + const alignedRuns = result.rows.filter( + (row) => + row.input?.semanticRunId === semanticRunId && + row.input?.projectOntologyVersionId === projectOntologyVersionId + ); + const currentReview = alignedRuns.find( + (row) => + row.input?.positioningRevision === positioningRevision && + row.input?.keywordMapRevision === keywordMapRevision && + row.output?.reviewState === "approved_current_revision" + ) ?? null; + const staleReviewExists = alignedRuns.some( + (row) => row.input?.keywordMapRevision && row.input.keywordMapRevision !== keywordMapRevision + ); + + return { + approvedAt: currentReview?.completed_at?.toISOString() ?? null, + approvedItemCount: currentReview?.output?.approvedItemCount ?? 0, + keywordMapRevision, + positioningRevision, + staleApprovedDecisionCount: persisted.staleApprovedDecisionCount, + state: resolveKeywordMapReviewState({ + blockerCount, + currentApprovedDecisionCount: persisted.approvedDecisionCount, + currentReviewApproved: Boolean(currentReview), + staleApprovedDecisionCount: persisted.staleApprovedDecisionCount, + staleReviewExists + }) + }; +} + async function getSuppressedKeywordMapItems( projectId: string, semanticRunId: string | null, @@ -1003,7 +1221,8 @@ async function getSuppressedKeywordMapItems( async function getPersistedKeywordMapLayouts( projectId: string, semanticRunId: string | null, - projectOntologyVersionId: string | null + projectOntologyVersionId: string | null, + keywordMapRevision: string ) { if (!semanticRunId || !projectOntologyVersionId) { return new Map(); @@ -1028,12 +1247,13 @@ async function getPersistedKeywordMapLayouts( and semantic_run_id = $2 and project_ontology_version_id = $3 and source_item_id is not null + and payload->>'keywordMapRevision' = $4 and ( (status = 'approved' and approved = true) or status = 'layout' ); `, - [projectId, semanticRunId, projectOntologyVersionId] + [projectId, semanticRunId, projectOntologyVersionId, keywordMapRevision] ); const layouts = new Map(); @@ -1095,12 +1315,14 @@ function buildNextActions(contract: Omit) { actions.push("Собрать Wordstat evidence и пересобрать keyword map, чтобы роли опирались на частотность."); } - if (contract.readiness.persistableItemCount > contract.persisted.approvedDecisionCount) { + if (contract.review.state === "stale") { + actions.push("Позиционирование или evidence-карта изменились: прежнее принятие устарело, нужно подтвердить текущую revision."); + } else if (contract.review.state !== "approved_current_revision") { actions.push( `Сохранить ${contract.readiness.persistableItemCount} evidence-confirmed keyword decisions и привязать их к workspace sections.` ); } else if (contract.persisted.approvedDecisionCount > 0) { - actions.push("Approved keyword decisions уже сохранены; перед rewrite сверить их с текущим keyword-cleaning contract."); + actions.push("Текущая revision keyword map принята; можно переходить к page/section strategy."); } else { actions.push("Нет exact-frequency keyword items для approved decisions; спорные фразы остаются в validate lane."); } @@ -1108,21 +1330,38 @@ function buildNextActions(contract: Omit) { return actions; } -export async function getKeywordMapContract(projectId: string): Promise { - const [marketContract, cleaningContract] = await Promise.all([ +async function buildKeywordMapContract(projectId: string): Promise { + const [marketContract, cleaningContract, positioningThesis] = await Promise.all([ getMarketEnrichmentContract(projectId), - getKeywordCleaningContract(projectId) + getKeywordCleaningContract(projectId), + getSeoPositioningThesis(projectId) ]); const reviewedOntology = isReviewedOntology(marketContract.projectOntologyVersion); const blockers = buildBlockers(marketContract, cleaningContract); const semanticRunId = marketContract.semanticRunId; const projectOntologyVersionId = marketContract.projectOntologyVersion?.id ?? null; + const baseItems = buildItems(marketContract, cleaningContract, reviewedOntology, positioningThesis); + const revision = buildKeywordMapRevision({ + items: baseItems, + positioningRevision: positioningThesis.revision, + projectOntologyVersionId, + semanticRunId + }); const [suppressedItems, persistedLayouts, persisted] = await Promise.all([ getSuppressedKeywordMapItems(projectId, semanticRunId, projectOntologyVersionId), - getPersistedKeywordMapLayouts(projectId, semanticRunId, projectOntologyVersionId), - getKeywordMapPersistenceSummary(projectId, semanticRunId, projectOntologyVersionId) + getPersistedKeywordMapLayouts(projectId, semanticRunId, projectOntologyVersionId, revision), + getKeywordMapPersistenceSummary(projectId, semanticRunId, projectOntologyVersionId, revision, positioningThesis.revision) ]); - const items = buildItems(marketContract, cleaningContract, reviewedOntology) + const review = await getKeywordMapReview( + projectId, + semanticRunId, + projectOntologyVersionId, + revision, + positioningThesis.revision, + blockers.length, + persisted + ); + const items = baseItems .map((item) => { const persistedLayout = persistedLayouts.get(item.id); @@ -1135,6 +1374,8 @@ export async function getKeywordMapContract(projectId: string): Promise = { schemaVersion: "keyword-map.v1", projectId, + revision, + positioningRevision: positioningThesis.revision, semanticRunId: marketContract.semanticRunId, projectOntologyVersion: marketContract.projectOntologyVersion, generatedAt: new Date().toISOString(), @@ -1160,6 +1401,7 @@ export async function getKeywordMapContract(projectId: string): Promise>(); + +export async function getKeywordMapContract(projectId: string): Promise { + const existing = keywordMapInFlight.get(projectId); + if (existing) return existing; + + const pending = buildKeywordMapContract(projectId); + keywordMapInFlight.set(projectId, pending); + try { + return await pending; + } finally { + if (keywordMapInFlight.get(projectId) === pending) { + keywordMapInFlight.delete(projectId); + } + } +} + export async function approveKeywordMapDecisions( projectId: string, input: KeywordMapApproveInput = {} @@ -1339,6 +1598,8 @@ export async function approveKeywordMapDecisions( item.frequency, item.frequencyGroup, JSON.stringify({ + keywordMapRevision: keywordMap.revision, + positioningRevision: keywordMap.positioningRevision, relatedLanding: item.relatedLanding, role: item.role, source: item.source, @@ -1419,6 +1680,8 @@ export async function approveKeywordMapDecisions( item.frequency, item.frequencyGroup, JSON.stringify({ + keywordMapRevision: keywordMap.revision, + positioningRevision: keywordMap.positioningRevision, relatedLanding: item.relatedLanding, role: item.role, source: item.source, @@ -1487,6 +1750,8 @@ export async function approveKeywordMapDecisions( item.frequency, item.frequencyGroup, JSON.stringify({ + keywordMapRevision: keywordMap.revision, + positioningRevision: keywordMap.positioningRevision, relatedLanding: item.relatedLanding, role: item.role, source: item.source, @@ -1577,6 +1842,8 @@ export async function approveKeywordMapDecisions( evidenceStatus: item.evidenceStatus, frequency: item.frequency, frequencyGroup: item.frequencyGroup, + keywordMapRevision: keywordMap.revision, + positioningRevision: keywordMap.positioningRevision, relatedLanding: item.relatedLanding, reason: item.reason }) @@ -1585,6 +1852,31 @@ export async function approveKeywordMapDecisions( savedMapItemCount += 1; } + if (!itemIdFilter && canApproveMap) { + await client.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_keyword_map_review', 'done', $2::jsonb, $3::jsonb, now(), now()); + `, + [ + projectId, + JSON.stringify({ + keywordMapRevision: keywordMap.revision, + positioningRevision: keywordMap.positioningRevision, + projectOntologyVersionId: keywordMap.projectOntologyVersion.id, + schemaVersion: "seo-keyword-map-review-input.v1", + semanticRunId: keywordMap.semanticRunId + }), + JSON.stringify({ + approvedItemCount: approvableItems.length, + reviewState: "approved_current_revision", + schemaVersion: "seo-keyword-map-review.v1", + suppressedItemCount: suppressedItems.length + }) + ] + ); + } + await client.query("commit"); return { diff --git a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts index a364f0c..75965f2 100644 --- a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts +++ b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts @@ -1,5 +1,6 @@ -import { env } from "../config/env.js"; import { getLatestSemanticAnalysis, type SemanticAnalysisRun } from "../analysis/semanticAnalysis.js"; +import { getRejectedDemandScopeReason } from "../contextReview/demandScopeGuard.js"; +import { getLatestSeoDemandResearchBrief } from "../contextReview/demandResearchBrief.js"; import { pool } from "../db/client.js"; import { getLatestProjectOntologyVersion, @@ -19,6 +20,11 @@ import { type AnchorReviewWordstatQueueItem } from "../keywords/anchorReview.js"; import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js"; +import { + getQueryDiscoveryContract, + getWordstatCandidatesFromQueryDiscovery, + type QueryDiscoveryContract +} from "../queryDiscovery/queryDiscovery.js"; import { createWordstatProvider } from "./wordstatProvider.js"; import { getSeoCommercialDemandContract, @@ -28,6 +34,24 @@ import { getMarketFrontierDiscoveryContract, mapMarketFrontierProbeSeedsToWordstatCandidates } from "./marketFrontierDiscovery.js"; +import { planPostWordstatExpansion, type PostWordstatExpansionPlan } from "../queryDiscovery/postWordstatExpansionPlanner.js"; +import { + applyPostWordstatProductFitGate, + type PostWordstatProductFitGate +} from "../queryDiscovery/postWordstatProductFitGate.js"; +import { + planPostWordstatBatch2, + type PostWordstatBatch2Plan +} from "../queryDiscovery/postWordstatBatch2Planner.js"; +import { + listPostWordstatCandidateDecisions, + listPostWordstatClusterDecisions +} from "../queryDiscovery/postWordstatRepository.js"; +import { getSerpRuntimeConfig } from "../settings/externalServiceSettings.js"; +import { + getDefaultSeoModelProviderId, + getSeoModelProviderDescriptor +} from "../modelProvider/modelProviderRegistry.js"; type MarketProviderId = "model" | "serp" | "text_quality" | "wordstat"; type MarketProviderStatus = "connected" | "connected_partial" | "disabled" | "not_configured" | "not_implemented"; @@ -72,11 +96,14 @@ export type MarketEnrichmentContract = { state: MarketEnrichmentState; normalization: SeoNormalizationContract; anchorReview: AnchorReviewContract; + queryDiscovery: QueryDiscoveryContract; demandCollectionProfile: AnchorReviewContract["demandCollectionProfile"]; readiness: { anchorApprovedCount: number; anchorPendingCount: number; anchorReviewReady: boolean; + queryDiscoveryReady: boolean; + wordstatReadyCount: number; anchorWordstatQueueCount: number; canCollectWordstat: boolean; canCollectSerp: boolean; @@ -128,19 +155,24 @@ export type MarketEnrichmentContract = { projectOntologyVersionId: string | null; }>; wordstat: WordstatEvidence; + postWordstatExpansion: PostWordstatExpansionPlan; + postWordstatProductFit: PostWordstatProductFitGate; + wordstatBatch2: PostWordstatBatch2Plan; + readModel: { + mode: "full" | "dev_compact"; + postWordstatCandidateDetails: "embedded" | "cluster_only"; + rawWordstatRowCount: number; + embeddedRawWordstatRowCount: number; + rawWordstatEndpoint: string; + }; nextActions: string[]; }; -function isSerpConfigured() { - const config = env.marketProviders.serp; - - return config.provider !== "disabled" && Boolean(config.apiBaseUrl && config.hasApiToken); -} - async function buildProviderRegistry(): Promise { - const serpConfig = env.marketProviders.serp; - const wordstatStatus = (await createWordstatProvider()).getStatus(); - const serpConfigured = isSerpConfigured(); + const [wordstatProvider, serpConfig] = await Promise.all([createWordstatProvider(), getSerpRuntimeConfig()]); + const wordstatStatus = wordstatProvider.getStatus(); + const serpConfigured = serpConfig.provider === "yandex_api"; + const modelProvider = getSeoModelProviderDescriptor(getDefaultSeoModelProviderId()); return [ { @@ -160,22 +192,24 @@ async function buildProviderRegistry(): Promise { label: "Yandex SERP", role: "Типы страниц в выдаче, конкурентные углы, сложность и сниппеты.", status: serpConfigured ? "connected" : "not_configured", - mode: serpConfig.provider, + mode: serpConfig.mode, userActionRequired: false, platformActionRequired: !serpConfigured, message: serpConfigured - ? "SERP provider настроен; можно собирать конкурентные evidence." + ? "Yandex Search API WebSearch подключён; backend собирает intent, page type и competitive evidence." : "SERP provider ещё не настроен; deterministic analysis не должен притворяться рыночной проверкой." }, { id: "model", label: "Model Provider", role: "Классификация неоднозначных фраз, интентов и конкурентных формулировок.", - status: "not_implemented", - mode: "provider_abstraction", + status: modelProvider.status === "active" ? "connected" : "not_configured", + mode: modelProvider.id, userActionRequired: false, - platformActionRequired: true, - message: "Model provider будет подключаться платформенно через общий AI layer; в MVP решение не просит пользователя о ключах." + platformActionRequired: modelProvider.status !== "active", + message: modelProvider.status === "active" + ? `${modelProvider.title}: contract-only task route активен; output остаётся proposal/evidence и проходит schema validation.` + : `${modelProvider.title}: ${modelProvider.nextSetupStep}` }, { id: "text_quality", @@ -318,14 +352,25 @@ const DEMAND_COLLECTION_MARKET_WIDE_PROBE_BUDGET = 12; const DEMAND_COLLECTION_MARKET_WIDE_ANCHOR_FALLBACK_PROBE_BUDGET = 2; const DEMAND_COLLECTION_MARKET_WIDE_FRONTIER_PROBE_BUDGET = 12; const DEMAND_COLLECTION_MARKET_WIDE_MIN_PROBE_SCORE = 64; + +export function getDemandCollectionExecutionPhase( + profile: "contextual" | "market_wide", + collectedSeedCount: number +) { + return profile === "market_wide" && collectedSeedCount > 0 + ? "deep_wordstat" as const + : "exploratory_wordstat" as const; +} const WORDSTAT_PROBE_MAX_PER_PATTERN_FAMILY = 4; const WORDSTAT_PROBE_MAX_PER_CLUSTER = 8; type WordstatProbeSourceKind = | "approved_anchor" + | "base_entity" | "commercial_demand" | "market_frontier_discovery" | "market_anchor_expansion" + | "observed_query" | "previous_expansion"; type WordstatProbeCandidate = WordstatSeedCandidate & { @@ -450,6 +495,8 @@ function getMarketRoleProbeWeight(role: AnchorReviewWordstatQueueItem["marketRol } function getProbeSourceWeight(source: WordstatProbeSourceKind) { + if (source === "observed_query") return 48; + if (source === "base_entity") return 30; if (source === "market_frontier_discovery") return 42; if (source === "approved_anchor") return 28; if (source === "commercial_demand") return 20; @@ -1636,6 +1683,74 @@ function buildSeedQueue( return [...anchorItems, ...expansionItems].slice(0, 260); } +function buildDiscoverySeedQueue( + discovery: QueryDiscoveryContract, + providers: MarketProviderDescriptor[], + wordstat: WordstatEvidence, + projectOntologyVersion: MarketProjectOntologyVersion | null, + suppressedPhrases: Set = new Set() +) { + const wordstatResults = buildWordstatSeedMap(wordstat); + const ontologyReady = isOntologyReadyForMarket(projectOntologyVersion); + const seen = new Set(); + const candidates = getWordstatCandidatesFromQueryDiscovery(discovery) + .filter((candidate) => !suppressedPhrases.has(normalizePhrase(candidate.phrase))) + .map((candidate) => { + const normalizedPhrase = normalizePhrase(candidate.phrase); + const result = wordstatResults.get(normalizedPhrase); + seen.add(`${normalizedPhrase}:${candidate.clusterId}`); + const evidenceStatus = ontologyReady ? getSeedEvidenceStatus(providers, wordstat, result) : "not_collected"; + + return { + phrase: candidate.phrase, + clusterId: candidate.clusterId, + clusterTitle: candidate.clusterTitle, + priority: candidate.priority, + source: candidate.source, + normalization: null, + evidenceStatus, + targetProviders: ["wordstat" as const], + blocker: getSeedBlocker(evidenceStatus), + frequency: result?.frequency ?? null, + frequencyGroup: result?.frequencyGroup ?? null, + relatedCount: result?.relatedCount ?? 0, + lastCollectedAt: result?.collectedAt ?? null, + projectOntologyVersionId: projectOntologyVersion?.id ?? null, + wordstatResultId: result?.id ?? null + }; + }); + const collectedItems = wordstat.seedResults + .filter((result) => !suppressedPhrases.has(normalizePhrase(result.phrase))) + .filter((result) => { + const key = `${normalizePhrase(result.phrase)}:${result.clusterId ?? ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .map((result) => { + const evidenceStatus = ontologyReady ? getSeedEvidenceStatus(providers, wordstat, result) : "not_collected"; + return { + phrase: result.phrase, + clusterId: result.clusterId ?? "market-observed", + clusterTitle: result.clusterTitle ?? "Observed market query", + priority: result.priority ?? "medium", + source: result.seedSource === "ontology" ? "ontology" as const : "detected" as const, + normalization: null, + evidenceStatus, + targetProviders: ["wordstat" as const], + blocker: getSeedBlocker(evidenceStatus), + frequency: result.frequency, + frequencyGroup: result.frequencyGroup, + relatedCount: result.relatedCount, + lastCollectedAt: result.collectedAt, + projectOntologyVersionId: projectOntologyVersion?.id ?? null, + wordstatResultId: result.id + }; + }); + + return [...candidates, ...collectedItems].slice(0, 260); +} + function shouldExpandSeed(seed: MarketEnrichmentContract["seedQueue"][number]) { return ( seed.evidenceStatus === "collected" || @@ -1769,7 +1884,7 @@ function getEnrichmentState( providers: MarketProviderDescriptor[], seedCount: number, wordstat: WordstatEvidence, - anchorReview: AnchorReviewContract, + queryDiscoveryReady: boolean, projectOntologyVersion: MarketProjectOntologyVersion | null ): MarketEnrichmentState { if (!analysis) { @@ -1780,7 +1895,7 @@ function getEnrichmentState( return "not_ready"; } - if (anchorReview.state !== "approved") { + if (!queryDiscoveryReady) { return "not_ready"; } @@ -1825,10 +1940,13 @@ function buildNextActions(contract: Omit { - const analysis = await getLatestSemanticAnalysis(projectId); - const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis)); - const providers = await buildProviderRegistry(); - const normalization = await getSeoNormalizationContract(projectId); - const anchorReview = await getAnchorReviewContract(projectId); - const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null); +async function buildMarketEnrichmentContract(projectId: string): Promise { + const analysis = (await getLatestSemanticAnalysis(projectId))!; + const [boundOntologyVersion, providers, normalization, anchorReview, queryDiscovery, wordstat] = await Promise.all([ + getBoundOntologyVersion(projectId, analysis), + buildProviderRegistry(), + getSeoNormalizationContract(projectId), + getAnchorReviewContract(projectId), + getQueryDiscoveryContract(projectId), + getLatestWordstatEvidence(projectId, analysis?.runId ?? null) + ]); + const projectOntologyVersion = mapMarketOntologyVersion(boundOntologyVersion); const suppressedPhrases = await getSuppressedMarketPhraseSet( projectId, analysis?.runId ?? null, projectOntologyVersion?.id ?? null ); - const seedQueue = analysis ? buildSeedQueue(anchorReview, providers, wordstat, projectOntologyVersion, suppressedPhrases) : []; - const briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : []; + const queryDiscoveryReady = queryDiscovery.state === "ready"; + const seedQueue = analysis && queryDiscoveryReady + ? buildDiscoverySeedQueue(queryDiscovery, providers, wordstat, projectOntologyVersion, suppressedPhrases) + : []; + const briefEvidence = analysis && queryDiscoveryReady ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : []; const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length; const ontologyReadyForMarket = isOntologyReadyForMarket(projectOntologyVersion); + const postWordstatExpansion = planPostWordstatExpansion({ + surfaces: queryDiscovery.marketExpansion.surfaces, + wordstat + }); + const postWordstatProductFit = applyPostWordstatProductFitGate({ + surfaces: queryDiscovery.marketExpansion.surfaces, + wordstat + }); + const [postWordstatDecisions, postWordstatClusterDecisions] = await Promise.all([ + listPostWordstatCandidateDecisions(projectId, analysis?.runId ?? null), + listPostWordstatClusterDecisions(projectId, analysis?.runId ?? null) + ]); + const wordstatBatch2 = planPostWordstatBatch2({ + clusterDecisions: postWordstatClusterDecisions, + decisions: postWordstatDecisions, + gate: postWordstatProductFit + }); const contractWithoutActions: Omit = { schemaVersion: "market-enrichment.v1", projectId, semanticRunId: analysis?.runId ?? null, projectOntologyVersion, generatedAt: new Date().toISOString(), - state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat, anchorReview, projectOntologyVersion), + state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat, queryDiscoveryReady, projectOntologyVersion), normalization, anchorReview, + queryDiscovery, demandCollectionProfile: anchorReview.demandCollectionProfile, readiness: { anchorApprovedCount: anchorReview.readiness.approvedAnchorCount, anchorPendingCount: anchorReview.readiness.pendingAnchorCount, anchorReviewReady: anchorReview.state === "approved", + queryDiscoveryReady, + wordstatReadyCount: queryDiscovery.readiness.wordstatReadyCount, anchorWordstatQueueCount: anchorReview.readiness.wordstatQueueCount, canCollectWordstat: ontologyReadyForMarket && - anchorReview.state === "approved" && + queryDiscovery.readiness.wordstatReadyCount > 0 && getProviderStatus(providers, "wordstat") === "connected" && seedQueue.length > 0 && wordstat.latestJob?.status !== "running", @@ -1924,7 +2069,17 @@ export async function getMarketEnrichmentContract(projectId: string): Promise>(); + +export async function getMarketEnrichmentContract(projectId: string): Promise { + const existing = marketEnrichmentInFlight.get(projectId); + if (existing) return existing; + + const pending = buildMarketEnrichmentContract(projectId); + marketEnrichmentInFlight.set(projectId, pending); + try { + return await pending; + } finally { + if (marketEnrichmentInFlight.get(projectId) === pending) { + marketEnrichmentInFlight.delete(projectId); + } + } +} + +export function projectMarketEnrichmentForClient(contract: MarketEnrichmentContract): MarketEnrichmentContract { + const rawPreview = contract.wordstat.topResults.slice(0, 10); + return { + ...contract, + anchorReview: { + ...contract.anchorReview, + anchors: [], + wordstatQueue: [] + }, + normalization: { + ...contract.normalization, + modelTask: null, + seedStrategy: [] + }, + postWordstatProductFit: { + ...contract.postWordstatProductFit, + candidates: [], + lanes: { + adjacentResearch: [], + commercialBatch2Review: [], + informationalContent: [], + rejected: [], + validatedSeeds: [] + }, + productFitClusters: [] + }, + readModel: { + embeddedRawWordstatRowCount: rawPreview.length, + mode: "dev_compact", + postWordstatCandidateDetails: "cluster_only", + rawWordstatEndpoint: `/projects/${contract.projectId}/post-wordstat/evidence`, + rawWordstatRowCount: contract.wordstat.topResults.length + }, + queryDiscovery: { + ...contract.queryDiscovery, + marketExpansion: { + ...contract.queryDiscovery.marketExpansion, + matrix: [], + observations: [], + suggestProbes: [] + }, + normalizedObservations: [], + observations: [], + patterns: [], + probes: [] + }, + wordstat: { + ...contract.wordstat, + topResults: rawPreview + }, + wordstatBatch2: { + ...contract.wordstatBatch2, + reviewCandidates: [] + } + }; +} + async function buildMarketWideWordstatProbeCandidates(input: { analysis: SemanticAnalysisRun; anchorProbeSeeds: WordstatProbeCandidate[]; @@ -2009,9 +2238,11 @@ function getWordstatProbePlanBlockers(input: { function getCandidateSourceCounts(candidates: WordstatProbeCandidate[]) { const counts: Record = { approved_anchor: 0, + base_entity: 0, commercial_demand: 0, market_anchor_expansion: 0, market_frontier_discovery: 0, + observed_query: 0, previous_expansion: 0 }; @@ -2197,12 +2428,180 @@ async function collectWordstatEvidenceBatches( return market; } +function isPlausibleUniversalProbePhrase(phrase: string) { + const normalizedPhrase = normalizePhrase(phrase); + const words = wordCount(normalizedPhrase); + + return ( + words >= 1 && + words <= 10 && + normalizedPhrase.length >= 3 && + !/[{}[\]<>#$%^*=~`/|]/.test(normalizedPhrase) && + !/^(n\/a|null|undefined|нет данных|не применимо)$/i.test(normalizedPhrase) + ); +} + +function buildQueryDiscoveryWordstatProbes(discovery: QueryDiscoveryContract, suppressedPhrases: Set) { + return getWordstatCandidatesFromQueryDiscovery(discovery) + .filter((candidate) => !suppressedPhrases.has(normalizePhrase(candidate.phrase))) + .filter((candidate) => isPlausibleUniversalProbePhrase(candidate.phrase)) + .map((candidate) => ({ + clusterId: candidate.clusterId, + clusterTitle: candidate.clusterTitle, + confidence: candidate.confidence, + phrase: candidate.phrase, + priority: candidate.priority, + probeSource: candidate.provenance === "base_entity" ? "base_entity" : "observed_query", + reason: candidate.reason, + source: candidate.source + })); +} + +function scoreUniversalQueryDiscoveryProbe(candidate: WordstatProbeCandidate) { + if (!isPlausibleUniversalProbePhrase(candidate.phrase)) { + return Number.NEGATIVE_INFINITY; + } + + const evidenceScore = Math.round((candidate.confidence ?? 0) * 100); + const sourceScore = candidate.probeSource === "observed_query" ? 22 : 0; + const priorityScore = candidate.priority === "high" ? 10 : candidate.priority === "medium" ? 5 : 0; + + return evidenceScore + sourceScore + priorityScore; +} + +function selectQueryDiscoveryWordstatProbes( + candidates: WordstatProbeCandidate[], + wordstat: WordstatEvidence, + limit: number, + blockedPhrases: Set +) { + const reusablePhrases = getReusableWordstatEvidencePhraseSet(wordstat); + const selected: WordstatProbeCandidate[] = []; + const seen = new Set(); + + for (const candidate of candidates + .map((candidate) => ({ candidate, score: scoreUniversalQueryDiscoveryProbe(candidate) })) + .filter((item) => Number.isFinite(item.score)) + .sort((left, right) => right.score - left.score || left.candidate.phrase.localeCompare(right.candidate.phrase, "ru"))) { + const phrase = normalizePhrase(candidate.candidate.phrase); + if (seen.has(phrase) || blockedPhrases.has(phrase) || reusablePhrases.has(phrase)) continue; + seen.add(phrase); + selected.push(candidate.candidate); + if (selected.length >= limit) break; + } + + return selected; +} + +async function getQueryDiscoveryWordstatProbePreview(projectId: string): Promise { + const [analysis, providers, anchorReview, discovery] = await Promise.all([ + getLatestSemanticAnalysis(projectId), + buildProviderRegistry(), + getAnchorReviewContract(projectId), + getQueryDiscoveryContract(projectId) + ]); + const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis)); + const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null); + const suppressedPhrases = await getSuppressedMarketPhraseSet( + projectId, + analysis?.runId ?? null, + projectOntologyVersion?.id ?? null + ); + const requestedPhrases = analysis ? await getRequestedWordstatPhraseSet(projectId, analysis.runId) : new Set(); + const probeBudget = getWordstatProbeBudget(anchorReview.demandCollectionProfile); + const candidates = discovery.state === "ready" ? buildQueryDiscoveryWordstatProbes(discovery, suppressedPhrases) : []; + const blockers: string[] = []; + if (!analysis) blockers.push("Сначала нужен semantic analysis проекта."); + if (!isOntologyReadyForMarket(projectOntologyVersion)) blockers.push("Project ontology не готова для market collection."); + if (discovery.state !== "ready") blockers.push("Сначала нужен model-backed topic decomposition; model keys не используются."); + if (getProviderStatus(providers, "wordstat") !== "connected") blockers.push("Wordstat provider не подключен."); + if (candidates.length === 0) blockers.push("Нет approved base probes или observed query candidates."); + const selectedSeeds = blockers.length === 0 + ? selectQueryDiscoveryWordstatProbes(candidates, wordstat, probeBudget, new Set([...requestedPhrases, ...suppressedPhrases])) + : []; + const auditItems = candidates.map((candidate) => { + const normalizedPhrase = normalizePhrase(candidate.phrase); + const score = scoreUniversalQueryDiscoveryProbe(candidate); + const selected = selectedSeeds.some((seed) => normalizePhrase(seed.phrase) === normalizedPhrase); + const status: WordstatProbeAuditStatus = selected + ? "selected" + : suppressedPhrases.has(normalizedPhrase) + ? "suppressed" + : requestedPhrases.has(normalizedPhrase) + ? "already_requested" + : !Number.isFinite(score) + ? "invalid_phrase" + : "not_selected_budget_or_balance"; + return { + ...mapWordstatProbePlanSelectedItem(candidate, Number.isFinite(score) ? score : 0), + status, + statusLabel: getWordstatProbeAuditStatusLabel(status) + }; + }); + const selectedProbes = selectedSeeds.map((seed) => + mapWordstatProbePlanSelectedItem(seed, scoreUniversalQueryDiscoveryProbe(seed)) + ); + const wordstatProvider = providers.find((provider) => provider.id === "wordstat"); + const candidateSourceCounts = getCandidateSourceCounts(candidates); + const rejectedSummary = getRejectedSummary(auditItems); + + return { + schemaVersion: "wordstat-probe-plan-preview.v1", + projectId, + semanticRunId: analysis?.runId ?? null, + generatedAt: new Date().toISOString(), + demandCollectionProfile: anchorReview.demandCollectionProfile, + orchestration: { + mode: "backend_orchestrated_contract_pipeline", + executionMode: "preview_only", + modelCanCallWordstat: false, + steps: [ + "Model supplies semantic topic slots only.", + "Backend expands allowed universal patterns into probes without treating them as keys.", + "Suggest/first-party observations or explicit base-probe approvals become candidates.", + "Backend dedupes and sends only a bounded evidence-backed batch to Wordstat." + ] + }, + provider: { + wordstatMode: wordstatProvider?.mode ?? "not_configured", + wordstatStatus: wordstatProvider?.status ?? "not_configured" + }, + readiness: { + blockers, + canExecute: blockers.length === 0 && selectedProbes.length > 0, + candidateCount: candidates.length, + probeBudget, + selectedCount: selectedProbes.length + }, + memory: { + collectedPhraseCount: getReusableWordstatEvidencePhraseSet(wordstat).size, + frontierProbeSeedCount: 0, + frontierSourceTaskId: discovery.topicDecomposition.sourceTaskId, + requestedPhraseCount: requestedPhrases.size, + suppressedPhraseCount: suppressedPhrases.size + }, + selectedProbes, + warnings: selectedProbes.length < probeBudget + ? [{ code: "low_selected_count", level: "info", examples: [], message: "Лимит не заполняется pattern-only гипотезами без observed evidence." }] + : [], + candidateSourceCounts, + rejectedSummary, + rejectedSamples: auditItems.filter((item) => item.status !== "selected").slice(0, 24), + nextActions: blockers.length > 0 + ? blockers + : ["Preview-only: Wordstat не вызван.", `Реальный запуск отправит ${selectedProbes.length} observed/approved probe-фраз.`] + }; +} + export async function getWordstatProbePlanPreview(projectId: string): Promise { - const analysis = await getLatestSemanticAnalysis(projectId); + return getQueryDiscoveryWordstatProbePreview(projectId); + + const analysis = (await getLatestSemanticAnalysis(projectId))!; const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis)); const providers = await buildProviderRegistry(); const anchorReview = await getAnchorReviewContract(projectId); const commercialDemand = await getSeoCommercialDemandContract(projectId); + const demandResearchBrief = (await getLatestSeoDemandResearchBrief(projectId, analysis.runId))!; const suppressedPhrases = await getSuppressedMarketPhraseSet( projectId, analysis?.runId ?? null, @@ -2210,7 +2609,8 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise(); const blockedProbePhrases = new Set([...requestedPhrases, ...suppressedPhrases]); - const anchorProbeSeeds = buildAnchorWordstatProbeSeeds(anchorReview, suppressedPhrases); + const anchorProbeSeeds = buildAnchorWordstatProbeSeeds(anchorReview, suppressedPhrases) + .filter((seed) => !getRejectedDemandScopeReason(seed.phrase, demandResearchBrief)); const blockers = getWordstatProbePlanBlockers({ analysis, anchorProbeSeedCount: anchorProbeSeeds.length, @@ -2218,6 +2618,9 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise provider.id === "wordstat"); @@ -2239,7 +2642,10 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise !getRejectedDemandScopeReason( + candidate.phrase, + demandResearchBrief + )); frontierSourceTaskId = marketWidePlan.marketFrontierDiscovery.sourceTaskId; frontierProbeSeedCount = marketWidePlan.marketFrontierDiscovery.probeSeeds.length; frontierProviderMode = marketWidePlan.marketFrontierDiscovery.provider.mode; @@ -2331,11 +2737,61 @@ export async function getWordstatProbePlanPreview(projectId: string): Promise { + const queryDiscovery = await getQueryDiscoveryContract(projectId); + if (queryDiscovery.state !== "ready") { + return getMarketEnrichmentContract(projectId); + } + + if (queryDiscovery.state === "ready") { + const analysis = await getLatestSemanticAnalysis(projectId); + const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis)); + const providers = await buildProviderRegistry(); + const anchorReview = await getAnchorReviewContract(projectId); + const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null); + const suppressedPhrases = await getSuppressedMarketPhraseSet( + projectId, + analysis?.runId ?? null, + projectOntologyVersion?.id ?? null + ); + const requestedPhrases = analysis ? await getRequestedWordstatPhraseSet(projectId, analysis.runId) : new Set(); + const candidates = buildQueryDiscoveryWordstatProbes(queryDiscovery, suppressedPhrases); + + if ( + !analysis || + !isOntologyReadyForMarket(projectOntologyVersion) || + getProviderStatus(providers, "wordstat") !== "connected" || + candidates.length === 0 + ) { + return getMarketEnrichmentContract(projectId); + } + + const selectedSeeds = selectQueryDiscoveryWordstatProbes( + candidates, + wordstat, + getWordstatProbeBudget(anchorReview.demandCollectionProfile), + new Set([...requestedPhrases, ...suppressedPhrases]) + ); + + if (selectedSeeds.length === 0) { + return getMarketEnrichmentContract(projectId); + } + + await collectWordstatEvidenceBatches( + projectId, + analysis, + selectedSeeds, + DEMAND_COLLECTION_MAX_BASE_RUNS, + getWordstatProbeBudget(anchorReview.demandCollectionProfile) + ); + return getMarketEnrichmentContract(projectId); + } + const analysis = await getLatestSemanticAnalysis(projectId); const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis)); const providers = await buildProviderRegistry(); const anchorReview = await getAnchorReviewContract(projectId); const commercialDemand = await getSeoCommercialDemandContract(projectId); + const demandResearchBrief = analysis ? await getLatestSeoDemandResearchBrief(projectId, analysis.runId) : null; const suppressedPhrases = await getSuppressedMarketPhraseSet( projectId, analysis?.runId ?? null, @@ -2343,10 +2799,13 @@ export async function runMarketEnrichment(projectId: string): Promise(); const blockedProbePhrases = new Set([...requestedPhrases, ...suppressedPhrases]); - const anchorProbeSeeds = buildAnchorWordstatProbeSeeds(anchorReview, suppressedPhrases); + const anchorProbeSeeds = buildAnchorWordstatProbeSeeds(anchorReview, suppressedPhrases) + .filter((seed) => !getRejectedDemandScopeReason(seed.phrase, demandResearchBrief)); if ( !analysis || + !demandResearchBrief || + demandResearchBrief.state !== "approved" || !isOntologyReadyForMarket(projectOntologyVersion) || anchorReview.state !== "approved" || getProviderStatus(providers, "wordstat") !== "connected" || @@ -2363,7 +2822,10 @@ export async function runMarketEnrichment(projectId: string): Promise !getRejectedDemandScopeReason( + seed.phrase, + demandResearchBrief + )); if (contextualProbeSeeds.length === 0) { return marketBeforeCollection; @@ -2379,6 +2841,36 @@ export async function runMarketEnrichment(projectId: string): Promise !getRejectedDemandScopeReason( + seed.phrase, + demandResearchBrief + )); + + if (exploratorySeeds.length === 0) { + return marketBeforeCollection; + } + + await collectWordstatEvidenceBatches( + projectId, + analysis, + exploratorySeeds, + DEMAND_COLLECTION_MAX_BASE_RUNS, + DEMAND_COLLECTION_CONTEXTUAL_PROBE_BUDGET + ); + + return getMarketEnrichmentContract(projectId); + } + const marketWidePlan = await buildMarketWideWordstatProbeCandidates({ analysis, anchorProbeSeeds, @@ -2396,7 +2888,10 @@ export async function runMarketEnrichment(projectId: string): Promise !getRejectedDemandScopeReason( + seed.phrase, + demandResearchBrief + )); if (frontierSeeds.length === 0) { return marketBeforeCollection; @@ -2415,3 +2910,22 @@ export async function runMarketEnrichment(projectId: string): Promise { + const market = await getMarketEnrichmentContract(projectId); + if (!market.semanticRunId || market.postWordstatProductFit.state !== "ready") return market; + if (market.wordstatBatch2.state !== "ready" || market.wordstatBatch2.queue.length === 0) return market; + const analysis = await getLatestSemanticAnalysis(projectId); + if (!analysis || analysis.runId !== market.semanticRunId) return market; + const candidates: WordstatSeedCandidate[] = market.wordstatBatch2.queue.map((item) => ({ + clusterId: item.targetSurfaceId ?? `post-wordstat:${item.candidateId}`, + clusterTitle: `Batch #2 · ${item.pageType}`, + confidence: item.productFitScore, + phrase: item.phrase, + priority: item.priority, + reason: `Wordstat related → ProductFitGate ${item.productFitScore} → human approved; intent ${item.intent}; relationship ${item.relationshipToCore}.`, + source: "detected" + })); + await collectWordstatEvidence(projectId, analysis, candidates, { maxSeeds: Math.min(12, candidates.length) }); + return getMarketEnrichmentContract(projectId); +} diff --git a/seo_mode/seo_mode/server/src/market/marketFrontierDiscovery.ts b/seo_mode/seo_mode/server/src/market/marketFrontierDiscovery.ts index 30c21df..d76f5cc 100644 --- a/seo_mode/seo_mode/server/src/market/marketFrontierDiscovery.ts +++ b/seo_mode/seo_mode/server/src/market/marketFrontierDiscovery.ts @@ -1,6 +1,15 @@ import crypto from "node:crypto"; import { getLatestSemanticAnalysis, type SemanticAnalysisRun } from "../analysis/semanticAnalysis.js"; import { getSeoCommercialDemandContract, type SeoCommercialDemandContract } from "../commercial/seoCommercialDemand.js"; +import { + getDemandScopeGuardSummary, + getRejectedDemandScopeReason, + getRejectedDemandScopeReasonFromGuard +} from "../contextReview/demandScopeGuard.js"; +import { + getLatestSeoDemandResearchBrief, + type SeoDemandResearchBriefContract +} from "../contextReview/demandResearchBrief.js"; import { pool } from "../db/client.js"; import { getAnchorReviewContract, type AnchorReviewContract } from "../keywords/anchorReview.js"; import { getLatestWordstatEvidence, type WordstatEvidence, type WordstatSeedCandidate } from "./wordstatRepository.js"; @@ -134,6 +143,7 @@ export type MarketFrontierDiscoveryModelTaskContract = { }>; marketMemory: MarketResearchMemory; queryLanguageFingerprint: QueryLanguageFingerprint; + demandScope: ReturnType; }; allowedActions: Array< | "detect_covered_demand" @@ -271,7 +281,7 @@ function clampConfidence(value: number) { return Math.max(0.1, Math.min(0.95, Number.isFinite(value) ? value : 0.55)); } -function getMarketFrontierMemoryHash(input: { +export function getMarketFrontierMemoryHash(input: { fingerprint: QueryLanguageFingerprint; memory: MarketResearchMemory; projectOntologyVersionId: string | null; @@ -290,9 +300,9 @@ function getMarketFrontierMemoryHash(input: { openHypotheses: input.memory.openHypotheses, fingerprint: { generatedFrom: input.fingerprint.generatedFrom, - marketNgrams: input.fingerprint.marketNgrams.slice(0, 18), - workingModifiers: input.fingerprint.workingModifiers.slice(0, 12), - workingPatterns: input.fingerprint.workingPatterns.slice(0, 10) + marketNgrams: (input.fingerprint.marketNgrams ?? []).slice(0, 18), + workingModifiers: (input.fingerprint.workingModifiers ?? []).slice(0, 12), + workingPatterns: (input.fingerprint.workingPatterns ?? []).slice(0, 10) } }) ) @@ -300,6 +310,40 @@ function getMarketFrontierMemoryHash(input: { .slice(0, 12); } +function mergeQueryLanguageFingerprint( + primary: Partial | null | undefined, + fallback: QueryLanguageFingerprint +): QueryLanguageFingerprint { + const generatedFrom = primary?.generatedFrom; + + return { + schemaVersion: "query-language-fingerprint.v1", + generatedFrom: { + exactPhraseCount: Number.isFinite(generatedFrom?.exactPhraseCount) + ? generatedFrom!.exactPhraseCount + : fallback.generatedFrom.exactPhraseCount, + relatedPhraseCount: Number.isFinite(generatedFrom?.relatedPhraseCount) + ? generatedFrom!.relatedPhraseCount + : fallback.generatedFrom.relatedPhraseCount, + seedPhraseCount: Number.isFinite(generatedFrom?.seedPhraseCount) + ? generatedFrom!.seedPhraseCount + : fallback.generatedFrom.seedPhraseCount + }, + marketNgrams: Array.isArray(primary?.marketNgrams) ? primary.marketNgrams : fallback.marketNgrams, + noisyPatterns: Array.isArray(primary?.noisyPatterns) ? primary.noisyPatterns : fallback.noisyPatterns, + summary: + typeof primary?.summary === "string" && primary.summary.trim() + ? primary.summary.trim() + : fallback.summary, + workingModifiers: Array.isArray(primary?.workingModifiers) + ? primary.workingModifiers + : fallback.workingModifiers, + workingPatterns: Array.isArray(primary?.workingPatterns) + ? primary.workingPatterns + : fallback.workingPatterns + }; +} + function isPlausibleProbePhrase(phrase: string) { const normalizedPhrase = normalizePhrase(phrase); @@ -563,7 +607,7 @@ function buildMarketResearchMemory( function inferFrontierType(value: string, buyerIntent?: string): FrontierType { const text = normalizePhrase(`${value} ${buyerIntent ?? ""}`); - if (/(промышлен|строител|бпла|робот|документооборот|учет|малый бизнес|медицина|логистик|ритейл|образован|финанс)/i.test(text)) { + if (/(^|\s)(vertical|sector|industry|отрасл|сектор|рынок)(\s|$)/i.test(text)) { return "vertical"; } @@ -656,142 +700,26 @@ function getAnchorSeedPhrases(anchorReview: AnchorReviewContract) { })); } -const SEMANTIC_APPLICATION_FRONTIER_RULES = [ - { - evidencePattern: /bim|digital\s+twin|цифров[а-яё]*\s+двойн|строител|инженер|3d|step|stl|las/i, - phrases: [ - "применение ии в строительстве", - "ии в строительстве", - "цифровой двойник в строительстве" - ], - title: "Строительство и инженерные объекты" - }, - { - evidencePattern: /документооборот|договор|регламент|согласован|процесс|тендер|закуп/i, - phrases: [ - "ии в документообороте", - "автоматизация документооборота с ии", - "ии автоматизация бизнес процессов", - "автоматизация рутинных задач с ии" - ], - title: "Документооборот и бизнес-процессы" - }, - { - evidencePattern: /промышлен|производств|iot|телеметр|робот|беспилот|бпла/i, - phrases: [ - "внедрение ии в промышленность", - "ии в промышленности", - "ии для производства", - "ии для беспилотников" - ], - title: "Промышленность и автономные системы" - }, - { - evidencePattern: /разработк|api|приложен|код|агент|ассистент|llm/i, - phrases: [ - "разработка ии агентов", - "разработка ai ассистентов", - "создание ии агентов для бизнеса" - ], - title: "Разработка ИИ-агентов" - }, - { - evidencePattern: /enterprise|корпоратив|контур|on[-\s]?prem|private\s+cloud|предприяти/i, - phrases: [ - "внедрение ии в бизнес", - "ии для предприятия", - "корпоративные ии агенты" - ], - title: "Корпоративное внедрение" - }, - { - evidencePattern: /1с|crm|erp|интеграц|api|данн|аналитик|хранилищ/i, - phrases: [ - "ии интеграция с 1с", - "ии автоматизация crm", - "автоматизация отчетности с ии" - ], - title: "Интеграции и данные" - } -]; - -function getSemanticApplicationSeedPhrases(analysis: SemanticAnalysisRun, anchorReview: AnchorReviewContract) { - const semanticText = [ - ...analysis.clusters.flatMap((cluster) => [ +function getSemanticContextSeedPhrases(analysis: SemanticAnalysisRun) { + return analysis.clusters.slice(0, 24).flatMap((cluster) => { + const priority: "high" | "medium" | "low" = + cluster.priority === "high" || cluster.priority === "low" ? cluster.priority : "medium"; + const phrases = unique([ + ...cluster.queryExamples, cluster.title, - cluster.targetIntent, - ...cluster.matchedTerms, - ...cluster.queryExamples - ]), - ...anchorReview.wordstatQueue.flatMap((anchor) => [ - anchor.phrase, - anchor.clusterTitle, - anchor.intentType, - anchor.marketRole, - ...anchor.normalizedFrom + ...cluster.matchedTerms.filter((term) => getPhraseTokens(term).length > 1) ]) - ].join(" "); - const hasAiAutomationContext = - /(^|\s)(ии|ai|llm|агент|ассистент|нейросет|искусственн[а-яё]*\s+интеллект|автоматизац)(\s|$)/i.test(semanticText); + .filter(isPlausibleProbePhrase) + .slice(0, 6); - if (!hasAiAutomationContext) { - return []; - } - - const seeds: Array<{ - phrase: string; - title: string; - priority: "high" | "medium" | "low"; - sourceText: string; - evidenceRefs: string[]; - }> = [ - { - phrase: "ии для бизнеса", - title: "Широкий B2B AI спрос", - priority: "high", - sourceText: "Semantic clusters show AI/agent/automation context; broad B2B probe checks market language before deeper verticals.", - evidenceRefs: ["semanticApplication:ai-business"] - }, - { - phrase: "ии агенты для бизнеса", - title: "AI-агенты для бизнеса", - priority: "high", - sourceText: "Semantic clusters contain agent/assistant automation; probe validates how buyers phrase agent demand.", - evidenceRefs: ["semanticApplication:ai-agents-business"] - }, - { - phrase: "автоматизация бизнес процессов с ии", - title: "AI-автоматизация процессов", - priority: "high", - sourceText: "Semantic clusters contain process/workflow automation; probe validates process automation language.", - evidenceRefs: ["semanticApplication:ai-process-automation"] - }, - { - phrase: "ии для малого бизнеса", - title: "Малый бизнес как B2B-сегмент", - priority: "medium", - sourceText: "Market-wide mode should test SME language when product value is framed as business automation.", - evidenceRefs: ["semanticApplication:small-business"] - } - ]; - - for (const rule of SEMANTIC_APPLICATION_FRONTIER_RULES) { - if (!rule.evidencePattern.test(semanticText)) { - continue; - } - - for (const phrase of rule.phrases) { - seeds.push({ - phrase, - title: rule.title, - priority: "high", - sourceText: `Semantic cluster evidence matched ${rule.title}; market-wide mode should test this application frontier.`, - evidenceRefs: [`semanticApplication:${toId(rule.title)}`] - }); - } - } - - return seeds.filter((seed) => isPlausibleProbePhrase(seed.phrase)); + return phrases.map((phrase) => ({ + phrase, + title: cluster.title, + priority, + sourceText: `Фраза получена из semantic cluster ${cluster.id}; новый рынок или отрасль здесь не подставляются.`, + evidenceRefs: [`semanticCluster:${cluster.id}`] + })); + }); } function getFingerprintProbePhrases(fingerprint: QueryLanguageFingerprint, anchorReview: AnchorReviewContract) { @@ -822,11 +750,12 @@ function buildFallbackFrontiers(input: { commercialDemand: SeoCommercialDemandContract | null; fingerprint: QueryLanguageFingerprint; memory: MarketResearchMemory; + demandResearchBrief: SeoDemandResearchBriefContract | null; }) { const covered = new Set(input.memory.coveredPhrases.map(normalizePhrase)); const suppressed = new Set(input.memory.suppressedPhrases.map(normalizePhrase)); const seedInputs = [ - ...getSemanticApplicationSeedPhrases(input.analysis, input.anchorReview), + ...getSemanticContextSeedPhrases(input.analysis), ...getCommercialSeedPhrases(input.commercialDemand), ...getAnchorSeedPhrases(input.anchorReview), ...getFingerprintProbePhrases(input.fingerprint, input.anchorReview) @@ -845,7 +774,11 @@ function buildFallbackFrontiers(input: { for (const seed of seedInputs) { const normalizedPhrase = normalizePhrase(seed.phrase); - if (!isPlausibleProbePhrase(normalizedPhrase) || suppressed.has(normalizedPhrase)) { + if ( + !isPlausibleProbePhrase(normalizedPhrase) || + suppressed.has(normalizedPhrase) || + getRejectedDemandScopeReason(seed.phrase, input.demandResearchBrief) + ) { continue; } @@ -942,7 +875,8 @@ function buildProbeSeedsFromFrontiers(frontiers: MarketFrontierDiscoveryContract function mergeMarketFrontierProbeSeeds( primarySeeds: MarketFrontierProbeSeed[], fallbackSeeds: MarketFrontierProbeSeed[], - memory: MarketResearchMemory + memory: MarketResearchMemory, + demandScope: ReturnType | null = null ) { const blocked = new Set([...memory.coveredPhrases, ...memory.suppressedPhrases].map(normalizePhrase)); const seen = new Set(); @@ -950,8 +884,14 @@ function mergeMarketFrontierProbeSeeds( for (const seed of [...primarySeeds, ...fallbackSeeds]) { const normalizedPhrase = normalizePhrase(seed.phrase); + const scopedSeedText = `${seed.frontierTitle} ${seed.phrase} ${seed.reason}`; - if (!isPlausibleProbePhrase(normalizedPhrase) || blocked.has(normalizedPhrase) || seen.has(normalizedPhrase)) { + if ( + !isPlausibleProbePhrase(normalizedPhrase) || + blocked.has(normalizedPhrase) || + seen.has(normalizedPhrase) || + getRejectedDemandScopeReasonFromGuard(scopedSeedText, demandScope) + ) { continue; } @@ -974,11 +914,12 @@ function buildModelTaskContract(input: { memory: MarketResearchMemory; projectId: string; projectOntologyVersionId: string | null; + demandResearchBrief: SeoDemandResearchBriefContract | null; }): MarketFrontierDiscoveryModelTaskContract { return { taskType: "seo.market_frontier_discovery", schemaVersion: "seo-market-frontier-discovery-task.v1", - taskId: `${input.analysis.runId}:market-frontier-discovery:${getMarketFrontierMemoryHash({ + taskId: `${input.analysis.runId}:market-frontier-discovery:${input.demandResearchBrief?.contextHash.slice(0, 12) ?? "unapproved"}:${getMarketFrontierMemoryHash({ fingerprint: input.fingerprint, memory: input.memory, projectOntologyVersionId: input.projectOntologyVersionId, @@ -1005,35 +946,58 @@ function buildModelTaskContract(input: { coreCommercialValue: input.commercialDemand?.commercialCore.coreCommercialValue ?? null, coreDifferentiator: input.commercialDemand?.commercialCore.coreDifferentiator ?? null, buyerIntentFamilies: - input.commercialDemand?.buyerIntentFamilies.slice(0, 16).map((family) => ({ - title: family.title, - buyerIntent: family.buyerIntent, - priority: family.priority, - commercialAngle: family.commercialAngle, - coreQualifier: family.coreQualifier, - queryPatterns: family.queryPatterns.slice(0, 8) - })) ?? [], + input.commercialDemand?.buyerIntentFamilies + .filter((family) => !getRejectedDemandScopeReason( + `${family.title} ${family.commercialAngle} ${family.sourceMeaning}`, + input.demandResearchBrief + )) + .slice(0, 16) + .map((family) => ({ + title: family.title, + buyerIntent: family.buyerIntent, + priority: family.priority, + commercialAngle: family.commercialAngle, + coreQualifier: family.coreQualifier, + queryPatterns: family.queryPatterns + .filter((phrase) => !getRejectedDemandScopeReason(phrase, input.demandResearchBrief)) + .slice(0, 8) + })) ?? [], applicationCommercialAngles: - input.commercialDemand?.applicationCommercialAngles.slice(0, 18).map((angle) => ({ - sourceVector: angle.sourceVector, - priority: angle.priority, - commercialReframe: angle.commercialReframe, - coreQualifier: angle.coreQualifier, - queryPatterns: angle.queryPatterns.slice(0, 8) - })) ?? [] + input.commercialDemand?.applicationCommercialAngles + .filter((angle) => !getRejectedDemandScopeReason( + `${angle.sourceVector} ${angle.commercialReframe} ${angle.queryPatterns.join(" ")}`, + input.demandResearchBrief + )) + .slice(0, 18) + .map((angle) => ({ + sourceVector: angle.sourceVector, + priority: angle.priority, + commercialReframe: angle.commercialReframe, + coreQualifier: angle.coreQualifier, + queryPatterns: angle.queryPatterns + .filter((phrase) => !getRejectedDemandScopeReason(phrase, input.demandResearchBrief)) + .slice(0, 8) + })) ?? [] } }, - approvedSemanticBasis: input.anchorReview.wordstatQueue.slice(0, 120).map((anchor) => ({ - phrase: anchor.phrase, - clusterId: anchor.clusterId, - clusterTitle: anchor.clusterTitle, - intentType: anchor.intentType, - marketRole: anchor.marketRole, - priority: anchor.priority, - normalizedFrom: anchor.normalizedFrom - })), + approvedSemanticBasis: input.anchorReview.wordstatQueue + .filter((anchor) => !getRejectedDemandScopeReason( + `${anchor.phrase} ${anchor.clusterTitle} ${anchor.normalizedFrom.join(" ")}`, + input.demandResearchBrief + )) + .slice(0, 120) + .map((anchor) => ({ + phrase: anchor.phrase, + clusterId: anchor.clusterId, + clusterTitle: anchor.clusterTitle, + intentType: anchor.intentType, + marketRole: anchor.marketRole, + priority: anchor.priority, + normalizedFrom: anchor.normalizedFrom + })), marketMemory: input.memory, - queryLanguageFingerprint: input.fingerprint + queryLanguageFingerprint: input.fingerprint, + demandScope: getDemandScopeGuardSummary(input.demandResearchBrief) }, allowedActions: [ "detect_covered_demand", @@ -1073,6 +1037,7 @@ function buildModelTaskContract(input: { "Использовать queryLanguageFingerprint: следующие probes должны повторять рабочие языковые паттерны рынка, а не внутренние слова сайта.", "Отделять plausible/adjacent/risky: широкие рынки можно проверять, но нельзя сразу считать их core SEO without evidence.", "Не подшивать логику под конкретный проект или домен; выводить универсальные buyer/problem/implementation/vertical frontiers.", + "Demand Scope — жёсткий backend-контракт: нельзя возвращать активный frontier или probe из rejectedDirections; расширение допустимо только вокруг fixedCorePillars и selected directions.", "Вернуть короткий probeSeeds список с объяснением expectedSignal и dedupeAgainst." ] }; @@ -1108,6 +1073,7 @@ function buildFallbackContract(input: { memory: MarketResearchMemory; projectId: string; projectOntologyVersionId: string | null; + demandResearchBrief: SeoDemandResearchBriefContract | null; }): MarketFrontierDiscoveryContract { const modelTask = input.analysis ? buildModelTaskContract({ @@ -1117,7 +1083,8 @@ function buildFallbackContract(input: { fingerprint: input.fingerprint, memory: input.memory, projectId: input.projectId, - projectOntologyVersionId: input.projectOntologyVersionId + projectOntologyVersionId: input.projectOntologyVersionId, + demandResearchBrief: input.demandResearchBrief }) : null; const frontiers = input.analysis @@ -1126,7 +1093,8 @@ function buildFallbackContract(input: { anchorReview: input.anchorReview, commercialDemand: input.commercialDemand, fingerprint: input.fingerprint, - memory: input.memory + memory: input.memory, + demandResearchBrief: input.demandResearchBrief }) : []; const contractWithoutActions: Omit = { @@ -1147,11 +1115,18 @@ function buildFallbackContract(input: { queryLanguageFingerprint: input.fingerprint, frontiers, probeSeeds: buildProbeSeedsFromFrontiers(frontiers, input.memory), - rejectedPatterns: input.memory.suppressedPhrases.slice(0, 24).map((phrase) => ({ + rejectedPatterns: [ + ...input.memory.suppressedPhrases.slice(0, 24).map((phrase) => ({ pattern: phrase, reason: "Suppressed пользователем или backend-аудитом; не возвращать в Wordstat queue и Stage 3.", examples: [phrase] - })), + })), + ...(input.demandResearchBrief?.rejectedDirections ?? []).map((direction) => ({ + pattern: direction.title, + reason: "Rejected by the approved Demand Research Brief; backend scope guard blocks resurrection downstream.", + examples: direction.expectedSearchLanguage.slice(0, 4) + })) + ], modelTask, summary: frontiers.length > 0 @@ -1221,12 +1196,24 @@ function normalizeModelOutput( throw new Error("Market frontier discovery output projectId не совпадает с проектом."); } - const frontiers = Array.isArray(output.frontiers) ? output.frontiers : []; + const demandScope = fallback.modelTask?.input.demandScope ?? null; + const frontiers = (Array.isArray(output.frontiers) ? output.frontiers : []) + .filter((frontier) => !getRejectedDemandScopeReasonFromGuard( + `${frontier.title ?? ""} ${frontier.rationale ?? ""} ${Array.isArray(frontier.probeSeeds) ? frontier.probeSeeds.join(" ") : ""}`, + demandScope + )) + .map((frontier) => ({ + ...frontier, + knownPhrases: (Array.isArray(frontier.knownPhrases) ? frontier.knownPhrases : []) + .filter((phrase) => !getRejectedDemandScopeReasonFromGuard(phrase, demandScope)), + probeSeeds: (Array.isArray(frontier.probeSeeds) ? frontier.probeSeeds : []) + .filter((phrase) => !getRejectedDemandScopeReasonFromGuard(phrase, demandScope)) + })); const frontierById = new Map(frontiers.map((frontier) => [frontier.id, frontier])); const probeSeeds = (Array.isArray(output.probeSeeds) ? output.probeSeeds : []) .map((seed) => normalizeProbeSeed(seed, frontierById, fallback.marketMemory)) .filter((seed): seed is MarketFrontierProbeSeed => Boolean(seed)); - const mergedProbeSeeds = mergeMarketFrontierProbeSeeds(probeSeeds, fallback.probeSeeds, fallback.marketMemory); + const mergedProbeSeeds = mergeMarketFrontierProbeSeeds(probeSeeds, fallback.probeSeeds, fallback.marketMemory, demandScope); const contractWithoutActions: Omit = { ...output, analystReview: output.analystReview ?? getDefaultAnalystReview(), @@ -1238,7 +1225,10 @@ function normalizeModelOutput( }, sourceTaskId: fallback.sourceTaskId, marketMemory: fallback.marketMemory, - queryLanguageFingerprint: output.queryLanguageFingerprint ?? fallback.queryLanguageFingerprint, + queryLanguageFingerprint: mergeQueryLanguageFingerprint( + output.queryLanguageFingerprint, + fallback.queryLanguageFingerprint + ), frontiers, probeSeeds: mergedProbeSeeds, modelTask: fallback.modelTask, @@ -1254,10 +1244,11 @@ function normalizeModelOutput( async function buildCurrentFallback(projectId: string): Promise { const analysis = await getLatestSemanticAnalysis(projectId); - const [anchorReview, commercialDemand, wordstat] = await Promise.all([ + const [anchorReview, commercialDemand, wordstat, demandResearchBrief] = await Promise.all([ getAnchorReviewContract(projectId), getSeoCommercialDemandContract(projectId), - getLatestWordstatEvidence(projectId, analysis?.runId ?? null) + getLatestWordstatEvidence(projectId, analysis?.runId ?? null), + analysis ? getLatestSeoDemandResearchBrief(projectId, analysis.runId) : Promise.resolve(null) ]); const projectOntologyVersionId = analysis?.projectOntology?.versionId ?? anchorReview.projectOntologyVersionId ?? null; const memoryRows = await getMarketMemoryRows(projectId, analysis?.runId ?? null, projectOntologyVersionId, anchorReview); @@ -1268,11 +1259,13 @@ async function buildCurrentFallback(projectId: string): Promise { - const [analysis, normalization, anchorReview, keywordCleaning, yandexEvidence, strategy, serpInterpretation, strategySynthesis] = await Promise.all([ +export async function getSeoModelContextContract( + projectId: string, + options: { includePostWordstat?: boolean } = {} +): Promise { + // Context Dev and Query Discovery are intentionally independent from + // Wordstat/SERP/strategy history. Loading the complete historical pipeline + // here previously made a slow post-Wordstat strategy read block every + // pre-Wordstat model task, including Context Review and topic decomposition. + const [analysis, normalization, anchorReview, keywordCleaning, yandexEvidence] = await Promise.all([ getLatestSemanticAnalysis(projectId), getSeoNormalizationContract(projectId), getAnchorReviewContract(projectId), getKeywordCleaningContract(projectId), - getLatestYandexEvidenceContract(projectId), - getSeoStrategyContract(projectId), - getLatestSerpInterpretationContract(projectId), - getLatestStrategySynthesisContract(projectId) + getLatestYandexEvidenceContract(projectId) ]); + const [strategy, serpInterpretation, strategySynthesis] = options.includePostWordstat + ? await Promise.all([ + getSeoStrategyContract(projectId), + getLatestSerpInterpretationContract(projectId), + getLatestStrategySynthesisContract(projectId) + ]) + : [null, null, null]; const skills = getSeoSkillPackContract(); const modelProvider = getSeoModelProviderCatalog(); const contextReview = analysis ? await getLatestAlignedSeoContextReview(projectId, analysis.runId) : null; + const demandResearchBrief = analysis ? await getLatestSeoDemandResearchBrief(projectId, analysis.runId) : null; + const positioningThesis = await getSeoPositioningThesis(projectId); const businessSynthesis = analysis ? await getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId) : null; + const opportunityDiscovery = businessSynthesis ? await getSeoOpportunityDiscoveryContract(projectId) : null; + const opportunityCritic = opportunityDiscovery?.state === "ready" ? await getSeoOpportunityCriticContract(projectId) : null; const commercialDemand = analysis ? await getLatestAlignedSeoCommercialDemand(projectId, analysis.runId) : null; const marketFrontierDiscovery = analysis ? await getMarketFrontierDiscoveryContract(projectId) : null; + const topicDecompositionTask = analysis + ? buildTopicDecompositionTask({ + projectId, + semanticRunId: analysis.runId, + projectOntologyVersionId: analysis.projectOntology?.versionId ?? null, + demandResearchBrief, + contextReview, + businessSynthesis, + positioningThesis + }) + : null; const contextReviewTask = analysis ? buildSeoContextReviewTask(projectId, analysis) : null; const businessSynthesisTask = analysis ? buildSeoBusinessSynthesisTask(projectId, analysis, contextReview) : null; - const commercialDemandTask = businessSynthesis ? buildSeoCommercialDemandTask(projectId, businessSynthesis) : null; - const serpInterpretationTask = buildSerpInterpretationModelTask(projectId, yandexEvidence); - const strategySynthesisTask = buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation); - const strategyQualityReviewTask = buildStrategyQualityReviewModelTask( - projectId, - strategy, - strategySynthesis, - serpInterpretation - ); + const commercialDemandTask = businessSynthesis + ? buildSeoCommercialDemandTask(projectId, businessSynthesis, demandResearchBrief) + : null; + const serpInterpretationTask = options.includePostWordstat + ? buildSerpInterpretationModelTask(projectId, yandexEvidence, demandResearchBrief) + : null; + const strategySynthesisTask = options.includePostWordstat && strategy + ? buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation) + : null; + const strategyQualityReviewTask = options.includePostWordstat && strategy + ? buildStrategyQualityReviewModelTask(projectId, strategy, strategySynthesis, serpInterpretation) + : null; const modelTasks = [ contextReviewTask, businessSynthesisTask, + opportunityDiscovery?.modelTask, + opportunityCritic?.modelTask, commercialDemandTask, marketFrontierDiscovery?.modelTask, + topicDecompositionTask, normalization.modelTask, keywordCleaning.modelTask, serpInterpretationTask, @@ -223,6 +277,16 @@ export async function getSeoModelContextContract(projectId: string): Promise= limit * 0.7 ? whitespace : limit).trim()}…`; +} + +function compactBridgeRecord(value: unknown, keys: string[], textLimits: Record = {}) { + if (!isJsonRecord(value)) return value; + return keys.reduce((result, key) => { + if (value[key] !== undefined) { + result[key] = truncateBridgeText(value[key], textLimits[key] ?? Number.POSITIVE_INFINITY); + } + return result; + }, {}); +} + +function compactBridgeRecordList(value: unknown, input: { limit: number; keys: string[]; textLimits?: Record }) { + if (!Array.isArray(value)) return value; + return value.slice(0, input.limit).map((item) => compactBridgeRecord(item, input.keys, input.textLimits)); +} + +/** + * The remote bridge must receive a representative evidence digest, not a + * pretty-printed duplicate of the complete crawl. The canonical, full task + * remains in the run database; this only bounds the transport payload while + * retaining every route/content group in the hierarchy. + */ +function compactTaskForAiWorkspace(task: SeoModelTaskContract): SeoModelTaskContract { + const rawTask = task as unknown as JsonRecord; + const rawInput = isJsonRecord(rawTask.input) ? rawTask.input : {}; + + if (task.taskType === "seo.business_synthesis") { + const rawEvidence = isJsonRecord(rawInput.siteTextEvidence) ? rawInput.siteTextEvidence : {}; + const rawContextReview = isJsonRecord(rawInput.contextReview) ? rawInput.contextReview : {}; + const compactInput: JsonRecord = { + ...rawInput, + siteTextEvidence: { + ...rawEvidence, + documents: compactBridgeRecordList(rawEvidence.documents, { + keys: ["id", "selected", "sourcePath", "title", "urlPath", "usedForCoverage", "wordCount"], + limit: 24, + textLimits: { sourcePath: 180, title: 140 } + }), + highSignalLines: compactBridgeRecordList(rawEvidence.highSignalLines, { + keys: ["id", "documentId", "scope", "selected", "sourcePath", "title", "urlPath", "usedForCoverage", "text"], + limit: 48, + textLimits: { sourcePath: 180, text: 180, title: 140 } + }) + }, + contextReview: { + ...rawContextReview, + summary: truncateBridgeText(rawContextReview.summary, 520), + forbiddenClaims: Array.isArray(rawContextReview.forbiddenClaims) + ? rawContextReview.forbiddenClaims.slice(0, 12).map((value) => truncateBridgeText(value, 180)) + : rawContextReview.forbiddenClaims, + normalizationHints: compactBridgeRecordList(rawContextReview.normalizationHints, { + keys: ["title", "targetIntent", "marketAngles", "sourcePhrases"], + limit: 16, + textLimits: { targetIntent: 160, title: 140 } + }) + }, + semanticClusters: compactBridgeRecordList(rawInput.semanticClusters, { + keys: ["id", "priority", "status", "title", "targetIntent", "matchedTerms", "queryExamples", "evidenceSnippets"], + limit: 14, + textLimits: { targetIntent: 160, title: 140 } + }) + }; + + return { ...rawTask, input: compactInput } as unknown as SeoModelTaskContract; + } + + if (task.taskType !== "seo.context_review") return task; + + const rawEvidence = isJsonRecord(rawInput.siteTextEvidence) ? rawInput.siteTextEvidence : {}; + const rawHierarchy = isJsonRecord(rawInput.evidenceHierarchy) ? rawInput.evidenceHierarchy : {}; + const compactInput: JsonRecord = { + ...rawInput, + pageSignals: compactBridgeRecordList(rawInput.pageSignals, { + keys: ["pageId", "selected", "title", "sourcePath", "urlPath", "scope", "wordCount", "missingCriticalFields", "matchedClusterIds"], + limit: 18, + textLimits: { title: 140, sourcePath: 180 } + }), + sectionSignals: compactBridgeRecordList(rawInput.sectionSignals, { + keys: ["id", "pageId", "title", "meaning", "evidenceIds"], + limit: 32, + textLimits: { meaning: 160, title: 120 } + }), + contentSources: compactBridgeRecordList(rawInput.contentSources, { + keys: ["id", "title", "sourcePath", "wordCount", "matchedClusterIds"], + limit: 12, + textLimits: { sourcePath: 180, title: 140 } + }), + semanticSignals: compactBridgeRecordList(rawInput.semanticSignals, { + keys: ["id", "title", "priority", "status", "targetIntent", "evidenceLevel", "matchedTerms", "missingTerms", "queryExamples"], + limit: 18, + textLimits: { targetIntent: 160, title: 140 } + }), + evidenceRefs: compactBridgeRecordList(rawInput.evidenceRefs, { + keys: ["id", "title", "sourcePath", "urlPath", "scope"], + limit: 28, + textLimits: { sourcePath: 180, title: 140 } + }), + knownRisks: compactBridgeRecordList(rawInput.knownRisks, { + keys: ["id", "level", "title", "message"], + limit: 14, + textLimits: { message: 280, title: 120 } + }), + siteTextEvidence: { + ...rawEvidence, + documents: compactBridgeRecordList(rawEvidence.documents, { + keys: ["id", "kind", "scope", "selected", "sourcePath", "title", "urlPath", "usedForCoverage", "wordCount", "chunkIds"], + limit: 32, + textLimits: { sourcePath: 180, title: 140 } + }), + chunks: compactBridgeRecordList(rawEvidence.chunks, { + keys: ["id", "documentId", "kind", "scope", "selected", "sourcePath", "title", "urlPath", "chunkIndex", "totalChunks", "usedForCoverage", "text"], + limit: 12, + textLimits: { sourcePath: 180, text: 420, title: 140 } + }) + }, + evidenceHierarchy: { + ...rawHierarchy, + groups: compactBridgeRecordList(rawHierarchy.groups, { + keys: ["id", "title", "kind", "scope", "documentCount", "representedDocumentCount", "wordCount", "matchedClusterIds", "representativeChunkIds", "synopsis"], + limit: 24, + textLimits: { synopsis: 420, title: 140 } + }) + } + }; + + return { ...rawTask, input: compactInput } as unknown as SeoModelTaskContract; +} + function assistantHeaders() { const headers: Record = { Accept: "application/json", @@ -169,21 +309,30 @@ function buildSeoModelTaskPrompt(input: { runId: string; task: SeoModelTaskContract; }) { + const transportTask = compactTaskForAiWorkspace(input.task); + return [ "NDC SEO Mode model-provider task.", "", "Return only one JSON object. Do not wrap it in markdown. Do not include prose outside JSON.", - `The JSON object must use schemaVersion "${input.task.outputSchema.schemaVersion}".`, - `Required top-level keys: ${input.task.outputSchema.requiredTopLevelKeys.join(", ")}.`, + `The JSON object must use schemaVersion "${transportTask.outputSchema.schemaVersion}".`, + `Required top-level keys: ${transportTask.outputSchema.requiredTopLevelKeys.join(", ")}.`, "If the output contract has bookkeeping keys such as modelTask, sourceTaskId, source, semanticRunId, projectId, or provider, populate them from the task contract and the provided evidence.", "For keyword tasks, classify only phrases present in task.input.seedQueue or task.input.wordstatTopResults; keep unresolved or weak rows out of approved rewrite lanes.", "For business synthesis tasks, behave like a senior product SEO strategist: read the site evidence, identify the central product offer, platform layers, application vectors, and demand families, while clearly separating explicit claims, conservative inferences, and risky expansion.", + "For opportunity discovery tasks, behave like a senior business opportunity analyst, not a generic idea generator. Execute task.analysisProfile.passes in order: interpret transferable capabilities, build a broad candidate space using task.input.opportunitySearchPolicy.explorationLenses, generate market-context/buyer/JTBD candidates, run a hostile opportunity critic, then synthesize a balanced visible portfolio. Meet rawCandidateTarget before filtering and visiblePortfolioTarget across shortlist + defer. Preserve the productFrame central differentiator in every candidate; reject generic workflows that could describe an unrelated product. Do not privilege only low-delta operational scenarios: cover near-core, new-buyer/JTBD, cross-environment and frontier transfers. Mark demand unverified, expose adaptations and fatal risks, reject duplicates of current applications, keep shortlist as recommended and defer as visible research hypotheses. Treat task.input.ownerStrategy as an owner_strategy prior, never as factual or market evidence: use only declared transferable traits from reference analogues, respect caveats/category exclusions, and never copy the analogue brand or its domain into the answer.", "For commercial demand tasks, behave like a buyer-intent SEO strategist: convert product architecture into purchasing, evaluation, implementation, automation, integration, replacement, pilot, or vendor-selection demand. Preserve the product's core differentiator from productFrame/corePillars in every vertical/application query pattern; do not output pure informational or architecture-inventory phrases as demand.", "For market frontier discovery tasks, behave like a market-research SEO strategist for a non-SEO user: read productUnderstanding, approvedSemanticBasis, marketMemory, and queryLanguageFingerprint; identify demand frontiers that current coveredPhrases/requestedSeeds do not cover; propose a short, high-leverage Wordstat probeSeeds list that can harvest top/related/popular rows. Do not return repeated, suppressed, n/a, or final Stage 3 keyword clutter.", + "For topic decomposition tasks, do not generate search queries, seed phrases, Wordstat probes, frequency, or demand estimates. Return semantic slots only: offer classes, categories, entities, synonyms, actions, processes, pains, buyer segments, integration systems, separate intents, and allowed pattern groups. Emit exactly one topic for every approvedDirections item: topicId, facetId and sourceDirectionId must copy that item's id byte-for-byte. Treat every approved direction as an independent selected semantic facet: preserve its lineage, do not collapse different facets into one lexical family, and do not use one facet as a substitute for the whole product. Include every topicRequiredKeys field as an array, obey outputSchema.slotValueLimits, use only outputSchema.allowedPatternGroups, and use only exact machine-code values from outputSchema.allowedOfferClasses in offerClasses; never put human labels there. primaryMarketEntities is one semantic category, not a query: return exactly one natural, differentiated market category for the facet in question. marketEntityFamily is a compact set of 3–6 externally recognisable semantic surfaces of that same facet, not a list of feature names or generic automation themes. Every family member must retain the facet distinction and must not turn a differentiated offer into a broad, adjacent competitive category. Keep integrations and application modules as context unless the approved Search Scope explicitly chose that direction. Respect task.input.marketLanguage: for Russian surface, never emit English-only entities or categories; technical acronyms may appear only inside a Russian phrase. Use one concise value only where its explicit slot limit is one; otherwise return several distinct values whenever the Offer Map evidence supports them. For a legacy core_offer, synthesize externally recognisable market categories from the whole Offer Map; for selected facets, remain inside that facet. Do not fill entities with raw feature inventory, plain objects, roles, statuses, document types, UI labels or internal component names. Keep summary under 280 characters. Compact cards are required: omission of a direction is invalid.", "For context review and normalization tasks, treat task.input.siteTextEvidence.chunks as the primary cleaned text corpus of the selected site scope when it is present.", + "For context review tasks, execute task.analysisProfile.passes in order: evidence interpreter, contradiction critic, then context synthesizer. Return only the final structured contract, but let the critic downgrade unsupported claims and preserve conflicting readings.", + "For context review tasks, process input.evidenceHierarchy as a map-reduce plan: interpret every route/content group first, audit representedDocumentCount and omittedDocumentCount, then synthesize the global context. Detailed chunks are evidence samples, not permission to ignore groups without detailed chunks.", + "For context review tasks, stay strictly inside factual project understanding. Do not propose growth directions, adjacent markets, keywords, demand, or strategy; those require a separate user-controlled opportunity task after the factual context is approved.", + "For context review tasks, treat task.input.siteTextEvidence.selection as an intentional evidence digest. Use pageSignals and sectionSignals to check scope coverage, and cite only canonical evidence IDs supplied by the task.", "For normalization tasks, use task.input.siteTextEvidence.highSignalLines as a checklist of short visible meanings: represent non-technical product, module, use-case, integration, audience, workflow, or vertical lines as needs-human query/corePhrase candidates, or put them in excludedPhrases with an explicit reason.", "For normalization tasks, use task.input.businessSynthesis as the product hierarchy: keep productFrame/corePillars as the semantic center and keep applicationVectors as use cases or vertical branches unless the synthesis explicitly marks them core.", - "For normalization tasks, use task.input.commercialDemand as the buyer-intent frame: seedStrategy and wordstatQueries should be commercial/problem-solution/evaluation/implementation phrases, not raw architecture theses. seedStrategy must include the actual semantic-basis query candidates across buyer-intent groups, not only 3-4 core phrases; target 24-60 needs-human seeds and keep them concise enough for market review. Do not shorten by deleting the product core differentiator; a longer qualified buyer query is better than a short generic phrase for another market. If a phrase is only informational, put it in excludedPhrases or rejectedSeeds with a reason.", + "For normalization tasks, task.input.demandScope is the authoritative human-approved handoff from Context Dev: expand only selectedDirectionIds, keep rejectedDirections out of the active seed basis, and do not claim demand is proven.", + "For normalization tasks, act as task.input.queryLanguagePolicy.analystRole and execute analysisPasses in order. commercialDemand decides relevance and buyer fit but is not the only language source: relevant informational, technology, application, problem, development, implementation and commercial surfaces may all remain support/needs_human. A seed is a plausible complete search-bar input, not product positioning or a feature inventory. Keep product-fit explanation in reason/cluster metadata when the full differentiator makes the phrase unnatural. Treat input queryPatterns as semantic hints, not copy. Meet phrase-length and portfolio-diversity constraints. Enforce selectedDirectionCoverage by using the exact selected direction id as clusterId for at least one seed, or by explicitly rejecting that id with a reason. Also enforce crossAxisCoverage: combine evidence-backed core capabilities with compatible selected applications without a mechanical Cartesian product. Leave every surviving seed needs_human until Wordstat.", "Decompose meanings from that corpus first, then use semanticSignals/pageSignals as backend hints and cross-checks.", "", "Hard boundaries:", @@ -197,11 +346,11 @@ function buildSeoModelTaskPrompt(input: { "Correlation:", `- projectId: ${input.projectId}`, `- seoModelTaskRunId: ${input.runId}`, - `- taskId: ${input.task.taskId}`, - `- taskType: ${input.task.taskType}`, + `- taskId: ${transportTask.taskId}`, + `- taskType: ${transportTask.taskType}`, "", - "SEO task contract JSON:", - JSON.stringify(input.task, null, 2) + "SEO task contract JSON (transport digest; canonical full evidence remains server-side):", + JSON.stringify(transportTask) ].join("\n"); } @@ -258,7 +407,9 @@ export async function dispatchSeoModelTaskToAiWorkspace(input: { metadata: { projectId: input.projectId, runId: input.runId, - surface: "seo-mode" + surface: "seo-mode", + threadKind: "model-task", + visibility: "service" }, originSurface: "seo-mode", title: threadTitle diff --git a/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts b/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts index 4100a2a..b484b59 100644 --- a/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts +++ b/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts @@ -4,8 +4,11 @@ import { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js"; export const SEO_MODEL_TASK_TYPES = [ "seo.context_review", "seo.business_synthesis", + "seo.opportunity_discovery", + "seo.opportunity_critic", "seo.commercial_demand", "seo.market_frontier_discovery", + "seo.topic_decomposition", "seo.normalization", "seo.keyword_cleaning", "seo.serp_interpretation", diff --git a/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts b/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts index 614b678..9c87e09 100644 --- a/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts +++ b/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts @@ -13,6 +13,18 @@ import { } from "../commercial/seoCommercialDemand.js"; import { saveSeoContextReviewFromModelProvider, type SeoContextReviewOutput } from "../contextReview/seoContextReview.js"; import { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js"; +import { + saveSerpInterpretationFromModelProvider, + type SerpInterpretationContract +} from "../evidence/serpInterpretation.js"; +import { + saveSeoOpportunityDiscoveryFromModelProvider, + type SeoOpportunityDiscoveryContract +} from "../opportunity/seoOpportunityDiscovery.js"; +import { + saveSeoOpportunityCriticFromModelProvider, + type SeoOpportunityCriticContract +} from "../opportunity/seoOpportunityCritic.js"; import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js"; import { saveMarketFrontierDiscoveryFromModelProvider, @@ -23,6 +35,11 @@ import { type SeoNormalizationContract, type SeoNormalizationModelTaskContract } from "../normalization/seoNormalization.js"; +import { + getTopicDecompositionModelTask, + saveTopicDecompositionFromModelProvider, + type TopicDecompositionModelTaskContract +} from "../queryDiscovery/queryDiscovery.js"; import { saveStrategyQualityReviewFromModelProvider, type StrategyQualityReviewContract } from "../strategy/strategyQualityReview.js"; import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js"; import { @@ -182,6 +199,14 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) { return 4800; } + if (task.taskType === "seo.opportunity_discovery") { + return 14000; + } + + if (task.taskType === "seo.opportunity_critic") { + return 10000; + } + if (task.taskType === "seo.commercial_demand") { return 5200; } @@ -190,8 +215,15 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) { return 6200; } + if (task.taskType === "seo.topic_decomposition") { + // This task covers all approved directions, but it has intentionally + // compact per-slot limits. Keep it within the response envelope that has + // already completed successfully through the remote Codex bridge. + return 6200; + } + if (task.taskType === "seo.normalization") { - return 4200; + return 6200; } if (task.taskType === "seo.keyword_cleaning") { @@ -604,6 +636,36 @@ async function promoteAiWorkspaceModelOutput( }; } + if (run.task.taskType === "seo.opportunity_discovery") { + await saveSeoOpportunityDiscoveryFromModelProvider( + projectId, + modelOutput.parsedJson as unknown as SeoOpportunityDiscoveryContract, + run.runId + ); + return { + nextAction: "Opportunity Discovery обновлён: карта 4.2 должна объединить подтверждённые применения, shortlist и defer-гипотезы.", + persistedResult: buildSeoAiWorkspacePromotedResult({ + runType: "seo_opportunity_discovery", + sourceModelTaskRunId: run.runId + }) + }; + } + + if (run.task.taskType === "seo.opportunity_critic") { + await saveSeoOpportunityCriticFromModelProvider( + projectId, + modelOutput.parsedJson as unknown as SeoOpportunityCriticContract, + run.runId + ); + return { + nextAction: "Independent Opportunity Critic обновлён: карта 4.2 должна разделять рекомендуемый shortlist и исследовательские defer-гипотезы.", + persistedResult: buildSeoAiWorkspacePromotedResult({ + runType: "seo_opportunity_critic", + sourceModelTaskRunId: run.runId + }) + }; + } + if (run.task.taskType === "seo.commercial_demand") { const inputTask = isRecord(run.input.task) ? run.input.task as unknown as SeoCommercialDemandModelTaskContract : null; @@ -659,6 +721,23 @@ async function promoteAiWorkspaceModelOutput( }; } + if (run.task.taskType === "seo.topic_decomposition") { + const inputTask = isRecord(run.input.task) ? run.input.task as unknown as TopicDecompositionModelTaskContract : null; + + if (!inputTask) { + throw new Error("Topic decomposition promotion fallback не может определить modelTask."); + } + + await saveTopicDecompositionFromModelProvider(projectId, modelOutput.parsedJson, run.runId, inputTask); + return { + nextAction: "Topic decomposition сохранён: pattern/suggest слой может формировать probes без генерации модельных ключей.", + persistedResult: buildSeoAiWorkspacePromotedResult({ + runType: "seo_topic_decomposition", + sourceModelTaskRunId: run.runId + }) + }; + } + if (run.task.taskType === "seo.keyword_cleaning") { await saveKeywordCleaningFromModelProvider( projectId, @@ -674,6 +753,21 @@ async function promoteAiWorkspaceModelOutput( }; } + if (run.task.taskType === "seo.serp_interpretation") { + await saveSerpInterpretationFromModelProvider( + projectId, + modelOutput.parsedJson as unknown as SerpInterpretationContract, + run.runId + ); + return { + nextAction: "SERP interpretation сохранён как evidence-aligned domain run; strategy может использовать target-fit, intent и competitor gaps.", + persistedResult: buildSeoAiWorkspacePromotedResult({ + runType: "seo_serp_interpretation", + sourceModelTaskRunId: run.runId + }) + }; + } + if (run.task.taskType === "seo.strategy_synthesis") { await saveStrategySynthesisFromModelProvider( projectId, @@ -883,15 +977,22 @@ export async function getSeoModelProviderStatusContract( export async function runSeoModelTask(projectId: string, input: SeoModelTaskRunInput): Promise { const providerId = input.providerId ?? getDefaultSeoModelProviderId(); const provider = getSeoModelProviderDescriptor(providerId); - const context = await getSeoModelContextContract(projectId); - const task = findTask(context.modelTasks, input); - const output = buildRunOutput(projectId, provider, task, context.schemaVersion); + const topicDecompositionTask = input.taskType === "seo.topic_decomposition" + ? await getTopicDecompositionModelTask(projectId) + : null; + const context = topicDecompositionTask + ? null + : await getSeoModelContextContract(projectId, { + includePostWordstat: input.taskType === "seo.serp_interpretation" || input.taskType === "seo.strategy_synthesis" || input.taskType === "seo.strategy_quality_review" + }); + const task = topicDecompositionTask ?? findTask(context?.modelTasks ?? [], input); + const output = buildRunOutput(projectId, provider, task, context?.schemaVersion ?? "seo-model-context.v1"); const baseInput = { schemaVersion: "seo-model-task-run-input.v1", providerId, requestedTaskId: input.taskId ?? null, requestedTaskType: input.taskType ?? null, - sourceContextGeneratedAt: context.generatedAt + sourceContextGeneratedAt: context?.generatedAt ?? new Date().toISOString() }; if (provider.id === "codex_workspace" && output.status === "contract_ready" && task) { diff --git a/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts b/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts index 3a3869e..8a9db0c 100644 --- a/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts +++ b/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts @@ -9,8 +9,19 @@ import { } from "../commercial/seoCommercialDemand.js"; import { getLatestAlignedSeoContextReview, + isSeoContextReviewApproved, type SeoContextReviewRun } from "../contextReview/seoContextReview.js"; +import { + getLatestSeoDemandResearchBrief, + type SeoDemandResearchBriefContract +} from "../contextReview/demandResearchBrief.js"; +import { + getDemandScopeGuardSummary, + getRejectedDemandScopeReason, + getRejectedDemandScopeReasonFromGuard +} from "../contextReview/demandScopeGuard.js"; +export { getRejectedDemandScopeReason } from "../contextReview/demandScopeGuard.js"; import { pool } from "../db/client.js"; import { getLatestProjectOntologyVersion } from "../ontology/projectOntologyRepository.js"; @@ -39,9 +50,9 @@ type SeoNormalizationRunRow = { created_at: Date; }; -const SITE_TEXT_EVIDENCE_MAX_CHARS = 9000; -const SITE_TEXT_EVIDENCE_MAX_CHUNKS = 8; -const SITE_TEXT_EVIDENCE_MAX_LINES = 32; +const SITE_TEXT_EVIDENCE_MAX_CHARS = 4000; +const SITE_TEXT_EVIDENCE_MAX_CHUNKS = 4; +const SITE_TEXT_EVIDENCE_MAX_LINES = 12; export type SeoNormalizationModelTaskContract = { taskType: "seo.normalization"; @@ -107,6 +118,7 @@ export type SeoNormalizationModelTaskContract = { whyItMatters: string; }>; applicationVectors: Array<{ + id: string; title: string; relationshipToCore: string; grounding: string; @@ -123,6 +135,41 @@ export type SeoNormalizationModelTaskContract = { }>; guardrails: string[]; }; + demandScope: ReturnType; + queryLanguagePolicy: { + objective: string; + analystRole: string; + analysisPasses: Array<{ + id: string; + objective: string; + deliverable: string; + }>; + targetSeedCount: { min: number; max: number }; + phraseLength: { + preferredMinTokens: number; + preferredMaxTokens: number; + absoluteMaxTokens: number; + minPreferredLengthShare: number; + }; + requiredSurfaceFamilies: string[]; + selectedDirectionCoverage: { + requireEverySelectedDirection: boolean; + preferredSeedsPerDirection: { min: number; max: number }; + allowExplicitRejectedCoverage: boolean; + }; + crossAxisCoverage: { + requireCoreCapabilityApplicationPairs: boolean; + targetSeedCount: { min: number; max: number }; + }; + diversity: { + maxCategoryOrVendorSurfaceShare: number; + maxRepeatedOpeningShare: number; + maxSupportingScaffoldShare: number; + minActionProblemOrApplicationShare: number; + minDistinctDemandHeads: number; + }; + rules: string[]; + }; commercialDemand: { status: "available" | "missing"; summary: string | null; @@ -146,6 +193,7 @@ export type SeoNormalizationModelTaskContract = { excludedInformationalPatterns: string[]; }>; applicationCommercialAngles: Array<{ + id: string; sourceVector: string; relationshipToCore: string; priority: "high" | "medium" | "low"; @@ -252,6 +300,7 @@ export type SeoNormalizationContract = { projectId: string; semanticRunId: string | null; projectOntologyVersionId: string | null; + demandResearchBriefHash: string | null; generatedAt: string; state: "not_ready" | "ready"; provider: { @@ -606,7 +655,7 @@ function isSemanticBasisPhrase(value: string, brandName?: string | null) { const meaningfulWords = words.filter((word) => !GENERIC_SINGLE_WORDS.has(word) && !brandWords.has(word) && word.length > 1); - return meaningfulWords.length >= 2; + return meaningfulWords.length >= 1 || new Set(words).size >= 2; } function getFallbackClusters(fallback?: SeoNormalizationModelProviderFallback) { @@ -907,13 +956,31 @@ function normalizeSeoNormalizationShape( ? (asRecord(rawOutput.modelTask) as unknown as SeoNormalizationModelTaskContract) : null); const providerIntentGroupSeeds = getProviderIntentGroupSeeds(rawOutput); - const rawSeeds = providerIntentGroupSeeds.length > 0 ? providerIntentGroupSeeds : getProviderRawSeeds(rawOutput, fallback); + const providerSeedStrategy = getProviderRawSeeds(rawOutput); + const rawSeeds = + providerSeedStrategy.length > 0 + ? providerSeedStrategy + : providerIntentGroupSeeds.length > 0 + ? providerIntentGroupSeeds + : fallback?.modelTask.input.sourceSeeds ?? []; const normalizedProviderSeeds = rawSeeds .map((seed, index) => normalizeProviderSeed(seed, index, fallback)) .filter((seed): seed is SeoNormalizationSeed => Boolean(seed)); + const demandScope = fallbackTask?.input.demandScope ?? null; const technicalRejectedSeeds = normalizedProviderSeeds.filter((seed) => seed.review.blockers.includes("technical_only_phrase")); - const seedStrategy = normalizedProviderSeeds.filter((seed) => !seed.review.blockers.includes("technical_only_phrase")); - const rejectedSeeds = [ + const rejectedScopeSeeds = normalizedProviderSeeds + .map((seed) => ({ + reason: demandScope?.selectedDirectionIds.includes(seed.clusterId) + ? null + : getRejectedDemandScopeReasonFromGuard(seed.phrase, demandScope), + seed + })) + .filter((item): item is { reason: string; seed: SeoNormalizationSeed } => Boolean(item.reason)); + const rejectedScopeSeedIds = new Set(rejectedScopeSeeds.map((item) => item.seed.id)); + const seedStrategy = normalizedProviderSeeds.filter( + (seed) => !seed.review.blockers.includes("technical_only_phrase") && !rejectedScopeSeedIds.has(seed.id) + ); + const rawRejectedSeeds = [ ...normalizeProviderRejectedSeeds(rawOutput, fallback), ...technicalRejectedSeeds.map((seed) => ({ clusterId: seed.clusterId, @@ -921,9 +988,47 @@ function normalizeSeoNormalizationShape( phrase: seed.phrase, reason: seed.review.reason, suggestedReplacement: null + })), + ...rejectedScopeSeeds.map(({ reason, seed }) => ({ + clusterId: seed.clusterId, + clusterTitle: seed.clusterTitle, + phrase: seed.phrase, + reason, + suggestedReplacement: null })) ]; - const intentGroups = normalizeProviderIntentGroups(rawOutput, seedStrategy, fallback); + const rejectedSeeds = rawRejectedSeeds.filter( + (seed, index) => rawRejectedSeeds.findIndex((candidate) => candidate.phrase === seed.phrase) === index + ); + const representedDirectionIds = new Set([ + ...seedStrategy.map((seed) => seed.clusterId), + ...rejectedSeeds.map((seed) => seed.clusterId) + ]); + const commercialAnglesById = new Map( + (fallbackTask?.input.commercialDemand.applicationCommercialAngles ?? []).map((angle) => [angle.id, angle]) + ); + const missingDirectionCoverage = (demandScope?.selectedDirectionIds ?? []) + .filter((directionId) => !representedDirectionIds.has(directionId)) + .map((directionId) => { + const angle = commercialAnglesById.get(directionId); + + return { + clusterId: directionId, + clusterTitle: angle?.sourceVector ?? directionId, + phrase: angle?.sourceVector ?? directionId, + reason: + "Model provider не вернул ни одной правдоподобной query-surface фразы для выбранного пользователем направления. Направление нельзя молча потерять: требуется regeneration или ручной seed.", + suggestedReplacement: null + }; + }); + const coverageSafeRejectedSeeds = [...rejectedSeeds, ...missingDirectionCoverage]; + const intentGroups = normalizeProviderIntentGroups(rawOutput, seedStrategy, fallback) + .map((group) => ({ + ...group, + corePhrases: group.corePhrases.filter((phrase) => !getRejectedDemandScopeReasonFromGuard(phrase, demandScope)), + wordstatQueries: group.wordstatQueries.filter((phrase) => !getRejectedDemandScopeReasonFromGuard(phrase, demandScope)) + })) + .filter((group) => seedStrategy.some((seed) => seed.intentGroupId === group.id)); return { ...output, @@ -941,6 +1046,8 @@ function normalizeSeoNormalizationShape( projectId: asString(rawOutput.projectId) ?? projectId, projectOntologyVersionId: asString(rawOutput.projectOntologyVersionId) ?? fallback?.projectOntologyVersionId ?? fallbackTask?.projectOntologyVersionId ?? null, + demandResearchBriefHash: + asString(rawOutput.demandResearchBriefHash) ?? fallbackTask?.input.demandScope.contextHash ?? null, provider: { message: asString(rawProvider.message) ?? "AI Workspace provider output normalized by backend.", mode: "codex_workspace", @@ -951,13 +1058,13 @@ function normalizeSeoNormalizationShape( intentGroupCount: intentGroups.length, needsHumanReviewCount: seedStrategy.length, normalizedSeedCount: seedStrategy.length, - rejectedSeedCount: rejectedSeeds.length, + rejectedSeedCount: coverageSafeRejectedSeeds.length, serpApprovedCount: 0, sourceSeedCount: fallbackTask?.input.sourceSeeds.length ?? seedStrategy.length, wordstatApprovedCount: 0, wordstatQueryCount: 0 }, - rejectedSeeds, + rejectedSeeds: coverageSafeRejectedSeeds, schemaVersion: "seo-normalization.v1", seedStrategy, semanticRunId: asString(rawOutput.semanticRunId) ?? fallback?.semanticRunId ?? fallbackTask?.semanticRunId ?? null, @@ -1742,8 +1849,10 @@ function buildModelTaskContract( projectOntologyVersionId: string | null, contextReview: SeoContextReviewRun | null, businessSynthesis: SeoBusinessSynthesisContract | null, - commercialDemand: SeoCommercialDemandContract | null + commercialDemand: SeoCommercialDemandContract | null, + demandResearchBrief: SeoDemandResearchBriefContract | null ): SeoNormalizationModelTaskContract { + const limitTaskText = (value: string, maxLength = 320) => value.length <= maxLength ? value : `${value.slice(0, maxLength - 1).trim()}…`; const evidenceRefs = analysis.clusters .flatMap((cluster) => cluster.sourceRefs.slice(0, 3).map((ref) => ({ @@ -1754,12 +1863,74 @@ function buildModelTaskContract( scope: ref.scope })) ) - .slice(0, 20); + .slice(0, 12); + + const selectedOpportunityIds = new Set([ + ...(demandResearchBrief?.selectedCurrentApplications ?? []), + ...(demandResearchBrief?.selectedExpansionHypotheses ?? []) + ].map((opportunity) => opportunity.id)); + const selectedOpportunityTitles = new Set([ + ...(demandResearchBrief?.selectedCurrentApplications ?? []), + ...(demandResearchBrief?.selectedExpansionHypotheses ?? []) + ].map((opportunity) => normalizePhrase(opportunity.title))); + const demandScopeSummary = getDemandScopeGuardSummary(demandResearchBrief); + const selectedScopePhrases = [ + ...(demandResearchBrief?.fixedCorePillars ?? []).map((pillar) => pillar.title), + ...(demandResearchBrief?.selectedCurrentApplications ?? []).flatMap((opportunity) => [ + opportunity.title, + opportunity.targetSector, + ...opportunity.expectedSearchLanguage.slice(0, 1) + ]), + ...(demandResearchBrief?.selectedExpansionHypotheses ?? []).flatMap((opportunity) => [ + opportunity.title, + opportunity.targetSector, + ...opportunity.expectedSearchLanguage.slice(0, 1) + ]) + ].filter((phrase): phrase is string => Boolean(phrase)).map((phrase) => limitTaskText(phrase, 180)); + const compactDemandScope: ReturnType = { + ...demandScopeSummary, + selectedPhrases: [...new Set(selectedScopePhrases)], + rejectedDirections: demandScopeSummary.rejectedDirections.map((direction) => ({ + ...direction, + expectedSearchLanguage: direction.expectedSearchLanguage.slice(0, 2).map((phrase) => limitTaskText(phrase, 180)), + phrases: direction.phrases + .filter((phrase): phrase is string => Boolean(phrase)) + .slice(0, 4) + .map((phrase) => limitTaskText(phrase, 180)) + })) + }; + const scopedApplicationVectors = businessSynthesis?.semanticHierarchy.applicationVectors.filter( + (vector) => !demandResearchBrief || selectedOpportunityIds.has(vector.id) + ) ?? []; + const scopedDemandFamilies = businessSynthesis?.demandFamilies.filter((family) => { + if (!demandResearchBrief || family.role === "core" || family.role === "commercial") { + return true; + } + + const familyText = normalizePhrase(`${family.title} ${family.rationale}`); + return [...selectedOpportunityTitles].some( + (title) => title && (familyText.includes(title) || title.includes(normalizePhrase(family.title))) + ); + }) ?? []; + const scopedCommercialAngles = commercialDemand?.applicationCommercialAngles.filter((angle) => { + if (!demandResearchBrief) { + return true; + } + + if (selectedOpportunityIds.has(angle.id)) { + return true; + } + + const sourceVector = normalizePhrase(angle.sourceVector); + return [...selectedOpportunityTitles].some( + (title) => title && (sourceVector.includes(title) || title.includes(sourceVector)) + ); + }) ?? []; return { taskType: "seo.normalization", schemaVersion: "seo-normalization-task.v1", - taskId: `${analysis.runId}:seo-normalization:v1`, + taskId: `${analysis.runId}:seo-normalization:${demandResearchBrief?.contextHash.slice(0, 12) ?? "unapproved"}:v6`, projectId, semanticRunId: analysis.runId, projectOntologyVersionId, @@ -1810,7 +1981,8 @@ function buildModelTaskContract( whyItMatters: pillar.whyItMatters })) ?? [], applicationVectors: - businessSynthesis?.semanticHierarchy.applicationVectors.slice(0, 10).map((vector) => ({ + scopedApplicationVectors.slice(0, 10).map((vector) => ({ + id: vector.id, grounding: vector.grounding, priority: vector.priority, queryDirection: vector.queryDirection, @@ -1818,7 +1990,7 @@ function buildModelTaskContract( title: vector.title })) ?? [], demandFamilies: - businessSynthesis?.demandFamilies.slice(0, 12).map((family) => ({ + scopedDemandFamilies.slice(0, 12).map((family) => ({ grounding: family.grounding, phrases: family.phrases.slice(0, 6), priority: family.priority, @@ -1828,6 +2000,86 @@ function buildModelTaskContract( })) ?? [], guardrails: businessSynthesis?.guardrails.slice(0, 12) ?? [] }, + demandScope: compactDemandScope, + queryLanguagePolicy: { + objective: + "Сформировать до Wordstat компактные гипотезы живого поискового языка: фразы, которые пользователь правдоподобно введёт целиком, а не продуктовые описания или перечни функций.", + analystRole: "search_query_linguist_and_hostile_zero_demand_critic", + analysisPasses: [ + { + id: "demand_lexicon", + objective: + "Из утверждённого контекста выделить центральные предметы спроса, действия/задачи, прикладные модификаторы и естественные intent-операторы. Не считать продуктовую категорию единственным поисковым head term.", + deliverable: + "Внутренняя карта нескольких независимых demand heads, jobs/actions, application modifiers и intent operators." + }, + { + id: "query_surface_generation", + objective: + "Собрать естественные короткие словосочетания на языке поиска, используя смысловые оси как совместимые коллокации, а не механический Cartesian product. Отдельно сформировать cross-axis поверхности, где доказанная core capability соединяется с выбранным применением.", + deliverable: "Кандидаты phrase по разным surface families с reason, связывающим запрос с контекстом проекта." + }, + { + id: "hostile_zero_demand_critic", + objective: + "Для каждой фразы предположить, выглядит ли она как буквальный пользовательский ввод или как продуктовый копирайтинг, внутренний label, калька, список функций либо искусственно уточнённый long tail.", + deliverable: "Слабые кандидаты переписаны или перенесены в rejectedSeeds с конкретной причиной." + }, + { + id: "portfolio_balance", + objective: + "Сбалансировать итог по центральным demand heads, проблемам, действиям, применениям, внедрению, информации и коммерческому выбору; устранить доминирование одной синтаксической рамки.", + deliverable: "36-56 разных needs_human seed phrases, готовых только к ручному отбору и Wordstat-проверке." + } + ], + targetSeedCount: { min: 36, max: 56 }, + phraseLength: { + preferredMinTokens: 2, + preferredMaxTokens: 5, + absoluteMaxTokens: 8, + minPreferredLengthShare: 0.65 + }, + requiredSurfaceFamilies: [ + "head_or_category", + "problem_or_job", + "application_or_domain", + "implementation_or_service", + "technology_or_information", + "core_capability_x_application", + "commercial_or_selection" + ], + selectedDirectionCoverage: { + requireEverySelectedDirection: true, + preferredSeedsPerDirection: { min: 1, max: 2 }, + allowExplicitRejectedCoverage: true + }, + crossAxisCoverage: { + requireCoreCapabilityApplicationPairs: true, + targetSeedCount: { min: 8, max: 14 } + }, + diversity: { + maxCategoryOrVendorSurfaceShare: 0.35, + maxRepeatedOpeningShare: 0.15, + maxSupportingScaffoldShare: 0.35, + minActionProblemOrApplicationShare: 0.4, + minDistinctDemandHeads: 5 + }, + rules: [ + "Каждая phrase должна быть самодостаточной строкой поискового запроса без поясняющего контекста.", + "Контекст проекта определяет релевантность, но полный differentiator хранится в reason/coreQualifier и не обязан дословно входить в каждую phrase.", + "Не превращать phrase в список возможностей: не более двух независимых объектов или признаков в одной строке.", + "Повторять можно центральный предмет спроса; вспомогательные слова-контейнеры, UI-сущности и одинаковые синтаксические зачины не должны доминировать в корпусе.", + "queryPatterns из входа — смысловые подсказки, а не текст для копирования; переписать их в естественную поисковую морфологию.", + "Не ограничивать базис коммерческими запросами: релевантные информационные, технологические и прикладные формулировки допустимы как support/needs_human до проверки реального спроса.", + "Смешать короткие категорийные, проблемные, прикладные, технологические, внедренческие и коммерческие поверхности; не сводить все группы к vendor-selection формулировкам.", + "Сначала определить несколько центральных demand heads из core context и выбранных направлений, затем варьировать действия и применения вокруг них; не использовать продуктовую категорию как обязательный head каждой группы.", + "Каждый id из demandScope.selectedDirectionIds должен быть представлен clusterId хотя бы одной seed phrase либо clusterId явно отклонённой строки с причиной отсутствия правдоподобного поискового языка.", + "Сформировать 8-14 cross-axis seeds: соединить несколько доказанных core capabilities с наиболее совместимыми выбранными применениями; использовать clusterId направления применения и не делать полный Cartesian product.", + "Общие слова-контейнеры для категории решения не должны подменять предмет спроса, действие или применение и не могут превышать maxSupportingScaffoldShare.", + "Перед ответом провести self-critic: убрать канцелярские конструкции, продуктовые слоганы, перечисления, дубли и фразы, которым вероятнее всего соответствует нулевая поисковая формулировка.", + "Ни одна phrase не считается подтверждённым спросом до Wordstat; вернуть needs_human." + ] + }, commercialDemand: { status: commercialDemand ? "available" : "missing", summary: commercialDemand?.summary ?? null, @@ -1842,26 +2094,27 @@ function buildModelTaskContract( } : null, buyerIntentFamilies: - commercialDemand?.buyerIntentFamilies.slice(0, 12).map((family) => ({ + commercialDemand?.buyerIntentFamilies.slice(0, 16).map((family) => ({ buyerIntent: family.buyerIntent, - commercialAngle: family.commercialAngle, - coreQualifier: family.coreQualifier, - excludedInformationalPatterns: family.excludedInformationalPatterns.slice(0, 5), + commercialAngle: limitTaskText(family.commercialAngle), + coreQualifier: limitTaskText(family.coreQualifier, 240), + excludedInformationalPatterns: family.excludedInformationalPatterns.slice(0, 2), grounding: family.grounding, priority: family.priority, - queryPatterns: family.queryPatterns.slice(0, 8), - sourceMeaning: family.sourceMeaning, - title: family.title + queryPatterns: family.queryPatterns.slice(0, 3), + sourceMeaning: limitTaskText(family.sourceMeaning, 240), + title: limitTaskText(family.title, 240) })) ?? [], applicationCommercialAngles: - commercialDemand?.applicationCommercialAngles.slice(0, 12).map((angle) => ({ - commercialReframe: angle.commercialReframe, - coreQualifier: angle.coreQualifier, + scopedCommercialAngles.slice(0, 32).map((angle) => ({ + id: angle.id, + commercialReframe: limitTaskText(angle.commercialReframe), + coreQualifier: limitTaskText(angle.coreQualifier, 240), grounding: angle.grounding, priority: angle.priority, - queryPatterns: angle.queryPatterns.slice(0, 6), + queryPatterns: angle.queryPatterns.slice(0, 2), relationshipToCore: angle.relationshipToCore, - sourceVector: angle.sourceVector + sourceVector: limitTaskText(angle.sourceVector, 240) })) ?? [], guardrails: commercialDemand?.guardrails.slice(0, 12) ?? [] }, @@ -1874,7 +2127,7 @@ function buildModelTaskContract( templateBlocks: analysis.pageScope.templateBlocks }, evidenceRefs, - semanticClusters: analysis.clusters.slice(0, 16).map((cluster) => ({ + semanticClusters: analysis.clusters.slice(0, 10).map((cluster) => ({ evidenceLevel: cluster.evidence?.level ?? "none", id: cluster.id, matchedTerms: cluster.matchedTerms.slice(0, 8), @@ -1886,20 +2139,20 @@ function buildModelTaskContract( })), verticalOpportunities: analysis.clusters .filter((cluster) => cluster.status !== "missing" || cluster.priority === "high") - .slice(0, 12) + .slice(0, 4) .map((cluster) => ({ id: cluster.id, - matchedTerms: cluster.matchedTerms.slice(0, 8), + matchedTerms: cluster.matchedTerms.slice(0, 5), priority: cluster.priority, - queryExamples: cluster.queryExamples.slice(0, 5), - sourceTitles: cluster.sourceRefs.slice(0, 4).map((ref) => ref.title), - sourceSnippets: cluster.evidenceSnippets.slice(0, 3).map((snippet) => snippet.text.slice(0, 180)), + queryExamples: cluster.queryExamples.slice(0, 3), + sourceTitles: cluster.sourceRefs.slice(0, 2).map((ref) => ref.title), + sourceSnippets: cluster.evidenceSnippets.slice(0, 2).map((snippet) => snippet.text.slice(0, 140)), status: cluster.status, targetIntent: cluster.targetIntent, - targetPages: cluster.targetPages.slice(0, 4), + targetPages: cluster.targetPages.slice(0, 2), title: cluster.title })), - sourceSeeds: analysis.seedCandidates.slice(0, 80).map((seed) => ({ + sourceSeeds: analysis.seedCandidates.slice(0, 30).map((seed) => ({ clusterId: seed.clusterId, phrase: seed.phrase, priority: seed.priority, @@ -1944,14 +2197,19 @@ function buildModelTaskContract( "Недостаточно source evidence для отделения бизнеса от технической оболочки сайта.", "Фраза выглядит как соседний рынок или business expansion без явного user approval.", "Не отбрасывать source-grounded vertical opportunity: если точной рыночной формулировки нет, вернуть needs_human seed или expansion query, а не rejected.", - "Для каждой видимой продуктовой/отраслевой вертикали предложить 2-6 Wordstat-safe аналогов: коротких, рыночных, без внутренних labels.", + "Для каждой значимой продуктовой или прикладной группы предложить компактные query-surface гипотезы без внутренних labels; не заполнять квоту слабыми перефразировками.", "Не пропускать non-technical строки из siteTextEvidence.highSignalLines: представить их как needs_human query/corePhrase или явно объяснить exclusion.", "Business synthesis is the hierarchy source: core/product families must stay centered on productFrame and corePillars; applicationVectors must be labeled as use cases or verticals, not promoted to core.", - "Commercial demand is the buyer-intent source: do not output raw architecture theses as seedStrategy phrases unless reframed as buyer problem, business value, solution search, implementation, vendor selection, integration, pilot, replacement, or commercial evaluation.", - "seedStrategy must contain the actual semantic-basis query candidates, not only intent group corePhrases: target 24-60 needs_human seeds across core buyer-intent groups and application angles.", - "Keep seedStrategy phrases concise enough for market review, usually 3-10 words; if a buyer-intent phrase is longer, rewrite it shorter while preserving the commercial value and core differentiator.", - "Do not shorten by deleting the core differentiator from commercialDemand.commercialCore/coreQualifier. A 9-12 word qualified buyer query is better than a 4-6 word generic phrase for another market.", - "For application vectors, keep the product core differentiator in query patterns when detaching it would create another market.", + "Demand Scope is the approved scope: keep the product frame and corePillars as the invariant center, expand only selectedDirectionIds, and never restore rejectedDirections as active demand branches.", + "If demandScope.approved is false, do not produce a market-ready semantic basis; return only a human-gated draft and do not route anything to Wordstat or SERP.", + "Commercial demand is a relevance and buyer-fit source, not the only query language source. Keep relevant informational, technology, application and development surfaces as support/needs_human when they express approved context.", + "seedStrategy must follow queryLanguagePolicy: return 36-56 needs_human query-surface candidates, with at least 65% containing 2-5 tokens and none above 8 tokens.", + "A seed phrase is a probable literal search-bar input, not a complete product positioning statement. Keep product fit and the core differentiator in reason, cluster and evidence metadata when literal inclusion makes the query unnatural.", + "Do not let one supporting scaffold, feature noun or repeated opening dominate the corpus; preserve the central demand subject while varying category, problem, application, implementation and commercial surfaces.", + "Execute queryLanguagePolicy.analysisPasses in order and meet its portfolio diversity constraints before returning seedStrategy.", + "Cover every demandScope.selectedDirectionIds item with one or two clusterId-aligned seeds, or an explicit rejectedSeeds entry explaining why no plausible literal query exists.", + "Return 8-14 natural core-capability-by-application phrases using selected direction clusterIds; choose only semantically compatible pairs and reject mechanical combinations.", + "Reject or rewrite enumerations, internal architecture language, UI/task inventory and mechanically qualified long tails before returning the contract.", "Нельзя определить, отправлять ли фразу в Wordstat/SERP без ручного review." ] }; @@ -1983,7 +2241,8 @@ function buildContract( projectOntologyVersionId: string | null, contextReview: SeoContextReviewRun | null, businessSynthesis: SeoBusinessSynthesisContract | null, - commercialDemand: SeoCommercialDemandContract | null + commercialDemand: SeoCommercialDemandContract | null, + demandResearchBrief: SeoDemandResearchBriefContract | null ): SeoNormalizationContract { if (!analysis) { return { @@ -1991,6 +2250,7 @@ function buildContract( projectId, semanticRunId: null, projectOntologyVersionId: null, + demandResearchBriefHash: null, generatedAt: new Date().toISOString(), state: "not_ready", provider: { @@ -2140,32 +2400,82 @@ function buildContract( } } - const normalizedSeedStrategy = Array.from(seedByPhrase.values()) + const deduplicatedSeedStrategy = Array.from(seedByPhrase.values()) .sort((left, right) => { return priorityScore[right.priority] - priorityScore[left.priority] || right.confidence - left.confidence; }) .slice(0, 120); - - const needsHumanReviewCount = normalizedSeedStrategy.filter((seed) => seed.review.status === "needs_human").length; - const autoApprovedCount = normalizedSeedStrategy.filter((seed) => seed.review.status === "auto_approved").length; - const wordstatApprovedCount = normalizedSeedStrategy.filter((seed) => seed.review.sendToWordstat).length; - const serpApprovedCount = normalizedSeedStrategy.filter((seed) => seed.review.sendToSerp).length; - const reviewRejectedCount = normalizedSeedStrategy.filter((seed) => seed.review.status === "rejected").length; - const modelTask = buildModelTaskContract(projectId, analysis, projectOntologyVersionId, contextReview, businessSynthesis, commercialDemand); + const rejectedScopeSeeds = deduplicatedSeedStrategy + .map((seed) => ({ reason: getRejectedDemandScopeReason(seed.phrase, demandResearchBrief), seed })) + .filter((item): item is { reason: string; seed: SeoNormalizationSeed } => Boolean(item.reason)); + const rejectedScopeSeedIds = new Set(rejectedScopeSeeds.map((item) => item.seed.id)); + const normalizedSeedStrategy = deduplicatedSeedStrategy.filter((seed) => !rejectedScopeSeedIds.has(seed.id)); + const scopedIntentGroups = intentGroups + .map((group) => ({ + ...group, + corePhrases: group.corePhrases.filter((phrase) => !getRejectedDemandScopeReason(phrase, demandResearchBrief)), + wordstatQueries: group.wordstatQueries.filter((phrase) => !getRejectedDemandScopeReason(phrase, demandResearchBrief)) + })) + .filter((group) => normalizedSeedStrategy.some((seed) => seed.intentGroupId === group.id)); + rejectedSeeds.push( + ...rejectedScopeSeeds.map(({ reason, seed }) => ({ + clusterId: seed.clusterId, + clusterTitle: seed.clusterTitle, + phrase: seed.phrase, + reason, + suggestedReplacement: null + })) + ); + const contextApproved = isSeoContextReviewApproved(contextReview); + const demandScopeApproved = Boolean(demandResearchBrief?.state === "approved"); + const contextReady = contextApproved && demandScopeApproved; + const gatedSeedStrategy = contextReady + ? normalizedSeedStrategy + : normalizedSeedStrategy.map((seed) => ({ + ...seed, + needsHumanReview: true, + reason: `${seed.reason} ${contextApproved ? "Карта направлений 4.2 ещё не утверждена." : "Project Context Contract ещё не утверждён."}`, + review: { + status: "needs_human" as const, + sendToSerp: false, + sendToWordstat: false, + reason: contextApproved + ? "Сначала нужно утвердить направления 4.2 и собрать Demand Research Brief." + : "Сначала нужно утвердить Project Context Contract.", + blockers: [contextApproved ? "demand_research_brief_missing" : "project_context_not_approved"] + } + })); + const needsHumanReviewCount = gatedSeedStrategy.filter((seed) => seed.review.status === "needs_human").length; + const autoApprovedCount = gatedSeedStrategy.filter((seed) => seed.review.status === "auto_approved").length; + const wordstatApprovedCount = gatedSeedStrategy.filter((seed) => seed.review.sendToWordstat).length; + const serpApprovedCount = gatedSeedStrategy.filter((seed) => seed.review.sendToSerp).length; + const reviewRejectedCount = gatedSeedStrategy.filter((seed) => seed.review.status === "rejected").length; + const modelTask = buildModelTaskContract( + projectId, + analysis, + projectOntologyVersionId, + contextReview, + businessSynthesis, + commercialDemand, + demandResearchBrief + ); return { schemaVersion: "seo-normalization.v1", projectId, semanticRunId: analysis.runId, projectOntologyVersionId, + demandResearchBriefHash: demandResearchBrief?.contextHash ?? null, generatedAt: new Date().toISOString(), - state: normalizedSeedStrategy.length > 0 ? "ready" : "not_ready", + state: normalizedSeedStrategy.length > 0 && contextReady ? "ready" : "not_ready", provider: { mode: "deterministic_fallback", modelRequired: true, message: - contextReview - ? "Seed strategy собирается deterministic fallback-ом, но уже учитывает Context Review forbidden claims и normalization hints." + contextReady + ? "Seed strategy собирается deterministic fallback-ом и учитывает утверждённый Demand Research Brief, Context Review и выбранный scope 4.2." + : contextReview + ? "Seed strategy остаётся черновиком до утверждения направлений 4.2 и создания Demand Research Brief." : "Seed strategy собирается deterministic fallback-ом без Context Review result. Сначала нужно сохранить seo.context_review draft evidence." }, sourceTaskId: modelTask.taskId, @@ -2174,7 +2484,7 @@ function buildContract( readiness: { sourceSeedCount: analysis.seedCandidates.length, normalizedSeedCount: normalizedSeedStrategy.length, - intentGroupCount: intentGroups.length, + intentGroupCount: scopedIntentGroups.length, wordstatQueryCount: wordstatApprovedCount, wordstatApprovedCount, serpApprovedCount, @@ -2184,10 +2494,10 @@ function buildContract( }, summary: normalizedSeedStrategy.length > 0 - ? `Нормализовано ${normalizedSeedStrategy.length} фраз в ${intentGroups.length} intent groups; ${wordstatApprovedCount} готовы к Wordstat, ${needsHumanReviewCount} требуют review.${contextReview ? " Context Review учтен." : " Context Review пока не сохранен."}` + ? `Нормализовано ${normalizedSeedStrategy.length} фраз в ${scopedIntentGroups.length} intent groups; ${wordstatApprovedCount} готовы к Wordstat, ${needsHumanReviewCount} требуют review.${demandScopeApproved ? " Утверждённый scope 4.2 учтён." : " Scope 4.2 ещё не утверждён."}` : "Контекст есть, но нормализованные рыночные фразы не собраны.", - intentGroups, - seedStrategy: normalizedSeedStrategy, + intentGroups: scopedIntentGroups, + seedStrategy: gatedSeedStrategy, rejectedSeeds, nextActions: [ ...(contextReview @@ -2199,19 +2509,23 @@ function buildContract( }; } -export async function getSeoNormalizationContract(projectId: string): Promise { +async function buildSeoNormalizationContract(projectId: string): Promise { const analysis = await getLatestSemanticAnalysis(projectId); - const projectOntologyVersion = await getLatestProjectOntologyVersion(projectId); - const contextReview = analysis ? await getLatestAlignedSeoContextReview(projectId, analysis.runId) : null; - const businessSynthesis = analysis ? await getSeoBusinessSynthesisContract(projectId) : null; - const commercialDemand = analysis ? await getSeoCommercialDemandContract(projectId) : null; + const [projectOntologyVersion, contextReview, businessSynthesis, commercialDemand, demandResearchBrief] = await Promise.all([ + getLatestProjectOntologyVersion(projectId), + analysis ? getLatestAlignedSeoContextReview(projectId, analysis.runId) : Promise.resolve(null), + analysis ? getSeoBusinessSynthesisContract(projectId) : Promise.resolve(null), + analysis ? getSeoCommercialDemandContract(projectId) : Promise.resolve(null), + analysis ? getLatestSeoDemandResearchBrief(projectId, analysis.runId) : Promise.resolve(null) + ]); const fallbackContract = buildContract( projectId, analysis, projectOntologyVersion?.id ?? analysis?.projectOntology?.versionId ?? null, contextReview, businessSynthesis, - commercialDemand + commercialDemand, + demandResearchBrief ); if (!analysis) { @@ -2220,17 +2534,61 @@ export async function getSeoNormalizationContract(projectId: string): Promise ({ + ...seed, + needsHumanReview: true, + review: { + status: "needs_human" as const, + sendToSerp: false, + sendToWordstat: false, + reason: "Сначала нужно утвердить Project Context Contract.", + blockers: ["project_context_not_approved"] + } + })); + return { ...alignedContract, + state: contextApproved ? alignedContract.state : "not_ready", modelTask: fallbackContract.modelTask, + readiness: contextApproved + ? alignedContract.readiness + : { + ...alignedContract.readiness, + autoApprovedCount: 0, + needsHumanReviewCount: seedStrategy.length, + serpApprovedCount: 0, + wordstatApprovedCount: 0, + wordstatQueryCount: 0 + }, + seedStrategy, sourceTaskId: fallbackContract.modelTask?.taskId ?? alignedContract.sourceTaskId }; } +const seoNormalizationInFlight = new Map>(); + +export async function getSeoNormalizationContract(projectId: string): Promise { + const existing = seoNormalizationInFlight.get(projectId); + if (existing) return existing; + + const pending = buildSeoNormalizationContract(projectId); + seoNormalizationInFlight.set(projectId, pending); + try { + return await pending; + } finally { + if (seoNormalizationInFlight.get(projectId) === pending) { + seoNormalizationInFlight.delete(projectId); + } + } +} + export async function getLatestAlignedSeoNormalization( projectId: string, semanticRunId: string diff --git a/seo_mode/seo_mode/server/src/ontology/projectOntology.ts b/seo_mode/seo_mode/server/src/ontology/projectOntology.ts index 09d77ac..d1d61f4 100644 --- a/seo_mode/seo_mode/server/src/ontology/projectOntology.ts +++ b/seo_mode/seo_mode/server/src/ontology/projectOntology.ts @@ -457,15 +457,20 @@ function inferBrandName(evidence: ProjectOntologyEvidence | undefined) { function buildBrandTerms(brandName: string, evidence: ProjectOntologyEvidence | undefined) { const sourceRootName = evidence ? getStringConfig(evidence.sourceConfig, "rootName") : null; + const compactBrandName = normalizeOntologyText(brandName).replace(/[^\p{L}\p{N}]+/gu, ""); + const compactSourceRootName = normalizeOntologyText(sourceRootName ?? "").replace(/[^\p{L}\p{N}]+/gu, ""); + const punctuationSpacedName = brandName.replace(/[._-]+/g, " ").replace(/\s+/g, " ").trim(); + const punctuationFreeName = brandName.replace(/[._\s-]+/g, "").trim(); + const verifiedSourceAlias = compactSourceRootName && compactSourceRootName === compactBrandName ? sourceRootName ?? "" : ""; const candidateTerms = [ brandName, normalizeOntologyText(brandName), - sourceRootName ?? "", - ...brandName.split(/\s+/g), - ...brandName.split(/[._-]+/g) + punctuationSpacedName, + punctuationFreeName, + verifiedSourceAlias ]; - return dedupeStrings(candidateTerms).filter((term) => term.length >= 2 && !ONTOLOGY_STOP_WORDS.has(normalizeOntologyText(term))); + return dedupeStrings(candidateTerms).filter((term) => term.length >= 3 && !ONTOLOGY_STOP_WORDS.has(normalizeOntologyText(term))); } function getCoverageDocuments(evidence: ProjectOntologyEvidence) { diff --git a/seo_mode/seo_mode/server/src/opportunity/seoOpportunityCritic.ts b/seo_mode/seo_mode/server/src/opportunity/seoOpportunityCritic.ts new file mode 100644 index 0000000..3d2abbf --- /dev/null +++ b/seo_mode/seo_mode/server/src/opportunity/seoOpportunityCritic.ts @@ -0,0 +1,536 @@ +import crypto from "node:crypto"; +import { pool } from "../db/client.js"; +import { + getSeoOpportunityDiscoveryContract, + type SeoOpportunityDiscoveryCandidate, + type SeoOpportunityDiscoveryContract +} from "./seoOpportunityDiscovery.js"; + +type CriticVerdict = "defer" | "reject" | "shortlist"; +type CriticConfidence = "high" | "low" | "medium"; + +type OpportunityCriticRunRow = { + id: string; + output: SeoOpportunityCriticContract | null; + created_at: Date; +}; + +export type SeoOpportunityCriticReview = { + candidateId: string; + verdict: CriticVerdict; + confidence: CriticConfidence; + reason: string; + strongestCase: string; + fatalRisks: string[]; + missingEvidence: string[]; +}; + +export type SeoOpportunityCriticModelTaskContract = { + taskType: "seo.opportunity_critic"; + schemaVersion: "seo-opportunity-critic-task.v1"; + taskId: string; + projectId: string; + semanticRunId: string; + businessSynthesisRunId: string; + opportunityDiscoveryRunId: string; + projectOntologyVersionId: string | null; + providerMode: "model_provider_contract"; + analysisProfile: { + role: "independent_business_opportunity_red_team"; + objective: string; + independenceRule: string; + }; + input: { + sourceDiscoveryTaskId: string; + productCore: { + capabilityPrimitives: SeoOpportunityDiscoveryContract["capabilityPrimitives"]; + currentApplications: NonNullable["input"]["currentApplications"]; + forbiddenClaims: string[]; + }; + generatedPortfolio: { + shortlist: SeoOpportunityDiscoveryCandidate[]; + deferred: SeoOpportunityDiscoveryCandidate[]; + rejected: SeoOpportunityDiscoveryContract["rejectedCandidates"]; + }; + evaluationPolicy: { + minShortlistCount: number; + minSectorDiversity: number; + targetShortlistCount: number; + targetSectorDiversity: number; + demandClaimsRequireEvidence: true; + regulatedSectorsRequireExplicitRisk: true; + deferredCannotBeUpgradedWithoutNewEvidence: true; + }; + }; + allowedActions: Array< + | "audit_candidate_grounding" + | "challenge_product_and_business_fit" + | "detect_current_application_duplicates" + | "downgrade_or_reject_candidates" + | "approve_portfolio_for_human_selection" + >; + outputSchema: { + schemaVersion: "seo-opportunity-critic.v1"; + requiredTopLevelKeys: string[]; + reviewRequiredKeys: string[]; + portfolioVerdictRequiredKeys: string[]; + }; + stopConditions: string[]; +}; + +export type SeoOpportunityCriticContract = { + schemaVersion: "seo-opportunity-critic.v1"; + projectId: string; + semanticRunId: string | null; + businessSynthesisRunId: string | null; + opportunityDiscoveryRunId: string | null; + runId: string | null; + generatedAt: string; + state: "model_required" | "ready"; + provider: { + mode: "codex_workspace" | "deterministic_fallback"; + modelRequired: true; + message: string; + }; + sourceTaskId: string | null; + reviews: SeoOpportunityCriticReview[]; + portfolioVerdict: { + status: "approved" | "needs_revision"; + approvedCandidateIds: string[]; + deferredCandidateIds: string[]; + rejectedCandidateIds: string[]; + sectorDiversity: number; + summary: string; + blockers: string[]; + }; + modelTask: SeoOpportunityCriticModelTaskContract | null; + summary: string; + nextActions: string[]; +}; + +const RUN_TYPE = "seo_opportunity_critic"; + +function normalizeText(value: string) { + return value.trim().replace(/\s+/g, " "); +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asString(value: unknown, fallback = "") { + return typeof value === "string" && value.trim() ? normalizeText(value) : fallback; +} + +function unique(values: string[]) { + return [...new Set(values.map(normalizeText).filter(Boolean))]; +} + +function asStringArray(value: unknown) { + return unique(asArray(value).map((item) => asString(item)).filter(Boolean)); +} + +function getVerdict(value: unknown): CriticVerdict { + const normalized = asString(value).toLocaleLowerCase("ru-RU"); + return normalized.includes("reject") ? "reject" : normalized.includes("defer") ? "defer" : "shortlist"; +} + +function getConfidence(value: unknown): CriticConfidence { + const normalized = asString(value).toLocaleLowerCase("ru-RU"); + return normalized.includes("high") ? "high" : normalized.includes("low") ? "low" : "medium"; +} + +function buildTask(discovery: SeoOpportunityDiscoveryContract): SeoOpportunityCriticModelTaskContract | null { + if ( + discovery.state !== "ready" || + !discovery.runId || + !discovery.semanticRunId || + !discovery.businessSynthesisRunId || + !discovery.sourceTaskId || + !discovery.modelTask + ) { + return null; + } + + const visibleTarget = discovery.modelTask.input.opportunitySearchPolicy.visiblePortfolioTarget; + const diversityTarget = discovery.modelTask.input.opportunitySearchPolicy.minContextDiversity; + const minimumShortlistCount = Math.max(4, Math.ceil(visibleTarget.min * 0.3)); + const targetShortlistCount = Math.max(minimumShortlistCount, Math.ceil(visibleTarget.min * 0.55)); + const minimumContextDiversity = Math.max(3, Math.ceil(diversityTarget * 0.5)); + const taskHash = crypto.createHash("sha256").update(JSON.stringify({ + contractVersion: 4, + discoveryRunId: discovery.runId, + shortlist: discovery.opportunities, + deferred: discovery.deferredOpportunities, + rejected: discovery.rejectedCandidates + })).digest("hex").slice(0, 16); + + return { + taskType: "seo.opportunity_critic", + schemaVersion: "seo-opportunity-critic-task.v1", + taskId: `${discovery.runId}:opportunity-critic:${taskHash}:v4`, + projectId: discovery.projectId, + semanticRunId: discovery.semanticRunId, + businessSynthesisRunId: discovery.businessSynthesisRunId, + opportunityDiscoveryRunId: discovery.runId, + projectOntologyVersionId: discovery.projectOntologyVersionId, + providerMode: "model_provider_contract", + analysisProfile: { + role: "independent_business_opportunity_red_team", + objective: + "Независимо проверить бизнес-коридоры генератора, найти самоубеждение, дубли текущего продукта, скрытые затраты и красивые, но слабые гипотезы.", + independenceRule: + "Не считать embedded critic из discovery доказательством. Выполнить новый анализ по candidate facts и вернуть самостоятельный verdict для каждого кандидата." + }, + input: { + sourceDiscoveryTaskId: discovery.sourceTaskId, + productCore: { + capabilityPrimitives: discovery.capabilityPrimitives, + currentApplications: discovery.modelTask.input.currentApplications, + forbiddenClaims: discovery.modelTask.input.factualContext.forbiddenClaims + }, + generatedPortfolio: { + shortlist: discovery.opportunities, + deferred: discovery.deferredOpportunities, + rejected: discovery.rejectedCandidates + }, + evaluationPolicy: { + minShortlistCount: minimumShortlistCount, + minSectorDiversity: minimumContextDiversity, + targetShortlistCount, + targetSectorDiversity: diversityTarget, + demandClaimsRequireEvidence: true, + regulatedSectorsRequireExplicitRisk: true, + deferredCannotBeUpgradedWithoutNewEvidence: true + } + }, + allowedActions: [ + "audit_candidate_grounding", + "challenge_product_and_business_fit", + "detect_current_application_duplicates", + "downgrade_or_reject_candidates", + "approve_portfolio_for_human_selection" + ], + outputSchema: { + schemaVersion: "seo-opportunity-critic.v1", + requiredTopLevelKeys: [ + "schemaVersion", + "projectId", + "semanticRunId", + "businessSynthesisRunId", + "opportunityDiscoveryRunId", + "reviews", + "portfolioVerdict", + "summary", + "nextActions" + ], + reviewRequiredKeys: [ + "candidateId", + "verdict", + "confidence", + "reason", + "strongestCase", + "fatalRisks", + "missingEvidence" + ], + portfolioVerdictRequiredKeys: ["status", "summary", "blockers"] + }, + stopConditions: [ + "Проверить каждый shortlist и deferred candidate; неизвестные candidateId запрещены.", + "Deferred candidate нельзя повышать в shortlist без нового evidence, которого нет в этом task.", + "Demand всегда unverified: Wordstat/SERP/интервью отсутствуют и не могут быть придуманы.", + "Fatal risk, неподтверждённая regulated readiness или low confidence должны понижать кандидата минимум до defer.", + "Для verdict shortlist поле fatalRisks обязано быть пустым. Управляемые ограничения, проверки и product gaps писать в missingEvidence/reason; fatalRisks означает, что shortlist запрещён.", + "Кандидат, повторяющий current application, должен быть rejected с указанием duplicate.", + `Цель portfolio — около ${targetShortlistCount} shortlist candidates с разнообразием ${diversityTarget}; hard gate — ${minimumShortlistCount} независимо подтверждённых candidates в ${minimumContextDiversity} разных market contexts.`, + "Не удалять перспективные, но недоказанные направления: такие кандидаты получают defer и остаются видимыми пользователю как исследовательские гипотезы.", + "Critic не генерирует новые возможности и не переписывает продукт; он только проверяет существующий portfolio." + ] + }; +} + +function buildFallback(discovery: SeoOpportunityDiscoveryContract): SeoOpportunityCriticContract { + const modelTask = buildTask(discovery); + return { + schemaVersion: "seo-opportunity-critic.v1", + projectId: discovery.projectId, + semanticRunId: discovery.semanticRunId, + businessSynthesisRunId: discovery.businessSynthesisRunId, + opportunityDiscoveryRunId: discovery.runId, + runId: null, + generatedAt: new Date().toISOString(), + state: "model_required", + provider: { + mode: "deterministic_fallback", + modelRequired: true, + message: "Независимый Critic не заменяется fallback-логикой: до отдельного model run карта 4.2 не считается red-team проверенной." + }, + sourceTaskId: modelTask?.taskId ?? null, + reviews: [], + portfolioVerdict: { + status: "needs_revision", + approvedCandidateIds: [], + deferredCandidateIds: discovery.deferredOpportunities.map((item) => item.id), + rejectedCandidateIds: discovery.rejectedCandidates.map((item) => item.id), + sectorDiversity: 0, + summary: modelTask ? "Контракт независимого Critic готов." : "Сначала нужен model-backed Opportunity Discovery.", + blockers: [modelTask ? "Нужен независимый seo.opportunity_critic run." : "Opportunity Discovery ещё не готов."] + }, + modelTask, + summary: modelTask + ? "Portfolio генератора ожидает независимую red-team проверку." + : "Независимый Critic пока не готов к запуску.", + nextActions: modelTask ? ["Запустить seo.opportunity_critic через AI Workspace."] : ["Сначала завершить seo.opportunity_discovery."] + }; +} + +function normalizeModelOutput( + projectId: string, + output: SeoOpportunityCriticContract, + fallback: SeoOpportunityCriticContract, + discovery: SeoOpportunityDiscoveryContract +): SeoOpportunityCriticContract { + const raw = asRecord(output); + + if (raw.schemaVersion !== "seo-opportunity-critic.v1") { + throw new Error("Opportunity Critic output schemaVersion должен быть seo-opportunity-critic.v1."); + } + + if ( + raw.projectId !== projectId || + raw.semanticRunId !== fallback.semanticRunId || + raw.businessSynthesisRunId !== fallback.businessSynthesisRunId || + raw.opportunityDiscoveryRunId !== fallback.opportunityDiscoveryRunId + ) { + throw new Error("Opportunity Critic output не совпадает с текущим discovery/semantic/business run."); + } + + const candidateById = new Map( + [...discovery.opportunities, ...discovery.deferredOpportunities].map((candidate) => [candidate.id, candidate]) + ); + const originalShortlistIds = new Set(discovery.opportunities.map((candidate) => candidate.id)); + const seen = new Set(); + const reviews = asArray(raw.reviews).map((item) => { + const record = asRecord(item); + const candidateId = asString(record.candidateId); + const candidate = candidateById.get(candidateId); + + if (!candidate || seen.has(candidateId)) { + return null; + } + + seen.add(candidateId); + const rawVerdict = getVerdict(record.verdict); + const confidence = getConfidence(record.confidence); + const fatalRisks = asStringArray(record.fatalRisks); + const missingEvidence = asStringArray(record.missingEvidence); + const verdict: CriticVerdict = + rawVerdict === "reject" + ? "reject" + : !originalShortlistIds.has(candidateId) || rawVerdict === "defer" || confidence === "low" || fatalRisks.length > 0 + ? "defer" + : "shortlist"; + + return { + candidateId, + verdict, + confidence, + reason: asString(record.reason, "Critic не объяснил решение; кандидат понижен до defer."), + strongestCase: asString(record.strongestCase, candidate.commercialHypothesis), + fatalRisks, + missingEvidence: + missingEvidence.length > 0 + ? missingEvidence + : ["Нет независимого evidence рыночного спроса, интервью покупателя и SERP-проверки."] + } satisfies SeoOpportunityCriticReview; + }).filter((review): review is SeoOpportunityCriticReview => Boolean(review)); + + const missingCandidateIds = [...candidateById.keys()].filter((candidateId) => !seen.has(candidateId)); + + if (missingCandidateIds.length > 0) { + throw new Error(`Opportunity Critic обязан проверить каждый candidate: missing ${missingCandidateIds.join(", ")}.`); + } + + const approvedCandidateIds = reviews.filter((review) => review.verdict === "shortlist").map((review) => review.candidateId); + const deferredCandidateIds = reviews.filter((review) => review.verdict === "defer").map((review) => review.candidateId); + const rejectedCandidateIds = reviews.filter((review) => review.verdict === "reject").map((review) => review.candidateId); + const approvedSectors = new Set( + approvedCandidateIds.map((candidateId) => candidateById.get(candidateId)?.targetSector.toLocaleLowerCase("ru-RU")).filter(Boolean) + ); + const policy = fallback.modelTask?.input.evaluationPolicy; + const minimumShortlistCount = policy?.minShortlistCount ?? 3; + const minimumSectorDiversity = policy?.minSectorDiversity ?? 3; + const blockers = [ + ...(approvedCandidateIds.length < minimumShortlistCount + ? [`Независимый Critic подтвердил меньше ${minimumShortlistCount} shortlist candidates.`] + : []), + ...(approvedSectors.size < minimumSectorDiversity + ? [`Независимый shortlist покрывает меньше ${minimumSectorDiversity} разных market contexts.`] + : []) + ]; + const portfolioRecord = asRecord(raw.portfolioVerdict); + const status = blockers.length === 0 ? "approved" as const : "needs_revision" as const; + + return { + schemaVersion: "seo-opportunity-critic.v1", + projectId, + semanticRunId: fallback.semanticRunId, + businessSynthesisRunId: fallback.businessSynthesisRunId, + opportunityDiscoveryRunId: fallback.opportunityDiscoveryRunId, + runId: null, + generatedAt: new Date().toISOString(), + state: "ready", + provider: { + mode: "codex_workspace", + modelRequired: true, + message: "AI Workspace выполнил отдельный red-team pass; backend запретил неизвестные IDs, upgrades deferred и слабые shortlist verdicts." + }, + sourceTaskId: fallback.sourceTaskId, + reviews, + portfolioVerdict: { + status, + approvedCandidateIds, + deferredCandidateIds, + rejectedCandidateIds, + sectorDiversity: approvedSectors.size, + summary: + asString(portfolioRecord.summary) || + `${approvedCandidateIds.length} кандидатов прошли независимый Critic; ${deferredCandidateIds.length} отложены, ${rejectedCandidateIds.length} отклонены.`, + blockers + }, + modelTask: fallback.modelTask, + summary: asString(raw.summary, "Независимый Critic завершил red-team проверку business opportunities."), + nextActions: + status === "approved" + ? ["Показать shortlist как рекомендуемые направления, а defer — как отдельные исследовательские гипотезы с рисками."] + : ["Пересобрать Opportunity Discovery с учётом blockers и повторить независимый Critic."] + }; +} + +function mapRun(row: OpportunityCriticRunRow | undefined) { + return row?.output ? { ...row.output, runId: row.id } : null; +} + +export async function getSeoOpportunityCriticContract(projectId: string): Promise { + const discovery = await getSeoOpportunityDiscoveryContract(projectId); + const fallback = buildFallback(discovery); + + if (!fallback.opportunityDiscoveryRunId || !fallback.sourceTaskId) { + return fallback; + } + + const result = await pool.query( + ` + select id, output, created_at + from runs + where project_id = $1 + and run_type = $2 + and status = 'done' + and output->>'opportunityDiscoveryRunId' = $3 + order by + case when output->>'sourceTaskId' = $4 then 0 else 1 end, + created_at desc + limit 1; + `, + [projectId, RUN_TYPE, fallback.opportunityDiscoveryRunId, fallback.sourceTaskId] + ); + const latest = mapRun(result.rows[0]); + return latest ? { ...latest, modelTask: fallback.modelTask } : fallback; +} + +export async function buildLatestSeoOpportunityCriticTask(projectId: string) { + return (await getSeoOpportunityCriticContract(projectId)).modelTask; +} + +export async function saveSeoOpportunityCriticFromModelProvider( + projectId: string, + output: SeoOpportunityCriticContract, + sourceModelTaskRunId: string +): Promise { + const discovery = await getSeoOpportunityDiscoveryContract(projectId); + const fallback = buildFallback(discovery); + const normalized = normalizeModelOutput(projectId, output, fallback, discovery); + const result = await pool.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, $2, 'done', $3::jsonb, $4::jsonb, now(), now()) + returning id, output, created_at; + `, + [ + projectId, + RUN_TYPE, + JSON.stringify({ + schemaVersion: "seo-opportunity-critic-model-provider-input.v1", + opportunityDiscoveryRunId: normalized.opportunityDiscoveryRunId, + sourceTaskId: normalized.sourceTaskId, + sourceModelTaskRunId, + taskType: "seo.opportunity_critic" + }), + JSON.stringify(normalized) + ] + ); + const run = mapRun(result.rows[0]); + + if (!run) { + throw new Error("Не удалось сохранить независимый Opportunity Critic."); + } + + return run; +} + +export function applySeoOpportunityCritic( + discovery: SeoOpportunityDiscoveryContract, + critic: SeoOpportunityCriticContract +): SeoOpportunityDiscoveryContract { + if ( + critic.state !== "ready" || + critic.opportunityDiscoveryRunId !== discovery.runId + ) { + return discovery; + } + + const reviewsById = new Map(critic.reviews.map((review) => [review.candidateId, review])); + const originalCandidates = [...discovery.opportunities, ...discovery.deferredOpportunities]; + const approvedIds = new Set(critic.portfolioVerdict.approvedCandidateIds); + const deferredIds = new Set(critic.portfolioVerdict.deferredCandidateIds); + const rejectedIds = new Set(critic.portfolioVerdict.rejectedCandidateIds); + const withIndependentReview = (candidate: SeoOpportunityDiscoveryCandidate): SeoOpportunityDiscoveryCandidate => { + const review = reviewsById.get(candidate.id); + return review ? { ...candidate, critic: { ...review, verdict: review.verdict } } : candidate; + }; + const opportunities = originalCandidates.filter((candidate) => approvedIds.has(candidate.id)).map(withIndependentReview); + const deferredOpportunities = originalCandidates.filter((candidate) => deferredIds.has(candidate.id)).map(withIndependentReview); + const rejectedCandidates = [ + ...discovery.rejectedCandidates, + ...originalCandidates + .filter((candidate) => rejectedIds.has(candidate.id)) + .map((candidate) => ({ + id: candidate.id, + title: candidate.title, + reason: reviewsById.get(candidate.id)?.reason ?? "Independent Critic rejected candidate.", + duplicateOf: null + })) + ]; + + return { + ...discovery, + opportunities, + deferredOpportunities, + rejectedCandidates, + portfolio: { + ...discovery.portfolio, + shortlistCount: opportunities.length, + adjacentCount: opportunities.filter((candidate) => candidate.tier === "adjacent").length, + experimentalCount: opportunities.filter((candidate) => candidate.tier === "experimental").length, + deferredCount: deferredOpportunities.length, + rejectedCount: rejectedCandidates.length, + summary: critic.portfolioVerdict.summary, + recommendedNextMove: "Пользователь видит независимо подтверждённый shortlist и defer-гипотезы, но риски и статус должны оставаться явными." + }, + summary: `${discovery.summary} Independent Critic: ${critic.summary}` + }; +} diff --git a/seo_mode/seo_mode/server/src/opportunity/seoOpportunityDiscovery.ts b/seo_mode/seo_mode/server/src/opportunity/seoOpportunityDiscovery.ts new file mode 100644 index 0000000..1309922 --- /dev/null +++ b/seo_mode/seo_mode/server/src/opportunity/seoOpportunityDiscovery.ts @@ -0,0 +1,848 @@ +import crypto from "node:crypto"; +import { getLatestSemanticAnalysis, type SemanticAnalysisRun } from "../analysis/semanticAnalysis.js"; +import { + getLatestAlignedSeoBusinessSynthesis, + type SeoBusinessSynthesisRun +} from "../business/seoBusinessSynthesis.js"; +import { getLatestAlignedSeoContextReview, type SeoContextReviewRun } from "../contextReview/seoContextReview.js"; +import { + getLatestSeoOwnerStrategyInput, + type SeoOwnerStrategyInput +} from "../contextReview/ownerStrategy.js"; +import { pool } from "../db/client.js"; + +type ProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback"; +type OpportunityTier = "adjacent" | "experimental"; +type CriticVerdict = "defer" | "reject" | "shortlist"; + +type OpportunityDiscoveryRunRow = { + id: string; + status: string; + input: Record; + output: SeoOpportunityDiscoveryContract | null; + error_message: string | null; + started_at: Date | null; + completed_at: Date | null; + created_at: Date; +}; + +export type SeoOpportunityScorecard = { + productFit: number; + businessFit: number; + searchability: number; + timeToValue: number; + implementationDelta: number; + regulatoryBurden: number; + salesCycleComplexity: number; + overall: number; +}; + +export type SeoOpportunityDiscoveryCandidate = { + id: string; + title: string; + tier: OpportunityTier; + targetSector: string; + buyer: string; + jobToBeDone: string; + reusedCapabilityIds: string[]; + requiredAdaptations: string[]; + commercialHypothesis: string; + expectedSearchLanguage: string[]; + evidenceRefs: string[]; + assumptions: string[]; + demandStatus: "unverified"; + scorecard: SeoOpportunityScorecard; + critic: { + verdict: CriticVerdict; + reason: string; + strongestCase: string; + fatalRisks: string[]; + missingEvidence: string[]; + confidence: "high" | "low" | "medium"; + }; +}; + +export type SeoOpportunityDiscoveryModelTaskContract = { + taskType: "seo.opportunity_discovery"; + schemaVersion: "seo-opportunity-discovery-task.v1"; + taskId: string; + projectId: string; + semanticRunId: string; + businessSynthesisRunId: string; + projectOntologyVersionId: string | null; + providerMode: "model_provider_contract"; + analysisProfile: { + role: "senior_business_opportunity_analyst"; + passes: Array< + | "capability_interpreter" + | "opportunity_generator" + | "opportunity_critic" + | "portfolio_synthesizer" + >; + objective: string; + }; + input: { + factualContext: { + brandName: string; + identity: string; + audience: string; + offer: string; + summary: string; + language: string; + forbiddenClaims: string[]; + semanticGaps: Array<{ id: string; title: string; reason: string }>; + }; + productFrame: SeoBusinessSynthesisRun["productFrame"]; + capabilityPrimitives: Array<{ + id: string; + title: string; + description: string; + transferability: "high" | "low" | "medium"; + evidenceRefs: string[]; + }>; + currentApplications: SeoBusinessSynthesisRun["semanticHierarchy"]["applicationVectors"]; + ownerStrategy: { + provenance: "owner_strategy"; + isFactualEvidence: false; + strategicStatement: string; + referenceAnalogues: SeoOwnerStrategyInput["referenceAnalogues"]; + priorityThemes: string[]; + categoryExclusions: string[]; + expansionPolicy: SeoOwnerStrategyInput["expansionPolicy"]; + notes: string | null; + } | null; + opportunitySearchPolicy: { + rawCandidateTarget: { min: number; max: number }; + visiblePortfolioTarget: { min: number; max: number }; + minContextDiversity: number; + explorationLenses: Array<{ id: string; question: string; purpose: string }>; + balanceRules: string[]; + }; + businessConstraints: { + targetRegions: string[]; + preferredSectors: string[]; + blockedSectors: string[]; + availableCompetencies: string[]; + businessGoals: string[]; + note: string; + }; + }; + allowedActions: Array< + | "interpret_transferable_capabilities" + | "map_capabilities_to_jobs_buyers_and_sectors" + | "generate_adjacent_and_experimental_candidates" + | "score_business_opportunities" + | "criticize_and_reject_weak_candidates" + | "synthesize_balanced_portfolio" + >; + outputSchema: { + schemaVersion: "seo-opportunity-discovery.v1"; + requiredTopLevelKeys: string[]; + opportunityRequiredKeys: string[]; + scorecardRequiredKeys: string[]; + criticRequiredKeys: string[]; + }; + stopConditions: string[]; +}; + +export type SeoOpportunityDiscoveryContract = { + schemaVersion: "seo-opportunity-discovery.v1"; + projectId: string; + semanticRunId: string | null; + businessSynthesisRunId: string | null; + projectOntologyVersionId: string | null; + runId: string | null; + generatedAt: string; + state: "model_required" | "ready"; + provider: { + mode: ProviderMode; + modelRequired: boolean; + message: string; + }; + sourceTaskId: string | null; + capabilityPrimitives: Array<{ + id: string; + title: string; + description: string; + transferability: "high" | "low" | "medium"; + evidenceRefs: string[]; + }>; + opportunities: SeoOpportunityDiscoveryCandidate[]; + deferredOpportunities: SeoOpportunityDiscoveryCandidate[]; + rejectedCandidates: Array<{ + id: string; + title: string; + reason: string; + duplicateOf: string | null; + }>; + portfolio: { + shortlistCount: number; + adjacentCount: number; + experimentalCount: number; + deferredCount: number; + rejectedCount: number; + summary: string; + recommendedNextMove: string; + }; + modelTask: SeoOpportunityDiscoveryModelTaskContract | null; + summary: string; + nextActions: string[]; +}; + +const RUN_TYPE = "seo_opportunity_discovery"; +const OPPORTUNITY_EXPLORATION_LENSES: SeoOpportunityDiscoveryModelTaskContract["input"]["opportunitySearchPolicy"]["explorationLenses"] = [ + { + id: "same_capability_new_job", + question: "Какую новую работу покупателя может закрыть уже доказанная capability без смены продуктового ядра?", + purpose: "Найти соседний JTBD, а не переименовать текущее применение." + }, + { + id: "same_job_new_buyer", + question: "У какого другого покупателя существует та же дорогая или рискованная работа?", + purpose: "Расширить buyer space без отраслевого хардкода." + }, + { + id: "capability_bundle", + question: "Какой новый результат появляется при объединении двух или более переносимых capabilities?", + purpose: "Искать продуктовые комбинации, а не одиночные функции." + }, + { + id: "operating_environment_transfer", + question: "В каких иных средах — распределённых, регулируемых, asset-heavy, knowledge-heavy или time-critical — это ядро создаёт ценность?", + purpose: "Модель самостоятельно выводит рынки из условий работы." + }, + { + id: "service_model_shift", + question: "Можно ли превратить capability в самостоятельный сервис, внутренний продукт, managed workflow или infrastructure layer?", + purpose: "Проверить альтернативную упаковку и способ покупки." + }, + { + id: "frontier_transfer", + question: "Где высокий implementation delta оправдан уникальным преимуществом продуктового ядра?", + purpose: "Не терять смелые гипотезы, но явно маркировать адаптации и риски." + } +]; + +function normalizeText(value: string) { + return value.trim().replace(/\s+/g, " "); +} + +function normalizeKey(value: string) { + return normalizeText(value).toLocaleLowerCase("ru-RU"); +} + +function unique(values: string[]) { + const seen = new Set(); + const result: string[] = []; + + for (const value of values.map(normalizeText).filter(Boolean)) { + const key = normalizeKey(value); + + if (!seen.has(key)) { + seen.add(key); + result.push(value); + } + } + + return result; +} + +function asRecord(value: unknown): Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)) ? value as Record : {}; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function asString(value: unknown, fallback = "") { + return typeof value === "string" && value.trim() ? normalizeText(value) : fallback; +} + +function asStringArray(value: unknown) { + return unique(asArray(value).map((item) => asString(item)).filter(Boolean)); +} + +function clampScore(value: unknown, fallback = 50) { + const number = typeof value === "number" ? value : Number(value); + + if (!Number.isFinite(number)) { + return fallback; + } + + const normalizedNumber = number >= 0 && number <= 10 ? number * 10 : number; + return Math.max(0, Math.min(100, Math.round(normalizedNumber))); +} + +function calculateOverallScore(scorecard: Omit) { + return Math.round( + scorecard.productFit * 0.3 + + scorecard.businessFit * 0.2 + + scorecard.searchability * 0.15 + + scorecard.timeToValue * 0.15 + + (100 - scorecard.implementationDelta) * 0.1 + + (100 - scorecard.regulatoryBurden) * 0.05 + + (100 - scorecard.salesCycleComplexity) * 0.05 + ); +} + +function toId(value: string, fallback: string) { + return normalizeKey(value) + .replace(/[^a-zа-яё0-9]+/gi, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 90) || fallback; +} + +function getTitleTokens(value: string) { + const stop = new Set(["для", "или", "как", "при", "под", "над", "с", "и", "в", "на", "по", "слой", "контур", "система", "платформа", "решение"]); + return new Set((normalizeKey(value).match(/[a-zа-яё0-9-]{3,}/giu) ?? []).filter((token) => !stop.has(token))); +} + +function titleSimilarity(left: string, right: string) { + const leftTokens = getTitleTokens(left); + const rightTokens = getTitleTokens(right); + const intersection = [...leftTokens].filter((token) => rightTokens.has(token)).length; + const union = new Set([...leftTokens, ...rightTokens]).size; + return union > 0 ? intersection / union : 0; +} + +function buildCapabilities(business: SeoBusinessSynthesisRun) { + return business.semanticHierarchy.corePillars.map((pillar) => ({ + id: pillar.id, + title: pillar.title, + description: pillar.whyItMatters, + transferability: + pillar.role === "core_platform" || pillar.role === "workflow_layer" + ? "high" as const + : pillar.role === "supporting_capability" + ? "low" as const + : "medium" as const, + evidenceRefs: unique(pillar.evidenceRefs) + })); +} + +function buildOpportunitySearchPolicy( + capabilities: SeoOpportunityDiscoveryModelTaskContract["input"]["capabilityPrimitives"] +): SeoOpportunityDiscoveryModelTaskContract["input"]["opportunitySearchPolicy"] { + const highTransferabilityCount = capabilities.filter((item) => item.transferability === "high").length; + const mediumTransferabilityCount = capabilities.filter((item) => item.transferability === "medium").length; + const visibleMinimum = Math.max( + 6, + Math.min(18, Math.round(6 + highTransferabilityCount * 3 + mediumTransferabilityCount * 1.5)) + ); + const visibleMaximum = Math.min(24, visibleMinimum + 6); + + return { + rawCandidateTarget: { + min: visibleMinimum * 2, + max: Math.min(48, visibleMaximum * 2) + }, + visiblePortfolioTarget: { + min: visibleMinimum, + max: visibleMaximum + }, + minContextDiversity: Math.max(3, Math.min(10, Math.ceil(visibleMinimum / 2))), + explorationLenses: OPPORTUNITY_EXPLORATION_LENSES, + balanceRules: [ + "Сначала построить широкое candidate space по всем exploration lenses, только затем включать critic.", + "Не сводить portfolio к самым простым операционным сценариям с минимальным implementation delta.", + "Сбалансировать near-core, new-buyer/JTBD, cross-environment и frontier opportunities.", + "Каждый кандидат обязан сохранять центральный differentiator продукта; generic workflow без него отклоняется.", + "Shortlist и defer вместе образуют пользовательскую карту исследования; reject остаётся только в audit memory." + ] + }; +} + +function buildTask(input: { + analysis: SemanticAnalysisRun; + contextReview: SeoContextReviewRun; + business: SeoBusinessSynthesisRun; + ownerStrategy: SeoOwnerStrategyInput | null; +}): SeoOpportunityDiscoveryModelTaskContract { + const capabilityPrimitives = buildCapabilities(input.business); + const opportunitySearchPolicy = buildOpportunitySearchPolicy(capabilityPrimitives); + const taskHash = crypto.createHash("sha256").update(JSON.stringify({ + contractVersion: 4, + semanticRunId: input.analysis.runId, + businessSynthesisRunId: input.business.runId, + capabilities: capabilityPrimitives, + applications: input.business.semanticHierarchy.applicationVectors, + ownerStrategy: input.ownerStrategy, + opportunitySearchPolicy + })).digest("hex").slice(0, 16); + + return { + taskType: "seo.opportunity_discovery", + schemaVersion: "seo-opportunity-discovery-task.v1", + taskId: `${input.analysis.runId}:opportunity-discovery:${taskHash}:v4`, + projectId: input.analysis.projectId, + semanticRunId: input.analysis.runId, + businessSynthesisRunId: input.business.runId, + projectOntologyVersionId: input.business.projectOntologyVersionId, + providerMode: "model_provider_contract", + analysisProfile: { + role: "senior_business_opportunity_analyst", + passes: ["capability_interpreter", "opportunity_generator", "opportunity_critic", "portfolio_synthesizer"], + objective: + "Найти коммерчески осмысленные коридоры применения продукта шире текущего сайта, затем жёстко отсеять красивые, но слабые фантазии." + }, + input: { + factualContext: { + brandName: input.contextReview.siteIdentity.brandName, + identity: input.contextReview.siteIdentity.description, + audience: input.contextReview.audience.primary, + offer: input.contextReview.offer.summary, + summary: input.contextReview.summary, + language: input.analysis.styleProfile.language, + forbiddenClaims: input.contextReview.forbiddenClaims.map((claim) => `${claim.claim}: ${claim.reason}`), + semanticGaps: input.contextReview.semanticGaps.map((gap) => ({ id: gap.id, reason: gap.reason, title: gap.title })) + }, + productFrame: input.business.productFrame, + capabilityPrimitives, + currentApplications: input.business.semanticHierarchy.applicationVectors, + ownerStrategy: input.ownerStrategy + ? { + categoryExclusions: input.ownerStrategy.categoryExclusions, + expansionPolicy: input.ownerStrategy.expansionPolicy, + isFactualEvidence: false, + notes: input.ownerStrategy.notes, + priorityThemes: input.ownerStrategy.priorityThemes, + provenance: "owner_strategy", + referenceAnalogues: input.ownerStrategy.referenceAnalogues, + strategicStatement: input.ownerStrategy.strategicStatement + } + : null, + opportunitySearchPolicy, + businessConstraints: { + targetRegions: [], + preferredSectors: [], + blockedSectors: [], + availableCompetencies: [], + businessGoals: unique([ + "Найти новые коммерчески проверяемые применения без подмены продуктового ядра.", + input.ownerStrategy?.strategicStatement ?? "", + ...(input.ownerStrategy?.priorityThemes ?? []) + ]), + note: input.ownerStrategy + ? "Owner strategy задаёт направление поиска, но не является доказательством текущей capability или рыночного спроса." + : "Ограничения пользователя пока не заданы. Не считать отсутствие ограничений разрешением на regulated/high-risk expansion." + } + }, + allowedActions: [ + "interpret_transferable_capabilities", + "map_capabilities_to_jobs_buyers_and_sectors", + "generate_adjacent_and_experimental_candidates", + "score_business_opportunities", + "criticize_and_reject_weak_candidates", + "synthesize_balanced_portfolio" + ], + outputSchema: { + schemaVersion: "seo-opportunity-discovery.v1", + requiredTopLevelKeys: [ + "schemaVersion", + "projectId", + "semanticRunId", + "businessSynthesisRunId", + "capabilityPrimitives", + "opportunities", + "deferredOpportunities", + "rejectedCandidates", + "portfolio", + "summary", + "nextActions" + ], + opportunityRequiredKeys: [ + "id", "title", "tier", "targetSector", "buyer", "jobToBeDone", "reusedCapabilityIds", + "requiredAdaptations", "commercialHypothesis", "expectedSearchLanguage", "evidenceRefs", "assumptions", "scorecard", "critic" + ], + scorecardRequiredKeys: [ + "productFit", "businessFit", "searchability", "timeToValue", "implementationDelta", "regulatoryBurden", "salesCycleComplexity", "overall" + ], + criticRequiredKeys: ["verdict", "reason", "strongestCase", "fatalRisks", "missingEvidence", "confidence"] + }, + stopConditions: [ + "Сначала интерпретировать capability primitives, только затем искать jobs, buyers и sectors.", + "Не повторять currentApplications под новым названием; такой кандидат должен попасть в rejectedCandidates как duplicate.", + "Не считать техническую применимость доказательством business fit, product readiness или рыночного спроса.", + "Каждая возможность обязана называть конкретный market/operating context, buyer, job-to-be-done, reused capabilities и required adaptations.", + "ownerStrategy имеет provenance owner_strategy и влияет на приоритет и breadth гипотез, но не может создавать factual capability, product readiness или market evidence.", + "referenceAnalogues разрешено использовать только через transferableTraits и caveat. Не копировать бренд, его продукты, рынки или claims в ответ и не превращать аналог в доменный шаблон.", + "categoryExclusions запрещают предлагать исключённую категорию как core positioning. Comparison/integration hypothesis допустима только как отдельная явно маркированная ветка с product relation.", + "До critic сгенерировать candidate space в пределах rawCandidateTarget; не обрывать exploration после первых безопасных идей.", + "После critic сохранить в shortlist + defer не меньше visiblePortfolioTarget.min направлений, если они проходят базовый grounding contract.", + "Каждый кандидат обязан объяснить, как centralThesis/coreOffer и минимум одна доказанная capability создают специфическое преимущество. Generic automation/workflow без differentiator отклоняется.", + "expectedSearchLanguage содержит минимум 4 разных формулировки: category/core, buyer/JTBD, implementation/automation и differentiator/capability. Минимум две сохраняют центральный differentiator продукта.", + "Каждая возможность проходит отдельный opportunity_critic pass. Critic должен искать fatal risks, missing evidence, regulated burden и sales-cycle mismatch.", + "Строго соблюдать enums: tier только adjacent|experimental; critic.verdict только shortlist|defer|reject; critic.confidence только high|medium|low.", + "Все scorecard поля задаются целыми числами 0-100: 0 означает минимальное свойство/нагрузку, 100 — максимальное. implementationDelta, regulatoryBurden и salesCycleComplexity являются penalties.", + "Demand status всегда unverified. Частотность, размер рынка и коммерческая эффективность не могут утверждаться без Wordstat/SERP/market evidence.", + "Не использовать NODE.DC, AI, software-platform или любую конкретную отрасль как шаблон ответа: рынки выводятся только из factualContext, productFrame, capabilityPrimitives и универсальных exploration lenses.", + "Не путать широту карты с утверждением спроса: shortlist — сильные гипотезы, defer — перспективные исследовательские гипотезы, reject — дубли или неподходящие направления.", + "Experimental opportunity должна явно описывать значимую адаптацию и повышенный риск; regulated sectors нельзя автоматически рекомендовать.", + "Возвращать reject/defer с причинами. Ценность анализа измеряется качеством отсечения, а не количеством придуманных идей." + ] + }; +} + +async function buildFallback(projectId: string): Promise { + const analysis = await getLatestSemanticAnalysis(projectId); + const [contextReview, business, ownerStrategy] = analysis + ? await Promise.all([ + getLatestAlignedSeoContextReview(projectId, analysis.runId), + getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId), + getLatestSeoOwnerStrategyInput(projectId) + ]) + : [null, null, null]; + const modelTask = analysis && contextReview && business ? buildTask({ analysis, business, contextReview, ownerStrategy }) : null; + const capabilities = business ? buildCapabilities(business) : []; + + return { + schemaVersion: "seo-opportunity-discovery.v1", + projectId, + semanticRunId: analysis?.runId ?? null, + businessSynthesisRunId: business?.runId ?? null, + projectOntologyVersionId: business?.projectOntologyVersionId ?? null, + runId: null, + generatedAt: new Date().toISOString(), + state: "model_required", + provider: { + mode: "deterministic_fallback", + modelRequired: true, + message: "Capabilities собраны детерминированно; новые бизнес-возможности нельзя придумывать fallback-логикой без model + critic pass." + }, + sourceTaskId: modelTask?.taskId ?? null, + capabilityPrimitives: capabilities, + opportunities: [], + deferredOpportunities: [], + rejectedCandidates: [], + portfolio: { + shortlistCount: 0, + adjacentCount: 0, + experimentalCount: 0, + deferredCount: 0, + rejectedCount: 0, + summary: modelTask ? "Контракт бизнес-аналитика готов к модельному прогону." : "Сначала нужны evidence-слой, контекст проекта 4.1 и Business Synthesis.", + recommendedNextMove: modelTask ? "Запустить Opportunity Discovery + Critic." : "Собрать фактический и смысловой контекст." + }, + modelTask, + summary: modelTask + ? "Продуктовые capabilities зафиксированы. Карта новых бизнес-коридоров появится только после модельного аналитика и критика." + : "Opportunity Discovery пока не готов.", + nextActions: modelTask ? ["Запустить seo.opportunity_discovery через AI Workspace."] : ["Сначала собрать evidence-слой и контекст проекта 4.1."] + }; +} + +function normalizeScorecard(value: unknown): SeoOpportunityScorecard { + const record = asRecord(value); + const scorecard = { + productFit: clampScore(record.productFit), + businessFit: clampScore(record.businessFit), + searchability: clampScore(record.searchability), + timeToValue: clampScore(record.timeToValue), + implementationDelta: clampScore(record.implementationDelta), + regulatoryBurden: clampScore(record.regulatoryBurden), + salesCycleComplexity: clampScore(record.salesCycleComplexity), + overall: 0 + }; + scorecard.overall = calculateOverallScore(scorecard); + return scorecard; +} + +function normalizeCandidate( + value: unknown, + index: number, + capabilities: SeoOpportunityDiscoveryContract["capabilityPrimitives"] +): SeoOpportunityDiscoveryCandidate | null { + const record = asRecord(value); + const criticRecord = asRecord(record.critic); + const title = asString(record.title); + const targetSector = asString(record.targetSector); + const buyer = asString(record.buyer); + const jobToBeDone = asString(record.jobToBeDone); + const validCapabilityIds = new Set(capabilities.map((item) => item.id)); + const reusedCapabilityIds = asStringArray(record.reusedCapabilityIds).filter((id) => validCapabilityIds.has(id)); + + if (!title || !targetSector || !buyer || !jobToBeDone || reusedCapabilityIds.length === 0) { + return null; + } + + const requiredAdaptations = asStringArray(record.requiredAdaptations); + const assumptions = asStringArray(record.assumptions); + const scorecard = normalizeScorecard(record.scorecard); + const explicitRegulationRisk = /regulat|compliance|licen[cs]|certif|safety[-\s]?critical|personal\s+data|privacy|регулятор|комплаенс|лиценз|сертифик|критич[^.]{0,24}безопас|персональн[^.]{0,24}данн/i.test( + [targetSector, ...requiredAdaptations, ...assumptions].join(" ") + ); + + if (explicitRegulationRisk && scorecard.regulatoryBurden < 55) { + scorecard.regulatoryBurden = 55; + scorecard.overall = calculateOverallScore(scorecard); + } + + const rawVerdict = asString(criticRecord.verdict); + const normalizedVerdict = normalizeKey(rawVerdict); + const rawCriticVerdict: CriticVerdict = normalizedVerdict.includes("reject") + ? "reject" + : normalizedVerdict.includes("defer") + ? "defer" + : "shortlist"; + const rawConfidence = asString(criticRecord.confidence); + const normalizedConfidence = normalizeKey(rawConfidence); + const expectedSearchLanguage = asStringArray(record.expectedSearchLanguage).slice(0, 12); + const commercialHypothesisRecord = asRecord(record.commercialHypothesis); + const commercialHypothesis = + asString(record.commercialHypothesis) || + asString(commercialHypothesisRecord.hypothesis) || + "Коммерческая гипотеза требует проверки интервью и рыночным спросом."; + const unsupportedDemandClaim = /рынок\s+(?:составляет|раст[её]т)|высок(?:ий|ая)\s+спрос|частотност|объ[её]м\s+рынка|market\s+size|high\s+demand/i.test(commercialHypothesis); + const criticMissingEvidence = asStringArray(criticRecord.missingEvidence); + const criticFatalRisks = asStringArray(criticRecord.fatalRisks); + const forcedDefer = + scorecard.overall < 55 || + normalizedConfidence.includes("low") || + requiredAdaptations.length === 0 || + expectedSearchLanguage.length < 4 || + unsupportedDemandClaim; + const verdict: CriticVerdict = rawCriticVerdict === "reject" ? "reject" : forcedDefer ? "defer" : rawCriticVerdict; + + return { + id: toId(asString(record.id, title), `opportunity-${index + 1}`), + title, + tier: record.tier === "experimental" ? "experimental" : "adjacent", + targetSector, + buyer, + jobToBeDone, + reusedCapabilityIds, + requiredAdaptations, + commercialHypothesis, + expectedSearchLanguage, + evidenceRefs: unique([ + ...asStringArray(record.evidenceRefs), + ...capabilities.filter((item) => reusedCapabilityIds.includes(item.id)).flatMap((item) => item.evidenceRefs) + ]).slice(0, 16), + assumptions: asStringArray(record.assumptions), + demandStatus: "unverified", + scorecard, + critic: { + verdict, + reason: asString(criticRecord.reason, "Critic не дал достаточного объяснения; требуется human review."), + strongestCase: asString(criticRecord.strongestCase, "Связь с переносимыми capabilities проекта."), + fatalRisks: unique([ + ...criticFatalRisks, + ...(unsupportedDemandClaim ? ["Commercial hypothesis содержит неподтверждённое утверждение о размере/спросе рынка."] : []) + ]), + missingEvidence: criticMissingEvidence.length > 0 + ? criticMissingEvidence + : ["Нет подтверждённого Wordstat/SERP evidence и интервью с покупателем."], + confidence: normalizedConfidence.includes("high") + ? "high" + : normalizedConfidence.includes("low") + ? "low" + : "medium" + } + }; +} + +function normalizeModelOutput( + projectId: string, + output: SeoOpportunityDiscoveryContract, + fallback: SeoOpportunityDiscoveryContract +): SeoOpportunityDiscoveryContract { + const raw = asRecord(output); + + if (raw.schemaVersion !== "seo-opportunity-discovery.v1") { + throw new Error("Opportunity Discovery output schemaVersion должен быть seo-opportunity-discovery.v1."); + } + + if (raw.projectId !== projectId || raw.semanticRunId !== fallback.semanticRunId || raw.businessSynthesisRunId !== fallback.businessSynthesisRunId) { + throw new Error("Opportunity Discovery output не совпадает с текущим проектом/semantic/business run."); + } + + const existingTitles = fallback.modelTask?.input.currentApplications.map((item) => item.title) ?? []; + const rejectedCandidates: SeoOpportunityDiscoveryContract["rejectedCandidates"] = asArray(raw.rejectedCandidates).map((item, index) => { + const record = asRecord(item); + return { + id: toId(asString(record.id, `rejected-${index + 1}`), `rejected-${index + 1}`), + title: asString(record.title, `Отклонённый кандидат ${index + 1}`), + reason: asString(record.reason, "Модельный критик отклонил направление."), + duplicateOf: asString(record.duplicateOf) || asString(record.duplicatesCurrentApplicationId) || null + }; + }); + const normalized = asArray(raw.opportunities) + .map((item, index) => normalizeCandidate(item, index, fallback.capabilityPrimitives)) + .filter((item): item is SeoOpportunityDiscoveryCandidate => Boolean(item)); + const deferredInput = asArray(raw.deferredOpportunities) + .map((item, index) => normalizeCandidate(item, normalized.length + index, fallback.capabilityPrimitives)) + .filter((item): item is SeoOpportunityDiscoveryCandidate => Boolean(item)); + const seen = new Set(); + const shortlist: SeoOpportunityDiscoveryCandidate[] = []; + const deferred: SeoOpportunityDiscoveryCandidate[] = []; + + for (const candidate of [...normalized, ...deferredInput]) { + const duplicateTitle = existingTitles.find((title) => titleSimilarity(title, candidate.title) >= 0.48) ?? null; + const key = normalizeKey(`${candidate.targetSector}:${candidate.jobToBeDone}`); + + if (duplicateTitle || seen.has(key)) { + rejectedCandidates.push({ + id: candidate.id, + title: candidate.title, + reason: duplicateTitle ? "Кандидат повторяет уже подтверждённое применение сайта." : "Дубликат sector + job-to-be-done в модельном портфеле.", + duplicateOf: duplicateTitle + }); + continue; + } + + seen.add(key); + + if (candidate.critic.verdict === "reject") { + rejectedCandidates.push({ id: candidate.id, title: candidate.title, reason: candidate.critic.reason, duplicateOf: null }); + } else if (candidate.critic.verdict === "defer") { + deferred.push(candidate); + } else { + shortlist.push(candidate); + } + } + + const visiblePolicy = fallback.modelTask?.input.opportunitySearchPolicy; + const opportunities = shortlist + .sort((left, right) => right.scorecard.overall - left.scorecard.overall) + .slice(0, 20); + const deferredOpportunities = deferred + .sort((left, right) => right.scorecard.overall - left.scorecard.overall) + .slice(0, 20); + const portfolioRecord = asRecord(raw.portfolio); + const rawSummary = asRecord(raw.summary); + + const visibleOpportunities = [...opportunities, ...deferredOpportunities]; + const minimumVisibleCount = visiblePolicy?.visiblePortfolioTarget.min ?? 6; + const minimumContextDiversity = visiblePolicy?.minContextDiversity ?? 3; + + if ( + opportunities.length < 3 || + visibleOpportunities.length < minimumVisibleCount || + new Set(visibleOpportunities.map((item) => normalizeKey(item.targetSector))).size < minimumContextDiversity + ) { + throw new Error( + `Opportunity Discovery quality gate: нужен shortlist минимум 3, видимая карта минимум ${minimumVisibleCount} направлений и ${minimumContextDiversity} разных market contexts.` + ); + } + const portfolio = { + shortlistCount: opportunities.length, + adjacentCount: opportunities.filter((item) => item.tier === "adjacent").length, + experimentalCount: opportunities.filter((item) => item.tier === "experimental").length, + deferredCount: deferredOpportunities.length, + rejectedCount: rejectedCandidates.length, + summary: + asString(portfolioRecord.summary) || + asString(portfolioRecord.rationale) || + asString(portfolioRecord.strategy) || + `Business analyst оставил ${opportunities.length} направлений и отложил ${deferredOpportunities.length}.`, + recommendedNextMove: + asString(portfolioRecord.recommendedNextMove) || + asString(portfolioRecord.strategy) || + "Выбрать 1-3 коридора для проверки языка спроса, не считая их доказанными рынками." + }; + + return { + schemaVersion: "seo-opportunity-discovery.v1", + projectId, + semanticRunId: fallback.semanticRunId, + businessSynthesisRunId: fallback.businessSynthesisRunId, + projectOntologyVersionId: fallback.projectOntologyVersionId, + runId: null, + generatedAt: new Date().toISOString(), + state: "ready", + provider: { + mode: "codex_workspace", + modelRequired: true, + message: "AI Workspace выполнил capability interpretation, opportunity generation, critic и portfolio synthesis; backend пересчитал scores и удалил дубли." + }, + sourceTaskId: fallback.sourceTaskId, + capabilityPrimitives: fallback.capabilityPrimitives, + opportunities, + deferredOpportunities, + rejectedCandidates: rejectedCandidates.slice(0, 30), + portfolio, + modelTask: fallback.modelTask, + summary: asString(raw.summary) || asString(rawSummary.centralFinding) || portfolio.summary, + nextActions: [ + "Показать текущие применения отдельно от новых shortlist и defer бизнес-гипотез.", + "Дать пользователю выбрать допустимые коридоры для Demand Research Brief.", + "Проверять выбранные гипотезы Wordstat/SERP только после Model Seed Draft и human gate." + ] + }; +} + +function mapRun(row: OpportunityDiscoveryRunRow | undefined): SeoOpportunityDiscoveryContract | null { + return row?.output ? { ...row.output, runId: row.id } : null; +} + +async function getLatestAligned(fallback: SeoOpportunityDiscoveryContract) { + if (!fallback.semanticRunId || !fallback.businessSynthesisRunId || !fallback.sourceTaskId) { + return null; + } + + const result = await pool.query( + ` + select id, status, input, output, error_message, started_at, completed_at, created_at + from runs + where project_id = $1 + and run_type = $2 + and output->>'semanticRunId' = $3 + and output->>'businessSynthesisRunId' = $4 + order by + case when output->>'sourceTaskId' = $5 then 0 else 1 end, + created_at desc + limit 1; + `, + [fallback.projectId, RUN_TYPE, fallback.semanticRunId, fallback.businessSynthesisRunId, fallback.sourceTaskId] + ); + + return mapRun(result.rows[0]); +} + +export async function getSeoOpportunityDiscoveryContract(projectId: string): Promise { + const fallback = await buildFallback(projectId); + const latest = await getLatestAligned(fallback); + + return latest ? { ...latest, capabilityPrimitives: fallback.capabilityPrimitives, modelTask: fallback.modelTask } : fallback; +} + +export async function buildLatestSeoOpportunityDiscoveryTask(projectId: string) { + return (await buildFallback(projectId)).modelTask; +} + +export async function saveSeoOpportunityDiscoveryFromModelProvider( + projectId: string, + output: SeoOpportunityDiscoveryContract, + sourceModelTaskRunId: string +): Promise { + const fallback = await buildFallback(projectId); + const normalized = normalizeModelOutput(projectId, output, fallback); + const result = await pool.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, $2, 'done', $3::jsonb, $4::jsonb, now(), now()) + returning id, status, input, output, error_message, started_at, completed_at, created_at; + `, + [ + projectId, + RUN_TYPE, + JSON.stringify({ + schemaVersion: "seo-opportunity-discovery-model-provider-input.v1", + semanticRunId: normalized.semanticRunId, + businessSynthesisRunId: normalized.businessSynthesisRunId, + sourceTaskId: normalized.sourceTaskId, + sourceModelTaskRunId, + taskType: "seo.opportunity_discovery" + }), + JSON.stringify(normalized) + ] + ); + const run = mapRun(result.rows[0]); + + if (!run) { + throw new Error("Не удалось сохранить Opportunity Discovery."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/preview/routes.ts b/seo_mode/seo_mode/server/src/preview/routes.ts index c134386..2e3c72a 100644 --- a/seo_mode/seo_mode/server/src/preview/routes.ts +++ b/seo_mode/seo_mode/server/src/preview/routes.ts @@ -303,14 +303,26 @@ function buildPreviewSiteUrl(projectId: string, assetPath: string) { return `/preview/projects/${encodeURIComponent(projectId)}/site/${encodePathname(assetPath)}`; } -function buildPreviewDirectoryUrl(projectId: string, directoryPath: string) { - const normalizedDirectory = normalizePreviewPath(directoryPath); - const siteUrl = buildPreviewSiteUrl(projectId, normalizedDirectory); - - return siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`; +function encodeRelativePath(value: string) { + return value + .split("/") + .map((part) => (part === "." || part === ".." || part === "" ? part : encodeURIComponent(part))) + .join("/"); } -function rewritePreviewUrl(value: string, projectId: string, siteRoot: string) { +function buildRelativePreviewAssetUrl(resolvedPath: string, assetPath: string) { + const currentDirectory = path.posix.dirname(normalizePreviewPath(resolvedPath)); + const relativePath = path.posix.relative(currentDirectory === "." ? "" : currentDirectory, normalizePreviewPath(assetPath)); + const encodedPath = encodeRelativePath(relativePath); + + if (!encodedPath) { + return "./"; + } + + return encodedPath.startsWith(".") ? encodedPath : `./${encodedPath}`; +} + +function rewritePreviewUrl(value: string, siteRoot: string, resolvedPath: string) { const trimmed = value.trim(); if (shouldKeepOriginalUrl(trimmed) || !trimmed.startsWith("/") || trimmed.startsWith("//")) { @@ -320,10 +332,13 @@ function rewritePreviewUrl(value: string, projectId: string, siteRoot: string) { const { pathname, suffix } = splitUrlSuffix(trimmed); const rootedPath = normalizePreviewPath(`${siteRoot}/${pathname.replace(/^\/+/, "")}`); - return `${buildPreviewSiteUrl(projectId, rootedPath)}${suffix}`; + // Keep preview assets under the same browser-visible route as the document. + // The API may be mounted directly at /preview or proxied through /api/preview; + // a relative URL preserves either prefix and also works for nested source pages. + return `${buildRelativePreviewAssetUrl(resolvedPath, rootedPath)}${suffix}`; } -function rewriteSrcset(value: string, projectId: string, siteRoot: string) { +function rewriteSrcset(value: string, siteRoot: string, resolvedPath: string) { return value .split(",") .map((entry) => { @@ -334,7 +349,7 @@ function rewriteSrcset(value: string, projectId: string, siteRoot: string) { } const [url, ...descriptor] = trimmedEntry.split(/\s+/); - return [rewritePreviewUrl(url ?? "", projectId, siteRoot), ...descriptor].join(" "); + return [rewritePreviewUrl(url ?? "", siteRoot, resolvedPath), ...descriptor].join(" "); }) .join(", "); } @@ -3611,25 +3626,21 @@ function buildPreviewStabilityStyle() { function rewriteHtml( html: string, - projectId: string, resolvedPath: string, sourceConfig: Record, options: PreviewRewriteOptions = {} ) { const siteRoot = getSiteRoot(sourceConfig, resolvedPath); - const currentDirectory = path.posix.dirname(resolvedPath); - const baseDirectory = currentDirectory === "." ? "" : `${currentDirectory}/`; - const baseHref = buildPreviewDirectoryUrl(projectId, baseDirectory); const annotatedHtml = annotatePreviewTargets(html, getPreviewTargetSeeds(html)); const withoutBase = annotatedHtml.replace(/]*>/gi, ""); const withRootedAttributes = withoutBase .replace(/\s(src|href|poster|action)\s*=\s*(["'])([^"']*)\2/gi, (match, attribute: string, quote: string, value: string) => { - return ` ${attribute}=${quote}${rewritePreviewUrl(value, projectId, siteRoot)}${quote}`; + return ` ${attribute}=${quote}${rewritePreviewUrl(value, siteRoot, resolvedPath)}${quote}`; }) .replace(/\s(srcset)\s*=\s*(["'])([^"']*)\2/gi, (match, attribute: string, quote: string, value: string) => { - return ` ${attribute}=${quote}${rewriteSrcset(value, projectId, siteRoot)}${quote}`; + return ` ${attribute}=${quote}${rewriteSrcset(value, siteRoot, resolvedPath)}${quote}`; }); - const baseTag = ``; + const baseTag = ``; const stabilityStyle = buildPreviewStabilityStyle(); const bridgeScript = buildPreviewBridgeScript(); const focusScript = buildPreviewFocusScript(options.focusSelector, options.focusIndex, options.focusOrder); @@ -3643,16 +3654,16 @@ function rewriteHtml( return `${baseTag}${stabilityStyle}${withRootedAttributes}${bridgeScript}${focusScript}`; } -function rewriteCss(css: string, projectId: string, resolvedPath: string, sourceConfig: Record) { +function rewriteCss(css: string, resolvedPath: string, sourceConfig: Record) { const siteRoot = getSiteRoot(sourceConfig, resolvedPath); return css .replace(/url\(\s*(["']?)([^"')]+)\1\s*\)/gi, (_match, quote: string, value: string) => { - const rewritten = rewritePreviewUrl(value, projectId, siteRoot); + const rewritten = rewritePreviewUrl(value, siteRoot, resolvedPath); return `url(${quote}${rewritten}${quote})`; }) .replace(/@import\s+(["'])([^"']+)\1/gi, (_match, quote: string, value: string) => { - return `@import ${quote}${rewritePreviewUrl(value, projectId, siteRoot)}${quote}`; + return `@import ${quote}${rewritePreviewUrl(value, siteRoot, resolvedPath)}${quote}`; }); } @@ -3744,14 +3755,14 @@ previewRouter.get(/^\/projects\/([^/]+)\/site\/?(.*)$/i, async (request, respons sendAsset( response, asset, - rewriteHtml(textFromAsset(asset), projectId, asset.path, source.config, { focusIndex, focusOrder, focusSelector }), + rewriteHtml(textFromAsset(asset), asset.path, source.config, { focusIndex, focusOrder, focusSelector }), "text/html; charset=utf-8" ); return; } if (isCss(asset.path, contentType)) { - sendAsset(response, asset, rewriteCss(textFromAsset(asset), projectId, asset.path, source.config), "text/css; charset=utf-8"); + sendAsset(response, asset, rewriteCss(textFromAsset(asset), asset.path, source.config), "text/css; charset=utf-8"); return; } diff --git a/seo_mode/seo_mode/server/src/projects/routes.ts b/seo_mode/seo_mode/server/src/projects/routes.ts index b29774f..1369b0c 100644 --- a/seo_mode/seo_mode/server/src/projects/routes.ts +++ b/seo_mode/seo_mode/server/src/projects/routes.ts @@ -2,6 +2,7 @@ import { Router } from "express"; import type { Request, Response } from "express"; import crypto from "node:crypto"; import { z } from "zod"; +import { pool } from "../db/client.js"; import { getLatestProjectScan, ProjectScanError, @@ -43,15 +44,68 @@ import { } from "../analysis/semanticAnalysis.js"; import { getLatestSeoContextReview, + getSeoProjectContextContract, + getSeoProjectContextExport, + reviewSeoContextReview, runSeoContextReviewDraft, saveSeoContextReviewManual, + SeoContextReviewError, type SeoContextReviewOutput } from "../contextReview/seoContextReview.js"; +import { getSeoBusinessSynthesisContract } from "../business/seoBusinessSynthesis.js"; +import { getSeoOpportunityDiscoveryContract } from "../opportunity/seoOpportunityDiscovery.js"; +import { getSeoOpportunityCriticContract } from "../opportunity/seoOpportunityCritic.js"; +import { + approveSeoContextOpportunityMap, + getSeoContextOpportunityMap, + saveSeoContextOpportunitySelection, + SeoContextOpportunityApprovalError +} from "../contextReview/contextOpportunityMap.js"; +import { + getLatestSeoDemandResearchBrief, + listSeoSemanticFacetLedger, + getSeoSearchScope, + saveSeoDemandResearchBrief, + saveSeoSearchScope +} from "../contextReview/demandResearchBrief.js"; +import { + getLatestSeoOwnerStrategyInput, + getSeoPositioningThesis, + saveSeoOwnerStrategyInput +} from "../contextReview/positioningThesis.js"; +import { + deleteContextDevSnapshot, + getContextDevSnapshot, + listContextDevSnapshots, + renameContextDevSnapshot, + saveContextDevSnapshot +} from "../contextReview/contextSnapshots.js"; import { getMarketEnrichmentContract, getWordstatProbePlanPreview, - runMarketEnrichment + projectMarketEnrichmentForClient, + runMarketEnrichment, + runPostWordstatBatch2 } from "../market/marketEnrichment.js"; +import { + getTopicDecompositionModelTask, + getQueryDiscoveryContract, + QUERY_OBSERVATION_SOURCES, + saveManualQueryCandidate, + saveManualQueryCandidateDecision, + saveQueryObservation, + saveQueryProbeDecision, + saveTopicDecompositionFromModelProvider +} from "../queryDiscovery/queryDiscovery.js"; +import { buildDemandCoreReadModel } from "../queryDiscovery/demandCoreReadModel.js"; +import { collectObservedLanguageFromYandexSuggest } from "../queryDiscovery/observedLanguageCollector.js"; +import { saveQueryClusterDecision } from "../queryDiscovery/preWordstatRepository.js"; +import { saveExpansionSurfaceDecision } from "../queryDiscovery/marketExpansionRepository.js"; +import { + savePostWordstatCandidateDecision, + savePostWordstatClusterDecisionWithVariants +} from "../queryDiscovery/postWordstatRepository.js"; +import { collectExpansionObservedLanguage } from "../queryDiscovery/expansionObservedLanguageCollector.js"; import { getSeoModelContextContract } from "../modelContext/seoModelContext.js"; import { getSeoStrategyContract } from "../strategy/seoStrategy.js"; import { @@ -108,6 +162,7 @@ import { } from "../keywords/keywordContextSnapshots.js"; import { addAnchorReviewManualAnchor, + editAnchorReviewAnchor, getAnchorReviewContract, saveAnchorReviewDecisions } from "../keywords/anchorReview.js"; @@ -118,6 +173,7 @@ import { updateProjectOntologyConfirmations } from "../ontology/projectOntologyRepository.js"; import { approveKeywordMapDecisions, getKeywordMapContract } from "../keywords/keywordMap.js"; +import { saveSeoStrategyClusterDecision } from "../strategy/strategyClusterDecisions.js"; import { applyMaterializationAction, getMaterializationContract, @@ -161,6 +217,69 @@ const updatePageSelectionSchema = z.object({ selected: z.boolean() }); +const queryObservationSchema = z.object({ + topicId: z.string().trim().min(1).max(240), + probeId: z.string().trim().min(1).max(240).nullable().optional(), + patternId: z.string().trim().min(1).max(120).nullable().optional(), + query: z.string().trim().min(2).max(240), + source: z.enum(QUERY_OBSERVATION_SOURCES), + sourceSeed: z.string().trim().max(240).nullable().optional(), + geo: z.string().trim().max(80).nullable().optional(), + language: z.string().trim().max(32).nullable().optional(), + rawArtifactKey: z.string().trim().max(500).nullable().optional() +}); + +const observedLanguageCollectionSchema = z.object({ + provider: z.literal("yandex_suggest").default("yandex_suggest"), + maxProbes: z.number().int().min(1).max(12).optional() +}); + +const queryProbeDecisionSchema = z.object({ + topicId: z.string().trim().min(1).max(240), + decisionStatus: z.enum(["pending", "kept", "hidden", "deleted", "wordstat"]), + reason: z.string().trim().max(600).optional() +}); + +const queryClusterDecisionSchema = z.object({ + decisionStatus: z.enum(["pending", "approved", "informational", "rerouted", "hidden", "raw_only"]), + reason: z.string().trim().max(800).optional(), + targetFacetId: z.string().trim().min(1).max(240).nullable().optional() +}); + +const expansionSurfaceDecisionSchema = z.object({ + decisionStatus: z.enum(["suggested", "selected_for_exploration", "rejected"]), + reason: z.string().trim().max(800).optional() +}); + +const expansionCollectionSchema = z.object({ + depth: z.enum(["smoke", "balanced", "deep"]).optional(), + maxProbes: z.number().int().min(1).max(240).optional() +}); + +const postWordstatCandidateDecisionSchema = z.object({ + decisionStatus: z.enum(["pending", "approved_for_wordstat", "content_only", "research_only", "rejected"]), + reason: z.string().trim().max(1000).optional() +}); +const postWordstatClusterDecisionSchema = postWordstatCandidateDecisionSchema.extend({ + applyToVariants: z.boolean().optional().default(true) +}); +const postWordstatBulkDecisionSchema = z.object({ + decisions: z.array(z.object({ + candidateId: z.string().trim().min(1).max(240), + decisionStatus: z.enum(["pending", "approved_for_wordstat", "content_only", "research_only", "rejected"]), + reason: z.string().trim().max(1000).optional() + })).min(1).max(240) +}); + +const manualQueryCandidateSchema = z.object({ + topicId: z.string().trim().min(1).max(240), + query: z.string().trim().min(2).max(240) +}); + +const topicDecompositionManualSchema = z.object({ + output: z.record(z.string(), z.unknown()) +}); + const updatePageWorkspaceSchema = z.object({ workingText: z.string().max(1_000_000, "Рабочий текст слишком большой для текущего MVP.") }); @@ -254,6 +373,15 @@ const keywordMapApproveSchema = z.object({ .optional(), suppressedItemIds: z.array(z.string().min(1)).max(300).optional() }); +const strategyClusterDecisionSchema = z.object({ + canonicalPhrase: z.string().trim().min(1).max(300), + keywordMapRevision: z.string().trim().min(1).max(160), + positioningRevision: z.string().trim().min(1).max(160), + reason: z.string().trim().min(1).max(2_000), + source: z.enum(["codex", "human"]).default("human"), + targetKind: z.enum(["existing_page", "new_page", "support_section", "content_backlog", "adjacent_research"]), + targetPath: z.string().trim().max(500).nullable().optional() +}); const keywordContextSnapshotSchema = z.object({ label: z.string().trim().max(180).optional(), note: z.string().trim().max(1000).optional(), @@ -291,6 +419,36 @@ const keywordContextSnapshotCurationSchema = z.object({ const contextReviewManualSchema = z.object({ output: z.record(z.string(), z.unknown()) }); +const contextReviewDecisionSchema = z.object({ + decision: z.enum(["approved", "needs_changes", "rejected"]), + notes: z.array(z.string().trim().min(1).max(600)).max(12).default([]) +}); +const contextOpportunitySelectionSchema = z.object({ + selectedIds: z.array(z.string().trim().min(1).max(260)).max(80) +}); +const searchScopeSelectionSchema = z.object({ + selectedVectorIds: z.array(z.string().trim().min(1).max(260)).min(1).max(32) +}); +const ownerStrategySchema = z.object({ + strategicStatement: z.string().trim().max(5000).default(""), + referenceAnalogues: z.array(z.object({ + label: z.string().trim().max(160).default(""), + transferableTraits: z.array(z.string().trim().min(1).max(400)).max(20).default([]), + caveat: z.string().trim().max(1000).default("") + })).max(10).default([]), + priorityThemes: z.array(z.string().trim().min(1).max(400)).max(30).default([]), + categoryExclusions: z.array(z.string().trim().min(1).max(240)).max(30).default([]), + expansionPolicy: z.enum(["core_only", "near_core", "balanced", "frontier"]).default("balanced"), + notes: z.string().trim().max(3000).nullable().optional().default(null) +}); +const contextDevSnapshotSchema = z.object({ + label: z.string().trim().max(180).optional(), + note: z.string().trim().max(1000).optional(), + substage: z.enum(["mechanical", "semantic", "opportunities"]).optional() +}); +const contextDevSnapshotRenameSchema = z.object({ + label: z.string().trim().min(1, "Нужно указать название snapshot.").max(180) +}); const seoNormalizationManualSchema = z.object({ output: z.record(z.string(), z.unknown()) }); @@ -315,6 +473,11 @@ const anchorReviewManualAnchorSchema = z.object({ sendToSerp: z.boolean().optional(), sendToWordstat: z.boolean().optional() }); +const anchorReviewEditAnchorSchema = z.object({ + anchorId: z.string().trim().min(1).max(260), + phrase: z.string().trim().min(1).max(180), + reason: z.string().trim().max(1000).optional() +}); const serpInterpretationManualSchema = z.object({ output: z.record(z.string(), z.unknown()) }); @@ -678,6 +841,320 @@ projectsRouter.get("/:projectId/context-review/latest", async (request, response } }); +projectsRouter.get("/:projectId/context-review/export", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const format = semanticExportFormatSchema.parse(request.query.format); + const [businessSynthesis, opportunityDiscovery, opportunityCritic, opportunityMap] = await Promise.all([ + getSeoBusinessSynthesisContract(projectId), + getSeoOpportunityDiscoveryContract(projectId), + getSeoOpportunityCriticContract(projectId), + getSeoContextOpportunityMap(projectId) + ]); + const exportedContext = await getSeoProjectContextExport(projectId, format, { + businessSynthesis, + opportunityCritic, + opportunityDiscovery, + opportunityMap + }); + + if (!exportedContext) { + sendError(response, 404, "Сначала нужен semantic analysis проекта."); + return; + } + + response + .status(200) + .setHeader("Content-Type", exportedContext.contentType) + .setHeader("Content-Disposition", `attachment; filename="${exportedContext.filename}"`) + .send(exportedContext.body); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный формат экспорта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось выгрузить Project Context проекта."); + } +}); + +projectsRouter.get("/:projectId/context-review/gate", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + projectContext: await getSeoProjectContextContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось собрать Project Context Contract."); + } +}); + +projectsRouter.get("/:projectId/business-synthesis/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ businessSynthesis: await getSeoBusinessSynthesisContract(projectId) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить карту возможностей проекта."); + } +}); + +projectsRouter.get("/:projectId/opportunity-discovery/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ opportunityDiscovery: await getSeoOpportunityDiscoveryContract(projectId) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось загрузить Business Opportunity Discovery."); + } +}); + +projectsRouter.get("/:projectId/opportunity-critic/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ opportunityCritic: await getSeoOpportunityCriticContract(projectId) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось загрузить независимый Opportunity Critic."); + } +}); + +projectsRouter.get("/:projectId/context-opportunities", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ opportunityMap: await getSeoContextOpportunityMap(projectId) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось собрать карту возможностей."); + } +}); + +projectsRouter.patch("/:projectId/context-opportunities", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = contextOpportunitySelectionSchema.parse(request.body ?? {}); + response.json({ opportunityMap: await saveSeoContextOpportunitySelection(projectId, input.selectedIds) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь выбранные направления."); + return; + } + + if (error instanceof SeoContextOpportunityApprovalError) { + sendError(response, 409, error.message); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить карту возможностей."); + } +}); + +projectsRouter.post("/:projectId/context-opportunities/approve", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = contextOpportunitySelectionSchema.parse(request.body ?? {}); + const client = await pool.connect(); + let result: Awaited>; + let demandResearchBrief: Awaited>; + + try { + await client.query("begin"); + result = await approveSeoContextOpportunityMap(projectId, input.selectedIds, client); + demandResearchBrief = await saveSeoDemandResearchBrief(projectId, result.opportunityMap, { + contextReview: result.reviewedContextReview, + executor: client, + opportunityDecisionRunId: result.opportunityDecisionRunId + }); + await client.query("commit"); + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } + + const projectContext = await getSeoProjectContextContract(projectId); + response.json({ opportunityMap: result.opportunityMap, projectContext, demandResearchBrief }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь выбранные направления."); + return; + } + + if (error instanceof SeoContextOpportunityApprovalError) { + sendError(response, 409, error.message); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось утвердить контекст."); + } +}); + +projectsRouter.get("/:projectId/demand-research-brief/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ demandResearchBrief: await getLatestSeoDemandResearchBrief(projectId) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось загрузить Demand Research Brief."); + } +}); + +projectsRouter.get("/:projectId/positioning-thesis", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ + ownerStrategy: await getLatestSeoOwnerStrategyInput(projectId), + positioningThesis: await getSeoPositioningThesis(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось загрузить Positioning Thesis."); + } +}); + +projectsRouter.put("/:projectId/owner-strategy", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = ownerStrategySchema.parse(request.body ?? {}); + const ownerStrategy = await saveSeoOwnerStrategyInput(projectId, input); + + response.json({ + ownerStrategy, + positioningThesis: await getSeoPositioningThesis(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь стратегическое намерение проекта."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить owner strategy."); + } +}); + +projectsRouter.get("/:projectId/context-dev-snapshots", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ snapshots: await listContextDevSnapshots(projectId) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить snapshots контекста."); + } +}); + +projectsRouter.get("/:projectId/context-dev-snapshots/:snapshotId", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const snapshotId = z.string().uuid("Некорректный id snapshot.").parse(request.params.snapshotId); + response.json(await getContextDevSnapshot(projectId, snapshotId)); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id snapshot."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось загрузить snapshot контекста."); + } +}); + +projectsRouter.post("/:projectId/context-dev-snapshots", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = contextDevSnapshotSchema.parse(request.body ?? {}); + response.status(201).json({ snapshot: await saveContextDevSnapshot(projectId, input) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь snapshot payload."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить snapshot контекста."); + } +}); + +projectsRouter.patch("/:projectId/context-dev-snapshots/:snapshotId", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const snapshotId = z.string().uuid("Некорректный id snapshot.").parse(request.params.snapshotId); + const input = contextDevSnapshotRenameSchema.parse(request.body ?? {}); + response.json({ snapshot: await renameContextDevSnapshot(projectId, snapshotId, input.label) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь snapshot payload."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось переименовать snapshot контекста."); + } +}); + +projectsRouter.delete("/:projectId/context-dev-snapshots/:snapshotId", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const snapshotId = z.string().uuid("Некорректный id snapshot.").parse(request.params.snapshotId); + await deleteContextDevSnapshot(projectId, snapshotId); + response.status(204).send(); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id snapshot."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось удалить snapshot контекста."); + } +}); + projectsRouter.post("/:projectId/context-review", async (request, response) => { try { const projectId = projectIdSchema.parse(request.params.projectId); @@ -721,6 +1198,32 @@ projectsRouter.post("/:projectId/context-review/manual", async (request, respons } }); +projectsRouter.post("/:projectId/context-review/:runId/decision", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const runId = projectIdSchema.parse(request.params.runId); + const input = contextReviewDecisionSchema.parse(request.body ?? {}); + + response.status(201).json({ + contextReview: await reviewSeoContextReview(projectId, runId, input.decision, input.notes), + projectContext: await getSeoProjectContextContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь решение по Context Review."); + return; + } + + if (error instanceof SeoContextReviewError) { + sendError(response, 409, error.message); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось сохранить решение по Context Review."); + } +}); + projectsRouter.get("/:projectId/ontology/latest", async (request, response) => { try { const projectId = projectIdSchema.parse(request.params.projectId); @@ -808,7 +1311,7 @@ projectsRouter.get("/:projectId/market-enrichment/latest", async (request, respo const projectId = projectIdSchema.parse(request.params.projectId); response.json({ - marketEnrichment: await getMarketEnrichmentContract(projectId) + marketEnrichment: projectMarketEnrichmentForClient(await getMarketEnrichmentContract(projectId)) }); } catch (error) { if (error instanceof z.ZodError) { @@ -821,6 +1324,532 @@ projectsRouter.get("/:projectId/market-enrichment/latest", async (request, respo } }); +projectsRouter.get("/:projectId/demand-core/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ demandCore: buildDemandCoreReadModel(await getQueryDiscoveryContract(projectId)) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось собрать canonical demand core."); + } +}); + +projectsRouter.get("/:projectId/post-wordstat/evidence", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const offset = z.coerce.number().int().min(0).max(10_000).optional().default(0).parse(request.query.offset); + const limit = z.coerce.number().int().min(1).max(100).optional().default(25).parse(request.query.limit); + const market = await getMarketEnrichmentContract(projectId); + response.json({ + evidence: { + limit, + offset, + rows: market.wordstat.topResults.slice(offset, offset + limit), + schemaVersion: "post-wordstat-evidence-page.v1", + total: market.wordstat.topResults.length + } + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры страницы Wordstat evidence."); + return; + } + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось загрузить Wordstat evidence."); + } +}); + +projectsRouter.get("/:projectId/query-discovery/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ queryDiscovery: await getQueryDiscoveryContract(projectId) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить pre-Wordstat query discovery."); + } +}); + +projectsRouter.get("/:projectId/search-scope", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ searchScope: await getSeoSearchScope(projectId) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить Search Scope."); + } +}); + +projectsRouter.get("/:projectId/semantic-facets", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + response.json({ semanticFacets: await listSeoSemanticFacetLedger(projectId) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось загрузить Semantic Portfolio."); + } +}); + +projectsRouter.put("/:projectId/search-scope", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = searchScopeSelectionSchema.parse(request.body ?? {}); + const searchScope = await saveSeoSearchScope(projectId, input.selectedVectorIds); + + response.json({ + searchScope, + queryDiscovery: await getQueryDiscoveryContract(projectId, { persistSnapshots: true }) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь векторы Search Scope."); + return; + } + + console.error(error); + sendError(response, 400, error instanceof Error ? error.message : "Не удалось сохранить Search Scope."); + } +}); + +projectsRouter.post("/:projectId/query-discovery/topic-decomposition/manual", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = topicDecompositionManualSchema.parse(request.body ?? {}); + const task = await getTopicDecompositionModelTask(projectId); + + if (!task) { + sendError(response, 409, "Сначала нужен утверждённый Context Dev для topic decomposition."); + return; + } + + response.status(201).json({ + topicDecomposition: await saveTopicDecompositionFromModelProvider( + projectId, + input.output, + `codex_manual:${crypto.randomUUID()}`, + task, + "manual" + ), + queryDiscovery: await getQueryDiscoveryContract(projectId, { persistSnapshots: true }) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь manual topic decomposition payload."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual topic decomposition."); + } +}); + +projectsRouter.post("/:projectId/query-discovery/observations", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = queryObservationSchema.parse(request.body ?? {}); + const discovery = await getQueryDiscoveryContract(projectId); + + if (discovery.state !== "ready" || !discovery.semanticRunId) { + sendError(response, 409, "Сначала нужен готовый topic decomposition; pattern probes не строятся из model-generated keys."); + return; + } + + const topic = discovery.topicDecomposition.topics.find((item) => item.topicId === input.topicId); + const probe = input.probeId ? discovery.probes.find((item) => item.id === input.probeId) : null; + + if (!topic || (input.probeId && (!probe || probe.topicId !== input.topicId))) { + sendError(response, 400, "Observation должен ссылаться на текущий topic и его probe."); + return; + } + + await saveQueryObservation(projectId, discovery.semanticRunId, input); + response.status(201).json({ queryDiscovery: await getQueryDiscoveryContract(projectId, { persistSnapshots: true }) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь observed query payload."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить observed query."); + } +}); + +projectsRouter.post("/:projectId/query-discovery/observed-language/collect", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = observedLanguageCollectionSchema.parse(request.body ?? {}); + + if (input.provider !== "yandex_suggest") { + sendError(response, 400, "Этот observed-language provider пока не поддерживается."); + return; + } + + const collection = await collectObservedLanguageFromYandexSuggest({ + maxProbes: input.maxProbes, + projectId + }); + response.status(201).json({ + collection, + queryDiscovery: await getQueryDiscoveryContract(projectId, { persistSnapshots: true }) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь observed-language request."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось собрать observed language."); + } +}); + +projectsRouter.put("/:projectId/query-discovery/clusters/:clusterId/decision", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const clusterId = z.string().trim().min(1).max(240).parse(request.params.clusterId); + const input = queryClusterDecisionSchema.parse(request.body ?? {}); + const discovery = await getQueryDiscoveryContract(projectId); + const cluster = discovery.observedLanguageReview.clusters.find((item) => item.id === clusterId); + + if (discovery.state !== "ready" || !discovery.semanticRunId || !cluster) { + sendError(response, 409, "Canonical cluster не найден в текущем semantic run."); + return; + } + if (input.decisionStatus === "approved" && cluster.recommendedAction !== "human_wordstat_review" && cluster.recommendedAction !== "auto_wordstat_ready") { + sendError(response, 400, "В Wordstat review можно утвердить только clean observed cluster."); + return; + } + if (input.decisionStatus === "rerouted" && input.targetFacetId && !discovery.searchScope?.availableVectors.some((vector) => vector.id === input.targetFacetId)) { + sendError(response, 400, "Target facet отсутствует в текущем Semantic Portfolio."); + return; + } + + await saveQueryClusterDecision({ + clusterId, + decisionStatus: input.decisionStatus, + projectId, + reason: input.reason, + semanticRunId: discovery.semanticRunId, + targetFacetId: input.targetFacetId + }); + response.json({ queryDiscovery: await getQueryDiscoveryContract(projectId, { persistSnapshots: true }) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь решение по observed cluster."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить решение по observed cluster."); + } +}); + +projectsRouter.put("/:projectId/query-discovery/expansion-surfaces/:surfaceId/decision", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const surfaceId = z.string().trim().min(1).max(240).parse(request.params.surfaceId); + const input = expansionSurfaceDecisionSchema.parse(request.body ?? {}); + const discovery = await getQueryDiscoveryContract(projectId); + const surface = discovery.marketExpansion.surfaces.find((item) => item.id === surfaceId); + if (discovery.state !== "ready" || !discovery.semanticRunId || !surface) { + sendError(response, 409, "Expansion surface не найден в текущем semantic run."); + return; + } + if (input.decisionStatus === "selected_for_exploration" && surface.relationshipToCore === "direct_core") { + sendError(response, 400, "Direct core уже покрывается Core Validation и не должен дублироваться в expansion exploration."); + return; + } + await saveExpansionSurfaceDecision({ + decisionStatus: input.decisionStatus, + projectId, + reason: input.reason, + semanticRunId: discovery.semanticRunId, + surfaceId + }); + response.json({ queryDiscovery: await getQueryDiscoveryContract(projectId, { persistSnapshots: true }) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь решение по expansion surface."); + return; + } + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить expansion decision."); + } +}); + +projectsRouter.post("/:projectId/query-discovery/market-expansion/collect", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = expansionCollectionSchema.parse(request.body ?? {}); + const collection = await collectExpansionObservedLanguage({ depth: input.depth, maxProbes: input.maxProbes, projectId }); + response.status(201).json({ collection, queryDiscovery: await getQueryDiscoveryContract(projectId, { persistSnapshots: true }) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь Market Expansion collection request."); + return; + } + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось собрать Market Expansion observed language."); + } +}); + +projectsRouter.put("/:projectId/post-wordstat/candidates/:candidateId/decision", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const candidateId = z.string().trim().min(1).max(240).parse(request.params.candidateId); + const input = postWordstatCandidateDecisionSchema.parse(request.body ?? {}); + const market = await getMarketEnrichmentContract(projectId); + const candidate = market.postWordstatProductFit.candidates.find((item) => item.id === candidateId); + if (!market.semanticRunId || !candidate) { + sendError(response, 409, "Post-Wordstat candidate не найден в текущем semantic run."); + return; + } + if (input.decisionStatus === "approved_for_wordstat" && candidate.recommendedDisposition !== "batch2_review" && candidate.sourceKind !== "validated_seed") { + sendError(response, 400, "Во второй Wordstat batch можно утвердить только commercial candidate, прошедший ProductFitGate."); + return; + } + await savePostWordstatCandidateDecision({ + candidateId, + decisionStatus: input.decisionStatus, + normalizedPhrase: candidate.normalizedPhrase, + projectId, + reason: input.reason, + semanticRunId: market.semanticRunId + }); + response.json({ marketEnrichment: projectMarketEnrichmentForClient(await getMarketEnrichmentContract(projectId)) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь решение ProductFitGate."); + return; + } + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить post-Wordstat decision."); + } +}); + +projectsRouter.put("/:projectId/post-wordstat/clusters/:clusterId/decision", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const clusterId = z.string().trim().min(1).max(240).parse(request.params.clusterId); + const input = postWordstatClusterDecisionSchema.parse(request.body ?? {}); + const market = await getMarketEnrichmentContract(projectId); + const cluster = market.postWordstatProductFit.productFitClusters.find((item) => item.id === clusterId); + if (!market.semanticRunId || !cluster) { + sendError(response, 409, "Post-Wordstat cluster не найден в текущем semantic run."); + return; + } + if (input.decisionStatus === "approved_for_wordstat" && cluster.disposition !== "batch2_review") { + sendError(response, 400, "Во второй Wordstat batch можно утвердить только commercial cluster, прошедший ProductFitGate."); + return; + } + const candidateById = new Map(market.postWordstatProductFit.candidates.map((candidate) => [candidate.id, candidate])); + const variants = cluster.variantIds + .map((candidateId) => candidateById.get(candidateId)) + .filter((candidate): candidate is NonNullable => Boolean(candidate)) + .map((candidate) => ({ candidateId: candidate.id, normalizedPhrase: candidate.normalizedPhrase })); + await savePostWordstatClusterDecisionWithVariants({ + applyToVariants: input.applyToVariants, + canonicalPhrase: cluster.canonicalPhrase, + clusterId, + decisionStatus: input.decisionStatus, + projectId, + reason: input.reason, + semanticRunId: market.semanticRunId, + variants + }); + response.json({ marketEnrichment: projectMarketEnrichmentForClient(await getMarketEnrichmentContract(projectId)) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь решение по ProductFit cluster."); + return; + } + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить решение по ProductFit cluster."); + } +}); + +projectsRouter.post("/:projectId/post-wordstat/decisions/bulk", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = postWordstatBulkDecisionSchema.parse(request.body ?? {}); + const market = await getMarketEnrichmentContract(projectId); + if (!market.semanticRunId) { + sendError(response, 409, "Нет текущего semantic run для ProductFit decisions."); + return; + } + const candidateById = new Map(market.postWordstatProductFit.candidates.map((item) => [item.id, item])); + for (const decision of input.decisions) { + const candidate = candidateById.get(decision.candidateId); + if (!candidate) { + sendError(response, 409, `Post-Wordstat candidate ${decision.candidateId} не найден.`); + return; + } + if (decision.decisionStatus === "approved_for_wordstat" && candidate.recommendedDisposition !== "batch2_review" && candidate.sourceKind !== "validated_seed") { + sendError(response, 400, `Candidate ${candidate.phrase} не прошёл commercial ProductFitGate.`); + return; + } + } + await Promise.all(input.decisions.map((decision) => { + const candidate = candidateById.get(decision.candidateId)!; + return savePostWordstatCandidateDecision({ + candidateId: candidate.id, + decisionStatus: decision.decisionStatus, + normalizedPhrase: candidate.normalizedPhrase, + projectId, + reason: decision.reason, + semanticRunId: market.semanticRunId! + }); + })); + response.json({ marketEnrichment: projectMarketEnrichmentForClient(await getMarketEnrichmentContract(projectId)) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь bulk ProductFit decisions."); + return; + } + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить ProductFit decisions."); + } +}); + +projectsRouter.post("/:projectId/post-wordstat/batch2/collect", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const before = await getMarketEnrichmentContract(projectId); + if (before.wordstatBatch2.state !== "ready" || before.wordstatBatch2.queue.length === 0) { + sendError(response, 409, "Batch #2 не готов: сначала нужны ProductFitGate и human approval."); + return; + } + response.status(202).json({ marketEnrichment: projectMarketEnrichmentForClient(await runPostWordstatBatch2(projectId)) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось выполнить Wordstat batch #2."); + } +}); + +projectsRouter.post("/:projectId/query-discovery/candidates/manual", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = manualQueryCandidateSchema.parse(request.body ?? {}); + const discovery = await getQueryDiscoveryContract(projectId); + + if (discovery.state !== "ready" || !discovery.semanticRunId) { + sendError(response, 409, "Сначала нужен готовый topic decomposition."); + return; + } + if (!discovery.topicDecomposition.topics.some((topic) => topic.topicId === input.topicId)) { + sendError(response, 400, "Ручная формулировка должна относиться к текущему Search Scope."); + return; + } + + await saveManualQueryCandidate({ + projectId, + semanticRunId: discovery.semanticRunId, + topicId: input.topicId, + query: input.query + }); + response.status(201).json({ queryDiscovery: await getQueryDiscoveryContract(projectId, { persistSnapshots: true }) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь ручную поисковую формулировку."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось добавить формулировку."); + } +}); + +projectsRouter.put("/:projectId/query-discovery/probes/:probeId/decision", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const probeId = z.string().trim().min(1).max(240).parse(request.params.probeId); + const input = queryProbeDecisionSchema.parse(request.body ?? {}); + const discovery = await getQueryDiscoveryContract(projectId); + const probe = discovery.probes.find((item) => item.id === probeId && item.topicId === input.topicId); + + if (discovery.state !== "ready" || !discovery.semanticRunId || !probe) { + sendError(response, 409, "Probe не найден в текущем topic decomposition."); + return; + } + if (input.decisionStatus === "wordstat" && probe.patternId !== "base_entity") { + sendError(response, 400, "В Wordstat можно поставить только базовую сущность или ручную формулировку. Pattern draft сначала нужно подтвердить рынком."); + return; + } + + await saveQueryProbeDecision({ + projectId, + semanticRunId: discovery.semanticRunId, + topicId: input.topicId, + probeId, + decisionStatus: input.decisionStatus, + reason: input.reason + }); + response.json({ queryDiscovery: await getQueryDiscoveryContract(projectId, { persistSnapshots: true }) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь решение по probe."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить решение по probe."); + } +}); + +projectsRouter.put("/:projectId/query-discovery/candidates/manual/:candidateId/decision", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const candidateId = z.string().uuid("Некорректный id ручной формулировки.").parse(request.params.candidateId); + const input = queryProbeDecisionSchema.parse(request.body ?? {}); + const discovery = await getQueryDiscoveryContract(projectId); + + if (discovery.state !== "ready" || !discovery.semanticRunId || !discovery.topicDecomposition.topics.some((topic) => topic.topicId === input.topicId)) { + sendError(response, 409, "Ручная формулировка не относится к текущему Search Scope."); + return; + } + + await saveManualQueryCandidateDecision({ + projectId, + semanticRunId: discovery.semanticRunId, + manualCandidateId: candidateId, + decisionStatus: input.decisionStatus, + reason: input.reason + }); + response.json({ queryDiscovery: await getQueryDiscoveryContract(projectId, { persistSnapshots: true }) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь решение по ручной формулировке."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить решение по ручной формулировке."); + } +}); + projectsRouter.get("/:projectId/market-enrichment/probe-preview", async (request, response) => { try { const projectId = projectIdSchema.parse(request.params.projectId); @@ -932,6 +1961,25 @@ projectsRouter.post("/:projectId/anchor-review/manual-anchor", async (request, r } }); +projectsRouter.post("/:projectId/anchor-review/edit-anchor", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = anchorReviewEditAnchorSchema.parse(request.body ?? {}); + + response.status(201).json({ + anchorReview: await editAnchorReviewAnchor(projectId, input) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь редактирование anchor review."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось изменить якорь."); + } +}); + projectsRouter.get("/:projectId/keyword-cleaning/latest", async (request, response) => { try { const projectId = projectIdSchema.parse(request.params.projectId); @@ -1056,7 +2104,7 @@ projectsRouter.post("/:projectId/market-enrichment", async (request, response) = } response.status(202).json({ - marketEnrichment + marketEnrichment: projectMarketEnrichmentForClient(marketEnrichment) }); } catch (error) { if (error instanceof z.ZodError) { @@ -1217,6 +2265,56 @@ projectsRouter.get("/:projectId/strategy/latest", async (request, response) => { } }); +projectsRouter.put("/:projectId/strategy/clusters/:clusterId/decision", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const clusterId = z.string().trim().min(1).max(500).parse(request.params.clusterId); + const input = strategyClusterDecisionSchema.parse(request.body ?? {}); + const [keywordMap, strategy] = await Promise.all([ + getKeywordMapContract(projectId), + getSeoStrategyContract(projectId) + ]); + + if ( + input.keywordMapRevision !== keywordMap.revision || + input.positioningRevision !== keywordMap.positioningRevision + ) { + sendError(response, 409, "Карта ключей или positioning изменились. Обнови стратегию перед сохранением решения."); + return; + } + + const cluster = strategy.strategyClusters.clusters.find((item) => item.id === clusterId); + if (!cluster) { + sendError(response, 404, "Кластер не найден в текущей стратегии."); + return; + } + + if (cluster.canonicalPhrase.trim() !== input.canonicalPhrase) { + sendError(response, 409, "Каноническая фраза кластера изменилась. Обнови стратегию перед сохранением решения."); + return; + } + + const decision = await saveSeoStrategyClusterDecision(projectId, { + ...input, + clusterId, + targetPath: input.targetPath ?? null + }); + + response.status(201).json({ + decision, + strategy: await getSeoStrategyContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь решение по привязке кластера."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить привязку кластера."); + } +}); + projectsRouter.get("/:projectId/strategy-synthesis/latest", async (request, response) => { try { const projectId = projectIdSchema.parse(request.params.projectId); diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/applicabilityMatrixBuilder.ts b/seo_mode/seo_mode/server/src/queryDiscovery/applicabilityMatrixBuilder.ts new file mode 100644 index 0000000..3e62067 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/applicabilityMatrixBuilder.ts @@ -0,0 +1,58 @@ +import crypto from "node:crypto"; +import type { ExpansionSurface } from "./expansionSurfaceBuilder.js"; + +export type ApplicabilityMatrixCell = { + id: string; + surfaceId: string; + capability: string | null; + process: string | null; + role: string | null; + industry: string | null; + object: string | null; + system: string | null; + applicabilityScore: number; + evidenceScore: number; + risk: ExpansionSurface["expansionRisk"]; + status: "candidate" | "hidden_by_budget"; +}; + +function id(parts: string[]) { + return `applicability-cell:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +export function buildApplicabilityMatrix(input: { surfaces: ExpansionSurface[]; maxCells?: number; maxPerSurface?: number }) { + const maxCells = Math.min(Math.max(input.maxCells ?? 160, 1), 500); + const maxPerSurface = Math.min(Math.max(input.maxPerSurface ?? 4, 1), 12); + const cells: ApplicabilityMatrixCell[] = []; + for (const surface of input.surfaces) { + const axes = surface.slotBundle; + const primary = { + capability: axes.capability[0] ?? null, + industry: axes.industry[0] ?? null, + object: axes.object[0] ?? null, + process: axes.process[0] ?? null, + role: axes.role[0] ?? null, + system: axes.system[0] ?? null + }; + const variants = [ + primary, + ...axes.process.slice(1, 3).map((process) => ({ ...primary, process })), + ...axes.role.slice(1, 3).map((role) => ({ ...primary, role })), + ...axes.industry.slice(1, 3).map((industry) => ({ ...primary, industry })), + ...axes.object.slice(1, 3).map((object) => ({ ...primary, object })), + ...axes.system.slice(1, 3).map((system) => ({ ...primary, system })) + ].slice(0, maxPerSurface); + for (const variant of variants) { + cells.push({ + ...variant, + applicabilityScore: surface.applicabilityScore, + evidenceScore: surface.evidenceScore, + id: id([surface.id, ...Object.values(variant).map(String)]), + risk: surface.expansionRisk, + status: cells.length < maxCells ? "candidate" : "hidden_by_budget", + surfaceId: surface.id + }); + } + } + return cells.slice(0, maxCells); +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/candidateQualityGate.ts b/seo_mode/seo_mode/server/src/queryDiscovery/candidateQualityGate.ts new file mode 100644 index 0000000..9aa9a39 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/candidateQualityGate.ts @@ -0,0 +1,167 @@ +export type CandidateQualityGateInput = { + id: string; + phrase: string; + facetId: string; + patternId: string | null; + candidateStage: "query_surface_draft" | "observed_query" | "wordstat_candidate"; +}; + +export type CandidateQualityDecision = { + candidateId: string; + status: "accepted" | "hidden_by_quality_gate"; + diagnostics: string[]; +}; + +export type CandidateQualityDiagnostics = { + totalDrafts: number; + acceptedDrafts: number; + hiddenDrafts: number; + selectedFacetCount: number; + representedFacetCount: number; + maxSameLemmaRootShare: number; + maxSameFacetShare: number; + warnings: Array<{ + code: "LOW_FACET_DIVERSITY" | "LEXICAL_CONCENTRATION" | "AUDIENCE_VARIANT_CONCENTRATION"; + message: string; + severity: "warning" | "error"; + }>; +}; + +function tokenize(value: string) { + return value + .toLocaleLowerCase() + .split(/[^\p{L}\p{N}]+/u) + .map((token) => token.trim()) + .filter((token) => token.length >= 4); +} + +function lexicalRoots(value: string) { + return [...new Set(tokenize(value).map((token) => token.slice(0, 5)))]; +} + +function getMaxShare(counts: number[], total: number) { + if (total === 0 || counts.length === 0) return 0; + return Math.max(...counts) / total; +} + +/** + * Applies only structural diversity checks. It intentionally does not contain + * product, industry or language-specific vocabulary: wording belongs to data + * and observed-language evidence, not router branching. + */ +export function applyCandidateQualityGate(input: { + candidates: CandidateQualityGateInput[]; + selectedFacetIds: string[]; +}) : { decisions: Map; diagnostics: CandidateQualityDiagnostics } { + const modelDrafts = input.candidates.filter((candidate) => candidate.candidateStage === "query_surface_draft"); + const facetCounts = new Map(); + const rootCounts = new Map(); + const audienceVariantCounts = new Map(); + + for (const candidate of modelDrafts) { + facetCounts.set(candidate.facetId, (facetCounts.get(candidate.facetId) ?? 0) + 1); + for (const root of lexicalRoots(candidate.phrase)) { + rootCounts.set(root, (rootCounts.get(root) ?? 0) + 1); + } + if (candidate.patternId && candidate.patternId !== "base_entity") { + audienceVariantCounts.set(candidate.facetId, (audienceVariantCounts.get(candidate.facetId) ?? 0) + 1); + } + } + + const totalDrafts = modelDrafts.length; + const representedFacetCount = facetCounts.size; + const maxSameFacetShare = getMaxShare([...facetCounts.values()], totalDrafts); + const maxSameLemmaRootShare = getMaxShare([...rootCounts.values()], totalDrafts); + const warnings: CandidateQualityDiagnostics["warnings"] = []; + + if (input.selectedFacetIds.length >= 3 && representedFacetCount < Math.min(3, input.selectedFacetIds.length)) { + warnings.push({ + code: "LOW_FACET_DIVERSITY", + message: "Выбранные поисковые направления не получили достаточного покрытия в черновиках. Нужна пересборка topic decomposition, а не расширение одной lexical family.", + severity: "error" + }); + } + if (totalDrafts >= 4 && maxSameFacetShare > 0.4) { + warnings.push({ + code: "LOW_FACET_DIVERSITY", + message: "Слишком большая доля черновиков пришла из одного semantic facet. Избыточные варианты скрыты quality gate.", + severity: "warning" + }); + } + if (totalDrafts >= 4 && maxSameLemmaRootShare > 0.55) { + warnings.push({ + code: "LEXICAL_CONCENTRATION", + message: "Черновики чрезмерно повторяют один lexical root. Это сигнал слабого semantic portfolio, а не спроса.", + severity: "warning" + }); + } + if ([...audienceVariantCounts.values()].some((count) => count > 1)) { + warnings.push({ + code: "AUDIENCE_VARIANT_CONCENTRATION", + message: "Несколько вариантов отличаются только аудиторным суффиксом; quality gate оставляет не более одного такого draft на facet.", + severity: "warning" + }); + } + + const decisions = new Map(); + const acceptedFacetCounts = new Map(); + const acceptedAudienceVariants = new Map(); + const rootAcceptedCounts = new Map(); + const maxPerFacet = Math.max(2, Math.ceil(totalDrafts * 0.4)); + + // Give every selected facet its first base surface a chance before variants + // from an alphabetically earlier facet consume the diversity budget. + const candidatesInQualityOrder = [...input.candidates].sort((left, right) => { + const leftBase = left.patternId === "base_entity" ? 0 : 1; + const rightBase = right.patternId === "base_entity" ? 0 : 1; + if (leftBase !== rightBase) return leftBase - rightBase; + if (left.facetId !== right.facetId) return left.facetId.localeCompare(right.facetId); + return left.id.localeCompare(right.id); + }); + + for (const candidate of candidatesInQualityOrder) { + if (candidate.candidateStage !== "query_surface_draft") { + decisions.set(candidate.id, { candidateId: candidate.id, diagnostics: [], status: "accepted" }); + continue; + } + + const diagnostics: string[] = []; + const facetCount = acceptedFacetCounts.get(candidate.facetId) ?? 0; + if (facetCount >= maxPerFacet && input.selectedFacetIds.length >= 3) { + diagnostics.push("facet_share_limit"); + } + if (candidate.patternId && candidate.patternId !== "base_entity" && (acceptedAudienceVariants.get(candidate.facetId) ?? 0) >= 1) { + diagnostics.push("audience_variant_limit"); + } + const roots = lexicalRoots(candidate.phrase); + const repeatedRoot = roots.some((root) => (rootAcceptedCounts.get(root) ?? 0) >= Math.max(2, Math.ceil(totalDrafts * 0.35))); + if (repeatedRoot && input.selectedFacetIds.length < 3) { + diagnostics.push("lexical_root_limit"); + } + + const status = diagnostics.length > 0 ? "hidden_by_quality_gate" : "accepted" as const; + decisions.set(candidate.id, { candidateId: candidate.id, diagnostics, status }); + if (status === "accepted") { + acceptedFacetCounts.set(candidate.facetId, facetCount + 1); + if (candidate.patternId && candidate.patternId !== "base_entity") { + acceptedAudienceVariants.set(candidate.facetId, (acceptedAudienceVariants.get(candidate.facetId) ?? 0) + 1); + } + for (const root of roots) rootAcceptedCounts.set(root, (rootAcceptedCounts.get(root) ?? 0) + 1); + } + } + + const acceptedDrafts = modelDrafts.filter((candidate) => decisions.get(candidate.id)?.status === "accepted").length; + return { + decisions, + diagnostics: { + acceptedDrafts, + hiddenDrafts: totalDrafts - acceptedDrafts, + maxSameFacetShare: Number(maxSameFacetShare.toFixed(2)), + maxSameLemmaRootShare: Number(maxSameLemmaRootShare.toFixed(2)), + representedFacetCount, + selectedFacetCount: input.selectedFacetIds.length, + totalDrafts, + warnings + } + }; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/canonicalQueryCluster.ts b/seo_mode/seo_mode/server/src/queryDiscovery/canonicalQueryCluster.ts new file mode 100644 index 0000000..b626567 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/canonicalQueryCluster.ts @@ -0,0 +1,95 @@ +import crypto from "node:crypto"; +import type { ObservedIntent, WordstatReviewCandidate } from "./observedRelevanceGate.js"; + +export type CanonicalQueryCluster = { + id: string; + projectId: string; + canonicalQuery: string; + normalizedCanonical: string; + facetId: string; + topicId?: string; + intent: ObservedIntent; + variants: Array<{ + query: string; + normalizedQuery: string; + observationId: string; + source: string; + seedId?: string; + confidence: number; + }>; + contaminationFlags: string[]; + score: number; + recommendedAction: "human_wordstat_review" | "auto_wordstat_ready"; + stage: "observed_cluster" | "human_wordstat_review" | "wordstat_ready" | "wordstat_sent" | "wordstat_validated" | "rejected"; + evidenceRefs: string[]; +}; + +function stableId(parts: string[]) { + return `query-cluster:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +function tokenSet(value: string) { + return new Set(value.split(/\s+/).filter(Boolean)); +} + +function similarity(left: string, right: string) { + const a = tokenSet(left); + const b = tokenSet(right); + const intersection = [...a].filter((token) => b.has(token)).length; + const union = new Set([...a, ...b]).size; + return union === 0 ? 0 : intersection / union; +} + +function betterCanonical(left: WordstatReviewCandidate, right: WordstatReviewCandidate) { + const leftWords = left.normalizedQuery.split(/\s+/).length; + const rightWords = right.normalizedQuery.split(/\s+/).length; + if (Math.abs(left.score - right.score) >= 0.08) return left.score > right.score ? left : right; + return leftWords <= rightWords ? left : right; +} + +export function buildCanonicalQueryClusters(input: { + projectId: string; + candidates: WordstatReviewCandidate[]; +}): CanonicalQueryCluster[] { + const candidateGroups: WordstatReviewCandidate[][] = []; + + for (const candidate of input.candidates) { + const group = candidateGroups.find((current) => { + const head = current[0]; + return head.facetId === candidate.facetId && head.detectedIntent === candidate.detectedIntent && similarity(head.canonicalCandidateKey, candidate.canonicalCandidateKey) >= 0.72; + }); + if (group) group.push(candidate); + else candidateGroups.push([candidate]); + } + + return candidateGroups.map((group) => { + const canonical = group.reduce(betterCanonical); + const recommendedAction: CanonicalQueryCluster["recommendedAction"] = group.some((candidate) => candidate.recommendedAction === "auto_wordstat_ready") + ? "auto_wordstat_ready" + : "human_wordstat_review"; + const stage: CanonicalQueryCluster["stage"] = recommendedAction === "auto_wordstat_ready" ? "wordstat_ready" : "human_wordstat_review"; + const variants = group.flatMap((candidate) => candidate.observationIds.map((observationId, index) => ({ + confidence: candidate.score, + normalizedQuery: candidate.normalizedQuery, + observationId, + query: candidate.query, + seedId: candidate.seedIds[index] ?? candidate.seedIds[0], + source: candidate.sources[index] ?? candidate.sources[0] ?? "unknown" + }))); + return { + canonicalQuery: canonical.query, + contaminationFlags: [...new Set(group.flatMap((candidate) => candidate.riskFlags))], + evidenceRefs: [...new Set(group.flatMap((candidate) => candidate.evidenceRefs))], + facetId: canonical.facetId, + id: stableId([input.projectId, canonical.facetId, canonical.detectedIntent, canonical.canonicalCandidateKey]), + intent: canonical.detectedIntent, + normalizedCanonical: canonical.normalizedQuery, + projectId: input.projectId, + recommendedAction, + score: Math.max(...group.map((candidate) => candidate.score)), + stage, + topicId: canonical.topicId, + variants + }; + }).sort((left, right) => right.score - left.score || left.canonicalQuery.localeCompare(right.canonicalQuery)); +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/demandCoreReadModel.ts b/seo_mode/seo_mode/server/src/queryDiscovery/demandCoreReadModel.ts new file mode 100644 index 0000000..8c5b861 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/demandCoreReadModel.ts @@ -0,0 +1,217 @@ +import type { QueryDiscoveryContract } from "./queryDiscovery.js"; + +export type DemandCoreReadModel = { + schemaVersion: "demand-core.v1"; + projectId: string; + semanticRunId: string | null; + generatedAt: string; + state: "blocked" | "ready"; + source: { + searchScopeMode: QueryDiscoveryContract["searchScope"] extends infer Scope + ? Scope extends { mode: infer Mode } + ? Mode + : null + : null; + selectedSurfaceIds: string[]; + expansionMode: QueryDiscoveryContract["marketExpansion"]["domainAgnosticity"]["recommendedExpansionMode"]; + breadthScore: number; + }; + searchSurfaces: Array<{ + id: string; + title: string; + relationship: "core" | "near_core" | "application" | "adjacent" | "speculative"; + kind: QueryDiscoveryContract["marketExpansion"]["surfaces"][number]["surfaceKind"]; + status: QueryDiscoveryContract["marketExpansion"]["surfaces"][number]["status"]; + slots: QueryDiscoveryContract["marketExpansion"]["surfaces"][number]["slotBundle"]; + applicabilityScore: number; + evidenceScore: number; + risk: QueryDiscoveryContract["marketExpansion"]["surfaces"][number]["expansionRisk"]; + evidenceRefs: string[]; + legacySource: string; + legacySourceId: string; + }>; + probeSeeds: Array<{ + id: string; + phrase: string; + normalizedPhrase: string; + surfaceId: string; + source: "core" | "expansion"; + stage: "probe_seed"; + purposes: Array<"suggest_collection" | "serp_collection" | "first_party_match">; + evidenceRefs: string[]; + riskFlags: string[]; + isFinalKeyword: false; + }>; + observedQueries: Array<{ + id: string; + phrase: string; + normalizedPhrase: string; + surfaceId: string | null; + source: string; + sourceSeed: string | null; + sourceBranch: "core" | "expansion"; + observedAt: string; + stage: "observed_query"; + isFinalKeyword: false; + }>; + demandClusters: Array<{ + id: string; + canonicalPhrase: string; + surfaceId: string; + intent: string; + score: number; + variants: string[]; + decisionStatus: string; + sourceBranch: "core" | "expansion"; + evidenceRefs: string[]; + wordstatReady: boolean; + }>; + invariants: { + modelDraftCanEnterWordstat: false; + rawSuggestCanEnterWordstat: false; + speculativeExpansionCanEnterWordstat: false; + clusterApprovalRequired: true; + externalEvidenceOwnsDemand: true; + }; + diagnostics: { + surfaceCount: number; + coreSurfaceCount: number; + optionalExpansionSurfaceCount: number; + probeSeedCount: number; + observedQueryCount: number; + demandClusterCount: number; + wordstatReadyClusterCount: number; + }; +}; + +function relationship(value: QueryDiscoveryContract["marketExpansion"]["surfaces"][number]["relationshipToCore"]): DemandCoreReadModel["searchSurfaces"][number]["relationship"] { + if (value === "direct_core") return "core"; + return value; +} + +export function buildDemandCoreReadModel(discovery: QueryDiscoveryContract): DemandCoreReadModel { + const decisionByClusterId = new Map(discovery.observedLanguageReview.decisions.map((decision) => [decision.clusterId, decision])); + const wordstatReadyClusterIds = new Set(discovery.wordstatQueue.map((item) => item.clusterId)); + const searchSurfaces = discovery.marketExpansion.surfaces.map((surface) => ({ + applicabilityScore: surface.applicabilityScore, + evidenceRefs: surface.evidenceRefs, + evidenceScore: surface.evidenceScore, + id: surface.id, + kind: surface.surfaceKind, + legacySource: surface.source, + legacySourceId: surface.sourceId, + relationship: relationship(surface.relationshipToCore), + risk: surface.expansionRisk, + slots: surface.slotBundle, + status: surface.status, + title: surface.title + })); + const coreSeeds: DemandCoreReadModel["probeSeeds"] = discovery.marketSeeds.map((seed) => ({ + evidenceRefs: seed.evidenceRefs, + id: seed.id, + isFinalKeyword: false, + normalizedPhrase: seed.normalizedSeed, + phrase: seed.seed, + purposes: [seed.purpose], + riskFlags: seed.riskFlags, + source: "core", + stage: "probe_seed", + surfaceId: seed.facetId + })); + const expansionSeeds: DemandCoreReadModel["probeSeeds"] = discovery.marketExpansion.seeds.map((seed) => ({ + evidenceRefs: seed.evidenceRefs, + id: seed.id, + isFinalKeyword: false, + normalizedPhrase: seed.normalizedPhrase, + phrase: seed.phrase, + purposes: [seed.purpose], + riskFlags: seed.riskFlags, + source: "expansion", + stage: "probe_seed", + surfaceId: seed.surfaceId + })); + const coreObservations: DemandCoreReadModel["observedQueries"] = discovery.observations.map((observation) => ({ + id: observation.id, + isFinalKeyword: false, + normalizedPhrase: observation.normalizedQuery, + observedAt: observation.observedAt, + phrase: observation.query, + source: observation.source, + sourceBranch: "core", + sourceSeed: observation.sourceSeed, + stage: "observed_query", + surfaceId: observation.topicId + })); + const expansionObservations: DemandCoreReadModel["observedQueries"] = discovery.marketExpansion.observations.map((observation) => ({ + id: observation.id, + isFinalKeyword: false, + normalizedPhrase: observation.normalizedQuery, + observedAt: observation.observedAt, + phrase: observation.query, + source: observation.source, + sourceBranch: "expansion", + sourceSeed: observation.sourceProbe, + stage: "observed_query", + surfaceId: observation.surfaceId + })); + const coreClusters: DemandCoreReadModel["demandClusters"] = discovery.observedLanguageReview.clusters.map((cluster) => ({ + canonicalPhrase: cluster.canonicalQuery, + decisionStatus: decisionByClusterId.get(cluster.id)?.decisionStatus ?? "pending", + evidenceRefs: cluster.evidenceRefs, + id: cluster.id, + intent: cluster.intent, + score: cluster.score, + sourceBranch: "core", + surfaceId: cluster.facetId, + variants: cluster.variants.map((variant) => variant.query), + wordstatReady: wordstatReadyClusterIds.has(cluster.id) + })); + const expansionClusters: DemandCoreReadModel["demandClusters"] = discovery.marketExpansion.observedLanguageReview.candidates.map((candidate) => ({ + canonicalPhrase: candidate.query, + decisionStatus: "human_expansion_review", + evidenceRefs: candidate.evidenceRefs, + id: candidate.id, + intent: candidate.detectedIntent, + score: candidate.score, + sourceBranch: "expansion", + surfaceId: candidate.surfaceId, + variants: [candidate.query], + wordstatReady: false + })); + const probeSeeds = [...coreSeeds, ...expansionSeeds]; + const observedQueries = [...coreObservations, ...expansionObservations]; + const demandClusters = [...coreClusters, ...expansionClusters]; + return { + demandClusters, + diagnostics: { + coreSurfaceCount: searchSurfaces.filter((surface) => surface.relationship === "core").length, + demandClusterCount: demandClusters.length, + observedQueryCount: observedQueries.length, + optionalExpansionSurfaceCount: searchSurfaces.filter((surface) => surface.relationship !== "core").length, + probeSeedCount: probeSeeds.length, + surfaceCount: searchSurfaces.length, + wordstatReadyClusterCount: coreClusters.filter((cluster) => cluster.wordstatReady).length + }, + generatedAt: new Date().toISOString(), + invariants: { + clusterApprovalRequired: true, + externalEvidenceOwnsDemand: true, + modelDraftCanEnterWordstat: false, + rawSuggestCanEnterWordstat: false, + speculativeExpansionCanEnterWordstat: false + }, + observedQueries, + probeSeeds, + projectId: discovery.projectId, + schemaVersion: "demand-core.v1", + searchSurfaces, + semanticRunId: discovery.semanticRunId, + source: { + breadthScore: discovery.marketExpansion.domainAgnosticity.score, + expansionMode: discovery.marketExpansion.domainAgnosticity.recommendedExpansionMode, + searchScopeMode: discovery.searchScope?.mode ?? null, + selectedSurfaceIds: discovery.searchScope?.selectedVectorIds ?? [] + }, + state: discovery.state + }; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/domainAgnosticityClassifier.ts b/seo_mode/seo_mode/server/src/queryDiscovery/domainAgnosticityClassifier.ts new file mode 100644 index 0000000..034609b --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/domainAgnosticityClassifier.ts @@ -0,0 +1,68 @@ +import type { SemanticPortfolioFacet, UniversalOfferMap } from "../contextReview/coreSemanticPortfolioRouter.js"; +import type { SeoContextOpportunity } from "../contextReview/contextOpportunityMap.js"; + +export type DomainAgnosticity = { + score: number; + reasons: { + hasGenericProcesses: boolean; + hasCrossIndustryObjects: boolean; + hasMultipleBuyerRoles: boolean; + hasIntegrationLayer: boolean; + hasPlatformDeliveryModel: boolean; + hasNoStrictVerticalConstraint: boolean; + hasCurrentApplicationsAcrossDomains: boolean; + }; + recommendedExpansionMode: "narrow" | "balanced" | "broad_exploration"; + diagnostics: string[]; +}; + +function unique(values: Array) { + return new Set(values.map((value) => value?.trim().toLocaleLowerCase()).filter(Boolean)).size; +} + +export function classifyDomainAgnosticity(input: { + offerMap: UniversalOfferMap | null; + facets: SemanticPortfolioFacet[]; + opportunities: SeoContextOpportunity[]; +}): DomainAgnosticity { + const signals = input.offerMap?.businessModelSignals; + const processCount = unique([ + ...(input.offerMap?.processes.map((item) => item.title) ?? []), + ...input.facets.flatMap((facet) => facet.slotBundle.processes.map((slot) => slot.value)) + ]); + const objectCount = unique([ + ...(input.offerMap?.assetsOrObjects.map((item) => item.title) ?? []), + ...input.facets.flatMap((facet) => facet.slotBundle.assets.map((slot) => slot.value)) + ]); + const audienceCount = unique([ + ...(input.offerMap?.audiences.map((item) => item.title) ?? []), + ...input.facets.flatMap((facet) => facet.slotBundle.audiences.map((slot) => slot.value)), + ...input.opportunities.map((item) => item.buyer) + ]); + const currentApplicationSectorCount = unique(input.opportunities.filter((item) => item.sourceKind === "current_application").map((item) => item.targetSector)); + const integrationCount = unique([ + ...(input.offerMap?.systemsOrIntegrations.map((item) => item.title) ?? []), + ...input.facets.flatMap((facet) => facet.slotBundle.systems.map((slot) => slot.value)) + ]); + const reasons = { + hasCurrentApplicationsAcrossDomains: currentApplicationSectorCount >= 2, + hasCrossIndustryObjects: objectCount >= 2, + hasGenericProcesses: processCount >= 2 || input.facets.filter((facet) => facet.facetKind === "process_automation" || facet.facetKind === "service_action").length >= 2, + hasIntegrationLayer: integrationCount > 0 || input.facets.some((facet) => facet.facetKind === "integration_system"), + hasMultipleBuyerRoles: audienceCount >= 2, + hasNoStrictVerticalConstraint: signals?.geoDependency !== "high" && signals?.regulationLevel !== "high" && signals?.isLocal !== true, + hasPlatformDeliveryModel: signals?.deliveryModels.some((model) => model === "platform" || model === "software" || model === "infrastructure" || model === "hybrid") ?? false + }; + const positive = Object.values(reasons).filter(Boolean).length; + const verticalPenalty = reasons.hasNoStrictVerticalConstraint ? 0 : 0.18; + const score = Number(Math.max(0, Math.min(1, positive / Object.keys(reasons).length - verticalPenalty)).toFixed(2)); + const recommendedExpansionMode = score >= 0.68 ? "broad_exploration" : score >= 0.4 ? "balanced" : "narrow"; + const diagnostics = [ + `processFamilies=${processCount}`, + `objectFamilies=${objectCount}`, + `buyerRoles=${audienceCount}`, + `currentApplicationSectors=${currentApplicationSectorCount}`, + `integrationFamilies=${integrationCount}` + ]; + return { diagnostics, reasons, recommendedExpansionMode, score }; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/expansionObservedLanguageCollector.ts b/seo_mode/seo_mode/server/src/queryDiscovery/expansionObservedLanguageCollector.ts new file mode 100644 index 0000000..21afdeb --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/expansionObservedLanguageCollector.ts @@ -0,0 +1,111 @@ +import { getQueryDiscoveryContract } from "./queryDiscovery.js"; +import { buildDepthTwoProbes, buildExpansionSuggestProbes } from "./recursiveSuggestExpansion.js"; +import { saveExpansionObservation } from "./marketExpansionRepository.js"; +import type { ExplorationDepth } from "./expansionSeedGenerator.js"; + +type SuggestResponse = [string, string[], Record?]; + +export type ExpansionCollectionResult = { + schemaVersion: "market-expansion-collection.v1"; + projectId: string; + depth: ExplorationDepth; + requestedProbeCount: number; + successfulProbeCount: number; + savedObservationCount: number; + emptyProbeCount: number; + depthTwoProbeCount: number; + errors: Array<{ probe: string; message: string }>; +}; + +async function fetchSuggest(seed: string) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + try { + const url = new URL("https://suggest.yandex.ru/suggest-ya.cgi"); + url.searchParams.set("v", "4"); + url.searchParams.set("lang", "ru"); + url.searchParams.set("part", seed); + const response = await fetch(url, { headers: { Accept: "application/json" }, signal: controller.signal }); + if (!response.ok) throw new Error(`yandex_suggest_http_${response.status}`); + const payload = await response.json() as SuggestResponse; + return { + metadata: { provider: "yandex_suggest", request: { seed }, response: payload[2] ?? {} }, + suggestions: Array.isArray(payload[1]) ? payload[1].filter((value): value is string => typeof value === "string") : [] + }; + } finally { + clearTimeout(timeout); + } +} + +function isCleanForRecursion(query: string, seed: string) { + const normalized = query.trim().toLocaleLowerCase("ru-RU"); + if (/\b(?:бесплатно|обучение|курс|ваканси|скачать)\b|(?:[a-z0-9-]+\.)+(?:ru|com|net|org)\b/iu.test(normalized)) return false; + if (normalized.split(/\s+/).length > 10) return false; + const seedTokens = new Set(seed.toLocaleLowerCase("ru-RU").match(/[\p{L}\p{N}]+/gu) ?? []); + const queryTokens = normalized.match(/[\p{L}\p{N}]+/gu) ?? []; + return queryTokens.some((token) => token.length >= 4 && [...seedTokens].some((seedToken) => seedToken.startsWith(token) || token.startsWith(seedToken))); +} + +export async function collectExpansionObservedLanguage(input: { + projectId: string; + depth?: ExplorationDepth; + maxProbes?: number; +}): Promise { + const discovery = await getQueryDiscoveryContract(input.projectId); + if (discovery.state !== "ready" || !discovery.semanticRunId) throw new Error("Сначала нужен готовый Query Discovery contract."); + const depth = input.depth ?? discovery.marketExpansion.depth; + const maxProbes = Math.min(Math.max(input.maxProbes ?? (depth === "smoke" ? 20 : depth === "balanced" ? 120 : 240), 1), 240); + const probes = buildExpansionSuggestProbes({ + depth, + language: discovery.topicDecomposition.marketLanguage, + seeds: discovery.marketExpansion.seeds + }).slice(0, maxProbes); + if (probes.length === 0) throw new Error("Сначала выберите хотя бы одну Market Expansion surface для exploration."); + + let successfulProbeCount = 0; + let emptyProbeCount = 0; + let savedObservationCount = 0; + const errors: ExpansionCollectionResult["errors"] = []; + const recursiveCandidates: Array<{ query: string; surfaceId: string; seedId: string; clean: boolean }> = []; + + const collect = async (probe: (typeof probes)[number]) => { + try { + const result = await fetchSuggest(probe.phrase); + const suggestions = [...new Set(result.suggestions.map((value) => value.trim().replace(/\s+/g, " ")).filter((value) => value.length >= 2 && value.length <= 180))].slice(0, 10); + successfulProbeCount += 1; + if (suggestions.length === 0) emptyProbeCount += 1; + for (const query of suggestions) { + await saveExpansionObservation({ + depth: probe.depth, + metadata: { ...result.metadata, expansionKind: probe.expansionKind }, + projectId: input.projectId, + query, + seedId: probe.seedId, + semanticRunId: discovery.semanticRunId as string, + sourceProbe: probe.phrase, + surfaceId: probe.surfaceId + }); + recursiveCandidates.push({ clean: isCleanForRecursion(query, probe.phrase), query, seedId: probe.seedId, surfaceId: probe.surfaceId }); + savedObservationCount += 1; + } + } catch (error) { + errors.push({ message: error instanceof Error ? error.message : "suggest_failed", probe: probe.phrase }); + } + }; + + for (const probe of probes) await collect(probe); + const depthTwo = buildDepthTwoProbes({ depth, observedQueries: recursiveCandidates }).slice(0, Math.max(0, maxProbes - probes.length)); + for (const probe of depthTwo) await collect(probe); + + return { + depth, + depthTwoProbeCount: depthTwo.length, + emptyProbeCount, + errors, + projectId: input.projectId, + requestedProbeCount: probes.length + depthTwo.length, + savedObservationCount, + schemaVersion: "market-expansion-collection.v1", + successfulProbeCount + }; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/expansionObservedRelevanceGate.ts b/seo_mode/seo_mode/server/src/queryDiscovery/expansionObservedRelevanceGate.ts new file mode 100644 index 0000000..2cd7b6b --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/expansionObservedRelevanceGate.ts @@ -0,0 +1,196 @@ +import crypto from "node:crypto"; +import type { ExpansionSurface } from "./expansionSurfaceBuilder.js"; +import { normalizeObservedQueries, type NormalizedObservedQuery } from "./observedQueryNormalizer.js"; + +export type ExpansionObservedAction = + | "human_expansion_review" + | "keep_as_informational" + | "hide_as_noise" + | "keep_raw_only"; + +export type ExpansionObservedCandidate = { + id: string; + surfaceId: string; + query: string; + normalizedQuery: string; + observationIds: string[]; + seedIds: string[]; + sources: string[]; + detectedIntent: "commercial" | "transactional" | "informational" | "comparison" | "integration" | "local"; + recommendedAction: "human_expansion_review"; + score: number; + evidenceRefs: string[]; + riskFlags: string[]; + reason: string; +}; + +export type ExpansionObservedReview = { + normalized: NormalizedObservedQuery[]; + candidates: ExpansionObservedCandidate[]; + informational: Array<{ query: NormalizedObservedQuery; action: "keep_as_informational"; reason: string }>; + hidden: Array<{ query: NormalizedObservedQuery; action: "hide_as_noise"; reason: string }>; + rawOnly: Array<{ query: NormalizedObservedQuery; action: "keep_raw_only"; reason: string }>; + diagnostics: { + normalized: number; + humanReview: number; + informational: number; + hidden: number; + rawOnly: number; + observedClustersBySurface: Record; + wordstatReady: 0; + }; +}; + +type RawExpansionObservation = { + id: string; + surfaceId: string; + seedId: string; + query: string; + source: string; + sourceProbe: string; + metadata: Record; + observedAt: string; +}; + +const STOP_WORDS = new Set([ + "the", "for", "with", "and", "для", "как", "или", "это", "что", "при", "под", "система", "платформа", "решение", "сервис" +]); +const GENERIC_ROOTS = ["автоматизац", "бизнес", "процесс", "систем", "платформ", "решен", "сервис", "управлен", "automation", "business", "process", "platform", "system"]; + +function stableId(parts: string[]) { + return `expansion-review:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +function root(token: string) { + const value = token.toLocaleLowerCase("ru-RU"); + if (value.length <= 5) return value; + return value.replace(/(иями|ями|ами|ого|ему|ому|ыми|ими|иях|ах|ях|ия|ие|ий|ый|ая|ое|ые|ов|ев|ам|ям|ом|ем|ы|и|а|я)$/u, ""); +} + +function tokens(values: string[]) { + return new Set(values.flatMap((value) => value.toLocaleLowerCase("ru-RU").match(/[\p{L}\p{N}]+/gu) ?? []).map(root).filter((value) => value.length >= 3 && !STOP_WORDS.has(value))); +} + +function detectIntent(query: NormalizedObservedQuery): ExpansionObservedCandidate["detectedIntent"] { + if (query.detectedFlags.hasIntegrationIntent) return "integration"; + if (query.detectedFlags.hasGeoIntent) return "local"; + if (query.detectedFlags.hasComparisonIntent) return "comparison"; + if (query.detectedFlags.hasInformationalIntent || query.detectedFlags.hasEducationIntent) return "informational"; + if (query.detectedFlags.hasPriceIntent) return "transactional"; + return "commercial"; +} + +function groupBySurfaceAndPhrase(queries: NormalizedObservedQuery[]) { + const groups = new Map(); + for (const query of queries) { + const key = `${query.facetId ?? "unknown"}\u0000${query.normalizedQuery}`; + groups.set(key, [...(groups.get(key) ?? []), query]); + } + return [...groups.values()]; +} + +export function reviewExpansionObservedLanguage(input: { + observations: RawExpansionObservation[]; + surfaces: ExpansionSurface[]; + language: string; + brandAliases?: string[]; +}): ExpansionObservedReview { + const surfaceById = new Map(input.surfaces.map((surface) => [surface.id, surface])); + const seedIdByTopicAndNormalizedSeed = new Map(input.observations.map((observation) => [ + `${observation.surfaceId}\u0000${observation.sourceProbe.trim().replace(/\s+/g, " ").toLocaleLowerCase(input.language === "ru" ? "ru-RU" : "en-US")}`, + observation.seedId + ])); + const normalized = normalizeObservedQueries({ + brandAliases: input.brandAliases, + language: input.language, + observations: input.observations.map((observation) => ({ + id: observation.id, + metadata: observation.metadata, + observedAt: observation.observedAt, + query: observation.query, + source: observation.source === "yandex_suggest" ? "yandex_suggest" : "manual", + sourceSeed: observation.sourceProbe, + topicId: observation.surfaceId + })), + seedIdByTopicAndNormalizedSeed, + topicFacetMap: new Map(input.surfaces.map((surface) => [surface.id, surface.id])) + }); + const candidates: ExpansionObservedCandidate[] = []; + const informational: ExpansionObservedReview["informational"] = []; + const hidden: ExpansionObservedReview["hidden"] = []; + const rawOnly: ExpansionObservedReview["rawOnly"] = []; + + for (const group of groupBySurfaceAndPhrase(normalized)) { + const primary = group[0]; + const surface = primary.facetId ? surfaceById.get(primary.facetId) : undefined; + const classify = ( + target: T extends "keep_as_informational" ? ExpansionObservedReview["informational"] : T extends "hide_as_noise" ? ExpansionObservedReview["hidden"] : ExpansionObservedReview["rawOnly"], + action: T, + reason: string + ) => { + for (const query of group) (target as Array<{ query: NormalizedObservedQuery; action: T; reason: string }>).push({ action, query, reason }); + }; + if (primary.detectedFlags.hasDomainLikeToken || primary.detectedFlags.hasBrandLikeToken || primary.detectedFlags.hasFreeIntent || primary.detectedFlags.hasJobIntent) { + classify(hidden, "hide_as_noise", "Брендовый, навигационный, free или job intent не является expansion evidence."); + continue; + } + if (primary.detectedFlags.hasEducationIntent || primary.detectedFlags.hasInformationalIntent) { + classify(informational, "keep_as_informational", "Информационный язык сохранён отдельно и не переводится в Wordstat queue."); + continue; + } + if (!surface || primary.detectedFlags.isTooLong || primary.contamination === "mixed_intent") { + classify(rawOnly, "keep_raw_only", "Observation не удалось безопасно привязать к expansion surface."); + continue; + } + const vocabulary = tokens([surface.title, ...Object.values(surface.slotBundle).flat()]); + const queryRoots = new Set(primary.tokens.map(root).filter((value) => value.length >= 3 && !STOP_WORDS.has(value))); + const matches = [...queryRoots].filter((queryRoot) => [...vocabulary].some((candidate) => candidate.startsWith(queryRoot) || queryRoot.startsWith(candidate))); + const specificMatches = matches.filter((match) => !GENERIC_ROOTS.some((generic) => match.startsWith(generic) || generic.startsWith(match))); + if (matches.length === 0 || specificMatches.length === 0) { + classify(rawOnly, "keep_raw_only", "Suggest-ответ потерял отличительный термин выбранной expansion surface."); + continue; + } + const score = Number(Math.min(0.94, 0.42 + Math.min(0.28, specificMatches.length * 0.12) + surface.evidenceScore * 0.14 + surface.applicabilityScore * 0.1).toFixed(2)); + candidates.push({ + detectedIntent: detectIntent(primary), + evidenceRefs: group.map((query) => `expansion-observed:${query.source}:${query.id}`), + id: stableId([surface.id, primary.normalizedQuery]), + normalizedQuery: primary.normalizedQuery, + observationIds: group.map((query) => query.id), + query: primary.rawQuery, + reason: "Observed expansion language прошёл normalization и surface-fit gate; нужен отдельный human review перед любым Wordstat batch.", + recommendedAction: "human_expansion_review", + riskFlags: [ + "expansion_only", + "human_approval_required", + ...(surface.relationshipToCore === "speculative" ? ["speculative_surface"] : []), + ...(group.length < 2 ? ["one_independent_source_only"] : []) + ], + score, + seedIds: group.map((query) => query.seedId).filter((value): value is string => Boolean(value)), + sources: [...new Set(group.map((query) => query.source))], + surfaceId: surface.id + }); + } + + const observedClustersBySurface = candidates.reduce>((result, candidate) => { + result[candidate.surfaceId] = (result[candidate.surfaceId] ?? 0) + 1; + return result; + }, {}); + return { + candidates: candidates.sort((left, right) => right.score - left.score), + diagnostics: { + hidden: hidden.length, + humanReview: candidates.length, + informational: informational.length, + normalized: normalized.length, + observedClustersBySurface, + rawOnly: rawOnly.length, + wordstatReady: 0 + }, + hidden, + informational, + normalized, + rawOnly + }; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/expansionSeedGenerator.ts b/seo_mode/seo_mode/server/src/queryDiscovery/expansionSeedGenerator.ts new file mode 100644 index 0000000..79ec878 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/expansionSeedGenerator.ts @@ -0,0 +1,128 @@ +import crypto from "node:crypto"; +import type { ExpansionSurface } from "./expansionSurfaceBuilder.js"; + +export type ExplorationDepth = "smoke" | "balanced" | "deep"; + +export const EXPLORATION_DEPTH_POLICIES = { + smoke: { alphabetExpansion: false, maxSeeds: 20, maxSurfaces: 3, suggestDepth: 1 }, + balanced: { alphabetExpansion: true, maxSeeds: 120, maxSurfaces: 12, suggestDepth: 1 }, + deep: { alphabetExpansion: true, maxSeeds: 500, maxSurfaces: 40, suggestDepth: 2 } +} as const; + +export type ExpansionMarketSeed = { + id: string; + surfaceId: string; + phrase: string; + normalizedPhrase: string; + kind: ExpansionSurface["surfaceKind"]; + purpose: "suggest_collection" | "serp_collection"; + stage: "market_seed"; + isFinalKeyword: false; + relationshipToCore: ExpansionSurface["relationshipToCore"]; + evidenceRefs: string[]; + riskFlags: string[]; + confidence: number; +}; + +type Template = { kind: ExpansionSurface["surfaceKind"]; value: string; slots: string[] }; + +const TEMPLATES: Record<"ru" | "en", Template[]> = { + ru: [ + { kind: "process", slots: ["process"], value: "{process}" }, + { kind: "role", slots: ["role"], value: "{role}" }, + { kind: "industry", slots: ["industry"], value: "{industry}" }, + { kind: "asset_object", slots: ["object"], value: "управление {object}" }, + { kind: "asset_object", slots: ["object"], value: "анализ {object}" }, + { kind: "asset_object", slots: ["object"], value: "автоматизация обработки {object}" }, + { kind: "integration", slots: ["system"], value: "{system}" }, + { kind: "integration", slots: ["system"], value: "интеграция {system}" }, + { kind: "problem", slots: ["problem"], value: "решение для {problem}" }, + { kind: "use_case", slots: ["capability"], value: "{capability}" }, + { kind: "use_case", slots: ["capability"], value: "{capability} для бизнеса" }, + { kind: "buying_stage", slots: ["capability"], value: "{capability}" }, + { kind: "buying_stage", slots: ["capability"], value: "внедрение {capability}" } + ], + en: [ + { kind: "process", slots: ["process"], value: "{process} automation" }, + { kind: "process", slots: ["process"], value: "platform for {process}" }, + { kind: "role", slots: ["capability", "role"], value: "{capability} for {role}" }, + { kind: "industry", slots: ["capability", "industry"], value: "{capability} for {industry}" }, + { kind: "asset_object", slots: ["object"], value: "{object} management" }, + { kind: "integration", slots: ["system"], value: "integration with {system}" }, + { kind: "problem", slots: ["problem"], value: "solution for {problem}" }, + { kind: "use_case", slots: ["capability"], value: "{capability}" }, + { kind: "use_case", slots: ["process"], value: "system for {process}" }, + { kind: "buying_stage", slots: ["capability"], value: "{capability} implementation" } + ] +}; + +function normalize(value: string, language: string) { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase(language === "ru" ? "ru-RU" : "en-US"); +} + +function id(parts: string[]) { + return `expansion-seed:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +function render(template: Template, slots: Record) { + return template.value.replace(/\{([a-z_]+)\}/g, (_match, name: string) => slots[name] ?? "").replace(/\s+/g, " ").trim(); +} + +export function generateExpansionSeeds(input: { + surfaces: ExpansionSurface[]; + selectedSurfaceIds: string[]; + language: string; + depth: ExplorationDepth; +}): ExpansionMarketSeed[] { + const policy = EXPLORATION_DEPTH_POLICIES[input.depth]; + const selected = new Set(input.selectedSurfaceIds); + const language = input.language === "ru" ? "ru" : "en"; + const surfaces = input.surfaces.filter((surface) => selected.has(surface.id)).slice(0, policy.maxSurfaces); + const seen = new Set(); + const result: ExpansionMarketSeed[] = []; + + for (const surface of surfaces) { + const templates = TEMPLATES[language].filter((template) => template.kind === surface.surfaceKind); + const slots = surface.slotBundle; + const slotValues: Record = { + capability: slots.capability, + industry: slots.industry, + object: slots.object, + outcome: slots.outcome, + problem: slots.problem, + process: slots.process, + role: slots.role, + system: slots.system + }; + for (const template of templates) { + if (template.slots.some((slot) => (slotValues[slot] ?? []).length === 0)) continue; + const values = Object.fromEntries(template.slots.map((slot) => [slot, slotValues[slot][0]])); + const phrase = render(template, values); + const normalizedPhrase = normalize(phrase, language); + const invalidRussianWrapper = language === "ru" && /^автоматизация(?:\s+\S+){0,2}\s+\S*ть(?:ся)?(?:\s|$)/iu.test(normalizedPhrase); + const repeatedSemanticHead = ["автоматизац", "платформ", "систем", "интеграц"] + .some((head) => (normalizedPhrase.match(new RegExp(head, "giu")) ?? []).length > 1); + const repeatedContentToken = (normalizedPhrase.match(/[\p{L}\p{N}]+/gu) ?? []) + .filter((token) => token.length >= 6) + .some((token, index, all) => all.indexOf(token) !== index); + if (!phrase || invalidRussianWrapper || repeatedSemanticHead || repeatedContentToken || normalizedPhrase.split(/\s+/).length > 10 || phrase.length > 140 || seen.has(normalizedPhrase)) continue; + seen.add(normalizedPhrase); + result.push({ + confidence: Number((surface.applicabilityScore * 0.55 + surface.evidenceScore * 0.45).toFixed(2)), + evidenceRefs: surface.evidenceRefs, + id: id([surface.id, normalizedPhrase]), + isFinalKeyword: false, + kind: surface.surfaceKind, + normalizedPhrase, + phrase, + purpose: "suggest_collection", + relationshipToCore: surface.relationshipToCore, + riskFlags: surface.expansionRisk === "high" ? ["high_expansion_risk"] : [], + stage: "market_seed", + surfaceId: surface.id + }); + if (result.length >= policy.maxSeeds) return result; + } + } + return result; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/expansionSurfaceBuilder.ts b/seo_mode/seo_mode/server/src/queryDiscovery/expansionSurfaceBuilder.ts new file mode 100644 index 0000000..4c57050 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/expansionSurfaceBuilder.ts @@ -0,0 +1,270 @@ +import crypto from "node:crypto"; +import type { SemanticPortfolioFacet, UniversalOfferMap } from "../contextReview/coreSemanticPortfolioRouter.js"; +import type { SeoContextOpportunity } from "../contextReview/contextOpportunityMap.js"; + +export type ExpansionSurfaceSource = + | "core_capability" + | "demand_family" + | "current_application" + | "opportunity_hypothesis" + | "role_expansion" + | "process_expansion" + | "industry_expansion" + | "asset_object_expansion" + | "integration_expansion"; + +export type ExpansionSurfaceRelationship = "direct_core" | "near_core" | "application" | "adjacent" | "speculative"; +export type ExpansionSurfaceKind = "process" | "role" | "industry" | "asset_object" | "integration" | "problem" | "use_case" | "buying_stage"; +export type ExpansionSurfaceStatus = "suggested" | "selected_for_exploration" | "rejected" | "observed" | "wordstat_candidate"; + +export type ExpansionSurface = { + id: string; + source: ExpansionSurfaceSource; + sourceId: string; + relationshipToCore: ExpansionSurfaceRelationship; + surfaceKind: ExpansionSurfaceKind; + title: string; + slotBundle: { + capability: string[]; + process: string[]; + role: string[]; + industry: string[]; + object: string[]; + system: string[]; + problem: string[]; + outcome: string[]; + }; + applicabilityScore: number; + evidenceScore: number; + expansionRisk: "low" | "medium" | "high"; + evidenceRefs: string[]; + activation: { + defaultActive: boolean; + requiresHumanApproval: boolean; + reason: string; + }; + status: ExpansionSurfaceStatus; +}; + +function normalize(value: string) { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU"); +} + +function unique(values: Array) { + const seen = new Set(); + return values.reduce((result, value) => { + const clean = value?.trim().replace(/\s+/g, " ") ?? ""; + const key = normalize(clean); + if (!clean || seen.has(key)) return result; + seen.add(key); + result.push(clean); + return result; + }, []); +} + +function id(parts: string[]) { + return `expansion-surface:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +function facetKindToSurfaceKind(facet: SemanticPortfolioFacet): ExpansionSurfaceKind { + if (facet.facetKind === "audience_role") return "role"; + if (facet.facetKind === "asset_object") return "asset_object"; + if (facet.facetKind === "integration_system") return "integration"; + if (facet.facetKind === "problem_solution") return "problem"; + if (facet.facetKind === "price_transaction") return "buying_stage"; + if (facet.facetKind === "use_case" || facet.facetKind === "local_geo" || facet.facetKind === "product_category" || facet.facetKind === "informational_education" || facet.facetKind === "comparison") return "use_case"; + return "process"; +} + +function facetRelationship(facet: SemanticPortfolioFacet, selected: Set): ExpansionSurfaceRelationship { + if (selected.has(facet.id)) return "direct_core"; + if (facet.relationshipToCore === "core_facet") return "near_core"; + if (facet.relationshipToCore === "current_application") return "application"; + return facet.risks.some((risk) => risk.type === "speculative_expansion" || risk.type === "thin_evidence") ? "speculative" : "adjacent"; +} + +function riskForRelationship(relationship: ExpansionSurfaceRelationship): ExpansionSurface["expansionRisk"] { + if (relationship === "direct_core" || relationship === "near_core") return "low"; + if (relationship === "application") return "medium"; + return "high"; +} + +function sourceForFacet(facet: SemanticPortfolioFacet): ExpansionSurfaceSource { + if (facet.relationshipToCore === "current_application") return "current_application"; + if (facet.relationshipToCore === "expansion_hypothesis") return "opportunity_hypothesis"; + return facet.id.startsWith("demand_family:") ? "demand_family" : "core_capability"; +} + +function fromFacet(facet: SemanticPortfolioFacet, selected: Set): ExpansionSurface { + const relationshipToCore = facetRelationship(facet, selected); + const evidenceScore = facet.evidence.length > 0 + ? facet.evidence.reduce((total, item) => total + item.confidence, 0) / facet.evidence.length + : 0.35; + const values = (key: keyof SemanticPortfolioFacet["slotBundle"]) => facet.slotBundle[key].filter((slot) => !slot.isInternalTerm).map((slot) => slot.value); + const surfaceKind = facetKindToSurfaceKind(facet); + const plainLanguage = facet.marketSurface.plainLanguage; + return { + activation: { + defaultActive: false, + reason: relationshipToCore === "direct_core" ? "Уже покрывается Core Validation; expansion activation не требуется." : "Expansion surface требует отдельного human exploration decision.", + requiresHumanApproval: true + }, + applicabilityScore: Number(Math.min(0.96, facet.activation.defaultActiveScore * 0.55 + evidenceScore * 0.45).toFixed(2)), + evidenceRefs: unique(facet.evidence.flatMap((item) => item.refs)), + evidenceScore: Number(evidenceScore.toFixed(2)), + expansionRisk: riskForRelationship(relationshipToCore), + id: id(["facet", facet.id, facet.facetKind]), + relationshipToCore, + slotBundle: { + capability: unique([plainLanguage, ...values("entities")]), + industry: [], + object: unique([...values("assets"), ...(surfaceKind === "asset_object" ? [plainLanguage] : [])]), + outcome: values("outcomes"), + problem: unique([...values("problems"), ...(surfaceKind === "problem" ? [plainLanguage] : [])]), + process: unique([...values("processes"), ...(surfaceKind === "process" || surfaceKind === "use_case" ? [plainLanguage] : [])]), + role: unique([...values("audiences"), ...(surfaceKind === "role" ? [plainLanguage] : [])]), + system: unique([...values("systems"), ...(surfaceKind === "integration" ? [plainLanguage] : [])]) + }, + source: sourceForFacet(facet), + sourceId: facet.id, + status: "suggested", + surfaceKind, + title: facet.title + }; +} + +function shortAxisValue(value: string | null | undefined, maxWords = 9) { + const clean = value?.trim().replace(/\s+/g, " ") ?? ""; + if (!clean) return ""; + const firstClause = clean.split(/[,;]|\s+или\s+/iu)[0]?.trim() ?? clean; + return firstClause.split(/\s+/).slice(0, maxWords).join(" "); +} + +function opportunityAxisSurfaces(opportunities: SeoContextOpportunity[], offerMap: UniversalOfferMap | null) { + const capability = offerMap?.coreOffer.plainSummary ?? ""; + const surfaces: ExpansionSurface[] = []; + const definitions: Array<{ + kind: ExpansionSurfaceKind; + source: ExpansionSurfaceSource; + getValue: (opportunity: SeoContextOpportunity) => string; + slot: "process" | "role" | "industry" | "object" | "system"; + }> = [ + { getValue: (item) => shortAxisValue(item.jobToBeDone, 10), kind: "process", slot: "process", source: "process_expansion" }, + { getValue: (item) => shortAxisValue(item.buyer, 8), kind: "role", slot: "role", source: "role_expansion" }, + { getValue: (item) => shortAxisValue(item.targetSector, 9), kind: "industry", slot: "industry", source: "industry_expansion" } + ]; + for (const definition of definitions) { + const seen = new Set(); + for (const opportunity of opportunities) { + const title = definition.getValue(opportunity); + const key = normalize(title); + if (!title || seen.has(key)) continue; + seen.add(key); + const relationshipToCore: ExpansionSurfaceRelationship = opportunity.sourceKind === "current_application" ? "application" : "speculative"; + const slotBundle: ExpansionSurface["slotBundle"] = { capability: unique([capability]), industry: [], object: [], outcome: [], problem: [], process: [], role: [], system: [] }; + slotBundle[definition.slot] = [title]; + surfaces.push({ + activation: { defaultActive: false, reason: "Opportunity axis is exploration-only and requires a separate human decision.", requiresHumanApproval: true }, + applicabilityScore: Number(Math.min(0.82, ((opportunity.scorecard?.productFit ?? 50) / 100) * 0.8).toFixed(2)), + evidenceRefs: opportunity.evidenceRefs, + evidenceScore: opportunity.grounding === "explicit" ? 0.82 : opportunity.grounding === "inferred" ? 0.55 : 0.32, + expansionRisk: riskForRelationship(relationshipToCore), + id: id([definition.source, opportunity.id, title]), + relationshipToCore, + slotBundle, + source: definition.source, + sourceId: opportunity.id, + status: "suggested", + surfaceKind: definition.kind, + title + }); + } + } + return surfaces; +} + +function fromOpportunity(opportunity: SeoContextOpportunity): ExpansionSurface { + const relationshipToCore: ExpansionSurfaceRelationship = opportunity.sourceKind === "current_application" + ? "application" + : opportunity.grounding === "explicit" ? "adjacent" : "speculative"; + const evidenceScore = opportunity.grounding === "explicit" ? 0.85 : opportunity.grounding === "inferred" ? 0.58 : 0.35; + return { + activation: { defaultActive: false, reason: "Portfolio opportunity is exploration-only until human selection.", requiresHumanApproval: true }, + applicabilityScore: Number(((opportunity.scorecard?.productFit ?? 55) / 100).toFixed(2)), + evidenceRefs: opportunity.evidenceRefs, + evidenceScore, + expansionRisk: riskForRelationship(relationshipToCore), + id: id(["opportunity", opportunity.id, opportunity.targetSector ?? ""]), + relationshipToCore, + slotBundle: { + capability: [], + industry: unique([opportunity.targetSector]), + object: [], + outcome: unique([opportunity.commercialHypothesis]), + problem: unique([opportunity.rationale, ...opportunity.assumptions]), + process: unique([opportunity.jobToBeDone]), + role: unique([opportunity.buyer]), + system: [] + }, + source: opportunity.sourceKind === "current_application" ? "current_application" : "opportunity_hypothesis", + sourceId: opportunity.id, + status: "suggested", + surfaceKind: opportunity.targetSector ? "industry" : "use_case", + title: opportunity.title + }; +} + +function offerMapAxisSurfaces(offerMap: UniversalOfferMap | null) { + if (!offerMap) return [] as ExpansionSurface[]; + const definitions: Array<{ kind: ExpansionSurfaceKind; source: ExpansionSurfaceSource; items: Array<{ id: string; title: string; evidenceRefs: string[]; confidence: number }> }> = [ + { items: offerMap.processes, kind: "process", source: "process_expansion" }, + { items: offerMap.audiences, kind: "role", source: "role_expansion" }, + { items: offerMap.assetsOrObjects, kind: "asset_object", source: "asset_object_expansion" }, + { items: offerMap.systemsOrIntegrations, kind: "integration", source: "integration_expansion" } + ]; + return definitions.flatMap((definition) => definition.items.map((item) => ({ + activation: { defaultActive: false, reason: "Offer Map axis requires human exploration selection.", requiresHumanApproval: true }, + applicabilityScore: Number(Math.min(0.92, item.confidence * 0.9).toFixed(2)), + evidenceRefs: item.evidenceRefs, + evidenceScore: item.confidence, + expansionRisk: "low" as const, + id: id([definition.source, item.id, item.title]), + relationshipToCore: "near_core" as const, + slotBundle: { + capability: definition.kind === "process" ? [] : [offerMap.coreOffer.plainSummary], + industry: [], object: definition.kind === "asset_object" ? [item.title] : [], outcome: [], problem: [], + process: definition.kind === "process" ? [item.title] : [], role: definition.kind === "role" ? [item.title] : [], + system: definition.kind === "integration" ? [item.title] : [] + }, + source: definition.source, + sourceId: item.id, + status: "suggested" as const, + surfaceKind: definition.kind, + title: item.title + }))); +} + +export function buildExpansionSurfaces(input: { + offerMap: UniversalOfferMap | null; + facets: SemanticPortfolioFacet[]; + selectedFacetIds: string[]; + opportunities: SeoContextOpportunity[]; +}): ExpansionSurface[] { + const selected = new Set(input.selectedFacetIds); + const surfaces = [ + ...input.facets.map((facet) => fromFacet(facet, selected)), + ...input.opportunities.map(fromOpportunity), + ...offerMapAxisSurfaces(input.offerMap), + ...opportunityAxisSurfaces(input.opportunities, input.offerMap) + ]; + const byKey = new Map(); + for (const surface of surfaces) { + const key = `${surface.surfaceKind}:${normalize(surface.title)}`; + const existing = byKey.get(key); + if (!existing || surface.evidenceScore > existing.evidenceScore) byKey.set(key, surface); + } + return [...byKey.values()].sort((left, right) => { + const relationshipRank = { direct_core: 0, near_core: 1, application: 2, adjacent: 3, speculative: 4 }; + return relationshipRank[left.relationshipToCore] - relationshipRank[right.relationshipToCore] || right.applicabilityScore - left.applicabilityScore; + }).slice(0, 120); +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/marketExpansionRepository.ts b/seo_mode/seo_mode/server/src/queryDiscovery/marketExpansionRepository.ts new file mode 100644 index 0000000..d558608 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/marketExpansionRepository.ts @@ -0,0 +1,109 @@ +import { pool } from "../db/client.js"; +import type { ExpansionSurface, ExpansionSurfaceStatus } from "./expansionSurfaceBuilder.js"; +import type { ExpansionMarketSeed } from "./expansionSeedGenerator.js"; + +export type ExpansionSurfaceDecisionStatus = "suggested" | "selected_for_exploration" | "rejected"; +export type ExpansionSurfaceDecision = { + surfaceId: string; + decisionStatus: ExpansionSurfaceDecisionStatus; + reason: string; + decidedAt: string; +}; + +type DecisionRow = { surface_id: string; decision_status: ExpansionSurfaceDecisionStatus; reason: string; decided_at: Date }; + +export async function listExpansionSurfaceDecisions(projectId: string, semanticRunId: string | null) { + if (!semanticRunId) return new Map(); + const result = await pool.query( + `select surface_id, decision_status, reason, decided_at from query_expansion_surface_decisions where project_id = $1 and semantic_run_id = $2`, + [projectId, semanticRunId] + ); + return new Map(result.rows.map((row) => [row.surface_id, { + decidedAt: row.decided_at.toISOString(), decisionStatus: row.decision_status, reason: row.reason, surfaceId: row.surface_id + }])); +} + +export async function saveExpansionSurfaceDecision(input: { + projectId: string; + semanticRunId: string; + surfaceId: string; + decisionStatus: ExpansionSurfaceDecisionStatus; + reason?: string; +}) { + await pool.query( + `insert into query_expansion_surface_decisions (project_id, semantic_run_id, surface_id, decision_status, reason, decided_at) + values ($1, $2, $3, $4, $5, now()) + on conflict (project_id, semantic_run_id, surface_id) + do update set decision_status = excluded.decision_status, reason = excluded.reason, decided_at = now()`, + [input.projectId, input.semanticRunId, input.surfaceId, input.decisionStatus, input.reason?.trim() ?? ""] + ); +} + +export async function persistMarketExpansionSnapshot(input: { + projectId: string; + semanticRunId: string; + surfaces: ExpansionSurface[]; + seeds: ExpansionMarketSeed[]; +}) { + const client = await pool.connect(); + try { + await client.query("begin"); + await client.query("select pg_advisory_xact_lock(hashtext($1))", [`market-expansion:${input.projectId}:${input.semanticRunId}`]); + await client.query("delete from query_expansion_surfaces where project_id = $1 and semantic_run_id = $2", [input.projectId, input.semanticRunId]); + for (const surface of input.surfaces) { + await client.query( + `insert into query_expansion_surfaces (project_id, semantic_run_id, surface_id, surface_kind, relationship_to_core, status, payload) + values ($1, $2, $3, $4, $5, $6, $7::jsonb)`, + [input.projectId, input.semanticRunId, surface.id, surface.surfaceKind, surface.relationshipToCore, surface.status, JSON.stringify(surface)] + ); + } + await client.query("delete from query_expansion_seeds where project_id = $1 and semantic_run_id = $2", [input.projectId, input.semanticRunId]); + for (const seed of input.seeds) { + await client.query( + `insert into query_expansion_seeds (project_id, semantic_run_id, seed_id, surface_id, normalized_phrase, payload) + values ($1, $2, $3, $4, $5, $6::jsonb)`, + [input.projectId, input.semanticRunId, seed.id, seed.surfaceId, seed.normalizedPhrase, JSON.stringify(seed)] + ); + } + await client.query("commit"); + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } +} + +export async function saveExpansionObservation(input: { + projectId: string; + semanticRunId: string; + surfaceId: string; + seedId: string; + query: string; + sourceProbe: string; + depth: number; + metadata?: Record; +}) { + const normalized = input.query.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU"); + await pool.query( + `insert into query_expansion_observations (project_id, semantic_run_id, surface_id, seed_id, query, normalized_query, source, source_probe, depth, metadata) + values ($1, $2, $3, $4, $5, $6, 'yandex_suggest', $7, $8, $9::jsonb) + on conflict (project_id, semantic_run_id, surface_id, normalized_query, source) + do update set seed_id = excluded.seed_id, source_probe = excluded.source_probe, depth = least(query_expansion_observations.depth, excluded.depth), metadata = excluded.metadata, observed_at = now()`, + [input.projectId, input.semanticRunId, input.surfaceId, input.seedId, input.query.trim().replace(/\s+/g, " "), normalized, input.sourceProbe, input.depth, JSON.stringify(input.metadata ?? {})] + ); +} + +export async function listExpansionObservations(projectId: string, semanticRunId: string | null) { + if (!semanticRunId) return []; + const result = await pool.query<{ + id: string; surface_id: string; seed_id: string; query: string; normalized_query: string; source: string; source_probe: string; depth: number; metadata: Record; observed_at: Date; + }>( + `select id, surface_id, seed_id, query, normalized_query, source, source_probe, depth, metadata, observed_at + from query_expansion_observations where project_id = $1 and semantic_run_id = $2 order by observed_at desc`, + [projectId, semanticRunId] + ); + return result.rows.map((row) => ({ + depth: row.depth, id: row.id, metadata: row.metadata, normalizedQuery: row.normalized_query, observedAt: row.observed_at.toISOString(), query: row.query, seedId: row.seed_id, source: row.source, sourceProbe: row.source_probe, surfaceId: row.surface_id + })); +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/marketSeedGenerator.ts b/seo_mode/seo_mode/server/src/queryDiscovery/marketSeedGenerator.ts new file mode 100644 index 0000000..ba0e32d --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/marketSeedGenerator.ts @@ -0,0 +1,293 @@ +import crypto from "node:crypto"; +import type { + SemanticPortfolioFacet, + UniversalOfferMap +} from "../contextReview/coreSemanticPortfolioRouter.js"; + +export const MARKET_SEED_KINDS = [ + "base_entity", + "category_entity", + "category_for_process", + "category_for_audience", + "action_object", + "action_process", + "problem_solution", + "process_with_modifier", + "entity_for_audience", + "integration_probe", + "local_probe", + "informational_probe", + "price_probe", + "comparison_probe" +] as const; + +export type MarketSeedKind = (typeof MARKET_SEED_KINDS)[number]; +export type MarketSeedPurpose = "suggest_collection" | "serp_collection" | "first_party_match"; + +export type MarketSeed = { + id: string; + projectId: string; + facetId: string; + topicId?: string; + seed: string; + normalizedSeed: string; + kind: MarketSeedKind; + stage: "market_seed"; + purpose: MarketSeedPurpose; + source: "semantic_slot" | "pattern_seed" | "user_manual" | "first_party"; + isFinalKeyword: false; + evidenceRefs: string[]; + blockedReason?: string; + riskFlags: string[]; + confidence: number; +}; + +export type MarketSeedTopic = { + topicId: string; + facetId: string; + facetKind: string | null; + marketEntityFamily: string[]; + coreEntities?: string[]; + marketSynonyms?: string[]; + primaryMarketEntities?: string[]; + categories: string[]; + actions: string[]; + businessProcesses: string[]; + pains: string[]; + buyerSegments: string[]; + systemsForIntegrations: string[]; + evidenceRefs: string[]; +}; + +type SeedSlots = { + entity: string[]; + category: string[]; + action: string[]; + object: string[]; + process: string[]; + problem: string[]; + audience: string[]; + system: string[]; + location: string[]; + modifier: string[]; +}; + +type Template = { + kind: MarketSeedKind; + value: string; + required: Array; +}; + +const LOCALIZED_TEMPLATES: Record<"ru" | "en", Template[]> = { + ru: [ + { kind: "base_entity", value: "{entity}", required: ["entity"] }, + { kind: "entity_for_audience", value: "{entity} для {audience}", required: ["entity", "audience"] }, + { kind: "category_entity", value: "{category} {entity}", required: ["category", "entity"] }, + { kind: "category_for_process", value: "{category} для {process}", required: ["category", "process"] }, + { kind: "category_for_audience", value: "{category} для {audience}", required: ["category", "audience"] }, + { kind: "action_object", value: "{action} {object}", required: ["action", "object"] }, + { kind: "action_process", value: "{action} {process}", required: ["action", "process"] }, + { kind: "problem_solution", value: "{category} для {problem}", required: ["category", "problem"] }, + { kind: "process_with_modifier", value: "{process} с {modifier}", required: ["process", "modifier"] }, + { kind: "integration_probe", value: "интеграция {entity} с {system}", required: ["entity", "system"] }, + { kind: "integration_probe", value: "{system} {entity}", required: ["system", "entity"] }, + { kind: "local_probe", value: "{entity} в {location}", required: ["entity", "location"] }, + { kind: "local_probe", value: "{action} {object} в {location}", required: ["action", "object", "location"] }, + { kind: "informational_probe", value: "что такое {entity}", required: ["entity"] }, + { kind: "informational_probe", value: "как {action} {process}", required: ["action", "process"] }, + { kind: "price_probe", value: "{entity} цена", required: ["entity"] }, + { kind: "price_probe", value: "стоимость {category}", required: ["category"] }, + { kind: "comparison_probe", value: "аналоги {entity}", required: ["entity"] }, + { kind: "comparison_probe", value: "лучшие {category}", required: ["category"] } + ], + en: [ + { kind: "base_entity", value: "{entity}", required: ["entity"] }, + { kind: "entity_for_audience", value: "{entity} for {audience}", required: ["entity", "audience"] }, + { kind: "category_entity", value: "{category} {entity}", required: ["category", "entity"] }, + { kind: "category_for_process", value: "{category} for {process}", required: ["category", "process"] }, + { kind: "category_for_audience", value: "{category} for {audience}", required: ["category", "audience"] }, + { kind: "action_object", value: "{action} {object}", required: ["action", "object"] }, + { kind: "action_process", value: "{action} {process}", required: ["action", "process"] }, + { kind: "problem_solution", value: "{category} for {problem}", required: ["category", "problem"] }, + { kind: "process_with_modifier", value: "{process} with {modifier}", required: ["process", "modifier"] }, + { kind: "integration_probe", value: "{entity} integration with {system}", required: ["entity", "system"] }, + { kind: "local_probe", value: "{entity} in {location}", required: ["entity", "location"] }, + { kind: "informational_probe", value: "what is {entity}", required: ["entity"] }, + { kind: "informational_probe", value: "how to {action} {process}", required: ["action", "process"] }, + { kind: "price_probe", value: "{entity} price", required: ["entity"] }, + { kind: "comparison_probe", value: "{entity} alternatives", required: ["entity"] } + ] +}; + +function normalize(value: string, language: string) { + return value.trim().replace(/[‐‑‒–—−]/g, "-").replace(/\s+/g, " ").toLocaleLowerCase(language === "ru" ? "ru-RU" : "en-US"); +} + +function unique(values: Array, language: string) { + const seen = new Set(); + return values.reduce((result, value) => { + const clean = value?.trim().replace(/\s+/g, " ") ?? ""; + const key = normalize(clean, language); + if (!clean || seen.has(key)) return result; + seen.add(key); + result.push(clean); + return result; + }, []); +} + +function stableId(parts: string[]) { + return `market-seed:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +function facetSlotValues(facet: SemanticPortfolioFacet | undefined, key: keyof SemanticPortfolioFacet["slotBundle"]) { + return facet?.slotBundle[key] + .filter((slot) => !slot.isInternalTerm && !slot.isWeakModifier) + .sort((left, right) => right.confidence - left.confidence) + .map((slot) => slot.value) ?? []; +} + +function splitValues(values: string[]) { + return values.flatMap((value) => value.split(/[,;/]+/).map((part) => part.trim())); +} + +function audienceSurfaces(values: string[], language: string, offerMap: UniversalOfferMap | null) { + if (language === "ru") { + const genitiveWord = /(?:ов|ев|ей|ий|аний|ений|бизнеса)$/iu; + const evidenceBackedGenitive = values.filter((value) => { + const words = value.trim().split(/\s+/); + return genitiveWord.test(words[0] ?? "") && genitiveWord.test(words.at(-1) ?? ""); + }); + const b2bSignal = offerMap?.businessModelSignals.isB2B === true || + offerMap?.businessModelSignals.purchaseComplexity === "high" || + offerMap?.businessModelSignals.purchaseComplexity === "enterprise" || + offerMap?.businessModelSignals.salesMotion.includes("enterprise_sales"); + return unique([...(b2bSignal ? ["бизнеса"] : []), ...evidenceBackedGenitive], language); + } + return unique(values, language); +} + +function buildSlots(topic: MarketSeedTopic, facet: SemanticPortfolioFacet | undefined, offerMap: UniversalOfferMap | null, language: string): SeedSlots { + const entityCandidates = unique([ + ...(topic.marketSynonyms ?? []), + ...(topic.coreEntities ?? []), + ...topic.marketEntityFamily, + ...(topic.primaryMarketEntities ?? []), + ...facetSlotValues(facet, "entities") + ], language).sort((left, right) => left.split(/\s+/).length - right.split(/\s+/).length || left.length - right.length); + return { + entity: entityCandidates.slice(0, 6), + category: unique([...topic.categories, ...(offerMap?.marketCategories ?? [])], language).slice(0, 3), + action: unique([...topic.actions, ...facetSlotValues(facet, "actions")], language).slice(0, 3), + object: unique([...facetSlotValues(facet, "assets"), ...topic.businessProcesses], language).slice(0, 3), + process: unique([...topic.businessProcesses, ...facetSlotValues(facet, "processes")], language).slice(0, 4), + problem: unique([...topic.pains, ...facetSlotValues(facet, "problems")], language).slice(0, 3), + audience: audienceSurfaces([...topic.buyerSegments, ...facetSlotValues(facet, "audiences"), ...(offerMap?.buyerRoles ?? [])], language, offerMap).slice(0, 4), + system: unique([...splitValues(topic.systemsForIntegrations), ...facetSlotValues(facet, "systems")], language).slice(0, 3), + location: unique([...facetSlotValues(facet, "locations"), ...(offerMap?.locations.map((item) => item.title) ?? [])], language).slice(0, 3), + modifier: unique(facetSlotValues(facet, "modifiers"), language).slice(0, 3) + }; +} + +function isTemplateEnabled(template: Template, facet: SemanticPortfolioFacet | undefined, offerMap: UniversalOfferMap | null, comparisonMode: boolean) { + const signals = offerMap?.businessModelSignals; + if (template.kind === "local_probe") return facet?.facetKind === "local_geo" || signals?.geoDependency === "high"; + if (template.kind === "integration_probe") return facet?.facetKind === "integration_system" || facet?.patternEligibility.allowedPatternGroups.includes("integration") === true; + if (template.kind === "comparison_probe") return comparisonMode && (facet?.facetKind === "comparison" || facet?.patternEligibility.allowedPatternGroups.includes("comparison") === true); + if (template.kind === "price_probe") { + const transactional = signals?.salesMotion.some((motion) => ["local_booking", "catalog_purchase", "request_quote", "subscription"].includes(motion)); + return facet?.facetKind === "price_transaction" || transactional === true; + } + return true; +} + +function combinations(required: Array, slots: SeedSlots, valueLimit = 2) { + let result: Array> = [{}]; + for (const slot of required) { + const next: Array> = []; + for (const current of result) { + for (const value of slots[slot].slice(0, valueLimit)) next.push({ ...current, [slot]: value }); + } + result = next.slice(0, 8); + } + return result; +} + +function render(template: string, values: Record) { + return template.replace(/\{([a-z_]+)\}/g, (_match, slot: string) => values[slot] ?? "").replace(/\s+/g, " ").trim(); +} + +function tokenRoots(value: string) { + return new Set(normalize(value, "ru").match(/[\p{L}\p{N}]+/gu)?.map((token) => token.length > 5 ? token.slice(0, Math.max(4, token.length - 3)) : token) ?? []); +} + +function hasMeaningfulOverlap(left: string, right: string) { + const leftRoots = tokenRoots(left); + return [...tokenRoots(right)].some((root) => root.length >= 4 && [...leftRoots].some((candidate) => candidate.startsWith(root) || root.startsWith(candidate))); +} + +export function generateMarketSeeds(input: { + projectId: string; + topics: MarketSeedTopic[]; + selectedFacets: SemanticPortfolioFacet[]; + offerMap: UniversalOfferMap | null; + language: string; + comparisonMode?: boolean; + maxPerFacet?: number; + maxTotal?: number; +}): MarketSeed[] { + const language = input.language === "ru" ? "ru" : input.language === "en" ? "en" : null; + const templates = language ? LOCALIZED_TEMPLATES[language] : LOCALIZED_TEMPLATES.en.filter((template) => template.kind === "base_entity"); + const facetById = new Map(input.selectedFacets.map((facet) => [facet.id, facet])); + const seen = new Set(); + const seeds: MarketSeed[] = []; + + for (const topic of input.topics) { + const facet = facetById.get(topic.facetId); + const slots = buildSlots(topic, facet, input.offerMap, language ?? input.language); + let facetCount = 0; + + for (const template of templates) { + if (!isTemplateEnabled(template, facet, input.offerMap, input.comparisonMode ?? false)) continue; + if (template.required.some((slot) => slots[slot].length === 0)) continue; + + const valueLimit = template.kind === "base_entity" || template.kind === "entity_for_audience" ? 4 : 2; + for (const values of combinations(template.required, slots, valueLimit)) { + if ((template.kind === "action_process" || template.kind === "action_object") && values.action?.split(/\s+/).length > 1) continue; + if (values.entity && values.audience && hasMeaningfulOverlap(values.entity, values.audience)) continue; + if (values.action && values.process && hasMeaningfulOverlap(values.action, values.process)) continue; + if (values.action && values.object && hasMeaningfulOverlap(values.action, values.object)) continue; + const seed = template.kind === "informational_probe" && values.action?.split(/\s+/).length > 1 + ? language === "ru" ? `как ${values.action}` : `how to ${values.action}` + : render(template.value, values); + const normalizedSeed = normalize(seed, language ?? input.language); + const wordCount = normalizedSeed.split(/\s+/).length; + const key = `${topic.facetId}\u0000${normalizedSeed}`; + if (!seed || seed.length < 3 || seed.length > 120 || wordCount > 8 || seen.has(key)) continue; + seen.add(key); + const riskFlags = language ? [] : ["unsupported_language_template"]; + seeds.push({ + confidence: Number((0.82 - Math.max(0, wordCount - 3) * 0.035 - riskFlags.length * 0.15).toFixed(2)), + evidenceRefs: [...new Set([...(topic.evidenceRefs ?? []), ...(facet?.evidence.flatMap((evidence) => evidence.refs) ?? [])])], + facetId: topic.facetId, + id: stableId([input.projectId, topic.facetId, topic.topicId, template.kind, normalizedSeed]), + isFinalKeyword: false, + kind: template.kind, + normalizedSeed, + projectId: input.projectId, + purpose: template.kind === "informational_probe" ? "serp_collection" : "suggest_collection", + riskFlags, + seed, + source: "semantic_slot", + stage: "market_seed", + topicId: topic.topicId + }); + facetCount += 1; + if (facetCount >= (input.maxPerFacet ?? 16) || seeds.length >= (input.maxTotal ?? 80)) break; + } + if (facetCount >= (input.maxPerFacet ?? 16) || seeds.length >= (input.maxTotal ?? 80)) break; + } + if (seeds.length >= (input.maxTotal ?? 80)) break; + } + + return seeds; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/observedLanguageCollector.ts b/seo_mode/seo_mode/server/src/queryDiscovery/observedLanguageCollector.ts new file mode 100644 index 0000000..266e193 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/observedLanguageCollector.ts @@ -0,0 +1,196 @@ +import crypto from "node:crypto"; +import { + getQueryDiscoveryContract, + saveQueryObservation, + type QueryObservationSource +} from "./queryDiscovery.js"; + +type YandexSuggestResponse = [string, string[], Record?]; + +export type ObservedLanguageCollectionResult = { + schemaVersion: "observed-language-collection.v1"; + projectId: string; + source: QueryObservationSource; + collectedAt: string; + requestedProbeCount: number; + successfulProbeCount: number; + savedObservationCount: number; + emptyProbeCount: number; + errors: Array<{ probeId: string; message: string }>; +}; + +function normalize(value: string) { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase(); +} + +async function fetchYandexSuggest(seed: string) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 10_000); + + try { + const url = new URL("https://suggest.yandex.ru/suggest-ya.cgi"); + url.searchParams.set("v", "4"); + url.searchParams.set("lang", "ru"); + url.searchParams.set("part", seed); + const response = await fetch(url, { + headers: { Accept: "application/json" }, + signal: controller.signal + }); + + if (!response.ok) { + throw new Error(`yandex_suggest_http_${response.status}`); + } + + const payload = await response.json(); + if (!Array.isArray(payload)) { + throw new Error("yandex_suggest_invalid_payload"); + } + + const tuple = payload as YandexSuggestResponse; + return { + suggestions: Array.isArray(tuple[1]) ? tuple[1].filter((value): value is string => typeof value === "string") : [], + metadata: { + request: { locale: "ru", seed }, + response: tuple[2] ?? {}, + provider: "yandex_suggest" + } + }; + } finally { + clearTimeout(timeout); + } +} + +/** + * Collects observed language only from a human-kept base probe or a human + * manual surface. It never creates model phrases, never changes scope, and + * never submits anything to Wordstat. + */ +export async function collectObservedLanguageFromYandexSuggest(input: { + projectId: string; + maxProbes?: number; +}): Promise { + const discovery = await getQueryDiscoveryContract(input.projectId); + if (discovery.state !== "ready" || !discovery.semanticRunId) { + throw new Error("Сначала нужен готовый semantic portfolio и topic decomposition."); + } + + const maxProbes = Math.min(Math.max(input.maxProbes ?? 8, 1), 12); + const generatedSeedPool = discovery.marketSeeds + .filter((seed) => seed.purpose === "suggest_collection" && !seed.blockedReason) + .sort((left, right) => right.confidence - left.confidence || left.seed.localeCompare(right.seed, "ru")); + const generatedByFacet = new Map(); + for (const seed of generatedSeedPool) generatedByFacet.set(seed.facetId, [...(generatedByFacet.get(seed.facetId) ?? []), seed]); + const generatedBalanced = [] as typeof generatedSeedPool; + let round = 0; + while (generatedBalanced.length < maxProbes && [...generatedByFacet.values()].some((items) => round < items.length)) { + for (const items of generatedByFacet.values()) { + const seed = items[round]; + if (seed) generatedBalanced.push(seed); + if (generatedBalanced.length >= maxProbes) break; + } + round += 1; + } + const generatedSeeds = generatedBalanced + .map((seed) => ({ + id: seed.id, + patternId: null, + phrase: seed.seed, + probeId: null, + topicId: seed.topicId ?? "" + })) + .filter((seed) => Boolean(seed.topicId)); + const manualSeeds = discovery.candidates + .filter((candidate) => candidate.status === "approved_for_collection") + .filter((candidate) => candidate.quality?.status !== "hidden_by_quality_gate") + .filter((candidate) => candidate.patternId === "base_entity" || candidate.generationMethod === "manual") + .filter((candidate) => /[а-яё]/i.test(candidate.phrase)) + // Explicitly supplied market language has higher provenance for this + // collection pass than a model-derived base surface. Both still require + // Suggest evidence and neither is a Wordstat submission. + .sort((left, right) => { + const leftManual = left.generationMethod === "manual" ? 0 : 1; + const rightManual = right.generationMethod === "manual" ? 0 : 1; + if (leftManual !== rightManual) return leftManual - rightManual; + return left.phrase.localeCompare(right.phrase, "ru"); + }) + .map((candidate) => ({ + id: candidate.probeId ?? candidate.id, + patternId: candidate.patternId, + phrase: candidate.phrase, + probeId: candidate.probeId, + topicId: candidate.topicId + })); + const seeds = [...generatedSeeds, ...manualSeeds].filter((seed, index, values) => + values.findIndex((candidate) => candidate.topicId === seed.topicId && normalize(candidate.phrase) === normalize(seed.phrase)) === index + ); + + if (seeds.length === 0) { + throw new Error("MarketSeedGenerator не построил ни одной безопасной поверхности для Suggest; проверьте semantic slots или добавьте ручную формулировку."); + } + + const probes = seeds.slice(0, maxProbes); + const errors: ObservedLanguageCollectionResult["errors"] = []; + let successfulProbeCount = 0; + let savedObservationCount = 0; + let emptyProbeCount = 0; + + for (const probe of probes) { + try { + const result = await fetchYandexSuggest(probe.phrase); + const seen = new Set(); + const suggestions = result.suggestions + .map((value) => value.trim().replace(/\s+/g, " ")) + .filter((value) => value.length >= 2 && value.length <= 240 && /[а-яё]/i.test(value)) + .filter((value) => { + const key = normalize(value); + if (seen.has(key)) return false; + seen.add(key); + return true; + }) + .slice(0, 10); + + successfulProbeCount += 1; + if (suggestions.length === 0) { + emptyProbeCount += 1; + continue; + } + + const rawArtifactKey = `yandex-suggest:${crypto + .createHash("sha256") + .update(`${probe.id}\u0000${normalize(probe.phrase)}`) + .digest("hex") + .slice(0, 24)}`; + for (const query of suggestions) { + await saveQueryObservation(input.projectId, discovery.semanticRunId, { + language: "ru", + metadata: result.metadata, + patternId: probe.patternId, + probeId: probe.probeId, + query, + rawArtifactKey, + source: "yandex_suggest", + sourceSeed: probe.phrase, + topicId: probe.topicId + }); + savedObservationCount += 1; + } + } catch (error) { + errors.push({ + message: error instanceof Error ? error.message : "yandex_suggest_failed", + probeId: probe.id + }); + } + } + + return { + collectedAt: new Date().toISOString(), + emptyProbeCount, + errors, + projectId: input.projectId, + requestedProbeCount: probes.length, + savedObservationCount, + schemaVersion: "observed-language-collection.v1", + source: "yandex_suggest", + successfulProbeCount + }; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/observedQueryNormalizer.ts b/seo_mode/seo_mode/server/src/queryDiscovery/observedQueryNormalizer.ts new file mode 100644 index 0000000..1f5a44d --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/observedQueryNormalizer.ts @@ -0,0 +1,183 @@ +import type { QueryObservationSource } from "./queryDiscovery.js"; + +export type ObservedContamination = + | "clean" + | "domain_or_brand" + | "free_low_intent" + | "education" + | "job" + | "geo_unselected" + | "integration_unselected" + | "too_long" + | "mixed_intent" + | "unknown"; + +export type NormalizedObservedQuery = { + id: string; + rawQuery: string; + normalizedQuery: string; + canonicalCandidateKey: string; + language: string; + source: QueryObservationSource; + providerPayload?: unknown; + seedId?: string; + facetId?: string; + topicId?: string; + sourceSeed?: string | null; + tokens: string[]; + lemmas?: string[]; + detectedFlags: { + hasDomainLikeToken: boolean; + hasBrandLikeToken: boolean; + hasFreeIntent: boolean; + hasEducationIntent: boolean; + hasJobIntent: boolean; + hasGeoIntent: boolean; + hasPriceIntent: boolean; + hasComparisonIntent: boolean; + hasIntegrationIntent: boolean; + hasInformationalIntent: boolean; + hasMixedLanguage: boolean; + isTooLong: boolean; + }; + contamination: ObservedContamination; + createdAt: string; +}; + +type NormalizerObservation = { + id: string; + query: string; + source: QueryObservationSource; + metadata?: Record; + topicId?: string; + sourceSeed?: string | null; + observedAt?: string; +}; + +const INTENT_LEXICONS = { + ru: { + free: ["бесплатно", "бесплатный", "скачать", "скачать бесплатно", "free"], + education: ["обучение", "курс", "курсы", "школа", "урок", "сертификат", "тест", "экзамен", "установите соответствие", "текст вопроса"], + job: ["вакансия", "вакансии", "работа", "резюме", "зарплата"], + geo: ["рядом", "поблизости", "в москве", "в спб", "в санкт-петербурге"], + price: ["цена", "стоимость", "тариф", "сколько стоит", "купить", "заказать"], + comparison: ["аналоги", "альтернатива", "альтернативы", "сравнение", "лучший", "лучшие", "отзывы"], + integration: ["интеграция", "интегрировать", "подключить", "синхронизация", "api"], + informational: ["что такое", "что это", "это", "как ", "какие ", "кто ", "кому ", "где ", "почему ", "можно ли", "имеет ли", "обязаны ли", "должен ", "должны ", "простыми словами", "предназначена для", "технология позволяющая", "как работает", "как сделать", "как выбрать", "зачем нужен", "примеры"] + }, + en: { + free: ["free", "free download"], + education: ["course", "courses", "training", "tutorial", "certification"], + job: ["job", "jobs", "vacancy", "salary", "career"], + geo: ["near me", "nearby"], + price: ["price", "pricing", "cost", "buy", "quote"], + comparison: ["alternatives", "alternative", "comparison", "versus", "vs", "reviews", "best"], + integration: ["integration", "integrate", "connector", "api"], + informational: ["what is", "how ", "who ", "where ", "why ", "can i", "should ", "how to", "how does", "examples of"] + } +} as const; + +function normalize(value: string, language: string) { + return value + .normalize("NFKC") + .trim() + .replace(/[‐‑‒–—−]/g, "-") + .replace(/[“”„«»]/g, '"') + .replace(/\s+/g, " ") + .toLocaleLowerCase(language === "ru" ? "ru-RU" : "en-US"); +} + +function includesLexeme(value: string, lexemes: readonly string[]) { + return lexemes.some((rawLexeme) => { + const lexeme = rawLexeme.trim(); + return value === lexeme || value.includes(` ${lexeme} `) || value.startsWith(`${lexeme} `) || value.endsWith(` ${lexeme}`); + }); +} + +function normalizedBrandAliases(aliases: string[], language: string) { + return aliases.map((alias) => normalize(alias, language)).filter((alias) => alias.length >= 3); +} + +function canonicalToken(token: string, language: string) { + if (language === "ru" && token.length > 5) return token.replace(/(иями|ями|ами|ого|ему|ому|ыми|ими|иях|ах|ях|ия|ие|ий|ый|ая|ое|ые|ов|ев|ам|ям|ом|ем|ы|и|а|я)$/u, ""); + if (language !== "ru" && token.length > 5) return token.replace(/(ing|ers|ies|ed|es|s)$/u, ""); + return token; +} + +export function normalizeObservedQueries(input: { + observations: NormalizerObservation[]; + topicFacetMap: Map; + language: string; + brandAliases?: string[]; + seedIdByTopicAndNormalizedSeed?: Map; +}): NormalizedObservedQuery[] { + const language = input.language === "ru" ? "ru" : "en"; + const lexicons = INTENT_LEXICONS[language]; + const brandAliases = normalizedBrandAliases(input.brandAliases ?? [], language); + + return input.observations.map((observation) => { + const normalizedQuery = normalize(observation.query, language); + const padded = ` ${normalizedQuery} `; + const surfaceTokens = normalizedQuery.match(/[\p{L}\p{N}]+(?:[-.][\p{L}\p{N}]+)*/gu) ?? []; + const tokens = normalizedQuery.match(/[\p{L}\p{N}]+/gu) ?? []; + const hasCyrillic = /[а-яё]/iu.test(normalizedQuery); + const hasLatin = /[a-z]/iu.test(normalizedQuery); + const hasDomainLikeToken = surfaceTokens.some((token) => /^(?:[a-z0-9-]+\.)+(?:ru|рф|com|net|org|io|ai|app|dev|online|site)$/iu.test(token)); + const normalizedSourceSeed = observation.sourceSeed ? normalize(observation.sourceSeed, language) : ""; + const tail = normalizedSourceSeed && normalizedQuery.startsWith(`${normalizedSourceSeed} `) + ? normalizedQuery.slice(normalizedSourceSeed.length).trim() + : ""; + const tailTokens = tail.match(/[\p{L}\p{N}]+/gu) ?? []; + const safeGenericTail = new Set(language === "ru" ? ["онлайн", "сервис", "система", "платформа", "программа"] : ["online", "service", "system", "platform", "software"]); + const hasSystemLikeTail = tailTokens.some((token) => /\d/u.test(token)) || tokens.some((token) => /\d/u.test(token) && /\p{L}/u.test(token)); + const tailIsKnownIntent = Object.values(lexicons).some((lexemes) => includesLexeme(tail, lexemes)); + const safeTailPhrase = language === "ru" ? /^(?:с помощью|для бизнеса|для компаний|под ключ|онлайн)/u : /^(?:with|for business|for companies|online)/u; + const looksLikeAudienceTail = tailTokens.length === 1 && /(?:ов|ев|ей|ий|аний|ений)$/iu.test(tailTokens[0] ?? ""); + const hasUnknownEntityTail = tailTokens.length >= 1 && tailTokens.length <= 3 && !safeGenericTail.has(tailTokens[0]!) && !safeTailPhrase.test(tail) && !looksLikeAudienceTail && !hasSystemLikeTail && !tailIsKnownIntent; + const hasBrandLikeToken = brandAliases.some((alias) => normalizedQuery === alias || padded.includes(` ${alias} `)) || hasUnknownEntityTail; + const flags = { + hasComparisonIntent: includesLexeme(normalizedQuery, lexicons.comparison), + hasDomainLikeToken, + hasEducationIntent: includesLexeme(normalizedQuery, lexicons.education), + hasFreeIntent: includesLexeme(normalizedQuery, lexicons.free), + hasGeoIntent: includesLexeme(normalizedQuery, lexicons.geo), + hasInformationalIntent: includesLexeme(normalizedQuery, lexicons.informational), + hasIntegrationIntent: includesLexeme(normalizedQuery, lexicons.integration) || hasSystemLikeTail, + hasJobIntent: includesLexeme(normalizedQuery, lexicons.job), + hasMixedLanguage: hasCyrillic && hasLatin, + hasPriceIntent: includesLexeme(normalizedQuery, lexicons.price), + hasBrandLikeToken, + isTooLong: tokens.length > 12 || normalizedQuery.length > 120 + }; + const strongIntentCount = [flags.hasFreeIntent, flags.hasEducationIntent, flags.hasJobIntent, flags.hasInformationalIntent, flags.hasPriceIntent, flags.hasComparisonIntent].filter(Boolean).length; + const contamination: ObservedContamination = + flags.hasDomainLikeToken || flags.hasBrandLikeToken ? "domain_or_brand" : + flags.hasFreeIntent ? "free_low_intent" : + flags.hasEducationIntent ? "education" : + flags.hasJobIntent ? "job" : + flags.isTooLong ? "too_long" : + strongIntentCount > 1 ? "mixed_intent" : + "clean"; + const canonicalCandidateKey = tokens.map((token) => canonicalToken(token, language)).filter(Boolean).join(" "); + + return { + canonicalCandidateKey, + contamination, + createdAt: observation.observedAt ?? new Date().toISOString(), + detectedFlags: flags, + facetId: observation.topicId ? input.topicFacetMap.get(observation.topicId) : undefined, + id: observation.id, + language, + normalizedQuery, + providerPayload: observation.metadata, + rawQuery: observation.query, + source: observation.source, + sourceSeed: observation.sourceSeed, + seedId: observation.sourceSeed && observation.topicId + ? input.seedIdByTopicAndNormalizedSeed?.get(`${observation.topicId}\u0000${normalize(observation.sourceSeed, language)}`) + : undefined, + tokens, + topicId: observation.topicId + }; + }); +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/observedRelevanceGate.ts b/seo_mode/seo_mode/server/src/queryDiscovery/observedRelevanceGate.ts new file mode 100644 index 0000000..cce756a --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/observedRelevanceGate.ts @@ -0,0 +1,273 @@ +import crypto from "node:crypto"; +import type { SemanticPortfolioFacet, UniversalOfferMap } from "../contextReview/coreSemanticPortfolioRouter.js"; +import type { QueryObservationSource } from "./queryDiscovery.js"; +import type { NormalizedObservedQuery } from "./observedQueryNormalizer.js"; + +export type ObservedIntent = "commercial" | "transactional" | "informational" | "comparison" | "integration" | "local"; +export type ObservedRecommendedAction = + | "human_wordstat_review" + | "auto_wordstat_ready" + | "reroute_to_inactive_facet" + | "keep_as_informational" + | "hide_as_noise" + | "keep_raw_only"; + +export type WordstatReviewCandidate = { + id: string; + query: string; + normalizedQuery: string; + canonicalCandidateKey: string; + facetId: string; + topicId?: string; + seedIds: string[]; + observationIds: string[]; + sources: QueryObservationSource[]; + detectedIntent: ObservedIntent; + recommendedAction: "human_wordstat_review" | "auto_wordstat_ready"; + score: number; + scoreBreakdown: { + sourceQuality: number; + facetFit: number; + intentFit: number; + queryCleanliness: number; + businessModelFit: number; + clusterQuality: number; + userScopeFit: number; + sourceDiversity: number; + penalties: number; + }; + riskFlags: string[]; + evidenceRefs: string[]; +}; + +export type ClassifiedObservedQuery = { + query: NormalizedObservedQuery; + detectedIntent: ObservedIntent; + recommendedAction: ObservedRecommendedAction; + reason: string; + targetFacetId?: string; + score?: number; +}; + +export type ObservedGateDiagnostics = { + humanReview: number; + autoReady: number; + informational: number; + rerouted: number; + hidden: number; + rawOnly: number; +}; + +export type ObservedRelevanceGateResult = { + acceptedForHumanReview: WordstatReviewCandidate[]; + autoReadyForWordstat: WordstatReviewCandidate[]; + rerouted: ClassifiedObservedQuery[]; + informational: ClassifiedObservedQuery[]; + hidden: ClassifiedObservedQuery[]; + rawOnly: ClassifiedObservedQuery[]; + diagnostics: ObservedGateDiagnostics; +}; + +const SOURCE_QUALITY: Record = { + yandex_suggest: 0.75, + google_suggest: 0.7, + bing_suggest: 0.68, + first_party_search_console: 1, + first_party_webmaster: 1, + serp: 0.82, + manual: 0.5 +}; + +function stableId(parts: string[]) { + return `review-candidate:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +function sourceClass(source: QueryObservationSource) { + if (source.startsWith("first_party_")) return "first_party"; + if (source.endsWith("_suggest")) return "suggest"; + return source; +} + +function detectIntent(query: NormalizedObservedQuery): ObservedIntent { + const flags = query.detectedFlags; + if (flags.hasIntegrationIntent) return "integration"; + if (flags.hasGeoIntent) return "local"; + if (flags.hasComparisonIntent) return "comparison"; + if (flags.hasInformationalIntent || flags.hasEducationIntent) return "informational"; + if (flags.hasPriceIntent) return "transactional"; + return "commercial"; +} + +function relevantInactiveFacet(intent: ObservedIntent, facets: SemanticPortfolioFacet[], selected: Set) { + const expectedKind = intent === "integration" ? "integration_system" : intent === "local" ? "local_geo" : intent === "comparison" ? "comparison" : null; + if (!expectedKind) return undefined; + return facets.find((facet) => facet.facetKind === expectedKind && !selected.has(facet.id))?.id; +} + +function getBusinessModelFit(intent: ObservedIntent, offerMap: UniversalOfferMap | null) { + const signals = offerMap?.businessModelSignals; + if (!signals) return 0.65; + if (intent === "local") return signals.geoDependency === "high" || signals.isLocal ? 1 : signals.geoDependency === "unknown" ? 0.5 : 0.2; + if (intent === "transactional") return signals.salesMotion.some((motion) => ["local_booking", "catalog_purchase", "request_quote", "subscription"].includes(motion)) ? 1 : 0.55; + if (intent === "integration") return signals.implementationDepth === "integration" || signals.implementationDepth === "custom_project" ? 0.9 : 0.6; + return 0.82; +} + +function groupObserved(queries: NormalizedObservedQuery[]) { + const groups = new Map(); + for (const query of queries) { + const key = `${query.facetId ?? "unknown"}\u0000${query.normalizedQuery}`; + groups.set(key, [...(groups.get(key) ?? []), query]); + } + return [...groups.values()]; +} + +export function applyObservedRelevanceGate(input: { + observations: NormalizedObservedQuery[]; + selectedFacetIds: string[]; + facets: SemanticPortfolioFacet[]; + offerMap: UniversalOfferMap | null; + comparisonMode?: boolean; + facetVocabulary?: Map; +}): ObservedRelevanceGateResult { + const selected = new Set(input.selectedFacetIds); + const facetById = new Map(input.facets.map((facet) => [facet.id, facet])); + const acceptedForHumanReview: WordstatReviewCandidate[] = []; + const autoReadyForWordstat: WordstatReviewCandidate[] = []; + const rerouted: ClassifiedObservedQuery[] = []; + const informational: ClassifiedObservedQuery[] = []; + const hidden: ClassifiedObservedQuery[] = []; + const rawOnly: ClassifiedObservedQuery[] = []; + + for (const group of groupObserved(input.observations)) { + const primary = group[0]; + let intent = detectIntent(primary); + const facetId = primary.facetId; + const facet = facetId ? facetById.get(facetId) : undefined; + if ( + intent === "integration" && + facet?.businessFit.deliveryModels.some((model) => model === "ecommerce" || model === "physical_product") && + !/(?:интеграц|подключ|синхрон|\bapi\b|integration|connector)/iu.test(primary.normalizedQuery) + ) { + intent = "commercial"; + } + const classifyAll = (target: ClassifiedObservedQuery[], recommendedAction: ObservedRecommendedAction, reason: string, targetFacetId?: string) => { + for (const query of group) target.push({ detectedIntent: intent, query, reason, recommendedAction, targetFacetId }); + }; + + if (primary.detectedFlags.hasDomainLikeToken || primary.detectedFlags.hasBrandLikeToken) { + classifyAll(hidden, "hide_as_noise", "Доменная или бренд-навигационная формулировка сохранена как observation, но исключена из коммерческой очереди."); + continue; + } + if (primary.detectedFlags.hasFreeIntent || primary.detectedFlags.hasJobIntent) { + classifyAll(hidden, "hide_as_noise", "Low-intent/free/job формулировка не относится к коммерческой Wordstat-очереди."); + continue; + } + if (primary.detectedFlags.hasEducationIntent || intent === "informational") { + classifyAll(informational, "keep_as_informational", "Информационный или образовательный интент сохранён в отдельном content bucket."); + continue; + } + if (primary.detectedFlags.isTooLong || primary.contamination === "mixed_intent" || !facetId) { + classifyAll(rawOnly, "keep_raw_only", "Формулировка сохранена, но требует дополнительного разбора интента или facet binding."); + continue; + } + + const vocabulary = input.facetVocabulary?.get(facetId) ?? []; + const stopWords = new Set(["для", "это", "как", "или", "при", "the", "for", "with", "and", "что", "такое", "система", "платформа"]); + const root = (token: string) => token.length > 5 ? token.replace(/(иями|ями|ами|ого|ему|ому|ыми|ими|иях|ах|ях|ия|ие|ий|ый|ая|ое|ые|ов|ев|ам|ям|ом|ем|ы|и|а|я)$/u, "") : token; + const queryRoots = new Set(primary.tokens.map((token) => root(token.toLocaleLowerCase())).filter((token) => token.length >= 2 && !stopWords.has(token))); + const vocabularyRoots = new Set(vocabulary.flatMap((value) => value.toLocaleLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).map(root).filter((token) => token.length >= 2 && !stopWords.has(token))); + const matchedRoots = [...queryRoots].filter((token) => [...vocabularyRoots].some((candidate) => candidate.startsWith(token) || token.startsWith(candidate))); + const lexicalOverlap = matchedRoots.length; + const genericSemanticRoots = new Set(["процесс", "систем", "платформ", "решен", "управлен", "автоматизац", "service", "system", "platform", "process", "solution", "automation"]); + const specificOverlap = matchedRoots.filter((token) => ![...genericSemanticRoots].some((generic) => token.startsWith(generic) || generic.startsWith(token))).length; + if (vocabularyRoots.size > 0 && (lexicalOverlap < 1 || specificOverlap < 1)) { + classifyAll(rawOnly, "keep_raw_only", "Observation сохранил provenance seed, но потерял отличительные термины выбранного semantic facet."); + continue; + } + + const inactiveTarget = relevantInactiveFacet(intent, input.facets, selected); + const selectedFacet = selected.has(facetId); + const allowedBySelectedFacet = + (intent !== "integration" || facet?.facetKind === "integration_system") && + (intent !== "local" || facet?.facetKind === "local_geo" || input.offerMap?.businessModelSignals.geoDependency === "high") && + (intent !== "comparison" || input.comparisonMode === true); + if (!selectedFacet || !allowedBySelectedFacet) { + classifyAll( + rerouted, + "reroute_to_inactive_facet", + selectedFacet ? `Интент ${intent} не разрешён политикой выбранного фасета.` : "Формулировка относится к фасету вне текущего Search Scope.", + inactiveTarget + ); + continue; + } + + const sources = [...new Set(group.map((query) => query.source))]; + const sourceClasses = new Set(sources.map(sourceClass)); + const hasFirstParty = sources.some((source) => source.startsWith("first_party_")); + const sourceQuality = Math.max(...sources.map((source) => SOURCE_QUALITY[source])); + const breakdown = { + businessModelFit: getBusinessModelFit(intent, input.offerMap), + clusterQuality: group.length > 1 ? 0.88 : 0.7, + facetFit: lexicalOverlap >= 3 ? 1 : lexicalOverlap === 2 ? 0.9 : 0.72, + intentFit: intent === "commercial" || intent === "transactional" ? 0.92 : 0.78, + penalties: primary.detectedFlags.hasMixedLanguage ? 0.03 : 0, + queryCleanliness: primary.contamination === "clean" ? 1 : 0.65, + sourceDiversity: sourceClasses.size >= 2 ? 1 : 0.35, + sourceQuality, + userScopeFit: 1 + }; + const score = Number(( + 0.2 * breakdown.sourceQuality + + 0.2 * breakdown.facetFit + + 0.15 * breakdown.intentFit + + 0.15 * breakdown.queryCleanliness + + 0.1 * breakdown.businessModelFit + + 0.1 * breakdown.clusterQuality + + 0.05 * breakdown.userScopeFit + + 0.05 * breakdown.sourceDiversity - + breakdown.penalties + ).toFixed(2)); + const independentEvidence = sourceClasses.size >= 2 || hasFirstParty; + const recommendedAction = independentEvidence && score >= 0.78 ? "auto_wordstat_ready" : "human_wordstat_review"; + const candidate: WordstatReviewCandidate = { + canonicalCandidateKey: primary.canonicalCandidateKey, + detectedIntent: intent, + evidenceRefs: group.flatMap((query) => [`observed:${query.source}:${query.id}`]), + facetId, + id: stableId([facetId, primary.normalizedQuery, intent]), + normalizedQuery: primary.normalizedQuery, + observationIds: group.map((query) => query.id), + query: primary.rawQuery, + recommendedAction, + riskFlags: [ + ...(sourceClasses.size < 2 && !hasFirstParty ? ["one_independent_source_only"] : []), + ...(primary.detectedFlags.hasMixedLanguage ? ["mixed_language"] : []) + ], + score, + scoreBreakdown: breakdown, + seedIds: group.map((query) => query.seedId).filter((value): value is string => Boolean(value)), + sources, + topicId: primary.topicId + }; + if (recommendedAction === "auto_wordstat_ready") autoReadyForWordstat.push(candidate); + else acceptedForHumanReview.push(candidate); + } + + return { + acceptedForHumanReview: acceptedForHumanReview.sort((left, right) => right.score - left.score), + autoReadyForWordstat: autoReadyForWordstat.sort((left, right) => right.score - left.score), + diagnostics: { + autoReady: autoReadyForWordstat.length, + hidden: hidden.length, + humanReview: acceptedForHumanReview.length, + informational: informational.length, + rawOnly: rawOnly.length, + rerouted: rerouted.length + }, + hidden, + informational, + rawOnly, + rerouted + }; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatBatch2Planner.ts b/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatBatch2Planner.ts new file mode 100644 index 0000000..e0d3357 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatBatch2Planner.ts @@ -0,0 +1,251 @@ +import type { + PostWordstatProductFitCandidate, + PostWordstatProductFitCluster, + PostWordstatProductFitGate, + PostWordstatScorecard +} from "./postWordstatProductFitGate.js"; + +export type PostWordstatCandidateDecisionStatus = "pending" | "approved_for_wordstat" | "content_only" | "research_only" | "rejected"; + +export type PostWordstatCandidateDecision = { + candidateId: string; + decisionStatus: PostWordstatCandidateDecisionStatus; + reason: string; + decidedAt: string; +}; + +export type PostWordstatClusterDecision = { + clusterId: string; + decisionStatus: PostWordstatCandidateDecisionStatus; + applyToVariants: boolean; + reason: string; + decidedAt: string; +}; + +export type PostWordstatResolvedDecision = PostWordstatCandidateDecision & { + source: "candidate_override" | "cluster"; +}; + +export type PostWordstatReviewCandidate = PostWordstatProductFitCandidate & { + decision: PostWordstatCandidateDecision | null; + inheritedClusterDecision: PostWordstatClusterDecision | null; + effectiveDecision: PostWordstatResolvedDecision | null; +}; + +export type PostWordstatReviewCluster = PostWordstatProductFitCluster & { + decision: PostWordstatClusterDecision | null; + candidates: PostWordstatReviewCandidate[]; + effectiveDecisionCounts: Record; +}; + +export type PostWordstatBatch2Plan = { + schemaVersion: "post-wordstat-batch2-plan.v2"; + sourcePhase: "post_wordstat_product_fit"; + state: "not_ready" | "awaiting_human_review" | "ready" | "no_candidates"; + reviewClusters: PostWordstatReviewCluster[]; + reviewCandidates: PostWordstatReviewCandidate[]; + queue: Array<{ + candidateId: string; + clusterId: string | null; + phrase: string; + normalizedPhrase: string; + sourcePhrase: string | null; + frequency: number | null; + productFitScore: number; + scorecard: PostWordstatScorecard; + intent: PostWordstatProductFitCandidate["intent"]; + pageType: PostWordstatProductFitCandidate["pageType"]; + relationshipToCore: PostWordstatProductFitCandidate["relationshipToCore"]; + targetSurfaceId: string | null; + priority: "high" | "medium" | "low"; + requiresSerpValidation: boolean; + decisionSource: PostWordstatResolvedDecision["source"]; + provenance: "wordstat_related_product_fit_approved"; + }>; + serpReviewQueue: Array<{ + candidateId: string; + clusterId: string | null; + phrase: string; + expectedIntent: PostWordstatProductFitCandidate["intent"]; + expectedPageType: PostWordstatProductFitCandidate["pageType"]; + targetSurfaceId: string | null; + reason: string; + }>; + decisions: PostWordstatCandidateDecision[]; + clusterDecisions: PostWordstatClusterDecision[]; + diagnostics: { + reviewCount: number; + reviewClusterCount: number; + pendingCount: number; + pendingClusterCount: number; + approvedCount: number; + contentOnlyCount: number; + researchOnlyCount: number; + rejectedCount: number; + queueCount: number; + }; +}; + +function priority(candidate: PostWordstatProductFitCandidate): "high" | "medium" | "low" { + const scores = candidate.scorecard; + if ( + scores.productFit >= 0.75 && + scores.queryNaturalness >= 0.72 && + scores.intentConfidence >= 0.65 && + scores.evidenceStrength >= 0.6 + ) return "high"; + if (scores.productFit >= 0.62 && scores.queryNaturalness >= 0.55) return "medium"; + return "low"; +} + +function resolveDecision( + candidate: PostWordstatProductFitCandidate, + candidateDecision: PostWordstatCandidateDecision | null, + clusterDecision: PostWordstatClusterDecision | null +): PostWordstatResolvedDecision | null { + if (candidateDecision && candidateDecision.decisionStatus !== "pending") { + return { ...candidateDecision, source: "candidate_override" }; + } + if (clusterDecision?.applyToVariants && clusterDecision.decisionStatus !== "pending") { + return { + candidateId: candidate.id, + decidedAt: clusterDecision.decidedAt, + decisionStatus: clusterDecision.decisionStatus, + reason: clusterDecision.reason, + source: "cluster" + }; + } + return null; +} + +function decisionCounts(candidates: PostWordstatReviewCandidate[]) { + const counts: Record = { + approved_for_wordstat: 0, + content_only: 0, + pending: 0, + rejected: 0, + research_only: 0 + }; + for (const candidate of candidates) counts[candidate.effectiveDecision?.decisionStatus ?? "pending"] += 1; + return counts; +} + +export function planPostWordstatBatch2(input: { + gate: PostWordstatProductFitGate; + decisions: Map; + clusterDecisions?: Map; + maxQueue?: number; +}): PostWordstatBatch2Plan { + const maxQueue = Math.min(Math.max(input.maxQueue ?? 12, 1), 24); + const clusterDecisions = input.clusterDecisions ?? new Map(); + const reviewClusterSource = input.gate.productFitClusters.filter((cluster) => cluster.disposition === "batch2_review"); + const clusterByCandidateId = new Map(reviewClusterSource.flatMap((cluster) => cluster.variantIds.map((candidateId) => [candidateId, cluster] as const))); + const reviewCandidates: PostWordstatReviewCandidate[] = input.gate.lanes.commercialBatch2Review.map((candidate) => { + const cluster = clusterByCandidateId.get(candidate.id) ?? null; + const decision = input.decisions.get(candidate.id) ?? null; + const inheritedClusterDecision = cluster ? clusterDecisions.get(cluster.id) ?? null : null; + return { + ...candidate, + decision, + effectiveDecision: resolveDecision(candidate, decision, inheritedClusterDecision), + inheritedClusterDecision + }; + }); + const reviewCandidateById = new Map(reviewCandidates.map((candidate) => [candidate.id, candidate])); + const reviewClusters: PostWordstatReviewCluster[] = reviewClusterSource.map((cluster) => { + const candidates = cluster.variantIds.map((id) => reviewCandidateById.get(id)).filter((item): item is PostWordstatReviewCandidate => Boolean(item)); + return { + ...cluster, + candidates, + decision: clusterDecisions.get(cluster.id) ?? null, + effectiveDecisionCounts: decisionCounts(candidates) + }; + }); + const currentCandidateIds = new Set(input.gate.candidates.map((candidate) => candidate.id)); + const decisions = [...input.decisions.values()] + .filter((decision) => currentCandidateIds.has(decision.candidateId)) + .sort((left, right) => right.decidedAt.localeCompare(left.decidedAt)); + const currentClusterIds = new Set(input.gate.productFitClusters.map((cluster) => cluster.id)); + const currentClusterDecisions = [...clusterDecisions.values()] + .filter((decision) => currentClusterIds.has(decision.clusterId)) + .sort((left, right) => right.decidedAt.localeCompare(left.decidedAt)); + const approved = reviewCandidates + .filter((candidate) => candidate.effectiveDecision?.decisionStatus === "approved_for_wordstat") + .sort((left, right) => + right.scorecard.productFit - left.scorecard.productFit || + right.scorecard.queryNaturalness - left.scorecard.queryNaturalness || + right.scorecard.evidenceStrength - left.scorecard.evidenceStrength || + (right.frequency ?? 0) - (left.frequency ?? 0) + ) + .slice(0, maxQueue); + const queue = approved.map((candidate) => ({ + candidateId: candidate.id, + clusterId: candidate.clusterId, + decisionSource: candidate.effectiveDecision!.source, + frequency: candidate.frequency, + intent: candidate.intent, + normalizedPhrase: candidate.normalizedPhrase, + pageType: candidate.pageType, + phrase: candidate.phrase, + priority: priority(candidate), + productFitScore: candidate.productFitScore, + provenance: "wordstat_related_product_fit_approved" as const, + relationshipToCore: candidate.relationshipToCore, + requiresSerpValidation: candidate.requiresSerpValidation, + scorecard: candidate.scorecard, + sourcePhrase: candidate.sourcePhrase, + targetSurfaceId: candidate.targetSurfaceId + })); + const approvedHistoricalCandidates = input.gate.candidates + .filter((candidate) => input.decisions.get(candidate.id)?.decisionStatus === "approved_for_wordstat") + .map((candidate) => ({ ...candidate, decision: input.decisions.get(candidate.id)! })); + const serpCandidateMap = new Map([...reviewCandidates, ...approvedHistoricalCandidates].map((candidate) => [candidate.id, candidate])); + const serpReviewQueue = [...serpCandidateMap.values()] + .filter((candidate) => { + if ("effectiveDecision" in candidate) return candidate.effectiveDecision?.decisionStatus === "approved_for_wordstat"; + return candidate.decision.decisionStatus === "approved_for_wordstat"; + }) + .map((candidate) => ({ + candidateId: candidate.id, + clusterId: candidate.clusterId, + expectedIntent: candidate.intent, + expectedPageType: candidate.pageType, + phrase: candidate.phrase, + reason: "Confirm intent, ranking page type and competitive class before SEO strategy assignment.", + targetSurfaceId: candidate.targetSurfaceId + })); + const pendingCount = reviewCandidates.filter((candidate) => !candidate.effectiveDecision).length; + const pendingClusterCount = reviewClusters.filter((cluster) => + cluster.candidates.some((candidate) => !candidate.effectiveDecision) + ).length; + const state: PostWordstatBatch2Plan["state"] = input.gate.state !== "ready" + ? "not_ready" + : reviewCandidates.length === 0 + ? "no_candidates" + : queue.length > 0 + ? "ready" + : "awaiting_human_review"; + const resolvedDecisions = reviewCandidates.map((candidate) => candidate.effectiveDecision).filter((decision): decision is PostWordstatResolvedDecision => Boolean(decision)); + return { + clusterDecisions: currentClusterDecisions, + decisions, + diagnostics: { + approvedCount: resolvedDecisions.filter((item) => item.decisionStatus === "approved_for_wordstat").length, + contentOnlyCount: resolvedDecisions.filter((item) => item.decisionStatus === "content_only").length, + pendingClusterCount, + pendingCount, + queueCount: queue.length, + rejectedCount: resolvedDecisions.filter((item) => item.decisionStatus === "rejected").length, + researchOnlyCount: resolvedDecisions.filter((item) => item.decisionStatus === "research_only").length, + reviewClusterCount: reviewClusters.length, + reviewCount: reviewCandidates.length + }, + queue, + reviewCandidates, + reviewClusters, + schemaVersion: "post-wordstat-batch2-plan.v2", + serpReviewQueue, + sourcePhase: "post_wordstat_product_fit", + state + }; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatExpansionPlanner.ts b/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatExpansionPlanner.ts new file mode 100644 index 0000000..5dbc360 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatExpansionPlanner.ts @@ -0,0 +1,138 @@ +import crypto from "node:crypto"; +import type { WordstatEvidence } from "../market/wordstatRepository.js"; +import type { ExpansionSurface } from "./expansionSurfaceBuilder.js"; + +export type PostWordstatExpansionPlan = { + schemaVersion: "post-wordstat-expansion-plan.v1"; + sourcePhase: "post_wordstat"; + state: "not_ready" | "ready"; + reroutedToExistingSurfaces: Array<{ + id: string; + phrase: string; + sourcePhrase: string | null; + frequency: number | null; + surfaceId: string; + overlapScore: number; + status: "observed_candidate"; + }>; + proposedSurfaces: Array<{ + id: string; + title: string; + sourcePhrase: string | null; + frequency: number | null; + relationshipToCore: "adjacent"; + status: "observed_proposal"; + requiresHumanApproval: true; + }>; + ignored: Array<{ phrase: string; reason: string }>; + diagnostics: { + resultCount: number; + reroutedCount: number; + proposedCount: number; + ignoredCount: number; + }; +}; + +const STOP_WORDS = new Set(["the", "for", "with", "and", "для", "как", "или", "это", "что", "при", "под", "система", "платформа", "решение", "сервис"]); + +function id(parts: string[]) { + return `post-wordstat:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +function normalize(value: string) { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU"); +} + +function roots(value: string) { + return new Set((normalize(value).match(/[\p{L}\p{N}]+/gu) ?? []) + .map((token) => token.length > 5 ? token.replace(/(иями|ями|ами|ого|ему|ому|ыми|ими|иях|ах|ях|ия|ие|ий|ый|ая|ое|ые|ов|ев|ам|ям|ом|ем|ы|и|а|я)$/u, "") : token) + .filter((token) => token.length >= 3 && !STOP_WORDS.has(token))); +} + +function surfaceVocabulary(surface: ExpansionSurface) { + return roots([surface.title, ...Object.values(surface.slotBundle).flat()].join(" ")); +} + +export function planPostWordstatExpansion(input: { + wordstat: WordstatEvidence; + surfaces: ExpansionSurface[]; + maxProposals?: number; +}): PostWordstatExpansionPlan { + const maxProposals = Math.min(Math.max(input.maxProposals ?? 40, 1), 120); + const latestJobDone = input.wordstat.latestJob?.status === "done"; + if (!latestJobDone || input.wordstat.topResults.length === 0) { + return { + diagnostics: { ignoredCount: 0, proposedCount: 0, reroutedCount: 0, resultCount: input.wordstat.topResults.length }, + ignored: [], + proposedSurfaces: [], + reroutedToExistingSurfaces: [], + schemaVersion: "post-wordstat-expansion-plan.v1", + sourcePhase: "post_wordstat", + state: "not_ready" + }; + } + const surfaceVocabularyIndex = input.surfaces.map((surface) => ({ surface, vocabulary: surfaceVocabulary(surface) })); + const reroutedToExistingSurfaces: PostWordstatExpansionPlan["reroutedToExistingSurfaces"] = []; + const proposedSurfaces: PostWordstatExpansionPlan["proposedSurfaces"] = []; + const ignored: PostWordstatExpansionPlan["ignored"] = []; + const seen = new Set(); + for (const result of input.wordstat.topResults) { + const phrase = normalize(result.phrase); + if (seen.has(phrase)) continue; + seen.add(phrase); + const phraseRoots = roots(phrase); + if (phraseRoots.size === 0 || phrase.split(/\s+/).length > 12) { + ignored.push({ phrase: result.phrase, reason: "Related query is empty, generic or too long for post-Wordstat routing." }); + continue; + } + if (/(?:^|\s)(?:что\s+такое|это|обучение|курс|ваканси|должностн\w*\s+инструкц)(?:\s|$)/iu.test(phrase)) { + ignored.push({ phrase: result.phrase, reason: "Informational, education or job intent remains outside commercial post-Wordstat expansion." }); + continue; + } + const relationshipRank: Record = { direct_core: 0, near_core: 1, application: 2, adjacent: 3, speculative: 4 }; + const best = surfaceVocabularyIndex + .map(({ surface, vocabulary }) => ({ + overlap: [...phraseRoots].filter((token) => [...vocabulary].some((candidate) => candidate.startsWith(token) || token.startsWith(candidate))).length, + surface + })) + .sort((left, right) => right.overlap - left.overlap || relationshipRank[left.surface.relationshipToCore] - relationshipRank[right.surface.relationshipToCore] || right.surface.evidenceScore - left.surface.evidenceScore)[0]; + const overlapScore = Number(Math.min(1, (best?.overlap ?? 0) / Math.max(2, phraseRoots.size)).toFixed(2)); + if (best && best.overlap >= 2) { + reroutedToExistingSurfaces.push({ + frequency: result.frequency, + id: id(["reroute", phrase, best.surface.id]), + overlapScore, + phrase: result.phrase, + sourcePhrase: result.sourcePhrase, + status: "observed_candidate", + surfaceId: best.surface.id + }); + continue; + } + if (proposedSurfaces.length < maxProposals) { + proposedSurfaces.push({ + frequency: result.frequency, + id: id(["proposal", phrase, result.sourcePhrase ?? ""]), + relationshipToCore: "adjacent", + requiresHumanApproval: true, + sourcePhrase: result.sourcePhrase, + status: "observed_proposal", + title: result.phrase + }); + } + } + return { + diagnostics: { + ignoredCount: ignored.length, + proposedCount: proposedSurfaces.length, + reroutedCount: reroutedToExistingSurfaces.length, + resultCount: input.wordstat.topResults.length + }, + ignored, + proposedSurfaces, + reroutedToExistingSurfaces, + schemaVersion: "post-wordstat-expansion-plan.v1", + sourcePhase: "post_wordstat", + state: "ready" + }; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatProductFitGate.ts b/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatProductFitGate.ts new file mode 100644 index 0000000..fb497f2 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatProductFitGate.ts @@ -0,0 +1,549 @@ +import crypto from "node:crypto"; +import type { WordstatEvidence } from "../market/wordstatRepository.js"; +import type { ExpansionSurface, ExpansionSurfaceRelationship } from "./expansionSurfaceBuilder.js"; + +export type PostWordstatIntent = + | "commercial_product" + | "commercial_solution" + | "commercial_service" + | "comparison" + | "informational" + | "methodology" + | "education" + | "job" + | "ambiguous"; + +export type PostWordstatPageType = + | "product_landing" + | "solution_landing" + | "integration_landing" + | "service_landing" + | "comparison_page" + | "supporting_article" + | "glossary_article" + | "research_only" + | "none"; + +export type PostWordstatRelationship = ExpansionSurfaceRelationship | "foreign_market" | "unclassified"; +export type PostWordstatDisposition = + | "validated_seed" + | "batch2_review" + | "content_candidate" + | "adjacent_research" + | "rejected"; + +export type PostWordstatScorecard = { + productFit: number; + queryNaturalness: number; + intentConfidence: number; + landingFit: number; + evidenceStrength: number; +}; + +export type PostWordstatProductFitCandidate = { + id: string; + clusterId: string | null; + resultId: string; + phrase: string; + normalizedPhrase: string; + sourcePhrase: string | null; + sourceKind: "validated_seed" | "related"; + frequency: number | null; + frequencyGroup: string | null; + intent: PostWordstatIntent; + pageType: PostWordstatPageType; + relationshipToCore: PostWordstatRelationship; + targetSurfaceId: string | null; + targetSurfaceTitle: string | null; + targetSurfaceKind: ExpansionSurface["surfaceKind"] | null; + productFitScore: number; + scorecard: PostWordstatScorecard; + semanticOverlapScore: number; + sourceAffinityScore: number; + specificityScore: number; + recommendedDisposition: PostWordstatDisposition; + requiresHumanApproval: true; + requiresSerpValidation: boolean; + warnings: string[]; + reasons: string[]; + evidenceRefs: string[]; +}; + +export type PostWordstatProductFitCluster = { + id: string; + canonicalPhrase: string; + representativeCandidateId: string; + disposition: PostWordstatDisposition; + intent: PostWordstatIntent; + pageType: PostWordstatPageType; + relationshipToCore: PostWordstatRelationship; + targetSurfaceId: string | null; + targetSurfaceTitle: string | null; + productFitScore: number; + scorecard: PostWordstatScorecard; + totalFrequency: number; + variantIds: string[]; + variants: string[]; + warnings: string[]; + evidenceRefs: string[]; +}; + +export type PostWordstatProductFitGate = { + schemaVersion: "post-wordstat-product-fit.v2"; + sourcePhase: "post_wordstat"; + state: "not_ready" | "ready"; + candidates: PostWordstatProductFitCandidate[]; + productFitClusters: PostWordstatProductFitCluster[]; + lanes: { + validatedSeeds: PostWordstatProductFitCandidate[]; + commercialBatch2Review: PostWordstatProductFitCandidate[]; + informationalContent: PostWordstatProductFitCandidate[]; + adjacentResearch: PostWordstatProductFitCandidate[]; + rejected: PostWordstatProductFitCandidate[]; + }; + diagnostics: { + resultCount: number; + candidateCount: number; + clusterCount: number; + validatedSeedCount: number; + batch2ReviewCount: number; + contentCount: number; + adjacentResearchCount: number; + rejectedCount: number; + requiresSerpCount: number; + }; +}; + +const FUNCTION_WORDS = new Set([ + "a", "an", "and", "by", "for", "from", "in", "of", "on", "or", "the", "to", "with", + "без", "в", "для", "до", "и", "из", "или", "как", "на", "о", "об", "от", "по", "под", "при", "с", "со", "у", "что", "это" +]); + +// These words identify a page/category shape, but do not prove product fit by +// themselves. Keeping them outside semantic overlap prevents a generic +// "system/platform/service" result from attaching to an unrelated product. +const GENERIC_PRODUCT_WORDS = new Set([ + "product", "service", "solution", "system", "platform", + "бизнес", "компания", "компаний", "предприятие", "предприятий", "продукт", "программа", "решение", "сервис", "система", "платформа" +]); + +const COMMERCIAL_PRODUCT = /(?:^|\s)(?:платформ\p{L}*|систем\p{L}*|программ\p{L}*|сервис\p{L}*|решени\p{L}*|platform|system|software|service|solution)(?:\s|$)/iu; +const COMMERCIAL_SERVICE = /(?:^|\s)(?:внедрен\p{L}*|разработ\p{L}*|настро\p{L}*|заказ\p{L}*|услуг\p{L}*|под\s+ключ|implementation|development|consulting)(?:\s|$)/iu; +const TRANSACTION = /(?:^|\s)(?:цен\p{L}*|стоимост\p{L}*|купить|заказать|тариф\p{L}*|price|pricing|buy)(?:\s|$)/iu; +const COMPARISON = /(?:^|\s)(?:сравнен\p{L}*|аналог\p{L}*|альтернатив\p{L}*|против|vs|versus|compare)(?:\s|$)/iu; +const EDUCATION = /(?:^|\s)(?:обучен\p{L}*|курс\p{L}*|вебинар\p{L}*|диплом\p{L}*|реферат\p{L}*|учеб\p{L}*|training|course|certification)(?:\s|$)/iu; +const JOB = /(?:^|\s)(?:ваканси\p{L}*|резюме|зарплат\p{L}*|должностн\p{L}*|работа|стажиров\p{L}*|vacancy|salary|job|career)(?:\s|$)/iu; +const QUESTION = /(?:^|\s)(?:что\s+такое|как|зачем|почему|когда|где|определение|виды|пример\p{L}*|инструкци\p{L}*|what|how|why|guide|examples?)(?:\s|$)/iu; +const METHODOLOGY = /(?:^|\s)(?:подход\p{L}*|метод\p{L}*|методологи\p{L}*|теори\p{L}*|принцип\p{L}*|этап\p{L}*|классификаци\p{L}*|methodology|framework|theory)(?:\s|$)/iu; +const INFORMATIONAL_SHAPE = /^(?:анализ|вариант\p{L}*|вид\p{L}*|цель|модель|уровень|описание|направлен\p{L}*|эффективност\p{L}*|технологи\p{L}*|инструмент\p{L}*|обзор|роль|руководител\p{L}*)(?:\s|$)|(?:\s|^)(?:определени\p{L}*|этап\p{L}*|вариант\p{L}*|виды|цель|уровень|описание|направлени\p{L}*|эффективность)(?:\s|$)/iu; +const COMMERCIAL_SOLUTION = /(?:^|\s)(?:автоматизац\p{L}*|управлен\p{L}*|обработк\p{L}*|мониторинг\p{L}*|контрол\p{L}*|оркестрац\p{L}*|workflow)(?:\s|$)/iu; +const MALFORMED_NUMERIC_PREFIX = /^\d+\s+/u; +const DANGLING_CONNECTOR = /(?:\s|^)(?:для|с|по|при|и|или|с\s+помощью)$/iu; +const GENERIC_PRODUCT_NOUN_PILE = /(?:^|\s)(?:платформ\p{L}*|систем\p{L}*|сервис\p{L}*|решени\p{L}*|программ\p{L}*|platform|system|service|solution|software)(?:\s+|\s+для\s+)(?:платформ\p{L}*|систем\p{L}*|сервис\p{L}*|решени\p{L}*|программ\p{L}*|platform|system|service|solution|software)(?:\s|$)/iu; +const SAFE_LATIN_QUERY_TOKENS = new Set(["ai", "api", "b2b", "bpm", "crm", "erp", "ii", "iot", "llm", "mcp", "no-code", "on-prem", "rpa", "saas", "workflow"]); +const GENERIC_SEMANTIC_ROOT_PREFIXES = [ + "автоматизац", "бизнес", "компан", "организац", "предприят", "процесс", "программ", "решен", "сервис", "систем", "управлен", "платформ", + "automation", "business", "company", "enterprise", "management", "platform", "process", "service", "solution", "software", "system" +]; + +function normalize(value: string) { + // Unicode lower-casing is sufficient for the token comparisons below. + // `toLocaleLowerCase("ru-RU")` creates an ICU locale on every call and made + // the post-Wordstat gate CPU-bound when several workspace reads arrived at + // once (the gate normalizes thousands of surface-axis values per contract). + return value.trim().replace(/Ё/g, "Е").replace(/ё/g, "е").replace(/\s+/g, " ").toLowerCase(); +} + +function stableId(parts: string[]) { + return `post-wordstat-fit:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +function stem(token: string) { + if (/^[a-z0-9]+$/i.test(token)) return token.toLowerCase(); + return token.replace(/(иями|ями|ами|ого|его|ому|ему|ыми|ими|иях|ях|ах|ией|иям|ям|ам|ов|ев|ей|ой|ый|ий|ая|яя|ое|ее|ые|ие|ую|юю|ия|ие|ом|ем|а|я|ы|и|у|ю|е|о)$/iu, ""); +} + +function roots(value: string, includeGeneric = false) { + return new Set((normalize(value).match(/[\p{L}\p{N}]+/gu) ?? []) + .filter((token) => token.length > 1 && !FUNCTION_WORDS.has(token)) + .filter((token) => includeGeneric || !GENERIC_PRODUCT_WORDS.has(token)) + .map(stem) + .filter((token) => token.length > 1)); +} + +function tokenOverlap(left: Set, right: Set) { + if (left.size === 0 || right.size === 0) return { count: 0, score: 0 }; + let count = 0; + for (const token of left) { + let matched = false; + for (const candidate of right) { + if (candidate === token || (Math.min(candidate.length, token.length) >= 5 && (candidate.startsWith(token) || token.startsWith(candidate)))) { + matched = true; + break; + } + } + if (matched) count += 1; + } + return { count, score: count / Math.max(1, left.size) }; +} + +function classifyIntent(phrase: string): PostWordstatIntent { + if (JOB.test(phrase)) return "job"; + if (EDUCATION.test(phrase)) return "education"; + if (COMPARISON.test(phrase)) return "comparison"; + if (METHODOLOGY.test(phrase)) return "methodology"; + if (QUESTION.test(phrase) || INFORMATIONAL_SHAPE.test(phrase) || /(?:^|\s)это(?:\s|$)/iu.test(phrase)) return "informational"; + if (COMMERCIAL_SERVICE.test(phrase)) return "commercial_service"; + if (COMMERCIAL_PRODUCT.test(phrase) || TRANSACTION.test(phrase)) return "commercial_product"; + if (COMMERCIAL_SOLUTION.test(phrase)) return "commercial_solution"; + return "ambiguous"; +} + +function relationshipPrior(value: ExpansionSurfaceRelationship | undefined) { + if (value === "direct_core") return 1; + if (value === "near_core") return 0.84; + if (value === "application") return 0.7; + if (value === "adjacent") return 0.48; + return 0.25; +} + +function roundScore(value: number) { + return Number(Math.min(1, Math.max(0, value)).toFixed(2)); +} + +function getQueryNaturalness(phrase: string) { + const normalized = normalize(phrase); + const tokens = normalized.match(/[\p{L}\p{N}-]+/gu) ?? []; + if (MALFORMED_NUMERIC_PREFIX.test(normalized) || DANGLING_CONNECTOR.test(normalized)) return 0.12; + + let score = 0.94; + if (tokens.length <= 1) score -= 0.42; + if (tokens.length > 8) score -= Math.min(0.42, (tokens.length - 8) * 0.08); + if (/[^\p{L}\p{N}\s'’+./-]/u.test(normalized)) score -= 0.18; + if (GENERIC_PRODUCT_NOUN_PILE.test(normalized)) score -= 0.44; + + const latinTokens = tokens.filter((token) => /[a-z]/iu.test(token)); + const cyrillicTokens = tokens.filter((token) => /[а-яё]/iu.test(token)); + const unsafeLatin = latinTokens.filter((token) => !SAFE_LATIN_QUERY_TOKENS.has(token.toLowerCase())); + if (unsafeLatin.length >= 2) score -= 0.38; + else if (unsafeLatin.length === 1 && cyrillicTokens.length > 0) score -= 0.18; + else if (latinTokens.length > 0 && cyrillicTokens.length > 0) score -= 0.04; + + const uniqueTokens = new Set(tokens.map((token) => token.toLowerCase())); + if (tokens.length >= 3 && uniqueTokens.size / tokens.length < 0.72) score -= 0.28; + return roundScore(score); +} + +function isGenericSemanticRoot(value: string) { + return GENERIC_SEMANTIC_ROOT_PREFIXES.some((prefix) => value.startsWith(prefix) || prefix.startsWith(value)); +} + +function getDistinctiveOverlapCount(phraseRoots: Set, surfaceRoots: Set) { + return [...phraseRoots].filter((phraseRoot) => + !isGenericSemanticRoot(phraseRoot) && + [...surfaceRoots].some((surfaceRoot) => surfaceRoot === phraseRoot || (Math.min(surfaceRoot.length, phraseRoot.length) >= 5 && (surfaceRoot.startsWith(phraseRoot) || phraseRoot.startsWith(surfaceRoot)))) + ).length; +} + +function getIntentConfidence(intent: PostWordstatIntent, phrase: string) { + if (intent === "ambiguous") return 0.32; + if (intent === "job" || intent === "education") return 0.95; + const signals = [COMMERCIAL_PRODUCT, COMMERCIAL_SERVICE, TRANSACTION, COMPARISON, EDUCATION, JOB, QUESTION, METHODOLOGY, INFORMATIONAL_SHAPE, COMMERCIAL_SOLUTION] + .filter((pattern) => pattern.test(phrase)).length; + if (signals > 1) return 0.68; + return intent === "commercial_solution" ? 0.72 : 0.84; +} + +function getLandingFit(intent: PostWordstatIntent, surface: ExpansionSurface | null) { + if (intent === "job" || intent === "education") return 0; + if (!surface) return intent === "informational" || intent === "methodology" ? 0.32 : 0.2; + const relationshipScore = relationshipPrior(surface.relationshipToCore); + if (intent === "informational" || intent === "methodology") return roundScore(Math.max(0.45, relationshipScore * 0.72)); + if (intent === "ambiguous") return roundScore(relationshipScore * 0.55); + return roundScore(relationshipScore * 0.92); +} + +function getEvidenceStrength(input: { + frequency: number | null; + sourceKind: PostWordstatProductFitCandidate["sourceKind"]; + sourcePhrase: string | null; + surface: ExpansionSurface | null; +}) { + let score = input.sourceKind === "validated_seed" ? 0.82 : input.sourcePhrase ? 0.5 : 0.35; + if (input.frequency !== null) score += input.sourceKind === "validated_seed" ? 0.14 : 0.12; + if (input.surface && input.surface.evidenceRefs.length > 0) score += 0.08; + return roundScore(score); +} + +function pageType(intent: PostWordstatIntent, surface: ExpansionSurface | null): PostWordstatPageType { + if (intent === "job" || intent === "education") return "none"; + if (intent === "comparison") return "comparison_page"; + if (intent === "methodology") return "glossary_article"; + if (intent === "informational") return "supporting_article"; + if (intent === "ambiguous") return "research_only"; + if (intent === "commercial_service") return "service_landing"; + if (surface?.surfaceKind === "integration") return "integration_landing"; + if (surface?.relationshipToCore === "application") return "solution_landing"; + return "product_landing"; +} + +function disposition(input: { + intent: PostWordstatIntent; + scorecard: PostWordstatScorecard; + relationship: PostWordstatRelationship; + sourceKind: PostWordstatProductFitCandidate["sourceKind"]; +}): PostWordstatDisposition { + if (input.sourceKind === "validated_seed") return "validated_seed"; + if (input.intent === "job" || input.intent === "education") return "rejected"; + if (input.scorecard.queryNaturalness < 0.35 || input.scorecard.productFit < 0.2) return "rejected"; + if (input.intent === "informational" || input.intent === "methodology") { + return input.scorecard.productFit >= 0.3 && input.scorecard.queryNaturalness >= 0.45 ? "content_candidate" : "rejected"; + } + if ( + input.scorecard.productFit >= 0.62 && + input.scorecard.queryNaturalness >= 0.55 && + input.scorecard.intentConfidence >= 0.55 && + input.scorecard.landingFit >= 0.45 && + input.scorecard.evidenceStrength >= 0.55 && + (input.relationship === "direct_core" || input.relationship === "near_core" || input.relationship === "application") + ) return "batch2_review"; + if (input.scorecard.productFit >= 0.4 && input.scorecard.queryNaturalness >= 0.45) return "adjacent_research"; + return "rejected"; +} + +function emptyGate(resultCount: number): PostWordstatProductFitGate { + return { + candidates: [], + diagnostics: { adjacentResearchCount: 0, batch2ReviewCount: 0, candidateCount: 0, clusterCount: 0, contentCount: 0, rejectedCount: 0, requiresSerpCount: 0, resultCount, validatedSeedCount: 0 }, + lanes: { adjacentResearch: [], commercialBatch2Review: [], informationalContent: [], rejected: [], validatedSeeds: [] }, + productFitClusters: [], + schemaVersion: "post-wordstat-product-fit.v2", + sourcePhase: "post_wordstat", + state: "not_ready" + }; +} + +export function applyPostWordstatProductFitGate(input: { + wordstat: WordstatEvidence; + surfaces: ExpansionSurface[]; +}): PostWordstatProductFitGate { + if (input.wordstat.latestJob?.status !== "done" || input.wordstat.topResults.length === 0) return emptyGate(input.wordstat.topResults.length); + + const relationshipRank: Record = { direct_core: 0, near_core: 1, application: 2, adjacent: 3, speculative: 4 }; + const surfaceIndex = input.surfaces.map((surface) => ({ + axisVocabulary: roots( + surface.surfaceKind === "integration" ? surface.slotBundle.system.join(" ") : + surface.surfaceKind === "asset_object" ? surface.slotBundle.object.join(" ") : "" + ), + surface, + vocabulary: roots([surface.title, ...Object.values(surface.slotBundle).flat()].join(" ")) + })); + const collectedSeedPhrases = new Set(input.wordstat.seedResults.map((item) => normalize(item.phrase))); + const seen = new Set(); + const candidates: PostWordstatProductFitCandidate[] = []; + + for (const result of input.wordstat.topResults) { + const normalizedPhrase = normalize(result.phrase); + if (!normalizedPhrase || seen.has(normalizedPhrase)) continue; + seen.add(normalizedPhrase); + const normalizedSource = result.sourcePhrase ? normalize(result.sourcePhrase) : null; + const sourceKind = collectedSeedPhrases.has(normalizedPhrase) || !normalizedSource || normalizedSource === normalizedPhrase ? "validated_seed" : "related"; + const phraseRoots = roots(normalizedPhrase); + const phraseAllRoots = roots(normalizedPhrase, true); + const sourceRoots = roots(normalizedSource ?? ""); + const matches = surfaceIndex.map(({ axisVocabulary, surface, vocabulary }) => ({ axisVocabulary, surface, ...tokenOverlap(phraseRoots, vocabulary) })) + .sort((left, right) => right.count - left.count || right.score - left.score || relationshipRank[left.surface.relationshipToCore] - relationshipRank[right.surface.relationshipToCore] || right.surface.evidenceScore - left.surface.evidenceScore); + const strongestCount = matches[0]?.count ?? 0; + const coreAlignedMatch = matches + .filter((match) => match.surface.relationshipToCore !== "adjacent" && match.surface.relationshipToCore !== "speculative") + .filter((match) => match.count >= 2 && match.count >= strongestCount - 1) + .sort((left, right) => relationshipRank[left.surface.relationshipToCore] - relationshipRank[right.surface.relationshipToCore] || right.count - left.count || right.score - left.score)[0] ?? null; + const bestMatch = coreAlignedMatch ?? matches[0] ?? null; + const bestSurface = bestMatch && bestMatch.count > 0 ? bestMatch.surface : null; + const semanticOverlapScore = bestSurface ? Math.min(1, bestMatch!.score) : 0; + const sourceAffinityScore = tokenOverlap(phraseRoots, sourceRoots).score; + const specificityScore = Math.min(1, Math.max(0.12, (phraseAllRoots.size - 1) / 4)); + const intent = classifyIntent(normalizedPhrase); + const rawTokens = normalizedPhrase.match(/[\p{L}\p{N}]+/gu) ?? []; + const bestVocabulary = bestSurface ? surfaceIndex.find((item) => item.surface.id === bestSurface.id)?.vocabulary ?? new Set() : new Set(); + const distinctiveOverlapCount = getDistinctiveOverlapCount(phraseRoots, bestVocabulary); + const unmappedExternalTokens = rawTokens + .filter((token) => /\d/u.test(token) || /^[a-z]{2,}$/iu.test(token)) + .map(stem) + .filter((token) => token.length > 1 && !bestVocabulary.has(token)); + const unmatchedPhraseRoots = new Set([...phraseRoots].filter((token) => !bestVocabulary.has(token))); + const secondaryAxisMatch = matches.find((match) => + match.surface.id !== bestSurface?.id && + (match.surface.surfaceKind === "integration" || match.surface.surfaceKind === "asset_object") && + tokenOverlap(unmatchedPhraseRoots, match.axisVocabulary).count > 0 + ) ?? null; + const malformed = MALFORMED_NUMERIC_PREFIX.test(normalizedPhrase) || DANGLING_CONNECTOR.test(normalizedPhrase); + const queryNaturalness = getQueryNaturalness(normalizedPhrase); + const intentConfidence = getIntentConfidence(intent, normalizedPhrase); + const landingFit = getLandingFit(intent, bestSurface); + const evidenceStrength = getEvidenceStrength({ + frequency: result.frequency, + sourceKind, + sourcePhrase: result.sourcePhrase, + surface: bestSurface + }); + let productFitScore = + semanticOverlapScore * 0.42 + + sourceAffinityScore * 0.25 + + relationshipPrior(bestSurface?.relationshipToCore) * 0.23 + + (bestSurface?.evidenceScore ?? 0.2) * 0.1; + // A validated seed has real provider evidence and a prior human gate, but + // that must not turn linguistic quality or landing fit into a fake 96%. + if (sourceKind === "validated_seed") productFitScore = Math.max(productFitScore, 0.68); + if (phraseAllRoots.size <= 1 && sourceKind === "related") productFitScore = Math.min(productFitScore, 0.48); + if (!bestSurface && sourceAffinityScore < 0.34 && sourceKind === "related") productFitScore = Math.min(productFitScore, 0.36); + if (unmappedExternalTokens.length > 0 && sourceKind === "related") productFitScore = Math.min(productFitScore, 0.58); + if (secondaryAxisMatch && sourceKind === "related") productFitScore = Math.min(productFitScore, 0.61); + if (malformed && sourceKind === "related") productFitScore = Math.min(productFitScore, 0.24); + if (distinctiveOverlapCount === 0) { + productFitScore = Math.min(productFitScore, sourceKind === "validated_seed" ? 0.74 : 0.6); + } + productFitScore = roundScore(productFitScore); + const scorecard: PostWordstatScorecard = { + evidenceStrength, + intentConfidence, + landingFit, + productFit: productFitScore, + queryNaturalness + }; + + let relationship: PostWordstatRelationship = bestSurface?.relationshipToCore ?? (sourceAffinityScore >= 0.55 ? "adjacent" : "unclassified"); + if (sourceKind === "related" && productFitScore < 0.25 && sourceAffinityScore === 0 && !bestSurface) relationship = "foreign_market"; + if (unmappedExternalTokens.length > 0 && sourceKind === "related") relationship = "adjacent"; + if (secondaryAxisMatch && sourceKind === "related") relationship = "adjacent"; + const recommendedDisposition = disposition({ intent, relationship, scorecard, sourceKind }); + const warnings: string[] = []; + if (sourceKind === "related") warnings.push("related_frequency_is_not_product_fit_evidence"); + if (phraseAllRoots.size <= 1) warnings.push("broad_category"); + if (!bestSurface) warnings.push("no_semantic_surface_mapping"); + else if (bestSurface.relationshipToCore !== "direct_core" && bestSurface.status !== "selected_for_exploration" && bestSurface.status !== "observed") warnings.push("inactive_expansion_surface"); + if (sourceKind === "related" && sourceAffinityScore < 0.34) warnings.push("related_source_drift"); + if (unmappedExternalTokens.length > 0) warnings.push("unmapped_named_system_or_technology"); + if (secondaryAxisMatch) warnings.push("secondary_axis_requires_activation"); + if (malformed) warnings.push("malformed_or_incomplete_query"); + if (distinctiveOverlapCount === 0) warnings.push("generic_category_without_distinctive_product_signal"); + if (queryNaturalness < 0.55) warnings.push("low_query_naturalness"); + if (intentConfidence < 0.55) warnings.push("low_intent_confidence"); + if (landingFit < 0.45) warnings.push("weak_landing_fit"); + if (evidenceStrength < 0.55) warnings.push("weak_evidence_strength"); + if (intent === "informational" || intent === "methodology") warnings.push("non_commercial_intent"); + if (intent === "education" || intent === "job") warnings.push("foreign_intent"); + if (intent === "ambiguous") warnings.push("ambiguous_intent"); + const requiresSerpValidation = sourceKind === "related" && recommendedDisposition !== "rejected"; + const reasons = [ + sourceKind === "validated_seed" ? "Фраза уже прошла bounded Wordstat queue; это evidence, а не кандидат второго батча." : "Фраза получена из Wordstat related и остаётся observed proposal до human decision.", + bestSurface ? `Лучшее соответствие: ${bestSurface.title} (${bestSurface.relationshipToCore}), semantic overlap ${semanticOverlapScore}.` : "Доказанная semantic surface не найдена.", + `Intent ${intent}; product fit ${productFitScore}; naturalness ${queryNaturalness}; intent confidence ${intentConfidence}; landing fit ${landingFit}; evidence ${evidenceStrength}.` + ]; + candidates.push({ + clusterId: null, + evidenceRefs: [...new Set([result.id, ...(bestSurface?.evidenceRefs ?? [])])], + frequency: result.frequency, + frequencyGroup: result.frequencyGroup, + // The decision identity must survive a related result becoming an exact + // batch-2 seed. sourcePhrase is evidence metadata and may change after + // collection, so it is deliberately excluded from the stable key. + id: stableId([normalizedPhrase, bestSurface?.id ?? "unmapped"]), + intent, + normalizedPhrase, + pageType: pageType(intent, bestSurface), + phrase: result.phrase, + productFitScore, + scorecard, + reasons, + recommendedDisposition, + relationshipToCore: relationship, + requiresHumanApproval: true, + requiresSerpValidation, + resultId: result.id, + semanticOverlapScore: Number(semanticOverlapScore.toFixed(2)), + sourceAffinityScore: Number(sourceAffinityScore.toFixed(2)), + sourceKind, + sourcePhrase: result.sourcePhrase, + specificityScore: Number(specificityScore.toFixed(2)), + targetSurfaceId: bestSurface?.id ?? null, + targetSurfaceKind: bestSurface?.surfaceKind ?? null, + targetSurfaceTitle: bestSurface?.title ?? null, + warnings + }); + } + + candidates.sort((left, right) => { + const rank: Record = { validated_seed: 0, batch2_review: 1, content_candidate: 2, adjacent_research: 3, rejected: 4 }; + return rank[left.recommendedDisposition] - rank[right.recommendedDisposition] || right.productFitScore - left.productFitScore || (right.frequency ?? 0) - (left.frequency ?? 0); + }); + const lanes = { + adjacentResearch: candidates.filter((item) => item.recommendedDisposition === "adjacent_research"), + commercialBatch2Review: candidates.filter((item) => item.recommendedDisposition === "batch2_review"), + informationalContent: candidates.filter((item) => item.recommendedDisposition === "content_candidate"), + rejected: candidates.filter((item) => item.recommendedDisposition === "rejected"), + validatedSeeds: candidates.filter((item) => item.recommendedDisposition === "validated_seed") + }; + const clusterGroups: PostWordstatProductFitCandidate[][] = []; + for (const candidate of candidates) { + const candidateRoots = roots(candidate.normalizedPhrase); + const group = clusterGroups.find((items) => { + const head = items[0]; + if (head.recommendedDisposition !== candidate.recommendedDisposition || head.intent !== candidate.intent || head.targetSurfaceId !== candidate.targetSurfaceId) return false; + const headRoots = roots(head.normalizedPhrase); + const intersection = [...candidateRoots].filter((token) => headRoots.has(token)).length; + const union = new Set([...candidateRoots, ...headRoots]).size; + return union > 0 && intersection / union >= 0.5; + }); + if (group) group.push(candidate); + else clusterGroups.push([candidate]); + } + const productFitClusters = clusterGroups.map((items) => { + const canonical = [...items].sort((left, right) => right.productFitScore - left.productFitScore || (right.frequency ?? 0) - (left.frequency ?? 0) || left.phrase.length - right.phrase.length)[0]; + return { + canonicalPhrase: canonical.phrase, + disposition: canonical.recommendedDisposition, + evidenceRefs: [...new Set(items.flatMap((item) => item.evidenceRefs))], + id: stableId(["cluster", canonical.recommendedDisposition, canonical.intent, canonical.targetSurfaceId ?? "unmapped", canonical.normalizedPhrase]), + intent: canonical.intent, + pageType: canonical.pageType, + productFitScore: Math.max(...items.map((item) => item.productFitScore)), + representativeCandidateId: canonical.id, + relationshipToCore: canonical.relationshipToCore, + scorecard: { + evidenceStrength: roundScore(items.reduce((sum, item) => sum + item.scorecard.evidenceStrength, 0) / items.length), + intentConfidence: roundScore(items.reduce((sum, item) => sum + item.scorecard.intentConfidence, 0) / items.length), + landingFit: roundScore(items.reduce((sum, item) => sum + item.scorecard.landingFit, 0) / items.length), + productFit: Math.max(...items.map((item) => item.scorecard.productFit)), + queryNaturalness: roundScore(items.reduce((sum, item) => sum + item.scorecard.queryNaturalness, 0) / items.length) + }, + targetSurfaceId: canonical.targetSurfaceId, + targetSurfaceTitle: canonical.targetSurfaceTitle, + totalFrequency: items.reduce((sum, item) => sum + (item.frequency ?? 0), 0), + variantIds: items.map((item) => item.id), + variants: items.map((item) => item.phrase), + warnings: [...new Set(items.flatMap((item) => item.warnings))] + }; + }).sort((left, right) => right.productFitScore - left.productFitScore || right.totalFrequency - left.totalFrequency); + const clusterIdByCandidateId = new Map(productFitClusters.flatMap((cluster) => cluster.variantIds.map((candidateId) => [candidateId, cluster.id] as const))); + for (const candidate of candidates) candidate.clusterId = clusterIdByCandidateId.get(candidate.id) ?? null; + return { + candidates, + diagnostics: { + adjacentResearchCount: lanes.adjacentResearch.length, + batch2ReviewCount: lanes.commercialBatch2Review.length, + candidateCount: candidates.length, + clusterCount: productFitClusters.length, + contentCount: lanes.informationalContent.length, + rejectedCount: lanes.rejected.length, + requiresSerpCount: candidates.filter((item) => item.requiresSerpValidation).length, + resultCount: input.wordstat.topResults.length, + validatedSeedCount: lanes.validatedSeeds.length + }, + lanes, + productFitClusters, + schemaVersion: "post-wordstat-product-fit.v2", + sourcePhase: "post_wordstat", + state: "ready" + }; +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatRepository.ts b/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatRepository.ts new file mode 100644 index 0000000..c92c042 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/postWordstatRepository.ts @@ -0,0 +1,163 @@ +import { pool } from "../db/client.js"; +import type { + PostWordstatCandidateDecision, + PostWordstatCandidateDecisionStatus, + PostWordstatClusterDecision +} from "./postWordstatBatch2Planner.js"; + +type DecisionRow = { + candidate_id: string; + decision_status: PostWordstatCandidateDecisionStatus; + reason: string; + decided_at: Date; +}; + +type ClusterDecisionRow = { + cluster_id: string; + decision_status: PostWordstatCandidateDecisionStatus; + apply_to_variants: boolean; + reason: string; + decided_at: Date; +}; + +export async function listPostWordstatCandidateDecisions(projectId: string, semanticRunId: string | null) { + if (!semanticRunId) return new Map(); + const result = await pool.query( + `select candidate_id, decision_status, reason, decided_at + from post_wordstat_candidate_decisions + where project_id = $1 and semantic_run_id = $2`, + [projectId, semanticRunId] + ); + return new Map(result.rows.map((row) => [row.candidate_id, { + candidateId: row.candidate_id, + decidedAt: row.decided_at.toISOString(), + decisionStatus: row.decision_status, + reason: row.reason + }])); +} + +export async function savePostWordstatCandidateDecision(input: { + projectId: string; + semanticRunId: string; + candidateId: string; + normalizedPhrase: string; + decisionStatus: PostWordstatCandidateDecisionStatus; + reason?: string; +}) { + await pool.query( + `insert into post_wordstat_candidate_decisions + (project_id, semantic_run_id, candidate_id, normalized_phrase, decision_status, reason, decided_at) + values ($1, $2, $3, $4, $5, $6, now()) + on conflict (project_id, semantic_run_id, candidate_id) + do update set normalized_phrase = excluded.normalized_phrase, + decision_status = excluded.decision_status, + reason = excluded.reason, + decided_at = now()`, + [input.projectId, input.semanticRunId, input.candidateId, input.normalizedPhrase, input.decisionStatus, input.reason?.trim() ?? ""] + ); +} + +export async function listPostWordstatClusterDecisions(projectId: string, semanticRunId: string | null) { + if (!semanticRunId) return new Map(); + const result = await pool.query( + `select cluster_id, decision_status, apply_to_variants, reason, decided_at + from post_wordstat_cluster_decisions + where project_id = $1 and semantic_run_id = $2`, + [projectId, semanticRunId] + ); + return new Map(result.rows.map((row) => [row.cluster_id, { + applyToVariants: row.apply_to_variants, + clusterId: row.cluster_id, + decidedAt: row.decided_at.toISOString(), + decisionStatus: row.decision_status, + reason: row.reason + }])); +} + +export async function savePostWordstatClusterDecision(input: { + projectId: string; + semanticRunId: string; + clusterId: string; + canonicalPhrase: string; + decisionStatus: PostWordstatCandidateDecisionStatus; + applyToVariants?: boolean; + reason?: string; +}) { + await pool.query( + `insert into post_wordstat_cluster_decisions + (project_id, semantic_run_id, cluster_id, canonical_phrase, decision_status, apply_to_variants, reason, decided_at) + values ($1, $2, $3, $4, $5, $6, $7, now()) + on conflict (project_id, semantic_run_id, cluster_id) + do update set canonical_phrase = excluded.canonical_phrase, + decision_status = excluded.decision_status, + apply_to_variants = excluded.apply_to_variants, + reason = excluded.reason, + decided_at = now()`, + [ + input.projectId, + input.semanticRunId, + input.clusterId, + input.canonicalPhrase, + input.decisionStatus, + input.applyToVariants ?? true, + input.reason?.trim() ?? "" + ] + ); +} + +export async function savePostWordstatClusterDecisionWithVariants(input: { + projectId: string; + semanticRunId: string; + clusterId: string; + canonicalPhrase: string; + decisionStatus: PostWordstatCandidateDecisionStatus; + applyToVariants?: boolean; + reason?: string; + variants: Array<{ candidateId: string; normalizedPhrase: string }>; +}) { + const client = await pool.connect(); + const applyToVariants = input.applyToVariants ?? true; + const reason = input.reason?.trim() ?? ""; + try { + await client.query("begin"); + await client.query( + `insert into post_wordstat_cluster_decisions + (project_id, semantic_run_id, cluster_id, canonical_phrase, decision_status, apply_to_variants, reason, decided_at) + values ($1, $2, $3, $4, $5, $6, $7, now()) + on conflict (project_id, semantic_run_id, cluster_id) + do update set canonical_phrase = excluded.canonical_phrase, + decision_status = excluded.decision_status, + apply_to_variants = excluded.apply_to_variants, + reason = excluded.reason, + decided_at = now()`, + [input.projectId, input.semanticRunId, input.clusterId, input.canonicalPhrase, input.decisionStatus, applyToVariants, reason] + ); + if (applyToVariants && input.variants.length > 0) { + await client.query( + `insert into post_wordstat_candidate_decisions + (project_id, semantic_run_id, candidate_id, normalized_phrase, decision_status, reason, decided_at) + select $1, $2, variant.candidate_id, variant.normalized_phrase, $3, $4, now() + from unnest($5::text[], $6::text[]) as variant(candidate_id, normalized_phrase) + on conflict (project_id, semantic_run_id, candidate_id) + do update set normalized_phrase = excluded.normalized_phrase, + decision_status = excluded.decision_status, + reason = excluded.reason, + decided_at = now()`, + [ + input.projectId, + input.semanticRunId, + input.decisionStatus, + reason, + input.variants.map((variant) => variant.candidateId), + input.variants.map((variant) => variant.normalizedPhrase) + ] + ); + } + await client.query("commit"); + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/preWordstatRepository.ts b/seo_mode/seo_mode/server/src/queryDiscovery/preWordstatRepository.ts new file mode 100644 index 0000000..e4ddec8 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/preWordstatRepository.ts @@ -0,0 +1,148 @@ +import { pool } from "../db/client.js"; +import type { CanonicalQueryCluster } from "./canonicalQueryCluster.js"; +import type { MarketSeed } from "./marketSeedGenerator.js"; +import type { NormalizedObservedQuery } from "./observedQueryNormalizer.js"; +import type { ObservedRelevanceGateResult } from "./observedRelevanceGate.js"; +import type { QueryClusterDecision, QueryClusterDecisionStatus, WordstatQueueItem } from "./wordstatQueueBuilder.js"; + +type ClusterDecisionRow = { + cluster_id: string; + decision_status: QueryClusterDecisionStatus; + target_facet_id: string | null; + reason: string; + decided_at: Date; +}; + +export async function listQueryClusterDecisions(projectId: string, semanticRunId: string | null) { + if (!semanticRunId) return new Map(); + const result = await pool.query( + ` + select cluster_id, decision_status, target_facet_id, reason, decided_at + from query_cluster_decisions + where project_id = $1 and semantic_run_id = $2 + `, + [projectId, semanticRunId] + ); + return new Map(result.rows.map((row) => [row.cluster_id, { + clusterId: row.cluster_id, + decidedAt: row.decided_at.toISOString(), + decisionStatus: row.decision_status, + reason: row.reason, + targetFacetId: row.target_facet_id + }])); +} + +export async function saveQueryClusterDecision(input: { + projectId: string; + semanticRunId: string; + clusterId: string; + decisionStatus: QueryClusterDecisionStatus; + reason?: string; + targetFacetId?: string | null; +}) { + await pool.query( + ` + insert into query_cluster_decisions ( + project_id, semantic_run_id, cluster_id, decision_status, target_facet_id, reason, decided_at + ) values ($1, $2, $3, $4, $5, $6, now()) + on conflict (project_id, semantic_run_id, cluster_id) + do update set + decision_status = excluded.decision_status, + target_facet_id = excluded.target_facet_id, + reason = excluded.reason, + decided_at = now() + `, + [input.projectId, input.semanticRunId, input.clusterId, input.decisionStatus, input.targetFacetId ?? null, input.reason?.trim() ?? ""] + ); +} + +function gateRows(gate: ObservedRelevanceGateResult) { + return [ + ...gate.acceptedForHumanReview.flatMap((candidate) => candidate.observationIds.map((observationId) => ({ + action: candidate.recommendedAction, + intent: candidate.detectedIntent, + observationId, + payload: candidate, + score: candidate.score + }))), + ...gate.autoReadyForWordstat.flatMap((candidate) => candidate.observationIds.map((observationId) => ({ + action: candidate.recommendedAction, + intent: candidate.detectedIntent, + observationId, + payload: candidate, + score: candidate.score + }))), + ...[...gate.rerouted, ...gate.informational, ...gate.hidden, ...gate.rawOnly].map((item) => ({ + action: item.recommendedAction, + intent: item.detectedIntent, + observationId: item.query.id, + payload: item, + score: item.score ?? null + })) + ]; +} + +export async function persistPreWordstatSnapshot(input: { + projectId: string; + semanticRunId: string; + seeds: MarketSeed[]; + normalizedObservations: NormalizedObservedQuery[]; + gate: ObservedRelevanceGateResult; + clusters: CanonicalQueryCluster[]; + queue: WordstatQueueItem[]; +}) { + const client = await pool.connect(); + try { + await client.query("begin"); + // Query Discovery can be requested concurrently by the UI and market + // enrichment. Serialize the deterministic snapshot refresh per semantic + // run so delete/insert cycles cannot race each other. + await client.query("select pg_advisory_xact_lock(hashtext($1))", [`${input.projectId}:${input.semanticRunId}`]); + await client.query("delete from query_market_seeds where project_id = $1 and semantic_run_id = $2", [input.projectId, input.semanticRunId]); + for (const seed of input.seeds) { + await client.query( + `insert into query_market_seeds (project_id, semantic_run_id, seed_id, facet_id, topic_id, normalized_seed, payload) + values ($1, $2, $3, $4, $5, $6, $7::jsonb)`, + [input.projectId, input.semanticRunId, seed.id, seed.facetId, seed.topicId ?? null, seed.normalizedSeed, JSON.stringify(seed)] + ); + } + await client.query("delete from query_normalized_observations where project_id = $1 and semantic_run_id = $2", [input.projectId, input.semanticRunId]); + for (const observation of input.normalizedObservations) { + await client.query( + `insert into query_normalized_observations (project_id, semantic_run_id, observation_id, facet_id, topic_id, normalized_query, contamination, payload) + values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`, + [input.projectId, input.semanticRunId, observation.id, observation.facetId ?? null, observation.topicId ?? null, observation.normalizedQuery, observation.contamination, JSON.stringify(observation)] + ); + } + await client.query("delete from query_observed_gate_results where project_id = $1 and semantic_run_id = $2", [input.projectId, input.semanticRunId]); + for (const item of gateRows(input.gate)) { + await client.query( + `insert into query_observed_gate_results (project_id, semantic_run_id, observation_id, action, intent, score, payload) + values ($1, $2, $3, $4, $5, $6, $7::jsonb)`, + [input.projectId, input.semanticRunId, item.observationId, item.action, item.intent, item.score, JSON.stringify(item.payload)] + ); + } + await client.query("delete from query_canonical_clusters where project_id = $1 and semantic_run_id = $2", [input.projectId, input.semanticRunId]); + for (const cluster of input.clusters) { + await client.query( + `insert into query_canonical_clusters (project_id, semantic_run_id, cluster_id, facet_id, topic_id, normalized_canonical, intent, stage, payload) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`, + [input.projectId, input.semanticRunId, cluster.id, cluster.facetId, cluster.topicId ?? null, cluster.normalizedCanonical, cluster.intent, cluster.stage, JSON.stringify(cluster)] + ); + } + await client.query("delete from query_wordstat_queue_items where project_id = $1 and semantic_run_id = $2", [input.projectId, input.semanticRunId]); + for (const item of input.queue) { + await client.query( + `insert into query_wordstat_queue_items (project_id, semantic_run_id, queue_item_id, cluster_id, facet_id, topic_id, normalized_phrase, status, payload) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`, + [input.projectId, input.semanticRunId, item.id, item.clusterId, item.facetId, item.topicId ?? null, item.normalizedPhrase, item.status, JSON.stringify(item)] + ); + } + await client.query("commit"); + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/queryDiscovery.ts b/seo_mode/seo_mode/server/src/queryDiscovery/queryDiscovery.ts new file mode 100644 index 0000000..777aa68 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/queryDiscovery.ts @@ -0,0 +1,2021 @@ +import crypto from "node:crypto"; +import { pool } from "../db/client.js"; +import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js"; +import { getLatestAlignedSeoBusinessSynthesis } from "../business/seoBusinessSynthesis.js"; +import { applyCandidateQualityGate, type CandidateQualityDiagnostics } from "./candidateQualityGate.js"; +import { + UNIVERSAL_OFFER_CLASSES, + UNIVERSAL_PATTERN_GROUPS, + UNIVERSAL_QUERY_PATTERNS, + selectUniversalFacetPatterns, + type UniversalOfferClass, + type UniversalPatternDescriptor, + type UniversalQueryIntent +} from "./universalPatternLibrary.js"; +import { getLatestSeoDemandResearchBrief, getSeoSearchScope } from "../contextReview/demandResearchBrief.js"; +import { + getSeoPositioningThesis, + type SeoPositioningThesisContract +} from "../contextReview/positioningThesis.js"; +import { getLatestAlignedSeoContextReview } from "../contextReview/seoContextReview.js"; +import type { SeoDemandResearchBriefContract } from "../contextReview/demandResearchBrief.js"; +import type { SeoContextReviewRun } from "../contextReview/seoContextReview.js"; +import type { SeoBusinessSynthesisContract } from "../business/seoBusinessSynthesis.js"; +import { generateMarketSeeds, type MarketSeed } from "./marketSeedGenerator.js"; +import { normalizeObservedQueries, type NormalizedObservedQuery } from "./observedQueryNormalizer.js"; +import { applyObservedRelevanceGate, type ObservedRelevanceGateResult } from "./observedRelevanceGate.js"; +import { buildCanonicalQueryClusters, type CanonicalQueryCluster } from "./canonicalQueryCluster.js"; +import { buildWordstatQueue, type QueryClusterDecision, type WordstatQueueItem } from "./wordstatQueueBuilder.js"; +import { listQueryClusterDecisions, persistPreWordstatSnapshot } from "./preWordstatRepository.js"; +import { getSeoContextOpportunityMap } from "../contextReview/contextOpportunityMap.js"; +import { classifyDomainAgnosticity, type DomainAgnosticity } from "./domainAgnosticityClassifier.js"; +import { buildExpansionSurfaces, type ExpansionSurface } from "./expansionSurfaceBuilder.js"; +import { buildApplicabilityMatrix, type ApplicabilityMatrixCell } from "./applicabilityMatrixBuilder.js"; +import { generateExpansionSeeds, type ExpansionMarketSeed, type ExplorationDepth } from "./expansionSeedGenerator.js"; +import { buildExpansionSuggestProbes, type ExpansionSuggestProbe } from "./recursiveSuggestExpansion.js"; +import { reviewExpansionObservedLanguage, type ExpansionObservedReview } from "./expansionObservedRelevanceGate.js"; +import { + listExpansionObservations, + listExpansionSurfaceDecisions, + persistMarketExpansionSnapshot, + type ExpansionSurfaceDecision +} from "./marketExpansionRepository.js"; + +export const QUERY_DISCOVERY_OFFER_CLASSES = UNIVERSAL_OFFER_CLASSES; + +export const QUERY_OBSERVATION_SOURCES = [ + "yandex_suggest", + "google_suggest", + "bing_suggest", + "first_party_search_console", + "first_party_webmaster", + "serp", + "manual" +] as const; + +type QueryOfferClass = UniversalOfferClass; +export type QueryObservationSource = (typeof QUERY_OBSERVATION_SOURCES)[number]; +type QueryPriority = "high" | "medium" | "low"; +type QueryIntent = UniversalQueryIntent; +type QueryCandidateStatus = + | "approved_probe" + | "approved_for_collection" + | "hidden" + | "deleted" + | "needs_observation" + | "ready_for_wordstat"; +export type QueryCandidateDecisionStatus = "pending" | "kept" | "hidden" | "deleted" | "wordstat"; + +const QUERY_PATTERN_GROUPS = UNIVERSAL_PATTERN_GROUPS; + +type TopicDirection = { + id: string; + title: string; + source: "core_offer" | "core_pillar" | "core_facet" | "current_application" | "expansion_hypothesis" | "opportunity"; + facetKind?: string; + relationshipToCore?: string; + allowedPatternGroups?: string[]; + blockedPatternGroups?: string[]; + searchLanguageHints?: string[]; + priority: QueryPriority; + evidenceRefs: string[]; + description: string; +}; + +export type QueryTopic = { + topicId: string; + facetId: string; + facetKind: string | null; + relationshipToCore: string | null; + title: string; + sourceDirectionId: string; + sourceDirectionTitle: string; + sourceDirectionKind: TopicDirection["source"]; + priority: QueryPriority; + evidenceRefs: string[]; + offerClasses: QueryOfferClass[]; + /** + * The one differentiated market category allowed to seed the default core + * checker. The family below may add only context-bound market surfaces. + */ + primaryMarketEntities: string[]; + /** Externally recognisable formulations of the same product meaning. */ + marketEntityFamily: string[]; + categories: string[]; + coreEntities: string[]; + marketSynonyms: string[]; + actions: string[]; + businessProcesses: string[]; + pains: string[]; + buyerSegments: string[]; + systemsForIntegrations: string[]; + negativeOrSeparateIntents: string[]; + recommendedPatternGroups: string[]; + notes: string[]; +}; + +export type QueryRejectedFacet = { + facetId: string; + title: string; + reason: string; + evidenceRefs: string[]; +}; + +export type TopicDecompositionModelTaskContract = { + taskType: "seo.topic_decomposition"; + schemaVersion: "seo-topic-decomposition-task.v1"; + taskId: string; + projectId: string; + semanticRunId: string; + projectOntologyVersionId: string | null; + providerMode: "model_provider_contract"; + allowedActions: ["decompose_topics", "classify_offer", "extract_semantic_slots", "recommend_pattern_groups"]; + input: { + approvedDirections: TopicDirection[]; + offerMap: { + plainLanguageCoreOffer: string; + marketCategories: string[]; + coreCapabilities: string[]; + differentiators: string[]; + buyerRoles: string[]; + deployment: string[]; + doNotOverweightTerms: string[]; + marketLanguageRule: string; + }; + searchScope: { + mode: "core_default" | "balanced_auto" | "human_selected" | "portfolio_review"; + rationale: string; + }; + positioningThesis: { + factualIdentity: { + productCategory: string | null; + coreOffer: string | null; + centralThesis: string | null; + }; + currentCategories: string[]; + differentiatedThemes: string[]; + productMechanisms: string[]; + ownerStrategy: { + provenance: "owner_strategy"; + isFactualEvidence: false; + strategicStatement: string; + referenceAnalogues: Array<{ label: string; transferableTraits: string[]; caveat: string }>; + priorityThemes: string[]; + categoryExclusions: string[]; + expansionPolicy: string; + } | null; + guardPolicy: SeoPositioningThesisContract["guardPolicy"]; + }; + marketLanguage: { + locale: "ru" | "mixed" | "unknown"; + rule: string; + }; + productFrame: string[]; + forbiddenClaims: string[]; + siteEvidence: Array<{ id: string; title: string; phrases: string[] }>; + }; + outputSchema: { + schemaVersion: "seo-topic-decomposition.v1"; + requiredTopLevelKeys: string[]; + topicRequiredKeys: string[]; + allowedOfferClasses: readonly string[]; + allowedPatternGroups: readonly string[]; + slotValueLimits: Record; + }; + stopConditions: string[]; +}; + +export type TopicDecompositionContract = { + schemaVersion: "seo-topic-decomposition.v1"; + projectId: string; + semanticRunId: string | null; + projectOntologyVersionId: string | null; + marketLanguage: "ru" | "mixed" | "unknown"; + sourceTaskId: string | null; + generatedAt: string; + state: "not_ready" | "ready"; + provider: { + mode: "codex_workspace" | "manual" | "not_run"; + message: string; + }; + topics: QueryTopic[]; + rejectedFacets: QueryRejectedFacet[]; + summary: string; + nextActions: string[]; +}; + +type QueryPattern = UniversalPatternDescriptor; + +export type QueryProbe = { + id: string; + topicId: string; + topicTitle: string; + phrase: string; + normalizedPhrase: string; + patternId: string; + patternGroup: string; + intent: QueryIntent; + pageType: QueryPattern["pageType"]; + evidenceRefs: string[]; + requiresSuggestConfirmation: boolean; + reason: string; +}; + +export type QueryObservation = { + id: string; + topicId: string; + probeId: string | null; + patternId: string | null; + query: string; + normalizedQuery: string; + source: QueryObservationSource; + sourceSeed: string | null; + geo: string | null; + language: string | null; + rawArtifactKey: string | null; + metadata: Record; + observedAt: string; +}; + +export type QueryCandidate = { + id: string; + probeId: string | null; + manualCandidateId: string | null; + topicId: string; + facetId: string; + facetKind: string | null; + topicTitle: string; + phrase: string; + normalizedPhrase: string; + priority: QueryPriority; + intent: QueryIntent; + pageType: QueryPattern["pageType"]; + patternId: string | null; + generationMethod: "base_entity" | "pattern_draft" | "observed_query" | "manual"; + candidateStage: "query_surface_draft" | "observed_query" | "wordstat_candidate"; + sourceEvidence: string[]; + preWordstatScore: number; + status: QueryCandidateStatus; + requiresHumanApproval: boolean; + reason: string; + quality?: { + status: "accepted" | "hidden_by_quality_gate"; + diagnostics: string[]; + }; +}; + +export type QueryDiscoveryContract = { + schemaVersion: "query-discovery.v1"; + projectId: string; + semanticRunId: string | null; + generatedAt: string; + state: "blocked" | "ready"; + positioningThesis: SeoPositioningThesisContract; + searchScope: { + mode: "core_default" | "balanced_auto" | "human_selected" | "portfolio_review"; + selectedVectorIds: string[]; + vectors: Array>; + availableVectors: Array>; + rationale: string; + } | null; + topicDecomposition: TopicDecompositionContract; + patterns: Array>; + probes: QueryProbe[]; + marketSeeds: MarketSeed[]; + observations: QueryObservation[]; + normalizedObservations: NormalizedObservedQuery[]; + observedLanguageReview: { + gate: ObservedRelevanceGateResult; + clusters: CanonicalQueryCluster[]; + decisions: QueryClusterDecision[]; + }; + wordstatQueue: WordstatQueueItem[]; + marketExpansion: { + domainAgnosticity: DomainAgnosticity; + depth: ExplorationDepth; + surfaces: ExpansionSurface[]; + decisions: ExpansionSurfaceDecision[]; + selectedSurfaceIds: string[]; + matrix: ApplicabilityMatrixCell[]; + seeds: ExpansionMarketSeed[]; + suggestProbes: ExpansionSuggestProbe[]; + observations: Awaited>; + observedLanguageReview: ExpansionObservedReview; + coverage: { + coreCovered: number; + nearCoreInactive: number; + applicationsInactive: number; + adjacentInactive: number; + speculativeHidden: number; + selectedForExploration: number; + byKind: Record; + observedSurfaceCount: number; + observedClusterCount: number; + warning: string; + }; + }; + candidates: QueryCandidate[]; + readiness: { + topicCount: number; + probeCount: number; + observedQueryCount: number; + approvedProbeCount: number; + marketSeedCount: number; + humanReviewCount: number; + autoReadyCount: number; + canonicalClusterCount: number; + wordstatReadyCount: number; + }; + diagnostics: { + candidateQuality: CandidateQualityDiagnostics; + preWordstat: { + seeds: { + generated: number; + blocked: number; + byFacet: Record; + byKind: Record; + }; + observations: { + raw: number; + normalized: number; + clean: number; + contaminated: number; + bySource: Record; + }; + gate: ObservedRelevanceGateResult["diagnostics"]; + clusters: { + total: number; + byIntent: Record; + byStage: Record; + }; + wordstat: { + queueItems: number; + blockedModelOnly: number; + blockedContaminated: number; + blockedNoApproval: number; + }; + diversity: CandidateQualityDiagnostics; + }; + }; + nextActions: string[]; +}; + +export type QueryObservationInput = { + topicId: string; + probeId?: string | null; + patternId?: string | null; + query: string; + source: QueryObservationSource; + sourceSeed?: string | null; + geo?: string | null; + language?: string | null; + rawArtifactKey?: string | null; + metadata?: Record; +}; + +type SavedTopicRow = { + output: TopicDecompositionContract | null; +}; + +type ObservationRow = { + id: string; + topic_id: string; + probe_id: string | null; + pattern_id: string | null; + query: string; + normalized_query: string; + source: QueryObservationSource; + source_seed: string | null; + geo: string | null; + language: string | null; + raw_artifact_key: string | null; + metadata: Record; + observed_at: Date; +}; + +type ProbeDecisionRow = { + topic_id: string; + probe_id: string; + approved: boolean; + decision_status: QueryCandidateDecisionStatus; + reason: string; +}; + +type ManualCandidateRow = { + id: string; + topic_id: string; + query: string; + normalized_query: string; + decision_status: QueryCandidateDecisionStatus; + reason: string; +}; + +const QUERY_PATTERNS: QueryPattern[] = UNIVERSAL_QUERY_PATTERNS; + +function normalizePhrase(value: string) { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU"); +} + +function stableId(prefix: string, parts: string[]) { + return `${prefix}:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +function unique(values: Array) { + const seen = new Set(); + + return values.reduce((result, value) => { + const clean = value?.trim().replace(/\s+/g, " ") ?? ""; + const key = normalizePhrase(clean); + + if (!clean || seen.has(key)) { + return result; + } + + seen.add(key); + result.push(clean); + return result; + }, []); +} + +function asPriority(value: unknown): QueryPriority { + return value === "high" || value === "low" ? value : "medium"; +} + +function asOfferClasses(value: unknown): QueryOfferClass[] { + return Array.isArray(value) + ? value.filter((item): item is QueryOfferClass => typeof item === "string" && QUERY_DISCOVERY_OFFER_CLASSES.includes(item as QueryOfferClass)) + : []; +} + +function asStringArray(value: unknown) { + return Array.isArray(value) ? unique(value.filter((item): item is string => typeof item === "string")) : []; +} + +function getApprovedDirections(brief: SeoDemandResearchBriefContract | null): TopicDirection[] { + if (!brief || brief.state !== "approved") { + return []; + } + + const scope = brief.searchScope; + + if (scope && scope.selectedVectorIds.length > 0) { + const selected = new Set(scope.selectedVectorIds); + + return scope.vectors + .filter((vector) => selected.has(vector.id)) + .map((vector) => ({ + description: vector.description, + evidenceRefs: vector.evidenceRefs, + facetKind: vector.facetKind, + allowedPatternGroups: vector.allowedPatternGroups, + blockedPatternGroups: vector.blockedPatternGroups, + searchLanguageHints: vector.searchLanguageHints, + id: vector.id, + priority: asPriority(vector.priority), + relationshipToCore: vector.relationshipToCore, + source: vector.source, + title: vector.title + })); + } + + // A stored v1 brief does not get a pass to leak its old opportunity list + // back into query discovery. Its factual core is enough to rebuild safely. + return [{ + id: "core_offer", + facetKind: "product_category", + relationshipToCore: "core_facet", + title: brief.coreContext.coreOffer, + description: brief.coreContext.centralThesis, + evidenceRefs: brief.fixedCorePillars.flatMap((pillar) => pillar.evidenceRefs), + priority: "high", + source: "core_offer" + }]; +} + +function getMarketLanguage(brief: SeoDemandResearchBriefContract | null): "ru" | "mixed" | "unknown" { + const language = brief?.projectProfile.language; + if (language === "ru") return "ru"; + if (language !== "mixed") return "unknown"; + + // A Russian site with names like API, AI or BIM is technically "mixed" in + // the style profile, but that does not establish an English search market. + // Treat the default market surface as Russian when the approved core itself + // is Cyrillic-dominant; an explicitly selected multilingual vector can + // still declare a different locale later. + const coreText = [brief?.coreContext.coreOffer, brief?.coreContext.centralThesis].filter(Boolean).join(" "); + const cyrillicCount = (coreText.match(/[а-яё]/gi) ?? []).length; + const latinCount = (coreText.match(/[a-z]/gi) ?? []).length; + + return cyrillicCount > latinCount ? "ru" : "mixed"; +} + +function getEffectiveOfferMap(input: { + demandResearchBrief: SeoDemandResearchBriefContract | null; + businessSynthesis: SeoBusinessSynthesisContract | null; + contextReview: SeoContextReviewRun | null; +}) { + if ( + (input.demandResearchBrief?.schemaVersion === "seo-demand-research-brief.v2" || input.demandResearchBrief?.schemaVersion === "seo-demand-research-brief.v3") && + input.demandResearchBrief.offerMap + ) { + return input.demandResearchBrief.offerMap; + } + + const businessSynthesis = input.businessSynthesis; + const legacyBrief = input.demandResearchBrief; + const corePillars = businessSynthesis?.semanticHierarchy.corePillars ?? []; + + // Stored v1 briefs have no Offer Map. Rebuild an effective one from the + // aligned factual synthesis instead of silently collapsing the task to its + // generic productCategory (which is how "enterprise operating platform" + // displaced the differentiating product meaning). + return { + plainLanguageCoreOffer: businessSynthesis?.productFrame.coreOffer ?? legacyBrief?.coreContext.coreOffer ?? "", + marketCategories: unique([ + businessSynthesis?.productFrame.productCategory ?? legacyBrief?.coreContext.productCategory ?? "" + ]), + coreCapabilities: corePillars.map((pillar) => ({ + id: pillar.id, + title: pillar.title, + evidenceRefs: pillar.evidenceRefs + })), + differentiators: unique([ + businessSynthesis?.productFrame.centralThesis ?? legacyBrief?.coreContext.centralThesis ?? "", + ...corePillars.map((pillar) => pillar.whyItMatters) + ]), + buyerRoles: input.contextReview?.audience.primary && !/требует ручной проверки/i.test(input.contextReview.audience.primary) + ? [input.contextReview.audience.primary] + : [], + deployment: [], + doNotOverweightTerms: [], + marketLanguageRule: + "Не считать внутренние названия модулей, слоёв, экранов и архитектурных сущностей языком рынка, пока они не подтверждены external evidence.", + evidenceRefs: unique([ + ...(businessSynthesis?.productFrame.evidenceRefs ?? []), + ...corePillars.flatMap((pillar) => pillar.evidenceRefs) + ]) + }; +} + +export function buildTopicDecompositionTask(input: { + projectId: string; + semanticRunId: string; + projectOntologyVersionId: string | null; + demandResearchBrief: SeoDemandResearchBriefContract | null; + contextReview: SeoContextReviewRun | null; + businessSynthesis: SeoBusinessSynthesisContract | null; + positioningThesis: SeoPositioningThesisContract; +}): TopicDecompositionModelTaskContract | null { + const approvedDirections = getApprovedDirections(input.demandResearchBrief); + + if (approvedDirections.length === 0) { + return null; + } + + const offerMap = getEffectiveOfferMap(input); + const productFrame = unique([ + offerMap.plainLanguageCoreOffer, + ...offerMap.marketCategories, + ...offerMap.differentiators, + input.businessSynthesis?.productFrame?.coreOffer, + input.businessSynthesis?.productFrame?.centralThesis + ]); + const searchScope = input.demandResearchBrief?.searchScope ?? { + mode: "core_default" as const, + rationale: "Legacy brief: Query Discovery safely uses factual product core only." + }; + const siteEvidence = approvedDirections.map((direction) => ({ + id: direction.id, + phrases: unique([direction.title, direction.description, ...(direction.searchLanguageHints ?? [])]).slice(0, 8), + title: direction.title + })); + + return { + taskType: "seo.topic_decomposition", + schemaVersion: "seo-topic-decomposition-task.v1", + taskId: `seo-topic-decomposition:v10:${input.semanticRunId}:${input.demandResearchBrief?.contextHash ?? "draft"}:${crypto + .createHash("sha256") + .update([ + getMarketLanguage(input.demandResearchBrief), + "facet-quality-revision-4", + input.positioningThesis.ownerStrategy?.updatedAt ?? "no-owner-strategy", + ...approvedDirections + .map((direction) => [ + direction.id, + ...(direction.allowedPatternGroups ?? []).slice().sort(), + "!", + ...(direction.blockedPatternGroups ?? []).slice().sort() + ].join(":")) + .sort() + ].join("\u0000")) + .digest("hex") + .slice(0, 12)}`, + projectId: input.projectId, + semanticRunId: input.semanticRunId, + projectOntologyVersionId: input.projectOntologyVersionId, + providerMode: "model_provider_contract", + allowedActions: ["decompose_topics", "classify_offer", "extract_semantic_slots", "recommend_pattern_groups"], + input: { + approvedDirections, + offerMap: { + plainLanguageCoreOffer: offerMap.plainLanguageCoreOffer, + marketCategories: offerMap.marketCategories, + coreCapabilities: offerMap.coreCapabilities.map((capability) => + typeof capability === "string" ? capability : capability.title + ), + differentiators: offerMap.differentiators, + buyerRoles: offerMap.buyerRoles, + deployment: offerMap.deployment, + doNotOverweightTerms: offerMap.doNotOverweightTerms, + marketLanguageRule: offerMap.marketLanguageRule + }, + searchScope, + positioningThesis: { + currentCategories: input.positioningThesis.categoryArchitecture.currentCategories.map((item) => item.text), + differentiatedThemes: input.positioningThesis.categoryArchitecture.differentiatedThemes.map((item) => item.text), + factualIdentity: { + centralThesis: input.positioningThesis.factualIdentity.centralThesis?.text ?? null, + coreOffer: input.positioningThesis.factualIdentity.coreOffer?.text ?? null, + productCategory: input.positioningThesis.factualIdentity.productCategory?.text ?? null + }, + guardPolicy: input.positioningThesis.guardPolicy, + ownerStrategy: input.positioningThesis.ownerStrategy + ? { + categoryExclusions: input.positioningThesis.ownerStrategy.categoryExclusions, + expansionPolicy: input.positioningThesis.ownerStrategy.expansionPolicy, + isFactualEvidence: false, + priorityThemes: input.positioningThesis.ownerStrategy.priorityThemes, + provenance: "owner_strategy", + referenceAnalogues: input.positioningThesis.ownerStrategy.referenceAnalogues, + strategicStatement: input.positioningThesis.ownerStrategy.strategicStatement + } + : null, + productMechanisms: input.positioningThesis.categoryArchitecture.productMechanisms.map((item) => item.text) + }, + marketLanguage: { + locale: getMarketLanguage(input.demandResearchBrief), + rule: + getMarketLanguage(input.demandResearchBrief) === "ru" + ? "Русскоязычный surface: не возвращай англоязычные-only категории, синонимы или первичные сущности. Техническое сокращение допустимо только внутри русской фразы." + : "Язык market surface определяется site evidence; не смешивай языки в одной сущности без внешнего evidence." + }, + productFrame, + forbiddenClaims: unique(input.contextReview?.forbiddenClaims.map((claim) => claim.claim) ?? []), + siteEvidence + }, + outputSchema: { + schemaVersion: "seo-topic-decomposition.v1", + requiredTopLevelKeys: ["schemaVersion", "topics", "rejectedFacets", "summary"], + topicRequiredKeys: [ + "topicId", + "facetId", + "sourceDirectionId", + "offerClasses", + "primaryMarketEntities", + "marketEntityFamily", + "coreEntities", + "marketSynonyms", + "actions", + "businessProcesses", + "buyerSegments", + "systemsForIntegrations", + "negativeOrSeparateIntents", + "recommendedPatternGroups" + ], + allowedOfferClasses: QUERY_DISCOVERY_OFFER_CLASSES, + allowedPatternGroups: QUERY_PATTERN_GROUPS, + slotValueLimits: { + offerClasses: 2, + primaryMarketEntities: 1, + marketEntityFamily: 6, + categories: 6, + coreEntities: 8, + marketSynonyms: 6, + actions: 3, + businessProcesses: 4, + pains: 3, + buyerSegments: 2, + systemsForIntegrations: 1, + negativeOrSeparateIntents: 1, + recommendedPatternGroups: 5, + notes: 1 + } + }, + stopConditions: [ + "Не генерировать поисковые фразы, Wordstat seeds, частотность, спрос или SEO-план.", + "Каждый topicId, facetId и sourceDirectionId должны быть ровно одним и тем же id из approvedDirections; не создавать скрытые направления.", + "Семантические слоты не должны смешивать разные intent в одной строке.", + "Не добавлять домены, отрасли, системы, географию или коммерческие обещания без source evidence.", + "Offer Map определяет ядро продукта, а Search Scope — единственный список разрешённых векторов. Opportunity Portfolio не является поисковым scope.", + "Positioning Thesis задаёт категорийную идентичность, дифференциаторы и категории-исключения. Owner strategy — приоритет интерпретации, но не site/market evidence и не разрешение создавать неподтверждённые claims.", + "Reference analogue нельзя копировать как бренд, домен, продукт или готовую семантику. Использовать можно только явно перечисленные transferableTraits с учётом caveat.", + "Не превращать categoryExclusions в primaryMarketEntities или differentiator. Integration/comparison surface допустим только при явно выбранном соответствующем Search Scope.", + "Каждый approvedDirection — самостоятельный selected semantic facet. Верни ровно один topic для каждого facet; не склеивай разные facets в одну lexical family и не подменяй facet общим описанием всего продукта.", + "Если selected facet нельзя выразить как доказанную отличимую рыночную категорию без общих модификаторов или ухода в соседний конкурентный класс, не выдумывай topic. Добавь его в rejectedFacets с точным facetId, краткой причиной и evidenceRefs. Это корректный quality result, а не ошибка покрытия.", + "Для core_facet покажи рыночную поверхность именно этого доказанного capability/process/governance смысла. Для current_application не заменяй модуль общим core_offer. Для expansion_hypothesis верни topic только при достаточном evidence, иначе сохрани topic с явно ограниченными semantic slots и note о риске.", + "searchLanguageHints в approvedDirection — только evidence-backed concept hints. Используй их, чтобы различить фасет и подобрать рыночную поверхность, но не копируй их механически как готовые запросы и не отправляй никуда вне semantic slots.", + "recommendedPatternGroups каждого topic могут содержать только группы из allowedPatternGroups соответствующего approvedDirection и никогда не могут содержать blockedPatternGroups. Pattern eligibility задаётся backend по facet kind и business model, а не выбирается по словам продукта.", + "Не считать названия внутренних модулей, слоёв, экранов или архитектурных сущностей языком рынка без external evidence.", + "primaryMarketEntities — единственный главный market entity для core_offer. Верни ровно одну естественную рыночную категорию, сохраняющую главный продуктовый дифференциатор; не подменяй её общими словами, которые уводят в чужой конкурентный класс. Это короткая категория, а не описание: максимум 6 слов и 58 символов, без запятых, перечислений или придаточных. Если factual core доказывает отличительный механизм, категория должна сохранять и продуктовый класс, и этот механизм, а не превращаться в перечисление внутренних сущностей.", + "marketEntityFamily — 3–6 коротких внешне узнаваемых рыночных формулировок того же продуктового смысла. Это НЕ поисковые запросы и НЕ список фич. Каждая формулировка обязана сохранять отличительный признак primaryMarketEntities; не добавляй общий класс продукта, процессный контур, интеграцию, систему или отрасль, если они не удерживают этот признак. Не расширяй один доказанный смысл случайными темами автоматизации.", + "Интеграции, системы и прикладные модули не являются default core seeds. Рекомендуй integration pattern только когда Search Scope явно выбран как integration/application direction, а не потому, что интеграции упомянуты в продуктовой архитектуре.", + "coreEntities и marketSynonyms должны быть устойчивыми рыночными категориями или многословными понятиями, а не перечнем объектов, ролей, документов, статусов, UI-элементов или внутренних имён.", + "Если Search Scope содержит legacy единый core_offer, синтезируй его market categories из всего Offer Map; если scope содержит selected facets, сохраняй их lineage и не дроби один facet на внутренние компоненты." + ] + }; +} + +export async function getTopicDecompositionModelTask(projectId: string) { + const analysis = await getLatestSemanticAnalysis(projectId); + if (!analysis) return null; + + const [demandResearchBrief, contextReview, businessSynthesis, positioningThesis] = await Promise.all([ + getLatestSeoDemandResearchBrief(projectId, analysis.runId), + getLatestAlignedSeoContextReview(projectId, analysis.runId), + getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId), + getSeoPositioningThesis(projectId) + ]); + + const searchScope = await getSeoSearchScope(projectId, demandResearchBrief); + + return buildTopicDecompositionTask({ + projectId, + semanticRunId: analysis.runId, + projectOntologyVersionId: analysis.projectOntology?.versionId ?? null, + demandResearchBrief: demandResearchBrief && searchScope + ? { ...demandResearchBrief, searchScope } + : demandResearchBrief, + contextReview, + businessSynthesis, + positioningThesis + }); +} + +function getEmptyTopicDecomposition(projectId: string): TopicDecompositionContract { + return { + schemaVersion: "seo-topic-decomposition.v1", + projectId, + semanticRunId: null, + projectOntologyVersionId: null, + marketLanguage: "unknown", + sourceTaskId: null, + generatedAt: new Date().toISOString(), + state: "not_ready", + provider: { mode: "not_run", message: "Сначала нужен утверждённый Context Dev и model task semantic decomposition." }, + topics: [], + rejectedFacets: [], + summary: "Темы ещё не декомпозированы на универсальные семантические слоты.", + nextActions: ["Утвердить направления Context Dev и выполнить seo.topic_decomposition."] + }; +} + +function normalizePrimaryMarketEntity(value: string, marketLanguage: TopicDecompositionContract["marketLanguage"]) { + const compact = value.trim().replace(/\s+/g, " "); + if (marketLanguage !== "ru") return compact; + + // These are broad corporate descriptors. Before a differentiated + // "платформа …" category they add no search distinction and reopen the + // generic ERP/CRM/BPM universe. The rule is structural, not product-bound. + return compact.replace(/^(?:(?:модульная|корпоративная|операционная|единая|цифровая)\s+)+(?=платформа\b)/i, ""); +} + +const GENERIC_MARKET_ENTITY_WORDS = new Set([ + "платформа", + "платформы", + "система", + "системы", + "решение", + "решения", + "программа", + "сервис", + "продукт", + "автоматизация", + "автоматизации", + "интеграция", + "интеграции", + "бизнес", + "предприятие", + "предприятий", + "предприятия", + "корпоративный", + "корпоративная", + "корпоративные", + "корпоративным", + "цифровой", + "цифровая", + "операционный", + "операционная", + "модульный", + "модульная", + "управление", + "управления", + "управляемый", + "управляемая", + "управляемых", + "единая", + "единый", + "рабочая", + "рабочих", + "среда", + "контур", + "контуры", + "процесс", + "процессов", + "задача", + "задач", + "для", + "и", + "с" +]); + +const GENERIC_MARKET_ENTITY_ROOTS = new Set( + [...GENERIC_MARKET_ENTITY_WORDS].map((word) => word.slice(0, 5)) +); + +function getDistinctiveEntityRoots(value: string) { + return unique( + value + .toLocaleLowerCase("ru-RU") + .split(/[^a-zа-яё0-9]+/i) + .map((word) => word.trim()) + .filter((word) => word.length >= 5 && !GENERIC_MARKET_ENTITY_ROOTS.has(word.slice(0, 5))) + .map((word) => word.slice(0, 5)) + ); +} + +function hasDifferentiatedMarketEntity(value: string) { + return getDistinctiveEntityRoots(value).length > 0; +} + +function isFacetBoundMarketEntity(input: { entity: string; direction: TopicDirection }) { + if (input.direction.source === "core_offer") return true; + const facetRoots = getDistinctiveEntityRoots(`${input.direction.title} ${input.direction.description}`); + const entityRoots = getDistinctiveEntityRoots(input.entity); + + // An empty semantic marker means that this facet cannot be verified by a + // lexical guard and must rely on model evidence. Otherwise a primary entity + // may not borrow a differentiator from another selected facet. + return facetRoots.length === 0 || entityRoots.some((root) => facetRoots.includes(root)); +} + +function isContextBoundMarketEntity(input: { + candidate: string; + marketLanguage: TopicDecompositionContract["marketLanguage"]; + primary: string; +}) { + const words = input.candidate.split(/\s+/).filter(Boolean); + if (input.candidate.length > 72 || words.length > 8 || /[,;:]/.test(input.candidate)) return false; + if (input.marketLanguage === "ru" && !/[а-яё]/i.test(input.candidate)) return false; + + const roots = getDistinctiveEntityRoots(input.primary); + const candidateSurface = input.candidate.toLocaleLowerCase("ru-RU"); + + // The family must carry a lexical marker from the differentiated core. + // This structural rule blocks generic ERP/CRM/BPM/process expansion while + // allowing inflected forms, for example «агенты» → «агентная». + return roots.length > 0 && roots.some((root) => candidateSurface.includes(root)); +} + +function normalizeMarketEntityFamily( + values: unknown, + primary: string, + marketLanguage: TopicDecompositionContract["marketLanguage"] +) { + return unique([primary, ...asStringArray(values).map((value) => normalizePrimaryMarketEntity(value, marketLanguage))]) + .filter( + (candidate) => + normalizePhrase(candidate) === normalizePhrase(primary) || + isContextBoundMarketEntity({ candidate, marketLanguage, primary }) + ) + .slice(0, 6); +} + +function normalizeTopic( + value: unknown, + direction: TopicDirection, + marketLanguage: TopicDecompositionContract["marketLanguage"] +): QueryTopic | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + + const topic = value as Record; + const sourceDirectionId = typeof topic.sourceDirectionId === "string" ? topic.sourceDirectionId : ""; + const topicId = typeof topic.topicId === "string" ? topic.topicId : ""; + const facetId = typeof topic.facetId === "string" ? topic.facetId : ""; + + if (sourceDirectionId !== direction.id || topicId !== direction.id || facetId !== direction.id) { + return null; + } + + const offerClasses = asOfferClasses(topic.offerClasses); + const coreEntities = asStringArray(topic.coreEntities); + const primaryMarketEntities = asStringArray(topic.primaryMarketEntities) + .map((entity) => normalizePrimaryMarketEntity(entity, marketLanguage)) + .filter((entity) => { + const words = entity.split(/\s+/).filter(Boolean); + // A primary market entity is a short category label, not a compressed + // product description. This remains language/domain neutral while + // rejecting phrases such as "environment for data, tasks and agents". + return entity.length <= 58 && words.length <= 6 && !/[,;:]/.test(entity) && hasDifferentiatedMarketEntity(entity); + }) + .slice(0, 1); + + if (offerClasses.length === 0 || coreEntities.length === 0 || primaryMarketEntities.length !== 1) { + return null; + } + + const marketEntityFamily = normalizeMarketEntityFamily(topic.marketEntityFamily, primaryMarketEntities[0], marketLanguage); + const allowedPatternGroups = new Set(direction.allowedPatternGroups ?? QUERY_PATTERN_GROUPS); + const blockedPatternGroups = new Set(direction.blockedPatternGroups ?? []); + + if (!isFacetBoundMarketEntity({ direction, entity: primaryMarketEntities[0] })) { + return null; + } + + return { + facetId: direction.id, + facetKind: direction.facetKind ?? null, + relationshipToCore: direction.relationshipToCore ?? null, + topicId, + title: typeof topic.title === "string" && topic.title.trim() ? topic.title.trim() : direction.title, + sourceDirectionId, + sourceDirectionTitle: direction.title, + sourceDirectionKind: direction.source, + priority: direction.priority, + evidenceRefs: direction.evidenceRefs, + offerClasses, + primaryMarketEntities, + marketEntityFamily, + categories: asStringArray(topic.categories), + coreEntities, + marketSynonyms: asStringArray(topic.marketSynonyms), + actions: asStringArray(topic.actions), + businessProcesses: asStringArray(topic.businessProcesses), + pains: asStringArray(topic.pains), + buyerSegments: asStringArray(topic.buyerSegments), + systemsForIntegrations: asStringArray(topic.systemsForIntegrations), + negativeOrSeparateIntents: asStringArray(topic.negativeOrSeparateIntents), + recommendedPatternGroups: asStringArray(topic.recommendedPatternGroups).filter((group) => + QUERY_PATTERN_GROUPS.includes(group as (typeof QUERY_PATTERN_GROUPS)[number]) && + allowedPatternGroups.has(group) && + !blockedPatternGroups.has(group) + ), + notes: asStringArray(topic.notes) + }; +} + +function normalizeRejectedFacet(value: unknown, direction: TopicDirection): QueryRejectedFacet | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const rejected = value as Record; + const facetId = typeof rejected.facetId === "string" ? rejected.facetId : ""; + const reason = typeof rejected.reason === "string" ? rejected.reason.trim() : ""; + + if (facetId !== direction.id || !reason) return null; + + return { + evidenceRefs: asStringArray(rejected.evidenceRefs).filter((ref) => direction.evidenceRefs.includes(ref)), + facetId, + reason, + title: direction.title + }; +} + +export async function saveTopicDecompositionFromModelProvider( + projectId: string, + output: unknown, + sourceModelTaskRunId: string, + task: TopicDecompositionModelTaskContract, + source: "codex_workspace" | "manual" = "codex_workspace" +) { + if (!output || typeof output !== "object" || Array.isArray(output)) { + throw new Error("Topic decomposition output должен быть JSON object."); + } + + const payload = output as Record; + if (payload.schemaVersion !== "seo-topic-decomposition.v1" || !Array.isArray(payload.topics) || !Array.isArray(payload.rejectedFacets)) { + throw new Error("Topic decomposition output не соответствует seo-topic-decomposition.v1."); + } + + const directionById = new Map(task.input.approvedDirections.map((direction) => [direction.id, direction])); + const automaticRejectedFacets: QueryRejectedFacet[] = []; + const topics = payload.topics + .map((topic) => { + const sourceDirectionId = topic && typeof topic === "object" && !Array.isArray(topic) + ? (topic as Record).sourceDirectionId + : null; + const direction = typeof sourceDirectionId === "string" ? directionById.get(sourceDirectionId) : null; + const normalized = direction ? normalizeTopic(topic, direction, task.input.marketLanguage.locale) : null; + + if (direction && !normalized) { + automaticRejectedFacets.push({ + evidenceRefs: direction.evidenceRefs, + facetId: direction.id, + reason: "Backend quality gate: фасет не получил отличимую рыночную поверхность, привязанную к собственному evidence, и не заменяется общей или чужой темой.", + title: direction.title + }); + } + + return normalized; + }) + .filter((topic): topic is QueryTopic => Boolean(topic)); + const explicitRejectedFacets = payload.rejectedFacets + .map((rejected) => { + const facetId = rejected && typeof rejected === "object" && !Array.isArray(rejected) + ? (rejected as Record).facetId + : null; + const direction = typeof facetId === "string" ? directionById.get(facetId) : null; + return direction ? normalizeRejectedFacet(rejected, direction) : null; + }) + .filter((facet): facet is QueryRejectedFacet => Boolean(facet)); + const rejectedById = new Map(); + for (const facet of [...explicitRejectedFacets, ...automaticRejectedFacets]) { + rejectedById.set(facet.facetId, facet); + } + const rejectedFacets = [...rejectedById.values()]; + const topicIds = new Set(topics.map((topic) => topic.topicId)); + const rejectedIds = new Set(rejectedFacets.map((facet) => facet.facetId)); + + if ( + topics.length + rejectedFacets.length !== directionById.size || + topicIds.size !== topics.length || + rejectedIds.size !== rejectedFacets.length || + [...topicIds].some((id) => rejectedIds.has(id)) + ) { + throw new Error("Topic decomposition должен покрывать каждый selected facet ровно одним topicId или rejectedFacets reason."); + } + + const contract: TopicDecompositionContract = { + schemaVersion: "seo-topic-decomposition.v1", + projectId, + semanticRunId: task.semanticRunId, + projectOntologyVersionId: task.projectOntologyVersionId, + marketLanguage: task.input.marketLanguage.locale, + sourceTaskId: task.taskId, + generatedAt: new Date().toISOString(), + state: "ready", + provider: { + mode: source, + message: + source === "manual" + ? "Codex manual review разложил утверждённые направления на semantic slots; поисковые фразы не генерировались." + : "Модель разложила утверждённые направления на semantic slots; поисковые фразы не генерировались." + }, + topics, + rejectedFacets, + summary: typeof payload.summary === "string" ? payload.summary : `Подготовлено ${topics.length} topic cards для pattern/suggest слоя.`, + nextActions: [ + "Собрать probes только из универсальных pattern groups.", + "Сохранить observed suggestions или first-party queries с provenance.", + "В Wordstat передавать только approved base probes и подтверждённые observed candidates." + ] + }; + + await pool.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_topic_decomposition', 'done', $2::jsonb, $3::jsonb, now(), now()) + `, + [projectId, JSON.stringify({ source, sourceModelTaskRunId, taskId: task.taskId }), JSON.stringify(contract)] + ); + + return contract; +} + +export async function getTopicDecompositionContract(projectId: string): Promise { + const expectedTask = await getTopicDecompositionModelTask(projectId); + + if (!expectedTask) { + return getEmptyTopicDecomposition(projectId); + } + + const result = await pool.query( + ` + select output + from runs + where project_id = $1 + and run_type = 'seo_topic_decomposition' + and status = 'done' + order by created_at desc + limit 1 + `, + [projectId] + ); + const contract = result.rows[0]?.output; + + return contract?.schemaVersion === "seo-topic-decomposition.v1" && + contract.state === "ready" && + contract.marketLanguage === expectedTask.input.marketLanguage.locale && + contract.sourceTaskId === expectedTask.taskId + ? contract + : { + ...getEmptyTopicDecomposition(projectId), + semanticRunId: expectedTask.semanticRunId, + projectOntologyVersionId: expectedTask.projectOntologyVersionId, + marketLanguage: expectedTask.input.marketLanguage.locale, + sourceTaskId: expectedTask.taskId, + provider: { + mode: "not_run", + message: "Search Scope или Offer Map обновились; нужен свежий seo.topic_decomposition." + }, + summary: "Старые topic cards не соответствуют текущему Offer Map и не используются.", + nextActions: ["Выполнить свежий seo.topic_decomposition из Offer Map и Search Scope."] + }; +} + +function makePhrase(template: string, slots: Record) { + return template.replace(/\{(action|category|entity|process|system)\}/g, (_match, slot: string) => slots[slot] ?? "").replace(/\s+/g, " ").trim(); +} + +function splitSystemValues(values: string[]) { + return unique( + values.flatMap((value) => value.split(/[,;/]+/).map((item) => item.trim())) + ); +} + +function getPatternSlots(topic: QueryTopic, pattern: QueryPattern): Array> { + const values: Record = { + action: topic.actions, + category: topic.categories, + // Every family member is validated against the differentiated primary + // entity. Core entities, raw synonyms and feature inventory stay + // provenance, so they cannot reopen ERP/CRM/BPM/integration noise. + entity: topic.marketEntityFamily, + process: unique([...topic.businessProcesses, ...topic.pains]), + system: splitSystemValues(topic.systemsForIntegrations) + }; + const slots = pattern.slots; + + if (slots.some((slot) => values[slot].length === 0)) { + return []; + } + + const combinations: Array> = [{}]; + for (const slot of slots) { + const next: Array> = []; + for (const combination of combinations) { + for (const value of values[slot].slice(0, 6)) { + next.push({ ...combination, [slot]: value }); + } + } + combinations.splice(0, combinations.length, ...next.slice(0, 18)); + } + + return combinations; +} + +function isAllowedProbeSurface(phrase: string, marketLanguage: TopicDecompositionContract["marketLanguage"]) { + if (marketLanguage !== "ru") return true; + + // In a Russian market surface, English-only categories (for example + // "enterprise operating platform") are not valid pre-observation probes. + // Technical acronyms are still allowed inside a Russian phrase. + return /[а-яё]/i.test(phrase); +} + +function buildProbes(topics: QueryTopic[], marketLanguage: TopicDecompositionContract["marketLanguage"]) { + const probes: QueryProbe[] = []; + const seen = new Set(); + + for (const topic of topics) { + const applicablePatterns = selectUniversalFacetPatterns({ + facet: { + allowedPatternGroups: topic.recommendedPatternGroups, + facetKind: topic.facetKind, + offerClasses: topic.offerClasses, + sourceDirectionKind: topic.sourceDirectionKind + }, + patterns: QUERY_PATTERNS + }); + + for (const pattern of applicablePatterns) { + const patternSlots = getPatternSlots(topic, pattern); + // A facet may expose a rich semantic family, but its initial visible + // draft set must stay reviewable. More variants belong to observed + // language, not to an unbounded model-only list. + const cappedPatternSlots = pattern.group === "base_entity" ? patternSlots.slice(0, 3) : patternSlots; + + for (const slots of cappedPatternSlots) { + if ( + slots.entity && + slots.system && + normalizePhrase(slots.entity) === normalizePhrase(slots.system) + ) { + continue; + } + const phrase = makePhrase(pattern.template, slots); + const normalizedPhrase = normalizePhrase(phrase); + const key = `${topic.topicId}:${normalizedPhrase}`; + + if (!phrase || normalizedPhrase.length < 3 || seen.has(key) || !isAllowedProbeSurface(phrase, marketLanguage)) { + continue; + } + + seen.add(key); + probes.push({ + id: stableId("probe", [topic.topicId, pattern.id, normalizedPhrase]), + topicId: topic.topicId, + topicTitle: topic.title, + phrase, + normalizedPhrase, + patternId: pattern.id, + patternGroup: pattern.group, + intent: pattern.intent, + pageType: pattern.pageType, + evidenceRefs: topic.evidenceRefs, + requiresSuggestConfirmation: pattern.id !== "base_entity", + reason: + pattern.id === "base_entity" + ? "Базовая сущность topic card; может быть явно утверждена как стартовый Wordstat probe." + : `Pattern probe ${pattern.id}; это гипотеза для suggest/first-party evidence, а не готовый ключ.` + }); + } + } + } + + return probes.slice(0, 360); +} + +async function listObservations(projectId: string, semanticRunId: string | null) { + if (!semanticRunId) { + return []; + } + + const result = await pool.query( + ` + select id, topic_id, probe_id, pattern_id, query, normalized_query, source, source_seed, geo, language, raw_artifact_key, metadata, observed_at + from query_observations + where project_id = $1 and semantic_run_id = $2 + order by observed_at desc, created_at desc + `, + [projectId, semanticRunId] + ); + + return result.rows.map((row) => ({ + id: row.id, + topicId: row.topic_id, + probeId: row.probe_id, + patternId: row.pattern_id, + query: row.query, + normalizedQuery: row.normalized_query, + source: row.source, + sourceSeed: row.source_seed, + geo: row.geo, + language: row.language, + rawArtifactKey: row.raw_artifact_key, + metadata: row.metadata ?? {}, + observedAt: row.observed_at.toISOString() + })); +} + +async function listProbeDecisions(projectId: string, semanticRunId: string | null) { + if (!semanticRunId) { + return new Map(); + } + + const result = await pool.query( + ` + select topic_id, probe_id, approved, decision_status, reason + from query_probe_decisions + where project_id = $1 and semantic_run_id = $2 + `, + [projectId, semanticRunId] + ); + + return new Map(result.rows.map((row) => [`${row.topic_id}:${row.probe_id}`, row])); +} + +async function listManualCandidates(projectId: string, semanticRunId: string | null) { + if (!semanticRunId) { + return [] as ManualCandidateRow[]; + } + + const result = await pool.query( + ` + select id, topic_id, query, normalized_query, decision_status, reason + from query_manual_candidates + where project_id = $1 and semantic_run_id = $2 + order by updated_at desc, created_at desc + `, + [projectId, semanticRunId] + ); + + return result.rows; +} + +function getDecisionStatus(decision: Pick | null | undefined) { + if (!decision) return "pending"; + return decision.decision_status ?? (decision.approved ? "wordstat" : "deleted"); +} + +function getControlledCandidateState(input: { + decisionStatus: QueryCandidateDecisionStatus; + isBaseEntity?: boolean; + isManual?: boolean; + reason?: string; +}) { + const { decisionStatus, isBaseEntity = false, isManual = false, reason } = input; + const canQueueForWordstat = isBaseEntity || isManual; + + if (decisionStatus === "deleted") { + return { + status: "deleted" as const, + requiresHumanApproval: false, + reason: reason || "Формулировка удалена из текущего набора." + }; + } + if (decisionStatus === "hidden") { + return { + status: "hidden" as const, + requiresHumanApproval: false, + reason: reason || "Формулировка временно скрыта; её можно вернуть в разбор." + }; + } + if (decisionStatus === "kept") { + return { + status: "approved_for_collection" as const, + requiresHumanApproval: false, + reason: reason || "Формулировка оставлена для последующего сбора наблюдаемого языка. В Wordstat она не отправлялась." + }; + } + if (decisionStatus === "wordstat" && canQueueForWordstat) { + return { + status: "approved_probe" as const, + requiresHumanApproval: false, + reason: reason || "Фраза вручную поставлена в будущую очередь Wordstat. Запуск Wordstat этим действием не производится." + }; + } + + return { + status: "needs_observation" as const, + requiresHumanApproval: true, + reason: "" + }; +} + +function getObservationScore(observations: QueryObservation[]) { + const sources = new Set(observations.map((observation) => observation.source)); + const sourceWeight = Math.max( + ...observations.map((observation) => + observation.source === "first_party_search_console" || observation.source === "first_party_webmaster" + ? 1 + : observation.source === "yandex_suggest" || observation.source === "google_suggest" || observation.source === "bing_suggest" + ? 0.84 + : observation.source === "serp" + ? 0.7 + : 0.58 + ) + ); + return Math.min(0.98, Number((sourceWeight * 0.82 + Math.min(sources.size - 1, 3) * 0.06).toFixed(2))); +} + +function buildCandidates(input: { + probes: QueryProbe[]; + observations: QueryObservation[]; + decisions: Map; + manualCandidates: ManualCandidateRow[]; + topics: QueryTopic[]; +}) { + const topicById = new Map(input.topics.map((topic) => [topic.topicId, topic])); + const probeById = new Map(input.probes.map((probe) => [probe.id, probe])); + const candidates: QueryCandidate[] = []; + + for (const probe of input.probes) { + const decision = input.decisions.get(`${probe.topicId}:${probe.id}`); + const topic = topicById.get(probe.topicId); + if (!topic) continue; + const isBaseEntity = probe.patternId === "base_entity"; + const decisionStatus = getDecisionStatus(decision); + const controlledState = getControlledCandidateState({ + decisionStatus, + isBaseEntity, + reason: decision?.reason + }); + candidates.push({ + candidateStage: controlledState.status === "approved_probe" ? "wordstat_candidate" : "query_surface_draft", + facetId: topic.facetId, + facetKind: topic.facetKind, + id: stableId("candidate", [probe.topicId, probe.normalizedPhrase, isBaseEntity ? "base_entity" : "pattern_draft"]), + probeId: probe.id, + manualCandidateId: null, + topicId: probe.topicId, + topicTitle: probe.topicTitle, + phrase: probe.phrase, + normalizedPhrase: probe.normalizedPhrase, + priority: topic.priority, + intent: probe.intent, + pageType: probe.pageType, + patternId: probe.patternId, + generationMethod: isBaseEntity ? "base_entity" : "pattern_draft", + sourceEvidence: [ + isBaseEntity ? "semantic_slot:core_entity" : `pattern:${probe.patternId}`, + ...probe.evidenceRefs + ], + preWordstatScore: isBaseEntity ? 0.55 : 0.35, + status: controlledState.status, + requiresHumanApproval: controlledState.requiresHumanApproval, + reason: controlledState.reason || (isBaseEntity + ? "Базовая сущность не является поисковым ключом автоматически; нужен явный human gate или observed query." + : "Детерминированная формулировка из semantic slots и pattern library. Это кандидат для проверки языка, не готовый ключ и не Wordstat input.") + }); + } + + for (const manualCandidate of input.manualCandidates) { + const topic = topicById.get(manualCandidate.topic_id); + if (!topic) continue; + const controlledState = getControlledCandidateState({ + decisionStatus: manualCandidate.decision_status, + isManual: true, + reason: manualCandidate.reason + }); + candidates.push({ + candidateStage: controlledState.status === "approved_probe" ? "wordstat_candidate" : "query_surface_draft", + facetId: topic.facetId, + facetKind: topic.facetKind, + id: `manual:${manualCandidate.id}`, + probeId: null, + manualCandidateId: manualCandidate.id, + topicId: topic.topicId, + topicTitle: topic.title, + phrase: manualCandidate.query, + normalizedPhrase: manualCandidate.normalized_query, + priority: topic.priority, + intent: "commercial", + pageType: "product", + patternId: null, + generationMethod: "manual", + sourceEvidence: [`manual_candidate:${manualCandidate.id}`], + preWordstatScore: 0.42, + status: controlledState.status, + requiresHumanApproval: controlledState.requiresHumanApproval, + reason: controlledState.reason || "Формулировка добавлена вручную для проверки языка. Это не доказанный спрос и не автоматический Wordstat input." + }); + } + + const observationsByTopicPhrase = new Map(); + for (const observation of input.observations) { + // Topic IDs are namespaced (for example `core_facet:...`), so `:` is not + // a safe serialization delimiter. Keep the compound evidence key opaque. + const key = `${observation.topicId}\u0000${observation.normalizedQuery}`; + observationsByTopicPhrase.set(key, [...(observationsByTopicPhrase.get(key) ?? []), observation]); + } + + for (const [key, observations] of observationsByTopicPhrase) { + const separatorIndex = key.indexOf("\u0000"); + const topicId = separatorIndex >= 0 ? key.slice(0, separatorIndex) : ""; + const normalizedPhrase = separatorIndex >= 0 ? key.slice(separatorIndex + 1) : ""; + const topic = topicById.get(topicId); + if (!topic) continue; + const linkedProbe = observations.map((observation) => observation.probeId ? probeById.get(observation.probeId) : null).find(Boolean) ?? null; + const score = getObservationScore(observations); + candidates.push({ + candidateStage: score >= 0.7 ? "wordstat_candidate" : "observed_query", + facetId: topic.facetId, + facetKind: topic.facetKind, + id: stableId("candidate", [topicId, normalizedPhrase, "observed"]), + probeId: linkedProbe?.id ?? null, + manualCandidateId: null, + topicId, + topicTitle: topic.title, + phrase: observations[0].query, + normalizedPhrase, + priority: topic.priority, + intent: linkedProbe?.intent ?? "commercial", + pageType: linkedProbe?.pageType ?? "product", + patternId: observations.find((observation) => observation.patternId)?.patternId ?? linkedProbe?.patternId ?? null, + generationMethod: "observed_query", + sourceEvidence: observations.map((observation) => `observed:${observation.source}:${observation.id}`), + preWordstatScore: score, + status: score >= 0.7 ? "ready_for_wordstat" : "needs_observation", + requiresHumanApproval: score < 0.7, + reason: `Фраза наблюдалась в ${new Set(observations.map((observation) => observation.source)).size} источнике(ах); pattern engine её не выдумывал.` + }); + } + + return candidates + .sort((left, right) => right.preWordstatScore - left.preWordstatScore || left.phrase.localeCompare(right.phrase, "ru")) + .slice(0, 420); +} + +function getEmptyCandidateQualityDiagnostics(selectedFacetCount = 0): CandidateQualityDiagnostics { + return { + acceptedDrafts: 0, + hiddenDrafts: 0, + maxSameFacetShare: 0, + maxSameLemmaRootShare: 0, + representedFacetCount: 0, + selectedFacetCount, + totalDrafts: 0, + warnings: [] + }; +} + +function countBy(items: T[], getKey: (item: T) => string) { + return items.reduce>((result, item) => { + const key = getKey(item); + result[key] = (result[key] ?? 0) + 1; + return result; + }, {}); +} + +function getEmptyObservedGate(): ObservedRelevanceGateResult { + return { + acceptedForHumanReview: [], + autoReadyForWordstat: [], + diagnostics: { autoReady: 0, hidden: 0, humanReview: 0, informational: 0, rawOnly: 0, rerouted: 0 }, + hidden: [], + informational: [], + rawOnly: [], + rerouted: [] + }; +} + +function getEmptyMarketExpansion(): QueryDiscoveryContract["marketExpansion"] { + return { + coverage: { + adjacentInactive: 0, + applicationsInactive: 0, + byKind: {}, + coreCovered: 0, + nearCoreInactive: 0, + observedSurfaceCount: 0, + observedClusterCount: 0, + selectedForExploration: 0, + speculativeHidden: 0, + warning: "Market expansion ожидает готовый Semantic Portfolio." + }, + decisions: [], + depth: "smoke", + domainAgnosticity: { + diagnostics: [], + reasons: { + hasCrossIndustryObjects: false, + hasCurrentApplicationsAcrossDomains: false, + hasGenericProcesses: false, + hasIntegrationLayer: false, + hasMultipleBuyerRoles: false, + hasNoStrictVerticalConstraint: false, + hasPlatformDeliveryModel: false + }, + recommendedExpansionMode: "narrow", + score: 0 + }, + matrix: [], + observations: [], + observedLanguageReview: { + candidates: [], + diagnostics: { hidden: 0, humanReview: 0, informational: 0, normalized: 0, observedClustersBySurface: {}, rawOnly: 0, wordstatReady: 0 }, + hidden: [], + informational: [], + normalized: [], + rawOnly: [] + }, + seeds: [], + selectedSurfaceIds: [], + suggestProbes: [], + surfaces: [] + }; +} + +function buildPreWordstatDiagnostics(input: { + seeds: MarketSeed[]; + observations: QueryObservation[]; + normalizedObservations: NormalizedObservedQuery[]; + gate: ObservedRelevanceGateResult; + clusters: CanonicalQueryCluster[]; + queue: WordstatQueueItem[]; + candidates: QueryCandidate[]; + quality: CandidateQualityDiagnostics; +}) { + return { + clusters: { + byIntent: countBy(input.clusters, (cluster) => cluster.intent), + byStage: countBy(input.clusters, (cluster) => cluster.stage), + total: input.clusters.length + }, + diversity: input.quality, + gate: input.gate.diagnostics, + observations: { + bySource: countBy(input.observations, (observation) => observation.source), + clean: input.normalizedObservations.filter((observation) => observation.contamination === "clean").length, + contaminated: input.normalizedObservations.filter((observation) => observation.contamination !== "clean").length, + normalized: input.normalizedObservations.length, + raw: input.observations.length + }, + seeds: { + blocked: input.seeds.filter((seed) => Boolean(seed.blockedReason)).length, + byFacet: countBy(input.seeds, (seed) => seed.facetId), + byKind: countBy(input.seeds, (seed) => seed.kind), + generated: input.seeds.length + }, + wordstat: { + blockedContaminated: input.gate.hidden.length, + blockedModelOnly: input.candidates.filter((candidate) => candidate.generationMethod === "base_entity" || candidate.generationMethod === "pattern_draft").length, + blockedNoApproval: input.clusters.filter((cluster) => !input.queue.some((item) => item.clusterId === cluster.id)).length, + queueItems: input.queue.length + } + }; +} + +export async function getQueryDiscoveryContract( + projectId: string, + options: { persistSnapshots?: boolean } = {} +): Promise { + const [topicDecomposition, searchScope, demandResearchBrief, opportunityMap, positioningThesis] = await Promise.all([ + getTopicDecompositionContract(projectId), + getSeoSearchScope(projectId), + getLatestSeoDemandResearchBrief(projectId), + getSeoContextOpportunityMap(projectId), + getSeoPositioningThesis(projectId) + ]); + const searchScopeSummary = searchScope + ? { + mode: searchScope.mode, + selectedVectorIds: searchScope.selectedVectorIds, + vectors: searchScope.vectors.map(({ id, title, source, priority, facetKind, relationshipToCore, allowedPatternGroups, blockedPatternGroups, searchLanguageHints }) => ({ id, title, source, priority, facetKind, relationshipToCore, allowedPatternGroups, blockedPatternGroups, searchLanguageHints })), + availableVectors: searchScope.availableVectors.map(({ id, title, source, priority, facetKind, relationshipToCore, allowedPatternGroups, blockedPatternGroups, searchLanguageHints }) => ({ id, title, source, priority, facetKind, relationshipToCore, allowedPatternGroups, blockedPatternGroups, searchLanguageHints })), + rationale: searchScope.rationale + } + : null; + const patterns = QUERY_PATTERNS.map(({ id, group, intent, pageType }) => ({ id, group, intent, pageType })); + + if (topicDecomposition.state !== "ready") { + const emptyQuality = getEmptyCandidateQualityDiagnostics(searchScope?.selectedVectorIds.length ?? 0); + const emptyGate = getEmptyObservedGate(); + return { + schemaVersion: "query-discovery.v1", + projectId, + semanticRunId: topicDecomposition.semanticRunId, + generatedAt: new Date().toISOString(), + state: "blocked", + positioningThesis, + searchScope: searchScopeSummary, + topicDecomposition, + patterns, + probes: [], + marketSeeds: [], + observations: [], + normalizedObservations: [], + observedLanguageReview: { clusters: [], decisions: [], gate: emptyGate }, + wordstatQueue: [], + marketExpansion: getEmptyMarketExpansion(), + candidates: [], + readiness: { + approvedProbeCount: 0, + autoReadyCount: 0, + canonicalClusterCount: 0, + humanReviewCount: 0, + marketSeedCount: 0, + observedQueryCount: 0, + probeCount: 0, + topicCount: 0, + wordstatReadyCount: 0 + }, + diagnostics: { + candidateQuality: emptyQuality, + preWordstat: buildPreWordstatDiagnostics({ + candidates: [], clusters: [], gate: emptyGate, normalizedObservations: [], observations: [], quality: emptyQuality, queue: [], seeds: [] + }) + }, + nextActions: topicDecomposition.nextActions + }; + } + + const [observations, decisions, manualCandidates, expansionDecisions, expansionObservations] = await Promise.all([ + listObservations(projectId, topicDecomposition.semanticRunId), + listProbeDecisions(projectId, topicDecomposition.semanticRunId), + listManualCandidates(projectId, topicDecomposition.semanticRunId), + listExpansionSurfaceDecisions(projectId, topicDecomposition.semanticRunId), + listExpansionObservations(projectId, topicDecomposition.semanticRunId) + ]); + const probes = buildProbes(topicDecomposition.topics, topicDecomposition.marketLanguage); + const rawCandidates = buildCandidates({ probes, observations, decisions, manualCandidates, topics: topicDecomposition.topics }); + const qualityGate = applyCandidateQualityGate({ + candidates: rawCandidates.map((candidate) => ({ + candidateStage: candidate.candidateStage, + facetId: candidate.facetId, + id: candidate.id, + patternId: candidate.patternId, + phrase: candidate.phrase + })), + selectedFacetIds: searchScope?.selectedVectorIds ?? [] + }); + const candidates = rawCandidates.map((candidate) => { + const decision = qualityGate.decisions.get(candidate.id); + return { + ...candidate, + quality: decision ? { diagnostics: decision.diagnostics, status: decision.status } : { diagnostics: [], status: "accepted" as const } + }; + }); + const selectedFacetIds = searchScope?.selectedVectorIds ?? []; + const allFacets = demandResearchBrief?.semanticPortfolio?.facets ?? []; + const selectedFacets = allFacets.filter((facet) => selectedFacetIds.includes(facet.id)); + const domainAgnosticity = classifyDomainAgnosticity({ + facets: allFacets, + offerMap: demandResearchBrief?.offerMap ?? null, + opportunities: opportunityMap.opportunities + }); + const rawExpansionSurfaces = buildExpansionSurfaces({ + facets: allFacets, + offerMap: demandResearchBrief?.offerMap ?? null, + opportunities: opportunityMap.opportunities, + selectedFacetIds + }); + const expansionSurfaces = rawExpansionSurfaces.map((surface) => { + const decision = expansionDecisions.get(surface.id); + return decision ? { + ...surface, + status: decision.decisionStatus === "selected_for_exploration" ? "selected_for_exploration" as const : decision.decisionStatus === "rejected" ? "rejected" as const : "suggested" as const + } : surface; + }); + const selectedExpansionSurfaceIds = expansionSurfaces.filter((surface) => surface.status === "selected_for_exploration").map((surface) => surface.id); + const expansionDepth: ExplorationDepth = domainAgnosticity.recommendedExpansionMode === "broad_exploration" ? "balanced" : "smoke"; + const expansionSeeds = generateExpansionSeeds({ + depth: expansionDepth, + language: topicDecomposition.marketLanguage, + selectedSurfaceIds: selectedExpansionSurfaceIds, + surfaces: expansionSurfaces + }); + const expansionSuggestProbes = buildExpansionSuggestProbes({ depth: expansionDepth, language: topicDecomposition.marketLanguage, seeds: expansionSeeds }); + const expansionMatrix = buildApplicabilityMatrix({ surfaces: expansionSurfaces, maxCells: 180, maxPerSurface: 4 }); + const expansionObservedLanguageReview = reviewExpansionObservedLanguage({ + brandAliases: [demandResearchBrief?.coreContext.brandName ?? "", demandResearchBrief?.offerMap?.siteIdentity.brandName ?? ""].filter(Boolean), + language: topicDecomposition.marketLanguage, + observations: expansionObservations, + surfaces: expansionSurfaces + }); + const observedExpansionSurfaceIds = new Set(expansionObservations.map((observation) => observation.surfaceId)); + const expansionCoverage = { + adjacentInactive: expansionSurfaces.filter((surface) => surface.relationshipToCore === "adjacent" && surface.status === "suggested").length, + applicationsInactive: expansionSurfaces.filter((surface) => surface.relationshipToCore === "application" && surface.status === "suggested").length, + byKind: countBy(expansionSurfaces, (surface) => surface.surfaceKind), + coreCovered: expansionSurfaces.filter((surface) => surface.relationshipToCore === "direct_core").length, + nearCoreInactive: expansionSurfaces.filter((surface) => surface.relationshipToCore === "near_core" && surface.status === "suggested").length, + observedSurfaceCount: observedExpansionSurfaceIds.size, + observedClusterCount: expansionObservedLanguageReview.candidates.length, + selectedForExploration: selectedExpansionSurfaceIds.length, + speculativeHidden: expansionSurfaces.filter((surface) => surface.relationshipToCore === "speculative" && surface.status !== "selected_for_exploration").length, + warning: selectedExpansionSurfaceIds.length === 0 + ? "Текущая Wordstat queue — только Core validation batch. Market Expansion Backlog ещё не выбран для exploration." + : "Expansion observations остаются отдельным exploration-контуром и не входят в Core Wordstat queue." + }; + const marketSeeds = generateMarketSeeds({ + language: topicDecomposition.marketLanguage, + offerMap: demandResearchBrief?.offerMap ?? null, + projectId, + selectedFacets, + topics: topicDecomposition.topics, + maxPerFacet: 16, + maxTotal: 80 + }); + const seedIdByTopicAndNormalizedSeed = new Map(marketSeeds.map((seed) => [`${seed.topicId}\u0000${seed.normalizedSeed}`, seed.id])); + const topicFacetMap = new Map(topicDecomposition.topics.map((topic) => [topic.topicId, topic.facetId])); + const normalizedObservations = normalizeObservedQueries({ + brandAliases: [demandResearchBrief?.coreContext.brandName ?? "", demandResearchBrief?.offerMap?.siteIdentity.brandName ?? ""].filter(Boolean), + language: topicDecomposition.marketLanguage, + observations, + seedIdByTopicAndNormalizedSeed, + topicFacetMap + }); + const gate = applyObservedRelevanceGate({ + facets: allFacets, + facetVocabulary: new Map(topicDecomposition.topics.map((topic) => [topic.facetId, unique([ + ...topic.primaryMarketEntities, + ...topic.marketEntityFamily, + ...topic.marketSynonyms, + ...topic.coreEntities, + ...topic.categories + ])])), + observations: normalizedObservations, + offerMap: demandResearchBrief?.offerMap ?? null, + selectedFacetIds + }); + const clusters = buildCanonicalQueryClusters({ + candidates: [...gate.acceptedForHumanReview, ...gate.autoReadyForWordstat], + projectId + }); + const clusterDecisions = await listQueryClusterDecisions(projectId, topicDecomposition.semanticRunId); + const wordstatQueue = buildWordstatQueue({ + allowAutoReady: false, + clusters, + decisions: clusterDecisions, + maxPhrasesPerCluster: 2, + maxPhrasesPerFacet: 12, + projectId + }); + const approvedProbeCount = candidates.filter((candidate) => candidate.status === "approved_probe").length; + const wordstatReadyCount = wordstatQueue.length; + const preWordstatDiagnostics = buildPreWordstatDiagnostics({ + candidates, + clusters, + gate, + normalizedObservations, + observations, + quality: qualityGate.diagnostics, + queue: wordstatQueue, + seeds: marketSeeds + }); + + if (options.persistSnapshots) { + await persistPreWordstatSnapshot({ + clusters, + gate, + normalizedObservations, + projectId, + queue: wordstatQueue, + seeds: marketSeeds, + semanticRunId: topicDecomposition.semanticRunId as string + }); + await persistMarketExpansionSnapshot({ + projectId, + seeds: expansionSeeds, + semanticRunId: topicDecomposition.semanticRunId as string, + surfaces: expansionSurfaces + }); + } + + return { + schemaVersion: "query-discovery.v1", + projectId, + semanticRunId: topicDecomposition.semanticRunId, + generatedAt: new Date().toISOString(), + state: "ready", + positioningThesis, + searchScope: searchScopeSummary, + topicDecomposition, + patterns, + probes, + marketSeeds, + observations, + normalizedObservations, + observedLanguageReview: { + clusters, + decisions: [...clusterDecisions.values()], + gate + }, + wordstatQueue, + marketExpansion: { + coverage: expansionCoverage, + decisions: [...expansionDecisions.values()], + depth: expansionDepth, + domainAgnosticity, + matrix: expansionMatrix, + observations: expansionObservations, + observedLanguageReview: expansionObservedLanguageReview, + seeds: expansionSeeds, + selectedSurfaceIds: selectedExpansionSurfaceIds, + suggestProbes: expansionSuggestProbes, + surfaces: expansionSurfaces + }, + candidates, + readiness: { + topicCount: topicDecomposition.topics.length, + probeCount: probes.length, + observedQueryCount: observations.length, + approvedProbeCount, + marketSeedCount: marketSeeds.length, + humanReviewCount: gate.acceptedForHumanReview.length, + autoReadyCount: gate.autoReadyForWordstat.length, + canonicalClusterCount: clusters.length, + wordstatReadyCount + }, + diagnostics: { candidateQuality: qualityGate.diagnostics, preWordstat: preWordstatDiagnostics }, + nextActions: + wordstatReadyCount > 0 + ? ["Очередь Wordstat сформирована из вручную утверждённых canonical clusters; запуск остаётся отдельным действием."] + : clusters.length > 0 + ? ["Проверить canonical clusters наблюдённого языка и вручную утвердить релевантные для bounded Wordstat queue."] + : ["Собрать Suggest по автоматически сгенерированным market seeds или добавить first-party observations."] + }; +} + +export async function saveQueryObservation(projectId: string, semanticRunId: string, input: QueryObservationInput) { + const result = await pool.query( + ` + insert into query_observations ( + project_id, semantic_run_id, topic_id, probe_id, pattern_id, query, normalized_query, + source, source_seed, geo, language, raw_artifact_key, metadata + ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb) + on conflict (project_id, topic_id, normalized_query, source) + do update set + probe_id = excluded.probe_id, + pattern_id = excluded.pattern_id, + source_seed = excluded.source_seed, + geo = excluded.geo, + language = excluded.language, + raw_artifact_key = excluded.raw_artifact_key, + metadata = excluded.metadata, + observed_at = now() + returning id, topic_id, probe_id, pattern_id, query, normalized_query, source, source_seed, geo, language, raw_artifact_key, metadata, observed_at; + `, + [ + projectId, + semanticRunId, + input.topicId, + input.probeId ?? null, + input.patternId ?? null, + input.query.trim().replace(/\s+/g, " "), + normalizePhrase(input.query), + input.source, + input.sourceSeed ?? null, + input.geo ?? null, + input.language ?? null, + input.rawArtifactKey ?? null, + JSON.stringify(input.metadata ?? {}) + ] + ); + + return result.rows[0]; +} + +export async function saveQueryProbeDecision(input: { + projectId: string; + semanticRunId: string; + topicId: string; + probeId: string; + decisionStatus: QueryCandidateDecisionStatus; + reason?: string; +}) { + await pool.query( + ` + insert into query_probe_decisions (project_id, semantic_run_id, topic_id, probe_id, approved, decision_status, reason) + values ($1, $2, $3, $4, $5, $6, $7) + on conflict (project_id, topic_id, probe_id) + do update set approved = excluded.approved, decision_status = excluded.decision_status, reason = excluded.reason, decided_at = now(); + `, + [ + input.projectId, + input.semanticRunId, + input.topicId, + input.probeId, + input.decisionStatus === "wordstat", + input.decisionStatus, + input.reason?.trim() ?? "" + ] + ); +} + +export async function saveManualQueryCandidate(input: { + projectId: string; + semanticRunId: string; + topicId: string; + query: string; +}) { + const query = input.query.trim().replace(/\s+/g, " "); + await pool.query( + ` + insert into query_manual_candidates (project_id, semantic_run_id, topic_id, query, normalized_query) + values ($1, $2, $3, $4, $5) + on conflict (project_id, semantic_run_id, topic_id, normalized_query) + do update set query = excluded.query, decision_status = 'pending', reason = '', updated_at = now(); + `, + [input.projectId, input.semanticRunId, input.topicId, query, normalizePhrase(query)] + ); +} + +export async function saveManualQueryCandidateDecision(input: { + projectId: string; + semanticRunId: string; + manualCandidateId: string; + decisionStatus: QueryCandidateDecisionStatus; + reason?: string; +}) { + const result = await pool.query( + ` + update query_manual_candidates + set decision_status = $4, reason = $5, updated_at = now() + where id = $1 and project_id = $2 and semantic_run_id = $3 + `, + [ + input.manualCandidateId, + input.projectId, + input.semanticRunId, + input.decisionStatus, + input.reason?.trim() ?? "" + ] + ); + + if (result.rowCount !== 1) { + throw new Error("Ручная формулировка не найдена в текущем semantic run."); + } +} + +export function getWordstatCandidatesFromQueryDiscovery(discovery: QueryDiscoveryContract) { + const clusterById = new Map(discovery.observedLanguageReview.clusters.map((cluster) => [cluster.id, cluster])); + return discovery.wordstatQueue + .map((item) => { + const cluster = clusterById.get(item.clusterId); + return { + clusterId: item.clusterId, + clusterTitle: cluster?.canonicalQuery ?? item.phrase, + confidence: cluster?.score ?? 0.7, + phrase: item.phrase, + priority: "medium" as const, + reason: item.reason, + source: "detected" as const, + provenance: "observed_cluster", + patternId: null, + evidenceRefs: cluster?.evidenceRefs ?? item.sourceEvidence.observationIds.map((id) => `observed:${id}`) + }; + }); +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/recursiveSuggestExpansion.ts b/seo_mode/seo_mode/server/src/queryDiscovery/recursiveSuggestExpansion.ts new file mode 100644 index 0000000..8741ce6 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/recursiveSuggestExpansion.ts @@ -0,0 +1,57 @@ +import type { ExpansionMarketSeed, ExplorationDepth } from "./expansionSeedGenerator.js"; +import { EXPLORATION_DEPTH_POLICIES } from "./expansionSeedGenerator.js"; + +export type ExpansionSuggestProbe = { + phrase: string; + surfaceId: string; + seedId: string; + depth: 1 | 2; + expansionKind: "base" | "suffix" | "alphabet" | "recursive_observed"; +}; + +const SUFFIXES: Record<"ru" | "en", string[]> = { + ru: ["для", "в", "внедрение", "система", "платформа", "сервис", "программа"], + en: ["for", "in", "implementation", "system", "platform", "service", "software"] +}; + +export function buildExpansionSuggestProbes(input: { + seeds: ExpansionMarketSeed[]; + depth: ExplorationDepth; + language: string; +}): ExpansionSuggestProbe[] { + const policy = EXPLORATION_DEPTH_POLICIES[input.depth]; + const language = input.language === "ru" ? "ru" : "en"; + const alphabet = language === "ru" ? "абвгдежзиклмнопрстуфхцчшэюя" : "abcdefghijklmnopqrstuvwxyz"; + const probes: ExpansionSuggestProbe[] = []; + const seen = new Set(); + const add = (probe: ExpansionSuggestProbe) => { + const key = probe.phrase.trim().toLocaleLowerCase(); + if (!key || seen.has(key) || probes.length >= policy.maxSeeds) return; + seen.add(key); + probes.push(probe); + }; + for (const seed of input.seeds) { + add({ depth: 1, expansionKind: "base", phrase: seed.phrase, seedId: seed.id, surfaceId: seed.surfaceId }); + for (const suffix of SUFFIXES[language]) { + add({ depth: 1, expansionKind: "suffix", phrase: `${seed.phrase} ${suffix}`, seedId: seed.id, surfaceId: seed.surfaceId }); + } + if (policy.alphabetExpansion) { + for (const letter of alphabet.slice(0, input.depth === "deep" ? 32 : 8)) { + add({ depth: 1, expansionKind: "alphabet", phrase: `${seed.phrase} для ${letter}`, seedId: seed.id, surfaceId: seed.surfaceId }); + } + } + if (probes.length >= policy.maxSeeds) break; + } + return probes; +} + +export function buildDepthTwoProbes(input: { + observedQueries: Array<{ query: string; surfaceId: string; seedId: string; clean: boolean }>; + depth: ExplorationDepth; +}) { + if (EXPLORATION_DEPTH_POLICIES[input.depth].suggestDepth < 2) return [] as ExpansionSuggestProbe[]; + return input.observedQueries + .filter((item) => item.clean && item.query.split(/\s+/).length <= 9) + .slice(0, 80) + .map((item) => ({ depth: 2 as const, expansionKind: "recursive_observed" as const, phrase: item.query, seedId: item.seedId, surfaceId: item.surfaceId })); +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/universalPatternLibrary.ts b/seo_mode/seo_mode/server/src/queryDiscovery/universalPatternLibrary.ts new file mode 100644 index 0000000..370ac33 --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/universalPatternLibrary.ts @@ -0,0 +1,104 @@ +/** + * Universal, domain-neutral query-pattern library. + * + * This is a language adapter, not a product taxonomy: it describes only + * generic search surfaces and the semantic slots they require. A project can + * influence availability through its selected semantic facets and evidence, + * but cannot inject a vertical-specific pattern into the decision layer. + */ +export const UNIVERSAL_OFFER_CLASSES = [ + "b2b_software", + "consumer_software", + "professional_service", + "local_service", + "ecommerce", + "education", + "marketplace", + "information_product" +] as const; + +export const UNIVERSAL_PATTERN_GROUPS = [ + "base_entity", + "category_process", + "entity_process", + "problem_process", + "service", + "integration", + "informational", + "comparison" +] as const; + +export type UniversalOfferClass = (typeof UNIVERSAL_OFFER_CLASSES)[number]; +export type UniversalQueryIntent = "commercial" | "comparison" | "informational" | "integration" | "problem" | "transactional"; +export type UniversalQueryPageType = "article" | "comparison" | "integration" | "product" | "service" | "use_case"; +export type UniversalQuerySlot = "action" | "category" | "entity" | "process" | "system"; + +export type UniversalPatternDescriptor = { + id: string; + group: (typeof UNIVERSAL_PATTERN_GROUPS)[number]; + offerClasses: readonly UniversalOfferClass[]; + intent: UniversalQueryIntent; + pageType: UniversalQueryPageType; + slots: readonly UniversalQuerySlot[]; + template: string; + /** Generic facet kinds supported by the pattern; absent means any facet. */ + allowedFacetKinds?: readonly string[]; + requiresGrammaticalProcess?: boolean; +}; + +export type UniversalFacetPatternInput = { + sourceDirectionKind: string; + facetKind?: string | null; + offerClasses: readonly string[]; + allowedPatternGroups: readonly string[]; +}; + +const SOFTWARE_CLASSES: UniversalOfferClass[] = ["b2b_software", "consumer_software", "information_product", "marketplace"]; +const SOFTWARE_OR_SERVICE: UniversalOfferClass[] = ["b2b_software", "consumer_software", "professional_service", "information_product"]; +const ENTITY_FACETS = ["product_category", "service_action", "implementation", "use_case", "process_automation"]; +const PROCESS_FACETS = ["process_automation", "problem_solution", "use_case", "implementation"]; + +export const UNIVERSAL_QUERY_PATTERNS: UniversalPatternDescriptor[] = [ + { id: "base_entity", group: "base_entity", offerClasses: [...UNIVERSAL_OFFER_CLASSES], intent: "commercial", pageType: "product", slots: ["entity"], template: "{entity}" }, + { id: "category_for_process", group: "category_process", offerClasses: SOFTWARE_CLASSES, intent: "commercial", pageType: "product", requiresGrammaticalProcess: true, slots: ["category", "process"], template: "{category} для {process}", allowedFacetKinds: PROCESS_FACETS }, + { id: "system_for_process", group: "category_process", offerClasses: SOFTWARE_CLASSES, intent: "commercial", pageType: "product", requiresGrammaticalProcess: true, slots: ["process"], template: "система {process}", allowedFacetKinds: PROCESS_FACETS }, + { id: "platform_for_process", group: "category_process", offerClasses: SOFTWARE_CLASSES, intent: "commercial", pageType: "product", requiresGrammaticalProcess: true, slots: ["process"], template: "платформа {process}", allowedFacetKinds: PROCESS_FACETS }, + { id: "entity_for_process", group: "entity_process", offerClasses: [...UNIVERSAL_OFFER_CLASSES], intent: "problem", pageType: "use_case", requiresGrammaticalProcess: true, slots: ["entity", "process"], template: "{entity} для {process}", allowedFacetKinds: PROCESS_FACETS }, + { id: "entity_for_business", group: "entity_process", offerClasses: ["b2b_software", "professional_service", "information_product"], intent: "commercial", pageType: "product", slots: ["entity"], template: "{entity} для бизнеса", allowedFacetKinds: ENTITY_FACETS }, + { id: "entity_for_enterprise", group: "entity_process", offerClasses: ["b2b_software", "professional_service"], intent: "commercial", pageType: "product", slots: ["entity"], template: "{entity} для предприятий", allowedFacetKinds: ENTITY_FACETS }, + { id: "automation_process", group: "problem_process", offerClasses: SOFTWARE_OR_SERVICE, intent: "problem", pageType: "use_case", requiresGrammaticalProcess: true, slots: ["process"], template: "автоматизация {process}", allowedFacetKinds: PROCESS_FACETS }, + { id: "management_process", group: "problem_process", offerClasses: SOFTWARE_OR_SERVICE, intent: "problem", pageType: "use_case", requiresGrammaticalProcess: true, slots: ["process"], template: "управление {process}", allowedFacetKinds: PROCESS_FACETS }, + { id: "control_process", group: "problem_process", offerClasses: SOFTWARE_OR_SERVICE, intent: "problem", pageType: "use_case", requiresGrammaticalProcess: true, slots: ["process"], template: "контроль {process}", allowedFacetKinds: PROCESS_FACETS }, + { id: "development_entity", group: "service", offerClasses: ["professional_service"], intent: "transactional", pageType: "service", slots: ["entity"], template: "разработка {entity}", allowedFacetKinds: ["service_action", "implementation"] }, + { id: "implementation_entity", group: "service", offerClasses: ["professional_service", "b2b_software"], intent: "transactional", pageType: "service", slots: ["entity"], template: "внедрение {entity}", allowedFacetKinds: ["service_action", "implementation"] }, + { id: "configuration_entity", group: "service", offerClasses: ["professional_service", "b2b_software"], intent: "transactional", pageType: "service", slots: ["entity"], template: "настройка {entity}", allowedFacetKinds: ["service_action", "implementation"] }, + { id: "consulting_entity", group: "service", offerClasses: ["professional_service"], intent: "transactional", pageType: "service", slots: ["entity"], template: "консалтинг {entity}", allowedFacetKinds: ["service_action", "implementation"] }, + { id: "integration_entity_system", group: "integration", offerClasses: ["b2b_software", "consumer_software", "professional_service"], intent: "integration", pageType: "integration", slots: ["entity", "system"], template: "{entity} интеграция с {system}", allowedFacetKinds: ["integration_system"] }, + { id: "what_is_entity", group: "informational", offerClasses: [...UNIVERSAL_OFFER_CLASSES], intent: "informational", pageType: "article", slots: ["entity"], template: "что такое {entity}" }, + { id: "how_entity_works", group: "informational", offerClasses: [...UNIVERSAL_OFFER_CLASSES], intent: "informational", pageType: "article", slots: ["entity"], template: "как работает {entity}" }, + { id: "entity_use_cases", group: "informational", offerClasses: [...UNIVERSAL_OFFER_CLASSES], intent: "informational", pageType: "article", slots: ["entity"], template: "применение {entity}" }, + { id: "entity_comparison", group: "comparison", offerClasses: SOFTWARE_CLASSES, intent: "comparison", pageType: "comparison", slots: ["entity"], template: "{entity} сравнение", allowedFacetKinds: ["product_category", "comparison"] }, + { id: "entity_alternatives", group: "comparison", offerClasses: SOFTWARE_CLASSES, intent: "comparison", pageType: "comparison", slots: ["entity"], template: "альтернативы {entity}", allowedFacetKinds: ["product_category", "comparison"] } +]; + +export function selectUniversalFacetPatterns(input: { + patterns: readonly T[]; + facet: UniversalFacetPatternInput; +}): T[] { + const allowedGroups = new Set(input.facet.allowedPatternGroups); + + return input.patterns.filter((pattern) => { + // Process templates require a morphology-aware language adapter. Until it + // is available, they are deliberately withheld rather than concatenated + // into ungrammatical pseudo-queries. + if (pattern.requiresGrammaticalProcess) return false; + if (!pattern.offerClasses.some((offerClass) => input.facet.offerClasses.includes(offerClass))) return false; + if (pattern.allowedFacetKinds && (!input.facet.facetKind || !pattern.allowedFacetKinds.includes(input.facet.facetKind))) return false; + + // A monolithic legacy scope may expose only its base entity. Portfolio + // facets have explicit eligibility and may add approved pattern groups. + if (input.facet.sourceDirectionKind === "core_offer") return pattern.id === "base_entity"; + + return pattern.group === "base_entity" || allowedGroups.has(pattern.group); + }); +} diff --git a/seo_mode/seo_mode/server/src/queryDiscovery/wordstatQueueBuilder.ts b/seo_mode/seo_mode/server/src/queryDiscovery/wordstatQueueBuilder.ts new file mode 100644 index 0000000..c2d746c --- /dev/null +++ b/seo_mode/seo_mode/server/src/queryDiscovery/wordstatQueueBuilder.ts @@ -0,0 +1,83 @@ +import crypto from "node:crypto"; +import type { CanonicalQueryCluster } from "./canonicalQueryCluster.js"; + +export type QueryClusterDecisionStatus = "pending" | "approved" | "informational" | "rerouted" | "hidden" | "raw_only"; + +export type QueryClusterDecision = { + clusterId: string; + decisionStatus: QueryClusterDecisionStatus; + reason: string; + targetFacetId?: string | null; + decidedAt?: string | null; +}; + +export type WordstatQueueItem = { + id: string; + projectId: string; + clusterId: string; + facetId: string; + topicId?: string; + phrase: string; + normalizedPhrase: string; + operatorMode: "broad" | "quoted" | "exact_order" | "must_include"; + status: "queued" | "sent" | "validated" | "zero" | "error"; + sourceEvidence: { + observationIds: string[]; + seedIds: string[]; + userDecisionId?: string; + clusterId: string; + }; + reason: string; +}; + +function stableId(parts: string[]) { + return `wordstat-queue:${crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20)}`; +} + +export function buildWordstatQueue(input: { + projectId: string; + clusters: CanonicalQueryCluster[]; + decisions: Map; + maxPhrasesPerCluster?: number; + maxPhrasesPerFacet?: number; + allowAutoReady?: boolean; +}): WordstatQueueItem[] { + const maxPerCluster = Math.min(Math.max(input.maxPhrasesPerCluster ?? 2, 1), 3); + const maxPerFacet = Math.min(Math.max(input.maxPhrasesPerFacet ?? 12, 1), 30); + const facetCounts = new Map(); + const queue: WordstatQueueItem[] = []; + + for (const cluster of input.clusters) { + const decision = input.decisions.get(cluster.id); + const approved = decision?.decisionStatus === "approved" || (input.allowAutoReady === true && cluster.recommendedAction === "auto_wordstat_ready" && decision?.decisionStatus !== "hidden"); + if (!approved || ["informational", "integration", "comparison", "local"].includes(cluster.intent)) continue; + if ((facetCounts.get(cluster.facetId) ?? 0) >= maxPerFacet) continue; + + const phrases = [cluster.canonicalQuery, ...cluster.variants.map((variant) => variant.query)] + .filter((phrase, index, values) => values.findIndex((value) => value.toLocaleLowerCase() === phrase.toLocaleLowerCase()) === index) + .slice(0, maxPerCluster); + for (const phrase of phrases) { + if ((facetCounts.get(cluster.facetId) ?? 0) >= maxPerFacet) break; + queue.push({ + clusterId: cluster.id, + facetId: cluster.facetId, + id: stableId([input.projectId, cluster.id, phrase]), + normalizedPhrase: phrase.trim().replace(/\s+/g, " ").toLocaleLowerCase(), + operatorMode: "broad", + phrase, + projectId: input.projectId, + reason: decision?.reason || "Canonical observed-language cluster approved for bounded Wordstat review.", + sourceEvidence: { + clusterId: cluster.id, + observationIds: [...new Set(cluster.variants.map((variant) => variant.observationId))], + seedIds: [...new Set(cluster.variants.map((variant) => variant.seedId).filter((value): value is string => Boolean(value)))] + }, + status: "queued", + topicId: cluster.topicId + }); + facetCounts.set(cluster.facetId, (facetCounts.get(cluster.facetId) ?? 0) + 1); + } + } + + return queue; +} diff --git a/seo_mode/seo_mode/server/src/scripts/checkContextBusinessValue.ts b/seo_mode/seo_mode/server/src/scripts/checkContextBusinessValue.ts new file mode 100644 index 0000000..716bd43 --- /dev/null +++ b/seo_mode/seo_mode/server/src/scripts/checkContextBusinessValue.ts @@ -0,0 +1,531 @@ +import assert from "node:assert/strict"; +import { closeDatabase, pool } from "../db/client.js"; +import type { + SeoOpportunityDiscoveryCandidate, + SeoOpportunityDiscoveryContract, + SeoOpportunityScorecard +} from "../opportunity/seoOpportunityDiscovery.js"; +import { getSeoOpportunityDiscoveryContract } from "../opportunity/seoOpportunityDiscovery.js"; +import { getSeoContextOpportunityMap } from "../contextReview/contextOpportunityMap.js"; +import { getSeoProjectContextContract } from "../contextReview/seoContextReview.js"; +import { + buildSeoDemandResearchContextHash, + getSeoSearchScope, + type SeoDemandResearchBriefContract +} from "../contextReview/demandResearchBrief.js"; +import { getQueryDiscoveryContract, getTopicDecompositionModelTask } from "../queryDiscovery/queryDiscovery.js"; +import { getDemandScopeGuardSummary } from "../contextReview/demandScopeGuard.js"; +import { getRejectedDemandScopeReason, type SeoNormalizationContract } from "../normalization/seoNormalization.js"; +import type { SeoOpportunityCriticContract } from "../opportunity/seoOpportunityCritic.js"; +import { + getMarketFrontierDiscoveryContract, + getMarketFrontierMemoryHash +} from "../market/marketFrontierDiscovery.js"; +import { getSeoCommercialDemandContract } from "../commercial/seoCommercialDemand.js"; +import { + getDemandCollectionExecutionPhase, + getWordstatProbePlanPreview +} from "../market/marketEnrichment.js"; +import { getKeywordCleaningContract } from "../keywords/keywordCleaning.js"; +import { getKeywordMapContract } from "../keywords/keywordMap.js"; +import { getSeoStrategyContract } from "../strategy/seoStrategy.js"; +import { applyAnchorReviewEdit, getAnchorReviewContract } from "../keywords/anchorReview.js"; + +type OpportunityRunRow = { + id: string; + project_id: string; + output: SeoOpportunityDiscoveryContract; +}; + +type DemandBriefRunRow = { + output: SeoDemandResearchBriefContract; +}; + +type NormalizationRunRow = { + output: SeoNormalizationContract; +}; + +type CriticRunRow = { + id: string; + input: Record; + output: SeoOpportunityCriticContract; +}; + +type BusinessCandidateContract = Pick< + SeoOpportunityDiscoveryCandidate, + | "assumptions" + | "buyer" + | "critic" + | "demandStatus" + | "expectedSearchLanguage" + | "id" + | "jobToBeDone" + | "requiredAdaptations" + | "reusedCapabilityIds" + | "scorecard" + | "targetSector" + | "title" +>; + +function expectedOverall(score: SeoOpportunityScorecard) { + return Math.round( + score.productFit * 0.3 + + score.businessFit * 0.2 + + score.searchability * 0.15 + + score.timeToValue * 0.15 + + (100 - score.implementationDelta) * 0.1 + + (100 - score.regulatoryBurden) * 0.05 + + (100 - score.salesCycleComplexity) * 0.05 + ); +} + +function assertScorecard(candidate: BusinessCandidateContract) { + for (const [name, value] of Object.entries(candidate.scorecard)) { + assert.ok(Number.isInteger(value) && value >= 0 && value <= 100, `${candidate.id}: ${name} must be an integer in 0..100.`); + } + assert.equal(candidate.scorecard.overall, expectedOverall(candidate.scorecard), `${candidate.id}: overall score must be recalculated by the backend.`); +} + +function assertBusinessCandidate(candidate: BusinessCandidateContract, expectedVerdict: "defer" | "shortlist") { + assert.ok(candidate.id && candidate.title, "Every business candidate must have a stable id and title."); + assert.ok(candidate.targetSector && candidate.buyer && candidate.jobToBeDone, `${candidate.id}: sector, buyer and JTBD are required.`); + assert.ok(candidate.reusedCapabilityIds.length > 0, `${candidate.id}: at least one proven capability must be reused.`); + assert.ok(candidate.requiredAdaptations.length > 0, `${candidate.id}: required adaptations must be explicit.`); + assert.ok(candidate.expectedSearchLanguage.length >= 2, `${candidate.id}: expected search language must contain at least two hypotheses.`); + assert.ok(candidate.assumptions.length > 0, `${candidate.id}: assumptions must be explicit.`); + assert.equal(candidate.demandStatus, "unverified", `${candidate.id}: demand cannot be claimed before market evidence.`); + assert.equal(candidate.critic.verdict, expectedVerdict, `${candidate.id}: critic verdict must match the portfolio bucket.`); + assert.ok(candidate.critic.reason && candidate.critic.strongestCase, `${candidate.id}: critic must explain both the case and the verdict.`); + assert.ok(candidate.critic.missingEvidence.length > 0, `${candidate.id}: missing evidence must stay visible.`); + assertScorecard(candidate); +} + +try { + businessValueCheck: { + const requestedProjectId = process.env.SEO_PROJECT_ID?.trim() || null; + const params = requestedProjectId ? [requestedProjectId] : []; + const projectFilter = requestedProjectId ? "and project_id = $1" : ""; + const opportunityResult = await pool.query( + ` + select id, project_id, output + from runs + where run_type = 'seo_opportunity_discovery' + and status = 'done' + and output->>'state' = 'ready' + ${projectFilter} + order by created_at desc + limit 1; + `, + params + ); + const run = opportunityResult.rows[0]; + assert.ok(run, "A promoted Opportunity Discovery run is required for the business-value check."); + + const discovery = run.output; + assert.equal(discovery.schemaVersion, "seo-opportunity-discovery.v1"); + assert.equal(discovery.state, "ready"); + assert.equal(discovery.provider.modelRequired, true, "Opportunity Discovery must never be silently replaced by deterministic invention."); + assert.ok(discovery.capabilityPrimitives.length >= 3, "The interpreter needs a meaningful transferable-capability foundation."); + assert.ok(discovery.capabilityPrimitives.every((item) => item.id && item.title && item.description && item.evidenceRefs.length > 0), "Every capability must remain evidence-backed."); + assert.ok(discovery.opportunities.length >= 3, "The shortlist must contain at least three critic-approved opportunities."); + assert.ok(new Set(discovery.opportunities.map((item) => item.targetSector.toLocaleLowerCase("ru-RU"))).size >= 3, "The shortlist must cover at least three distinct sectors."); + discovery.opportunities.forEach((candidate) => assertBusinessCandidate(candidate, "shortlist")); + discovery.deferredOpportunities.forEach((candidate) => assertBusinessCandidate(candidate, "defer")); + assert.ok(discovery.deferredOpportunities.length > 0, "The analyst must preserve promising but premature directions."); + assert.ok(discovery.rejectedCandidates.length > 0, "The critic must preserve rejected ideas instead of only generating positives."); + assert.ok(discovery.rejectedCandidates.every((item) => item.id && item.title && item.reason), "Every rejected idea must have an auditable reason."); + assert.equal(discovery.portfolio.shortlistCount, discovery.opportunities.length); + assert.equal(discovery.portfolio.deferredCount, discovery.deferredOpportunities.length); + assert.equal(discovery.portfolio.rejectedCount, discovery.rejectedCandidates.length); + + const liveDiscovery = await getSeoOpportunityDiscoveryContract(run.project_id); + const searchPolicy = liveDiscovery.modelTask?.input.opportunitySearchPolicy; + assert.ok(searchPolicy, "Opportunity Discovery must expose a universal opportunity-search policy."); + assert.ok(searchPolicy.explorationLenses.length >= 6, "Opportunity search must use multiple capability-transfer lenses."); + assert.ok(searchPolicy.visiblePortfolioTarget.min >= 6, "Visible opportunity target must scale beyond a tiny shortlist."); + assert.ok( + liveDiscovery.modelTask!.stopConditions.some((condition) => condition.includes("expectedSearchLanguage содержит минимум 4")), + "Fresh opportunity runs must require four differentiated search-language hypotheses per candidate." + ); + assert.equal( + "sectorTaxonomy" in liveDiscovery.modelTask!.input, + false, + "Opportunity search must not depend on a hardcoded sector taxonomy." + ); + const highTransferabilityCount = liveDiscovery.capabilityPrimitives.filter((item) => item.transferability === "high").length; + if (highTransferabilityCount >= 3) { + assert.ok( + searchPolicy.visiblePortfolioTarget.min >= 15, + "Highly transferable products must request at least 15 visible opportunity hypotheses." + ); + } + + const opportunityMap = await getSeoContextOpportunityMap(run.project_id); + const projectContext = await getSeoProjectContextContract(run.project_id); + assert.equal(projectContext.evidenceIntegrity.invalidRefs.length, 0, "Promoted Context Review must contain canonical evidence refs only."); + const visibleDeferred = opportunityMap.opportunities.filter( + (item) => item.sourceKind === "discovered_opportunity" && item.critic?.verdict === "defer" + ); + assert.ok(visibleDeferred.length > 0, "Context Dev must keep defer hypotheses visible instead of hiding them after Critic."); + + const allCandidateIds = [ + ...discovery.opportunities.map((item) => item.id), + ...discovery.deferredOpportunities.map((item) => item.id), + ...discovery.rejectedCandidates.map((item) => item.id) + ]; + assert.equal(new Set(allCandidateIds).size, allCandidateIds.length, "Opportunity ids must be unique across shortlist, deferred and rejected memory."); + + const criticResult = await pool.query( + ` + select id, input, output + from runs + where project_id = $1 + and run_type = 'seo_opportunity_critic' + and status = 'done' + and output->>'opportunityDiscoveryRunId' = $2 + order by created_at desc + limit 1; + `, + [run.project_id, run.id] + ); + const criticRun = criticResult.rows[0]; + assert.ok(criticRun, "A separate promoted Opportunity Critic run is required."); + assert.notEqual(criticRun.id, run.id, "Critic must have an independent run id."); + assert.equal(criticRun.output.schemaVersion, "seo-opportunity-critic.v1"); + assert.equal(criticRun.output.state, "ready"); + assert.equal(criticRun.output.portfolioVerdict.status, "approved"); + assert.ok(criticRun.output.portfolioVerdict.approvedCandidateIds.length >= 2, "Independent Critic must approve at least two candidates for human selection."); + assert.ok(criticRun.output.portfolioVerdict.sectorDiversity >= 2, "Independent Critic must preserve at least two sectors."); + assert.equal( + criticRun.output.reviews.length, + discovery.opportunities.length + discovery.deferredOpportunities.length, + "Independent Critic must review every generated shortlist/deferred candidate." + ); + assert.ok( + criticRun.output.reviews + .filter((review) => review.verdict === "shortlist") + .every((review) => review.fatalRisks.length === 0 && review.confidence !== "low"), + "Shortlisted critic reviews cannot keep fatal risks or low confidence." + ); + + const briefResult = await pool.query( + ` + select output + from runs + where project_id = $1 + and run_type = 'seo_demand_research_brief' + and status = 'done' + and output->'sourceRuns'->>'opportunityDiscoveryRunId' = $2 + order by created_at desc + limit 1; + `, + [run.project_id, run.id] + ); + const brief = briefResult.rows[0]?.output; + if (!brief) { + assert.notEqual( + opportunityMap.selection.status, + "approved", + "An approved Context Dev portfolio must always produce a Demand Research Brief." + ); + console.info(JSON.stringify({ + status: "passed_preapproval", + projectId: run.project_id, + opportunityDiscoveryRunId: run.id, + capabilities: discovery.capabilityPrimitives.length, + shortlisted: discovery.opportunities.length, + deferred: discovery.deferredOpportunities.length, + rejected: discovery.rejectedCandidates.length, + visibleOpportunities: opportunityMap.opportunities.length, + selectionStatus: opportunityMap.selection.status, + note: "Context Dev is waiting for human 4.2 approval; downstream demand checks are intentionally deferred." + }, null, 2)); + break businessValueCheck; + } + assert.equal(brief.state, "approved"); + assert.match(brief.contextHash, /^[a-f0-9]{64}$/, "Approved context must have a stable SHA-256 hash."); + assert.equal(brief.sourceRuns.opportunityDiscoveryRunId, run.id); + assert.equal(brief.sourceRuns.opportunityCriticRunId, criticRun.id); + assert.equal(brief.approval.selectedCount, brief.selectedCurrentApplications.length + brief.selectedExpansionHypotheses.length); + assert.ok(brief.fixedCorePillars.length > 0, "The fixed product core must reach demand research independently of opportunity selection."); + assert.ok(brief.offerMap?.coreOffer.plainSummary, "The fixed product core must remain explicit in the Offer Map."); + assert.ok( + brief.semanticPortfolio?.facets.some((facet) => facet.relationshipToCore === "core_facet"), + "The semantic portfolio must preserve at least one current-core facet even for an expansion-only opportunity selection." + ); + const resolvedSearchScope = await getSeoSearchScope(run.project_id, brief); + const topicTask = await getTopicDecompositionModelTask(run.project_id); + assert.ok(topicTask?.input.offerMap, "Query discovery must receive an explicit Offer Map, including legacy brief fallback."); + assert.ok(resolvedSearchScope, "Demand research must expose an explicit Search Scope before query discovery."); + assert.ok( + resolvedSearchScope?.mode === "balanced_auto" || resolvedSearchScope?.mode === "human_selected", + "Query discovery must use an evidence-backed balanced scope or an explicit human-selected scope." + ); + if (resolvedSearchScope?.mode === "balanced_auto") { + assert.ok(resolvedSearchScope.vectors.length >= 3, "Balanced scope must preserve multiple core facets when evidence supports them."); + assert.ok( + resolvedSearchScope.vectors.every((vector) => vector.source === "core_facet"), + "Current applications and expansion hypotheses must not become the default Search Scope." + ); + } else { + assert.ok((resolvedSearchScope?.vectors.length ?? 0) > 0, "Human-selected Search Scope cannot be empty."); + assert.deepEqual( + resolvedSearchScope?.vectors.map((vector) => vector.id), + resolvedSearchScope?.selectedVectorIds, + "Human-selected Search Scope must preserve the exact selected vector lineage." + ); + } + + const hashInput: Omit = { + ...brief, + approval: { ...brief.approval, approvedAt: "2026-07-10T00:00:00.000Z" }, + sourceRuns: { + ...brief.sourceRuns, + contextReviewRunId: "context-review-reapproval-a", + opportunityDecisionRunId: "opportunity-reapproval-a" + } + }; + delete (hashInput as Partial).contextHash; + delete (hashInput as Partial).generatedAt; + const stableHashA = buildSeoDemandResearchContextHash(hashInput); + const stableHashB = buildSeoDemandResearchContextHash({ + ...hashInput, + approval: { ...hashInput.approval, approvedAt: "2026-07-10T23:59:59.000Z" }, + sourceRuns: { + ...hashInput.sourceRuns, + contextReviewRunId: "context-review-reapproval-b", + opportunityDecisionRunId: "opportunity-reapproval-b" + } + }); + assert.equal(stableHashA, stableHashB, "Identical approved scope must keep the same context hash across re-approval."); + + for (const selectedDirection of [ + ...brief.selectedCurrentApplications, + ...brief.selectedExpansionHypotheses + ]) { + assert.equal( + getRejectedDemandScopeReason(selectedDirection.title, brief), + null, + `${selectedDirection.id}: selected directions must not be blocked by the rejected-scope guard.` + ); + } + assert.ok( + brief.rejectedDirections.some((direction) => getRejectedDemandScopeReason(direction.title, brief)), + "The hard scope guard must recognize at least one explicitly rejected direction without relying on the model response." + ); + + const normalizationResult = await pool.query( + ` + select output + from runs + where project_id = $1 + and run_type = 'seo_normalization' + and status = 'done' + order by created_at desc + limit 1; + `, + [run.project_id] + ); + const normalization = normalizationResult.rows[0]?.output; + assert.ok(normalization, "A promoted normalization run is required for the scope-guard check."); + const queryDiscovery = await getQueryDiscoveryContract(run.project_id); + const selectedFacetIds = new Set(resolvedSearchScope?.selectedVectorIds ?? []); + assert.ok( + queryDiscovery.wordstatQueue.every((item) => selectedFacetIds.has(item.facetId)), + "Active pre-Wordstat queue must preserve the explicit selected-facet authority chain." + ); + if (normalization.demandResearchBriefHash !== brief.contextHash) { + console.info(JSON.stringify({ + status: "passed_prewordstat", + projectId: run.project_id, + queryDiscoveryState: queryDiscovery.state, + wordstatQueueItems: queryDiscovery.wordstatQueue.length, + note: "Legacy normalization is stale for the latest approved Demand Research Brief; downstream checks are intentionally deferred until a separately authorized post-Wordstat run." + }, null, 2)); + break businessValueCheck; + } + + const [commercialDemand, marketFrontier, wordstatProbePlan, keywordCleaning, keywordMap, strategy] = await Promise.all([ + getSeoCommercialDemandContract(run.project_id), + getMarketFrontierDiscoveryContract(run.project_id), + getWordstatProbePlanPreview(run.project_id), + getKeywordCleaningContract(run.project_id), + getKeywordMapContract(run.project_id), + getSeoStrategyContract(run.project_id) + ]); + const anchorReview = await getAnchorReviewContract(run.project_id); + assert.ok(anchorReview.anchors.length >= 2, "Anchor edit/merge checker requires at least two live seed anchors."); + const [firstAnchor, secondAnchor] = anchorReview.anchors; + const editedPhrase = `${firstAnchor.phrase} проверка редактора`; + const editPreview = applyAnchorReviewEdit( + anchorReview, + { anchorId: firstAnchor.id, phrase: editedPhrase }, + "2026-07-10T00:00:00.000Z" + ); + assert.equal(editPreview.anchorReview.anchors.length, anchorReview.anchors.length, "Phrase edit must replace one anchor without changing visible count."); + assert.ok(editPreview.anchorReview.anchors.some((anchor) => anchor.phrase === editedPhrase), "Edited phrase must be visible as a persistent manual replacement."); + assert.ok(editPreview.anchorReview.deletedAnchors.some((anchor) => anchor.id === firstAnchor.id), "The immutable source anchor must remain in deleted audit memory."); + const mergePreview = applyAnchorReviewEdit( + anchorReview, + { anchorId: firstAnchor.id, phrase: secondAnchor.phrase }, + "2026-07-10T00:00:00.000Z" + ); + assert.equal(mergePreview.mergedIntoAnchorId, secondAnchor.id, "Editing to an existing phrase must use explicit backend merge semantics."); + assert.equal(mergePreview.anchorReview.anchors.length, anchorReview.anchors.length - 1, "Merged anchors must not leave a duplicate visible row."); + assert.ok(commercialDemand, "A scoped commercial-demand contract is required after Context Dev approval."); + assert.equal( + commercialDemand.demandResearchBriefHash, + brief.contextHash, + "Commercial demand must be aligned with the latest approved Demand Research Brief." + ); + assert.ok( + commercialDemand.buyerIntentFamilies.every((family) => + family.queryPatterns.every((phrase) => !getRejectedDemandScopeReason(phrase, brief)) + ), + "Active commercial buyer-intent query patterns must not restore rejected directions." + ); + assert.ok( + commercialDemand.applicationCommercialAngles.every((angle) => + getDemandScopeGuardSummary(brief).selectedDirectionIds.includes(angle.id) || + !getRejectedDemandScopeReason( + `${angle.sourceVector} ${angle.commercialReframe} ${angle.queryPatterns.join(" ")}`, + brief + ) + ), + "Commercial application angles must remain inside the approved scope." + ); + assert.ok( + marketFrontier.frontiers.every((frontier) => !getRejectedDemandScopeReason( + `${frontier.title} ${frontier.rationale} ${frontier.probeSeeds.join(" ")}`, + brief + )), + "Market frontiers must not resurrect user-rejected directions." + ); + assert.ok( + marketFrontier.probeSeeds.every((seed) => !getRejectedDemandScopeReason( + `${seed.frontierTitle} ${seed.phrase} ${seed.reason}`, + brief + )), + "Frontier Wordstat probes must remain inside the approved demand scope." + ); + assert.equal( + getDemandCollectionExecutionPhase("market_wide", 0), + "exploratory_wordstat", + "Market-wide collection must begin with an exploratory anchor batch." + ); + assert.equal( + getDemandCollectionExecutionPhase("market_wide", 1), + "deep_wordstat", + "Market-wide collection may enter deep harvest only after exploratory evidence exists." + ); + const frontierHashBeforeEvidence = getMarketFrontierMemoryHash({ + fingerprint: marketFrontier.queryLanguageFingerprint, + memory: marketFrontier.marketMemory, + projectOntologyVersionId: marketFrontier.projectOntologyVersionId, + semanticRunId: marketFrontier.semanticRunId ?? brief.sourceRuns.semanticRunId + }); + const frontierHashAfterEvidence = getMarketFrontierMemoryHash({ + fingerprint: { + ...marketFrontier.queryLanguageFingerprint, + generatedFrom: { + ...marketFrontier.queryLanguageFingerprint.generatedFrom, + relatedPhraseCount: marketFrontier.queryLanguageFingerprint.generatedFrom.relatedPhraseCount + 1 + }, + workingPatterns: [ + ...marketFrontier.queryLanguageFingerprint.workingPatterns, + { count: 1, examples: ["fixture buyer language"], pattern: "fixture-pattern" } + ] + }, + memory: { + ...marketFrontier.marketMemory, + coveredPhrases: [...marketFrontier.marketMemory.coveredPhrases, "fixture buyer language"] + }, + projectOntologyVersionId: marketFrontier.projectOntologyVersionId, + semanticRunId: marketFrontier.semanticRunId ?? brief.sourceRuns.semanticRunId + }); + assert.notEqual( + frontierHashBeforeEvidence, + frontierHashAfterEvidence, + "Exploratory evidence must invalidate the pre-Wordstat frontier task and force a fingerprint-aware model pass." + ); + assert.ok( + wordstatProbePlan.selectedProbes.every((probe) => !getRejectedDemandScopeReason( + `${probe.phrase} ${probe.clusterTitle} ${probe.reason}`, + brief + )), + "The executable Wordstat probe plan must reject unapproved directions." + ); + assert.ok( + keywordCleaning.items + .filter((item) => item.decision !== "trash") + .every((item) => !getRejectedDemandScopeReason( + `${item.phrase} ${item.sourcePhrase ?? ""} ${item.clusterTitle}`, + brief + )), + "No rejected direction may remain active after keyword cleaning." + ); + assert.ok( + keywordMap.items.every((item) => !getRejectedDemandScopeReason(item.phrase, brief)), + "Keyword map must expose only approved-scope phrases." + ); + assert.ok( + strategy.opportunities.every((opportunity) => !getRejectedDemandScopeReason( + `${opportunity.title} ${opportunity.demand.topPhrase ?? ""}`, + brief + )), + "Strategy opportunities must not resurrect directions rejected at Context Dev 4.2." + ); + + const reviewedCandidates = new Map( + [...discovery.opportunities, ...discovery.deferredOpportunities].map((item) => [item.id, item] as const) + ); + const independentlyApprovedIds = new Set(criticRun.output.portfolioVerdict.approvedCandidateIds); + for (const hypothesis of brief.selectedExpansionHypotheses) { + const reviewedCandidate = reviewedCandidates.get(hypothesis.id); + assert.ok(reviewedCandidate, `${hypothesis.id}: a human-selected expansion must have an auditable Critic review.`); + if (hypothesis.critic?.verdict === "shortlist") { + assert.ok(independentlyApprovedIds.has(hypothesis.id), `${hypothesis.id}: shortlisted handoff must be independently approved.`); + } else { + assert.equal( + hypothesis.critic?.verdict, + "defer", + `${hypothesis.id}: a human override may preserve a reviewed defer hypothesis, but never an unreviewed or rejected candidate.` + ); + } + assert.ok(hypothesis.targetSector && hypothesis.buyer && hypothesis.jobToBeDone && hypothesis.scorecard && hypothesis.critic, `${hypothesis.id}: approved expansion must preserve the full business contract.`); + assert.equal(hypothesis.demandStatus, "unverified", `${hypothesis.id}: approved expansion must not turn a hypothesis into claimed demand.`); + assertBusinessCandidate({ + ...hypothesis, + buyer: hypothesis.buyer, + critic: hypothesis.critic, + demandStatus: "unverified", + jobToBeDone: hypothesis.jobToBeDone, + scorecard: hypothesis.scorecard, + targetSector: hypothesis.targetSector + }, hypothesis.critic.verdict); + } + + console.info(JSON.stringify({ + status: "passed", + projectId: run.project_id, + opportunityDiscoveryRunId: run.id, + contextHash: brief.contextHash, + capabilities: discovery.capabilityPrimitives.length, + shortlisted: discovery.opportunities.length, + deferred: discovery.deferredOpportunities.length, + rejected: discovery.rejectedCandidates.length, + approvedCurrentApplications: brief.selectedCurrentApplications.length, + approvedExpansionHypotheses: brief.selectedExpansionHypotheses.length, + commercialBuyerIntentFamilies: commercialDemand.buyerIntentFamilies.length, + marketFrontiers: marketFrontier.frontiers.length, + selectedWordstatProbes: wordstatProbePlan.selectedProbes.length, + activeCleaningItems: keywordCleaning.items.filter((item) => item.decision !== "trash").length, + keywordMapItems: keywordMap.items.length, + strategyOpportunities: strategy.opportunities.length, + anchorEditMerge: "passed", + phasedDemandLoop: "passed" + }, null, 2)); + } +} finally { + await closeDatabase(); +} diff --git a/seo_mode/seo_mode/server/src/scripts/checkOntologyUniversality.ts b/seo_mode/seo_mode/server/src/scripts/checkOntologyUniversality.ts index 48b5e4b..e11aebb 100644 --- a/seo_mode/seo_mode/server/src/scripts/checkOntologyUniversality.ts +++ b/seo_mode/seo_mode/server/src/scripts/checkOntologyUniversality.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import { getProjectOntology, type ProjectOntologyEvidence } from "../ontology/projectOntology.js"; function buildEvidence(sourceConfig: Record = {}): ProjectOntologyEvidence { @@ -62,6 +63,23 @@ assert.ok( "Generic extraction must not inject SaaS/automation Wordstat seeds." ); +const dottedBrandEvidence = buildEvidence({ rootName: "ACME.LAB" }); +dottedBrandEvidence.pages = dottedBrandEvidence.pages.map((page, index) => ({ + ...page, + title: index === 0 ? "ACME.LAB — лабораторное оборудование" : "ACME.LAB — каталог и доставка", + headings: index === 0 ? ["Оборудование ACME.LAB"] : ["Доставка ACME.LAB"], + text: `ACME.LAB поставляет лабораторное оборудование. ${page.text}` +})); +const dottedBrandOntology = await getProjectOntology("universality-dotted-brand", dottedBrandEvidence); +const dottedAliases = dottedBrandOntology.projectOntologyInstance?.brand.aliases ?? []; + +assert.equal(dottedBrandOntology.brandName, "ACME.LAB"); +assert.ok(!dottedAliases.some((alias) => /^(acme|lab)$/iu.test(alias)), "A dotted brand must not be split into generic token aliases."); +assert.ok( + dottedAliases.some((alias) => normalizeAlias(alias) === "acmelab"), + "A dotted brand should keep a safe punctuation-free full-brand alias." +); + const fixtureOntology = await getProjectOntology( "universality-check", buildEvidence({ @@ -75,4 +93,26 @@ assert.ok( "NODE.DC fixture must be available only when explicitly requested." ); +const opportunitySource = await readFile(new URL("../opportunity/seoOpportunityDiscovery.ts", import.meta.url), "utf8"); +const frontierSource = await readFile(new URL("../market/marketFrontierDiscovery.ts", import.meta.url), "utf8"); + +assert.ok(!opportunitySource.includes("SECTOR_TAXONOMY"), "Opportunity Discovery must not restore a hardcoded sector taxonomy."); +for (const forbiddenSeed of [ + "ии для бизнеса", + "ии агенты для бизнеса", + "ии для малого бизнеса", + "применение ии в строительстве", + "цифровой двойник в строительстве", + "ии интеграция с 1с" +]) { + assert.ok( + !frontierSource.toLocaleLowerCase("ru-RU").includes(forbiddenSeed), + `Market Frontier must derive \"${forbiddenSeed}\" from project/model evidence instead of shipping it as a built-in seed.` + ); +} + console.info("Ontology universality check passed."); + +function normalizeAlias(value: string) { + return value.toLocaleLowerCase("ru-RU").replace(/[^\p{L}\p{N}]+/gu, ""); +} diff --git a/seo_mode/seo_mode/server/src/scripts/checkPositioningIntelligence.ts b/seo_mode/seo_mode/server/src/scripts/checkPositioningIntelligence.ts new file mode 100644 index 0000000..9402d97 --- /dev/null +++ b/seo_mode/seo_mode/server/src/scripts/checkPositioningIntelligence.ts @@ -0,0 +1,226 @@ +import assert from "node:assert/strict"; +import { + assessPhrasePositioning, + buildSeoPositioningThesis, + getSeoPositioningRevision, + hasExplicitCompetitorRelation +} from "../contextReview/positioningThesis.js"; +import type { SeoDemandResearchBriefContract } from "../contextReview/demandResearchBrief.js"; +import type { SeoOwnerStrategyInput } from "../contextReview/ownerStrategy.js"; +import { getRejectedDemandScopeReasonFromGuard } from "../contextReview/demandScopeGuard.js"; +import { resolveKeywordMapReviewState } from "../keywords/keywordMap.js"; +import { getStrategyCandidateDecisionStatus, resolveSeoStrategyState } from "../strategy/seoStrategy.js"; +import type { PostWordstatProductFitCandidate } from "../queryDiscovery/postWordstatProductFitGate.js"; + +function brief(input: { + brand: string; + category: string; + offer: string; + thesis: string; + differentiators: string[]; + mechanisms: string[]; +}) { + return { + contextHash: `fixture:${input.brand}`, + coreContext: { + audience: "покупатели", + audienceReason: "fixture", + brandName: input.brand, + centralThesis: input.thesis, + coreOffer: input.offer, + identity: `${input.brand}: ${input.offer}`, + productCategory: input.category, + summary: input.offer + }, + fixedCorePillars: input.mechanisms.map((title, index) => ({ + evidenceRefs: [`fixture:page:${index}`], + grounding: "explicit", + id: `pillar:${index}`, + role: "product mechanism", + title, + whyItMatters: input.differentiators[index] ?? input.thesis + })), + offerMap: { + buyerRoles: ["покупатель"], + differentiators: input.differentiators, + evidenceRefs: ["fixture:home"], + marketCategories: [input.category], + plainLanguageCoreOffer: input.offer, + version: "seo-offer-map.v3" + }, + rejectedDirections: [], + searchScope: { vectors: [] }, + selectedCurrentApplications: [], + selectedExpansionHypotheses: [], + state: "approved" + } as unknown as SeoDemandResearchBriefContract; +} + +function owner(projectId: string): SeoOwnerStrategyInput { + return { + categoryExclusions: ["CRM", "ERP"], + expansionPolicy: "balanced", + notes: "Strategy is a prior, not factual evidence.", + priorityThemes: ["управляемые ИИ-агенты для бизнеса", "интеллектуальная автоматизация"], + projectId, + referenceAnalogues: [{ + caveat: "Only a strategic reference; no feature parity claim.", + label: "Reference Product", + transferableTraits: ["data-to-decision-to-action", "операционный контекст решений"] + }], + schemaVersion: "seo-owner-strategy.v1", + strategicStatement: "Стать интеллектуальным операционным слоем, который доводит данные до контролируемого действия.", + updatedAt: new Date().toISOString() + }; +} + +const software = buildSeoPositioningThesis("fixture:software", brief({ + brand: "Universal Ops", + category: "корпоративная платформа автоматизации", + differentiators: ["управляемые ИИ-агенты", "единый контекст данных и действий"], + mechanisms: ["агентный слой", "оркестрация рабочих процессов"], + offer: "платформа для автоматизации процессов и управления ИИ-агентами", + thesis: "данные, решения и действия связаны в управляемом операционном контуре" +}), owner("fixture:software")); + +assert.equal(software.state, "ready"); +assert.equal(software.guardPolicy.ownerStrategyIsFactualEvidence, false); +assert.ok(software.categoryArchitecture.currentCategories.every((claim) => claim.provenance === "site_evidence")); +assert.ok(software.strategicIntent.priorityThemes.every((claim) => claim.provenance === "owner_strategy")); +assert.ok(!software.strategicIntent.referenceAnalogues[0].transferableTraits.some((claim) => claim.text.includes("Reference Product"))); + +const agentPhrase = assessPhrasePositioning("платформа управляемых ии агентов для бизнеса", software); +assert.equal(agentPhrase.lane, "differentiated_core"); +assert.ok(agentPhrase.strategicFit > 0); +assert.notEqual(assessPhrasePositioning("автоматизация бизнес процессов", software).lane, "differentiated_core"); +assert.notEqual( + assessPhrasePositioning("ии автоматизация бизнес процессов", software).lane, + "differentiated_core", + "Generic AI/category bridge must not outrank a distinctive current product anchor." +); + +const sameOwnerLater = { ...owner("fixture:software"), updatedAt: new Date(Date.now() + 60_000).toISOString() }; +assert.equal( + getSeoPositioningRevision("fixture:software", brief({ + brand: "Universal Ops", + category: "корпоративная платформа автоматизации", + differentiators: ["управляемые ИИ-агенты", "единый контекст данных и действий"], + mechanisms: ["агентный слой", "оркестрация рабочих процессов"], + offer: "платформа для автоматизации процессов и управления ИИ-агентами", + thesis: "данные, решения и действия связаны в управляемом операционном контуре" + }), sameOwnerLater), + getSeoPositioningRevision("fixture:software", brief({ + brand: "Universal Ops", + category: "корпоративная платформа автоматизации", + differentiators: ["управляемые ИИ-агенты", "единый контекст данных и действий"], + mechanisms: ["агентный слой", "оркестрация рабочих процессов"], + offer: "платформа для автоматизации процессов и управления ИИ-агентами", + thesis: "данные, решения и действия связаны в управляемом операционном контуре" + }), { ...sameOwnerLater, updatedAt: new Date(Date.now() + 120_000).toISOString() }) +); +assert.notEqual( + getSeoPositioningRevision("fixture:software", null, sameOwnerLater), + getSeoPositioningRevision("fixture:software", null, { ...sameOwnerLater, priorityThemes: [...sameOwnerLater.priorityThemes, "новый стратегический вектор"] }) +); + +const serviceBrief = brief({ + brand: "Local Care", + category: "сервис ремонта кофемашин", + differentiators: ["выезд в день обращения"], + mechanisms: ["диагностика на выезде"], + offer: "ремонт кофемашин с выездом мастера", + thesis: "быстрое восстановление техники на месте" +}); +const serviceOwner = { + ...owner("fixture:service-owner"), + categoryExclusions: [], + priorityThemes: ["подписка на профилактическое обслуживание"], + referenceAnalogues: [], + strategicStatement: "Развивать подписочную модель обслуживания." +}; +const servicePositioning = buildSeoPositioningThesis("fixture:service-owner", serviceBrief, serviceOwner); +assert.equal( + assessPhrasePositioning("подписка на профилактическое обслуживание", servicePositioning).lane, + "expansion_hypothesis", + "Owner-only aspiration must not become current differentiated core." +); + +const explicitExpansionBrief = serviceBrief as SeoDemandResearchBriefContract; +explicitExpansionBrief.selectedExpansionHypotheses = [{ + buyer: "управляющая компания", + evidenceRefs: ["fixture:expansion"], + expectedSearchLanguage: ["контрактное обслуживание парка кофемашин"], + id: "expansion:fleet-care", + jobToBeDone: "снизить простой парка техники", + requiredAdaptations: ["SLA и кабинет управляющего"], + reusedCapabilityIds: ["diagnostics"], + targetSector: "корпоративные офисы", + title: "Контрактное обслуживание парка" +}] as SeoDemandResearchBriefContract["selectedExpansionHypotheses"]; +const explicitExpansion = buildSeoPositioningThesis("fixture:service-expansion", explicitExpansionBrief, null); +assert.equal( + assessPhrasePositioning("контрактное обслуживание парка кофемашин", explicitExpansion).lane, + "expansion_hypothesis" +); + +const crmPhrase = assessPhrasePositioning("crm система для бизнеса", software); +assert.equal(crmPhrase.lane, "competitor_comparison"); +assert.equal(hasExplicitCompetitorRelation("crm система для бизнеса", crmPhrase), false); +assert.equal(hasExplicitCompetitorRelation("интеграция платформы с crm", assessPhrasePositioning("интеграция платформы с crm", software)), true); + +for (const fixture of [ + brief({ + brand: "Local Care", + category: "сервис ремонта кофемашин", + differentiators: ["выезд в день обращения"], + mechanisms: ["диагностика на выезде"], + offer: "ремонт кофемашин с выездом мастера", + thesis: "быстрое восстановление техники на месте" + }), + brief({ + brand: "Pump Works", + category: "промышленное насосное оборудование", + differentiators: ["подбор по рабочей среде"], + mechanisms: ["инженерный расчёт"], + offer: "поставка промышленных насосов", + thesis: "оборудование подбирается по условиям эксплуатации" + }) +]) { + const thesis = buildSeoPositioningThesis(`fixture:${fixture.coreContext.brandName}`, fixture, null); + assert.equal(thesis.state, "ready"); + assert.ok(thesis.readiness.currentDemandAnchorCount > 0); + assert.equal(thesis.readiness.hasOwnerStrategy, false); +} + +const guard = { + approved: true, + contextHash: "fixture", + selectedDirectionIds: ["agent-core"], + selectedPhrases: ["агентный контур", "платформа для ИИ-агентов"], + rejectedDirections: [{ + expectedSearchLanguage: ["диспетчерское обслуживание", "агенты выездного контроля"], + id: "rejected-field-service", + phrases: ["диспетчерское обслуживание", "агенты выездного контроля"], + targetSector: "выездной сервис", + title: "Выездной диспетчерский контур" + }] +}; +assert.equal(getRejectedDemandScopeReasonFromGuard("платформа для ии агентов", guard), null); +assert.ok(getRejectedDemandScopeReasonFromGuard("диспетчерское обслуживание", guard)); + +const validatedSeed = { sourceKind: "validated_seed" } as PostWordstatProductFitCandidate; +assert.equal(getStrategyCandidateDecisionStatus(validatedSeed, undefined), "validated_seed"); +assert.equal(resolveSeoStrategyState(100, false, 0, 3, 0, []), "evidence_gap"); +assert.equal(resolveSeoStrategyState(100, true, 3, 3, 0, []), "ready_for_review"); +assert.equal(resolveKeywordMapReviewState({ blockerCount: 0, currentApprovedDecisionCount: 3, currentReviewApproved: true, staleApprovedDecisionCount: 0, staleReviewExists: false }), "approved_current_revision"); +assert.equal(resolveKeywordMapReviewState({ blockerCount: 0, currentApprovedDecisionCount: 0, currentReviewApproved: false, staleApprovedDecisionCount: 3, staleReviewExists: true }), "stale"); + +console.log(JSON.stringify({ + agentLane: agentPhrase.lane, + competitorLane: crmPhrase.lane, + domainFixtures: 3, + expansionLane: assessPhrasePositioning("контрактное обслуживание парка кофемашин", explicitExpansion).lane, + keywordMapGate: resolveSeoStrategyState(100, false, 0, 3, 0, []), + ownerStrategyIsEvidence: software.guardPolicy.ownerStrategyIsFactualEvidence, + validatedSeedDecision: getStrategyCandidateDecisionStatus(validatedSeed, undefined) +}, null, 2)); diff --git a/seo_mode/seo_mode/server/src/scripts/checkPostWordstatArchitecture.ts b/seo_mode/seo_mode/server/src/scripts/checkPostWordstatArchitecture.ts new file mode 100644 index 0000000..deab3a9 --- /dev/null +++ b/seo_mode/seo_mode/server/src/scripts/checkPostWordstatArchitecture.ts @@ -0,0 +1,145 @@ +import assert from "node:assert/strict"; +import type { WordstatEvidence } from "../market/wordstatRepository.js"; +import type { ExpansionSurface } from "../queryDiscovery/expansionSurfaceBuilder.js"; +import { planPostWordstatBatch2, type PostWordstatClusterDecision } from "../queryDiscovery/postWordstatBatch2Planner.js"; +import { applyPostWordstatProductFitGate } from "../queryDiscovery/postWordstatProductFitGate.js"; + +const surface: ExpansionSurface = { + activation: { defaultActive: true, reason: "fixture", requiresHumanApproval: true }, + applicabilityScore: 0.94, + evidenceRefs: ["fixture:site-brief:agent-layer"], + evidenceScore: 0.92, + expansionRisk: "low", + id: "fixture:surface:managed-ai-agents", + relationshipToCore: "direct_core", + slotBundle: { + capability: ["платформа управления AI-агентами", "управляемые ИИ агенты"], + industry: [], + object: [], + outcome: ["контроль результатов агентов"], + problem: ["неуправляемые AI-агенты"], + process: ["агентная автоматизация бизнес-процессов"], + role: ["бизнес"], + system: [] + }, + source: "core_capability", + sourceId: "fixture:facet:agent-layer", + status: "observed", + surfaceKind: "use_case", + title: "Платформа управляемых AI-агентов" +}; + +const wordstat: WordstatEvidence = { + latestJob: { + completedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + errorMessage: null, + id: "fixture:wordstat-job", + mode: "fixture", + provider: "fixture", + rawResultKey: null, + region: "ru", + requestedPhraseCount: 1, + resultCount: 5, + startedAt: new Date().toISOString(), + status: "done", + updatedAt: new Date().toISOString() + }, + quota: null, + seedResults: [{ + clusterId: surface.id, + clusterTitle: surface.title, + collectedAt: new Date().toISOString(), + frequency: 302, + frequencyGroup: "low", + id: "fixture:result:seed", + phrase: "платформа для ии агентов", + priority: "high", + region: "ru", + relatedCount: 4, + seedId: "fixture:seed", + seedSource: "detected", + sourcePhrase: "платформа для ии агентов" + }], + summary: { + collectedSeedCount: 1, + exactFrequencySeedCount: 1, + highFrequencyCount: 0, + lowFrequencyCount: 4, + midFrequencyCount: 0, + noSignalSeedCount: 0, + relatedOnlySeedCount: 0, + resultCount: 5, + totalFrequency: 720 + }, + topResults: [ + { frequency: 302, frequencyGroup: "low", id: "fixture:result:seed", phrase: "платформа для ии агентов", region: "ru", sourceClusterId: surface.id, sourceClusterTitle: surface.title, sourcePhrase: "платформа для ии агентов", sourcePriority: "high", sourceSeedSource: "detected" }, + { frequency: 180, frequencyGroup: "low", id: "fixture:result:related:1", phrase: "платформа управления ии агентами", region: "ru", sourceClusterId: surface.id, sourceClusterTitle: surface.title, sourcePhrase: "платформа для ии агентов", sourcePriority: "high", sourceSeedSource: "detected" }, + { frequency: 120, frequencyGroup: "low", id: "fixture:result:related:2", phrase: "платформа управления ai агентами для бизнеса", region: "ru", sourceClusterId: surface.id, sourceClusterTitle: surface.title, sourcePhrase: "платформа для ии агентов", sourcePriority: "high", sourceSeedSource: "detected" }, + { frequency: 90, frequencyGroup: "low", id: "fixture:result:generic", phrase: "автоматизация бизнес процессов система сервис", region: "ru", sourceClusterId: surface.id, sourceClusterTitle: surface.title, sourcePhrase: "платформа для ии агентов", sourcePriority: "high", sourceSeedSource: "detected" }, + { frequency: 28, frequencyGroup: "low", id: "fixture:result:job", phrase: "вакансии ai агент разработчик", region: "ru", sourceClusterId: surface.id, sourceClusterTitle: surface.title, sourcePhrase: "платформа для ии агентов", sourcePriority: "high", sourceSeedSource: "detected" } + ] +}; + +const gate = applyPostWordstatProductFitGate({ surfaces: [surface], wordstat }); +assert.equal(gate.schemaVersion, "post-wordstat-product-fit.v2"); +assert.equal(gate.state, "ready"); + +const generic = gate.candidates.find((candidate) => candidate.phrase === "автоматизация бизнес процессов система сервис"); +assert.ok(generic); +assert.ok(generic.scorecard.queryNaturalness < 0.55); +assert.notEqual(generic.recommendedDisposition, "batch2_review"); +assert.ok(generic.warnings.includes("generic_category_without_distinctive_product_signal")); + +const job = gate.candidates.find((candidate) => candidate.phrase === "вакансии ai агент разработчик"); +assert.equal(job?.recommendedDisposition, "rejected"); + +const reviewCluster = gate.productFitClusters.find((cluster) => cluster.disposition === "batch2_review"); +assert.ok(reviewCluster); +assert.ok(reviewCluster.variantIds.length >= 1); +assert.ok(reviewCluster.scorecard.productFit >= 0.62); +assert.ok(reviewCluster.scorecard.queryNaturalness >= 0.55); + +const clusterDecision: PostWordstatClusterDecision = { + applyToVariants: true, + clusterId: reviewCluster.id, + decidedAt: new Date().toISOString(), + decisionStatus: "approved_for_wordstat", + reason: "fixture cluster approval" +}; +const inheritedPlan = planPostWordstatBatch2({ + clusterDecisions: new Map([[reviewCluster.id, clusterDecision]]), + decisions: new Map(), + gate +}); +assert.equal(inheritedPlan.schemaVersion, "post-wordstat-batch2-plan.v2"); +assert.equal(inheritedPlan.reviewClusters[0]?.decision?.decisionStatus, "approved_for_wordstat"); +assert.equal(inheritedPlan.queue.length, reviewCluster.variantIds.length); +assert.ok(inheritedPlan.queue.every((item) => item.decisionSource === "cluster")); + +const overriddenCandidate = inheritedPlan.reviewClusters[0]?.candidates[0]; +assert.ok(overriddenCandidate); +const overridePlan = planPostWordstatBatch2({ + clusterDecisions: new Map([[reviewCluster.id, clusterDecision]]), + decisions: new Map([[overriddenCandidate.id, { + candidateId: overriddenCandidate.id, + decidedAt: new Date().toISOString(), + decisionStatus: "rejected", + reason: "fixture candidate override" + }]]), + gate +}); +assert.equal(overridePlan.queue.length, reviewCluster.variantIds.length - 1); +assert.equal( + overridePlan.reviewCandidates.find((candidate) => candidate.id === overriddenCandidate.id)?.effectiveDecision?.source, + "candidate_override" +); + +console.log(JSON.stringify({ + candidateCount: gate.diagnostics.candidateCount, + clusterCount: gate.diagnostics.clusterCount, + inheritedQueueCount: inheritedPlan.queue.length, + overrideQueueCount: overridePlan.queue.length, + reviewClusterCount: inheritedPlan.diagnostics.reviewClusterCount, + schemaVersion: gate.schemaVersion +}, null, 2)); diff --git a/seo_mode/seo_mode/server/src/scripts/checkPreWordstatPipeline.ts b/seo_mode/seo_mode/server/src/scripts/checkPreWordstatPipeline.ts new file mode 100644 index 0000000..3b905eb --- /dev/null +++ b/seo_mode/seo_mode/server/src/scripts/checkPreWordstatPipeline.ts @@ -0,0 +1,280 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import type { SemanticFacetKind, SemanticPortfolioFacet, UniversalOfferMap } from "../contextReview/coreSemanticPortfolioRouter.js"; +import { buildCanonicalQueryClusters } from "../queryDiscovery/canonicalQueryCluster.js"; +import { generateMarketSeeds, type MarketSeedTopic } from "../queryDiscovery/marketSeedGenerator.js"; +import { normalizeObservedQueries } from "../queryDiscovery/observedQueryNormalizer.js"; +import { applyObservedRelevanceGate } from "../queryDiscovery/observedRelevanceGate.js"; +import { buildWordstatQueue } from "../queryDiscovery/wordstatQueueBuilder.js"; +import { classifyDomainAgnosticity } from "../queryDiscovery/domainAgnosticityClassifier.js"; +import { buildExpansionSurfaces } from "../queryDiscovery/expansionSurfaceBuilder.js"; +import { buildApplicabilityMatrix } from "../queryDiscovery/applicabilityMatrixBuilder.js"; +import { generateExpansionSeeds } from "../queryDiscovery/expansionSeedGenerator.js"; +import { buildDepthTwoProbes, buildExpansionSuggestProbes } from "../queryDiscovery/recursiveSuggestExpansion.js"; +import { reviewExpansionObservedLanguage } from "../queryDiscovery/expansionObservedRelevanceGate.js"; +import { planPostWordstatExpansion } from "../queryDiscovery/postWordstatExpansionPlanner.js"; +import { applyPostWordstatProductFitGate } from "../queryDiscovery/postWordstatProductFitGate.js"; +import { planPostWordstatBatch2 } from "../queryDiscovery/postWordstatBatch2Planner.js"; + +function slot(value: string) { + return { confidence: 0.9, evidenceRefs: ["fixture:evidence"], normalized: value.toLocaleLowerCase(), source: "site" as const, value }; +} + +function facet(input: { + id: string; + kind: SemanticFacetKind; + entities?: string[]; + categories?: string[]; + actions?: string[]; + processes?: string[]; + audiences?: string[]; + systems?: string[]; + locations?: string[]; + modifiers?: string[]; + allowed?: SemanticPortfolioFacet["patternEligibility"]["allowedPatternGroups"]; +}): SemanticPortfolioFacet { + return { + activation: { defaultActive: true, defaultActiveScore: 0.8, reason: "fixture", requiresHumanApproval: false }, + businessFit: { deliveryModels: ["service"], geoDependency: input.kind === "local_geo" ? "high" : "none", purchaseComplexity: "medium", salesMotion: ["lead_generation"] }, + description: `${input.kind} fixture`, + evidence: [{ confidence: 0.9, refs: ["fixture:evidence"], source: "site_corpus" }], + facetKind: input.kind, + id: input.id, + marketSurface: { doNotTreatAsFinalQueries: true, plainLanguage: input.entities?.[0] ?? input.categories?.[0] ?? input.id, searchLanguageHints: [] }, + negativeInterpretations: [], + patternEligibility: { allowedPatternGroups: input.allowed ?? ["base_entity", "category_process"], blockedPatternGroups: [], reason: "fixture" }, + priority: "high", + relationshipToCore: "core_facet", + risks: [], + slotBundle: { + actions: (input.actions ?? []).map(slot), + assets: [], + audiences: (input.audiences ?? []).map(slot), + entities: (input.entities ?? []).map(slot), + locations: (input.locations ?? []).map(slot), + modifiers: (input.modifiers ?? []).map(slot), + outcomes: [], + problems: [], + processes: (input.processes ?? []).map(slot), + systems: (input.systems ?? []).map(slot) + }, + status: "selected", + title: input.id + }; +} + +function offerMap(input: { delivery: UniversalOfferMap["businessModelSignals"]["deliveryModels"]; local?: boolean; transactional?: boolean }): UniversalOfferMap { + return { + assetsOrObjects: [], audiences: [], buyerRoles: [], capabilities: [], coreCapabilities: [], deployment: [], differentiators: [], doNotOverweightTerms: [], evidenceRefs: [], guardrails: [], locations: [], marketCategories: [], processes: [], productsOrServices: [], proofSignals: [], systemsOrIntegrations: [], + businessModelSignals: { + deliveryModels: input.delivery, + geoDependency: input.local ? "high" : "none", + implementationDepth: "setup", + isB2B: null, + isB2C: null, + isDigital: null, + isLocal: input.local ?? false, + isPhysical: null, + monetization: [], + purchaseComplexity: "medium", + regulationLevel: "none", + salesMotion: input.transactional ? ["catalog_purchase"] : ["lead_generation"] + }, + coreOffer: { confidence: 0.8, evidenceRefs: [], factualSummary: "fixture", plainSummary: "fixture" }, + marketLanguageRule: "fixture", + plainLanguageCoreOffer: "fixture", + siteIdentity: { brandName: "Fixture Brand", geoHints: [], language: "ru" }, + version: "seo-offer-map.v3" + }; +} + +function topic(input: { id: string; facetId: string; entities: string[]; categories?: string[]; actions?: string[]; processes?: string[]; audiences?: string[]; systems?: string[]; pains?: string[] }): MarketSeedTopic { + return { + actions: input.actions ?? [], buyerSegments: input.audiences ?? [], businessProcesses: input.processes ?? [], categories: input.categories ?? [], evidenceRefs: ["fixture:evidence"], facetId: input.facetId, facetKind: null, marketEntityFamily: input.entities, pains: input.pains ?? [], systemsForIntegrations: input.systems ?? [], topicId: input.id + }; +} + +const fixtures = [ + { + facet: facet({ id: "fixture:platform", kind: "product_category", entities: ["цифровая платформа"], categories: ["система"], audiences: ["компаний"], processes: ["управления задачами"] }), + map: offerMap({ delivery: ["platform"] }), + topic: topic({ id: "topic:platform", facetId: "fixture:platform", entities: ["цифровая платформа"], categories: ["система"], audiences: ["компаний"], processes: ["управления задачами"] }) + }, + { + facet: facet({ id: "fixture:local", kind: "local_geo", entities: ["ремонт техники"], actions: ["отремонтировать"], locations: ["Казани"] }), + map: offerMap({ delivery: ["local_service"], local: true }), + topic: topic({ id: "topic:local", facetId: "fixture:local", entities: ["ремонт техники"], actions: ["отремонтировать"] }) + }, + { + facet: facet({ id: "fixture:catalog", kind: "product_category", entities: ["промышленный насос"], categories: ["оборудование"] }), + map: offerMap({ delivery: ["ecommerce", "physical_product"], transactional: true }), + topic: topic({ id: "topic:catalog", facetId: "fixture:catalog", entities: ["промышленный насос"], categories: ["оборудование"] }) + }, + { + facet: facet({ id: "fixture:agency", kind: "service_action", entities: ["исследование рынка"], actions: ["провести"], processes: ["исследование рынка"] }), + map: offerMap({ delivery: ["agency", "service"] }), + topic: topic({ id: "topic:agency", facetId: "fixture:agency", entities: ["исследование рынка"], actions: ["провести"], processes: ["исследование рынка"] }) + }, + { + facet: facet({ id: "fixture:education", kind: "informational_education", entities: ["учебная программа"], categories: ["обучение"] }), + map: offerMap({ delivery: ["education", "content"] }), + topic: topic({ id: "topic:education", facetId: "fixture:education", entities: ["учебная программа"], categories: ["обучение"] }) + }, + { + facet: facet({ id: "fixture:regulated", kind: "compliance_trust", entities: ["проверка соответствия"], categories: ["услуга"] }), + map: { ...offerMap({ delivery: ["service"] }), guardrails: ["Не обещать гарантированный результат без доказательств."] }, + topic: topic({ id: "topic:regulated", facetId: "fixture:regulated", entities: ["проверка соответствия"], categories: ["услуга"] }) + }, + { + facet: facet({ id: "fixture:marketplace", kind: "product_category", entities: ["каталог специалистов"], categories: ["маркетплейс"] }), + map: offerMap({ delivery: ["marketplace"] }), + topic: topic({ id: "topic:marketplace", facetId: "fixture:marketplace", entities: ["каталог специалистов"], categories: ["маркетплейс"] }) + }, + { + facet: facet({ id: "fixture:implementation", kind: "implementation", entities: ["внедрение оборудования"], actions: ["внедрить"], processes: ["пусконаладка"] }), + map: offerMap({ delivery: ["physical_product", "service"] }), + topic: topic({ id: "topic:implementation", facetId: "fixture:implementation", entities: ["внедрение оборудования"], actions: ["внедрить"], processes: ["пусконаладка"] }) + } +]; + +for (const item of fixtures) { + const seeds = generateMarketSeeds({ language: "ru", maxPerFacet: 20, offerMap: item.map, projectId: "00000000-0000-0000-0000-000000000001", selectedFacets: [item.facet], topics: [item.topic] }); + assert.ok(seeds.length > 0, `${item.facet.id} must produce market seeds`); + assert.ok(seeds.every((seed) => seed.stage === "market_seed" && seed.isFinalKeyword === false)); +} + +const localSeeds = generateMarketSeeds({ language: "ru", offerMap: fixtures[1].map, projectId: "fixture", selectedFacets: [fixtures[1].facet], topics: [fixtures[1].topic] }); +assert.ok(localSeeds.some((seed) => seed.kind === "local_probe")); +const catalogSeeds = generateMarketSeeds({ language: "ru", offerMap: fixtures[2].map, projectId: "fixture", selectedFacets: [fixtures[2].facet], topics: [fixtures[2].topic] }); +assert.ok(catalogSeeds.some((seed) => seed.kind === "price_probe")); +assert.ok(!catalogSeeds.some((seed) => seed.kind === "local_probe")); + +const platformFacet = fixtures[0].facet; +const integrationFacet = facet({ id: "fixture:integration", kind: "integration_system", entities: ["обмен данными"], systems: ["учётная система"], allowed: ["base_entity", "integration"] }); +const observations = normalizeObservedQueries({ + brandAliases: ["Fixture Brand"], + language: "ru", + observations: [ + { id: "00000000-0000-0000-0000-000000000011", observedAt: new Date().toISOString(), query: "цифровая платформа для компаний", source: "yandex_suggest", topicId: "topic:platform" }, + { id: "00000000-0000-0000-0000-000000000012", observedAt: new Date().toISOString(), query: "цифровая платформа бесплатно", source: "yandex_suggest", topicId: "topic:platform" }, + { id: "00000000-0000-0000-0000-000000000013", observedAt: new Date().toISOString(), query: "что такое цифровая платформа", source: "yandex_suggest", topicId: "topic:platform" }, + { id: "00000000-0000-0000-0000-000000000014", observedAt: new Date().toISOString(), query: "цифровая платформа example.com", source: "yandex_suggest", topicId: "topic:platform" }, + { id: "00000000-0000-0000-0000-000000000015", observedAt: new Date().toISOString(), query: "интеграция цифровой платформы api", source: "yandex_suggest", topicId: "topic:platform" } + ], + topicFacetMap: new Map([["topic:platform", platformFacet.id]]) +}); +const gate = applyObservedRelevanceGate({ facets: [platformFacet, integrationFacet], observations, offerMap: fixtures[0].map, selectedFacetIds: [platformFacet.id] }); +assert.equal(gate.acceptedForHumanReview.length, 1, "one clean Suggest query must enter human review"); +assert.equal(gate.informational.length, 1, "informational observation must be separated"); +assert.equal(gate.hidden.length, 2, "free and domain observations must be hidden"); +assert.equal(gate.rerouted.length, 1, "unselected integration intent must be rerouted"); + +const clusters = buildCanonicalQueryClusters({ projectId: "fixture", candidates: gate.acceptedForHumanReview }); +assert.equal(clusters.length, 1); +assert.equal(buildWordstatQueue({ clusters, decisions: new Map(), projectId: "fixture" }).length, 0, "queue must stay empty before human approval"); +const queue = buildWordstatQueue({ clusters, decisions: new Map([[clusters[0].id, { clusterId: clusters[0].id, decisionStatus: "approved", reason: "fixture approval" }]]), projectId: "fixture" }); +assert.ok(queue.length > 0, "approved clean cluster must enter bounded queue"); +assert.ok(queue.every((item) => item.sourceEvidence.observationIds.length > 0)); + +const expansionFacets = fixtures.map((item) => item.facet); +const domainAgnosticity = classifyDomainAgnosticity({ facets: expansionFacets, offerMap: fixtures[0].map, opportunities: [] }); +assert.ok(domainAgnosticity.score >= 0 && domainAgnosticity.score <= 1); +const expansionSurfaces = buildExpansionSurfaces({ + facets: expansionFacets, + offerMap: fixtures[0].map, + opportunities: [], + selectedFacetIds: [fixtures[0].facet.id] +}); +assert.ok(expansionSurfaces.some((surface) => surface.relationshipToCore === "direct_core")); +assert.ok(expansionSurfaces.some((surface) => surface.relationshipToCore === "near_core")); +const agencyExpansion = expansionSurfaces.find((surface) => surface.sourceId === "fixture:agency"); +assert.ok(agencyExpansion, "A transferable professional-service process must remain visible as an expansion surface."); +const expansionSeeds = generateExpansionSeeds({ depth: "smoke", language: "ru", selectedSurfaceIds: [agencyExpansion!.id], surfaces: expansionSurfaces }); +assert.ok(expansionSeeds.length > 0 && expansionSeeds.every((seed) => seed.isFinalKeyword === false)); +assert.equal(generateExpansionSeeds({ depth: "smoke", language: "ru", selectedSurfaceIds: [], surfaces: expansionSurfaces }).length, 0); +const expansionProbes = buildExpansionSuggestProbes({ depth: "balanced", language: "ru", seeds: expansionSeeds }); +assert.ok(expansionProbes.some((probe) => probe.expansionKind === "suffix")); +assert.ok(expansionProbes.some((probe) => probe.expansionKind === "alphabet")); +assert.equal(buildDepthTwoProbes({ depth: "balanced", observedQueries: [] }).length, 0); +assert.equal(buildDepthTwoProbes({ depth: "deep", observedQueries: [{ clean: true, query: "автоматизация исследования рынка", seedId: expansionSeeds[0].id, surfaceId: agencyExpansion!.id }] }).length, 1); +assert.ok(buildApplicabilityMatrix({ surfaces: expansionSurfaces, maxCells: 50 }).length > 0); +const expansionReview = reviewExpansionObservedLanguage({ + brandAliases: ["Fixture Brand"], + language: "ru", + observations: [ + { id: "expansion-observation:1", metadata: {}, observedAt: new Date().toISOString(), query: "автоматизация исследования рынка", seedId: expansionSeeds[0].id, source: "yandex_suggest", sourceProbe: expansionSeeds[0].phrase, surfaceId: agencyExpansion!.id }, + { id: "expansion-observation:2", metadata: {}, observedAt: new Date().toISOString(), query: "автоматизация исследования рынка бесплатно", seedId: expansionSeeds[0].id, source: "yandex_suggest", sourceProbe: expansionSeeds[0].phrase, surfaceId: agencyExpansion!.id } + ], + surfaces: expansionSurfaces +}); +assert.equal(expansionReview.candidates.length, 1, "clean expansion observation must require human expansion review"); +assert.equal(expansionReview.hidden.length, 1, "contaminated expansion observation must stay out of review"); +assert.equal(expansionReview.diagnostics.wordstatReady, 0, "expansion review must never make Wordstat-ready phrases automatically"); +const postWordstatPlan = planPostWordstatExpansion({ + surfaces: expansionSurfaces, + wordstat: { + latestJob: { completedAt: new Date().toISOString(), createdAt: new Date().toISOString(), errorMessage: null, id: "fixture-job", mode: "fixture", provider: "fixture", rawResultKey: null, region: "RU", requestedPhraseCount: 1, resultCount: 1, startedAt: new Date().toISOString(), status: "done", updatedAt: new Date().toISOString() }, + quota: null, + seedResults: [], + summary: { collectedSeedCount: 1, exactFrequencySeedCount: 1, highFrequencyCount: 0, lowFrequencyCount: 1, midFrequencyCount: 0, noSignalSeedCount: 0, relatedOnlySeedCount: 0, resultCount: 1, totalFrequency: 12 }, + topResults: [{ frequency: 12, frequencyGroup: "low", id: "fixture-result", phrase: "автоматизация исследования рынка", region: "RU", sourceClusterId: null, sourceClusterTitle: null, sourcePhrase: "исследование рынка", sourcePriority: "medium", sourceSeedSource: "detected" }] + } +}); +assert.equal(postWordstatPlan.state, "ready"); +assert.equal(postWordstatPlan.reroutedToExistingSurfaces.length, 1, "post-Wordstat result should reroute to an existing evidence-backed surface"); + +const productFitGate = applyPostWordstatProductFitGate({ + surfaces: expansionSurfaces, + wordstat: { + latestJob: { completedAt: new Date().toISOString(), createdAt: new Date().toISOString(), errorMessage: null, id: "fit-job", mode: "fixture", provider: "fixture", rawResultKey: null, region: "RU", requestedPhraseCount: 1, resultCount: 5, startedAt: new Date().toISOString(), status: "done", updatedAt: new Date().toISOString() }, + quota: null, + seedResults: [], + summary: { collectedSeedCount: 1, exactFrequencySeedCount: 1, highFrequencyCount: 2, lowFrequencyCount: 2, midFrequencyCount: 1, noSignalSeedCount: 0, relatedOnlySeedCount: 0, resultCount: 5, totalFrequency: 6400 }, + topResults: [ + { frequency: 120, frequencyGroup: "low", id: "fit-exact", phrase: "цифровая платформа для компаний", region: "RU", sourceClusterId: null, sourceClusterTitle: null, sourcePhrase: "цифровая платформа для компаний", sourcePriority: "high", sourceSeedSource: "detected" }, + { frequency: 180, frequencyGroup: "mid", id: "fit-commercial", phrase: "система цифрового управления задачами", region: "RU", sourceClusterId: null, sourceClusterTitle: null, sourcePhrase: "цифровая платформа для компаний", sourcePriority: "high", sourceSeedSource: "detected" }, + { frequency: 4200, frequencyGroup: "high", id: "fit-methodology", phrase: "подход к управлению задачами", region: "RU", sourceClusterId: null, sourceClusterTitle: null, sourcePhrase: "цифровая платформа для компаний", sourcePriority: "high", sourceSeedSource: "detected" }, + { frequency: 900, frequencyGroup: "high", id: "fit-education", phrase: "курс цифровых платформ", region: "RU", sourceClusterId: null, sourceClusterTitle: null, sourcePhrase: "цифровая платформа для компаний", sourcePriority: "high", sourceSeedSource: "detected" }, + { frequency: 1000, frequencyGroup: "high", id: "fit-broad", phrase: "роботизация", region: "RU", sourceClusterId: null, sourceClusterTitle: null, sourcePhrase: "цифровая платформа для компаний", sourcePriority: "high", sourceSeedSource: "detected" } + ] + } +}); +assert.equal(productFitGate.state, "ready"); +assert.equal(productFitGate.lanes.validatedSeeds.length, 1, "already collected seed must not re-enter batch #2"); +assert.equal(productFitGate.lanes.commercialBatch2Review.length, 1, "a mapped commercial related result should enter human batch #2 review"); +assert.equal(productFitGate.lanes.informationalContent.length, 1, "methodology demand should be routed to content instead of a commercial landing"); +assert.equal(productFitGate.lanes.rejected.length, 2, "education and unmapped broad demand must stay outside batch #2"); +const fitCandidate = productFitGate.lanes.commercialBatch2Review[0]; +assert.equal(planPostWordstatBatch2({ decisions: new Map(), gate: productFitGate }).queue.length, 0, "ProductFitGate recommendation alone must never authorize Wordstat batch #2"); +const approvedBatch2 = planPostWordstatBatch2({ + decisions: new Map([[fitCandidate.id, { candidateId: fitCandidate.id, decidedAt: new Date().toISOString(), decisionStatus: "approved_for_wordstat", reason: "fixture human approval" }]]), + gate: productFitGate +}); +assert.equal(approvedBatch2.queue.length, 1, "human-approved product-fit candidate should enter bounded batch #2"); +assert.equal(approvedBatch2.queue[0].provenance, "wordstat_related_product_fit_approved"); + +const productionFiles = [ + "../contextReview/coreSemanticPortfolioRouter.ts", + "../contextReview/demandFamilyMapper.ts", + "../queryDiscovery/marketSeedGenerator.ts", + "../queryDiscovery/observedQueryNormalizer.ts", + "../queryDiscovery/observedRelevanceGate.ts", + "../queryDiscovery/canonicalQueryCluster.ts", + "../queryDiscovery/wordstatQueueBuilder.ts" + ,"../queryDiscovery/domainAgnosticityClassifier.ts" + ,"../queryDiscovery/expansionSurfaceBuilder.ts" + ,"../queryDiscovery/applicabilityMatrixBuilder.ts" + ,"../queryDiscovery/expansionSeedGenerator.ts" + ,"../queryDiscovery/recursiveSuggestExpansion.ts" + ,"../queryDiscovery/expansionObservedRelevanceGate.ts" + ,"../queryDiscovery/postWordstatExpansionPlanner.ts" + ,"../queryDiscovery/postWordstatProductFitGate.ts" + ,"../queryDiscovery/postWordstatBatch2Planner.ts" +]; +const forbiddenFixtureTerms = ["nodedc.ru", "node.dc", "leoagent.ru", "кьюсофт", "битрикс24"]; +for (const file of productionFiles) { + const source = (await readFile(new URL(file, import.meta.url), "utf8")).toLocaleLowerCase(); + for (const term of forbiddenFixtureTerms) assert.ok(!source.includes(term), `${file} contains fixture-specific term: ${term}`); +} + +console.log(JSON.stringify({ clusters: clusters.length, expansion: { mode: domainAgnosticity.recommendedExpansionMode, probes: expansionProbes.length, seeds: expansionSeeds.length, surfaces: expansionSurfaces.length }, fixtures: fixtures.length, gate: gate.diagnostics, queue: queue.length, status: "ok" }, null, 2)); diff --git a/seo_mode/seo_mode/server/src/scripts/checkSemanticPortfolioUniversality.ts b/seo_mode/seo_mode/server/src/scripts/checkSemanticPortfolioUniversality.ts new file mode 100644 index 0000000..c49dfd8 --- /dev/null +++ b/seo_mode/seo_mode/server/src/scripts/checkSemanticPortfolioUniversality.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import type { SeoBusinessSynthesisContract } from "../business/seoBusinessSynthesis.js"; +import { + buildCoreSemanticPortfolio, + buildUniversalOfferMap, + selectBalancedCoreFacets +} from "../contextReview/coreSemanticPortfolioRouter.js"; +import { applyCandidateQualityGate } from "../queryDiscovery/candidateQualityGate.js"; + +const synthesis: SeoBusinessSynthesisContract = { + analystReview: { notes: [], reviewedAt: null, reviewer: "system", status: "approved" }, + demandFamilies: [ + { grounding: "explicit", id: "family-1", phrases: ["финансовая отчетность"], priority: "high", rationale: "Explicit evidence", role: "core", title: "Финансовая отчетность" }, + { grounding: "inferred", id: "family-2", phrases: ["клиентское обучение"], priority: "medium", rationale: "Conservative inference", role: "use_case", title: "Клиентское обучение" } + ], + generatedAt: "2026-07-12T00:00:00.000Z", + guardrails: [], + nextActions: [], + productFrame: { + centralThesis: "Объединяет независимые рабочие роли в проверяемый процесс.", + confidence: "high", + coreOffer: "Специализированная цифровая среда для команд.", + evidenceRefs: ["page:home"], + productCategory: "Цифровая среда для совместной работы" + }, + projectId: "fixture-project", + projectOntologyVersionId: null, + provider: { message: "fixture", mode: "deterministic_fallback", modelRequired: true }, + risks: [], + schemaVersion: "seo-business-synthesis.v1", + semanticHierarchy: { + applicationVectors: [], + corePillars: [ + { evidenceRefs: ["page:home"], grounding: "explicit", id: "pillar-1", role: "core_platform", title: "Единая цифровая среда", whyItMatters: "Связывает участников." }, + { evidenceRefs: ["page:workflow"], grounding: "explicit", id: "pillar-2", role: "workflow_layer", title: "Исполнимые рабочие сценарии", whyItMatters: "Сокращает ручные переходы." }, + { evidenceRefs: ["page:control"], grounding: "explicit", id: "pillar-3", role: "governance_layer", title: "Проверяемый контроль доступа", whyItMatters: "Делает ответственность прозрачной." } + ] + }, + semanticRunId: "fixture-run", + sourceTaskId: "fixture-task", + summary: "Fixture" +}; + +const offerMap = buildUniversalOfferMap({ + businessSynthesis: synthesis, + contextReview: { + audience: { primary: "Руководители команд" }, + forbiddenClaims: [], + siteIdentity: { brandName: "Fixture" } + }, + language: "ru" +}); +const portfolio = buildCoreSemanticPortfolio({ + businessSynthesis: synthesis, + corePillars: synthesis.semanticHierarchy.corePillars, + offerMap, + opportunities: [] +}); +const selected = selectBalancedCoreFacets(portfolio.facets); + +assert.equal(offerMap.version, "seo-offer-map.v3"); +assert.ok(selected.length >= 3, "Three evidence-backed core facets must survive the balanced scope."); +assert.equal(new Set(selected.map((facet) => facet.facetKind)).size, 3, "Balanced scope must preserve independent semantic roles."); +assert.ok(selected.every((facet) => facet.relationshipToCore === "core_facet")); +assert.ok(portfolio.facets.filter((facet) => facet.relationshipToCore !== "core_facet").every((facet) => !facet.activation.defaultActive)); +assert.ok(portfolio.diagnostics.demandFamilyFacetCount >= 2, "Unrelated demand families must remain visible facets instead of merging through generic wording."); + +const concentrated = applyCandidateQualityGate({ + candidates: Array.from({ length: 6 }, (_, index) => ({ + candidateStage: "query_surface_draft" as const, + facetId: "facet-a", + id: `same-${index}`, + patternId: "base_entity", + phrase: `единая среда вариант ${index}` + })), + selectedFacetIds: ["facet-a", "facet-b", "facet-c"] +}); +assert.ok(concentrated.diagnostics.warnings.some((warning) => warning.code === "LOW_FACET_DIVERSITY")); +assert.ok(concentrated.diagnostics.hiddenDrafts > 0, "Facet dominance must hide excess drafts."); + +const balanced = applyCandidateQualityGate({ + candidates: ["a", "b", "c"].map((facetId) => ({ + candidateStage: "query_surface_draft" as const, + facetId, + id: facetId, + patternId: "base_entity", + phrase: `независимая поверхность ${facetId}` + })), + selectedFacetIds: ["a", "b", "c"] +}); +assert.equal(balanced.diagnostics.representedFacetCount, 3); +assert.equal(balanced.diagnostics.hiddenDrafts, 0); + +const routerPath = fileURLToPath(new URL("../contextReview/coreSemanticPortfolioRouter.ts", import.meta.url)); +const routerSource = await readFile(routerPath, "utf8"); +assert.doesNotMatch(routerSource.toLocaleLowerCase(), /nodedc|wordstat|\bbim\b|\bcrm\b|\berp\b|construction/); + +console.info(JSON.stringify({ + status: "passed", + selectedFacetCount: selected.length, + representedFacetCount: balanced.diagnostics.representedFacetCount, + routerDomainBranching: "absent" +}, null, 2)); diff --git a/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts b/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts index 838ae41..84dc42b 100644 --- a/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts +++ b/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts @@ -108,6 +108,18 @@ export type WordstatRuntimeConfig = maxSeedsPerRun: number; }; +export type SerpRuntimeConfig = + | { provider: "disabled"; mode: "disabled" } + | { + provider: "yandex_api"; + mode: "yandex_search_api"; + apiBaseUrl: string; + apiToken: string; + authType: "api_key" | "iam_token"; + folderId: string; + searchType: string; + }; + const WORDSTAT_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/wordstat"; const WEB_SEARCH_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/web/search"; const WEBMASTER_BASE_URL = "https://api.webmaster.yandex.net/v4"; @@ -134,6 +146,14 @@ const serviceDefinitions: Record = required: false, placeholder: "generated after setup command", help: "Stored SEO-only executor id. Hidden in UI; used to avoid global Assistant selected-executor fallback." + }, + { + id: "executorAliases", + label: "SEO executor aliases", + type: "text", + required: false, + placeholder: "{}", + help: "SEO-owned display names keyed by executor id. Hidden in UI and isolated from Engine/Ops selection." } ], secretFields: [] @@ -274,7 +294,7 @@ const serviceDefinitions: Record = id: "yandex_search_serp", label: "Yandex SERP", description: "Поисковая выдача, конкуренты, сниппеты и типы страниц через Yandex Search API WebSearch.", - implementationStatus: "planned", + implementationStatus: "active", defaultMode: "yandex_search_api", modes: [ { id: "disabled", label: "Отключено" }, @@ -831,3 +851,30 @@ export async function getWordstatRuntimeConfig(): Promise mode: "disabled" }; } + +export async function getSerpRuntimeConfig(): Promise { + const rows = await getSettingsRows(); + const row = rows.get("yandex_search_serp"); + const cloudRow = rows.get("yandex_cloud"); + const definition = serviceDefinitions.yandex_search_serp; + const dependencyStatuses = getDependencyStatuses(rows); + const mapped = mapRow(definition, row ?? null, dependencyStatuses); + + if (row?.enabled && row.mode === "yandex_search_api" && mapped.status === "configured") { + const cloudPublicConfig = getPublicConfig(serviceDefinitions.yandex_cloud, cloudRow ?? null); + const cloudSecretConfig = cloudRow?.secret_config ?? {}; + const apiKey = typeof cloudSecretConfig.apiKey === "string" ? cloudSecretConfig.apiKey.trim() : ""; + const iamToken = typeof cloudSecretConfig.iamToken === "string" ? cloudSecretConfig.iamToken.trim() : ""; + return { + apiBaseUrl: mapped.publicConfig.apiBaseUrl || WEB_SEARCH_BASE_URL, + apiToken: apiKey || iamToken, + authType: apiKey ? "api_key" : "iam_token", + folderId: cloudPublicConfig.folderId, + mode: "yandex_search_api", + provider: "yandex_api", + searchType: mapped.publicConfig.searchType || "SEARCH_TYPE_RU" + }; + } + + return { mode: "disabled", provider: "disabled" }; +} diff --git a/seo_mode/seo_mode/server/src/settings/routes.ts b/seo_mode/seo_mode/server/src/settings/routes.ts index 6d7e243..106b248 100644 --- a/seo_mode/seo_mode/server/src/settings/routes.ts +++ b/seo_mode/seo_mode/server/src/settings/routes.ts @@ -7,7 +7,9 @@ import { type ExternalServiceId } from "./externalServiceSettings.js"; import { + createSeoAnalyticsModelExecutor, createSeoAnalyticsModelSetupCommand, + listSeoAnalyticsModelExecutors, probeSeoAnalyticsModel } from "./seoAnalyticsModelBridge.js"; import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js"; @@ -39,6 +41,9 @@ const seoAnalyticsModelSetupCommandSchema = z.object({ const seoAnalyticsModelProbeSchema = z.object({ executorId: z.string().uuid().optional() }); +const seoAnalyticsModelExecutorSchema = z.object({ + name: z.string().trim().min(2).max(80) +}); function sendError(response: Response, status: number, message: string) { response.status(status).json({ @@ -95,6 +100,29 @@ settingsRouter.post("/external-services/seo_analytics_model/setup-command", asyn } }); +settingsRouter.get("/external-services/seo_analytics_model/executors", async (_request, response) => { + try { + response.json({ executors: await listSeoAnalyticsModelExecutors() }); + } catch (error) { + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось загрузить Codex-устройства."); + } +}); + +settingsRouter.post("/external-services/seo_analytics_model/executors", async (request, response) => { + try { + const input = seoAnalyticsModelExecutorSchema.parse(request.body ?? {}); + response.status(201).json({ executor: await createSeoAnalyticsModelExecutor(input) }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь название устройства."); + return; + } + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось добавить Codex-устройство."); + } +}); + settingsRouter.post("/external-services/seo_analytics_model/probe", async (request, response) => { try { const input = seoAnalyticsModelProbeSchema.parse(request.body ?? {}); diff --git a/seo_mode/seo_mode/server/src/settings/seoAnalyticsModelBridge.ts b/seo_mode/seo_mode/server/src/settings/seoAnalyticsModelBridge.ts index 01fe3a1..4c766f1 100644 --- a/seo_mode/seo_mode/server/src/settings/seoAnalyticsModelBridge.ts +++ b/seo_mode/seo_mode/server/src/settings/seoAnalyticsModelBridge.ts @@ -49,6 +49,20 @@ type AiWorkspaceExecutorCheckResponse = { check?: JsonRecord; }; +export type SeoAnalyticsModelExecutor = { + id: string; + name: string; + status: AiWorkspaceExecutor["status"]; + statusDetail: string; + lastSeenAt: string | null; +}; + +export type SeoAnalyticsModelExecutors = { + schemaVersion: "seo-analytics-model-executors.v1"; + profile: string; + executors: SeoAnalyticsModelExecutor[]; +}; + export type SeoAnalyticsModelSetupCommand = { schemaVersion: "seo-analytics-model-setup-command.v1"; generatedAt: string; @@ -122,8 +136,12 @@ function isSeoAnalyticsExecutor(executor: AiWorkspaceExecutor) { ); } +function isCompatibleCodexExecutor(executor: AiWorkspaceExecutor) { + return executor.type === "codex-remote" && executor.connectionMode === "hub"; +} + async function listAiWorkspaceExecutors() { - const response = await assistantRequest("/api/ai-workspace/assistant/v1/executors", { + const response = await assistantRequest("/api/ai-workspace/assistant/v1/executors?visibility=all", { method: "GET" }); @@ -133,10 +151,20 @@ async function listAiWorkspaceExecutors() { }; } -async function createSeoAnalyticsExecutor() { +function toSeoAnalyticsModelExecutor(executor: AiWorkspaceExecutor): SeoAnalyticsModelExecutor { + return { + id: executor.id, + name: executor.name, + status: executor.status, + statusDetail: executor.statusDetail, + lastSeenAt: executor.lastSeenAt + }; +} + +async function createSeoAnalyticsExecutor(name: string) { const response = await assistantRequest("/api/ai-workspace/assistant/v1/executors", { body: { - accountLabel: "NDC SEO Mode", + accountLabel: "NDC SEO Mode", capabilities: ["codex-exec", "dynamic-run-profile", "seo-model-task", "structured-json"], connectionMode: "hub", id: randomUUID(), @@ -145,10 +173,11 @@ async function createSeoAnalyticsExecutor() { createdBy: "nodedc-seo-mode", modeId: "seo-model-task", profile: env.aiWorkspace.profile, - surface: "seo-mode" + surface: "seo-mode", + visibility: "service" }, model: "codex-bridge", - name: "NDC SEO Mode Codex Bridge", + name, status: "unknown", type: "codex-remote", workspacePath: env.aiWorkspace.contractWorkspacePath ?? "" @@ -163,13 +192,39 @@ async function createSeoAnalyticsExecutor() { return response.executor; } +export async function listSeoAnalyticsModelExecutors(): Promise { + const configStatus = getSeoAiWorkspaceConfigStatus(); + + if (!configStatus.configured) { + throw new Error(configStatus.errors.join(" ") || "SEO AI Workspace bridge is not configured."); + } + + const { executors } = await listAiWorkspaceExecutors(); + + return { + schemaVersion: "seo-analytics-model-executors.v1", + profile: configStatus.profile, + executors: executors.filter(isCompatibleCodexExecutor).map(toSeoAnalyticsModelExecutor) + }; +} + +export async function createSeoAnalyticsModelExecutor(input: { name: string }) { + const configStatus = getSeoAiWorkspaceConfigStatus(); + + if (!configStatus.configured) { + throw new Error(configStatus.errors.join(" ") || "SEO AI Workspace bridge is not configured."); + } + + return toSeoAnalyticsModelExecutor(await createSeoAnalyticsExecutor(input.name.trim())); +} + async function resolveSeoAnalyticsExecutor(inputExecutorId?: string | null) { const { executors, selectedExecutorId } = await listAiWorkspaceExecutors(); - const explicitExecutorId = - (isUuid(inputExecutorId) ? inputExecutorId : null) || + const requestedExecutorId = isUuid(inputExecutorId) ? inputExecutorId : null; + const explicitExecutorId = requestedExecutorId || (isUuid(env.aiWorkspace.selectedExecutorId) ? env.aiWorkspace.selectedExecutorId : null); const explicitExecutor = explicitExecutorId - ? executors.find((executor) => executor.id === explicitExecutorId) ?? null + ? executors.find((executor) => executor.id === explicitExecutorId && isCompatibleCodexExecutor(executor)) ?? null : null; const selectedExecutor = selectedExecutorId ? executors.find((executor) => executor.id === selectedExecutorId && isSeoAnalyticsExecutor(executor)) ?? null @@ -177,7 +232,7 @@ async function resolveSeoAnalyticsExecutor(inputExecutorId?: string | null) { const seoExecutor = executors.find(isSeoAnalyticsExecutor) ?? null; return { - executor: explicitExecutor ?? selectedExecutor ?? seoExecutor, + executor: requestedExecutorId ? explicitExecutor : explicitExecutor ?? selectedExecutor ?? seoExecutor, selectedExecutorId }; } @@ -190,7 +245,12 @@ export async function createSeoAnalyticsModelSetupCommand(input: { executorId?: } const resolved = await resolveSeoAnalyticsExecutor(input.executorId); - const executor = resolved.executor ?? await createSeoAnalyticsExecutor(); + + if (input.executorId && !resolved.executor) { + throw new Error("seo_codex_executor_not_found"); + } + + const executor = resolved.executor ?? await createSeoAnalyticsExecutor("NDC SEO Mode Codex Bridge"); const setup = await assistantRequest( `/api/ai-workspace/assistant/v1/executors/${encodeURIComponent(executor.id)}/agent/setup-command`, diff --git a/seo_mode/seo_mode/server/src/skills/seoSkillRegistry.ts b/seo_mode/seo_mode/server/src/skills/seoSkillRegistry.ts index a64e51a..e3bee0c 100644 --- a/seo_mode/seo_mode/server/src/skills/seoSkillRegistry.ts +++ b/seo_mode/seo_mode/server/src/skills/seoSkillRegistry.ts @@ -83,6 +83,18 @@ export type SeoContextReviewModelTaskContract = { projectOntologyVersionId: string | null; providerMode: "model_provider_contract"; activeSkillId: "seo.context_review"; + analysisProfile: { + id: "evidence_grounded_project_context"; + version: "1.0.0"; + objective: string; + passes: Array<{ + id: "evidence_interpreter" | "contradiction_critic" | "context_synthesizer"; + goal: string; + }>; + requiredSeparations: string[]; + universalityRules: string[]; + nonGoals: string[]; + }; input: { siteSummary: { brandName: string; @@ -111,6 +123,13 @@ export type SeoContextReviewModelTaskContract = { missingCriticalFields: string[]; matchedClusterIds: string[]; }>; + sectionSignals: Array<{ + id: string; + pageId: string; + title: string; + meaning: string; + evidenceIds: string[]; + }>; contentSources: Array<{ id: string; title: string; @@ -120,6 +139,13 @@ export type SeoContextReviewModelTaskContract = { }>; siteTextEvidence: { schemaVersion: "seo-site-text-evidence.v1"; + selection: { + strategy: "high_signal_per_document"; + sourceChunkCount: number; + selectedChunkCount: number; + maxChunkChars: number; + omittedCharCount: number; + }; extraction: { chunkCount: number; documentCount: number; @@ -158,6 +184,31 @@ export type SeoContextReviewModelTaskContract = { usedForCoverage: boolean; }>; }; + evidenceHierarchy: { + schemaVersion: "seo-context-evidence-hierarchy.v1"; + strategy: "route_family_map_reduce"; + coverage: { + sourceDocumentCount: number; + representedDocumentCount: number; + groupCount: number; + detailedChunkCount: number; + omittedDocumentCount: number; + }; + groups: Array<{ + id: string; + title: string; + kind: "content" | "route_family" | "scope"; + scope: string; + documentCount: number; + representedDocumentCount: number; + wordCount: number; + documentIds: string[]; + pathSamples: string[]; + representativeChunkIds: string[]; + matchedClusterIds: string[]; + synopsis: string; + }>; + }; semanticSignals: Array<{ id: string; title: string; @@ -586,21 +637,117 @@ function getEvidenceRefs(analysis: SemanticAnalysisRun): SeoContextReviewModelTa scope: ref.scope })) ) - .slice(0, 60); + .slice(0, 36); +} + +const CONTEXT_MODEL_MAX_CHUNKS = 20; +const CONTEXT_MODEL_MAX_CHUNK_CHARS = 600; + +function trimContextEvidenceText(value: string) { + if (value.length <= CONTEXT_MODEL_MAX_CHUNK_CHARS) { + return value; + } + + const hardSlice = value.slice(0, CONTEXT_MODEL_MAX_CHUNK_CHARS); + const sentenceBreak = Math.max(hardSlice.lastIndexOf(". "), hardSlice.lastIndexOf("! "), hardSlice.lastIndexOf("? ")); + const whitespaceBreak = hardSlice.lastIndexOf(" "); + const cutAt = sentenceBreak >= CONTEXT_MODEL_MAX_CHUNK_CHARS * 0.62 + ? sentenceBreak + 1 + : whitespaceBreak >= CONTEXT_MODEL_MAX_CHUNK_CHARS * 0.72 + ? whitespaceBreak + : CONTEXT_MODEL_MAX_CHUNK_CHARS; + + return `${hardSlice.slice(0, cutAt).trim()}…`; } function getTextCorpusEvidence(analysis: SemanticAnalysisRun): SeoContextReviewModelTaskContract["input"]["siteTextEvidence"] { if (analysis.textCorpus) { + const signalTerms = Array.from( + new Set([ + ...analysis.styleProfile.topTerms.slice(0, 24).map((term) => term.term), + ...analysis.clusters.flatMap((cluster) => cluster.matchedTerms.slice(0, 8)) + ].map((term) => term.toLocaleLowerCase("ru-RU").trim()).filter(Boolean)) + ).slice(0, 96); + const sourceChunks = analysis.textCorpus.chunks.filter((chunk) => chunk.usedForCoverage || chunk.selected); + const scoreChunk = (chunk: (typeof sourceChunks)[number]) => { + const normalizedText = chunk.text.toLocaleLowerCase("ru-RU"); + const signalHits = signalTerms.reduce((score, term) => score + (normalizedText.includes(term) ? 1 : 0), 0); + + return signalHits * 12 + (chunk.usedForCoverage ? 30 : 0) + (chunk.selected ? 10 : 0) + (chunk.chunkIndex === 1 ? 4 : 0); + }; + const chunksByDocument = new Map(); + + for (const chunk of sourceChunks) { + const documentChunks = chunksByDocument.get(chunk.documentId) ?? []; + documentChunks.push(chunk); + chunksByDocument.set(chunk.documentId, documentChunks); + } + + const selectedChunkIds = new Set(); + const selectedChunks = Array.from(chunksByDocument.values()) + .map((documentChunks) => [...documentChunks].sort((left, right) => scoreChunk(right) - scoreChunk(left))[0]) + .filter((chunk): chunk is (typeof sourceChunks)[number] => Boolean(chunk)) + .sort((left, right) => scoreChunk(right) - scoreChunk(left)) + .slice(0, CONTEXT_MODEL_MAX_CHUNKS); + + for (const chunk of selectedChunks) { + selectedChunkIds.add(chunk.id); + } + + if (selectedChunks.length < CONTEXT_MODEL_MAX_CHUNKS) { + const remainingChunks = sourceChunks + .filter((chunk) => !selectedChunkIds.has(chunk.id)) + .sort((left, right) => scoreChunk(right) - scoreChunk(left)); + + for (const chunk of remainingChunks) { + selectedChunks.push(chunk); + selectedChunkIds.add(chunk.id); + + if (selectedChunks.length >= CONTEXT_MODEL_MAX_CHUNKS) { + break; + } + } + } + + const compactChunks = selectedChunks + .sort((left, right) => left.sourcePath.localeCompare(right.sourcePath, "ru") || left.chunkIndex - right.chunkIndex) + .map((chunk) => ({ + ...chunk, + text: trimContextEvidenceText(chunk.text) + })); + const selectedCharCount = compactChunks.reduce((sum, chunk) => sum + chunk.text.length, 0); + return { schemaVersion: "seo-site-text-evidence.v1", + selection: { + strategy: "high_signal_per_document", + sourceChunkCount: analysis.textCorpus.chunks.length, + selectedChunkCount: compactChunks.length, + maxChunkChars: CONTEXT_MODEL_MAX_CHUNK_CHARS, + omittedCharCount: Math.max(0, analysis.textCorpus.extraction.totalChars - selectedCharCount) + }, extraction: analysis.textCorpus.extraction, - documents: analysis.textCorpus.documents, - chunks: analysis.textCorpus.chunks + documents: analysis.textCorpus.documents + .filter((document) => document.selected || document.usedForCoverage) + .sort((left, right) => Number(right.usedForCoverage) - Number(left.usedForCoverage) || right.wordCount - left.wordCount) + .slice(0, 80) + .map((document) => ({ + ...document, + chunkIds: document.chunkIds.filter((chunkId) => selectedChunkIds.has(chunkId)).slice(0, 3) + })), + chunks: compactChunks }; } return { schemaVersion: "seo-site-text-evidence.v1", + selection: { + strategy: "high_signal_per_document", + sourceChunkCount: 0, + selectedChunkCount: 0, + maxChunkChars: CONTEXT_MODEL_MAX_CHUNK_CHARS, + omittedCharCount: 0 + }, extraction: { chunkCount: 0, documentCount: 0, @@ -614,11 +761,106 @@ function getTextCorpusEvidence(analysis: SemanticAnalysisRun): SeoContextReviewM }; } +function getEvidenceGroupKey(document: SemanticAnalysisRun["textCorpus"]["documents"][number]) { + if (document.kind === "content_json") { + const pathParts = document.sourcePath.split("/").filter(Boolean); + return `content:${pathParts.slice(0, Math.min(2, pathParts.length)).join("/") || "root"}`; + } + + const routeParts = (document.urlPath ?? "/").split("/").filter(Boolean); + + if (routeParts.length === 0) { + return "route:home"; + } + + return `route:${routeParts.slice(0, 2).join("/")}`; +} + +function getEvidenceGroupTitle(key: string) { + if (key === "route:home") { + return "Главная страница"; + } + + const [, value = key] = key.split(":", 2); + return value.replaceAll("/", " · "); +} + +function buildEvidenceHierarchy( + analysis: SemanticAnalysisRun, + siteTextEvidence: SeoContextReviewModelTaskContract["input"]["siteTextEvidence"] +): SeoContextReviewModelTaskContract["input"]["evidenceHierarchy"] { + const sourceDocuments = analysis.textCorpus.documents.filter((document) => document.selected || document.usedForCoverage); + const documentsByGroup = new Map(); + + for (const document of sourceDocuments) { + const groupKey = getEvidenceGroupKey(document); + const groupDocuments = documentsByGroup.get(groupKey) ?? []; + groupDocuments.push(document); + documentsByGroup.set(groupKey, groupDocuments); + } + + const selectedChunkByDocument = new Map(siteTextEvidence.chunks.map((chunk) => [chunk.documentId, chunk])); + const pageClusterIds = new Map(analysis.pages.map((page) => [page.pageId, page.matchedClusters])); + const contentClusterIds = new Map(analysis.contentSources.map((source) => [source.id, source.matchedClusters])); + const groups = Array.from(documentsByGroup.entries()) + .map(([key, documents]) => { + const representativeChunks = documents + .map((document) => selectedChunkByDocument.get(document.id) ?? analysis.textCorpus.chunks.find((chunk) => chunk.documentId === document.id)) + .filter((chunk): chunk is SemanticAnalysisRun["textCorpus"]["chunks"][number] => Boolean(chunk)) + .slice(0, 3); + const synopsis = representativeChunks + .map((chunk) => trimContextEvidenceText(chunk.text).slice(0, 320)) + .join(" ") + .slice(0, 780); + const matchedClusterIds = Array.from( + new Set( + documents.flatMap((document) => pageClusterIds.get(document.id) ?? contentClusterIds.get(document.id) ?? []) + ) + ).slice(0, 16); + + return { + documentCount: documents.length, + documentIds: documents.map((document) => document.id).slice(0, 20), + id: `group:${key.replace(/[^a-zа-яё0-9]+/giu, "-").replace(/^-|-$/g, "") || "root"}`, + kind: key.startsWith("content:") ? "content" as const : key.startsWith("route:") ? "route_family" as const : "scope" as const, + matchedClusterIds, + pathSamples: documents.map((document) => document.urlPath ?? document.sourcePath).slice(0, 8), + representativeChunkIds: representativeChunks.map((chunk) => chunk.id), + representedDocumentCount: documents.length, + scope: documents[0]?.scope ?? "unknown", + synopsis, + title: getEvidenceGroupTitle(key), + wordCount: documents.reduce((sum, document) => sum + document.wordCount, 0) + }; + }) + .sort((left, right) => right.wordCount - left.wordCount) + .slice(0, 48); + const representedDocumentCount = groups.reduce((sum, group) => sum + group.representedDocumentCount, 0); + + return { + schemaVersion: "seo-context-evidence-hierarchy.v1", + strategy: "route_family_map_reduce", + coverage: { + sourceDocumentCount: sourceDocuments.length, + representedDocumentCount, + groupCount: groups.length, + detailedChunkCount: siteTextEvidence.chunks.length, + omittedDocumentCount: Math.max(0, sourceDocuments.length - representedDocumentCount) + }, + groups + }; +} + export function buildSeoContextReviewTask( projectId: string, analysis: SemanticAnalysisRun ): SeoContextReviewModelTaskContract { const siteTextEvidence = getTextCorpusEvidence(analysis); + const evidenceHierarchy = buildEvidenceHierarchy(analysis, siteTextEvidence); + const selectedContextPages = analysis.pages + .filter((page) => page.selected && page.scope === "indexable_page") + .slice(0, 32); + const selectedContextPageIds = new Set(selectedContextPages.map((page) => page.pageId)); return { taskType: "seo.context_review", @@ -630,6 +872,47 @@ export function buildSeoContextReviewTask( projectOntologyVersionId: analysis.projectOntology.versionId, providerMode: "model_provider_contract", activeSkillId: "seo.context_review", + analysisProfile: { + id: "evidence_grounded_project_context", + version: "1.0.0", + objective: + "Собрать универсальный человеческий контекст проекта из выбранного сайта, не подменяя факты рыночными гипотезами или SEO-стратегией.", + passes: [ + { + id: "evidence_interpreter", + goal: "Восстановить фактическое ядро: субъект, оффер, аудитории, задачи, сущности, доказательства и роли страниц." + }, + { + id: "contradiction_critic", + goal: "Найти рваные темы, конфликтующие claims, смешение продукта и примеров применения, слабые либо отсутствующие доказательства." + }, + { + id: "context_synthesizer", + goal: "Сформулировать короткий связный Project Context и атомарные findings, сохранив provenance каждого существенного вывода." + } + ], + requiredSeparations: [ + "явный факт сайта vs консервативный вывод", + "основной продукт/услуга vs пример применения", + "целевая аудитория vs упомянутый участник процесса", + "доказанный claim vs формулировка, требующая human review" + ], + universalityRules: [ + "Не предполагать, что проект является SaaS, платформой, магазином, услугой или медиа до evidence.", + "Выводить тип бизнеса, сущности и роли из корпуса конкретного проекта, а не из фиксированной отраслевой онтологии.", + "Сначала интерпретировать каждый evidenceHierarchy group, затем собирать общий вывод; отсутствие detailed chunk не означает отсутствие route family.", + "Если evidenceHierarchy.coverage.omittedDocumentCount больше нуля, явно понижать полноту и формировать human-review gap.", + "Сохранять неоднозначность, если сайт одновременно описывает несколько продуктов, услуг, аудиторий или вертикалей.", + "Каждый high-confidence вывод должен ссылаться минимум на два валидных evidence ID." + ], + nonGoals: [ + "оценка поискового спроса", + "генерация ключей", + "выбор SEO-стратегии", + "придумывание новых рынков или направлений развития", + "rewrite или применение изменений" + ] + }, input: { siteSummary: { brandName: analysis.projectOntology.brandName, @@ -647,7 +930,7 @@ export function buildSeoContextReviewTask( technicalPages: analysis.pageScope.technicalPages, templateBlocks: analysis.pageScope.templateBlocks }, - pageSignals: analysis.pages.slice(0, 40).map((page) => ({ + pageSignals: selectedContextPages.map((page) => ({ pageId: page.pageId, selected: page.selected, title: page.title, @@ -656,8 +939,18 @@ export function buildSeoContextReviewTask( scope: page.scope, wordCount: page.wordCount, missingCriticalFields: page.missingCriticalFields, - matchedClusterIds: page.matchedClusters + matchedClusterIds: page.matchedClusters.slice(0, 8) })), + sectionSignals: (analysis.projectOntology.instance?.sections ?? []) + .filter((section) => selectedContextPageIds.has(section.pageId)) + .slice(0, 72) + .map((section) => ({ + id: section.id, + pageId: section.pageId, + title: section.title.slice(0, 180), + meaning: section.meaning === section.title ? "" : section.meaning.slice(0, 240), + evidenceIds: [] + })), contentSources: analysis.contentSources.slice(0, 24).map((source) => ({ id: source.id, title: source.title, @@ -666,6 +959,7 @@ export function buildSeoContextReviewTask( matchedClusterIds: source.matchedClusters })), siteTextEvidence, + evidenceHierarchy, semanticSignals: analysis.clusters.slice(0, 30).map((cluster) => ({ evidenceLevel: cluster.evidence.level, id: cluster.id, @@ -717,8 +1011,8 @@ export function buildSeoContextReviewTask( "needsHumanReview", "summary" ], - findingRequiredKeys: ["id", "title", "evidenceRefs", "confidence", "risk", "needsHumanReview"], - evidenceRequiredKeys: ["sourcePath", "scope", "reason"] + findingRequiredKeys: ["id", "title", "status", "targetIntent", "evidenceLevel", "confidence", "evidenceRefs"], + evidenceRequiredKeys: ["id"] }, confidencePolicy: { highConfidenceMinEvidenceRefs: 2, @@ -728,6 +1022,8 @@ export function buildSeoContextReviewTask( stopConditions: [ "Недостаточно clean text evidence или evidence refs для определения реального оффера сайта.", "Сайт выражен слишком слабо, и модель не может отделить продукт от технической оболочки.", + "EvidenceRefs должны содержать только id из input.evidenceRefs или input.siteTextEvidence.chunks; пути и свободный текст не считаются ссылкой на доказательство.", + "PageRoles должны ссылаться на pageId из input.pageSignals; запрещено создавать виртуальные page-home/page-product идентификаторы.", "Запрошенное действие требует business expansion, rewrite или apply, которые запрещены на context review stage." ] }; diff --git a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts index e714ae7..26f15a7 100644 --- a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts +++ b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts @@ -1,4 +1,18 @@ -import { getLatestSeoContextReview } from "../contextReview/seoContextReview.js"; +import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js"; +import { getRejectedDemandScopeReason } from "../contextReview/demandScopeGuard.js"; +import { + getLatestSeoDemandResearchBrief, + type SeoDemandResearchBriefContract +} from "../contextReview/demandResearchBrief.js"; +import { getLatestAlignedSeoContextReview, type SeoContextReviewRun } from "../contextReview/seoContextReview.js"; +import { + assessPhrasePositioning, + getSeoPositioningThesis, + hasExplicitCompetitorRelation, + type PhrasePositioningAssessment, + type PositioningPortfolioLane, + type SeoPositioningThesisContract +} from "../contextReview/positioningThesis.js"; import { getKeywordCleaningContract, type KeywordCleaningContract } from "../keywords/keywordCleaning.js"; import { getKeywordMapContract, type KeywordMapContract } from "../keywords/keywordMap.js"; import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js"; @@ -8,6 +22,10 @@ import { getLatestSerpInterpretationContract, type SerpInterpretationContract } from "../evidence/serpInterpretation.js"; +import { + getSeoStrategyClusterDecisions, + type SeoStrategyClusterDecision +} from "./strategyClusterDecisions.js"; type SeoStrategyState = "blocked" | "evidence_gap" | "strategy_draft" | "ready_for_review"; type SeoStrategyPriority = "high" | "low" | "medium"; @@ -37,6 +55,7 @@ export type SeoStrategyContract = { contextReady: boolean; evidenceGapCount: number; externalProviderConnectedCount: number; + keywordMapReviewState: KeywordMapContract["review"]["state"]; marketEvidenceCount: number; opportunityCount: number; riskCount: number; @@ -152,6 +171,41 @@ export type SeoStrategyContract = { nextStep: string; }; }; + strategyClusters: { + schemaVersion: "seo-strategy-clusters.v1"; + state: "not_ready" | "ready"; + clusters: Array<{ + id: string; + canonicalPhrase: string; + phrases: string[]; + totalFrequency: number; + decisionStatus: "approved_for_wordstat" | "content_only" | "research_only" | "validated_seed"; + portfolioLane: PositioningPortfolioLane; + positioning: PhrasePositioningAssessment; + intent: MarketEnrichmentContract["postWordstatProductFit"]["candidates"][number]["intent"]; + serpIntent: SerpInterpretationContract["phraseInterpretations"][number]["intent"] | null; + serpConfidence: SerpInterpretationContract["phraseInterpretations"][number]["confidence"] | null; + targetKind: "existing_page" | "new_page" | "support_section" | "content_backlog" | "adjacent_research" | "human_review"; + targetPath: string | null; + targetSurfaceId: string | null; + pageType: MarketEnrichmentContract["postWordstatProductFit"]["candidates"][number]["pageType"]; + scorecard: MarketEnrichmentContract["postWordstatProductFit"]["candidates"][number]["scorecard"]; + evidenceRefs: string[]; + recommendation: string; + nextAction: string; + decision: SeoStrategyClusterDecision | null; + }>; + readiness: { + clusterCount: number; + existingPageCount: number; + newPageCount: number; + supportSectionCount: number; + contentBacklogCount: number; + researchCount: number; + humanReviewCount: number; + serpCoveredCount: number; + }; + }; opportunities: Array<{ id: string; title: string; @@ -769,7 +823,7 @@ function mapProviders( })); } -function getContextStatus(contextReview: Awaited>) { +function getContextStatus(contextReview: SeoContextReviewRun | null) { if (!contextReview) { return "missing" as const; } @@ -782,7 +836,7 @@ function getContextStatus(contextReview: Awaited> + contextReview: SeoContextReviewRun | null ): SeoStrategyContract["evidence"]["context"] { const status = getContextStatus(contextReview); @@ -812,10 +866,11 @@ function buildContextEvidence( function buildDemandEvidence( market: MarketEnrichmentContract, cleaning: KeywordCleaningContract, - keywordMap: KeywordMapContract + keywordMap: KeywordMapContract, + demandResearchBrief: SeoDemandResearchBriefContract | null ): SeoStrategyContract["evidence"]["demand"] { const persistedPhraseSet = new Set( - getActiveStrategyKeywordMapItems(cleaning, keywordMap).map((item) => normalizePhraseKey(item.phrase)) + getActiveStrategyKeywordMapItems(cleaning, keywordMap, demandResearchBrief).map((item) => normalizePhraseKey(item.phrase)) ); const topSignals = cleaning.items .filter((item) => persistedPhraseSet.has(normalizePhraseKey(item.phrase)) && item.frequency !== null) @@ -886,7 +941,15 @@ function getCleaningItemByPhrase(cleaning: KeywordCleaningContract) { return itemsByPhrase; } -function getActiveStrategyKeywordMapItems(cleaning: KeywordCleaningContract, keywordMap: KeywordMapContract) { +function getActiveStrategyKeywordMapItems( + cleaning: KeywordCleaningContract, + keywordMap: KeywordMapContract, + demandResearchBrief: SeoDemandResearchBriefContract | null +) { + if (keywordMap.review.state !== "approved_current_revision") { + return []; + } + const cleaningByPhrase = getCleaningItemByPhrase(cleaning); const currentRoutableByPhrase = new Map( keywordMap.items @@ -903,6 +966,7 @@ function getActiveStrategyKeywordMapItems(cleaning: KeywordCleaningContract, key approvedRoleIsActive && Boolean(currentMapItem) && Boolean(cleaningItem) && + !getRejectedDemandScopeReason(item.phrase, demandResearchBrief) && cleaningItem?.evidenceStatus === "collected" && cleaningItem?.frequency !== null && normalizeKey(currentMapItem?.targetPath ?? null) === normalizeKey(item.targetPath) @@ -913,12 +977,13 @@ function getActiveStrategyKeywordMapItems(cleaning: KeywordCleaningContract, key function buildOpportunities( cleaning: KeywordCleaningContract, keywordMap: KeywordMapContract, - siteMaturity: SeoStrategyContract["siteMaturity"] + siteMaturity: SeoStrategyContract["siteMaturity"], + demandResearchBrief: SeoDemandResearchBriefContract | null ): SeoStrategyContract["opportunities"] { const cleaningByPhrase = getCleaningItemByPhrase(cleaning); const groups = new Map(); - for (const item of getActiveStrategyKeywordMapItems(cleaning, keywordMap)) { + for (const item of getActiveStrategyKeywordMapItems(cleaning, keywordMap, demandResearchBrief)) { const key = normalizeKey(item.targetPath); const group = groups.get(key) ?? []; @@ -986,13 +1051,16 @@ function buildSerpOpportunities( cleaning: KeywordCleaningContract, serpInterpretation: SerpInterpretationContract, keywordMap: KeywordMapContract, - siteMaturity: SeoStrategyContract["siteMaturity"] + siteMaturity: SeoStrategyContract["siteMaturity"], + demandResearchBrief: SeoDemandResearchBriefContract | null ): SeoStrategyContract["opportunities"] { if (serpInterpretation.state !== "ready") { return []; } - const approvedPhraseSet = new Set(getActiveStrategyKeywordMapItems(cleaning, keywordMap).map((item) => normalizePhraseKey(item.phrase))); + const approvedPhraseSet = new Set( + getActiveStrategyKeywordMapItems(cleaning, keywordMap, demandResearchBrief).map((item) => normalizePhraseKey(item.phrase)) + ); return serpInterpretation.phraseInterpretations .filter( @@ -1039,9 +1107,177 @@ function buildSerpOpportunities( }); } +const STRATEGY_CLUSTER_STOP_WORDS = new Set([ + "ai", "ии", "бизнес", "для", "и", "на", "по", "процесс", "процессы", "с", "система", "системы", "платформа", "платформы" +]); +const STRATEGY_GENERIC_BINDING_ROOTS = [ + "автоматизац", "бизнес", "интеграц", "компан", "организац", "предприят", "процесс", "решен", "сервис", "систем", "технолог", "управлен", "платформ", + "automation", "business", "company", "enterprise", "integration", "management", "platform", "process", "service", "solution", "system", "technology" +]; + +function strategyClusterRoots(value: string) { + return new Set( + (normalizePhraseKey(value).match(/[\p{L}\p{N}]+/gu) ?? []) + .filter((token) => token.length > 2 && !STRATEGY_CLUSTER_STOP_WORDS.has(token)) + .map((token) => token.replace(/(иями|ями|ами|ого|его|ому|ему|ыми|ими|иях|ях|ах|ией|иям|ям|ам|ов|ев|ей|ой|ый|ий|ая|яя|ое|ее|ые|ие|ую|юю|ом|ем|а|я|ы|и|у|ю|е|о)$/iu, "")) + .filter((token) => token.length > 2) + ); +} + +function isGenericStrategyBindingRoot(value: string) { + return STRATEGY_GENERIC_BINDING_ROOTS.some((root) => value.startsWith(root) || root.startsWith(value)); +} + +export function getStrategyCandidateDecisionStatus( + candidate: MarketEnrichmentContract["postWordstatProductFit"]["candidates"][number], + decision: MarketEnrichmentContract["wordstatBatch2"]["decisions"][number] | undefined +) { + return decision?.decisionStatus ?? (candidate.sourceKind === "validated_seed" ? "validated_seed" : null); +} + +function buildStrategyClusters( + market: MarketEnrichmentContract, + serpInterpretation: SerpInterpretationContract, + positioningThesis: SeoPositioningThesisContract, + savedDecisions: Map +): SeoStrategyContract["strategyClusters"] { + const decisionByCandidateId = new Map(market.wordstatBatch2.decisions.map((decision) => [decision.candidateId, decision])); + const clusterById = new Map(market.postWordstatProductFit.productFitClusters.map((cluster) => [cluster.id, cluster])); + const serpByPhrase = new Map(serpInterpretation.phraseInterpretations.map((phrase) => [normalizePhraseKey(phrase.phrase), phrase])); + const eligible = market.postWordstatProductFit.candidates.filter((candidate) => { + const status = getStrategyCandidateDecisionStatus(candidate, decisionByCandidateId.get(candidate.id)); + return status === "approved_for_wordstat" || status === "content_only" || status === "research_only" || status === "validated_seed"; + }); + const groups = new Map(); + for (const candidate of eligible) { + const status = getStrategyCandidateDecisionStatus(candidate, decisionByCandidateId.get(candidate.id)); + if (!status) continue; + const key = `${candidate.clusterId ?? candidate.id}\u0000${status}`; + groups.set(key, [...(groups.get(key) ?? []), candidate]); + } + + const clusters: SeoStrategyContract["strategyClusters"]["clusters"] = []; + for (const candidates of groups.values()) { + const canonical = [...candidates].sort((left, right) => + right.scorecard.productFit - left.scorecard.productFit || + right.scorecard.queryNaturalness - left.scorecard.queryNaturalness || + (right.frequency ?? 0) - (left.frequency ?? 0) + )[0]; + if (!canonical) continue; + const decisionStatus = getStrategyCandidateDecisionStatus(canonical, decisionByCandidateId.get(canonical.id)); + if ( + decisionStatus !== "approved_for_wordstat" && + decisionStatus !== "content_only" && + decisionStatus !== "research_only" && + decisionStatus !== "validated_seed" + ) continue; + const positioning = assessPhrasePositioning(canonical.phrase, positioningThesis); + const explicitCompetitorRelation = hasExplicitCompetitorRelation(canonical.phrase, positioning); + const sourceCluster = canonical.clusterId ? clusterById.get(canonical.clusterId) ?? null : null; + const serpRows = candidates.map((candidate) => serpByPhrase.get(normalizePhraseKey(candidate.phrase))).filter((row): row is NonNullable => Boolean(row)); + const serpRow = [...serpRows].sort((left, right) => { + const confidenceRank = { high: 3, low: 1, medium: 2 } as const; + const fitRank = { mismatch: 1, partial_fit: 2, strong_fit: 3, unknown: 0 } as const; + return confidenceRank[right.confidence] - confidenceRank[left.confidence] || fitRank[right.targetFit] - fitRank[left.targetFit]; + })[0] ?? null; + // Page binding is allowed to use the observed query language only. A + // previously inferred surface title may explain a candidate, but must not + // manufacture a false page match on its own. + const roots = strategyClusterRoots(canonical.phrase); + const brief = market.briefEvidence + .map((item) => { + const briefRoots = strategyClusterRoots(item.seedPhrases.join(" ")); + const matchedRoots = [...roots].filter((root) => briefRoots.has(root)); + const distinctiveOverlap = matchedRoots.filter((root) => !isGenericStrategyBindingRoot(root)).length; + return { distinctiveOverlap, item, overlap: matchedRoots.length }; + }) + .filter(({ distinctiveOverlap, overlap }) => overlap >= 2 && distinctiveOverlap >= 1) + .sort((left, right) => right.distinctiveOverlap - left.distinctiveOverlap || right.overlap - left.overlap || left.item.path.localeCompare(right.item.path, "ru"))[0]?.item ?? null; + const strategyClusterId = `strategy-cluster:${canonical.clusterId ?? canonical.id}:${decisionStatus}`; + const savedDecision = savedDecisions.get(strategyClusterId) ?? null; + let targetPath: string | null = serpRow?.targetPath ?? brief?.path ?? null; + let targetKind: SeoStrategyContract["strategyClusters"]["clusters"][number]["targetKind"]; + if (positioning.lane === "competitor_comparison" && !explicitCompetitorRelation) targetKind = "adjacent_research"; + else if (positioning.lane === "competitor_comparison") targetKind = "support_section"; + else if (positioning.lane === "expansion_hypothesis") targetKind = "adjacent_research"; + else if (positioning.lane === "content_support") targetKind = "support_section"; + else if (decisionStatus === "content_only" || serpRow?.recommendedRole === "article_backlog") targetKind = "content_backlog"; + else if (decisionStatus === "research_only") targetKind = "adjacent_research"; + else if (serpRow?.recommendedRole === "support_section") targetKind = "support_section"; + else if (brief?.action === "create" || (!targetPath && serpRow?.recommendedRole === "core_landing")) targetKind = "new_page"; + else if (targetPath) targetKind = "existing_page"; + else targetKind = "human_review"; + if (savedDecision) { + targetKind = savedDecision.targetKind; + targetPath = savedDecision.targetPath; + } + const scorecard = { + evidenceStrength: Number((candidates.reduce((sum, item) => sum + item.scorecard.evidenceStrength, 0) / candidates.length).toFixed(2)), + intentConfidence: Number((candidates.reduce((sum, item) => sum + item.scorecard.intentConfidence, 0) / candidates.length).toFixed(2)), + landingFit: Number((candidates.reduce((sum, item) => sum + item.scorecard.landingFit, 0) / candidates.length).toFixed(2)), + productFit: Math.max(...candidates.map((item) => item.scorecard.productFit)), + queryNaturalness: Number((candidates.reduce((sum, item) => sum + item.scorecard.queryNaturalness, 0) / candidates.length).toFixed(2)) + }; + const recommendation = savedDecision + ? `Решение ${savedDecision.source} зафиксировано для текущей keyword-map revision: ${savedDecision.reason}` : + positioning.lane === "competitor_comparison" && !explicitCompetitorRelation + ? `Фраза попала в конкурирующую категорию (${positioning.matchedExcludedCategories.join(", ")}) без явного compare/integration intent: не использовать как продуктовую посадочную.` : + targetKind === "existing_page" ? "Усилить существующую страницу кластером после ручной проверки соответствия секции." : + targetKind === "new_page" ? "Подготовить отдельную посадочную; не смешивать кластер с общей страницей продукта." : + targetKind === "support_section" ? "Использовать как поддерживающий раздел, FAQ или доказательный блок, а не как самостоятельный коммерческий оффер." : + targetKind === "content_backlog" ? "Вынести в информационный backlog и не использовать в коммерческом rewrite." : + targetKind === "adjacent_research" ? "Оставить в исследовательском backlog до более сильного product/SERP evidence." : + "Нужна ручная привязка existing/new page перед стратегией."; + clusters.push({ + canonicalPhrase: sourceCluster?.canonicalPhrase ?? canonical.phrase, + decision: savedDecision, + decisionStatus, + evidenceRefs: [...new Set([...candidates.flatMap((candidate) => candidate.evidenceRefs), ...serpRows.flatMap((row) => row.evidenceRefs)])], + id: strategyClusterId, + intent: canonical.intent, + nextAction: savedDecision + ? `Использовать принятое решение: ${savedDecision.targetKind}${savedDecision.targetPath ? ` · ${savedDecision.targetPath}` : ""}.` + : targetPath ? `Проверить page/section binding для ${targetPath}.` : "Выбрать существующую страницу или создать новую посадочную.", + pageType: canonical.pageType, + phrases: candidates.map((candidate) => candidate.phrase), + portfolioLane: positioning.lane, + positioning, + recommendation, + scorecard, + serpConfidence: serpRow?.confidence ?? null, + serpIntent: serpRow?.intent ?? null, + targetKind, + targetPath, + targetSurfaceId: canonical.targetSurfaceId, + totalFrequency: candidates.reduce((sum, candidate) => sum + (candidate.frequency ?? 0), 0) + }); + } + clusters.sort((left, right) => + right.scorecard.productFit - left.scorecard.productFit || + right.scorecard.evidenceStrength - left.scorecard.evidenceStrength || + right.totalFrequency - left.totalFrequency + ); + return { + clusters, + readiness: { + clusterCount: clusters.length, + contentBacklogCount: clusters.filter((cluster) => cluster.targetKind === "content_backlog").length, + existingPageCount: clusters.filter((cluster) => cluster.targetKind === "existing_page").length, + humanReviewCount: clusters.filter((cluster) => cluster.targetKind === "human_review").length, + newPageCount: clusters.filter((cluster) => cluster.targetKind === "new_page").length, + researchCount: clusters.filter((cluster) => cluster.targetKind === "adjacent_research").length, + serpCoveredCount: clusters.filter((cluster) => cluster.serpIntent !== null).length, + supportSectionCount: clusters.filter((cluster) => cluster.targetKind === "support_section").length + }, + schemaVersion: "seo-strategy-clusters.v1", + state: clusters.length > 0 ? "ready" : "not_ready" + }; +} + function buildMissingEvidence( market: MarketEnrichmentContract, - contextReview: Awaited>, + keywordMap: KeywordMapContract, + contextReview: SeoContextReviewRun | null, yandexEvidence: YandexEvidenceContract, serpInterpretation: SerpInterpretationContract ): SeoStrategyContract["missingEvidence"] { @@ -1076,6 +1312,16 @@ function buildMissingEvidence( }); } + if (keywordMap.review.state !== "approved_current_revision") { + missing.push({ + id: "keyword_map:current_revision_not_approved", + impact: "Dev Mode не может считать стратегию принятой, пока не подтверждена текущая revision positioning/evidence карты.", + owner: "product_review", + priority: "high", + title: keywordMap.review.state === "stale" ? "Принятие keyword map устарело" : "Keyword map ещё не принята" + }); + } + if (yandexEvidence.state === "not_collected") { missing.push({ id: "external:yandex_evidence", @@ -1102,13 +1348,14 @@ function buildMissingEvidence( function buildRisks(input: { activeApprovedKeywordCount: number; cleaning: KeywordCleaningContract; - contextReview: Awaited>; + contextReview: SeoContextReviewRun | null; keywordMap: KeywordMapContract; market: MarketEnrichmentContract; missingEvidence: SeoStrategyContract["missingEvidence"]; opportunities: SeoStrategyContract["opportunities"]; serpInterpretation: SerpInterpretationContract; siteMaturity: SeoStrategyContract["siteMaturity"]; + strategyClusters: SeoStrategyContract["strategyClusters"]; yandexEvidence: YandexEvidenceContract; }): SeoStrategyContract["risks"] { const risks: SeoStrategyContract["risks"] = []; @@ -1181,6 +1428,18 @@ function buildRisks(input: { if (input.serpInterpretation.state === "ready") { for (const risk of input.serpInterpretation.risks.slice(0, 6)) { + if ( + risk.id === "serp-risk:no_target_binding" && + input.strategyClusters.readiness.humanReviewCount === 0 && + input.strategyClusters.clusters.every( + (cluster) => + cluster.targetKind !== "human_review" && + (cluster.targetKind !== "existing_page" && cluster.targetKind !== "new_page" || Boolean(cluster.targetPath)) + ) + ) { + continue; + } + risks.push({ id: risk.id, level: risk.level, @@ -1269,6 +1528,8 @@ function buildStrategyOptions(input: { function getStrategyScore(input: { activeApprovedKeywordCount: number; + strategyClusterCount: number; + strategyClusterHumanReviewCount: number; contextReady: boolean; keywordMap: KeywordMapContract; market: MarketEnrichmentContract; @@ -1283,7 +1544,7 @@ function getStrategyScore(input: { score += 20; } - if (input.activeApprovedKeywordCount > 0) { + if (input.activeApprovedKeywordCount > 0 || input.strategyClusterCount > 0) { score += 20; } @@ -1307,20 +1568,33 @@ function getStrategyScore(input: { score += 10; } + score -= Math.min(20, input.strategyClusterHumanReviewCount * 5); + score -= Math.min(25, input.missingEvidence.length * 5); return Math.max(0, Math.min(100, score)); } -function getState( +export function resolveSeoStrategyState( score: number, + keywordMapReviewApproved: boolean, activeApprovedKeywordCount: number, + strategyClusterCount: number, + strategyClusterHumanReviewCount: number, missingEvidence: SeoStrategyContract["missingEvidence"] ): SeoStrategyState { - if (activeApprovedKeywordCount === 0) { + if (activeApprovedKeywordCount === 0 && strategyClusterCount === 0) { return "blocked"; } + if (!keywordMapReviewApproved) { + return "evidence_gap"; + } + + if (strategyClusterHumanReviewCount > 0) { + return "evidence_gap"; + } + if (missingEvidence.some((evidence) => evidence.priority === "high")) { return "evidence_gap"; } @@ -1335,6 +1609,12 @@ function getState( function buildNextActions(contract: Omit) { const actions: string[] = []; + if (contract.strategyClusters.readiness.humanReviewCount > 0) { + actions.push( + `Привязать ${contract.strategyClusters.readiness.humanReviewCount} post-Wordstat кластеров к existing/new page или явно отправить в support/content backlog.` + ); + } + if (contract.missingEvidence.length > 0) { actions.push(`Закрыть пробелы доказательств: ${contract.missingEvidence.slice(0, 3).map((item) => item.title).join("; ")}.`); } @@ -1363,13 +1643,16 @@ function buildNextActions(contract: Omit) { } export async function getSeoStrategyContract(projectId: string): Promise { - const [contextReview, market, cleaning, keywordMap, yandexEvidence, latestSerpInterpretation] = await Promise.all([ - getLatestSeoContextReview(projectId), + const analysis = await getLatestSemanticAnalysis(projectId); + const [contextReview, market, cleaning, keywordMap, yandexEvidence, latestSerpInterpretation, demandResearchBrief, positioningThesis] = await Promise.all([ + analysis ? getLatestAlignedSeoContextReview(projectId, analysis.runId) : Promise.resolve(null), getMarketEnrichmentContract(projectId), getKeywordCleaningContract(projectId), getKeywordMapContract(projectId), getLatestYandexEvidenceContract(projectId), - getLatestSerpInterpretationContract(projectId) + getLatestSerpInterpretationContract(projectId), + analysis ? getLatestSeoDemandResearchBrief(projectId, analysis.runId) : Promise.resolve(null), + getSeoPositioningThesis(projectId) ]); const serpInterpretation = latestSerpInterpretation.state === "ready" && latestSerpInterpretation.yandexEvidenceRunId === yandexEvidence.runId @@ -1377,9 +1660,15 @@ export async function getSeoStrategyContract(projectId: string): Promise 0 - ? `Есть ${opportunities.length} групп возможностей, но стратегия ещё зависит от внешних доказательств и проверки рисков.` + opportunities.length > 0 || strategyClusters.readiness.clusterCount > 0 + ? strategyClusters.readiness.humanReviewCount === 0 + ? `Есть ${opportunities.length} групп возможностей и ${strategyClusters.readiness.clusterCount} post-Wordstat кластеров; текущая revision маршрутов page/section зафиксирована и готова к проверке стратегии.` + : `Есть ${opportunities.length} групп возможностей и ${strategyClusters.readiness.clusterCount} post-Wordstat кластеров, но стратегия ещё зависит от page/section binding и проверки рисков.` : "Пока нет достаточной карты возможностей: нужно усилить рыночные доказательства и очистку фраз.", title: strategyConfidenceScore >= 75 @@ -1453,6 +1754,7 @@ export async function getSeoStrategyContract(projectId: string): Promise( + ` + select input, output + from runs + where project_id = $1 + and run_type = 'seo_strategy_cluster_decision' + and status = 'done' + order by created_at desc + limit 200; + `, + [projectId] + ); + const decisions = new Map(); + + for (const row of result.rows) { + const output = row.output; + if ( + !output || + output.schemaVersion !== "seo-strategy-cluster-decision.v1" || + output.keywordMapRevision !== keywordMapRevision || + output.positioningRevision !== positioningRevision || + decisions.has(output.clusterId) + ) { + continue; + } + + decisions.set(output.clusterId, output); + } + + return decisions; +} + +export async function saveSeoStrategyClusterDecision( + projectId: string, + input: Omit +) { + if ((input.targetKind === "existing_page" || input.targetKind === "new_page") && !input.targetPath?.trim()) { + throw new Error("Для existing/new page решения нужен targetPath."); + } + + const output: SeoStrategyClusterDecision = { + ...input, + canonicalPhrase: input.canonicalPhrase.trim(), + clusterId: input.clusterId.trim(), + decidedAt: new Date().toISOString(), + projectId, + reason: input.reason.trim(), + schemaVersion: "seo-strategy-cluster-decision.v1", + targetPath: input.targetPath?.trim() || null + }; + + await pool.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_strategy_cluster_decision', 'done', $2::jsonb, $3::jsonb, now(), now()); + `, + [ + projectId, + JSON.stringify({ + clusterId: output.clusterId, + keywordMapRevision: output.keywordMapRevision, + positioningRevision: output.positioningRevision, + schemaVersion: "seo-strategy-cluster-decision-input.v1" + }), + JSON.stringify(output) + ] + ); + + return output; +} diff --git a/seo_mode/seo_mode/server/src/strategy/strategyQualityReview.ts b/seo_mode/seo_mode/server/src/strategy/strategyQualityReview.ts index 835279e..d94aae2 100644 --- a/seo_mode/seo_mode/server/src/strategy/strategyQualityReview.ts +++ b/seo_mode/seo_mode/server/src/strategy/strategyQualityReview.ts @@ -163,7 +163,7 @@ function normalizeModelProviderChecks(value: unknown, fallback: StrategyQualityR return fallback; } - return value.map((item, index) => { + const modelChecks = value.map((item, index) => { const record = (item && typeof item === "object" ? item : {}) as Record; return makeCheck({ @@ -177,6 +177,46 @@ function normalizeModelProviderChecks(value: unknown, fallback: StrategyQualityR title: typeof record.title === "string" && record.title.trim() ? record.title.trim() : `Проверка ${index + 1}` }); }); + + const fallbackEvidenceCheck = fallback.find((check) => check.id === "evidence:refs_present"); + + return modelChecks.map((check) => { + const text = `${check.id} ${check.title} ${check.message}`; + + if (/text[_ -]?quality|качество\s+текст/i.test(text)) { + return { + ...check, + action: "Запустить отдельный Text Quality gate после появления rewrite-output, до apply.", + category: "evidence_integrity" as const, + evidenceRefs: + check.evidenceRefs.length > 0 + ? check.evidenceRefs + : ["seo-strategy.missingEvidence:provider:text_quality"], + message: + "Text Quality остаётся обязательным downstream gate, но текущая strategy quality review не создаёт текст и запрещает rewrite/apply.", + severity: "low" as const, + status: "warning" as const, + title: "Text Quality отложен до rewrite" + }; + } + + if ( + fallbackEvidenceCheck?.status === "pass" && + (/evidence.*ref|ref.*evidence|ссылк.*доказ|доказ.*ссылк/i.test(text)) + ) { + return { + ...check, + action: fallbackEvidenceCheck.action, + evidenceRefs: fallbackEvidenceCheck.evidenceRefs, + message: fallbackEvidenceCheck.message, + severity: "low" as const, + status: "pass" as const, + title: fallbackEvidenceCheck.title + }; + } + + return check; + }); } function stringList(value: unknown, fallback: string[]) { @@ -350,10 +390,10 @@ function makeCheck(input: { } function getEvidenceRefIssues(strategy: SeoStrategyContract, synthesis: StrategySynthesisContract) { - const opportunityIssues = strategy.opportunities.filter((opportunity) => opportunity.evidenceRefs.length === 0); - const narrativeIssues = synthesis.evidenceNarrative.filter((item) => item.evidenceRefs.length === 0); + const opportunityIssues = strategy.opportunities.filter((opportunity) => (opportunity.evidenceRefs ?? []).length === 0); + const narrativeIssues = synthesis.evidenceNarrative.filter((item) => (item.evidenceRefs ?? []).length === 0); const optionIssues = synthesis.decisionOptions.filter( - (option) => option.evidenceRefs.length === 0 && option.scenario !== "bootstrap_runway" + (option) => (option.evidenceRefs ?? []).length === 0 && option.scenario !== "bootstrap_runway" ); return { @@ -704,7 +744,8 @@ function normalizeOutput( const warnings = output.checks.filter((check) => check.status === "warning").map((check) => `${check.title}: ${check.action}`); const approvedSignals = output.checks.filter((check) => check.status === "pass").map((check) => check.title); const score = getScore(output.checks); - const summary = `${getVerdictStatusSummary(output.verdict.status)}: проверок ${output.checks.length}, пройдено ${approvedSignals.length}, блокеров ${blockers.length}, предупреждений ${warnings.length}.`; + const verdict = getVerdict(output.checks, score); + const summary = `${getVerdictStatusSummary(verdict.status)}: проверок ${output.checks.length}, пройдено ${approvedSignals.length}, блокеров ${blockers.length}, предупреждений ${warnings.length}.`; return { ...output, @@ -733,9 +774,7 @@ function normalizeOutput( summary, state: output.checks.length > 0 ? "ready" : "not_ready", verdict: { - ...output.verdict, - primaryBlocker: blockers[0] ?? output.verdict.primaryBlocker, - score, + ...verdict, summary }, warnings @@ -748,25 +787,28 @@ export async function getLatestStrategyQualityReviewContract(projectId: string): getLatestStrategySynthesisContract(projectId), getLatestSerpInterpretationContract(projectId) ]); + const currentTask = buildStrategyQualityReviewModelTask(projectId, strategy, synthesis, serpInterpretation); const currentBackendReview = buildStrategyQualityReviewOutput(projectId, strategy, synthesis, serpInterpretation); - if (currentBackendReview) { - return currentBackendReview; + if (!currentTask) { + return getEmptyStrategyQualityReviewContract(projectId); } const result = await pool.query( ` select id, status, input, output, error_message, started_at, completed_at, created_at from runs - where project_id = $1 and run_type = 'seo_strategy_quality_review' + where project_id = $1 + and run_type = 'seo_strategy_quality_review' + and output->>'sourceTaskId' = $2 order by created_at desc limit 1; `, - [projectId] + [projectId, currentTask.taskId] ); const run = result.rows[0] ? mapStrategyQualityReviewRun(result.rows[0]) : null; - return run ?? getEmptyStrategyQualityReviewContract(projectId); + return run ?? currentBackendReview ?? getEmptyStrategyQualityReviewContract(projectId); } export async function runStrategyQualityReviewDraft(projectId: string): Promise { diff --git a/seo_mode/seo_mode/server/src/strategy/strategyQualityReviewTask.ts b/seo_mode/seo_mode/server/src/strategy/strategyQualityReviewTask.ts index 75347d4..eceef07 100644 --- a/seo_mode/seo_mode/server/src/strategy/strategyQualityReviewTask.ts +++ b/seo_mode/seo_mode/server/src/strategy/strategyQualityReviewTask.ts @@ -23,6 +23,14 @@ export type StrategyQualityReviewModelTaskContract = { siteMaturity: SeoStrategyContract["siteMaturity"]; phasePlan: SeoStrategyContract["phasePlan"]; phaseStrategies: SeoStrategyContract["phaseStrategies"]; + strategyClusters: { + readiness: SeoStrategyContract["strategyClusters"]["readiness"]; + clusters: Array<{ + id: string; + targetKind: SeoStrategyContract["strategyClusters"]["clusters"][number]["targetKind"]; + targetPath: string | null; + }>; + }; topOpportunities: SeoStrategyContract["opportunities"]; risks: SeoStrategyContract["risks"]; missingEvidence: SeoStrategyContract["missingEvidence"]; @@ -67,9 +75,11 @@ export type StrategyQualityReviewModelTaskContract = { }; guardrails: { noRewriteOrApply: boolean; + downstreamTextQualityDoesNotBlockReadOnlyStrategy: boolean; noInventedDemand: boolean; noImmediateHeadTermsForZeroSite: boolean; noBusinessExpansionWithoutBranch: boolean; + currentStrategyRoutingOverridesRawSerpBindingGap: boolean; requireEvidenceRefs: boolean; qualityGateOnly: boolean; }; @@ -99,6 +109,8 @@ export function buildStrategyQualityReviewModelTask( "request_more_evidence" ], guardrails: { + currentStrategyRoutingOverridesRawSerpBindingGap: true, + downstreamTextQualityDoesNotBlockReadOnlyStrategy: true, noBusinessExpansionWithoutBranch: true, noImmediateHeadTermsForZeroSite: true, noInventedDemand: true, @@ -120,6 +132,14 @@ export function buildStrategyQualityReviewModelTask( nextActions: strategy.nextActions.slice(0, 10), phasePlan: strategy.phasePlan, phaseStrategies: strategy.phaseStrategies, + strategyClusters: { + readiness: strategy.strategyClusters.readiness, + clusters: strategy.strategyClusters.clusters.slice(0, 40).map((cluster) => ({ + id: cluster.id, + targetKind: cluster.targetKind, + targetPath: cluster.targetPath + })) + }, primaryConstraint: strategy.verdict.primaryConstraint, risks: strategy.risks.slice(0, 12), schemaVersion: "seo-strategy.v1", @@ -170,10 +190,12 @@ export function buildStrategyQualityReviewModelTask( "Нет ready seo-strategy-synthesis.v1: quality review не проверяет сырую стратегию без synthesis.", "Strategy предлагает immediate head/top keywords для bootstrap/indexing сайта: вернуть blocked.", "В strategy/synthesis найдены claims без evidence refs: вернуть needs_changes или blocked.", + "Если strategyClusters.humanReviewCount = 0, existing/new имеют targetPath, а остальные кластеры явно routed в support/content/research, запрещено возвращать target/page binding как проблему качества.", + "Text Quality относится к будущему rewrite-output. Пока noRewriteOrApply=true и текст не генерируется, незакрытый Text Quality может быть только deferred warning, но не blocker/fail текущей read-only стратегии.", "Запрошены rewrite/apply/source mutations: этот task только quality gate.", "Business expansion смешан с core SEO route без user-approved branch: вернуть blocker." ], - taskId: `${strategySynthesis.runId ?? strategySynthesis.generatedAt}:strategy-quality-review:v1`, + taskId: `${strategySynthesis.sourceTaskId ?? strategySynthesis.runId ?? strategySynthesis.generatedAt}:strategy-quality-review:v4`, taskType: "seo.strategy_quality_review" }; } diff --git a/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts b/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts index ecf9c3f..23504be 100644 --- a/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts +++ b/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts @@ -210,15 +210,120 @@ function mergeModelProviderStrategySynthesisOutput( : fallback.summary; const executiveKeyPoints = stringList(executiveSummary.keyPoints, []); const blockedUntil = stringList(recommendedPath.blockedUntil, []); + const decisionOptions = Array.isArray(rawOutput.decisionOptions) + ? rawOutput.decisionOptions.map((item: Record, index: number) => { + const fallbackOption = + fallback.decisionOptions.find((option) => option.id === item?.id) ?? fallback.decisionOptions[index]; + const id = typeof item?.id === "string" && item.id.trim() ? item.id.trim() : fallbackOption?.id ?? `model-option:${index + 1}`; + + return { + confidence: isConfidence(item?.confidence) ? item.confidence : fallbackOption?.confidence ?? "low", + effort: isPriority(item?.effort) ? item.effort : fallbackOption?.effort ?? "medium", + evidenceRefs: stringList(item?.evidenceRefs, fallbackOption?.evidenceRefs ?? []), + expectedImpact: isPriority(item?.expectedImpact) ? item.expectedImpact : fallbackOption?.expectedImpact ?? "medium", + id, + nextStep: + typeof item?.nextStep === "string" && item.nextStep.trim() + ? item.nextStep.trim() + : fallbackOption?.nextStep ?? "Проверить вариант перед утверждением.", + priority: isPriority(item?.priority) ? item.priority : fallbackOption?.priority ?? "medium", + rationale: + typeof item?.rationale === "string" && item.rationale.trim() + ? item.rationale.trim() + : fallbackOption?.rationale ?? "Модель не объяснила вариант; нужна ручная проверка.", + risks: stringList(item?.risks, fallbackOption?.risks ?? []), + scenario: + item?.scenario === "analytics_first" || + item?.scenario === "bootstrap_runway" || + item?.scenario === "core_growth" || + item?.scenario === "risk_reduction" || + item?.scenario === "serp_expansion" + ? item.scenario + : fallbackOption?.scenario ?? getScenario(id), + title: + typeof item?.title === "string" && item.title.trim() + ? item.title.trim() + : fallbackOption?.title ?? `Вариант ${index + 1}` + }; + }) + : fallback.decisionOptions; + const dataGaps = Array.isArray(rawOutput.dataGaps) + ? rawOutput.dataGaps.map((item: Record, index: number) => { + const fallbackGap = fallback.dataGaps.find((gap) => gap.id === item?.id) ?? fallback.dataGaps[index]; + const owner = + item?.owner === "ai_layer" || item?.owner === "external_service" || item?.owner === "product_review" + ? item.owner + : fallbackGap?.owner ?? "product_review"; + + return { + id: + typeof item?.id === "string" && item.id.trim() + ? item.id.trim() + : fallbackGap?.id ?? `model-gap:${index + 1}`, + nextStep: + typeof item?.nextStep === "string" && item.nextStep.trim() + ? item.nextStep.trim() + : fallbackGap?.nextStep ?? + (owner === "ai_layer" + ? "Закрыть отдельной структурированной задачей модели." + : owner === "external_service" + ? "Собрать внешний источник доказательств." + : "Проверить и утвердить продуктовое решение."), + owner, + priority: isPriority(item?.priority) ? item.priority : fallbackGap?.priority ?? "medium", + title: + typeof item?.title === "string" && item.title.trim() + ? item.title.trim() + : fallbackGap?.title ?? `Пробел данных ${index + 1}`, + whyItBlocks: + typeof item?.whyItBlocks === "string" && item.whyItBlocks.trim() + ? item.whyItBlocks.trim() + : typeof item?.impact === "string" && item.impact.trim() + ? item.impact.trim() + : fallbackGap?.whyItBlocks ?? "Без этих данных нельзя повысить уверенность стратегии." + }; + }) + : fallback.dataGaps; + const evidenceNarrative = Array.isArray(rawOutput.evidenceNarrative) + ? rawOutput.evidenceNarrative.map((item: Record, index: number) => { + const matchedFallback = fallback.evidenceNarrative.find((candidate) => candidate.id === item?.id); + const fallbackItem = matchedFallback ?? fallback.evidenceNarrative[index]; + const id = + typeof item?.id === "string" && item.id.trim() + ? item.id.trim() + : fallbackItem?.id ?? `model-evidence:${index + 1}`; + + return { + confidence: isConfidence(item?.confidence) ? item.confidence : fallbackItem?.confidence ?? "low", + evidenceRefs: stringList( + item?.evidenceRefs, + matchedFallback?.evidenceRefs?.length ? matchedFallback.evidenceRefs : [`seo-strategy.v1:${id}`] + ), + id, + title: + typeof item?.title === "string" && item.title.trim() + ? item.title.trim() + : fallbackItem?.title ?? `Доказательный вывод ${index + 1}`, + whatWeKnow: + typeof item?.whatWeKnow === "string" && item.whatWeKnow.trim() + ? item.whatWeKnow.trim() + : fallbackItem?.whatWeKnow ?? "Вывод требует ручной проверки.", + whyItMatters: + typeof item?.whyItMatters === "string" && item.whyItMatters.trim() + ? item.whyItMatters.trim() + : fallbackItem?.whyItMatters ?? "Вывод влияет на выбор текущего маршрута стратегии." + }; + }) + : fallback.evidenceNarrative; return { ...fallback, ...output, analystReview: output.analystReview ?? fallback.analystReview, blockedActions: stringList(rawOutput.blockedActions, fallback.blockedActions), - dataGaps: Array.isArray(rawOutput.dataGaps) ? rawOutput.dataGaps : fallback.dataGaps, - decisionOptions: Array.isArray(rawOutput.decisionOptions) ? rawOutput.decisionOptions : fallback.decisionOptions, - evidenceNarrative: Array.isArray(rawOutput.evidenceNarrative) ? rawOutput.evidenceNarrative : fallback.evidenceNarrative, + dataGaps, + decisionOptions, + evidenceNarrative, executiveSummary: { confidence: isConfidence(executiveSummary.confidence) ? executiveSummary.confidence : fallback.executiveSummary.confidence, primaryConstraint: @@ -294,6 +399,60 @@ function mergeModelProviderStrategySynthesisOutput( }; } +function hasCompleteStrategyClusterRouting(strategy: SeoStrategyContract) { + return ( + strategy.strategyClusters.readiness.humanReviewCount === 0 && + strategy.strategyClusters.clusters.every( + (cluster) => + cluster.targetKind !== "human_review" && + (cluster.targetKind !== "existing_page" && cluster.targetKind !== "new_page" ? true : Boolean(cluster.targetPath)) + ) + ); +} + +function isStaleTargetBindingGap(gap: StrategySynthesisContract["dataGaps"][number]) { + const text = `${gap.id} ${gap.title} ${gap.whyItBlocks} ${gap.nextStep}`.toLocaleLowerCase("ru-RU"); + + return ( + gap.id === "gap:target_page_binding" || + /(?:target|page|страниц\w*)[_ -]?binding/.test(text) || + /(?:нет|не хватает|отсутств\w*)[^.]{0,80}(?:привяз\w*|маршрут\w*)[^.]{0,80}(?:страниц\w*|page)/.test(text) + ); +} + +function reconcileStrategySynthesisAgainstCurrentStrategy( + output: StrategySynthesisContract, + strategy: SeoStrategyContract +): StrategySynthesisContract { + if (!hasCompleteStrategyClusterRouting(strategy)) { + return output; + } + + const dataGaps = output.dataGaps.filter((gap) => !isStaleTargetBindingGap(gap)); + + if (dataGaps.length === output.dataGaps.length) { + return output; + } + + const summary = `Вариантов стратегии: ${output.decisionOptions.length}, доказательных выводов: ${output.evidenceNarrative.length}, рисков: ${output.riskPosture.length}, пробелов данных: ${dataGaps.length}.`; + + return { + ...output, + dataGaps, + executiveSummary: { + ...output.executiveSummary, + verdict: /^Вариантов стратегии:\s*\d+/i.test(output.executiveSummary.verdict) + ? summary + : output.executiveSummary.verdict + }, + readiness: { + ...output.readiness, + dataGapCount: dataGaps.length + }, + summary + }; +} + function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesisRun | null { if (!row.output) { return null; @@ -677,6 +836,11 @@ export async function getLatestStrategySynthesisContract(projectId: string): Pro return getEmptyStrategySynthesisContract(projectId); } + const currentTask = buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation); + if (!currentTask) { + return getEmptyStrategySynthesisContract(projectId); + } + const result = await pool.query( ` select id, status, input, output, error_message, started_at, completed_at, created_at @@ -685,14 +849,21 @@ export async function getLatestStrategySynthesisContract(projectId: string): Pro and run_type = 'seo_strategy_synthesis' and output->>'semanticRunId' = $2 and output->>'sourceSerpInterpretationRunId' = $3 + and output->>'sourceTaskId' = $4 order by created_at desc limit 1; `, - [projectId, strategy.semanticRunId, serpInterpretation.runId] + [projectId, strategy.semanticRunId, serpInterpretation.runId, currentTask.taskId] ); const run = result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null; + const fallbackOutput = buildStrategySynthesisOutput(projectId, strategy, serpInterpretation); - return run ?? getEmptyStrategySynthesisContract(projectId); + return run && fallbackOutput + ? reconcileStrategySynthesisAgainstCurrentStrategy( + mergeModelProviderStrategySynthesisOutput(run, fallbackOutput), + strategy + ) + : getEmptyStrategySynthesisContract(projectId); } export async function runStrategySynthesisDraft(projectId: string): Promise { @@ -784,7 +955,10 @@ export async function saveStrategySynthesisFromModelProvider( const normalizedOutput = normalizeOutput( projectId, - mergeModelProviderStrategySynthesisOutput(output, fallbackOutput), + reconcileStrategySynthesisAgainstCurrentStrategy( + mergeModelProviderStrategySynthesisOutput(output, fallbackOutput), + strategy + ), "codex_workspace", "AI Workspace Codex provider выполнил seo.strategy_synthesis по contract-only task; результат сохранён как evidence после backend validation." ); diff --git a/seo_mode/seo_mode/server/src/strategy/strategySynthesisTask.ts b/seo_mode/seo_mode/server/src/strategy/strategySynthesisTask.ts index 3989d1b..5174f17 100644 --- a/seo_mode/seo_mode/server/src/strategy/strategySynthesisTask.ts +++ b/seo_mode/seo_mode/server/src/strategy/strategySynthesisTask.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { SerpInterpretationContract } from "../evidence/serpInterpretation.js"; import type { SeoStrategyContract } from "./seoStrategy.js"; @@ -20,7 +21,19 @@ export type StrategySynthesisModelTaskContract = { primaryConstraint: string; siteMaturity: SeoStrategyContract["siteMaturity"]; phasePlan: SeoStrategyContract["phasePlan"]; - phaseStrategies: SeoStrategyContract["phaseStrategies"]; + phaseStrategies: SeoStrategyContract["phaseStrategies"]; + strategyClusters: { + readiness: SeoStrategyContract["strategyClusters"]["readiness"]; + clusters: Array<{ + canonicalPhrase: string; + decisionSource: "codex" | "human" | null; + phrases: string[]; + portfolioLane: SeoStrategyContract["strategyClusters"]["clusters"][number]["portfolioLane"]; + targetKind: SeoStrategyContract["strategyClusters"]["clusters"][number]["targetKind"]; + targetPath: string | null; + totalFrequency: number; + }>; + }; missingEvidence: SeoStrategyContract["missingEvidence"]; topOpportunities: SeoStrategyContract["opportunities"]; risks: SeoStrategyContract["risks"]; @@ -60,11 +73,37 @@ export type StrategySynthesisModelTaskContract = { noInventedDemand: boolean; noBusinessExpansionWithoutBranch: boolean; noImmediateHeadTermsForZeroSite: boolean; + currentStrategyRoutingOverridesRawSerpBindingGap: boolean; requireEvidenceRefs: boolean; }; stopConditions: string[]; }; +function buildStrategySynthesisTaskId(strategy: SeoStrategyContract, serpInterpretation: SerpInterpretationContract) { + const payload = { + keywordMapReviewState: strategy.readiness.keywordMapReviewState, + missingEvidence: strategy.missingEvidence.map((item) => ({ id: item.id, priority: item.priority })), + opportunities: strategy.opportunities.map((item) => ({ + demand: item.demand.totalFrequency, + id: item.id, + targetPath: item.targetPath, + timing: item.timing.action + })), + risks: strategy.risks.map((item) => ({ id: item.id, level: item.level, message: item.message })), + semanticRunId: strategy.semanticRunId, + serpInterpretationRunId: serpInterpretation.runId, + siteMaturityPhase: strategy.siteMaturity.phase, + strategyClusters: strategy.strategyClusters.clusters.map((cluster) => ({ + decisionSource: cluster.decision?.source ?? null, + id: cluster.id, + targetKind: cluster.targetKind, + targetPath: cluster.targetPath + })) + }; + const revision = createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 24); + return `strategy-synthesis:${revision}:v3`; +} + export function buildStrategySynthesisModelTask( projectId: string, strategy: SeoStrategyContract, @@ -86,6 +125,7 @@ export function buildStrategySynthesisModelTask( "preserve_evidence_refs" ], guardrails: { + currentStrategyRoutingOverridesRawSerpBindingGap: true, noBusinessExpansionWithoutBranch: true, noImmediateHeadTermsForZeroSite: true, noInventedDemand: true, @@ -104,10 +144,22 @@ export function buildStrategySynthesisModelTask( strategySignals: serpInterpretation.strategySignals.slice(0, 8), topPhrases: serpInterpretation.phraseInterpretations.slice(0, 8) }, - strategy: { + strategy: { confidence: strategy.verdict.confidence, phasePlan: strategy.phasePlan, phaseStrategies: strategy.phaseStrategies, + strategyClusters: { + readiness: strategy.strategyClusters.readiness, + clusters: strategy.strategyClusters.clusters.slice(0, 40).map((cluster) => ({ + canonicalPhrase: cluster.canonicalPhrase, + decisionSource: cluster.decision?.source ?? null, + phrases: cluster.phrases, + portfolioLane: cluster.portfolioLane, + targetKind: cluster.targetKind, + targetPath: cluster.targetPath, + totalFrequency: cluster.totalFrequency + })) + }, missingEvidence: strategy.missingEvidence.slice(0, 10), nextActions: strategy.nextActions.slice(0, 8), primaryConstraint: strategy.verdict.primaryConstraint, @@ -151,10 +203,11 @@ export function buildStrategySynthesisModelTask( "Нет готового seo-strategy.v1 с opportunities.", "Нет ready seo-serp-interpretation.v1: synthesis не должен строиться на сырых доменах без interpretation.", "Сайт в bootstrap/indexing фазе, а запрошен immediate route на head/top keywords: вернуть defer/hold.", + "Текущая strategyClusters routing revision авторитетнее сырого SERP no_target_binding: existing/new page требуют targetPath, support/content/research уже являются явным маршрутом; если humanReviewCount = 0 и эти условия выполнены, запрещено возвращать target/page binding как risk, dataGap, blocker или nextAction.", "Запрошены rewrite/apply/source mutations: этот task только аналитический.", "Нужно менять бизнес-вектор или добавлять новый рынок: вернуть proposal/data gap, не утверждать как core strategy." ], - taskId: `${strategy.generatedAt}:strategy-synthesis:v1`, + taskId: buildStrategySynthesisTaskId(strategy, serpInterpretation), taskType: "seo.strategy_synthesis" }; }