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

@@ -38,10 +38,12 @@ import {
} from "../graph/memory-scope.js";
import {
computeKnowledgeGateForNode,
listKnowledgeOwners,
pushRecentRecallOwner,
resolveActiveRegionContext,
resolveAdjacentRegions,
resolveKnowledgeOwner,
resolveKnowledgeOwnerKeyFromScope,
} from "../graph/knowledge-state.js";
import { applyTaskRegex } from "../prompting/task-regex.js";
import { getSTContextForPrompt } from "../host/st-context.js";
@@ -164,16 +166,23 @@ function createRetrievalMeta(enableLLMRecall) {
activeCharacterPovOwner: "",
activeUserPovOwner: "",
activeRecallOwnerKey: "",
activeRecallOwnerKeys: [],
activeRecallOwnerScores: {},
sceneOwnerResolutionMode: "unresolved",
sceneOwnerCandidates: [],
bucketWeights: {},
selectedByBucket: {},
knowledgeGateMode: "disabled",
knowledgeAnchoredNodes: [],
knowledgeSuppressedNodes: [],
knowledgeRescuedNodes: [],
knowledgeVisibleOwnersByNode: {},
knowledgeSuppressedOwnersByNode: {},
visibilityTopHits: [],
visibilitySuppressedReasons: {},
adjacentRegionMatches: [],
selectedByKnowledgeState: {},
selectedByOwner: {},
skipReasons: [],
timings: {},
llm: {
@@ -707,6 +716,426 @@ function getScopeBucketPriority(bucket) {
}
}
function normalizeTrimmedString(value) {
return String(value ?? "").trim();
}
function normalizeOwnerKeyList(ownerKeys = []) {
return [
...new Set(
(Array.isArray(ownerKeys) ? ownerKeys : [ownerKeys])
.map((value) => normalizeTrimmedString(value))
.filter(Boolean),
),
];
}
function roundOwnerScore(value) {
return Math.round((Number(value) || 0) * 1000) / 1000;
}
function buildCharacterOwnerCatalog(graph) {
return (listKnowledgeOwners(graph) || [])
.filter((entry) => String(entry?.ownerType || "") === "character")
.map((entry) => ({
ownerKey: normalizeTrimmedString(entry.ownerKey),
ownerName: normalizeTrimmedString(entry.ownerName),
nodeId: normalizeTrimmedString(entry.nodeId),
aliases: [
...new Set(
[entry.ownerName, ...(entry.aliases || [])]
.map((value) => normalizeTrimmedString(value))
.filter(Boolean),
),
],
updatedAt: Number(entry?.updatedAt || 0),
}))
.filter((entry) => entry.ownerKey && entry.ownerName);
}
function createSceneOwnerCandidateMap() {
return new Map();
}
function addSceneOwnerCandidate(
candidateMap,
owner,
{ score = 0, source = "", reason = "" } = {},
) {
if (!(candidateMap instanceof Map)) return;
const ownerKey = normalizeTrimmedString(owner?.ownerKey);
const ownerName = normalizeTrimmedString(owner?.ownerName);
if (!ownerKey || !ownerName) return;
const existing = candidateMap.get(ownerKey) || {
ownerKey,
ownerName,
ownerType: "character",
nodeId: normalizeTrimmedString(owner?.nodeId),
aliases: [
...new Set(
[ownerName, ...(owner?.aliases || [])]
.map((value) => normalizeTrimmedString(value))
.filter(Boolean),
),
],
score: 0,
sources: [],
reasons: [],
};
existing.score += Math.max(0, Number(score) || 0);
if (source && !existing.sources.includes(source)) {
existing.sources.push(source);
}
if (reason && !existing.reasons.includes(reason)) {
existing.reasons.push(reason);
}
candidateMap.set(ownerKey, existing);
}
function finalizeSceneOwnerCandidates(candidateMap, maxCount = 8) {
if (!(candidateMap instanceof Map)) return [];
return [...candidateMap.values()]
.map((entry) => ({
...entry,
score: roundOwnerScore(entry.score),
aliases: [...new Set((entry.aliases || []).filter(Boolean))],
sources: [...new Set((entry.sources || []).filter(Boolean))],
reasons: [...new Set((entry.reasons || []).filter(Boolean))],
}))
.sort((left, right) => {
const scoreDelta = Number(right.score || 0) - Number(left.score || 0);
if (scoreDelta !== 0) return scoreDelta;
return String(left.ownerName || "").localeCompare(
String(right.ownerName || ""),
"zh-Hans-CN",
);
})
.slice(0, Math.max(1, maxCount));
}
function resolveLegacySceneOwner(graph, rawOwnerName = "") {
const ownerName = normalizeTrimmedString(rawOwnerName);
if (!ownerName) return null;
const ownerCatalog = buildCharacterOwnerCatalog(graph);
const matchedOwners = ownerCatalog.filter(
(owner) =>
String(owner.ownerName || "").trim() === ownerName &&
String(owner.nodeId || "").trim(),
);
if (matchedOwners.length !== 1) {
return null;
}
const resolved = resolveKnowledgeOwner(graph, {
ownerType: "character",
ownerName,
});
return resolved?.ownerKey ? resolved : null;
}
function collectOwnerCandidatesFromText(
ownerCatalog,
texts = [],
{ score = 0.8, source = "recent-message" } = {},
) {
const candidateMap = createSceneOwnerCandidateMap();
const normalizedTexts = (Array.isArray(texts) ? texts : [texts])
.map((value) => normalizeQueryText(value, 800).toLowerCase())
.filter(Boolean);
if (normalizedTexts.length === 0) {
return [];
}
for (const owner of ownerCatalog || []) {
const alias = [...(owner.aliases || [])]
.map((value) => normalizeTrimmedString(value))
.filter((value) => value.length >= 2)
.sort((left, right) => right.length - left.length)
.find((value) =>
normalizedTexts.some((text) => text.includes(value.toLowerCase())),
);
if (!alias) continue;
addSceneOwnerCandidate(candidateMap, owner, {
score,
source,
reason: `文本直接点名 ${alias}`,
});
}
return finalizeSceneOwnerCandidates(candidateMap, 8);
}
function collectOwnerCandidatesFromNodes(
graph,
ownerCatalog,
nodeEntries = [],
) {
const candidateMap = createSceneOwnerCandidateMap();
for (const entry of Array.isArray(nodeEntries) ? nodeEntries : []) {
const node = entry?.node || entry;
const baseScore = Math.max(0, Number(entry?.weightedScore ?? entry?.finalScore ?? 0) || 0);
if (!node || node.archived) continue;
if (node.type === "pov_memory" && String(node?.scope?.ownerType || "") === "character") {
const resolvedOwner = resolveKnowledgeOwner(graph, {
ownerType: "character",
ownerName: node?.scope?.ownerName || node?.scope?.ownerId,
});
if (resolvedOwner.ownerKey) {
addSceneOwnerCandidate(candidateMap, resolvedOwner, {
score: 1.0 + Math.min(0.8, baseScore / 8),
source: "candidate-pov-owner",
reason: `候选 POV 命中 ${resolvedOwner.ownerName || resolvedOwner.ownerKey}`,
});
}
continue;
}
const text = [
node?.fields?.participants,
node?.fields?.summary,
node?.fields?.state,
node?.fields?.title,
node?.fields?.name,
node?.fields?.status,
]
.filter((value) => value != null)
.join(" ");
if (!text) continue;
const matched = collectOwnerCandidatesFromText(ownerCatalog, [text], {
score: 0.9 + Math.min(0.6, baseScore / 10),
source: "objective-participant",
});
for (const owner of matched) {
addSceneOwnerCandidate(candidateMap, owner, {
score: owner.score,
source: "objective-participant",
reason: owner.reasons?.[0] || "高分客观节点提到该角色",
});
}
}
return finalizeSceneOwnerCandidates(candidateMap, 8);
}
function collectRecentActiveOwnerCandidates(
graph,
ownerCatalog,
limit = 4,
) {
const sorted = [...(ownerCatalog || [])].sort((left, right) => {
const updatedDelta = Number(right.updatedAt || 0) - Number(left.updatedAt || 0);
if (updatedDelta !== 0) return updatedDelta;
const leftNode = left.nodeId ? getNode(graph, left.nodeId) : null;
const rightNode = right.nodeId ? getNode(graph, right.nodeId) : null;
return (Number(rightNode?.seqRange?.[1] ?? rightNode?.seq ?? 0) || 0)
- (Number(leftNode?.seqRange?.[1] ?? leftNode?.seq ?? 0) || 0);
});
return sorted.slice(0, Math.max(1, limit)).map((owner) => ({
...owner,
score: 0.35,
sources: ["recent-active"],
reasons: ["近期活跃角色"],
}));
}
function mergeSceneOwnerCandidateLists(...lists) {
const candidateMap = createSceneOwnerCandidateMap();
for (const list of lists) {
for (const owner of Array.isArray(list) ? list : []) {
addSceneOwnerCandidate(candidateMap, owner, {
score: owner.score,
source: Array.isArray(owner.sources) ? owner.sources[0] || "" : "",
reason: Array.isArray(owner.reasons) ? owner.reasons[0] || "" : "",
});
for (const source of Array.isArray(owner.sources) ? owner.sources : []) {
addSceneOwnerCandidate(candidateMap, owner, {
score: 0,
source,
reason: "",
});
}
for (const reason of Array.isArray(owner.reasons) ? owner.reasons : []) {
addSceneOwnerCandidate(candidateMap, owner, {
score: 0,
source: "",
reason,
});
}
}
}
return finalizeSceneOwnerCandidates(candidateMap, 8);
}
function resolveSceneOwnersHeuristically(
sceneOwnerCandidates = [],
{ mode = "heuristic", maxOwners = 4, minScore = 0.55 } = {},
) {
const filtered = (Array.isArray(sceneOwnerCandidates) ? sceneOwnerCandidates : [])
.filter(
(candidate) =>
candidate?.ownerKey && Number(candidate?.score || 0) >= Number(minScore || 0),
)
.slice(0, Math.max(1, maxOwners));
return {
ownerKeys: filtered.map((candidate) => candidate.ownerKey),
ownerScores: Object.fromEntries(
filtered.map((candidate) => [
candidate.ownerKey,
roundOwnerScore(Math.min(1, Number(candidate.score || 0))),
]),
),
mode: filtered.length > 0 ? mode : "unresolved",
};
}
function getSceneOwnerNamesByKeys(sceneOwnerCandidates = [], ownerKeys = []) {
const keySet = new Set(normalizeOwnerKeyList(ownerKeys));
if (keySet.size === 0) return [];
return (Array.isArray(sceneOwnerCandidates) ? sceneOwnerCandidates : [])
.filter((candidate) => keySet.has(candidate.ownerKey))
.map((candidate) => candidate.ownerName)
.filter(Boolean);
}
function buildSceneOwnerCandidateText(sceneOwnerCandidates = []) {
const candidates = Array.isArray(sceneOwnerCandidates) ? sceneOwnerCandidates : [];
if (candidates.length === 0) {
return "(当前没有足够可靠的具体角色候选;如果无法判断,请返回空数组)";
}
return candidates
.map((candidate) => {
const reasonText = Array.isArray(candidate.reasons) && candidate.reasons.length
? candidate.reasons.join("")
: "无";
return `- ownerKey=${candidate.ownerKey}; ownerName=${candidate.ownerName}; score=${roundOwnerScore(candidate.score).toFixed(3)}; reasons=${reasonText}`;
})
.join("\n");
}
function resolveSceneOwnerKeyFromValue(sceneOwnerCandidates = [], value = "") {
const normalizedValue = normalizeTrimmedString(value);
if (!normalizedValue) return "";
const directMatch = (Array.isArray(sceneOwnerCandidates) ? sceneOwnerCandidates : [])
.find((candidate) => candidate.ownerKey === normalizedValue);
if (directMatch?.ownerKey) {
return directMatch.ownerKey;
}
const lowered = normalizedValue.toLowerCase();
const aliasMatch = (Array.isArray(sceneOwnerCandidates) ? sceneOwnerCandidates : [])
.find((candidate) =>
[candidate.ownerName, ...(candidate.aliases || [])]
.map((item) => normalizeTrimmedString(item).toLowerCase())
.includes(lowered),
);
return aliasMatch?.ownerKey || "";
}
function normalizeLlmSceneOwnerScores(
sceneOwnerCandidates = [],
rawScores = [],
) {
const normalizedEntries = Array.isArray(rawScores)
? rawScores
: rawScores && typeof rawScores === "object"
? Object.entries(rawScores).map(([ownerKey, score]) => ({
ownerKey,
score,
reason: "",
}))
: [];
const result = [];
for (const entry of normalizedEntries) {
const ownerKey = resolveSceneOwnerKeyFromValue(
sceneOwnerCandidates,
entry?.ownerKey || entry?.owner || entry?.owner_name || "",
);
if (!ownerKey) continue;
result.push({
ownerKey,
score: clampRange(entry?.score, 0, 0, 1),
reason: normalizeTrimmedString(entry?.reason),
});
}
return result;
}
function buildSelectedByOwner(
graph,
selectedNodes = [],
scoredNodes = [],
sceneOwnerCandidates = [],
) {
const scoredMap = new Map(
(Array.isArray(scoredNodes) ? scoredNodes : []).map((item) => [item.nodeId, item]),
);
const result = {};
for (const node of Array.isArray(selectedNodes) ? selectedNodes : []) {
if (!node?.id) continue;
const scored = scoredMap.get(node.id);
const ownerKeys = normalizeOwnerKeyList([
resolveKnowledgeOwnerKeyFromScope(graph, node?.scope),
...(scored?.knowledgeVisibleOwnerKeys || []),
])
.map((ownerKey) => resolveSceneOwnerKeyFromValue(sceneOwnerCandidates, ownerKey) || ownerKey)
.filter(Boolean);
for (const ownerKey of ownerKeys) {
result[ownerKey] ||= [];
result[ownerKey].push(node.id);
}
}
return result;
}
function augmentSelectedNodeIdsWithActiveOwnerPov(
graph,
selectedNodeIds = [],
scoredNodes = [],
activeOwnerKeys = [],
limit = 8,
) {
const ownerKeys = normalizeOwnerKeyList(activeOwnerKeys);
if (ownerKeys.length === 0) {
return uniqueNodeIds(selectedNodeIds).slice(0, Math.max(1, limit));
}
const selectedSet = new Set(uniqueNodeIds(selectedNodeIds));
const ownerPovNodeIds = [];
for (const ownerKey of ownerKeys) {
const bestPov = (Array.isArray(scoredNodes) ? scoredNodes : []).find((item) => {
if (!item?.node || item.node.archived || item.node.type !== "pov_memory") {
return false;
}
const scopeOwnerKey = resolveKnowledgeOwnerKeyFromScope(graph, item.node.scope);
return scopeOwnerKey === ownerKey;
});
if (!bestPov?.nodeId || selectedSet.has(bestPov.nodeId)) continue;
selectedSet.add(bestPov.nodeId);
ownerPovNodeIds.push(bestPov.nodeId);
}
return uniqueNodeIds([
...ownerPovNodeIds,
...selectedNodeIds,
]).slice(0, Math.max(1, limit));
}
function buildRecallSceneOwnerAugmentPrompt(maxNodes, sceneOwnerCandidateText = "") {
return [
"除了 selected_ids你还需要同时判断这轮场景里真正参与当前回应的具体人物。",
`最多返回 ${Math.max(1, Math.min(4, Number(maxNodes) || 4))} 个 active_owner_keys如果无法可靠判断可以返回空数组。`,
"active_owner_keys 必须从给出的 ownerKey 候选里选择,不要用角色卡名替代具体人物。",
"active_owner_scores 必须是数组,每项格式为 {\"ownerKey\":\"...\",\"score\":0.0,\"reason\":\"...\"}score 范围 0..1。",
"如果某个客观事实只被部分人物知道,也要保留这些具体人物的判断,不要把所有人混成一个总角色。",
"",
"## 场景角色候选",
sceneOwnerCandidateText || "(无)",
].join("\n");
}
/**
* 三层混合检索管线
*
@@ -822,11 +1251,13 @@ export async function retrieve({
const injectUserPovMemory = options.injectUserPovMemory ?? true;
const injectObjectiveGlobalMemory = options.injectObjectiveGlobalMemory ?? true;
const stPromptContext = getSTContextForPrompt();
const ownerCatalog = buildCharacterOwnerCatalog(graph);
const legacyOwnerCandidate =
resolveLegacySceneOwner(graph, options.activeCharacterPovOwner) ||
resolveLegacySceneOwner(graph, graph?.historyState?.activeCharacterPovOwner) ||
resolveLegacySceneOwner(graph, stPromptContext?.charName);
const activeCharacterPovOwner = String(
options.activeCharacterPovOwner ||
graph?.historyState?.activeCharacterPovOwner ||
stPromptContext?.charName ||
"",
legacyOwnerCandidate?.ownerName || "",
).trim();
const activeUserPovOwner = String(
options.activeUserPovOwner ||
@@ -834,14 +1265,42 @@ export async function retrieve({
stPromptContext?.userName ||
"",
).trim();
const activeRecallOwner = resolveKnowledgeOwner(graph, {
ownerType: "character",
ownerName:
options.activeCharacterPovOwner ||
graph?.historyState?.activeCharacterPovOwner ||
stPromptContext?.charName ||
"",
});
const preliminarySceneOwnerCandidates = mergeSceneOwnerCandidateLists(
legacyOwnerCandidate
? [
{
...legacyOwnerCandidate,
score: 0.6,
sources: ["legacy-unique-match"],
reasons: ["唯一映射到图内具体角色"],
},
]
: [],
collectOwnerCandidatesFromText(ownerCatalog, [
userMessage,
...recentMessages,
]),
collectRecentActiveOwnerCandidates(graph, ownerCatalog),
);
const preliminarySceneOwnerResolution = resolveSceneOwnersHeuristically(
preliminarySceneOwnerCandidates,
{
mode: "heuristic",
maxOwners: 4,
minScore: 0.55,
},
);
let activeRecallOwnerKeys = normalizeOwnerKeyList(
options.activeRecallOwnerKeys ||
preliminarySceneOwnerResolution.ownerKeys ||
[],
);
let activeRecallOwnerScores = {
...(preliminarySceneOwnerResolution.ownerScores || {}),
};
let sceneOwnerResolutionMode = activeRecallOwnerKeys.length
? preliminarySceneOwnerResolution.mode || "heuristic"
: "unresolved";
const activeRegionContext = resolveActiveRegionContext(
graph,
options.activeRegion || "",
@@ -875,9 +1334,22 @@ export async function retrieve({
const retrievalMeta = createRetrievalMeta(enableLLMRecall);
retrievalMeta.activeRegion = activeRegion;
retrievalMeta.activeRegionSource = activeRegionContext.source || "";
retrievalMeta.activeCharacterPovOwner = activeCharacterPovOwner;
retrievalMeta.activeCharacterPovOwner =
activeCharacterPovOwner ||
preliminarySceneOwnerCandidates[0]?.ownerName ||
"";
retrievalMeta.activeUserPovOwner = activeUserPovOwner;
retrievalMeta.activeRecallOwnerKey = activeRecallOwner.ownerKey || "";
retrievalMeta.activeRecallOwnerKey = activeRecallOwnerKeys[0] || "";
retrievalMeta.activeRecallOwnerKeys = [...activeRecallOwnerKeys];
retrievalMeta.activeRecallOwnerScores = { ...activeRecallOwnerScores };
retrievalMeta.sceneOwnerResolutionMode = sceneOwnerResolutionMode;
retrievalMeta.sceneOwnerCandidates = preliminarySceneOwnerCandidates.map((candidate) => ({
ownerKey: candidate.ownerKey,
ownerName: candidate.ownerName,
score: roundOwnerScore(candidate.score),
sources: [...(candidate.sources || [])],
reasons: [...(candidate.reasons || [])],
}));
retrievalMeta.bucketWeights = { ...bucketWeights };
retrievalMeta.knowledgeGateMode = enableCognitiveMemory
? "anchored-soft-visibility"
@@ -939,7 +1411,14 @@ export async function retrieve({
activeRegionSource: activeRegionContext.source || "",
activeCharacterPovOwner,
activeUserPovOwner,
activeRecallOwnerKey: activeRecallOwner.ownerKey || "",
activeCharacterPovOwners: preliminarySceneOwnerCandidates.map(
(candidate) => candidate.ownerName,
),
activeRecallOwnerKey: activeRecallOwnerKeys[0] || "",
activeRecallOwnerKeys: [...activeRecallOwnerKeys],
activeRecallOwnerScores: { ...activeRecallOwnerScores },
sceneOwnerResolutionMode,
sceneOwnerCandidates: retrievalMeta.sceneOwnerCandidates,
adjacentRegions: adjacentRegionContext.adjacentRegions,
injectLowConfidenceObjectiveMemory,
graph,
@@ -1191,18 +1670,24 @@ export async function retrieve({
const scopeBucket = enableScopedMemory
? classifyNodeScopeBucket(node, {
activeCharacterPovOwner,
activeCharacterPovOwners: activeRecallOwnerKeys.map((ownerKey) =>
preliminarySceneOwnerCandidates.find(
(candidate) => candidate.ownerKey === ownerKey,
)?.ownerName || "",
),
activeUserPovOwner,
activeRegion,
adjacentRegions: adjacentRegionContext.adjacentRegions,
enablePovMemory,
enableRegionScopedObjective,
allowImplicitCharacterPovFallback: false,
})
: MEMORY_SCOPE_BUCKETS.OBJECTIVE_GLOBAL;
const knowledgeGate = enableCognitiveMemory
? computeKnowledgeGateForNode(
graph,
node,
activeRecallOwner.ownerKey,
activeRecallOwnerKeys,
{
vectorScore: scores.vectorScore,
graphScore: scores.graphScore,
@@ -1223,6 +1708,12 @@ export async function retrieve({
if (scopeBucket === MEMORY_SCOPE_BUCKETS.OBJECTIVE_ADJACENT_REGION) {
retrievalMeta.adjacentRegionMatches.push(nodeId);
}
retrievalMeta.knowledgeVisibleOwnersByNode[nodeId] = [
...(knowledgeGate.visibleOwnerKeys || []),
];
retrievalMeta.knowledgeSuppressedOwnersByNode[nodeId] = [
...(knowledgeGate.suppressedOwnerKeys || []),
];
if (!knowledgeGate.visible) {
retrievalMeta.knowledgeSuppressedNodes.push(nodeId);
if (knowledgeGate.suppressedReason) {
@@ -1247,7 +1738,10 @@ export async function retrieve({
? 0.92
: Math.max(0.35, 0.55 + Number(knowledgeGate.visibilityScore || 0) * 0.6)
: 1;
const weightedScore = finalScore * scopeWeight * knowledgeWeight;
const ownerCoverageWeight = enableCognitiveMemory
? 1 + Math.max(0, Number(knowledgeGate.ownerCoverage || 0) - 1 / Math.max(1, activeRecallOwnerKeys.length || 1)) * 0.08
: 1;
const weightedScore = finalScore * scopeWeight * knowledgeWeight * ownerCoverageWeight;
scoredNodes.push({
nodeId,
@@ -1262,6 +1756,10 @@ export async function retrieve({
knowledgeWeight,
knowledgeAnchored: Boolean(knowledgeGate.anchored),
knowledgeRescued: Boolean(knowledgeGate.rescued),
knowledgeOwnerCoverage: Number(knowledgeGate.ownerCoverage || 0),
knowledgeVisibleOwnerKeys: [...(knowledgeGate.visibleOwnerKeys || [])],
knowledgeSuppressedOwnerKeys: [...(knowledgeGate.suppressedOwnerKeys || [])],
ownerCoverageWeight,
...scores,
});
pushScopeBucketDebug(
@@ -1286,6 +1784,21 @@ export async function retrieve({
).length;
retrievalMeta.lexicalTopHits = buildLexicalTopHits(scoredNodes);
retrievalMeta.visibilityTopHits = buildVisibilityTopHits(scoredNodes);
const sceneOwnerCandidates = mergeSceneOwnerCandidateLists(
preliminarySceneOwnerCandidates,
collectOwnerCandidatesFromNodes(
graph,
ownerCatalog,
scoredNodes.slice(0, Math.max(normalizedLlmCandidatePool, 12)),
),
);
retrievalMeta.sceneOwnerCandidates = sceneOwnerCandidates.map((candidate) => ({
ownerKey: candidate.ownerKey,
ownerName: candidate.ownerName,
score: roundOwnerScore(candidate.score),
sources: [...(candidate.sources || [])],
reasons: [...(candidate.reasons || [])],
}));
retrievalMeta.timings.scoring = roundMs(nowMs() - scoringStartedAt);
let selectedNodeIds;
@@ -1312,12 +1825,45 @@ export async function retrieve({
schema,
normalizedMaxRecallNodes,
options.recallPrompt,
sceneOwnerCandidates,
settings,
signal,
onStreamProgress,
);
llmDurationMs = nowMs() - llmStartedAt;
selectedNodeIds = llmResult.selectedNodeIds;
const llmOwnerResolution =
llmResult.activeOwnerKeys?.length > 0
? {
ownerKeys: normalizeOwnerKeyList(llmResult.activeOwnerKeys).slice(0, 4),
ownerScores: Object.fromEntries(
normalizeOwnerKeyList(llmResult.activeOwnerKeys)
.slice(0, 4)
.map((ownerKey) => [
ownerKey,
roundOwnerScore(
Math.min(
1,
Number(llmResult.activeOwnerScores?.[ownerKey]) ||
Number(
sceneOwnerCandidates.find(
(candidate) => candidate.ownerKey === ownerKey,
)?.score || 0,
),
),
),
]),
),
mode: llmResult.sceneOwnerResolutionMode || "llm",
}
: resolveSceneOwnersHeuristically(sceneOwnerCandidates, {
mode: "fallback",
maxOwners: 4,
minScore: 0.55,
});
activeRecallOwnerKeys = [...llmOwnerResolution.ownerKeys];
activeRecallOwnerScores = { ...(llmOwnerResolution.ownerScores || {}) };
sceneOwnerResolutionMode = llmOwnerResolution.mode || "unresolved";
llmMeta = {
enabled: true,
status: llmResult.status,
@@ -1336,6 +1882,17 @@ export async function retrieve({
retrievalMeta,
);
selectedNodeIds = selectedCandidates.map((item) => item.nodeId);
const heuristicResolution = resolveSceneOwnersHeuristically(
sceneOwnerCandidates,
{
mode: "heuristic",
maxOwners: 4,
minScore: 0.55,
},
);
activeRecallOwnerKeys = [...heuristicResolution.ownerKeys];
activeRecallOwnerScores = { ...(heuristicResolution.ownerScores || {}) };
sceneOwnerResolutionMode = heuristicResolution.mode || "unresolved";
llmMeta = {
enabled: false,
status: "disabled",
@@ -1346,7 +1903,20 @@ export async function retrieve({
}
retrievalMeta.timings.diversity = roundMs(nowMs() - diversityStartedAt);
retrievalMeta.timings.llm = roundMs(llmDurationMs);
retrievalMeta.activeRecallOwnerKey = activeRecallOwnerKeys[0] || "";
retrievalMeta.activeRecallOwnerKeys = [...activeRecallOwnerKeys];
retrievalMeta.activeRecallOwnerScores = { ...activeRecallOwnerScores };
retrievalMeta.sceneOwnerResolutionMode = sceneOwnerResolutionMode;
retrievalMeta.activeCharacterPovOwner =
getSceneOwnerNamesByKeys(sceneOwnerCandidates, activeRecallOwnerKeys)[0] || "";
selectedNodeIds = augmentSelectedNodeIdsWithActiveOwnerPov(
graph,
selectedNodeIds,
scoredNodes,
activeRecallOwnerKeys,
normalizedMaxRecallNodes,
);
selectedNodeIds = reconstructSceneNodeIds(
graph,
selectedNodeIds,
@@ -1361,11 +1931,16 @@ export async function retrieve({
const bucket = enableScopedMemory
? classifyNodeScopeBucket(node, {
activeCharacterPovOwner,
activeCharacterPovOwners: getSceneOwnerNamesByKeys(
sceneOwnerCandidates,
activeRecallOwnerKeys,
),
activeUserPovOwner,
activeRegion,
adjacentRegions: adjacentRegionContext.adjacentRegions,
enablePovMemory,
enableRegionScopedObjective,
allowImplicitCharacterPovFallback: false,
})
: MEMORY_SCOPE_BUCKETS.OBJECTIVE_GLOBAL;
pushScopeBucketDebug(acc, bucket, node.id);
@@ -1380,15 +1955,31 @@ export async function retrieve({
mode: String(scored?.knowledgeMode || "selected"),
anchored: Boolean(scored?.knowledgeAnchored),
rescued: Boolean(scored?.knowledgeRescued),
ownerCoverage:
Math.round((Number(scored?.knowledgeOwnerCoverage) || 0) * 1000) /
1000,
visibilityScore:
Math.round((Number(scored?.knowledgeVisibilityScore) || 0) * 1000) /
1000,
visibleOwners: [...(scored?.knowledgeVisibleOwnerKeys || [])],
suppressedOwners: [...(scored?.knowledgeSuppressedOwnerKeys || [])],
},
];
}),
);
if (graph?.historyState && activeRecallOwner.ownerKey) {
pushRecentRecallOwner(graph.historyState, activeRecallOwner.ownerKey);
retrievalMeta.selectedByOwner = buildSelectedByOwner(
graph,
selectedNodes,
scoredNodes,
sceneOwnerCandidates,
);
if (graph?.historyState) {
graph.historyState.activeRecallOwnerKey = activeRecallOwnerKeys[0] || "";
if (activeRecallOwnerKeys.length > 0) {
for (const ownerKey of [...activeRecallOwnerKeys].reverse()) {
pushRecentRecallOwner(graph.historyState, ownerKey);
}
}
}
reinforceAccessBatch(selectedNodes);
@@ -1448,9 +2039,19 @@ export async function retrieve({
injectObjectiveGlobalMemory,
activeRegion,
activeRegionSource: activeRegionContext.source || "",
activeCharacterPovOwner,
activeCharacterPovOwner:
getSceneOwnerNamesByKeys(sceneOwnerCandidates, activeRecallOwnerKeys)[0] ||
activeCharacterPovOwner,
activeUserPovOwner,
activeRecallOwnerKey: activeRecallOwner.ownerKey || "",
activeCharacterPovOwners: getSceneOwnerNamesByKeys(
sceneOwnerCandidates,
activeRecallOwnerKeys,
),
activeRecallOwnerKey: activeRecallOwnerKeys[0] || "",
activeRecallOwnerKeys: [...activeRecallOwnerKeys],
activeRecallOwnerScores: { ...activeRecallOwnerScores },
sceneOwnerResolutionMode,
sceneOwnerCandidates: retrievalMeta.sceneOwnerCandidates,
adjacentRegions: adjacentRegionContext.adjacentRegions,
injectLowConfidenceObjectiveMemory,
graph,
@@ -1604,12 +2205,14 @@ async function llmRecall(
schema,
maxNodes,
customPrompt,
sceneOwnerCandidates = [],
settings = {},
signal,
onStreamProgress = null,
) {
throwIfAborted(signal);
const contextStr = recentMessages.join("\n---\n");
const sceneOwnerCandidateText = buildSceneOwnerCandidateText(sceneOwnerCandidates);
const candidateDescriptions = candidates
.map((c) => {
const node = c.node;
@@ -1628,6 +2231,7 @@ async function llmRecall(
userMessage,
candidateNodes: candidateDescriptions,
candidateText: candidateDescriptions,
sceneOwnerCandidates: sceneOwnerCandidateText,
graphStats: `candidate_count=${candidates.length}`,
...getSTContextForPrompt(),
});
@@ -1639,10 +2243,11 @@ async function llmRecall(
recallPromptBuild.systemPrompt || customPrompt || [
"你是一个记忆召回分析器。",
"根据用户最新输入和对话上下文,从候选记忆节点中选择最相关的节点。",
"你还需要判断这轮真正参与当前回应的具体人物,并返回他们的 ownerKey。",
"优先选择:(1) 直接相关的当前场景节点, (2) 因果关系连续性节点, (3) 有潜在影响的背景节点。",
`最多选择 ${maxNodes} 个节点。`,
"输出严格的 JSON 格式:",
'{"selected_ids": ["id1", "id2", ...], "reason": "简要说明选择理由"}',
'{"selected_ids": ["id1", "id2"], "reason": "简要说明选择理由", "active_owner_keys": ["character:alice"], "active_owner_scores": [{"ownerKey": "character:alice", "score": 0.92, "reason": "她在场并且 POV 最相关"}]}',
].join("\n"),
recallRegexInput,
"system",
@@ -1658,9 +2263,22 @@ async function llmRecall(
"## 候选记忆节点",
candidateDescriptions,
"",
"请选择最相关的节点并输出 JSON。",
"## 场景角色候选",
sceneOwnerCandidateText,
"",
"请选择最相关的节点,并同时返回这轮真正参与当前回应的具体人物 ownerKey。",
].join("\n");
const promptPayload = resolveTaskPromptPayload(recallPromptBuild, userPrompt);
const additionalMessages = Array.isArray(promptPayload.additionalMessages)
? [...promptPayload.additionalMessages]
: [];
additionalMessages.push({
role: "system",
content: buildRecallSceneOwnerAugmentPrompt(
maxNodes,
sceneOwnerCandidateText,
),
});
const llmResult = await callLLMForJSON({
systemPrompt: resolveTaskLlmSystemPrompt(promptPayload, systemPrompt),
@@ -1673,12 +2291,27 @@ async function llmRecall(
recallRegexInput,
),
promptMessages: promptPayload.promptMessages,
additionalMessages: promptPayload.additionalMessages,
additionalMessages,
onStreamProgress,
maxCompletionTokens: Math.max(512, maxNodes * 160),
returnFailureDetails: true,
});
const result = llmResult?.ok ? llmResult.data : null;
const activeOwnerKeys = normalizeOwnerKeyList(
(Array.isArray(result?.active_owner_keys) ? result.active_owner_keys : []).map(
(value) => resolveSceneOwnerKeyFromValue(sceneOwnerCandidates, value),
),
).slice(0, 4);
const activeOwnerScoreEntries = normalizeLlmSceneOwnerScores(
sceneOwnerCandidates,
result?.active_owner_scores,
);
const activeOwnerScores = Object.fromEntries(
activeOwnerScoreEntries.map((entry) => [
entry.ownerKey,
roundOwnerScore(entry.score),
]),
);
if (result?.selected_ids && Array.isArray(result.selected_ids)) {
// 校验 ID 有效性
@@ -1692,6 +2325,9 @@ async function llmRecall(
return {
selectedNodeIds: validIds,
status: "llm",
activeOwnerKeys,
activeOwnerScores,
sceneOwnerResolutionMode: activeOwnerKeys.length > 0 ? "llm" : "fallback",
reason:
validIds.length < result.selected_ids.length
? "LLM 返回了部分无效或超限 ID已自动裁剪"
@@ -1709,6 +2345,9 @@ async function llmRecall(
return {
selectedNodeIds: candidates.slice(0, maxNodes).map((c) => c.nodeId),
status: "fallback",
activeOwnerKeys: [],
activeOwnerScores: {},
sceneOwnerResolutionMode: "fallback",
reason: fallbackReason,
fallbackType: llmResult?.ok ? "invalid-candidate" : llmResult?.errorType || "unknown",
};
@@ -1814,10 +2453,15 @@ function buildResult(graph, selectedNodeIds, schema, meta = {}) {
function buildScopedInjectionBuckets(coreNodes, selectedNodes, scopeContext = {}) {
const buckets = {
characterPov: [],
characterPovByOwner: {},
characterPovOwnerOrder: [],
userPov: [],
objectiveCurrentRegion: [],
objectiveGlobal: [],
};
const activeRecallOwnerKeys = normalizeOwnerKeyList(
scopeContext.activeRecallOwnerKeys || scopeContext.activeRecallOwnerKey || [],
);
const combinedNodes = [
...selectedNodes,
...coreNodes,
@@ -1830,19 +2474,21 @@ function buildScopedInjectionBuckets(coreNodes, selectedNodes, scopeContext = {}
seen.add(node.id);
const bucket = classifyNodeScopeBucket(node, {
activeCharacterPovOwner: scopeContext.activeCharacterPovOwner,
activeCharacterPovOwners: scopeContext.activeCharacterPovOwners || [],
activeUserPovOwner: scopeContext.activeUserPovOwner,
activeRegion: scopeContext.activeRegion,
adjacentRegions: scopeContext.adjacentRegions,
enablePovMemory: scopeContext.enablePovMemory !== false,
enableRegionScopedObjective:
scopeContext.enableRegionScopedObjective !== false,
allowImplicitCharacterPovFallback: false,
});
const knowledgeGate =
scopeContext.enableCognitiveMemory !== false
? computeKnowledgeGateForNode(
scopeContext.graph,
node,
scopeContext.activeRecallOwnerKey,
activeRecallOwnerKeys,
{
scopeBucket: bucket,
injectLowConfidenceObjectiveMemory:
@@ -1855,7 +2501,23 @@ function buildScopedInjectionBuckets(coreNodes, selectedNodes, scopeContext = {}
}
if (bucket === MEMORY_SCOPE_BUCKETS.CHARACTER_POV) {
if (scopeContext.sceneOwnerResolutionMode === "unresolved") {
continue;
}
const ownerKey =
resolveKnowledgeOwnerKeyFromScope(scopeContext.graph, node?.scope) ||
"";
if (activeRecallOwnerKeys.length > 0 && !activeRecallOwnerKeys.includes(ownerKey)) {
continue;
}
buckets.characterPov.push(node);
if (ownerKey) {
buckets.characterPovByOwner[ownerKey] ||= [];
buckets.characterPovByOwner[ownerKey].push(node);
if (!buckets.characterPovOwnerOrder.includes(ownerKey)) {
buckets.characterPovOwnerOrder.push(ownerKey);
}
}
continue;
}
if (bucket === MEMORY_SCOPE_BUCKETS.USER_POV) {
@@ -1877,6 +2539,19 @@ function buildScopedInjectionBuckets(coreNodes, selectedNodes, scopeContext = {}
}
buckets.characterPov.sort(compareNodeRecallOrder);
for (const ownerKey of Object.keys(buckets.characterPovByOwner)) {
buckets.characterPovByOwner[ownerKey].sort(compareNodeRecallOrder);
}
buckets.characterPovOwnerOrder = [
...activeRecallOwnerKeys.filter((ownerKey) =>
buckets.characterPovByOwner[ownerKey]?.length > 0,
),
...buckets.characterPovOwnerOrder.filter(
(ownerKey) =>
!activeRecallOwnerKeys.includes(ownerKey) &&
buckets.characterPovByOwner[ownerKey]?.length > 0,
),
];
buckets.userPov.sort(compareNodeRecallOrder);
buckets.objectiveCurrentRegion.sort(compareNodeRecallOrder);
const cappedGlobal = (scopeContext.injectObjectiveGlobalMemory === false