mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
3055 lines
96 KiB
JavaScript
3055 lines
96 KiB
JavaScript
// ST-BME: 三层混合检索编排
|
||
// 融合向量预筛(PeroCore)+ 图扩散(PeroCore PEDSA)+ 可选 LLM 精确召回
|
||
// v2: + 认知边界过滤(RoleRAG) + 双记忆交叉检索(AriGraph) + 概率触发
|
||
|
||
import { debugLog } from "../runtime/debug-logging.js";
|
||
import { diffuseAndRank } from "./diffusion.js";
|
||
import { hybridScore, reinforceAccessBatch } from "./dynamics.js";
|
||
import {
|
||
buildTemporalAdjacencyMap,
|
||
getActiveNodes,
|
||
getNode,
|
||
getNodeEdges,
|
||
} from "../graph/graph.js";
|
||
import { callLLMForJSON } from "../llm/llm.js";
|
||
import {
|
||
buildTaskExecutionDebugContext,
|
||
buildTaskLlmPayload,
|
||
buildTaskPrompt,
|
||
} from "../prompting/prompt-builder.js";
|
||
import {
|
||
applyCooccurrenceBoost,
|
||
applyDiversitySampling,
|
||
collectSupplementalAnchorNodeIds,
|
||
createCooccurrenceIndex,
|
||
isEligibleAnchorNode,
|
||
mergeVectorResults,
|
||
runResidualRecall,
|
||
splitIntentSegments,
|
||
} from "./retrieval-enhancer.js";
|
||
import {
|
||
MEMORY_SCOPE_BUCKETS,
|
||
classifyNodeScopeBucket,
|
||
describeMemoryScope,
|
||
describeScopeBucket,
|
||
getScopeRegionKey,
|
||
normalizeMemoryScope,
|
||
resolveScopeBucketWeight,
|
||
} from "../graph/memory-scope.js";
|
||
import {
|
||
computeKnowledgeGateForNode,
|
||
listKnowledgeOwners,
|
||
pushRecentRecallOwner,
|
||
resolveActiveRegionContext,
|
||
resolveAdjacentRegions,
|
||
resolveKnowledgeOwner,
|
||
resolveKnowledgeOwnerKeyFromScope,
|
||
} from "../graph/knowledge-state.js";
|
||
import {
|
||
classifyStoryTemporalBucket,
|
||
describeNodeStoryTime,
|
||
resolveActiveStoryContext,
|
||
resolveStoryCueMode,
|
||
STORY_TEMPORAL_BUCKETS,
|
||
} from "../graph/story-timeline.js";
|
||
import { getActiveSummaryEntries } from "../graph/summary-state.js";
|
||
import { applyTaskRegex } from "../prompting/task-regex.js";
|
||
import { getSTContextForPrompt } from "../host/st-context.js";
|
||
import { findSimilarNodesByText, validateVectorConfig } from "../vector/vector-index.js";
|
||
|
||
function createAbortError(message = "操作已终止") {
|
||
const error = new Error(message);
|
||
error.name = "AbortError";
|
||
return error;
|
||
}
|
||
|
||
function createTaskLlmDebugContext(promptBuild, regexInput) {
|
||
return typeof buildTaskExecutionDebugContext === "function"
|
||
? buildTaskExecutionDebugContext(promptBuild, { regexInput })
|
||
: null;
|
||
}
|
||
|
||
function resolveTaskPromptPayload(promptBuild, fallbackUserPrompt = "") {
|
||
if (typeof buildTaskLlmPayload === "function") {
|
||
return buildTaskLlmPayload(promptBuild, fallbackUserPrompt);
|
||
}
|
||
|
||
return {
|
||
systemPrompt: String(promptBuild?.systemPrompt || ""),
|
||
userPrompt: String(fallbackUserPrompt || ""),
|
||
promptMessages: [],
|
||
additionalMessages: Array.isArray(promptBuild?.privateTaskMessages)
|
||
? promptBuild.privateTaskMessages
|
||
: [],
|
||
};
|
||
}
|
||
|
||
function resolveTaskLlmSystemPrompt(promptPayload, fallbackSystemPrompt = "") {
|
||
const hasPromptMessages =
|
||
Array.isArray(promptPayload?.promptMessages) &&
|
||
promptPayload.promptMessages.length > 0;
|
||
if (hasPromptMessages) {
|
||
return String(promptPayload?.systemPrompt || "");
|
||
}
|
||
return String(promptPayload?.systemPrompt || fallbackSystemPrompt || "");
|
||
}
|
||
|
||
function buildRecallFallbackReason(llmResult) {
|
||
const failureType = String(llmResult?.errorType || "").trim();
|
||
const failureReason = String(llmResult?.failureReason || "").trim();
|
||
switch (failureType) {
|
||
case "timeout":
|
||
return "LLM 精排请求超时,已回退到评分排序";
|
||
case "empty-response":
|
||
return "LLM 精排返回空响应,已回退到评分排序";
|
||
case "truncated-json":
|
||
return "LLM 精排输出被截断,已回退到评分排序";
|
||
case "invalid-json":
|
||
return "LLM 精排未返回有效 JSON,已回退到评分排序";
|
||
case "provider-error":
|
||
return failureReason
|
||
? `LLM 精排调用失败(${failureReason}),已回退到评分排序`
|
||
: "LLM 精排调用失败,已回退到评分排序";
|
||
default:
|
||
return failureReason || "LLM 精排未返回可用结果,已回退到评分排序";
|
||
}
|
||
}
|
||
|
||
function isAbortError(error) {
|
||
return error?.name === "AbortError";
|
||
}
|
||
|
||
function throwIfAborted(signal) {
|
||
if (signal?.aborted) {
|
||
throw signal.reason instanceof Error
|
||
? signal.reason
|
||
: createAbortError();
|
||
}
|
||
}
|
||
|
||
function nowMs() {
|
||
return typeof performance !== "undefined" && performance?.now
|
||
? performance.now()
|
||
: Date.now();
|
||
}
|
||
|
||
function roundMs(value) {
|
||
return Math.round((Number(value) || 0) * 10) / 10;
|
||
}
|
||
|
||
function pushSkipReason(meta, reason) {
|
||
if (!reason) return;
|
||
if (!Array.isArray(meta.skipReasons)) {
|
||
meta.skipReasons = [];
|
||
}
|
||
if (!meta.skipReasons.includes(reason)) {
|
||
meta.skipReasons.push(reason);
|
||
}
|
||
}
|
||
|
||
function createRetrievalMeta(enableLLMRecall) {
|
||
return {
|
||
vectorHits: 0,
|
||
diffusionHits: 0,
|
||
scoredCandidates: 0,
|
||
segmentsUsed: [],
|
||
queryBlendActive: false,
|
||
queryBlendParts: [],
|
||
queryBlendWeights: {},
|
||
vectorMergedHits: 0,
|
||
seedCount: 0,
|
||
temporalSyntheticEdgeCount: 0,
|
||
teleportAlpha: 0,
|
||
lexicalBoostedNodes: 0,
|
||
lexicalTopHits: [],
|
||
cooccurrenceBoostedNodes: 0,
|
||
candidatePoolBeforeDpp: 0,
|
||
candidatePoolAfterDpp: 0,
|
||
diversityApplied: false,
|
||
residualTriggered: false,
|
||
residualHits: 0,
|
||
scopeBuckets: {},
|
||
temporalBuckets: {},
|
||
activeRegion: "",
|
||
activeRegionSource: "",
|
||
activeStorySegmentId: "",
|
||
activeStoryTimeLabel: "",
|
||
activeStoryTimeSource: "",
|
||
activeCharacterPovOwner: "",
|
||
activeUserPovOwner: "",
|
||
activeRecallOwnerKey: "",
|
||
activeRecallOwnerKeys: [],
|
||
activeRecallOwnerScores: {},
|
||
sceneOwnerResolutionMode: "unresolved",
|
||
sceneOwnerCandidates: [],
|
||
bucketWeights: {},
|
||
selectedByBucket: {},
|
||
knowledgeGateMode: "disabled",
|
||
knowledgeAnchoredNodes: [],
|
||
knowledgeSuppressedNodes: [],
|
||
knowledgeRescuedNodes: [],
|
||
knowledgeVisibleOwnersByNode: {},
|
||
knowledgeSuppressedOwnersByNode: {},
|
||
visibilityTopHits: [],
|
||
visibilitySuppressedReasons: {},
|
||
adjacentRegionMatches: [],
|
||
temporalSuppressedNodes: [],
|
||
temporalRescuedNodes: [],
|
||
temporalTopHits: [],
|
||
selectedByStoryTime: {},
|
||
timelineAdvanceApplied: false,
|
||
selectedByKnowledgeState: {},
|
||
selectedByOwner: {},
|
||
skipReasons: [],
|
||
timings: {},
|
||
llm: {
|
||
enabled: enableLLMRecall,
|
||
status: enableLLMRecall ? "pending" : "disabled",
|
||
reason: enableLLMRecall ? "" : "LLM 精排已关闭",
|
||
selectionProtocol: "",
|
||
rawSelectedKeys: [],
|
||
resolvedSelectedKeys: [],
|
||
resolvedSelectedNodeIds: [],
|
||
fallbackReason: "",
|
||
fallbackType: "",
|
||
emptySelectionAccepted: false,
|
||
candidateKeyMapPreview: {},
|
||
legacySelectionUsed: false,
|
||
candidatePool: 0,
|
||
selectedSeedCount: 0,
|
||
},
|
||
};
|
||
}
|
||
|
||
function clampPositiveInt(value, fallback, min = 1) {
|
||
const parsed = Math.floor(Number(value));
|
||
return Number.isFinite(parsed) && parsed >= min ? parsed : fallback;
|
||
}
|
||
|
||
function clampRange(value, fallback, min = 0, max = 1) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return fallback;
|
||
return Math.max(min, Math.min(max, parsed));
|
||
}
|
||
|
||
function normalizeQueryText(value, maxLength = 400) {
|
||
const normalized = String(value ?? "")
|
||
.replace(/\r\n/g, "\n")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
if (!normalized) return "";
|
||
return normalized.slice(0, Math.max(1, maxLength));
|
||
}
|
||
|
||
function createTextPreview(text, maxLength = 120) {
|
||
const normalized = normalizeQueryText(text, maxLength + 4);
|
||
if (!normalized) return "";
|
||
return normalized.length > maxLength
|
||
? `${normalized.slice(0, maxLength)}...`
|
||
: normalized;
|
||
}
|
||
|
||
function normalizeRecallSelectionList(values = [], maxLength = 64) {
|
||
const normalized = [];
|
||
const seen = new Set();
|
||
for (const value of Array.isArray(values) ? values : []) {
|
||
const text = String(value || "").trim();
|
||
if (!text || seen.has(text)) continue;
|
||
seen.add(text);
|
||
normalized.push(text);
|
||
if (normalized.length >= maxLength) break;
|
||
}
|
||
return normalized;
|
||
}
|
||
|
||
function getRecallCandidateLabel(node = {}) {
|
||
return String(
|
||
node?.fields?.title ||
|
||
node?.fields?.name ||
|
||
node?.fields?.summary ||
|
||
node?.fields?.insight ||
|
||
node?.fields?.belief ||
|
||
node?.id ||
|
||
"",
|
||
).trim();
|
||
}
|
||
|
||
function createRecallCandidateKeyMaps(candidates = []) {
|
||
const candidateKeyToNodeId = {};
|
||
const candidateKeyToCandidateMeta = {};
|
||
const nodeIdToCandidateKey = {};
|
||
|
||
for (const [index, candidate] of (Array.isArray(candidates) ? candidates : []).entries()) {
|
||
const node = candidate?.node || {};
|
||
const nodeId = String(candidate?.nodeId || node?.id || "").trim();
|
||
if (!nodeId) continue;
|
||
const candidateKey = `R${index + 1}`;
|
||
candidateKeyToNodeId[candidateKey] = nodeId;
|
||
nodeIdToCandidateKey[nodeId] = candidateKey;
|
||
candidateKeyToCandidateMeta[candidateKey] = {
|
||
nodeId,
|
||
type: String(node?.type || ""),
|
||
label: getRecallCandidateLabel(node),
|
||
scopeBucket: String(candidate?.scopeBucket || ""),
|
||
temporalBucket: String(candidate?.temporalBucket || ""),
|
||
score:
|
||
Math.round(
|
||
(Number(candidate?.weightedScore ?? candidate?.finalScore) || 0) * 1000,
|
||
) / 1000,
|
||
};
|
||
}
|
||
|
||
return {
|
||
candidateKeyToNodeId,
|
||
candidateKeyToCandidateMeta,
|
||
nodeIdToCandidateKey,
|
||
};
|
||
}
|
||
|
||
function roundBlendWeight(value) {
|
||
return Math.round((Number(value) || 0) * 1000) / 1000;
|
||
}
|
||
|
||
function uniqueStrings(values = [], maxLength = 400) {
|
||
const result = [];
|
||
const seen = new Set();
|
||
|
||
for (const value of values) {
|
||
const text = normalizeQueryText(value, maxLength);
|
||
const key = text.toLowerCase();
|
||
if (!text || seen.has(key)) continue;
|
||
seen.add(key);
|
||
result.push(text);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
function parseRecallContextLine(line = "") {
|
||
const raw = String(line ?? "").trim();
|
||
if (!raw) return null;
|
||
|
||
const bracketMatch = raw.match(/^\[(user|assistant)\]\s*:\s*([\s\S]*)$/i);
|
||
if (bracketMatch) {
|
||
const role = String(bracketMatch[1] || "").toLowerCase();
|
||
const text = normalizeQueryText(bracketMatch[2] || "");
|
||
return text ? { role, text } : null;
|
||
}
|
||
|
||
const plainMatch = raw.match(
|
||
/^(user|assistant|用户|助手|ai)\s*[::]\s*([\s\S]*)$/i,
|
||
);
|
||
if (!plainMatch) return null;
|
||
|
||
const roleToken = String(plainMatch[1] || "").toLowerCase();
|
||
const role =
|
||
roleToken === "assistant" || roleToken === "助手" || roleToken === "ai"
|
||
? "assistant"
|
||
: "user";
|
||
const text = normalizeQueryText(plainMatch[2] || "");
|
||
return text ? { role, text } : null;
|
||
}
|
||
|
||
function buildContextQueryBlend(
|
||
userMessage,
|
||
recentMessages = [],
|
||
{
|
||
enabled = true,
|
||
assistantWeight = 0.2,
|
||
previousUserWeight = 0.1,
|
||
maxTextLength = 400,
|
||
} = {},
|
||
) {
|
||
const currentText = normalizeQueryText(userMessage, maxTextLength);
|
||
const normalizedAssistantWeight = clampRange(assistantWeight, 0.2, 0, 1);
|
||
const normalizedPreviousUserWeight = clampRange(
|
||
previousUserWeight,
|
||
0.1,
|
||
0,
|
||
1,
|
||
);
|
||
const currentWeight = Math.max(
|
||
0,
|
||
1 - normalizedAssistantWeight - normalizedPreviousUserWeight,
|
||
);
|
||
|
||
let assistantText = "";
|
||
let previousUserText = "";
|
||
const parsedMessages = Array.isArray(recentMessages)
|
||
? recentMessages.map((line) => parseRecallContextLine(line)).filter(Boolean)
|
||
: [];
|
||
|
||
for (let index = parsedMessages.length - 1; index >= 0; index--) {
|
||
const item = parsedMessages[index];
|
||
if (!assistantText && item.role === "assistant") {
|
||
assistantText = normalizeQueryText(item.text, maxTextLength);
|
||
}
|
||
if (
|
||
!previousUserText &&
|
||
item.role === "user" &&
|
||
normalizeQueryText(item.text, maxTextLength).toLowerCase() !==
|
||
currentText.toLowerCase()
|
||
) {
|
||
previousUserText = normalizeQueryText(item.text, maxTextLength);
|
||
}
|
||
if (assistantText && previousUserText) break;
|
||
}
|
||
|
||
const rawParts = [
|
||
{
|
||
kind: "currentUser",
|
||
label: "当前用户消息",
|
||
text: currentText,
|
||
weight: enabled ? currentWeight : 1,
|
||
},
|
||
];
|
||
|
||
if (enabled && assistantText) {
|
||
rawParts.push({
|
||
kind: "assistantContext",
|
||
label: "最近 assistant 回复",
|
||
text: assistantText,
|
||
weight: normalizedAssistantWeight,
|
||
});
|
||
}
|
||
|
||
if (enabled && previousUserText) {
|
||
rawParts.push({
|
||
kind: "previousUser",
|
||
label: "上一条 user 消息",
|
||
text: previousUserText,
|
||
weight: normalizedPreviousUserWeight,
|
||
});
|
||
}
|
||
|
||
const dedupedParts = [];
|
||
const seen = new Set();
|
||
for (const part of rawParts) {
|
||
const text = normalizeQueryText(part.text, maxTextLength);
|
||
const key = text.toLowerCase();
|
||
if (!text || seen.has(key)) continue;
|
||
seen.add(key);
|
||
dedupedParts.push({
|
||
...part,
|
||
text,
|
||
});
|
||
}
|
||
|
||
if (dedupedParts.length === 0) {
|
||
return {
|
||
active: false,
|
||
parts: [],
|
||
currentText: "",
|
||
assistantText: "",
|
||
previousUserText: "",
|
||
combinedText: "",
|
||
};
|
||
}
|
||
|
||
const totalWeight = dedupedParts.reduce(
|
||
(sum, part) => sum + Math.max(0, Number(part.weight) || 0),
|
||
0,
|
||
);
|
||
const normalizedParts = dedupedParts.map((part) => ({
|
||
...part,
|
||
weight:
|
||
totalWeight > 0
|
||
? roundBlendWeight((Math.max(0, Number(part.weight) || 0) || 0) / totalWeight)
|
||
: roundBlendWeight(1 / dedupedParts.length),
|
||
}));
|
||
const combinedText =
|
||
normalizedParts.length <= 1
|
||
? normalizedParts[0]?.text || ""
|
||
: normalizedParts
|
||
.map((part) => `${part.label}:\n${part.text}`)
|
||
.join("\n\n");
|
||
|
||
return {
|
||
active: enabled && normalizedParts.length > 1,
|
||
parts: normalizedParts,
|
||
currentText: currentText || normalizedParts[0]?.text || "",
|
||
assistantText,
|
||
previousUserText,
|
||
combinedText,
|
||
};
|
||
}
|
||
|
||
function buildVectorQueryPlan(
|
||
blendPlan,
|
||
{ enableMultiIntent = true, maxSegments = 4 } = {},
|
||
) {
|
||
const plan = [];
|
||
let currentSegments = [];
|
||
|
||
for (const part of blendPlan?.parts || []) {
|
||
let queries = [part.text];
|
||
if (part.kind === "currentUser" && enableMultiIntent) {
|
||
currentSegments = splitIntentSegments(part.text, { maxSegments });
|
||
queries = uniqueStrings([
|
||
part.text,
|
||
...currentSegments.filter((item) => item !== part.text),
|
||
]);
|
||
} else {
|
||
queries = uniqueStrings([part.text]);
|
||
}
|
||
|
||
plan.push({
|
||
kind: part.kind,
|
||
label: part.label,
|
||
weight: part.weight,
|
||
queries,
|
||
});
|
||
}
|
||
|
||
return {
|
||
plan,
|
||
currentSegments,
|
||
};
|
||
}
|
||
|
||
function buildLexicalQuerySources(
|
||
userMessage,
|
||
{ enableMultiIntent = true, maxSegments = 4 } = {},
|
||
) {
|
||
const currentText = normalizeQueryText(userMessage, 400);
|
||
const segments = enableMultiIntent
|
||
? splitIntentSegments(currentText, { maxSegments })
|
||
: [];
|
||
return {
|
||
sources: uniqueStrings([currentText, ...segments]),
|
||
segments,
|
||
};
|
||
}
|
||
|
||
function normalizeLexicalText(value = "") {
|
||
return normalizeQueryText(value, 600).toLowerCase();
|
||
}
|
||
|
||
function buildLexicalUnits(text = "") {
|
||
const normalized = normalizeLexicalText(text);
|
||
if (!normalized) return [];
|
||
|
||
const rawTokens = normalized.match(/[a-z0-9]+|[\u4e00-\u9fff]+/g) || [];
|
||
const units = [];
|
||
|
||
for (const token of rawTokens) {
|
||
if (token.length >= 2) {
|
||
units.push(token);
|
||
}
|
||
if (/[\u4e00-\u9fff]/.test(token) && token.length > 2) {
|
||
for (let index = 0; index < token.length - 1; index++) {
|
||
units.push(token.slice(index, index + 2));
|
||
}
|
||
}
|
||
}
|
||
|
||
return [...new Set(units)];
|
||
}
|
||
|
||
function computeTokenOverlapScore(sourceUnits = [], targetUnits = []) {
|
||
if (!sourceUnits.length || !targetUnits.length) return 0;
|
||
const targetSet = new Set(targetUnits);
|
||
let overlap = 0;
|
||
for (const unit of sourceUnits) {
|
||
if (targetSet.has(unit)) {
|
||
overlap += 1;
|
||
}
|
||
}
|
||
return overlap / Math.max(1, sourceUnits.length);
|
||
}
|
||
|
||
function scoreFieldMatch(
|
||
fieldText,
|
||
querySources = [],
|
||
{ exact = 1, includes = 0.9, overlap = 0.6 } = {},
|
||
) {
|
||
const normalizedField = normalizeLexicalText(fieldText);
|
||
if (!normalizedField) return 0;
|
||
|
||
const fieldUnits = buildLexicalUnits(normalizedField);
|
||
let best = 0;
|
||
|
||
for (const sourceText of querySources) {
|
||
const normalizedSource = normalizeLexicalText(sourceText);
|
||
if (!normalizedSource) continue;
|
||
|
||
if (normalizedSource === normalizedField) {
|
||
best = Math.max(best, exact);
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
Math.min(normalizedSource.length, normalizedField.length) >= 2 &&
|
||
(normalizedSource.includes(normalizedField) ||
|
||
normalizedField.includes(normalizedSource))
|
||
) {
|
||
best = Math.max(best, includes);
|
||
}
|
||
|
||
const overlapScore = computeTokenOverlapScore(
|
||
buildLexicalUnits(normalizedSource),
|
||
fieldUnits,
|
||
);
|
||
best = Math.max(best, overlapScore * overlap);
|
||
}
|
||
|
||
return Math.min(1, best);
|
||
}
|
||
|
||
function collectNodeLexicalTexts(node, fieldNames = []) {
|
||
const values = [];
|
||
for (const fieldName of fieldNames) {
|
||
const value = node?.fields?.[fieldName];
|
||
if (typeof value === "string" && value.trim()) {
|
||
values.push(value.trim());
|
||
} else if (Array.isArray(value)) {
|
||
for (const item of value) {
|
||
if (typeof item === "string" && item.trim()) {
|
||
values.push(item.trim());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return values;
|
||
}
|
||
|
||
function computeLexicalScore(node, querySources = []) {
|
||
if (!node || !Array.isArray(querySources) || querySources.length === 0) {
|
||
return 0;
|
||
}
|
||
|
||
const primaryTexts = collectNodeLexicalTexts(node, ["name", "title"]);
|
||
const secondaryTexts = collectNodeLexicalTexts(node, [
|
||
"summary",
|
||
"insight",
|
||
"state",
|
||
"traits",
|
||
"participants",
|
||
"status",
|
||
]);
|
||
const combinedText = [...primaryTexts, ...secondaryTexts].join(" ");
|
||
|
||
const primaryScore = primaryTexts.reduce(
|
||
(best, value) =>
|
||
Math.max(
|
||
best,
|
||
scoreFieldMatch(value, querySources, {
|
||
exact: 1,
|
||
includes: 0.92,
|
||
overlap: 0.72,
|
||
}),
|
||
),
|
||
0,
|
||
);
|
||
const secondaryScore = secondaryTexts.reduce(
|
||
(best, value) =>
|
||
Math.max(
|
||
best,
|
||
scoreFieldMatch(value, querySources, {
|
||
exact: 0.82,
|
||
includes: 0.68,
|
||
overlap: 0.52,
|
||
}),
|
||
),
|
||
0,
|
||
);
|
||
const tokenScore = scoreFieldMatch(combinedText, querySources, {
|
||
exact: 0.65,
|
||
includes: 0.55,
|
||
overlap: 0.45,
|
||
});
|
||
|
||
if (primaryScore <= 0 && secondaryScore <= 0 && tokenScore <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
return Math.min(
|
||
1,
|
||
Math.max(
|
||
primaryScore,
|
||
secondaryScore * 0.82,
|
||
tokenScore * 0.7,
|
||
primaryScore * 0.75 + secondaryScore * 0.35 + tokenScore * 0.2,
|
||
),
|
||
);
|
||
}
|
||
|
||
function buildLexicalTopHits(scoredNodes = [], maxCount = 5) {
|
||
return scoredNodes
|
||
.filter((item) => (Number(item?.lexicalScore) || 0) > 0)
|
||
.sort((a, b) => {
|
||
const lexicalDelta =
|
||
(Number(b?.lexicalScore) || 0) - (Number(a?.lexicalScore) || 0);
|
||
if (lexicalDelta !== 0) return lexicalDelta;
|
||
return (Number(b?.finalScore) || 0) - (Number(a?.finalScore) || 0);
|
||
})
|
||
.slice(0, Math.max(1, maxCount))
|
||
.map((item) => ({
|
||
nodeId: item.nodeId,
|
||
type: item.node?.type || "",
|
||
label:
|
||
item.node?.fields?.name ||
|
||
item.node?.fields?.title ||
|
||
item.node?.fields?.summary ||
|
||
item.nodeId,
|
||
lexicalScore: Math.round((Number(item.lexicalScore) || 0) * 1000) / 1000,
|
||
finalScore: Math.round((Number(item.finalScore) || 0) * 1000) / 1000,
|
||
}));
|
||
}
|
||
|
||
function buildVisibilityTopHits(scoredNodes = [], maxCount = 6) {
|
||
return scoredNodes
|
||
.filter((item) => Number(item?.knowledgeVisibilityScore) > 0)
|
||
.sort((a, b) => {
|
||
const visibilityDelta =
|
||
(Number(b?.knowledgeVisibilityScore) || 0) -
|
||
(Number(a?.knowledgeVisibilityScore) || 0);
|
||
if (visibilityDelta !== 0) return visibilityDelta;
|
||
return (Number(b?.weightedScore) || 0) - (Number(a?.weightedScore) || 0);
|
||
})
|
||
.slice(0, Math.max(1, maxCount))
|
||
.map((item) => ({
|
||
nodeId: item.nodeId,
|
||
type: item.node?.type || "",
|
||
label:
|
||
item.node?.fields?.name ||
|
||
item.node?.fields?.title ||
|
||
item.node?.fields?.summary ||
|
||
item.nodeId,
|
||
visibilityScore:
|
||
Math.round((Number(item.knowledgeVisibilityScore) || 0) * 1000) / 1000,
|
||
knowledgeMode: String(item.knowledgeMode || ""),
|
||
}));
|
||
}
|
||
|
||
function scaleVectorResults(results = [], weight = 1) {
|
||
return (Array.isArray(results) ? results : []).map((item) => ({
|
||
...item,
|
||
score: (Number(item?.score) || 0) * Math.max(0, Number(weight) || 0),
|
||
}));
|
||
}
|
||
|
||
function pickActiveRegion(graph, optionValue = "") {
|
||
const direct = String(optionValue || "").trim();
|
||
if (direct) return direct;
|
||
|
||
const historyRegion = String(
|
||
graph?.historyState?.activeRegion || graph?.historyState?.lastExtractedRegion || "",
|
||
).trim();
|
||
if (historyRegion) return historyRegion;
|
||
|
||
const fallback = getActiveNodes(graph)
|
||
.filter((node) => !node.archived)
|
||
.sort((a, b) => (b.seqRange?.[1] ?? b.seq ?? 0) - (a.seqRange?.[1] ?? a.seq ?? 0))
|
||
.find((node) => getScopeRegionKey(node?.scope));
|
||
|
||
return String(getScopeRegionKey(fallback?.scope) || "").trim();
|
||
}
|
||
|
||
function buildScopeBucketWeightMap(options = {}) {
|
||
return {
|
||
[MEMORY_SCOPE_BUCKETS.CHARACTER_POV]: Number(
|
||
options.recallCharacterPovWeight ?? 1.25,
|
||
),
|
||
[MEMORY_SCOPE_BUCKETS.USER_POV]: Number(options.recallUserPovWeight ?? 1.05),
|
||
[MEMORY_SCOPE_BUCKETS.OBJECTIVE_CURRENT_REGION]: Number(
|
||
options.recallObjectiveCurrentRegionWeight ?? 1.15,
|
||
),
|
||
[MEMORY_SCOPE_BUCKETS.OBJECTIVE_ADJACENT_REGION]: Number(
|
||
options.recallObjectiveAdjacentRegionWeight ?? 0.9,
|
||
),
|
||
[MEMORY_SCOPE_BUCKETS.OBJECTIVE_GLOBAL]: Number(
|
||
options.recallObjectiveGlobalWeight ?? 0.75,
|
||
),
|
||
[MEMORY_SCOPE_BUCKETS.OTHER_POV]: 0.6,
|
||
};
|
||
}
|
||
|
||
function createEmptyScopeBucketMap() {
|
||
return {
|
||
[MEMORY_SCOPE_BUCKETS.CHARACTER_POV]: [],
|
||
[MEMORY_SCOPE_BUCKETS.USER_POV]: [],
|
||
[MEMORY_SCOPE_BUCKETS.OBJECTIVE_CURRENT_REGION]: [],
|
||
[MEMORY_SCOPE_BUCKETS.OBJECTIVE_ADJACENT_REGION]: [],
|
||
[MEMORY_SCOPE_BUCKETS.OBJECTIVE_GLOBAL]: [],
|
||
};
|
||
}
|
||
|
||
function createEmptyTemporalBucketMap() {
|
||
return {
|
||
[STORY_TEMPORAL_BUCKETS.CURRENT]: [],
|
||
[STORY_TEMPORAL_BUCKETS.ADJACENT_PAST]: [],
|
||
[STORY_TEMPORAL_BUCKETS.DISTANT_PAST]: [],
|
||
[STORY_TEMPORAL_BUCKETS.FLASHBACK]: [],
|
||
[STORY_TEMPORAL_BUCKETS.FUTURE]: [],
|
||
[STORY_TEMPORAL_BUCKETS.UNDATED]: [],
|
||
};
|
||
}
|
||
|
||
function pushScopeBucketDebug(map, bucket, value) {
|
||
if (!Object.prototype.hasOwnProperty.call(map, bucket)) {
|
||
map[bucket] = [];
|
||
}
|
||
map[bucket].push(value);
|
||
}
|
||
|
||
function getScopeBucketPriority(bucket) {
|
||
switch (bucket) {
|
||
case MEMORY_SCOPE_BUCKETS.CHARACTER_POV:
|
||
return 5;
|
||
case MEMORY_SCOPE_BUCKETS.USER_POV:
|
||
return 4;
|
||
case MEMORY_SCOPE_BUCKETS.OBJECTIVE_CURRENT_REGION:
|
||
return 3;
|
||
case MEMORY_SCOPE_BUCKETS.OBJECTIVE_ADJACENT_REGION:
|
||
return 2;
|
||
case MEMORY_SCOPE_BUCKETS.OBJECTIVE_GLOBAL:
|
||
return 1;
|
||
default:
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
function getTemporalBucketPriority(bucket) {
|
||
switch (bucket) {
|
||
case STORY_TEMPORAL_BUCKETS.CURRENT:
|
||
return 5;
|
||
case STORY_TEMPORAL_BUCKETS.ADJACENT_PAST:
|
||
return 4;
|
||
case STORY_TEMPORAL_BUCKETS.UNDATED:
|
||
return 3;
|
||
case STORY_TEMPORAL_BUCKETS.FLASHBACK:
|
||
return 2;
|
||
case STORY_TEMPORAL_BUCKETS.DISTANT_PAST:
|
||
return 1;
|
||
case STORY_TEMPORAL_BUCKETS.FUTURE:
|
||
return 0;
|
||
default:
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
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_keys,你还需要同时判断这轮场景里真正参与当前回应的具体人物。",
|
||
`最多返回 ${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。",
|
||
"selected_keys 只能从当前候选短键里选;如果一个都不选,系统会回退到评分召回。",
|
||
"如果某个客观事实只被部分人物知道,也要保留这些具体人物的判断,不要把所有人混成一个总角色。",
|
||
"",
|
||
"## 场景角色候选",
|
||
sceneOwnerCandidateText || "(无)",
|
||
].join("\n");
|
||
}
|
||
|
||
/**
|
||
* 三层混合检索管线
|
||
*
|
||
* @param {object} params
|
||
* @param {object} params.graph - 当前图状态
|
||
* @param {string} params.userMessage - 用户输入
|
||
* @param {string[]} params.recentMessages - 最近几轮对话内容
|
||
* @param {object} params.embeddingConfig - Embedding API 配置
|
||
* @param {object[]} params.schema - 节点类型 Schema
|
||
* @param {object} [params.options] - 检索选项
|
||
* @returns {Promise<RetrievalResult>}
|
||
*/
|
||
export async function retrieve({
|
||
graph,
|
||
userMessage,
|
||
recentMessages = [],
|
||
embeddingConfig,
|
||
schema,
|
||
signal = undefined,
|
||
options = {},
|
||
settings = {},
|
||
onStreamProgress = null,
|
||
}) {
|
||
throwIfAborted(signal);
|
||
const startedAt = nowMs();
|
||
const topK = clampPositiveInt(options.topK, 20);
|
||
const maxRecallNodes = clampPositiveInt(options.maxRecallNodes, 8);
|
||
const enableLLMRecall = options.enableLLMRecall ?? true;
|
||
const enableVectorPrefilter = options.enableVectorPrefilter ?? true;
|
||
const enableGraphDiffusion = options.enableGraphDiffusion ?? true;
|
||
const diffusionTopK = clampPositiveInt(options.diffusionTopK, 100);
|
||
const llmCandidatePool = clampPositiveInt(options.llmCandidatePool, 30);
|
||
const weights = options.weights ?? {};
|
||
const enableVisibility = options.enableVisibility ?? false;
|
||
const visibilityFilter = options.visibilityFilter ?? null;
|
||
const enableCrossRecall = options.enableCrossRecall ?? false;
|
||
const enableProbRecall = options.enableProbRecall ?? false;
|
||
const probRecallChance = options.probRecallChance ?? 0.15;
|
||
const enableMultiIntent = options.enableMultiIntent ?? true;
|
||
const multiIntentMaxSegments = clampPositiveInt(
|
||
options.multiIntentMaxSegments,
|
||
4,
|
||
);
|
||
const teleportAlpha = clampRange(options.teleportAlpha, 0.15);
|
||
const enableTemporalLinks = options.enableTemporalLinks ?? true;
|
||
const temporalLinkStrength = clampRange(
|
||
options.temporalLinkStrength,
|
||
0.2,
|
||
);
|
||
const enableDiversitySampling = options.enableDiversitySampling ?? true;
|
||
const dppCandidateMultiplier = clampPositiveInt(
|
||
options.dppCandidateMultiplier,
|
||
3,
|
||
);
|
||
const dppQualityWeight = clampRange(
|
||
options.dppQualityWeight,
|
||
1.0,
|
||
0,
|
||
10,
|
||
);
|
||
const enableCooccurrenceBoost = options.enableCooccurrenceBoost ?? false;
|
||
const cooccurrenceScale = clampRange(
|
||
options.cooccurrenceScale,
|
||
0.1,
|
||
0,
|
||
10,
|
||
);
|
||
const cooccurrenceMaxNeighbors = clampPositiveInt(
|
||
options.cooccurrenceMaxNeighbors,
|
||
10,
|
||
);
|
||
const enableResidualRecall = options.enableResidualRecall ?? false;
|
||
const residualBasisMaxNodes = clampPositiveInt(
|
||
options.residualBasisMaxNodes,
|
||
24,
|
||
2,
|
||
);
|
||
const residualNmfTopics = clampPositiveInt(options.residualNmfTopics, 15);
|
||
const residualNmfNoveltyThreshold = clampRange(
|
||
options.residualNmfNoveltyThreshold,
|
||
0.4,
|
||
);
|
||
const residualThreshold = clampRange(
|
||
options.residualThreshold,
|
||
0.3,
|
||
0,
|
||
10,
|
||
);
|
||
const residualTopK = clampPositiveInt(options.residualTopK, 5);
|
||
const enableContextQueryBlend = options.enableContextQueryBlend ?? true;
|
||
const contextAssistantWeight = clampRange(
|
||
options.contextAssistantWeight,
|
||
0.2,
|
||
0,
|
||
1,
|
||
);
|
||
const contextPreviousUserWeight = clampRange(
|
||
options.contextPreviousUserWeight,
|
||
0.1,
|
||
0,
|
||
1,
|
||
);
|
||
const enableLexicalBoost = options.enableLexicalBoost ?? true;
|
||
const lexicalWeight = clampRange(options.lexicalWeight, 0.18, 0, 10);
|
||
const enableScopedMemory = options.enableScopedMemory ?? true;
|
||
const enablePovMemory = options.enablePovMemory ?? true;
|
||
const enableRegionScopedObjective =
|
||
options.enableRegionScopedObjective ?? true;
|
||
const enableCognitiveMemory = options.enableCognitiveMemory ?? true;
|
||
const enableSpatialAdjacency = options.enableSpatialAdjacency ?? true;
|
||
const injectLowConfidenceObjectiveMemory =
|
||
options.injectLowConfidenceObjectiveMemory ?? false;
|
||
const injectUserPovMemory = options.injectUserPovMemory ?? true;
|
||
const injectObjectiveGlobalMemory = options.injectObjectiveGlobalMemory ?? true;
|
||
const enableStoryTimeline = options.enableStoryTimeline ?? true;
|
||
const injectStoryTimeLabel = options.injectStoryTimeLabel ?? true;
|
||
const storyTimeSoftDirecting = options.storyTimeSoftDirecting ?? 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(
|
||
legacyOwnerCandidate?.ownerName || "",
|
||
).trim();
|
||
const activeUserPovOwner = String(
|
||
options.activeUserPovOwner ||
|
||
graph?.historyState?.activeUserPovOwner ||
|
||
stPromptContext?.userName ||
|
||
"",
|
||
).trim();
|
||
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 || "",
|
||
);
|
||
const activeRegion = activeRegionContext.activeRegion || pickActiveRegion(graph, options.activeRegion);
|
||
const adjacentRegionContext = enableSpatialAdjacency
|
||
? resolveAdjacentRegions(graph, activeRegion)
|
||
: { adjacentRegions: [] };
|
||
const storyCueMode = enableStoryTimeline
|
||
? resolveStoryCueMode(userMessage, recentMessages)
|
||
: "";
|
||
const activeStoryContext = enableStoryTimeline
|
||
? resolveActiveStoryContext(graph, {
|
||
segmentId: options.activeStorySegmentId || "",
|
||
label: options.activeStoryTimeLabel || "",
|
||
})
|
||
: {
|
||
activeSegmentId: "",
|
||
activeStoryTimeLabel: "",
|
||
source: "",
|
||
segment: null,
|
||
resolved: false,
|
||
};
|
||
const bucketWeights = buildScopeBucketWeightMap(options);
|
||
|
||
let activeNodes = getActiveNodes(graph).filter(
|
||
(node) =>
|
||
!node.archived &&
|
||
Array.isArray(node.seqRange) &&
|
||
Number.isFinite(node.seqRange[1]),
|
||
);
|
||
|
||
if (enableVisibility && visibilityFilter) {
|
||
activeNodes = filterByVisibility(activeNodes, visibilityFilter);
|
||
}
|
||
|
||
const nodeCount = activeNodes.length;
|
||
const normalizedTopK = Math.max(1, topK);
|
||
const normalizedMaxRecallNodes = Math.max(1, maxRecallNodes);
|
||
const normalizedDiffusionTopK = Math.max(1, diffusionTopK);
|
||
const normalizedLlmCandidatePool = Math.max(
|
||
normalizedMaxRecallNodes,
|
||
llmCandidatePool,
|
||
);
|
||
const vectorValidation = validateVectorConfig(embeddingConfig);
|
||
const retrievalMeta = createRetrievalMeta(enableLLMRecall);
|
||
retrievalMeta.activeRegion = activeRegion;
|
||
retrievalMeta.activeRegionSource = activeRegionContext.source || "";
|
||
retrievalMeta.activeStorySegmentId = activeStoryContext.activeSegmentId || "";
|
||
retrievalMeta.activeStoryTimeLabel = activeStoryContext.activeStoryTimeLabel || "";
|
||
retrievalMeta.activeStoryTimeSource = activeStoryContext.source || "";
|
||
retrievalMeta.activeCharacterPovOwner =
|
||
activeCharacterPovOwner ||
|
||
preliminarySceneOwnerCandidates[0]?.ownerName ||
|
||
"";
|
||
retrievalMeta.activeUserPovOwner = activeUserPovOwner;
|
||
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.temporalBuckets = createEmptyTemporalBucketMap();
|
||
retrievalMeta.knowledgeGateMode = enableCognitiveMemory
|
||
? "anchored-soft-visibility"
|
||
: "disabled";
|
||
const contextQueryBlend = buildContextQueryBlend(userMessage, recentMessages, {
|
||
enabled: enableContextQueryBlend,
|
||
assistantWeight: contextAssistantWeight,
|
||
previousUserWeight: contextPreviousUserWeight,
|
||
});
|
||
retrievalMeta.queryBlendActive = contextQueryBlend.active;
|
||
retrievalMeta.queryBlendParts = (contextQueryBlend.parts || []).map((part) => ({
|
||
kind: part.kind,
|
||
label: part.label,
|
||
weight: part.weight,
|
||
text: createTextPreview(part.text),
|
||
length: part.text.length,
|
||
}));
|
||
retrievalMeta.queryBlendWeights = Object.fromEntries(
|
||
(contextQueryBlend.parts || []).map((part) => [part.kind, part.weight]),
|
||
);
|
||
const lexicalQuery = buildLexicalQuerySources(
|
||
contextQueryBlend.currentText || userMessage,
|
||
{
|
||
enableMultiIntent,
|
||
maxSegments: multiIntentMaxSegments,
|
||
},
|
||
);
|
||
debugLog(
|
||
`[ST-BME] 检索开始: ${nodeCount} 个活跃节点${enableVisibility ? " (认知边界已启用)" : ""}`,
|
||
);
|
||
|
||
let vectorResults = [];
|
||
let diffusionResults = [];
|
||
let llmMeta = { ...retrievalMeta.llm };
|
||
const exactEntityAnchors = [];
|
||
let supplementalAnchorNodeIds = [];
|
||
|
||
if (nodeCount === 0) {
|
||
return buildResult(graph, [], schema, {
|
||
retrieval: {
|
||
...retrievalMeta,
|
||
llm: {
|
||
...llmMeta,
|
||
status: enableLLMRecall ? "skipped" : "disabled",
|
||
reason: "当前没有可参与召回的活跃节点",
|
||
},
|
||
timings: {
|
||
total: roundMs(nowMs() - startedAt),
|
||
},
|
||
},
|
||
scopeContext: {
|
||
enableScopedMemory,
|
||
enablePovMemory,
|
||
enableRegionScopedObjective,
|
||
enableCognitiveMemory,
|
||
enableStoryTimeline,
|
||
injectUserPovMemory,
|
||
injectObjectiveGlobalMemory,
|
||
activeRegion,
|
||
activeRegionSource: activeRegionContext.source || "",
|
||
activeStorySegmentId: activeStoryContext.activeSegmentId || "",
|
||
activeStoryTimeLabel: activeStoryContext.activeStoryTimeLabel || "",
|
||
activeStoryTimeSource: activeStoryContext.source || "",
|
||
injectStoryTimeLabel,
|
||
activeCharacterPovOwner,
|
||
activeUserPovOwner,
|
||
activeCharacterPovOwners: preliminarySceneOwnerCandidates.map(
|
||
(candidate) => candidate.ownerName,
|
||
),
|
||
activeRecallOwnerKey: activeRecallOwnerKeys[0] || "",
|
||
activeRecallOwnerKeys: [...activeRecallOwnerKeys],
|
||
activeRecallOwnerScores: { ...activeRecallOwnerScores },
|
||
sceneOwnerResolutionMode,
|
||
sceneOwnerCandidates: retrievalMeta.sceneOwnerCandidates,
|
||
adjacentRegions: adjacentRegionContext.adjacentRegions,
|
||
injectLowConfidenceObjectiveMemory,
|
||
graph,
|
||
bucketWeights,
|
||
},
|
||
});
|
||
}
|
||
|
||
const vectorStartedAt = nowMs();
|
||
if (enableVectorPrefilter && vectorValidation.valid) {
|
||
debugLog("[ST-BME] 第1层: 向量预筛");
|
||
const queryPlan = buildVectorQueryPlan(contextQueryBlend, {
|
||
enableMultiIntent,
|
||
maxSegments: multiIntentMaxSegments,
|
||
});
|
||
const groups = [];
|
||
|
||
retrievalMeta.segmentsUsed = queryPlan.currentSegments;
|
||
for (const part of queryPlan.plan) {
|
||
for (const queryText of part.queries) {
|
||
const results = await vectorPreFilter(
|
||
graph,
|
||
queryText,
|
||
activeNodes,
|
||
embeddingConfig,
|
||
normalizedTopK,
|
||
signal,
|
||
);
|
||
groups.push(scaleVectorResults(results, part.weight || 1));
|
||
}
|
||
}
|
||
|
||
const merged = mergeVectorResults(
|
||
groups,
|
||
Math.max(normalizedTopK * 2, 24),
|
||
);
|
||
retrievalMeta.vectorHits = merged.rawHitCount;
|
||
retrievalMeta.vectorMergedHits = merged.results.length;
|
||
vectorResults = merged.results;
|
||
} else if (enableVectorPrefilter) {
|
||
pushSkipReason(retrievalMeta, "vector-config-invalid");
|
||
}
|
||
retrievalMeta.timings.vector = roundMs(nowMs() - vectorStartedAt);
|
||
|
||
exactEntityAnchors.push(
|
||
...extractEntityAnchors(
|
||
contextQueryBlend.currentText || userMessage,
|
||
activeNodes,
|
||
),
|
||
);
|
||
supplementalAnchorNodeIds = collectSupplementalAnchorNodeIds(
|
||
graph,
|
||
vectorResults,
|
||
exactEntityAnchors.map((item) => item.nodeId),
|
||
5,
|
||
);
|
||
|
||
let residualResult = {
|
||
triggered: false,
|
||
hits: [],
|
||
skipReason: "",
|
||
};
|
||
const residualStartedAt = nowMs();
|
||
if (enableResidualRecall) {
|
||
const basisNodes = buildResidualBasisNodes(
|
||
graph,
|
||
exactEntityAnchors,
|
||
vectorResults,
|
||
residualBasisMaxNodes,
|
||
);
|
||
residualResult = await runResidualRecall({
|
||
queryText: contextQueryBlend.combinedText || userMessage,
|
||
graph,
|
||
embeddingConfig,
|
||
basisNodes,
|
||
candidateNodes: activeNodes,
|
||
basisLimit: residualBasisMaxNodes,
|
||
nTopics: residualNmfTopics,
|
||
noveltyThreshold: residualNmfNoveltyThreshold,
|
||
residualThreshold,
|
||
residualTopK,
|
||
signal,
|
||
});
|
||
retrievalMeta.residualTriggered = Boolean(residualResult.triggered);
|
||
retrievalMeta.residualHits = residualResult.hits?.length || 0;
|
||
pushSkipReason(retrievalMeta, residualResult.skipReason);
|
||
}
|
||
retrievalMeta.timings.residual = roundMs(nowMs() - residualStartedAt);
|
||
|
||
const diffusionStartedAt = nowMs();
|
||
if (enableGraphDiffusion) {
|
||
debugLog("[ST-BME] 第2层: PEDSA 图扩散");
|
||
const seeds = [
|
||
...vectorResults.map((v) => ({ id: v.nodeId, energy: v.score })),
|
||
...exactEntityAnchors.map((item) => ({ id: item.nodeId, energy: 2.0 })),
|
||
...(residualResult.hits || []).map((item) => ({
|
||
id: item.nodeId,
|
||
energy: item.score,
|
||
})),
|
||
];
|
||
|
||
if (enableCrossRecall && exactEntityAnchors.length > 0) {
|
||
for (const anchor of exactEntityAnchors) {
|
||
const connectedEdges = getNodeEdges(graph, anchor.nodeId);
|
||
for (const edge of connectedEdges) {
|
||
if (edge.invalidAt) continue;
|
||
const neighborId =
|
||
edge.fromId === anchor.nodeId ? edge.toId : edge.fromId;
|
||
const neighbor = getNode(graph, neighborId);
|
||
if (neighbor && !neighbor.archived && neighbor.type === "event") {
|
||
seeds.push({ id: neighborId, energy: 1.5 * edge.strength });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const seedMap = new Map();
|
||
for (const s of seeds) {
|
||
const existing = seedMap.get(s.id) || 0;
|
||
if (s.energy > existing) seedMap.set(s.id, s.energy);
|
||
}
|
||
const uniqueSeeds = [...seedMap.entries()].map(([id, energy]) => ({
|
||
id,
|
||
energy,
|
||
}));
|
||
retrievalMeta.seedCount = uniqueSeeds.length;
|
||
|
||
if (uniqueSeeds.length > 0) {
|
||
const adjacencyMap = buildTemporalAdjacencyMap(graph, {
|
||
includeTemporalLinks: enableTemporalLinks,
|
||
temporalLinkStrength,
|
||
});
|
||
retrievalMeta.temporalSyntheticEdgeCount =
|
||
Number(adjacencyMap.syntheticEdgeCount) || 0;
|
||
retrievalMeta.teleportAlpha = teleportAlpha;
|
||
diffusionResults = diffuseAndRank(adjacencyMap, uniqueSeeds, {
|
||
maxSteps: 2,
|
||
decayFactor: 0.6,
|
||
topK: normalizedDiffusionTopK,
|
||
teleportAlpha,
|
||
}).filter((item) => {
|
||
const node = getNode(graph, item.nodeId);
|
||
return node && !node.archived;
|
||
});
|
||
}
|
||
}
|
||
retrievalMeta.diffusionHits = diffusionResults.length;
|
||
retrievalMeta.timings.diffusion = roundMs(nowMs() - diffusionStartedAt);
|
||
|
||
debugLog("[ST-BME] 第3层: 混合评分");
|
||
|
||
const scoreMap = new Map();
|
||
|
||
for (const v of vectorResults) {
|
||
const entry = scoreMap.get(v.nodeId) || { graphScore: 0, vectorScore: 0 };
|
||
entry.vectorScore = v.score;
|
||
scoreMap.set(v.nodeId, entry);
|
||
}
|
||
|
||
for (const d of diffusionResults) {
|
||
const entry = scoreMap.get(d.nodeId) || { graphScore: 0, vectorScore: 0 };
|
||
entry.graphScore = d.energy;
|
||
scoreMap.set(d.nodeId, entry);
|
||
}
|
||
|
||
if (scoreMap.size === 0) {
|
||
for (const node of activeNodes) {
|
||
if (!scoreMap.has(node.id)) {
|
||
scoreMap.set(node.id, { graphScore: 0, vectorScore: 0 });
|
||
}
|
||
}
|
||
}
|
||
|
||
const cooccurrenceStartedAt = nowMs();
|
||
if (enableCooccurrenceBoost) {
|
||
const anchorWeights = new Map();
|
||
for (const anchor of exactEntityAnchors) {
|
||
anchorWeights.set(anchor.nodeId, 2.0);
|
||
}
|
||
for (const nodeId of supplementalAnchorNodeIds) {
|
||
const fallbackWeight =
|
||
scoreMap.get(nodeId)?.vectorScore ||
|
||
scoreMap.get(nodeId)?.graphScore ||
|
||
0.5;
|
||
anchorWeights.set(
|
||
nodeId,
|
||
Math.max(anchorWeights.get(nodeId) || 0, fallbackWeight),
|
||
);
|
||
}
|
||
|
||
if (anchorWeights.size > 0) {
|
||
const cooccurrenceIndex = createCooccurrenceIndex(graph, {
|
||
maxAnchorsPerBatch: 10,
|
||
eligibleNodes: activeNodes.filter(isEligibleAnchorNode),
|
||
});
|
||
const graphScores = new Map(
|
||
[...scoreMap.entries()].map(([nodeId, value]) => [
|
||
nodeId,
|
||
value.graphScore || 0,
|
||
]),
|
||
);
|
||
const boosted = applyCooccurrenceBoost(
|
||
graphScores,
|
||
anchorWeights,
|
||
cooccurrenceIndex,
|
||
{
|
||
scale: cooccurrenceScale,
|
||
maxNeighbors: cooccurrenceMaxNeighbors,
|
||
},
|
||
);
|
||
retrievalMeta.cooccurrenceBoostedNodes = boosted.boostedNodes.length;
|
||
|
||
for (const [nodeId, boostedScore] of boosted.scores.entries()) {
|
||
const entry = scoreMap.get(nodeId) || { graphScore: 0, vectorScore: 0 };
|
||
entry.graphScore = boostedScore;
|
||
scoreMap.set(nodeId, entry);
|
||
}
|
||
if (boosted.boostedNodes.length === 0) {
|
||
pushSkipReason(retrievalMeta, "cooccurrence-no-neighbors");
|
||
}
|
||
} else {
|
||
pushSkipReason(retrievalMeta, "cooccurrence-no-anchor");
|
||
}
|
||
}
|
||
retrievalMeta.timings.cooccurrence = roundMs(nowMs() - cooccurrenceStartedAt);
|
||
|
||
const scoringStartedAt = nowMs();
|
||
const scoredNodes = [];
|
||
for (const [nodeId, scores] of scoreMap) {
|
||
const node = getNode(graph, nodeId);
|
||
if (!node || node.archived) continue;
|
||
const lexicalScore = enableLexicalBoost
|
||
? computeLexicalScore(node, lexicalQuery.sources)
|
||
: 0;
|
||
|
||
const finalScore = hybridScore(
|
||
{
|
||
graphScore: scores.graphScore,
|
||
vectorScore: scores.vectorScore,
|
||
lexicalScore,
|
||
importance: node.importance,
|
||
createdTime: node.createdTime,
|
||
},
|
||
{
|
||
...weights,
|
||
lexicalWeight: enableLexicalBoost ? lexicalWeight : 0,
|
||
},
|
||
);
|
||
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 temporalDirector = enableStoryTimeline
|
||
? classifyStoryTemporalBucket(graph, node, {
|
||
activeSegmentId: activeStoryContext.activeSegmentId,
|
||
cueMode: storyCueMode,
|
||
})
|
||
: {
|
||
bucket: STORY_TEMPORAL_BUCKETS.UNDATED,
|
||
weight: 1,
|
||
suppressed: false,
|
||
rescued: false,
|
||
reason: "disabled",
|
||
};
|
||
const knowledgeGate = enableCognitiveMemory
|
||
? computeKnowledgeGateForNode(
|
||
graph,
|
||
node,
|
||
activeRecallOwnerKeys,
|
||
{
|
||
vectorScore: scores.vectorScore,
|
||
graphScore: scores.graphScore,
|
||
lexicalScore,
|
||
scopeBucket,
|
||
injectLowConfidenceObjectiveMemory,
|
||
},
|
||
)
|
||
: {
|
||
visible: true,
|
||
anchored: false,
|
||
rescued: false,
|
||
suppressed: false,
|
||
suppressedReason: "",
|
||
visibilityScore: 0,
|
||
mode: "disabled",
|
||
};
|
||
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) {
|
||
retrievalMeta.visibilitySuppressedReasons[nodeId] =
|
||
knowledgeGate.suppressedReason;
|
||
}
|
||
continue;
|
||
}
|
||
if (knowledgeGate.anchored) {
|
||
retrievalMeta.knowledgeAnchoredNodes.push(nodeId);
|
||
}
|
||
if (knowledgeGate.rescued) {
|
||
retrievalMeta.knowledgeRescuedNodes.push(nodeId);
|
||
}
|
||
pushScopeBucketDebug(
|
||
retrievalMeta.temporalBuckets,
|
||
temporalDirector.bucket,
|
||
nodeId,
|
||
);
|
||
if (storyTimeSoftDirecting && temporalDirector.suppressed) {
|
||
retrievalMeta.temporalSuppressedNodes.push(nodeId);
|
||
continue;
|
||
}
|
||
if (temporalDirector.rescued) {
|
||
retrievalMeta.temporalRescuedNodes.push(nodeId);
|
||
}
|
||
const scopeWeight = enableScopedMemory
|
||
? resolveScopeBucketWeight(scopeBucket, bucketWeights)
|
||
: 1;
|
||
const knowledgeWeight = enableCognitiveMemory
|
||
? knowledgeGate.anchored
|
||
? 1.18
|
||
: knowledgeGate.rescued
|
||
? 0.92
|
||
: Math.max(0.35, 0.55 + Number(knowledgeGate.visibilityScore || 0) * 0.6)
|
||
: 1;
|
||
const ownerCoverageWeight = enableCognitiveMemory
|
||
? 1 + Math.max(0, Number(knowledgeGate.ownerCoverage || 0) - 1 / Math.max(1, activeRecallOwnerKeys.length || 1)) * 0.08
|
||
: 1;
|
||
const temporalWeight =
|
||
enableStoryTimeline && storyTimeSoftDirecting
|
||
? Number(temporalDirector.weight || 1)
|
||
: 1;
|
||
const weightedScore =
|
||
finalScore *
|
||
scopeWeight *
|
||
knowledgeWeight *
|
||
ownerCoverageWeight *
|
||
temporalWeight;
|
||
|
||
scoredNodes.push({
|
||
nodeId,
|
||
node,
|
||
finalScore,
|
||
weightedScore,
|
||
lexicalScore,
|
||
scopeBucket,
|
||
scopeWeight,
|
||
knowledgeMode: knowledgeGate.mode,
|
||
knowledgeVisibilityScore: Number(knowledgeGate.visibilityScore || 0),
|
||
knowledgeWeight,
|
||
knowledgeAnchored: Boolean(knowledgeGate.anchored),
|
||
knowledgeRescued: Boolean(knowledgeGate.rescued),
|
||
knowledgeOwnerCoverage: Number(knowledgeGate.ownerCoverage || 0),
|
||
knowledgeVisibleOwnerKeys: [...(knowledgeGate.visibleOwnerKeys || [])],
|
||
knowledgeSuppressedOwnerKeys: [...(knowledgeGate.suppressedOwnerKeys || [])],
|
||
ownerCoverageWeight,
|
||
storyTimeLabel: describeNodeStoryTime(node),
|
||
temporalBucket: temporalDirector.bucket,
|
||
temporalWeight,
|
||
temporalSuppressed: Boolean(temporalDirector.suppressed),
|
||
temporalRescued: Boolean(temporalDirector.rescued),
|
||
temporalReason: String(temporalDirector.reason || ""),
|
||
...scores,
|
||
});
|
||
pushScopeBucketDebug(
|
||
retrievalMeta.scopeBuckets,
|
||
scopeBucket,
|
||
nodeId,
|
||
);
|
||
}
|
||
|
||
scoredNodes.sort((a, b) => {
|
||
const bucketDelta =
|
||
getScopeBucketPriority(b.scopeBucket) - getScopeBucketPriority(a.scopeBucket);
|
||
if (bucketDelta !== 0) return bucketDelta;
|
||
const temporalDelta =
|
||
getTemporalBucketPriority(b.temporalBucket) -
|
||
getTemporalBucketPriority(a.temporalBucket);
|
||
if (temporalDelta !== 0) return temporalDelta;
|
||
const weightedDelta =
|
||
(Number(b.weightedScore) || 0) - (Number(a.weightedScore) || 0);
|
||
if (weightedDelta !== 0) return weightedDelta;
|
||
return (Number(b.finalScore) || 0) - (Number(a.finalScore) || 0);
|
||
});
|
||
retrievalMeta.scoredCandidates = scoredNodes.length;
|
||
retrievalMeta.lexicalBoostedNodes = scoredNodes.filter(
|
||
(item) => (Number(item.lexicalScore) || 0) > 0,
|
||
).length;
|
||
retrievalMeta.lexicalTopHits = buildLexicalTopHits(scoredNodes);
|
||
retrievalMeta.visibilityTopHits = buildVisibilityTopHits(scoredNodes);
|
||
retrievalMeta.temporalTopHits = scoredNodes.slice(0, 8).map((item) => ({
|
||
nodeId: item.nodeId,
|
||
bucket: item.temporalBucket,
|
||
weight: Math.round((Number(item.temporalWeight) || 0) * 1000) / 1000,
|
||
label: item.storyTimeLabel || "",
|
||
reason: item.temporalReason || "",
|
||
}));
|
||
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;
|
||
let llmCandidates = [];
|
||
const diversityStartedAt = nowMs();
|
||
let llmDurationMs = 0;
|
||
|
||
if (enableLLMRecall && nodeCount > 0) {
|
||
debugLog("[ST-BME] LLM 精确召回");
|
||
llmCandidates = resolveCandidatePool(
|
||
scoredNodes,
|
||
normalizedLlmCandidatePool,
|
||
dppCandidateMultiplier,
|
||
enableDiversitySampling,
|
||
dppQualityWeight,
|
||
retrievalMeta,
|
||
);
|
||
const llmStartedAt = nowMs();
|
||
const llmResult = await llmRecall(
|
||
userMessage,
|
||
recentMessages,
|
||
llmCandidates,
|
||
graph,
|
||
schema,
|
||
normalizedMaxRecallNodes,
|
||
options.recallPrompt,
|
||
sceneOwnerCandidates,
|
||
activeStoryContext.activeStoryTimeLabel || "",
|
||
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 = {
|
||
...retrievalMeta.llm,
|
||
enabled: true,
|
||
status: llmResult.status,
|
||
reason: llmResult.reason,
|
||
selectionProtocol: llmResult.selectionProtocol || "",
|
||
rawSelectedKeys: Array.isArray(llmResult.rawSelectedKeys)
|
||
? [...llmResult.rawSelectedKeys]
|
||
: [],
|
||
resolvedSelectedKeys: Array.isArray(llmResult.resolvedSelectedKeys)
|
||
? [...llmResult.resolvedSelectedKeys]
|
||
: [],
|
||
resolvedSelectedNodeIds: Array.isArray(llmResult.resolvedSelectedNodeIds)
|
||
? [...llmResult.resolvedSelectedNodeIds]
|
||
: [],
|
||
fallbackReason: llmResult.fallbackReason || "",
|
||
fallbackType: llmResult.fallbackType || "",
|
||
emptySelectionAccepted: llmResult.emptySelectionAccepted === true,
|
||
candidateKeyMapPreview: { ...(llmResult.candidateKeyMapPreview || {}) },
|
||
legacySelectionUsed: llmResult.legacySelectionUsed === true,
|
||
candidatePool: llmCandidates.length,
|
||
selectedSeedCount: llmResult.selectedNodeIds.length,
|
||
};
|
||
} else {
|
||
const selectedCandidates = resolveCandidatePool(
|
||
scoredNodes,
|
||
normalizedTopK,
|
||
dppCandidateMultiplier,
|
||
enableDiversitySampling,
|
||
dppQualityWeight,
|
||
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 = {
|
||
...retrievalMeta.llm,
|
||
enabled: false,
|
||
status: "disabled",
|
||
reason: "LLM 精排已关闭,直接采用评分排序",
|
||
candidatePool: 0,
|
||
selectedSeedCount: selectedNodeIds.length,
|
||
};
|
||
}
|
||
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,
|
||
normalizedMaxRecallNodes,
|
||
);
|
||
|
||
// 访问强化
|
||
const selectedNodes = selectedNodeIds
|
||
.map((id) => getNode(graph, id))
|
||
.filter(Boolean);
|
||
retrievalMeta.selectedByBucket = selectedNodes.reduce((acc, node) => {
|
||
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);
|
||
return acc;
|
||
}, createEmptyScopeBucketMap());
|
||
retrievalMeta.selectedByKnowledgeState = Object.fromEntries(
|
||
selectedNodes.map((node) => {
|
||
const scored = scoredNodes.find((item) => item.nodeId === node.id);
|
||
return [
|
||
node.id,
|
||
{
|
||
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 || [])],
|
||
},
|
||
];
|
||
}),
|
||
);
|
||
retrievalMeta.selectedByStoryTime = Object.fromEntries(
|
||
selectedNodes.map((node) => {
|
||
const scored = scoredNodes.find((item) => item.nodeId === node.id);
|
||
return [
|
||
node.id,
|
||
{
|
||
bucket: String(scored?.temporalBucket || STORY_TEMPORAL_BUCKETS.UNDATED),
|
||
weight:
|
||
Math.round((Number(scored?.temporalWeight) || 0) * 1000) / 1000,
|
||
label: String(scored?.storyTimeLabel || ""),
|
||
rescued: Boolean(scored?.temporalRescued),
|
||
reason: String(scored?.temporalReason || ""),
|
||
},
|
||
];
|
||
}),
|
||
);
|
||
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);
|
||
|
||
debugLog(`[ST-BME] 检索完成: 选中 ${selectedNodeIds.length} 个节点`);
|
||
|
||
if (enableProbRecall && probRecallChance > 0) {
|
||
const selectedSet = new Set(selectedNodeIds);
|
||
const probability = Math.max(0.01, Math.min(0.5, probRecallChance));
|
||
const candidates = activeNodes
|
||
.filter(
|
||
(n) =>
|
||
!selectedSet.has(n.id) &&
|
||
n.importance >= 6 &&
|
||
n.type !== "synopsis" &&
|
||
n.type !== "rule",
|
||
)
|
||
.sort((a, b) => (b.importance || 0) - (a.importance || 0))
|
||
.slice(0, 3);
|
||
for (const c of candidates) {
|
||
if (Math.random() < probability) {
|
||
selectedNodeIds.push(c.id);
|
||
debugLog(
|
||
`[ST-BME] 概率触发: ${c.fields?.name || c.fields?.summary || c.id}`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
selectedNodeIds = uniqueNodeIds(selectedNodeIds).slice(
|
||
0,
|
||
normalizedMaxRecallNodes,
|
||
);
|
||
retrievalMeta.knowledgeAnchoredNodes = uniqueNodeIds(
|
||
retrievalMeta.knowledgeAnchoredNodes,
|
||
);
|
||
retrievalMeta.knowledgeSuppressedNodes = uniqueNodeIds(
|
||
retrievalMeta.knowledgeSuppressedNodes,
|
||
);
|
||
retrievalMeta.knowledgeRescuedNodes = uniqueNodeIds(
|
||
retrievalMeta.knowledgeRescuedNodes,
|
||
);
|
||
retrievalMeta.adjacentRegionMatches = uniqueNodeIds(
|
||
retrievalMeta.adjacentRegionMatches,
|
||
);
|
||
retrievalMeta.temporalSuppressedNodes = uniqueNodeIds(
|
||
retrievalMeta.temporalSuppressedNodes,
|
||
);
|
||
retrievalMeta.temporalRescuedNodes = uniqueNodeIds(
|
||
retrievalMeta.temporalRescuedNodes,
|
||
);
|
||
retrievalMeta.llm = llmMeta;
|
||
retrievalMeta.timings.total = roundMs(nowMs() - startedAt);
|
||
|
||
return buildResult(graph, selectedNodeIds, schema, {
|
||
retrieval: retrievalMeta,
|
||
scopeContext: {
|
||
enableScopedMemory,
|
||
enablePovMemory,
|
||
enableRegionScopedObjective,
|
||
enableCognitiveMemory,
|
||
enableStoryTimeline,
|
||
injectUserPovMemory,
|
||
injectObjectiveGlobalMemory,
|
||
activeRegion,
|
||
activeRegionSource: activeRegionContext.source || "",
|
||
activeStorySegmentId: activeStoryContext.activeSegmentId || "",
|
||
activeStoryTimeLabel: activeStoryContext.activeStoryTimeLabel || "",
|
||
activeStoryTimeSource: activeStoryContext.source || "",
|
||
injectStoryTimeLabel,
|
||
activeCharacterPovOwner:
|
||
getSceneOwnerNamesByKeys(sceneOwnerCandidates, activeRecallOwnerKeys)[0] ||
|
||
activeCharacterPovOwner,
|
||
activeUserPovOwner,
|
||
activeCharacterPovOwners: getSceneOwnerNamesByKeys(
|
||
sceneOwnerCandidates,
|
||
activeRecallOwnerKeys,
|
||
),
|
||
activeRecallOwnerKey: activeRecallOwnerKeys[0] || "",
|
||
activeRecallOwnerKeys: [...activeRecallOwnerKeys],
|
||
activeRecallOwnerScores: { ...activeRecallOwnerScores },
|
||
sceneOwnerResolutionMode,
|
||
sceneOwnerCandidates: retrievalMeta.sceneOwnerCandidates,
|
||
adjacentRegions: adjacentRegionContext.adjacentRegions,
|
||
injectLowConfidenceObjectiveMemory,
|
||
graph,
|
||
bucketWeights,
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 向量预筛选
|
||
*/
|
||
async function vectorPreFilter(
|
||
graph,
|
||
userMessage,
|
||
activeNodes,
|
||
embeddingConfig,
|
||
topK,
|
||
signal,
|
||
) {
|
||
try {
|
||
return await findSimilarNodesByText(
|
||
graph,
|
||
userMessage,
|
||
embeddingConfig,
|
||
topK,
|
||
activeNodes,
|
||
signal,
|
||
);
|
||
} catch (e) {
|
||
if (isAbortError(e)) {
|
||
throw e;
|
||
}
|
||
console.error("[ST-BME] 向量预筛失败:", e);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 实体锚点提取
|
||
* 从用户消息中提取名词/实体,匹配图中的节点名称
|
||
*/
|
||
function extractEntityAnchors(userMessage, activeNodes) {
|
||
const anchors = [];
|
||
const seen = new Set();
|
||
|
||
for (const node of activeNodes) {
|
||
const candidates = [node.fields?.name, node.fields?.title]
|
||
.filter((value) => typeof value === "string")
|
||
.map((value) => value.trim())
|
||
.filter((value) => value.length >= 2);
|
||
|
||
for (const candidate of candidates) {
|
||
if (!userMessage.includes(candidate)) continue;
|
||
const key = `${node.id}:${candidate}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
anchors.push({ nodeId: node.id, entity: candidate });
|
||
break;
|
||
}
|
||
}
|
||
|
||
return anchors;
|
||
}
|
||
|
||
function buildResidualBasisNodes(
|
||
graph,
|
||
exactEntityAnchors,
|
||
vectorResults,
|
||
maxNodes = 24,
|
||
) {
|
||
const basis = [];
|
||
const seen = new Set();
|
||
|
||
for (const anchor of exactEntityAnchors || []) {
|
||
const node = getNode(graph, anchor?.nodeId);
|
||
if (
|
||
!node ||
|
||
seen.has(node.id) ||
|
||
!Array.isArray(node.embedding) ||
|
||
node.embedding.length === 0
|
||
) {
|
||
continue;
|
||
}
|
||
seen.add(node.id);
|
||
basis.push(node);
|
||
if (basis.length >= maxNodes) return basis;
|
||
}
|
||
|
||
for (const result of vectorResults || []) {
|
||
const node = getNode(graph, result?.nodeId);
|
||
if (
|
||
!isEligibleAnchorNode(node) ||
|
||
seen.has(node?.id) ||
|
||
!Array.isArray(node?.embedding) ||
|
||
node.embedding.length === 0
|
||
) {
|
||
continue;
|
||
}
|
||
seen.add(node.id);
|
||
basis.push(node);
|
||
if (basis.length >= maxNodes) break;
|
||
}
|
||
|
||
return basis;
|
||
}
|
||
|
||
function resolveCandidatePool(
|
||
scoredNodes,
|
||
targetCount,
|
||
multiplier,
|
||
enableDiversitySampling,
|
||
qualityWeight,
|
||
retrievalMeta,
|
||
) {
|
||
const safeTarget = Math.max(1, targetCount);
|
||
const fallback = scoredNodes.slice(0, Math.min(safeTarget, scoredNodes.length));
|
||
retrievalMeta.candidatePoolBeforeDpp = fallback.length;
|
||
retrievalMeta.candidatePoolAfterDpp = fallback.length;
|
||
retrievalMeta.diversityApplied = false;
|
||
|
||
if (!enableDiversitySampling) {
|
||
return fallback;
|
||
}
|
||
|
||
const poolLimit = Math.min(
|
||
scoredNodes.length,
|
||
Math.max(safeTarget, safeTarget * Math.max(1, multiplier)),
|
||
);
|
||
const pool = scoredNodes.slice(0, poolLimit);
|
||
retrievalMeta.candidatePoolBeforeDpp = pool.length;
|
||
|
||
const diversity = applyDiversitySampling(pool, {
|
||
k: safeTarget,
|
||
qualityWeight,
|
||
});
|
||
retrievalMeta.candidatePoolAfterDpp = diversity.afterCount;
|
||
retrievalMeta.diversityApplied = diversity.applied;
|
||
pushSkipReason(retrievalMeta, diversity.reason);
|
||
|
||
return diversity.applied ? diversity.selected : fallback;
|
||
}
|
||
|
||
/**
|
||
* LLM 精确召回
|
||
*/
|
||
async function llmRecall(
|
||
userMessage,
|
||
recentMessages,
|
||
candidates,
|
||
graph,
|
||
schema,
|
||
maxNodes,
|
||
customPrompt,
|
||
sceneOwnerCandidates = [],
|
||
activeStoryTimeLabel = "",
|
||
settings = {},
|
||
signal,
|
||
onStreamProgress = null,
|
||
) {
|
||
throwIfAborted(signal);
|
||
const contextStr = recentMessages.join("\n---\n");
|
||
const sceneOwnerCandidateText = buildSceneOwnerCandidateText(sceneOwnerCandidates);
|
||
const {
|
||
candidateKeyToNodeId,
|
||
candidateKeyToCandidateMeta,
|
||
nodeIdToCandidateKey,
|
||
} = createRecallCandidateKeyMaps(candidates);
|
||
const candidateDescriptions = candidates
|
||
.map((c, index) => {
|
||
const node = c.node;
|
||
const typeDef = schema.find((s) => s.id === node.type);
|
||
const typeLabel = typeDef?.label || node.type;
|
||
const storyTimeLabel = describeNodeStoryTime(node);
|
||
const fieldsStr = Object.entries(node.fields)
|
||
.map(([k, v]) => `${k}: ${v}`)
|
||
.join(", ");
|
||
const candidateKey = `R${index + 1}`;
|
||
return `[${candidateKey}] 类型=${typeLabel}, 作用域=${describeMemoryScope(node.scope)}, 时间=${storyTimeLabel || "未标注"}, 时间桶=${String(c.temporalBucket || STORY_TEMPORAL_BUCKETS.UNDATED)}, 召回桶=${describeScopeBucket(c.scopeBucket)}, 认知=${String(c.knowledgeMode || "unknown")}, 可见性=${(Number(c.knowledgeVisibilityScore) || 0).toFixed(3)}, ${fieldsStr} (评分=${(c.weightedScore ?? c.finalScore).toFixed(3)})`;
|
||
})
|
||
.join("\n");
|
||
|
||
const recallPromptBuild = await buildTaskPrompt(settings, "recall", {
|
||
taskName: "recall",
|
||
recentMessages: contextStr || "(无)",
|
||
userMessage,
|
||
candidateNodes: candidateDescriptions,
|
||
candidateText: candidateDescriptions,
|
||
sceneOwnerCandidates: sceneOwnerCandidateText,
|
||
activeStoryTimeLabel,
|
||
graphStats: `candidate_count=${candidates.length}`,
|
||
...getSTContextForPrompt(),
|
||
});
|
||
const recallRegexInput = { entries: [] };
|
||
const systemPrompt = applyTaskRegex(
|
||
settings,
|
||
"recall",
|
||
"finalPrompt",
|
||
recallPromptBuild.systemPrompt || customPrompt || [
|
||
"你是一个记忆召回分析器。",
|
||
"根据用户最新输入和对话上下文,从候选记忆节点中选择最相关的节点。",
|
||
"你还需要判断这轮真正参与当前回应的具体人物,并返回他们的 ownerKey。",
|
||
"优先维持剧情时间一致,不要把未来信息当成当前已经发生的客观事实带入。",
|
||
"优先选择:(1) 直接相关的当前场景节点, (2) 因果关系连续性节点, (3) 有潜在影响的背景节点。",
|
||
`最多选择 ${maxNodes} 个节点。`,
|
||
"候选节点使用短键标识(R1 / R2 / R3 ...),只能从给出的短键里选择。",
|
||
"如果你一个都不选,系统会自动回退到评分召回。",
|
||
"输出严格的 JSON 格式:",
|
||
'{"selected_keys": ["R1", "R2"], "reason": "R1: 简要说明选择理由;R2: 简要说明选择理由", "active_owner_keys": ["character:alice"], "active_owner_scores": [{"ownerKey": "character:alice", "score": 0.92, "reason": "她在场并且 POV 最相关"}]}',
|
||
].join("\n"),
|
||
recallRegexInput,
|
||
"system",
|
||
);
|
||
|
||
const userPrompt = [
|
||
"## 当前剧情时间",
|
||
activeStoryTimeLabel || "(未确定)",
|
||
"",
|
||
"## 最近对话上下文",
|
||
contextStr || "(无)",
|
||
"",
|
||
"## 用户最新输入",
|
||
userMessage,
|
||
"",
|
||
"## 候选记忆节点",
|
||
candidateDescriptions,
|
||
"",
|
||
"## 场景角色候选",
|
||
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),
|
||
userPrompt: promptPayload.userPrompt,
|
||
maxRetries: 2,
|
||
signal,
|
||
taskType: "recall",
|
||
debugContext: createTaskLlmDebugContext(
|
||
recallPromptBuild,
|
||
recallRegexInput,
|
||
),
|
||
promptMessages: promptPayload.promptMessages,
|
||
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),
|
||
]),
|
||
);
|
||
|
||
const hasSelectedKeysField =
|
||
result && Object.prototype.hasOwnProperty.call(result, "selected_keys");
|
||
const hasSelectedIdsField =
|
||
result && Object.prototype.hasOwnProperty.call(result, "selected_ids");
|
||
const rawSelectedKeys = Array.isArray(result?.selected_keys)
|
||
? normalizeRecallSelectionList(result.selected_keys, maxNodes * 4)
|
||
: [];
|
||
const rawSelectedIds = Array.isArray(result?.selected_ids)
|
||
? normalizeRecallSelectionList(result.selected_ids, maxNodes * 4)
|
||
: [];
|
||
const selectionProtocol = hasSelectedKeysField
|
||
? "candidate-keys-v1"
|
||
: hasSelectedIdsField
|
||
? "legacy-selected-ids"
|
||
: "candidate-keys-v1";
|
||
const legacySelectionUsed =
|
||
!hasSelectedKeysField && hasSelectedIdsField && Array.isArray(result?.selected_ids);
|
||
|
||
let resolvedSelectedKeys = [];
|
||
let resolvedSelectedNodeIds = [];
|
||
let fallbackReason = "";
|
||
let fallbackType = "";
|
||
|
||
if (hasSelectedKeysField) {
|
||
if (!Array.isArray(result?.selected_keys)) {
|
||
fallbackType = "invalid-candidate";
|
||
fallbackReason = "LLM 返回的 selected_keys 结构无效,已回退到评分排序";
|
||
} else if (rawSelectedKeys.length === 0) {
|
||
fallbackType = "empty-selection";
|
||
fallbackReason = "LLM 返回了空的 selected_keys,已回退到评分排序";
|
||
} else {
|
||
resolvedSelectedKeys = rawSelectedKeys
|
||
.filter((key) => candidateKeyToNodeId[key])
|
||
.slice(0, maxNodes);
|
||
resolvedSelectedNodeIds = uniqueNodeIds(
|
||
resolvedSelectedKeys
|
||
.map((key) => candidateKeyToNodeId[key])
|
||
.filter(Boolean),
|
||
).slice(0, maxNodes);
|
||
}
|
||
} else if (hasSelectedIdsField) {
|
||
if (!Array.isArray(result?.selected_ids)) {
|
||
fallbackType = "invalid-candidate";
|
||
fallbackReason = "LLM 返回的 selected_ids 结构无效,已回退到评分排序";
|
||
} else if (rawSelectedIds.length === 0) {
|
||
fallbackType = "empty-selection";
|
||
fallbackReason = "LLM 返回了空的 selected_ids,已回退到评分排序";
|
||
} else {
|
||
resolvedSelectedNodeIds = uniqueNodeIds(
|
||
rawSelectedIds.filter((id) => candidates.some((c) => c.nodeId === id)),
|
||
).slice(0, maxNodes);
|
||
resolvedSelectedKeys = resolvedSelectedNodeIds
|
||
.map((nodeId) => nodeIdToCandidateKey[nodeId])
|
||
.filter(Boolean)
|
||
.slice(0, maxNodes);
|
||
}
|
||
} else if (llmResult?.ok) {
|
||
fallbackType = "invalid-candidate";
|
||
fallbackReason = "LLM 返回了无法识别的 JSON 结构,已回退到评分排序";
|
||
}
|
||
|
||
if (resolvedSelectedNodeIds.length > 0) {
|
||
return {
|
||
selectedNodeIds: resolvedSelectedNodeIds,
|
||
status: "llm",
|
||
activeOwnerKeys,
|
||
activeOwnerScores,
|
||
sceneOwnerResolutionMode: activeOwnerKeys.length > 0 ? "llm" : "fallback",
|
||
reason:
|
||
selectionProtocol === "legacy-selected-ids"
|
||
? resolvedSelectedNodeIds.length < rawSelectedIds.length
|
||
? "LLM 返回了部分无效或超限 selected_ids,已保留可解析结果"
|
||
: "LLM 主导演选择完成(legacy selected_ids)"
|
||
: resolvedSelectedNodeIds.length < rawSelectedKeys.length
|
||
? "LLM 返回了部分无效或超限 selected_keys,已保留可解析结果"
|
||
: "LLM 主导演选择完成",
|
||
selectionProtocol,
|
||
rawSelectedKeys,
|
||
resolvedSelectedKeys,
|
||
resolvedSelectedNodeIds,
|
||
legacySelectionUsed,
|
||
emptySelectionAccepted: false,
|
||
candidateKeyMapPreview: candidateKeyToCandidateMeta,
|
||
fallbackReason: "",
|
||
};
|
||
}
|
||
|
||
// LLM 失败时回退到纯评分排序
|
||
fallbackReason ||= llmResult?.ok
|
||
? hasSelectedKeysField || hasSelectedIdsField
|
||
? "LLM 返回的候选短键或候选 ID 无法映射到当前候选,已回退到评分排序"
|
||
: "LLM 返回了无法识别的 JSON 结构,已回退到评分排序"
|
||
: buildRecallFallbackReason(llmResult);
|
||
fallbackType ||= llmResult?.ok
|
||
? "invalid-candidate"
|
||
: llmResult?.errorType || "unknown";
|
||
return {
|
||
selectedNodeIds: candidates.slice(0, maxNodes).map((c) => c.nodeId),
|
||
status: "fallback",
|
||
activeOwnerKeys: [],
|
||
activeOwnerScores: {},
|
||
sceneOwnerResolutionMode: "fallback",
|
||
reason: fallbackReason,
|
||
fallbackType,
|
||
selectionProtocol,
|
||
rawSelectedKeys,
|
||
resolvedSelectedKeys,
|
||
resolvedSelectedNodeIds,
|
||
legacySelectionUsed,
|
||
emptySelectionAccepted: false,
|
||
candidateKeyMapPreview: candidateKeyToCandidateMeta,
|
||
fallbackReason,
|
||
};
|
||
}
|
||
|
||
// ==================== v2 辅助函数 ====================
|
||
|
||
/**
|
||
* ⑥ 认知边界过滤(RoleRAG 启发)
|
||
* 过滤掉设置了 visibility 但不包含当前角色的节点
|
||
* @param {object[]} nodes
|
||
* @param {string} characterName - 当前视角角色名
|
||
* @returns {object[]}
|
||
*/
|
||
function filterByVisibility(nodes, characterName) {
|
||
if (!characterName || typeof characterName !== "string") return nodes;
|
||
return nodes.filter((node) => {
|
||
if (!node.fields?.visibility) return true;
|
||
if (Array.isArray(node.fields.visibility)) {
|
||
return (
|
||
node.fields.visibility.includes(characterName) ||
|
||
node.fields.visibility.includes("*")
|
||
);
|
||
}
|
||
if (typeof node.fields.visibility === "string") {
|
||
const visibleTo = node.fields.visibility
|
||
.split(",")
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
return visibleTo.includes(characterName) || visibleTo.includes("*");
|
||
}
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 构建最终检索结果
|
||
* 分离常驻注入(Core)和召回注入(Recall)
|
||
*/
|
||
function buildResult(graph, selectedNodeIds, schema, meta = {}) {
|
||
const coreNodes = [];
|
||
const recallNodes = [];
|
||
const selectedSet = new Set(uniqueNodeIds(selectedNodeIds));
|
||
const scopeContext = meta.scopeContext || {};
|
||
const compareForResult = compareNodeRecallOrderWithContext(graph, scopeContext);
|
||
const summaryEntries =
|
||
typeof getActiveSummaryEntries === "function"
|
||
? getActiveSummaryEntries(graph)
|
||
: [];
|
||
|
||
// 常驻注入节点(alwaysInject=true 的类型)
|
||
const alwaysInjectTypes = new Set(
|
||
schema.filter((s) => s.alwaysInject).map((s) => s.id),
|
||
);
|
||
if (summaryEntries.length > 0) {
|
||
alwaysInjectTypes.delete("synopsis");
|
||
}
|
||
|
||
const activeNodes = getActiveNodes(graph).filter((node) => !node.archived);
|
||
|
||
for (const node of activeNodes) {
|
||
if (alwaysInjectTypes.has(node.type)) {
|
||
coreNodes.push(node);
|
||
}
|
||
}
|
||
|
||
for (const nodeId of selectedSet) {
|
||
const node = getNode(graph, nodeId);
|
||
if (!node || node.archived) continue;
|
||
if (!alwaysInjectTypes.has(node.type)) {
|
||
recallNodes.push(node);
|
||
}
|
||
}
|
||
|
||
coreNodes.sort(compareForResult);
|
||
recallNodes.sort(compareForResult);
|
||
const groupedRecallNodes = groupRecallNodes(recallNodes);
|
||
const selectedNodes = [...selectedSet]
|
||
.map((nodeId) => getNode(graph, nodeId))
|
||
.filter((node) => node && !node.archived)
|
||
.sort(compareForResult);
|
||
const scopeBuckets = buildScopedInjectionBuckets(
|
||
coreNodes,
|
||
selectedNodes,
|
||
scopeContext,
|
||
);
|
||
|
||
return {
|
||
summaryEntries,
|
||
coreNodes,
|
||
recallNodes,
|
||
groupedRecallNodes,
|
||
scopeBuckets,
|
||
selectedNodeIds: [...selectedSet],
|
||
meta,
|
||
stats: {
|
||
totalActive: activeNodes.length,
|
||
summaryCount: summaryEntries.length,
|
||
coreCount: coreNodes.length,
|
||
recallCount: recallNodes.length,
|
||
characterPovCount: scopeBuckets.characterPov.length,
|
||
userPovCount: scopeBuckets.userPov.length,
|
||
objectiveCurrentRegionCount: scopeBuckets.objectiveCurrentRegion.length,
|
||
objectiveGlobalCount: scopeBuckets.objectiveGlobal.length,
|
||
episodicCount: groupedRecallNodes.episodic.length,
|
||
stateCount: groupedRecallNodes.state.length,
|
||
reflectiveCount: groupedRecallNodes.reflective.length,
|
||
ruleCount: groupedRecallNodes.rule.length,
|
||
},
|
||
};
|
||
}
|
||
|
||
function buildScopedInjectionBuckets(coreNodes, selectedNodes, scopeContext = {}) {
|
||
const buckets = {
|
||
characterPov: [],
|
||
characterPovByOwner: {},
|
||
characterPovOwnerOrder: [],
|
||
userPov: [],
|
||
objectiveCurrentRegion: [],
|
||
objectiveGlobal: [],
|
||
};
|
||
const activeRecallOwnerKeys = normalizeOwnerKeyList(
|
||
scopeContext.activeRecallOwnerKeys || scopeContext.activeRecallOwnerKey || [],
|
||
);
|
||
const combinedNodes = [
|
||
...selectedNodes,
|
||
...coreNodes,
|
||
];
|
||
const compareForBucket = compareNodeRecallOrderWithContext(
|
||
scopeContext.graph,
|
||
scopeContext,
|
||
);
|
||
const seen = new Set();
|
||
const globalCandidates = [];
|
||
|
||
for (const node of combinedNodes) {
|
||
if (!node?.id || seen.has(node.id)) continue;
|
||
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,
|
||
activeRecallOwnerKeys,
|
||
{
|
||
scopeBucket: bucket,
|
||
injectLowConfidenceObjectiveMemory:
|
||
scopeContext.injectLowConfidenceObjectiveMemory === true,
|
||
},
|
||
)
|
||
: { visible: true };
|
||
if (!knowledgeGate.visible && String(node?.scope?.layer || "objective") === "objective") {
|
||
continue;
|
||
}
|
||
|
||
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) {
|
||
if (scopeContext.injectUserPovMemory !== false) {
|
||
buckets.userPov.push(node);
|
||
}
|
||
continue;
|
||
}
|
||
if (bucket === MEMORY_SCOPE_BUCKETS.OBJECTIVE_CURRENT_REGION) {
|
||
buckets.objectiveCurrentRegion.push(node);
|
||
continue;
|
||
}
|
||
if (
|
||
bucket === MEMORY_SCOPE_BUCKETS.OBJECTIVE_ADJACENT_REGION ||
|
||
bucket === MEMORY_SCOPE_BUCKETS.OBJECTIVE_GLOBAL
|
||
) {
|
||
globalCandidates.push(node);
|
||
}
|
||
}
|
||
|
||
buckets.characterPov.sort(compareForBucket);
|
||
for (const ownerKey of Object.keys(buckets.characterPovByOwner)) {
|
||
buckets.characterPovByOwner[ownerKey].sort(compareForBucket);
|
||
}
|
||
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(compareForBucket);
|
||
buckets.objectiveCurrentRegion.sort(compareForBucket);
|
||
const cappedGlobal = (scopeContext.injectObjectiveGlobalMemory === false
|
||
? []
|
||
: globalCandidates.sort(compareForBucket).slice(0, 6));
|
||
buckets.objectiveGlobal = cappedGlobal;
|
||
|
||
return buckets;
|
||
}
|
||
|
||
function reconstructSceneNodeIds(graph, seedNodeIds, limit = 16) {
|
||
const selected = [];
|
||
const seen = new Set();
|
||
|
||
function push(nodeId) {
|
||
if (!nodeId || seen.has(nodeId) || selected.length >= limit) return;
|
||
const node = getNode(graph, nodeId);
|
||
if (!node || node.archived) return;
|
||
seen.add(nodeId);
|
||
selected.push(nodeId);
|
||
}
|
||
|
||
for (const nodeId of uniqueNodeIds(seedNodeIds)) {
|
||
if (selected.length >= limit) break;
|
||
push(nodeId);
|
||
const node = getNode(graph, nodeId);
|
||
if (!node) continue;
|
||
|
||
if (node.type === "event") {
|
||
expandEventScene(graph, node, push);
|
||
} else if (node.type === "pov_memory") {
|
||
const relatedNodes = getNodeEdges(graph, node.id)
|
||
.filter(isUsableSceneEdge)
|
||
.map((e) => (e.fromId === node.id ? e.toId : e.fromId))
|
||
.map((id) => getNode(graph, id))
|
||
.filter(Boolean)
|
||
.sort(compareNodeRecallOrder)
|
||
.slice(0, 2);
|
||
for (const relatedNode of relatedNodes) {
|
||
push(relatedNode.id);
|
||
if (relatedNode.type === "event") {
|
||
expandEventScene(graph, relatedNode, push);
|
||
}
|
||
}
|
||
} else if (node.type === "character" || node.type === "location") {
|
||
const relatedEvents = getNodeEdges(graph, node.id)
|
||
.filter(isUsableSceneEdge)
|
||
.map((e) => (e.fromId === node.id ? e.toId : e.fromId))
|
||
.map((id) => getNode(graph, id))
|
||
.filter((n) => n && !n.archived && n.type === "event")
|
||
.sort(compareNodeRecallOrder)
|
||
.slice(0, 2);
|
||
for (const eventNode of relatedEvents) {
|
||
push(eventNode.id);
|
||
expandEventScene(graph, eventNode, push);
|
||
}
|
||
}
|
||
}
|
||
|
||
return selected.slice(0, limit);
|
||
}
|
||
|
||
function expandEventScene(graph, eventNode, push) {
|
||
const edges = getNodeEdges(graph, eventNode.id).filter(isUsableSceneEdge);
|
||
for (const edge of edges) {
|
||
const neighborId = edge.fromId === eventNode.id ? edge.toId : edge.fromId;
|
||
const neighbor = getNode(graph, neighborId);
|
||
if (!neighbor || neighbor.archived) continue;
|
||
if (
|
||
neighbor.type === "character" ||
|
||
neighbor.type === "location" ||
|
||
neighbor.type === "thread" ||
|
||
neighbor.type === "reflection" ||
|
||
neighbor.type === "pov_memory"
|
||
) {
|
||
push(neighbor.id);
|
||
}
|
||
}
|
||
|
||
const adjacentEvents = getTemporalNeighborEvents(
|
||
graph,
|
||
eventNode.seq,
|
||
eventNode.id,
|
||
);
|
||
for (const neighborEvent of adjacentEvents) {
|
||
push(neighborEvent.id);
|
||
}
|
||
}
|
||
|
||
function getTemporalNeighborEvents(graph, seq, excludeId) {
|
||
return getActiveNodes(graph, "event")
|
||
.filter((n) => n.id !== excludeId && !n.archived)
|
||
.sort((a, b) => {
|
||
const distance =
|
||
Math.abs((a.seq || 0) - seq) - Math.abs((b.seq || 0) - seq);
|
||
if (distance !== 0) return distance;
|
||
return (b.seq || 0) - (a.seq || 0);
|
||
})
|
||
.slice(0, 2);
|
||
}
|
||
|
||
function isUsableSceneEdge(edge) {
|
||
return edge && !edge.invalidAt && !edge.expiredAt;
|
||
}
|
||
|
||
function compareNodeRecallOrder(a, b) {
|
||
const aSeq = a?.seqRange?.[1] ?? a?.seq ?? 0;
|
||
const bSeq = b?.seqRange?.[1] ?? b?.seq ?? 0;
|
||
if (bSeq !== aSeq) return bSeq - aSeq;
|
||
return (b.importance || 0) - (a.importance || 0);
|
||
}
|
||
|
||
function compareNodeRecallOrderWithContext(graph, scopeContext = {}) {
|
||
const activeStorySegmentId = String(scopeContext?.activeStorySegmentId || "").trim();
|
||
const enableStoryTimeline = scopeContext?.enableStoryTimeline !== false;
|
||
if (!enableStoryTimeline || !activeStorySegmentId) {
|
||
return compareNodeRecallOrder;
|
||
}
|
||
return (a, b) => {
|
||
const temporalDelta =
|
||
getTemporalBucketPriority(
|
||
classifyStoryTemporalBucket(graph, b, {
|
||
activeSegmentId: activeStorySegmentId,
|
||
}).bucket,
|
||
) -
|
||
getTemporalBucketPriority(
|
||
classifyStoryTemporalBucket(graph, a, {
|
||
activeSegmentId: activeStorySegmentId,
|
||
}).bucket,
|
||
);
|
||
if (temporalDelta !== 0) return temporalDelta;
|
||
return compareNodeRecallOrder(a, b);
|
||
};
|
||
}
|
||
|
||
function groupRecallNodes(nodes) {
|
||
return {
|
||
state: nodes.filter((n) => n.type === "character" || n.type === "location"),
|
||
episodic: nodes.filter((n) => n.type === "event" || n.type === "thread"),
|
||
reflective: nodes.filter(
|
||
(n) => n.type === "reflection" || n.type === "synopsis",
|
||
),
|
||
rule: nodes.filter((n) => n.type === "rule"),
|
||
other: nodes.filter(
|
||
(n) =>
|
||
![
|
||
"character",
|
||
"location",
|
||
"event",
|
||
"thread",
|
||
"reflection",
|
||
"synopsis",
|
||
"rule",
|
||
].includes(n.type),
|
||
),
|
||
};
|
||
}
|
||
|
||
function uniqueNodeIds(nodeIds) {
|
||
return [...new Set(nodeIds)];
|
||
}
|