feat: support multi-owner scene recall anchors

This commit is contained in:
Youzini-afk
2026-04-08 21:29:36 +08:00
parent 835303d4fb
commit d7989303d9
16 changed files with 1729 additions and 91 deletions

View File

@@ -0,0 +1,212 @@
import assert from "node:assert/strict";
import { registerHooks } from "node:module";
const extensionsShimSource = [
"export const extension_settings = {};",
"export function getContext() {",
" return globalThis.__stBmeTestContext || {",
" chat: [],",
" chatMetadata: {},",
" extensionSettings: {},",
" powerUserSettings: {},",
" characters: {},",
" characterId: null,",
" name1: '玩家',",
" name2: '',",
" chatId: 'test-chat',",
" };",
"}",
].join("\n");
const scriptShimSource = [
"export function getRequestHeaders() {",
" return {};",
"}",
"export function substituteParamsExtended(value) {",
" return String(value ?? '');",
"}",
].join("\n");
const openAiShimSource = [
"export const chat_completion_sources = {};",
"export async function sendOpenAIRequest() {",
" throw new Error('sendOpenAIRequest should not be called in extractor-owner-scope test');",
"}",
].join("\n");
registerHooks({
resolve(specifier, context, nextResolve) {
if (
specifier === "../../../extensions.js" ||
specifier === "../../../../extensions.js" ||
specifier === "../../../../../extensions.js"
) {
return {
shortCircuit: true,
url: `data:text/javascript,${encodeURIComponent(extensionsShimSource)}`,
};
}
if (
specifier === "../../../../script.js" ||
specifier === "../../../../../script.js"
) {
return {
shortCircuit: true,
url: `data:text/javascript,${encodeURIComponent(scriptShimSource)}`,
};
}
if (
specifier === "../../../../openai.js" ||
specifier === "../../../../../openai.js"
) {
return {
shortCircuit: true,
url: `data:text/javascript,${encodeURIComponent(openAiShimSource)}`,
};
}
return nextResolve(specifier, context);
},
});
const { createEmptyGraph, createNode, addNode } = await import("../graph/graph.js");
const { DEFAULT_NODE_SCHEMA } = await import("../graph/schema.js");
const { extractMemories } = await import("../maintenance/extractor.js");
function setTestOverrides(overrides = {}) {
globalThis.__stBmeTestOverrides = overrides;
return () => {
delete globalThis.__stBmeTestOverrides;
};
}
globalThis.__stBmeTestContext = {
chat: [],
chatMetadata: {},
extensionSettings: {},
powerUserSettings: {},
characters: {},
characterId: null,
name1: "玩家",
name2: "",
chatId: "test-chat",
};
{
const graph = createEmptyGraph();
addNode(
graph,
createNode({
type: "character",
fields: { name: "艾琳" },
seq: 1,
}),
);
addNode(
graph,
createNode({
type: "character",
fields: { name: "露西亚" },
seq: 1,
}),
);
globalThis.__stBmeTestContext.name2 = "群像卡";
const restore = setTestOverrides({
llm: {
async callLLMForJSON() {
return {
operations: [
{
action: "create",
type: "pov_memory",
fields: { summary: "有人觉得钟楼里还有问题" },
},
],
cognitionUpdates: [
{
knownRefs: ["evt-missing"],
},
],
regionUpdates: {},
};
},
},
});
try {
const result = await extractMemories({
graph,
messages: [{ seq: 3, role: "assistant", content: "多人场景测试" }],
startSeq: 3,
endSeq: 3,
schema: DEFAULT_NODE_SCHEMA,
embeddingConfig: null,
settings: {},
});
assert.equal(result.success, true);
assert.equal(
graph.nodes.filter((node) => !node.archived && node.type === "pov_memory").length,
0,
);
assert.ok(Array.isArray(result.ownerWarnings));
assert.ok(
result.ownerWarnings.some((warning) => warning.kind === "invalid-owner-scope"),
);
} finally {
restore();
}
}
{
const graph = createEmptyGraph();
addNode(
graph,
createNode({
type: "character",
fields: { name: "艾琳" },
seq: 1,
}),
);
globalThis.__stBmeTestContext.name2 = "艾琳";
const restore = setTestOverrides({
llm: {
async callLLMForJSON() {
return {
operations: [
{
action: "create",
type: "pov_memory",
fields: { summary: "艾琳觉得钟楼里藏着第二条暗道" },
},
],
cognitionUpdates: [],
regionUpdates: {},
};
},
},
});
try {
const result = await extractMemories({
graph,
messages: [{ seq: 5, role: "assistant", content: "单角色场景测试" }],
startSeq: 5,
endSeq: 5,
schema: DEFAULT_NODE_SCHEMA,
embeddingConfig: null,
settings: {},
});
assert.equal(result.success, true);
const povNode = graph.nodes.find(
(node) => !node.archived && node.type === "pov_memory",
);
assert.ok(povNode);
assert.equal(povNode.scope?.ownerType, "character");
assert.equal(povNode.scope?.ownerName, "艾琳");
} finally {
restore();
}
}
console.log("extractor-owner-scope tests passed");

View File

@@ -57,15 +57,26 @@ const text = formatInjection(
recallNodes: [recalledCharacter, recalledReflection],
scopeBuckets: {
characterPov: [recalledCharacter],
characterPovByOwner: {
"character:艾琳": [recalledCharacter],
},
characterPovOwnerOrder: ["character:艾琳"],
userPov: [recalledReflection],
objectiveCurrentRegion: [coreEvent],
objectiveGlobal: [],
},
meta: {
retrieval: {
sceneOwnerCandidates: [
{ ownerKey: "character:艾琳", ownerName: "艾琳" },
],
},
},
},
DEFAULT_NODE_SCHEMA,
);
assert.match(text, /\[Memory - Character POV\]/);
assert.match(text, /\[Memory - Character POV: 艾琳\]/);
assert.match(text, /\[Memory - User POV \/ Not Character Facts\]/);
assert.match(text, /不等于角色已知事实/);
assert.match(text, /\[Memory - Objective \/ Current Region\]/);

View File

@@ -101,6 +101,36 @@ const gateRestored = computeKnowledgeGateForNode(graph, bellEvent, ownerA.ownerK
assert.equal(gateRestored.visible, true);
assert.notEqual(gateRestored.suppressedReason, "mistaken-objective");
applyCognitionUpdates(
graph,
[
{
ownerType: "character",
ownerName: "露西亚",
ownerNodeId: lucia.id,
knownRefs: [bellEvent.id],
visibility: [{ ref: bellEvent.id, score: 1 }],
},
],
{ changedNodeIds: [bellEvent.id] },
);
applyManualKnowledgeOverride(graph, {
ownerKey: ownerA.ownerKey,
nodeId: bellEvent.id,
mode: "mistaken",
});
const gateUnion = computeKnowledgeGateForNode(
graph,
bellEvent,
[ownerA.ownerKey, `character:露西亚`],
{
scopeBucket: "objectiveCurrentRegion",
},
);
assert.equal(gateUnion.visible, true);
assert.deepEqual(gateUnion.visibleOwnerKeys, ["character:露西亚"]);
assert.deepEqual(gateUnion.suppressedOwnerKeys, [ownerA.ownerKey]);
applyRegionUpdates(graph, {
activeRegionHint: "钟楼",
adjacency: [{ region: "钟楼", adjacent: ["旧城区", "内廷"] }],
@@ -116,7 +146,7 @@ const ownerList = listKnowledgeOwners(graph);
assert.ok(ownerList.some((entry) => entry.ownerKey === ownerA.ownerKey));
assert.ok(
ownerList.some(
(entry) => entry.ownerName === "露西亚" && entry.knownCount === 0,
(entry) => entry.ownerName === "露西亚" && entry.knownCount >= 1,
),
);

View File

@@ -109,6 +109,7 @@ const recallPromptBuild = await buildTaskPrompt(settings, "recall", {
recentMessages: "上下文",
userMessage: "用户最新发言",
candidateNodes: "候选 1\n候选 2",
sceneOwnerCandidates: "character:alice\ncharacter:bob",
graphStats: "candidate_count=2",
});
const recallPayload = buildTaskLlmPayload(recallPromptBuild, "fallback-user");
@@ -128,8 +129,14 @@ assert.deepEqual(
"recentMessages",
"userMessage",
"candidateNodes",
"sceneOwnerCandidates",
"graphStats",
],
);
const recallFormatBlock = recallPayload.promptMessages.find(
(message) => message.blockName === "输出格式",
);
assert.match(String(recallFormatBlock?.content || ""), /active_owner_keys/);
assert.match(String(recallFormatBlock?.content || ""), /active_owner_scores/);
console.log("prompt-builder-defaults tests passed");

View File

@@ -158,6 +158,24 @@ const retrieve = await loadRetrieve({
ownerKey: ownerType && ownerName ? `${ownerType}:${ownerName}` : "",
};
},
resolveKnowledgeOwnerKeyFromScope(_graph, scope = {}) {
const ownerType = String(scope.ownerType || "").trim();
const ownerName = String(scope.ownerName || scope.ownerId || "").trim();
return ownerType && ownerName ? `${ownerType}:${ownerName}` : "";
},
listKnowledgeOwners(targetGraph) {
return (targetGraph?.nodes || [])
.filter((node) => node?.type === "character" && !node?.archived)
.map((node) => ({
ownerKey: `character:${String(node?.fields?.name || "").trim()}`,
ownerType: "character",
ownerName: String(node?.fields?.name || "").trim(),
nodeId: String(node?.id || "").trim(),
aliases: [String(node?.fields?.name || "").trim()].filter(Boolean),
updatedAt: 0,
}))
.filter((entry) => entry.ownerKey && entry.ownerName);
},
resolveActiveRegionContext(graph, preferredRegion = "") {
return {
activeRegion:
@@ -598,4 +616,111 @@ assert.equal(scopedResult.meta.retrieval.activeRegion, "钟楼");
assert.ok(Array.isArray(scopedResult.scopeBuckets.characterPov));
assert.equal(scopedResult.scopeBuckets.characterPov[0]?.id, "char-pov");
const multiOwnerGraph = {
nodes: [
{
id: "char-node-a",
type: "character",
importance: 6,
createdTime: 1,
archived: false,
fields: { name: "艾琳" },
seqRange: [1, 1],
},
{
id: "char-node-b",
type: "character",
importance: 6,
createdTime: 1,
archived: false,
fields: { name: "露西亚" },
seqRange: [1, 1],
},
{
id: "pov-a",
type: "pov_memory",
importance: 8,
createdTime: 2,
archived: false,
fields: { summary: "艾琳觉得钟楼里还有第二条暗道" },
seqRange: [2, 2],
scope: {
layer: "pov",
ownerType: "character",
ownerId: "艾琳",
ownerName: "艾琳",
},
},
{
id: "pov-b",
type: "pov_memory",
importance: 7,
createdTime: 3,
archived: false,
fields: { summary: "露西亚认为钟楼守卫在故意拖时间" },
seqRange: [3, 3],
scope: {
layer: "pov",
ownerType: "character",
ownerId: "露西亚",
ownerName: "露西亚",
},
},
],
edges: [],
historyState: {
activeRegion: "",
activeCharacterPovOwner: "",
activeUserPovOwner: "玩家",
},
};
const multiOwnerSchema = [
{ id: "character", label: "角色", alwaysInject: false },
{ id: "pov_memory", label: "主观记忆", alwaysInject: false },
];
state.llmResponse = {
selected_ids: ["pov-a", "pov-b"],
active_owner_keys: ["character:艾琳", "character:露西亚"],
active_owner_scores: [
{ ownerKey: "character:艾琳", score: 0.91, reason: "她的 POV 直接命中当前追问" },
{ ownerKey: "character:露西亚", score: 0.83, reason: "她也在同一场景并提供互补判断" },
],
};
const multiOwnerResult = await retrieve({
graph: multiOwnerGraph,
userMessage: "艾琳和露西亚现在各自怎么看钟楼这件事",
recentMessages: ["[assistant]: 她们刚刚一起进入钟楼大厅"],
embeddingConfig: {},
schema: multiOwnerSchema,
options: {
topK: 4,
maxRecallNodes: 2,
enableVectorPrefilter: false,
enableGraphDiffusion: false,
enableLLMRecall: true,
llmCandidatePool: 4,
},
});
assert.deepEqual(
Array.from(multiOwnerResult.meta.retrieval.activeRecallOwnerKeys),
["character:艾琳", "character:露西亚"],
);
assert.equal(multiOwnerResult.meta.retrieval.sceneOwnerResolutionMode, "llm");
assert.deepEqual(
Array.from(multiOwnerResult.scopeBuckets.characterPovOwnerOrder),
["character:艾琳", "character:露西亚"],
);
assert.equal(
multiOwnerResult.scopeBuckets.characterPovByOwner["character:艾琳"]?.[0]?.id,
"pov-a",
);
assert.equal(
multiOwnerResult.scopeBuckets.characterPovByOwner["character:露西亚"]?.[0]?.id,
"pov-b",
);
assert.equal(
multiOwnerResult.meta.retrieval.selectedByOwner["character:艾琳"]?.[0],
"pov-a",
);
console.log("retrieval-config tests passed");

View File

@@ -110,6 +110,7 @@ assert.deepEqual(
"recentMessages",
"userMessage",
"candidateNodes",
"sceneOwnerCandidates",
"graphStats",
"default-format",
"default-rules",