Compare commits
No commits in common. "a8f03570c6ededd260b7ed85bd66b9dc76ac58c7" and "75fd57b036d0fc6089ba55eafcac6316130001e5" have entirely different histories.
a8f03570c6
...
75fd57b036
|
|
@ -19,33 +19,9 @@ WORDSTAT_PROVIDER=disabled
|
||||||
# WORDSTAT_REGION_IDS=225
|
# WORDSTAT_REGION_IDS=225
|
||||||
# WORDSTAT_DEVICES=DEVICE_ALL
|
# WORDSTAT_DEVICES=DEVICE_ALL
|
||||||
# WORDSTAT_NUM_PHRASES=50
|
# WORDSTAT_NUM_PHRASES=50
|
||||||
# WORDSTAT_MAX_SEEDS_PER_RUN=40
|
|
||||||
|
|
||||||
SERP_PROVIDER=disabled
|
SERP_PROVIDER=disabled
|
||||||
# SERP_API_BASE_URL=https://serp-provider.internal
|
# SERP_API_BASE_URL=https://serp-provider.internal
|
||||||
# SERP_API_TOKEN=change-me
|
# SERP_API_TOKEN=change-me
|
||||||
|
|
||||||
# SEO model-provider bridge through AI Workspace Assistant / AI Hub / remote Codex worker.
|
|
||||||
# Keep disabled until a contract-only worker workspace and explicit owner/profile are configured.
|
|
||||||
# Profiles:
|
|
||||||
# - local: local AI Workspace Assistant at 127.0.0.1/localhost.
|
|
||||||
# - deployed-hub: local SEO backend uses deployed https://ai-hub.nodedc.ru token-gated control-plane proxy.
|
|
||||||
# - deploy-host: SEO backend runs inside deployed Docker network and talks to http://ai-workspace-assistant:18082.
|
|
||||||
# - tunnel-local-e2e: explicit tunnel/Tailscale end-to-end test profile.
|
|
||||||
SEO_AI_WORKSPACE_PROFILE=disabled
|
|
||||||
# SEO_AI_WORKSPACE_CONTROL_URL=http://127.0.0.1:18082
|
|
||||||
# SEO_AI_WORKSPACE_CONTROL_URL=https://ai-hub.nodedc.ru
|
|
||||||
# SEO_AI_WORKSPACE_CONTROL_TOKEN=
|
|
||||||
# Legacy aliases accepted:
|
|
||||||
# SEO_AI_WORKSPACE_ASSISTANT_URL=
|
|
||||||
# SEO_AI_WORKSPACE_ASSISTANT_TOKEN=
|
|
||||||
# SEO_AI_WORKSPACE_OWNER_USER_ID=
|
|
||||||
# SEO_AI_WORKSPACE_OWNER_EMAIL=
|
|
||||||
# SEO_AI_WORKSPACE_OWNER_ROLE=
|
|
||||||
# SEO_AI_WORKSPACE_OWNER_GROUPS=
|
|
||||||
# SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID=
|
|
||||||
# SEO_AI_WORKSPACE_CONTRACT_ONLY=1
|
|
||||||
# SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH=/tmp/nodedc-seo-model-contracts
|
|
||||||
# SEO_AI_WORKSPACE_TIMEOUT_MS=15000
|
|
||||||
|
|
||||||
VITE_API_BASE_URL=http://localhost:4100
|
VITE_API_BASE_URL=http://localhost:4100
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -910,27 +910,6 @@ export type MarketEnrichmentContract = {
|
||||||
projectOntologyVersionId: string | null;
|
projectOntologyVersionId: string | null;
|
||||||
}>;
|
}>;
|
||||||
wordstat: {
|
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: {
|
latestJob: {
|
||||||
id: string;
|
id: string;
|
||||||
status: "queued" | "running" | "done" | "failed" | "cancelled";
|
status: "queued" | "running" | "done" | "failed" | "cancelled";
|
||||||
|
|
@ -955,10 +934,6 @@ export type MarketEnrichmentContract = {
|
||||||
frequencyGroup: string | null;
|
frequencyGroup: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
relatedCount: number;
|
relatedCount: number;
|
||||||
clusterId: string | null;
|
|
||||||
clusterTitle: string | null;
|
|
||||||
priority: "high" | "medium" | "low" | null;
|
|
||||||
seedSource: "detected" | "ontology" | string | null;
|
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
}>;
|
}>;
|
||||||
topResults: Array<{
|
topResults: Array<{
|
||||||
|
|
@ -968,10 +943,6 @@ export type MarketEnrichmentContract = {
|
||||||
frequency: number | null;
|
frequency: number | null;
|
||||||
frequencyGroup: string | null;
|
frequencyGroup: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
sourceClusterId: string | null;
|
|
||||||
sourceClusterTitle: string | null;
|
|
||||||
sourcePriority: "high" | "medium" | "low" | null;
|
|
||||||
sourceSeedSource: "detected" | "ontology" | string | null;
|
|
||||||
}>;
|
}>;
|
||||||
summary: {
|
summary: {
|
||||||
collectedSeedCount: number;
|
collectedSeedCount: number;
|
||||||
|
|
@ -1129,47 +1100,6 @@ export type SeoModelProviderCatalog = {
|
||||||
providers: SeoModelProviderDescriptor[];
|
providers: SeoModelProviderDescriptor[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SeoModelTaskAiWorkspaceDispatch = {
|
|
||||||
schemaVersion: "seo-ai-workspace-dispatch.v1";
|
|
||||||
profile: "deploy-host" | "deployed-hub" | "disabled" | "local" | "tunnel-local-e2e";
|
|
||||||
assistantThreadId: string;
|
|
||||||
assistantMessageId: string;
|
|
||||||
bridgeRequestId: string;
|
|
||||||
bridgeMode: string;
|
|
||||||
accepted: boolean;
|
|
||||||
dispatchedAt: string;
|
|
||||||
selectedExecutorId: string | null;
|
|
||||||
contractWorkspacePath: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SeoModelTaskModelOutput = {
|
|
||||||
schemaVersion: "seo-model-output.v1";
|
|
||||||
source: "ai_workspace";
|
|
||||||
status: "dispatch_accepted" | "schema_invalid" | "schema_valid";
|
|
||||||
aiWorkspace: SeoModelTaskAiWorkspaceDispatch;
|
|
||||||
rawText: string | null;
|
|
||||||
parsedJson: Record<string, unknown> | null;
|
|
||||||
validation: {
|
|
||||||
ok: boolean;
|
|
||||||
errors: string[];
|
|
||||||
warnings: string[];
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SeoModelTaskPersistedResult = {
|
|
||||||
schemaVersion: "seo-model-persisted-result.v1";
|
|
||||||
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
|
||||||
runType:
|
|
||||||
| "keyword_cleaning"
|
|
||||||
| "seo_model_task"
|
|
||||||
| "seo_normalization"
|
|
||||||
| "seo_strategy_quality_review"
|
|
||||||
| "seo_strategy_synthesis";
|
|
||||||
runId: string;
|
|
||||||
sourceModelTaskRunId?: string;
|
|
||||||
note: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SeoModelTaskRun = {
|
export type SeoModelTaskRun = {
|
||||||
schemaVersion: "seo-model-task-run.v1";
|
schemaVersion: "seo-model-task-run.v1";
|
||||||
runId: string;
|
runId: string;
|
||||||
|
|
@ -1207,8 +1137,8 @@ export type SeoModelTaskRun = {
|
||||||
inputTokenEstimate: number;
|
inputTokenEstimate: number;
|
||||||
outputTokenBudget: number;
|
outputTokenBudget: number;
|
||||||
};
|
};
|
||||||
modelOutput: SeoModelTaskModelOutput | null;
|
modelOutput: null;
|
||||||
persistedResult: SeoModelTaskPersistedResult | null;
|
persistedResult: null;
|
||||||
nextActions: string[];
|
nextActions: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -2104,27 +2034,14 @@ export type RewriteDiffContract = {
|
||||||
export type ExternalServiceSettings = {
|
export type ExternalServiceSettings = {
|
||||||
schemaVersion: "external-service-settings.v1";
|
schemaVersion: "external-service-settings.v1";
|
||||||
services: Array<{
|
services: Array<{
|
||||||
id:
|
id: "yandex_cloud" | "yandex_metrica" | "yandex_search_serp" | "yandex_webmaster" | "yandex_wordstat";
|
||||||
| "seo_analytics_model"
|
|
||||||
| "yandex_cloud"
|
|
||||||
| "yandex_metrica"
|
|
||||||
| "yandex_search_serp"
|
|
||||||
| "yandex_webmaster"
|
|
||||||
| "yandex_wordstat";
|
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
implementationStatus: "active" | "planned";
|
implementationStatus: "active" | "planned";
|
||||||
status: "configured" | "disabled" | "missing_required" | "not_implemented";
|
status: "configured" | "disabled" | "missing_required" | "not_implemented";
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mode: string;
|
mode: string;
|
||||||
dependsOn: Array<
|
dependsOn: Array<"yandex_cloud" | "yandex_metrica" | "yandex_search_serp" | "yandex_webmaster" | "yandex_wordstat">;
|
||||||
| "seo_analytics_model"
|
|
||||||
| "yandex_cloud"
|
|
||||||
| "yandex_metrica"
|
|
||||||
| "yandex_search_serp"
|
|
||||||
| "yandex_webmaster"
|
|
||||||
| "yandex_wordstat"
|
|
||||||
>;
|
|
||||||
modes: Array<{
|
modes: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -2195,56 +2112,6 @@ export type YandexAiStudioProbe = {
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SeoAnalyticsModelSetupCommand = {
|
|
||||||
schemaVersion: "seo-analytics-model-setup-command.v1";
|
|
||||||
generatedAt: string;
|
|
||||||
profile: string;
|
|
||||||
executor: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status: "checking" | "error" | "offline" | "online" | "unknown";
|
|
||||||
statusDetail: string;
|
|
||||||
};
|
|
||||||
install: {
|
|
||||||
command: string;
|
|
||||||
packageName: string;
|
|
||||||
packageSpec: string;
|
|
||||||
gatewayUrl: string;
|
|
||||||
expiresAt: string | null;
|
|
||||||
};
|
|
||||||
setupCode: {
|
|
||||||
suffix: string | null;
|
|
||||||
expiresAt: string | null;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SeoAnalyticsModelProbe = {
|
|
||||||
schemaVersion: "seo-analytics-model-probe.v1";
|
|
||||||
checkedAt: string;
|
|
||||||
profile: string;
|
|
||||||
config: {
|
|
||||||
configured: boolean;
|
|
||||||
assistantUrl: string | null;
|
|
||||||
selectedExecutorId: string | null;
|
|
||||||
contractWorkspacePath: string | null;
|
|
||||||
errors: string[];
|
|
||||||
warnings: string[];
|
|
||||||
};
|
|
||||||
executor: {
|
|
||||||
id: string | null;
|
|
||||||
name: string | null;
|
|
||||||
status: "checking" | "error" | "not_configured" | "offline" | "online" | "unknown";
|
|
||||||
statusDetail: string;
|
|
||||||
lastSeenAt: string | null;
|
|
||||||
};
|
|
||||||
connection: {
|
|
||||||
ok: boolean;
|
|
||||||
status: "connected" | "error" | "missing_config" | "not_configured" | "offline";
|
|
||||||
detail: string;
|
|
||||||
};
|
|
||||||
check: Record<string, unknown> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PageWorkspace = {
|
export type PageWorkspace = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|
@ -3262,38 +3129,6 @@ export async function probeYandexAiStudioCredential(phrase?: string) {
|
||||||
return response.json() as Promise<{ probe: YandexAiStudioProbe }>;
|
return response.json() as Promise<{ probe: YandexAiStudioProbe }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSeoAnalyticsModelSetupCommand(input: { executorId?: string; port?: number } = {}) {
|
|
||||||
const response = await fetch(`${apiBaseUrl}/settings/external-services/seo_analytics_model/setup-command`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify(input)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await getErrorMessage(response, `Не удалось подготовить Codex bridge setup command: ${response.status}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<{ setupCommand: SeoAnalyticsModelSetupCommand }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function probeSeoAnalyticsModel(input: { executorId?: string } = {}) {
|
|
||||||
const response = await fetch(`${apiBaseUrl}/settings/external-services/seo_analytics_model/probe`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
body: JSON.stringify(input)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await getErrorMessage(response, `Не удалось проверить Codex bridge: ${response.status}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<{ probe: SeoAnalyticsModelProbe }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateProjectPageSelection(projectId: string, pageIds: string[], selected: boolean) {
|
export async function updateProjectPageSelection(projectId: string, pageIds: string[], selected: boolean) {
|
||||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/selection`, {
|
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/pages/selection`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|
|
||||||
|
|
@ -522,67 +522,6 @@ input {
|
||||||
font-weight: 780;
|
font-weight: 780;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-command-panel {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
background: rgba(30, 30, 30, 0.04);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-command-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-command-head strong {
|
|
||||||
color: var(--ink);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-command-head span {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 22px;
|
|
||||||
padding: 0 8px;
|
|
||||||
color: #70521f;
|
|
||||||
background: rgba(112, 82, 31, 0.07);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-command-head span.ok {
|
|
||||||
color: #2f6b4f;
|
|
||||||
background: rgba(47, 107, 79, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-command-panel pre {
|
|
||||||
max-height: 118px;
|
|
||||||
margin: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 9px;
|
|
||||||
color: #f5f5f5;
|
|
||||||
background: #1f2428;
|
|
||||||
border-radius: 7px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-command-panel small {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 680;
|
|
||||||
line-height: 1.35;
|
|
||||||
}
|
|
||||||
|
|
||||||
.service-card-footer {
|
.service-card-footer {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
|
|
@ -2919,41 +2858,6 @@ input {
|
||||||
overflow-wrap: anywhere;
|
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 {
|
.anchor-review-workspace {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
@ -2993,14 +2897,13 @@ input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-curation-board {
|
.keyword-curation-board {
|
||||||
--keyword-curation-radius: var(--launcher-radius-card, 1.35rem);
|
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: var(--keyword-curation-radius);
|
border-radius: var(--launcher-radius-card, 1.35rem);
|
||||||
box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055);
|
box-shadow: 0 14px 42px rgba(24, 32, 29, 0.055);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3023,9 +2926,9 @@ input {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 9px;
|
padding: 0 9px;
|
||||||
color: rgba(8, 8, 10, 0.78);
|
color: #2e5a67;
|
||||||
background: rgba(8, 8, 10, 0.07);
|
background: rgba(46, 90, 103, 0.08);
|
||||||
border: 0;
|
border: 1px solid rgba(46, 90, 103, 0.14);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
|
@ -3071,15 +2974,15 @@ input {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: rgba(24, 32, 29, 0.035);
|
background: rgba(24, 32, 29, 0.035);
|
||||||
border: 1px solid rgba(24, 32, 29, 0.07);
|
border: 1px solid rgba(24, 32, 29, 0.07);
|
||||||
border-radius: var(--keyword-curation-radius);
|
border-radius: 8px;
|
||||||
transition:
|
transition:
|
||||||
background-color 140ms ease,
|
background-color 140ms ease,
|
||||||
border-color 140ms ease;
|
border-color 140ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-curation-column.over {
|
.keyword-curation-column.over {
|
||||||
background: rgba(8, 8, 10, 0.055);
|
background: rgba(46, 90, 103, 0.07);
|
||||||
border-color: rgba(8, 8, 10, 0.18);
|
border-color: rgba(46, 90, 103, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyword-curation-column-head {
|
.keyword-curation-column-head {
|
||||||
|
|
@ -3140,7 +3043,7 @@ input {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: rgba(255, 255, 255, 0.92);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
border: 1px solid rgba(24, 32, 29, 0.08);
|
border: 1px solid rgba(24, 32, 29, 0.08);
|
||||||
border-radius: var(--keyword-curation-radius);
|
border-radius: 8px;
|
||||||
box-shadow: 0 10px 24px rgba(24, 32, 29, 0.08);
|
box-shadow: 0 10px 24px rgba(24, 32, 29, 0.08);
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
@ -3169,7 +3072,7 @@ input {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
background: rgba(255, 255, 255, 0.52);
|
background: rgba(255, 255, 255, 0.52);
|
||||||
border: 1px dashed rgba(24, 32, 29, 0.14);
|
border: 1px dashed rgba(24, 32, 29, 0.14);
|
||||||
border-radius: var(--keyword-curation-radius);
|
border-radius: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 820;
|
font-weight: 820;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
@ -14189,7 +14092,6 @@ body:has(.seo-launcher-shell) {
|
||||||
|
|
||||||
/* Work panels: the header is part of the same surface, not a separate card. */
|
/* Work panels: the header is part of the same surface, not a separate card. */
|
||||||
.seo-launcher-shell.project-open .topbar {
|
.seo-launcher-shell.project-open .topbar {
|
||||||
position: relative !important;
|
|
||||||
display: grid !important;
|
display: grid !important;
|
||||||
grid-template-columns: minmax(0, 1fr) auto !important;
|
grid-template-columns: minmax(0, 1fr) auto !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
|
|
@ -14251,84 +14153,6 @@ body:has(.seo-launcher-shell) {
|
||||||
gap: 0.6rem;
|
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 {
|
.seo-topbar-primary-action {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-height: 2.65rem;
|
min-height: 2.65rem;
|
||||||
|
|
@ -14382,21 +14206,6 @@ body:has(.seo-launcher-shell) {
|
||||||
color: #fff;
|
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 {
|
.seo-topbar-workspace-actions em {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 1.34rem;
|
min-width: 1.34rem;
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@
|
||||||
"check:semantic-block-runtime": "npm run check:semantic-block-runtime -w server",
|
"check:semantic-block-runtime": "npm run check:semantic-block-runtime -w server",
|
||||||
"check:semantic-blocks": "npm run check:semantic-blocks -w server",
|
"check:semantic-blocks": "npm run check:semantic-blocks -w server",
|
||||||
"check:source-models": "npm run check:source-models -w server",
|
"check:source-models": "npm run check:source-models -w server",
|
||||||
"check:ontology-universality": "npm run check:ontology-universality -w server",
|
|
||||||
"typecheck": "npm run typecheck -w server && npm run typecheck -w app",
|
"typecheck": "npm run typecheck -w server && npm run typecheck -w app",
|
||||||
"db:migrate": "npm run db:migrate -w server"
|
"db:migrate": "npm run db:migrate -w server"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -25,25 +25,9 @@ const envSchema = z.object({
|
||||||
WORDSTAT_REGION_IDS: z.string().default("225"),
|
WORDSTAT_REGION_IDS: z.string().default("225"),
|
||||||
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
|
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
|
||||||
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
|
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
|
||||||
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(40),
|
|
||||||
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
|
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
|
||||||
SERP_API_BASE_URL: z.string().url().optional(),
|
SERP_API_BASE_URL: z.string().url().optional(),
|
||||||
SERP_API_TOKEN: z.string().optional(),
|
SERP_API_TOKEN: z.string().optional(),
|
||||||
SEO_AI_WORKSPACE_PROFILE: z
|
|
||||||
.enum(["disabled", "local", "deployed-hub", "deploy-host", "tunnel-local-e2e"])
|
|
||||||
.default("disabled"),
|
|
||||||
SEO_AI_WORKSPACE_CONTROL_URL: z.string().url().optional(),
|
|
||||||
SEO_AI_WORKSPACE_CONTROL_TOKEN: z.string().optional(),
|
|
||||||
SEO_AI_WORKSPACE_ASSISTANT_URL: z.string().url().optional(),
|
|
||||||
SEO_AI_WORKSPACE_ASSISTANT_TOKEN: z.string().optional(),
|
|
||||||
SEO_AI_WORKSPACE_OWNER_USER_ID: z.string().optional(),
|
|
||||||
SEO_AI_WORKSPACE_OWNER_EMAIL: z.string().optional(),
|
|
||||||
SEO_AI_WORKSPACE_OWNER_ROLE: z.string().optional(),
|
|
||||||
SEO_AI_WORKSPACE_OWNER_GROUPS: z.string().default(""),
|
|
||||||
SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID: z.string().optional(),
|
|
||||||
SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH: z.string().optional(),
|
|
||||||
SEO_AI_WORKSPACE_CONTRACT_ONLY: booleanFromString.default(false),
|
|
||||||
SEO_AI_WORKSPACE_TIMEOUT_MS: z.coerce.number().int().positive().max(60_000).default(15_000),
|
|
||||||
PREVIEW_CHROME_PATH: z.string().optional(),
|
PREVIEW_CHROME_PATH: z.string().optional(),
|
||||||
PREVIEW_SNAPSHOT_CONCURRENCY: z.coerce.number().int().positive().max(4).default(2),
|
PREVIEW_SNAPSHOT_CONCURRENCY: z.coerce.number().int().positive().max(4).default(2),
|
||||||
PREVIEW_SNAPSHOT_WAIT_MS: z.coerce.number().int().nonnegative().max(10_000).default(1400)
|
PREVIEW_SNAPSHOT_WAIT_MS: z.coerce.number().int().nonnegative().max(10_000).default(1400)
|
||||||
|
|
@ -73,7 +57,6 @@ export const env = {
|
||||||
regionIds: parsedEnv.WORDSTAT_REGION_IDS.split(",").map((item) => item.trim()).filter(Boolean),
|
regionIds: parsedEnv.WORDSTAT_REGION_IDS.split(",").map((item) => item.trim()).filter(Boolean),
|
||||||
devices: parsedEnv.WORDSTAT_DEVICES.split(",").map((item) => item.trim()).filter(Boolean),
|
devices: parsedEnv.WORDSTAT_DEVICES.split(",").map((item) => item.trim()).filter(Boolean),
|
||||||
numPhrases: parsedEnv.WORDSTAT_NUM_PHRASES,
|
numPhrases: parsedEnv.WORDSTAT_NUM_PHRASES,
|
||||||
maxSeedsPerRun: parsedEnv.WORDSTAT_MAX_SEEDS_PER_RUN,
|
|
||||||
hasApiToken: Boolean(parsedEnv.WORDSTAT_API_TOKEN)
|
hasApiToken: Boolean(parsedEnv.WORDSTAT_API_TOKEN)
|
||||||
},
|
},
|
||||||
serp: {
|
serp: {
|
||||||
|
|
@ -82,22 +65,6 @@ export const env = {
|
||||||
hasApiToken: Boolean(parsedEnv.SERP_API_TOKEN)
|
hasApiToken: Boolean(parsedEnv.SERP_API_TOKEN)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
aiWorkspace: {
|
|
||||||
profile: parsedEnv.SEO_AI_WORKSPACE_PROFILE,
|
|
||||||
assistantUrl: parsedEnv.SEO_AI_WORKSPACE_CONTROL_URL ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_URL,
|
|
||||||
assistantToken: parsedEnv.SEO_AI_WORKSPACE_CONTROL_TOKEN ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_TOKEN,
|
|
||||||
contractOnly: parsedEnv.SEO_AI_WORKSPACE_CONTRACT_ONLY,
|
|
||||||
contractWorkspacePath: parsedEnv.SEO_AI_WORKSPACE_CONTRACT_WORKSPACE_PATH,
|
|
||||||
selectedExecutorId: parsedEnv.SEO_AI_WORKSPACE_SELECTED_EXECUTOR_ID,
|
|
||||||
requestTimeoutMs: parsedEnv.SEO_AI_WORKSPACE_TIMEOUT_MS,
|
|
||||||
owner: {
|
|
||||||
userId: parsedEnv.SEO_AI_WORKSPACE_OWNER_USER_ID,
|
|
||||||
email: parsedEnv.SEO_AI_WORKSPACE_OWNER_EMAIL,
|
|
||||||
role: parsedEnv.SEO_AI_WORKSPACE_OWNER_ROLE,
|
|
||||||
groups: parsedEnv.SEO_AI_WORKSPACE_OWNER_GROUPS.split(",").map((item) => item.trim()).filter(Boolean)
|
|
||||||
},
|
|
||||||
hasAssistantToken: Boolean(parsedEnv.SEO_AI_WORKSPACE_CONTROL_TOKEN ?? parsedEnv.SEO_AI_WORKSPACE_ASSISTANT_TOKEN)
|
|
||||||
},
|
|
||||||
preview: {
|
preview: {
|
||||||
chromePath: parsedEnv.PREVIEW_CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
chromePath: parsedEnv.PREVIEW_CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||||
snapshotConcurrency: parsedEnv.PREVIEW_SNAPSHOT_CONCURRENCY,
|
snapshotConcurrency: parsedEnv.PREVIEW_SNAPSHOT_CONCURRENCY,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import { pool } from "../db/client.js";
|
import { pool } from "../db/client.js";
|
||||||
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||||
|
|
||||||
type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
type KeywordCleaningProviderMode = "codex_manual" | "deterministic_fallback";
|
||||||
type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
|
type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
|
||||||
type KeywordCleaningSource = "manual" | "normalization_seed" | "serp" | "wordstat_related" | "wordstat_seed";
|
type KeywordCleaningSource = "manual" | "normalization_seed" | "serp" | "wordstat_related" | "wordstat_seed";
|
||||||
|
|
||||||
const KEYWORD_CLEANING_DECISIONS: KeywordCleaningDecision[] = ["use", "support", "article", "risky", "trash"];
|
|
||||||
const KEYWORD_CLEANING_DECISION_SET = new Set<string>(KEYWORD_CLEANING_DECISIONS);
|
|
||||||
|
|
||||||
export type KeywordCleaningModelTaskContract = {
|
export type KeywordCleaningModelTaskContract = {
|
||||||
taskType: "seo.keyword_cleaning";
|
taskType: "seo.keyword_cleaning";
|
||||||
schemaVersion: "seo-keyword-cleaning-task.v1";
|
schemaVersion: "seo-keyword-cleaning-task.v1";
|
||||||
|
|
@ -29,90 +26,17 @@ export type KeywordCleaningModelTaskContract = {
|
||||||
wordstatResultCount: number;
|
wordstatResultCount: number;
|
||||||
wordstatJobStatus: string | null;
|
wordstatJobStatus: string | null;
|
||||||
};
|
};
|
||||||
input: {
|
|
||||||
approvedAnchors: Array<{
|
|
||||||
phrase: string;
|
|
||||||
clusterId: string;
|
|
||||||
clusterTitle: string;
|
|
||||||
intentType: string;
|
|
||||||
marketRole: string;
|
|
||||||
priority: string;
|
|
||||||
confidence: number;
|
|
||||||
normalizedFrom: string[];
|
|
||||||
}>;
|
|
||||||
landingBriefs: Array<{
|
|
||||||
path: string;
|
|
||||||
action: string;
|
|
||||||
priority: string;
|
|
||||||
qualityScore: number | null;
|
|
||||||
seedPhrases: string[];
|
|
||||||
evidenceStatus: string;
|
|
||||||
}>;
|
|
||||||
normalizationIntentGroups: Array<{
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
intentType: string;
|
|
||||||
priority: string;
|
|
||||||
marketHypothesis: string;
|
|
||||||
wordstatQueries: string[];
|
|
||||||
excludedPhrases: string[];
|
|
||||||
}>;
|
|
||||||
seedQueue: Array<{
|
|
||||||
phrase: string;
|
|
||||||
clusterId: string;
|
|
||||||
clusterTitle: string;
|
|
||||||
priority: string;
|
|
||||||
source: string;
|
|
||||||
intentType: string | null;
|
|
||||||
marketRole: string | null;
|
|
||||||
evidenceStatus: string;
|
|
||||||
frequency: number | null;
|
|
||||||
frequencyGroup: string | null;
|
|
||||||
relatedCount: number;
|
|
||||||
targetPath: string | null;
|
|
||||||
projectOntologyVersionId: string | null;
|
|
||||||
wordstatResultId: string | null;
|
|
||||||
}>;
|
|
||||||
wordstatTopResults: Array<{
|
|
||||||
id: string;
|
|
||||||
phrase: string;
|
|
||||||
sourcePhrase: string | null;
|
|
||||||
frequency: number | null;
|
|
||||||
frequencyGroup: string | null;
|
|
||||||
region: string | null;
|
|
||||||
sourceClusterId: string | null;
|
|
||||||
sourceClusterTitle: string | null;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
schemaVersion: "keyword-cleaning.v1";
|
schemaVersion: "keyword-cleaning.v1";
|
||||||
requiredTopLevelKeys: string[];
|
requiredTopLevelKeys: string[];
|
||||||
itemRequiredKeys: string[];
|
itemRequiredKeys: string[];
|
||||||
decisionValues: KeywordCleaningDecision[];
|
|
||||||
};
|
};
|
||||||
decisionPolicy: {
|
decisionPolicy: {
|
||||||
broadFrequencyDoesNotOverrideSemanticFit: boolean;
|
|
||||||
demoteRelatedWhenDominantFacetMissing: boolean;
|
|
||||||
preserveApprovedAnchorFacets: boolean;
|
|
||||||
useRequiresExactEvidence: boolean;
|
useRequiresExactEvidence: boolean;
|
||||||
riskyWhenNoPageBinding: boolean;
|
riskyWhenNoPageBinding: boolean;
|
||||||
trashWhenOffContext: boolean;
|
trashWhenOffContext: boolean;
|
||||||
articleDoesNotEnterRewrite: 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[];
|
stopConditions: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -200,27 +124,13 @@ type KeywordCleaningRunRow = {
|
||||||
|
|
||||||
type SeedQueueItem = MarketEnrichmentContract["seedQueue"][number];
|
type SeedQueueItem = MarketEnrichmentContract["seedQueue"][number];
|
||||||
type WordstatTopResult = MarketEnrichmentContract["wordstat"]["topResults"][number];
|
type WordstatTopResult = MarketEnrichmentContract["wordstat"]["topResults"][number];
|
||||||
type RelatedSourceSeed = Pick<
|
|
||||||
SeedQueueItem,
|
|
||||||
"clusterId" | "clusterTitle" | "normalization" | "phrase" | "priority" | "projectOntologyVersionId" | "source"
|
|
||||||
>;
|
|
||||||
|
|
||||||
const HARD_TRASH_PATTERNS = [
|
const HARD_TRASH_PATTERNS = [
|
||||||
/(^|\s)(ваканси[ия]|работа|зарплата|резюме)(\s|$)/i,
|
/(^|\s)(ваканси[ия]|работа|зарплата|резюме)(\s|$)/i,
|
||||||
/(^|\s)(реферат|курсовая|диплом|презентация|скачать|pdf|книга)(\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 = [
|
const ARTICLE_PATTERNS = [
|
||||||
/(^|\s)(как|что такое|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд)(\s|$)/i,
|
/(^|\s)(как|что такое|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд)(\s|$)/i,
|
||||||
/(^|\s)(выбрать|сравнение|обзор|решение проблемы)(\s|$)/i
|
/(^|\s)(выбрать|сравнение|обзор|решение проблемы)(\s|$)/i
|
||||||
|
|
@ -241,110 +151,6 @@ function wordCount(value: string) {
|
||||||
return normalizePhrase(value).split(/\s+/).filter(Boolean).length;
|
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[]) {
|
function hasPattern(value: string, patterns: RegExp[]) {
|
||||||
return patterns.some((pattern) => pattern.test(value));
|
return patterns.some((pattern) => pattern.test(value));
|
||||||
}
|
}
|
||||||
|
|
@ -368,36 +174,6 @@ function unique(values: string[]) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeKeywordCleaningDecision(value: unknown): KeywordCleaningDecision {
|
|
||||||
const decision = String(value || "").trim();
|
|
||||||
|
|
||||||
if (KEYWORD_CLEANING_DECISION_SET.has(decision)) {
|
|
||||||
return decision as KeywordCleaningDecision;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decision === "route_keyword_map") {
|
|
||||||
return "use";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decision === "mark_risky_for_human" || decision === "preserve_unresolved") {
|
|
||||||
return "risky";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decision === "keep_article_backlog") {
|
|
||||||
return "article";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decision === "reject_noise") {
|
|
||||||
return "trash";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decision === "preserve_evidence_refs") {
|
|
||||||
return "support";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "risky";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultAnalystReview(): KeywordCleaningContract["analystReview"] {
|
function getDefaultAnalystReview(): KeywordCleaningContract["analystReview"] {
|
||||||
return {
|
return {
|
||||||
notes: [],
|
notes: [],
|
||||||
|
|
@ -437,139 +213,7 @@ function buildBriefClusterMap(contract: MarketEnrichmentContract) {
|
||||||
return clusterMap;
|
return clusterMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
type KeywordCleaningFacetGuard = {
|
function getTargetPath(seed: SeedQueueItem, briefPhraseMap: Map<string, string>, briefClusterMap: Map<string, string>) {
|
||||||
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));
|
const directTarget = briefPhraseMap.get(normalizePhrase(seed.phrase));
|
||||||
|
|
||||||
if (directTarget) {
|
if (directTarget) {
|
||||||
|
|
@ -676,7 +320,7 @@ function buildSeedItem(
|
||||||
|
|
||||||
function buildRelatedItem(
|
function buildRelatedItem(
|
||||||
result: WordstatTopResult,
|
result: WordstatTopResult,
|
||||||
sourceSeed: RelatedSourceSeed,
|
sourceSeed: SeedQueueItem,
|
||||||
index: number,
|
index: number,
|
||||||
briefPhraseMap: Map<string, string>,
|
briefPhraseMap: Map<string, string>,
|
||||||
briefClusterMap: Map<string, string>
|
briefClusterMap: Map<string, string>
|
||||||
|
|
@ -717,25 +361,6 @@ 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) {
|
function getBaseItems(contract: MarketEnrichmentContract) {
|
||||||
const briefPhraseMap = buildBriefPhraseMap(contract);
|
const briefPhraseMap = buildBriefPhraseMap(contract);
|
||||||
const briefClusterMap = buildBriefClusterMap(contract);
|
const briefClusterMap = buildBriefClusterMap(contract);
|
||||||
|
|
@ -754,9 +379,7 @@ function getBaseItems(contract: MarketEnrichmentContract) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourcePhrase = result.sourcePhrase ? normalizePhrase(result.sourcePhrase) : normalizedResult;
|
const sourcePhrase = result.sourcePhrase ? normalizePhrase(result.sourcePhrase) : normalizedResult;
|
||||||
const sourceSeed =
|
const sourceSeed = seedByPhrase.get(sourcePhrase);
|
||||||
seedByPhrase.get(sourcePhrase) ??
|
|
||||||
buildRelatedSourceSeedFromWordstatResult(result, contract.projectOntologyVersion?.id ?? null);
|
|
||||||
|
|
||||||
if (!sourceSeed) {
|
if (!sourceSeed) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -769,137 +392,6 @@ function getBaseItems(contract: MarketEnrichmentContract) {
|
||||||
return [...seedItems, ...relatedItems];
|
return [...seedItems, ...relatedItems];
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeModelProviderItemsWithMarketEvidence(
|
|
||||||
items: KeywordCleaningItem[],
|
|
||||||
market: MarketEnrichmentContract
|
|
||||||
): KeywordCleaningItem[] {
|
|
||||||
const baseItems = getBaseItems(market);
|
|
||||||
const baseById = new Map(baseItems.map((item) => [item.id, item]));
|
|
||||||
const baseByPhrase = new Map(baseItems.map((item) => [item.normalizedPhrase, item]));
|
|
||||||
const baseByWordstatResultId = new Map(
|
|
||||||
baseItems
|
|
||||||
.filter((item) => item.wordstatResultId)
|
|
||||||
.map((item) => [item.wordstatResultId as string, item])
|
|
||||||
);
|
|
||||||
|
|
||||||
return items.map((item) => {
|
|
||||||
const normalizedPhrase = normalizePhrase(item.normalizedPhrase || item.phrase);
|
|
||||||
const base =
|
|
||||||
(item.wordstatResultId ? baseByWordstatResultId.get(item.wordstatResultId) : null) ??
|
|
||||||
(item.id ? baseById.get(item.id) : null) ??
|
|
||||||
baseByPhrase.get(normalizedPhrase) ??
|
|
||||||
null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
clusterId: base?.clusterId ?? item.clusterId ?? "model-provider",
|
|
||||||
clusterTitle: base?.clusterTitle ?? item.clusterTitle ?? item.phrase,
|
|
||||||
evidenceRefs: unique([
|
|
||||||
...(base?.evidenceRefs ?? []),
|
|
||||||
...(Array.isArray(item.evidenceRefs) ? item.evidenceRefs : [])
|
|
||||||
]),
|
|
||||||
evidenceStatus: base?.evidenceStatus ?? item.evidenceStatus ?? "not_collected",
|
|
||||||
frequency: base?.frequency ?? item.frequency ?? null,
|
|
||||||
frequencyGroup: base?.frequencyGroup ?? item.frequencyGroup ?? null,
|
|
||||||
id: base?.id ?? item.id ?? `model:${slugify(item.phrase)}`,
|
|
||||||
intentType: base?.intentType ?? item.intentType ?? null,
|
|
||||||
marketRole: base?.marketRole ?? item.marketRole ?? null,
|
|
||||||
normalizedPhrase: base?.normalizedPhrase ?? normalizedPhrase,
|
|
||||||
phrase: base?.phrase ?? item.phrase,
|
|
||||||
priority: base?.priority ?? item.priority ?? "medium",
|
|
||||||
projectOntologyVersionId:
|
|
||||||
base?.projectOntologyVersionId ?? item.projectOntologyVersionId ?? market.projectOntologyVersion?.id ?? null,
|
|
||||||
relatedCount: base?.relatedCount ?? item.relatedCount ?? 0,
|
|
||||||
seedSource: base?.seedSource ?? item.seedSource ?? "detected",
|
|
||||||
source: base?.source ?? item.source ?? "manual",
|
|
||||||
sourcePhrase: base?.sourcePhrase ?? item.sourcePhrase ?? null,
|
|
||||||
targetPath: base?.targetPath ?? item.targetPath ?? null,
|
|
||||||
wordstatResultId: base?.wordstatResultId ?? item.wordstatResultId ?? null
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): KeywordCleaningModelTaskContract["input"] {
|
|
||||||
const briefPhraseMap = buildBriefPhraseMap(contract);
|
|
||||||
const briefClusterMap = buildBriefClusterMap(contract);
|
|
||||||
|
|
||||||
return {
|
|
||||||
approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, 80).map((anchor) => ({
|
|
||||||
clusterId: anchor.clusterId,
|
|
||||||
clusterTitle: anchor.clusterTitle,
|
|
||||||
confidence: anchor.confidence,
|
|
||||||
intentType: anchor.intentType,
|
|
||||||
marketRole: anchor.marketRole,
|
|
||||||
normalizedFrom: anchor.normalizedFrom,
|
|
||||||
phrase: anchor.phrase,
|
|
||||||
priority: anchor.priority
|
|
||||||
})),
|
|
||||||
landingBriefs: contract.briefEvidence.slice(0, 40).map((brief) => ({
|
|
||||||
action: brief.action,
|
|
||||||
evidenceStatus: brief.evidenceStatus,
|
|
||||||
path: brief.path,
|
|
||||||
priority: brief.priority,
|
|
||||||
qualityScore: brief.qualityScore,
|
|
||||||
seedPhrases: brief.seedPhrases.slice(0, 10)
|
|
||||||
})),
|
|
||||||
normalizationIntentGroups: contract.normalization.intentGroups.slice(0, 40).map((group) => ({
|
|
||||||
excludedPhrases: group.excludedPhrases.slice(0, 12).map((item) => item.phrase),
|
|
||||||
id: group.id,
|
|
||||||
intentType: group.intentType,
|
|
||||||
marketHypothesis: group.marketHypothesis,
|
|
||||||
priority: group.priority,
|
|
||||||
title: group.title,
|
|
||||||
wordstatQueries: group.wordstatQueries.slice(0, 12)
|
|
||||||
})),
|
|
||||||
seedQueue: contract.seedQueue.slice(0, 120).map((seed) => ({
|
|
||||||
clusterId: seed.clusterId,
|
|
||||||
clusterTitle: seed.clusterTitle,
|
|
||||||
evidenceStatus: seed.evidenceStatus,
|
|
||||||
frequency: seed.frequency,
|
|
||||||
frequencyGroup: seed.frequencyGroup,
|
|
||||||
intentType: seed.normalization?.intentType ?? null,
|
|
||||||
marketRole: seed.normalization?.marketRole ?? null,
|
|
||||||
phrase: seed.phrase,
|
|
||||||
priority: seed.priority,
|
|
||||||
projectOntologyVersionId: seed.projectOntologyVersionId,
|
|
||||||
relatedCount: seed.relatedCount,
|
|
||||||
source: seed.source,
|
|
||||||
targetPath: getTargetPath(seed, briefPhraseMap, briefClusterMap),
|
|
||||||
wordstatResultId: seed.wordstatResultId
|
|
||||||
})),
|
|
||||||
wordstatTopResults: contract.wordstat.topResults.slice(0, 120).map((result) => ({
|
|
||||||
frequency: result.frequency,
|
|
||||||
frequencyGroup: result.frequencyGroup,
|
|
||||||
id: result.id,
|
|
||||||
phrase: result.phrase,
|
|
||||||
region: result.region,
|
|
||||||
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>) {
|
function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>) {
|
||||||
const blockers: string[] = [];
|
const blockers: string[] = [];
|
||||||
const exactEvidence = item.frequency !== null;
|
const exactEvidence = item.frequency !== null;
|
||||||
|
|
@ -914,9 +406,6 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>
|
||||||
const isTrash =
|
const isTrash =
|
||||||
item.marketRole === "exclude" ||
|
item.marketRole === "exclude" ||
|
||||||
hasPattern(item.normalizedPhrase, HARD_TRASH_PATTERNS) ||
|
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" && !hasContextOverlap(item.phrase, contextTokens)) ||
|
||||||
(item.source === "wordstat_related" && wordCount(item.phrase) <= 1);
|
(item.source === "wordstat_related" && wordCount(item.phrase) <= 1);
|
||||||
|
|
||||||
|
|
@ -1018,13 +507,7 @@ function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningCont
|
||||||
return {
|
return {
|
||||||
articleCount: lanes.article.length,
|
articleCount: lanes.article.length,
|
||||||
exactEvidenceCount: items.filter((item) => item.frequency !== null).length,
|
exactEvidenceCount: items.filter((item) => item.frequency !== null).length,
|
||||||
keywordMapCandidateCount: items.filter(
|
keywordMapCandidateCount: lanes.use.length + lanes.support.length,
|
||||||
(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,
|
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
|
||||||
riskyCount: lanes.risky.length,
|
riskyCount: lanes.risky.length,
|
||||||
sourcePhraseCount: items.length,
|
sourcePhraseCount: items.length,
|
||||||
|
|
@ -1053,9 +536,6 @@ function buildKeywordCleaningModelTask(
|
||||||
],
|
],
|
||||||
decisionPolicy: {
|
decisionPolicy: {
|
||||||
articleDoesNotEnterRewrite: true,
|
articleDoesNotEnterRewrite: true,
|
||||||
broadFrequencyDoesNotOverrideSemanticFit: true,
|
|
||||||
demoteRelatedWhenDominantFacetMissing: true,
|
|
||||||
preserveApprovedAnchorFacets: true,
|
|
||||||
riskyWhenNoPageBinding: true,
|
riskyWhenNoPageBinding: true,
|
||||||
trashWhenOffContext: true,
|
trashWhenOffContext: true,
|
||||||
useRequiresExactEvidence: true
|
useRequiresExactEvidence: true
|
||||||
|
|
@ -1065,9 +545,7 @@ function buildKeywordCleaningModelTask(
|
||||||
wordstatJobStatus: contract.wordstat.latestJob?.status ?? null,
|
wordstatJobStatus: contract.wordstat.latestJob?.status ?? null,
|
||||||
wordstatResultCount: contract.wordstat.summary.resultCount
|
wordstatResultCount: contract.wordstat.summary.resultCount
|
||||||
},
|
},
|
||||||
input: buildKeywordCleaningModelInput(contract),
|
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
decisionValues: KEYWORD_CLEANING_DECISIONS,
|
|
||||||
itemRequiredKeys: [
|
itemRequiredKeys: [
|
||||||
"id",
|
"id",
|
||||||
"phrase",
|
"phrase",
|
||||||
|
|
@ -1084,12 +562,9 @@ function buildKeywordCleaningModelTask(
|
||||||
"semanticRunId",
|
"semanticRunId",
|
||||||
"provider",
|
"provider",
|
||||||
"analystReview",
|
"analystReview",
|
||||||
"source",
|
|
||||||
"modelTask",
|
|
||||||
"readiness",
|
"readiness",
|
||||||
"lanes",
|
"lanes",
|
||||||
"items",
|
"items",
|
||||||
"summary",
|
|
||||||
"nextActions"
|
"nextActions"
|
||||||
],
|
],
|
||||||
schemaVersion: "keyword-cleaning.v1"
|
schemaVersion: "keyword-cleaning.v1"
|
||||||
|
|
@ -1099,12 +574,9 @@ function buildKeywordCleaningModelTask(
|
||||||
providerMode: "model_provider_contract",
|
providerMode: "model_provider_contract",
|
||||||
schemaVersion: "seo-keyword-cleaning-task.v1",
|
schemaVersion: "seo-keyword-cleaning-task.v1",
|
||||||
semanticRunId: contract.semanticRunId,
|
semanticRunId: contract.semanticRunId,
|
||||||
semanticGuard: buildKeywordCleaningSemanticGuard(contract),
|
|
||||||
stopConditions: [
|
stopConditions: [
|
||||||
"Не отправлять trash в keyword map. Risky/article exact-фразы можно только предсортировать в Stage 3, но не считать approved без пользователя.",
|
"Не отправлять trash/risky в keyword map как approved decision.",
|
||||||
"Не придумывать спрос без Wordstat/SERP evidence.",
|
"Не придумывать спрос без Wordstat/SERP evidence.",
|
||||||
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
|
|
||||||
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря или доминантный смысл approved anchors.",
|
|
||||||
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
||||||
"Не сохранять rewrite/apply decisions."
|
"Не сохранять rewrite/apply decisions."
|
||||||
],
|
],
|
||||||
|
|
@ -1141,7 +613,7 @@ function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">
|
||||||
}
|
}
|
||||||
|
|
||||||
actions.push(
|
actions.push(
|
||||||
`В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} exact-кандидатов в 5 колонок; финальные роли фиксирует пользователь.`
|
`В keyword map передавать только use/support: ${contract.readiness.keywordMapCandidateCount} кандидатов с evidence и page binding.`
|
||||||
);
|
);
|
||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
|
|
@ -1149,7 +621,7 @@ function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">
|
||||||
|
|
||||||
function buildFallbackContract(projectId: string, market: MarketEnrichmentContract): KeywordCleaningContract {
|
function buildFallbackContract(projectId: string, market: MarketEnrichmentContract): KeywordCleaningContract {
|
||||||
const modelTask = buildKeywordCleaningModelTask(projectId, market);
|
const modelTask = buildKeywordCleaningModelTask(projectId, market);
|
||||||
const items = enforceKeywordCleaningSemanticGuard(market, classifyItems(market, getBaseItems(market)));
|
const items = classifyItems(market, getBaseItems(market));
|
||||||
const lanes = buildLanes(items);
|
const lanes = buildLanes(items);
|
||||||
const readiness = buildReadiness(items, lanes);
|
const readiness = buildReadiness(items, lanes);
|
||||||
const contractWithoutActions: Omit<KeywordCleaningContract, "nextActions"> = {
|
const contractWithoutActions: Omit<KeywordCleaningContract, "nextActions"> = {
|
||||||
|
|
@ -1232,18 +704,8 @@ function normalizeKeywordCleaningOutput(
|
||||||
|
|
||||||
const items = output.items.map((item) => ({
|
const items = output.items.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
blockers: Array.isArray(item.blockers)
|
blockers: Array.isArray(item.blockers) ? unique(item.blockers) : [],
|
||||||
? unique([
|
|
||||||
...item.blockers,
|
|
||||||
KEYWORD_CLEANING_DECISION_SET.has(String(item.decision || "").trim())
|
|
||||||
? ""
|
|
||||||
: `model_decision_alias:${String(item.decision || "").trim() || "empty"}`
|
|
||||||
].filter(Boolean))
|
|
||||||
: KEYWORD_CLEANING_DECISION_SET.has(String(item.decision || "").trim())
|
|
||||||
? []
|
|
||||||
: [`model_decision_alias:${String(item.decision || "").trim() || "empty"}`],
|
|
||||||
confidence: Math.max(0, Math.min(1, item.confidence)),
|
confidence: Math.max(0, Math.min(1, item.confidence)),
|
||||||
decision: normalizeKeywordCleaningDecision(item.decision),
|
|
||||||
evidenceRefs: Array.isArray(item.evidenceRefs) ? unique(item.evidenceRefs) : [],
|
evidenceRefs: Array.isArray(item.evidenceRefs) ? unique(item.evidenceRefs) : [],
|
||||||
normalizedPhrase: normalizePhrase(item.normalizedPhrase || item.phrase)
|
normalizedPhrase: normalizePhrase(item.normalizedPhrase || item.phrase)
|
||||||
}));
|
}));
|
||||||
|
|
@ -1258,7 +720,7 @@ function normalizeKeywordCleaningOutput(
|
||||||
provider: {
|
provider: {
|
||||||
message: providerMessage,
|
message: providerMessage,
|
||||||
mode: providerMode,
|
mode: providerMode,
|
||||||
modelRequired: providerMode !== "codex_manual"
|
modelRequired: true
|
||||||
},
|
},
|
||||||
readiness,
|
readiness,
|
||||||
sourceTaskId: output.modelTask.taskId,
|
sourceTaskId: output.modelTask.taskId,
|
||||||
|
|
@ -1313,36 +775,7 @@ export async function getKeywordCleaningContract(projectId: string): Promise<Key
|
||||||
return fallbackContract;
|
return fallbackContract;
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = await getLatestAlignedKeywordCleaning(projectId, market.semanticRunId, market.wordstat.latestJob?.id ?? null);
|
return (await getLatestAlignedKeywordCleaning(projectId, market.semanticRunId, market.wordstat.latestJob?.id ?? null)) ?? fallbackContract;
|
||||||
|
|
||||||
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(
|
export async function saveKeywordCleaningManual(
|
||||||
|
|
@ -1382,67 +815,3 @@ export async function saveKeywordCleaningManual(
|
||||||
|
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveKeywordCleaningFromModelProvider(
|
|
||||||
projectId: string,
|
|
||||||
output: KeywordCleaningContract,
|
|
||||||
sourceModelTaskRunId: string
|
|
||||||
): Promise<KeywordCleaningContract> {
|
|
||||||
const market = await getMarketEnrichmentContract(projectId);
|
|
||||||
const modelTask = buildKeywordCleaningModelTask(projectId, market);
|
|
||||||
|
|
||||||
if (market.semanticRunId && output.semanticRunId && output.semanticRunId !== market.semanticRunId) {
|
|
||||||
throw new Error("Keyword cleaning output semanticRunId не совпадает с текущим market enrichment.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const alignedOutput: KeywordCleaningContract = {
|
|
||||||
...output,
|
|
||||||
items: 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,6 +103,30 @@ function getKeywordProfileText(item: Pick<KeywordCleaningItem, "clusterTitle" |
|
||||||
return normalizePhrase(`${item.phrase} ${item.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"]) {
|
function isReviewedOntology(version: MarketEnrichmentContract["projectOntologyVersion"]) {
|
||||||
return (
|
return (
|
||||||
Boolean(version) &&
|
Boolean(version) &&
|
||||||
|
|
@ -127,306 +151,46 @@ function buildBriefPhraseMap(contract: MarketEnrichmentContract) {
|
||||||
return phraseMap;
|
return phraseMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeywordWordCount(value: string) {
|
function getKeywordRole(
|
||||||
return normalizePhrase(value).split(/\s+/).filter(Boolean).length;
|
item: KeywordCleaningItem,
|
||||||
}
|
indexInTarget: number,
|
||||||
|
targetHasCorePhrase: boolean,
|
||||||
const KEYWORD_FACET_STOP_WORDS = new Set([
|
reviewedOntology: boolean
|
||||||
"a",
|
): KeywordMapRole {
|
||||||
"an",
|
if (reviewedOntology && isHybridSecondaryCandidate(item)) {
|
||||||
"and",
|
return "secondary_candidate";
|
||||||
"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 (/^(искусствен|интеллект)/i.test(normalizedToken)) {
|
if (reviewedOntology && isBroadArticleBacklogPhrase(item.phrase)) {
|
||||||
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";
|
return "validate";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.decision === "use" && indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) {
|
if (
|
||||||
return "primary";
|
!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 (isLongTailKeywordCandidate(item)) {
|
if (targetHasCorePhrase && isAiDifferentiatorCandidate(item)) {
|
||||||
return "support";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isClarifyingKeywordCandidate(item)) {
|
|
||||||
return "differentiator";
|
return "differentiator";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (indexInTarget <= 7 && (item.priority === "high" || item.priority === "medium") && item.decision !== "article") {
|
if (item.decision === "support") {
|
||||||
return "secondary";
|
return "support";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.priority === "high" || item.priority === "medium") {
|
if (indexInTarget === 0 && item.priority === "high") {
|
||||||
|
return "primary";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.priority === "high" || item.frequency !== null) {
|
||||||
return "secondary";
|
return "secondary";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -465,64 +229,36 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
|
||||||
|
|
||||||
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
||||||
const targetCounts = new Map<string, number>();
|
const targetCounts = new Map<string, number>();
|
||||||
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
const sourceItems = cleaning.items.filter((item) => item.decision !== "trash").slice(0, 120);
|
||||||
const sourceItems = cleaning.items
|
const targetHasCorePhrase = sourceItems.reduce<Map<string, boolean>>((accumulator, item) => {
|
||||||
.filter(
|
const rawTargetPath = item.targetPath;
|
||||||
(item) =>
|
const targetKey = rawTargetPath ?? item.clusterId;
|
||||||
item.decision !== "trash" &&
|
const canAnchorTarget =
|
||||||
item.evidenceStatus === "collected" &&
|
reviewedOntology &&
|
||||||
item.frequency !== null &&
|
item.decision !== "article" &&
|
||||||
Boolean(item.wordstatResultId)
|
item.decision !== "risky" &&
|
||||||
)
|
item.evidenceStatus === "collected" &&
|
||||||
.sort((left, right) => {
|
item.frequency !== null &&
|
||||||
const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
|
!isBroadArticleBacklogPhrase(item.phrase) &&
|
||||||
const rightRouteWeight = canModelRouteKeyword(right, semanticFacetGuard) ? 1 : 0;
|
!isHybridSecondaryCandidate(item) &&
|
||||||
|
!isAiDifferentiatorCandidate(item);
|
||||||
|
|
||||||
if (leftRouteWeight !== rightRouteWeight) {
|
if (canAnchorTarget) {
|
||||||
return rightRouteWeight - leftRouteWeight;
|
accumulator.set(targetKey, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
const leftDecisionWeight = getKeywordDecisionRouteWeight(left.decision);
|
return accumulator;
|
||||||
const rightDecisionWeight = getKeywordDecisionRouteWeight(right.decision);
|
}, new Map());
|
||||||
|
|
||||||
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) => {
|
return sourceItems.map((item) => {
|
||||||
const rawTargetPath = item.targetPath;
|
const rawTargetPath = item.targetPath;
|
||||||
const targetKey = rawTargetPath ?? item.clusterId;
|
const targetKey = rawTargetPath ?? item.clusterId;
|
||||||
const indexInTarget = targetCounts.get(targetKey) ?? 0;
|
const indexInTarget = targetCounts.get(targetKey) ?? 0;
|
||||||
const role = getKeywordRole(item, indexInTarget, semanticFacetGuard);
|
const role = getKeywordRole(item, indexInTarget, targetHasCorePhrase.get(targetKey) ?? false, reviewedOntology);
|
||||||
const targetPath = role === "secondary_candidate" ? null : rawTargetPath;
|
const targetPath = item.decision === "article" || role === "secondary_candidate" || isBroadArticleBacklogPhrase(item.phrase) ? null : rawTargetPath;
|
||||||
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null;
|
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null;
|
||||||
|
|
||||||
if (targetPath && role !== "validate") {
|
if (targetPath && role !== "validate" && role !== "differentiator" && item.decision !== "support") {
|
||||||
targetCounts.set(targetKey, indexInTarget + 1);
|
targetCounts.set(targetKey, indexInTarget + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -541,7 +277,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
|
||||||
frequencyGroup: item.frequencyGroup,
|
frequencyGroup: item.frequencyGroup,
|
||||||
projectOntologyVersionId: item.projectOntologyVersionId,
|
projectOntologyVersionId: item.projectOntologyVersionId,
|
||||||
wordstatResultId: item.wordstatResultId,
|
wordstatResultId: item.wordstatResultId,
|
||||||
reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology, semanticFacetGuard)
|
reason: getKeywordMapItemReason(item, role, targetPath, reviewedOntology)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -550,11 +286,10 @@ function getKeywordMapItemReason(
|
||||||
item: KeywordCleaningItem,
|
item: KeywordCleaningItem,
|
||||||
role: KeywordMapRole,
|
role: KeywordMapRole,
|
||||||
targetPath: string | null,
|
targetPath: string | null,
|
||||||
reviewedOntology: boolean,
|
reviewedOntology: boolean
|
||||||
semanticFacetGuard: SemanticFacetGuard
|
|
||||||
) {
|
) {
|
||||||
if (!reviewedOntology) {
|
if (!reviewedOntology) {
|
||||||
return "Модель предварительно распределила фразу, но фиксация ждёт reviewed ontology.";
|
return "Фразу нельзя закреплять без reviewed ontology.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "secondary_candidate") {
|
if (role === "secondary_candidate") {
|
||||||
|
|
@ -565,40 +300,16 @@ function getKeywordMapItemReason(
|
||||||
return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title.";
|
return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "primary") {
|
if (isBroadArticleBacklogPhrase(item.phrase)) {
|
||||||
return "Модель поставила фразу в посадочное ядро: это strongest exact-запрос для выбранной посадочной.";
|
return "Широкая head-фраза уходит в article/backlog: не делаем её прямой основной ставкой текущей посадочной без отдельного SERP page-type решения.";
|
||||||
}
|
|
||||||
|
|
||||||
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") {
|
if (item.decision === "article") {
|
||||||
return role === "validate"
|
return "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной.";
|
||||||
? "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной."
|
|
||||||
: "Модель предварительно распределила article/backlog-фразу; пользователь должен подтвердить, что она нужна текущей стратегии.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.decision === "risky") {
|
if (item.decision === "risky") {
|
||||||
return role === "validate"
|
return item.reason;
|
||||||
? item.reason
|
|
||||||
: `Модель предварительно распределила спорную фразу; проверь перед фиксацией. ${item.reason}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.evidenceStatus === "collected_no_signal") {
|
if (item.evidenceStatus === "collected_no_signal") {
|
||||||
|
|
@ -740,17 +451,44 @@ function normalizePersistedKeywordMapItem(input: {
|
||||||
targetPath: string | null;
|
targetPath: string | null;
|
||||||
workspaceSectionId: string | null;
|
workspaceSectionId: string | null;
|
||||||
}) {
|
}) {
|
||||||
|
let role = input.role;
|
||||||
|
let targetPath = input.targetPath;
|
||||||
|
let relatedLanding = input.relatedLanding;
|
||||||
|
let workspaceSectionId = input.workspaceSectionId;
|
||||||
|
let pageId = input.pageId;
|
||||||
|
|
||||||
|
if (role === "primary" && isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) {
|
||||||
|
role = "differentiator";
|
||||||
|
workspaceSectionId = getWorkspaceSectionIdForRole("differentiator");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBroadArticleBacklogPhrase(input.phrase)) {
|
||||||
|
role = "validate";
|
||||||
|
relatedLanding = relatedLanding ?? targetPath;
|
||||||
|
targetPath = null;
|
||||||
|
pageId = null;
|
||||||
|
workspaceSectionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasProductLikeHybridSignal(input.phrase) && !isAiDifferentiatorCandidate({ clusterTitle: "", phrase: input.phrase })) {
|
||||||
|
role = "secondary_candidate";
|
||||||
|
relatedLanding = relatedLanding ?? targetPath;
|
||||||
|
targetPath = null;
|
||||||
|
pageId = null;
|
||||||
|
workspaceSectionId = null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
approvedAt: input.approvedAt,
|
approvedAt: input.approvedAt,
|
||||||
decisionId: input.decisionId,
|
decisionId: input.decisionId,
|
||||||
mapItemId: input.mapItemId,
|
mapItemId: input.mapItemId,
|
||||||
pageId: input.pageId,
|
pageId,
|
||||||
phrase: input.phrase,
|
phrase: input.phrase,
|
||||||
relatedLanding: input.relatedLanding,
|
relatedLanding,
|
||||||
role: input.role,
|
role,
|
||||||
sourceItemId: input.sourceItemId,
|
sourceItemId: input.sourceItemId,
|
||||||
targetPath: input.targetPath,
|
targetPath,
|
||||||
workspaceSectionId: input.workspaceSectionId
|
workspaceSectionId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -793,7 +531,7 @@ async function getKeywordMapPersistenceSummary(
|
||||||
kmi.page_id,
|
kmi.page_id,
|
||||||
kd.updated_at as approved_at
|
kd.updated_at as approved_at
|
||||||
from keyword_decisions kd
|
from keyword_decisions kd
|
||||||
left join keyword_map_items kmi on kmi.keyword_decision_id = kd.id and kmi.project_id = kd.project_id and kmi.approved = true
|
left join keyword_map_items kmi on kmi.keyword_decision_id = kd.id and kmi.project_id = kd.project_id
|
||||||
where kd.project_id = $1
|
where kd.project_id = $1
|
||||||
and kd.semantic_run_id = $2
|
and kd.semantic_run_id = $2
|
||||||
and kd.project_ontology_version_id = $3
|
and kd.project_ontology_version_id = $3
|
||||||
|
|
@ -836,7 +574,7 @@ function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
|
||||||
|
|
||||||
if (contract.cleaning.keywordMapCandidateCount === 0) {
|
if (contract.cleaning.keywordMapCandidateCount === 0) {
|
||||||
actions.push(
|
actions.push(
|
||||||
"Keyword cleaning не дал exact-кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
|
"Keyword cleaning не пропустил ни одной use/support фразы: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
|
||||||
);
|
);
|
||||||
|
|
||||||
if (contract.readiness.marketEvidenceCount === 0) {
|
if (contract.readiness.marketEvidenceCount === 0) {
|
||||||
|
|
@ -1092,20 +830,6 @@ export async function approveKeywordMapDecisions(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
|
||||||
`
|
|
||||||
update keyword_map_items
|
|
||||||
set approved = false, updated_at = now()
|
|
||||||
where project_id = $1
|
|
||||||
and semantic_run_id = $2
|
|
||||||
and project_ontology_version_id = $3
|
|
||||||
and source_item_id = $4
|
|
||||||
and workspace_section_id <> $5
|
|
||||||
and approved = true;
|
|
||||||
`,
|
|
||||||
[projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id, item.id, workspaceSectionId]
|
|
||||||
);
|
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
insert into keyword_map_items (
|
insert into keyword_map_items (
|
||||||
|
|
|
||||||
|
|
@ -226,184 +226,6 @@ function normalizePhrase(value: string) {
|
||||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
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) {
|
function buildWordstatSeedMap(wordstat: WordstatEvidence) {
|
||||||
return new Map(wordstat.seedResults.map((result) => [normalizePhrase(result.phrase), result]));
|
return new Map(wordstat.seedResults.map((result) => [normalizePhrase(result.phrase), result]));
|
||||||
}
|
}
|
||||||
|
|
@ -449,18 +271,18 @@ function getSeedEvidenceStatus(
|
||||||
return "collected";
|
return "collected";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wordstatResult && wordstatResult.relatedCount > 0) {
|
if (wordstat.latestJob?.status === "failed") {
|
||||||
|
return "collection_failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordstat.latestJob?.status === "done" && wordstatResult && wordstatResult.relatedCount > 0) {
|
||||||
return "collected_related_only";
|
return "collected_related_only";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wordstatResult) {
|
if (wordstat.latestJob?.status === "done" && wordstatResult) {
|
||||||
return "collected_no_signal";
|
return "collected_no_signal";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wordstat.latestJob?.status === "failed") {
|
|
||||||
return "collection_failed";
|
|
||||||
}
|
|
||||||
|
|
||||||
return getEvidenceStatus(providers, ["wordstat"]);
|
return getEvidenceStatus(providers, ["wordstat"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -508,95 +330,6 @@ 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(
|
function buildBriefEvidence(
|
||||||
analysis: SemanticAnalysisRun,
|
analysis: SemanticAnalysisRun,
|
||||||
providers: MarketProviderDescriptor[],
|
providers: MarketProviderDescriptor[],
|
||||||
|
|
@ -740,7 +473,8 @@ export async function getMarketEnrichmentContract(projectId: string): Promise<Ma
|
||||||
const providers = await buildProviderRegistry();
|
const providers = await buildProviderRegistry();
|
||||||
const normalization = await getSeoNormalizationContract(projectId);
|
const normalization = await getSeoNormalizationContract(projectId);
|
||||||
const anchorReview = await getAnchorReviewContract(projectId);
|
const anchorReview = await getAnchorReviewContract(projectId);
|
||||||
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null);
|
const approvedWordstatPhrases = anchorReview.wordstatQueue.map((seed) => seed.phrase);
|
||||||
|
const wordstat = await getLatestWordstatEvidence(projectId, analysis?.runId ?? null, approvedWordstatPhrases);
|
||||||
const seedQueue = analysis ? buildSeedQueue(anchorReview, providers, wordstat, projectOntologyVersion) : [];
|
const seedQueue = analysis ? buildSeedQueue(anchorReview, providers, wordstat, projectOntologyVersion) : [];
|
||||||
const briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : [];
|
const briefEvidence = analysis ? buildBriefEvidence(analysis, providers, projectOntologyVersion) : [];
|
||||||
const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length;
|
const configuredProviderCount = providers.filter((provider) => provider.status === "connected").length;
|
||||||
|
|
@ -812,12 +546,6 @@ export async function runMarketEnrichment(projectId: string): Promise<MarketEnri
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await collectWordstatEvidence(projectId, analysis, normalizedSeeds);
|
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) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,6 @@ export type WordstatProviderStatus =
|
||||||
provider: "mcp_kv" | "yandex_api";
|
provider: "mcp_kv" | "yandex_api";
|
||||||
mode: string;
|
mode: string;
|
||||||
message: string;
|
message: string;
|
||||||
maxSeedsPerRun: number;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
connected: false;
|
connected: false;
|
||||||
|
|
@ -61,8 +60,6 @@ export interface WordstatProvider {
|
||||||
collect(request: WordstatCollectRequest): Promise<WordstatCollectResult>;
|
collect(request: WordstatCollectRequest): Promise<WordstatCollectResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const YANDEX_WORDSTAT_MIN_REQUEST_INTERVAL_MS = 125;
|
|
||||||
|
|
||||||
export class WordstatProviderUnavailableError extends Error {
|
export class WordstatProviderUnavailableError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|
@ -70,12 +67,6 @@ export class WordstatProviderUnavailableError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function delay(ms: number) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFrequencyGroup(frequency: number | null, explicitGroup?: unknown) {
|
function getFrequencyGroup(frequency: number | null, explicitGroup?: unknown) {
|
||||||
if (typeof explicitGroup === "string" && explicitGroup.trim()) {
|
if (typeof explicitGroup === "string" && explicitGroup.trim()) {
|
||||||
return explicitGroup.trim();
|
return explicitGroup.trim();
|
||||||
|
|
@ -255,7 +246,7 @@ async function postJson(endpoint: string, body: unknown, headers: Record<string,
|
||||||
}
|
}
|
||||||
|
|
||||||
class McpKvWordstatProvider implements WordstatProvider {
|
class McpKvWordstatProvider implements WordstatProvider {
|
||||||
constructor(private readonly endpoint: string | undefined, private readonly maxSeedsPerRun: number) {}
|
constructor(private readonly endpoint: string | undefined) {}
|
||||||
|
|
||||||
getStatus(): WordstatProviderStatus {
|
getStatus(): WordstatProviderStatus {
|
||||||
if (!this.endpoint) {
|
if (!this.endpoint) {
|
||||||
|
|
@ -271,8 +262,7 @@ class McpKvWordstatProvider implements WordstatProvider {
|
||||||
connected: true,
|
connected: true,
|
||||||
provider: "mcp_kv",
|
provider: "mcp_kv",
|
||||||
mode: "mcp_kv",
|
mode: "mcp_kv",
|
||||||
message: "Wordstat MCP-KV endpoint настроен на платформе.",
|
message: "Wordstat MCP-KV endpoint настроен на платформе."
|
||||||
maxSeedsPerRun: this.maxSeedsPerRun
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -310,8 +300,7 @@ class YandexApiWordstatProvider implements WordstatProvider {
|
||||||
connected: true,
|
connected: true,
|
||||||
provider: "yandex_api",
|
provider: "yandex_api",
|
||||||
mode: "yandex_search_api",
|
mode: "yandex_search_api",
|
||||||
message: "Yandex Wordstat Search API настроен на платформе.",
|
message: "Yandex Wordstat Search API настроен на платформе."
|
||||||
maxSeedsPerRun: this.config.maxSeedsPerRun
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -325,11 +314,7 @@ class YandexApiWordstatProvider implements WordstatProvider {
|
||||||
this.config.authType === "iam_token" ? `Bearer ${this.config.apiToken}` : `Api-Key ${this.config.apiToken}`;
|
this.config.authType === "iam_token" ? `Bearer ${this.config.apiToken}` : `Api-Key ${this.config.apiToken}`;
|
||||||
const seedResponses = [];
|
const seedResponses = [];
|
||||||
|
|
||||||
for (const [index, seed] of request.seeds.entries()) {
|
for (const seed of request.seeds) {
|
||||||
if (index > 0) {
|
|
||||||
await delay(YANDEX_WORDSTAT_MIN_REQUEST_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await postJson(
|
const payload = await postJson(
|
||||||
endpoint,
|
endpoint,
|
||||||
{
|
{
|
||||||
|
|
@ -374,7 +359,7 @@ export async function createWordstatProvider(): Promise<WordstatProvider> {
|
||||||
const config = await getWordstatRuntimeConfig();
|
const config = await getWordstatRuntimeConfig();
|
||||||
|
|
||||||
if (config.provider === "mcp_kv") {
|
if (config.provider === "mcp_kv") {
|
||||||
return new McpKvWordstatProvider(config.mcpEndpoint, config.maxSeedsPerRun);
|
return new McpKvWordstatProvider(config.mcpEndpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.provider === "yandex_api") {
|
if (config.provider === "yandex_api") {
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,6 @@ type WordstatResultRow = {
|
||||||
id: string;
|
id: string;
|
||||||
job_id: string;
|
job_id: string;
|
||||||
seed_id: string | null;
|
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;
|
phrase: string;
|
||||||
source_phrase: string | null;
|
source_phrase: string | null;
|
||||||
normalized_phrase: string | null;
|
normalized_phrase: string | null;
|
||||||
|
|
@ -64,33 +60,7 @@ type WordstatResultRow = {
|
||||||
created_at: Date;
|
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 = {
|
export type WordstatEvidence = {
|
||||||
quota: WordstatQuotaSnapshot | null;
|
|
||||||
latestJob: {
|
latestJob: {
|
||||||
id: string;
|
id: string;
|
||||||
status: WordstatJobRow["status"];
|
status: WordstatJobRow["status"];
|
||||||
|
|
@ -115,10 +85,6 @@ export type WordstatEvidence = {
|
||||||
frequencyGroup: string | null;
|
frequencyGroup: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
relatedCount: number;
|
relatedCount: number;
|
||||||
clusterId: string | null;
|
|
||||||
clusterTitle: string | null;
|
|
||||||
priority: "high" | "medium" | "low" | null;
|
|
||||||
seedSource: "detected" | "ontology" | string | null;
|
|
||||||
collectedAt: string;
|
collectedAt: string;
|
||||||
}>;
|
}>;
|
||||||
topResults: Array<{
|
topResults: Array<{
|
||||||
|
|
@ -128,10 +94,6 @@ export type WordstatEvidence = {
|
||||||
frequency: number | null;
|
frequency: number | null;
|
||||||
frequencyGroup: string | null;
|
frequencyGroup: string | null;
|
||||||
region: string | null;
|
region: string | null;
|
||||||
sourceClusterId: string | null;
|
|
||||||
sourceClusterTitle: string | null;
|
|
||||||
sourcePriority: "high" | "medium" | "low" | null;
|
|
||||||
sourceSeedSource: "detected" | "ontology" | string | null;
|
|
||||||
}>;
|
}>;
|
||||||
summary: {
|
summary: {
|
||||||
collectedSeedCount: number;
|
collectedSeedCount: number;
|
||||||
|
|
@ -420,34 +382,7 @@ function getRepresentativeTopResultRows(results: WordstatResultRow[]) {
|
||||||
return Array.from(selectedRows.values()).sort(compareWordstatResultRows);
|
return Array.from(selectedRows.values()).sort(compareWordstatResultRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWordstatResultIdentity(row: WordstatResultRow) {
|
function buildEvidence(job: WordstatJobRow | null, results: WordstatResultRow[]): WordstatEvidence {
|
||||||
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 seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
|
||||||
const relatedCounts = new Map<string, number>();
|
const relatedCounts = new Map<string, number>();
|
||||||
|
|
||||||
|
|
@ -458,7 +393,6 @@ function buildEvidence(
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quota,
|
|
||||||
latestJob: job ? mapJob(job) : null,
|
latestJob: job ? mapJob(job) : null,
|
||||||
seedResults: seedRows.map((result) => ({
|
seedResults: seedRows.map((result) => ({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
|
|
@ -469,10 +403,6 @@ function buildEvidence(
|
||||||
frequencyGroup: result.frequency_group,
|
frequencyGroup: result.frequency_group,
|
||||||
region: result.region,
|
region: result.region,
|
||||||
relatedCount: relatedCounts.get(result.phrase) ?? 0,
|
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()
|
collectedAt: result.created_at.toISOString()
|
||||||
})),
|
})),
|
||||||
topResults: getRepresentativeTopResultRows(results)
|
topResults: getRepresentativeTopResultRows(results)
|
||||||
|
|
@ -482,11 +412,7 @@ function buildEvidence(
|
||||||
sourcePhrase: result.source_phrase,
|
sourcePhrase: result.source_phrase,
|
||||||
frequency: result.frequency,
|
frequency: result.frequency,
|
||||||
frequencyGroup: result.frequency_group,
|
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: {
|
summary: {
|
||||||
collectedSeedCount: seedRows.length,
|
collectedSeedCount: seedRows.length,
|
||||||
|
|
@ -509,7 +435,7 @@ function buildEvidence(
|
||||||
async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun, seedCandidates: WordstatSeedCandidate[]) {
|
async function ensureSeedRows(projectId: string, analysis: SemanticAnalysisRun, seedCandidates: WordstatSeedCandidate[]) {
|
||||||
const seedRows: SeedRow[] = [];
|
const seedRows: SeedRow[] = [];
|
||||||
|
|
||||||
for (const seed of seedCandidates) {
|
for (const seed of seedCandidates.slice(0, 80)) {
|
||||||
const inserted = await pool.query<SeedRow>(
|
const inserted = await pool.query<SeedRow>(
|
||||||
`
|
`
|
||||||
insert into seeds (
|
insert into seeds (
|
||||||
|
|
@ -579,109 +505,6 @@ 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(
|
async function createJob(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
semanticRunId: string,
|
semanticRunId: string,
|
||||||
|
|
@ -870,25 +693,8 @@ export async function collectWordstatEvidence(
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
||||||
const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId);
|
const seeds = seedRows.map(toWordstatSeed);
|
||||||
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 region = "ru";
|
||||||
|
|
||||||
const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds);
|
const job = await createJob(projectId, analysis.runId, status.provider, status.mode, region, seeds);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
|
|
@ -920,7 +726,7 @@ export async function getLatestWordstatEvidence(
|
||||||
scopePhrases?: string[]
|
scopePhrases?: string[]
|
||||||
): Promise<WordstatEvidence> {
|
): Promise<WordstatEvidence> {
|
||||||
if (!semanticRunId) {
|
if (!semanticRunId) {
|
||||||
return buildEvidence(null, [], await getWordstatQuotaSnapshotForEvidence(null));
|
return buildEvidence(null, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobResult = await pool.query<WordstatJobRow>(
|
const jobResult = await pool.query<WordstatJobRow>(
|
||||||
|
|
@ -943,57 +749,45 @@ export async function getLatestWordstatEvidence(
|
||||||
updated_at
|
updated_at
|
||||||
from wordstat_jobs
|
from wordstat_jobs
|
||||||
where project_id = $1 and semantic_run_id = $2
|
where project_id = $1 and semantic_run_id = $2
|
||||||
order by
|
order by created_at desc
|
||||||
case when status = 'done' then 0 else 1 end,
|
|
||||||
created_at desc
|
|
||||||
limit 1;
|
limit 1;
|
||||||
`,
|
`,
|
||||||
[projectId, semanticRunId]
|
[projectId, semanticRunId]
|
||||||
);
|
);
|
||||||
const latestJob = jobResult.rows[0] ?? null;
|
const latestJob = jobResult.rows[0] ?? null;
|
||||||
const quota = await getWordstatQuotaSnapshotForEvidence(latestJob);
|
|
||||||
|
|
||||||
if (!latestJob) {
|
if (!latestJob) {
|
||||||
return buildEvidence(null, [], quota);
|
return buildEvidence(null, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await pool.query<WordstatResultRow>(
|
const results = await pool.query<WordstatResultRow>(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
wr.id,
|
id,
|
||||||
wr.job_id,
|
job_id,
|
||||||
wr.seed_id,
|
seed_id,
|
||||||
s.cluster_id as seed_cluster_id,
|
phrase,
|
||||||
s.cluster_title as seed_cluster_title,
|
source_phrase,
|
||||||
s.priority as seed_priority,
|
normalized_phrase,
|
||||||
s.source as seed_source,
|
frequency,
|
||||||
wr.phrase,
|
frequency_group,
|
||||||
wr.source_phrase,
|
region,
|
||||||
wr.normalized_phrase,
|
position,
|
||||||
wr.frequency,
|
raw,
|
||||||
wr.frequency_group,
|
created_at
|
||||||
wr.region,
|
from wordstat_results
|
||||||
wr.position,
|
where job_id = $1
|
||||||
wr.raw,
|
order by position asc, phrase asc;
|
||||||
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;
|
|
||||||
`,
|
`,
|
||||||
[projectId, semanticRunId]
|
[latestJob.id]
|
||||||
);
|
);
|
||||||
const latestUniqueResults = getLatestUniqueWordstatResults(results.rows);
|
|
||||||
|
|
||||||
if (!scopePhrases || scopePhrases.length === 0) {
|
if (!scopePhrases || scopePhrases.length === 0) {
|
||||||
return buildEvidence(latestJob, latestUniqueResults, quota);
|
return buildEvidence(latestJob, results.rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scopePhraseSet = new Set(scopePhrases.map(normalizePhrase));
|
const scopePhraseSet = new Set(scopePhrases.map(normalizePhrase));
|
||||||
const scopedResults = latestUniqueResults.filter((row) => {
|
const scopedResults = results.rows.filter((row) => {
|
||||||
const sourcePhrase = row.source_phrase ? normalizePhrase(row.source_phrase) : null;
|
const sourcePhrase = row.source_phrase ? normalizePhrase(row.source_phrase) : null;
|
||||||
const phrase = normalizePhrase(row.phrase);
|
const phrase = normalizePhrase(row.phrase);
|
||||||
|
|
||||||
|
|
@ -1005,5 +799,5 @@ export async function getLatestWordstatEvidence(
|
||||||
result_count: scopedResults.length
|
result_count: scopedResults.length
|
||||||
};
|
};
|
||||||
|
|
||||||
return buildEvidence(scopedJob, scopedResults, quota);
|
return buildEvidence(scopedJob, scopedResults);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -347,7 +347,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
"SERP interpretation работает только по сохранённому yandex-evidence.v1 и не придумывает конкурентов или спрос.",
|
"SERP interpretation работает только по сохранённому yandex-evidence.v1 и не придумывает конкурентов или спрос.",
|
||||||
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
|
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
|
||||||
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
|
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
|
||||||
"Keyword map предсортирует exact evidence в пять Stage 3 колонок; Неразобранные не участвуют, финальные роли утверждает пользователь.",
|
"Keyword map получает только keyword-cleaning use/support lanes; risky/trash не становятся approved decisions.",
|
||||||
"Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval."
|
"Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval."
|
||||||
],
|
],
|
||||||
nextActions: !analysis
|
nextActions: !analysis
|
||||||
|
|
@ -360,7 +360,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
||||||
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
|
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
|
||||||
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
|
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
|
||||||
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать exact-кандидаты в пять колонок для ручной правки.",
|
"После Wordstat прогонять seo.keyword_cleaning и только use/support передавать в keyword map.",
|
||||||
"Не запускать external market collection без anchor review approval."
|
"Не запускать external market collection без anchor review approval."
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
|
||||||
|
|
@ -1,479 +0,0 @@
|
||||||
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}.`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
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,5 +1,4 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js";
|
|
||||||
|
|
||||||
export const SEO_MODEL_TASK_TYPES = [
|
export const SEO_MODEL_TASK_TYPES = [
|
||||||
"seo.context_review",
|
"seo.context_review",
|
||||||
|
|
@ -142,7 +141,7 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
|
||||||
title: "Codex Workspace adapter",
|
title: "Codex Workspace adapter",
|
||||||
mode: "workspace_agent",
|
mode: "workspace_agent",
|
||||||
summary:
|
summary:
|
||||||
"Adapter через AI Workspace Assistant / AI Hub / remote Codex worker: модель получает только SEO task contract и возвращает structured JSON.",
|
"Будущий adapter для dev-mode/model-review: модель получает только SEO task contract и возвращает structured JSON.",
|
||||||
allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES,
|
allowedTaskTypes: ALL_SEO_MODEL_TASK_TYPES,
|
||||||
requiresSecrets: false,
|
requiresSecrets: false,
|
||||||
secretNames: [],
|
secretNames: [],
|
||||||
|
|
@ -158,11 +157,9 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
|
||||||
guardrails: [
|
guardrails: [
|
||||||
"Workspace agent не должен читать репозиторий напрямую для пользовательских сайтов.",
|
"Workspace agent не должен читать репозиторий напрямую для пользовательских сайтов.",
|
||||||
"Все filesystem/tool calls должны идти через approved MCP capabilities.",
|
"Все filesystem/tool calls должны идти через approved MCP capabilities.",
|
||||||
"SEO route требует явный contract-only workspace path и не использует executor workspace fallback.",
|
|
||||||
"Ответ должен проходить schema validation перед записью результата."
|
"Ответ должен проходить schema validation перед записью результата."
|
||||||
],
|
],
|
||||||
nextSetupStep:
|
nextSetupStep: "Описать handoff contract между AI Workspace Hub/MCP и seo_mode backend."
|
||||||
"Настроить SEO_AI_WORKSPACE_PROFILE, AI Workspace control URL/token, owner, contract-only workspace path and connected Codex executor."
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "openai_api",
|
id: "openai_api",
|
||||||
|
|
@ -242,38 +239,10 @@ const PROVIDERS: SeoModelProviderDescriptor[] = [
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
function buildProviderDescriptors() {
|
const PARSED_PROVIDERS = z.array(SeoModelProviderDescriptorSchema).parse(PROVIDERS);
|
||||||
const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus();
|
|
||||||
|
|
||||||
return PROVIDERS.map((provider) => {
|
|
||||||
if (provider.id !== "codex_workspace") {
|
|
||||||
return provider;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...provider,
|
|
||||||
status: aiWorkspaceStatus.configured ? "active" as const : "not_configured" as const,
|
|
||||||
summary: aiWorkspaceStatus.configured
|
|
||||||
? `${provider.summary} Configured profile: ${aiWorkspaceStatus.profile}.`
|
|
||||||
: `${provider.summary} Пока не настроен: ${aiWorkspaceStatus.errors[0] ?? provider.nextSetupStep}`,
|
|
||||||
guardrails: [
|
|
||||||
...provider.guardrails,
|
|
||||||
`AI Workspace profile: ${aiWorkspaceStatus.profile}.`,
|
|
||||||
"Remote Codex output is proposal/evidence only; it never bypasses human gates or rewrite/apply approval."
|
|
||||||
],
|
|
||||||
nextSetupStep: aiWorkspaceStatus.configured
|
|
||||||
? "Запустить seo model task через codex_workspace и дождаться structured JSON в model task evidence run."
|
|
||||||
: aiWorkspaceStatus.errors.join(" ") || provider.nextSetupStep
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getParsedProviders() {
|
|
||||||
return z.array(SeoModelProviderDescriptorSchema).parse(buildProviderDescriptors());
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDefaultSeoModelProviderId(): SeoModelProviderId {
|
export function getDefaultSeoModelProviderId(): SeoModelProviderId {
|
||||||
return getSeoAiWorkspaceConfigStatus().configured ? "codex_workspace" : "codex_manual";
|
return "codex_manual";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSeoModelProviderId(value: string): value is SeoModelProviderId {
|
export function isSeoModelProviderId(value: string): value is SeoModelProviderId {
|
||||||
|
|
@ -281,17 +250,14 @@ export function isSeoModelProviderId(value: string): value is SeoModelProviderId
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSeoModelProviderDescriptor(providerId: SeoModelProviderId): SeoModelProviderDescriptor {
|
export function getSeoModelProviderDescriptor(providerId: SeoModelProviderId): SeoModelProviderDescriptor {
|
||||||
const providers = getParsedProviders();
|
return PARSED_PROVIDERS.find((provider) => provider.id === providerId) ?? PARSED_PROVIDERS[0];
|
||||||
return providers.find((provider) => provider.id === providerId) ?? providers[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSeoModelProviderCatalog(): SeoModelProviderCatalog {
|
export function getSeoModelProviderCatalog(): SeoModelProviderCatalog {
|
||||||
const providers = getParsedProviders();
|
|
||||||
|
|
||||||
return SeoModelProviderCatalogSchema.parse({
|
return SeoModelProviderCatalogSchema.parse({
|
||||||
schemaVersion: "seo-model-provider-catalog.v1",
|
schemaVersion: "seo-model-provider-catalog.v1",
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
defaultProviderId: getDefaultSeoModelProviderId(),
|
defaultProviderId: getDefaultSeoModelProviderId(),
|
||||||
providers
|
providers: PARSED_PROVIDERS
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,5 @@
|
||||||
import { pool } from "../db/client.js";
|
import { pool } from "../db/client.js";
|
||||||
import { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js";
|
|
||||||
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
|
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
|
||||||
import { saveSeoNormalizationFromModelProvider, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
|
||||||
import { saveStrategyQualityReviewFromModelProvider, type StrategyQualityReviewContract } from "../strategy/strategyQualityReview.js";
|
|
||||||
import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js";
|
|
||||||
import {
|
|
||||||
buildAcceptedAiWorkspaceModelOutput,
|
|
||||||
buildSeoAiWorkspacePromotedResult,
|
|
||||||
buildSeoAiWorkspacePersistedResult,
|
|
||||||
dispatchSeoModelTaskToAiWorkspace,
|
|
||||||
fetchSeoAiWorkspaceCompletion,
|
|
||||||
validateSeoAiWorkspaceModelOutput,
|
|
||||||
type SeoAiWorkspaceDispatch,
|
|
||||||
type SeoAiWorkspaceModelOutput,
|
|
||||||
type SeoAiWorkspacePersistedResult
|
|
||||||
} from "./aiWorkspaceBridge.js";
|
|
||||||
import {
|
import {
|
||||||
getDefaultSeoModelProviderId,
|
getDefaultSeoModelProviderId,
|
||||||
getSeoModelProviderCatalog,
|
getSeoModelProviderCatalog,
|
||||||
|
|
@ -39,11 +24,6 @@ type SeoModelTaskRunRow = {
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DomainPromotionResult = {
|
|
||||||
persistedResult: SeoAiWorkspacePersistedResult;
|
|
||||||
nextAction: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SeoModelTaskRunInput = {
|
export type SeoModelTaskRunInput = {
|
||||||
providerId?: SeoModelProviderId;
|
providerId?: SeoModelProviderId;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
|
|
@ -80,8 +60,8 @@ export type SeoModelTaskRunOutput = {
|
||||||
inputTokenEstimate: number;
|
inputTokenEstimate: number;
|
||||||
outputTokenBudget: number;
|
outputTokenBudget: number;
|
||||||
};
|
};
|
||||||
modelOutput: SeoAiWorkspaceModelOutput | null;
|
modelOutput: null;
|
||||||
persistedResult: SeoAiWorkspacePersistedResult | null;
|
persistedResult: null;
|
||||||
nextActions: string[];
|
nextActions: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -158,7 +138,7 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.taskType === "seo.keyword_cleaning") {
|
if (task.taskType === "seo.keyword_cleaning") {
|
||||||
return 5200;
|
return 2400;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.taskType === "seo.serp_interpretation") {
|
if (task.taskType === "seo.serp_interpretation") {
|
||||||
|
|
@ -238,11 +218,7 @@ function validateTaskContract(task: SeoModelTaskContract | null, provider: SeoMo
|
||||||
warnings.push(`Provider ${provider.id} пока ${provider.status}; боевой model call не выполняется.`);
|
warnings.push(`Provider ${provider.id} пока ${provider.status}; боевой model call не выполняется.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.id === "codex_workspace" && provider.status === "active") {
|
notes.push("Dry-run проверяет только provider/task contract, а не качество будущего model output.");
|
||||||
notes.push("AI Workspace adapter отправляет sanitized task contract в remote Codex worker через Assistant/Hub route.");
|
|
||||||
} else {
|
|
||||||
notes.push("Dry-run проверяет только provider/task contract, а не качество будущего model output.");
|
|
||||||
}
|
|
||||||
notes.push("Результат модели должен сохраняться отдельным evidence run и проходить schema validation.");
|
notes.push("Результат модели должен сохраняться отдельным evidence run и проходить schema validation.");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -312,17 +288,11 @@ function buildRunOutput(
|
||||||
persistedResult: null,
|
persistedResult: null,
|
||||||
nextActions:
|
nextActions:
|
||||||
status === "contract_ready"
|
status === "contract_ready"
|
||||||
? provider.id === "codex_workspace"
|
? [
|
||||||
? [
|
"Подключить реальный model adapter к этому же task contract.",
|
||||||
"Отправить task contract в AI Workspace Assistant / AI Hub / remote Codex worker.",
|
"Сохранять model output как evidence run только после schema validation.",
|
||||||
"Дождаться structured JSON и проверить output schema.",
|
"Сравнивать model output с deterministic fallback перед human approval."
|
||||||
"Сохранить валидный output как model-task evidence run; доменное promotion остается backend-owned."
|
]
|
||||||
]
|
|
||||||
: [
|
|
||||||
"Подключить реальный model adapter к этому же task contract.",
|
|
||||||
"Сохранять model output как evidence run только после schema validation.",
|
|
||||||
"Сравнивать model output с deterministic fallback перед human approval."
|
|
||||||
]
|
|
||||||
: status === "provider_not_configured"
|
: status === "provider_not_configured"
|
||||||
? [provider.nextSetupStep, "Пока использовать contract_dry_run для проверки маршрута."]
|
? [provider.nextSetupStep, "Пока использовать contract_dry_run для проверки маршрута."]
|
||||||
: status === "task_not_found"
|
: status === "task_not_found"
|
||||||
|
|
@ -334,311 +304,20 @@ function buildRunOutput(
|
||||||
async function insertSeoModelTaskRun(
|
async function insertSeoModelTaskRun(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
input: Record<string, unknown>,
|
input: Record<string, unknown>,
|
||||||
output: SeoModelTaskRunOutput,
|
output: SeoModelTaskRunOutput
|
||||||
status: RunStatus = "done"
|
|
||||||
) {
|
) {
|
||||||
const isTerminal = status === "done" || status === "failed" || status === "cancelled";
|
|
||||||
const result = await pool.query<SeoModelTaskRunRow>(
|
const result = await pool.query<SeoModelTaskRunRow>(
|
||||||
`
|
`
|
||||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||||
values ($1, 'seo_model_task', $4::run_status, $2::jsonb, $3::jsonb, now(), ${isTerminal ? "now()" : "null"})
|
values ($1, 'seo_model_task', 'done', $2::jsonb, $3::jsonb, now(), now())
|
||||||
returning id, status, input, output, error_message, started_at, completed_at, created_at
|
returning id, status, input, output, error_message, started_at, completed_at, created_at
|
||||||
`,
|
`,
|
||||||
[projectId, JSON.stringify(input), JSON.stringify(output), status]
|
[projectId, JSON.stringify(input), JSON.stringify(output)]
|
||||||
);
|
);
|
||||||
|
|
||||||
return mapSeoModelTaskRun(result.rows[0]);
|
return mapSeoModelTaskRun(result.rows[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateSeoModelTaskRun(
|
|
||||||
runId: string,
|
|
||||||
patch: {
|
|
||||||
completed?: boolean;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
input?: Record<string, unknown>;
|
|
||||||
output?: SeoModelTaskRunOutput;
|
|
||||||
status?: RunStatus;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
const fields: string[] = [];
|
|
||||||
const values: unknown[] = [runId];
|
|
||||||
|
|
||||||
if (patch.status) {
|
|
||||||
values.push(patch.status);
|
|
||||||
fields.push(`status = $${values.length}::run_status`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (patch.input) {
|
|
||||||
values.push(JSON.stringify(patch.input));
|
|
||||||
fields.push(`input = $${values.length}::jsonb`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (patch.output) {
|
|
||||||
values.push(JSON.stringify(patch.output));
|
|
||||||
fields.push(`output = $${values.length}::jsonb`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.hasOwn(patch, "errorMessage")) {
|
|
||||||
values.push(patch.errorMessage ?? null);
|
|
||||||
fields.push(`error_message = $${values.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (patch.completed) {
|
|
||||||
fields.push("completed_at = now()");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.length === 0) {
|
|
||||||
const result = await pool.query<SeoModelTaskRunRow>(
|
|
||||||
`
|
|
||||||
select id, status, input, output, error_message, started_at, completed_at, created_at
|
|
||||||
from runs
|
|
||||||
where id = $1 and run_type = 'seo_model_task'
|
|
||||||
`,
|
|
||||||
[runId]
|
|
||||||
);
|
|
||||||
|
|
||||||
return mapSeoModelTaskRun(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query<SeoModelTaskRunRow>(
|
|
||||||
`
|
|
||||||
update runs
|
|
||||||
set ${fields.join(", ")}
|
|
||||||
where id = $1 and run_type = 'seo_model_task'
|
|
||||||
returning id, status, input, output, error_message, started_at, completed_at, created_at
|
|
||||||
`,
|
|
||||||
values
|
|
||||||
);
|
|
||||||
|
|
||||||
return mapSeoModelTaskRun(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRunAiWorkspaceDispatch(run: SeoModelTaskRun): SeoAiWorkspaceDispatch | null {
|
|
||||||
const fromOutput = isRecord(run.modelOutput) && isRecord(run.modelOutput.aiWorkspace)
|
|
||||||
? run.modelOutput.aiWorkspace
|
|
||||||
: null;
|
|
||||||
const fromInput = isRecord(run.input.aiWorkspace) ? run.input.aiWorkspace : null;
|
|
||||||
const source = fromOutput ?? fromInput;
|
|
||||||
|
|
||||||
if (!source) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const assistantThreadId = typeof source.assistantThreadId === "string" ? source.assistantThreadId : "";
|
|
||||||
const assistantMessageId = typeof source.assistantMessageId === "string" ? source.assistantMessageId : "";
|
|
||||||
const bridgeRequestId = typeof source.bridgeRequestId === "string" ? source.bridgeRequestId : "";
|
|
||||||
const contractWorkspacePath = typeof source.contractWorkspacePath === "string" ? source.contractWorkspacePath : "";
|
|
||||||
|
|
||||||
if (!assistantThreadId || !assistantMessageId || !bridgeRequestId || !contractWorkspacePath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
schemaVersion: "seo-ai-workspace-dispatch.v1",
|
|
||||||
profile: source.profile === "deploy-host" ||
|
|
||||||
source.profile === "deployed-hub" ||
|
|
||||||
source.profile === "tunnel-local-e2e" ||
|
|
||||||
source.profile === "local"
|
|
||||||
? source.profile
|
|
||||||
: "disabled",
|
|
||||||
assistantThreadId,
|
|
||||||
assistantMessageId,
|
|
||||||
bridgeRequestId,
|
|
||||||
bridgeMode: typeof source.bridgeMode === "string" ? source.bridgeMode : "hub",
|
|
||||||
accepted: source.accepted === true,
|
|
||||||
dispatchedAt: typeof source.dispatchedAt === "string" ? source.dispatchedAt : run.startedAt ?? run.createdAt,
|
|
||||||
selectedExecutorId: typeof source.selectedExecutorId === "string" ? source.selectedExecutorId : null,
|
|
||||||
contractWorkspacePath
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function promoteAiWorkspaceModelOutput(
|
|
||||||
projectId: string,
|
|
||||||
run: SeoModelTaskRun,
|
|
||||||
modelOutput: SeoAiWorkspaceModelOutput
|
|
||||||
): Promise<DomainPromotionResult | null> {
|
|
||||||
if (!modelOutput.validation?.ok || !modelOutput.parsedJson) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (run.task.taskType === "seo.normalization") {
|
|
||||||
await saveSeoNormalizationFromModelProvider(
|
|
||||||
projectId,
|
|
||||||
modelOutput.parsedJson as unknown as SeoNormalizationContract,
|
|
||||||
run.runId
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
nextAction: "SEO normalization обновлена из AI Workspace output; можно пересобирать anchor review/Wordstat queue.",
|
|
||||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
|
||||||
runType: "seo_normalization",
|
|
||||||
sourceModelTaskRunId: run.runId
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (run.task.taskType === "seo.keyword_cleaning") {
|
|
||||||
await saveKeywordCleaningFromModelProvider(
|
|
||||||
projectId,
|
|
||||||
modelOutput.parsedJson as unknown as KeywordCleaningContract,
|
|
||||||
run.runId
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
nextAction: "Keyword cleaning обновлён из AI Workspace output; Stage 3 keyword map должен пересобраться из новой очистки.",
|
|
||||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
|
||||||
runType: "keyword_cleaning",
|
|
||||||
sourceModelTaskRunId: run.runId
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (run.task.taskType === "seo.strategy_synthesis") {
|
|
||||||
await saveStrategySynthesisFromModelProvider(
|
|
||||||
projectId,
|
|
||||||
modelOutput.parsedJson as unknown as StrategySynthesisContract,
|
|
||||||
run.runId
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
nextAction: "Strategy synthesis обновлён из AI Workspace output; пользователь может проверять стратегию и quality gate.",
|
|
||||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
|
||||||
runType: "seo_strategy_synthesis",
|
|
||||||
sourceModelTaskRunId: run.runId
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (run.task.taskType === "seo.strategy_quality_review") {
|
|
||||||
await saveStrategyQualityReviewFromModelProvider(
|
|
||||||
projectId,
|
|
||||||
modelOutput.parsedJson as unknown as StrategyQualityReviewContract,
|
|
||||||
run.runId
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
nextAction: "Strategy quality review обновлён из AI Workspace output; блокеры и warnings доступны в strategy gate.",
|
|
||||||
persistedResult: buildSeoAiWorkspacePromotedResult({
|
|
||||||
runType: "seo_strategy_quality_review",
|
|
||||||
sourceModelTaskRunId: run.runId
|
|
||||||
})
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
nextAction: "Model output сохранен только в seo_model_task evidence; для этого task type доменное promotion пока не задано.",
|
|
||||||
persistedResult: buildSeoAiWorkspacePersistedResult(run.runId)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshRunningSeoModelTaskRun(row: SeoModelTaskRunRow) {
|
|
||||||
const run = mapSeoModelTaskRun(row);
|
|
||||||
|
|
||||||
if (!run || run.provider.id !== "codex_workspace" || run.runStatus !== "running") {
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dispatch = getRunAiWorkspaceDispatch(run);
|
|
||||||
|
|
||||||
if (!dispatch) {
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
||||||
const completion = await fetchSeoAiWorkspaceCompletion(dispatch);
|
|
||||||
|
|
||||||
if (completion.status === "pending") {
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completion.status === "failed") {
|
|
||||||
return updateSeoModelTaskRun(run.runId, {
|
|
||||||
completed: true,
|
|
||||||
errorMessage: completion.error,
|
|
||||||
status: "failed"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const modelOutput = validateSeoAiWorkspaceModelOutput({
|
|
||||||
dispatch,
|
|
||||||
outputSchemaVersion: run.task.outputSchemaVersion,
|
|
||||||
rawText: completion.rawText,
|
|
||||||
requiredTopLevelKeys: run.task.requiredTopLevelKeys
|
|
||||||
});
|
|
||||||
let promotion: DomainPromotionResult | null = null;
|
|
||||||
|
|
||||||
if (modelOutput.validation?.ok) {
|
|
||||||
try {
|
|
||||||
promotion = await promoteAiWorkspaceModelOutput(run.projectId, run, modelOutput);
|
|
||||||
} catch (error) {
|
|
||||||
modelOutput.validation = {
|
|
||||||
...modelOutput.validation,
|
|
||||||
ok: false,
|
|
||||||
errors: [
|
|
||||||
...modelOutput.validation.errors,
|
|
||||||
`Domain promotion failed: ${error instanceof Error ? error.message : "unknown_error"}`
|
|
||||||
]
|
|
||||||
};
|
|
||||||
modelOutput.status = "schema_invalid";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
runId: _runId,
|
|
||||||
runStatus: _runStatus,
|
|
||||||
input: _input,
|
|
||||||
startedAt: _startedAt,
|
|
||||||
completedAt: _completedAt,
|
|
||||||
errorMessage: _errorMessage,
|
|
||||||
createdAt: _createdAt,
|
|
||||||
...storedOutput
|
|
||||||
} = run;
|
|
||||||
const output: SeoModelTaskRunOutput = {
|
|
||||||
...storedOutput,
|
|
||||||
modelOutput,
|
|
||||||
persistedResult: modelOutput.validation?.ok ? promotion?.persistedResult ?? buildSeoAiWorkspacePersistedResult(run.runId) : null,
|
|
||||||
nextActions: modelOutput.validation?.ok
|
|
||||||
? [
|
|
||||||
promotion?.nextAction ?? "Model output сохранен в seo_model_task run как evidence.",
|
|
||||||
"Human gates остаются обязательными перед strategy/rewrite/apply."
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
"Исправить remote Codex output: он не прошел JSON/schema validation.",
|
|
||||||
"Повторить task через codex_workspace после корректировки prompt/contract.",
|
|
||||||
"Не продвигать этот output в strategy/rewrite."
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
return updateSeoModelTaskRun(run.runId, {
|
|
||||||
completed: true,
|
|
||||||
errorMessage: modelOutput.validation?.ok ? null : modelOutput.validation?.errors.join(" "),
|
|
||||||
output,
|
|
||||||
status: modelOutput.validation?.ok ? "done" : "failed"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshRunningSeoModelTaskRuns(projectId: string) {
|
|
||||||
const result = await pool.query<SeoModelTaskRunRow>(
|
|
||||||
`
|
|
||||||
select id, status, input, output, error_message, started_at, completed_at, created_at
|
|
||||||
from runs
|
|
||||||
where project_id = $1
|
|
||||||
and run_type = 'seo_model_task'
|
|
||||||
and status = 'running'
|
|
||||||
and input->>'providerId' = 'codex_workspace'
|
|
||||||
order by created_at desc
|
|
||||||
limit 5
|
|
||||||
`,
|
|
||||||
[projectId]
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of result.rows) {
|
|
||||||
try {
|
|
||||||
await refreshRunningSeoModelTaskRun(row);
|
|
||||||
} catch {
|
|
||||||
// Status reads should not fail when the external Assistant route is temporarily unavailable.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): Promise<SeoModelTaskRun[]> {
|
export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): Promise<SeoModelTaskRun[]> {
|
||||||
const result = await pool.query<SeoModelTaskRunRow>(
|
const result = await pool.query<SeoModelTaskRunRow>(
|
||||||
`
|
`
|
||||||
|
|
@ -657,7 +336,6 @@ export async function getLatestSeoModelTaskRuns(projectId: string, limit = 8): P
|
||||||
export async function getSeoModelProviderStatusContract(
|
export async function getSeoModelProviderStatusContract(
|
||||||
projectId: string
|
projectId: string
|
||||||
): Promise<SeoModelProviderStatusContract> {
|
): Promise<SeoModelProviderStatusContract> {
|
||||||
await refreshRunningSeoModelTaskRuns(projectId);
|
|
||||||
const [catalog, latestRuns] = await Promise.all([getSeoModelProviderCatalog(), getLatestSeoModelTaskRuns(projectId)]);
|
const [catalog, latestRuns] = await Promise.all([getSeoModelProviderCatalog(), getLatestSeoModelTaskRuns(projectId)]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -675,77 +353,15 @@ export async function runSeoModelTask(projectId: string, input: SeoModelTaskRunI
|
||||||
const context = await getSeoModelContextContract(projectId);
|
const context = await getSeoModelContextContract(projectId);
|
||||||
const task = findTask(context.modelTasks, input);
|
const task = findTask(context.modelTasks, input);
|
||||||
const output = buildRunOutput(projectId, provider, task, context.schemaVersion);
|
const output = buildRunOutput(projectId, provider, task, context.schemaVersion);
|
||||||
const baseInput = {
|
|
||||||
schemaVersion: "seo-model-task-run-input.v1",
|
|
||||||
providerId,
|
|
||||||
requestedTaskId: input.taskId ?? null,
|
|
||||||
requestedTaskType: input.taskType ?? null,
|
|
||||||
sourceContextGeneratedAt: context.generatedAt
|
|
||||||
};
|
|
||||||
|
|
||||||
if (provider.id === "codex_workspace" && output.status === "contract_ready" && task) {
|
|
||||||
const queuedRun = await insertSeoModelTaskRun(projectId, baseInput, output, "running");
|
|
||||||
|
|
||||||
if (!queuedRun) {
|
|
||||||
throw new Error("Не удалось сохранить seo model task run.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dispatch = await dispatchSeoModelTaskToAiWorkspace({
|
|
||||||
projectId,
|
|
||||||
runId: queuedRun.runId,
|
|
||||||
task
|
|
||||||
});
|
|
||||||
const dispatchedOutput: SeoModelTaskRunOutput = {
|
|
||||||
...output,
|
|
||||||
modelOutput: buildAcceptedAiWorkspaceModelOutput(dispatch),
|
|
||||||
nextActions: [
|
|
||||||
"AI Workspace request accepted; model output will be collected from Assistant thread messages.",
|
|
||||||
"Refresh model-provider status to pick up bridge_final/bridge_failure.",
|
|
||||||
"Structured JSON must pass schema validation before task-specific evidence promotion."
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const dispatchedRun = await updateSeoModelTaskRun(queuedRun.runId, {
|
|
||||||
input: {
|
|
||||||
...baseInput,
|
|
||||||
aiWorkspace: dispatch
|
|
||||||
},
|
|
||||||
output: dispatchedOutput,
|
|
||||||
status: "running"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!dispatchedRun) {
|
|
||||||
throw new Error("Не удалось обновить seo model task run после AI Workspace dispatch.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return dispatchedRun;
|
|
||||||
} catch (error) {
|
|
||||||
const failedOutput: SeoModelTaskRunOutput = {
|
|
||||||
...output,
|
|
||||||
nextActions: [
|
|
||||||
"Проверить SEO AI Workspace config/profile и выбранный Codex executor.",
|
|
||||||
"Повторить dispatch после восстановления Assistant/Hub/worker route.",
|
|
||||||
"Не использовать fallback на deployed product hosts или executor workspace по умолчанию."
|
|
||||||
]
|
|
||||||
};
|
|
||||||
const failedRun = await updateSeoModelTaskRun(queuedRun.runId, {
|
|
||||||
completed: true,
|
|
||||||
errorMessage: error instanceof Error ? error.message : "ai_workspace_dispatch_failed",
|
|
||||||
output: failedOutput,
|
|
||||||
status: "failed"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!failedRun) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return failedRun;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = await insertSeoModelTaskRun(
|
const run = await insertSeoModelTaskRun(
|
||||||
projectId,
|
projectId,
|
||||||
baseInput,
|
{
|
||||||
|
schemaVersion: "seo-model-task-run-input.v1",
|
||||||
|
providerId,
|
||||||
|
requestedTaskId: input.taskId ?? null,
|
||||||
|
requestedTaskType: input.taskType ?? null,
|
||||||
|
sourceContextGeneratedAt: context.generatedAt
|
||||||
|
},
|
||||||
output
|
output
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ type SeoIntentType =
|
||||||
type SeoMarketRole = "commercial" | "core" | "exclude" | "integration" | "problem" | "support";
|
type SeoMarketRole = "commercial" | "core" | "exclude" | "integration" | "problem" | "support";
|
||||||
type SeoReviewStatus = "auto_approved" | "needs_human" | "rejected";
|
type SeoReviewStatus = "auto_approved" | "needs_human" | "rejected";
|
||||||
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
||||||
type SeoNormalizationProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
type SeoNormalizationProviderMode = "codex_manual" | "deterministic_fallback";
|
||||||
|
|
||||||
type SeoNormalizationRunRow = {
|
type SeoNormalizationRunRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -87,18 +87,6 @@ export type SeoNormalizationModelTaskContract = {
|
||||||
queryExamples: string[];
|
queryExamples: string[];
|
||||||
evidenceLevel: "none" | "thin" | "moderate" | "strong";
|
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<{
|
sourceSeeds: Array<{
|
||||||
phrase: string;
|
phrase: string;
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
|
|
@ -112,7 +100,6 @@ export type SeoNormalizationModelTaskContract = {
|
||||||
| "normalize_market_queries"
|
| "normalize_market_queries"
|
||||||
| "reject_noise"
|
| "reject_noise"
|
||||||
| "mark_human_review"
|
| "mark_human_review"
|
||||||
| "expand_vertical_queries"
|
|
||||||
| "route_wordstat"
|
| "route_wordstat"
|
||||||
| "route_serp"
|
| "route_serp"
|
||||||
>;
|
>;
|
||||||
|
|
@ -220,9 +207,6 @@ export type SeoNormalizationContract = {
|
||||||
|
|
||||||
const GENERIC_SINGLE_WORDS = new Set([
|
const GENERIC_SINGLE_WORDS = new Set([
|
||||||
"ai",
|
"ai",
|
||||||
"ии",
|
|
||||||
"искусственный интеллект",
|
|
||||||
"engine",
|
|
||||||
"bpm",
|
"bpm",
|
||||||
"workflow",
|
"workflow",
|
||||||
"автоматизация",
|
"автоматизация",
|
||||||
|
|
@ -333,13 +317,12 @@ function normalizeSeoNormalizationOutput(
|
||||||
throw new Error("SEO normalization output должен иметь intentGroups, seedStrategy и rejectedSeeds.");
|
throw new Error("SEO normalization output должен иметь intentGroups, seedStrategy и rejectedSeeds.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const seedStrategy = output.seedStrategy.map(normalizeModelSeedReview);
|
const wordstatApprovedCount = output.seedStrategy.filter((seed) => seed.review.sendToWordstat).length;
|
||||||
const wordstatApprovedCount = seedStrategy.filter((seed) => seed.review.sendToWordstat).length;
|
const serpApprovedCount = output.seedStrategy.filter((seed) => seed.review.sendToSerp).length;
|
||||||
const serpApprovedCount = seedStrategy.filter((seed) => seed.review.sendToSerp).length;
|
const needsHumanReviewCount = output.seedStrategy.filter((seed) => seed.review.status === "needs_human").length;
|
||||||
const needsHumanReviewCount = seedStrategy.filter((seed) => seed.review.status === "needs_human").length;
|
const autoApprovedCount = output.seedStrategy.filter((seed) => seed.review.status === "auto_approved").length;
|
||||||
const autoApprovedCount = seedStrategy.filter((seed) => seed.review.status === "auto_approved").length;
|
|
||||||
const rejectedSeedCount =
|
const rejectedSeedCount =
|
||||||
output.rejectedSeeds.length + seedStrategy.filter((seed) => seed.review.status === "rejected").length;
|
output.rejectedSeeds.length + output.seedStrategy.filter((seed) => seed.review.status === "rejected").length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...output,
|
...output,
|
||||||
|
|
@ -356,45 +339,13 @@ function normalizeSeoNormalizationOutput(
|
||||||
autoApprovedCount,
|
autoApprovedCount,
|
||||||
intentGroupCount: output.intentGroups.length,
|
intentGroupCount: output.intentGroups.length,
|
||||||
needsHumanReviewCount,
|
needsHumanReviewCount,
|
||||||
normalizedSeedCount: seedStrategy.length,
|
normalizedSeedCount: output.seedStrategy.length,
|
||||||
rejectedSeedCount,
|
rejectedSeedCount,
|
||||||
serpApprovedCount,
|
serpApprovedCount,
|
||||||
wordstatApprovedCount,
|
wordstatApprovedCount,
|
||||||
wordstatQueryCount: wordstatApprovedCount
|
wordstatQueryCount: wordstatApprovedCount
|
||||||
},
|
},
|
||||||
seedStrategy,
|
state: output.seedStrategy.length > 0 ? "ready" : "not_ready"
|
||||||
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"
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -552,171 +503,8 @@ function collectClusterPhrases(
|
||||||
...(contextHint?.sourcePhrases ?? []),
|
...(contextHint?.sourcePhrases ?? []),
|
||||||
...(contextHint?.marketAngles ?? []).filter((angle) => wordCount(angle) <= 6)
|
...(contextHint?.marketAngles ?? []).filter((angle) => wordCount(angle) <= 6)
|
||||||
];
|
];
|
||||||
const expansionPhrases = buildClusterExpansionPhrases(cluster, contextHint);
|
|
||||||
|
|
||||||
return unique([
|
return unique([...seeds.map((seed) => seed.phrase), ...cluster.queryExamples, ...briefPhrases, ...safeHintPhrases]);
|
||||||
...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[]) {
|
function buildGroupQueries(phrases: string[], clusterTitle: string, siteTopTerms: string[], stopPhrases: string[]) {
|
||||||
|
|
@ -897,21 +685,6 @@ function buildModelTaskContract(
|
||||||
targetIntent: cluster.targetIntent,
|
targetIntent: cluster.targetIntent,
|
||||||
title: cluster.title
|
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) => ({
|
sourceSeeds: analysis.seedCandidates.slice(0, 80).map((seed) => ({
|
||||||
clusterId: seed.clusterId,
|
clusterId: seed.clusterId,
|
||||||
phrase: seed.phrase,
|
phrase: seed.phrase,
|
||||||
|
|
@ -930,7 +703,6 @@ function buildModelTaskContract(
|
||||||
"normalize_market_queries",
|
"normalize_market_queries",
|
||||||
"reject_noise",
|
"reject_noise",
|
||||||
"mark_human_review",
|
"mark_human_review",
|
||||||
"expand_vertical_queries",
|
|
||||||
"route_wordstat",
|
"route_wordstat",
|
||||||
"route_serp"
|
"route_serp"
|
||||||
],
|
],
|
||||||
|
|
@ -956,8 +728,6 @@ function buildModelTaskContract(
|
||||||
stopConditions: [
|
stopConditions: [
|
||||||
"Недостаточно source evidence для отделения бизнеса от технической оболочки сайта.",
|
"Недостаточно source evidence для отделения бизнеса от технической оболочки сайта.",
|
||||||
"Фраза выглядит как соседний рынок или business expansion без явного user approval.",
|
"Фраза выглядит как соседний рынок или business expansion без явного user approval.",
|
||||||
"Не отбрасывать source-grounded vertical opportunity: если точной рыночной формулировки нет, вернуть needs_human seed или expansion query, а не rejected.",
|
|
||||||
"Для каждой видимой продуктовой/отраслевой вертикали предложить 2-6 Wordstat-safe аналогов: коротких, рыночных, без внутренних labels.",
|
|
||||||
"Нельзя определить, отправлять ли фразу в Wordstat/SERP без ручного review."
|
"Нельзя определить, отправлять ли фразу в Wordstat/SERP без ручного review."
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
@ -1277,42 +1047,3 @@ export async function saveSeoNormalizationManual(
|
||||||
|
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveSeoNormalizationFromModelProvider(
|
|
||||||
projectId: string,
|
|
||||||
output: SeoNormalizationContract,
|
|
||||||
sourceModelTaskRunId: string
|
|
||||||
): Promise<SeoNormalizationContract> {
|
|
||||||
const normalizedOutput = normalizeSeoNormalizationOutput(
|
|
||||||
projectId,
|
|
||||||
output,
|
|
||||||
"codex_workspace",
|
|
||||||
"AI Workspace Codex provider выполнил seo.normalization по contract-only task; результат сохранён как evidence после backend validation."
|
|
||||||
);
|
|
||||||
const result = await pool.query<SeoNormalizationRunRow>(
|
|
||||||
`
|
|
||||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
|
||||||
values ($1, 'seo_normalization', 'done', $2::jsonb, $3::jsonb, now(), now())
|
|
||||||
returning id, status, input, output, error_message, started_at, completed_at, created_at;
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
projectId,
|
|
||||||
JSON.stringify({
|
|
||||||
providerMode: "codex_workspace",
|
|
||||||
schemaVersion: "seo-normalization-model-provider-input.v1",
|
|
||||||
semanticRunId: normalizedOutput.semanticRunId,
|
|
||||||
sourceModelTaskRunId,
|
|
||||||
sourceTaskId: normalizedOutput.sourceTaskId,
|
|
||||||
taskType: "seo.normalization"
|
|
||||||
}),
|
|
||||||
JSON.stringify(normalizedOutput)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
const run = result.rows[0] ? mapSeoNormalizationRun(result.rows[0]) : null;
|
|
||||||
|
|
||||||
if (!run) {
|
|
||||||
throw new Error("Не удалось сохранить AI Workspace SEO normalization.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { env } from "../config/env.js";
|
import { env } from "../config/env.js";
|
||||||
import { pool } from "../db/client.js";
|
import { pool } from "../db/client.js";
|
||||||
import { getSeoAiWorkspaceConfigStatus } from "../modelProvider/aiWorkspaceBridgeConfig.js";
|
|
||||||
|
|
||||||
export type ExternalServiceId =
|
export type ExternalServiceId =
|
||||||
| "seo_analytics_model"
|
|
||||||
| "yandex_cloud"
|
| "yandex_cloud"
|
||||||
| "yandex_metrica"
|
| "yandex_metrica"
|
||||||
| "yandex_search_serp"
|
| "yandex_search_serp"
|
||||||
|
|
@ -93,7 +91,6 @@ export type WordstatRuntimeConfig =
|
||||||
provider: "mcp_kv";
|
provider: "mcp_kv";
|
||||||
mode: "mcp_kv";
|
mode: "mcp_kv";
|
||||||
mcpEndpoint: string;
|
mcpEndpoint: string;
|
||||||
maxSeedsPerRun: number;
|
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
provider: "yandex_api";
|
provider: "yandex_api";
|
||||||
|
|
@ -105,7 +102,6 @@ export type WordstatRuntimeConfig =
|
||||||
regionIds: string[];
|
regionIds: string[];
|
||||||
devices: string[];
|
devices: string[];
|
||||||
numPhrases: number;
|
numPhrases: number;
|
||||||
maxSeedsPerRun: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const WORDSTAT_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/wordstat";
|
const WORDSTAT_BASE_URL = "https://searchapi.api.cloud.yandex.net/v2/wordstat";
|
||||||
|
|
@ -114,30 +110,6 @@ const WEBMASTER_BASE_URL = "https://api.webmaster.yandex.net/v4";
|
||||||
const METRICA_BASE_URL = "https://api-metrika.yandex.net";
|
const METRICA_BASE_URL = "https://api-metrika.yandex.net";
|
||||||
|
|
||||||
const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = {
|
const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> = {
|
||||||
seo_analytics_model: {
|
|
||||||
id: "seo_analytics_model",
|
|
||||||
label: "Аналитическая модель",
|
|
||||||
description:
|
|
||||||
"Model-provider для SEO task contracts через AI Workspace surface seo-mode. Сейчас доступен Codex bridge без доступа к Yandex credentials, исходникам и apply/write actions.",
|
|
||||||
implementationStatus: "active",
|
|
||||||
defaultMode: "local_codex",
|
|
||||||
modes: [
|
|
||||||
{ id: "disabled", label: "Отключено" },
|
|
||||||
{ id: "local_codex", label: "Codex bridge" }
|
|
||||||
],
|
|
||||||
docs: [],
|
|
||||||
publicFields: [
|
|
||||||
{
|
|
||||||
id: "executorId",
|
|
||||||
label: "AI Workspace executor",
|
|
||||||
type: "text",
|
|
||||||
required: false,
|
|
||||||
placeholder: "generated after setup command",
|
|
||||||
help: "Stored SEO-only executor id. Hidden in UI; used to avoid global Assistant selected-executor fallback."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
secretFields: []
|
|
||||||
},
|
|
||||||
yandex_cloud: {
|
yandex_cloud: {
|
||||||
id: "yandex_cloud",
|
id: "yandex_cloud",
|
||||||
label: "Yandex AI Studio / Cloud API key",
|
label: "Yandex AI Studio / Cloud API key",
|
||||||
|
|
@ -251,14 +223,6 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
|
||||||
placeholder: "50",
|
placeholder: "50",
|
||||||
help: "Сколько Wordstat rows запрашивать для одной seed-фразы."
|
help: "Сколько Wordstat rows запрашивать для одной seed-фразы."
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "maxSeedsPerRun",
|
|
||||||
label: "Max seeds per run",
|
|
||||||
type: "number",
|
|
||||||
required: false,
|
|
||||||
placeholder: "40",
|
|
||||||
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "mcpEndpoint",
|
id: "mcpEndpoint",
|
||||||
label: "MCP endpoint",
|
label: "MCP endpoint",
|
||||||
|
|
@ -422,7 +386,6 @@ function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) {
|
||||||
apiBaseUrl: WORDSTAT_BASE_URL,
|
apiBaseUrl: WORDSTAT_BASE_URL,
|
||||||
devices: "DEVICE_ALL",
|
devices: "DEVICE_ALL",
|
||||||
numPhrases: "50",
|
numPhrases: "50",
|
||||||
maxSeedsPerRun: "40",
|
|
||||||
regionIds: "225"
|
regionIds: "225"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -483,16 +446,6 @@ function getMissingFields(
|
||||||
|
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
|
|
||||||
if (definition.id === "seo_analytics_model") {
|
|
||||||
const aiWorkspaceStatus = getSeoAiWorkspaceConfigStatus();
|
|
||||||
|
|
||||||
if (!aiWorkspaceStatus.configured) {
|
|
||||||
return ["aiWorkspaceProfile"];
|
|
||||||
}
|
|
||||||
|
|
||||||
return publicConfig.executorId?.trim() ? [] : ["executorId"];
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const field of definition.publicFields) {
|
for (const field of definition.publicFields) {
|
||||||
if (field.id === "mcpEndpoint" && mode !== "mcp_kv") {
|
if (field.id === "mcpEndpoint" && mode !== "mcp_kv") {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -728,19 +681,6 @@ export async function updateExternalServiceSettings(
|
||||||
return mapRow(definition, row, dependencyStatuses);
|
return mapRow(definition, row, dependencyStatuses);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSeoAnalyticsModelRuntimeConfig() {
|
|
||||||
const rows = await getSettingsRows();
|
|
||||||
const row = rows.get("seo_analytics_model") ?? null;
|
|
||||||
const definition = serviceDefinitions.seo_analytics_model;
|
|
||||||
const publicConfig = getPublicConfig(definition, row);
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: Boolean(row?.enabled && row.mode === "local_codex"),
|
|
||||||
executorId: publicConfig.executorId?.trim() || null,
|
|
||||||
mode: row?.mode ?? "disabled"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function splitCsv(value: string | undefined, fallback: string[]) {
|
function splitCsv(value: string | undefined, fallback: string[]) {
|
||||||
const values = (value ?? "")
|
const values = (value ?? "")
|
||||||
.split(",")
|
.split(",")
|
||||||
|
|
@ -771,8 +711,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
||||||
return {
|
return {
|
||||||
provider: "mcp_kv",
|
provider: "mcp_kv",
|
||||||
mode: "mcp_kv",
|
mode: "mcp_kv",
|
||||||
mcpEndpoint: endpoint,
|
mcpEndpoint: endpoint
|
||||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -792,8 +731,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
||||||
folderId: cloudPublicConfig.folderId,
|
folderId: cloudPublicConfig.folderId,
|
||||||
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
|
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
|
||||||
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
|
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
|
||||||
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
|
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50)
|
||||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -801,8 +739,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
||||||
return {
|
return {
|
||||||
provider: "mcp_kv",
|
provider: "mcp_kv",
|
||||||
mode: "mcp_kv",
|
mode: "mcp_kv",
|
||||||
mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint,
|
mcpEndpoint: env.marketProviders.wordstat.mcpEndpoint
|
||||||
maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -821,8 +758,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
||||||
folderId: env.marketProviders.wordstat.folderId,
|
folderId: env.marketProviders.wordstat.folderId,
|
||||||
regionIds: env.marketProviders.wordstat.regionIds,
|
regionIds: env.marketProviders.wordstat.regionIds,
|
||||||
devices: env.marketProviders.wordstat.devices,
|
devices: env.marketProviders.wordstat.devices,
|
||||||
numPhrases: env.marketProviders.wordstat.numPhrases,
|
numPhrases: env.marketProviders.wordstat.numPhrases
|
||||||
maxSeedsPerRun: env.marketProviders.wordstat.maxSeedsPerRun
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,11 @@ import {
|
||||||
updateExternalServiceSettings,
|
updateExternalServiceSettings,
|
||||||
type ExternalServiceId
|
type ExternalServiceId
|
||||||
} from "./externalServiceSettings.js";
|
} from "./externalServiceSettings.js";
|
||||||
import {
|
|
||||||
createSeoAnalyticsModelSetupCommand,
|
|
||||||
probeSeoAnalyticsModel
|
|
||||||
} from "./seoAnalyticsModelBridge.js";
|
|
||||||
import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js";
|
import { runYandexAiStudioProbe } from "./yandexAiStudioProbe.js";
|
||||||
|
|
||||||
export const settingsRouter = Router();
|
export const settingsRouter = Router();
|
||||||
|
|
||||||
const externalServiceIdSchema = z.enum([
|
const externalServiceIdSchema = z.enum([
|
||||||
"seo_analytics_model",
|
|
||||||
"yandex_cloud",
|
"yandex_cloud",
|
||||||
"yandex_metrica",
|
"yandex_metrica",
|
||||||
"yandex_search_serp",
|
"yandex_search_serp",
|
||||||
|
|
@ -32,13 +27,6 @@ const updateExternalServiceSettingsSchema = z.object({
|
||||||
const yandexAiStudioProbeSchema = z.object({
|
const yandexAiStudioProbeSchema = z.object({
|
||||||
phrase: z.string().trim().min(2).max(160).optional()
|
phrase: z.string().trim().min(2).max(160).optional()
|
||||||
});
|
});
|
||||||
const seoAnalyticsModelSetupCommandSchema = z.object({
|
|
||||||
executorId: z.string().uuid().optional(),
|
|
||||||
port: z.coerce.number().int().positive().max(65535).optional()
|
|
||||||
});
|
|
||||||
const seoAnalyticsModelProbeSchema = z.object({
|
|
||||||
executorId: z.string().uuid().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
function sendError(response: Response, status: number, message: string) {
|
function sendError(response: Response, status: number, message: string) {
|
||||||
response.status(status).json({
|
response.status(status).json({
|
||||||
|
|
@ -77,42 +65,6 @@ settingsRouter.post("/external-services/yandex_cloud/probe", async (request, res
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
settingsRouter.post("/external-services/seo_analytics_model/setup-command", async (request, response) => {
|
|
||||||
try {
|
|
||||||
const input = seoAnalyticsModelSetupCommandSchema.parse(request.body ?? {});
|
|
||||||
|
|
||||||
response.status(201).json({
|
|
||||||
setupCommand: await createSeoAnalyticsModelSetupCommand(input)
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры setup command.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось подготовить Codex bridge setup command.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
settingsRouter.post("/external-services/seo_analytics_model/probe", async (request, response) => {
|
|
||||||
try {
|
|
||||||
const input = seoAnalyticsModelProbeSchema.parse(request.body ?? {});
|
|
||||||
|
|
||||||
response.json({
|
|
||||||
probe: await probeSeoAnalyticsModel(input)
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof z.ZodError) {
|
|
||||||
sendError(response, 400, error.issues[0]?.message ?? "Проверь параметры probe.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(error);
|
|
||||||
sendError(response, 500, error instanceof Error ? error.message : "Не удалось проверить Codex bridge.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
settingsRouter.patch("/external-services/:serviceId", async (request, response) => {
|
settingsRouter.patch("/external-services/:serviceId", async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId;
|
const serviceId = externalServiceIdSchema.parse(request.params.serviceId) as ExternalServiceId;
|
||||||
|
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
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,10 +207,6 @@ function normalizeKey(value: string | null) {
|
||||||
return value?.trim() || "unmapped";
|
return value?.trim() || "unmapped";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePhraseKey(value: string) {
|
|
||||||
return value.trim().replace(/\s+/g, " ").toLocaleLowerCase("ru-RU");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConfidence(score: number): SeoStrategyConfidence {
|
function getConfidence(score: number): SeoStrategyConfidence {
|
||||||
if (score >= 75) {
|
if (score >= 75) {
|
||||||
return "high";
|
return "high";
|
||||||
|
|
@ -307,7 +303,6 @@ function getOpportunityTiming(
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSiteMaturity(input: {
|
function buildSiteMaturity(input: {
|
||||||
activeApprovedKeywordCount: number;
|
|
||||||
keywordMap: KeywordMapContract;
|
keywordMap: KeywordMapContract;
|
||||||
market: MarketEnrichmentContract;
|
market: MarketEnrichmentContract;
|
||||||
serpInterpretation: SerpInterpretationContract;
|
serpInterpretation: SerpInterpretationContract;
|
||||||
|
|
@ -356,7 +351,7 @@ function buildSiteMaturity(input: {
|
||||||
: "Нет данных Webmaster/Метрики/Search Console-style: рынок виден, но сам сайт ещё не доказал индексацию и первые сигналы.",
|
: "Нет данных Webmaster/Метрики/Search Console-style: рынок виден, но сам сайт ещё не доказал индексацию и первые сигналы.",
|
||||||
schemaVersion: "site-maturity.v1",
|
schemaVersion: "site-maturity.v1",
|
||||||
signals: {
|
signals: {
|
||||||
approvedKeywordCount: input.activeApprovedKeywordCount,
|
approvedKeywordCount: input.keywordMap.persisted.approvedDecisionCount,
|
||||||
hasAuthorityEvidence,
|
hasAuthorityEvidence,
|
||||||
hasIndexEvidence,
|
hasIndexEvidence,
|
||||||
hasPerformanceEvidence,
|
hasPerformanceEvidence,
|
||||||
|
|
@ -814,11 +809,9 @@ function buildDemandEvidence(
|
||||||
cleaning: KeywordCleaningContract,
|
cleaning: KeywordCleaningContract,
|
||||||
keywordMap: KeywordMapContract
|
keywordMap: KeywordMapContract
|
||||||
): SeoStrategyContract["evidence"]["demand"] {
|
): SeoStrategyContract["evidence"]["demand"] {
|
||||||
const persistedPhraseSet = new Set(
|
const persistedPhraseSet = new Set(keywordMap.persisted.items.map((item) => item.phrase.toLocaleLowerCase("ru-RU")));
|
||||||
getActiveStrategyKeywordMapItems(cleaning, keywordMap).map((item) => normalizePhraseKey(item.phrase))
|
|
||||||
);
|
|
||||||
const topSignals = cleaning.items
|
const topSignals = cleaning.items
|
||||||
.filter((item) => persistedPhraseSet.has(normalizePhraseKey(item.phrase)) && item.frequency !== null)
|
.filter((item) => persistedPhraseSet.has(item.phrase.toLocaleLowerCase("ru-RU")) && item.frequency !== null)
|
||||||
.sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))
|
.sort((left, right) => (right.frequency ?? 0) - (left.frequency ?? 0))
|
||||||
.slice(0, 8)
|
.slice(0, 8)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
|
|
@ -875,7 +868,7 @@ function getCleaningItemByPhrase(cleaning: KeywordCleaningContract) {
|
||||||
const itemsByPhrase = new Map<string, KeywordCleaningContract["items"][number]>();
|
const itemsByPhrase = new Map<string, KeywordCleaningContract["items"][number]>();
|
||||||
|
|
||||||
for (const item of cleaning.items) {
|
for (const item of cleaning.items) {
|
||||||
const key = normalizePhraseKey(item.phrase);
|
const key = item.phrase.toLocaleLowerCase("ru-RU");
|
||||||
const currentItem = itemsByPhrase.get(key);
|
const currentItem = itemsByPhrase.get(key);
|
||||||
|
|
||||||
if (!currentItem || (item.frequency ?? 0) > (currentItem.frequency ?? 0)) {
|
if (!currentItem || (item.frequency ?? 0) > (currentItem.frequency ?? 0)) {
|
||||||
|
|
@ -886,30 +879,6 @@ function getCleaningItemByPhrase(cleaning: KeywordCleaningContract) {
|
||||||
return itemsByPhrase;
|
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(
|
function buildOpportunities(
|
||||||
cleaning: KeywordCleaningContract,
|
cleaning: KeywordCleaningContract,
|
||||||
keywordMap: KeywordMapContract,
|
keywordMap: KeywordMapContract,
|
||||||
|
|
@ -918,7 +887,7 @@ function buildOpportunities(
|
||||||
const cleaningByPhrase = getCleaningItemByPhrase(cleaning);
|
const cleaningByPhrase = getCleaningItemByPhrase(cleaning);
|
||||||
const groups = new Map<string, KeywordMapPersistedItem[]>();
|
const groups = new Map<string, KeywordMapPersistedItem[]>();
|
||||||
|
|
||||||
for (const item of getActiveStrategyKeywordMapItems(cleaning, keywordMap)) {
|
for (const item of keywordMap.persisted.items) {
|
||||||
const key = normalizeKey(item.targetPath);
|
const key = normalizeKey(item.targetPath);
|
||||||
const group = groups.get(key) ?? [];
|
const group = groups.get(key) ?? [];
|
||||||
|
|
||||||
|
|
@ -983,24 +952,15 @@ function buildOpportunities(
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSerpOpportunities(
|
function buildSerpOpportunities(
|
||||||
cleaning: KeywordCleaningContract,
|
|
||||||
serpInterpretation: SerpInterpretationContract,
|
serpInterpretation: SerpInterpretationContract,
|
||||||
keywordMap: KeywordMapContract,
|
|
||||||
siteMaturity: SeoStrategyContract["siteMaturity"]
|
siteMaturity: SeoStrategyContract["siteMaturity"]
|
||||||
): SeoStrategyContract["opportunities"] {
|
): SeoStrategyContract["opportunities"] {
|
||||||
if (serpInterpretation.state !== "ready") {
|
if (serpInterpretation.state !== "ready") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const approvedPhraseSet = new Set(getActiveStrategyKeywordMapItems(cleaning, keywordMap).map((item) => normalizePhraseKey(item.phrase)));
|
|
||||||
|
|
||||||
return serpInterpretation.phraseInterpretations
|
return serpInterpretation.phraseInterpretations
|
||||||
.filter(
|
.filter((phrase) => phrase.confidence !== "low" && phrase.intent !== "unknown")
|
||||||
(phrase) =>
|
|
||||||
phrase.confidence !== "low" &&
|
|
||||||
phrase.intent !== "unknown" &&
|
|
||||||
approvedPhraseSet.has(normalizePhraseKey(phrase.phrase))
|
|
||||||
)
|
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map((phrase) => {
|
.map((phrase) => {
|
||||||
const isMismatch = phrase.targetFit === "mismatch";
|
const isMismatch = phrase.targetFit === "mismatch";
|
||||||
|
|
@ -1100,7 +1060,6 @@ function buildMissingEvidence(
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRisks(input: {
|
function buildRisks(input: {
|
||||||
activeApprovedKeywordCount: number;
|
|
||||||
cleaning: KeywordCleaningContract;
|
cleaning: KeywordCleaningContract;
|
||||||
contextReview: Awaited<ReturnType<typeof getLatestSeoContextReview>>;
|
contextReview: Awaited<ReturnType<typeof getLatestSeoContextReview>>;
|
||||||
keywordMap: KeywordMapContract;
|
keywordMap: KeywordMapContract;
|
||||||
|
|
@ -1133,13 +1092,13 @@ function buildRisks(input: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.activeApprovedKeywordCount === 0) {
|
if (input.keywordMap.persisted.approvedDecisionCount === 0) {
|
||||||
risks.push({
|
risks.push({
|
||||||
id: "risk:no_approved_keywords",
|
id: "risk:no_approved_keywords",
|
||||||
level: "critical",
|
level: "critical",
|
||||||
message: "Нет активных подтверждённых решений по фразам: сохранённые ранее решения устарели или не проходят текущий semantic/evidence guard.",
|
message: "Нет подтверждённых решений по фразам, поэтому стратегия не имеет опорной карты спроса.",
|
||||||
mitigation: "Сначала сохранить актуальные решения карты фраз, подтверждённые текущим cleaning и Stage 3.",
|
mitigation: "Сначала сохранить решения карты фраз, подтверждённые доказательствами.",
|
||||||
title: "Нет актуальной карты решений"
|
title: "Нет утверждённой карты решений"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1268,7 +1227,6 @@ function buildStrategyOptions(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStrategyScore(input: {
|
function getStrategyScore(input: {
|
||||||
activeApprovedKeywordCount: number;
|
|
||||||
contextReady: boolean;
|
contextReady: boolean;
|
||||||
keywordMap: KeywordMapContract;
|
keywordMap: KeywordMapContract;
|
||||||
market: MarketEnrichmentContract;
|
market: MarketEnrichmentContract;
|
||||||
|
|
@ -1283,7 +1241,7 @@ function getStrategyScore(input: {
|
||||||
score += 20;
|
score += 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.activeApprovedKeywordCount > 0) {
|
if (input.keywordMap.persisted.approvedDecisionCount > 0) {
|
||||||
score += 20;
|
score += 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1312,12 +1270,8 @@ function getStrategyScore(input: {
|
||||||
return Math.max(0, Math.min(100, score));
|
return Math.max(0, Math.min(100, score));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getState(
|
function getState(score: number, keywordMap: KeywordMapContract, missingEvidence: SeoStrategyContract["missingEvidence"]): SeoStrategyState {
|
||||||
score: number,
|
if (keywordMap.persisted.approvedDecisionCount === 0) {
|
||||||
activeApprovedKeywordCount: number,
|
|
||||||
missingEvidence: SeoStrategyContract["missingEvidence"]
|
|
||||||
): SeoStrategyState {
|
|
||||||
if (activeApprovedKeywordCount === 0) {
|
|
||||||
return "blocked";
|
return "blocked";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1377,24 +1331,15 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
||||||
: getEmptySerpInterpretationContract(projectId);
|
: getEmptySerpInterpretationContract(projectId);
|
||||||
const context = buildContextEvidence(contextReview);
|
const context = buildContextEvidence(contextReview);
|
||||||
const contextReady = context.status === "ready";
|
const contextReady = context.status === "ready";
|
||||||
const activeStrategyKeywordMapItems = getActiveStrategyKeywordMapItems(cleaning, keywordMap);
|
|
||||||
const activeApprovedKeywordCount = activeStrategyKeywordMapItems.length;
|
|
||||||
const demand = buildDemandEvidence(market, cleaning, keywordMap);
|
const demand = buildDemandEvidence(market, cleaning, keywordMap);
|
||||||
const siteMaturity = buildSiteMaturity({
|
const siteMaturity = buildSiteMaturity({ keywordMap, market, serpInterpretation, yandexEvidence });
|
||||||
activeApprovedKeywordCount,
|
|
||||||
keywordMap,
|
|
||||||
market,
|
|
||||||
serpInterpretation,
|
|
||||||
yandexEvidence
|
|
||||||
});
|
|
||||||
const phasePlan = buildPhasePlan(siteMaturity);
|
const phasePlan = buildPhasePlan(siteMaturity);
|
||||||
const opportunities = [
|
const opportunities = [
|
||||||
...buildOpportunities(cleaning, keywordMap, siteMaturity),
|
...buildOpportunities(cleaning, keywordMap, siteMaturity),
|
||||||
...buildSerpOpportunities(cleaning, serpInterpretation, keywordMap, siteMaturity)
|
...buildSerpOpportunities(serpInterpretation, siteMaturity)
|
||||||
];
|
];
|
||||||
const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation);
|
const missingEvidence = buildMissingEvidence(market, contextReview, yandexEvidence, serpInterpretation);
|
||||||
const risks = buildRisks({
|
const risks = buildRisks({
|
||||||
activeApprovedKeywordCount,
|
|
||||||
cleaning,
|
cleaning,
|
||||||
contextReview,
|
contextReview,
|
||||||
keywordMap,
|
keywordMap,
|
||||||
|
|
@ -1413,7 +1358,6 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
||||||
siteMaturity
|
siteMaturity
|
||||||
});
|
});
|
||||||
const strategyConfidenceScore = getStrategyScore({
|
const strategyConfidenceScore = getStrategyScore({
|
||||||
activeApprovedKeywordCount,
|
|
||||||
contextReady,
|
contextReady,
|
||||||
keywordMap,
|
keywordMap,
|
||||||
market,
|
market,
|
||||||
|
|
@ -1432,7 +1376,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
||||||
semanticRunId: market.semanticRunId,
|
semanticRunId: market.semanticRunId,
|
||||||
projectOntologyVersion: market.projectOntologyVersion,
|
projectOntologyVersion: market.projectOntologyVersion,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
state: getState(strategyConfidenceScore, activeApprovedKeywordCount, missingEvidence),
|
state: getState(strategyConfidenceScore, keywordMap, missingEvidence),
|
||||||
verdict: {
|
verdict: {
|
||||||
confidence: getConfidence(strategyConfidenceScore),
|
confidence: getConfidence(strategyConfidenceScore),
|
||||||
primaryConstraint:
|
primaryConstraint:
|
||||||
|
|
@ -1449,7 +1393,7 @@ export async function getSeoStrategyContract(projectId: string): Promise<SeoStra
|
||||||
: "Стратегия пока не доказана"
|
: "Стратегия пока не доказана"
|
||||||
},
|
},
|
||||||
readiness: {
|
readiness: {
|
||||||
approvedKeywordCount: activeApprovedKeywordCount,
|
approvedKeywordCount: keywordMap.persisted.approvedDecisionCount,
|
||||||
contextReady,
|
contextReady,
|
||||||
evidenceGapCount: missingEvidence.length,
|
evidenceGapCount: missingEvidence.length,
|
||||||
externalProviderConnectedCount,
|
externalProviderConnectedCount,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { getLatestStrategySynthesisContract, type StrategySynthesisContract } fr
|
||||||
import { buildStrategyQualityReviewModelTask } from "./strategyQualityReviewTask.js";
|
import { buildStrategyQualityReviewModelTask } from "./strategyQualityReviewTask.js";
|
||||||
|
|
||||||
type RunStatus = "cancelled" | "done" | "failed" | "queued" | "running";
|
type RunStatus = "cancelled" | "done" | "failed" | "queued" | "running";
|
||||||
type StrategyQualityReviewProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
type StrategyQualityReviewProviderMode = "codex_manual" | "deterministic_fallback";
|
||||||
type StrategyQualityReviewState = "not_ready" | "ready";
|
type StrategyQualityReviewState = "not_ready" | "ready";
|
||||||
type StrategyQualityReviewVerdictStatus = "approved" | "blocked" | "needs_changes";
|
type StrategyQualityReviewVerdictStatus = "approved" | "blocked" | "needs_changes";
|
||||||
type StrategyQualityReviewCheckStatus = "fail" | "pass" | "warning";
|
type StrategyQualityReviewCheckStatus = "fail" | "pass" | "warning";
|
||||||
|
|
@ -112,161 +112,6 @@ function getDefaultAnalystReview(): StrategyQualityReviewContract["analystReview
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isVerdictStatus(value: unknown): value is StrategyQualityReviewVerdictStatus {
|
|
||||||
return value === "approved" || value === "blocked" || value === "needs_changes";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isConfidence(value: unknown): value is StrategyQualityReviewContract["verdict"]["confidence"] {
|
|
||||||
return value === "high" || value === "medium" || value === "low";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCheckStatus(value: unknown): StrategyQualityReviewCheckStatus {
|
|
||||||
if (value === "pass" || value === "passed") return "pass";
|
|
||||||
if (value === "fail" || value === "failed") return "fail";
|
|
||||||
if (value === "warning" || value === "warn") return "warning";
|
|
||||||
return "warning";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCheckSeverity(value: unknown): StrategyQualityReviewSeverity {
|
|
||||||
if (value === "critical" || value === "high" || value === "medium" || value === "low") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "low";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCheckCategory(value: unknown): StrategyQualityReviewCategory {
|
|
||||||
const category = String(value || "").trim();
|
|
||||||
|
|
||||||
if (
|
|
||||||
category === "business_drift" ||
|
|
||||||
category === "evidence_integrity" ||
|
|
||||||
category === "hallucination_guardrail" ||
|
|
||||||
category === "phase_guardrail" ||
|
|
||||||
category === "source_safety" ||
|
|
||||||
category === "ux_clarity"
|
|
||||||
) {
|
|
||||||
return category;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (category === "business_scope") return "business_drift";
|
|
||||||
if (category === "evidence" || category === "evidence_gap") return "evidence_integrity";
|
|
||||||
if (category === "phase" || category === "phase_violation") return "phase_guardrail";
|
|
||||||
if (category === "guardrail" || category === "intent") return "hallucination_guardrail";
|
|
||||||
if (category === "input_state") return "ux_clarity";
|
|
||||||
|
|
||||||
return "source_safety";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeModelProviderChecks(value: unknown, fallback: StrategyQualityReviewContract["checks"]) {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value.map((item, index) => {
|
|
||||||
const record = (item && typeof item === "object" ? item : {}) as Record<string, unknown>;
|
|
||||||
|
|
||||||
return makeCheck({
|
|
||||||
action: typeof record.action === "string" && record.action.trim() ? record.action.trim() : "Проверить пункт quality gate вручную.",
|
|
||||||
category: normalizeCheckCategory(record.category),
|
|
||||||
evidenceRefs: Array.isArray(record.evidenceRefs) ? record.evidenceRefs.map((ref) => String(ref || "").trim()).filter(Boolean) : [],
|
|
||||||
id: typeof record.id === "string" && record.id.trim() ? record.id.trim() : `model-check:${index + 1}`,
|
|
||||||
message: typeof record.message === "string" && record.message.trim() ? record.message.trim() : "Модель вернула проверку без описания.",
|
|
||||||
severity: normalizeCheckSeverity(record.severity),
|
|
||||||
status: normalizeCheckStatus(record.status),
|
|
||||||
title: typeof record.title === "string" && record.title.trim() ? record.title.trim() : `Проверка ${index + 1}`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringList(value: unknown, fallback: string[]) {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = value.map((item) => {
|
|
||||||
if (typeof item === "string") {
|
|
||||||
return item.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item && typeof item === "object") {
|
|
||||||
const record = item as Record<string, unknown>;
|
|
||||||
|
|
||||||
for (const key of ["title", "message", "action", "summary", "nextStep", "label"]) {
|
|
||||||
const text = record[key];
|
|
||||||
|
|
||||||
if (typeof text === "string" && text.trim()) {
|
|
||||||
return text.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(item || "").trim();
|
|
||||||
}).filter(Boolean);
|
|
||||||
|
|
||||||
return result.length > 0 ? result : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeModelProviderStrategyQualityReviewOutput(
|
|
||||||
output: StrategyQualityReviewContract,
|
|
||||||
fallback: StrategyQualityReviewContract
|
|
||||||
): StrategyQualityReviewContract {
|
|
||||||
const rawOutput = output as StrategyQualityReviewContract & Record<string, any>;
|
|
||||||
const rawVerdict = rawOutput.verdict;
|
|
||||||
const verdict = (rawVerdict && typeof rawVerdict === "object" ? rawVerdict : {}) as Record<string, any>;
|
|
||||||
const status = isVerdictStatus(rawVerdict) ? rawVerdict : isVerdictStatus(verdict.status) ? verdict.status : fallback.verdict.status;
|
|
||||||
const blockers = stringList(rawOutput.blockers, fallback.blockers);
|
|
||||||
const warnings = stringList(rawOutput.warnings, fallback.warnings);
|
|
||||||
const summary =
|
|
||||||
typeof rawOutput.summary === "string" && rawOutput.summary.trim()
|
|
||||||
? rawOutput.summary.trim()
|
|
||||||
: typeof verdict.summary === "string" && verdict.summary.trim()
|
|
||||||
? verdict.summary.trim()
|
|
||||||
: fallback.summary;
|
|
||||||
const score = Number(verdict.score ?? rawOutput.readiness?.score ?? fallback.verdict.score);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...fallback,
|
|
||||||
...output,
|
|
||||||
analystReview: output.analystReview ?? fallback.analystReview,
|
|
||||||
approvedSignals: stringList(rawOutput.approvedSignals, fallback.approvedSignals),
|
|
||||||
blockers,
|
|
||||||
checks: normalizeModelProviderChecks(rawOutput.checks, fallback.checks),
|
|
||||||
nextActions: stringList(rawOutput.nextActions, fallback.nextActions),
|
|
||||||
readiness: {
|
|
||||||
...fallback.readiness,
|
|
||||||
...(rawOutput.readiness && typeof rawOutput.readiness === "object" ? rawOutput.readiness : {})
|
|
||||||
},
|
|
||||||
runId: output.runId ?? fallback.runId,
|
|
||||||
semanticRunId: output.semanticRunId ?? fallback.semanticRunId,
|
|
||||||
sourceSerpInterpretationRunId: output.sourceSerpInterpretationRunId ?? fallback.sourceSerpInterpretationRunId,
|
|
||||||
sourceStrategyGeneratedAt: output.sourceStrategyGeneratedAt ?? fallback.sourceStrategyGeneratedAt,
|
|
||||||
sourceStrategySynthesisRunId: output.sourceStrategySynthesisRunId ?? fallback.sourceStrategySynthesisRunId,
|
|
||||||
sourceTaskId: output.sourceTaskId ?? fallback.sourceTaskId,
|
|
||||||
state: output.state ?? fallback.state,
|
|
||||||
summary,
|
|
||||||
verdict: {
|
|
||||||
confidence: isConfidence(verdict.confidence) ? verdict.confidence : fallback.verdict.confidence,
|
|
||||||
primaryBlocker:
|
|
||||||
typeof verdict.primaryBlocker === "string" && verdict.primaryBlocker.trim()
|
|
||||||
? verdict.primaryBlocker.trim()
|
|
||||||
: blockers[0] ?? fallback.verdict.primaryBlocker,
|
|
||||||
score: Number.isFinite(score) ? Math.max(0, Math.min(100, Math.round(score))) : fallback.verdict.score,
|
|
||||||
status,
|
|
||||||
summary,
|
|
||||||
title:
|
|
||||||
typeof verdict.title === "string" && verdict.title.trim()
|
|
||||||
? verdict.title.trim()
|
|
||||||
: status === "approved"
|
|
||||||
? "Стратегия прошла проверку"
|
|
||||||
: status === "needs_changes"
|
|
||||||
? "Стратегия требует исправлений"
|
|
||||||
: fallback.verdict.title
|
|
||||||
},
|
|
||||||
warnings
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStrategyQualityReviewRun(row: StrategyQualityReviewRunRow): StrategyQualityReviewRun | null {
|
function mapStrategyQualityReviewRun(row: StrategyQualityReviewRunRow): StrategyQualityReviewRun | null {
|
||||||
if (!row.output) {
|
if (!row.output) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -379,54 +224,13 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis
|
||||||
const hasApplyLeak = [...synthesis.recommendedPath.doNow, ...synthesis.nextActions].some((action) =>
|
const hasApplyLeak = [...synthesis.recommendedPath.doNow, ...synthesis.nextActions].some((action) =>
|
||||||
/запустить rewrite|запустить apply|apply changes|source mutation|создать правк|применить|изменить исходник/i.test(action)
|
/запустить rewrite|запустить apply|apply changes|source mutation|создать правк|применить|изменить исходник/i.test(action)
|
||||||
);
|
);
|
||||||
const expansionOptions = synthesis.decisionOptions.filter((option) => option.scenario === "serp_expansion");
|
const hasExpansionLeak = synthesis.decisionOptions.some(
|
||||||
const expansionCurrentRoute = expansionOptions.some((option) => option.id === synthesis.recommendedPath.id);
|
(option) => option.scenario === "serp_expansion" && option.confidence !== "low" && strategy.siteMaturity.phase === "bootstrap_indexing"
|
||||||
const expansionActionPattern = /расширени|расширить|соседн|expansion/i;
|
|
||||||
const expansionBlockedPattern = /не |нельзя|запрет|отлож|будущ|после|без /i;
|
|
||||||
const hasExpansionDoNow = [...synthesis.recommendedPath.doNow, ...synthesis.phaseDecision.allowedNow].some(
|
|
||||||
(action) => expansionActionPattern.test(action) && !expansionBlockedPattern.test(action)
|
|
||||||
);
|
);
|
||||||
const hasExpansionLeak = strategy.siteMaturity.phase === "bootstrap_indexing" && (expansionCurrentRoute || hasExpansionDoNow);
|
|
||||||
const hasFutureExpansionBranch =
|
|
||||||
!hasExpansionLeak &&
|
|
||||||
strategy.siteMaturity.phase === "bootstrap_indexing" &&
|
|
||||||
expansionOptions.some((option) => option.confidence !== "low" || option.priority !== "low" || option.expectedImpact !== "low");
|
|
||||||
const hasPhaseDecision = synthesis.phaseDecision.currentPhase === strategy.siteMaturity.phase && synthesis.phaseDecision.hold.length > 0;
|
const hasPhaseDecision = synthesis.phaseDecision.currentPhase === strategy.siteMaturity.phase && synthesis.phaseDecision.hold.length > 0;
|
||||||
const hasReadableNextStep = synthesis.recommendedPath.doNow.length > 0 && synthesis.recommendedPath.hold.length > 0;
|
const hasReadableNextStep = synthesis.recommendedPath.doNow.length > 0 && synthesis.recommendedPath.hold.length > 0;
|
||||||
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 [
|
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({
|
makeCheck({
|
||||||
action:
|
action:
|
||||||
immediateHeadTargets.length > 0
|
immediateHeadTargets.length > 0
|
||||||
|
|
@ -484,18 +288,14 @@ function buildChecks(strategy: SeoStrategyContract, synthesis: StrategySynthesis
|
||||||
makeCheck({
|
makeCheck({
|
||||||
action: hasExpansionLeak
|
action: hasExpansionLeak
|
||||||
? "Понизить доверие у ветки расширения или вынести её в отдельную ветку после подтверждения пользователем."
|
? "Понизить доверие у ветки расширения или вынести её в отдельную ветку после подтверждения пользователем."
|
||||||
: hasFutureExpansionBranch
|
: "Оставить расширение только как будущую ветку с низким доверием.",
|
||||||
? "Оставить расширение отдельной будущей веткой и не показывать его как текущее действие."
|
|
||||||
: "Оставить расширение только как будущую ветку с низким доверием.",
|
|
||||||
category: "business_drift",
|
category: "business_drift",
|
||||||
id: "business:no_core_expansion_leak",
|
id: "business:no_core_expansion_leak",
|
||||||
message: hasExpansionLeak
|
message: hasExpansionLeak
|
||||||
? "Ветка расширения выглядит как основной маршрут для стартовой фазы."
|
? "Ветка расширения выглядит как основной маршрут для стартовой фазы."
|
||||||
: hasFutureExpansionBranch
|
: "Расширение бизнеса не смешано с текущим основным SEO-маршрутом.",
|
||||||
? "Расширение есть как отдельная будущая ветка, но не попало в текущий маршрут."
|
|
||||||
: "Расширение бизнеса не смешано с текущим основным SEO-маршрутом.",
|
|
||||||
severity: hasExpansionLeak ? "high" : "low",
|
severity: hasExpansionLeak ? "high" : "low",
|
||||||
status: hasExpansionLeak ? "fail" : hasFutureExpansionBranch ? "warning" : "pass",
|
status: hasExpansionLeak ? "fail" : "pass",
|
||||||
title: "Нет смешения с расширением бизнеса"
|
title: "Нет смешения с расширением бизнеса"
|
||||||
}),
|
}),
|
||||||
makeCheck({
|
makeCheck({
|
||||||
|
|
@ -703,8 +503,6 @@ function normalizeOutput(
|
||||||
const blockers = output.checks.filter((check) => check.status === "fail").map((check) => `${check.title}: ${check.action}`);
|
const blockers = output.checks.filter((check) => check.status === "fail").map((check) => `${check.title}: ${check.action}`);
|
||||||
const warnings = output.checks.filter((check) => check.status === "warning").map((check) => `${check.title}: ${check.action}`);
|
const warnings = output.checks.filter((check) => check.status === "warning").map((check) => `${check.title}: ${check.action}`);
|
||||||
const approvedSignals = output.checks.filter((check) => check.status === "pass").map((check) => check.title);
|
const approvedSignals = output.checks.filter((check) => check.status === "pass").map((check) => check.title);
|
||||||
const score = getScore(output.checks);
|
|
||||||
const summary = `${getVerdictStatusSummary(output.verdict.status)}: проверок ${output.checks.length}, пройдено ${approvedSignals.length}, блокеров ${blockers.length}, предупреждений ${warnings.length}.`;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...output,
|
...output,
|
||||||
|
|
@ -726,34 +524,16 @@ function normalizeOutput(
|
||||||
issueCount: blockers.length + warnings.length,
|
issueCount: blockers.length + warnings.length,
|
||||||
passedCount: approvedSignals.length,
|
passedCount: approvedSignals.length,
|
||||||
phaseGuardrailIssueCount: output.checks.filter((check) => check.category === "phase_guardrail" && check.status !== "pass").length,
|
phaseGuardrailIssueCount: output.checks.filter((check) => check.category === "phase_guardrail" && check.status !== "pass").length,
|
||||||
score,
|
score: output.verdict.score,
|
||||||
uxIssueCount: output.checks.filter((check) => check.category === "ux_clarity" && check.status !== "pass").length,
|
uxIssueCount: output.checks.filter((check) => check.category === "ux_clarity" && check.status !== "pass").length,
|
||||||
warningCount: warnings.length
|
warningCount: warnings.length
|
||||||
},
|
},
|
||||||
summary,
|
|
||||||
state: output.checks.length > 0 ? "ready" : "not_ready",
|
state: output.checks.length > 0 ? "ready" : "not_ready",
|
||||||
verdict: {
|
|
||||||
...output.verdict,
|
|
||||||
primaryBlocker: blockers[0] ?? output.verdict.primaryBlocker,
|
|
||||||
score,
|
|
||||||
summary
|
|
||||||
},
|
|
||||||
warnings
|
warnings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getLatestStrategyQualityReviewContract(projectId: string): Promise<StrategyQualityReviewContract> {
|
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>(
|
const result = await pool.query<StrategyQualityReviewRunRow>(
|
||||||
`
|
`
|
||||||
select id, status, input, output, error_message, started_at, completed_at, created_at
|
select id, status, input, output, error_message, started_at, completed_at, created_at
|
||||||
|
|
@ -839,54 +619,3 @@ export async function saveStrategyQualityReviewManual(
|
||||||
|
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveStrategyQualityReviewFromModelProvider(
|
|
||||||
projectId: string,
|
|
||||||
output: StrategyQualityReviewContract,
|
|
||||||
sourceModelTaskRunId: string
|
|
||||||
): Promise<StrategyQualityReviewRun> {
|
|
||||||
const [strategy, synthesis, serpInterpretation] = await Promise.all([
|
|
||||||
getSeoStrategyContract(projectId),
|
|
||||||
getLatestStrategySynthesisContract(projectId),
|
|
||||||
getLatestSerpInterpretationContract(projectId)
|
|
||||||
]);
|
|
||||||
const task = buildStrategyQualityReviewModelTask(projectId, strategy, synthesis, serpInterpretation);
|
|
||||||
const fallbackOutput = task ? buildStrategyQualityReviewOutput(projectId, strategy, synthesis, serpInterpretation) : null;
|
|
||||||
|
|
||||||
if (!task || !fallbackOutput) {
|
|
||||||
throw new Error("Не удалось собрать backend fallback для AI Workspace strategy quality review.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedOutput = normalizeOutput(
|
|
||||||
projectId,
|
|
||||||
mergeModelProviderStrategyQualityReviewOutput(output, fallbackOutput),
|
|
||||||
"codex_workspace",
|
|
||||||
"AI Workspace Codex provider выполнил seo.strategy_quality_review по contract-only task; результат сохранён как evidence после backend validation."
|
|
||||||
);
|
|
||||||
const result = await pool.query<StrategyQualityReviewRunRow>(
|
|
||||||
`
|
|
||||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
|
||||||
values ($1, 'seo_strategy_quality_review', 'done', $2::jsonb, $3::jsonb, now(), now())
|
|
||||||
returning id, status, input, output, error_message, started_at, completed_at, created_at;
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
projectId,
|
|
||||||
{
|
|
||||||
providerMode: "codex_workspace",
|
|
||||||
schemaVersion: "seo-strategy-quality-review-model-provider-input.v1",
|
|
||||||
sourceModelTaskRunId,
|
|
||||||
sourceStrategyGeneratedAt: normalizedOutput.sourceStrategyGeneratedAt,
|
|
||||||
sourceStrategySynthesisRunId: normalizedOutput.sourceStrategySynthesisRunId,
|
|
||||||
taskType: "seo.strategy_quality_review"
|
|
||||||
},
|
|
||||||
normalizedOutput
|
|
||||||
]
|
|
||||||
);
|
|
||||||
const run = result.rows[0] ? mapStrategyQualityReviewRun(result.rows[0]) : null;
|
|
||||||
|
|
||||||
if (!run) {
|
|
||||||
throw new Error("Не удалось сохранить AI Workspace strategy quality review.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { getSeoStrategyContract, type SeoStrategyContract } from "./seoStrategy.
|
||||||
import { buildStrategySynthesisModelTask } from "./strategySynthesisTask.js";
|
import { buildStrategySynthesisModelTask } from "./strategySynthesisTask.js";
|
||||||
|
|
||||||
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
|
||||||
type StrategySynthesisProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
type StrategySynthesisProviderMode = "codex_manual" | "deterministic_fallback";
|
||||||
type StrategySynthesisState = "not_ready" | "ready";
|
type StrategySynthesisState = "not_ready" | "ready";
|
||||||
type StrategySynthesisConfidence = "high" | "low" | "medium";
|
type StrategySynthesisConfidence = "high" | "low" | "medium";
|
||||||
type StrategySynthesisPriority = "high" | "low" | "medium";
|
type StrategySynthesisPriority = "high" | "low" | "medium";
|
||||||
|
|
@ -158,142 +158,6 @@ function getDefaultAnalystReview(): StrategySynthesisContract["analystReview"] {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isConfidence(value: unknown): value is StrategySynthesisConfidence {
|
|
||||||
return value === "high" || value === "medium" || value === "low";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPriority(value: unknown): value is StrategySynthesisPriority {
|
|
||||||
return value === "high" || value === "medium" || value === "low";
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringList(value: unknown, fallback: string[]) {
|
|
||||||
if (!Array.isArray(value)) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = value.map((item) => {
|
|
||||||
if (typeof item === "string") {
|
|
||||||
return item.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item && typeof item === "object") {
|
|
||||||
const record = item as Record<string, unknown>;
|
|
||||||
|
|
||||||
for (const key of ["title", "message", "nextStep", "action", "summary", "label"]) {
|
|
||||||
const text = record[key];
|
|
||||||
|
|
||||||
if (typeof text === "string" && text.trim()) {
|
|
||||||
return text.trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(item || "").trim();
|
|
||||||
}).filter(Boolean);
|
|
||||||
|
|
||||||
return result.length > 0 ? result : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeModelProviderStrategySynthesisOutput(
|
|
||||||
output: StrategySynthesisContract,
|
|
||||||
fallback: StrategySynthesisContract
|
|
||||||
): StrategySynthesisContract {
|
|
||||||
const rawOutput = output as StrategySynthesisContract & Record<string, any>;
|
|
||||||
const executiveSummary = (rawOutput.executiveSummary ?? {}) as Record<string, any>;
|
|
||||||
const phaseDecision = (rawOutput.phaseDecision ?? {}) as Record<string, any>;
|
|
||||||
const recommendedPath = (rawOutput.recommendedPath ?? {}) as Record<string, any>;
|
|
||||||
const summary =
|
|
||||||
typeof rawOutput.summary === "string" && rawOutput.summary.trim()
|
|
||||||
? rawOutput.summary.trim()
|
|
||||||
: typeof executiveSummary.summary === "string" && executiveSummary.summary.trim()
|
|
||||||
? executiveSummary.summary.trim()
|
|
||||||
: fallback.summary;
|
|
||||||
const executiveKeyPoints = stringList(executiveSummary.keyPoints, []);
|
|
||||||
const blockedUntil = stringList(recommendedPath.blockedUntil, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...fallback,
|
|
||||||
...output,
|
|
||||||
analystReview: output.analystReview ?? fallback.analystReview,
|
|
||||||
blockedActions: stringList(rawOutput.blockedActions, fallback.blockedActions),
|
|
||||||
dataGaps: Array.isArray(rawOutput.dataGaps) ? rawOutput.dataGaps : fallback.dataGaps,
|
|
||||||
decisionOptions: Array.isArray(rawOutput.decisionOptions) ? rawOutput.decisionOptions : fallback.decisionOptions,
|
|
||||||
evidenceNarrative: Array.isArray(rawOutput.evidenceNarrative) ? rawOutput.evidenceNarrative : fallback.evidenceNarrative,
|
|
||||||
executiveSummary: {
|
|
||||||
confidence: isConfidence(executiveSummary.confidence) ? executiveSummary.confidence : fallback.executiveSummary.confidence,
|
|
||||||
primaryConstraint:
|
|
||||||
typeof executiveSummary.primaryConstraint === "string" && executiveSummary.primaryConstraint.trim()
|
|
||||||
? executiveSummary.primaryConstraint.trim()
|
|
||||||
: blockedUntil[0] ?? fallback.executiveSummary.primaryConstraint,
|
|
||||||
title:
|
|
||||||
typeof executiveSummary.title === "string" && executiveSummary.title.trim()
|
|
||||||
? executiveSummary.title.trim()
|
|
||||||
: fallback.executiveSummary.title,
|
|
||||||
verdict:
|
|
||||||
typeof executiveSummary.verdict === "string" && executiveSummary.verdict.trim()
|
|
||||||
? executiveSummary.verdict.trim()
|
|
||||||
: summary,
|
|
||||||
whyNow:
|
|
||||||
typeof executiveSummary.whyNow === "string" && executiveSummary.whyNow.trim()
|
|
||||||
? executiveSummary.whyNow.trim()
|
|
||||||
: executiveKeyPoints.length > 0
|
|
||||||
? executiveKeyPoints.join(" ")
|
|
||||||
: fallback.executiveSummary.whyNow
|
|
||||||
},
|
|
||||||
generatedAt: output.generatedAt ?? fallback.generatedAt,
|
|
||||||
nextActions: stringList(rawOutput.nextActions, fallback.nextActions),
|
|
||||||
phaseDecision: {
|
|
||||||
allowedNow: stringList(phaseDecision.allowedNow, fallback.phaseDecision.allowedNow),
|
|
||||||
currentPhase: phaseDecision.currentPhase ?? fallback.phaseDecision.currentPhase,
|
|
||||||
hold: stringList(phaseDecision.hold, stringList(phaseDecision.deferred, fallback.phaseDecision.hold)),
|
|
||||||
phaseRoadmap: Array.isArray(phaseDecision.phaseRoadmap) ? phaseDecision.phaseRoadmap : fallback.phaseDecision.phaseRoadmap,
|
|
||||||
prepareNext: stringList(phaseDecision.prepareNext, fallback.phaseDecision.prepareNext),
|
|
||||||
promotionSignals: stringList(phaseDecision.promotionSignals, fallback.phaseDecision.promotionSignals),
|
|
||||||
schemaVersion: "seo-phase-decision.v1",
|
|
||||||
summary:
|
|
||||||
typeof phaseDecision.summary === "string" && phaseDecision.summary.trim()
|
|
||||||
? phaseDecision.summary.trim()
|
|
||||||
: typeof phaseDecision.reason === "string" && phaseDecision.reason.trim()
|
|
||||||
? phaseDecision.reason.trim()
|
|
||||||
: fallback.phaseDecision.summary,
|
|
||||||
title:
|
|
||||||
typeof phaseDecision.title === "string" && phaseDecision.title.trim()
|
|
||||||
? phaseDecision.title.trim()
|
|
||||||
: fallback.phaseDecision.title
|
|
||||||
},
|
|
||||||
phaseStrategies: Array.isArray(rawOutput.phaseStrategies) ? rawOutput.phaseStrategies : fallback.phaseStrategies,
|
|
||||||
readiness: {
|
|
||||||
...fallback.readiness,
|
|
||||||
...(rawOutput.readiness && typeof rawOutput.readiness === "object" ? rawOutput.readiness : {})
|
|
||||||
},
|
|
||||||
recommendedPath: {
|
|
||||||
confidence: isConfidence(recommendedPath.confidence) ? recommendedPath.confidence : fallback.recommendedPath.confidence,
|
|
||||||
doNow: stringList(recommendedPath.doNow, stringList(recommendedPath.currentActions, fallback.recommendedPath.doNow)),
|
|
||||||
expectedImpact: isPriority(recommendedPath.expectedImpact) ? recommendedPath.expectedImpact : fallback.recommendedPath.expectedImpact,
|
|
||||||
hold: stringList(recommendedPath.hold, blockedUntil.length > 0 ? blockedUntil : fallback.recommendedPath.hold),
|
|
||||||
id: typeof recommendedPath.id === "string" && recommendedPath.id.trim() ? recommendedPath.id.trim() : fallback.recommendedPath.id,
|
|
||||||
needsEvidence: stringList(recommendedPath.needsEvidence, blockedUntil.length > 0 ? blockedUntil : fallback.recommendedPath.needsEvidence),
|
|
||||||
priority: isPriority(recommendedPath.priority) ? recommendedPath.priority : fallback.recommendedPath.priority,
|
|
||||||
rationale:
|
|
||||||
typeof recommendedPath.rationale === "string" && recommendedPath.rationale.trim()
|
|
||||||
? recommendedPath.rationale.trim()
|
|
||||||
: fallback.recommendedPath.rationale,
|
|
||||||
title:
|
|
||||||
typeof recommendedPath.title === "string" && recommendedPath.title.trim()
|
|
||||||
? recommendedPath.title.trim()
|
|
||||||
: fallback.recommendedPath.title
|
|
||||||
},
|
|
||||||
riskPosture: Array.isArray(rawOutput.riskPosture) ? rawOutput.riskPosture : fallback.riskPosture,
|
|
||||||
runId: output.runId ?? fallback.runId,
|
|
||||||
semanticRunId: output.semanticRunId ?? fallback.semanticRunId,
|
|
||||||
sourceSerpInterpretationRunId: output.sourceSerpInterpretationRunId ?? fallback.sourceSerpInterpretationRunId,
|
|
||||||
sourceStrategyGeneratedAt: output.sourceStrategyGeneratedAt ?? fallback.sourceStrategyGeneratedAt,
|
|
||||||
sourceTaskId: output.sourceTaskId ?? fallback.sourceTaskId,
|
|
||||||
state: output.state ?? fallback.state,
|
|
||||||
summary
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesisRun | null {
|
function mapStrategySynthesisRun(row: StrategySynthesisRunRow): StrategySynthesisRun | null {
|
||||||
if (!row.output) {
|
if (!row.output) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -754,53 +618,3 @@ export async function saveStrategySynthesisManual(
|
||||||
|
|
||||||
return run;
|
return run;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveStrategySynthesisFromModelProvider(
|
|
||||||
projectId: string,
|
|
||||||
output: StrategySynthesisContract,
|
|
||||||
sourceModelTaskRunId: string
|
|
||||||
): Promise<StrategySynthesisRun> {
|
|
||||||
const [strategy, serpInterpretation] = await Promise.all([
|
|
||||||
getSeoStrategyContract(projectId),
|
|
||||||
getLatestSerpInterpretationContract(projectId)
|
|
||||||
]);
|
|
||||||
const fallbackOutput = buildStrategySynthesisOutput(projectId, strategy, serpInterpretation);
|
|
||||||
|
|
||||||
if (!fallbackOutput) {
|
|
||||||
throw new Error("Не удалось собрать backend fallback для AI Workspace Strategy synthesis.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedOutput = normalizeOutput(
|
|
||||||
projectId,
|
|
||||||
mergeModelProviderStrategySynthesisOutput(output, fallbackOutput),
|
|
||||||
"codex_workspace",
|
|
||||||
"AI Workspace Codex provider выполнил seo.strategy_synthesis по contract-only task; результат сохранён как evidence после backend validation."
|
|
||||||
);
|
|
||||||
const result = await pool.query<StrategySynthesisRunRow>(
|
|
||||||
`
|
|
||||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
|
||||||
values ($1, 'seo_strategy_synthesis', 'done', $2::jsonb, $3::jsonb, now(), now())
|
|
||||||
returning id, status, input, output, error_message, started_at, completed_at, created_at;
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
projectId,
|
|
||||||
JSON.stringify({
|
|
||||||
providerMode: "codex_workspace",
|
|
||||||
schemaVersion: "seo-strategy-synthesis-model-provider-input.v1",
|
|
||||||
sourceModelTaskRunId,
|
|
||||||
sourceSerpInterpretationRunId: normalizedOutput.sourceSerpInterpretationRunId,
|
|
||||||
sourceStrategyGeneratedAt: normalizedOutput.sourceStrategyGeneratedAt,
|
|
||||||
sourceTaskId: normalizedOutput.sourceTaskId,
|
|
||||||
taskType: "seo.strategy_synthesis"
|
|
||||||
}),
|
|
||||||
JSON.stringify(normalizedOutput)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
const run = result.rows[0] ? mapStrategySynthesisRun(result.rows[0]) : null;
|
|
||||||
|
|
||||||
if (!run) {
|
|
||||||
throw new Error("Не удалось сохранить AI Workspace Strategy synthesis.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue