Compare commits

...

4 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS a8f03570c6 Stabilize SEO demand workflow UX 2026-07-03 12:32:08 +03:00
DCCONSTRUCTIONS a5a680bf00 fix(seo): block stale keyword strategy gates 2026-07-03 09:21:26 +03:00
DCCONSTRUCTIONS 43d2af454d fix(seo): guard keyword map semantic fit 2026-07-03 09:05:01 +03:00
DCCONSTRUCTIONS 1ed099140f feat(seo): wire AI workspace model flow 2026-07-03 08:43:37 +03:00
23 changed files with 4986 additions and 319 deletions

View File

@ -19,9 +19,33 @@ WORDSTAT_PROVIDER=disabled
# WORDSTAT_REGION_IDS=225
# WORDSTAT_DEVICES=DEVICE_ALL
# WORDSTAT_NUM_PHRASES=50
# WORDSTAT_MAX_SEEDS_PER_RUN=40
SERP_PROVIDER=disabled
# SERP_API_BASE_URL=https://serp-provider.internal
# SERP_API_TOKEN=change-me
# SEO model-provider bridge through AI Workspace Assistant / AI Hub / remote Codex worker.
# Keep disabled until a contract-only worker workspace and explicit owner/profile are configured.
# Profiles:
# - local: local AI Workspace Assistant at 127.0.0.1/localhost.
# - deployed-hub: local SEO backend uses deployed https://ai-hub.nodedc.ru token-gated control-plane proxy.
# - deploy-host: SEO backend runs inside deployed Docker network and talks to http://ai-workspace-assistant:18082.
# - tunnel-local-e2e: explicit tunnel/Tailscale end-to-end test profile.
SEO_AI_WORKSPACE_PROFILE=disabled
# SEO_AI_WORKSPACE_CONTROL_URL=http://127.0.0.1:18082
# SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru
# SEO_AI_WORKSPACE_CONTROL_TOKEN=
# Legacy aliases accepted:
# SEO_AI_WORKSPACE_ASSISTANT_URL=
# SEO_AI_WORKSPACE_ASSISTANT_TOKEN=
# SEO_AI_WORKSPACE_OWNER_USER_ID=
# SEO_AI_WORKSPACE_OWNER_EMAIL=
# SEO_AI_WORKSPACE_OWNER_ROLE=
# SEO_AI_WORKSPACE_OWNER_GROUPS=
# SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID=
# SEO_AI_WORKSPACE_CONTRACT_ONLY=1
# SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH=/tmp/nodedc-seo-model-contracts
# SEO_AI_WORKSPACE_TIMEOUT_MS=15000
VITE_API_BASE_URL=http://localhost:4100

File diff suppressed because it is too large Load Diff

View File

