feat(seo): wire AI workspace model flow

This commit is contained in:
DCCONSTRUCTIONS 2026-07-03 08:43:37 +03:00
parent 75fd57b036
commit 1ed099140f
22 changed files with 3422 additions and 227 deletions

View File

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

View File

@ -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({
<SlidersHorizontal size={20} />
<div>
<strong>Этап 3 · Распределение ключей</strong>
<small>Перетащи запросы между колонками, чтобы поправить модельную сортировку перед стратегией.</small>
<small>Модель распределяет, пользователь правит. Неразобранные не участвуют.</small>
</div>
</div>
<button className="tiny-action" onClick={onReconfigure} type="button">
@ -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<string, string> = {
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<string | null>(null);
const [synthesizingStrategyProjectId, setSynthesizingStrategyProjectId] = useState<string | null>(null);
const [reviewingStrategyQualityProjectId, setReviewingStrategyQualityProjectId] = useState<string | null>(null);
const [reviewingKeywordCleaningProjectId, setReviewingKeywordCleaningProjectId] = useState<string | null>(null);
const [persistingKeywordMapProjectId, setPersistingKeywordMapProjectId] = useState<string | null>(null);
const [materializingTargetKey, setMaterializingTargetKey] = useState<string | null>(null);
const [buildingPatchProjectId, setBuildingPatchProjectId] = useState<string | null>(null);
@ -8513,6 +8555,10 @@ export function App() {
const [savingExternalServiceId, setSavingExternalServiceId] = useState<string | null>(null);
const [probingExternalServiceId, setProbingExternalServiceId] = useState<string | null>(null);
const [yandexAiStudioProbe, setYandexAiStudioProbe] = useState<YandexAiStudioProbe | null>(null);
const [seoAnalyticsModelProbe, setSeoAnalyticsModelProbe] = useState<SeoAnalyticsModelProbe | null>(null);
const [seoAnalyticsModelSetupCommand, setSeoAnalyticsModelSetupCommand] =
useState<SeoAnalyticsModelSetupCommand | null>(null);
const [generatingSeoAnalyticsModelCommand, setGeneratingSeoAnalyticsModelCommand] = useState(false);
const [externalServicesError, setExternalServicesError] = useState<string | null>(null);
const [externalServicesMessage, setExternalServicesMessage] = useState<string | null>(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() {
))}
</div>
{service.id === "seo_analytics_model" && draft.enabled && draft.mode === "local_codex" ? (
<div className="service-command-panel">
<div className="service-command-head">
<strong>Codex bridge</strong>
<span
className={
seoAnalyticsModelProbe?.connection.ok || service.status === "configured" ? "ok" : "idle"
}
>
{seoAnalyticsModelProbe?.connection.ok
? "подключена"
: probingExternalServiceId === service.id
? "проверяю..."
: service.status === "configured" && draft.publicConfig.executorId
? "сохранена"
: "ожидает проверки"}
</span>
</div>
{seoAnalyticsModelSetupCommand ? (
<>
<pre>{seoAnalyticsModelSetupCommand.install.command}</pre>
<small>
executor {seoAnalyticsModelSetupCommand.executor.name}
{seoAnalyticsModelSetupCommand.setupCode.expiresAt
? ` · code expires ${new Date(
seoAnalyticsModelSetupCommand.setupCode.expiresAt
).toLocaleString("ru-RU")}`
: ""}
</small>
</>
) : (
<small>NPM command появится после генерации setup-code.</small>
)}
</div>
) : null}
{service.id === "seo_analytics_model" && seoAnalyticsModelProbe ? (
<div className="service-probe-result">
<div>
<strong>Статус модели</strong>
<span>{seoAnalyticsModelProbe.profile}</span>
</div>
<ul>
<li className={seoAnalyticsModelProbe.config.configured ? "ok" : "error"}>
<span>AI Workspace config</span>
<small>
{seoAnalyticsModelProbe.config.configured
? `ok · ${seoAnalyticsModelProbe.config.assistantUrl ?? "assistant URL"}`
: seoAnalyticsModelProbe.config.errors.join(" ")}
</small>
</li>
<li className={seoAnalyticsModelProbe.connection.ok ? "ok" : "error"}>
<span>{seoAnalyticsModelProbe.executor.name ?? "Codex bridge executor"}</span>
<small>
{seoAnalyticsModelProbe.executor.status} · {seoAnalyticsModelProbe.connection.detail}
</small>
</li>
</ul>
</div>
) : null}
{service.id === "yandex_cloud" && yandexAiStudioProbe ? (
<div className="service-probe-result">
<div>
@ -13132,6 +13548,45 @@ export function App() {
{probingExternalServiceId === service.id ? "Проверяю..." : "Проверить"}
</button>
) : null}
{service.id === "seo_analytics_model" ? (
<>
<button
className="tiny-action"
disabled={isSaving || generatingSeoAnalyticsModelCommand || !draft.enabled || draft.mode !== "local_codex"}
onClick={() => void handleGenerateSeoAnalyticsModelCommand()}
type="button"
>
{generatingSeoAnalyticsModelCommand ? (
<Loader2 className="spin" size={14} />
) : (
<Server size={14} />
)}
{generatingSeoAnalyticsModelCommand ? "Генерирую..." : "NPM command"}
</button>
<button
className="tiny-action"
disabled={isSaving || !seoAnalyticsModelSetupCommand?.install.command}
onClick={() => void handleCopySeoAnalyticsModelCommand()}
type="button"
>
<Copy size={14} />
Скопировать
</button>
<button
className="tiny-action"
disabled={isSaving || probingExternalServiceId === service.id || !draft.enabled || draft.mode !== "local_codex"}
onClick={() => void handleProbeSeoAnalyticsModel()}
type="button"
>
{probingExternalServiceId === service.id ? (
<Loader2 className="spin" size={14} />
) : (
<RefreshCw size={14} />
)}
{probingExternalServiceId === service.id ? "Проверяю..." : "Проверить"}
</button>
</>
) : null}
<button
className="tiny-action primary"
disabled={isSaving}
@ -13687,6 +14142,7 @@ export function App() {
);
const activeAnchorReviewFilter = anchorReviewFilters[project.id] ?? "proposed";
const keywordCurationOverrides = keywordCurationRoleOverrides[project.id] ?? {};
const keywordCleaningStage5Candidates = keywordCleaning ? getKeywordCleaningStage5Candidates(keywordCleaning) : [];
const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null;
const keywordPagePlans = keywordMap ? getKeywordPlanPageSummaries(keywordMap) : [];
const keywordReviewItems = keywordMap ? getKeywordPlanReviewItems(keywordMap) : [];
@ -13709,6 +14165,7 @@ export function App() {
const isInterpretingSerp = interpretingSerpProjectId === project.id;
const isSynthesizingStrategy = synthesizingStrategyProjectId === project.id;
const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id;
const isReviewingKeywordCleaning = reviewingKeywordCleaningProjectId === project.id;
const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id;
const isBuildingPatch = buildingPatchProjectId === project.id;
const isRunningPatchDryRun = patchArtifact
@ -16871,16 +17328,17 @@ export function App() {
<div className="market-normalization-stats">
<span>{keywordCleaning.provider.mode === "codex_manual" ? "модель" : "резерв"}</span>
<span>{keywordCleaning.analystReview.status}</span>
<span>Главные {keywordCleaning.readiness.useCount}</span>
<span>Поддержка {keywordCleaning.readiness.supportCount}</span>
<span>Stage 5 {keywordCleaning.readiness.keywordMapCandidateCount}</span>
<span>Exact {keywordCleaning.readiness.exactEvidenceCount}</span>
<span>Контент {keywordCleaning.readiness.articleCount}</span>
<span>Риск {keywordCleaning.readiness.riskyCount}</span>
<span>Мусор {keywordCleaning.readiness.trashCount}</span>
</div>
</div>
<div className="model-provider-actions">
<button
className="tiny-action"
disabled={!keywordCleaning.modelTask || isRunningKeywordCleaningModelTask}
disabled={!keywordCleaning.modelTask || isRunningKeywordCleaningModelTask || isReviewingKeywordCleaning}
onClick={() => void handleSeoModelTaskRun(project, "seo.keyword_cleaning")}
type="button"
>
@ -16891,14 +17349,37 @@ export function App() {
)}
{isRunningKeywordCleaningModelTask ? "Готовлю..." : "Маршрут модели"}
</button>
<button
className="tiny-action primary"
disabled={
!keywordCleaning.modelTask ||
isRunningKeywordCleaningModelTask ||
isReviewingKeywordCleaning ||
keywordCleaning.provider.mode === "codex_manual"
}
onClick={() => void handleKeywordCleaningManualReview(project)}
type="button"
>
{isReviewingKeywordCleaning ? (
<Loader2 className="spin" size={14} />
) : (
<CheckCircle2 size={14} />
)}
{isReviewingKeywordCleaning
? "Фиксирую..."
: keywordCleaning.provider.mode === "codex_manual"
? "Проверка сохранена"
: "Зафиксировать проверку"}
</button>
</div>
</div>
<div className="market-normalization-review keyword-cleaning-review">
<SeoKeywordCleaningLane
emptyLabel="Нет фраз для плана правок"
items={[...keywordCleaning.lanes.use, ...keywordCleaning.lanes.support]}
subtitle="только это попадёт в карту фраз"
title="В SEO-план"
emptyLabel="Нет exact-кандидатов для Stage 5"
items={keywordCleaningStage5Candidates}
subtitle="пойдут в доску распределения"
title="В Stage 5"
/>
<SeoKeywordCleaningLane
emptyLabel="Очередь пустая"

View File

@ -1100,6 +1100,47 @@ export type SeoModelProviderCatalog = {
providers: SeoModelProviderDescriptor[];
};
export type SeoModelTaskAiWorkspaceDispatch = {
schemaVersion: "seo-ai-workspace-dispatch.v1";
profile: "deploy-host" | "deployed-hub" | "disabled" | "local" | "tunnel-local-e2e";
assistantThreadId: string;
assistantMessageId: string;
bridgeRequestId: string;
bridgeMode: string;
accepted: boolean;
dispatchedAt: string;
selectedExecutorId: string | null;
contractWorkspacePath: string;
};
export type SeoModelTaskModelOutput = {
schemaVersion: "seo-model-output.v1";
source: "ai_workspace";
status: "dispatch_accepted" | "schema_invalid" | "schema_valid";
aiWorkspace: SeoModelTaskAiWorkspaceDispatch;
rawText: string | null;
parsedJson: Record<string, unknown> | 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<string, unknown> | 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",

View File

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

View File

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

View File

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

View File

@ -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<string>(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<string>) {
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<KeywordCleaningContract, "nextActions">
}
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<Key
return fallbackContract;
}
return (await getLatestAlignedKeywordCleaning(projectId, market.semanticRunId, market.wordstat.latestJob?.id ?? null)) ?? fallbackContract;
const latest = await getLatestAlignedKeywordCleaning(projectId, market.semanticRunId, market.wordstat.latestJob?.id ?? null);
return latest
? {
...latest,
modelTask: fallbackContract.modelTask,
sourceTaskId: fallbackContract.sourceTaskId
}
: fallbackContract;
}
export async function saveKeywordCleaningManual(
@ -815,3 +1036,67 @@ export async function saveKeywordCleaningManual(
return run;
}
export async function saveKeywordCleaningFromModelProvider(
projectId: string,
output: KeywordCleaningContract,
sourceModelTaskRunId: string
): Promise<KeywordCleaningContract> {
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<KeywordCleaningRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'keyword_cleaning', 'done', $2::jsonb, $3::jsonb, now(), now())
returning id, status, input, output, error_message, started_at, completed_at, created_at;
`,
[
projectId,
JSON.stringify({
providerMode: "codex_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;
}

View File

@ -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;
}
if (reviewedOntology && isBroadArticleBacklogPhrase(item.phrase)) {
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<string>;
stemDocumentCounts: Map<string, number>;
};
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 (/^(искусствен|интеллект)/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<string, number>();
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<SemanticFacetGuard, "approvedAnchorCount" | "stemDocumentCounts">) {
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<string>();
}
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<string, number>();
const sourceItems = cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120);
const targetHasCorePhrase = sourceItems.reduce<Map<string, boolean>>((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<KeywordMapContract, "nextActions">) {
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 (

View File

@ -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<string,
}
class McpKvWordstatProvider implements WordstatProvider {
constructor(private readonly endpoint: string | undefined) {}
constructor(private readonly endpoint: string | undefined, private readonly maxSeedsPerRun: number) {}
getStatus(): WordstatProviderStatus {
if (!this.endpoint) {
@ -262,7 +263,8 @@ class McpKvWordstatProvider implements WordstatProvider {
connected: true,
provider: "mcp_kv",
mode: "mcp_kv",
message: "Wordstat MCP-KV endpoint настроен на платформе."
message: "Wordstat MCP-KV endpoint настроен на платформе.",
maxSeedsPerRun: this.maxSeedsPerRun
};
}
@ -300,7 +302,8 @@ class YandexApiWordstatProvider implements WordstatProvider {
connected: true,
provider: "yandex_api",
mode: "yandex_search_api",
message: "Yandex Wordstat Search API настроен на платформе."
message: "Yandex Wordstat Search API настроен на платформе.",
maxSeedsPerRun: this.config.maxSeedsPerRun
};
}
@ -359,7 +362,7 @@ export async function createWordstatProvider(): Promise<WordstatProvider> {
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") {

View File

@ -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<string, WordstatResultRow>();
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<string, number>();
@ -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<WordstatResultRow>(
`
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);

View File

@ -347,7 +347,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
"SERP interpretation работает только по сохранённому yandex-evidence.v1 и не придумывает конкурентов или спрос.",
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
"Keyword map получает только keyword-cleaning use/support lanes; risky/trash не становятся approved decisions.",
"Keyword map предсортирует exact evidence в пять Stage 3 колонок; Неразобранные не участвуют, финальные роли утверждает пользователь.",
"Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval."
],
nextActions: !analysis
@ -360,7 +360,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
"После Wordstat прогонять seo.keyword_cleaning и только use/support передавать в keyword map.",
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать exact-кандидаты в пять колонок для ручной правки.",
"Не запускать external market collection без anchor review approval."
]
: [

View File

@ -0,0 +1,479 @@
import { env } from "../config/env.js";
import type { SeoModelTaskContract } from "../modelContext/seoModelContext.js";
import { getSeoAnalyticsModelRuntimeConfig } from "../settings/externalServiceSettings.js";
import { getSeoAiWorkspaceConfigStatus, type SeoAiWorkspaceProfile } from "./aiWorkspaceBridgeConfig.js";
type JsonRecord = Record<string, unknown>;
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<string, string> = {
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<T>(
path: string,
options: {
body?: JsonRecord;
method: "GET" | "POST";
}
): Promise<T> {
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<SeoAiWorkspaceDispatch> {
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<AssistantThreadResponse>("/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<AssistantMessageResponse>(
`/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<AssistantDispatchResponse>(
`/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<SeoAiWorkspaceCompletion> {
const payload = await assistantRequest<AssistantMessagesResponse>(
`/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<SeoAiWorkspacePersistedResult["runType"], "seo_model_task">;
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}.`
};
}

View File

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

View File

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

View File

@ -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 не выполняется.`);
}
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,7 +312,13 @@ function buildRunOutput(
persistedResult: null,
nextActions:
status === "contract_ready"
? 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."
@ -304,20 +334,311 @@ function buildRunOutput(
async function insertSeoModelTaskRun(
projectId: string,
input: Record<string, unknown>,
output: SeoModelTaskRunOutput
output: SeoModelTaskRunOutput,
status: RunStatus = "done"
) {
const isTerminal = status === "done" || status === "failed" || status === "cancelled";
const result = await pool.query<SeoModelTaskRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'seo_model_task', 'done', $2::jsonb, $3::jsonb, now(), now())
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<string, unknown>;
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<SeoModelTaskRunRow>(
`
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<SeoModelTaskRunRow>(
`
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<string, unknown> {
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<DomainPromotionResult | null> {
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<SeoModelTaskRunRow>(
`
select id, status, input, output, error_message, started_at, completed_at, created_at
from runs
where project_id = $1
and run_type = 'seo_model_task'
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<SeoModelTaskRun[]> {
const result = await pool.query<SeoModelTaskRunRow>(
`
@ -336,6 +657,7 @@ export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): P
export async function getSeoModelProviderStatusContract(
projectId: string
): Promise<SeoModelProviderStatusContract> {
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 run = await insertSeoModelTaskRun(
projectId,
{
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,
baseInput,
output
);

View File

@ -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<SeoNormalizationContract> {
const normalizedOutput = normalizeSeoNormalizationOutput(
projectId,
output,
"codex_workspace",
"AI Workspace Codex provider выполнил seo.normalization по contract-only task; результат сохранён как evidence после backend validation."
);
const result = await pool.query<SeoNormalizationRunRow>(
`
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;
}

View File

@ -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<ExternalServiceId, ExternalServiceDefinition> = {
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<ExternalServiceId, ExternalServiceDefinition> =
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<WordstatRuntimeConfig>
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<WordstatRuntimeConfig>
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<WordstatRuntimeConfig>
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<WordstatRuntimeConfig>
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
};
}

View File

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

View File

@ -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<string, unknown>;
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<AiWorkspaceExecutorsResponse>("/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<AiWorkspaceExecutorResponse>("/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<AiWorkspaceSetupCommandResponse>(
`/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<SeoAnalyticsModelProbe> {
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<AiWorkspaceExecutorCheckResponse>(
`/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
};
}

View File

@ -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<SeoStra
const phasePlan = buildPhasePlan(siteMaturity);
const opportunities = [
...buildOpportunities(cleaning, keywordMap, siteMaturity),
...buildSerpOpportunities(serpInterpretation, siteMaturity)
...buildSerpOpportunities(serpInterpretation, keywordMap, siteMaturity)
];
const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation);
const risks = buildRisks({

View File

@ -5,7 +5,7 @@ import { getLatestStrategySynthesisContract, type StrategySynthesisContract } fr
import { buildStrategyQualityReviewModelTask } from "./strategyQualityReviewTask.js";
type RunStatus = "cancelled" | "done" | "failed" | "queued" | "running";
type StrategyQualityReviewProviderMode = "codex_manual" | "deterministic_fallback";
type StrategyQualityReviewProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
type StrategyQualityReviewState = "not_ready" | "ready";
type StrategyQualityReviewVerdictStatus = "approved" | "blocked" | "needs_changes";
type StrategyQualityReviewCheckStatus = "fail" | "pass" | "warning";
@ -112,6 +112,161 @@ function getDefaultAnalystReview(): StrategyQualityReviewContract["analystReview
};
}
function isVerdictStatus(value: unknown): value is StrategyQualityReviewVerdictStatus {
return value === "approved" || value === "blocked" || value === "needs_changes";
}
function isConfidence(value: unknown): value is StrategyQualityReviewContract["verdict"]["confidence"] {
return value === "high" || value === "medium" || value === "low";
}
function normalizeCheckStatus(value: unknown): StrategyQualityReviewCheckStatus {
if (value === "pass" || value === "passed") return "pass";
if (value === "fail" || value === "failed") return "fail";
if (value === "warning" || value === "warn") return "warning";
return "warning";
}
function normalizeCheckSeverity(value: unknown): StrategyQualityReviewSeverity {
if (value === "critical" || value === "high" || value === "medium" || value === "low") {
return value;
}
return "low";
}
function normalizeCheckCategory(value: unknown): StrategyQualityReviewCategory {
const category = String(value || "").trim();
if (
category === "business_drift" ||
category === "evidence_integrity" ||
category === "hallucination_guardrail" ||
category === "phase_guardrail" ||
category === "source_safety" ||
category === "ux_clarity"
) {
return category;
}
if (category === "business_scope") return "business_drift";
if (category === "evidence" || category === "evidence_gap") return "evidence_integrity";
if (category === "phase" || category === "phase_violation") return "phase_guardrail";
if (category === "guardrail" || category === "intent") return "hallucination_guardrail";
if (category === "input_state") return "ux_clarity";
return "source_safety";
}
function normalizeModelProviderChecks(value: unknown, fallback: StrategyQualityReviewContract["checks"]) {
if (!Array.isArray(value)) {
return fallback;
}
return value.map((item, index) => {
const record = (item && typeof item === "object" ? item : {}) as Record<string, unknown>;
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<string, unknown>;
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<string, any>;
const rawVerdict = rawOutput.verdict;
const verdict = (rawVerdict && typeof rawVerdict === "object" ? rawVerdict : {}) as Record<string, any>;
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
? "Ветка расширения выглядит как основной маршрут для стартовой фазы."
: 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<StrategyQualityReviewRun> {
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<StrategyQualityReviewRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'seo_strategy_quality_review', 'done', $2::jsonb, $3::jsonb, now(), now())
returning id, status, input, output, error_message, started_at, completed_at, created_at;
`,
[
projectId,
{
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;
}

View File

@ -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<string, unknown>;
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<string, any>;
const executiveSummary = (rawOutput.executiveSummary ?? {}) as Record<string, any>;
const phaseDecision = (rawOutput.phaseDecision ?? {}) as Record<string, any>;
const recommendedPath = (rawOutput.recommendedPath ?? {}) as Record<string, any>;
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<StrategySynthesisRun> {
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<StrategySynthesisRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'seo_strategy_synthesis', 'done', $2::jsonb, $3::jsonb, now(), now())
returning id, status, input, output, error_message, started_at, completed_at, created_at;
`,
[
projectId,
JSON.stringify({
providerMode: "codex_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;
}