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 {
|
||||
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}`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue