Fix manual maintenance action execution feedback

This commit is contained in:
Youzini-afk
2026-04-05 19:11:15 +08:00
parent 3446a44387
commit 28049d89bc
3 changed files with 479 additions and 42 deletions

View File

@@ -9347,6 +9347,7 @@ async function onManualCompress() {
getEmbeddingConfig,
getSchema,
getSettings,
inspectCompressionCandidates: inspectAutoCompressionCandidates,
recordMaintenanceAction,
recordGraphMutation,
toastr,
@@ -9531,6 +9532,7 @@ async function onManualEvolve() {
recordMaintenanceAction,
recordGraphMutation,
toastr,
validateVectorConfig,
});
}

View File

@@ -61,6 +61,11 @@ import {
setBatchStageOutcome,
shouldRunRecallForTransaction,
} from "../ui-status.js";
import {
onManualCompressController,
onManualEvolveController,
onManualSleepController,
} from "../ui-actions-controller.js";
const waitForTick = () => new Promise((resolve) => setTimeout(resolve, 0));
const extensionsShimSource = [
@@ -5276,6 +5281,255 @@ async function testLlmOutputRegexCleansResponseBeforeJsonParse() {
}
}
async function testManualCompressSkipsWithoutCandidatesAndDoesNotPretendItRan() {
const calls = {
compressAll: 0,
recordGraphMutation: 0,
recordMaintenanceAction: 0,
};
const toastMessages = [];
const graph = { nodes: [], historyState: {} };
const result = await onManualCompressController({
getCurrentGraph: () => graph,
ensureGraphMutationReady: () => true,
getSchema: () => [],
inspectCompressionCandidates: () => ({
hasCandidates: false,
reason: "当前没有可压缩候选组,本次未发起 LLM 压缩",
}),
cloneGraphSnapshot: (value) => JSON.parse(JSON.stringify(value ?? null)),
compressAll: async () => {
calls.compressAll += 1;
return { created: 1, archived: 1 };
},
getEmbeddingConfig: () => ({}),
getSettings: () => ({}),
recordMaintenanceAction() {
calls.recordMaintenanceAction += 1;
},
recordGraphMutation: async () => {
calls.recordGraphMutation += 1;
},
toastr: {
info(message) {
toastMessages.push(["info", message]);
},
success(message) {
toastMessages.push(["success", message]);
},
},
});
assert.equal(calls.compressAll, 0);
assert.equal(calls.recordMaintenanceAction, 0);
assert.equal(calls.recordGraphMutation, 0);
assert.equal(result?.handledToast, true);
assert.equal(result?.requestDispatched, false);
assert.match(String(toastMessages[0]?.[1] || ""), /未发起 LLM 压缩/);
}
async function testManualCompressUsesForcedCompressionAndPersistsRealMutation() {
const calls = {
forceFlag: null,
recordGraphMutation: 0,
recordMaintenanceAction: 0,
};
const graph = { nodes: [], historyState: {} };
const result = await onManualCompressController({
getCurrentGraph: () => graph,
ensureGraphMutationReady: () => true,
getSchema: () => [{ id: "event", compression: { mode: "hierarchical" } }],
inspectCompressionCandidates: () => ({
hasCandidates: true,
reason: "",
}),
cloneGraphSnapshot: (value) => JSON.parse(JSON.stringify(value ?? null)),
compressAll: async (_graph, _schema, _embeddingConfig, force) => {
calls.forceFlag = force;
return { created: 1, archived: 2 };
},
getEmbeddingConfig: () => ({}),
getSettings: () => ({}),
recordMaintenanceAction() {
calls.recordMaintenanceAction += 1;
},
recordGraphMutation: async () => {
calls.recordGraphMutation += 1;
},
buildMaintenanceSummary: () => "手动压缩",
toastr: {
info() {},
success() {},
},
});
assert.equal(calls.forceFlag, true);
assert.equal(calls.recordMaintenanceAction, 1);
assert.equal(calls.recordGraphMutation, 1);
assert.equal(result?.handledToast, true);
assert.equal(result?.requestDispatched, true);
assert.equal(result?.mutated, true);
}
async function testManualEvolveFallsBackToLatestExtractionBatchAfterRefresh() {
const graph = {
nodes: [
{
id: "evt-1",
type: "event",
archived: false,
level: 0,
},
],
historyState: {
extractionCount: 3,
},
batchJournal: [
{
stateBefore: {
extractionCount: 3,
},
createdNodeIds: ["compression-1"],
},
{
stateBefore: {
extractionCount: 2,
},
createdNodeIds: ["evt-1"],
},
],
};
let receivedCandidateIds = null;
let recordGraphMutationCalls = 0;
const toastMessages = [];
const result = await onManualEvolveController({
getCurrentGraph: () => graph,
ensureGraphMutationReady: () => true,
getEmbeddingConfig: () => ({ mode: "direct" }),
validateVectorConfig: () => ({ valid: true }),
getLastExtractedItems: () => [],
cloneGraphSnapshot: (value) => JSON.parse(JSON.stringify(value ?? null)),
getSettings: () => ({
consolidationNeighborCount: 5,
consolidationThreshold: 0.85,
}),
consolidateMemories: async ({ newNodeIds }) => {
receivedCandidateIds = [...newNodeIds];
return {
merged: 0,
skipped: 0,
kept: 1,
evolved: 0,
connections: 0,
updates: 0,
};
},
recordMaintenanceAction() {
throw new Error("keep-only 结果不应写入维护账本");
},
recordGraphMutation: async () => {
recordGraphMutationCalls += 1;
},
toastr: {
info(message) {
toastMessages.push(["info", message]);
},
success(message) {
toastMessages.push(["success", message]);
},
warning(message) {
toastMessages.push(["warning", message]);
},
},
});
assert.deepEqual(receivedCandidateIds, ["evt-1"]);
assert.equal(recordGraphMutationCalls, 0);
assert.equal(result?.handledToast, true);
assert.equal(result?.requestDispatched, true);
assert.equal(result?.mutated, false);
assert.match(String(toastMessages[0]?.[1] || ""), /最近一批提取落盘/);
}
async function testManualEvolveWarnsOnInvalidVectorConfigInsteadOfPretendingComplete() {
let consolidateCalls = 0;
const toastMessages = [];
const result = await onManualEvolveController({
getCurrentGraph: () => ({
nodes: [{ id: "evt-2", type: "event", archived: false, level: 0 }],
historyState: { extractionCount: 1 },
batchJournal: [],
}),
ensureGraphMutationReady: () => true,
getEmbeddingConfig: () => ({ mode: "direct" }),
validateVectorConfig: () => ({
valid: false,
error: "Embedding 配置无效",
}),
getLastExtractedItems: () => [{ id: "evt-2" }],
consolidateMemories: async () => {
consolidateCalls += 1;
return {
merged: 1,
skipped: 0,
kept: 0,
evolved: 0,
connections: 0,
updates: 0,
};
},
toastr: {
warning(message) {
toastMessages.push(["warning", message]);
},
info(message) {
toastMessages.push(["info", message]);
},
},
});
assert.equal(consolidateCalls, 0);
assert.equal(result?.handledToast, true);
assert.equal(result?.requestDispatched, false);
assert.match(String(toastMessages[0]?.[1] || ""), /配置无效/);
}
async function testManualSleepExplainsThatItIsLocalOnlyWhenNothingChanges() {
let recordGraphMutationCalls = 0;
const toastMessages = [];
const result = await onManualSleepController({
getCurrentGraph: () => ({ nodes: [] }),
ensureGraphMutationReady: () => true,
cloneGraphSnapshot: (value) => JSON.parse(JSON.stringify(value ?? null)),
sleepCycle: () => ({ forgotten: 0 }),
getSettings: () => ({ forgetThreshold: 0.5 }),
recordMaintenanceAction() {
throw new Error("无归档时不应写入维护账本");
},
recordGraphMutation: async () => {
recordGraphMutationCalls += 1;
},
toastr: {
info(message) {
toastMessages.push(["info", message]);
},
success(message) {
toastMessages.push(["success", message]);
},
},
});
assert.equal(recordGraphMutationCalls, 0);
assert.equal(result?.handledToast, true);
assert.equal(result?.requestDispatched, false);
assert.match(String(toastMessages[0]?.[1] || ""), /不会发送 LLM 请求/);
}
await testCompressorMigratesEdgesToCompressedNode();
await testVectorIndexKeepsDirtyOnDirectPartialEmbeddingFailure();
await testExtractorFailsOnUnknownOperation();
@@ -5358,5 +5612,10 @@ await testRerollPreservesPrefixHashesWhenReextractDoesNotAdvance();
await testLlmDebugSnapshotRedactsSecretsBeforeStorage();
await testEmbeddingUsesConfigTimeoutInsteadOfDefault();
await testLlmOutputRegexCleansResponseBeforeJsonParse();
await testManualCompressSkipsWithoutCandidatesAndDoesNotPretendItRan();
await testManualCompressUsesForcedCompressionAndPersistsRealMutation();
await testManualEvolveFallsBackToLatestExtractionBatchAfterRefresh();
await testManualEvolveWarnsOnInvalidVectorConfigInsteadOfPretendingComplete();
await testManualSleepExplainsThatItIsLocalOnlyWhenNothingChanges();
console.log("p0-regressions tests passed");

View File

@@ -18,6 +18,107 @@ function getTimerApi(runtime = {}) {
};
}
function hasCompressionMutation(result = {}) {
return (
Math.max(0, Number(result?.created) || 0) > 0 ||
Math.max(0, Number(result?.archived) || 0) > 0
);
}
function hasSleepMutation(result = {}) {
return Math.max(0, Number(result?.forgotten) || 0) > 0;
}
function hasConsolidationMutation(result = {}) {
return (
Math.max(0, Number(result?.merged) || 0) > 0 ||
Math.max(0, Number(result?.skipped) || 0) > 0 ||
Math.max(0, Number(result?.evolved) || 0) > 0 ||
Math.max(0, Number(result?.connections) || 0) > 0 ||
Math.max(0, Number(result?.updates) || 0) > 0
);
}
function findGraphNode(graph, nodeId) {
if (!graph || !Array.isArray(graph.nodes)) return null;
return graph.nodes.find((node) => node?.id === nodeId) || null;
}
function isManualEvolutionCandidateNode(node) {
if (!node || node.archived) return false;
if (Number(node.level || 0) > 0) return false;
return !["synopsis", "reflection"].includes(String(node.type || ""));
}
function normalizeManualEvolutionCandidateIds(graph, nodeIds = []) {
const unique = new Set();
for (const rawId of Array.isArray(nodeIds) ? nodeIds : []) {
const nodeId = String(rawId || "").trim();
if (!nodeId || unique.has(nodeId)) continue;
const node = findGraphNode(graph, nodeId);
if (!isManualEvolutionCandidateNode(node)) continue;
unique.add(nodeId);
}
return [...unique];
}
function resolveManualEvolutionCandidates(runtime, graph) {
const liveRecentIds = normalizeManualEvolutionCandidateIds(
graph,
runtime.getLastExtractedItems?.()
?.map((item) => item?.id)
.filter(Boolean) || [],
);
if (liveRecentIds.length > 0) {
return {
ids: liveRecentIds,
source: "recent-extract",
};
}
const currentExtractionCount = Math.max(
0,
Number(graph?.historyState?.extractionCount) || 0,
);
const batchJournal = Array.isArray(graph?.batchJournal) ? graph.batchJournal : [];
for (let index = batchJournal.length - 1; index >= 0; index -= 1) {
const entry = batchJournal[index];
const beforeExtractionCount = Math.max(
0,
Number(entry?.stateBefore?.extractionCount) || 0,
);
if (beforeExtractionCount >= currentExtractionCount) {
continue;
}
const fallbackIds = normalizeManualEvolutionCandidateIds(
graph,
entry?.createdNodeIds || [],
);
if (fallbackIds.length > 0) {
return {
ids: fallbackIds,
source: "latest-extraction-batch",
};
}
}
return {
ids: [],
source: "none",
};
}
function describeManualEvolutionSource(source, count) {
switch (String(source || "")) {
case "recent-extract":
return `使用最近提取的 ${count} 个节点`;
case "latest-extraction-batch":
return `使用最近一批提取落盘的 ${count} 个节点`;
default:
return `候选节点 ${count}`;
}
}
export async function onViewGraphController(runtime) {
const graph = runtime.getCurrentGraph();
if (!graph) {
@@ -111,28 +212,55 @@ export async function onManualCompressController(runtime) {
if (!graph) return;
if (!runtime.ensureGraphMutationReady("手动压缩")) return;
const schema = runtime.getSchema();
const inspection = runtime.inspectCompressionCandidates?.(graph, schema, true);
if (inspection && !inspection.hasCandidates) {
runtime.toastr.info(
String(inspection.reason || "当前没有可压缩候选组,本次未发起 LLM 压缩"),
);
return {
handledToast: true,
requestDispatched: false,
mutated: false,
reason: String(inspection.reason || ""),
};
}
const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
const result = await runtime.compressAll(
graph,
runtime.getSchema(),
schema,
runtime.getEmbeddingConfig(),
false,
true,
undefined,
undefined,
runtime.getSettings(),
);
runtime.recordMaintenanceAction?.({
action: "compress",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("compress", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["compression"],
});
const mutated = hasCompressionMutation(result);
if (mutated) {
runtime.recordMaintenanceAction?.({
action: "compress",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("compress", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["compression"],
});
runtime.toastr.success(
`手动压缩完成:新建 ${result.created},归档 ${result.archived}`,
);
} else {
runtime.toastr.info("已尝试手动压缩,但本轮没有产生可持久化变化");
}
runtime.toastr.info(`压缩完成: 新建 ${result.created}, 归档 ${result.archived}`);
return {
handledToast: true,
requestDispatched: true,
mutated,
result,
};
}
export async function onExportGraphController(runtime) {
@@ -413,17 +541,30 @@ export async function onManualSleepController(runtime) {
const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
const result = runtime.sleepCycle(graph, runtime.getSettings());
runtime.recordMaintenanceAction?.({
action: "sleep",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("sleep", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["sleep"],
});
runtime.toastr.info(`执行完成:归档 ${result.forgotten} 个节点`);
const mutated = hasSleepMutation(result);
if (mutated) {
runtime.recordMaintenanceAction?.({
action: "sleep",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("sleep", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["sleep"],
});
runtime.toastr.success(`执行遗忘完成:归档 ${result.forgotten} 个节点`);
} else {
runtime.toastr.info(
"当前没有符合遗忘条件的节点。本操作只做本地图清理,不会发送 LLM 请求。",
);
}
return {
handledToast: true,
requestDispatched: false,
mutated,
result,
};
}
export async function onManualSynopsisController(runtime) {
@@ -451,12 +592,28 @@ export async function onManualEvolveController(runtime) {
if (!graph) return;
if (!runtime.ensureGraphMutationReady("强制进化")) return;
const candidateIds = runtime.getLastExtractedItems()
.map((item) => item.id)
.filter(Boolean);
const embeddingConfig = runtime.getEmbeddingConfig();
const vectorValidation = runtime.validateVectorConfig?.(embeddingConfig);
if (vectorValidation && !vectorValidation.valid) {
runtime.toastr.warning(vectorValidation.error);
return {
handledToast: true,
requestDispatched: false,
mutated: false,
reason: vectorValidation.error,
};
}
const candidateResolution = resolveManualEvolutionCandidates(runtime, graph);
const candidateIds = candidateResolution.ids;
if (candidateIds.length === 0) {
runtime.toastr.info("暂无最近提取节点可用于进化");
return;
runtime.toastr.info("当前没有可用于进化的最近提取节点,本次未发起整合请求");
return {
handledToast: true,
requestDispatched: false,
mutated: false,
reason: "no-candidates",
};
}
const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
@@ -464,7 +621,7 @@ export async function onManualEvolveController(runtime) {
const result = await runtime.consolidateMemories({
graph,
newNodeIds: candidateIds,
embeddingConfig: runtime.getEmbeddingConfig(),
embeddingConfig,
customPrompt: undefined,
settings,
options: {
@@ -472,19 +629,38 @@ export async function onManualEvolveController(runtime) {
conflictThreshold: settings.consolidationThreshold,
},
});
runtime.recordMaintenanceAction?.({
action: "consolidate",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("consolidate", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["consolidation"],
});
runtime.toastr.success(
`整合完成:合并 ${result.merged},跳过 ${result.skipped},保留 ${result.kept},进化 ${result.evolved},新链接 ${result.connections},回溯更新 ${result.updates}`,
const mutated = hasConsolidationMutation(result);
const sourceLabel = describeManualEvolutionSource(
candidateResolution.source,
candidateIds.length,
);
if (mutated) {
runtime.recordMaintenanceAction?.({
action: "consolidate",
beforeSnapshot,
mode: "manual",
summary: runtime.buildMaintenanceSummary?.("consolidate", result, "manual"),
});
await runtime.recordGraphMutation({
beforeSnapshot,
artifactTags: ["consolidation"],
});
runtime.toastr.success(
`强制进化完成:合并 ${result.merged},跳过 ${result.skipped},保留 ${result.kept},进化 ${result.evolved},新链接 ${result.connections},回溯更新 ${result.updates}${sourceLabel}`,
);
} else {
runtime.toastr.info(
`已完成整合判定,但本轮没有产生图谱变更。${sourceLabel}`,
);
}
return {
handledToast: true,
requestDispatched: true,
mutated,
result,
candidateSource: candidateResolution.source,
};
}
export async function onUndoLastMaintenanceController(runtime) {