diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index cb6231c..94e9863 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -54,6 +54,7 @@ import { ScatterChart as BklitScatterChart } from "@bklitui/charts/scatter-chart import { XAxis as BklitXAxis } from "@bklitui/charts/x-axis"; import { applyMaterializationAction, + addAnchorReviewManualAnchor, buildPatchArtifact, approveKeywordMapDecisions, copyProject, @@ -185,22 +186,22 @@ const keywordCurationColumns: Array<{ { description: "главные запросы, под которые строится посадочная логика", role: "primary", - title: "Посадочное ядро" + title: "Семантическое ядро" }, { description: "запросы, раскрывающие основной интент и структуру страницы", role: "secondary", - title: "Поддерживающее ядро" + title: "Поддерживающая семантика" }, { description: "дифференцирующие, продуктовые и уточняющие формулировки", role: "differentiator", - title: "Уточняющие / УТП" + title: "Уточняющая семантика / УТП" }, { description: "низкочастотные хвосты, FAQ, подписи и внутренние связи", role: "support", - title: "Длинный хвост" + title: "НЧ-хвост" } ]; @@ -4143,8 +4144,10 @@ function SeoAnchorReviewWorkspace({ activeFilter, anchorReview, isBulkApproving, + isManualAdding, onDecision, onFilterChange, + onManualAdd, stageProjectId, sectionId }: { @@ -4152,8 +4155,10 @@ function SeoAnchorReviewWorkspace({ activeFilter: AnchorReviewFilter; anchorReview: AnchorReviewContract; isBulkApproving: boolean; + isManualAdding: boolean; onDecision: (anchor: AnchorReviewAnchorView, decision: AnchorReviewDecisionInput) => void; onFilterChange: (filter: AnchorReviewFilter) => void; + onManualAdd: () => void; stageProjectId?: string; sectionId?: string; }) { @@ -4174,17 +4179,31 @@ function SeoAnchorReviewWorkspace({ data-keyword-stage-project={stageProjectId} id={sectionId} > -
- -
- Этап 1 · Фиксация ключей - Выбираем смысловые якоря и вручную решаем, что отправлять в очередь Wordstat. - - {getAnchorReviewStateLabel(anchorReview.state)} · очередь Wordstat {anchorReview.readiness.wordstatQueueCount} · - нормализовано: {anchorReview.readiness.normalizedAnchorCount} · предложено:{" "} - {anchorReview.readiness.proposedWordstatCount} · на разборе: {anchorReview.readiness.pendingAnchorCount} · скрыто:{" "} - {anchorReview.readiness.disabledAnchorCount} - +
+
+ +
+ Этап 1 · Фиксация семантического базиса + Выбираем смысловые якоря и вручную решаем, что отправлять в очередь Wordstat. + + {getAnchorReviewStateLabel(anchorReview.state)} · очередь Wordstat {anchorReview.readiness.wordstatQueueCount} · + нормализовано: {anchorReview.readiness.normalizedAnchorCount} · предложено:{" "} + {anchorReview.readiness.proposedWordstatCount} · на разборе: {anchorReview.readiness.pendingAnchorCount} · скрыто:{" "} + {anchorReview.readiness.disabledAnchorCount} + +
+
+
+
@@ -4294,6 +4313,71 @@ function SeoAnchorReviewWorkspace({ ); } +function SeoManualAnchorModal({ + isSaving, + onClose, + onSubmit, + onValueChange, + projectName, + value +}: { + isSaving: boolean; + onClose: () => void; + onSubmit: (event: FormEvent) => void; + onValueChange: (value: string) => void; + projectName: string; + value: string; +}) { + const cleanValue = value.trim(); + + return createPortal( +
{ + if (event.target === event.currentTarget && !isSaving) { + onClose(); + } + }} + role="presentation" + > +
+
+ Семантический базис + +
+
+ Ручное добавление ключа + {projectName} +
+ +
+ + +
+
+
, + document.body + ); +} + function SeoKeywordCleaningLane({ emptyLabel, items, @@ -4339,7 +4423,6 @@ function getKeywordCleaningStage5Candidates(keywordCleaning: KeywordCleaningCont item.decision !== "trash" && item.evidenceStatus === "collected" && item.frequency !== null && - Boolean(item.targetPath) && Boolean(item.projectOntologyVersionId) && Boolean(item.wordstatResultId) ); @@ -5473,11 +5556,11 @@ function getKeywordMapStateLabel(state: KeywordMapContract["state"]) { function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) { const labels = { - differentiator: "уточняющие / УТП", - primary: "посадочное ядро", - secondary: "поддерживающее ядро", + differentiator: "уточняющая семантика / УТП", + primary: "семантическое ядро", + secondary: "поддерживающая семантика", secondary_candidate: "кандидат", - support: "длинный хвост", + support: "НЧ-хвост", validate: "неразобранные" }; @@ -8609,6 +8692,8 @@ export function App() { const [reviewingContextProjectId, setReviewingContextProjectId] = useState(null); const [approvingAnchorReviewProjectId, setApprovingAnchorReviewProjectId] = useState(null); const [anchorReviewActionKey, setAnchorReviewActionKey] = useState(null); + const [manualAnchorProjectId, setManualAnchorProjectId] = useState(null); + const [manualAnchorPhrase, setManualAnchorPhrase] = useState(""); const [anchorReviewFilters, setAnchorReviewFilters] = useState>({}); const [keywordCurationRoleOverrides, setKeywordCurationRoleOverrides] = useState({}); const [activeKeywordWorkflowSteps, setActiveKeywordWorkflowSteps] = useState>({}); @@ -10585,6 +10670,56 @@ export function App() { } } + function openManualAnchorModal(project: ProjectSummary) { + setManualAnchorProjectId(project.id); + setManualAnchorPhrase(""); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + } + + function closeManualAnchorModal() { + if (manualAnchorProjectId && anchorReviewActionKey === `${manualAnchorProjectId}:manual-anchor`) { + return; + } + + setManualAnchorProjectId(null); + setManualAnchorPhrase(""); + } + + async function handleAnchorReviewManualAdd(project: ProjectSummary, event?: FormEvent) { + event?.preventDefault(); + const normalizedPhrase = manualAnchorPhrase.trim().replace(/\s+/g, " "); + + if (!normalizedPhrase) { + return; + } + + setAnchorReviewActionKey(`${project.id}:manual-anchor`); + setProjectActionError(null); + setProjectActionMessage(null); + setProjectActionProjectId(project.id); + + try { + const result = await addAnchorReviewManualAnchor(project.id, { + phrase: normalizedPhrase, + reason: "Пользователь вручную добавил ключ в семантический базис.", + sendToSerp: true, + sendToWordstat: true + }); + await refreshAfterAnchorReviewDecision(project); + setProjectActionMessage( + `Ручной ключ добавлен: ${normalizedPhrase}. Очередь Wordstat ${result.anchorReview.readiness.wordstatQueueCount}, на разборе ${result.anchorReview.readiness.pendingAnchorCount}.` + ); + setManualAnchorProjectId(null); + setManualAnchorPhrase(""); + } catch (error: unknown) { + setProjectActionError(getErrorMessage(error, "Не удалось добавить ручной ключ.")); + } finally { + setAnchorReviewActionKey(null); + } + } + async function handleSeoModelTaskRun(project: ProjectSummary, taskType: SeoModelTaskType) { const taskKey = `${project.id}:${taskType}`; setRunningModelTaskKey(taskKey); @@ -13267,6 +13402,10 @@ export function App() { document.body ) : null; + const manualAnchorProject = manualAnchorProjectId + ? (projects.find((project) => project.id === manualAnchorProjectId) ?? null) + : null; + const isSavingManualAnchor = manualAnchorProject ? anchorReviewActionKey === `${manualAnchorProject.id}:manual-anchor` : false; return (
{strategyExportMenuPortal} + {manualAnchorProject ? ( + void handleAnchorReviewManualAdd(manualAnchorProject, event)} + onValueChange={setManualAnchorPhrase} + projectName={manualAnchorProject.name} + value={manualAnchorPhrase} + /> + ) : null}
@@ -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) ); });