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 · Распределение ключей
- Перетащи запросы между колонками, чтобы поправить модельную сортировку перед стратегией.
+ Модель распределяет, пользователь правит. Неразобранные не участвуют.
@@ -5346,12 +5367,12 @@ function getKeywordMapStateLabel(state: KeywordMapContract["state"]) {
function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) {
const labels = {
- differentiator: "дифф.",
- primary: "главный",
- secondary: "доп.",
+ differentiator: "уточняющие / УТП",
+ primary: "посадочное ядро",
+ secondary: "поддерживающее ядро",
secondary_candidate: "кандидат",
- support: "поддержка",
- validate: "проверить"
+ support: "длинный хвост",
+ validate: "неразобранные"
};
return labels[role as keyof typeof labels] ?? role;
@@ -5928,6 +5949,18 @@ function getModelTaskRunStatusLabel(status: SeoModelTaskRun["status"]) {
return labels[status];
}
+function getModelTaskExecutionStatusLabel(status: SeoModelTaskRun["runStatus"]) {
+ const labels = {
+ cancelled: "отменено",
+ done: "готово",
+ failed: "ошибка",
+ queued: "в очереди",
+ running: "в работе"
+ };
+
+ return labels[status];
+}
+
function getModelProviderStatusLabel(status: SeoModelProviderStatusContract["catalog"]["providers"][number]["status"]) {
const labels = {
active: "активен",
@@ -5951,6 +5984,7 @@ function getExternalServiceStatusLabel(status: ExternalService["status"]) {
function getExternalServiceShortName(serviceId: ExternalService["id"]) {
const labels = {
+ seo_analytics_model: "Аналитическая модель",
yandex_cloud: "Yandex AI Studio / Cloud API key",
yandex_metrica: "Yandex Metrica",
yandex_search_serp: "Yandex SERP",
@@ -5963,9 +5997,12 @@ function getExternalServiceShortName(serviceId: ExternalService["id"]) {
function getExternalMissingFieldLabel(fieldId: string) {
const labels: Record = {
+ aiWorkspaceProfile: "AI Workspace profile",
apiKey: "Yandex AI Studio API key",
+ executorId: "Codex bridge executor",
folderId: "folderId",
iamToken: "IAM token",
+ seo_analytics_model: "Аналитическая модель",
yandex_cloud: "Yandex AI Studio / Cloud API key"
};
@@ -5986,6 +6023,10 @@ function buildExternalServiceDrafts(settings: ExternalServiceSettings) {
}
function getVisiblePublicFields(service: ExternalService, draft: ExternalServiceDraft | undefined) {
+ if (service.id === "seo_analytics_model") {
+ return [];
+ }
+
if (service.id === "yandex_wordstat" && draft?.mode === "mcp_kv") {
return service.publicFields.filter((field) => field.id === "mcpEndpoint");
}
@@ -8472,6 +8513,7 @@ export function App() {
const [interpretingSerpProjectId, setInterpretingSerpProjectId] = useState(null);
const [synthesizingStrategyProjectId, setSynthesizingStrategyProjectId] = useState(null);
const [reviewingStrategyQualityProjectId, setReviewingStrategyQualityProjectId] = useState(null);
+ const [reviewingKeywordCleaningProjectId, setReviewingKeywordCleaningProjectId] = useState(null);
const [persistingKeywordMapProjectId, setPersistingKeywordMapProjectId] = useState(null);
const [materializingTargetKey, setMaterializingTargetKey] = useState(null);
const [buildingPatchProjectId, setBuildingPatchProjectId] = useState(null);
@@ -8513,6 +8555,10 @@ export function App() {
const [savingExternalServiceId, setSavingExternalServiceId] = useState(null);
const [probingExternalServiceId, setProbingExternalServiceId] = useState(null);
const [yandexAiStudioProbe, setYandexAiStudioProbe] = useState(null);
+ const [seoAnalyticsModelProbe, setSeoAnalyticsModelProbe] = useState(null);
+ const [seoAnalyticsModelSetupCommand, setSeoAnalyticsModelSetupCommand] =
+ useState(null);
+ const [generatingSeoAnalyticsModelCommand, setGeneratingSeoAnalyticsModelCommand] = useState(false);
const [externalServicesError, setExternalServicesError] = useState(null);
const [externalServicesMessage, setExternalServicesMessage] = useState(null);
@@ -8781,6 +8827,7 @@ export function App() {
setExternalServices(result.externalServices);
setExternalServiceDrafts(buildExternalServiceDrafts(result.externalServices));
+ void refreshSeoAnalyticsModelProbe(result.externalServices);
} catch (error: unknown) {
setExternalServicesError(getErrorMessage(error, "Не удалось загрузить внешние сервисы."));
} finally {
@@ -8788,9 +8835,31 @@ export function App() {
}
}
+ async function refreshSeoAnalyticsModelProbe(settings: ExternalServiceSettings) {
+ const service = settings.services.find((item) => item.id === "seo_analytics_model");
+ const executorId = service?.publicConfig.executorId?.trim();
+
+ if (!service?.enabled || service.mode !== "local_codex" || !executorId) {
+ return;
+ }
+
+ setProbingExternalServiceId("seo_analytics_model");
+
+ try {
+ const result = await probeSeoAnalyticsModel({ executorId });
+ setSeoAnalyticsModelProbe(result.probe);
+ } catch (error: unknown) {
+ setExternalServicesError(getErrorMessage(error, "Не удалось проверить Codex bridge."));
+ } finally {
+ setProbingExternalServiceId((currentId) => (currentId === "seo_analytics_model" ? null : currentId));
+ }
+ }
+
function openExternalServicesModal() {
setIsExternalServicesOpen(true);
setYandexAiStudioProbe(null);
+ setSeoAnalyticsModelProbe(null);
+ setSeoAnalyticsModelSetupCommand(null);
void loadExternalServices();
}
@@ -8884,6 +8953,84 @@ export function App() {
}
}
+ async function handleGenerateSeoAnalyticsModelCommand() {
+ setGeneratingSeoAnalyticsModelCommand(true);
+ setExternalServicesError(null);
+ setExternalServicesMessage(null);
+
+ try {
+ const currentExecutorId = externalServiceDrafts.seo_analytics_model?.publicConfig.executorId?.trim();
+ const result = await createSeoAnalyticsModelSetupCommand({
+ executorId: currentExecutorId || undefined
+ });
+
+ setSeoAnalyticsModelSetupCommand(result.setupCommand);
+ setExternalServiceDrafts((currentDrafts) => {
+ const currentDraft = currentDrafts.seo_analytics_model;
+
+ if (!currentDraft) {
+ return currentDrafts;
+ }
+
+ return {
+ ...currentDrafts,
+ seo_analytics_model: {
+ ...currentDraft,
+ publicConfig: {
+ ...currentDraft.publicConfig,
+ executorId: result.setupCommand.executor.id
+ }
+ }
+ };
+ });
+ setExternalServicesMessage("Codex bridge: setup command подготовлена.");
+ } catch (error: unknown) {
+ setExternalServicesError(getErrorMessage(error, "Не удалось подготовить Codex bridge setup command."));
+ } finally {
+ setGeneratingSeoAnalyticsModelCommand(false);
+ }
+ }
+
+ async function handleProbeSeoAnalyticsModel() {
+ setProbingExternalServiceId("seo_analytics_model");
+ setExternalServicesError(null);
+ setExternalServicesMessage(null);
+
+ try {
+ const executorId =
+ externalServiceDrafts.seo_analytics_model?.publicConfig.executorId?.trim() ||
+ seoAnalyticsModelSetupCommand?.executor.id ||
+ undefined;
+ const result = await probeSeoAnalyticsModel({ executorId });
+
+ setSeoAnalyticsModelProbe(result.probe);
+ setExternalServicesMessage(
+ result.probe.connection.ok
+ ? "Codex bridge: модель подключена."
+ : `Codex bridge: ${result.probe.connection.detail}`
+ );
+ } catch (error: unknown) {
+ setExternalServicesError(getErrorMessage(error, "Не удалось проверить Codex bridge."));
+ } finally {
+ setProbingExternalServiceId(null);
+ }
+ }
+
+ async function handleCopySeoAnalyticsModelCommand() {
+ const command = seoAnalyticsModelSetupCommand?.install.command;
+
+ if (!command) {
+ return;
+ }
+
+ try {
+ await navigator.clipboard.writeText(command);
+ setExternalServicesMessage("Codex bridge: команда скопирована.");
+ } catch (error: unknown) {
+ setExternalServicesError(getErrorMessage(error, "Не удалось скопировать команду."));
+ }
+ }
+
async function loadProjects() {
setProjectsLoading(true);
setProjectsError(null);
@@ -10162,6 +10309,123 @@ export function App() {
return marketResult.marketEnrichment.anchorReview;
}
+ async function waitForSeoModelTaskCompletion(projectId: string, runId: string) {
+ const startedAt = Date.now();
+ let latestRun: SeoModelTaskRun | null = null;
+
+ while (Date.now() - startedAt < 10 * 60 * 1000) {
+ const statusResult = await fetchModelProviderStatus(projectId);
+ latestRun = statusResult.modelProvider.latestRuns.find((run) => run.runId === runId) ?? latestRun;
+
+ setModelProviderStatuses((currentStatuses) => ({
+ ...currentStatuses,
+ [projectId]: statusResult.modelProvider
+ }));
+
+ if (latestRun && latestRun.runStatus !== "queued" && latestRun.runStatus !== "running") {
+ return latestRun;
+ }
+
+ await sleep(3000);
+ }
+
+ throw new Error("Model task timeout: внешний Codex не вернул финальный результат за 10 минут.");
+ }
+
+ async function runAndWaitSeoModelTask(project: ProjectSummary, taskType: SeoModelTaskType) {
+ const taskKey = `${project.id}:${taskType}`;
+ setRunningModelTaskKey(taskKey);
+
+ try {
+ const result = await runSeoModelTask(project.id, { taskType });
+ const completedRun =
+ result.modelTaskRun.runStatus === "queued" || result.modelTaskRun.runStatus === "running"
+ ? await waitForSeoModelTaskCompletion(project.id, result.modelTaskRun.runId)
+ : result.modelTaskRun;
+
+ if (completedRun.runStatus === "failed") {
+ throw new Error(completedRun.errorMessage || `${getModelTaskTypeLabel(taskType)} завершилась ошибкой.`);
+ }
+
+ return completedRun;
+ } finally {
+ setRunningModelTaskKey(null);
+ }
+ }
+
+ async function refreshAfterSeoModelTask(project: ProjectSummary, taskType: SeoModelTaskType) {
+ if (taskType === "seo.keyword_cleaning") {
+ const [marketResult, keywordCleaningResult, keywordMapResult, strategyResult] = await Promise.all([
+ fetchLatestMarketEnrichment(project.id),
+ fetchLatestKeywordCleaning(project.id),
+ fetchLatestKeywordMap(project.id),
+ fetchLatestSeoStrategy(project.id)
+ ]);
+ setMarketEnrichments((currentEnrichments) => ({
+ ...currentEnrichments,
+ [project.id]: marketResult.marketEnrichment
+ }));
+ setKeywordCleanings((currentKeywordCleanings) => ({
+ ...currentKeywordCleanings,
+ [project.id]: keywordCleaningResult.keywordCleaning
+ }));
+ setKeywordMaps((currentKeywordMaps) => ({
+ ...currentKeywordMaps,
+ [project.id]: keywordMapResult.keywordMap
+ }));
+ setSeoStrategies((currentStrategies) => ({
+ ...currentStrategies,
+ [project.id]: strategyResult.strategy
+ }));
+ return;
+ }
+
+ if (taskType === "seo.strategy_synthesis") {
+ const [strategyResult, synthesisResult] = await Promise.all([
+ fetchLatestSeoStrategy(project.id),
+ fetchLatestStrategySynthesis(project.id)
+ ]);
+ setSeoStrategies((currentStrategies) => ({
+ ...currentStrategies,
+ [project.id]: strategyResult.strategy
+ }));
+ setStrategySyntheses((currentSyntheses) => ({
+ ...currentSyntheses,
+ [project.id]: synthesisResult.strategySynthesis
+ }));
+ return;
+ }
+
+ if (taskType === "seo.strategy_quality_review") {
+ const qualityResult = await fetchLatestStrategyQualityReview(project.id);
+ setStrategyQualityReviews((currentReviews) => ({
+ ...currentReviews,
+ [project.id]: qualityResult.strategyQualityReview
+ }));
+ return;
+ }
+
+ if (taskType === "seo.normalization") {
+ const [marketResult, keywordCleaningResult, keywordMapResult] = await Promise.all([
+ fetchLatestMarketEnrichment(project.id),
+ fetchLatestKeywordCleaning(project.id),
+ fetchLatestKeywordMap(project.id)
+ ]);
+ setMarketEnrichments((currentEnrichments) => ({
+ ...currentEnrichments,
+ [project.id]: marketResult.marketEnrichment
+ }));
+ setKeywordCleanings((currentKeywordCleanings) => ({
+ ...currentKeywordCleanings,
+ [project.id]: keywordCleaningResult.keywordCleaning
+ }));
+ setKeywordMaps((currentKeywordMaps) => ({
+ ...currentKeywordMaps,
+ [project.id]: keywordMapResult.keywordMap
+ }));
+ }
+ }
+
async function handleAnchorReviewApproval(project: ProjectSummary) {
setApprovingAnchorReviewProjectId(project.id);
setAnchorReviewActionKey(`${project.id}:bulk`);
@@ -10171,13 +10435,18 @@ export function App() {
try {
const result = await saveAnchorReviewDecisions(project.id);
+ let keywordCleaningRun: SeoModelTaskRun | null = null;
+
if (result.anchorReview.readiness.wordstatQueueCount > 0) {
setEnrichingMarketProjectId(project.id);
await runMarketEnrichment(project.id);
+ keywordCleaningRun = await runAndWaitSeoModelTask(project, "seo.keyword_cleaning");
}
await refreshAfterAnchorReviewDecision(project);
setProjectActionMessage(
- `Якоря сохранены: в Wordstat ${result.anchorReview.readiness.approvedAnchorCount}, очередь ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}. Wordstat обновлён по доступной очереди.`
+ `Якоря сохранены: в Wordstat ${result.anchorReview.readiness.approvedAnchorCount}, очередь ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}. Wordstat обновлён${
+ keywordCleaningRun ? `, очистка ключей: ${getModelTaskExecutionStatusLabel(keywordCleaningRun.runStatus)}.` : "."
+ }`
);
} catch (error: unknown) {
setProjectActionError(getErrorMessage(error, "Не удалось сохранить anchor review."));
@@ -10217,20 +10486,21 @@ export function App() {
setProjectActionProjectId(project.id);
try {
- const result = await runSeoModelTask(project.id, {
- providerId: "codex_manual",
- taskType
- });
- const statusResult = await fetchModelProviderStatus(project.id);
+ const result = await runSeoModelTask(project.id, { taskType });
+ const completedRun =
+ result.modelTaskRun.runStatus === "queued" || result.modelTaskRun.runStatus === "running"
+ ? await waitForSeoModelTaskCompletion(project.id, result.modelTaskRun.runId)
+ : result.modelTaskRun;
- setModelProviderStatuses((currentStatuses) => ({
- ...currentStatuses,
- [project.id]: statusResult.modelProvider
- }));
+ if (completedRun.runStatus === "failed") {
+ throw new Error(completedRun.errorMessage || `${getModelTaskTypeLabel(taskType)} завершилась ошибкой.`);
+ }
+
+ await refreshAfterSeoModelTask(project, taskType);
setProjectActionMessage(
- `${getModelTaskTypeLabel(taskType)}: ${getModelTaskRunStatusLabel(result.modelTaskRun.status)} · примерно ${
- result.modelTaskRun.estimate.inputTokenEstimate
- } входных токенов.`
+ `${getModelTaskTypeLabel(taskType)}: ${getModelTaskExecutionStatusLabel(completedRun.runStatus)} · provider ${
+ completedRun.provider.title
+ } · примерно ${completedRun.estimate.inputTokenEstimate} входных токенов.`
);
} catch (error: unknown) {
setProjectActionError(getErrorMessage(error, "Не удалось подготовить задачу модели."));
@@ -10239,6 +10509,73 @@ export function App() {
}
}
+ async function handleKeywordCleaningManualReview(project: ProjectSummary) {
+ setReviewingKeywordCleaningProjectId(project.id);
+ setProjectActionError(null);
+ setProjectActionMessage(null);
+ setProjectActionProjectId(project.id);
+
+ try {
+ const currentCleaning = keywordCleanings[project.id] ?? (await fetchLatestKeywordCleaning(project.id)).keywordCleaning;
+
+ if (!currentCleaning.modelTask) {
+ throw new Error("Нет keyword cleaning model task: сначала нужен semantic analysis и market evidence.");
+ }
+
+ const modelTaskResult = await runSeoModelTask(project.id, {
+ providerId: "codex_manual",
+ taskType: "seo.keyword_cleaning"
+ });
+ const savedCleaning = await saveManualKeywordCleaning(project.id, currentCleaning);
+ const [keywordMapResult, strategyResult, rewritePlanResult, materializationResult, rewriteDiffResult, statusResult] =
+ await Promise.all([
+ fetchLatestKeywordMap(project.id),
+ fetchLatestSeoStrategy(project.id),
+ fetchLatestRewritePlan(project.id),
+ fetchLatestMaterialization(project.id),
+ fetchLatestRewriteDiff(project.id),
+ fetchModelProviderStatus(project.id)
+ ]);
+
+ setKeywordCleanings((currentKeywordCleanings) => ({
+ ...currentKeywordCleanings,
+ [project.id]: savedCleaning.keywordCleaning
+ }));
+ setKeywordMaps((currentKeywordMaps) => ({
+ ...currentKeywordMaps,
+ [project.id]: keywordMapResult.keywordMap
+ }));
+ setSeoStrategies((currentStrategies) => ({
+ ...currentStrategies,
+ [project.id]: strategyResult.strategy
+ }));
+ setRewritePlans((currentRewritePlans) => ({
+ ...currentRewritePlans,
+ [project.id]: rewritePlanResult.rewritePlan
+ }));
+ setMaterializationContracts((currentContracts) => ({
+ ...currentContracts,
+ [project.id]: materializationResult.materialization
+ }));
+ setRewriteDiffContracts((currentContracts) => ({
+ ...currentContracts,
+ [project.id]: rewriteDiffResult.rewriteDiff
+ }));
+ setModelProviderStatuses((currentStatuses) => ({
+ ...currentStatuses,
+ [project.id]: statusResult.modelProvider
+ }));
+ invalidateStrategyQualityReview(project.id);
+ setProjectActionMessage(
+ `Keyword cleaning зафиксирован как Codex/manual evidence: ${savedCleaning.keywordCleaning.readiness.keywordMapCandidateCount} exact-кандидатов. Карта ключей: ${getKeywordMapStateLabel(keywordMapResult.keywordMap.state)} · ${getModelTaskRunStatusLabel(modelTaskResult.modelTaskRun.status)}.`
+ );
+ } catch (error: unknown) {
+ setProjectActionError(getErrorMessage(error, "Не удалось зафиксировать keyword cleaning review."));
+ } finally {
+ setReviewingKeywordCleaningProjectId(null);
+ }
+ }
+
async function handleProjectOntologyApproval(
project: ProjectSummary,
ontology: ProjectOntologyVersion,
@@ -10640,6 +10977,24 @@ export function App() {
}));
if (input?.jumpToStrategy) {
setActiveStageIndex(5);
+ const synthesisRun = await runAndWaitSeoModelTask(project, "seo.strategy_synthesis");
+ const [nextStrategyResult, synthesisResult] = await Promise.all([
+ fetchLatestSeoStrategy(project.id),
+ fetchLatestStrategySynthesis(project.id)
+ ]);
+ setSeoStrategies((currentStrategies) => ({
+ ...currentStrategies,
+ [project.id]: nextStrategyResult.strategy
+ }));
+ setStrategySyntheses((currentSyntheses) => ({
+ ...currentSyntheses,
+ [project.id]: synthesisResult.strategySynthesis
+ }));
+ setProjectActionMessage(
+ `Распределение ключей сохранено: решений ${result.savedDecisionCount}, привязок ${
+ result.savedMapItemCount
+ }. Синтез стратегии: ${getModelTaskExecutionStatusLabel(synthesisRun.runStatus)}.`
+ );
}
} catch (error: unknown) {
setProjectActionError(getErrorMessage(error, "Не удалось сохранить keyword decisions."));
@@ -13087,6 +13442,67 @@ export function App() {
))}
+ {service.id === "seo_analytics_model" && draft.enabled && draft.mode === "local_codex" ? (
+
+
+ Codex bridge
+
+ {seoAnalyticsModelProbe?.connection.ok
+ ? "подключена"
+ : probingExternalServiceId === service.id
+ ? "проверяю..."
+ : service.status === "configured" && draft.publicConfig.executorId
+ ? "сохранена"
+ : "ожидает проверки"}
+
+
+ {seoAnalyticsModelSetupCommand ? (
+ <>
+
{seoAnalyticsModelSetupCommand.install.command}
+
+ executor {seoAnalyticsModelSetupCommand.executor.name}
+ {seoAnalyticsModelSetupCommand.setupCode.expiresAt
+ ? ` · code expires ${new Date(
+ seoAnalyticsModelSetupCommand.setupCode.expiresAt
+ ).toLocaleString("ru-RU")}`
+ : ""}
+
+ >
+ ) : (
+
NPM command появится после генерации setup-code.
+ )}
+
+ ) : null}
+
+ {service.id === "seo_analytics_model" && seoAnalyticsModelProbe ? (
+
+
+ Статус модели
+ {seoAnalyticsModelProbe.profile}
+
+
+
+ AI Workspace config
+
+ {seoAnalyticsModelProbe.config.configured
+ ? `ok · ${seoAnalyticsModelProbe.config.assistantUrl ?? "assistant URL"}`
+ : seoAnalyticsModelProbe.config.errors.join(" ")}
+
+
+
+ {seoAnalyticsModelProbe.executor.name ?? "Codex bridge executor"}
+
+ {seoAnalyticsModelProbe.executor.status} · {seoAnalyticsModelProbe.connection.detail}
+
+
+
+
+ ) : null}
+
{service.id === "yandex_cloud" && yandexAiStudioProbe ? (
@@ -13132,6 +13548,45 @@ export function App() {
{probingExternalServiceId === service.id ? "Проверяю..." : "Проверить"}
) : null}
+ {service.id === "seo_analytics_model" ? (
+ <>
+
void handleGenerateSeoAnalyticsModelCommand()}
+ type="button"
+ >
+ {generatingSeoAnalyticsModelCommand ? (
+
+ ) : (
+
+ )}
+ {generatingSeoAnalyticsModelCommand ? "Генерирую..." : "NPM command"}
+
+
void handleCopySeoAnalyticsModelCommand()}
+ type="button"
+ >
+
+ Скопировать
+
+
void handleProbeSeoAnalyticsModel()}
+ type="button"
+ >
+ {probingExternalServiceId === service.id ? (
+
+ ) : (
+
+ )}
+ {probingExternalServiceId === service.id ? "Проверяю..." : "Проверить"}
+
+ >
+ ) : null}
- {keywordCleaning && !wordstatOutdated ? (
- <>
-
-
+ {keywordCleaning && !wordstatOutdated ? (
+ <>
+
+
Очистка фраз
-
{keywordCleaning.summary}
-
+ {keywordCleaning.summary}
+
После Wordstat и разбора выдачи: что можно пустить в SEO-план, что оставить в запасе, что
- проверить и что выкинуть.
-
-
+ проверить и что выкинуть.
+
+
{keywordCleaning.provider.mode === "codex_manual" ? "модель" : "резерв"}
- {keywordCleaning.analystReview.status}
- Главные {keywordCleaning.readiness.useCount}
- Поддержка {keywordCleaning.readiness.supportCount}
+ {keywordCleaning.analystReview.status}
+ Stage 5 {keywordCleaning.readiness.keywordMapCandidateCount}
+ Exact {keywordCleaning.readiness.exactEvidenceCount}
Контент {keywordCleaning.readiness.articleCount}
Риск {keywordCleaning.readiness.riskyCount}
Мусор {keywordCleaning.readiness.trashCount}
-
-
- void handleSeoModelTaskRun(project, "seo.keyword_cleaning")}
- type="button"
- >
- {isRunningKeywordCleaningModelTask ? (
-
- ) : (
-
- )}
- {isRunningKeywordCleaningModelTask ? "Готовлю..." : "Маршрут модели"}
-
-
+
+
+
+ void handleSeoModelTaskRun(project, "seo.keyword_cleaning")}
+ type="button"
+ >
+ {isRunningKeywordCleaningModelTask ? (
+
+ ) : (
+
+ )}
+ {isRunningKeywordCleaningModelTask ? "Готовлю..." : "Маршрут модели"}
+
+ void handleKeywordCleaningManualReview(project)}
+ type="button"
+ >
+ {isReviewingKeywordCleaning ? (
+
+ ) : (
+
+ )}
+ {isReviewingKeywordCleaning
+ ? "Фиксирую..."
+ : keywordCleaning.provider.mode === "codex_manual"
+ ? "Проверка сохранена"
+ : "Зафиксировать проверку"}
+
+
+
-
-
+
+
| 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;
+}