Add SEO AI evidence contracts

This commit is contained in:
DCCONSTRUCTIONS 2026-06-29 23:34:52 +03:00
parent 95eea435ac
commit 6ed1df6803
34 changed files with 14460 additions and 517 deletions

View File

@ -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.

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -8,6 +8,7 @@
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"start": "node dist/index.js", "start": "node dist/index.js",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"check:ontology-universality": "tsx src/scripts/checkOntologyUniversality.ts",
"db:migrate": "tsx src/scripts/migrate.ts" "db:migrate": "tsx src/scripts/migrate.ts"
}, },
"dependencies": { "dependencies": {

View File

@ -1,6 +1,20 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { pool } from "../db/client.js"; 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"; import { storageProvider } from "../storage/index.js";
type LatestScanRow = { type LatestScanRow = {
@ -84,44 +98,6 @@ type AnalysisVerdict = {
nextValidationStep: string; 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 = { type LegacyMarker = {
id: string; id: string;
label: string; label: string;
@ -211,6 +187,25 @@ export type SemanticAnalysisOutput = {
ctaPhrases: string[]; ctaPhrases: string[];
toneSignals: 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[]; qualitySignals: AnalysisSignal[];
analysisVerdict: AnalysisVerdict; analysisVerdict: AnalysisVerdict;
clusters: Array<{ 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[] = [ const LEGACY_MARKERS: LegacyMarker[] = [
{ {
id: "webflow", id: "webflow",
@ -689,7 +417,7 @@ const LEGACY_MARKERS: LegacyMarker[] = [
label: "SS-script", label: "SS-script",
terms: ["ss-script", "ss script", "ss_script", "ssscript"], terms: ["ss-script", "ss script", "ss_script", "ssscript"],
severity: "blocker", severity: "blocker",
action: "Заменить старое продуктово-техническое упоминание на NodeDC или удалить." action: "Заменить старое продуктово-техническое упоминание на актуальный бренд проекта или удалить."
}, },
{ {
id: "federat", id: "federat",
@ -703,7 +431,7 @@ const LEGACY_MARKERS: LegacyMarker[] = [
label: "старые docs-ссылки", label: "старые docs-ссылки",
terms: ["docs.ss", "ss-docs", "old docs", "legacy docs", "docs.federat"], terms: ["docs.ss", "ss-docs", "old docs", "legacy docs", "docs.federat"],
severity: "warning", 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<IntentSignalGroupId, string[]>;
function toIsoDate(value: Date | null) { function toIsoDate(value: Date | null) {
return value ? value.toISOString() : 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 text = documents.map((document) => document.text).join("\n");
const normalizedText = normalizeSearchText(text); const normalizedText = normalizeSearchText(text);
const cyrillicCount = (normalizedText.match(/[а-я]/g) ?? []).length; 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")) .sort((first, second) => second[1] - first[1] || first[0].localeCompare(second[0], "ru-RU"))
.slice(0, 18) .slice(0, 18)
.map(([term, count]) => ({ term, count })); .map(([term, count]) => ({ term, count }));
const knownBrandTerms = ["nodedc", "node.dc", "node dc", "node.js", "нодедс", "ии", "ai", "ops", "hub", "engine"]; const brandTerms = ontology.brandTerms.filter((term) => countTermHits(normalizedText, term) > 0);
const brandTerms = knownBrandTerms.filter((term) => countTermHits(normalizedText, term) > 0);
const ctaPhrases = CTA_PATTERNS.filter((phrase) => countTermHits(normalizedText, phrase) > 0); const ctaPhrases = CTA_PATTERNS.filter((phrase) => countTermHits(normalizedText, phrase) > 0);
const toneSignals = [ const toneSignals = [
topTerms.some((item) => ["платформа", "контур", "инфраструктура", "процесс"].includes(item.term)) topTerms.some((item) => ["платформа", "контур", "инфраструктура", "процесс"].includes(item.term))
? "инженерно-продуктовый тон" ? "инженерно-продуктовый тон"
: "", : "",
topTerms.some((item) => ["бизнес", "команда", "проект", "задача"].includes(item.term)) topTerms.some((item) => ["бизнес", "команда", "проект", "задача"].includes(item.term))
? "B2B/операционный язык" ? "организационно-операционный язык"
: "", : "",
brandTerms.some((term) => ["ai", "ии"].includes(term)) ? "AI-first лексика" : "", brandTerms.some((term) => ["ai", "ии"].includes(term)) ? "AI-first лексика" : "",
ctaPhrases.length > 0 ? "есть коммерческие CTA" : "" 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) { function getClusterHits(cluster: SemanticClusterDefinition, document: ContentDocument) {
const normalizedText = normalizeSearchText(document.text); const normalizedText = normalizeSearchText(document.text);
let hits = 0; let hits = 0;
@ -1316,8 +1068,8 @@ function getClusterHits(cluster: SemanticClusterDefinition, document: ContentDoc
return { hits, matchedTerms }; return { hits, matchedTerms };
} }
function analyzeClusters(documents: ContentDocument[]) { function analyzeClusters(documents: ContentDocument[], ontology: ProjectOntology) {
return NODEDC_CLUSTERS.map((cluster) => { return ontology.clusters.map((cluster) => {
const docHits = documents const docHits = documents
.map((document) => { .map((document) => {
const result = getClusterHits(cluster, document); const result = getClusterHits(cluster, document);
@ -1513,10 +1265,13 @@ function getEvidenceScore(clusters: ReturnType<typeof analyzeClusters>) {
return getWeightedClusterScore(clusters, (cluster) => cluster.evidence.score); return getWeightedClusterScore(clusters, (cluster) => cluster.evidence.score);
} }
function getCommercialReadinessScore(clusters: ReturnType<typeof analyzeClusters>, styleProfile: SemanticAnalysisOutput["styleProfile"]) { function getCommercialReadinessScore(
const commercialClusters = clusters.filter((cluster) => clusters: ReturnType<typeof analyzeClusters>,
["business_process_automation", "integrations_api", "conversion_pilot_pricing", "private_cloud_security"].includes(cluster.id) 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 clusterScore = getWeightedClusterScore(commercialClusters.length > 0 ? commercialClusters : clusters, (cluster) => cluster.score);
const ctaScore = Math.min(20, styleProfile.ctaPhrases.length * 10); const ctaScore = Math.min(20, styleProfile.ctaPhrases.length * 10);
@ -1552,13 +1307,13 @@ function analyzeLegacy(documents: ContentDocument[]) {
}); });
} }
function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>) { function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>, ontology: ProjectOntology) {
return clusters.flatMap((cluster) => { return clusters.flatMap((cluster) => {
const source: SemanticAnalysisOutput["seedCandidates"][number]["source"] = const source: SemanticAnalysisOutput["seedCandidates"][number]["source"] =
cluster.status === "missing" ? "ontology" : "detected"; cluster.status === "missing" ? "ontology" : "detected";
const reason = const reason =
cluster.status === "missing" cluster.status === "missing"
? "Кластер важен для NodeDC, но в текущем сайте почти не раскрыт." ? `Кластер важен для ${ontology.brandName}, но в текущем сайте почти не раскрыт.`
: "Кластер уже найден в контенте и подходит для проверки частотности."; : "Кластер уже найден в контенте и подходит для проверки частотности.";
return cluster.queryExamples.slice(0, cluster.priority === "high" ? 3 : 2).map((phrase) => ({ 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()}`; 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 normalizedPath = normalizeUrlLikePath(pagePath);
const labels: Record<string, string> = {
"/": "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]) { if (ontology.landingBrief.entityLabels[normalizedPath]) {
return labels[normalizedPath]; return ontology.landingBrief.entityLabels[normalizedPath];
} }
if (pageType === "module") { return ontology.landingBrief.fallbackEntities[pageType] ?? ontology.brandName;
return "Модуль NodeDC";
}
if (pageType === "use_case") {
return "Сценарий NodeDC";
}
if (pageType === "integration") {
return "Интеграция NodeDC";
}
return "NodeDC";
} }
function getLandingBriefAudience(pageType: LandingOpportunityPageType) { function getLandingBriefAudience(pageType: LandingOpportunityPageType, ontology: ProjectOntology) {
const audiences: Record<LandingOpportunityPageType, string> = { return ontology.landingBrief.audiences[pageType];
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 getLandingBriefPrimaryCta(pageType: LandingOpportunityPageType) { function getLandingBriefPrimaryCta(pageType: LandingOpportunityPageType) {
if (pageType === "conversion") { if (pageType === "conversion") {
return "Запросить расчет пилота"; return "Уточнить условия";
} }
if (pageType === "docs") { if (pageType === "docs") {
return "Открыть технический контур"; return "Открыть инструкцию";
} }
if (pageType === "security" || pageType === "deployment") { if (pageType === "security" || pageType === "deployment") {
return "Обсудить развертывание"; return "Обсудить запуск";
} }
return "Запросить демо"; return "Получить консультацию";
} }
function getLandingBriefSecondaryCta(pageType: LandingOpportunityPageType) { function getLandingBriefSecondaryCta(pageType: LandingOpportunityPageType) {
@ -1955,73 +1662,22 @@ function getLandingBriefSecondaryCta(pageType: LandingOpportunityPageType) {
} }
if (pageType === "module" || pageType === "use_case") { if (pageType === "module" || pageType === "use_case") {
return "Собрать пилотный сценарий"; return "Разобрать сценарий";
} }
return "Получить консультацию"; return "Получить консультацию";
} }
function getLandingBriefInternalLinks(pagePath: string, pageType: LandingOpportunityPageType) { function getLandingBriefInternalLinks(pagePath: string, pageType: LandingOpportunityPageType, ontology: ProjectOntology) {
const normalizedPath = normalizeUrlLikePath(pagePath); const normalizedPath = normalizeUrlLikePath(pagePath);
const baseLinks = ["/", "/platform/", "/modules/", "/integrations/", "/deployment/", "/security/", "/pilot/", "/contacts/"];
const typedLinks: Record<LandingOpportunityPageType, string[]> = {
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) .filter((link) => normalizeUrlLikePath(link) !== normalizedPath)
.slice(0, 5); .slice(0, 5);
} }
function getLandingBriefTypeSection(pageType: LandingOpportunityPageType) { function getLandingBriefTypeSection(pageType: LandingOpportunityPageType, ontology: ProjectOntology) {
const sections: Record<LandingOpportunityPageType, { title: string; goal: string }> = { return ontology.landingBrief.typeSections[pageType];
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];
} }
type LandingBriefDraft = { type LandingBriefDraft = {
@ -2222,7 +1878,7 @@ function buildLandingBriefQualityContract(
"Wordstat частотность и сезонность по seed-фразам", "Wordstat частотность и сезонность по seed-фразам",
"SERP-конкуренты и типы страниц в топе Яндекса", "SERP-конкуренты и типы страниц в топе Яндекса",
"Региональная выдача и коммерческие маркеры", "Региональная выдача и коммерческие маркеры",
"Фактические продуктовые доказательства: кейсы, интеграции, SLA, ограничения" "Фактические доказательства: примеры, условия, ограничения и подтверждения"
]; ];
const rawScore = const rawScore =
checks.reduce((sum, check) => sum + getBriefCheckScore(check.status), 0) / Math.max(1, checks.length) - 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) => { 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 primaryCluster = page.clusters[0];
const clusterTitles = page.clusters.map((cluster) => cluster.title); const clusterTitles = page.clusters.map((cluster) => cluster.title);
const primaryIntent = primaryCluster?.targetIntent ?? page.reason; 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 mustUseTerms = Array.from(new Set([...page.seedPhrases.slice(0, 5), ...clusterTitles.slice(0, 4)])).slice(0, 8);
const typeSection = getLandingBriefTypeSection(page.pageType); const typeSection = getLandingBriefTypeSection(page.pageType, ontology);
const h1 = trimSeoText(`${entity}: ${primaryCluster?.title ?? "AI-автоматизация для бизнеса"}`, 96); const fallbackTopic = primaryCluster?.title ?? "основное предложение";
const title = trimSeoText(`${entity}${primaryCluster?.title ?? "AI-автоматизация"} | NodeDC`, 72); 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( const metaDescription = trimSeoText(
`${entity}: ${clusterTitles.slice(0, 2).join(", ")}. Сценарии, интеграции, безопасность и пилот внедрения NodeDC.`, `${entity}: ${clusterSummary}. Что уже есть на сайте, кому это подходит и какой следующий шаг в ${ontology.brandName}.`,
158 158
); );
const sections = [ const sections = [
@ -2290,9 +1949,9 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing
mustCoverTerms: mustUseTerms.slice(2, 6) mustCoverTerms: mustUseTerms.slice(2, 6)
}, },
{ {
title: "Доказательства, внедрение и CTA", title: "Доказательства и следующий шаг",
goal: "Показать интеграции, контур внедрения, безопасность, формат пилота и основной CTA.", goal: "Показать подтверждения, условия, ограничения и основной CTA без новых неподтвержденных обещаний.",
mustCoverTerms: [getLandingBriefPrimaryCta(page.pageType), "пилот", "внедрение"].slice(0, 3) mustCoverTerms: [primaryCta, ...mustUseTerms.slice(0, 2)].slice(0, 3)
} }
]; ];
@ -2326,12 +1985,12 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing
}, },
{ {
id: "commercial_block", id: "commercial_block",
title: "Коммерческий блок и CTA учтены", title: "Следующий шаг и CTA учтены",
done: page.action === "validate", done: page.action === "validate",
reason: reason:
page.action === "validate" page.action === "validate"
? "Страница выглядит готовой к внешней проверке." ? "Страница выглядит готовой к внешней проверке."
: "В брифе нужно явно добавить CTA, пилот/демо и путь к заявке." : "В брифе нужно явно добавить следующий шаг и путь к обращению без неподтвержденных обещаний."
}, },
{ {
id: "market_validation", id: "market_validation",
@ -2345,11 +2004,11 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing
title, title,
metaDescription, metaDescription,
targetIntent: primaryIntent, targetIntent: primaryIntent,
primaryCta: getLandingBriefPrimaryCta(page.pageType), primaryCta,
secondaryCta: getLandingBriefSecondaryCta(page.pageType), secondaryCta: getLandingBriefSecondaryCta(page.pageType),
mustUseTerms, mustUseTerms,
seedPhrases: page.seedPhrases, seedPhrases: page.seedPhrases,
internalLinks: getLandingBriefInternalLinks(page.path, page.pageType), internalLinks: getLandingBriefInternalLinks(page.path, page.pageType, ontology),
sections, sections,
readinessChecklist readinessChecklist
}; };
@ -2363,10 +2022,10 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing
title, title,
metaDescription, metaDescription,
targetIntent: primaryIntent, targetIntent: primaryIntent,
audience: getLandingBriefAudience(page.pageType), audience: getLandingBriefAudience(page.pageType, ontology),
primaryCta: briefDraft.primaryCta, primaryCta: briefDraft.primaryCta,
secondaryCta: briefDraft.secondaryCta, secondaryCta: briefDraft.secondaryCta,
introAngle: `Страница должна связать ${clusterTitles.slice(0, 3).join(", ")} с понятным коммерческим сценарием NodeDC.`, introAngle: `Страница должна связать ${clusterSummary} с понятным следующим шагом ${ontology.brandName}.`,
mustUseTerms, mustUseTerms,
seedPhrases: briefDraft.seedPhrases, seedPhrases: briefDraft.seedPhrases,
internalLinks: briefDraft.internalLinks, internalLinks: briefDraft.internalLinks,
@ -2436,9 +2095,21 @@ function buildFallbackLandingPlanForBrief(
function hydrateLandingBriefQualityContracts(output: SemanticAnalysisOutput): SemanticAnalysisOutput { function hydrateLandingBriefQualityContracts(output: SemanticAnalysisOutput): SemanticAnalysisOutput {
const planByPath = new Map(output.landingPagePlan.map((page) => [page.path, page])); const planByPath = new Map(output.landingPagePlan.map((page) => [page.path, page]));
const runtimeOutput = output as SemanticAnalysisOutput & { projectOntology?: SemanticAnalysisOutput["projectOntology"] };
return { return {
...output, ...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) => { landingPageBriefs: output.landingPageBriefs.map((brief) => {
const runtimeBrief = brief as typeof brief & { qualityContract?: BriefQualityContract }; const runtimeBrief = brief as typeof brief & { qualityContract?: BriefQualityContract };
@ -2570,7 +2241,7 @@ function buildQualitySignals(output: SemanticAnalysisBaseOutput): AnalysisSignal
return signals; return signals;
} }
function buildAnalysisVerdict(output: SemanticAnalysisQualityOutput): AnalysisVerdict { function buildAnalysisVerdict(output: SemanticAnalysisQualityOutput, ontology: ProjectOntology): AnalysisVerdict {
const risks: AnalysisVerdict["risks"] = [ const risks: AnalysisVerdict["risks"] = [
{ {
id: "local_only", id: "local_only",
@ -2663,7 +2334,7 @@ function buildAnalysisVerdict(output: SemanticAnalysisQualityOutput): AnalysisVe
"Нет live SERP Яндекса, конкурентов, сниппетов и оценки сложности выдачи.", "Нет live SERP Яндекса, конкурентов, сниппетов и оценки сложности выдачи.",
"Нет данных Yandex Webmaster об индексации, дублях, canonical и ошибках обхода.", "Нет данных Yandex Webmaster об индексации, дублях, canonical и ошибках обхода.",
"Нет Метрики/Search queries, поэтому нельзя связать семантику с фактическим трафиком и конверсией.", "Нет Метрики/Search queries, поэтому нельзя связать семантику с фактическим трафиком и конверсией.",
"Онтология NodeDC пока задана правилами внутри модуля, а не обучена на рынке." ontology.analysisLimitNote
], ],
externalSignalsNeeded: [ externalSignalsNeeded: [
"Yandex Wordstat: частотность, регионы, похожие запросы, минус-слова.", "Yandex Wordstat: частотность, регионы, похожие запросы, минус-слова.",
@ -2844,7 +2515,7 @@ async function getScanDataAssets(scanVersionId: string) {
return result.rows; return result.rows;
} }
async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisOutput | null> { async function buildAnalysisOutput(projectId: string): Promise<{ ontology: ProjectOntology; output: SemanticAnalysisOutput } | null> {
const scan = await getLatestDoneScan(projectId); const scan = await getLatestDoneScan(projectId);
if (!scan) { if (!scan) {
@ -2858,14 +2529,16 @@ async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisO
).filter((document): document is ContentDocument => Boolean(document)); ).filter((document): document is ContentDocument => Boolean(document));
const documents = [...pageDocuments, ...contentDocuments]; const documents = [...pageDocuments, ...contentDocuments];
const coverageDocuments = documents.filter((document) => document.usedForCoverage); 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 pageScope = buildPageScopeSummary(pageDocuments, contentDocuments);
const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents); const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents, ontology);
const clusters = analyzeClusters(coverageDocuments); const clusters = analyzeClusters(coverageDocuments, ontology);
const coverageScore = getCoverageScore(clusters); const coverageScore = getCoverageScore(clusters);
const lexicalCoverageScore = getLexicalCoverageScore(clusters); const lexicalCoverageScore = getLexicalCoverageScore(clusters);
const evidenceScore = getEvidenceScore(clusters); const evidenceScore = getEvidenceScore(clusters);
const intentCoverageScore = getIntentCoverageScore(clusters); const intentCoverageScore = getIntentCoverageScore(clusters);
const commercialReadinessScore = getCommercialReadinessScore(clusters, styleProfile); const commercialReadinessScore = getCommercialReadinessScore(clusters, styleProfile, ontology);
const legacyFindings = analyzeLegacy(documents); const legacyFindings = analyzeLegacy(documents);
const gaps = clusters const gaps = clusters
.filter((cluster) => cluster.status !== "covered") .filter((cluster) => cluster.status !== "covered")
@ -2879,10 +2552,10 @@ async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisO
targetPages: cluster.targetPages, targetPages: cluster.targetPages,
seedExamples: cluster.queryExamples seedExamples: cluster.queryExamples
})); }));
const seedCandidates = buildSeedCandidates(clusters); const seedCandidates = buildSeedCandidates(clusters, ontology);
const landingOpportunities = buildLandingOpportunities(clusters, coverageDocuments); const landingOpportunities = buildLandingOpportunities(clusters, coverageDocuments);
const landingPagePlan = buildLandingPagePlan(landingOpportunities, clusters); const landingPagePlan = buildLandingPagePlan(landingOpportunities, clusters);
const landingPageBriefs = buildLandingPageBriefs(landingPagePlan); const landingPageBriefs = buildLandingPageBriefs(landingPagePlan, ontology);
const pageClusterMap = new Map( const pageClusterMap = new Map(
pageDocuments.map((document) => { pageDocuments.map((document) => {
const matchedClusters = clusters const matchedClusters = clusters
@ -2929,6 +2602,27 @@ async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisO
}, },
pageScope, pageScope,
styleProfile, styleProfile,
projectOntology: {
id: ontology.id,
versionId: null,
version: null,
source: ontology.source,
brandName: ontology.brandName,
domainPackage: ontology.domainPackage,
instance: ontology.projectOntologyInstance
? {
schemaVersion: ontology.projectOntologyInstance.schemaVersion,
siteId: ontology.projectOntologyInstance.siteId,
sourceType: ontology.projectOntologyInstance.source.sourceType,
scanVersionId: ontology.projectOntologyInstance.source.scanVersionId,
approvalStatus: ontology.projectOntologyInstance.approvals.status,
pageCount: ontology.projectOntologyInstance.pages.length,
sectionCount: ontology.projectOntologyInstance.sections.length,
intentClusterCount: ontology.projectOntologyInstance.intentClusters.length,
evidenceCount: ontology.projectOntologyInstance.evidence.length
}
: null
},
clusters, clusters,
pages: pages.map((page) => { pages: pages.map((page) => {
const document = pageDocuments.find((item) => item.id === page.page_id); const document = pageDocuments.find((item) => item.id === page.page_id);
@ -2973,22 +2667,39 @@ async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisO
}, },
qualitySignals qualitySignals
} satisfies SemanticAnalysisQualityOutput; } satisfies SemanticAnalysisQualityOutput;
const analysisVerdict = buildAnalysisVerdict(outputWithQuality); const analysisVerdict = buildAnalysisVerdict(outputWithQuality, ontology);
return { return {
ontology,
output: {
...outputWithQuality, ...outputWithQuality,
analysisVerdict, analysisVerdict,
nextActions: buildNextActions(outputWithQuality) nextActions: buildNextActions(outputWithQuality)
}
}; };
} }
export async function runSemanticAnalysis(projectId: string) { export async function runSemanticAnalysis(projectId: string) {
const output = await buildAnalysisOutput(projectId); const analysisBuild = await buildAnalysisOutput(projectId);
if (!output) { if (!analysisBuild) {
return null; return null;
} }
const ontologyVersion = await saveProjectOntologyVersion({
ontology: analysisBuild.ontology,
projectId,
scanVersionId: analysisBuild.output.scanVersionId
});
const output: SemanticAnalysisOutput = {
...analysisBuild.output,
projectOntology: {
...analysisBuild.output.projectOntology,
version: ontologyVersion?.version ?? null,
versionId: ontologyVersion?.id ?? null
}
};
const result = await pool.query<AnalysisRunRow>( const result = await pool.query<AnalysisRunRow>(
` `
insert into runs (project_id, run_type, status, input, output, started_at, completed_at) 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."); throw new SemanticAnalysisError("Не удалось сохранить semantic analysis run.");
} }
if (ontologyVersion) {
await linkProjectOntologyVersionToRun(ontologyVersion.id, row.id);
}
return mapAnalysisRun(row); return mapAnalysisRun(row);
} }
@ -3093,9 +2808,23 @@ function markdownList(items: string[]) {
return items.map((item) => `- ${item}`).join("\n"); 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) { function buildSemanticMarkdownExport(analysis: SemanticAnalysisRun) {
const lines: string[] = [ const lines: string[] = [
`# NodeDC SEO export`, `# ${getSemanticExportTitle(analysis)}`,
"", "",
`Run: ${analysis.runId}`, `Run: ${analysis.runId}`,
`Generated: ${analysis.generatedAt}`, `Generated: ${analysis.generatedAt}`,
@ -3105,6 +2834,7 @@ function buildSemanticMarkdownExport(analysis: SemanticAnalysisRun) {
`Commercial readiness: ${analysis.summary.commercialReadinessScore}%`, `Commercial readiness: ${analysis.summary.commercialReadinessScore}%`,
`Landing page plan: ${analysis.landingPagePlan.length}`, `Landing page plan: ${analysis.landingPagePlan.length}`,
`SEO briefs: ${analysis.landingPageBriefs.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", "## Landing page plan",
"" ""
@ -3211,7 +2941,7 @@ export async function getSemanticAnalysisExport(projectId: string, format: Seman
return null; return null;
} }
const fileBaseName = `nodedc-seo-${analysis.runId.slice(0, 8)}`; const fileBaseName = `${getSemanticExportFilePrefix(analysis)}-${analysis.runId.slice(0, 8)}`;
if (format === "csv") { if (format === "csv") {
return { return {

View File

@ -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<string, unknown>;
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<string, unknown>;
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<SeoContextReviewRun | null> {
const result = await pool.query<SeoContextReviewRunRow>(
`
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<SeoContextReviewRun | null> {
const result = await pool.query<SeoContextReviewRunRow>(
`
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<SeoContextReviewRun | null> {
const analysis = await getLatestSemanticAnalysis(projectId);
if (!analysis) {
return null;
}
const task = buildSeoContextReviewTask(projectId, analysis);
const output = buildContextReviewOutput(projectId, analysis);
const result = await pool.query<SeoContextReviewRunRow>(
`
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<SeoContextReviewRun> {
const normalizedOutput = normalizeContextReviewOutput(
projectId,
output,
"codex_manual",
"Codex manual dev-loop: текущий Codex-сеанс выполнил seo.context_review task по правилам, без внешнего provider adapter."
);
const result = await pool.query<SeoContextReviewRunRow>(
`
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;
}

View File

@ -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<string, unknown>;
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<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(): 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<SerpInterpretationContract, "nextActions">) {
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<SerpInterpretationContract, "nextActions"> = {
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<SerpInterpretationContract> {
const result = await pool.query<SerpInterpretationRunRow>(
`
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<SerpInterpretationRun | null> {
const evidence = await getLatestYandexEvidenceContract(projectId);
const output = buildSerpInterpretationOutput(projectId, evidence);
if (!output) {
return null;
}
const result = await pool.query<SerpInterpretationRunRow>(
`
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<SerpInterpretationRun> {
const normalizedOutput = normalizeOutput(
projectId,
output,
"codex_manual",
"Codex manual dev-loop: текущий Codex-сеанс выполнил seo.serp_interpretation task по правилам, без внешнего provider adapter."
);
const result = await pool.query<SerpInterpretationRunRow>(
`
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;
}

View File

@ -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
};
}

View File

@ -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<string, number>;
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<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(/&quot;/g, "\"")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/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(/<found priority="([^"]+)">([\s\S]*?)<\/found>/g)].map((match) => [
match[1] ?? "",
getNumber(stripXmlTags(match[2] ?? "")) ?? 0
])
);
const docs = [...xml.matchAll(/<doc id="[^"]+">([\s\S]*?)<\/doc>/g)].slice(0, 8).map((match) => {
const block = match[1] ?? "";
const url = grabXml(block, /<url>([\s\S]*?)<\/url>/);
const title = grabXml(block, /<title>([\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");
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -1,6 +1,17 @@
import { env } from "../config/env.js"; import { env } from "../config/env.js";
import { getLatestSemanticAnalysis, type SemanticAnalysisRun } from "../analysis/semanticAnalysis.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"; import { createWordstatProvider } from "./wordstatProvider.js";
type MarketProviderId = "model" | "serp" | "text_quality" | "wordstat"; type MarketProviderId = "model" | "serp" | "text_quality" | "wordstat";
@ -16,6 +27,16 @@ type MarketEvidenceStatus =
| "not_configured" | "not_configured"
| "ready_for_collection"; | "ready_for_collection";
type MarketProjectOntologyVersion = {
id: string;
version: number;
approvalStatus: ProjectOntologyVersionRecord["approvalStatus"];
brandName: string;
schemaVersion: ProjectOntologyVersionRecord["schemaVersion"];
domainPackage: ProjectOntologyVersionRecord["domainPackage"];
reviewProblemCount: number;
};
type MarketProviderDescriptor = { type MarketProviderDescriptor = {
id: MarketProviderId; id: MarketProviderId;
label: string; label: string;
@ -31,11 +52,14 @@ export type MarketEnrichmentContract = {
schemaVersion: "market-enrichment.v1"; schemaVersion: "market-enrichment.v1";
projectId: string; projectId: string;
semanticRunId: string | null; semanticRunId: string | null;
projectOntologyVersion: MarketProjectOntologyVersion | null;
generatedAt: string; generatedAt: string;
state: MarketEnrichmentState; state: MarketEnrichmentState;
normalization: SeoNormalizationContract;
readiness: { readiness: {
canCollectWordstat: boolean; canCollectWordstat: boolean;
canCollectSerp: boolean; canCollectSerp: boolean;
ontologyReadyForMarket: boolean;
seedCount: number; seedCount: number;
briefCount: number; briefCount: number;
configuredProviderCount: number; configuredProviderCount: number;
@ -48,6 +72,17 @@ export type MarketEnrichmentContract = {
clusterTitle: string; clusterTitle: string;
priority: "high" | "medium" | "low"; priority: "high" | "medium" | "low";
source: "detected" | "ontology"; 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; evidenceStatus: MarketEvidenceStatus;
targetProviders: MarketProviderId[]; targetProviders: MarketProviderId[];
blocker: string | null; blocker: string | null;
@ -55,9 +90,12 @@ export type MarketEnrichmentContract = {
frequencyGroup: string | null; frequencyGroup: string | null;
relatedCount: number; relatedCount: number;
lastCollectedAt: string | null; lastCollectedAt: string | null;
projectOntologyVersionId: string | null;
wordstatResultId: string | null;
}>; }>;
briefEvidence: Array<{ briefEvidence: Array<{
path: string; path: string;
clusterIds: string[];
priority: "high" | "medium" | "low"; priority: "high" | "medium" | "low";
action: "create" | "strengthen" | "validate"; action: "create" | "strengthen" | "validate";
qualityScore: number | null; qualityScore: number | null;
@ -66,6 +104,7 @@ export type MarketEnrichmentContract = {
evidenceStatus: MarketEvidenceStatus; evidenceStatus: MarketEvidenceStatus;
requiredProviders: MarketProviderId[]; requiredProviders: MarketProviderId[];
missingEvidence: string[]; missingEvidence: string[];
projectOntologyVersionId: string | null;
}>; }>;
wordstat: WordstatEvidence; wordstat: WordstatEvidence;
nextActions: string[]; nextActions: string[];
@ -181,6 +220,38 @@ function buildWordstatSeedMap(wordstat: WordstatEvidence) {
return new Map(wordstat.seedResults.map((result) => [normalizePhrase(result.phrase), result])); 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( function getSeedEvidenceStatus(
providers: MarketProviderDescriptor[], providers: MarketProviderDescriptor[],
wordstat: WordstatEvidence, wordstat: WordstatEvidence,
@ -198,20 +269,26 @@ function getSeedEvidenceStatus(
return "collected_related_only"; return "collected_related_only";
} }
if (wordstat.latestJob?.status === "done") { if (wordstat.latestJob?.status === "done" && wordstatResult) {
return "collected_no_signal"; return "collected_no_signal";
} }
return getEvidenceStatus(providers, ["wordstat"]); 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 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 targetProviders: MarketProviderId[] = ["wordstat"];
const result = wordstatResults.get(normalizePhrase(seed.phrase)); const result = wordstatResults.get(normalizePhrase(seed.phrase));
const evidenceStatus = getSeedEvidenceStatus(providers, wordstat, result); const evidenceStatus = ontologyReady ? getSeedEvidenceStatus(providers, wordstat, result) : "not_collected";
return { return {
phrase: seed.phrase, phrase: seed.phrase,
@ -219,24 +296,44 @@ function buildSeedQueue(analysis: SemanticAnalysisRun, providers: MarketProvider
clusterTitle: seed.clusterTitle, clusterTitle: seed.clusterTitle,
priority: seed.priority, priority: seed.priority,
source: seed.source, 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, evidenceStatus,
targetProviders, targetProviders,
blocker: getSeedBlocker(evidenceStatus), blocker: getSeedBlocker(evidenceStatus),
frequency: result?.frequency ?? null, frequency: result?.frequency ?? null,
frequencyGroup: result?.frequencyGroup ?? null, frequencyGroup: result?.frequencyGroup ?? null,
relatedCount: result?.relatedCount ?? 0, 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) => { return (analysis.landingPageBriefs ?? []).slice(0, 80).map((brief) => {
const requiredProviders: MarketProviderId[] = ["wordstat", "serp"]; const requiredProviders: MarketProviderId[] = ["wordstat", "serp"];
const evidenceStatus = getEvidenceStatus(providers, requiredProviders); const evidenceStatus = ontologyReady ? getEvidenceStatus(providers, requiredProviders) : "not_collected";
return { return {
path: brief.path, path: brief.path,
clusterIds: brief.clusters.map((cluster) => cluster.clusterId),
priority: brief.priority, priority: brief.priority,
action: brief.action, action: brief.action,
qualityScore: brief.qualityContract?.score ?? null, qualityScore: brief.qualityContract?.score ?? null,
@ -248,7 +345,8 @@ function buildBriefEvidence(analysis: SemanticAnalysisRun, providers: MarketProv
"Wordstat частотность и сезонность", "Wordstat частотность и сезонность",
"SERP-конкуренты и типы страниц", "SERP-конкуренты и типы страниц",
"Региональная выдача" "Региональная выдача"
] ],
projectOntologyVersionId: projectOntologyVersion?.id ?? null
}; };
}); });
} }
@ -257,12 +355,17 @@ function getEnrichmentState(
analysis: SemanticAnalysisRun | null, analysis: SemanticAnalysisRun | null,
providers: MarketProviderDescriptor[], providers: MarketProviderDescriptor[],
seedCount: number, seedCount: number,
wordstat: WordstatEvidence wordstat: WordstatEvidence,
projectOntologyVersion: MarketProjectOntologyVersion | null
): MarketEnrichmentState { ): MarketEnrichmentState {
if (!analysis) { if (!analysis) {
return "not_ready"; return "not_ready";
} }
if (!isOntologyReadyForMarket(projectOntologyVersion)) {
return "not_ready";
}
if (seedCount === 0) { if (seedCount === 0) {
return "not_ready"; return "not_ready";
} }
@ -284,21 +387,45 @@ function getEnrichmentState(
function buildNextActions(contract: Omit<MarketEnrichmentContract, "nextActions">) { function buildNextActions(contract: Omit<MarketEnrichmentContract, "nextActions">) {
const actions: string[] = []; const actions: string[] = [];
const marketSignalCount =
(contract.wordstat.summary.exactFrequencySeedCount ?? 0) + (contract.wordstat.summary.relatedOnlySeedCount ?? 0);
if (!contract.semanticRunId) { if (!contract.semanticRunId) {
actions.push("Сначала запустить semantic analysis: без seed candidates market layer не стартует."); actions.push("Сначала запустить semantic analysis: без seed candidates market layer не стартует.");
return actions; 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 wordstat = contract.providers.find((provider) => provider.id === "wordstat");
const serp = contract.providers.find((provider) => provider.id === "serp"); const serp = contract.providers.find((provider) => provider.id === "serp");
if (wordstat?.status !== "connected") { if (wordstat?.status !== "connected") {
actions.push("Настроить платформенный Wordstat provider: MCP-KV endpoint или прямой API adapter на backend."); actions.push("Настроить платформенный Wordstat provider: MCP-KV endpoint или прямой API adapter на backend.");
} else if (contract.wordstat.latestJob?.status === "done") { } else if (contract.wordstat.latestJob?.status === "done") {
if (contract.wordstat.summary.collectedSeedCount < contract.readiness.seedCount) {
actions.push( actions.push(
`Wordstat evidence собрано: ${contract.wordstat.summary.collectedSeedCount} seed-фраз, ${contract.wordstat.summary.resultCount} rows. Следующий слой — keywordService cleaning.` `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") { } else if (contract.wordstat.latestJob?.status === "failed") {
actions.push("Проверить Wordstat provider: последний job завершился ошибкой, raw/error сохранены в dev-mode contract."); actions.push("Проверить Wordstat provider: последний job завершился ошибкой, raw/error сохранены в dev-mode contract.");
} else if (contract.readiness.seedCount > 0) { } else if (contract.readiness.seedCount > 0) {
@ -320,23 +447,33 @@ function buildNextActions(contract: Omit<MarketEnrichmentContract, "nextActions"
export async function getMarketEnrichmentContract(projectId: string): Promise<MarketEnrichmentContract> { export async function getMarketEnrichmentContract(projectId: string): Promise<MarketEnrichmentContract> {
const analysis = await getLatestSemanticAnalysis(projectId); const analysis = await getLatestSemanticAnalysis(projectId);
const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis));
const providers = await buildProviderRegistry(); const providers = await buildProviderRegistry();
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null); const normalization = await getSeoNormalizationContract(projectId);
const seedQueue = analysis ? buildSeedQueue(analysis, providers, wordstat) : []; const approvedWordstatPhrases = normalization.seedStrategy
const briefEvidence = analysis ? buildBriefEvidence(analysis, providers) : []; .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 configuredProviderCount = providers.filter((provider) => provider.status === "connected").length;
const ontologyReadyForMarket = isOntologyReadyForMarket(projectOntologyVersion);
const contractWithoutActions: Omit<MarketEnrichmentContract, "nextActions"> = { const contractWithoutActions: Omit<MarketEnrichmentContract, "nextActions"> = {
schemaVersion: "market-enrichment.v1", schemaVersion: "market-enrichment.v1",
projectId, projectId,
semanticRunId: analysis?.runId ?? null, semanticRunId: analysis?.runId ?? null,
projectOntologyVersion,
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat), state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat, projectOntologyVersion),
normalization,
readiness: { readiness: {
canCollectWordstat: canCollectWordstat:
ontologyReadyForMarket &&
getProviderStatus(providers, "wordstat") === "connected" && getProviderStatus(providers, "wordstat") === "connected" &&
seedQueue.length > 0 && seedQueue.length > 0 &&
wordstat.latestJob?.status !== "running", 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, seedCount: seedQueue.length,
briefCount: briefEvidence.length, briefCount: briefEvidence.length,
configuredProviderCount, configuredProviderCount,
@ -356,14 +493,32 @@ export async function getMarketEnrichmentContract(projectId: string): Promise<Ma
export async function runMarketEnrichment(projectId: string): Promise<MarketEnrichmentContract> { export async function runMarketEnrichment(projectId: string): Promise<MarketEnrichmentContract> {
const analysis = await getLatestSemanticAnalysis(projectId); const analysis = await getLatestSemanticAnalysis(projectId);
const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis));
const providers = await buildProviderRegistry(); 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); return getMarketEnrichmentContract(projectId);
} }
try { try {
await collectWordstatEvidence(projectId, analysis); await collectWordstatEvidence(projectId, analysis, normalizedSeeds);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }

View File

@ -17,6 +17,16 @@ type SeedRow = {
source: "detected" | "ontology" | string; 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 = { type WordstatJobRow = {
id: string; id: string;
project_id: string; project_id: string;
@ -67,6 +77,7 @@ export type WordstatEvidence = {
updatedAt: string; updatedAt: string;
} | null; } | null;
seedResults: Array<{ seedResults: Array<{
id: string;
seedId: string | null; seedId: string | null;
phrase: string; phrase: string;
sourcePhrase: string | null; sourcePhrase: string | null;
@ -77,6 +88,7 @@ export type WordstatEvidence = {
collectedAt: string; collectedAt: string;
}>; }>;
topResults: Array<{ topResults: Array<{
id: string;
phrase: string; phrase: string;
sourcePhrase: string | null; sourcePhrase: string | null;
frequency: number | null; frequency: number | null;
@ -310,6 +322,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
return { return {
latestJob: job ? mapJob(job) : null, latestJob: job ? mapJob(job) : null,
seedResults: seedRows.map((result) => ({ seedResults: seedRows.map((result) => ({
id: result.id,
seedId: result.seed_id, seedId: result.seed_id,
phrase: result.phrase, phrase: result.phrase,
sourcePhrase: result.source_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)) .sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))
.slice(0, 20) .slice(0, 20)
.map((result) => ({ .map((result) => ({
id: result.id,
phrase: result.phrase, phrase: result.phrase,
sourcePhrase: result.source_phrase, sourcePhrase: result.source_phrase,
frequency: result.frequency, 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[] = []; 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>( const inserted = await pool.query<SeedRow>(
` `
insert into seeds ( insert into seeds (
@ -376,7 +390,7 @@ async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun)
seed.phrase, seed.phrase,
seed.source, seed.source,
seed.reason, 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.clusterId,
seed.clusterTitle, seed.clusterTitle,
seed.priority 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 provider = await createWordstatProvider();
const status = provider.getStatus(); const status = provider.getStatus();
@ -603,7 +621,7 @@ export async function collectWordstatEvidence(projectId: string, analysis: Seman
throw new WordstatProviderUnavailableError(status.message); throw new WordstatProviderUnavailableError(status.message);
} }
const seedRows = await ensureSeedRows(projectId, analysis); const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
const seeds = seedRows.map(toWordstatSeed); const seeds = seedRows.map(toWordstatSeed);
const region = "ru"; const region = "ru";
const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds); 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); 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) { if (!semanticRunId) {
return buildEvidence(null, []); return buildEvidence(null, []);
} }
@ -689,5 +711,22 @@ export async function getLatestWordstatEvidence(projectId: string, semanticRunId
[latestJob.id] [latestJob.id]
); );
if (!scopePhrases || scopePhrases.length === 0) {
return buildEvidence(latestJob, results.rows); 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);
} }

View File

@ -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."
]
};
}

View File

@ -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
});
}

View File

@ -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;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -1,5 +1,5 @@
import { Router } from "express"; import { Router } from "express";
import type { Response } from "express"; import type { Request, Response } from "express";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { z } from "zod"; import { z } from "zod";
import { import {
@ -31,7 +31,69 @@ import {
runSemanticAnalysis, runSemanticAnalysis,
SemanticAnalysisError SemanticAnalysisError
} from "../analysis/semanticAnalysis.js"; } from "../analysis/semanticAnalysis.js";
import {
getLatestSeoContextReview,
runSeoContextReviewDraft,
saveSeoContextReviewManual,
type SeoContextReviewOutput
} from "../contextReview/seoContextReview.js";
import { getMarketEnrichmentContract, runMarketEnrichment } from "../market/marketEnrichment.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"; import { storageProvider } from "../storage/index.js";
export const projectsRouter = Router(); export const projectsRouter = Router();
@ -71,6 +133,80 @@ const updatePageWorkspaceSchema = z.object({
}); });
const semanticExportFormatSchema = z.enum(["csv", "json", "markdown"]).default("json"); 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 scopeLayoutKeySchema = z.string().trim().min(1).max(240);
const scopeLayoutSchema = z.object({ const scopeLayoutSchema = z.object({
version: z.number().int().positive(), 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) { function getScopeLayoutObjectKey(projectId: string, layoutKey: string) {
const digest = crypto.createHash("sha256").update(layoutKey).digest("hex").slice(0, 40); 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) => { projectsRouter.get("/:projectId/market-enrichment/latest", async (request, response) => {
try { try {
const projectId = projectIdSchema.parse(request.params.projectId); 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) => { projectsRouter.post("/:projectId/market-enrichment", async (request, response) => {
try { try {
const projectId = projectIdSchema.parse(request.params.projectId); 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) => { projectsRouter.get("/:projectId/scope-layouts/:layoutKey", async (request, response) => {
try { try {
const projectId = projectIdSchema.parse(request.params.projectId); const projectId = projectIdSchema.parse(request.params.projectId);

View File

@ -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
};
}

View File

@ -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)
};
}

View File

@ -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)
};
}

View File

@ -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.");

View File

@ -112,8 +112,9 @@ const METRICA_BASE_URL = "https://api-metrika.yandex.net";
const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = { const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = {
yandex_cloud: { yandex_cloud: {
id: "yandex_cloud", id: "yandex_cloud",
label: "Yandex Cloud credentials", label: "Yandex AI Studio / Cloud API key",
description: "Один общий credential для Search API слоёв: Wordstat сейчас, SERP позже.", description:
"Один общий folderId и API key для Yandex AI Studio/Yandex Cloud: Search API, Wordstat, WebSearch и будущий model layer используют этот credential по ролям service account.",
implementationStatus: "active", implementationStatus: "active",
defaultMode: "api_key", defaultMode: "api_key",
modes: [ modes: [
@ -122,6 +123,10 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
{ id: "iam_token", label: "IAM token" } { id: "iam_token", label: "IAM token" }
], ],
docs: [ docs: [
{
label: "AI Studio Search API quickstart",
url: "https://yandex.cloud/en/docs/search-api/quickstart"
},
{ {
label: "API keys", label: "API keys",
url: "https://yandex.cloud/en/docs/iam/concepts/authorization/api-key" url: "https://yandex.cloud/en/docs/iam/concepts/authorization/api-key"
@ -134,21 +139,21 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
publicFields: [ publicFields: [
{ {
id: "folderId", id: "folderId",
label: "Yandex Cloud folderId", label: "Yandex AI Studio folderId",
type: "text", type: "text",
required: true, required: true,
placeholder: "b1g...", placeholder: "b1g...",
help: "ID каталога Yandex Cloud. Его используют Wordstat и SERP Search API." help: "ID каталога Yandex Cloud. Его используют Wordstat, SERP/WebSearch и будущие AI Studio вызовы."
} }
], ],
secretFields: [ secretFields: [
{ {
id: "apiKey", id: "apiKey",
label: "API key", label: "Yandex AI Studio API key",
type: "password", type: "password",
required: false, required: false,
placeholder: "Api-Key ...", placeholder: "AQVN...",
help: "Yandex Cloud API key сервисного аккаунта с доступом к Search API." help: "API key сервисного аккаунта. Доступ к Search API/Wordstat/WebSearch определяется ролями и scope этого ключа."
}, },
{ {
id: "iamToken", id: "iamToken",

View File

@ -6,6 +6,7 @@ import {
updateExternalServiceSettings, updateExternalServiceSettings,
type ExternalServiceId type ExternalServiceId
} from "./externalServiceSettings.js"; } from "./externalServiceSettings.js";
import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js";
export const settingsRouter = Router(); export const settingsRouter = Router();
@ -23,6 +24,9 @@ const updateExternalServiceSettingsSchema = z.object({
publicConfig: z.record(z.string(), z.unknown()).optional(), publicConfig: z.record(z.string(), z.unknown()).optional(),
secrets: 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) { function sendError(response: Response, status: number, message: string) {
response.status(status).json({ 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) => { settingsRouter.patch("/external-services/:serviceId", async (request, response) => {
try { try {
const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId; const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId;

View File

@ -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(/&quot;/g, "\"")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/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
};
}

View File

@ -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."
]
};
}

File diff suppressed because it is too large Load Diff

View File

@ -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;
}

View File

@ -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"
};
}

View File

@ -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;
}

View File

@ -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"
};
}