@ -910,6 +910,27 @@ export type MarketEnrichmentContract = {
projectOntologyVersionId: string | null;
}>;
wordstat: {
quota: {
provider: string;
mode: string;
limitPerHour: number;
limitPerSecond: number;
usedInWindow: number;
remainingInWindow: number;
windowMinutes: number;
windowStartedAt: string | null;
resetAt: string | null;
nextAvailableAt: string;
calculatedAt: string;
utilizationPercent: number;
releaseSchedule: Array<{
jobId: string;
status: "queued" | "running" | "done" | "failed" | "cancelled";
requestCount: number;
createdAt: string;
releasesAt: string;
}>;
} | null;
latestJob: {
id: string;
status: "queued" | "running" | "done" | "failed" | "cancelled";
@ -934,6 +955,10 @@ export type MarketEnrichmentContract = {
frequencyGroup: string | null;
region: string | null;
relatedCount: number;
clusterId: string | null;
clusterTitle: string | null;
priority: "high" | "medium" | "low" | null;
seedSource: "detected" | "ontology" | string | null;
collectedAt: string;
}>;
topResults: Array<{
@ -943,6 +968,10 @@ export type MarketEnrichmentContract = {
frequency: number | null;
frequencyGroup: string | null;
region: string | null;
sourceClusterId: string | null;
sourceClusterTitle: string | null;
sourcePriority: "high" | "medium" | "low" | null;
sourceSeedSource: "detected" | "ontology" | string | null;
}>;
summary: {
collectedSeedCount: number;
@ -1100,6 +1129,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 +1207,8 @@ export type SeoModelTaskRun = {
inputTokenEstimate: number;
outputTokenBudget: number;
};
modelOutput: null;
persistedResult: null;
modelOutput: SeoModelTaskModelOutput | null;
persistedResult: SeoModelTaskPersistedResult | null;
nextActions: string[];
};
@ -2034,14 +2104,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 +2195,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 +3262,38 @@ export async function probeYandexAiStudioCredential(phrase?: string) {
return response.json() as Promise<{ probe: YandexAiStudioProbe }>;
}
export async function createSeoAnalyticsModelSetupCommand(input: { executorId?: string; port?: number } = {}) {
const response = await fetch(`${apiBaseUrl}/settings/external-services/seo_analytics_model/setup-command`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(input)
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось подготовить Codex bridge setup command: ${response.status}`));
}
return response.json() as Promise<{ setupCommand: SeoAnalyticsModelSetupCommand }>;
}
export async function probeSeoAnalyticsModel(input: { executorId?: string } = {}) {
const response = await fetch(`${apiBaseUrl}/settings/external-services/seo_analytics_model/probe`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(input)
});
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось проверить Codex bridge: ${response.status}`));
}
return response.json() as Promise<{ probe: SeoAnalyticsModelProbe }>;
}
export async function updateProjectPageSelection(projectId: string, pageIds: string[], selected: boolean) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/selection`, {
method: "PATCH",

View File

@ -522,6 +522,67 @@ input {
font-weight: 780;
}
.service-command-panel {
display: grid;
gap: 8px;
padding: 10px;
background: rgba(30, 30, 30, 0.04);
border: 1px solid var(--line);
border-radius: 8px;
}
.service-command-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.service-command-head strong {
color: var(--ink);
font-size: 12px;
font-weight: 900;
}
.service-command-head span {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 0 8px;
color: #70521f;
background: rgba(112, 82, 31, 0.07);
border: 1px solid var(--line);
border-radius: 999px;
font-size: 11px;
font-weight: 900;
}
.service-command-head span.ok {
color: #2f6b4f;
background: rgba(47, 107, 79, 0.08);
}
.service-command-panel pre {
max-height: 118px;
margin: 0;
overflow: auto;
padding: 9px;
color: #f5f5f5;
background: #1f2428;
border-radius: 7px;
font-size: 12px;
line-height: 1.45;
white-space: pre-wrap;
word-break: break-word;
}
.service-command-panel small {
color: var(--muted);
font-size: 12px;
font-weight: 680;
line-height: 1.35;
}
.service-card-footer {
align-items: center;
padding-top: 2px;
@ -2858,6 +2919,41 @@ input {
overflow-wrap: anywhere;
}
.keyword-stage-heading .keyword-stage-run-date {
color: var(--ink);
}
.keyword-stage-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.keyword-stage-icon-action {
display: grid;
width: 3rem;
height: 3rem;
place-items: center;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.9);
color: rgba(10, 12, 14, 0.84);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.94), 0 10px 30px rgba(24, 32, 29, 0.08);
cursor: pointer;
}
.keyword-stage-icon-action:hover {
background: rgba(24, 24, 24, 0.94);
color: #fff;
}
.keyword-stage-icon-action:disabled {
cursor: not-allowed;
opacity: 0.52;
}
.anchor-review-workspace {
background: #fff;
}
@ -2897,13 +2993,14 @@ input {
}
.keyword-curation-board {
--keyword-curation-radius: var(--launcher-radius-card, 1.35rem);
display: grid;
gap: 12px;
min-width: 0;
padding: 1rem;
background: #fff;
border: 0;
border-radius: var(--launcher-radius-card, 1.35rem);
border-radius: var(--keyword-curation-radius);
box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055);
}
@ -2926,9 +3023,9 @@ input {
display: inline-flex;
align-items: center;
padding: 0 9px;
color: #2e5a67;
background: rgba(46, 90, 103, 0.08);
border: 1px solid rgba(46, 90, 103, 0.14);
color: rgba(8, 8, 10, 0.78);
background: rgba(8, 8, 10, 0.07);
border: 0;
border-radius: 999px;
font-size: 11px;
font-weight: 900;
@ -2974,15 +3071,15 @@ input {
padding: 8px;
background: rgba(24, 32, 29, 0.035);
border: 1px solid rgba(24, 32, 29, 0.07);
border-radius: 8px;
border-radius: var(--keyword-curation-radius);
transition:
background-color 140ms ease,
border-color 140ms ease;
}
.keyword-curation-column.over {
background: rgba(46, 90, 103, 0.07);
border-color: rgba(46, 90, 103, 0.22);
background: rgba(8, 8, 10, 0.055);
border-color: rgba(8, 8, 10, 0.18);
}
.keyword-curation-column-head {
@ -3043,7 +3140,7 @@ input {
padding: 10px;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(24, 32, 29, 0.08);
border-radius: 8px;
border-radius: var(--keyword-curation-radius);
box-shadow: 0 10px 24px rgba(24, 32, 29, 0.08);
user-select: none;
}
@ -3072,7 +3169,7 @@ input {
color: var(--muted);
background: rgba(255, 255, 255, 0.52);
border: 1px dashed rgba(24, 32, 29, 0.14);
border-radius: 8px;
border-radius: var(--keyword-curation-radius);
font-size: 12px;
font-weight: 820;
text-align: center;
@ -14092,6 +14189,7 @@ body:has(.seo-launcher-shell) {
/* Work panels: the header is part of the same surface, not a separate card. */
.seo-launcher-shell.project-open .topbar {
position: relative !important;
display: grid !important;
grid-template-columns: minmax(0, 1fr) auto !important;
align-items: center !important;
@ -14153,6 +14251,84 @@ body:has(.seo-launcher-shell) {
gap: 0.6rem;
}
.wordstat-quota-strip {
--wordstat-quota-left: 100%;
display: grid;
width: clamp(15rem, 24vw, 22rem);
min-width: 13.5rem;
gap: 0.24rem;
color: rgba(8, 8, 10, 0.72);
}
.seo-stage-5 .wordstat-quota-strip {
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
transform: translate(-50%, -50%);
}
.wordstat-quota-head,
.wordstat-quota-meta {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.44rem;
}
.wordstat-quota-head span,
.wordstat-quota-meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.wordstat-quota-head span {
color: rgba(8, 8, 10, 0.54);
font-size: 0.68rem;
font-weight: 860;
text-transform: uppercase;
}
.wordstat-quota-head strong {
color: rgba(8, 8, 10, 0.92);
font-size: 0.78rem;
font-weight: 920;
line-height: 1;
}
.wordstat-quota-bar {
position: relative;
height: 0.27rem;
overflow: hidden;
border-radius: 999px;
background: rgba(8, 8, 10, 0.08);
}
.wordstat-quota-bar span {
position: absolute;
inset: 0 auto 0 0;
width: var(--wordstat-quota-left);
border-radius: inherit;
background: #e83e8c;
}
.wordstat-quota-strip.tight .wordstat-quota-bar span {
background: #e83e8c;
}
.wordstat-quota-strip.blocked .wordstat-quota-bar span {
background: #e83e8c;
}
.wordstat-quota-meta {
color: rgba(8, 8, 10, 0.52);
font-size: 0.64rem;
font-weight: 760;
line-height: 1;
}
.seo-topbar-primary-action {
display: inline-flex;
min-height: 2.65rem;
@ -14206,6 +14382,21 @@ body:has(.seo-launcher-shell) {
color: #fff;
}
@media (max-width: 1360px) {
.seo-stage-5 .seo-topbar-keyword-actions > button {
width: 3rem;
min-width: 3rem;
padding: 0;
gap: 0;
font-size: 0;
}
.seo-stage-5 .seo-topbar-keyword-actions > button svg {
width: 1rem;
height: 1rem;
}
}
.seo-topbar-workspace-actions em {
display: inline-flex;
min-width: 1.34rem;

View File

@ -16,6 +16,7 @@
"check:semantic-block-runtime": "npm run check:semantic-block-runtime -w server",
"check:semantic-blocks": "npm run check:semantic-blocks -w server",
"check:source-models": "npm run check:source-models -w server",
"check:ontology-universality": "npm run check:ontology-universality -w server",
"typecheck": "npm run typecheck -w server && npm run typecheck -w app",
"db:migrate": "npm run db:migrate -w server"
},

View File

@ -25,9 +25,25 @@ const envSchema = z.object({
WORDSTAT_REGION_IDS: z.string().default("225"),
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(40),
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
SERP_API_BASE_URL: z.string().url().optional(),
SERP_API_TOKEN: z.string().optional(),
SEO_AI_WORKSPACE_PROFILE: z
.enum(["disabled", "local", "deployed-hub", "deploy-host", "tunnel-local-e2e"])
.default("disabled"),
SEO_AI_WORKSPACE_CONTROL_URL: z.string().url().optional(),
SEO_AI_WORKSPACE_CONTROL_TOKEN: z.string().optional(),
SEO_AI_WORKSPACE_ASSISTANT_URL: z.string().url().optional(),
SEO_AI_WORKSPACE_ASSISTANT_TOKEN: z.string().optional(),
SEO_AI_WORKSPACE_OWNER_USER_ID: z.string().optional(),
SEO_AI_WORKSPACE_OWNER_EMAIL: z.string().optional(),
SEO_AI_WORKSPACE_OWNER_ROLE: z.string().optional(),
SEO_AI_WORKSPACE_OWNER_GROUPS: z.string().default(""),
SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID: z.string().optional(),
SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH: z.string().optional(),
SEO_AI_WORKSPACE_CONTRACT_ONLY: booleanFromString.default(false),
SEO_AI_WORKSPACE_TIMEOUT_MS: z.coerce.number().int().positive().max(60_000).default(15_000),
PREVIEW_CHROME_PATH: z.string().optional(),
PREVIEW_SNAPSHOT_CONCURRENCY: z.coerce.number().int().positive().max(4).default(2),
PREVIEW_SNAPSHOT_WAIT_MS: z.coerce.number().int().nonnegative().max(10_000).default(1400)
@ -57,6 +73,7 @@ export const env = {
regionIds: parsedEnv.WORDSTAT_REGION_IDS.split(",").map((item) => item.trim()).filter(Boolean),
devices: parsedEnv.WORDSTAT_DEVICES.split(",").map((item) => item.trim()).filter(Boolean),
numPhrases: parsedEnv.WORDSTAT_NUM_PHRASES,
maxSeedsPerRun: parsedEnv.WORDSTAT_MAX_SEEDS_PER_RUN,
hasApiToken: Boolean(parsedEnv.WORDSTAT_API_TOKEN)
},
serp: {
@ -65,6 +82,22 @@ export const env = {
hasApiToken: Boolean(parsedEnv.SERP_API_TOKEN)
}
},
aiWorkspace: {
profile: parsedEnv.SEO_AI_WORKSPACE_PROFILE,
assistantUrl: parsedEnv.SEO_AI_WORKSPACE_CONTROL_URL ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_URL,
assistantToken: parsedEnv.SEO_AI_WORKSPACE_CONTROL_TOKEN ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_TOKEN,
contractOnly: parsedEnv.SEO_AI_WORKSPACE_CONTRACT_ONLY,
contractWorkspacePath: parsedEnv.SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH,
selectedExecutorId: parsedEnv.SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID,
requestTimeoutMs: parsedEnv.SEO_AI_WORKSPACE_TIMEOUT_MS,
owner: {
userId: parsedEnv.SEO_AI_WORKSPACE_OWNER_USER_ID,
email: parsedEnv.SEO_AI_WORKSPACE_OWNER_EMAIL,
role: parsedEnv.SEO_AI_WORKSPACE_OWNER_ROLE,
groups: parsedEnv.SEO_AI_WORKSPACE_OWNER_GROUPS.split(",").map((item) => item.trim()).filter(Boolean)
},
hasAssistantToken: Boolean(parsedEnv.SEO_AI_WORKSPACE_CONTROL_TOKEN ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_TOKEN)
},
preview: {
chromePath: parsedEnv.PREVIEW_CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
snapshotConcurrency: parsedEnv.PREVIEW_SNAPSHOT_CONCURRENCY,

View File

@ -1,10 +1,13 @@
import { pool } from "../db/client.js";
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
type KeywordCleaningProviderMode = "codex_manual" | "deterministic_fallback";
type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
type KeywordCleaningSource = "manual" | "normalization_seed" | "serp" | "wordstat_related" | "wordstat_seed";
const KEYWORD_CLEANING_DECISIONS: KeywordCleaningDecision[] = ["use", "support", "article", "risky", "trash"];
const KEYWORD_CLEANING_DECISION_SET = new Set<string>(KEYWORD_CLEANING_DECISIONS);
export type KeywordCleaningModelTaskContract = {
taskType: "seo.keyword_cleaning";
schemaVersion: "seo-keyword-cleaning-task.v1";
@ -26,17 +29,90 @@ 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;
sourceClusterId: string | null;
sourceClusterTitle: string | null;
}>;
};
outputSchema: {
schemaVersion: "keyword-cleaning.v1";
requiredTopLevelKeys: string[];
itemRequiredKeys: string[];
decisionValues: KeywordCleaningDecision[];
};
decisionPolicy: {
broadFrequencyDoesNotOverrideSemanticFit: boolean;
demoteRelatedWhenDominantFacetMissing: boolean;
preserveApprovedAnchorFacets: boolean;
useRequiresExactEvidence: boolean;
riskyWhenNoPageBinding: boolean;
trashWhenOffContext: boolean;
articleDoesNotEnterRewrite: boolean;
};
semanticGuard: {
approvedAnchorCount: number;
dominantProtectedFacets: Array<{
approvedAnchorCount: number;
approvedAnchorShare: number;
examples: string[];
stem: string;
}>;
policy: {
broadFrequencyDoesNotOverrideSemanticFit: boolean;
demoteRelatedWhenDominantFacetMissing: boolean;
preserveSourceAnchorFacetsForRelated: boolean;
};
};
stopConditions: string[];
};
@ -124,13 +200,27 @@ type KeywordCleaningRunRow = {
type SeedQueueItem = MarketEnrichmentContract["seedQueue"][number];
type WordstatTopResult = MarketEnrichmentContract["wordstat"]["topResults"][number];
type RelatedSourceSeed = Pick<
SeedQueueItem,
"clusterId" | "clusterTitle" | "normalization" | "phrase" | "priority" | "projectOntologyVersionId" | "source"
>;
const HARD_TRASH_PATTERNS = [
/(^|\s)(ваканси[ия]|работа|зарплата|резюме)(\s|$)/i,
/(^|\s)(реферат|курсовая|диплом|презентация|скачать|pdf|книга)(\s|$)/i,
/(^|\s)(договор|образец|бланк|закон|гост|оквэд)(\s|$)/i
/(^|\s)(образец|бланк)\s+договор/i,
/(^|\s)(закон|гост|оквэд)(\s|$)/i
];
const CONSUMER_AI_NOISE_PATTERNS = [
/(^|\s)(бесплатн[а-яёa-z0-9-]*|онлайн|чат|бот|ответ[а-яёa-z0-9-]*|написать|написание|текст[а-яёa-z0-9-]*|песн[а-яёa-z0-9-]*|музык[а-яёa-z0-9-]*|фильм[а-яёa-z0-9-]*|фото|картинк[а-яёa-z0-9-]*|изображен[а-яёa-z0-9-]*|видео|порно|секс|таро|гороскоп)(\s|$)/i,
/нейросет[а-яёa-z0-9-]*\s+(бесплатн[а-яёa-z0-9-]*|онлайн|для\s+текст[а-яёa-z0-9-]*|для\s+картин[а-яёa-z0-9-]*)/i
];
const AI_KEYWORD_PATTERN = /(^|\s)(ai|ии)(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект|нейросет/i;
const B2B_KEYWORD_FACET_PATTERN =
/бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|1с|crm|erp|bpm|workflow|digital|twin|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн|предприят|корпоратив/i;
const ARTICLE_PATTERNS = [
/(^|\s)(как|что такое|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд)(\s|$)/i,
/(^|\s)(выбрать|сравнение|обзор|решение проблемы)(\s|$)/i
@ -151,6 +241,110 @@ function wordCount(value: string) {
return normalizePhrase(value).split(/\s+/).filter(Boolean).length;
}
const KEYWORD_CLEANING_FACET_STOP_WORDS = new Set([
"a",
"an",
"and",
"by",
"for",
"in",
"of",
"on",
"or",
"the",
"to",
"без",
"в",
"для",
"и",
"или",
"как",
"на",
"о",
"об",
"от",
"по",
"при",
"с",
"со",
"что",
"это"
]);
const GENERIC_KEYWORD_CLEANING_FACET_STEMS = new Set([
"автоматизац",
"автоматизаци",
"бизнес",
"данн",
"задач",
"инструмент",
"компан",
"контур",
"модул",
"платформ",
"приложен",
"процесс",
"проект",
"разработк",
"решен",
"сервис",
"систем",
"сайт",
"управлен",
"услуг"
]);
function stemKeywordCleaningToken(token: string) {
const normalizedToken = token.toLocaleLowerCase("ru-RU").replace(/ё/g, "е");
if (/^(ai|ии)$/i.test(normalizedToken) || /^(artificial|intelligence)$/i.test(normalizedToken)) {
return "ai";
}
if (/^(искусствен|интеллект)/i.test(normalizedToken)) {
return "ai";
}
if (/^[a-z0-9]+$/i.test(normalizedToken)) {
return normalizedToken.toLowerCase();
}
return normalizedToken.replace(
/(иями|ями|ами|ого|его|ому|ему|ыми|ими|ых|их|ией|иям|ям|ам|ях|ах|ов|ев|ей|ой|ый|ий|ая|яя|ое|ее|ые|ие|ую|юю|ом|ем|а|я|ы|и|у|ю|е|о)$/i,
""
);
}
function getKeywordCleaningFacetStems(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_CLEANING_FACET_STOP_WORDS.has(token))
.map(stemKeywordCleaningToken)
.filter((stem) => stem.length > 1 && !KEYWORD_CLEANING_FACET_STOP_WORDS.has(stem))
);
}
function isProtectedKeywordCleaningFacetStem(
stem: string,
approvedAnchorCount: number,
stemDocumentCounts: Map<string, number>
) {
if (GENERIC_KEYWORD_CLEANING_FACET_STEMS.has(stem)) {
return false;
}
if (/^[a-z0-9]+$/i.test(stem)) {
return true;
}
const documentShare = approvedAnchorCount > 0 ? (stemDocumentCounts.get(stem) ?? 0) / approvedAnchorCount : 0;
return stem.length >= 7 || documentShare >= 0.18;
}
function hasPattern(value: string, patterns: RegExp[]) {
return patterns.some((pattern) => pattern.test(value));
}
@ -174,6 +368,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: [],
@ -213,7 +437,139 @@ function buildBriefClusterMap(contract: MarketEnrichmentContract) {
return clusterMap;
}
function getTargetPath(seed: SeedQueueItem, briefPhraseMap: Map<string, string>, briefClusterMap: Map<string, string>) {
type KeywordCleaningFacetGuard = {
approvedAnchorCount: number;
dominantProtectedStems: Array<{ count: number; stem: string }>;
examplesByStem: Map<string, string[]>;
stemDocumentCounts: Map<string, number>;
};
function buildKeywordCleaningFacetGuard(contract: MarketEnrichmentContract): KeywordCleaningFacetGuard {
const stemDocumentCounts = new Map<string, number>();
const examplesByStem = new Map<string, string[]>();
const approvedAnchors = contract.anchorReview.wordstatQueue.slice(0, 120);
for (const anchor of approvedAnchors) {
for (const stem of getKeywordCleaningFacetStems(`${anchor.phrase} ${anchor.clusterTitle}`)) {
stemDocumentCounts.set(stem, (stemDocumentCounts.get(stem) ?? 0) + 1);
const examples = examplesByStem.get(stem) ?? [];
if (examples.length < 3 && !examples.map(normalizePhrase).includes(normalizePhrase(anchor.phrase))) {
examples.push(anchor.phrase);
examplesByStem.set(stem, examples);
}
}
}
const dominantProtectedStems = [...stemDocumentCounts.entries()]
.filter(([stem, count]) => {
const share = approvedAnchors.length > 0 ? count / approvedAnchors.length : 0;
return share >= 0.3 && isProtectedKeywordCleaningFacetStem(stem, approvedAnchors.length, stemDocumentCounts);
})
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0], "ru"))
.slice(0, 12)
.map(([stem, count]) => ({ count, stem }));
return {
approvedAnchorCount: approvedAnchors.length,
dominantProtectedStems,
examplesByStem,
stemDocumentCounts
};
}
function buildKeywordCleaningSemanticGuard(contract: MarketEnrichmentContract): KeywordCleaningModelTaskContract["semanticGuard"] {
const guard = buildKeywordCleaningFacetGuard(contract);
const dominantProtectedFacets = guard.dominantProtectedStems
.map((facet) => ({
approvedAnchorCount: facet.count,
approvedAnchorShare: Number((facet.count / Math.max(guard.approvedAnchorCount, 1)).toFixed(3)),
examples: guard.examplesByStem.get(facet.stem) ?? [],
stem: facet.stem
}));
return {
approvedAnchorCount: guard.approvedAnchorCount,
dominantProtectedFacets,
policy: {
broadFrequencyDoesNotOverrideSemanticFit: true,
demoteRelatedWhenDominantFacetMissing: true,
preserveSourceAnchorFacetsForRelated: true
}
};
}
function getProtectedSourceFacetStems(item: KeywordCleaningItem, guard: KeywordCleaningFacetGuard) {
if (item.source !== "wordstat_related" || !item.sourcePhrase) {
return new Set<string>();
}
const sourceStems = getKeywordCleaningFacetStems(`${item.sourcePhrase} ${item.clusterTitle}`);
return new Set(
[...sourceStems].filter((stem) =>
isProtectedKeywordCleaningFacetStem(stem, guard.approvedAnchorCount, guard.stemDocumentCounts)
)
);
}
function getKeywordCleaningSemanticDowngradeReason(item: KeywordCleaningItem, guard: KeywordCleaningFacetGuard) {
const phraseStems = getKeywordCleaningFacetStems(item.phrase);
const sourceProtectedStems = getProtectedSourceFacetStems(item, guard);
const missingSourceStems = [...sourceProtectedStems].filter((stem) => !phraseStems.has(stem));
if (sourceProtectedStems.size > 0 && missingSourceStems.length === sourceProtectedStems.size) {
return `Wordstat related потерял protected-признак исходного якоря (${missingSourceStems.join(", ")}).`;
}
const dominantProtectedStems = guard.dominantProtectedStems.map((item) => item.stem);
const missingDominantStems = dominantProtectedStems.filter((stem) => !phraseStems.has(stem));
if (dominantProtectedStems.length > 0 && missingDominantStems.length === dominantProtectedStems.length) {
return `Фраза не содержит доминантный protected-смысл approved anchors (${missingDominantStems.join(", ")}).`;
}
return null;
}
function enforceKeywordCleaningSemanticGuard(
market: MarketEnrichmentContract,
items: KeywordCleaningItem[]
): KeywordCleaningItem[] {
const guard = buildKeywordCleaningFacetGuard(market);
if (guard.approvedAnchorCount === 0) {
return items;
}
return items.map((item) => {
if (item.decision !== "use" && item.decision !== "support") {
return item;
}
const semanticDowngradeReason = getKeywordCleaningSemanticDowngradeReason(item, guard);
if (!semanticDowngradeReason) {
return item;
}
return {
...item,
blockers: unique([...(Array.isArray(item.blockers) ? item.blockers : []), "semantic_facet_loss"]),
confidence: Math.min(item.confidence, 0.62),
decision: "risky",
reason: `${semanticDowngradeReason} Backend guard понизил фразу в risky: частотность не перекрывает потерю смысла.`
};
});
}
function getTargetPath(
seed: Pick<SeedQueueItem, "clusterId" | "normalization" | "phrase">,
briefPhraseMap: Map<string, string>,
briefClusterMap: Map<string, string>
) {
const directTarget = briefPhraseMap.get(normalizePhrase(seed.phrase));
if (directTarget) {
@ -320,7 +676,7 @@ function buildSeedItem(
function buildRelatedItem(
result: WordstatTopResult,
sourceSeed: SeedQueueItem,
sourceSeed: RelatedSourceSeed,
index: number,
briefPhraseMap: Map<string, string>,
briefClusterMap: Map<string, string>
@ -361,6 +717,25 @@ function buildRelatedItem(
};
}
function buildRelatedSourceSeedFromWordstatResult(
result: WordstatTopResult,
projectOntologyVersionId: string | null
): RelatedSourceSeed | null {
if (!result.sourceClusterId) {
return null;
}
return {
clusterId: result.sourceClusterId,
clusterTitle: result.sourceClusterTitle ?? result.sourcePhrase ?? result.phrase,
normalization: null,
phrase: result.sourcePhrase ?? result.phrase,
priority: result.sourcePriority ?? "medium",
projectOntologyVersionId,
source: result.sourceSeedSource === "ontology" ? "ontology" : "detected"
};
}
function getBaseItems(contract: MarketEnrichmentContract) {
const briefPhraseMap = buildBriefPhraseMap(contract);
const briefClusterMap = buildBriefClusterMap(contract);
@ -379,7 +754,9 @@ function getBaseItems(contract: MarketEnrichmentContract) {
}
const sourcePhrase = result.sourcePhrase ? normalizePhrase(result.sourcePhrase) : normalizedResult;
const sourceSeed = seedByPhrase.get(sourcePhrase);
const sourceSeed =
seedByPhrase.get(sourcePhrase) ??
buildRelatedSourceSeedFromWordstatResult(result, contract.projectOntologyVersion?.id ?? null);
if (!sourceSeed) {
continue;
@ -392,6 +769,137 @@ 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,
sourceClusterId: result.sourceClusterId,
sourceClusterTitle: result.sourceClusterTitle,
sourcePhrase: result.sourcePhrase
}))
};
}
function isConsumerAiNoisePhrase(phrase: string) {
const normalizedPhrase = normalizePhrase(phrase);
return AI_KEYWORD_PATTERN.test(normalizedPhrase) && hasPattern(normalizedPhrase, CONSUMER_AI_NOISE_PATTERNS);
}
function hasB2BKeywordFacet(phrase: string) {
return B2B_KEYWORD_FACET_PATTERN.test(normalizePhrase(phrase));
}
function isBroadAiKeywordNoise(phrase: string | null) {
if (!phrase) {
return false;
}
const normalizedPhrase = normalizePhrase(phrase);
return AI_KEYWORD_PATTERN.test(normalizedPhrase) && !hasB2BKeywordFacet(normalizedPhrase);
}
function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>) {
const blockers: string[] = [];
const exactEvidence = item.frequency !== null;
@ -406,6 +914,9 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>
const isTrash =
item.marketRole === "exclude" ||
hasPattern(item.normalizedPhrase, HARD_TRASH_PATTERNS) ||
isConsumerAiNoisePhrase(item.normalizedPhrase) ||
isBroadAiKeywordNoise(item.normalizedPhrase) ||
(item.source === "wordstat_related" && isBroadAiKeywordNoise(item.sourcePhrase)) ||
(item.source === "wordstat_related" && !hasContextOverlap(item.phrase, contextTokens)) ||
(item.source === "wordstat_related" && wordCount(item.phrase) <= 1);
@ -507,7 +1018,13 @@ 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 === "use" || item.decision === "support") &&
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,
@ -536,6 +1053,9 @@ function buildKeywordCleaningModelTask(
],
decisionPolicy: {
articleDoesNotEnterRewrite: true,
broadFrequencyDoesNotOverrideSemanticFit: true,
demoteRelatedWhenDominantFacetMissing: true,
preserveApprovedAnchorFacets: true,
riskyWhenNoPageBinding: true,
trashWhenOffContext: true,
useRequiresExactEvidence: true
@ -545,7 +1065,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 +1084,12 @@ function buildKeywordCleaningModelTask(
"semanticRunId",
"provider",
"analystReview",
"source",
"modelTask",
"readiness",
"lanes",
"items",
"summary",
"nextActions"
],
schemaVersion: "keyword-cleaning.v1"
@ -574,9 +1099,12 @@ function buildKeywordCleaningModelTask(
providerMode: "model_provider_contract",
schemaVersion: "seo-keyword-cleaning-task.v1",
semanticRunId: contract.semanticRunId,
semanticGuard: buildKeywordCleaningSemanticGuard(contract),
stopConditions: [
"Не отправлять trash/risky в keyword map как approved decision.",
"Не отправлять trash в keyword map. Risky/article exact-фразы можно только предсортировать в Stage 3, но не считать approved без пользователя.",
"Не придумывать спрос без Wordstat/SERP evidence.",
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря или доминантный смысл approved anchors.",
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
"Не сохранять rewrite/apply decisions."
],
@ -613,7 +1141,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;
@ -621,7 +1149,7 @@ function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">
function buildFallbackContract(projectId: string, market: MarketEnrichmentContract): KeywordCleaningContract {
const modelTask = buildKeywordCleaningModelTask(projectId, market);
const items = classifyItems(market, getBaseItems(market));
const items = enforceKeywordCleaningSemanticGuard(market, classifyItems(market, getBaseItems(market)));
const lanes = buildLanes(items);
const readiness = buildReadiness(items, lanes);
const contractWithoutActions: Omit<KeywordCleaningContract, "nextActions"> = {
@ -704,8 +1232,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 +1258,7 @@ function normalizeKeywordCleaningOutput(
provider: {
message: providerMessage,
mode: providerMode,
modelRequired: true
modelRequired: providerMode !== "codex_manual"
},
readiness,
sourceTaskId: output.modelTask.taskId,
@ -775,7 +1313,36 @@ 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);
if (!latest) {
return fallbackContract;
}
const items = enforceKeywordCleaningSemanticGuard(market, latest.items);
const lanes = buildLanes(items);
const readiness = buildReadiness(items, lanes);
return {
...latest,
items,
lanes,
modelTask: fallbackContract.modelTask,
nextActions: buildNextActions({
...latest,
items,
lanes,
modelTask: fallbackContract.modelTask,
readiness,
sourceTaskId: fallbackContract.sourceTaskId
}),
readiness,
sourceTaskId: fallbackContract.sourceTaskId,
summary:
items.length > 0
? `Очищено ${items.length} фраз: ${readiness.useCount} use, ${readiness.supportCount} support, ${readiness.articleCount} article, ${readiness.riskyCount} risky, ${readiness.trashCount} trash.`
: "Нет фраз для keyword cleaning."
};
}
export async function saveKeywordCleaningManual(
@ -815,3 +1382,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: enforceKeywordCleaningSemanticGuard(market, mergeModelProviderItemsWithMarketEvidence(output.items, market)),
modelTask,
projectOntologyVersion: market.projectOntologyVersion,
semanticRunId: market.semanticRunId,
source: {
...output.source,
marketGeneratedAt: market.generatedAt,
marketSeedCount: market.seedQueue.length,
marketState: market.state,
wordstatJobId: market.wordstat.latestJob?.id ?? null,
wordstatJobStatus: market.wordstat.latestJob?.status ?? null,
wordstatResultCount: market.wordstat.summary.resultCount
},
sourceTaskId: modelTask?.taskId ?? null
};
const normalizedOutput = normalizeKeywordCleaningOutput(
projectId,
alignedOutput,
"codex_workspace",
"AI Workspace Codex provider выполнил seo.keyword_cleaning по contract-only task; результат сохранён как evidence после backend validation."
);
const result = await pool.query<KeywordCleaningRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'keyword_cleaning', 'done', $2::jsonb, $3::jsonb, now(), now())
returning id, status, input, output, error_message, started_at, completed_at, created_at;
`,
[
projectId,
JSON.stringify({
providerMode: "codex_workspace",
schemaVersion: "keyword-cleaning-model-provider-input.v1",
semanticRunId: normalizedOutput.semanticRunId,
sourceModelTaskRunId,
sourceTaskId: normalizedOutput.sourceTaskId,
taskType: "seo.keyword_cleaning",
wordstatJobId: normalizedOutput.source.wordstatJobId
}),
JSON.stringify(normalizedOutput)
]
);
const run = result.rows[0] ? mapKeywordCleaningRun(result.rows[0]) : null;
if (!run) {
throw new Error("Не удалось сохранить AI Workspace keyword cleaning.");
}
return run;
}

View File

@ -103,30 +103,6 @@ function getKeywordProfileText(item: Pick<KeywordCleaningItem, "clusterTitle" |
return normalizePhrase(`${item.phrase} ${item.clusterTitle}`);
}
function isAiDifferentiatorCandidate(item: Pick<KeywordCleaningItem, "clusterTitle" | "phrase">) {
const text = getKeywordProfileText(item);
return /(^|\s)(ai|ии)(\s|$)/i.test(text) || /(агент|агентн|ассистент|assistant|agentic)/i.test(text);
}
function hasProductLikeHybridSignal(value: string) {
const text = normalizePhrase(value);
const hasProductNoun = /(систем[ауы]?|платформ[ауы]?|сервис|software|product|suite)/i.test(text);
const hasProcessNoun = /(бизнес[-\s]?процесс|bpms?|workflow|процесс[а-яё]*)/i.test(text);
return hasProductNoun && hasProcessNoun;
}
function isBroadArticleBacklogPhrase(value: string) {
const text = normalizePhrase(value);
return /^автоматизаци[яи]\s+бизнес[-\s]?процесс(ов|ы)?$/i.test(text);
}
function isHybridSecondaryCandidate(item: KeywordCleaningItem) {
return item.frequency !== null && hasProductLikeHybridSignal(item.phrase) && !isAiDifferentiatorCandidate(item);
}
function isReviewedOntology(version: MarketEnrichmentContract["projectOntologyVersion"]) {
return (
Boolean(version) &&
@ -151,46 +127,306 @@ 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>;
};
type SemanticFitAssessment = {
label: "lost_project_facet" | "lost_source_facet" | "strong" | "weak";
matchedProjectStems: string[];
matchedSourceStems: string[];
missingProjectStems: string[];
missingSourceStems: string[];
projectCoverage: number | null;
score: number;
sourceCoverage: number | null;
};
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 getCoverage(matchedCount: number, totalCount: number) {
return totalCount > 0 ? matchedCount / totalCount : null;
}
function getSemanticFitAssessment(item: KeywordCleaningItem, guard: SemanticFacetGuard): SemanticFitAssessment {
const phraseStems = getKeywordFacetStems(item.phrase);
const sourceStems = item.source === "wordstat_related" ? getProtectedSourceFacetStems(item, guard) : new Set<string>();
const projectStems = guard.dominantProtectedStems;
const matchedSourceStems = [...sourceStems].filter((stem) => phraseStems.has(stem));
const matchedProjectStems = [...projectStems].filter((stem) => phraseStems.has(stem));
const missingSourceStems = [...sourceStems].filter((stem) => !phraseStems.has(stem));
const missingProjectStems = [...projectStems].filter((stem) => !phraseStems.has(stem));
const sourceCoverage = getCoverage(matchedSourceStems.length, sourceStems.size);
const projectCoverage = getCoverage(matchedProjectStems.length, projectStems.size);
const score = Math.min(sourceCoverage ?? 1, projectCoverage ?? 1);
if (sourceCoverage === 0 && sourceStems.size > 0) {
return {
label: "lost_source_facet",
matchedProjectStems,
matchedSourceStems,
missingProjectStems,
missingSourceStems,
projectCoverage,
score,
sourceCoverage
};
}
if (projectCoverage === 0 && projectStems.size > 0) {
return {
label: "lost_project_facet",
matchedProjectStems,
matchedSourceStems,
missingProjectStems,
missingSourceStems,
projectCoverage,
score,
sourceCoverage
};
}
return {
label: score >= 0.5 ? "strong" : "weak",
matchedProjectStems,
matchedSourceStems,
missingProjectStems,
missingSourceStems,
projectCoverage,
score,
sourceCoverage
};
}
function missesDominantProjectFacet(item: KeywordCleaningItem, guard: SemanticFacetGuard) {
return getSemanticFitAssessment(item, guard).label === "lost_project_facet";
}
function getKeywordPriorityWeight(priority: KeywordCleaningItem["priority"]) {
if (priority === "high") return 3;
if (priority === "medium") return 2;
return 1;
}
function getKeywordDecisionRouteWeight(decision: KeywordCleaningItem["decision"]) {
if (decision === "use") return 2;
if (decision === "support") return 1;
return 0;
}
function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGuard) {
const semanticFit = guard ? getSemanticFitAssessment(item, guard) : null;
return (
(item.decision === "use" || item.decision === "support") &&
item.evidenceStatus === "collected" &&
item.frequency !== null &&
Boolean(item.projectOntologyVersionId) &&
Boolean(item.wordstatResultId) &&
Boolean(item.targetPath) &&
(!semanticFit || (semanticFit.label === "strong" && semanticFit.score >= 0.5))
);
}
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 (item.decision === "use" && indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) {
return "primary";
}
if (item.priority === "high" || item.frequency !== null) {
if (isLongTailKeywordCandidate(item)) {
return "support";
}
if (isClarifyingKeywordCandidate(item)) {
return "differentiator";
}
if (indexInTarget <= 7 && (item.priority === "high" || item.priority === "medium") && item.decision !== "article") {
return "secondary";
}
if (item.priority === "high" || item.priority === "medium") {
return "secondary";
}
@ -229,36 +465,64 @@ 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" &&
item.evidenceStatus === "collected" &&
item.frequency !== null &&
Boolean(item.wordstatResultId)
)
.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 leftDecisionWeight = getKeywordDecisionRouteWeight(left.decision);
const rightDecisionWeight = getKeywordDecisionRouteWeight(right.decision);
if (leftDecisionWeight !== rightDecisionWeight) {
return rightDecisionWeight - leftDecisionWeight;
}
const leftPriorityWeight = getKeywordPriorityWeight(left.priority);
const rightPriorityWeight = getKeywordPriorityWeight(right.priority);
if (leftPriorityWeight !== rightPriorityWeight) {
return rightPriorityWeight - leftPriorityWeight;
}
const leftSemanticFit = getSemanticFitAssessment(left, semanticFacetGuard).score;
const rightSemanticFit = getSemanticFitAssessment(right, semanticFacetGuard).score;
if (leftSemanticFit !== rightSemanticFit) {
return rightSemanticFit - leftSemanticFit;
}
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 +541,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 +550,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 +565,40 @@ function getKeywordMapItemReason(
return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title.";
}
if (isBroadArticleBacklogPhrase(item.phrase)) {
return "Широкая head-фраза уходит в article/backlog: не делаем её прямой основной ставкой текущей посадочной без отдельного SERP page-type решения.";
if (role === "primary") {
return "Модель поставила фразу в посадочное ядро: это strongest exact-запрос для выбранной посадочной.";
}
if (role === "secondary") {
return "Модель поставила фразу в поддерживающее ядро: она раскрывает главный спрос и структуру посадочной.";
}
if (role === "support") {
return "Модель поставила фразу в длинный хвост: использовать в FAQ, подписях, внутренних связях или вторичных блоках.";
}
if (role === "validate" && hasSemanticFacetLoss(item, semanticFacetGuard)) {
const semanticFit = getSemanticFitAssessment(item, semanticFacetGuard);
return `Wordstat related потерял protected-признак исходного якоря (${semanticFit.missingSourceStems.join(", ")}): оставляем на ручной разбор, чтобы широкий спрос не вытеснил смысл проекта.`;
}
if (role === "validate" && missesDominantProjectFacet(item, semanticFacetGuard)) {
const semanticFit = getSemanticFitAssessment(item, semanticFacetGuard);
return `Фраза не содержит доминантный protected-смысл из подтверждённой очереди (${semanticFit.missingProjectStems.join(", ")}): оставляем на ручной разбор, чтобы широкий спрос не стал ядром автоматически.`;
}
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 +740,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 +793,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 +836,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 +1092,20 @@ export async function approveKeywordMapDecisions(
continue;
}
await client.query(
`
update keyword_map_items
set approved = false, updated_at = now()
where project_id = $1
and semantic_run_id = $2
and project_ontology_version_id = $3
and source_item_id = $4
and workspace_section_id <> $5
and approved = true;
`,
[projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id, item.id, workspaceSectionId]
);
await client.query(
`
insert into keyword_map_items (

View File

@ -226,6 +226,184 @@ function normalizePhrase(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
const EXPANSION_STOP_PARTS = new Set([
"ai",
"ии",
"искусственный интеллект",
"api",
"bpm",
"crm",
"engine",
"erp",
"hub",
"mcp",
"ops",
"3d",
"demo",
"pilot",
"workspace",
"on-prem",
"private cloud",
"данных",
"данные",
"доступы",
"интеграции",
"модули",
"платформа",
"процессы",
"система"
]);
const ALLOWED_EXPANSION_LATIN_TOKENS = new Set(["1c", "ai", "api", "bim", "bpm", "crm", "erp", "iot", "workflow"]);
const EXPANSION_SYNONYMS: Array<[RegExp, string[]]> = [
[/digital\s+twin|цифров\w*\s+двойн/i, ["цифровой двойник", "цифровые двойники", "digital twin"]],
[/\bbim\b|(^|\s)бим(\s|$)/i, ["bim", "бим"]],
[/1c|1с/i, ["1с", "1c"]],
[/закуп/i, ["закупки", "автоматизация закупок", "управление закупками"]],
[/тендер/i, ["тендеры", "тендерный помощник", "автоматизация тендеров"]],
[/юрид/i, ["юридические процессы", "юридический ассистент", "автоматизация договоров"]],
[/беспилот/i, ["беспилотная техника", "управление беспилотниками", "платформа для беспилотников"]],
[/\biot\b|интернет\s+вещ/i, ["iot", "интернет вещей", "iot платформа"]],
[/телеметр/i, ["телеметрия", "система телеметрии", "платформа телеметрии"]],
[/строител/i, ["строительство", "автоматизация строительства", "строительный контроль"]],
[/эксплуатац/i, ["эксплуатация объектов", "управление эксплуатацией", "автоматизация эксплуатации"]]
];
const EXPANSION_QUERY_BLOCKLIST = [
/(^|\s)(базов[а-яёa-z0-9-]*|трендов[а-яёa-z0-9-]*|коммерческ[а-яёa-z0-9-]*)\s+интент/i,
/(^|\s)интент(:|\s|$)/i,
/(^|\s)(задач|проверок)\.$/i,
/(^|\s)(искусственн[а-яёa-z0-9-]*\s+интеллект|ии|ai)\s+(платформ[а-яёa-z0-9-]*|систем[а-яёa-z0-9-]*|автоматизац[а-яёa-z0-9-]*)$/i,
/^(платформ[а-яёa-z0-9-]*|систем[а-яёa-z0-9-]*|автоматизац[а-яёa-z0-9-]*)\s+(искусственн[а-яёa-z0-9-]*\s+интеллект|ии|ai)(\s|$)/i,
/[.!?:]/,
/\b[a-z]+\s+[a-z]+\s+[a-z]+\b/i
];
function wordCount(value: string) {
return normalizePhrase(value).split(/\s+/).filter(Boolean).length;
}
function unique(values: string[]) {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const cleanValue = normalizePhrase(value);
if (!cleanValue || seen.has(cleanValue)) {
continue;
}
seen.add(cleanValue);
result.push(cleanValue);
}
return result;
}
function splitExpansionParts(value: string) {
return normalizePhrase(value)
.replace(/[()]/g, " ")
.replace(/\s+(?:и|and)\s+/gi, ",")
.split(/\s*(?:,|\/|&|\+|;)\s*/i)
.map((part) => part.trim())
.filter((part) => wordCount(part) >= 2 || /^[a-z0-9-]{2,16}$/i.test(part))
.filter((part) => !EXPANSION_STOP_PARTS.has(part));
}
function getExpansionSynonyms(value: string) {
return EXPANSION_SYNONYMS.flatMap(([pattern, replacements]) => (pattern.test(value) ? replacements : []));
}
function hasSpecificExpansionFacet(phrase: string) {
const normalizedPhrase = normalizePhrase(phrase);
return /бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|1с|crm|erp|bpm|workflow|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн/i.test(
normalizedPhrase
);
}
function hasUnsafeExpansionLatinToken(phrase: string) {
return phrase
.split(/\s+/)
.flatMap((token) => token.match(/[a-z0-9-]+/gi) ?? [])
.some((token) => !ALLOWED_EXPANSION_LATIN_TOKENS.has(token.toLowerCase()));
}
function buildExpansionPhraseVariants(phrase: string, contextText: string) {
const variants = new Set<string>();
const cleanPhrase = normalizePhrase(phrase);
const hasAiContext = /\bai\b|(^|\s)ии(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(contextText);
if (!cleanPhrase || EXPANSION_STOP_PARTS.has(cleanPhrase)) {
return [];
}
variants.add(cleanPhrase);
if (!/платформ/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
variants.add(`платформа ${cleanPhrase}`);
variants.add(`${cleanPhrase} платформа`);
}
if (!/систем/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
variants.add(`система ${cleanPhrase}`);
}
if (!/автоматизац/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
variants.add(`автоматизация ${cleanPhrase}`);
}
if (hasAiContext && !/\bai\b|(^|\s)ии(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
variants.add(`${cleanPhrase} с искусственным интеллектом`);
variants.add(`ai ${cleanPhrase}`);
}
return [...variants].filter(isSafeWordstatExpansionPhrase);
}
function isSafeWordstatExpansionPhrase(phrase: string) {
const normalizedPhrase = normalizePhrase(phrase);
return (
wordCount(normalizedPhrase) >= 2 &&
wordCount(normalizedPhrase) <= 7 &&
/[а-яё]/i.test(normalizedPhrase) &&
normalizedPhrase !== "искусственный интеллект" &&
!/[{}[\]<>#$%^*=~`]/.test(normalizedPhrase) &&
!EXPANSION_QUERY_BLOCKLIST.some((pattern) => pattern.test(normalizedPhrase)) &&
!hasUnsafeExpansionLatinToken(normalizedPhrase) &&
(!/\b(ai|ии)\b|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(normalizedPhrase) ||
hasSpecificExpansionFacet(normalizedPhrase)) &&
!/(^|\s)(система|платформа)\s+(система|платформа)(\s|$)/i.test(normalizedPhrase) &&
!/искусственн[а-яёa-z0-9-]*\s+интеллект.*искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(normalizedPhrase) &&
!normalizedPhrase.split(/\s+/).some((token) => /^[a-z0-9-]{1,2}$/i.test(token) && !/^(ai|bim)$/i.test(token))
);
}
function buildClusterExpansionPhrases(cluster: SemanticAnalysisRun["clusters"][number], seedPhrase: string) {
const contextText = [
seedPhrase,
cluster.title,
cluster.targetIntent,
...cluster.matchedTerms,
...cluster.queryExamples,
...cluster.sourceRefs.map((ref) => ref.title),
...cluster.evidenceSnippets.slice(0, 8).flatMap((snippet) => [snippet.title, snippet.text]),
...cluster.targetPages
].join(" ");
const baseParts = unique([
...splitExpansionParts(seedPhrase),
...splitExpansionParts(cluster.title),
...splitExpansionParts(cluster.targetIntent),
...cluster.matchedTerms.flatMap(splitExpansionParts),
...getExpansionSynonyms(contextText)
]);
return unique(baseParts.flatMap((part) => buildExpansionPhraseVariants(part, contextText))).slice(0, 16);
}
function buildWordstatSeedMap(wordstat: WordstatEvidence) {
return new Map(wordstat.seedResults.map((result) => [normalizePhrase(result.phrase), result]));
}
@ -271,18 +449,18 @@ function getSeedEvidenceStatus(
return "collected";
}
if (wordstat.latestJob?.status === "failed") {
return "collection_failed";
}
if (wordstat.latestJob?.status === "done" && wordstatResult && wordstatResult.relatedCount > 0) {
if (wordstatResult && wordstatResult.relatedCount > 0) {
return "collected_related_only";
}
if (wordstat.latestJob?.status === "done" && wordstatResult) {
if (wordstatResult) {
return "collected_no_signal";
}
if (wordstat.latestJob?.status === "failed") {
return "collection_failed";
}
return getEvidenceStatus(providers, ["wordstat"]);
}
@ -330,6 +508,95 @@ function buildSeedQueue(
});
}
function shouldExpandSeed(seed: MarketEnrichmentContract["seedQueue"][number]) {
return (
seed.frequency === null &&
(seed.evidenceStatus === "collected_no_signal" ||
seed.evidenceStatus === "collected_related_only" ||
seed.evidenceStatus === "collection_failed")
);
}
function buildWordstatExpansionSeeds(
analysis: SemanticAnalysisRun,
market: MarketEnrichmentContract
): WordstatSeedCandidate[] {
const clusterById = new Map(analysis.clusters.map((cluster) => [cluster.id, cluster]));
const existingPhrases = new Set([
...market.seedQueue.map((seed) => normalizePhrase(seed.phrase)),
...market.wordstat.seedResults.map((result) => normalizePhrase(result.phrase)),
...market.wordstat.topResults.map((result) => normalizePhrase(result.phrase))
]);
const candidatesByCluster = new Map<string, WordstatSeedCandidate[]>();
const clusterOrder: string[] = [];
for (const seed of market.seedQueue.filter(shouldExpandSeed)) {
const cluster = clusterById.get(seed.clusterId);
if (!cluster) {
continue;
}
for (const phrase of buildClusterExpansionPhrases(cluster, seed.phrase)) {
const normalizedPhrase = normalizePhrase(phrase);
if (existingPhrases.has(normalizedPhrase)) {
continue;
}
existingPhrases.add(normalizedPhrase);
if (!candidatesByCluster.has(seed.clusterId)) {
candidatesByCluster.set(seed.clusterId, []);
clusterOrder.push(seed.clusterId);
}
const clusterCandidates = candidatesByCluster.get(seed.clusterId);
if (!clusterCandidates || clusterCandidates.length >= 8) {
continue;
}
clusterCandidates.push({
clusterId: seed.clusterId,
clusterTitle: seed.clusterTitle,
confidence: Math.max(0.42, Math.min((seed.normalization?.confidence ?? 0.6) * 0.9, 0.82)),
phrase: normalizedPhrase,
priority: seed.priority,
reason: `Expansion seed для Wordstat: исходная фраза "${seed.phrase}" не дала exact demand; аналог собран из semantic cluster/source evidence.`,
source: seed.source
});
}
}
const diversifiedCandidates: WordstatSeedCandidate[] = [];
for (let index = 0; diversifiedCandidates.length < 48; index += 1) {
let added = false;
for (const clusterId of clusterOrder) {
const candidate = candidatesByCluster.get(clusterId)?.[index];
if (!candidate) {
continue;
}
diversifiedCandidates.push(candidate);
added = true;
if (diversifiedCandidates.length >= 48) {
break;
}
}
if (!added) {
break;
}
}
return diversifiedCandidates;
}
function buildBriefEvidence(
analysis: SemanticAnalysisRun,
providers: MarketProviderDescriptor[],
@ -473,8 +740,7 @@ export async function getMarketEnrichmentContract(projectId: string): Promise<Ma
const providers = await buildProviderRegistry();
const normalization = await getSeoNormalizationContract(projectId);
const anchorReview = await getAnchorReviewContract(projectId);
const approvedWordstatPhrases = anchorReview.wordstatQueue.map((seed) => seed.phrase);
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null, approvedWordstatPhrases);
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null);
const seedQueue = analysis ? buildSeedQueue(anchorReview, providers, wordstat, projectOntologyVersion) : [];
const briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : [];
const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length;
@ -546,6 +812,12 @@ export async function runMarketEnrichment(projectId: string): Promise<MarketEnri
try {
await collectWordstatEvidence(projectId, analysis, normalizedSeeds);
const marketAfterBaseCollection = await getMarketEnrichmentContract(projectId);
const expansionSeeds = buildWordstatExpansionSeeds(analysis, marketAfterBaseCollection);
if (expansionSeeds.length > 0) {
await collectWordstatEvidence(projectId, analysis, [...normalizedSeeds, ...expansionSeeds]);
}
} catch (error) {
console.error(error);
}

