feat(seo): orchestrate market demand discovery

This commit is contained in:
DCCONSTRUCTIONS 2026-07-05 11:07:13 +03:00
parent 284c72d75c
commit a1d1a4980f
25 changed files with 8929 additions and 312 deletions

View File

@ -19,7 +19,7 @@ WORDSTAT_PROVIDER=disabled
# WORDSTAT_REGION_IDS=225
# WORDSTAT_DEVICES=DEVICE_ALL
# WORDSTAT_NUM_PHRASES=50
# WORDSTAT_MAX_SEEDS_PER_RUN=40
# WORDSTAT_MAX_SEEDS_PER_RUN=12
SERP_PROVIDER=disabled
# SERP_API_BASE_URL=https://serp-provider.internal

View File

@ -68,6 +68,37 @@ type ContentDocument = {
type PageScope = "indexable_page" | "template_block" | "admin_page" | "technical_page";
type TextCorpusChunk = {
charEnd: number;
charStart: number;
chunkIndex: number;
documentId: string;
id: string;
kind: ContentDocument["kind"];
scope: ContentDocument["scope"];
selected: boolean;
sourcePath: string;
text: string;
title: string;
totalChunks: number;
urlPath: string | null;
usedForCoverage: boolean;
};
type TextCorpusDocument = {
chunkIds: string[];
id: string;
kind: ContentDocument["kind"];
scope: ContentDocument["scope"];
selected: boolean;
sourcePath: string;
textCharCount: number;
title: string;
urlPath: string | null;
usedForCoverage: boolean;
wordCount: number;
};
type AnalysisSignal = {
id: string;
level: "ok" | "warning" | "problem";
@ -272,6 +303,19 @@ export type SemanticAnalysisOutput = {
wordCount: number;
matchedClusters: string[];
}>;
textCorpus: {
schemaVersion: "seo-text-corpus.v1";
extraction: {
chunkCount: number;
documentCount: number;
mode: "clean_visible_text";
sourceDocumentCount: number;
totalChars: number;
totalWords: number;
};
documents: TextCorpusDocument[];
chunks: TextCorpusChunk[];
};
legacyFindings: Array<{
markerId: string;
label: string;
@ -484,6 +528,11 @@ const RUSSIAN_STOP_WORDS = new Set([
"assets",
"attr",
"avif",
"accesscard",
"bodyhtml",
"bottominfo",
"card",
"cards",
"class",
"collection",
"content",
@ -492,16 +541,22 @@ const RUSSIAN_STOP_WORDS = new Set([
"data",
"features",
"footer",
"form",
"html",
"image",
"images",
"introhtml",
"json",
"heading",
"main",
"media",
"muted",
"node",
"site",
"src",
"static",
"staticelements",
"target",
"text",
"txt",
"webp",
@ -537,6 +592,8 @@ const CTA_PATTERNS = [
"заказать"
];
const TEXT_CORPUS_CHUNK_CHARS = 6000;
function toIsoDate(value: Date | null) {
return value ? value.toISOString() : null;
}
@ -626,12 +683,113 @@ function visibleTextFromHtml(html: string) {
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ")
.replace(/<svg\b[\s\S]*?<\/svg>/gi, " ")
.replace(/<(br|hr)\b[^>]*>/gi, "\n")
.replace(/<\/(article|aside|blockquote|dd|details|dialog|div|dl|dt|figcaption|figure|footer|form|h[1-6]|header|li|main|nav|ol|p|section|summary|table|tbody|td|tfoot|th|thead|tr|ul)>/gi, "\n")
.replace(/<[^>]+>/g, " ")
)
.replace(/\s+/g, " ")
.split(/\n+/)
.map((line) => line.replace(/[ \t\f\v]+/g, " ").trim())
.filter(Boolean)
.join("\n")
.trim();
}
function splitTextCorpusChunks(text: string, maxChars = TEXT_CORPUS_CHUNK_CHARS) {
const chunks: Array<{ charEnd: number; charStart: number; text: string }> = [];
let start = 0;
while (start < text.length) {
const hardEnd = Math.min(text.length, start + maxChars);
let end = hardEnd;
if (hardEnd < text.length) {
const windowStart = Math.max(start + Math.floor(maxChars * 0.65), start);
const newlineIndex = text.lastIndexOf("\n", hardEnd);
const spaceIndex = text.lastIndexOf(" ", hardEnd);
const breakIndex = Math.max(newlineIndex, spaceIndex);
if (breakIndex >= windowStart) {
end = breakIndex;
}
}
const chunkText = text.slice(start, end).trim();
if (chunkText) {
chunks.push({
charEnd: end,
charStart: start,
text: chunkText
});
}
start = end;
while (start < text.length && /\s/.test(text[start] ?? "")) {
start += 1;
}
}
return chunks;
}
function buildTextCorpus(documents: ContentDocument[]): SemanticAnalysisOutput["textCorpus"] {
const sourceDocuments = documents.filter((document) => document.text.trim().length > 0);
const corpusDocuments: TextCorpusDocument[] = [];
const chunks: TextCorpusChunk[] = [];
for (const document of sourceDocuments) {
const documentChunks = splitTextCorpusChunks(document.text);
const chunkIds = documentChunks.map((_, index) => `${document.id}:chunk:${index + 1}`);
corpusDocuments.push({
chunkIds,
id: document.id,
kind: document.kind,
scope: document.scope,
selected: document.selected,
sourcePath: document.sourcePath,
textCharCount: document.text.length,
title: document.title,
urlPath: document.urlPath,
usedForCoverage: document.usedForCoverage,
wordCount: document.wordCount
});
documentChunks.forEach((chunk, index) => {
chunks.push({
charEnd: chunk.charEnd,
charStart: chunk.charStart,
chunkIndex: index + 1,
documentId: document.id,
id: chunkIds[index] ?? `${document.id}:chunk:${index + 1}`,
kind: document.kind,
scope: document.scope,
selected: document.selected,
sourcePath: document.sourcePath,
text: chunk.text,
title: document.title,
totalChunks: documentChunks.length,
urlPath: document.urlPath,
usedForCoverage: document.usedForCoverage
});
});
}
return {
schemaVersion: "seo-text-corpus.v1",
extraction: {
chunkCount: chunks.length,
documentCount: corpusDocuments.length,
mode: "clean_visible_text",
sourceDocumentCount: sourceDocuments.length,
totalChars: corpusDocuments.reduce((sum, document) => sum + document.textCharCount, 0),
totalWords: corpusDocuments.reduce((sum, document) => sum + document.wordCount, 0)
},
documents: corpusDocuments,
chunks
};
}
function normalizeSearchText(value: string) {
return value
.toLocaleLowerCase("ru-RU")
@ -1316,7 +1474,7 @@ function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>, ontol
? `Кластер важен для ${ontology.brandName}, но в текущем сайте почти не раскрыт.`
: "Кластер уже найден в контенте и подходит для проверки частотности.";
return cluster.queryExamples.slice(0, cluster.priority === "high" ? 3 : 2).map((phrase) => ({
return buildClusterSeedPhrases(cluster, ontology.brandName).slice(0, cluster.priority === "high" ? 4 : 2).map((phrase) => ({
phrase,
clusterId: cluster.id,
clusterTitle: cluster.title,
@ -1327,6 +1485,122 @@ function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>, ontol
});
}
function getSeedPhraseWords(value: string) {
return normalizeSearchText(value)
.split(/\s+/)
.map((word) => word.trim())
.filter(Boolean);
}
function isSemanticBasisPhrase(value: string, brandName: string) {
const normalizedValue = normalizeSearchText(value);
const words = getSeedPhraseWords(normalizedValue);
const normalizedBrand = normalizeSearchText(brandName);
const brandWords = new Set(getSeedPhraseWords(normalizedBrand));
if (words.length < 3 || words.length > 8) {
return false;
}
if (
!/[a-zа-яё0-9]/i.test(normalizedValue) ||
/\{\{[^}]+}}/.test(value) ||
/https?:|\/\/|\.html\b|href\b|logo\b|логотип\b|beforecviz\b|staticelements\b|txt-файл|txt-файла|иконка\b|^[^а-яёa-z0-9]+$/iu.test(normalizedValue) ||
/[0-9]{6,}/.test(normalizedValue)
) {
return false;
}
if (normalizedValue === normalizedBrand) {
return false;
}
const meaningfulWords = words.filter((word) => !RUSSIAN_STOP_WORDS.has(word) && !brandWords.has(word) && word.length > 1);
return meaningfulWords.length >= 2;
}
function cleanSeedPhrase(value: string) {
return value
.replace(/^\.\.\.\s*/, "")
.replace(/\s*\.\.\.$/, "")
.replace(/[“”«»"]/g, "")
.replace(/[|;:…]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function getWindowedPhrase(sentence: string, term: string) {
const words = cleanSeedPhrase(sentence).split(/\s+/).filter(Boolean);
const normalizedTermWords = getSeedPhraseWords(term);
const normalizedWords = words.map((word) => normalizeSearchText(word));
const termIndex = normalizedWords.findIndex((word, index) =>
normalizedTermWords.every((termWord, termWordIndex) => normalizedWords[index + termWordIndex] === termWord)
);
if (termIndex < 0) {
return null;
}
const start = Math.max(0, termIndex - 2);
const end = Math.min(words.length, termIndex + Math.max(normalizedTermWords.length, 1) + 4);
return cleanSeedPhrase(words.slice(start, end).join(" "));
}
function extractEvidenceSeedPhrases(
cluster: ReturnType<typeof analyzeClusters>[number],
brandName: string
) {
const terms = [...cluster.matchedTerms, ...cluster.queryExamples];
const candidates: string[] = [];
for (const snippet of cluster.evidenceSnippets) {
const sentenceCandidates = snippet.text
.split(/[.!?\n\r]+/u)
.map(cleanSeedPhrase)
.filter(Boolean);
for (const sentence of sentenceCandidates) {
const sentenceHasTerm = terms.some((term) => countTermHits(normalizeSearchText(sentence), term) > 0);
if (!sentenceHasTerm) {
continue;
}
if (isSemanticBasisPhrase(sentence, brandName)) {
candidates.push(sentence);
continue;
}
for (const term of terms) {
if (countTermHits(normalizeSearchText(sentence), term) === 0) {
continue;
}
const phrase = getWindowedPhrase(sentence, term);
if (phrase && isSemanticBasisPhrase(phrase, brandName)) {
candidates.push(phrase);
}
}
}
}
return candidates;
}
function buildClusterSeedPhrases(cluster: ReturnType<typeof analyzeClusters>[number], brandName: string) {
return Array.from(
new Set(
cluster.queryExamples
.map(cleanSeedPhrase)
.filter((phrase) => isSemanticBasisPhrase(phrase, brandName))
.map((phrase) => phrase.toLocaleLowerCase("ru-RU"))
)
);
}
function getLandingOpportunityPageType(
pagePath: string
): SemanticAnalysisOutput["landingOpportunities"][number]["pageType"] {
@ -2523,6 +2797,7 @@ async function buildAnalysisOutput(projectId: string): Promise<{ ontology: Proje
const ontology = await getProjectOntology(projectId, ontologyEvidence);
const pageScope = buildPageScopeSummary(pageDocuments, contentDocuments);
const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents, ontology);
const textCorpus = buildTextCorpus(coverageDocuments.length > 0 ? coverageDocuments : documents);
const clusters = analyzeClusters(coverageDocuments, ontology);
const coverageScore = getCoverageScore(clusters);
const lexicalCoverageScore = getLexicalCoverageScore(clusters);
@ -2640,6 +2915,7 @@ async function buildAnalysisOutput(projectId: string): Promise<{ ontology: Proje
wordCount: document.wordCount,
matchedClusters: contentClusterMap.get(document.id) ?? []
})),
textCorpus,
legacyFindings,
gaps,
landingOpportunities,

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,732 @@
import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js";
import {
getLatestAlignedSeoBusinessSynthesis,
getSeoBusinessSynthesisContract,
type SeoBusinessSynthesisContract
} from "../business/seoBusinessSynthesis.js";
import { pool } from "../db/client.js";
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
type ProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
type Grounding = "explicit" | "inferred" | "hypothesis";
type BuyerIntent =
| "solution_search"
| "vendor_selection"
| "implementation"
| "automation"
| "integration"
| "replacement"
| "pilot"
| "commercial";
type SeoCommercialDemandRunRow = {
id: string;
status: RunStatus;
input: Record<string, unknown>;
output: SeoCommercialDemandContract | null;
error_message: string | null;
started_at: Date | null;
completed_at: Date | null;
created_at: Date;
};
export type SeoCommercialDemandModelTaskContract = {
taskType: "seo.commercial_demand";
schemaVersion: "seo-commercial-demand-task.v1";
taskId: string;
projectId: string;
semanticRunId: string;
projectOntologyVersionId: string | null;
providerMode: "model_provider_contract";
input: {
siteSummary: {
brandName: string;
language: string;
topTerms: string[];
};
businessSynthesis: {
productFrame: SeoBusinessSynthesisContract["productFrame"];
corePillars: SeoBusinessSynthesisContract["semanticHierarchy"]["corePillars"];
applicationVectors: SeoBusinessSynthesisContract["semanticHierarchy"]["applicationVectors"];
demandFamilies: SeoBusinessSynthesisContract["demandFamilies"];
guardrails: string[];
summary: string;
};
};
allowedActions: Array<
| "identify_buyer_context"
| "convert_architecture_to_commercial_value"
| "preserve_core_differentiator"
| "separate_commercial_queries_from_informational_queries"
| "flag_noncommercial_or_overbroad_angles"
>;
outputSchema: {
schemaVersion: "seo-commercial-demand.v1";
requiredTopLevelKeys: string[];
};
stopConditions: string[];
};
export type SeoCommercialDemandContract = {
schemaVersion: "seo-commercial-demand.v1";
projectId: string;
semanticRunId: string;
projectOntologyVersionId: string | null;
generatedAt: string;
provider: {
mode: ProviderMode;
modelRequired: true;
message: string;
};
sourceTaskId: string;
analystReview: {
status: "approved" | "needs_changes" | "not_reviewed" | "rejected";
reviewer: "codex" | "human" | "system";
reviewedAt: string | null;
notes: string[];
};
commercialCore: {
coreBuyerProblem: string;
coreCommercialValue: string;
coreDifferentiator: string;
buyingTrigger: string;
decisionContext: string;
confidence: "low" | "medium" | "high";
evidenceRefs: string[];
};
buyerIntentFamilies: Array<{
id: string;
title: string;
buyerIntent: BuyerIntent;
priority: "high" | "medium" | "low";
grounding: Grounding;
commercialAngle: string;
sourceMeaning: string;
coreQualifier: string;
queryPatterns: string[];
excludedInformationalPatterns: string[];
evidenceRefs: string[];
}>;
applicationCommercialAngles: Array<{
id: string;
sourceVector: string;
relationshipToCore: string;
priority: "high" | "medium" | "low";
grounding: Grounding;
commercialReframe: string;
coreQualifier: string;
queryPatterns: string[];
evidenceRefs: string[];
}>;
rejectedAngles: Array<{
id: string;
title: string;
reason: string;
suggestedCommercialReframe: string | null;
}>;
guardrails: string[];
summary: string;
nextActions: string[];
};
export type SeoCommercialDemandRun = SeoCommercialDemandContract & {
runId: string;
status: RunStatus;
input: Record<string, unknown>;
startedAt: string | null;
completedAt: string | null;
errorMessage: string | null;
createdAt: string;
};
type SeoCommercialDemandModelProviderFallback = {
modelTask: SeoCommercialDemandModelTaskContract;
projectOntologyVersionId: string | null;
semanticRunId: string;
};
function toIsoDate(value: Date | null) {
return value ? value.toISOString() : null;
}
function asRecord(value: unknown): Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value)) ? value as Record<string, unknown> : {};
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function asString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function asLabel(value: unknown): string | null {
const direct = asString(value);
if (direct) {
return direct;
}
const record = asRecord(value);
return asString(record.title) ?? asString(record.label) ?? asString(record.name) ?? asString(record.summary) ?? asString(record.description);
}
function asStringArray(value: unknown): string[] {
if (typeof value === "string" && value.trim().length > 0) {
return [value.trim()];
}
return asArray(value).map(asLabel).filter((item): item is string => Boolean(item));
}
function normalizeText(value: string) {
return value
.trim()
.replace(/[“”«»"]/g, "")
.replace(/[|,;:…]+/g, " ")
.replace(/[\\/]+/g, " ")
.replace(/\s+/g, " ")
.toLocaleLowerCase("ru-RU");
}
function unique(values: string[]) {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalizedValue = normalizeText(value);
if (!normalizedValue || seen.has(normalizedValue)) {
continue;
}
seen.add(normalizedValue);
result.push(normalizedValue);
}
return result;
}
function toId(value: string) {
return normalizeText(value)
.replace(/[^a-zа-яё0-9]+/gi, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
function normalizeGrounding(value: unknown, fallback: Grounding = "inferred"): Grounding {
if (value === "explicit" || value === "inferred" || value === "hypothesis") {
return value;
}
const text = normalizeText(asString(value) ?? "");
if (/(risk|unsupported|hypothesis|гипотез|не подтверж)/iu.test(text)) {
return "hypothesis";
}
if (/(infer|possible|conservative|предполож|вывод)/iu.test(text)) {
return "inferred";
}
if (/(explicit|source|evidence|direct|явн|доказ)/iu.test(text)) {
return "explicit";
}
return fallback;
}
function normalizePriority(value: unknown): "high" | "medium" | "low" {
if (value === "high" || value === "medium" || value === "low") {
return value;
}
const text = normalizeText(asString(value) ?? "");
if (/(highest|critical|high|core|главн|высок)/iu.test(text)) {
return "high";
}
if (/(low|weak|optional|низк)/iu.test(text)) {
return "low";
}
return "medium";
}
function normalizeBuyerIntent(value: unknown, text: string): BuyerIntent {
const normalizedValue = normalizeText(`${asString(value) ?? ""} ${text}`);
if (/(pilot|demo|trial|пилот|демо|заявк|подключ)/iu.test(normalizedValue)) {
return "pilot";
}
if (/(implement|deploy|rollout|внедрен|запуск|разверт)/iu.test(normalizedValue)) {
return "implementation";
}
if (/(integrat|api|webhook|1c|1с|интеграц|подключ)/iu.test(normalizedValue)) {
return "integration";
}
if (/(replace|alternative|вместо|замен|альтернатив)/iu.test(normalizedValue)) {
return "replacement";
}
if (/(vendor|platform|service|provider|поставщик|подрядчик|платформ|сервис|решение)/iu.test(normalizedValue)) {
return "vendor_selection";
}
if (/(automat|agent|workflow|автоматизац|агент|процесс)/iu.test(normalizedValue)) {
return "automation";
}
if (/(commercial|price|tariff|цена|стоим|тариф)/iu.test(normalizedValue)) {
return "commercial";
}
return "solution_search";
}
function getDefaultAnalystReview(): SeoCommercialDemandContract["analystReview"] {
return {
notes: [],
reviewedAt: null,
reviewer: "system",
status: "not_reviewed"
};
}
function mapSeoCommercialDemandRun(row: SeoCommercialDemandRunRow): SeoCommercialDemandRun | null {
if (!row.output) {
return null;
}
return {
runId: row.id,
status: row.status,
input: row.input,
startedAt: toIsoDate(row.started_at),
completedAt: toIsoDate(row.completed_at),
errorMessage: row.error_message,
createdAt: row.created_at.toISOString(),
...row.output,
analystReview: row.output.analystReview ?? getDefaultAnalystReview()
};
}
export function buildSeoCommercialDemandTask(
projectId: string,
businessSynthesis: SeoBusinessSynthesisContract
): SeoCommercialDemandModelTaskContract {
return {
taskType: "seo.commercial_demand",
schemaVersion: "seo-commercial-demand-task.v1",
taskId: `${businessSynthesis.semanticRunId}:seo-commercial-demand:v1`,
projectId,
semanticRunId: businessSynthesis.semanticRunId,
projectOntologyVersionId: businessSynthesis.projectOntologyVersionId,
providerMode: "model_provider_contract",
input: {
siteSummary: {
brandName: businessSynthesis.productFrame.coreOffer,
language: "mixed",
topTerms: []
},
businessSynthesis: {
productFrame: businessSynthesis.productFrame,
corePillars: businessSynthesis.semanticHierarchy.corePillars.slice(0, 12),
applicationVectors: businessSynthesis.semanticHierarchy.applicationVectors.slice(0, 18),
demandFamilies: businessSynthesis.demandFamilies.slice(0, 20),
guardrails: businessSynthesis.guardrails.slice(0, 20),
summary: businessSynthesis.summary
}
},
allowedActions: [
"identify_buyer_context",
"convert_architecture_to_commercial_value",
"preserve_core_differentiator",
"separate_commercial_queries_from_informational_queries",
"flag_noncommercial_or_overbroad_angles"
],
outputSchema: {
schemaVersion: "seo-commercial-demand.v1",
requiredTopLevelKeys: [
"commercialCore",
"buyerIntentFamilies",
"applicationCommercialAngles"
]
},
stopConditions: [
"Do not output architecture inventory as keyword demand.",
"Every query pattern must express buyer intent, business value, implementation intent, vendor selection, automation, replacement, integration, pilot, or commercial evaluation.",
"Preserve the site's core differentiator from productFrame/corePillars; do not detach verticals into unrelated markets.",
"If an application vector only makes sense with the core platform differentiator, include that qualifier in query patterns.",
"Do not call Wordstat/SERP or claim market demand is proven."
]
};
}
function getEvidenceRefs(record: Record<string, unknown>) {
return unique([
...asStringArray(record.evidenceRefs),
...asStringArray(record.evidence),
...asStringArray(record.evidenceBasis),
...asStringArray(record.sources)
]).slice(0, 12);
}
function normalizeCommercialDemandShape(
projectId: string,
output: SeoCommercialDemandContract,
fallback: SeoCommercialDemandModelProviderFallback
): SeoCommercialDemandContract {
const rawOutput = asRecord(output);
const rawCore = asRecord(rawOutput.commercialCore);
const fallbackProductFrame = fallback.modelTask.input.businessSynthesis.productFrame;
const coreDifferentiator =
asString(rawCore.coreDifferentiator) ??
asString(rawCore.differentiator) ??
fallbackProductFrame.coreOffer;
const commercialCore: SeoCommercialDemandContract["commercialCore"] = {
buyingTrigger:
asString(rawCore.buyingTrigger) ??
asString(rawCore.trigger) ??
"Покупатель ищет решение, когда текущие процессы, данные, роли или исполнение требуют системного улучшения.",
confidence:
rawCore.confidence === "high" || rawCore.confidence === "medium" || rawCore.confidence === "low"
? rawCore.confidence
: fallbackProductFrame.confidence,
coreBuyerProblem:
asString(rawCore.coreBuyerProblem) ??
asString(rawCore.buyerProblem) ??
asString(rawCore.problem) ??
fallbackProductFrame.centralThesis,
coreCommercialValue:
asString(rawCore.coreCommercialValue) ??
asString(rawCore.commercialValue) ??
asString(rawCore.value) ??
fallbackProductFrame.coreOffer,
coreDifferentiator,
decisionContext:
asString(rawCore.decisionContext) ??
asString(rawCore.context) ??
"Выбор, внедрение или пилот решения внутри бизнес/операционного контура.",
evidenceRefs: getEvidenceRefs(rawCore)
};
const buyerIntentFamilies = asArray(rawOutput.buyerIntentFamilies).map((item, index) => {
const record = asRecord(item);
const title = asString(record.title) ?? asLabel(record.name) ?? `Коммерческий intent ${index + 1}`;
const queryPatterns = unique([
...asStringArray(record.queryPatterns),
...asStringArray(record.queries),
...asStringArray(record.corePhrases),
...asStringArray(record.phrases)
]).slice(0, 12);
return {
buyerIntent: normalizeBuyerIntent(record.buyerIntent ?? record.intent, `${title} ${queryPatterns.join(" ")}`),
commercialAngle:
asString(record.commercialAngle) ??
asString(record.angle) ??
asString(record.rationale) ??
"Покупатель оценивает практическую бизнес-ценность решения.",
coreQualifier: asString(record.coreQualifier) ?? asString(record.qualifier) ?? coreDifferentiator,
evidenceRefs: getEvidenceRefs(record),
excludedInformationalPatterns: unique([
...asStringArray(record.excludedInformationalPatterns),
...asStringArray(record.excludedPatterns),
...asStringArray(record.nonCommercialPatterns)
]).slice(0, 10),
grounding: normalizeGrounding(record.grounding ?? record.claimType, "inferred"),
id: asString(record.id) ?? `buyer.${toId(title) || index + 1}`,
priority: normalizePriority(record.priority),
queryPatterns,
sourceMeaning: asString(record.sourceMeaning) ?? asString(record.meaning) ?? title,
title
};
});
const applicationCommercialAngles = asArray(rawOutput.applicationCommercialAngles).map((item, index) => {
const record = asRecord(item);
const sourceVector = asString(record.sourceVector) ?? asString(record.title) ?? asLabel(record.name) ?? `Application vector ${index + 1}`;
return {
commercialReframe:
asString(record.commercialReframe) ??
asString(record.reframe) ??
asString(record.commercialAngle) ??
"Переформулировать как покупательский сценарий, связанный с ядром продукта.",
coreQualifier: asString(record.coreQualifier) ?? asString(record.qualifier) ?? coreDifferentiator,
evidenceRefs: getEvidenceRefs(record),
grounding: normalizeGrounding(record.grounding ?? record.claimType, "inferred"),
id: asString(record.id) ?? `angle.${toId(sourceVector) || index + 1}`,
priority: normalizePriority(record.priority),
queryPatterns: unique([
...asStringArray(record.queryPatterns),
...asStringArray(record.queries),
...asStringArray(record.phrases)
]).slice(0, 10),
relationshipToCore: asString(record.relationshipToCore) ?? asString(record.relationship) ?? "application_of_core_platform",
sourceVector
};
});
const fallbackBuyerIntentFamilies =
buyerIntentFamilies.length > 0
? buyerIntentFamilies
: fallback.modelTask.input.businessSynthesis.demandFamilies.slice(0, 10).map((family, index) => ({
buyerIntent: normalizeBuyerIntent(family.role, family.title),
commercialAngle: family.rationale,
coreQualifier: coreDifferentiator,
evidenceRefs: [],
excludedInformationalPatterns: [],
grounding: family.grounding,
id: `buyer.${family.id || index + 1}`,
priority: family.priority,
queryPatterns: family.phrases.slice(0, 8),
sourceMeaning: family.title,
title: family.title
}));
return {
...output,
analystReview: output.analystReview ?? getDefaultAnalystReview(),
applicationCommercialAngles,
buyerIntentFamilies: fallbackBuyerIntentFamilies,
commercialCore,
generatedAt: asString(rawOutput.generatedAt) ?? new Date().toISOString(),
guardrails:
asStringArray(rawOutput.guardrails).length > 0
? asStringArray(rawOutput.guardrails)
: [
"Не превращать архитектурный тезис в запрос без покупательской ценности.",
"Прикладной сценарий должен оставаться связанным с core differentiator продукта.",
"Wordstat/SERP запускаются только после ручной фиксации semantic basis."
],
nextActions:
asStringArray(rawOutput.nextActions).length > 0
? asStringArray(rawOutput.nextActions)
: ["Передать commercial demand framing в seo.normalization для формирования human-gated basis."],
projectId: asString(rawOutput.projectId) ?? projectId,
projectOntologyVersionId:
asString(rawOutput.projectOntologyVersionId) ?? fallback.projectOntologyVersionId,
provider: {
message: asString(asRecord(rawOutput.provider).message) ?? "Commercial demand normalized by backend.",
mode: "codex_workspace",
modelRequired: true
},
rejectedAngles: asArray(rawOutput.rejectedAngles).map((item, index) => {
const record = asRecord(item);
return {
id: asString(record.id) ?? `rejected.${index + 1}`,
reason: asString(record.reason) ?? "Недостаточно коммерческого buyer intent.",
suggestedCommercialReframe: asString(record.suggestedCommercialReframe) ?? asString(record.suggestedReplacement),
title: asString(record.title) ?? asString(record.phrase) ?? `Некоммерческий угол ${index + 1}`
};
}),
schemaVersion: "seo-commercial-demand.v1",
semanticRunId: asString(rawOutput.semanticRunId) ?? fallback.semanticRunId,
sourceTaskId: asString(rawOutput.sourceTaskId) ?? fallback.modelTask.taskId,
summary:
asString(rawOutput.summary) ??
`${commercialCore.coreBuyerProblem} ${commercialCore.coreCommercialValue}`.trim()
};
}
function normalizeCommercialDemandOutput(
projectId: string,
output: SeoCommercialDemandContract,
providerMode: ProviderMode,
providerMessage: string,
fallback: SeoCommercialDemandModelProviderFallback
) {
const normalizedOutput = normalizeCommercialDemandShape(projectId, output, fallback);
if (normalizedOutput.schemaVersion !== "seo-commercial-demand.v1") {
throw new Error("Commercial demand output schemaVersion должен быть seo-commercial-demand.v1.");
}
if (normalizedOutput.projectId !== projectId || !normalizedOutput.semanticRunId || !normalizedOutput.sourceTaskId) {
throw new Error("Commercial demand output должен иметь projectId, semanticRunId и sourceTaskId.");
}
if (!normalizedOutput.commercialCore.coreBuyerProblem || !normalizedOutput.commercialCore.coreDifferentiator) {
throw new Error("Commercial demand output должен иметь commercialCore.coreBuyerProblem и coreDifferentiator.");
}
return {
...normalizedOutput,
generatedAt: new Date().toISOString(),
provider: {
message: providerMessage,
mode: providerMode,
modelRequired: true
}
};
}
function buildFallbackCommercialDemand(
projectId: string,
businessSynthesis: SeoBusinessSynthesisContract
): SeoCommercialDemandContract {
const task = buildSeoCommercialDemandTask(projectId, businessSynthesis);
const coreDifferentiator = businessSynthesis.productFrame.coreOffer;
return {
schemaVersion: "seo-commercial-demand.v1",
projectId,
semanticRunId: businessSynthesis.semanticRunId,
projectOntologyVersionId: businessSynthesis.projectOntologyVersionId,
generatedAt: new Date().toISOString(),
provider: {
mode: "deterministic_fallback",
modelRequired: true,
message: "Commercial demand fallback собран из business synthesis; model pass должен заменить архитектурные тезисы buyer-intent формулировками."
},
sourceTaskId: task.taskId,
analystReview: getDefaultAnalystReview(),
commercialCore: {
buyingTrigger: "Покупатель ищет практическое решение вокруг выявленного ядра продукта.",
confidence: businessSynthesis.productFrame.confidence,
coreBuyerProblem: businessSynthesis.productFrame.centralThesis,
coreCommercialValue: businessSynthesis.productFrame.coreOffer,
coreDifferentiator,
decisionContext: "Выбор, внедрение или пилот решения.",
evidenceRefs: businessSynthesis.productFrame.evidenceRefs.slice(0, 8)
},
buyerIntentFamilies: businessSynthesis.demandFamilies.slice(0, 10).map((family, index) => ({
buyerIntent: normalizeBuyerIntent(family.role, family.title),
commercialAngle: family.rationale,
coreQualifier: coreDifferentiator,
evidenceRefs: [],
excludedInformationalPatterns: [],
grounding: family.grounding,
id: `buyer.${family.id || index + 1}`,
priority: family.priority,
queryPatterns: family.phrases.slice(0, 8),
sourceMeaning: family.title,
title: family.title
})),
applicationCommercialAngles: businessSynthesis.semanticHierarchy.applicationVectors.slice(0, 10).map((vector, index) => ({
commercialReframe: vector.queryDirection,
coreQualifier: coreDifferentiator,
evidenceRefs: vector.evidenceRefs.slice(0, 8),
grounding: vector.grounding,
id: `angle.${vector.id || index + 1}`,
priority: vector.priority,
queryPatterns: [],
relationshipToCore: vector.relationshipToCore,
sourceVector: vector.title
})),
rejectedAngles: [],
guardrails: [
"Fallback не доказывает рынок и не запускает Wordstat/SERP.",
"Model pass должен заменить архитектурные тезисы покупательскими формулировками."
],
summary: businessSynthesis.summary,
nextActions: ["Запустить seo.commercial_demand через model provider перед normalization."]
};
}
export async function getLatestAlignedSeoCommercialDemand(
projectId: string,
semanticRunId: string
): Promise<SeoCommercialDemandRun | null> {
const result = await pool.query<SeoCommercialDemandRunRow>(
`
select id, status, input, output, error_message, started_at, completed_at, created_at
from runs
where project_id = $1
and run_type = 'seo_commercial_demand'
and output->>'semanticRunId' = $2
order by created_at desc
limit 1;
`,
[projectId, semanticRunId]
);
return result.rows[0] ? mapSeoCommercialDemandRun(result.rows[0]) : null;
}
export async function getSeoCommercialDemandContract(projectId: string): Promise<SeoCommercialDemandContract | null> {
const analysis = await getLatestSemanticAnalysis(projectId);
if (!analysis) {
return null;
}
const aligned = await getLatestAlignedSeoCommercialDemand(projectId, analysis.runId);
if (aligned) {
return aligned;
}
const businessSynthesis = await getSeoBusinessSynthesisContract(projectId);
return businessSynthesis ? buildFallbackCommercialDemand(projectId, businessSynthesis) : null;
}
export async function buildLatestSeoCommercialDemandTask(projectId: string): Promise<SeoCommercialDemandModelTaskContract | null> {
const analysis = await getLatestSemanticAnalysis(projectId);
if (!analysis) {
return null;
}
const businessSynthesis = await getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId);
return businessSynthesis ? buildSeoCommercialDemandTask(projectId, businessSynthesis) : null;
}
export async function saveSeoCommercialDemandFromModelProvider(
projectId: string,
output: SeoCommercialDemandContract,
sourceModelTaskRunId: string,
fallback: SeoCommercialDemandModelProviderFallback
) {
const normalizedOutput = normalizeCommercialDemandOutput(
projectId,
output,
"codex_workspace",
"AI Workspace Codex provider выполнил seo.commercial_demand; результат сохранён как buyer-intent evidence.",
fallback
);
const result = await pool.query<SeoCommercialDemandRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'seo_commercial_demand', '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-commercial-demand-model-provider-input.v1",
semanticRunId: normalizedOutput.semanticRunId,
sourceModelTaskRunId,
sourceTaskId: normalizedOutput.sourceTaskId,
taskType: "seo.commercial_demand"
}),
JSON.stringify(normalizedOutput)
]
);
const run = result.rows[0] ? mapSeoCommercialDemandRun(result.rows[0]) : null;
if (!run) {
throw new Error("Не удалось сохранить AI Workspace Commercial demand.");
}
return run;
}
export function buildSeoCommercialDemandPromotionFallback(
modelTask: SeoCommercialDemandModelTaskContract
): SeoCommercialDemandModelProviderFallback {
return {
modelTask,
projectOntologyVersionId: modelTask.projectOntologyVersionId,
semanticRunId: modelTask.semanticRunId
};
}

View File

@ -25,7 +25,7 @@ const envSchema = z.object({
WORDSTAT_REGION_IDS: z.string().default("225"),
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(40),
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(12),
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
SERP_API_BASE_URL: z.string().url().optional(),
SERP_API_TOKEN: z.string().optional(),

View File

@ -17,7 +17,14 @@ type SeoContextReviewRunRow = {
type ReviewLevel = "ok" | "warning" | "problem";
type ContextReviewConfidence = "low" | "medium" | "high";
type SeoContextReviewProviderMode = "codex_manual" | "deterministic_fallback";
type SeoContextReviewProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
type SeoContextReviewModelProviderFallback = {
projectOntologyVersionId: string | null;
scanVersionId: string;
semanticRunId: string;
sourceTaskId: string;
};
export type SeoContextReviewOutput = {
schemaVersion: "seo-context-review.v1";
@ -166,52 +173,316 @@ function getDefaultAnalystReview(): SeoContextReviewOutput["analystReview"] {
};
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function asStringArray(value: unknown): string[] {
return asArray(value).map(asString).filter((item): item is string => Boolean(item));
}
function asBoolean(value: unknown, fallback = false) {
return typeof value === "boolean" ? value : fallback;
}
function normalizeConfidence(value: unknown, fallback: ContextReviewConfidence = "medium"): ContextReviewConfidence {
const normalized = asString(value)?.toLowerCase();
if (normalized === "high" || normalized === "medium" || normalized === "low") {
return normalized;
}
return normalized === "moderate" ? "medium" : fallback;
}
function normalizeReviewLevel(value: unknown): ReviewLevel {
const normalized = asString(value)?.toLowerCase();
return normalized === "problem" || normalized === "warning" || normalized === "ok" ? normalized : "warning";
}
function normalizeEvidenceRefs(value: unknown): string[] {
return asArray(value)
.map((item, index) => {
if (typeof item === "string") {
return item.trim();
}
const record = asRecord(item);
const sourcePath = asString(record.sourcePath) ?? asString(record.ref) ?? asString(record.id);
const reason = asString(record.reason) ?? asString(record.title);
return [sourcePath, reason].filter(Boolean).join(" · ") || `evidence:${index + 1}`;
})
.filter(Boolean)
.slice(0, 24);
}
function normalizeContextReviewShape(
projectId: string,
output: SeoContextReviewOutput,
fallback?: SeoContextReviewModelProviderFallback
): SeoContextReviewOutput {
const rawOutput = asRecord(output);
const rawSiteIdentity = asRecord(rawOutput.siteIdentity);
const rawOffer = asRecord(rawOutput.offer);
const rawAudience = asRecord(rawOutput.audience);
const summary = asString(rawOutput.summary) ?? "Context Review model output сохранен после backend normalization.";
const siteIdentityDescription =
asString(rawSiteIdentity.description) ?? asString(rawSiteIdentity.identitySummary) ?? summary;
const offerSummary = asString(rawOffer.summary) ?? asString(rawOffer.primaryOffer) ?? summary;
const audiencePrimary =
asString(rawAudience.primary) ??
asStringArray(rawAudience.primaryAudiences)[0] ??
asStringArray(rawAudience.decisionMakers)[0] ??
"Целевая аудитория требует ручной проверки.";
const providerReadiness = asRecord(rawOutput.readiness);
const needsHumanReview = asBoolean(
rawOutput.needsHumanReview,
asBoolean(providerReadiness.needsHumanReview, true)
);
const confirmedClaims =
asArray(rawOffer.confirmedClaims).length > 0
? asArray(rawOffer.confirmedClaims).map((claim, index) => {
const record = asRecord(claim);
return {
confidence: normalizeConfidence(record.confidence),
evidenceRefs: normalizeEvidenceRefs(record.evidenceRefs),
id: asString(record.id) ?? `claim.${index + 1}`,
title: asString(record.title) ?? asString(record.claim) ?? `Подтвержденный смысл ${index + 1}`
};
})
: [
{
confidence: normalizeConfidence(rawOffer.confidence, "medium"),
evidenceRefs: normalizeEvidenceRefs(rawOffer.evidenceRefs),
id: "claim.primary_offer",
title: offerSummary
},
...asStringArray(rawOffer.supportingCapabilities)
.slice(0, 8)
.map((title, index) => ({
confidence: normalizeConfidence(rawOffer.confidence, "medium"),
evidenceRefs: normalizeEvidenceRefs(rawOffer.evidenceRefs),
id: `claim.capability.${index + 1}`,
title
}))
];
const pageRoles = asArray(rawOutput.pageRoles).map((role, index) => {
const record = asRecord(role);
const rawRole = asString(record.role);
const normalizedRole: SeoContextReviewOutput["pageRoles"][number]["role"] =
rawRole === "content_source" || asString(record.scope) === "content_source"
? "content_source"
: rawRole === "template_block" || asString(record.scope) === "template_block"
? "template_block"
: rawRole === "admin_or_technical" || rawRole === "non_seo_admin_surface" || asString(record.scope) === "admin_page"
? "admin_or_technical"
: rawRole === "primary_landing" || rawRole === "primary_product_landing" || index === 0
? "primary_landing"
: "supporting_page";
return {
evidenceRefs: normalizeEvidenceRefs(record.evidenceRefs),
pageId: asString(record.pageId) ?? asString(record.id) ?? `page.${index + 1}`,
path: asString(record.path) ?? asString(record.urlPath),
reason: asString(record.reason) ?? asString(record.risk) ?? "Model provider page role.",
role: normalizedRole,
scope: asString(record.scope) ?? "unknown",
title: asString(record.title) ?? `Страница ${index + 1}`
};
});
const sectionFindings = asArray(rawOutput.sectionFindings).map((finding, index) => {
const record = asRecord(finding);
const status = asString(record.status);
const evidenceLevel = asString(record.evidenceLevel);
const normalizedEvidenceLevel: SeoContextReviewOutput["sectionFindings"][number]["evidenceLevel"] =
evidenceLevel === "none" || evidenceLevel === "thin" || evidenceLevel === "moderate" || evidenceLevel === "strong"
? evidenceLevel
: normalizeConfidence(record.confidence) === "high"
? "moderate"
: "thin";
const normalizedStatus: SeoContextReviewOutput["sectionFindings"][number]["status"] =
status === "covered" || status === "weak" || status === "missing"
? status
: asBoolean(record.needsHumanReview)
? "weak"
: "covered";
return {
confidence: normalizeConfidence(record.confidence),
evidenceLevel: normalizedEvidenceLevel,
evidenceRefs: normalizeEvidenceRefs(record.evidenceRefs),
id: asString(record.id) ?? `section.${index + 1}`,
status: normalizedStatus,
targetIntent: asString(record.targetIntent) ?? asString(record.risk) ?? "Проверить смысл секции.",
title: asString(record.title) ?? `Секция ${index + 1}`
};
});
const semanticGaps = asArray(rawOutput.semanticGaps).map((gap, index) => {
const record = asRecord(gap);
const priority = asString(record.priority);
const normalizedPriority: SeoContextReviewOutput["semanticGaps"][number]["priority"] =
priority === "high" || priority === "medium" || priority === "low" ? priority : "medium";
return {
id: asString(record.id) ?? `gap.${index + 1}`,
priority: normalizedPriority,
reason: asString(record.reason) ?? asString(record.description) ?? "Нужна ручная проверка.",
suggestedReview: asString(record.suggestedReview) ?? "Проверить перед отправкой в спрос.",
title: asString(record.title) ?? `Семантический пробел ${index + 1}`
};
});
const forbiddenClaims = asArray(rawOutput.forbiddenClaims).map((claim, index) => {
const record = asRecord(claim);
const source = asString(record.source);
const normalizedSource: SeoContextReviewOutput["forbiddenClaims"][number]["source"] =
source === "semantic_gap" || source === "weak_evidence" || source === "guardrail" ? source : "guardrail";
return {
claim: typeof claim === "string" ? claim : asString(record.claim) ?? asString(record.title) ?? `Запрет ${index + 1}`,
id: asString(record.id) ?? `forbidden.${index + 1}`,
reason: asString(record.reason) ?? "Guardrail из Context Review.",
source: normalizedSource
};
});
const normalizationHints = asArray(rawOutput.normalizationHints).map((hint, index) => {
const record = asRecord(hint);
return {
clusterId: asString(record.clusterId) ?? asString(record.id) ?? `hint.${index + 1}`,
id: asString(record.id) ?? `hint.${index + 1}`,
marketAngles: asStringArray(record.marketAngles),
reason: asString(record.reason) ?? asString(record.hint) ?? "Normalization hint from model provider.",
reviewRequired: asBoolean(record.reviewRequired, true),
sourcePhrases: asStringArray(record.sourcePhrases),
stopPhrases: asStringArray(record.stopPhrases),
targetIntent: asString(record.targetIntent) ?? asString(record.title) ?? "Проверить нормализацию.",
title: asString(record.title) ?? `Подсказка нормализации ${index + 1}`
};
});
const risks = asArray(rawOutput.risks).map((risk, index) => {
const record = asRecord(risk);
return {
id: asString(record.id) ?? `risk.${index + 1}`,
level: normalizeReviewLevel(record.level),
message: asString(record.message) ?? asString(record.reason) ?? "Risk from model provider.",
title: asString(record.title) ?? `Риск ${index + 1}`
};
});
return {
...output,
analystReview: output.analystReview ?? getDefaultAnalystReview(),
audience: {
confidence: normalizeConfidence(rawAudience.confidence),
primary: audiencePrimary,
reason: asString(rawAudience.reason) ?? "Audience normalized from model provider output.",
secondary: asStringArray(rawAudience.secondary)
},
forbiddenClaims,
needsHumanReview,
normalizationHints,
offer: {
confirmedClaims,
summary: offerSummary,
weakClaims: asArray(rawOffer.weakClaims).map((claim, index) => {
const record = asRecord(claim);
return {
id: asString(record.id) ?? `weak_claim.${index + 1}`,
reason: asString(record.reason) ?? "Нужна проверка доказательности.",
title: asString(record.title) ?? asString(record.claim) ?? `Слабое утверждение ${index + 1}`
};
})
},
pageRoles,
projectId: asString(rawOutput.projectId) ?? projectId,
projectOntologyVersionId: asString(rawOutput.projectOntologyVersionId) ?? fallback?.projectOntologyVersionId ?? null,
readiness: {
evidenceRefCount: Number(providerReadiness.evidenceRefCount) || normalizeEvidenceRefs(rawSiteIdentity.evidenceRefs).length,
forbiddenClaimCount: forbiddenClaims.length,
needsHumanReview,
normalizationHintCount: normalizationHints.length,
pageRoleCount: pageRoles.length,
semanticGapCount: semanticGaps.length
},
risks,
scanVersionId: asString(rawOutput.scanVersionId) ?? fallback?.scanVersionId ?? "",
schemaVersion: "seo-context-review.v1",
sectionFindings,
semanticGaps,
semanticRunId: asString(rawOutput.semanticRunId) ?? fallback?.semanticRunId ?? "",
siteIdentity: {
brandName: asString(rawSiteIdentity.brandName) ?? "Проект",
confidence: normalizeConfidence(rawSiteIdentity.confidence),
description: siteIdentityDescription,
evidenceRefs: normalizeEvidenceRefs(rawSiteIdentity.evidenceRefs)
},
sourceTaskId: asString(rawOutput.sourceTaskId) ?? fallback?.sourceTaskId ?? "",
summary
};
}
function normalizeContextReviewOutput(
projectId: string,
output: SeoContextReviewOutput,
providerMode: SeoContextReviewProviderMode,
providerMessage: string
providerMessage: string,
fallback?: SeoContextReviewModelProviderFallback
): SeoContextReviewOutput {
if (output.schemaVersion !== "seo-context-review.v1") {
const normalizedShape = normalizeContextReviewShape(projectId, output, fallback);
if (normalizedShape.schemaVersion !== "seo-context-review.v1") {
throw new Error("Context Review output schemaVersion должен быть seo-context-review.v1.");
}
if (output.projectId !== projectId) {
if (normalizedShape.projectId !== projectId) {
throw new Error("Context Review output projectId не совпадает с проектом.");
}
if (!output.semanticRunId || !output.scanVersionId || !output.sourceTaskId) {
if (!normalizedShape.semanticRunId || !normalizedShape.scanVersionId || !normalizedShape.sourceTaskId) {
throw new Error("Context Review output должен иметь semanticRunId, scanVersionId и sourceTaskId.");
}
if (!output.siteIdentity?.description || !output.offer?.summary || !output.summary) {
if (!normalizedShape.siteIdentity?.description || !normalizedShape.offer?.summary || !normalizedShape.summary) {
throw new Error("Context Review output должен иметь siteIdentity.description, offer.summary и summary.");
}
if (!Array.isArray(output.pageRoles) || !Array.isArray(output.sectionFindings)) {
if (!Array.isArray(normalizedShape.pageRoles) || !Array.isArray(normalizedShape.sectionFindings)) {
throw new Error("Context Review output должен иметь pageRoles и sectionFindings.");
}
if (!Array.isArray(output.semanticGaps) || !Array.isArray(output.forbiddenClaims) || !Array.isArray(output.normalizationHints)) {
if (!Array.isArray(normalizedShape.semanticGaps) || !Array.isArray(normalizedShape.forbiddenClaims) || !Array.isArray(normalizedShape.normalizationHints)) {
throw new Error("Context Review output должен иметь semanticGaps, forbiddenClaims и normalizationHints.");
}
return {
...output,
...normalizedShape,
generatedAt: new Date().toISOString(),
provider: {
message: providerMessage,
mode: providerMode,
modelRequired: true
},
analystReview: output.analystReview ?? getDefaultAnalystReview(),
analystReview: normalizedShape.analystReview ?? getDefaultAnalystReview(),
readiness: {
...output.readiness,
forbiddenClaimCount: output.forbiddenClaims.length,
normalizationHintCount: output.normalizationHints.length,
pageRoleCount: output.pageRoles.length,
semanticGapCount: output.semanticGaps.length,
needsHumanReview: output.needsHumanReview
...normalizedShape.readiness,
forbiddenClaimCount: normalizedShape.forbiddenClaims.length,
normalizationHintCount: normalizedShape.normalizationHints.length,
pageRoleCount: normalizedShape.pageRoles.length,
semanticGapCount: normalizedShape.semanticGaps.length,
needsHumanReview: normalizedShape.needsHumanReview
}
};
}
@ -541,3 +812,45 @@ export async function saveSeoContextReviewManual(
return run;
}
export async function saveSeoContextReviewFromModelProvider(
projectId: string,
output: SeoContextReviewOutput,
sourceModelTaskRunId: string,
fallback: SeoContextReviewModelProviderFallback
): Promise<SeoContextReviewRun> {
const normalizedOutput = normalizeContextReviewOutput(
projectId,
output,
"codex_workspace",
"AI Workspace Codex provider вернул seo.context_review task как structured JSON; backend schema validation passed.",
fallback
);
const result = await pool.query<SeoContextReviewRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'seo_context_review', '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",
scanVersionId: normalizedOutput.scanVersionId,
schemaVersion: "seo-context-review-model-provider-input.v1",
semanticRunId: normalizedOutput.semanticRunId,
sourceModelTaskRunId,
sourceTaskId: normalizedOutput.sourceTaskId,
taskType: "seo.context_review"
}),
JSON.stringify(normalizedOutput)
]
);
const run = result.rows[0] ? mapSeoContextReviewRun(result.rows[0]) : null;
if (!run) {
throw new Error("Не удалось сохранить AI Workspace Context Review.");
}
return run;
}

View File

@ -1,9 +1,12 @@
import { pool } from "../db/client.js";
import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
type AnchorDecisionStatus = "approved" | "disabled" | "pending";
type AnchorDecisionStatus = "approved" | "deleted" | "disabled" | "pending";
type AnchorDecisionSource = "human" | "model" | "system";
type AnchorReviewState = "approved" | "empty" | "needs_review" | "not_ready";
export type DemandCollectionProfile = "contextual" | "market_wide";
export const DEFAULT_DEMAND_COLLECTION_PROFILE: DemandCollectionProfile = "contextual";
type SeoNormalizationSeed = SeoNormalizationContract["seedStrategy"][number];
export type AnchorReviewDecisionInput = {
@ -80,6 +83,7 @@ export type AnchorReviewContract = {
projectOntologyVersionId: string | null;
generatedAt: string;
state: AnchorReviewState;
demandCollectionProfile: DemandCollectionProfile;
source: {
normalizationGeneratedAt: string | null;
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
@ -104,6 +108,13 @@ export type AnchorReviewContract = {
manualDecisionCount: number;
};
anchors: AnchorReviewAnchor[];
deletedAnchors: Array<{
deletedAt: string | null;
id: string;
key: string;
phrase: string;
reason: string;
}>;
wordstatQueue: AnchorReviewWordstatQueueItem[];
nextActions: string[];
};
@ -124,6 +135,10 @@ function normalizePhrase(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
export function normalizeDemandCollectionProfile(value: unknown): DemandCollectionProfile {
return value === "market_wide" ? "market_wide" : DEFAULT_DEMAND_COLLECTION_PROFILE;
}
function getAnchorKey(seed: Pick<SeoNormalizationSeed, "clusterId" | "phrase">) {
return `${seed.clusterId}:${normalizePhrase(seed.phrase)}`;
}
@ -153,6 +168,18 @@ function getDefaultDecision(seed: SeoNormalizationSeed): AnchorReviewDecision {
}
function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorReviewDecision): AnchorReviewDecision {
if (decision.status === "deleted") {
return {
decidedAt: decision.decidedAt,
decidedBy: decision.decidedBy,
reason: decision.reason || "Anchor deleted by review.",
sendToSerp: false,
sendToWordstat: false,
source: decision.source,
status: "deleted"
};
}
if (decision.status === "approved") {
return {
decidedAt: decision.decidedAt,
@ -189,15 +216,47 @@ function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorRevi
}
function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] {
return (savedOutput?.anchors ?? []).map((anchor) => ({
const anchorSnapshots = (savedOutput?.anchors ?? []).map((anchor) => ({
anchor,
decision: anchor.decision,
id: anchor.id,
key: anchor.key
}));
const deletedSnapshots =
savedOutput?.deletedAnchors?.map((anchor) => ({
decision: {
decidedAt: anchor.deletedAt,
decidedBy: "dev-mode",
reason: anchor.reason || "Anchor deleted by review.",
sendToSerp: false,
sendToWordstat: false,
source: "human" as const,
status: "deleted" as const
},
id: anchor.id,
key: anchor.key
})) ?? [];
return [...anchorSnapshots, ...deletedSnapshots];
}
function getSavedDemandCollectionProfile(savedOutput: AnchorReviewContract | null): DemandCollectionProfile {
return normalizeDemandCollectionProfile(savedOutput?.demandCollectionProfile);
}
function normalizeSavedManualDecision(decision: AnchorReviewDecision): AnchorReviewDecision {
if (decision.status === "deleted") {
return {
decidedAt: decision.decidedAt,
decidedBy: decision.decidedBy,
reason: decision.reason || "Manual anchor deleted by review.",
sendToSerp: false,
sendToWordstat: false,
source: decision.source,
status: "deleted"
};
}
if (decision.status === "approved") {
return {
decidedAt: decision.decidedAt,
@ -312,6 +371,16 @@ function buildWordstatQueue(anchors: AnchorReviewAnchor[]): AnchorReviewWordstat
}));
}
function mapDeletedAnchor(anchor: AnchorReviewAnchor) {
return {
deletedAt: anchor.decision.decidedAt,
id: anchor.id,
key: anchor.key,
phrase: anchor.phrase,
reason: anchor.decision.reason || "Anchor deleted by review."
};
}
function getState(normalization: SeoNormalizationContract, anchors: AnchorReviewAnchor[], wordstatQueueCount: number): AnchorReviewState {
if (normalization.state !== "ready" || !normalization.semanticRunId) {
return "not_ready";
@ -324,6 +393,18 @@ function getState(normalization: SeoNormalizationContract, anchors: AnchorReview
return wordstatQueueCount > 0 ? "approved" : "needs_review";
}
function getStateFromContract(contract: AnchorReviewContract, anchors: AnchorReviewAnchor[], wordstatQueueCount: number): AnchorReviewState {
if (contract.source.normalizationState !== "ready" || !contract.semanticRunId) {
return "not_ready";
}
if (anchors.length === 0) {
return "empty";
}
return wordstatQueueCount > 0 ? "approved" : "needs_review";
}
function buildNextActions(contract: Omit<AnchorReviewContract, "nextActions">) {
const actions: string[] = [];
@ -360,7 +441,8 @@ function buildNextActions(contract: Omit<AnchorReviewContract, "nextActions">) {
function buildContract(
projectId: string,
normalization: SeoNormalizationContract,
decisionSnapshots: DecisionSnapshot[] = []
decisionSnapshots: DecisionSnapshot[] = [],
demandCollectionProfile: DemandCollectionProfile = DEFAULT_DEMAND_COLLECTION_PROFILE
): AnchorReviewContract {
const decisionSnapshotsById = new Map(decisionSnapshots.map((snapshot) => [snapshot.id, snapshot]));
const decisionSnapshotsByKey = new Map(decisionSnapshots.map((snapshot) => [snapshot.key, snapshot]));
@ -369,7 +451,11 @@ function buildContract(
);
const seedAnchorIds = new Set(anchors.map((anchor) => anchor.id));
const seedAnchorKeys = new Set(anchors.map((anchor) => anchor.key));
const allAnchors = [...anchors, ...getSavedManualAnchors(decisionSnapshots, seedAnchorIds, seedAnchorKeys)];
const allAnchorCandidates = [...anchors, ...getSavedManualAnchors(decisionSnapshots, seedAnchorIds, seedAnchorKeys)];
const allAnchors = allAnchorCandidates.filter((anchor) => anchor.decision.status !== "deleted");
const deletedAnchors = allAnchorCandidates
.filter((anchor) => anchor.decision.status === "deleted")
.map(mapDeletedAnchor);
const wordstatQueue = buildWordstatQueue(allAnchors);
const pendingAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "pending").length;
const approvedAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "approved").length;
@ -382,6 +468,7 @@ function buildContract(
projectOntologyVersionId: normalization.projectOntologyVersionId,
generatedAt: new Date().toISOString(),
state: getState(normalization, allAnchors, wordstatQueue.length),
demandCollectionProfile,
source: {
normalizationGeneratedAt: normalization.generatedAt,
normalizationProviderMode: normalization.provider.mode,
@ -409,6 +496,55 @@ function buildContract(
wordstatQueueCount: wordstatQueue.length
},
anchors: allAnchors,
deletedAnchors,
wordstatQueue
};
return {
...contractWithoutActions,
nextActions: buildNextActions(contractWithoutActions)
};
}
function rebuildContractWithAnchors(contract: AnchorReviewContract, anchors: AnchorReviewAnchor[]): AnchorReviewContract {
const deletedAnchorById = new Map((contract.deletedAnchors ?? []).map((anchor) => [anchor.id, anchor]));
anchors
.filter((anchor) => anchor.decision.status === "deleted")
.forEach((anchor) => deletedAnchorById.set(anchor.id, mapDeletedAnchor(anchor)));
const visibleAnchors = anchors.filter((anchor) => anchor.decision.status !== "deleted");
const wordstatQueue = buildWordstatQueue(visibleAnchors);
const pendingAnchorCount = visibleAnchors.filter((anchor) => anchor.decision.status === "pending").length;
const approvedAnchorCount = visibleAnchors.filter((anchor) => anchor.decision.status === "approved").length;
const disabledAnchorCount = visibleAnchors.filter((anchor) => anchor.decision.status === "disabled").length;
const manualDecisionCount = visibleAnchors.filter((anchor) => anchor.decision.decidedAt).length;
const contractWithoutActions: Omit<AnchorReviewContract, "nextActions"> = {
...contract,
generatedAt: new Date().toISOString(),
state: getStateFromContract(contract, visibleAnchors, wordstatQueue.length),
analystReview: {
notes:
wordstatQueue.length > 0
? ["Human-approved anchor queue exists. Market collection must use only wordstatQueue."]
: ["Anchor queue awaits human approval before market collection."],
reviewedAt: manualDecisionCount > 0 ? anchors.find((anchor) => anchor.decision.decidedAt)?.decision.decidedAt ?? null : null,
reviewer: manualDecisionCount > 0 ? "human" : "system",
status: wordstatQueue.length > 0 ? "approved" : "not_reviewed"
},
readiness: {
...contract.readiness,
approvedAnchorCount,
disabledAnchorCount,
manualDecisionCount,
needsHumanReviewCount: visibleAnchors.filter((anchor) => anchor.proposal.status === "needs_human").length,
normalizedAnchorCount: visibleAnchors.length,
pendingAnchorCount,
proposedWordstatCount: visibleAnchors.filter((anchor) => anchor.proposal.sendToWordstat).length,
wordstatQueueCount: wordstatQueue.length
},
anchors: visibleAnchors,
deletedAnchors: Array.from(deletedAnchorById.values()),
wordstatQueue
};
@ -446,7 +582,12 @@ export async function getAnchorReviewContract(projectId: string): Promise<Anchor
? await getLatestAlignedAnchorReviewOutput(projectId, normalization.semanticRunId)
: null;
return buildContract(projectId, normalization, getSavedDecisionSnapshots(savedOutput));
return buildContract(
projectId,
normalization,
getSavedDecisionSnapshots(savedOutput),
getSavedDemandCollectionProfile(savedOutput)
);
}
function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecisionInput, decidedAt: string): AnchorReviewDecision {
@ -484,6 +625,18 @@ function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecis
};
}
if (input.status === "deleted") {
return {
decidedAt,
decidedBy: "dev-mode",
reason: input.reason?.trim() || "Human deleted this anchor from semantic basis.",
sendToSerp: false,
sendToWordstat: false,
source: "human",
status: "deleted"
};
}
return {
decidedAt,
decidedBy: "dev-mode",
@ -495,9 +648,36 @@ function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecis
};
}
function applyDecisionInputsToContract(
contract: AnchorReviewContract,
inputs: AnchorReviewDecisionInput[],
decidedAt: string
): AnchorReviewContract {
if (inputs.length === 0) {
return contract;
}
const inputsByAnchorId = new Map(inputs.map((input) => [input.anchorId, input]));
const anchors = contract.anchors.map((anchor) => {
const input = inputsByAnchorId.get(anchor.id);
if (!input) {
return anchor;
}
return {
...anchor,
decision: applyDecisionInput(anchor, input, decidedAt)
};
});
return rebuildContractWithAnchors(contract, anchors);
}
export async function saveAnchorReviewDecisions(
projectId: string,
decisions: AnchorReviewDecisionInput[] = []
decisions?: AnchorReviewDecisionInput[],
demandCollectionProfile?: DemandCollectionProfile
): Promise<AnchorReviewContract> {
const current = await getAnchorReviewContract(projectId);
@ -508,18 +688,25 @@ export async function saveAnchorReviewDecisions(
const decidedAt = new Date().toISOString();
const anchorsById = new Map(current.anchors.map((anchor) => [anchor.id, anchor]));
const nextDecisionById = new Map(current.anchors.map((anchor) => [anchor.id, anchor.decision]));
const hasExplicitDecisions = Array.isArray(decisions);
const explicitDecisions = decisions ?? [];
const nextDemandCollectionProfile = normalizeDemandCollectionProfile(
demandCollectionProfile ?? current.demandCollectionProfile
);
const inputs =
decisions.length > 0
? decisions
: current.anchors
.filter((anchor) => anchor.proposal.sendToWordstat && anchor.proposal.status !== "rejected")
.map((anchor) => ({
anchorId: anchor.id,
reason: "Human approved proposed Wordstat anchor queue.",
sendToSerp: anchor.proposal.sendToSerp,
sendToWordstat: true,
status: "approved" as const
}));
explicitDecisions.length > 0
? explicitDecisions
: !hasExplicitDecisions && demandCollectionProfile === undefined
? current.anchors
.filter((anchor) => anchor.proposal.sendToWordstat && anchor.proposal.status !== "rejected")
.map((anchor) => ({
anchorId: anchor.id,
reason: "Human approved proposed Wordstat anchor queue.",
sendToSerp: anchor.proposal.sendToSerp,
sendToWordstat: true,
status: "approved" as const
}))
: [];
for (const input of inputs) {
const anchor = anchorsById.get(input.anchorId);
@ -531,14 +718,33 @@ export async function saveAnchorReviewDecisions(
nextDecisionById.set(anchor.id, applyDecisionInput(anchor, input, decidedAt));
}
const decisionSnapshots: DecisionSnapshot[] = current.anchors.map((anchor) => ({
anchor,
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
id: anchor.id,
key: anchor.key
}));
const decisionSnapshots: DecisionSnapshot[] = [
...current.anchors.map((anchor) => ({
anchor,
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
id: anchor.id,
key: anchor.key
})),
...(current.deletedAnchors ?? []).map((anchor) => ({
decision: {
decidedAt: anchor.deletedAt,
decidedBy: "dev-mode",
reason: anchor.reason || "Anchor deleted by review.",
sendToSerp: false,
sendToWordstat: false,
source: "human" as const,
status: "deleted" as const
},
id: anchor.id,
key: anchor.key
}))
];
const normalization = await getSeoNormalizationContract(projectId);
const output = buildContract(projectId, normalization, decisionSnapshots);
const output = applyDecisionInputsToContract(
buildContract(projectId, normalization, decisionSnapshots, nextDemandCollectionProfile),
inputs,
decidedAt
);
const result = await pool.query<AnchorReviewRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
@ -549,6 +755,7 @@ export async function saveAnchorReviewDecisions(
projectId,
JSON.stringify({
decisionCount: inputs.length,
demandCollectionProfile: output.demandCollectionProfile,
schemaVersion: "anchor-review-input.v1",
semanticRunId: output.semanticRunId
}),
@ -655,6 +862,19 @@ export async function addAnchorReviewManualAnchor(
id: anchor.id,
key: anchor.key
})),
...(current.deletedAnchors ?? []).map((anchor) => ({
decision: {
decidedAt: anchor.deletedAt,
decidedBy: "dev-mode",
reason: anchor.reason || "Anchor deleted by review.",
sendToSerp: false,
sendToWordstat: false,
source: "human" as const,
status: "deleted" as const
},
id: anchor.id,
key: anchor.key
})),
{
anchor: manualAnchor,
decision: manualAnchor.decision,
@ -663,7 +883,7 @@ export async function addAnchorReviewManualAnchor(
}
];
const normalization = await getSeoNormalizationContract(projectId);
const output = buildContract(projectId, normalization, decisionSnapshots);
const output = buildContract(projectId, normalization, decisionSnapshots, current.demandCollectionProfile);
const result = await pool.query<AnchorReviewRunRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)

View File

@ -0,0 +1,768 @@
import crypto from "node:crypto";
import { pool } from "../db/client.js";
import { getAnchorReviewContract } from "./anchorReview.js";
import { getKeywordCleaningByModelTaskRun, getKeywordCleaningContract } from "./keywordCleaning.js";
import { getMarketEnrichmentContract, runMarketEnrichment } from "../market/marketEnrichment.js";
import { getSeoModelProviderStatusContract, runSeoModelTask } from "../modelProvider/seoModelTaskRuns.js";
import { updateProjectOntologyApproval } from "../ontology/projectOntologyRepository.js";
type RunStatus = "queued" | "running" | "done" | "failed" | "cancelled";
type KeywordAnalysisWorkflowStatus = "blocked" | "completed" | "failed" | "queued" | "running" | "waiting";
type KeywordAnalysisWorkflowStepId =
| "semantic_basis"
| "project_context"
| "demand_collection"
| "keyword_cleaning"
| "keyword_proposal";
type KeywordAnalysisWorkflowStepStatus = "blocked" | "completed" | "failed" | "pending" | "running" | "waiting";
export type KeywordAnalysisWorkflowStep = {
id: KeywordAnalysisWorkflowStepId;
label: string;
status: KeywordAnalysisWorkflowStepStatus;
detail: string;
reason: string | null;
startedAt: string | null;
completedAt: string | null;
};
export type KeywordAnalysisWorkflowContract = {
schemaVersion: "keyword-analysis-workflow.v1";
runId: string;
projectId: string;
semanticRunId: string | null;
generatedAt: string;
status: KeywordAnalysisWorkflowStatus;
statusLabel: string;
activeStepId: KeywordAnalysisWorkflowStepId | null;
activeStepLabel: string | null;
summary: string;
reason: string | null;
steps: KeywordAnalysisWorkflowStep[];
artifacts: {
keywordCleaningRunId: string | null;
marketGeneratedAt: string | null;
modelTaskRunId: string | null;
wordstatJobId: string | null;
};
nextActions: string[];
};
type KeywordAnalysisWorkflowRunRow = {
id: string;
status: RunStatus;
output: KeywordAnalysisWorkflowContract | null;
error_message: string | null;
started_at: Date | null;
completed_at: Date | null;
created_at: Date;
};
const WORKFLOW_RUN_TYPE = "keyword_analysis_workflow";
const activeWorkflowExecutions = new Set<string>();
const WORKFLOW_STEPS: Array<Pick<KeywordAnalysisWorkflowStep, "detail" | "id" | "label">> = [
{
detail: "Проверяем, что пользователь выбрал смысловые якоря и разрешил очередь для анализа.",
id: "semantic_basis",
label: "Семантический базис"
},
{
detail: "Проверяем проектный контекст и готовность онтологии перед внешними данными.",
id: "project_context",
label: "Проектный контекст"
},
{
detail: "Собираем рыночную базу Wordstat по выбранным якорям, коммерческим формулировкам и смежным веткам.",
id: "demand_collection",
label: "Рыночный спрос"
},
{
detail: "Передаем собранный рынок в модельную очистку и группировку ключей.",
id: "keyword_cleaning",
label: "Разбор ключей"
},
{
detail: "Готовим человекочитаемое предложение по ключам для следующего этапа.",
id: "keyword_proposal",
label: "Предложение ключей"
}
];
function nowIso() {
return new Date().toISOString();
}
function getDemandCollectionProfileLabel(profile: "contextual" | "market_wide") {
return profile === "market_wide" ? "максимальный рыночный охват" : "контекстный охват";
}
function isModelTaskRunning(status: string) {
return status === "queued" || status === "running";
}
function getWorkflowStatusLabel(status: KeywordAnalysisWorkflowStatus) {
const labels: Record<KeywordAnalysisWorkflowStatus, string> = {
blocked: "Остановлено",
completed: "Анализ готов",
failed: "Ошибка",
queued: "В очереди",
running: "Идет анализ",
waiting: "Ожидаем результат"
};
return labels[status];
}
function getStepLabel(stepId: KeywordAnalysisWorkflowStepId) {
return WORKFLOW_STEPS.find((step) => step.id === stepId)?.label ?? null;
}
function buildInitialSteps(): KeywordAnalysisWorkflowStep[] {
return WORKFLOW_STEPS.map((step) => ({
...step,
completedAt: null,
reason: null,
startedAt: null,
status: "pending"
}));
}
function buildInitialWorkflow(projectId: string, runId: string): KeywordAnalysisWorkflowContract {
return {
schemaVersion: "keyword-analysis-workflow.v1",
runId,
projectId,
semanticRunId: null,
generatedAt: nowIso(),
status: "running",
statusLabel: getWorkflowStatusLabel("running"),
activeStepId: "semantic_basis",
activeStepLabel: getStepLabel("semantic_basis"),
summary: "Запускаем анализ выбранных ключей.",
reason: null,
steps: buildInitialSteps(),
artifacts: {
keywordCleaningRunId: null,
marketGeneratedAt: null,
modelTaskRunId: null,
wordstatJobId: null
},
nextActions: []
};
}
function setWorkflowStatus(
contract: KeywordAnalysisWorkflowContract,
status: KeywordAnalysisWorkflowStatus,
patch: Partial<Pick<KeywordAnalysisWorkflowContract, "activeStepId" | "reason" | "summary">> = {}
): KeywordAnalysisWorkflowContract {
const activeStepId = Object.hasOwn(patch, "activeStepId") ? (patch.activeStepId ?? null) : contract.activeStepId;
return {
...contract,
...patch,
activeStepId,
activeStepLabel: activeStepId ? getStepLabel(activeStepId) : null,
generatedAt: nowIso(),
status,
statusLabel: getWorkflowStatusLabel(status)
};
}
function setStepStatus(
contract: KeywordAnalysisWorkflowContract,
stepId: KeywordAnalysisWorkflowStepId,
status: KeywordAnalysisWorkflowStepStatus,
patch: Partial<Pick<KeywordAnalysisWorkflowStep, "detail" | "reason">> = {}
): KeywordAnalysisWorkflowContract {
const timestamp = nowIso();
return {
...contract,
activeStepId: status === "completed" ? contract.activeStepId : stepId,
activeStepLabel: status === "completed" ? contract.activeStepLabel : getStepLabel(stepId),
generatedAt: timestamp,
steps: contract.steps.map((step) => {
if (step.id !== stepId) {
return step;
}
return {
...step,
...patch,
completedAt:
status === "blocked" || status === "completed" || status === "failed" ? step.completedAt ?? timestamp : step.completedAt,
reason: Object.hasOwn(patch, "reason") ? (patch.reason ?? null) : step.reason,
startedAt: step.startedAt ?? timestamp,
status
};
})
};
}
async function saveWorkflow(
contract: KeywordAnalysisWorkflowContract,
runStatus: RunStatus,
errorMessage: string | null = null
) {
const completed = runStatus === "done" || runStatus === "failed" || runStatus === "cancelled";
const result = await pool.query<KeywordAnalysisWorkflowRunRow>(
`
update runs
set status = $2::run_status,
output = $3::jsonb,
error_message = $4,
completed_at = case when $5 then coalesce(completed_at, now()) else completed_at end
where id = $1 and run_type = '${WORKFLOW_RUN_TYPE}'
returning id, status, output, error_message, started_at, completed_at, created_at;
`,
[contract.runId, runStatus, JSON.stringify(contract), errorMessage, completed]
);
return mapWorkflowRow(result.rows[0]);
}
function mapWorkflowRow(row: KeywordAnalysisWorkflowRunRow | undefined) {
return row?.output?.schemaVersion === "keyword-analysis-workflow.v1" ? row.output : null;
}
async function getWorkflowRun(runId: string) {
const result = await pool.query<KeywordAnalysisWorkflowRunRow>(
`
select id, status, output, error_message, started_at, completed_at, created_at
from runs
where id = $1 and run_type = '${WORKFLOW_RUN_TYPE}'
limit 1;
`,
[runId]
);
return result.rows[0] ?? null;
}
async function blockWorkflow(
contract: KeywordAnalysisWorkflowContract,
stepId: KeywordAnalysisWorkflowStepId,
reason: string,
nextActions: string[] = []
) {
const blocked = setWorkflowStatus(setStepStatus(contract, stepId, "blocked", { reason }), "blocked", {
activeStepId: stepId,
reason,
summary: `${getStepLabel(stepId) ?? "Шаг"} остановлен: ${reason}`
});
blocked.nextActions = nextActions.length > 0 ? nextActions : [reason];
return saveWorkflow(blocked, "done");
}
async function failWorkflow(contract: KeywordAnalysisWorkflowContract, stepId: KeywordAnalysisWorkflowStepId, reason: string) {
const failed = setWorkflowStatus(setStepStatus(contract, stepId, "failed", { reason }), "failed", {
activeStepId: stepId,
reason,
summary: `${getStepLabel(stepId) ?? "Шаг"} завершился ошибкой: ${reason}`
});
failed.nextActions = ["Проверить ошибку шага и повторить анализ после исправления причины."];
return saveWorkflow(failed, "failed", reason);
}
async function ensureProjectContextReady(projectId: string) {
let market = await getMarketEnrichmentContract(projectId);
const ontologyVersion = market.projectOntologyVersion;
if (
ontologyVersion &&
ontologyVersion.approvalStatus === "draft" &&
ontologyVersion.reviewProblemCount === 0
) {
await updateProjectOntologyApproval({
approvedBy: "keyword-analysis-workflow",
projectId,
status: "needs_review",
versionId: ontologyVersion.id
});
market = await getMarketEnrichmentContract(projectId);
}
return market;
}
async function ensureMarketFrontierDiscoveryReady(
contract: KeywordAnalysisWorkflowContract,
projectId: string,
demandCollectionProfile: "contextual" | "market_wide"
): Promise<{ contract: KeywordAnalysisWorkflowContract; ready: boolean }> {
if (demandCollectionProfile !== "market_wide") {
return { contract, ready: true };
}
if (contract.artifacts.modelTaskRunId) {
const providerStatus = await getSeoModelProviderStatusContract(projectId);
const existingRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.modelTaskRunId);
if (existingRun && isModelTaskRunning(existingRun.runStatus)) {
const waiting = setWorkflowStatus(
setStepStatus(contract, "demand_collection", "waiting", {
reason: "AI Workspace строит market frontier discovery перед Wordstat."
}),
"waiting",
{
activeStepId: "demand_collection",
reason: "Ждём модельный план рыночных фронтиров.",
summary: "Модель строит память рынка, языковой fingerprint и probe plan перед Wordstat."
}
);
waiting.nextActions = ["Дождаться завершения seo.market_frontier_discovery; после этого workflow продолжит Wordstat-добор."];
await saveWorkflow(waiting, "running");
return { contract: waiting, ready: false };
}
return { contract, ready: true };
}
const modelTaskRun = await runSeoModelTask(projectId, {
providerId: "codex_workspace",
taskType: "seo.market_frontier_discovery"
});
const nextContract = {
...contract,
artifacts: {
...contract.artifacts,
modelTaskRunId: modelTaskRun.runId
}
};
if (isModelTaskRunning(modelTaskRun.runStatus)) {
const waiting = setWorkflowStatus(
setStepStatus(nextContract, "demand_collection", "waiting", {
reason: "AI Workspace принял market frontier discovery; ждём структурированный probe plan."
}),
"waiting",
{
activeStepId: "demand_collection",
reason: "Ждём модельный план рыночных фронтиров.",
summary: "Модель строит память рынка, языковой fingerprint и probe plan перед Wordstat."
}
);
waiting.nextActions = ["Дождаться завершения seo.market_frontier_discovery; после этого workflow продолжит Wordstat-добор."];
await saveWorkflow(waiting, "running");
return { contract: waiting, ready: false };
}
return { contract: nextContract, ready: true };
}
async function completeWorkflow(contract: KeywordAnalysisWorkflowContract) {
const completed = setWorkflowStatus(contract, "completed", {
activeStepId: null,
reason: null,
summary: "Анализ выбранных ключей завершен. Можно разбирать предложение по ключам."
});
completed.nextActions = ["Проверить предложенные ключи и перейти к распределению по посадочным."];
return saveWorkflow(completed, "done");
}
async function executeKeywordAnalysisWorkflow(runId: string, projectId: string) {
if (activeWorkflowExecutions.has(runId)) {
return;
}
activeWorkflowExecutions.add(runId);
try {
const row = await getWorkflowRun(runId);
let contract = mapWorkflowRow(row);
if (!contract) {
return;
}
contract = setWorkflowStatus(setStepStatus(contract, "semantic_basis", "running"), "running", {
activeStepId: "semantic_basis",
summary: "Проверяем семантический базис."
});
await saveWorkflow(contract, "running");
const anchorReview = await getAnchorReviewContract(projectId);
contract = {
...contract,
semanticRunId: anchorReview.semanticRunId
};
if (!anchorReview.semanticRunId) {
await blockWorkflow(contract, "semantic_basis", "Сначала нужен semantic analysis проекта.");
return;
}
if (anchorReview.state !== "approved" || anchorReview.readiness.wordstatQueueCount <= 0) {
await blockWorkflow(
contract,
"semantic_basis",
"Нужно выбрать хотя бы один ключ в семантическом базисе и отправить его на анализ."
);
return;
}
contract = setStepStatus(contract, "semantic_basis", "completed", {
detail: `Выбрано ${anchorReview.readiness.wordstatQueueCount} ключей для анализа.`
});
contract = setWorkflowStatus(setStepStatus(contract, "project_context", "running"), "running", {
activeStepId: "project_context",
summary: "Проверяем проектный контекст."
});
await saveWorkflow(contract, "running");
let market = await ensureProjectContextReady(projectId);
contract = {
...contract,
artifacts: {
...contract.artifacts,
marketGeneratedAt: market.generatedAt,
wordstatJobId: market.wordstat.latestJob?.id ?? null
}
};
if (!market.semanticRunId) {
await blockWorkflow(contract, "project_context", "Сначала нужен semantic analysis проекта.", market.nextActions);
return;
}
if (!market.readiness.ontologyReadyForMarket) {
await blockWorkflow(
contract,
"project_context",
"Проектный контекст не подтвержден для анализа спроса.",
market.nextActions
);
return;
}
contract = setStepStatus(contract, "project_context", "completed", {
detail: "Проектный контекст принят для анализа спроса."
});
contract = setWorkflowStatus(setStepStatus(contract, "demand_collection", "running"), "running", {
activeStepId: "demand_collection",
summary: "Собираем рыночную базу Wordstat."
});
await saveWorkflow(contract, "running");
if (!market.readiness.canCollectWordstat) {
await blockWorkflow(
contract,
"demand_collection",
"Сбор спроса сейчас недоступен: провайдер или очередь не готовы.",
market.nextActions
);
return;
}
const marketFrontierDiscovery = await ensureMarketFrontierDiscoveryReady(
contract,
projectId,
anchorReview.demandCollectionProfile
);
contract = marketFrontierDiscovery.contract;
if (!marketFrontierDiscovery.ready) {
return;
}
const wordstatJobIdBeforeCollection = market.wordstat.latestJob?.id ?? null;
market = await runMarketEnrichment(projectId);
contract = {
...contract,
artifacts: {
...contract.artifacts,
marketGeneratedAt: market.generatedAt,
wordstatJobId: market.wordstat.latestJob?.id ?? null
}
};
const latestWordstatJob = market.wordstat.latestJob;
const latestWordstatJobId = latestWordstatJob?.id ?? null;
const hasFreshWordstatFailure =
latestWordstatJob?.status === "failed" && latestWordstatJobId !== wordstatJobIdBeforeCollection;
const hasReusableWordstatEvidence = market.wordstat.summary.resultCount > 0;
if (hasFreshWordstatFailure || (latestWordstatJob?.status === "failed" && !hasReusableWordstatEvidence)) {
await failWorkflow(
contract,
"demand_collection",
latestWordstatJob.errorMessage ?? "Сбор спроса завершился ошибкой провайдера."
);
return;
}
if (!latestWordstatJob) {
await blockWorkflow(contract, "demand_collection", "Сбор спроса не стартовал.", market.nextActions);
return;
}
contract = setStepStatus(contract, "demand_collection", "completed", {
detail:
`Профиль: ${getDemandCollectionProfileLabel(anchorReview.demandCollectionProfile)}. ` +
`Собрано ${market.wordstat.summary.collectedSeedCount} seed-фраз; ` +
`точный спрос ${market.wordstat.summary.exactFrequencySeedCount}, ` +
`related-сигнал ${market.wordstat.summary.relatedOnlySeedCount}.` +
(market.wordstat.latestJob?.status === "failed"
? " Последний failed job старый; используем ранее собранное evidence для текущего сравнения."
: "")
});
contract = setWorkflowStatus(setStepStatus(contract, "keyword_cleaning", "running"), "running", {
activeStepId: "keyword_cleaning",
summary: "Готовим разбор ключей."
});
await saveWorkflow(contract, "running");
const modelTaskRun = await runSeoModelTask(projectId, { taskType: "seo.keyword_cleaning" });
contract = {
...contract,
artifacts: {
...contract.artifacts,
modelTaskRunId: modelTaskRun.runId
}
};
if (modelTaskRun.runStatus === "running") {
const waiting = setWorkflowStatus(
setStepStatus(contract, "keyword_cleaning", "waiting", {
reason: "AI Workspace принял задачу; ждем структурированный результат модели."
}),
"waiting",
{
activeStepId: "keyword_cleaning",
reason: "AI Workspace обрабатывает разбор ключей.",
summary: "Ждем модельный разбор ключей."
}
);
waiting.nextActions = ["Дождаться завершения AI Workspace task; workflow обновится при следующей проверке статуса."];
await saveWorkflow(waiting, "running");
return;
}
if (modelTaskRun.runStatus === "failed") {
const fallbackReason =
modelTaskRun.errorMessage ??
"Модельный разбор ключей не стартовал, используем backend fallback по собранному Wordstat evidence.";
const fallbackContract = setWorkflowStatus(
setStepStatus(contract, "keyword_cleaning", "running", {
detail: `AI Workspace недоступен: ${fallbackReason}. Собираем backend fallback по exact Wordstat evidence.`,
reason: fallbackReason
}),
"running",
{
activeStepId: "keyword_cleaning",
reason: fallbackReason,
summary: "Модельный разбор недоступен, собираем fallback-разбор ключей."
}
);
await saveWorkflow(fallbackContract, "running");
const completedFallback = await finishKeywordCleaningWorkflow(fallbackContract, projectId, null);
if (!completedFallback) {
await failWorkflow(fallbackContract, "keyword_cleaning", fallbackReason);
}
return;
}
await finishKeywordCleaningWorkflow(contract, projectId, modelTaskRun.runId);
} catch (error) {
const row = await getWorkflowRun(runId);
const contract = mapWorkflowRow(row);
if (contract) {
await failWorkflow(contract, contract.activeStepId ?? "keyword_cleaning", error instanceof Error ? error.message : "workflow_failed");
}
} finally {
activeWorkflowExecutions.delete(runId);
}
}
async function finishKeywordCleaningWorkflow(
contract: KeywordAnalysisWorkflowContract,
projectId: string,
expectedModelTaskRunId: string | null = contract.artifacts.modelTaskRunId
) {
const keywordCleaningMatch = expectedModelTaskRunId
? await getKeywordCleaningByModelTaskRun(projectId, expectedModelTaskRunId)
: null;
const keywordCleaning = expectedModelTaskRunId
? keywordCleaningMatch?.keywordCleaning ?? null
: await getKeywordCleaningContract(projectId);
if (!keywordCleaning) {
await blockWorkflow(
contract,
"keyword_cleaning",
"Разбор ключей для текущего запуска ещё не сохранён: ждём новый model-task result, старые данные не используются.",
[
"Дождаться завершения AI Workspace task и повторной проверки workflow.",
"Если task завершился без сохранения keyword cleaning, повторить отправку на анализ."
]
);
return null;
}
if (keywordCleaning.state !== "ready") {
await blockWorkflow(
contract,
"keyword_cleaning",
"Разбор ключей пока не готов: модельный результат не сохранен или не прошел backend-проверку.",
keywordCleaning.nextActions
);
return null;
}
let nextContract: KeywordAnalysisWorkflowContract = {
...contract,
artifacts: {
...contract.artifacts,
keywordCleaningRunId: keywordCleaningMatch?.runId ?? null
}
};
nextContract = setStepStatus(nextContract, "keyword_cleaning", "completed", {
detail: `Разобрано ${keywordCleaning.readiness.sourcePhraseCount} ключей.`
});
nextContract = setStepStatus(nextContract, "keyword_proposal", "running");
nextContract = setStepStatus(nextContract, "keyword_proposal", "completed", {
detail: `Предложение готово: ${keywordCleaning.readiness.useCount} основных, ${keywordCleaning.readiness.supportCount} поддерживающих.`
});
return completeWorkflow(nextContract);
}
async function refreshWaitingWorkflow(contract: KeywordAnalysisWorkflowContract) {
if (contract.status !== "waiting" || !contract.artifacts.modelTaskRunId) {
return contract;
}
const providerStatus = await getSeoModelProviderStatusContract(contract.projectId);
const modelTaskRun = providerStatus.latestRuns.find((run) => run.runId === contract.artifacts.modelTaskRunId) ?? null;
if (!modelTaskRun || isModelTaskRunning(modelTaskRun.runStatus)) {
return contract;
}
if (contract.activeStepId === "demand_collection") {
const taskDidNotFinish = modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled";
const detail = taskDidNotFinish
? `Market frontier discovery не завершился: ${modelTaskRun.errorMessage ?? "нет provider result"}. Продолжаем через deterministic fallback.`
: "Market frontier discovery готов; продолжаем Wordstat-добор по памяти рынка, fingerprint и probe plan.";
const resumed = setWorkflowStatus(
setStepStatus(contract, "demand_collection", "running", {
detail,
reason: taskDidNotFinish ? modelTaskRun.errorMessage ?? "market_frontier_discovery_not_completed" : null
}),
"running",
{
activeStepId: "demand_collection",
reason: taskDidNotFinish ? modelTaskRun.errorMessage ?? "market_frontier_discovery_not_completed" : null,
summary: taskDidNotFinish
? "Market frontier discovery недоступен, продолжаем Wordstat через fallback-план."
: "Продолжаем Wordstat-добор по market frontier discovery."
}
);
resumed.nextActions = ["Workflow продолжит Wordstat-добор; повторно нажимать кнопку не нужно."];
const saved = await saveWorkflow(resumed, "running");
void executeKeywordAnalysisWorkflow(contract.runId, contract.projectId);
return saved ?? resumed;
}
if (contract.activeStepId !== "keyword_cleaning") {
return contract;
}
if (modelTaskRun.runStatus === "failed" || modelTaskRun.runStatus === "cancelled") {
const failed = await failWorkflow(
contract,
"keyword_cleaning",
modelTaskRun.errorMessage ?? "Модельный разбор ключей не завершился успешно."
);
return failed ?? contract;
}
return (await finishKeywordCleaningWorkflow(contract, contract.projectId)) ?? contract;
}
export async function getLatestKeywordAnalysisWorkflow(projectId: string): Promise<KeywordAnalysisWorkflowContract | null> {
const result = await pool.query<KeywordAnalysisWorkflowRunRow>(
`
select id, status, output, error_message, started_at, completed_at, created_at
from runs
where project_id = $1 and run_type = '${WORKFLOW_RUN_TYPE}'
order by created_at desc
limit 1;
`,
[projectId]
);
const row = result.rows[0] ?? null;
const contract = mapWorkflowRow(row ?? undefined);
if (!row || !contract) {
return null;
}
if (row.status === "running" && contract.status === "waiting") {
return refreshWaitingWorkflow(contract);
}
if (row.status === "running" && (contract.status === "queued" || contract.status === "running") && !activeWorkflowExecutions.has(row.id)) {
void executeKeywordAnalysisWorkflow(row.id, projectId);
}
return contract;
}
export async function startKeywordAnalysisWorkflow(projectId: string): Promise<KeywordAnalysisWorkflowContract> {
const existing = await pool.query<KeywordAnalysisWorkflowRunRow>(
`
select id, status, output, error_message, started_at, completed_at, created_at
from runs
where project_id = $1
and run_type = '${WORKFLOW_RUN_TYPE}'
and status in ('queued', 'running')
order by created_at desc
limit 1;
`,
[projectId]
);
const existingContract = mapWorkflowRow(existing.rows[0]);
if (existing.rows[0] && existingContract) {
void executeKeywordAnalysisWorkflow(existing.rows[0].id, projectId);
return existingContract;
}
const runId = crypto.randomUUID();
const output = buildInitialWorkflow(projectId, runId);
await pool.query(
`
insert into runs (id, project_id, run_type, status, input, output, started_at)
values ($1, $2, '${WORKFLOW_RUN_TYPE}', 'running', $3::jsonb, $4::jsonb, now());
`,
[
runId,
projectId,
JSON.stringify({
schemaVersion: "keyword-analysis-workflow-input.v1",
trigger: "semantic_basis_submitted"
}),
JSON.stringify(output)
]
);
void executeKeywordAnalysisWorkflow(runId, projectId);
return output;
}

View File

@ -1,5 +1,6 @@
import { pool } from "../db/client.js";
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
import { hasEducationIntentMismatch } from "./keywordIntentGuards.js";
type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
@ -220,6 +221,11 @@ const CONSUMER_AI_NOISE_PATTERNS = [
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 KEYWORD_CLEANING_MODEL_APPROVED_ANCHOR_LIMIT = 60;
const KEYWORD_CLEANING_MODEL_LANDING_BRIEF_LIMIT = 18;
const KEYWORD_CLEANING_MODEL_NORMALIZATION_GROUP_LIMIT = 24;
const KEYWORD_CLEANING_MODEL_SEED_QUEUE_LIMIT = 150;
const KEYWORD_CLEANING_MODEL_TOP_RESULT_LIMIT = 120;
const ARTICLE_PATTERNS = [
/(^|\s)(как|что такое|что\s+это|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд|определен[а-яёa-z0-9-]*|вопрос[а-яёa-z0-9-]*|ответ[а-яёa-z0-9-]*)(\s|$)/i,
@ -258,6 +264,12 @@ function wordCount(value: string) {
return normalizePhrase(value).split(/\s+/).filter(Boolean).length;
}
function truncateTaskText(value: string | null | undefined, limit = 120) {
const normalized = String(value ?? "").trim().replace(/\s+/g, " ");
return normalized.length > limit ? `${normalized.slice(0, limit - 1)}` : normalized;
}
const KEYWORD_CLEANING_FACET_STOP_WORDS = new Set([
"a",
"an",
@ -575,7 +587,7 @@ function enforceKeywordCleaningSemanticGuard(
});
}
function shouldRescueExactTrashItem(item: KeywordCleaningItem) {
function shouldRescueExactTrashItem(market: MarketEnrichmentContract, item: KeywordCleaningItem) {
return (
item.decision === "trash" &&
item.evidenceStatus === "collected" &&
@ -587,6 +599,7 @@ function shouldRescueExactTrashItem(item: KeywordCleaningItem) {
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
!hasEducationIntentMismatch(market, item.normalizedPhrase) &&
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
!isBroadAiKeywordNoise(item.normalizedPhrase)
);
@ -599,6 +612,7 @@ function isBroadHeadDemandItem(item: KeywordCleaningItem) {
}
function shouldPromoteExactDemandItem(
market: MarketEnrichmentContract,
item: KeywordCleaningItem,
contextTokens: Set<string>,
guard: KeywordCleaningFacetGuard
@ -621,14 +635,15 @@ function shouldPromoteExactDemandItem(
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
!hasEducationIntentMismatch(market, item.normalizedPhrase) &&
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
!isBroadAiKeywordNoise(item.normalizedPhrase)
);
}
function rescueExactTrashItems(items: KeywordCleaningItem[]): KeywordCleaningItem[] {
function rescueExactTrashItems(market: MarketEnrichmentContract, items: KeywordCleaningItem[]): KeywordCleaningItem[] {
return items.map((item) => {
if (!shouldRescueExactTrashItem(item)) {
if (!shouldRescueExactTrashItem(market, item)) {
return item;
}
@ -648,7 +663,7 @@ function promoteExactDemandItems(market: MarketEnrichmentContract, items: Keywor
const guard = buildKeywordCleaningFacetGuard(market);
return items.map((item) => {
if (!shouldPromoteExactDemandItem(item, contextTokens, guard)) {
if (!shouldPromoteExactDemandItem(market, item, contextTokens, guard)) {
return item;
}
@ -667,20 +682,26 @@ function enforceKeywordCleaningBackendGuards(
market: MarketEnrichmentContract,
items: KeywordCleaningItem[]
): KeywordCleaningItem[] {
return promoteExactDemandItems(market, rescueExactTrashItems(enforceKeywordCleaningSemanticGuard(market, items)));
return promoteExactDemandItems(market, rescueExactTrashItems(market, enforceKeywordCleaningSemanticGuard(market, items)));
}
function getKeywordCleaningMergeKeys(item: KeywordCleaningItem) {
return [item.wordstatResultId ? `wordstat:${item.wordstatResultId}` : null, `phrase:${item.normalizedPhrase}`].filter(Boolean);
}
function shouldBackfillMissingExactItem(item: KeywordCleaningItem) {
function shouldBackfillMissingExactItem(market: MarketEnrichmentContract, item: KeywordCleaningItem) {
return (
item.decision !== "trash" &&
(item.decision === "use" || item.decision === "support") &&
item.evidenceStatus === "collected" &&
item.frequency !== null &&
Boolean(item.wordstatResultId) &&
wordCount(item.phrase) > 1
wordCount(item.phrase) > 1 &&
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
!hasEducationIntentMismatch(market, item.normalizedPhrase) &&
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
!isBroadAiKeywordNoise(item.normalizedPhrase)
);
}
@ -690,7 +711,7 @@ function mergeMissingExactEvidenceItems(
): KeywordCleaningItem[] {
const knownKeys = new Set(modelItems.flatMap(getKeywordCleaningMergeKeys));
const missingItems = classifyItems(market, getBaseItems(market))
.filter(shouldBackfillMissingExactItem)
.filter((item) => shouldBackfillMissingExactItem(market, item))
.filter((item) => getKeywordCleaningMergeKeys(item).every((key) => !knownKeys.has(key)))
.sort((left, right) => {
const leftPriority = left.priority === "high" ? 3 : left.priority === "medium" ? 2 : 1;
@ -698,11 +719,11 @@ function mergeMissingExactEvidenceItems(
return rightPriority - leftPriority || (right.frequency ?? 0) - (left.frequency ?? 0);
})
.slice(0, 80)
.slice(0, 24)
.map((item) => ({
...item,
evidenceRefs: unique([...item.evidenceRefs, "backend:missing-exact-evidence-backfill"]),
reason: `Backend evidence добавил exact Wordstat item, который модель не вернула в keyword_cleaning. ${item.reason}`
reason: `Backend audit сохранил high-fit exact Wordstat evidence, который модель не вернула в keyword_cleaning. ${item.reason}`
}));
return [...modelItems, ...missingItems];
@ -886,13 +907,13 @@ function getBaseItems(contract: MarketEnrichmentContract) {
const briefPhraseMap = buildBriefPhraseMap(contract);
const briefClusterMap = buildBriefClusterMap(contract);
const seedItems = contract.seedQueue
.slice(0, 160)
.slice(0, 260)
.map((seed, index) => buildSeedItem(seed, index, briefPhraseMap, briefClusterMap));
const seedByPhrase = new Map(contract.seedQueue.map((seed) => [normalizePhrase(seed.phrase), seed]));
const seen = new Set(seedItems.map((item) => item.normalizedPhrase));
const relatedItems: KeywordCleaningItem[] = [];
for (const [index, result] of contract.wordstat.topResults.slice(0, 120).entries()) {
for (const [index, result] of contract.wordstat.topResults.slice(0, 220).entries()) {
const normalizedResult = normalizePhrase(result.phrase);
if (seen.has(normalizedResult)) {
@ -965,41 +986,84 @@ function mergeModelProviderItemsWithMarketEvidence(
});
}
function getKeywordCleaningFrequencyScore(frequency: number | null) {
if (frequency === null) return 0;
if (frequency >= 5000) return 42;
if (frequency >= 1000) return 34;
if (frequency >= 100) return 26;
if (frequency >= 20) return 18;
if (frequency > 0) return 10;
return 0;
}
function getKeywordCleaningModelSeedScore(seed: SeedQueueItem) {
return (
getKeywordCleaningFrequencyScore(seed.frequency) +
(seed.evidenceStatus === "collected" ? 36 : 0) +
(seed.wordstatResultId ? 24 : 0) +
(seed.normalization?.marketRole === "commercial" ? 14 : 0) +
(seed.normalization?.marketRole === "core" ? 10 : 0) +
(seed.priority === "high" ? 12 : seed.priority === "medium" ? 6 : 0) +
Math.min(seed.relatedCount ?? 0, 10)
);
}
function getKeywordCleaningModelTopResultScore(result: WordstatTopResult) {
return (
getKeywordCleaningFrequencyScore(result.frequency) +
(result.frequency !== null ? 30 : 0) +
(result.sourcePhrase ? 10 : 0) +
(result.sourceClusterId ? 6 : 0) +
(hasB2BKeywordFacet(result.phrase) ? 8 : 0) -
(isConsumerAiNoisePhrase(result.phrase) ? 50 : 0)
);
}
function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): KeywordCleaningModelTaskContract["input"] {
const briefPhraseMap = buildBriefPhraseMap(contract);
const briefClusterMap = buildBriefClusterMap(contract);
const modelSeedQueue = contract.seedQueue
.map((seed, index) => ({ index, score: getKeywordCleaningModelSeedScore(seed), seed }))
.sort((left, right) => right.score - left.score || left.index - right.index)
.slice(0, KEYWORD_CLEANING_MODEL_SEED_QUEUE_LIMIT)
.map((item) => item.seed);
const modelTopResults = contract.wordstat.topResults
.map((result, index) => ({ index, result, score: getKeywordCleaningModelTopResultScore(result) }))
.sort((left, right) => right.score - left.score || left.index - right.index)
.slice(0, KEYWORD_CLEANING_MODEL_TOP_RESULT_LIMIT)
.map((item) => item.result);
return {
approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, 80).map((anchor) => ({
approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, KEYWORD_CLEANING_MODEL_APPROVED_ANCHOR_LIMIT).map((anchor) => ({
clusterId: anchor.clusterId,
clusterTitle: anchor.clusterTitle,
clusterTitle: truncateTaskText(anchor.clusterTitle),
confidence: anchor.confidence,
intentType: anchor.intentType,
marketRole: anchor.marketRole,
normalizedFrom: anchor.normalizedFrom,
normalizedFrom: anchor.normalizedFrom.slice(0, 3).map((phrase) => truncateTaskText(phrase)),
phrase: anchor.phrase,
priority: anchor.priority
})),
landingBriefs: contract.briefEvidence.slice(0, 40).map((brief) => ({
action: brief.action,
landingBriefs: contract.briefEvidence.slice(0, KEYWORD_CLEANING_MODEL_LANDING_BRIEF_LIMIT).map((brief) => ({
action: truncateTaskText(brief.action, 160),
evidenceStatus: brief.evidenceStatus,
path: brief.path,
priority: brief.priority,
qualityScore: brief.qualityScore,
seedPhrases: brief.seedPhrases.slice(0, 10)
seedPhrases: brief.seedPhrases.slice(0, 6).map((phrase) => truncateTaskText(phrase))
})),
normalizationIntentGroups: contract.normalization.intentGroups.slice(0, 40).map((group) => ({
excludedPhrases: group.excludedPhrases.slice(0, 12).map((item) => item.phrase),
normalizationIntentGroups: contract.normalization.intentGroups.slice(0, KEYWORD_CLEANING_MODEL_NORMALIZATION_GROUP_LIMIT).map((group) => ({
excludedPhrases: group.excludedPhrases.slice(0, 6).map((item) => truncateTaskText(item.phrase)),
id: group.id,
intentType: group.intentType,
marketHypothesis: group.marketHypothesis,
marketHypothesis: truncateTaskText(group.marketHypothesis, 180),
priority: group.priority,
title: group.title,
wordstatQueries: group.wordstatQueries.slice(0, 12)
title: truncateTaskText(group.title),
wordstatQueries: group.wordstatQueries.slice(0, 6).map((query) => truncateTaskText(query))
})),
seedQueue: contract.seedQueue.slice(0, 120).map((seed) => ({
seedQueue: modelSeedQueue.map((seed) => ({
clusterId: seed.clusterId,
clusterTitle: seed.clusterTitle,
clusterTitle: truncateTaskText(seed.clusterTitle),
evidenceStatus: seed.evidenceStatus,
frequency: seed.frequency,
frequencyGroup: seed.frequencyGroup,
@ -1013,14 +1077,14 @@ function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): Key
targetPath: getTargetPath(seed, briefPhraseMap, briefClusterMap),
wordstatResultId: seed.wordstatResultId
})),
wordstatTopResults: contract.wordstat.topResults.slice(0, 120).map((result) => ({
wordstatTopResults: modelTopResults.map((result) => ({
frequency: result.frequency,
frequencyGroup: result.frequencyGroup,
id: result.id,
phrase: result.phrase,
region: result.region,
sourceClusterId: result.sourceClusterId,
sourceClusterTitle: result.sourceClusterTitle,
sourceClusterTitle: truncateTaskText(result.sourceClusterTitle),
sourcePhrase: result.sourcePhrase
}))
};
@ -1046,7 +1110,7 @@ function isBroadAiKeywordNoise(phrase: string | null) {
return AI_KEYWORD_PATTERN.test(normalizedPhrase) && !hasB2BKeywordFacet(normalizedPhrase);
}
function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>) {
function getClassification(contract: MarketEnrichmentContract, item: KeywordCleaningItem, contextTokens: Set<string>) {
const blockers: string[] = [];
const exactEvidence = item.frequency !== null;
const noExternalSignal =
@ -1110,6 +1174,16 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>
};
}
if (hasEducationIntentMismatch(contract, item.normalizedPhrase)) {
return {
blockers: unique([...blockers, "education_intent_not_in_product_offer"]),
confidence: exactEvidence ? 0.72 : 0.58,
decision: "article" as const,
reason:
"Фраза относится к обучению/курсам, но продуктовая нормализация сайта не содержит образовательного offer; держим как backlog/контент-гипотезу, не как продуктовую карту Stage 3."
};
}
if (isArticle && exactEvidence) {
return {
blockers,
@ -1151,7 +1225,7 @@ function classifyItems(contract: MarketEnrichmentContract, items: KeywordCleanin
const contextTokens = buildContextTokenSet(contract);
return items.map((item) => {
const classification = getClassification(item, contextTokens);
const classification = getClassification(contract, item, contextTokens);
return {
...item,
@ -1181,7 +1255,8 @@ function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningCont
(item) =>
(item.decision === "use" || item.decision === "support") &&
item.evidenceStatus === "collected" &&
item.frequency !== null
item.frequency !== null &&
Boolean(item.wordstatResultId)
).length,
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
riskyCount: lanes.risky.length,
@ -1263,9 +1338,11 @@ function buildKeywordCleaningModelTask(
"Не придумывать спрос без Wordstat/SERP evidence.",
"Stage 5 не выбирает посадочную автоматически: exact-фразы без targetPath можно раскладывать в keyword map, target/landing решает стратегия.",
"Не пропускать exact Wordstat items молча: для каждой значимой exact-фразы из seedQueue/topResults вернуть item с use/support/article/risky/trash и причиной.",
"Если модель не вернула exact-фразу, backend может сохранить её только как audit evidence; Stage 3 не должен показывать такие строки пользователю как candidates.",
"Stage 5 собирает глобальную карту спроса, а не rewrite текущей посадочной: частотные exact B2B-смежные ветки с сохранённым source-фасетом должны попадать минимум в support/risky, а не исчезать в article только из-за соседнего рынка.",
"Article/backlog использовать для информационного контента и гипотез без сильного коммерческого/продуктового спроса; не маркировать article каждую подтверждённую смежную B2B-ветку.",
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
"Образовательный спрос (курсы, обучение, тренинги, сертификаты) не отправлять в product keyword map, если Product Understanding/normalization не подтверждает образовательный offer сайта.",
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря; глобальный доминантный термин проекта не должен запрещать отдельные подтверждённые ветки спроса.",
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
"Не сохранять rewrite/apply decisions."
@ -1303,7 +1380,7 @@ function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">
}
actions.push(
`В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} exact-кандидатов в 5 колонок; финальные роли фиксирует пользователь.`
`В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} кандидатов в 5 колонок; exact-frequency закрепляется строже, финальные роли фиксирует пользователь.`
);
return actions;
@ -1467,6 +1544,29 @@ export async function getLatestAlignedKeywordCleaning(
return run;
}
export async function getKeywordCleaningByModelTaskRun(
projectId: string,
sourceModelTaskRunId: string
): Promise<{ keywordCleaning: KeywordCleaningContract; runId: string } | null> {
const result = await pool.query<KeywordCleaningRunRow>(
`
select id, status, input, output, error_message, started_at, completed_at, created_at
from runs
where project_id = $1
and run_type = 'keyword_cleaning'
and input->>'sourceModelTaskRunId' = $2
order by created_at desc
limit 1;
`,
[projectId, sourceModelTaskRunId]
);
const row = result.rows[0] ?? null;
const keywordCleaning = row ? mapKeywordCleaningRun(row) : null;
return row && keywordCleaning ? { keywordCleaning, runId: row.id } : null;
}
export async function getKeywordCleaningContract(projectId: string): Promise<KeywordCleaningContract> {
const market = await getMarketEnrichmentContract(projectId);
const fallbackContract = buildFallbackContract(projectId, market);

View File

@ -0,0 +1,261 @@
import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js";
import { pool } from "../db/client.js";
import { getMarketEnrichmentContract } from "../market/marketEnrichment.js";
import { getAnchorReviewContract } from "./anchorReview.js";
import { getKeywordCleaningContract } from "./keywordCleaning.js";
import { getKeywordMapContract } from "./keywordMap.js";
type KeywordContextSnapshotInput = {
label?: string;
note?: string;
source?: "manual_save" | "expansion_checkpoint";
curation?: {
roleOverrides?: Array<{
itemId: string;
role: string;
}>;
suppressedItemIds?: string[];
};
};
type KeywordContextSnapshotCurationInput = NonNullable<KeywordContextSnapshotInput["curation"]>;
export type KeywordContextSnapshotSummary = {
id: string;
label: string;
source: "manual_save" | "expansion_checkpoint";
createdAt: string;
semanticRunId: string | null;
keywordMapGeneratedAt: string | null;
keywordCount: number;
keywordPhrases: string[];
suppressedCount: number;
roleOverrideCount: number;
};
type KeywordContextSnapshotRow = {
id: string;
input: {
label?: string;
source?: "manual_save" | "expansion_checkpoint";
};
output: {
semanticRunId?: string | null;
keywordMap?: {
generatedAt?: string | null;
items?: unknown[];
};
curation?: {
roleOverrides?: unknown[];
suppressedItemIds?: unknown[];
};
} | null;
created_at: Date;
};
function getSnapshotKeywordPhrases(row: KeywordContextSnapshotRow) {
const items = row.output?.keywordMap?.items;
if (!Array.isArray(items)) {
return [];
}
return items
.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) {
return null;
}
const phrase = (item as { phrase?: unknown }).phrase;
return typeof phrase === "string" && phrase.trim() ? phrase : null;
})
.filter((phrase): phrase is string => Boolean(phrase));
}
function mapSnapshotRow(row: KeywordContextSnapshotRow): KeywordContextSnapshotSummary {
const curation = row.output?.curation;
const keywordPhrases = getSnapshotKeywordPhrases(row);
return {
id: row.id,
label: row.input.label || `Ключи и спрос · ${row.created_at.toLocaleString("ru-RU")}`,
source: row.input.source ?? "manual_save",
createdAt: row.created_at.toISOString(),
semanticRunId: row.output?.semanticRunId ?? null,
keywordMapGeneratedAt: row.output?.keywordMap?.generatedAt ?? null,
keywordCount: keywordPhrases.length,
keywordPhrases,
roleOverrideCount: Array.isArray(curation?.roleOverrides) ? curation.roleOverrides.length : 0,
suppressedCount: Array.isArray(curation?.suppressedItemIds) ? curation.suppressedItemIds.length : 0
};
}
export async function listKeywordContextSnapshots(projectId: string): Promise<KeywordContextSnapshotSummary[]> {
const result = await pool.query<KeywordContextSnapshotRow>(
`
select id, input, output, created_at
from runs
where project_id = $1
and run_type = 'keyword_context_snapshot'
and status = 'done'
order by created_at desc
limit 30;
`,
[projectId]
);
return result.rows.map(mapSnapshotRow);
}
export async function saveKeywordContextSnapshot(
projectId: string,
input: KeywordContextSnapshotInput
): Promise<KeywordContextSnapshotSummary> {
const [semanticAnalysis, anchorReview, marketEnrichment, keywordCleaning, keywordMap] = await Promise.all([
getLatestSemanticAnalysis(projectId),
getAnchorReviewContract(projectId),
getMarketEnrichmentContract(projectId),
getKeywordCleaningContract(projectId),
getKeywordMapContract(projectId)
]);
const label = input.label?.trim() || `Ключи и спрос · ${new Date().toLocaleString("ru-RU")}`;
const snapshot = {
schemaVersion: "keyword-context-snapshot.v1",
projectId,
createdAt: new Date().toISOString(),
label,
note: input.note?.trim() || null,
source: input.source ?? "manual_save",
semanticRunId: keywordMap.semanticRunId ?? anchorReview.semanticRunId ?? semanticAnalysis?.runId ?? null,
semanticAnalysis,
anchorReview,
marketEnrichment,
keywordCleaning,
keywordMap,
curation: {
roleOverrides: input.curation?.roleOverrides ?? [],
suppressedItemIds: input.curation?.suppressedItemIds ?? []
},
replayContract: {
purpose: "Reproduce Stage 5 context for keyword analysis, frontier expansion and model debugging.",
modelMustKnow: [
"Исходный контекст сайта и semantic analysis.",
"Какие semantic anchors пользователь подтвердил, скрыл или оставил на разборе.",
"Какой профиль сбора спроса выбран.",
"Какие Wordstat evidence уже собраны и какие seed-фразы дали no signal.",
"Какая текущая keyword map раскладка сохранена пользователем."
],
expansionRule:
"Дополнительное расширение должно выбирать новые frontier seed-фразы из реального Wordstat evidence, не повторяя уже проверенную траекторию и не сбрасывая пользовательскую раскладку."
}
};
const result = await pool.query<KeywordContextSnapshotRow>(
`
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
values ($1, 'keyword_context_snapshot', 'done', $2::jsonb, $3::jsonb, now(), now())
returning id, input, output, created_at;
`,
[
projectId,
JSON.stringify({
label,
schemaVersion: "keyword-context-snapshot-input.v1",
source: input.source ?? "manual_save"
}),
JSON.stringify(snapshot)
]
);
const row = result.rows[0];
if (!row) {
throw new Error("Не удалось сохранить snapshot ключей и спроса.");
}
return mapSnapshotRow(row);
}
export async function renameKeywordContextSnapshot(
projectId: string,
snapshotId: string,
label: string
): Promise<KeywordContextSnapshotSummary> {
const normalizedLabel = label.trim();
if (!normalizedLabel) {
throw new Error("Нужно указать название snapshot-профиля.");
}
const result = await pool.query<KeywordContextSnapshotRow>(
`
update runs
set input = jsonb_set(coalesce(input, '{}'::jsonb), '{label}', to_jsonb($3::text), true),
output = jsonb_set(coalesce(output, '{}'::jsonb), '{label}', to_jsonb($3::text), true),
completed_at = now()
where id = $2
and project_id = $1
and run_type = 'keyword_context_snapshot'
and status = 'done'
returning id, input, output, created_at;
`,
[projectId, snapshotId, normalizedLabel]
);
const row = result.rows[0];
if (!row) {
throw new Error("Snapshot-профиль не найден.");
}
return mapSnapshotRow(row);
}
export async function updateKeywordContextSnapshotCuration(
projectId: string,
snapshotId: string,
curation: KeywordContextSnapshotCurationInput
): Promise<KeywordContextSnapshotSummary> {
const normalizedCuration = {
roleOverrides: curation.roleOverrides ?? [],
savedAt: new Date().toISOString(),
suppressedItemIds: curation.suppressedItemIds ?? []
};
const result = await pool.query<KeywordContextSnapshotRow>(
`
update runs
set output = jsonb_set(coalesce(output, '{}'::jsonb), '{curation}', $3::jsonb, true),
completed_at = now()
where id = $2
and project_id = $1
and run_type = 'keyword_context_snapshot'
and status = 'done'
returning id, input, output, created_at;
`,
[projectId, snapshotId, JSON.stringify(normalizedCuration)]
);
const row = result.rows[0];
if (!row) {
throw new Error("Snapshot-профиль не найден.");
}
return mapSnapshotRow(row);
}
export async function deleteKeywordContextSnapshot(projectId: string, snapshotId: string) {
const result = await pool.query(
`
update runs
set status = 'cancelled',
completed_at = now()
where id = $2
and project_id = $1
and run_type = 'keyword_context_snapshot'
and status = 'done';
`,
[projectId, snapshotId]
);
if (result.rowCount === 0) {
throw new Error("Snapshot-профиль не найден.");
}
}

View File

@ -0,0 +1,108 @@
import type { MarketEnrichmentContract } from "../market/marketEnrichment.js";
const EDUCATION_SEARCH_INTENT_PATTERN =
/(^|\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+квалификац[а-яёa-z0-9-]*|професси[яию]|course|courses|training|academy|bootcamp|certification)(\s|$)/i;
const EDUCATION_OFFER_CONTEXT_PATTERN =
/(^|\s)(курс[а-яёa-z0-9-]*|образовательн[а-яёa-z0-9-]*|учебн[а-яёa-z0-9-]*\s+(центр|программ|курс)|академи[яи]|школа|тренинг[а-яёa-z0-9-]*|семинар[а-яёa-z0-9-]*|вебинар[а-яёa-z0-9-]*|сертификат[а-яёa-z0-9-]*|повышени[а-яёa-z0-9-]*\s+квалификац[а-яёa-z0-9-]*|наставничеств[а-яёa-z0-9-]*|практикум[а-яёa-z0-9-]*|обучени[а-яёa-z0-9-]*\s+(сотрудник[а-яёa-z0-9-]*|специалист[а-яёa-z0-9-]*|пользовател[а-яёa-z0-9-]*|клиент[а-яёa-z0-9-]*|команд[а-яёa-z0-9-]*|персонал[а-яёa-z0-9-]*|работе|по)|course|courses|training|academy|bootcamp|certification)(\s|$)/i;
const MODEL_TRAINING_CONTEXT_PATTERN =
/(^|\s)(машинн[а-яёa-z0-9-]*\s+обучени[а-яёa-z0-9-]*|обучени[а-яёa-z0-9-]*\s+(модел[а-яёa-z0-9-]*|нейросет[а-яёa-z0-9-]*|алгоритм[а-яёa-z0-9-]*|model|models|algorithm[а-яёa-z0-9-]*))(\s|$)/gi;
function normalizeIntentText(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
function pushText(parts: string[], value: string | null | undefined) {
const normalized = normalizeIntentText(String(value ?? ""));
if (normalized) {
parts.push(normalized);
}
}
function buildProductOfferContextText(contract: MarketEnrichmentContract) {
const parts: string[] = [];
const modelInput = contract.normalization.modelTask?.input;
pushText(parts, contract.normalization.summary);
for (const group of contract.normalization.intentGroups) {
pushText(parts, group.title);
pushText(parts, group.marketHypothesis);
for (const phrase of group.corePhrases) {
pushText(parts, phrase);
}
}
pushText(parts, modelInput?.contextReview.summary);
pushText(parts, modelInput?.businessSynthesis.summary);
pushText(parts, modelInput?.businessSynthesis.productFrame?.coreOffer);
pushText(parts, modelInput?.businessSynthesis.productFrame?.productCategory);
pushText(parts, modelInput?.businessSynthesis.productFrame?.centralThesis);
pushText(parts, modelInput?.commercialDemand.summary);
pushText(parts, modelInput?.commercialDemand.commercialCore?.coreBuyerProblem);
pushText(parts, modelInput?.commercialDemand.commercialCore?.coreCommercialValue);
pushText(parts, modelInput?.commercialDemand.commercialCore?.coreDifferentiator);
pushText(parts, modelInput?.commercialDemand.commercialCore?.decisionContext);
for (const pillar of modelInput?.businessSynthesis.corePillars ?? []) {
pushText(parts, pillar.title);
pushText(parts, pillar.role);
pushText(parts, pillar.grounding);
}
for (const vector of modelInput?.businessSynthesis.applicationVectors ?? []) {
pushText(parts, vector.title);
pushText(parts, vector.relationshipToCore);
pushText(parts, vector.grounding);
pushText(parts, vector.queryDirection);
}
for (const family of modelInput?.businessSynthesis.demandFamilies ?? []) {
pushText(parts, family.title);
pushText(parts, family.role);
pushText(parts, family.grounding);
pushText(parts, family.rationale);
for (const phrase of family.phrases) {
pushText(parts, phrase);
}
}
for (const family of modelInput?.commercialDemand.buyerIntentFamilies ?? []) {
pushText(parts, family.title);
pushText(parts, family.buyerIntent);
pushText(parts, family.commercialAngle);
pushText(parts, family.sourceMeaning);
pushText(parts, family.coreQualifier);
}
for (const angle of modelInput?.commercialDemand.applicationCommercialAngles ?? []) {
pushText(parts, angle.sourceVector);
pushText(parts, angle.relationshipToCore);
pushText(parts, angle.grounding);
pushText(parts, angle.commercialReframe);
pushText(parts, angle.coreQualifier);
}
return parts.join(" ");
}
export function hasEducationSearchIntent(phrase: string) {
const normalizedPhrase = normalizeIntentText(phrase);
const phraseWithoutModelTraining = normalizedPhrase.replace(MODEL_TRAINING_CONTEXT_PATTERN, " ");
return EDUCATION_SEARCH_INTENT_PATTERN.test(phraseWithoutModelTraining);
}
export function hasEducationOfferContext(contract: MarketEnrichmentContract) {
const productContext = buildProductOfferContextText(contract).replace(MODEL_TRAINING_CONTEXT_PATTERN, " ");
return EDUCATION_OFFER_CONTEXT_PATTERN.test(productContext);
}
export function hasEducationIntentMismatch(contract: MarketEnrichmentContract, phrase: string) {
return hasEducationSearchIntent(phrase) && !hasEducationOfferContext(contract);
}

View File

@ -1,6 +1,7 @@
import { pool } from "../db/client.js";
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js";
import { hasEducationIntentMismatch } from "./keywordIntentGuards.js";
type KeywordMapState = "blocked" | "draft";
type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
@ -82,6 +83,15 @@ export type KeywordMapPersistResult = {
type KeywordMapItem = KeywordMapContract["items"][number];
type KeywordCleaningItem = KeywordCleaningContract["items"][number];
type PersistedKeywordMapLayout = {
reason: string | null;
relatedLanding: string | null;
role: KeywordMapRole;
targetPath: string | null;
};
type KeywordRoleContext = {
market: MarketEnrichmentContract;
};
export type KeywordMapApproveInput = {
itemIds?: string[];
@ -89,12 +99,39 @@ export type KeywordMapApproveInput = {
itemId: string;
role: KeywordMapRole;
}>;
suppressedItemIds?: string[];
};
function isKeywordMapRole(value: string): value is KeywordMapRole {
return keywordMapRoles.includes(value as KeywordMapRole);
}
function normalizeKeywordMapDecisionPayload(payload: unknown): Record<string, unknown> {
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
return payload as Record<string, unknown>;
}
if (typeof payload === "string") {
try {
const parsed = JSON.parse(payload) as unknown;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
return {};
}
}
return {};
}
function getKeywordMapPayloadRole(payload: unknown) {
const role = normalizeKeywordMapDecisionPayload(payload).role;
return typeof role === "string" && isKeywordMapRole(role) ? role : null;
}
function normalizePhrase(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
@ -371,6 +408,10 @@ function getKeywordDecisionRouteWeight(decision: KeywordCleaningItem["decision"]
return 0;
}
function isBackendMissingExactBackfillItem(item: KeywordCleaningItem) {
return item.evidenceRefs.some((ref) => ref === "backend:missing-exact-evidence-backfill");
}
function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGuard) {
const semanticFit = guard ? getSemanticFitAssessment(item, guard) : null;
@ -384,41 +425,137 @@ function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGu
);
}
function hasProjectFacetForPrimary(item: KeywordCleaningItem, guard: SemanticFacetGuard) {
const semanticFit = getSemanticFitAssessment(item, guard);
return semanticFit.projectCoverage === null || semanticFit.projectCoverage > 0;
}
function isKeywordMapReviewCandidate(item: KeywordCleaningItem, guard: SemanticFacetGuard) {
const semanticFit = getSemanticFitAssessment(item, guard);
return (
item.decision === "risky" &&
item.evidenceStatus === "collected" &&
item.frequency !== null &&
Boolean(item.projectOntologyVersionId) &&
Boolean(item.wordstatResultId) &&
semanticFit.label !== "lost_source_facet"
);
}
function hasExactWordstatDemandEvidence(item: KeywordCleaningItem) {
return (
item.evidenceStatus === "collected" &&
item.frequency !== null &&
Boolean(item.wordstatResultId)
);
}
function isKeywordMapDisplayCandidate(item: KeywordCleaningItem, guard: SemanticFacetGuard, roleContext: KeywordRoleContext) {
const semanticFit = getSemanticFitAssessment(item, guard);
if (
item.decision === "trash" ||
isBackendMissingExactBackfillItem(item) ||
!hasExactWordstatDemandEvidence(item) ||
!item.projectOntologyVersionId ||
semanticFit.label === "lost_source_facet" ||
hasEducationIntentMismatch(roleContext.market, item.phrase)
) {
return false;
}
if (canModelRouteKeyword(item, guard) || isKeywordMapReviewCandidate(item, guard)) {
return true;
}
return item.decision === "use" || item.decision === "support";
}
function isLongTailKeywordCandidate(item: KeywordCleaningItem) {
return item.priority === "low" || getKeywordWordCount(item.phrase) >= 5 || (item.frequency !== null && item.frequency < 50);
}
function isLowFrequencyOnlyKeywordCandidate(item: KeywordCleaningItem) {
return item.priority === "low" || (item.frequency !== null && item.frequency < 50);
}
function isCommercialActionKeywordCandidate(item: KeywordCleaningItem) {
const text = normalizePhrase(item.phrase);
return (
item.marketRole === "commercial" ||
item.intentType === "commercial" ||
/(услуг[а-яёa-z0-9-]*|заказать|купить|стоимост[а-яёa-z0-9-]*|цен[а-яёa-z0-9-]*|под\s+ключ|компани[а-яёa-z0-9-]*\s+по|интегратор[а-яёa-z0-9-]*|внедрен[а-яёa-z0-9-]*|разработк[а-яёa-z0-9-]*|автоматизац[а-яёa-z0-9-]*)/i.test(
text
)
);
}
function isEditorialOrLocalTailKeywordCandidate(item: KeywordCleaningItem) {
const text = normalizePhrase(item.phrase);
return /(кейс[а-яёa-z0-9-]*|пример[а-яёa-z0-9-]*|риск[а-яёa-z0-9-]*|специалист[а-яёa-z0-9-]*|компани[а-яёa-z0-9-]*|рынок\s+заказн[а-яёa-z0-9-]*|обзор[а-яёa-z0-9-]*|сравнен[а-яёa-z0-9-]*|рейтинг[а-яёa-z0-9-]*|топ\s+|екатеринбург|ростов|москва|санкт|спб|unity|игр[а-яёa-z0-9-]*)/i.test(
text
);
}
function isClarifyingKeywordCandidate(item: KeywordCleaningItem) {
const text = getKeywordProfileText(item);
const text = normalizePhrase(item.phrase);
return (
item.marketRole === "integration" ||
item.intentType === "deployment_security" ||
item.intentType === "integration" ||
/(^|\s)(api|bim|crm|erp|mcp|iot|1с|3d|digital twin|on[-\s]?premise)(\s|$)/i.test(text) ||
/(интеграц|архитект|безопас|облак|workspace|документооборот|утп|дифференц|цифров[а-яёa-z0-9-]*\s+двойн|инженер|тендер|закуп|юрид|договор|беспилот|телеметр|строител|эксплуатац)/i.test(
/(интеграц|архитект|безопас|облак|workspace|документооборот|утп|дифференц|цифров[а-яёa-z0-9-]*\s+двойн|инженер|тендер|закуп|юрид|договор|беспилот|телеметр|строител|эксплуатац|промышлен|производств|учет|отчет|логистик|ритейл|медицин)/i.test(
text
)
);
}
function getKeywordRole(item: KeywordCleaningItem, indexInTarget: number, guard: SemanticFacetGuard): KeywordMapRole {
function getKeywordRole(
item: KeywordCleaningItem,
indexInTarget: number,
guard: SemanticFacetGuard,
roleContext: KeywordRoleContext
): KeywordMapRole {
if (!canModelRouteKeyword(item, guard)) {
return "validate";
}
if (item.decision === "use" && indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) {
return "primary";
if (hasEducationIntentMismatch(roleContext.market, item.phrase)) {
return "validate";
}
if (isLongTailKeywordCandidate(item)) {
return "support";
if (
indexInTarget === 0 &&
(item.decision === "use" || item.decision === "support") &&
(item.priority === "high" || item.priority === "medium") &&
!isLongTailKeywordCandidate(item) &&
!isClarifyingKeywordCandidate(item) &&
hasProjectFacetForPrimary(item, guard)
) {
return "primary";
}
if (isClarifyingKeywordCandidate(item)) {
return "differentiator";
}
if (isEditorialOrLocalTailKeywordCandidate(item)) {
return "support";
}
if (isCommercialActionKeywordCandidate(item)) {
return "secondary";
}
if (isLowFrequencyOnlyKeywordCandidate(item)) {
return "support";
}
if (indexInTarget <= 7 && (item.priority === "high" || item.priority === "medium") && item.decision !== "article") {
return "secondary";
}
@ -463,15 +600,10 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
const targetCounts = new Map<string, number>();
const semanticFacetGuard = buildSemanticFacetGuard(contract);
const roleContext: KeywordRoleContext = { market: contract };
const seenPhrases = new Set<string>();
const sourceItems = cleaning.items
.filter(
(item) =>
item.decision !== "trash" &&
item.evidenceStatus === "collected" &&
item.frequency !== null &&
Boolean(item.wordstatResultId)
)
.filter((item) => isKeywordMapDisplayCandidate(item, semanticFacetGuard, roleContext))
.sort((left, right) => {
const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
const rightRouteWeight = canModelRouteKeyword(right, semanticFacetGuard) ? 1 : 0;
@ -526,7 +658,7 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
const rawTargetPath = item.targetPath;
const targetKey = rawTargetPath ?? item.clusterId;
const indexInTarget = targetCounts.get(targetKey) ?? 0;
const role = getKeywordRole(item, indexInTarget, semanticFacetGuard);
const role = getKeywordRole(item, indexInTarget, semanticFacetGuard, roleContext);
const targetPath = role === "secondary_candidate" ? null : rawTargetPath;
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null;
@ -831,6 +963,109 @@ async function getKeywordMapPersistenceSummary(
};
}
async function getSuppressedKeywordMapItems(
projectId: string,
semanticRunId: string | null,
projectOntologyVersionId: string | null
) {
const emptySuppressed = {
normalizedPhrases: new Set<string>(),
sourceItemIds: new Set<string>()
};
if (!semanticRunId || !projectOntologyVersionId) {
return emptySuppressed;
}
const result = await pool.query<{ phrase: string | null; source_item_id: string | null }>(
`
select source_item_id, phrase
from keyword_decisions
where project_id = $1
and semantic_run_id = $2
and project_ontology_version_id = $3
and status = 'suppressed'
and (source_item_id is not null or nullif(trim(phrase), '') is not null);
`,
[projectId, semanticRunId, projectOntologyVersionId]
);
return {
normalizedPhrases: new Set(
result.rows
.map((row) => (row.phrase ? normalizePhrase(row.phrase) : null))
.filter((phrase): phrase is string => Boolean(phrase))
),
sourceItemIds: new Set(result.rows.map((row) => row.source_item_id).filter((id): id is string => Boolean(id)))
};
}
async function getPersistedKeywordMapLayouts(
projectId: string,
semanticRunId: string | null,
projectOntologyVersionId: string | null
) {
if (!semanticRunId || !projectOntologyVersionId) {
return new Map<string, PersistedKeywordMapLayout>();
}
const result = await pool.query<{
source_item_id: string;
target_path: string | null;
related_landing: string | null;
reason: string | null;
payload: unknown;
}>(
`
select
source_item_id,
target_path,
payload->>'relatedLanding' as related_landing,
reason,
payload
from keyword_decisions
where project_id = $1
and semantic_run_id = $2
and project_ontology_version_id = $3
and source_item_id is not null
and (
(status = 'approved' and approved = true)
or status = 'layout'
);
`,
[projectId, semanticRunId, projectOntologyVersionId]
);
const layouts = new Map<string, PersistedKeywordMapLayout>();
for (const row of result.rows) {
const role = getKeywordMapPayloadRole(row.payload);
if (!role) {
continue;
}
layouts.set(row.source_item_id, {
reason: row.reason,
relatedLanding: row.related_landing,
role,
targetPath: row.target_path
});
}
return layouts;
}
function applyPersistedKeywordMapLayout(item: KeywordMapItem, layout: PersistedKeywordMapLayout): KeywordMapItem {
const nonRewriteRole = layout.role === "validate" || layout.role === "secondary_candidate";
return {
...item,
relatedLanding: layout.relatedLanding ?? (nonRewriteRole ? (item.relatedLanding ?? item.targetPath) : item.relatedLanding),
role: layout.role,
targetPath: nonRewriteRole ? null : (layout.targetPath ?? item.targetPath ?? item.relatedLanding)
};
}
function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
if (contract.blockers.length > 0) {
return contract.blockers;
@ -840,7 +1075,7 @@ function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
if (contract.cleaning.keywordMapCandidateCount === 0) {
actions.push(
"Keyword cleaning не дал exact-кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
"Keyword cleaning не дал кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
);
if (contract.readiness.marketEvidenceCount === 0) {
@ -880,12 +1115,23 @@ export async function getKeywordMapContract(projectId: string): Promise<KeywordM
]);
const reviewedOntology = isReviewedOntology(marketContract.projectOntologyVersion);
const blockers = buildBlockers(marketContract, cleaningContract);
const items = buildItems(marketContract, cleaningContract, reviewedOntology);
const persisted = await getKeywordMapPersistenceSummary(
projectId,
marketContract.semanticRunId,
marketContract.projectOntologyVersion?.id ?? null
);
const semanticRunId = marketContract.semanticRunId;
const projectOntologyVersionId = marketContract.projectOntologyVersion?.id ?? null;
const [suppressedItems, persistedLayouts, persisted] = await Promise.all([
getSuppressedKeywordMapItems(projectId, semanticRunId, projectOntologyVersionId),
getPersistedKeywordMapLayouts(projectId, semanticRunId, projectOntologyVersionId),
getKeywordMapPersistenceSummary(projectId, semanticRunId, projectOntologyVersionId)
]);
const items = buildItems(marketContract, cleaningContract, reviewedOntology)
.map((item) => {
const persistedLayout = persistedLayouts.get(item.id);
return persistedLayout ? applyPersistedKeywordMapLayout(item, persistedLayout) : item;
})
.filter(
(item) =>
!suppressedItems.sourceItemIds.has(item.id) && !suppressedItems.normalizedPhrases.has(normalizePhrase(item.phrase))
);
const contractWithoutActions: Omit<KeywordMapContract, "nextActions"> = {
schemaVersion: "keyword-map.v1",
projectId,
@ -935,6 +1181,7 @@ export async function approveKeywordMapDecisions(
.filter((override) => isKeywordMapRole(override.role))
.map((override) => [override.itemId, override.role] as const)
);
const suppressedItemIds = new Set(input.suppressedItemIds ?? []);
const selectedItems = keywordMap.items
.filter((item) => !itemIdFilter || itemIdFilter.has(item.id))
.map((item) => {
@ -942,9 +1189,17 @@ export async function approveKeywordMapDecisions(
return roleOverride ? applyKeywordMapRoleOverride(item, roleOverride) : item;
});
const persistableItems = selectedItems.filter(isPersistableKeywordMapItem);
const suppressedItems = selectedItems.filter((item) => suppressedItemIds.has(item.id));
const persistableItems = selectedItems.filter((item) => !suppressedItemIds.has(item.id) && isPersistableKeywordMapItem(item));
const canApproveMap = keywordMap.blockers.length === 0;
const approvableItems = canApproveMap ? persistableItems : [];
const layoutItems = selectedItems.filter(
(item) =>
!suppressedItemIds.has(item.id) &&
(!isPersistableKeywordMapItem(item) || (!canApproveMap && roleOverrideByItemId.has(item.id)))
);
if (keywordMap.blockers.length > 0 || !keywordMap.semanticRunId || !keywordMap.projectOntologyVersion) {
if (!keywordMap.semanticRunId || !keywordMap.projectOntologyVersion) {
return {
keywordMap,
savedDecisionCount: 0,
@ -960,9 +1215,9 @@ export async function approveKeywordMapDecisions(
let savedDecisionCount = 0;
let savedMapItemCount = 0;
const activeSourceItemIds = persistableItems.map((item) => item.id);
const activeSourceItemIds = approvableItems.map((item) => item.id);
if (!itemIdFilter) {
if (!itemIdFilter && canApproveMap) {
if (activeSourceItemIds.length > 0) {
await client.query(
`
@ -1016,7 +1271,166 @@ export async function approveKeywordMapDecisions(
}
}
for (const item of persistableItems) {
for (const item of suppressedItems) {
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 approved = true;
`,
[projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id, item.id]
);
await client.query(
`
insert into keyword_decisions (
project_id,
semantic_run_id,
project_ontology_version_id,
wordstat_result_id,
source_item_id,
cluster_id,
cluster_title,
target_path,
phrase,
status,
reason,
approved,
evidence_status,
frequency,
frequency_group,
payload,
updated_at
)
values ($1, $2, $3, $4, $5, $6, $7, null, $8, 'suppressed', $9, false, $10, $11, $12, $13::jsonb, now())
on conflict (project_id, project_ontology_version_id, source_item_id)
where source_item_id is not null
do update set
semantic_run_id = excluded.semantic_run_id,
wordstat_result_id = excluded.wordstat_result_id,
cluster_id = excluded.cluster_id,
cluster_title = excluded.cluster_title,
target_path = null,
phrase = excluded.phrase,
status = excluded.status,
reason = excluded.reason,
approved = false,
evidence_status = excluded.evidence_status,
frequency = excluded.frequency,
frequency_group = excluded.frequency_group,
payload = excluded.payload,
updated_at = now();
`,
[
projectId,
keywordMap.semanticRunId,
keywordMap.projectOntologyVersion.id,
item.wordstatResultId,
item.id,
item.clusterId,
item.clusterTitle,
item.phrase,
"Пользователь удалил фразу из Stage 5 distribution board.",
item.evidenceStatus,
item.frequency,
item.frequencyGroup,
JSON.stringify({
relatedLanding: item.relatedLanding,
role: item.role,
source: item.source,
priority: item.priority,
normalizedPhrase: normalizePhrase(item.phrase),
suppressedAt: new Date().toISOString()
})
]
);
savedDecisionCount += 1;
}
for (const item of layoutItems) {
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 approved = true;
`,
[projectId, keywordMap.semanticRunId, keywordMap.projectOntologyVersion.id, item.id]
);
await client.query(
`
insert into keyword_decisions (
project_id,
semantic_run_id,
project_ontology_version_id,
wordstat_result_id,
source_item_id,
cluster_id,
cluster_title,
target_path,
phrase,
status,
reason,
approved,
evidence_status,
frequency,
frequency_group,
payload,
updated_at
)
values ($1, $2, $3, $4, $5, $6, $7, null, $8, 'layout', $9, false, $10, $11, $12, $13::jsonb, now())
on conflict (project_id, project_ontology_version_id, source_item_id)
where source_item_id is not null
do update set
semantic_run_id = excluded.semantic_run_id,
wordstat_result_id = excluded.wordstat_result_id,
cluster_id = excluded.cluster_id,
cluster_title = excluded.cluster_title,
target_path = null,
phrase = excluded.phrase,
status = excluded.status,
reason = excluded.reason,
approved = false,
evidence_status = excluded.evidence_status,
frequency = excluded.frequency,
frequency_group = excluded.frequency_group,
payload = excluded.payload,
updated_at = now();
`,
[
projectId,
keywordMap.semanticRunId,
keywordMap.projectOntologyVersion.id,
item.wordstatResultId,
item.id,
item.clusterId,
item.clusterTitle,
item.phrase,
item.reason,
item.evidenceStatus,
item.frequency,
item.frequencyGroup,
JSON.stringify({
relatedLanding: item.relatedLanding,
role: item.role,
source: item.source,
priority: item.priority,
projectOntologyVersionId: item.projectOntologyVersionId
})
]
);
savedDecisionCount += 1;
}
for (const item of approvableItems) {
const decisionResult = await client.query<{ id: string }>(
`
insert into keyword_decisions (
@ -1177,7 +1591,7 @@ export async function approveKeywordMapDecisions(
keywordMap: await getKeywordMapContract(projectId),
savedDecisionCount,
savedMapItemCount,
skippedItemCount: selectedItems.length - persistableItems.length
skippedItemCount: selectedItems.length - suppressedItems.length - layoutItems.length - approvableItems.length
};
} catch (error) {
await client.query("rollback");

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -100,6 +100,20 @@ function getString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function normalizePhrase(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
function isRealWordstatPhrase(value: string) {
const normalizedValue = normalizePhrase(value);
return (
normalizedValue.length > 1 &&
/[a-zа-яё0-9]/i.test(normalizedValue) &&
!["-", "—", "n/a", "na", "null", "undefined", "нет данных", "не применимо"].includes(normalizedValue)
);
}
function getNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return Math.max(0, Math.round(value));
@ -124,6 +138,10 @@ function asArray(value: unknown) {
return Array.isArray(value) ? value : [];
}
function mergeArraySources(...values: unknown[]) {
return values.flatMap((value) => asArray(value));
}
function normalizeRelatedQuery(value: unknown): WordstatRelatedQuery | null {
const record = asRecord(value);
const phrase =
@ -133,7 +151,7 @@ function normalizeRelatedQuery(value: unknown): WordstatRelatedQuery | null {
getString(record.text) ||
getString(record.name);
if (!phrase) {
if (!phrase || !isRealWordstatPhrase(phrase)) {
return null;
}
@ -162,20 +180,27 @@ function normalizeSeedResult(value: unknown, seed: WordstatSeedInput): WordstatS
getNumber(record.count) ??
getNumber(record.total) ??
getNumber(record.impressions);
const relatedSource =
record.relatedQueries ??
record.related_queries ??
record.queries ??
record.items ??
record.results ??
record.associations;
const relatedSource = mergeArraySources(
record.relatedQueries,
record.related_queries,
record.queries,
record.items,
record.results,
record.associations,
record.topRequests,
record.top_requests,
record.top,
record.popular,
record.popularQueries,
record.popular_queries
);
return {
seedId: seed.seedId,
seedPhrase: phrase,
seedPhrase: isRealWordstatPhrase(phrase) ? phrase : seed.phrase,
frequency,
frequencyGroup: getFrequencyGroup(frequency, record.frequencyGroup ?? record.frequency_group ?? record.group),
relatedQueries: asArray(relatedSource).flatMap((related) => {
relatedQueries: relatedSource.flatMap((related) => {
const normalized = normalizeRelatedQuery(related);
return normalized ? [normalized] : [];
}),
@ -237,14 +262,22 @@ class DisabledWordstatProvider implements WordstatProvider {
}
async function postJson(endpoint: string, body: unknown, headers: Record<string, string> = {}) {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
...headers
},
body: JSON.stringify(body)
});
let response: Response;
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
...headers
},
body: JSON.stringify(body)
});
} catch (error) {
if (error instanceof TypeError && /fetch failed/i.test(error.message)) {
throw new Error(`Wordstat provider недоступен: backend не смог подключиться к ${endpoint}.`);
}
throw error;
}
if (!response.ok) {
const message = await response.text().catch(() => "");

View File

@ -66,6 +66,7 @@ type WordstatResultRow = {
const WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT = 100;
const WORDSTAT_STATS_REQUESTS_PER_SECOND_LIMIT = 10;
const WORDSTAT_MAX_SAFE_SEEDS_PER_COLLECTION = 16;
export type WordstatQuotaSnapshot = {
provider: string;
@ -154,8 +155,18 @@ function normalizePhrase(value: string) {
return value.trim().replace(/\s+/g, " ").toLowerCase();
}
const WORDSTAT_TOP_RESULTS_LIMIT = 160;
const WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT = 12;
function isRealWordstatPhrase(value: string) {
const normalizedValue = normalizePhrase(value);
return (
normalizedValue.length > 1 &&
/[a-zа-яё0-9]/i.test(normalizedValue) &&
!["-", "—", "n/a", "na", "null", "undefined", "нет данных", "не применимо"].includes(normalizedValue)
);
}
const WORDSTAT_TOP_RESULTS_LIMIT = 240;
const WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT = 16;
const RELATED_QUERY_STOP_WORDS = new Set([
"a",
@ -270,7 +281,7 @@ function shouldKeepRelatedQuery(seedPhrase: string, relatedPhrase: string) {
const normalizedSeedPhrase = normalizePhrase(seedPhrase);
const normalizedRelatedPhrase = normalizePhrase(relatedPhrase);
if (!normalizedRelatedPhrase || normalizedRelatedPhrase === normalizedSeedPhrase) {
if (!isRealWordstatPhrase(normalizedRelatedPhrase) || normalizedRelatedPhrase === normalizedSeedPhrase) {
return false;
}
@ -352,7 +363,7 @@ function getRepresentativeTopResultRows(results: WordstatResultRow[]) {
const topResultByPhrase = new Map<string, WordstatResultRow>();
for (const result of results) {
if (result.frequency === null) {
if (result.frequency === null || !isRealWordstatPhrase(result.phrase)) {
continue;
}
@ -448,10 +459,11 @@ function buildEvidence(
results: WordstatResultRow[],
quota: WordstatQuotaSnapshot | null
): WordstatEvidence {
const seedRows = results.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
const realResults = results.filter((result) => isRealWordstatPhrase(result.phrase));
const seedRows = realResults.filter((result) => result.source_phrase === result.phrase || !result.source_phrase);
const relatedCounts = new Map<string, number>();
for (const result of results) {
for (const result of realResults) {
if (result.source_phrase && result.source_phrase !== result.phrase) {
relatedCounts.set(result.source_phrase, (relatedCounts.get(result.source_phrase) ?? 0) + 1);
}
@ -475,7 +487,7 @@ function buildEvidence(
seedSource: result.seed_source,
collectedAt: result.created_at.toISOString()
})),
topResults: getRepresentativeTopResultRows(results)
topResults: getRepresentativeTopResultRows(realResults)
.map((result) => ({
id: result.id,
phrase: result.phrase,
@ -497,11 +509,11 @@ function buildEvidence(
noSignalSeedCount: seedRows.filter(
(result) => result.frequency === null && (relatedCounts.get(result.phrase) ?? 0) === 0
).length,
resultCount: results.length,
totalFrequency: results.reduce((sum, result) => sum + (result.frequency ?? 0), 0),
highFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "high").length,
midFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "mid").length,
lowFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "low").length
resultCount: realResults.length,
totalFrequency: realResults.reduce((sum, result) => sum + (result.frequency ?? 0), 0),
highFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "high").length,
midFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "mid").length,
lowFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "low").length
}
};
}
@ -599,6 +611,27 @@ async function getCollectedSeedPhraseSet(projectId: string, semanticRunId: strin
return new Set(result.rows.map((row) => normalizePhrase(row.normalized_phrase ?? row.phrase)));
}
export async function getRequestedWordstatPhraseSet(projectId: string, semanticRunId: string) {
const result = await pool.query<{ requested_phrases: string[] | null }>(
`
select requested_phrases
from wordstat_jobs
where project_id = $1
and semantic_run_id = $2
and status in ('queued', 'running', 'done', 'failed')
and jsonb_array_length(requested_phrases) > 0;
`,
[projectId, semanticRunId]
);
return new Set(
result.rows
.flatMap((row) => Array.isArray(row.requested_phrases) ? row.requested_phrases : [])
.map(normalizePhrase)
.filter(Boolean)
);
}
async function getWordstatQuotaSnapshot(provider: string, mode: string): Promise<WordstatQuotaSnapshot> {
const result = await pool.query<{
id: string;
@ -860,7 +893,8 @@ async function updateJobFailed(jobId: string, error: unknown) {
export async function collectWordstatEvidence(
projectId: string,
analysis: SemanticAnalysisRun,
seedCandidates: WordstatSeedCandidate[] = analysis.seedCandidates
seedCandidates: WordstatSeedCandidate[] = analysis.seedCandidates,
options: { maxSeeds?: number } = {}
) {
const provider = await createWordstatProvider();
const status = provider.getStatus();
@ -871,14 +905,20 @@ export async function collectWordstatEvidence(
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId);
const uncollectedSeedRows = seedRows.filter((row) => !collectedSeedPhrases.has(normalizePhrase(row.phrase)));
const requestedSeedPhrases = await getRequestedWordstatPhraseSet(projectId, analysis.runId);
const alreadyTriedSeedPhrases = new Set([...collectedSeedPhrases, ...requestedSeedPhrases]);
const uncollectedSeedRows = seedRows.filter((row) => !alreadyTriedSeedPhrases.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);
const requestedMaxSeeds = Math.min(
options.maxSeeds ?? status.maxSeedsPerRun,
WORDSTAT_MAX_SAFE_SEEDS_PER_COLLECTION
);
const maxSeedsForRun = Math.min(status.maxSeedsPerRun, budget.remaining, requestedMaxSeeds);
if (maxSeedsForRun <= 0) {
throw new Error(
@ -943,9 +983,7 @@ export async function getLatestWordstatEvidence(
updated_at
from wordstat_jobs
where project_id = $1 and semantic_run_id = $2
order by
case when status = 'done' then 0 else 1 end,
created_at desc
order by created_at desc
limit 1;
`,
[projectId, semanticRunId]

View File

@ -1,8 +1,22 @@
import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js";
import {
buildSeoBusinessSynthesisTask,
getLatestAlignedSeoBusinessSynthesis,
type SeoBusinessSynthesisModelTaskContract
} from "../business/seoBusinessSynthesis.js";
import {
buildSeoCommercialDemandTask,
getLatestAlignedSeoCommercialDemand,
type SeoCommercialDemandModelTaskContract
} from "../commercial/seoCommercialDemand.js";
import { getLatestAlignedSeoContextReview } from "../contextReview/seoContextReview.js";
import { buildSerpInterpretationModelTask, type SerpInterpretationModelTaskContract } from "../evidence/serpInterpretationTask.js";
import { getLatestYandexEvidenceContract } from "../evidence/yandexEvidence.js";
import { getAnchorReviewContract } from "../keywords/anchorReview.js";
import {
getMarketFrontierDiscoveryContract,
type MarketFrontierDiscoveryModelTaskContract
} from "../market/marketFrontierDiscovery.js";
import {
getSeoModelProviderCatalog,
type SeoModelProviderCatalog
@ -26,7 +40,10 @@ import {
export type SeoModelTaskContract =
| KeywordCleaningModelTaskContract
| SeoBusinessSynthesisModelTaskContract
| SeoCommercialDemandModelTaskContract
| SeoContextReviewModelTaskContract
| MarketFrontierDiscoveryModelTaskContract
| SeoNormalizationModelTaskContract
| SerpInterpretationModelTaskContract
| StrategyQualityReviewModelTaskContract
@ -64,8 +81,13 @@ export type SeoModelContextContract = {
} | null;
stageReadiness: {
semanticContextReady: boolean;
businessSynthesisTaskReady: boolean;
businessSynthesisResultReady: boolean;
commercialDemandTaskReady: boolean;
commercialDemandResultReady: boolean;
contextReviewTaskReady: boolean;
contextReviewResultReady: boolean;
marketFrontierDiscoveryTaskReady: boolean;
normalizationState: "not_ready" | "ready";
normalizationTaskReady: boolean;
keywordCleaningState: "not_ready" | "ready";
@ -80,7 +102,7 @@ export type SeoModelContextContract = {
modelProvider: SeoModelProviderCatalog;
contextReview: {
runId: string;
providerMode: "codex_manual" | "deterministic_fallback";
providerMode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
generatedAt: string;
needsHumanReview: boolean;
summary: string;
@ -167,7 +189,12 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
const skills = getSeoSkillPackContract();
const modelProvider = getSeoModelProviderCatalog();
const contextReview = analysis ? await getLatestAlignedSeoContextReview(projectId, analysis.runId) : null;
const businessSynthesis = analysis ? await getLatestAlignedSeoBusinessSynthesis(projectId, analysis.runId) : null;
const commercialDemand = analysis ? await getLatestAlignedSeoCommercialDemand(projectId, analysis.runId) : null;
const marketFrontierDiscovery = analysis ? await getMarketFrontierDiscoveryContract(projectId) : null;
const contextReviewTask = analysis ? buildSeoContextReviewTask(projectId, analysis) : null;
const businessSynthesisTask = analysis ? buildSeoBusinessSynthesisTask(projectId, analysis, contextReview) : null;
const commercialDemandTask = businessSynthesis ? buildSeoCommercialDemandTask(projectId, businessSynthesis) : null;
const serpInterpretationTask = buildSerpInterpretationModelTask(projectId, yandexEvidence);
const strategySynthesisTask = buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation);
const strategyQualityReviewTask = buildStrategyQualityReviewModelTask(
@ -178,6 +205,9 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
);
const modelTasks = [
contextReviewTask,
businessSynthesisTask,
commercialDemandTask,
marketFrontierDiscovery?.modelTask,
normalization.modelTask,
keywordCleaning.modelTask,
serpInterpretationTask,
@ -225,8 +255,14 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
allowedEvidence: [
"latest scan metadata",
"project ontology summary",
"clean selected site text chunks",
"active SEO skill pack contracts",
"context review model task",
"business synthesis model task",
"business synthesis product-frame evidence",
"commercial demand model task",
"commercial demand buyer-intent evidence",
"market frontier discovery memory and query-language fingerprint",
"semantic clusters and source refs",
"normalization model task",
"keyword cleaning model task",
@ -262,8 +298,13 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
: null,
stageReadiness: {
semanticContextReady: Boolean(analysis),
businessSynthesisTaskReady: Boolean(businessSynthesisTask),
businessSynthesisResultReady: Boolean(businessSynthesis),
commercialDemandTaskReady: Boolean(commercialDemandTask),
commercialDemandResultReady: Boolean(commercialDemand),
contextReviewTaskReady: Boolean(contextReviewTask),
contextReviewResultReady: Boolean(contextReview),
marketFrontierDiscoveryTaskReady: Boolean(marketFrontierDiscovery?.modelTask),
normalizationState: normalization.state,
normalizationTaskReady: Boolean(normalization.modelTask),
keywordCleaningState: keywordCleaning.state,
@ -348,6 +389,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
"Keyword map предсортирует exact evidence в пять Stage 3 колонок; Неразобранные не участвуют, финальные роли утверждает пользователь.",
"Stage 3 не должен превращаться в мусорный буфер: backend-only missing exact backfill хранится как evidence/audit, но не как user-facing candidate без model-returned semantic fit.",
"Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval."
],
nextActions: !analysis
@ -360,7 +402,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать exact-кандидаты в пять колонок для ручной правки.",
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать широкие кандидаты в пять колонок для ручной правки; exact-frequency нужен для строгой фиксации.",
"Не запускать external market collection без anchor review approval."
]
: [

View File

@ -38,7 +38,11 @@ export type SeoAiWorkspacePersistedResult = {
schemaVersion: "seo-model-persisted-result.v1";
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
runType:
| "seo_business_synthesis"
| "seo_commercial_demand"
| "seo_context_review"
| "keyword_cleaning"
| "market_frontier_discovery"
| "seo_model_task"
| "seo_normalization"
| "seo_strategy_quality_review"
@ -150,6 +154,10 @@ export async function assistantRequest<T>(
throw new Error("ai_workspace_assistant_timeout");
}
if (error instanceof TypeError && /fetch failed/i.test(error.message)) {
throw new Error("AI Workspace bridge недоступен: backend не смог подключиться к Assistant/Hub endpoint.");
}
throw error;
} finally {
clearTimeout(timeout);
@ -169,6 +177,14 @@ function buildSeoModelTaskPrompt(input: {
`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.",
"For business synthesis tasks, behave like a senior product SEO strategist: read the site evidence, identify the central product offer, platform layers, application vectors, and demand families, while clearly separating explicit claims, conservative inferences, and risky expansion.",
"For commercial demand tasks, behave like a buyer-intent SEO strategist: convert product architecture into purchasing, evaluation, implementation, automation, integration, replacement, pilot, or vendor-selection demand. Preserve the product's core differentiator from productFrame/corePillars in every vertical/application query pattern; do not output pure informational or architecture-inventory phrases as demand.",
"For market frontier discovery tasks, behave like a market-research SEO strategist for a non-SEO user: read productUnderstanding, approvedSemanticBasis, marketMemory, and queryLanguageFingerprint; identify demand frontiers that current coveredPhrases/requestedSeeds do not cover; propose a short, high-leverage Wordstat probeSeeds list that can harvest top/related/popular rows. Do not return repeated, suppressed, n/a, or final Stage 3 keyword clutter.",
"For context review and normalization tasks, treat task.input.siteTextEvidence.chunks as the primary cleaned text corpus of the selected site scope when it is present.",
"For normalization tasks, use task.input.siteTextEvidence.highSignalLines as a checklist of short visible meanings: represent non-technical product, module, use-case, integration, audience, workflow, or vertical lines as needs-human query/corePhrase candidates, or put them in excludedPhrases with an explicit reason.",
"For normalization tasks, use task.input.businessSynthesis as the product hierarchy: keep productFrame/corePillars as the semantic center and keep applicationVectors as use cases or vertical branches unless the synthesis explicitly marks them core.",
"For normalization tasks, use task.input.commercialDemand as the buyer-intent frame: seedStrategy and wordstatQueries should be commercial/problem-solution/evaluation/implementation phrases, not raw architecture theses. seedStrategy must include the actual semantic-basis query candidates across buyer-intent groups, not only 3-4 core phrases; target 24-60 needs-human seeds and keep them concise enough for market review. Do not shorten by deleting the product core differentiator; a longer qualified buyer query is better than a short generic phrase for another market. If a phrase is only informational, put it in excludedPhrases or rejectedSeeds with a reason.",
"Decompose meanings from that corpus first, then use semanticSignals/pageSignals as backend hints and cross-checks.",
"",
"Hard boundaries:",
"- Work only from the task contract below.",
@ -381,11 +397,52 @@ function parseJsonFromText(rawText: string): JsonRecord | null {
}
const firstBrace = text.indexOf("{");
const lastBrace = text.lastIndexOf("}");
if (firstBrace >= 0 && lastBrace > firstBrace) {
if (firstBrace >= 0) {
let depth = 0;
let inString = false;
let escaped = false;
for (let index = firstBrace; index < text.length; index += 1) {
const char = text[index];
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = inString;
continue;
}
if (char === "\"") {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char === "{") {
depth += 1;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
try {
const parsed = JSON.parse(text.slice(firstBrace, index + 1));
return isJsonRecord(parsed) ? parsed : null;
} catch {}
}
}
}
try {
const parsed = JSON.parse(text.slice(firstBrace, lastBrace + 1));
const parsed = JSON.parse(text.slice(firstBrace));
return isJsonRecord(parsed) ? parsed : null;
} catch {}
}

View File

@ -3,6 +3,9 @@ import { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js";
export const SEO_MODEL_TASK_TYPES = [
"seo.context_review",
"seo.business_synthesis",
"seo.commercial_demand",
"seo.market_frontier_discovery",
"seo.normalization",
"seo.keyword_cleaning",
"seo.serp_interpretation",

View File

@ -1,7 +1,28 @@
import { pool } from "../db/client.js";
import {
buildSeoBusinessSynthesisPromotionFallback,
saveSeoBusinessSynthesisFromModelProvider,
type SeoBusinessSynthesisContract,
type SeoBusinessSynthesisModelTaskContract
} from "../business/seoBusinessSynthesis.js";
import {
buildSeoCommercialDemandPromotionFallback,
saveSeoCommercialDemandFromModelProvider,
type SeoCommercialDemandContract,
type SeoCommercialDemandModelTaskContract
} from "../commercial/seoCommercialDemand.js";
import { saveSeoContextReviewFromModelProvider, type SeoContextReviewOutput } from "../contextReview/seoContextReview.js";
import { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js";
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
import { saveSeoNormalizationFromModelProvider, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
import {
saveMarketFrontierDiscoveryFromModelProvider,
type MarketFrontierDiscoveryContract
} from "../market/marketFrontierDiscovery.js";
import {
saveSeoNormalizationFromModelProvider,
type SeoNormalizationContract,
type SeoNormalizationModelTaskContract
} from "../normalization/seoNormalization.js";
import { saveStrategyQualityReviewFromModelProvider, type StrategyQualityReviewContract } from "../strategy/strategyQualityReview.js";
import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js";
import {
@ -157,6 +178,22 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) {
return 2600;
}
if (task.taskType === "seo.business_synthesis") {
return 4800;
}
if (task.taskType === "seo.commercial_demand") {
return 5200;
}
if (task.taskType === "seo.market_frontier_discovery") {
return 6200;
}
if (task.taskType === "seo.normalization") {
return 4200;
}
if (task.taskType === "seo.keyword_cleaning") {
return 9000;
}
@ -417,6 +454,68 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function getRecordString(record: Record<string, unknown>, key: string) {
const value = record[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
async function getContextReviewPromotionFallback(projectId: string, run: SeoModelTaskRun) {
const inputTask = isRecord(run.input.task) ? run.input.task : {};
const semanticRunId = getRecordString(inputTask, "semanticRunId") ?? run.task.semanticRunId;
const sourceTaskId = getRecordString(inputTask, "taskId") ?? run.task.taskId;
if (!semanticRunId || !sourceTaskId) {
throw new Error("Context Review promotion fallback не может определить semanticRunId/sourceTaskId.");
}
const analysisResult = await pool.query<{ output: { scanVersionId?: unknown; projectOntology?: { versionId?: unknown } } | null }>(
`
select output
from runs
where id = $1
and project_id = $2
and run_type = 'semantic_analysis'
limit 1;
`,
[semanticRunId, projectId]
);
const analysisOutput = analysisResult.rows[0]?.output ?? null;
const scanVersionId = getRecordString(inputTask, "scanVersionId") ?? (
typeof analysisOutput?.scanVersionId === "string" ? analysisOutput.scanVersionId : null
);
const projectOntologyVersionId = getRecordString(inputTask, "projectOntologyVersionId") ?? (
typeof analysisOutput?.projectOntology?.versionId === "string" ? analysisOutput.projectOntology.versionId : null
);
if (!scanVersionId) {
throw new Error("Context Review promotion fallback не может определить scanVersionId.");
}
return {
projectOntologyVersionId,
scanVersionId,
semanticRunId,
sourceTaskId
};
}
function getNormalizationPromotionFallback(run: SeoModelTaskRun) {
const inputTask = isRecord(run.input.task) ? run.input.task : null;
const semanticRunId = inputTask ? getRecordString(inputTask, "semanticRunId") : run.task.semanticRunId;
const taskId = inputTask ? getRecordString(inputTask, "taskId") : run.task.taskId;
if (!inputTask || !semanticRunId || !taskId) {
throw new Error("SEO normalization promotion fallback не может определить semanticRunId/modelTask.taskId.");
}
return {
modelTask: inputTask as unknown as SeoNormalizationModelTaskContract,
projectOntologyVersionId: getRecordString(inputTask, "projectOntologyVersionId"),
semanticRunId
};
}
function getRunAiWorkspaceDispatch(run: SeoModelTaskRun): SeoAiWorkspaceDispatch | null {
const fromOutput = isRecord(run.modelOutput) && isRecord(run.modelOutput.aiWorkspace)
? run.modelOutput.aiWorkspace
@ -465,11 +564,91 @@ async function promoteAiWorkspaceModelOutput(
return null;
}
if (run.task.taskType === "seo.context_review") {
const fallback = await getContextReviewPromotionFallback(projectId, run);
await saveSeoContextReviewFromModelProvider(
projectId,
modelOutput.parsedJson as unknown as SeoContextReviewOutput,
run.runId,
fallback
);
return {
nextAction: "Context Review обновлён из AI Workspace output; normalization/anchor review должны читать model-backed semantic basis.",
persistedResult: buildSeoAiWorkspacePromotedResult({
runType: "seo_context_review",
sourceModelTaskRunId: run.runId
})
};
}
if (run.task.taskType === "seo.business_synthesis") {
const inputTask = isRecord(run.input.task) ? run.input.task as unknown as SeoBusinessSynthesisModelTaskContract : null;
if (!inputTask) {
throw new Error("Business synthesis promotion fallback не может определить modelTask.");
}
await saveSeoBusinessSynthesisFromModelProvider(
projectId,
modelOutput.parsedJson as unknown as SeoBusinessSynthesisContract,
run.runId,
buildSeoBusinessSynthesisPromotionFallback(inputTask)
);
return {
nextAction: "Business synthesis обновлен из AI Workspace output; normalization должна читать product-frame перед semantic basis.",
persistedResult: buildSeoAiWorkspacePromotedResult({
runType: "seo_business_synthesis",
sourceModelTaskRunId: run.runId
})
};
}
if (run.task.taskType === "seo.commercial_demand") {
const inputTask = isRecord(run.input.task) ? run.input.task as unknown as SeoCommercialDemandModelTaskContract : null;
if (!inputTask) {
throw new Error("Commercial demand promotion fallback не может определить modelTask.");
}
await saveSeoCommercialDemandFromModelProvider(
projectId,
modelOutput.parsedJson as unknown as SeoCommercialDemandContract,
run.runId,
buildSeoCommercialDemandPromotionFallback(inputTask)
);
return {
nextAction: "Commercial demand обновлен из AI Workspace output; normalization должна читать buyer-intent framing перед semantic basis.",
persistedResult: buildSeoAiWorkspacePromotedResult({
runType: "seo_commercial_demand",
sourceModelTaskRunId: run.runId
})
};
}
if (run.task.taskType === "seo.market_frontier_discovery") {
await saveMarketFrontierDiscoveryFromModelProvider(
projectId,
modelOutput.parsedJson as unknown as MarketFrontierDiscoveryContract,
run.runId
);
return {
nextAction: "Market frontier discovery обновлён из AI Workspace output; market-wide Wordstat должен читать новую память, fingerprint и probe plan.",
persistedResult: buildSeoAiWorkspacePromotedResult({
runType: "market_frontier_discovery",
sourceModelTaskRunId: run.runId
})
};
}
if (run.task.taskType === "seo.normalization") {
const fallback = getNormalizationPromotionFallback(run);
await saveSeoNormalizationFromModelProvider(
projectId,
modelOutput.parsedJson as unknown as SeoNormalizationContract,
run.runId
run.runId,
fallback
);
return {
nextAction: "SEO normalization обновлена из AI Workspace output; можно пересобирать anchor review/Wordstat queue.",
@ -538,6 +717,14 @@ async function refreshRunningSeoModelTaskRun(row: SeoModelTaskRunRow) {
return run;
}
if (await hasNewerDoneSeoModelTaskRun(run)) {
return updateSeoModelTaskRun(run.runId, {
completed: true,
errorMessage: "Cancelled stale workspace task: a newer successful run for the same task type already exists.",
status: "cancelled"
});
}
const dispatch = getRunAiWorkspaceDispatch(run);
if (!dispatch) {
@ -615,6 +802,30 @@ async function refreshRunningSeoModelTaskRun(row: SeoModelTaskRunRow) {
});
}
async function hasNewerDoneSeoModelTaskRun(run: SeoModelTaskRun) {
if (!run.task.taskType) {
return false;
}
const result = await pool.query<{ exists: boolean }>(
`
select exists(
select 1
from runs
where project_id = $1
and run_type = 'seo_model_task'
and status = 'done'
and created_at > $2::timestamptz
and output->'task'->>'taskType' = $3
limit 1
) as exists
`,
[run.projectId, run.createdAt, run.task.taskType]
);
return result.rows[0]?.exists === true;
}
async function refreshRunningSeoModelTaskRuns(projectId: string) {
const result = await pool.query<SeoModelTaskRunRow>(
`
@ -708,7 +919,8 @@ export async function runSeoModelTask(projectId: string, input: SeoModelTaskRunI
const dispatchedRun = await updateSeoModelTaskRun(queuedRun.runId, {
input: {
...baseInput,
aiWorkspace: dispatch
aiWorkspace: dispatch,
task
},
output: dispatchedOutput,
status: "running"

File diff suppressed because it is too large Load Diff

View File

@ -253,20 +253,67 @@ const INTENT_SIGNAL_GROUP_META = {
const ONTOLOGY_STOP_WORDS = new Set([
"and",
"app",
"assets",
"attr",
"bodyhtml",
"bottominfo",
"card",
"collection",
"com",
"content",
"cta",
"cviz",
"form",
"для",
"enabled",
"features",
"footer",
"heading",
"html",
"image",
"images",
"introhtml",
"json",
"или",
"как",
"компания",
"main",
"media",
"muted",
"на",
"об",
"от",
"по",
"под",
"при",
"scope",
"src",
"static",
"staticelements",
"target",
"text",
"txt",
"txt-файл",
"txt-файла",
"txt-файлы",
"сайт",
"сервис",
"страница",
"webp",
"аватар",
"блок",
"заголовок",
"иконка",
"информационный",
"карточка",
"основная",
"приглушенная",
"пункт",
"пункты",
"строка",
"текст",
"форма",
"ячейка",
"это"
]);
@ -337,11 +384,12 @@ function getTextWords(value: string) {
return normalizeOntologyText(value).match(/[a-zа-я0-9-]{3,}/giu) ?? [];
}
function getTopTerms(value: string, limit: number) {
function getTopTerms(value: string, limit: number, extraStopTerms: string[] = []) {
const counts = new Map<string, number>();
const stopTerms = new Set([...extraStopTerms.map(normalizeOntologyText), ...ONTOLOGY_STOP_WORDS]);
for (const word of getTextWords(value)) {
if (ONTOLOGY_STOP_WORDS.has(word) || /^\d+$/.test(word) || /^[0-9a-f]{12,}$/i.test(word)) {
if (stopTerms.has(word) || /^\d+$/.test(word) || /^[0-9a-f]{12,}$/i.test(word)) {
continue;
}
@ -526,7 +574,7 @@ function buildGenericLandingBrief(brandName: string, documents: ProjectOntologyE
function buildCoreOfferQueries(brandName: string, topTerms: string[]) {
const evidenceTerms = topTerms.filter((term) => normalizeOntologyText(term) !== normalizeOntologyText(brandName)).slice(0, 5);
const pairedTerms = evidenceTerms.slice(0, 3).map((term) => `${term} ${brandName}`);
const pairedTerms = evidenceTerms.slice(0, 3).map((term) => `${brandName} ${term}`);
const topicPair = evidenceTerms.length >= 2 ? `${evidenceTerms[0]} ${evidenceTerms[1]}` : null;
return dedupeStrings([brandName, ...pairedTerms, topicPair ?? ""]).slice(0, 4);
@ -534,7 +582,8 @@ function buildCoreOfferQueries(brandName: string, topTerms: string[]) {
function buildEvidenceSeedQueries(brandName: string, matchedTerms: string[], topTerms: string[]) {
const brandKey = normalizeOntologyText(brandName);
const terms = dedupeStrings([...matchedTerms, ...topTerms])
const evidenceTerms = matchedTerms.length > 0 ? matchedTerms : topTerms;
const terms = dedupeStrings(evidenceTerms)
.filter((term) => {
const normalizedTerm = normalizeOntologyText(term);
@ -557,7 +606,8 @@ function buildEvidenceSeedQueries(brandName: string, matchedTerms: string[], top
function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: string) {
const documents = getCoverageDocuments(evidence);
const allText = normalizeOntologyText(documents.map((document) => document.text).join("\n"));
const topTerms = getTopTerms(allText, 10);
const brandTerms = buildBrandTerms(brandName, evidence);
const topTerms = getTopTerms(allText, 10, brandTerms);
const homeDocument = documents.find((document) => document.kind === "page") ?? documents[0] ?? null;
const homeTarget = homeDocument ? getPagePath(homeDocument) : "/";
const coreKeywords = dedupeStrings([...topTerms.slice(0, 8), ...buildBrandTerms(brandName, evidence)]).slice(0, 10);
@ -598,7 +648,7 @@ function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: st
priority,
targetIntent: meta.targetIntent,
intentGroups: [groupId],
keywords: dedupeStrings([...matchedTerms, ...topTerms.slice(0, 4)]).slice(0, 12),
keywords: dedupeStrings([...matchedTerms, ...(matchedTerms.length >= 4 ? [] : topTerms.slice(0, 4))]).slice(0, 12),
queryExamples: seedQueries.length > 0 ? seedQueries : buildCoreOfferQueries(brandName, topTerms),
targetPages: targetPages.length > 0 ? targetPages : [homeTarget]
});

View File

@ -47,7 +47,11 @@ import {
saveSeoContextReviewManual,
type SeoContextReviewOutput
} from "../contextReview/seoContextReview.js";
import { getMarketEnrichmentContract, runMarketEnrichment } from "../market/marketEnrichment.js";
import {
getMarketEnrichmentContract,
getWordstatProbePlanPreview,
runMarketEnrichment
} from "../market/marketEnrichment.js";
import { getSeoModelContextContract } from "../modelContext/seoModelContext.js";
import { getSeoStrategyContract } from "../strategy/seoStrategy.js";
import {
@ -90,6 +94,17 @@ import {
saveKeywordCleaningManual,
type KeywordCleaningContract
} from "../keywords/keywordCleaning.js";
import {
getLatestKeywordAnalysisWorkflow,
startKeywordAnalysisWorkflow
} from "../keywords/keywordAnalysisWorkflow.js";
import {
deleteKeywordContextSnapshot,
listKeywordContextSnapshots,
renameKeywordContextSnapshot,
saveKeywordContextSnapshot,
updateKeywordContextSnapshotCuration
} from "../keywords/keywordContextSnapshots.js";
import {
addAnchorReviewManualAnchor,
getAnchorReviewContract,
@ -235,8 +250,43 @@ const keywordMapApproveSchema = z.object({
role: z.enum(["differentiator", "primary", "secondary", "secondary_candidate", "support", "validate"])
})
)
.optional(),
suppressedItemIds: z.array(z.string().min(1)).max(300).optional()
});
const keywordContextSnapshotSchema = z.object({
label: z.string().trim().max(180).optional(),
note: z.string().trim().max(1000).optional(),
source: z.enum(["manual_save", "expansion_checkpoint"]).optional(),
curation: z
.object({
roleOverrides: z
.array(
z.object({
itemId: z.string().min(1),
role: z.string().min(1)
})
)
.max(1000)
.optional(),
suppressedItemIds: z.array(z.string().min(1)).max(1000).optional()
})
.optional()
});
const keywordContextSnapshotRenameSchema = z.object({
label: z.string().trim().min(1, "Нужно указать название snapshot-профиля.").max(180)
});
const keywordContextSnapshotCurationSchema = z.object({
roleOverrides: z
.array(
z.object({
itemId: z.string().min(1),
role: z.string().min(1)
})
)
.max(1000)
.optional(),
suppressedItemIds: z.array(z.string().min(1)).max(1000).optional()
});
const contextReviewManualSchema = z.object({
output: z.record(z.string(), z.unknown())
});
@ -251,10 +301,12 @@ const anchorReviewDecisionSchema = z.object({
reason: z.string().trim().max(1000).optional(),
sendToSerp: z.boolean().optional(),
sendToWordstat: z.boolean().optional(),
status: z.enum(["approved", "disabled", "pending"])
status: z.enum(["approved", "deleted", "disabled", "pending"])
});
const demandCollectionProfileSchema = z.enum(["contextual", "market_wide"]);
const anchorReviewDecisionsSchema = z.object({
decisions: z.array(anchorReviewDecisionSchema).max(200).optional()
decisions: z.array(anchorReviewDecisionSchema).max(200).optional(),
demandCollectionProfile: demandCollectionProfileSchema.optional()
});
const anchorReviewManualAnchorSchema = z.object({
phrase: z.string().trim().min(1).max(180),
@ -768,6 +820,24 @@ projectsRouter.get("/:projectId/market-enrichment/latest", async (request, respo
}
});
projectsRouter.get("/:projectId/market-enrichment/probe-preview", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
response.json({
probePreview: await getWordstatProbePlanPreview(projectId)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
return;
}
console.error(error);
sendError(response, 500, "Не удалось построить preview Wordstat probe plan.");
}
});
projectsRouter.get("/:projectId/seo-normalization/latest", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
@ -829,7 +899,7 @@ projectsRouter.post("/:projectId/anchor-review/decisions", async (request, respo
const input = anchorReviewDecisionsSchema.parse(request.body ?? {});
response.status(201).json({
anchorReview: await saveAnchorReviewDecisions(projectId, input.decisions ?? [])
anchorReview: await saveAnchorReviewDecisions(projectId, input.decisions, input.demandCollectionProfile)
});
} catch (error) {
if (error instanceof z.ZodError) {
@ -879,6 +949,42 @@ projectsRouter.get("/:projectId/keyword-cleaning/latest", async (request, respon
}
});
projectsRouter.get("/:projectId/keyword-analysis-workflow/latest", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
response.json({
workflow: await getLatestKeywordAnalysisWorkflow(projectId)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
return;
}
console.error(error);
sendError(response, 500, "Не удалось загрузить workflow анализа ключей.");
}
});
projectsRouter.post("/:projectId/keyword-analysis-workflow", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
response.status(202).json({
workflow: await startKeywordAnalysisWorkflow(projectId)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
return;
}
console.error(error);
sendError(response, 500, error instanceof Error ? error.message : "Не удалось запустить workflow анализа ключей.");
}
});
projectsRouter.post("/:projectId/keyword-cleaning/manual", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
@ -980,6 +1086,101 @@ projectsRouter.get("/:projectId/keyword-map/latest", async (request, response) =
}
});
projectsRouter.get("/:projectId/keyword-context-snapshots", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
response.json({
snapshots: await listKeywordContextSnapshots(projectId)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id проекта.");
return;
}
console.error(error);
sendError(response, 500, "Не удалось загрузить snapshot-профили ключей.");
}
});
projectsRouter.post("/:projectId/keyword-context-snapshots", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
const input = keywordContextSnapshotSchema.parse(request.body ?? {});
response.status(201).json({
snapshot: await saveKeywordContextSnapshot(projectId, input)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Проверь snapshot payload.");
return;
}
console.error(error);
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить snapshot ключей.");
}
});
projectsRouter.patch("/:projectId/keyword-context-snapshots/:snapshotId", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
const snapshotId = z.string().uuid("Некорректный id snapshot-профиля.").parse(request.params.snapshotId);
const input = keywordContextSnapshotRenameSchema.parse(request.body ?? {});
response.json({
snapshot: await renameKeywordContextSnapshot(projectId, snapshotId, input.label)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Проверь snapshot payload.");
return;
}
console.error(error);
sendError(response, 500, error instanceof Error ? error.message : "Не удалось переименовать snapshot ключей.");
}
});
projectsRouter.patch("/:projectId/keyword-context-snapshots/:snapshotId/curation", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
const snapshotId = z.string().uuid("Некорректный id snapshot-профиля.").parse(request.params.snapshotId);
const input = keywordContextSnapshotCurationSchema.parse(request.body ?? {});
response.json({
snapshot: await updateKeywordContextSnapshotCuration(projectId, snapshotId, input)
});
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Проверь layout snapshot payload.");
return;
}
console.error(error);
sendError(response, 500, error instanceof Error ? error.message : "Не удалось сохранить раскладку ключей.");
}
});
projectsRouter.delete("/:projectId/keyword-context-snapshots/:snapshotId", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
const snapshotId = z.string().uuid("Некорректный id snapshot-профиля.").parse(request.params.snapshotId);
await deleteKeywordContextSnapshot(projectId, snapshotId);
response.status(204).send();
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id snapshot-профиля.");
return;
}
console.error(error);
sendError(response, 500, error instanceof Error ? error.message : "Не удалось удалить snapshot ключей.");
}
});
projectsRouter.get("/:projectId/strategy/latest", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);

View File

@ -256,7 +256,7 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
label: "Max seeds per run",
type: "number",
required: false,
placeholder: "40",
placeholder: "12",
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
},
{
@ -422,7 +422,7 @@ function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) {
apiBaseUrl: WORDSTAT_BASE_URL,
devices: "DEVICE_ALL",
numPhrases: "50",
maxSeedsPerRun: "40",
maxSeedsPerRun: "12",
regionIds: "225"
};
@ -772,7 +772,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
provider: "mcp_kv",
mode: "mcp_kv",
mcpEndpoint: endpoint,
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 12)
};
}
}
@ -793,7 +793,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 12)
};
}

View File

@ -118,6 +118,46 @@ export type SeoContextReviewModelTaskContract = {
wordCount: number;
matchedClusterIds: string[];
}>;
siteTextEvidence: {
schemaVersion: "seo-site-text-evidence.v1";
extraction: {
chunkCount: number;
documentCount: number;
mode: "clean_visible_text";
sourceDocumentCount: number;
totalChars: number;
totalWords: number;
};
documents: Array<{
chunkIds: string[];
id: string;
kind: "page" | "content_json";
scope: string;
selected: boolean;
sourcePath: string;
textCharCount: number;
title: string;
urlPath: string | null;
usedForCoverage: boolean;
wordCount: number;
}>;
chunks: Array<{
charEnd: number;
charStart: number;
chunkIndex: number;
documentId: string;
id: string;
kind: "page" | "content_json";
scope: string;
selected: boolean;
sourcePath: string;
text: string;
title: string;
totalChunks: number;
urlPath: string | null;
usedForCoverage: boolean;
}>;
};
semanticSignals: Array<{
id: string;
title: string;
@ -224,12 +264,13 @@ const SEO_SKILL_MANIFESTS: SeoSkillManifest[] = [
summary:
"Первый полноценный AI entrypoint: модель объясняет смысл сайта, страницы, оффера, аудитории, gaps, ambiguity и forbidden claims по evidence.",
owner: "seo_mode",
inputs: ["semantic_analysis", "project_ontology", "page_scope", "style_profile", "crawl_indexability_evidence"],
requiredEvidence: ["semantic_clusters", "page_signals", "content_sources", "source_refs", "style_profile"],
inputs: ["semantic_analysis", "project_ontology", "page_scope", "style_profile", "crawl_indexability_evidence", "site_text_evidence"],
requiredEvidence: ["clean_visible_text_chunks", "semantic_clusters", "page_signals", "content_sources", "source_refs", "style_profile"],
deterministicChecks: [
{ id: "analysis_ready", title: "Semantic analysis exists", source: "backend", required: true },
{ id: "scope_ready", title: "Scope summary exists", source: "backend", required: true },
{ id: "evidence_refs", title: "Evidence refs are attached to semantic claims", source: "backend", required: true }
{ id: "evidence_refs", title: "Evidence refs are attached to semantic claims", source: "backend", required: true },
{ id: "site_text", title: "Clean selected site text chunks are attached", source: "backend", required: true }
],
modelTasks: [
{
@ -548,10 +589,37 @@ function getEvidenceRefs(analysis: SemanticAnalysisRun): SeoContextReviewModelTa
.slice(0, 60);
}
function getTextCorpusEvidence(analysis: SemanticAnalysisRun): SeoContextReviewModelTaskContract["input"]["siteTextEvidence"] {
if (analysis.textCorpus) {
return {
schemaVersion: "seo-site-text-evidence.v1",
extraction: analysis.textCorpus.extraction,
documents: analysis.textCorpus.documents,
chunks: analysis.textCorpus.chunks
};
}
return {
schemaVersion: "seo-site-text-evidence.v1",
extraction: {
chunkCount: 0,
documentCount: 0,
mode: "clean_visible_text",
sourceDocumentCount: 0,
totalChars: 0,
totalWords: 0
},
documents: [],
chunks: []
};
}
export function buildSeoContextReviewTask(
projectId: string,
analysis: SemanticAnalysisRun
): SeoContextReviewModelTaskContract {
const siteTextEvidence = getTextCorpusEvidence(analysis);
return {
taskType: "seo.context_review",
schemaVersion: "seo-context-review-task.v1",
@ -597,6 +665,7 @@ export function buildSeoContextReviewTask(
wordCount: source.wordCount,
matchedClusterIds: source.matchedClusters
})),
siteTextEvidence,
semanticSignals: analysis.clusters.slice(0, 30).map((cluster) => ({
evidenceLevel: cluster.evidence.level,
id: cluster.id,
@ -657,7 +726,7 @@ export function buildSeoContextReviewTask(
requireHumanReviewWhenAmbiguous: true
},
stopConditions: [
"Недостаточно evidence refs для определения реального оффера сайта.",
"Недостаточно clean text evidence или evidence refs для определения реального оффера сайта.",
"Сайт выражен слишком слабо, и модель не может отделить продукт от технической оболочки.",
"Запрошенное действие требует business expansion, rewrite или apply, которые запрещены на context review stage."
]