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_REGION_IDS=225
# WORDSTAT_DEVICES=DEVICE_ALL # WORDSTAT_DEVICES=DEVICE_ALL
# WORDSTAT_NUM_PHRASES=50 # WORDSTAT_NUM_PHRASES=50
# WORDSTAT_MAX_SEEDS_PER_RUN=40
SERP_PROVIDER=disabled SERP_PROVIDER=disabled
# SERP_API_BASE_URL=https://serp-provider.internal # SERP_API_BASE_URL=https://serp-provider.internal
# SERP_API_TOKEN=change-me # 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 VITE_API_BASE_URL=http://localhost:4100

View File

@ -57,6 +57,7 @@ import {
buildPatchArtifact, buildPatchArtifact,
approveKeywordMapDecisions, approveKeywordMapDecisions,
copyProject, copyProject,
createSeoAnalyticsModelSetupCommand,
deleteProject, deleteProject,
downloadSemanticAnalysisExport, downloadSemanticAnalysisExport,
fetchExternalServiceSettings, fetchExternalServiceSettings,
@ -83,6 +84,7 @@ import {
getPreviewPageUrl, getPreviewPageUrl,
importBrowserFolder, importBrowserFolder,
openPageWorkspace, openPageWorkspace,
probeSeoAnalyticsModel,
probeYandexAiStudioCredential, probeYandexAiStudioCredential,
renameProject, renameProject,
resetPageWorkspace, resetPageWorkspace,
@ -97,6 +99,7 @@ import {
runSeoContextReview, runSeoContextReview,
runSeoModelTask, runSeoModelTask,
saveAnchorReviewDecisions, saveAnchorReviewDecisions,
saveManualKeywordCleaning,
savePageSemanticBlocks, savePageSemanticBlocks,
saveContentFieldDraft, saveContentFieldDraft,
savePageWorkspace, savePageWorkspace,
@ -131,6 +134,8 @@ import {
type RewriteDiffContract, type RewriteDiffContract,
type RewritePlanContract, type RewritePlanContract,
type SeoContextReviewRun, type SeoContextReviewRun,
type SeoAnalyticsModelProbe,
type SeoAnalyticsModelSetupCommand,
type SeoStrategyContract, type SeoStrategyContract,
type SeoModelProviderStatusContract, type SeoModelProviderStatusContract,
type SeoModelTaskRun, type SeoModelTaskRun,
@ -163,13 +168,17 @@ type WorkspaceMode = "auto" | "dev";
type ExternalService = ExternalServiceSettings["services"][number]; type ExternalService = ExternalServiceSettings["services"][number];
type ExternalServiceDraft = ExternalServiceUpdateInput; type ExternalServiceDraft = ExternalServiceUpdateInput;
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
const keywordCurationColumns: Array<{ const keywordCurationColumns: Array<{
description: string; description: string;
role: KeywordCurationRole; role: KeywordCurationRole;
title: string; title: string;
}> = [ }> = [
{ {
description: "предложенные, спорные или ещё не отнесённые запросы", description: "не участвуют в стратегии",
role: "validate", role: "validate",
title: "Неразобранные" 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({ function KeywordCurationCard({
item, item,
role role
@ -4552,7 +4573,7 @@ function KeywordCurationBoard({
<SlidersHorizontal size={20} /> <SlidersHorizontal size={20} />
<div> <div>
<strong>Этап 3 · Распределение ключей</strong> <strong>Этап 3 · Распределение ключей</strong>
<small>Перетащи запросы между колонками, чтобы поправить модельную сортировку перед стратегией.</small> <small>Модель распределяет, пользователь правит. Неразобранные не участвуют.</small>
</div> </div>
</div> </div>
<button className="tiny-action" onClick={onReconfigure} type="button"> <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) { function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) {
const labels = { const labels = {
differentiator: "дифф.", differentiator: "уточняющие / УТП",
primary: "главный", primary: "посадочное ядро",
secondary: "доп.", secondary: "поддерживающее ядро",
secondary_candidate: "кандидат", secondary_candidate: "кандидат",
support: "поддержка", support: "длинный хвост",
validate: "проверить" validate: "неразобранные"
}; };
return labels[role as keyof typeof labels] ?? role; return labels[role as keyof typeof labels] ?? role;
@ -5928,6 +5949,18 @@ function getModelTaskRunStatusLabel(status: SeoModelTaskRun["status"]) {
return labels[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"]) { function getModelProviderStatusLabel(status: SeoModelProviderStatusContract["catalog"]["providers"][number]["status"]) {
const labels = { const labels = {
active: "активен", active: "активен",
@ -5951,6 +5984,7 @@ function getExternalServiceStatusLabel(status: ExternalService["status"]) {
function getExternalServiceShortName(serviceId: ExternalService["id"]) { function getExternalServiceShortName(serviceId: ExternalService["id"]) {
const labels = { const labels = {
seo_analytics_model: "Аналитическая модель",
yandex_cloud: "Yandex AI Studio / Cloud API key", yandex_cloud: "Yandex AI Studio / Cloud API key",
yandex_metrica: "Yandex Metrica", yandex_metrica: "Yandex Metrica",
yandex_search_serp: "Yandex SERP", yandex_search_serp: "Yandex SERP",
@ -5963,9 +5997,12 @@ function getExternalServiceShortName(serviceId: ExternalService["id"]) {
function getExternalMissingFieldLabel(fieldId: string) { function getExternalMissingFieldLabel(fieldId: string) {
const labels: Record<string, string> = { const labels: Record<string, string> = {
aiWorkspaceProfile: "AI Workspace profile",
apiKey: "Yandex AI Studio API key", apiKey: "Yandex AI Studio API key",
executorId: "Codex bridge executor",
folderId: "folderId", folderId: "folderId",
iamToken: "IAM token", iamToken: "IAM token",
seo_analytics_model: "Аналитическая модель",
yandex_cloud: "Yandex AI Studio / Cloud API key" yandex_cloud: "Yandex AI Studio / Cloud API key"
}; };
@ -5986,6 +6023,10 @@ function buildExternalServiceDrafts(settings: ExternalServiceSettings) {
} }
function getVisiblePublicFields(service: ExternalService, draft: ExternalServiceDraft | undefined) { function getVisiblePublicFields(service: ExternalService, draft: ExternalServiceDraft | undefined) {
if (service.id === "seo_analytics_model") {
return [];
}
if (service.id === "yandex_wordstat" && draft?.mode === "mcp_kv") { if (service.id === "yandex_wordstat" && draft?.mode === "mcp_kv") {
return service.publicFields.filter((field) => field.id === "mcpEndpoint"); return service.publicFields.filter((field) => field.id === "mcpEndpoint");
} }
@ -8472,6 +8513,7 @@ export function App() {
const [interpretingSerpProjectId, setInterpretingSerpProjectId] = useState<string | null>(null); const [interpretingSerpProjectId, setInterpretingSerpProjectId] = useState<string | null>(null);
const [synthesizingStrategyProjectId, setSynthesizingStrategyProjectId] = useState<string | null>(null); const [synthesizingStrategyProjectId, setSynthesizingStrategyProjectId] = useState<string | null>(null);
const [reviewingStrategyQualityProjectId, setReviewingStrategyQualityProjectId] = 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 [persistingKeywordMapProjectId, setPersistingKeywordMapProjectId] = useState<string | null>(null);
const [materializingTargetKey, setMaterializingTargetKey] = useState<string | null>(null); const [materializingTargetKey, setMaterializingTargetKey] = useState<string | null>(null);
const [buildingPatchProjectId, setBuildingPatchProjectId] = 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 [savingExternalServiceId, setSavingExternalServiceId] = useState<string | null>(null);
const [probingExternalServiceId, setProbingExternalServiceId] = useState<string | null>(null); const [probingExternalServiceId, setProbingExternalServiceId] = useState<string | null>(null);
const [yandexAiStudioProbe, setYandexAiStudioProbe] = useState<YandexAiStudioProbe | 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 [externalServicesError, setExternalServicesError] = useState<string | null>(null);
const [externalServicesMessage, setExternalServicesMessage] = useState<string | null>(null); const [externalServicesMessage, setExternalServicesMessage] = useState<string | null>(null);
@ -8781,6 +8827,7 @@ export function App() {
setExternalServices(result.externalServices); setExternalServices(result.externalServices);
setExternalServiceDrafts(buildExternalServiceDrafts(result.externalServices)); setExternalServiceDrafts(buildExternalServiceDrafts(result.externalServices));
void refreshSeoAnalyticsModelProbe(result.externalServices);
} catch (error: unknown) { } catch (error: unknown) {
setExternalServicesError(getErrorMessage(error, "Не удалось загрузить внешние сервисы.")); setExternalServicesError(getErrorMessage(error, "Не удалось загрузить внешние сервисы."));
} finally { } 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() { function openExternalServicesModal() {
setIsExternalServicesOpen(true); setIsExternalServicesOpen(true);
setYandexAiStudioProbe(null); setYandexAiStudioProbe(null);
setSeoAnalyticsModelProbe(null);
setSeoAnalyticsModelSetupCommand(null);
void loadExternalServices(); 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() { async function loadProjects() {
setProjectsLoading(true); setProjectsLoading(true);
setProjectsError(null); setProjectsError(null);
@ -10162,6 +10309,123 @@ export function App() {
return marketResult.marketEnrichment.anchorReview; 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) { async function handleAnchorReviewApproval(project: ProjectSummary) {
setApprovingAnchorReviewProjectId(project.id); setApprovingAnchorReviewProjectId(project.id);
setAnchorReviewActionKey(`${project.id}:bulk`); setAnchorReviewActionKey(`${project.id}:bulk`);
@ -10171,13 +10435,18 @@ export function App() {
try { try {
const result = await saveAnchorReviewDecisions(project.id); const result = await saveAnchorReviewDecisions(project.id);
let keywordCleaningRun: SeoModelTaskRun | null = null;
if (result.anchorReview.readiness.wordstatQueueCount > 0) { if (result.anchorReview.readiness.wordstatQueueCount > 0) {
setEnrichingMarketProjectId(project.id); setEnrichingMarketProjectId(project.id);
await runMarketEnrichment(project.id); await runMarketEnrichment(project.id);
keywordCleaningRun = await runAndWaitSeoModelTask(project, "seo.keyword_cleaning");
} }
await refreshAfterAnchorReviewDecision(project); await refreshAfterAnchorReviewDecision(project);
setProjectActionMessage( 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) { } catch (error: unknown) {
setProjectActionError(getErrorMessage(error, "Не удалось сохранить anchor review.")); setProjectActionError(getErrorMessage(error, "Не удалось сохранить anchor review."));
@ -10217,20 +10486,21 @@ export function App() {
setProjectActionProjectId(project.id); setProjectActionProjectId(project.id);
try { try {
const result = await runSeoModelTask(project.id, { const result = await runSeoModelTask(project.id, { taskType });
providerId: "codex_manual", const completedRun =
taskType result.modelTaskRun.runStatus === "queued" || result.modelTaskRun.runStatus === "running"
}); ? await waitForSeoModelTaskCompletion(project.id, result.modelTaskRun.runId)
const statusResult = await fetchModelProviderStatus(project.id); : result.modelTaskRun;
setModelProviderStatuses((currentStatuses) => ({ if (completedRun.runStatus === "failed") {
...currentStatuses, throw new Error(completedRun.errorMessage || `${getModelTaskTypeLabel(taskType)} завершилась ошибкой.`);
[project.id]: statusResult.modelProvider }
}));
await refreshAfterSeoModelTask(project, taskType);
setProjectActionMessage( setProjectActionMessage(
`${getModelTaskTypeLabel(taskType)}: ${getModelTaskRunStatusLabel(result.modelTaskRun.status)} · примерно ${ `${getModelTaskTypeLabel(taskType)}: ${getModelTaskExecutionStatusLabel(completedRun.runStatus)} · provider ${
result.modelTaskRun.estimate.inputTokenEstimate completedRun.provider.title
} входных токенов.` } · примерно ${completedRun.estimate.inputTokenEstimate} входных токенов.`
); );
} catch (error: unknown) { } catch (error: unknown) {
setProjectActionError(getErrorMessage(error, "Не удалось подготовить задачу модели.")); 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( async function handleProjectOntologyApproval(
project: ProjectSummary, project: ProjectSummary,
ontology: ProjectOntologyVersion, ontology: ProjectOntologyVersion,
@ -10640,6 +10977,24 @@ export function App() {
})); }));
if (input?.jumpToStrategy) { if (input?.jumpToStrategy) {
setActiveStageIndex(5); 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) { } catch (error: unknown) {
setProjectActionError(getErrorMessage(error, "Не удалось сохранить keyword decisions.")); setProjectActionError(getErrorMessage(error, "Не удалось сохранить keyword decisions."));
@ -13087,6 +13442,67 @@ export function App() {
))} ))}
</div> </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 ? ( {service.id === "yandex_cloud" && yandexAiStudioProbe ? (
<div className="service-probe-result"> <div className="service-probe-result">
<div> <div>
@ -13132,6 +13548,45 @@ export function App() {
{probingExternalServiceId === service.id ? "Проверяю..." : "Проверить"} {probingExternalServiceId === service.id ? "Проверяю..." : "Проверить"}
</button> </button>
) : null} ) : 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 <button
className="tiny-action primary" className="tiny-action primary"
disabled={isSaving} disabled={isSaving}
@ -13687,6 +14142,7 @@ export function App() {
); );
const activeAnchorReviewFilter = anchorReviewFilters[project.id] ?? "proposed"; const activeAnchorReviewFilter = anchorReviewFilters[project.id] ?? "proposed";
const keywordCurationOverrides = keywordCurationRoleOverrides[project.id] ?? {}; const keywordCurationOverrides = keywordCurationRoleOverrides[project.id] ?? {};
const keywordCleaningStage5Candidates = keywordCleaning ? getKeywordCleaningStage5Candidates(keywordCleaning) : [];
const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null; const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null;
const keywordPagePlans = keywordMap ? getKeywordPlanPageSummaries(keywordMap) : []; const keywordPagePlans = keywordMap ? getKeywordPlanPageSummaries(keywordMap) : [];
const keywordReviewItems = keywordMap ? getKeywordPlanReviewItems(keywordMap) : []; const keywordReviewItems = keywordMap ? getKeywordPlanReviewItems(keywordMap) : [];
@ -13706,10 +14162,11 @@ export function App() {
const isEnrichingMarket = enrichingMarketProjectId === project.id; const isEnrichingMarket = enrichingMarketProjectId === project.id;
const showKeywordAnalysisPending = Boolean(anchorReviewReady && (isEnrichingMarket || !keywordWordstatFresh)); const showKeywordAnalysisPending = Boolean(anchorReviewReady && (isEnrichingMarket || !keywordWordstatFresh));
const showKeywordDemandAnalysis = Boolean(anchorReviewReady && keywordWordstatFresh && !isEnrichingMarket); const showKeywordDemandAnalysis = Boolean(anchorReviewReady && keywordWordstatFresh && !isEnrichingMarket);
const isInterpretingSerp = interpretingSerpProjectId === project.id; const isInterpretingSerp = interpretingSerpProjectId === project.id;
const isSynthesizingStrategy = synthesizingStrategyProjectId === project.id; const isSynthesizingStrategy = synthesizingStrategyProjectId === project.id;
const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id; const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id;
const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id; const isReviewingKeywordCleaning = reviewingKeywordCleaningProjectId === project.id;
const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id;
const isBuildingPatch = buildingPatchProjectId === project.id; const isBuildingPatch = buildingPatchProjectId === project.id;
const isRunningPatchDryRun = patchArtifact const isRunningPatchDryRun = patchArtifact
? runningPatchDryRunKey === `${project.id}:${patchArtifact.changesetId}` ? runningPatchDryRunKey === `${project.id}:${patchArtifact.changesetId}`
@ -16858,48 +17315,72 @@ export function App() {
) : null} ) : null}
</div> </div>
{keywordCleaning && !wordstatOutdated ? ( {keywordCleaning && !wordstatOutdated ? (
<> <>
<div className="market-normalization-card keyword-cleaning-card"> <div className="market-normalization-card keyword-cleaning-card">
<div> <div>
<span>Очистка фраз</span> <span>Очистка фраз</span>
<strong>{keywordCleaning.summary}</strong> <strong>{keywordCleaning.summary}</strong>
<small> <small>
После Wordstat и разбора выдачи: что можно пустить в SEO-план, что оставить в запасе, что После Wordstat и разбора выдачи: что можно пустить в SEO-план, что оставить в запасе, что
проверить и что выкинуть. проверить и что выкинуть.
</small> </small>
<div className="market-normalization-stats"> <div className="market-normalization-stats">
<span>{keywordCleaning.provider.mode === "codex_manual" ? "модель" : "резерв"}</span> <span>{keywordCleaning.provider.mode === "codex_manual" ? "модель" : "резерв"}</span>
<span>{keywordCleaning.analystReview.status}</span> <span>{keywordCleaning.analystReview.status}</span>
<span>Главные {keywordCleaning.readiness.useCount}</span> <span>Stage 5 {keywordCleaning.readiness.keywordMapCandidateCount}</span>
<span>Поддержка {keywordCleaning.readiness.supportCount}</span> <span>Exact {keywordCleaning.readiness.exactEvidenceCount}</span>
<span>Контент {keywordCleaning.readiness.articleCount}</span> <span>Контент {keywordCleaning.readiness.articleCount}</span>
<span>Риск {keywordCleaning.readiness.riskyCount}</span> <span>Риск {keywordCleaning.readiness.riskyCount}</span>
<span>Мусор {keywordCleaning.readiness.trashCount}</span> <span>Мусор {keywordCleaning.readiness.trashCount}</span>
</div> </div>
</div> </div>
<button <div className="model-provider-actions">
className="tiny-action" <button
disabled={!keywordCleaning.modelTask || isRunningKeywordCleaningModelTask} className="tiny-action"
onClick={() => void handleSeoModelTaskRun(project, "seo.keyword_cleaning")} disabled={!keywordCleaning.modelTask || isRunningKeywordCleaningModelTask || isReviewingKeywordCleaning}
type="button" onClick={() => void handleSeoModelTaskRun(project, "seo.keyword_cleaning")}
> type="button"
{isRunningKeywordCleaningModelTask ? ( >
<Loader2 className="spin" size={14} /> {isRunningKeywordCleaningModelTask ? (
) : ( <Loader2 className="spin" size={14} />
<KeyRound size={14} /> ) : (
)} <KeyRound size={14} />
{isRunningKeywordCleaningModelTask ? "Готовлю..." : "Маршрут модели"} )}
</button> {isRunningKeywordCleaningModelTask ? "Готовлю..." : "Маршрут модели"}
</div> </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"> <div className="market-normalization-review keyword-cleaning-review">
<SeoKeywordCleaningLane <SeoKeywordCleaningLane
emptyLabel="Нет фраз для плана правок" emptyLabel="Нет exact-кандидатов для Stage 5"
items={[...keywordCleaning.lanes.use, ...keywordCleaning.lanes.support]} items={keywordCleaningStage5Candidates}
subtitle="только это попадёт в карту фраз" subtitle="пойдут в доску распределения"
title="В SEO-план" title="В Stage 5"
/> />
<SeoKeywordCleaningLane <SeoKeywordCleaningLane
emptyLabel="Очередь пустая" emptyLabel="Очередь пустая"
items={keywordCleaning.lanes.article} items={keywordCleaning.lanes.article}

View File

@ -1100,6 +1100,47 @@ export type SeoModelProviderCatalog = {
providers: SeoModelProviderDescriptor[]; 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 = { export type SeoModelTaskRun = {
schemaVersion: "seo-model-task-run.v1"; schemaVersion: "seo-model-task-run.v1";
runId: string; runId: string;
@ -1137,8 +1178,8 @@ export type SeoModelTaskRun = {
inputTokenEstimate: number; inputTokenEstimate: number;
outputTokenBudget: number; outputTokenBudget: number;
}; };
modelOutput: null; modelOutput: SeoModelTaskModelOutput | null;
persistedResult: null; persistedResult: SeoModelTaskPersistedResult | null;
nextActions: string[]; nextActions: string[];
}; };
@ -2034,14 +2075,27 @@ export type RewriteDiffContract = {
export type ExternalServiceSettings = { export type ExternalServiceSettings = {
schemaVersion: "external-service-settings.v1"; schemaVersion: "external-service-settings.v1";
services: Array<{ 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; label: string;
description: string; description: string;
implementationStatus: "active" | "planned"; implementationStatus: "active" | "planned";
status: "configured" | "disabled" | "missing_required" | "not_implemented"; status: "configured" | "disabled" | "missing_required" | "not_implemented";
enabled: boolean; enabled: boolean;
mode: string; 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<{ modes: Array<{
id: string; id: string;
label: 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 = { export type PageWorkspace = {
projectId: string; projectId: string;
pageId: string; pageId: string;
@ -3129,6 +3233,38 @@ export async function probeYandexAiStudioCredential(phrase?: string) {
return response.json() as Promise<{ probe: YandexAiStudioProbe }>; 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) { export async function updateProjectPageSelection(projectId: string, pageIds: string[], selected: boolean) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/selection`, { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/selection`, {
method: "PATCH", method: "PATCH",

View File

@ -522,6 +522,67 @@ input {
font-weight: 780; 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 { .service-card-footer {
align-items: center; align-items: center;
padding-top: 2px; padding-top: 2px;

View File

@ -16,6 +16,7 @@
"check:semantic-block-runtime": "npm run check:semantic-block-runtime -w server", "check:semantic-block-runtime": "npm run check:semantic-block-runtime -w server",
"check:semantic-blocks": "npm run check:semantic-blocks -w server", "check:semantic-blocks": "npm run check:semantic-blocks -w server",
"check:source-models": "npm run check:source-models -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", "typecheck": "npm run typecheck -w server && npm run typecheck -w app",
"db:migrate": "npm run db:migrate -w server" "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_REGION_IDS: z.string().default("225"),
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"), WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50), 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_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
SERP_API_BASE_URL: z.string().url().optional(), SERP_API_BASE_URL: z.string().url().optional(),
SERP_API_TOKEN: z.string().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_CHROME_PATH: z.string().optional(),
PREVIEW_SNAPSHOT_CONCURRENCY: z.coerce.number().int().positive().max(4).default(2), 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) 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), regionIds: parsedEnv.WORDSTAT_REGION_IDS.split(",").map((item) => item.trim()).filter(Boolean),
devices: parsedEnv.WORDSTAT_DEVICES.split(",").map((item) => item.trim()).filter(Boolean), devices: parsedEnv.WORDSTAT_DEVICES.split(",").map((item) => item.trim()).filter(Boolean),
numPhrases: parsedEnv.WORDSTAT_NUM_PHRASES, numPhrases: parsedEnv.WORDSTAT_NUM_PHRASES,
maxSeedsPerRun: parsedEnv.WORDSTAT_MAX_SEEDS_PER_RUN,
hasApiToken: Boolean(parsedEnv.WORDSTAT_API_TOKEN) hasApiToken: Boolean(parsedEnv.WORDSTAT_API_TOKEN)
}, },
serp: { serp: {
@ -65,6 +82,22 @@ export const env = {
hasApiToken: Boolean(parsedEnv.SERP_API_TOKEN) 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: { preview: {
chromePath: parsedEnv.PREVIEW_CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", chromePath: parsedEnv.PREVIEW_CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
snapshotConcurrency: parsedEnv.PREVIEW_SNAPSHOT_CONCURRENCY, snapshotConcurrency: parsedEnv.PREVIEW_SNAPSHOT_CONCURRENCY,

View File

@ -1,10 +1,13 @@
import { pool } from "../db/client.js"; import { pool } from "../db/client.js";
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.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 KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
type KeywordCleaningSource = "manual" | "normalization_seed" | "serp" | "wordstat_related" | "wordstat_seed"; 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 = { export type KeywordCleaningModelTaskContract = {
taskType: "seo.keyword_cleaning"; taskType: "seo.keyword_cleaning";
schemaVersion: "seo-keyword-cleaning-task.v1"; schemaVersion: "seo-keyword-cleaning-task.v1";
@ -26,10 +29,64 @@ export type KeywordCleaningModelTaskContract = {
wordstatResultCount: number; wordstatResultCount: number;
wordstatJobStatus: string | null; 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: { outputSchema: {
schemaVersion: "keyword-cleaning.v1"; schemaVersion: "keyword-cleaning.v1";
requiredTopLevelKeys: string[]; requiredTopLevelKeys: string[];
itemRequiredKeys: string[]; itemRequiredKeys: string[];
decisionValues: KeywordCleaningDecision[];
}; };
decisionPolicy: { decisionPolicy: {
useRequiresExactEvidence: boolean; useRequiresExactEvidence: boolean;
@ -174,6 +231,36 @@ function unique(values: string[]) {
return result; 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"] { function getDefaultAnalystReview(): KeywordCleaningContract["analystReview"] {
return { return {
notes: [], notes: [],
@ -392,6 +479,115 @@ function getBaseItems(contract: MarketEnrichmentContract) {
return [...seedItems, ...relatedItems]; 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>) { function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>) {
const blockers: string[] = []; const blockers: string[] = [];
const exactEvidence = item.frequency !== null; const exactEvidence = item.frequency !== null;
@ -507,7 +703,9 @@ function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningCont
return { return {
articleCount: lanes.article.length, articleCount: lanes.article.length,
exactEvidenceCount: items.filter((item) => item.frequency !== null).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, missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
riskyCount: lanes.risky.length, riskyCount: lanes.risky.length,
sourcePhraseCount: items.length, sourcePhraseCount: items.length,
@ -545,7 +743,9 @@ function buildKeywordCleaningModelTask(
wordstatJobStatus: contract.wordstat.latestJob?.status ?? null, wordstatJobStatus: contract.wordstat.latestJob?.status ?? null,
wordstatResultCount: contract.wordstat.summary.resultCount wordstatResultCount: contract.wordstat.summary.resultCount
}, },
input: buildKeywordCleaningModelInput(contract),
outputSchema: { outputSchema: {
decisionValues: KEYWORD_CLEANING_DECISIONS,
itemRequiredKeys: [ itemRequiredKeys: [
"id", "id",
"phrase", "phrase",
@ -562,9 +762,12 @@ function buildKeywordCleaningModelTask(
"semanticRunId", "semanticRunId",
"provider", "provider",
"analystReview", "analystReview",
"source",
"modelTask",
"readiness", "readiness",
"lanes", "lanes",
"items", "items",
"summary",
"nextActions" "nextActions"
], ],
schemaVersion: "keyword-cleaning.v1" schemaVersion: "keyword-cleaning.v1"
@ -575,7 +778,7 @@ function buildKeywordCleaningModelTask(
schemaVersion: "seo-keyword-cleaning-task.v1", schemaVersion: "seo-keyword-cleaning-task.v1",
semanticRunId: contract.semanticRunId, semanticRunId: contract.semanticRunId,
stopConditions: [ stopConditions: [
"Не отправлять trash/risky в keyword map как approved decision.", "Не отправлять trash в keyword map. Risky/article exact-фразы можно только предсортировать в Stage 3, но не считать approved без пользователя.",
"Не придумывать спрос без Wordstat/SERP evidence.", "Не придумывать спрос без Wordstat/SERP evidence.",
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.", "Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
"Не сохранять rewrite/apply decisions." "Не сохранять rewrite/apply decisions."
@ -613,7 +816,7 @@ function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">
} }
actions.push( actions.push(
`В keyword map передавать только use/support: ${contract.readiness.keywordMapCandidateCount} кандидатов с evidence и page binding.` `В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} exact-кандидатов в 5 колонок; финальные роли фиксирует пользователь.`
); );
return actions; return actions;
@ -704,8 +907,18 @@ function normalizeKeywordCleaningOutput(
const items = output.items.map((item) => ({ const items = output.items.map((item) => ({
...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)), confidence: Math.max(0, Math.min(1, item.confidence)),
decision: normalizeKeywordCleaningDecision(item.decision),
evidenceRefs: Array.isArray(item.evidenceRefs) ? unique(item.evidenceRefs) : [], evidenceRefs: Array.isArray(item.evidenceRefs) ? unique(item.evidenceRefs) : [],
normalizedPhrase: normalizePhrase(item.normalizedPhrase || item.phrase) normalizedPhrase: normalizePhrase(item.normalizedPhrase || item.phrase)
})); }));
@ -720,7 +933,7 @@ function normalizeKeywordCleaningOutput(
provider: { provider: {
message: providerMessage, message: providerMessage,
mode: providerMode, mode: providerMode,
modelRequired: true modelRequired: providerMode !== "codex_manual"
}, },
readiness, readiness,
sourceTaskId: output.modelTask.taskId, sourceTaskId: output.modelTask.taskId,
@ -775,7 +988,15 @@ export async function getKeywordCleaningContract(projectId: string): Promise<Key
return fallbackContract; 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( export async function saveKeywordCleaningManual(
@ -815,3 +1036,67 @@ export async function saveKeywordCleaningManual(
return run; 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; return phraseMap;
} }
function getKeywordRole( function getKeywordWordCount(value: string) {
item: KeywordCleaningItem, return normalizePhrase(value).split(/\s+/).filter(Boolean).length;
indexInTarget: number, }
targetHasCorePhrase: boolean,
reviewedOntology: boolean const KEYWORD_FACET_STOP_WORDS = new Set([
): KeywordMapRole { "a",
if (reviewedOntology && isHybridSecondaryCandidate(item)) { "an",
return "secondary_candidate"; "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 (reviewedOntology && isBroadArticleBacklogPhrase(item.phrase)) { if (/^(искусствен|интеллект)/i.test(normalizedToken)) {
return "ai";
}
if (/^[a-z0-9]+$/i.test(normalizedToken)) {
return normalizedToken.toLowerCase();
}
return normalizedToken.replace(
/(иями|ями|ами|ого|его|ому|ему|ыми|ими|ых|их|ией|иям|ям|ам|ях|ах|ов|ев|ей|ой|ый|ий|ая|яя|ое|ее|ые|ие|ую|юю|ом|ем|а|я|ы|и|у|ю|е|о)$/i,
""
);
}
function getKeywordFacetStems(value: string) {
return new Set(
normalizePhrase(value)
.replace(/ё/g, "е")
.split(/[^a-zа-я0-9]+/i)
.map((token) => token.trim())
.filter((token) => token.length > 1 && !KEYWORD_FACET_STOP_WORDS.has(token))
.map(stemKeywordToken)
.filter((stem) => stem.length > 1 && !KEYWORD_FACET_STOP_WORDS.has(stem))
);
}
function buildSemanticFacetGuard(contract: MarketEnrichmentContract): SemanticFacetGuard {
const stemDocumentCounts = new Map<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"; return "validate";
} }
if ( if (indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) {
!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") {
return "primary"; 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"; return "secondary";
} }
@ -229,36 +421,44 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) { function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
const targetCounts = new Map<string, number>(); const targetCounts = new Map<string, number>();
const sourceItems = cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120); const semanticFacetGuard = buildSemanticFacetGuard(contract);
const targetHasCorePhrase = sourceItems.reduce<Map<string, boolean>>((accumulator, item) => { const sourceItems = cleaning.items
const rawTargetPath = item.targetPath; .filter((item) => item.decision !== "trash")
const targetKey = rawTargetPath ?? item.clusterId; .sort((left, right) => {
const canAnchorTarget = const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
reviewedOntology && const rightRouteWeight = canModelRouteKeyword(right, semanticFacetGuard) ? 1 : 0;
item.decision !== "article" &&
item.decision !== "risky" &&
item.evidenceStatus === "collected" &&
item.frequency !== null &&
!isBroadArticleBacklogPhrase(item.phrase) &&
!isHybridSecondaryCandidate(item) &&
!isAiDifferentiatorCandidate(item);
if (canAnchorTarget) { if (leftRouteWeight !== rightRouteWeight) {
accumulator.set(targetKey, true); return rightRouteWeight - leftRouteWeight;
} }
return accumulator; const leftPriorityWeight = getKeywordPriorityWeight(left.priority);
}, new Map()); 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) => { return sourceItems.map((item) => {
const rawTargetPath = item.targetPath; const rawTargetPath = item.targetPath;
const targetKey = rawTargetPath ?? item.clusterId; const targetKey = rawTargetPath ?? item.clusterId;
const indexInTarget = targetCounts.get(targetKey) ?? 0; const indexInTarget = targetCounts.get(targetKey) ?? 0;
const role = getKeywordRole(item, indexInTarget, targetHasCorePhrase.get(targetKey) ?? false, reviewedOntology); const role = getKeywordRole(item, indexInTarget, semanticFacetGuard);
const targetPath = item.decision === "article" || role === "secondary_candidate" || isBroadArticleBacklogPhrase(item.phrase) ? null : rawTargetPath; const targetPath = role === "secondary_candidate" ? null : rawTargetPath;
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null; 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); targetCounts.set(targetKey, indexInTarget + 1);
} }
@ -277,7 +477,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
frequencyGroup: item.frequencyGroup, frequencyGroup: item.frequencyGroup,
projectOntologyVersionId: item.projectOntologyVersionId, projectOntologyVersionId: item.projectOntologyVersionId,
wordstatResultId: item.wordstatResultId, wordstatResultId: item.wordstatResultId,
reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology) reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology, semanticFacetGuard)
}; };
}); });
} }
@ -286,10 +486,11 @@ function getKeywordMapItemReason(
item: KeywordCleaningItem, item: KeywordCleaningItem,
role: KeywordMapRole, role: KeywordMapRole,
targetPath: string | null, targetPath: string | null,
reviewedOntology: boolean reviewedOntology: boolean,
semanticFacetGuard: SemanticFacetGuard
) { ) {
if (!reviewedOntology) { if (!reviewedOntology) {
return "Фразу нельзя закреплять без reviewed ontology."; return "Модель предварительно распределила фразу, но фиксация ждёт reviewed ontology.";
} }
if (role === "secondary_candidate") { if (role === "secondary_candidate") {
@ -300,16 +501,40 @@ function getKeywordMapItemReason(
return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title."; 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 решения."; return "Широкая head-фраза уходит в article/backlog: не делаем её прямой основной ставкой текущей посадочной без отдельного SERP page-type решения.";
} }
if (item.decision === "article") { if (item.decision === "article") {
return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной."; return role === "validate"
? "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной."
: "Модель предварительно распределила article/backlog-фразу; пользователь должен подтвердить, что она нужна текущей стратегии.";
} }
if (item.decision === "risky") { if (item.decision === "risky") {
return item.reason; return role === "validate"
? item.reason
: `Модель предварительно распределила спорную фразу; проверь перед фиксацией. ${item.reason}`;
} }
if (item.evidenceStatus === "collected_no_signal") { if (item.evidenceStatus === "collected_no_signal") {
@ -451,44 +676,17 @@ function normalizePersistedKeywordMapItem(input: {
targetPath: string | null; targetPath: string | null;
workspaceSectionId: 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 { return {
approvedAt: input.approvedAt, approvedAt: input.approvedAt,
decisionId: input.decisionId, decisionId: input.decisionId,
mapItemId: input.mapItemId, mapItemId: input.mapItemId,
pageId, pageId: input.pageId,
phrase: input.phrase, phrase: input.phrase,
relatedLanding, relatedLanding: input.relatedLanding,
role, role: input.role,
sourceItemId: input.sourceItemId, sourceItemId: input.sourceItemId,
targetPath, targetPath: input.targetPath,
workspaceSectionId workspaceSectionId: input.workspaceSectionId
}; };
} }
@ -531,7 +729,7 @@ async function getKeywordMapPersistenceSummary(
kmi.page_id, kmi.page_id,
kd.updated_at as approved_at kd.updated_at as approved_at
from keyword_decisions kd 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 where kd.project_id = $1
and kd.semantic_run_id = $2 and kd.semantic_run_id = $2
and kd.project_ontology_version_id = $3 and kd.project_ontology_version_id = $3
@ -574,7 +772,7 @@ function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
if (contract.cleaning.keywordMapCandidateCount === 0) { if (contract.cleaning.keywordMapCandidateCount === 0) {
actions.push( 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) { if (contract.readiness.marketEvidenceCount === 0) {
@ -830,6 +1028,20 @@ export async function approveKeywordMapDecisions(
continue; 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( await client.query(
` `
insert into keyword_map_items ( insert into keyword_map_items (

View File

@ -47,6 +47,7 @@ export type WordstatProviderStatus =
provider: "mcp_kv" | "yandex_api"; provider: "mcp_kv" | "yandex_api";
mode: string; mode: string;
message: string; message: string;
maxSeedsPerRun: number;
} }
| { | {
connected: false; connected: false;
@ -246,7 +247,7 @@ async function postJson(endpoint: string, body: unknown, headers: Record<string,
} }
class McpKvWordstatProvider implements WordstatProvider { class McpKvWordstatProvider implements WordstatProvider {
constructor(private readonly endpoint: string | undefined) {} constructor(private readonly endpoint: string | undefined, private readonly maxSeedsPerRun: number) {}
getStatus(): WordstatProviderStatus { getStatus(): WordstatProviderStatus {
if (!this.endpoint) { if (!this.endpoint) {
@ -262,7 +263,8 @@ class McpKvWordstatProvider implements WordstatProvider {
connected: true, connected: true,
provider: "mcp_kv", provider: "mcp_kv",
mode: "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, connected: true,
provider: "yandex_api", provider: "yandex_api",
mode: "yandex_search_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(); const config = await getWordstatRuntimeConfig();
if (config.provider === "mcp_kv") { if (config.provider === "mcp_kv") {
return new McpKvWordstatProvider(config.mcpEndpoint); return new McpKvWordstatProvider(config.mcpEndpoint, config.maxSeedsPerRun);
} }
if (config.provider === "yandex_api") { if (config.provider === "yandex_api") {

View File

@ -382,6 +382,29 @@ function getRepresentativeTopResultRows(results: WordstatResultRow[]) {
return Array.from(selectedRows.values()).sort(compareWordstatResultRows); 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 { function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]): WordstatEvidence {
const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase); const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
const relatedCounts = new Map<string, number>(); 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( async function createJob(
projectId: string, projectId: string,
semanticRunId: string, semanticRunId: string,
@ -693,8 +736,17 @@ export async function collectWordstatEvidence(
} }
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates); 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"; 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); const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds);
if (!job) { if (!job) {
@ -763,31 +815,35 @@ export async function getLatestWordstatEvidence(
const results = await pool.query<WordstatResultRow>( const results = await pool.query<WordstatResultRow>(
` `
select select
id, wr.id,
job_id, wr.job_id,
seed_id, wr.seed_id,
phrase, wr.phrase,
source_phrase, wr.source_phrase,
normalized_phrase, wr.normalized_phrase,
frequency, wr.frequency,
frequency_group, wr.frequency_group,
region, wr.region,
position, wr.position,
raw, wr.raw,
created_at wr.created_at
from wordstat_results from wordstat_results wr
where job_id = $1 join wordstat_jobs wj on wj.id = wr.job_id
order by position asc, phrase asc; 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) { if (!scopePhrases || scopePhrases.length === 0) {
return buildEvidence(latestJob, results.rows); return buildEvidence(latestJob, latestUniqueResults);
} }
const scopePhraseSet = new Set(scopePhrases.map(normalizePhrase)); 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 sourcePhrase = row.source_phrase ? normalizePhrase(row.source_phrase) : null;
const phrase = normalizePhrase(row.phrase); const phrase = normalizePhrase(row.phrase);

View File

@ -347,7 +347,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
"SERP interpretation работает только по сохранённому yandex-evidence.v1 и не придумывает конкурентов или спрос.", "SERP interpretation работает только по сохранённому yandex-evidence.v1 и не придумывает конкурентов или спрос.",
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.", "Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.", "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." "Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval."
], ],
nextActions: !analysis nextActions: !analysis
@ -360,7 +360,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.", "После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.", "После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.", "После 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." "Не запускать 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 { z } from "zod";
import { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js";
export const SEO_MODEL_TASK_TYPES = [ export const SEO_MODEL_TASK_TYPES = [
"seo.context_review", "seo.context_review",
@ -141,7 +142,7 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
title: "Codex Workspace adapter", title: "Codex Workspace adapter",
mode: "workspace_agent", mode: "workspace_agent",
summary: 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, allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES,
requiresSecrets: false, requiresSecrets: false,
secretNames: [], secretNames: [],
@ -157,9 +158,11 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
guardrails: [ guardrails: [
"Workspace agent не должен читать репозиторий напрямую для пользовательских сайтов.", "Workspace agent не должен читать репозиторий напрямую для пользовательских сайтов.",
"Все filesystem/tool calls должны идти через approved MCP capabilities.", "Все filesystem/tool calls должны идти через approved MCP capabilities.",
"SEO route требует явный contract-only workspace path и не использует executor workspace fallback.",
"Ответ должен проходить schema validation перед записью результата." "Ответ должен проходить 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", 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 { export function getDefaultSeoModelProviderId(): SeoModelProviderId {
return "codex_manual"; return getSeoAiWorkspaceConfigStatus().configured ? "codex_workspace" : "codex_manual";
} }
export function isSeoModelProviderId(value: string): value is SeoModelProviderId { 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 { 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 { export function getSeoModelProviderCatalog(): SeoModelProviderCatalog {
const providers = getParsedProviders();
return SeoModelProviderCatalogSchema.parse({ return SeoModelProviderCatalogSchema.parse({
schemaVersion: "seo-model-provider-catalog.v1", schemaVersion: "seo-model-provider-catalog.v1",
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
defaultProviderId: getDefaultSeoModelProviderId(), defaultProviderId: getDefaultSeoModelProviderId(),
providers: PARSED_PROVIDERS providers
}); });
} }

View File

@ -1,5 +1,20 @@
import { pool } from "../db/client.js"; import { pool } from "../db/client.js";
import { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js";
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.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 { import {
getDefaultSeoModelProviderId, getDefaultSeoModelProviderId,
getSeoModelProviderCatalog, getSeoModelProviderCatalog,
@ -24,6 +39,11 @@ type SeoModelTaskRunRow = {
created_at: Date; created_at: Date;
}; };
type DomainPromotionResult = {
persistedResult: SeoAiWorkspacePersistedResult;
nextAction: string;
};
export type SeoModelTaskRunInput = { export type SeoModelTaskRunInput = {
providerId?: SeoModelProviderId; providerId?: SeoModelProviderId;
taskId?: string; taskId?: string;
@ -60,8 +80,8 @@ export type SeoModelTaskRunOutput = {
inputTokenEstimate: number; inputTokenEstimate: number;
outputTokenBudget: number; outputTokenBudget: number;
}; };
modelOutput: null; modelOutput: SeoAiWorkspaceModelOutput | null;
persistedResult: null; persistedResult: SeoAiWorkspacePersistedResult | null;
nextActions: string[]; nextActions: string[];
}; };
@ -218,7 +238,11 @@ function validateTaskContract(task: SeoModelTaskContract | null, provider: SeoMo
warnings.push(`Provider ${provider.id} пока ${provider.status}; боевой model call не выполняется.`); warnings.push(`Provider ${provider.id} пока ${provider.status}; боевой model call не выполняется.`);
} }
notes.push("Dry-run проверяет только provider/task contract, а не качество будущего model output."); if (provider.id === "codex_workspace" && provider.status === "active") {
notes.push("AI Workspace adapter отправляет sanitized task contract в remote Codex worker через Assistant/Hub route.");
} else {
notes.push("Dry-run проверяет только provider/task contract, а не качество будущего model output.");
}
notes.push("Результат модели должен сохраняться отдельным evidence run и проходить schema validation."); notes.push("Результат модели должен сохраняться отдельным evidence run и проходить schema validation.");
return { return {
@ -288,11 +312,17 @@ function buildRunOutput(
persistedResult: null, persistedResult: null,
nextActions: nextActions:
status === "contract_ready" status === "contract_ready"
? [ ? provider.id === "codex_workspace"
"Подключить реальный model adapter к этому же task contract.", ? [
"Сохранять model output как evidence run только после schema validation.", "Отправить task contract в AI Workspace Assistant / AI Hub / remote Codex worker.",
"Сравнивать model output с deterministic fallback перед human approval." "Дождаться structured JSON и проверить output schema.",
] "Сохранить валидный output как model-task evidence run; доменное promotion остается backend-owned."
]
: [
"Подключить реальный model adapter к этому же task contract.",
"Сохранять model output как evidence run только после schema validation.",
"Сравнивать model output с deterministic fallback перед human approval."
]
: status === "provider_not_configured" : status === "provider_not_configured"
? [provider.nextSetupStep, "Пока использовать contract_dry_run для проверки маршрута."] ? [provider.nextSetupStep, "Пока использовать contract_dry_run для проверки маршрута."]
: status === "task_not_found" : status === "task_not_found"
@ -304,20 +334,311 @@ function buildRunOutput(
async function insertSeoModelTaskRun( async function insertSeoModelTaskRun(
projectId: string, projectId: string,
input: Record<string, unknown>, 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>( const result = await pool.query<SeoModelTaskRunRow>(
` `
insert into runs (project_id, run_type, status, input, output, started_at, completed_at) insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
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 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]); 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[]> { export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): Promise<SeoModelTaskRun[]> {
const result = await pool.query<SeoModelTaskRunRow>( const result = await pool.query<SeoModelTaskRunRow>(
` `
@ -336,6 +657,7 @@ export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): P
export async function getSeoModelProviderStatusContract( export async function getSeoModelProviderStatusContract(
projectId: string projectId: string
): Promise<SeoModelProviderStatusContract> { ): Promise<SeoModelProviderStatusContract> {
await refreshRunningSeoModelTaskRuns(projectId);
const [catalog, latestRuns] = await Promise.all([getSeoModelProviderCatalog(), getLatestSeoModelTaskRuns(projectId)]); const [catalog, latestRuns] = await Promise.all([getSeoModelProviderCatalog(), getLatestSeoModelTaskRuns(projectId)]);
return { return {
@ -353,15 +675,77 @@ export async function runSeoModelTask(projectId: string, input: SeoModelTaskRunI
const context = await getSeoModelContextContract(projectId); const context = await getSeoModelContextContract(projectId);
const task = findTask(context.modelTasks, input); const task = findTask(context.modelTasks, input);
const output = buildRunOutput(projectId, provider, task, context.schemaVersion); const output = buildRunOutput(projectId, provider, task, context.schemaVersion);
const baseInput = {
schemaVersion: "seo-model-task-run-input.v1",
providerId,
requestedTaskId: input.taskId ?? null,
requestedTaskType: input.taskType ?? null,
sourceContextGeneratedAt: context.generatedAt
};
if (provider.id === "codex_workspace" && output.status === "contract_ready" && task) {
const queuedRun = await insertSeoModelTaskRun(projectId, baseInput, output, "running");
if (!queuedRun) {
throw new Error("Не удалось сохранить seo model task run.");
}
try {
const dispatch = await dispatchSeoModelTaskToAiWorkspace({
projectId,
runId: queuedRun.runId,
task
});
const dispatchedOutput: SeoModelTaskRunOutput = {
...output,
modelOutput: buildAcceptedAiWorkspaceModelOutput(dispatch),
nextActions: [
"AI Workspace request accepted; model output will be collected from Assistant thread messages.",
"Refresh model-provider status to pick up bridge_final/bridge_failure.",
"Structured JSON must pass schema validation before task-specific evidence promotion."
]
};
const dispatchedRun = await updateSeoModelTaskRun(queuedRun.runId, {
input: {
...baseInput,
aiWorkspace: dispatch
},
output: dispatchedOutput,
status: "running"
});
if (!dispatchedRun) {
throw new Error("Не удалось обновить seo model task run после AI Workspace dispatch.");
}
return dispatchedRun;
} catch (error) {
const failedOutput: SeoModelTaskRunOutput = {
...output,
nextActions: [
"Проверить SEO AI Workspace config/profile и выбранный Codex executor.",
"Повторить dispatch после восстановления Assistant/Hub/worker route.",
"Не использовать fallback на deployed product hosts или executor workspace по умолчанию."
]
};
const failedRun = await updateSeoModelTaskRun(queuedRun.runId, {
completed: true,
errorMessage: error instanceof Error ? error.message : "ai_workspace_dispatch_failed",
output: failedOutput,
status: "failed"
});
if (!failedRun) {
throw error;
}
return failedRun;
}
}
const run = await insertSeoModelTaskRun( const run = await insertSeoModelTaskRun(
projectId, projectId,
{ baseInput,
schemaVersion: "seo-model-task-run-input.v1",
providerId,
requestedTaskId: input.taskId ?? null,
requestedTaskType: input.taskType ?? null,
sourceContextGeneratedAt: context.generatedAt
},
output output
); );

View File

@ -18,7 +18,7 @@ type SeoIntentType =
type SeoMarketRole = "commercial" | "core" | "exclude" | "integration" | "problem" | "support"; type SeoMarketRole = "commercial" | "core" | "exclude" | "integration" | "problem" | "support";
type SeoReviewStatus = "auto_approved" | "needs_human" | "rejected"; type SeoReviewStatus = "auto_approved" | "needs_human" | "rejected";
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
type SeoNormalizationProviderMode = "codex_manual" | "deterministic_fallback"; type SeoNormalizationProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
type SeoNormalizationRunRow = { type SeoNormalizationRunRow = {
id: string; id: string;
@ -1047,3 +1047,42 @@ export async function saveSeoNormalizationManual(
return run; 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 { env } from "../config/env.js";
import { pool } from "../db/client.js"; import { pool } from "../db/client.js";
import { getSeoAiWorkspaceConfigStatus } from "../modelProvider/aiWorkspaceBridgeConfig.js";
export type ExternalServiceId = export type ExternalServiceId =
| "seo_analytics_model"
| "yandex_cloud" | "yandex_cloud"
| "yandex_metrica" | "yandex_metrica"
| "yandex_search_serp" | "yandex_search_serp"
@ -91,6 +93,7 @@ export type WordstatRuntimeConfig =
provider: "mcp_kv"; provider: "mcp_kv";
mode: "mcp_kv"; mode: "mcp_kv";
mcpEndpoint: string; mcpEndpoint: string;
maxSeedsPerRun: number;
} }
| { | {
provider: "yandex_api"; provider: "yandex_api";
@ -102,6 +105,7 @@ export type WordstatRuntimeConfig =
regionIds: string[]; regionIds: string[];
devices: string[]; devices: string[];
numPhrases: number; numPhrases: number;
maxSeedsPerRun: number;
}; };
const WORDSTAT_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/wordstat"; 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 METRICA_BASE_URL = "https://api-metrika.yandex.net";
const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = { 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: { yandex_cloud: {
id: "yandex_cloud", id: "yandex_cloud",
label: "Yandex AI Studio / Cloud API key", label: "Yandex AI Studio / Cloud API key",
@ -223,6 +251,14 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
placeholder: "50", placeholder: "50",
help: "Сколько Wordstat rows запрашивать для одной seed-фразы." help: "Сколько Wordstat rows запрашивать для одной seed-фразы."
}, },
{
id: "maxSeedsPerRun",
label: "Max seeds per run",
type: "number",
required: false,
placeholder: "40",
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
},
{ {
id: "mcpEndpoint", id: "mcpEndpoint",
label: "MCP endpoint", label: "MCP endpoint",
@ -386,6 +422,7 @@ function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) {
apiBaseUrl: WORDSTAT_BASE_URL, apiBaseUrl: WORDSTAT_BASE_URL,
devices: "DEVICE_ALL", devices: "DEVICE_ALL",
numPhrases: "50", numPhrases: "50",
maxSeedsPerRun: "40",
regionIds: "225" regionIds: "225"
}; };
@ -446,6 +483,16 @@ function getMissingFields(
const missing: string[] = []; 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) { for (const field of definition.publicFields) {
if (field.id === "mcpEndpoint" && mode !== "mcp_kv") { if (field.id === "mcpEndpoint" && mode !== "mcp_kv") {
continue; continue;
@ -681,6 +728,19 @@ export async function updateExternalServiceSettings(
return mapRow(definition, row, dependencyStatuses); 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[]) { function splitCsv(value: string | undefined, fallback: string[]) {
const values = (value ?? "") const values = (value ?? "")
.split(",") .split(",")
@ -711,7 +771,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
return { return {
provider: "mcp_kv", provider: "mcp_kv",
mode: "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, folderId: cloudPublicConfig.folderId,
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]), regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]), 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 { return {
provider: "mcp_kv", provider: "mcp_kv",
mode: "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, folderId: env.marketProviders.wordstat.folderId,
regionIds: env.marketProviders.wordstat.regionIds, regionIds: env.marketProviders.wordstat.regionIds,
devices: env.marketProviders.wordstat.devices, 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, updateExternalServiceSettings,
type ExternalServiceId type ExternalServiceId
} from "./externalServiceSettings.js"; } from "./externalServiceSettings.js";
import {
createSeoAnalyticsModelSetupCommand,
probeSeoAnalyticsModel
} from "./seoAnalyticsModelBridge.js";
import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js"; import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js";
export const settingsRouter = Router(); export const settingsRouter = Router();
const externalServiceIdSchema = z.enum([ const externalServiceIdSchema = z.enum([
"seo_analytics_model",
"yandex_cloud", "yandex_cloud",
"yandex_metrica", "yandex_metrica",
"yandex_search_serp", "yandex_search_serp",
@ -27,6 +32,13 @@ const updateExternalServiceSettingsSchema = z.object({
const yandexAiStudioProbeSchema = z.object({ const yandexAiStudioProbeSchema = z.object({
phrase: z.string().trim().min(2).max(160).optional() 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) { function sendError(response: Response, status: number, message: string) {
response.status(status).json({ 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) => { settingsRouter.patch("/external-services/:serviceId", async (request, response) => {
try { try {
const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId; const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId;

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"; return value?.trim() || "unmapped";
} }
function normalizePhraseKey(value: string) {
return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU");
}
function getConfidence(score: number): SeoStrategyConfidence { function getConfidence(score: number): SeoStrategyConfidence {
if (score >= 75) { if (score >= 75) {
return "high"; return "high";
@ -953,14 +957,22 @@ function buildOpportunities(
function buildSerpOpportunities( function buildSerpOpportunities(
serpInterpretation: SerpInterpretationContract, serpInterpretation: SerpInterpretationContract,
keywordMap: KeywordMapContract,
siteMaturity: SeoStrategyContract["siteMaturity"] siteMaturity: SeoStrategyContract["siteMaturity"]
): SeoStrategyContract["opportunities"] { ): SeoStrategyContract["opportunities"] {
if (serpInterpretation.state !== "ready") { if (serpInterpretation.state !== "ready") {
return []; return [];
} }
const approvedPhraseSet = new Set(keywordMap.persisted.items.map((item) => normalizePhraseKey(item.phrase)));
return serpInterpretation.phraseInterpretations 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) .slice(0, 6)
.map((phrase) => { .map((phrase) => {
const isMismatch = phrase.targetFit === "mismatch"; const isMismatch = phrase.targetFit === "mismatch";
@ -1336,7 +1348,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
const phasePlan = buildPhasePlan(siteMaturity); const phasePlan = buildPhasePlan(siteMaturity);
const opportunities = [ const opportunities = [
...buildOpportunities(cleaning, keywordMap, siteMaturity), ...buildOpportunities(cleaning, keywordMap, siteMaturity),
...buildSerpOpportunities(serpInterpretation, siteMaturity) ...buildSerpOpportunities(serpInterpretation, keywordMap, siteMaturity)
]; ];
const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation); const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation);
const risks = buildRisks({ const risks = buildRisks({

View File

@ -5,7 +5,7 @@ import { getLatestStrategySynthesisContract, type StrategySynthesisContract } fr
import { buildStrategyQualityReviewModelTask } from "./strategyQualityReviewTask.js"; import { buildStrategyQualityReviewModelTask } from "./strategyQualityReviewTask.js";
type RunStatus = "cancelled" | "done" | "failed" | "queued" | "running"; 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 StrategyQualityReviewState = "not_ready" | "ready";
type StrategyQualityReviewVerdictStatus = "approved" | "blocked" | "needs_changes"; type StrategyQualityReviewVerdictStatus = "approved" | "blocked" | "needs_changes";
type StrategyQualityReviewCheckStatus = "fail" | "pass" | "warning"; 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 { function mapStrategyQualityReviewRun(row: StrategyQualityReviewRunRow): StrategyQualityReviewRun | null {
if (!row.output) { if (!row.output) {
return null; return null;
@ -224,9 +379,18 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis
const hasApplyLeak = [...synthesis.recommendedPath.doNow, ...synthesis.nextActions].some((action) => const hasApplyLeak = [...synthesis.recommendedPath.doNow, ...synthesis.nextActions].some((action) =>
/запустить rewrite|запустить apply|apply changes|source mutation|создать правк|применить|изменить исходник/i.test(action) /запустить rewrite|запустить apply|apply changes|source mutation|создать правк|применить|изменить исходник/i.test(action)
); );
const hasExpansionLeak = synthesis.decisionOptions.some( const expansionOptions = synthesis.decisionOptions.filter((option) => option.scenario === "serp_expansion");
(option) => option.scenario === "serp_expansion" && option.confidence !== "low" && strategy.siteMaturity.phase === "bootstrap_indexing" 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 hasPhaseDecision = synthesis.phaseDecision.currentPhase === strategy.siteMaturity.phase && synthesis.phaseDecision.hold.length > 0;
const hasReadableNextStep = synthesis.recommendedPath.doNow.length > 0 && synthesis.recommendedPath.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({ makeCheck({
action: hasExpansionLeak action: hasExpansionLeak
? "Понизить доверие у ветки расширения или вынести её в отдельную ветку после подтверждения пользователем." ? "Понизить доверие у ветки расширения или вынести её в отдельную ветку после подтверждения пользователем."
: "Оставить расширение только как будущую ветку с низким доверием.", : hasFutureExpansionBranch
? "Оставить расширение отдельной будущей веткой и не показывать его как текущее действие."
: "Оставить расширение только как будущую ветку с низким доверием.",
category: "business_drift", category: "business_drift",
id: "business:no_core_expansion_leak", id: "business:no_core_expansion_leak",
message: hasExpansionLeak message: hasExpansionLeak
? "Ветка расширения выглядит как основной маршрут для стартовой фазы." ? "Ветка расширения выглядит как основной маршрут для стартовой фазы."
: "Расширение бизнеса не смешано с текущим основным SEO-маршрутом.", : hasFutureExpansionBranch
? "Расширение есть как отдельная будущая ветка, но не попало в текущий маршрут."
: "Расширение бизнеса не смешано с текущим основным SEO-маршрутом.",
severity: hasExpansionLeak ? "high" : "low", severity: hasExpansionLeak ? "high" : "low",
status: hasExpansionLeak ? "fail" : "pass", status: hasExpansionLeak ? "fail" : hasFutureExpansionBranch ? "warning" : "pass",
title: "Нет смешения с расширением бизнеса" title: "Нет смешения с расширением бизнеса"
}), }),
makeCheck({ makeCheck({
@ -503,6 +671,8 @@ function normalizeOutput(
const blockers = output.checks.filter((check) => check.status === "fail").map((check) => `${check.title}: ${check.action}`); 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 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 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 { return {
...output, ...output,
@ -524,11 +694,18 @@ function normalizeOutput(
issueCount: blockers.length + warnings.length, issueCount: blockers.length + warnings.length,
passedCount: approvedSignals.length, passedCount: approvedSignals.length,
phaseGuardrailIssueCount: output.checks.filter((check) => check.category === "phase_guardrail" && check.status !== "pass").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, uxIssueCount: output.checks.filter((check) => check.category === "ux_clarity" && check.status !== "pass").length,
warningCount: warnings.length warningCount: warnings.length
}, },
summary,
state: output.checks.length > 0 ? "ready" : "not_ready", state: output.checks.length > 0 ? "ready" : "not_ready",
verdict: {
...output.verdict,
primaryBlocker: blockers[0] ?? output.verdict.primaryBlocker,
score,
summary
},
warnings warnings
}; };
} }
@ -619,3 +796,54 @@ export async function saveStrategyQualityReviewManual(
return run; 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"; import { buildStrategySynthesisModelTask } from "./strategySynthesisTask.js";
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled"; 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 StrategySynthesisState = "not_ready" | "ready";
type StrategySynthesisConfidence = "high" | "low" | "medium"; type StrategySynthesisConfidence = "high" | "low" | "medium";
type StrategySynthesisPriority = "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 { function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesisRun | null {
if (!row.output) { if (!row.output) {
return null; return null;
@ -618,3 +754,53 @@ export async function saveStrategySynthesisManual(
return run; 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;
}