@@ -17248,6 +17397,7 @@ export function App() {
activeFilter={activeAnchorReviewFilter}
anchorReview={anchorReview}
isBulkApproving={isApprovingAnchorReview}
+ isManualAdding={anchorReviewActionKey === `${project.id}:manual-anchor`}
onDecision={(_anchor, decision) => void handleAnchorReviewDecision(project, decision)}
onFilterChange={(filter) =>
setAnchorReviewFilters((currentFilters) => ({
@@ -17255,6 +17405,7 @@ export function App() {
[project.id]: filter
}))
}
+ onManualAdd={() => openManualAnchorModal(project)}
stageProjectId={project.id}
sectionId={`keyword-anchor-review:${project.id}`}
/>
diff --git a/seo_mode/seo_mode/app/src/api.ts b/seo_mode/seo_mode/app/src/api.ts
index 2163591..4569fd2 100644
--- a/seo_mode/seo_mode/app/src/api.ts
+++ b/seo_mode/seo_mode/app/src/api.ts
@@ -821,6 +821,13 @@ export type AnchorReviewDecisionInput = {
status: AnchorReviewDecision["status"];
};
+export type AnchorReviewManualAnchorInput = {
+ phrase: string;
+ reason?: string;
+ sendToSerp?: boolean;
+ sendToWordstat?: boolean;
+};
+
export type MarketEnrichmentContract = {
schemaVersion: "market-enrichment.v1";
projectId: string;
@@ -2990,6 +2997,22 @@ export async function saveAnchorReviewDecisions(projectId: string, decisions?: A
return response.json() as Promise<{ anchorReview: AnchorReviewContract }>;
}
+export async function addAnchorReviewManualAnchor(projectId: string, input: AnchorReviewManualAnchorInput) {
+ const response = await fetch(`${apiBaseUrl}/projects/${projectId}/anchor-review/manual-anchor`, {
+ body: JSON.stringify(input),
+ headers: {
+ "Content-Type": "application/json"
+ },
+ method: "POST"
+ });
+
+ if (!response.ok) {
+ throw new Error(await getErrorMessage(response, `Не удалось добавить ручной якорь: ${response.status}`));
+ }
+
+ return response.json() as Promise<{ anchorReview: AnchorReviewContract }>;
+}
+
export async function fetchLatestKeywordCleaning(projectId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-cleaning/latest`);
diff --git a/seo_mode/seo_mode/app/src/styles.css b/seo_mode/seo_mode/app/src/styles.css
index c20b907..977afec 100644
--- a/seo_mode/seo_mode/app/src/styles.css
+++ b/seo_mode/seo_mode/app/src/styles.css
@@ -2958,6 +2958,18 @@ input {
background: #fff;
}
+.keyword-anchor-stage-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 10px;
+ min-width: 0;
+}
+
+.keyword-anchor-stage-head > .keyword-stage-heading {
+ flex: 1 1 auto;
+}
+
.anchor-review-row > div:first-child span {
justify-self: start;
min-height: 22px;
@@ -13111,6 +13123,31 @@ textarea:focus {
border-radius: 1rem;
}
+.seo-manual-anchor-modal {
+ width: min(34rem, 100%);
+}
+
+.seo-manual-anchor-modal-title {
+ display: grid;
+ gap: 0.25rem;
+ min-width: 0;
+}
+
+.seo-manual-anchor-modal-title strong {
+ color: var(--ink);
+ font-size: 1.28rem;
+ font-weight: 920;
+ line-height: 1.05;
+ overflow-wrap: anywhere;
+}
+
+.seo-manual-anchor-modal-title small {
+ color: var(--muted);
+ font-size: 0.78rem;
+ font-weight: 760;
+ overflow-wrap: anywhere;
+}
+
.seo-project-name-modal-actions {
display: flex;
justify-content: flex-end;
diff --git a/seo_mode/seo_mode/server/src/keywords/anchorReview.ts b/seo_mode/seo_mode/server/src/keywords/anchorReview.ts
index 4baaaa0..23ad1ba 100644
--- a/seo_mode/seo_mode/server/src/keywords/anchorReview.ts
+++ b/seo_mode/seo_mode/server/src/keywords/anchorReview.ts
@@ -14,6 +14,13 @@ export type AnchorReviewDecisionInput = {
status: AnchorDecisionStatus;
};
+export type AnchorReviewManualAnchorInput = {
+ phrase: string;
+ reason?: string;
+ sendToSerp?: boolean;
+ sendToWordstat?: boolean;
+};
+
export type AnchorReviewDecision = {
decidedAt: string | null;
decidedBy: string | null;
@@ -107,6 +114,7 @@ type AnchorReviewRunRow = {
};
type DecisionSnapshot = {
+ anchor?: AnchorReviewAnchor;
id: string;
key: string;
decision: AnchorReviewDecision;
@@ -182,12 +190,71 @@ function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorRevi
function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] {
return (savedOutput?.anchors ?? []).map((anchor) => ({
+ anchor,
decision: anchor.decision,
id: anchor.id,
key: anchor.key
}));
}
+function normalizeSavedManualDecision(decision: AnchorReviewDecision): AnchorReviewDecision {
+ if (decision.status === "approved") {
+ return {
+ decidedAt: decision.decidedAt,
+ decidedBy: decision.decidedBy,
+ reason: decision.reason || "Human-added semantic basis anchor.",
+ sendToSerp: Boolean(decision.sendToSerp),
+ sendToWordstat: Boolean(decision.sendToWordstat),
+ source: decision.source,
+ status: decision.sendToWordstat || decision.sendToSerp ? "approved" : "pending"
+ };
+ }
+
+ if (decision.status === "disabled") {
+ return {
+ decidedAt: decision.decidedAt,
+ decidedBy: decision.decidedBy,
+ reason: decision.reason || "Manual anchor disabled by review.",
+ sendToSerp: false,
+ sendToWordstat: false,
+ source: decision.source,
+ status: "disabled"
+ };
+ }
+
+ return {
+ decidedAt: decision.decidedAt,
+ decidedBy: decision.decidedBy,
+ reason: decision.reason || "Manual anchor awaits review.",
+ sendToSerp: false,
+ sendToWordstat: false,
+ source: decision.source,
+ status: "pending"
+ };
+}
+
+function getSavedManualAnchors(
+ decisionSnapshots: DecisionSnapshot[],
+ seedAnchorIds: Set
,
+ seedAnchorKeys: Set
+): AnchorReviewAnchor[] {
+ return decisionSnapshots
+ .map((snapshot) => snapshot.anchor)
+ .filter((anchor): anchor is AnchorReviewAnchor => Boolean(anchor))
+ .filter((anchor) => !seedAnchorIds.has(anchor.id) && !seedAnchorKeys.has(anchor.key))
+ .map((anchor) => ({
+ ...anchor,
+ decision: normalizeSavedManualDecision(anchor.decision),
+ proposal: {
+ blockers: Array.isArray(anchor.proposal.blockers) ? anchor.proposal.blockers : [],
+ reason: anchor.proposal.reason || "Ручной якорь семантического базиса.",
+ sendToSerp: Boolean(anchor.proposal.sendToSerp),
+ sendToWordstat: Boolean(anchor.proposal.sendToWordstat),
+ status: anchor.proposal.status === "rejected" ? "needs_human" : anchor.proposal.status
+ }
+ }));
+}
+
function buildAnchor(
seed: SeoNormalizationSeed,
index: number,
@@ -300,18 +367,21 @@ function buildContract(
const anchors = normalization.seedStrategy.map((seed, index) =>
buildAnchor(seed, index, decisionSnapshotsById, decisionSnapshotsByKey)
);
- const wordstatQueue = buildWordstatQueue(anchors);
- const pendingAnchorCount = anchors.filter((anchor) => anchor.decision.status === "pending").length;
- const approvedAnchorCount = anchors.filter((anchor) => anchor.decision.status === "approved").length;
- const disabledAnchorCount = anchors.filter((anchor) => anchor.decision.status === "disabled").length;
- const manualDecisionCount = anchors.filter((anchor) => anchor.decision.decidedAt).length;
+ const seedAnchorIds = new Set(anchors.map((anchor) => anchor.id));
+ const seedAnchorKeys = new Set(anchors.map((anchor) => anchor.key));
+ const allAnchors = [...anchors, ...getSavedManualAnchors(decisionSnapshots, seedAnchorIds, seedAnchorKeys)];
+ const wordstatQueue = buildWordstatQueue(allAnchors);
+ const pendingAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "pending").length;
+ const approvedAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "approved").length;
+ const disabledAnchorCount = allAnchors.filter((anchor) => anchor.decision.status === "disabled").length;
+ const manualDecisionCount = allAnchors.filter((anchor) => anchor.decision.decidedAt).length;
const contractWithoutActions: Omit = {
schemaVersion: "anchor-review.v1",
projectId,
semanticRunId: normalization.semanticRunId,
projectOntologyVersionId: normalization.projectOntologyVersionId,
generatedAt: new Date().toISOString(),
- state: getState(normalization, anchors, wordstatQueue.length),
+ state: getState(normalization, allAnchors, wordstatQueue.length),
source: {
normalizationGeneratedAt: normalization.generatedAt,
normalizationProviderMode: normalization.provider.mode,
@@ -331,14 +401,14 @@ function buildContract(
approvedAnchorCount,
disabledAnchorCount,
manualDecisionCount,
- needsHumanReviewCount: anchors.filter((anchor) => anchor.proposal.status === "needs_human").length,
- normalizedAnchorCount: anchors.length,
+ needsHumanReviewCount: allAnchors.filter((anchor) => anchor.proposal.status === "needs_human").length,
+ normalizedAnchorCount: allAnchors.length,
pendingAnchorCount,
- proposedWordstatCount: anchors.filter((anchor) => anchor.proposal.sendToWordstat).length,
+ proposedWordstatCount: allAnchors.filter((anchor) => anchor.proposal.sendToWordstat).length,
sourceSeedCount: normalization.readiness.sourceSeedCount,
wordstatQueueCount: wordstatQueue.length
},
- anchors,
+ anchors: allAnchors,
wordstatQueue
};
@@ -462,6 +532,7 @@ export async function saveAnchorReviewDecisions(
}
const decisionSnapshots: DecisionSnapshot[] = current.anchors.map((anchor) => ({
+ anchor,
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
id: anchor.id,
key: anchor.key
@@ -487,3 +558,129 @@ export async function saveAnchorReviewDecisions(
return result.rows[0]?.output ?? output;
}
+
+function getManualAnchorId(phrase: string, existingIds: Set) {
+ const slug = normalizePhrase(phrase)
+ .replace(/[^0-9a-zа-яё]+/gi, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 90);
+ const baseId = `manual:${slug || "anchor"}`;
+ let id = baseId;
+ let index = 2;
+
+ while (existingIds.has(id)) {
+ id = `${baseId}-${index}`;
+ index += 1;
+ }
+
+ return id;
+}
+
+export async function addAnchorReviewManualAnchor(
+ projectId: string,
+ input: AnchorReviewManualAnchorInput
+): Promise {
+ const current = await getAnchorReviewContract(projectId);
+
+ if (!current.semanticRunId || current.state === "not_ready") {
+ throw new Error("Anchor review не готов: сначала нужен semantic analysis, context review и normalization.");
+ }
+
+ const phrase = input.phrase.trim().replace(/\s+/g, " ");
+
+ if (!phrase) {
+ throw new Error("Нужно передать ручной ключ.");
+ }
+
+ const normalizedPhrase = normalizePhrase(phrase);
+ const existingAnchor = current.anchors.find((anchor) => normalizePhrase(anchor.phrase) === normalizedPhrase);
+
+ if (existingAnchor) {
+ return saveAnchorReviewDecisions(projectId, [
+ {
+ anchorId: existingAnchor.id,
+ reason: input.reason || "Пользователь добавил существующий якорь в семантический базис.",
+ sendToSerp: input.sendToSerp ?? existingAnchor.proposal.sendToSerp,
+ sendToWordstat: input.sendToWordstat ?? true,
+ status: "approved"
+ }
+ ]);
+ }
+
+ const decidedAt = new Date().toISOString();
+ const existingIds = new Set(current.anchors.map((anchor) => anchor.id));
+ const id = getManualAnchorId(phrase, existingIds);
+ const reference =
+ current.anchors.find((anchor) => anchor.marketRole === "core") ??
+ current.anchors.find((anchor) => anchor.proposal.sendToWordstat) ??
+ current.anchors[0];
+ const sendToSerp = input.sendToSerp ?? reference?.proposal.sendToSerp ?? true;
+ const sendToWordstat = input.sendToWordstat ?? true;
+ const manualAnchor: AnchorReviewAnchor = {
+ clusterId: "manual_semantic_basis",
+ clusterTitle: "Ручной семантический базис",
+ confidence: 1,
+ decision: {
+ decidedAt,
+ decidedBy: "dev-mode",
+ reason: input.reason?.trim() || "Пользователь вручную добавил якорь в семантический базис.",
+ sendToSerp,
+ sendToWordstat,
+ source: "human",
+ status: sendToSerp || sendToWordstat ? "approved" : "pending"
+ },
+ evidenceRefs: ["manual.semantic_basis"],
+ id,
+ intentGroupId: reference?.intentGroupId ?? "manual_semantic_basis",
+ intentType: reference?.intentType ?? "product",
+ key: `manual_semantic_basis:${normalizedPhrase}`,
+ marketRole: reference?.marketRole === "exclude" ? "core" : reference?.marketRole ?? "core",
+ normalizedFrom: [phrase],
+ phrase,
+ priority: "high",
+ proposal: {
+ blockers: [],
+ reason: "Ручной якорь семантического базиса: отправляется в проверку Wordstat по решению пользователя.",
+ sendToSerp,
+ sendToWordstat,
+ status: "auto_approved"
+ },
+ reason: "Ручной якорь семантического базиса.",
+ source: "detected"
+ };
+ const decisionSnapshots: DecisionSnapshot[] = [
+ ...current.anchors.map((anchor) => ({
+ anchor,
+ decision: anchor.decision,
+ id: anchor.id,
+ key: anchor.key
+ })),
+ {
+ anchor: manualAnchor,
+ decision: manualAnchor.decision,
+ id: manualAnchor.id,
+ key: manualAnchor.key
+ }
+ ];
+ const normalization = await getSeoNormalizationContract(projectId);
+ const output = buildContract(projectId, normalization, decisionSnapshots);
+ const result = await pool.query(
+ `
+ insert into runs (project_id, run_type, status, input, output, started_at, completed_at)
+ values ($1, 'anchor_review', 'done', $2::jsonb, $3::jsonb, now(), now())
+ returning id, output;
+ `,
+ [
+ projectId,
+ JSON.stringify({
+ anchorId: manualAnchor.id,
+ phrase: manualAnchor.phrase,
+ schemaVersion: "anchor-review-manual-anchor-input.v1",
+ semanticRunId: output.semanticRunId
+ }),
+ JSON.stringify(output)
+ ]
+ );
+
+ return result.rows[0]?.output ?? output;
+}
diff --git a/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts b/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts
index caea07c..55a6c8c 100644
--- a/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts
+++ b/seo_mode/seo_mode/server/src/keywords/keywordCleaning.ts
@@ -219,11 +219,28 @@ const CONSUMER_AI_NOISE_PATTERNS = [
const AI_KEYWORD_PATTERN = /(^|\s)(ai|ии)(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект|нейросет/i;
const B2B_KEYWORD_FACET_PATTERN =
- /бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|1с|crm|erp|bpm|workflow|digital|twin|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн|предприят|корпоратив/i;
+ /бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|платформ|стоимост|внедрен|купить|заказать|тариф|1с|crm|erp|bpm|workflow|digital|twin|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн|предприят|корпоратив/i;
const ARTICLE_PATTERNS = [
- /(^|\s)(как|что такое|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд)(\s|$)/i,
- /(^|\s)(выбрать|сравнение|обзор|решение проблемы)(\s|$)/i
+ /(^|\s)(как|что такое|что\s+это|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд|определен[а-яёa-z0-9-]*|вопрос[а-яёa-z0-9-]*|ответ[а-яёa-z0-9-]*)(\s|$)/i,
+ /(^|\s)(выбрать|сравнение|обзор|решение проблемы)(\s|$)/i,
+ /(^|\s).+\s+это$/i
+];
+
+const RELATED_REVIEW_PATTERNS = [
+ /(^|\s)(акт[а-яёa-z0-9-]*\s+проверки|проверка\s+автономн[а-яёa-z0-9-]*|автономн[а-яёa-z0-9-]*\s+учрежден[а-яёa-z0-9-]*|проектн[а-яёa-z0-9-]*\s+документац[а-яёa-z0-9-]*)(\s|$)/i,
+ /(^|\s)(автономн[а-яёa-z0-9-]*\s+провер[а-яёa-z0-9-]*|провер[а-яёa-z0-9-]*\s+автономн[а-яёa-z0-9-]*)(\s|$)/i,
+ /(^|\s)(закон[а-яёa-z0-9-]*|приказ[а-яёa-z0-9-]*|бюджет[а-яёa-z0-9-]*)(\s|$)/i,
+ /(^|\s)(гражданск[а-яёa-z0-9-]*|уголовн[а-яёa-z0-9-]*|правов[а-яёa-z0-9-]*|систем[а-яёa-z0-9-]*\s+прав[а-яёa-z0-9-]*)(\s|$).*юридическ[а-яёa-z0-9-]*\s+процесс/i,
+ /(^|\s)юридическ[а-яёa-z0-9-]*\s+процесс(\s|$)/i,
+ /(^|\s)систем[а-яёa-z0-9-]*\s+юридическ[а-яёa-z0-9-]*\s+процесс[а-яёa-z0-9-]*(\s|$)/i,
+ /(^|\s)(рабоч[а-яёa-z0-9-]*\s+пространств[а-яёa-z0-9-]*|пространств[а-яёa-z0-9-]*\s+рабоч[а-яёa-z0-9-]*\s+мест[а-яёa-z0-9-]*|5с)(\s|$)/i,
+ /(^|\s)(ошибк[а-яёa-z0-9-]*\s+запуск[а-яёa-z0-9-]*\s+модул[а-яёa-z0-9-]*|запуск[а-яёa-z0-9-]*\s+перв[а-яёa-z0-9-]*\s+модул[а-яёa-z0-9-]*|локальн[а-яёa-z0-9-]*\s+модул[а-яёa-z0-9-]*\s+в\s+процесс[а-яёa-z0-9-]*\s+запуск[а-яёa-z0-9-]*)(\s|$)/i,
+ /(^|\s)прикладными\s+модулями(\s|$)/i
+];
+
+const EXACT_TRASH_RESCUE_BLOCKLIST = [
+ /(^|\s)(уголовн[а-яёa-z0-9-]*|судебн[а-яёa-z0-9-]*|антенн[а-яёa-z0-9-]*|телевиз[а-яёa-z0-9-]*|тв|цирк[а-яёa-z0-9-]*|бойцовск[а-яёa-z0-9-]*)(\s|$)/i
];
function normalizePhrase(value: string) {
@@ -495,7 +512,7 @@ function buildKeywordCleaningSemanticGuard(contract: MarketEnrichmentContract):
dominantProtectedFacets,
policy: {
broadFrequencyDoesNotOverrideSemanticFit: true,
- demoteRelatedWhenDominantFacetMissing: true,
+ demoteRelatedWhenDominantFacetMissing: false,
preserveSourceAnchorFacetsForRelated: true
}
};
@@ -506,7 +523,7 @@ function getProtectedSourceFacetStems(item: KeywordCleaningItem, guard: KeywordC
return new Set();
}
- const sourceStems = getKeywordCleaningFacetStems(`${item.sourcePhrase} ${item.clusterTitle}`);
+ const sourceStems = getKeywordCleaningFacetStems(item.sourcePhrase);
return new Set(
[...sourceStems].filter((stem) =>
@@ -524,13 +541,6 @@ function getKeywordCleaningSemanticDowngradeReason(item: KeywordCleaningItem, gu
return `Wordstat related потерял protected-признак исходного якоря (${missingSourceStems.join(", ")}).`;
}
- const dominantProtectedStems = guard.dominantProtectedStems.map((item) => item.stem);
- const missingDominantStems = dominantProtectedStems.filter((stem) => !phraseStems.has(stem));
-
- if (dominantProtectedStems.length > 0 && missingDominantStems.length === dominantProtectedStems.length) {
- return `Фраза не содержит доминантный protected-смысл approved anchors (${missingDominantStems.join(", ")}).`;
- }
-
return null;
}
@@ -565,6 +575,139 @@ function enforceKeywordCleaningSemanticGuard(
});
}
+function shouldRescueExactTrashItem(item: KeywordCleaningItem) {
+ return (
+ item.decision === "trash" &&
+ item.evidenceStatus === "collected" &&
+ item.frequency !== null &&
+ Boolean(item.wordstatResultId) &&
+ wordCount(item.phrase) > 1 &&
+ hasB2BKeywordFacet(item.normalizedPhrase) &&
+ !hasPattern(item.normalizedPhrase, HARD_TRASH_PATTERNS) &&
+ !hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
+ !hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
+ !hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
+ !isConsumerAiNoisePhrase(item.normalizedPhrase) &&
+ !isBroadAiKeywordNoise(item.normalizedPhrase)
+ );
+}
+
+function isBroadHeadDemandItem(item: KeywordCleaningItem) {
+ const phraseWordCount = wordCount(item.phrase);
+
+ return phraseWordCount <= 2 && (item.frequency ?? 0) >= 5000;
+}
+
+function shouldPromoteExactDemandItem(
+ item: KeywordCleaningItem,
+ contextTokens: Set,
+ guard: KeywordCleaningFacetGuard
+) {
+ if (item.decision !== "article" && item.decision !== "risky") {
+ return false;
+ }
+
+ return (
+ item.evidenceStatus === "collected" &&
+ item.frequency !== null &&
+ Boolean(item.wordstatResultId) &&
+ Boolean(item.projectOntologyVersionId) &&
+ wordCount(item.phrase) > 1 &&
+ hasB2BKeywordFacet(item.normalizedPhrase) &&
+ hasContextOverlap(item.phrase, contextTokens) &&
+ !isBroadHeadDemandItem(item) &&
+ !getKeywordCleaningSemanticDowngradeReason(item, guard) &&
+ !hasPattern(item.normalizedPhrase, HARD_TRASH_PATTERNS) &&
+ !hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) &&
+ !hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS) &&
+ !hasPattern(item.normalizedPhrase, EXACT_TRASH_RESCUE_BLOCKLIST) &&
+ !isConsumerAiNoisePhrase(item.normalizedPhrase) &&
+ !isBroadAiKeywordNoise(item.normalizedPhrase)
+ );
+}
+
+function rescueExactTrashItems(items: KeywordCleaningItem[]): KeywordCleaningItem[] {
+ return items.map((item) => {
+ if (!shouldRescueExactTrashItem(item)) {
+ return item;
+ }
+
+ return {
+ ...item,
+ blockers: unique([...(Array.isArray(item.blockers) ? item.blockers : []), "model_trash_review"]),
+ confidence: Math.min(item.confidence, 0.58),
+ decision: "risky",
+ reason:
+ "Backend guard поднял exact B2B/market-related фразу из trash в risky: Stage 5 должен показать проверенный спрос пользователю, а не скрывать его без human gate."
+ };
+ });
+}
+
+function promoteExactDemandItems(market: MarketEnrichmentContract, items: KeywordCleaningItem[]): KeywordCleaningItem[] {
+ const contextTokens = buildContextTokenSet(market);
+ const guard = buildKeywordCleaningFacetGuard(market);
+
+ return items.map((item) => {
+ if (!shouldPromoteExactDemandItem(item, contextTokens, guard)) {
+ return item;
+ }
+
+ return {
+ ...item,
+ blockers: unique((Array.isArray(item.blockers) ? item.blockers : []).filter((blocker) => blocker !== "посадочную определит стратегия")),
+ confidence: Math.max(item.confidence, 0.72),
+ decision: "support",
+ reason:
+ "Backend guard поднял exact Wordstat demand в support для Stage 5: фраза подтверждена спросом, держит B2B/контекстный фасет и должна попасть в предсортировку перед стратегией."
+ };
+ });
+}
+
+function enforceKeywordCleaningBackendGuards(
+ market: MarketEnrichmentContract,
+ items: KeywordCleaningItem[]
+): KeywordCleaningItem[] {
+ return promoteExactDemandItems(market, rescueExactTrashItems(enforceKeywordCleaningSemanticGuard(market, items)));
+}
+
+function getKeywordCleaningMergeKeys(item: KeywordCleaningItem) {
+ return [item.wordstatResultId ? `wordstat:${item.wordstatResultId}` : null, `phrase:${item.normalizedPhrase}`].filter(Boolean);
+}
+
+function shouldBackfillMissingExactItem(item: KeywordCleaningItem) {
+ return (
+ item.decision !== "trash" &&
+ item.evidenceStatus === "collected" &&
+ item.frequency !== null &&
+ Boolean(item.wordstatResultId) &&
+ wordCount(item.phrase) > 1
+ );
+}
+
+function mergeMissingExactEvidenceItems(
+ market: MarketEnrichmentContract,
+ modelItems: KeywordCleaningItem[]
+): KeywordCleaningItem[] {
+ const knownKeys = new Set(modelItems.flatMap(getKeywordCleaningMergeKeys));
+ const missingItems = classifyItems(market, getBaseItems(market))
+ .filter(shouldBackfillMissingExactItem)
+ .filter((item) => getKeywordCleaningMergeKeys(item).every((key) => !knownKeys.has(key)))
+ .sort((left, right) => {
+ const leftPriority = left.priority === "high" ? 3 : left.priority === "medium" ? 2 : 1;
+ const rightPriority = right.priority === "high" ? 3 : right.priority === "medium" ? 2 : 1;
+
+ return rightPriority - leftPriority || (right.frequency ?? 0) - (left.frequency ?? 0);
+ })
+ .slice(0, 80)
+ .map((item) => ({
+ ...item,
+ evidenceRefs: unique([...item.evidenceRefs, "backend:missing-exact-evidence-backfill"]),
+ reason: `Backend evidence добавил exact Wordstat item, который модель не вернула в keyword_cleaning. ${item.reason}`
+ }));
+
+ return [...modelItems, ...missingItems];
+}
+
function getTargetPath(
seed: Pick,
briefPhraseMap: Map,
@@ -600,6 +743,7 @@ function buildContextTokenSet(contract: MarketEnrichmentContract) {
for (const token of normalizePhrase(value).split(/\s+/)) {
if (token.length >= 4) {
tokens.add(token);
+ tokens.add(stemKeywordCleaningToken(token));
}
}
};
@@ -626,13 +770,15 @@ function buildContextTokenSet(contract: MarketEnrichmentContract) {
}
function hasContextOverlap(phrase: string, contextTokens: Set) {
- const phraseTokens = normalizePhrase(phrase).split(/\s+/).filter((token) => token.length >= 4);
+ const phraseTokens = normalizePhrase(phrase)
+ .split(/\s+/)
+ .filter((token) => token.length >= 4);
if (phraseTokens.length === 0) {
return false;
}
- return phraseTokens.some((token) => contextTokens.has(token));
+ return phraseTokens.some((token) => contextTokens.has(token) || contextTokens.has(stemKeywordCleaningToken(token)));
}
function buildSeedItem(
@@ -908,8 +1054,9 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set
item.evidenceStatus === "not_collected" ||
item.evidenceStatus === "not_configured" ||
item.evidenceStatus === "ready_for_collection";
- const weakExternalSignal = item.evidenceStatus === "collected_related_only" || item.source === "wordstat_related";
- const needsHumanReview = item.evidenceStatus === "collection_failed" || noExternalSignal || item.targetPath === null;
+ const weakExternalSignal =
+ item.evidenceStatus === "collected_related_only" || (item.source === "wordstat_related" && !exactEvidence);
+ const needsHumanReview = item.evidenceStatus === "collection_failed" || noExternalSignal;
const isArticle = hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) || item.intentType === "informational";
const isTrash =
item.marketRole === "exclude" ||
@@ -917,11 +1064,11 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set
isConsumerAiNoisePhrase(item.normalizedPhrase) ||
isBroadAiKeywordNoise(item.normalizedPhrase) ||
(item.source === "wordstat_related" && isBroadAiKeywordNoise(item.sourcePhrase)) ||
- (item.source === "wordstat_related" && !hasContextOverlap(item.phrase, contextTokens)) ||
+ (item.source === "wordstat_related" && !exactEvidence && !hasContextOverlap(item.phrase, contextTokens)) ||
(item.source === "wordstat_related" && wordCount(item.phrase) <= 1);
if (item.targetPath === null) {
- blockers.push("нет привязки к посадочной");
+ blockers.push("посадочную определит стратегия");
}
if (noExternalSignal) {
@@ -953,6 +1100,16 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set
};
}
+ if (item.source === "wordstat_related" && exactEvidence && hasPattern(item.normalizedPhrase, RELATED_REVIEW_PATTERNS)) {
+ return {
+ blockers: unique([...blockers, "related_review_intent"]),
+ confidence: 0.58,
+ decision: "risky" as const,
+ reason:
+ "Wordstat дал exact related, но формулировка выглядит как смежный справочный/бюрократический интент; держим на ручном разборе, не в approved lanes."
+ };
+ }
+
if (isArticle && exactEvidence) {
return {
blockers,
@@ -967,7 +1124,7 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set
blockers,
confidence: exactEvidence ? 0.62 : 0.48,
decision: "risky" as const,
- reason: "Фраза требует ручной проверки перед SEO-планом: не хватает exact evidence, page binding или уверенности формулировки."
+ reason: "Фраза требует ручной проверки перед SEO-планом: не хватает exact evidence или уверенности формулировки."
};
}
@@ -976,7 +1133,9 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set
blockers,
confidence: item.priority === "high" ? 0.9 : 0.82,
decision: "use" as const,
- reason: "Есть exact Wordstat evidence, контекстная роль и привязка к посадочной; можно отдавать в keyword map."
+ reason: item.targetPath
+ ? "Есть exact Wordstat evidence, контекстная роль и привязка к посадочной; можно отдавать в keyword map."
+ : "Есть exact Wordstat evidence и контекстная роль; Stage 5 может распределить фразу, а посадочную определит стратегия."
};
}
@@ -1022,8 +1181,7 @@ function buildReadiness(items: KeywordCleaningItem[], lanes: KeywordCleaningCont
(item) =>
(item.decision === "use" || item.decision === "support") &&
item.evidenceStatus === "collected" &&
- item.frequency !== null &&
- item.targetPath
+ item.frequency !== null
).length,
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
riskyCount: lanes.risky.length,
@@ -1054,9 +1212,9 @@ function buildKeywordCleaningModelTask(
decisionPolicy: {
articleDoesNotEnterRewrite: true,
broadFrequencyDoesNotOverrideSemanticFit: true,
- demoteRelatedWhenDominantFacetMissing: true,
+ demoteRelatedWhenDominantFacetMissing: false,
preserveApprovedAnchorFacets: true,
- riskyWhenNoPageBinding: true,
+ riskyWhenNoPageBinding: false,
trashWhenOffContext: true,
useRequiresExactEvidence: true
},
@@ -1103,8 +1261,12 @@ function buildKeywordCleaningModelTask(
stopConditions: [
"Не отправлять trash в keyword map. Risky/article exact-фразы можно только предсортировать в Stage 3, но не считать approved без пользователя.",
"Не придумывать спрос без Wordstat/SERP evidence.",
+ "Stage 5 не выбирает посадочную автоматически: exact-фразы без targetPath можно раскладывать в keyword map, target/landing решает стратегия.",
+ "Не пропускать exact Wordstat items молча: для каждой значимой exact-фразы из seedQueue/topResults вернуть item с use/support/article/risky/trash и причиной.",
+ "Stage 5 собирает глобальную карту спроса, а не rewrite текущей посадочной: частотные exact B2B-смежные ветки с сохранённым source-фасетом должны попадать минимум в support/risky, а не исчезать в article только из-за соседнего рынка.",
+ "Article/backlog использовать для информационного контента и гипотез без сильного коммерческого/продуктового спроса; не маркировать article каждую подтверждённую смежную B2B-ветку.",
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
- "Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря или доминантный смысл approved anchors.",
+ "Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря; глобальный доминантный термин проекта не должен запрещать отдельные подтверждённые ветки спроса.",
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
"Не сохранять rewrite/apply decisions."
],
@@ -1149,7 +1311,7 @@ function buildNextActions(contract: Omit
function buildFallbackContract(projectId: string, market: MarketEnrichmentContract): KeywordCleaningContract {
const modelTask = buildKeywordCleaningModelTask(projectId, market);
- const items = enforceKeywordCleaningSemanticGuard(market, classifyItems(market, getBaseItems(market)));
+ const items = enforceKeywordCleaningBackendGuards(market, classifyItems(market, getBaseItems(market)));
const lanes = buildLanes(items);
const readiness = buildReadiness(items, lanes);
const contractWithoutActions: Omit = {
@@ -1319,7 +1481,7 @@ export async function getKeywordCleaningContract(projectId: string): Promise();
}
- const sourceStems = getKeywordFacetStems(`${item.sourcePhrase} ${item.clusterTitle}`);
+ const sourceStems = getKeywordFacetStems(item.sourcePhrase);
return new Set([...sourceStems].filter((stem) => isProtectedSemanticFacetStem(stem, guard)));
}
@@ -359,10 +359,6 @@ function getSemanticFitAssessment(item: KeywordCleaningItem, guard: SemanticFace
};
}
-function missesDominantProjectFacet(item: KeywordCleaningItem, guard: SemanticFacetGuard) {
- return getSemanticFitAssessment(item, guard).label === "lost_project_facet";
-}
-
function getKeywordPriorityWeight(priority: KeywordCleaningItem["priority"]) {
if (priority === "high") return 3;
if (priority === "medium") return 2;
@@ -384,8 +380,7 @@ function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGu
item.frequency !== null &&
Boolean(item.projectOntologyVersionId) &&
Boolean(item.wordstatResultId) &&
- Boolean(item.targetPath) &&
- (!semanticFit || (semanticFit.label === "strong" && semanticFit.score >= 0.5))
+ (!semanticFit || semanticFit.label !== "lost_source_facet")
);
}
@@ -400,8 +395,10 @@ function isClarifyingKeywordCandidate(item: KeywordCleaningItem) {
item.marketRole === "integration" ||
item.intentType === "deployment_security" ||
item.intentType === "integration" ||
- /(^|\s)(api|bim|crm|mcp|1с|3d|digital twin|on[-\s]?premise)(\s|$)/i.test(text) ||
- /(интеграц|архитект|безопас|облак|workspace|документооборот|утп|дифференц)/i.test(text)
+ /(^|\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(
+ text
+ )
);
}
@@ -466,6 +463,7 @@ function buildBlockers(contract: MarketEnrichmentContract, cleaning: KeywordClea
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
const targetCounts = new Map();
const semanticFacetGuard = buildSemanticFacetGuard(contract);
+ const seenPhrases = new Set();
const sourceItems = cleaning.items
.filter(
(item) =>
@@ -512,6 +510,16 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
return normalizePhrase(left.phrase).localeCompare(normalizePhrase(right.phrase), "ru");
})
+ .filter((item) => {
+ const normalizedPhrase = normalizePhrase(item.phrase);
+
+ if (seenPhrases.has(normalizedPhrase)) {
+ return false;
+ }
+
+ seenPhrases.add(normalizedPhrase);
+ return true;
+ })
.slice(0, 120);
return sourceItems.map((item) => {
@@ -562,19 +570,21 @@ function getKeywordMapItemReason(
}
if (role === "differentiator") {
- return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title.";
+ return "Фраза усиливает отличающий слой спроса: секции, FAQ или внутренние связи, но не primary и не SEO title.";
}
if (role === "primary") {
- return "Модель поставила фразу в посадочное ядро: это strongest exact-запрос для выбранной посадочной.";
+ return targetPath
+ ? "Модель поставила фразу в посадочное ядро Stage 5: это strongest exact-запрос для спроса, а финальную посадочную подтвердит стратегия."
+ : "Модель поставила фразу в посадочное ядро Stage 5: есть exact Wordstat evidence, но посадочную ещё должна выбрать стратегия.";
}
if (role === "secondary") {
- return "Модель поставила фразу в поддерживающее ядро: она раскрывает главный спрос и структуру посадочной.";
+ return "Модель поставила фразу в поддерживающее ядро Stage 5: она раскрывает главный спрос и будущую структуру стратегии.";
}
if (role === "support") {
- return "Модель поставила фразу в длинный хвост: использовать в FAQ, подписях, внутренних связях или вторичных блоках.";
+ return "Модель поставила фразу в длинный хвост Stage 5: использовать в FAQ, подписях, внутренних связях или вторичных блоках после стратегии.";
}
if (role === "validate" && hasSemanticFacetLoss(item, semanticFacetGuard)) {
@@ -583,16 +593,10 @@ function getKeywordMapItemReason(
return `Wordstat related потерял protected-признак исходного якоря (${semanticFit.missingSourceStems.join(", ")}): оставляем на ручной разбор, чтобы широкий спрос не вытеснил смысл проекта.`;
}
- if (role === "validate" && missesDominantProjectFacet(item, semanticFacetGuard)) {
- const semanticFit = getSemanticFitAssessment(item, semanticFacetGuard);
-
- return `Фраза не содержит доминантный protected-смысл из подтверждённой очереди (${semanticFit.missingProjectStems.join(", ")}): оставляем на ручной разбор, чтобы широкий спрос не стал ядром автоматически.`;
- }
-
if (item.decision === "article") {
return role === "validate"
- ? "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной."
- : "Модель предварительно распределила article/backlog-фразу; пользователь должен подтвердить, что она нужна текущей стратегии.";
+ ? "Keyword cleaning отнесла фразу в article/backlog: оставляем на ручной разбор до стратегии."
+ : "Модель предварительно распределила article/backlog-фразу; пользователь должен подтвердить, что она нужна общей стратегии.";
}
if (item.decision === "risky") {
@@ -892,7 +896,7 @@ export async function getKeywordMapContract(projectId: string): Promise item.targetPath !== null).length,
+ mappedItemCount: items.filter((item) => item.role !== "validate" && item.role !== "secondary_candidate").length,
marketEvidenceCount: cleaningContract.readiness.exactEvidenceCount,
persistableItemCount: items.filter(isPersistableKeywordMapItem).length,
blockerCount: blockers.length
diff --git a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts
index d549b95..80c30f3 100644
--- a/seo_mode/seo_mode/server/src/market/marketEnrichment.ts
+++ b/seo_mode/seo_mode/server/src/market/marketEnrichment.ts
@@ -510,13 +510,21 @@ function buildSeedQueue(
function shouldExpandSeed(seed: MarketEnrichmentContract["seedQueue"][number]) {
return (
- seed.frequency === null &&
- (seed.evidenceStatus === "collected_no_signal" ||
- seed.evidenceStatus === "collected_related_only" ||
- seed.evidenceStatus === "collection_failed")
+ seed.evidenceStatus === "collected" ||
+ seed.evidenceStatus === "collected_no_signal" ||
+ seed.evidenceStatus === "collected_related_only" ||
+ seed.evidenceStatus === "collection_failed"
);
}
+function getExpansionSeedReason(seed: MarketEnrichmentContract["seedQueue"][number]) {
+ if (seed.frequency !== null) {
+ return `Expansion seed для Wordstat: исходная фраза "${seed.phrase}" уже дала exact demand, но Stage 5 должен собрать соседние рыночные формулировки всего смыслового кластера.`;
+ }
+
+ return `Expansion seed для Wordstat: исходная фраза "${seed.phrase}" не дала exact demand; аналог собран из semantic cluster/source evidence.`;
+}
+
function buildWordstatExpansionSeeds(
analysis: SemanticAnalysisRun,
market: MarketEnrichmentContract
@@ -553,7 +561,7 @@ function buildWordstatExpansionSeeds(
const clusterCandidates = candidatesByCluster.get(seed.clusterId);
- if (!clusterCandidates || clusterCandidates.length >= 8) {
+ if (!clusterCandidates || clusterCandidates.length >= 10) {
continue;
}
@@ -563,7 +571,7 @@ function buildWordstatExpansionSeeds(
confidence: Math.max(0.42, Math.min((seed.normalization?.confidence ?? 0.6) * 0.9, 0.82)),
phrase: normalizedPhrase,
priority: seed.priority,
- reason: `Expansion seed для Wordstat: исходная фраза "${seed.phrase}" не дала exact demand; аналог собран из semantic cluster/source evidence.`,
+ reason: getExpansionSeedReason(seed),
source: seed.source
});
}
@@ -571,7 +579,7 @@ function buildWordstatExpansionSeeds(
const diversifiedCandidates: WordstatSeedCandidate[] = [];
- for (let index = 0; diversifiedCandidates.length < 48; index += 1) {
+ for (let index = 0; diversifiedCandidates.length < 72; index += 1) {
let added = false;
for (const clusterId of clusterOrder) {
@@ -584,7 +592,7 @@ function buildWordstatExpansionSeeds(
diversifiedCandidates.push(candidate);
added = true;
- if (diversifiedCandidates.length >= 48) {
+ if (diversifiedCandidates.length >= 72) {
break;
}
}
diff --git a/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts b/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts
index bfae6a7..631df12 100644
--- a/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts
+++ b/seo_mode/seo_mode/server/src/modelProvider/seoModelTaskRuns.ts
@@ -158,7 +158,7 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) {
}
if (task.taskType === "seo.keyword_cleaning") {
- return 5200;
+ return 9000;
}
if (task.taskType === "seo.serp_interpretation") {
diff --git a/seo_mode/seo_mode/server/src/projects/routes.ts b/seo_mode/seo_mode/server/src/projects/routes.ts
index ca25006..246121e 100644
--- a/seo_mode/seo_mode/server/src/projects/routes.ts
+++ b/seo_mode/seo_mode/server/src/projects/routes.ts
@@ -91,6 +91,7 @@ import {
type KeywordCleaningContract
} from "../keywords/keywordCleaning.js";
import {
+ addAnchorReviewManualAnchor,
getAnchorReviewContract,
saveAnchorReviewDecisions
} from "../keywords/anchorReview.js";
@@ -255,6 +256,12 @@ const anchorReviewDecisionSchema = z.object({
const anchorReviewDecisionsSchema = z.object({
decisions: z.array(anchorReviewDecisionSchema).max(200).optional()
});
+const anchorReviewManualAnchorSchema = z.object({
+ phrase: z.string().trim().min(1).max(180),
+ reason: z.string().trim().max(1000).optional(),
+ sendToSerp: z.boolean().optional(),
+ sendToWordstat: z.boolean().optional()
+});
const serpInterpretationManualSchema = z.object({
output: z.record(z.string(), z.unknown())
});
@@ -835,6 +842,25 @@ projectsRouter.post("/:projectId/anchor-review/decisions", async (request, respo
}
});
+projectsRouter.post("/:projectId/anchor-review/manual-anchor", async (request, response) => {
+ try {
+ const projectId = projectIdSchema.parse(request.params.projectId);
+ const input = anchorReviewManualAnchorSchema.parse(request.body ?? {});
+
+ response.status(201).json({
+ anchorReview: await addAnchorReviewManualAnchor(projectId, input)
+ });
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ sendError(response, 400, error.issues[0]?.message ?? "Проверь ручной anchor review payload.");
+ return;
+ }
+
+ console.error(error);
+ sendError(response, 500, error instanceof Error ? error.message : "Не удалось добавить ручной якорь.");
+ }
+});
+
projectsRouter.get("/:projectId/keyword-cleaning/latest", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
diff --git a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts
index 2876a6e..e714ae7 100644
--- a/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts
+++ b/seo_mode/seo_mode/server/src/strategy/seoStrategy.ts
@@ -897,14 +897,14 @@ function getActiveStrategyKeywordMapItems(cleaning: KeywordCleaningContract, key
return keywordMap.persisted.items.filter((item) => {
const cleaningItem = cleaningByPhrase.get(normalizePhraseKey(item.phrase));
const currentMapItem = currentRoutableByPhrase.get(normalizePhraseKey(item.phrase));
+ const approvedRoleIsActive = item.role !== "validate" && item.role !== "secondary_candidate";
return (
+ approvedRoleIsActive &&
Boolean(currentMapItem) &&
Boolean(cleaningItem) &&
- (cleaningItem?.decision === "use" || cleaningItem?.decision === "support") &&
cleaningItem?.evidenceStatus === "collected" &&
cleaningItem?.frequency !== null &&
- currentMapItem?.role === item.role &&
normalizeKey(currentMapItem?.targetPath ?? null) === normalizeKey(item.targetPath)
);
});