Compare commits
4 Commits
75fd57b036
...
a8f03570c6
| Author | SHA1 | Date |
|---|---|---|
|
|
a8f03570c6 | |
|
|
a5a680bf00 | |
|
|
43d2af454d | |
|
|
1ed099140f |
|
|
@ -19,9 +19,33 @@ WORDSTAT_PROVIDER=disabled
|
|||
# WORDSTAT_REGION_IDS=225
|
||||
# WORDSTAT_DEVICES=DEVICE_ALL
|
||||
# WORDSTAT_NUM_PHRASES=50
|
||||
# WORDSTAT_MAX_SEEDS_PER_RUN=40
|
||||
|
||||
SERP_PROVIDER=disabled
|
||||
# SERP_API_BASE_URL=https://serp-provider.internal
|
||||
# SERP_API_TOKEN=change-me
|
||||
|
||||
# SEO model-provider bridge through AI Workspace Assistant / AI Hub / remote Codex worker.
|
||||
# Keep disabled until a contract-only worker workspace and explicit owner/profile are configured.
|
||||
# Profiles:
|
||||
# - local: local AI Workspace Assistant at 127.0.0.1/localhost.
|
||||
# - deployed-hub: local SEO backend uses deployed https://ai-hub.nodedc.ru token-gated control-plane proxy.
|
||||
# - deploy-host: SEO backend runs inside deployed Docker network and talks to http://ai-workspace-assistant:18082.
|
||||
# - tunnel-local-e2e: explicit tunnel/Tailscale end-to-end test profile.
|
||||
SEO_AI_WORKSPACE_PROFILE=disabled
|
||||
# SEO_AI_WORKSPACE_CONTROL_URL=http://127.0.0.1:18082
|
||||
# SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru
|
||||
# SEO_AI_WORKSPACE_CONTROL_TOKEN=
|
||||
# Legacy aliases accepted:
|
||||
# SEO_AI_WORKSPACE_ASSISTANT_URL=
|
||||
# SEO_AI_WORKSPACE_ASSISTANT_TOKEN=
|
||||
# SEO_AI_WORKSPACE_OWNER_USER_ID=
|
||||
# SEO_AI_WORKSPACE_OWNER_EMAIL=
|
||||
# SEO_AI_WORKSPACE_OWNER_ROLE=
|
||||
# SEO_AI_WORKSPACE_OWNER_GROUPS=
|
||||
# SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID=
|
||||
# SEO_AI_WORKSPACE_CONTRACT_ONLY=1
|
||||
# SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH=/tmp/nodedc-seo-model-contracts
|
||||
# SEO_AI_WORKSPACE_TIMEOUT_MS=15000
|
||||
|
||||
VITE_API_BASE_URL=http://localhost:4100
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"check:semantic-block-runtime": "npm run check:semantic-block-runtime -w server",
|
||||
"check:semantic-blocks": "npm run check:semantic-blocks -w server",
|
||||
"check:source-models": "npm run check:source-models -w server",
|
||||
"check:ontology-universality": "npm run check:ontology-universality -w server",
|
||||
"typecheck": "npm run typecheck -w server && npm run typecheck -w app",
|
||||
"db:migrate": "npm run db:migrate -w server"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,9 +25,25 @@ const envSchema = z.object({
|
|||
WORDSTAT_REGION_IDS: z.string().default("225"),
|
||||
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
|
||||
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
|
||||
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(40),
|
||||
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
|
||||
SERP_API_BASE_URL: z.string().url().optional(),
|
||||
SERP_API_TOKEN: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_PROFILE: z
|
||||
.enum(["disabled", "local", "deployed-hub", "deploy-host", "tunnel-local-e2e"])
|
||||
.default("disabled"),
|
||||
SEO_AI_WORKSPACE_CONTROL_URL: z.string().url().optional(),
|
||||
SEO_AI_WORKSPACE_CONTROL_TOKEN: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_ASSISTANT_URL: z.string().url().optional(),
|
||||
SEO_AI_WORKSPACE_ASSISTANT_TOKEN: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_OWNER_USER_ID: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_OWNER_EMAIL: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_OWNER_ROLE: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_OWNER_GROUPS: z.string().default(""),
|
||||
SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH: z.string().optional(),
|
||||
SEO_AI_WORKSPACE_CONTRACT_ONLY: booleanFromString.default(false),
|
||||
SEO_AI_WORKSPACE_TIMEOUT_MS: z.coerce.number().int().positive().max(60_000).default(15_000),
|
||||
PREVIEW_CHROME_PATH: z.string().optional(),
|
||||
PREVIEW_SNAPSHOT_CONCURRENCY: z.coerce.number().int().positive().max(4).default(2),
|
||||
PREVIEW_SNAPSHOT_WAIT_MS: z.coerce.number().int().nonnegative().max(10_000).default(1400)
|
||||
|
|
@ -57,6 +73,7 @@ export const env = {
|
|||
regionIds: parsedEnv.WORDSTAT_REGION_IDS.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
devices: parsedEnv.WORDSTAT_DEVICES.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
numPhrases: parsedEnv.WORDSTAT_NUM_PHRASES,
|
||||
maxSeedsPerRun: parsedEnv.WORDSTAT_MAX_SEEDS_PER_RUN,
|
||||
hasApiToken: Boolean(parsedEnv.WORDSTAT_API_TOKEN)
|
||||
},
|
||||
serp: {
|
||||
|
|
@ -65,6 +82,22 @@ export const env = {
|
|||
hasApiToken: Boolean(parsedEnv.SERP_API_TOKEN)
|
||||
}
|
||||
},
|
||||
aiWorkspace: {
|
||||
profile: parsedEnv.SEO_AI_WORKSPACE_PROFILE,
|
||||
assistantUrl: parsedEnv.SEO_AI_WORKSPACE_CONTROL_URL ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_URL,
|
||||
assistantToken: parsedEnv.SEO_AI_WORKSPACE_CONTROL_TOKEN ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_TOKEN,
|
||||
contractOnly: parsedEnv.SEO_AI_WORKSPACE_CONTRACT_ONLY,
|
||||
contractWorkspacePath: parsedEnv.SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH,
|
||||
selectedExecutorId: parsedEnv.SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID,
|
||||
requestTimeoutMs: parsedEnv.SEO_AI_WORKSPACE_TIMEOUT_MS,
|
||||
owner: {
|
||||
userId: parsedEnv.SEO_AI_WORKSPACE_OWNER_USER_ID,
|
||||
email: parsedEnv.SEO_AI_WORKSPACE_OWNER_EMAIL,
|
||||
role: parsedEnv.SEO_AI_WORKSPACE_OWNER_ROLE,
|
||||
groups: parsedEnv.SEO_AI_WORKSPACE_OWNER_GROUPS.split(",").map((item) => item.trim()).filter(Boolean)
|
||||
},
|
||||
hasAssistantToken: Boolean(parsedEnv.SEO_AI_WORKSPACE_CONTROL_TOKEN ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_TOKEN)
|
||||
},
|
||||
preview: {
|
||||
chromePath: parsedEnv.PREVIEW_CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
snapshotConcurrency: parsedEnv.PREVIEW_SNAPSHOT_CONCURRENCY,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { pool } from "../db/client.js";
|
||||
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||
|
||||
type KeywordCleaningProviderMode = "codex_manual" | "deterministic_fallback";
|
||||
type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
|
||||
type KeywordCleaningSource = "manual" | "normalization_seed" | "serp" | "wordstat_related" | "wordstat_seed";
|
||||
|
||||
const KEYWORD_CLEANING_DECISIONS: KeywordCleaningDecision[] = ["use", "support", "article", "risky", "trash"];
|
||||
const KEYWORD_CLEANING_DECISION_SET = new Set<string>(KEYWORD_CLEANING_DECISIONS);
|
||||
|
||||
export type KeywordCleaningModelTaskContract = {
|
||||
taskType: "seo.keyword_cleaning";
|
||||
schemaVersion: "seo-keyword-cleaning-task.v1";
|
||||
|
|
@ -26,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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
"SERP interpretation работает только по сохранённому yandex-evidence.v1 и не придумывает конкурентов или спрос.",
|
||||
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
|
||||
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
|
||||
"Keyword map получает только keyword-cleaning use/support lanes; risky/trash не становятся approved decisions.",
|
||||
"Keyword map предсортирует exact evidence в пять Stage 3 колонок; Неразобранные не участвуют, финальные роли утверждает пользователь.",
|
||||
"Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval."
|
||||
],
|
||||
nextActions: !analysis
|
||||
|
|
@ -360,7 +360,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
|||
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
||||
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
|
||||
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
|
||||
"После Wordstat прогонять seo.keyword_cleaning и только use/support передавать в keyword map.",
|
||||
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать exact-кандидаты в пять колонок для ручной правки.",
|
||||
"Не запускать external market collection без anchor review approval."
|
||||
]
|
||||
: [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,479 @@
|
|||
import { env } from "../config/env.js";
|
||||
import type { SeoModelTaskContract } from "../modelContext/seoModelContext.js";
|
||||
import { getSeoAnalyticsModelRuntimeConfig } from "../settings/externalServiceSettings.js";
|
||||
import { getSeoAiWorkspaceConfigStatus, type SeoAiWorkspaceProfile } from "./aiWorkspaceBridgeConfig.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type SeoAiWorkspaceDispatch = {
|
||||
schemaVersion: "seo-ai-workspace-dispatch.v1";
|
||||
profile: SeoAiWorkspaceProfile;
|
||||
assistantThreadId: string;
|
||||
assistantMessageId: string;
|
||||
bridgeRequestId: string;
|
||||
bridgeMode: string;
|
||||
accepted: boolean;
|
||||
dispatchedAt: string;
|
||||
selectedExecutorId: string | null;
|
||||
contractWorkspacePath: string;
|
||||
};
|
||||
|
||||
export type SeoAiWorkspaceOutputValidation = {
|
||||
ok: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type SeoAiWorkspaceModelOutput = {
|
||||
schemaVersion: "seo-model-output.v1";
|
||||
source: "ai_workspace";
|
||||
status: "dispatch_accepted" | "schema_valid" | "schema_invalid";
|
||||
aiWorkspace: SeoAiWorkspaceDispatch;
|
||||
rawText: string | null;
|
||||
parsedJson: JsonRecord | null;
|
||||
validation: SeoAiWorkspaceOutputValidation | null;
|
||||
};
|
||||
|
||||
export type SeoAiWorkspacePersistedResult = {
|
||||
schemaVersion: "seo-model-persisted-result.v1";
|
||||
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
||||
runType:
|
||||
| "keyword_cleaning"
|
||||
| "seo_model_task"
|
||||
| "seo_normalization"
|
||||
| "seo_strategy_quality_review"
|
||||
| "seo_strategy_synthesis";
|
||||
runId: string;
|
||||
sourceModelTaskRunId?: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type SeoAiWorkspaceCompletion =
|
||||
| { status: "pending" }
|
||||
| { status: "failed"; error: string }
|
||||
| { status: "completed"; rawText: string };
|
||||
|
||||
type AssistantThreadResponse = {
|
||||
thread?: {
|
||||
id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type AssistantMessageResponse = {
|
||||
message?: {
|
||||
id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type AssistantDispatchResponse = {
|
||||
bridge?: {
|
||||
accepted?: boolean;
|
||||
requestId?: string;
|
||||
mode?: string;
|
||||
error?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type AssistantMessagesResponse = {
|
||||
messages?: Array<{
|
||||
role?: string;
|
||||
content?: string;
|
||||
payload?: JsonRecord;
|
||||
}>;
|
||||
};
|
||||
|
||||
function isJsonRecord(value: unknown): value is JsonRecord {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function assistantHeaders() {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${env.aiWorkspace.assistantToken ?? ""}`,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
if (env.aiWorkspace.owner.userId) {
|
||||
headers["X-NODEDC-User-Id"] = env.aiWorkspace.owner.userId;
|
||||
}
|
||||
|
||||
if (env.aiWorkspace.owner.email) {
|
||||
headers["X-NODEDC-User-Email"] = env.aiWorkspace.owner.email;
|
||||
}
|
||||
|
||||
if (env.aiWorkspace.owner.role) {
|
||||
headers["X-NODEDC-User-Role"] = env.aiWorkspace.owner.role;
|
||||
}
|
||||
|
||||
if (env.aiWorkspace.owner.groups.length > 0) {
|
||||
headers["X-NODEDC-User-Groups"] = env.aiWorkspace.owner.groups.join(",");
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export async function assistantRequest<T>(
|
||||
path: string,
|
||||
options: {
|
||||
body?: JsonRecord;
|
||||
method: "GET" | "POST";
|
||||
}
|
||||
): Promise<T> {
|
||||
if (!env.aiWorkspace.assistantUrl) {
|
||||
throw new Error("SEO_AI_WORKSPACE_CONTROL_URL or SEO_AI_WORKSPACE_ASSISTANT_URL is required.");
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), env.aiWorkspace.requestTimeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${env.aiWorkspace.assistantUrl.replace(/\/+$/, "")}${path}`, {
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
headers: assistantHeaders(),
|
||||
method: options.method,
|
||||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) as JsonRecord : {};
|
||||
|
||||
if (!response.ok || payload.ok === false) {
|
||||
throw new Error(String(payload.error || payload.message || `ai_workspace_assistant_http_${response.status}`));
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new Error("ai_workspace_assistant_invalid_json");
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error("ai_workspace_assistant_timeout");
|
||||
}
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function buildSeoModelTaskPrompt(input: {
|
||||
projectId: string;
|
||||
runId: string;
|
||||
task: SeoModelTaskContract;
|
||||
}) {
|
||||
return [
|
||||
"NDC SEO Mode model-provider task.",
|
||||
"",
|
||||
"Return only one JSON object. Do not wrap it in markdown. Do not include prose outside JSON.",
|
||||
`The JSON object must use schemaVersion "${input.task.outputSchema.schemaVersion}".`,
|
||||
`Required top-level keys: ${input.task.outputSchema.requiredTopLevelKeys.join(", ")}.`,
|
||||
"If the output contract has bookkeeping keys such as modelTask, sourceTaskId, source, semanticRunId, projectId, or provider, populate them from the task contract and the provided evidence.",
|
||||
"For keyword tasks, classify only phrases present in task.input.seedQueue or task.input.wordstatTopResults; keep unresolved or weak rows out of approved rewrite lanes.",
|
||||
"",
|
||||
"Hard boundaries:",
|
||||
"- Work only from the task contract below.",
|
||||
"- Do not inspect repository files, local filesystem paths, browser state, secrets, tokens, or product databases.",
|
||||
"- Do not call Wordstat, SERP, Yandex, OpenAI API, product app APIs, or external tools from this task.",
|
||||
"- Do not mutate source files, rewrite/apply content, create pages, delete data, or approve decisions.",
|
||||
"- Treat every output as a proposal/evidence draft that must pass backend schema validation and human gates.",
|
||||
"- Preserve unresolved/Неразобранные semantics when the task is about keywords.",
|
||||
"",
|
||||
"Correlation:",
|
||||
`- projectId: ${input.projectId}`,
|
||||
`- seoModelTaskRunId: ${input.runId}`,
|
||||
`- taskId: ${input.task.taskId}`,
|
||||
`- taskType: ${input.task.taskType}`,
|
||||
"",
|
||||
"SEO task contract JSON:",
|
||||
JSON.stringify(input.task, null, 2)
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function dispatchSeoModelTaskToAiWorkspace(input: {
|
||||
projectId: string;
|
||||
runId: string;
|
||||
task: SeoModelTaskContract;
|
||||
}): Promise<SeoAiWorkspaceDispatch> {
|
||||
const status = getSeoAiWorkspaceConfigStatus();
|
||||
|
||||
if (!status.configured) {
|
||||
throw new Error(status.errors.join(" ") || "AI Workspace bridge is not configured.");
|
||||
}
|
||||
|
||||
const threadTitle = `SEO model task: ${input.task.taskType}`;
|
||||
const analyticsModelConfig = await getSeoAnalyticsModelRuntimeConfig();
|
||||
const selectedExecutorId = env.aiWorkspace.selectedExecutorId ?? analyticsModelConfig.executorId;
|
||||
|
||||
if (!selectedExecutorId) {
|
||||
throw new Error("SEO analytics model executorId is required; generate Codex bridge setup command and save external service settings.");
|
||||
}
|
||||
|
||||
const activeContext = {
|
||||
surface: "seo-mode",
|
||||
sourceSurface: "seo-mode",
|
||||
modeId: "seo-model-task",
|
||||
modeTitle: "NDC SEO Mode model provider",
|
||||
accessMode: "contract-only",
|
||||
contextReady: true,
|
||||
workspacePath: env.aiWorkspace.contractWorkspacePath,
|
||||
remoteControlMode: "remote",
|
||||
seoModelTask: {
|
||||
projectId: input.projectId,
|
||||
runId: input.runId,
|
||||
taskId: input.task.taskId,
|
||||
taskType: input.task.taskType,
|
||||
outputSchemaVersion: input.task.outputSchema.schemaVersion
|
||||
}
|
||||
};
|
||||
|
||||
const threadPayload: JsonRecord = {
|
||||
activeContext,
|
||||
enabledToolPacks: [],
|
||||
lifecycleState: "active",
|
||||
linkedArtifacts: [
|
||||
{
|
||||
kind: "seo_model_task",
|
||||
projectId: input.projectId,
|
||||
runId: input.runId,
|
||||
taskId: input.task.taskId,
|
||||
taskType: input.task.taskType
|
||||
}
|
||||
],
|
||||
metadata: {
|
||||
projectId: input.projectId,
|
||||
runId: input.runId,
|
||||
surface: "seo-mode"
|
||||
},
|
||||
originSurface: "seo-mode",
|
||||
title: threadTitle
|
||||
};
|
||||
|
||||
threadPayload.selectedExecutorId = selectedExecutorId;
|
||||
|
||||
const threadResponse = await assistantRequest<AssistantThreadResponse>("/api/ai-workspace/assistant/v1/threads", {
|
||||
body: threadPayload,
|
||||
method: "POST"
|
||||
});
|
||||
const threadId = String(threadResponse.thread?.id || "");
|
||||
|
||||
if (!threadId) {
|
||||
throw new Error("ai_workspace_thread_id_missing");
|
||||
}
|
||||
|
||||
const messageResponse = await assistantRequest<AssistantMessageResponse>(
|
||||
`/api/ai-workspace/assistant/v1/threads/${encodeURIComponent(threadId)}/messages`,
|
||||
{
|
||||
body: {
|
||||
content: buildSeoModelTaskPrompt(input),
|
||||
payload: {
|
||||
kind: "seo_model_task_request",
|
||||
projectId: input.projectId,
|
||||
runId: input.runId,
|
||||
taskId: input.task.taskId,
|
||||
taskType: input.task.taskType
|
||||
},
|
||||
role: "user"
|
||||
},
|
||||
method: "POST"
|
||||
}
|
||||
);
|
||||
const messageId = String(messageResponse.message?.id || "");
|
||||
|
||||
if (!messageId) {
|
||||
throw new Error("ai_workspace_message_id_missing");
|
||||
}
|
||||
|
||||
const dispatchPayload: JsonRecord = {
|
||||
context: activeContext,
|
||||
messageId,
|
||||
modeId: "seo-model-task",
|
||||
modeTitle: "NDC SEO Mode model provider",
|
||||
originSurface: "seo-mode",
|
||||
remoteControlMode: "remote",
|
||||
workspacePath: env.aiWorkspace.contractWorkspacePath
|
||||
};
|
||||
|
||||
dispatchPayload.selectedExecutorId = selectedExecutorId;
|
||||
|
||||
const dispatchResponse = await assistantRequest<AssistantDispatchResponse>(
|
||||
`/api/ai-workspace/assistant/v1/threads/${encodeURIComponent(threadId)}/dispatch`,
|
||||
{
|
||||
body: dispatchPayload,
|
||||
method: "POST"
|
||||
}
|
||||
);
|
||||
const bridge = dispatchResponse.bridge;
|
||||
const requestId = String(bridge?.requestId || "");
|
||||
|
||||
if (bridge?.accepted !== true || !requestId) {
|
||||
throw new Error(String(bridge?.error || "ai_workspace_bridge_request_not_accepted"));
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: "seo-ai-workspace-dispatch.v1",
|
||||
profile: status.profile,
|
||||
assistantThreadId: threadId,
|
||||
assistantMessageId: messageId,
|
||||
bridgeRequestId: requestId,
|
||||
bridgeMode: String(bridge.mode || "hub"),
|
||||
accepted: true,
|
||||
dispatchedAt: new Date().toISOString(),
|
||||
selectedExecutorId,
|
||||
contractWorkspacePath: env.aiWorkspace.contractWorkspacePath ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSeoAiWorkspaceCompletion(
|
||||
dispatch: SeoAiWorkspaceDispatch
|
||||
): Promise<SeoAiWorkspaceCompletion> {
|
||||
const payload = await assistantRequest<AssistantMessagesResponse>(
|
||||
`/api/ai-workspace/assistant/v1/threads/${encodeURIComponent(dispatch.assistantThreadId)}/messages?limit=100`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
const messages = Array.isArray(payload.messages) ? payload.messages : [];
|
||||
const finalMessage = messages.find((message) => {
|
||||
const item = isJsonRecord(message.payload) ? message.payload : {};
|
||||
const kind = String(item.kind || "");
|
||||
const requestId = String(item.requestId || item.request_id || "");
|
||||
return requestId === dispatch.bridgeRequestId && (kind === "bridge_final" || kind === "bridge_failure");
|
||||
});
|
||||
|
||||
if (!finalMessage) {
|
||||
return { status: "pending" };
|
||||
}
|
||||
|
||||
const finalPayload = isJsonRecord(finalMessage.payload) ? finalMessage.payload : {};
|
||||
const finalKind = String(finalPayload.kind || "");
|
||||
const content = String(finalMessage.content || "");
|
||||
|
||||
if (finalKind === "bridge_failure") {
|
||||
return {
|
||||
status: "failed",
|
||||
error: content || "ai_workspace_bridge_failed"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "completed",
|
||||
rawText: content
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonFromText(rawText: string): JsonRecord | null {
|
||||
const text = rawText.trim();
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
return isJsonRecord(parsed) ? parsed : null;
|
||||
} catch {}
|
||||
|
||||
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
|
||||
if (fence?.[1]) {
|
||||
try {
|
||||
const parsed = JSON.parse(fence[1].trim());
|
||||
return isJsonRecord(parsed) ? parsed : null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const firstBrace = text.indexOf("{");
|
||||
const lastBrace = text.lastIndexOf("}");
|
||||
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
try {
|
||||
const parsed = JSON.parse(text.slice(firstBrace, lastBrace + 1));
|
||||
return isJsonRecord(parsed) ? parsed : null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildAcceptedAiWorkspaceModelOutput(
|
||||
dispatch: SeoAiWorkspaceDispatch
|
||||
): SeoAiWorkspaceModelOutput {
|
||||
return {
|
||||
schemaVersion: "seo-model-output.v1",
|
||||
source: "ai_workspace",
|
||||
status: "dispatch_accepted",
|
||||
aiWorkspace: dispatch,
|
||||
rawText: null,
|
||||
parsedJson: null,
|
||||
validation: null
|
||||
};
|
||||
}
|
||||
|
||||
export function validateSeoAiWorkspaceModelOutput(input: {
|
||||
dispatch: SeoAiWorkspaceDispatch;
|
||||
rawText: string;
|
||||
outputSchemaVersion: string | null;
|
||||
requiredTopLevelKeys: string[];
|
||||
}): SeoAiWorkspaceModelOutput {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const parsedJson = parseJsonFromText(input.rawText);
|
||||
|
||||
if (!parsedJson) {
|
||||
errors.push("Remote Codex response is not a JSON object.");
|
||||
}
|
||||
|
||||
if (parsedJson && input.outputSchemaVersion && parsedJson.schemaVersion !== input.outputSchemaVersion) {
|
||||
errors.push(`schemaVersion must be ${input.outputSchemaVersion}.`);
|
||||
}
|
||||
|
||||
if (parsedJson) {
|
||||
for (const key of input.requiredTopLevelKeys) {
|
||||
if (!Object.hasOwn(parsedJson, key)) {
|
||||
errors.push(`Missing required top-level key: ${key}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (input.rawText.includes("```")) {
|
||||
warnings.push("Remote Codex wrapped output in markdown; JSON was extracted before validation.");
|
||||
}
|
||||
|
||||
const validation = {
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
|
||||
return {
|
||||
schemaVersion: "seo-model-output.v1",
|
||||
source: "ai_workspace",
|
||||
status: validation.ok ? "schema_valid" : "schema_invalid",
|
||||
aiWorkspace: input.dispatch,
|
||||
rawText: input.rawText,
|
||||
parsedJson,
|
||||
validation
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeoAiWorkspacePersistedResult(runId: string): SeoAiWorkspacePersistedResult {
|
||||
return {
|
||||
schemaVersion: "seo-model-persisted-result.v1",
|
||||
status: "stored_as_model_task_evidence",
|
||||
runType: "seo_model_task",
|
||||
runId,
|
||||
note: "Structured model output is persisted on seo_model_task run; task-specific evidence promotion remains backend-owned."
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeoAiWorkspacePromotedResult(input: {
|
||||
runType: Exclude<SeoAiWorkspacePersistedResult["runType"], "seo_model_task">;
|
||||
sourceModelTaskRunId: string;
|
||||
}): SeoAiWorkspacePersistedResult {
|
||||
return {
|
||||
schemaVersion: "seo-model-persisted-result.v1",
|
||||
status: "promoted_to_domain_evidence",
|
||||
runType: input.runType,
|
||||
runId: input.sourceModelTaskRunId,
|
||||
sourceModelTaskRunId: input.sourceModelTaskRunId,
|
||||
note: `Structured model output passed schema validation and was promoted to ${input.runType}.`
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { env } from "../config/env.js";
|
||||
|
||||
export type SeoAiWorkspaceProfile = "disabled" | "local" | "deployed-hub" | "deploy-host" | "tunnel-local-e2e";
|
||||
|
||||
export type SeoAiWorkspaceConfigStatus = {
|
||||
configured: boolean;
|
||||
profile: SeoAiWorkspaceProfile;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
assistantUrl: string | null;
|
||||
selectedExecutorId: string | null;
|
||||
contractWorkspacePath: string | null;
|
||||
ownerConfigured: boolean;
|
||||
};
|
||||
|
||||
const LOCAL_ASSISTANT_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "host.docker.internal"]);
|
||||
const LOCAL_FORBIDDEN_PRODUCT_HOSTS = new Set([
|
||||
"hub.nodedc.ru",
|
||||
"engine.nodedc.ru",
|
||||
"ops.nodedc.ru",
|
||||
"id.nodedc.ru",
|
||||
"ops-agents.nodedc.ru",
|
||||
"ai-hub.nodedc.ru"
|
||||
]);
|
||||
|
||||
function hostname(value: string | undefined) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value).hostname.toLowerCase();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function getSeoAiWorkspaceConfigStatus(): SeoAiWorkspaceConfigStatus {
|
||||
const profile = env.aiWorkspace.profile;
|
||||
const assistantHost = hostname(env.aiWorkspace.assistantUrl);
|
||||
const ownerConfigured = Boolean(env.aiWorkspace.owner.userId || env.aiWorkspace.owner.email);
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (profile === "disabled") {
|
||||
errors.push("SEO_AI_WORKSPACE_PROFILE is disabled; choose local, deployed-hub, deploy-host, or tunnel-local-e2e.");
|
||||
}
|
||||
|
||||
if (!env.aiWorkspace.assistantUrl) {
|
||||
errors.push("SEO_AI_WORKSPACE_CONTROL_URL or SEO_AI_WORKSPACE_ASSISTANT_URL is required.");
|
||||
}
|
||||
|
||||
if (!env.aiWorkspace.assistantToken) {
|
||||
errors.push("SEO_AI_WORKSPACE_CONTROL_TOKEN or SEO_AI_WORKSPACE_ASSISTANT_TOKEN is required.");
|
||||
}
|
||||
|
||||
if (!ownerConfigured) {
|
||||
errors.push("SEO_AI_WORKSPACE_OWNER_USER_ID or SEO_AI_WORKSPACE_OWNER_EMAIL is required.");
|
||||
}
|
||||
|
||||
if (!env.aiWorkspace.contractOnly) {
|
||||
errors.push("SEO_AI_WORKSPACE_CONTRACT_ONLY=1 is required for SEO model tasks.");
|
||||
}
|
||||
|
||||
if (!env.aiWorkspace.contractWorkspacePath) {
|
||||
errors.push("SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH is required to avoid executor workspace fallback.");
|
||||
}
|
||||
|
||||
if (profile === "local") {
|
||||
if (assistantHost && !LOCAL_ASSISTANT_HOSTS.has(assistantHost)) {
|
||||
errors.push("local profile requires a local AI Workspace Assistant URL.");
|
||||
}
|
||||
|
||||
if (LOCAL_FORBIDDEN_PRODUCT_HOSTS.has(assistantHost)) {
|
||||
errors.push(`local profile must not use deployed product host ${assistantHost} as Assistant URL.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (profile === "deploy-host" && assistantHost && LOCAL_ASSISTANT_HOSTS.has(assistantHost)) {
|
||||
errors.push("deploy-host profile must not use localhost/host.docker.internal Assistant URL.");
|
||||
}
|
||||
|
||||
if (profile === "deployed-hub") {
|
||||
if (assistantHost && LOCAL_ASSISTANT_HOSTS.has(assistantHost)) {
|
||||
errors.push("deployed-hub profile requires the public AI Hub control-plane URL, not localhost.");
|
||||
}
|
||||
|
||||
if (assistantHost && assistantHost !== "ai-hub.nodedc.ru") {
|
||||
warnings.push(`deployed-hub profile is using ${assistantHost}; expected ai-hub.nodedc.ru unless this is an explicit deploy profile.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (env.aiWorkspace.selectedExecutorId) {
|
||||
warnings.push("SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID pins the Codex executor for this backend.");
|
||||
} else {
|
||||
warnings.push("No env executor id is pinned; SEO external-service settings must provide executorId before dispatch.");
|
||||
}
|
||||
|
||||
return {
|
||||
configured: errors.length === 0,
|
||||
profile,
|
||||
errors,
|
||||
warnings,
|
||||
assistantUrl: env.aiWorkspace.assistantUrl ?? null,
|
||||
selectedExecutorId: env.aiWorkspace.selectedExecutorId ?? null,
|
||||
contractWorkspacePath: env.aiWorkspace.contractWorkspacePath ?? null,
|
||||
ownerConfigured
|
||||
};
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js";
|
||||
|
||||
export const SEO_MODEL_TASK_TYPES = [
|
||||
"seo.context_review",
|
||||
|
|
@ -141,7 +142,7 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
|
|||
title: "Codex Workspace adapter",
|
||||
mode: "workspace_agent",
|
||||
summary:
|
||||
"Будущий adapter для dev-mode/model-review: модель получает только SEO task contract и возвращает structured JSON.",
|
||||
"Adapter через AI Workspace Assistant / AI Hub / remote Codex worker: модель получает только SEO task contract и возвращает structured JSON.",
|
||||
allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES,
|
||||
requiresSecrets: false,
|
||||
secretNames: [],
|
||||
|
|
@ -157,9 +158,11 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
|
|||
guardrails: [
|
||||
"Workspace agent не должен читать репозиторий напрямую для пользовательских сайтов.",
|
||||
"Все filesystem/tool calls должны идти через approved MCP capabilities.",
|
||||
"SEO route требует явный contract-only workspace path и не использует executor workspace fallback.",
|
||||
"Ответ должен проходить schema validation перед записью результата."
|
||||
],
|
||||
nextSetupStep: "Описать handoff contract между AI Workspace Hub/MCP и seo_mode backend."
|
||||
nextSetupStep:
|
||||
"Настроить SEO_AI_WORKSPACE_PROFILE, AI Workspace control URL/token, owner, contract-only workspace path and connected Codex executor."
|
||||
},
|
||||
{
|
||||
id: "openai_api",
|
||||
|
|
@ -239,10 +242,38 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
|
|||
}
|
||||
];
|
||||
|
||||
const PARSED_PROVIDERS = z.array(SeoModelProviderDescriptorSchema).parse(PROVIDERS);
|
||||
function buildProviderDescriptors() {
|
||||
const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus();
|
||||
|
||||
return PROVIDERS.map((provider) => {
|
||||
if (provider.id !== "codex_workspace") {
|
||||
return provider;
|
||||
}
|
||||
|
||||
return {
|
||||
...provider,
|
||||
status: aiWorkspaceStatus.configured ? "active" as const : "not_configured" as const,
|
||||
summary: aiWorkspaceStatus.configured
|
||||
? `${provider.summary} Configured profile: ${aiWorkspaceStatus.profile}.`
|
||||
: `${provider.summary} Пока не настроен: ${aiWorkspaceStatus.errors[0] ?? provider.nextSetupStep}`,
|
||||
guardrails: [
|
||||
...provider.guardrails,
|
||||
`AI Workspace profile: ${aiWorkspaceStatus.profile}.`,
|
||||
"Remote Codex output is proposal/evidence only; it never bypasses human gates or rewrite/apply approval."
|
||||
],
|
||||
nextSetupStep: aiWorkspaceStatus.configured
|
||||
? "Запустить seo model task через codex_workspace и дождаться structured JSON в model task evidence run."
|
||||
: aiWorkspaceStatus.errors.join(" ") || provider.nextSetupStep
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getParsedProviders() {
|
||||
return z.array(SeoModelProviderDescriptorSchema).parse(buildProviderDescriptors());
|
||||
}
|
||||
|
||||
export function getDefaultSeoModelProviderId(): SeoModelProviderId {
|
||||
return "codex_manual";
|
||||
return getSeoAiWorkspaceConfigStatus().configured ? "codex_workspace" : "codex_manual";
|
||||
}
|
||||
|
||||
export function isSeoModelProviderId(value: string): value is SeoModelProviderId {
|
||||
|
|
@ -250,14 +281,17 @@ export function isSeoModelProviderId(value: string): value is SeoModelProviderId
|
|||
}
|
||||
|
||||
export function getSeoModelProviderDescriptor(providerId: SeoModelProviderId): SeoModelProviderDescriptor {
|
||||
return PARSED_PROVIDERS.find((provider) => provider.id === providerId) ?? PARSED_PROVIDERS[0];
|
||||
const providers = getParsedProviders();
|
||||
return providers.find((provider) => provider.id === providerId) ?? providers[0];
|
||||
}
|
||||
|
||||
export function getSeoModelProviderCatalog(): SeoModelProviderCatalog {
|
||||
const providers = getParsedProviders();
|
||||
|
||||
return SeoModelProviderCatalogSchema.parse({
|
||||
schemaVersion: "seo-model-provider-catalog.v1",
|
||||
generatedAt: new Date().toISOString(),
|
||||
defaultProviderId: getDefaultSeoModelProviderId(),
|
||||
providers: PARSED_PROVIDERS
|
||||
providers
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,20 @@
|
|||
import { pool } from "../db/client.js";
|
||||
import { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js";
|
||||
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
|
||||
import { saveSeoNormalizationFromModelProvider, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
||||
import { saveStrategyQualityReviewFromModelProvider, type StrategyQualityReviewContract } from "../strategy/strategyQualityReview.js";
|
||||
import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js";
|
||||
import {
|
||||
buildAcceptedAiWorkspaceModelOutput,
|
||||
buildSeoAiWorkspacePromotedResult,
|
||||
buildSeoAiWorkspacePersistedResult,
|
||||
dispatchSeoModelTaskToAiWorkspace,
|
||||
fetchSeoAiWorkspaceCompletion,
|
||||
validateSeoAiWorkspaceModelOutput,
|
||||
type SeoAiWorkspaceDispatch,
|
||||
type SeoAiWorkspaceModelOutput,
|
||||
type SeoAiWorkspacePersistedResult
|
||||
} from "./aiWorkspaceBridge.js";
|
||||
import {
|
||||
getDefaultSeoModelProviderId,
|
||||
getSeoModelProviderCatalog,
|
||||
|
|
@ -24,6 +39,11 @@ type SeoModelTaskRunRow = {
|
|||
created_at: Date;
|
||||
};
|
||||
|
||||
type DomainPromotionResult = {
|
||||
persistedResult: SeoAiWorkspacePersistedResult;
|
||||
nextAction: string;
|
||||
};
|
||||
|
||||
export type SeoModelTaskRunInput = {
|
||||
providerId?: SeoModelProviderId;
|
||||
taskId?: string;
|
||||
|
|
@ -60,8 +80,8 @@ export type SeoModelTaskRunOutput = {
|
|||
inputTokenEstimate: number;
|
||||
outputTokenBudget: number;
|
||||
};
|
||||
modelOutput: null;
|
||||
persistedResult: null;
|
||||
modelOutput: SeoAiWorkspaceModelOutput | null;
|
||||
persistedResult: SeoAiWorkspacePersistedResult | null;
|
||||
nextActions: string[];
|
||||
};
|
||||
|
||||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { env } from "../config/env.js";
|
||||
import { pool } from "../db/client.js";
|
||||
import { getSeoAiWorkspaceConfigStatus } from "../modelProvider/aiWorkspaceBridgeConfig.js";
|
||||
|
||||
export type ExternalServiceId =
|
||||
| "seo_analytics_model"
|
||||
| "yandex_cloud"
|
||||
| "yandex_metrica"
|
||||
| "yandex_search_serp"
|
||||
|
|
@ -91,6 +93,7 @@ export type WordstatRuntimeConfig =
|
|||
provider: "mcp_kv";
|
||||
mode: "mcp_kv";
|
||||
mcpEndpoint: string;
|
||||
maxSeedsPerRun: number;
|
||||
}
|
||||
| {
|
||||
provider: "yandex_api";
|
||||
|
|
@ -102,6 +105,7 @@ export type WordstatRuntimeConfig =
|
|||
regionIds: string[];
|
||||
devices: string[];
|
||||
numPhrases: number;
|
||||
maxSeedsPerRun: number;
|
||||
};
|
||||
|
||||
const WORDSTAT_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/wordstat";
|
||||
|
|
@ -110,6 +114,30 @@ const WEBMASTER_BASE_URL = "https://api.webmaster.yandex.net/v4";
|
|||
const METRICA_BASE_URL = "https://api-metrika.yandex.net";
|
||||
|
||||
const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = {
|
||||
seo_analytics_model: {
|
||||
id: "seo_analytics_model",
|
||||
label: "Аналитическая модель",
|
||||
description:
|
||||
"Model-provider для SEO task contracts через AI Workspace surface seo-mode. Сейчас доступен Codex bridge без доступа к Yandex credentials, исходникам и apply/write actions.",
|
||||
implementationStatus: "active",
|
||||
defaultMode: "local_codex",
|
||||
modes: [
|
||||
{ id: "disabled", label: "Отключено" },
|
||||
{ id: "local_codex", label: "Codex bridge" }
|
||||
],
|
||||
docs: [],
|
||||
publicFields: [
|
||||
{
|
||||
id: "executorId",
|
||||
label: "AI Workspace executor",
|
||||
type: "text",
|
||||
required: false,
|
||||
placeholder: "generated after setup command",
|
||||
help: "Stored SEO-only executor id. Hidden in UI; used to avoid global Assistant selected-executor fallback."
|
||||
}
|
||||
],
|
||||
secretFields: []
|
||||
},
|
||||
yandex_cloud: {
|
||||
id: "yandex_cloud",
|
||||
label: "Yandex AI Studio / Cloud API key",
|
||||
|
|
@ -223,6 +251,14 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
|
|||
placeholder: "50",
|
||||
help: "Сколько Wordstat rows запрашивать для одной seed-фразы."
|
||||
},
|
||||
{
|
||||
id: "maxSeedsPerRun",
|
||||
label: "Max seeds per run",
|
||||
type: "number",
|
||||
required: false,
|
||||
placeholder: "40",
|
||||
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
|
||||
},
|
||||
{
|
||||
id: "mcpEndpoint",
|
||||
label: "MCP endpoint",
|
||||
|
|
@ -386,6 +422,7 @@ function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) {
|
|||
apiBaseUrl: WORDSTAT_BASE_URL,
|
||||
devices: "DEVICE_ALL",
|
||||
numPhrases: "50",
|
||||
maxSeedsPerRun: "40",
|
||||
regionIds: "225"
|
||||
};
|
||||
|
||||
|
|
@ -446,6 +483,16 @@ function getMissingFields(
|
|||
|
||||
const missing: string[] = [];
|
||||
|
||||
if (definition.id === "seo_analytics_model") {
|
||||
const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus();
|
||||
|
||||
if (!aiWorkspaceStatus.configured) {
|
||||
return ["aiWorkspaceProfile"];
|
||||
}
|
||||
|
||||
return publicConfig.executorId?.trim() ? [] : ["executorId"];
|
||||
}
|
||||
|
||||
for (const field of definition.publicFields) {
|
||||
if (field.id === "mcpEndpoint" && mode !== "mcp_kv") {
|
||||
continue;
|
||||
|
|
@ -681,6 +728,19 @@ export async function updateExternalServiceSettings(
|
|||
return mapRow(definition, row, dependencyStatuses);
|
||||
}
|
||||
|
||||
export async function getSeoAnalyticsModelRuntimeConfig() {
|
||||
const rows = await getSettingsRows();
|
||||
const row = rows.get("seo_analytics_model") ?? null;
|
||||
const definition = serviceDefinitions.seo_analytics_model;
|
||||
const publicConfig = getPublicConfig(definition, row);
|
||||
|
||||
return {
|
||||
enabled: Boolean(row?.enabled && row.mode === "local_codex"),
|
||||
executorId: publicConfig.executorId?.trim() || null,
|
||||
mode: row?.mode ?? "disabled"
|
||||
};
|
||||
}
|
||||
|
||||
function splitCsv(value: string | undefined, fallback: string[]) {
|
||||
const values = (value ?? "")
|
||||
.split(",")
|
||||
|
|
@ -711,7 +771,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
|||
return {
|
||||
provider: "mcp_kv",
|
||||
mode: "mcp_kv",
|
||||
mcpEndpoint: endpoint
|
||||
mcpEndpoint: endpoint,
|
||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -731,7 +792,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
|||
folderId: cloudPublicConfig.folderId,
|
||||
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
|
||||
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
|
||||
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50)
|
||||
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
|
||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -739,7 +801,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
|||
return {
|
||||
provider: "mcp_kv",
|
||||
mode: "mcp_kv",
|
||||
mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint
|
||||
mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint,
|
||||
maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -758,7 +821,8 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
|||
folderId: env.marketProviders.wordstat.folderId,
|
||||
regionIds: env.marketProviders.wordstat.regionIds,
|
||||
devices: env.marketProviders.wordstat.devices,
|
||||
numPhrases: env.marketProviders.wordstat.numPhrases
|
||||
numPhrases: env.marketProviders.wordstat.numPhrases,
|
||||
maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,16 @@ import {
|
|||
updateExternalServiceSettings,
|
||||
type ExternalServiceId
|
||||
} from "./externalServiceSettings.js";
|
||||
import {
|
||||
createSeoAnalyticsModelSetupCommand,
|
||||
probeSeoAnalyticsModel
|
||||
} from "./seoAnalyticsModelBridge.js";
|
||||
import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js";
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
const externalServiceIdSchema = z.enum([
|
||||
"seo_analytics_model",
|
||||
"yandex_cloud",
|
||||
"yandex_metrica",
|
||||
"yandex_search_serp",
|
||||
|
|
@ -27,6 +32,13 @@ const updateExternalServiceSettingsSchema = z.object({
|
|||
const yandexAiStudioProbeSchema = z.object({
|
||||
phrase: z.string().trim().min(2).max(160).optional()
|
||||
});
|
||||
const seoAnalyticsModelSetupCommandSchema = z.object({
|
||||
executorId: z.string().uuid().optional(),
|
||||
port: z.coerce.number().int().positive().max(65535).optional()
|
||||
});
|
||||
const seoAnalyticsModelProbeSchema = z.object({
|
||||
executorId: z.string().uuid().optional()
|
||||
});
|
||||
|
||||
function sendError(response: Response, status: number, message: string) {
|
||||
response.status(status).json({
|
||||
|
|
@ -65,6 +77,42 @@ settingsRouter.post("/external-services/yandex_cloud/probe", async (request, res
|
|||
}
|
||||
});
|
||||
|
||||
settingsRouter.post("/external-services/seo_analytics_model/setup-command", async (request, response) => {
|
||||
try {
|
||||
const input = seoAnalyticsModelSetupCommandSchema.parse(request.body ?? {});
|
||||
|
||||
response.status(201).json({
|
||||
setupCommand: await createSeoAnalyticsModelSetupCommand(input)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры setup command.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось подготовить Codex bridge setup command.");
|
||||
}
|
||||
});
|
||||
|
||||
settingsRouter.post("/external-services/seo_analytics_model/probe", async (request, response) => {
|
||||
try {
|
||||
const input = seoAnalyticsModelProbeSchema.parse(request.body ?? {});
|
||||
|
||||
response.json({
|
||||
probe: await probeSeoAnalyticsModel(input)
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры probe.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось проверить Codex bridge.");
|
||||
}
|
||||
});
|
||||
|
||||
settingsRouter.patch("/external-services/:serviceId", async (request, response) => {
|
||||
try {
|
||||
const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,320 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { env } from "../config/env.js";
|
||||
import { assistantRequest } from "../modelProvider/aiWorkspaceBridge.js";
|
||||
import { getSeoAiWorkspaceConfigStatus } from "../modelProvider/aiWorkspaceBridgeConfig.js";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type AiWorkspaceExecutor = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
connectionMode: "direct" | "hub";
|
||||
workspacePath: string | null;
|
||||
pairingCode: string | null;
|
||||
model: string | null;
|
||||
accountLabel: string | null;
|
||||
capabilities: string[];
|
||||
status: "unknown" | "online" | "offline" | "checking" | "error";
|
||||
statusDetail: string;
|
||||
lastSeenAt: string | null;
|
||||
metadata: JsonRecord;
|
||||
};
|
||||
|
||||
type AiWorkspaceExecutorsResponse = {
|
||||
selectedExecutorId?: string | null;
|
||||
executors?: AiWorkspaceExecutor[];
|
||||
};
|
||||
|
||||
type AiWorkspaceExecutorResponse = {
|
||||
executor?: AiWorkspaceExecutor;
|
||||
};
|
||||
|
||||
type AiWorkspaceSetupCommandResponse = {
|
||||
install?: {
|
||||
command?: string;
|
||||
packageName?: string;
|
||||
packageSpec?: string;
|
||||
gatewayUrl?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
setupCode?: {
|
||||
suffix?: string;
|
||||
expiresAt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type AiWorkspaceExecutorCheckResponse = {
|
||||
executor?: AiWorkspaceExecutor;
|
||||
check?: JsonRecord;
|
||||
};
|
||||
|
||||
export type SeoAnalyticsModelSetupCommand = {
|
||||
schemaVersion: "seo-analytics-model-setup-command.v1";
|
||||
generatedAt: string;
|
||||
profile: string;
|
||||
executor: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: AiWorkspaceExecutor["status"];
|
||||
statusDetail: string;
|
||||
};
|
||||
install: {
|
||||
command: string;
|
||||
packageName: string;
|
||||
packageSpec: string;
|
||||
gatewayUrl: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
setupCode: {
|
||||
suffix: string | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type SeoAnalyticsModelProbe = {
|
||||
schemaVersion: "seo-analytics-model-probe.v1";
|
||||
checkedAt: string;
|
||||
profile: string;
|
||||
config: {
|
||||
configured: boolean;
|
||||
assistantUrl: string | null;
|
||||
selectedExecutorId: string | null;
|
||||
contractWorkspacePath: string | null;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
executor: {
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
status: AiWorkspaceExecutor["status"] | "not_configured";
|
||||
statusDetail: string;
|
||||
lastSeenAt: string | null;
|
||||
};
|
||||
connection: {
|
||||
ok: boolean;
|
||||
status: "connected" | "missing_config" | "not_configured" | "offline" | "error";
|
||||
detail: string;
|
||||
};
|
||||
check: JsonRecord | null;
|
||||
};
|
||||
|
||||
function isJsonRecord(value: unknown): value is JsonRecord {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isUuid(value: string | null | undefined) {
|
||||
return Boolean(
|
||||
value &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function isSeoAnalyticsExecutor(executor: AiWorkspaceExecutor) {
|
||||
const metadata = isJsonRecord(executor.metadata) ? executor.metadata : {};
|
||||
|
||||
return (
|
||||
metadata.appId === "seo-mode" ||
|
||||
metadata.surface === "seo-mode" ||
|
||||
metadata.modeId === "seo-model-task" ||
|
||||
executor.name === "NDC SEO Mode Codex Bridge" ||
|
||||
executor.name === "NDC SEO Mode Local Codex"
|
||||
);
|
||||
}
|
||||
|
||||
async function listAiWorkspaceExecutors() {
|
||||
const response = await assistantRequest<AiWorkspaceExecutorsResponse>("/api/ai-workspace/assistant/v1/executors", {
|
||||
method: "GET"
|
||||
});
|
||||
|
||||
return {
|
||||
executors: Array.isArray(response.executors) ? response.executors : [],
|
||||
selectedExecutorId: typeof response.selectedExecutorId === "string" ? response.selectedExecutorId : null
|
||||
};
|
||||
}
|
||||
|
||||
async function createSeoAnalyticsExecutor() {
|
||||
const response = await assistantRequest<AiWorkspaceExecutorResponse>("/api/ai-workspace/assistant/v1/executors", {
|
||||
body: {
|
||||
accountLabel: "NDC SEO Mode",
|
||||
capabilities: ["codex-exec", "dynamic-run-profile", "seo-model-task", "structured-json"],
|
||||
connectionMode: "hub",
|
||||
id: randomUUID(),
|
||||
metadata: {
|
||||
appId: "seo-mode",
|
||||
createdBy: "nodedc-seo-mode",
|
||||
modeId: "seo-model-task",
|
||||
profile: env.aiWorkspace.profile,
|
||||
surface: "seo-mode"
|
||||
},
|
||||
model: "codex-bridge",
|
||||
name: "NDC SEO Mode Codex Bridge",
|
||||
status: "unknown",
|
||||
type: "codex-remote",
|
||||
workspacePath: env.aiWorkspace.contractWorkspacePath ?? ""
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
|
||||
if (!response.executor?.id) {
|
||||
throw new Error("ai_workspace_executor_id_missing");
|
||||
}
|
||||
|
||||
return response.executor;
|
||||
}
|
||||
|
||||
async function resolveSeoAnalyticsExecutor(inputExecutorId?: string | null) {
|
||||
const { executors, selectedExecutorId } = await listAiWorkspaceExecutors();
|
||||
const explicitExecutorId =
|
||||
(isUuid(inputExecutorId) ? inputExecutorId : null) ||
|
||||
(isUuid(env.aiWorkspace.selectedExecutorId) ? env.aiWorkspace.selectedExecutorId : null);
|
||||
const explicitExecutor = explicitExecutorId
|
||||
? executors.find((executor) => executor.id === explicitExecutorId) ?? null
|
||||
: null;
|
||||
const selectedExecutor = selectedExecutorId
|
||||
? executors.find((executor) => executor.id === selectedExecutorId && isSeoAnalyticsExecutor(executor)) ?? null
|
||||
: null;
|
||||
const seoExecutor = executors.find(isSeoAnalyticsExecutor) ?? null;
|
||||
|
||||
return {
|
||||
executor: explicitExecutor ?? selectedExecutor ?? seoExecutor,
|
||||
selectedExecutorId
|
||||
};
|
||||
}
|
||||
|
||||
export async function createSeoAnalyticsModelSetupCommand(input: { executorId?: string | null; port?: number }) {
|
||||
const configStatus = getSeoAiWorkspaceConfigStatus();
|
||||
|
||||
if (!configStatus.configured) {
|
||||
throw new Error(configStatus.errors.join(" ") || "SEO AI Workspace bridge is not configured.");
|
||||
}
|
||||
|
||||
const resolved = await resolveSeoAnalyticsExecutor(input.executorId);
|
||||
const executor = resolved.executor ?? await createSeoAnalyticsExecutor();
|
||||
|
||||
const setup = await assistantRequest<AiWorkspaceSetupCommandResponse>(
|
||||
`/api/ai-workspace/assistant/v1/executors/${encodeURIComponent(executor.id)}/agent/setup-command`,
|
||||
{
|
||||
body: {
|
||||
port: input.port ?? 8787
|
||||
},
|
||||
method: "POST"
|
||||
}
|
||||
);
|
||||
const command = String(setup.install?.command || "");
|
||||
|
||||
if (!command) {
|
||||
throw new Error("ai_workspace_setup_command_missing");
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: "seo-analytics-model-setup-command.v1",
|
||||
generatedAt: new Date().toISOString(),
|
||||
profile: configStatus.profile,
|
||||
executor: {
|
||||
id: executor.id,
|
||||
name: executor.name,
|
||||
status: executor.status,
|
||||
statusDetail: executor.statusDetail
|
||||
},
|
||||
install: {
|
||||
command,
|
||||
packageName: String(setup.install?.packageName || ""),
|
||||
packageSpec: String(setup.install?.packageSpec || ""),
|
||||
gatewayUrl: String(setup.install?.gatewayUrl || ""),
|
||||
expiresAt: setup.install?.expiresAt ?? null
|
||||
},
|
||||
setupCode: {
|
||||
suffix: setup.setupCode?.suffix ?? null,
|
||||
expiresAt: setup.setupCode?.expiresAt ?? setup.install?.expiresAt ?? null
|
||||
}
|
||||
} satisfies SeoAnalyticsModelSetupCommand;
|
||||
}
|
||||
|
||||
export async function probeSeoAnalyticsModel(input: { executorId?: string | null } = {}): Promise<SeoAnalyticsModelProbe> {
|
||||
const checkedAt = new Date().toISOString();
|
||||
const configStatus = getSeoAiWorkspaceConfigStatus();
|
||||
const config = {
|
||||
configured: configStatus.configured,
|
||||
assistantUrl: configStatus.assistantUrl,
|
||||
selectedExecutorId: configStatus.selectedExecutorId,
|
||||
contractWorkspacePath: configStatus.contractWorkspacePath,
|
||||
errors: configStatus.errors,
|
||||
warnings: configStatus.warnings
|
||||
};
|
||||
|
||||
if (!configStatus.configured) {
|
||||
return {
|
||||
schemaVersion: "seo-analytics-model-probe.v1",
|
||||
checkedAt,
|
||||
profile: configStatus.profile,
|
||||
config,
|
||||
executor: {
|
||||
id: null,
|
||||
name: null,
|
||||
status: "not_configured",
|
||||
statusDetail: "missing_ai_workspace_config",
|
||||
lastSeenAt: null
|
||||
},
|
||||
connection: {
|
||||
ok: false,
|
||||
status: "missing_config",
|
||||
detail: configStatus.errors.join(" ") || "SEO AI Workspace bridge is not configured."
|
||||
},
|
||||
check: null
|
||||
};
|
||||
}
|
||||
|
||||
const resolved = await resolveSeoAnalyticsExecutor(input.executorId);
|
||||
|
||||
if (!resolved.executor) {
|
||||
return {
|
||||
schemaVersion: "seo-analytics-model-probe.v1",
|
||||
checkedAt,
|
||||
profile: configStatus.profile,
|
||||
config,
|
||||
executor: {
|
||||
id: null,
|
||||
name: null,
|
||||
status: "not_configured",
|
||||
statusDetail: "seo_codex_executor_missing",
|
||||
lastSeenAt: null
|
||||
},
|
||||
connection: {
|
||||
ok: false,
|
||||
status: "not_configured",
|
||||
detail: "Сначала сгенерируй NPM-команду и запусти bridge на Codex-машине."
|
||||
},
|
||||
check: null
|
||||
};
|
||||
}
|
||||
|
||||
const response = await assistantRequest<AiWorkspaceExecutorCheckResponse>(
|
||||
`/api/ai-workspace/assistant/v1/executors/${encodeURIComponent(resolved.executor.id)}/check`,
|
||||
{
|
||||
method: "POST"
|
||||
}
|
||||
);
|
||||
const executor = response.executor ?? resolved.executor;
|
||||
const isConnected = executor.status === "online";
|
||||
|
||||
return {
|
||||
schemaVersion: "seo-analytics-model-probe.v1",
|
||||
checkedAt,
|
||||
profile: configStatus.profile,
|
||||
config,
|
||||
executor: {
|
||||
id: executor.id,
|
||||
name: executor.name,
|
||||
status: executor.status,
|
||||
statusDetail: executor.statusDetail,
|
||||
lastSeenAt: executor.lastSeenAt
|
||||
},
|
||||
connection: {
|
||||
ok: isConnected,
|
||||
status: isConnected ? "connected" : executor.status === "error" ? "error" : "offline",
|
||||
detail: isConnected ? "Codex bridge is online." : executor.statusDetail || "bridge_agent_offline"
|
||||
},
|
||||
check: response.check ?? null
|
||||
};
|
||||
}
|
||||
|
|
@ -207,6 +207,10 @@ function normalizeKey(value: string | null) {
|
|||
return value?.trim() || "unmapped";
|
||||
}
|
||||
|
||||
function normalizePhraseKey(value: string) {
|
||||
return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU");
|
||||
}
|
||||
|
||||
function getConfidence(score: number): SeoStrategyConfidence {
|
||||
if (score >= 75) {
|
||||
return "high";
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { getSeoStrategyContract, type SeoStrategyContract } from "./seoStrategy.
|
|||
import { buildStrategySynthesisModelTask } from "./strategySynthesisTask.js";
|
||||
|
||||
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
||||
type StrategySynthesisProviderMode = "codex_manual" | "deterministic_fallback";
|
||||
type StrategySynthesisProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||
type StrategySynthesisState = "not_ready" | "ready";
|
||||
type StrategySynthesisConfidence = "high" | "low" | "medium";
|
||||
type StrategySynthesisPriority = "high" | "low" | "medium";
|
||||
|
|
@ -158,6 +158,142 @@ function getDefaultAnalystReview(): StrategySynthesisContract["analystReview"] {
|
|||
};
|
||||
}
|
||||
|
||||
function isConfidence(value: unknown): value is StrategySynthesisConfidence {
|
||||
return value === "high" || value === "medium" || value === "low";
|
||||
}
|
||||
|
||||
function isPriority(value: unknown): value is StrategySynthesisPriority {
|
||||
return value === "high" || value === "medium" || value === "low";
|
||||
}
|
||||
|
||||
function stringList(value: unknown, fallback: string[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const result = value.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return item.trim();
|
||||
}
|
||||
|
||||
if (item && typeof item === "object") {
|
||||
const record = item as Record<string, unknown>;
|
||||
|
||||
for (const key of ["title", "message", "nextStep", "action", "summary", "label"]) {
|
||||
const text = record[key];
|
||||
|
||||
if (typeof text === "string" && text.trim()) {
|
||||
return text.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return String(item || "").trim();
|
||||
}).filter(Boolean);
|
||||
|
||||
return result.length > 0 ? result : fallback;
|
||||
}
|
||||
|
||||
function mergeModelProviderStrategySynthesisOutput(
|
||||
output: StrategySynthesisContract,
|
||||
fallback: StrategySynthesisContract
|
||||
): StrategySynthesisContract {
|
||||
const rawOutput = output as StrategySynthesisContract & Record<string, any>;
|
||||
const executiveSummary = (rawOutput.executiveSummary ?? {}) as Record<string, any>;
|
||||
const phaseDecision = (rawOutput.phaseDecision ?? {}) as Record<string, any>;
|
||||
const recommendedPath = (rawOutput.recommendedPath ?? {}) as Record<string, any>;
|
||||
const summary =
|
||||
typeof rawOutput.summary === "string" && rawOutput.summary.trim()
|
||||
? rawOutput.summary.trim()
|
||||
: typeof executiveSummary.summary === "string" && executiveSummary.summary.trim()
|
||||
? executiveSummary.summary.trim()
|
||||
: fallback.summary;
|
||||
const executiveKeyPoints = stringList(executiveSummary.keyPoints, []);
|
||||
const blockedUntil = stringList(recommendedPath.blockedUntil, []);
|
||||
|
||||
return {
|
||||
...fallback,
|
||||
...output,
|
||||
analystReview: output.analystReview ?? fallback.analystReview,
|
||||
blockedActions: stringList(rawOutput.blockedActions, fallback.blockedActions),
|
||||
dataGaps: Array.isArray(rawOutput.dataGaps) ? rawOutput.dataGaps : fallback.dataGaps,
|
||||
decisionOptions: Array.isArray(rawOutput.decisionOptions) ? rawOutput.decisionOptions : fallback.decisionOptions,
|
||||
evidenceNarrative: Array.isArray(rawOutput.evidenceNarrative) ? rawOutput.evidenceNarrative : fallback.evidenceNarrative,
|
||||
executiveSummary: {
|
||||
confidence: isConfidence(executiveSummary.confidence) ? executiveSummary.confidence : fallback.executiveSummary.confidence,
|
||||
primaryConstraint:
|
||||
typeof executiveSummary.primaryConstraint === "string" && executiveSummary.primaryConstraint.trim()
|
||||
? executiveSummary.primaryConstraint.trim()
|
||||
: blockedUntil[0] ?? fallback.executiveSummary.primaryConstraint,
|
||||
title:
|
||||
typeof executiveSummary.title === "string" && executiveSummary.title.trim()
|
||||
? executiveSummary.title.trim()
|
||||
: fallback.executiveSummary.title,
|
||||
verdict:
|
||||
typeof executiveSummary.verdict === "string" && executiveSummary.verdict.trim()
|
||||
? executiveSummary.verdict.trim()
|
||||
: summary,
|
||||
whyNow:
|
||||
typeof executiveSummary.whyNow === "string" && executiveSummary.whyNow.trim()
|
||||
? executiveSummary.whyNow.trim()
|
||||
: executiveKeyPoints.length > 0
|
||||
? executiveKeyPoints.join(" ")
|
||||
: fallback.executiveSummary.whyNow
|
||||
},
|
||||
generatedAt: output.generatedAt ?? fallback.generatedAt,
|
||||
nextActions: stringList(rawOutput.nextActions, fallback.nextActions),
|
||||
phaseDecision: {
|
||||
allowedNow: stringList(phaseDecision.allowedNow, fallback.phaseDecision.allowedNow),
|
||||
currentPhase: phaseDecision.currentPhase ?? fallback.phaseDecision.currentPhase,
|
||||
hold: stringList(phaseDecision.hold, stringList(phaseDecision.deferred, fallback.phaseDecision.hold)),
|
||||
phaseRoadmap: Array.isArray(phaseDecision.phaseRoadmap) ? phaseDecision.phaseRoadmap : fallback.phaseDecision.phaseRoadmap,
|
||||
prepareNext: stringList(phaseDecision.prepareNext, fallback.phaseDecision.prepareNext),
|
||||
promotionSignals: stringList(phaseDecision.promotionSignals, fallback.phaseDecision.promotionSignals),
|
||||
schemaVersion: "seo-phase-decision.v1",
|
||||
summary:
|
||||
typeof phaseDecision.summary === "string" && phaseDecision.summary.trim()
|
||||
? phaseDecision.summary.trim()
|
||||
: typeof phaseDecision.reason === "string" && phaseDecision.reason.trim()
|
||||
? phaseDecision.reason.trim()
|
||||
: fallback.phaseDecision.summary,
|
||||
title:
|
||||
typeof phaseDecision.title === "string" && phaseDecision.title.trim()
|
||||
? phaseDecision.title.trim()
|
||||
: fallback.phaseDecision.title
|
||||
},
|
||||
phaseStrategies: Array.isArray(rawOutput.phaseStrategies) ? rawOutput.phaseStrategies : fallback.phaseStrategies,
|
||||
readiness: {
|
||||
...fallback.readiness,
|
||||
...(rawOutput.readiness && typeof rawOutput.readiness === "object" ? rawOutput.readiness : {})
|
||||
},
|
||||
recommendedPath: {
|
||||
confidence: isConfidence(recommendedPath.confidence) ? recommendedPath.confidence : fallback.recommendedPath.confidence,
|
||||
doNow: stringList(recommendedPath.doNow, stringList(recommendedPath.currentActions, fallback.recommendedPath.doNow)),
|
||||
expectedImpact: isPriority(recommendedPath.expectedImpact) ? recommendedPath.expectedImpact : fallback.recommendedPath.expectedImpact,
|
||||
hold: stringList(recommendedPath.hold, blockedUntil.length > 0 ? blockedUntil : fallback.recommendedPath.hold),
|
||||
id: typeof recommendedPath.id === "string" && recommendedPath.id.trim() ? recommendedPath.id.trim() : fallback.recommendedPath.id,
|
||||
needsEvidence: stringList(recommendedPath.needsEvidence, blockedUntil.length > 0 ? blockedUntil : fallback.recommendedPath.needsEvidence),
|
||||
priority: isPriority(recommendedPath.priority) ? recommendedPath.priority : fallback.recommendedPath.priority,
|
||||
rationale:
|
||||
typeof recommendedPath.rationale === "string" && recommendedPath.rationale.trim()
|
||||
? recommendedPath.rationale.trim()
|
||||
: fallback.recommendedPath.rationale,
|
||||
title:
|
||||
typeof recommendedPath.title === "string" && recommendedPath.title.trim()
|
||||
? recommendedPath.title.trim()
|
||||
: fallback.recommendedPath.title
|
||||
},
|
||||
riskPosture: Array.isArray(rawOutput.riskPosture) ? rawOutput.riskPosture : fallback.riskPosture,
|
||||
runId: output.runId ?? fallback.runId,
|
||||
semanticRunId: output.semanticRunId ?? fallback.semanticRunId,
|
||||
sourceSerpInterpretationRunId: output.sourceSerpInterpretationRunId ?? fallback.sourceSerpInterpretationRunId,
|
||||
sourceStrategyGeneratedAt: output.sourceStrategyGeneratedAt ?? fallback.sourceStrategyGeneratedAt,
|
||||
sourceTaskId: output.sourceTaskId ?? fallback.sourceTaskId,
|
||||
state: output.state ?? fallback.state,
|
||||
summary
|
||||
};
|
||||
}
|
||||
|
||||
function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesisRun | null {
|
||||
if (!row.output) {
|
||||
return null;
|
||||
|
|
@ -618,3 +754,53 @@ export async function saveStrategySynthesisManual(
|
|||
|
||||
return run;
|
||||
}
|
||||
|
||||
export async function saveStrategySynthesisFromModelProvider(
|
||||
projectId: string,
|
||||
output: StrategySynthesisContract,
|
||||
sourceModelTaskRunId: string
|
||||
): Promise<StrategySynthesisRun> {
|
||||
const [strategy, serpInterpretation] = await Promise.all([
|
||||
getSeoStrategyContract(projectId),
|
||||
getLatestSerpInterpretationContract(projectId)
|
||||
]);
|
||||
const fallbackOutput = buildStrategySynthesisOutput(projectId, strategy, serpInterpretation);
|
||||
|
||||
if (!fallbackOutput) {
|
||||
throw new Error("Не удалось собрать backend fallback для AI Workspace Strategy synthesis.");
|
||||
}
|
||||
|
||||
const normalizedOutput = normalizeOutput(
|
||||
projectId,
|
||||
mergeModelProviderStrategySynthesisOutput(output, fallbackOutput),
|
||||
"codex_workspace",
|
||||
"AI Workspace Codex provider выполнил seo.strategy_synthesis по contract-only task; результат сохранён как evidence после backend validation."
|
||||
);
|
||||
const result = await pool.query<StrategySynthesisRunRow>(
|
||||
`
|
||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||
values ($1, 'seo_strategy_synthesis', 'done', $2::jsonb, $3::jsonb, now(), now())
|
||||
returning id, status, input, output, error_message, started_at, completed_at, created_at;
|
||||
`,
|
||||
[
|
||||
projectId,
|
||||
JSON.stringify({
|
||||
providerMode: "codex_workspace",
|
||||
schemaVersion: "seo-strategy-synthesis-model-provider-input.v1",
|
||||
sourceModelTaskRunId,
|
||||
sourceSerpInterpretationRunId: normalizedOutput.sourceSerpInterpretationRunId,
|
||||
sourceStrategyGeneratedAt: normalizedOutput.sourceStrategyGeneratedAt,
|
||||
sourceTaskId: normalizedOutput.sourceTaskId,
|
||||
taskType: "seo.strategy_synthesis"
|
||||
}),
|
||||
JSON.stringify(normalizedOutput)
|
||||
]
|
||||
);
|
||||
const run = result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null;
|
||||
|
||||
if (!run) {
|
||||
throw new Error("Не удалось сохранить AI Workspace Strategy synthesis.");
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue