mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
feat: add reflective memory and scene reconstruction
This commit is contained in:
47
README.md
47
README.md
@@ -21,9 +21,12 @@ ST-BME(ST-Bionic-Memory-Ecology)是一个运行在 SillyTavern 第三方扩
|
||||
- **聊天级持久化**:图状态写入当前聊天 `chat_metadata`,可随聊天保存与恢复。
|
||||
- **自动记忆提取**:按设定频率从最近对话中抽取结构化操作并更新图谱。
|
||||
- **三阶段召回编排**:支持向量预筛、图扩散排序、混合评分与可选 LLM 精确召回。
|
||||
- **情景重构召回**:会围绕命中的事件、角色、地点自动补齐相邻场景节点,使注入结果更接近连续剧情记忆。
|
||||
- **层级压缩**:对事件与主线等类型执行分层摘要,控制图谱膨胀。
|
||||
- **全局概要节点**:周期性生成 `synopsis` 类型节点,作为长期叙事锚点。
|
||||
- **记忆进化**:新节点写入后,基于近邻分析回溯更新已有记忆并建立新连接。
|
||||
- **时序更新追踪增强**:节点更新时会补充 `updates` / `temporal_update` 语义链路,并生成可追踪的状态更新事件。
|
||||
- **反思条目生成**:支持按提取周期生成 `reflection` 节点,用于沉淀高层叙事结论、关系趋势与后续建议。
|
||||
- **时序边字段**:关系边携带 `validAt` / `invalidAt` / `expiredAt` 等时间语义字段。
|
||||
- **导入导出与手动操作入口**:设置面板已提供查看图谱、查看注入、重建、压缩、导入、导出等入口。
|
||||
|
||||
@@ -34,6 +37,7 @@ ST-BME(ST-Bionic-Memory-Ecology)是一个运行在 SillyTavern 第三方扩
|
||||
- **Mem0 风格精确对照**:新记忆可与近邻旧记忆对照后再决定新增、更新或跳过。
|
||||
- **认知边界过滤**:可按可见性约束过滤检索结果,适用于“角色不知道的信息不应注入”的场景。
|
||||
- **交叉检索**:实体命中后沿图边扩展相关事件节点,补充情境上下文。
|
||||
- **分桶式注入编排**:召回结果会按“当前状态 / 情景事件 / 反思锚点 / 规则约束”分组组织,降低碎片化注入。
|
||||
- **主动遗忘**:按保留价值归档低价值节点,缓解长期运行后的图谱膨胀。
|
||||
- **概率触发回忆**:未被主流程命中的高重要性节点有概率被额外召回。
|
||||
|
||||
@@ -42,7 +46,6 @@ ST-BME(ST-Bionic-Memory-Ecology)是一个运行在 SillyTavern 第三方扩
|
||||
以下方向在现有代码中仅有预留开关、设计痕迹或路线图描述,尚不应视为完整能力:
|
||||
|
||||
- **惊奇度分割 / 智能触发提取**
|
||||
- **反思条目自动生成**
|
||||
- **完整端到端测试与稳定性验证**
|
||||
- **图谱可视化面板**
|
||||
- **自定义 Schema 编辑体验增强**
|
||||
@@ -173,17 +176,17 @@ SillyTavern/public/scripts/extensions/third-party/ST-BME/
|
||||
|
||||
### 增强能力开关
|
||||
|
||||
| 能力 | 默认状态 | 当前判断 |
|
||||
| ------------ | -------- | ----------------- |
|
||||
| 记忆进化 | 开启 | 已实现 |
|
||||
| 精确对照 | 开启 | 实验性 |
|
||||
| 全局概要 | 开启 | 已实现 |
|
||||
| 认知边界 | 关闭 | 实验性 |
|
||||
| 交叉检索 | 关闭 | 实验性 |
|
||||
| 惊奇度分割 | 关闭 | 规划中 / 预留开关 |
|
||||
| 主动遗忘 | 关闭 | 实验性 |
|
||||
| 概率触发回忆 | 关闭 | 实验性 |
|
||||
| 反思条目 | 关闭 | 规划中 / 部分预留 |
|
||||
| 能力 | 默认状态 | 当前判断 |
|
||||
| ------------ | -------- | -------------------------- |
|
||||
| 记忆进化 | 开启 | 已实现 |
|
||||
| 精确对照 | 开启 | 实验性 |
|
||||
| 全局概要 | 开启 | 已实现 |
|
||||
| 认知边界 | 关闭 | 实验性 |
|
||||
| 交叉检索 | 关闭 | 实验性 |
|
||||
| 惊奇度分割 | 关闭 | 规划中 / 预留开关 |
|
||||
| 主动遗忘 | 关闭 | 实验性 |
|
||||
| 概率触发回忆 | 关闭 | 实验性 |
|
||||
| 反思条目 | 关闭 | 已实现(建议先小规模验证) |
|
||||
|
||||
## 数据模型
|
||||
|
||||
@@ -203,15 +206,15 @@ SillyTavern/public/scripts/extensions/third-party/ST-BME/
|
||||
|
||||
默认 Schema 定义在 [`schema.js`](ST-BME/schema.js)。当前内置节点类型包括:
|
||||
|
||||
| 类型 | 用途 | 当前状态 |
|
||||
| ------------ | -------------------- | ------------------------------ |
|
||||
| `event` | 事件、动作、剧情推进 | 已实现 |
|
||||
| `character` | 角色状态快照 | 已实现 |
|
||||
| `location` | 地点与环境状态 | 已实现 |
|
||||
| `rule` | 世界规则、约束与设定 | 已实现 |
|
||||
| `thread` | 主线与阶段性进度 | 已实现 |
|
||||
| `synopsis` | 全局前情提要 | 已实现 |
|
||||
| `reflection` | 反思与元认知记录 | 结构已定义,生成流程未完整落地 |
|
||||
| 类型 | 用途 | 当前状态 |
|
||||
| ------------ | -------------------- | ------------------------------------ |
|
||||
| `event` | 事件、动作、剧情推进 | 已实现 |
|
||||
| `character` | 角色状态快照 | 已实现 |
|
||||
| `location` | 地点与环境状态 | 已实现 |
|
||||
| `rule` | 世界规则、约束与设定 | 已实现 |
|
||||
| `thread` | 主线与阶段性进度 | 已实现 |
|
||||
| `synopsis` | 全局前情提要 | 已实现 |
|
||||
| `reflection` | 反思与元认知记录 | 已实现周期生成,建议结合实际剧情验证 |
|
||||
|
||||
### 关系类型
|
||||
|
||||
@@ -277,12 +280,14 @@ SillyTavern/public/scripts/extensions/third-party/ST-BME/
|
||||
- 交叉检索对召回质量的增益
|
||||
- 主动遗忘的阈值与副作用控制
|
||||
- 概率触发回忆的叙事收益与噪声控制
|
||||
- 情景重构召回在大图、复杂多线叙事中的排序稳定性
|
||||
|
||||
### 规划中
|
||||
|
||||
- 惊奇度驱动的提取触发机制
|
||||
- 反思节点自动生成闭环
|
||||
- 更完整的可视化与调试工具
|
||||
- 智能触发提取与惊奇度分割的进一步增强
|
||||
- 更系统的 benchmark、回归测试与使用文档
|
||||
|
||||
## 路线图
|
||||
|
||||
945
extractor.js
945
extractor.js
File diff suppressed because it is too large
Load Diff
151
injector.js
151
injector.js
@@ -1,7 +1,7 @@
|
||||
// ST-BME: Prompt 注入模块
|
||||
// 将检索结果格式化为表格注入到 LLM 上下文中
|
||||
|
||||
import { getSchemaType } from './schema.js';
|
||||
import { getSchemaType } from "./schema.js";
|
||||
|
||||
/**
|
||||
* 将检索结果转换为注入文本
|
||||
@@ -11,84 +11,121 @@ import { getSchemaType } from './schema.js';
|
||||
* @returns {string} 注入文本
|
||||
*/
|
||||
export function formatInjection(retrievalResult, schema) {
|
||||
const { coreNodes, recallNodes } = retrievalResult;
|
||||
const parts = [];
|
||||
const { coreNodes, recallNodes, groupedRecallNodes } = retrievalResult;
|
||||
const parts = [];
|
||||
|
||||
// ========== Core 常驻注入 ==========
|
||||
if (coreNodes.length > 0) {
|
||||
parts.push('[Memory - Core]');
|
||||
// ========== Core 常驻注入 ==========
|
||||
if (coreNodes.length > 0) {
|
||||
parts.push("[Memory - Core]");
|
||||
|
||||
// 按类型分组
|
||||
const grouped = groupByType(coreNodes);
|
||||
const grouped = groupByType(coreNodes);
|
||||
|
||||
for (const [typeId, nodes] of grouped) {
|
||||
const typeDef = getSchemaType(schema, typeId);
|
||||
if (!typeDef) continue;
|
||||
for (const [typeId, nodes] of grouped) {
|
||||
const typeDef = getSchemaType(schema, typeId);
|
||||
if (!typeDef) continue;
|
||||
|
||||
const table = formatTable(nodes, typeDef);
|
||||
if (table) parts.push(table);
|
||||
}
|
||||
const table = formatTable(nodes, typeDef);
|
||||
if (table) parts.push(table);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Recall 召回注入 ==========
|
||||
if (recallNodes.length > 0) {
|
||||
parts.push('');
|
||||
parts.push('[Memory - Recalled]');
|
||||
// ========== Recall 召回注入 ==========
|
||||
if (recallNodes.length > 0) {
|
||||
parts.push("");
|
||||
parts.push("[Memory - Recalled]");
|
||||
|
||||
const grouped = groupByType(recallNodes);
|
||||
const buckets = groupedRecallNodes || {
|
||||
state: recallNodes.filter(
|
||||
(n) => n.type === "character" || n.type === "location",
|
||||
),
|
||||
episodic: recallNodes.filter(
|
||||
(n) => n.type === "event" || n.type === "thread",
|
||||
),
|
||||
reflective: recallNodes.filter(
|
||||
(n) => n.type === "reflection" || n.type === "synopsis",
|
||||
),
|
||||
rule: recallNodes.filter((n) => n.type === "rule"),
|
||||
other: recallNodes.filter(
|
||||
(n) =>
|
||||
![
|
||||
"character",
|
||||
"location",
|
||||
"event",
|
||||
"thread",
|
||||
"reflection",
|
||||
"synopsis",
|
||||
"rule",
|
||||
].includes(n.type),
|
||||
),
|
||||
};
|
||||
|
||||
for (const [typeId, nodes] of grouped) {
|
||||
const typeDef = getSchemaType(schema, typeId);
|
||||
if (!typeDef) continue;
|
||||
appendBucket(parts, "当前状态记忆", buckets.state, schema);
|
||||
appendBucket(parts, "情景事件记忆", buckets.episodic, schema);
|
||||
appendBucket(parts, "反思与长期锚点", buckets.reflective, schema);
|
||||
appendBucket(parts, "规则与约束", buckets.rule, schema);
|
||||
appendBucket(parts, "其他关联记忆", buckets.other, schema);
|
||||
}
|
||||
|
||||
const table = formatTable(nodes, typeDef);
|
||||
if (table) parts.push(table);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型分组节点
|
||||
*/
|
||||
function groupByType(nodes) {
|
||||
const map = new Map();
|
||||
for (const node of nodes) {
|
||||
if (!map.has(node.type)) map.set(node.type, []);
|
||||
map.get(node.type).push(node);
|
||||
}
|
||||
return map;
|
||||
const map = new Map();
|
||||
for (const node of nodes) {
|
||||
if (!map.has(node.type)) map.set(node.type, []);
|
||||
map.get(node.type).push(node);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function appendBucket(parts, title, nodes, schema) {
|
||||
if (!nodes || nodes.length === 0) return;
|
||||
parts.push(`## ${title}`);
|
||||
|
||||
const grouped = groupByType(nodes);
|
||||
for (const [typeId, groupedNodes] of grouped) {
|
||||
const typeDef = getSchemaType(schema, typeId);
|
||||
if (!typeDef) continue;
|
||||
|
||||
const table = formatTable(groupedNodes, typeDef);
|
||||
if (table) parts.push(table);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将同类型节点格式化为 Markdown 表格
|
||||
*/
|
||||
function formatTable(nodes, typeDef) {
|
||||
if (nodes.length === 0) return '';
|
||||
if (nodes.length === 0) return "";
|
||||
|
||||
// 确定要展示的列(有实际数据的列)
|
||||
const activeCols = typeDef.columns.filter(col =>
|
||||
nodes.some(n => n.fields[col.name]),
|
||||
);
|
||||
// 确定要展示的列(有实际数据的列)
|
||||
const activeCols = typeDef.columns.filter((col) =>
|
||||
nodes.some((n) => n.fields[col.name]),
|
||||
);
|
||||
|
||||
if (activeCols.length === 0) return '';
|
||||
if (activeCols.length === 0) return "";
|
||||
|
||||
// 表头
|
||||
const header = `| ${activeCols.map(c => c.name).join(' | ')} |`;
|
||||
const separator = `| ${activeCols.map(() => '---').join(' | ')} |`;
|
||||
// 表头
|
||||
const header = `| ${activeCols.map((c) => c.name).join(" | ")} |`;
|
||||
const separator = `| ${activeCols.map(() => "---").join(" | ")} |`;
|
||||
|
||||
// 数据行
|
||||
const rows = nodes.map(node => {
|
||||
const cells = activeCols.map(col => {
|
||||
const val = node.fields[col.name] || '';
|
||||
// 转义管道符,限制单元格长度
|
||||
return String(val).replace(/\|/g, '\\|').replace(/\n/g, ' ').slice(0, 200);
|
||||
});
|
||||
return `| ${cells.join(' | ')} |`;
|
||||
// 数据行
|
||||
const rows = nodes.map((node) => {
|
||||
const cells = activeCols.map((col) => {
|
||||
const val = node.fields[col.name] || "";
|
||||
// 转义管道符,限制单元格长度
|
||||
return String(val)
|
||||
.replace(/\|/g, "\\|")
|
||||
.replace(/\n/g, " ")
|
||||
.slice(0, 200);
|
||||
});
|
||||
return `| ${cells.join(" | ")} |`;
|
||||
});
|
||||
|
||||
return `${typeDef.tableName}:\n${header}\n${separator}\n${rows.join('\n')}`;
|
||||
return `${typeDef.tableName}:\n${header}\n${separator}\n${rows.join("\n")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,9 +136,9 @@ function formatTable(nodes, typeDef) {
|
||||
* @returns {number} 估算 token 数
|
||||
*/
|
||||
export function estimateTokens(injectionText) {
|
||||
if (!injectionText) return 0;
|
||||
// 简单估算:中文 2 字符/token,英文 4 字符/token
|
||||
const cnChars = (injectionText.match(/[\u4e00-\u9fff]/g) || []).length;
|
||||
const otherChars = injectionText.length - cnChars;
|
||||
return Math.ceil(cnChars / 2 + otherChars / 4);
|
||||
if (!injectionText) return 0;
|
||||
// 简单估算:中文 2 字符/token,英文 4 字符/token
|
||||
const cnChars = (injectionText.match(/[\u4e00-\u9fff]/g) || []).length;
|
||||
const otherChars = injectionText.length - cnChars;
|
||||
return Math.ceil(cnChars / 2 + otherChars / 4);
|
||||
}
|
||||
|
||||
708
retriever.js
708
retriever.js
@@ -2,19 +2,24 @@
|
||||
// 融合向量预筛(PeroCore)+ 图扩散(PeroCore PEDSA)+ 可选 LLM 精确召回
|
||||
// v2: + 认知边界过滤(RoleRAG) + 双记忆交叉检索(AriGraph) + 概率触发
|
||||
|
||||
import { getActiveNodes, buildAdjacencyMap, buildTemporalAdjacencyMap, getNode, getNodeEdges } from './graph.js';
|
||||
import { propagateActivation, diffuseAndRank } from './diffusion.js';
|
||||
import { embedText, searchSimilar } from './embedding.js';
|
||||
import { hybridScore, reinforceAccessBatch } from './dynamics.js';
|
||||
import { callLLMForJSON } from './llm.js';
|
||||
import { diffuseAndRank } from "./diffusion.js";
|
||||
import { hybridScore, reinforceAccessBatch } from "./dynamics.js";
|
||||
import { embedText, searchSimilar } from "./embedding.js";
|
||||
import {
|
||||
buildTemporalAdjacencyMap,
|
||||
getActiveNodes,
|
||||
getNode,
|
||||
getNodeEdges,
|
||||
} from "./graph.js";
|
||||
import { callLLMForJSON } from "./llm.js";
|
||||
|
||||
/**
|
||||
* 自适应阈值
|
||||
*/
|
||||
const STRATEGY_THRESHOLDS = {
|
||||
SMALL: 20, // < 20 节点:跳过向量,全图 + LLM
|
||||
MEDIUM: 200, // 20-200 节点:向量 + 图扩散 + 评分(不调 LLM)
|
||||
// > 200 节点:三层全开
|
||||
SMALL: 20, // < 20 节点:跳过向量,全图 + LLM
|
||||
MEDIUM: 200, // 20-200 节点:向量 + 图扩散 + 评分(不调 LLM)
|
||||
// > 200 节点:三层全开
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,213 +35,240 @@ const STRATEGY_THRESHOLDS = {
|
||||
* @returns {Promise<RetrievalResult>}
|
||||
*/
|
||||
export async function retrieve({
|
||||
graph,
|
||||
userMessage,
|
||||
recentMessages = [],
|
||||
embeddingConfig,
|
||||
schema,
|
||||
options = {},
|
||||
graph,
|
||||
userMessage,
|
||||
recentMessages = [],
|
||||
embeddingConfig,
|
||||
schema,
|
||||
options = {},
|
||||
}) {
|
||||
const topK = options.topK ?? 15;
|
||||
const maxRecallNodes = options.maxRecallNodes ?? 8;
|
||||
const enableLLMRecall = options.enableLLMRecall ?? true;
|
||||
const weights = options.weights ?? {};
|
||||
const topK = options.topK ?? 15;
|
||||
const maxRecallNodes = options.maxRecallNodes ?? 8;
|
||||
const enableLLMRecall = options.enableLLMRecall ?? true;
|
||||
const weights = options.weights ?? {};
|
||||
|
||||
// v2 options
|
||||
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;
|
||||
// v2 options
|
||||
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;
|
||||
|
||||
let activeNodes = getActiveNodes(graph);
|
||||
let activeNodes = getActiveNodes(graph);
|
||||
|
||||
// v2 ⑦: 认知边界过滤(RoleRAG 启发)
|
||||
if (enableVisibility && visibilityFilter) {
|
||||
activeNodes = filterByVisibility(activeNodes, visibilityFilter);
|
||||
}
|
||||
// v2 ⑦: 认知边界过滤(RoleRAG 启发)
|
||||
if (enableVisibility && visibilityFilter) {
|
||||
activeNodes = filterByVisibility(activeNodes, visibilityFilter);
|
||||
}
|
||||
|
||||
const nodeCount = activeNodes.length;
|
||||
console.log(`[ST-BME] 检索开始: ${nodeCount} 个活跃节点${enableVisibility ? ' (认知边界已启用)' : ''}`);
|
||||
const nodeCount = activeNodes.length;
|
||||
console.log(
|
||||
`[ST-BME] 检索开始: ${nodeCount} 个活跃节点${enableVisibility ? " (认知边界已启用)" : ""}`,
|
||||
);
|
||||
|
||||
let vectorResults = [];
|
||||
let diffusionResults = [];
|
||||
let useLLM = false;
|
||||
let vectorResults = [];
|
||||
let diffusionResults = [];
|
||||
let useLLM = false;
|
||||
|
||||
if (nodeCount === 0) {
|
||||
return buildResult(graph, [], schema);
|
||||
}
|
||||
if (nodeCount === 0) {
|
||||
return buildResult(graph, [], schema);
|
||||
}
|
||||
|
||||
// ========== 第 1 层:向量预筛 ==========
|
||||
if (nodeCount >= STRATEGY_THRESHOLDS.SMALL && embeddingConfig?.apiUrl) {
|
||||
console.log('[ST-BME] 第1层: 向量预筛');
|
||||
vectorResults = await vectorPreFilter(userMessage, activeNodes, embeddingConfig, topK);
|
||||
}
|
||||
// ========== 第 1 层:向量预筛 ==========
|
||||
if (nodeCount >= STRATEGY_THRESHOLDS.SMALL && embeddingConfig?.apiUrl) {
|
||||
console.log("[ST-BME] 第1层: 向量预筛");
|
||||
vectorResults = await vectorPreFilter(
|
||||
userMessage,
|
||||
activeNodes,
|
||||
embeddingConfig,
|
||||
topK,
|
||||
);
|
||||
}
|
||||
|
||||
// ========== 第 2 层:图扩散 ==========
|
||||
if (nodeCount >= STRATEGY_THRESHOLDS.SMALL) {
|
||||
console.log('[ST-BME] 第2层: PEDSA 图扩散');
|
||||
const entityAnchors = extractEntityAnchors(userMessage, activeNodes);
|
||||
// ========== 第 2 层:图扩散 ==========
|
||||
if (nodeCount >= STRATEGY_THRESHOLDS.SMALL) {
|
||||
console.log("[ST-BME] 第2层: PEDSA 图扩散");
|
||||
const entityAnchors = extractEntityAnchors(userMessage, activeNodes);
|
||||
|
||||
const seeds = [
|
||||
...vectorResults.map(v => ({ id: v.nodeId, energy: v.score })),
|
||||
...entityAnchors.map(a => ({ id: a.nodeId, energy: 2.0 })),
|
||||
];
|
||||
const seeds = [
|
||||
...vectorResults.map((v) => ({ id: v.nodeId, energy: v.score })),
|
||||
...entityAnchors.map((a) => ({ id: a.nodeId, energy: 2.0 })),
|
||||
];
|
||||
|
||||
// v2 ⑧: 双记忆交叉检索(AriGraph 启发)
|
||||
// 实体锚点命中后,沿边展开关联的情景节点作为额外种子
|
||||
if (enableCrossRecall && entityAnchors.length > 0) {
|
||||
for (const anchor of entityAnchors) {
|
||||
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 }));
|
||||
|
||||
if (uniqueSeeds.length > 0) {
|
||||
const adjacencyMap = buildTemporalAdjacencyMap(graph);
|
||||
diffusionResults = diffuseAndRank(adjacencyMap, uniqueSeeds, {
|
||||
maxSteps: 2,
|
||||
decayFactor: 0.6,
|
||||
topK: 100,
|
||||
});
|
||||
// v2 ⑧: 双记忆交叉检索(AriGraph 启发)
|
||||
// 实体锚点命中后,沿边展开关联的情景节点作为额外种子
|
||||
if (enableCrossRecall && entityAnchors.length > 0) {
|
||||
for (const anchor of entityAnchors) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 第 3 层:混合评分 + 可选 LLM 精确 ==========
|
||||
console.log('[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);
|
||||
// 去重种子
|
||||
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,
|
||||
}));
|
||||
|
||||
// 添加图扩散得分
|
||||
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 (uniqueSeeds.length > 0) {
|
||||
const adjacencyMap = buildTemporalAdjacencyMap(graph);
|
||||
diffusionResults = diffuseAndRank(adjacencyMap, uniqueSeeds, {
|
||||
maxSteps: 2,
|
||||
decayFactor: 0.6,
|
||||
topK: 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 小图模式:所有节点都参与评分
|
||||
if (nodeCount < STRATEGY_THRESHOLDS.SMALL) {
|
||||
for (const node of activeNodes) {
|
||||
if (!scoreMap.has(node.id)) {
|
||||
scoreMap.set(node.id, { graphScore: 0, vectorScore: 0 });
|
||||
}
|
||||
}
|
||||
// ========== 第 3 层:混合评分 + 可选 LLM 精确 ==========
|
||||
console.log("[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 (nodeCount < STRATEGY_THRESHOLDS.SMALL) {
|
||||
for (const node of activeNodes) {
|
||||
if (!scoreMap.has(node.id)) {
|
||||
scoreMap.set(node.id, { graphScore: 0, vectorScore: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算混合得分
|
||||
const scoredNodes = [];
|
||||
for (const [nodeId, scores] of scoreMap) {
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node || node.archived) continue;
|
||||
// 计算混合得分
|
||||
const scoredNodes = [];
|
||||
for (const [nodeId, scores] of scoreMap) {
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node || node.archived) continue;
|
||||
|
||||
const finalScore = hybridScore({
|
||||
graphScore: scores.graphScore,
|
||||
vectorScore: scores.vectorScore,
|
||||
importance: node.importance,
|
||||
createdTime: node.createdTime,
|
||||
}, weights);
|
||||
|
||||
scoredNodes.push({ nodeId, node, finalScore, ...scores });
|
||||
}
|
||||
|
||||
scoredNodes.sort((a, b) => b.finalScore - a.finalScore);
|
||||
|
||||
// 决定是否使用 LLM 精确召回
|
||||
useLLM = enableLLMRecall && (
|
||||
nodeCount < STRATEGY_THRESHOLDS.SMALL || // 小图:直接 LLM
|
||||
nodeCount > STRATEGY_THRESHOLDS.MEDIUM // 大图:LLM 精确
|
||||
const finalScore = hybridScore(
|
||||
{
|
||||
graphScore: scores.graphScore,
|
||||
vectorScore: scores.vectorScore,
|
||||
importance: node.importance,
|
||||
createdTime: node.createdTime,
|
||||
},
|
||||
weights,
|
||||
);
|
||||
|
||||
let selectedNodeIds;
|
||||
scoredNodes.push({ nodeId, node, finalScore, ...scores });
|
||||
}
|
||||
|
||||
if (useLLM && nodeCount > 0) {
|
||||
console.log('[ST-BME] LLM 精确召回');
|
||||
const candidateNodes = scoredNodes.slice(0, Math.min(30, scoredNodes.length));
|
||||
selectedNodeIds = await llmRecall(
|
||||
userMessage,
|
||||
recentMessages,
|
||||
candidateNodes,
|
||||
graph,
|
||||
schema,
|
||||
maxRecallNodes,
|
||||
scoredNodes.sort((a, b) => b.finalScore - a.finalScore);
|
||||
|
||||
// 决定是否使用 LLM 精确召回
|
||||
useLLM =
|
||||
enableLLMRecall &&
|
||||
(nodeCount < STRATEGY_THRESHOLDS.SMALL || // 小图:直接 LLM
|
||||
nodeCount > STRATEGY_THRESHOLDS.MEDIUM); // 大图:LLM 精确
|
||||
|
||||
let selectedNodeIds;
|
||||
|
||||
if (useLLM && nodeCount > 0) {
|
||||
console.log("[ST-BME] LLM 精确召回");
|
||||
const candidateNodes = scoredNodes.slice(
|
||||
0,
|
||||
Math.min(30, scoredNodes.length),
|
||||
);
|
||||
selectedNodeIds = await llmRecall(
|
||||
userMessage,
|
||||
recentMessages,
|
||||
candidateNodes,
|
||||
graph,
|
||||
schema,
|
||||
maxRecallNodes,
|
||||
);
|
||||
} else {
|
||||
// 中等图:直接取 Top-N
|
||||
selectedNodeIds = scoredNodes.slice(0, topK).map((s) => s.nodeId);
|
||||
}
|
||||
|
||||
selectedNodeIds = reconstructSceneNodeIds(graph, selectedNodeIds, topK + 6);
|
||||
|
||||
// 访问强化
|
||||
const selectedNodes = selectedNodeIds
|
||||
.map((id) => getNode(graph, id))
|
||||
.filter(Boolean);
|
||||
|
||||
reinforceAccessBatch(selectedNodes);
|
||||
|
||||
console.log(`[ST-BME] 检索完成: 选中 ${selectedNodeIds.length} 个节点`);
|
||||
|
||||
// v2 ⑧: 概率触发回忆
|
||||
// 未被选中的高重要性节点有概率随机激活
|
||||
if (enableProbRecall && probRecallChance > 0) {
|
||||
const selectedSet = new Set(selectedNodeIds);
|
||||
const candidates = activeNodes.filter(
|
||||
(n) =>
|
||||
!selectedSet.has(n.id) &&
|
||||
n.importance >= 6 &&
|
||||
n.type !== "synopsis" &&
|
||||
n.type !== "rule",
|
||||
);
|
||||
for (const c of candidates) {
|
||||
if (Math.random() < probRecallChance) {
|
||||
selectedNodeIds.push(c.id);
|
||||
console.log(
|
||||
`[ST-BME] 概率触发: ${c.fields?.name || c.fields?.summary || c.id}`,
|
||||
);
|
||||
} else {
|
||||
// 中等图:直接取 Top-N
|
||||
selectedNodeIds = scoredNodes
|
||||
.slice(0, topK)
|
||||
.map(s => s.nodeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 访问强化
|
||||
const selectedNodes = selectedNodeIds
|
||||
.map(id => getNode(graph, id))
|
||||
.filter(Boolean);
|
||||
selectedNodeIds = uniqueNodeIds(selectedNodeIds);
|
||||
|
||||
reinforceAccessBatch(selectedNodes);
|
||||
|
||||
console.log(`[ST-BME] 检索完成: 选中 ${selectedNodeIds.length} 个节点`);
|
||||
|
||||
// v2 ⑧: 概率触发回忆
|
||||
// 未被选中的高重要性节点有概率随机激活
|
||||
if (enableProbRecall && probRecallChance > 0) {
|
||||
const selectedSet = new Set(selectedNodeIds);
|
||||
const candidates = activeNodes.filter(n =>
|
||||
!selectedSet.has(n.id) &&
|
||||
n.importance >= 6 &&
|
||||
n.type !== 'synopsis' &&
|
||||
n.type !== 'rule',
|
||||
);
|
||||
for (const c of candidates) {
|
||||
if (Math.random() < probRecallChance) {
|
||||
selectedNodeIds.push(c.id);
|
||||
console.log(`[ST-BME] 概率触发: ${c.fields?.name || c.fields?.summary || c.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildResult(graph, selectedNodeIds, schema);
|
||||
return buildResult(graph, selectedNodeIds, schema);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向量预筛选
|
||||
*/
|
||||
async function vectorPreFilter(userMessage, activeNodes, embeddingConfig, topK) {
|
||||
try {
|
||||
const queryVec = await embedText(userMessage, embeddingConfig);
|
||||
if (!queryVec) return [];
|
||||
async function vectorPreFilter(
|
||||
userMessage,
|
||||
activeNodes,
|
||||
embeddingConfig,
|
||||
topK,
|
||||
) {
|
||||
try {
|
||||
const queryVec = await embedText(userMessage, embeddingConfig);
|
||||
if (!queryVec) return [];
|
||||
|
||||
const candidates = activeNodes
|
||||
.filter(n => n.embedding)
|
||||
.map(n => ({ nodeId: n.id, embedding: n.embedding }));
|
||||
const candidates = activeNodes
|
||||
.filter((n) => n.embedding)
|
||||
.map((n) => ({ nodeId: n.id, embedding: n.embedding }));
|
||||
|
||||
return searchSimilar(queryVec, candidates, topK);
|
||||
} catch (e) {
|
||||
console.error('[ST-BME] 向量预筛失败:', e);
|
||||
return [];
|
||||
}
|
||||
return searchSimilar(queryVec, candidates, topK);
|
||||
} catch (e) {
|
||||
console.error("[ST-BME] 向量预筛失败:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,75 +276,88 @@ async function vectorPreFilter(userMessage, activeNodes, embeddingConfig, topK)
|
||||
* 从用户消息中提取名词/实体,匹配图中的节点名称
|
||||
*/
|
||||
function extractEntityAnchors(userMessage, activeNodes) {
|
||||
const anchors = [];
|
||||
const anchors = [];
|
||||
|
||||
for (const node of activeNodes) {
|
||||
// 检查 name 字段
|
||||
const name = node.fields?.name;
|
||||
if (name && userMessage.includes(name)) {
|
||||
anchors.push({ nodeId: node.id, entity: name });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查 title 字段
|
||||
const title = node.fields?.title;
|
||||
if (title && userMessage.includes(title)) {
|
||||
anchors.push({ nodeId: node.id, entity: title });
|
||||
}
|
||||
for (const node of activeNodes) {
|
||||
// 检查 name 字段
|
||||
const name = node.fields?.name;
|
||||
if (name && userMessage.includes(name)) {
|
||||
anchors.push({ nodeId: node.id, entity: name });
|
||||
continue;
|
||||
}
|
||||
|
||||
return anchors;
|
||||
// 检查 title 字段
|
||||
const title = node.fields?.title;
|
||||
if (title && userMessage.includes(title)) {
|
||||
anchors.push({ nodeId: node.id, entity: title });
|
||||
}
|
||||
}
|
||||
|
||||
return anchors;
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 精确召回
|
||||
*/
|
||||
async function llmRecall(userMessage, recentMessages, candidates, graph, schema, maxNodes) {
|
||||
const contextStr = recentMessages.join('\n---\n');
|
||||
const candidateDescriptions = candidates.map(c => {
|
||||
const node = c.node;
|
||||
const typeDef = schema.find(s => s.id === node.type);
|
||||
const typeLabel = typeDef?.label || node.type;
|
||||
const fieldsStr = Object.entries(node.fields)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(', ');
|
||||
return `[${node.id}] 类型=${typeLabel}, ${fieldsStr} (评分=${c.finalScore.toFixed(3)})`;
|
||||
}).join('\n');
|
||||
async function llmRecall(
|
||||
userMessage,
|
||||
recentMessages,
|
||||
candidates,
|
||||
graph,
|
||||
schema,
|
||||
maxNodes,
|
||||
) {
|
||||
const contextStr = recentMessages.join("\n---\n");
|
||||
const candidateDescriptions = candidates
|
||||
.map((c) => {
|
||||
const node = c.node;
|
||||
const typeDef = schema.find((s) => s.id === node.type);
|
||||
const typeLabel = typeDef?.label || node.type;
|
||||
const fieldsStr = Object.entries(node.fields)
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(", ");
|
||||
return `[${node.id}] 类型=${typeLabel}, ${fieldsStr} (评分=${c.finalScore.toFixed(3)})`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const systemPrompt = [
|
||||
'你是一个记忆召回分析器。',
|
||||
'根据用户最新输入和对话上下文,从候选记忆节点中选择最相关的节点。',
|
||||
'优先选择:(1) 直接相关的当前场景节点, (2) 因果关系连续性节点, (3) 有潜在影响的背景节点。',
|
||||
`最多选择 ${maxNodes} 个节点。`,
|
||||
'输出严格的 JSON 格式:',
|
||||
'{"selected_ids": ["id1", "id2", ...], "reason": "简要说明选择理由"}',
|
||||
].join('\n');
|
||||
const systemPrompt = [
|
||||
"你是一个记忆召回分析器。",
|
||||
"根据用户最新输入和对话上下文,从候选记忆节点中选择最相关的节点。",
|
||||
"优先选择:(1) 直接相关的当前场景节点, (2) 因果关系连续性节点, (3) 有潜在影响的背景节点。",
|
||||
`最多选择 ${maxNodes} 个节点。`,
|
||||
"输出严格的 JSON 格式:",
|
||||
'{"selected_ids": ["id1", "id2", ...], "reason": "简要说明选择理由"}',
|
||||
].join("\n");
|
||||
|
||||
const userPrompt = [
|
||||
'## 最近对话上下文',
|
||||
contextStr || '(无)',
|
||||
'',
|
||||
'## 用户最新输入',
|
||||
userMessage,
|
||||
'',
|
||||
'## 候选记忆节点',
|
||||
candidateDescriptions,
|
||||
'',
|
||||
'请选择最相关的节点并输出 JSON。',
|
||||
].join('\n');
|
||||
const userPrompt = [
|
||||
"## 最近对话上下文",
|
||||
contextStr || "(无)",
|
||||
"",
|
||||
"## 用户最新输入",
|
||||
userMessage,
|
||||
"",
|
||||
"## 候选记忆节点",
|
||||
candidateDescriptions,
|
||||
"",
|
||||
"请选择最相关的节点并输出 JSON。",
|
||||
].join("\n");
|
||||
|
||||
const result = await callLLMForJSON({ systemPrompt, userPrompt, maxRetries: 1 });
|
||||
const result = await callLLMForJSON({
|
||||
systemPrompt,
|
||||
userPrompt,
|
||||
maxRetries: 1,
|
||||
});
|
||||
|
||||
if (result?.selected_ids && Array.isArray(result.selected_ids)) {
|
||||
// 校验 ID 有效性
|
||||
const validIds = result.selected_ids.filter(
|
||||
id => candidates.some(c => c.nodeId === id),
|
||||
);
|
||||
return validIds;
|
||||
}
|
||||
if (result?.selected_ids && Array.isArray(result.selected_ids)) {
|
||||
// 校验 ID 有效性
|
||||
const validIds = result.selected_ids.filter((id) =>
|
||||
candidates.some((c) => c.nodeId === id),
|
||||
);
|
||||
return validIds;
|
||||
}
|
||||
|
||||
// LLM 失败时回退到纯评分排序
|
||||
return candidates.slice(0, maxNodes).map(c => c.nodeId);
|
||||
// LLM 失败时回退到纯评分排序
|
||||
return candidates.slice(0, maxNodes).map((c) => c.nodeId);
|
||||
}
|
||||
|
||||
// ==================== v2 辅助函数 ====================
|
||||
@@ -325,20 +370,20 @@ async function llmRecall(userMessage, recentMessages, candidates, graph, schema,
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function filterByVisibility(nodes, characterName) {
|
||||
return nodes.filter(node => {
|
||||
// 没有 visibility 字段 → 对所有人可见
|
||||
if (!node.fields?.visibility) return true;
|
||||
// visibility 是数组 → 检查当前角色是否在列表中
|
||||
if (Array.isArray(node.fields.visibility)) {
|
||||
return node.fields.visibility.includes(characterName);
|
||||
}
|
||||
// visibility 是字符串(逗号分隔)→ 解析后检查
|
||||
if (typeof node.fields.visibility === 'string') {
|
||||
const visibleTo = node.fields.visibility.split(',').map(s => s.trim());
|
||||
return visibleTo.includes(characterName) || visibleTo.includes('*');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return nodes.filter((node) => {
|
||||
// 没有 visibility 字段 → 对所有人可见
|
||||
if (!node.fields?.visibility) return true;
|
||||
// visibility 是数组 → 检查当前角色是否在列表中
|
||||
if (Array.isArray(node.fields.visibility)) {
|
||||
return node.fields.visibility.includes(characterName);
|
||||
}
|
||||
// visibility 是字符串(逗号分隔)→ 解析后检查
|
||||
if (typeof node.fields.visibility === "string") {
|
||||
const visibleTo = node.fields.visibility.split(",").map((s) => s.trim());
|
||||
return visibleTo.includes(characterName) || visibleTo.includes("*");
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -346,41 +391,144 @@ function filterByVisibility(nodes, characterName) {
|
||||
* 分离常驻注入(Core)和召回注入(Recall)
|
||||
*/
|
||||
function buildResult(graph, selectedNodeIds, schema) {
|
||||
const coreNodes = []; // 常驻注入
|
||||
const recallNodes = []; // 召回注入
|
||||
const coreNodes = []; // 常驻注入
|
||||
const recallNodes = []; // 召回注入
|
||||
|
||||
// 常驻注入节点(alwaysInject=true 的类型)
|
||||
const alwaysInjectTypes = new Set(
|
||||
schema.filter(s => s.alwaysInject).map(s => s.id),
|
||||
);
|
||||
// 常驻注入节点(alwaysInject=true 的类型)
|
||||
const alwaysInjectTypes = new Set(
|
||||
schema.filter((s) => s.alwaysInject).map((s) => s.id),
|
||||
);
|
||||
|
||||
const activeNodes = getActiveNodes(graph);
|
||||
const activeNodes = getActiveNodes(graph);
|
||||
|
||||
for (const node of activeNodes) {
|
||||
if (alwaysInjectTypes.has(node.type)) {
|
||||
coreNodes.push(node);
|
||||
}
|
||||
for (const node of activeNodes) {
|
||||
if (alwaysInjectTypes.has(node.type)) {
|
||||
coreNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
// 召回注入节点
|
||||
const selectedSet = new Set(selectedNodeIds);
|
||||
for (const nodeId of selectedNodeIds) {
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node) continue;
|
||||
// 已在 Core 中的不重复添加
|
||||
if (!alwaysInjectTypes.has(node.type)) {
|
||||
recallNodes.push(node);
|
||||
}
|
||||
for (const nodeId of selectedNodeIds) {
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node) continue;
|
||||
if (!alwaysInjectTypes.has(node.type)) {
|
||||
recallNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
coreNodes,
|
||||
recallNodes,
|
||||
selectedNodeIds: [...selectedNodeIds],
|
||||
stats: {
|
||||
totalActive: activeNodes.length,
|
||||
coreCount: coreNodes.length,
|
||||
recallCount: recallNodes.length,
|
||||
},
|
||||
};
|
||||
const groupedRecallNodes = groupRecallNodes(recallNodes);
|
||||
|
||||
return {
|
||||
coreNodes,
|
||||
recallNodes,
|
||||
groupedRecallNodes,
|
||||
selectedNodeIds: [...selectedNodeIds],
|
||||
stats: {
|
||||
totalActive: activeNodes.length,
|
||||
coreCount: coreNodes.length,
|
||||
recallCount: recallNodes.length,
|
||||
episodicCount: groupedRecallNodes.episodic.length,
|
||||
stateCount: groupedRecallNodes.state.length,
|
||||
reflectiveCount: groupedRecallNodes.reflective.length,
|
||||
ruleCount: groupedRecallNodes.rule.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function reconstructSceneNodeIds(graph, seedNodeIds, limit = 16) {
|
||||
const selected = [];
|
||||
const seen = new Set();
|
||||
|
||||
function push(nodeId) {
|
||||
if (!nodeId || seen.has(nodeId)) return;
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node || node.archived) return;
|
||||
seen.add(nodeId);
|
||||
selected.push(nodeId);
|
||||
}
|
||||
|
||||
for (const nodeId of seedNodeIds) {
|
||||
push(nodeId);
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
if (node.type === "event") {
|
||||
expandEventScene(graph, node, push);
|
||||
} else if (node.type === "character" || node.type === "location") {
|
||||
const relatedEvents = getNodeEdges(graph, node.id)
|
||||
.filter((e) => !e.invalidAt)
|
||||
.map((e) => (e.fromId === node.id ? e.toId : e.fromId))
|
||||
.map((id) => getNode(graph, id))
|
||||
.filter((n) => n && n.type === "event")
|
||||
.sort((a, b) => b.seq - a.seq)
|
||||
.slice(0, 2);
|
||||
for (const eventNode of relatedEvents) {
|
||||
push(eventNode.id);
|
||||
expandEventScene(graph, eventNode, push);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.length >= limit) break;
|
||||
}
|
||||
|
||||
return selected.slice(0, limit);
|
||||
}
|
||||
|
||||
function expandEventScene(graph, eventNode, push) {
|
||||
const edges = getNodeEdges(graph, eventNode.id).filter((e) => !e.invalidAt);
|
||||
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"
|
||||
) {
|
||||
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)
|
||||
.sort((a, b) => Math.abs(a.seq - seq) - Math.abs(b.seq - seq))
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
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)];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user