feat(seo): orchestrate market demand discovery
This commit is contained in:
parent
284c72d75c
commit
a1d1a4980f
|
|
@ -19,7 +19,7 @@ WORDSTAT_PROVIDER=disabled
|
||||||
# WORDSTAT_REGION_IDS=225
|
# WORDSTAT_REGION_IDS=225
|
||||||
# WORDSTAT_DEVICES=DEVICE_ALL
|
# WORDSTAT_DEVICES=DEVICE_ALL
|
||||||
# WORDSTAT_NUM_PHRASES=50
|
# WORDSTAT_NUM_PHRASES=50
|
||||||
# WORDSTAT_MAX_SEEDS_PER_RUN=40
|
# WORDSTAT_MAX_SEEDS_PER_RUN=12
|
||||||
|
|
||||||
SERP_PROVIDER=disabled
|
SERP_PROVIDER=disabled
|
||||||
# SERP_API_BASE_URL=https://serp-provider.internal
|
# SERP_API_BASE_URL=https://serp-provider.internal
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,37 @@ type ContentDocument = {
|
||||||
|
|
||||||
type PageScope = "indexable_page" | "template_block" | "admin_page" | "technical_page";
|
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 = {
|
type AnalysisSignal = {
|
||||||
id: string;
|
id: string;
|
||||||
level: "ok" | "warning" | "problem";
|
level: "ok" | "warning" | "problem";
|
||||||
|
|
@ -272,6 +303,19 @@ export type SemanticAnalysisOutput = {
|
||||||
wordCount: number;
|
wordCount: number;
|
||||||
matchedClusters: string[];
|
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<{
|
legacyFindings: Array<{
|
||||||
markerId: string;
|
markerId: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -484,6 +528,11 @@ const RUSSIAN_STOP_WORDS = new Set([
|
||||||
"assets",
|
"assets",
|
||||||
"attr",
|
"attr",
|
||||||
"avif",
|
"avif",
|
||||||
|
"accesscard",
|
||||||
|
"bodyhtml",
|
||||||
|
"bottominfo",
|
||||||
|
"card",
|
||||||
|
"cards",
|
||||||
"class",
|
"class",
|
||||||
"collection",
|
"collection",
|
||||||
"content",
|
"content",
|
||||||
|
|
@ -492,16 +541,22 @@ const RUSSIAN_STOP_WORDS = new Set([
|
||||||
"data",
|
"data",
|
||||||
"features",
|
"features",
|
||||||
"footer",
|
"footer",
|
||||||
|
"form",
|
||||||
"html",
|
"html",
|
||||||
"image",
|
"image",
|
||||||
"images",
|
"images",
|
||||||
|
"introhtml",
|
||||||
"json",
|
"json",
|
||||||
"heading",
|
"heading",
|
||||||
"main",
|
"main",
|
||||||
"media",
|
"media",
|
||||||
"muted",
|
"muted",
|
||||||
|
"node",
|
||||||
"site",
|
"site",
|
||||||
"src",
|
"src",
|
||||||
|
"static",
|
||||||
|
"staticelements",
|
||||||
|
"target",
|
||||||
"text",
|
"text",
|
||||||
"txt",
|
"txt",
|
||||||
"webp",
|
"webp",
|
||||||
|
|
@ -537,6 +592,8 @@ const CTA_PATTERNS = [
|
||||||
"заказать"
|
"заказать"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TEXT_CORPUS_CHUNK_CHARS = 6000;
|
||||||
|
|
||||||
function toIsoDate(value: Date | null) {
|
function toIsoDate(value: Date | null) {
|
||||||
return value ? value.toISOString() : null;
|
return value ? value.toISOString() : null;
|
||||||
}
|
}
|
||||||
|
|
@ -626,12 +683,113 @@ function visibleTextFromHtml(html: string) {
|
||||||
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
.replace(/<style\b[\s\S]*?<\/style>/gi, " ")
|
||||||
.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ")
|
.replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ")
|
||||||
.replace(/<svg\b[\s\S]*?<\/svg>/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(/<[^>]+>/g, " ")
|
||||||
)
|
)
|
||||||
.replace(/\s+/g, " ")
|
.split(/\n+/)
|
||||||
|
.map((line) => line.replace(/[ \t\f\v]+/g, " ").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n")
|
||||||
.trim();
|
.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) {
|
function normalizeSearchText(value: string) {
|
||||||
return value
|
return value
|
||||||
.toLocaleLowerCase("ru-RU")
|
.toLocaleLowerCase("ru-RU")
|
||||||
|
|
@ -1316,7 +1474,7 @@ function buildSeedCandidates(clusters: ReturnType<typeof analyzeClusters>, ontol
|
||||||
? `Кластер важен для ${ontology.brandName}, но в текущем сайте почти не раскрыт.`
|
? `Кластер важен для ${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,
|
phrase,
|
||||||
clusterId: cluster.id,
|
clusterId: cluster.id,
|
||||||
clusterTitle: cluster.title,
|
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(
|
function getLandingOpportunityPageType(
|
||||||
pagePath: string
|
pagePath: string
|
||||||
): SemanticAnalysisOutput["landingOpportunities"][number]["pageType"] {
|
): SemanticAnalysisOutput["landingOpportunities"][number]["pageType"] {
|
||||||
|
|
@ -2523,6 +2797,7 @@ async function buildAnalysisOutput(projectId: string): Promise<{ ontology: Proje
|
||||||
const ontology = await getProjectOntology(projectId, ontologyEvidence);
|
const ontology = await getProjectOntology(projectId, ontologyEvidence);
|
||||||
const pageScope = buildPageScopeSummary(pageDocuments, contentDocuments);
|
const pageScope = buildPageScopeSummary(pageDocuments, contentDocuments);
|
||||||
const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents, ontology);
|
const styleProfile = buildStyleProfile(coverageDocuments.length > 0 ? coverageDocuments : documents, ontology);
|
||||||
|
const textCorpus = buildTextCorpus(coverageDocuments.length > 0 ? coverageDocuments : documents);
|
||||||
const clusters = analyzeClusters(coverageDocuments, ontology);
|
const clusters = analyzeClusters(coverageDocuments, ontology);
|
||||||
const coverageScore = getCoverageScore(clusters);
|
const coverageScore = getCoverageScore(clusters);
|
||||||
const lexicalCoverageScore = getLexicalCoverageScore(clusters);
|
const lexicalCoverageScore = getLexicalCoverageScore(clusters);
|
||||||
|
|
@ -2640,6 +2915,7 @@ async function buildAnalysisOutput(projectId: string): Promise<{ ontology: Proje
|
||||||
wordCount: document.wordCount,
|
wordCount: document.wordCount,
|
||||||
matchedClusters: contentClusterMap.get(document.id) ?? []
|
matchedClusters: contentClusterMap.get(document.id) ?? []
|
||||||
})),
|
})),
|
||||||
|
textCorpus,
|
||||||
legacyFindings,
|
legacyFindings,
|
||||||
gaps,
|
gaps,
|
||||||
landingOpportunities,
|
landingOpportunities,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ const envSchema = z.object({
|
||||||
WORDSTAT_REGION_IDS: z.string().default("225"),
|
WORDSTAT_REGION_IDS: z.string().default("225"),
|
||||||
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
|
WORDSTAT_DEVICES: z.string().default("DEVICE_ALL"),
|
||||||
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
|
WORDSTAT_NUM_PHRASES: z.coerce.number().int().positive().default(50),
|
||||||
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(40),
|
WORDSTAT_MAX_SEEDS_PER_RUN: z.coerce.number().int().positive().max(80).default(12),
|
||||||
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
|
SERP_PROVIDER: z.enum(["disabled", "external_api"]).default("disabled"),
|
||||||
SERP_API_BASE_URL: z.string().url().optional(),
|
SERP_API_BASE_URL: z.string().url().optional(),
|
||||||
SERP_API_TOKEN: z.string().optional(),
|
SERP_API_TOKEN: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,14 @@ type SeoContextReviewRunRow = {
|
||||||
|
|
||||||
type ReviewLevel = "ok" | "warning" | "problem";
|
type ReviewLevel = "ok" | "warning" | "problem";
|
||||||
type ContextReviewConfidence = "low" | "medium" | "high";
|
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 = {
|
export type SeoContextReviewOutput = {
|
||||||
schemaVersion: "seo-context-review.v1";
|
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(
|
function normalizeContextReviewOutput(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
output: SeoContextReviewOutput,
|
output: SeoContextReviewOutput,
|
||||||
providerMode: SeoContextReviewProviderMode,
|
providerMode: SeoContextReviewProviderMode,
|
||||||
providerMessage: string
|
providerMessage: string,
|
||||||
|
fallback?: SeoContextReviewModelProviderFallback
|
||||||
): SeoContextReviewOutput {
|
): 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.");
|
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 не совпадает с проектом.");
|
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.");
|
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.");
|
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.");
|
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.");
|
throw new Error("Context Review output должен иметь semanticGaps, forbiddenClaims и normalizationHints.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...output,
|
...normalizedShape,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
provider: {
|
provider: {
|
||||||
message: providerMessage,
|
message: providerMessage,
|
||||||
mode: providerMode,
|
mode: providerMode,
|
||||||
modelRequired: true
|
modelRequired: true
|
||||||
},
|
},
|
||||||
analystReview: output.analystReview ?? getDefaultAnalystReview(),
|
analystReview: normalizedShape.analystReview ?? getDefaultAnalystReview(),
|
||||||
readiness: {
|
readiness: {
|
||||||
...output.readiness,
|
...normalizedShape.readiness,
|
||||||
forbiddenClaimCount: output.forbiddenClaims.length,
|
forbiddenClaimCount: normalizedShape.forbiddenClaims.length,
|
||||||
normalizationHintCount: output.normalizationHints.length,
|
normalizationHintCount: normalizedShape.normalizationHints.length,
|
||||||
pageRoleCount: output.pageRoles.length,
|
pageRoleCount: normalizedShape.pageRoles.length,
|
||||||
semanticGapCount: output.semanticGaps.length,
|
semanticGapCount: normalizedShape.semanticGaps.length,
|
||||||
needsHumanReview: output.needsHumanReview
|
needsHumanReview: normalizedShape.needsHumanReview
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -541,3 +812,45 @@ export async function saveSeoContextReviewManual(
|
||||||
|
|
||||||
return run;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { pool } from "../db/client.js";
|
import { pool } from "../db/client.js";
|
||||||
import { getSeoNormalizationContract, type SeoNormalizationContract } from "../normalization/seoNormalization.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 AnchorDecisionSource = "human" | "model" | "system";
|
||||||
type AnchorReviewState = "approved" | "empty" | "needs_review" | "not_ready";
|
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];
|
type SeoNormalizationSeed = SeoNormalizationContract["seedStrategy"][number];
|
||||||
|
|
||||||
export type AnchorReviewDecisionInput = {
|
export type AnchorReviewDecisionInput = {
|
||||||
|
|
@ -80,6 +83,7 @@ export type AnchorReviewContract = {
|
||||||
projectOntologyVersionId: string | null;
|
projectOntologyVersionId: string | null;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
state: AnchorReviewState;
|
state: AnchorReviewState;
|
||||||
|
demandCollectionProfile: DemandCollectionProfile;
|
||||||
source: {
|
source: {
|
||||||
normalizationGeneratedAt: string | null;
|
normalizationGeneratedAt: string | null;
|
||||||
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
|
normalizationProviderMode: SeoNormalizationContract["provider"]["mode"] | null;
|
||||||
|
|
@ -104,6 +108,13 @@ export type AnchorReviewContract = {
|
||||||
manualDecisionCount: number;
|
manualDecisionCount: number;
|
||||||
};
|
};
|
||||||
anchors: AnchorReviewAnchor[];
|
anchors: AnchorReviewAnchor[];
|
||||||
|
deletedAnchors: Array<{
|
||||||
|
deletedAt: string | null;
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
phrase: string;
|
||||||
|
reason: string;
|
||||||
|
}>;
|
||||||
wordstatQueue: AnchorReviewWordstatQueueItem[];
|
wordstatQueue: AnchorReviewWordstatQueueItem[];
|
||||||
nextActions: string[];
|
nextActions: string[];
|
||||||
};
|
};
|
||||||
|
|
@ -124,6 +135,10 @@ function normalizePhrase(value: string) {
|
||||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
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">) {
|
function getAnchorKey(seed: Pick<SeoNormalizationSeed, "clusterId" | "phrase">) {
|
||||||
return `${seed.clusterId}:${normalizePhrase(seed.phrase)}`;
|
return `${seed.clusterId}:${normalizePhrase(seed.phrase)}`;
|
||||||
}
|
}
|
||||||
|
|
@ -153,6 +168,18 @@ function getDefaultDecision(seed: SeoNormalizationSeed): AnchorReviewDecision {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorReviewDecision): 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") {
|
if (decision.status === "approved") {
|
||||||
return {
|
return {
|
||||||
decidedAt: decision.decidedAt,
|
decidedAt: decision.decidedAt,
|
||||||
|
|
@ -189,15 +216,47 @@ function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorRevi
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] {
|
function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] {
|
||||||
return (savedOutput?.anchors ?? []).map((anchor) => ({
|
const anchorSnapshots = (savedOutput?.anchors ?? []).map((anchor) => ({
|
||||||
anchor,
|
anchor,
|
||||||
decision: anchor.decision,
|
decision: anchor.decision,
|
||||||
id: anchor.id,
|
id: anchor.id,
|
||||||
key: anchor.key
|
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 {
|
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") {
|
if (decision.status === "approved") {
|
||||||
return {
|
return {
|
||||||
decidedAt: decision.decidedAt,
|
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 {
|
function getState(normalization: SeoNormalizationContract, anchors: AnchorReviewAnchor[], wordstatQueueCount: number): AnchorReviewState {
|
||||||
if (normalization.state !== "ready" || !normalization.semanticRunId) {
|
if (normalization.state !== "ready" || !normalization.semanticRunId) {
|
||||||
return "not_ready";
|
return "not_ready";
|
||||||
|
|
@ -324,6 +393,18 @@ function getState(normalization: SeoNormalizationContract, anchors: AnchorReview
|
||||||
return wordstatQueueCount > 0 ? "approved" : "needs_review";
|
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">) {
|
function buildNextActions(contract: Omit<AnchorReviewContract, "nextActions">) {
|
||||||
const actions: string[] = [];
|
const actions: string[] = [];
|
||||||
|
|
||||||
|
|
@ -360,7 +441,8 @@ function buildNextActions(contract: Omit<AnchorReviewContract, "nextActions">) {
|
||||||
function buildContract(
|
function buildContract(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
normalization: SeoNormalizationContract,
|
normalization: SeoNormalizationContract,
|
||||||
decisionSnapshots: DecisionSnapshot[] = []
|
decisionSnapshots: DecisionSnapshot[] = [],
|
||||||
|
demandCollectionProfile: DemandCollectionProfile = DEFAULT_DEMAND_COLLECTION_PROFILE
|
||||||
): AnchorReviewContract {
|
): AnchorReviewContract {
|
||||||
const decisionSnapshotsById = new Map(decisionSnapshots.map((snapshot) => [snapshot.id, snapshot]));
|
const decisionSnapshotsById = new Map(decisionSnapshots.map((snapshot) => [snapshot.id, snapshot]));
|
||||||
const decisionSnapshotsByKey = new Map(decisionSnapshots.map((snapshot) => [snapshot.key, 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 seedAnchorIds = new Set(anchors.map((anchor) => anchor.id));
|
||||||
const seedAnchorKeys = new Set(anchors.map((anchor) => anchor.key));
|
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 wordstatQueue = buildWordstatQueue(allAnchors);
|
||||||
const pendingAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "pending").length;
|
const pendingAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "pending").length;
|
||||||
const approvedAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "approved").length;
|
const approvedAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "approved").length;
|
||||||
|
|
@ -382,6 +468,7 @@ function buildContract(
|
||||||
projectOntologyVersionId: normalization.projectOntologyVersionId,
|
projectOntologyVersionId: normalization.projectOntologyVersionId,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
state: getState(normalization, allAnchors, wordstatQueue.length),
|
state: getState(normalization, allAnchors, wordstatQueue.length),
|
||||||
|
demandCollectionProfile,
|
||||||
source: {
|
source: {
|
||||||
normalizationGeneratedAt: normalization.generatedAt,
|
normalizationGeneratedAt: normalization.generatedAt,
|
||||||
normalizationProviderMode: normalization.provider.mode,
|
normalizationProviderMode: normalization.provider.mode,
|
||||||
|
|
@ -409,6 +496,55 @@ function buildContract(
|
||||||
wordstatQueueCount: wordstatQueue.length
|
wordstatQueueCount: wordstatQueue.length
|
||||||
},
|
},
|
||||||
anchors: allAnchors,
|
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
|
wordstatQueue
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -446,7 +582,12 @@ export async function getAnchorReviewContract(projectId: string): Promise<Anchor
|
||||||
? await getLatestAlignedAnchorReviewOutput(projectId, normalization.semanticRunId)
|
? await getLatestAlignedAnchorReviewOutput(projectId, normalization.semanticRunId)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return buildContract(projectId, normalization, getSavedDecisionSnapshots(savedOutput));
|
return buildContract(
|
||||||
|
projectId,
|
||||||
|
normalization,
|
||||||
|
getSavedDecisionSnapshots(savedOutput),
|
||||||
|
getSavedDemandCollectionProfile(savedOutput)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyDecisionInput(anchor: AnchorReviewAnchor, input: AnchorReviewDecisionInput, decidedAt: string): AnchorReviewDecision {
|
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 {
|
return {
|
||||||
decidedAt,
|
decidedAt,
|
||||||
decidedBy: "dev-mode",
|
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(
|
export async function saveAnchorReviewDecisions(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
decisions: AnchorReviewDecisionInput[] = []
|
decisions?: AnchorReviewDecisionInput[],
|
||||||
|
demandCollectionProfile?: DemandCollectionProfile
|
||||||
): Promise<AnchorReviewContract> {
|
): Promise<AnchorReviewContract> {
|
||||||
const current = await getAnchorReviewContract(projectId);
|
const current = await getAnchorReviewContract(projectId);
|
||||||
|
|
||||||
|
|
@ -508,10 +688,16 @@ export async function saveAnchorReviewDecisions(
|
||||||
const decidedAt = new Date().toISOString();
|
const decidedAt = new Date().toISOString();
|
||||||
const anchorsById = new Map(current.anchors.map((anchor) => [anchor.id, anchor]));
|
const anchorsById = new Map(current.anchors.map((anchor) => [anchor.id, anchor]));
|
||||||
const nextDecisionById = new Map(current.anchors.map((anchor) => [anchor.id, anchor.decision]));
|
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 =
|
const inputs =
|
||||||
decisions.length > 0
|
explicitDecisions.length > 0
|
||||||
? decisions
|
? explicitDecisions
|
||||||
: current.anchors
|
: !hasExplicitDecisions && demandCollectionProfile === undefined
|
||||||
|
? current.anchors
|
||||||
.filter((anchor) => anchor.proposal.sendToWordstat && anchor.proposal.status !== "rejected")
|
.filter((anchor) => anchor.proposal.sendToWordstat && anchor.proposal.status !== "rejected")
|
||||||
.map((anchor) => ({
|
.map((anchor) => ({
|
||||||
anchorId: anchor.id,
|
anchorId: anchor.id,
|
||||||
|
|
@ -519,7 +705,8 @@ export async function saveAnchorReviewDecisions(
|
||||||
sendToSerp: anchor.proposal.sendToSerp,
|
sendToSerp: anchor.proposal.sendToSerp,
|
||||||
sendToWordstat: true,
|
sendToWordstat: true,
|
||||||
status: "approved" as const
|
status: "approved" as const
|
||||||
}));
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
const anchor = anchorsById.get(input.anchorId);
|
const anchor = anchorsById.get(input.anchorId);
|
||||||
|
|
@ -531,14 +718,33 @@ export async function saveAnchorReviewDecisions(
|
||||||
nextDecisionById.set(anchor.id, applyDecisionInput(anchor, input, decidedAt));
|
nextDecisionById.set(anchor.id, applyDecisionInput(anchor, input, decidedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
const decisionSnapshots: DecisionSnapshot[] = current.anchors.map((anchor) => ({
|
const decisionSnapshots: DecisionSnapshot[] = [
|
||||||
|
...current.anchors.map((anchor) => ({
|
||||||
anchor,
|
anchor,
|
||||||
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
|
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
|
||||||
id: anchor.id,
|
id: anchor.id,
|
||||||
key: anchor.key
|
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 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>(
|
const result = await pool.query<AnchorReviewRunRow>(
|
||||||
`
|
`
|
||||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||||
|
|
@ -549,6 +755,7 @@ export async function saveAnchorReviewDecisions(
|
||||||
projectId,
|
projectId,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
decisionCount: inputs.length,
|
decisionCount: inputs.length,
|
||||||
|
demandCollectionProfile: output.demandCollectionProfile,
|
||||||
schemaVersion: "anchor-review-input.v1",
|
schemaVersion: "anchor-review-input.v1",
|
||||||
semanticRunId: output.semanticRunId
|
semanticRunId: output.semanticRunId
|
||||||
}),
|
}),
|
||||||
|
|
@ -655,6 +862,19 @@ export async function addAnchorReviewManualAnchor(
|
||||||
id: anchor.id,
|
id: anchor.id,
|
||||||
key: anchor.key
|
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,
|
anchor: manualAnchor,
|
||||||
decision: manualAnchor.decision,
|
decision: manualAnchor.decision,
|
||||||
|
|
@ -663,7 +883,7 @@ export async function addAnchorReviewManualAnchor(
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const normalization = await getSeoNormalizationContract(projectId);
|
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>(
|
const result = await pool.query<AnchorReviewRunRow>(
|
||||||
`
|
`
|
||||||
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { pool } from "../db/client.js";
|
import { pool } from "../db/client.js";
|
||||||
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||||
|
import { hasEducationIntentMismatch } from "./keywordIntentGuards.js";
|
||||||
|
|
||||||
type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
type KeywordCleaningProviderMode = "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||||
type KeywordCleaningDecision = "article" | "risky" | "support" | "trash" | "use";
|
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 AI_KEYWORD_PATTERN = /(^|\s)(ai|ии)(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект|нейросет/i;
|
||||||
const B2B_KEYWORD_FACET_PATTERN =
|
const B2B_KEYWORD_FACET_PATTERN =
|
||||||
/бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|платформ|стоимост|внедрен|купить|заказать|тариф|1с|crm|erp|bpm|workflow|digital|twin|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн|предприят|корпоратив/i;
|
/бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|платформ|стоимост|внедрен|купить|заказать|тариф|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 = [
|
const ARTICLE_PATTERNS = [
|
||||||
/(^|\s)(как|что такое|что\s+это|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд|определен[а-яёa-z0-9-]*|вопрос[а-яёa-z0-9-]*|ответ[а-яёa-z0-9-]*)(\s|$)/i,
|
/(^|\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;
|
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([
|
const KEYWORD_CLEANING_FACET_STOP_WORDS = new Set([
|
||||||
"a",
|
"a",
|
||||||
"an",
|
"an",
|
||||||
|
|
@ -575,7 +587,7 @@ function enforceKeywordCleaningSemanticGuard(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRescueExactTrashItem(item: KeywordCleaningItem) {
|
function shouldRescueExactTrashItem(market: MarketEnrichmentContract, item: KeywordCleaningItem) {
|
||||||
return (
|
return (
|
||||||
item.decision === "trash" &&
|
item.decision === "trash" &&
|
||||||
item.evidenceStatus === "collected" &&
|
item.evidenceStatus === "collected" &&
|
||||||
|
|
@ -587,6 +599,7 @@ function shouldRescueExactTrashItem(item: KeywordCleaningItem) {
|
||||||
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
||||||
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
||||||
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
||||||
|
!hasEducationIntentMismatch(market, item.normalizedPhrase) &&
|
||||||
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
||||||
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
||||||
);
|
);
|
||||||
|
|
@ -599,6 +612,7 @@ function isBroadHeadDemandItem(item: KeywordCleaningItem) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldPromoteExactDemandItem(
|
function shouldPromoteExactDemandItem(
|
||||||
|
market: MarketEnrichmentContract,
|
||||||
item: KeywordCleaningItem,
|
item: KeywordCleaningItem,
|
||||||
contextTokens: Set<string>,
|
contextTokens: Set<string>,
|
||||||
guard: KeywordCleaningFacetGuard
|
guard: KeywordCleaningFacetGuard
|
||||||
|
|
@ -621,14 +635,15 @@ function shouldPromoteExactDemandItem(
|
||||||
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
!hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
|
||||||
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
!hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
|
||||||
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
!hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
|
||||||
|
!hasEducationIntentMismatch(market, item.normalizedPhrase) &&
|
||||||
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
!isConsumerAiNoisePhrase(item.normalizedPhrase) &&
|
||||||
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
!isBroadAiKeywordNoise(item.normalizedPhrase)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function rescueExactTrashItems(items: KeywordCleaningItem[]): KeywordCleaningItem[] {
|
function rescueExactTrashItems(market: MarketEnrichmentContract, items: KeywordCleaningItem[]): KeywordCleaningItem[] {
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
if (!shouldRescueExactTrashItem(item)) {
|
if (!shouldRescueExactTrashItem(market, item)) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -648,7 +663,7 @@ function promoteExactDemandItems(market: MarketEnrichmentContract, items: Keywor
|
||||||
const guard = buildKeywordCleaningFacetGuard(market);
|
const guard = buildKeywordCleaningFacetGuard(market);
|
||||||
|
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
if (!shouldPromoteExactDemandItem(item, contextTokens, guard)) {
|
if (!shouldPromoteExactDemandItem(market, item, contextTokens, guard)) {
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -667,20 +682,26 @@ function enforceKeywordCleaningBackendGuards(
|
||||||
market: MarketEnrichmentContract,
|
market: MarketEnrichmentContract,
|
||||||
items: KeywordCleaningItem[]
|
items: KeywordCleaningItem[]
|
||||||
): KeywordCleaningItem[] {
|
): KeywordCleaningItem[] {
|
||||||
return promoteExactDemandItems(market, rescueExactTrashItems(enforceKeywordCleaningSemanticGuard(market, items)));
|
return promoteExactDemandItems(market, rescueExactTrashItems(market, enforceKeywordCleaningSemanticGuard(market, items)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeywordCleaningMergeKeys(item: KeywordCleaningItem) {
|
function getKeywordCleaningMergeKeys(item: KeywordCleaningItem) {
|
||||||
return [item.wordstatResultId ? `wordstat:${item.wordstatResultId}` : null, `phrase:${item.normalizedPhrase}`].filter(Boolean);
|
return [item.wordstatResultId ? `wordstat:${item.wordstatResultId}` : null, `phrase:${item.normalizedPhrase}`].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldBackfillMissingExactItem(item: KeywordCleaningItem) {
|
function shouldBackfillMissingExactItem(market: MarketEnrichmentContract, item: KeywordCleaningItem) {
|
||||||
return (
|
return (
|
||||||
item.decision !== "trash" &&
|
(item.decision === "use" || item.decision === "support") &&
|
||||||
item.evidenceStatus === "collected" &&
|
item.evidenceStatus === "collected" &&
|
||||||
item.frequency !== null &&
|
item.frequency !== null &&
|
||||||
Boolean(item.wordstatResultId) &&
|
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[] {
|
): KeywordCleaningItem[] {
|
||||||
const knownKeys = new Set(modelItems.flatMap(getKeywordCleaningMergeKeys));
|
const knownKeys = new Set(modelItems.flatMap(getKeywordCleaningMergeKeys));
|
||||||
const missingItems = classifyItems(market, getBaseItems(market))
|
const missingItems = classifyItems(market, getBaseItems(market))
|
||||||
.filter(shouldBackfillMissingExactItem)
|
.filter((item) => shouldBackfillMissingExactItem(market, item))
|
||||||
.filter((item) => getKeywordCleaningMergeKeys(item).every((key) => !knownKeys.has(key)))
|
.filter((item) => getKeywordCleaningMergeKeys(item).every((key) => !knownKeys.has(key)))
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const leftPriority = left.priority === "high" ? 3 : left.priority === "medium" ? 2 : 1;
|
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);
|
return rightPriority - leftPriority || (right.frequency ?? 0) - (left.frequency ?? 0);
|
||||||
})
|
})
|
||||||
.slice(0, 80)
|
.slice(0, 24)
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
evidenceRefs: unique([...item.evidenceRefs, "backend:missing-exact-evidence-backfill"]),
|
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];
|
return [...modelItems, ...missingItems];
|
||||||
|
|
@ -886,13 +907,13 @@ function getBaseItems(contract: MarketEnrichmentContract) {
|
||||||
const briefPhraseMap = buildBriefPhraseMap(contract);
|
const briefPhraseMap = buildBriefPhraseMap(contract);
|
||||||
const briefClusterMap = buildBriefClusterMap(contract);
|
const briefClusterMap = buildBriefClusterMap(contract);
|
||||||
const seedItems = contract.seedQueue
|
const seedItems = contract.seedQueue
|
||||||
.slice(0, 160)
|
.slice(0, 260)
|
||||||
.map((seed, index) => buildSeedItem(seed, index, briefPhraseMap, briefClusterMap));
|
.map((seed, index) => buildSeedItem(seed, index, briefPhraseMap, briefClusterMap));
|
||||||
const seedByPhrase = new Map(contract.seedQueue.map((seed) => [normalizePhrase(seed.phrase), seed]));
|
const seedByPhrase = new Map(contract.seedQueue.map((seed) => [normalizePhrase(seed.phrase), seed]));
|
||||||
const seen = new Set(seedItems.map((item) => item.normalizedPhrase));
|
const seen = new Set(seedItems.map((item) => item.normalizedPhrase));
|
||||||
const relatedItems: KeywordCleaningItem[] = [];
|
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);
|
const normalizedResult = normalizePhrase(result.phrase);
|
||||||
|
|
||||||
if (seen.has(normalizedResult)) {
|
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"] {
|
function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): KeywordCleaningModelTaskContract["input"] {
|
||||||
const briefPhraseMap = buildBriefPhraseMap(contract);
|
const briefPhraseMap = buildBriefPhraseMap(contract);
|
||||||
const briefClusterMap = buildBriefClusterMap(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 {
|
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,
|
clusterId: anchor.clusterId,
|
||||||
clusterTitle: anchor.clusterTitle,
|
clusterTitle: truncateTaskText(anchor.clusterTitle),
|
||||||
confidence: anchor.confidence,
|
confidence: anchor.confidence,
|
||||||
intentType: anchor.intentType,
|
intentType: anchor.intentType,
|
||||||
marketRole: anchor.marketRole,
|
marketRole: anchor.marketRole,
|
||||||
normalizedFrom: anchor.normalizedFrom,
|
normalizedFrom: anchor.normalizedFrom.slice(0, 3).map((phrase) => truncateTaskText(phrase)),
|
||||||
phrase: anchor.phrase,
|
phrase: anchor.phrase,
|
||||||
priority: anchor.priority
|
priority: anchor.priority
|
||||||
})),
|
})),
|
||||||
landingBriefs: contract.briefEvidence.slice(0, 40).map((brief) => ({
|
landingBriefs: contract.briefEvidence.slice(0, KEYWORD_CLEANING_MODEL_LANDING_BRIEF_LIMIT).map((brief) => ({
|
||||||
action: brief.action,
|
action: truncateTaskText(brief.action, 160),
|
||||||
evidenceStatus: brief.evidenceStatus,
|
evidenceStatus: brief.evidenceStatus,
|
||||||
path: brief.path,
|
path: brief.path,
|
||||||
priority: brief.priority,
|
priority: brief.priority,
|
||||||
qualityScore: brief.qualityScore,
|
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) => ({
|
normalizationIntentGroups: contract.normalization.intentGroups.slice(0, KEYWORD_CLEANING_MODEL_NORMALIZATION_GROUP_LIMIT).map((group) => ({
|
||||||
excludedPhrases: group.excludedPhrases.slice(0, 12).map((item) => item.phrase),
|
excludedPhrases: group.excludedPhrases.slice(0, 6).map((item) => truncateTaskText(item.phrase)),
|
||||||
id: group.id,
|
id: group.id,
|
||||||
intentType: group.intentType,
|
intentType: group.intentType,
|
||||||
marketHypothesis: group.marketHypothesis,
|
marketHypothesis: truncateTaskText(group.marketHypothesis, 180),
|
||||||
priority: group.priority,
|
priority: group.priority,
|
||||||
title: group.title,
|
title: truncateTaskText(group.title),
|
||||||
wordstatQueries: group.wordstatQueries.slice(0, 12)
|
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,
|
clusterId: seed.clusterId,
|
||||||
clusterTitle: seed.clusterTitle,
|
clusterTitle: truncateTaskText(seed.clusterTitle),
|
||||||
evidenceStatus: seed.evidenceStatus,
|
evidenceStatus: seed.evidenceStatus,
|
||||||
frequency: seed.frequency,
|
frequency: seed.frequency,
|
||||||
frequencyGroup: seed.frequencyGroup,
|
frequencyGroup: seed.frequencyGroup,
|
||||||
|
|
@ -1013,14 +1077,14 @@ function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): Key
|
||||||
targetPath: getTargetPath(seed, briefPhraseMap, briefClusterMap),
|
targetPath: getTargetPath(seed, briefPhraseMap, briefClusterMap),
|
||||||
wordstatResultId: seed.wordstatResultId
|
wordstatResultId: seed.wordstatResultId
|
||||||
})),
|
})),
|
||||||
wordstatTopResults: contract.wordstat.topResults.slice(0, 120).map((result) => ({
|
wordstatTopResults: modelTopResults.map((result) => ({
|
||||||
frequency: result.frequency,
|
frequency: result.frequency,
|
||||||
frequencyGroup: result.frequencyGroup,
|
frequencyGroup: result.frequencyGroup,
|
||||||
id: result.id,
|
id: result.id,
|
||||||
phrase: result.phrase,
|
phrase: result.phrase,
|
||||||
region: result.region,
|
region: result.region,
|
||||||
sourceClusterId: result.sourceClusterId,
|
sourceClusterId: result.sourceClusterId,
|
||||||
sourceClusterTitle: result.sourceClusterTitle,
|
sourceClusterTitle: truncateTaskText(result.sourceClusterTitle),
|
||||||
sourcePhrase: result.sourcePhrase
|
sourcePhrase: result.sourcePhrase
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
@ -1046,7 +1110,7 @@ function isBroadAiKeywordNoise(phrase: string | null) {
|
||||||
return AI_KEYWORD_PATTERN.test(normalizedPhrase) && !hasB2BKeywordFacet(normalizedPhrase);
|
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 blockers: string[] = [];
|
||||||
const exactEvidence = item.frequency !== null;
|
const exactEvidence = item.frequency !== null;
|
||||||
const noExternalSignal =
|
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) {
|
if (isArticle && exactEvidence) {
|
||||||
return {
|
return {
|
||||||
blockers,
|
blockers,
|
||||||
|
|
@ -1151,7 +1225,7 @@ function classifyItems(contract: MarketEnrichmentContract, items: KeywordCleanin
|
||||||
const contextTokens = buildContextTokenSet(contract);
|
const contextTokens = buildContextTokenSet(contract);
|
||||||
|
|
||||||
return items.map((item) => {
|
return items.map((item) => {
|
||||||
const classification = getClassification(item, contextTokens);
|
const classification = getClassification(contract, item, contextTokens);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
|
|
@ -1181,7 +1255,8 @@ function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningCont
|
||||||
(item) =>
|
(item) =>
|
||||||
(item.decision === "use" || item.decision === "support") &&
|
(item.decision === "use" || item.decision === "support") &&
|
||||||
item.evidenceStatus === "collected" &&
|
item.evidenceStatus === "collected" &&
|
||||||
item.frequency !== null
|
item.frequency !== null &&
|
||||||
|
Boolean(item.wordstatResultId)
|
||||||
).length,
|
).length,
|
||||||
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
|
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
|
||||||
riskyCount: lanes.risky.length,
|
riskyCount: lanes.risky.length,
|
||||||
|
|
@ -1263,9 +1338,11 @@ function buildKeywordCleaningModelTask(
|
||||||
"Не придумывать спрос без Wordstat/SERP evidence.",
|
"Не придумывать спрос без Wordstat/SERP evidence.",
|
||||||
"Stage 5 не выбирает посадочную автоматически: exact-фразы без targetPath можно раскладывать в keyword map, target/landing решает стратегия.",
|
"Stage 5 не выбирает посадочную автоматически: exact-фразы без targetPath можно раскладывать в keyword map, target/landing решает стратегия.",
|
||||||
"Не пропускать exact Wordstat items молча: для каждой значимой exact-фразы из seedQueue/topResults вернуть item с use/support/article/risky/trash и причиной.",
|
"Не пропускать 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 только из-за соседнего рынка.",
|
"Stage 5 собирает глобальную карту спроса, а не rewrite текущей посадочной: частотные exact B2B-смежные ветки с сохранённым source-фасетом должны попадать минимум в support/risky, а не исчезать в article только из-за соседнего рынка.",
|
||||||
"Article/backlog использовать для информационного контента и гипотез без сильного коммерческого/продуктового спроса; не маркировать article каждую подтверждённую смежную B2B-ветку.",
|
"Article/backlog использовать для информационного контента и гипотез без сильного коммерческого/продуктового спроса; не маркировать article каждую подтверждённую смежную B2B-ветку.",
|
||||||
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
|
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
|
||||||
|
"Образовательный спрос (курсы, обучение, тренинги, сертификаты) не отправлять в product keyword map, если Product Understanding/normalization не подтверждает образовательный offer сайта.",
|
||||||
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря; глобальный доминантный термин проекта не должен запрещать отдельные подтверждённые ветки спроса.",
|
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря; глобальный доминантный термин проекта не должен запрещать отдельные подтверждённые ветки спроса.",
|
||||||
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
||||||
"Не сохранять rewrite/apply decisions."
|
"Не сохранять rewrite/apply decisions."
|
||||||
|
|
@ -1303,7 +1380,7 @@ function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">
|
||||||
}
|
}
|
||||||
|
|
||||||
actions.push(
|
actions.push(
|
||||||
`В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} exact-кандидатов в 5 колонок; финальные роли фиксирует пользователь.`
|
`В Stage 3 keyword map предсортировать ${contract.readiness.keywordMapCandidateCount} кандидатов в 5 колонок; exact-frequency закрепляется строже, финальные роли фиксирует пользователь.`
|
||||||
);
|
);
|
||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
|
|
@ -1467,6 +1544,29 @@ export async function getLatestAlignedKeywordCleaning(
|
||||||
return run;
|
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> {
|
export async function getKeywordCleaningContract(projectId: string): Promise<KeywordCleaningContract> {
|
||||||
const market = await getMarketEnrichmentContract(projectId);
|
const market = await getMarketEnrichmentContract(projectId);
|
||||||
const fallbackContract = buildFallbackContract(projectId, market);
|
const fallbackContract = buildFallbackContract(projectId, market);
|
||||||
|
|
|
||||||
|
|
@ -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-профиль не найден.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { pool } from "../db/client.js";
|
import { pool } from "../db/client.js";
|
||||||
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
import { getMarketEnrichmentContract, type MarketEnrichmentContract } from "../market/marketEnrichment.js";
|
||||||
import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js";
|
import { getKeywordCleaningContract, type KeywordCleaningContract } from "./keywordCleaning.js";
|
||||||
|
import { hasEducationIntentMismatch } from "./keywordIntentGuards.js";
|
||||||
|
|
||||||
type KeywordMapState = "blocked" | "draft";
|
type KeywordMapState = "blocked" | "draft";
|
||||||
type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
|
type KeywordMapRole = "differentiator" | "primary" | "secondary" | "secondary_candidate" | "support" | "validate";
|
||||||
|
|
@ -82,6 +83,15 @@ export type KeywordMapPersistResult = {
|
||||||
|
|
||||||
type KeywordMapItem = KeywordMapContract["items"][number];
|
type KeywordMapItem = KeywordMapContract["items"][number];
|
||||||
type KeywordCleaningItem = KeywordCleaningContract["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 = {
|
export type KeywordMapApproveInput = {
|
||||||
itemIds?: string[];
|
itemIds?: string[];
|
||||||
|
|
@ -89,12 +99,39 @@ export type KeywordMapApproveInput = {
|
||||||
itemId: string;
|
itemId: string;
|
||||||
role: KeywordMapRole;
|
role: KeywordMapRole;
|
||||||
}>;
|
}>;
|
||||||
|
suppressedItemIds?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
function isKeywordMapRole(value: string): value is KeywordMapRole {
|
function isKeywordMapRole(value: string): value is KeywordMapRole {
|
||||||
return keywordMapRoles.includes(value as 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) {
|
function normalizePhrase(value: string) {
|
||||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
@ -371,6 +408,10 @@ function getKeywordDecisionRouteWeight(decision: KeywordCleaningItem["decision"]
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isBackendMissingExactBackfillItem(item: KeywordCleaningItem) {
|
||||||
|
return item.evidenceRefs.some((ref) => ref === "backend:missing-exact-evidence-backfill");
|
||||||
|
}
|
||||||
|
|
||||||
function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGuard) {
|
function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGuard) {
|
||||||
const semanticFit = guard ? getSemanticFitAssessment(item, guard) : null;
|
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) {
|
function isLongTailKeywordCandidate(item: KeywordCleaningItem) {
|
||||||
return item.priority === "low" || getKeywordWordCount(item.phrase) >= 5 || (item.frequency !== null && item.frequency < 50);
|
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) {
|
function isClarifyingKeywordCandidate(item: KeywordCleaningItem) {
|
||||||
const text = getKeywordProfileText(item);
|
const text = normalizePhrase(item.phrase);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
item.marketRole === "integration" ||
|
item.marketRole === "integration" ||
|
||||||
item.intentType === "deployment_security" ||
|
item.intentType === "deployment_security" ||
|
||||||
item.intentType === "integration" ||
|
item.intentType === "integration" ||
|
||||||
/(^|\s)(api|bim|crm|erp|mcp|iot|1с|3d|digital twin|on[-\s]?premise)(\s|$)/i.test(text) ||
|
/(^|\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
|
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)) {
|
if (!canModelRouteKeyword(item, guard)) {
|
||||||
return "validate";
|
return "validate";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.decision === "use" && indexInTarget === 0 && item.priority === "high" && !isLongTailKeywordCandidate(item)) {
|
if (hasEducationIntentMismatch(roleContext.market, item.phrase)) {
|
||||||
return "primary";
|
return "validate";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLongTailKeywordCandidate(item)) {
|
if (
|
||||||
return "support";
|
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)) {
|
if (isClarifyingKeywordCandidate(item)) {
|
||||||
return "differentiator";
|
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") {
|
if (indexInTarget <= 7 && (item.priority === "high" || item.priority === "medium") && item.decision !== "article") {
|
||||||
return "secondary";
|
return "secondary";
|
||||||
}
|
}
|
||||||
|
|
@ -463,15 +600,10 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
|
||||||
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
||||||
const targetCounts = new Map<string, number>();
|
const targetCounts = new Map<string, number>();
|
||||||
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
||||||
|
const roleContext: KeywordRoleContext = { market: contract };
|
||||||
const seenPhrases = new Set<string>();
|
const seenPhrases = new Set<string>();
|
||||||
const sourceItems = cleaning.items
|
const sourceItems = cleaning.items
|
||||||
.filter(
|
.filter((item) => isKeywordMapDisplayCandidate(item, semanticFacetGuard, roleContext))
|
||||||
(item) =>
|
|
||||||
item.decision !== "trash" &&
|
|
||||||
item.evidenceStatus === "collected" &&
|
|
||||||
item.frequency !== null &&
|
|
||||||
Boolean(item.wordstatResultId)
|
|
||||||
)
|
|
||||||
.sort((left, right) => {
|
.sort((left, right) => {
|
||||||
const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
|
const leftRouteWeight = canModelRouteKeyword(left, semanticFacetGuard) ? 1 : 0;
|
||||||
const rightRouteWeight = canModelRouteKeyword(right, 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 rawTargetPath = item.targetPath;
|
||||||
const targetKey = rawTargetPath ?? item.clusterId;
|
const targetKey = rawTargetPath ?? item.clusterId;
|
||||||
const indexInTarget = targetCounts.get(targetKey) ?? 0;
|
const indexInTarget = targetCounts.get(targetKey) ?? 0;
|
||||||
const role = getKeywordRole(item, indexInTarget, semanticFacetGuard);
|
const role = getKeywordRole(item, indexInTarget, semanticFacetGuard, roleContext);
|
||||||
const targetPath = role === "secondary_candidate" ? null : rawTargetPath;
|
const targetPath = role === "secondary_candidate" ? null : rawTargetPath;
|
||||||
const relatedLanding = targetPath === null && rawTargetPath ? rawTargetPath : null;
|
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">) {
|
function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
|
||||||
if (contract.blockers.length > 0) {
|
if (contract.blockers.length > 0) {
|
||||||
return contract.blockers;
|
return contract.blockers;
|
||||||
|
|
@ -840,7 +1075,7 @@ function buildNextActions(contract: Omit<KeywordMapContract, "nextActions">) {
|
||||||
|
|
||||||
if (contract.cleaning.keywordMapCandidateCount === 0) {
|
if (contract.cleaning.keywordMapCandidateCount === 0) {
|
||||||
actions.push(
|
actions.push(
|
||||||
"Keyword cleaning не дал exact-кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
|
"Keyword cleaning не дал кандидатов для Stage 5: SEO-план нельзя фиксировать, сначала нужен повторный market evidence или новая seed strategy."
|
||||||
);
|
);
|
||||||
|
|
||||||
if (contract.readiness.marketEvidenceCount === 0) {
|
if (contract.readiness.marketEvidenceCount === 0) {
|
||||||
|
|
@ -880,11 +1115,22 @@ export async function getKeywordMapContract(projectId: string): Promise<KeywordM
|
||||||
]);
|
]);
|
||||||
const reviewedOntology = isReviewedOntology(marketContract.projectOntologyVersion);
|
const reviewedOntology = isReviewedOntology(marketContract.projectOntologyVersion);
|
||||||
const blockers = buildBlockers(marketContract, cleaningContract);
|
const blockers = buildBlockers(marketContract, cleaningContract);
|
||||||
const items = buildItems(marketContract, cleaningContract, reviewedOntology);
|
const semanticRunId = marketContract.semanticRunId;
|
||||||
const persisted = await getKeywordMapPersistenceSummary(
|
const projectOntologyVersionId = marketContract.projectOntologyVersion?.id ?? null;
|
||||||
projectId,
|
const [suppressedItems, persistedLayouts, persisted] = await Promise.all([
|
||||||
marketContract.semanticRunId,
|
getSuppressedKeywordMapItems(projectId, semanticRunId, projectOntologyVersionId),
|
||||||
marketContract.projectOntologyVersion?.id ?? null
|
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"> = {
|
const contractWithoutActions: Omit<KeywordMapContract, "nextActions"> = {
|
||||||
schemaVersion: "keyword-map.v1",
|
schemaVersion: "keyword-map.v1",
|
||||||
|
|
@ -935,6 +1181,7 @@ export async function approveKeywordMapDecisions(
|
||||||
.filter((override) => isKeywordMapRole(override.role))
|
.filter((override) => isKeywordMapRole(override.role))
|
||||||
.map((override) => [override.itemId, override.role] as const)
|
.map((override) => [override.itemId, override.role] as const)
|
||||||
);
|
);
|
||||||
|
const suppressedItemIds = new Set(input.suppressedItemIds ?? []);
|
||||||
const selectedItems = keywordMap.items
|
const selectedItems = keywordMap.items
|
||||||
.filter((item) => !itemIdFilter || itemIdFilter.has(item.id))
|
.filter((item) => !itemIdFilter || itemIdFilter.has(item.id))
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
|
|
@ -942,9 +1189,17 @@ export async function approveKeywordMapDecisions(
|
||||||
|
|
||||||
return roleOverride ? applyKeywordMapRoleOverride(item, roleOverride) : item;
|
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 {
|
return {
|
||||||
keywordMap,
|
keywordMap,
|
||||||
savedDecisionCount: 0,
|
savedDecisionCount: 0,
|
||||||
|
|
@ -960,9 +1215,9 @@ export async function approveKeywordMapDecisions(
|
||||||
|
|
||||||
let savedDecisionCount = 0;
|
let savedDecisionCount = 0;
|
||||||
let savedMapItemCount = 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) {
|
if (activeSourceItemIds.length > 0) {
|
||||||
await client.query(
|
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 }>(
|
const decisionResult = await client.query<{ id: string }>(
|
||||||
`
|
`
|
||||||
insert into keyword_decisions (
|
insert into keyword_decisions (
|
||||||
|
|
@ -1177,7 +1591,7 @@ export async function approveKeywordMapDecisions(
|
||||||
keywordMap: await getKeywordMapContract(projectId),
|
keywordMap: await getKeywordMapContract(projectId),
|
||||||
savedDecisionCount,
|
savedDecisionCount,
|
||||||
savedMapItemCount,
|
savedMapItemCount,
|
||||||
skippedItemCount: selectedItems.length - persistableItems.length
|
skippedItemCount: selectedItems.length - suppressedItems.length - layoutItems.length - approvableItems.length
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query("rollback");
|
await client.query("rollback");
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -100,6 +100,20 @@ function getString(value: unknown) {
|
||||||
return typeof value === "string" ? value.trim() : "";
|
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) {
|
function getNumber(value: unknown) {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
return Math.max(0, Math.round(value));
|
return Math.max(0, Math.round(value));
|
||||||
|
|
@ -124,6 +138,10 @@ function asArray(value: unknown) {
|
||||||
return Array.isArray(value) ? value : [];
|
return Array.isArray(value) ? value : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeArraySources(...values: unknown[]) {
|
||||||
|
return values.flatMap((value) => asArray(value));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRelatedQuery(value: unknown): WordstatRelatedQuery | null {
|
function normalizeRelatedQuery(value: unknown): WordstatRelatedQuery | null {
|
||||||
const record = asRecord(value);
|
const record = asRecord(value);
|
||||||
const phrase =
|
const phrase =
|
||||||
|
|
@ -133,7 +151,7 @@ function normalizeRelatedQuery(value: unknown): WordstatRelatedQuery | null {
|
||||||
getString(record.text) ||
|
getString(record.text) ||
|
||||||
getString(record.name);
|
getString(record.name);
|
||||||
|
|
||||||
if (!phrase) {
|
if (!phrase || !isRealWordstatPhrase(phrase)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,20 +180,27 @@ function normalizeSeedResult(value: unknown, seed: WordstatSeedInput): WordstatS
|
||||||
getNumber(record.count) ??
|
getNumber(record.count) ??
|
||||||
getNumber(record.total) ??
|
getNumber(record.total) ??
|
||||||
getNumber(record.impressions);
|
getNumber(record.impressions);
|
||||||
const relatedSource =
|
const relatedSource = mergeArraySources(
|
||||||
record.relatedQueries ??
|
record.relatedQueries,
|
||||||
record.related_queries ??
|
record.related_queries,
|
||||||
record.queries ??
|
record.queries,
|
||||||
record.items ??
|
record.items,
|
||||||
record.results ??
|
record.results,
|
||||||
record.associations;
|
record.associations,
|
||||||
|
record.topRequests,
|
||||||
|
record.top_requests,
|
||||||
|
record.top,
|
||||||
|
record.popular,
|
||||||
|
record.popularQueries,
|
||||||
|
record.popular_queries
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
seedId: seed.seedId,
|
seedId: seed.seedId,
|
||||||
seedPhrase: phrase,
|
seedPhrase: isRealWordstatPhrase(phrase) ? phrase : seed.phrase,
|
||||||
frequency,
|
frequency,
|
||||||
frequencyGroup: getFrequencyGroup(frequency, record.frequencyGroup ?? record.frequency_group ?? record.group),
|
frequencyGroup: getFrequencyGroup(frequency, record.frequencyGroup ?? record.frequency_group ?? record.group),
|
||||||
relatedQueries: asArray(relatedSource).flatMap((related) => {
|
relatedQueries: relatedSource.flatMap((related) => {
|
||||||
const normalized = normalizeRelatedQuery(related);
|
const normalized = normalizeRelatedQuery(related);
|
||||||
return normalized ? [normalized] : [];
|
return normalized ? [normalized] : [];
|
||||||
}),
|
}),
|
||||||
|
|
@ -237,7 +262,9 @@ class DisabledWordstatProvider implements WordstatProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postJson(endpoint: string, body: unknown, headers: Record<string, string> = {}) {
|
async function postJson(endpoint: string, body: unknown, headers: Record<string, string> = {}) {
|
||||||
const response = await fetch(endpoint, {
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|
@ -245,6 +272,12 @@ async function postJson(endpoint: string, body: unknown, headers: Record<string,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body)
|
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) {
|
if (!response.ok) {
|
||||||
const message = await response.text().catch(() => "");
|
const message = await response.text().catch(() => "");
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ type WordstatResultRow = {
|
||||||
|
|
||||||
const WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT = 100;
|
const WORDSTAT_STATS_REQUESTS_PER_HOUR_LIMIT = 100;
|
||||||
const WORDSTAT_STATS_REQUESTS_PER_SECOND_LIMIT = 10;
|
const WORDSTAT_STATS_REQUESTS_PER_SECOND_LIMIT = 10;
|
||||||
|
const WORDSTAT_MAX_SAFE_SEEDS_PER_COLLECTION = 16;
|
||||||
|
|
||||||
export type WordstatQuotaSnapshot = {
|
export type WordstatQuotaSnapshot = {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
|
@ -154,8 +155,18 @@ function normalizePhrase(value: string) {
|
||||||
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
return value.trim().replace(/\s+/g, " ").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
const WORDSTAT_TOP_RESULTS_LIMIT = 160;
|
function isRealWordstatPhrase(value: string) {
|
||||||
const WORDSTAT_TOP_RESULTS_PER_SOURCE_LIMIT = 12;
|
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([
|
const RELATED_QUERY_STOP_WORDS = new Set([
|
||||||
"a",
|
"a",
|
||||||
|
|
@ -270,7 +281,7 @@ function shouldKeepRelatedQuery(seedPhrase: string, relatedPhrase: string) {
|
||||||
const normalizedSeedPhrase = normalizePhrase(seedPhrase);
|
const normalizedSeedPhrase = normalizePhrase(seedPhrase);
|
||||||
const normalizedRelatedPhrase = normalizePhrase(relatedPhrase);
|
const normalizedRelatedPhrase = normalizePhrase(relatedPhrase);
|
||||||
|
|
||||||
if (!normalizedRelatedPhrase || normalizedRelatedPhrase === normalizedSeedPhrase) {
|
if (!isRealWordstatPhrase(normalizedRelatedPhrase) || normalizedRelatedPhrase === normalizedSeedPhrase) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -352,7 +363,7 @@ function getRepresentativeTopResultRows(results: WordstatResultRow[]) {
|
||||||
const topResultByPhrase = new Map<string, WordstatResultRow>();
|
const topResultByPhrase = new Map<string, WordstatResultRow>();
|
||||||
|
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
if (result.frequency === null) {
|
if (result.frequency === null || !isRealWordstatPhrase(result.phrase)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -448,10 +459,11 @@ function buildEvidence(
|
||||||
results: WordstatResultRow[],
|
results: WordstatResultRow[],
|
||||||
quota: WordstatQuotaSnapshot | null
|
quota: WordstatQuotaSnapshot | null
|
||||||
): WordstatEvidence {
|
): 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>();
|
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) {
|
if (result.source_phrase && result.source_phrase !== result.phrase) {
|
||||||
relatedCounts.set(result.source_phrase, (relatedCounts.get(result.source_phrase) ?? 0) + 1);
|
relatedCounts.set(result.source_phrase, (relatedCounts.get(result.source_phrase) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
@ -475,7 +487,7 @@ function buildEvidence(
|
||||||
seedSource: result.seed_source,
|
seedSource: result.seed_source,
|
||||||
collectedAt: result.created_at.toISOString()
|
collectedAt: result.created_at.toISOString()
|
||||||
})),
|
})),
|
||||||
topResults: getRepresentativeTopResultRows(results)
|
topResults: getRepresentativeTopResultRows(realResults)
|
||||||
.map((result) => ({
|
.map((result) => ({
|
||||||
id: result.id,
|
id: result.id,
|
||||||
phrase: result.phrase,
|
phrase: result.phrase,
|
||||||
|
|
@ -497,11 +509,11 @@ function buildEvidence(
|
||||||
noSignalSeedCount: seedRows.filter(
|
noSignalSeedCount: seedRows.filter(
|
||||||
(result) => result.frequency === null && (relatedCounts.get(result.phrase) ?? 0) === 0
|
(result) => result.frequency === null && (relatedCounts.get(result.phrase) ?? 0) === 0
|
||||||
).length,
|
).length,
|
||||||
resultCount: results.length,
|
resultCount: realResults.length,
|
||||||
totalFrequency: results.reduce((sum, result) => sum + (result.frequency ?? 0), 0),
|
totalFrequency: realResults.reduce((sum, result) => sum + (result.frequency ?? 0), 0),
|
||||||
highFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "high").length,
|
highFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "high").length,
|
||||||
midFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "mid").length,
|
midFrequencyCount: realResults.filter((result) => getFrequencyBucket(result.frequency_group) === "mid").length,
|
||||||
lowFrequencyCount: results.filter((result) => getFrequencyBucket(result.frequency_group) === "low").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)));
|
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> {
|
async function getWordstatQuotaSnapshot(provider: string, mode: string): Promise<WordstatQuotaSnapshot> {
|
||||||
const result = await pool.query<{
|
const result = await pool.query<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -860,7 +893,8 @@ async function updateJobFailed(jobId: string, error: unknown) {
|
||||||
export async function collectWordstatEvidence(
|
export async function collectWordstatEvidence(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
analysis: SemanticAnalysisRun,
|
analysis: SemanticAnalysisRun,
|
||||||
seedCandidates: WordstatSeedCandidate[] = analysis.seedCandidates
|
seedCandidates: WordstatSeedCandidate[] = analysis.seedCandidates,
|
||||||
|
options: { maxSeeds?: number } = {}
|
||||||
) {
|
) {
|
||||||
const provider = await createWordstatProvider();
|
const provider = await createWordstatProvider();
|
||||||
const status = provider.getStatus();
|
const status = provider.getStatus();
|
||||||
|
|
@ -871,14 +905,20 @@ export async function collectWordstatEvidence(
|
||||||
|
|
||||||
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
const seedRows = await ensureSeedRows(projectId, analysis, seedCandidates);
|
||||||
const collectedSeedPhrases = await getCollectedSeedPhraseSet(projectId, analysis.runId);
|
const 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) {
|
if (uncollectedSeedRows.length === 0) {
|
||||||
return getLatestWordstatEvidence(projectId, analysis.runId);
|
return getLatestWordstatEvidence(projectId, analysis.runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const budget = await getWordstatHourlyBudget(status.provider, status.mode);
|
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) {
|
if (maxSeedsForRun <= 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -943,9 +983,7 @@ export async function getLatestWordstatEvidence(
|
||||||
updated_at
|
updated_at
|
||||||
from wordstat_jobs
|
from wordstat_jobs
|
||||||
where project_id = $1 and semantic_run_id = $2
|
where project_id = $1 and semantic_run_id = $2
|
||||||
order by
|
order by created_at desc
|
||||||
case when status = 'done' then 0 else 1 end,
|
|
||||||
created_at desc
|
|
||||||
limit 1;
|
limit 1;
|
||||||
`,
|
`,
|
||||||
[projectId, semanticRunId]
|
[projectId, semanticRunId]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,22 @@
|
||||||
import { getLatestSemanticAnalysis } from "../analysis/semanticAnalysis.js";
|
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 { getLatestAlignedSeoContextReview } from "../contextReview/seoContextReview.js";
|
||||||
import { buildSerpInterpretationModelTask, type SerpInterpretationModelTaskContract } from "../evidence/serpInterpretationTask.js";
|
import { buildSerpInterpretationModelTask, type SerpInterpretationModelTaskContract } from "../evidence/serpInterpretationTask.js";
|
||||||
import { getLatestYandexEvidenceContract } from "../evidence/yandexEvidence.js";
|
import { getLatestYandexEvidenceContract } from "../evidence/yandexEvidence.js";
|
||||||
import { getAnchorReviewContract } from "../keywords/anchorReview.js";
|
import { getAnchorReviewContract } from "../keywords/anchorReview.js";
|
||||||
|
import {
|
||||||
|
getMarketFrontierDiscoveryContract,
|
||||||
|
type MarketFrontierDiscoveryModelTaskContract
|
||||||
|
} from "../market/marketFrontierDiscovery.js";
|
||||||
import {
|
import {
|
||||||
getSeoModelProviderCatalog,
|
getSeoModelProviderCatalog,
|
||||||
type SeoModelProviderCatalog
|
type SeoModelProviderCatalog
|
||||||
|
|
@ -26,7 +40,10 @@ import {
|
||||||
|
|
||||||
export type SeoModelTaskContract =
|
export type SeoModelTaskContract =
|
||||||
| KeywordCleaningModelTaskContract
|
| KeywordCleaningModelTaskContract
|
||||||
|
| SeoBusinessSynthesisModelTaskContract
|
||||||
|
| SeoCommercialDemandModelTaskContract
|
||||||
| SeoContextReviewModelTaskContract
|
| SeoContextReviewModelTaskContract
|
||||||
|
| MarketFrontierDiscoveryModelTaskContract
|
||||||
| SeoNormalizationModelTaskContract
|
| SeoNormalizationModelTaskContract
|
||||||
| SerpInterpretationModelTaskContract
|
| SerpInterpretationModelTaskContract
|
||||||
| StrategyQualityReviewModelTaskContract
|
| StrategyQualityReviewModelTaskContract
|
||||||
|
|
@ -64,8 +81,13 @@ export type SeoModelContextContract = {
|
||||||
} | null;
|
} | null;
|
||||||
stageReadiness: {
|
stageReadiness: {
|
||||||
semanticContextReady: boolean;
|
semanticContextReady: boolean;
|
||||||
|
businessSynthesisTaskReady: boolean;
|
||||||
|
businessSynthesisResultReady: boolean;
|
||||||
|
commercialDemandTaskReady: boolean;
|
||||||
|
commercialDemandResultReady: boolean;
|
||||||
contextReviewTaskReady: boolean;
|
contextReviewTaskReady: boolean;
|
||||||
contextReviewResultReady: boolean;
|
contextReviewResultReady: boolean;
|
||||||
|
marketFrontierDiscoveryTaskReady: boolean;
|
||||||
normalizationState: "not_ready" | "ready";
|
normalizationState: "not_ready" | "ready";
|
||||||
normalizationTaskReady: boolean;
|
normalizationTaskReady: boolean;
|
||||||
keywordCleaningState: "not_ready" | "ready";
|
keywordCleaningState: "not_ready" | "ready";
|
||||||
|
|
@ -80,7 +102,7 @@ export type SeoModelContextContract = {
|
||||||
modelProvider: SeoModelProviderCatalog;
|
modelProvider: SeoModelProviderCatalog;
|
||||||
contextReview: {
|
contextReview: {
|
||||||
runId: string;
|
runId: string;
|
||||||
providerMode: "codex_manual" | "deterministic_fallback";
|
providerMode: "codex_manual" | "codex_workspace" | "deterministic_fallback";
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
needsHumanReview: boolean;
|
needsHumanReview: boolean;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
|
@ -167,7 +189,12 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
const skills = getSeoSkillPackContract();
|
const skills = getSeoSkillPackContract();
|
||||||
const modelProvider = getSeoModelProviderCatalog();
|
const modelProvider = getSeoModelProviderCatalog();
|
||||||
const contextReview = analysis ? await getLatestAlignedSeoContextReview(projectId, analysis.runId) : null;
|
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 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 serpInterpretationTask = buildSerpInterpretationModelTask(projectId, yandexEvidence);
|
||||||
const strategySynthesisTask = buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation);
|
const strategySynthesisTask = buildStrategySynthesisModelTask(projectId, strategy, serpInterpretation);
|
||||||
const strategyQualityReviewTask = buildStrategyQualityReviewModelTask(
|
const strategyQualityReviewTask = buildStrategyQualityReviewModelTask(
|
||||||
|
|
@ -178,6 +205,9 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
);
|
);
|
||||||
const modelTasks = [
|
const modelTasks = [
|
||||||
contextReviewTask,
|
contextReviewTask,
|
||||||
|
businessSynthesisTask,
|
||||||
|
commercialDemandTask,
|
||||||
|
marketFrontierDiscovery?.modelTask,
|
||||||
normalization.modelTask,
|
normalization.modelTask,
|
||||||
keywordCleaning.modelTask,
|
keywordCleaning.modelTask,
|
||||||
serpInterpretationTask,
|
serpInterpretationTask,
|
||||||
|
|
@ -225,8 +255,14 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
allowedEvidence: [
|
allowedEvidence: [
|
||||||
"latest scan metadata",
|
"latest scan metadata",
|
||||||
"project ontology summary",
|
"project ontology summary",
|
||||||
|
"clean selected site text chunks",
|
||||||
"active SEO skill pack contracts",
|
"active SEO skill pack contracts",
|
||||||
"context review model task",
|
"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",
|
"semantic clusters and source refs",
|
||||||
"normalization model task",
|
"normalization model task",
|
||||||
"keyword cleaning model task",
|
"keyword cleaning model task",
|
||||||
|
|
@ -262,8 +298,13 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
: null,
|
: null,
|
||||||
stageReadiness: {
|
stageReadiness: {
|
||||||
semanticContextReady: Boolean(analysis),
|
semanticContextReady: Boolean(analysis),
|
||||||
|
businessSynthesisTaskReady: Boolean(businessSynthesisTask),
|
||||||
|
businessSynthesisResultReady: Boolean(businessSynthesis),
|
||||||
|
commercialDemandTaskReady: Boolean(commercialDemandTask),
|
||||||
|
commercialDemandResultReady: Boolean(commercialDemand),
|
||||||
contextReviewTaskReady: Boolean(contextReviewTask),
|
contextReviewTaskReady: Boolean(contextReviewTask),
|
||||||
contextReviewResultReady: Boolean(contextReview),
|
contextReviewResultReady: Boolean(contextReview),
|
||||||
|
marketFrontierDiscoveryTaskReady: Boolean(marketFrontierDiscovery?.modelTask),
|
||||||
normalizationState: normalization.state,
|
normalizationState: normalization.state,
|
||||||
normalizationTaskReady: Boolean(normalization.modelTask),
|
normalizationTaskReady: Boolean(normalization.modelTask),
|
||||||
keywordCleaningState: keywordCleaning.state,
|
keywordCleaningState: keywordCleaning.state,
|
||||||
|
|
@ -348,6 +389,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
|
"Strategy synthesis объясняет варианты, confidence и gaps; он не является approval и не создаёт изменения сайта.",
|
||||||
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
|
"Strategy quality review проверяет зрелость стратегии, phase runway, evidence refs и hallucination risks; он не открывает rewrite/apply.",
|
||||||
"Keyword map предсортирует exact evidence в пять Stage 3 колонок; Неразобранные не участвуют, финальные роли утверждает пользователь.",
|
"Keyword map предсортирует 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."
|
"Rewrite/apply остаётся заблокированным до отдельного diff contract и human approval."
|
||||||
],
|
],
|
||||||
nextActions: !analysis
|
nextActions: !analysis
|
||||||
|
|
@ -360,7 +402,7 @@ export async function getSeoModelContextContract(projectId: string): Promise<Seo
|
||||||
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
"После yandex-evidence.v1 прогонять seo.serp_interpretation, чтобы отделить реальный интент выдачи от сырых цифр.",
|
||||||
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
|
"После SERP interpretation прогонять seo.strategy_synthesis, чтобы собрать понятный decision layer для пользователя.",
|
||||||
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
|
"После strategy synthesis прогонять seo.strategy_quality_review, чтобы проверить фазовую зрелость и доверие к стратегии.",
|
||||||
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать exact-кандидаты в пять колонок для ручной правки.",
|
"После Wordstat прогонять seo.keyword_cleaning, затем Stage 3 keyword map должен предсортировать широкие кандидаты в пять колонок для ручной правки; exact-frequency нужен для строгой фиксации.",
|
||||||
"Не запускать external market collection без anchor review approval."
|
"Не запускать external market collection без anchor review approval."
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,11 @@ export type SeoAiWorkspacePersistedResult = {
|
||||||
schemaVersion: "seo-model-persisted-result.v1";
|
schemaVersion: "seo-model-persisted-result.v1";
|
||||||
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
status: "promoted_to_domain_evidence" | "stored_as_model_task_evidence";
|
||||||
runType:
|
runType:
|
||||||
|
| "seo_business_synthesis"
|
||||||
|
| "seo_commercial_demand"
|
||||||
|
| "seo_context_review"
|
||||||
| "keyword_cleaning"
|
| "keyword_cleaning"
|
||||||
|
| "market_frontier_discovery"
|
||||||
| "seo_model_task"
|
| "seo_model_task"
|
||||||
| "seo_normalization"
|
| "seo_normalization"
|
||||||
| "seo_strategy_quality_review"
|
| "seo_strategy_quality_review"
|
||||||
|
|
@ -150,6 +154,10 @@ export async function assistantRequest<T>(
|
||||||
throw new Error("ai_workspace_assistant_timeout");
|
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;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
@ -169,6 +177,14 @@ function buildSeoModelTaskPrompt(input: {
|
||||||
`Required top-level keys: ${input.task.outputSchema.requiredTopLevelKeys.join(", ")}.`,
|
`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.",
|
"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 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:",
|
"Hard boundaries:",
|
||||||
"- Work only from the task contract below.",
|
"- Work only from the task contract below.",
|
||||||
|
|
@ -381,11 +397,52 @@ function parseJsonFromText(rawText: string): JsonRecord | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstBrace = text.indexOf("{");
|
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 {
|
try {
|
||||||
const parsed = JSON.parse(text.slice(firstBrace, lastBrace + 1));
|
const parsed = JSON.parse(text.slice(firstBrace, index + 1));
|
||||||
|
return isJsonRecord(parsed) ? parsed : null;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text.slice(firstBrace));
|
||||||
return isJsonRecord(parsed) ? parsed : null;
|
return isJsonRecord(parsed) ? parsed : null;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ import { getSeoAiWorkspaceConfigStatus } from "./aiWorkspaceBridgeConfig.js";
|
||||||
|
|
||||||
export const SEO_MODEL_TASK_TYPES = [
|
export const SEO_MODEL_TASK_TYPES = [
|
||||||
"seo.context_review",
|
"seo.context_review",
|
||||||
|
"seo.business_synthesis",
|
||||||
|
"seo.commercial_demand",
|
||||||
|
"seo.market_frontier_discovery",
|
||||||
"seo.normalization",
|
"seo.normalization",
|
||||||
"seo.keyword_cleaning",
|
"seo.keyword_cleaning",
|
||||||
"seo.serp_interpretation",
|
"seo.serp_interpretation",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,28 @@
|
||||||
import { pool } from "../db/client.js";
|
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 { saveKeywordCleaningFromModelProvider, type KeywordCleaningContract } from "../keywords/keywordCleaning.js";
|
||||||
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
|
import { getSeoModelContextContract, type SeoModelTaskContract } from "../modelContext/seoModelContext.js";
|
||||||
import { saveSeoNormalizationFromModelProvider, type SeoNormalizationContract } from "../normalization/seoNormalization.js";
|
import {
|
||||||
|
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 { saveStrategyQualityReviewFromModelProvider, type StrategyQualityReviewContract } from "../strategy/strategyQualityReview.js";
|
||||||
import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js";
|
import { saveStrategySynthesisFromModelProvider, type StrategySynthesisContract } from "../strategy/strategySynthesis.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -157,6 +178,22 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) {
|
||||||
return 2600;
|
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") {
|
if (task.taskType === "seo.keyword_cleaning") {
|
||||||
return 9000;
|
return 9000;
|
||||||
}
|
}
|
||||||
|
|
@ -417,6 +454,68 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
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 {
|
function getRunAiWorkspaceDispatch(run: SeoModelTaskRun): SeoAiWorkspaceDispatch | null {
|
||||||
const fromOutput = isRecord(run.modelOutput) && isRecord(run.modelOutput.aiWorkspace)
|
const fromOutput = isRecord(run.modelOutput) && isRecord(run.modelOutput.aiWorkspace)
|
||||||
? run.modelOutput.aiWorkspace
|
? run.modelOutput.aiWorkspace
|
||||||
|
|
@ -465,11 +564,91 @@ async function promoteAiWorkspaceModelOutput(
|
||||||
return null;
|
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") {
|
if (run.task.taskType === "seo.normalization") {
|
||||||
|
const fallback = getNormalizationPromotionFallback(run);
|
||||||
|
|
||||||
await saveSeoNormalizationFromModelProvider(
|
await saveSeoNormalizationFromModelProvider(
|
||||||
projectId,
|
projectId,
|
||||||
modelOutput.parsedJson as unknown as SeoNormalizationContract,
|
modelOutput.parsedJson as unknown as SeoNormalizationContract,
|
||||||
run.runId
|
run.runId,
|
||||||
|
fallback
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
nextAction: "SEO normalization обновлена из AI Workspace output; можно пересобирать anchor review/Wordstat queue.",
|
nextAction: "SEO normalization обновлена из AI Workspace output; можно пересобирать anchor review/Wordstat queue.",
|
||||||
|
|
@ -538,6 +717,14 @@ async function refreshRunningSeoModelTaskRun(row: SeoModelTaskRunRow) {
|
||||||
return run;
|
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);
|
const dispatch = getRunAiWorkspaceDispatch(run);
|
||||||
|
|
||||||
if (!dispatch) {
|
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) {
|
async function refreshRunningSeoModelTaskRuns(projectId: string) {
|
||||||
const result = await pool.query<SeoModelTaskRunRow>(
|
const result = await pool.query<SeoModelTaskRunRow>(
|
||||||
`
|
`
|
||||||
|
|
@ -708,7 +919,8 @@ export async function runSeoModelTask(projectId: string, input: SeoModelTaskRunI
|
||||||
const dispatchedRun = await updateSeoModelTaskRun(queuedRun.runId, {
|
const dispatchedRun = await updateSeoModelTaskRun(queuedRun.runId, {
|
||||||
input: {
|
input: {
|
||||||
...baseInput,
|
...baseInput,
|
||||||
aiWorkspace: dispatch
|
aiWorkspace: dispatch,
|
||||||
|
task
|
||||||
},
|
},
|
||||||
output: dispatchedOutput,
|
output: dispatchedOutput,
|
||||||
status: "running"
|
status: "running"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -253,20 +253,67 @@ const INTENT_SIGNAL_GROUP_META = {
|
||||||
const ONTOLOGY_STOP_WORDS = new Set([
|
const ONTOLOGY_STOP_WORDS = new Set([
|
||||||
"and",
|
"and",
|
||||||
"app",
|
"app",
|
||||||
|
"assets",
|
||||||
|
"attr",
|
||||||
|
"bodyhtml",
|
||||||
|
"bottominfo",
|
||||||
|
"card",
|
||||||
|
"collection",
|
||||||
"com",
|
"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) ?? [];
|
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 counts = new Map<string, number>();
|
||||||
|
const stopTerms = new Set([...extraStopTerms.map(normalizeOntologyText), ...ONTOLOGY_STOP_WORDS]);
|
||||||
|
|
||||||
for (const word of getTextWords(value)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -526,7 +574,7 @@ function buildGenericLandingBrief(brandName: string, documents: ProjectOntologyE
|
||||||
|
|
||||||
function buildCoreOfferQueries(brandName: string, topTerms: string[]) {
|
function buildCoreOfferQueries(brandName: string, topTerms: string[]) {
|
||||||
const evidenceTerms = topTerms.filter((term) => normalizeOntologyText(term) !== normalizeOntologyText(brandName)).slice(0, 5);
|
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;
|
const topicPair = evidenceTerms.length >= 2 ? `${evidenceTerms[0]} ${evidenceTerms[1]}` : null;
|
||||||
|
|
||||||
return dedupeStrings([brandName, ...pairedTerms, topicPair ?? ""]).slice(0, 4);
|
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[]) {
|
function buildEvidenceSeedQueries(brandName: string, matchedTerms: string[], topTerms: string[]) {
|
||||||
const brandKey = normalizeOntologyText(brandName);
|
const brandKey = normalizeOntologyText(brandName);
|
||||||
const terms = dedupeStrings([...matchedTerms, ...topTerms])
|
const evidenceTerms = matchedTerms.length > 0 ? matchedTerms : topTerms;
|
||||||
|
const terms = dedupeStrings(evidenceTerms)
|
||||||
.filter((term) => {
|
.filter((term) => {
|
||||||
const normalizedTerm = normalizeOntologyText(term);
|
const normalizedTerm = normalizeOntologyText(term);
|
||||||
|
|
||||||
|
|
@ -557,7 +606,8 @@ function buildEvidenceSeedQueries(brandName: string, matchedTerms: string[], top
|
||||||
function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: string) {
|
function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: string) {
|
||||||
const documents = getCoverageDocuments(evidence);
|
const documents = getCoverageDocuments(evidence);
|
||||||
const allText = normalizeOntologyText(documents.map((document) => document.text).join("\n"));
|
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 homeDocument = documents.find((document) => document.kind === "page") ?? documents[0] ?? null;
|
||||||
const homeTarget = homeDocument ? getPagePath(homeDocument) : "/";
|
const homeTarget = homeDocument ? getPagePath(homeDocument) : "/";
|
||||||
const coreKeywords = dedupeStrings([...topTerms.slice(0, 8), ...buildBrandTerms(brandName, evidence)]).slice(0, 10);
|
const coreKeywords = dedupeStrings([...topTerms.slice(0, 8), ...buildBrandTerms(brandName, evidence)]).slice(0, 10);
|
||||||
|
|
@ -598,7 +648,7 @@ function buildExtractedClusters(evidence: ProjectOntologyEvidence, brandName: st
|
||||||
priority,
|
priority,
|
||||||
targetIntent: meta.targetIntent,
|
targetIntent: meta.targetIntent,
|
||||||
intentGroups: [groupId],
|
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),
|
queryExamples: seedQueries.length > 0 ? seedQueries : buildCoreOfferQueries(brandName, topTerms),
|
||||||
targetPages: targetPages.length > 0 ? targetPages : [homeTarget]
|
targetPages: targetPages.length > 0 ? targetPages : [homeTarget]
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,11 @@ import {
|
||||||
saveSeoContextReviewManual,
|
saveSeoContextReviewManual,
|
||||||
type SeoContextReviewOutput
|
type SeoContextReviewOutput
|
||||||
} from "../contextReview/seoContextReview.js";
|
} 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 { getSeoModelContextContract } from "../modelContext/seoModelContext.js";
|
||||||
import { getSeoStrategyContract } from "../strategy/seoStrategy.js";
|
import { getSeoStrategyContract } from "../strategy/seoStrategy.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -90,6 +94,17 @@ import {
|
||||||
saveKeywordCleaningManual,
|
saveKeywordCleaningManual,
|
||||||
type KeywordCleaningContract
|
type KeywordCleaningContract
|
||||||
} from "../keywords/keywordCleaning.js";
|
} from "../keywords/keywordCleaning.js";
|
||||||
|
import {
|
||||||
|
getLatestKeywordAnalysisWorkflow,
|
||||||
|
startKeywordAnalysisWorkflow
|
||||||
|
} from "../keywords/keywordAnalysisWorkflow.js";
|
||||||
|
import {
|
||||||
|
deleteKeywordContextSnapshot,
|
||||||
|
listKeywordContextSnapshots,
|
||||||
|
renameKeywordContextSnapshot,
|
||||||
|
saveKeywordContextSnapshot,
|
||||||
|
updateKeywordContextSnapshotCuration
|
||||||
|
} from "../keywords/keywordContextSnapshots.js";
|
||||||
import {
|
import {
|
||||||
addAnchorReviewManualAnchor,
|
addAnchorReviewManualAnchor,
|
||||||
getAnchorReviewContract,
|
getAnchorReviewContract,
|
||||||
|
|
@ -235,8 +250,43 @@ const keywordMapApproveSchema = z.object({
|
||||||
role: z.enum(["differentiator", "primary", "secondary", "secondary_candidate", "support", "validate"])
|
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()
|
.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({
|
const contextReviewManualSchema = z.object({
|
||||||
output: z.record(z.string(), z.unknown())
|
output: z.record(z.string(), z.unknown())
|
||||||
});
|
});
|
||||||
|
|
@ -251,10 +301,12 @@ const anchorReviewDecisionSchema = z.object({
|
||||||
reason: z.string().trim().max(1000).optional(),
|
reason: z.string().trim().max(1000).optional(),
|
||||||
sendToSerp: z.boolean().optional(),
|
sendToSerp: z.boolean().optional(),
|
||||||
sendToWordstat: 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({
|
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({
|
const anchorReviewManualAnchorSchema = z.object({
|
||||||
phrase: z.string().trim().min(1).max(180),
|
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) => {
|
projectsRouter.get("/:projectId/seo-normalization/latest", async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
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 ?? {});
|
const input = anchorReviewDecisionsSchema.parse(request.body ?? {});
|
||||||
|
|
||||||
response.status(201).json({
|
response.status(201).json({
|
||||||
anchorReview: await saveAnchorReviewDecisions(projectId, input.decisions ?? [])
|
anchorReview: await saveAnchorReviewDecisions(projectId, input.decisions, input.demandCollectionProfile)
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof z.ZodError) {
|
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) => {
|
projectsRouter.post("/:projectId/keyword-cleaning/manual", async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
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) => {
|
projectsRouter.get("/:projectId/strategy/latest", async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||||
|
|
|
||||||
|
|
@ -256,7 +256,7 @@ const serviceDefinitions: Record<ExternalServiceId, ExternalServiceDefinition> =
|
||||||
label: "Max seeds per run",
|
label: "Max seeds per run",
|
||||||
type: "number",
|
type: "number",
|
||||||
required: false,
|
required: false,
|
||||||
placeholder: "40",
|
placeholder: "12",
|
||||||
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
|
help: "Сколько seed-фраз максимум отправлять за один запуск Wordstat, чтобы не сжечь внешний бюджет."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -422,7 +422,7 @@ function getFieldDefault(serviceId: ExternalServiceId, fieldId: string) {
|
||||||
apiBaseUrl: WORDSTAT_BASE_URL,
|
apiBaseUrl: WORDSTAT_BASE_URL,
|
||||||
devices: "DEVICE_ALL",
|
devices: "DEVICE_ALL",
|
||||||
numPhrases: "50",
|
numPhrases: "50",
|
||||||
maxSeedsPerRun: "40",
|
maxSeedsPerRun: "12",
|
||||||
regionIds: "225"
|
regionIds: "225"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -772,7 +772,7 @@ export async function getWordstatRuntimeConfig(): Promise<WordstatRuntimeConfig>
|
||||||
provider: "mcp_kv",
|
provider: "mcp_kv",
|
||||||
mode: "mcp_kv",
|
mode: "mcp_kv",
|
||||||
mcpEndpoint: endpoint,
|
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"]),
|
regionIds: splitCsv(mapped.publicConfig.regionIds, ["225"]),
|
||||||
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
|
devices: splitCsv(mapped.publicConfig.devices, ["DEVICE_ALL"]),
|
||||||
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
|
numPhrases: getNumber(mapped.publicConfig.numPhrases, 50),
|
||||||
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 40)
|
maxSeedsPerRun: getNumber(mapped.publicConfig.maxSeedsPerRun, 12)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,46 @@ export type SeoContextReviewModelTaskContract = {
|
||||||
wordCount: number;
|
wordCount: number;
|
||||||
matchedClusterIds: string[];
|
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<{
|
semanticSignals: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -224,12 +264,13 @@ const SEO_SKILL_MANIFESTS: SeoSkillManifest[] = [
|
||||||
summary:
|
summary:
|
||||||
"Первый полноценный AI entrypoint: модель объясняет смысл сайта, страницы, оффера, аудитории, gaps, ambiguity и forbidden claims по evidence.",
|
"Первый полноценный AI entrypoint: модель объясняет смысл сайта, страницы, оффера, аудитории, gaps, ambiguity и forbidden claims по evidence.",
|
||||||
owner: "seo_mode",
|
owner: "seo_mode",
|
||||||
inputs: ["semantic_analysis", "project_ontology", "page_scope", "style_profile", "crawl_indexability_evidence"],
|
inputs: ["semantic_analysis", "project_ontology", "page_scope", "style_profile", "crawl_indexability_evidence", "site_text_evidence"],
|
||||||
requiredEvidence: ["semantic_clusters", "page_signals", "content_sources", "source_refs", "style_profile"],
|
requiredEvidence: ["clean_visible_text_chunks", "semantic_clusters", "page_signals", "content_sources", "source_refs", "style_profile"],
|
||||||
deterministicChecks: [
|
deterministicChecks: [
|
||||||
{ id: "analysis_ready", title: "Semantic analysis exists", source: "backend", required: true },
|
{ id: "analysis_ready", title: "Semantic analysis exists", source: "backend", required: true },
|
||||||
{ id: "scope_ready", title: "Scope summary 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: [
|
modelTasks: [
|
||||||
{
|
{
|
||||||
|
|
@ -548,10 +589,37 @@ function getEvidenceRefs(analysis: SemanticAnalysisRun): SeoContextReviewModelTa
|
||||||
.slice(0, 60);
|
.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(
|
export function buildSeoContextReviewTask(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
analysis: SemanticAnalysisRun
|
analysis: SemanticAnalysisRun
|
||||||
): SeoContextReviewModelTaskContract {
|
): SeoContextReviewModelTaskContract {
|
||||||
|
const siteTextEvidence = getTextCorpusEvidence(analysis);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
taskType: "seo.context_review",
|
taskType: "seo.context_review",
|
||||||
schemaVersion: "seo-context-review-task.v1",
|
schemaVersion: "seo-context-review-task.v1",
|
||||||
|
|
@ -597,6 +665,7 @@ export function buildSeoContextReviewTask(
|
||||||
wordCount: source.wordCount,
|
wordCount: source.wordCount,
|
||||||
matchedClusterIds: source.matchedClusters
|
matchedClusterIds: source.matchedClusters
|
||||||
})),
|
})),
|
||||||
|
siteTextEvidence,
|
||||||
semanticSignals: analysis.clusters.slice(0, 30).map((cluster) => ({
|
semanticSignals: analysis.clusters.slice(0, 30).map((cluster) => ({
|
||||||
evidenceLevel: cluster.evidence.level,
|
evidenceLevel: cluster.evidence.level,
|
||||||
id: cluster.id,
|
id: cluster.id,
|
||||||
|
|
@ -657,7 +726,7 @@ export function buildSeoContextReviewTask(
|
||||||
requireHumanReviewWhenAmbiguous: true
|
requireHumanReviewWhenAmbiguous: true
|
||||||
},
|
},
|
||||||
stopConditions: [
|
stopConditions: [
|
||||||
"Недостаточно evidence refs для определения реального оффера сайта.",
|
"Недостаточно clean text evidence или evidence refs для определения реального оффера сайта.",
|
||||||
"Сайт выражен слишком слабо, и модель не может отделить продукт от технической оболочки.",
|
"Сайт выражен слишком слабо, и модель не может отделить продукт от технической оболочки.",
|
||||||
"Запрошенное действие требует business expansion, rewrite или apply, которые запрещены на context review stage."
|
"Запрошенное действие требует business expansion, rewrite или apply, которые запрещены на context review stage."
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue