From 1ed099140faca5d628141be8112b9aee06a48e34 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 3 Jul 2026 08:43:37 +0300 Subject: [PATCH] feat(seo): wire AI workspace model flow --- seo_mode/seo_mode/.env.example | 24 + seo_mode/seo_mode/app/src/App.tsx | 599 ++++++++++++++++-- seo_mode/seo_mode/app/src/api.ts | 144 ++++- seo_mode/seo_mode/app/src/styles.css | 61 ++ seo_mode/seo_mode/package.json | 1 + seo_mode/seo_mode/server/src/config/env.ts | 33 + .../server/src/keywords/keywordCleaning.ts | 299 ++++++++- .../server/src/keywords/keywordMap.ts | 398 +++++++++--- .../server/src/market/wordstatProvider.ts | 11 +- .../server/src/market/wordstatRepository.ts | 94 ++- .../src/modelContext/seoModelContext.ts | 4 +- .../src/modelProvider/aiWorkspaceBridge.ts | 479 ++++++++++++++ .../modelProvider/aiWorkspaceBridgeConfig.ts | 109 ++++ .../modelProvider/modelProviderRegistry.ts | 46 +- .../src/modelProvider/seoModelTaskRuns.ts | 420 +++++++++++- .../src/normalization/seoNormalization.ts | 41 +- .../src/settings/externalServiceSettings.ts | 72 ++- .../seo_mode/server/src/settings/routes.ts | 48 ++ .../src/settings/seoAnalyticsModelBridge.ts | 320 ++++++++++ .../server/src/strategy/seoStrategy.ts | 16 +- .../src/strategy/strategyQualityReview.ts | 242 ++++++- .../server/src/strategy/strategySynthesis.ts | 188 +++++- 22 files changed, 3422 insertions(+), 227 deletions(-) create mode 100644 seo_mode/seo_mode/server/src/modelProvider/aiWorkspaceBridge.ts create mode 100644 seo_mode/seo_mode/server/src/modelProvider/aiWorkspaceBridgeConfig.ts create mode 100644 seo_mode/seo_mode/server/src/settings/seoAnalyticsModelBridge.ts diff --git a/seo_mode/seo_mode/.env.example b/seo_mode/seo_mode/.env.example index 4a9a418..ff4d590 100644 --- a/seo_mode/seo_mode/.env.example +++ b/seo_mode/seo_mode/.env.example @@ -19,9 +19,33 @@ WORDSTAT_PROVIDER=disabled # WORDSTAT_REGION_IDS=225 # WORDSTAT_DEVICES=DEVICE_ALL # WORDSTAT_NUM_PHRASES=50 +# WORDSTAT_MAX_SEEDS_PER_RUN=40 SERP_PROVIDER=disabled # SERP_API_BASE_URL=https://serp-provider.internal # SERP_API_TOKEN=change-me +# SEO model-provider bridge through AI Workspace Assistant / AI Hub / remote Codex worker. +# Keep disabled until a contract-only worker workspace and explicit owner/profile are configured. +# Profiles: +# - local: local AI Workspace Assistant at 127.0.0.1/localhost. +# - deployed-hub: local SEO backend uses deployed https://ai-hub.nodedc.ru token-gated control-plane proxy. +# - deploy-host: SEO backend runs inside deployed Docker network and talks to http://ai-workspace-assistant:18082. +# - tunnel-local-e2e: explicit tunnel/Tailscale end-to-end test profile. +SEO_AI_WORKSPACE_PROFILE=disabled +# SEO_AI_WORKSPACE_CONTROL_URL=http://127.0.0.1:18082 +# SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru +# SEO_AI_WORKSPACE_CONTROL_TOKEN= +# Legacy aliases accepted: +# SEO_AI_WORKSPACE_ASSISTANT_URL= +# SEO_AI_WORKSPACE_ASSISTANT_TOKEN= +# SEO_AI_WORKSPACE_OWNER_USER_ID= +# SEO_AI_WORKSPACE_OWNER_EMAIL= +# SEO_AI_WORKSPACE_OWNER_ROLE= +# SEO_AI_WORKSPACE_OWNER_GROUPS= +# SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID= +# SEO_AI_WORKSPACE_CONTRACT_ONLY=1 +# SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH=/tmp/nodedc-seo-model-contracts +# SEO_AI_WORKSPACE_TIMEOUT_MS=15000 + VITE_API_BASE_URL=http://localhost:4100 diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index f9399fc..804af4b 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -57,6 +57,7 @@ import { buildPatchArtifact, approveKeywordMapDecisions, copyProject, + createSeoAnalyticsModelSetupCommand, deleteProject, downloadSemanticAnalysisExport, fetchExternalServiceSettings, @@ -83,6 +84,7 @@ import { getPreviewPageUrl, importBrowserFolder, openPageWorkspace, + probeSeoAnalyticsModel, probeYandexAiStudioCredential, renameProject, resetPageWorkspace, @@ -97,6 +99,7 @@ import { runSeoContextReview, runSeoModelTask, saveAnchorReviewDecisions, + saveManualKeywordCleaning, savePageSemanticBlocks, saveContentFieldDraft, savePageWorkspace, @@ -131,6 +134,8 @@ import { type RewriteDiffContract, type RewritePlanContract, type SeoContextReviewRun, + type SeoAnalyticsModelProbe, + type SeoAnalyticsModelSetupCommand, type SeoStrategyContract, type SeoModelProviderStatusContract, type SeoModelTaskRun, @@ -163,13 +168,17 @@ type WorkspaceMode = "auto" | "dev"; type ExternalService = ExternalServiceSettings["services"][number]; type ExternalServiceDraft = ExternalServiceUpdateInput; +function sleep(ms: number) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + const keywordCurationColumns: Array<{ description: string; role: KeywordCurationRole; title: string; }> = [ { - description: "предложенные, спорные или ещё не отнесённые запросы", + description: "не участвуют в стратегии", role: "validate", title: "Неразобранные" }, @@ -4307,6 +4316,18 @@ function SeoKeywordCleaningLane({ ); } +function getKeywordCleaningStage5Candidates(keywordCleaning: KeywordCleaningContract) { + return keywordCleaning.items.filter( + (item) => + item.decision !== "trash" && + item.evidenceStatus === "collected" && + item.frequency !== null && + Boolean(item.targetPath) && + Boolean(item.projectOntologyVersionId) && + Boolean(item.wordstatResultId) + ); +} + function KeywordCurationCard({ item, role @@ -4552,7 +4573,7 @@ function KeywordCurationBoard({
Этап 3 · Распределение ключей - Перетащи запросы между колонками, чтобы поправить модельную сортировку перед стратегией. + Модель распределяет, пользователь правит. Неразобранные не участвуют.
) : null} + {service.id === "seo_analytics_model" ? ( + <> + + + + + ) : null} - + + +
+ + +
+ -
- +
+ | null; + validation: { + ok: boolean; + errors: string[]; + warnings: string[]; + } | null; +}; + +export type SeoModelTaskPersistedResult = { + schemaVersion: "seo-model-persisted-result.v1"; + status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence"; + runType: + | "keyword_cleaning" + | "seo_model_task" + | "seo_normalization" + | "seo_strategy_quality_review" + | "seo_strategy_synthesis"; + runId: string; + sourceModelTaskRunId?: string; + note: string; +}; + export type SeoModelTaskRun = { schemaVersion: "seo-model-task-run.v1"; runId: string; @@ -1137,8 +1178,8 @@ export type SeoModelTaskRun = { inputTokenEstimate: number; outputTokenBudget: number; }; - modelOutput: null; - persistedResult: null; + modelOutput: SeoModelTaskModelOutput | null; + persistedResult: SeoModelTaskPersistedResult | null; nextActions: string[]; }; @@ -2034,14 +2075,27 @@ export type RewriteDiffContract = { export type ExternalServiceSettings = { schemaVersion: "external-service-settings.v1"; services: Array<{ - id: "yandex_cloud" | "yandex_metrica" | "yandex_search_serp" | "yandex_webmaster" | "yandex_wordstat"; + id: + | "seo_analytics_model" + | "yandex_cloud" + | "yandex_metrica" + | "yandex_search_serp" + | "yandex_webmaster" + | "yandex_wordstat"; label: string; description: string; implementationStatus: "active" | "planned"; status: "configured" | "disabled" | "missing_required" | "not_implemented"; enabled: boolean; mode: string; - dependsOn: Array<"yandex_cloud" | "yandex_metrica" | "yandex_search_serp" | "yandex_webmaster" | "yandex_wordstat">; + dependsOn: Array< + | "seo_analytics_model" + | "yandex_cloud" + | "yandex_metrica" + | "yandex_search_serp" + | "yandex_webmaster" + | "yandex_wordstat" + >; modes: Array<{ id: string; label: string; @@ -2112,6 +2166,56 @@ export type YandexAiStudioProbe = { }>; }; +export type SeoAnalyticsModelSetupCommand = { + schemaVersion: "seo-analytics-model-setup-command.v1"; + generatedAt: string; + profile: string; + executor: { + id: string; + name: string; + status: "checking" | "error" | "offline" | "online" | "unknown"; + statusDetail: string; + }; + install: { + command: string; + packageName: string; + packageSpec: string; + gatewayUrl: string; + expiresAt: string | null; + }; + setupCode: { + suffix: string | null; + expiresAt: string | null; + }; +}; + +export type SeoAnalyticsModelProbe = { + schemaVersion: "seo-analytics-model-probe.v1"; + checkedAt: string; + profile: string; + config: { + configured: boolean; + assistantUrl: string | null; + selectedExecutorId: string | null; + contractWorkspacePath: string | null; + errors: string[]; + warnings: string[]; + }; + executor: { + id: string | null; + name: string | null; + status: "checking" | "error" | "not_configured" | "offline" | "online" | "unknown"; + statusDetail: string; + lastSeenAt: string | null; + }; + connection: { + ok: boolean; + status: "connected" | "error" | "missing_config" | "not_configured" | "offline"; + detail: string; + }; + check: Record | null; +}; + export type PageWorkspace = { projectId: string; pageId: string; @@ -3129,6 +3233,38 @@ export async function probeYandexAiStudioCredential(phrase?: string) { return response.json() as Promise<{ probe: YandexAiStudioProbe }>; } +export async function createSeoAnalyticsModelSetupCommand(input: { executorId?: string; port?: number } = {}) { + const response = await fetch(`${apiBaseUrl}/settings/external-services/seo_analytics_model/setup-command`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(input) + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось подготовить Codex bridge setup command: ${response.status}`)); + } + + return response.json() as Promise<{ setupCommand: SeoAnalyticsModelSetupCommand }>; +} + +export async function probeSeoAnalyticsModel(input: { executorId?: string } = {}) { + const response = await fetch(`${apiBaseUrl}/settings/external-services/seo_analytics_model/probe`, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(input) + }); + + if (!response.ok) { + throw new Error(await getErrorMessage(response, `Не удалось проверить Codex bridge: ${response.status}`)); + } + + return response.json() as Promise<{ probe: SeoAnalyticsModelProbe }>; +} + export async function updateProjectPageSelection(projectId: string, pageIds: string[], selected: boolean) { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/selection`, { method: "PATCH", diff --git a/seo_mode/seo_mode/app/src/styles.css b/seo_mode/seo_mode/app/src/styles.css index 9e60193..b84a55a 100644 --- a/seo_mode/seo_mode/app/src/styles.css +++ b/seo_mode/seo_mode/app/src/styles.css @@ -522,6 +522,67 @@ input { font-weight: 780; } +.service-command-panel { + display: grid; + gap: 8px; + padding: 10px; + background: rgba(30, 30, 30, 0.04); + border: 1px solid var(--line); + border-radius: 8px; +} + +.service-command-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.service-command-head strong { + color: var(--ink); + font-size: 12px; + font-weight: 900; +} + +.service-command-head span { + display: inline-flex; + align-items: center; + min-height: 22px; + padding: 0 8px; + color: #70521f; + background: rgba(112, 82, 31, 0.07); + border: 1px solid var(--line); + border-radius: 999px; + font-size: 11px; + font-weight: 900; +} + +.service-command-head span.ok { + color: #2f6b4f; + background: rgba(47, 107, 79, 0.08); +} + +.service-command-panel pre { + max-height: 118px; + margin: 0; + overflow: auto; + padding: 9px; + color: #f5f5f5; + background: #1f2428; + border-radius: 7px; + font-size: 12px; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} + +.service-command-panel small { + color: var(--muted); + font-size: 12px; + font-weight: 680; + line-height: 1.35; +} + .service-card-footer { align-items: center; padding-top: 2px; diff --git a/seo_mode/seo_mode/package.json b/seo_mode/seo_mode/package.json index ca93080..b5ff07a 100644 --- a/seo_mode/seo_mode/package.json +++ b/seo_mode/seo_mode/package.json @@ -16,6 +16,7 @@ "check:semantic-block-runtime": "npm run check:semantic-block-runtime -w server", "check:semantic-blocks": "npm run check:semantic-blocks -w server", "check:source-models": "npm run check:source-models -w server", + "check:ontology-universality": "npm run check:ontology-universality -w server", "typecheck": "npm run typecheck -w server && npm run typecheck -w app", "db:migrate": "npm run db:migrate -w server" }, diff --git a/seo_mode/seo_mode/server/src/config/env.ts b/seo_mode/seo_mode/server/src/config/env.ts index 5834daf..1b2f8e8 100644 --- a/seo_mode/seo_mode/server/src/config/env.ts +++ b/seo_mode/seo_mode/server/src/config/env.ts @@ -25,9 +25,25 @@ const envSchema = z.object({ WORDSTAT_REGION_IDS: z.string().default("225"), WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"), WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50), + WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(40), SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"), SERP_API_BASE_URL: z.string().url().optional(), SERP_API_TOKEN: z.string().optional(), + SEO_AI_WORKSPACE_PROFILE: z + .enum(["disabled", "local", "deployed-hub", "deploy-host", "tunnel-local-e2e"]) + .default("disabled"), + SEO_AI_WORKSPACE_CONTROL_URL: z.string().url().optional(), + SEO_AI_WORKSPACE_CONTROL_TOKEN: z.string().optional(), + SEO_AI_WORKSPACE_ASSISTANT_URL: z.string().url().optional(), + SEO_AI_WORKSPACE_ASSISTANT_TOKEN: z.string().optional(), + SEO_AI_WORKSPACE_OWNER_USER_ID: z.string().optional(), + SEO_AI_WORKSPACE_OWNER_EMAIL: z.string().optional(), + SEO_AI_WORKSPACE_OWNER_ROLE: z.string().optional(), + SEO_AI_WORKSPACE_OWNER_GROUPS: z.string().default(""), + SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID: z.string().optional(), + SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH: z.string().optional(), + SEO_AI_WORKSPACE_CONTRACT_ONLY: booleanFromString.default(false), + SEO_AI_WORKSPACE_TIMEOUT_MS: z.coerce.number().int().positive().max(60_000).default(15_000), PREVIEW_CHROME_PATH: z.string().optional(), PREVIEW_SNAPSHOT_CONCURRENCY: z.coerce.number().int().positive().max(4).default(2), PREVIEW_SNAPSHOT_WAIT_MS: z.coerce.number().int().nonnegative().max(10_000).default(1400) @@ -57,6 +73,7 @@ export const env = { regionIds: parsedEnv.WORDSTAT_REGION_IDS.split(",").map((item) => item.trim()).filter(Boolean), devices: parsedEnv.WORDSTAT_DEVICES.split(",").map((item) => item.trim()).filter(Boolean), numPhrases: parsedEnv.WORDSTAT_NUM_PHRASES, + maxSeedsPerRun: parsedEnv.WORDSTAT_MAX_SEEDS_PER_RUN, hasApiToken: Boolean(parsedEnv.WORDSTAT_API_TOKEN) }, serp: { @@ -65,6 +82,22 @@ export const env = { hasApiToken: Boolean(parsedEnv.SERP_API_TOKEN) } }, + aiWorkspace: { + profile: parsedEnv.SEO_AI_WORKSPACE_PROFILE, + assistantUrl: parsedEnv.SEO_AI_WORKSPACE_CONTROL_URL ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_URL, + assistantToken: parsedEnv.SEO_AI_WORKSPACE_CONTROL_TOKEN ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_TOKEN, + contractOnly: parsedEnv.SEO_AI_WORKSPACE_CONTRACT_ONLY, + contractWorkspacePath: parsedEnv.SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH, + selectedExecutorId: parsedEnv.SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID, + requestTimeoutMs: parsedEnv.SEO_AI_WORKSPACE_TIMEOUT_MS, + owner: { + userId: parsedEnv.SEO_AI_WORKSPACE_OWNER_USER_ID, + email: parsedEnv.SEO_AI_WORKSPACE_OWNER_EMAIL, + role: parsedEnv.SEO_AI_WORKSPACE_OWNER_ROLE, + groups: parsedEnv.SEO_AI_WORKSPACE_OWNER_GROUPS.split(",").map((item) => item.trim()).filter(Boolean) + }, + hasAssistantToken: Boolean(parsedEnv.SEO_AI_WORKSPACE_CONTROL_TOKEN ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_TOKEN) + }, preview: { chromePath: parsedEnv.PREVIEW_CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", snapshotConcurrency: parsedEnv.PREVIEW_SNAPSHOT_CONCURRENCY, diff --git a/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts b/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts index 5388176..0eb5a33 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts @@ -1,10 +1,13 @@ import { pool } from "../db/client.js"; import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js"; -type KeywordCleaningProviderMode = "codex_manual" | "deterministic_fallback"; +type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback"; type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use"; type KeywordCleaningSource = "manual" | "normalization_seed" | "serp" | "wordstat_related" | "wordstat_seed"; +const KEYWORD_CLEANING_DECISIONS: KeywordCleaningDecision[] = ["use", "support", "article", "risky", "trash"]; +const KEYWORD_CLEANING_DECISION_SET = new Set(KEYWORD_CLEANING_DECISIONS); + export type KeywordCleaningModelTaskContract = { taskType: "seo.keyword_cleaning"; schemaVersion: "seo-keyword-cleaning-task.v1"; @@ -26,10 +29,64 @@ export type KeywordCleaningModelTaskContract = { wordstatResultCount: number; wordstatJobStatus: string | null; }; + input: { + approvedAnchors: Array<{ + phrase: string; + clusterId: string; + clusterTitle: string; + intentType: string; + marketRole: string; + priority: string; + confidence: number; + normalizedFrom: string[]; + }>; + landingBriefs: Array<{ + path: string; + action: string; + priority: string; + qualityScore: number | null; + seedPhrases: string[]; + evidenceStatus: string; + }>; + normalizationIntentGroups: Array<{ + id: string; + title: string; + intentType: string; + priority: string; + marketHypothesis: string; + wordstatQueries: string[]; + excludedPhrases: string[]; + }>; + seedQueue: Array<{ + phrase: string; + clusterId: string; + clusterTitle: string; + priority: string; + source: string; + intentType: string | null; + marketRole: string | null; + evidenceStatus: string; + frequency: number | null; + frequencyGroup: string | null; + relatedCount: number; + targetPath: string | null; + projectOntologyVersionId: string | null; + wordstatResultId: string | null; + }>; + wordstatTopResults: Array<{ + id: string; + phrase: string; + sourcePhrase: string | null; + frequency: number | null; + frequencyGroup: string | null; + region: string | null; + }>; + }; outputSchema: { schemaVersion: "keyword-cleaning.v1"; requiredTopLevelKeys: string[]; itemRequiredKeys: string[]; + decisionValues: KeywordCleaningDecision[]; }; decisionPolicy: { useRequiresExactEvidence: boolean; @@ -174,6 +231,36 @@ function unique(values: string[]) { return result; } +function normalizeKeywordCleaningDecision(value: unknown): KeywordCleaningDecision { + const decision = String(value || "").trim(); + + if (KEYWORD_CLEANING_DECISION_SET.has(decision)) { + return decision as KeywordCleaningDecision; + } + + if (decision === "route_keyword_map") { + return "use"; + } + + if (decision === "mark_risky_for_human" || decision === "preserve_unresolved") { + return "risky"; + } + + if (decision === "keep_article_backlog") { + return "article"; + } + + if (decision === "reject_noise") { + return "trash"; + } + + if (decision === "preserve_evidence_refs") { + return "support"; + } + + return "risky"; +} + function getDefaultAnalystReview(): KeywordCleaningContract["analystReview"] { return { notes: [], @@ -392,6 +479,115 @@ function getBaseItems(contract: MarketEnrichmentContract) { return [...seedItems, ...relatedItems]; } +function mergeModelProviderItemsWithMarketEvidence( + items: KeywordCleaningItem[], + market: MarketEnrichmentContract +): KeywordCleaningItem[] { + const baseItems = getBaseItems(market); + const baseById = new Map(baseItems.map((item) => [item.id, item])); + const baseByPhrase = new Map(baseItems.map((item) => [item.normalizedPhrase, item])); + const baseByWordstatResultId = new Map( + baseItems + .filter((item) => item.wordstatResultId) + .map((item) => [item.wordstatResultId as string, item]) + ); + + return items.map((item) => { + const normalizedPhrase = normalizePhrase(item.normalizedPhrase || item.phrase); + const base = + (item.wordstatResultId ? baseByWordstatResultId.get(item.wordstatResultId) : null) ?? + (item.id ? baseById.get(item.id) : null) ?? + baseByPhrase.get(normalizedPhrase) ?? + null; + + return { + ...item, + clusterId: base?.clusterId ?? item.clusterId ?? "model-provider", + clusterTitle: base?.clusterTitle ?? item.clusterTitle ?? item.phrase, + evidenceRefs: unique([ + ...(base?.evidenceRefs ?? []), + ...(Array.isArray(item.evidenceRefs) ? item.evidenceRefs : []) + ]), + evidenceStatus: base?.evidenceStatus ?? item.evidenceStatus ?? "not_collected", + frequency: base?.frequency ?? item.frequency ?? null, + frequencyGroup: base?.frequencyGroup ?? item.frequencyGroup ?? null, + id: base?.id ?? item.id ?? `model:${slugify(item.phrase)}`, + intentType: base?.intentType ?? item.intentType ?? null, + marketRole: base?.marketRole ?? item.marketRole ?? null, + normalizedPhrase: base?.normalizedPhrase ?? normalizedPhrase, + phrase: base?.phrase ?? item.phrase, + priority: base?.priority ?? item.priority ?? "medium", + projectOntologyVersionId: + base?.projectOntologyVersionId ?? item.projectOntologyVersionId ?? market.projectOntologyVersion?.id ?? null, + relatedCount: base?.relatedCount ?? item.relatedCount ?? 0, + seedSource: base?.seedSource ?? item.seedSource ?? "detected", + source: base?.source ?? item.source ?? "manual", + sourcePhrase: base?.sourcePhrase ?? item.sourcePhrase ?? null, + targetPath: base?.targetPath ?? item.targetPath ?? null, + wordstatResultId: base?.wordstatResultId ?? item.wordstatResultId ?? null + }; + }); +} + +function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): KeywordCleaningModelTaskContract["input"] { + const briefPhraseMap = buildBriefPhraseMap(contract); + const briefClusterMap = buildBriefClusterMap(contract); + + return { + approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, 80).map((anchor) => ({ + clusterId: anchor.clusterId, + clusterTitle: anchor.clusterTitle, + confidence: anchor.confidence, + intentType: anchor.intentType, + marketRole: anchor.marketRole, + normalizedFrom: anchor.normalizedFrom, + phrase: anchor.phrase, + priority: anchor.priority + })), + landingBriefs: contract.briefEvidence.slice(0, 40).map((brief) => ({ + action: brief.action, + evidenceStatus: brief.evidenceStatus, + path: brief.path, + priority: brief.priority, + qualityScore: brief.qualityScore, + seedPhrases: brief.seedPhrases.slice(0, 10) + })), + normalizationIntentGroups: contract.normalization.intentGroups.slice(0, 40).map((group) => ({ + excludedPhrases: group.excludedPhrases.slice(0, 12).map((item) => item.phrase), + id: group.id, + intentType: group.intentType, + marketHypothesis: group.marketHypothesis, + priority: group.priority, + title: group.title, + wordstatQueries: group.wordstatQueries.slice(0, 12) + })), + seedQueue: contract.seedQueue.slice(0, 120).map((seed) => ({ + clusterId: seed.clusterId, + clusterTitle: seed.clusterTitle, + evidenceStatus: seed.evidenceStatus, + frequency: seed.frequency, + frequencyGroup: seed.frequencyGroup, + intentType: seed.normalization?.intentType ?? null, + marketRole: seed.normalization?.marketRole ?? null, + phrase: seed.phrase, + priority: seed.priority, + projectOntologyVersionId: seed.projectOntologyVersionId, + relatedCount: seed.relatedCount, + source: seed.source, + targetPath: getTargetPath(seed, briefPhraseMap, briefClusterMap), + wordstatResultId: seed.wordstatResultId + })), + wordstatTopResults: contract.wordstat.topResults.slice(0, 120).map((result) => ({ + frequency: result.frequency, + frequencyGroup: result.frequencyGroup, + id: result.id, + phrase: result.phrase, + region: result.region, + sourcePhrase: result.sourcePhrase + })) + }; +} + function getClassification(item: KeywordCleaningItem, contextTokens: Set) { const blockers: string[] = []; const exactEvidence = item.frequency !== null; @@ -507,7 +703,9 @@ function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningCont return { articleCount: lanes.article.length, exactEvidenceCount: items.filter((item) => item.frequency !== null).length, - keywordMapCandidateCount: lanes.use.length + lanes.support.length, + keywordMapCandidateCount: items.filter( + (item) => item.decision !== "trash" && item.evidenceStatus === "collected" && item.frequency !== null && item.targetPath + ).length, missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length, riskyCount: lanes.risky.length, sourcePhraseCount: items.length, @@ -545,7 +743,9 @@ function buildKeywordCleaningModelTask( wordstatJobStatus: contract.wordstat.latestJob?.status ?? null, wordstatResultCount: contract.wordstat.summary.resultCount }, + input: buildKeywordCleaningModelInput(contract), outputSchema: { + decisionValues: KEYWORD_CLEANING_DECISIONS, itemRequiredKeys: [ "id", "phrase", @@ -562,9 +762,12 @@ function buildKeywordCleaningModelTask( "semanticRunId", "provider", "analystReview", + "source", + "modelTask", "readiness", "lanes", "items", + "summary", "nextActions" ], schemaVersion: "keyword-cleaning.v1" @@ -575,7 +778,7 @@ function buildKeywordCleaningModelTask( schemaVersion: "seo-keyword-cleaning-task.v1", semanticRunId: contract.semanticRunId, stopConditions: [ - "Не отправлять trash/risky в keyword map как approved decision.", + "Не отправлять trash в keyword map. Risky/article exact-фразы можно только предсортировать в Stage 3, но не считать approved без пользователя.", "Не придумывать спрос без Wordstat/SERP evidence.", "Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.", "Не сохранять rewrite/apply decisions." @@ -613,7 +816,7 @@ function buildNextActions(contract: Omit } actions.push( - `В keyword map передавать только use/support: ${contract.readiness.keywordMapCandidateCount} кандидатов с evidence и page binding.` + `В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} exact-кандидатов в 5 колонок; финальные роли фиксирует пользователь.` ); return actions; @@ -704,8 +907,18 @@ function normalizeKeywordCleaningOutput( const items = output.items.map((item) => ({ ...item, - blockers: Array.isArray(item.blockers) ? unique(item.blockers) : [], + blockers: Array.isArray(item.blockers) + ? unique([ + ...item.blockers, + KEYWORD_CLEANING_DECISION_SET.has(String(item.decision || "").trim()) + ? "" + : `model_decision_alias:${String(item.decision || "").trim() || "empty"}` + ].filter(Boolean)) + : KEYWORD_CLEANING_DECISION_SET.has(String(item.decision || "").trim()) + ? [] + : [`model_decision_alias:${String(item.decision || "").trim() || "empty"}`], confidence: Math.max(0, Math.min(1, item.confidence)), + decision: normalizeKeywordCleaningDecision(item.decision), evidenceRefs: Array.isArray(item.evidenceRefs) ? unique(item.evidenceRefs) : [], normalizedPhrase: normalizePhrase(item.normalizedPhrase || item.phrase) })); @@ -720,7 +933,7 @@ function normalizeKeywordCleaningOutput( provider: { message: providerMessage, mode: providerMode, - modelRequired: true + modelRequired: providerMode !== "codex_manual" }, readiness, sourceTaskId: output.modelTask.taskId, @@ -775,7 +988,15 @@ export async function getKeywordCleaningContract(projectId: string): Promise { + const market = await getMarketEnrichmentContract(projectId); + const modelTask = buildKeywordCleaningModelTask(projectId, market); + + if (market.semanticRunId && output.semanticRunId && output.semanticRunId !== market.semanticRunId) { + throw new Error("Keyword cleaning output semanticRunId не совпадает с текущим market enrichment."); + } + + const alignedOutput: KeywordCleaningContract = { + ...output, + items: mergeModelProviderItemsWithMarketEvidence(output.items, market), + modelTask, + projectOntologyVersion: market.projectOntologyVersion, + semanticRunId: market.semanticRunId, + source: { + ...output.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 + }; + const normalizedOutput = normalizeKeywordCleaningOutput( + projectId, + alignedOutput, + "codex_workspace", + "AI Workspace Codex provider выполнил seo.keyword_cleaning по contract-only task; результат сохранён как evidence после backend validation." + ); + const result = await pool.query( + ` + 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_workspace", + schemaVersion: "keyword-cleaning-model-provider-input.v1", + semanticRunId: normalizedOutput.semanticRunId, + sourceModelTaskRunId, + 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("Не удалось сохранить AI Workspace keyword cleaning."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts index 8b2bdc4..e0fe430 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordMap.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordMap.ts @@ -151,46 +151,238 @@ function buildBriefPhraseMap(contract: MarketEnrichmentContract) { return phraseMap; } -function getKeywordRole( - item: KeywordCleaningItem, - indexInTarget: number, - targetHasCorePhrase: boolean, - reviewedOntology: boolean -): KeywordMapRole { - if (reviewedOntology && isHybridSecondaryCandidate(item)) { - return "secondary_candidate"; +function getKeywordWordCount(value: string) { + return normalizePhrase(value).split(/\s+/).filter(Boolean).length; +} + +const KEYWORD_FACET_STOP_WORDS = new Set([ + "a", + "an", + "and", + "by", + "for", + "in", + "of", + "on", + "or", + "the", + "to", + "без", + "в", + "для", + "и", + "или", + "как", + "на", + "о", + "об", + "от", + "по", + "при", + "с", + "со", + "что", + "это" +]); + +const GENERIC_MARKET_FACET_STEMS = new Set([ + "автоматизац", + "автоматизаци", + "бизнес", + "данн", + "задач", + "инструмент", + "компан", + "контур", + "модул", + "платформ", + "приложен", + "процесс", + "проект", + "разработк", + "решен", + "сервис", + "систем", + "сайт", + "управлен", + "услуг" +]); + +type SemanticFacetGuard = { + approvedAnchorCount: number; + dominantProtectedStems: Set; + stemDocumentCounts: Map; +}; + +function stemKeywordToken(token: string) { + const normalizedToken = token.toLocaleLowerCase("ru-RU").replace(/ё/g, "е"); + + if (/^(ai|ии)$/i.test(normalizedToken) || /^(artificial|intelligence)$/i.test(normalizedToken)) { + return "ai"; } - if (reviewedOntology && isBroadArticleBacklogPhrase(item.phrase)) { + if (/^(искусствен|интеллект)/i.test(normalizedToken)) { + return "ai"; + } + + if (/^[a-z0-9]+$/i.test(normalizedToken)) { + return normalizedToken.toLowerCase(); + } + + return normalizedToken.replace( + /(иями|ями|ами|ого|его|ому|ему|ыми|ими|ых|их|ией|иям|ям|ам|ях|ах|ов|ев|ей|ой|ый|ий|ая|яя|ое|ее|ые|ие|ую|юю|ом|ем|а|я|ы|и|у|ю|е|о)$/i, + "" + ); +} + +function getKeywordFacetStems(value: string) { + return new Set( + normalizePhrase(value) + .replace(/ё/g, "е") + .split(/[^a-zа-я0-9]+/i) + .map((token) => token.trim()) + .filter((token) => token.length > 1 && !KEYWORD_FACET_STOP_WORDS.has(token)) + .map(stemKeywordToken) + .filter((stem) => stem.length > 1 && !KEYWORD_FACET_STOP_WORDS.has(stem)) + ); +} + +function buildSemanticFacetGuard(contract: MarketEnrichmentContract): SemanticFacetGuard { + const stemDocumentCounts = new Map(); + const anchorPhrases = contract.anchorReview.wordstatQueue.map((anchor) => anchor.phrase); + + for (const phrase of anchorPhrases) { + for (const stem of getKeywordFacetStems(phrase)) { + stemDocumentCounts.set(stem, (stemDocumentCounts.get(stem) ?? 0) + 1); + } + } + + const guardBase = { + approvedAnchorCount: anchorPhrases.length, + stemDocumentCounts + }; + const dominantProtectedStems = new Set( + [...stemDocumentCounts.entries()] + .filter(([stem, count]) => isProtectedSemanticFacetStem(stem, guardBase) && count / Math.max(anchorPhrases.length, 1) >= 0.3) + .map(([stem]) => stem) + ); + + return { + ...guardBase, + dominantProtectedStems + }; +} + +function isProtectedSemanticFacetStem(stem: string, guard: Pick) { + if (GENERIC_MARKET_FACET_STEMS.has(stem)) { + return false; + } + + if (/^[a-z0-9]+$/i.test(stem)) { + return true; + } + + const documentShare = guard.approvedAnchorCount > 0 ? (guard.stemDocumentCounts.get(stem) ?? 0) / guard.approvedAnchorCount : 0; + + return stem.length >= 7 || documentShare >= 0.18; +} + +function getProtectedSourceFacetStems(item: KeywordCleaningItem, guard: SemanticFacetGuard) { + if (!item.sourcePhrase) { + return new Set(); + } + + const sourceStems = getKeywordFacetStems(`${item.sourcePhrase} ${item.clusterTitle}`); + + return new Set([...sourceStems].filter((stem) => isProtectedSemanticFacetStem(stem, guard))); +} + +function getMissingSemanticFacetStems(item: KeywordCleaningItem, guard: SemanticFacetGuard) { + if (item.source !== "wordstat_related") { + return []; + } + + const protectedStems = getProtectedSourceFacetStems(item, guard); + + if (protectedStems.size === 0) { + return []; + } + + const phraseStems = getKeywordFacetStems(item.phrase); + const matchedStems = [...protectedStems].filter((stem) => phraseStems.has(stem)); + + return matchedStems.length === 0 ? [...protectedStems] : []; +} + +function hasSemanticFacetLoss(item: KeywordCleaningItem, guard: SemanticFacetGuard) { + return getMissingSemanticFacetStems(item, guard).length > 0; +} + +function missesDominantProjectFacet(item: KeywordCleaningItem, guard: SemanticFacetGuard) { + if (guard.dominantProtectedStems.size === 0) { + return false; + } + + const phraseStems = getKeywordFacetStems(item.phrase); + + return [...guard.dominantProtectedStems].every((stem) => !phraseStems.has(stem)); +} + +function getKeywordPriorityWeight(priority: KeywordCleaningItem["priority"]) { + if (priority === "high") return 3; + if (priority === "medium") return 2; + return 1; +} + +function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGuard) { + return ( + item.evidenceStatus === "collected" && + item.frequency !== null && + Boolean(item.projectOntologyVersionId) && + Boolean(item.wordstatResultId) && + Boolean(item.targetPath) && + (!guard || (!hasSemanticFacetLoss(item, guard) && !missesDominantProjectFacet(item, guard))) + ); +} + +function isLongTailKeywordCandidate(item: KeywordCleaningItem) { + return item.priority === "low" || getKeywordWordCount(item.phrase) >= 5 || (item.frequency !== null && item.frequency < 50); +} + +function isClarifyingKeywordCandidate(item: KeywordCleaningItem) { + const text = getKeywordProfileText(item); + + return ( + item.marketRole === "integration" || + item.intentType === "deployment_security" || + item.intentType === "integration" || + /(^|\s)(api|bim|crm|mcp|1с|3d|digital twin|on[-\s]?premise)(\s|$)/i.test(text) || + /(интеграц|архитект|безопас|облак|workspace|документооборот|утп|дифференц)/i.test(text) + ); +} + +function getKeywordRole(item: KeywordCleaningItem, indexInTarget: number, guard: SemanticFacetGuard): KeywordMapRole { + if (!canModelRouteKeyword(item, guard)) { return "validate"; } - 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 (targetHasCorePhrase && isAiDifferentiatorCandidate(item)) { - return "differentiator"; - } - - if (item.decision === "support") { - return "support"; - } - - if (indexInTarget === 0 && item.priority === "high") { + if (indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) { return "primary"; } - if (item.priority === "high" || item.frequency !== null) { + if (isLongTailKeywordCandidate(item)) { + return "support"; + } + + if (indexInTarget <= 7 && (item.priority === "high" || item.priority === "medium") && item.decision !== "article") { + return "secondary"; + } + + if (isClarifyingKeywordCandidate(item)) { + return "differentiator"; + } + + if (item.priority === "high" || item.priority === "medium") { return "secondary"; } @@ -229,36 +421,44 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) { const targetCounts = new Map(); - const sourceItems = cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120); - const targetHasCorePhrase = sourceItems.reduce>((accumulator, item) => { - const rawTargetPath = item.targetPath; - const targetKey = rawTargetPath ?? item.clusterId; - const canAnchorTarget = - reviewedOntology && - item.decision !== "article" && - item.decision !== "risky" && - item.evidenceStatus === "collected" && - item.frequency !== null && - !isBroadArticleBacklogPhrase(item.phrase) && - !isHybridSecondaryCandidate(item) && - !isAiDifferentiatorCandidate(item); + const semanticFacetGuard = buildSemanticFacetGuard(contract); + const sourceItems = cleaning.items + .filter((item) => item.decision !== "trash") + .sort((left, right) => { + const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0; + const rightRouteWeight = canModelRouteKeyword(right, semanticFacetGuard) ? 1 : 0; - if (canAnchorTarget) { - accumulator.set(targetKey, true); - } + if (leftRouteWeight !== rightRouteWeight) { + return rightRouteWeight - leftRouteWeight; + } - return accumulator; - }, new Map()); + const leftPriorityWeight = getKeywordPriorityWeight(left.priority); + const rightPriorityWeight = getKeywordPriorityWeight(right.priority); + + if (leftPriorityWeight !== rightPriorityWeight) { + return rightPriorityWeight - leftPriorityWeight; + } + + const leftFrequency = left.frequency ?? -1; + const rightFrequency = right.frequency ?? -1; + + if (leftFrequency !== rightFrequency) { + return rightFrequency - leftFrequency; + } + + return normalizePhrase(left.phrase).localeCompare(normalizePhrase(right.phrase), "ru"); + }) + .slice(0, 120); return sourceItems.map((item) => { const rawTargetPath = item.targetPath; const targetKey = rawTargetPath ?? item.clusterId; const indexInTarget = targetCounts.get(targetKey) ?? 0; - const role = getKeywordRole(item, indexInTarget, targetHasCorePhrase.get(targetKey) ?? false, reviewedOntology); - const targetPath = item.decision === "article" || role === "secondary_candidate" || isBroadArticleBacklogPhrase(item.phrase) ? null : rawTargetPath; + const role = getKeywordRole(item, indexInTarget, semanticFacetGuard); + const targetPath = role === "secondary_candidate" ? null : rawTargetPath; const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null; - if (targetPath && role !== "validate" && role !== "differentiator" && item.decision !== "support") { + if (targetPath && role !== "validate") { targetCounts.set(targetKey, indexInTarget + 1); } @@ -277,7 +477,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin frequencyGroup: item.frequencyGroup, projectOntologyVersionId: item.projectOntologyVersionId, wordstatResultId: item.wordstatResultId, - reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology) + reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology, semanticFacetGuard) }; }); } @@ -286,10 +486,11 @@ function getKeywordMapItemReason( item: KeywordCleaningItem, role: KeywordMapRole, targetPath: string | null, - reviewedOntology: boolean + reviewedOntology: boolean, + semanticFacetGuard: SemanticFacetGuard ) { if (!reviewedOntology) { - return "Фразу нельзя закреплять без reviewed ontology."; + return "Модель предварительно распределила фразу, но фиксация ждёт reviewed ontology."; } if (role === "secondary_candidate") { @@ -300,16 +501,40 @@ function getKeywordMapItemReason( return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title."; } - if (isBroadArticleBacklogPhrase(item.phrase)) { + if (role === "primary") { + return "Модель поставила фразу в посадочное ядро: это strongest exact-запрос для выбранной посадочной."; + } + + if (role === "secondary") { + return "Модель поставила фразу в поддерживающее ядро: она раскрывает главный спрос и структуру посадочной."; + } + + if (role === "support") { + return "Модель поставила фразу в длинный хвост: использовать в FAQ, подписях, внутренних связях или вторичных блоках."; + } + + if (role === "validate" && hasSemanticFacetLoss(item, semanticFacetGuard)) { + return "Wordstat related потерял доминантный признак исходного якоря: оставляем на ручной разбор, чтобы широкий спрос не вытеснил смысл проекта."; + } + + if (role === "validate" && missesDominantProjectFacet(item, semanticFacetGuard)) { + return "Фраза не содержит доминантный смысл из подтверждённой очереди якорей: оставляем на ручной разбор, чтобы широкий спрос не стал ядром автоматически."; + } + + if (isBroadArticleBacklogPhrase(item.phrase) && role === "validate") { return "Широкая head-фраза уходит в article/backlog: не делаем её прямой основной ставкой текущей посадочной без отдельного SERP page-type решения."; } if (item.decision === "article") { - return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной."; + return role === "validate" + ? "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной." + : "Модель предварительно распределила article/backlog-фразу; пользователь должен подтвердить, что она нужна текущей стратегии."; } if (item.decision === "risky") { - return item.reason; + return role === "validate" + ? item.reason + : `Модель предварительно распределила спорную фразу; проверь перед фиксацией. ${item.reason}`; } if (item.evidenceStatus === "collected_no_signal") { @@ -451,44 +676,17 @@ function normalizePersistedKeywordMapItem(input: { targetPath: string | null; workspaceSectionId: string | null; }) { - let role = input.role; - let targetPath = input.targetPath; - let relatedLanding = input.relatedLanding; - let workspaceSectionId = input.workspaceSectionId; - let pageId = input.pageId; - - if (role === "primary" && isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) { - role = "differentiator"; - workspaceSectionId = getWorkspaceSectionIdForRole("differentiator"); - } - - if (isBroadArticleBacklogPhrase(input.phrase)) { - role = "validate"; - relatedLanding = relatedLanding ?? targetPath; - targetPath = null; - pageId = null; - workspaceSectionId = null; - } - - if (hasProductLikeHybridSignal(input.phrase) && !isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) { - role = "secondary_candidate"; - relatedLanding = relatedLanding ?? targetPath; - targetPath = null; - pageId = null; - workspaceSectionId = null; - } - return { approvedAt: input.approvedAt, decisionId: input.decisionId, mapItemId: input.mapItemId, - pageId, + pageId: input.pageId, phrase: input.phrase, - relatedLanding, - role, + relatedLanding: input.relatedLanding, + role: input.role, sourceItemId: input.sourceItemId, - targetPath, - workspaceSectionId + targetPath: input.targetPath, + workspaceSectionId: input.workspaceSectionId }; } @@ -531,7 +729,7 @@ async function getKeywordMapPersistenceSummary( 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 + left join keyword_map_items kmi on kmi.keyword_decision_id = kd.id and kmi.project_id = kd.project_id and kmi.approved = true where kd.project_id = $1 and kd.semantic_run_id = $2 and kd.project_ontology_version_id = $3 @@ -574,7 +772,7 @@ function buildNextActions(contract: Omit) { if (contract.cleaning.keywordMapCandidateCount === 0) { actions.push( - "Keyword cleaning не пропустил ни одной use/support фразы: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy." + "Keyword cleaning не дал exact-кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy." ); if (contract.readiness.marketEvidenceCount === 0) { @@ -830,6 +1028,20 @@ export async function approveKeywordMapDecisions( continue; } + 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 source_item_id = $4 + and workspace_section_id <> $5 + and approved = true; + `, + [projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id, item.id, workspaceSectionId] + ); + await client.query( ` insert into keyword_map_items ( diff --git a/seo_mode/seo_mode/server/src/market/wordstatProvider.ts b/seo_mode/seo_mode/server/src/market/wordstatProvider.ts index 415a7bc..05a3f9b 100644 --- a/seo_mode/seo_mode/server/src/market/wordstatProvider.ts +++ b/seo_mode/seo_mode/server/src/market/wordstatProvider.ts @@ -47,6 +47,7 @@ export type WordstatProviderStatus = provider: "mcp_kv" | "yandex_api"; mode: string; message: string; + maxSeedsPerRun: number; } | { connected: false; @@ -246,7 +247,7 @@ async function postJson(endpoint: string, body: unknown, headers: Record { const config = await getWordstatRuntimeConfig(); if (config.provider === "mcp_kv") { - return new McpKvWordstatProvider(config.mcpEndpoint); + return new McpKvWordstatProvider(config.mcpEndpoint, config.maxSeedsPerRun); } if (config.provider === "yandex_api") { diff --git a/seo_mode/seo_mode/server/src/market/wordstatRepository.ts b/seo_mode/seo_mode/server/src/market/wordstatRepository.ts index d865a7a..5694cea 100644 --- a/seo_mode/seo_mode/server/src/market/wordstatRepository.ts +++ b/seo_mode/seo_mode/server/src/market/wordstatRepository.ts @@ -382,6 +382,29 @@ function getRepresentativeTopResultRows(results: WordstatResultRow[]) { return Array.from(selectedRows.values()).sort(compareWordstatResultRows); } +function getWordstatResultIdentity(row: WordstatResultRow) { + const phrase = row.normalized_phrase ?? normalizePhrase(row.phrase); + const sourcePhrase = row.source_phrase ? normalizePhrase(row.source_phrase) : ""; + const kind = sourcePhrase && sourcePhrase !== phrase ? "related" : "seed"; + + return `${kind}:${sourcePhrase || phrase}:${phrase}`; +} + +function getLatestUniqueWordstatResults(results: WordstatResultRow[]) { + const resultByIdentity = new Map(); + const sortedResults = [...results].sort((left, right) => right.created_at.getTime() - left.created_at.getTime()); + + for (const result of sortedResults) { + const identity = getWordstatResultIdentity(result); + + if (!resultByIdentity.has(identity)) { + resultByIdentity.set(identity, result); + } + } + + return Array.from(resultByIdentity.values()).sort(compareWordstatResultRows); +} + function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]): WordstatEvidence { const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase); const relatedCounts = new Map(); @@ -505,6 +528,26 @@ function toWordstatSeed(row: SeedRow): WordstatSeedInput { }; } +async function getCollectedSeedPhraseSet(projectId: string, semanticRunId: string) { + const result = await pool.query<{ normalized_phrase: string | null; phrase: string }>( + ` + select distinct on (coalesce(wr.normalized_phrase, lower(wr.phrase))) + wr.normalized_phrase, + wr.phrase + from wordstat_results wr + join wordstat_jobs wj on wj.id = wr.job_id + where wj.project_id = $1 + and wj.semantic_run_id = $2 + and wj.status = 'done' + and (wr.source_phrase is null or lower(wr.source_phrase) = lower(wr.phrase)) + order by coalesce(wr.normalized_phrase, lower(wr.phrase)), wr.created_at desc; + `, + [projectId, semanticRunId] + ); + + return new Set(result.rows.map((row) => normalizePhrase(row.normalized_phrase ?? row.phrase))); +} + async function createJob( projectId: string, semanticRunId: string, @@ -693,8 +736,17 @@ export async function collectWordstatEvidence( } const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates); - const seeds = seedRows.map(toWordstatSeed); + const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId); + const seeds = seedRows + .filter((row) => !collectedSeedPhrases.has(normalizePhrase(row.phrase))) + .slice(0, status.maxSeedsPerRun) + .map(toWordstatSeed); const region = "ru"; + + if (seeds.length === 0) { + return getLatestWordstatEvidence(projectId, analysis.runId); + } + const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds); if (!job) { @@ -763,31 +815,35 @@ export async function getLatestWordstatEvidence( const results = await pool.query( ` select - id, - job_id, - seed_id, - phrase, - source_phrase, - normalized_phrase, - frequency, - frequency_group, - region, - position, - raw, - created_at - from wordstat_results - where job_id = $1 - order by position asc, phrase asc; + wr.id, + wr.job_id, + wr.seed_id, + wr.phrase, + wr.source_phrase, + wr.normalized_phrase, + wr.frequency, + wr.frequency_group, + wr.region, + wr.position, + wr.raw, + wr.created_at + from wordstat_results wr + join wordstat_jobs wj on wj.id = wr.job_id + where wj.project_id = $1 + and wj.semantic_run_id = $2 + and wj.status = 'done' + order by wr.created_at desc, wr.position asc, wr.phrase asc; `, - [latestJob.id] + [projectId, semanticRunId] ); + const latestUniqueResults = getLatestUniqueWordstatResults(results.rows); if (!scopePhrases || scopePhrases.length === 0) { - return buildEvidence(latestJob, results.rows); + return buildEvidence(latestJob, latestUniqueResults); } const scopePhraseSet = new Set(scopePhrases.map(normalizePhrase)); - const scopedResults = results.rows.filter((row) => { + const scopedResults = latestUniqueResults.filter((row) => { const sourcePhrase = row.source_phrase ? normalizePhrase(row.source_phrase) : null; const phrase = normalizePhrase(row.phrase); diff --git a/seo_mode/seo_mode/server/src/modelContext/seoModelContext.ts b/seo_mode/seo_mode/server/src/modelContext/seoModelContext.ts index eca0b59..d903ff4 100644 --- a/seo_mode/seo_mode/server/src/modelContext/seoModelContext.ts +++ b/seo_mode/seo_mode/server/src/modelContext/seoModelContext.ts @@ -347,7 +347,7 @@ export async function getSeoModelContextContract(projectId: string): Promise; + +export type SeoAiWorkspaceDispatch = { + schemaVersion: "seo-ai-workspace-dispatch.v1"; + profile: SeoAiWorkspaceProfile; + assistantThreadId: string; + assistantMessageId: string; + bridgeRequestId: string; + bridgeMode: string; + accepted: boolean; + dispatchedAt: string; + selectedExecutorId: string | null; + contractWorkspacePath: string; +}; + +export type SeoAiWorkspaceOutputValidation = { + ok: boolean; + errors: string[]; + warnings: string[]; +}; + +export type SeoAiWorkspaceModelOutput = { + schemaVersion: "seo-model-output.v1"; + source: "ai_workspace"; + status: "dispatch_accepted" | "schema_valid" | "schema_invalid"; + aiWorkspace: SeoAiWorkspaceDispatch; + rawText: string | null; + parsedJson: JsonRecord | null; + validation: SeoAiWorkspaceOutputValidation | null; +}; + +export type SeoAiWorkspacePersistedResult = { + schemaVersion: "seo-model-persisted-result.v1"; + status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence"; + runType: + | "keyword_cleaning" + | "seo_model_task" + | "seo_normalization" + | "seo_strategy_quality_review" + | "seo_strategy_synthesis"; + runId: string; + sourceModelTaskRunId?: string; + note: string; +}; + +export type SeoAiWorkspaceCompletion = + | { status: "pending" } + | { status: "failed"; error: string } + | { status: "completed"; rawText: string }; + +type AssistantThreadResponse = { + thread?: { + id?: string; + }; +}; + +type AssistantMessageResponse = { + message?: { + id?: string; + }; +}; + +type AssistantDispatchResponse = { + bridge?: { + accepted?: boolean; + requestId?: string; + mode?: string; + error?: string; + }; +}; + +type AssistantMessagesResponse = { + messages?: Array<{ + role?: string; + content?: string; + payload?: JsonRecord; + }>; +}; + +function isJsonRecord(value: unknown): value is JsonRecord { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function assistantHeaders() { + const headers: Record = { + Accept: "application/json", + Authorization: `Bearer ${env.aiWorkspace.assistantToken ?? ""}`, + "Content-Type": "application/json" + }; + + if (env.aiWorkspace.owner.userId) { + headers["X-NODEDC-User-Id"] = env.aiWorkspace.owner.userId; + } + + if (env.aiWorkspace.owner.email) { + headers["X-NODEDC-User-Email"] = env.aiWorkspace.owner.email; + } + + if (env.aiWorkspace.owner.role) { + headers["X-NODEDC-User-Role"] = env.aiWorkspace.owner.role; + } + + if (env.aiWorkspace.owner.groups.length > 0) { + headers["X-NODEDC-User-Groups"] = env.aiWorkspace.owner.groups.join(","); + } + + return headers; +} + +export async function assistantRequest( + path: string, + options: { + body?: JsonRecord; + method: "GET" | "POST"; + } +): Promise { + if (!env.aiWorkspace.assistantUrl) { + throw new Error("SEO_AI_WORKSPACE_CONTROL_URL or SEO_AI_WORKSPACE_ASSISTANT_URL is required."); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), env.aiWorkspace.requestTimeoutMs); + + try { + const response = await fetch(`${env.aiWorkspace.assistantUrl.replace(/\/+$/, "")}${path}`, { + body: options.body ? JSON.stringify(options.body) : undefined, + headers: assistantHeaders(), + method: options.method, + signal: controller.signal + }); + const text = await response.text(); + const payload = text ? JSON.parse(text) as JsonRecord : {}; + + if (!response.ok || payload.ok === false) { + throw new Error(String(payload.error || payload.message || `ai_workspace_assistant_http_${response.status}`)); + } + + return payload as T; + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error("ai_workspace_assistant_invalid_json"); + } + + if (error instanceof Error && error.name === "AbortError") { + throw new Error("ai_workspace_assistant_timeout"); + } + + throw error; + } finally { + clearTimeout(timeout); + } +} + +function buildSeoModelTaskPrompt(input: { + projectId: string; + runId: string; + task: SeoModelTaskContract; +}) { + return [ + "NDC SEO Mode model-provider task.", + "", + "Return only one JSON object. Do not wrap it in markdown. Do not include prose outside JSON.", + `The JSON object must use schemaVersion "${input.task.outputSchema.schemaVersion}".`, + `Required top-level keys: ${input.task.outputSchema.requiredTopLevelKeys.join(", ")}.`, + "If the output contract has bookkeeping keys such as modelTask, sourceTaskId, source, semanticRunId, projectId, or provider, populate them from the task contract and the provided evidence.", + "For keyword tasks, classify only phrases present in task.input.seedQueue or task.input.wordstatTopResults; keep unresolved or weak rows out of approved rewrite lanes.", + "", + "Hard boundaries:", + "- Work only from the task contract below.", + "- Do not inspect repository files, local filesystem paths, browser state, secrets, tokens, or product databases.", + "- Do not call Wordstat, SERP, Yandex, OpenAI API, product app APIs, or external tools from this task.", + "- Do not mutate source files, rewrite/apply content, create pages, delete data, or approve decisions.", + "- Treat every output as a proposal/evidence draft that must pass backend schema validation and human gates.", + "- Preserve unresolved/Неразобранные semantics when the task is about keywords.", + "", + "Correlation:", + `- projectId: ${input.projectId}`, + `- seoModelTaskRunId: ${input.runId}`, + `- taskId: ${input.task.taskId}`, + `- taskType: ${input.task.taskType}`, + "", + "SEO task contract JSON:", + JSON.stringify(input.task, null, 2) + ].join("\n"); +} + +export async function dispatchSeoModelTaskToAiWorkspace(input: { + projectId: string; + runId: string; + task: SeoModelTaskContract; +}): Promise { + const status = getSeoAiWorkspaceConfigStatus(); + + if (!status.configured) { + throw new Error(status.errors.join(" ") || "AI Workspace bridge is not configured."); + } + + const threadTitle = `SEO model task: ${input.task.taskType}`; + const analyticsModelConfig = await getSeoAnalyticsModelRuntimeConfig(); + const selectedExecutorId = env.aiWorkspace.selectedExecutorId ?? analyticsModelConfig.executorId; + + if (!selectedExecutorId) { + throw new Error("SEO analytics model executorId is required; generate Codex bridge setup command and save external service settings."); + } + + const activeContext = { + surface: "seo-mode", + sourceSurface: "seo-mode", + modeId: "seo-model-task", + modeTitle: "NDC SEO Mode model provider", + accessMode: "contract-only", + contextReady: true, + workspacePath: env.aiWorkspace.contractWorkspacePath, + remoteControlMode: "remote", + seoModelTask: { + projectId: input.projectId, + runId: input.runId, + taskId: input.task.taskId, + taskType: input.task.taskType, + outputSchemaVersion: input.task.outputSchema.schemaVersion + } + }; + + const threadPayload: JsonRecord = { + activeContext, + enabledToolPacks: [], + lifecycleState: "active", + linkedArtifacts: [ + { + kind: "seo_model_task", + projectId: input.projectId, + runId: input.runId, + taskId: input.task.taskId, + taskType: input.task.taskType + } + ], + metadata: { + projectId: input.projectId, + runId: input.runId, + surface: "seo-mode" + }, + originSurface: "seo-mode", + title: threadTitle + }; + + threadPayload.selectedExecutorId = selectedExecutorId; + + const threadResponse = await assistantRequest("/api/ai-workspace/assistant/v1/threads", { + body: threadPayload, + method: "POST" + }); + const threadId = String(threadResponse.thread?.id || ""); + + if (!threadId) { + throw new Error("ai_workspace_thread_id_missing"); + } + + const messageResponse = await assistantRequest( + `/api/ai-workspace/assistant/v1/threads/${encodeURIComponent(threadId)}/messages`, + { + body: { + content: buildSeoModelTaskPrompt(input), + payload: { + kind: "seo_model_task_request", + projectId: input.projectId, + runId: input.runId, + taskId: input.task.taskId, + taskType: input.task.taskType + }, + role: "user" + }, + method: "POST" + } + ); + const messageId = String(messageResponse.message?.id || ""); + + if (!messageId) { + throw new Error("ai_workspace_message_id_missing"); + } + + const dispatchPayload: JsonRecord = { + context: activeContext, + messageId, + modeId: "seo-model-task", + modeTitle: "NDC SEO Mode model provider", + originSurface: "seo-mode", + remoteControlMode: "remote", + workspacePath: env.aiWorkspace.contractWorkspacePath + }; + + dispatchPayload.selectedExecutorId = selectedExecutorId; + + const dispatchResponse = await assistantRequest( + `/api/ai-workspace/assistant/v1/threads/${encodeURIComponent(threadId)}/dispatch`, + { + body: dispatchPayload, + method: "POST" + } + ); + const bridge = dispatchResponse.bridge; + const requestId = String(bridge?.requestId || ""); + + if (bridge?.accepted !== true || !requestId) { + throw new Error(String(bridge?.error || "ai_workspace_bridge_request_not_accepted")); + } + + return { + schemaVersion: "seo-ai-workspace-dispatch.v1", + profile: status.profile, + assistantThreadId: threadId, + assistantMessageId: messageId, + bridgeRequestId: requestId, + bridgeMode: String(bridge.mode || "hub"), + accepted: true, + dispatchedAt: new Date().toISOString(), + selectedExecutorId, + contractWorkspacePath: env.aiWorkspace.contractWorkspacePath ?? "" + }; +} + +export async function fetchSeoAiWorkspaceCompletion( + dispatch: SeoAiWorkspaceDispatch +): Promise { + const payload = await assistantRequest( + `/api/ai-workspace/assistant/v1/threads/${encodeURIComponent(dispatch.assistantThreadId)}/messages?limit=100`, + { method: "GET" } + ); + const messages = Array.isArray(payload.messages) ? payload.messages : []; + const finalMessage = messages.find((message) => { + const item = isJsonRecord(message.payload) ? message.payload : {}; + const kind = String(item.kind || ""); + const requestId = String(item.requestId || item.request_id || ""); + return requestId === dispatch.bridgeRequestId && (kind === "bridge_final" || kind === "bridge_failure"); + }); + + if (!finalMessage) { + return { status: "pending" }; + } + + const finalPayload = isJsonRecord(finalMessage.payload) ? finalMessage.payload : {}; + const finalKind = String(finalPayload.kind || ""); + const content = String(finalMessage.content || ""); + + if (finalKind === "bridge_failure") { + return { + status: "failed", + error: content || "ai_workspace_bridge_failed" + }; + } + + return { + status: "completed", + rawText: content + }; +} + +function parseJsonFromText(rawText: string): JsonRecord | null { + const text = rawText.trim(); + + if (!text) { + return null; + } + + try { + const parsed = JSON.parse(text); + return isJsonRecord(parsed) ? parsed : null; + } catch {} + + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + + if (fence?.[1]) { + try { + const parsed = JSON.parse(fence[1].trim()); + return isJsonRecord(parsed) ? parsed : null; + } catch {} + } + + const firstBrace = text.indexOf("{"); + const lastBrace = text.lastIndexOf("}"); + + if (firstBrace >= 0 && lastBrace > firstBrace) { + try { + const parsed = JSON.parse(text.slice(firstBrace, lastBrace + 1)); + return isJsonRecord(parsed) ? parsed : null; + } catch {} + } + + return null; +} + +export function buildAcceptedAiWorkspaceModelOutput( + dispatch: SeoAiWorkspaceDispatch +): SeoAiWorkspaceModelOutput { + return { + schemaVersion: "seo-model-output.v1", + source: "ai_workspace", + status: "dispatch_accepted", + aiWorkspace: dispatch, + rawText: null, + parsedJson: null, + validation: null + }; +} + +export function validateSeoAiWorkspaceModelOutput(input: { + dispatch: SeoAiWorkspaceDispatch; + rawText: string; + outputSchemaVersion: string | null; + requiredTopLevelKeys: string[]; +}): SeoAiWorkspaceModelOutput { + const errors: string[] = []; + const warnings: string[] = []; + const parsedJson = parseJsonFromText(input.rawText); + + if (!parsedJson) { + errors.push("Remote Codex response is not a JSON object."); + } + + if (parsedJson && input.outputSchemaVersion && parsedJson.schemaVersion !== input.outputSchemaVersion) { + errors.push(`schemaVersion must be ${input.outputSchemaVersion}.`); + } + + if (parsedJson) { + for (const key of input.requiredTopLevelKeys) { + if (!Object.hasOwn(parsedJson, key)) { + errors.push(`Missing required top-level key: ${key}.`); + } + } + } + + if (input.rawText.includes("```")) { + warnings.push("Remote Codex wrapped output in markdown; JSON was extracted before validation."); + } + + const validation = { + ok: errors.length === 0, + errors, + warnings + }; + + return { + schemaVersion: "seo-model-output.v1", + source: "ai_workspace", + status: validation.ok ? "schema_valid" : "schema_invalid", + aiWorkspace: input.dispatch, + rawText: input.rawText, + parsedJson, + validation + }; +} + +export function buildSeoAiWorkspacePersistedResult(runId: string): SeoAiWorkspacePersistedResult { + return { + schemaVersion: "seo-model-persisted-result.v1", + status: "stored_as_model_task_evidence", + runType: "seo_model_task", + runId, + note: "Structured model output is persisted on seo_model_task run; task-specific evidence promotion remains backend-owned." + }; +} + +export function buildSeoAiWorkspacePromotedResult(input: { + runType: Exclude; + sourceModelTaskRunId: string; +}): SeoAiWorkspacePersistedResult { + return { + schemaVersion: "seo-model-persisted-result.v1", + status: "promoted_to_domain_evidence", + runType: input.runType, + runId: input.sourceModelTaskRunId, + sourceModelTaskRunId: input.sourceModelTaskRunId, + note: `Structured model output passed schema validation and was promoted to ${input.runType}.` + }; +} diff --git a/seo_mode/seo_mode/server/src/modelProvider/aiWorkspaceBridgeConfig.ts b/seo_mode/seo_mode/server/src/modelProvider/aiWorkspaceBridgeConfig.ts new file mode 100644 index 0000000..ada182f --- /dev/null +++ b/seo_mode/seo_mode/server/src/modelProvider/aiWorkspaceBridgeConfig.ts @@ -0,0 +1,109 @@ +import { env } from "../config/env.js"; + +export type SeoAiWorkspaceProfile = "disabled" | "local" | "deployed-hub" | "deploy-host" | "tunnel-local-e2e"; + +export type SeoAiWorkspaceConfigStatus = { + configured: boolean; + profile: SeoAiWorkspaceProfile; + errors: string[]; + warnings: string[]; + assistantUrl: string | null; + selectedExecutorId: string | null; + contractWorkspacePath: string | null; + ownerConfigured: boolean; +}; + +const LOCAL_ASSISTANT_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "host.docker.internal"]); +const LOCAL_FORBIDDEN_PRODUCT_HOSTS = new Set([ + "hub.nodedc.ru", + "engine.nodedc.ru", + "ops.nodedc.ru", + "id.nodedc.ru", + "ops-agents.nodedc.ru", + "ai-hub.nodedc.ru" +]); + +function hostname(value: string | undefined) { + if (!value) { + return ""; + } + + try { + return new URL(value).hostname.toLowerCase(); + } catch { + return ""; + } +} + +export function getSeoAiWorkspaceConfigStatus(): SeoAiWorkspaceConfigStatus { + const profile = env.aiWorkspace.profile; + const assistantHost = hostname(env.aiWorkspace.assistantUrl); + const ownerConfigured = Boolean(env.aiWorkspace.owner.userId || env.aiWorkspace.owner.email); + const errors: string[] = []; + const warnings: string[] = []; + + if (profile === "disabled") { + errors.push("SEO_AI_WORKSPACE_PROFILE is disabled; choose local, deployed-hub, deploy-host, or tunnel-local-e2e."); + } + + if (!env.aiWorkspace.assistantUrl) { + errors.push("SEO_AI_WORKSPACE_CONTROL_URL or SEO_AI_WORKSPACE_ASSISTANT_URL is required."); + } + + if (!env.aiWorkspace.assistantToken) { + errors.push("SEO_AI_WORKSPACE_CONTROL_TOKEN or SEO_AI_WORKSPACE_ASSISTANT_TOKEN is required."); + } + + if (!ownerConfigured) { + errors.push("SEO_AI_WORKSPACE_OWNER_USER_ID or SEO_AI_WORKSPACE_OWNER_EMAIL is required."); + } + + if (!env.aiWorkspace.contractOnly) { + errors.push("SEO_AI_WORKSPACE_CONTRACT_ONLY=1 is required for SEO model tasks."); + } + + if (!env.aiWorkspace.contractWorkspacePath) { + errors.push("SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH is required to avoid executor workspace fallback."); + } + + if (profile === "local") { + if (assistantHost && !LOCAL_ASSISTANT_HOSTS.has(assistantHost)) { + errors.push("local profile requires a local AI Workspace Assistant URL."); + } + + if (LOCAL_FORBIDDEN_PRODUCT_HOSTS.has(assistantHost)) { + errors.push(`local profile must not use deployed product host ${assistantHost} as Assistant URL.`); + } + } + + if (profile === "deploy-host" && assistantHost && LOCAL_ASSISTANT_HOSTS.has(assistantHost)) { + errors.push("deploy-host profile must not use localhost/host.docker.internal Assistant URL."); + } + + if (profile === "deployed-hub") { + if (assistantHost && LOCAL_ASSISTANT_HOSTS.has(assistantHost)) { + errors.push("deployed-hub profile requires the public AI Hub control-plane URL, not localhost."); + } + + if (assistantHost && assistantHost !== "ai-hub.nodedc.ru") { + warnings.push(`deployed-hub profile is using ${assistantHost}; expected ai-hub.nodedc.ru unless this is an explicit deploy profile.`); + } + } + + if (env.aiWorkspace.selectedExecutorId) { + warnings.push("SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID pins the Codex executor for this backend."); + } else { + warnings.push("No env executor id is pinned; SEO external-service settings must provide executorId before dispatch."); + } + + return { + configured: errors.length === 0, + profile, + errors, + warnings, + assistantUrl: env.aiWorkspace.assistantUrl ?? null, + selectedExecutorId: env.aiWorkspace.selectedExecutorId ?? null, + contractWorkspacePath: env.aiWorkspace.contractWorkspacePath ?? null, + ownerConfigured + }; +} diff --git a/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts b/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts index 89b9ffa..f01af8e 100644 --- a/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts +++ b/seo_mode/seo_mode/server/src/modelProvider/modelProviderRegistry.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js"; export const SEO_MODEL_TASK_TYPES = [ "seo.context_review", @@ -141,7 +142,7 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [ title: "Codex Workspace adapter", mode: "workspace_agent", summary: - "Будущий adapter для dev-mode/model-review: модель получает только SEO task contract и возвращает structured JSON.", + "Adapter через AI Workspace Assistant / AI Hub / remote Codex worker: модель получает только SEO task contract и возвращает structured JSON.", allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES, requiresSecrets: false, secretNames: [], @@ -157,9 +158,11 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [ guardrails: [ "Workspace agent не должен читать репозиторий напрямую для пользовательских сайтов.", "Все filesystem/tool calls должны идти через approved MCP capabilities.", + "SEO route требует явный contract-only workspace path и не использует executor workspace fallback.", "Ответ должен проходить schema validation перед записью результата." ], - nextSetupStep: "Описать handoff contract между AI Workspace Hub/MCP и seo_mode backend." + nextSetupStep: + "Настроить SEO_AI_WORKSPACE_PROFILE, AI Workspace control URL/token, owner, contract-only workspace path and connected Codex executor." }, { id: "openai_api", @@ -239,10 +242,38 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [ } ]; -const PARSED_PROVIDERS = z.array(SeoModelProviderDescriptorSchema).parse(PROVIDERS); +function buildProviderDescriptors() { + const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus(); + + return PROVIDERS.map((provider) => { + if (provider.id !== "codex_workspace") { + return provider; + } + + return { + ...provider, + status: aiWorkspaceStatus.configured ? "active" as const : "not_configured" as const, + summary: aiWorkspaceStatus.configured + ? `${provider.summary} Configured profile: ${aiWorkspaceStatus.profile}.` + : `${provider.summary} Пока не настроен: ${aiWorkspaceStatus.errors[0] ?? provider.nextSetupStep}`, + guardrails: [ + ...provider.guardrails, + `AI Workspace profile: ${aiWorkspaceStatus.profile}.`, + "Remote Codex output is proposal/evidence only; it never bypasses human gates or rewrite/apply approval." + ], + nextSetupStep: aiWorkspaceStatus.configured + ? "Запустить seo model task через codex_workspace и дождаться structured JSON в model task evidence run." + : aiWorkspaceStatus.errors.join(" ") || provider.nextSetupStep + }; + }); +} + +function getParsedProviders() { + return z.array(SeoModelProviderDescriptorSchema).parse(buildProviderDescriptors()); +} export function getDefaultSeoModelProviderId(): SeoModelProviderId { - return "codex_manual"; + return getSeoAiWorkspaceConfigStatus().configured ? "codex_workspace" : "codex_manual"; } export function isSeoModelProviderId(value: string): value is SeoModelProviderId { @@ -250,14 +281,17 @@ export function isSeoModelProviderId(value: string): value is SeoModelProviderId } export function getSeoModelProviderDescriptor(providerId: SeoModelProviderId): SeoModelProviderDescriptor { - return PARSED_PROVIDERS.find((provider) => provider.id === providerId) ?? PARSED_PROVIDERS[0]; + const providers = getParsedProviders(); + return providers.find((provider) => provider.id === providerId) ?? providers[0]; } export function getSeoModelProviderCatalog(): SeoModelProviderCatalog { + const providers = getParsedProviders(); + return SeoModelProviderCatalogSchema.parse({ schemaVersion: "seo-model-provider-catalog.v1", generatedAt: new Date().toISOString(), defaultProviderId: getDefaultSeoModelProviderId(), - providers: PARSED_PROVIDERS + providers }); } diff --git a/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts b/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts index 1c3d9af..53ee0e2 100644 --- a/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts +++ b/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts @@ -1,5 +1,20 @@ import { pool } from "../db/client.js"; +import { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js"; import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js"; +import { saveSeoNormalizationFromModelProvider, type SeoNormalizationContract } from "../normalization/seoNormalization.js"; +import { saveStrategyQualityReviewFromModelProvider, type StrategyQualityReviewContract } from "../strategy/strategyQualityReview.js"; +import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js"; +import { + buildAcceptedAiWorkspaceModelOutput, + buildSeoAiWorkspacePromotedResult, + buildSeoAiWorkspacePersistedResult, + dispatchSeoModelTaskToAiWorkspace, + fetchSeoAiWorkspaceCompletion, + validateSeoAiWorkspaceModelOutput, + type SeoAiWorkspaceDispatch, + type SeoAiWorkspaceModelOutput, + type SeoAiWorkspacePersistedResult +} from "./aiWorkspaceBridge.js"; import { getDefaultSeoModelProviderId, getSeoModelProviderCatalog, @@ -24,6 +39,11 @@ type SeoModelTaskRunRow = { created_at: Date; }; +type DomainPromotionResult = { + persistedResult: SeoAiWorkspacePersistedResult; + nextAction: string; +}; + export type SeoModelTaskRunInput = { providerId?: SeoModelProviderId; taskId?: string; @@ -60,8 +80,8 @@ export type SeoModelTaskRunOutput = { inputTokenEstimate: number; outputTokenBudget: number; }; - modelOutput: null; - persistedResult: null; + modelOutput: SeoAiWorkspaceModelOutput | null; + persistedResult: SeoAiWorkspacePersistedResult | null; nextActions: string[]; }; @@ -218,7 +238,11 @@ function validateTaskContract(task: SeoModelTaskContract | null, provider: SeoMo warnings.push(`Provider ${provider.id} пока ${provider.status}; боевой model call не выполняется.`); } - notes.push("Dry-run проверяет только provider/task contract, а не качество будущего model output."); + if (provider.id === "codex_workspace" && provider.status === "active") { + notes.push("AI Workspace adapter отправляет sanitized task contract в remote Codex worker через Assistant/Hub route."); + } else { + notes.push("Dry-run проверяет только provider/task contract, а не качество будущего model output."); + } notes.push("Результат модели должен сохраняться отдельным evidence run и проходить schema validation."); return { @@ -288,11 +312,17 @@ function buildRunOutput( persistedResult: null, nextActions: status === "contract_ready" - ? [ - "Подключить реальный model adapter к этому же task contract.", - "Сохранять model output как evidence run только после schema validation.", - "Сравнивать model output с deterministic fallback перед human approval." - ] + ? provider.id === "codex_workspace" + ? [ + "Отправить task contract в AI Workspace Assistant / AI Hub / remote Codex worker.", + "Дождаться structured JSON и проверить output schema.", + "Сохранить валидный output как model-task evidence run; доменное promotion остается backend-owned." + ] + : [ + "Подключить реальный 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" @@ -304,20 +334,311 @@ function buildRunOutput( async function insertSeoModelTaskRun( projectId: string, input: Record, - output: SeoModelTaskRunOutput + output: SeoModelTaskRunOutput, + status: RunStatus = "done" ) { + const isTerminal = status === "done" || status === "failed" || status === "cancelled"; const result = await pool.query( ` 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()) + values ($1, 'seo_model_task', $4::run_status, $2::jsonb, $3::jsonb, now(), ${isTerminal ? "now()" : "null"}) returning id, status, input, output, error_message, started_at, completed_at, created_at `, - [projectId, JSON.stringify(input), JSON.stringify(output)] + [projectId, JSON.stringify(input), JSON.stringify(output), status] ); return mapSeoModelTaskRun(result.rows[0]); } +async function updateSeoModelTaskRun( + runId: string, + patch: { + completed?: boolean; + errorMessage?: string | null; + input?: Record; + output?: SeoModelTaskRunOutput; + status?: RunStatus; + } +) { + const fields: string[] = []; + const values: unknown[] = [runId]; + + if (patch.status) { + values.push(patch.status); + fields.push(`status = $${values.length}::run_status`); + } + + if (patch.input) { + values.push(JSON.stringify(patch.input)); + fields.push(`input = $${values.length}::jsonb`); + } + + if (patch.output) { + values.push(JSON.stringify(patch.output)); + fields.push(`output = $${values.length}::jsonb`); + } + + if (Object.hasOwn(patch, "errorMessage")) { + values.push(patch.errorMessage ?? null); + fields.push(`error_message = $${values.length}`); + } + + if (patch.completed) { + fields.push("completed_at = now()"); + } + + if (fields.length === 0) { + const result = await pool.query( + ` + select id, status, input, output, error_message, started_at, completed_at, created_at + from runs + where id = $1 and run_type = 'seo_model_task' + `, + [runId] + ); + + return mapSeoModelTaskRun(result.rows[0]); + } + + const result = await pool.query( + ` + update runs + set ${fields.join(", ")} + where id = $1 and run_type = 'seo_model_task' + returning id, status, input, output, error_message, started_at, completed_at, created_at + `, + values + ); + + return mapSeoModelTaskRun(result.rows[0]); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function getRunAiWorkspaceDispatch(run: SeoModelTaskRun): SeoAiWorkspaceDispatch | null { + const fromOutput = isRecord(run.modelOutput) && isRecord(run.modelOutput.aiWorkspace) + ? run.modelOutput.aiWorkspace + : null; + const fromInput = isRecord(run.input.aiWorkspace) ? run.input.aiWorkspace : null; + const source = fromOutput ?? fromInput; + + if (!source) { + return null; + } + + const assistantThreadId = typeof source.assistantThreadId === "string" ? source.assistantThreadId : ""; + const assistantMessageId = typeof source.assistantMessageId === "string" ? source.assistantMessageId : ""; + const bridgeRequestId = typeof source.bridgeRequestId === "string" ? source.bridgeRequestId : ""; + const contractWorkspacePath = typeof source.contractWorkspacePath === "string" ? source.contractWorkspacePath : ""; + + if (!assistantThreadId || !assistantMessageId || !bridgeRequestId || !contractWorkspacePath) { + return null; + } + + return { + schemaVersion: "seo-ai-workspace-dispatch.v1", + profile: source.profile === "deploy-host" || + source.profile === "deployed-hub" || + source.profile === "tunnel-local-e2e" || + source.profile === "local" + ? source.profile + : "disabled", + assistantThreadId, + assistantMessageId, + bridgeRequestId, + bridgeMode: typeof source.bridgeMode === "string" ? source.bridgeMode : "hub", + accepted: source.accepted === true, + dispatchedAt: typeof source.dispatchedAt === "string" ? source.dispatchedAt : run.startedAt ?? run.createdAt, + selectedExecutorId: typeof source.selectedExecutorId === "string" ? source.selectedExecutorId : null, + contractWorkspacePath + }; +} + +async function promoteAiWorkspaceModelOutput( + projectId: string, + run: SeoModelTaskRun, + modelOutput: SeoAiWorkspaceModelOutput +): Promise { + if (!modelOutput.validation?.ok || !modelOutput.parsedJson) { + return null; + } + + if (run.task.taskType === "seo.normalization") { + await saveSeoNormalizationFromModelProvider( + projectId, + modelOutput.parsedJson as unknown as SeoNormalizationContract, + run.runId + ); + return { + nextAction: "SEO normalization обновлена из AI Workspace output; можно пересобирать anchor review/Wordstat queue.", + persistedResult: buildSeoAiWorkspacePromotedResult({ + runType: "seo_normalization", + sourceModelTaskRunId: run.runId + }) + }; + } + + if (run.task.taskType === "seo.keyword_cleaning") { + await saveKeywordCleaningFromModelProvider( + projectId, + modelOutput.parsedJson as unknown as KeywordCleaningContract, + run.runId + ); + return { + nextAction: "Keyword cleaning обновлён из AI Workspace output; Stage 3 keyword map должен пересобраться из новой очистки.", + persistedResult: buildSeoAiWorkspacePromotedResult({ + runType: "keyword_cleaning", + sourceModelTaskRunId: run.runId + }) + }; + } + + if (run.task.taskType === "seo.strategy_synthesis") { + await saveStrategySynthesisFromModelProvider( + projectId, + modelOutput.parsedJson as unknown as StrategySynthesisContract, + run.runId + ); + return { + nextAction: "Strategy synthesis обновлён из AI Workspace output; пользователь может проверять стратегию и quality gate.", + persistedResult: buildSeoAiWorkspacePromotedResult({ + runType: "seo_strategy_synthesis", + sourceModelTaskRunId: run.runId + }) + }; + } + + if (run.task.taskType === "seo.strategy_quality_review") { + await saveStrategyQualityReviewFromModelProvider( + projectId, + modelOutput.parsedJson as unknown as StrategyQualityReviewContract, + run.runId + ); + return { + nextAction: "Strategy quality review обновлён из AI Workspace output; блокеры и warnings доступны в strategy gate.", + persistedResult: buildSeoAiWorkspacePromotedResult({ + runType: "seo_strategy_quality_review", + sourceModelTaskRunId: run.runId + }) + }; + } + + return { + nextAction: "Model output сохранен только в seo_model_task evidence; для этого task type доменное promotion пока не задано.", + persistedResult: buildSeoAiWorkspacePersistedResult(run.runId) + }; +} + +async function refreshRunningSeoModelTaskRun(row: SeoModelTaskRunRow) { + const run = mapSeoModelTaskRun(row); + + if (!run || run.provider.id !== "codex_workspace" || run.runStatus !== "running") { + return run; + } + + const dispatch = getRunAiWorkspaceDispatch(run); + + if (!dispatch) { + return run; + } + + const completion = await fetchSeoAiWorkspaceCompletion(dispatch); + + if (completion.status === "pending") { + return run; + } + + if (completion.status === "failed") { + return updateSeoModelTaskRun(run.runId, { + completed: true, + errorMessage: completion.error, + status: "failed" + }); + } + + const modelOutput = validateSeoAiWorkspaceModelOutput({ + dispatch, + outputSchemaVersion: run.task.outputSchemaVersion, + rawText: completion.rawText, + requiredTopLevelKeys: run.task.requiredTopLevelKeys + }); + let promotion: DomainPromotionResult | null = null; + + if (modelOutput.validation?.ok) { + try { + promotion = await promoteAiWorkspaceModelOutput(run.projectId, run, modelOutput); + } catch (error) { + modelOutput.validation = { + ...modelOutput.validation, + ok: false, + errors: [ + ...modelOutput.validation.errors, + `Domain promotion failed: ${error instanceof Error ? error.message : "unknown_error"}` + ] + }; + modelOutput.status = "schema_invalid"; + } + } + const { + runId: _runId, + runStatus: _runStatus, + input: _input, + startedAt: _startedAt, + completedAt: _completedAt, + errorMessage: _errorMessage, + createdAt: _createdAt, + ...storedOutput + } = run; + const output: SeoModelTaskRunOutput = { + ...storedOutput, + modelOutput, + persistedResult: modelOutput.validation?.ok ? promotion?.persistedResult ?? buildSeoAiWorkspacePersistedResult(run.runId) : null, + nextActions: modelOutput.validation?.ok + ? [ + promotion?.nextAction ?? "Model output сохранен в seo_model_task run как evidence.", + "Human gates остаются обязательными перед strategy/rewrite/apply." + ] + : [ + "Исправить remote Codex output: он не прошел JSON/schema validation.", + "Повторить task через codex_workspace после корректировки prompt/contract.", + "Не продвигать этот output в strategy/rewrite." + ] + }; + + return updateSeoModelTaskRun(run.runId, { + completed: true, + errorMessage: modelOutput.validation?.ok ? null : modelOutput.validation?.errors.join(" "), + output, + status: modelOutput.validation?.ok ? "done" : "failed" + }); +} + +async function refreshRunningSeoModelTaskRuns(projectId: string) { + const result = await pool.query( + ` + select id, status, input, output, error_message, started_at, completed_at, created_at + from runs + where project_id = $1 + and run_type = 'seo_model_task' + and status = 'running' + and input->>'providerId' = 'codex_workspace' + order by created_at desc + limit 5 + `, + [projectId] + ); + + for (const row of result.rows) { + try { + await refreshRunningSeoModelTaskRun(row); + } catch { + // Status reads should not fail when the external Assistant route is temporarily unavailable. + } + } +} + export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): Promise { const result = await pool.query( ` @@ -336,6 +657,7 @@ export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): P export async function getSeoModelProviderStatusContract( projectId: string ): Promise { + await refreshRunningSeoModelTaskRuns(projectId); const [catalog, latestRuns] = await Promise.all([getSeoModelProviderCatalog(), getLatestSeoModelTaskRuns(projectId)]); return { @@ -353,15 +675,77 @@ export async function runSeoModelTask(projectId: string, input: SeoModelTaskRunI const context = await getSeoModelContextContract(projectId); const task = findTask(context.modelTasks, input); const output = buildRunOutput(projectId, provider, task, context.schemaVersion); + const baseInput = { + schemaVersion: "seo-model-task-run-input.v1", + providerId, + requestedTaskId: input.taskId ?? null, + requestedTaskType: input.taskType ?? null, + sourceContextGeneratedAt: context.generatedAt + }; + + if (provider.id === "codex_workspace" && output.status === "contract_ready" && task) { + const queuedRun = await insertSeoModelTaskRun(projectId, baseInput, output, "running"); + + if (!queuedRun) { + throw new Error("Не удалось сохранить seo model task run."); + } + + try { + const dispatch = await dispatchSeoModelTaskToAiWorkspace({ + projectId, + runId: queuedRun.runId, + task + }); + const dispatchedOutput: SeoModelTaskRunOutput = { + ...output, + modelOutput: buildAcceptedAiWorkspaceModelOutput(dispatch), + nextActions: [ + "AI Workspace request accepted; model output will be collected from Assistant thread messages.", + "Refresh model-provider status to pick up bridge_final/bridge_failure.", + "Structured JSON must pass schema validation before task-specific evidence promotion." + ] + }; + const dispatchedRun = await updateSeoModelTaskRun(queuedRun.runId, { + input: { + ...baseInput, + aiWorkspace: dispatch + }, + output: dispatchedOutput, + status: "running" + }); + + if (!dispatchedRun) { + throw new Error("Не удалось обновить seo model task run после AI Workspace dispatch."); + } + + return dispatchedRun; + } catch (error) { + const failedOutput: SeoModelTaskRunOutput = { + ...output, + nextActions: [ + "Проверить SEO AI Workspace config/profile и выбранный Codex executor.", + "Повторить dispatch после восстановления Assistant/Hub/worker route.", + "Не использовать fallback на deployed product hosts или executor workspace по умолчанию." + ] + }; + const failedRun = await updateSeoModelTaskRun(queuedRun.runId, { + completed: true, + errorMessage: error instanceof Error ? error.message : "ai_workspace_dispatch_failed", + output: failedOutput, + status: "failed" + }); + + if (!failedRun) { + throw error; + } + + return failedRun; + } + } + const run = await insertSeoModelTaskRun( projectId, - { - schemaVersion: "seo-model-task-run-input.v1", - providerId, - requestedTaskId: input.taskId ?? null, - requestedTaskType: input.taskType ?? null, - sourceContextGeneratedAt: context.generatedAt - }, + baseInput, output ); diff --git a/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts b/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts index 57bd78a..e01c227 100644 --- a/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts +++ b/seo_mode/seo_mode/server/src/normalization/seoNormalization.ts @@ -18,7 +18,7 @@ type SeoIntentType = type SeoMarketRole = "commercial" | "core" | "exclude" | "integration" | "problem" | "support"; type SeoReviewStatus = "auto_approved" | "needs_human" | "rejected"; type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; -type SeoNormalizationProviderMode = "codex_manual" | "deterministic_fallback"; +type SeoNormalizationProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback"; type SeoNormalizationRunRow = { id: string; @@ -1047,3 +1047,42 @@ export async function saveSeoNormalizationManual( return run; } + +export async function saveSeoNormalizationFromModelProvider( + projectId: string, + output: SeoNormalizationContract, + sourceModelTaskRunId: string +): Promise { + const normalizedOutput = normalizeSeoNormalizationOutput( + projectId, + output, + "codex_workspace", + "AI Workspace Codex provider выполнил seo.normalization по contract-only task; результат сохранён как evidence после backend validation." + ); + const result = await pool.query( + ` + insert into runs (project_id, run_type, status, input, output, started_at, completed_at) + values ($1, 'seo_normalization', 'done', $2::jsonb, $3::jsonb, now(), now()) + returning id, status, input, output, error_message, started_at, completed_at, created_at; + `, + [ + projectId, + JSON.stringify({ + providerMode: "codex_workspace", + schemaVersion: "seo-normalization-model-provider-input.v1", + semanticRunId: normalizedOutput.semanticRunId, + sourceModelTaskRunId, + sourceTaskId: normalizedOutput.sourceTaskId, + taskType: "seo.normalization" + }), + JSON.stringify(normalizedOutput) + ] + ); + const run = result.rows[0] ? mapSeoNormalizationRun(result.rows[0]) : null; + + if (!run) { + throw new Error("Не удалось сохранить AI Workspace SEO normalization."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts b/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts index 382491e..6937d11 100644 --- a/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts +++ b/seo_mode/seo_mode/server/src/settings/externalServiceSettings.ts @@ -1,7 +1,9 @@ import { env } from "../config/env.js"; import { pool } from "../db/client.js"; +import { getSeoAiWorkspaceConfigStatus } from "../modelProvider/aiWorkspaceBridgeConfig.js"; export type ExternalServiceId = + | "seo_analytics_model" | "yandex_cloud" | "yandex_metrica" | "yandex_search_serp" @@ -91,6 +93,7 @@ export type WordstatRuntimeConfig = provider: "mcp_kv"; mode: "mcp_kv"; mcpEndpoint: string; + maxSeedsPerRun: number; } | { provider: "yandex_api"; @@ -102,6 +105,7 @@ export type WordstatRuntimeConfig = regionIds: string[]; devices: string[]; numPhrases: number; + maxSeedsPerRun: number; }; const WORDSTAT_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/wordstat"; @@ -110,6 +114,30 @@ const WEBMASTER_BASE_URL = "https://api.webmaster.yandex.net/v4"; const METRICA_BASE_URL = "https://api-metrika.yandex.net"; const serviceDefinitions: Record = { + seo_analytics_model: { + id: "seo_analytics_model", + label: "Аналитическая модель", + description: + "Model-provider для SEO task contracts через AI Workspace surface seo-mode. Сейчас доступен Codex bridge без доступа к Yandex credentials, исходникам и apply/write actions.", + implementationStatus: "active", + defaultMode: "local_codex", + modes: [ + { id: "disabled", label: "Отключено" }, + { id: "local_codex", label: "Codex bridge" } + ], + docs: [], + publicFields: [ + { + id: "executorId", + label: "AI Workspace executor", + type: "text", + required: false, + placeholder: "generated after setup command", + help: "Stored SEO-only executor id. Hidden in UI; used to avoid global Assistant selected-executor fallback." + } + ], + secretFields: [] + }, yandex_cloud: { id: "yandex_cloud", label: "Yandex AI Studio / Cloud API key", @@ -223,6 +251,14 @@ const serviceDefinitions: Record = placeholder: "50", help: "Сколько Wordstat rows запрашивать для одной seed-фразы." }, + { + id: "maxSeedsPerRun", + label: "Max seeds per run", + type: "number", + required: false, + placeholder: "40", + help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет." + }, { id: "mcpEndpoint", label: "MCP endpoint", @@ -386,6 +422,7 @@ function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) { apiBaseUrl: WORDSTAT_BASE_URL, devices: "DEVICE_ALL", numPhrases: "50", + maxSeedsPerRun: "40", regionIds: "225" }; @@ -446,6 +483,16 @@ function getMissingFields( const missing: string[] = []; + if (definition.id === "seo_analytics_model") { + const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus(); + + if (!aiWorkspaceStatus.configured) { + return ["aiWorkspaceProfile"]; + } + + return publicConfig.executorId?.trim() ? [] : ["executorId"]; + } + for (const field of definition.publicFields) { if (field.id === "mcpEndpoint" && mode !== "mcp_kv") { continue; @@ -681,6 +728,19 @@ export async function updateExternalServiceSettings( return mapRow(definition, row, dependencyStatuses); } +export async function getSeoAnalyticsModelRuntimeConfig() { + const rows = await getSettingsRows(); + const row = rows.get("seo_analytics_model") ?? null; + const definition = serviceDefinitions.seo_analytics_model; + const publicConfig = getPublicConfig(definition, row); + + return { + enabled: Boolean(row?.enabled && row.mode === "local_codex"), + executorId: publicConfig.executorId?.trim() || null, + mode: row?.mode ?? "disabled" + }; +} + function splitCsv(value: string | undefined, fallback: string[]) { const values = (value ?? "") .split(",") @@ -711,7 +771,8 @@ export async function getWordstatRuntimeConfig(): Promise return { provider: "mcp_kv", mode: "mcp_kv", - mcpEndpoint: endpoint + mcpEndpoint: endpoint, + maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40) }; } } @@ -731,7 +792,8 @@ export async function getWordstatRuntimeConfig(): Promise folderId: cloudPublicConfig.folderId, regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]), devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]), - numPhrases: getNumber(mapped.publicConfig.numPhrases, 50) + numPhrases: getNumber(mapped.publicConfig.numPhrases, 50), + maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40) }; } @@ -739,7 +801,8 @@ export async function getWordstatRuntimeConfig(): Promise return { provider: "mcp_kv", mode: "mcp_kv", - mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint + mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint, + maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun }; } @@ -758,7 +821,8 @@ export async function getWordstatRuntimeConfig(): Promise folderId: env.marketProviders.wordstat.folderId, regionIds: env.marketProviders.wordstat.regionIds, devices: env.marketProviders.wordstat.devices, - numPhrases: env.marketProviders.wordstat.numPhrases + numPhrases: env.marketProviders.wordstat.numPhrases, + maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun }; } diff --git a/seo_mode/seo_mode/server/src/settings/routes.ts b/seo_mode/seo_mode/server/src/settings/routes.ts index 8faf2ef..6d7e243 100644 --- a/seo_mode/seo_mode/server/src/settings/routes.ts +++ b/seo_mode/seo_mode/server/src/settings/routes.ts @@ -6,11 +6,16 @@ import { updateExternalServiceSettings, type ExternalServiceId } from "./externalServiceSettings.js"; +import { + createSeoAnalyticsModelSetupCommand, + probeSeoAnalyticsModel +} from "./seoAnalyticsModelBridge.js"; import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js"; export const settingsRouter = Router(); const externalServiceIdSchema = z.enum([ + "seo_analytics_model", "yandex_cloud", "yandex_metrica", "yandex_search_serp", @@ -27,6 +32,13 @@ const updateExternalServiceSettingsSchema = z.object({ const yandexAiStudioProbeSchema = z.object({ phrase: z.string().trim().min(2).max(160).optional() }); +const seoAnalyticsModelSetupCommandSchema = z.object({ + executorId: z.string().uuid().optional(), + port: z.coerce.number().int().positive().max(65535).optional() +}); +const seoAnalyticsModelProbeSchema = z.object({ + executorId: z.string().uuid().optional() +}); function sendError(response: Response, status: number, message: string) { response.status(status).json({ @@ -65,6 +77,42 @@ settingsRouter.post("/external-services/yandex_cloud/probe", async (request, res } }); +settingsRouter.post("/external-services/seo_analytics_model/setup-command", async (request, response) => { + try { + const input = seoAnalyticsModelSetupCommandSchema.parse(request.body ?? {}); + + response.status(201).json({ + setupCommand: await createSeoAnalyticsModelSetupCommand(input) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры setup command."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось подготовить Codex bridge setup command."); + } +}); + +settingsRouter.post("/external-services/seo_analytics_model/probe", async (request, response) => { + try { + const input = seoAnalyticsModelProbeSchema.parse(request.body ?? {}); + + response.json({ + probe: await probeSeoAnalyticsModel(input) + }); + } catch (error) { + if (error instanceof z.ZodError) { + sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры probe."); + return; + } + + console.error(error); + sendError(response, 500, error instanceof Error ? error.message : "Не удалось проверить Codex bridge."); + } +}); + settingsRouter.patch("/external-services/:serviceId", async (request, response) => { try { const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId; diff --git a/seo_mode/seo_mode/server/src/settings/seoAnalyticsModelBridge.ts b/seo_mode/seo_mode/server/src/settings/seoAnalyticsModelBridge.ts new file mode 100644 index 0000000..01fe3a1 --- /dev/null +++ b/seo_mode/seo_mode/server/src/settings/seoAnalyticsModelBridge.ts @@ -0,0 +1,320 @@ +import { randomUUID } from "node:crypto"; +import { env } from "../config/env.js"; +import { assistantRequest } from "../modelProvider/aiWorkspaceBridge.js"; +import { getSeoAiWorkspaceConfigStatus } from "../modelProvider/aiWorkspaceBridgeConfig.js"; + +type JsonRecord = Record; + +type AiWorkspaceExecutor = { + id: string; + name: string; + type: string; + connectionMode: "direct" | "hub"; + workspacePath: string | null; + pairingCode: string | null; + model: string | null; + accountLabel: string | null; + capabilities: string[]; + status: "unknown" | "online" | "offline" | "checking" | "error"; + statusDetail: string; + lastSeenAt: string | null; + metadata: JsonRecord; +}; + +type AiWorkspaceExecutorsResponse = { + selectedExecutorId?: string | null; + executors?: AiWorkspaceExecutor[]; +}; + +type AiWorkspaceExecutorResponse = { + executor?: AiWorkspaceExecutor; +}; + +type AiWorkspaceSetupCommandResponse = { + install?: { + command?: string; + packageName?: string; + packageSpec?: string; + gatewayUrl?: string; + expiresAt?: string; + }; + setupCode?: { + suffix?: string; + expiresAt?: string; + }; +}; + +type AiWorkspaceExecutorCheckResponse = { + executor?: AiWorkspaceExecutor; + check?: JsonRecord; +}; + +export type SeoAnalyticsModelSetupCommand = { + schemaVersion: "seo-analytics-model-setup-command.v1"; + generatedAt: string; + profile: string; + executor: { + id: string; + name: string; + status: AiWorkspaceExecutor["status"]; + statusDetail: string; + }; + install: { + command: string; + packageName: string; + packageSpec: string; + gatewayUrl: string; + expiresAt: string | null; + }; + setupCode: { + suffix: string | null; + expiresAt: string | null; + }; +}; + +export type SeoAnalyticsModelProbe = { + schemaVersion: "seo-analytics-model-probe.v1"; + checkedAt: string; + profile: string; + config: { + configured: boolean; + assistantUrl: string | null; + selectedExecutorId: string | null; + contractWorkspacePath: string | null; + errors: string[]; + warnings: string[]; + }; + executor: { + id: string | null; + name: string | null; + status: AiWorkspaceExecutor["status"] | "not_configured"; + statusDetail: string; + lastSeenAt: string | null; + }; + connection: { + ok: boolean; + status: "connected" | "missing_config" | "not_configured" | "offline" | "error"; + detail: string; + }; + check: JsonRecord | null; +}; + +function isJsonRecord(value: unknown): value is JsonRecord { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function isUuid(value: string | null | undefined) { + return Boolean( + value && + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) + ); +} + +function isSeoAnalyticsExecutor(executor: AiWorkspaceExecutor) { + const metadata = isJsonRecord(executor.metadata) ? executor.metadata : {}; + + return ( + metadata.appId === "seo-mode" || + metadata.surface === "seo-mode" || + metadata.modeId === "seo-model-task" || + executor.name === "NDC SEO Mode Codex Bridge" || + executor.name === "NDC SEO Mode Local Codex" + ); +} + +async function listAiWorkspaceExecutors() { + const response = await assistantRequest("/api/ai-workspace/assistant/v1/executors", { + method: "GET" + }); + + return { + executors: Array.isArray(response.executors) ? response.executors : [], + selectedExecutorId: typeof response.selectedExecutorId === "string" ? response.selectedExecutorId : null + }; +} + +async function createSeoAnalyticsExecutor() { + const response = await assistantRequest("/api/ai-workspace/assistant/v1/executors", { + body: { + accountLabel: "NDC SEO Mode", + capabilities: ["codex-exec", "dynamic-run-profile", "seo-model-task", "structured-json"], + connectionMode: "hub", + id: randomUUID(), + metadata: { + appId: "seo-mode", + createdBy: "nodedc-seo-mode", + modeId: "seo-model-task", + profile: env.aiWorkspace.profile, + surface: "seo-mode" + }, + model: "codex-bridge", + name: "NDC SEO Mode Codex Bridge", + status: "unknown", + type: "codex-remote", + workspacePath: env.aiWorkspace.contractWorkspacePath ?? "" + }, + method: "POST" + }); + + if (!response.executor?.id) { + throw new Error("ai_workspace_executor_id_missing"); + } + + return response.executor; +} + +async function resolveSeoAnalyticsExecutor(inputExecutorId?: string | null) { + const { executors, selectedExecutorId } = await listAiWorkspaceExecutors(); + const explicitExecutorId = + (isUuid(inputExecutorId) ? inputExecutorId : null) || + (isUuid(env.aiWorkspace.selectedExecutorId) ? env.aiWorkspace.selectedExecutorId : null); + const explicitExecutor = explicitExecutorId + ? executors.find((executor) => executor.id === explicitExecutorId) ?? null + : null; + const selectedExecutor = selectedExecutorId + ? executors.find((executor) => executor.id === selectedExecutorId && isSeoAnalyticsExecutor(executor)) ?? null + : null; + const seoExecutor = executors.find(isSeoAnalyticsExecutor) ?? null; + + return { + executor: explicitExecutor ?? selectedExecutor ?? seoExecutor, + selectedExecutorId + }; +} + +export async function createSeoAnalyticsModelSetupCommand(input: { executorId?: string | null; port?: number }) { + const configStatus = getSeoAiWorkspaceConfigStatus(); + + if (!configStatus.configured) { + throw new Error(configStatus.errors.join(" ") || "SEO AI Workspace bridge is not configured."); + } + + const resolved = await resolveSeoAnalyticsExecutor(input.executorId); + const executor = resolved.executor ?? await createSeoAnalyticsExecutor(); + + const setup = await assistantRequest( + `/api/ai-workspace/assistant/v1/executors/${encodeURIComponent(executor.id)}/agent/setup-command`, + { + body: { + port: input.port ?? 8787 + }, + method: "POST" + } + ); + const command = String(setup.install?.command || ""); + + if (!command) { + throw new Error("ai_workspace_setup_command_missing"); + } + + return { + schemaVersion: "seo-analytics-model-setup-command.v1", + generatedAt: new Date().toISOString(), + profile: configStatus.profile, + executor: { + id: executor.id, + name: executor.name, + status: executor.status, + statusDetail: executor.statusDetail + }, + install: { + command, + packageName: String(setup.install?.packageName || ""), + packageSpec: String(setup.install?.packageSpec || ""), + gatewayUrl: String(setup.install?.gatewayUrl || ""), + expiresAt: setup.install?.expiresAt ?? null + }, + setupCode: { + suffix: setup.setupCode?.suffix ?? null, + expiresAt: setup.setupCode?.expiresAt ?? setup.install?.expiresAt ?? null + } + } satisfies SeoAnalyticsModelSetupCommand; +} + +export async function probeSeoAnalyticsModel(input: { executorId?: string | null } = {}): Promise { + const checkedAt = new Date().toISOString(); + const configStatus = getSeoAiWorkspaceConfigStatus(); + const config = { + configured: configStatus.configured, + assistantUrl: configStatus.assistantUrl, + selectedExecutorId: configStatus.selectedExecutorId, + contractWorkspacePath: configStatus.contractWorkspacePath, + errors: configStatus.errors, + warnings: configStatus.warnings + }; + + if (!configStatus.configured) { + return { + schemaVersion: "seo-analytics-model-probe.v1", + checkedAt, + profile: configStatus.profile, + config, + executor: { + id: null, + name: null, + status: "not_configured", + statusDetail: "missing_ai_workspace_config", + lastSeenAt: null + }, + connection: { + ok: false, + status: "missing_config", + detail: configStatus.errors.join(" ") || "SEO AI Workspace bridge is not configured." + }, + check: null + }; + } + + const resolved = await resolveSeoAnalyticsExecutor(input.executorId); + + if (!resolved.executor) { + return { + schemaVersion: "seo-analytics-model-probe.v1", + checkedAt, + profile: configStatus.profile, + config, + executor: { + id: null, + name: null, + status: "not_configured", + statusDetail: "seo_codex_executor_missing", + lastSeenAt: null + }, + connection: { + ok: false, + status: "not_configured", + detail: "Сначала сгенерируй NPM-команду и запусти bridge на Codex-машине." + }, + check: null + }; + } + + const response = await assistantRequest( + `/api/ai-workspace/assistant/v1/executors/${encodeURIComponent(resolved.executor.id)}/check`, + { + method: "POST" + } + ); + const executor = response.executor ?? resolved.executor; + const isConnected = executor.status === "online"; + + return { + schemaVersion: "seo-analytics-model-probe.v1", + checkedAt, + profile: configStatus.profile, + config, + executor: { + id: executor.id, + name: executor.name, + status: executor.status, + statusDetail: executor.statusDetail, + lastSeenAt: executor.lastSeenAt + }, + connection: { + ok: isConnected, + status: isConnected ? "connected" : executor.status === "error" ? "error" : "offline", + detail: isConnected ? "Codex bridge is online." : executor.statusDetail || "bridge_agent_offline" + }, + check: response.check ?? null + }; +} diff --git a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts index 503a6b3..0190a25 100644 --- a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts +++ b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts @@ -207,6 +207,10 @@ function normalizeKey(value: string | null) { return value?.trim() || "unmapped"; } +function normalizePhraseKey(value: string) { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU"); +} + function getConfidence(score: number): SeoStrategyConfidence { if (score >= 75) { return "high"; @@ -953,14 +957,22 @@ function buildOpportunities( function buildSerpOpportunities( serpInterpretation: SerpInterpretationContract, + keywordMap: KeywordMapContract, siteMaturity: SeoStrategyContract["siteMaturity"] ): SeoStrategyContract["opportunities"] { if (serpInterpretation.state !== "ready") { return []; } + const approvedPhraseSet = new Set(keywordMap.persisted.items.map((item) => normalizePhraseKey(item.phrase))); + return serpInterpretation.phraseInterpretations - .filter((phrase) => phrase.confidence !== "low" && phrase.intent !== "unknown") + .filter( + (phrase) => + phrase.confidence !== "low" && + phrase.intent !== "unknown" && + approvedPhraseSet.has(normalizePhraseKey(phrase.phrase)) + ) .slice(0, 6) .map((phrase) => { const isMismatch = phrase.targetFit === "mismatch"; @@ -1336,7 +1348,7 @@ export async function getSeoStrategyContract(projectId: string): Promise { + const record = (item && typeof item === "object" ? item : {}) as Record; + + return makeCheck({ + action: typeof record.action === "string" && record.action.trim() ? record.action.trim() : "Проверить пункт quality gate вручную.", + category: normalizeCheckCategory(record.category), + evidenceRefs: Array.isArray(record.evidenceRefs) ? record.evidenceRefs.map((ref) => String(ref || "").trim()).filter(Boolean) : [], + id: typeof record.id === "string" && record.id.trim() ? record.id.trim() : `model-check:${index + 1}`, + message: typeof record.message === "string" && record.message.trim() ? record.message.trim() : "Модель вернула проверку без описания.", + severity: normalizeCheckSeverity(record.severity), + status: normalizeCheckStatus(record.status), + title: typeof record.title === "string" && record.title.trim() ? record.title.trim() : `Проверка ${index + 1}` + }); + }); +} + +function stringList(value: unknown, fallback: string[]) { + if (!Array.isArray(value)) { + return fallback; + } + + const result = value.map((item) => { + if (typeof item === "string") { + return item.trim(); + } + + if (item && typeof item === "object") { + const record = item as Record; + + for (const key of ["title", "message", "action", "summary", "nextStep", "label"]) { + const text = record[key]; + + if (typeof text === "string" && text.trim()) { + return text.trim(); + } + } + } + + return String(item || "").trim(); + }).filter(Boolean); + + return result.length > 0 ? result : fallback; +} + +function mergeModelProviderStrategyQualityReviewOutput( + output: StrategyQualityReviewContract, + fallback: StrategyQualityReviewContract +): StrategyQualityReviewContract { + const rawOutput = output as StrategyQualityReviewContract & Record; + const rawVerdict = rawOutput.verdict; + const verdict = (rawVerdict && typeof rawVerdict === "object" ? rawVerdict : {}) as Record; + const status = isVerdictStatus(rawVerdict) ? rawVerdict : isVerdictStatus(verdict.status) ? verdict.status : fallback.verdict.status; + const blockers = stringList(rawOutput.blockers, fallback.blockers); + const warnings = stringList(rawOutput.warnings, fallback.warnings); + const summary = + typeof rawOutput.summary === "string" && rawOutput.summary.trim() + ? rawOutput.summary.trim() + : typeof verdict.summary === "string" && verdict.summary.trim() + ? verdict.summary.trim() + : fallback.summary; + const score = Number(verdict.score ?? rawOutput.readiness?.score ?? fallback.verdict.score); + + return { + ...fallback, + ...output, + analystReview: output.analystReview ?? fallback.analystReview, + approvedSignals: stringList(rawOutput.approvedSignals, fallback.approvedSignals), + blockers, + checks: normalizeModelProviderChecks(rawOutput.checks, fallback.checks), + nextActions: stringList(rawOutput.nextActions, fallback.nextActions), + readiness: { + ...fallback.readiness, + ...(rawOutput.readiness && typeof rawOutput.readiness === "object" ? rawOutput.readiness : {}) + }, + runId: output.runId ?? fallback.runId, + semanticRunId: output.semanticRunId ?? fallback.semanticRunId, + sourceSerpInterpretationRunId: output.sourceSerpInterpretationRunId ?? fallback.sourceSerpInterpretationRunId, + sourceStrategyGeneratedAt: output.sourceStrategyGeneratedAt ?? fallback.sourceStrategyGeneratedAt, + sourceStrategySynthesisRunId: output.sourceStrategySynthesisRunId ?? fallback.sourceStrategySynthesisRunId, + sourceTaskId: output.sourceTaskId ?? fallback.sourceTaskId, + state: output.state ?? fallback.state, + summary, + verdict: { + confidence: isConfidence(verdict.confidence) ? verdict.confidence : fallback.verdict.confidence, + primaryBlocker: + typeof verdict.primaryBlocker === "string" && verdict.primaryBlocker.trim() + ? verdict.primaryBlocker.trim() + : blockers[0] ?? fallback.verdict.primaryBlocker, + score: Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score))) : fallback.verdict.score, + status, + summary, + title: + typeof verdict.title === "string" && verdict.title.trim() + ? verdict.title.trim() + : status === "approved" + ? "Стратегия прошла проверку" + : status === "needs_changes" + ? "Стратегия требует исправлений" + : fallback.verdict.title + }, + warnings + }; +} + function mapStrategyQualityReviewRun(row: StrategyQualityReviewRunRow): StrategyQualityReviewRun | null { if (!row.output) { return null; @@ -224,9 +379,18 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis 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 expansionOptions = synthesis.decisionOptions.filter((option) => option.scenario === "serp_expansion"); + const expansionCurrentRoute = expansionOptions.some((option) => option.id === synthesis.recommendedPath.id); + const expansionActionPattern = /расширени|расширить|соседн|expansion/i; + const expansionBlockedPattern = /не |нельзя|запрет|отлож|будущ|после|без /i; + const hasExpansionDoNow = [...synthesis.recommendedPath.doNow, ...synthesis.phaseDecision.allowedNow].some( + (action) => expansionActionPattern.test(action) && !expansionBlockedPattern.test(action) ); + const hasExpansionLeak = strategy.siteMaturity.phase === "bootstrap_indexing" && (expansionCurrentRoute || hasExpansionDoNow); + const hasFutureExpansionBranch = + !hasExpansionLeak && + strategy.siteMaturity.phase === "bootstrap_indexing" && + expansionOptions.some((option) => option.confidence !== "low" || option.priority !== "low" || option.expectedImpact !== "low"); 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; @@ -288,14 +452,18 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis makeCheck({ action: hasExpansionLeak ? "Понизить доверие у ветки расширения или вынести её в отдельную ветку после подтверждения пользователем." - : "Оставить расширение только как будущую ветку с низким доверием.", + : hasFutureExpansionBranch + ? "Оставить расширение отдельной будущей веткой и не показывать его как текущее действие." + : "Оставить расширение только как будущую ветку с низким доверием.", category: "business_drift", id: "business:no_core_expansion_leak", message: hasExpansionLeak ? "Ветка расширения выглядит как основной маршрут для стартовой фазы." - : "Расширение бизнеса не смешано с текущим основным SEO-маршрутом.", + : hasFutureExpansionBranch + ? "Расширение есть как отдельная будущая ветка, но не попало в текущий маршрут." + : "Расширение бизнеса не смешано с текущим основным SEO-маршрутом.", severity: hasExpansionLeak ? "high" : "low", - status: hasExpansionLeak ? "fail" : "pass", + status: hasExpansionLeak ? "fail" : hasFutureExpansionBranch ? "warning" : "pass", title: "Нет смешения с расширением бизнеса" }), makeCheck({ @@ -503,6 +671,8 @@ function normalizeOutput( 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); + const score = getScore(output.checks); + const summary = `${getVerdictStatusSummary(output.verdict.status)}: проверок ${output.checks.length}, пройдено ${approvedSignals.length}, блокеров ${blockers.length}, предупреждений ${warnings.length}.`; return { ...output, @@ -524,11 +694,18 @@ function normalizeOutput( 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, + score, uxIssueCount: output.checks.filter((check) => check.category === "ux_clarity" && check.status !== "pass").length, warningCount: warnings.length }, + summary, state: output.checks.length > 0 ? "ready" : "not_ready", + verdict: { + ...output.verdict, + primaryBlocker: blockers[0] ?? output.verdict.primaryBlocker, + score, + summary + }, warnings }; } @@ -619,3 +796,54 @@ export async function saveStrategyQualityReviewManual( return run; } + +export async function saveStrategyQualityReviewFromModelProvider( + projectId: string, + output: StrategyQualityReviewContract, + sourceModelTaskRunId: string +): Promise { + const [strategy, synthesis, serpInterpretation] = await Promise.all([ + getSeoStrategyContract(projectId), + getLatestStrategySynthesisContract(projectId), + getLatestSerpInterpretationContract(projectId) + ]); + const task = buildStrategyQualityReviewModelTask(projectId, strategy, synthesis, serpInterpretation); + const fallbackOutput = task ? buildStrategyQualityReviewOutput(projectId, strategy, synthesis, serpInterpretation) : null; + + if (!task || !fallbackOutput) { + throw new Error("Не удалось собрать backend fallback для AI Workspace strategy quality review."); + } + + const normalizedOutput = normalizeOutput( + projectId, + mergeModelProviderStrategyQualityReviewOutput(output, fallbackOutput), + "codex_workspace", + "AI Workspace Codex provider выполнил seo.strategy_quality_review по contract-only task; результат сохранён как evidence после backend validation." + ); + const result = await pool.query( + ` + 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, + { + providerMode: "codex_workspace", + schemaVersion: "seo-strategy-quality-review-model-provider-input.v1", + sourceModelTaskRunId, + 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("Не удалось сохранить AI Workspace strategy quality review."); + } + + return run; +} diff --git a/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts b/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts index deb7544..e130f51 100644 --- a/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts +++ b/seo_mode/seo_mode/server/src/strategy/strategySynthesis.ts @@ -4,7 +4,7 @@ import { getSeoStrategyContract, type SeoStrategyContract } from "./seoStrategy. import { buildStrategySynthesisModelTask } from "./strategySynthesisTask.js"; type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; -type StrategySynthesisProviderMode = "codex_manual" | "deterministic_fallback"; +type StrategySynthesisProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback"; type StrategySynthesisState = "not_ready" | "ready"; type StrategySynthesisConfidence = "high" | "low" | "medium"; type StrategySynthesisPriority = "high" | "low" | "medium"; @@ -158,6 +158,142 @@ function getDefaultAnalystReview(): StrategySynthesisContract["analystReview"] { }; } +function isConfidence(value: unknown): value is StrategySynthesisConfidence { + return value === "high" || value === "medium" || value === "low"; +} + +function isPriority(value: unknown): value is StrategySynthesisPriority { + return value === "high" || value === "medium" || value === "low"; +} + +function stringList(value: unknown, fallback: string[]) { + if (!Array.isArray(value)) { + return fallback; + } + + const result = value.map((item) => { + if (typeof item === "string") { + return item.trim(); + } + + if (item && typeof item === "object") { + const record = item as Record; + + for (const key of ["title", "message", "nextStep", "action", "summary", "label"]) { + const text = record[key]; + + if (typeof text === "string" && text.trim()) { + return text.trim(); + } + } + } + + return String(item || "").trim(); + }).filter(Boolean); + + return result.length > 0 ? result : fallback; +} + +function mergeModelProviderStrategySynthesisOutput( + output: StrategySynthesisContract, + fallback: StrategySynthesisContract +): StrategySynthesisContract { + const rawOutput = output as StrategySynthesisContract & Record; + const executiveSummary = (rawOutput.executiveSummary ?? {}) as Record; + const phaseDecision = (rawOutput.phaseDecision ?? {}) as Record; + const recommendedPath = (rawOutput.recommendedPath ?? {}) as Record; + const summary = + typeof rawOutput.summary === "string" && rawOutput.summary.trim() + ? rawOutput.summary.trim() + : typeof executiveSummary.summary === "string" && executiveSummary.summary.trim() + ? executiveSummary.summary.trim() + : fallback.summary; + const executiveKeyPoints = stringList(executiveSummary.keyPoints, []); + const blockedUntil = stringList(recommendedPath.blockedUntil, []); + + return { + ...fallback, + ...output, + analystReview: output.analystReview ?? fallback.analystReview, + blockedActions: stringList(rawOutput.blockedActions, fallback.blockedActions), + dataGaps: Array.isArray(rawOutput.dataGaps) ? rawOutput.dataGaps : fallback.dataGaps, + decisionOptions: Array.isArray(rawOutput.decisionOptions) ? rawOutput.decisionOptions : fallback.decisionOptions, + evidenceNarrative: Array.isArray(rawOutput.evidenceNarrative) ? rawOutput.evidenceNarrative : fallback.evidenceNarrative, + executiveSummary: { + confidence: isConfidence(executiveSummary.confidence) ? executiveSummary.confidence : fallback.executiveSummary.confidence, + primaryConstraint: + typeof executiveSummary.primaryConstraint === "string" && executiveSummary.primaryConstraint.trim() + ? executiveSummary.primaryConstraint.trim() + : blockedUntil[0] ?? fallback.executiveSummary.primaryConstraint, + title: + typeof executiveSummary.title === "string" && executiveSummary.title.trim() + ? executiveSummary.title.trim() + : fallback.executiveSummary.title, + verdict: + typeof executiveSummary.verdict === "string" && executiveSummary.verdict.trim() + ? executiveSummary.verdict.trim() + : summary, + whyNow: + typeof executiveSummary.whyNow === "string" && executiveSummary.whyNow.trim() + ? executiveSummary.whyNow.trim() + : executiveKeyPoints.length > 0 + ? executiveKeyPoints.join(" ") + : fallback.executiveSummary.whyNow + }, + generatedAt: output.generatedAt ?? fallback.generatedAt, + nextActions: stringList(rawOutput.nextActions, fallback.nextActions), + phaseDecision: { + allowedNow: stringList(phaseDecision.allowedNow, fallback.phaseDecision.allowedNow), + currentPhase: phaseDecision.currentPhase ?? fallback.phaseDecision.currentPhase, + hold: stringList(phaseDecision.hold, stringList(phaseDecision.deferred, fallback.phaseDecision.hold)), + phaseRoadmap: Array.isArray(phaseDecision.phaseRoadmap) ? phaseDecision.phaseRoadmap : fallback.phaseDecision.phaseRoadmap, + prepareNext: stringList(phaseDecision.prepareNext, fallback.phaseDecision.prepareNext), + promotionSignals: stringList(phaseDecision.promotionSignals, fallback.phaseDecision.promotionSignals), + schemaVersion: "seo-phase-decision.v1", + summary: + typeof phaseDecision.summary === "string" && phaseDecision.summary.trim() + ? phaseDecision.summary.trim() + : typeof phaseDecision.reason === "string" && phaseDecision.reason.trim() + ? phaseDecision.reason.trim() + : fallback.phaseDecision.summary, + title: + typeof phaseDecision.title === "string" && phaseDecision.title.trim() + ? phaseDecision.title.trim() + : fallback.phaseDecision.title + }, + phaseStrategies: Array.isArray(rawOutput.phaseStrategies) ? rawOutput.phaseStrategies : fallback.phaseStrategies, + readiness: { + ...fallback.readiness, + ...(rawOutput.readiness && typeof rawOutput.readiness === "object" ? rawOutput.readiness : {}) + }, + recommendedPath: { + confidence: isConfidence(recommendedPath.confidence) ? recommendedPath.confidence : fallback.recommendedPath.confidence, + doNow: stringList(recommendedPath.doNow, stringList(recommendedPath.currentActions, fallback.recommendedPath.doNow)), + expectedImpact: isPriority(recommendedPath.expectedImpact) ? recommendedPath.expectedImpact : fallback.recommendedPath.expectedImpact, + hold: stringList(recommendedPath.hold, blockedUntil.length > 0 ? blockedUntil : fallback.recommendedPath.hold), + id: typeof recommendedPath.id === "string" && recommendedPath.id.trim() ? recommendedPath.id.trim() : fallback.recommendedPath.id, + needsEvidence: stringList(recommendedPath.needsEvidence, blockedUntil.length > 0 ? blockedUntil : fallback.recommendedPath.needsEvidence), + priority: isPriority(recommendedPath.priority) ? recommendedPath.priority : fallback.recommendedPath.priority, + rationale: + typeof recommendedPath.rationale === "string" && recommendedPath.rationale.trim() + ? recommendedPath.rationale.trim() + : fallback.recommendedPath.rationale, + title: + typeof recommendedPath.title === "string" && recommendedPath.title.trim() + ? recommendedPath.title.trim() + : fallback.recommendedPath.title + }, + riskPosture: Array.isArray(rawOutput.riskPosture) ? rawOutput.riskPosture : fallback.riskPosture, + runId: output.runId ?? fallback.runId, + semanticRunId: output.semanticRunId ?? fallback.semanticRunId, + sourceSerpInterpretationRunId: output.sourceSerpInterpretationRunId ?? fallback.sourceSerpInterpretationRunId, + sourceStrategyGeneratedAt: output.sourceStrategyGeneratedAt ?? fallback.sourceStrategyGeneratedAt, + sourceTaskId: output.sourceTaskId ?? fallback.sourceTaskId, + state: output.state ?? fallback.state, + summary + }; +} + function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesisRun | null { if (!row.output) { return null; @@ -618,3 +754,53 @@ export async function saveStrategySynthesisManual( return run; } + +export async function saveStrategySynthesisFromModelProvider( + projectId: string, + output: StrategySynthesisContract, + sourceModelTaskRunId: string +): Promise { + const [strategy, serpInterpretation] = await Promise.all([ + getSeoStrategyContract(projectId), + getLatestSerpInterpretationContract(projectId) + ]); + const fallbackOutput = buildStrategySynthesisOutput(projectId, strategy, serpInterpretation); + + if (!fallbackOutput) { + throw new Error("Не удалось собрать backend fallback для AI Workspace Strategy synthesis."); + } + + const normalizedOutput = normalizeOutput( + projectId, + mergeModelProviderStrategySynthesisOutput(output, fallbackOutput), + "codex_workspace", + "AI Workspace Codex provider выполнил seo.strategy_synthesis по contract-only task; результат сохранён как evidence после backend validation." + ); + const result = await pool.query( + ` + 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_workspace", + schemaVersion: "seo-strategy-synthesis-model-provider-input.v1", + sourceModelTaskRunId, + 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("Не удалось сохранить AI Workspace Strategy synthesis."); + } + + return run; +}