Compare commits
No commits in common. "4d82de82c1ffacd3bc7bbb3ee714bc631841fa3f" and "cebc0d9b7d6bf886a7fe7162becee5bedb0f766f" have entirely different histories.
4d82de82c1
...
cebc0d9b7d
|
|
@ -66,7 +66,6 @@ import {
|
||||||
deleteProject,
|
deleteProject,
|
||||||
downloadSemanticAnalysisExport,
|
downloadSemanticAnalysisExport,
|
||||||
fetchExternalServiceSettings,
|
fetchExternalServiceSettings,
|
||||||
fetchKeywordContextSnapshot,
|
|
||||||
fetchKeywordContextSnapshots,
|
fetchKeywordContextSnapshots,
|
||||||
fetchLatestKeywordAnalysisWorkflow,
|
fetchLatestKeywordAnalysisWorkflow,
|
||||||
fetchModelProviderStatus,
|
fetchModelProviderStatus,
|
||||||
|
|
@ -129,7 +128,6 @@ 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,
|
||||||
|
|
@ -333,15 +331,7 @@ 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) {
|
||||||
if (!(error instanceof Error)) {
|
return error instanceof Error ? error.message : fallback;
|
||||||
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) {
|
||||||
|
|
@ -6355,28 +6345,6 @@ 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);
|
||||||
|
|
||||||
|
|
@ -9588,10 +9556,6 @@ 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] =
|
||||||
|
|
@ -9792,45 +9756,6 @@ 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) => {
|
||||||
|
|
@ -10769,6 +10694,7 @@ 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 () => {
|
||||||
|
|
@ -12453,22 +12379,15 @@ export function App() {
|
||||||
setPendingKeywordSuppress(null);
|
setPendingKeywordSuppress(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeywordContextSnapshotCuration(
|
function getKeywordContextSnapshotCuration(projectId: string, keywordMap: KeywordMapContract | null) {
|
||||||
projectId: string,
|
const suppressedItemIds = keywordCurationSuppressedItemIds[projectId] ?? [];
|
||||||
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,
|
||||||
curationState?.roleOverrides ?? keywordCurationRoleOverrides[projectId] ?? {},
|
keywordCurationRoleOverrides[projectId] ?? {},
|
||||||
suppressedItemIdSet
|
suppressedItemIdSet
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
|
|
@ -12523,22 +12442,33 @@ export function App() {
|
||||||
setProjectActionProjectId(project.id);
|
setProjectActionProjectId(project.id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const detailKey = getKeywordContextSnapshotDetailKey(project.id, activeSnapshot.id);
|
const keywordMap = keywordMaps[project.id] ?? null;
|
||||||
const snapshotDetail =
|
const curation = getKeywordContextSnapshotCuration(project.id, keywordMap);
|
||||||
keywordContextSnapshotDetails[detailKey] ?? (await loadKeywordContextSnapshotDetail(project.id, activeSnapshot.id));
|
|
||||||
const keywordMap = snapshotDetail?.snapshot.keywordMap ?? keywordMaps[project.id] ?? null;
|
if (keywordMap && (curation.roleOverrides.length > 0 || curation.suppressedItemIds.length > 0)) {
|
||||||
const snapshotRoleOverrides = getKeywordContextSnapshotOverrideRecord(snapshotDetail);
|
const changedItemIds = Array.from(
|
||||||
const liveRoleOverrides = keywordCurationRoleOverrides[project.id] ?? {};
|
new Set([...curation.suppressedItemIds, ...curation.roleOverrides.map((override) => override.itemId)])
|
||||||
const snapshotSuppressedIds = getKeywordContextSnapshotSuppressedIds(snapshotDetail);
|
);
|
||||||
const liveSuppressedIds = keywordCurationSuppressedItemIds[project.id] ?? [];
|
const curationResult = await approveKeywordMapDecisions(project.id, {
|
||||||
const curation = getKeywordContextSnapshotCuration(project.id, keywordMap, {
|
itemIds: changedItemIds,
|
||||||
roleOverrides: {
|
roleOverrides: curation.roleOverrides,
|
||||||
...snapshotRoleOverrides,
|
suppressedItemIds: curation.suppressedItemIds
|
||||||
...liveRoleOverrides
|
|
||||||
},
|
|
||||||
suppressedItemIds: Array.from(new Set([...snapshotSuppressedIds, ...liveSuppressedIds]))
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setKeywordMaps((currentKeywordMaps) => ({
|
||||||
|
...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,
|
||||||
activeSnapshot.id,
|
activeSnapshot.id,
|
||||||
|
|
@ -12551,25 +12481,6 @@ 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
|
||||||
|
|
@ -12672,13 +12583,6 @@ 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);
|
||||||
|
|
||||||
|
|
@ -14276,89 +14180,16 @@ 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 activeKeywordContextSnapshotList = activeProject ? (keywordContextSnapshots[activeProject.id] ?? []) : [];
|
const activeSemanticAnalysis = activeProject ? (semanticAnalyses[activeProject.id] ?? null) : null;
|
||||||
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 =
|
const activeMarketEnrichment = activeProject ? (marketEnrichments[activeProject.id] ?? null) : null;
|
||||||
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;
|
||||||
|
|
@ -14436,6 +14267,7 @@ 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;
|
||||||
|
|
@ -14797,16 +14629,11 @@ 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 =
|
const activeAnchorReviewRaw = activeMarketEnrichment?.anchorReview ?? null;
|
||||||
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(
|
const activeAnchorReviewReady = Boolean(activeMarketEnrichment?.readiness.anchorReviewReady);
|
||||||
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(
|
||||||
|
|
@ -14815,20 +14642,8 @@ export function App() {
|
||||||
? activeKeywordAnalysisWorkflow.status === "completed"
|
? activeKeywordAnalysisWorkflow.status === "completed"
|
||||||
: activeMarketEnrichment && isMarketAnalysisReady(activeMarketEnrichment))
|
: activeMarketEnrichment && isMarketAnalysisReady(activeMarketEnrichment))
|
||||||
);
|
);
|
||||||
const activeKeywordCurationOverrides = activeProject
|
const activeKeywordCurationOverrides = activeProject ? (keywordCurationRoleOverrides[activeProject.id] ?? {}) : {};
|
||||||
? {
|
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
|
||||||
|
|
@ -14884,7 +14699,11 @@ export function App() {
|
||||||
!isPersistingActiveKeywordMap
|
!isPersistingActiveKeywordMap
|
||||||
);
|
);
|
||||||
const isSubmittingActiveKeywordStrategy = isPersistingActiveKeywordMap && persistingKeywordMapMode === "strategy";
|
const isSubmittingActiveKeywordStrategy = isPersistingActiveKeywordMap && persistingKeywordMapMode === "strategy";
|
||||||
const isActiveKeywordContextSnapshotReplay = activeKeywordContextSnapshotId !== "current";
|
const activeKeywordContextSnapshotList = activeProject ? (keywordContextSnapshots[activeProject.id] ?? []) : [];
|
||||||
|
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) => ({
|
||||||
|
|
@ -14919,24 +14738,15 @@ 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}
|
||||||
|
|
@ -15014,7 +14824,7 @@ export function App() {
|
||||||
{activeKeywordActionStep === "curation" && activeKeywordMap ? (
|
{activeKeywordActionStep === "curation" && activeKeywordMap ? (
|
||||||
<button
|
<button
|
||||||
className="active"
|
className="active"
|
||||||
disabled={!canSubmitActiveKeywordStrategy || isActiveKeywordContextSnapshotReplay}
|
disabled={!canSubmitActiveKeywordStrategy}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void handleApproveKeywordMapDecisions(activeProject, {
|
void handleApproveKeywordMapDecisions(activeProject, {
|
||||||
jumpToStrategy: true,
|
jumpToStrategy: true,
|
||||||
|
|
@ -15027,9 +14837,7 @@ export function App() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
isActiveKeywordContextSnapshotReplay
|
activeKeywordMap.state === "draft"
|
||||||
? "Сначала переключись на текущий контекст: сохранённый snapshot открыт только для просмотра и раскладки."
|
|
||||||
: activeKeywordMap.state === "draft"
|
|
||||||
? "Сформировать стратегию по распределённым ключам"
|
? "Сформировать стратегию по распределённым ключам"
|
||||||
: "Карта ключей заблокирована backend-гейтами"
|
: "Карта ключей заблокирована backend-гейтами"
|
||||||
}
|
}
|
||||||
|
|
@ -15040,16 +14848,7 @@ export function App() {
|
||||||
</button>
|
</button>
|
||||||
) : activeKeywordActionStep === "analysis" && activeAnchorReviewReady ? (
|
) : activeKeywordActionStep === "analysis" && activeAnchorReviewReady ? (
|
||||||
activeKeywordAnalysisReady ? (
|
activeKeywordAnalysisReady ? (
|
||||||
<button
|
<button onClick={() => handleKeywordReconfigure(activeProject.id)} type="button">
|
||||||
disabled={isActiveKeywordContextSnapshotReplay}
|
|
||||||
onClick={() => handleKeywordReconfigure(activeProject.id)}
|
|
||||||
title={
|
|
||||||
isActiveKeywordContextSnapshotReplay
|
|
||||||
? "Сначала переключись на текущий контекст: snapshot не запускает новый анализ."
|
|
||||||
: "Переконфигурировать ключи"
|
|
||||||
}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RefreshCw size={14} />
|
<RefreshCw size={14} />
|
||||||
Переконфигурировать ключи
|
Переконфигурировать ключи
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -15068,15 +14867,12 @@ 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={
|
||||||
isActiveKeywordContextSnapshotReplay
|
activeAnchorReviewDraftCount > 0
|
||||||
? "Сначала переключись на текущий контекст: snapshot не запускает новый рыночный анализ."
|
|
||||||
: activeAnchorReviewDraftCount > 0
|
|
||||||
? "Автосохранить текущую раскладку и отправить семантический базис на рыночный анализ"
|
? "Автосохранить текущую раскладку и отправить семантический базис на рыночный анализ"
|
||||||
: "Отправить семантический базис на рыночный анализ"
|
: "Отправить семантический базис на рыночный анализ"
|
||||||
}
|
}
|
||||||
|
|
@ -15233,11 +15029,6 @@ export function App() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!projectActionProjectId) {
|
|
||||||
setProjectActionError(null);
|
|
||||||
setProjectActionMessage(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setActiveStageIndex(stageIndex);
|
setActiveStageIndex(stageIndex);
|
||||||
setIsStageRailOpen(true);
|
setIsStageRailOpen(true);
|
||||||
setIsWorkPanelOpen(true);
|
setIsWorkPanelOpen(true);
|
||||||
|
|
@ -15269,20 +15060,6 @@ 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) => ({
|
||||||
|
|
@ -15362,7 +15139,7 @@ export function App() {
|
||||||
}));
|
}));
|
||||||
invalidateStrategyQualityReview(project.id);
|
invalidateStrategyQualityReview(project.id);
|
||||||
setProjectActionMessage(
|
setProjectActionMessage(
|
||||||
`${safetySnapshotLabel ? `Safety snapshot сохранён: ${safetySnapshotLabel}. ` : ""}DEV context готов: ${getModelTaskExecutionStatusLabel(contextRun.runStatus)}. Business synthesis: ${getModelTaskExecutionStatusLabel(
|
`DEV context готов: ${getModelTaskExecutionStatusLabel(contextRun.runStatus)}. Business synthesis: ${getModelTaskExecutionStatusLabel(
|
||||||
businessRun.runStatus
|
businessRun.runStatus
|
||||||
)}. Commercial demand: ${getModelTaskExecutionStatusLabel(
|
)}. Commercial demand: ${getModelTaskExecutionStatusLabel(
|
||||||
commercialRun.runStatus
|
commercialRun.runStatus
|
||||||
|
|
@ -16458,28 +16235,16 @@ 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 projectKeywordContextSnapshotId =
|
const semanticAnalysis = semanticAnalyses[project.id] ?? null;
|
||||||
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 =
|
const marketEnrichment = marketEnrichments[project.id] ?? null;
|
||||||
projectKeywordContextSnapshotDetail?.snapshot.marketEnrichment ?? (marketEnrichments[project.id] ?? null);
|
const keywordCleaning = keywordCleanings[project.id] ?? null;
|
||||||
const keywordCleaning =
|
const keywordMap = keywordMaps[project.id] ?? null;
|
||||||
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;
|
||||||
|
|
@ -16506,8 +16271,7 @@ 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 =
|
const rawAnchorReview = marketEnrichment?.anchorReview ?? null;
|
||||||
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 =
|
||||||
|
|
@ -16518,11 +16282,7 @@ 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(
|
const anchorReviewReady = Boolean(marketEnrichment?.readiness.anchorReviewReady);
|
||||||
marketEnrichment?.readiness.anchorReviewReady ||
|
|
||||||
rawAnchorReview?.state === "approved" ||
|
|
||||||
(rawAnchorReview?.readiness.wordstatQueueCount ?? 0) > 0
|
|
||||||
);
|
|
||||||
const keywordAnalysisReady = Boolean(
|
const keywordAnalysisReady = Boolean(
|
||||||
!isKeywordAnalysisRebuilding &&
|
!isKeywordAnalysisRebuilding &&
|
||||||
(keywordAnalysisWorkflow
|
(keywordAnalysisWorkflow
|
||||||
|
|
@ -16539,16 +16299,8 @@ export function App() {
|
||||||
const isCurationStageCollapsed = Boolean(
|
const isCurationStageCollapsed = Boolean(
|
||||||
collapsedKeywordStages[getKeywordStageCollapseKey(project.id, "curation")]
|
collapsedKeywordStages[getKeywordStageCollapseKey(project.id, "curation")]
|
||||||
);
|
);
|
||||||
const keywordCurationOverrides = {
|
const keywordCurationOverrides = keywordCurationRoleOverrides[project.id] ?? {};
|
||||||
...getKeywordContextSnapshotOverrideRecord(projectKeywordContextSnapshotDetail),
|
const keywordCurationSuppressedIds = keywordCurationSuppressedItemIds[project.id] ?? [];
|
||||||
...(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;
|
||||||
|
|
|
||||||
|
|
@ -1676,30 +1676,6 @@ 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;
|
||||||
|
|
@ -3500,16 +3476,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -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) auto auto;
|
grid-template-columns: minmax(12rem, 17rem) 2.42rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.38rem;
|
gap: 0.38rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
@ -15032,17 +15032,6 @@ 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));
|
||||||
|
|
|
||||||
|
|
@ -84,17 +84,6 @@ 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";
|
||||||
|
|
@ -106,8 +95,6 @@ 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;
|
||||||
|
|
@ -218,22 +205,6 @@ 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,
|
||||||
|
|
@ -278,26 +249,6 @@ 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;
|
||||||
|
|
@ -761,233 +712,6 @@ 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)) {
|
||||||
|
|
@ -1032,10 +756,7 @@ function enforceKeywordCleaningBackendGuards(
|
||||||
items: KeywordCleaningItem[]
|
items: KeywordCleaningItem[]
|
||||||
): KeywordCleaningItem[] {
|
): KeywordCleaningItem[] {
|
||||||
return enforceKeywordCleaningNoiseGuard(
|
return enforceKeywordCleaningNoiseGuard(
|
||||||
enforceWordstatSourceGroupCritic(
|
|
||||||
market,
|
|
||||||
promoteExactDemandItems(market, rescueExactTrashItems(market, enforceKeywordCleaningSemanticGuard(market, items)))
|
promoteExactDemandItems(market, rescueExactTrashItems(market, enforceKeywordCleaningSemanticGuard(market, items)))
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1395,15 +1116,6 @@ 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) => ({
|
||||||
|
|
@ -1458,17 +1170,6 @@ 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
|
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1738,8 +1439,6 @@ 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
|
||||||
|
|
@ -1795,7 +1494,6 @@ 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."
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -33,43 +33,23 @@ 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: KeywordContextSnapshotOutput | null;
|
output: {
|
||||||
|
semanticRunId?: string | null;
|
||||||
|
keywordMap?: {
|
||||||
|
generatedAt?: string | null;
|
||||||
|
items?: unknown[];
|
||||||
|
};
|
||||||
|
curation?: {
|
||||||
|
roleOverrides?: unknown[];
|
||||||
|
suppressedItemIds?: unknown[];
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -128,34 +108,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,6 @@ import {
|
||||||
} from "../keywords/keywordAnalysisWorkflow.js";
|
} from "../keywords/keywordAnalysisWorkflow.js";
|
||||||
import {
|
import {
|
||||||
deleteKeywordContextSnapshot,
|
deleteKeywordContextSnapshot,
|
||||||
getKeywordContextSnapshot,
|
|
||||||
listKeywordContextSnapshots,
|
listKeywordContextSnapshots,
|
||||||
renameKeywordContextSnapshot,
|
renameKeywordContextSnapshot,
|
||||||
saveKeywordContextSnapshot,
|
saveKeywordContextSnapshot,
|
||||||
|
|
@ -1105,23 +1104,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -160,15 +160,6 @@ 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."
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue