diff --git a/seo_mode/seo_mode/SEO-farm/MODEL_PROVIDER_CONTRACT.md b/seo_mode/seo_mode/SEO-farm/MODEL_PROVIDER_CONTRACT.md new file mode 100644 index 0000000..5a18c40 --- /dev/null +++ b/seo_mode/seo_mode/SEO-farm/MODEL_PROVIDER_CONTRACT.md @@ -0,0 +1,108 @@ +# NDC SEO mod: model provider contract + +Этот документ фиксирует, как подключать модель, чтобы SEO mod не зависел от одного runtime вроде Codex desktop. + +## Главный принцип + +Модель не является владельцем SEO pipeline. Она является исполнителем строго ограниченных задач внутри contract, который формирует SEO mod. + +SEO mod отвечает за: +- scan, scope, page identity, section identity; +- project ontology и evidence; +- market evidence; +- keyword/page/field plan; +- validation gates; +- changeset/apply safety. + +Model runtime отвечает только за: +- интерпретацию неоднозначного контекста; +- нормализацию seed phrases; +- подготовку вариантов текста в approved slots; +- объяснение риска и confidence; +- structured JSON output по схеме. + +## Provider-agnostic shape + +Целевой слой должен называться условно `ModelProvider`. + +Обязательный interface: +- `capabilities`: context window, json mode, tool use, streaming, cost policy, rate limits; +- `runStructuredTask(task)`: принять task contract и вернуть validated JSON; +- `explainFailure(error)`: нормализовать provider/runtime error; +- `estimateCost(task)`: оценить cost/limits до запуска; +- `cancel(runId)`: остановить long task. + +Провайдеры: +- `codex_workspace`: текущий dev/runtime через AI Workspace/Codex-like rules; +- `openai_api`: production token-based model access; +- `local_model`: локальная модель для private/on-prem; +- `external_agent`: enterprise adapter через AI Workspace Hub или MCP-compatible gateway. + +## MCP / AI Workspace boundary + +MCP не должен быть местом бизнес-логики SEO. + +MCP/AI Workspace может давать модели: +- read-only контекст проекта; +- approved task contract; +- инструменты для чтения artifacts; +- tools для draft-only output. + +MCP/AI Workspace не должен давать модели: +- прямой apply в source files; +- возможность менять project ontology truth без review; +- возможность писать changeset без validation; +- доступ к secrets без platform permission layer; +- свободный crawl/write вне выбранного scope. + +## Когда модель нужна + +1. Context Review +- уточнить реальный смысл сайта, если текст или разметка плохие; +- найти ambiguity и low-confidence места; +- предложить seed phrases с evidence references; +- не писать финальный SEO-текст. + +2. Seed Review +- нормализовать фразы; +- убрать слишком широкие или выдуманные фразы; +- объяснить, почему фраза привязана к intent/page. + +3. Model Review +- предложить rewrite variants только по approved diff slots; +- не менять бизнес-вектор; +- не создавать новые страницы без materialization decision; +- вернуть before/after rationale and risk flags. + +4. Validation Assist +- объяснить validation warnings; +- предложить безопасные правки для accepted draft; +- не override hard blockers. + +## Task contract requirements + +Каждый model task должен содержать: +- `taskType`; +- `schemaVersion`; +- `projectOntologyVersionId`; +- `scopeContract`; +- `evidenceRefs`; +- `forbiddenClaims`; +- `styleProfile`; +- `allowedActions`; +- `outputSchema`; +- `confidencePolicy`; +- `stopConditions`. + +Модель должна возвращать: +- `schemaVersion`; +- `confidence`; +- `evidenceRefsUsed`; +- `changes` или `proposals`; +- `risks`; +- `needsHumanReview`; +- `refusalReason`, если contract запрещает уверенный ответ. + +## Product rule + +До подключения production provider мы можем симулировать model output через deterministic contracts и manual review. Но все contracts уже должны выглядеть так, будто завтра вместо Codex придет OpenAI API, local model или enterprise AI Workspace adapter. diff --git a/seo_mode/seo_mode/server/migrations/007_project_ontology_versions.sql b/seo_mode/seo_mode/server/migrations/007_project_ontology_versions.sql new file mode 100644 index 0000000..5038df7 --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/007_project_ontology_versions.sql @@ -0,0 +1,28 @@ +create table if not exists project_ontology_versions ( + id uuid primary key default gen_random_uuid(), + project_id uuid not null references projects(id) on delete cascade, + scan_version_id uuid references scan_versions(id) on delete set null, + semantic_run_id uuid references runs(id) on delete set null, + version integer not null, + schema_version text not null, + source text not null, + brand_name text not null, + approval_status text not null default 'draft', + domain_package jsonb not null default '{}'::jsonb, + instance jsonb not null default '{}'::jsonb, + evidence_summary jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + approved_at timestamptz, + approved_by text, + unique(project_id, version) +); + +create index if not exists project_ontology_versions_project_created_idx +on project_ontology_versions(project_id, created_at desc); + +create index if not exists project_ontology_versions_scan_idx +on project_ontology_versions(scan_version_id); + +create index if not exists project_ontology_versions_semantic_run_idx +on project_ontology_versions(semantic_run_id); diff --git a/seo_mode/seo_mode/server/migrations/008_keyword_decision_bindings.sql b/seo_mode/seo_mode/server/migrations/008_keyword_decision_bindings.sql new file mode 100644 index 0000000..cef5c2a --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/008_keyword_decision_bindings.sql @@ -0,0 +1,36 @@ +alter table keyword_decisions +add column if not exists semantic_run_id uuid references runs(id) on delete set null, +add column if not exists project_ontology_version_id uuid references project_ontology_versions(id) on delete set null, +add column if not exists source_item_id text, +add column if not exists cluster_id text, +add column if not exists cluster_title text, +add column if not exists target_path text, +add column if not exists evidence_status text, +add column if not exists frequency integer, +add column if not exists frequency_group text, +add column if not exists payload jsonb not null default '{}'::jsonb, +add column if not exists updated_at timestamptz not null default now(); + +create unique index if not exists keyword_decisions_project_ontology_source_idx +on keyword_decisions(project_id, project_ontology_version_id, source_item_id) +where source_item_id is not null; + +create index if not exists keyword_decisions_project_semantic_idx +on keyword_decisions(project_id, semantic_run_id, created_at desc); + +alter table keyword_map_items +add column if not exists semantic_run_id uuid references runs(id) on delete set null, +add column if not exists project_ontology_version_id uuid references project_ontology_versions(id) on delete set null, +add column if not exists source_item_id text, +add column if not exists page_id uuid references pages(id) on delete set null, +add column if not exists target_path text, +add column if not exists workspace_section_id text, +add column if not exists payload jsonb not null default '{}'::jsonb, +add column if not exists updated_at timestamptz not null default now(); + +create unique index if not exists keyword_map_items_project_decision_workspace_idx +on keyword_map_items(project_id, keyword_decision_id, workspace_section_id) +where workspace_section_id is not null; + +create index if not exists keyword_map_items_project_ontology_idx +on keyword_map_items(project_id, project_ontology_version_id, created_at desc); diff --git a/seo_mode/seo_mode/server/migrations/009_materialization_decisions.sql b/seo_mode/seo_mode/server/migrations/009_materialization_decisions.sql new file mode 100644 index 0000000..53e2552 --- /dev/null +++ b/seo_mode/seo_mode/server/migrations/009_materialization_decisions.sql @@ -0,0 +1,19 @@ +create table if not exists materialization_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, + project_ontology_version_id uuid references project_ontology_versions(id) on delete set null, + target_key text not null, + target_path text, + action text not null, + status text not null, + page_id uuid references pages(id) on delete set null, + note text, + payload jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique(project_id, project_ontology_version_id, target_key) +); + +create index if not exists materialization_decisions_project_updated_idx +on materialization_decisions(project_id, updated_at desc); diff --git a/seo_mode/seo_mode/server/package.json b/seo_mode/seo_mode/server/package.json index 30ec8da..3bf8057 100644 --- a/seo_mode/seo_mode/server/package.json +++ b/seo_mode/seo_mode/server/package.json @@ -8,6 +8,7 @@ "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc --noEmit -p tsconfig.json", + "check:ontology-universality": "tsx src/scripts/checkOntologyUniversality.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 08932a2..a8e3f4d 100644 --- a/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts +++ b/seo_mode/seo_mode/server/src/analysis/semanticAnalysis.ts @@ -1,6 +1,20 @@ import fs from "node:fs/promises"; import path from "node:path"; import { pool } from "../db/client.js"; +import { + INTENT_SIGNAL_GROUPS, + SEO_DOMAIN_PACKAGE_BINDING, + getProjectOntology, + type IntentSignalGroupId, + type LandingOpportunityPageType, + type ProjectOntologyEvidence, + type ProjectOntology, + type SemanticClusterDefinition +} from "../ontology/projectOntology.js"; +import { + linkProjectOntologyVersionToRun, + saveProjectOntologyVersion +} from "../ontology/projectOntologyRepository.js"; import { storageProvider } from "../storage/index.js"; type LatestScanRow = { @@ -84,44 +98,6 @@ type AnalysisVerdict = { nextValidationStep: string; }; -type IntentSignalGroupId = - | "agents" - | "ai" - | "automation" - | "business" - | "commercial" - | "data" - | "development" - | "engineering" - | "enterprise" - | "implementation" - | "integrations" - | "operations" - | "process" - | "security"; - -type SemanticClusterDefinition = { - id: string; - title: string; - priority: "high" | "medium" | "low"; - targetIntent: string; - intentGroups: IntentSignalGroupId[]; - keywords: string[]; - queryExamples: string[]; - targetPages: string[]; -}; - -type LandingOpportunityPageType = - | "conversion" - | "deployment" - | "docs" - | "home" - | "integration" - | "landing" - | "module" - | "security" - | "use_case"; - type LegacyMarker = { id: string; label: string; @@ -211,6 +187,25 @@ export type SemanticAnalysisOutput = { ctaPhrases: string[]; toneSignals: string[]; }; + projectOntology: { + id: string; + versionId: string | null; + version: number | null; + source: ProjectOntology["source"]; + brandName: string; + domainPackage: ProjectOntology["domainPackage"]; + instance: { + schemaVersion: "seo-project-ontology.v1"; + siteId: string; + sourceType: string; + scanVersionId: string; + approvalStatus: "draft" | "approved" | "rejected" | "needs_review"; + pageCount: number; + sectionCount: number; + intentClusterCount: number; + evidenceCount: number; + } | null; + }; qualitySignals: AnalysisSignal[]; analysisVerdict: AnalysisVerdict; clusters: Array<{ @@ -409,273 +404,6 @@ export class SemanticAnalysisError extends Error { } } -const NODEDC_CLUSTERS: SemanticClusterDefinition[] = [ - { - id: "ai_platform_development", - title: "AI-платформа для разработки и автоматизации", - priority: "high", - targetIntent: "Базовый коммерческий интент: платформа, разработка, автоматизация, искусственный интеллект.", - intentGroups: ["ai", "automation", "business", "development", "enterprise"], - keywords: [ - "платформа", - "разработка", - "искусственный интеллект", - "ai", - "llm", - "low-code", - "no-code", - "автоматизация", - "продуктовая разработка" - ], - queryExamples: [ - "платформа автоматизации бизнеса ai", - "ai платформа для разработки", - "разработка бизнес приложений с искусственным интеллектом" - ], - targetPages: ["/", "/platform/", "/modules/"] - }, - { - id: "business_process_automation", - title: "Автоматизация бизнес-процессов", - priority: "high", - targetIntent: "BPM/workflow рынок: процессы, регламенты, контроль исполнения, сквозные цепочки.", - intentGroups: ["automation", "business", "process", "operations", "implementation"], - keywords: [ - "бизнес-процесс", - "workflow", - "bpm", - "регламент", - "процесс", - "операционный контур", - "сквозной процесс", - "маршрут согласования" - ], - queryExamples: [ - "автоматизация бизнес процессов", - "workflow система для бизнеса", - "bpm платформа с искусственным интеллектом" - ], - targetPages: ["/use-cases/", "/modules/ops/", "/modules/engine/"] - }, - { - id: "agentic_automation", - title: "Агентная автоматизация и AI-ассистенты", - priority: "high", - targetIntent: "Трендовый интент: AI agents, ассистенты, автономные проверки и выполнение задач.", - intentGroups: ["agents", "ai", "automation", "operations"], - keywords: [ - "агент", - "агентная", - "ai agent", - "ассистент", - "codex", - "нейросеть", - "модель", - "автономный", - "toolpack" - ], - queryExamples: [ - "ai агенты для бизнеса", - "агентная автоматизация процессов", - "ai ассистент для разработки и операций" - ], - targetPages: ["/modules/assistant/", "/modules/ops/", "/docs/"] - }, - { - id: "ops_project_control", - title: "Ops, Tasker и управление проектами", - priority: "medium", - targetIntent: "Операционное управление: задачи, чекеры, статусы, проектные карточки.", - intentGroups: ["operations", "business", "process", "implementation"], - keywords: [ - "ops", - "tasker", - "задача", - "проект", - "проверка", - "чекер", - "статус", - "исполнитель", - "операционный" - ], - queryExamples: [ - "ai ops управление задачами", - "система контроля проектов с ai", - "автоматизация проектного офиса" - ], - targetPages: ["/modules/ops/", "/use-cases/project-office/"] - }, - { - id: "hub_workspace_rbac", - title: "Hub, workspace, роли и доступы", - priority: "medium", - targetIntent: "Рабочие пространства, запуск модулей, роли, витрина приложений.", - intentGroups: ["business", "enterprise", "operations", "security"], - keywords: [ - "hub", - "workspace", - "рабочее пространство", - "роль", - "rbac", - "доступ", - "витрина", - "launcher", - "команда" - ], - queryExamples: [ - "ai workspace для команды", - "hub платформа бизнес приложений", - "rbac для ai платформы" - ], - targetPages: ["/modules/hub/", "/security/"] - }, - { - id: "engine_infrastructure", - title: "Engine, инфраструктура и оркестрация", - priority: "medium", - targetIntent: "Технический слой: engine, docker, deploy, сервисы, mcp, контуры.", - intentGroups: ["development", "implementation", "enterprise", "integrations"], - keywords: [ - "engine", - "оркестрация", - "инфраструктура", - "docker", - "deploy", - "mcp", - "сервис", - "pipeline", - "контур" - ], - queryExamples: [ - "оркестрация ai сервисов", - "инфраструктура для ai автоматизации", - "mcp интеграции для бизнеса" - ], - targetPages: ["/modules/engine/", "/deployment/", "/docs/"] - }, - { - id: "integrations_api", - title: "Интеграции, API, 1C, CRM/ERP", - priority: "high", - targetIntent: "Коммерческий интент интеграций: 1С, CRM, ERP, API, webhooks.", - intentGroups: ["integrations", "business", "automation", "implementation"], - keywords: [ - "интеграция", - "api", - "webhook", - "1с", - "1c", - "crm", - "erp", - "битрикс", - "telegram", - "внешняя система" - ], - queryExamples: [ - "ai интеграция с 1с", - "автоматизация 1с с искусственным интеллектом", - "api платформа для интеграции бизнес систем" - ], - targetPages: ["/integrations/", "/modules/1c-assistant/"] - }, - { - id: "tender_legal_procurement", - title: "Тендеры, закупки и юридические процессы", - priority: "medium", - targetIntent: "Отраслевые сценарии с высоким value: тендеры, договоры, закупки, юристы.", - intentGroups: ["business", "process", "automation", "commercial"], - keywords: [ - "тендер", - "закупка", - "договор", - "юридический", - "юрист", - "согласование", - "коммерческое предложение", - "поставщик" - ], - queryExamples: [ - "ai для тендеров", - "автоматизация закупок с ai", - "ai ассистент для договоров" - ], - targetPages: ["/modules/tender/", "/use-cases/procurement/", "/use-cases/legal/"] - }, - { - id: "digital_twin_3d_data", - title: "Digital Twin, BIM, 3D и инженерные данные", - priority: "medium", - targetIntent: "Инженерный рынок: цифровые двойники, BIM, STEP/STL/3DGS/LAS, облака точек.", - intentGroups: ["engineering", "data", "ai", "enterprise"], - keywords: [ - "digital twin", - "цифровой двойник", - "bim", - "step", - "stl", - "3dgs", - "las", - "облако точек", - "инженерный", - "модель объекта" - ], - queryExamples: [ - "ai digital twin платформа", - "цифровой двойник bim ai", - "автоматизация инженерных данных" - ], - targetPages: ["/modules/digital-twin/", "/use-cases/construction/"] - }, - { - id: "private_cloud_security", - title: "Private cloud, on-prem и безопасность", - priority: "high", - targetIntent: "B2B enterprise интент: данные, контур, безопасность, развертывание.", - intentGroups: ["enterprise", "security", "data", "implementation"], - keywords: [ - "on-prem", - "private cloud", - "частное облако", - "безопасность", - "данные", - "контур", - "сервер", - "инфраструктура заказчика", - "изоляция" - ], - queryExamples: [ - "ai платформа on premise", - "частное облако для ai автоматизации", - "безопасная ai платформа для бизнеса" - ], - targetPages: ["/deployment/", "/security/"] - }, - { - id: "conversion_pilot_pricing", - title: "Демо, пилот, тарифы и внедрение", - priority: "high", - targetIntent: "Нижняя часть воронки: запрос демо, пилот, стоимость, внедрение.", - intentGroups: ["commercial", "business", "implementation"], - keywords: [ - "демо", - "пилот", - "тариф", - "стоимость", - "внедрение", - "проект", - "заявка", - "консультация", - "коммерческое предложение" - ], - queryExamples: [ - "заказать ai автоматизацию бизнеса", - "стоимость внедрения ai платформы", - "пилот ai автоматизации бизнес процессов" - ], - targetPages: ["/tariffs/", "/pilot/", "/contacts/"] - } -]; - const LEGACY_MARKERS: LegacyMarker[] = [ { id: "webflow", @@ -689,7 +417,7 @@ const LEGACY_MARKERS: LegacyMarker[] = [ label: "SS-script", terms: ["ss-script", "ss script", "ss_script", "ssscript"], severity: "blocker", - action: "Заменить старое продуктово-техническое упоминание на NodeDC или удалить." + action: "Заменить старое продуктово-техническое упоминание на актуальный бренд проекта или удалить." }, { id: "federat", @@ -703,7 +431,7 @@ const LEGACY_MARKERS: LegacyMarker[] = [ label: "старые docs-ссылки", terms: ["docs.ss", "ss-docs", "old docs", "legacy docs", "docs.federat"], severity: "warning", - action: "Проверить ссылку: оставить только актуальные NodeDC docs или убрать." + action: "Проверить ссылку: оставить только актуальные docs проекта или убрать." } ]; @@ -809,23 +537,6 @@ const CTA_PATTERNS = [ "заказать" ]; -const INTENT_SIGNAL_GROUPS = { - agents: ["агент", "агентная", "ai agent", "автономный", "ассистент", "toolpack", "codex"], - ai: ["искусственный интеллект", "ai", "ии", "llm", "нейросеть", "модель", "prompt", "генеративный"], - automation: ["автоматизация", "автоматизировать", "workflow", "bpm", "регламент", "сквозной", "маршрут"], - business: ["бизнес", "компания", "команда", "клиент", "заказчик", "операционный", "подразделение"], - commercial: ["демо", "пилот", "стоимость", "тариф", "внедрение", "заявка", "консультация", "коммерческое предложение"], - data: ["данные", "база", "хранилище", "интеграция данных", "аналитика", "сущность", "dataset"], - development: ["разработка", "приложение", "код", "api", "frontend", "backend", "low-code", "no-code"], - engineering: ["bim", "digital twin", "цифровой двойник", "3d", "step", "stl", "las", "инженерный"], - enterprise: ["enterprise", "b2b", "корпоративный", "on-prem", "private cloud", "частное облако", "контур"], - implementation: ["внедрение", "развертывание", "deploy", "инфраструктура", "сервер", "docker", "production"], - integrations: ["интеграция", "api", "webhook", "1с", "crm", "erp", "битрикс", "telegram"], - operations: ["ops", "tasker", "задача", "статус", "чекер", "исполнитель", "проектный офис"], - process: ["процесс", "бизнес-процесс", "согласование", "закупка", "договор", "тендер", "регламент"], - security: ["безопасность", "доступ", "роль", "rbac", "изоляция", "права", "закрытый контур"] -} satisfies Record; - function toIsoDate(value: Date | null) { return value ? value.toISOString() : null; } @@ -1071,7 +782,7 @@ function buildEvidenceSnippet(document: ContentDocument, terms: string[]) { }; } -function buildStyleProfile(documents: ContentDocument[]): SemanticAnalysisOutput["styleProfile"] { +function buildStyleProfile(documents: ContentDocument[], ontology: ProjectOntology): SemanticAnalysisOutput["styleProfile"] { const text = documents.map((document) => document.text).join("\n"); const normalizedText = normalizeSearchText(text); const cyrillicCount = (normalizedText.match(/[а-я]/g) ?? []).length; @@ -1096,15 +807,14 @@ function buildStyleProfile(documents: ContentDocument[]): SemanticAnalysisOutput .sort((first, second) => second[1] - first[1] || first[0].localeCompare(second[0], "ru-RU")) .slice(0, 18) .map(([term, count]) => ({ term, count })); - const knownBrandTerms = ["nodedc", "node.dc", "node dc", "node.js", "нодедс", "ии", "ai", "ops", "hub", "engine"]; - const brandTerms = knownBrandTerms.filter((term) => countTermHits(normalizedText, term) > 0); + const brandTerms = ontology.brandTerms.filter((term) => countTermHits(normalizedText, term) > 0); const ctaPhrases = CTA_PATTERNS.filter((phrase) => countTermHits(normalizedText, phrase) > 0); const toneSignals = [ topTerms.some((item) => ["платформа", "контур", "инфраструктура", "процесс"].includes(item.term)) ? "инженерно-продуктовый тон" : "", topTerms.some((item) => ["бизнес", "команда", "проект", "задача"].includes(item.term)) - ? "B2B/операционный язык" + ? "организационно-операционный язык" : "", brandTerms.some((term) => ["ai", "ии"].includes(term)) ? "AI-first лексика" : "", ctaPhrases.length > 0 ? "есть коммерческие CTA" : "" @@ -1299,6 +1009,48 @@ async function buildContentJsonDocument(source: LatestScanRow, sourcePath: strin } } +function buildProjectOntologyEvidence( + projectId: string, + scan: LatestScanRow, + pages: PageRow[], + pageDocuments: ContentDocument[], + contentDocuments: ContentDocument[] +): ProjectOntologyEvidence { + const pageRowsById = new Map(pages.map((page) => [page.page_id, page])); + + return { + projectId, + scanVersionId: scan.id, + sourceId: scan.source_id, + projectIndexKey: scan.project_index_key, + sourceType: scan.source_type, + sourceConfig: scan.source_config, + pages: pageDocuments.map((document) => ({ + id: document.id, + kind: document.kind, + selected: document.selected, + scope: document.scope, + usedForCoverage: document.usedForCoverage, + title: document.title, + sourcePath: document.sourcePath, + urlPath: document.urlPath, + text: document.text, + headings: normalizeHeadings(pageRowsById.get(document.id)?.raw ?? {}) + })), + contentDocuments: contentDocuments.map((document) => ({ + id: document.id, + kind: document.kind, + selected: document.selected, + scope: document.scope, + usedForCoverage: document.usedForCoverage, + title: document.title, + sourcePath: document.sourcePath, + urlPath: document.urlPath, + text: document.text + })) + }; +} + function getClusterHits(cluster: SemanticClusterDefinition, document: ContentDocument) { const normalizedText = normalizeSearchText(document.text); let hits = 0; @@ -1316,8 +1068,8 @@ function getClusterHits(cluster: SemanticClusterDefinition, document: ContentDoc return { hits, matchedTerms }; } -function analyzeClusters(documents: ContentDocument[]) { - return NODEDC_CLUSTERS.map((cluster) => { +function analyzeClusters(documents: ContentDocument[], ontology: ProjectOntology) { + return ontology.clusters.map((cluster) => { const docHits = documents .map((document) => { const result = getClusterHits(cluster, document); @@ -1513,10 +1265,13 @@ function getEvidenceScore(clusters: ReturnType) { return getWeightedClusterScore(clusters, (cluster) => cluster.evidence.score); } -function getCommercialReadinessScore(clusters: ReturnType, styleProfile: SemanticAnalysisOutput["styleProfile"]) { - const commercialClusters = clusters.filter((cluster) => - ["business_process_automation", "integrations_api", "conversion_pilot_pricing", "private_cloud_security"].includes(cluster.id) - ); +function getCommercialReadinessScore( + clusters: ReturnType, + styleProfile: SemanticAnalysisOutput["styleProfile"], + ontology: ProjectOntology +) { + const commercialClusterIds = new Set(ontology.commercialClusterIds); + const commercialClusters = clusters.filter((cluster) => commercialClusterIds.has(cluster.id)); const clusterScore = getWeightedClusterScore(commercialClusters.length > 0 ? commercialClusters : clusters, (cluster) => cluster.score); const ctaScore = Math.min(20, styleProfile.ctaPhrases.length * 10); @@ -1552,13 +1307,13 @@ function analyzeLegacy(documents: ContentDocument[]) { }); } -function buildSeedCandidates(clusters: ReturnType) { +function buildSeedCandidates(clusters: ReturnType, ontology: ProjectOntology) { return clusters.flatMap((cluster) => { const source: SemanticAnalysisOutput["seedCandidates"][number]["source"] = cluster.status === "missing" ? "ontology" : "detected"; const reason = cluster.status === "missing" - ? "Кластер важен для NodeDC, но в текущем сайте почти не раскрыт." + ? `Кластер важен для ${ontology.brandName}, но в текущем сайте почти не раскрыт.` : "Кластер уже найден в контенте и подходит для проверки частотности."; return cluster.queryExamples.slice(0, cluster.priority === "high" ? 3 : 2).map((phrase) => ({ @@ -1871,82 +1626,34 @@ function trimSeoText(value: string, maxLength: number) { return `${sliced.slice(0, lastSpaceIndex > 40 ? lastSpaceIndex : maxLength - 1).trim()}…`; } -function getLandingBriefEntity(pagePath: string, pageType: LandingOpportunityPageType) { +function getLandingBriefEntity(pagePath: string, pageType: LandingOpportunityPageType, ontology: ProjectOntology) { const normalizedPath = normalizeUrlLikePath(pagePath); - const labels: Record = { - "/": "NodeDC", - "/contacts": "Контакты NodeDC", - "/deployment": "Внедрение NodeDC", - "/docs": "Документация NodeDC", - "/integrations": "Интеграции NodeDC", - "/modules": "Модули NodeDC", - "/modules/1c-assistant": "1С-ассистент NodeDC", - "/modules/assistant": "AI-ассистенты NodeDC", - "/modules/digital-twin": "Digital Twin NodeDC", - "/modules/engine": "NDC Engine", - "/modules/hub": "NDC Hub", - "/modules/ops": "NDC Ops", - "/modules/tender": "Тендерный модуль NodeDC", - "/pilot": "Пилот NodeDC", - "/platform": "Платформа NodeDC", - "/security": "Безопасность NodeDC", - "/tariffs": "Тарифы NodeDC", - "/use-cases": "Сценарии NodeDC", - "/use-cases/construction": "NodeDC для строительства", - "/use-cases/legal": "NodeDC для юридических процессов", - "/use-cases/procurement": "NodeDC для закупок", - "/use-cases/project-office": "NodeDC для проектного офиса" - }; - if (labels[normalizedPath]) { - return labels[normalizedPath]; + if (ontology.landingBrief.entityLabels[normalizedPath]) { + return ontology.landingBrief.entityLabels[normalizedPath]; } - if (pageType === "module") { - return "Модуль NodeDC"; - } - - if (pageType === "use_case") { - return "Сценарий NodeDC"; - } - - if (pageType === "integration") { - return "Интеграция NodeDC"; - } - - return "NodeDC"; + return ontology.landingBrief.fallbackEntities[pageType] ?? ontology.brandName; } -function getLandingBriefAudience(pageType: LandingOpportunityPageType) { - const audiences: Record = { - conversion: "ЛПР, которому нужно быстро понять формат пилота, стоимость и следующий шаг.", - deployment: "IT-директор и архитектор, которые оценивают развертывание в корпоративном контуре.", - docs: "Техническая команда, которая хочет понять подключение, API, модули и ограничения.", - home: "Бизнес и технический ЛПР, который впервые оценивает платформу NodeDC.", - integration: "Руководитель автоматизации и интегратор, которым важны 1С, CRM/ERP, API и webhooks.", - landing: "ЛПР по цифровизации, которому нужно связать проблему, решение и внедрение.", - module: "Владелец процесса и техническая команда, которые выбирают конкретный модуль платформы.", - security: "Enterprise/IT/security команда, которая проверяет доступы, контур, данные и риски.", - use_case: "Владелец бизнес-процесса, которому нужен понятный сценарий применения NodeDC." - }; - - return audiences[pageType]; +function getLandingBriefAudience(pageType: LandingOpportunityPageType, ontology: ProjectOntology) { + return ontology.landingBrief.audiences[pageType]; } function getLandingBriefPrimaryCta(pageType: LandingOpportunityPageType) { if (pageType === "conversion") { - return "Запросить расчет пилота"; + return "Уточнить условия"; } if (pageType === "docs") { - return "Открыть технический контур"; + return "Открыть инструкцию"; } if (pageType === "security" || pageType === "deployment") { - return "Обсудить развертывание"; + return "Обсудить запуск"; } - return "Запросить демо"; + return "Получить консультацию"; } function getLandingBriefSecondaryCta(pageType: LandingOpportunityPageType) { @@ -1955,73 +1662,22 @@ function getLandingBriefSecondaryCta(pageType: LandingOpportunityPageType) { } if (pageType === "module" || pageType === "use_case") { - return "Собрать пилотный сценарий"; + return "Разобрать сценарий"; } return "Получить консультацию"; } -function getLandingBriefInternalLinks(pagePath: string, pageType: LandingOpportunityPageType) { +function getLandingBriefInternalLinks(pagePath: string, pageType: LandingOpportunityPageType, ontology: ProjectOntology) { const normalizedPath = normalizeUrlLikePath(pagePath); - const baseLinks = ["/", "/platform/", "/modules/", "/integrations/", "/deployment/", "/security/", "/pilot/", "/contacts/"]; - const typedLinks: Record = { - conversion: ["/platform/", "/modules/", "/contacts/"], - deployment: ["/security/", "/docs/", "/integrations/"], - docs: ["/modules/", "/integrations/", "/deployment/"], - home: ["/platform/", "/modules/", "/pilot/"], - integration: ["/modules/engine/", "/security/", "/pilot/"], - landing: ["/platform/", "/modules/", "/pilot/"], - module: ["/integrations/", "/deployment/", "/security/", "/pilot/"], - security: ["/deployment/", "/docs/", "/contacts/"], - use_case: ["/modules/ops/", "/integrations/", "/pilot/"] - }; - return Array.from(new Set([...typedLinks[pageType], ...baseLinks])) + return Array.from(new Set([...ontology.landingBrief.typedInternalLinks[pageType], ...ontology.landingBrief.baseInternalLinks])) .filter((link) => normalizeUrlLikePath(link) !== normalizedPath) .slice(0, 5); } -function getLandingBriefTypeSection(pageType: LandingOpportunityPageType) { - const sections: Record = { - conversion: { - title: "Формат пилота и следующий шаг", - goal: "Показать, как начинается проект, что входит в пилот и какие данные нужны для оценки." - }, - deployment: { - title: "Архитектура внедрения", - goal: "Раскрыть контур, инфраструктуру, этапы развертывания и ответственность сторон." - }, - docs: { - title: "Как подключиться", - goal: "Дать технический вход: модули, API, интеграции, роли и ограничения." - }, - home: { - title: "Платформа и модули", - goal: "Связать общий value proposition с конкретными модулями и сценариями." - }, - integration: { - title: "Системы и API", - goal: "Показать подключение к 1С, CRM/ERP, API, webhooks и внешним контурам." - }, - landing: { - title: "Как NodeDC закрывает задачу", - goal: "Собрать проблему, решение, результат и следующий коммерческий шаг." - }, - module: { - title: "Как работает модуль", - goal: "Объяснить входные данные, действия модуля, роли, статусы и результат для бизнеса." - }, - security: { - title: "Контур, доступы и данные", - goal: "Раскрыть безопасность, роли, RBAC, изоляцию, хранение данных и аудит." - }, - use_case: { - title: "Сценарий до и после внедрения", - goal: "Показать текущую боль процесса, целевой workflow и измеримый эффект." - } - }; - - return sections[pageType]; +function getLandingBriefTypeSection(pageType: LandingOpportunityPageType, ontology: ProjectOntology) { + return ontology.landingBrief.typeSections[pageType]; } type LandingBriefDraft = { @@ -2222,7 +1878,7 @@ function buildLandingBriefQualityContract( "Wordstat частотность и сезонность по seed-фразам", "SERP-конкуренты и типы страниц в топе Яндекса", "Региональная выдача и коммерческие маркеры", - "Фактические продуктовые доказательства: кейсы, интеграции, SLA, ограничения" + "Фактические доказательства: примеры, условия, ограничения и подтверждения" ]; const rawScore = checks.reduce((sum, check) => sum + getBriefCheckScore(check.status), 0) / Math.max(1, checks.length) - @@ -2260,18 +1916,21 @@ function buildLandingBriefQualityContract( }; } -function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landingPagePlan"]) { +function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landingPagePlan"], ontology: ProjectOntology) { return landingPagePlan.map((page) => { - const entity = getLandingBriefEntity(page.path, page.pageType); + const entity = getLandingBriefEntity(page.path, page.pageType, ontology); const primaryCluster = page.clusters[0]; const clusterTitles = page.clusters.map((cluster) => cluster.title); const primaryIntent = primaryCluster?.targetIntent ?? page.reason; const mustUseTerms = Array.from(new Set([...page.seedPhrases.slice(0, 5), ...clusterTitles.slice(0, 4)])).slice(0, 8); - const typeSection = getLandingBriefTypeSection(page.pageType); - const h1 = trimSeoText(`${entity}: ${primaryCluster?.title ?? "AI-автоматизация для бизнеса"}`, 96); - const title = trimSeoText(`${entity} — ${primaryCluster?.title ?? "AI-автоматизация"} | NodeDC`, 72); + const typeSection = getLandingBriefTypeSection(page.pageType, ontology); + const fallbackTopic = primaryCluster?.title ?? "основное предложение"; + const clusterSummary = clusterTitles.slice(0, 2).join(", ") || fallbackTopic; + const primaryCta = getLandingBriefPrimaryCta(page.pageType); + const h1 = trimSeoText(`${entity}: ${fallbackTopic}`, 96); + const title = trimSeoText(`${entity} — ${fallbackTopic} | ${ontology.brandName}`, 72); const metaDescription = trimSeoText( - `${entity}: ${clusterTitles.slice(0, 2).join(", ")}. Сценарии, интеграции, безопасность и пилот внедрения NodeDC.`, + `${entity}: ${clusterSummary}. Что уже есть на сайте, кому это подходит и какой следующий шаг в ${ontology.brandName}.`, 158 ); const sections = [ @@ -2290,9 +1949,9 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing mustCoverTerms: mustUseTerms.slice(2, 6) }, { - title: "Доказательства, внедрение и CTA", - goal: "Показать интеграции, контур внедрения, безопасность, формат пилота и основной CTA.", - mustCoverTerms: [getLandingBriefPrimaryCta(page.pageType), "пилот", "внедрение"].slice(0, 3) + title: "Доказательства и следующий шаг", + goal: "Показать подтверждения, условия, ограничения и основной CTA без новых неподтвержденных обещаний.", + mustCoverTerms: [primaryCta, ...mustUseTerms.slice(0, 2)].slice(0, 3) } ]; @@ -2326,12 +1985,12 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing }, { id: "commercial_block", - title: "Коммерческий блок и CTA учтены", + title: "Следующий шаг и CTA учтены", done: page.action === "validate", reason: page.action === "validate" ? "Страница выглядит готовой к внешней проверке." - : "В брифе нужно явно добавить CTA, пилот/демо и путь к заявке." + : "В брифе нужно явно добавить следующий шаг и путь к обращению без неподтвержденных обещаний." }, { id: "market_validation", @@ -2345,11 +2004,11 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing title, metaDescription, targetIntent: primaryIntent, - primaryCta: getLandingBriefPrimaryCta(page.pageType), + primaryCta, secondaryCta: getLandingBriefSecondaryCta(page.pageType), mustUseTerms, seedPhrases: page.seedPhrases, - internalLinks: getLandingBriefInternalLinks(page.path, page.pageType), + internalLinks: getLandingBriefInternalLinks(page.path, page.pageType, ontology), sections, readinessChecklist }; @@ -2363,10 +2022,10 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing title, metaDescription, targetIntent: primaryIntent, - audience: getLandingBriefAudience(page.pageType), + audience: getLandingBriefAudience(page.pageType, ontology), primaryCta: briefDraft.primaryCta, secondaryCta: briefDraft.secondaryCta, - introAngle: `Страница должна связать ${clusterTitles.slice(0, 3).join(", ")} с понятным коммерческим сценарием NodeDC.`, + introAngle: `Страница должна связать ${clusterSummary} с понятным следующим шагом ${ontology.brandName}.`, mustUseTerms, seedPhrases: briefDraft.seedPhrases, internalLinks: briefDraft.internalLinks, @@ -2436,9 +2095,21 @@ function buildFallbackLandingPlanForBrief( function hydrateLandingBriefQualityContracts(output: SemanticAnalysisOutput): SemanticAnalysisOutput { const planByPath = new Map(output.landingPagePlan.map((page) => [page.path, page])); + const runtimeOutput = output as SemanticAnalysisOutput & { projectOntology?: SemanticAnalysisOutput["projectOntology"] }; return { ...output, + projectOntology: + runtimeOutput.projectOntology ?? + { + id: `project:${output.projectId}:ontology:legacy-output`, + versionId: null, + version: null, + source: "generic_fallback", + brandName: "Проект", + domainPackage: SEO_DOMAIN_PACKAGE_BINDING, + instance: null + }, landingPageBriefs: output.landingPageBriefs.map((brief) => { const runtimeBrief = brief as typeof brief & { qualityContract?: BriefQualityContract }; @@ -2570,7 +2241,7 @@ function buildQualitySignals(output: SemanticAnalysisBaseOutput): AnalysisSignal return signals; } -function buildAnalysisVerdict(output: SemanticAnalysisQualityOutput): AnalysisVerdict { +function buildAnalysisVerdict(output: SemanticAnalysisQualityOutput, ontology: ProjectOntology): AnalysisVerdict { const risks: AnalysisVerdict["risks"] = [ { id: "local_only", @@ -2663,7 +2334,7 @@ function buildAnalysisVerdict(output: SemanticAnalysisQualityOutput): AnalysisVe "Нет live SERP Яндекса, конкурентов, сниппетов и оценки сложности выдачи.", "Нет данных Yandex Webmaster об индексации, дублях, canonical и ошибках обхода.", "Нет Метрики/Search queries, поэтому нельзя связать семантику с фактическим трафиком и конверсией.", - "Онтология NodeDC пока задана правилами внутри модуля, а не обучена на рынке." + ontology.analysisLimitNote ], externalSignalsNeeded: [ "Yandex Wordstat: частотность, регионы, похожие запросы, минус-слова.", @@ -2844,7 +2515,7 @@ async function getScanDataAssets(scanVersionId: string) { return result.rows; } -async function buildAnalysisOutput(projectId: string): Promise { +async function buildAnalysisOutput(projectId: string): Promise<{ ontology: ProjectOntology; output: SemanticAnalysisOutput } | null> { const scan = await getLatestDoneScan(projectId); if (!scan) { @@ -2858,14 +2529,16 @@ async function buildAnalysisOutput(projectId: string): Promise Boolean(document)); const documents = [...pageDocuments, ...contentDocuments]; const coverageDocuments = documents.filter((document) => document.usedForCoverage); + const ontologyEvidence = buildProjectOntologyEvidence(projectId, scan, pages, pageDocuments, contentDocuments); + const ontology = await getProjectOntology(projectId, ontologyEvidence); const pageScope = buildPageScopeSummary(pageDocuments, contentDocuments); - const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents); - const clusters = analyzeClusters(coverageDocuments); + const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents, ontology); + const clusters = analyzeClusters(coverageDocuments, ontology); const coverageScore = getCoverageScore(clusters); const lexicalCoverageScore = getLexicalCoverageScore(clusters); const evidenceScore = getEvidenceScore(clusters); const intentCoverageScore = getIntentCoverageScore(clusters); - const commercialReadinessScore = getCommercialReadinessScore(clusters, styleProfile); + const commercialReadinessScore = getCommercialReadinessScore(clusters, styleProfile, ontology); const legacyFindings = analyzeLegacy(documents); const gaps = clusters .filter((cluster) => cluster.status !== "covered") @@ -2879,10 +2552,10 @@ async function buildAnalysisOutput(projectId: string): Promise { const matchedClusters = clusters @@ -2929,6 +2602,27 @@ async function buildAnalysisOutput(projectId: string): Promise { const document = pageDocuments.find((item) => item.id === page.page_id); @@ -2973,22 +2667,39 @@ async function buildAnalysisOutput(projectId: string): Promise( ` insert into runs (project_id, run_type, status, input, output, started_at, completed_at) @@ -3011,6 +2722,10 @@ export async function runSemanticAnalysis(projectId: string) { throw new SemanticAnalysisError("Не удалось сохранить semantic analysis run."); } + if (ontologyVersion) { + await linkProjectOntologyVersionToRun(ontologyVersion.id, row.id); + } + return mapAnalysisRun(row); } @@ -3093,9 +2808,23 @@ function markdownList(items: string[]) { return items.map((item) => `- ${item}`).join("\n"); } +function getSemanticExportTitle(analysis: SemanticAnalysisRun) { + return analysis.projectOntology.source === "generic_fallback" + ? "SEO analysis export" + : `${analysis.projectOntology.brandName} SEO analysis export`; +} + +function getSemanticExportFilePrefix(analysis: SemanticAnalysisRun) { + if (analysis.projectOntology.source === "generic_fallback") { + return "seo-analysis"; + } + + return `${normalizeSearchText(analysis.projectOntology.brandName).replace(/[^a-zа-я0-9]+/giu, "-").replace(/^-|-$/g, "") || "seo"}-seo`; +} + function buildSemanticMarkdownExport(analysis: SemanticAnalysisRun) { const lines: string[] = [ - `# NodeDC SEO export`, + `# ${getSemanticExportTitle(analysis)}`, "", `Run: ${analysis.runId}`, `Generated: ${analysis.generatedAt}`, @@ -3105,6 +2834,7 @@ function buildSemanticMarkdownExport(analysis: SemanticAnalysisRun) { `Commercial readiness: ${analysis.summary.commercialReadinessScore}%`, `Landing page plan: ${analysis.landingPagePlan.length}`, `SEO briefs: ${analysis.landingPageBriefs.length}`, + `Project ontology: ${analysis.projectOntology.brandName} (${analysis.projectOntology.source}, ${analysis.projectOntology.domainPackage.id}@${analysis.projectOntology.domainPackage.version})`, "", "## Landing page plan", "" @@ -3211,7 +2941,7 @@ export async function getSemanticAnalysisExport(projectId: string, format: Seman return null; } - const fileBaseName = `nodedc-seo-${analysis.runId.slice(0, 8)}`; + const fileBaseName = `${getSemanticExportFilePrefix(analysis)}-${analysis.runId.slice(0, 8)}`; if (format === "csv") { return { diff --git a/seo_mode/seo_mode/server/src/contextReview/seoContextReview.ts b/seo_mode/seo_mode/server/src/contextReview/seoContextReview.ts new file mode 100644 index 0000000..e3770dc --- /dev/null +++ b/seo_mode/seo_mode/server/src/contextReview/seoContextReview.ts @@ -0,0 +1,543 @@ +import { pool } from "../db/client.js"; +import { getLatestSemanticAnalysis, type SemanticAnalysisRun } from "../analysis/semanticAnalysis.js"; +import { buildSeoContextReviewTask } from "../skills/seoSkillRegistry.js"; + +type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; + +type SeoContextReviewRunRow = { + id: string; + status: RunStatus; + input: Record; + output: SeoContextReviewOutput | null; + error_message: string | null; + started_at: Date | null; + completed_at: Date | null; + created_at: Date; +}; + +type ReviewLevel = "ok" | "warning" | "problem"; +type ContextReviewConfidence = "low" | "medium" | "high"; +type SeoContextReviewProviderMode = "codex_manual" | "deterministic_fallback"; + +export type SeoContextReviewOutput = { + schemaVersion: "seo-context-review.v1"; + projectId: string; + semanticRunId: string; + scanVersionId: string; + projectOntologyVersionId: string | null; + generatedAt: string; + provider: { + mode: SeoContextReviewProviderMode; + modelRequired: true; + message: string; + }; + sourceTaskId: string; + analystReview: { + status: "approved" | "needs_changes" | "not_reviewed" | "rejected"; + reviewer: "codex" | "human" | "system"; + reviewedAt: string | null; + notes: string[]; + }; + readiness: { + evidenceRefCount: number; + pageRoleCount: number; + semanticGapCount: number; + forbiddenClaimCount: number; + normalizationHintCount: number; + needsHumanReview: boolean; + }; + siteIdentity: { + brandName: string; + description: string; + confidence: ContextReviewConfidence; + evidenceRefs: string[]; + }; + offer: { + summary: string; + confirmedClaims: Array<{ + id: string; + title: string; + evidenceRefs: string[]; + confidence: ContextReviewConfidence; + }>; + weakClaims: Array<{ + id: string; + title: string; + reason: string; + }>; + }; + audience: { + primary: string; + secondary: string[]; + confidence: ContextReviewConfidence; + reason: string; + }; + pageRoles: Array<{ + pageId: string; + title: string; + path: string | null; + scope: string; + role: "admin_or_technical" | "content_source" | "primary_landing" | "supporting_page" | "template_block"; + reason: string; + evidenceRefs: string[]; + }>; + sectionFindings: Array<{ + id: string; + title: string; + status: "covered" | "weak" | "missing"; + targetIntent: string; + evidenceLevel: "none" | "thin" | "moderate" | "strong"; + confidence: ContextReviewConfidence; + evidenceRefs: string[]; + }>; + semanticGaps: Array<{ + id: string; + title: string; + priority: "high" | "medium" | "low"; + reason: string; + suggestedReview: string; + }>; + forbiddenClaims: Array<{ + id: string; + claim: string; + reason: string; + source: "guardrail" | "semantic_gap" | "weak_evidence"; + }>; + normalizationHints: Array<{ + id: string; + clusterId: string; + title: string; + targetIntent: string; + sourcePhrases: string[]; + marketAngles: string[]; + stopPhrases: string[]; + reviewRequired: boolean; + reason: string; + }>; + risks: Array<{ + id: string; + level: ReviewLevel; + title: string; + message: string; + }>; + needsHumanReview: boolean; + summary: string; + nextActions: string[]; +}; + +export type SeoContextReviewRun = SeoContextReviewOutput & { + runId: string; + status: RunStatus; + input: Record; + startedAt: string | null; + completedAt: string | null; + errorMessage: string | null; + createdAt: string; +}; + +function toIsoDate(value: Date | null) { + return value ? value.toISOString() : null; +} + +function mapSeoContextReviewRun(row: SeoContextReviewRunRow): SeoContextReviewRun | null { + if (!row.output) { + return null; + } + + return { + runId: row.id, + status: row.status, + input: row.input, + startedAt: toIsoDate(row.started_at), + completedAt: toIsoDate(row.completed_at), + errorMessage: row.error_message, + createdAt: row.created_at.toISOString(), + ...row.output, + analystReview: row.output.analystReview ?? getDefaultAnalystReview() + }; +} + +function getDefaultAnalystReview(): SeoContextReviewOutput["analystReview"] { + return { + notes: [], + reviewedAt: null, + reviewer: "system", + status: "not_reviewed" + }; +} + +function normalizeContextReviewOutput( + projectId: string, + output: SeoContextReviewOutput, + providerMode: SeoContextReviewProviderMode, + providerMessage: string +): SeoContextReviewOutput { + if (output.schemaVersion !== "seo-context-review.v1") { + throw new Error("Context Review output schemaVersion должен быть seo-context-review.v1."); + } + + if (output.projectId !== projectId) { + throw new Error("Context Review output projectId не совпадает с проектом."); + } + + if (!output.semanticRunId || !output.scanVersionId || !output.sourceTaskId) { + throw new Error("Context Review output должен иметь semanticRunId, scanVersionId и sourceTaskId."); + } + + if (!output.siteIdentity?.description || !output.offer?.summary || !output.summary) { + throw new Error("Context Review output должен иметь siteIdentity.description, offer.summary и summary."); + } + + if (!Array.isArray(output.pageRoles) || !Array.isArray(output.sectionFindings)) { + throw new Error("Context Review output должен иметь pageRoles и sectionFindings."); + } + + if (!Array.isArray(output.semanticGaps) || !Array.isArray(output.forbiddenClaims) || !Array.isArray(output.normalizationHints)) { + throw new Error("Context Review output должен иметь semanticGaps, forbiddenClaims и normalizationHints."); + } + + return { + ...output, + generatedAt: new Date().toISOString(), + provider: { + message: providerMessage, + mode: providerMode, + modelRequired: true + }, + analystReview: output.analystReview ?? getDefaultAnalystReview(), + readiness: { + ...output.readiness, + forbiddenClaimCount: output.forbiddenClaims.length, + normalizationHintCount: output.normalizationHints.length, + pageRoleCount: output.pageRoles.length, + semanticGapCount: output.semanticGaps.length, + needsHumanReview: output.needsHumanReview + } + }; +} + +function getConfidence(score: number): ContextReviewConfidence { + if (score >= 75) { + return "high"; + } + + if (score >= 45) { + return "medium"; + } + + return "low"; +} + +function getEvidenceIds(cluster: SemanticAnalysisRun["clusters"][number]) { + return cluster.sourceRefs.slice(0, 4).map((ref) => `${cluster.id}:${ref.id}`); +} + +function getPageRole(page: SemanticAnalysisRun["pages"][number], index: number): SeoContextReviewOutput["pageRoles"][number]["role"] { + if (page.scope === "admin_page" || page.scope === "technical_page") { + return "admin_or_technical"; + } + + if (page.scope === "template_block") { + return "template_block"; + } + + if (index === 0 || page.urlPath === "/" || page.sourcePath.toLowerCase().endsWith("index.html")) { + return "primary_landing"; + } + + return "supporting_page"; +} + +function uniq(values: string[]) { + return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean))); +} + +function buildContextReviewOutput(projectId: string, analysis: SemanticAnalysisRun): SeoContextReviewOutput { + const task = buildSeoContextReviewTask(projectId, analysis); + const highPriorityClusters = analysis.clusters + .filter((cluster) => cluster.priority === "high" || cluster.status === "covered") + .slice(0, 8); + const coveredClaims = highPriorityClusters + .filter((cluster) => cluster.status === "covered" && cluster.evidence.level !== "none") + .slice(0, 6); + const weakClaims = highPriorityClusters + .filter((cluster) => cluster.status !== "covered" || cluster.evidence.level === "none" || cluster.evidence.level === "thin") + .slice(0, 8); + const selectedPages = analysis.pages.filter((page) => page.selected); + const mainPage = selectedPages.find((page) => page.urlPath === "/") ?? selectedPages[0] ?? analysis.pages[0] ?? null; + const topClusterTitles = highPriorityClusters.slice(0, 4).map((cluster) => cluster.title); + const evidenceRefCount = task.input.evidenceRefs.length; + const risks: SeoContextReviewOutput["risks"] = [ + ...analysis.qualitySignals.slice(0, 12), + ...analysis.analysisVerdict.risks.slice(0, 10).map((risk) => ({ + id: risk.id, + level: risk.level, + title: risk.title, + message: risk.message + })) + ]; + const semanticGaps = analysis.gaps.slice(0, 12).map((gap) => ({ + id: gap.clusterId, + priority: gap.priority, + reason: + gap.status === "missing" + ? "Интент почти не подтвержден текущим контентом сайта." + : "Интент есть в контенте, но раскрыт слабо и требует проверки перед market routing.", + suggestedReview: "Проверить, является ли это реальным предложением сайта или только потенциальной зоной развития.", + title: gap.title + })); + const forbiddenClaims: SeoContextReviewOutput["forbiddenClaims"] = [ + { + id: "guardrail.no_unproven_services", + claim: "Не добавлять услуги, рынки, регионы, гарантии, цены или интеграции без source evidence.", + reason: "Context Review работает как защитный слой перед normalization, keyword map и rewrite.", + source: "guardrail" + }, + { + id: "guardrail.no_business_expansion", + claim: "Не менять бизнес-вектор и не расширять рынок без отдельной user-approved expansion branch.", + reason: "Core SEO pipeline сейчас улучшает существующий сайт, а не придумывает новый бизнес.", + source: "guardrail" + }, + ...weakClaims.slice(0, 8).map((cluster) => ({ + id: `weak_evidence.${cluster.id}`, + claim: `Не утверждать полное покрытие интента "${cluster.title}" без дополнительного evidence.`, + reason: + cluster.status === "missing" + ? "Кластер отсутствует в текущем контенте." + : `Evidence level: ${cluster.evidence.level}; нужен review перед закреплением.`, + source: cluster.status === "missing" ? "semantic_gap" : "weak_evidence" + }) satisfies SeoContextReviewOutput["forbiddenClaims"][number]) + ]; + const normalizationHints = analysis.clusters + .filter((cluster) => cluster.status !== "missing" || cluster.priority === "high") + .slice(0, 18) + .map((cluster) => { + const sourcePhrases = uniq([...cluster.queryExamples, ...cluster.matchedTerms]).slice(0, 10); + const marketAngles = uniq([cluster.targetIntent, cluster.title, ...cluster.targetPages]).slice(0, 8); + const stopPhrases = + cluster.status === "missing" || cluster.evidence.level === "none" + ? [cluster.title] + : cluster.missingTerms.slice(0, 6); + + return { + id: `hint.${cluster.id}`, + clusterId: cluster.id, + title: cluster.title, + targetIntent: cluster.targetIntent, + sourcePhrases, + marketAngles, + stopPhrases, + reviewRequired: cluster.status !== "covered" || cluster.evidence.level === "none" || cluster.evidence.level === "thin", + reason: + cluster.status === "covered" + ? "Использовать как source-grounded normalization hint, но проверять рыночную формулировку через Wordstat/SERP." + : "Нужен human/model review перед отправкой фраз во внешний спрос." + }; + }); + const pageRoles = analysis.pages.slice(0, 40).map((page, index) => ({ + pageId: page.pageId, + title: page.title, + path: page.urlPath, + scope: page.scope, + role: getPageRole(page, index), + reason: + page.scope === "indexable_page" + ? "Индексируемая страница может участвовать в SEO-плане после review." + : "Не является обычной индексируемой страницей; не отправлять в rewrite/market routing без scope decision.", + evidenceRefs: page.matchedClusters.slice(0, 8).map((clusterId) => `cluster:${clusterId}`) + })); + const sectionFindings = analysis.clusters.slice(0, 24).map((cluster) => ({ + id: cluster.id, + title: cluster.title, + status: cluster.status, + targetIntent: cluster.targetIntent, + evidenceLevel: cluster.evidence.level, + confidence: getConfidence(cluster.evidence.score), + evidenceRefs: getEvidenceIds(cluster) + })); + const needsHumanReview = + analysis.analysisVerdict.confidence !== "high" || + weakClaims.length > 0 || + risks.some((risk) => risk.level === "problem") || + forbiddenClaims.length > 2 || + normalizationHints.some((hint) => hint.reviewRequired); + + return { + schemaVersion: "seo-context-review.v1", + projectId, + semanticRunId: analysis.runId, + scanVersionId: analysis.scanVersionId, + projectOntologyVersionId: analysis.projectOntology.versionId, + generatedAt: new Date().toISOString(), + provider: { + mode: "deterministic_fallback", + modelRequired: true, + message: + "Context Review сохранен deterministic fallback-ом. Следующий слой должен заменить semantic judgement ModelProvider-ом, но сохранить этот JSON contract." + }, + sourceTaskId: task.taskId, + analystReview: getDefaultAnalystReview(), + readiness: { + evidenceRefCount, + pageRoleCount: pageRoles.length, + semanticGapCount: semanticGaps.length, + forbiddenClaimCount: forbiddenClaims.length, + normalizationHintCount: normalizationHints.length, + needsHumanReview + }, + siteIdentity: { + brandName: analysis.projectOntology.brandName, + description: + topClusterTitles.length > 0 + ? `${analysis.projectOntology.brandName}: ${topClusterTitles.join(", ")}.` + : `${analysis.projectOntology.brandName}: контекст сайта требует дополнительного review.`, + confidence: getConfidence(analysis.summary.evidenceScore), + evidenceRefs: mainPage ? [`page:${mainPage.pageId}`] : [] + }, + offer: { + summary: + coveredClaims.length > 0 + ? `Подтвержденные направления: ${coveredClaims.map((cluster) => cluster.title).join(", ")}.` + : "Fallback не нашел достаточно сильных подтвержденных направлений; нужен model/human review.", + confirmedClaims: coveredClaims.map((cluster) => ({ + id: cluster.id, + title: cluster.title, + evidenceRefs: getEvidenceIds(cluster), + confidence: getConfidence(cluster.evidence.score) + })), + weakClaims: weakClaims.map((cluster) => ({ + id: cluster.id, + title: cluster.title, + reason: `Status ${cluster.status}, evidence ${cluster.evidence.level}; не использовать как уверенный claim без review.` + })) + }, + audience: { + primary: analysis.landingPageBriefs[0]?.audience ?? "Аудитория требует уточнения по context review.", + secondary: uniq(analysis.landingPageBriefs.slice(1, 6).map((brief) => brief.audience)), + confidence: analysis.landingPageBriefs.length > 0 ? "medium" : "low", + reason: "Fallback берёт аудиторию из SEO briefs/semantic plan; model review должен проверить это по реальному контенту." + }, + pageRoles, + sectionFindings, + semanticGaps, + forbiddenClaims, + normalizationHints, + risks, + needsHumanReview, + summary: needsHumanReview + ? "Context Review сохранен как draft evidence: есть неоднозначности, которые нельзя отправлять в Wordstat/rewrite без review." + : "Context Review сохранен как draft evidence: критичных semantic blockers fallback не нашел.", + nextActions: [ + "Передать `seo.context_review` task в ModelProvider и сравнить с fallback output.", + "Использовать forbiddenClaims и normalizationHints как вход `seo.semantic_normalization` перед Wordstat.", + "Не запускать rewrite/apply до approved page/field plan." + ] + }; +} + +export async function getLatestSeoContextReview(projectId: string): Promise { + 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_context_review' + order by created_at desc + limit 1; + `, + [projectId] + ); + + return result.rows[0] ? mapSeoContextReviewRun(result.rows[0]) : null; +} + +export async function getLatestAlignedSeoContextReview( + projectId: string, + semanticRunId: string +): Promise { + 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_context_review' + and output->>'semanticRunId' = $2 + order by created_at desc + limit 1; + `, + [projectId, semanticRunId] + ); + + return result.rows[0] ? mapSeoContextReviewRun(result.rows[0]) : null; +} + +export async function runSeoContextReviewDraft(projectId: string): Promise { + const analysis = await getLatestSemanticAnalysis(projectId); + + if (!analysis) { + return null; + } + + const task = buildSeoContextReviewTask(projectId, analysis); + const output = buildContextReviewOutput(projectId, analysis); + const result = await pool.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({ + taskId: task.taskId, + taskType: task.taskType, + schemaVersion: task.schemaVersion, + semanticRunId: analysis.runId, + scanVersionId: analysis.scanVersionId, + projectOntologyVersionId: analysis.projectOntology.versionId, + providerMode: "deterministic_fallback" + }), + JSON.stringify(output) + ] + ); + + return result.rows[0] ? mapSeoContextReviewRun(result.rows[0]) : null; +} + +export async function saveSeoContextReviewManual( + projectId: string, + output: SeoContextReviewOutput +): Promise { + const normalizedOutput = normalizeContextReviewOutput( + projectId, + output, + "codex_manual", + "Codex manual dev-loop: текущий Codex-сеанс выполнил seo.context_review task по правилам, без внешнего provider adapter." + ); + const result = await pool.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({ + providerMode: "codex_manual", + scanVersionId: normalizedOutput.scanVersionId, + schemaVersion: "seo-context-review-manual-input.v1", + semanticRunId: normalizedOutput.semanticRunId, + sourceTaskId: normalizedOutput.sourceTaskId, + taskType: "seo.context_review" + }), + JSON.stringify(normalizedOutput) + ] + ); + const run = result.rows[0] ? mapSeoContextReviewRun(result.rows[0]) : null; + + if (!run) { + throw new Error("Не удалось сохранить Codex manual Context Review."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts b/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts new file mode 100644 index 0000000..767b965 --- /dev/null +++ b/seo_mode/seo_mode/server/src/evidence/serpInterpretation.ts @@ -0,0 +1,668 @@ +import { pool } from "../db/client.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 SerpInterpretationState = "not_ready" | "ready"; +type SerpInterpretationConfidence = "high" | "low" | "medium"; +type SerpTargetFit = "mismatch" | "partial_fit" | "strong_fit" | "unknown"; +type SerpRecommendedRole = "article_backlog" | "core_landing" | "human_review" | "support_section"; + +type SerpInterpretationRunRow = { + id: string; + status: RunStatus; + input: Record; + output: SerpInterpretationContract | null; + error_message: string | null; + started_at: Date | null; + completed_at: Date | null; + created_at: Date; +}; + +export type SerpInterpretationContract = { + schemaVersion: "seo-serp-interpretation.v1"; + projectId: string; + semanticRunId: string | null; + runId: string | null; + yandexEvidenceRunId: string | null; + generatedAt: string; + state: SerpInterpretationState; + provider: { + mode: SerpInterpretationProviderMode; + modelRequired: true; + message: string; + }; + sourceTaskId: string | null; + analystReview: { + status: "approved" | "needs_changes" | "not_reviewed" | "rejected"; + reviewer: "codex" | "human" | "system"; + reviewedAt: string | null; + notes: string[]; + }; + readiness: { + competitorGapCount: number; + interpretedPhraseCount: number; + mismatchCount: number; + nextEvidenceCount: number; + phraseCount: number; + riskCount: number; + }; + yandexEvidence: { + checkedPhraseCount: number; + competitorDomainCount: number; + runId: string | null; + serpDocCount: number; + totalDemand: number; + }; + phraseInterpretations: Array<{ + phrase: string; + targetPath: string | null; + intent: YandexEvidenceContract["phrases"][number]["interpretation"]["serpIntent"]; + targetFit: SerpTargetFit; + recommendedRole: SerpRecommendedRole; + totalDemand: number | null; + demandTier: YandexEvidenceContract["phrases"][number]["interpretation"]["demandTier"]; + seasonality: YandexEvidenceContract["phrases"][number]["interpretation"]["seasonality"]; + difficultyScore: number; + confidence: SerpInterpretationConfidence; + opportunity: string; + risk: string | null; + evidenceRefs: string[]; + nextEvidenceNeeded: string[]; + topDomains: string[]; + }>; + competitorGaps: Array<{ + count: number; + domain: string; + observedAngle: string; + sampleTitle: string; + sampleUrl: string; + strategyImplication: string; + evidenceRefs: string[]; + }>; + strategySignals: Array<{ + id: string; + title: string; + priority: "high" | "low" | "medium"; + confidence: SerpInterpretationConfidence; + message: string; + evidenceRefs: string[]; + }>; + risks: Array<{ + id: string; + level: "critical" | "high" | "medium"; + title: string; + message: string; + mitigation: string; + evidenceRefs: string[]; + }>; + summary: string; + nextActions: string[]; +}; + +export type SerpInterpretationRun = SerpInterpretationContract & { + runId: string; + status: RunStatus; + input: Record; + startedAt: string | null; + completedAt: string | null; + errorMessage: string | null; + createdAt: string; +}; + +function toIsoDate(value: Date | null) { + return value ? value.toISOString() : null; +} + +function getDefaultAnalystReview(): SerpInterpretationContract["analystReview"] { + return { + notes: [], + reviewedAt: null, + reviewer: "system", + status: "not_reviewed" + }; +} + +function mapSerpInterpretationRun(row: SerpInterpretationRunRow): SerpInterpretationRun | null { + if (!row.output) { + return null; + } + + return { + ...row.output, + runId: row.id, + status: row.status, + input: row.input, + startedAt: toIsoDate(row.started_at), + completedAt: toIsoDate(row.completed_at), + errorMessage: row.error_message, + createdAt: row.created_at.toISOString(), + analystReview: row.output.analystReview ?? getDefaultAnalystReview() + }; +} + +export function getEmptySerpInterpretationContract(projectId: string): SerpInterpretationContract { + return { + analystReview: getDefaultAnalystReview(), + competitorGaps: [], + generatedAt: new Date().toISOString(), + nextActions: ["Сначала собрать Yandex evidence и SERP docs, затем запустить SERP interpretation."], + phraseInterpretations: [], + projectId, + runId: null, + provider: { + message: "SERP interpretation не готов: нет сохранённого yandex-evidence.v1 с SERP docs.", + mode: "deterministic_fallback", + modelRequired: true + }, + readiness: { + competitorGapCount: 0, + interpretedPhraseCount: 0, + mismatchCount: 0, + nextEvidenceCount: 0, + phraseCount: 0, + riskCount: 0 + }, + risks: [], + schemaVersion: "seo-serp-interpretation.v1", + semanticRunId: null, + sourceTaskId: null, + state: "not_ready", + strategySignals: [], + summary: "SERP interpretation ещё не собран.", + yandexEvidence: { + checkedPhraseCount: 0, + competitorDomainCount: 0, + runId: null, + serpDocCount: 0, + totalDemand: 0 + }, + yandexEvidenceRunId: null + }; +} + +function normalizeOutput( + projectId: string, + output: SerpInterpretationContract, + providerMode: SerpInterpretationProviderMode, + providerMessage: string +): SerpInterpretationContract { + if (output.schemaVersion !== "seo-serp-interpretation.v1") { + throw new Error("SERP interpretation output schemaVersion должен быть seo-serp-interpretation.v1."); + } + + if (output.projectId !== projectId) { + throw new Error("SERP interpretation output projectId не совпадает с проектом."); + } + + if (!output.yandexEvidenceRunId || !output.sourceTaskId) { + throw new Error("SERP interpretation output должен иметь yandexEvidenceRunId и sourceTaskId."); + } + + if (!Array.isArray(output.phraseInterpretations) || !Array.isArray(output.competitorGaps)) { + throw new Error("SERP interpretation output должен иметь phraseInterpretations и competitorGaps."); + } + + if (!Array.isArray(output.strategySignals) || !Array.isArray(output.risks)) { + throw new Error("SERP interpretation output должен иметь strategySignals и risks."); + } + + return { + ...output, + analystReview: output.analystReview ?? getDefaultAnalystReview(), + generatedAt: new Date().toISOString(), + provider: { + message: providerMessage, + mode: providerMode, + modelRequired: true + }, + readiness: { + competitorGapCount: output.competitorGaps.length, + interpretedPhraseCount: output.phraseInterpretations.length, + mismatchCount: output.phraseInterpretations.filter((phrase) => phrase.targetFit === "mismatch").length, + nextEvidenceCount: output.phraseInterpretations.reduce( + (sum, phrase) => sum + phrase.nextEvidenceNeeded.length, + output.strategySignals.length + ), + phraseCount: output.phraseInterpretations.length, + riskCount: output.risks.length + }, + state: output.phraseInterpretations.length > 0 ? "ready" : "not_ready" + }; +} + +function getPhraseEvidenceRefs(phrase: YandexEvidenceContract["phrases"][number], index: number) { + const refs = [`yandex-evidence.phrases:${index}`]; + + if (phrase.wordstat.totalCount !== null) refs.push(`wordstat.top:${phrase.phrase}`); + if (phrase.wordstat.dynamics.length > 0) refs.push(`wordstat.dynamics:${phrase.phrase}`); + if (phrase.wordstat.regions.length > 0) refs.push(`wordstat.regions:${phrase.phrase}`); + if (phrase.serp.docs.length > 0) refs.push(`serp.docs:${phrase.phrase}`); + + return refs; +} + +function getTargetFit(phrase: YandexEvidenceContract["phrases"][number]): SerpTargetFit { + if (!phrase.targetPath) { + return phrase.interpretation.serpIntent === "commercial" ? "partial_fit" : "unknown"; + } + + if (phrase.interpretation.serpIntent === "informational") { + return "mismatch"; + } + + if (phrase.interpretation.serpIntent === "commercial") { + return "strong_fit"; + } + + if (phrase.interpretation.serpIntent === "mixed") { + return "partial_fit"; + } + + return "unknown"; +} + +function getRecommendedRole( + phrase: YandexEvidenceContract["phrases"][number], + targetFit: SerpTargetFit +): SerpRecommendedRole { + if (targetFit === "mismatch" || phrase.interpretation.serpIntent === "informational") { + return "article_backlog"; + } + + if (targetFit === "strong_fit") { + return "core_landing"; + } + + if (targetFit === "partial_fit") { + return "support_section"; + } + + return "human_review"; +} + +function getConfidence(phrase: YandexEvidenceContract["phrases"][number], targetFit: SerpTargetFit): SerpInterpretationConfidence { + let score = 20; + + if (phrase.status === "ok") score += 20; + if ((phrase.wordstat.totalCount ?? 0) > 0) score += 20; + if (phrase.serp.docs.length >= 5) score += 20; + if (phrase.interpretation.serpIntent !== "unknown") score += 10; + if (targetFit === "strong_fit" || targetFit === "mismatch") score += 10; + + if (score >= 75) return "high"; + if (score >= 45) return "medium"; + return "low"; +} + +function getPhraseOpportunity( + phrase: YandexEvidenceContract["phrases"][number], + targetFit: SerpTargetFit, + recommendedRole: SerpRecommendedRole +) { + if (targetFit === "mismatch") { + return "Выдача требует отдельного информационного слоя: не вести фразу как прямой коммерческий rewrite."; + } + + if (recommendedRole === "core_landing") { + return "Есть совпадение спроса, SERP intent и целевой страницы: можно рассматривать как core strategy signal."; + } + + if (recommendedRole === "support_section") { + return "Есть внешний спрос, но интент смешанный или посадочная неочевидна: держать как supporting opportunity."; + } + + return phrase.interpretation.opportunity; +} + +function getObservedAngle(domain: YandexEvidenceContract["competitorDomains"][number]) { + const text = `${domain.domain} ${domain.sampleTitle}`.toLocaleLowerCase("ru-RU"); + + if (/курс|обуч|университет|академ|школ/.test(text)) { + return "education/content angle"; + } + + if (/crm|erp|bpm|битрикс|bitrix|платформ|сервис|продукт/.test(text)) { + return "product/platform angle"; + } + + if (/банк|финанс|предприним|малый бизнес|бизнес/.test(text)) { + return "business operations angle"; + } + + if (/рейтинг|топ|обзор|сравн/.test(text)) { + return "comparison/marketplace angle"; + } + + return "SERP competitor angle"; +} + +function buildPhraseInterpretations(evidence: YandexEvidenceContract): SerpInterpretationContract["phraseInterpretations"] { + return evidence.phrases.map((phrase, index) => { + const targetFit = getTargetFit(phrase); + const recommendedRole = getRecommendedRole(phrase, targetFit); + const confidence = getConfidence(phrase, targetFit); + const nextEvidenceNeeded = [ + ...phrase.interpretation.nextDataNeeded.filter((item) => item !== "model interpretation against site context"), + ...(targetFit === "mismatch" ? ["human decision: article/support/landing"] : []), + ...(phrase.targetPath ? [] : ["target page binding"]) + ]; + + return { + confidence, + demandTier: phrase.interpretation.demandTier, + difficultyScore: phrase.interpretation.difficultyScore, + evidenceRefs: getPhraseEvidenceRefs(phrase, index), + intent: phrase.interpretation.serpIntent, + nextEvidenceNeeded, + opportunity: getPhraseOpportunity(phrase, targetFit, recommendedRole), + phrase: phrase.phrase, + recommendedRole, + risk: + targetFit === "mismatch" + ? "SERP intent не совпадает с коммерческой посадочной; нужна отдельная контентная или support-логика." + : phrase.interpretation.risk, + seasonality: phrase.interpretation.seasonality, + targetFit, + targetPath: phrase.targetPath, + topDomains: phrase.serp.topDomains.slice(0, 6), + totalDemand: phrase.wordstat.totalCount + }; + }); +} + +function buildCompetitorGaps(evidence: YandexEvidenceContract): SerpInterpretationContract["competitorGaps"] { + return evidence.competitorDomains.slice(0, 10).map((domain, index) => { + const observedAngle = getObservedAngle(domain); + + return { + count: domain.count, + domain: domain.domain, + evidenceRefs: [`yandex-evidence.competitorDomains:${index}`], + observedAngle, + sampleTitle: domain.sampleTitle, + sampleUrl: domain.sampleUrl, + strategyImplication: + observedAngle === "education/content angle" + ? "Проверить, нужен ли информационный слой или статья до коммерческого лендинга." + : observedAngle === "comparison/marketplace angle" + ? "Проверить сравнение, альтернативы и критерии выбора как отдельный proof layer." + : "Сравнить оффер, сниппеты и page type конкурента с текущей посадочной." + }; + }); +} + +function buildStrategySignals( + phrases: SerpInterpretationContract["phraseInterpretations"], + competitorGaps: SerpInterpretationContract["competitorGaps"] +): SerpInterpretationContract["strategySignals"] { + const signals: SerpInterpretationContract["strategySignals"] = []; + const strongFit = phrases.filter((phrase) => phrase.targetFit === "strong_fit"); + const mismatch = phrases.filter((phrase) => phrase.targetFit === "mismatch"); + const highDemand = phrases.filter((phrase) => (phrase.totalDemand ?? 0) >= 1000); + + if (strongFit.length > 0) { + signals.push({ + confidence: "high", + evidenceRefs: strongFit.flatMap((phrase) => phrase.evidenceRefs).slice(0, 8), + id: "signal:core_serp_fit", + message: `${strongFit.length} фраз имеют внешний спрос, SERP docs и понятный fit с посадочной.`, + priority: "high", + title: "Есть подтверждённый SERP fit" + }); + } + + if (highDemand.length > 0) { + signals.push({ + confidence: "medium", + evidenceRefs: highDemand.flatMap((phrase) => phrase.evidenceRefs).slice(0, 8), + id: "signal:visible_demand", + message: `${highDemand.length} фраз имеют спрос >= 1000; их нельзя прятать в сырой таблице ключей.`, + priority: "high", + title: "Сильный спрос требует отдельной стратегии" + }); + } + + if (mismatch.length > 0) { + signals.push({ + confidence: "medium", + evidenceRefs: mismatch.flatMap((phrase) => phrase.evidenceRefs).slice(0, 8), + id: "signal:intent_mismatch", + message: `${mismatch.length} фраз требуют разделить коммерческий лендинг и информационный/support intent.`, + priority: "medium", + title: "Есть mismatch интента" + }); + } + + if (competitorGaps.length > 0) { + signals.push({ + confidence: "medium", + evidenceRefs: competitorGaps.flatMap((gap) => gap.evidenceRefs).slice(0, 8), + id: "signal:competitor_angles", + message: `Найдено ${competitorGaps.length} конкурентных доменов/углов, которые нужно сравнить с текущим оффером.`, + priority: "medium", + title: "Появились конкурентные углы" + }); + } + + return signals; +} + +function buildRisks( + phrases: SerpInterpretationContract["phraseInterpretations"], + competitorGaps: SerpInterpretationContract["competitorGaps"] +): SerpInterpretationContract["risks"] { + const risks: SerpInterpretationContract["risks"] = []; + const mismatch = phrases.filter((phrase) => phrase.targetFit === "mismatch"); + const noTarget = phrases.filter((phrase) => !phrase.targetPath && (phrase.totalDemand ?? 0) > 0); + const concentratedCompetitors = competitorGaps.filter((gap) => gap.count >= 4); + const volatile = phrases.filter((phrase) => phrase.seasonality === "volatile"); + + if (mismatch.length > 0) { + risks.push({ + evidenceRefs: mismatch.flatMap((phrase) => phrase.evidenceRefs).slice(0, 8), + id: "serp-risk:intent_mismatch", + level: "high", + message: `${mismatch.length} фраз выглядят информационными при наличии targetPath.`, + mitigation: "Разделить article/support/landing решения до keyword map approval.", + title: "SERP intent не совпадает с посадочной" + }); + } + + if (noTarget.length > 0) { + risks.push({ + evidenceRefs: noTarget.flatMap((phrase) => phrase.evidenceRefs).slice(0, 8), + id: "serp-risk:no_target_binding", + level: "medium", + message: `${noTarget.length} фраз имеют спрос, но не имеют целевой страницы.`, + mitigation: "Не считать это задачей rewrite; сначала принять page/section strategy decision.", + title: "Спрос не привязан к странице" + }); + } + + if (concentratedCompetitors.length > 0) { + risks.push({ + evidenceRefs: concentratedCompetitors.flatMap((gap) => gap.evidenceRefs).slice(0, 8), + id: "serp-risk:competitive_concentration", + level: "medium", + message: "Часть доменов повторяется по нескольким фразам; SERP может быть занят сильными вертикальными игроками.", + mitigation: "Сравнить page type, сниппеты, доказательства и оффер перед выбором strategy option.", + title: "Конкурентная выдача может быть плотной" + }); + } + + if (volatile.length > 0) { + risks.push({ + evidenceRefs: volatile.flatMap((phrase) => phrase.evidenceRefs).slice(0, 8), + id: "serp-risk:volatile_demand", + level: "medium", + message: `${volatile.length} фраз имеют волатильную динамику спроса.`, + mitigation: "Проверить сезонность и не обещать стабильный эффект без динамики/Метрики.", + title: "Спрос нестабилен" + }); + } + + return risks; +} + +function buildNextActions(contract: Omit) { + const actions: string[] = []; + + if (contract.state !== "ready") { + return ["Сначала собрать Yandex evidence с SERP docs."]; + } + + if (contract.readiness.mismatchCount > 0) { + actions.push("Разобрать mismatch фразы: article backlog, support section или отдельная посадочная."); + } + + if (contract.competitorGaps.length > 0) { + actions.push("Показать competitor gap map в стратегии: домен, угол, page type, implication."); + } + + actions.push("Вмержить SERP interpretation в seo-strategy.v1 как evidence, не как rewrite/apply команду."); + + return actions; +} + +function buildSerpInterpretationOutput(projectId: string, evidence: YandexEvidenceContract): SerpInterpretationContract | null { + const task = buildSerpInterpretationModelTask(projectId, evidence); + + if (!task) { + return null; + } + + const phraseInterpretations = buildPhraseInterpretations(evidence); + const competitorGaps = buildCompetitorGaps(evidence); + const strategySignals = buildStrategySignals(phraseInterpretations, competitorGaps); + const risks = buildRisks(phraseInterpretations, competitorGaps); + const contractWithoutActions: Omit = { + analystReview: getDefaultAnalystReview(), + competitorGaps, + generatedAt: new Date().toISOString(), + phraseInterpretations, + projectId, + provider: { + message: + "SERP interpretation сохранён deterministic fallback-ом. Следующий слой должен заменить judgement ModelProvider-ом, но сохранить этот JSON contract.", + mode: "deterministic_fallback", + modelRequired: true + }, + readiness: { + competitorGapCount: competitorGaps.length, + interpretedPhraseCount: phraseInterpretations.length, + mismatchCount: phraseInterpretations.filter((phrase) => phrase.targetFit === "mismatch").length, + nextEvidenceCount: phraseInterpretations.reduce((sum, phrase) => sum + phrase.nextEvidenceNeeded.length, strategySignals.length), + phraseCount: phraseInterpretations.length, + riskCount: risks.length + }, + risks, + runId: null, + schemaVersion: "seo-serp-interpretation.v1", + semanticRunId: evidence.semanticRunId, + sourceTaskId: task.taskId, + state: "ready", + strategySignals, + summary: + risks.length > 0 + ? `SERP interpretation: ${phraseInterpretations.length} фраз, ${competitorGaps.length} competitor gaps, ${risks.length} рисков до approval.` + : `SERP interpretation: ${phraseInterpretations.length} фраз и ${competitorGaps.length} competitor gaps без критичных рисков fallback.`, + yandexEvidence: { + checkedPhraseCount: evidence.summary.checkedPhraseCount, + competitorDomainCount: evidence.summary.competitorDomainCount, + runId: evidence.runId, + serpDocCount: evidence.summary.serpDocCount, + totalDemand: evidence.summary.totalDemand + }, + yandexEvidenceRunId: evidence.runId + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} + +export async function getLatestSerpInterpretationContract(projectId: string): Promise { + 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_serp_interpretation' + order by created_at desc + limit 1; + `, + [projectId] + ); + const run = result.rows[0] ? mapSerpInterpretationRun(result.rows[0]) : null; + + return run ?? getEmptySerpInterpretationContract(projectId); +} + +export async function runSerpInterpretationDraft(projectId: string): Promise { + const evidence = await getLatestYandexEvidenceContract(projectId); + const output = buildSerpInterpretationOutput(projectId, evidence); + + if (!output) { + return null; + } + + 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: "deterministic_fallback", + schemaVersion: "seo-serp-interpretation-run-input.v1", + sourceTaskId: output.sourceTaskId, + taskType: "seo.serp_interpretation", + yandexEvidenceRunId: output.yandexEvidenceRunId + }), + JSON.stringify(output) + ] + ); + + return result.rows[0] ? mapSerpInterpretationRun(result.rows[0]) : null; +} + +export async function saveSerpInterpretationManual( + projectId: string, + output: SerpInterpretationContract +): Promise { + const normalizedOutput = normalizeOutput( + projectId, + output, + "codex_manual", + "Codex manual dev-loop: текущий Codex-сеанс выполнил seo.serp_interpretation task по правилам, без внешнего provider adapter." + ); + 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_manual", + schemaVersion: "seo-serp-interpretation-manual-input.v1", + sourceTaskId: normalizedOutput.sourceTaskId, + taskType: "seo.serp_interpretation", + yandexEvidenceRunId: normalizedOutput.yandexEvidenceRunId + }), + JSON.stringify(normalizedOutput) + ] + ); + const run = result.rows[0] ? mapSerpInterpretationRun(result.rows[0]) : null; + + if (!run) { + throw new Error("Не удалось сохранить Codex manual 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 new file mode 100644 index 0000000..fe5dace --- /dev/null +++ b/seo_mode/seo_mode/server/src/evidence/serpInterpretationTask.ts @@ -0,0 +1,203 @@ +import type { YandexEvidenceContract } from "./yandexEvidence.js"; + +export type SerpInterpretationModelTaskContract = { + taskType: "seo.serp_interpretation"; + schemaVersion: "seo-serp-interpretation-task.v1"; + taskId: string; + projectId: string; + semanticRunId: string | null; + yandexEvidenceRunId: string; + providerMode: "model_provider_contract"; + activeSkillId: "seo.serp_interpretation"; + input: { + yandexEvidence: { + schemaVersion: "yandex-evidence.v1"; + runId: string; + generatedAt: string; + checkedPhraseCount: number; + totalDemand: number; + serpDocCount: number; + competitorDomainCount: number; + topDomains: Array<{ count: number; domain: string }>; + }; + phrases: Array<{ + phrase: string; + targetPath: string | null; + priority: "high" | "low" | "medium"; + source: "keyword_map" | "market_seed" | "top_demand"; + totalDemand: number | null; + demandTier: "high" | "low" | "mid" | "no_signal"; + seasonality: "falling" | "insufficient" | "rising" | "stable" | "volatile"; + deterministicSerpIntent: "commercial" | "informational" | "mixed" | "unknown"; + difficultyScore: number; + topRequests: Array<{ count: number | null; phrase: string }>; + associations: Array<{ count: number | null; phrase: string }>; + regions: Array<{ affinityIndex: number | null; count: number | null; region: string; share: number | null }>; + serpDocs: Array<{ + domain: string; + title: string; + url: string; + snippet: string; + pageType: "article" | "docs" | "home" | "landing" | "marketplace" | "product" | "unknown"; + }>; + evidenceRefs: string[]; + deterministicRisk: string | null; + deterministicOpportunity: string; + }>; + competitors: Array<{ + count: number; + domain: string; + sampleTitle: string; + sampleUrl: string; + }>; + }; + allowedActions: Array< + | "classify_serp_intent" + | "compare_site_target_to_serp" + | "identify_competitor_angles" + | "flag_intent_mismatch" + | "rank_opportunities" + | "request_more_evidence" + | "preserve_evidence_refs" + >; + outputSchema: { + schemaVersion: "seo-serp-interpretation.v1"; + requiredTopLevelKeys: string[]; + phraseRequiredKeys: string[]; + competitorRequiredKeys: string[]; + riskRequiredKeys: string[]; + }; + evidencePolicy: { + doNotInventDemand: boolean; + requireEvidenceRefs: boolean; + keepExpansionAsProposal: boolean; + noRewriteOrApply: boolean; + }; + stopConditions: string[]; +}; + +function buildPhraseEvidenceRefs(phrase: YandexEvidenceContract["phrases"][number], index: number) { + const refs = [`yandex-evidence.phrases:${index}`]; + + if (phrase.wordstat.totalCount !== null) { + refs.push(`wordstat.top:${phrase.phrase}`); + } + + if (phrase.wordstat.dynamics.length > 0) { + refs.push(`wordstat.dynamics:${phrase.phrase}`); + } + + if (phrase.wordstat.regions.length > 0) { + refs.push(`wordstat.regions:${phrase.phrase}`); + } + + if (phrase.serp.docs.length > 0) { + refs.push(`serp.docs:${phrase.phrase}`); + } + + return refs; +} + +export function buildSerpInterpretationModelTask( + projectId: string, + evidence: YandexEvidenceContract +): SerpInterpretationModelTaskContract | null { + if (evidence.state !== "ready" || !evidence.runId || evidence.summary.serpDocCount === 0) { + return null; + } + + return { + activeSkillId: "seo.serp_interpretation", + allowedActions: [ + "classify_serp_intent", + "compare_site_target_to_serp", + "identify_competitor_angles", + "flag_intent_mismatch", + "rank_opportunities", + "request_more_evidence", + "preserve_evidence_refs" + ], + evidencePolicy: { + doNotInventDemand: true, + keepExpansionAsProposal: true, + noRewriteOrApply: true, + requireEvidenceRefs: true + }, + input: { + competitors: evidence.competitorDomains.slice(0, 12), + phrases: evidence.phrases.slice(0, 8).map((phrase, index) => ({ + associations: phrase.wordstat.associations.slice(0, 6), + demandTier: phrase.interpretation.demandTier, + deterministicOpportunity: phrase.interpretation.opportunity, + deterministicRisk: phrase.interpretation.risk, + deterministicSerpIntent: phrase.interpretation.serpIntent, + difficultyScore: phrase.interpretation.difficultyScore, + evidenceRefs: buildPhraseEvidenceRefs(phrase, index), + phrase: phrase.phrase, + priority: phrase.priority, + regions: phrase.wordstat.regions.slice(0, 5), + seasonality: phrase.interpretation.seasonality, + serpDocs: phrase.serp.docs.slice(0, 5).map((doc) => ({ + domain: doc.domain, + pageType: doc.pageType, + snippet: doc.snippet, + title: doc.title, + url: doc.url + })), + source: phrase.source, + targetPath: phrase.targetPath, + topRequests: phrase.wordstat.topRequests.slice(0, 6), + totalDemand: phrase.wordstat.totalCount + })), + yandexEvidence: { + checkedPhraseCount: evidence.summary.checkedPhraseCount, + competitorDomainCount: evidence.summary.competitorDomainCount, + generatedAt: evidence.generatedAt, + runId: evidence.runId, + schemaVersion: "yandex-evidence.v1", + serpDocCount: evidence.summary.serpDocCount, + topDomains: evidence.summary.topDomains, + totalDemand: evidence.summary.totalDemand + } + }, + outputSchema: { + competitorRequiredKeys: ["domain", "observedAngle", "evidenceRefs", "strategyImplication"], + phraseRequiredKeys: [ + "phrase", + "intent", + "targetFit", + "opportunity", + "risk", + "confidence", + "evidenceRefs", + "nextEvidenceNeeded" + ], + requiredTopLevelKeys: [ + "schemaVersion", + "projectId", + "sourceYandexEvidenceRunId", + "phraseInterpretations", + "competitorGaps", + "strategySignals", + "risks", + "nextActions", + "summary" + ], + riskRequiredKeys: ["id", "level", "title", "message", "mitigation", "evidenceRefs"], + schemaVersion: "seo-serp-interpretation.v1" + }, + projectId, + providerMode: "model_provider_contract", + schemaVersion: "seo-serp-interpretation-task.v1", + semanticRunId: evidence.semanticRunId, + stopConditions: [ + "Нет yandex-evidence.v1 в состоянии ready.", + "SERP docs не собраны или пусты: модель не должна угадывать выдачу.", + "Нужно принять решение о rewrite/apply: этот task только аналитический и read-only.", + "Фраза требует business expansion за пределами текущего сайта: вернуть proposal/risk, не менять стратегию автоматически." + ], + taskId: `${evidence.runId}:serp-interpretation:v1`, + 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 new file mode 100644 index 0000000..181d8a2 --- /dev/null +++ b/seo_mode/seo_mode/server/src/evidence/yandexEvidence.ts @@ -0,0 +1,854 @@ +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"; + +type YandexEvidenceState = "failed" | "not_collected" | "not_configured" | "ready"; +type YandexEvidencePriority = "high" | "low" | "medium"; +type YandexEvidencePhraseSource = "keyword_map" | "market_seed" | "top_demand"; +type YandexEvidenceDemandTier = "high" | "low" | "mid" | "no_signal"; +type YandexEvidenceSeasonality = "falling" | "insufficient" | "rising" | "stable" | "volatile"; +type YandexEvidenceSerpIntent = "commercial" | "informational" | "mixed" | "unknown"; + +type YandexEvidenceRunRow = { + id: string; + output: unknown; + created_at: Date; +}; + +type EvidencePhraseInput = { + phrase: string; + targetPath: string | null; + priority: YandexEvidencePriority; + source: YandexEvidencePhraseSource; + reason: string; +}; + +export type YandexEvidenceContract = { + schemaVersion: "yandex-evidence.v1"; + projectId: string; + semanticRunId: string | null; + runId: string | null; + generatedAt: string; + state: YandexEvidenceState; + provider: { + configured: boolean; + authType: "api_key" | "iam_token" | null; + folderId: string | null; + mode: string; + }; + input: { + phraseLimit: number; + phrases: EvidencePhraseInput[]; + }; + summary: { + checkedPhraseCount: number; + okPhraseCount: number; + totalDemand: number; + risingPhraseCount: number; + serpDocCount: number; + competitorDomainCount: number; + topDomains: Array<{ + domain: string; + count: number; + }>; + }; + capabilities: Array<{ + id: "serp" | "wordstat_dynamics" | "wordstat_regions" | "wordstat_top"; + ok: boolean; + summary: string; + }>; + phrases: Array<{ + phrase: string; + targetPath: string | null; + priority: YandexEvidencePriority; + source: YandexEvidencePhraseSource; + status: "failed" | "ok" | "partial"; + errors: string[]; + wordstat: { + totalCount: number | null; + topRequests: Array<{ phrase: string; count: number | null }>; + associations: Array<{ phrase: string; count: number | null }>; + regions: Array<{ affinityIndex: number | null; count: number | null; region: string; share: number | null }>; + dynamics: Array<{ count: number | null; date: string; share: number | null }>; + }; + serp: { + found: Record; + docs: Array<{ + domain: string; + url: string; + title: string; + snippet: string; + mime: string; + lang: string; + pageType: "article" | "docs" | "home" | "landing" | "marketplace" | "product" | "unknown"; + }>; + topDomains: string[]; + }; + interpretation: { + demandTier: YandexEvidenceDemandTier; + difficultyScore: number; + nextDataNeeded: string[]; + opportunity: string; + risk: string | null; + seasonality: YandexEvidenceSeasonality; + serpIntent: YandexEvidenceSerpIntent; + }; + }>; + competitorDomains: Array<{ + domain: string; + count: number; + sampleTitle: string; + sampleUrl: string; + }>; + nextActions: string[]; +}; + +export type RunYandexEvidenceInput = { + phraseLimit?: number; +}; + +const WEB_SEARCH_ENDPOINT = "https://searchapi.api.cloud.yandex.net/v2/web/search"; +const DEFAULT_PHRASE_LIMIT = 5; + +function normalizePhrase(value: string) { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU"); +} + +function maskFolderId(folderId: string) { + return folderId.length > 8 ? `${folderId.slice(0, 4)}...${folderId.slice(-4)}` : "configured"; +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function asArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function getString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function getNumber(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string") { + const parsed = Number(value.replace(/\s+/g, "")); + + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return null; +} + +function stripXmlTags(value: string) { + return value + .replace(//g, "") + .replace(/<\/hlword>/g, "") + .replace(/<[^>]+>/g, "") + .replace(/"/g, "\"") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/\s+/g, " ") + .trim(); +} + +function grabXml(value: string, pattern: RegExp) { + const match = value.match(pattern); + + return match ? stripXmlTags(match[1] ?? "") : ""; +} + +function decodeWebSearchRawData(payload: unknown) { + const rawData = getString(asRecord(payload).rawData); + + if (!rawData) { + return { + docs: [], + found: {} + }; + } + + const xml = Buffer.from(rawData, "base64").toString("utf8"); + const found = Object.fromEntries( + [...xml.matchAll(/([\s\S]*?)<\/found>/g)].map((match) => [ + match[1] ?? "", + getNumber(stripXmlTags(match[2] ?? "")) ?? 0 + ]) + ); + const docs = [...xml.matchAll(/([\s\S]*?)<\/doc>/g)].slice(0, 8).map((match) => { + const block = match[1] ?? ""; + const url = grabXml(block, /([\s\S]*?)<\/url>/); + const title = grabXml(block, /([\s\S]*?)<\/title>/); + + return { + domain: grabXml(block, /<domain>([\s\S]*?)<\/domain>/), + lang: grabXml(block, /<lang>([\s\S]*?)<\/lang>/), + mime: grabXml(block, /<mime-type>([\s\S]*?)<\/mime-type>/), + pageType: classifyPageType(url, title), + snippet: grabXml(block, /<passage>([\s\S]*?)<\/passage>/).slice(0, 260), + title, + url + }; + }); + + return { + docs, + found + }; +} + +function classifyPageType(url: string, title: string) { + const text = `${url} ${title}`.toLocaleLowerCase("ru-RU"); + + if (/\/docs|документац|documentation|api/.test(text)) return "docs" as const; + if (/blog|journal|article|стать|гайд|что такое|как /.test(text)) return "article" as const; + if (/market|catalog|каталог|ratings?|рейтинг/.test(text)) return "marketplace" as const; + if (/pricing|price|tariff|тариф|стоим/.test(text)) return "product" as const; + if (/platform|product|service|решени|сервис/.test(text)) return "landing" as const; + if (/\/$/.test(url)) return "home" as const; + + return "unknown" as const; +} + +function getSeasonality(dynamics: Array<{ count: number | null }>): YandexEvidenceSeasonality { + const values = dynamics.map((item) => item.count).filter((value): value is number => value !== null); + + if (values.length < 3) { + return "insufficient"; + } + + const first = values[0] ?? 0; + const last = values[values.length - 1] ?? 0; + const average = values.reduce((sum, value) => sum + value, 0) / values.length; + const spread = Math.max(...values) - Math.min(...values); + + if (average > 0 && spread / average > 0.45) { + return "volatile"; + } + + if (first > 0 && (last - first) / first >= 0.12) { + return "rising"; + } + + if (first > 0 && (first - last) / first >= 0.12) { + return "falling"; + } + + return "stable"; +} + +function getDemandTier(count: number | null): YandexEvidenceDemandTier { + if (count === null || count <= 0) return "no_signal"; + if (count >= 10_000) return "high"; + if (count >= 1_000) return "mid"; + return "low"; +} + +function getSerpIntent(docs: Array<{ pageType: string }>): YandexEvidenceSerpIntent { + const articleCount = docs.filter((doc) => doc.pageType === "article" || doc.pageType === "docs").length; + const commercialCount = docs.filter((doc) => ["landing", "marketplace", "product"].includes(doc.pageType)).length; + + if (articleCount === 0 && commercialCount === 0) return "unknown"; + if (articleCount > 0 && commercialCount > 0) return "mixed"; + return articleCount > commercialCount ? "informational" : "commercial"; +} + +function getDifficultyScore(found: Record<string, number>, docs: Array<{ domain: string }>) { + const foundAll = found.all ?? found.strict ?? found.phrase ?? 0; + const uniqueDomainCount = new Set(docs.map((doc) => doc.domain).filter(Boolean)).size; + const volumeScore = Math.min(60, Math.round(Math.log10(Math.max(foundAll, 1)) * 12)); + const diversityScore = Math.min(30, uniqueDomainCount * 5); + + return Math.max(0, Math.min(100, volumeScore + diversityScore)); +} + +function getLastCompletedMonthWindow(now = new Date()) { + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)); + const start = new Date(Date.UTC(end.getUTCFullYear(), Math.max(0, end.getUTCMonth() - 5), 1)); + + return { + fromDate: start.toISOString(), + toDate: end.toISOString() + }; +} + +function toPhraseCountList(value: unknown) { + return asArray(value).slice(0, 8).map((item) => { + const record = asRecord(item); + + return { + count: getNumber(record.count), + phrase: getString(record.phrase) + }; + }); +} + +function selectPhrases(input: { + cleaning: KeywordCleaningContract; + keywordMap: KeywordMapContract; + market: MarketEnrichmentContract; + phraseLimit: number; +}) { + const selected = new Map<string, EvidencePhraseInput>(); + + function add(item: EvidencePhraseInput) { + const key = normalizePhrase(item.phrase); + + if (!key || selected.has(key) || selected.size >= input.phraseLimit) { + return; + } + + selected.set(key, item); + } + + for (const item of input.keywordMap.persisted.items) { + add({ + phrase: item.phrase, + priority: item.role === "primary" ? "high" : item.role === "secondary" ? "medium" : "low", + reason: "Approved keyword decision from SEO plan.", + source: "keyword_map", + targetPath: item.targetPath + }); + } + + for (const item of input.cleaning.items.sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))) { + if (item.decision === "trash") { + continue; + } + + add({ + phrase: item.phrase, + priority: (item.frequency ?? 0) >= 1000 ? "high" : item.decision === "use" ? "medium" : "low", + reason: "High demand candidate from keyword cleaning.", + source: "top_demand", + targetPath: item.targetPath + }); + } + + for (const item of input.market.seedQueue) { + add({ + phrase: item.phrase, + priority: item.priority, + reason: item.normalization?.reviewReason ?? "Approved market seed.", + source: "market_seed", + targetPath: null + }); + } + + return Array.from(selected.values()); +} + +async function postJson(input: { + authorization: string; + body: unknown; + endpoint: string; + token: string; +}) { + const response = await fetch(input.endpoint, { + body: JSON.stringify(input.body), + headers: { + Authorization: input.authorization, + "Content-Type": "application/json" + }, + method: "POST", + signal: AbortSignal.timeout(15_000) + }); + const text = await response.text(); + let json: unknown = null; + + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + + if (!response.ok) { + throw new Error(text.replaceAll(input.token, "[redacted]").slice(0, 400) || `HTTP ${response.status}`); + } + + return json; +} + +async function collectPhraseEvidence(input: { + authorization: string; + dates: { fromDate: string; toDate: string }; + device: string; + folderId: string; + phrase: EvidencePhraseInput; + region: string; + token: string; + wordstatBase: string; +}): Promise<YandexEvidenceContract["phrases"][number]> { + const errors: string[] = []; + const wordstat = { + associations: [] as Array<{ phrase: string; count: number | null }>, + dynamics: [] as Array<{ count: number | null; date: string; share: number | null }>, + regions: [] as Array<{ affinityIndex: number | null; count: number | null; region: string; share: number | null }>, + topRequests: [] as Array<{ phrase: string; count: number | null }>, + totalCount: null as number | null + }; + let serp: YandexEvidenceContract["phrases"][number]["serp"] = { + docs: [], + found: {}, + topDomains: [] + }; + + try { + const topPayload = asRecord( + await postJson({ + authorization: input.authorization, + endpoint: `${input.wordstatBase}/topRequests`, + token: input.token, + body: { + devices: [input.device], + folderId: input.folderId, + numPhrases: 8, + phrase: input.phrase.phrase, + regions: [input.region] + } + }) + ); + + wordstat.totalCount = getNumber(topPayload.totalCount); + wordstat.topRequests = toPhraseCountList(topPayload.results); + wordstat.associations = toPhraseCountList(topPayload.associations); + } catch (error) { + errors.push(`Wordstat top: ${error instanceof Error ? error.message : "failed"}`); + } + + try { + const regionsPayload = asRecord( + await postJson({ + authorization: input.authorization, + endpoint: `${input.wordstatBase}/regions`, + token: input.token, + body: { + devices: [input.device], + folderId: input.folderId, + phrase: input.phrase.phrase, + regions: [input.region] + } + }) + ); + + wordstat.regions = asArray(regionsPayload.results).slice(0, 8).map((item) => { + const record = asRecord(item); + + return { + affinityIndex: getNumber(record.affinityIndex), + count: getNumber(record.count), + region: getString(record.region), + share: getNumber(record.share) + }; + }); + } catch (error) { + errors.push(`Wordstat regions: ${error instanceof Error ? error.message : "failed"}`); + } + + try { + const dynamicsPayload = asRecord( + await postJson({ + authorization: input.authorization, + endpoint: `${input.wordstatBase}/dynamics`, + token: input.token, + body: { + devices: [input.device], + folderId: input.folderId, + fromDate: input.dates.fromDate, + period: "PERIOD_MONTHLY", + phrase: input.phrase.phrase, + regions: [input.region], + toDate: input.dates.toDate + } + }) + ); + + wordstat.dynamics = asArray(dynamicsPayload.results).slice(0, 8).map((item) => { + const record = asRecord(item); + + return { + count: getNumber(record.count), + date: getString(record.date), + share: getNumber(record.share) + }; + }); + } catch (error) { + errors.push(`Wordstat dynamics: ${error instanceof Error ? error.message : "failed"}`); + } + + try { + const serpPayload = await postJson({ + authorization: input.authorization, + endpoint: WEB_SEARCH_ENDPOINT, + token: input.token, + body: { + folderId: input.folderId, + groupSpec: { + docsInGroup: 1, + groupMode: "GROUP_MODE_DEEP", + groupsOnPage: 8 + }, + query: { + familyMode: "FAMILY_MODE_NONE", + queryText: input.phrase.phrase, + searchType: "SEARCH_TYPE_RU" + }, + responseFormat: "FORMAT_XML", + sortSpec: { + sortMode: "SORT_MODE_BY_RELEVANCE" + } + } + }); + const parsedSerp = decodeWebSearchRawData(serpPayload); + + serp = { + docs: parsedSerp.docs, + found: parsedSerp.found, + topDomains: [...new Set(parsedSerp.docs.map((doc) => doc.domain).filter(Boolean))].slice(0, 8) + }; + } catch (error) { + errors.push(`SERP: ${error instanceof Error ? error.message : "failed"}`); + } + + const seasonality = getSeasonality(wordstat.dynamics); + const serpIntent = getSerpIntent(serp.docs); + const demandTier = getDemandTier(wordstat.totalCount); + const difficultyScore = getDifficultyScore(serp.found, serp.docs); + const status = errors.length === 0 ? "ok" : errors.length >= 4 ? "failed" : "partial"; + + return { + errors, + interpretation: { + demandTier, + difficultyScore, + nextDataNeeded: [ + ...(serp.docs.length === 0 ? ["SERP top docs"] : []), + ...(wordstat.regions.length === 0 ? ["regional demand"] : []), + ...(wordstat.dynamics.length === 0 ? ["seasonality"] : []), + "model interpretation against site context" + ], + opportunity: + demandTier === "mid" || demandTier === "high" + ? "Есть проверяемый внешний спрос; можно сравнивать текущую страницу с SERP intent и конкурентными углами." + : "Спрос слабый или не подтверждён; не использовать как основной стратегический вектор без расширения формулировок.", + risk: + serpIntent === "informational" && input.phrase.targetPath + ? "Выдача похожа на информационную, а целевая страница может быть коммерческой." + : errors.length > 0 + ? "Evidence неполный; confidence нельзя повышать автоматически." + : null, + seasonality, + serpIntent + }, + phrase: input.phrase.phrase, + priority: input.phrase.priority, + serp, + source: input.phrase.source, + status, + targetPath: input.phrase.targetPath, + wordstat + }; +} + +function buildCompetitorDomains(phrases: YandexEvidenceContract["phrases"]) { + const domains = new Map<string, { count: number; sampleTitle: string; sampleUrl: string }>(); + + for (const phrase of phrases) { + for (const doc of phrase.serp.docs) { + if (!doc.domain) { + continue; + } + + const current = domains.get(doc.domain) ?? { + count: 0, + sampleTitle: doc.title, + sampleUrl: doc.url + }; + + domains.set(doc.domain, { + ...current, + count: current.count + 1 + }); + } + } + + return Array.from(domains.entries()) + .map(([domain, value]) => ({ domain, ...value })) + .sort((left, right) => right.count - left.count || left.domain.localeCompare(right.domain)) + .slice(0, 12); +} + +function buildCapabilities(phrases: YandexEvidenceContract["phrases"]): YandexEvidenceContract["capabilities"] { + const checkedCount = Math.max(1, phrases.length); + const countOk = (callback: (phrase: YandexEvidenceContract["phrases"][number]) => boolean) => + phrases.filter(callback).length; + + return [ + { + id: "wordstat_top", + ok: countOk((phrase) => phrase.wordstat.totalCount !== null) > 0, + summary: `${countOk((phrase) => phrase.wordstat.totalCount !== null)}/${checkedCount} phrases` + }, + { + id: "wordstat_regions", + ok: countOk((phrase) => phrase.wordstat.regions.length > 0) > 0, + summary: `${countOk((phrase) => phrase.wordstat.regions.length > 0)}/${checkedCount} phrases` + }, + { + id: "wordstat_dynamics", + ok: countOk((phrase) => phrase.wordstat.dynamics.length > 0) > 0, + summary: `${countOk((phrase) => phrase.wordstat.dynamics.length > 0)}/${checkedCount} phrases` + }, + { + id: "serp", + ok: countOk((phrase) => phrase.serp.docs.length > 0) > 0, + summary: `${countOk((phrase) => phrase.serp.docs.length > 0)}/${checkedCount} phrases` + } + ]; +} + +function buildSummary(phrases: YandexEvidenceContract["phrases"], competitorDomains: YandexEvidenceContract["competitorDomains"]) { + const totalDemand = phrases.reduce((sum, phrase) => sum + (phrase.wordstat.totalCount ?? 0), 0); + + return { + checkedPhraseCount: phrases.length, + competitorDomainCount: competitorDomains.length, + okPhraseCount: phrases.filter((phrase) => phrase.status === "ok").length, + risingPhraseCount: phrases.filter((phrase) => phrase.interpretation.seasonality === "rising").length, + serpDocCount: phrases.reduce((sum, phrase) => sum + phrase.serp.docs.length, 0), + topDomains: competitorDomains.slice(0, 5).map((domain) => ({ + count: domain.count, + domain: domain.domain + })), + totalDemand + }; +} + +function buildNextActions(contract: Omit<YandexEvidenceContract, "nextActions">) { + const actions: string[] = []; + + if (!contract.provider.configured) { + actions.push("Настроить один Yandex AI Studio / Cloud credential и проверить capabilities."); + return actions; + } + + if (contract.input.phrases.length === 0) { + actions.push("Сначала получить reviewed keyword map или approved market seed queue."); + return actions; + } + + if (contract.summary.serpDocCount > 0) { + actions.push("Передать SERP docs в model task `serp_interpretation`: интент, тип выдачи, конкурентные углы, риск mismatch."); + } + + if (contract.summary.totalDemand > 0) { + actions.push("Поднять strategy confidence только по фразам, где есть Wordstat demand и SERP evidence одновременно."); + } + + actions.push("Не использовать Yandex evidence как команду на rewrite: это только read-only аналитика до human strategy approval."); + + return actions; +} + +function emptyContract(projectId: string, state: YandexEvidenceState, phraseLimit = DEFAULT_PHRASE_LIMIT): YandexEvidenceContract { + return { + capabilities: [], + competitorDomains: [], + generatedAt: new Date().toISOString(), + input: { + phraseLimit, + phrases: [] + }, + nextActions: state === "not_configured" ? ["Настроить Yandex AI Studio / Cloud API key."] : ["Запустить Yandex evidence collection."], + phrases: [], + projectId, + provider: { + authType: null, + configured: false, + folderId: null, + mode: "disabled" + }, + runId: null, + schemaVersion: "yandex-evidence.v1", + semanticRunId: null, + state, + summary: { + checkedPhraseCount: 0, + competitorDomainCount: 0, + okPhraseCount: 0, + risingPhraseCount: 0, + serpDocCount: 0, + topDomains: [], + totalDemand: 0 + } + }; +} + +async function buildConfiguredContract(projectId: string, runId: string, phraseLimit: number): Promise<YandexEvidenceContract> { + const [market, cleaning, keywordMap, config] = await Promise.all([ + getMarketEnrichmentContract(projectId), + getKeywordCleaningContract(projectId), + getKeywordMapContract(projectId), + getWordstatRuntimeConfig() + ]); + + if (config.provider !== "yandex_api") { + return emptyContract(projectId, "not_configured", phraseLimit); + } + + const phrasesInput = selectPhrases({ cleaning, keywordMap, market, phraseLimit }); + const authorization = config.authType === "iam_token" ? `Bearer ${config.apiToken}` : `Api-Key ${config.apiToken}`; + const dates = getLastCompletedMonthWindow(); + const phrases = []; + + for (const phrase of phrasesInput) { + phrases.push( + await collectPhraseEvidence({ + authorization, + dates, + device: config.devices[0] ?? "DEVICE_ALL", + folderId: config.folderId, + phrase, + region: config.regionIds[0] ?? "225", + token: config.apiToken, + wordstatBase: config.apiBaseUrl.replace(/\/+$/, "") + }) + ); + } + + const competitorDomains = buildCompetitorDomains(phrases); + const capabilities = buildCapabilities(phrases); + const contractWithoutActions: Omit<YandexEvidenceContract, "nextActions"> = { + capabilities, + competitorDomains, + generatedAt: new Date().toISOString(), + input: { + phraseLimit, + phrases: phrasesInput + }, + phrases, + projectId, + provider: { + authType: config.authType, + configured: true, + folderId: maskFolderId(config.folderId), + mode: config.mode + }, + runId, + schemaVersion: "yandex-evidence.v1", + semanticRunId: market.semanticRunId, + state: phrases.length > 0 ? "ready" : "not_collected", + summary: buildSummary(phrases, competitorDomains) + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} + +function normalizePhraseLimit(value: number | undefined) { + if (!Number.isFinite(value)) { + return DEFAULT_PHRASE_LIMIT; + } + + return Math.max(1, Math.min(12, Math.round(value ?? DEFAULT_PHRASE_LIMIT))); +} + +async function createStartedRun(projectId: string, input: Record<string, unknown>) { + const result = await pool.query<{ id: string }>( + ` + insert into runs (project_id, run_type, status, input, started_at) + values ($1, 'yandex_evidence', 'running', $2::jsonb, now()) + returning id; + `, + [projectId, JSON.stringify(input)] + ); + + return result.rows[0]?.id ?? null; +} + +async function completeRun(runId: string, output: YandexEvidenceContract) { + await pool.query( + ` + update runs + set status = 'done', + output = $2::jsonb, + completed_at = now() + where id = $1; + `, + [runId, JSON.stringify(output)] + ); +} + +async function failRun(runId: string, error: unknown) { + await pool.query( + ` + update runs + set status = 'failed', + error_message = $2, + completed_at = now() + where id = $1; + `, + [runId, error instanceof Error ? error.message : "Yandex evidence collection failed."] + ); +} + +export async function runYandexEvidenceCollection( + projectId: string, + input: RunYandexEvidenceInput = {} +): Promise<YandexEvidenceContract> { + const phraseLimit = normalizePhraseLimit(input.phraseLimit); + const runId = await createStartedRun(projectId, { + phraseLimit, + schemaVersion: "yandex-evidence-run-input.v1" + }); + + if (!runId) { + throw new Error("Не удалось создать Yandex evidence run."); + } + + try { + const contract = await buildConfiguredContract(projectId, runId, phraseLimit); + + await completeRun(runId, contract); + + return contract; + } catch (error) { + await failRun(runId, error); + throw error; + } +} + +export async function getLatestYandexEvidenceContract(projectId: string): Promise<YandexEvidenceContract> { + const result = await pool.query<YandexEvidenceRunRow>( + ` + select id, output, created_at + from runs + where project_id = $1 and run_type = 'yandex_evidence' and status = 'done' + order by created_at desc + limit 1; + `, + [projectId] + ); + const row = result.rows[0]; + const output = asRecord(row?.output); + + if (output.schemaVersion === "yandex-evidence.v1") { + return { + ...(output as unknown as YandexEvidenceContract), + runId: row?.id ?? (output.runId as string | null) + }; + } + + return emptyContract(projectId, "not_collected"); +} diff --git a/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts b/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts new file mode 100644 index 0000000..5388176 --- /dev/null +++ b/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts @@ -0,0 +1,817 @@ +import { pool } from "../db/client.js"; +import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js"; + +type KeywordCleaningProviderMode = "codex_manual" | "deterministic_fallback"; +type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use"; +type KeywordCleaningSource = "manual" | "normalization_seed" | "serp" | "wordstat_related" | "wordstat_seed"; + +export type KeywordCleaningModelTaskContract = { + taskType: "seo.keyword_cleaning"; + schemaVersion: "seo-keyword-cleaning-task.v1"; + taskId: string; + projectId: string; + semanticRunId: string; + projectOntologyVersionId: string | null; + providerMode: "model_provider_contract"; + allowedActions: Array< + | "classify_keyword_decisions" + | "keep_article_backlog" + | "mark_risky_for_human" + | "preserve_evidence_refs" + | "reject_noise" + | "route_keyword_map" + >; + inputStats: { + marketSeedCount: number; + wordstatResultCount: number; + wordstatJobStatus: string | null; + }; + outputSchema: { + schemaVersion: "keyword-cleaning.v1"; + requiredTopLevelKeys: string[]; + itemRequiredKeys: string[]; + }; + decisionPolicy: { + useRequiresExactEvidence: boolean; + riskyWhenNoPageBinding: boolean; + trashWhenOffContext: boolean; + articleDoesNotEnterRewrite: boolean; + }; + stopConditions: string[]; +}; + +export type KeywordCleaningContract = { + schemaVersion: "keyword-cleaning.v1"; + projectId: string; + semanticRunId: string | null; + projectOntologyVersion: MarketEnrichmentContract["projectOntologyVersion"]; + generatedAt: string; + state: "not_ready" | "ready"; + provider: { + mode: KeywordCleaningProviderMode; + modelRequired: boolean; + message: string; + }; + sourceTaskId: string | null; + analystReview: { + status: "approved" | "needs_changes" | "not_reviewed" | "rejected"; + reviewer: "codex" | "human" | "system"; + reviewedAt: string | null; + notes: string[]; + }; + source: { + marketState: MarketEnrichmentContract["state"]; + marketGeneratedAt: string; + marketSeedCount: number; + wordstatJobId: string | null; + wordstatJobStatus: string | null; + wordstatResultCount: number; + }; + modelTask: KeywordCleaningModelTaskContract | null; + readiness: { + sourcePhraseCount: number; + useCount: number; + supportCount: number; + articleCount: number; + riskyCount: number; + trashCount: number; + keywordMapCandidateCount: number; + exactEvidenceCount: number; + missingPageBindingCount: number; + }; + lanes: Record<KeywordCleaningDecision, KeywordCleaningItem[]>; + items: KeywordCleaningItem[]; + summary: string; + nextActions: string[]; +}; + +export type KeywordCleaningItem = { + id: string; + phrase: string; + normalizedPhrase: string; + source: KeywordCleaningSource; + sourcePhrase: string | null; + clusterId: string; + clusterTitle: string; + targetPath: string | null; + priority: "high" | "medium" | "low"; + seedSource: "detected" | "ontology"; + intentType: MarketEnrichmentContract["normalization"]["seedStrategy"][number]["intentType"] | null; + marketRole: MarketEnrichmentContract["normalization"]["seedStrategy"][number]["marketRole"] | null; + evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"]; + frequency: number | null; + frequencyGroup: string | null; + relatedCount: number; + projectOntologyVersionId: string | null; + wordstatResultId: string | null; + decision: KeywordCleaningDecision; + confidence: number; + reason: string; + blockers: string[]; + evidenceRefs: string[]; +}; + +type KeywordCleaningRunRow = { + id: string; + status: string; + input: Record<string, unknown>; + output: KeywordCleaningContract | null; + error_message: string | null; + started_at: Date | null; + completed_at: Date | null; + created_at: Date; +}; + +type SeedQueueItem = MarketEnrichmentContract["seedQueue"][number]; +type WordstatTopResult = MarketEnrichmentContract["wordstat"]["topResults"][number]; + +const HARD_TRASH_PATTERNS = [ + /(^|\s)(ваканси[ия]|работа|зарплата|резюме)(\s|$)/i, + /(^|\s)(реферат|курсовая|диплом|презентация|скачать|pdf|книга)(\s|$)/i, + /(^|\s)(договор|образец|бланк|закон|гост|оквэд)(\s|$)/i +]; + +const ARTICLE_PATTERNS = [ + /(^|\s)(как|что такое|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд)(\s|$)/i, + /(^|\s)(выбрать|сравнение|обзор|решение проблемы)(\s|$)/i +]; + +function normalizePhrase(value: string) { + return value.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function slugify(value: string) { + return normalizePhrase(value) + .replace(/[^a-zа-яё0-9]+/gi, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 90); +} + +function wordCount(value: string) { + return normalizePhrase(value).split(/\s+/).filter(Boolean).length; +} + +function hasPattern(value: string, patterns: RegExp[]) { + return patterns.some((pattern) => pattern.test(value)); +} + +function unique(values: string[]) { + const seen = new Set<string>(); + const result: string[] = []; + + for (const value of values) { + const cleanValue = value.trim(); + const normalized = normalizePhrase(cleanValue); + + if (!normalized || seen.has(normalized)) { + continue; + } + + seen.add(normalized); + result.push(cleanValue); + } + + return result; +} + +function getDefaultAnalystReview(): KeywordCleaningContract["analystReview"] { + return { + notes: [], + reviewedAt: null, + reviewer: "system", + status: "not_reviewed" + }; +} + +function buildBriefPhraseMap(contract: MarketEnrichmentContract) { + const phraseMap = new Map<string, string>(); + + for (const brief of contract.briefEvidence) { + for (const phrase of brief.seedPhrases) { + const normalized = normalizePhrase(phrase); + + if (!phraseMap.has(normalized)) { + phraseMap.set(normalized, brief.path); + } + } + } + + return phraseMap; +} + +function buildBriefClusterMap(contract: MarketEnrichmentContract) { + const clusterMap = new Map<string, string>(); + + for (const brief of contract.briefEvidence) { + for (const clusterId of brief.clusterIds) { + if (!clusterMap.has(clusterId)) { + clusterMap.set(clusterId, brief.path); + } + } + } + + return clusterMap; +} + +function getTargetPath(seed: SeedQueueItem, briefPhraseMap: Map<string, string>, briefClusterMap: Map<string, string>) { + const directTarget = briefPhraseMap.get(normalizePhrase(seed.phrase)); + + if (directTarget) { + return directTarget; + } + + for (const sourcePhrase of seed.normalization?.normalizedFrom ?? []) { + const sourceTarget = briefPhraseMap.get(normalizePhrase(sourcePhrase)); + + if (sourceTarget) { + return sourceTarget; + } + } + + const clusterTarget = briefClusterMap.get(seed.clusterId); + + if (clusterTarget) { + return clusterTarget; + } + + return null; +} + +function buildContextTokenSet(contract: MarketEnrichmentContract) { + const tokens = new Set<string>(); + + const addTokens = (value: string) => { + for (const token of normalizePhrase(value).split(/\s+/)) { + if (token.length >= 4) { + tokens.add(token); + } + } + }; + + for (const seed of contract.seedQueue) { + addTokens(seed.phrase); + addTokens(seed.clusterTitle); + + for (const sourcePhrase of seed.normalization?.normalizedFrom ?? []) { + addTokens(sourcePhrase); + } + } + + for (const group of contract.normalization.intentGroups) { + addTokens(group.title); + addTokens(group.marketHypothesis); + + for (const phrase of group.corePhrases) { + addTokens(phrase); + } + } + + return tokens; +} + +function hasContextOverlap(phrase: string, contextTokens: Set<string>) { + const phraseTokens = normalizePhrase(phrase).split(/\s+/).filter((token) => token.length >= 4); + + if (phraseTokens.length === 0) { + return false; + } + + return phraseTokens.some((token) => contextTokens.has(token)); +} + +function buildSeedItem( + seed: SeedQueueItem, + index: number, + briefPhraseMap: Map<string, string>, + briefClusterMap: Map<string, string> +): KeywordCleaningItem { + const normalizedPhrase = normalizePhrase(seed.phrase); + + return { + blockers: [], + clusterId: seed.clusterId, + clusterTitle: seed.clusterTitle, + confidence: 0, + decision: "risky", + evidenceRefs: [ + `market.seedQueue:${index}`, + ...(seed.wordstatResultId ? [`wordstat.result:${seed.wordstatResultId}`] : []), + ...(seed.projectOntologyVersionId ? [`ontology.version:${seed.projectOntologyVersionId}`] : []) + ], + evidenceStatus: seed.evidenceStatus, + frequency: seed.frequency, + frequencyGroup: seed.frequencyGroup, + id: `seed:${seed.clusterId}:${slugify(seed.phrase)}`, + intentType: seed.normalization?.intentType ?? null, + marketRole: seed.normalization?.marketRole ?? null, + normalizedPhrase, + phrase: seed.phrase, + priority: seed.priority, + projectOntologyVersionId: seed.projectOntologyVersionId, + reason: "", + relatedCount: seed.relatedCount, + seedSource: seed.source, + source: seed.wordstatResultId ? "wordstat_seed" : "normalization_seed", + sourcePhrase: null, + targetPath: getTargetPath(seed, briefPhraseMap, briefClusterMap), + wordstatResultId: seed.wordstatResultId + }; +} + +function buildRelatedItem( + result: WordstatTopResult, + sourceSeed: SeedQueueItem, + index: number, + briefPhraseMap: Map<string, string>, + briefClusterMap: Map<string, string> +): KeywordCleaningItem { + const normalizedPhrase = normalizePhrase(result.phrase); + const targetPath = result.sourcePhrase + ? (briefPhraseMap.get(normalizePhrase(result.sourcePhrase)) ?? getTargetPath(sourceSeed, briefPhraseMap, briefClusterMap)) + : getTargetPath(sourceSeed, briefPhraseMap, briefClusterMap); + + return { + blockers: [], + clusterId: sourceSeed.clusterId, + clusterTitle: sourceSeed.clusterTitle, + confidence: 0, + decision: "risky", + evidenceRefs: [ + `wordstat.related:${result.id}`, + `wordstat.source:${result.sourcePhrase ?? sourceSeed.phrase}`, + ...(sourceSeed.projectOntologyVersionId ? [`ontology.version:${sourceSeed.projectOntologyVersionId}`] : []) + ], + evidenceStatus: result.frequency !== null ? "collected" : "collected_related_only", + frequency: result.frequency, + frequencyGroup: result.frequencyGroup, + id: `related:${sourceSeed.clusterId}:${index}:${slugify(result.phrase)}`, + intentType: sourceSeed.normalization?.intentType ?? null, + marketRole: sourceSeed.normalization?.marketRole ?? null, + normalizedPhrase, + phrase: result.phrase, + priority: sourceSeed.priority, + projectOntologyVersionId: sourceSeed.projectOntologyVersionId, + reason: "", + relatedCount: 0, + seedSource: sourceSeed.source, + source: "wordstat_related", + sourcePhrase: result.sourcePhrase ?? sourceSeed.phrase, + targetPath, + wordstatResultId: result.id + }; +} + +function getBaseItems(contract: MarketEnrichmentContract) { + const briefPhraseMap = buildBriefPhraseMap(contract); + const briefClusterMap = buildBriefClusterMap(contract); + const seedItems = contract.seedQueue + .slice(0, 160) + .map((seed, index) => buildSeedItem(seed, index, briefPhraseMap, briefClusterMap)); + const seedByPhrase = new Map(contract.seedQueue.map((seed) => [normalizePhrase(seed.phrase), seed])); + const seen = new Set(seedItems.map((item) => item.normalizedPhrase)); + const relatedItems: KeywordCleaningItem[] = []; + + for (const [index, result] of contract.wordstat.topResults.slice(0, 120).entries()) { + const normalizedResult = normalizePhrase(result.phrase); + + if (seen.has(normalizedResult)) { + continue; + } + + const sourcePhrase = result.sourcePhrase ? normalizePhrase(result.sourcePhrase) : normalizedResult; + const sourceSeed = seedByPhrase.get(sourcePhrase); + + if (!sourceSeed) { + continue; + } + + seen.add(normalizedResult); + relatedItems.push(buildRelatedItem(result, sourceSeed, index, briefPhraseMap, briefClusterMap)); + } + + return [...seedItems, ...relatedItems]; +} + +function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>) { + const blockers: string[] = []; + const exactEvidence = item.frequency !== null; + const noExternalSignal = + item.evidenceStatus === "collected_no_signal" || + item.evidenceStatus === "not_collected" || + item.evidenceStatus === "not_configured" || + item.evidenceStatus === "ready_for_collection"; + const weakExternalSignal = item.evidenceStatus === "collected_related_only" || item.source === "wordstat_related"; + const needsHumanReview = item.evidenceStatus === "collection_failed" || noExternalSignal || item.targetPath === null; + const isArticle = hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) || item.intentType === "informational"; + const isTrash = + item.marketRole === "exclude" || + hasPattern(item.normalizedPhrase, HARD_TRASH_PATTERNS) || + (item.source === "wordstat_related" && !hasContextOverlap(item.phrase, contextTokens)) || + (item.source === "wordstat_related" && wordCount(item.phrase) <= 1); + + if (item.targetPath === null) { + blockers.push("нет привязки к посадочной"); + } + + if (noExternalSignal) { + blockers.push("нет exact Wordstat evidence"); + } + + if (weakExternalSignal && !exactEvidence) { + blockers.push("только related signal"); + } + + if (isTrash) { + return { + blockers, + confidence: 0.9, + decision: "trash" as const, + reason: + item.marketRole === "exclude" + ? "Фраза помечена normalization как exclude и не должна попадать в Wordstat/SEO-план." + : "Фраза выглядит как внешний шум или off-context related query; не пускаем в карту ключей." + }; + } + + if (item.evidenceStatus === "collection_failed") { + return { + blockers, + confidence: 0.55, + decision: "risky" as const, + reason: "Провайдер не дал валидный evidence: фразу нельзя закреплять до повторного сбора." + }; + } + + if (isArticle && exactEvidence) { + return { + blockers, + confidence: item.targetPath ? 0.78 : 0.64, + decision: "article" as const, + reason: "Фраза больше похожа на информационный спрос; держим как backlog для контента, не как rewrite текущей посадочной." + }; + } + + if (needsHumanReview || weakExternalSignal || !exactEvidence) { + return { + blockers, + confidence: exactEvidence ? 0.62 : 0.48, + decision: "risky" as const, + reason: "Фраза требует ручной проверки перед SEO-планом: не хватает exact evidence, page binding или уверенности формулировки." + }; + } + + if (item.marketRole === "core" || item.marketRole === "commercial" || item.intentType === "commercial") { + return { + blockers, + confidence: item.priority === "high" ? 0.9 : 0.82, + decision: "use" as const, + reason: "Есть exact Wordstat evidence, контекстная роль и привязка к посадочной; можно отдавать в keyword map." + }; + } + + return { + blockers, + confidence: 0.76, + decision: "support" as const, + reason: "Фраза подтверждена рынком, но лучше работает как поддерживающий термин, а не главный intent страницы." + }; +} + +function classifyItems(contract: MarketEnrichmentContract, items: KeywordCleaningItem[]) { + const contextTokens = buildContextTokenSet(contract); + + return items.map((item) => { + const classification = getClassification(item, contextTokens); + + return { + ...item, + blockers: unique(classification.blockers), + confidence: classification.confidence, + decision: classification.decision, + reason: classification.reason + }; + }); +} + +function buildLanes(items: KeywordCleaningItem[]): KeywordCleaningContract["lanes"] { + return { + article: items.filter((item) => item.decision === "article"), + risky: items.filter((item) => item.decision === "risky"), + support: items.filter((item) => item.decision === "support"), + trash: items.filter((item) => item.decision === "trash"), + use: items.filter((item) => item.decision === "use") + }; +} + +function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningContract["lanes"]): KeywordCleaningContract["readiness"] { + return { + articleCount: lanes.article.length, + exactEvidenceCount: items.filter((item) => item.frequency !== null).length, + keywordMapCandidateCount: lanes.use.length + lanes.support.length, + missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length, + riskyCount: lanes.risky.length, + sourcePhraseCount: items.length, + supportCount: lanes.support.length, + trashCount: lanes.trash.length, + useCount: lanes.use.length + }; +} + +function buildKeywordCleaningModelTask( + projectId: string, + contract: MarketEnrichmentContract +): KeywordCleaningModelTaskContract | null { + if (!contract.semanticRunId) { + return null; + } + + return { + allowedActions: [ + "classify_keyword_decisions", + "reject_noise", + "mark_risky_for_human", + "route_keyword_map", + "keep_article_backlog", + "preserve_evidence_refs" + ], + decisionPolicy: { + articleDoesNotEnterRewrite: true, + riskyWhenNoPageBinding: true, + trashWhenOffContext: true, + useRequiresExactEvidence: true + }, + inputStats: { + marketSeedCount: contract.seedQueue.length, + wordstatJobStatus: contract.wordstat.latestJob?.status ?? null, + wordstatResultCount: contract.wordstat.summary.resultCount + }, + outputSchema: { + itemRequiredKeys: [ + "id", + "phrase", + "decision", + "confidence", + "reason", + "evidenceRefs", + "targetPath", + "wordstatResultId" + ], + requiredTopLevelKeys: [ + "schemaVersion", + "projectId", + "semanticRunId", + "provider", + "analystReview", + "readiness", + "lanes", + "items", + "nextActions" + ], + schemaVersion: "keyword-cleaning.v1" + }, + projectId, + projectOntologyVersionId: contract.projectOntologyVersion?.id ?? null, + providerMode: "model_provider_contract", + schemaVersion: "seo-keyword-cleaning-task.v1", + semanticRunId: contract.semanticRunId, + stopConditions: [ + "Не отправлять trash/risky в keyword map как approved decision.", + "Не придумывать спрос без Wordstat/SERP evidence.", + "Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.", + "Не сохранять rewrite/apply decisions." + ], + taskId: `${contract.semanticRunId}:keyword-cleaning:v1`, + taskType: "seo.keyword_cleaning" + }; +} + +function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">) { + const actions: string[] = []; + + if (!contract.semanticRunId) { + return ["Сначала нужен semantic analysis и market enrichment: keyword cleaning не может работать без source evidence."]; + } + + if (contract.items.length === 0) { + return ["Нет фраз для очистки: сначала подготовить approved Wordstat queue через SEO normalization."]; + } + + if (contract.provider.mode === "deterministic_fallback") { + actions.push("Прогнать seo.keyword_cleaning через Codex/model route и сохранить manual evidence перед фиксацией SEO-плана."); + } + + if (contract.readiness.riskyCount > 0) { + actions.push(`Разобрать risky lane: ${contract.readiness.riskyCount} фраз нельзя отдавать в rewrite без проверки.`); + } + + if (contract.readiness.trashCount > 0) { + actions.push(`Оставить trash lane вне keyword map: ${contract.readiness.trashCount} фраз не должны попадать в SEO-план.`); + } + + if (contract.readiness.articleCount > 0) { + actions.push(`Article lane (${contract.readiness.articleCount}) держать как backlog посадочных/контента, а не как правку текущих блоков.`); + } + + actions.push( + `В keyword map передавать только use/support: ${contract.readiness.keywordMapCandidateCount} кандидатов с evidence и page binding.` + ); + + return actions; +} + +function buildFallbackContract(projectId: string, market: MarketEnrichmentContract): KeywordCleaningContract { + const modelTask = buildKeywordCleaningModelTask(projectId, market); + const items = classifyItems(market, getBaseItems(market)); + const lanes = buildLanes(items); + const readiness = buildReadiness(items, lanes); + const contractWithoutActions: Omit<KeywordCleaningContract, "nextActions"> = { + analystReview: getDefaultAnalystReview(), + generatedAt: new Date().toISOString(), + items, + lanes, + modelTask, + projectId, + projectOntologyVersion: market.projectOntologyVersion, + provider: { + message: + "Keyword cleaning собран deterministic fallback-ом. Перед фиксацией SEO-плана нужен Codex/model review по этому же contract.", + mode: "deterministic_fallback", + modelRequired: true + }, + readiness, + schemaVersion: "keyword-cleaning.v1", + semanticRunId: market.semanticRunId, + source: { + marketGeneratedAt: market.generatedAt, + marketSeedCount: market.seedQueue.length, + marketState: market.state, + wordstatJobId: market.wordstat.latestJob?.id ?? null, + wordstatJobStatus: market.wordstat.latestJob?.status ?? null, + wordstatResultCount: market.wordstat.summary.resultCount + }, + sourceTaskId: modelTask?.taskId ?? null, + state: market.semanticRunId && items.length > 0 ? "ready" : "not_ready", + summary: + items.length > 0 + ? `Очищено ${items.length} фраз: ${readiness.useCount} use, ${readiness.supportCount} support, ${readiness.articleCount} article, ${readiness.riskyCount} risky, ${readiness.trashCount} trash.` + : "Нет фраз для keyword cleaning." + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} + +function mapKeywordCleaningRun(row: KeywordCleaningRunRow): KeywordCleaningContract | null { + if (!row.output) { + return null; + } + + const items = row.output.items ?? []; + const lanes = row.output.lanes ?? buildLanes(items); + const readiness = row.output.readiness ?? buildReadiness(items, lanes); + + return { + ...row.output, + analystReview: row.output.analystReview ?? getDefaultAnalystReview(), + lanes, + readiness + }; +} + +function normalizeKeywordCleaningOutput( + projectId: string, + output: KeywordCleaningContract, + providerMode: KeywordCleaningProviderMode, + providerMessage: string +): KeywordCleaningContract { + if (output.schemaVersion !== "keyword-cleaning.v1") { + throw new Error("Keyword cleaning output schemaVersion должен быть keyword-cleaning.v1."); + } + + if (output.projectId !== projectId) { + throw new Error("Keyword cleaning output projectId не совпадает с проектом."); + } + + if (!output.semanticRunId || !output.modelTask?.taskId) { + throw new Error("Keyword cleaning output должен иметь semanticRunId и modelTask.taskId."); + } + + if (!Array.isArray(output.items)) { + throw new Error("Keyword cleaning output должен иметь items."); + } + + const items = output.items.map((item) => ({ + ...item, + blockers: Array.isArray(item.blockers) ? unique(item.blockers) : [], + confidence: Math.max(0, Math.min(1, item.confidence)), + evidenceRefs: Array.isArray(item.evidenceRefs) ? unique(item.evidenceRefs) : [], + normalizedPhrase: normalizePhrase(item.normalizedPhrase || item.phrase) + })); + const lanes = buildLanes(items); + const readiness = buildReadiness(items, lanes); + const contractWithoutActions: Omit<KeywordCleaningContract, "nextActions"> = { + ...output, + analystReview: output.analystReview ?? getDefaultAnalystReview(), + generatedAt: new Date().toISOString(), + items, + lanes, + provider: { + message: providerMessage, + mode: providerMode, + modelRequired: true + }, + readiness, + sourceTaskId: output.modelTask.taskId, + state: items.length > 0 ? "ready" : "not_ready", + summary: + items.length > 0 + ? `Очищено ${items.length} фраз: ${readiness.useCount} use, ${readiness.supportCount} support, ${readiness.articleCount} article, ${readiness.riskyCount} risky, ${readiness.trashCount} trash.` + : "Нет фраз для keyword cleaning." + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} + +export async function getLatestAlignedKeywordCleaning( + projectId: string, + semanticRunId: string, + wordstatJobId: string | null +): Promise<KeywordCleaningContract | null> { + const result = await pool.query<KeywordCleaningRunRow>( + ` + select id, status, input, output, error_message, started_at, completed_at, created_at + from runs + where project_id = $1 + and run_type = 'keyword_cleaning' + and output->>'semanticRunId' = $2 + order by created_at desc + limit 1; + `, + [projectId, semanticRunId] + ); + const run = result.rows[0] ? mapKeywordCleaningRun(result.rows[0]) : null; + + if (!run) { + return null; + } + + if ((run.source?.wordstatJobId ?? null) !== wordstatJobId) { + return null; + } + + return run; +} + +export async function getKeywordCleaningContract(projectId: string): Promise<KeywordCleaningContract> { + const market = await getMarketEnrichmentContract(projectId); + const fallbackContract = buildFallbackContract(projectId, market); + + if (!market.semanticRunId) { + return fallbackContract; + } + + return (await getLatestAlignedKeywordCleaning(projectId, market.semanticRunId, market.wordstat.latestJob?.id ?? null)) ?? fallbackContract; +} + +export async function saveKeywordCleaningManual( + projectId: string, + output: KeywordCleaningContract +): Promise<KeywordCleaningContract> { + const normalizedOutput = normalizeKeywordCleaningOutput( + projectId, + output, + "codex_manual", + "Codex manual dev-loop: текущий Codex-сеанс выполнил seo.keyword_cleaning task по contract evidence, без внешнего provider adapter." + ); + const result = await pool.query<KeywordCleaningRunRow>( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'keyword_cleaning', '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_manual", + schemaVersion: "keyword-cleaning-manual-input.v1", + semanticRunId: normalizedOutput.semanticRunId, + sourceTaskId: normalizedOutput.sourceTaskId, + taskType: "seo.keyword_cleaning", + wordstatJobId: normalizedOutput.source.wordstatJobId + }), + JSON.stringify(normalizedOutput) + ] + ); + const run = result.rows[0] ? mapKeywordCleaningRun(result.rows[0]) : null; + + if (!run) { + throw new Error("Не удалось сохранить Codex manual keyword cleaning."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts new file mode 100644 index 0000000..7d8d474 --- /dev/null +++ b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts @@ -0,0 +1,722 @@ +import { pool } from "../db/client.js"; +import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js"; +import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js"; + +type KeywordMapState = "blocked" | "draft"; +type KeywordMapRole = "primary" | "secondary" | "support" | "validate"; + +export type KeywordMapContract = { + schemaVersion: "keyword-map.v1"; + projectId: string; + semanticRunId: string | null; + projectOntologyVersion: MarketEnrichmentContract["projectOntologyVersion"]; + generatedAt: string; + state: KeywordMapState; + readiness: { + reviewedOntology: boolean; + seedCount: number; + mappedItemCount: number; + marketEvidenceCount: number; + persistableItemCount: number; + blockerCount: number; + }; + cleaning: { + schemaVersion: "keyword-cleaning.v1"; + providerMode: KeywordCleaningContract["provider"]["mode"]; + analystStatus: KeywordCleaningContract["analystReview"]["status"]; + sourcePhraseCount: number; + useCount: number; + supportCount: number; + articleCount: number; + riskyCount: number; + trashCount: number; + keywordMapCandidateCount: number; + }; + blockers: string[]; + persisted: KeywordMapPersistenceSummary; + items: Array<{ + id: string; + phrase: string; + role: KeywordMapRole; + clusterId: string; + clusterTitle: string; + targetPath: string | null; + priority: "high" | "medium" | "low"; + source: "detected" | "ontology"; + evidenceStatus: MarketEnrichmentContract["seedQueue"][number]["evidenceStatus"]; + frequency: number | null; + frequencyGroup: string | null; + projectOntologyVersionId: string | null; + wordstatResultId: string | null; + reason: string; + }>; + nextActions: string[]; +}; + +export type KeywordMapPersistenceSummary = { + approvedDecisionCount: number; + mapItemCount: number; + latestApprovedAt: string | null; + items: Array<{ + decisionId: string; + mapItemId: string | null; + sourceItemId: string | null; + phrase: string; + role: KeywordMapRole | string; + targetPath: string | null; + workspaceSectionId: string | null; + pageId: string | null; + approvedAt: string; + }>; +}; + +export type KeywordMapPersistResult = { + savedDecisionCount: number; + savedMapItemCount: number; + skippedItemCount: number; + keywordMap: KeywordMapContract; +}; + +type KeywordMapItem = KeywordMapContract["items"][number]; +type KeywordCleaningItem = KeywordCleaningContract["items"][number]; + +function normalizePhrase(value: string) { + return value.trim().replace(/\s+/g, " ").toLowerCase(); +} + +function isReviewedOntology(version: MarketEnrichmentContract["projectOntologyVersion"]) { + return ( + Boolean(version) && + (version?.approvalStatus === "approved" || version?.approvalStatus === "needs_review") && + (version?.reviewProblemCount ?? 0) === 0 + ); +} + +function buildBriefPhraseMap(contract: MarketEnrichmentContract) { + const phraseMap = new Map<string, string>(); + + for (const brief of contract.briefEvidence) { + for (const phrase of brief.seedPhrases) { + const normalized = normalizePhrase(phrase); + + if (!phraseMap.has(normalized)) { + phraseMap.set(normalized, brief.path); + } + } + } + + return phraseMap; +} + +function getKeywordRole( + item: KeywordCleaningItem, + indexInTarget: number, + reviewedOntology: boolean +): KeywordMapRole { + if ( + !reviewedOntology || + item.decision === "article" || + item.decision === "risky" || + item.evidenceStatus === "collected_no_signal" || + item.evidenceStatus === "collected_related_only" || + item.evidenceStatus === "collection_failed" || + item.evidenceStatus === "not_collected" || + item.evidenceStatus === "not_configured" + ) { + return "validate"; + } + + if (item.decision === "support") { + return "support"; + } + + if (indexInTarget === 0 && item.priority === "high") { + return "primary"; + } + + if (item.priority === "high" || item.frequency !== null) { + return "secondary"; + } + + return "support"; +} + +function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract) { + const blockers: string[] = []; + + if (!contract.semanticRunId) { + blockers.push("Нет semantic analysis run: keyword map не может распределить фразы по посадочным."); + } + + if (!contract.projectOntologyVersion) { + blockers.push("Нет project ontology version: keyword map должен ссылаться на reviewed ontology."); + } else if (!isReviewedOntology(contract.projectOntologyVersion)) { + blockers.push( + `Project ontology v${contract.projectOntologyVersion.version} ещё не reviewed: нужен needs review/approved без problem items.` + ); + } + + if (cleaning.state !== "ready" || cleaning.items.length === 0) { + blockers.push("Нет keyword cleaning candidates: сначала нужен market evidence и слой очистки ключей."); + } + + if (cleaning.provider.mode === "deterministic_fallback") { + blockers.push("Keyword cleaning пока fallback: перед фиксацией SEO-плана нужен Codex/model cleaning evidence."); + } + + if (cleaning.analystReview.status === "rejected") { + blockers.push("Keyword cleaning отклонён analyst review: нельзя фиксировать keyword map."); + } + + return blockers; +} + +function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) { + const targetCounts = new Map<string, number>(); + + return cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120).map((item, index) => { + const targetPath = item.targetPath; + const targetKey = targetPath ?? item.clusterId; + const indexInTarget = targetCounts.get(targetKey) ?? 0; + targetCounts.set(targetKey, indexInTarget + 1); + const role = getKeywordRole(item, indexInTarget, reviewedOntology); + + return { + id: `clean:${item.id}`, + phrase: item.phrase, + role, + clusterId: item.clusterId, + clusterTitle: item.clusterTitle, + targetPath, + priority: item.priority, + source: item.seedSource, + evidenceStatus: item.evidenceStatus, + frequency: item.frequency, + frequencyGroup: item.frequencyGroup, + projectOntologyVersionId: item.projectOntologyVersionId, + wordstatResultId: item.wordstatResultId, + reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology) + }; + }); +} + +function getKeywordMapItemReason( + item: KeywordCleaningItem, + role: KeywordMapRole, + targetPath: string | null, + reviewedOntology: boolean +) { + if (!reviewedOntology) { + return "Фразу нельзя закреплять без reviewed ontology."; + } + + if (item.decision === "article") { + return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной."; + } + + if (item.decision === "risky") { + return item.reason; + } + + if (item.evidenceStatus === "collected_no_signal") { + return "Wordstat не дал exact или related signal; фразу нужно проверить перед закреплением."; + } + + if (item.evidenceStatus === "collected_related_only") { + return "Wordstat дал только related queries без exact frequency; нужна ручная проверка формулировки."; + } + + if (item.evidenceStatus === "collection_failed") { + return "Последний Wordstat job упал; фразу нельзя закреплять до повторного сбора."; + } + + if (item.evidenceStatus === "not_collected" || item.evidenceStatus === "not_configured") { + return "Фразу нельзя закреплять без market evidence."; + } + + if (role === "validate") { + return "Фраза требует ручной проверки перед закреплением."; + } + + if (targetPath) { + return item.reason || "Фраза прошла keyword cleaning и подтверждена external evidence."; + } + + return item.reason || "Фраза пока не совпала с brief seedPhrases, нужна ручная проверка target page."; +} + +function isPersistableKeywordMapItem(item: KeywordMapItem) { + return ( + item.role !== "validate" && + item.evidenceStatus === "collected" && + item.frequency !== null && + Boolean(item.projectOntologyVersionId) && + Boolean(item.wordstatResultId) + ); +} + +function getWorkspaceSectionIdForRole(role: KeywordMapRole) { + if (role === "primary") { + return "seo-title"; + } + + if (role === "secondary") { + return "seo-description"; + } + + if (role === "support") { + return "block-1"; + } + + return null; +} + +function normalizeTargetPath(value: string | null) { + if (!value) { + return null; + } + + const cleanValue = value.trim(); + + if (!cleanValue) { + return null; + } + + if (cleanValue === "/") { + return "/"; + } + + return cleanValue.endsWith("/") ? cleanValue : `${cleanValue}/`; +} + +async function resolveTargetPageId(projectId: string, targetPath: string | null) { + const normalizedTargetPath = normalizeTargetPath(targetPath); + + if (!normalizedTargetPath) { + return null; + } + + const targetVariants = Array.from( + new Set([ + normalizedTargetPath, + normalizedTargetPath.endsWith("/") && normalizedTargetPath !== "/" + ? normalizedTargetPath.slice(0, -1) + : normalizedTargetPath, + normalizedTargetPath.startsWith("/") ? normalizedTargetPath.slice(1) : normalizedTargetPath + ]) + ).filter(Boolean); + const result = await pool.query<{ page_id: string }>( + ` + select pv.page_id + from page_versions pv + join scan_versions sv on sv.id = pv.scan_version_id + where sv.project_id = $1 + and sv.status = 'done' + and (pv.url_path = any($2::text[]) or pv.source_path = any($2::text[])) + order by sv.completed_at desc nulls last, sv.created_at desc + limit 1; + `, + [projectId, targetVariants] + ); + + return result.rows[0]?.page_id ?? null; +} + +async function getKeywordMapPersistenceSummary( + projectId: string, + semanticRunId: string | null, + projectOntologyVersionId: string | null +): Promise<KeywordMapPersistenceSummary> { + if (!semanticRunId || !projectOntologyVersionId) { + return { + approvedDecisionCount: 0, + items: [], + latestApprovedAt: null, + mapItemCount: 0 + }; + } + + const result = await pool.query<{ + decision_id: string; + map_item_id: string | null; + source_item_id: string | null; + phrase: string; + role: string | null; + target_path: string | null; + workspace_section_id: string | null; + page_id: string | null; + approved_at: Date; + }>( + ` + select + kd.id as decision_id, + kmi.id as map_item_id, + kd.source_item_id, + kd.phrase, + coalesce(kmi.role, kd.payload->>'role') as role, + coalesce(kmi.target_path, kd.target_path) as target_path, + kmi.workspace_section_id, + kmi.page_id, + 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 + where kd.project_id = $1 + and kd.semantic_run_id = $2 + and kd.project_ontology_version_id = $3 + and kd.approved = true + order by kd.updated_at desc, kd.created_at desc; + `, + [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; + + return { + approvedDecisionCount: decisionIds.size, + mapItemCount: mapItemIds.size, + latestApprovedAt, + items: result.rows.map((row) => ({ + approvedAt: row.approved_at.toISOString(), + decisionId: row.decision_id, + mapItemId: row.map_item_id, + pageId: row.page_id, + phrase: row.phrase, + role: row.role ?? "unknown", + sourceItemId: row.source_item_id, + targetPath: row.target_path, + workspaceSectionId: row.workspace_section_id + })) + }; +} + +function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) { + if (contract.blockers.length > 0) { + return contract.blockers; + } + + const actions: string[] = []; + + if (contract.cleaning.keywordMapCandidateCount === 0) { + actions.push( + "Keyword cleaning не пропустил ни одной use/support фразы: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy." + ); + + if (contract.readiness.marketEvidenceCount === 0) { + actions.push("Собрать/пересобрать Wordstat evidence по approved queue и расширенным рыночным формулировкам."); + } + + if (contract.cleaning.riskyCount > 0) { + actions.push(`Разобрать risky lane: ${contract.cleaning.riskyCount} фраз ждут human/model review.`); + } + + return actions; + } + + actions.push("Проверить primary/secondary роли перед rewrite: один primary intent на посадочную, support terms без переспама."); + + if (contract.readiness.marketEvidenceCount === 0) { + actions.push("Собрать Wordstat evidence и пересобрать keyword map, чтобы роли опирались на частотность."); + } + + if (contract.readiness.persistableItemCount > contract.persisted.approvedDecisionCount) { + 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."); + } else { + actions.push("Нет exact-frequency keyword items для approved decisions; спорные фразы остаются в validate lane."); + } + + return actions; +} + +export async function getKeywordMapContract(projectId: string): Promise<KeywordMapContract> { + const [marketContract, cleaningContract] = await Promise.all([ + getMarketEnrichmentContract(projectId), + getKeywordCleaningContract(projectId) + ]); + const reviewedOntology = isReviewedOntology(marketContract.projectOntologyVersion); + const blockers = buildBlockers(marketContract, cleaningContract); + const items = buildItems(marketContract, cleaningContract, reviewedOntology); + const persisted = await getKeywordMapPersistenceSummary( + projectId, + marketContract.semanticRunId, + marketContract.projectOntologyVersion?.id ?? null + ); + const contractWithoutActions: Omit<KeywordMapContract, "nextActions"> = { + schemaVersion: "keyword-map.v1", + projectId, + semanticRunId: marketContract.semanticRunId, + projectOntologyVersion: marketContract.projectOntologyVersion, + generatedAt: new Date().toISOString(), + state: blockers.length === 0 ? "draft" : "blocked", + readiness: { + reviewedOntology, + seedCount: cleaningContract.readiness.sourcePhraseCount, + mappedItemCount: items.filter((item) => item.targetPath !== null).length, + marketEvidenceCount: cleaningContract.readiness.exactEvidenceCount, + persistableItemCount: items.filter(isPersistableKeywordMapItem).length, + blockerCount: blockers.length + }, + cleaning: { + analystStatus: cleaningContract.analystReview.status, + articleCount: cleaningContract.readiness.articleCount, + keywordMapCandidateCount: cleaningContract.readiness.keywordMapCandidateCount, + providerMode: cleaningContract.provider.mode, + riskyCount: cleaningContract.readiness.riskyCount, + schemaVersion: "keyword-cleaning.v1", + sourcePhraseCount: cleaningContract.readiness.sourcePhraseCount, + supportCount: cleaningContract.readiness.supportCount, + trashCount: cleaningContract.readiness.trashCount, + useCount: cleaningContract.readiness.useCount + }, + blockers, + persisted, + items + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} + +export async function approveKeywordMapDecisions( + projectId: string, + input: { + itemIds?: string[]; + } = {} +): Promise<KeywordMapPersistResult> { + const keywordMap = await getKeywordMapContract(projectId); + const itemIdFilter = input.itemIds && input.itemIds.length > 0 ? new Set(input.itemIds) : null; + const selectedItems = keywordMap.items.filter((item) => !itemIdFilter || itemIdFilter.has(item.id)); + const persistableItems = selectedItems.filter(isPersistableKeywordMapItem); + + if (keywordMap.blockers.length > 0 || !keywordMap.semanticRunId || !keywordMap.projectOntologyVersion) { + return { + keywordMap, + savedDecisionCount: 0, + savedMapItemCount: 0, + skippedItemCount: selectedItems.length + }; + } + + const client = await pool.connect(); + + try { + await client.query("begin"); + + let savedDecisionCount = 0; + let savedMapItemCount = 0; + const activeSourceItemIds = persistableItems.map((item) => item.id); + + if (!itemIdFilter) { + if (activeSourceItemIds.length > 0) { + await client.query( + ` + update keyword_map_items + set approved = false, updated_at = now() + where project_id = $1 + and semantic_run_id = $2 + and project_ontology_version_id = $3 + and approved = true + and (source_item_id is null or source_item_id <> all($4::text[])); + `, + [projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id, activeSourceItemIds] + ); + + await client.query( + ` + update keyword_decisions + set status = 'superseded', approved = false, updated_at = now() + where project_id = $1 + and semantic_run_id = $2 + and project_ontology_version_id = $3 + and approved = true + and (source_item_id is null or source_item_id <> all($4::text[])); + `, + [projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id, activeSourceItemIds] + ); + } else { + await client.query( + ` + update keyword_map_items + set approved = false, updated_at = now() + where project_id = $1 + and semantic_run_id = $2 + and project_ontology_version_id = $3 + and approved = true; + `, + [projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id] + ); + + await client.query( + ` + update keyword_decisions + set status = 'superseded', approved = false, updated_at = now() + where project_id = $1 + and semantic_run_id = $2 + and project_ontology_version_id = $3 + and approved = true; + `, + [projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id] + ); + } + } + + for (const item of persistableItems) { + const decisionResult = await client.query<{ id: string }>( + ` + insert into keyword_decisions ( + project_id, + semantic_run_id, + project_ontology_version_id, + wordstat_result_id, + source_item_id, + cluster_id, + cluster_title, + target_path, + phrase, + status, + reason, + approved, + evidence_status, + frequency, + frequency_group, + payload, + updated_at + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'approved', $10, true, $11, $12, $13, $14::jsonb, now()) + on conflict (project_id, project_ontology_version_id, source_item_id) + where source_item_id is not null + do update set + semantic_run_id = excluded.semantic_run_id, + wordstat_result_id = excluded.wordstat_result_id, + cluster_id = excluded.cluster_id, + cluster_title = excluded.cluster_title, + target_path = excluded.target_path, + phrase = excluded.phrase, + status = excluded.status, + reason = excluded.reason, + approved = excluded.approved, + evidence_status = excluded.evidence_status, + frequency = excluded.frequency, + frequency_group = excluded.frequency_group, + payload = excluded.payload, + updated_at = now() + returning id; + `, + [ + projectId, + keywordMap.semanticRunId, + keywordMap.projectOntologyVersion.id, + item.wordstatResultId, + item.id, + item.clusterId, + item.clusterTitle, + item.targetPath, + item.phrase, + item.reason, + item.evidenceStatus, + item.frequency, + item.frequencyGroup, + JSON.stringify({ + role: item.role, + source: item.source, + priority: item.priority, + projectOntologyVersionId: item.projectOntologyVersionId + }) + ] + ); + const decisionId = decisionResult.rows[0]?.id; + + if (!decisionId) { + continue; + } + + savedDecisionCount += 1; + + const workspaceSectionId = getWorkspaceSectionIdForRole(item.role); + const pageId = await resolveTargetPageId(projectId, item.targetPath); + + if (!workspaceSectionId) { + continue; + } + + await client.query( + ` + insert into keyword_map_items ( + project_id, + semantic_run_id, + project_ontology_version_id, + source_item_id, + page_id, + section_id, + keyword_decision_id, + role, + exact_limit, + soft_forms, + approved, + target_path, + workspace_section_id, + payload, + updated_at + ) + values ($1, $2, $3, $4, $5, null, $6, $7, $8, $9::jsonb, true, $10, $11, $12::jsonb, now()) + on conflict (project_id, keyword_decision_id, workspace_section_id) + where workspace_section_id is not null + do update set + semantic_run_id = excluded.semantic_run_id, + project_ontology_version_id = excluded.project_ontology_version_id, + source_item_id = excluded.source_item_id, + page_id = excluded.page_id, + role = excluded.role, + exact_limit = excluded.exact_limit, + soft_forms = excluded.soft_forms, + approved = excluded.approved, + target_path = excluded.target_path, + payload = excluded.payload, + updated_at = now(); + `, + [ + projectId, + keywordMap.semanticRunId, + keywordMap.projectOntologyVersion.id, + item.id, + pageId, + decisionId, + item.role, + item.role === "primary" ? 1 : null, + JSON.stringify([]), + item.targetPath, + workspaceSectionId, + JSON.stringify({ + clusterId: item.clusterId, + clusterTitle: item.clusterTitle, + evidenceStatus: item.evidenceStatus, + frequency: item.frequency, + frequencyGroup: item.frequencyGroup, + reason: item.reason + }) + ] + ); + savedMapItemCount += 1; + } + + await client.query("commit"); + + return { + keywordMap: await getKeywordMapContract(projectId), + savedDecisionCount, + savedMapItemCount, + skippedItemCount: selectedItems.length - persistableItems.length + }; + } catch (error) { + await client.query("rollback"); + throw error; + } finally { + client.release(); + } +} diff --git a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts index 64e141f..4f3d6b1 100644 --- a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts +++ b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts @@ -1,6 +1,17 @@ import { env } from "../config/env.js"; import { getLatestSemanticAnalysis, type SemanticAnalysisRun } from "../analysis/semanticAnalysis.js"; -import { collectWordstatEvidence, getLatestWordstatEvidence, type WordstatEvidence } from "./wordstatRepository.js"; +import { + getLatestProjectOntologyVersion, + getProjectOntologyVersion, + type ProjectOntologyVersionRecord +} from "../ontology/projectOntologyRepository.js"; +import { + collectWordstatEvidence, + getLatestWordstatEvidence, + type WordstatEvidence, + type WordstatSeedCandidate +} from "./wordstatRepository.js"; +import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js"; import { createWordstatProvider } from "./wordstatProvider.js"; type MarketProviderId = "model" | "serp" | "text_quality" | "wordstat"; @@ -16,6 +27,16 @@ type MarketEvidenceStatus = | "not_configured" | "ready_for_collection"; +type MarketProjectOntologyVersion = { + id: string; + version: number; + approvalStatus: ProjectOntologyVersionRecord["approvalStatus"]; + brandName: string; + schemaVersion: ProjectOntologyVersionRecord["schemaVersion"]; + domainPackage: ProjectOntologyVersionRecord["domainPackage"]; + reviewProblemCount: number; +}; + type MarketProviderDescriptor = { id: MarketProviderId; label: string; @@ -31,11 +52,14 @@ export type MarketEnrichmentContract = { schemaVersion: "market-enrichment.v1"; projectId: string; semanticRunId: string | null; + projectOntologyVersion: MarketProjectOntologyVersion | null; generatedAt: string; state: MarketEnrichmentState; + normalization: SeoNormalizationContract; readiness: { canCollectWordstat: boolean; canCollectSerp: boolean; + ontologyReadyForMarket: boolean; seedCount: number; briefCount: number; configuredProviderCount: number; @@ -48,6 +72,17 @@ export type MarketEnrichmentContract = { clusterTitle: string; priority: "high" | "medium" | "low"; source: "detected" | "ontology"; + normalization: { + intentGroupId: string; + intentType: SeoNormalizationContract["seedStrategy"][number]["intentType"]; + marketRole: SeoNormalizationContract["seedStrategy"][number]["marketRole"]; + normalizedFrom: string[]; + confidence: number; + needsHumanReview: boolean; + reviewStatus: SeoNormalizationContract["seedStrategy"][number]["review"]["status"]; + reviewReason: string; + sendToSerp: boolean; + } | null; evidenceStatus: MarketEvidenceStatus; targetProviders: MarketProviderId[]; blocker: string | null; @@ -55,9 +90,12 @@ export type MarketEnrichmentContract = { frequencyGroup: string | null; relatedCount: number; lastCollectedAt: string | null; + projectOntologyVersionId: string | null; + wordstatResultId: string | null; }>; briefEvidence: Array<{ path: string; + clusterIds: string[]; priority: "high" | "medium" | "low"; action: "create" | "strengthen" | "validate"; qualityScore: number | null; @@ -66,6 +104,7 @@ export type MarketEnrichmentContract = { evidenceStatus: MarketEvidenceStatus; requiredProviders: MarketProviderId[]; missingEvidence: string[]; + projectOntologyVersionId: string | null; }>; wordstat: WordstatEvidence; nextActions: string[]; @@ -181,6 +220,38 @@ function buildWordstatSeedMap(wordstat: WordstatEvidence) { return new Map(wordstat.seedResults.map((result) => [normalizePhrase(result.phrase), result])); } +function isOntologyReadyForMarket(ontologyVersion: MarketProjectOntologyVersion | null) { + return ( + Boolean(ontologyVersion) && + (ontologyVersion?.approvalStatus === "approved" || ontologyVersion?.approvalStatus === "needs_review") && + (ontologyVersion?.reviewProblemCount ?? 0) === 0 + ); +} + +function mapMarketOntologyVersion(ontologyVersion: ProjectOntologyVersionRecord | null): MarketProjectOntologyVersion | null { + if (!ontologyVersion) { + return null; + } + + return { + approvalStatus: ontologyVersion.approvalStatus, + brandName: ontologyVersion.brandName, + domainPackage: ontologyVersion.domainPackage, + id: ontologyVersion.id, + reviewProblemCount: (ontologyVersion.evidenceSummary.reviewItems ?? []).filter((item) => item.level === "problem").length, + schemaVersion: ontologyVersion.schemaVersion, + version: ontologyVersion.version + }; +} + +async function getBoundOntologyVersion(projectId: string, analysis: SemanticAnalysisRun | null) { + if (!analysis?.projectOntology?.versionId) { + return getLatestProjectOntologyVersion(projectId); + } + + return getProjectOntologyVersion(projectId, analysis.projectOntology.versionId); +} + function getSeedEvidenceStatus( providers: MarketProviderDescriptor[], wordstat: WordstatEvidence, @@ -198,20 +269,26 @@ function getSeedEvidenceStatus( return "collected_related_only"; } - if (wordstat.latestJob?.status === "done") { + if (wordstat.latestJob?.status === "done" && wordstatResult) { return "collected_no_signal"; } return getEvidenceStatus(providers, ["wordstat"]); } -function buildSeedQueue(analysis: SemanticAnalysisRun, providers: MarketProviderDescriptor[], wordstat: WordstatEvidence) { +function buildSeedQueue( + normalization: SeoNormalizationContract, + providers: MarketProviderDescriptor[], + wordstat: WordstatEvidence, + projectOntologyVersion: MarketProjectOntologyVersion | null +) { const wordstatResults = buildWordstatSeedMap(wordstat); + const ontologyReady = isOntologyReadyForMarket(projectOntologyVersion); - return analysis.seedCandidates.slice(0, 80).map((seed) => { + return normalization.seedStrategy.filter((seed) => seed.review.sendToWordstat).slice(0, 120).map((seed) => { const targetProviders: MarketProviderId[] = ["wordstat"]; const result = wordstatResults.get(normalizePhrase(seed.phrase)); - const evidenceStatus = getSeedEvidenceStatus(providers, wordstat, result); + const evidenceStatus = ontologyReady ? getSeedEvidenceStatus(providers, wordstat, result) : "not_collected"; return { phrase: seed.phrase, @@ -219,24 +296,44 @@ function buildSeedQueue(analysis: SemanticAnalysisRun, providers: MarketProvider clusterTitle: seed.clusterTitle, priority: seed.priority, source: seed.source, + normalization: { + confidence: seed.confidence, + intentGroupId: seed.intentGroupId, + intentType: seed.intentType, + marketRole: seed.marketRole, + needsHumanReview: seed.needsHumanReview, + normalizedFrom: seed.normalizedFrom, + reviewReason: seed.review.reason, + reviewStatus: seed.review.status, + sendToSerp: seed.review.sendToSerp + }, evidenceStatus, targetProviders, blocker: getSeedBlocker(evidenceStatus), frequency: result?.frequency ?? null, frequencyGroup: result?.frequencyGroup ?? null, relatedCount: result?.relatedCount ?? 0, - lastCollectedAt: result?.collectedAt ?? null + lastCollectedAt: result?.collectedAt ?? null, + projectOntologyVersionId: projectOntologyVersion?.id ?? null, + wordstatResultId: result?.id ?? null }; }); } -function buildBriefEvidence(analysis: SemanticAnalysisRun, providers: MarketProviderDescriptor[]) { +function buildBriefEvidence( + analysis: SemanticAnalysisRun, + providers: MarketProviderDescriptor[], + projectOntologyVersion: MarketProjectOntologyVersion | null +) { + const ontologyReady = isOntologyReadyForMarket(projectOntologyVersion); + return (analysis.landingPageBriefs ?? []).slice(0, 80).map((brief) => { const requiredProviders: MarketProviderId[] = ["wordstat", "serp"]; - const evidenceStatus = getEvidenceStatus(providers, requiredProviders); + const evidenceStatus = ontologyReady ? getEvidenceStatus(providers, requiredProviders) : "not_collected"; return { path: brief.path, + clusterIds: brief.clusters.map((cluster) => cluster.clusterId), priority: brief.priority, action: brief.action, qualityScore: brief.qualityContract?.score ?? null, @@ -248,7 +345,8 @@ function buildBriefEvidence(analysis: SemanticAnalysisRun, providers: MarketProv "Wordstat частотность и сезонность", "SERP-конкуренты и типы страниц", "Региональная выдача" - ] + ], + projectOntologyVersionId: projectOntologyVersion?.id ?? null }; }); } @@ -257,12 +355,17 @@ function getEnrichmentState( analysis: SemanticAnalysisRun | null, providers: MarketProviderDescriptor[], seedCount: number, - wordstat: WordstatEvidence + wordstat: WordstatEvidence, + projectOntologyVersion: MarketProjectOntologyVersion | null ): MarketEnrichmentState { if (!analysis) { return "not_ready"; } + if (!isOntologyReadyForMarket(projectOntologyVersion)) { + return "not_ready"; + } + if (seedCount === 0) { return "not_ready"; } @@ -284,21 +387,45 @@ function getEnrichmentState( function buildNextActions(contract: Omit<MarketEnrichmentContract, "nextActions">) { const actions: string[] = []; + const marketSignalCount = + (contract.wordstat.summary.exactFrequencySeedCount ?? 0) + (contract.wordstat.summary.relatedOnlySeedCount ?? 0); if (!contract.semanticRunId) { actions.push("Сначала запустить semantic analysis: без seed candidates market layer не стартует."); return actions; } + if (!contract.projectOntologyVersion) { + actions.push("Сначала сохранить project ontology version из semantic analysis: market evidence должен быть привязан к версии онтологии."); + return actions; + } + + if (!contract.readiness.ontologyReadyForMarket) { + actions.push( + `Перевести project ontology v${contract.projectOntologyVersion.version} в needs review или approved и убрать problem review items перед сбором рынка.` + ); + return actions; + } + const wordstat = contract.providers.find((provider) => provider.id === "wordstat"); const serp = contract.providers.find((provider) => provider.id === "serp"); if (wordstat?.status !== "connected") { actions.push("Настроить платформенный Wordstat provider: MCP-KV endpoint или прямой API adapter на backend."); } else if (contract.wordstat.latestJob?.status === "done") { - actions.push( - `Wordstat evidence собрано: ${contract.wordstat.summary.collectedSeedCount} seed-фраз, ${contract.wordstat.summary.resultCount} rows. Следующий слой — keywordService cleaning.` - ); + if (contract.wordstat.summary.collectedSeedCount < contract.readiness.seedCount) { + actions.push( + `Approved Wordstat queue собран не полностью: ${contract.wordstat.summary.collectedSeedCount}/${contract.readiness.seedCount}. Запустить сбор по оставшимся фразам.` + ); + } else if (marketSignalCount > 0) { + actions.push( + `Wordstat evidence собрано по approved queue: ${marketSignalCount}/${contract.readiness.seedCount} фраз дали спрос. Следующий слой — keywordService cleaning.` + ); + } else { + actions.push( + "Approved Wordstat queue собран, но спрос не подтверждён. До SEO-плана нужен model/human review спорных фраз и расширение seed strategy." + ); + } } else if (contract.wordstat.latestJob?.status === "failed") { actions.push("Проверить Wordstat provider: последний job завершился ошибкой, raw/error сохранены в dev-mode contract."); } else if (contract.readiness.seedCount > 0) { @@ -320,23 +447,33 @@ function buildNextActions(contract: Omit<MarketEnrichmentContract, "nextActions" export async function getMarketEnrichmentContract(projectId: string): Promise<MarketEnrichmentContract> { const analysis = await getLatestSemanticAnalysis(projectId); + const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis)); const providers = await buildProviderRegistry(); - const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null); - const seedQueue = analysis ? buildSeedQueue(analysis, providers, wordstat) : []; - const briefEvidence = analysis ? buildBriefEvidence(analysis, providers) : []; + const normalization = await getSeoNormalizationContract(projectId); + const approvedWordstatPhrases = normalization.seedStrategy + .filter((seed) => seed.review.sendToWordstat) + .map((seed) => seed.phrase); + const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null, approvedWordstatPhrases); + const seedQueue = analysis ? buildSeedQueue(normalization, providers, wordstat, projectOntologyVersion) : []; + const briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : []; const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length; + const ontologyReadyForMarket = isOntologyReadyForMarket(projectOntologyVersion); const contractWithoutActions: Omit<MarketEnrichmentContract, "nextActions"> = { schemaVersion: "market-enrichment.v1", projectId, semanticRunId: analysis?.runId ?? null, + projectOntologyVersion, generatedAt: new Date().toISOString(), - state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat), + state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat, projectOntologyVersion), + normalization, readiness: { canCollectWordstat: + ontologyReadyForMarket && getProviderStatus(providers, "wordstat") === "connected" && seedQueue.length > 0 && wordstat.latestJob?.status !== "running", - canCollectSerp: getProviderStatus(providers, "serp") === "connected" && briefEvidence.length > 0, + canCollectSerp: ontologyReadyForMarket && getProviderStatus(providers, "serp") === "connected" && briefEvidence.length > 0, + ontologyReadyForMarket, seedCount: seedQueue.length, briefCount: briefEvidence.length, configuredProviderCount, @@ -356,14 +493,32 @@ export async function getMarketEnrichmentContract(projectId: string): Promise<Ma export async function runMarketEnrichment(projectId: string): Promise<MarketEnrichmentContract> { const analysis = await getLatestSemanticAnalysis(projectId); + const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis)); const providers = await buildProviderRegistry(); + const normalization = await getSeoNormalizationContract(projectId); + const normalizedSeeds: WordstatSeedCandidate[] = normalization.seedStrategy + .filter((seed) => seed.review.sendToWordstat) + .map((seed) => ({ + clusterId: seed.clusterId, + clusterTitle: seed.clusterTitle, + confidence: seed.confidence, + phrase: seed.phrase, + priority: seed.priority, + reason: seed.reason, + source: seed.source + })); - if (!analysis || getProviderStatus(providers, "wordstat") !== "connected" || analysis.seedCandidates.length === 0) { + if ( + !analysis || + !isOntologyReadyForMarket(projectOntologyVersion) || + getProviderStatus(providers, "wordstat") !== "connected" || + normalizedSeeds.length === 0 + ) { return getMarketEnrichmentContract(projectId); } try { - await collectWordstatEvidence(projectId, analysis); + await collectWordstatEvidence(projectId, analysis, normalizedSeeds); } catch (error) { console.error(error); } diff --git a/seo_mode/seo_mode/server/src/market/wordstatRepository.ts b/seo_mode/seo_mode/server/src/market/wordstatRepository.ts index 249dd25..ddd730c 100644 --- a/seo_mode/seo_mode/server/src/market/wordstatRepository.ts +++ b/seo_mode/seo_mode/server/src/market/wordstatRepository.ts @@ -17,6 +17,16 @@ type SeedRow = { source: "detected" | "ontology" | string; }; +export type WordstatSeedCandidate = { + phrase: string; + clusterId: string; + clusterTitle: string; + source: "detected" | "ontology"; + reason: string; + priority: "high" | "medium" | "low"; + confidence?: number; +}; + type WordstatJobRow = { id: string; project_id: string; @@ -67,6 +77,7 @@ export type WordstatEvidence = { updatedAt: string; } | null; seedResults: Array<{ + id: string; seedId: string | null; phrase: string; sourcePhrase: string | null; @@ -77,6 +88,7 @@ export type WordstatEvidence = { collectedAt: string; }>; topResults: Array<{ + id: string; phrase: string; sourcePhrase: string | null; frequency: number | null; @@ -310,6 +322,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]) return { latestJob: job ? mapJob(job) : null, seedResults: seedRows.map((result) => ({ + id: result.id, seedId: result.seed_id, phrase: result.phrase, sourcePhrase: result.source_phrase, @@ -323,6 +336,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]) .sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0)) .slice(0, 20) .map((result) => ({ + id: result.id, phrase: result.phrase, sourcePhrase: result.source_phrase, frequency: result.frequency, @@ -347,10 +361,10 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]) }; } -async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun) { +async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun, seedCandidates: WordstatSeedCandidate[]) { const seedRows: SeedRow[] = []; - for (const seed of analysis.seedCandidates.slice(0, 80)) { + for (const seed of seedCandidates.slice(0, 80)) { const inserted = await pool.query<SeedRow>( ` insert into seeds ( @@ -376,7 +390,7 @@ async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun) seed.phrase, seed.source, seed.reason, - seed.priority === "high" ? 0.85 : seed.priority === "medium" ? 0.65 : 0.45, + seed.confidence ?? (seed.priority === "high" ? 0.85 : seed.priority === "medium" ? 0.65 : 0.45), seed.clusterId, seed.clusterTitle, seed.priority @@ -595,7 +609,11 @@ async function updateJobFailed(jobId: string, error: unknown) { ); } -export async function collectWordstatEvidence(projectId: string, analysis: SemanticAnalysisRun) { +export async function collectWordstatEvidence( + projectId: string, + analysis: SemanticAnalysisRun, + seedCandidates: WordstatSeedCandidate[] = analysis.seedCandidates +) { const provider = await createWordstatProvider(); const status = provider.getStatus(); @@ -603,7 +621,7 @@ export async function collectWordstatEvidence(projectId: string, analysis: Seman throw new WordstatProviderUnavailableError(status.message); } - const seedRows = await ensureSeedRows(projectId, analysis); + const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates); const seeds = seedRows.map(toWordstatSeed); const region = "ru"; const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds); @@ -631,7 +649,11 @@ export async function collectWordstatEvidence(projectId: string, analysis: Seman return getLatestWordstatEvidence(projectId, analysis.runId); } -export async function getLatestWordstatEvidence(projectId: string, semanticRunId: string | null): Promise<WordstatEvidence> { +export async function getLatestWordstatEvidence( + projectId: string, + semanticRunId: string | null, + scopePhrases?: string[] +): Promise<WordstatEvidence> { if (!semanticRunId) { return buildEvidence(null, []); } @@ -689,5 +711,22 @@ export async function getLatestWordstatEvidence(projectId: string, semanticRunId [latestJob.id] ); - return buildEvidence(latestJob, results.rows); + if (!scopePhrases || scopePhrases.length === 0) { + return buildEvidence(latestJob, results.rows); + } + + const scopePhraseSet = new Set(scopePhrases.map(normalizePhrase)); + const scopedResults = results.rows.filter((row) => { + const sourcePhrase = row.source_phrase ? normalizePhrase(row.source_phrase) : null; + const phrase = normalizePhrase(row.phrase); + + return (sourcePhrase ? scopePhraseSet.has(sourcePhrase) : false) || scopePhraseSet.has(phrase); + }); + const scopedJob: WordstatJobRow = { + ...latestJob, + requested_phrases: latestJob.requested_phrases.filter((phrase) => scopePhraseSet.has(normalizePhrase(phrase))), + result_count: scopedResults.length + }; + + return buildEvidence(scopedJob, scopedResults); } diff --git a/seo_mode/seo_mode/server/src/modelContext/seoModelContext.ts b/seo_mode/seo_mode/server/src/modelContext/seoModelContext.ts new file mode 100644 index 0000000..6edc249 --- /dev/null +++ b/seo_mode/seo_mode/server/src/modelContext/seoModelContext.ts @@ -0,0 +1,370 @@ +import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js"; +import { getLatestAlignedSeoContextReview } from "../contextReview/seoContextReview.js"; +import { buildSerpInterpretationModelTask, type SerpInterpretationModelTaskContract } from "../evidence/serpInterpretationTask.js"; +import { getLatestYandexEvidenceContract } from "../evidence/yandexEvidence.js"; +import { + getSeoModelProviderCatalog, + type SeoModelProviderCatalog +} from "../modelProvider/modelProviderRegistry.js"; +import { getKeywordCleaningContract, type KeywordCleaningModelTaskContract } from "../keywords/keywordCleaning.js"; +import { getSeoNormalizationContract, type SeoNormalizationModelTaskContract } from "../normalization/seoNormalization.js"; +import { getLatestSerpInterpretationContract } from "../evidence/serpInterpretation.js"; +import { getSeoStrategyContract } from "../strategy/seoStrategy.js"; +import { buildStrategySynthesisModelTask, type StrategySynthesisModelTaskContract } from "../strategy/strategySynthesisTask.js"; +import { getLatestStrategySynthesisContract } from "../strategy/strategySynthesis.js"; +import { + buildStrategyQualityReviewModelTask, + type StrategyQualityReviewModelTaskContract +} from "../strategy/strategyQualityReviewTask.js"; +import { + buildSeoContextReviewTask, + getSeoSkillPackContract, + type SeoContextReviewModelTaskContract, + type SeoSkillPackContract +} from "../skills/seoSkillRegistry.js"; + +export type SeoModelTaskContract = + | KeywordCleaningModelTaskContract + | SeoContextReviewModelTaskContract + | SeoNormalizationModelTaskContract + | SerpInterpretationModelTaskContract + | StrategyQualityReviewModelTaskContract + | StrategySynthesisModelTaskContract; + +type SeoModelCapability = { + id: string; + writes: boolean; + description: string; +}; + +export type SeoModelContextContract = { + schemaVersion: "seo-model-context.v1"; + projectId: string; + generatedAt: string; + sourceAccess: { + mode: "contract_only"; + repositorySourceAvailable: false; + allowedEvidence: string[]; + forbiddenAccess: string[]; + }; + project: { + semanticRunId: string; + scanVersionId: string; + projectIndexKey: string | null; + ontology: { + id: string; + versionId: string | null; + version: number | null; + source: string; + brandName: string; + domainPackageId: string; + domainPackageVersion: string; + }; + } | null; + stageReadiness: { + semanticContextReady: boolean; + contextReviewTaskReady: boolean; + contextReviewResultReady: boolean; + normalizationState: "not_ready" | "ready"; + normalizationTaskReady: boolean; + keywordCleaningState: "not_ready" | "ready"; + keywordCleaningTaskReady: boolean; + serpInterpretationTaskReady: boolean; + strategySynthesisTaskReady: boolean; + strategyQualityReviewTaskReady: boolean; + modelTaskReady: boolean; + canRunMarketCollection: boolean; + }; + skills: SeoSkillPackContract; + modelProvider: SeoModelProviderCatalog; + contextReview: { + runId: string; + providerMode: "codex_manual" | "deterministic_fallback"; + generatedAt: string; + needsHumanReview: boolean; + summary: string; + forbiddenClaims: string[]; + normalizationHints: Array<{ + clusterId: string; + title: string; + reviewRequired: boolean; + sourcePhrases: string[]; + stopPhrases: string[]; + }>; + } | null; + context: { + scope: { + indexablePages: number; + selectedIndexablePages: number; + contentSources: number; + templateBlocks: number; + adminPages: number; + technicalPages: number; + }; + style: { + language: string; + topTerms: string[]; + brandTerms: string[]; + ctaPhrases: string[]; + toneSignals: string[]; + }; + semanticClusters: Array<{ + id: string; + title: string; + priority: "high" | "medium" | "low"; + status: "covered" | "weak" | "missing"; + targetIntent: string; + matchedTerms: string[]; + queryExamples: string[]; + evidenceLevel: "none" | "thin" | "moderate" | "strong"; + sourceRefs: Array<{ + title: string; + sourcePath: string; + urlPath: string | null; + scope: string; + }>; + }>; + landingPagePlan: Array<{ + path: string; + pageType: string; + action: "create" | "strengthen" | "validate"; + priority: "high" | "medium" | "low"; + seedPhrases: string[]; + clusterTitles: string[]; + }>; + } | null; + modelTasks: SeoModelTaskContract[]; + capabilities: SeoModelCapability[]; + guardrails: string[]; + nextActions: string[]; +}; + +function uniqueCapabilities(capabilities: SeoModelCapability[]) { + const seen = new Set<string>(); + + return capabilities.filter((capability) => { + if (seen.has(capability.id)) { + return false; + } + + seen.add(capability.id); + return true; + }); +} + +export async function getSeoModelContextContract(projectId: string): Promise<SeoModelContextContract> { + const [analysis, normalization, keywordCleaning, yandexEvidence, strategy, serpInterpretation, strategySynthesis] = await Promise.all([ + getLatestSemanticAnalysis(projectId), + getSeoNormalizationContract(projectId), + getKeywordCleaningContract(projectId), + getLatestYandexEvidenceContract(projectId), + getSeoStrategyContract(projectId), + getLatestSerpInterpretationContract(projectId), + getLatestStrategySynthesisContract(projectId) + ]); + const skills = getSeoSkillPackContract(); + const modelProvider = getSeoModelProviderCatalog(); + const contextReview = analysis ? await getLatestAlignedSeoContextReview(projectId, analysis.runId) : null; + const contextReviewTask = analysis ? buildSeoContextReviewTask(projectId, analysis) : null; + const serpInterpretationTask = buildSerpInterpretationModelTask(projectId, yandexEvidence); + const strategySynthesisTask = buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation); + const strategyQualityReviewTask = buildStrategyQualityReviewModelTask( + projectId, + strategy, + strategySynthesis, + serpInterpretation + ); + const modelTasks = [ + contextReviewTask, + normalization.modelTask, + keywordCleaning.modelTask, + serpInterpretationTask, + strategySynthesisTask, + strategyQualityReviewTask + ].filter( + (task): task is SeoModelTaskContract => Boolean(task) + ); + const capabilities = uniqueCapabilities([ + ...skills.skills.flatMap((skill) => skill.mcpCapabilities), + { + id: "seo.ontology.delta.propose", + writes: false, + description: "Предложить project ontology delta без изменения approved ontology." + }, + { + id: "seo.keyword-map.review", + writes: false, + description: "Проверить будущие keyword/page/field decisions перед human approval." + }, + { + id: "seo.yandex-evidence.read", + writes: false, + description: "Читать сохранённые Wordstat/SERP facts для AI-интерпретации без внешних вызовов." + }, + { + id: "seo.strategy_synthesis.propose", + writes: false, + description: "Синтезировать варианты SEO-стратегии из готовых evidence contracts без rewrite/apply." + }, + { + id: "seo.strategy_quality_review.gate", + writes: false, + description: "Проверить зрелость SEO-стратегии, phase guardrails, evidence refs и hallucination risks без rewrite/apply." + } + ]); + + return { + schemaVersion: "seo-model-context.v1", + projectId, + generatedAt: new Date().toISOString(), + sourceAccess: { + mode: "contract_only", + repositorySourceAvailable: false, + allowedEvidence: [ + "latest scan metadata", + "project ontology summary", + "active SEO skill pack contracts", + "context review model task", + "semantic clusters and source refs", + "normalization model task", + "keyword cleaning model task", + "yandex evidence contract", + "serp interpretation model task", + "strategy synthesis model task", + "strategy quality review model task", + "keyword cleaning lanes and evidence refs", + "landing page plan" + ], + forbiddenAccess: [ + "repository source files", + "unscoped local filesystem paths", + "unapproved business expansion claims", + "direct rewrite/apply without human approval" + ] + }, + project: analysis + ? { + semanticRunId: analysis.runId, + scanVersionId: analysis.scanVersionId, + projectIndexKey: analysis.projectIndexKey, + ontology: { + id: analysis.projectOntology.id, + versionId: analysis.projectOntology.versionId, + version: analysis.projectOntology.version, + source: analysis.projectOntology.source, + brandName: analysis.projectOntology.brandName, + domainPackageId: analysis.projectOntology.domainPackage.id, + domainPackageVersion: analysis.projectOntology.domainPackage.version + } + } + : null, + stageReadiness: { + semanticContextReady: Boolean(analysis), + contextReviewTaskReady: Boolean(contextReviewTask), + contextReviewResultReady: Boolean(contextReview), + normalizationState: normalization.state, + normalizationTaskReady: Boolean(normalization.modelTask), + keywordCleaningState: keywordCleaning.state, + keywordCleaningTaskReady: Boolean(keywordCleaning.modelTask), + serpInterpretationTaskReady: Boolean(serpInterpretationTask), + strategySynthesisTaskReady: Boolean(strategySynthesisTask), + strategyQualityReviewTaskReady: Boolean(strategyQualityReviewTask), + modelTaskReady: modelTasks.length > 0, + canRunMarketCollection: normalization.seedStrategy.some((seed) => seed.review.sendToWordstat) + }, + skills, + modelProvider, + contextReview: contextReview + ? { + runId: contextReview.runId, + providerMode: contextReview.provider.mode, + generatedAt: contextReview.generatedAt, + needsHumanReview: contextReview.needsHumanReview, + summary: contextReview.summary, + forbiddenClaims: contextReview.forbiddenClaims.map((claim) => claim.claim).slice(0, 20), + normalizationHints: contextReview.normalizationHints.slice(0, 30).map((hint) => ({ + clusterId: hint.clusterId, + reviewRequired: hint.reviewRequired, + sourcePhrases: hint.sourcePhrases.slice(0, 10), + stopPhrases: hint.stopPhrases.slice(0, 8), + title: hint.title + })) + } + : null, + context: analysis + ? { + scope: { + indexablePages: analysis.pageScope.indexablePages, + selectedIndexablePages: analysis.pageScope.selectedIndexablePages, + contentSources: analysis.pageScope.contentSources, + templateBlocks: analysis.pageScope.templateBlocks, + adminPages: analysis.pageScope.adminPages, + technicalPages: analysis.pageScope.technicalPages + }, + style: { + language: analysis.styleProfile.language, + topTerms: analysis.styleProfile.topTerms.slice(0, 20).map((term) => term.term), + brandTerms: analysis.styleProfile.brandTerms, + ctaPhrases: analysis.styleProfile.ctaPhrases, + toneSignals: analysis.styleProfile.toneSignals + }, + semanticClusters: analysis.clusters.slice(0, 30).map((cluster) => ({ + id: cluster.id, + title: cluster.title, + priority: cluster.priority, + status: cluster.status, + targetIntent: cluster.targetIntent, + matchedTerms: cluster.matchedTerms.slice(0, 12), + queryExamples: cluster.queryExamples.slice(0, 8), + evidenceLevel: cluster.evidence.level, + sourceRefs: cluster.sourceRefs.slice(0, 5).map((ref) => ({ + title: ref.title, + sourcePath: ref.sourcePath, + urlPath: ref.urlPath, + scope: ref.scope + })) + })), + landingPagePlan: analysis.landingPagePlan.slice(0, 30).map((page) => ({ + path: page.path, + pageType: page.pageType, + action: page.action, + priority: page.priority, + seedPhrases: page.seedPhrases.slice(0, 8), + clusterTitles: page.clusters.slice(0, 6).map((cluster) => cluster.title) + })) + } + : null, + modelTasks, + capabilities, + guardrails: [ + "Модель работает только по contract evidence и не предполагает доступ к исходникам сайта или SEO-модуля.", + "Активный SEO Skill Pack определяет allowed actions, output schema, blockers и forbidden actions для каждого AI task.", + "Context Review запускается до рыночного спроса и нужен для forbidden claims, ambiguity и normalization hints.", + "Любой соседний рынок или business expansion должен возвращаться как proposal, а не как auto-applied rewrite.", + "Wordstat/SERP маршрутизация допускается только для фраз с source evidence, approved ontology или human review.", + "SERP interpretation работает только по сохранённому yandex-evidence.v1 и не придумывает конкурентов или спрос.", + "Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.", + "Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.", + "Keyword map получает только keyword-cleaning use/support lanes; risky/trash не становятся approved decisions.", + "Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval." + ], + nextActions: !analysis + ? ["Запустить scan и semantic analysis, затем пересобрать AI/MCP context."] + : contextReview + ? [ + "Передать сохраненный Context Review + normalization/keyword-cleaning tasks в ModelProvider и сравнить ответ с deterministic fallback.", + "Пока реальный provider не подключен, проверять task route через contract_dry_run.", + "Использовать forbiddenClaims и normalizationHints как вход seo.semantic_normalization перед Wordstat.", + "После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.", + "После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.", + "После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.", + "После Wordstat прогонять seo.keyword_cleaning и только use/support передавать в keyword map.", + "Не запускать external market collection без review/approval для needsHumanReview фраз." + ] + : [ + "Сохранить seo.context_review draft evidence через POST /projects/:projectId/context-review.", + "После context review пересобрать seo.semantic_normalization, чтобы forbiddenClaims и normalizationHints попали перед Wordstat.", + "Не запускать external market collection, пока context review missing." + ] + }; +} diff --git a/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts b/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts new file mode 100644 index 0000000..89b9ffa --- /dev/null +++ b/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts @@ -0,0 +1,263 @@ +import { z } from "zod"; + +export const SEO_MODEL_TASK_TYPES = [ + "seo.context_review", + "seo.normalization", + "seo.keyword_cleaning", + "seo.serp_interpretation", + "seo.strategy_synthesis", + "seo.strategy_quality_review" +] as const; +export const SEO_MODEL_PROVIDER_IDS = [ + "codex_manual", + "contract_dry_run", + "codex_workspace", + "openai_api", + "local_model", + "external_agent" +] as const; + +export type SeoModelTaskType = (typeof SEO_MODEL_TASK_TYPES)[number]; +export type SeoModelProviderId = (typeof SEO_MODEL_PROVIDER_IDS)[number]; +export type SeoModelProviderStatus = "active" | "not_configured" | "planned"; + +const ALL_SEO_MODEL_TASK_TYPES: SeoModelTaskType[] = [...SEO_MODEL_TASK_TYPES]; + +export type SeoModelProviderDescriptor = { + id: SeoModelProviderId; + status: SeoModelProviderStatus; + title: string; + mode: "api_model" | "dry_run" | "local_runtime" | "manual_dev_loop" | "mcp_agent" | "workspace_agent"; + summary: string; + allowedTaskTypes: SeoModelTaskType[]; + requiresSecrets: boolean; + secretNames: string[]; + capabilities: { + structuredJson: boolean; + toolUse: boolean; + streaming: boolean; + contextWindowTokens: number | null; + rateLimitPolicy: string; + costPolicy: string; + supportsCancel: boolean; + }; + guardrails: string[]; + nextSetupStep: string; +}; + +export type SeoModelProviderCatalog = { + schemaVersion: "seo-model-provider-catalog.v1"; + generatedAt: string; + defaultProviderId: SeoModelProviderId; + providers: SeoModelProviderDescriptor[]; +}; + +const SeoModelProviderDescriptorSchema = z.object({ + id: z.enum(SEO_MODEL_PROVIDER_IDS), + status: z.enum(["active", "not_configured", "planned"]), + title: z.string().min(1), + mode: z.enum(["api_model", "dry_run", "local_runtime", "manual_dev_loop", "mcp_agent", "workspace_agent"]), + summary: z.string().min(1), + allowedTaskTypes: z.array(z.enum(SEO_MODEL_TASK_TYPES)).min(1), + requiresSecrets: z.boolean(), + secretNames: z.array(z.string().min(1)), + capabilities: z.object({ + structuredJson: z.boolean(), + toolUse: z.boolean(), + streaming: z.boolean(), + contextWindowTokens: z.number().int().positive().nullable(), + rateLimitPolicy: z.string().min(1), + costPolicy: z.string().min(1), + supportsCancel: z.boolean() + }), + guardrails: z.array(z.string().min(1)), + nextSetupStep: z.string().min(1) +}); + +const SeoModelProviderCatalogSchema = z.object({ + schemaVersion: z.literal("seo-model-provider-catalog.v1"), + generatedAt: z.string(), + defaultProviderId: z.enum(SEO_MODEL_PROVIDER_IDS), + providers: z.array(SeoModelProviderDescriptorSchema).min(1) +}); + +const PROVIDERS: SeoModelProviderDescriptor[] = [ + { + id: "codex_manual", + status: "active", + title: "Codex manual dev-loop", + mode: "manual_dev_loop", + summary: + "Dev-mode route: backend выдаёт SEO task contract, текущий Codex-сеанс выполняет анализ по правилам и сохраняет результат как evidence без внешнего model adapter.", + allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES, + requiresSecrets: false, + secretNames: [], + capabilities: { + structuredJson: true, + toolUse: false, + streaming: false, + contextWindowTokens: null, + rateLimitPolicy: "manual Codex session", + costPolicy: "current dev session", + supportsCancel: false + }, + guardrails: [ + "Backend не вызывает модель автоматически.", + "Codex-result сохраняется только через explicit manual evidence endpoint.", + "После сохранения нужен отдельный analyst review pass перед market/rewrite decisions." + ], + nextSetupStep: + "Прогнать seo.context_review, seo.normalization, seo.keyword_cleaning, seo.serp_interpretation, seo.strategy_synthesis и seo.strategy_quality_review manual results, затем проверять их как SEO-аналитик." + }, + { + id: "contract_dry_run", + status: "active", + title: "Contract dry-run", + mode: "dry_run", + summary: + "Проверяет task envelope, output schema, stop conditions и guardrails без вызова внешней модели. Нужен для dev-mode маршрута.", + allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES, + requiresSecrets: false, + secretNames: [], + capabilities: { + structuredJson: true, + toolUse: false, + streaming: false, + contextWindowTokens: null, + rateLimitPolicy: "no external calls", + costPolicy: "free local validation", + supportsCancel: false + }, + guardrails: [ + "Не генерирует SEO-решения и не заменяет model review.", + "Не имеет доступа к исходникам сайта и работает только с task contract.", + "Не пишет rewrite/apply decisions." + ], + nextSetupStep: "Подключить первый реальный adapter и прогнать его через тот же seo model task run contract." + }, + { + id: "codex_workspace", + status: "not_configured", + title: "Codex Workspace adapter", + mode: "workspace_agent", + summary: + "Будущий adapter для dev-mode/model-review: модель получает только SEO task contract и возвращает structured JSON.", + allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES, + requiresSecrets: false, + secretNames: [], + capabilities: { + structuredJson: true, + toolUse: true, + streaming: false, + contextWindowTokens: null, + rateLimitPolicy: "workspace limits", + costPolicy: "depends on workspace provider", + supportsCancel: true + }, + guardrails: [ + "Workspace agent не должен читать репозиторий напрямую для пользовательских сайтов.", + "Все filesystem/tool calls должны идти через approved MCP capabilities.", + "Ответ должен проходить schema validation перед записью результата." + ], + nextSetupStep: "Описать handoff contract между AI Workspace Hub/MCP и seo_mode backend." + }, + { + id: "openai_api", + status: "not_configured", + title: "OpenAI API adapter", + mode: "api_model", + summary: "Будущий production adapter для structured outputs, токен-бюджетов, retries и audit trail.", + allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES, + requiresSecrets: true, + secretNames: ["OPENAI_API_KEY"], + capabilities: { + structuredJson: true, + toolUse: true, + streaming: true, + contextWindowTokens: null, + rateLimitPolicy: "provider quota + project queue", + costPolicy: "token metering required", + supportsCancel: true + }, + guardrails: [ + "External provider получает только sanitized task contract.", + "Secrets не хранятся в project data.", + "Нужны retries, timeout, schema validation и cost logging." + ], + nextSetupStep: "Выбрать модель и добавить adapter после утверждения AI Layer архитектуры." + }, + { + id: "local_model", + status: "planned", + title: "Local model adapter", + mode: "local_runtime", + summary: "Опциональный adapter для on-prem/enterprise, где нельзя отправлять evidence во внешний API.", + allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES, + requiresSecrets: false, + secretNames: [], + capabilities: { + structuredJson: false, + toolUse: false, + streaming: false, + contextWindowTokens: null, + rateLimitPolicy: "local hardware", + costPolicy: "infrastructure cost", + supportsCancel: true + }, + guardrails: [ + "Local runtime должен подтверждать structured JSON compatibility.", + "Слабые модели нельзя допускать к rewrite/apply stages.", + "Нужен отдельный benchmark на SEO task contracts." + ], + nextSetupStep: "Собрать требования к on-prem deployment и quality benchmark." + }, + { + id: "external_agent", + status: "planned", + title: "External MCP agent", + mode: "mcp_agent", + summary: + "Будущий route для внешних SEO skills/tools через MCP, если конкретная задача лучше решается специализированным агентом.", + allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES, + requiresSecrets: true, + secretNames: ["MCP_AGENT_ENDPOINT", "MCP_AGENT_TOKEN"], + capabilities: { + structuredJson: true, + toolUse: true, + streaming: false, + contextWindowTokens: null, + rateLimitPolicy: "agent policy", + costPolicy: "agent policy", + supportsCancel: true + }, + guardrails: [ + "External agent не получает apply/write capabilities.", + "Tool output должен сохраняться как evidence, а не как approved decision.", + "Нужна allowlist external capabilities per project." + ], + nextSetupStep: "Спроектировать MCP allowlist и audit protocol для внешних SEO tools." + } +]; + +const PARSED_PROVIDERS = z.array(SeoModelProviderDescriptorSchema).parse(PROVIDERS); + +export function getDefaultSeoModelProviderId(): SeoModelProviderId { + return "codex_manual"; +} + +export function isSeoModelProviderId(value: string): value is SeoModelProviderId { + return SEO_MODEL_PROVIDER_IDS.includes(value as SeoModelProviderId); +} + +export function getSeoModelProviderDescriptor(providerId: SeoModelProviderId): SeoModelProviderDescriptor { + return PARSED_PROVIDERS.find((provider) => provider.id === providerId) ?? PARSED_PROVIDERS[0]; +} + +export function getSeoModelProviderCatalog(): SeoModelProviderCatalog { + return SeoModelProviderCatalogSchema.parse({ + schemaVersion: "seo-model-provider-catalog.v1", + generatedAt: new Date().toISOString(), + defaultProviderId: getDefaultSeoModelProviderId(), + providers: PARSED_PROVIDERS + }); +} diff --git a/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts b/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts new file mode 100644 index 0000000..1c3d9af --- /dev/null +++ b/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts @@ -0,0 +1,373 @@ +import { pool } from "../db/client.js"; +import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js"; +import { + getDefaultSeoModelProviderId, + getSeoModelProviderCatalog, + getSeoModelProviderDescriptor, + type SeoModelProviderCatalog, + type SeoModelProviderDescriptor, + type SeoModelProviderId, + type SeoModelTaskType +} from "./modelProviderRegistry.js"; + +type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; +type SeoModelTaskRunStatus = "contract_ready" | "provider_not_configured" | "task_not_found" | "validation_failed"; + +type SeoModelTaskRunRow = { + id: string; + status: RunStatus; + input: Record<string, unknown>; + output: SeoModelTaskRunOutput | null; + error_message: string | null; + started_at: Date | null; + completed_at: Date | null; + created_at: Date; +}; + +export type SeoModelTaskRunInput = { + providerId?: SeoModelProviderId; + taskId?: string; + taskType?: SeoModelTaskType; +}; + +export type SeoModelTaskRunOutput = { + schemaVersion: "seo-model-task-run.v1"; + projectId: string; + generatedAt: string; + provider: Pick<SeoModelProviderDescriptor, "id" | "mode" | "status" | "title">; + task: { + taskId: string | null; + taskType: SeoModelTaskType | null; + schemaVersion: string | null; + semanticRunId: string | null; + activeSkillId: string | null; + providerMode: string | null; + outputSchemaVersion: string | null; + requiredTopLevelKeys: string[]; + allowedActionCount: number; + stopConditionCount: number; + sourceContextVersion: "seo-model-context.v1"; + }; + status: SeoModelTaskRunStatus; + validation: { + ok: boolean; + errors: string[]; + warnings: string[]; + notes: string[]; + }; + estimate: { + inputChars: number; + inputTokenEstimate: number; + outputTokenBudget: number; + }; + modelOutput: null; + persistedResult: null; + nextActions: string[]; +}; + +export type SeoModelTaskRun = SeoModelTaskRunOutput & { + runId: string; + runStatus: RunStatus; + input: Record<string, unknown>; + startedAt: string | null; + completedAt: string | null; + errorMessage: string | null; + createdAt: string; +}; + +export type SeoModelProviderStatusContract = { + schemaVersion: "seo-model-provider-status.v1"; + projectId: string; + generatedAt: string; + catalog: SeoModelProviderCatalog; + latestRuns: SeoModelTaskRun[]; +}; + +function toIsoDate(value: Date | null) { + return value ? value.toISOString() : null; +} + +function mapSeoModelTaskRun(row: SeoModelTaskRunRow): SeoModelTaskRun | null { + if (!row.output) { + return null; + } + + return { + runId: row.id, + runStatus: row.status, + input: row.input, + startedAt: toIsoDate(row.started_at), + completedAt: toIsoDate(row.completed_at), + errorMessage: row.error_message, + createdAt: row.created_at.toISOString(), + ...row.output + }; +} + +function getTaskSemanticRunId(task: SeoModelTaskContract | null) { + if (!task) { + return null; + } + + return "semanticRunId" in task ? task.semanticRunId : null; +} + +function getTaskActiveSkillId(task: SeoModelTaskContract | null) { + if (!task) { + return null; + } + + return "activeSkillId" in task ? task.activeSkillId : null; +} + +function getTaskOutputSchema(task: SeoModelTaskContract | null) { + if (!task) { + return null; + } + + return task.outputSchema; +} + +function getOutputTokenBudget(task: SeoModelTaskContract | null) { + if (!task) { + return 0; + } + + if (task.taskType === "seo.context_review") { + return 2600; + } + + if (task.taskType === "seo.keyword_cleaning") { + return 2400; + } + + if (task.taskType === "seo.serp_interpretation") { + return 2800; + } + + if (task.taskType === "seo.strategy_synthesis") { + return 3200; + } + + if (task.taskType === "seo.strategy_quality_review") { + return 2600; + } + + return 2200; +} + +function findTask(tasks: SeoModelTaskContract[], input: SeoModelTaskRunInput) { + if (input.taskId) { + return tasks.find((task) => task.taskId === input.taskId) ?? null; + } + + if (input.taskType) { + return tasks.find((task) => task.taskType === input.taskType) ?? null; + } + + return null; +} + +function validateTaskContract(task: SeoModelTaskContract | null, provider: SeoModelProviderDescriptor) { + const errors: string[] = []; + const warnings: string[] = []; + const notes: string[] = []; + + if (!task) { + return { + ok: false, + errors: ["Model task не найден в seo-model-context."], + notes, + warnings + }; + } + + if (!provider.allowedTaskTypes.includes(task.taskType)) { + errors.push(`Provider ${provider.id} не разрешен для ${task.taskType}.`); + } + + if (task.providerMode !== "model_provider_contract") { + errors.push("Task providerMode должен быть model_provider_contract."); + } + + if (!task.taskId) { + errors.push("Task должен иметь taskId."); + } + + if (!task.schemaVersion) { + errors.push("Task должен иметь schemaVersion."); + } + + if (!task.allowedActions.length) { + errors.push("Task должен перечислять allowedActions."); + } + + if (!task.stopConditions.length) { + errors.push("Task должен перечислять stopConditions."); + } + + if (!task.outputSchema.schemaVersion) { + errors.push("Task должен указывать outputSchema.schemaVersion."); + } + + if (!task.outputSchema.requiredTopLevelKeys.length) { + errors.push("Task должен указывать outputSchema.requiredTopLevelKeys."); + } + + if (provider.status !== "active") { + warnings.push(`Provider ${provider.id} пока ${provider.status}; боевой model call не выполняется.`); + } + + notes.push("Dry-run проверяет только provider/task contract, а не качество будущего model output."); + notes.push("Результат модели должен сохраняться отдельным evidence run и проходить schema validation."); + + return { + ok: errors.length === 0, + errors, + notes, + warnings + }; +} + +function buildTaskSummary( + contextSchemaVersion: "seo-model-context.v1", + task: SeoModelTaskContract | null +): SeoModelTaskRunOutput["task"] { + const outputSchema = getTaskOutputSchema(task); + + return { + taskId: task?.taskId ?? null, + taskType: task?.taskType ?? null, + schemaVersion: task?.schemaVersion ?? null, + semanticRunId: getTaskSemanticRunId(task), + activeSkillId: getTaskActiveSkillId(task), + providerMode: task?.providerMode ?? null, + outputSchemaVersion: outputSchema?.schemaVersion ?? null, + requiredTopLevelKeys: outputSchema?.requiredTopLevelKeys ?? [], + allowedActionCount: task?.allowedActions.length ?? 0, + stopConditionCount: task?.stopConditions.length ?? 0, + sourceContextVersion: contextSchemaVersion + }; +} + +function buildRunOutput( + projectId: string, + provider: SeoModelProviderDescriptor, + task: SeoModelTaskContract | null, + contextSchemaVersion: "seo-model-context.v1" +): SeoModelTaskRunOutput { + const validation = validateTaskContract(task, provider); + const inputChars = task ? JSON.stringify(task).length : 0; + const status: SeoModelTaskRunStatus = !task + ? "task_not_found" + : !validation.ok + ? "validation_failed" + : provider.status !== "active" + ? "provider_not_configured" + : "contract_ready"; + + return { + schemaVersion: "seo-model-task-run.v1", + projectId, + generatedAt: new Date().toISOString(), + provider: { + id: provider.id, + mode: provider.mode, + status: provider.status, + title: provider.title + }, + task: buildTaskSummary(contextSchemaVersion, task), + status, + validation, + estimate: { + inputChars, + inputTokenEstimate: Math.ceil(inputChars / 4), + outputTokenBudget: getOutputTokenBudget(task) + }, + modelOutput: null, + persistedResult: null, + nextActions: + status === "contract_ready" + ? [ + "Подключить реальный model adapter к этому же task contract.", + "Сохранять model output как evidence run только после schema validation.", + "Сравнивать model output с deterministic fallback перед human approval." + ] + : status === "provider_not_configured" + ? [provider.nextSetupStep, "Пока использовать contract_dry_run для проверки маршрута."] + : status === "task_not_found" + ? ["Сначала подготовить stage task contract: context_review, normalization, keyword_cleaning или serp_interpretation."] + : ["Исправить task contract validation errors перед вызовом модели."] + }; +} + +async function insertSeoModelTaskRun( + projectId: string, + input: Record<string, unknown>, + output: SeoModelTaskRunOutput +) { + const result = await pool.query<SeoModelTaskRunRow>( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_model_task', 'done', $2::jsonb, $3::jsonb, now(), now()) + returning id, status, input, output, error_message, started_at, completed_at, created_at + `, + [projectId, JSON.stringify(input), JSON.stringify(output)] + ); + + return mapSeoModelTaskRun(result.rows[0]); +} + +export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): Promise<SeoModelTaskRun[]> { + const result = await pool.query<SeoModelTaskRunRow>( + ` + select id, status, input, output, error_message, started_at, completed_at, created_at + from runs + where project_id = $1 and run_type = 'seo_model_task' + order by created_at desc + limit $2 + `, + [projectId, limit] + ); + + return result.rows.map(mapSeoModelTaskRun).filter((run): run is SeoModelTaskRun => Boolean(run)); +} + +export async function getSeoModelProviderStatusContract( + projectId: string +): Promise<SeoModelProviderStatusContract> { + const [catalog, latestRuns] = await Promise.all([getSeoModelProviderCatalog(), getLatestSeoModelTaskRuns(projectId)]); + + return { + schemaVersion: "seo-model-provider-status.v1", + projectId, + generatedAt: new Date().toISOString(), + catalog, + latestRuns + }; +} + +export async function runSeoModelTask(projectId: string, input: SeoModelTaskRunInput): Promise<SeoModelTaskRun> { + 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 run = await insertSeoModelTaskRun( + projectId, + { + schemaVersion: "seo-model-task-run-input.v1", + providerId, + requestedTaskId: input.taskId ?? null, + requestedTaskType: input.taskType ?? null, + sourceContextGeneratedAt: context.generatedAt + }, + output + ); + + if (!run) { + throw new Error("Не удалось сохранить seo model task run."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts b/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts new file mode 100644 index 0000000..57bd78a --- /dev/null +++ b/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts @@ -0,0 +1,1049 @@ +import { getLatestSemanticAnalysis, type SemanticAnalysisRun } from "../analysis/semanticAnalysis.js"; +import { + getLatestAlignedSeoContextReview, + type SeoContextReviewRun +} from "../contextReview/seoContextReview.js"; +import { pool } from "../db/client.js"; +import { getLatestProjectOntologyVersion } from "../ontology/projectOntologyRepository.js"; + +type SeoIntentType = + | "commercial" + | "deployment_security" + | "enterprise" + | "informational" + | "integration" + | "problem" + | "product" + | "support"; +type SeoMarketRole = "commercial" | "core" | "exclude" | "integration" | "problem" | "support"; +type SeoReviewStatus = "auto_approved" | "needs_human" | "rejected"; +type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; +type SeoNormalizationProviderMode = "codex_manual" | "deterministic_fallback"; + +type SeoNormalizationRunRow = { + id: string; + status: RunStatus; + input: Record<string, unknown>; + output: SeoNormalizationContract | null; + error_message: string | null; + started_at: Date | null; + completed_at: Date | null; + created_at: Date; +}; + +export type SeoNormalizationModelTaskContract = { + taskType: "seo.normalization"; + schemaVersion: "seo-normalization-task.v1"; + taskId: string; + projectId: string; + semanticRunId: string; + projectOntologyVersionId: string | null; + providerMode: "model_provider_contract"; + input: { + siteSummary: { + brandName: string; + language: SemanticAnalysisRun["styleProfile"]["language"]; + topTerms: string[]; + ctaPhrases: string[]; + }; + contextReview: { + status: "available" | "missing"; + runId: string | null; + needsHumanReview: boolean; + summary: string | null; + forbiddenClaims: string[]; + normalizationHints: Array<{ + clusterId: string; + title: string; + targetIntent: string; + sourcePhrases: string[]; + marketAngles: string[]; + stopPhrases: string[]; + reviewRequired: boolean; + }>; + }; + scopeSummary: { + indexablePages: number; + selectedIndexablePages: number; + contentSources: number; + templateBlocks: number; + adminPages: number; + technicalPages: number; + }; + evidenceRefs: Array<{ + id: string; + title: string; + sourcePath: string; + urlPath: string | null; + scope: string; + }>; + semanticClusters: Array<{ + id: string; + title: string; + priority: "high" | "medium" | "low"; + status: "covered" | "weak" | "missing"; + targetIntent: string; + matchedTerms: string[]; + queryExamples: string[]; + evidenceLevel: "none" | "thin" | "moderate" | "strong"; + }>; + sourceSeeds: Array<{ + phrase: string; + clusterId: string; + reason: string; + priority: "high" | "medium" | "low"; + }>; + forbiddenClaims: string[]; + }; + allowedActions: Array< + | "group_intents" + | "normalize_market_queries" + | "reject_noise" + | "mark_human_review" + | "route_wordstat" + | "route_serp" + >; + outputSchema: { + schemaVersion: "seo-normalization.v1"; + requiredTopLevelKeys: string[]; + seedRequiredKeys: string[]; + intentRequiredKeys: string[]; + }; + confidencePolicy: { + autoApproveMinConfidence: number; + humanReviewBelowConfidence: number; + rejectWhenOffBusiness: boolean; + }; + stopConditions: string[]; +}; + +export type SeoNormalizationSeed = { + id: string; + phrase: string; + normalizedFrom: string[]; + clusterId: string; + clusterTitle: string; + intentGroupId: string; + intentType: SeoIntentType; + marketRole: SeoMarketRole; + priority: "high" | "medium" | "low"; + source: "detected" | "ontology"; + reason: string; + confidence: number; + needsHumanReview: boolean; + review: { + status: SeoReviewStatus; + sendToWordstat: boolean; + sendToSerp: boolean; + reason: string; + blockers: string[]; + }; +}; + +export type SeoNormalizationContract = { + schemaVersion: "seo-normalization.v1"; + projectId: string; + semanticRunId: string | null; + projectOntologyVersionId: string | null; + generatedAt: string; + state: "not_ready" | "ready"; + provider: { + mode: SeoNormalizationProviderMode; + modelRequired: boolean; + message: string; + }; + sourceTaskId: string | null; + analystReview: { + status: "approved" | "needs_changes" | "not_reviewed" | "rejected"; + reviewer: "codex" | "human" | "system"; + reviewedAt: string | null; + notes: string[]; + }; + modelTask: SeoNormalizationModelTaskContract | null; + readiness: { + sourceSeedCount: number; + normalizedSeedCount: number; + intentGroupCount: number; + wordstatQueryCount: number; + wordstatApprovedCount: number; + serpApprovedCount: number; + rejectedSeedCount: number; + needsHumanReviewCount: number; + autoApprovedCount: number; + }; + summary: string; + intentGroups: Array<{ + id: string; + title: string; + intentType: SeoIntentType; + priority: "high" | "medium" | "low"; + marketHypothesis: string; + sourceClusterIds: string[]; + sourceRefs: Array<{ + title: string; + sourcePath: string; + urlPath: string | null; + scope: string; + }>; + corePhrases: string[]; + wordstatQueries: string[]; + excludedPhrases: Array<{ + phrase: string; + reason: string; + }>; + risks: string[]; + confidence: number; + }>; + seedStrategy: SeoNormalizationSeed[]; + rejectedSeeds: Array<{ + phrase: string; + clusterId: string; + clusterTitle: string; + reason: string; + suggestedReplacement: string | null; + }>; + nextActions: string[]; +}; + +const GENERIC_SINGLE_WORDS = new Set([ + "ai", + "bpm", + "workflow", + "автоматизация", + "платформа", + "роботизация", + "система", + "процессы", + "ассистент", + "агент", + "данные", + "задачи" +]); + +const LOW_VALUE_PHRASE_PARTS = ["ла-ла", "test", "demo text", "lorem", "undefined", "null", "интент"]; + +function normalizePhrase(value: string) { + const cleanValue = value + .trim() + .replace(/[“”«»"]/g, "") + .replace(/[|,;:…]+/g, " ") + .replace(/[\\/]+/g, " ") + .replace(/платформа\s+ai[-‑–—]?платформа/gi, "ai-платформа") + .replace(/платформа\s+платформа/gi, "платформа") + .replace(/^[a-zа-яё0-9_.-]{2,32}\s*(?:[-—–])\s+/i, "") + .replace(/\s+/g, " ") + .toLowerCase(); + const words = cleanValue.split(" ").filter(Boolean); + + if (words.length >= 2 && words[0] === words[1]) { + words.shift(); + } + + if (words.length >= 4 && `${words[0]} ${words[1]}` === `${words[2]} ${words[3]}`) { + words.splice(0, 2); + } + + return words.join(" "); +} + +function toId(value: string) { + return normalizePhrase(value) + .replace(/[^a-zа-яё0-9]+/gi, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); +} + +function unique(values: string[]) { + const seen = new Set<string>(); + const result: string[] = []; + + for (const value of values) { + const cleanValue = normalizePhrase(value); + + if (!cleanValue || seen.has(cleanValue)) { + continue; + } + + seen.add(cleanValue); + result.push(cleanValue); + } + + return result; +} + +function wordCount(value: string) { + return normalizePhrase(value).split(/\s+/).filter(Boolean).length; +} + +function getDefaultAnalystReview(): SeoNormalizationContract["analystReview"] { + return { + notes: [], + reviewedAt: null, + reviewer: "system", + status: "not_reviewed" + }; +} + +function mapSeoNormalizationRun(row: SeoNormalizationRunRow): SeoNormalizationContract | null { + if (!row.output) { + return null; + } + + return { + ...row.output, + analystReview: row.output.analystReview ?? getDefaultAnalystReview() + }; +} + +function normalizeSeoNormalizationOutput( + projectId: string, + output: SeoNormalizationContract, + providerMode: SeoNormalizationProviderMode, + providerMessage: string +): SeoNormalizationContract { + if (output.schemaVersion !== "seo-normalization.v1") { + throw new Error("SEO normalization output schemaVersion должен быть seo-normalization.v1."); + } + + if (output.projectId !== projectId) { + throw new Error("SEO normalization output projectId не совпадает с проектом."); + } + + if (!output.semanticRunId || !output.modelTask?.taskId) { + throw new Error("SEO normalization output должен иметь semanticRunId и modelTask.taskId."); + } + + if (!Array.isArray(output.intentGroups) || !Array.isArray(output.seedStrategy) || !Array.isArray(output.rejectedSeeds)) { + throw new Error("SEO normalization output должен иметь intentGroups, seedStrategy и rejectedSeeds."); + } + + const wordstatApprovedCount = output.seedStrategy.filter((seed) => seed.review.sendToWordstat).length; + const serpApprovedCount = output.seedStrategy.filter((seed) => seed.review.sendToSerp).length; + const needsHumanReviewCount = output.seedStrategy.filter((seed) => seed.review.status === "needs_human").length; + const autoApprovedCount = output.seedStrategy.filter((seed) => seed.review.status === "auto_approved").length; + const rejectedSeedCount = + output.rejectedSeeds.length + output.seedStrategy.filter((seed) => seed.review.status === "rejected").length; + + return { + ...output, + generatedAt: new Date().toISOString(), + provider: { + message: providerMessage, + mode: providerMode, + modelRequired: true + }, + sourceTaskId: output.modelTask.taskId, + analystReview: output.analystReview ?? getDefaultAnalystReview(), + readiness: { + ...output.readiness, + autoApprovedCount, + intentGroupCount: output.intentGroups.length, + needsHumanReviewCount, + normalizedSeedCount: output.seedStrategy.length, + rejectedSeedCount, + serpApprovedCount, + wordstatApprovedCount, + wordstatQueryCount: wordstatApprovedCount + }, + state: output.seedStrategy.length > 0 ? "ready" : "not_ready" + }; +} + +function isLowValuePhrase(value: string) { + const phrase = normalizePhrase(value); + + return LOW_VALUE_PHRASE_PARTS.some((part) => phrase.includes(part)); +} + +function isBroadSinglePhrase(value: string) { + const phrase = normalizePhrase(value); + + return wordCount(phrase) <= 1 && GENERIC_SINGLE_WORDS.has(phrase); +} + +function hasAny(value: string, terms: string[]) { + const phrase = normalizePhrase(value); + + return terms.some((term) => phrase.includes(term)); +} + +function inferIntentType(text: string): SeoIntentType { + if (hasAny(text, ["стоим", "цена", "тариф", "условия", "заявка", "консультация", "заказать", "купить"])) { + return "commercial"; + } + + if (hasAny(text, ["1c", "1с", "api", "интеграц", "crm", "erp", "webhook", "коннектор"])) { + return "integration"; + } + + if (hasAny(text, ["security", "безопас", "sso", "rbac", "private cloud", "on-prem", "deployment", "обла"])) { + return "deployment_security"; + } + + if (hasAny(text, ["enterprise", "корпоратив", "b2b", "private cloud", "on-prem", "масштабирован"])) { + return "enterprise"; + } + + if (hasAny(text, ["продукт", "сервис", "услуга", "каталог", "модуль", "раздел", "приложение", "ассистент", "агент", "ии", "ai"])) { + return "product"; + } + + if (hasAny(text, ["как", "что такое", "документ", "docs", "гайд", "инструкц"])) { + return "informational"; + } + + if (hasAny(text, ["автоматизац", "управлен", "контроль", "роботизац", "задач"])) { + return "problem"; + } + + return "support"; +} + +function getMarketRole(phrase: string, intentType: SeoIntentType): SeoMarketRole { + if (isLowValuePhrase(phrase) || isBroadSinglePhrase(phrase)) { + return "exclude"; + } + + if (intentType === "commercial") { + return "commercial"; + } + + if (intentType === "integration") { + return "integration"; + } + + if (intentType === "problem") { + return "problem"; + } + + if (intentType === "support" || intentType === "informational") { + return "support"; + } + + return "core"; +} + +function expandBroadPhrase(phrase: string, clusterTitle: string, siteTopTerms: string[]) { + const cleanPhrase = normalizePhrase(phrase); + const cluster = normalizePhrase(clusterTitle); + + if (cluster.includes(cleanPhrase) && wordCount(cluster) > 1) { + return [cluster]; + } + + const contextualTerms = unique(siteTopTerms) + .filter((term) => !GENERIC_SINGLE_WORDS.has(term) && wordCount(term) <= 3 && !term.includes(cleanPhrase)) + .slice(0, 3); + + return contextualTerms.map((term) => `${cleanPhrase} ${term}`); +} + +function getIntentTitle(intentType: SeoIntentType) { + const labels: Record<SeoIntentType, string> = { + commercial: "Коммерческий спрос", + deployment_security: "Развёртывание и безопасность", + enterprise: "Корпоративная платформа", + informational: "Информационный спрос", + integration: "Интеграции", + problem: "Проблемный спрос", + product: "Продуктовые запросы", + support: "Поддерживающие запросы" + }; + + return labels[intentType]; +} + +function getMarketHypothesis(intentType: SeoIntentType, clusterTitle: string) { + const title = normalizePhrase(clusterTitle); + + if (intentType === "commercial") { + return `Проверить коммерческие формулировки вокруг "${title}": условия, стоимость, заявка и следующий шаг.`; + } + + if (intentType === "integration") { + return `Проверить спрос на интеграционные формулировки вокруг "${title}" и смежные системы.`; + } + + if (intentType === "deployment_security") { + return `Проверить спрос на запуск, доступы, безопасность и ограничения вокруг "${title}".`; + } + + if (intentType === "enterprise") { + return `Проверить, как рынок ищет масштабируемые и корпоративные формулировки вокруг "${title}".`; + } + + if (intentType === "problem") { + return `Проверить проблемные формулировки пользователя вокруг "${title}" без выхода за контекст сайта.`; + } + + return `Проверить поисковые формулировки вокруг "${title}" без выхода за контекст проекта.`; +} + +function getClusterText(cluster: SemanticAnalysisRun["clusters"][number]) { + return [ + cluster.title, + cluster.targetIntent, + ...cluster.matchedTerms, + ...cluster.queryExamples, + ...cluster.targetPages + ].join(" "); +} + +function collectClusterPhrases( + analysis: SemanticAnalysisRun, + cluster: SemanticAnalysisRun["clusters"][number], + contextHint: SeoContextReviewRun["normalizationHints"][number] | undefined +) { + const seeds = analysis.seedCandidates.filter((seed) => seed.clusterId === cluster.id); + const briefs = (analysis.landingPageBriefs ?? []).filter((brief) => + brief.clusters.some((briefCluster) => briefCluster.clusterId === cluster.id) + ); + const briefPhrases = briefs.flatMap((brief) => [...brief.seedPhrases, ...brief.mustUseTerms]); + const safeHintPhrases = [ + ...(contextHint?.sourcePhrases ?? []), + ...(contextHint?.marketAngles ?? []).filter((angle) => wordCount(angle) <= 6) + ]; + + return unique([...seeds.map((seed) => seed.phrase), ...cluster.queryExamples, ...briefPhrases, ...safeHintPhrases]); +} + +function buildGroupQueries(phrases: string[], clusterTitle: string, siteTopTerms: string[], stopPhrases: string[]) { + const expanded: string[] = []; + const excluded: Array<{ phrase: string; reason: string }> = []; + const normalizedStopPhrases = new Set(stopPhrases.map(normalizePhrase)); + + for (const phrase of phrases) { + if (normalizedStopPhrases.has(normalizePhrase(phrase))) { + excluded.push({ + phrase, + reason: "Context Review пометил фразу как слабую или опасную для market routing без review." + }); + continue; + } + + if (isLowValuePhrase(phrase)) { + excluded.push({ + phrase, + reason: "Техническая или мусорная формулировка не должна идти в Wordstat." + }); + continue; + } + + if (isBroadSinglePhrase(phrase)) { + const contextualExpansions = expandBroadPhrase(phrase, clusterTitle, siteTopTerms); + excluded.push({ + phrase, + reason: + contextualExpansions.length > 0 + ? "Одиночная широкая фраза слишком шумная; fallback оставил только уточнения из контекста сайта." + : "Одиночная широкая фраза слишком шумная; без контекстного уточнения её нельзя отправлять в Wordstat." + }); + expanded.push(...contextualExpansions); + continue; + } + + expanded.push(phrase); + } + + return { + excluded, + queries: unique(expanded).slice(0, 14) + }; +} + +function getSeedConfidence(phrase: string, cluster: SemanticAnalysisRun["clusters"][number], role: SeoMarketRole) { + let confidence = cluster.evidence?.score ? Math.max(0.45, Math.min(cluster.evidence.score / 100, 0.92)) : 0.62; + + if (role === "commercial" || role === "core") { + confidence += 0.08; + } + + if (isBroadSinglePhrase(phrase)) { + confidence -= 0.22; + } + + return Math.max(0.25, Math.min(Number(confidence.toFixed(2)), 0.95)); +} + +function getSeedReason(seed: SeoNormalizationSeed) { + if (seed.marketRole === "commercial") { + return "Коммерческая формулировка для проверки спроса и будущего SEO-плана."; + } + + if (seed.marketRole === "integration") { + return "Интеграционный интент может давать отдельные посадочные или секции."; + } + + if (seed.needsHumanReview) { + return "Формулировка получена нормализацией широкого seed; нужна проверка перед закреплением."; + } + + return "Фраза нормализована из контекста сайта и может идти в Wordstat query plan."; +} + +function getSeedReviewDecision(seed: Omit<SeoNormalizationSeed, "review">): SeoNormalizationSeed["review"] { + if (seed.marketRole === "exclude" || isLowValuePhrase(seed.phrase)) { + return { + status: "rejected", + sendToWordstat: false, + sendToSerp: false, + reason: "Фраза выглядит как шум или техническая формулировка и не должна уходить во внешние сервисы.", + blockers: ["noise_or_technical_phrase"] + }; + } + + if (seed.confidence < 0.65 || seed.needsHumanReview) { + return { + status: "needs_human", + sendToWordstat: false, + sendToSerp: false, + reason: "Фраза может быть полезной, но fallback не уверен в рыночной формулировке без review.", + blockers: ["needs_seed_review"] + }; + } + + return { + status: "auto_approved", + sendToWordstat: true, + sendToSerp: + seed.intentType === "commercial" || + seed.intentType === "deployment_security" || + seed.intentType === "enterprise" || + seed.intentType === "integration" || + seed.intentType === "product", + reason: "Фраза достаточно уверенно связана с контекстом проекта и может идти в market evidence collection.", + blockers: [] + }; +} + +function buildModelTaskContract( + projectId: string, + analysis: SemanticAnalysisRun, + projectOntologyVersionId: string | null, + contextReview: SeoContextReviewRun | null +): SeoNormalizationModelTaskContract { + const evidenceRefs = analysis.clusters + .flatMap((cluster) => + cluster.sourceRefs.slice(0, 3).map((ref) => ({ + id: `${cluster.id}:${ref.id}`, + title: ref.title, + sourcePath: ref.sourcePath, + urlPath: ref.urlPath, + scope: ref.scope + })) + ) + .slice(0, 40); + + return { + taskType: "seo.normalization", + schemaVersion: "seo-normalization-task.v1", + taskId: `${analysis.runId}:seo-normalization:v1`, + projectId, + semanticRunId: analysis.runId, + projectOntologyVersionId, + providerMode: "model_provider_contract", + input: { + siteSummary: { + brandName: analysis.projectOntology?.brandName ?? "project", + language: analysis.styleProfile.language, + topTerms: analysis.styleProfile.topTerms.slice(0, 20).map((term) => term.term), + ctaPhrases: analysis.styleProfile.ctaPhrases.slice(0, 10) + }, + contextReview: { + status: contextReview ? "available" : "missing", + runId: contextReview?.runId ?? null, + needsHumanReview: contextReview?.needsHumanReview ?? true, + summary: contextReview?.summary ?? null, + forbiddenClaims: contextReview?.forbiddenClaims.map((claim) => claim.claim).slice(0, 20) ?? [], + normalizationHints: + contextReview?.normalizationHints.slice(0, 30).map((hint) => ({ + clusterId: hint.clusterId, + marketAngles: hint.marketAngles.slice(0, 8), + reviewRequired: hint.reviewRequired, + sourcePhrases: hint.sourcePhrases.slice(0, 10), + stopPhrases: hint.stopPhrases.slice(0, 8), + targetIntent: hint.targetIntent, + title: hint.title + })) ?? [] + }, + scopeSummary: { + adminPages: analysis.pageScope.adminPages, + contentSources: analysis.pageScope.contentSources, + indexablePages: analysis.pageScope.indexablePages, + selectedIndexablePages: analysis.pageScope.selectedIndexablePages ?? analysis.pageScope.indexablePages, + technicalPages: analysis.pageScope.technicalPages, + templateBlocks: analysis.pageScope.templateBlocks + }, + evidenceRefs, + semanticClusters: analysis.clusters.slice(0, 24).map((cluster) => ({ + evidenceLevel: cluster.evidence?.level ?? "none", + id: cluster.id, + matchedTerms: cluster.matchedTerms.slice(0, 12), + priority: cluster.priority, + queryExamples: cluster.queryExamples.slice(0, 8), + status: cluster.status, + targetIntent: cluster.targetIntent, + title: cluster.title + })), + sourceSeeds: analysis.seedCandidates.slice(0, 80).map((seed) => ({ + clusterId: seed.clusterId, + phrase: seed.phrase, + priority: seed.priority, + reason: seed.reason + })), + forbiddenClaims: [ + "Не придумывать услуги, рынки, регионы, интеграции или гарантии, которых нет в source evidence или approved ontology.", + "Не превращать одиночные широкие слова в Wordstat-запрос без уточнения из source evidence, approved ontology или human review.", + "Не отправлять технические labels, brand/debug strings и внутренние названия блоков как market queries.", + ...(contextReview?.forbiddenClaims.map((claim) => claim.claim).slice(0, 20) ?? []) + ] + }, + allowedActions: [ + "group_intents", + "normalize_market_queries", + "reject_noise", + "mark_human_review", + "route_wordstat", + "route_serp" + ], + outputSchema: { + schemaVersion: "seo-normalization.v1", + requiredTopLevelKeys: ["intentGroups", "seedStrategy", "rejectedSeeds", "summary", "readiness"], + seedRequiredKeys: [ + "phrase", + "clusterId", + "intentGroupId", + "intentType", + "marketRole", + "confidence", + "review" + ], + intentRequiredKeys: ["id", "title", "intentType", "marketHypothesis", "wordstatQueries", "risks", "confidence"] + }, + confidencePolicy: { + autoApproveMinConfidence: 0.65, + humanReviewBelowConfidence: 0.65, + rejectWhenOffBusiness: true + }, + stopConditions: [ + "Недостаточно source evidence для отделения бизнеса от технической оболочки сайта.", + "Фраза выглядит как соседний рынок или business expansion без явного user approval.", + "Нельзя определить, отправлять ли фразу в Wordstat/SERP без ручного review." + ] + }; +} + +function getContextHintMap(contextReview: SeoContextReviewRun | null) { + return new Map((contextReview?.normalizationHints ?? []).map((hint) => [hint.clusterId, hint])); +} + +function getContextHintPhraseClusterMap(contextReview: SeoContextReviewRun | null) { + const phraseClusterMap = new Map<string, string>(); + + for (const hint of contextReview?.normalizationHints ?? []) { + for (const phrase of [...hint.sourcePhrases, ...hint.marketAngles]) { + const phraseKey = normalizePhrase(phrase); + + if (phraseKey && !phraseClusterMap.has(phraseKey)) { + phraseClusterMap.set(phraseKey, hint.clusterId); + } + } + } + + return phraseClusterMap; +} + +function buildContract( + projectId: string, + analysis: SemanticAnalysisRun | null, + projectOntologyVersionId: string | null, + contextReview: SeoContextReviewRun | null +): SeoNormalizationContract { + if (!analysis) { + return { + schemaVersion: "seo-normalization.v1", + projectId, + semanticRunId: null, + projectOntologyVersionId: null, + generatedAt: new Date().toISOString(), + state: "not_ready", + provider: { + mode: "deterministic_fallback", + modelRequired: true, + message: "Сначала нужен Context Review; после него модель или fallback нормализуют seed strategy." + }, + sourceTaskId: null, + analystReview: getDefaultAnalystReview(), + modelTask: null, + readiness: { + sourceSeedCount: 0, + normalizedSeedCount: 0, + intentGroupCount: 0, + wordstatQueryCount: 0, + wordstatApprovedCount: 0, + serpApprovedCount: 0, + rejectedSeedCount: 0, + needsHumanReviewCount: 0, + autoApprovedCount: 0 + }, + summary: "Нет semantic analysis, нормализация спроса не готова.", + intentGroups: [], + seedStrategy: [], + rejectedSeeds: [], + nextActions: ["Запустить Context Review, затем пересобрать SEO normalization."] + }; + } + + const intentGroups: SeoNormalizationContract["intentGroups"] = []; + const seedStrategy: SeoNormalizationSeed[] = []; + const rejectedSeeds: SeoNormalizationContract["rejectedSeeds"] = []; + const siteTopTerms = analysis.styleProfile.topTerms.slice(0, 20).map((term) => term.term); + const contextHintsByClusterId = getContextHintMap(contextReview); + const contextHintClusterByPhrase = getContextHintPhraseClusterMap(contextReview); + + for (const cluster of analysis.clusters.filter((item) => item.status !== "missing" || item.priority === "high").slice(0, 24)) { + const intentType = inferIntentType(getClusterText(cluster)); + const contextHint = contextHintsByClusterId.get(cluster.id); + const phrases = collectClusterPhrases(analysis, cluster, contextHint); + const { excluded, queries } = buildGroupQueries(phrases, cluster.title, siteTopTerms, contextHint?.stopPhrases ?? []); + const groupId = `${intentType}:${cluster.id}`; + + if (queries.length === 0 && excluded.length === 0) { + continue; + } + + intentGroups.push({ + id: groupId, + title: `${getIntentTitle(intentType)} · ${cluster.title}`, + intentType, + priority: cluster.priority, + marketHypothesis: getMarketHypothesis(intentType, cluster.title), + sourceClusterIds: [cluster.id], + sourceRefs: cluster.sourceRefs.slice(0, 4).map((ref) => ({ + title: ref.title, + sourcePath: ref.sourcePath, + urlPath: ref.urlPath, + scope: ref.scope + })), + corePhrases: phrases.filter((phrase) => !isBroadSinglePhrase(phrase) && !isLowValuePhrase(phrase)).slice(0, 6), + wordstatQueries: queries, + excludedPhrases: excluded, + risks: [ + ...(cluster.status === "weak" ? ["Контент сайта слабо раскрывает интент; запросы нельзя закреплять без проверки."] : []), + ...(intentType === "enterprise" ? ["Корпоративные запросы могут быть широкими; нужна SERP-проверка типа страниц."] : []), + ...(contextHint?.reviewRequired ? ["Context Review требует human/model review перед market routing."] : []) + ], + confidence: Number(((cluster.evidence?.score ?? cluster.score) / 100).toFixed(2)) + }); + + for (const excludedPhrase of excluded) { + rejectedSeeds.push({ + phrase: excludedPhrase.phrase, + clusterId: cluster.id, + clusterTitle: cluster.title, + reason: excludedPhrase.reason, + suggestedReplacement: queries[0] ?? null + }); + } + + queries.slice(0, 10).forEach((phrase, index) => { + const originalSeed = analysis.seedCandidates.find( + (seed) => seed.clusterId === cluster.id && normalizePhrase(seed.phrase) === normalizePhrase(phrase) + ); + const source = originalSeed?.source ?? "detected"; + const marketRole = getMarketRole(phrase, intentType); + const confidence = getSeedConfidence(phrase, cluster, marketRole); + const normalizedFrom = phrases.filter((item) => normalizePhrase(item) === normalizePhrase(phrase)); + const needsHumanReview = + confidence < 0.65 || normalizedFrom.length === 0 || cluster.status !== "covered" || Boolean(contextHint?.reviewRequired); + const seedWithoutReview: Omit<SeoNormalizationSeed, "review"> = { + id: `${groupId}:${index}:${toId(phrase)}`, + phrase, + normalizedFrom: normalizedFrom.length > 0 ? normalizedFrom : [cluster.title], + clusterId: cluster.id, + clusterTitle: cluster.title, + intentGroupId: groupId, + intentType, + marketRole, + priority: cluster.priority, + source, + reason: "", + confidence, + needsHumanReview + }; + const seed: SeoNormalizationSeed = { + ...seedWithoutReview, + review: getSeedReviewDecision(seedWithoutReview) + }; + + seedStrategy.push({ + ...seed, + reason: getSeedReason(seed) + }); + }); + } + + const seedByPhrase = new Map<string, SeoNormalizationSeed>(); + const priorityScore = { high: 3, low: 1, medium: 2 }; + + for (const seed of seedStrategy.filter((item) => item.marketRole !== "exclude")) { + const phraseKey = normalizePhrase(seed.phrase); + const existingSeed = seedByPhrase.get(phraseKey); + const preferredClusterId = contextHintClusterByPhrase.get(phraseKey); + + if (preferredClusterId && existingSeed) { + const existingIsPreferred = existingSeed.clusterId === preferredClusterId; + const seedIsPreferred = seed.clusterId === preferredClusterId; + + if (existingIsPreferred && !seedIsPreferred) { + continue; + } + + if (seedIsPreferred && !existingIsPreferred) { + seedByPhrase.set(phraseKey, seed); + continue; + } + } + + if ( + !existingSeed || + priorityScore[seed.priority] > priorityScore[existingSeed.priority] || + (priorityScore[seed.priority] === priorityScore[existingSeed.priority] && seed.confidence > existingSeed.confidence) + ) { + seedByPhrase.set(phraseKey, seed); + } + } + + const normalizedSeedStrategy = 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); + + return { + schemaVersion: "seo-normalization.v1", + projectId, + semanticRunId: analysis.runId, + projectOntologyVersionId, + generatedAt: new Date().toISOString(), + state: normalizedSeedStrategy.length > 0 ? "ready" : "not_ready", + provider: { + mode: "deterministic_fallback", + modelRequired: true, + message: + contextReview + ? "Seed strategy собирается deterministic fallback-ом, но уже учитывает Context Review forbidden claims и normalization hints." + : "Seed strategy собирается deterministic fallback-ом без Context Review result. Сначала нужно сохранить seo.context_review draft evidence." + }, + sourceTaskId: modelTask.taskId, + analystReview: getDefaultAnalystReview(), + modelTask, + readiness: { + sourceSeedCount: analysis.seedCandidates.length, + normalizedSeedCount: normalizedSeedStrategy.length, + intentGroupCount: intentGroups.length, + wordstatQueryCount: wordstatApprovedCount, + wordstatApprovedCount, + serpApprovedCount, + rejectedSeedCount: rejectedSeeds.length + reviewRejectedCount, + needsHumanReviewCount, + autoApprovedCount + }, + summary: + normalizedSeedStrategy.length > 0 + ? `Нормализовано ${normalizedSeedStrategy.length} фраз в ${intentGroups.length} intent groups; ${wordstatApprovedCount} готовы к Wordstat, ${needsHumanReviewCount} требуют review.${contextReview ? " Context Review учтен." : " Context Review пока не сохранен."}` + : "Контекст есть, но нормализованные рыночные фразы не собраны.", + intentGroups, + seedStrategy: normalizedSeedStrategy, + rejectedSeeds, + nextActions: [ + ...(contextReview + ? ["Передать Context Review + normalization task в ModelProvider и сравнить его с deterministic fallback."] + : ["Сначала сохранить seo.context_review draft evidence, чтобы normalization не работала только от сырых semantic clusters."]), + "Отправлять в Wordstat только seedStrategy.wordstat queries, а сырой semantic seed list оставить в dev details.", + "После Wordstat добавить cleaning: normalize, dedupe, trash/risky/use/support/article." + ] + }; +} + +export async function getSeoNormalizationContract(projectId: string): Promise<SeoNormalizationContract> { + const analysis = await getLatestSemanticAnalysis(projectId); + const projectOntologyVersion = await getLatestProjectOntologyVersion(projectId); + const contextReview = analysis ? await getLatestAlignedSeoContextReview(projectId, analysis.runId) : null; + const fallbackContract = buildContract( + projectId, + analysis, + projectOntologyVersion?.id ?? analysis?.projectOntology?.versionId ?? null, + contextReview + ); + + if (!analysis) { + return fallbackContract; + } + + return (await getLatestAlignedSeoNormalization(projectId, analysis.runId)) ?? fallbackContract; +} + +export async function getLatestAlignedSeoNormalization( + projectId: string, + semanticRunId: string +): Promise<SeoNormalizationContract | null> { + const result = await pool.query<SeoNormalizationRunRow>( + ` + select id, status, input, output, error_message, started_at, completed_at, created_at + from runs + where project_id = $1 + and run_type = 'seo_normalization' + and output->>'semanticRunId' = $2 + order by created_at desc + limit 1; + `, + [projectId, semanticRunId] + ); + + return result.rows[0] ? mapSeoNormalizationRun(result.rows[0]) : null; +} + +export async function saveSeoNormalizationManual( + projectId: string, + output: SeoNormalizationContract +): Promise<SeoNormalizationContract> { + const normalizedOutput = normalizeSeoNormalizationOutput( + projectId, + output, + "codex_manual", + "Codex manual dev-loop: текущий Codex-сеанс выполнил seo.semantic_normalization task по правилам, без внешнего provider adapter." + ); + const result = await pool.query<SeoNormalizationRunRow>( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_normalization', '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_manual", + schemaVersion: "seo-normalization-manual-input.v1", + semanticRunId: normalizedOutput.semanticRunId, + sourceTaskId: normalizedOutput.sourceTaskId, + taskType: "seo.normalization" + }), + JSON.stringify(normalizedOutput) + ] + ); + const run = result.rows[0] ? mapSeoNormalizationRun(result.rows[0]) : null; + + if (!run) { + throw new Error("Не удалось сохранить Codex manual SEO normalization."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/ontology/projectOntology.ts b/seo_mode/seo_mode/server/src/ontology/projectOntology.ts new file mode 100644 index 0000000..e2e304c --- /dev/null +++ b/seo_mode/seo_mode/server/src/ontology/projectOntology.ts @@ -0,0 +1,1177 @@ +export type IntentSignalGroupId = + | "agents" + | "ai" + | "automation" + | "business" + | "commercial" + | "data" + | "development" + | "engineering" + | "enterprise" + | "implementation" + | "integrations" + | "operations" + | "process" + | "security"; + +export type SemanticClusterDefinition = { + id: string; + title: string; + priority: "high" | "medium" | "low"; + targetIntent: string; + intentGroups: IntentSignalGroupId[]; + keywords: string[]; + queryExamples: string[]; + targetPages: string[]; +}; + +export type LandingOpportunityPageType = + | "conversion" + | "deployment" + | "docs" + | "home" + | "integration" + | "landing" + | "module" + | "security" + | "use_case"; + +export type ProjectOntology = { + id: string; + schemaVersion: "seo-project-ontology.v1"; + source: "seed_fixture" | "extracted" | "generic_fallback"; + domainPackage: SeoDomainPackageBinding; + brandName: string; + brandTerms: string[]; + clusters: SemanticClusterDefinition[]; + commercialClusterIds: string[]; + exportTitle: string; + exportFilePrefix: string; + analysisLimitNote: string; + landingBrief: { + entityLabels: Record<string, string>; + fallbackEntities: Partial<Record<LandingOpportunityPageType, string>>; + audiences: Record<LandingOpportunityPageType, string>; + baseInternalLinks: string[]; + typedInternalLinks: Record<LandingOpportunityPageType, string[]>; + typeSections: Record<LandingOpportunityPageType, { title: string; goal: string }>; + }; + projectOntologyInstance?: ProjectOntologyInstance; +}; + +export type ProjectOntologyProvider = { + getProjectOntology(projectId: string, evidence?: ProjectOntologyEvidence): Promise<ProjectOntology>; +}; + +export type SeoDomainPackageBinding = { + id: "seo"; + version: "0.1.0"; + coreOntologyVersion: string; + projectOntologySchema: "seo-project-ontology.v1"; + packagePath: string; +}; + +export type ProjectOntologyEvidenceDocument = { + id: string; + kind: "page" | "content_json"; + selected: boolean; + scope: string; + usedForCoverage: boolean; + title: string; + sourcePath: string; + urlPath: string | null; + text: string; + headings?: string[]; +}; + +export type ProjectOntologyEvidence = { + projectId: string; + scanVersionId: string; + sourceId: string; + projectIndexKey: string | null; + sourceType: string; + sourceConfig: Record<string, unknown>; + pages: ProjectOntologyEvidenceDocument[]; + contentDocuments: ProjectOntologyEvidenceDocument[]; +}; + +export type ProjectOntologyInstance = { + schemaVersion: "seo-project-ontology.v1"; + projectId: string; + siteId: string; + coreOntologyVersion: string; + seoDomainPackageVersion: string; + source: { + sourceType: string; + scanVersionId: string; + rootUrl: string | null; + sourcePath: string | null; + }; + scope: { + scopeContractId: string; + scopeItemIds: string[]; + }; + brand: { + name: string; + aliases: string[]; + description: string; + }; + pages: Array<{ + id: string; + url: string; + title: string; + evidenceIds: string[]; + }>; + sections: Array<{ + id: string; + pageId: string; + title: string; + meaning: string; + evidenceIds: string[]; + }>; + intentClusters: Array<{ + id: string; + title: string; + priority: SemanticClusterDefinition["priority"]; + targetIntent: string; + seedQueries: string[]; + evidenceIds: string[]; + }>; + tone: { + language: string; + signals: string[]; + forbiddenShifts: string[]; + }; + constraints: { + forbiddenTopics: string[]; + forbiddenClaims: string[]; + mustKeep: string[]; + }; + evidence: Array<{ + id: string; + sourceType: string; + ref: string; + summary: string; + }>; + approvals: { + status: "draft" | "approved" | "rejected" | "needs_review"; + approvedBy: string | null; + approvedAt: string | null; + }; + ontologyDeltaProposals: Array<{ + id: string; + target: "core" | "seo_domain" | "project_only"; + summary: string; + status: "proposed" | "accepted" | "rejected" | "deferred"; + }>; +}; + +export const SEO_DOMAIN_PACKAGE_BINDING: SeoDomainPackageBinding = { + id: "seo", + version: "0.1.0", + coreOntologyVersion: "ontology-core.local", + projectOntologySchema: "seo-project-ontology.v1", + packagePath: "platform/services/ontology-core/catalog/domain-packages/seo" +}; + +export const INTENT_SIGNAL_GROUPS = { + agents: ["агент", "агентная", "ai agent", "автономный", "ассистент", "toolpack", "codex"], + ai: ["искусственный интеллект", "ai", "ии", "llm", "нейросеть", "модель", "prompt", "генеративный"], + automation: ["автоматизация", "автоматизировать", "workflow", "bpm", "регламент", "сквозной", "маршрут"], + business: ["бизнес", "компания", "команда", "клиент", "заказчик", "операционный", "подразделение"], + commercial: ["демо", "пилот", "стоимость", "тариф", "внедрение", "заявка", "консультация", "коммерческое предложение"], + data: ["данные", "база", "хранилище", "интеграция данных", "аналитика", "сущность", "dataset"], + development: ["разработка", "приложение", "код", "api", "frontend", "backend", "low-code", "no-code"], + engineering: ["bim", "digital twin", "цифровой двойник", "3d", "step", "stl", "las", "инженерный"], + enterprise: ["enterprise", "b2b", "корпоративный", "on-prem", "private cloud", "частное облако", "контур"], + implementation: ["внедрение", "развертывание", "deploy", "инфраструктура", "сервер", "docker", "production"], + integrations: ["интеграция", "api", "webhook", "1с", "crm", "erp", "битрикс", "telegram"], + operations: ["ops", "tasker", "задача", "статус", "чекер", "исполнитель", "проектный офис"], + process: ["процесс", "бизнес-процесс", "согласование", "закупка", "договор", "тендер", "регламент"], + security: ["безопасность", "доступ", "роль", "rbac", "изоляция", "права", "закрытый контур"] +} satisfies Record<IntentSignalGroupId, string[]>; + +const INTENT_SIGNAL_GROUP_META = { + agents: { + title: "AI-ассистенты и агентная автоматизация", + targetIntent: "Запросы про ассистентов, агентов и автономное выполнение задач." + }, + ai: { + title: "Искусственный интеллект и LLM", + targetIntent: "Запросы про искусственный интеллект, модели и AI-функции продукта." + }, + automation: { + title: "Автоматизация процессов", + targetIntent: "Запросы про автоматизацию, workflow, регламенты и сквозные процессы." + }, + business: { + title: "Бизнес-задачи и операционный контур", + targetIntent: "Запросы ЛПР про пользу продукта для бизнеса, команды и клиентов." + }, + commercial: { + title: "Демо, цена и внедрение", + targetIntent: "Нижняя часть воронки: цена, демо, заявка, пилот и консультация." + }, + data: { + title: "Данные и аналитика", + targetIntent: "Запросы про данные, аналитику, хранилища и интеграцию данных." + }, + development: { + title: "Разработка и API", + targetIntent: "Технические запросы про разработку, API, приложения и код." + }, + engineering: { + title: "Инженерные данные и digital twin", + targetIntent: "Инженерные запросы про BIM, digital twin, 3D и технические модели." + }, + enterprise: { + title: "Enterprise, on-prem и корпоративный контур", + targetIntent: "Enterprise-запросы про корпоративное внедрение, закрытый контур и private cloud." + }, + implementation: { + title: "Внедрение и инфраструктура", + targetIntent: "Запросы про развертывание, инфраструктуру, этапы внедрения и production-контур." + }, + integrations: { + title: "Интеграции и внешние системы", + targetIntent: "Запросы про API, webhooks, 1С, CRM/ERP и внешние контуры." + }, + operations: { + title: "Операционное управление", + targetIntent: "Запросы про задачи, статусы, контроль исполнения и проектный офис." + }, + process: { + title: "Процессы, согласования и регламенты", + targetIntent: "Запросы про бизнес-процессы, согласования, закупки, договоры и регламенты." + }, + security: { + title: "Безопасность, роли и доступы", + targetIntent: "Запросы про безопасность, RBAC, доступы, изоляцию и права." + } +} satisfies Record<IntentSignalGroupId, { title: string; targetIntent: string }>; + +const ONTOLOGY_STOP_WORDS = new Set([ + "and", + "app", + "com", + "для", + "или", + "как", + "компания", + "на", + "об", + "от", + "по", + "под", + "при", + "сайт", + "сервис", + "страница", + "это" +]); + +function getStringConfig(config: Record<string, unknown>, key: string) { + const value = config[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeOntologyText(value: string) { + return value + .toLocaleLowerCase("ru-RU") + .replace(/ё/g, "е") + .replace(/[«»“”]/g, "\"") + .replace(/\s+/g, " ") + .trim(); +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function countTermHits(text: string, term: string) { + const normalizedTerm = normalizeOntologyText(term); + + if (!normalizedTerm) { + return 0; + } + + const escapedTerm = escapeRegExp(normalizedTerm).replace(/\\ /g, "\\s+"); + const isAsciiToken = /^[a-z0-9-]+$/i.test(normalizedTerm); + const pattern = isAsciiToken + ? new RegExp(`(^|[^a-z0-9-])${escapedTerm}(?=$|[^a-z0-9-])`, "g") + : new RegExp(escapedTerm, "g"); + + return Array.from(text.matchAll(pattern)).length; +} + +function dedupeStrings(values: string[]) { + const seen = new Set<string>(); + const result: string[] = []; + + for (const value of values) { + const normalized = value.replace(/\s+/g, " ").trim(); + const key = normalizeOntologyText(normalized); + + if (!normalized || seen.has(key)) { + continue; + } + + seen.add(key); + result.push(normalized); + } + + return result; +} + +function normalizeUrlLikePath(value: string) { + const normalized = `/${value.replace(/\\/g, "/").replace(/^\/+/, "")}`.replace(/\/index\.html$/i, "/").replace(/\.html$/i, ""); + + return normalized.length > 1 ? normalized.replace(/\/+$/g, "") : "/"; +} + +function getPagePath(document: ProjectOntologyEvidenceDocument) { + return normalizeUrlLikePath(document.urlPath ?? document.sourcePath); +} + +function getTextWords(value: string) { + return normalizeOntologyText(value).match(/[a-zа-я0-9-]{3,}/giu) ?? []; +} + +function getTopTerms(value: string, limit: number) { + const counts = new Map<string, number>(); + + for (const word of getTextWords(value)) { + if (ONTOLOGY_STOP_WORDS.has(word) || /^\d+$/.test(word) || /^[0-9a-f]{12,}$/i.test(word)) { + continue; + } + + counts.set(word, (counts.get(word) ?? 0) + 1); + } + + return [...counts.entries()] + .filter(([, count]) => count >= 2) + .sort((first, second) => second[1] - first[1] || first[0].localeCompare(second[0], "ru-RU")) + .slice(0, limit) + .map(([term]) => term); +} + +function splitBrandCandidates(value: string | null | undefined) { + if (!value) { + return []; + } + + return value + .split(/\s+[|—–-]\s+|[:|]/g) + .map((candidate) => candidate.replace(/\b(главная|home|index|официальный сайт)\b/giu, "").trim()) + .filter((candidate) => candidate.length >= 2 && candidate.length <= 48 && !/\{\{[^}]+}}/.test(candidate)); +} + +function inferBrandName(evidence: ProjectOntologyEvidence | undefined) { + if (!evidence) { + return "Проект"; + } + + const homePage = + evidence.pages.find((page) => getPagePath(page) === "/") ?? + evidence.pages.find((page) => page.usedForCoverage) ?? + evidence.pages[0] ?? + null; + const sourceRootName = getStringConfig(evidence.sourceConfig, "rootName"); + const sourcePath = getStringConfig(evidence.sourceConfig, "path"); + const sourcePathName = sourcePath ? sourcePath.split(/[\\/]/g).filter(Boolean).at(-1) ?? null : null; + const headings = homePage?.headings ?? []; + const rawCandidates = [ + ...splitBrandCandidates(homePage?.title), + ...splitBrandCandidates(headings[0]), + ...splitBrandCandidates(sourceRootName), + ...splitBrandCandidates(sourcePathName) + ]; + const allText = normalizeOntologyText([homePage?.title, homePage?.text, sourceRootName, sourcePathName].filter(Boolean).join("\n")); + + const scored = rawCandidates + .map((candidate) => { + const normalized = normalizeOntologyText(candidate); + const occurrenceScore = countTermHits(allText, candidate) * 8; + const shapeScore = /[A-Z][a-z]+[A-Z]/.test(candidate) || /^[A-Z0-9.]{2,}$/u.test(candidate) ? 20 : 0; + const lengthScore = candidate.length <= 24 ? 8 : 0; + const genericPenalty = /^(главная|home|index|сайт|платформа|решение)$/i.test(normalized) ? -50 : 0; + + return { + candidate, + score: occurrenceScore + shapeScore + lengthScore + genericPenalty + }; + }) + .filter((item) => item.score > -20) + .sort((first, second) => second.score - first.score || first.candidate.length - second.candidate.length); + + return scored[0]?.candidate ?? "Проект"; +} + +function buildBrandTerms(brandName: string, evidence: ProjectOntologyEvidence | undefined) { + const sourceRootName = evidence ? getStringConfig(evidence.sourceConfig, "rootName") : null; + const candidateTerms = [ + brandName, + normalizeOntologyText(brandName), + sourceRootName ?? "", + ...brandName.split(/\s+/g), + ...brandName.split(/[._-]+/g) + ]; + + return dedupeStrings(candidateTerms).filter((term) => term.length >= 2 && !ONTOLOGY_STOP_WORDS.has(normalizeOntologyText(term))); +} + +function getCoverageDocuments(evidence: ProjectOntologyEvidence) { + const documents = [...evidence.pages, ...evidence.contentDocuments]; + const coverageDocuments = documents.filter((document) => document.usedForCoverage); + + return coverageDocuments.length > 0 ? coverageDocuments : documents; +} + +function buildTargetPagesForGroup(groupId: IntentSignalGroupId, documents: ProjectOntologyEvidenceDocument[]) { + const terms = INTENT_SIGNAL_GROUPS[groupId]; + const ranked = documents + .map((document) => { + const text = normalizeOntologyText(document.text); + const hits = terms.reduce((sum, term) => sum + countTermHits(text, term), 0); + + return { + path: getPagePath(document), + hits + }; + }) + .filter((item) => item.hits > 0) + .sort((first, second) => second.hits - first.hits); + + return dedupeStrings(ranked.map((item) => item.path)).slice(0, 4); +} + +function buildGenericLandingBrief(brandName: string, documents: ProjectOntologyEvidenceDocument[]) { + const indexablePages = documents.filter((document) => document.kind === "page" && document.usedForCoverage); + const entityLabels = Object.fromEntries( + indexablePages.slice(0, 60).map((document) => [getPagePath(document), document.title || `${brandName}: ${getPagePath(document)}`]) + ); + const baseInternalLinks = dedupeStrings(indexablePages.map(getPagePath)).slice(0, 8); + const normalizedBaseLinks = baseInternalLinks.length > 0 ? baseInternalLinks : ["/"]; + + return { + entityLabels, + fallbackEntities: { + integration: `Подключения ${brandName}`, + module: `Раздел ${brandName}`, + use_case: `Сценарий ${brandName}` + }, + audiences: { + conversion: "Пользователь, которому нужно быстро понять условия, формат обращения и следующий шаг.", + deployment: "Команда, которая оценивает запуск, ограничения и ответственность сторон.", + docs: "Пользователь, которому нужно понять разделы, подключение, ограничения и порядок работы.", + home: `Пользователь, который впервые оценивает ${brandName}.`, + integration: "Команда, которой важны внешние системы, обмен данными и совместимость.", + landing: "ЛПР, которому нужно связать проблему, решение, доказательства и следующий шаг.", + module: "Пользователь, который выбирает конкретный раздел, услугу или возможность проекта.", + security: "IT/security команда, которая проверяет доступы, данные и риски.", + use_case: `Пользователь, которому нужен понятный сценарий применения ${brandName}.` + }, + baseInternalLinks: normalizedBaseLinks, + typedInternalLinks: { + conversion: normalizedBaseLinks, + deployment: normalizedBaseLinks, + docs: normalizedBaseLinks, + home: normalizedBaseLinks, + integration: normalizedBaseLinks, + landing: normalizedBaseLinks, + module: normalizedBaseLinks, + security: normalizedBaseLinks, + use_case: normalizedBaseLinks + }, + typeSections: { + conversion: { + title: "Формат заявки и следующий шаг", + goal: "Показать, как начинается взаимодействие, что нужно для оценки и что получает пользователь." + }, + deployment: { + title: "Запуск и ограничения", + goal: "Раскрыть формат запуска, ответственность сторон, зависимости и ограничения." + }, + docs: { + title: "Как подключиться", + goal: "Дать понятный вход: разделы, роли, данные, порядок работы и ограничения." + }, + home: { + title: "Предложение и разделы", + goal: "Связать общий value proposition с конкретными разделами, услугами и сценариями." + }, + integration: { + title: "Подключения и обмен данными", + goal: "Показать внешние системы, формат обмена, ответственность и ограничения." + }, + landing: { + title: `Как ${brandName} закрывает задачу`, + goal: "Собрать проблему, решение, доказательства и следующий шаг." + }, + module: { + title: "Как работает раздел", + goal: "Объяснить входные данные, действия, роли, статусы и результат для пользователя." + }, + security: { + title: "Доступы и данные", + goal: "Раскрыть безопасность, роли, доступы, хранение данных и аудит." + }, + use_case: { + title: "Сценарий до и после", + goal: "Показать текущую боль, целевой процесс и измеримый эффект." + } + } + } satisfies ProjectOntology["landingBrief"]; +} + +function buildCoreOfferQueries(brandName: string, topTerms: string[]) { + const evidenceTerms = topTerms.filter((term) => normalizeOntologyText(term) !== normalizeOntologyText(brandName)).slice(0, 5); + const pairedTerms = evidenceTerms.slice(0, 3).map((term) => `${term} ${brandName}`); + const topicPair = evidenceTerms.length >= 2 ? `${evidenceTerms[0]} ${evidenceTerms[1]}` : null; + + return dedupeStrings([brandName, ...pairedTerms, topicPair ?? ""]).slice(0, 4); +} + +function buildEvidenceSeedQueries(brandName: string, matchedTerms: string[], topTerms: string[]) { + const brandKey = normalizeOntologyText(brandName); + const terms = dedupeStrings([...matchedTerms, ...topTerms]) + .filter((term) => { + const normalizedTerm = normalizeOntologyText(term); + + return normalizedTerm.length >= 3 && normalizedTerm !== brandKey && !ONTOLOGY_STOP_WORDS.has(normalizedTerm); + }) + .slice(0, 8); + const phraseQueries = terms.slice(0, 4); + const pairedQueries = terms + .slice(0, 3) + .map((term, index) => { + const modifier = terms.find((candidate, candidateIndex) => candidateIndex !== index && !normalizeOntologyText(candidate).includes(normalizeOntologyText(term))); + + return modifier ? `${term} ${modifier}` : ""; + }) + .filter(Boolean); + + return dedupeStrings([...phraseQueries, ...pairedQueries]).slice(0, 4); +} + +function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: string) { + const documents = getCoverageDocuments(evidence); + const allText = normalizeOntologyText(documents.map((document) => document.text).join("\n")); + const topTerms = getTopTerms(allText, 10); + const homeDocument = documents.find((document) => document.kind === "page") ?? documents[0] ?? null; + const homeTarget = homeDocument ? getPagePath(homeDocument) : "/"; + const coreKeywords = dedupeStrings([...topTerms.slice(0, 8), ...buildBrandTerms(brandName, evidence)]).slice(0, 10); + const clusters: SemanticClusterDefinition[] = [ + { + id: "core_offer", + title: `${brandName}: основное предложение`, + priority: "high", + targetIntent: "Базовый интент проекта: что предлагает сайт, кому это нужно и какой следующий шаг.", + intentGroups: ["business"], + keywords: coreKeywords.length > 0 ? coreKeywords : ["продукт", "услуга", "решение"], + queryExamples: buildCoreOfferQueries(brandName, topTerms), + targetPages: [homeTarget] + } + ]; + + for (const groupId of Object.keys(INTENT_SIGNAL_GROUPS) as IntentSignalGroupId[]) { + const matchedTerms = INTENT_SIGNAL_GROUPS[groupId].filter((term) => countTermHits(allText, term) > 0); + + if (matchedTerms.length === 0) { + continue; + } + + const groupHits = matchedTerms.reduce((sum, term) => sum + countTermHits(allText, term), 0); + const meta = INTENT_SIGNAL_GROUP_META[groupId]; + const priority: SemanticClusterDefinition["priority"] = + groupHits >= 10 || ["commercial", "automation", "integrations", "security"].includes(groupId) + ? "high" + : groupHits >= 3 + ? "medium" + : "low"; + const targetPages = buildTargetPagesForGroup(groupId, documents); + const seedQueries = buildEvidenceSeedQueries(brandName, matchedTerms, topTerms); + + clusters.push({ + id: `detected_${groupId}`, + title: meta.title, + priority, + targetIntent: meta.targetIntent, + intentGroups: [groupId], + keywords: dedupeStrings([...matchedTerms, ...topTerms.slice(0, 4)]).slice(0, 12), + queryExamples: seedQueries.length > 0 ? seedQueries : buildCoreOfferQueries(brandName, topTerms), + targetPages: targetPages.length > 0 ? targetPages : [homeTarget] + }); + } + + return clusters; +} + +function buildProjectOntologyInstance( + evidence: ProjectOntologyEvidence, + brandName: string, + brandTerms: string[], + clusters: SemanticClusterDefinition[] +): ProjectOntologyInstance { + const documents = getCoverageDocuments(evidence); + const pageDocuments = documents.filter((document) => document.kind === "page"); + const evidenceRows: ProjectOntologyInstance["evidence"] = [ + { + id: `scan:${evidence.scanVersionId}`, + sourceType: evidence.sourceType, + ref: evidence.projectIndexKey ?? evidence.scanVersionId, + summary: "Scan/source evidence used to extract the draft project ontology." + }, + ...documents.slice(0, 80).map((document) => ({ + id: `doc:${document.id}`, + sourceType: document.kind, + ref: document.urlPath ?? document.sourcePath, + summary: document.title + })) + ]; + const pages = pageDocuments.slice(0, 120).map((document) => ({ + id: document.id, + url: getPagePath(document), + title: document.title, + evidenceIds: [`doc:${document.id}`] + })); + const sections = pageDocuments.flatMap((document) => + (document.headings ?? []).slice(0, 8).map((heading, index) => ({ + id: `section:${document.id}:${index + 1}`, + pageId: document.id, + title: heading, + meaning: heading, + evidenceIds: [`doc:${document.id}`] + })) + ); + const rootUrl = getStringConfig(evidence.sourceConfig, "rootUrl"); + const sourcePath = getStringConfig(evidence.sourceConfig, "path"); + + return { + schemaVersion: "seo-project-ontology.v1", + projectId: evidence.projectId, + siteId: evidence.sourceId, + coreOntologyVersion: SEO_DOMAIN_PACKAGE_BINDING.coreOntologyVersion, + seoDomainPackageVersion: SEO_DOMAIN_PACKAGE_BINDING.version, + source: { + sourceType: evidence.sourceType, + scanVersionId: evidence.scanVersionId, + rootUrl, + sourcePath + }, + scope: { + scopeContractId: "scan-selected-pages", + scopeItemIds: documents.filter((document) => document.usedForCoverage).map((document) => document.id) + }, + brand: { + name: brandName, + aliases: brandTerms.filter((term) => normalizeOntologyText(term) !== normalizeOntologyText(brandName)), + description: `Brand extracted from scan evidence for ${brandName}.` + }, + pages, + sections, + intentClusters: clusters.map((cluster) => ({ + id: cluster.id, + title: cluster.title, + priority: cluster.priority, + targetIntent: cluster.targetIntent, + seedQueries: cluster.queryExamples, + evidenceIds: [`scan:${evidence.scanVersionId}`] + })), + tone: { + language: normalizeOntologyText(documents.map((document) => document.text).join("\n")).match(/[а-я]/) ? "ru" : "unknown", + signals: ["extracted-from-crawl"], + forbiddenShifts: ["Do not add unrelated offers, markets, or product claims without evidence or approval."] + }, + constraints: { + forbiddenTopics: [], + forbiddenClaims: ["Claims not present in source/crawl evidence or approved project ontology."], + mustKeep: [brandName] + }, + evidence: evidenceRows, + approvals: { + status: "draft", + approvedBy: null, + approvedAt: null + }, + ontologyDeltaProposals: [] + }; +} + +function getExplicitSeedFixtureId(evidence: ProjectOntologyEvidence) { + return ( + getStringConfig(evidence.sourceConfig, "seoSeedFixture") ?? + getStringConfig(evidence.sourceConfig, "ontologySeedFixture") ?? + getStringConfig(evidence.sourceConfig, "ontologyFixture") + ); +} + +function isNodeDcSeedFixture(fixtureId: string | null) { + const normalized = normalizeOntologyText(fixtureId ?? ""); + + return ["nodedc", "node.dc", "nodedc-site-seed", NODEDC_PROJECT_ONTOLOGY.id].includes(normalized); +} + +function buildGenericFallbackOntology(projectId: string): ProjectOntology { + const brandName = "Проект"; + + return { + id: `project:${projectId}:ontology:fallback`, + schemaVersion: "seo-project-ontology.v1", + source: "generic_fallback", + domainPackage: SEO_DOMAIN_PACKAGE_BINDING, + brandName, + brandTerms: [], + clusters: [ + { + id: "core_offer", + title: "Основное предложение проекта", + priority: "high", + targetIntent: "Базовый интент проекта: определить предложение, аудиторию и следующий шаг.", + intentGroups: ["business"], + keywords: ["продукт", "услуга", "решение", "клиент", "проект"], + queryExamples: ["основное предложение проекта", "услуги проекта", "контакты проекта"], + targetPages: ["/"] + } + ], + commercialClusterIds: ["core_offer"], + exportTitle: "SEO analysis export", + exportFilePrefix: "seo-analysis", + analysisLimitNote: "Проектная онтология не извлечена из scan/source evidence; используется generic fallback.", + landingBrief: buildGenericLandingBrief(brandName, []) + }; +} + +function buildExtractedProjectOntology(projectId: string, evidence: ProjectOntologyEvidence): ProjectOntology { + const brandName = inferBrandName(evidence); + const brandTerms = buildBrandTerms(brandName, evidence); + const extractedClusters = buildExtractedClusters(evidence, brandName); + const instance = buildProjectOntologyInstance(evidence, brandName, brandTerms, extractedClusters); + const documents = getCoverageDocuments(evidence); + const genericLandingBrief = buildGenericLandingBrief(brandName, documents); + const commercialClusterIds = extractedClusters + .filter((cluster) => cluster.intentGroups.some((groupId) => ["commercial", "implementation", "integrations"].includes(groupId))) + .map((cluster) => cluster.id) + .slice(0, 6); + const baseOntology: ProjectOntology = { + id: `project:${projectId}:ontology:${evidence.scanVersionId}`, + schemaVersion: "seo-project-ontology.v1", + source: "extracted", + domainPackage: SEO_DOMAIN_PACKAGE_BINDING, + brandName, + brandTerms, + clusters: extractedClusters, + commercialClusterIds: commercialClusterIds.length > 0 ? commercialClusterIds : ["core_offer"], + exportTitle: `${brandName} SEO analysis export`, + exportFilePrefix: `${normalizeOntologyText(brandName).replace(/[^a-zа-я0-9]+/giu, "-").replace(/^-|-$/g, "") || "seo"}-seo`, + analysisLimitNote: "Проектная онтология извлечена эвристически из scan/source evidence; требуется dev-mode review перед rewrite/apply.", + landingBrief: genericLandingBrief, + projectOntologyInstance: instance + }; + + if (!isNodeDcSeedFixture(getExplicitSeedFixtureId(evidence))) { + return baseOntology; + } + + return { + ...NODEDC_PROJECT_ONTOLOGY, + id: baseOntology.id, + source: "seed_fixture", + domainPackage: SEO_DOMAIN_PACKAGE_BINDING, + brandName, + brandTerms: dedupeStrings([...brandTerms, ...NODEDC_PROJECT_ONTOLOGY.brandTerms]), + landingBrief: { + ...NODEDC_PROJECT_ONTOLOGY.landingBrief, + entityLabels: { + ...genericLandingBrief.entityLabels, + ...NODEDC_PROJECT_ONTOLOGY.landingBrief.entityLabels + } + }, + analysisLimitNote: + "Проектная онтология извлечена из crawl/source evidence и усилена явно выбранной NODE.DC seed fixture; использовать только для dev-mode сверки текущего сайта.", + projectOntologyInstance: instance + }; +} + +const NODEDC_CLUSTERS: SemanticClusterDefinition[] = [ + { + id: "ai_platform_development", + title: "AI-платформа для разработки и автоматизации", + priority: "high", + targetIntent: "Базовый коммерческий интент: платформа, разработка, автоматизация, искусственный интеллект.", + intentGroups: ["ai", "automation", "business", "development", "enterprise"], + keywords: [ + "платформа", + "разработка", + "искусственный интеллект", + "ai", + "llm", + "low-code", + "no-code", + "автоматизация", + "продуктовая разработка" + ], + queryExamples: [ + "платформа автоматизации бизнеса ai", + "ai платформа для разработки", + "разработка бизнес приложений с искусственным интеллектом" + ], + targetPages: ["/", "/platform/", "/modules/"] + }, + { + id: "business_process_automation", + title: "Автоматизация бизнес-процессов", + priority: "high", + targetIntent: "BPM/workflow рынок: процессы, регламенты, контроль исполнения, сквозные цепочки.", + intentGroups: ["automation", "business", "process", "operations", "implementation"], + keywords: [ + "бизнес-процесс", + "workflow", + "bpm", + "регламент", + "процесс", + "операционный контур", + "сквозной процесс", + "маршрут согласования" + ], + queryExamples: [ + "автоматизация бизнес процессов", + "workflow система для бизнеса", + "bpm платформа с искусственным интеллектом" + ], + targetPages: ["/use-cases/", "/modules/ops/", "/modules/engine/"] + }, + { + id: "agentic_automation", + title: "Агентная автоматизация и AI-ассистенты", + priority: "high", + targetIntent: "Трендовый интент: AI agents, ассистенты, автономные проверки и выполнение задач.", + intentGroups: ["agents", "ai", "automation", "operations"], + keywords: [ + "агент", + "агентная", + "ai agent", + "ассистент", + "codex", + "нейросеть", + "модель", + "автономный", + "toolpack" + ], + queryExamples: [ + "ai агенты для бизнеса", + "агентная автоматизация процессов", + "ai ассистент для разработки и операций" + ], + targetPages: ["/modules/assistant/", "/modules/ops/", "/docs/"] + }, + { + id: "ops_project_control", + title: "Ops, Tasker и управление проектами", + priority: "medium", + targetIntent: "Операционное управление: задачи, чекеры, статусы, проектные карточки.", + intentGroups: ["operations", "business", "process", "implementation"], + keywords: [ + "ops", + "tasker", + "задача", + "проект", + "проверка", + "чекер", + "статус", + "исполнитель", + "операционный" + ], + queryExamples: [ + "ai ops управление задачами", + "система контроля проектов с ai", + "автоматизация проектного офиса" + ], + targetPages: ["/modules/ops/", "/use-cases/project-office/"] + }, + { + id: "hub_workspace_rbac", + title: "Hub, workspace, роли и доступы", + priority: "medium", + targetIntent: "Рабочие пространства, запуск модулей, роли, витрина приложений.", + intentGroups: ["business", "enterprise", "operations", "security"], + keywords: [ + "hub", + "workspace", + "рабочее пространство", + "роль", + "rbac", + "доступ", + "витрина", + "launcher", + "команда" + ], + queryExamples: [ + "ai workspace для команды", + "hub платформа бизнес приложений", + "rbac для ai платформы" + ], + targetPages: ["/modules/hub/", "/security/"] + }, + { + id: "engine_infrastructure", + title: "Engine, инфраструктура и оркестрация", + priority: "medium", + targetIntent: "Технический слой: engine, docker, deploy, сервисы, mcp, контуры.", + intentGroups: ["development", "implementation", "enterprise", "integrations"], + keywords: [ + "engine", + "оркестрация", + "инфраструктура", + "docker", + "deploy", + "mcp", + "сервис", + "pipeline", + "контур" + ], + queryExamples: [ + "оркестрация ai сервисов", + "инфраструктура для ai автоматизации", + "mcp интеграции для бизнеса" + ], + targetPages: ["/modules/engine/", "/deployment/", "/docs/"] + }, + { + id: "integrations_api", + title: "Интеграции, API, 1C, CRM/ERP", + priority: "high", + targetIntent: "Коммерческий интент интеграций: 1С, CRM, ERP, API, webhooks.", + intentGroups: ["integrations", "business", "automation", "implementation"], + keywords: [ + "интеграция", + "api", + "webhook", + "1с", + "1c", + "crm", + "erp", + "битрикс", + "telegram", + "внешняя система" + ], + queryExamples: [ + "ai интеграция с 1с", + "автоматизация 1с с искусственным интеллектом", + "api платформа для интеграции бизнес систем" + ], + targetPages: ["/integrations/", "/modules/1c-assistant/"] + }, + { + id: "tender_legal_procurement", + title: "Тендеры, закупки и юридические процессы", + priority: "medium", + targetIntent: "Отраслевые сценарии с высоким value: тендеры, договоры, закупки, юристы.", + intentGroups: ["business", "process", "automation", "commercial"], + keywords: [ + "тендер", + "закупка", + "договор", + "юридический", + "юрист", + "согласование", + "коммерческое предложение", + "поставщик" + ], + queryExamples: [ + "ai для тендеров", + "автоматизация закупок с ai", + "ai ассистент для договоров" + ], + targetPages: ["/modules/tender/", "/use-cases/procurement/", "/use-cases/legal/"] + }, + { + id: "digital_twin_3d_data", + title: "Digital Twin, BIM, 3D и инженерные данные", + priority: "medium", + targetIntent: "Инженерный рынок: цифровые двойники, BIM, STEP/STL/3DGS/LAS, облака точек.", + intentGroups: ["engineering", "data", "ai", "enterprise"], + keywords: [ + "digital twin", + "цифровой двойник", + "bim", + "step", + "stl", + "3dgs", + "las", + "облако точек", + "инженерный", + "модель объекта" + ], + queryExamples: [ + "ai digital twin платформа", + "цифровой двойник bim ai", + "автоматизация инженерных данных" + ], + targetPages: ["/modules/digital-twin/", "/use-cases/construction/"] + }, + { + id: "private_cloud_security", + title: "Private cloud, on-prem и безопасность", + priority: "high", + targetIntent: "B2B enterprise интент: данные, контур, безопасность, развертывание.", + intentGroups: ["enterprise", "security", "data", "implementation"], + keywords: [ + "on-prem", + "private cloud", + "частное облако", + "безопасность", + "данные", + "контур", + "сервер", + "инфраструктура заказчика", + "изоляция" + ], + queryExamples: [ + "ai платформа on premise", + "частное облако для ai автоматизации", + "безопасная ai платформа для бизнеса" + ], + targetPages: ["/deployment/", "/security/"] + }, + { + id: "conversion_pilot_pricing", + title: "Демо, пилот, тарифы и внедрение", + priority: "high", + targetIntent: "Нижняя часть воронки: запрос демо, пилот, стоимость, внедрение.", + intentGroups: ["commercial", "business", "implementation"], + keywords: [ + "демо", + "пилот", + "тариф", + "стоимость", + "внедрение", + "проект", + "заявка", + "консультация", + "коммерческое предложение" + ], + queryExamples: [ + "заказать ai автоматизацию бизнеса", + "стоимость внедрения ai платформы", + "пилот ai автоматизации бизнес процессов" + ], + targetPages: ["/tariffs/", "/pilot/", "/contacts/"] + } +]; + +export const NODEDC_PROJECT_ONTOLOGY: ProjectOntology = { + id: "nodedc-site-seed", + schemaVersion: "seo-project-ontology.v1", + source: "seed_fixture", + domainPackage: SEO_DOMAIN_PACKAGE_BINDING, + brandName: "NodeDC", + brandTerms: ["nodedc", "node.dc", "node dc", "node.js", "нодедс", "ии", "ai", "ops", "hub", "engine"], + clusters: NODEDC_CLUSTERS, + commercialClusterIds: ["business_process_automation", "integrations_api", "conversion_pilot_pricing", "private_cloud_security"], + exportTitle: "NodeDC SEO export", + exportFilePrefix: "nodedc-seo", + analysisLimitNote: "Проектная онтология пока подключена как NODE.DC seed fixture; следующий шаг — извлекать ProjectOntologyInstance из source/crawl evidence.", + landingBrief: { + entityLabels: { + "/": "NodeDC", + "/contacts": "Контакты NodeDC", + "/deployment": "Внедрение NodeDC", + "/docs": "Документация NodeDC", + "/integrations": "Интеграции NodeDC", + "/modules": "Модули NodeDC", + "/modules/1c-assistant": "1С-ассистент NodeDC", + "/modules/assistant": "AI-ассистенты NodeDC", + "/modules/digital-twin": "Digital Twin NodeDC", + "/modules/engine": "NDC Engine", + "/modules/hub": "NDC Hub", + "/modules/ops": "NDC Ops", + "/modules/tender": "Тендерный модуль NodeDC", + "/pilot": "Пилот NodeDC", + "/platform": "Платформа NodeDC", + "/security": "Безопасность NodeDC", + "/tariffs": "Тарифы NodeDC", + "/use-cases": "Сценарии NodeDC", + "/use-cases/construction": "NodeDC для строительства", + "/use-cases/legal": "NodeDC для юридических процессов", + "/use-cases/procurement": "NodeDC для закупок", + "/use-cases/project-office": "NodeDC для проектного офиса" + }, + fallbackEntities: { + integration: "Интеграция NodeDC", + module: "Модуль NodeDC", + use_case: "Сценарий NodeDC" + }, + audiences: { + conversion: "ЛПР, которому нужно быстро понять формат пилота, стоимость и следующий шаг.", + deployment: "IT-директор и архитектор, которые оценивают развертывание в корпоративном контуре.", + docs: "Техническая команда, которая хочет понять подключение, API, модули и ограничения.", + home: "Бизнес и технический ЛПР, который впервые оценивает платформу NodeDC.", + integration: "Руководитель автоматизации и интегратор, которым важны 1С, CRM/ERP, API и webhooks.", + landing: "ЛПР по цифровизации, которому нужно связать проблему, решение и внедрение.", + module: "Владелец процесса и техническая команда, которые выбирают конкретный модуль платформы.", + security: "Enterprise/IT/security команда, которая проверяет доступы, контур, данные и риски.", + use_case: "Владелец бизнес-процесса, которому нужен понятный сценарий применения NodeDC." + }, + baseInternalLinks: ["/", "/platform/", "/modules/", "/integrations/", "/deployment/", "/security/", "/pilot/", "/contacts/"], + typedInternalLinks: { + conversion: ["/platform/", "/modules/", "/contacts/"], + deployment: ["/security/", "/docs/", "/integrations/"], + docs: ["/modules/", "/integrations/", "/deployment/"], + home: ["/platform/", "/modules/", "/pilot/"], + integration: ["/modules/engine/", "/security/", "/pilot/"], + landing: ["/platform/", "/modules/", "/pilot/"], + module: ["/integrations/", "/deployment/", "/security/", "/pilot/"], + security: ["/deployment/", "/docs/", "/contacts/"], + use_case: ["/modules/ops/", "/integrations/", "/pilot/"] + }, + typeSections: { + conversion: { + title: "Формат пилота и следующий шаг", + goal: "Показать, как начинается проект, что входит в пилот и какие данные нужны для оценки." + }, + deployment: { + title: "Архитектура внедрения", + goal: "Раскрыть контур, инфраструктуру, этапы развертывания и ответственность сторон." + }, + docs: { + title: "Как подключиться", + goal: "Дать технический вход: модули, API, интеграции, роли и ограничения." + }, + home: { + title: "Платформа и модули", + goal: "Связать общий value proposition с конкретными модулями и сценариями." + }, + integration: { + title: "Системы и API", + goal: "Показать подключение к 1С, CRM/ERP, API, webhooks и внешним контурам." + }, + landing: { + title: "Как NodeDC закрывает задачу", + goal: "Собрать проблему, решение, результат и следующий коммерческий шаг." + }, + module: { + title: "Как работает модуль", + goal: "Объяснить входные данные, действия модуля, роли, статусы и результат для бизнеса." + }, + security: { + title: "Контур, доступы и данные", + goal: "Раскрыть безопасность, роли, RBAC, изоляцию, хранение данных и аудит." + }, + use_case: { + title: "Сценарий до и после внедрения", + goal: "Показать текущую боль процесса, целевой workflow и измеримый эффект." + } + } + } +}; + +export const projectOntologyProvider: ProjectOntologyProvider = { + async getProjectOntology(projectId: string, evidence?: ProjectOntologyEvidence) { + return evidence ? buildExtractedProjectOntology(projectId, evidence) : buildGenericFallbackOntology(projectId); + } +}; + +export async function getProjectOntology(projectId: string, evidence?: ProjectOntologyEvidence): Promise<ProjectOntology> { + return projectOntologyProvider.getProjectOntology(projectId, evidence); +} diff --git a/seo_mode/seo_mode/server/src/ontology/projectOntologyRepository.ts b/seo_mode/seo_mode/server/src/ontology/projectOntologyRepository.ts new file mode 100644 index 0000000..2e70e44 --- /dev/null +++ b/seo_mode/seo_mode/server/src/ontology/projectOntologyRepository.ts @@ -0,0 +1,494 @@ +import { pool } from "../db/client.js"; +import type { ProjectOntology, ProjectOntologyInstance } from "./projectOntology.js"; + +export type ProjectOntologyApprovalStatus = "approved" | "draft" | "needs_review" | "rejected"; + +export class ProjectOntologyApprovalError extends Error { + constructor( + message: string, + readonly blockers: NonNullable<ProjectOntologyEvidenceSummary["reviewItems"]> + ) { + super(message); + this.name = "ProjectOntologyApprovalError"; + } +} + +export type ProjectOntologyConfirmationState = { + confirmed: boolean; + confirmedAt: string | null; + confirmedBy: string | null; +}; + +export type ProjectOntologyVersionRecord = { + id: string; + projectId: string; + scanVersionId: string | null; + semanticRunId: string | null; + version: number; + schemaVersion: "seo-project-ontology.v1"; + source: ProjectOntology["source"]; + brandName: string; + approvalStatus: ProjectOntologyApprovalStatus; + domainPackage: ProjectOntology["domainPackage"]; + instance: ProjectOntologyInstance; + evidenceSummary: ProjectOntologyEvidenceSummary; + createdAt: string; + updatedAt: string; + approvedAt: string | null; + approvedBy: string | null; +}; + +export type ProjectOntologyEvidenceSummary = { + pageCount: number; + sectionCount: number; + intentClusterCount: number; + evidenceCount: number; + confirmations?: { + brand: ProjectOntologyConfirmationState; + aliases: ProjectOntologyConfirmationState; + }; + reviewItems?: Array<{ + id: string; + level: "ok" | "problem" | "warning"; + title: string; + message: string; + }>; + pages: Array<{ + id: string; + url: string; + title: string; + }>; + intentClusters: Array<{ + id: string; + title: string; + priority: ProjectOntologyInstance["intentClusters"][number]["priority"]; + }>; + evidence: Array<{ + id: string; + sourceType: string; + ref: string; + summary: string; + }>; +}; + +function isGenericBrandName(value: string) { + return ["проект", "site", "website", "сайт"].includes(value.trim().toLocaleLowerCase("ru-RU")); +} + +function getDefaultConfirmations(): NonNullable<ProjectOntologyEvidenceSummary["confirmations"]> { + return { + aliases: { + confirmed: false, + confirmedAt: null, + confirmedBy: null + }, + brand: { + confirmed: false, + confirmedAt: null, + confirmedBy: null + } + }; +} + +function normalizeConfirmations( + confirmations: ProjectOntologyEvidenceSummary["confirmations"] | undefined +): NonNullable<ProjectOntologyEvidenceSummary["confirmations"]> { + const defaults = getDefaultConfirmations(); + + return { + aliases: { + ...defaults.aliases, + ...(confirmations?.aliases ?? {}) + }, + brand: { + ...defaults.brand, + ...(confirmations?.brand ?? {}) + } + }; +} + +function buildReviewItems( + instance: ProjectOntologyInstance, + confirmations = getDefaultConfirmations() +): NonNullable<ProjectOntologyEvidenceSummary["reviewItems"]> { + const reviewItems: NonNullable<ProjectOntologyEvidenceSummary["reviewItems"]> = []; + + if (isGenericBrandName(instance.brand.name)) { + reviewItems.push({ + id: "brand.generic", + level: "problem", + title: "Brand не подтвержден", + message: "Extractor не нашел уверенное имя бренда. Перед approval нужно вручную подтвердить brand.name и aliases." + }); + } else if (!confirmations.brand.confirmed) { + reviewItems.push({ + id: "brand.confirmation_required", + level: "warning", + title: "Brand ждёт подтверждения", + message: "Проверь brand.name перед Wordstat, keyword map и rewrite, чтобы модель не тянула чужой контекст." + }); + } + + if (instance.brand.aliases.length === 0 && !isGenericBrandName(instance.brand.name) && !confirmations.aliases.confirmed) { + reviewItems.push({ + id: "brand.aliases_empty", + level: "warning", + title: "Нет aliases", + message: "Проверь, нет ли короткого написания, доменного имени или альтернативного названия бренда." + }); + } else if (instance.brand.aliases.length > 0 && !confirmations.aliases.confirmed) { + reviewItems.push({ + id: "brand.aliases_confirmation_required", + level: "warning", + title: "Aliases ждут подтверждения", + message: "Проверь альтернативные написания бренда, чтобы seed generation не закрепил ошибочные варианты." + }); + } + + if (instance.pages.length === 0) { + reviewItems.push({ + id: "scope.no_pages", + level: "problem", + title: "Нет страниц в scope", + message: "Project ontology не содержит страниц. Semantic/rewrite нельзя считать надежными до проверки scan scope." + }); + } + + if (instance.intentClusters.length <= 1) { + reviewItems.push({ + id: "intent.thin_clusters", + level: "warning", + title: "Мало intent clusters", + message: "Extractor нашел только базовый интент. Нужна проверка offer/audience/market wording перед Wordstat и rewrite." + }); + } + + if (instance.evidence.length <= 1) { + reviewItems.push({ + id: "evidence.thin", + level: "warning", + title: "Мало evidence", + message: "Онтология почти не подкреплена evidence refs. Проверь, что HTML/JSON контент попал в скан." + }); + } + + if (instance.constraints.forbiddenClaims.length === 0) { + reviewItems.push({ + id: "constraints.no_forbidden_claims", + level: "warning", + title: "Нет forbidden claims", + message: "Перед rewrite нужно явно зафиксировать, какие обещания модель не должна добавлять без evidence." + }); + } + + if (reviewItems.length === 0) { + reviewItems.push({ + id: "review.ready", + level: "ok", + title: "Критичных вопросов нет", + message: "Базовая extracted ontology выглядит пригодной для dev-mode review." + }); + } + + return reviewItems; +} + +type ProjectOntologyVersionRow = { + id: string; + project_id: string; + scan_version_id: string | null; + semantic_run_id: string | null; + version: number; + schema_version: "seo-project-ontology.v1"; + source: ProjectOntology["source"]; + brand_name: string; + approval_status: ProjectOntologyApprovalStatus; + domain_package: ProjectOntology["domainPackage"]; + instance: ProjectOntologyInstance; + evidence_summary: ProjectOntologyEvidenceSummary; + created_at: Date; + updated_at: Date; + approved_at: Date | null; + approved_by: string | null; +}; + +function toIsoDate(value: Date | null) { + return value ? value.toISOString() : null; +} + +function buildEvidenceSummary(instance: ProjectOntologyInstance): ProjectOntologyEvidenceSummary { + const confirmations = getDefaultConfirmations(); + + return { + pageCount: instance.pages.length, + sectionCount: instance.sections.length, + intentClusterCount: instance.intentClusters.length, + evidenceCount: instance.evidence.length, + confirmations, + reviewItems: buildReviewItems(instance, confirmations), + pages: instance.pages.slice(0, 12).map((page) => ({ + id: page.id, + title: page.title, + url: page.url + })), + intentClusters: instance.intentClusters.slice(0, 12).map((cluster) => ({ + id: cluster.id, + priority: cluster.priority, + title: cluster.title + })), + evidence: instance.evidence.slice(0, 12).map((evidence) => ({ + id: evidence.id, + ref: evidence.ref, + sourceType: evidence.sourceType, + summary: evidence.summary + })) + }; +} + +function hydrateEvidenceSummary( + instance: ProjectOntologyInstance, + summary: ProjectOntologyEvidenceSummary +): ProjectOntologyEvidenceSummary { + const confirmations = normalizeConfirmations(summary.confirmations); + + return { + ...summary, + confirmations, + reviewItems: buildReviewItems(instance, confirmations) + }; +} + +function mapProjectOntologyVersion(row: ProjectOntologyVersionRow): ProjectOntologyVersionRecord { + return { + id: row.id, + projectId: row.project_id, + scanVersionId: row.scan_version_id, + semanticRunId: row.semantic_run_id, + version: row.version, + schemaVersion: row.schema_version, + source: row.source, + brandName: row.brand_name, + approvalStatus: row.approval_status, + domainPackage: row.domain_package, + instance: row.instance, + evidenceSummary: hydrateEvidenceSummary(row.instance, row.evidence_summary), + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + approvedAt: toIsoDate(row.approved_at), + approvedBy: row.approved_by + }; +} + +async function getProjectOntologyVersionRow(projectId: string, versionId: string) { + const result = await pool.query<ProjectOntologyVersionRow>( + ` + select * + from project_ontology_versions + where id = $1 and project_id = $2 + limit 1; + `, + [versionId, projectId] + ); + + return result.rows[0] ?? null; +} + +async function getNextProjectOntologyVersion(projectId: string) { + const result = await pool.query<{ next_version: number }>( + ` + select coalesce(max(version), 0) + 1 as next_version + from project_ontology_versions + where project_id = $1; + `, + [projectId] + ); + + return result.rows[0]?.next_version ?? 1; +} + +export async function saveProjectOntologyVersion(input: { + ontology: ProjectOntology; + projectId: string; + scanVersionId: string; + semanticRunId?: string | null; +}) { + if (!input.ontology.projectOntologyInstance) { + return null; + } + + const version = await getNextProjectOntologyVersion(input.projectId); + const instance = input.ontology.projectOntologyInstance; + const evidenceSummary = buildEvidenceSummary(instance); + const result = await pool.query<ProjectOntologyVersionRow>( + ` + insert into project_ontology_versions ( + project_id, + scan_version_id, + semantic_run_id, + version, + schema_version, + source, + brand_name, + approval_status, + domain_package, + instance, + evidence_summary + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11::jsonb) + returning *; + `, + [ + input.projectId, + input.scanVersionId, + input.semanticRunId ?? null, + version, + instance.schemaVersion, + input.ontology.source, + input.ontology.brandName, + instance.approvals.status, + JSON.stringify(input.ontology.domainPackage), + JSON.stringify(instance), + JSON.stringify(evidenceSummary) + ] + ); + + return mapProjectOntologyVersion(result.rows[0]); +} + +export async function linkProjectOntologyVersionToRun(versionId: string, semanticRunId: string) { + const result = await pool.query<ProjectOntologyVersionRow>( + ` + update project_ontology_versions + set semantic_run_id = $2, + updated_at = now() + where id = $1 + returning *; + `, + [versionId, semanticRunId] + ); + + return result.rows[0] ? mapProjectOntologyVersion(result.rows[0]) : null; +} + +export async function getLatestProjectOntologyVersion(projectId: string) { + const result = await pool.query<ProjectOntologyVersionRow>( + ` + select * + from project_ontology_versions + where project_id = $1 + order by version desc + limit 1; + `, + [projectId] + ); + + return result.rows[0] ? mapProjectOntologyVersion(result.rows[0]) : null; +} + +export async function getProjectOntologyVersion(projectId: string, versionId: string) { + const row = await getProjectOntologyVersionRow(projectId, versionId); + + return row ? mapProjectOntologyVersion(row) : null; +} + +export async function updateProjectOntologyApproval(input: { + approvedBy?: string | null; + projectId: string; + status: ProjectOntologyApprovalStatus; + versionId: string; +}) { + if (input.status === "approved") { + const currentRow = await getProjectOntologyVersionRow(input.projectId, input.versionId); + + if (!currentRow) { + return null; + } + + const evidenceSummary = hydrateEvidenceSummary(currentRow.instance, currentRow.evidence_summary); + const blockers = (evidenceSummary.reviewItems ?? []).filter((item) => item.level === "problem"); + + if (blockers.length > 0) { + throw new ProjectOntologyApprovalError("Project ontology нельзя approve, пока есть problem review items.", blockers); + } + } + + const result = await pool.query<ProjectOntologyVersionRow>( + ` + update project_ontology_versions + set approval_status = $3, + approved_at = case when $3 = 'approved' then now() else null end, + approved_by = case when $3 = 'approved' then $4 else null end, + updated_at = now(), + instance = jsonb_set( + jsonb_set( + jsonb_set(instance, '{approvals,status}', to_jsonb($3::text), true), + '{approvals,approvedAt}', + case when $3 = 'approved' then to_jsonb(now()::text) else 'null'::jsonb end, + true + ), + '{approvals,approvedBy}', + case when $3 = 'approved' then to_jsonb($4::text) else 'null'::jsonb end, + true + ) + where id = $1 and project_id = $2 + returning *; + `, + [input.versionId, input.projectId, input.status, input.approvedBy ?? null] + ); + + return result.rows[0] ? mapProjectOntologyVersion(result.rows[0]) : null; +} + +export async function updateProjectOntologyConfirmations(input: { + aliasesConfirmed?: boolean; + brandConfirmed?: boolean; + confirmedBy?: string | null; + projectId: string; + versionId: string; +}) { + const currentRow = await getProjectOntologyVersionRow(input.projectId, input.versionId); + + if (!currentRow) { + return null; + } + + const evidenceSummary = hydrateEvidenceSummary(currentRow.instance, currentRow.evidence_summary); + const confirmedAt = new Date().toISOString(); + const confirmedBy = input.confirmedBy ?? null; + const confirmations = { + aliases: + typeof input.aliasesConfirmed === "boolean" + ? { + confirmed: input.aliasesConfirmed, + confirmedAt: input.aliasesConfirmed ? confirmedAt : null, + confirmedBy: input.aliasesConfirmed ? confirmedBy : null + } + : evidenceSummary.confirmations?.aliases ?? getDefaultConfirmations().aliases, + brand: + typeof input.brandConfirmed === "boolean" + ? { + confirmed: input.brandConfirmed, + confirmedAt: input.brandConfirmed ? confirmedAt : null, + confirmedBy: input.brandConfirmed ? confirmedBy : null + } + : evidenceSummary.confirmations?.brand ?? getDefaultConfirmations().brand + }; + const nextSummary: ProjectOntologyEvidenceSummary = { + ...evidenceSummary, + confirmations, + reviewItems: buildReviewItems(currentRow.instance, confirmations) + }; + + const result = await pool.query<ProjectOntologyVersionRow>( + ` + update project_ontology_versions + set evidence_summary = $3::jsonb, + updated_at = now() + where id = $1 and project_id = $2 + returning *; + `, + [input.versionId, input.projectId, JSON.stringify(nextSummary)] + ); + + return result.rows[0] ? mapProjectOntologyVersion(result.rows[0]) : null; +} diff --git a/seo_mode/seo_mode/server/src/projects/routes.ts b/seo_mode/seo_mode/server/src/projects/routes.ts index e9c8b40..a76b765 100644 --- a/seo_mode/seo_mode/server/src/projects/routes.ts +++ b/seo_mode/seo_mode/server/src/projects/routes.ts @@ -1,5 +1,5 @@ import { Router } from "express"; -import type { Response } from "express"; +import type { Request, Response } from "express"; import crypto from "node:crypto"; import { z } from "zod"; import { @@ -31,7 +31,69 @@ import { runSemanticAnalysis, SemanticAnalysisError } from "../analysis/semanticAnalysis.js"; +import { + getLatestSeoContextReview, + runSeoContextReviewDraft, + saveSeoContextReviewManual, + type SeoContextReviewOutput +} from "../contextReview/seoContextReview.js"; import { getMarketEnrichmentContract, runMarketEnrichment } from "../market/marketEnrichment.js"; +import { getSeoModelContextContract } from "../modelContext/seoModelContext.js"; +import { getSeoStrategyContract } from "../strategy/seoStrategy.js"; +import { + getLatestStrategySynthesisContract, + runStrategySynthesisDraft, + saveStrategySynthesisManual, + type StrategySynthesisContract +} from "../strategy/strategySynthesis.js"; +import { + getLatestStrategyQualityReviewContract, + runStrategyQualityReviewDraft, + saveStrategyQualityReviewManual, + type StrategyQualityReviewContract +} from "../strategy/strategyQualityReview.js"; +import { + getLatestYandexEvidenceContract, + runYandexEvidenceCollection +} from "../evidence/yandexEvidence.js"; +import { + getLatestSerpInterpretationContract, + runSerpInterpretationDraft, + saveSerpInterpretationManual, + type SerpInterpretationContract +} from "../evidence/serpInterpretation.js"; +import { + getSeoModelProviderStatusContract, + runSeoModelTask +} from "../modelProvider/seoModelTaskRuns.js"; +import { + SEO_MODEL_PROVIDER_IDS, + SEO_MODEL_TASK_TYPES +} from "../modelProvider/modelProviderRegistry.js"; +import { + getSeoNormalizationContract, + saveSeoNormalizationManual, + type SeoNormalizationContract +} from "../normalization/seoNormalization.js"; +import { + getKeywordCleaningContract, + saveKeywordCleaningManual, + type KeywordCleaningContract +} from "../keywords/keywordCleaning.js"; +import { + getLatestProjectOntologyVersion, + ProjectOntologyApprovalError, + updateProjectOntologyApproval, + updateProjectOntologyConfirmations +} from "../ontology/projectOntologyRepository.js"; +import { approveKeywordMapDecisions, getKeywordMapContract } from "../keywords/keywordMap.js"; +import { + applyMaterializationAction, + getMaterializationContract, + MaterializationError +} from "../rewrite/materialization.js"; +import { getRewriteDiffContract } from "../rewrite/rewriteDiff.js"; +import { getRewritePlanContract } from "../rewrite/rewritePlan.js"; import { storageProvider } from "../storage/index.js"; export const projectsRouter = Router(); @@ -71,6 +133,80 @@ const updatePageWorkspaceSchema = z.object({ }); const semanticExportFormatSchema = z.enum(["csv", "json", "markdown"]).default("json"); +const ontologyApprovalSchema = z.object({ + status: z.enum(["approved", "draft", "needs_review", "rejected"]) +}); +const ontologyConfirmationsSchema = z + .object({ + aliasesConfirmed: z.boolean().optional(), + brandConfirmed: z.boolean().optional() + }) + .refine((value) => typeof value.aliasesConfirmed === "boolean" || typeof value.brandConfirmed === "boolean", { + message: "Нужно передать brandConfirmed или aliasesConfirmed." + }); +const keywordMapApproveSchema = z.object({ + itemIds: z.array(z.string().min(1)).optional() +}); +const contextReviewManualSchema = z.object({ + output: z.record(z.string(), z.unknown()) +}); +const seoNormalizationManualSchema = z.object({ + output: z.record(z.string(), z.unknown()) +}); +const keywordCleaningManualSchema = z.object({ + output: z.record(z.string(), z.unknown()) +}); +const serpInterpretationManualSchema = z.object({ + output: z.record(z.string(), z.unknown()) +}); +const strategySynthesisManualSchema = z.object({ + output: z.record(z.string(), z.unknown()) +}); +const strategyQualityReviewManualSchema = z.object({ + output: z.record(z.string(), z.unknown()) +}); +const modelTaskRunSchema = z + .object({ + providerId: z.enum(SEO_MODEL_PROVIDER_IDS).optional(), + taskId: z.string().trim().min(1).optional(), + taskType: z.enum(SEO_MODEL_TASK_TYPES).optional() + }) + .refine((value) => Boolean(value.taskId || value.taskType), { + message: "Нужно передать taskId или taskType." + }); +const yandexEvidenceRunSchema = z.object({ + phraseLimit: z.number().int().min(1).max(12).optional() +}); +const materializationActionSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("approve_source_plan"), + note: z.string().trim().max(1000).optional(), + sourcePath: z.string().trim().min(1).max(500), + targetPath: z.string().trim().min(1) + }), + z.object({ + action: z.literal("create_placeholder"), + note: z.string().trim().max(1000).optional(), + targetPath: z.string().trim().min(1), + title: z.string().trim().max(160).optional() + }), + z.object({ + action: z.literal("defer"), + note: z.string().trim().max(1000).optional(), + targetPath: z.string().trim().min(1) + }), + z.object({ + action: z.literal("link_existing_page"), + note: z.string().trim().max(1000).optional(), + pageId: projectIdSchema, + targetPath: z.string().trim().min(1) + }), + z.object({ + action: z.literal("reject"), + note: z.string().trim().max(1000).optional(), + targetPath: z.string().trim().min(1) + }) +]); const scopeLayoutKeySchema = z.string().trim().min(1).max(240); const scopeLayoutSchema = z.object({ version: z.number().int().positive(), @@ -105,6 +241,24 @@ function sendError(response: Response, status: number, message: string) { }); } +async function sendSeoModelContext(request: Request, response: Response) { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + aiContext: await getSeoModelContextContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить AI context проекта."); + } +} + function getScopeLayoutObjectKey(projectId: string, layoutKey: string) { const digest = crypto.createHash("sha256").update(layoutKey).digest("hex").slice(0, 40); @@ -347,6 +501,149 @@ projectsRouter.get("/:projectId/analysis/semantic/export", async (request, respo } }); +projectsRouter.get("/:projectId/context-review/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + contextReview: await getLatestSeoContextReview(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить context review проекта."); + } +}); + +projectsRouter.post("/:projectId/context-review", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const contextReview = await runSeoContextReviewDraft(projectId); + + if (!contextReview) { + sendError(response, 404, "Сначала нужен semantic analysis проекта."); + return; + } + + response.status(201).json({ + contextReview + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось сохранить context review проекта."); + } +}); + +projectsRouter.post("/:projectId/context-review/manual", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = contextReviewManualSchema.parse(request.body ?? {}); + + response.status(201).json({ + contextReview: await saveSeoContextReviewManual(projectId, input.output as SeoContextReviewOutput) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь manual context review payload."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual context review."); + } +}); + +projectsRouter.get("/:projectId/ontology/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + ontology: await getLatestProjectOntologyVersion(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить project ontology."); + } +}); + +projectsRouter.patch("/:projectId/ontology/:versionId/approval", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const versionId = projectIdSchema.parse(request.params.versionId); + const input = ontologyApprovalSchema.parse(request.body); + const ontology = await updateProjectOntologyApproval({ + approvedBy: "dev-mode", + projectId, + status: input.status, + versionId + }); + + if (!ontology) { + sendError(response, 404, "Project ontology version не найдена."); + return; + } + + response.json({ ontology }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь статус project ontology."); + return; + } + + if (error instanceof ProjectOntologyApprovalError) { + sendError(response, 400, error.message); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось обновить approval project ontology."); + } +}); + +projectsRouter.patch("/:projectId/ontology/:versionId/confirmations", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const versionId = projectIdSchema.parse(request.params.versionId); + const input = ontologyConfirmationsSchema.parse(request.body); + const ontology = await updateProjectOntologyConfirmations({ + aliasesConfirmed: input.aliasesConfirmed, + brandConfirmed: input.brandConfirmed, + confirmedBy: "dev-mode", + projectId, + versionId + }); + + if (!ontology) { + sendError(response, 404, "Project ontology version не найдена."); + return; + } + + response.json({ ontology }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь confirmations project ontology."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось обновить confirmations project ontology."); + } +}); + projectsRouter.get("/:projectId/market-enrichment/latest", async (request, response) => { try { const projectId = projectIdSchema.parse(request.params.projectId); @@ -365,6 +662,120 @@ projectsRouter.get("/:projectId/market-enrichment/latest", async (request, respo } }); +projectsRouter.get("/:projectId/seo-normalization/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + normalization: await getSeoNormalizationContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить SEO normalization проекта."); + } +}); + +projectsRouter.post("/:projectId/seo-normalization/manual", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = seoNormalizationManualSchema.parse(request.body ?? {}); + + response.status(201).json({ + normalization: await saveSeoNormalizationManual(projectId, input.output as SeoNormalizationContract) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь manual SEO normalization payload."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual SEO normalization."); + } +}); + +projectsRouter.get("/:projectId/keyword-cleaning/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + keywordCleaning: await getKeywordCleaningContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить keyword cleaning проекта."); + } +}); + +projectsRouter.post("/:projectId/keyword-cleaning/manual", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = keywordCleaningManualSchema.parse(request.body ?? {}); + + response.status(201).json({ + keywordCleaning: await saveKeywordCleaningManual(projectId, input.output as KeywordCleaningContract) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь manual keyword cleaning payload."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual keyword cleaning."); + } +}); + +projectsRouter.get("/:projectId/ai-context/latest", sendSeoModelContext); +projectsRouter.get("/:projectId/mcp-context/latest", sendSeoModelContext); + +projectsRouter.get("/:projectId/model-provider/status", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + modelProvider: await getSeoModelProviderStatusContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить model provider status проекта."); + } +}); + +projectsRouter.post("/:projectId/model-tasks/run", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = modelTaskRunSchema.parse(request.body ?? {}); + + response.status(202).json({ + modelTaskRun: await runSeoModelTask(projectId, input) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь model task run input."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось выполнить model task run."); + } +}); + projectsRouter.post("/:projectId/market-enrichment", async (request, response) => { try { const projectId = projectIdSchema.parse(request.params.projectId); @@ -389,6 +800,355 @@ projectsRouter.post("/:projectId/market-enrichment", async (request, response) = } }); +projectsRouter.get("/:projectId/keyword-map/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + keywordMap: await getKeywordMapContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить keyword map проекта."); + } +}); + +projectsRouter.get("/:projectId/strategy/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + strategy: await getSeoStrategyContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить SEO strategy проекта."); + } +}); + +projectsRouter.get("/:projectId/strategy-synthesis/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + strategySynthesis: await getLatestStrategySynthesisContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить strategy synthesis проекта."); + } +}); + +projectsRouter.post("/:projectId/strategy-synthesis", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const strategySynthesis = await runStrategySynthesisDraft(projectId); + + if (!strategySynthesis) { + sendError(response, 404, "Сначала нужны seo-strategy.v1 opportunities и ready SERP interpretation."); + return; + } + + response.status(201).json({ + strategySynthesis + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось сохранить strategy synthesis проекта."); + } +}); + +projectsRouter.post("/:projectId/strategy-synthesis/manual", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = strategySynthesisManualSchema.parse(request.body ?? {}); + + response.status(201).json({ + strategySynthesis: await saveStrategySynthesisManual(projectId, input.output as StrategySynthesisContract) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь manual strategy synthesis payload."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual strategy synthesis."); + } +}); + +projectsRouter.get("/:projectId/strategy-quality-review/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + strategyQualityReview: await getLatestStrategyQualityReviewContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить strategy quality review проекта."); + } +}); + +projectsRouter.post("/:projectId/strategy-quality-review", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const strategyQualityReview = await runStrategyQualityReviewDraft(projectId); + + if (!strategyQualityReview) { + sendError(response, 404, "Сначала нужен ready seo-strategy-synthesis.v1."); + return; + } + + response.status(201).json({ + strategyQualityReview + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось сохранить strategy quality review проекта."); + } +}); + +projectsRouter.post("/:projectId/strategy-quality-review/manual", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = strategyQualityReviewManualSchema.parse(request.body ?? {}); + + response.status(201).json({ + strategyQualityReview: await saveStrategyQualityReviewManual(projectId, input.output as StrategyQualityReviewContract) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь manual strategy quality review payload."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual strategy quality review."); + } +}); + +projectsRouter.get("/:projectId/yandex-evidence/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + yandexEvidence: await getLatestYandexEvidenceContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить Yandex evidence проекта."); + } +}); + +projectsRouter.post("/:projectId/yandex-evidence", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = yandexEvidenceRunSchema.parse(request.body ?? {}); + + response.status(202).json({ + yandexEvidence: await runYandexEvidenceCollection(projectId, input) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры Yandex evidence."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось собрать Yandex evidence проекта."); + } +}); + +projectsRouter.get("/:projectId/serp-interpretation/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + serpInterpretation: await getLatestSerpInterpretationContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить SERP interpretation проекта."); + } +}); + +projectsRouter.post("/:projectId/serp-interpretation", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const serpInterpretation = await runSerpInterpretationDraft(projectId); + + if (!serpInterpretation) { + sendError(response, 404, "Сначала нужен ready yandex-evidence.v1 с SERP docs."); + return; + } + + response.status(201).json({ + serpInterpretation + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось сохранить SERP interpretation проекта."); + } +}); + +projectsRouter.post("/:projectId/serp-interpretation/manual", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = serpInterpretationManualSchema.parse(request.body ?? {}); + + response.status(201).json({ + serpInterpretation: await saveSerpInterpretationManual(projectId, input.output as SerpInterpretationContract) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь manual SERP interpretation payload."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual SERP interpretation."); + } +}); + +projectsRouter.post("/:projectId/keyword-map/decisions", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = keywordMapApproveSchema.parse(request.body ?? {}); + + response.status(202).json(await approveKeywordMapDecisions(projectId, input)); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь keyword map decisions."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось сохранить keyword map decisions."); + } +}); + +projectsRouter.get("/:projectId/rewrite-plan/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + rewritePlan: await getRewritePlanContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить rewrite plan проекта."); + } +}); + +projectsRouter.get("/:projectId/materialization/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + materialization: await getMaterializationContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить materialization contract проекта."); + } +}); + +projectsRouter.get("/:projectId/rewrite-diff/latest", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + + response.json({ + rewriteDiff: await getRewriteDiffContract(projectId) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось загрузить rewrite diff contract проекта."); + } +}); + +projectsRouter.post("/:projectId/materialization/actions", async (request, response) => { + try { + const projectId = projectIdSchema.parse(request.params.projectId); + const input = materializationActionSchema.parse(request.body); + + response.status(202).json(await applyMaterializationAction(projectId, input)); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь materialization action."); + return; + } + + if (error instanceof MaterializationError) { + sendError(response, 400, error.message); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось применить materialization action."); + } +}); + projectsRouter.get("/:projectId/scope-layouts/:layoutKey", async (request, response) => { try { const projectId = projectIdSchema.parse(request.params.projectId); diff --git a/seo_mode/seo_mode/server/src/rewrite/materialization.ts b/seo_mode/seo_mode/server/src/rewrite/materialization.ts new file mode 100644 index 0000000..2ad526f --- /dev/null +++ b/seo_mode/seo_mode/server/src/rewrite/materialization.ts @@ -0,0 +1,901 @@ +import { pool } from "../db/client.js"; +import { getRewritePlanContract, type RewritePlanContract } from "./rewritePlan.js"; + +type MaterializationAction = "approve_source_plan" | "create_placeholder" | "defer" | "link_existing_page" | "reject"; +type MaterializationStatus = + | "awaiting_source" + | "can_link_existing" + | "deferred" + | "linked_existing" + | "needs_materialization" + | "planned_placeholder" + | "rejected"; +type MaterializationState = "blocked" | "complete" | "partial" | "pending"; + +type LatestPageCandidateRow = { + page_id: string; + source_path: string; + url_path: string | null; + title: string | null; + selected: boolean; +}; + +type ProjectSourceContextRow = { + source_type: string; + source_config: Record<string, unknown>; +}; + +type MaterializationDecisionRow = { + id: string; + semantic_run_id: string | null; + project_ontology_version_id: string | null; + target_key: string; + target_path: string | null; + action: MaterializationAction; + status: MaterializationStatus; + page_id: string | null; + note: string | null; + payload: Record<string, unknown>; + updated_at: Date; +}; + +export type MaterializationContract = { + schemaVersion: "materialization-contract.v1"; + projectId: string; + semanticRunId: string | null; + projectOntologyVersionId: string | null; + generatedAt: string; + state: MaterializationState; + rewritePlanState: RewritePlanContract["state"]; + readiness: { + awaitingSourceCount: number; + blockerCount: number; + linkedTargetCount: number; + pendingTargetCount: number; + placeholderTargetCount: number; + rejectedOrDeferredCount: number; + targetCount: number; + }; + blockers: string[]; + targets: MaterializationTarget[]; + nextActions: string[]; +}; + +export type MaterializationTarget = { + id: string; + targetKey: string; + targetPath: string | null; + normalizedTargetPath: string | null; + status: MaterializationStatus; + pageId: string | null; + pageTitle: string | null; + sourcePath: string | null; + urlPath: string | null; + blocker: string | null; + sourcePlan: { + action: "create_source_snapshot" | "link_existing_page" | "manual_review"; + reason: string; + requiresApproval: boolean; + sourceType: string | null; + status: "approval_required" | "can_link_existing" | "manual_review" | "source_approved"; + suggestedSourcePath: string | null; + }; + decision: { + id: string; + action: MaterializationAction; + note: string | null; + sourcePath: string | null; + updatedAt: string; + } | null; + linkCandidates: Array<{ + pageId: string; + sourcePath: string; + urlPath: string | null; + title: string | null; + selected: boolean; + match: "exact" | "home" | "selected"; + }>; + keywordBindings: Array<{ + decisionId: string; + mapItemId: string | null; + phrase: string; + role: string; + field: string; + frequency: number | null; + frequencyGroup: string | null; + }>; +}; + +export type MaterializationActionInput = + | { + action: "approve_source_plan"; + note?: string; + sourcePath: string; + targetPath: string; + } + | { + action: "create_placeholder"; + note?: string; + targetPath: string; + title?: string; + } + | { + action: "defer"; + note?: string; + targetPath: string; + } + | { + action: "link_existing_page"; + note?: string; + pageId: string; + targetPath: string; + } + | { + action: "reject"; + note?: string; + targetPath: string; + }; + +export class MaterializationError extends Error { + constructor(message: string) { + super(message); + this.name = "MaterializationError"; + } +} + +function normalizeTargetPath(value: string | null) { + if (!value) { + return null; + } + + const cleanValue = value.trim(); + + if (!cleanValue) { + return null; + } + + const withLeadingSlash = cleanValue.startsWith("/") ? cleanValue : `/${cleanValue}`; + + if (withLeadingSlash === "/") { + return "/"; + } + + return withLeadingSlash.endsWith("/") ? withLeadingSlash : `${withLeadingSlash}/`; +} + +function getTargetKey(targetPath: string | null) { + return normalizeTargetPath(targetPath) ?? "target:unknown"; +} + +function getTargetVariants(targetPath: string | null) { + const normalized = normalizeTargetPath(targetPath); + + if (!normalized) { + return []; + } + + return Array.from( + new Set([ + normalized, + normalized.endsWith("/") && normalized !== "/" ? normalized.slice(0, -1) : normalized, + normalized.startsWith("/") ? normalized.slice(1) : normalized + ]) + ).filter(Boolean); +} + +function getStringConfig(config: Record<string, unknown>, key: string) { + const value = config[key]; + + return typeof value === "string" ? value : null; +} + +function getSuggestedSourcePath(targetPath: string | null) { + const normalized = normalizeTargetPath(targetPath); + + if (!normalized) { + return null; + } + + if (normalized === "/") { + return "index.html"; + } + + const cleanPath = normalized.replace(/^\/+|\/+$/g, ""); + + if (!cleanPath) { + return "index.html"; + } + + if (/\.[a-z0-9]+$/i.test(cleanPath)) { + return cleanPath; + } + + return `${cleanPath}/index.html`; +} + +function normalizeSourcePath(value: string) { + const cleanValue = value.trim().replace(/\\/g, "/").replace(/^\/+/, ""); + + if (!cleanValue) { + throw new MaterializationError("Source path is empty."); + } + + if (cleanValue.startsWith("/") || cleanValue.includes("://")) { + throw new MaterializationError("Source path должен быть относительным путём внутри source root."); + } + + const segments = cleanValue.split("/").filter(Boolean); + + if (segments.length === 0 || segments.some((segment) => segment === "." || segment === "..")) { + throw new MaterializationError("Source path содержит небезопасные сегменты."); + } + + return segments.join("/"); +} + +function isHomeCandidate(candidate: LatestPageCandidateRow) { + const urlPath = normalizeTargetPath(candidate.url_path); + const sourcePath = candidate.source_path.toLowerCase(); + + return urlPath === "/" || sourcePath === "index.html"; +} + +function getCandidateMatch(candidate: LatestPageCandidateRow, targetVariants: string[]) { + if (candidate.url_path && targetVariants.includes(candidate.url_path)) { + return "exact" as const; + } + + if (targetVariants.includes(candidate.source_path)) { + return "exact" as const; + } + + if (isHomeCandidate(candidate)) { + return "home" as const; + } + + return "selected" as const; +} + +async function getLatestPageCandidates(projectId: string) { + const result = await pool.query<LatestPageCandidateRow>( + ` + select distinct on (pv.page_id) + pv.page_id, + pv.source_path, + pv.url_path, + pv.title, + p.selected + from page_versions pv + join pages p on p.id = pv.page_id + join scan_versions sv on sv.id = pv.scan_version_id + where sv.project_id = $1 + and sv.status = 'done' + order by pv.page_id, sv.completed_at desc nulls last, sv.created_at desc; + `, + [projectId] + ); + + return result.rows; +} + +async function getProjectSourceContext(projectId: string) { + const result = await pool.query<ProjectSourceContextRow>( + ` + select type as source_type, config as source_config + from project_sources + where project_id = $1 + order by created_at asc + limit 1; + `, + [projectId] + ); + + return result.rows[0] ?? null; +} + +async function getMaterializationDecisions( + projectId: string, + projectOntologyVersionId: string | null +): Promise<MaterializationDecisionRow[]> { + if (!projectOntologyVersionId) { + return []; + } + + const result = await pool.query<MaterializationDecisionRow>( + ` + select + id, + semantic_run_id, + project_ontology_version_id, + target_key, + target_path, + action, + status, + page_id, + note, + payload, + updated_at + from materialization_decisions + where project_id = $1 + and project_ontology_version_id = $2 + order by updated_at desc; + `, + [projectId, projectOntologyVersionId] + ); + + return result.rows; +} + +function buildLinkCandidates(candidates: LatestPageCandidateRow[], targetPath: string | null) { + const targetVariants = getTargetVariants(targetPath); + const scoredCandidates = candidates + .map((candidate) => ({ + ...candidate, + match: getCandidateMatch(candidate, targetVariants) + })) + .filter((candidate) => candidate.match === "exact" || candidate.match === "home" || candidate.selected) + .sort((left, right) => { + const order = { exact: 0, home: 1, selected: 2 } satisfies Record<"exact" | "home" | "selected", number>; + + return order[left.match] - order[right.match] || left.source_path.localeCompare(right.source_path); + }) + .slice(0, 8); + + return scoredCandidates.map((candidate) => ({ + match: candidate.match, + pageId: candidate.page_id, + selected: candidate.selected, + sourcePath: candidate.source_path, + title: candidate.title, + urlPath: candidate.url_path + })); +} + +function buildSourcePlan(input: { + decision: MaterializationDecisionRow | null; + linkCandidates: ReturnType<typeof buildLinkCandidates>; + sourceContext: ProjectSourceContextRow | null; + targetPath: string | null; +}) { + const exactCandidate = input.linkCandidates.find((candidate) => candidate.match === "exact"); + const decisionSourcePlan = getDecisionSourcePlan(input.decision); + const sourceType = input.sourceContext?.source_type ?? null; + const rootName = input.sourceContext ? getStringConfig(input.sourceContext.source_config, "rootName") : null; + + if (input.decision?.status === "awaiting_source" && decisionSourcePlan?.sourcePath) { + return { + action: "create_source_snapshot" as const, + reason: "Source plan сохранён как future apply blocker. До зрелой аналитики и стратегии сайт не меняем.", + requiresApproval: false, + sourceType, + status: "source_approved" as const, + suggestedSourcePath: decisionSourcePlan.sourcePath + }; + } + + if (exactCandidate) { + return { + action: "link_existing_page" as const, + reason: "В последнем scan уже есть страница с тем же url/source path; можно связать без создания нового файла.", + requiresApproval: true, + sourceType, + status: "can_link_existing" as const, + suggestedSourcePath: exactCandidate.sourcePath + }; + } + + const suggestedSourcePath = getSuggestedSourcePath(input.targetPath); + const rootedPath = rootName && suggestedSourcePath ? `${rootName.replace(/\/+$/g, "")}/${suggestedSourcePath}` : suggestedSourcePath; + + if (!suggestedSourcePath) { + return { + action: "manual_review" as const, + reason: "Target path пустой или невалидный; source нельзя предложить детерминированно.", + requiresApproval: true, + sourceType, + status: "manual_review" as const, + suggestedSourcePath: null + }; + } + + return { + action: "create_source_snapshot" as const, + reason: "Target отсутствует в последнем scan. Сейчас это аналитический blocker, а не задача на создание файла.", + requiresApproval: true, + sourceType, + status: "approval_required" as const, + suggestedSourcePath: rootedPath + }; +} + +function getDecisionSourcePlan(decision: MaterializationDecisionRow | null) { + const sourcePlan = decision?.payload?.sourcePlan; + + if (!sourcePlan || typeof sourcePlan !== "object" || Array.isArray(sourcePlan)) { + return null; + } + + const sourcePath = (sourcePlan as { sourcePath?: unknown }).sourcePath; + + return typeof sourcePath === "string" && sourcePath.trim() ? { sourcePath: sourcePath.trim() } : null; +} + +function getTargetStatus(input: { + decision: MaterializationDecisionRow | null; + linkCandidates: ReturnType<typeof buildLinkCandidates>; + target: RewritePlanContract["materializationQueue"][number] | RewritePlanContract["existingPageTargets"][number]; +}) { + if (input.target.status === "ready_for_diff") { + return "linked_existing" satisfies MaterializationStatus; + } + + if (input.decision?.status === "awaiting_source") { + return "awaiting_source" satisfies MaterializationStatus; + } + + if (input.decision?.status === "planned_placeholder") { + return "planned_placeholder" satisfies MaterializationStatus; + } + + if (input.decision?.status === "deferred") { + return "deferred" satisfies MaterializationStatus; + } + + if (input.decision?.status === "rejected") { + return "rejected" satisfies MaterializationStatus; + } + + if (input.linkCandidates.some((candidate) => candidate.match === "exact")) { + return "can_link_existing" satisfies MaterializationStatus; + } + + return "needs_materialization" satisfies MaterializationStatus; +} + +function buildTarget(input: { + candidates: LatestPageCandidateRow[]; + decision: MaterializationDecisionRow | null; + sourceContext: ProjectSourceContextRow | null; + target: RewritePlanContract["materializationQueue"][number] | RewritePlanContract["existingPageTargets"][number]; +}): MaterializationTarget { + const normalizedTargetPath = normalizeTargetPath(input.target.targetPath); + const linkCandidates = buildLinkCandidates(input.candidates, input.target.targetPath); + + return { + blocker: input.target.blocker, + decision: input.decision + ? { + action: input.decision.action, + id: input.decision.id, + note: input.decision.note, + sourcePath: getDecisionSourcePlan(input.decision)?.sourcePath ?? null, + updatedAt: input.decision.updated_at.toISOString() + } + : null, + id: input.target.id, + keywordBindings: input.target.keywordBindings.map((binding) => ({ + decisionId: binding.decisionId, + field: binding.field, + frequency: binding.frequency, + frequencyGroup: binding.frequencyGroup, + mapItemId: binding.mapItemId, + phrase: binding.phrase, + role: binding.role + })), + linkCandidates, + normalizedTargetPath, + pageId: input.target.pageId, + pageTitle: input.target.pageTitle, + sourcePath: input.target.sourcePath, + sourcePlan: buildSourcePlan({ + decision: input.decision, + linkCandidates, + sourceContext: input.sourceContext, + targetPath: input.target.targetPath + }), + status: getTargetStatus({ decision: input.decision, linkCandidates, target: input.target }), + targetKey: getTargetKey(input.target.targetPath), + targetPath: input.target.targetPath, + urlPath: input.target.urlPath + }; +} + +function buildContractState(targets: MaterializationTarget[], blockers: string[]): MaterializationState { + if (blockers.length > 0) { + return "blocked"; + } + + if (targets.length === 0) { + return "complete"; + } + + const unresolvedTargets = targets.filter( + (target) => + target.status === "awaiting_source" || + target.status === "needs_materialization" || + target.status === "can_link_existing" || + target.status === "planned_placeholder" + ); + + if (unresolvedTargets.length === 0) { + return "complete"; + } + + if (unresolvedTargets.length < targets.length) { + return "partial"; + } + + return "pending"; +} + +function buildNextActions(contract: Omit<MaterializationContract, "nextActions">) { + if (contract.blockers.length > 0) { + return contract.blockers; + } + + const actions: string[] = []; + + if (contract.readiness.pendingTargetCount > 0) { + actions.push( + `Зафиксировать ${contract.readiness.pendingTargetCount} planned landing target как аналитические gap/blocker, без правки сайта.` + ); + } + + if (contract.readiness.placeholderTargetCount > 0) { + actions.push( + `Для ${contract.readiness.placeholderTargetCount} targets нет реальной страницы в scan; это блокирует apply, но не блокирует стратегическую аналитику.` + ); + } + + const sourceApprovedCount = contract.targets.filter((target) => target.status === "awaiting_source").length; + + if (sourceApprovedCount > 0) { + actions.push(`Source plan сохранён для ${sourceApprovedCount} targets как future apply context; к созданию файлов пока не переходим.`); + } + + if (contract.readiness.linkedTargetCount > 0) { + actions.push(`Linked targets готовы к следующему слою rewrite diff: ${contract.readiness.linkedTargetCount}.`); + } + + if (actions.length === 0) { + actions.push("Materialization queue закрыта; дальше можно собирать rewrite diff contract."); + } + + return actions; +} + +export async function getMaterializationContract(projectId: string): Promise<MaterializationContract> { + const [rewritePlan, candidates, sourceContext] = await Promise.all([ + getRewritePlanContract(projectId), + getLatestPageCandidates(projectId), + getProjectSourceContext(projectId) + ]); + const decisions = await getMaterializationDecisions(projectId, rewritePlan.projectOntologyVersionId); + const decisionByTargetKey = new Map(decisions.map((decision) => [decision.target_key, decision])); + const targets = [...rewritePlan.materializationQueue, ...rewritePlan.existingPageTargets].map((target) => { + const targetKey = getTargetKey(target.targetPath); + + return buildTarget({ + candidates, + decision: decisionByTargetKey.get(targetKey) ?? null, + sourceContext, + target + }); + }); + const blockers = [...rewritePlan.blockers]; + const contractWithoutActions: Omit<MaterializationContract, "nextActions"> = { + schemaVersion: "materialization-contract.v1", + projectId, + semanticRunId: rewritePlan.semanticRunId, + projectOntologyVersionId: rewritePlan.projectOntologyVersionId, + generatedAt: new Date().toISOString(), + rewritePlanState: rewritePlan.state, + state: buildContractState(targets, blockers), + readiness: { + awaitingSourceCount: targets.filter((target) => target.status === "awaiting_source").length, + blockerCount: blockers.length, + linkedTargetCount: targets.filter((target) => target.status === "linked_existing").length, + pendingTargetCount: targets.filter( + (target) => target.status === "needs_materialization" || target.status === "can_link_existing" + ).length, + placeholderTargetCount: targets.filter( + (target) => target.status === "planned_placeholder" || target.status === "awaiting_source" + ).length, + rejectedOrDeferredCount: targets.filter((target) => target.status === "deferred" || target.status === "rejected").length, + targetCount: targets.length + }, + blockers, + targets + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} + +async function assertPageInLatestScan(projectId: string, pageId: string) { + const result = await pool.query<{ page_id: string }>( + ` + select pv.page_id + from page_versions pv + join scan_versions sv on sv.id = pv.scan_version_id + where sv.project_id = $1 + and sv.status = 'done' + and pv.page_id = $2 + order by sv.completed_at desc nulls last, sv.created_at desc + limit 1; + `, + [projectId, pageId] + ); + + if (!result.rows[0]) { + throw new MaterializationError("Нельзя link existing page: pageId не найден в последнем успешном scan."); + } +} + +async function getOrCreatePlaceholderPage(projectId: string, targetPath: string, title?: string) { + const normalizedTargetPath = normalizeTargetPath(targetPath); + + if (!normalizedTargetPath) { + throw new MaterializationError("Нужен targetPath для planned placeholder."); + } + + const logicalKey = `planned:${normalizedTargetPath}`; + const result = await pool.query<{ id: string }>( + ` + insert into pages (project_id, logical_key, selected) + values ($1, $2, false) + on conflict (project_id, logical_key) + do update set logical_key = excluded.logical_key + returning id; + `, + [projectId, logicalKey] + ); + const pageId = result.rows[0]?.id; + + if (!pageId) { + throw new MaterializationError("Не удалось создать planned page placeholder."); + } + + await pool.query( + ` + update pages + set logical_key = logical_key + where id = $1; + `, + [pageId] + ); + + return { + pageId, + title: title?.trim() || normalizedTargetPath + }; +} + +async function getActiveRewriteContext(projectId: string) { + const rewritePlan = await getRewritePlanContract(projectId); + + if (!rewritePlan.semanticRunId || !rewritePlan.projectOntologyVersionId) { + throw new MaterializationError("Materialization requires approved keyword decisions with semantic run and ontology version."); + } + + return rewritePlan; +} + +async function upsertMaterializationDecision(input: { + action: MaterializationAction; + note?: string; + pageId: string | null; + projectId: string; + projectOntologyVersionId: string; + semanticRunId: string; + sourcePath?: string | null; + status: MaterializationStatus; + targetPath: string; +}) { + const normalizedTargetPath = normalizeTargetPath(input.targetPath); + + if (!normalizedTargetPath) { + throw new MaterializationError("Materialization targetPath is empty."); + } + + const payload = { + materialization: { + action: input.action, + pageId: input.pageId, + status: input.status, + targetPath: normalizedTargetPath + }, + sourcePlan: input.sourcePath + ? { + sourcePath: input.sourcePath.trim(), + status: "approved", + targetPath: normalizedTargetPath + } + : undefined + }; + + const compactPayload = JSON.parse(JSON.stringify(payload)) as Record<string, unknown>; + + await pool.query( + ` + insert into materialization_decisions ( + project_id, + semantic_run_id, + project_ontology_version_id, + target_key, + target_path, + action, + status, + page_id, + note, + payload, + updated_at + ) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, now()) + on conflict (project_id, project_ontology_version_id, target_key) + do update set + semantic_run_id = excluded.semantic_run_id, + target_path = excluded.target_path, + action = excluded.action, + status = excluded.status, + page_id = excluded.page_id, + note = excluded.note, + payload = excluded.payload, + updated_at = now(); + `, + [ + input.projectId, + input.semanticRunId, + input.projectOntologyVersionId, + getTargetKey(input.targetPath), + normalizedTargetPath, + input.action, + input.status, + input.pageId, + input.note?.trim() || null, + JSON.stringify(compactPayload) + ] + ); +} + +async function updateKeywordMapItemsForTarget(input: { + note?: string; + pageId: string | null; + projectId: string; + projectOntologyVersionId: string; + semanticRunId: string; + sourcePath?: string | null; + status: MaterializationStatus; + targetPath: string; +}) { + const variants = getTargetVariants(input.targetPath); + + if (variants.length === 0) { + throw new MaterializationError("Materialization targetPath is empty."); + } + + const payload = { + materialization: { + note: input.note?.trim() || null, + pageId: input.pageId, + status: input.status, + targetPath: normalizeTargetPath(input.targetPath) + }, + sourcePlan: input.sourcePath + ? { + sourcePath: input.sourcePath.trim(), + status: "approved", + targetPath: normalizeTargetPath(input.targetPath) + } + : undefined + }; + const compactPayload = JSON.parse(JSON.stringify(payload)) as Record<string, unknown>; + + const result = await pool.query<{ id: string }>( + ` + update keyword_map_items + set + page_id = $5, + payload = coalesce(payload, '{}'::jsonb) || $6::jsonb, + updated_at = now() + where project_id = $1 + and semantic_run_id = $2 + and project_ontology_version_id = $3 + and target_path = any($4::text[]) + and approved = true + returning id; + `, + [ + input.projectId, + input.semanticRunId, + input.projectOntologyVersionId, + variants, + input.pageId, + JSON.stringify(compactPayload) + ] + ); + + if (result.rowCount === 0) { + throw new MaterializationError("Не найдены approved keyword map items для materialization target."); + } + + return result.rowCount; +} + +export async function applyMaterializationAction(projectId: string, input: MaterializationActionInput) { + const rewritePlan = await getActiveRewriteContext(projectId); + const semanticRunId = rewritePlan.semanticRunId; + const projectOntologyVersionId = rewritePlan.projectOntologyVersionId; + + if (!semanticRunId || !projectOntologyVersionId) { + throw new MaterializationError("Materialization requires approved keyword decisions with semantic run and ontology version."); + } + + const currentTarget = [...rewritePlan.materializationQueue, ...rewritePlan.existingPageTargets].find( + (target) => getTargetKey(target.targetPath) === getTargetKey(input.targetPath) + ); + + if (!currentTarget) { + throw new MaterializationError("Materialization target не найден в текущем rewrite plan."); + } + + let pageId: string | null = null; + let sourcePath: string | null = null; + let status: MaterializationStatus; + + if (input.action === "link_existing_page") { + await assertPageInLatestScan(projectId, input.pageId); + pageId = input.pageId; + status = "linked_existing"; + } else if (input.action === "approve_source_plan") { + sourcePath = normalizeSourcePath(input.sourcePath); + pageId = currentTarget.pageId; + + if (!pageId) { + const placeholder = await getOrCreatePlaceholderPage(projectId, input.targetPath, input.targetPath); + + pageId = placeholder.pageId; + } + + status = "awaiting_source"; + } else if (input.action === "create_placeholder") { + const placeholder = await getOrCreatePlaceholderPage(projectId, input.targetPath, input.title); + pageId = placeholder.pageId; + status = "planned_placeholder"; + } else if (input.action === "defer") { + status = "deferred"; + } else { + status = "rejected"; + } + + await upsertMaterializationDecision({ + action: input.action, + note: input.note, + pageId, + projectId, + projectOntologyVersionId, + semanticRunId, + sourcePath, + status, + targetPath: input.targetPath + }); + + const updatedMapItemCount = await updateKeywordMapItemsForTarget({ + note: input.note, + pageId, + projectId, + projectOntologyVersionId, + semanticRunId, + sourcePath, + status, + targetPath: input.targetPath + }); + + return { + materialization: await getMaterializationContract(projectId), + rewritePlan: await getRewritePlanContract(projectId), + updatedMapItemCount + }; +} diff --git a/seo_mode/seo_mode/server/src/rewrite/rewriteDiff.ts b/seo_mode/seo_mode/server/src/rewrite/rewriteDiff.ts new file mode 100644 index 0000000..db6ddf8 --- /dev/null +++ b/seo_mode/seo_mode/server/src/rewrite/rewriteDiff.ts @@ -0,0 +1,440 @@ +import { pool } from "../db/client.js"; +import { storageProvider } from "../storage/index.js"; +import { getRewritePlanContract, type RewritePlanContract } from "./rewritePlan.js"; + +type RewriteDiffState = "blocked" | "materialization_required" | "partial_ready" | "ready_for_model_review"; +type RewriteDiffSlotStatus = "ready_for_model_review" | "source_pending"; +type RewriteDiffField = "body_section" | "h1" | "meta_description" | "title"; + +type LatestPageRow = { + page_id: string; + scan_version_id: string; + source_path: string; + url_path: string | null; + title: string | null; + description: string | null; + raw: Record<string, unknown>; +}; + +type DraftSnapshotRow = { + page_id: string; + original_snapshot_key: string | null; + working_text: string | null; +}; + +type WorkspaceSnapshot = { + schemaVersion: "page-workspace.v2"; + sections?: Array<{ + id: string; + kind: string; + title: string; + originalText: string; + }>; +}; + +export type RewriteDiffContract = { + schemaVersion: "rewrite-diff.v1"; + projectId: string; + semanticRunId: string | null; + projectOntologyVersionId: string | null; + generatedAt: string; + state: RewriteDiffState; + readiness: { + blockerCount: number; + materializationTargetCount: number; + modelReadySlotCount: number; + sourcePendingSlotCount: number; + slotCount: number; + targetCount: number; + }; + blockers: string[]; + targets: RewriteDiffTarget[]; + nextActions: string[]; +}; + +export type RewriteDiffTarget = { + id: string; + pageId: string; + pageTitle: string | null; + sourcePath: string | null; + urlPath: string | null; + status: "ready_for_model_review" | "source_pending"; + slots: RewriteDiffSlot[]; +}; + +export type RewriteDiffSlot = { + id: string; + field: RewriteDiffField; + workspaceSectionId: string | null; + status: RewriteDiffSlotStatus; + source: "page_version" | "workspace_snapshot" | "missing_source"; + currentValue: string | null; + proposedValue: null; + blockers: string[]; + instruction: string; + keywordBindings: Array<{ + decisionId: string; + phrase: string; + role: string; + frequency: number | null; + frequencyGroup: string | null; + exactLimit: number | null; + }>; +}; + +function normalizeHeadings(raw: Record<string, unknown>) { + const headings = raw.headings; + + if (!Array.isArray(headings)) { + return []; + } + + return headings + .map((heading) => { + if (!heading || typeof heading !== "object") { + return null; + } + + const level = (heading as { level?: unknown }).level; + const text = (heading as { text?: unknown }).text; + + return typeof level === "number" && typeof text === "string" ? { level, text } : null; + }) + .filter(Boolean) as Array<{ level: number; text: string }>; +} + +function getWorkspaceSectionTexts(workingText: string, sectionCount: number) { + const headings = Array.from(workingText.matchAll(/^## .+$/gm)); + + if (headings.length === 0) { + return []; + } + + return headings.slice(0, sectionCount).map((heading, index) => { + const start = (heading.index ?? 0) + heading[0].length; + const end = headings[index + 1]?.index ?? workingText.length; + + return workingText.slice(start, end).trim(); + }); +} + +async function getLatestPages(projectId: string, pageIds: string[]) { + if (pageIds.length === 0) { + return new Map<string, LatestPageRow>(); + } + + const result = await pool.query<LatestPageRow>( + ` + select distinct on (pv.page_id) + pv.page_id, + pv.scan_version_id, + pv.source_path, + pv.url_path, + pv.title, + pv.description, + pv.raw + from page_versions pv + join scan_versions sv on sv.id = pv.scan_version_id + where sv.project_id = $1 + and sv.status = 'done' + and pv.page_id = any($2::uuid[]) + order by pv.page_id, sv.completed_at desc nulls last, sv.created_at desc; + `, + [projectId, pageIds] + ); + + return new Map(result.rows.map((row) => [row.page_id, row])); +} + +async function getDraftSnapshots(projectId: string, pageIds: string[]) { + if (pageIds.length === 0) { + return new Map<string, DraftSnapshotRow>(); + } + + const result = await pool.query<DraftSnapshotRow>( + ` + select distinct on (d.page_id) + d.page_id, + di.original_snapshot_key, + di.working_text + from drafts d + join draft_items di on di.draft_id = d.id + where d.project_id = $1 + and d.page_id = any($2::uuid[]) + and d.status = 'active' + and di.field = 'page_text' + order by d.page_id, d.updated_at desc, di.updated_at desc; + `, + [projectId, pageIds] + ); + + return new Map(result.rows.map((row) => [row.page_id, row])); +} + +async function readWorkspaceSnapshot(row: DraftSnapshotRow | undefined) { + if (!row?.original_snapshot_key) { + return null; + } + + try { + const rawSnapshot = await storageProvider.getObjectText(row.original_snapshot_key); + + return JSON.parse(rawSnapshot) as WorkspaceSnapshot; + } catch { + return null; + } +} + +async function getWorkspaceSnapshotMap(drafts: Map<string, DraftSnapshotRow>) { + const entries = await Promise.all( + Array.from(drafts.entries()).map(async ([pageId, draft]) => [pageId, await readWorkspaceSnapshot(draft)] as const) + ); + + return new Map(entries); +} + +function getCurrentValueFromWorkspace(input: { + draft: DraftSnapshotRow | undefined; + field: RewriteDiffField; + page: LatestPageRow | undefined; + snapshot: WorkspaceSnapshot | null | undefined; + workspaceSectionId: string | null; +}) { + const sections = input.snapshot?.sections ?? []; + const sectionIndex = sections.findIndex((section) => section.id === input.workspaceSectionId); + const section = sectionIndex >= 0 ? sections[sectionIndex] : null; + const workingSectionTexts = input.draft?.working_text ? getWorkspaceSectionTexts(input.draft.working_text, sections.length) : []; + const workspaceValue = section ? (workingSectionTexts[sectionIndex] ?? section.originalText) : null; + + if (workspaceValue) { + return { + source: "workspace_snapshot" as const, + value: workspaceValue + }; + } + + if (input.field === "title") { + return { + source: "page_version" as const, + value: input.page?.title ?? null + }; + } + + if (input.field === "meta_description") { + return { + source: "page_version" as const, + value: input.page?.description ?? null + }; + } + + if (input.field === "h1") { + return { + source: "page_version" as const, + value: normalizeHeadings(input.page?.raw ?? {}).find((heading) => heading.level === 1)?.text ?? null + }; + } + + return { + source: "missing_source" as const, + value: null + }; +} + +function getSlotBlockers(input: { + currentValue: string | null; + field: RewriteDiffField; + source: RewriteDiffSlot["source"]; + workspaceSectionId: string | null; +}) { + const blockers: string[] = []; + + if (!input.workspaceSectionId) { + blockers.push("Нет workspaceSectionId: slot нельзя отдать в model review без field binding."); + } + + if (!input.currentValue) { + blockers.push("Нет current value для before/after diff."); + } + + if (input.field === "body_section" && input.source !== "workspace_snapshot") { + blockers.push("Body section требует page workspace snapshot, чтобы не редактировать неизвестный фрагмент."); + } + + return blockers; +} + +function buildSlots(input: { + draft: DraftSnapshotRow | undefined; + page: LatestPageRow | undefined; + snapshot: WorkspaceSnapshot | null | undefined; + target: RewritePlanContract["existingPageTargets"][number]; +}) { + const bindingsByField = new Map<string, typeof input.target.keywordBindings>(); + + for (const binding of input.target.keywordBindings) { + const key = `${binding.field}:${binding.workspaceSectionId ?? "none"}`; + const currentBindings = bindingsByField.get(key) ?? []; + + currentBindings.push(binding); + bindingsByField.set(key, currentBindings); + } + + return Array.from(bindingsByField.entries()).map(([key, bindings]) => { + const firstBinding = bindings[0]; + const field = firstBinding?.field ?? "body_section"; + const workspaceSectionId = firstBinding?.workspaceSectionId ?? null; + const current = getCurrentValueFromWorkspace({ + draft: input.draft, + field, + page: input.page, + snapshot: input.snapshot, + workspaceSectionId + }); + const blockers = getSlotBlockers({ + currentValue: current.value, + field, + source: current.source, + workspaceSectionId + }); + + return { + blockers, + currentValue: current.value, + field, + id: `${input.target.id}:${key}`, + instruction: bindings.map((binding) => binding.instruction).join(" "), + keywordBindings: bindings.map((binding) => ({ + decisionId: binding.decisionId, + exactLimit: binding.exactLimit, + frequency: binding.frequency, + frequencyGroup: binding.frequencyGroup, + phrase: binding.phrase, + role: binding.role + })), + proposedValue: null, + source: current.source, + status: blockers.length === 0 ? "ready_for_model_review" : "source_pending", + workspaceSectionId + } satisfies RewriteDiffSlot; + }); +} + +function buildNextActions(contract: Omit<RewriteDiffContract, "nextActions">) { + if (contract.blockers.length > 0) { + return contract.blockers; + } + + const actions: string[] = []; + + if (contract.readiness.materializationTargetCount > 0) { + actions.push( + `Apply/model rewrite заблокирован ${contract.readiness.materializationTargetCount} planned targets; текущий фокус — аналитика, сервисы и стратегия.` + ); + } + + if (contract.readiness.sourcePendingSlotCount > 0) { + actions.push(`Открыть page workspace / source snapshot для ${contract.readiness.sourcePendingSlotCount} diff slots.`); + } + + if (contract.readiness.modelReadySlotCount > 0) { + actions.push(`Обсудить model provider и prompt contract для ${contract.readiness.modelReadySlotCount} ready slots.`); + } + + if (actions.length === 0) { + actions.push("Rewrite diff contract ждёт approved SEO-план и materialized page targets."); + } + + return actions; +} + +function getState(input: { + blockerCount: number; + materializationTargetCount: number; + modelReadySlotCount: number; + sourcePendingSlotCount: number; + targetCount: number; +}) { + if (input.blockerCount > 0) { + return "blocked" satisfies RewriteDiffState; + } + + if (input.materializationTargetCount > 0 && input.modelReadySlotCount === 0) { + return "materialization_required" satisfies RewriteDiffState; + } + + if (input.targetCount === 0) { + return "blocked" satisfies RewriteDiffState; + } + + if (input.sourcePendingSlotCount > 0 || input.materializationTargetCount > 0) { + return "partial_ready" satisfies RewriteDiffState; + } + + return "ready_for_model_review" satisfies RewriteDiffState; +} + +export async function getRewriteDiffContract(projectId: string): Promise<RewriteDiffContract> { + const rewritePlan = await getRewritePlanContract(projectId); + const pageIds = rewritePlan.existingPageTargets.map((target) => target.pageId).filter(Boolean) as string[]; + const [pageMap, draftMap] = await Promise.all([getLatestPages(projectId, pageIds), getDraftSnapshots(projectId, pageIds)]); + const snapshotMap = await getWorkspaceSnapshotMap(draftMap); + const targets = rewritePlan.existingPageTargets + .filter((target): target is RewritePlanContract["existingPageTargets"][number] & { pageId: string } => Boolean(target.pageId)) + .map((target) => { + const page = pageMap.get(target.pageId); + const slots = buildSlots({ + draft: draftMap.get(target.pageId), + page, + snapshot: snapshotMap.get(target.pageId), + target + }); + + return { + id: target.id, + pageId: target.pageId, + pageTitle: target.pageTitle ?? page?.title ?? null, + sourcePath: target.sourcePath ?? page?.source_path ?? null, + status: slots.every((slot) => slot.status === "ready_for_model_review") + ? "ready_for_model_review" + : "source_pending", + slots, + urlPath: target.urlPath ?? page?.url_path ?? null + } satisfies RewriteDiffTarget; + }); + const blockers = [...rewritePlan.blockers]; + const slotCount = targets.reduce((sum, target) => sum + target.slots.length, 0); + const sourcePendingSlotCount = targets.reduce( + (sum, target) => sum + target.slots.filter((slot) => slot.status === "source_pending").length, + 0 + ); + const modelReadySlotCount = slotCount - sourcePendingSlotCount; + const contractWithoutActions: Omit<RewriteDiffContract, "nextActions"> = { + schemaVersion: "rewrite-diff.v1", + projectId, + semanticRunId: rewritePlan.semanticRunId, + projectOntologyVersionId: rewritePlan.projectOntologyVersionId, + generatedAt: new Date().toISOString(), + state: getState({ + blockerCount: blockers.length, + materializationTargetCount: rewritePlan.materializationQueue.length, + modelReadySlotCount, + sourcePendingSlotCount, + targetCount: targets.length + }), + readiness: { + blockerCount: blockers.length, + materializationTargetCount: rewritePlan.materializationQueue.length, + modelReadySlotCount, + sourcePendingSlotCount, + slotCount, + targetCount: targets.length + }, + blockers, + targets + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} diff --git a/seo_mode/seo_mode/server/src/rewrite/rewritePlan.ts b/seo_mode/seo_mode/server/src/rewrite/rewritePlan.ts new file mode 100644 index 0000000..59758c9 --- /dev/null +++ b/seo_mode/seo_mode/server/src/rewrite/rewritePlan.ts @@ -0,0 +1,404 @@ +import { pool } from "../db/client.js"; + +type RewritePlanState = "blocked" | "materialization_required" | "partial_ready" | "ready_for_diff"; +type RewriteField = "body_section" | "h1" | "meta_description" | "title"; + +type ApprovedKeywordBindingRow = { + decision_id: string; + map_item_id: string | null; + semantic_run_id: string | null; + project_ontology_version_id: string | null; + phrase: string; + role: string; + reason: string | null; + cluster_id: string | null; + cluster_title: string | null; + evidence_status: string | null; + frequency: number | null; + frequency_group: string | null; + target_path: string | null; + page_id: string | null; + workspace_section_id: string | null; + exact_limit: number | null; + map_payload: Record<string, unknown> | null; + page_url_path: string | null; + page_source_path: string | null; + page_title: string | null; + approved_at: Date; +}; + +export type RewritePlanContract = { + schemaVersion: "rewrite-plan.v1"; + projectId: string; + semanticRunId: string | null; + projectOntologyVersionId: string | null; + generatedAt: string; + state: RewritePlanState; + readiness: { + approvedDecisionCount: number; + existingPageTargetCount: number; + plannedLandingTargetCount: number; + rewriteCandidateCount: number; + blockerCount: number; + }; + blockers: string[]; + existingPageTargets: RewriteTarget[]; + materializationQueue: RewriteTarget[]; + nextActions: string[]; +}; + +type RewriteKeywordBinding = { + decisionId: string; + mapItemId: string | null; + phrase: string; + role: string; + field: RewriteField; + clusterId: string | null; + clusterTitle: string | null; + evidenceStatus: string | null; + frequency: number | null; + frequencyGroup: string | null; + workspaceSectionId: string | null; + exactLimit: number | null; + approvedAt: string; + instruction: string; +}; + +type RewriteTarget = { + id: string; + targetPath: string | null; + pageId: string | null; + pageTitle: string | null; + sourcePath: string | null; + urlPath: string | null; + status: "needs_materialization" | "planned_placeholder" | "ready_for_diff"; + blocker: string | null; + keywordBindings: RewriteKeywordBinding[]; +}; + +function getRewriteField(workspaceSectionId: string | null): RewriteField { + if (workspaceSectionId === "seo-title") { + return "title"; + } + + if (workspaceSectionId === "seo-description") { + return "meta_description"; + } + + if (workspaceSectionId === "seo-h1") { + return "h1"; + } + + return "body_section"; +} + +function getRewriteInstruction(row: ApprovedKeywordBindingRow) { + const field = getRewriteField(row.workspace_section_id); + const frequencyPart = row.frequency !== null ? ` Wordstat exact=${row.frequency}${row.frequency_group ? `/${row.frequency_group}` : ""}.` : ""; + + if (field === "title") { + return `Use as the primary page title intent once, keep brand context, avoid unsupported claims.${frequencyPart}`; + } + + if (field === "meta_description") { + return `Use as secondary snippet intent naturally, no keyword stuffing, keep CTA grounded in page evidence.${frequencyPart}`; + } + + if (field === "h1") { + return `Use as H1 intent only if it matches the page offer exactly; otherwise keep it for manual review.${frequencyPart}`; + } + + return `Use as supporting body term where the section already covers this intent; do not add unrelated markets.${frequencyPart}`; +} + +async function getLatestApprovedContext(projectId: string) { + const result = await pool.query<{ + semantic_run_id: string | null; + project_ontology_version_id: string | null; + }>( + ` + select semantic_run_id, project_ontology_version_id + from keyword_decisions + where project_id = $1 and approved = true + order by updated_at desc, created_at desc + limit 1; + `, + [projectId] + ); + + return result.rows[0] ?? null; +} + +async function getApprovedKeywordBindings(projectId: string, semanticRunId: string, projectOntologyVersionId: string) { + const result = await pool.query<ApprovedKeywordBindingRow>( + ` + select + kd.id as decision_id, + kmi.id as map_item_id, + kd.semantic_run_id, + kd.project_ontology_version_id, + kd.phrase, + coalesce(kmi.role, kd.payload->>'role', 'support') as role, + kd.reason, + kd.cluster_id, + kd.cluster_title, + kd.evidence_status, + kd.frequency, + kd.frequency_group, + coalesce(kmi.target_path, kd.target_path) as target_path, + kmi.page_id, + kmi.workspace_section_id, + kmi.exact_limit, + kmi.payload as map_payload, + page_info.url_path as page_url_path, + page_info.source_path as page_source_path, + page_info.title as page_title, + 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 + left join lateral ( + select pv.url_path, pv.source_path, pv.title + from page_versions pv + join scan_versions sv on sv.id = pv.scan_version_id + where pv.page_id = kmi.page_id + and sv.project_id = kd.project_id + and sv.status = 'done' + order by sv.completed_at desc nulls last, sv.created_at desc + limit 1 + ) page_info on true + where kd.project_id = $1 + and kd.approved = true + and kd.semantic_run_id = $2 + and kd.project_ontology_version_id = $3 + order by coalesce(kmi.target_path, kd.target_path) nulls last, kmi.role, kd.phrase; + `, + [projectId, semanticRunId, projectOntologyVersionId] + ); + + return result.rows; +} + +function buildKeywordBinding(row: ApprovedKeywordBindingRow): RewriteKeywordBinding { + return { + approvedAt: row.approved_at.toISOString(), + clusterId: row.cluster_id, + clusterTitle: row.cluster_title, + decisionId: row.decision_id, + evidenceStatus: row.evidence_status, + exactLimit: row.exact_limit, + field: getRewriteField(row.workspace_section_id), + frequency: row.frequency, + frequencyGroup: row.frequency_group, + instruction: getRewriteInstruction(row), + mapItemId: row.map_item_id, + phrase: row.phrase, + role: row.role, + workspaceSectionId: row.workspace_section_id + }; +} + +function getMapMaterializationStatus(payload: Record<string, unknown> | null) { + const materialization = payload?.materialization; + + if (!materialization || typeof materialization !== "object" || Array.isArray(materialization)) { + return null; + } + + const status = (materialization as { status?: unknown }).status; + + return typeof status === "string" ? status : null; +} + +function normalizeTargetPath(value: string | null) { + if (!value) { + return null; + } + + const cleanValue = value.trim(); + + if (!cleanValue) { + return null; + } + + const withLeadingSlash = cleanValue.startsWith("/") ? cleanValue : `/${cleanValue}`; + + if (withLeadingSlash === "/") { + return "/"; + } + + return withLeadingSlash.endsWith("/") ? withLeadingSlash : `${withLeadingSlash}/`; +} + +function getRewriteTargetKey(row: ApprovedKeywordBindingRow) { + const normalizedTargetPath = normalizeTargetPath(row.target_path); + + if (normalizedTargetPath) { + return `target:${normalizedTargetPath}`; + } + + if (row.page_id) { + return `page:${row.page_id}`; + } + + return `decision:${row.decision_id}`; +} + +function getRewriteTargetStatus(row: ApprovedKeywordBindingRow): RewriteTarget["status"] { + if (row.page_id && row.page_source_path) { + return "ready_for_diff"; + } + + const materializationStatus = getMapMaterializationStatus(row.map_payload); + + if (materializationStatus === "planned_placeholder" || (row.page_id && !row.page_source_path)) { + return "planned_placeholder"; + } + + return "needs_materialization"; +} + +function getMergedRewriteTargetStatus(left: RewriteTarget["status"], right: RewriteTarget["status"]) { + const priority = { + needs_materialization: 0, + planned_placeholder: 1, + ready_for_diff: 2 + } satisfies Record<RewriteTarget["status"], number>; + + return priority[right] > priority[left] ? right : left; +} + +function getRewriteTargetBlocker(status: RewriteTarget["status"]) { + if (status === "ready_for_diff") { + return null; + } + + if (status === "planned_placeholder") { + return "Planned page placeholder exists, but there is no page version/source snapshot yet; create source or link an existing scanned page before diff/apply."; + } + + return "Target page is not present in the current scan; materialize landing page before diff/apply."; +} + +function mergeRewriteTargetRow(target: RewriteTarget, row: ApprovedKeywordBindingRow, status: RewriteTarget["status"]) { + const mergedStatus = getMergedRewriteTargetStatus(target.status, status); + + target.status = mergedStatus; + target.blocker = getRewriteTargetBlocker(mergedStatus); + target.pageId = target.pageId ?? row.page_id; + target.pageTitle = target.pageTitle ?? row.page_title; + target.sourcePath = target.sourcePath ?? row.page_source_path; + target.targetPath = target.targetPath ?? normalizeTargetPath(row.target_path) ?? row.target_path; + target.urlPath = target.urlPath ?? row.page_url_path; +} + +function buildRewriteTargets(rows: ApprovedKeywordBindingRow[]) { + const targets = new Map<string, RewriteTarget>(); + + for (const row of rows) { + const key = getRewriteTargetKey(row); + const status = getRewriteTargetStatus(row); + const currentTarget = targets.get(key); + const target: RewriteTarget = + currentTarget ?? + { + blocker: getRewriteTargetBlocker(status), + id: key, + keywordBindings: [], + pageId: row.page_id, + pageTitle: row.page_title, + sourcePath: row.page_source_path, + status, + targetPath: normalizeTargetPath(row.target_path) ?? row.target_path, + urlPath: row.page_url_path + }; + + if (currentTarget) { + mergeRewriteTargetRow(target, row, status); + } + + target.keywordBindings.push(buildKeywordBinding(row)); + targets.set(key, target); + } + + return Array.from(targets.values()); +} + +function getRewriteState(existingPageTargets: RewriteTarget[], materializationQueue: RewriteTarget[]): RewritePlanState { + if (existingPageTargets.length === 0 && materializationQueue.length === 0) { + return "blocked"; + } + + if (existingPageTargets.length === 0 && materializationQueue.length > 0) { + return "materialization_required"; + } + + if (existingPageTargets.length > 0 && materializationQueue.length > 0) { + return "partial_ready"; + } + + return "ready_for_diff"; +} + +function buildNextActions(contract: Omit<RewritePlanContract, "nextActions">) { + if (contract.blockers.length > 0) { + return contract.blockers; + } + + const actions: string[] = []; + + if (contract.materializationQueue.length > 0) { + actions.push( + `Материализовать ${contract.materializationQueue.length} planned landing target перед rewrite/apply: без pageId diff не создаётся.` + ); + } + + if (contract.existingPageTargets.length > 0) { + actions.push( + `Собрать rewrite diff для ${contract.existingPageTargets.length} существующих страниц с approved keyword bindings и human review.` + ); + } + + actions.push("Model rewrite provider подключать только после фиксации page/section binding, forbidden claims и validation gates."); + + return actions; +} + +export async function getRewritePlanContract(projectId: string): Promise<RewritePlanContract> { + const context = await getLatestApprovedContext(projectId); + const blockers: string[] = []; + + if (!context?.semantic_run_id || !context.project_ontology_version_id) { + blockers.push("Нет approved keyword decisions: сначала сохранить evidence-confirmed keyword map decisions."); + } + + const rows = + context?.semantic_run_id && context.project_ontology_version_id + ? await getApprovedKeywordBindings(projectId, context.semantic_run_id, context.project_ontology_version_id) + : []; + const targets = buildRewriteTargets(rows); + const existingPageTargets = targets.filter((target) => target.status === "ready_for_diff"); + const materializationQueue = targets.filter((target) => target.status !== "ready_for_diff"); + const contractWithoutActions: Omit<RewritePlanContract, "nextActions"> = { + schemaVersion: "rewrite-plan.v1", + projectId, + semanticRunId: context?.semantic_run_id ?? null, + projectOntologyVersionId: context?.project_ontology_version_id ?? null, + generatedAt: new Date().toISOString(), + state: blockers.length > 0 ? "blocked" : getRewriteState(existingPageTargets, materializationQueue), + readiness: { + approvedDecisionCount: rows.length, + blockerCount: blockers.length + materializationQueue.length, + existingPageTargetCount: existingPageTargets.length, + plannedLandingTargetCount: materializationQueue.length, + rewriteCandidateCount: existingPageTargets.reduce((sum, target) => sum + target.keywordBindings.length, 0) + }, + blockers, + existingPageTargets, + materializationQueue + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} diff --git a/seo_mode/seo_mode/server/src/scripts/checkOntologyUniversality.ts b/seo_mode/seo_mode/server/src/scripts/checkOntologyUniversality.ts new file mode 100644 index 0000000..48b5e4b --- /dev/null +++ b/seo_mode/seo_mode/server/src/scripts/checkOntologyUniversality.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { getProjectOntology, type ProjectOntologyEvidence } from "../ontology/projectOntology.js"; + +function buildEvidence(sourceConfig: Record<string, unknown> = {}): ProjectOntologyEvidence { + return { + projectId: "universality-check", + scanVersionId: "scan-universality-check", + sourceId: "source-universality-check", + projectIndexKey: null, + sourceType: "local_folder", + sourceConfig: { + rootName: "Северный Свет", + path: "/tmp/north-light", + ...sourceConfig + }, + pages: [ + { + id: "home", + kind: "page", + selected: true, + scope: "indexable_page", + usedForCoverage: true, + title: "Северный Свет - авторские торты и десерты", + sourcePath: "index.html", + urlPath: "/", + headings: ["Авторские торты и десерты на заказ"], + text: + "Северный Свет делает авторские торты, десерты и наборы для праздников. " + + "Торты собираются под событие, начинку и дату доставки. Десерты можно заказать для дня рождения, свадьбы и корпоративного стола. " + + "Мастерская показывает примеры работ, условия доставки, составы и варианты оформления." + }, + { + id: "delivery", + kind: "page", + selected: true, + scope: "indexable_page", + usedForCoverage: true, + title: "Доставка и условия заказа", + sourcePath: "delivery.html", + urlPath: "/delivery/", + headings: ["Доставка тортов", "Условия заказа"], + text: + "Доставка тортов работает по городу и ближайшим районам. Заказ подтверждается после выбора начинки, веса и даты. " + + "Клиент получает фото перед отправкой, рекомендации по хранению и понятные условия оплаты." + } + ], + contentDocuments: [] + }; +} + +const genericOntology = await getProjectOntology("universality-check", buildEvidence()); +const genericPayload = JSON.stringify(genericOntology).toLocaleLowerCase("ru-RU"); + +assert.equal(genericOntology.source, "extracted"); +assert.ok(!genericPayload.includes("nodedc"), "Generic extraction must not inject NODE.DC aliases."); +assert.ok(!genericPayload.includes("/modules/ops"), "Generic extraction must not inject NODE.DC target pages."); +assert.ok(!genericPayload.includes("ai-автоматизация"), "Generic extraction must not inject NODE.DC/AI fixture titles."); +assert.ok( + genericOntology.clusters.every((cluster) => + cluster.queryExamples.every((query) => !/workflow система|автоматизация бизнес процессов|ai платформа/iu.test(query)) + ), + "Generic extraction must not inject SaaS/automation Wordstat seeds." +); + +const fixtureOntology = await getProjectOntology( + "universality-check", + buildEvidence({ + seoSeedFixture: "nodedc-site-seed" + }) +); + +assert.equal(fixtureOntology.source, "seed_fixture"); +assert.ok( + fixtureOntology.brandTerms.some((term) => /nodedc|node\.dc/iu.test(term)), + "NODE.DC fixture must be available only when explicitly requested." +); + +console.info("Ontology universality check passed."); diff --git a/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts b/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts index 25eb9c8..382491e 100644 --- a/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts +++ b/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts @@ -112,8 +112,9 @@ const METRICA_BASE_URL = "https://api-metrika.yandex.net"; const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = { yandex_cloud: { id: "yandex_cloud", - label: "Yandex Cloud credentials", - description: "Один общий credential для Search API слоёв: Wordstat сейчас, SERP позже.", + label: "Yandex AI Studio / Cloud API key", + description: + "Один общий folderId и API key для Yandex AI Studio/Yandex Cloud: Search API, Wordstat, WebSearch и будущий model layer используют этот credential по ролям service account.", implementationStatus: "active", defaultMode: "api_key", modes: [ @@ -122,6 +123,10 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = { id: "iam_token", label: "IAM token" } ], docs: [ + { + label: "AI Studio Search API quickstart", + url: "https://yandex.cloud/en/docs/search-api/quickstart" + }, { label: "API keys", url: "https://yandex.cloud/en/docs/iam/concepts/authorization/api-key" @@ -134,21 +139,21 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = publicFields: [ { id: "folderId", - label: "Yandex Cloud folderId", + label: "Yandex AI Studio folderId", type: "text", required: true, placeholder: "b1g...", - help: "ID каталога Yandex Cloud. Его используют Wordstat и SERP Search API." + help: "ID каталога Yandex Cloud. Его используют Wordstat, SERP/WebSearch и будущие AI Studio вызовы." } ], secretFields: [ { id: "apiKey", - label: "API key", + label: "Yandex AI Studio API key", type: "password", required: false, - placeholder: "Api-Key ...", - help: "Yandex Cloud API key сервисного аккаунта с доступом к Search API." + placeholder: "AQVN...", + help: "API key сервисного аккаунта. Доступ к Search API/Wordstat/WebSearch определяется ролями и scope этого ключа." }, { id: "iamToken", diff --git a/seo_mode/seo_mode/server/src/settings/routes.ts b/seo_mode/seo_mode/server/src/settings/routes.ts index 9717d01..8faf2ef 100644 --- a/seo_mode/seo_mode/server/src/settings/routes.ts +++ b/seo_mode/seo_mode/server/src/settings/routes.ts @@ -6,6 +6,7 @@ import { updateExternalServiceSettings, type ExternalServiceId } from "./externalServiceSettings.js"; +import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js"; export const settingsRouter = Router(); @@ -23,6 +24,9 @@ const updateExternalServiceSettingsSchema = z.object({ publicConfig: z.record(z.string(), z.unknown()).optional(), secrets: z.record(z.string(), z.unknown()).optional() }); +const yandexAiStudioProbeSchema = z.object({ + phrase: z.string().trim().min(2).max(160).optional() +}); function sendError(response: Response, status: number, message: string) { response.status(status).json({ @@ -43,6 +47,24 @@ settingsRouter.get("/external-services", async (_request, response) => { } }); +settingsRouter.post("/external-services/yandex_cloud/probe", async (request, response) => { + try { + const input = yandexAiStudioProbeSchema.parse(request.body ?? {}); + + response.json({ + probe: await runYandexAiStudioProbe(input.phrase) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь probe phrase."); + return; + } + + console.error(error); + sendError(response, 500, "Не удалось проверить Yandex AI Studio credential."); + } +}); + settingsRouter.patch("/external-services/:serviceId", async (request, response) => { try { const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId; diff --git a/seo_mode/seo_mode/server/src/settings/yandexAiStudioProbe.ts b/seo_mode/seo_mode/server/src/settings/yandexAiStudioProbe.ts new file mode 100644 index 0000000..0cddb12 --- /dev/null +++ b/seo_mode/seo_mode/server/src/settings/yandexAiStudioProbe.ts @@ -0,0 +1,396 @@ +import { getWordstatRuntimeConfig } from "./externalServiceSettings.js"; + +type ProbeCapability = + | { + id: "wordstat_top" | "wordstat_regions" | "wordstat_dynamics" | "web_search"; + label: string; + ok: boolean; + status: number | null; + ms: number; + summary: string; + sample: unknown; + error: string | null; + }; + +export type YandexAiStudioProbeResult = { + schemaVersion: "yandex-ai-studio-probe.v1"; + checkedAt: string; + phrase: string; + credential: + | { + configured: false; + provider: "disabled" | "mcp_kv"; + } + | { + configured: true; + provider: "yandex_api"; + authType: "api_key" | "iam_token"; + folderId: string; + }; + capabilities: ProbeCapability[]; +}; + +const WEB_SEARCH_ENDPOINT = "https://searchapi.api.cloud.yandex.net/v2/web/search"; +const DEFAULT_PROBE_PHRASE = "автоматизация бизнес процессов"; + +function maskFolderId(folderId: string) { + if (folderId.length <= 8) { + return "configured"; + } + + return `${folderId.slice(0, 4)}...${folderId.slice(-4)}`; +} + +function sanitizeText(text: string, token: string) { + return text.replaceAll(token, "[redacted]").slice(0, 900); +} + +function asRecord(value: unknown): Record<string, unknown> { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}; +} + +function asArray(value: unknown) { + return Array.isArray(value) ? value : []; +} + +function getString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function getNumber(value: unknown) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string") { + const parsed = Number(value.replace(/\s+/g, "")); + + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return null; +} + +function stripXmlTags(value: string) { + return value + .replace(/<hlword>/g, "") + .replace(/<\/hlword>/g, "") + .replace(/<[^>]+>/g, "") + .replace(/"/g, "\"") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/\s+/g, " ") + .trim(); +} + +function grabXml(value: string, pattern: RegExp) { + const match = value.match(pattern); + + return match ? stripXmlTags(match[1] ?? "") : ""; +} + +function decodeWebSearchRawData(payload: unknown) { + const rawData = getString(asRecord(payload).rawData); + + if (!rawData) { + return { + found: {}, + docs: [] + }; + } + + const xml = Buffer.from(rawData, "base64").toString("utf8"); + const found = Object.fromEntries( + [...xml.matchAll(/<found priority="([^"]+)">([\s\S]*?)<\/found>/g)].map((match) => [ + match[1], + stripXmlTags(match[2] ?? "") + ]) + ); + const docs = [...xml.matchAll(/<doc id="[^"]+">([\s\S]*?)<\/doc>/g)].slice(0, 5).map((match) => { + const block = match[1] ?? ""; + + return { + domain: grabXml(block, /<domain>([\s\S]*?)<\/domain>/), + url: grabXml(block, /<url>([\s\S]*?)<\/url>/), + title: grabXml(block, /<title>([\s\S]*?)<\/title>/), + snippet: grabXml(block, /<passage>([\s\S]*?)<\/passage>/).slice(0, 260), + mime: grabXml(block, /<mime-type>([\s\S]*?)<\/mime-type>/), + lang: grabXml(block, /<lang>([\s\S]*?)<\/lang>/) + }; + }); + + return { + found, + docs + }; +} + +async function postJson( + input: { + endpoint: string; + body: unknown; + authorization: string; + token: string; + } +) { + const started = Date.now(); + const response = await fetch(input.endpoint, { + method: "POST", + headers: { + Authorization: input.authorization, + "Content-Type": "application/json" + }, + body: JSON.stringify(input.body), + signal: AbortSignal.timeout(12_000) + }); + const text = await response.text(); + let json: unknown = null; + + try { + json = text ? JSON.parse(text) : null; + } catch { + json = null; + } + + return { + ok: response.ok, + status: response.status, + ms: Date.now() - started, + json, + text: sanitizeText(text, input.token) + }; +} + +async function runCapability( + id: ProbeCapability["id"], + label: string, + callback: () => Promise<Omit<ProbeCapability, "id" | "label">> +): Promise<ProbeCapability> { + try { + const result = await callback(); + + return { + id, + label, + ...result + }; + } catch (error) { + return { + id, + label, + ok: false, + status: null, + ms: 0, + summary: "probe failed before response", + sample: null, + error: error instanceof Error ? error.message : "Unknown probe error" + }; + } +} + +function getLastCompletedMonthWindow(now = new Date()) { + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0)); + const start = new Date(Date.UTC(end.getUTCFullYear(), Math.max(0, end.getUTCMonth() - 5), 1)); + + return { + fromDate: start.toISOString(), + toDate: end.toISOString() + }; +} + +export async function runYandexAiStudioProbe(phraseInput?: string): Promise<YandexAiStudioProbeResult> { + const phrase = phraseInput?.trim() || DEFAULT_PROBE_PHRASE; + const config = await getWordstatRuntimeConfig(); + + if (config.provider !== "yandex_api") { + return { + schemaVersion: "yandex-ai-studio-probe.v1", + checkedAt: new Date().toISOString(), + phrase, + credential: { + configured: false, + provider: config.provider + }, + capabilities: [] + }; + } + + const authorization = config.authType === "iam_token" ? `Bearer ${config.apiToken}` : `Api-Key ${config.apiToken}`; + const wordstatBase = config.apiBaseUrl.replace(/\/+$/, ""); + const region = config.regionIds[0] ?? "225"; + const device = config.devices[0] ?? "DEVICE_ALL"; + const dates = getLastCompletedMonthWindow(); + + const capabilities = await Promise.all([ + runCapability("wordstat_top", "Wordstat top requests", async () => { + const response = await postJson({ + authorization, + endpoint: `${wordstatBase}/topRequests`, + token: config.apiToken, + body: { + devices: [device], + folderId: config.folderId, + numPhrases: 5, + phrase, + regions: [region] + } + }); + const payload = asRecord(response.json); + const results = asArray(payload.results).slice(0, 5).map((item) => { + const record = asRecord(item); + + return { + phrase: getString(record.phrase), + count: getNumber(record.count) + }; + }); + const associations = asArray(payload.associations).slice(0, 5).map((item) => { + const record = asRecord(item); + + return { + phrase: getString(record.phrase), + count: getNumber(record.count) + }; + }); + + return { + ok: response.ok, + status: response.status, + ms: response.ms, + summary: response.ok + ? `${getNumber(payload.totalCount) ?? 0} total; ${results.length} top; ${associations.length} associations` + : response.text, + sample: { + totalCount: getNumber(payload.totalCount), + results, + associations + }, + error: response.ok ? null : response.text + }; + }), + runCapability("wordstat_regions", "Wordstat regions", async () => { + const response = await postJson({ + authorization, + endpoint: `${wordstatBase}/regions`, + token: config.apiToken, + body: { + devices: [device], + folderId: config.folderId, + phrase, + regions: [region] + } + }); + const results = asArray(asRecord(response.json).results) + .slice(0, 8) + .map((item) => { + const record = asRecord(item); + + return { + region: getString(record.region), + count: getNumber(record.count), + share: getNumber(record.share), + affinityIndex: getNumber(record.affinityIndex) + }; + }); + + return { + ok: response.ok, + status: response.status, + ms: response.ms, + summary: response.ok ? `${results.length} region rows sampled` : response.text, + sample: { + results + }, + error: response.ok ? null : response.text + }; + }), + runCapability("wordstat_dynamics", "Wordstat dynamics", async () => { + const response = await postJson({ + authorization, + endpoint: `${wordstatBase}/dynamics`, + token: config.apiToken, + body: { + devices: [device], + folderId: config.folderId, + fromDate: dates.fromDate, + period: "PERIOD_MONTHLY", + phrase, + regions: [region], + toDate: dates.toDate + } + }); + const results = asArray(asRecord(response.json).results) + .slice(0, 8) + .map((item) => { + const record = asRecord(item); + + return { + date: getString(record.date), + count: getNumber(record.count), + share: getNumber(record.share) + }; + }); + + return { + ok: response.ok, + status: response.status, + ms: response.ms, + summary: response.ok ? `${results.length} monthly rows sampled` : response.text, + sample: { + results + }, + error: response.ok ? null : response.text + }; + }), + runCapability("web_search", "Web Search SERP", async () => { + const response = await postJson({ + authorization, + endpoint: WEB_SEARCH_ENDPOINT, + token: config.apiToken, + body: { + folderId: config.folderId, + groupSpec: { + docsInGroup: 1, + groupMode: "GROUP_MODE_DEEP", + groupsOnPage: 5 + }, + query: { + familyMode: "FAMILY_MODE_NONE", + queryText: phrase, + searchType: "SEARCH_TYPE_RU" + }, + responseFormat: "FORMAT_XML", + sortSpec: { + sortMode: "SORT_MODE_BY_RELEVANCE" + } + } + }); + const parsed = decodeWebSearchRawData(response.json); + + return { + ok: response.ok, + status: response.status, + ms: response.ms, + summary: response.ok ? `${parsed.docs.length} SERP docs sampled` : response.text, + sample: parsed, + error: response.ok ? null : response.text + }; + }) + ]); + + return { + schemaVersion: "yandex-ai-studio-probe.v1", + checkedAt: new Date().toISOString(), + phrase, + credential: { + authType: config.authType, + configured: true, + folderId: maskFolderId(config.folderId), + provider: "yandex_api" + }, + capabilities + }; +} diff --git a/seo_mode/seo_mode/server/src/skills/seoSkillRegistry.ts b/seo_mode/seo_mode/server/src/skills/seoSkillRegistry.ts new file mode 100644 index 0000000..f0af33f --- /dev/null +++ b/seo_mode/seo_mode/server/src/skills/seoSkillRegistry.ts @@ -0,0 +1,665 @@ +import { z } from "zod"; +import type { SemanticAnalysisRun } from "../analysis/semanticAnalysis.js"; + +const SeoSkillStageSchema = z.enum([ + "scan", + "context_review", + "scope", + "normalization", + "market", + "serp", + "strategy", + "keyword_map", + "rewrite", + "validation", + "apply" +]); + +const SeoSkillManifestSchema = z.object({ + schemaVersion: z.literal("seo-skill.v1"), + skillId: z.string().regex(/^seo\.[a-z0-9_]+$/), + version: z.string().min(1), + stage: SeoSkillStageSchema, + title: z.string().min(1), + summary: z.string().min(1), + owner: z.literal("seo_mode"), + inputs: z.array(z.string().min(1)), + requiredEvidence: z.array(z.string().min(1)), + deterministicChecks: z.array( + z.object({ + id: z.string().min(1), + title: z.string().min(1), + source: z.enum(["backend", "external_provider", "human_review"]), + required: z.boolean() + }) + ), + modelTasks: z.array( + z.object({ + taskType: z.string().min(1), + required: z.boolean(), + providerMode: z.enum(["none", "model_provider_contract", "optional_assist"]), + description: z.string().min(1) + }) + ), + outputSchema: z.object({ + schemaVersion: z.string().min(1), + requiredTopLevelKeys: z.array(z.string().min(1)) + }), + blockers: z.array(z.string().min(1)), + forbiddenActions: z.array(z.string().min(1)), + mcpCapabilities: z.array( + z.object({ + id: z.string().min(1), + writes: z.boolean(), + description: z.string().min(1) + }) + ), + uiDecisionSurface: z.array(z.string().min(1)), + validationRules: z.array(z.string().min(1)) +}); + +const SeoSkillPackContractSchema = z.object({ + schemaVersion: z.literal("seo-skill-pack.v1"), + packageId: z.literal("ndc-seo-skill-pack"), + version: z.literal("0.1.0"), + runtime: z.literal("seo_mode_backend"), + generatedAt: z.string(), + activeSkillIds: z.array(z.string()), + skills: z.array(SeoSkillManifestSchema), + guardrails: z.array(z.string()), + nextImplementationStage: z.string() +}); + +export type SeoSkillManifest = z.infer<typeof SeoSkillManifestSchema>; +export type SeoSkillPackContract = z.infer<typeof SeoSkillPackContractSchema>; + +export type SeoContextReviewModelTaskContract = { + taskType: "seo.context_review"; + schemaVersion: "seo-context-review-task.v1"; + taskId: string; + projectId: string; + semanticRunId: string; + scanVersionId: string; + projectOntologyVersionId: string | null; + providerMode: "model_provider_contract"; + activeSkillId: "seo.context_review"; + input: { + siteSummary: { + brandName: string; + language: SemanticAnalysisRun["styleProfile"]["language"]; + topTerms: string[]; + brandTerms: string[]; + ctaPhrases: string[]; + toneSignals: string[]; + }; + scopeSummary: { + indexablePages: number; + selectedIndexablePages: number; + contentSources: number; + templateBlocks: number; + adminPages: number; + technicalPages: number; + }; + pageSignals: Array<{ + pageId: string; + selected: boolean; + title: string; + sourcePath: string; + urlPath: string | null; + scope: string; + wordCount: number; + missingCriticalFields: string[]; + matchedClusterIds: string[]; + }>; + contentSources: Array<{ + id: string; + title: string; + sourcePath: string; + wordCount: number; + matchedClusterIds: string[]; + }>; + semanticSignals: Array<{ + id: string; + title: string; + priority: "high" | "medium" | "low"; + status: "covered" | "weak" | "missing"; + targetIntent: string; + evidenceLevel: "none" | "thin" | "moderate" | "strong"; + matchedTerms: string[]; + missingTerms: string[]; + queryExamples: string[]; + }>; + evidenceRefs: Array<{ + id: string; + title: string; + sourcePath: string; + urlPath: string | null; + scope: string; + }>; + knownRisks: Array<{ + id: string; + level: "ok" | "warning" | "problem"; + title: string; + message: string; + }>; + forbiddenClaims: string[]; + }; + allowedActions: Array< + | "identify_site_meaning" + | "extract_page_roles" + | "flag_ambiguity" + | "identify_forbidden_claims" + | "propose_normalization_hints" + | "request_human_review" + >; + outputSchema: { + schemaVersion: "seo-context-review.v1"; + requiredTopLevelKeys: string[]; + findingRequiredKeys: string[]; + evidenceRequiredKeys: string[]; + }; + confidencePolicy: { + highConfidenceMinEvidenceRefs: number; + requireHumanReviewWhenAmbiguous: boolean; + rejectClaimsWithoutEvidence: boolean; + }; + stopConditions: string[]; +}; + +const SEO_SKILL_MANIFESTS: SeoSkillManifest[] = [ + { + schemaVersion: "seo-skill.v1", + skillId: "seo.crawl_indexability", + version: "0.1.0", + stage: "scan", + title: "Crawl and indexability evidence", + summary: + "Собирает технические SEO-факты страницы и сайта. Модель может читать результат, но не решает фактические indexability проверки.", + owner: "seo_mode", + inputs: ["scan_version", "page_versions", "source_config", "project_index"], + requiredEvidence: ["html_head", "status_or_source_presence", "robots_policy", "sitemap_policy", "canonical_meta"], + deterministicChecks: [ + { id: "title", title: "Title exists and has usable length", source: "backend", required: true }, + { id: "description", title: "Meta description exists and has usable length", source: "backend", required: true }, + { id: "h1", title: "H1 exists and page heading order is inspectable", source: "backend", required: true }, + { id: "canonical", title: "Canonical/noindex/nofollow facts are captured", source: "backend", required: false }, + { id: "robots", title: "robots.txt policy is parsed when live URL is available", source: "external_provider", required: false }, + { id: "sitemap", title: "sitemap entries are parsed when live URL is available", source: "external_provider", required: false }, + { id: "schema", title: "Structured data candidates are extracted", source: "backend", required: false } + ], + modelTasks: [ + { + taskType: "none", + required: false, + providerMode: "none", + description: "Этот skill формирует факты для следующих AI stages, но сам не требует модели." + } + ], + outputSchema: { + schemaVersion: "seo-crawl-indexability.v1", + requiredTopLevelKeys: ["pages", "technicalFindings", "scopeCandidates", "evidenceRefs"] + }, + blockers: ["scan_missing", "source_unavailable", "html_unparseable"], + forbiddenActions: ["rewrite_content", "change_source_files", "invent_indexability_status"], + mcpCapabilities: [ + { + id: "seo.crawl_indexability.read", + writes: false, + description: "Читать технические SEO-факты, которые собрал backend scanner." + } + ], + uiDecisionSurface: ["Scan", "Audit", "Context Review"], + validationRules: [ + "Indexability facts must come from scanner/live crawl, not model guess.", + "Missing live URL must be represented as unknown, not ok.", + "Technical pages and admin pages must stay separated from indexable scope." + ] + }, + { + schemaVersion: "seo-skill.v1", + skillId: "seo.context_review", + version: "0.1.0", + stage: "context_review", + title: "AI context review", + summary: + "Первый полноценный AI entrypoint: модель объясняет смысл сайта, страницы, оффера, аудитории, gaps, ambiguity и forbidden claims по evidence.", + owner: "seo_mode", + inputs: ["semantic_analysis", "project_ontology", "page_scope", "style_profile", "crawl_indexability_evidence"], + requiredEvidence: ["semantic_clusters", "page_signals", "content_sources", "source_refs", "style_profile"], + deterministicChecks: [ + { id: "analysis_ready", title: "Semantic analysis exists", source: "backend", required: true }, + { id: "scope_ready", title: "Scope summary exists", source: "backend", required: true }, + { id: "evidence_refs", title: "Evidence refs are attached to semantic claims", source: "backend", required: true } + ], + modelTasks: [ + { + taskType: "seo.context_review", + required: true, + providerMode: "model_provider_contract", + description: "Вернуть site identity, offer, audience, page roles, semantic gaps, forbidden claims и normalization hints." + } + ], + outputSchema: { + schemaVersion: "seo-context-review.v1", + requiredTopLevelKeys: [ + "siteIdentity", + "offer", + "audience", + "pageRoles", + "semanticGaps", + "forbiddenClaims", + "normalizationHints", + "risks", + "needsHumanReview", + "summary" + ] + }, + blockers: ["semantic_analysis_missing", "insufficient_evidence_refs", "project_scope_unknown"], + forbiddenActions: ["rewrite_content", "send_wordstat", "apply_changes", "invent_services", "expand_business_direction"], + mcpCapabilities: [ + { + id: "seo.context_review.propose", + writes: false, + description: "Предложить структурированный context review без записи решений в проект." + }, + { + id: "seo.context_review.flag_ambiguity", + writes: false, + description: "Пометить неоднозначные claims, intents и зоны, требующие human review." + } + ], + uiDecisionSurface: ["Контекст сайта", "Dev details", "AI context"], + validationRules: [ + "Every important claim must reference source evidence.", + "Business expansion must be returned only as blocked proposal unless user opened expansion branch.", + "Forbidden claims must travel forward into normalization, keyword map and rewrite tasks." + ] + }, + { + schemaVersion: "seo-skill.v1", + skillId: "seo.semantic_normalization", + version: "0.1.0", + stage: "normalization", + title: "Semantic normalization before market demand", + summary: + "Нормализует язык сайта в рыночные query hypotheses до Wordstat/SERP, чтобы спрос не строился на сырых внутренних формулировках.", + owner: "seo_mode", + inputs: ["context_review", "semantic_clusters", "seed_candidates", "project_ontology", "forbidden_claims"], + requiredEvidence: ["source_seeds", "cluster_evidence", "context_review_findings", "style_profile"], + deterministicChecks: [ + { id: "dedupe", title: "Normalize and dedupe phrases", source: "backend", required: true }, + { id: "noise_filter", title: "Reject debug, template and technical phrases", source: "backend", required: true }, + { id: "single_word_filter", title: "Block broad single-word phrases without context", source: "backend", required: true } + ], + modelTasks: [ + { + taskType: "seo.normalization", + required: true, + providerMode: "model_provider_contract", + description: "Сгруппировать intents, предложить market queries, rejected seeds и routing в Wordstat/SERP." + } + ], + outputSchema: { + schemaVersion: "seo-normalization.v1", + requiredTopLevelKeys: ["intentGroups", "seedStrategy", "rejectedSeeds", "summary", "readiness"] + }, + blockers: ["context_review_missing", "forbidden_claims_missing", "source_seed_evidence_missing"], + forbiddenActions: ["send_unreviewed_noise_to_wordstat", "invent_market_without_evidence", "rewrite_content", "apply_changes"], + mcpCapabilities: [ + { + id: "seo.normalization.propose", + writes: false, + description: "Предложить market query normalization и routing без запуска внешних сервисов." + }, + { + id: "seo.normalization.route_review", + writes: false, + description: "Пометить фразы, которые нельзя отправлять в Wordstat/SERP без human review." + } + ], + uiDecisionSurface: ["Рыночный спрос", "Seed Review", "Dev details"], + validationRules: [ + "Wordstat receives normalized queries, not raw site text.", + "Low-confidence and expansion-like phrases must stop at human review.", + "Market demand evidence must stay separated from model hypothesis." + ] + }, + { + schemaVersion: "seo-skill.v1", + skillId: "seo.serp_interpretation", + version: "0.1.0", + stage: "serp", + title: "SERP and competitor interpretation", + summary: + "AI-слой над yandex-evidence.v1: модель объясняет интент выдачи, конкурентные углы, mismatch с целевой страницей и следующие evidence gaps.", + owner: "seo_mode", + inputs: ["yandex_evidence", "keyword_map", "market_seed_queue", "competitor_domains"], + requiredEvidence: ["wordstat_top", "wordstat_regions", "wordstat_dynamics", "serp_docs", "competitor_domains"], + deterministicChecks: [ + { id: "yandex_evidence_ready", title: "Yandex evidence run is ready", source: "backend", required: true }, + { id: "serp_docs_present", title: "SERP docs exist for checked phrases", source: "external_provider", required: true }, + { id: "demand_present", title: "Wordstat demand is preserved as evidence, not invented", source: "external_provider", required: true } + ], + modelTasks: [ + { + taskType: "seo.serp_interpretation", + required: true, + providerMode: "model_provider_contract", + description: + "Вернуть SERP intent, target fit, competitor gaps, risks, confidence и next evidence без rewrite/apply решений." + } + ], + outputSchema: { + schemaVersion: "seo-serp-interpretation.v1", + requiredTopLevelKeys: [ + "sourceYandexEvidenceRunId", + "phraseInterpretations", + "competitorGaps", + "strategySignals", + "risks", + "nextActions", + "summary" + ] + }, + blockers: ["yandex_evidence_missing", "serp_docs_missing", "competitor_evidence_missing"], + forbiddenActions: ["rewrite_content", "change_source_files", "invent_demand", "invent_competitors", "apply_changes"], + mcpCapabilities: [ + { + id: "seo.serp_interpretation.propose", + writes: false, + description: "Интерпретировать сохранённые SERP/Wordstat факты и вернуть read-only стратегические выводы." + }, + { + id: "seo.serp_interpretation.flag_mismatch", + writes: false, + description: "Пометить несовпадение интента выдачи и текущей посадочной без изменения сайта." + } + ], + uiDecisionSurface: ["SEO-стратегия", "Yandex evidence", "Model provider"], + validationRules: [ + "Model output must cite yandex-evidence refs for every phrase and competitor claim.", + "Competitor gaps must be phrased as evidence-backed opportunities, not automatic rewrite tasks.", + "No demand, SERP position, CTR, region or seasonality value may be invented by the model." + ] + }, + { + schemaVersion: "seo-skill.v1", + skillId: "seo.strategy_synthesis", + version: "0.1.0", + stage: "strategy", + title: "SEO strategy synthesis", + summary: + "AI-слой над seo-strategy.v1 и SERP interpretation: модель собирает понятную стратегию, варианты решения, риски, gaps и следующий аналитический шаг без записи в сайт.", + owner: "seo_mode", + inputs: ["seo_strategy", "serp_interpretation", "keyword_map", "context_review", "missing_evidence"], + requiredEvidence: ["seo-strategy.v1", "seo-serp-interpretation.v1", "approved_keyword_map", "context_review"], + deterministicChecks: [ + { id: "strategy_ready", title: "SEO strategy contract is ready", source: "backend", required: true }, + { id: "serp_interpretation_ready", title: "SERP interpretation is ready", source: "backend", required: true }, + { id: "no_apply_scope", title: "Source mutation scope is blocked", source: "backend", required: true } + ], + modelTasks: [ + { + taskType: "seo.strategy_synthesis", + required: true, + providerMode: "model_provider_contract", + description: + "Вернуть executive summary, recommended path, decision options, evidence narrative, risk posture, data gaps и blocked actions." + } + ], + outputSchema: { + schemaVersion: "seo-strategy-synthesis.v1", + requiredTopLevelKeys: [ + "executiveSummary", + "phaseDecision", + "recommendedPath", + "decisionOptions", + "evidenceNarrative", + "riskPosture", + "dataGaps", + "blockedActions", + "nextActions" + ] + }, + blockers: ["strategy_missing", "serp_interpretation_missing", "approved_keyword_map_missing"], + forbiddenActions: ["rewrite_content", "change_source_files", "invent_demand", "approve_strategy", "apply_changes"], + mcpCapabilities: [ + { + id: "seo.strategy_synthesis.propose", + writes: false, + description: "Собрать read-only SEO strategy decision surface из подтверждённых evidence contracts." + }, + { + id: "seo.strategy_synthesis.flag_gaps", + writes: false, + description: "Выделить недостающие данные, которые нельзя заменять догадками модели." + } + ], + uiDecisionSurface: ["SEO-стратегия", "Strategy synthesis", "Model provider"], + validationRules: [ + "Every recommendation must reference existing evidence refs or be marked as a data gap.", + "Zero-site/bootstrap strategy must defer head terms until index, traction and authority signals exist.", + "Recommended path is a decision proposal, not permission to mutate source files.", + "Business expansion and new market branches stay blocked until user explicitly opens expansion mode." + ] + }, + { + schemaVersion: "seo-skill.v1", + skillId: "seo.strategy_quality_review", + version: "0.1.0", + stage: "validation", + title: "SEO strategy quality review", + summary: + "AI/QA gate над seo-strategy.v1 и seo-strategy-synthesis.v1: проверяет phase runway, evidence refs, hallucination risk, business drift и UX-понятность без записи в сайт.", + owner: "seo_mode", + inputs: ["seo_strategy", "strategy_synthesis", "site_maturity", "serp_interpretation", "evidence_refs"], + requiredEvidence: ["seo-strategy.v1", "seo-strategy-synthesis.v1", "site-maturity.v1", "seo-serp-interpretation.v1"], + deterministicChecks: [ + { id: "strategy_synthesis_ready", title: "Strategy synthesis is ready", source: "backend", required: true }, + { id: "phase_guardrails_present", title: "Site maturity and phaseDecision are present", source: "backend", required: true }, + { id: "no_apply_scope", title: "Source mutation scope is blocked", source: "backend", required: true } + ], + modelTasks: [ + { + taskType: "seo.strategy_quality_review", + required: true, + providerMode: "model_provider_contract", + description: + "Вернуть approved/needs_changes/blocked, checks, blockers, warnings, approvedSignals и nextActions для доверия к стратегии." + } + ], + outputSchema: { + schemaVersion: "seo-strategy-quality-review.v1", + requiredTopLevelKeys: [ + "verdict", + "readiness", + "checks", + "blockers", + "warnings", + "approvedSignals", + "nextActions" + ] + }, + blockers: ["strategy_synthesis_missing", "phase_decision_missing", "evidence_refs_missing"], + forbiddenActions: ["rewrite_content", "change_source_files", "invent_demand", "invent_authority", "approve_apply", "apply_changes"], + mcpCapabilities: [ + { + id: "seo.strategy_quality_review.gate", + writes: false, + description: "Проверить доверие к strategy synthesis перед human approval без изменения сайта." + }, + { + id: "seo.strategy_quality_review.flag_hallucination", + writes: false, + description: "Найти выдуманный спрос, authority claims, business drift и phase violations." + } + ], + uiDecisionSurface: ["Проверка качества", "Strategy quality", "Model provider"], + validationRules: [ + "Quality review approves only analytics trust, never rewrite/apply.", + "Zero-site/bootstrap strategy is blocked if head terms are immediate actions.", + "Every strategic claim must either cite evidence refs or appear as a data gap.", + "Business expansion must remain outside core route unless user opened expansion branch." + ] + } +]; + +const PARSED_SEO_SKILL_MANIFESTS = z.array(SeoSkillManifestSchema).parse(SEO_SKILL_MANIFESTS); + +export function getSeoSkillManifests(): SeoSkillManifest[] { + return PARSED_SEO_SKILL_MANIFESTS; +} + +export function getSeoSkillManifest(skillId: string): SeoSkillManifest | null { + return PARSED_SEO_SKILL_MANIFESTS.find((skill) => skill.skillId === skillId) ?? null; +} + +export function getSeoSkillPackContract(): SeoSkillPackContract { + return SeoSkillPackContractSchema.parse({ + schemaVersion: "seo-skill-pack.v1", + packageId: "ndc-seo-skill-pack", + version: "0.1.0", + runtime: "seo_mode_backend", + generatedAt: new Date().toISOString(), + activeSkillIds: PARSED_SEO_SKILL_MANIFESTS.map((skill) => skill.skillId), + skills: PARSED_SEO_SKILL_MANIFESTS, + guardrails: [ + "Deterministic backend owns scan facts, external evidence, validation, diff and apply.", + "Model tasks are structured JSON contracts and never direct source mutations.", + "Every semantic claim must be grounded in evidence refs or marked as human review.", + "Business expansion is blocked unless user explicitly enters expansion branch." + ], + nextImplementationStage: + "Use seo-strategy-synthesis.v1 as input for seo.strategy_quality_review, then prepare external Webmaster/Metrica evidence after deploy." + }); +} + +function getEvidenceRefs(analysis: SemanticAnalysisRun): SeoContextReviewModelTaskContract["input"]["evidenceRefs"] { + return analysis.clusters + .flatMap((cluster) => + cluster.sourceRefs.slice(0, 4).map((ref) => ({ + id: `${cluster.id}:${ref.id}`, + title: ref.title, + sourcePath: ref.sourcePath, + urlPath: ref.urlPath, + scope: ref.scope + })) + ) + .slice(0, 60); +} + +export function buildSeoContextReviewTask( + projectId: string, + analysis: SemanticAnalysisRun +): SeoContextReviewModelTaskContract { + return { + taskType: "seo.context_review", + schemaVersion: "seo-context-review-task.v1", + taskId: `${analysis.runId}:seo-context-review:v1`, + projectId, + semanticRunId: analysis.runId, + scanVersionId: analysis.scanVersionId, + projectOntologyVersionId: analysis.projectOntology.versionId, + providerMode: "model_provider_contract", + activeSkillId: "seo.context_review", + input: { + siteSummary: { + brandName: analysis.projectOntology.brandName, + language: analysis.styleProfile.language, + topTerms: analysis.styleProfile.topTerms.slice(0, 24).map((term) => term.term), + brandTerms: analysis.styleProfile.brandTerms.slice(0, 12), + ctaPhrases: analysis.styleProfile.ctaPhrases.slice(0, 12), + toneSignals: analysis.styleProfile.toneSignals.slice(0, 12) + }, + scopeSummary: { + adminPages: analysis.pageScope.adminPages, + contentSources: analysis.pageScope.contentSources, + indexablePages: analysis.pageScope.indexablePages, + selectedIndexablePages: analysis.pageScope.selectedIndexablePages, + technicalPages: analysis.pageScope.technicalPages, + templateBlocks: analysis.pageScope.templateBlocks + }, + pageSignals: analysis.pages.slice(0, 40).map((page) => ({ + pageId: page.pageId, + selected: page.selected, + title: page.title, + sourcePath: page.sourcePath, + urlPath: page.urlPath, + scope: page.scope, + wordCount: page.wordCount, + missingCriticalFields: page.missingCriticalFields, + matchedClusterIds: page.matchedClusters + })), + contentSources: analysis.contentSources.slice(0, 24).map((source) => ({ + id: source.id, + title: source.title, + sourcePath: source.sourcePath, + wordCount: source.wordCount, + matchedClusterIds: source.matchedClusters + })), + semanticSignals: analysis.clusters.slice(0, 30).map((cluster) => ({ + evidenceLevel: cluster.evidence.level, + id: cluster.id, + matchedTerms: cluster.matchedTerms.slice(0, 12), + missingTerms: cluster.missingTerms.slice(0, 12), + priority: cluster.priority, + queryExamples: cluster.queryExamples.slice(0, 8), + status: cluster.status, + targetIntent: cluster.targetIntent, + title: cluster.title + })), + evidenceRefs: getEvidenceRefs(analysis), + knownRisks: [ + ...analysis.qualitySignals.slice(0, 20), + ...analysis.analysisVerdict.risks.slice(0, 12).map((risk) => ({ + id: risk.id, + level: risk.level, + title: risk.title, + message: risk.message + })) + ], + forbiddenClaims: [ + "Не утверждать услуги, рынки, регионы, интеграции, гарантии или цены без source evidence или approved ontology.", + "Не подменять смысл сайта соседним рынком; expansion branch должен быть отдельным user-approved режимом.", + "Не считать market demand доказанным до Wordstat/SERP/Webmaster/Metrica evidence.", + "Не предлагать rewrite/apply без approved page/field plan." + ] + }, + allowedActions: [ + "identify_site_meaning", + "extract_page_roles", + "flag_ambiguity", + "identify_forbidden_claims", + "propose_normalization_hints", + "request_human_review" + ], + outputSchema: { + schemaVersion: "seo-context-review.v1", + requiredTopLevelKeys: [ + "siteIdentity", + "offer", + "audience", + "pageRoles", + "sectionFindings", + "semanticGaps", + "forbiddenClaims", + "normalizationHints", + "risks", + "needsHumanReview", + "summary" + ], + findingRequiredKeys: ["id", "title", "evidenceRefs", "confidence", "risk", "needsHumanReview"], + evidenceRequiredKeys: ["sourcePath", "scope", "reason"] + }, + confidencePolicy: { + highConfidenceMinEvidenceRefs: 2, + rejectClaimsWithoutEvidence: true, + requireHumanReviewWhenAmbiguous: true + }, + stopConditions: [ + "Недостаточно evidence refs для определения реального оффера сайта.", + "Сайт выражен слишком слабо, и модель не может отделить продукт от технической оболочки.", + "Запрошенное действие требует 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 new file mode 100644 index 0000000..a013f9d --- /dev/null +++ b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts @@ -0,0 +1,1067 @@ +import { getLatestSeoContextReview } from "../contextReview/seoContextReview.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 { getLatestYandexEvidenceContract, type YandexEvidenceContract } from "../evidence/yandexEvidence.js"; +import { + getEmptySerpInterpretationContract, + getLatestSerpInterpretationContract, + type SerpInterpretationContract +} from "../evidence/serpInterpretation.js"; + +type SeoStrategyState = "blocked" | "evidence_gap" | "strategy_draft" | "ready_for_review"; +type SeoStrategyPriority = "high" | "low" | "medium"; +type SeoStrategyConfidence = "high" | "low" | "medium"; +type SeoMaturityPhase = "authority_head_terms" | "bootstrap_indexing" | "growth" | "traction"; +type SeoOpportunityAction = "defer" | "do_now" | "prepare"; +type SeoCompetitionTier = "head" | "long_tail" | "mid" | "unknown"; + +type EvidenceProvider = MarketEnrichmentContract["providers"][number]; +type KeywordMapPersistedItem = KeywordMapContract["persisted"]["items"][number]; + +export type SeoStrategyContract = { + schemaVersion: "seo-strategy.v1"; + projectId: string; + semanticRunId: string | null; + projectOntologyVersion: MarketEnrichmentContract["projectOntologyVersion"]; + generatedAt: string; + state: SeoStrategyState; + verdict: { + title: string; + summary: string; + confidence: SeoStrategyConfidence; + primaryConstraint: string; + }; + readiness: { + approvedKeywordCount: number; + contextReady: boolean; + evidenceGapCount: number; + externalProviderConnectedCount: number; + marketEvidenceCount: number; + opportunityCount: number; + riskCount: number; + strategyConfidenceScore: number; + trashFilteredCount: number; + }; + siteMaturity: { + schemaVersion: "site-maturity.v1"; + phase: SeoMaturityPhase; + confidence: SeoStrategyConfidence; + reason: string; + signals: { + approvedKeywordCount: number; + hasAuthorityEvidence: boolean; + hasIndexEvidence: boolean; + hasPerformanceEvidence: boolean; + hasSerpInterpretation: boolean; + hasTractionEvidence: boolean; + marketEvidenceCount: number; + totalDemand: number; + }; + allowedNow: string[]; + holdUntil: string[]; + promotionSignals: string[]; + guardrails: string[]; + }; + phasePlan: Array<{ + phase: SeoMaturityPhase; + title: string; + status: "current" | "future" | "locked" | "ready"; + goal: string; + allowedTargets: string[]; + deferredTargets: string[]; + promotionSignals: string[]; + risk: string; + }>; + evidence: { + context: { + status: "missing" | "needs_review" | "ready"; + summary: string; + gaps: string[]; + }; + demand: { + exactSeedCount: number; + usefulCandidateCount: number; + rejectedNoiseCount: number; + riskyCandidateCount: number; + topSignals: Array<{ + phrase: string; + frequency: number | null; + targetPath: string | null; + }>; + totalFrequency: number; + }; + providers: Array<{ + id: EvidenceProvider["id"]; + label: string; + status: EvidenceProvider["status"]; + impact: string; + nextStep: string; + }>; + yandex: { + state: YandexEvidenceContract["state"]; + runId: string | null; + checkedPhraseCount: number; + totalDemand: number; + serpDocCount: number; + competitorDomainCount: number; + topDomains: Array<{ + domain: string; + count: number; + }>; + nextStep: string; + }; + serpInterpretation: { + state: SerpInterpretationContract["state"]; + runId: string | null; + yandexEvidenceRunId: string | null; + interpretedPhraseCount: number; + mismatchCount: number; + competitorGapCount: number; + riskCount: number; + nextStep: string; + }; + }; + opportunities: Array<{ + id: string; + title: string; + category: "content_gap" | "core_demand" | "market_expansion" | "proof_gap" | "technical_blocker"; + confidence: SeoStrategyConfidence; + demand: { + phraseCount: number; + totalFrequency: number; + topPhrase: string | null; + }; + evidenceRefs: string[]; + nextDataNeeded: string[]; + priority: SeoStrategyPriority; + competitionTier: SeoCompetitionTier; + recommendation: string; + targetPath: string | null; + timing: { + action: SeoOpportunityAction; + currentPhase: SeoMaturityPhase; + earliestPhase: SeoMaturityPhase; + reason: string; + promotionSignal: string; + }; + whyItMatters: string; + }>; + risks: Array<{ + id: string; + title: string; + level: "critical" | "high" | "medium"; + message: string; + mitigation: string; + }>; + strategyOptions: Array<{ + id: string; + title: string; + confidence: SeoStrategyConfidence; + effort: "high" | "low" | "medium"; + expectedImpact: "high" | "low" | "medium"; + rationale: string; + requiredEvidence: string[]; + }>; + missingEvidence: Array<{ + id: string; + title: string; + impact: string; + owner: "ai_layer" | "external_service" | "product_review"; + priority: SeoStrategyPriority; + }>; + nextActions: string[]; +}; + +function normalizeKey(value: string | null) { + return value?.trim() || "unmapped"; +} + +function getConfidence(score: number): SeoStrategyConfidence { + if (score >= 75) { + return "high"; + } + + if (score >= 45) { + return "medium"; + } + + return "low"; +} + +function getCompetitionTier(totalFrequency: number | null | undefined, difficultyScore?: number | null): SeoCompetitionTier { + const frequency = totalFrequency ?? 0; + + if (difficultyScore !== null && difficultyScore !== undefined && difficultyScore >= 78) { + return "head"; + } + + if (frequency >= 5000) { + return "head"; + } + + if (frequency >= 1000 || (difficultyScore !== null && difficultyScore !== undefined && difficultyScore >= 55)) { + return "mid"; + } + + if (frequency > 0) { + return "long_tail"; + } + + return "unknown"; +} + +function getPhaseRank(phase: SeoMaturityPhase) { + const ranks = { + authority_head_terms: 4, + bootstrap_indexing: 1, + growth: 3, + traction: 2 + } satisfies Record<SeoMaturityPhase, number>; + + return ranks[phase]; +} + +function getEarliestPhaseForTier(tier: SeoCompetitionTier): SeoMaturityPhase { + if (tier === "head") { + return "authority_head_terms"; + } + + if (tier === "mid") { + return "growth"; + } + + return "bootstrap_indexing"; +} + +function getOpportunityTiming( + siteMaturity: SeoStrategyContract["siteMaturity"], + tier: SeoCompetitionTier, + hasPrimaryIntent: boolean +): SeoStrategyContract["opportunities"][number]["timing"] { + const earliestPhase = getEarliestPhaseForTier(tier); + const currentRank = getPhaseRank(siteMaturity.phase); + const earliestRank = getPhaseRank(earliestPhase); + + if (currentRank >= earliestRank && (tier === "long_tail" || hasPrimaryIntent || siteMaturity.phase !== "bootstrap_indexing")) { + return { + action: "do_now", + currentPhase: siteMaturity.phase, + earliestPhase, + promotionSignal: "Сохранить evidence refs и подтвердить интент перед human approval.", + reason: "Фраза соответствует текущей зрелости сайта и не требует более поздней authority-фазы." + }; + } + + if (currentRank + 1 >= earliestRank) { + return { + action: "prepare", + currentPhase: siteMaturity.phase, + earliestPhase, + promotionSignal: "Дождаться индексации, первых impressions/CTR или подтверждённого target-fit.", + reason: "Фраза перспективна, но её лучше готовить как следующий слой после первых site signals." + }; + } + + return { + action: "defer", + currentPhase: siteMaturity.phase, + earliestPhase, + promotionSignal: "Нужны index/traction/authority signals: показы, клики, поведение, external references или брендовые сигналы.", + reason: "Для текущей зрелости сайта это слишком конкурентный target; высокий спрос не равен immediate priority." + }; +} + +function buildSiteMaturity(input: { + keywordMap: KeywordMapContract; + market: MarketEnrichmentContract; + serpInterpretation: SerpInterpretationContract; + yandexEvidence: YandexEvidenceContract; +}): SeoStrategyContract["siteMaturity"] { + const hasPerformanceEvidence = input.market.providers.some( + (provider) => ["metrika", "search_console", "webmaster"].includes(String(provider.id)) && provider.status === "connected" + ); + const hasIndexEvidence = hasPerformanceEvidence; + const hasTractionEvidence = false; + const hasAuthorityEvidence = false; + const phase: SeoMaturityPhase = hasAuthorityEvidence + ? "authority_head_terms" + : hasTractionEvidence + ? "growth" + : hasIndexEvidence + ? "traction" + : "bootstrap_indexing"; + + return { + allowedNow: [ + "Собирать low-competition exact intent и long-tail кластеры.", + "Держать семантику понятной для индексации: один смысловой слой на посадочную.", + "Готовить SERP/evidence backlog без попытки сразу забрать head terms." + ], + confidence: hasPerformanceEvidence ? "medium" : "high", + guardrails: [ + "Head/top keywords нельзя считать immediate action для zero-site/new-domain.", + "External demand не является site traction.", + "Переход фазы требует индексации, impressions/clicks или authority evidence." + ], + holdUntil: [ + "Mid/head keywords держать как future targets до появления индексации и traction.", + "Не строить стратегию на top demand без Webmaster/Метрики/Search Console-style evidence.", + "Не расширять core business route без отдельной user-approved expansion branch." + ], + phase, + promotionSignals: [ + "Страницы появились в индексе.", + "Есть первые impressions/clicks/CTR по bootstrap-кластерам.", + "SERP target-fit подтверждён и нет critical mismatch.", + "Появились поведенческие, брендовые или внешние authority signals." + ], + reason: hasPerformanceEvidence + ? "Есть внешние site-performance источники; фазу можно повышать после анализа индексации и traction." + : "Нет Webmaster/Метрики/Search Console-style evidence: рынок виден, но сам сайт ещё не доказал индексацию и traction.", + schemaVersion: "site-maturity.v1", + signals: { + approvedKeywordCount: input.keywordMap.persisted.approvedDecisionCount, + hasAuthorityEvidence, + hasIndexEvidence, + hasPerformanceEvidence, + hasSerpInterpretation: input.serpInterpretation.state === "ready", + hasTractionEvidence, + marketEvidenceCount: input.keywordMap.readiness.marketEvidenceCount, + totalDemand: input.market.wordstat.summary.totalFrequency + input.yandexEvidence.summary.totalDemand + } + }; +} + +function buildPhasePlan(siteMaturity: SeoStrategyContract["siteMaturity"]): SeoStrategyContract["phasePlan"] { + const phases: Array<Omit<SeoStrategyContract["phasePlan"][number], "status">> = [ + { + allowedTargets: ["точные low-competition фразы", "long-tail формулировки", "понятные product/category terms"], + deferredTargets: ["top/head keywords", "широкие конкурентные кластеры", "агрессивное расширение рынка"], + goal: "Дать поисковику ясный контекст сайта и получить базовую индексацию.", + phase: "bootstrap_indexing", + promotionSignals: ["страницы в индексе", "первые impressions", "нет critical intent mismatch"], + risk: "Если сразу идти в head terms, сайт не получает понятного стартового релевантного сигнала.", + title: "Bootstrap / indexing" + }, + { + allowedTargets: ["точные mid-tail фразы", "смежные support-кластеры", "страницы с первым demand evidence"], + deferredTargets: ["самые конкурентные head terms", "большие market expansion ветки"], + goal: "Расширять охват после первых признаков индексации и релевантности.", + phase: "traction", + promotionSignals: ["impressions/clicks", "CTR не провален", "страницы стабильно индексируются"], + risk: "Без traction средние кластеры могут размазать фокус и не дать роста.", + title: "Traction" + }, + { + allowedTargets: ["mid-competition clusters", "supporting pages/sections", "internal linking между подтверждёнными кластерами"], + deferredTargets: ["самые дорогие head terms без authority signals"], + goal: "Усиливать посадочные и контентные подпорки на базе подтверждённых сигналов.", + phase: "growth", + promotionSignals: ["рост impressions/clicks", "есть ranking movement", "SERP fit подтверждён"], + risk: "Рост без quality/relevance контроля может привести к переспаму и слабому поведенческому сигналу.", + title: "Growth" + }, + { + allowedTargets: ["head terms", "широкие конкурентные категории", "authority-level landing/content clusters"], + deferredTargets: ["новые рынки без отдельной strategy branch"], + goal: "Атаковать top keywords только после накопления доверия, истории и authority evidence.", + phase: "authority_head_terms", + promotionSignals: ["стабильный органический трафик", "брендовые/внешние сигналы", "поведенческие метрики выдерживают конкуренцию"], + risk: "Head terms без authority дают ложный фокус и съедают ресурс стратегии.", + title: "Authority / head terms" + } + ]; + const currentRank = getPhaseRank(siteMaturity.phase); + + return phases.map((phase) => { + const rank = getPhaseRank(phase.phase); + return { + ...phase, + status: rank === currentRank ? "current" : rank < currentRank ? "ready" : rank === currentRank + 1 ? "future" : "locked" + }; + }); +} + +function getProviderNextStep(provider: EvidenceProvider) { + if (provider.status === "connected") { + return "Использовать как evidence source в стратегии и показывать дату/объём сбора."; + } + + if (provider.id === "serp") { + return "Подключить SERP collector: типы страниц, конкуренты, сниппеты, интент и сложность."; + } + + if (provider.id === "model") { + return "Описать structured model task для strategy synthesis и gap analysis."; + } + + if (provider.id === "text_quality") { + return "Добавить локальный quality checker до любых будущих текстовых правок."; + } + + return "Подключить платформенный provider без ручной настройки пользователем."; +} + +function mapProviders(providers: EvidenceProvider[]): SeoStrategyContract["evidence"]["providers"] { + return providers.map((provider) => ({ + id: provider.id, + impact: provider.role, + label: provider.label, + nextStep: getProviderNextStep(provider), + status: provider.status + })); +} + +function getContextStatus(contextReview: Awaited<ReturnType<typeof getLatestSeoContextReview>>) { + if (!contextReview) { + return "missing" as const; + } + + if (contextReview.analystReview.status === "approved" && !contextReview.needsHumanReview) { + return "ready" as const; + } + + return "needs_review" as const; +} + +function buildContextEvidence( + contextReview: Awaited<ReturnType<typeof getLatestSeoContextReview>> +): SeoStrategyContract["evidence"]["context"] { + const status = getContextStatus(contextReview); + + if (!contextReview) { + return { + gaps: ["Нет context review: стратегия не должна строиться только на raw scan/semantic output."], + status, + summary: "Контекст сайта ещё не подтверждён AI/human review." + }; + } + + const gaps = [ + ...contextReview.semanticGaps.slice(0, 4).map((gap) => gap.title), + ...contextReview.risks + .filter((risk) => risk.level !== "ok") + .slice(0, 3) + .map((risk) => risk.title) + ]; + + return { + gaps, + status, + summary: contextReview.summary || contextReview.siteIdentity.description + }; +} + +function buildDemandEvidence( + market: MarketEnrichmentContract, + cleaning: KeywordCleaningContract, + keywordMap: KeywordMapContract +): SeoStrategyContract["evidence"]["demand"] { + const persistedPhraseSet = new Set(keywordMap.persisted.items.map((item) => item.phrase.toLocaleLowerCase("ru-RU"))); + const topSignals = cleaning.items + .filter((item) => persistedPhraseSet.has(item.phrase.toLocaleLowerCase("ru-RU")) && item.frequency !== null) + .sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0)) + .slice(0, 8) + .map((item) => ({ + frequency: item.frequency, + phrase: item.phrase, + targetPath: item.targetPath + })); + + return { + exactSeedCount: market.wordstat.summary.exactFrequencySeedCount, + rejectedNoiseCount: cleaning.readiness.trashCount, + riskyCandidateCount: cleaning.readiness.riskyCount, + topSignals, + totalFrequency: market.wordstat.summary.totalFrequency, + usefulCandidateCount: cleaning.readiness.useCount + cleaning.readiness.supportCount + }; +} + +function buildYandexEvidenceSummary(yandexEvidence: YandexEvidenceContract): SeoStrategyContract["evidence"]["yandex"] { + return { + checkedPhraseCount: yandexEvidence.summary.checkedPhraseCount, + competitorDomainCount: yandexEvidence.summary.competitorDomainCount, + nextStep: + yandexEvidence.state === "ready" + ? "Use Yandex evidence for SERP interpretation and competitor gap model task." + : "Run Yandex evidence collection before strategy approval.", + runId: yandexEvidence.runId, + serpDocCount: yandexEvidence.summary.serpDocCount, + state: yandexEvidence.state, + topDomains: yandexEvidence.summary.topDomains, + totalDemand: yandexEvidence.summary.totalDemand + }; +} + +function buildSerpInterpretationSummary( + serpInterpretation: SerpInterpretationContract +): SeoStrategyContract["evidence"]["serpInterpretation"] { + return { + competitorGapCount: serpInterpretation.readiness.competitorGapCount, + interpretedPhraseCount: serpInterpretation.readiness.interpretedPhraseCount, + mismatchCount: serpInterpretation.readiness.mismatchCount, + nextStep: + serpInterpretation.state === "ready" + ? "Use interpreted SERP signals inside opportunity map, risks and strategy options." + : "Run SERP interpretation after Yandex evidence collection.", + riskCount: serpInterpretation.readiness.riskCount, + runId: serpInterpretation.runId, + state: serpInterpretation.state, + yandexEvidenceRunId: serpInterpretation.yandexEvidenceRunId + }; +} + +function getCleaningItemByPhrase(cleaning: KeywordCleaningContract) { + const itemsByPhrase = new Map<string, KeywordCleaningContract["items"][number]>(); + + for (const item of cleaning.items) { + const key = item.phrase.toLocaleLowerCase("ru-RU"); + const currentItem = itemsByPhrase.get(key); + + if (!currentItem || (item.frequency ?? 0) > (currentItem.frequency ?? 0)) { + itemsByPhrase.set(key, item); + } + } + + return itemsByPhrase; +} + +function buildOpportunities( + cleaning: KeywordCleaningContract, + keywordMap: KeywordMapContract, + siteMaturity: SeoStrategyContract["siteMaturity"] +): SeoStrategyContract["opportunities"] { + const cleaningByPhrase = getCleaningItemByPhrase(cleaning); + const groups = new Map<string, KeywordMapPersistedItem[]>(); + + for (const item of keywordMap.persisted.items) { + const key = normalizeKey(item.targetPath); + const group = groups.get(key) ?? []; + + group.push(item); + groups.set(key, group); + } + + return Array.from(groups.entries()) + .map(([targetPath, items]) => { + const enrichedItems = items.map((item) => ({ + ...item, + cleaning: cleaningByPhrase.get(item.phrase.toLocaleLowerCase("ru-RU")) ?? null + })); + const totalFrequency = enrichedItems.reduce((sum, item) => sum + (item.cleaning?.frequency ?? 0), 0); + const topItem = enrichedItems + .filter((item) => item.cleaning?.frequency !== null) + .sort((left, right) => (right.cleaning?.frequency ?? 0) - (left.cleaning?.frequency ?? 0))[0]; + const hasPrimary = items.some((item) => item.role === "primary"); + const supportCount = items.filter((item) => item.role === "support").length; + const confidenceScore = Math.min(95, 35 + (hasPrimary ? 25 : 0) + Math.min(25, supportCount * 4) + (totalFrequency > 0 ? 15 : 0)); + const cleanTargetPath = targetPath === "unmapped" ? null : targetPath; + const priority: SeoStrategyPriority = + totalFrequency >= 1000 || hasPrimary ? "high" : supportCount >= 3 ? "medium" : "low"; + const competitionTier = getCompetitionTier(totalFrequency); + const timing = getOpportunityTiming(siteMaturity, competitionTier, hasPrimary); + + return { + category: hasPrimary ? ("core_demand" as const) : ("proof_gap" as const), + competitionTier, + confidence: getConfidence(confidenceScore), + demand: { + phraseCount: items.length, + topPhrase: topItem?.phrase ?? items[0]?.phrase ?? null, + totalFrequency + }, + evidenceRefs: items.map((item) => item.decisionId), + id: `opportunity:${targetPath}`, + nextDataNeeded: + totalFrequency > 0 + ? ["SERP top-10 по основным фразам", "конкурентные snippets", "показы/CTR из Webmaster/Метрики"] + : ["точная частотность", "SERP intent", "проверка формулировки моделью"], + priority, + recommendation: hasPrimary + ? timing.action === "defer" + ? "Держать как будущий strategic target: для текущей зрелости сайта нельзя атаковать этот спрос сразу." + : "Держать как основной стратегический кластер, но не считать финальным решением до SERP/competitor проверки." + : "Оставить как supporting opportunity до подтверждения интента и спроса.", + targetPath: cleanTargetPath, + timing, + title: topItem?.phrase ?? cleanTargetPath ?? "Неразмеченная возможность", + whyItMatters: + totalFrequency > 0 + ? `Есть подтверждённый спрос и ${items.length} связанных решений; это может стать понятной точкой роста.` + : "Есть смысловая связь с сайтом, но market evidence ещё недостаточно сильный." + }; + }) + .sort((left, right) => { + const priorityScore = { high: 3, low: 1, medium: 2 } satisfies Record<SeoStrategyPriority, number>; + + return priorityScore[right.priority] - priorityScore[left.priority] || right.demand.totalFrequency - left.demand.totalFrequency; + }); +} + +function buildSerpOpportunities( + serpInterpretation: SerpInterpretationContract, + siteMaturity: SeoStrategyContract["siteMaturity"] +): SeoStrategyContract["opportunities"] { + if (serpInterpretation.state !== "ready") { + return []; + } + + return serpInterpretation.phraseInterpretations + .filter((phrase) => phrase.confidence !== "low" && phrase.intent !== "unknown") + .slice(0, 6) + .map((phrase) => { + const isMismatch = phrase.targetFit === "mismatch"; + const isCore = phrase.targetFit === "strong_fit"; + const priority: SeoStrategyPriority = + (phrase.totalDemand ?? 0) >= 1000 || phrase.confidence === "high" ? "high" : isMismatch ? "medium" : "low"; + const competitionTier = getCompetitionTier(phrase.totalDemand, phrase.difficultyScore); + const timing = getOpportunityTiming(siteMaturity, competitionTier, isCore); + + return { + category: isMismatch ? ("content_gap" as const) : isCore ? ("core_demand" as const) : ("proof_gap" as const), + competitionTier, + confidence: phrase.confidence, + demand: { + phraseCount: 1, + topPhrase: phrase.phrase, + totalFrequency: phrase.totalDemand ?? 0 + }, + evidenceRefs: phrase.evidenceRefs, + id: `serp-opportunity:${phrase.phrase}`, + nextDataNeeded: phrase.nextEvidenceNeeded, + priority, + recommendation: + timing.action === "defer" + ? "Оставить как поздний target: высокий спрос/сложность не должен идти в immediate route для текущей зрелости сайта." + : phrase.recommendedRole === "article_backlog" + ? "Не тащить в коммерческий rewrite: оформить как контентную/support гипотезу и проверить вручную." + : phrase.recommendedRole === "core_landing" + ? "Держать как SERP-confirmed core opportunity перед strategy review." + : "Использовать как supporting opportunity до финальной привязки страницы.", + targetPath: phrase.targetPath, + timing, + title: phrase.phrase, + whyItMatters: phrase.opportunity + }; + }); +} + +function buildMissingEvidence( + market: MarketEnrichmentContract, + contextReview: Awaited<ReturnType<typeof getLatestSeoContextReview>>, + yandexEvidence: YandexEvidenceContract, + serpInterpretation: SerpInterpretationContract +): SeoStrategyContract["missingEvidence"] { + const missing: SeoStrategyContract["missingEvidence"] = []; + const hasYandexSerpEvidence = yandexEvidence.summary.serpDocCount > 0; + + for (const provider of market.providers) { + if (provider.status === "connected") { + continue; + } + + if (provider.id === "serp" && hasYandexSerpEvidence) { + continue; + } + + missing.push({ + id: `provider:${provider.id}`, + impact: provider.role, + owner: provider.id === "model" || provider.id === "text_quality" ? "ai_layer" : "external_service", + priority: provider.id === "serp" ? "high" : "medium", + title: `${provider.label}: ${provider.status === "not_implemented" ? "нужно реализовать" : "не подключено"}` + }); + } + + if (!contextReview || contextReview.analystReview.status !== "approved") { + missing.push({ + id: "context:analyst_review", + impact: "Без подтверждённого контекста стратегия рискует оптимизировать не тот смысл сайта.", + owner: "product_review", + priority: "high", + title: "Context review требует финальной проверки" + }); + } + + if (yandexEvidence.state === "not_collected") { + missing.push({ + id: "external:yandex_evidence", + impact: "Без Yandex evidence стратегия не видит SERP-конкурентов, сезонность и региональный спрос.", + owner: "external_service", + priority: "high", + title: "Yandex evidence ещё не собран" + }); + } + + if (yandexEvidence.state === "ready" && serpInterpretation.state !== "ready") { + missing.push({ + id: "ai:serp_interpretation", + impact: "Без интерпретации SERP стратегия видит цифры и домены, но не понимает target-fit, page type и конкурентные углы.", + owner: "ai_layer", + priority: "high", + title: "SERP interpretation ещё не собран" + }); + } + + return missing; +} + +function buildRisks(input: { + cleaning: KeywordCleaningContract; + contextReview: Awaited<ReturnType<typeof getLatestSeoContextReview>>; + keywordMap: KeywordMapContract; + market: MarketEnrichmentContract; + missingEvidence: SeoStrategyContract["missingEvidence"]; + opportunities: SeoStrategyContract["opportunities"]; + serpInterpretation: SerpInterpretationContract; + siteMaturity: SeoStrategyContract["siteMaturity"]; + yandexEvidence: YandexEvidenceContract; +}): SeoStrategyContract["risks"] { + const risks: SeoStrategyContract["risks"] = []; + + if (input.missingEvidence.some((item) => item.id === "provider:serp" || item.id === "external:yandex_evidence")) { + risks.push({ + id: "risk:no_serp", + level: "high", + message: "Сейчас есть Wordstat-сигналы, но нет проверки типа выдачи, конкурентов и intent fit.", + mitigation: "Подключить SERP evidence before strategy approval.", + title: "Нет конкурентного SERP слоя" + }); + } + + if (input.cleaning.readiness.riskyCount > input.cleaning.readiness.keywordMapCandidateCount) { + risks.push({ + id: "risk:too_many_risky_phrases", + level: "medium", + message: `${input.cleaning.readiness.riskyCount} фраз остаются risky; это сигнал, что seed strategy ещё узкая или грязная.`, + mitigation: "Отдать risky lane в model/human cleaning и расширить seed acquisition.", + title: "Много спорных фраз" + }); + } + + if (input.keywordMap.persisted.approvedDecisionCount === 0) { + risks.push({ + id: "risk:no_approved_keywords", + level: "critical", + message: "Нет подтверждённых keyword decisions, поэтому стратегия не имеет опорной карты спроса.", + mitigation: "Сначала сохранить evidence-confirmed keyword map decisions.", + title: "Нет утверждённой карты решений" + }); + } + + if (input.contextReview?.forbiddenClaims.length) { + risks.push({ + id: "risk:forbidden_claims", + level: "medium", + message: `Context review нашёл ${input.contextReview.forbiddenClaims.length} claim guardrails.`, + mitigation: "Держать forbidden claims как hard guardrails для всех AI strategy/rewrite tasks.", + title: "Есть ограничения по утверждениям" + }); + } + + if (input.market.wordstat.summary.noSignalSeedCount > input.market.wordstat.summary.exactFrequencySeedCount) { + risks.push({ + id: "risk:weak_seed_market_fit", + level: "medium", + message: "Пустых Wordstat-сигналов больше, чем точных. Нужна нормализация формулировок и расширение seed strategy.", + mitigation: "Добавить model SERP/keyword expansion task и отделить support/article/backlog lanes.", + title: "Слабый market fit части seed-ов" + }); + } + + const deferredCompetitiveTargets = input.opportunities.filter( + (opportunity) => + opportunity.timing.action === "defer" && + (opportunity.competitionTier === "head" || opportunity.competitionTier === "mid") + ); + + if (input.siteMaturity.phase === "bootstrap_indexing" && deferredCompetitiveTargets.length > 0) { + risks.push({ + id: "risk:zero_site_head_terms", + level: "high", + message: `${deferredCompetitiveTargets.length} конкурентных targets нельзя вести как immediate action: сайт ещё в bootstrap/indexing фазе.`, + mitigation: "Сначала собрать low-competition индексируемое ядро, дождаться index/traction signals и только потом повышать фазу.", + title: "Нулевой сайт не должен сразу атаковать head terms" + }); + } + + if (input.serpInterpretation.state === "ready") { + for (const risk of input.serpInterpretation.risks.slice(0, 6)) { + risks.push({ + id: risk.id, + level: risk.level, + message: risk.message, + mitigation: risk.mitigation, + title: risk.title + }); + } + } else if (input.yandexEvidence.summary.serpDocCount > 0) { + const informationalMismatchCount = input.yandexEvidence.phrases.filter( + (phrase) => phrase.interpretation.serpIntent === "informational" && phrase.targetPath + ).length; + + if (informationalMismatchCount > 0) { + risks.push({ + id: "risk:serp_intent_mismatch", + level: "medium", + message: `${informationalMismatchCount} фраз имеют информационную выдачу при наличии целевой страницы. Нельзя автоматически вести их как коммерческие.`, + mitigation: "Отдать эти фразы в model task `serp_interpretation` и решить: статья, support-секция или коммерческий лендинг.", + title: "Возможен mismatch интента выдачи" + }); + } + } + + return risks; +} + +function buildStrategyOptions(input: { + missingEvidence: SeoStrategyContract["missingEvidence"]; + opportunities: SeoStrategyContract["opportunities"]; + risks: SeoStrategyContract["risks"]; + siteMaturity: SeoStrategyContract["siteMaturity"]; +}): SeoStrategyContract["strategyOptions"] { + const immediateOpportunityCount = input.opportunities.filter((opportunity) => opportunity.timing.action === "do_now").length; + const deferredHeadCount = input.opportunities.filter( + (opportunity) => opportunity.timing.action === "defer" && opportunity.competitionTier === "head" + ).length; + const hasSerpGap = input.missingEvidence.some( + (evidence) => evidence.id === "provider:serp" || evidence.id === "ai:serp_interpretation" + ); + + return [ + { + confidence: input.siteMaturity.confidence, + effort: "low", + expectedImpact: "medium", + id: "option:bootstrap_runway", + rationale: + "Для zero-site сначала нужен phased runway: индексируемое long-tail ядро, понятный смысл сайта и сигналы, которые откроют следующие фазы.", + requiredEvidence: input.siteMaturity.promotionSignals, + title: "Начать с bootstrap/indexing стратегии" + }, + { + confidence: immediateOpportunityCount > 0 ? "medium" : "low", + effort: "low", + expectedImpact: immediateOpportunityCount > 0 ? "medium" : "low", + id: "option:stabilize_core", + rationale: + "Удержать только targets, допустимые для текущей зрелости сайта, и не раздувать охват до index/traction signals.", + requiredEvidence: hasSerpGap ? ["SERP evidence по top opportunity phrases"] : ["human strategy review"], + title: "Усилить подтверждённое ядро спроса" + }, + { + confidence: hasSerpGap || deferredHeadCount > 0 ? "low" : "medium", + effort: "medium", + expectedImpact: input.siteMaturity.phase === "bootstrap_indexing" ? "medium" : "high", + id: "option:market_gap_research", + rationale: + deferredHeadCount > 0 + ? "Конкурентные gap-сигналы видны, но для текущей зрелости сайта это future runway, а не immediate route." + : "Найти соседние рынки и конкурентные углы только после внешней проверки выдачи, а не по догадкам из текущего текста сайта.", + requiredEvidence: ["SERP competitors", "snippets", "page type distribution", "CTR/impression data"], + title: "Подготовить расширение через конкурентные gap-сигналы" + }, + { + confidence: input.risks.some((risk) => risk.level === "critical") ? "low" : "medium", + effort: "medium", + expectedImpact: "medium", + id: "option:analytics_first", + rationale: "Сначала закрыть данные и UX, чтобы пользователь понял ценность до обсуждения изменений сайта.", + requiredEvidence: input.missingEvidence.slice(0, 5).map((evidence) => evidence.title), + title: "Сначала доказать аналитику, потом обсуждать реализацию" + } + ]; +} + +function getStrategyScore(input: { + contextReady: boolean; + keywordMap: KeywordMapContract; + market: MarketEnrichmentContract; + missingEvidence: SeoStrategyContract["missingEvidence"]; + opportunities: SeoStrategyContract["opportunities"]; + serpInterpretation: SerpInterpretationContract; + yandexEvidence: YandexEvidenceContract; +}) { + let score = 15; + + if (input.contextReady) { + score += 20; + } + + if (input.keywordMap.persisted.approvedDecisionCount > 0) { + score += 20; + } + + if (input.market.wordstat.summary.exactFrequencySeedCount > 0) { + score += 20; + } + + if (input.opportunities.some((opportunity) => opportunity.priority === "high")) { + score += 15; + } + + if (input.yandexEvidence.summary.serpDocCount > 0) { + score += 10; + } + + if (input.yandexEvidence.summary.totalDemand > 0) { + score += 5; + } + + if (input.serpInterpretation.state === "ready") { + score += 10; + } + + score -= Math.min(25, input.missingEvidence.length * 5); + + return Math.max(0, Math.min(100, score)); +} + +function getState(score: number, keywordMap: KeywordMapContract, missingEvidence: SeoStrategyContract["missingEvidence"]): SeoStrategyState { + if (keywordMap.persisted.approvedDecisionCount === 0) { + return "blocked"; + } + + if (missingEvidence.some((evidence) => evidence.priority === "high")) { + return "evidence_gap"; + } + + if (score >= 75) { + return "ready_for_review"; + } + + return "strategy_draft"; +} + +function buildNextActions(contract: Omit<SeoStrategyContract, "nextActions">) { + const actions: string[] = []; + + if (contract.missingEvidence.length > 0) { + actions.push(`Закрыть evidence gaps: ${contract.missingEvidence.slice(0, 3).map((item) => item.title).join("; ")}.`); + } + + if (contract.opportunities.length > 0) { + actions.push("Показать opportunity map как основной UX: спрос, риск, confidence, следующий evidence."); + } + + actions.push( + contract.siteMaturity.phase === "bootstrap_indexing" + ? "Собрать bootstrap/indexing roadmap: do-now long-tail, prepare mid-tail, defer head terms до traction/authority signals." + : `Держать strategy roadmap в фазе ${contract.siteMaturity.phase}: повышать targets только после promotion signals.` + ); + + if (contract.evidence.yandex.state === "not_collected") { + actions.push("Запустить Yandex evidence collection: Wordstat regions/dynamics + SERP competitors по top фразам."); + } else if (contract.evidence.serpInterpretation.state !== "ready" && contract.evidence.yandex.serpDocCount > 0) { + actions.push("Отдать Yandex SERP evidence в model task для interpretation: intent fit, competitor gaps, page type distribution."); + } else if (contract.evidence.serpInterpretation.state === "ready") { + actions.push("Использовать SERP interpretation как основу для strategy synthesis: target-fit, mismatch, competitor angles."); + } + + actions.push("Не переходить к изменениям сайта, пока seo-strategy.v1 не получит external evidence и human strategy approval."); + + return actions; +} + +export async function getSeoStrategyContract(projectId: string): Promise<SeoStrategyContract> { + const [contextReview, market, cleaning, keywordMap, yandexEvidence, latestSerpInterpretation] = await Promise.all([ + getLatestSeoContextReview(projectId), + getMarketEnrichmentContract(projectId), + getKeywordCleaningContract(projectId), + getKeywordMapContract(projectId), + getLatestYandexEvidenceContract(projectId), + getLatestSerpInterpretationContract(projectId) + ]); + const serpInterpretation = + latestSerpInterpretation.state === "ready" && latestSerpInterpretation.yandexEvidenceRunId === yandexEvidence.runId + ? latestSerpInterpretation + : getEmptySerpInterpretationContract(projectId); + const context = buildContextEvidence(contextReview); + const contextReady = context.status === "ready"; + const demand = buildDemandEvidence(market, cleaning, keywordMap); + const siteMaturity = buildSiteMaturity({ keywordMap, market, serpInterpretation, yandexEvidence }); + const phasePlan = buildPhasePlan(siteMaturity); + const opportunities = [ + ...buildOpportunities(cleaning, keywordMap, siteMaturity), + ...buildSerpOpportunities(serpInterpretation, siteMaturity) + ]; + const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation); + const risks = buildRisks({ + cleaning, + contextReview, + keywordMap, + market, + missingEvidence, + opportunities, + serpInterpretation, + siteMaturity, + yandexEvidence + }); + const strategyConfidenceScore = getStrategyScore({ + contextReady, + keywordMap, + market, + missingEvidence, + opportunities, + serpInterpretation, + yandexEvidence + }); + const externalProviderConnectedCount = + market.providers.filter((provider) => provider.status === "connected").length + + (yandexEvidence.summary.serpDocCount > 0 ? 1 : 0) + + (serpInterpretation.state === "ready" ? 1 : 0); + const contractWithoutActions: Omit<SeoStrategyContract, "nextActions"> = { + schemaVersion: "seo-strategy.v1", + projectId, + semanticRunId: market.semanticRunId, + projectOntologyVersion: market.projectOntologyVersion, + generatedAt: new Date().toISOString(), + state: getState(strategyConfidenceScore, keywordMap, missingEvidence), + verdict: { + confidence: getConfidence(strategyConfidenceScore), + primaryConstraint: + missingEvidence[0]?.title ?? "Нужен human strategy review перед любыми изменениями сайта.", + summary: + opportunities.length > 0 + ? `Есть ${opportunities.length} opportunity groups, но стратегия ещё зависит от внешних evidence и проверки рисков.` + : "Пока нет достаточной opportunity map: нужно усилить market evidence и очистку фраз.", + title: + strategyConfidenceScore >= 75 + ? "Стратегия почти готова к review" + : strategyConfidenceScore >= 45 + ? "Есть рабочая аналитическая база, но не хватает evidence" + : "Стратегия пока не доказана" + }, + readiness: { + approvedKeywordCount: keywordMap.persisted.approvedDecisionCount, + contextReady, + evidenceGapCount: missingEvidence.length, + externalProviderConnectedCount, + marketEvidenceCount: keywordMap.readiness.marketEvidenceCount, + opportunityCount: opportunities.length, + riskCount: risks.length, + strategyConfidenceScore, + trashFilteredCount: cleaning.readiness.trashCount + }, + siteMaturity, + phasePlan, + evidence: { + context, + demand, + providers: mapProviders(market.providers), + yandex: buildYandexEvidenceSummary(yandexEvidence), + serpInterpretation: buildSerpInterpretationSummary(serpInterpretation) + }, + opportunities, + risks, + strategyOptions: buildStrategyOptions({ missingEvidence, opportunities, risks, siteMaturity }), + missingEvidence + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} diff --git a/seo_mode/seo_mode/server/src/strategy/strategyQualityReview.ts b/seo_mode/seo_mode/server/src/strategy/strategyQualityReview.ts new file mode 100644 index 0000000..bc3014b --- /dev/null +++ b/seo_mode/seo_mode/server/src/strategy/strategyQualityReview.ts @@ -0,0 +1,609 @@ +import { pool } from "../db/client.js"; +import { getLatestSerpInterpretationContract, type SerpInterpretationContract } from "../evidence/serpInterpretation.js"; +import { getSeoStrategyContract, type SeoStrategyContract } from "./seoStrategy.js"; +import { getLatestStrategySynthesisContract, type StrategySynthesisContract } from "./strategySynthesis.js"; +import { buildStrategyQualityReviewModelTask } from "./strategyQualityReviewTask.js"; + +type RunStatus = "cancelled" | "done" | "failed" | "queued" | "running"; +type StrategyQualityReviewProviderMode = "codex_manual" | "deterministic_fallback"; +type StrategyQualityReviewState = "not_ready" | "ready"; +type StrategyQualityReviewVerdictStatus = "approved" | "blocked" | "needs_changes"; +type StrategyQualityReviewCheckStatus = "fail" | "pass" | "warning"; +type StrategyQualityReviewSeverity = "critical" | "high" | "low" | "medium"; +type StrategyQualityReviewCategory = + | "business_drift" + | "evidence_integrity" + | "hallucination_guardrail" + | "phase_guardrail" + | "source_safety" + | "ux_clarity"; + +type StrategyQualityReviewRunRow = { + id: string; + status: RunStatus; + input: Record<string, unknown>; + output: StrategyQualityReviewContract | null; + error_message: string | null; + started_at: Date | null; + completed_at: Date | null; + created_at: Date; +}; + +export type StrategyQualityReviewContract = { + schemaVersion: "seo-strategy-quality-review.v1"; + projectId: string; + semanticRunId: string | null; + runId: string | null; + generatedAt: string; + state: StrategyQualityReviewState; + provider: { + mode: StrategyQualityReviewProviderMode; + modelRequired: true; + message: string; + }; + sourceTaskId: string | null; + sourceStrategyGeneratedAt: string | null; + sourceStrategySynthesisRunId: string | null; + sourceSerpInterpretationRunId: string | null; + analystReview: { + status: "approved" | "needs_changes" | "not_reviewed" | "rejected"; + reviewer: "codex" | "human" | "system"; + reviewedAt: string | null; + notes: string[]; + }; + verdict: { + status: StrategyQualityReviewVerdictStatus; + title: string; + summary: string; + confidence: "high" | "low" | "medium"; + score: number; + primaryBlocker: string | null; + }; + readiness: { + score: number; + approvedSignalCount: number; + blockerCount: number; + checkCount: number; + evidenceRefIssueCount: number; + hallucinationRiskCount: number; + issueCount: number; + passedCount: number; + phaseGuardrailIssueCount: number; + warningCount: number; + uxIssueCount: number; + }; + checks: Array<{ + id: string; + category: StrategyQualityReviewCategory; + status: StrategyQualityReviewCheckStatus; + severity: StrategyQualityReviewSeverity; + title: string; + message: string; + action: string; + evidenceRefs: string[]; + }>; + blockers: string[]; + warnings: string[]; + approvedSignals: string[]; + summary: string; + nextActions: string[]; +}; + +export type StrategyQualityReviewRun = StrategyQualityReviewContract & { + runId: string; + status: RunStatus; + input: Record<string, unknown>; + startedAt: string | null; + completedAt: string | null; + errorMessage: string | null; + createdAt: string; +}; + +function toIsoDate(value: Date | null) { + return value ? value.toISOString() : null; +} + +function getDefaultAnalystReview(): StrategyQualityReviewContract["analystReview"] { + return { + notes: [], + reviewedAt: null, + reviewer: "system", + status: "not_reviewed" + }; +} + +function mapStrategyQualityReviewRun(row: StrategyQualityReviewRunRow): StrategyQualityReviewRun | null { + if (!row.output) { + return null; + } + + return { + ...row.output, + analystReview: row.output.analystReview ?? getDefaultAnalystReview(), + createdAt: row.created_at.toISOString(), + errorMessage: row.error_message, + input: row.input, + runId: row.id, + startedAt: toIsoDate(row.started_at), + status: row.status, + completedAt: toIsoDate(row.completed_at) + }; +} + +export function getEmptyStrategyQualityReviewContract(projectId: string): StrategyQualityReviewContract { + return { + analystReview: getDefaultAnalystReview(), + approvedSignals: [], + blockers: ["Strategy quality review ещё не собран."], + checks: [], + generatedAt: new Date().toISOString(), + nextActions: ["Собрать seo-strategy-synthesis.v1, затем запустить quality review."], + projectId, + provider: { + message: "Strategy quality review не готов: нет проверенного synthesis.", + mode: "deterministic_fallback", + modelRequired: true + }, + readiness: { + approvedSignalCount: 0, + blockerCount: 1, + checkCount: 0, + evidenceRefIssueCount: 0, + hallucinationRiskCount: 0, + issueCount: 1, + passedCount: 0, + phaseGuardrailIssueCount: 0, + score: 0, + uxIssueCount: 0, + warningCount: 0 + }, + runId: null, + schemaVersion: "seo-strategy-quality-review.v1", + semanticRunId: null, + sourceSerpInterpretationRunId: null, + sourceStrategyGeneratedAt: null, + sourceStrategySynthesisRunId: null, + sourceTaskId: null, + state: "not_ready", + summary: "Strategy quality review ещё не собран.", + verdict: { + confidence: "low", + primaryBlocker: "Нет quality review.", + score: 0, + status: "blocked", + summary: "Нельзя доверять стратегии без отдельного quality gate.", + title: "Quality gate не готов" + }, + warnings: [] + }; +} + +function makeCheck(input: { + id: string; + category: StrategyQualityReviewCategory; + status: StrategyQualityReviewCheckStatus; + severity: StrategyQualityReviewSeverity; + title: string; + message: string; + action: string; + evidenceRefs?: string[]; +}): StrategyQualityReviewContract["checks"][number] { + return { + evidenceRefs: input.evidenceRefs ?? [], + ...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 optionIssues = synthesis.decisionOptions.filter( + (option) => option.evidenceRefs.length === 0 && option.scenario !== "bootstrap_runway" + ); + + return { + count: opportunityIssues.length + narrativeIssues.length + optionIssues.length, + sample: [...opportunityIssues.map((item) => item.title), ...narrativeIssues.map((item) => item.title), ...optionIssues.map((item) => item.title)].slice(0, 5) + }; +} + +function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesisContract): StrategyQualityReviewContract["checks"] { + const immediateHeadTargets = strategy.opportunities.filter( + (opportunity) => + strategy.siteMaturity.phase === "bootstrap_indexing" && + opportunity.timing.action === "do_now" && + (opportunity.competitionTier === "head" || opportunity.competitionTier === "mid") + ); + const deferredHeadTargets = strategy.opportunities.filter( + (opportunity) => opportunity.timing.action === "defer" && opportunity.competitionTier === "head" + ); + const evidenceRefIssues = getEvidenceRefIssues(strategy, synthesis); + const missingHeadHold = !synthesis.blockedActions.some((action) => action.toLocaleLowerCase("ru-RU").includes("head")); + const hasApplyLeak = [...synthesis.recommendedPath.doNow, ...synthesis.nextActions].some((action) => + /запустить rewrite|запустить apply|apply changes|source mutation|создать правк|применить/i.test(action) + ); + const hasExpansionLeak = synthesis.decisionOptions.some( + (option) => option.scenario === "serp_expansion" && option.confidence !== "low" && strategy.siteMaturity.phase === "bootstrap_indexing" + ); + const hasPhaseDecision = synthesis.phaseDecision.currentPhase === strategy.siteMaturity.phase && synthesis.phaseDecision.hold.length > 0; + const hasReadableNextStep = synthesis.recommendedPath.doNow.length > 0 && synthesis.recommendedPath.hold.length > 0; + + return [ + makeCheck({ + action: + immediateHeadTargets.length > 0 + ? "Пересобрать strategy/synthesis: head/mid targets должны уйти в defer/prepare до maturity signals." + : "Сохранить phase-aware timing как hard guardrail.", + category: "phase_guardrail", + id: "phase:no_immediate_head_terms", + message: + immediateHeadTargets.length > 0 + ? `${immediateHeadTargets.length} competitive targets ошибочно попали в immediate action.` + : `${deferredHeadTargets.length} head targets корректно отложены до поздней фазы.`, + severity: immediateHeadTargets.length > 0 ? "critical" : "low", + status: immediateHeadTargets.length > 0 ? "fail" : "pass", + title: "Zero-site/head terms guardrail" + }), + makeCheck({ + action: hasPhaseDecision ? "Оставить phaseDecision обязательной частью strategy synthesis." : "Пересобрать synthesis с phaseDecision.", + category: "phase_guardrail", + id: "phase:decision_present", + message: hasPhaseDecision + ? "Synthesis содержит phaseDecision, current phase и hold-ограничения." + : "Synthesis не отражает текущую maturity phase или не содержит hold-ограничения.", + severity: hasPhaseDecision ? "low" : "high", + status: hasPhaseDecision ? "pass" : "fail", + title: "Phase decision completeness" + }), + makeCheck({ + action: + evidenceRefIssues.count > 0 + ? `Добавить evidence refs или пометить как data gap: ${evidenceRefIssues.sample.join("; ")}.` + : "Продолжать требовать refs для каждого claim.", + category: "evidence_integrity", + id: "evidence:refs_present", + message: + evidenceRefIssues.count > 0 + ? `${evidenceRefIssues.count} claims/options не имеют evidence refs.` + : "Opportunities, evidence narrative и options имеют evidence refs или допустимый bootstrap exception.", + severity: evidenceRefIssues.count > 0 ? "high" : "low", + status: evidenceRefIssues.count > 0 ? "fail" : "pass", + title: "Evidence refs" + }), + makeCheck({ + action: strategy.siteMaturity.signals.hasAuthorityEvidence + ? "Authority evidence можно использовать в будущих head-term checks." + : "Не утверждать authority/head-term readiness без Webmaster/Метрики/Search Console-style данных.", + category: "hallucination_guardrail", + id: "hallucination:no_authority_claim", + message: strategy.siteMaturity.signals.hasAuthorityEvidence + ? "Authority evidence присутствует." + : "Authority evidence отсутствует, стратегия должна явно держать head terms в hold/defer.", + severity: strategy.siteMaturity.signals.hasAuthorityEvidence || !missingHeadHold ? "low" : "high", + status: strategy.siteMaturity.signals.hasAuthorityEvidence || !missingHeadHold ? "pass" : "fail", + title: "No invented authority" + }), + makeCheck({ + action: hasExpansionLeak + ? "Понизить confidence у expansion option или вынести её в separate user-approved branch." + : "Оставить expansion только как low-confidence/future branch.", + category: "business_drift", + id: "business:no_core_expansion_leak", + message: hasExpansionLeak + ? "Expansion option выглядит как core route для bootstrap сайта." + : "Business expansion не смешан с immediate core SEO route.", + severity: hasExpansionLeak ? "high" : "low", + status: hasExpansionLeak ? "fail" : "pass", + title: "Business drift guardrail" + }), + makeCheck({ + action: hasApplyLeak + ? "Убрать rewrite/apply/source wording из immediate actions quality stage." + : "Продолжать держать quality review read-only.", + category: "source_safety", + id: "source:no_mutation_route", + message: hasApplyLeak + ? "В doNow/nextActions есть ранний намёк на rewrite/apply/source mutation." + : "Quality review не открывает rewrite/apply/source mutation.", + severity: hasApplyLeak ? "critical" : "low", + status: hasApplyLeak ? "fail" : "pass", + title: "No site mutation" + }), + makeCheck({ + action: hasReadableNextStep + ? "Показывать пользователю do-now/hold/next evidence вместо сырых таблиц." + : "Пересобрать synthesis: нужен понятный doNow/hold для пользователя.", + category: "ux_clarity", + id: "ux:decision_surface", + message: hasReadableNextStep + ? "Recommended path содержит doNow и hold; это можно показывать как decision surface." + : "Recommended path не объясняет пользователю, что делать сейчас и что запрещено.", + severity: hasReadableNextStep ? "low" : "medium", + status: hasReadableNextStep ? "pass" : "warning", + title: "Human-readable decision surface" + }) + ]; +} + +function getScore(checks: StrategyQualityReviewContract["checks"]) { + let score = 100; + + for (const check of checks) { + if (check.status === "pass") { + continue; + } + + if (check.status === "warning") { + score -= check.severity === "medium" ? 10 : 5; + continue; + } + + if (check.severity === "critical") { + score -= 40; + } else if (check.severity === "high") { + score -= 25; + } else if (check.severity === "medium") { + score -= 15; + } else { + score -= 8; + } + } + + return Math.max(0, Math.min(100, score)); +} + +function getVerdict( + checks: StrategyQualityReviewContract["checks"], + score: number +): StrategyQualityReviewContract["verdict"] { + const failedChecks = checks.filter((check) => check.status === "fail"); + const criticalBlocker = failedChecks.find((check) => check.severity === "critical" || check.severity === "high"); + + if (criticalBlocker) { + return { + confidence: "high", + primaryBlocker: criticalBlocker.title, + score, + status: "blocked", + summary: `Strategy quality blocked: ${criticalBlocker.message}`, + title: "Стратегия заблокирована quality gate" + }; + } + + if (failedChecks.length > 0 || score < 80) { + return { + confidence: "medium", + primaryBlocker: failedChecks[0]?.title ?? "Есть warnings до approval.", + score, + status: "needs_changes", + summary: "Стратегия понятна, но требует исправлений перед human approval.", + title: "Нужны правки strategy quality" + }; + } + + return { + confidence: "high", + primaryBlocker: null, + score, + status: "approved", + summary: "Strategy quality gate пройден как read-only analytics layer. Это не разрешение на rewrite/apply.", + title: "Стратегия готова к human review" + }; +} + +function buildNextActions(contract: Omit<StrategyQualityReviewContract, "nextActions">) { + const actions: string[] = []; + + if (contract.verdict.status === "approved") { + actions.push("Отдать strategy quality result в human review: подтвердить phased roadmap и blocked actions."); + actions.push("Следующий аналитический слой: добрать external Webmaster/Метрика/Search Console-style evidence после деплоя."); + } else { + actions.push(`Закрыть blockers: ${contract.blockers.slice(0, 3).join("; ")}.`); + actions.push("Пересобрать seo-strategy.v1 / seo-strategy-synthesis.v1 после исправлений."); + } + + actions.push("Rewrite/apply остаётся заблокированным: quality review проверяет стратегию, а не генерирует правки сайта."); + + return actions; +} + +function buildStrategyQualityReviewOutput( + projectId: string, + strategy: SeoStrategyContract, + synthesis: StrategySynthesisContract, + serpInterpretation: SerpInterpretationContract +): StrategyQualityReviewContract | null { + const checks = buildChecks(strategy, synthesis); + const task = buildStrategyQualityReviewModelTask(projectId, strategy, synthesis, serpInterpretation); + + if (!task) { + return null; + } + + const score = getScore(checks); + const verdict = getVerdict(checks, score); + const blockers = checks.filter((check) => check.status === "fail").map((check) => `${check.title}: ${check.action}`); + const warnings = checks.filter((check) => check.status === "warning").map((check) => `${check.title}: ${check.action}`); + const approvedSignals = checks.filter((check) => check.status === "pass").map((check) => check.title); + const contractWithoutActions: Omit<StrategyQualityReviewContract, "nextActions"> = { + analystReview: getDefaultAnalystReview(), + approvedSignals, + blockers, + checks, + generatedAt: new Date().toISOString(), + projectId, + provider: { + message: + "Strategy quality review сохранён deterministic fallback-ом. Следующий слой может заменить judgement ModelProvider-ом, сохранив этот JSON contract.", + mode: "deterministic_fallback", + modelRequired: true + }, + readiness: { + approvedSignalCount: approvedSignals.length, + blockerCount: blockers.length, + checkCount: checks.length, + evidenceRefIssueCount: checks.filter((check) => check.category === "evidence_integrity" && check.status !== "pass").length, + hallucinationRiskCount: checks.filter((check) => check.category === "hallucination_guardrail" && check.status !== "pass").length, + issueCount: blockers.length + warnings.length, + passedCount: approvedSignals.length, + phaseGuardrailIssueCount: checks.filter((check) => check.category === "phase_guardrail" && check.status !== "pass").length, + score, + uxIssueCount: checks.filter((check) => check.category === "ux_clarity" && check.status !== "pass").length, + warningCount: warnings.length + }, + runId: null, + schemaVersion: "seo-strategy-quality-review.v1", + semanticRunId: strategy.semanticRunId, + sourceSerpInterpretationRunId: synthesis.sourceSerpInterpretationRunId, + sourceStrategyGeneratedAt: strategy.generatedAt, + sourceStrategySynthesisRunId: synthesis.runId, + sourceTaskId: task.taskId, + state: "ready", + summary: `${verdict.status}: ${checks.length} checks, ${approvedSignals.length} passed, ${blockers.length} blockers, ${warnings.length} warnings.`, + verdict, + warnings + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} + +function normalizeOutput( + projectId: string, + output: StrategyQualityReviewContract, + providerMode: StrategyQualityReviewProviderMode, + providerMessage: string +): StrategyQualityReviewContract { + if (output.schemaVersion !== "seo-strategy-quality-review.v1") { + throw new Error("Strategy quality review output schemaVersion должен быть seo-strategy-quality-review.v1."); + } + + if (output.projectId !== projectId) { + throw new Error("Strategy quality review output projectId не совпадает с проектом."); + } + + if (!output.verdict?.status || !Array.isArray(output.checks)) { + throw new Error("Strategy quality review output должен иметь verdict и checks."); + } + + const blockers = output.checks.filter((check) => check.status === "fail").map((check) => `${check.title}: ${check.action}`); + 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); + + return { + ...output, + analystReview: output.analystReview ?? getDefaultAnalystReview(), + approvedSignals, + blockers, + generatedAt: new Date().toISOString(), + provider: { + message: providerMessage, + mode: providerMode, + modelRequired: true + }, + readiness: { + approvedSignalCount: approvedSignals.length, + blockerCount: blockers.length, + checkCount: output.checks.length, + evidenceRefIssueCount: output.checks.filter((check) => check.category === "evidence_integrity" && check.status !== "pass").length, + hallucinationRiskCount: output.checks.filter((check) => check.category === "hallucination_guardrail" && check.status !== "pass").length, + issueCount: blockers.length + warnings.length, + passedCount: approvedSignals.length, + phaseGuardrailIssueCount: output.checks.filter((check) => check.category === "phase_guardrail" && check.status !== "pass").length, + score: output.verdict.score, + uxIssueCount: output.checks.filter((check) => check.category === "ux_clarity" && check.status !== "pass").length, + warningCount: warnings.length + }, + state: output.checks.length > 0 ? "ready" : "not_ready", + warnings + }; +} + +export async function getLatestStrategyQualityReviewContract(projectId: string): Promise<StrategyQualityReviewContract> { + const result = await pool.query<StrategyQualityReviewRunRow>( + ` + 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' + order by created_at desc + limit 1; + `, + [projectId] + ); + const run = result.rows[0] ? mapStrategyQualityReviewRun(result.rows[0]) : null; + + return run ?? getEmptyStrategyQualityReviewContract(projectId); +} + +export async function runStrategyQualityReviewDraft(projectId: string): Promise<StrategyQualityReviewRun | null> { + const [strategy, synthesis, serpInterpretation] = await Promise.all([ + getSeoStrategyContract(projectId), + getLatestStrategySynthesisContract(projectId), + getLatestSerpInterpretationContract(projectId) + ]); + const task = buildStrategyQualityReviewModelTask(projectId, strategy, synthesis, serpInterpretation); + const output = task ? buildStrategyQualityReviewOutput(projectId, strategy, synthesis, serpInterpretation) : null; + + if (!task || !output) { + return null; + } + + const result = await pool.query<StrategyQualityReviewRunRow>( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_strategy_quality_review', 'done', $2::jsonb, $3::jsonb, now(), now()) + returning id, status, input, output, error_message, started_at, completed_at, created_at; + `, + [ + projectId, + { + schemaVersion: "seo-strategy-quality-review-run-input.v1", + sourceStrategyGeneratedAt: strategy.generatedAt, + sourceStrategySynthesisRunId: synthesis.runId, + sourceSerpInterpretationRunId: synthesis.sourceSerpInterpretationRunId, + taskType: "seo.strategy_quality_review" + }, + output + ] + ); + + return result.rows[0] ? mapStrategyQualityReviewRun(result.rows[0]) : null; +} + +export async function saveStrategyQualityReviewManual( + projectId: string, + output: StrategyQualityReviewContract +): Promise<StrategyQualityReviewRun> { + const normalizedOutput = normalizeOutput( + projectId, + output, + "codex_manual", + "Codex manual dev-loop: текущий Codex-сеанс выполнил seo.strategy_quality_review task по правилам, без внешнего provider adapter." + ); + const result = await pool.query<StrategyQualityReviewRunRow>( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_strategy_quality_review', 'done', $2::jsonb, $3::jsonb, now(), now()) + returning id, status, input, output, error_message, started_at, completed_at, created_at; + `, + [ + projectId, + { + schemaVersion: "seo-strategy-quality-review-manual-input.v1", + sourceStrategyGeneratedAt: normalizedOutput.sourceStrategyGeneratedAt, + sourceStrategySynthesisRunId: normalizedOutput.sourceStrategySynthesisRunId, + taskType: "seo.strategy_quality_review" + }, + normalizedOutput + ] + ); + const run = result.rows[0] ? mapStrategyQualityReviewRun(result.rows[0]) : null; + + if (!run) { + throw new Error("Не удалось сохранить manual strategy quality review."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/strategy/strategyQualityReviewTask.ts b/seo_mode/seo_mode/server/src/strategy/strategyQualityReviewTask.ts new file mode 100644 index 0000000..900dce8 --- /dev/null +++ b/seo_mode/seo_mode/server/src/strategy/strategyQualityReviewTask.ts @@ -0,0 +1,175 @@ +import type { SerpInterpretationContract } from "../evidence/serpInterpretation.js"; +import type { SeoStrategyContract } from "./seoStrategy.js"; +import type { StrategySynthesisContract } from "./strategySynthesis.js"; + +export type StrategyQualityReviewModelTaskContract = { + taskType: "seo.strategy_quality_review"; + schemaVersion: "seo-strategy-quality-review-task.v1"; + taskId: string; + projectId: string; + semanticRunId: string | null; + sourceStrategyGeneratedAt: string; + sourceStrategySynthesisRunId: string | null; + sourceSerpInterpretationRunId: string | null; + providerMode: "model_provider_contract"; + activeSkillId: "seo.strategy_quality_review"; + input: { + strategy: { + schemaVersion: "seo-strategy.v1"; + state: SeoStrategyContract["state"]; + confidence: SeoStrategyContract["verdict"]["confidence"]; + score: number; + primaryConstraint: string; + siteMaturity: SeoStrategyContract["siteMaturity"]; + phasePlan: SeoStrategyContract["phasePlan"]; + topOpportunities: SeoStrategyContract["opportunities"]; + risks: SeoStrategyContract["risks"]; + missingEvidence: SeoStrategyContract["missingEvidence"]; + nextActions: string[]; + }; + strategySynthesis: { + schemaVersion: "seo-strategy-synthesis.v1"; + state: StrategySynthesisContract["state"]; + runId: string | null; + phaseDecision: StrategySynthesisContract["phaseDecision"]; + recommendedPath: StrategySynthesisContract["recommendedPath"]; + decisionOptions: StrategySynthesisContract["decisionOptions"]; + evidenceNarrative: StrategySynthesisContract["evidenceNarrative"]; + riskPosture: StrategySynthesisContract["riskPosture"]; + dataGaps: StrategySynthesisContract["dataGaps"]; + blockedActions: string[]; + nextActions: string[]; + }; + serpInterpretation: { + state: SerpInterpretationContract["state"]; + runId: string | null; + mismatchCount: number; + competitorGapCount: number; + riskCount: number; + }; + }; + allowedActions: Array< + | "approve_strategy_quality" + | "block_strategy_quality" + | "explain_quality_risks" + | "flag_business_drift" + | "flag_evidence_gaps" + | "flag_hallucination_risk" + | "flag_phase_violation" + | "request_more_evidence" + >; + outputSchema: { + schemaVersion: "seo-strategy-quality-review.v1"; + requiredTopLevelKeys: string[]; + checkRequiredKeys: string[]; + }; + guardrails: { + noRewriteOrApply: boolean; + noInventedDemand: boolean; + noImmediateHeadTermsForZeroSite: boolean; + noBusinessExpansionWithoutBranch: boolean; + requireEvidenceRefs: boolean; + qualityGateOnly: boolean; + }; + stopConditions: string[]; +}; + +export function buildStrategyQualityReviewModelTask( + projectId: string, + strategy: SeoStrategyContract, + strategySynthesis: StrategySynthesisContract, + serpInterpretation: SerpInterpretationContract +): StrategyQualityReviewModelTaskContract | null { + if (strategySynthesis.state !== "ready" || strategySynthesis.decisionOptions.length === 0) { + return null; + } + + return { + activeSkillId: "seo.strategy_quality_review", + allowedActions: [ + "approve_strategy_quality", + "block_strategy_quality", + "explain_quality_risks", + "flag_business_drift", + "flag_evidence_gaps", + "flag_hallucination_risk", + "flag_phase_violation", + "request_more_evidence" + ], + guardrails: { + noBusinessExpansionWithoutBranch: true, + noImmediateHeadTermsForZeroSite: true, + noInventedDemand: true, + noRewriteOrApply: true, + qualityGateOnly: true, + requireEvidenceRefs: true + }, + input: { + serpInterpretation: { + competitorGapCount: serpInterpretation.readiness.competitorGapCount, + mismatchCount: serpInterpretation.readiness.mismatchCount, + riskCount: serpInterpretation.readiness.riskCount, + runId: serpInterpretation.runId, + state: serpInterpretation.state + }, + strategy: { + confidence: strategy.verdict.confidence, + missingEvidence: strategy.missingEvidence.slice(0, 12), + nextActions: strategy.nextActions.slice(0, 10), + phasePlan: strategy.phasePlan, + primaryConstraint: strategy.verdict.primaryConstraint, + risks: strategy.risks.slice(0, 12), + schemaVersion: "seo-strategy.v1", + score: strategy.readiness.strategyConfidenceScore, + siteMaturity: strategy.siteMaturity, + state: strategy.state, + topOpportunities: strategy.opportunities.slice(0, 12) + }, + strategySynthesis: { + blockedActions: strategySynthesis.blockedActions, + dataGaps: strategySynthesis.dataGaps.slice(0, 10), + decisionOptions: strategySynthesis.decisionOptions.slice(0, 8), + evidenceNarrative: strategySynthesis.evidenceNarrative.slice(0, 10), + nextActions: strategySynthesis.nextActions.slice(0, 10), + phaseDecision: strategySynthesis.phaseDecision, + recommendedPath: strategySynthesis.recommendedPath, + riskPosture: strategySynthesis.riskPosture.slice(0, 10), + runId: strategySynthesis.runId, + schemaVersion: "seo-strategy-synthesis.v1", + state: strategySynthesis.state + } + }, + outputSchema: { + checkRequiredKeys: ["id", "category", "status", "severity", "title", "message", "action"], + requiredTopLevelKeys: [ + "schemaVersion", + "projectId", + "sourceStrategySynthesisRunId", + "verdict", + "readiness", + "checks", + "blockers", + "warnings", + "approvedSignals", + "nextActions" + ], + schemaVersion: "seo-strategy-quality-review.v1" + }, + projectId, + providerMode: "model_provider_contract", + schemaVersion: "seo-strategy-quality-review-task.v1", + semanticRunId: strategy.semanticRunId, + sourceSerpInterpretationRunId: serpInterpretation.runId, + sourceStrategyGeneratedAt: strategy.generatedAt, + sourceStrategySynthesisRunId: strategySynthesis.runId, + stopConditions: [ + "Нет ready seo-strategy-synthesis.v1: quality review не проверяет сырую стратегию без synthesis.", + "Strategy предлагает immediate head/top keywords для bootstrap/indexing сайта: вернуть blocked.", + "В strategy/synthesis найдены claims без evidence refs: вернуть needs_changes или blocked.", + "Запрошены 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`, + 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 new file mode 100644 index 0000000..a066e47 --- /dev/null +++ b/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts @@ -0,0 +1,615 @@ +import { pool } from "../db/client.js"; +import { getLatestSerpInterpretationContract, type SerpInterpretationContract } from "../evidence/serpInterpretation.js"; +import { getSeoStrategyContract, type SeoStrategyContract } from "./seoStrategy.js"; +import { buildStrategySynthesisModelTask } from "./strategySynthesisTask.js"; + +type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; +type StrategySynthesisProviderMode = "codex_manual" | "deterministic_fallback"; +type StrategySynthesisState = "not_ready" | "ready"; +type StrategySynthesisConfidence = "high" | "low" | "medium"; +type StrategySynthesisPriority = "high" | "low" | "medium"; + +type StrategySynthesisRunRow = { + id: string; + status: RunStatus; + input: Record<string, unknown>; + output: StrategySynthesisContract | null; + error_message: string | null; + started_at: Date | null; + completed_at: Date | null; + created_at: Date; +}; + +export type StrategySynthesisContract = { + schemaVersion: "seo-strategy-synthesis.v1"; + projectId: string; + semanticRunId: string | null; + runId: string | null; + generatedAt: string; + state: StrategySynthesisState; + provider: { + mode: StrategySynthesisProviderMode; + modelRequired: true; + message: string; + }; + sourceTaskId: string | null; + sourceStrategyGeneratedAt: string | null; + sourceSerpInterpretationRunId: string | null; + analystReview: { + status: "approved" | "needs_changes" | "not_reviewed" | "rejected"; + reviewer: "codex" | "human" | "system"; + reviewedAt: string | null; + notes: string[]; + }; + readiness: { + confidenceScore: number; + dataGapCount: number; + decisionOptionCount: number; + evidenceNarrativeCount: number; + nextActionCount: number; + riskCount: number; + }; + executiveSummary: { + title: string; + verdict: string; + confidence: StrategySynthesisConfidence; + whyNow: string; + primaryConstraint: string; + }; + phaseDecision: { + schemaVersion: "seo-phase-decision.v1"; + currentPhase: SeoStrategyContract["siteMaturity"]["phase"]; + title: string; + summary: string; + allowedNow: string[]; + prepareNext: string[]; + hold: string[]; + promotionSignals: string[]; + phaseRoadmap: SeoStrategyContract["phasePlan"]; + }; + recommendedPath: { + id: string; + title: string; + priority: StrategySynthesisPriority; + expectedImpact: StrategySynthesisPriority; + confidence: StrategySynthesisConfidence; + rationale: string; + doNow: string[]; + hold: string[]; + needsEvidence: string[]; + }; + decisionOptions: Array<{ + id: string; + title: string; + scenario: "analytics_first" | "bootstrap_runway" | "core_growth" | "risk_reduction" | "serp_expansion"; + priority: StrategySynthesisPriority; + expectedImpact: StrategySynthesisPriority; + effort: StrategySynthesisPriority; + confidence: StrategySynthesisConfidence; + rationale: string; + nextStep: string; + evidenceRefs: string[]; + risks: string[]; + }>; + evidenceNarrative: Array<{ + id: string; + title: string; + whatWeKnow: string; + whyItMatters: string; + confidence: StrategySynthesisConfidence; + evidenceRefs: string[]; + }>; + riskPosture: Array<{ + id: string; + level: "critical" | "high" | "medium"; + title: string; + message: string; + mitigation: string; + evidenceRefs: string[]; + }>; + dataGaps: Array<{ + id: string; + title: string; + priority: StrategySynthesisPriority; + owner: "ai_layer" | "external_service" | "product_review"; + whyItBlocks: string; + nextStep: string; + }>; + blockedActions: string[]; + summary: string; + nextActions: string[]; +}; + +export type StrategySynthesisRun = StrategySynthesisContract & { + runId: string; + status: RunStatus; + input: Record<string, unknown>; + startedAt: string | null; + completedAt: string | null; + errorMessage: string | null; + createdAt: string; +}; + +function toIsoDate(value: Date | null) { + return value ? value.toISOString() : null; +} + +function getDefaultPhaseDecision(): StrategySynthesisContract["phaseDecision"] { + return { + allowedNow: [], + currentPhase: "bootstrap_indexing", + hold: ["Не переходить к head terms без site maturity evidence."], + phaseRoadmap: [], + prepareNext: [], + promotionSignals: ["Собрать strategy и SERP interpretation."], + schemaVersion: "seo-phase-decision.v1", + summary: "Фазовый roadmap ещё не собран.", + title: "SEO runway не готов" + }; +} + +function getDefaultAnalystReview(): StrategySynthesisContract["analystReview"] { + return { + notes: [], + reviewedAt: null, + reviewer: "system", + status: "not_reviewed" + }; +} + +function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesisRun | null { + if (!row.output) { + return null; + } + + return { + ...row.output, + runId: row.id, + status: row.status, + input: row.input, + startedAt: toIsoDate(row.started_at), + completedAt: toIsoDate(row.completed_at), + errorMessage: row.error_message, + createdAt: row.created_at.toISOString(), + analystReview: row.output.analystReview ?? getDefaultAnalystReview(), + phaseDecision: row.output.phaseDecision ?? getDefaultPhaseDecision() + }; + } + +export function getEmptyStrategySynthesisContract(projectId: string): StrategySynthesisContract { + return { + analystReview: getDefaultAnalystReview(), + blockedActions: ["rewrite/apply заблокирован до strategy synthesis, quality review и human approval."], + dataGaps: [], + decisionOptions: [], + evidenceNarrative: [], + executiveSummary: { + confidence: "low", + primaryConstraint: "Нет готового strategy synthesis.", + title: "Strategy synthesis ещё не собран", + verdict: "Недостаточно аналитического вывода поверх evidence.", + whyNow: "Сначала нужен seo-strategy.v1 и SERP interpretation." + }, + phaseDecision: getDefaultPhaseDecision(), + generatedAt: new Date().toISOString(), + nextActions: ["Собрать seo-strategy.v1 и SERP interpretation, затем запустить strategy synthesis."], + projectId, + provider: { + message: "Strategy synthesis не готов: нет полного evidence stack.", + mode: "deterministic_fallback", + modelRequired: true + }, + readiness: { + confidenceScore: 0, + dataGapCount: 0, + decisionOptionCount: 0, + evidenceNarrativeCount: 0, + nextActionCount: 1, + riskCount: 0 + }, + recommendedPath: { + confidence: "low", + doNow: [], + expectedImpact: "low", + hold: ["Не переходить к изменениям сайта."], + id: "path:not_ready", + needsEvidence: ["strategy", "serp interpretation"], + priority: "low", + rationale: "Нет готового аналитического вывода.", + title: "Сначала собрать аналитику" + }, + riskPosture: [], + runId: null, + schemaVersion: "seo-strategy-synthesis.v1", + semanticRunId: null, + sourceSerpInterpretationRunId: null, + sourceStrategyGeneratedAt: null, + sourceTaskId: null, + state: "not_ready", + summary: "Strategy synthesis ещё не собран." + }; +} + +function normalizeOutput( + projectId: string, + output: StrategySynthesisContract, + providerMode: StrategySynthesisProviderMode, + providerMessage: string +): StrategySynthesisContract { + if (output.schemaVersion !== "seo-strategy-synthesis.v1") { + throw new Error("Strategy synthesis output schemaVersion должен быть seo-strategy-synthesis.v1."); + } + + if (output.projectId !== projectId) { + throw new Error("Strategy synthesis output projectId не совпадает с проектом."); + } + + if (!output.sourceTaskId || !output.sourceStrategyGeneratedAt) { + throw new Error("Strategy synthesis output должен иметь sourceTaskId и sourceStrategyGeneratedAt."); + } + + if (!output.executiveSummary?.title || !output.phaseDecision?.title || !output.recommendedPath?.title || !output.summary) { + throw new Error("Strategy synthesis output должен иметь executiveSummary, phaseDecision, recommendedPath и summary."); + } + + if (!Array.isArray(output.decisionOptions) || !Array.isArray(output.evidenceNarrative)) { + throw new Error("Strategy synthesis output должен иметь decisionOptions и evidenceNarrative."); + } + + return { + ...output, + analystReview: output.analystReview ?? getDefaultAnalystReview(), + generatedAt: new Date().toISOString(), + provider: { + message: providerMessage, + mode: providerMode, + modelRequired: true + }, + readiness: { + confidenceScore: output.readiness.confidenceScore, + dataGapCount: output.dataGaps.length, + decisionOptionCount: output.decisionOptions.length, + evidenceNarrativeCount: output.evidenceNarrative.length, + nextActionCount: output.nextActions.length, + riskCount: output.riskPosture.length + }, + state: output.decisionOptions.length > 0 ? "ready" : "not_ready" + }; +} + +function getPriorityFromImpact(value: "high" | "low" | "medium"): StrategySynthesisPriority { + return value; +} + +function getScenario(optionId: string): StrategySynthesisContract["decisionOptions"][number]["scenario"] { + if (optionId.includes("bootstrap")) return "bootstrap_runway"; + if (optionId.includes("market_gap")) return "serp_expansion"; + if (optionId.includes("analytics")) return "analytics_first"; + if (optionId.includes("stabilize")) return "core_growth"; + return "risk_reduction"; +} + +function buildDecisionOptions(strategy: SeoStrategyContract): StrategySynthesisContract["decisionOptions"] { + return strategy.strategyOptions.slice(0, 5).map((option) => ({ + confidence: option.confidence, + effort: getPriorityFromImpact(option.effort), + evidenceRefs: option.requiredEvidence, + expectedImpact: getPriorityFromImpact(option.expectedImpact), + id: `synthesis:${option.id}`, + nextStep: + option.id === "option:bootstrap_runway" + ? "Собрать phased SEO runway: do-now long-tail, prepare mid-tail, defer head terms до traction/authority." + : option.id === "option:analytics_first" + ? "Закрыть data gaps и сделать результат понятным в UX до любых site changes." + : option.id === "option:market_gap_research" + ? "Проверить competitor angles и отделить core SEO от optional expansion branch." + : "Стабилизировать подтверждённое ядро и проверить оставшиеся риски.", + priority: option.expectedImpact === "high" ? "high" : option.confidence === "low" ? "low" : "medium", + rationale: option.rationale, + risks: strategy.risks.slice(0, 4).map((risk) => risk.id), + scenario: getScenario(option.id), + title: option.title + })); +} + +function buildEvidenceNarrative( + strategy: SeoStrategyContract, + serpInterpretation: SerpInterpretationContract +): StrategySynthesisContract["evidenceNarrative"] { + const opportunityNarrative = strategy.opportunities.slice(0, 5).map((opportunity) => ({ + confidence: opportunity.confidence, + evidenceRefs: opportunity.evidenceRefs.slice(0, 8), + id: `evidence:${opportunity.id}`, + title: opportunity.title, + whatWeKnow: `${opportunity.demand.totalFrequency} спрос, ${opportunity.demand.phraseCount} связанных фраз, priority ${opportunity.priority}.`, + whyItMatters: opportunity.whyItMatters + })); + const serpNarrative = serpInterpretation.strategySignals.slice(0, 4).map((signal) => ({ + confidence: signal.confidence, + evidenceRefs: signal.evidenceRefs.slice(0, 8), + id: `evidence:${signal.id}`, + title: signal.title, + whatWeKnow: signal.message, + whyItMatters: "SERP interpretation превращает внешние факты в проверяемый стратегический сигнал." + })); + + return [...opportunityNarrative, ...serpNarrative].slice(0, 8); +} + +function buildRiskPosture(strategy: SeoStrategyContract, serpInterpretation: SerpInterpretationContract) { + const serpRiskRefs = new Map(serpInterpretation.risks.map((risk) => [risk.id, risk.evidenceRefs])); + + return strategy.risks.slice(0, 8).map((risk) => ({ + evidenceRefs: serpRiskRefs.get(risk.id) ?? [], + id: risk.id, + level: risk.level, + message: risk.message, + mitigation: risk.mitigation, + title: risk.title + })); +} + +function buildDataGaps(strategy: SeoStrategyContract): StrategySynthesisContract["dataGaps"] { + return strategy.missingEvidence.slice(0, 8).map((gap) => ({ + id: gap.id, + nextStep: + gap.owner === "ai_layer" + ? "Закрыть через следующий structured model task/manual review." + : gap.owner === "external_service" + ? "Подключить или собрать внешний evidence source." + : "Отдать на human/product approval.", + owner: gap.owner, + priority: gap.priority, + title: gap.title, + whyItBlocks: gap.impact + })); +} + +function buildPhaseDecision(strategy: SeoStrategyContract): StrategySynthesisContract["phaseDecision"] { + const prepareNext = strategy.opportunities + .filter((opportunity) => opportunity.timing.action === "prepare") + .slice(0, 5) + .map((opportunity) => `${opportunity.title}: ${opportunity.timing.promotionSignal}`); + const deferredTargets = strategy.opportunities + .filter((opportunity) => opportunity.timing.action === "defer") + .slice(0, 5) + .map((opportunity) => `${opportunity.title}: ${opportunity.timing.reason}`); + + return { + allowedNow: strategy.siteMaturity.allowedNow, + currentPhase: strategy.siteMaturity.phase, + hold: [...strategy.siteMaturity.holdUntil, ...deferredTargets].slice(0, 8), + phaseRoadmap: strategy.phasePlan, + prepareNext, + promotionSignals: strategy.siteMaturity.promotionSignals, + schemaVersion: "seo-phase-decision.v1", + summary: + strategy.siteMaturity.phase === "bootstrap_indexing" + ? "Сайт в bootstrap/indexing фазе: immediate route должен начинаться с индексируемого long-tail ядра; head terms держим как будущий runway." + : strategy.siteMaturity.reason, + title: + strategy.siteMaturity.phase === "bootstrap_indexing" + ? "Сначала bootstrap/indexing, потом рост" + : "Фаза сайта определяет допустимые targets" + }; +} + +function buildRecommendedPath( + strategy: SeoStrategyContract, + decisionOptions: StrategySynthesisContract["decisionOptions"] +): StrategySynthesisContract["recommendedPath"] { + const topOption = + strategy.siteMaturity.phase === "bootstrap_indexing" + ? (decisionOptions.find((option) => option.scenario === "bootstrap_runway") ?? decisionOptions[0]) + : (decisionOptions.find((option) => option.priority === "high") ?? decisionOptions[0]); + const highPriorityGaps = strategy.missingEvidence.filter((gap) => gap.priority === "high"); + + return { + confidence: strategy.verdict.confidence, + doNow: [ + strategy.siteMaturity.phase === "bootstrap_indexing" + ? "Собрать bootstrap/indexing runway и не атаковать head terms как immediate action." + : "Закрепить аналитический вывод как strategy synthesis draft.", + "Показать пользователю opportunity/risk/data gap без перехода к site changes.", + ...(highPriorityGaps.length > 0 ? ["Закрыть high-priority gaps до human strategy approval."] : []) + ], + expectedImpact: topOption?.expectedImpact ?? "medium", + hold: [ + "Не запускать rewrite/apply.", + "Не считать top/head keywords immediate route без maturity signals.", + "Не добавлять business expansion в core SEO route.", + "Не считать risky phrases approved без model/human review." + ], + id: topOption?.id ?? "path:analytics_first", + needsEvidence: strategy.missingEvidence.slice(0, 5).map((gap) => gap.title), + priority: topOption?.priority ?? "medium", + rationale: topOption?.rationale ?? strategy.verdict.summary, + title: topOption?.title ?? "Сначала доказать аналитику" + }; +} + +function buildNextActions(contract: Omit<StrategySynthesisContract, "nextActions">) { + const actions: string[] = []; + + actions.push( + contract.phaseDecision.currentPhase === "bootstrap_indexing" + ? "Держать immediate strategy в bootstrap/indexing фазе: low-competition exact intent, deferred head terms, promotion signals." + : `Проверить phase roadmap перед approval: текущая фаза ${contract.phaseDecision.currentPhase}.` + ); + + if (contract.dataGaps.length > 0) { + actions.push(`Закрыть gaps перед approval: ${contract.dataGaps.slice(0, 3).map((gap) => gap.title).join("; ")}.`); + } + + actions.push("Проверить synthesis как SEO-аналитик: понятность вывода, evidence refs, отсутствие business drift."); + actions.push("После synthesis переходить к model-review контракту качества, а не к правке сайта."); + + return actions; +} + +function buildStrategySynthesisOutput( + projectId: string, + strategy: SeoStrategyContract, + serpInterpretation: SerpInterpretationContract +): StrategySynthesisContract | null { + const task = buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation); + + if (!task) { + return null; + } + + const decisionOptions = buildDecisionOptions(strategy); + const evidenceNarrative = buildEvidenceNarrative(strategy, serpInterpretation); + const riskPosture = buildRiskPosture(strategy, serpInterpretation); + const dataGaps = buildDataGaps(strategy); + const phaseDecision = buildPhaseDecision(strategy); + const recommendedPath = buildRecommendedPath(strategy, decisionOptions); + const contractWithoutActions: Omit<StrategySynthesisContract, "nextActions"> = { + analystReview: getDefaultAnalystReview(), + blockedActions: [ + "source/write/rewrite/apply недоступны до отдельного diff contract и human approval.", + "head/top keywords недоступны как immediate action для bootstrap/indexing сайта.", + "business expansion не входит в core strategy route без отдельной user-approved branch.", + "model output не может менять Wordstat/SERP факты или придумывать спрос." + ], + dataGaps, + decisionOptions, + evidenceNarrative, + executiveSummary: { + confidence: strategy.verdict.confidence, + primaryConstraint: + strategy.siteMaturity.phase === "bootstrap_indexing" + ? "Нельзя атаковать head/top keywords до индексации, traction и authority signals." + : strategy.verdict.primaryConstraint, + title: + strategy.readiness.strategyConfidenceScore >= 75 + ? "Есть сильная аналитическая база для strategy review" + : "Стратегия требует усиления evidence", + verdict: strategy.verdict.summary, + whyNow: + strategy.siteMaturity.phase === "bootstrap_indexing" + ? "Рынок уже виден, но сайт ещё не доказал индексацию/traction; нужен phased roadmap вместо немедленной атаки на высокий спрос." + : "Контекст, спрос, Yandex evidence и SERP interpretation уже собраны в read-only слой; теперь нужен понятный synthesis вместо сырой выдачи." + }, + phaseDecision, + generatedAt: new Date().toISOString(), + projectId, + provider: { + message: + "Strategy synthesis сохранён deterministic fallback-ом. Следующий слой должен заменить judgement ModelProvider-ом, но сохранить этот JSON contract.", + mode: "deterministic_fallback", + modelRequired: true + }, + readiness: { + confidenceScore: strategy.readiness.strategyConfidenceScore, + dataGapCount: dataGaps.length, + decisionOptionCount: decisionOptions.length, + evidenceNarrativeCount: evidenceNarrative.length, + nextActionCount: 0, + riskCount: riskPosture.length + }, + recommendedPath, + riskPosture, + runId: null, + schemaVersion: "seo-strategy-synthesis.v1", + semanticRunId: strategy.semanticRunId, + sourceSerpInterpretationRunId: serpInterpretation.runId, + sourceStrategyGeneratedAt: strategy.generatedAt, + sourceTaskId: task.taskId, + state: "ready", + summary: `${decisionOptions.length} strategy options, ${evidenceNarrative.length} evidence narratives, ${riskPosture.length} risks, ${dataGaps.length} data gaps.` + }; + + return { + ...contractWithoutActions, + nextActions: buildNextActions(contractWithoutActions) + }; +} + +export async function getLatestStrategySynthesisContract(projectId: string): Promise<StrategySynthesisContract> { + const result = await pool.query<StrategySynthesisRunRow>( + ` + select id, status, input, output, error_message, started_at, completed_at, created_at + from runs + where project_id = $1 and run_type = 'seo_strategy_synthesis' + order by created_at desc + limit 1; + `, + [projectId] + ); + const run = result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null; + + return run ?? getEmptyStrategySynthesisContract(projectId); +} + +export async function runStrategySynthesisDraft(projectId: string): Promise<StrategySynthesisRun | null> { + const [strategy, serpInterpretation] = await Promise.all([ + getSeoStrategyContract(projectId), + getLatestSerpInterpretationContract(projectId) + ]); + const output = buildStrategySynthesisOutput(projectId, strategy, serpInterpretation); + + if (!output) { + return null; + } + + const result = await pool.query<StrategySynthesisRunRow>( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_strategy_synthesis', 'done', $2::jsonb, $3::jsonb, now(), now()) + returning id, status, input, output, error_message, started_at, completed_at, created_at; + `, + [ + projectId, + JSON.stringify({ + providerMode: "deterministic_fallback", + schemaVersion: "seo-strategy-synthesis-run-input.v1", + sourceSerpInterpretationRunId: output.sourceSerpInterpretationRunId, + sourceStrategyGeneratedAt: output.sourceStrategyGeneratedAt, + sourceTaskId: output.sourceTaskId, + taskType: "seo.strategy_synthesis" + }), + JSON.stringify(output) + ] + ); + + return result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null; +} + +export async function saveStrategySynthesisManual( + projectId: string, + output: StrategySynthesisContract +): Promise<StrategySynthesisRun> { + const normalizedOutput = normalizeOutput( + projectId, + output, + "codex_manual", + "Codex manual dev-loop: текущий Codex-сеанс выполнил seo.strategy_synthesis task по правилам, без внешнего provider adapter." + ); + const result = await pool.query<StrategySynthesisRunRow>( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_strategy_synthesis', '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_manual", + schemaVersion: "seo-strategy-synthesis-manual-input.v1", + sourceSerpInterpretationRunId: normalizedOutput.sourceSerpInterpretationRunId, + sourceStrategyGeneratedAt: normalizedOutput.sourceStrategyGeneratedAt, + sourceTaskId: normalizedOutput.sourceTaskId, + taskType: "seo.strategy_synthesis" + }), + JSON.stringify(normalizedOutput) + ] + ); + const run = result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null; + + if (!run) { + throw new Error("Не удалось сохранить Codex manual Strategy synthesis."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/strategy/strategySynthesisTask.ts b/seo_mode/seo_mode/server/src/strategy/strategySynthesisTask.ts new file mode 100644 index 0000000..d2ea49b --- /dev/null +++ b/seo_mode/seo_mode/server/src/strategy/strategySynthesisTask.ts @@ -0,0 +1,157 @@ +import type { SerpInterpretationContract } from "../evidence/serpInterpretation.js"; +import type { SeoStrategyContract } from "./seoStrategy.js"; + +export type StrategySynthesisModelTaskContract = { + taskType: "seo.strategy_synthesis"; + schemaVersion: "seo-strategy-synthesis-task.v1"; + taskId: string; + projectId: string; + semanticRunId: string | null; + sourceStrategyGeneratedAt: string; + sourceSerpInterpretationRunId: string | null; + providerMode: "model_provider_contract"; + activeSkillId: "seo.strategy_synthesis"; + input: { + strategy: { + schemaVersion: "seo-strategy.v1"; + state: SeoStrategyContract["state"]; + confidence: SeoStrategyContract["verdict"]["confidence"]; + score: number; + primaryConstraint: string; + siteMaturity: SeoStrategyContract["siteMaturity"]; + phasePlan: SeoStrategyContract["phasePlan"]; + missingEvidence: SeoStrategyContract["missingEvidence"]; + topOpportunities: SeoStrategyContract["opportunities"]; + risks: SeoStrategyContract["risks"]; + strategyOptions: SeoStrategyContract["strategyOptions"]; + nextActions: string[]; + }; + serpInterpretation: { + state: SerpInterpretationContract["state"]; + runId: string | null; + interpretedPhraseCount: number; + mismatchCount: number; + competitorGapCount: number; + riskCount: number; + topPhrases: SerpInterpretationContract["phraseInterpretations"]; + competitorGaps: SerpInterpretationContract["competitorGaps"]; + strategySignals: SerpInterpretationContract["strategySignals"]; + }; + }; + allowedActions: Array< + | "synthesize_strategy" + | "prioritize_opportunities" + | "compare_strategy_options" + | "explain_confidence" + | "flag_blockers" + | "request_more_evidence" + | "preserve_evidence_refs" + >; + outputSchema: { + schemaVersion: "seo-strategy-synthesis.v1"; + requiredTopLevelKeys: string[]; + optionRequiredKeys: string[]; + evidenceRequiredKeys: string[]; + riskRequiredKeys: string[]; + }; + guardrails: { + noRewriteOrApply: boolean; + noInventedDemand: boolean; + noBusinessExpansionWithoutBranch: boolean; + noImmediateHeadTermsForZeroSite: boolean; + requireEvidenceRefs: boolean; + }; + stopConditions: string[]; +}; + +export function buildStrategySynthesisModelTask( + projectId: string, + strategy: SeoStrategyContract, + serpInterpretation: SerpInterpretationContract +): StrategySynthesisModelTaskContract | null { + if (strategy.opportunities.length === 0 || strategy.evidence.serpInterpretation.state !== "ready") { + return null; + } + + return { + activeSkillId: "seo.strategy_synthesis", + allowedActions: [ + "synthesize_strategy", + "prioritize_opportunities", + "compare_strategy_options", + "explain_confidence", + "flag_blockers", + "request_more_evidence", + "preserve_evidence_refs" + ], + guardrails: { + noBusinessExpansionWithoutBranch: true, + noImmediateHeadTermsForZeroSite: true, + noInventedDemand: true, + noRewriteOrApply: true, + requireEvidenceRefs: true + }, + input: { + serpInterpretation: { + competitorGapCount: serpInterpretation.readiness.competitorGapCount, + competitorGaps: serpInterpretation.competitorGaps.slice(0, 8), + interpretedPhraseCount: serpInterpretation.readiness.interpretedPhraseCount, + mismatchCount: serpInterpretation.readiness.mismatchCount, + riskCount: serpInterpretation.readiness.riskCount, + runId: serpInterpretation.runId, + state: serpInterpretation.state, + strategySignals: serpInterpretation.strategySignals.slice(0, 8), + topPhrases: serpInterpretation.phraseInterpretations.slice(0, 8) + }, + strategy: { + confidence: strategy.verdict.confidence, + phasePlan: strategy.phasePlan, + missingEvidence: strategy.missingEvidence.slice(0, 10), + nextActions: strategy.nextActions.slice(0, 8), + primaryConstraint: strategy.verdict.primaryConstraint, + risks: strategy.risks.slice(0, 10), + schemaVersion: "seo-strategy.v1", + score: strategy.readiness.strategyConfidenceScore, + siteMaturity: strategy.siteMaturity, + state: strategy.state, + strategyOptions: strategy.strategyOptions.slice(0, 6), + topOpportunities: strategy.opportunities.slice(0, 10) + } + }, + outputSchema: { + evidenceRequiredKeys: ["id", "title", "whatWeKnow", "whyItMatters", "evidenceRefs", "confidence"], + optionRequiredKeys: ["id", "title", "priority", "expectedImpact", "confidence", "rationale", "nextStep"], + requiredTopLevelKeys: [ + "schemaVersion", + "projectId", + "sourceStrategyGeneratedAt", + "phaseDecision", + "executiveSummary", + "recommendedPath", + "decisionOptions", + "evidenceNarrative", + "riskPosture", + "dataGaps", + "blockedActions", + "nextActions" + ], + riskRequiredKeys: ["id", "level", "title", "message", "mitigation", "evidenceRefs"], + schemaVersion: "seo-strategy-synthesis.v1" + }, + projectId, + providerMode: "model_provider_contract", + schemaVersion: "seo-strategy-synthesis-task.v1", + semanticRunId: strategy.semanticRunId, + sourceSerpInterpretationRunId: serpInterpretation.runId, + sourceStrategyGeneratedAt: strategy.generatedAt, + stopConditions: [ + "Нет готового seo-strategy.v1 с opportunities.", + "Нет ready seo-serp-interpretation.v1: synthesis не должен строиться на сырых доменах без interpretation.", + "Сайт в bootstrap/indexing фазе, а запрошен immediate route на head/top keywords: вернуть defer/hold.", + "Запрошены rewrite/apply/source mutations: этот task только аналитический.", + "Нужно менять бизнес-вектор или добавлять новый рынок: вернуть proposal/data gap, не утверждать как core strategy." + ], + taskId: `${strategy.generatedAt}:strategy-synthesis:v1`, + taskType: "seo.strategy_synthesis" + }; +}