Improve SEO demand workflow
This commit is contained in:
parent
a8f03570c6
commit
284c72d75c
|
|
@ -54,6 +54,7 @@ import { ScatterChart as BklitScatterChart } from "@bklitui/charts/scatter-chart
|
||||||
import { XAxis as BklitXAxis } from "@bklitui/charts/x-axis";
|
import { XAxis as BklitXAxis } from "@bklitui/charts/x-axis";
|
||||||
import {
|
import {
|
||||||
applyMaterializationAction,
|
applyMaterializationAction,
|
||||||
|
addAnchorReviewManualAnchor,
|
||||||
buildPatchArtifact,
|
buildPatchArtifact,
|
||||||
approveKeywordMapDecisions,
|
approveKeywordMapDecisions,
|
||||||
copyProject,
|
copyProject,
|
||||||
|
|
@ -185,22 +186,22 @@ const keywordCurationColumns: Array<{
|
||||||
{
|
{
|
||||||
description: "главные запросы, под которые строится посадочная логика",
|
description: "главные запросы, под которые строится посадочная логика",
|
||||||
role: "primary",
|
role: "primary",
|
||||||
title: "Посадочное ядро"
|
title: "Семантическое ядро"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "запросы, раскрывающие основной интент и структуру страницы",
|
description: "запросы, раскрывающие основной интент и структуру страницы",
|
||||||
role: "secondary",
|
role: "secondary",
|
||||||
title: "Поддерживающее ядро"
|
title: "Поддерживающая семантика"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "дифференцирующие, продуктовые и уточняющие формулировки",
|
description: "дифференцирующие, продуктовые и уточняющие формулировки",
|
||||||
role: "differentiator",
|
role: "differentiator",
|
||||||
title: "Уточняющие / УТП"
|
title: "Уточняющая семантика / УТП"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "низкочастотные хвосты, FAQ, подписи и внутренние связи",
|
description: "низкочастотные хвосты, FAQ, подписи и внутренние связи",
|
||||||
role: "support",
|
role: "support",
|
||||||
title: "Длинный хвост"
|
title: "НЧ-хвост"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -4143,8 +4144,10 @@ function SeoAnchorReviewWorkspace({
|
||||||
activeFilter,
|
activeFilter,
|
||||||
anchorReview,
|
anchorReview,
|
||||||
isBulkApproving,
|
isBulkApproving,
|
||||||
|
isManualAdding,
|
||||||
onDecision,
|
onDecision,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
onManualAdd,
|
||||||
stageProjectId,
|
stageProjectId,
|
||||||
sectionId
|
sectionId
|
||||||
}: {
|
}: {
|
||||||
|
|
@ -4152,8 +4155,10 @@ function SeoAnchorReviewWorkspace({
|
||||||
activeFilter: AnchorReviewFilter;
|
activeFilter: AnchorReviewFilter;
|
||||||
anchorReview: AnchorReviewContract;
|
anchorReview: AnchorReviewContract;
|
||||||
isBulkApproving: boolean;
|
isBulkApproving: boolean;
|
||||||
|
isManualAdding: boolean;
|
||||||
onDecision: (anchor: AnchorReviewAnchorView, decision: AnchorReviewDecisionInput) => void;
|
onDecision: (anchor: AnchorReviewAnchorView, decision: AnchorReviewDecisionInput) => void;
|
||||||
onFilterChange: (filter: AnchorReviewFilter) => void;
|
onFilterChange: (filter: AnchorReviewFilter) => void;
|
||||||
|
onManualAdd: () => void;
|
||||||
stageProjectId?: string;
|
stageProjectId?: string;
|
||||||
sectionId?: string;
|
sectionId?: string;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -4174,17 +4179,31 @@ function SeoAnchorReviewWorkspace({
|
||||||
data-keyword-stage-project={stageProjectId}
|
data-keyword-stage-project={stageProjectId}
|
||||||
id={sectionId}
|
id={sectionId}
|
||||||
>
|
>
|
||||||
<div className="keyword-stage-heading">
|
<div className="keyword-anchor-stage-head">
|
||||||
<KeyRound size={20} />
|
<div className="keyword-stage-heading">
|
||||||
<div>
|
<KeyRound size={20} />
|
||||||
<strong>Этап 1 · Фиксация ключей</strong>
|
<div>
|
||||||
<small>Выбираем смысловые якоря и вручную решаем, что отправлять в очередь Wordstat.</small>
|
<strong>Этап 1 · Фиксация семантического базиса</strong>
|
||||||
<small>
|
<small>Выбираем смысловые якоря и вручную решаем, что отправлять в очередь Wordstat.</small>
|
||||||
{getAnchorReviewStateLabel(anchorReview.state)} · очередь Wordstat {anchorReview.readiness.wordstatQueueCount} ·
|
<small>
|
||||||
нормализовано: {anchorReview.readiness.normalizedAnchorCount} · предложено:{" "}
|
{getAnchorReviewStateLabel(anchorReview.state)} · очередь Wordstat {anchorReview.readiness.wordstatQueueCount} ·
|
||||||
{anchorReview.readiness.proposedWordstatCount} · на разборе: {anchorReview.readiness.pendingAnchorCount} · скрыто:{" "}
|
нормализовано: {anchorReview.readiness.normalizedAnchorCount} · предложено:{" "}
|
||||||
{anchorReview.readiness.disabledAnchorCount}
|
{anchorReview.readiness.proposedWordstatCount} · на разборе: {anchorReview.readiness.pendingAnchorCount} · скрыто:{" "}
|
||||||
</small>
|
{anchorReview.readiness.disabledAnchorCount}
|
||||||
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -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({
|
function SeoKeywordCleaningLane({
|
||||||
emptyLabel,
|
emptyLabel,
|
||||||
items,
|
items,
|
||||||
|
|
@ -4339,7 +4423,6 @@ function getKeywordCleaningStage5Candidates(keywordCleaning: KeywordCleaningCont
|
||||||
item.decision !== "trash" &&
|
item.decision !== "trash" &&
|
||||||
item.evidenceStatus === "collected" &&
|
item.evidenceStatus === "collected" &&
|
||||||
item.frequency !== null &&
|
item.frequency !== null &&
|
||||||
Boolean(item.targetPath) &&
|
|
||||||
Boolean(item.projectOntologyVersionId) &&
|
Boolean(item.projectOntologyVersionId) &&
|
||||||
Boolean(item.wordstatResultId)
|
Boolean(item.wordstatResultId)
|
||||||
);
|
);
|
||||||
|
|
@ -5473,11 +5556,11 @@ function getKeywordMapStateLabel(state: KeywordMapContract["state"]) {
|
||||||
|
|
||||||
function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) {
|
function getKeywordMapRoleLabel(role: KeywordMapContract["items"][number]["role"] | string) {
|
||||||
const labels = {
|
const labels = {
|
||||||
differentiator: "уточняющие / УТП",
|
differentiator: "уточняющая семантика / УТП",
|
||||||
primary: "посадочное ядро",
|
primary: "семантическое ядро",
|
||||||
secondary: "поддерживающее ядро",
|
secondary: "поддерживающая семантика",
|
||||||
secondary_candidate: "кандидат",
|
secondary_candidate: "кандидат",
|
||||||
support: "длинный хвост",
|
support: "НЧ-хвост",
|
||||||
validate: "неразобранные"
|
validate: "неразобранные"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -8609,6 +8692,8 @@ export function App() {
|
||||||
const [reviewingContextProjectId, setReviewingContextProjectId] = useState<string | null>(null);
|
const [reviewingContextProjectId, setReviewingContextProjectId] = useState<string | null>(null);
|
||||||
const [approvingAnchorReviewProjectId, setApprovingAnchorReviewProjectId] = useState<string | null>(null);
|
const [approvingAnchorReviewProjectId, setApprovingAnchorReviewProjectId] = useState<string | null>(null);
|
||||||
const [anchorReviewActionKey, setAnchorReviewActionKey] = 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 [anchorReviewFilters, setAnchorReviewFilters] = useState<Record<string, AnchorReviewFilter>>({});
|
||||||
const [keywordCurationRoleOverrides, setKeywordCurationRoleOverrides] = useState<KeywordCurationRoleOverrides>({});
|
const [keywordCurationRoleOverrides, setKeywordCurationRoleOverrides] = useState<KeywordCurationRoleOverrides>({});
|
||||||
const [activeKeywordWorkflowSteps, setActiveKeywordWorkflowSteps] = useState<Record<string, KeywordWorkflowStep>>({});
|
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) {
|
async function handleSeoModelTaskRun(project: ProjectSummary, taskType: SeoModelTaskType) {
|
||||||
const taskKey = `${project.id}:${taskType}`;
|
const taskKey = `${project.id}:${taskType}`;
|
||||||
setRunningModelTaskKey(taskKey);
|
setRunningModelTaskKey(taskKey);
|
||||||
|
|
@ -13267,6 +13402,10 @@ export function App() {
|
||||||
document.body
|
document.body
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
const manualAnchorProject = manualAnchorProjectId
|
||||||
|
? (projects.find((project) => project.id === manualAnchorProjectId) ?? null)
|
||||||
|
: null;
|
||||||
|
const isSavingManualAnchor = manualAnchorProject ? anchorReviewActionKey === `${manualAnchorProject.id}:manual-anchor` : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -13279,6 +13418,16 @@ export function App() {
|
||||||
} seo-stage-${activeStageIndex + 1}`}
|
} seo-stage-${activeStageIndex + 1}`}
|
||||||
>
|
>
|
||||||
{strategyExportMenuPortal}
|
{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">
|
<header className="app-header nodedc-expanded-toolbar-shell">
|
||||||
<div className="nodedc-expanded-toolbar">
|
<div className="nodedc-expanded-toolbar">
|
||||||
<div className="nodedc-expanded-toolbar-top">
|
<div className="nodedc-expanded-toolbar-top">
|
||||||
|
|
@ -17248,6 +17397,7 @@ export function App() {
|
||||||
activeFilter={activeAnchorReviewFilter}
|
activeFilter={activeAnchorReviewFilter}
|
||||||
anchorReview={anchorReview}
|
anchorReview={anchorReview}
|
||||||
isBulkApproving={isApprovingAnchorReview}
|
isBulkApproving={isApprovingAnchorReview}
|
||||||
|
isManualAdding={anchorReviewActionKey === `${project.id}:manual-anchor`}
|
||||||
onDecision={(_anchor, decision) => void handleAnchorReviewDecision(project, decision)}
|
onDecision={(_anchor, decision) => void handleAnchorReviewDecision(project, decision)}
|
||||||
onFilterChange={(filter) =>
|
onFilterChange={(filter) =>
|
||||||
setAnchorReviewFilters((currentFilters) => ({
|
setAnchorReviewFilters((currentFilters) => ({
|
||||||
|
|
@ -17255,6 +17405,7 @@ export function App() {
|
||||||
[project.id]: filter
|
[project.id]: filter
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
onManualAdd={() => openManualAnchorModal(project)}
|
||||||
stageProjectId={project.id}
|
stageProjectId={project.id}
|
||||||
sectionId={`keyword-anchor-review:${project.id}`}
|
sectionId={`keyword-anchor-review:${project.id}`}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -821,6 +821,13 @@ export type AnchorReviewDecisionInput = {
|
||||||
status: AnchorReviewDecision["status"];
|
status: AnchorReviewDecision["status"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AnchorReviewManualAnchorInput = {
|
||||||
|
phrase: string;
|
||||||
|
reason?: string;
|
||||||
|
sendToSerp?: boolean;
|
||||||
|
sendToWordstat?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type MarketEnrichmentContract = {
|
export type MarketEnrichmentContract = {
|
||||||
schemaVersion: "market-enrichment.v1";
|
schemaVersion: "market-enrichment.v1";
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -2990,6 +2997,22 @@ export async function saveAnchorReviewDecisions(projectId: string, decisions?: A
|
||||||
return response.json() as Promise<{ anchorReview: AnchorReviewContract }>;
|
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) {
|
export async function fetchLatestKeywordCleaning(projectId: string) {
|
||||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-cleaning/latest`);
|
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-cleaning/latest`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2958,6 +2958,18 @@ input {
|
||||||
background: #fff;
|
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 {
|
.anchor-review-row > div:first-child span {
|
||||||
justify-self: start;
|
justify-self: start;
|
||||||
min-height: 22px;
|
min-height: 22px;
|
||||||
|
|
@ -13111,6 +13123,31 @@ textarea:focus {
|
||||||
border-radius: 1rem;
|
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 {
|
.seo-project-name-modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ export type AnchorReviewDecisionInput = {
|
||||||
status: AnchorDecisionStatus;
|
status: AnchorDecisionStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AnchorReviewManualAnchorInput = {
|
||||||
|
phrase: string;
|
||||||
|
reason?: string;
|
||||||
|
sendToSerp?: boolean;
|
||||||
|
sendToWordstat?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type AnchorReviewDecision = {
|
export type AnchorReviewDecision = {
|
||||||
decidedAt: string | null;
|
decidedAt: string | null;
|
||||||
decidedBy: string | null;
|
decidedBy: string | null;
|
||||||
|
|
@ -107,6 +114,7 @@ type AnchorReviewRunRow = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type DecisionSnapshot = {
|
type DecisionSnapshot = {
|
||||||
|
anchor?: AnchorReviewAnchor;
|
||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
decision: AnchorReviewDecision;
|
decision: AnchorReviewDecision;
|
||||||
|
|
@ -182,12 +190,71 @@ function normalizeSavedDecision(seed: SeoNormalizationSeed, decision: AnchorRevi
|
||||||
|
|
||||||
function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] {
|
function getSavedDecisionSnapshots(savedOutput: AnchorReviewContract | null): DecisionSnapshot[] {
|
||||||
return (savedOutput?.anchors ?? []).map((anchor) => ({
|
return (savedOutput?.anchors ?? []).map((anchor) => ({
|
||||||
|
anchor,
|
||||||
decision: anchor.decision,
|
decision: anchor.decision,
|
||||||
id: anchor.id,
|
id: anchor.id,
|
||||||
key: anchor.key
|
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(
|
function buildAnchor(
|
||||||
seed: SeoNormalizationSeed,
|
seed: SeoNormalizationSeed,
|
||||||
index: number,
|
index: number,
|
||||||
|
|
@ -300,18 +367,21 @@ function buildContract(
|
||||||
const anchors = normalization.seedStrategy.map((seed, index) =>
|
const anchors = normalization.seedStrategy.map((seed, index) =>
|
||||||
buildAnchor(seed, index, decisionSnapshotsById, decisionSnapshotsByKey)
|
buildAnchor(seed, index, decisionSnapshotsById, decisionSnapshotsByKey)
|
||||||
);
|
);
|
||||||
const wordstatQueue = buildWordstatQueue(anchors);
|
const seedAnchorIds = new Set(anchors.map((anchor) => anchor.id));
|
||||||
const pendingAnchorCount = anchors.filter((anchor) => anchor.decision.status === "pending").length;
|
const seedAnchorKeys = new Set(anchors.map((anchor) => anchor.key));
|
||||||
const approvedAnchorCount = anchors.filter((anchor) => anchor.decision.status === "approved").length;
|
const allAnchors = [...anchors, ...getSavedManualAnchors(decisionSnapshots, seedAnchorIds, seedAnchorKeys)];
|
||||||
const disabledAnchorCount = anchors.filter((anchor) => anchor.decision.status === "disabled").length;
|
const wordstatQueue = buildWordstatQueue(allAnchors);
|
||||||
const manualDecisionCount = anchors.filter((anchor) => anchor.decision.decidedAt).length;
|
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"> = {
|
const contractWithoutActions: Omit<AnchorReviewContract, "nextActions"> = {
|
||||||
schemaVersion: "anchor-review.v1",
|
schemaVersion: "anchor-review.v1",
|
||||||
projectId,
|
projectId,
|
||||||
semanticRunId: normalization.semanticRunId,
|
semanticRunId: normalization.semanticRunId,
|
||||||
projectOntologyVersionId: normalization.projectOntologyVersionId,
|
projectOntologyVersionId: normalization.projectOntologyVersionId,
|
||||||
generatedAt: new Date().toISOString(),
|
generatedAt: new Date().toISOString(),
|
||||||
state: getState(normalization, anchors, wordstatQueue.length),
|
state: getState(normalization, allAnchors, wordstatQueue.length),
|
||||||
source: {
|
source: {
|
||||||
normalizationGeneratedAt: normalization.generatedAt,
|
normalizationGeneratedAt: normalization.generatedAt,
|
||||||
normalizationProviderMode: normalization.provider.mode,
|
normalizationProviderMode: normalization.provider.mode,
|
||||||
|
|
@ -331,14 +401,14 @@ function buildContract(
|
||||||
approvedAnchorCount,
|
approvedAnchorCount,
|
||||||
disabledAnchorCount,
|
disabledAnchorCount,
|
||||||
manualDecisionCount,
|
manualDecisionCount,
|
||||||
needsHumanReviewCount: anchors.filter((anchor) => anchor.proposal.status === "needs_human").length,
|
needsHumanReviewCount: allAnchors.filter((anchor) => anchor.proposal.status === "needs_human").length,
|
||||||
normalizedAnchorCount: anchors.length,
|
normalizedAnchorCount: allAnchors.length,
|
||||||
pendingAnchorCount,
|
pendingAnchorCount,
|
||||||
proposedWordstatCount: anchors.filter((anchor) => anchor.proposal.sendToWordstat).length,
|
proposedWordstatCount: allAnchors.filter((anchor) => anchor.proposal.sendToWordstat).length,
|
||||||
sourceSeedCount: normalization.readiness.sourceSeedCount,
|
sourceSeedCount: normalization.readiness.sourceSeedCount,
|
||||||
wordstatQueueCount: wordstatQueue.length
|
wordstatQueueCount: wordstatQueue.length
|
||||||
},
|
},
|
||||||
anchors,
|
anchors: allAnchors,
|
||||||
wordstatQueue
|
wordstatQueue
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -462,6 +532,7 @@ export async function saveAnchorReviewDecisions(
|
||||||
}
|
}
|
||||||
|
|
||||||
const decisionSnapshots: DecisionSnapshot[] = current.anchors.map((anchor) => ({
|
const decisionSnapshots: DecisionSnapshot[] = current.anchors.map((anchor) => ({
|
||||||
|
anchor,
|
||||||
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
|
decision: nextDecisionById.get(anchor.id) ?? anchor.decision,
|
||||||
id: anchor.id,
|
id: anchor.id,
|
||||||
key: anchor.key
|
key: anchor.key
|
||||||
|
|
@ -487,3 +558,129 @@ export async function saveAnchorReviewDecisions(
|
||||||
|
|
||||||
return result.rows[0]?.output ?? output;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -219,11 +219,28 @@ const CONSUMER_AI_NOISE_PATTERNS = [
|
||||||
|
|
||||||
const AI_KEYWORD_PATTERN = /(^|\s)(ai|ии)(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект|нейросет/i;
|
const AI_KEYWORD_PATTERN = /(^|\s)(ai|ии)(\s|$)|искусственн[а-яёa-z0-9-]*\s+интеллект|нейросет/i;
|
||||||
const B2B_KEYWORD_FACET_PATTERN =
|
const B2B_KEYWORD_FACET_PATTERN =
|
||||||
/бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|1с|crm|erp|bpm|workflow|digital|twin|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн|предприят|корпоратив/i;
|
/бизнес|процесс|разработ|приложен|агент|ассистент|интеграц|решен|платформ|стоимост|внедрен|купить|заказать|тариф|1с|crm|erp|bpm|workflow|digital|twin|цифров|двойн|bim|бим|инженер|данн|тендер|закуп|юрид|договор|беспилот|iot|телеметр|строител|эксплуатац|проект|офис|облак|оркестр|инфраструктур|безопасн|предприят|корпоратив/i;
|
||||||
|
|
||||||
const ARTICLE_PATTERNS = [
|
const ARTICLE_PATTERNS = [
|
||||||
/(^|\s)(как|что такое|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд)(\s|$)/i,
|
/(^|\s)(как|что такое|что\s+это|почему|зачем|когда|пример|виды|этапы|способы|инструкция|гайд|определен[а-яёa-z0-9-]*|вопрос[а-яёa-z0-9-]*|ответ[а-яёa-z0-9-]*)(\s|$)/i,
|
||||||
/(^|\s)(выбрать|сравнение|обзор|решение проблемы)(\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) {
|
function normalizePhrase(value: string) {
|
||||||
|
|
@ -495,7 +512,7 @@ function buildKeywordCleaningSemanticGuard(contract: MarketEnrichmentContract):
|
||||||
dominantProtectedFacets,
|
dominantProtectedFacets,
|
||||||
policy: {
|
policy: {
|
||||||
broadFrequencyDoesNotOverrideSemanticFit: true,
|
broadFrequencyDoesNotOverrideSemanticFit: true,
|
||||||
demoteRelatedWhenDominantFacetMissing: true,
|
demoteRelatedWhenDominantFacetMissing: false,
|
||||||
preserveSourceAnchorFacetsForRelated: true
|
preserveSourceAnchorFacetsForRelated: true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -506,7 +523,7 @@ function getProtectedSourceFacetStems(item: KeywordCleaningItem, guard: KeywordC
|
||||||
return new Set<string>();
|
return new Set<string>();
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceStems = getKeywordCleaningFacetStems(`${item.sourcePhrase} ${item.clusterTitle}`);
|
const sourceStems = getKeywordCleaningFacetStems(item.sourcePhrase);
|
||||||
|
|
||||||
return new Set(
|
return new Set(
|
||||||
[...sourceStems].filter((stem) =>
|
[...sourceStems].filter((stem) =>
|
||||||
|
|
@ -524,13 +541,6 @@ function getKeywordCleaningSemanticDowngradeReason(item: KeywordCleaningItem, gu
|
||||||
return `Wordstat related потерял protected-признак исходного якоря (${missingSourceStems.join(", ")}).`;
|
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;
|
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(
|
function getTargetPath(
|
||||||
seed: Pick<SeedQueueItem, "clusterId" | "normalization" | "phrase">,
|
seed: Pick<SeedQueueItem, "clusterId" | "normalization" | "phrase">,
|
||||||
briefPhraseMap: Map<string, string>,
|
briefPhraseMap: Map<string, string>,
|
||||||
|
|
@ -600,6 +743,7 @@ function buildContextTokenSet(contract: MarketEnrichmentContract) {
|
||||||
for (const token of normalizePhrase(value).split(/\s+/)) {
|
for (const token of normalizePhrase(value).split(/\s+/)) {
|
||||||
if (token.length >= 4) {
|
if (token.length >= 4) {
|
||||||
tokens.add(token);
|
tokens.add(token);
|
||||||
|
tokens.add(stemKeywordCleaningToken(token));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -626,13 +770,15 @@ function buildContextTokenSet(contract: MarketEnrichmentContract) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasContextOverlap(phrase: string, contextTokens: Set<string>) {
|
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) {
|
if (phraseTokens.length === 0) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return phraseTokens.some((token) => contextTokens.has(token));
|
return phraseTokens.some((token) => contextTokens.has(token) || contextTokens.has(stemKeywordCleaningToken(token)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSeedItem(
|
function buildSeedItem(
|
||||||
|
|
@ -908,8 +1054,9 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>
|
||||||
item.evidenceStatus === "not_collected" ||
|
item.evidenceStatus === "not_collected" ||
|
||||||
item.evidenceStatus === "not_configured" ||
|
item.evidenceStatus === "not_configured" ||
|
||||||
item.evidenceStatus === "ready_for_collection";
|
item.evidenceStatus === "ready_for_collection";
|
||||||
const weakExternalSignal = item.evidenceStatus === "collected_related_only" || item.source === "wordstat_related";
|
const weakExternalSignal =
|
||||||
const needsHumanReview = item.evidenceStatus === "collection_failed" || noExternalSignal || item.targetPath === null;
|
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 isArticle = hasPattern(item.normalizedPhrase, ARTICLE_PATTERNS) || item.intentType === "informational";
|
||||||
const isTrash =
|
const isTrash =
|
||||||
item.marketRole === "exclude" ||
|
item.marketRole === "exclude" ||
|
||||||
|
|
@ -917,11 +1064,11 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>
|
||||||
isConsumerAiNoisePhrase(item.normalizedPhrase) ||
|
isConsumerAiNoisePhrase(item.normalizedPhrase) ||
|
||||||
isBroadAiKeywordNoise(item.normalizedPhrase) ||
|
isBroadAiKeywordNoise(item.normalizedPhrase) ||
|
||||||
(item.source === "wordstat_related" && isBroadAiKeywordNoise(item.sourcePhrase)) ||
|
(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);
|
(item.source === "wordstat_related" && wordCount(item.phrase) <= 1);
|
||||||
|
|
||||||
if (item.targetPath === null) {
|
if (item.targetPath === null) {
|
||||||
blockers.push("нет привязки к посадочной");
|
blockers.push("посадочную определит стратегия");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (noExternalSignal) {
|
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) {
|
if (isArticle && exactEvidence) {
|
||||||
return {
|
return {
|
||||||
blockers,
|
blockers,
|
||||||
|
|
@ -967,7 +1124,7 @@ function getClassification(item: KeywordCleaningItem, contextTokens: Set<string>
|
||||||
blockers,
|
blockers,
|
||||||
confidence: exactEvidence ? 0.62 : 0.48,
|
confidence: exactEvidence ? 0.62 : 0.48,
|
||||||
decision: "risky" as const,
|
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,
|
blockers,
|
||||||
confidence: item.priority === "high" ? 0.9 : 0.82,
|
confidence: item.priority === "high" ? 0.9 : 0.82,
|
||||||
decision: "use" as const,
|
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) =>
|
||||||
(item.decision === "use" || item.decision === "support") &&
|
(item.decision === "use" || item.decision === "support") &&
|
||||||
item.evidenceStatus === "collected" &&
|
item.evidenceStatus === "collected" &&
|
||||||
item.frequency !== null &&
|
item.frequency !== null
|
||||||
item.targetPath
|
|
||||||
).length,
|
).length,
|
||||||
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
|
missingPageBindingCount: items.filter((item) => item.targetPath === null && item.decision !== "trash").length,
|
||||||
riskyCount: lanes.risky.length,
|
riskyCount: lanes.risky.length,
|
||||||
|
|
@ -1054,9 +1212,9 @@ function buildKeywordCleaningModelTask(
|
||||||
decisionPolicy: {
|
decisionPolicy: {
|
||||||
articleDoesNotEnterRewrite: true,
|
articleDoesNotEnterRewrite: true,
|
||||||
broadFrequencyDoesNotOverrideSemanticFit: true,
|
broadFrequencyDoesNotOverrideSemanticFit: true,
|
||||||
demoteRelatedWhenDominantFacetMissing: true,
|
demoteRelatedWhenDominantFacetMissing: false,
|
||||||
preserveApprovedAnchorFacets: true,
|
preserveApprovedAnchorFacets: true,
|
||||||
riskyWhenNoPageBinding: true,
|
riskyWhenNoPageBinding: false,
|
||||||
trashWhenOffContext: true,
|
trashWhenOffContext: true,
|
||||||
useRequiresExactEvidence: true
|
useRequiresExactEvidence: true
|
||||||
},
|
},
|
||||||
|
|
@ -1103,8 +1261,12 @@ function buildKeywordCleaningModelTask(
|
||||||
stopConditions: [
|
stopConditions: [
|
||||||
"Не отправлять trash в keyword map. Risky/article exact-фразы можно только предсортировать в Stage 3, но не считать approved без пользователя.",
|
"Не отправлять trash в keyword map. Risky/article exact-фразы можно только предсортировать в Stage 3, но не считать approved без пользователя.",
|
||||||
"Не придумывать спрос без Wordstat/SERP evidence.",
|
"Не придумывать спрос без 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.",
|
"Отбрасывать бытовой 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.",
|
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
|
||||||
"Не сохранять rewrite/apply decisions."
|
"Не сохранять rewrite/apply decisions."
|
||||||
],
|
],
|
||||||
|
|
@ -1149,7 +1311,7 @@ function buildNextActions(contract: Omit<KeywordCleaningContract, "nextActions">
|
||||||
|
|
||||||
function buildFallbackContract(projectId: string, market: MarketEnrichmentContract): KeywordCleaningContract {
|
function buildFallbackContract(projectId: string, market: MarketEnrichmentContract): KeywordCleaningContract {
|
||||||
const modelTask = buildKeywordCleaningModelTask(projectId, market);
|
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 lanes = buildLanes(items);
|
||||||
const readiness = buildReadiness(items, lanes);
|
const readiness = buildReadiness(items, lanes);
|
||||||
const contractWithoutActions: Omit<KeywordCleaningContract, "nextActions"> = {
|
const contractWithoutActions: Omit<KeywordCleaningContract, "nextActions"> = {
|
||||||
|
|
@ -1319,7 +1481,7 @@ export async function getKeywordCleaningContract(projectId: string): Promise<Key
|
||||||
return fallbackContract;
|
return fallbackContract;
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = enforceKeywordCleaningSemanticGuard(market, latest.items);
|
const items = enforceKeywordCleaningBackendGuards(market, mergeMissingExactEvidenceItems(market, latest.items));
|
||||||
const lanes = buildLanes(items);
|
const lanes = buildLanes(items);
|
||||||
const readiness = buildReadiness(items, lanes);
|
const readiness = buildReadiness(items, lanes);
|
||||||
|
|
||||||
|
|
@ -1397,7 +1559,10 @@ export async function saveKeywordCleaningFromModelProvider(
|
||||||
|
|
||||||
const alignedOutput: KeywordCleaningContract = {
|
const alignedOutput: KeywordCleaningContract = {
|
||||||
...output,
|
...output,
|
||||||
items: enforceKeywordCleaningSemanticGuard(market, mergeModelProviderItemsWithMarketEvidence(output.items, market)),
|
items: enforceKeywordCleaningBackendGuards(
|
||||||
|
market,
|
||||||
|
mergeMissingExactEvidenceItems(market, mergeModelProviderItemsWithMarketEvidence(output.items, market))
|
||||||
|
),
|
||||||
modelTask,
|
modelTask,
|
||||||
projectOntologyVersion: market.projectOntologyVersion,
|
projectOntologyVersion: market.projectOntologyVersion,
|
||||||
semanticRunId: market.semanticRunId,
|
semanticRunId: market.semanticRunId,
|
||||||
|
|
|
||||||
|
|
@ -279,7 +279,7 @@ function getProtectedSourceFacetStems(item: KeywordCleaningItem, guard: Semantic
|
||||||
return new Set<string>();
|
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)));
|
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"]) {
|
function getKeywordPriorityWeight(priority: KeywordCleaningItem["priority"]) {
|
||||||
if (priority === "high") return 3;
|
if (priority === "high") return 3;
|
||||||
if (priority === "medium") return 2;
|
if (priority === "medium") return 2;
|
||||||
|
|
@ -384,8 +380,7 @@ function canModelRouteKeyword(item: KeywordCleaningItem, guard?: SemanticFacetGu
|
||||||
item.frequency !== null &&
|
item.frequency !== null &&
|
||||||
Boolean(item.projectOntologyVersionId) &&
|
Boolean(item.projectOntologyVersionId) &&
|
||||||
Boolean(item.wordstatResultId) &&
|
Boolean(item.wordstatResultId) &&
|
||||||
Boolean(item.targetPath) &&
|
(!semanticFit || semanticFit.label !== "lost_source_facet")
|
||||||
(!semanticFit || (semanticFit.label === "strong" && semanticFit.score >= 0.5))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -400,8 +395,10 @@ function isClarifyingKeywordCandidate(item: KeywordCleaningItem) {
|
||||||
item.marketRole === "integration" ||
|
item.marketRole === "integration" ||
|
||||||
item.intentType === "deployment_security" ||
|
item.intentType === "deployment_security" ||
|
||||||
item.intentType === "integration" ||
|
item.intentType === "integration" ||
|
||||||
/(^|\s)(api|bim|crm|mcp|1с|3d|digital twin|on[-\s]?premise)(\s|$)/i.test(text) ||
|
/(^|\s)(api|bim|crm|erp|mcp|iot|1с|3d|digital twin|on[-\s]?premise)(\s|$)/i.test(text) ||
|
||||||
/(интеграц|архитект|безопас|облак|workspace|документооборот|утп|дифференц)/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) {
|
function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleaningContract, reviewedOntology: boolean) {
|
||||||
const targetCounts = new Map<string, number>();
|
const targetCounts = new Map<string, number>();
|
||||||
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
const semanticFacetGuard = buildSemanticFacetGuard(contract);
|
||||||
|
const seenPhrases = new Set<string>();
|
||||||
const sourceItems = cleaning.items
|
const sourceItems = cleaning.items
|
||||||
.filter(
|
.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
|
|
@ -512,6 +510,16 @@ function buildItems(contract: MarketEnrichmentContract, cleaning: KeywordCleanin
|
||||||
|
|
||||||
return normalizePhrase(left.phrase).localeCompare(normalizePhrase(right.phrase), "ru");
|
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);
|
.slice(0, 120);
|
||||||
|
|
||||||
return sourceItems.map((item) => {
|
return sourceItems.map((item) => {
|
||||||
|
|
@ -562,19 +570,21 @@ function getKeywordMapItemReason(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "differentiator") {
|
if (role === "differentiator") {
|
||||||
return "Фраза усиливает отличающий слой посадочной: секции, FAQ или внутренние ссылки, но не primary и не SEO title.";
|
return "Фраза усиливает отличающий слой спроса: секции, FAQ или внутренние связи, но не primary и не SEO title.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "primary") {
|
if (role === "primary") {
|
||||||
return "Модель поставила фразу в посадочное ядро: это strongest exact-запрос для выбранной посадочной.";
|
return targetPath
|
||||||
|
? "Модель поставила фразу в посадочное ядро Stage 5: это strongest exact-запрос для спроса, а финальную посадочную подтвердит стратегия."
|
||||||
|
: "Модель поставила фразу в посадочное ядро Stage 5: есть exact Wordstat evidence, но посадочную ещё должна выбрать стратегия.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "secondary") {
|
if (role === "secondary") {
|
||||||
return "Модель поставила фразу в поддерживающее ядро: она раскрывает главный спрос и структуру посадочной.";
|
return "Модель поставила фразу в поддерживающее ядро Stage 5: она раскрывает главный спрос и будущую структуру стратегии.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "support") {
|
if (role === "support") {
|
||||||
return "Модель поставила фразу в длинный хвост: использовать в FAQ, подписях, внутренних связях или вторичных блоках.";
|
return "Модель поставила фразу в длинный хвост Stage 5: использовать в FAQ, подписях, внутренних связях или вторичных блоках после стратегии.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === "validate" && hasSemanticFacetLoss(item, semanticFacetGuard)) {
|
if (role === "validate" && hasSemanticFacetLoss(item, semanticFacetGuard)) {
|
||||||
|
|
@ -583,16 +593,10 @@ function getKeywordMapItemReason(
|
||||||
return `Wordstat related потерял protected-признак исходного якоря (${semanticFit.missingSourceStems.join(", ")}): оставляем на ручной разбор, чтобы широкий спрос не вытеснил смысл проекта.`;
|
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") {
|
if (item.decision === "article") {
|
||||||
return role === "validate"
|
return role === "validate"
|
||||||
? "Keyword cleaning отнесла фразу в article/backlog: не пускаем в rewrite текущей посадочной."
|
? "Keyword cleaning отнесла фразу в article/backlog: оставляем на ручной разбор до стратегии."
|
||||||
: "Модель предварительно распределила article/backlog-фразу; пользователь должен подтвердить, что она нужна текущей стратегии.";
|
: "Модель предварительно распределила article/backlog-фразу; пользователь должен подтвердить, что она нужна общей стратегии.";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.decision === "risky") {
|
if (item.decision === "risky") {
|
||||||
|
|
@ -892,7 +896,7 @@ export async function getKeywordMapContract(projectId: string): Promise<KeywordM
|
||||||
readiness: {
|
readiness: {
|
||||||
reviewedOntology,
|
reviewedOntology,
|
||||||
seedCount: cleaningContract.readiness.sourcePhraseCount,
|
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,
|
marketEvidenceCount: cleaningContract.readiness.exactEvidenceCount,
|
||||||
persistableItemCount: items.filter(isPersistableKeywordMapItem).length,
|
persistableItemCount: items.filter(isPersistableKeywordMapItem).length,
|
||||||
blockerCount: blockers.length
|
blockerCount: blockers.length
|
||||||
|
|
|
||||||
|
|
@ -510,13 +510,21 @@ function buildSeedQueue(
|
||||||
|
|
||||||
function shouldExpandSeed(seed: MarketEnrichmentContract["seedQueue"][number]) {
|
function shouldExpandSeed(seed: MarketEnrichmentContract["seedQueue"][number]) {
|
||||||
return (
|
return (
|
||||||
seed.frequency === null &&
|
seed.evidenceStatus === "collected" ||
|
||||||
(seed.evidenceStatus === "collected_no_signal" ||
|
seed.evidenceStatus === "collected_no_signal" ||
|
||||||
seed.evidenceStatus === "collected_related_only" ||
|
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(
|
function buildWordstatExpansionSeeds(
|
||||||
analysis: SemanticAnalysisRun,
|
analysis: SemanticAnalysisRun,
|
||||||
market: MarketEnrichmentContract
|
market: MarketEnrichmentContract
|
||||||
|
|
@ -553,7 +561,7 @@ function buildWordstatExpansionSeeds(
|
||||||
|
|
||||||
const clusterCandidates = candidatesByCluster.get(seed.clusterId);
|
const clusterCandidates = candidatesByCluster.get(seed.clusterId);
|
||||||
|
|
||||||
if (!clusterCandidates || clusterCandidates.length >= 8) {
|
if (!clusterCandidates || clusterCandidates.length >= 10) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -563,7 +571,7 @@ function buildWordstatExpansionSeeds(
|
||||||
confidence: Math.max(0.42, Math.min((seed.normalization?.confidence ?? 0.6) * 0.9, 0.82)),
|
confidence: Math.max(0.42, Math.min((seed.normalization?.confidence ?? 0.6) * 0.9, 0.82)),
|
||||||
phrase: normalizedPhrase,
|
phrase: normalizedPhrase,
|
||||||
priority: seed.priority,
|
priority: seed.priority,
|
||||||
reason: `Expansion seed для Wordstat: исходная фраза "${seed.phrase}" не дала exact demand; аналог собран из semantic cluster/source evidence.`,
|
reason: getExpansionSeedReason(seed),
|
||||||
source: seed.source
|
source: seed.source
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -571,7 +579,7 @@ function buildWordstatExpansionSeeds(
|
||||||
|
|
||||||
const diversifiedCandidates: WordstatSeedCandidate[] = [];
|
const diversifiedCandidates: WordstatSeedCandidate[] = [];
|
||||||
|
|
||||||
for (let index = 0; diversifiedCandidates.length < 48; index += 1) {
|
for (let index = 0; diversifiedCandidates.length < 72; index += 1) {
|
||||||
let added = false;
|
let added = false;
|
||||||
|
|
||||||
for (const clusterId of clusterOrder) {
|
for (const clusterId of clusterOrder) {
|
||||||
|
|
@ -584,7 +592,7 @@ function buildWordstatExpansionSeeds(
|
||||||
diversifiedCandidates.push(candidate);
|
diversifiedCandidates.push(candidate);
|
||||||
added = true;
|
added = true;
|
||||||
|
|
||||||
if (diversifiedCandidates.length >= 48) {
|
if (diversifiedCandidates.length >= 72) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ function getOutputTokenBudget(task: SeoModelTaskContract | null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.taskType === "seo.keyword_cleaning") {
|
if (task.taskType === "seo.keyword_cleaning") {
|
||||||
return 5200;
|
return 9000;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (task.taskType === "seo.serp_interpretation") {
|
if (task.taskType === "seo.serp_interpretation") {
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ import {
|
||||||
type KeywordCleaningContract
|
type KeywordCleaningContract
|
||||||
} from "../keywords/keywordCleaning.js";
|
} from "../keywords/keywordCleaning.js";
|
||||||
import {
|
import {
|
||||||
|
addAnchorReviewManualAnchor,
|
||||||
getAnchorReviewContract,
|
getAnchorReviewContract,
|
||||||
saveAnchorReviewDecisions
|
saveAnchorReviewDecisions
|
||||||
} from "../keywords/anchorReview.js";
|
} from "../keywords/anchorReview.js";
|
||||||
|
|
@ -255,6 +256,12 @@ const anchorReviewDecisionSchema = z.object({
|
||||||
const anchorReviewDecisionsSchema = z.object({
|
const anchorReviewDecisionsSchema = z.object({
|
||||||
decisions: z.array(anchorReviewDecisionSchema).max(200).optional()
|
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({
|
const serpInterpretationManualSchema = z.object({
|
||||||
output: z.record(z.string(), z.unknown())
|
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) => {
|
projectsRouter.get("/:projectId/keyword-cleaning/latest", async (request, response) => {
|
||||||
try {
|
try {
|
||||||
const projectId = projectIdSchema.parse(request.params.projectId);
|
const projectId = projectIdSchema.parse(request.params.projectId);
|
||||||
|
|
|
||||||
|
|
@ -897,14 +897,14 @@ function getActiveStrategyKeywordMapItems(cleaning: KeywordCleaningContract, key
|
||||||
return keywordMap.persisted.items.filter((item) => {
|
return keywordMap.persisted.items.filter((item) => {
|
||||||
const cleaningItem = cleaningByPhrase.get(normalizePhraseKey(item.phrase));
|
const cleaningItem = cleaningByPhrase.get(normalizePhraseKey(item.phrase));
|
||||||
const currentMapItem = currentRoutableByPhrase.get(normalizePhraseKey(item.phrase));
|
const currentMapItem = currentRoutableByPhrase.get(normalizePhraseKey(item.phrase));
|
||||||
|
const approvedRoleIsActive = item.role !== "validate" && item.role !== "secondary_candidate";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
approvedRoleIsActive &&
|
||||||
Boolean(currentMapItem) &&
|
Boolean(currentMapItem) &&
|
||||||
Boolean(cleaningItem) &&
|
Boolean(cleaningItem) &&
|
||||||
(cleaningItem?.decision === "use" || cleaningItem?.decision === "support") &&
|
|
||||||
cleaningItem?.evidenceStatus === "collected" &&
|
cleaningItem?.evidenceStatus === "collected" &&
|
||||||
cleaningItem?.frequency !== null &&
|
cleaningItem?.frequency !== null &&
|
||||||
currentMapItem?.role === item.role &&
|
|
||||||
normalizeKey(currentMapItem?.targetPath ?? null) === normalizeKey(item.targetPath)
|
normalizeKey(currentMapItem?.targetPath ?? null) === normalizeKey(item.targetPath)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue