Compare commits

..

2 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 4d82de82c1 Restore keyword context snapshot replay 2026-07-05 16:03:00 +03:00
DCCONSTRUCTIONS 50619193f0 Harden Wordstat source group filtering 2026-07-05 16:02:42 +03:00
7 changed files with 742 additions and 72 deletions

View File

@ -66,6 +66,7 @@ import {
deleteProject, deleteProject,
downloadSemanticAnalysisExport, downloadSemanticAnalysisExport,
fetchExternalServiceSettings, fetchExternalServiceSettings,
fetchKeywordContextSnapshot,
fetchKeywordContextSnapshots, fetchKeywordContextSnapshots,
fetchLatestKeywordAnalysisWorkflow, fetchLatestKeywordAnalysisWorkflow,
fetchModelProviderStatus, fetchModelProviderStatus,
@ -128,6 +129,7 @@ import {
type HealthResponse, type HealthResponse,
type KeywordAnalysisWorkflowContract, type KeywordAnalysisWorkflowContract,
type KeywordCleaningContract, type KeywordCleaningContract,
type KeywordContextSnapshotDetail,
type KeywordContextSnapshotSummary, type KeywordContextSnapshotSummary,
type KeywordMapContract, type KeywordMapContract,
type KeywordMapRole, type KeywordMapRole,
@ -331,7 +333,15 @@ const dateFormatter = new Intl.DateTimeFormat("ru-RU", {
const numberFormatter = new Intl.NumberFormat("ru-RU"); const numberFormatter = new Intl.NumberFormat("ru-RU");
function getErrorMessage(error: unknown, fallback: string) { function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback; if (!(error instanceof Error)) {
return fallback;
}
if (error.message === "Failed to fetch") {
return `${fallback}: backend API недоступен или вкладка потеряла соединение с http://localhost:4100. Обнови страницу после запуска сервера.`;
}
return error.message;
} }
function formatDate(value: string) { function formatDate(value: string) {
@ -6345,6 +6355,28 @@ function getKeywordSnapshotTimestamp(snapshot: KeywordContextSnapshotSummary) {
return Date.parse(snapshot.keywordMapGeneratedAt ?? snapshot.createdAt); return Date.parse(snapshot.keywordMapGeneratedAt ?? snapshot.createdAt);
} }
function getKeywordContextSnapshotDetailKey(projectId: string, snapshotId: string) {
return `${projectId}:${snapshotId}`;
}
function getKeywordContextSnapshotOverrideRecord(snapshot: KeywordContextSnapshotDetail | null) {
const roleOverrides = snapshot?.snapshot.curation?.roleOverrides ?? [];
return Object.fromEntries(
roleOverrides
.map((override) => {
const role = normalizeKeywordCurationRole(override.role);
return keywordCurationColumns.some((column) => column.role === role) ? [override.itemId, role] : null;
})
.filter((entry): entry is [string, KeywordCurationRole] => Boolean(entry))
);
}
function getKeywordContextSnapshotSuppressedIds(snapshot: KeywordContextSnapshotDetail | null) {
return snapshot?.snapshot.curation?.suppressedItemIds ?? [];
}
function getKeywordExpansionBaselineSnapshot(keywordMap: KeywordMapContract, snapshots: KeywordContextSnapshotSummary[]) { function getKeywordExpansionBaselineSnapshot(keywordMap: KeywordMapContract, snapshots: KeywordContextSnapshotSummary[]) {
const keywordMapTimestamp = Date.parse(keywordMap.generatedAt); const keywordMapTimestamp = Date.parse(keywordMap.generatedAt);
@ -9556,6 +9588,10 @@ export function App() {
const [keywordCurationSuppressedItemIds, setKeywordCurationSuppressedItemIds] = useState<Record<string, string[]>>({}); const [keywordCurationSuppressedItemIds, setKeywordCurationSuppressedItemIds] = useState<Record<string, string[]>>({});
const [keywordContextSnapshots, setKeywordContextSnapshots] = useState<Record<string, KeywordContextSnapshotSummary[]>>({}); const [keywordContextSnapshots, setKeywordContextSnapshots] = useState<Record<string, KeywordContextSnapshotSummary[]>>({});
const [activeKeywordContextSnapshotIds, setActiveKeywordContextSnapshotIds] = useState<Record<string, string>>({}); const [activeKeywordContextSnapshotIds, setActiveKeywordContextSnapshotIds] = useState<Record<string, string>>({});
const [keywordContextSnapshotDetails, setKeywordContextSnapshotDetails] = useState<
Record<string, KeywordContextSnapshotDetail>
>({});
const [loadingKeywordContextSnapshotKey, setLoadingKeywordContextSnapshotKey] = useState<string | null>(null);
const [savingKeywordContextSnapshotProjectId, setSavingKeywordContextSnapshotProjectId] = useState<string | null>(null); const [savingKeywordContextSnapshotProjectId, setSavingKeywordContextSnapshotProjectId] = useState<string | null>(null);
const [savingKeywordCurationLayoutProjectId, setSavingKeywordCurationLayoutProjectId] = useState<string | null>(null); const [savingKeywordCurationLayoutProjectId, setSavingKeywordCurationLayoutProjectId] = useState<string | null>(null);
const [pendingKeywordContextSnapshotDialog, setPendingKeywordContextSnapshotDialog] = const [pendingKeywordContextSnapshotDialog, setPendingKeywordContextSnapshotDialog] =
@ -9756,6 +9792,45 @@ export function App() {
setKeywordContextSnapshots(Object.fromEntries(snapshotEntries)); setKeywordContextSnapshots(Object.fromEntries(snapshotEntries));
} }
async function loadKeywordContextSnapshotDetail(projectId: string, snapshotId: string) {
if (snapshotId === "current") {
return null;
}
const detailKey = getKeywordContextSnapshotDetailKey(projectId, snapshotId);
const cachedDetail = keywordContextSnapshotDetails[detailKey];
if (cachedDetail) {
return cachedDetail;
}
setLoadingKeywordContextSnapshotKey(detailKey);
try {
const result = await fetchKeywordContextSnapshot(projectId, snapshotId);
setKeywordContextSnapshotDetails((currentDetails) => ({
...currentDetails,
[detailKey]: result
}));
setKeywordContextSnapshots((currentSnapshots) => ({
...currentSnapshots,
[projectId]: (currentSnapshots[projectId] ?? []).map((snapshot) =>
snapshot.id === result.summary.id ? result.summary : snapshot
)
}));
return result;
} catch (error: unknown) {
setProjectActionProjectId(projectId);
setProjectActionError(getErrorMessage(error, "Не удалось загрузить сохранённый контекст ключей."));
setProjectActionMessage(null);
return null;
} finally {
setLoadingKeywordContextSnapshotKey((currentKey) => (currentKey === detailKey ? null : currentKey));
}
}
async function loadSeoStrategies(projectList: ProjectSummary[]) { async function loadSeoStrategies(projectList: ProjectSummary[]) {
const strategyEntries = await Promise.all( const strategyEntries = await Promise.all(
projectList.map(async (project) => { projectList.map(async (project) => {
@ -10694,7 +10769,6 @@ export function App() {
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
setProjectActionMessage(null); setProjectActionMessage(null);
setProjectActionProjectId(null);
}, NOTICE_AUTO_HIDE_MS); }, NOTICE_AUTO_HIDE_MS);
return () => { return () => {
@ -12379,15 +12453,22 @@ export function App() {
setPendingKeywordSuppress(null); setPendingKeywordSuppress(null);
} }
function getKeywordContextSnapshotCuration(projectId: string, keywordMap: KeywordMapContract | null) { function getKeywordContextSnapshotCuration(
const suppressedItemIds = keywordCurationSuppressedItemIds[projectId] ?? []; projectId: string,
keywordMap: KeywordMapContract | null,
curationState?: {
roleOverrides?: Record<string, KeywordCurationRole>;
suppressedItemIds?: string[];
}
) {
const suppressedItemIds = curationState?.suppressedItemIds ?? keywordCurationSuppressedItemIds[projectId] ?? [];
const suppressedItemIdSet = new Set(suppressedItemIds); const suppressedItemIdSet = new Set(suppressedItemIds);
return { return {
roleOverrides: keywordMap roleOverrides: keywordMap
? getKeywordCurationRoleOverrides( ? getKeywordCurationRoleOverrides(
keywordMap, keywordMap,
keywordCurationRoleOverrides[projectId] ?? {}, curationState?.roleOverrides ?? keywordCurationRoleOverrides[projectId] ?? {},
suppressedItemIdSet suppressedItemIdSet
) )
: [], : [],
@ -12442,32 +12523,21 @@ export function App() {
setProjectActionProjectId(project.id); setProjectActionProjectId(project.id);
try { try {
const keywordMap = keywordMaps[project.id] ?? null; const detailKey = getKeywordContextSnapshotDetailKey(project.id, activeSnapshot.id);
const curation = getKeywordContextSnapshotCuration(project.id, keywordMap); const snapshotDetail =
keywordContextSnapshotDetails[detailKey] ?? (await loadKeywordContextSnapshotDetail(project.id, activeSnapshot.id));
if (keywordMap && (curation.roleOverrides.length > 0 || curation.suppressedItemIds.length > 0)) { const keywordMap = snapshotDetail?.snapshot.keywordMap ?? keywordMaps[project.id] ?? null;
const changedItemIds = Array.from( const snapshotRoleOverrides = getKeywordContextSnapshotOverrideRecord(snapshotDetail);
new Set([...curation.suppressedItemIds, ...curation.roleOverrides.map((override) => override.itemId)]) const liveRoleOverrides = keywordCurationRoleOverrides[project.id] ?? {};
); const snapshotSuppressedIds = getKeywordContextSnapshotSuppressedIds(snapshotDetail);
const curationResult = await approveKeywordMapDecisions(project.id, { const liveSuppressedIds = keywordCurationSuppressedItemIds[project.id] ?? [];
itemIds: changedItemIds, const curation = getKeywordContextSnapshotCuration(project.id, keywordMap, {
roleOverrides: curation.roleOverrides, roleOverrides: {
suppressedItemIds: curation.suppressedItemIds ...snapshotRoleOverrides,
}); ...liveRoleOverrides
},
setKeywordMaps((currentKeywordMaps) => ({ suppressedItemIds: Array.from(new Set([...snapshotSuppressedIds, ...liveSuppressedIds]))
...currentKeywordMaps, });
[project.id]: curationResult.keywordMap
}));
setKeywordCurationRoleOverrides((currentOverrides) => ({
...currentOverrides,
[project.id]: {}
}));
setKeywordCurationSuppressedItemIds((currentSuppressed) => ({
...currentSuppressed,
[project.id]: []
}));
}
const result = await updateKeywordContextSnapshotCuration( const result = await updateKeywordContextSnapshotCuration(
project.id, project.id,
@ -12481,6 +12551,25 @@ export function App() {
snapshot.id === result.snapshot.id ? result.snapshot : snapshot snapshot.id === result.snapshot.id ? result.snapshot : snapshot
) )
})); }));
setKeywordContextSnapshotDetails((currentDetails) => {
const currentDetail = currentDetails[detailKey];
if (!currentDetail) {
return currentDetails;
}
return {
...currentDetails,
[detailKey]: {
...currentDetail,
snapshot: {
...currentDetail.snapshot,
curation
},
summary: result.snapshot
}
};
});
setActiveKeywordContextSnapshotIds((currentIds) => ({ setActiveKeywordContextSnapshotIds((currentIds) => ({
...currentIds, ...currentIds,
[project.id]: result.snapshot.id [project.id]: result.snapshot.id
@ -12583,6 +12672,13 @@ export function App() {
setProjectActionMessage(`Контекст переименован: ${result.snapshot.label}.`); setProjectActionMessage(`Контекст переименован: ${result.snapshot.label}.`);
} else { } else {
await deleteKeywordContextSnapshot(project.id, dialog.snapshotId); await deleteKeywordContextSnapshot(project.id, dialog.snapshotId);
setKeywordContextSnapshotDetails((currentDetails) =>
Object.fromEntries(
Object.entries(currentDetails).filter(
([detailKey]) => detailKey !== getKeywordContextSnapshotDetailKey(project.id, dialog.snapshotId)
)
)
);
setKeywordContextSnapshots((currentSnapshots) => { setKeywordContextSnapshots((currentSnapshots) => {
const nextSnapshots = (currentSnapshots[project.id] ?? []).filter((snapshot) => snapshot.id !== dialog.snapshotId); const nextSnapshots = (currentSnapshots[project.id] ?? []).filter((snapshot) => snapshot.id !== dialog.snapshotId);
@ -14180,16 +14276,89 @@ export function App() {
const activeProject = activeProjectId ? (projects.find((project) => project.id === activeProjectId) ?? null) : null; const activeProject = activeProjectId ? (projects.find((project) => project.id === activeProjectId) ?? null) : null;
const activeProjectScanSummary = activeProject ? scanSummaries[activeProject.id] : null; const activeProjectScanSummary = activeProject ? scanSummaries[activeProject.id] : null;
const activeSemanticAnalysis = activeProject ? (semanticAnalyses[activeProject.id] ?? null) : null; const activeKeywordContextSnapshotList = activeProject ? (keywordContextSnapshots[activeProject.id] ?? []) : [];
const activeKeywordContextSnapshotId =
activeProject && activeKeywordContextSnapshotList.length > 0
? (activeKeywordContextSnapshotIds[activeProject.id] ?? activeKeywordContextSnapshotList[0]?.id ?? "current")
: "current";
const activeKeywordContextSnapshotKey =
activeProject && activeKeywordContextSnapshotId !== "current"
? getKeywordContextSnapshotDetailKey(activeProject.id, activeKeywordContextSnapshotId)
: null;
const activeKeywordContextSnapshotDetail = activeKeywordContextSnapshotKey
? (keywordContextSnapshotDetails[activeKeywordContextSnapshotKey] ?? null)
: null;
const isActiveKeywordContextSnapshotLoading = Boolean(
activeKeywordContextSnapshotKey && loadingKeywordContextSnapshotKey === activeKeywordContextSnapshotKey
);
const activeSemanticAnalysis =
activeKeywordContextSnapshotDetail?.snapshot.semanticAnalysis ?? (activeProject ? (semanticAnalyses[activeProject.id] ?? null) : null);
const activeContextReviewRaw = activeProject ? (contextReviews[activeProject.id] ?? null) : null; const activeContextReviewRaw = activeProject ? (contextReviews[activeProject.id] ?? null) : null;
const activeContextReview = const activeContextReview =
activeSemanticAnalysis && activeContextReviewRaw?.semanticRunId === activeSemanticAnalysis.runId activeSemanticAnalysis && activeContextReviewRaw?.semanticRunId === activeSemanticAnalysis.runId
? activeContextReviewRaw ? activeContextReviewRaw
: null; : null;
const activeMarketEnrichment = activeProject ? (marketEnrichments[activeProject.id] ?? null) : null; const activeMarketEnrichment =
activeKeywordContextSnapshotDetail?.snapshot.marketEnrichment ??
(activeProject ? (marketEnrichments[activeProject.id] ?? null) : null);
const activeKeywordAnalysisWorkflow = activeProject ? (keywordAnalysisWorkflows[activeProject.id] ?? null) : null; const activeKeywordAnalysisWorkflow = activeProject ? (keywordAnalysisWorkflows[activeProject.id] ?? null) : null;
const activeKeywordMap =
activeKeywordContextSnapshotDetail?.snapshot.keywordMap ?? (activeProject ? (keywordMaps[activeProject.id] ?? null) : null);
const activeQuotaProjectId = activeProject?.id ?? null; const activeQuotaProjectId = activeProject?.id ?? null;
useEffect(() => {
if (activeStageIndex !== 4 || !activeQuotaProjectId) {
return;
}
let cancelled = false;
void fetchKeywordContextSnapshots(activeQuotaProjectId)
.then((result) => {
if (cancelled) {
return;
}
setKeywordContextSnapshots((currentSnapshots) => ({
...currentSnapshots,
[activeQuotaProjectId]: result.snapshots
}));
})
.catch((error: unknown) => {
if (cancelled) {
return;
}
setProjectActionProjectId(activeQuotaProjectId);
setProjectActionError(getErrorMessage(error, "Не удалось обновить snapshot-профили ключей."));
setProjectActionMessage(null);
});
return () => {
cancelled = true;
};
}, [activeQuotaProjectId, activeStageIndex]);
useEffect(() => {
if (!activeProject || activeStageIndex !== 4 || activeKeywordContextSnapshotId === "current") {
return;
}
const detailKey = getKeywordContextSnapshotDetailKey(activeProject.id, activeKeywordContextSnapshotId);
if (keywordContextSnapshotDetails[detailKey] || loadingKeywordContextSnapshotKey === detailKey) {
return;
}
void loadKeywordContextSnapshotDetail(activeProject.id, activeKeywordContextSnapshotId);
}, [
activeKeywordContextSnapshotId,
activeProject?.id,
activeStageIndex,
keywordContextSnapshotDetails,
loadingKeywordContextSnapshotKey
]);
useEffect(() => { useEffect(() => {
if (activeStageIndex !== 4 || !activeQuotaProjectId) { if (activeStageIndex !== 4 || !activeQuotaProjectId) {
return; return;
@ -14267,7 +14436,6 @@ export function App() {
return () => window.clearInterval(intervalId); return () => window.clearInterval(intervalId);
}, [activeKeywordAnalysisWorkflow?.runId, activeKeywordAnalysisWorkflow?.status, activeQuotaProjectId, activeStageIndex]); }, [activeKeywordAnalysisWorkflow?.runId, activeKeywordAnalysisWorkflow?.status, activeQuotaProjectId, activeStageIndex]);
const activeKeywordMap = activeProject ? (keywordMaps[activeProject.id] ?? null) : null;
const activeSeoStrategy = activeProject ? (seoStrategies[activeProject.id] ?? null) : null; const activeSeoStrategy = activeProject ? (seoStrategies[activeProject.id] ?? null) : null;
const activeStrategySynthesis = activeProject ? (strategySyntheses[activeProject.id] ?? null) : null; const activeStrategySynthesis = activeProject ? (strategySyntheses[activeProject.id] ?? null) : null;
const activeStrategyQualityReview = activeProject ? (strategyQualityReviews[activeProject.id] ?? null) : null; const activeStrategyQualityReview = activeProject ? (strategyQualityReviews[activeProject.id] ?? null) : null;
@ -14629,11 +14797,16 @@ export function App() {
title: workspaceMode === "dev" ? activeStage.title : "Автоматический SEO-процесс", title: workspaceMode === "dev" ? activeStage.title : "Автоматический SEO-процесс",
subtitle: workspaceMode === "dev" ? `Dev Mode · этап ${activeStageIndex + 1}` : "Auto Mode · целевой слой" subtitle: workspaceMode === "dev" ? `Dev Mode · этап ${activeStageIndex + 1}` : "Auto Mode · целевой слой"
}; };
const activeAnchorReviewRaw = activeMarketEnrichment?.anchorReview ?? null; const activeAnchorReviewRaw =
activeKeywordContextSnapshotDetail?.snapshot.anchorReview ?? activeMarketEnrichment?.anchorReview ?? null;
const activeAnchorReviewDraft = activeProject ? (anchorReviewDraftDecisions[activeProject.id] ?? {}) : {}; const activeAnchorReviewDraft = activeProject ? (anchorReviewDraftDecisions[activeProject.id] ?? {}) : {};
const activeAnchorReviewDraftCount = getAnchorReviewDraftDecisionList(activeAnchorReviewRaw, activeAnchorReviewDraft).length; const activeAnchorReviewDraftCount = getAnchorReviewDraftDecisionList(activeAnchorReviewRaw, activeAnchorReviewDraft).length;
const activeAnchorReview = activeAnchorReviewRaw ? applyAnchorReviewDraft(activeAnchorReviewRaw, activeAnchorReviewDraft) : null; const activeAnchorReview = activeAnchorReviewRaw ? applyAnchorReviewDraft(activeAnchorReviewRaw, activeAnchorReviewDraft) : null;
const activeAnchorReviewReady = Boolean(activeMarketEnrichment?.readiness.anchorReviewReady); const activeAnchorReviewReady = Boolean(
activeMarketEnrichment?.readiness.anchorReviewReady ||
activeAnchorReviewRaw?.state === "approved" ||
(activeAnchorReviewRaw?.readiness.wordstatQueueCount ?? 0) > 0
);
const activeKeywordWordstatOutdated = activeMarketEnrichment ? isMarketWordstatOutdated(activeMarketEnrichment) : false; const activeKeywordWordstatOutdated = activeMarketEnrichment ? isMarketWordstatOutdated(activeMarketEnrichment) : false;
const activeKeywordAnalysisRebuilding = isKeywordAnalysisWorkflowActive(activeKeywordAnalysisWorkflow); const activeKeywordAnalysisRebuilding = isKeywordAnalysisWorkflowActive(activeKeywordAnalysisWorkflow);
const activeKeywordAnalysisReady = Boolean( const activeKeywordAnalysisReady = Boolean(
@ -14642,8 +14815,20 @@ export function App() {
? activeKeywordAnalysisWorkflow.status === "completed" ? activeKeywordAnalysisWorkflow.status === "completed"
: activeMarketEnrichment && isMarketAnalysisReady(activeMarketEnrichment)) : activeMarketEnrichment && isMarketAnalysisReady(activeMarketEnrichment))
); );
const activeKeywordCurationOverrides = activeProject ? (keywordCurationRoleOverrides[activeProject.id] ?? {}) : {}; const activeKeywordCurationOverrides = activeProject
const activeKeywordCurationSuppressedIds = activeProject ? (keywordCurationSuppressedItemIds[activeProject.id] ?? []) : []; ? {
...getKeywordContextSnapshotOverrideRecord(activeKeywordContextSnapshotDetail),
...(keywordCurationRoleOverrides[activeProject.id] ?? {})
}
: {};
const activeKeywordCurationSuppressedIds = activeProject
? Array.from(
new Set([
...getKeywordContextSnapshotSuppressedIds(activeKeywordContextSnapshotDetail),
...(keywordCurationSuppressedItemIds[activeProject.id] ?? [])
])
)
: [];
const activeKeywordCurationSuppressedSet = new Set(activeKeywordCurationSuppressedIds); const activeKeywordCurationSuppressedSet = new Set(activeKeywordCurationSuppressedIds);
const activeKeywordWorkflowStep = const activeKeywordWorkflowStep =
activeProject && activeStageIndex === 4 activeProject && activeStageIndex === 4
@ -14699,11 +14884,7 @@ export function App() {
!isPersistingActiveKeywordMap !isPersistingActiveKeywordMap
); );
const isSubmittingActiveKeywordStrategy = isPersistingActiveKeywordMap && persistingKeywordMapMode === "strategy"; const isSubmittingActiveKeywordStrategy = isPersistingActiveKeywordMap && persistingKeywordMapMode === "strategy";
const activeKeywordContextSnapshotList = activeProject ? (keywordContextSnapshots[activeProject.id] ?? []) : []; const isActiveKeywordContextSnapshotReplay = activeKeywordContextSnapshotId !== "current";
const activeKeywordContextSnapshotId =
activeProject && activeKeywordContextSnapshotList.length > 0
? (activeKeywordContextSnapshotIds[activeProject.id] ?? activeKeywordContextSnapshotList[0]?.id ?? "current")
: "current";
const activeKeywordContextSnapshotOptions: NdcGlassSelectOption<string>[] = [ const activeKeywordContextSnapshotOptions: NdcGlassSelectOption<string>[] = [
{ label: "Текущий контекст", value: "current" }, { label: "Текущий контекст", value: "current" },
...activeKeywordContextSnapshotList.map((snapshot) => ({ ...activeKeywordContextSnapshotList.map((snapshot) => ({
@ -14738,15 +14919,24 @@ export function App() {
className="keyword-context-profile-select" className="keyword-context-profile-select"
disabled={isSavingActiveKeywordContextSnapshot} disabled={isSavingActiveKeywordContextSnapshot}
menuClassName="keyword-context-profile-menu" menuClassName="keyword-context-profile-menu"
onChange={(snapshotId) => onChange={(snapshotId) => {
setActiveKeywordContextSnapshotIds((currentIds) => ({ setActiveKeywordContextSnapshotIds((currentIds) => ({
...currentIds, ...currentIds,
[activeProject.id]: snapshotId [activeProject.id]: snapshotId
})) }));
}
if (snapshotId !== "current") {
void loadKeywordContextSnapshotDetail(activeProject.id, snapshotId);
}
}}
options={activeKeywordContextSnapshotOptions} options={activeKeywordContextSnapshotOptions}
value={activeKeywordContextSnapshotId} value={activeKeywordContextSnapshotId}
/> />
{isActiveKeywordContextSnapshotReplay ? (
<span className="keyword-context-profile-status">
{isActiveKeywordContextSnapshotLoading ? "загружаю снимок" : "снимок"}
</span>
) : null}
<button <button
aria-label="Сохранить как новый профиль контекста ключей" aria-label="Сохранить как новый профиль контекста ключей"
className={isSavingActiveKeywordContextSnapshot ? "saving" : undefined} className={isSavingActiveKeywordContextSnapshot ? "saving" : undefined}
@ -14824,7 +15014,7 @@ export function App() {
{activeKeywordActionStep === "curation" && activeKeywordMap ? ( {activeKeywordActionStep === "curation" && activeKeywordMap ? (
<button <button
className="active" className="active"
disabled={!canSubmitActiveKeywordStrategy} disabled={!canSubmitActiveKeywordStrategy || isActiveKeywordContextSnapshotReplay}
onClick={() => onClick={() =>
void handleApproveKeywordMapDecisions(activeProject, { void handleApproveKeywordMapDecisions(activeProject, {
jumpToStrategy: true, jumpToStrategy: true,
@ -14837,7 +15027,9 @@ export function App() {
}) })
} }
title={ title={
activeKeywordMap.state === "draft" isActiveKeywordContextSnapshotReplay
? "Сначала переключись на текущий контекст: сохранённый snapshot открыт только для просмотра и раскладки."
: activeKeywordMap.state === "draft"
? "Сформировать стратегию по распределённым ключам" ? "Сформировать стратегию по распределённым ключам"
: "Карта ключей заблокирована backend-гейтами" : "Карта ключей заблокирована backend-гейтами"
} }
@ -14848,7 +15040,16 @@ export function App() {
</button> </button>
) : activeKeywordActionStep === "analysis" && activeAnchorReviewReady ? ( ) : activeKeywordActionStep === "analysis" && activeAnchorReviewReady ? (
activeKeywordAnalysisReady ? ( activeKeywordAnalysisReady ? (
<button onClick={() => handleKeywordReconfigure(activeProject.id)} type="button"> <button
disabled={isActiveKeywordContextSnapshotReplay}
onClick={() => handleKeywordReconfigure(activeProject.id)}
title={
isActiveKeywordContextSnapshotReplay
? "Сначала переключись на текущий контекст: snapshot не запускает новый анализ."
: "Переконфигурировать ключи"
}
type="button"
>
<RefreshCw size={14} /> <RefreshCw size={14} />
Переконфигурировать ключи Переконфигурировать ключи
</button> </button>
@ -14867,12 +15068,15 @@ export function App() {
className="active" className="active"
disabled={ disabled={
isApprovingActiveAnchorReview || isApprovingActiveAnchorReview ||
isActiveKeywordContextSnapshotReplay ||
activeAnchorReview.state === "not_ready" || activeAnchorReview.state === "not_ready" ||
activeAnchorReview.readiness.wordstatQueueCount === 0 activeAnchorReview.readiness.wordstatQueueCount === 0
} }
onClick={() => void handleAnchorReviewApproval(activeProject)} onClick={() => void handleAnchorReviewApproval(activeProject)}
title={ title={
activeAnchorReviewDraftCount > 0 isActiveKeywordContextSnapshotReplay
? "Сначала переключись на текущий контекст: snapshot не запускает новый рыночный анализ."
: activeAnchorReviewDraftCount > 0
? "Автосохранить текущую раскладку и отправить семантический базис на рыночный анализ" ? "Автосохранить текущую раскладку и отправить семантический базис на рыночный анализ"
: "Отправить семантический базис на рыночный анализ" : "Отправить семантический базис на рыночный анализ"
} }
@ -15029,6 +15233,11 @@ export function App() {
return; return;
} }
if (!projectActionProjectId) {
setProjectActionError(null);
setProjectActionMessage(null);
}
setActiveStageIndex(stageIndex); setActiveStageIndex(stageIndex);
setIsStageRailOpen(true); setIsStageRailOpen(true);
setIsWorkPanelOpen(true); setIsWorkPanelOpen(true);
@ -15060,6 +15269,20 @@ export function App() {
setAnalyzingProjectId(project.id); setAnalyzingProjectId(project.id);
try { try {
const currentKeywordMap = keywordMaps[project.id] ?? null;
const shouldCreateSafetySnapshot = Boolean(currentKeywordMap && currentKeywordMap.items.length > 0);
let safetySnapshotLabel: string | null = null;
if (shouldCreateSafetySnapshot) {
const safetySnapshot = await persistKeywordContextSnapshot(
project,
"manual_save",
`Автоснимок перед новым анализом · ${new Date().toLocaleString("ru-RU")}`,
"Safety snapshot: текущая карта ключей сохранена перед полным пересбором semantic/context/latest состояния проекта."
);
safetySnapshotLabel = safetySnapshot.label;
}
const result = await runSemanticAnalysis(project.id); const result = await runSemanticAnalysis(project.id);
setSemanticAnalyses((currentAnalyses) => ({ setSemanticAnalyses((currentAnalyses) => ({
@ -15139,7 +15362,7 @@ export function App() {
})); }));
invalidateStrategyQualityReview(project.id); invalidateStrategyQualityReview(project.id);
setProjectActionMessage( setProjectActionMessage(
`DEV context готов: ${getModelTaskExecutionStatusLabel(contextRun.runStatus)}. Business synthesis: ${getModelTaskExecutionStatusLabel( `${safetySnapshotLabel ? `Safety snapshot сохранён: ${safetySnapshotLabel}. ` : ""}DEV context готов: ${getModelTaskExecutionStatusLabel(contextRun.runStatus)}. Business synthesis: ${getModelTaskExecutionStatusLabel(
businessRun.runStatus businessRun.runStatus
)}. Commercial demand: ${getModelTaskExecutionStatusLabel( )}. Commercial demand: ${getModelTaskExecutionStatusLabel(
commercialRun.runStatus commercialRun.runStatus
@ -16235,16 +16458,28 @@ export function App() {
<div className="project-list"> <div className="project-list">
{visibleProjects.map((project) => { {visibleProjects.map((project) => {
const scanSummary = scanSummaries[project.id]; const scanSummary = scanSummaries[project.id];
const semanticAnalysis = semanticAnalyses[project.id] ?? null; const projectKeywordContextSnapshotId =
project.id === activeProject?.id ? activeKeywordContextSnapshotId : "current";
const projectKeywordContextSnapshotKey =
projectKeywordContextSnapshotId !== "current"
? getKeywordContextSnapshotDetailKey(project.id, projectKeywordContextSnapshotId)
: null;
const projectKeywordContextSnapshotDetail = projectKeywordContextSnapshotKey
? (keywordContextSnapshotDetails[projectKeywordContextSnapshotKey] ?? null)
: null;
const semanticAnalysis =
projectKeywordContextSnapshotDetail?.snapshot.semanticAnalysis ?? (semanticAnalyses[project.id] ?? null);
const contextReview = contextReviews[project.id] ?? null; const contextReview = contextReviews[project.id] ?? null;
const isContextReviewAligned = Boolean( const isContextReviewAligned = Boolean(
semanticAnalysis && contextReview && contextReview.semanticRunId === semanticAnalysis.runId semanticAnalysis && contextReview && contextReview.semanticRunId === semanticAnalysis.runId
); );
const activeContextReview = isContextReviewAligned ? contextReview : null; const activeContextReview = isContextReviewAligned ? contextReview : null;
const projectOntology = projectOntologies[project.id] ?? null; const projectOntology = projectOntologies[project.id] ?? null;
const marketEnrichment = marketEnrichments[project.id] ?? null; const marketEnrichment =
const keywordCleaning = keywordCleanings[project.id] ?? null; projectKeywordContextSnapshotDetail?.snapshot.marketEnrichment ?? (marketEnrichments[project.id] ?? null);
const keywordMap = keywordMaps[project.id] ?? null; const keywordCleaning =
projectKeywordContextSnapshotDetail?.snapshot.keywordCleaning ?? (keywordCleanings[project.id] ?? null);
const keywordMap = projectKeywordContextSnapshotDetail?.snapshot.keywordMap ?? (keywordMaps[project.id] ?? null);
const seoStrategy = seoStrategies[project.id] ?? null; const seoStrategy = seoStrategies[project.id] ?? null;
const yandexEvidence = yandexEvidences[project.id] ?? null; const yandexEvidence = yandexEvidences[project.id] ?? null;
const serpInterpretation = serpInterpretations[project.id] ?? null; const serpInterpretation = serpInterpretations[project.id] ?? null;
@ -16271,7 +16506,8 @@ export function App() {
const keywordAnalysisWorkflow = keywordAnalysisWorkflows[project.id] ?? null; const keywordAnalysisWorkflow = keywordAnalysisWorkflows[project.id] ?? null;
const isKeywordAnalysisRebuilding = isKeywordAnalysisWorkflowActive(keywordAnalysisWorkflow); const isKeywordAnalysisRebuilding = isKeywordAnalysisWorkflowActive(keywordAnalysisWorkflow);
const anchorReviewDraft = anchorReviewDraftDecisions[project.id] ?? {}; const anchorReviewDraft = anchorReviewDraftDecisions[project.id] ?? {};
const rawAnchorReview = marketEnrichment?.anchorReview ?? null; const rawAnchorReview =
projectKeywordContextSnapshotDetail?.snapshot.anchorReview ?? marketEnrichment?.anchorReview ?? null;
const anchorReviewDraftCount = getAnchorReviewDraftDecisionList(rawAnchorReview, anchorReviewDraft).length; const anchorReviewDraftCount = getAnchorReviewDraftDecisionList(rawAnchorReview, anchorReviewDraft).length;
const savedDemandCollectionProfile = rawAnchorReview?.demandCollectionProfile ?? "contextual"; const savedDemandCollectionProfile = rawAnchorReview?.demandCollectionProfile ?? "contextual";
const anchorReviewDemandProfile = const anchorReviewDemandProfile =
@ -16282,7 +16518,11 @@ export function App() {
const anchorReviewDraftChangeCount = const anchorReviewDraftChangeCount =
anchorReviewDraftCount + (isAnchorReviewDemandProfileDirty ? 1 : 0); anchorReviewDraftCount + (isAnchorReviewDemandProfileDirty ? 1 : 0);
const anchorReview = rawAnchorReview ? applyAnchorReviewDraft(rawAnchorReview, anchorReviewDraft) : null; const anchorReview = rawAnchorReview ? applyAnchorReviewDraft(rawAnchorReview, anchorReviewDraft) : null;
const anchorReviewReady = Boolean(marketEnrichment?.readiness.anchorReviewReady); const anchorReviewReady = Boolean(
marketEnrichment?.readiness.anchorReviewReady ||
rawAnchorReview?.state === "approved" ||
(rawAnchorReview?.readiness.wordstatQueueCount ?? 0) > 0
);
const keywordAnalysisReady = Boolean( const keywordAnalysisReady = Boolean(
!isKeywordAnalysisRebuilding && !isKeywordAnalysisRebuilding &&
(keywordAnalysisWorkflow (keywordAnalysisWorkflow
@ -16299,8 +16539,16 @@ export function App() {
const isCurationStageCollapsed = Boolean( const isCurationStageCollapsed = Boolean(
collapsedKeywordStages[getKeywordStageCollapseKey(project.id, "curation")] collapsedKeywordStages[getKeywordStageCollapseKey(project.id, "curation")]
); );
const keywordCurationOverrides = keywordCurationRoleOverrides[project.id] ?? {}; const keywordCurationOverrides = {
const keywordCurationSuppressedIds = keywordCurationSuppressedItemIds[project.id] ?? []; ...getKeywordContextSnapshotOverrideRecord(projectKeywordContextSnapshotDetail),
...(keywordCurationRoleOverrides[project.id] ?? {})
};
const keywordCurationSuppressedIds = Array.from(
new Set([
...getKeywordContextSnapshotSuppressedIds(projectKeywordContextSnapshotDetail),
...(keywordCurationSuppressedItemIds[project.id] ?? [])
])
);
const keywordCurationSuppressedSet = new Set(keywordCurationSuppressedIds); const keywordCurationSuppressedSet = new Set(keywordCurationSuppressedIds);
const keywordCleaningStage5Candidates = keywordCleaning ? getKeywordCleaningStage5Candidates(keywordCleaning) : []; const keywordCleaningStage5Candidates = keywordCleaning ? getKeywordCleaningStage5Candidates(keywordCleaning) : [];
const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null; const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null;

View File

@ -1676,6 +1676,30 @@ export type KeywordContextSnapshotInput = {
export type KeywordContextSnapshotCurationInput = NonNullable<KeywordContextSnapshotInput["curation"]>; export type KeywordContextSnapshotCurationInput = NonNullable<KeywordContextSnapshotInput["curation"]>;
export type KeywordContextSnapshotDetail = {
summary: KeywordContextSnapshotSummary;
snapshot: {
schemaVersion: string;
projectId: string;
createdAt: string;
label: string;
note: string | null;
source: "expansion_checkpoint" | "manual_save";
semanticRunId: string | null;
semanticAnalysis: SemanticAnalysisRun | null;
anchorReview: AnchorReviewContract | null;
marketEnrichment: MarketEnrichmentContract | null;
keywordCleaning: KeywordCleaningContract | null;
keywordMap: KeywordMapContract | null;
curation: {
roleOverrides?: KeywordMapRoleOverrideInput[];
savedAt?: string;
suppressedItemIds?: string[];
};
replayContract?: Record<string, unknown>;
};
};
export type SeoStrategyContract = { export type SeoStrategyContract = {
schemaVersion: "seo-strategy.v1"; schemaVersion: "seo-strategy.v1";
projectId: string; projectId: string;
@ -3476,6 +3500,16 @@ export async function fetchKeywordContextSnapshots(projectId: string) {
return response.json() as Promise<{ snapshots: KeywordContextSnapshotSummary[] }>; return response.json() as Promise<{ snapshots: KeywordContextSnapshotSummary[] }>;
} }
export async function fetchKeywordContextSnapshot(projectId: string, snapshotId: string) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots/${snapshotId}`);
if (!response.ok) {
throw new Error(await getErrorMessage(response, `Не удалось загрузить snapshot-профиль ключей: ${response.status}`));
}
return response.json() as Promise<KeywordContextSnapshotDetail>;
}
export async function saveKeywordContextSnapshot(projectId: string, input: KeywordContextSnapshotInput) { export async function saveKeywordContextSnapshot(projectId: string, input: KeywordContextSnapshotInput) {
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots`, { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots`, {
method: "POST", method: "POST",

View File

@ -14986,7 +14986,7 @@ body:has(.seo-launcher-shell) {
.keyword-context-toolbar { .keyword-context-toolbar {
display: inline-grid; display: inline-grid;
grid-template-columns: minmax(12rem, 17rem) 2.42rem; grid-template-columns: minmax(12rem, 17rem) auto auto;
align-items: center; align-items: center;
gap: 0.38rem; gap: 0.38rem;
min-width: 0; min-width: 0;
@ -15032,6 +15032,17 @@ body:has(.seo-launcher-shell) {
opacity: 0.68; opacity: 0.68;
} }
.keyword-context-profile-status {
max-width: 5.8rem;
overflow: hidden;
color: rgba(10, 12, 14, 0.56);
font-size: 0.66rem;
font-weight: 800;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.keyword-context-profile-menu { .keyword-context-profile-menu {
min-width: 25rem; min-width: 25rem;
max-width: min(31rem, calc(100vw - 1.5rem)); max-width: min(31rem, calc(100vw - 1.5rem));

View File

@ -84,6 +84,17 @@ export type KeywordCleaningModelTaskContract = {
sourceClusterId: string | null; sourceClusterId: string | null;
sourceClusterTitle: string | null; sourceClusterTitle: string | null;
}>; }>;
sourceGroupCritics: Array<{
sourcePhrase: string;
verdict: "clean" | "mixed" | "source_drift";
reason: string;
resultCount: number;
exactCount: number;
driftSignalCount: number;
strictFitSignalCount: number;
driftExamples: string[];
strictFitExamples: string[];
}>;
}; };
outputSchema: { outputSchema: {
schemaVersion: "keyword-cleaning.v1"; schemaVersion: "keyword-cleaning.v1";
@ -95,6 +106,8 @@ export type KeywordCleaningModelTaskContract = {
broadFrequencyDoesNotOverrideSemanticFit: boolean; broadFrequencyDoesNotOverrideSemanticFit: boolean;
demoteRelatedWhenDominantFacetMissing: boolean; demoteRelatedWhenDominantFacetMissing: boolean;
preserveApprovedAnchorFacets: boolean; preserveApprovedAnchorFacets: boolean;
quarantineNoisyRelatedGroups: boolean;
sourceGroupDriftDoesNotEnterKeywordMap: boolean;
useRequiresExactEvidence: boolean; useRequiresExactEvidence: boolean;
riskyWhenNoPageBinding: boolean; riskyWhenNoPageBinding: boolean;
trashWhenOffContext: boolean; trashWhenOffContext: boolean;
@ -205,6 +218,22 @@ type RelatedSourceSeed = Pick<
SeedQueueItem, SeedQueueItem,
"clusterId" | "clusterTitle" | "normalization" | "phrase" | "priority" | "projectOntologyVersionId" | "source" "clusterId" | "clusterTitle" | "normalization" | "phrase" | "priority" | "projectOntologyVersionId" | "source"
>; >;
type WordstatSourceGroupCriticVerdict = "clean" | "mixed" | "source_drift";
type WordstatSourceGroupCritic = {
sourcePhrase: string;
normalizedSourcePhrase: string;
verdict: WordstatSourceGroupCriticVerdict;
reason: string;
resultCount: number;
exactCount: number;
signalCount: number;
driftSignalCount: number;
strictFitSignalCount: number;
sourceHasStrictFit: boolean;
sourceLooksWeak: boolean;
driftExamples: string[];
strictFitExamples: string[];
};
const HARD_TRASH_PATTERNS = [ const HARD_TRASH_PATTERNS = [
/(^|\s)(ваканси[ия]|работа|зарплата|резюме)(\s|$)/i, /(^|\s)(ваканси[ия]|работа|зарплата|резюме)(\s|$)/i,
@ -249,6 +278,26 @@ const PRODUCT_TARIFF_QUALIFIER_PATTERN =
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 SOURCE_GROUP_CRITIC_MIN_RESULTS = 4;
const SOURCE_GROUP_CRITIC_MODEL_LIMIT = 24;
const SOURCE_GROUP_CRITIC_DRIFT_PATTERNS = [
/(^|\s)(звук[а-яёa-z0-9-]*|слог[а-яёa-z0-9-]*|букв[а-яёa-z0-9-]*|реч[а-яёa-z0-9-]*|логопед[а-яёa-z0-9-]*|артикуляц[а-яёa-z0-9-]*|дошкольн[а-яёa-z0-9-]*|произношен[а-яёa-z0-9-]*)(\s|$)/i,
/(^|\s)(основн[а-яёa-z0-9-]*|базов[а-яёa-z0-9-]*)\s+(поняти[а-яёa-z0-9-]*|цель|цели|задач[а-яёa-z0-9-]*|принцип[а-яёa-z0-9-]*|вид[а-яёa-z0-9-]*|тип[а-яёa-z0-9-]*|функци[а-яёa-z0-9-]*|этап[а-яёa-z0-9-]*|направлен[а-яёa-z0-9-]*|уровн[а-яёa-z0-9-]*|средств[а-яёa-z0-9-]*)/i,
/(^|\s)(поняти[ея]|определен[а-яёa-z0-9-]*|цель|цели|задач[аи]?|принцип[а-яёa-z0-9-]*|виды|типы|функци[а-яёa-z0-9-]*|этапы|направлен[а-яёa-z0-9-]*|уровни|схем[а-яёa-z0-9-]*|структур[а-яёa-z0-9-]*|классификац[а-яёa-z0-9-]*|реферат|конспект|презентац[а-яёa-z0-9-]*)(\s|$)/i,
/(^|\s)(упражнен[а-яёa-z0-9-]*|задани[а-яёa-z0-9-]*|игр[а-яёa-z0-9-]*|картинк[а-яёa-z0-9-]*|стих[а-яёa-z0-9-]*)(\s|$)/i
];
const SOURCE_GROUP_CRITIC_WEAK_SOURCE_PATTERNS = [
/(^|\s)(предложени[еяюях]*|основн[а-яёa-z0-9-]*|поняти[а-яёa-z0-9-]*|цель|цели|задач[а-яёa-z0-9-]*|принцип[а-яёa-z0-9-]*|вид[а-яёa-z0-9-]*|тип[а-яёa-z0-9-]*|функци[а-яёa-z0-9-]*|этап[а-яёa-z0-9-]*|направлен[а-яёa-z0-9-]*)(\s|$)/i
];
const SOURCE_GROUP_STRICT_PRODUCT_FIT_PATTERNS = [
/(^|\s)(агент[а-яёa-z0-9-]*|ассистент[а-яёa-z0-9-]*)(\s|$)/i,
/(^|\s)(ai|ии|llm|ml|нейросет[а-яёa-z0-9-]*|искусственн[а-яёa-z0-9-]*\s+интеллект)\s+(агент[а-яёa-z0-9-]*|ассистент[а-яёa-z0-9-]*|платформ[а-яёa-z0-9-]*|решен[а-яёa-z0-9-]*|сервис[а-яёa-z0-9-]*|для\s+(бизнес[а-яёa-z0-9-]*|компан[а-яёa-z0-9-]*|предприят[а-яёa-z0-9-]*|корпоративн[а-яёa-z0-9-]*))/i,
/(^|\s)(платформ[а-яёa-z0-9-]*|решен[а-яёa-z0-9-]*|сервис[а-яёa-z0-9-]*)\s+(ai|ии|llm|ml|нейросет[а-яёa-z0-9-]*|искусственн[а-яёa-z0-9-]*\s+интеллект)(\s|$)/i,
/(^|\s)(b2b|enterprise|корпоративн[а-яёa-z0-9-]*|предприят[а-яёa-z0-9-]*|компан[а-яёa-z0-9-]*|бизнес[а-яёa-z0-9-]*)(\s|$)/i,
/(^|\s)(crm|erp|bpm|workflow|api|1с|1c|эдо|документооборот|закуп[а-яёa-z0-9-]*|тендер[а-яёa-z0-9-]*|договор[а-яёa-z0-9-]*|юридическ[а-яёa-z0-9-]*|финанс[а-яёa-z0-9-]*|поручен[а-яёa-z0-9-]*|согласован[а-яёa-z0-9-]*|база\s+знан[а-яёa-z0-9-]*)(\s|$)/i,
/(^|\s)(интеграц[а-яёa-z0-9-]*|внедрен[а-яёa-z0-9-]*|разработк[а-яёa-z0-9-]*|под\s+ключ|заказать|купить|стоимост[а-яёa-z0-9-]*|цен[а-яёa-z0-9-]*|платформ[а-яёa-z0-9-]*|решен[а-яёa-z0-9-]*)(\s|$)/i,
/(^|\s)(строител[а-яёa-z0-9-]*|производств[а-яёa-z0-9-]*|промышленн[а-яёa-z0-9-]*|беспилот[а-яёa-z0-9-]*|iot|телеметр[а-яёa-z0-9-]*|digital\s+twin|цифров[а-яёa-z0-9-]*\s+двойн[а-яёa-z0-9-]*|bim|бим)(\s|$)/i
];
const KEYWORD_CLEANING_MODEL_APPROVED_ANCHOR_LIMIT = 60; const KEYWORD_CLEANING_MODEL_APPROVED_ANCHOR_LIMIT = 60;
const KEYWORD_CLEANING_MODEL_LANDING_BRIEF_LIMIT = 18; const KEYWORD_CLEANING_MODEL_LANDING_BRIEF_LIMIT = 18;
const KEYWORD_CLEANING_MODEL_NORMALIZATION_GROUP_LIMIT = 24; const KEYWORD_CLEANING_MODEL_NORMALIZATION_GROUP_LIMIT = 24;
@ -712,6 +761,233 @@ function promoteExactDemandItems(market: MarketEnrichmentContract, items: Keywor
}); });
} }
function hasProtectedProjectFacet(value: string, guard: KeywordCleaningFacetGuard) {
const phraseStems = getKeywordCleaningFacetStems(value);
return guard.dominantProtectedStems.some((facet) => phraseStems.has(facet.stem));
}
function hasStrictRelatedProductFit(market: MarketEnrichmentContract, value: string, guard: KeywordCleaningFacetGuard) {
const normalizedValue = normalizePhrase(value);
if (hasProtectedProjectFacet(normalizedValue, guard)) {
return true;
}
if (hasPattern(normalizedValue, SOURCE_GROUP_STRICT_PRODUCT_FIT_PATTERNS)) {
return true;
}
return (
AI_KEYWORD_PATTERN.test(normalizedValue) &&
hasB2BKeywordFacet(normalizedValue) &&
!isConsumerAiNoisePhrase(normalizedValue) &&
!hasEducationIntentMismatch(market, normalizedValue)
);
}
function hasSourceGroupRelatedDriftSignal(market: MarketEnrichmentContract, phrase: string) {
const normalizedPhrase = normalizePhrase(phrase);
return (
hasPattern(normalizedPhrase, SOURCE_GROUP_CRITIC_DRIFT_PATTERNS) ||
isConsumerAiNoisePhrase(normalizedPhrase) ||
isOffContextRelatedNoisePhrase(normalizedPhrase) ||
hasThirdPartyBrandDemandMismatch(normalizedPhrase) ||
hasNonProductMarketIntentMismatch(normalizedPhrase) ||
hasEducationIntentMismatch(market, normalizedPhrase)
);
}
function sourceGroupLooksWeak(sourcePhrase: string, guard: KeywordCleaningFacetGuard) {
const normalizedSourcePhrase = normalizePhrase(sourcePhrase);
return hasPattern(normalizedSourcePhrase, SOURCE_GROUP_CRITIC_WEAK_SOURCE_PATTERNS) && !hasProtectedProjectFacet(normalizedSourcePhrase, guard);
}
function getSourceGroupCriticVerdict(input: {
driftShare: number;
driftSignalCount: number;
signalCount: number;
sourceHasStrictFit: boolean;
sourceLooksWeak: boolean;
strictFitShare: number;
strictFitSignalCount: number;
}): WordstatSourceGroupCriticVerdict {
if (input.signalCount < SOURCE_GROUP_CRITIC_MIN_RESULTS) {
return "clean";
}
if (
input.sourceLooksWeak &&
input.driftSignalCount >= 2 &&
input.strictFitSignalCount <= 1 &&
input.strictFitShare < 0.5
) {
return "source_drift";
}
if (!input.sourceHasStrictFit && input.driftShare >= 0.45 && input.strictFitShare < 0.45 && input.signalCount >= 6) {
return "source_drift";
}
if (input.driftShare >= 0.7 && input.strictFitShare < 0.35 && input.signalCount >= 6) {
return "source_drift";
}
if (input.driftShare >= 0.3 && input.strictFitShare < 0.6) {
return "mixed";
}
return "clean";
}
function buildWordstatSourceGroupCriticReason(critic: Omit<WordstatSourceGroupCritic, "reason">) {
if (critic.verdict === "source_drift") {
return `Source-group critic пометил Wordstat related-группу "${critic.sourcePhrase}" как source drift: ${critic.driftSignalCount}/${critic.signalCount} строк ушли в омонимический или справочный спрос, строгий product-fit держат ${critic.strictFitSignalCount}.`;
}
if (critic.verdict === "mixed") {
return `Source-group critic пометил Wordstat related-группу "${critic.sourcePhrase}" как mixed: в пачке есть drift-сигналы, поэтому related без строгого product-fit нельзя повышать в карту только по частотности.`;
}
return `Source-group critic не нашел групповой drift у Wordstat related-группы "${critic.sourcePhrase}".`;
}
function buildWordstatSourceGroupCritics(
market: MarketEnrichmentContract,
items: KeywordCleaningItem[]
): Map<string, WordstatSourceGroupCritic> {
const guard = buildKeywordCleaningFacetGuard(market);
const groups = new Map<string, { sourcePhrase: string; items: KeywordCleaningItem[] }>();
for (const item of items) {
if (item.source !== "wordstat_related" || !item.sourcePhrase) {
continue;
}
const normalizedSourcePhrase = normalizePhrase(item.sourcePhrase);
const group = groups.get(normalizedSourcePhrase) ?? { items: [], sourcePhrase: item.sourcePhrase };
group.items.push(item);
groups.set(normalizedSourcePhrase, group);
}
const critics = new Map<string, WordstatSourceGroupCritic>();
for (const [normalizedSourcePhrase, group] of groups.entries()) {
const exactItems = group.items.filter((item) => item.frequency !== null && Boolean(item.wordstatResultId));
const signalItems = exactItems.length >= SOURCE_GROUP_CRITIC_MIN_RESULTS ? exactItems : group.items;
const driftItems = signalItems.filter((item) => hasSourceGroupRelatedDriftSignal(market, item.phrase));
const strictFitItems = signalItems.filter(
(item) => hasStrictRelatedProductFit(market, item.phrase, guard) && !hasSourceGroupRelatedDriftSignal(market, item.phrase)
);
const signalCount = signalItems.length;
const driftShare = signalCount > 0 ? driftItems.length / signalCount : 0;
const strictFitShare = signalCount > 0 ? strictFitItems.length / signalCount : 0;
const sourceHasStrictFit = hasStrictRelatedProductFit(market, group.sourcePhrase, guard);
const weakSource = sourceGroupLooksWeak(group.sourcePhrase, guard);
const baseCritic = {
driftExamples: driftItems.slice(0, 5).map((item) => item.phrase),
driftSignalCount: driftItems.length,
exactCount: exactItems.length,
normalizedSourcePhrase,
resultCount: group.items.length,
signalCount,
sourceHasStrictFit,
sourceLooksWeak: weakSource,
sourcePhrase: group.sourcePhrase,
strictFitExamples: strictFitItems.slice(0, 5).map((item) => item.phrase),
strictFitSignalCount: strictFitItems.length,
verdict: getSourceGroupCriticVerdict({
driftShare,
driftSignalCount: driftItems.length,
signalCount,
sourceHasStrictFit,
sourceLooksWeak: weakSource,
strictFitShare,
strictFitSignalCount: strictFitItems.length
})
};
const critic: WordstatSourceGroupCritic = {
...baseCritic,
reason: buildWordstatSourceGroupCriticReason(baseCritic)
};
critics.set(normalizedSourcePhrase, critic);
}
return critics;
}
function getSourceGroupCriticEvidenceRef(critic: WordstatSourceGroupCritic) {
return `backend:source-group-critic:${slugify(critic.sourcePhrase)}`;
}
function enforceWordstatSourceGroupCritic(
market: MarketEnrichmentContract,
items: KeywordCleaningItem[]
): KeywordCleaningItem[] {
const guard = buildKeywordCleaningFacetGuard(market);
const critics = buildWordstatSourceGroupCritics(market, items);
return items.map((item) => {
if (item.source !== "wordstat_related" || !item.sourcePhrase) {
return item;
}
const critic = critics.get(normalizePhrase(item.sourcePhrase));
if (!critic || critic.verdict === "clean") {
return item;
}
const selfHasDriftSignal = hasSourceGroupRelatedDriftSignal(market, item.phrase);
const selfHasStrictFit = hasStrictRelatedProductFit(market, item.phrase, guard) && !selfHasDriftSignal;
const blocker = critic.verdict === "source_drift" ? "source_group_related_drift" : "source_group_related_mixed";
const evidenceRefs = unique([...item.evidenceRefs, getSourceGroupCriticEvidenceRef(critic)]);
const blockers = unique([...(Array.isArray(item.blockers) ? item.blockers : []), blocker]);
if (critic.verdict === "mixed") {
if ((item.decision !== "use" && item.decision !== "support") || selfHasStrictFit) {
return {
...item,
blockers,
evidenceRefs
};
}
return {
...item,
blockers,
confidence: Math.min(item.confidence, 0.58),
decision: "risky",
evidenceRefs,
reason: `${critic.reason} Backend guard понизил related без строгого product-fit в risky: mixed-группа требует ручной проверки.`
};
}
if (selfHasStrictFit) {
return {
...item,
blockers,
confidence: Math.min(item.confidence, 0.58),
decision: "risky",
evidenceRefs,
reason: `${critic.reason} Фраза сама держит product-fit, поэтому не удаляется, но не может попасть в approved lanes без ручной проверки source-группы.`
};
}
return {
...item,
blockers,
confidence: Math.max(item.confidence, 0.9),
decision: "trash",
evidenceRefs,
reason: `${critic.reason} Backend guard отбросил фразу до keyword map: загрязненная source-группа не должна протаскивать омонимический related-спрос.`
};
});
}
function enforceKeywordCleaningNoiseGuard(items: KeywordCleaningItem[]): KeywordCleaningItem[] { function enforceKeywordCleaningNoiseGuard(items: KeywordCleaningItem[]): KeywordCleaningItem[] {
return items.map((item) => { return items.map((item) => {
if (isOffContextRelatedNoisePhrase(item.normalizedPhrase)) { if (isOffContextRelatedNoisePhrase(item.normalizedPhrase)) {
@ -756,7 +1032,10 @@ function enforceKeywordCleaningBackendGuards(
items: KeywordCleaningItem[] items: KeywordCleaningItem[]
): KeywordCleaningItem[] { ): KeywordCleaningItem[] {
return enforceKeywordCleaningNoiseGuard( return enforceKeywordCleaningNoiseGuard(
promoteExactDemandItems(market, rescueExactTrashItems(market, enforceKeywordCleaningSemanticGuard(market, items))) enforceWordstatSourceGroupCritic(
market,
promoteExactDemandItems(market, rescueExactTrashItems(market, enforceKeywordCleaningSemanticGuard(market, items)))
)
); );
} }
@ -1116,6 +1395,15 @@ function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): Key
.sort((left, right) => right.score - left.score || left.index - right.index) .sort((left, right) => right.score - left.score || left.index - right.index)
.slice(0, KEYWORD_CLEANING_MODEL_TOP_RESULT_LIMIT) .slice(0, KEYWORD_CLEANING_MODEL_TOP_RESULT_LIMIT)
.map((item) => item.result); .map((item) => item.result);
const modelSourceGroupCritics = [...buildWordstatSourceGroupCritics(contract, getBaseItems(contract)).values()]
.filter((critic) => critic.verdict !== "clean")
.sort(
(left, right) =>
(right.verdict === "source_drift" ? 1 : 0) - (left.verdict === "source_drift" ? 1 : 0) ||
right.driftSignalCount - left.driftSignalCount ||
right.resultCount - left.resultCount
)
.slice(0, SOURCE_GROUP_CRITIC_MODEL_LIMIT);
return { return {
approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, KEYWORD_CLEANING_MODEL_APPROVED_ANCHOR_LIMIT).map((anchor) => ({ approvedAnchors: contract.anchorReview.wordstatQueue.slice(0, KEYWORD_CLEANING_MODEL_APPROVED_ANCHOR_LIMIT).map((anchor) => ({
@ -1170,6 +1458,17 @@ function buildKeywordCleaningModelInput(contract: MarketEnrichmentContract): Key
sourceClusterId: result.sourceClusterId, sourceClusterId: result.sourceClusterId,
sourceClusterTitle: truncateTaskText(result.sourceClusterTitle), sourceClusterTitle: truncateTaskText(result.sourceClusterTitle),
sourcePhrase: result.sourcePhrase sourcePhrase: result.sourcePhrase
})),
sourceGroupCritics: modelSourceGroupCritics.map((critic) => ({
driftExamples: critic.driftExamples.map((phrase) => truncateTaskText(phrase)),
driftSignalCount: critic.driftSignalCount,
exactCount: critic.exactCount,
reason: truncateTaskText(critic.reason, 220),
resultCount: critic.resultCount,
sourcePhrase: truncateTaskText(critic.sourcePhrase),
strictFitExamples: critic.strictFitExamples.map((phrase) => truncateTaskText(phrase)),
strictFitSignalCount: critic.strictFitSignalCount,
verdict: critic.verdict
})) }))
}; };
} }
@ -1439,6 +1738,8 @@ function buildKeywordCleaningModelTask(
broadFrequencyDoesNotOverrideSemanticFit: true, broadFrequencyDoesNotOverrideSemanticFit: true,
demoteRelatedWhenDominantFacetMissing: false, demoteRelatedWhenDominantFacetMissing: false,
preserveApprovedAnchorFacets: true, preserveApprovedAnchorFacets: true,
quarantineNoisyRelatedGroups: true,
sourceGroupDriftDoesNotEnterKeywordMap: true,
riskyWhenNoPageBinding: false, riskyWhenNoPageBinding: false,
trashWhenOffContext: true, trashWhenOffContext: true,
useRequiresExactEvidence: true useRequiresExactEvidence: true
@ -1494,6 +1795,7 @@ function buildKeywordCleaningModelTask(
"Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.", "Отбрасывать бытовой AI/нейросетевой шум: онлайн, бесплатно, тексты, песни, фото, видео, чат, порно и похожие consumer-запросы не являются B2B SEO-кандидатами без source evidence.",
"Образовательный спрос (курсы, обучение, тренинги, сертификаты) не отправлять в product keyword map, если Product Understanding/normalization не подтверждает образовательный offer сайта.", "Образовательный спрос (курсы, обучение, тренинги, сертификаты) не отправлять в product keyword map, если Product Understanding/normalization не подтверждает образовательный offer сайта.",
"Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря; глобальный доминантный термин проекта не должен запрещать отдельные подтверждённые ветки спроса.", "Не повышать broad/head phrase до use/support, если Wordstat related потерял protected-смысл исходного якоря; глобальный доминантный термин проекта не должен запрещать отдельные подтверждённые ветки спроса.",
"Проверять sourceGroupCritics после Wordstat: source_drift-группы не отправлять в keyword map, mixed-группы повышать только при строгом product/source fit у конкретной фразы.",
"Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.", "Не менять бизнес-вектор сайта; соседние рынки держать как article/backlog proposal.",
"Не сохранять rewrite/apply decisions." "Не сохранять rewrite/apply decisions."
], ],

View File

@ -33,23 +33,43 @@ export type KeywordContextSnapshotSummary = {
roleOverrideCount: number; roleOverrideCount: number;
}; };
export type KeywordContextSnapshotOutput = {
schemaVersion?: string;
projectId?: string;
createdAt?: string;
label?: string;
note?: string | null;
source?: "manual_save" | "expansion_checkpoint";
semanticRunId?: string | null;
semanticAnalysis?: unknown | null;
anchorReview?: unknown | null;
marketEnrichment?: unknown | null;
keywordCleaning?: unknown | null;
keywordMap?: {
generatedAt?: string | null;
items?: unknown[];
[key: string]: unknown;
} | null;
curation?: {
roleOverrides?: unknown[];
savedAt?: string;
suppressedItemIds?: unknown[];
};
replayContract?: unknown;
};
export type KeywordContextSnapshotDetail = {
summary: KeywordContextSnapshotSummary;
snapshot: KeywordContextSnapshotOutput;
};
type KeywordContextSnapshotRow = { type KeywordContextSnapshotRow = {
id: string; id: string;
input: { input: {
label?: string; label?: string;
source?: "manual_save" | "expansion_checkpoint"; source?: "manual_save" | "expansion_checkpoint";
}; };
output: { output: KeywordContextSnapshotOutput | null;
semanticRunId?: string | null;
keywordMap?: {
generatedAt?: string | null;
items?: unknown[];
};
curation?: {
roleOverrides?: unknown[];
suppressedItemIds?: unknown[];
};
} | null;
created_at: Date; created_at: Date;
}; };
@ -108,6 +128,34 @@ export async function listKeywordContextSnapshots(projectId: string): Promise<Ke
return result.rows.map(mapSnapshotRow); return result.rows.map(mapSnapshotRow);
} }
export async function getKeywordContextSnapshot(
projectId: string,
snapshotId: string
): Promise<KeywordContextSnapshotDetail> {
const result = await pool.query<KeywordContextSnapshotRow>(
`
select id, input, output, created_at
from runs
where project_id = $1
and id = $2
and run_type = 'keyword_context_snapshot'
and status = 'done'
limit 1;
`,
[projectId, snapshotId]
);
const row = result.rows[0];
if (!row || !row.output) {
throw new Error("Snapshot-профиль не найден.");
}
return {
snapshot: row.output,
summary: mapSnapshotRow(row)
};
}
export async function saveKeywordContextSnapshot( export async function saveKeywordContextSnapshot(
projectId: string, projectId: string,
input: KeywordContextSnapshotInput input: KeywordContextSnapshotInput

View File

@ -100,6 +100,7 @@ import {
} from "../keywords/keywordAnalysisWorkflow.js"; } from "../keywords/keywordAnalysisWorkflow.js";
import { import {
deleteKeywordContextSnapshot, deleteKeywordContextSnapshot,
getKeywordContextSnapshot,
listKeywordContextSnapshots, listKeywordContextSnapshots,
renameKeywordContextSnapshot, renameKeywordContextSnapshot,
saveKeywordContextSnapshot, saveKeywordContextSnapshot,
@ -1104,6 +1105,23 @@ projectsRouter.get("/:projectId/keyword-context-snapshots", async (request, resp
} }
}); });
projectsRouter.get("/:projectId/keyword-context-snapshots/:snapshotId", async (request, response) => {
try {
const projectId = projectIdSchema.parse(request.params.projectId);
const snapshotId = z.string().uuid("Некорректный id snapshot-профиля.").parse(request.params.snapshotId);
response.json(await getKeywordContextSnapshot(projectId, snapshotId));
} catch (error) {
if (error instanceof z.ZodError) {
sendError(response, 400, error.issues[0]?.message ?? "Некорректный id snapshot-профиля.");
return;
}
console.error(error);
sendError(response, 500, error instanceof Error ? error.message : "Не удалось загрузить snapshot-профиль ключей.");
}
});
projectsRouter.post("/:projectId/keyword-context-snapshots", async (request, response) => { projectsRouter.post("/:projectId/keyword-context-snapshots", async (request, response) => {
try { try {
const projectId = projectIdSchema.parse(request.params.projectId); const projectId = projectIdSchema.parse(request.params.projectId);

View File

@ -160,6 +160,15 @@ assert.ok(
keywordCleaningSource.includes("getKeywordCleaningModelTopResultScore"), keywordCleaningSource.includes("getKeywordCleaningModelTopResultScore"),
"Keyword cleaning model task must keep a slim ranked payload so AI Workspace does not fail with request entity too large." "Keyword cleaning model task must keep a slim ranked payload so AI Workspace does not fail with request entity too large."
); );
assert.ok(
keywordCleaningSource.includes("buildWordstatSourceGroupCritics") &&
keywordCleaningSource.includes("enforceWordstatSourceGroupCritic") &&
keywordCleaningSource.includes("source_group_related_drift") &&
keywordCleaningSource.includes("sourceGroupCritics") &&
keywordCleaningSource.includes("quarantineNoisyRelatedGroups") &&
keywordCleaningSource.includes("sourceGroupDriftDoesNotEnterKeywordMap"),
"Keyword cleaning must run a source-group/related-fit critic after Wordstat so homonym related batches are quarantined before keyword map."
);
assert.ok( assert.ok(
modelProviderRegistrySource.includes('"seo.market_frontier_discovery"'), modelProviderRegistrySource.includes('"seo.market_frontier_discovery"'),
"Model provider registry must expose seo.market_frontier_discovery as a runnable task type." "Model provider registry must expose seo.market_frontier_discovery as a runnable task type."