From 4d82de82c1ffacd3bc7bbb3ee714bc631841fa3f Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sun, 5 Jul 2026 16:03:00 +0300 Subject: [PATCH] Restore keyword context snapshot replay --- seo_mode/seo_mode/app/src/App.tsx | 366 +++++++++++++++--- seo_mode/seo_mode/app/src/api.ts | 34 ++ seo_mode/seo_mode/app/src/styles.css | 13 +- .../src/keywords/keywordContextSnapshots.ts | 70 +++- .../seo_mode/server/src/projects/routes.ts | 18 + 5 files changed, 430 insertions(+), 71 deletions(-) diff --git a/seo_mode/seo_mode/app/src/App.tsx b/seo_mode/seo_mode/app/src/App.tsx index e2a7563..5758031 100644 --- a/seo_mode/seo_mode/app/src/App.tsx +++ b/seo_mode/seo_mode/app/src/App.tsx @@ -66,6 +66,7 @@ import { deleteProject, downloadSemanticAnalysisExport, fetchExternalServiceSettings, + fetchKeywordContextSnapshot, fetchKeywordContextSnapshots, fetchLatestKeywordAnalysisWorkflow, fetchModelProviderStatus, @@ -128,6 +129,7 @@ import { type HealthResponse, type KeywordAnalysisWorkflowContract, type KeywordCleaningContract, + type KeywordContextSnapshotDetail, type KeywordContextSnapshotSummary, type KeywordMapContract, type KeywordMapRole, @@ -331,7 +333,15 @@ const dateFormatter = new Intl.DateTimeFormat("ru-RU", { const numberFormatter = new Intl.NumberFormat("ru-RU"); 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) { @@ -6345,6 +6355,28 @@ function getKeywordSnapshotTimestamp(snapshot: KeywordContextSnapshotSummary) { 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[]) { const keywordMapTimestamp = Date.parse(keywordMap.generatedAt); @@ -9556,6 +9588,10 @@ export function App() { const [keywordCurationSuppressedItemIds, setKeywordCurationSuppressedItemIds] = useState>({}); const [keywordContextSnapshots, setKeywordContextSnapshots] = useState>({}); const [activeKeywordContextSnapshotIds, setActiveKeywordContextSnapshotIds] = useState>({}); + const [keywordContextSnapshotDetails, setKeywordContextSnapshotDetails] = useState< + Record + >({}); + const [loadingKeywordContextSnapshotKey, setLoadingKeywordContextSnapshotKey] = useState(null); const [savingKeywordContextSnapshotProjectId, setSavingKeywordContextSnapshotProjectId] = useState(null); const [savingKeywordCurationLayoutProjectId, setSavingKeywordCurationLayoutProjectId] = useState(null); const [pendingKeywordContextSnapshotDialog, setPendingKeywordContextSnapshotDialog] = @@ -9756,6 +9792,45 @@ export function App() { 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[]) { const strategyEntries = await Promise.all( projectList.map(async (project) => { @@ -10694,7 +10769,6 @@ export function App() { const timeoutId = window.setTimeout(() => { setProjectActionMessage(null); - setProjectActionProjectId(null); }, NOTICE_AUTO_HIDE_MS); return () => { @@ -12379,15 +12453,22 @@ export function App() { setPendingKeywordSuppress(null); } - function getKeywordContextSnapshotCuration(projectId: string, keywordMap: KeywordMapContract | null) { - const suppressedItemIds = keywordCurationSuppressedItemIds[projectId] ?? []; + function getKeywordContextSnapshotCuration( + projectId: string, + keywordMap: KeywordMapContract | null, + curationState?: { + roleOverrides?: Record; + suppressedItemIds?: string[]; + } + ) { + const suppressedItemIds = curationState?.suppressedItemIds ?? keywordCurationSuppressedItemIds[projectId] ?? []; const suppressedItemIdSet = new Set(suppressedItemIds); return { roleOverrides: keywordMap ? getKeywordCurationRoleOverrides( keywordMap, - keywordCurationRoleOverrides[projectId] ?? {}, + curationState?.roleOverrides ?? keywordCurationRoleOverrides[projectId] ?? {}, suppressedItemIdSet ) : [], @@ -12442,32 +12523,21 @@ export function App() { setProjectActionProjectId(project.id); try { - const keywordMap = keywordMaps[project.id] ?? null; - const curation = getKeywordContextSnapshotCuration(project.id, keywordMap); - - if (keywordMap && (curation.roleOverrides.length > 0 || curation.suppressedItemIds.length > 0)) { - const changedItemIds = Array.from( - new Set([...curation.suppressedItemIds, ...curation.roleOverrides.map((override) => override.itemId)]) - ); - const curationResult = await approveKeywordMapDecisions(project.id, { - itemIds: changedItemIds, - roleOverrides: curation.roleOverrides, - suppressedItemIds: curation.suppressedItemIds - }); - - setKeywordMaps((currentKeywordMaps) => ({ - ...currentKeywordMaps, - [project.id]: curationResult.keywordMap - })); - setKeywordCurationRoleOverrides((currentOverrides) => ({ - ...currentOverrides, - [project.id]: {} - })); - setKeywordCurationSuppressedItemIds((currentSuppressed) => ({ - ...currentSuppressed, - [project.id]: [] - })); - } + const detailKey = getKeywordContextSnapshotDetailKey(project.id, activeSnapshot.id); + const snapshotDetail = + keywordContextSnapshotDetails[detailKey] ?? (await loadKeywordContextSnapshotDetail(project.id, activeSnapshot.id)); + const keywordMap = snapshotDetail?.snapshot.keywordMap ?? keywordMaps[project.id] ?? null; + const snapshotRoleOverrides = getKeywordContextSnapshotOverrideRecord(snapshotDetail); + const liveRoleOverrides = keywordCurationRoleOverrides[project.id] ?? {}; + const snapshotSuppressedIds = getKeywordContextSnapshotSuppressedIds(snapshotDetail); + const liveSuppressedIds = keywordCurationSuppressedItemIds[project.id] ?? []; + const curation = getKeywordContextSnapshotCuration(project.id, keywordMap, { + roleOverrides: { + ...snapshotRoleOverrides, + ...liveRoleOverrides + }, + suppressedItemIds: Array.from(new Set([...snapshotSuppressedIds, ...liveSuppressedIds])) + }); const result = await updateKeywordContextSnapshotCuration( project.id, @@ -12481,6 +12551,25 @@ export function App() { 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) => ({ ...currentIds, [project.id]: result.snapshot.id @@ -12583,6 +12672,13 @@ export function App() { setProjectActionMessage(`Контекст переименован: ${result.snapshot.label}.`); } else { await deleteKeywordContextSnapshot(project.id, dialog.snapshotId); + setKeywordContextSnapshotDetails((currentDetails) => + Object.fromEntries( + Object.entries(currentDetails).filter( + ([detailKey]) => detailKey !== getKeywordContextSnapshotDetailKey(project.id, dialog.snapshotId) + ) + ) + ); setKeywordContextSnapshots((currentSnapshots) => { 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 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 activeContextReview = activeSemanticAnalysis && activeContextReviewRaw?.semanticRunId === activeSemanticAnalysis.runId ? activeContextReviewRaw : 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 activeKeywordMap = + activeKeywordContextSnapshotDetail?.snapshot.keywordMap ?? (activeProject ? (keywordMaps[activeProject.id] ?? null) : 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(() => { if (activeStageIndex !== 4 || !activeQuotaProjectId) { return; @@ -14267,7 +14436,6 @@ export function App() { return () => window.clearInterval(intervalId); }, [activeKeywordAnalysisWorkflow?.runId, activeKeywordAnalysisWorkflow?.status, activeQuotaProjectId, activeStageIndex]); - const activeKeywordMap = activeProject ? (keywordMaps[activeProject.id] ?? null) : null; const activeSeoStrategy = activeProject ? (seoStrategies[activeProject.id] ?? null) : null; const activeStrategySynthesis = activeProject ? (strategySyntheses[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-процесс", 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 activeAnchorReviewDraftCount = getAnchorReviewDraftDecisionList(activeAnchorReviewRaw, activeAnchorReviewDraft).length; 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 activeKeywordAnalysisRebuilding = isKeywordAnalysisWorkflowActive(activeKeywordAnalysisWorkflow); const activeKeywordAnalysisReady = Boolean( @@ -14642,8 +14815,20 @@ export function App() { ? activeKeywordAnalysisWorkflow.status === "completed" : activeMarketEnrichment && isMarketAnalysisReady(activeMarketEnrichment)) ); - const activeKeywordCurationOverrides = activeProject ? (keywordCurationRoleOverrides[activeProject.id] ?? {}) : {}; - const activeKeywordCurationSuppressedIds = activeProject ? (keywordCurationSuppressedItemIds[activeProject.id] ?? []) : []; + const activeKeywordCurationOverrides = activeProject + ? { + ...getKeywordContextSnapshotOverrideRecord(activeKeywordContextSnapshotDetail), + ...(keywordCurationRoleOverrides[activeProject.id] ?? {}) + } + : {}; + const activeKeywordCurationSuppressedIds = activeProject + ? Array.from( + new Set([ + ...getKeywordContextSnapshotSuppressedIds(activeKeywordContextSnapshotDetail), + ...(keywordCurationSuppressedItemIds[activeProject.id] ?? []) + ]) + ) + : []; const activeKeywordCurationSuppressedSet = new Set(activeKeywordCurationSuppressedIds); const activeKeywordWorkflowStep = activeProject && activeStageIndex === 4 @@ -14699,11 +14884,7 @@ export function App() { !isPersistingActiveKeywordMap ); const isSubmittingActiveKeywordStrategy = isPersistingActiveKeywordMap && persistingKeywordMapMode === "strategy"; - const activeKeywordContextSnapshotList = activeProject ? (keywordContextSnapshots[activeProject.id] ?? []) : []; - const activeKeywordContextSnapshotId = - activeProject && activeKeywordContextSnapshotList.length > 0 - ? (activeKeywordContextSnapshotIds[activeProject.id] ?? activeKeywordContextSnapshotList[0]?.id ?? "current") - : "current"; + const isActiveKeywordContextSnapshotReplay = activeKeywordContextSnapshotId !== "current"; const activeKeywordContextSnapshotOptions: NdcGlassSelectOption[] = [ { label: "Текущий контекст", value: "current" }, ...activeKeywordContextSnapshotList.map((snapshot) => ({ @@ -14738,15 +14919,24 @@ export function App() { className="keyword-context-profile-select" disabled={isSavingActiveKeywordContextSnapshot} menuClassName="keyword-context-profile-menu" - onChange={(snapshotId) => + onChange={(snapshotId) => { setActiveKeywordContextSnapshotIds((currentIds) => ({ ...currentIds, [activeProject.id]: snapshotId - })) - } + })); + + if (snapshotId !== "current") { + void loadKeywordContextSnapshotDetail(activeProject.id, snapshotId); + } + }} options={activeKeywordContextSnapshotOptions} value={activeKeywordContextSnapshotId} /> + {isActiveKeywordContextSnapshotReplay ? ( + + {isActiveKeywordContextSnapshotLoading ? "загружаю снимок" : "снимок"} + + ) : null} ) : activeKeywordActionStep === "analysis" && activeAnchorReviewReady ? ( activeKeywordAnalysisReady ? ( - @@ -14867,12 +15068,15 @@ export function App() { className="active" disabled={ isApprovingActiveAnchorReview || + isActiveKeywordContextSnapshotReplay || activeAnchorReview.state === "not_ready" || activeAnchorReview.readiness.wordstatQueueCount === 0 } onClick={() => void handleAnchorReviewApproval(activeProject)} title={ - activeAnchorReviewDraftCount > 0 + isActiveKeywordContextSnapshotReplay + ? "Сначала переключись на текущий контекст: snapshot не запускает новый рыночный анализ." + : activeAnchorReviewDraftCount > 0 ? "Автосохранить текущую раскладку и отправить семантический базис на рыночный анализ" : "Отправить семантический базис на рыночный анализ" } @@ -15029,6 +15233,11 @@ export function App() { return; } + if (!projectActionProjectId) { + setProjectActionError(null); + setProjectActionMessage(null); + } + setActiveStageIndex(stageIndex); setIsStageRailOpen(true); setIsWorkPanelOpen(true); @@ -15060,6 +15269,20 @@ export function App() { setAnalyzingProjectId(project.id); 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); setSemanticAnalyses((currentAnalyses) => ({ @@ -15139,7 +15362,7 @@ export function App() { })); invalidateStrategyQualityReview(project.id); setProjectActionMessage( - `DEV context готов: ${getModelTaskExecutionStatusLabel(contextRun.runStatus)}. Business synthesis: ${getModelTaskExecutionStatusLabel( + `${safetySnapshotLabel ? `Safety snapshot сохранён: ${safetySnapshotLabel}. ` : ""}DEV context готов: ${getModelTaskExecutionStatusLabel(contextRun.runStatus)}. Business synthesis: ${getModelTaskExecutionStatusLabel( businessRun.runStatus )}. Commercial demand: ${getModelTaskExecutionStatusLabel( commercialRun.runStatus @@ -16235,16 +16458,28 @@ export function App() {
{visibleProjects.map((project) => { 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 isContextReviewAligned = Boolean( semanticAnalysis && contextReview && contextReview.semanticRunId === semanticAnalysis.runId ); const activeContextReview = isContextReviewAligned ? contextReview : null; const projectOntology = projectOntologies[project.id] ?? null; - const marketEnrichment = marketEnrichments[project.id] ?? null; - const keywordCleaning = keywordCleanings[project.id] ?? null; - const keywordMap = keywordMaps[project.id] ?? null; + const marketEnrichment = + projectKeywordContextSnapshotDetail?.snapshot.marketEnrichment ?? (marketEnrichments[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 yandexEvidence = yandexEvidences[project.id] ?? null; const serpInterpretation = serpInterpretations[project.id] ?? null; @@ -16271,7 +16506,8 @@ export function App() { const keywordAnalysisWorkflow = keywordAnalysisWorkflows[project.id] ?? null; const isKeywordAnalysisRebuilding = isKeywordAnalysisWorkflowActive(keywordAnalysisWorkflow); 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 savedDemandCollectionProfile = rawAnchorReview?.demandCollectionProfile ?? "contextual"; const anchorReviewDemandProfile = @@ -16282,7 +16518,11 @@ export function App() { const anchorReviewDraftChangeCount = anchorReviewDraftCount + (isAnchorReviewDemandProfileDirty ? 1 : 0); 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( !isKeywordAnalysisRebuilding && (keywordAnalysisWorkflow @@ -16299,8 +16539,16 @@ export function App() { const isCurationStageCollapsed = Boolean( collapsedKeywordStages[getKeywordStageCollapseKey(project.id, "curation")] ); - const keywordCurationOverrides = keywordCurationRoleOverrides[project.id] ?? {}; - const keywordCurationSuppressedIds = keywordCurationSuppressedItemIds[project.id] ?? []; + const keywordCurationOverrides = { + ...getKeywordContextSnapshotOverrideRecord(projectKeywordContextSnapshotDetail), + ...(keywordCurationRoleOverrides[project.id] ?? {}) + }; + const keywordCurationSuppressedIds = Array.from( + new Set([ + ...getKeywordContextSnapshotSuppressedIds(projectKeywordContextSnapshotDetail), + ...(keywordCurationSuppressedItemIds[project.id] ?? []) + ]) + ); const keywordCurationSuppressedSet = new Set(keywordCurationSuppressedIds); const keywordCleaningStage5Candidates = keywordCleaning ? getKeywordCleaningStage5Candidates(keywordCleaning) : []; const keywordPlanDecision = keywordMap ? getKeywordPlanDecision(keywordMap) : null; diff --git a/seo_mode/seo_mode/app/src/api.ts b/seo_mode/seo_mode/app/src/api.ts index e6cdda2..459c7a2 100644 --- a/seo_mode/seo_mode/app/src/api.ts +++ b/seo_mode/seo_mode/app/src/api.ts @@ -1676,6 +1676,30 @@ export type KeywordContextSnapshotInput = { export type KeywordContextSnapshotCurationInput = NonNullable; +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; + }; +}; + export type SeoStrategyContract = { schemaVersion: "seo-strategy.v1"; projectId: string; @@ -3476,6 +3500,16 @@ export async function fetchKeywordContextSnapshots(projectId: string) { 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; +} + export async function saveKeywordContextSnapshot(projectId: string, input: KeywordContextSnapshotInput) { const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots`, { method: "POST", diff --git a/seo_mode/seo_mode/app/src/styles.css b/seo_mode/seo_mode/app/src/styles.css index 568c1ee..b324b2f 100644 --- a/seo_mode/seo_mode/app/src/styles.css +++ b/seo_mode/seo_mode/app/src/styles.css @@ -14986,7 +14986,7 @@ body:has(.seo-launcher-shell) { .keyword-context-toolbar { display: inline-grid; - grid-template-columns: minmax(12rem, 17rem) 2.42rem; + grid-template-columns: minmax(12rem, 17rem) auto auto; align-items: center; gap: 0.38rem; min-width: 0; @@ -15032,6 +15032,17 @@ body:has(.seo-launcher-shell) { 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 { min-width: 25rem; max-width: min(31rem, calc(100vw - 1.5rem)); diff --git a/seo_mode/seo_mode/server/src/keywords/keywordContextSnapshots.ts b/seo_mode/seo_mode/server/src/keywords/keywordContextSnapshots.ts index d56e747..58731ba 100644 --- a/seo_mode/seo_mode/server/src/keywords/keywordContextSnapshots.ts +++ b/seo_mode/seo_mode/server/src/keywords/keywordContextSnapshots.ts @@ -33,23 +33,43 @@ export type KeywordContextSnapshotSummary = { 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 = { id: string; input: { label?: string; source?: "manual_save" | "expansion_checkpoint"; }; - output: { - semanticRunId?: string | null; - keywordMap?: { - generatedAt?: string | null; - items?: unknown[]; - }; - curation?: { - roleOverrides?: unknown[]; - suppressedItemIds?: unknown[]; - }; - } | null; + output: KeywordContextSnapshotOutput | null; created_at: Date; }; @@ -108,6 +128,34 @@ export async function listKeywordContextSnapshots(projectId: string): Promise { + const result = await pool.query( + ` + 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( projectId: string, input: KeywordContextSnapshotInput diff --git a/seo_mode/seo_mode/server/src/projects/routes.ts b/seo_mode/seo_mode/server/src/projects/routes.ts index 4a1e9d9..b29774f 100644 --- a/seo_mode/seo_mode/server/src/projects/routes.ts +++ b/seo_mode/seo_mode/server/src/projects/routes.ts @@ -100,6 +100,7 @@ import { } from "../keywords/keywordAnalysisWorkflow.js"; import { deleteKeywordContextSnapshot, + getKeywordContextSnapshot, listKeywordContextSnapshots, renameKeywordContextSnapshot, 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) => { try { const projectId = projectIdSchema.parse(request.params.projectId);