Restore keyword context snapshot replay
This commit is contained in:
parent
50619193f0
commit
4d82de82c1
|
|
@ -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<Record<string, string[]>>({});
|
||||
const [keywordContextSnapshots, setKeywordContextSnapshots] = useState<Record<string, KeywordContextSnapshotSummary[]>>({});
|
||||
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 [savingKeywordCurationLayoutProjectId, setSavingKeywordCurationLayoutProjectId] = useState<string | null>(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<string, KeywordCurationRole>;
|
||||
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,33 +12523,22 @@ 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
|
||||
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]))
|
||||
});
|
||||
|
||||
setKeywordMaps((currentKeywordMaps) => ({
|
||||
...currentKeywordMaps,
|
||||
[project.id]: curationResult.keywordMap
|
||||
}));
|
||||
setKeywordCurationRoleOverrides((currentOverrides) => ({
|
||||
...currentOverrides,
|
||||
[project.id]: {}
|
||||
}));
|
||||
setKeywordCurationSuppressedItemIds((currentSuppressed) => ({
|
||||
...currentSuppressed,
|
||||
[project.id]: []
|
||||
}));
|
||||
}
|
||||
|
||||
const result = await updateKeywordContextSnapshotCuration(
|
||||
project.id,
|
||||
activeSnapshot.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<string>[] = [
|
||||
{ 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 ? (
|
||||
<span className="keyword-context-profile-status">
|
||||
{isActiveKeywordContextSnapshotLoading ? "загружаю снимок" : "снимок"}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
aria-label="Сохранить как новый профиль контекста ключей"
|
||||
className={isSavingActiveKeywordContextSnapshot ? "saving" : undefined}
|
||||
|
|
@ -14824,7 +15014,7 @@ export function App() {
|
|||
{activeKeywordActionStep === "curation" && activeKeywordMap ? (
|
||||
<button
|
||||
className="active"
|
||||
disabled={!canSubmitActiveKeywordStrategy}
|
||||
disabled={!canSubmitActiveKeywordStrategy || isActiveKeywordContextSnapshotReplay}
|
||||
onClick={() =>
|
||||
void handleApproveKeywordMapDecisions(activeProject, {
|
||||
jumpToStrategy: true,
|
||||
|
|
@ -14837,7 +15027,9 @@ export function App() {
|
|||
})
|
||||
}
|
||||
title={
|
||||
activeKeywordMap.state === "draft"
|
||||
isActiveKeywordContextSnapshotReplay
|
||||
? "Сначала переключись на текущий контекст: сохранённый snapshot открыт только для просмотра и раскладки."
|
||||
: activeKeywordMap.state === "draft"
|
||||
? "Сформировать стратегию по распределённым ключам"
|
||||
: "Карта ключей заблокирована backend-гейтами"
|
||||
}
|
||||
|
|
@ -14848,7 +15040,16 @@ export function App() {
|
|||
</button>
|
||||
) : activeKeywordActionStep === "analysis" && activeAnchorReviewReady ? (
|
||||
activeKeywordAnalysisReady ? (
|
||||
<button onClick={() => handleKeywordReconfigure(activeProject.id)} type="button">
|
||||
<button
|
||||
disabled={isActiveKeywordContextSnapshotReplay}
|
||||
onClick={() => handleKeywordReconfigure(activeProject.id)}
|
||||
title={
|
||||
isActiveKeywordContextSnapshotReplay
|
||||
? "Сначала переключись на текущий контекст: snapshot не запускает новый анализ."
|
||||
: "Переконфигурировать ключи"
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Переконфигурировать ключи
|
||||
</button>
|
||||
|
|
@ -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() {
|
|||
<div className="project-list">
|
||||
{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;
|
||||
|
|
|
|||
|
|
@ -1676,6 +1676,30 @@ export type KeywordContextSnapshotInput = {
|
|||
|
||||
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 = {
|
||||
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<KeywordContextSnapshotDetail>;
|
||||
}
|
||||
|
||||
export async function saveKeywordContextSnapshot(projectId: string, input: KeywordContextSnapshotInput) {
|
||||
const response = await fetch(`${apiBaseUrl}/projects/${projectId}/keyword-context-snapshots`, {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<Ke
|
|||
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(
|
||||
projectId: string,
|
||||
input: KeywordContextSnapshotInput
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue