Improve SEO demand workflow

This commit is contained in:
DCCONSTRUCTIONS 2026-07-03 17:44:56 +03:00
parent a8f03570c6
commit 284c72d75c
10 changed files with 702 additions and 91 deletions

View File

@ -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,10 +4179,11 @@ function SeoAnchorReviewWorkspace({
data-keyword-stage-project={stageProjectId}
id={sectionId}
>
<div className="keyword-anchor-stage-head">
<div className="keyword-stage-heading">
<KeyRound size={20} />
<div>
<strong>Этап 1 · Фиксация ключей</strong>
<strong>Этап 1 · Фиксация семантического базиса</strong>
<small>Выбираем смысловые якоря и вручную решаем, что отправлять в очередь Wordstat.</small>
<small>
{getAnchorReviewStateLabel(anchorReview.state)} · очередь Wordstat {anchorReview.readiness.wordstatQueueCount} ·
@ -4187,6 +4193,19 @@ function SeoAnchorReviewWorkspace({
</small>
</div>
</div>
<div className="keyword-stage-actions">
<button
aria-label="Ручное добавление ключа"
className="keyword-stage-icon-action"
disabled={isBulkApproving || isManualAdding || anchorReview.state === "not_ready"}
onClick={onManualAdd}
title="Ручное добавление ключа"
type="button"
>
{isManualAdding ? <Loader2 className="spin" size={15} /> : <Plus size={15} />}
</button>
</div>
</div>
<div className="anchor-review-filterbar" aria-label="Фильтр смысловых якорей">
{filters.map((filter) => (
@ -4294,6 +4313,71 @@ function SeoAnchorReviewWorkspace({
);
}
function SeoManualAnchorModal({
isSaving,
onClose,
onSubmit,
onValueChange,
projectName,
value
}: {
isSaving: boolean;
onClose: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onValueChange: (value: string) => void;
projectName: string;
value: string;
}) {
const cleanValue = value.trim();
return createPortal(
<div
className="seo-project-name-modal-backdrop seo-manual-anchor-modal-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !isSaving) {
onClose();
}
}}
role="presentation"
>
<form aria-labelledby="seo-manual-anchor-title" className="seo-project-name-modal seo-manual-anchor-modal" onSubmit={onSubmit}>
<div className="seo-project-name-modal-head">
<span>Семантический базис</span>
<button aria-label="Закрыть" disabled={isSaving} onClick={onClose} type="button">
<X size={17} />
</button>
</div>
<div className="seo-manual-anchor-modal-title">
<strong id="seo-manual-anchor-title">Ручное добавление ключа</strong>
<small>{projectName}</small>
</div>
<label className="service-field">
<span>Ключ / смысловой якорь</span>
<input
autoFocus
disabled={isSaving}
maxLength={180}
onChange={(event) => onValueChange(event.target.value)}
placeholder="Например: AI-платформа для цифровых двойников"
value={value}
/>
<small>Ключ попадёт в семантический базис и в очередь Wordstat после сохранения.</small>
</label>
<div className="seo-project-name-modal-actions">
<button className="tiny-action" disabled={isSaving} onClick={onClose} type="button">
Отмена
</button>
<button className="primary-action" disabled={!cleanValue || isSaving} type="submit">
{isSaving ? <Loader2 className="spin" size={16} /> : <Plus size={16} />}
{isSaving ? "Добавляю..." : "Добавить"}
</button>
</div>
</form>
</div>,
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<string | null>(null);
const [approvingAnchorReviewProjectId, setApprovingAnchorReviewProjectId] = useState<string | null>(null);
const [anchorReviewActionKey, setAnchorReviewActionKey] = useState<string | null>(null);
const [manualAnchorProjectId, setManualAnchorProjectId] = useState<string | null>(null);
const [manualAnchorPhrase, setManualAnchorPhrase] = useState("");
const [anchorReviewFilters, setAnchorReviewFilters] = useState<Record<string, AnchorReviewFilter>>({});
const [keywordCurationRoleOverrides, setKeywordCurationRoleOverrides] = useState<KeywordCurationRoleOverrides>({});
const [activeKeywordWorkflowSteps, setActiveKeywordWorkflowSteps] = useState<Record<string, KeywordWorkflowStep>>({});
@ -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<HTMLFormElement>) {
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 (
<div
@ -13279,6 +13418,16 @@ export function App() {
} seo-stage-${activeStageIndex + 1}`}
>
{strategyExportMenuPortal}
{manualAnchorProject ? (
<SeoManualAnchorModal
isSaving={isSavingManualAnchor}
onClose={closeManualAnchorModal}
onSubmit={(event) => void handleAnchorReviewManualAdd(manualAnchorProject, event)}
onValueChange={setManualAnchorPhrase}
projectName={manualAnchorProject.name}
value={manualAnchorPhrase}
/>
) : null}
<header className="app-header nodedc-expanded-toolbar-shell">
<div className="nodedc-expanded-toolbar">
<div className="nodedc-expanded-toolbar-top">
@ -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}`}
/>

View File

@ -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`);

View File

@ -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;

View File

@ -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<string>,
seedAnchorKeys: Set<string>
): 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<AnchorReviewContract, "nextActions"> = {
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<string>) {
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<AnchorReviewContract> {
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<AnchorReviewRunRow>(
`
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;
}

View File

@ -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<string>();
}
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<string>,
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<SeedQueueItem, "clusterId" | "normalization" | "phrase">,
briefPhraseMap: Map<string, string>,
@ -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<string>) {
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<string>
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<string>
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<string>
};
}
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<string>
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<string>
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<KeywordCleaningContract, "nextActions">
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<KeywordCleaningContract, "nextActions"> = {
@ -1319,7 +1481,7 @@ export async function getKeywordCleaningContract(projectId: string): Promise<Key
return fallbackContract;
}
const items = enforceKeywordCleaningSemanticGuard(market, latest.items);
const items = enforceKeywordCleaningBackendGuards(market, mergeMissingExactEvidenceItems(market, latest.items));
const lanes = buildLanes(items);
const readiness = buildReadiness(items, lanes);
@ -1397,7 +1559,10 @@ export async function saveKeywordCleaningFromModelProvider(
const alignedOutput: KeywordCleaningContract = {
...output,
items: enforceKeywordCleaningSemanticGuard(market, mergeModelProviderItemsWithMarketEvidence(output.items, market)),
items: enforceKeywordCleaningBackendGuards(
market,
mergeMissingExactEvidenceItems(market, mergeModelProviderItemsWithMarketEvidence(output.items, market))
),
modelTask,
projectOntologyVersion: market.projectOntologyVersion,
semanticRunId: market.semanticRunId,

View File

@ -279,7 +279,7 @@ function getProtectedSourceFacetStems(item: KeywordCleaningItem, guard: Semantic
return new Set<string>();
}
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<string, number>();
const semanticFacetGuard = buildSemanticFacetGuard(contract);
const seenPhrases = new Set<string>();
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<KeywordM
readiness: {
reviewedOntology,
seedCount: cleaningContract.readiness.sourcePhraseCount,
mappedItemCount: items.filter((item) => 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

View File

@ -510,13 +510,21 @@ function buildSeedQueue(
function shouldExpandSeed(seed: MarketEnrichmentContract["seedQueue"][number]) {
return (
seed.frequency === null &&
(seed.evidenceStatus === "collected_no_signal" ||
seed.evidenceStatus === "collected" ||
seed.evidenceStatus === "collected_no_signal" ||
seed.evidenceStatus === "collected_related_only" ||
seed.evidenceStatus === "collection_failed")
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;
}
}

View File

@ -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") {

View File

@ -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);

View File

@ -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)
);
});