View File

@ -47,6 +47,7 @@ export type WordstatProviderStatus =
provider: "mcp_kv" | "yandex_api";
mode: string;
message: string;
maxSeedsPerRun: number;
}
| {
connected: false;
@ -60,6 +61,8 @@ export interface WordstatProvider {
collect(request: WordstatCollectRequest): Promise<WordstatCollectResult>;
}
const YANDEX_WORDSTAT_MIN_REQUEST_INTERVAL_MS = 125;
export class WordstatProviderUnavailableError extends Error {
constructor(message: string) {
super(message);
@ -67,6 +70,12 @@ export class WordstatProviderUnavailableError extends Error {
}
}
function delay(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function getFrequencyGroup(frequency: number | null, explicitGroup?: unknown) {
if (typeof explicitGroup === "string" && explicitGroup.trim()) {
return explicitGroup.trim();
@ -246,7 +255,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 +271,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 +310,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
};
}
@ -314,7 +325,11 @@ class YandexApiWordstatProvider implements WordstatProvider {
this.config.authType === "iam_token" ? `Bearer ${this.config.apiToken}` : `Api-Key ${this.config.apiToken}`;
const seedResponses = [];
for (const seed of request.seeds) {
for (const [index, seed] of request.seeds.entries()) {
if (index > 0) {
await delay(YANDEX_WORDSTAT_MIN_REQUEST_INTERVAL_MS);
}
const payload = await postJson(
endpoint,
{
@ -359,7 +374,7 @@ export async function createWordstatProvider(): Promise<WordstatProvider> {
const config = await getWordstatRuntimeConfig();
if (config.provider === "mcp_kv") {
return new McpKvWordstatProvider(config.mcpEndpoint);
return new McpKvWordstatProvider(config.mcpEndpoint, config.maxSeedsPerRun);
}
if (config.provider === "yandex_api") {

View File

@ -49,6 +49,10 @@ type WordstatResultRow = {
id: string;
job_id: string;
seed_id: string | null;
seed_cluster_id: string | null;
seed_cluster_title: string | null;
seed_priority: "high" | "medium" | "low" | null;
seed_source: "detected" | "ontology" | string | null;
phrase: string;
source_phrase: string | null;
normalized_phrase: string | null;
@ -60,7 +64,33 @@ type WordstatResultRow = {
created_at: Date;
};
const WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT = 100;
const WORDSTAT_STATS_REQUESTS_PER_SECOND_LIMIT = 10;
export type WordstatQuotaSnapshot = {
provider: string;
mode: string;
limitPerHour: number;
limitPerSecond: number;
usedInWindow: number;
remainingInWindow: number;
windowMinutes: number;
windowStartedAt: string | null;
resetAt: string | null;
nextAvailableAt: string;
calculatedAt: string;
utilizationPercent: number;
releaseSchedule: Array<{
jobId: string;
status: WordstatJobRow["status"];
requestCount: number;
createdAt: string;
releasesAt: string;
}>;
};
export type WordstatEvidence = {
quota: WordstatQuotaSnapshot | null;
latestJob: {
id: string;
status: WordstatJobRow["status"];
@ -85,6 +115,10 @@ export type WordstatEvidence = {
frequencyGroup: string | null;
region: string | null;
relatedCount: number;
clusterId: string | null;
clusterTitle: string | null;
priority: "high" | "medium" | "low" | null;
seedSource: "detected" | "ontology" | string | null;
collectedAt: string;
}>;
topResults: Array<{
@ -94,6 +128,10 @@ export type WordstatEvidence = {
frequency: number | null;
frequencyGroup: string | null;
region: string | null;
sourceClusterId: string | null;
sourceClusterTitle: string | null;
sourcePriority: "high" | "medium" | "low" | null;
sourceSeedSource: "detected" | "ontology" | string | null;
}>;
summary: {
collectedSeedCount: number;
@ -382,7 +420,34 @@ function getRepresentativeTopResultRows(results: WordstatResultRow[]) {
return Array.from(selectedRows.values()).sort(compareWordstatResultRows);
}
function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]): WordstatEvidence {
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[],
quota: WordstatQuotaSnapshot | null
): WordstatEvidence {
const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
const relatedCounts = new Map<string, number>();
@ -393,6 +458,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
}
return {
quota,
latestJob: job ? mapJob(job) : null,
seedResults: seedRows.map((result) => ({
id: result.id,
@ -403,6 +469,10 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
frequencyGroup: result.frequency_group,
region: result.region,
relatedCount: relatedCounts.get(result.phrase) ?? 0,
clusterId: result.seed_cluster_id,
clusterTitle: result.seed_cluster_title,
priority: result.seed_priority,
seedSource: result.seed_source,
collectedAt: result.created_at.toISOString()
})),
topResults: getRepresentativeTopResultRows(results)
@ -412,7 +482,11 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
sourcePhrase: result.source_phrase,
frequency: result.frequency,
frequencyGroup: result.frequency_group,
region: result.region
region: result.region,
sourceClusterId: result.seed_cluster_id,
sourceClusterTitle: result.seed_cluster_title,
sourcePriority: result.seed_priority,
sourceSeedSource: result.seed_source
})),
summary: {
collectedSeedCount: seedRows.length,
@ -435,7 +509,7 @@ function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[])
async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun, seedCandidates: WordstatSeedCandidate[]) {
const seedRows: SeedRow[] = [];
for (const seed of seedCandidates.slice(0, 80)) {
for (const seed of seedCandidates) {
const inserted = await pool.query<SeedRow>(
`
insert into seeds (
@ -505,6 +579,109 @@ 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 getWordstatQuotaSnapshot(provider: string, mode: string): Promise<WordstatQuotaSnapshot> {
const result = await pool.query<{
id: string;
status: WordstatJobRow["status"];
created_at: Date;
request_count: string | number | null;
}>(
`
select
id,
status,
created_at,
jsonb_array_length(requested_phrases) as request_count
from wordstat_jobs
where provider = $1
and mode = $2
and status in ('running', 'done', 'failed')
and created_at >= now() - interval '1 hour'
and jsonb_array_length(requested_phrases) > 0
order by created_at asc;
`,
[provider, mode]
);
const releaseSchedule = result.rows.map((row) => {
const createdAt = row.created_at;
const releasesAt = new Date(createdAt.getTime() + 60 * 60 * 1000);
return {
createdAt: createdAt.toISOString(),
jobId: row.id,
releasesAt: releasesAt.toISOString(),
requestCount: Number(row.request_count ?? 0),
status: row.status
};
});
const usedCount = releaseSchedule.reduce((sum, item) => sum + item.requestCount, 0);
const remaining = Math.max(0, WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT - usedCount);
const calculatedAt = new Date().toISOString();
const resetAt = releaseSchedule[0]?.releasesAt ?? null;
return {
calculatedAt,
limitPerHour: WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT,
limitPerSecond: WORDSTAT_STATS_REQUESTS_PER_SECOND_LIMIT,
mode,
nextAvailableAt: remaining > 0 ? calculatedAt : (resetAt ?? calculatedAt),
provider,
releaseSchedule,
remainingInWindow: remaining,
resetAt,
usedInWindow: usedCount,
utilizationPercent: Math.min(100, Math.round((usedCount / WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT) * 100)),
windowMinutes: 60,
windowStartedAt: releaseSchedule[0]?.createdAt ?? null
};
}
async function getWordstatQuotaSnapshotForEvidence(job: WordstatJobRow | null) {
if (job) {
return getWordstatQuotaSnapshot(job.provider, job.mode);
}
const provider = await createWordstatProvider();
const status = provider.getStatus();
if (status.provider === "disabled") {
return null;
}
return getWordstatQuotaSnapshot(status.provider, status.mode);
}
async function getWordstatHourlyBudget(provider: string, mode: string) {
const snapshot = await getWordstatQuotaSnapshot(provider, mode);
return {
limit: snapshot.limitPerHour,
remaining: snapshot.remainingInWindow,
resetAt: snapshot.nextAvailableAt,
usedCount: snapshot.usedInWindow
};
}
async function createJob(
projectId: string,
semanticRunId: string,
@ -693,8 +870,25 @@ export async function collectWordstatEvidence(
}
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
const seeds = seedRows.map(toWordstatSeed);
const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId);
const uncollectedSeedRows = seedRows.filter((row) => !collectedSeedPhrases.has(normalizePhrase(row.phrase)));
if (uncollectedSeedRows.length === 0) {
return getLatestWordstatEvidence(projectId, analysis.runId);
}
const budget = await getWordstatHourlyBudget(status.provider, status.mode);
const maxSeedsForRun = Math.min(status.maxSeedsPerRun, budget.remaining);
if (maxSeedsForRun <= 0) {
throw new Error(
`Wordstat hourly quota exhausted: ${budget.usedCount}/${budget.limit} statistics requests used. Retry after ${budget.resetAt}.`
);
}
const seeds = uncollectedSeedRows.slice(0, maxSeedsForRun).map(toWordstatSeed);
const region = "ru";
const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds);
if (!job) {
@ -726,7 +920,7 @@ export async function getLatestWordstatEvidence(
scopePhrases?: string[]
): Promise<WordstatEvidence> {
if (!semanticRunId) {
return buildEvidence(null, []);
return buildEvidence(null, [], await getWordstatQuotaSnapshotForEvidence(null));
}
const jobResult = await pool.query<WordstatJobRow>(
@ -749,45 +943,57 @@ export async function getLatestWordstatEvidence(
updated_at
from wordstat_jobs
where project_id = $1 and semantic_run_id = $2
order by created_at desc
order by
case when status = 'done' then 0 else 1 end,
created_at desc
limit 1;
`,
[projectId, semanticRunId]
);
const latestJob = jobResult.rows[0] ?? null;
const quota = await getWordstatQuotaSnapshotForEvidence(latestJob);
if (!latestJob) {
return buildEvidence(null, []);
return buildEvidence(null, [], quota);
}
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,
s.cluster_id as seed_cluster_id,
s.cluster_title as seed_cluster_title,
s.priority as seed_priority,
s.source as seed_source,
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
left join seeds s on s.id = wr.seed_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, quota);
}
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);
@ -799,5 +1005,5 @@ export async function getLatestWordstatEvidence(
result_count: scopedResults.length
};
return buildEvidence(scopedJob, scopedResults);
return buildEvidence(scopedJob, scopedResults, quota);
}

View File

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

View File

@ -0,0 +1,479 @@
import { env } from "../config/env.js";
import type { SeoModelTaskContract } from "../modelContext/seoModelContext.js";
import { getSeoAnalyticsModelRuntimeConfig } from "../settings/externalServiceSettings.js";
import { getSeoAiWorkspaceConfigStatus, type SeoAiWorkspaceProfile } from "./aiWorkspaceBridgeConfig.js";
type JsonRecord = Record<string, unknown>;
export type SeoAiWorkspaceDispatch = {
schemaVersion: "seo-ai-workspace-dispatch.v1";
profile: SeoAiWorkspaceProfile;
assistantThreadId: string;
assistantMessageId: string;
bridgeRequestId: string;
bridgeMode: string;
accepted: boolean;
dispatchedAt: string;
selectedExecutorId: string | null;
contractWorkspacePath: string;
};
export type SeoAiWorkspaceOutputValidation = {
ok: boolean;
errors: string[];
warnings: string[];
};
export type SeoAiWorkspaceModelOutput = {
schemaVersion: "seo-model-output.v1";
source: "ai_workspace";
status: "dispatch_accepted" | "schema_valid" | "schema_invalid";
aiWorkspace: SeoAiWorkspaceDispatch;
rawText: string | null;
parsedJson: JsonRecord | null;
validation: SeoAiWorkspaceOutputValidation | null;
};
export type SeoAiWorkspacePersistedResult = {
schemaVersion: "seo-model-persisted-result.v1";
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
runType:
| "keyword_cleaning"
| "seo_model_task"
| "seo_normalization"
| "seo_strategy_quality_review"
| "seo_strategy_synthesis";
runId: string;
sourceModelTaskRunId?: string;
note: string;
};
export type SeoAiWorkspaceCompletion =
| { status: "pending" }
| { status: "failed"; error: string }
| { status: "completed"; rawText: string };
type AssistantThreadResponse = {
thread?: {
id?: string;
};
};
type AssistantMessageResponse = {
message?: {
id?: string;
};
};
type AssistantDispatchResponse = {
bridge?: {
accepted?: boolean;
requestId?: string;
mode?: string;
error?: string;
};
};
type AssistantMessagesResponse = {
messages?: Array<{
role?: string;
content?: string;
payload?: JsonRecord;
}>;
};
function isJsonRecord(value: unknown): value is JsonRecord {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function assistantHeaders() {
const headers: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${env.aiWorkspace.assistantToken ?? ""}`,
"Content-Type": "application/json"
};
if (env.aiWorkspace.owner.userId) {
headers["X-NODEDC-User-Id"] = env.aiWorkspace.owner.userId;
}
if (env.aiWorkspace.owner.email) {
headers["X-NODEDC-User-Email"] = env.aiWorkspace.owner.email;
}
if (env.aiWorkspace.owner.role) {
headers["X-NODEDC-User-Role"] = env.aiWorkspace.owner.role;
}
if (env.aiWorkspace.owner.groups.length > 0) {
headers["X-NODEDC-User-Groups"] = env.aiWorkspace.owner.groups.join(",");
}
return headers;
}
export async function assistantRequest<T>(
path: string,
options: {
body?: JsonRecord;
method: "GET" | "POST";
}
): Promise<T> {
if (!env.aiWorkspace.assistantUrl) {
throw new Error("SEO_AI_WORKSPACE_CONTROL_URL or SEO_AI_WORKSPACE_ASSISTANT_URL is required.");
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), env.aiWorkspace.requestTimeoutMs);
try {
const response = await fetch(`${env.aiWorkspace.assistantUrl.replace(/\/+$/, "")}${path}`, {
body: options.body ? JSON.stringify(options.body) : undefined,
headers: assistantHeaders(),
method: options.method,
signal: controller.signal
});
const text = await response.text();
const payload = text ? JSON.parse(text) as JsonRecord : {};
if (!response.ok || payload.ok === false) {
throw new Error(String(payload.error || payload.message || `ai_workspace_assistant_http_${response.status}`));
}
return payload as T;
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error("ai_workspace_assistant_invalid_json");
}
if (error instanceof Error && error.name === "AbortError") {
throw new Error("ai_workspace_assistant_timeout");
}
throw error;
} finally {
clearTimeout(timeout);
}
}
function buildSeoModelTaskPrompt(input: {
projectId: string;
runId: string;
task: SeoModelTaskContract;
}) {
return [
"NDC SEO Mode model-provider task.",
"",
"Return only one JSON object. Do not wrap it in markdown. Do not include prose outside JSON.",
`The JSON object must use schemaVersion "${input.task.outputSchema.schemaVersion}".`,
`Required top-level keys: ${input.task.outputSchema.requiredTopLevelKeys.join(", ")}.`,
"If the output contract has bookkeeping keys such as modelTask, sourceTaskId, source, semanticRunId, projectId, or provider, populate them from the task contract and the provided evidence.",
"For keyword tasks, classify only phrases present in task.input.seedQueue or task.input.wordstatTopResults; keep unresolved or weak rows out of approved rewrite lanes.",
"",
"Hard boundaries:",
"- Work only from the task contract below.",
"- Do not inspect repository files, local filesystem paths, browser state, secrets, tokens, or product databases.",
"- Do not call Wordstat, SERP, Yandex, OpenAI API, product app APIs, or external tools from this task.",
"- Do not mutate source files, rewrite/apply content, create pages, delete data, or approve decisions.",
"- Treat every output as a proposal/evidence draft that must pass backend schema validation and human gates.",
"- Preserve unresolved/Неразобранные semantics when the task is about keywords.",
"",
"Correlation:",
`- projectId: ${input.projectId}`,
`- seoModelTaskRunId: ${input.runId}`,
`- taskId: ${input.task.taskId}`,
`- taskType: ${input.task.taskType}`,
"",
"SEO task contract JSON:",
JSON.stringify(input.task, null, 2)
].join("\n");
}
export async function dispatchSeoModelTaskToAiWorkspace(input: {
projectId: string;
runId: string;
task: SeoModelTaskContract;
}): Promise<SeoAiWorkspaceDispatch> {
const status = getSeoAiWorkspaceConfigStatus();
if (!status.configured) {
throw new Error(status.errors.join(" ") || "AI Workspace bridge is not configured.");
}
const threadTitle = `SEO model task: ${input.task.taskType}`;
const analyticsModelConfig = await getSeoAnalyticsModelRuntimeConfig();
const selectedExecutorId = env.aiWorkspace.selectedExecutorId ?? analyticsModelConfig.executorId;
if (!selectedExecutorId) {
throw new Error("SEO analytics model executorId is required; generate Codex bridge setup command and save external service settings.");
}
const activeContext = {
surface: "seo-mode",
sourceSurface: "seo-mode",
modeId: "seo-model-task",
modeTitle: "NDC SEO Mode model provider",
accessMode: "contract-only",
contextReady: true,
workspacePath: env.aiWorkspace.contractWorkspacePath,
remoteControlMode: "remote",
seoModelTask: {
projectId: input.projectId,
runId: input.runId,
taskId: input.task.taskId,
taskType: input.task.taskType,
outputSchemaVersion: input.task.outputSchema.schemaVersion
}
};
const threadPayload: JsonRecord = {
activeContext,
enabledToolPacks: [],
lifecycleState: "active",
linkedArtifacts: [
{
kind: "seo_model_task",
projectId: input.projectId,
runId: input.runId,
taskId: input.task.taskId,
taskType: input.task.taskType
}
],
metadata: {
projectId: input.projectId,
runId: input.runId,
surface: "seo-mode"
},
originSurface: "seo-mode",
title: threadTitle
};
threadPayload.selectedExecutorId = selectedExecutorId;
const threadResponse = await assistantRequest<AssistantThreadResponse>("/api/ai-workspace/assistant/v1/threads", {
body: threadPayload,
method: "POST"
});
const threadId = String(threadResponse.thread?.id || "");
if (!threadId) {
throw new Error("ai_workspace_thread_id_missing");
}
const messageResponse = await assistantRequest<AssistantMessageResponse>(
`/api/ai-workspace/assistant/v1/threads/${encodeURIComponent(threadId)}/messages`,
{
body: {
content: buildSeoModelTaskPrompt(input),
payload: {
kind: "seo_model_task_request",
projectId: input.projectId,
runId: input.runId,
taskId: input.task.taskId,
taskType: input.task.taskType
},
role: "user"
},
method: "POST"
}
);
const messageId = String(messageResponse.message?.id || "");
if (!messageId) {
throw new Error("ai_workspace_message_id_missing");
}
const dispatchPayload: JsonRecord = {
context: activeContext,
messageId,
modeId: "seo-model-task",
modeTitle: "NDC SEO Mode model provider",
originSurface: "seo-mode",
remoteControlMode: "remote",
workspacePath: env.aiWorkspace.contractWorkspacePath
};
dispatchPayload.selectedExecutorId = selectedExecutorId;
const dispatchResponse = await assistantRequest<AssistantDispatchResponse>(
`/api/ai-workspace/assistant/v1/threads/${encodeURIComponent(threadId)}/dispatch`,
{
body: dispatchPayload,
method: "POST"
}
);
const bridge = dispatchResponse.bridge;
const requestId = String(bridge?.requestId || "");
if (bridge?.accepted !== true || !requestId) {
throw new Error(String(bridge?.error || "ai_workspace_bridge_request_not_accepted"));
}
return {
schemaVersion: "seo-ai-workspace-dispatch.v1",
profile: status.profile,
assistantThreadId: threadId,
assistantMessageId: messageId,
bridgeRequestId: requestId,
bridgeMode: String(bridge.mode || "hub"),
accepted: true,
dispatchedAt: new Date().toISOString(),
selectedExecutorId,
contractWorkspacePath: env.aiWorkspace.contractWorkspacePath ?? ""
};
}
export async function fetchSeoAiWorkspaceCompletion(
dispatch: SeoAiWorkspaceDispatch
): Promise<SeoAiWorkspaceCompletion> {
const payload = await assistantRequest<AssistantMessagesResponse>(
`/api/ai-workspace/assistant/v1/threads/${encodeURIComponent(dispatch.assistantThreadId)}/messages?limit=100`,
{ method: "GET" }
);
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const finalMessage = messages.find((message) => {
const item = isJsonRecord(message.payload) ? message.payload : {};
const kind = String(item.kind || "");
const requestId = String(item.requestId || item.request_id || "");
return requestId === dispatch.bridgeRequestId && (kind === "bridge_final" || kind === "bridge_failure");
});
if (!finalMessage) {
return { status: "pending" };
}
const finalPayload = isJsonRecord(finalMessage.payload) ? finalMessage.payload : {};
const finalKind = String(finalPayload.kind || "");
const content = String(finalMessage.content || "");
if (finalKind === "bridge_failure") {
return {
status: "failed",
error: content || "ai_workspace_bridge_failed"
};
}
return {
status: "completed",
rawText: content
};
}
function parseJsonFromText(rawText: string): JsonRecord | null {
const text = rawText.trim();
if (!text) {
return null;
}
try {
const parsed = JSON.parse(text);
return isJsonRecord(parsed) ? parsed : null;
} catch {}
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
if (fence?.[1]) {
try {
const parsed = JSON.parse(fence[1].trim());
return isJsonRecord(parsed) ? parsed : null;
} catch {}
}
const firstBrace = text.indexOf("{");
const lastBrace = text.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
try {
const parsed = JSON.parse(text.slice(firstBrace, lastBrace + 1));
return isJsonRecord(parsed) ? parsed : null;
} catch {}
}
return null;
}
export function buildAcceptedAiWorkspaceModelOutput(
dispatch: SeoAiWorkspaceDispatch
): SeoAiWorkspaceModelOutput {
return {
schemaVersion: "seo-model-output.v1",
source: "ai_workspace",
status: "dispatch_accepted",
aiWorkspace: dispatch,
rawText: null,
parsedJson: null,
validation: null
};
}
export function validateSeoAiWorkspaceModelOutput(input: {
dispatch: SeoAiWorkspaceDispatch;
rawText: string;
outputSchemaVersion: string | null;
requiredTopLevelKeys: string[];
}): SeoAiWorkspaceModelOutput {
const errors: string[] = [];
const warnings: string[] = [];
const parsedJson = parseJsonFromText(input.rawText);
if (!parsedJson) {
errors.push("Remote Codex response is not a JSON object.");
}
if (parsedJson && input.outputSchemaVersion && parsedJson.schemaVersion !== input.outputSchemaVersion) {
errors.push(`schemaVersion must be ${input.outputSchemaVersion}.`);
}
if (parsedJson) {
for (const key of input.requiredTopLevelKeys) {
if (!Object.hasOwn(parsedJson, key)) {
errors.push(`Missing required top-level key: ${key}.`);
}
}
}
if (input.rawText.includes("```")) {
warnings.push("Remote Codex wrapped output in markdown; JSON was extracted before validation.");
}
const validation = {
ok: errors.length === 0,
errors,
warnings
};
return {
schemaVersion: "seo-model-output.v1",
source: "ai_workspace",
status: validation.ok ? "schema_valid" : "schema_invalid",
aiWorkspace: input.dispatch,
rawText: input.rawText,
parsedJson,
validation
};
}
export function buildSeoAiWorkspacePersistedResult(runId: string): SeoAiWorkspacePersistedResult {
return {
schemaVersion: "seo-model-persisted-result.v1",
status: "stored_as_model_task_evidence",
runType: "seo_model_task",
runId,
note: "Structured model output is persisted on seo_model_task run; task-specific evidence promotion remains backend-owned."
};
}
export function buildSeoAiWorkspacePromotedResult(input: {
runType: Exclude<SeoAiWorkspacePersistedResult["runType"], "seo_model_task">;
sourceModelTaskRunId: string;
}): SeoAiWorkspacePersistedResult {
return {
schemaVersion: "seo-model-persisted-result.v1",
status: "promoted_to_domain_evidence",
runType: input.runType,
runId: input.sourceModelTaskRunId,
sourceModelTaskRunId: input.sourceModelTaskRunId,
note: `Structured model output passed schema validation and was promoted to ${input.runType}.`
};
}

View File

@ -0,0 +1,109 @@
import { env } from "../config/env.js";
export type SeoAiWorkspaceProfile = "disabled" | "local" | "deployed-hub" | "deploy-host" | "tunnel-local-e2e";
export type SeoAiWorkspaceConfigStatus = {
configured: boolean;
profile: SeoAiWorkspaceProfile;
errors: string[];
warnings: string[];
assistantUrl: string | null;
selectedExecutorId: string | null;
contractWorkspacePath: string | null;
ownerConfigured: boolean;
};
const LOCAL_ASSISTANT_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "host.docker.internal"]);
const LOCAL_FORBIDDEN_PRODUCT_HOSTS = new Set([
"hub.nodedc.ru",
"engine.nodedc.ru",
"ops.nodedc.ru",
"id.nodedc.ru",
"ops-agents.nodedc.ru",
"ai-hub.nodedc.ru"
]);
function hostname(value: string | undefined) {
if (!value) {
return "";
}
try {
return new URL(value).hostname.toLowerCase();
} catch {
return "";
}
}
export function getSeoAiWorkspaceConfigStatus(): SeoAiWorkspaceConfigStatus {
const profile = env.aiWorkspace.profile;
const assistantHost = hostname(env.aiWorkspace.assistantUrl);
const ownerConfigured = Boolean(env.aiWorkspace.owner.userId || env.aiWorkspace.owner.email);
const errors: string[] = [];
const warnings: string[] = [];
if (profile === "disabled") {
errors.push("SEO_AI_WORKSPACE_PROFILE is disabled; choose local, deployed-hub, deploy-host, or tunnel-local-e2e.");
}
if (!env.aiWorkspace.assistantUrl) {
errors.push("SEO_AI_WORKSPACE_CONTROL_URL or SEO_AI_WORKSPACE_ASSISTANT_URL is required.");
}
if (!env.aiWorkspace.assistantToken) {
errors.push("SEO_AI_WORKSPACE_CONTROL_TOKEN or SEO_AI_WORKSPACE_ASSISTANT_TOKEN is required.");
}
if (!ownerConfigured) {
errors.push("SEO_AI_WORKSPACE_OWNER_USER_ID or SEO_AI_WORKSPACE_OWNER_EMAIL is required.");
}
if (!env.aiWorkspace.contractOnly) {
errors.push("SEO_AI_WORKSPACE_CONTRACT_ONLY=1 is required for SEO model tasks.");
}
if (!env.aiWorkspace.contractWorkspacePath) {
errors.push("SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH is required to avoid executor workspace fallback.");
}
if (profile === "local") {
if (assistantHost && !LOCAL_ASSISTANT_HOSTS.has(assistantHost)) {
errors.push("local profile requires a local AI Workspace Assistant URL.");
}
if (LOCAL_FORBIDDEN_PRODUCT_HOSTS.has(assistantHost)) {
errors.push(`local profile must not use deployed product host ${assistantHost} as Assistant URL.`);
}
}
if (profile === "deploy-host" && assistantHost && LOCAL_ASSISTANT_HOSTS.has(assistantHost)) {
errors.push("deploy-host profile must not use localhost/host.docker.internal Assistant URL.");
}
if (profile === "deployed-hub") {
if (assistantHost && LOCAL_ASSISTANT_HOSTS.has(assistantHost)) {
errors.push("deployed-hub profile requires the public AI Hub control-plane URL, not localhost.");
}
if (assistantHost && assistantHost !== "ai-hub.nodedc.ru") {
warnings.push(`deployed-hub profile is using ${assistantHost}; expected ai-hub.nodedc.ru unless this is an explicit deploy profile.`);
}
}
if (env.aiWorkspace.selectedExecutorId) {
warnings.push("SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID pins the Codex executor for this backend.");
} else {
warnings.push("No env executor id is pinned; SEO external-service settings must provide executorId before dispatch.");
}
return {
configured: errors.length === 0,
profile,
errors,
warnings,
assistantUrl: env.aiWorkspace.assistantUrl ?? null,
selectedExecutorId: env.aiWorkspace.selectedExecutorId ?? null,
contractWorkspacePath: env.aiWorkspace.contractWorkspacePath ?? null,
ownerConfigured
};
}

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js";
export const SEO_MODEL_TASK_TYPES = [
"seo.context_review",
@ -141,7 +142,7 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
title: "Codex Workspace adapter",
mode: "workspace_agent",
summary:
"Будущий adapter для dev-mode/model-review: модель получает только SEO task contract и возвращает structured JSON.",
"Adapter через AI Workspace Assistant / AI Hub / remote Codex worker: модель получает только SEO task contract и возвращает structured JSON.",
allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES,
requiresSecrets: false,
secretNames: [],
@ -157,9 +158,11 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
guardrails: [
"Workspace agent не должен читать репозиторий напрямую для пользовательских сайтов.",
"Все filesystem/tool calls должны идти через approved MCP capabilities.",
"SEO route требует явный contract-only workspace path и не использует executor workspace fallback.",
"Ответ должен проходить schema validation перед записью результата."
],
nextSetupStep: "Описать handoff contract между AI Workspace Hub/MCP и seo_mode backend."
nextSetupStep:
"Настроить SEO_AI_WORKSPACE_PROFILE, AI Workspace control URL/token, owner, contract-only workspace path and connected Codex executor."
},
{
id: "openai_api",
@ -239,10 +242,38 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
}
];
const PARSED_PROVIDERS = z.array(SeoModelProviderDescriptorSchema).parse(PROVIDERS);
function buildProviderDescriptors() {
const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus();
return PROVIDERS.map((provider) => {
if (provider.id !== "codex_workspace") {
return provider;
}
return {
...provider,
status: aiWorkspaceStatus.configured ? "active" as const : "not_configured" as const,
summary: aiWorkspaceStatus.configured
? `${provider.summary} Configured profile: ${aiWorkspaceStatus.profile}.`
: `${provider.summary} Пока не настроен: ${aiWorkspaceStatus.errors[0] ?? provider.nextSetupStep}`,
guardrails: [
...provider.guardrails,
`AI Workspace profile: ${aiWorkspaceStatus.profile}.`,
"Remote Codex output is proposal/evidence only; it never bypasses human gates or rewrite/apply approval."
],
nextSetupStep: aiWorkspaceStatus.configured
? "Запустить seo model task через codex_workspace и дождаться structured JSON в model task evidence run."
: aiWorkspaceStatus.errors.join(" ") || provider.nextSetupStep
};
});
}
function getParsedProviders() {
return z.array(SeoModelProviderDescriptorSchema).parse(buildProviderDescriptors());
}
export function getDefaultSeoModelProviderId(): SeoModelProviderId {
return "codex_manual";
return getSeoAiWorkspaceConfigStatus().configured ? "codex_workspace" : "codex_manual";
}
export function isSeoModelProviderId(value: string): value is SeoModelProviderId {
@ -250,14 +281,17 @@ export function isSeoModelProviderId(value: string): value is SeoModelProviderId
}
export function getSeoModelProviderDescriptor(providerId: SeoModelProviderId): SeoModelProviderDescriptor {
return PARSED_PROVIDERS.find((provider) => provider.id === providerId) ?? PARSED_PROVIDERS[0];
const providers = getParsedProviders();
return providers.find((provider) => provider.id === providerId) ?? providers[0];
}
export function getSeoModelProviderCatalog(): SeoModelProviderCatalog {
const providers = getParsedProviders();
return SeoModelProviderCatalogSchema.parse({
schemaVersion: "seo-model-provider-catalog.v1",
generatedAt: new Date().toISOString(),
defaultProviderId: getDefaultSeoModelProviderId(),
providers: PARSED_PROVIDERS
providers
});
}

View File

@ -1,5 +1,20 @@
import { pool } from "../db/client.js";
import { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js";
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
import { saveSeoNormalizationFromModelProvider, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
import { saveStrategyQualityReviewFromModelProvider, type StrategyQualityReviewContract } from "../strategy/strategyQualityReview.js";
import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js";
import {
buildAcceptedAiWorkspaceModelOutput,
buildSeoAiWorkspacePromotedResult,
buildSeoAiWorkspacePersistedResult,
dispatchSeoModelTaskToAiWorkspace,
fetchSeoAiWorkspaceCompletion,
validateSeoAiWorkspaceModelOutput,
type SeoAiWorkspaceDispatch,
type SeoAiWorkspaceModelOutput,
type SeoAiWorkspacePersistedResult
} from "./aiWorkspaceBridge.js";
import {
getDefaultSeoModelProviderId,
getSeoModelProviderCatalog,
@ -24,6 +39,11 @@ type SeoModelTaskRunRow = {
created_at: Date;
};
type DomainPromotionResult = {
persistedResult: SeoAiWorkspacePersistedResult;
nextAction: string;
};
export type SeoModelTaskRunInput = {
providerId?: SeoModelProviderId;
taskId?: string;
@ -60,8 +80,8 @@ export type SeoModelTaskRunOutput = {
inputTokenEstimate: number;
outputTokenBudget: number;
};
modelOutput: null;
persistedResult: null;
modelOutput: SeoAiWorkspaceModelOutput | null;
persistedResult: SeoAiWorkspacePersistedResult | null;
nextActions: string[];
};
@ -138,7 +158,7 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) {
}
if (task.taskType === "seo.keyword_cleaning") {
return 2400;
return 5200;
}
if (task.taskType === "seo.serp_interpretation") {
@ -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
);

View File

@ -18,7 +18,7 @@ type SeoIntentType =
type SeoMarketRole = "commercial" | "core" | "exclude" | "integration" | "problem" | "support";
type SeoReviewStatus = "auto_approved" | "needs_human" | "rejected";
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
type SeoNormalizationProviderMode = "codex_manual" | "deterministic_fallback";
type SeoNormalizationProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
type SeoNormalizationRunRow = {
id: string;
@ -87,6 +87,18 @@ export type SeoNormalizationModelTaskContract = {
queryExamples: string[];
evidenceLevel: "none" | "thin" | "moderate" | "strong";
}>;
verticalOpportunities: Array<{
id: string;
title: string;
priority: "high" | "medium" | "low";
status: "covered" | "weak" | "missing";
targetIntent: string;
matchedTerms: string[];
queryExamples: string[];
sourceTitles: string[];
sourceSnippets: string[];
targetPages: string[];
}>;
sourceSeeds: Array<{
phrase: string;
clusterId: string;
@ -100,6 +112,7 @@ export type SeoNormalizationModelTaskContract = {
| "normalize_market_queries"
| "reject_noise"
| "mark_human_review"
| "expand_vertical_queries"
| "route_wordstat"
| "route_serp"
>;
@ -207,6 +220,9 @@ export type SeoNormalizationContract = {
const GENERIC_SINGLE_WORDS = new Set([
"ai",
"ии",
"искусственный интеллект",
"engine",
"bpm",
"workflow",
"автоматизация",
@ -317,12 +333,13 @@ function normalizeSeoNormalizationOutput(
throw new Error("SEO normalization output должен иметь intentGroups, seedStrategy и rejectedSeeds.");
}
const wordstatApprovedCount = output.seedStrategy.filter((seed) => seed.review.sendToWordstat).length;
const serpApprovedCount = output.seedStrategy.filter((seed) => seed.review.sendToSerp).length;
const needsHumanReviewCount = output.seedStrategy.filter((seed) => seed.review.status === "needs_human").length;
const autoApprovedCount = output.seedStrategy.filter((seed) => seed.review.status === "auto_approved").length;
const seedStrategy = output.seedStrategy.map(normalizeModelSeedReview);
const wordstatApprovedCount = seedStrategy.filter((seed) => seed.review.sendToWordstat).length;
const serpApprovedCount = seedStrategy.filter((seed) => seed.review.sendToSerp).length;
const needsHumanReviewCount = seedStrategy.filter((seed) => seed.review.status === "needs_human").length;
const autoApprovedCount = seedStrategy.filter((seed) => seed.review.status === "auto_approved").length;
const rejectedSeedCount =
output.rejectedSeeds.length + output.seedStrategy.filter((seed) => seed.review.status === "rejected").length;
output.rejectedSeeds.length + seedStrategy.filter((seed) => seed.review.status === "rejected").length;
return {
...output,
@ -339,13 +356,45 @@ function normalizeSeoNormalizationOutput(
autoApprovedCount,
intentGroupCount: output.intentGroups.length,
needsHumanReviewCount,
normalizedSeedCount: output.seedStrategy.length,
normalizedSeedCount: seedStrategy.length,
rejectedSeedCount,
serpApprovedCount,
wordstatApprovedCount,
wordstatQueryCount: wordstatApprovedCount
},
state: output.seedStrategy.length > 0 ? "ready" : "not_ready"
seedStrategy,
state: seedStrategy.length > 0 ? "ready" : "not_ready"
};
}
function isSourceGroundedVerticalSeed(seed: SeoNormalizationSeed) {
return (
seed.source === "detected" &&
seed.marketRole !== "exclude" &&
!isLowValuePhrase(seed.phrase) &&
!isBroadSinglePhrase(seed.phrase) &&
wordCount(seed.phrase) >= 2 &&
seed.confidence >= 0.5
);
}
function normalizeModelSeedReview(seed: SeoNormalizationSeed): SeoNormalizationSeed {
if (seed.review.status !== "rejected" || !isSourceGroundedVerticalSeed(seed)) {
return seed;
}
return {
...seed,
needsHumanReview: true,
review: {
...seed.review,
blockers: Array.from(new Set([...seed.review.blockers, "source_grounded_vertical_review"])),
reason:
`${seed.review.reason || "Model rejected source-grounded vertical."} Backend guard перевёл source-grounded vertical в needs_human: модель не должна молча выбрасывать потенциальную вертикаль сайта.`,
sendToSerp: false,
sendToWordstat: false,
status: "needs_human"
}
};
}
@ -503,8 +552,171 @@ function collectClusterPhrases(
...(contextHint?.sourcePhrases ?? []),
...(contextHint?.marketAngles ?? []).filter((angle) => wordCount(angle) <= 6)
];
const expansionPhrases = buildClusterExpansionPhrases(cluster, contextHint);
return unique([...seeds.map((seed) => seed.phrase), ...cluster.queryExamples, ...briefPhrases, ...safeHintPhrases]);
return unique([
...seeds.map((seed) => seed.phrase),
...cluster.queryExamples,
...briefPhrases,
...safeHintPhrases,
...expansionPhrases
]);
}
const MARKET_EXPANSION_STOP_PARTS = new Set([
"ai",
"bpm",
"crm",
"erp",
"hub",
"ops",
"api",
"mcp",
"3d",
"demo",
"pilot",
"workspace",
"on-prem",
"private cloud",
"данных",
"данные",
"доступы",
"интеграции",
"модули",
"платформа",
"процессы",
"система"
]);
const ALLOWED_MARKET_EXPANSION_LATIN_TOKENS = new Set(["1c", "ai", "api", "bim", "bpm", "crm", "erp", "iot", "workflow"]);
const MARKET_EXPANSION_SYNONYMS: Array<[RegExp, string[]]> = [
[/digital\s+twin|цифров\w*\s+двойн/i, ["цифровой двойник", "цифровые двойники", "digital twin"]],
[/\bbim\b|(^|\s)бим(\s|$)/i, ["bim", "бим"]],
[/1c|1с/i, ["1с", "1c"]],
[/закуп/i, ["закупки", "автоматизация закупок", "управление закупками"]],
[/тендер/i, ["тендеры", "тендерный помощник", "автоматизация тендеров"]],
[/юрид/i, ["юридические процессы", "юридический ассистент", "автоматизация договоров"]],
[/беспилот/i, ["беспилотная техника", "управление беспилотниками", "платформа для беспилотников"]],
[/\biot\b|интернет\s+вещ/i, ["iot", "интернет вещей", "iot платформа"]],
[/телеметр/i, ["телеметрия", "система телеметрии", "платформа телеметрии"]],
[/строител/i, ["строительство", "автоматизация строительства", "строительный контроль"]],
[/эксплуатац/i, ["эксплуатация объектов", "управление эксплуатацией", "автоматизация эксплуатации"]]
];
const MARKET_EXPANSION_QUERY_BLOCKLIST = [
/(^|\s)(базов[а-яёa-z0-9-]*|трендов[а-яёa-z0-9-]*|коммерческ[а-яёa-z0-9-]*)\s+интент/i,
/(^|\s)интент(:|\s|$)/i,
/(^|\s)(задач|проверок)\.$/i,
/(^|\s)(искусственн[а-яёa-z0-9-]*\s+интеллект|ии|ai)\s+(платформ[а-яёa-z0-9-]*|систем[а-яёa-z0-9-]*|автоматизац[а-яёa-z0-9-]*)$/i,
/^(платформ[а-яёa-z0-9-]*|систем[а-яёa-z0-9-]*|автоматизац[а-яёa-z0-9-]*)\s+(искусственн[а-яёa-z0-9-]*\s+интеллект|ии|ai)(\s|$)/i,
/[.!?:]/,
/\b[a-z]+\s+[a-z]+\s+[a-z]+\b/i
];
function splitMarketExpansionParts(value: string) {
return normalizePhrase(value)
.replace(/[()]/g, " ")
.replace(/\s+(?:и|and)\s+/gi, ",")
.split(/\s*(?:,|\/|&|\+|;)\s*/i)
.map((part) => part.trim())
.filter((part) => wordCount(part) >= 2 || /^[a-z0-9-]{2,16}$/i.test(part))
.filter((part) => !MARKET_EXPANSION_STOP_PARTS.has(part));
}
function getMarketExpansionSynonyms(value: string) {
return MARKET_EXPANSION_SYNONYMS.flatMap(([pattern, replacements]) => (pattern.test(value) ? replacements : []));
}
function hasSpecificMarketExpansionFacet(phrase: string) {
const normalizedPhrase = normalizePhrase(phrase);
return /бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|1с|crm|erp|bpm|workflow|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн/i.test(
normalizedPhrase
);
}
function hasUnsafeMarketExpansionLatinToken(phrase: string) {
return phrase
.split(/\s+/)
.flatMap((token) => token.match(/[a-z0-9-]+/gi) ?? [])
.some((token) => !ALLOWED_MARKET_EXPANSION_LATIN_TOKENS.has(token.toLowerCase()));
}
function buildPhraseExpansionVariants(phrase: string, clusterText: string) {
const variants = new Set<string>();
const cleanPhrase = normalizePhrase(phrase);
const hasAiContext = /\bai\b|(^|\s)ии(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(clusterText);
if (!cleanPhrase || MARKET_EXPANSION_STOP_PARTS.has(cleanPhrase)) {
return [];
}
variants.add(cleanPhrase);
if (!/платформ/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
variants.add(`платформа ${cleanPhrase}`);
variants.add(`${cleanPhrase} платформа`);
}
if (!/систем/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
variants.add(`система ${cleanPhrase}`);
}
if (!/автоматизац/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
variants.add(`автоматизация ${cleanPhrase}`);
}
if (hasAiContext && !/\bai\b|(^|\s)ии(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(cleanPhrase) && wordCount(cleanPhrase) <= 4) {
variants.add(`${cleanPhrase} с искусственным интеллектом`);
variants.add(`ai ${cleanPhrase}`);
}
return [...variants].filter(isSafeWordstatExpansionPhrase);
}
function isSafeWordstatExpansionPhrase(phrase: string) {
const normalizedPhrase = normalizePhrase(phrase);
return (
wordCount(normalizedPhrase) >= 2 &&
wordCount(normalizedPhrase) <= 7 &&
/[а-яё]/i.test(normalizedPhrase) &&
normalizedPhrase !== "искусственный интеллект" &&
!/[{}[\]<>#$%^*=~`]/.test(normalizedPhrase) &&
!MARKET_EXPANSION_QUERY_BLOCKLIST.some((pattern) => pattern.test(normalizedPhrase)) &&
!hasUnsafeMarketExpansionLatinToken(normalizedPhrase) &&
(!/\b(ai|ии)\b|искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(normalizedPhrase) ||
hasSpecificMarketExpansionFacet(normalizedPhrase)) &&
!/(^|\s)(система|платформа)\s+(система|платформа)(\s|$)/i.test(normalizedPhrase) &&
!/искусственн[а-яёa-z0-9-]*\s+интеллект.*искусственн[а-яёa-z0-9-]*\s+интеллект/i.test(normalizedPhrase) &&
!normalizedPhrase.split(/\s+/).some((token) => /^[a-z0-9-]{1,2}$/i.test(token) && !/^(ai|bim)$/i.test(token))
);
}
function buildClusterExpansionPhrases(
cluster: SemanticAnalysisRun["clusters"][number],
contextHint: SeoContextReviewRun["normalizationHints"][number] | undefined
) {
const clusterText = [
cluster.title,
cluster.targetIntent,
...cluster.matchedTerms,
...cluster.queryExamples,
...(contextHint?.sourcePhrases ?? []),
...(contextHint?.marketAngles ?? []),
...cluster.evidenceSnippets.slice(0, 8).flatMap((snippet) => [snippet.title, snippet.text]),
...cluster.sourceRefs.map((ref) => ref.title)
].join(" ");
const baseParts = unique([
...splitMarketExpansionParts(cluster.title),
...splitMarketExpansionParts(cluster.targetIntent),
...cluster.matchedTerms.flatMap(splitMarketExpansionParts),
...(contextHint?.marketAngles ?? []).flatMap(splitMarketExpansionParts),
...getMarketExpansionSynonyms(clusterText)
]);
return unique(baseParts.flatMap((part) => buildPhraseExpansionVariants(part, clusterText))).slice(0, 18);
}
function buildGroupQueries(phrases: string[], clusterTitle: string, siteTopTerms: string[], stopPhrases: string[]) {
@ -685,6 +897,21 @@ function buildModelTaskContract(
targetIntent: cluster.targetIntent,
title: cluster.title
})),
verticalOpportunities: analysis.clusters
.filter((cluster) => cluster.status !== "missing" || cluster.priority === "high")
.slice(0, 24)
.map((cluster) => ({
id: cluster.id,
matchedTerms: cluster.matchedTerms.slice(0, 16),
priority: cluster.priority,
queryExamples: cluster.queryExamples.slice(0, 12),
sourceTitles: cluster.sourceRefs.slice(0, 8).map((ref) => ref.title),
sourceSnippets: cluster.evidenceSnippets.slice(0, 8).map((snippet) => snippet.text),
status: cluster.status,
targetIntent: cluster.targetIntent,
targetPages: cluster.targetPages.slice(0, 8),
title: cluster.title
})),
sourceSeeds: analysis.seedCandidates.slice(0, 80).map((seed) => ({
clusterId: seed.clusterId,
phrase: seed.phrase,
@ -703,6 +930,7 @@ function buildModelTaskContract(
"normalize_market_queries",
"reject_noise",
"mark_human_review",
"expand_vertical_queries",
"route_wordstat",
"route_serp"
],
@ -728,6 +956,8 @@ function buildModelTaskContract(
stopConditions: [
"Недостаточно source evidence для отделения бизнеса от технической оболочки сайта.",
"Фраза выглядит как соседний рынок или business expansion без явного user approval.",
"Не отбрасывать source-grounded vertical opportunity: если точной рыночной формулировки нет, вернуть needs_human seed или expansion query, а не rejected.",
"Для каждой видимой продуктовой/отраслевой вертикали предложить 2-6 Wordstat-safe аналогов: коротких, рыночных, без внутренних labels.",
"Нельзя определить, отправлять ли фразу в Wordstat/SERP без ручного review."
]
};
@ -1047,3 +1277,42 @@ export async function saveSeoNormalizationManual(
return run;
}
export async function saveSeoNormalizationFromModelProvider(
projectId: string,
output: SeoNormalizationContract,
sourceModelTaskRunId: string
): Promise<SeoNormalizationContract> {
const normalizedOutput = normalizeSeoNormalizationOutput(
projectId,
output,
"codex_workspace",
"AI Workspace Codex provider выполнил seo.normalization по contract-only task; результат сохранён как evidence после backend validation."
);
const result = await pool.query<SeoNormalizationRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'seo_normalization', 'done', $2::jsonb, $3::jsonb, now(), now())
returning id, status, input, output, error_message, started_at, completed_at, created_at;
`,
[
projectId,
JSON.stringify({
providerMode: "codex_workspace",
schemaVersion: "seo-normalization-model-provider-input.v1",
semanticRunId: normalizedOutput.semanticRunId,
sourceModelTaskRunId,
sourceTaskId: normalizedOutput.sourceTaskId,
taskType: "seo.normalization"
}),
JSON.stringify(normalizedOutput)
]
);
const run = result.rows[0] ? mapSeoNormalizationRun(result.rows[0]) : null;
if (!run) {
throw new Error("Не удалось сохранить AI Workspace SEO normalization.");
}
return run;
}

View File

@ -1,7 +1,9 @@
import { env } from "../config/env.js";
import { pool } from "../db/client.js";
import { getSeoAiWorkspaceConfigStatus } from "../modelProvider/aiWorkspaceBridgeConfig.js";
export type ExternalServiceId =
| "seo_analytics_model"
| "yandex_cloud"
| "yandex_metrica"
| "yandex_search_serp"
@ -91,6 +93,7 @@ export type WordstatRuntimeConfig =
provider: "mcp_kv";
mode: "mcp_kv";
mcpEndpoint: string;
maxSeedsPerRun: number;
}
| {
provider: "yandex_api";
@ -102,6 +105,7 @@ export type WordstatRuntimeConfig =
regionIds: string[];
devices: string[];
numPhrases: number;
maxSeedsPerRun: number;
};
const WORDSTAT_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/wordstat";
@ -110,6 +114,30 @@ const WEBMASTER_BASE_URL = "https://api.webmaster.yandex.net/v4";
const METRICA_BASE_URL = "https://api-metrika.yandex.net";
const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = {
seo_analytics_model: {
id: "seo_analytics_model",
label: "Аналитическая модель",
description:
"Model-provider для SEO task contracts через AI Workspace surface seo-mode. Сейчас доступен Codex bridge без доступа к Yandex credentials, исходникам и apply/write actions.",
implementationStatus: "active",
defaultMode: "local_codex",
modes: [
{ id: "disabled", label: "Отключено" },
{ id: "local_codex", label: "Codex bridge" }
],
docs: [],
publicFields: [
{
id: "executorId",
label: "AI Workspace executor",
type: "text",
required: false,
placeholder: "generated after setup command",
help: "Stored SEO-only executor id. Hidden in UI; used to avoid global Assistant selected-executor fallback."
}
],
secretFields: []
},
yandex_cloud: {
id: "yandex_cloud",
label: "Yandex AI Studio / Cloud API key",
@ -223,6 +251,14 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
placeholder: "50",
help: "Сколько Wordstat rows запрашивать для одной seed-фразы."
},
{
id: "maxSeedsPerRun",
label: "Max seeds per run",
type: "number",
required: false,
placeholder: "40",
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
},
{
id: "mcpEndpoint",
label: "MCP endpoint",
@ -386,6 +422,7 @@ function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) {
apiBaseUrl: WORDSTAT_BASE_URL,
devices: "DEVICE_ALL",
numPhrases: "50",
maxSeedsPerRun: "40",
regionIds: "225"
};
@ -446,6 +483,16 @@ function getMissingFields(
const missing: string[] = [];
if (definition.id === "seo_analytics_model") {
const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus();
if (!aiWorkspaceStatus.configured) {
return ["aiWorkspaceProfile"];
}
return publicConfig.executorId?.trim() ? [] : ["executorId"];
}
for (const field of definition.publicFields) {
if (field.id === "mcpEndpoint" && mode !== "mcp_kv") {
continue;
@ -681,6 +728,19 @@ export async function updateExternalServiceSettings(
return mapRow(definition, row, dependencyStatuses);
}
export async function getSeoAnalyticsModelRuntimeConfig() {
const rows = await getSettingsRows();
const row = rows.get("seo_analytics_model") ?? null;
const definition = serviceDefinitions.seo_analytics_model;
const publicConfig = getPublicConfig(definition, row);
return {
enabled: Boolean(row?.enabled && row.mode === "local_codex"),
executorId: publicConfig.executorId?.trim() || null,
mode: row?.mode ?? "disabled"
};
}
function splitCsv(value: string | undefined, fallback: string[]) {
const values = (value ?? "")
.split(",")
@ -711,7 +771,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
return {
provider: "mcp_kv",
mode: "mcp_kv",
mcpEndpoint: endpoint
mcpEndpoint: endpoint,
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
};
}
}
@ -731,7 +792,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
folderId: cloudPublicConfig.folderId,
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50)
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
};
}
@ -739,7 +801,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
return {
provider: "mcp_kv",
mode: "mcp_kv",
mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint
mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint,
maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun
};
}
@ -758,7 +821,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
folderId: env.marketProviders.wordstat.folderId,
regionIds: env.marketProviders.wordstat.regionIds,
devices: env.marketProviders.wordstat.devices,
numPhrases: env.marketProviders.wordstat.numPhrases
numPhrases: env.marketProviders.wordstat.numPhrases,
maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun
};
}

View File

@ -6,11 +6,16 @@ import {
updateExternalServiceSettings,
type ExternalServiceId
} from "./externalServiceSettings.js";
import {
createSeoAnalyticsModelSetupCommand,
probeSeoAnalyticsModel
} from "./seoAnalyticsModelBridge.js";
import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js";
export const settingsRouter = Router();
const externalServiceIdSchema = z.enum([
"seo_analytics_model",
"yandex_cloud",
"yandex_metrica",
"yandex_search_serp",
@ -27,6 +32,13 @@ const updateExternalServiceSettingsSchema = z.object({
const yandexAiStudioProbeSchema = z.object({
phrase: z.string().trim().min(2).max(160).optional()
});
const seoAnalyticsModelSetupCommandSchema = z.object({
executorId: z.string().uuid().optional(),
port: z.coerce.number().int().positive().max(65535).optional()
});
const seoAnalyticsModelProbeSchema = z.object({
executorId: z.string().uuid().optional()
});
function sendError(response: Response, status: number, message: string) {
response.status(status).json({
@ -65,6 +77,42 @@ settingsRouter.post("/external-services/yandex_cloud/probe", async (request, res
}
});
settingsRouter.post("/external-services/seo_analytics_model/setup-command", async (request, response) => {
try {
const input = seoAnalyticsModelSetupCommandSchema.parse(request.body ?? {});
response.status(201).json({
setupCommand: await createSeoAnalyticsModelSetupCommand(input)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры setup command.");
return;
}
console.error(error);
sendError(response, 500, error instanceof Error ? error.message : "Не удалось подготовить Codex bridge setup command.");
}
});
settingsRouter.post("/external-services/seo_analytics_model/probe", async (request, response) => {
try {
const input = seoAnalyticsModelProbeSchema.parse(request.body ?? {});
response.json({
probe: await probeSeoAnalyticsModel(input)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры probe.");
return;
}
console.error(error);
sendError(response, 500, error instanceof Error ? error.message : "Не удалось проверить Codex bridge.");
}
});
settingsRouter.patch("/external-services/:serviceId", async (request, response) => {
try {
const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId;

View File

@ -0,0 +1,320 @@
import { randomUUID } from "node:crypto";
import { env } from "../config/env.js";
import { assistantRequest } from "../modelProvider/aiWorkspaceBridge.js";
import { getSeoAiWorkspaceConfigStatus } from "../modelProvider/aiWorkspaceBridgeConfig.js";
type JsonRecord = Record<string, unknown>;
type AiWorkspaceExecutor = {
id: string;
name: string;
type: string;
connectionMode: "direct" | "hub";
workspacePath: string | null;
pairingCode: string | null;
model: string | null;
accountLabel: string | null;
capabilities: string[];
status: "unknown" | "online" | "offline" | "checking" | "error";
statusDetail: string;
lastSeenAt: string | null;
metadata: JsonRecord;
};
type AiWorkspaceExecutorsResponse = {
selectedExecutorId?: string | null;
executors?: AiWorkspaceExecutor[];
};
type AiWorkspaceExecutorResponse = {
executor?: AiWorkspaceExecutor;
};
type AiWorkspaceSetupCommandResponse = {
install?: {
command?: string;
packageName?: string;
packageSpec?: string;
gatewayUrl?: string;
expiresAt?: string;
};
setupCode?: {
suffix?: string;
expiresAt?: string;
};
};
type AiWorkspaceExecutorCheckResponse = {
executor?: AiWorkspaceExecutor;
check?: JsonRecord;
};
export type SeoAnalyticsModelSetupCommand = {
schemaVersion: "seo-analytics-model-setup-command.v1";
generatedAt: string;
profile: string;
executor: {
id: string;
name: string;
status: AiWorkspaceExecutor["status"];
statusDetail: string;
};
install: {
command: string;
packageName: string;
packageSpec: string;
gatewayUrl: string;
expiresAt: string | null;
};
setupCode: {
suffix: string | null;
expiresAt: string | null;
};
};
export type SeoAnalyticsModelProbe = {
schemaVersion: "seo-analytics-model-probe.v1";
checkedAt: string;
profile: string;
config: {
configured: boolean;
assistantUrl: string | null;
selectedExecutorId: string | null;
contractWorkspacePath: string | null;
errors: string[];
warnings: string[];
};
executor: {
id: string | null;
name: string | null;
status: AiWorkspaceExecutor["status"] | "not_configured";
statusDetail: string;
lastSeenAt: string | null;
};
connection: {
ok: boolean;
status: "connected" | "missing_config" | "not_configured" | "offline" | "error";
detail: string;
};
check: JsonRecord | null;
};
function isJsonRecord(value: unknown): value is JsonRecord {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isUuid(value: string | null | undefined) {
return Boolean(
value &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
);
}
function isSeoAnalyticsExecutor(executor: AiWorkspaceExecutor) {
const metadata = isJsonRecord(executor.metadata) ? executor.metadata : {};
return (
metadata.appId === "seo-mode" ||
metadata.surface === "seo-mode" ||
metadata.modeId === "seo-model-task" ||
executor.name === "NDC SEO Mode Codex Bridge" ||
executor.name === "NDC SEO Mode Local Codex"
);
}
async function listAiWorkspaceExecutors() {
const response = await assistantRequest<AiWorkspaceExecutorsResponse>("/api/ai-workspace/assistant/v1/executors", {
method: "GET"
});
return {
executors: Array.isArray(response.executors) ? response.executors : [],
selectedExecutorId: typeof response.selectedExecutorId === "string" ? response.selectedExecutorId : null
};
}
async function createSeoAnalyticsExecutor() {
const response = await assistantRequest<AiWorkspaceExecutorResponse>("/api/ai-workspace/assistant/v1/executors", {
body: {
accountLabel: "NDC SEO Mode",
capabilities: ["codex-exec", "dynamic-run-profile", "seo-model-task", "structured-json"],
connectionMode: "hub",
id: randomUUID(),
metadata: {
appId: "seo-mode",
createdBy: "nodedc-seo-mode",
modeId: "seo-model-task",
profile: env.aiWorkspace.profile,
surface: "seo-mode"
},
model: "codex-bridge",
name: "NDC SEO Mode Codex Bridge",
status: "unknown",
type: "codex-remote",
workspacePath: env.aiWorkspace.contractWorkspacePath ?? ""
},
method: "POST"
});
if (!response.executor?.id) {
throw new Error("ai_workspace_executor_id_missing");
}
return response.executor;
}
async function resolveSeoAnalyticsExecutor(inputExecutorId?: string | null) {
const { executors, selectedExecutorId } = await listAiWorkspaceExecutors();
const explicitExecutorId =
(isUuid(inputExecutorId) ? inputExecutorId : null) ||
(isUuid(env.aiWorkspace.selectedExecutorId) ? env.aiWorkspace.selectedExecutorId : null);
const explicitExecutor = explicitExecutorId
? executors.find((executor) => executor.id === explicitExecutorId) ?? null
: null;
const selectedExecutor = selectedExecutorId
? executors.find((executor) => executor.id === selectedExecutorId && isSeoAnalyticsExecutor(executor)) ?? null
: null;
const seoExecutor = executors.find(isSeoAnalyticsExecutor) ?? null;
return {
executor: explicitExecutor ?? selectedExecutor ?? seoExecutor,
selectedExecutorId
};
}
export async function createSeoAnalyticsModelSetupCommand(input: { executorId?: string | null; port?: number }) {
const configStatus = getSeoAiWorkspaceConfigStatus();
if (!configStatus.configured) {
throw new Error(configStatus.errors.join(" ") || "SEO AI Workspace bridge is not configured.");
}
const resolved = await resolveSeoAnalyticsExecutor(input.executorId);
const executor = resolved.executor ?? await createSeoAnalyticsExecutor();
const setup = await assistantRequest<AiWorkspaceSetupCommandResponse>(
`/api/ai-workspace/assistant/v1/executors/${encodeURIComponent(executor.id)}/agent/setup-command`,
{
body: {
port: input.port ?? 8787
},
method: "POST"
}
);
const command = String(setup.install?.command || "");
if (!command) {
throw new Error("ai_workspace_setup_command_missing");
}
return {
schemaVersion: "seo-analytics-model-setup-command.v1",
generatedAt: new Date().toISOString(),
profile: configStatus.profile,
executor: {
id: executor.id,
name: executor.name,
status: executor.status,
statusDetail: executor.statusDetail
},
install: {
command,
packageName: String(setup.install?.packageName || ""),
packageSpec: String(setup.install?.packageSpec || ""),
gatewayUrl: String(setup.install?.gatewayUrl || ""),
expiresAt: setup.install?.expiresAt ?? null
},
setupCode: {
suffix: setup.setupCode?.suffix ?? null,
expiresAt: setup.setupCode?.expiresAt ?? setup.install?.expiresAt ?? null
}
} satisfies SeoAnalyticsModelSetupCommand;
}
export async function probeSeoAnalyticsModel(input: { executorId?: string | null } = {}): Promise<SeoAnalyticsModelProbe> {
const checkedAt = new Date().toISOString();
const configStatus = getSeoAiWorkspaceConfigStatus();
const config = {
configured: configStatus.configured,
assistantUrl: configStatus.assistantUrl,
selectedExecutorId: configStatus.selectedExecutorId,
contractWorkspacePath: configStatus.contractWorkspacePath,
errors: configStatus.errors,
warnings: configStatus.warnings
};
if (!configStatus.configured) {
return {
schemaVersion: "seo-analytics-model-probe.v1",
checkedAt,
profile: configStatus.profile,
config,
executor: {
id: null,
name: null,
status: "not_configured",
statusDetail: "missing_ai_workspace_config",
lastSeenAt: null
},
connection: {
ok: false,
status: "missing_config",
detail: configStatus.errors.join(" ") || "SEO AI Workspace bridge is not configured."
},
check: null
};
}
const resolved = await resolveSeoAnalyticsExecutor(input.executorId);
if (!resolved.executor) {
return {
schemaVersion: "seo-analytics-model-probe.v1",
checkedAt,
profile: configStatus.profile,
config,
executor: {
id: null,
name: null,
status: "not_configured",
statusDetail: "seo_codex_executor_missing",
lastSeenAt: null
},
connection: {
ok: false,
status: "not_configured",
detail: "Сначала сгенерируй NPM-команду и запусти bridge на Codex-машине."
},
check: null
};
}
const response = await assistantRequest<AiWorkspaceExecutorCheckResponse>(
`/api/ai-workspace/assistant/v1/executors/${encodeURIComponent(resolved.executor.id)}/check`,
{
method: "POST"
}
);
const executor = response.executor ?? resolved.executor;
const isConnected = executor.status === "online";
return {
schemaVersion: "seo-analytics-model-probe.v1",
checkedAt,
profile: configStatus.profile,
config,
executor: {
id: executor.id,
name: executor.name,
status: executor.status,
statusDetail: executor.statusDetail,
lastSeenAt: executor.lastSeenAt
},
connection: {
ok: isConnected,
status: isConnected ? "connected" : executor.status === "error" ? "error" : "offline",
detail: isConnected ? "Codex bridge is online." : executor.statusDetail || "bridge_agent_offline"
},
check: response.check ?? null
};
}

View File

@ -207,6 +207,10 @@ function normalizeKey(value: string | null) {
return value?.trim() || "unmapped";
}
function normalizePhraseKey(value: string) {
return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU");
}
function getConfidence(score: number): SeoStrategyConfidence {
if (score >= 75) {
return "high";
@ -303,6 +307,7 @@ function getOpportunityTiming(
}
function buildSiteMaturity(input: {
activeApprovedKeywordCount: number;
keywordMap: KeywordMapContract;
market: MarketEnrichmentContract;
serpInterpretation: SerpInterpretationContract;
@ -351,7 +356,7 @@ function buildSiteMaturity(input: {
: "Нет данных Webmaster/Метрики/Search Console-style: рынок виден, но сам сайт ещё не доказал индексацию и первые сигналы.",
schemaVersion: "site-maturity.v1",
signals: {
approvedKeywordCount: input.keywordMap.persisted.approvedDecisionCount,
approvedKeywordCount: input.activeApprovedKeywordCount,
hasAuthorityEvidence,
hasIndexEvidence,
hasPerformanceEvidence,
@ -809,9 +814,11 @@ function buildDemandEvidence(
cleaning: KeywordCleaningContract,
keywordMap: KeywordMapContract
): SeoStrategyContract["evidence"]["demand"] {
const persistedPhraseSet = new Set(keywordMap.persisted.items.map((item) => item.phrase.toLocaleLowerCase("ru-RU")));
const persistedPhraseSet = new Set(
getActiveStrategyKeywordMapItems(cleaning, keywordMap).map((item) => normalizePhraseKey(item.phrase))
);
const topSignals = cleaning.items
.filter((item) => persistedPhraseSet.has(item.phrase.toLocaleLowerCase("ru-RU")) && item.frequency !== null)
.filter((item) => persistedPhraseSet.has(normalizePhraseKey(item.phrase)) && item.frequency !== null)
.sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))
.slice(0, 8)
.map((item) => ({
@ -868,7 +875,7 @@ function getCleaningItemByPhrase(cleaning: KeywordCleaningContract) {
const itemsByPhrase = new Map<string, KeywordCleaningContract["items"][number]>();
for (const item of cleaning.items) {
const key = item.phrase.toLocaleLowerCase("ru-RU");
const key = normalizePhraseKey(item.phrase);
const currentItem = itemsByPhrase.get(key);
if (!currentItem || (item.frequency ?? 0) > (currentItem.frequency ?? 0)) {
@ -879,6 +886,30 @@ function getCleaningItemByPhrase(cleaning: KeywordCleaningContract) {
return itemsByPhrase;
}
function getActiveStrategyKeywordMapItems(cleaning: KeywordCleaningContract, keywordMap: KeywordMapContract) {
const cleaningByPhrase = getCleaningItemByPhrase(cleaning);
const currentRoutableByPhrase = new Map(
keywordMap.items
.filter((item) => item.role !== "validate" && item.role !== "secondary_candidate")
.map((item) => [normalizePhraseKey(item.phrase), item])
);
return keywordMap.persisted.items.filter((item) => {
const cleaningItem = cleaningByPhrase.get(normalizePhraseKey(item.phrase));
const currentMapItem = currentRoutableByPhrase.get(normalizePhraseKey(item.phrase));
return (
Boolean(currentMapItem) &&
Boolean(cleaningItem) &&
(cleaningItem?.decision === "use" || cleaningItem?.decision === "support") &&
cleaningItem?.evidenceStatus === "collected" &&
cleaningItem?.frequency !== null &&
currentMapItem?.role === item.role &&
normalizeKey(currentMapItem?.targetPath ?? null) === normalizeKey(item.targetPath)
);
});
}
function buildOpportunities(
cleaning: KeywordCleaningContract,
keywordMap: KeywordMapContract,
@ -887,7 +918,7 @@ function buildOpportunities(
const cleaningByPhrase = getCleaningItemByPhrase(cleaning);
const groups = new Map<string, KeywordMapPersistedItem[]>();
for (const item of keywordMap.persisted.items) {
for (const item of getActiveStrategyKeywordMapItems(cleaning, keywordMap)) {
const key = normalizeKey(item.targetPath);
const group = groups.get(key) ?? [];
@ -952,15 +983,24 @@ function buildOpportunities(
}
function buildSerpOpportunities(
cleaning: KeywordCleaningContract,
serpInterpretation: SerpInterpretationContract,
keywordMap: KeywordMapContract,
siteMaturity: SeoStrategyContract["siteMaturity"]
): SeoStrategyContract["opportunities"] {
if (serpInterpretation.state !== "ready") {
return [];
}
const approvedPhraseSet = new Set(getActiveStrategyKeywordMapItems(cleaning, keywordMap).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";
@ -1060,6 +1100,7 @@ function buildMissingEvidence(
}
function buildRisks(input: {
activeApprovedKeywordCount: number;
cleaning: KeywordCleaningContract;
contextReview: Awaited<ReturnType<typeof getLatestSeoContextReview>>;
keywordMap: KeywordMapContract;
@ -1092,13 +1133,13 @@ function buildRisks(input: {
});
}
if (input.keywordMap.persisted.approvedDecisionCount === 0) {
if (input.activeApprovedKeywordCount === 0) {
risks.push({
id: "risk:no_approved_keywords",
level: "critical",
message: "Нет подтверждённых решений по фразам, поэтому стратегия не имеет опорной карты спроса.",
mitigation: "Сначала сохранить решения карты фраз, подтверждённые доказательствами.",
title: "Нет утверждённой карты решений"
message: "Нет активных подтверждённых решений по фразам: сохранённые ранее решения устарели или не проходят текущий semantic/evidence guard.",
mitigation: "Сначала сохранить актуальные решения карты фраз, подтверждённые текущим cleaning и Stage 3.",
title: "Нет актуальной карты решений"
});
}
@ -1227,6 +1268,7 @@ function buildStrategyOptions(input: {
}
function getStrategyScore(input: {
activeApprovedKeywordCount: number;
contextReady: boolean;
keywordMap: KeywordMapContract;
market: MarketEnrichmentContract;
@ -1241,7 +1283,7 @@ function getStrategyScore(input: {
score += 20;
}
if (input.keywordMap.persisted.approvedDecisionCount > 0) {
if (input.activeApprovedKeywordCount > 0) {
score += 20;
}
@ -1270,8 +1312,12 @@ function getStrategyScore(input: {
return Math.max(0, Math.min(100, score));
}
function getState(score: number, keywordMap: KeywordMapContract, missingEvidence: SeoStrategyContract["missingEvidence"]): SeoStrategyState {
if (keywordMap.persisted.approvedDecisionCount === 0) {
function getState(
score: number,
activeApprovedKeywordCount: number,
missingEvidence: SeoStrategyContract["missingEvidence"]
): SeoStrategyState {
if (activeApprovedKeywordCount === 0) {
return "blocked";
}
@ -1331,15 +1377,24 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
: getEmptySerpInterpretationContract(projectId);
const context = buildContextEvidence(contextReview);
const contextReady = context.status === "ready";
const activeStrategyKeywordMapItems = getActiveStrategyKeywordMapItems(cleaning, keywordMap);
const activeApprovedKeywordCount = activeStrategyKeywordMapItems.length;
const demand = buildDemandEvidence(market, cleaning, keywordMap);
const siteMaturity = buildSiteMaturity({ keywordMap, market, serpInterpretation, yandexEvidence });
const siteMaturity = buildSiteMaturity({
activeApprovedKeywordCount,
keywordMap,
market,
serpInterpretation,
yandexEvidence
});
const phasePlan = buildPhasePlan(siteMaturity);
const opportunities = [
...buildOpportunities(cleaning, keywordMap, siteMaturity),
...buildSerpOpportunities(serpInterpretation, siteMaturity)
...buildSerpOpportunities(cleaning, serpInterpretation, keywordMap, siteMaturity)
];
const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation);
const risks = buildRisks({
activeApprovedKeywordCount,
cleaning,
contextReview,
keywordMap,
@ -1358,6 +1413,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
siteMaturity
});
const strategyConfidenceScore = getStrategyScore({
activeApprovedKeywordCount,
contextReady,
keywordMap,
market,
@ -1376,7 +1432,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
semanticRunId: market.semanticRunId,
projectOntologyVersion: market.projectOntologyVersion,
generatedAt: new Date().toISOString(),
state: getState(strategyConfidenceScore, keywordMap, missingEvidence),
state: getState(strategyConfidenceScore, activeApprovedKeywordCount, missingEvidence),
verdict: {
confidence: getConfidence(strategyConfidenceScore),
primaryConstraint:
@ -1393,7 +1449,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
: "Стратегия пока не доказана"
},
readiness: {
approvedKeywordCount: keywordMap.persisted.approvedDecisionCount,
approvedKeywordCount: activeApprovedKeywordCount,
contextReady,
evidenceGapCount: missingEvidence.length,
externalProviderConnectedCount,

View File

@ -5,7 +5,7 @@ import { getLatestStrategySynthesisContract, type StrategySynthesisContract } fr
import { buildStrategyQualityReviewModelTask } from "./strategyQualityReviewTask.js";
type RunStatus = "cancelled" | "done" | "failed" | "queued" | "running";
type StrategyQualityReviewProviderMode = "codex_manual" | "deterministic_fallback";
type StrategyQualityReviewProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
type StrategyQualityReviewState = "not_ready" | "ready";
type StrategyQualityReviewVerdictStatus = "approved" | "blocked" | "needs_changes";
type StrategyQualityReviewCheckStatus = "fail" | "pass" | "warning";
@ -112,6 +112,161 @@ function getDefaultAnalystReview(): StrategyQualityReviewContract["analystReview
};
}
function isVerdictStatus(value: unknown): value is StrategyQualityReviewVerdictStatus {
return value === "approved" || value === "blocked" || value === "needs_changes";
}
function isConfidence(value: unknown): value is StrategyQualityReviewContract["verdict"]["confidence"] {
return value === "high" || value === "medium" || value === "low";
}
function normalizeCheckStatus(value: unknown): StrategyQualityReviewCheckStatus {
if (value === "pass" || value === "passed") return "pass";
if (value === "fail" || value === "failed") return "fail";
if (value === "warning" || value === "warn") return "warning";
return "warning";
}
function normalizeCheckSeverity(value: unknown): StrategyQualityReviewSeverity {
if (value === "critical" || value === "high" || value === "medium" || value === "low") {
return value;
}
return "low";
}
function normalizeCheckCategory(value: unknown): StrategyQualityReviewCategory {
const category = String(value || "").trim();
if (
category === "business_drift" ||
category === "evidence_integrity" ||
category === "hallucination_guardrail" ||
category === "phase_guardrail" ||
category === "source_safety" ||
category === "ux_clarity"
) {
return category;
}
if (category === "business_scope") return "business_drift";
if (category === "evidence" || category === "evidence_gap") return "evidence_integrity";
if (category === "phase" || category === "phase_violation") return "phase_guardrail";
if (category === "guardrail" || category === "intent") return "hallucination_guardrail";
if (category === "input_state") return "ux_clarity";
return "source_safety";
}
function normalizeModelProviderChecks(value: unknown, fallback: StrategyQualityReviewContract["checks"]) {
if (!Array.isArray(value)) {
return fallback;
}
return value.map((item, index) => {
const record = (item && typeof item === "object" ? item : {}) as Record<string, unknown>;
return makeCheck({
action: typeof record.action === "string" && record.action.trim() ? record.action.trim() : "Проверить пункт quality gate вручную.",
category: normalizeCheckCategory(record.category),
evidenceRefs: Array.isArray(record.evidenceRefs) ? record.evidenceRefs.map((ref) => String(ref || "").trim()).filter(Boolean) : [],
id: typeof record.id === "string" && record.id.trim() ? record.id.trim() : `model-check:${index + 1}`,
message: typeof record.message === "string" && record.message.trim() ? record.message.trim() : "Модель вернула проверку без описания.",
severity: normalizeCheckSeverity(record.severity),
status: normalizeCheckStatus(record.status),
title: typeof record.title === "string" && record.title.trim() ? record.title.trim() : `Проверка ${index + 1}`
});
});
}
function stringList(value: unknown, fallback: string[]) {
if (!Array.isArray(value)) {
return fallback;
}
const result = value.map((item) => {
if (typeof item === "string") {
return item.trim();
}
if (item && typeof item === "object") {
const record = item as Record<string, unknown>;
for (const key of ["title", "message", "action", "summary", "nextStep", "label"]) {
const text = record[key];
if (typeof text === "string" && text.trim()) {
return text.trim();
}
}
}
return String(item || "").trim();
}).filter(Boolean);
return result.length > 0 ? result : fallback;
}
function mergeModelProviderStrategyQualityReviewOutput(
output: StrategyQualityReviewContract,
fallback: StrategyQualityReviewContract
): StrategyQualityReviewContract {
const rawOutput = output as StrategyQualityReviewContract & Record<string, any>;
const rawVerdict = rawOutput.verdict;
const verdict = (rawVerdict && typeof rawVerdict === "object" ? rawVerdict : {}) as Record<string, any>;
const status = isVerdictStatus(rawVerdict) ? rawVerdict : isVerdictStatus(verdict.status) ? verdict.status : fallback.verdict.status;
const blockers = stringList(rawOutput.blockers, fallback.blockers);
const warnings = stringList(rawOutput.warnings, fallback.warnings);
const summary =
typeof rawOutput.summary === "string" && rawOutput.summary.trim()
? rawOutput.summary.trim()
: typeof verdict.summary === "string" && verdict.summary.trim()
? verdict.summary.trim()
: fallback.summary;
const score = Number(verdict.score ?? rawOutput.readiness?.score ?? fallback.verdict.score);
return {
...fallback,
...output,
analystReview: output.analystReview ?? fallback.analystReview,
approvedSignals: stringList(rawOutput.approvedSignals, fallback.approvedSignals),
blockers,
checks: normalizeModelProviderChecks(rawOutput.checks, fallback.checks),
nextActions: stringList(rawOutput.nextActions, fallback.nextActions),
readiness: {
...fallback.readiness,
...(rawOutput.readiness && typeof rawOutput.readiness === "object" ? rawOutput.readiness : {})
},
runId: output.runId ?? fallback.runId,
semanticRunId: output.semanticRunId ?? fallback.semanticRunId,
sourceSerpInterpretationRunId: output.sourceSerpInterpretationRunId ?? fallback.sourceSerpInterpretationRunId,
sourceStrategyGeneratedAt: output.sourceStrategyGeneratedAt ?? fallback.sourceStrategyGeneratedAt,
sourceStrategySynthesisRunId: output.sourceStrategySynthesisRunId ?? fallback.sourceStrategySynthesisRunId,
sourceTaskId: output.sourceTaskId ?? fallback.sourceTaskId,
state: output.state ?? fallback.state,
summary,
verdict: {
confidence: isConfidence(verdict.confidence) ? verdict.confidence : fallback.verdict.confidence,
primaryBlocker:
typeof verdict.primaryBlocker === "string" && verdict.primaryBlocker.trim()
? verdict.primaryBlocker.trim()
: blockers[0] ?? fallback.verdict.primaryBlocker,
score: Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score))) : fallback.verdict.score,
status,
summary,
title:
typeof verdict.title === "string" && verdict.title.trim()
? verdict.title.trim()
: status === "approved"
? "Стратегия прошла проверку"
: status === "needs_changes"
? "Стратегия требует исправлений"
: fallback.verdict.title
},
warnings
};
}
function mapStrategyQualityReviewRun(row: StrategyQualityReviewRunRow): StrategyQualityReviewRun | null {
if (!row.output) {
return null;
@ -224,13 +379,54 @@ 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;
const hasActiveKeywordMap = strategy.readiness.approvedKeywordCount > 0 && strategy.opportunities.length > 0;
const synthesisHasActionableRoute =
synthesis.recommendedPath.doNow.length > 0 ||
synthesis.decisionOptions.some((option) => option.scenario !== "analytics_first" && option.scenario !== "bootstrap_runway");
return [
makeCheck({
action: hasActiveKeywordMap
? "Продолжать синтез только из актуальной сохранённой keyword map."
: "Сначала пересохранить Stage 3 после текущего keyword cleaning: stale approved decisions не должны кормить стратегию.",
category: "evidence_integrity",
id: "evidence:active_keyword_map",
message: hasActiveKeywordMap
? `${strategy.readiness.approvedKeywordCount} активных keyword decisions доступны стратегии.`
: "Нет активных keyword decisions: текущий cleaning/Stage 3 не подтверждает сохранённую ранее карту.",
severity: hasActiveKeywordMap ? "low" : "critical",
status: hasActiveKeywordMap ? "pass" : "fail",
title: "Актуальная карта ключей"
}),
makeCheck({
action:
strategy.state === "blocked" && synthesisHasActionableRoute
? "Пересобрать synthesis после актуализации keyword map или оставить только data-gap/bootstrap действия."
: "Не давать synthesis текущие SEO-действия, если базовая strategy заблокирована.",
category: "phase_guardrail",
id: "phase:no_actionable_synthesis_when_strategy_blocked",
message:
strategy.state === "blocked" && synthesisHasActionableRoute
? "Strategy заблокирована, но synthesis всё ещё содержит action route/decision options."
: "Synthesis не обходит заблокированное состояние базовой strategy.",
severity: strategy.state === "blocked" && synthesisHasActionableRoute ? "critical" : "low",
status: strategy.state === "blocked" && synthesisHasActionableRoute ? "fail" : "pass",
title: "Synthesis не обходит strategy gate"
}),
makeCheck({
action:
immediateHeadTargets.length > 0
@ -288,14 +484,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 +703,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,16 +726,34 @@ 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
};
}
export async function getLatestStrategyQualityReviewContract(projectId: string): Promise<StrategyQualityReviewContract> {
const [strategy, synthesis, serpInterpretation] = await Promise.all([
getSeoStrategyContract(projectId),
getLatestStrategySynthesisContract(projectId),
getLatestSerpInterpretationContract(projectId)
]);
const currentBackendReview = buildStrategyQualityReviewOutput(projectId, strategy, synthesis, serpInterpretation);
if (currentBackendReview) {
return currentBackendReview;
}
const result = await pool.query<StrategyQualityReviewRunRow>(
`
select id, status, input, output, error_message, started_at, completed_at, created_at
@ -619,3 +839,54 @@ export async function saveStrategyQualityReviewManual(
return run;
}
export async function saveStrategyQualityReviewFromModelProvider(
projectId: string,
output: StrategyQualityReviewContract,
sourceModelTaskRunId: string
): Promise<StrategyQualityReviewRun> {
const [strategy, synthesis, serpInterpretation] = await Promise.all([
getSeoStrategyContract(projectId),
getLatestStrategySynthesisContract(projectId),
getLatestSerpInterpretationContract(projectId)
]);
const task = buildStrategyQualityReviewModelTask(projectId, strategy, synthesis, serpInterpretation);
const fallbackOutput = task ? buildStrategyQualityReviewOutput(projectId, strategy, synthesis, serpInterpretation) : null;
if (!task || !fallbackOutput) {
throw new Error("Не удалось собрать backend fallback для AI Workspace strategy quality review.");
}
const normalizedOutput = normalizeOutput(
projectId,
mergeModelProviderStrategyQualityReviewOutput(output, fallbackOutput),
"codex_workspace",
"AI Workspace Codex provider выполнил seo.strategy_quality_review по contract-only task; результат сохранён как evidence после backend validation."
);
const result = await pool.query<StrategyQualityReviewRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'seo_strategy_quality_review', 'done', $2::jsonb, $3::jsonb, now(), now())
returning id, status, input, output, error_message, started_at, completed_at, created_at;
`,
[
projectId,
{
providerMode: "codex_workspace",
schemaVersion: "seo-strategy-quality-review-model-provider-input.v1",
sourceModelTaskRunId,
sourceStrategyGeneratedAt: normalizedOutput.sourceStrategyGeneratedAt,
sourceStrategySynthesisRunId: normalizedOutput.sourceStrategySynthesisRunId,
taskType: "seo.strategy_quality_review"
},
normalizedOutput
]
);
const run = result.rows[0] ? mapStrategyQualityReviewRun(result.rows[0]) : null;
if (!run) {
throw new Error("Не удалось сохранить AI Workspace strategy quality review.");
}
return run;
}

View File

@ -4,7 +4,7 @@ import { getSeoStrategyContract, type SeoStrategyContract } from "./seoStrategy.
import { buildStrategySynthesisModelTask } from "./strategySynthesisTask.js";
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
type StrategySynthesisProviderMode = "codex_manual" | "deterministic_fallback";
type StrategySynthesisProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
type StrategySynthesisState = "not_ready" | "ready";
type StrategySynthesisConfidence = "high" | "low" | "medium";
type StrategySynthesisPriority = "high" | "low" | "medium";
@ -158,6 +158,142 @@ function getDefaultAnalystReview(): StrategySynthesisContract["analystReview"] {
};
}
function isConfidence(value: unknown): value is StrategySynthesisConfidence {
return value === "high" || value === "medium" || value === "low";
}
function isPriority(value: unknown): value is StrategySynthesisPriority {
return value === "high" || value === "medium" || value === "low";
}
function stringList(value: unknown, fallback: string[]) {
if (!Array.isArray(value)) {
return fallback;
}
const result = value.map((item) => {
if (typeof item === "string") {
return item.trim();
}
if (item && typeof item === "object") {
const record = item as Record<string, unknown>;
for (const key of ["title", "message", "nextStep", "action", "summary", "label"]) {
const text = record[key];
if (typeof text === "string" && text.trim()) {
return text.trim();
}
}
}
return String(item || "").trim();
}).filter(Boolean);
return result.length > 0 ? result : fallback;
}
function mergeModelProviderStrategySynthesisOutput(
output: StrategySynthesisContract,
fallback: StrategySynthesisContract
): StrategySynthesisContract {
const rawOutput = output as StrategySynthesisContract & Record<string, any>;
const executiveSummary = (rawOutput.executiveSummary ?? {}) as Record<string, any>;
const phaseDecision = (rawOutput.phaseDecision ?? {}) as Record<string, any>;
const recommendedPath = (rawOutput.recommendedPath ?? {}) as Record<string, any>;
const summary =
typeof rawOutput.summary === "string" && rawOutput.summary.trim()
? rawOutput.summary.trim()
: typeof executiveSummary.summary === "string" && executiveSummary.summary.trim()
? executiveSummary.summary.trim()
: fallback.summary;
const executiveKeyPoints = stringList(executiveSummary.keyPoints, []);
const blockedUntil = stringList(recommendedPath.blockedUntil, []);
return {
...fallback,
...output,
analystReview: output.analystReview ?? fallback.analystReview,
blockedActions: stringList(rawOutput.blockedActions, fallback.blockedActions),
dataGaps: Array.isArray(rawOutput.dataGaps) ? rawOutput.dataGaps : fallback.dataGaps,
decisionOptions: Array.isArray(rawOutput.decisionOptions) ? rawOutput.decisionOptions : fallback.decisionOptions,
evidenceNarrative: Array.isArray(rawOutput.evidenceNarrative) ? rawOutput.evidenceNarrative : fallback.evidenceNarrative,
executiveSummary: {
confidence: isConfidence(executiveSummary.confidence) ? executiveSummary.confidence : fallback.executiveSummary.confidence,
primaryConstraint:
typeof executiveSummary.primaryConstraint === "string" && executiveSummary.primaryConstraint.trim()
? executiveSummary.primaryConstraint.trim()
: blockedUntil[0] ?? fallback.executiveSummary.primaryConstraint,
title:
typeof executiveSummary.title === "string" && executiveSummary.title.trim()
? executiveSummary.title.trim()
: fallback.executiveSummary.title,
verdict:
typeof executiveSummary.verdict === "string" && executiveSummary.verdict.trim()
? executiveSummary.verdict.trim()
: summary,
whyNow:
typeof executiveSummary.whyNow === "string" && executiveSummary.whyNow.trim()
? executiveSummary.whyNow.trim()
: executiveKeyPoints.length > 0
? executiveKeyPoints.join(" ")
: fallback.executiveSummary.whyNow
},
generatedAt: output.generatedAt ?? fallback.generatedAt,
nextActions: stringList(rawOutput.nextActions, fallback.nextActions),
phaseDecision: {
allowedNow: stringList(phaseDecision.allowedNow, fallback.phaseDecision.allowedNow),
currentPhase: phaseDecision.currentPhase ?? fallback.phaseDecision.currentPhase,
hold: stringList(phaseDecision.hold, stringList(phaseDecision.deferred, fallback.phaseDecision.hold)),
phaseRoadmap: Array.isArray(phaseDecision.phaseRoadmap) ? phaseDecision.phaseRoadmap : fallback.phaseDecision.phaseRoadmap,
prepareNext: stringList(phaseDecision.prepareNext, fallback.phaseDecision.prepareNext),
promotionSignals: stringList(phaseDecision.promotionSignals, fallback.phaseDecision.promotionSignals),
schemaVersion: "seo-phase-decision.v1",
summary:
typeof phaseDecision.summary === "string" && phaseDecision.summary.trim()
? phaseDecision.summary.trim()
: typeof phaseDecision.reason === "string" && phaseDecision.reason.trim()
? phaseDecision.reason.trim()
: fallback.phaseDecision.summary,
title:
typeof phaseDecision.title === "string" && phaseDecision.title.trim()
? phaseDecision.title.trim()
: fallback.phaseDecision.title
},
phaseStrategies: Array.isArray(rawOutput.phaseStrategies) ? rawOutput.phaseStrategies : fallback.phaseStrategies,
readiness: {
...fallback.readiness,
...(rawOutput.readiness && typeof rawOutput.readiness === "object" ? rawOutput.readiness : {})
},
recommendedPath: {
confidence: isConfidence(recommendedPath.confidence) ? recommendedPath.confidence : fallback.recommendedPath.confidence,
doNow: stringList(recommendedPath.doNow, stringList(recommendedPath.currentActions, fallback.recommendedPath.doNow)),
expectedImpact: isPriority(recommendedPath.expectedImpact) ? recommendedPath.expectedImpact : fallback.recommendedPath.expectedImpact,
hold: stringList(recommendedPath.hold, blockedUntil.length > 0 ? blockedUntil : fallback.recommendedPath.hold),
id: typeof recommendedPath.id === "string" && recommendedPath.id.trim() ? recommendedPath.id.trim() : fallback.recommendedPath.id,
needsEvidence: stringList(recommendedPath.needsEvidence, blockedUntil.length > 0 ? blockedUntil : fallback.recommendedPath.needsEvidence),
priority: isPriority(recommendedPath.priority) ? recommendedPath.priority : fallback.recommendedPath.priority,
rationale:
typeof recommendedPath.rationale === "string" && recommendedPath.rationale.trim()
? recommendedPath.rationale.trim()
: fallback.recommendedPath.rationale,
title:
typeof recommendedPath.title === "string" && recommendedPath.title.trim()
? recommendedPath.title.trim()
: fallback.recommendedPath.title
},
riskPosture: Array.isArray(rawOutput.riskPosture) ? rawOutput.riskPosture : fallback.riskPosture,
runId: output.runId ?? fallback.runId,
semanticRunId: output.semanticRunId ?? fallback.semanticRunId,
sourceSerpInterpretationRunId: output.sourceSerpInterpretationRunId ?? fallback.sourceSerpInterpretationRunId,
sourceStrategyGeneratedAt: output.sourceStrategyGeneratedAt ?? fallback.sourceStrategyGeneratedAt,
sourceTaskId: output.sourceTaskId ?? fallback.sourceTaskId,
state: output.state ?? fallback.state,
summary
};
}
function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesisRun | null {
if (!row.output) {
return null;
@ -618,3 +754,53 @@ export async function saveStrategySynthesisManual(
return run;
}
export async function saveStrategySynthesisFromModelProvider(
projectId: string,
output: StrategySynthesisContract,
sourceModelTaskRunId: string
): Promise<StrategySynthesisRun> {
const [strategy, serpInterpretation] = await Promise.all([
getSeoStrategyContract(projectId),
getLatestSerpInterpretationContract(projectId)
]);
const fallbackOutput = buildStrategySynthesisOutput(projectId, strategy, serpInterpretation);
if (!fallbackOutput) {
throw new Error("Не удалось собрать backend fallback для AI Workspace Strategy synthesis.");
}
const normalizedOutput = normalizeOutput(
projectId,
mergeModelProviderStrategySynthesisOutput(output, fallbackOutput),
"codex_workspace",
"AI Workspace Codex provider выполнил seo.strategy_synthesis по contract-only task; результат сохранён как evidence после backend validation."
);
const result = await pool.query<StrategySynthesisRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'seo_strategy_synthesis', 'done', $2::jsonb, $3::jsonb, now(), now())
returning id, status, input, output, error_message, started_at, completed_at, created_at;
`,
[
projectId,
JSON.stringify({
providerMode: "codex_workspace",
schemaVersion: "seo-strategy-synthesis-model-provider-input.v1",
sourceModelTaskRunId,
sourceSerpInterpretationRunId: normalizedOutput.sourceSerpInterpretationRunId,
sourceStrategyGeneratedAt: normalizedOutput.sourceStrategyGeneratedAt,
sourceTaskId: normalizedOutput.sourceTaskId,
taskType: "seo.strategy_synthesis"
}),
JSON.stringify(normalizedOutput)
]
);
const run = result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null;
if (!run) {
throw new Error("Не удалось сохранить AI Workspace Strategy synthesis.");
}
return run;
}