mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
Fix manual maintenance action execution feedback
This commit is contained in:
2
index.js
2
index.js
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,16 +212,32 @@ 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(),
|
||||
);
|
||||
const mutated = hasCompressionMutation(result);
|
||||
if (mutated) {
|
||||
runtime.recordMaintenanceAction?.({
|
||||
action: "compress",
|
||||
beforeSnapshot,
|
||||
@@ -131,8 +248,19 @@ export async function onManualCompressController(runtime) {
|
||||
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,6 +541,8 @@ export async function onManualSleepController(runtime) {
|
||||
|
||||
const beforeSnapshot = runtime.cloneGraphSnapshot(graph);
|
||||
const result = runtime.sleepCycle(graph, runtime.getSettings());
|
||||
const mutated = hasSleepMutation(result);
|
||||
if (mutated) {
|
||||
runtime.recordMaintenanceAction?.({
|
||||
action: "sleep",
|
||||
beforeSnapshot,
|
||||
@@ -423,7 +553,18 @@ export async function onManualSleepController(runtime) {
|
||||
beforeSnapshot,
|
||||
artifactTags: ["sleep"],
|
||||
});
|
||||
runtime.toastr.info(`执行完成:归档 ${result.forgotten} 个节点`);
|
||||
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,6 +629,12 @@ export async function onManualEvolveController(runtime) {
|
||||
conflictThreshold: settings.consolidationThreshold,
|
||||
},
|
||||
});
|
||||
const mutated = hasConsolidationMutation(result);
|
||||
const sourceLabel = describeManualEvolutionSource(
|
||||
candidateResolution.source,
|
||||
candidateIds.length,
|
||||
);
|
||||
if (mutated) {
|
||||
runtime.recordMaintenanceAction?.({
|
||||
action: "consolidate",
|
||||
beforeSnapshot,
|
||||
@@ -483,8 +646,21 @@ export async function onManualEvolveController(runtime) {
|
||||
artifactTags: ["consolidation"],
|
||||
});
|
||||
runtime.toastr.success(
|
||||
`整合完成:合并 ${result.merged},跳过 ${result.skipped},保留 ${result.kept},进化 ${result.evolved},新链接 ${result.connections},回溯更新 ${result.updates}`,
|
||||
`强制进化完成:合并 ${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) {
|
||||
|
||||
Reference in New Issue
Block a user