Add SEO AI evidence contracts
This commit is contained in:
parent
95eea435ac
commit
6ed1df6803
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -8,6 +8,7 @@
|
|||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"check:ontology-universality": "tsx src/scripts/checkOntologyUniversality.ts",
|
||||
"db:migrate": "tsx src/scripts/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { pool } from "../db/client.js";
|
||||
import {
|
||||
INTENT_SIGNAL_GROUPS,
|
||||
SEO_DOMAIN_PACKAGE_BINDING,
|
||||
getProjectOntology,
|
||||
type IntentSignalGroupId,
|
||||
type LandingOpportunityPageType,
|
||||
type ProjectOntologyEvidence,
|
||||
type ProjectOntology,
|
||||
type SemanticClusterDefinition
|
||||
} from "../ontology/projectOntology.js";
|
||||
import {
|
||||
linkProjectOntologyVersionToRun,
|
||||
saveProjectOntologyVersion
|
||||
} from "../ontology/projectOntologyRepository.js";
|
||||
import { storageProvider } from "../storage/index.js";
|
||||
|
||||
type LatestScanRow = {
|
||||
|
|
@ -84,44 +98,6 @@ type AnalysisVerdict = {
|
|||
nextValidationStep: string;
|
||||
};
|
||||
|
||||
type IntentSignalGroupId =
|
||||
| "agents"
|
||||
| "ai"
|
||||
| "automation"
|
||||
| "business"
|
||||
| "commercial"
|
||||
| "data"
|
||||
| "development"
|
||||
| "engineering"
|
||||
| "enterprise"
|
||||
| "implementation"
|
||||
| "integrations"
|
||||
| "operations"
|
||||
| "process"
|
||||
| "security";
|
||||
|
||||
type SemanticClusterDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
targetIntent: string;
|
||||
intentGroups: IntentSignalGroupId[];
|
||||
keywords: string[];
|
||||
queryExamples: string[];
|
||||
targetPages: string[];
|
||||
};
|
||||
|
||||
type LandingOpportunityPageType =
|
||||
| "conversion"
|
||||
| "deployment"
|
||||
| "docs"
|
||||
| "home"
|
||||
| "integration"
|
||||
| "landing"
|
||||
| "module"
|
||||
| "security"
|
||||
| "use_case";
|
||||
|
||||
type LegacyMarker = {
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
@ -211,6 +187,25 @@ export type SemanticAnalysisOutput = {
|
|||
ctaPhrases: string[];
|
||||
toneSignals: string[];
|
||||
};
|
||||
projectOntology: {
|
||||
id: string;
|
||||
versionId: string | null;
|
||||
version: number | null;
|
||||
source: ProjectOntology["source"];
|
||||
brandName: string;
|
||||
domainPackage: ProjectOntology["domainPackage"];
|
||||
instance: {
|
||||
schemaVersion: "seo-project-ontology.v1";
|
||||
siteId: string;
|
||||
sourceType: string;
|
||||
scanVersionId: string;
|
||||
approvalStatus: "draft" | "approved" | "rejected" | "needs_review";
|
||||
pageCount: number;
|
||||
sectionCount: number;
|
||||
intentClusterCount: number;
|
||||
evidenceCount: number;
|
||||
} | null;
|
||||
};
|
||||
qualitySignals: AnalysisSignal[];
|
||||
analysisVerdict: AnalysisVerdict;
|
||||
clusters: Array<{
|
||||
|
|
@ -409,273 +404,6 @@ export class SemanticAnalysisError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
const NODEDC_CLUSTERS: SemanticClusterDefinition[] = [
|
||||
{
|
||||
id: "ai_platform_development",
|
||||
title: "AI-платформа для разработки и автоматизации",
|
||||
priority: "high",
|
||||
targetIntent: "Базовый коммерческий интент: платформа, разработка, автоматизация, искусственный интеллект.",
|
||||
intentGroups: ["ai", "automation", "business", "development", "enterprise"],
|
||||
keywords: [
|
||||
"платформа",
|
||||
"разработка",
|
||||
"искусственный интеллект",
|
||||
"ai",
|
||||
"llm",
|
||||
"low-code",
|
||||
"no-code",
|
||||
"автоматизация",
|
||||
"продуктовая разработка"
|
||||
],
|
||||
queryExamples: [
|
||||
"платформа автоматизации бизнеса ai",
|
||||
"ai платформа для разработки",
|
||||
"разработка бизнес приложений с искусственным интеллектом"
|
||||
],
|
||||
targetPages: ["/", "/platform/", "/modules/"]
|
||||
},
|
||||
{
|
||||
id: "business_process_automation",
|
||||
title: "Автоматизация бизнес-процессов",
|
||||
priority: "high",
|
||||
targetIntent: "BPM/workflow рынок: процессы, регламенты, контроль исполнения, сквозные цепочки.",
|
||||
intentGroups: ["automation", "business", "process", "operations", "implementation"],
|
||||
keywords: [
|
||||
"бизнес-процесс",
|
||||
"workflow",
|
||||
"bpm",
|
||||
"регламент",
|
||||
"процесс",
|
||||
"операционный контур",
|
||||
"сквозной процесс",
|
||||
"маршрут согласования"
|
||||
],
|
||||
queryExamples: [
|
||||
"автоматизация бизнес процессов",
|
||||
"workflow система для бизнеса",
|
||||
"bpm платформа с искусственным интеллектом"
|
||||
],
|
||||
targetPages: ["/use-cases/", "/modules/ops/", "/modules/engine/"]
|
||||
},
|
||||
{
|
||||
id: "agentic_automation",
|
||||
title: "Агентная автоматизация и AI-ассистенты",
|
||||
priority: "high",
|
||||
targetIntent: "Трендовый интент: AI agents, ассистенты, автономные проверки и выполнение задач.",
|
||||
intentGroups: ["agents", "ai", "automation", "operations"],
|
||||
keywords: [
|
||||
"агент",
|
||||
"агентная",
|
||||
"ai agent",
|
||||
"ассистент",
|
||||
"codex",
|
||||
"нейросеть",
|
||||
"модель",
|
||||
"автономный",
|
||||
"toolpack"
|
||||
],
|
||||
queryExamples: [
|
||||
"ai агенты для бизнеса",
|
||||
"агентная автоматизация процессов",
|
||||
"ai ассистент для разработки и операций"
|
||||
],
|
||||
targetPages: ["/modules/assistant/", "/modules/ops/", "/docs/"]
|
||||
},
|
||||
{
|
||||
id: "ops_project_control",
|
||||
title: "Ops, Tasker и управление проектами",
|
||||
priority: "medium",
|
||||
targetIntent: "Операционное управление: задачи, чекеры, статусы, проектные карточки.",
|
||||
intentGroups: ["operations", "business", "process", "implementation"],
|
||||
keywords: [
|
||||
"ops",
|
||||
"tasker",
|
||||
"задача",
|
||||
"проект",
|
||||
"проверка",
|
||||
"чекер",
|
||||
"статус",
|
||||
"исполнитель",
|
||||
"операционный"
|
||||
],
|
||||
queryExamples: [
|
||||
"ai ops управление задачами",
|
||||
"система контроля проектов с ai",
|
||||
"автоматизация проектного офиса"
|
||||
],
|
||||
targetPages: ["/modules/ops/", "/use-cases/project-office/"]
|
||||
},
|
||||
{
|
||||
id: "hub_workspace_rbac",
|
||||
title: "Hub, workspace, роли и доступы",
|
||||
priority: "medium",
|
||||
targetIntent: "Рабочие пространства, запуск модулей, роли, витрина приложений.",
|
||||
intentGroups: ["business", "enterprise", "operations", "security"],
|
||||
keywords: [
|
||||
"hub",
|
||||
"workspace",
|
||||
"рабочее пространство",
|
||||
"роль",
|
||||
"rbac",
|
||||
"доступ",
|
||||
"витрина",
|
||||
"launcher",
|
||||
"команда"
|
||||
],
|
||||
queryExamples: [
|
||||
"ai workspace для команды",
|
||||
"hub платформа бизнес приложений",
|
||||
"rbac для ai платформы"
|
||||
],
|
||||
targetPages: ["/modules/hub/", "/security/"]
|
||||
},
|
||||
{
|
||||
id: "engine_infrastructure",
|
||||
title: "Engine, инфраструктура и оркестрация",
|
||||
priority: "medium",
|
||||
targetIntent: "Технический слой: engine, docker, deploy, сервисы, mcp, контуры.",
|
||||
intentGroups: ["development", "implementation", "enterprise", "integrations"],
|
||||
keywords: [
|
||||
"engine",
|
||||
"оркестрация",
|
||||
"инфраструктура",
|
||||
"docker",
|
||||
"deploy",
|
||||
"mcp",
|
||||
"сервис",
|
||||
"pipeline",
|
||||
"контур"
|
||||
],
|
||||
queryExamples: [
|
||||
"оркестрация ai сервисов",
|
||||
"инфраструктура для ai автоматизации",
|
||||
"mcp интеграции для бизнеса"
|
||||
],
|
||||
targetPages: ["/modules/engine/", "/deployment/", "/docs/"]
|
||||
},
|
||||
{
|
||||
id: "integrations_api",
|
||||
title: "Интеграции, API, 1C, CRM/ERP",
|
||||
priority: "high",
|
||||
targetIntent: "Коммерческий интент интеграций: 1С, CRM, ERP, API, webhooks.",
|
||||
intentGroups: ["integrations", "business", "automation", "implementation"],
|
||||
keywords: [
|
||||
"интеграция",
|
||||
"api",
|
||||
"webhook",
|
||||
"1с",
|
||||
"1c",
|
||||
"crm",
|
||||
"erp",
|
||||
"битрикс",
|
||||
"telegram",
|
||||
"внешняя система"
|
||||
],
|
||||
queryExamples: [
|
||||
"ai интеграция с 1с",
|
||||
"автоматизация 1с с искусственным интеллектом",
|
||||
"api платформа для интеграции бизнес систем"
|
||||
],
|
||||
targetPages: ["/integrations/", "/modules/1c-assistant/"]
|
||||
},
|
||||
{
|
||||
id: "tender_legal_procurement",
|
||||
title: "Тендеры, закупки и юридические процессы",
|
||||
priority: "medium",
|
||||
targetIntent: "Отраслевые сценарии с высоким value: тендеры, договоры, закупки, юристы.",
|
||||
intentGroups: ["business", "process", "automation", "commercial"],
|
||||
keywords: [
|
||||
"тендер",
|
||||
"закупка",
|
||||
"договор",
|
||||
"юридический",
|
||||
"юрист",
|
||||
"согласование",
|
||||
"коммерческое предложение",
|
||||
"поставщик"
|
||||
],
|
||||
queryExamples: [
|
||||
"ai для тендеров",
|
||||
"автоматизация закупок с ai",
|
||||
"ai ассистент для договоров"
|
||||
],
|
||||
targetPages: ["/modules/tender/", "/use-cases/procurement/", "/use-cases/legal/"]
|
||||
},
|
||||
{
|
||||
id: "digital_twin_3d_data",
|
||||
title: "Digital Twin, BIM, 3D и инженерные данные",
|
||||
priority: "medium",
|
||||
targetIntent: "Инженерный рынок: цифровые двойники, BIM, STEP/STL/3DGS/LAS, облака точек.",
|
||||
intentGroups: ["engineering", "data", "ai", "enterprise"],
|
||||
keywords: [
|
||||
"digital twin",
|
||||
"цифровой двойник",
|
||||
"bim",
|
||||
"step",
|
||||
"stl",
|
||||
"3dgs",
|
||||
"las",
|
||||
"облако точек",
|
||||
"инженерный",
|
||||
"модель объекта"
|
||||
],
|
||||
queryExamples: [
|
||||
"ai digital twin платформа",
|
||||
"цифровой двойник bim ai",
|
||||
"автоматизация инженерных данных"
|
||||
],
|
||||
targetPages: ["/modules/digital-twin/", "/use-cases/construction/"]
|
||||
},
|
||||
{
|
||||
id: "private_cloud_security",
|
||||
title: "Private cloud, on-prem и безопасность",
|
||||
priority: "high",
|
||||
targetIntent: "B2B enterprise интент: данные, контур, безопасность, развертывание.",
|
||||
intentGroups: ["enterprise", "security", "data", "implementation"],
|
||||
keywords: [
|
||||
"on-prem",
|
||||
"private cloud",
|
||||
"частное облако",
|
||||
"безопасность",
|
||||
"данные",
|
||||
"контур",
|
||||
"сервер",
|
||||
"инфраструктура заказчика",
|
||||
"изоляция"
|
||||
],
|
||||
queryExamples: [
|
||||
"ai платформа on premise",
|
||||
"частное облако для ai автоматизации",
|
||||
"безопасная ai платформа для бизнеса"
|
||||
],
|
||||
targetPages: ["/deployment/", "/security/"]
|
||||
},
|
||||
{
|
||||
id: "conversion_pilot_pricing",
|
||||
title: "Демо, пилот, тарифы и внедрение",
|
||||
priority: "high",
|
||||
targetIntent: "Нижняя часть воронки: запрос демо, пилот, стоимость, внедрение.",
|
||||
intentGroups: ["commercial", "business", "implementation"],
|
||||
keywords: [
|
||||
"демо",
|
||||
"пилот",
|
||||
"тариф",
|
||||
"стоимость",
|
||||
"внедрение",
|
||||
"проект",
|
||||
"заявка",
|
||||
"консультация",
|
||||
"коммерческое предложение"
|
||||
],
|
||||
queryExamples: [
|
||||
"заказать ai автоматизацию бизнеса",
|
||||
"стоимость внедрения ai платформы",
|
||||
"пилот ai автоматизации бизнес процессов"
|
||||
],
|
||||
targetPages: ["/tariffs/", "/pilot/", "/contacts/"]
|
||||
}
|
||||
];
|
||||
|
||||
const LEGACY_MARKERS: LegacyMarker[] = [
|
||||
{
|
||||
id: "webflow",
|
||||
|
|
@ -689,7 +417,7 @@ const LEGACY_MARKERS: LegacyMarker[] = [
|
|||
label: "SS-script",
|
||||
terms: ["ss-script", "ss script", "ss_script", "ssscript"],
|
||||
severity: "blocker",
|
||||
action: "Заменить старое продуктово-техническое упоминание на NodeDC или удалить."
|
||||
action: "Заменить старое продуктово-техническое упоминание на актуальный бренд проекта или удалить."
|
||||
},
|
||||
{
|
||||
id: "federat",
|
||||
|
|
@ -703,7 +431,7 @@ const LEGACY_MARKERS: LegacyMarker[] = [
|
|||
label: "старые docs-ссылки",
|
||||
terms: ["docs.ss", "ss-docs", "old docs", "legacy docs", "docs.federat"],
|
||||
severity: "warning",
|
||||
action: "Проверить ссылку: оставить только актуальные NodeDC docs или убрать."
|
||||
action: "Проверить ссылку: оставить только актуальные docs проекта или убрать."
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -809,23 +537,6 @@ const CTA_PATTERNS = [
|
|||
"заказать"
|
||||
];
|
||||
|
||||
const INTENT_SIGNAL_GROUPS = {
|
||||
agents: ["агент", "агентная", "ai agent", "автономный", "ассистент", "toolpack", "codex"],
|
||||
ai: ["искусственный интеллект", "ai", "ии", "llm", "нейросеть", "модель", "prompt", "генеративный"],
|
||||
automation: ["автоматизация", "автоматизировать", "workflow", "bpm", "регламент", "сквозной", "маршрут"],
|
||||
business: ["бизнес", "компания", "команда", "клиент", "заказчик", "операционный", "подразделение"],
|
||||
commercial: ["демо", "пилот", "стоимость", "тариф", "внедрение", "заявка", "консультация", "коммерческое предложение"],
|
||||
data: ["данные", "база", "хранилище", "интеграция данных", "аналитика", "сущность", "dataset"],
|
||||
development: ["разработка", "приложение", "код", "api", "frontend", "backend", "low-code", "no-code"],
|
||||
engineering: ["bim", "digital twin", "цифровой двойник", "3d", "step", "stl", "las", "инженерный"],
|
||||
enterprise: ["enterprise", "b2b", "корпоративный", "on-prem", "private cloud", "частное облако", "контур"],
|
||||
implementation: ["внедрение", "развертывание", "deploy", "инфраструктура", "сервер", "docker", "production"],
|
||||
integrations: ["интеграция", "api", "webhook", "1с", "crm", "erp", "битрикс", "telegram"],
|
||||
operations: ["ops", "tasker", "задача", "статус", "чекер", "исполнитель", "проектный офис"],
|
||||
process: ["процесс", "бизнес-процесс", "согласование", "закупка", "договор", "тендер", "регламент"],
|
||||
security: ["безопасность", "доступ", "роль", "rbac", "изоляция", "права", "закрытый контур"]
|
||||
} satisfies Record<IntentSignalGroupId, string[]>;
|
||||
|
||||
function toIsoDate(value: Date | null) {
|
||||
return value ? value.toISOString() : null;
|
||||
}
|
||||
|
|
@ -1071,7 +782,7 @@ function buildEvidenceSnippet(document: ContentDocument, terms: string[]) {
|
|||
};
|
||||
}
|
||||
|
||||
function buildStyleProfile(documents: ContentDocument[]): SemanticAnalysisOutput["styleProfile"] {
|
||||
function buildStyleProfile(documents: ContentDocument[], ontology: ProjectOntology): SemanticAnalysisOutput["styleProfile"] {
|
||||
const text = documents.map((document) => document.text).join("\n");
|
||||
const normalizedText = normalizeSearchText(text);
|
||||
const cyrillicCount = (normalizedText.match(/[а-я]/g) ?? []).length;
|
||||
|
|
@ -1096,15 +807,14 @@ function buildStyleProfile(documents: ContentDocument[]): SemanticAnalysisOutput
|
|||
.sort((first, second) => second[1] - first[1] || first[0].localeCompare(second[0], "ru-RU"))
|
||||
.slice(0, 18)
|
||||
.map(([term, count]) => ({ term, count }));
|
||||
const knownBrandTerms = ["nodedc", "node.dc", "node dc", "node.js", "нодедс", "ии", "ai", "ops", "hub", "engine"];
|
||||
const brandTerms = knownBrandTerms.filter((term) => countTermHits(normalizedText, term) > 0);
|
||||
const brandTerms = ontology.brandTerms.filter((term) => countTermHits(normalizedText, term) > 0);
|
||||
const ctaPhrases = CTA_PATTERNS.filter((phrase) => countTermHits(normalizedText, phrase) > 0);
|
||||
const toneSignals = [
|
||||
topTerms.some((item) => ["платформа", "контур", "инфраструктура", "процесс"].includes(item.term))
|
||||
? "инженерно-продуктовый тон"
|
||||
: "",
|
||||
topTerms.some((item) => ["бизнес", "команда", "проект", "задача"].includes(item.term))
|
||||
? "B2B/операционный язык"
|
||||
? "организационно-операционный язык"
|
||||
: "",
|
||||
brandTerms.some((term) => ["ai", "ии"].includes(term)) ? "AI-first лексика" : "",
|
||||
ctaPhrases.length > 0 ? "есть коммерческие CTA" : ""
|
||||
|
|
@ -1299,6 +1009,48 @@ async function buildContentJsonDocument(source: LatestScanRow, sourcePath: strin
|
|||
}
|
||||
}
|
||||
|
||||
function buildProjectOntologyEvidence(
|
||||
projectId: string,
|
||||
scan: LatestScanRow,
|
||||
pages: PageRow[],
|
||||
pageDocuments: ContentDocument[],
|
||||
contentDocuments: ContentDocument[]
|
||||
): ProjectOntologyEvidence {
|
||||
const pageRowsById = new Map(pages.map((page) => [page.page_id, page]));
|
||||
|
||||
return {
|
||||
projectId,
|
||||
scanVersionId: scan.id,
|
||||
sourceId: scan.source_id,
|
||||
projectIndexKey: scan.project_index_key,
|
||||
sourceType: scan.source_type,
|
||||
sourceConfig: scan.source_config,
|
||||
pages: pageDocuments.map((document) => ({
|
||||
id: document.id,
|
||||
kind: document.kind,
|
||||
selected: document.selected,
|
||||
scope: document.scope,
|
||||
usedForCoverage: document.usedForCoverage,
|
||||
title: document.title,
|
||||
sourcePath: document.sourcePath,
|
||||
urlPath: document.urlPath,
|
||||
text: document.text,
|
||||
headings: normalizeHeadings(pageRowsById.get(document.id)?.raw ?? {})
|
||||
})),
|
||||
contentDocuments: contentDocuments.map((document) => ({
|
||||
id: document.id,
|
||||
kind: document.kind,
|
||||
selected: document.selected,
|
||||
scope: document.scope,
|
||||
usedForCoverage: document.usedForCoverage,
|
||||
title: document.title,
|
||||
sourcePath: document.sourcePath,
|
||||
urlPath: document.urlPath,
|
||||
text: document.text
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function getClusterHits(cluster: SemanticClusterDefinition, document: ContentDocument) {
|
||||
const normalizedText = normalizeSearchText(document.text);
|
||||
let hits = 0;
|
||||
|
|
@ -1316,8 +1068,8 @@ function getClusterHits(cluster: SemanticClusterDefinition, document: ContentDoc
|
|||
return { hits, matchedTerms };
|
||||
}
|
||||
|
||||
function analyzeClusters(documents: ContentDocument[]) {
|
||||
return NODEDC_CLUSTERS.map((cluster) => {
|
||||
function analyzeClusters(documents: ContentDocument[], ontology: ProjectOntology) {
|
||||
return ontology.clusters.map((cluster) => {
|
||||
const docHits = documents
|
||||
.map((document) => {
|
||||
const result = getClusterHits(cluster, document);
|
||||
|
|
@ -1513,10 +1265,13 @@ function getEvidenceScore(clusters: ReturnType<typeof analyzeClusters>) {
|
|||
return getWeightedClusterScore(clusters, (cluster) => cluster.evidence.score);
|
||||
}
|
||||
|
||||
function getCommercialReadinessScore(clusters: ReturnType<typeof analyzeClusters>, styleProfile: SemanticAnalysisOutput["styleProfile"]) {
|
||||
const commercialClusters = clusters.filter((cluster) =>
|
||||
["business_process_automation", "integrations_api", "conversion_pilot_pricing", "private_cloud_security"].includes(cluster.id)
|
||||
);
|
||||
function getCommercialReadinessScore(
|
||||
clusters: ReturnType<typeof analyzeClusters>,
|
||||
styleProfile: SemanticAnalysisOutput["styleProfile"],
|
||||
ontology: ProjectOntology
|
||||
) {
|
||||
const commercialClusterIds = new Set(ontology.commercialClusterIds);
|
||||
const commercialClusters = clusters.filter((cluster) => commercialClusterIds.has(cluster.id));
|
||||
const clusterScore = getWeightedClusterScore(commercialClusters.length > 0 ? commercialClusters : clusters, (cluster) => cluster.score);
|
||||
const ctaScore = Math.min(20, styleProfile.ctaPhrases.length * 10);
|
||||
|
||||
|
|
@ -1552,13 +1307,13 @@ function analyzeLegacy(documents: ContentDocument[]) {
|
|||
});
|
||||
}
|
||||
|
||||
function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>) {
|
||||
function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>, ontology: ProjectOntology) {
|
||||
return clusters.flatMap((cluster) => {
|
||||
const source: SemanticAnalysisOutput["seedCandidates"][number]["source"] =
|
||||
cluster.status === "missing" ? "ontology" : "detected";
|
||||
const reason =
|
||||
cluster.status === "missing"
|
||||
? "Кластер важен для NodeDC, но в текущем сайте почти не раскрыт."
|
||||
? `Кластер важен для ${ontology.brandName}, но в текущем сайте почти не раскрыт.`
|
||||
: "Кластер уже найден в контенте и подходит для проверки частотности.";
|
||||
|
||||
return cluster.queryExamples.slice(0, cluster.priority === "high" ? 3 : 2).map((phrase) => ({
|
||||
|
|
@ -1871,82 +1626,34 @@ function trimSeoText(value: string, maxLength: number) {
|
|||
return `${sliced.slice(0, lastSpaceIndex > 40 ? lastSpaceIndex : maxLength - 1).trim()}…`;
|
||||
}
|
||||
|
||||
function getLandingBriefEntity(pagePath: string, pageType: LandingOpportunityPageType) {
|
||||
function getLandingBriefEntity(pagePath: string, pageType: LandingOpportunityPageType, ontology: ProjectOntology) {
|
||||
const normalizedPath = normalizeUrlLikePath(pagePath);
|
||||
const labels: Record<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]) {
|
||||
return labels[normalizedPath];
|
||||
if (ontology.landingBrief.entityLabels[normalizedPath]) {
|
||||
return ontology.landingBrief.entityLabels[normalizedPath];
|
||||
}
|
||||
|
||||
if (pageType === "module") {
|
||||
return "Модуль NodeDC";
|
||||
return ontology.landingBrief.fallbackEntities[pageType] ?? ontology.brandName;
|
||||
}
|
||||
|
||||
if (pageType === "use_case") {
|
||||
return "Сценарий NodeDC";
|
||||
}
|
||||
|
||||
if (pageType === "integration") {
|
||||
return "Интеграция NodeDC";
|
||||
}
|
||||
|
||||
return "NodeDC";
|
||||
}
|
||||
|
||||
function getLandingBriefAudience(pageType: LandingOpportunityPageType) {
|
||||
const audiences: Record<LandingOpportunityPageType, string> = {
|
||||
conversion: "ЛПР, которому нужно быстро понять формат пилота, стоимость и следующий шаг.",
|
||||
deployment: "IT-директор и архитектор, которые оценивают развертывание в корпоративном контуре.",
|
||||
docs: "Техническая команда, которая хочет понять подключение, API, модули и ограничения.",
|
||||
home: "Бизнес и технический ЛПР, который впервые оценивает платформу NodeDC.",
|
||||
integration: "Руководитель автоматизации и интегратор, которым важны 1С, CRM/ERP, API и webhooks.",
|
||||
landing: "ЛПР по цифровизации, которому нужно связать проблему, решение и внедрение.",
|
||||
module: "Владелец процесса и техническая команда, которые выбирают конкретный модуль платформы.",
|
||||
security: "Enterprise/IT/security команда, которая проверяет доступы, контур, данные и риски.",
|
||||
use_case: "Владелец бизнес-процесса, которому нужен понятный сценарий применения NodeDC."
|
||||
};
|
||||
|
||||
return audiences[pageType];
|
||||
function getLandingBriefAudience(pageType: LandingOpportunityPageType, ontology: ProjectOntology) {
|
||||
return ontology.landingBrief.audiences[pageType];
|
||||
}
|
||||
|
||||
function getLandingBriefPrimaryCta(pageType: LandingOpportunityPageType) {
|
||||
if (pageType === "conversion") {
|
||||
return "Запросить расчет пилота";
|
||||
return "Уточнить условия";
|
||||
}
|
||||
|
||||
if (pageType === "docs") {
|
||||
return "Открыть технический контур";
|
||||
return "Открыть инструкцию";
|
||||
}
|
||||
|
||||
if (pageType === "security" || pageType === "deployment") {
|
||||
return "Обсудить развертывание";
|
||||
return "Обсудить запуск";
|
||||
}
|
||||
|
||||
return "Запросить демо";
|
||||
return "Получить консультацию";
|
||||
}
|
||||
|
||||
function getLandingBriefSecondaryCta(pageType: LandingOpportunityPageType) {
|
||||
|
|
@ -1955,73 +1662,22 @@ function getLandingBriefSecondaryCta(pageType: LandingOpportunityPageType) {
|
|||
}
|
||||
|
||||
if (pageType === "module" || pageType === "use_case") {
|
||||
return "Собрать пилотный сценарий";
|
||||
return "Разобрать сценарий";
|
||||
}
|
||||
|
||||
return "Получить консультацию";
|
||||
}
|
||||
|
||||
function getLandingBriefInternalLinks(pagePath: string, pageType: LandingOpportunityPageType) {
|
||||
function getLandingBriefInternalLinks(pagePath: string, pageType: LandingOpportunityPageType, ontology: ProjectOntology) {
|
||||
const normalizedPath = normalizeUrlLikePath(pagePath);
|
||||
const baseLinks = ["/", "/platform/", "/modules/", "/integrations/", "/deployment/", "/security/", "/pilot/", "/contacts/"];
|
||||
const typedLinks: Record<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)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
function getLandingBriefTypeSection(pageType: LandingOpportunityPageType) {
|
||||
const sections: Record<LandingOpportunityPageType, { title: string; goal: string }> = {
|
||||
conversion: {
|
||||
title: "Формат пилота и следующий шаг",
|
||||
goal: "Показать, как начинается проект, что входит в пилот и какие данные нужны для оценки."
|
||||
},
|
||||
deployment: {
|
||||
title: "Архитектура внедрения",
|
||||
goal: "Раскрыть контур, инфраструктуру, этапы развертывания и ответственность сторон."
|
||||
},
|
||||
docs: {
|
||||
title: "Как подключиться",
|
||||
goal: "Дать технический вход: модули, API, интеграции, роли и ограничения."
|
||||
},
|
||||
home: {
|
||||
title: "Платформа и модули",
|
||||
goal: "Связать общий value proposition с конкретными модулями и сценариями."
|
||||
},
|
||||
integration: {
|
||||
title: "Системы и API",
|
||||
goal: "Показать подключение к 1С, CRM/ERP, API, webhooks и внешним контурам."
|
||||
},
|
||||
landing: {
|
||||
title: "Как NodeDC закрывает задачу",
|
||||
goal: "Собрать проблему, решение, результат и следующий коммерческий шаг."
|
||||
},
|
||||
module: {
|
||||
title: "Как работает модуль",
|
||||
goal: "Объяснить входные данные, действия модуля, роли, статусы и результат для бизнеса."
|
||||
},
|
||||
security: {
|
||||
title: "Контур, доступы и данные",
|
||||
goal: "Раскрыть безопасность, роли, RBAC, изоляцию, хранение данных и аудит."
|
||||
},
|
||||
use_case: {
|
||||
title: "Сценарий до и после внедрения",
|
||||
goal: "Показать текущую боль процесса, целевой workflow и измеримый эффект."
|
||||
}
|
||||
};
|
||||
|
||||
return sections[pageType];
|
||||
function getLandingBriefTypeSection(pageType: LandingOpportunityPageType, ontology: ProjectOntology) {
|
||||
return ontology.landingBrief.typeSections[pageType];
|
||||
}
|
||||
|
||||
type LandingBriefDraft = {
|
||||
|
|
@ -2222,7 +1878,7 @@ function buildLandingBriefQualityContract(
|
|||
"Wordstat частотность и сезонность по seed-фразам",
|
||||
"SERP-конкуренты и типы страниц в топе Яндекса",
|
||||
"Региональная выдача и коммерческие маркеры",
|
||||
"Фактические продуктовые доказательства: кейсы, интеграции, SLA, ограничения"
|
||||
"Фактические доказательства: примеры, условия, ограничения и подтверждения"
|
||||
];
|
||||
const rawScore =
|
||||
checks.reduce((sum, check) => sum + getBriefCheckScore(check.status), 0) / Math.max(1, checks.length) -
|
||||
|
|
@ -2260,18 +1916,21 @@ function buildLandingBriefQualityContract(
|
|||
};
|
||||
}
|
||||
|
||||
function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landingPagePlan"]) {
|
||||
function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landingPagePlan"], ontology: ProjectOntology) {
|
||||
return landingPagePlan.map((page) => {
|
||||
const entity = getLandingBriefEntity(page.path, page.pageType);
|
||||
const entity = getLandingBriefEntity(page.path, page.pageType, ontology);
|
||||
const primaryCluster = page.clusters[0];
|
||||
const clusterTitles = page.clusters.map((cluster) => cluster.title);
|
||||
const primaryIntent = primaryCluster?.targetIntent ?? page.reason;
|
||||
const mustUseTerms = Array.from(new Set([...page.seedPhrases.slice(0, 5), ...clusterTitles.slice(0, 4)])).slice(0, 8);
|
||||
const typeSection = getLandingBriefTypeSection(page.pageType);
|
||||
const h1 = trimSeoText(`${entity}: ${primaryCluster?.title ?? "AI-автоматизация для бизнеса"}`, 96);
|
||||
const title = trimSeoText(`${entity} — ${primaryCluster?.title ?? "AI-автоматизация"} | NodeDC`, 72);
|
||||
const typeSection = getLandingBriefTypeSection(page.pageType, ontology);
|
||||
const fallbackTopic = primaryCluster?.title ?? "основное предложение";
|
||||
const clusterSummary = clusterTitles.slice(0, 2).join(", ") || fallbackTopic;
|
||||
const primaryCta = getLandingBriefPrimaryCta(page.pageType);
|
||||
const h1 = trimSeoText(`${entity}: ${fallbackTopic}`, 96);
|
||||
const title = trimSeoText(`${entity} — ${fallbackTopic} | ${ontology.brandName}`, 72);
|
||||
const metaDescription = trimSeoText(
|
||||
`${entity}: ${clusterTitles.slice(0, 2).join(", ")}. Сценарии, интеграции, безопасность и пилот внедрения NodeDC.`,
|
||||
`${entity}: ${clusterSummary}. Что уже есть на сайте, кому это подходит и какой следующий шаг в ${ontology.brandName}.`,
|
||||
158
|
||||
);
|
||||
const sections = [
|
||||
|
|
@ -2290,9 +1949,9 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing
|
|||
mustCoverTerms: mustUseTerms.slice(2, 6)
|
||||
},
|
||||
{
|
||||
title: "Доказательства, внедрение и CTA",
|
||||
goal: "Показать интеграции, контур внедрения, безопасность, формат пилота и основной CTA.",
|
||||
mustCoverTerms: [getLandingBriefPrimaryCta(page.pageType), "пилот", "внедрение"].slice(0, 3)
|
||||
title: "Доказательства и следующий шаг",
|
||||
goal: "Показать подтверждения, условия, ограничения и основной CTA без новых неподтвержденных обещаний.",
|
||||
mustCoverTerms: [primaryCta, ...mustUseTerms.slice(0, 2)].slice(0, 3)
|
||||
}
|
||||
];
|
||||
|
||||
|
|
@ -2326,12 +1985,12 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing
|
|||
},
|
||||
{
|
||||
id: "commercial_block",
|
||||
title: "Коммерческий блок и CTA учтены",
|
||||
title: "Следующий шаг и CTA учтены",
|
||||
done: page.action === "validate",
|
||||
reason:
|
||||
page.action === "validate"
|
||||
? "Страница выглядит готовой к внешней проверке."
|
||||
: "В брифе нужно явно добавить CTA, пилот/демо и путь к заявке."
|
||||
: "В брифе нужно явно добавить следующий шаг и путь к обращению без неподтвержденных обещаний."
|
||||
},
|
||||
{
|
||||
id: "market_validation",
|
||||
|
|
@ -2345,11 +2004,11 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing
|
|||
title,
|
||||
metaDescription,
|
||||
targetIntent: primaryIntent,
|
||||
primaryCta: getLandingBriefPrimaryCta(page.pageType),
|
||||
primaryCta,
|
||||
secondaryCta: getLandingBriefSecondaryCta(page.pageType),
|
||||
mustUseTerms,
|
||||
seedPhrases: page.seedPhrases,
|
||||
internalLinks: getLandingBriefInternalLinks(page.path, page.pageType),
|
||||
internalLinks: getLandingBriefInternalLinks(page.path, page.pageType, ontology),
|
||||
sections,
|
||||
readinessChecklist
|
||||
};
|
||||
|
|
@ -2363,10 +2022,10 @@ function buildLandingPageBriefs(landingPagePlan: SemanticAnalysisOutput["landing
|
|||
title,
|
||||
metaDescription,
|
||||
targetIntent: primaryIntent,
|
||||
audience: getLandingBriefAudience(page.pageType),
|
||||
audience: getLandingBriefAudience(page.pageType, ontology),
|
||||
primaryCta: briefDraft.primaryCta,
|
||||
secondaryCta: briefDraft.secondaryCta,
|
||||
introAngle: `Страница должна связать ${clusterTitles.slice(0, 3).join(", ")} с понятным коммерческим сценарием NodeDC.`,
|
||||
introAngle: `Страница должна связать ${clusterSummary} с понятным следующим шагом ${ontology.brandName}.`,
|
||||
mustUseTerms,
|
||||
seedPhrases: briefDraft.seedPhrases,
|
||||
internalLinks: briefDraft.internalLinks,
|
||||
|
|
@ -2436,9 +2095,21 @@ function buildFallbackLandingPlanForBrief(
|
|||
|
||||
function hydrateLandingBriefQualityContracts(output: SemanticAnalysisOutput): SemanticAnalysisOutput {
|
||||
const planByPath = new Map(output.landingPagePlan.map((page) => [page.path, page]));
|
||||
const runtimeOutput = output as SemanticAnalysisOutput & { projectOntology?: SemanticAnalysisOutput["projectOntology"] };
|
||||
|
||||
return {
|
||||
...output,
|
||||
projectOntology:
|
||||
runtimeOutput.projectOntology ??
|
||||
{
|
||||
id: `project:${output.projectId}:ontology:legacy-output`,
|
||||
versionId: null,
|
||||
version: null,
|
||||
source: "generic_fallback",
|
||||
brandName: "Проект",
|
||||
domainPackage: SEO_DOMAIN_PACKAGE_BINDING,
|
||||
instance: null
|
||||
},
|
||||
landingPageBriefs: output.landingPageBriefs.map((brief) => {
|
||||
const runtimeBrief = brief as typeof brief & { qualityContract?: BriefQualityContract };
|
||||
|
||||
|
|
@ -2570,7 +2241,7 @@ function buildQualitySignals(output: SemanticAnalysisBaseOutput): AnalysisSignal
|
|||
return signals;
|
||||
}
|
||||
|
||||
function buildAnalysisVerdict(output: SemanticAnalysisQualityOutput): AnalysisVerdict {
|
||||
function buildAnalysisVerdict(output: SemanticAnalysisQualityOutput, ontology: ProjectOntology): AnalysisVerdict {
|
||||
const risks: AnalysisVerdict["risks"] = [
|
||||
{
|
||||
id: "local_only",
|
||||
|
|
@ -2663,7 +2334,7 @@ function buildAnalysisVerdict(output: SemanticAnalysisQualityOutput): AnalysisVe
|
|||
"Нет live SERP Яндекса, конкурентов, сниппетов и оценки сложности выдачи.",
|
||||
"Нет данных Yandex Webmaster об индексации, дублях, canonical и ошибках обхода.",
|
||||
"Нет Метрики/Search queries, поэтому нельзя связать семантику с фактическим трафиком и конверсией.",
|
||||
"Онтология NodeDC пока задана правилами внутри модуля, а не обучена на рынке."
|
||||
ontology.analysisLimitNote
|
||||
],
|
||||
externalSignalsNeeded: [
|
||||
"Yandex Wordstat: частотность, регионы, похожие запросы, минус-слова.",
|
||||
|
|
@ -2844,7 +2515,7 @@ async function getScanDataAssets(scanVersionId: string) {
|
|||
return result.rows;
|
||||
}
|
||||
|
||||
async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisOutput | null> {
|
||||
async function buildAnalysisOutput(projectId: string): Promise<{ ontology: ProjectOntology; output: SemanticAnalysisOutput } | null> {
|
||||
const scan = await getLatestDoneScan(projectId);
|
||||
|
||||
if (!scan) {
|
||||
|
|
@ -2858,14 +2529,16 @@ async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisO
|
|||
).filter((document): document is ContentDocument => Boolean(document));
|
||||
const documents = [...pageDocuments, ...contentDocuments];
|
||||
const coverageDocuments = documents.filter((document) => document.usedForCoverage);
|
||||
const ontologyEvidence = buildProjectOntologyEvidence(projectId, scan, pages, pageDocuments, contentDocuments);
|
||||
const ontology = await getProjectOntology(projectId, ontologyEvidence);
|
||||
const pageScope = buildPageScopeSummary(pageDocuments, contentDocuments);
|
||||
const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents);
|
||||
const clusters = analyzeClusters(coverageDocuments);
|
||||
const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents, ontology);
|
||||
const clusters = analyzeClusters(coverageDocuments, ontology);
|
||||
const coverageScore = getCoverageScore(clusters);
|
||||
const lexicalCoverageScore = getLexicalCoverageScore(clusters);
|
||||
const evidenceScore = getEvidenceScore(clusters);
|
||||
const intentCoverageScore = getIntentCoverageScore(clusters);
|
||||
const commercialReadinessScore = getCommercialReadinessScore(clusters, styleProfile);
|
||||
const commercialReadinessScore = getCommercialReadinessScore(clusters, styleProfile, ontology);
|
||||
const legacyFindings = analyzeLegacy(documents);
|
||||
const gaps = clusters
|
||||
.filter((cluster) => cluster.status !== "covered")
|
||||
|
|
@ -2879,10 +2552,10 @@ async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisO
|
|||
targetPages: cluster.targetPages,
|
||||
seedExamples: cluster.queryExamples
|
||||
}));
|
||||
const seedCandidates = buildSeedCandidates(clusters);
|
||||
const seedCandidates = buildSeedCandidates(clusters, ontology);
|
||||
const landingOpportunities = buildLandingOpportunities(clusters, coverageDocuments);
|
||||
const landingPagePlan = buildLandingPagePlan(landingOpportunities, clusters);
|
||||
const landingPageBriefs = buildLandingPageBriefs(landingPagePlan);
|
||||
const landingPageBriefs = buildLandingPageBriefs(landingPagePlan, ontology);
|
||||
const pageClusterMap = new Map(
|
||||
pageDocuments.map((document) => {
|
||||
const matchedClusters = clusters
|
||||
|
|
@ -2929,6 +2602,27 @@ async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisO
|
|||
},
|
||||
pageScope,
|
||||
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,
|
||||
pages: pages.map((page) => {
|
||||
const document = pageDocuments.find((item) => item.id === page.page_id);
|
||||
|
|
@ -2973,22 +2667,39 @@ async function buildAnalysisOutput(projectId: string): Promise<SemanticAnalysisO
|
|||
},
|
||||
qualitySignals
|
||||
} satisfies SemanticAnalysisQualityOutput;
|
||||
const analysisVerdict = buildAnalysisVerdict(outputWithQuality);
|
||||
const analysisVerdict = buildAnalysisVerdict(outputWithQuality, ontology);
|
||||
|
||||
return {
|
||||
ontology,
|
||||
output: {
|
||||
...outputWithQuality,
|
||||
analysisVerdict,
|
||||
nextActions: buildNextActions(outputWithQuality)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSemanticAnalysis(projectId: string) {
|
||||
const output = await buildAnalysisOutput(projectId);
|
||||
const analysisBuild = await buildAnalysisOutput(projectId);
|
||||
|
||||
if (!output) {
|
||||
if (!analysisBuild) {
|
||||
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>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
|
|
@ -3011,6 +2722,10 @@ export async function runSemanticAnalysis(projectId: string) {
|
|||
throw new SemanticAnalysisError("Не удалось сохранить semantic analysis run.");
|
||||
}
|
||||
|
||||
if (ontologyVersion) {
|
||||
await linkProjectOntologyVersionToRun(ontologyVersion.id, row.id);
|
||||
}
|
||||
|
||||
return mapAnalysisRun(row);
|
||||
}
|
||||
|
||||
|
|
@ -3093,9 +2808,23 @@ function markdownList(items: string[]) {
|
|||
return items.map((item) => `- ${item}`).join("\n");
|
||||
}
|
||||
|
||||
function getSemanticExportTitle(analysis: SemanticAnalysisRun) {
|
||||
return analysis.projectOntology.source === "generic_fallback"
|
||||
? "SEO analysis export"
|
||||
: `${analysis.projectOntology.brandName} SEO analysis export`;
|
||||
}
|
||||
|
||||
function getSemanticExportFilePrefix(analysis: SemanticAnalysisRun) {
|
||||
if (analysis.projectOntology.source === "generic_fallback") {
|
||||
return "seo-analysis";
|
||||
}
|
||||
|
||||
return `${normalizeSearchText(analysis.projectOntology.brandName).replace(/[^a-zа-я0-9]+/giu, "-").replace(/^-|-$/g, "") || "seo"}-seo`;
|
||||
}
|
||||
|
||||
function buildSemanticMarkdownExport(analysis: SemanticAnalysisRun) {
|
||||
const lines: string[] = [
|
||||
`# NodeDC SEO export`,
|
||||
`# ${getSemanticExportTitle(analysis)}`,
|
||||
"",
|
||||
`Run: ${analysis.runId}`,
|
||||
`Generated: ${analysis.generatedAt}`,
|
||||
|
|
@ -3105,6 +2834,7 @@ function buildSemanticMarkdownExport(analysis: SemanticAnalysisRun) {
|
|||
`Commercial readiness: ${analysis.summary.commercialReadinessScore}%`,
|
||||
`Landing page plan: ${analysis.landingPagePlan.length}`,
|
||||
`SEO briefs: ${analysis.landingPageBriefs.length}`,
|
||||
`Project ontology: ${analysis.projectOntology.brandName} (${analysis.projectOntology.source}, ${analysis.projectOntology.domainPackage.id}@${analysis.projectOntology.domainPackage.version})`,
|
||||
"",
|
||||
"## Landing page plan",
|
||||
""
|
||||
|
|
@ -3211,7 +2941,7 @@ export async function getSemanticAnalysisExport(projectId: string, format: Seman
|
|||
return null;
|
||||
}
|
||||
|
||||
const fileBaseName = `nodedc-seo-${analysis.runId.slice(0, 8)}`;
|
||||
const fileBaseName = `${getSemanticExportFilePrefix(analysis)}-${analysis.runId.slice(0, 8)}`;
|
||||
|
||||
if (format === "csv") {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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(/"/g, "\"")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function grabXml(value: string, pattern: RegExp) {
|
||||
const match = value.match(pattern);
|
||||
|
||||
return match ? stripXmlTags(match[1] ?? "") : "";
|
||||
}
|
||||
|
||||
function decodeWebSearchRawData(payload: unknown) {
|
||||
const rawData = getString(asRecord(payload).rawData);
|
||||
|
||||
if (!rawData) {
|
||||
return {
|
||||
docs: [],
|
||||
found: {}
|
||||
};
|
||||
}
|
||||
|
||||
const xml = Buffer.from(rawData, "base64").toString("utf8");
|
||||
const found = Object.fromEntries(
|
||||
[...xml.matchAll(/<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");
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,17 @@
|
|||
import { env } from "../config/env.js";
|
||||
import { getLatestSemanticAnalysis, type SemanticAnalysisRun } from "../analysis/semanticAnalysis.js";
|
||||
import { collectWordstatEvidence, getLatestWordstatEvidence, type WordstatEvidence } from "./wordstatRepository.js";
|
||||
import {
|
||||
getLatestProjectOntologyVersion,
|
||||
getProjectOntologyVersion,
|
||||
type ProjectOntologyVersionRecord
|
||||
} from "../ontology/projectOntologyRepository.js";
|
||||
import {
|
||||
collectWordstatEvidence,
|
||||
getLatestWordstatEvidence,
|
||||
type WordstatEvidence,
|
||||
type WordstatSeedCandidate
|
||||
} from "./wordstatRepository.js";
|
||||
import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
||||
import { createWordstatProvider } from "./wordstatProvider.js";
|
||||
|
||||
type MarketProviderId = "model" | "serp" | "text_quality" | "wordstat";
|
||||
|
|
@ -16,6 +27,16 @@ type MarketEvidenceStatus =
|
|||
| "not_configured"
|
||||
| "ready_for_collection";
|
||||
|
||||
type MarketProjectOntologyVersion = {
|
||||
id: string;
|
||||
version: number;
|
||||
approvalStatus: ProjectOntologyVersionRecord["approvalStatus"];
|
||||
brandName: string;
|
||||
schemaVersion: ProjectOntologyVersionRecord["schemaVersion"];
|
||||
domainPackage: ProjectOntologyVersionRecord["domainPackage"];
|
||||
reviewProblemCount: number;
|
||||
};
|
||||
|
||||
type MarketProviderDescriptor = {
|
||||
id: MarketProviderId;
|
||||
label: string;
|
||||
|
|
@ -31,11 +52,14 @@ export type MarketEnrichmentContract = {
|
|||
schemaVersion: "market-enrichment.v1";
|
||||
projectId: string;
|
||||
semanticRunId: string | null;
|
||||
projectOntologyVersion: MarketProjectOntologyVersion | null;
|
||||
generatedAt: string;
|
||||
state: MarketEnrichmentState;
|
||||
normalization: SeoNormalizationContract;
|
||||
readiness: {
|
||||
canCollectWordstat: boolean;
|
||||
canCollectSerp: boolean;
|
||||
ontologyReadyForMarket: boolean;
|
||||
seedCount: number;
|
||||
briefCount: number;
|
||||
configuredProviderCount: number;
|
||||
|
|
@ -48,6 +72,17 @@ export type MarketEnrichmentContract = {
|
|||
clusterTitle: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
source: "detected" | "ontology";
|
||||
normalization: {
|
||||
intentGroupId: string;
|
||||
intentType: SeoNormalizationContract["seedStrategy"][number]["intentType"];
|
||||
marketRole: SeoNormalizationContract["seedStrategy"][number]["marketRole"];
|
||||
normalizedFrom: string[];
|
||||
confidence: number;
|
||||
needsHumanReview: boolean;
|
||||
reviewStatus: SeoNormalizationContract["seedStrategy"][number]["review"]["status"];
|
||||
reviewReason: string;
|
||||
sendToSerp: boolean;
|
||||
} | null;
|
||||
evidenceStatus: MarketEvidenceStatus;
|
||||
targetProviders: MarketProviderId[];
|
||||
blocker: string | null;
|
||||
|
|
@ -55,9 +90,12 @@ export type MarketEnrichmentContract = {
|
|||
frequencyGroup: string | null;
|
||||
relatedCount: number;
|
||||
lastCollectedAt: string | null;
|
||||
projectOntologyVersionId: string | null;
|
||||
wordstatResultId: string | null;
|
||||
}>;
|
||||
briefEvidence: Array<{
|
||||
path: string;
|
||||
clusterIds: string[];
|
||||
priority: "high" | "medium" | "low";
|
||||
action: "create" | "strengthen" | "validate";
|
||||
qualityScore: number | null;
|
||||
|
|
@ -66,6 +104,7 @@ export type MarketEnrichmentContract = {
|
|||
evidenceStatus: MarketEvidenceStatus;
|
||||
requiredProviders: MarketProviderId[];
|
||||
missingEvidence: string[];
|
||||
projectOntologyVersionId: string | null;
|
||||
}>;
|
||||
wordstat: WordstatEvidence;
|
||||
nextActions: string[];
|
||||
|
|
@ -181,6 +220,38 @@ function buildWordstatSeedMap(wordstat: WordstatEvidence) {
|
|||
return new Map(wordstat.seedResults.map((result) => [normalizePhrase(result.phrase), result]));
|
||||
}
|
||||
|
||||
function isOntologyReadyForMarket(ontologyVersion: MarketProjectOntologyVersion | null) {
|
||||
return (
|
||||
Boolean(ontologyVersion) &&
|
||||
(ontologyVersion?.approvalStatus === "approved" || ontologyVersion?.approvalStatus === "needs_review") &&
|
||||
(ontologyVersion?.reviewProblemCount ?? 0) === 0
|
||||
);
|
||||
}
|
||||
|
||||
function mapMarketOntologyVersion(ontologyVersion: ProjectOntologyVersionRecord | null): MarketProjectOntologyVersion | null {
|
||||
if (!ontologyVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
approvalStatus: ontologyVersion.approvalStatus,
|
||||
brandName: ontologyVersion.brandName,
|
||||
domainPackage: ontologyVersion.domainPackage,
|
||||
id: ontologyVersion.id,
|
||||
reviewProblemCount: (ontologyVersion.evidenceSummary.reviewItems ?? []).filter((item) => item.level === "problem").length,
|
||||
schemaVersion: ontologyVersion.schemaVersion,
|
||||
version: ontologyVersion.version
|
||||
};
|
||||
}
|
||||
|
||||
async function getBoundOntologyVersion(projectId: string, analysis: SemanticAnalysisRun | null) {
|
||||
if (!analysis?.projectOntology?.versionId) {
|
||||
return getLatestProjectOntologyVersion(projectId);
|
||||
}
|
||||
|
||||
return getProjectOntologyVersion(projectId, analysis.projectOntology.versionId);
|
||||
}
|
||||
|
||||
function getSeedEvidenceStatus(
|
||||
providers: MarketProviderDescriptor[],
|
||||
wordstat: WordstatEvidence,
|
||||
|
|
@ -198,20 +269,26 @@ function getSeedEvidenceStatus(
|
|||
return "collected_related_only";
|
||||
}
|
||||
|
||||
if (wordstat.latestJob?.status === "done") {
|
||||
if (wordstat.latestJob?.status === "done" && wordstatResult) {
|
||||
return "collected_no_signal";
|
||||
}
|
||||
|
||||
return getEvidenceStatus(providers, ["wordstat"]);
|
||||
}
|
||||
|
||||
function buildSeedQueue(analysis: SemanticAnalysisRun, providers: MarketProviderDescriptor[], wordstat: WordstatEvidence) {
|
||||
function buildSeedQueue(
|
||||
normalization: SeoNormalizationContract,
|
||||
providers: MarketProviderDescriptor[],
|
||||
wordstat: WordstatEvidence,
|
||||
projectOntologyVersion: MarketProjectOntologyVersion | null
|
||||
) {
|
||||
const wordstatResults = buildWordstatSeedMap(wordstat);
|
||||
const ontologyReady = isOntologyReadyForMarket(projectOntologyVersion);
|
||||
|
||||
return analysis.seedCandidates.slice(0, 80).map((seed) => {
|
||||
return normalization.seedStrategy.filter((seed) => seed.review.sendToWordstat).slice(0, 120).map((seed) => {
|
||||
const targetProviders: MarketProviderId[] = ["wordstat"];
|
||||
const result = wordstatResults.get(normalizePhrase(seed.phrase));
|
||||
const evidenceStatus = getSeedEvidenceStatus(providers, wordstat, result);
|
||||
const evidenceStatus = ontologyReady ? getSeedEvidenceStatus(providers, wordstat, result) : "not_collected";
|
||||
|
||||
return {
|
||||
phrase: seed.phrase,
|
||||
|
|
@ -219,24 +296,44 @@ function buildSeedQueue(analysis: SemanticAnalysisRun, providers: MarketProvider
|
|||
clusterTitle: seed.clusterTitle,
|
||||
priority: seed.priority,
|
||||
source: seed.source,
|
||||
normalization: {
|
||||
confidence: seed.confidence,
|
||||
intentGroupId: seed.intentGroupId,
|
||||
intentType: seed.intentType,
|
||||
marketRole: seed.marketRole,
|
||||
needsHumanReview: seed.needsHumanReview,
|
||||
normalizedFrom: seed.normalizedFrom,
|
||||
reviewReason: seed.review.reason,
|
||||
reviewStatus: seed.review.status,
|
||||
sendToSerp: seed.review.sendToSerp
|
||||
},
|
||||
evidenceStatus,
|
||||
targetProviders,
|
||||
blocker: getSeedBlocker(evidenceStatus),
|
||||
frequency: result?.frequency ?? null,
|
||||
frequencyGroup: result?.frequencyGroup ?? null,
|
||||
relatedCount: result?.relatedCount ?? 0,
|
||||
lastCollectedAt: result?.collectedAt ?? null
|
||||
lastCollectedAt: result?.collectedAt ?? null,
|
||||
projectOntologyVersionId: projectOntologyVersion?.id ?? null,
|
||||
wordstatResultId: result?.id ?? null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildBriefEvidence(analysis: SemanticAnalysisRun, providers: MarketProviderDescriptor[]) {
|
||||
function buildBriefEvidence(
|
||||
analysis: SemanticAnalysisRun,
|
||||
providers: MarketProviderDescriptor[],
|
||||
projectOntologyVersion: MarketProjectOntologyVersion | null
|
||||
) {
|
||||
const ontologyReady = isOntologyReadyForMarket(projectOntologyVersion);
|
||||
|
||||
return (analysis.landingPageBriefs ?? []).slice(0, 80).map((brief) => {
|
||||
const requiredProviders: MarketProviderId[] = ["wordstat", "serp"];
|
||||
const evidenceStatus = getEvidenceStatus(providers, requiredProviders);
|
||||
const evidenceStatus = ontologyReady ? getEvidenceStatus(providers, requiredProviders) : "not_collected";
|
||||
|
||||
return {
|
||||
path: brief.path,
|
||||
clusterIds: brief.clusters.map((cluster) => cluster.clusterId),
|
||||
priority: brief.priority,
|
||||
action: brief.action,
|
||||
qualityScore: brief.qualityContract?.score ?? null,
|
||||
|
|
@ -248,7 +345,8 @@ function buildBriefEvidence(analysis: SemanticAnalysisRun, providers: MarketProv
|
|||
"Wordstat частотность и сезонность",
|
||||
"SERP-конкуренты и типы страниц",
|
||||
"Региональная выдача"
|
||||
]
|
||||
],
|
||||
projectOntologyVersionId: projectOntologyVersion?.id ?? null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -257,12 +355,17 @@ function getEnrichmentState(
|
|||
analysis: SemanticAnalysisRun | null,
|
||||
providers: MarketProviderDescriptor[],
|
||||
seedCount: number,
|
||||
wordstat: WordstatEvidence
|
||||
wordstat: WordstatEvidence,
|
||||
projectOntologyVersion: MarketProjectOntologyVersion | null
|
||||
): MarketEnrichmentState {
|
||||
if (!analysis) {
|
||||
return "not_ready";
|
||||
}
|
||||
|
||||
if (!isOntologyReadyForMarket(projectOntologyVersion)) {
|
||||
return "not_ready";
|
||||
}
|
||||
|
||||
if (seedCount === 0) {
|
||||
return "not_ready";
|
||||
}
|
||||
|
|
@ -284,21 +387,45 @@ function getEnrichmentState(
|
|||
|
||||
function buildNextActions(contract: Omit<MarketEnrichmentContract, "nextActions">) {
|
||||
const actions: string[] = [];
|
||||
const marketSignalCount =
|
||||
(contract.wordstat.summary.exactFrequencySeedCount ?? 0) + (contract.wordstat.summary.relatedOnlySeedCount ?? 0);
|
||||
|
||||
if (!contract.semanticRunId) {
|
||||
actions.push("Сначала запустить semantic analysis: без seed candidates market layer не стартует.");
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (!contract.projectOntologyVersion) {
|
||||
actions.push("Сначала сохранить project ontology version из semantic analysis: market evidence должен быть привязан к версии онтологии.");
|
||||
return actions;
|
||||
}
|
||||
|
||||
if (!contract.readiness.ontologyReadyForMarket) {
|
||||
actions.push(
|
||||
`Перевести project ontology v${contract.projectOntologyVersion.version} в needs review или approved и убрать problem review items перед сбором рынка.`
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
const wordstat = contract.providers.find((provider) => provider.id === "wordstat");
|
||||
const serp = contract.providers.find((provider) => provider.id === "serp");
|
||||
|
||||
if (wordstat?.status !== "connected") {
|
||||
actions.push("Настроить платформенный Wordstat provider: MCP-KV endpoint или прямой API adapter на backend.");
|
||||
} else if (contract.wordstat.latestJob?.status === "done") {
|
||||
if (contract.wordstat.summary.collectedSeedCount < contract.readiness.seedCount) {
|
||||
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") {
|
||||
actions.push("Проверить Wordstat provider: последний job завершился ошибкой, raw/error сохранены в dev-mode contract.");
|
||||
} else if (contract.readiness.seedCount > 0) {
|
||||
|
|
@ -320,23 +447,33 @@ function buildNextActions(contract: Omit<MarketEnrichmentContract, "nextActions"
|
|||
|
||||
export async function getMarketEnrichmentContract(projectId: string): Promise<MarketEnrichmentContract> {
|
||||
const analysis = await getLatestSemanticAnalysis(projectId);
|
||||
const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis));
|
||||
const providers = await buildProviderRegistry();
|
||||
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null);
|
||||
const seedQueue = analysis ? buildSeedQueue(analysis, providers, wordstat) : [];
|
||||
const briefEvidence = analysis ? buildBriefEvidence(analysis, providers) : [];
|
||||
const normalization = await getSeoNormalizationContract(projectId);
|
||||
const approvedWordstatPhrases = normalization.seedStrategy
|
||||
.filter((seed) => seed.review.sendToWordstat)
|
||||
.map((seed) => seed.phrase);
|
||||
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null, approvedWordstatPhrases);
|
||||
const seedQueue = analysis ? buildSeedQueue(normalization, providers, wordstat, projectOntologyVersion) : [];
|
||||
const briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : [];
|
||||
const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length;
|
||||
const ontologyReadyForMarket = isOntologyReadyForMarket(projectOntologyVersion);
|
||||
const contractWithoutActions: Omit<MarketEnrichmentContract, "nextActions"> = {
|
||||
schemaVersion: "market-enrichment.v1",
|
||||
projectId,
|
||||
semanticRunId: analysis?.runId ?? null,
|
||||
projectOntologyVersion,
|
||||
generatedAt: new Date().toISOString(),
|
||||
state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat),
|
||||
state: getEnrichmentState(analysis, providers, seedQueue.length, wordstat, projectOntologyVersion),
|
||||
normalization,
|
||||
readiness: {
|
||||
canCollectWordstat:
|
||||
ontologyReadyForMarket &&
|
||||
getProviderStatus(providers, "wordstat") === "connected" &&
|
||||
seedQueue.length > 0 &&
|
||||
wordstat.latestJob?.status !== "running",
|
||||
canCollectSerp: getProviderStatus(providers, "serp") === "connected" && briefEvidence.length > 0,
|
||||
canCollectSerp: ontologyReadyForMarket && getProviderStatus(providers, "serp") === "connected" && briefEvidence.length > 0,
|
||||
ontologyReadyForMarket,
|
||||
seedCount: seedQueue.length,
|
||||
briefCount: briefEvidence.length,
|
||||
configuredProviderCount,
|
||||
|
|
@ -356,14 +493,32 @@ export async function getMarketEnrichmentContract(projectId: string): Promise<Ma
|
|||
|
||||
export async function runMarketEnrichment(projectId: string): Promise<MarketEnrichmentContract> {
|
||||
const analysis = await getLatestSemanticAnalysis(projectId);
|
||||
const projectOntologyVersion = mapMarketOntologyVersion(await getBoundOntologyVersion(projectId, analysis));
|
||||
const providers = await buildProviderRegistry();
|
||||
const normalization = await getSeoNormalizationContract(projectId);
|
||||
const normalizedSeeds: WordstatSeedCandidate[] = normalization.seedStrategy
|
||||
.filter((seed) => seed.review.sendToWordstat)
|
||||
.map((seed) => ({
|
||||
clusterId: seed.clusterId,
|
||||
clusterTitle: seed.clusterTitle,
|
||||
confidence: seed.confidence,
|
||||
phrase: seed.phrase,
|
||||
priority: seed.priority,
|
||||
reason: seed.reason,
|
||||
source: seed.source
|
||||
}));
|
||||
|
||||
if (!analysis || getProviderStatus(providers, "wordstat") !== "connected" || analysis.seedCandidates.length === 0) {
|
||||
if (
|
||||
!analysis ||
|
||||
!isOntologyReadyForMarket(projectOntologyVersion) ||
|
||||
getProviderStatus(providers, "wordstat") !== "connected" ||
|
||||
normalizedSeeds.length === 0
|
||||
) {
|
||||
return getMarketEnrichmentContract(projectId);
|
||||
}
|
||||
|
||||
try {
|
||||
await collectWordstatEvidence(projectId, analysis);
|
||||
await collectWordstatEvidence(projectId, analysis, normalizedSeeds);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ type SeedRow = {
|
|||
source: "detected" | "ontology" | string;
|
||||
};
|
||||
|
||||
export type WordstatSeedCandidate = {
|
||||
phrase: string;
|
||||
clusterId: string;
|
||||
clusterTitle: string;
|
||||
source: "detected" | "ontology";
|
||||
reason: string;
|
||||
priority: "high" | "medium" | "low";
|
||||
confidence?: number;
|
||||
};
|
||||
|
||||
type WordstatJobRow = {
|
||||
id: string;
|
||||
project_id: string;
|
||||
|
|
@ -67,6 +77,7 @@ export type WordstatEvidence = {
|
|||
updatedAt: string;
|
||||
} | null;
|
||||
seedResults: Array<{
|
||||
id: string;
|
||||
seedId: string | null;
|
||||
phrase: string;
|
||||
sourcePhrase: string | null;
|
||||
|
|
@ -77,6 +88,7 @@ export type WordstatEvidence = {
|
|||
collectedAt: string;
|
||||
}>;
|
||||
topResults: Array<{
|
||||
id: string;
|
||||
phrase: string;
|
||||
sourcePhrase: string | null;
|
||||
frequency: number | null;
|
||||
|
|
@ -310,6 +322,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
|
|||
return {
|
||||
latestJob: job ? mapJob(job) : null,
|
||||
seedResults: seedRows.map((result) => ({
|
||||
id: result.id,
|
||||
seedId: result.seed_id,
|
||||
phrase: result.phrase,
|
||||
sourcePhrase: result.source_phrase,
|
||||
|
|
@ -323,6 +336,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
|
|||
.sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))
|
||||
.slice(0, 20)
|
||||
.map((result) => ({
|
||||
id: result.id,
|
||||
phrase: result.phrase,
|
||||
sourcePhrase: result.source_phrase,
|
||||
frequency: result.frequency,
|
||||
|
|
@ -347,10 +361,10 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
|
|||
};
|
||||
}
|
||||
|
||||
async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun) {
|
||||
async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun, seedCandidates: WordstatSeedCandidate[]) {
|
||||
const seedRows: SeedRow[] = [];
|
||||
|
||||
for (const seed of analysis.seedCandidates.slice(0, 80)) {
|
||||
for (const seed of seedCandidates.slice(0, 80)) {
|
||||
const inserted = await pool.query<SeedRow>(
|
||||
`
|
||||
insert into seeds (
|
||||
|
|
@ -376,7 +390,7 @@ async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun)
|
|||
seed.phrase,
|
||||
seed.source,
|
||||
seed.reason,
|
||||
seed.priority === "high" ? 0.85 : seed.priority === "medium" ? 0.65 : 0.45,
|
||||
seed.confidence ?? (seed.priority === "high" ? 0.85 : seed.priority === "medium" ? 0.65 : 0.45),
|
||||
seed.clusterId,
|
||||
seed.clusterTitle,
|
||||
seed.priority
|
||||
|
|
@ -595,7 +609,11 @@ async function updateJobFailed(jobId: string, error: unknown) {
|
|||
);
|
||||
}
|
||||
|
||||
export async function collectWordstatEvidence(projectId: string, analysis: SemanticAnalysisRun) {
|
||||
export async function collectWordstatEvidence(
|
||||
projectId: string,
|
||||
analysis: SemanticAnalysisRun,
|
||||
seedCandidates: WordstatSeedCandidate[] = analysis.seedCandidates
|
||||
) {
|
||||
const provider = await createWordstatProvider();
|
||||
const status = provider.getStatus();
|
||||
|
||||
|
|
@ -603,7 +621,7 @@ export async function collectWordstatEvidence(projectId: string, analysis: Seman
|
|||
throw new WordstatProviderUnavailableError(status.message);
|
||||
}
|
||||
|
||||
const seedRows = await ensureSeedRows(projectId, analysis);
|
||||
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
||||
const seeds = seedRows.map(toWordstatSeed);
|
||||
const region = "ru";
|
||||
const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds);
|
||||
|
|
@ -631,7 +649,11 @@ export async function collectWordstatEvidence(projectId: string, analysis: Seman
|
|||
return getLatestWordstatEvidence(projectId, analysis.runId);
|
||||
}
|
||||
|
||||
export async function getLatestWordstatEvidence(projectId: string, semanticRunId: string | null): Promise<WordstatEvidence> {
|
||||
export async function getLatestWordstatEvidence(
|
||||
projectId: string,
|
||||
semanticRunId: string | null,
|
||||
scopePhrases?: string[]
|
||||
): Promise<WordstatEvidence> {
|
||||
if (!semanticRunId) {
|
||||
return buildEvidence(null, []);
|
||||
}
|
||||
|
|
@ -689,5 +711,22 @@ export async function getLatestWordstatEvidence(projectId: string, semanticRunId
|
|||
[latestJob.id]
|
||||
);
|
||||
|
||||
if (!scopePhrases || scopePhrases.length === 0) {
|
||||
return buildEvidence(latestJob, results.rows);
|
||||
}
|
||||
|
||||
const scopePhraseSet = new Set(scopePhrases.map(normalizePhrase));
|
||||
const scopedResults = results.rows.filter((row) => {
|
||||
const sourcePhrase = row.source_phrase ? normalizePhrase(row.source_phrase) : null;
|
||||
const phrase = normalizePhrase(row.phrase);
|
||||
|
||||
return (sourcePhrase ? scopePhraseSet.has(sourcePhrase) : false) || scopePhraseSet.has(phrase);
|
||||
});
|
||||
const scopedJob: WordstatJobRow = {
|
||||
...latestJob,
|
||||
requested_phrases: latestJob.requested_phrases.filter((phrase) => scopePhraseSet.has(normalizePhrase(phrase))),
|
||||
result_count: scopedResults.length
|
||||
};
|
||||
|
||||
return buildEvidence(scopedJob, scopedResults);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Router } from "express";
|
||||
import type { Response } from "express";
|
||||
import type { Request, Response } from "express";
|
||||
import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
|
|
@ -31,7 +31,69 @@ import {
|
|||
runSemanticAnalysis,
|
||||
SemanticAnalysisError
|
||||
} from "../analysis/semanticAnalysis.js";
|
||||
import {
|
||||
getLatestSeoContextReview,
|
||||
runSeoContextReviewDraft,
|
||||
saveSeoContextReviewManual,
|
||||
type SeoContextReviewOutput
|
||||
} from "../contextReview/seoContextReview.js";
|
||||
import { getMarketEnrichmentContract, runMarketEnrichment } from "../market/marketEnrichment.js";
|
||||
import { getSeoModelContextContract } from "../modelContext/seoModelContext.js";
|
||||
import { getSeoStrategyContract } from "../strategy/seoStrategy.js";
|
||||
import {
|
||||
getLatestStrategySynthesisContract,
|
||||
runStrategySynthesisDraft,
|
||||
saveStrategySynthesisManual,
|
||||
type StrategySynthesisContract
|
||||
} from "../strategy/strategySynthesis.js";
|
||||
import {
|
||||
getLatestStrategyQualityReviewContract,
|
||||
runStrategyQualityReviewDraft,
|
||||
saveStrategyQualityReviewManual,
|
||||
type StrategyQualityReviewContract
|
||||
} from "../strategy/strategyQualityReview.js";
|
||||
import {
|
||||
getLatestYandexEvidenceContract,
|
||||
runYandexEvidenceCollection
|
||||
} from "../evidence/yandexEvidence.js";
|
||||
import {
|
||||
getLatestSerpInterpretationContract,
|
||||
runSerpInterpretationDraft,
|
||||
saveSerpInterpretationManual,
|
||||
type SerpInterpretationContract
|
||||
} from "../evidence/serpInterpretation.js";
|
||||
import {
|
||||
getSeoModelProviderStatusContract,
|
||||
runSeoModelTask
|
||||
} from "../modelProvider/seoModelTaskRuns.js";
|
||||
import {
|
||||
SEO_MODEL_PROVIDER_IDS,
|
||||
SEO_MODEL_TASK_TYPES
|
||||
} from "../modelProvider/modelProviderRegistry.js";
|
||||
import {
|
||||
getSeoNormalizationContract,
|
||||
saveSeoNormalizationManual,
|
||||
type SeoNormalizationContract
|
||||
} from "../normalization/seoNormalization.js";
|
||||
import {
|
||||
getKeywordCleaningContract,
|
||||
saveKeywordCleaningManual,
|
||||
type KeywordCleaningContract
|
||||
} from "../keywords/keywordCleaning.js";
|
||||
import {
|
||||
getLatestProjectOntologyVersion,
|
||||
ProjectOntologyApprovalError,
|
||||
updateProjectOntologyApproval,
|
||||
updateProjectOntologyConfirmations
|
||||
} from "../ontology/projectOntologyRepository.js";
|
||||
import { approveKeywordMapDecisions, getKeywordMapContract } from "../keywords/keywordMap.js";
|
||||
import {
|
||||
applyMaterializationAction,
|
||||
getMaterializationContract,
|
||||
MaterializationError
|
||||
} from "../rewrite/materialization.js";
|
||||
import { getRewriteDiffContract } from "../rewrite/rewriteDiff.js";
|
||||
import { getRewritePlanContract } from "../rewrite/rewritePlan.js";
|
||||
import { storageProvider } from "../storage/index.js";
|
||||
|
||||
export const projectsRouter = Router();
|
||||
|
|
@ -71,6 +133,80 @@ const updatePageWorkspaceSchema = z.object({
|
|||
});
|
||||
|
||||
const semanticExportFormatSchema = z.enum(["csv", "json", "markdown"]).default("json");
|
||||
const ontologyApprovalSchema = z.object({
|
||||
status: z.enum(["approved", "draft", "needs_review", "rejected"])
|
||||
});
|
||||
const ontologyConfirmationsSchema = z
|
||||
.object({
|
||||
aliasesConfirmed: z.boolean().optional(),
|
||||
brandConfirmed: z.boolean().optional()
|
||||
})
|
||||
.refine((value) => typeof value.aliasesConfirmed === "boolean" || typeof value.brandConfirmed === "boolean", {
|
||||
message: "Нужно передать brandConfirmed или aliasesConfirmed."
|
||||
});
|
||||
const keywordMapApproveSchema = z.object({
|
||||
itemIds: z.array(z.string().min(1)).optional()
|
||||
});
|
||||
const contextReviewManualSchema = z.object({
|
||||
output: z.record(z.string(), z.unknown())
|
||||
});
|
||||
const seoNormalizationManualSchema = z.object({
|
||||
output: z.record(z.string(), z.unknown())
|
||||
});
|
||||
const keywordCleaningManualSchema = z.object({
|
||||
output: z.record(z.string(), z.unknown())
|
||||
});
|
||||
const serpInterpretationManualSchema = z.object({
|
||||
output: z.record(z.string(), z.unknown())
|
||||
});
|
||||
const strategySynthesisManualSchema = z.object({
|
||||
output: z.record(z.string(), z.unknown())
|
||||
});
|
||||
const strategyQualityReviewManualSchema = z.object({
|
||||
output: z.record(z.string(), z.unknown())
|
||||
});
|
||||
const modelTaskRunSchema = z
|
||||
.object({
|
||||
providerId: z.enum(SEO_MODEL_PROVIDER_IDS).optional(),
|
||||
taskId: z.string().trim().min(1).optional(),
|
||||
taskType: z.enum(SEO_MODEL_TASK_TYPES).optional()
|
||||
})
|
||||
.refine((value) => Boolean(value.taskId || value.taskType), {
|
||||
message: "Нужно передать taskId или taskType."
|
||||
});
|
||||
const yandexEvidenceRunSchema = z.object({
|
||||
phraseLimit: z.number().int().min(1).max(12).optional()
|
||||
});
|
||||
const materializationActionSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("approve_source_plan"),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
sourcePath: z.string().trim().min(1).max(500),
|
||||
targetPath: z.string().trim().min(1)
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("create_placeholder"),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
targetPath: z.string().trim().min(1),
|
||||
title: z.string().trim().max(160).optional()
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("defer"),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
targetPath: z.string().trim().min(1)
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("link_existing_page"),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
pageId: projectIdSchema,
|
||||
targetPath: z.string().trim().min(1)
|
||||
}),
|
||||
z.object({
|
||||
action: z.literal("reject"),
|
||||
note: z.string().trim().max(1000).optional(),
|
||||
targetPath: z.string().trim().min(1)
|
||||
})
|
||||
]);
|
||||
const scopeLayoutKeySchema = z.string().trim().min(1).max(240);
|
||||
const scopeLayoutSchema = z.object({
|
||||
version: z.number().int().positive(),
|
||||
|
|
@ -105,6 +241,24 @@ function sendError(response: Response, status: number, message: string) {
|
|||
});
|
||||
}
|
||||
|
||||
async function sendSeoModelContext(request: Request, response: Response) {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
aiContext: await getSeoModelContextContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить AI context проекта.");
|
||||
}
|
||||
}
|
||||
|
||||
function getScopeLayoutObjectKey(projectId: string, layoutKey: string) {
|
||||
const digest = crypto.createHash("sha256").update(layoutKey).digest("hex").slice(0, 40);
|
||||
|
||||
|
|
@ -347,6 +501,149 @@ projectsRouter.get("/:projectId/analysis/semantic/export", async (request, respo
|
|||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/context-review/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
contextReview: await getLatestSeoContextReview(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить context review проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/context-review", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const contextReview = await runSeoContextReviewDraft(projectId);
|
||||
|
||||
if (!contextReview) {
|
||||
sendError(response, 404, "Сначала нужен semantic analysis проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
response.status(201).json({
|
||||
contextReview
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось сохранить context review проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/context-review/manual", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = contextReviewManualSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
contextReview: await saveSeoContextReviewManual(projectId, input.output as SeoContextReviewOutput)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь manual context review payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual context review.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/ontology/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
ontology: await getLatestProjectOntologyVersion(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить project ontology.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.patch("/:projectId/ontology/:versionId/approval", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const versionId = projectIdSchema.parse(request.params.versionId);
|
||||
const input = ontologyApprovalSchema.parse(request.body);
|
||||
const ontology = await updateProjectOntologyApproval({
|
||||
approvedBy: "dev-mode",
|
||||
projectId,
|
||||
status: input.status,
|
||||
versionId
|
||||
});
|
||||
|
||||
if (!ontology) {
|
||||
sendError(response, 404, "Project ontology version не найдена.");
|
||||
return;
|
||||
}
|
||||
|
||||
response.json({ ontology });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь статус project ontology.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof ProjectOntologyApprovalError) {
|
||||
sendError(response, 400, error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось обновить approval project ontology.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.patch("/:projectId/ontology/:versionId/confirmations", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const versionId = projectIdSchema.parse(request.params.versionId);
|
||||
const input = ontologyConfirmationsSchema.parse(request.body);
|
||||
const ontology = await updateProjectOntologyConfirmations({
|
||||
aliasesConfirmed: input.aliasesConfirmed,
|
||||
brandConfirmed: input.brandConfirmed,
|
||||
confirmedBy: "dev-mode",
|
||||
projectId,
|
||||
versionId
|
||||
});
|
||||
|
||||
if (!ontology) {
|
||||
sendError(response, 404, "Project ontology version не найдена.");
|
||||
return;
|
||||
}
|
||||
|
||||
response.json({ ontology });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь confirmations project ontology.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось обновить confirmations project ontology.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/market-enrichment/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
|
@ -365,6 +662,120 @@ projectsRouter.get("/:projectId/market-enrichment/latest", async (request, respo
|
|||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/seo-normalization/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
normalization: await getSeoNormalizationContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить SEO normalization проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/seo-normalization/manual", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = seoNormalizationManualSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
normalization: await saveSeoNormalizationManual(projectId, input.output as SeoNormalizationContract)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь manual SEO normalization payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual SEO normalization.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/keyword-cleaning/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
keywordCleaning: await getKeywordCleaningContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить keyword cleaning проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/keyword-cleaning/manual", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = keywordCleaningManualSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
keywordCleaning: await saveKeywordCleaningManual(projectId, input.output as KeywordCleaningContract)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь manual keyword cleaning payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual keyword cleaning.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/ai-context/latest", sendSeoModelContext);
|
||||
projectsRouter.get("/:projectId/mcp-context/latest", sendSeoModelContext);
|
||||
|
||||
projectsRouter.get("/:projectId/model-provider/status", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
modelProvider: await getSeoModelProviderStatusContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить model provider status проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/model-tasks/run", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = modelTaskRunSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(202).json({
|
||||
modelTaskRun: await runSeoModelTask(projectId, input)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь model task run input.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось выполнить model task run.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/market-enrichment", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
|
@ -389,6 +800,355 @@ projectsRouter.post("/:projectId/market-enrichment", async (request, response) =
|
|||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/keyword-map/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
keywordMap: await getKeywordMapContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить keyword map проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/strategy/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
strategy: await getSeoStrategyContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить SEO strategy проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/strategy-synthesis/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
strategySynthesis: await getLatestStrategySynthesisContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить strategy synthesis проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/strategy-synthesis", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const strategySynthesis = await runStrategySynthesisDraft(projectId);
|
||||
|
||||
if (!strategySynthesis) {
|
||||
sendError(response, 404, "Сначала нужны seo-strategy.v1 opportunities и ready SERP interpretation.");
|
||||
return;
|
||||
}
|
||||
|
||||
response.status(201).json({
|
||||
strategySynthesis
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось сохранить strategy synthesis проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/strategy-synthesis/manual", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = strategySynthesisManualSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
strategySynthesis: await saveStrategySynthesisManual(projectId, input.output as StrategySynthesisContract)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь manual strategy synthesis payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual strategy synthesis.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/strategy-quality-review/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
strategyQualityReview: await getLatestStrategyQualityReviewContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить strategy quality review проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/strategy-quality-review", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const strategyQualityReview = await runStrategyQualityReviewDraft(projectId);
|
||||
|
||||
if (!strategyQualityReview) {
|
||||
sendError(response, 404, "Сначала нужен ready seo-strategy-synthesis.v1.");
|
||||
return;
|
||||
}
|
||||
|
||||
response.status(201).json({
|
||||
strategyQualityReview
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось сохранить strategy quality review проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/strategy-quality-review/manual", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = strategyQualityReviewManualSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
strategyQualityReview: await saveStrategyQualityReviewManual(projectId, input.output as StrategyQualityReviewContract)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь manual strategy quality review payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual strategy quality review.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/yandex-evidence/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
yandexEvidence: await getLatestYandexEvidenceContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить Yandex evidence проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/yandex-evidence", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = yandexEvidenceRunSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(202).json({
|
||||
yandexEvidence: await runYandexEvidenceCollection(projectId, input)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры Yandex evidence.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось собрать Yandex evidence проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/serp-interpretation/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
serpInterpretation: await getLatestSerpInterpretationContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить SERP interpretation проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/serp-interpretation", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const serpInterpretation = await runSerpInterpretationDraft(projectId);
|
||||
|
||||
if (!serpInterpretation) {
|
||||
sendError(response, 404, "Сначала нужен ready yandex-evidence.v1 с SERP docs.");
|
||||
return;
|
||||
}
|
||||
|
||||
response.status(201).json({
|
||||
serpInterpretation
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось сохранить SERP interpretation проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/serp-interpretation/manual", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = serpInterpretationManualSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
serpInterpretation: await saveSerpInterpretationManual(projectId, input.output as SerpInterpretationContract)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь manual SERP interpretation payload.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить manual SERP interpretation.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/keyword-map/decisions", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = keywordMapApproveSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(202).json(await approveKeywordMapDecisions(projectId, input));
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь keyword map decisions.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось сохранить keyword map decisions.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/rewrite-plan/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
rewritePlan: await getRewritePlanContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить rewrite plan проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/materialization/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
materialization: await getMaterializationContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить materialization contract проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/rewrite-diff/latest", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
||||
response.json({
|
||||
rewriteDiff: await getRewriteDiffContract(projectId)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось загрузить rewrite diff contract проекта.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.post("/:projectId/materialization/actions", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
const input = materializationActionSchema.parse(request.body);
|
||||
|
||||
response.status(202).json(await applyMaterializationAction(projectId, input));
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь materialization action.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof MaterializationError) {
|
||||
sendError(response, 400, error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось применить materialization action.");
|
||||
}
|
||||
});
|
||||
|
||||
projectsRouter.get("/:projectId/scope-layouts/:layoutKey", async (request, response) => {
|
||||
try {
|
||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
@ -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.");
|
||||
|
|
@ -112,8 +112,9 @@ const METRICA_BASE_URL = "https://api-metrika.yandex.net";
|
|||
const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = {
|
||||
yandex_cloud: {
|
||||
id: "yandex_cloud",
|
||||
label: "Yandex Cloud credentials",
|
||||
description: "Один общий credential для Search API слоёв: Wordstat сейчас, SERP позже.",
|
||||
label: "Yandex AI Studio / Cloud API key",
|
||||
description:
|
||||
"Один общий folderId и API key для Yandex AI Studio/Yandex Cloud: Search API, Wordstat, WebSearch и будущий model layer используют этот credential по ролям service account.",
|
||||
implementationStatus: "active",
|
||||
defaultMode: "api_key",
|
||||
modes: [
|
||||
|
|
@ -122,6 +123,10 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
|
|||
{ id: "iam_token", label: "IAM token" }
|
||||
],
|
||||
docs: [
|
||||
{
|
||||
label: "AI Studio Search API quickstart",
|
||||
url: "https://yandex.cloud/en/docs/search-api/quickstart"
|
||||
},
|
||||
{
|
||||
label: "API keys",
|
||||
url: "https://yandex.cloud/en/docs/iam/concepts/authorization/api-key"
|
||||
|
|
@ -134,21 +139,21 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
|
|||
publicFields: [
|
||||
{
|
||||
id: "folderId",
|
||||
label: "Yandex Cloud folderId",
|
||||
label: "Yandex AI Studio folderId",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: "b1g...",
|
||||
help: "ID каталога Yandex Cloud. Его используют Wordstat и SERP Search API."
|
||||
help: "ID каталога Yandex Cloud. Его используют Wordstat, SERP/WebSearch и будущие AI Studio вызовы."
|
||||
}
|
||||
],
|
||||
secretFields: [
|
||||
{
|
||||
id: "apiKey",
|
||||
label: "API key",
|
||||
label: "Yandex AI Studio API key",
|
||||
type: "password",
|
||||
required: false,
|
||||
placeholder: "Api-Key ...",
|
||||
help: "Yandex Cloud API key сервисного аккаунта с доступом к Search API."
|
||||
placeholder: "AQVN...",
|
||||
help: "API key сервисного аккаунта. Доступ к Search API/Wordstat/WebSearch определяется ролями и scope этого ключа."
|
||||
},
|
||||
{
|
||||
id: "iamToken",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
updateExternalServiceSettings,
|
||||
type ExternalServiceId
|
||||
} from "./externalServiceSettings.js";
|
||||
import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js";
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
|
|
@ -23,6 +24,9 @@ const updateExternalServiceSettingsSchema = z.object({
|
|||
publicConfig: z.record(z.string(), z.unknown()).optional(),
|
||||
secrets: z.record(z.string(), z.unknown()).optional()
|
||||
});
|
||||
const yandexAiStudioProbeSchema = z.object({
|
||||
phrase: z.string().trim().min(2).max(160).optional()
|
||||
});
|
||||
|
||||
function sendError(response: Response, status: number, message: string) {
|
||||
response.status(status).json({
|
||||
|
|
@ -43,6 +47,24 @@ settingsRouter.get("/external-services", async (_request, response) => {
|
|||
}
|
||||
});
|
||||
|
||||
settingsRouter.post("/external-services/yandex_cloud/probe", async (request, response) => {
|
||||
try {
|
||||
const input = yandexAiStudioProbeSchema.parse(request.body ?? {});
|
||||
|
||||
response.json({
|
||||
probe: await runYandexAiStudioProbe(input.phrase)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь probe phrase.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, "Не удалось проверить Yandex AI Studio credential.");
|
||||
}
|
||||
});
|
||||
|
||||
settingsRouter.patch("/external-services/:serviceId", async (request, response) => {
|
||||
try {
|
||||
const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,396 @@
|
|||
import { getWordstatRuntimeConfig } from "./externalServiceSettings.js";
|
||||
|
||||
type ProbeCapability =
|
||||
| {
|
||||
id: "wordstat_top" | "wordstat_regions" | "wordstat_dynamics" | "web_search";
|
||||
label: string;
|
||||
ok: boolean;
|
||||
status: number | null;
|
||||
ms: number;
|
||||
summary: string;
|
||||
sample: unknown;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
export type YandexAiStudioProbeResult = {
|
||||
schemaVersion: "yandex-ai-studio-probe.v1";
|
||||
checkedAt: string;
|
||||
phrase: string;
|
||||
credential:
|
||||
| {
|
||||
configured: false;
|
||||
provider: "disabled" | "mcp_kv";
|
||||
}
|
||||
| {
|
||||
configured: true;
|
||||
provider: "yandex_api";
|
||||
authType: "api_key" | "iam_token";
|
||||
folderId: string;
|
||||
};
|
||||
capabilities: ProbeCapability[];
|
||||
};
|
||||
|
||||
const WEB_SEARCH_ENDPOINT = "https://searchapi.api.cloud.yandex.net/v2/web/search";
|
||||
const DEFAULT_PROBE_PHRASE = "автоматизация бизнес процессов";
|
||||
|
||||
function maskFolderId(folderId: string) {
|
||||
if (folderId.length <= 8) {
|
||||
return "configured";
|
||||
}
|
||||
|
||||
return `${folderId.slice(0, 4)}...${folderId.slice(-4)}`;
|
||||
}
|
||||
|
||||
function sanitizeText(text: string, token: string) {
|
||||
return text.replaceAll(token, "[redacted]").slice(0, 900);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function asArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function getString(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function getNumber(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number(value.replace(/\s+/g, ""));
|
||||
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function stripXmlTags(value: string) {
|
||||
return value
|
||||
.replace(/<hlword>/g, "")
|
||||
.replace(/<\/hlword>/g, "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/"/g, "\"")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function grabXml(value: string, pattern: RegExp) {
|
||||
const match = value.match(pattern);
|
||||
|
||||
return match ? stripXmlTags(match[1] ?? "") : "";
|
||||
}
|
||||
|
||||
function decodeWebSearchRawData(payload: unknown) {
|
||||
const rawData = getString(asRecord(payload).rawData);
|
||||
|
||||
if (!rawData) {
|
||||
return {
|
||||
found: {},
|
||||
docs: []
|
||||
};
|
||||
}
|
||||
|
||||
const xml = Buffer.from(rawData, "base64").toString("utf8");
|
||||
const found = Object.fromEntries(
|
||||
[...xml.matchAll(/<found priority="([^"]+)">([\s\S]*?)<\/found>/g)].map((match) => [
|
||||
match[1],
|
||||
stripXmlTags(match[2] ?? "")
|
||||
])
|
||||
);
|
||||
const docs = [...xml.matchAll(/<doc id="[^"]+">([\s\S]*?)<\/doc>/g)].slice(0, 5).map((match) => {
|
||||
const block = match[1] ?? "";
|
||||
|
||||
return {
|
||||
domain: grabXml(block, /<domain>([\s\S]*?)<\/domain>/),
|
||||
url: grabXml(block, /<url>([\s\S]*?)<\/url>/),
|
||||
title: grabXml(block, /<title>([\s\S]*?)<\/title>/),
|
||||
snippet: grabXml(block, /<passage>([\s\S]*?)<\/passage>/).slice(0, 260),
|
||||
mime: grabXml(block, /<mime-type>([\s\S]*?)<\/mime-type>/),
|
||||
lang: grabXml(block, /<lang>([\s\S]*?)<\/lang>/)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
found,
|
||||
docs
|
||||
};
|
||||
}
|
||||
|
||||
async function postJson(
|
||||
input: {
|
||||
endpoint: string;
|
||||
body: unknown;
|
||||
authorization: string;
|
||||
token: string;
|
||||
}
|
||||
) {
|
||||
const started = Date.now();
|
||||
const response = await fetch(input.endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: input.authorization,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(input.body),
|
||||
signal: AbortSignal.timeout(12_000)
|
||||
});
|
||||
const text = await response.text();
|
||||
let json: unknown = null;
|
||||
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
ms: Date.now() - started,
|
||||
json,
|
||||
text: sanitizeText(text, input.token)
|
||||
};
|
||||
}
|
||||
|
||||
async function runCapability(
|
||||
id: ProbeCapability["id"],
|
||||
label: string,
|
||||
callback: () => Promise<Omit<ProbeCapability, "id" | "label">>
|
||||
): Promise<ProbeCapability> {
|
||||
try {
|
||||
const result = await callback();
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
...result
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
ok: false,
|
||||
status: null,
|
||||
ms: 0,
|
||||
summary: "probe failed before response",
|
||||
sample: null,
|
||||
error: error instanceof Error ? error.message : "Unknown probe error"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getLastCompletedMonthWindow(now = new Date()) {
|
||||
const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0));
|
||||
const start = new Date(Date.UTC(end.getUTCFullYear(), Math.max(0, end.getUTCMonth() - 5), 1));
|
||||
|
||||
return {
|
||||
fromDate: start.toISOString(),
|
||||
toDate: end.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export async function runYandexAiStudioProbe(phraseInput?: string): Promise<YandexAiStudioProbeResult> {
|
||||
const phrase = phraseInput?.trim() || DEFAULT_PROBE_PHRASE;
|
||||
const config = await getWordstatRuntimeConfig();
|
||||
|
||||
if (config.provider !== "yandex_api") {
|
||||
return {
|
||||
schemaVersion: "yandex-ai-studio-probe.v1",
|
||||
checkedAt: new Date().toISOString(),
|
||||
phrase,
|
||||
credential: {
|
||||
configured: false,
|
||||
provider: config.provider
|
||||
},
|
||||
capabilities: []
|
||||
};
|
||||
}
|
||||
|
||||
const authorization = config.authType === "iam_token" ? `Bearer ${config.apiToken}` : `Api-Key ${config.apiToken}`;
|
||||
const wordstatBase = config.apiBaseUrl.replace(/\/+$/, "");
|
||||
const region = config.regionIds[0] ?? "225";
|
||||
const device = config.devices[0] ?? "DEVICE_ALL";
|
||||
const dates = getLastCompletedMonthWindow();
|
||||
|
||||
const capabilities = await Promise.all([
|
||||
runCapability("wordstat_top", "Wordstat top requests", async () => {
|
||||
const response = await postJson({
|
||||
authorization,
|
||||
endpoint: `${wordstatBase}/topRequests`,
|
||||
token: config.apiToken,
|
||||
body: {
|
||||
devices: [device],
|
||||
folderId: config.folderId,
|
||||
numPhrases: 5,
|
||||
phrase,
|
||||
regions: [region]
|
||||
}
|
||||
});
|
||||
const payload = asRecord(response.json);
|
||||
const results = asArray(payload.results).slice(0, 5).map((item) => {
|
||||
const record = asRecord(item);
|
||||
|
||||
return {
|
||||
phrase: getString(record.phrase),
|
||||
count: getNumber(record.count)
|
||||
};
|
||||
});
|
||||
const associations = asArray(payload.associations).slice(0, 5).map((item) => {
|
||||
const record = asRecord(item);
|
||||
|
||||
return {
|
||||
phrase: getString(record.phrase),
|
||||
count: getNumber(record.count)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
ms: response.ms,
|
||||
summary: response.ok
|
||||
? `${getNumber(payload.totalCount) ?? 0} total; ${results.length} top; ${associations.length} associations`
|
||||
: response.text,
|
||||
sample: {
|
||||
totalCount: getNumber(payload.totalCount),
|
||||
results,
|
||||
associations
|
||||
},
|
||||
error: response.ok ? null : response.text
|
||||
};
|
||||
}),
|
||||
runCapability("wordstat_regions", "Wordstat regions", async () => {
|
||||
const response = await postJson({
|
||||
authorization,
|
||||
endpoint: `${wordstatBase}/regions`,
|
||||
token: config.apiToken,
|
||||
body: {
|
||||
devices: [device],
|
||||
folderId: config.folderId,
|
||||
phrase,
|
||||
regions: [region]
|
||||
}
|
||||
});
|
||||
const results = asArray(asRecord(response.json).results)
|
||||
.slice(0, 8)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
|
||||
return {
|
||||
region: getString(record.region),
|
||||
count: getNumber(record.count),
|
||||
share: getNumber(record.share),
|
||||
affinityIndex: getNumber(record.affinityIndex)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
ms: response.ms,
|
||||
summary: response.ok ? `${results.length} region rows sampled` : response.text,
|
||||
sample: {
|
||||
results
|
||||
},
|
||||
error: response.ok ? null : response.text
|
||||
};
|
||||
}),
|
||||
runCapability("wordstat_dynamics", "Wordstat dynamics", async () => {
|
||||
const response = await postJson({
|
||||
authorization,
|
||||
endpoint: `${wordstatBase}/dynamics`,
|
||||
token: config.apiToken,
|
||||
body: {
|
||||
devices: [device],
|
||||
folderId: config.folderId,
|
||||
fromDate: dates.fromDate,
|
||||
period: "PERIOD_MONTHLY",
|
||||
phrase,
|
||||
regions: [region],
|
||||
toDate: dates.toDate
|
||||
}
|
||||
});
|
||||
const results = asArray(asRecord(response.json).results)
|
||||
.slice(0, 8)
|
||||
.map((item) => {
|
||||
const record = asRecord(item);
|
||||
|
||||
return {
|
||||
date: getString(record.date),
|
||||
count: getNumber(record.count),
|
||||
share: getNumber(record.share)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
ms: response.ms,
|
||||
summary: response.ok ? `${results.length} monthly rows sampled` : response.text,
|
||||
sample: {
|
||||
results
|
||||
},
|
||||
error: response.ok ? null : response.text
|
||||
};
|
||||
}),
|
||||
runCapability("web_search", "Web Search SERP", async () => {
|
||||
const response = await postJson({
|
||||
authorization,
|
||||
endpoint: WEB_SEARCH_ENDPOINT,
|
||||
token: config.apiToken,
|
||||
body: {
|
||||
folderId: config.folderId,
|
||||
groupSpec: {
|
||||
docsInGroup: 1,
|
||||
groupMode: "GROUP_MODE_DEEP",
|
||||
groupsOnPage: 5
|
||||
},
|
||||
query: {
|
||||
familyMode: "FAMILY_MODE_NONE",
|
||||
queryText: phrase,
|
||||
searchType: "SEARCH_TYPE_RU"
|
||||
},
|
||||
responseFormat: "FORMAT_XML",
|
||||
sortSpec: {
|
||||
sortMode: "SORT_MODE_BY_RELEVANCE"
|
||||
}
|
||||
}
|
||||
});
|
||||
const parsed = decodeWebSearchRawData(response.json);
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
ms: response.ms,
|
||||
summary: response.ok ? `${parsed.docs.length} SERP docs sampled` : response.text,
|
||||
sample: parsed,
|
||||
error: response.ok ? null : response.text
|
||||
};
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
schemaVersion: "yandex-ai-studio-probe.v1",
|
||||
checkedAt: new Date().toISOString(),
|
||||
phrase,
|
||||
credential: {
|
||||
authType: config.authType,
|
||||
configured: true,
|
||||
folderId: maskFolderId(config.folderId),
|
||||
provider: "yandex_api"
|
||||
},
|
||||
capabilities
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue