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