diff --git a/graph/memory-scope.js b/graph/memory-scope.js index 7731b67..bd4cc0d 100644 --- a/graph/memory-scope.js +++ b/graph/memory-scope.js @@ -45,6 +45,24 @@ function normalizeKey(value) { return normalizeString(value).toLowerCase(); } +const SCOPE_REGION_TEXT_KEYS = ["name", "title", "label", "value", "text"]; + +function isPlainScopeObject(scope = null) { + if (!scope || typeof scope !== "object" || Array.isArray(scope)) { + return false; + } + const prototype = Object.getPrototypeOf(scope); + return prototype === Object.prototype || prototype === null; +} + +function hasScopeAccessor(scope = {}, key = "") { + const descriptor = Object.getOwnPropertyDescriptor(scope, key); + return Boolean( + descriptor && + (typeof descriptor.get === "function" || typeof descriptor.set === "function"), + ); +} + function normalizeStringArray(values = []) { const result = []; const seen = new Set(); @@ -58,6 +76,101 @@ function normalizeStringArray(values = []) { return result; } +function splitScopeRegionText(value = "", { allowSlash = true } = {}) { + const normalized = normalizeString(value) + .replace(/[>>→]+/g, "/") + .replace(/\r/g, "\n"); + if (!normalized) { + return []; + } + const separatorPattern = allowSlash + ? /[,\n,/\\、;;|]+/ + : /[,\n,、;;|]+/; + return normalized + .split(separatorPattern) + .map((entry) => normalizeString(entry)) + .filter(Boolean); +} + +function extractScopeRegionText(value = null) { + if (value == null) { + return ""; + } + if (typeof value === "string" || typeof value === "number") { + return normalizeString(value); + } + if (typeof value === "boolean" || typeof value === "symbol") { + return ""; + } + if (Array.isArray(value)) { + return ""; + } + if (typeof value === "object") { + for (const key of SCOPE_REGION_TEXT_KEYS) { + let candidate = ""; + try { + candidate = value?.[key]; + } catch { + candidate = ""; + } + if (typeof candidate === "string" || typeof candidate === "number") { + return normalizeString(candidate); + } + } + return ""; + } + return normalizeString(value); +} + +function normalizeScopeRegionList(values = [], { allowSlash = true } = {}) { + const result = []; + const seen = new Set(); + const pushValue = (value) => { + const normalized = normalizeString(value); + const key = normalizeKey(normalized); + if (!normalized || seen.has(key)) { + return; + } + seen.add(key); + result.push(normalized); + }; + const visit = (value) => { + if (Array.isArray(value)) { + for (const entry of value) { + visit(entry); + } + return; + } + const text = extractScopeRegionText(value); + if (!text) { + return; + } + const parts = splitScopeRegionText(text, { allowSlash }); + if (parts.length === 0) { + pushValue(text); + return; + } + for (const part of parts) { + pushValue(part); + } + }; + visit(values); + return result; +} + +function appendUniqueTokenToPath(values = [], token = "") { + const normalizedToken = normalizeString(token); + if (!normalizedToken) { + return normalizeScopeRegionList(values, { allowSlash: true }); + } + const tokenKey = normalizeKey(normalizedToken); + const filtered = normalizeScopeRegionList(values, { allowSlash: true }); + if (filtered.some((value) => normalizeKey(value) === tokenKey)) { + return filtered; + } + return [...filtered, normalizedToken]; +} + function isAlreadyNormalizedStringArray(values = []) { if (!Array.isArray(values)) return false; const seen = new Set(); @@ -75,13 +188,24 @@ function isAlreadyNormalizedStringArray(values = []) { function canReuseNormalizedMemoryScope(scope = {}, defaults = {}) { if ( - !scope || - typeof scope !== "object" || - Array.isArray(scope) || + !isPlainScopeObject(scope) || (defaults && typeof defaults === "object" && Object.keys(defaults).length > 0) ) { return false; } + if ( + [ + "layer", + "ownerType", + "ownerId", + "ownerName", + "regionPrimary", + "regionPath", + "regionSecondary", + ].some((key) => hasScopeAccessor(scope, key)) + ) { + return false; + } const layer = normalizeLayer(scope.layer); const ownerType = normalizeOwnerType(layer, normalizeString(scope.ownerType)); const ownerId = ownerType @@ -144,9 +268,37 @@ export function normalizeMemoryScope(scope = {}, defaults = {}) { ? normalizeString(merged.ownerId || merged.ownerName) : ""; const ownerName = ownerType ? normalizeString(merged.ownerName) : ""; - const regionPrimary = normalizeString(merged.regionPrimary); - const regionPath = normalizeStringArray(merged.regionPath); - const regionSecondary = normalizeStringArray(merged.regionSecondary); + const regionPrimaryTokens = normalizeScopeRegionList(merged.regionPrimary, { + allowSlash: true, + }); + let regionPath = normalizeScopeRegionList(merged.regionPath, { + allowSlash: true, + }); + let regionSecondary = normalizeScopeRegionList(merged.regionSecondary, { + allowSlash: true, + }); + if (regionPath.length === 0 && regionPrimaryTokens.length > 1) { + regionPath = [...regionPrimaryTokens]; + } + let regionPrimary = regionPrimaryTokens[regionPrimaryTokens.length - 1] || ""; + if (!regionPrimary && regionPath.length > 0) { + regionPrimary = regionPath[regionPath.length - 1] || ""; + } + if (regionPrimary && regionPath.length > 0) { + regionPath = appendUniqueTokenToPath(regionPath, regionPrimary); + } + if (regionPrimary) { + const regionPrimaryKey = normalizeKey(regionPrimary); + regionSecondary = regionSecondary.filter( + (value) => normalizeKey(value) !== regionPrimaryKey, + ); + } + if (regionPath.length > 0) { + const regionPathKeys = new Set(regionPath.map((value) => normalizeKey(value))); + regionSecondary = regionSecondary.filter( + (value) => !regionPathKeys.has(normalizeKey(value)), + ); + } return { layer, @@ -192,10 +344,12 @@ export function getScopeOwnerKey(scope) { export function getScopeRegionTokens(scope) { const normalized = normalizeMemoryScope(scope); + const regionPath = normalizeStringArray(normalized.regionPath); + const regionSecondary = normalizeStringArray(normalized.regionSecondary); return normalizeStringArray([ normalized.regionPrimary, - ...normalized.regionPath, - ...normalized.regionSecondary, + ...regionPath, + ...regionSecondary, ]); } @@ -219,6 +373,18 @@ export function getScopeSummary(scope) { }; } +export function hasMeaningfulMemoryScope(scope) { + const normalized = normalizeMemoryScope(scope); + return ( + normalized.layer === MEMORY_SCOPE_LAYER.POV || + Boolean(normalized.ownerType || normalized.ownerId || normalized.ownerName) || + Boolean(normalized.regionPrimary) || + (Array.isArray(normalized.regionPath) && normalized.regionPath.length > 0) || + (Array.isArray(normalized.regionSecondary) && + normalized.regionSecondary.length > 0) + ); +} + export function matchesScopeOwner(scope, ownerType, ownerValue = "") { const normalized = normalizeMemoryScope(scope); if (normalizeString(normalized.ownerType) !== normalizeString(ownerType)) { @@ -419,15 +585,17 @@ export function buildScopeBadgeText(scope) { export function buildRegionLine(scope) { const normalized = normalizeMemoryScope(scope); + const regionPath = normalizeStringArray(normalized.regionPath); + const regionSecondary = normalizeStringArray(normalized.regionSecondary); const parts = []; if (normalized.regionPrimary) { parts.push(`主地区: ${normalized.regionPrimary}`); } - if (normalized.regionPath.length > 0) { - parts.push(`地区路径: ${normalized.regionPath.join(" / ")}`); + if (regionPath.length > 0) { + parts.push(`地区路径: ${regionPath.join(" / ")}`); } - if (normalized.regionSecondary.length > 0) { - parts.push(`次级地区: ${normalized.regionSecondary.join(", ")}`); + if (regionSecondary.length > 0) { + parts.push(`次级地区: ${regionSecondary.join(", ")}`); } return parts.join(" | "); } diff --git a/index.js b/index.js index 9017234..5665790 100644 --- a/index.js +++ b/index.js @@ -262,6 +262,7 @@ import { createMaintenanceJournalEntry, detectHistoryMutation, findJournalRecoveryPoint, + hasGraphPersistDirtyState, markHistoryDirty, normalizeGraphRuntimeState, pruneGraphPersistDirtyState, @@ -9470,8 +9471,35 @@ function applyIndexedDbSnapshotToRuntime( persistencePatch.indexedDbRevision = revision; } updateGraphPersistenceState(persistencePatch); + const shouldPersistPostLoadRepairs = hasGraphPersistDirtyState(currentGraph); rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId); + if (shouldPersistPostLoadRepairs) { + const repairedNodeCount = Number(hydrateDiagnostics?.scopeRepairNodeCount) || 0; + const repairedEdgeCount = Number(hydrateDiagnostics?.scopeRepairEdgeCount) || 0; + void Promise.resolve().then(() => { + if (currentGraph !== graphFromSnapshot) { + return; + } + if ( + normalizeChatIdCandidate(currentGraph?.historyState?.chatId) !== normalizedChatId + ) { + return; + } + debugDebug("[ST-BME] 已检测到加载后作用域自愈,后台写回修复结果", { + chatId: normalizedChatId, + repairedNodeCount, + repairedEdgeCount, + source, + }); + saveGraphToChat({ + reason: "scope-auto-repair-after-load", + markMutation: false, + immediate: false, + }); + }); + } + removeGraphShadowSnapshot(normalizedChatId); refreshPanelLiveState(); schedulePersistedRecallMessageUiRefresh(30); diff --git a/runtime/runtime-state.js b/runtime/runtime-state.js index 4b33ea5..eaf014e 100644 --- a/runtime/runtime-state.js +++ b/runtime/runtime-state.js @@ -527,6 +527,26 @@ export function normalizeGraphRuntimeState(graph, chatId = "", options = {}) { } const skipRecordFieldNormalization = options?.skipRecordFieldNormalization === true; + const recordNormalizationContext = + options?.recordNormalizationContext && + typeof options.recordNormalizationContext === "object" && + !Array.isArray(options.recordNormalizationContext) + ? options.recordNormalizationContext + : null; + const normalizedNodeIds = new Set( + Array.isArray(recordNormalizationContext?.normalizedNodeIds) + ? recordNormalizationContext.normalizedNodeIds + .map((value) => String(value || "").trim()) + .filter(Boolean) + : [], + ); + const normalizedEdgeIds = new Set( + Array.isArray(recordNormalizationContext?.normalizedEdgeIds) + ? recordNormalizationContext.normalizedEdgeIds + .map((value) => String(value || "").trim()) + .filter(Boolean) + : [], + ); const hadSummaryState = graph.summaryState && typeof graph.summaryState === "object" && @@ -775,10 +795,32 @@ export function normalizeGraphRuntimeState(graph, chatId = "", options = {}) { graph.historyState = historyState; graph.vectorIndexState = vectorIndexState; if (!skipRecordFieldNormalization && Array.isArray(graph.nodes)) { - graph.nodes.forEach((node) => normalizeNodeMemoryScope(node)); + graph.nodes.forEach((node) => { + const previousScope = node?.scope; + const nextScope = normalizeNodeMemoryScope(node); + if (previousScope !== nextScope) { + const nodeId = String(node?.id || "").trim(); + if (nodeId) { + normalizedNodeIds.add(nodeId); + } + } + }); } if (!skipRecordFieldNormalization && Array.isArray(graph.edges)) { - graph.edges.forEach((edge) => normalizeEdgeMemoryScope(edge)); + graph.edges.forEach((edge) => { + const previousScope = edge?.scope; + const nextScope = normalizeEdgeMemoryScope(edge); + if (previousScope !== nextScope) { + const edgeId = String(edge?.id || "").trim(); + if (edgeId) { + normalizedEdgeIds.add(edgeId); + } + } + }); + } + if (recordNormalizationContext) { + recordNormalizationContext.normalizedNodeIds = [...normalizedNodeIds]; + recordNormalizationContext.normalizedEdgeIds = [...normalizedEdgeIds]; } graph.batchJournal = Array.isArray(graph.batchJournal) ? graph.batchJournal.slice(-BATCH_JOURNAL_LIMIT) diff --git a/sync/bme-db.js b/sync/bme-db.js index ba72860..bd154c7 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -1,5 +1,8 @@ import { createEmptyGraph, deserializeGraph } from "../graph/graph.js"; -import { normalizeMemoryScope } from "../graph/memory-scope.js"; +import { + hasMeaningfulMemoryScope, + normalizeMemoryScope, +} from "../graph/memory-scope.js"; import { normalizeStoryTime, normalizeStoryTimeSpan, @@ -8,6 +11,9 @@ import { buildVectorCollectionId, cloneGraphPersistDirtyState, getGraphPersistDirtyStateSnapshot, + markGraphPersistEdgeUpsert, + markGraphPersistNodeUpsert, + markGraphPersistRuntimeMetaDirty, normalizeGraphRuntimeState, } from "../runtime/runtime-state.js"; @@ -246,10 +252,15 @@ function cloneHydrateSnapshotMemoryScope(scope = null) { } return { ...scope, - regionPath: Array.isArray(scope.regionPath) ? [...scope.regionPath] : [], + regionPath: Array.isArray(scope.regionPath) + ? cloneHydrateSnapshotNestedValue(scope.regionPath, []) + : cloneHydrateSnapshotNestedValue(scope.regionPath, scope.regionPath), regionSecondary: Array.isArray(scope.regionSecondary) - ? [...scope.regionSecondary] - : [], + ? cloneHydrateSnapshotNestedValue(scope.regionSecondary, []) + : cloneHydrateSnapshotNestedValue( + scope.regionSecondary, + scope.regionSecondary, + ), }; } @@ -3181,13 +3192,93 @@ export function buildGraphFromSnapshot(snapshot, options = {}) { hydrateDiagnostics.stateMs = readPersistDeltaNow() - hydrateStateStartedAt; } + const recordNormalizationContext = {}; const normalizeStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0; const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId, { skipRecordFieldNormalization: snapshotRecordsNormalized, + recordNormalizationContext, }); if (hydrateDiagnostics) { hydrateDiagnostics.normalizeMs = readPersistDeltaNow() - normalizeStartedAt; } + const normalizedNodeIds = Array.isArray( + recordNormalizationContext.normalizedNodeIds, + ) + ? recordNormalizationContext.normalizedNodeIds + .map((value) => normalizeRecordId(value)) + .filter(Boolean) + : []; + const normalizedEdgeIds = Array.isArray( + recordNormalizationContext.normalizedEdgeIds, + ) + ? recordNormalizationContext.normalizedEdgeIds + .map((value) => normalizeRecordId(value)) + .filter(Boolean) + : []; + if (normalizedNodeIds.length > 0 || normalizedEdgeIds.length > 0) { + const nodeById = new Map( + toArray(normalizedGraph.nodes) + .map((node) => [normalizeRecordId(node?.id), node]) + .filter(([id]) => Boolean(id)), + ); + const vectorReplayRequiredNodeIds = new Set( + toArray(normalizedGraph.vectorIndexState?.replayRequiredNodeIds) + .map((value) => normalizeRecordId(value)) + .filter(Boolean), + ); + let repairFloor = Number.isFinite( + Number(normalizedGraph.vectorIndexState?.pendingRepairFromFloor), + ) + ? Number(normalizedGraph.vectorIndexState.pendingRepairFromFloor) + : null; + for (const nodeId of normalizedNodeIds) { + const node = nodeById.get(nodeId) || null; + if (!node) { + continue; + } + markGraphPersistNodeUpsert(normalizedGraph, node, "scope-auto-repair", "snapshot.hydrate"); + if (hasMeaningfulMemoryScope(node.scope)) { + vectorReplayRequiredNodeIds.add(nodeId); + const sourceFloor = Number(node?.sourceFloor ?? node?.seq); + if (Number.isFinite(sourceFloor)) { + repairFloor = + repairFloor == null + ? Math.max(0, Math.floor(sourceFloor)) + : Math.min(repairFloor, Math.max(0, Math.floor(sourceFloor))); + } + } + } + for (const edgeId of normalizedEdgeIds) { + const edge = toArray(normalizedGraph.edges).find( + (entry) => normalizeRecordId(entry?.id) === edgeId, + ); + if (!edge) { + continue; + } + markGraphPersistEdgeUpsert(normalizedGraph, edge, "scope-auto-repair", "snapshot.hydrate"); + } + markGraphPersistRuntimeMetaDirty( + normalizedGraph, + "scope-auto-repair-runtime-meta", + "snapshot.hydrate", + ); + if (vectorReplayRequiredNodeIds.size > 0) { + normalizedGraph.vectorIndexState.replayRequiredNodeIds = [ + ...vectorReplayRequiredNodeIds, + ]; + normalizedGraph.vectorIndexState.dirty = true; + normalizedGraph.vectorIndexState.dirtyReason = + normalizedGraph.vectorIndexState.dirtyReason || "scope-auto-repair"; + normalizedGraph.vectorIndexState.lastWarning = + normalizedGraph.vectorIndexState.lastWarning || + "已自动修复旧作用域结构,相关向量会按需重放"; + normalizedGraph.vectorIndexState.pendingRepairFromFloor = repairFloor; + } + } + if (hydrateDiagnostics) { + hydrateDiagnostics.scopeRepairNodeCount = normalizedNodeIds.length; + hydrateDiagnostics.scopeRepairEdgeCount = normalizedEdgeIds.length; + } if ( normalizedGraph.knowledgeState && typeof normalizedGraph.knowledgeState === "object" && diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 3b03298..db52940 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -92,6 +92,7 @@ import { } from "../retrieval/recall-persistence.js"; import { getNodeDisplayName } from "../graph/node-labels.js"; import { + hasGraphPersistDirtyState, normalizeGraphRuntimeState, pruneGraphPersistDirtyState, } from "../runtime/runtime-state.js"; @@ -1041,6 +1042,7 @@ async function createGraphPersistenceHarness({ buildSnapshotFromGraph, evaluateNativeHydrateGate, evaluatePersistNativeDeltaGate, + hasGraphPersistDirtyState, pruneGraphPersistDirtyState, buildBmeDbName, BME_GRAPH_LOCAL_STORAGE_MODE_AUTO: "auto", diff --git a/tests/indexeddb-persistence.mjs b/tests/indexeddb-persistence.mjs index 71a82b7..67d361a 100644 --- a/tests/indexeddb-persistence.mjs +++ b/tests/indexeddb-persistence.mjs @@ -15,6 +15,7 @@ import { } from "../sync/bme-db.js"; import { BmeChatManager } from "../sync/bme-chat-manager.js"; import { createEmptyGraph } from "../graph/graph.js"; +import { getGraphPersistDirtyStateSnapshot } from "../runtime/runtime-state.js"; const PREFIX = "[ST-BME][indexeddb-persistence]"; @@ -744,6 +745,29 @@ async function testGraphSnapshotConverters() { const rebuiltMalformedButFlagged = buildGraphFromSnapshot(malformedButFlaggedSnapshot, { chatId: "chat-a", }); + const scopeRepairSnapshot = { + ...snapshot, + meta: { + ...snapshot.meta, + }, + nodes: [ + { + ...snapshot.nodes[0], + scope: { + layer: "objective", + regionPrimary: "王都/钟楼", + regionSecondary: "旧城区 / 集市 / 钟楼", + }, + }, + ], + }; + delete scopeRepairSnapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]; + const rebuiltScopeRepair = buildGraphFromSnapshot(scopeRepairSnapshot, { + chatId: "chat-a", + }); + const scopeRepairDirtyState = getGraphPersistDirtyStateSnapshot( + rebuiltScopeRepair, + ); assert.equal(rebuilt.historyState.lastProcessedAssistantFloor, 9); assert.equal(rebuilt.historyState.extractionCount, 4); assert.equal(rebuilt.nodes.length, 1); @@ -764,6 +788,23 @@ async function testGraphSnapshotConverters() { assert.equal(rebuiltMalformedButFlagged.nodes[0].scope?.layer, "objective"); assert.equal(rebuiltMalformedButFlagged.nodes[0].storyTime?.tense, "unknown"); assert.equal(rebuiltMalformedButFlagged.nodes[0].storyTimeSpan?.mixed, false); + assert.equal(rebuiltScopeRepair.nodes[0].scope?.regionPrimary, "钟楼"); + assert.deepEqual(rebuiltScopeRepair.nodes[0].scope?.regionPath, ["王都", "钟楼"]); + assert.deepEqual(rebuiltScopeRepair.nodes[0].scope?.regionSecondary, [ + "旧城区", + "集市", + ]); + assert.equal( + scopeRepairDirtyState?.nodeUpsertIds?.includes("node-converter"), + true, + ); + assert.equal(rebuiltScopeRepair.vectorIndexState?.dirty, true); + assert.equal( + rebuiltScopeRepair.vectorIndexState?.replayRequiredNodeIds?.includes( + "node-converter", + ), + true, + ); rebuilt.nodes[0].fields.title = "Mutated Converter Node"; rebuilt.nodes[0].embedding[0] = 99; diff --git a/tests/scoped-memory.mjs b/tests/scoped-memory.mjs index 04fcc0f..4f3857e 100644 --- a/tests/scoped-memory.mjs +++ b/tests/scoped-memory.mjs @@ -8,7 +8,11 @@ import { findLatestNode, serializeGraph, } from "../graph/graph.js"; -import { normalizeMemoryScope } from "../graph/memory-scope.js"; +import { + buildRegionLine, + getScopeRegionTokens, + normalizeMemoryScope, +} from "../graph/memory-scope.js"; import { normalizeStoryTime, normalizeStoryTimeSpan, @@ -73,6 +77,61 @@ assert.equal( "已规范的 scope 对象应直接复用", ); +const malformedSecondaryScope = normalizeMemoryScope({ + layer: "objective", + regionPrimary: "王都/钟楼", + regionSecondary: "旧城区, 集市 / 下水道 / 钟楼", +}); +assert.equal(malformedSecondaryScope.regionPrimary, "钟楼"); +assert.deepEqual(malformedSecondaryScope.regionPath, ["王都", "钟楼"]); +assert.deepEqual(malformedSecondaryScope.regionSecondary, [ + "旧城区", + "集市", + "下水道", +]); +assert.deepEqual(getScopeRegionTokens(malformedSecondaryScope), [ + "钟楼", + "王都", + "旧城区", + "集市", + "下水道", +]); +assert.match(buildRegionLine(malformedSecondaryScope), /次级地区/); + +const accessorBackedScope = {}; +Object.defineProperty(accessorBackedScope, "layer", { + get() { + return "objective"; + }, + enumerable: true, +}); +Object.defineProperty(accessorBackedScope, "regionPrimary", { + get() { + return "钟楼"; + }, + enumerable: true, +}); +Object.defineProperty(accessorBackedScope, "regionPath", { + get() { + return "王都 > 钟楼"; + }, + enumerable: true, +}); +Object.defineProperty(accessorBackedScope, "regionSecondary", { + get() { + return { label: "旧城区 / 集市" }; + }, + enumerable: true, +}); +const normalizedAccessorScope = normalizeMemoryScope(accessorBackedScope); +assert.notEqual( + normalizedAccessorScope, + accessorBackedScope, + "带 accessor 的 scope 不应复用原对象", +); +assert.deepEqual(normalizedAccessorScope.regionPath, ["王都", "钟楼"]); +assert.deepEqual(normalizedAccessorScope.regionSecondary, ["旧城区", "集市"]); + const normalizedStoryTime = { segmentId: "tl-1", label: "第二天清晨", diff --git a/ui/panel.js b/ui/panel.js index 229a154..cddbde8 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -4241,8 +4241,8 @@ function _refreshMemoryBrowser() { const scope = normalizeMemoryScope(node.scope); const regionText = [ scope.regionPrimary, - ...(scope.regionPath || []), - ...(scope.regionSecondary || []), + ...(Array.isArray(scope.regionPath) ? scope.regionPath : []), + ...(Array.isArray(scope.regionSecondary) ? scope.regionSecondary : []), ] .join(" ") .toLowerCase();