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_DEVICES=DEVICE_ALL
|
||||
# WORDSTAT_NUM_PHRASES=50
|
||||
# WORDSTAT_MAX_SEEDS_PER_RUN=40
|
||||
|
||||
SERP_PROVIDER=disabled
|
||||
# SERP_API_BASE_URL=https://serp-provider.internal
|
||||
# SERP_API_TOKEN=change-me
|
||||
|
||||
# SEO model-provider bridge through AI Workspace Assistant / AI Hub / remote Codex worker.
|
||||
# Keep disabled until a contract-only worker workspace and explicit owner/profile are configured.
|
||||
# Profiles:
|
||||
# - local: local AI Workspace Assistant at 127.0.0.1/localhost.
|
||||
# - deployed-hub: local SEO backend uses deployed https://ai-hub.nodedc.ru token-gated control-plane proxy.
|
||||
# - deploy-host: SEO backend runs inside deployed Docker network and talks to http://ai-workspace-assistant:18082.
|
||||
# - tunnel-local-e2e: explicit tunnel/Tailscale end-to-end test profile.
|
||||
SEO_AI_WORKSPACE_PROFILE=disabled
|
||||
# SEO_AI_WORKSPACE_CONTROL_URL=http://127.0.0.1:18082
|
||||
# SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru
|
||||
# SEO_AI_WORKSPACE_CONTROL_TOKEN=
|
||||
# Legacy aliases accepted:
|
||||
# SEO_AI_WORKSPACE_ASSISTANT_URL=
|
||||
# SEO_AI_WORKSPACE_ASSISTANT_TOKEN=
|
||||
# SEO_AI_WORKSPACE_OWNER_USER_ID=
|
||||
# SEO_AI_WORKSPACE_OWNER_EMAIL=
|
||||
# SEO_AI_WORKSPACE_OWNER_ROLE=
|
||||
# SEO_AI_WORKSPACE_OWNER_GROUPS=
|
||||
# SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID=
|
||||
# SEO_AI_WORKSPACE_CONTRACT_ONLY=1
|
||||
# SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH=/tmp/nodedc-seo-model-contracts
|
||||
# SEO_AI_WORKSPACE_TIMEOUT_MS=15000
|
||||
|
||||
VITE_API_BASE_URL=http://localhost:4100
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import {
|
|||
buildPatchArtifact,
|
||||
approveKeywordMapDecisions,
|
||||
copyProject,
|
||||
createSeoAnalyticsModelSetupCommand,
|
||||
deleteProject,
|
||||
downloadSemanticAnalysisExport,
|
||||
fetchExternalServiceSettings,
|
||||
|
|
@ -83,6 +84,7 @@ import {
|
|||
getPreviewPageUrl,
|
||||
importBrowserFolder,
|
||||
openPageWorkspace,
|
||||
probeSeoAnalyticsModel,
|
||||
probeYandexAiStudioCredential,
|
||||
renameProject,
|
||||
resetPageWorkspace,
|
||||
|
|
@ -97,6 +99,7 @@ import {
|
|||
runSeoContextReview,
|
||||
runSeoModelTask,
|
||||
saveAnchorReviewDecisions,
|
||||
saveManualKeywordCleaning,
|
||||
savePageSemanticBlocks,
|
||||
saveContentFieldDraft,
|
||||
savePageWorkspace,
|
||||
|
|
@ -131,6 +134,8 @@ import {
|
|||
type RewriteDiffContract,
|
||||
type RewritePlanContract,
|
||||
type SeoContextReviewRun,
|
||||
type SeoAnalyticsModelProbe,
|
||||
type SeoAnalyticsModelSetupCommand,
|
||||
type SeoStrategyContract,
|
||||
type SeoModelProviderStatusContract,
|
||||
type SeoModelTaskRun,
|
||||
|
|
@ -163,13 +168,17 @@ type WorkspaceMode = "auto" | "dev";
|
|||
type ExternalService = ExternalServiceSettings["services"][number];
|
||||
type ExternalServiceDraft = ExternalServiceUpdateInput;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const keywordCurationColumns: Array<{
|
||||
description: string;
|
||||
role: KeywordCurationRole;
|
||||
title: string;
|
||||
}> = [
|
||||
{
|
||||
description: "предложенные, спорные или ещё не отнесённые запросы",
|
||||
description: "не участвуют в стратегии",
|
||||
role: "validate",
|
||||
title: "Неразобранные"
|
||||
},
|
||||
|
|
@ -4307,6 +4316,18 @@ function SeoKeywordCleaningLane({
|
|||
);
|
||||
}
|
||||
|
||||
function getKeywordCleaningStage5Candidates(keywordCleaning: KeywordCleaningContract) {
|
||||
return keywordCleaning.items.filter(
|
||||
(item) =>
|
||||
item.decision !== "trash" &&
|
||||
item.evidenceStatus === "collected" &&
|
||||
item.frequency !== null &&
|
||||
Boolean(item.targetPath) &&
|
||||
Boolean(item.projectOntologyVersionId) &&
|
||||
Boolean(item.wordstatResultId)
|
||||
);
|
||||
}
|
||||
|
||||
function KeywordCurationCard({
|
||||
item,
|
||||
role
|
||||
|
|
@ -4552,7 +4573,7 @@ function KeywordCurationBoard({
|
|||
<SlidersHorizontal size={20} />
|
||||
<div>
|
||||
<strong>Этап 3 · Распределение ключей</strong>
|
||||
<small>Перетащи запросы между колонками, чтобы поправить модельную сортировку перед стратегией.</small>
|
||||
<small>Модель распределяет, пользователь правит. Неразобранные не участвуют.</small>
|
||||
</div>
|
||||
</div>
|
||||
<button className="tiny-action" onClick={onReconfigure} type="button">
|
||||
|
|
@ -5346,12 +5367,12 @@ function getKeywordMapStateLabel(state: KeywordMapContract["state"]) {
|
|||
|
||||
function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) {
|
||||
const labels = {
|
||||
differentiator: "дифф.",
|
||||
primary: "главный",
|
||||
secondary: "доп.",
|
||||
differentiator: "уточняющие / УТП",
|
||||
primary: "посадочное ядро",
|
||||
secondary: "поддерживающее ядро",
|
||||
secondary_candidate: "кандидат",
|
||||
support: "поддержка",
|
||||
validate: "проверить"
|
||||
support: "длинный хвост",
|
||||
validate: "неразобранные"
|
||||
};
|
||||
|
||||
return labels[role as keyof typeof labels] ?? role;
|
||||
|
|
@ -5928,6 +5949,18 @@ function getModelTaskRunStatusLabel(status: SeoModelTaskRun["status"]) {
|
|||
return labels[status];
|
||||
}
|
||||
|
||||
function getModelTaskExecutionStatusLabel(status: SeoModelTaskRun["runStatus"]) {
|
||||
const labels = {
|
||||
cancelled: "отменено",
|
||||
done: "готово",
|
||||
failed: "ошибка",
|
||||
queued: "в очереди",
|
||||
running: "в работе"
|
||||
};
|
||||
|
||||
return labels[status];
|
||||
}
|
||||
|
||||
function getModelProviderStatusLabel(status: SeoModelProviderStatusContract["catalog"]["providers"][number]["status"]) {
|
||||
const labels = {
|
||||
active: "активен",
|
||||
|
|
@ -5951,6 +5984,7 @@ function getExternalServiceStatusLabel(status: ExternalService["status"]) {
|
|||
|
||||
function getExternalServiceShortName(serviceId: ExternalService["id"]) {
|
||||
const labels = {
|
||||
seo_analytics_model: "Аналитическая модель",
|
||||
yandex_cloud: "Yandex AI Studio / Cloud API key",
|
||||
yandex_metrica: "Yandex Metrica",
|
||||
yandex_search_serp: "Yandex SERP",
|
||||
|
|
@ -5963,9 +5997,12 @@ function getExternalServiceShortName(serviceId: ExternalService["id"]) {
|
|||
|
||||
function getExternalMissingFieldLabel(fieldId: string) {
|
||||
const labels: Record<string, string> = {
|
||||
aiWorkspaceProfile: "AI Workspace profile",
|
||||
apiKey: "Yandex AI Studio API key",
|
||||
executorId: "Codex bridge executor",
|
||||
folderId: "folderId",
|
||||
iamToken: "IAM token",
|
||||
seo_analytics_model: "Аналитическая модель",
|
||||
yandex_cloud: "Yandex AI Studio / Cloud API key"
|
||||
};
|
||||
|
||||
|
|
@ -5986,6 +6023,10 @@ function buildExternalServiceDrafts(settings: ExternalServiceSettings) {
|
|||
}
|
||||
|
||||
function getVisiblePublicFields(service: ExternalService, draft: ExternalServiceDraft | undefined) {
|
||||
if (service.id === "seo_analytics_model") {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (service.id === "yandex_wordstat" && draft?.mode === "mcp_kv") {
|
||||
return service.publicFields.filter((field) => field.id === "mcpEndpoint");
|
||||
}
|
||||
|
|
@ -8472,6 +8513,7 @@ export function App() {
|
|||
const [interpretingSerpProjectId, setInterpretingSerpProjectId] = useState<string | null>(null);
|
||||
const [synthesizingStrategyProjectId, setSynthesizingStrategyProjectId] = useState<string | null>(null);
|
||||
const [reviewingStrategyQualityProjectId, setReviewingStrategyQualityProjectId] = useState<string | null>(null);
|
||||
const [reviewingKeywordCleaningProjectId, setReviewingKeywordCleaningProjectId] = useState<string | null>(null);
|
||||
const [persistingKeywordMapProjectId, setPersistingKeywordMapProjectId] = useState<string | null>(null);
|
||||
const [materializingTargetKey, setMaterializingTargetKey] = useState<string | null>(null);
|
||||
const [buildingPatchProjectId, setBuildingPatchProjectId] = useState<string | null>(null);
|
||||
|
|
@ -8513,6 +8555,10 @@ export function App() {
|
|||
const [savingExternalServiceId, setSavingExternalServiceId] = useState<string | null>(null);
|
||||
const [probingExternalServiceId, setProbingExternalServiceId] = useState<string | null>(null);
|
||||
const [yandexAiStudioProbe, setYandexAiStudioProbe] = useState<YandexAiStudioProbe | null>(null);
|
||||
const [seoAnalyticsModelProbe, setSeoAnalyticsModelProbe] = useState<SeoAnalyticsModelProbe | null>(null);
|
||||
const [seoAnalyticsModelSetupCommand, setSeoAnalyticsModelSetupCommand] =
|
||||
useState<SeoAnalyticsModelSetupCommand | null>(null);
|
||||
const [generatingSeoAnalyticsModelCommand, setGeneratingSeoAnalyticsModelCommand] = useState(false);
|
||||
const [externalServicesError, setExternalServicesError] = useState<string | null>(null);
|
||||
const [externalServicesMessage, setExternalServicesMessage] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -8781,6 +8827,7 @@ export function App() {
|
|||
|
||||
setExternalServices(result.externalServices);
|
||||
setExternalServiceDrafts(buildExternalServiceDrafts(result.externalServices));
|
||||
void refreshSeoAnalyticsModelProbe(result.externalServices);
|
||||
} catch (error: unknown) {
|
||||
setExternalServicesError(getErrorMessage(error, "Не удалось загрузить внешние сервисы."));
|
||||
} finally {
|
||||
|
|
@ -8788,9 +8835,31 @@ export function App() {
|
|||
}
|
||||
}
|
||||
|
||||
async function refreshSeoAnalyticsModelProbe(settings: ExternalServiceSettings) {
|
||||
const service = settings.services.find((item) => item.id === "seo_analytics_model");
|
||||
const executorId = service?.publicConfig.executorId?.trim();
|
||||
|
||||
if (!service?.enabled || service.mode !== "local_codex" || !executorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setProbingExternalServiceId("seo_analytics_model");
|
||||
|
||||
try {
|
||||
const result = await probeSeoAnalyticsModel({ executorId });
|
||||
setSeoAnalyticsModelProbe(result.probe);
|
||||
} catch (error: unknown) {
|
||||
setExternalServicesError(getErrorMessage(error, "Не удалось проверить Codex bridge."));
|
||||
} finally {
|
||||
setProbingExternalServiceId((currentId) => (currentId === "seo_analytics_model" ? null : currentId));
|
||||
}
|
||||
}
|
||||
|
||||
function openExternalServicesModal() {
|
||||
setIsExternalServicesOpen(true);
|
||||
setYandexAiStudioProbe(null);
|
||||
setSeoAnalyticsModelProbe(null);
|
||||
setSeoAnalyticsModelSetupCommand(null);
|
||||
void loadExternalServices();
|
||||
}
|
||||
|
||||
|
|
@ -8884,6 +8953,84 @@ export function App() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleGenerateSeoAnalyticsModelCommand() {
|
||||
setGeneratingSeoAnalyticsModelCommand(true);
|
||||
setExternalServicesError(null);
|
||||
setExternalServicesMessage(null);
|
||||
|
||||
try {
|
||||
const currentExecutorId = externalServiceDrafts.seo_analytics_model?.publicConfig.executorId?.trim();
|
||||
const result = await createSeoAnalyticsModelSetupCommand({
|
||||
executorId: currentExecutorId || undefined
|
||||
});
|
||||
|
||||
setSeoAnalyticsModelSetupCommand(result.setupCommand);
|
||||
setExternalServiceDrafts((currentDrafts) => {
|
||||
const currentDraft = currentDrafts.seo_analytics_model;
|
||||
|
||||
if (!currentDraft) {
|
||||
return currentDrafts;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentDrafts,
|
||||
seo_analytics_model: {
|
||||
...currentDraft,
|
||||
publicConfig: {
|
||||
...currentDraft.publicConfig,
|
||||
executorId: result.setupCommand.executor.id
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
setExternalServicesMessage("Codex bridge: setup command подготовлена.");
|
||||
} catch (error: unknown) {
|
||||
setExternalServicesError(getErrorMessage(error, "Не удалось подготовить Codex bridge setup command."));
|
||||
} finally {
|
||||
setGeneratingSeoAnalyticsModelCommand(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProbeSeoAnalyticsModel() {
|
||||
setProbingExternalServiceId("seo_analytics_model");
|
||||
setExternalServicesError(null);
|
||||
setExternalServicesMessage(null);
|
||||
|
||||
try {
|
||||
const executorId =
|
||||
externalServiceDrafts.seo_analytics_model?.publicConfig.executorId?.trim() ||
|
||||
seoAnalyticsModelSetupCommand?.executor.id ||
|
||||
undefined;
|
||||
const result = await probeSeoAnalyticsModel({ executorId });
|
||||
|
||||
setSeoAnalyticsModelProbe(result.probe);
|
||||
setExternalServicesMessage(
|
||||
result.probe.connection.ok
|
||||
? "Codex bridge: модель подключена."
|
||||
: `Codex bridge: ${result.probe.connection.detail}`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
setExternalServicesError(getErrorMessage(error, "Не удалось проверить Codex bridge."));
|
||||
} finally {
|
||||
setProbingExternalServiceId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopySeoAnalyticsModelCommand() {
|
||||
const command = seoAnalyticsModelSetupCommand?.install.command;
|
||||
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setExternalServicesMessage("Codex bridge: команда скопирована.");
|
||||
} catch (error: unknown) {
|
||||
setExternalServicesError(getErrorMessage(error, "Не удалось скопировать команду."));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
setProjectsLoading(true);
|
||||
setProjectsError(null);
|
||||
|
|
@ -10162,6 +10309,123 @@ export function App() {
|
|||
return marketResult.marketEnrichment.anchorReview;
|
||||
}
|
||||
|
||||
async function waitForSeoModelTaskCompletion(projectId: string, runId: string) {
|
||||
const startedAt = Date.now();
|
||||
let latestRun: SeoModelTaskRun | null = null;
|
||||
|
||||
while (Date.now() - startedAt < 10 * 60 * 1000) {
|
||||
const statusResult = await fetchModelProviderStatus(projectId);
|
||||
latestRun = statusResult.modelProvider.latestRuns.find((run) => run.runId === runId) ?? latestRun;
|
||||
|
||||
setModelProviderStatuses((currentStatuses) => ({
|
||||
...currentStatuses,
|
||||
[projectId]: statusResult.modelProvider
|
||||
}));
|
||||
|
||||
if (latestRun && latestRun.runStatus !== "queued" && latestRun.runStatus !== "running") {
|
||||
return latestRun;
|
||||
}
|
||||
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
throw new Error("Model task timeout: внешний Codex не вернул финальный результат за 10 минут.");
|
||||
}
|
||||
|
||||
async function runAndWaitSeoModelTask(project: ProjectSummary, taskType: SeoModelTaskType) {
|
||||
const taskKey = `${project.id}:${taskType}`;
|
||||
setRunningModelTaskKey(taskKey);
|
||||
|
||||
try {
|
||||
const result = await runSeoModelTask(project.id, { taskType });
|
||||
const completedRun =
|
||||
result.modelTaskRun.runStatus === "queued" || result.modelTaskRun.runStatus === "running"
|
||||
? await waitForSeoModelTaskCompletion(project.id, result.modelTaskRun.runId)
|
||||
: result.modelTaskRun;
|
||||
|
||||
if (completedRun.runStatus === "failed") {
|
||||
throw new Error(completedRun.errorMessage || `${getModelTaskTypeLabel(taskType)} завершилась ошибкой.`);
|
||||
}
|
||||
|
||||
return completedRun;
|
||||
} finally {
|
||||
setRunningModelTaskKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAfterSeoModelTask(project: ProjectSummary, taskType: SeoModelTaskType) {
|
||||
if (taskType === "seo.keyword_cleaning") {
|
||||
const [marketResult, keywordCleaningResult, keywordMapResult, strategyResult] = await Promise.all([
|
||||
fetchLatestMarketEnrichment(project.id),
|
||||
fetchLatestKeywordCleaning(project.id),
|
||||
fetchLatestKeywordMap(project.id),
|
||||
fetchLatestSeoStrategy(project.id)
|
||||
]);
|
||||
setMarketEnrichments((currentEnrichments) => ({
|
||||
...currentEnrichments,
|
||||
[project.id]: marketResult.marketEnrichment
|
||||
}));
|
||||
setKeywordCleanings((currentKeywordCleanings) => ({
|
||||
...currentKeywordCleanings,
|
||||
[project.id]: keywordCleaningResult.keywordCleaning
|
||||
}));
|
||||
setKeywordMaps((currentKeywordMaps) => ({
|
||||
...currentKeywordMaps,
|
||||
[project.id]: keywordMapResult.keywordMap
|
||||
}));
|
||||
setSeoStrategies((currentStrategies) => ({
|
||||
...currentStrategies,
|
||||
[project.id]: strategyResult.strategy
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskType === "seo.strategy_synthesis") {
|
||||
const [strategyResult, synthesisResult] = await Promise.all([
|
||||
fetchLatestSeoStrategy(project.id),
|
||||
fetchLatestStrategySynthesis(project.id)
|
||||
]);
|
||||
setSeoStrategies((currentStrategies) => ({
|
||||
...currentStrategies,
|
||||
[project.id]: strategyResult.strategy
|
||||
}));
|
||||
setStrategySyntheses((currentSyntheses) => ({
|
||||
...currentSyntheses,
|
||||
[project.id]: synthesisResult.strategySynthesis
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskType === "seo.strategy_quality_review") {
|
||||
const qualityResult = await fetchLatestStrategyQualityReview(project.id);
|
||||
setStrategyQualityReviews((currentReviews) => ({
|
||||
...currentReviews,
|
||||
[project.id]: qualityResult.strategyQualityReview
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskType === "seo.normalization") {
|
||||
const [marketResult, keywordCleaningResult, keywordMapResult] = await Promise.all([
|
||||
fetchLatestMarketEnrichment(project.id),
|
||||
fetchLatestKeywordCleaning(project.id),
|
||||
fetchLatestKeywordMap(project.id)
|
||||
]);
|
||||
setMarketEnrichments((currentEnrichments) => ({
|
||||
...currentEnrichments,
|
||||
[project.id]: marketResult.marketEnrichment
|
||||
}));
|
||||
setKeywordCleanings((currentKeywordCleanings) => ({
|
||||
...currentKeywordCleanings,
|
||||
[project.id]: keywordCleaningResult.keywordCleaning
|
||||
}));
|
||||
setKeywordMaps((currentKeywordMaps) => ({
|
||||
...currentKeywordMaps,
|
||||
[project.id]: keywordMapResult.keywordMap
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAnchorReviewApproval(project: ProjectSummary) {
|
||||
setApprovingAnchorReviewProjectId(project.id);
|
||||
setAnchorReviewActionKey(`${project.id}:bulk`);
|
||||
|
|
@ -10171,13 +10435,18 @@ export function App() {
|
|||
|
||||
try {
|
||||
const result = await saveAnchorReviewDecisions(project.id);
|
||||
let keywordCleaningRun: SeoModelTaskRun | null = null;
|
||||
|
||||
if (result.anchorReview.readiness.wordstatQueueCount > 0) {
|
||||
setEnrichingMarketProjectId(project.id);
|
||||
await runMarketEnrichment(project.id);
|
||||
keywordCleaningRun = await runAndWaitSeoModelTask(project, "seo.keyword_cleaning");
|
||||
}
|
||||
await refreshAfterAnchorReviewDecision(project);
|
||||
setProjectActionMessage(
|
||||
`Якоря сохранены: в Wordstat ${result.anchorReview.readiness.approvedAnchorCount}, очередь ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}. Wordstat обновлён по доступной очереди.`
|
||||
`Якоря сохранены: в Wordstat ${result.anchorReview.readiness.approvedAnchorCount}, очередь ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}. Wordstat обновлён${
|
||||
keywordCleaningRun ? `, очистка ключей: ${getModelTaskExecutionStatusLabel(keywordCleaningRun.runStatus)}.` : "."
|
||||
}`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
setProjectActionError(getErrorMessage(error, "Не удалось сохранить anchor review."));
|
||||
|
|
@ -10217,20 +10486,21 @@ export function App() {
|
|||
setProjectActionProjectId(project.id);
|
||||
|
||||
try {
|
||||
const result = await runSeoModelTask(project.id, {
|
||||
providerId: "codex_manual",
|
||||
taskType
|
||||
});
|
||||
const statusResult = await fetchModelProviderStatus(project.id);
|
||||
const result = await runSeoModelTask(project.id, { taskType });
|
||||
const completedRun =
|
||||
result.modelTaskRun.runStatus === "queued" || result.modelTaskRun.runStatus === "running"
|
||||
? await waitForSeoModelTaskCompletion(project.id, result.modelTaskRun.runId)
|
||||
: result.modelTaskRun;
|
||||
|
||||
setModelProviderStatuses((currentStatuses) => ({
|
||||
...currentStatuses,
|
||||
[project.id]: statusResult.modelProvider
|
||||
}));
|
||||
if (completedRun.runStatus === "failed") {
|
||||
throw new Error(completedRun.errorMessage || `${getModelTaskTypeLabel(taskType)} завершилась ошибкой.`);
|
||||
}
|
||||
|
||||
await refreshAfterSeoModelTask(project, taskType);
|
||||
setProjectActionMessage(
|
||||
`${getModelTaskTypeLabel(taskType)}: ${getModelTaskRunStatusLabel(result.modelTaskRun.status)} · примерно ${
|
||||
result.modelTaskRun.estimate.inputTokenEstimate
|
||||
} входных токенов.`
|
||||
`${getModelTaskTypeLabel(taskType)}: ${getModelTaskExecutionStatusLabel(completedRun.runStatus)} · provider ${
|
||||
completedRun.provider.title
|
||||
} · примерно ${completedRun.estimate.inputTokenEstimate} входных токенов.`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
setProjectActionError(getErrorMessage(error, "Не удалось подготовить задачу модели."));
|
||||
|
|
@ -10239,6 +10509,73 @@ export function App() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleKeywordCleaningManualReview(project: ProjectSummary) {
|
||||
setReviewingKeywordCleaningProjectId(project.id);
|
||||
setProjectActionError(null);
|
||||
setProjectActionMessage(null);
|
||||
setProjectActionProjectId(project.id);
|
||||
|
||||
try {
|
||||
const currentCleaning = keywordCleanings[project.id] ?? (await fetchLatestKeywordCleaning(project.id)).keywordCleaning;
|
||||
|
||||
if (!currentCleaning.modelTask) {
|
||||
throw new Error("Нет keyword cleaning model task: сначала нужен semantic analysis и market evidence.");
|
||||
}
|
||||
|
||||
const modelTaskResult = await runSeoModelTask(project.id, {
|
||||
providerId: "codex_manual",
|
||||
taskType: "seo.keyword_cleaning"
|
||||
});
|
||||
const savedCleaning = await saveManualKeywordCleaning(project.id, currentCleaning);
|
||||
const [keywordMapResult, strategyResult, rewritePlanResult, materializationResult, rewriteDiffResult, statusResult] =
|
||||
await Promise.all([
|
||||
fetchLatestKeywordMap(project.id),
|
||||
fetchLatestSeoStrategy(project.id),
|
||||
fetchLatestRewritePlan(project.id),
|
||||
fetchLatestMaterialization(project.id),
|
||||
fetchLatestRewriteDiff(project.id),
|
||||
fetchModelProviderStatus(project.id)
|
||||
]);
|
||||
|
||||
setKeywordCleanings((currentKeywordCleanings) => ({
|
||||
...currentKeywordCleanings,
|
||||
[project.id]: savedCleaning.keywordCleaning
|
||||
}));
|
||||
setKeywordMaps((currentKeywordMaps) => ({
|
||||
...currentKeywordMaps,
|
||||
[project.id]: keywordMapResult.keywordMap
|
||||
}));
|
||||
setSeoStrategies((currentStrategies) => ({
|
||||
...currentStrategies,
|
||||
[project.id]: strategyResult.strategy
|
||||
}));
|
||||
setRewritePlans((currentRewritePlans) => ({
|
||||
...currentRewritePlans,
|
||||
[project.id]: rewritePlanResult.rewritePlan
|
||||
}));
|
||||
setMaterializationContracts((currentContracts) => ({
|
||||
...currentContracts,
|
||||
[project.id]: materializationResult.materialization
|
||||
}));
|
||||
setRewriteDiffContracts((currentContracts) => ({
|
||||
...currentContracts,
|
||||
[project.id]: rewriteDiffResult.rewriteDiff
|
||||
}));
|
||||
setModelProviderStatuses((currentStatuses) => ({
|
||||
...currentStatuses,
|
||||
[project.id]: statusResult.modelProvider
|
||||
}));
|
||||
invalidateStrategyQualityReview(project.id);
|
||||
setProjectActionMessage(
|
||||
`Keyword cleaning зафиксирован как Codex/manual evidence: ${savedCleaning.keywordCleaning.readiness.keywordMapCandidateCount} exact-кандидатов. Карта ключей: ${getKeywordMapStateLabel(keywordMapResult.keywordMap.state)} · ${getModelTaskRunStatusLabel(modelTaskResult.modelTaskRun.status)}.`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
setProjectActionError(getErrorMessage(error, "Не удалось зафиксировать keyword cleaning review."));
|
||||
} finally {
|
||||
setReviewingKeywordCleaningProjectId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProjectOntologyApproval(
|
||||
project: ProjectSummary,
|
||||
ontology: ProjectOntologyVersion,
|
||||
|
|
@ -10640,6 +10977,24 @@ export function App() {
|
|||
}));
|
||||
if (input?.jumpToStrategy) {
|
||||
setActiveStageIndex(5);
|
||||
const synthesisRun = await runAndWaitSeoModelTask(project, "seo.strategy_synthesis");
|
||||
const [nextStrategyResult, synthesisResult] = await Promise.all([
|
||||
fetchLatestSeoStrategy(project.id),
|
||||
fetchLatestStrategySynthesis(project.id)
|
||||
]);
|
||||
setSeoStrategies((currentStrategies) => ({
|
||||
...currentStrategies,
|
||||
[project.id]: nextStrategyResult.strategy
|
||||
}));
|
||||
setStrategySyntheses((currentSyntheses) => ({
|
||||
...currentSyntheses,
|
||||
[project.id]: synthesisResult.strategySynthesis
|
||||
}));
|
||||
setProjectActionMessage(
|
||||
`Распределение ключей сохранено: решений ${result.savedDecisionCount}, привязок ${
|
||||
result.savedMapItemCount
|
||||
}. Синтез стратегии: ${getModelTaskExecutionStatusLabel(synthesisRun.runStatus)}.`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
setProjectActionError(getErrorMessage(error, "Не удалось сохранить keyword decisions."));
|
||||
|
|
@ -13087,6 +13442,67 @@ export function App() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{service.id === "seo_analytics_model" && draft.enabled && draft.mode === "local_codex" ? (
|
||||
<div className="service-command-panel">
|
||||
<div className="service-command-head">
|
||||
<strong>Codex bridge</strong>
|
||||
<span
|
||||
className={
|
||||
seoAnalyticsModelProbe?.connection.ok || service.status === "configured" ? "ok" : "idle"
|
||||
}
|
||||
>
|
||||
{seoAnalyticsModelProbe?.connection.ok
|
||||
? "подключена"
|
||||
: probingExternalServiceId === service.id
|
||||
? "проверяю..."
|
||||
: service.status === "configured" && draft.publicConfig.executorId
|
||||
? "сохранена"
|
||||
: "ожидает проверки"}
|
||||
</span>
|
||||
</div>
|
||||
{seoAnalyticsModelSetupCommand ? (
|
||||
<>
|
||||
<pre>{seoAnalyticsModelSetupCommand.install.command}</pre>
|
||||
<small>
|
||||
executor {seoAnalyticsModelSetupCommand.executor.name}
|
||||
{seoAnalyticsModelSetupCommand.setupCode.expiresAt
|
||||
? ` · code expires ${new Date(
|
||||
seoAnalyticsModelSetupCommand.setupCode.expiresAt
|
||||
).toLocaleString("ru-RU")}`
|
||||
: ""}
|
||||
</small>
|
||||
</>
|
||||
) : (
|
||||
<small>NPM command появится после генерации setup-code.</small>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{service.id === "seo_analytics_model" && seoAnalyticsModelProbe ? (
|
||||
<div className="service-probe-result">
|
||||
<div>
|
||||
<strong>Статус модели</strong>
|
||||
<span>{seoAnalyticsModelProbe.profile}</span>
|
||||
</div>
|
||||
<ul>
|
||||
<li className={seoAnalyticsModelProbe.config.configured ? "ok" : "error"}>
|
||||
<span>AI Workspace config</span>
|
||||
<small>
|
||||
{seoAnalyticsModelProbe.config.configured
|
||||
? `ok · ${seoAnalyticsModelProbe.config.assistantUrl ?? "assistant URL"}`
|
||||
: seoAnalyticsModelProbe.config.errors.join(" ")}
|
||||
</small>
|
||||
</li>
|
||||
<li className={seoAnalyticsModelProbe.connection.ok ? "ok" : "error"}>
|
||||
<span>{seoAnalyticsModelProbe.executor.name ?? "Codex bridge executor"}</span>
|
||||
<small>
|
||||
{seoAnalyticsModelProbe.executor.status} · {seoAnalyticsModelProbe.connection.detail}
|
||||
</small>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{service.id === "yandex_cloud" && yandexAiStudioProbe ? (
|
||||
<div className="service-probe-result">
|
||||
<div>
|
||||
|
|
@ -13132,6 +13548,45 @@ export function App() {
|
|||
{probingExternalServiceId === service.id ? "Проверяю..." : "Проверить"}
|
||||
</button>
|
||||
) : null}
|
||||
{service.id === "seo_analytics_model" ? (
|
||||
<>
|
||||
<button
|
||||
className="tiny-action"
|
||||
disabled={isSaving || generatingSeoAnalyticsModelCommand || !draft.enabled || draft.mode !== "local_codex"}
|
||||
onClick={() => void handleGenerateSeoAnalyticsModelCommand()}
|
||||
type="button"
|
||||
>
|
||||
{generatingSeoAnalyticsModelCommand ? (
|
||||
<Loader2 className="spin" size={14} />
|
||||
) : (
|
||||
<Server size={14} />
|
||||
)}
|
||||
{generatingSeoAnalyticsModelCommand ? "Генерирую..." : "NPM command"}
|
||||
</button>
|
||||
<button
|
||||
className="tiny-action"
|
||||
disabled={isSaving || !seoAnalyticsModelSetupCommand?.install.command}
|
||||
onClick={() => void handleCopySeoAnalyticsModelCommand()}
|
||||
type="button"
|
||||
>
|
||||
<Copy size={14} />
|
||||
Скопировать
|
||||
</button>
|
||||
<button
|
||||
className="tiny-action"
|
||||
disabled={isSaving || probingExternalServiceId === service.id || !draft.enabled || draft.mode !== "local_codex"}
|
||||
onClick={() => void handleProbeSeoAnalyticsModel()}
|
||||
type="button"
|
||||
>
|
||||
{probingExternalServiceId === service.id ? (
|
||||
<Loader2 className="spin" size={14} />
|
||||
) : (
|
||||
<RefreshCw size={14} />
|
||||
)}
|
||||
{probingExternalServiceId === service.id ? "Проверяю..." : "Проверить"}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
<button
|
||||
className="tiny-action primary"
|
||||
disabled={isSaving}
|
||||
|
|
@ -13687,6 +14142,7 @@ export function App() {
|
|||
);
|
||||
const activeAnchorReviewFilter = anchorReviewFilters[project.id] ?? "proposed";
|
||||
const keywordCurationOverrides = keywordCurationRoleOverrides[project.id] ?? {};
|
||||
const keywordCleaningStage5Candidates = keywordCleaning ? getKeywordCleaningStage5Candidates(keywordCleaning) : [];
|
||||
const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null;
|
||||
const keywordPagePlans = keywordMap ? getKeywordPlanPageSummaries(keywordMap) : [];
|
||||
const keywordReviewItems = keywordMap ? getKeywordPlanReviewItems(keywordMap) : [];
|
||||
|
|
@ -13706,10 +14162,11 @@ export function App() {
|
|||
const isEnrichingMarket = enrichingMarketProjectId === project.id;
|
||||
const showKeywordAnalysisPending = Boolean(anchorReviewReady && (isEnrichingMarket || !keywordWordstatFresh));
|
||||
const showKeywordDemandAnalysis = Boolean(anchorReviewReady && keywordWordstatFresh && !isEnrichingMarket);
|
||||
const isInterpretingSerp = interpretingSerpProjectId === project.id;
|
||||
const isSynthesizingStrategy = synthesizingStrategyProjectId === project.id;
|
||||
const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id;
|
||||
const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id;
|
||||
const isInterpretingSerp = interpretingSerpProjectId === project.id;
|
||||
const isSynthesizingStrategy = synthesizingStrategyProjectId === project.id;
|
||||
const isReviewingStrategyQuality = reviewingStrategyQualityProjectId === project.id;
|
||||
const isReviewingKeywordCleaning = reviewingKeywordCleaningProjectId === project.id;
|
||||
const isPersistingKeywordMap = persistingKeywordMapProjectId === project.id;
|
||||
const isBuildingPatch = buildingPatchProjectId === project.id;
|
||||
const isRunningPatchDryRun = patchArtifact
|
||||
? runningPatchDryRunKey === `${project.id}:${patchArtifact.changesetId}`
|
||||
|
|
@ -16858,48 +17315,72 @@ export function App() {
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
{keywordCleaning && !wordstatOutdated ? (
|
||||
<>
|
||||
<div className="market-normalization-card keyword-cleaning-card">
|
||||
<div>
|
||||
{keywordCleaning && !wordstatOutdated ? (
|
||||
<>
|
||||
<div className="market-normalization-card keyword-cleaning-card">
|
||||
<div>
|
||||
<span>Очистка фраз</span>
|
||||
<strong>{keywordCleaning.summary}</strong>
|
||||
<small>
|
||||
<strong>{keywordCleaning.summary}</strong>
|
||||
<small>
|
||||
После Wordstat и разбора выдачи: что можно пустить в SEO-план, что оставить в запасе, что
|
||||
проверить и что выкинуть.
|
||||
</small>
|
||||
<div className="market-normalization-stats">
|
||||
проверить и что выкинуть.
|
||||
</small>
|
||||
<div className="market-normalization-stats">
|
||||
<span>{keywordCleaning.provider.mode === "codex_manual" ? "модель" : "резерв"}</span>
|
||||
<span>{keywordCleaning.analystReview.status}</span>
|
||||
<span>Главные {keywordCleaning.readiness.useCount}</span>
|
||||
<span>Поддержка {keywordCleaning.readiness.supportCount}</span>
|
||||
<span>{keywordCleaning.analystReview.status}</span>
|
||||
<span>Stage 5 {keywordCleaning.readiness.keywordMapCandidateCount}</span>
|
||||
<span>Exact {keywordCleaning.readiness.exactEvidenceCount}</span>
|
||||
<span>Контент {keywordCleaning.readiness.articleCount}</span>
|
||||
<span>Риск {keywordCleaning.readiness.riskyCount}</span>
|
||||
<span>Мусор {keywordCleaning.readiness.trashCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="tiny-action"
|
||||
disabled={!keywordCleaning.modelTask || isRunningKeywordCleaningModelTask}
|
||||
onClick={() => void handleSeoModelTaskRun(project, "seo.keyword_cleaning")}
|
||||
type="button"
|
||||
>
|
||||
{isRunningKeywordCleaningModelTask ? (
|
||||
<Loader2 className="spin" size={14} />
|
||||
) : (
|
||||
<KeyRound size={14} />
|
||||
)}
|
||||
{isRunningKeywordCleaningModelTask ? "Готовлю..." : "Маршрут модели"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="model-provider-actions">
|
||||
<button
|
||||
className="tiny-action"
|
||||
disabled={!keywordCleaning.modelTask || isRunningKeywordCleaningModelTask || isReviewingKeywordCleaning}
|
||||
onClick={() => void handleSeoModelTaskRun(project, "seo.keyword_cleaning")}
|
||||
type="button"
|
||||
>
|
||||
{isRunningKeywordCleaningModelTask ? (
|
||||
<Loader2 className="spin" size={14} />
|
||||
) : (
|
||||
<KeyRound size={14} />
|
||||
)}
|
||||
{isRunningKeywordCleaningModelTask ? "Готовлю..." : "Маршрут модели"}
|
||||
</button>
|
||||
<button
|
||||
className="tiny-action primary"
|
||||
disabled={
|
||||
!keywordCleaning.modelTask ||
|
||||
isRunningKeywordCleaningModelTask ||
|
||||
isReviewingKeywordCleaning ||
|
||||
keywordCleaning.provider.mode === "codex_manual"
|
||||
}
|
||||
onClick={() => void handleKeywordCleaningManualReview(project)}
|
||||
type="button"
|
||||
>
|
||||
{isReviewingKeywordCleaning ? (
|
||||
<Loader2 className="spin" size={14} />
|
||||
) : (
|
||||
<CheckCircle2 size={14} />
|
||||
)}
|
||||
{isReviewingKeywordCleaning
|
||||
? "Фиксирую..."
|
||||
: keywordCleaning.provider.mode === "codex_manual"
|
||||
? "Проверка сохранена"
|
||||
: "Зафиксировать проверку"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="market-normalization-review keyword-cleaning-review">
|
||||
<SeoKeywordCleaningLane
|
||||
emptyLabel="Нет фраз для плана правок"
|
||||
items={[...keywordCleaning.lanes.use, ...keywordCleaning.lanes.support]}
|
||||
subtitle="только это попадёт в карту фраз"
|
||||
title="В SEO-план"
|
||||
/>
|
||||
<div className="market-normalization-review keyword-cleaning-review">
|
||||
<SeoKeywordCleaningLane
|
||||
emptyLabel="Нет exact-кандидатов для Stage 5"
|
||||
items={keywordCleaningStage5Candidates}
|
||||
subtitle="пойдут в доску распределения"
|
||||
title="В Stage 5"
|
||||
/>
|
||||
<SeoKeywordCleaningLane
|
||||
emptyLabel="Очередь пустая"
|
||||
items={keywordCleaning.lanes.article}
|
||||
|
|
|
|||
|
|
@ -1100,6 +1100,47 @@ export type SeoModelProviderCatalog = {
|
|||
providers: SeoModelProviderDescriptor[];
|
||||
};
|
||||
|
||||
export type SeoModelTaskAiWorkspaceDispatch = {
|
||||
schemaVersion: "seo-ai-workspace-dispatch.v1";
|
||||
profile: "deploy-host" | "deployed-hub" | "disabled" | "local" | "tunnel-local-e2e";
|
||||
assistantThreadId: string;
|
||||
assistantMessageId: string;
|
||||
bridgeRequestId: string;
|
||||
bridgeMode: string;
|
||||
accepted: boolean;
|
||||
dispatchedAt: string;
|
||||
selectedExecutorId: string | null;
|
||||
contractWorkspacePath: string;
|
||||
};
|
||||
|
||||
export type SeoModelTaskModelOutput = {
|
||||
schemaVersion: "seo-model-output.v1";
|
||||
source: "ai_workspace";
|
||||
status: "dispatch_accepted" | "schema_invalid" | "schema_valid";
|
||||
aiWorkspace: SeoModelTaskAiWorkspaceDispatch;
|
||||
rawText: string | null;
|
||||
parsedJson: Record<string, unknown> | null;
|
||||
validation: {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type SeoModelTaskPersistedResult = {
|
||||
schemaVersion: "seo-model-persisted-result.v1";
|
||||
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
||||
runType:
|
||||
| "keyword_cleaning"
|
||||
| "seo_model_task"
|
||||
| "seo_normalization"
|
||||
| "seo_strategy_quality_review"
|
||||
| "seo_strategy_synthesis";
|
||||
runId: string;
|
||||
sourceModelTaskRunId?: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type SeoModelTaskRun = {
|
||||
schemaVersion: "seo-model-task-run.v1";
|
||||
runId: string;
|
||||
|
|
@ -1137,8 +1178,8 @@ export type SeoModelTaskRun = {
|
|||
inputTokenEstimate: number;
|
||||
outputTokenBudget: number;
|
||||
};
|
||||
modelOutput: null;
|
||||
persistedResult: null;
|
||||
modelOutput: SeoModelTaskModelOutput | null;
|
||||
persistedResult: SeoModelTaskPersistedResult | null;
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
|
|
@ -2034,14 +2075,27 @@ export type RewriteDiffContract = {
|
|||
export type ExternalServiceSettings = {
|
||||
schemaVersion: "external-service-settings.v1";
|
||||
services: Array<{
|
||||
id: "yandex_cloud" | "yandex_metrica" | "yandex_search_serp" | "yandex_webmaster" | "yandex_wordstat";
|
||||
id:
|
||||
| "seo_analytics_model"
|
||||
| "yandex_cloud"
|
||||
| "yandex_metrica"
|
||||
| "yandex_search_serp"
|
||||
| "yandex_webmaster"
|
||||
| "yandex_wordstat";
|
||||
label: string;
|
||||
description: string;
|
||||
implementationStatus: "active" | "planned";
|
||||
status: "configured" | "disabled" | "missing_required" | "not_implemented";
|
||||
enabled: boolean;
|
||||
mode: string;
|
||||
dependsOn: Array<"yandex_cloud" | "yandex_metrica" | "yandex_search_serp" | "yandex_webmaster" | "yandex_wordstat">;
|
||||
dependsOn: Array<
|
||||
| "seo_analytics_model"
|
||||
| "yandex_cloud"
|
||||
| "yandex_metrica"
|
||||
| "yandex_search_serp"
|
||||
| "yandex_webmaster"
|
||||
| "yandex_wordstat"
|
||||
>;
|
||||
modes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
@ -2112,6 +2166,56 @@ export type YandexAiStudioProbe = {
|
|||
}>;
|
||||
};
|
||||
|
||||
export type SeoAnalyticsModelSetupCommand = {
|
||||
schemaVersion: "seo-analytics-model-setup-command.v1";
|
||||
generatedAt: string;
|
||||
profile: string;
|
||||
executor: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "checking" | "error" | "offline" | "online" | "unknown";
|
||||
statusDetail: string;
|
||||
};
|
||||
install: {
|
||||
command: string;
|
||||
packageName: string;
|
||||
packageSpec: string;
|
||||
gatewayUrl: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
setupCode: {
|
||||
suffix: string | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type SeoAnalyticsModelProbe = {
|
||||
schemaVersion: "seo-analytics-model-probe.v1";
|
||||
checkedAt: string;
|
||||
profile: string;
|
||||
config: {
|
||||
configured: boolean;
|
||||
assistantUrl: string | null;
|
||||
selectedExecutorId: string | null;
|
||||
contractWorkspacePath: string | null;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
executor: {
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
status: "checking" | "error" | "not_configured" | "offline" | "online" | "unknown";
|
||||
statusDetail: string;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
connection: {
|
||||
ok: boolean;
|
||||
status: "connected" | "error" | "missing_config" | "not_configured" | "offline";
|
||||
detail: string;
|
||||
};
|
||||
check: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type PageWorkspace = {
|
||||
projectId: string;
|
||||
pageId: string;
|
||||
|
|
@ -3129,6 +3233,38 @@ export async function probeYandexAiStudioCredential(phrase?: string) {
|
|||
return response.json() as Promise<{ probe: YandexAiStudioProbe }>;
|
||||
}
|
||||
|
||||
export async function createSeoAnalyticsModelSetupCommand(input: { executorId?: string; port?: number } = {}) {
|
||||
const response = await fetch(`${apiBaseUrl}/settings/external-services/seo_analytics_model/setup-command`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось подготовить Codex bridge setup command: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ setupCommand: SeoAnalyticsModelSetupCommand }>;
|
||||
}
|
||||
|
||||
export async function probeSeoAnalyticsModel(input: { executorId?: string } = {}) {
|
||||
const response = await fetch(`${apiBaseUrl}/settings/external-services/seo_analytics_model/probe`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, `Не удалось проверить Codex bridge: ${response.status}`));
|
||||
}
|
||||
|
||||
return response.json() as Promise<{ probe: SeoAnalyticsModelProbe }>;
|
||||
}
|
||||
|
||||
export async function updateProjectPageSelection(projectId: string, pageIds: string[], selected: boolean) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/selection`, {
|
||||
method: "PATCH",
|
||||
|
|
|
|||
|
|
@ -522,6 +522,67 @@ input {
|
|||
font-weight: 780;
|
||||
}
|
||||
|
||||
.service-command-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(30, 30, 30, 0.04);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.service-command-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.service-command-head strong {
|
||||
color: var(--ink);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.service-command-head span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 8px;
|
||||
color: #70521f;
|
||||
background: rgba(112, 82, 31, 0.07);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.service-command-head span.ok {
|
||||
color: #2f6b4f;
|
||||
background: rgba(47, 107, 79, 0.08);
|
||||
}
|
||||
|
||||
.service-command-panel pre {
|
||||
max-height: 118px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 9px;
|
||||
color: #f5f5f5;
|
||||
background: #1f2428;
|
||||
border-radius: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.service-command-panel small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 680;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.service-card-footer {
|
||||
align-items: center;
|
||||
padding-top: 2px;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"check:semantic-block-runtime": "npm run check:semantic-block-runtime -w server",
|
||||
"check:semantic-blocks": "npm run check:semantic-blocks -w server",
|
||||
"check:source-models": "npm run check:source-models -w server",
|
||||
"check:ontology-universality": "npm run check:ontology-universality -w server",
|
||||
"typecheck": "npm run typecheck -w server && npm run typecheck -w app",
|
||||
"db:migrate": "npm run db:migrate -w server"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,9 +25,25 @@ const envSchema = z.object({
|
|||
WORDSTAT_REGION_IDS: z.string().default("225"),
|
||||
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
|
||||
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
|
||||
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(40),
|
||||
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
|
||||
SERP_API_BASE_URL: z.string().url().optional(),
|
||||
SERP_API_TOKEN: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_PROFILE: z
|
||||
.enum(["disabled", "local", "deployed-hub", "deploy-host", "tunnel-local-e2e"])
|
||||
.default("disabled"),
|
||||
SEO_AI_WORKSPACE_CONTROL_URL: z.string().url().optional(),
|
||||
SEO_AI_WORKSPACE_CONTROL_TOKEN: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_ASSISTANT_URL: z.string().url().optional(),
|
||||
SEO_AI_WORKSPACE_ASSISTANT_TOKEN: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_OWNER_USER_ID: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_OWNER_EMAIL: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_OWNER_ROLE: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_OWNER_GROUPS: z.string().default(""),
|
||||
SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_CONTRACT_ONLY: booleanFromString.default(false),
|
||||
SEO_AI_WORKSPACE_TIMEOUT_MS: z.coerce.number().int().positive().max(60_000).default(15_000),
|
||||
PREVIEW_CHROME_PATH: z.string().optional(),
|
||||
PREVIEW_SNAPSHOT_CONCURRENCY: z.coerce.number().int().positive().max(4).default(2),
|
||||
PREVIEW_SNAPSHOT_WAIT_MS: z.coerce.number().int().nonnegative().max(10_000).default(1400)
|
||||
|
|
@ -57,6 +73,7 @@ export const env = {
|
|||
regionIds: parsedEnv.WORDSTAT_REGION_IDS.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
devices: parsedEnv.WORDSTAT_DEVICES.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
numPhrases: parsedEnv.WORDSTAT_NUM_PHRASES,
|
||||
maxSeedsPerRun: parsedEnv.WORDSTAT_MAX_SEEDS_PER_RUN,
|
||||
hasApiToken: Boolean(parsedEnv.WORDSTAT_API_TOKEN)
|
||||
},
|
||||
serp: {
|
||||
|
|
@ -65,6 +82,22 @@ export const env = {
|
|||
hasApiToken: Boolean(parsedEnv.SERP_API_TOKEN)
|
||||
}
|
||||
},
|
||||
aiWorkspace: {
|
||||
profile: parsedEnv.SEO_AI_WORKSPACE_PROFILE,
|
||||
assistantUrl: parsedEnv.SEO_AI_WORKSPACE_CONTROL_URL ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_URL,
|
||||
assistantToken: parsedEnv.SEO_AI_WORKSPACE_CONTROL_TOKEN ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_TOKEN,
|
||||
contractOnly: parsedEnv.SEO_AI_WORKSPACE_CONTRACT_ONLY,
|
||||
contractWorkspacePath: parsedEnv.SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH,
|
||||
selectedExecutorId: parsedEnv.SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID,
|
||||
requestTimeoutMs: parsedEnv.SEO_AI_WORKSPACE_TIMEOUT_MS,
|
||||
owner: {
|
||||
userId: parsedEnv.SEO_AI_WORKSPACE_OWNER_USER_ID,
|
||||
email: parsedEnv.SEO_AI_WORKSPACE_OWNER_EMAIL,
|
||||
role: parsedEnv.SEO_AI_WORKSPACE_OWNER_ROLE,
|
||||
groups: parsedEnv.SEO_AI_WORKSPACE_OWNER_GROUPS.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
},
|
||||
hasAssistantToken: Boolean(parsedEnv.SEO_AI_WORKSPACE_CONTROL_TOKEN ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_TOKEN)
|
||||
},
|
||||
preview: {
|
||||
chromePath: parsedEnv.PREVIEW_CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
snapshotConcurrency: parsedEnv.PREVIEW_SNAPSHOT_CONCURRENCY,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { pool } from "../db/client.js";
|
||||
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||
|
||||
type KeywordCleaningProviderMode = "codex_manual" | "deterministic_fallback";
|
||||
type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
|
||||
type KeywordCleaningSource = "manual" | "normalization_seed" | "serp" | "wordstat_related" | "wordstat_seed";
|
||||
|
||||
const KEYWORD_CLEANING_DECISIONS: KeywordCleaningDecision[] = ["use", "support", "article", "risky", "trash"];
|
||||
const KEYWORD_CLEANING_DECISION_SET = new Set<string>(KEYWORD_CLEANING_DECISIONS);
|
||||
|
||||
export type KeywordCleaningModelTaskContract = {
|
||||
taskType: "seo.keyword_cleaning";
|
||||
schemaVersion: "seo-keyword-cleaning-task.v1";
|
||||
|
|
@ -26,10 +29,64 @@ export type KeywordCleaningModelTaskContract = {
|
|||
wordstatResultCount: number;
|
||||
wordstatJobStatus: string | null;
|
||||
};
|
||||
input: {
|
||||
approvedAnchors: Array<{
|
||||
phrase: string;
|
||||
clusterId: string;
|
||||
clusterTitle: string;
|
||||
intentType: string;
|
||||
marketRole: string;
|
||||
priority: string;
|
||||
confidence: number;
|
||||
normalizedFrom: string[];
|
||||
}>;
|
||||
landingBriefs: Array<{
|
||||
path: string;
|
||||
action: string;
|
||||
priority: string;
|
||||
qualityScore: number | null;
|
||||
seedPhrases: string[];
|
||||
evidenceStatus: string;
|
||||
}>;
|
||||
normalizationIntentGroups: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
intentType: string;
|
||||
priority: string;
|
||||
marketHypothesis: string;
|
||||
wordstatQueries: string[];
|
||||
excludedPhrases: string[];
|
||||
}>;
|
||||
seedQueue: Array<{
|
||||
phrase: string;
|
||||
clusterId: string;
|
||||
clusterTitle: string;
|
||||
priority: string;
|
||||
source: string;
|
||||
intentType: string | null;
|
||||
marketRole: string | null;
|
||||
evidenceStatus: string;
|
||||
frequency: number | null;
|
||||
frequencyGroup: string | null;
|
||||
relatedCount: number;
|
||||
targetPath: string | null;
|
||||
projectOntologyVersionId: string | null;
|
||||
wordstatResultId: string | null;
|
||||
}>;
|
||||
wordstatTopResults: Array<{
|
||||
id: string;
|
||||
phrase: string;
|
||||
sourcePhrase: string | null;
|
||||
frequency: number | null;
|
||||
frequencyGroup: string | null;
|
||||
region: string | null;
|
||||
}>;
|
||||
};
|
||||
outputSchema: {
|
||||
schemaVersion: "keyword-cleaning.v1";
|
||||
requiredTopLevelKeys: string[];
|
||||
itemRequiredKeys: string[];
|
||||
decisionValues: KeywordCleaningDecision[];
|
||||
};
|
||||
decisionPolicy: {
|
||||
useRequiresExactEvidence: boolean;
|
||||
|
|
@ -174,6 +231,36 @@ function unique(values: string[]) {
|
|||
return result;
|
||||
}
|
||||
|
||||
function normalizeKeywordCleaningDecision(value: unknown): KeywordCleaningDecision {
|
||||
const decision = String(value || "").trim();
|
||||
|
||||
if (KEYWORD_CLEANING_DECISION_SET.has(decision)) {
|
||||
return decision as KeywordCleaningDecision;
|
||||
}
|
||||
|
||||
if (decision === "route_keyword_map") {
|
||||
return "use";
|
||||
}
|
||||
|
||||
if (decision === "mark_risky_for_human" || decision === "preserve_unresolved") {
|
||||
return "risky";
|
||||
}
|
||||
|
||||
if (decision === "keep_article_backlog") {
|
||||
return "article";
|
||||
}
|
||||
|
||||
if (decision === "reject_noise") {
|
||||
return "trash";
|
||||
}
|
||||
|
||||
if (decision === "preserve_evidence_refs") {
|
||||
return "support";
|
||||
}
|
||||
|
||||
return "risky";
|
||||
}
|
||||
|
||||
function getDefaultAnalystReview(): KeywordCleaningContract["analystReview"] {
|
||||
return {
|
||||
notes: [],
|
||||
|
|
@ -392,6 +479,115 @@ function getBaseItems(contract: MarketEnrichmentContract) {
|
|||
return [...seedItems, ...relatedItems];
|
||||
}
|
||||
|
||||
function mergeModelProviderItemsWithMarketEvidence(
|
||||
items: KeywordCleaningItem[],
|
||||
market: MarketEnrichmentContract
|
||||
): KeywordCleaningItem[] {
|
||||
const baseItems = getBaseItems(market);
|
||||
const baseById = new Map(baseItems.map((item) => [item.id, item]));
|
||||
const baseByPhrase = new Map(baseItems.map((item) => [item.normalizedPhrase, item]));
|
||||
const baseByWordstatResultId = new Map(
|
||||
baseItems
|
||||
.filter((item) => item.wordstatResultId)
|
||||
.map((item) => [item.wordstatResultId as string, item])
|
||||
);
|
||||
|
||||
return items.map((item) => {
|
||||
const normalizedPhrase = normalizePhrase(item.normalizedPhrase || item.phrase);
|
||||
const base =
|
||||
(item.wordstatResultId ? baseByWordstatResultId.get(item.wordstatResultId) : null) ??
|
||||
(item.id ? baseById.get(item.id) : null) ??
|
||||
baseByPhrase.get(normalizedPhrase) ??
|
||||
null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
clusterId: base?.clusterId ?? item.clusterId ?? "model-provider",
|
||||
clusterTitle: base?.clusterTitle ?? item.clusterTitle ?? item.phrase,
|
||||
evidenceRefs: unique([
|
||||
...(base?.evidenceRefs ?? []),
|
||||
...(Array.isArray(item.evidenceRefs) ? item.evidenceRefs : [])
|
||||
]),
|
||||
evidenceStatus: base?.evidenceStatus ?? item.evidenceStatus ?? "not_collected",
|
||||
frequency: base?.frequency ?? item.frequency ?? null,
|
||||
frequencyGroup: base?.frequencyGroup ?? item.frequencyGroup ?? null,
|
||||
id: base?.id ?? item.id ?? `model:${slugify(item.phrase)}`,
|
||||
intentType: base?.intentType ?? item.intentType ?? null,
|
||||
marketRole: base?.marketRole ?? item.marketRole ?? null,
|
||||
normalizedPhrase: base?.normalizedPhrase ?? normalizedPhrase,
|
||||
phrase: base?.phrase ?? item.phrase,
|
||||
priority: base?.priority ?? item.priority ?? "medium",
|
||||
projectOntologyVersionId:
|
||||
base?.projectOntologyVersionId ?? item.projectOntologyVersionId ?? market.projectOntologyVersion?.id ?? null,
|
||||
relatedCount: base?.relatedCount ?? item.relatedCount ?? 0,
|
||||
seedSource: base?.seedSource ?? item.seedSource ?? "detected",
|
||||
source: base?.source ?? item.source ?? "manual",
|
||||
sourcePhrase: base?.sourcePhrase ?? item.sourcePhrase ?? null,
|
||||
targetPath: base?.targetPath ?? item.targetPath ?? null,
|
||||
wordstatResultId: base?.wordstatResultId ?? item.wordstatResultId ?? null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): KeywordCleaningModelTaskContract["input"] {
|
||||
const briefPhraseMap = buildBriefPhraseMap(contract);
|
||||
const briefClusterMap = buildBriefClusterMap(contract);
|
||||
|
||||
return {
|
||||
approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, 80).map((anchor) => ({
|
||||
clusterId: anchor.clusterId,
|
||||
clusterTitle: anchor.clusterTitle,
|
||||
confidence: anchor.confidence,
|
||||
intentType: anchor.intentType,
|
||||
marketRole: anchor.marketRole,
|
||||
normalizedFrom: anchor.normalizedFrom,
|
||||
phrase: anchor.phrase,
|
||||
priority: anchor.priority
|
||||
})),
|
||||
landingBriefs: contract.briefEvidence.slice(0, 40).map((brief) => ({
|
||||
action: brief.action,
|
||||
evidenceStatus: brief.evidenceStatus,
|
||||
path: brief.path,
|
||||
priority: brief.priority,
|
||||
qualityScore: brief.qualityScore,
|
||||
seedPhrases: brief.seedPhrases.slice(0, 10)
|
||||
})),
|
||||
normalizationIntentGroups: contract.normalization.intentGroups.slice(0, 40).map((group) => ({
|
||||
excludedPhrases: group.excludedPhrases.slice(0, 12).map((item) => item.phrase),
|
||||
id: group.id,
|
||||
intentType: group.intentType,
|
||||
marketHypothesis: group.marketHypothesis,
|
||||
priority: group.priority,
|
||||
title: group.title,
|
||||
wordstatQueries: group.wordstatQueries.slice(0, 12)
|
||||
})),
|
||||
seedQueue: contract.seedQueue.slice(0, 120).map((seed) => ({
|
||||
clusterId: seed.clusterId,
|
||||
clusterTitle: seed.clusterTitle,
|
||||
evidenceStatus: seed.evidenceStatus,
|
||||
frequency: seed.frequency,
|
||||
frequencyGroup: seed.frequencyGroup,
|
||||
intentType: seed.normalization?.intentType ?? null,
|
||||
marketRole: seed.normalization?.marketRole ?? null,
|
||||
phrase: seed.phrase,
|
||||
priority: seed.priority,
|
||||
projectOntologyVersionId: seed.projectOntologyVersionId,
|
||||
relatedCount: seed.relatedCount,
|
||||
source: seed.source,
|
||||
targetPath: getTargetPath(seed, briefPhraseMap, briefClusterMap),
|
||||
wordstatResultId: seed.wordstatResultId
|
||||
})),
|
||||
wordstatTopResults: contract.wordstat.topResults.slice(0, 120).map((result) => ({
|
||||
frequency: result.frequency,
|
||||
frequencyGroup: result.frequencyGroup,
|
||||
id: result.id,
|
||||
phrase: result.phrase,
|
||||
region: result.region,
|
||||
sourcePhrase: result.sourcePhrase
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>) {
|
||||
const blockers: string[] = [];
|
||||
const exactEvidence = item.frequency !== null;
|
||||
|
|
@ -507,7 +703,9 @@ function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningCont
|
|||
return {
|
||||
articleCount: lanes.article.length,
|
||||
exactEvidenceCount: items.filter((item) => item.frequency !== null).length,
|
||||
keywordMapCandidateCount: lanes.use.length + lanes.support.length,
|
||||
keywordMapCandidateCount: items.filter(
|
||||
(item) => item.decision !== "trash" && item.evidenceStatus === "collected" && item.frequency !== null && item.targetPath
|
||||
).length,
|
||||
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
|
||||
riskyCount: lanes.risky.length,
|
||||
sourcePhraseCount: items.length,
|
||||
|
|
@ -545,7 +743,9 @@ function buildKeywordCleaningModelTask(
|
|||
wordstatJobStatus: contract.wordstat.latestJob?.status ?? null,
|
||||
wordstatResultCount: contract.wordstat.summary.resultCount
|
||||
},
|
||||
input: buildKeywordCleaningModelInput(contract),
|
||||
outputSchema: {
|
||||
decisionValues: KEYWORD_CLEANING_DECISIONS,
|
||||
itemRequiredKeys: [
|
||||
"id",
|
||||
"phrase",
|
||||
|
|
@ -562,9 +762,12 @@ function buildKeywordCleaningModelTask(
|
|||
"semanticRunId",
|
||||
"provider",
|
||||
"analystReview",
|
||||
"source",
|
||||
"modelTask",
|
||||
"readiness",
|
||||
"lanes",
|
||||
"items",
|
||||
"summary",
|
||||
"nextActions"
|
||||
],
|
||||
schemaVersion: "keyword-cleaning.v1"
|
||||
|
|
@ -575,7 +778,7 @@ function buildKeywordCleaningModelTask(
|
|||
schemaVersion: "seo-keyword-cleaning-task.v1",
|
||||
semanticRunId: contract.semanticRunId,
|
||||
stopConditions: [
|
||||
"Не отправлять trash/risky в keyword map как approved decision.",
|
||||
"Не отправлять trash в keyword map. Risky/article exact-фразы можно только предсортировать в Stage 3, но не считать approved без пользователя.",
|
||||
"Не придумывать спрос без Wordstat/SERP evidence.",
|
||||
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
||||
"Не сохранять rewrite/apply decisions."
|
||||
|
|
@ -613,7 +816,7 @@ function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">
|
|||
}
|
||||
|
||||
actions.push(
|
||||
`В keyword map передавать только use/support: ${contract.readiness.keywordMapCandidateCount} кандидатов с evidence и page binding.`
|
||||
`В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} exact-кандидатов в 5 колонок; финальные роли фиксирует пользователь.`
|
||||
);
|
||||
|
||||
return actions;
|
||||
|
|
@ -704,8 +907,18 @@ function normalizeKeywordCleaningOutput(
|
|||
|
||||
const items = output.items.map((item) => ({
|
||||
...item,
|
||||
blockers: Array.isArray(item.blockers) ? unique(item.blockers) : [],
|
||||
blockers: Array.isArray(item.blockers)
|
||||
? unique([
|
||||
...item.blockers,
|
||||
KEYWORD_CLEANING_DECISION_SET.has(String(item.decision || "").trim())
|
||||
? ""
|
||||
: `model_decision_alias:${String(item.decision || "").trim() || "empty"}`
|
||||
].filter(Boolean))
|
||||
: KEYWORD_CLEANING_DECISION_SET.has(String(item.decision || "").trim())
|
||||
? []
|
||||
: [`model_decision_alias:${String(item.decision || "").trim() || "empty"}`],
|
||||
confidence: Math.max(0, Math.min(1, item.confidence)),
|
||||
decision: normalizeKeywordCleaningDecision(item.decision),
|
||||
evidenceRefs: Array.isArray(item.evidenceRefs) ? unique(item.evidenceRefs) : [],
|
||||
normalizedPhrase: normalizePhrase(item.normalizedPhrase || item.phrase)
|
||||
}));
|
||||
|
|
@ -720,7 +933,7 @@ function normalizeKeywordCleaningOutput(
|
|||
provider: {
|
||||
message: providerMessage,
|
||||
mode: providerMode,
|
||||
modelRequired: true
|
||||
modelRequired: providerMode !== "codex_manual"
|
||||
},
|
||||
readiness,
|
||||
sourceTaskId: output.modelTask.taskId,
|
||||
|
|
@ -775,7 +988,15 @@ export async function getKeywordCleaningContract(projectId: string): Promise<Key
|
|||
return fallbackContract;
|
||||
}
|
||||
|
||||
return (await getLatestAlignedKeywordCleaning(projectId, market.semanticRunId, market.wordstat.latestJob?.id ?? null)) ?? fallbackContract;
|
||||
const latest = await getLatestAlignedKeywordCleaning(projectId, market.semanticRunId, market.wordstat.latestJob?.id ?? null);
|
||||
|
||||
return latest
|
||||
? {
|
||||
...latest,
|
||||
modelTask: fallbackContract.modelTask,
|
||||
sourceTaskId: fallbackContract.sourceTaskId
|
||||
}
|
||||
: fallbackContract;
|
||||
}
|
||||
|
||||
export async function saveKeywordCleaningManual(
|
||||
|
|
@ -815,3 +1036,67 @@ export async function saveKeywordCleaningManual(
|
|||
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function saveKeywordCleaningFromModelProvider(
|
||||
projectId: string,
|
||||
output: KeywordCleaningContract,
|
||||
sourceModelTaskRunId: string
|
||||
): Promise<KeywordCleaningContract> {
|
||||
const market = await getMarketEnrichmentContract(projectId);
|
||||
const modelTask = buildKeywordCleaningModelTask(projectId, market);
|
||||
|
||||
if (market.semanticRunId && output.semanticRunId && output.semanticRunId !== market.semanticRunId) {
|
||||
throw new Error("Keyword cleaning output semanticRunId не совпадает с текущим market enrichment.");
|
||||
}
|
||||
|
||||
const alignedOutput: KeywordCleaningContract = {
|
||||
...output,
|
||||
items: mergeModelProviderItemsWithMarketEvidence(output.items, market),
|
||||
modelTask,
|
||||
projectOntologyVersion: market.projectOntologyVersion,
|
||||
semanticRunId: market.semanticRunId,
|
||||
source: {
|
||||
...output.source,
|
||||
marketGeneratedAt: market.generatedAt,
|
||||
marketSeedCount: market.seedQueue.length,
|
||||
marketState: market.state,
|
||||
wordstatJobId: market.wordstat.latestJob?.id ?? null,
|
||||
wordstatJobStatus: market.wordstat.latestJob?.status ?? null,
|
||||
wordstatResultCount: market.wordstat.summary.resultCount
|
||||
},
|
||||
sourceTaskId: modelTask?.taskId ?? null
|
||||
};
|
||||
const normalizedOutput = normalizeKeywordCleaningOutput(
|
||||
projectId,
|
||||
alignedOutput,
|
||||
"codex_workspace",
|
||||
"AI Workspace Codex provider выполнил seo.keyword_cleaning по contract-only task; результат сохранён как evidence после backend validation."
|
||||
);
|
||||
const result = await pool.query<KeywordCleaningRunRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
values ($1, 'keyword_cleaning', 'done', $2::jsonb, $3::jsonb, now(), now())
|
||||
returning id, status, input, output, error_message, started_at, completed_at, created_at;
|
||||
`,
|
||||
[
|
||||
projectId,
|
||||
JSON.stringify({
|
||||
providerMode: "codex_workspace",
|
||||
schemaVersion: "keyword-cleaning-model-provider-input.v1",
|
||||
semanticRunId: normalizedOutput.semanticRunId,
|
||||
sourceModelTaskRunId,
|
||||
sourceTaskId: normalizedOutput.sourceTaskId,
|
||||
taskType: "seo.keyword_cleaning",
|
||||
wordstatJobId: normalizedOutput.source.wordstatJobId
|
||||
}),
|
||||
JSON.stringify(normalizedOutput)
|
||||
]
|
||||
);
|
||||
const run = result.rows[0] ? mapKeywordCleaningRun(result.rows[0]) : null;
|
||||
|
||||
if (!run) {
|
||||
throw new Error("Не удалось сохранить AI Workspace keyword cleaning.");
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -151,46 +151,238 @@ function buildBriefPhraseMap(contract: MarketEnrichmentContract) {
|
|||
return phraseMap;
|
||||
}
|
||||
|
||||
function getKeywordRole(
|
||||
item: KeywordCleaningItem,
|
||||
indexInTarget: number,
|
||||
targetHasCorePhrase: boolean,
|
||||
reviewedOntology: boolean
|
||||
): KeywordMapRole {
|
||||
if (reviewedOntology && isHybridSecondaryCandidate(item)) {
|
||||
return "secondary_candidate";
|
||||
function getKeywordWordCount(value: string) {
|
||||
return normalizePhrase(value).split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
const KEYWORD_FACET_STOP_WORDS = new Set([
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"by",
|
||||
"for",
|
||||
"in",
|
||||
"of",
|
||||
"on",
|
||||
"or",
|
||||
"the",
|
||||
"to",
|
||||
"без",
|
||||
"в",
|
||||
"для",
|
||||
"и",
|
||||
"или",
|
||||
"как",
|
||||
"на",
|
||||
"о",
|
||||
"об",
|
||||
"от",
|
||||
"по",
|
||||
"при",
|
||||
"с",
|
||||
"со",
|
||||
"что",
|
||||
"это"
|
||||
]);
|
||||
|
||||
const GENERIC_MARKET_FACET_STEMS = new Set([
|
||||
"автоматизац",
|
||||
"автоматизаци",
|
||||
"бизнес",
|
||||
"данн",
|
||||
"задач",
|
||||
"инструмент",
|
||||
"компан",
|
||||
"контур",
|
||||
"модул",
|
||||
"платформ",
|
||||
"приложен",
|
||||
"процесс",
|
||||
"проект",
|
||||
"разработк",
|
||||
"решен",
|
||||
"сервис",
|
||||
"систем",
|
||||
"сайт",
|
||||
"управлен",
|
||||
"услуг"
|
||||
]);
|
||||
|
||||
type SemanticFacetGuard = {
|
||||
approvedAnchorCount: number;
|
||||
dominantProtectedStems: Set<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";
|
||||
}
|
||||
|
||||
if (
|
||||
!reviewedOntology ||
|
||||
item.decision === "article" ||
|
||||
item.decision === "risky" ||
|
||||
item.evidenceStatus === "collected_no_signal" ||
|
||||
item.evidenceStatus === "collected_related_only" ||
|
||||
item.evidenceStatus === "collection_failed" ||
|
||||
item.evidenceStatus === "not_collected" ||
|
||||
item.evidenceStatus === "not_configured"
|
||||
) {
|
||||
return "validate";
|
||||
}
|
||||
|
||||
if (targetHasCorePhrase && isAiDifferentiatorCandidate(item)) {
|
||||
return "differentiator";
|
||||
}
|
||||
|
||||
if (item.decision === "support") {
|
||||
return "support";
|
||||
}
|
||||
|
||||
if (indexInTarget === 0 && item.priority === "high") {
|
||||
if (indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) {
|
||||
return "primary";
|
||||
}
|
||||
|
||||
if (item.priority === "high" || item.frequency !== null) {
|
||||
if (isLongTailKeywordCandidate(item)) {
|
||||
return "support";
|
||||
}
|
||||
|
||||
if (indexInTarget <= 7 && (item.priority === "high" || item.priority === "medium") && item.decision !== "article") {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
if (isClarifyingKeywordCandidate(item)) {
|
||||
return "differentiator";
|
||||
}
|
||||
|
||||
if (item.priority === "high" || item.priority === "medium") {
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
|
|
@ -229,36 +421,44 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
|
|||
|
||||
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
||||
const targetCounts = new Map<string, number>();
|
||||
const sourceItems = cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120);
|
||||
const targetHasCorePhrase = sourceItems.reduce<Map<string, boolean>>((accumulator, item) => {
|
||||
const rawTargetPath = item.targetPath;
|
||||
const targetKey = rawTargetPath ?? item.clusterId;
|
||||
const canAnchorTarget =
|
||||
reviewedOntology &&
|
||||
item.decision !== "article" &&
|
||||
item.decision !== "risky" &&
|
||||
item.evidenceStatus === "collected" &&
|
||||
item.frequency !== null &&
|
||||
!isBroadArticleBacklogPhrase(item.phrase) &&
|
||||
!isHybridSecondaryCandidate(item) &&
|
||||
!isAiDifferentiatorCandidate(item);
|
||||
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
||||
const sourceItems = cleaning.items
|
||||
.filter((item) => item.decision !== "trash")
|
||||
.sort((left, right) => {
|
||||
const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
|
||||
const rightRouteWeight = canModelRouteKeyword(right, semanticFacetGuard) ? 1 : 0;
|
||||
|
||||
if (canAnchorTarget) {
|
||||
accumulator.set(targetKey, true);
|
||||
}
|
||||
if (leftRouteWeight !== rightRouteWeight) {
|
||||
return rightRouteWeight - leftRouteWeight;
|
||||
}
|
||||
|
||||
return accumulator;
|
||||
}, new Map());
|
||||
const leftPriorityWeight = getKeywordPriorityWeight(left.priority);
|
||||
const rightPriorityWeight = getKeywordPriorityWeight(right.priority);
|
||||
|
||||
if (leftPriorityWeight !== rightPriorityWeight) {
|
||||
return rightPriorityWeight - leftPriorityWeight;
|
||||
}
|
||||
|
||||
const leftFrequency = left.frequency ?? -1;
|
||||
const rightFrequency = right.frequency ?? -1;
|
||||
|
||||
if (leftFrequency !== rightFrequency) {
|
||||
return rightFrequency - leftFrequency;
|
||||
}
|
||||
|
||||
return normalizePhrase(left.phrase).localeCompare(normalizePhrase(right.phrase), "ru");
|
||||
})
|
||||
.slice(0, 120);
|
||||
|
||||
return sourceItems.map((item) => {
|
||||
const rawTargetPath = item.targetPath;
|
||||
const targetKey = rawTargetPath ?? item.clusterId;
|
||||
const indexInTarget = targetCounts.get(targetKey) ?? 0;
|
||||
const role = getKeywordRole(item, indexInTarget, targetHasCorePhrase.get(targetKey) ?? false, reviewedOntology);
|
||||
const targetPath = item.decision === "article" || role === "secondary_candidate" || isBroadArticleBacklogPhrase(item.phrase) ? null : rawTargetPath;
|
||||
const role = getKeywordRole(item, indexInTarget, semanticFacetGuard);
|
||||
const targetPath = role === "secondary_candidate" ? null : rawTargetPath;
|
||||
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null;
|
||||
|
||||
if (targetPath && role !== "validate" && role !== "differentiator" && item.decision !== "support") {
|
||||
if (targetPath && role !== "validate") {
|
||||
targetCounts.set(targetKey, indexInTarget + 1);
|
||||
}
|
||||
|
||||
|
|
@ -277,7 +477,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
|
|||
frequencyGroup: item.frequencyGroup,
|
||||
projectOntologyVersionId: item.projectOntologyVersionId,
|
||||
wordstatResultId: item.wordstatResultId,
|
||||
reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology)
|
||||
reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology, semanticFacetGuard)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -286,10 +486,11 @@ function getKeywordMapItemReason(
|
|||
item: KeywordCleaningItem,
|
||||
role: KeywordMapRole,
|
||||
targetPath: string | null,
|
||||
reviewedOntology: boolean
|
||||
reviewedOntology: boolean,
|
||||
semanticFacetGuard: SemanticFacetGuard
|
||||
) {
|
||||
if (!reviewedOntology) {
|
||||
return "Фразу нельзя закреплять без reviewed ontology.";
|
||||
return "Модель предварительно распределила фразу, но фиксация ждёт reviewed ontology.";
|
||||
}
|
||||
|
||||
if (role === "secondary_candidate") {
|
||||
|
|
@ -300,16 +501,40 @@ function getKeywordMapItemReason(
|
|||
return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title.";
|
||||
}
|
||||
|
||||
if (isBroadArticleBacklogPhrase(item.phrase)) {
|
||||
if (role === "primary") {
|
||||
return "Модель поставила фразу в посадочное ядро: это strongest exact-запрос для выбранной посадочной.";
|
||||
}
|
||||
|
||||
if (role === "secondary") {
|
||||
return "Модель поставила фразу в поддерживающее ядро: она раскрывает главный спрос и структуру посадочной.";
|
||||
}
|
||||
|
||||
if (role === "support") {
|
||||
return "Модель поставила фразу в длинный хвост: использовать в FAQ, подписях, внутренних связях или вторичных блоках.";
|
||||
}
|
||||
|
||||
if (role === "validate" && hasSemanticFacetLoss(item, semanticFacetGuard)) {
|
||||
return "Wordstat related потерял доминантный признак исходного якоря: оставляем на ручной разбор, чтобы широкий спрос не вытеснил смысл проекта.";
|
||||
}
|
||||
|
||||
if (role === "validate" && missesDominantProjectFacet(item, semanticFacetGuard)) {
|
||||
return "Фраза не содержит доминантный смысл из подтверждённой очереди якорей: оставляем на ручной разбор, чтобы широкий спрос не стал ядром автоматически.";
|
||||
}
|
||||
|
||||
if (isBroadArticleBacklogPhrase(item.phrase) && role === "validate") {
|
||||
return "Широкая head-фраза уходит в article/backlog: не делаем её прямой основной ставкой текущей посадочной без отдельного SERP page-type решения.";
|
||||
}
|
||||
|
||||
if (item.decision === "article") {
|
||||
return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной.";
|
||||
return role === "validate"
|
||||
? "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной."
|
||||
: "Модель предварительно распределила article/backlog-фразу; пользователь должен подтвердить, что она нужна текущей стратегии.";
|
||||
}
|
||||
|
||||
if (item.decision === "risky") {
|
||||
return item.reason;
|
||||
return role === "validate"
|
||||
? item.reason
|
||||
: `Модель предварительно распределила спорную фразу; проверь перед фиксацией. ${item.reason}`;
|
||||
}
|
||||
|
||||
if (item.evidenceStatus === "collected_no_signal") {
|
||||
|
|
@ -451,44 +676,17 @@ function normalizePersistedKeywordMapItem(input: {
|
|||
targetPath: string | null;
|
||||
workspaceSectionId: string | null;
|
||||
}) {
|
||||
let role = input.role;
|
||||
let targetPath = input.targetPath;
|
||||
let relatedLanding = input.relatedLanding;
|
||||
let workspaceSectionId = input.workspaceSectionId;
|
||||
let pageId = input.pageId;
|
||||
|
||||
if (role === "primary" && isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) {
|
||||
role = "differentiator";
|
||||
workspaceSectionId = getWorkspaceSectionIdForRole("differentiator");
|
||||
}
|
||||
|
||||
if (isBroadArticleBacklogPhrase(input.phrase)) {
|
||||
role = "validate";
|
||||
relatedLanding = relatedLanding ?? targetPath;
|
||||
targetPath = null;
|
||||
pageId = null;
|
||||
workspaceSectionId = null;
|
||||
}
|
||||
|
||||
if (hasProductLikeHybridSignal(input.phrase) && !isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) {
|
||||
role = "secondary_candidate";
|
||||
relatedLanding = relatedLanding ?? targetPath;
|
||||
targetPath = null;
|
||||
pageId = null;
|
||||
workspaceSectionId = null;
|
||||
}
|
||||
|
||||
return {
|
||||
approvedAt: input.approvedAt,
|
||||
decisionId: input.decisionId,
|
||||
mapItemId: input.mapItemId,
|
||||
pageId,
|
||||
pageId: input.pageId,
|
||||
phrase: input.phrase,
|
||||
relatedLanding,
|
||||
role,
|
||||
relatedLanding: input.relatedLanding,
|
||||
role: input.role,
|
||||
sourceItemId: input.sourceItemId,
|
||||
targetPath,
|
||||
workspaceSectionId
|
||||
targetPath: input.targetPath,
|
||||
workspaceSectionId: input.workspaceSectionId
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -531,7 +729,7 @@ async function getKeywordMapPersistenceSummary(
|
|||
kmi.page_id,
|
||||
kd.updated_at as approved_at
|
||||
from keyword_decisions kd
|
||||
left join keyword_map_items kmi on kmi.keyword_decision_id = kd.id and kmi.project_id = kd.project_id
|
||||
left join keyword_map_items kmi on kmi.keyword_decision_id = kd.id and kmi.project_id = kd.project_id and kmi.approved = true
|
||||
where kd.project_id = $1
|
||||
and kd.semantic_run_id = $2
|
||||
and kd.project_ontology_version_id = $3
|
||||
|
|
@ -574,7 +772,7 @@ function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
|
|||
|
||||
if (contract.cleaning.keywordMapCandidateCount === 0) {
|
||||
actions.push(
|
||||
"Keyword cleaning не пропустил ни одной use/support фразы: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
|
||||
"Keyword cleaning не дал exact-кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
|
||||
);
|
||||
|
||||
if (contract.readiness.marketEvidenceCount === 0) {
|
||||
|
|
@ -830,6 +1028,20 @@ export async function approveKeywordMapDecisions(
|
|||
continue;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update keyword_map_items
|
||||
set approved = false, updated_at = now()
|
||||
where project_id = $1
|
||||
and semantic_run_id = $2
|
||||
and project_ontology_version_id = $3
|
||||
and source_item_id = $4
|
||||
and workspace_section_id <> $5
|
||||
and approved = true;
|
||||
`,
|
||||
[projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id, item.id, workspaceSectionId]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into keyword_map_items (
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export type WordstatProviderStatus =
|
|||
provider: "mcp_kv" | "yandex_api";
|
||||
mode: string;
|
||||
message: string;
|
||||
maxSeedsPerRun: number;
|
||||
}
|
||||
| {
|
||||
connected: false;
|
||||
|
|
@ -246,7 +247,7 @@ async function postJson(endpoint: string, body: unknown, headers: Record<string,
|
|||
}
|
||||
|
||||
class McpKvWordstatProvider implements WordstatProvider {
|
||||
constructor(private readonly endpoint: string | undefined) {}
|
||||
constructor(private readonly endpoint: string | undefined, private readonly maxSeedsPerRun: number) {}
|
||||
|
||||
getStatus(): WordstatProviderStatus {
|
||||
if (!this.endpoint) {
|
||||
|
|
@ -262,7 +263,8 @@ class McpKvWordstatProvider implements WordstatProvider {
|
|||
connected: true,
|
||||
provider: "mcp_kv",
|
||||
mode: "mcp_kv",
|
||||
message: "Wordstat MCP-KV endpoint настроен на платформе."
|
||||
message: "Wordstat MCP-KV endpoint настроен на платформе.",
|
||||
maxSeedsPerRun: this.maxSeedsPerRun
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -300,7 +302,8 @@ class YandexApiWordstatProvider implements WordstatProvider {
|
|||
connected: true,
|
||||
provider: "yandex_api",
|
||||
mode: "yandex_search_api",
|
||||
message: "Yandex Wordstat Search API настроен на платформе."
|
||||
message: "Yandex Wordstat Search API настроен на платформе.",
|
||||
maxSeedsPerRun: this.config.maxSeedsPerRun
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -359,7 +362,7 @@ export async function createWordstatProvider(): Promise<WordstatProvider> {
|
|||
const config = await getWordstatRuntimeConfig();
|
||||
|
||||
if (config.provider === "mcp_kv") {
|
||||
return new McpKvWordstatProvider(config.mcpEndpoint);
|
||||
return new McpKvWordstatProvider(config.mcpEndpoint, config.maxSeedsPerRun);
|
||||
}
|
||||
|
||||
if (config.provider === "yandex_api") {
|
||||
|
|
|
|||
|
|
@ -382,6 +382,29 @@ function getRepresentativeTopResultRows(results: WordstatResultRow[]) {
|
|||
return Array.from(selectedRows.values()).sort(compareWordstatResultRows);
|
||||
}
|
||||
|
||||
function getWordstatResultIdentity(row: WordstatResultRow) {
|
||||
const phrase = row.normalized_phrase ?? normalizePhrase(row.phrase);
|
||||
const sourcePhrase = row.source_phrase ? normalizePhrase(row.source_phrase) : "";
|
||||
const kind = sourcePhrase && sourcePhrase !== phrase ? "related" : "seed";
|
||||
|
||||
return `${kind}:${sourcePhrase || phrase}:${phrase}`;
|
||||
}
|
||||
|
||||
function getLatestUniqueWordstatResults(results: WordstatResultRow[]) {
|
||||
const resultByIdentity = new Map<string, WordstatResultRow>();
|
||||
const sortedResults = [...results].sort((left, right) => right.created_at.getTime() - left.created_at.getTime());
|
||||
|
||||
for (const result of sortedResults) {
|
||||
const identity = getWordstatResultIdentity(result);
|
||||
|
||||
if (!resultByIdentity.has(identity)) {
|
||||
resultByIdentity.set(identity, result);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(resultByIdentity.values()).sort(compareWordstatResultRows);
|
||||
}
|
||||
|
||||
function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]): WordstatEvidence {
|
||||
const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
|
||||
const relatedCounts = new Map<string, number>();
|
||||
|
|
@ -505,6 +528,26 @@ function toWordstatSeed(row: SeedRow): WordstatSeedInput {
|
|||
};
|
||||
}
|
||||
|
||||
async function getCollectedSeedPhraseSet(projectId: string, semanticRunId: string) {
|
||||
const result = await pool.query<{ normalized_phrase: string | null; phrase: string }>(
|
||||
`
|
||||
select distinct on (coalesce(wr.normalized_phrase, lower(wr.phrase)))
|
||||
wr.normalized_phrase,
|
||||
wr.phrase
|
||||
from wordstat_results wr
|
||||
join wordstat_jobs wj on wj.id = wr.job_id
|
||||
where wj.project_id = $1
|
||||
and wj.semantic_run_id = $2
|
||||
and wj.status = 'done'
|
||||
and (wr.source_phrase is null or lower(wr.source_phrase) = lower(wr.phrase))
|
||||
order by coalesce(wr.normalized_phrase, lower(wr.phrase)), wr.created_at desc;
|
||||
`,
|
||||
[projectId, semanticRunId]
|
||||
);
|
||||
|
||||
return new Set(result.rows.map((row) => normalizePhrase(row.normalized_phrase ?? row.phrase)));
|
||||
}
|
||||
|
||||
async function createJob(
|
||||
projectId: string,
|
||||
semanticRunId: string,
|
||||
|
|
@ -693,8 +736,17 @@ export async function collectWordstatEvidence(
|
|||
}
|
||||
|
||||
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
||||
const seeds = seedRows.map(toWordstatSeed);
|
||||
const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId);
|
||||
const seeds = seedRows
|
||||
.filter((row) => !collectedSeedPhrases.has(normalizePhrase(row.phrase)))
|
||||
.slice(0, status.maxSeedsPerRun)
|
||||
.map(toWordstatSeed);
|
||||
const region = "ru";
|
||||
|
||||
if (seeds.length === 0) {
|
||||
return getLatestWordstatEvidence(projectId, analysis.runId);
|
||||
}
|
||||
|
||||
const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds);
|
||||
|
||||
if (!job) {
|
||||
|
|
@ -763,31 +815,35 @@ export async function getLatestWordstatEvidence(
|
|||
const results = await pool.query<WordstatResultRow>(
|
||||
`
|
||||
select
|
||||
id,
|
||||
job_id,
|
||||
seed_id,
|
||||
phrase,
|
||||
source_phrase,
|
||||
normalized_phrase,
|
||||
frequency,
|
||||
frequency_group,
|
||||
region,
|
||||
position,
|
||||
raw,
|
||||
created_at
|
||||
from wordstat_results
|
||||
where job_id = $1
|
||||
order by position asc, phrase asc;
|
||||
wr.id,
|
||||
wr.job_id,
|
||||
wr.seed_id,
|
||||
wr.phrase,
|
||||
wr.source_phrase,
|
||||
wr.normalized_phrase,
|
||||
wr.frequency,
|
||||
wr.frequency_group,
|
||||
wr.region,
|
||||
wr.position,
|
||||
wr.raw,
|
||||
wr.created_at
|
||||
from wordstat_results wr
|
||||
join wordstat_jobs wj on wj.id = wr.job_id
|
||||
where wj.project_id = $1
|
||||
and wj.semantic_run_id = $2
|
||||
and wj.status = 'done'
|
||||
order by wr.created_at desc, wr.position asc, wr.phrase asc;
|
||||
`,
|
||||
[latestJob.id]
|
||||
[projectId, semanticRunId]
|
||||
);
|
||||
const latestUniqueResults = getLatestUniqueWordstatResults(results.rows);
|
||||
|
||||
if (!scopePhrases || scopePhrases.length === 0) {
|
||||
return buildEvidence(latestJob, results.rows);
|
||||
return buildEvidence(latestJob, latestUniqueResults);
|
||||
}
|
||||
|
||||
const scopePhraseSet = new Set(scopePhrases.map(normalizePhrase));
|
||||
const scopedResults = results.rows.filter((row) => {
|
||||
const scopedResults = latestUniqueResults.filter((row) => {
|
||||
const sourcePhrase = row.source_phrase ? normalizePhrase(row.source_phrase) : null;
|
||||
const phrase = normalizePhrase(row.phrase);
|
||||
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
"SERP interpretation работает только по сохранённому yandex-evidence.v1 и не придумывает конкурентов или спрос.",
|
||||
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
|
||||
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
|
||||
"Keyword map получает только keyword-cleaning use/support lanes; risky/trash не становятся approved decisions.",
|
||||
"Keyword map предсортирует exact evidence в пять Stage 3 колонок; Неразобранные не участвуют, финальные роли утверждает пользователь.",
|
||||
"Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval."
|
||||
],
|
||||
nextActions: !analysis
|
||||
|
|
@ -360,7 +360,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
||||
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
|
||||
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
|
||||
"После Wordstat прогонять seo.keyword_cleaning и только use/support передавать в keyword map.",
|
||||
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать exact-кандидаты в пять колонок для ручной правки.",
|
||||
"Не запускать external market collection без anchor review approval."
|
||||
]
|
||||
: [
|
||||
|
|
|
|||
|
|
@ -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 { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js";
|
||||
|
||||
export const SEO_MODEL_TASK_TYPES = [
|
||||
"seo.context_review",
|
||||
|
|
@ -141,7 +142,7 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
|
|||
title: "Codex Workspace adapter",
|
||||
mode: "workspace_agent",
|
||||
summary:
|
||||
"Будущий adapter для dev-mode/model-review: модель получает только SEO task contract и возвращает structured JSON.",
|
||||
"Adapter через AI Workspace Assistant / AI Hub / remote Codex worker: модель получает только SEO task contract и возвращает structured JSON.",
|
||||
allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES,
|
||||
requiresSecrets: false,
|
||||
secretNames: [],
|
||||
|
|
@ -157,9 +158,11 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
|
|||
guardrails: [
|
||||
"Workspace agent не должен читать репозиторий напрямую для пользовательских сайтов.",
|
||||
"Все filesystem/tool calls должны идти через approved MCP capabilities.",
|
||||
"SEO route требует явный contract-only workspace path и не использует executor workspace fallback.",
|
||||
"Ответ должен проходить schema validation перед записью результата."
|
||||
],
|
||||
nextSetupStep: "Описать handoff contract между AI Workspace Hub/MCP и seo_mode backend."
|
||||
nextSetupStep:
|
||||
"Настроить SEO_AI_WORKSPACE_PROFILE, AI Workspace control URL/token, owner, contract-only workspace path and connected Codex executor."
|
||||
},
|
||||
{
|
||||
id: "openai_api",
|
||||
|
|
@ -239,10 +242,38 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
|
|||
}
|
||||
];
|
||||
|
||||
const PARSED_PROVIDERS = z.array(SeoModelProviderDescriptorSchema).parse(PROVIDERS);
|
||||
function buildProviderDescriptors() {
|
||||
const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus();
|
||||
|
||||
return PROVIDERS.map((provider) => {
|
||||
if (provider.id !== "codex_workspace") {
|
||||
return provider;
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
status: aiWorkspaceStatus.configured ? "active" as const : "not_configured" as const,
|
||||
summary: aiWorkspaceStatus.configured
|
||||
? `${provider.summary} Configured profile: ${aiWorkspaceStatus.profile}.`
|
||||
: `${provider.summary} Пока не настроен: ${aiWorkspaceStatus.errors[0] ?? provider.nextSetupStep}`,
|
||||
guardrails: [
|
||||
...provider.guardrails,
|
||||
`AI Workspace profile: ${aiWorkspaceStatus.profile}.`,
|
||||
"Remote Codex output is proposal/evidence only; it never bypasses human gates or rewrite/apply approval."
|
||||
],
|
||||
nextSetupStep: aiWorkspaceStatus.configured
|
||||
? "Запустить seo model task через codex_workspace и дождаться structured JSON в model task evidence run."
|
||||
: aiWorkspaceStatus.errors.join(" ") || provider.nextSetupStep
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getParsedProviders() {
|
||||
return z.array(SeoModelProviderDescriptorSchema).parse(buildProviderDescriptors());
|
||||
}
|
||||
|
||||
export function getDefaultSeoModelProviderId(): SeoModelProviderId {
|
||||
return "codex_manual";
|
||||
return getSeoAiWorkspaceConfigStatus().configured ? "codex_workspace" : "codex_manual";
|
||||
}
|
||||
|
||||
export function isSeoModelProviderId(value: string): value is SeoModelProviderId {
|
||||
|
|
@ -250,14 +281,17 @@ export function isSeoModelProviderId(value: string): value is SeoModelProviderId
|
|||
}
|
||||
|
||||
export function getSeoModelProviderDescriptor(providerId: SeoModelProviderId): SeoModelProviderDescriptor {
|
||||
return PARSED_PROVIDERS.find((provider) => provider.id === providerId) ?? PARSED_PROVIDERS[0];
|
||||
const providers = getParsedProviders();
|
||||
return providers.find((provider) => provider.id === providerId) ?? providers[0];
|
||||
}
|
||||
|
||||
export function getSeoModelProviderCatalog(): SeoModelProviderCatalog {
|
||||
const providers = getParsedProviders();
|
||||
|
||||
return SeoModelProviderCatalogSchema.parse({
|
||||
schemaVersion: "seo-model-provider-catalog.v1",
|
||||
generatedAt: new Date().toISOString(),
|
||||
defaultProviderId: getDefaultSeoModelProviderId(),
|
||||
providers: PARSED_PROVIDERS
|
||||
providers
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
import { pool } from "../db/client.js";
|
||||
import { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js";
|
||||
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
|
||||
import { saveSeoNormalizationFromModelProvider, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
||||
import { saveStrategyQualityReviewFromModelProvider, type StrategyQualityReviewContract } from "../strategy/strategyQualityReview.js";
|
||||
import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js";
|
||||
import {
|
||||
buildAcceptedAiWorkspaceModelOutput,
|
||||
buildSeoAiWorkspacePromotedResult,
|
||||
buildSeoAiWorkspacePersistedResult,
|
||||
dispatchSeoModelTaskToAiWorkspace,
|
||||
fetchSeoAiWorkspaceCompletion,
|
||||
validateSeoAiWorkspaceModelOutput,
|
||||
type SeoAiWorkspaceDispatch,
|
||||
type SeoAiWorkspaceModelOutput,
|
||||
type SeoAiWorkspacePersistedResult
|
||||
} from "./aiWorkspaceBridge.js";
|
||||
import {
|
||||
getDefaultSeoModelProviderId,
|
||||
getSeoModelProviderCatalog,
|
||||
|
|
@ -24,6 +39,11 @@ type SeoModelTaskRunRow = {
|
|||
created_at: Date;
|
||||
};
|
||||
|
||||
type DomainPromotionResult = {
|
||||
persistedResult: SeoAiWorkspacePersistedResult;
|
||||
nextAction: string;
|
||||
};
|
||||
|
||||
export type SeoModelTaskRunInput = {
|
||||
providerId?: SeoModelProviderId;
|
||||
taskId?: string;
|
||||
|
|
@ -60,8 +80,8 @@ export type SeoModelTaskRunOutput = {
|
|||
inputTokenEstimate: number;
|
||||
outputTokenBudget: number;
|
||||
};
|
||||
modelOutput: null;
|
||||
persistedResult: null;
|
||||
modelOutput: SeoAiWorkspaceModelOutput | null;
|
||||
persistedResult: SeoAiWorkspacePersistedResult | null;
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
|
|
@ -218,7 +238,11 @@ function validateTaskContract(task: SeoModelTaskContract | null, provider: SeoMo
|
|||
warnings.push(`Provider ${provider.id} пока ${provider.status}; боевой model call не выполняется.`);
|
||||
}
|
||||
|
||||
notes.push("Dry-run проверяет только provider/task contract, а не качество будущего model output.");
|
||||
if (provider.id === "codex_workspace" && provider.status === "active") {
|
||||
notes.push("AI Workspace adapter отправляет sanitized task contract в remote Codex worker через Assistant/Hub route.");
|
||||
} else {
|
||||
notes.push("Dry-run проверяет только provider/task contract, а не качество будущего model output.");
|
||||
}
|
||||
notes.push("Результат модели должен сохраняться отдельным evidence run и проходить schema validation.");
|
||||
|
||||
return {
|
||||
|
|
@ -288,11 +312,17 @@ function buildRunOutput(
|
|||
persistedResult: null,
|
||||
nextActions:
|
||||
status === "contract_ready"
|
||||
? [
|
||||
"Подключить реальный model adapter к этому же task contract.",
|
||||
"Сохранять model output как evidence run только после schema validation.",
|
||||
"Сравнивать model output с deterministic fallback перед human approval."
|
||||
]
|
||||
? provider.id === "codex_workspace"
|
||||
? [
|
||||
"Отправить task contract в AI Workspace Assistant / AI Hub / remote Codex worker.",
|
||||
"Дождаться structured JSON и проверить output schema.",
|
||||
"Сохранить валидный output как model-task evidence run; доменное promotion остается backend-owned."
|
||||
]
|
||||
: [
|
||||
"Подключить реальный model adapter к этому же task contract.",
|
||||
"Сохранять model output как evidence run только после schema validation.",
|
||||
"Сравнивать model output с deterministic fallback перед human approval."
|
||||
]
|
||||
: status === "provider_not_configured"
|
||||
? [provider.nextSetupStep, "Пока использовать contract_dry_run для проверки маршрута."]
|
||||
: status === "task_not_found"
|
||||
|
|
@ -304,20 +334,311 @@ function buildRunOutput(
|
|||
async function insertSeoModelTaskRun(
|
||||
projectId: string,
|
||||
input: Record<string, unknown>,
|
||||
output: SeoModelTaskRunOutput
|
||||
output: SeoModelTaskRunOutput,
|
||||
status: RunStatus = "done"
|
||||
) {
|
||||
const isTerminal = status === "done" || status === "failed" || status === "cancelled";
|
||||
const result = await pool.query<SeoModelTaskRunRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
values ($1, 'seo_model_task', 'done', $2::jsonb, $3::jsonb, now(), now())
|
||||
values ($1, 'seo_model_task', $4::run_status, $2::jsonb, $3::jsonb, now(), ${isTerminal ? "now()" : "null"})
|
||||
returning id, status, input, output, error_message, started_at, completed_at, created_at
|
||||
`,
|
||||
[projectId, JSON.stringify(input), JSON.stringify(output)]
|
||||
[projectId, JSON.stringify(input), JSON.stringify(output), status]
|
||||
);
|
||||
|
||||
return mapSeoModelTaskRun(result.rows[0]);
|
||||
}
|
||||
|
||||
async function updateSeoModelTaskRun(
|
||||
runId: string,
|
||||
patch: {
|
||||
completed?: boolean;
|
||||
errorMessage?: string | null;
|
||||
input?: Record<string, unknown>;
|
||||
output?: SeoModelTaskRunOutput;
|
||||
status?: RunStatus;
|
||||
}
|
||||
) {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [runId];
|
||||
|
||||
if (patch.status) {
|
||||
values.push(patch.status);
|
||||
fields.push(`status = $${values.length}::run_status`);
|
||||
}
|
||||
|
||||
if (patch.input) {
|
||||
values.push(JSON.stringify(patch.input));
|
||||
fields.push(`input = $${values.length}::jsonb`);
|
||||
}
|
||||
|
||||
if (patch.output) {
|
||||
values.push(JSON.stringify(patch.output));
|
||||
fields.push(`output = $${values.length}::jsonb`);
|
||||
}
|
||||
|
||||
if (Object.hasOwn(patch, "errorMessage")) {
|
||||
values.push(patch.errorMessage ?? null);
|
||||
fields.push(`error_message = $${values.length}`);
|
||||
}
|
||||
|
||||
if (patch.completed) {
|
||||
fields.push("completed_at = now()");
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
const result = await pool.query<SeoModelTaskRunRow>(
|
||||
`
|
||||
select id, status, input, output, error_message, started_at, completed_at, created_at
|
||||
from runs
|
||||
where id = $1 and run_type = 'seo_model_task'
|
||||
`,
|
||||
[runId]
|
||||
);
|
||||
|
||||
return mapSeoModelTaskRun(result.rows[0]);
|
||||
}
|
||||
|
||||
const result = await pool.query<SeoModelTaskRunRow>(
|
||||
`
|
||||
update runs
|
||||
set ${fields.join(", ")}
|
||||
where id = $1 and run_type = 'seo_model_task'
|
||||
returning id, status, input, output, error_message, started_at, completed_at, created_at
|
||||
`,
|
||||
values
|
||||
);
|
||||
|
||||
return mapSeoModelTaskRun(result.rows[0]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function getRunAiWorkspaceDispatch(run: SeoModelTaskRun): SeoAiWorkspaceDispatch | null {
|
||||
const fromOutput = isRecord(run.modelOutput) && isRecord(run.modelOutput.aiWorkspace)
|
||||
? run.modelOutput.aiWorkspace
|
||||
: null;
|
||||
const fromInput = isRecord(run.input.aiWorkspace) ? run.input.aiWorkspace : null;
|
||||
const source = fromOutput ?? fromInput;
|
||||
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistantThreadId = typeof source.assistantThreadId === "string" ? source.assistantThreadId : "";
|
||||
const assistantMessageId = typeof source.assistantMessageId === "string" ? source.assistantMessageId : "";
|
||||
const bridgeRequestId = typeof source.bridgeRequestId === "string" ? source.bridgeRequestId : "";
|
||||
const contractWorkspacePath = typeof source.contractWorkspacePath === "string" ? source.contractWorkspacePath : "";
|
||||
|
||||
if (!assistantThreadId || !assistantMessageId || !bridgeRequestId || !contractWorkspacePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: "seo-ai-workspace-dispatch.v1",
|
||||
profile: source.profile === "deploy-host" ||
|
||||
source.profile === "deployed-hub" ||
|
||||
source.profile === "tunnel-local-e2e" ||
|
||||
source.profile === "local"
|
||||
? source.profile
|
||||
: "disabled",
|
||||
assistantThreadId,
|
||||
assistantMessageId,
|
||||
bridgeRequestId,
|
||||
bridgeMode: typeof source.bridgeMode === "string" ? source.bridgeMode : "hub",
|
||||
accepted: source.accepted === true,
|
||||
dispatchedAt: typeof source.dispatchedAt === "string" ? source.dispatchedAt : run.startedAt ?? run.createdAt,
|
||||
selectedExecutorId: typeof source.selectedExecutorId === "string" ? source.selectedExecutorId : null,
|
||||
contractWorkspacePath
|
||||
};
|
||||
}
|
||||
|
||||
async function promoteAiWorkspaceModelOutput(
|
||||
projectId: string,
|
||||
run: SeoModelTaskRun,
|
||||
modelOutput: SeoAiWorkspaceModelOutput
|
||||
): Promise<DomainPromotionResult | null> {
|
||||
if (!modelOutput.validation?.ok || !modelOutput.parsedJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (run.task.taskType === "seo.normalization") {
|
||||
await saveSeoNormalizationFromModelProvider(
|
||||
projectId,
|
||||
modelOutput.parsedJson as unknown as SeoNormalizationContract,
|
||||
run.runId
|
||||
);
|
||||
return {
|
||||
nextAction: "SEO normalization обновлена из AI Workspace output; можно пересобирать anchor review/Wordstat queue.",
|
||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
||||
runType: "seo_normalization",
|
||||
sourceModelTaskRunId: run.runId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (run.task.taskType === "seo.keyword_cleaning") {
|
||||
await saveKeywordCleaningFromModelProvider(
|
||||
projectId,
|
||||
modelOutput.parsedJson as unknown as KeywordCleaningContract,
|
||||
run.runId
|
||||
);
|
||||
return {
|
||||
nextAction: "Keyword cleaning обновлён из AI Workspace output; Stage 3 keyword map должен пересобраться из новой очистки.",
|
||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
||||
runType: "keyword_cleaning",
|
||||
sourceModelTaskRunId: run.runId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (run.task.taskType === "seo.strategy_synthesis") {
|
||||
await saveStrategySynthesisFromModelProvider(
|
||||
projectId,
|
||||
modelOutput.parsedJson as unknown as StrategySynthesisContract,
|
||||
run.runId
|
||||
);
|
||||
return {
|
||||
nextAction: "Strategy synthesis обновлён из AI Workspace output; пользователь может проверять стратегию и quality gate.",
|
||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
||||
runType: "seo_strategy_synthesis",
|
||||
sourceModelTaskRunId: run.runId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (run.task.taskType === "seo.strategy_quality_review") {
|
||||
await saveStrategyQualityReviewFromModelProvider(
|
||||
projectId,
|
||||
modelOutput.parsedJson as unknown as StrategyQualityReviewContract,
|
||||
run.runId
|
||||
);
|
||||
return {
|
||||
nextAction: "Strategy quality review обновлён из AI Workspace output; блокеры и warnings доступны в strategy gate.",
|
||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
||||
runType: "seo_strategy_quality_review",
|
||||
sourceModelTaskRunId: run.runId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nextAction: "Model output сохранен только в seo_model_task evidence; для этого task type доменное promotion пока не задано.",
|
||||
persistedResult: buildSeoAiWorkspacePersistedResult(run.runId)
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshRunningSeoModelTaskRun(row: SeoModelTaskRunRow) {
|
||||
const run = mapSeoModelTaskRun(row);
|
||||
|
||||
if (!run || run.provider.id !== "codex_workspace" || run.runStatus !== "running") {
|
||||
return run;
|
||||
}
|
||||
|
||||
const dispatch = getRunAiWorkspaceDispatch(run);
|
||||
|
||||
if (!dispatch) {
|
||||
return run;
|
||||
}
|
||||
|
||||
const completion = await fetchSeoAiWorkspaceCompletion(dispatch);
|
||||
|
||||
if (completion.status === "pending") {
|
||||
return run;
|
||||
}
|
||||
|
||||
if (completion.status === "failed") {
|
||||
return updateSeoModelTaskRun(run.runId, {
|
||||
completed: true,
|
||||
errorMessage: completion.error,
|
||||
status: "failed"
|
||||
});
|
||||
}
|
||||
|
||||
const modelOutput = validateSeoAiWorkspaceModelOutput({
|
||||
dispatch,
|
||||
outputSchemaVersion: run.task.outputSchemaVersion,
|
||||
rawText: completion.rawText,
|
||||
requiredTopLevelKeys: run.task.requiredTopLevelKeys
|
||||
});
|
||||
let promotion: DomainPromotionResult | null = null;
|
||||
|
||||
if (modelOutput.validation?.ok) {
|
||||
try {
|
||||
promotion = await promoteAiWorkspaceModelOutput(run.projectId, run, modelOutput);
|
||||
} catch (error) {
|
||||
modelOutput.validation = {
|
||||
...modelOutput.validation,
|
||||
ok: false,
|
||||
errors: [
|
||||
...modelOutput.validation.errors,
|
||||
`Domain promotion failed: ${error instanceof Error ? error.message : "unknown_error"}`
|
||||
]
|
||||
};
|
||||
modelOutput.status = "schema_invalid";
|
||||
}
|
||||
}
|
||||
const {
|
||||
runId: _runId,
|
||||
runStatus: _runStatus,
|
||||
input: _input,
|
||||
startedAt: _startedAt,
|
||||
completedAt: _completedAt,
|
||||
errorMessage: _errorMessage,
|
||||
createdAt: _createdAt,
|
||||
...storedOutput
|
||||
} = run;
|
||||
const output: SeoModelTaskRunOutput = {
|
||||
...storedOutput,
|
||||
modelOutput,
|
||||
persistedResult: modelOutput.validation?.ok ? promotion?.persistedResult ?? buildSeoAiWorkspacePersistedResult(run.runId) : null,
|
||||
nextActions: modelOutput.validation?.ok
|
||||
? [
|
||||
promotion?.nextAction ?? "Model output сохранен в seo_model_task run как evidence.",
|
||||
"Human gates остаются обязательными перед strategy/rewrite/apply."
|
||||
]
|
||||
: [
|
||||
"Исправить remote Codex output: он не прошел JSON/schema validation.",
|
||||
"Повторить task через codex_workspace после корректировки prompt/contract.",
|
||||
"Не продвигать этот output в strategy/rewrite."
|
||||
]
|
||||
};
|
||||
|
||||
return updateSeoModelTaskRun(run.runId, {
|
||||
completed: true,
|
||||
errorMessage: modelOutput.validation?.ok ? null : modelOutput.validation?.errors.join(" "),
|
||||
output,
|
||||
status: modelOutput.validation?.ok ? "done" : "failed"
|
||||
});
|
||||
}
|
||||
|
||||
async function refreshRunningSeoModelTaskRuns(projectId: string) {
|
||||
const result = await pool.query<SeoModelTaskRunRow>(
|
||||
`
|
||||
select id, status, input, output, error_message, started_at, completed_at, created_at
|
||||
from runs
|
||||
where project_id = $1
|
||||
and run_type = 'seo_model_task'
|
||||
and status = 'running'
|
||||
and input->>'providerId' = 'codex_workspace'
|
||||
order by created_at desc
|
||||
limit 5
|
||||
`,
|
||||
[projectId]
|
||||
);
|
||||
|
||||
for (const row of result.rows) {
|
||||
try {
|
||||
await refreshRunningSeoModelTaskRun(row);
|
||||
} catch {
|
||||
// Status reads should not fail when the external Assistant route is temporarily unavailable.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): Promise<SeoModelTaskRun[]> {
|
||||
const result = await pool.query<SeoModelTaskRunRow>(
|
||||
`
|
||||
|
|
@ -336,6 +657,7 @@ export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): P
|
|||
export async function getSeoModelProviderStatusContract(
|
||||
projectId: string
|
||||
): Promise<SeoModelProviderStatusContract> {
|
||||
await refreshRunningSeoModelTaskRuns(projectId);
|
||||
const [catalog, latestRuns] = await Promise.all([getSeoModelProviderCatalog(), getLatestSeoModelTaskRuns(projectId)]);
|
||||
|
||||
return {
|
||||
|
|
@ -353,15 +675,77 @@ export async function runSeoModelTask(projectId: string, input: SeoModelTaskRunI
|
|||
const context = await getSeoModelContextContract(projectId);
|
||||
const task = findTask(context.modelTasks, input);
|
||||
const output = buildRunOutput(projectId, provider, task, context.schemaVersion);
|
||||
const baseInput = {
|
||||
schemaVersion: "seo-model-task-run-input.v1",
|
||||
providerId,
|
||||
requestedTaskId: input.taskId ?? null,
|
||||
requestedTaskType: input.taskType ?? null,
|
||||
sourceContextGeneratedAt: context.generatedAt
|
||||
};
|
||||
|
||||
if (provider.id === "codex_workspace" && output.status === "contract_ready" && task) {
|
||||
const queuedRun = await insertSeoModelTaskRun(projectId, baseInput, output, "running");
|
||||
|
||||
if (!queuedRun) {
|
||||
throw new Error("Не удалось сохранить seo model task run.");
|
||||
}
|
||||
|
||||
try {
|
||||
const dispatch = await dispatchSeoModelTaskToAiWorkspace({
|
||||
projectId,
|
||||
runId: queuedRun.runId,
|
||||
task
|
||||
});
|
||||
const dispatchedOutput: SeoModelTaskRunOutput = {
|
||||
...output,
|
||||
modelOutput: buildAcceptedAiWorkspaceModelOutput(dispatch),
|
||||
nextActions: [
|
||||
"AI Workspace request accepted; model output will be collected from Assistant thread messages.",
|
||||
"Refresh model-provider status to pick up bridge_final/bridge_failure.",
|
||||
"Structured JSON must pass schema validation before task-specific evidence promotion."
|
||||
]
|
||||
};
|
||||
const dispatchedRun = await updateSeoModelTaskRun(queuedRun.runId, {
|
||||
input: {
|
||||
...baseInput,
|
||||
aiWorkspace: dispatch
|
||||
},
|
||||
output: dispatchedOutput,
|
||||
status: "running"
|
||||
});
|
||||
|
||||
if (!dispatchedRun) {
|
||||
throw new Error("Не удалось обновить seo model task run после AI Workspace dispatch.");
|
||||
}
|
||||
|
||||
return dispatchedRun;
|
||||
} catch (error) {
|
||||
const failedOutput: SeoModelTaskRunOutput = {
|
||||
...output,
|
||||
nextActions: [
|
||||
"Проверить SEO AI Workspace config/profile и выбранный Codex executor.",
|
||||
"Повторить dispatch после восстановления Assistant/Hub/worker route.",
|
||||
"Не использовать fallback на deployed product hosts или executor workspace по умолчанию."
|
||||
]
|
||||
};
|
||||
const failedRun = await updateSeoModelTaskRun(queuedRun.runId, {
|
||||
completed: true,
|
||||
errorMessage: error instanceof Error ? error.message : "ai_workspace_dispatch_failed",
|
||||
output: failedOutput,
|
||||
status: "failed"
|
||||
});
|
||||
|
||||
if (!failedRun) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return failedRun;
|
||||
}
|
||||
}
|
||||
|
||||
const run = await insertSeoModelTaskRun(
|
||||
projectId,
|
||||
{
|
||||
schemaVersion: "seo-model-task-run-input.v1",
|
||||
providerId,
|
||||
requestedTaskId: input.taskId ?? null,
|
||||
requestedTaskType: input.taskType ?? null,
|
||||
sourceContextGeneratedAt: context.generatedAt
|
||||
},
|
||||
baseInput,
|
||||
output
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ type SeoIntentType =
|
|||
type SeoMarketRole = "commercial" | "core" | "exclude" | "integration" | "problem" | "support";
|
||||
type SeoReviewStatus = "auto_approved" | "needs_human" | "rejected";
|
||||
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
||||
type SeoNormalizationProviderMode = "codex_manual" | "deterministic_fallback";
|
||||
type SeoNormalizationProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
|
||||
type SeoNormalizationRunRow = {
|
||||
id: string;
|
||||
|
|
@ -1047,3 +1047,42 @@ export async function saveSeoNormalizationManual(
|
|||
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function saveSeoNormalizationFromModelProvider(
|
||||
projectId: string,
|
||||
output: SeoNormalizationContract,
|
||||
sourceModelTaskRunId: string
|
||||
): Promise<SeoNormalizationContract> {
|
||||
const normalizedOutput = normalizeSeoNormalizationOutput(
|
||||
projectId,
|
||||
output,
|
||||
"codex_workspace",
|
||||
"AI Workspace Codex provider выполнил seo.normalization по contract-only task; результат сохранён как evidence после backend validation."
|
||||
);
|
||||
const result = await pool.query<SeoNormalizationRunRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
values ($1, 'seo_normalization', 'done', $2::jsonb, $3::jsonb, now(), now())
|
||||
returning id, status, input, output, error_message, started_at, completed_at, created_at;
|
||||
`,
|
||||
[
|
||||
projectId,
|
||||
JSON.stringify({
|
||||
providerMode: "codex_workspace",
|
||||
schemaVersion: "seo-normalization-model-provider-input.v1",
|
||||
semanticRunId: normalizedOutput.semanticRunId,
|
||||
sourceModelTaskRunId,
|
||||
sourceTaskId: normalizedOutput.sourceTaskId,
|
||||
taskType: "seo.normalization"
|
||||
}),
|
||||
JSON.stringify(normalizedOutput)
|
||||
]
|
||||
);
|
||||
const run = result.rows[0] ? mapSeoNormalizationRun(result.rows[0]) : null;
|
||||
|
||||
if (!run) {
|
||||
throw new Error("Не удалось сохранить AI Workspace SEO normalization.");
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { env } from "../config/env.js";
|
||||
import { pool } from "../db/client.js";
|
||||
import { getSeoAiWorkspaceConfigStatus } from "../modelProvider/aiWorkspaceBridgeConfig.js";
|
||||
|
||||
export type ExternalServiceId =
|
||||
| "seo_analytics_model"
|
||||
| "yandex_cloud"
|
||||
| "yandex_metrica"
|
||||
| "yandex_search_serp"
|
||||
|
|
@ -91,6 +93,7 @@ export type WordstatRuntimeConfig =
|
|||
provider: "mcp_kv";
|
||||
mode: "mcp_kv";
|
||||
mcpEndpoint: string;
|
||||
maxSeedsPerRun: number;
|
||||
}
|
||||
| {
|
||||
provider: "yandex_api";
|
||||
|
|
@ -102,6 +105,7 @@ export type WordstatRuntimeConfig =
|
|||
regionIds: string[];
|
||||
devices: string[];
|
||||
numPhrases: number;
|
||||
maxSeedsPerRun: number;
|
||||
};
|
||||
|
||||
const WORDSTAT_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/wordstat";
|
||||
|
|
@ -110,6 +114,30 @@ const WEBMASTER_BASE_URL = "https://api.webmaster.yandex.net/v4";
|
|||
const METRICA_BASE_URL = "https://api-metrika.yandex.net";
|
||||
|
||||
const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = {
|
||||
seo_analytics_model: {
|
||||
id: "seo_analytics_model",
|
||||
label: "Аналитическая модель",
|
||||
description:
|
||||
"Model-provider для SEO task contracts через AI Workspace surface seo-mode. Сейчас доступен Codex bridge без доступа к Yandex credentials, исходникам и apply/write actions.",
|
||||
implementationStatus: "active",
|
||||
defaultMode: "local_codex",
|
||||
modes: [
|
||||
{ id: "disabled", label: "Отключено" },
|
||||
{ id: "local_codex", label: "Codex bridge" }
|
||||
],
|
||||
docs: [],
|
||||
publicFields: [
|
||||
{
|
||||
id: "executorId",
|
||||
label: "AI Workspace executor",
|
||||
type: "text",
|
||||
required: false,
|
||||
placeholder: "generated after setup command",
|
||||
help: "Stored SEO-only executor id. Hidden in UI; used to avoid global Assistant selected-executor fallback."
|
||||
}
|
||||
],
|
||||
secretFields: []
|
||||
},
|
||||
yandex_cloud: {
|
||||
id: "yandex_cloud",
|
||||
label: "Yandex AI Studio / Cloud API key",
|
||||
|
|
@ -223,6 +251,14 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
|
|||
placeholder: "50",
|
||||
help: "Сколько Wordstat rows запрашивать для одной seed-фразы."
|
||||
},
|
||||
{
|
||||
id: "maxSeedsPerRun",
|
||||
label: "Max seeds per run",
|
||||
type: "number",
|
||||
required: false,
|
||||
placeholder: "40",
|
||||
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
|
||||
},
|
||||
{
|
||||
id: "mcpEndpoint",
|
||||
label: "MCP endpoint",
|
||||
|
|
@ -386,6 +422,7 @@ function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) {
|
|||
apiBaseUrl: WORDSTAT_BASE_URL,
|
||||
devices: "DEVICE_ALL",
|
||||
numPhrases: "50",
|
||||
maxSeedsPerRun: "40",
|
||||
regionIds: "225"
|
||||
};
|
||||
|
||||
|
|
@ -446,6 +483,16 @@ function getMissingFields(
|
|||
|
||||
const missing: string[] = [];
|
||||
|
||||
if (definition.id === "seo_analytics_model") {
|
||||
const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus();
|
||||
|
||||
if (!aiWorkspaceStatus.configured) {
|
||||
return ["aiWorkspaceProfile"];
|
||||
}
|
||||
|
||||
return publicConfig.executorId?.trim() ? [] : ["executorId"];
|
||||
}
|
||||
|
||||
for (const field of definition.publicFields) {
|
||||
if (field.id === "mcpEndpoint" && mode !== "mcp_kv") {
|
||||
continue;
|
||||
|
|
@ -681,6 +728,19 @@ export async function updateExternalServiceSettings(
|
|||
return mapRow(definition, row, dependencyStatuses);
|
||||
}
|
||||
|
||||
export async function getSeoAnalyticsModelRuntimeConfig() {
|
||||
const rows = await getSettingsRows();
|
||||
const row = rows.get("seo_analytics_model") ?? null;
|
||||
const definition = serviceDefinitions.seo_analytics_model;
|
||||
const publicConfig = getPublicConfig(definition, row);
|
||||
|
||||
return {
|
||||
enabled: Boolean(row?.enabled && row.mode === "local_codex"),
|
||||
executorId: publicConfig.executorId?.trim() || null,
|
||||
mode: row?.mode ?? "disabled"
|
||||
};
|
||||
}
|
||||
|
||||
function splitCsv(value: string | undefined, fallback: string[]) {
|
||||
const values = (value ?? "")
|
||||
.split(",")
|
||||
|
|
@ -711,7 +771,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
|||
return {
|
||||
provider: "mcp_kv",
|
||||
mode: "mcp_kv",
|
||||
mcpEndpoint: endpoint
|
||||
mcpEndpoint: endpoint,
|
||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -731,7 +792,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
|||
folderId: cloudPublicConfig.folderId,
|
||||
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
|
||||
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
|
||||
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50)
|
||||
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
|
||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -739,7 +801,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
|||
return {
|
||||
provider: "mcp_kv",
|
||||
mode: "mcp_kv",
|
||||
mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint
|
||||
mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint,
|
||||
maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -758,7 +821,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
|||
folderId: env.marketProviders.wordstat.folderId,
|
||||
regionIds: env.marketProviders.wordstat.regionIds,
|
||||
devices: env.marketProviders.wordstat.devices,
|
||||
numPhrases: env.marketProviders.wordstat.numPhrases
|
||||
numPhrases: env.marketProviders.wordstat.numPhrases,
|
||||
maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,16 @@ import {
|
|||
updateExternalServiceSettings,
|
||||
type ExternalServiceId
|
||||
} from "./externalServiceSettings.js";
|
||||
import {
|
||||
createSeoAnalyticsModelSetupCommand,
|
||||
probeSeoAnalyticsModel
|
||||
} from "./seoAnalyticsModelBridge.js";
|
||||
import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js";
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
const externalServiceIdSchema = z.enum([
|
||||
"seo_analytics_model",
|
||||
"yandex_cloud",
|
||||
"yandex_metrica",
|
||||
"yandex_search_serp",
|
||||
|
|
@ -27,6 +32,13 @@ const updateExternalServiceSettingsSchema = z.object({
|
|||
const yandexAiStudioProbeSchema = z.object({
|
||||
phrase: z.string().trim().min(2).max(160).optional()
|
||||
});
|
||||
const seoAnalyticsModelSetupCommandSchema = z.object({
|
||||
executorId: z.string().uuid().optional(),
|
||||
port: z.coerce.number().int().positive().max(65535).optional()
|
||||
});
|
||||
const seoAnalyticsModelProbeSchema = z.object({
|
||||
executorId: z.string().uuid().optional()
|
||||
});
|
||||
|
||||
function sendError(response: Response, status: number, message: string) {
|
||||
response.status(status).json({
|
||||
|
|
@ -65,6 +77,42 @@ settingsRouter.post("/external-services/yandex_cloud/probe", async (request, res
|
|||
}
|
||||
});
|
||||
|
||||
settingsRouter.post("/external-services/seo_analytics_model/setup-command", async (request, response) => {
|
||||
try {
|
||||
const input = seoAnalyticsModelSetupCommandSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
setupCommand: await createSeoAnalyticsModelSetupCommand(input)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры setup command.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось подготовить Codex bridge setup command.");
|
||||
}
|
||||
});
|
||||
|
||||
settingsRouter.post("/external-services/seo_analytics_model/probe", async (request, response) => {
|
||||
try {
|
||||
const input = seoAnalyticsModelProbeSchema.parse(request.body ?? {});
|
||||
|
||||
response.json({
|
||||
probe: await probeSeoAnalyticsModel(input)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры probe.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось проверить Codex bridge.");
|
||||
}
|
||||
});
|
||||
|
||||
settingsRouter.patch("/external-services/:serviceId", async (request, response) => {
|
||||
try {
|
||||
const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
||||
function normalizePhraseKey(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU");
|
||||
}
|
||||
|
||||
function getConfidence(score: number): SeoStrategyConfidence {
|
||||
if (score >= 75) {
|
||||
return "high";
|
||||
|
|
@ -953,14 +957,22 @@ function buildOpportunities(
|
|||
|
||||
function buildSerpOpportunities(
|
||||
serpInterpretation: SerpInterpretationContract,
|
||||
keywordMap: KeywordMapContract,
|
||||
siteMaturity: SeoStrategyContract["siteMaturity"]
|
||||
): SeoStrategyContract["opportunities"] {
|
||||
if (serpInterpretation.state !== "ready") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const approvedPhraseSet = new Set(keywordMap.persisted.items.map((item) => normalizePhraseKey(item.phrase)));
|
||||
|
||||
return serpInterpretation.phraseInterpretations
|
||||
.filter((phrase) => phrase.confidence !== "low" && phrase.intent !== "unknown")
|
||||
.filter(
|
||||
(phrase) =>
|
||||
phrase.confidence !== "low" &&
|
||||
phrase.intent !== "unknown" &&
|
||||
approvedPhraseSet.has(normalizePhraseKey(phrase.phrase))
|
||||
)
|
||||
.slice(0, 6)
|
||||
.map((phrase) => {
|
||||
const isMismatch = phrase.targetFit === "mismatch";
|
||||
|
|
@ -1336,7 +1348,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
|||
const phasePlan = buildPhasePlan(siteMaturity);
|
||||
const opportunities = [
|
||||
...buildOpportunities(cleaning, keywordMap, siteMaturity),
|
||||
...buildSerpOpportunities(serpInterpretation, siteMaturity)
|
||||
...buildSerpOpportunities(serpInterpretation, keywordMap, siteMaturity)
|
||||
];
|
||||
const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation);
|
||||
const risks = buildRisks({
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { getLatestStrategySynthesisContract, type StrategySynthesisContract } fr
|
|||
import { buildStrategyQualityReviewModelTask } from "./strategyQualityReviewTask.js";
|
||||
|
||||
type RunStatus = "cancelled" | "done" | "failed" | "queued" | "running";
|
||||
type StrategyQualityReviewProviderMode = "codex_manual" | "deterministic_fallback";
|
||||
type StrategyQualityReviewProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
type StrategyQualityReviewState = "not_ready" | "ready";
|
||||
type StrategyQualityReviewVerdictStatus = "approved" | "blocked" | "needs_changes";
|
||||
type StrategyQualityReviewCheckStatus = "fail" | "pass" | "warning";
|
||||
|
|
@ -112,6 +112,161 @@ function getDefaultAnalystReview(): StrategyQualityReviewContract["analystReview
|
|||
};
|
||||
}
|
||||
|
||||
function isVerdictStatus(value: unknown): value is StrategyQualityReviewVerdictStatus {
|
||||
return value === "approved" || value === "blocked" || value === "needs_changes";
|
||||
}
|
||||
|
||||
function isConfidence(value: unknown): value is StrategyQualityReviewContract["verdict"]["confidence"] {
|
||||
return value === "high" || value === "medium" || value === "low";
|
||||
}
|
||||
|
||||
function normalizeCheckStatus(value: unknown): StrategyQualityReviewCheckStatus {
|
||||
if (value === "pass" || value === "passed") return "pass";
|
||||
if (value === "fail" || value === "failed") return "fail";
|
||||
if (value === "warning" || value === "warn") return "warning";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
function normalizeCheckSeverity(value: unknown): StrategyQualityReviewSeverity {
|
||||
if (value === "critical" || value === "high" || value === "medium" || value === "low") {
|
||||
return value;
|
||||
}
|
||||
|
||||
return "low";
|
||||
}
|
||||
|
||||
function normalizeCheckCategory(value: unknown): StrategyQualityReviewCategory {
|
||||
const category = String(value || "").trim();
|
||||
|
||||
if (
|
||||
category === "business_drift" ||
|
||||
category === "evidence_integrity" ||
|
||||
category === "hallucination_guardrail" ||
|
||||
category === "phase_guardrail" ||
|
||||
category === "source_safety" ||
|
||||
category === "ux_clarity"
|
||||
) {
|
||||
return category;
|
||||
}
|
||||
|
||||
if (category === "business_scope") return "business_drift";
|
||||
if (category === "evidence" || category === "evidence_gap") return "evidence_integrity";
|
||||
if (category === "phase" || category === "phase_violation") return "phase_guardrail";
|
||||
if (category === "guardrail" || category === "intent") return "hallucination_guardrail";
|
||||
if (category === "input_state") return "ux_clarity";
|
||||
|
||||
return "source_safety";
|
||||
}
|
||||
|
||||
function normalizeModelProviderChecks(value: unknown, fallback: StrategyQualityReviewContract["checks"]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return value.map((item, index) => {
|
||||
const record = (item && typeof item === "object" ? item : {}) as Record<string, unknown>;
|
||||
|
||||
return makeCheck({
|
||||
action: typeof record.action === "string" && record.action.trim() ? record.action.trim() : "Проверить пункт quality gate вручную.",
|
||||
category: normalizeCheckCategory(record.category),
|
||||
evidenceRefs: Array.isArray(record.evidenceRefs) ? record.evidenceRefs.map((ref) => String(ref || "").trim()).filter(Boolean) : [],
|
||||
id: typeof record.id === "string" && record.id.trim() ? record.id.trim() : `model-check:${index + 1}`,
|
||||
message: typeof record.message === "string" && record.message.trim() ? record.message.trim() : "Модель вернула проверку без описания.",
|
||||
severity: normalizeCheckSeverity(record.severity),
|
||||
status: normalizeCheckStatus(record.status),
|
||||
title: typeof record.title === "string" && record.title.trim() ? record.title.trim() : `Проверка ${index + 1}`
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stringList(value: unknown, fallback: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const result = value.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return item.trim();
|
||||
}
|
||||
|
||||
if (item && typeof item === "object") {
|
||||
const record = item as Record<string, unknown>;
|
||||
|
||||
for (const key of ["title", "message", "action", "summary", "nextStep", "label"]) {
|
||||
const text = record[key];
|
||||
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(item || "").trim();
|
||||
}).filter(Boolean);
|
||||
|
||||
return result.length > 0 ? result : fallback;
|
||||
}
|
||||
|
||||
function mergeModelProviderStrategyQualityReviewOutput(
|
||||
output: StrategyQualityReviewContract,
|
||||
fallback: StrategyQualityReviewContract
|
||||
): StrategyQualityReviewContract {
|
||||
const rawOutput = output as StrategyQualityReviewContract & Record<string, any>;
|
||||
const rawVerdict = rawOutput.verdict;
|
||||
const verdict = (rawVerdict && typeof rawVerdict === "object" ? rawVerdict : {}) as Record<string, any>;
|
||||
const status = isVerdictStatus(rawVerdict) ? rawVerdict : isVerdictStatus(verdict.status) ? verdict.status : fallback.verdict.status;
|
||||
const blockers = stringList(rawOutput.blockers, fallback.blockers);
|
||||
const warnings = stringList(rawOutput.warnings, fallback.warnings);
|
||||
const summary =
|
||||
typeof rawOutput.summary === "string" && rawOutput.summary.trim()
|
||||
? rawOutput.summary.trim()
|
||||
: typeof verdict.summary === "string" && verdict.summary.trim()
|
||||
? verdict.summary.trim()
|
||||
: fallback.summary;
|
||||
const score = Number(verdict.score ?? rawOutput.readiness?.score ?? fallback.verdict.score);
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
...output,
|
||||
analystReview: output.analystReview ?? fallback.analystReview,
|
||||
approvedSignals: stringList(rawOutput.approvedSignals, fallback.approvedSignals),
|
||||
blockers,
|
||||
checks: normalizeModelProviderChecks(rawOutput.checks, fallback.checks),
|
||||
nextActions: stringList(rawOutput.nextActions, fallback.nextActions),
|
||||
readiness: {
|
||||
...fallback.readiness,
|
||||
...(rawOutput.readiness && typeof rawOutput.readiness === "object" ? rawOutput.readiness : {})
|
||||
},
|
||||
runId: output.runId ?? fallback.runId,
|
||||
semanticRunId: output.semanticRunId ?? fallback.semanticRunId,
|
||||
sourceSerpInterpretationRunId: output.sourceSerpInterpretationRunId ?? fallback.sourceSerpInterpretationRunId,
|
||||
sourceStrategyGeneratedAt: output.sourceStrategyGeneratedAt ?? fallback.sourceStrategyGeneratedAt,
|
||||
sourceStrategySynthesisRunId: output.sourceStrategySynthesisRunId ?? fallback.sourceStrategySynthesisRunId,
|
||||
sourceTaskId: output.sourceTaskId ?? fallback.sourceTaskId,
|
||||
state: output.state ?? fallback.state,
|
||||
summary,
|
||||
verdict: {
|
||||
confidence: isConfidence(verdict.confidence) ? verdict.confidence : fallback.verdict.confidence,
|
||||
primaryBlocker:
|
||||
typeof verdict.primaryBlocker === "string" && verdict.primaryBlocker.trim()
|
||||
? verdict.primaryBlocker.trim()
|
||||
: blockers[0] ?? fallback.verdict.primaryBlocker,
|
||||
score: Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score))) : fallback.verdict.score,
|
||||
status,
|
||||
summary,
|
||||
title:
|
||||
typeof verdict.title === "string" && verdict.title.trim()
|
||||
? verdict.title.trim()
|
||||
: status === "approved"
|
||||
? "Стратегия прошла проверку"
|
||||
: status === "needs_changes"
|
||||
? "Стратегия требует исправлений"
|
||||
: fallback.verdict.title
|
||||
},
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
function mapStrategyQualityReviewRun(row: StrategyQualityReviewRunRow): StrategyQualityReviewRun | null {
|
||||
if (!row.output) {
|
||||
return null;
|
||||
|
|
@ -224,9 +379,18 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis
|
|||
const hasApplyLeak = [...synthesis.recommendedPath.doNow, ...synthesis.nextActions].some((action) =>
|
||||
/запустить rewrite|запустить apply|apply changes|source mutation|создать правк|применить|изменить исходник/i.test(action)
|
||||
);
|
||||
const hasExpansionLeak = synthesis.decisionOptions.some(
|
||||
(option) => option.scenario === "serp_expansion" && option.confidence !== "low" && strategy.siteMaturity.phase === "bootstrap_indexing"
|
||||
const expansionOptions = synthesis.decisionOptions.filter((option) => option.scenario === "serp_expansion");
|
||||
const expansionCurrentRoute = expansionOptions.some((option) => option.id === synthesis.recommendedPath.id);
|
||||
const expansionActionPattern = /расширени|расширить|соседн|expansion/i;
|
||||
const expansionBlockedPattern = /не |нельзя|запрет|отлож|будущ|после|без /i;
|
||||
const hasExpansionDoNow = [...synthesis.recommendedPath.doNow, ...synthesis.phaseDecision.allowedNow].some(
|
||||
(action) => expansionActionPattern.test(action) && !expansionBlockedPattern.test(action)
|
||||
);
|
||||
const hasExpansionLeak = strategy.siteMaturity.phase === "bootstrap_indexing" && (expansionCurrentRoute || hasExpansionDoNow);
|
||||
const hasFutureExpansionBranch =
|
||||
!hasExpansionLeak &&
|
||||
strategy.siteMaturity.phase === "bootstrap_indexing" &&
|
||||
expansionOptions.some((option) => option.confidence !== "low" || option.priority !== "low" || option.expectedImpact !== "low");
|
||||
const hasPhaseDecision = synthesis.phaseDecision.currentPhase === strategy.siteMaturity.phase && synthesis.phaseDecision.hold.length > 0;
|
||||
const hasReadableNextStep = synthesis.recommendedPath.doNow.length > 0 && synthesis.recommendedPath.hold.length > 0;
|
||||
|
||||
|
|
@ -288,14 +452,18 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis
|
|||
makeCheck({
|
||||
action: hasExpansionLeak
|
||||
? "Понизить доверие у ветки расширения или вынести её в отдельную ветку после подтверждения пользователем."
|
||||
: "Оставить расширение только как будущую ветку с низким доверием.",
|
||||
: hasFutureExpansionBranch
|
||||
? "Оставить расширение отдельной будущей веткой и не показывать его как текущее действие."
|
||||
: "Оставить расширение только как будущую ветку с низким доверием.",
|
||||
category: "business_drift",
|
||||
id: "business:no_core_expansion_leak",
|
||||
message: hasExpansionLeak
|
||||
? "Ветка расширения выглядит как основной маршрут для стартовой фазы."
|
||||
: "Расширение бизнеса не смешано с текущим основным SEO-маршрутом.",
|
||||
: hasFutureExpansionBranch
|
||||
? "Расширение есть как отдельная будущая ветка, но не попало в текущий маршрут."
|
||||
: "Расширение бизнеса не смешано с текущим основным SEO-маршрутом.",
|
||||
severity: hasExpansionLeak ? "high" : "low",
|
||||
status: hasExpansionLeak ? "fail" : "pass",
|
||||
status: hasExpansionLeak ? "fail" : hasFutureExpansionBranch ? "warning" : "pass",
|
||||
title: "Нет смешения с расширением бизнеса"
|
||||
}),
|
||||
makeCheck({
|
||||
|
|
@ -503,6 +671,8 @@ function normalizeOutput(
|
|||
const blockers = output.checks.filter((check) => check.status === "fail").map((check) => `${check.title}: ${check.action}`);
|
||||
const warnings = output.checks.filter((check) => check.status === "warning").map((check) => `${check.title}: ${check.action}`);
|
||||
const approvedSignals = output.checks.filter((check) => check.status === "pass").map((check) => check.title);
|
||||
const score = getScore(output.checks);
|
||||
const summary = `${getVerdictStatusSummary(output.verdict.status)}: проверок ${output.checks.length}, пройдено ${approvedSignals.length}, блокеров ${blockers.length}, предупреждений ${warnings.length}.`;
|
||||
|
||||
return {
|
||||
...output,
|
||||
|
|
@ -524,11 +694,18 @@ function normalizeOutput(
|
|||
issueCount: blockers.length + warnings.length,
|
||||
passedCount: approvedSignals.length,
|
||||
phaseGuardrailIssueCount: output.checks.filter((check) => check.category === "phase_guardrail" && check.status !== "pass").length,
|
||||
score: output.verdict.score,
|
||||
score,
|
||||
uxIssueCount: output.checks.filter((check) => check.category === "ux_clarity" && check.status !== "pass").length,
|
||||
warningCount: warnings.length
|
||||
},
|
||||
summary,
|
||||
state: output.checks.length > 0 ? "ready" : "not_ready",
|
||||
verdict: {
|
||||
...output.verdict,
|
||||
primaryBlocker: blockers[0] ?? output.verdict.primaryBlocker,
|
||||
score,
|
||||
summary
|
||||
},
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
|
@ -619,3 +796,54 @@ export async function saveStrategyQualityReviewManual(
|
|||
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function saveStrategyQualityReviewFromModelProvider(
|
||||
projectId: string,
|
||||
output: StrategyQualityReviewContract,
|
||||
sourceModelTaskRunId: string
|
||||
): Promise<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";
|
||||
|
||||
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
||||
type StrategySynthesisProviderMode = "codex_manual" | "deterministic_fallback";
|
||||
type StrategySynthesisProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
type StrategySynthesisState = "not_ready" | "ready";
|
||||
type StrategySynthesisConfidence = "high" | "low" | "medium";
|
||||
type StrategySynthesisPriority = "high" | "low" | "medium";
|
||||
|
|
@ -158,6 +158,142 @@ function getDefaultAnalystReview(): StrategySynthesisContract["analystReview"] {
|
|||
};
|
||||
}
|
||||
|
||||
function isConfidence(value: unknown): value is StrategySynthesisConfidence {
|
||||
return value === "high" || value === "medium" || value === "low";
|
||||
}
|
||||
|
||||
function isPriority(value: unknown): value is StrategySynthesisPriority {
|
||||
return value === "high" || value === "medium" || value === "low";
|
||||
}
|
||||
|
||||
function stringList(value: unknown, fallback: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const result = value.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return item.trim();
|
||||
}
|
||||
|
||||
if (item && typeof item === "object") {
|
||||
const record = item as Record<string, unknown>;
|
||||
|
||||
for (const key of ["title", "message", "nextStep", "action", "summary", "label"]) {
|
||||
const text = record[key];
|
||||
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(item || "").trim();
|
||||
}).filter(Boolean);
|
||||
|
||||
return result.length > 0 ? result : fallback;
|
||||
}
|
||||
|
||||
function mergeModelProviderStrategySynthesisOutput(
|
||||
output: StrategySynthesisContract,
|
||||
fallback: StrategySynthesisContract
|
||||
): StrategySynthesisContract {
|
||||
const rawOutput = output as StrategySynthesisContract & Record<string, any>;
|
||||
const executiveSummary = (rawOutput.executiveSummary ?? {}) as Record<string, any>;
|
||||
const phaseDecision = (rawOutput.phaseDecision ?? {}) as Record<string, any>;
|
||||
const recommendedPath = (rawOutput.recommendedPath ?? {}) as Record<string, any>;
|
||||
const summary =
|
||||
typeof rawOutput.summary === "string" && rawOutput.summary.trim()
|
||||
? rawOutput.summary.trim()
|
||||
: typeof executiveSummary.summary === "string" && executiveSummary.summary.trim()
|
||||
? executiveSummary.summary.trim()
|
||||
: fallback.summary;
|
||||
const executiveKeyPoints = stringList(executiveSummary.keyPoints, []);
|
||||
const blockedUntil = stringList(recommendedPath.blockedUntil, []);
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
...output,
|
||||
analystReview: output.analystReview ?? fallback.analystReview,
|
||||
blockedActions: stringList(rawOutput.blockedActions, fallback.blockedActions),
|
||||
dataGaps: Array.isArray(rawOutput.dataGaps) ? rawOutput.dataGaps : fallback.dataGaps,
|
||||
decisionOptions: Array.isArray(rawOutput.decisionOptions) ? rawOutput.decisionOptions : fallback.decisionOptions,
|
||||
evidenceNarrative: Array.isArray(rawOutput.evidenceNarrative) ? rawOutput.evidenceNarrative : fallback.evidenceNarrative,
|
||||
executiveSummary: {
|
||||
confidence: isConfidence(executiveSummary.confidence) ? executiveSummary.confidence : fallback.executiveSummary.confidence,
|
||||
primaryConstraint:
|
||||
typeof executiveSummary.primaryConstraint === "string" && executiveSummary.primaryConstraint.trim()
|
||||
? executiveSummary.primaryConstraint.trim()
|
||||
: blockedUntil[0] ?? fallback.executiveSummary.primaryConstraint,
|
||||
title:
|
||||
typeof executiveSummary.title === "string" && executiveSummary.title.trim()
|
||||
? executiveSummary.title.trim()
|
||||
: fallback.executiveSummary.title,
|
||||
verdict:
|
||||
typeof executiveSummary.verdict === "string" && executiveSummary.verdict.trim()
|
||||
? executiveSummary.verdict.trim()
|
||||
: summary,
|
||||
whyNow:
|
||||
typeof executiveSummary.whyNow === "string" && executiveSummary.whyNow.trim()
|
||||
? executiveSummary.whyNow.trim()
|
||||
: executiveKeyPoints.length > 0
|
||||
? executiveKeyPoints.join(" ")
|
||||
: fallback.executiveSummary.whyNow
|
||||
},
|
||||
generatedAt: output.generatedAt ?? fallback.generatedAt,
|
||||
nextActions: stringList(rawOutput.nextActions, fallback.nextActions),
|
||||
phaseDecision: {
|
||||
allowedNow: stringList(phaseDecision.allowedNow, fallback.phaseDecision.allowedNow),
|
||||
currentPhase: phaseDecision.currentPhase ?? fallback.phaseDecision.currentPhase,
|
||||
hold: stringList(phaseDecision.hold, stringList(phaseDecision.deferred, fallback.phaseDecision.hold)),
|
||||
phaseRoadmap: Array.isArray(phaseDecision.phaseRoadmap) ? phaseDecision.phaseRoadmap : fallback.phaseDecision.phaseRoadmap,
|
||||
prepareNext: stringList(phaseDecision.prepareNext, fallback.phaseDecision.prepareNext),
|
||||
promotionSignals: stringList(phaseDecision.promotionSignals, fallback.phaseDecision.promotionSignals),
|
||||
schemaVersion: "seo-phase-decision.v1",
|
||||
summary:
|
||||
typeof phaseDecision.summary === "string" && phaseDecision.summary.trim()
|
||||
? phaseDecision.summary.trim()
|
||||
: typeof phaseDecision.reason === "string" && phaseDecision.reason.trim()
|
||||
? phaseDecision.reason.trim()
|
||||
: fallback.phaseDecision.summary,
|
||||
title:
|
||||
typeof phaseDecision.title === "string" && phaseDecision.title.trim()
|
||||
? phaseDecision.title.trim()
|
||||
: fallback.phaseDecision.title
|
||||
},
|
||||
phaseStrategies: Array.isArray(rawOutput.phaseStrategies) ? rawOutput.phaseStrategies : fallback.phaseStrategies,
|
||||
readiness: {
|
||||
...fallback.readiness,
|
||||
...(rawOutput.readiness && typeof rawOutput.readiness === "object" ? rawOutput.readiness : {})
|
||||
},
|
||||
recommendedPath: {
|
||||
confidence: isConfidence(recommendedPath.confidence) ? recommendedPath.confidence : fallback.recommendedPath.confidence,
|
||||
doNow: stringList(recommendedPath.doNow, stringList(recommendedPath.currentActions, fallback.recommendedPath.doNow)),
|
||||
expectedImpact: isPriority(recommendedPath.expectedImpact) ? recommendedPath.expectedImpact : fallback.recommendedPath.expectedImpact,
|
||||
hold: stringList(recommendedPath.hold, blockedUntil.length > 0 ? blockedUntil : fallback.recommendedPath.hold),
|
||||
id: typeof recommendedPath.id === "string" && recommendedPath.id.trim() ? recommendedPath.id.trim() : fallback.recommendedPath.id,
|
||||
needsEvidence: stringList(recommendedPath.needsEvidence, blockedUntil.length > 0 ? blockedUntil : fallback.recommendedPath.needsEvidence),
|
||||
priority: isPriority(recommendedPath.priority) ? recommendedPath.priority : fallback.recommendedPath.priority,
|
||||
rationale:
|
||||
typeof recommendedPath.rationale === "string" && recommendedPath.rationale.trim()
|
||||
? recommendedPath.rationale.trim()
|
||||
: fallback.recommendedPath.rationale,
|
||||
title:
|
||||
typeof recommendedPath.title === "string" && recommendedPath.title.trim()
|
||||
? recommendedPath.title.trim()
|
||||
: fallback.recommendedPath.title
|
||||
},
|
||||
riskPosture: Array.isArray(rawOutput.riskPosture) ? rawOutput.riskPosture : fallback.riskPosture,
|
||||
runId: output.runId ?? fallback.runId,
|
||||
semanticRunId: output.semanticRunId ?? fallback.semanticRunId,
|
||||
sourceSerpInterpretationRunId: output.sourceSerpInterpretationRunId ?? fallback.sourceSerpInterpretationRunId,
|
||||
sourceStrategyGeneratedAt: output.sourceStrategyGeneratedAt ?? fallback.sourceStrategyGeneratedAt,
|
||||
sourceTaskId: output.sourceTaskId ?? fallback.sourceTaskId,
|
||||
state: output.state ?? fallback.state,
|
||||
summary
|
||||
};
|
||||
}
|
||||
|
||||
function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesisRun | null {
|
||||
if (!row.output) {
|
||||
return null;
|
||||
|
|
@ -618,3 +754,53 @@ export async function saveStrategySynthesisManual(
|
|||
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function saveStrategySynthesisFromModelProvider(
|
||||
projectId: string,
|
||||
output: StrategySynthesisContract,
|
||||
sourceModelTaskRunId: string
|
||||
): Promise<StrategySynthesisRun> {
|
||||
const [strategy, serpInterpretation] = await Promise.all([
|
||||
getSeoStrategyContract(projectId),
|
||||
getLatestSerpInterpretationContract(projectId)
|
||||
]);
|
||||
const fallbackOutput = buildStrategySynthesisOutput(projectId, strategy, serpInterpretation);
|
||||
|
||||
if (!fallbackOutput) {
|
||||
throw new Error("Не удалось собрать backend fallback для AI Workspace Strategy synthesis.");
|
||||
}
|
||||
|
||||
const normalizedOutput = normalizeOutput(
|
||||
projectId,
|
||||
mergeModelProviderStrategySynthesisOutput(output, fallbackOutput),
|
||||
"codex_workspace",
|
||||
"AI Workspace Codex provider выполнил seo.strategy_synthesis по contract-only task; результат сохранён как evidence после backend validation."
|
||||
);
|
||||
const result = await pool.query<StrategySynthesisRunRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
values ($1, 'seo_strategy_synthesis', 'done', $2::jsonb, $3::jsonb, now(), now())
|
||||
returning id, status, input, output, error_message, started_at, completed_at, created_at;
|
||||
`,
|
||||
[
|
||||
projectId,
|
||||
JSON.stringify({
|
||||
providerMode: "codex_workspace",
|
||||
schemaVersion: "seo-strategy-synthesis-model-provider-input.v1",
|
||||
sourceModelTaskRunId,
|
||||
sourceSerpInterpretationRunId: normalizedOutput.sourceSerpInterpretationRunId,
|
||||
sourceStrategyGeneratedAt: normalizedOutput.sourceStrategyGeneratedAt,
|
||||
sourceTaskId: normalizedOutput.sourceTaskId,
|
||||
taskType: "seo.strategy_synthesis"
|
||||
}),
|
||||
JSON.stringify(normalizedOutput)
|
||||
]
|
||||
);
|
||||
const run = result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null;
|
||||
|
||||
if (!run) {
|
||||
throw new Error("Не удалось сохранить AI Workspace Strategy synthesis.");
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue