feat(seo): wire AI workspace model flow
This commit is contained in:
parent
75fd57b036
commit
1ed099140f
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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") {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
|
||||||
|
|
@ -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}.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue