mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
feat: update smart trigger and graph retrieval
This commit is contained in:
172
diffusion.js
172
diffusion.js
@@ -32,12 +32,12 @@ const INHIBIT_EDGE_TYPE = 255;
|
||||
* 默认配置
|
||||
*/
|
||||
const DEFAULT_OPTIONS = {
|
||||
maxSteps: 2, // 最大扩散步数
|
||||
decayFactor: 0.6, // 每步衰减因子
|
||||
topK: 100, // 每步保留的最大活跃节点数
|
||||
minEnergy: 0.01, // 最小有效能量(低于此值视为不活跃)
|
||||
maxEnergy: 2.0, // 能量上限
|
||||
minEnergy_clamp: -2.0, // 能量下限(抑制)
|
||||
maxSteps: 2, // 最大扩散步数
|
||||
decayFactor: 0.6, // 每步衰减因子
|
||||
topK: 100, // 每步保留的最大活跃节点数
|
||||
minEnergy: 0.01, // 最小有效能量(低于此值视为不活跃)
|
||||
maxEnergy: 2.0, // 能量上限
|
||||
minEnergy_clamp: -2.0, // 能量下限(抑制)
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -58,85 +58,88 @@ const DEFAULT_OPTIONS = {
|
||||
* nodeId → energy(正值=激活,负值=抑制)
|
||||
*/
|
||||
export function propagateActivation(adjacencyMap, seedNodes, options = {}) {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
// Step 0: 初始化能量表
|
||||
/** @type {Map<string, number>} */
|
||||
let currentEnergy = new Map();
|
||||
|
||||
for (const seed of seedNodes || []) {
|
||||
if (!seed?.id) continue;
|
||||
const clamped = clampEnergy(Number(seed.energy) || 0, opts);
|
||||
if (Math.abs(clamped) >= opts.minEnergy) {
|
||||
const existing = currentEnergy.get(seed.id) || 0;
|
||||
currentEnergy.set(seed.id, clampEnergy(existing + clamped, opts));
|
||||
}
|
||||
}
|
||||
|
||||
// 累积结果(所有步骤的最大能量)
|
||||
/** @type {Map<string, number>} */
|
||||
const result = new Map(currentEnergy);
|
||||
|
||||
// Step 1~N: 逐步扩散
|
||||
for (let step = 0; step < opts.maxSteps; step++) {
|
||||
/** @type {Map<string, number>} */
|
||||
let currentEnergy = new Map();
|
||||
const nextEnergy = new Map();
|
||||
|
||||
for (const seed of seedNodes) {
|
||||
const clamped = clampEnergy(seed.energy, opts);
|
||||
if (Math.abs(clamped) >= opts.minEnergy) {
|
||||
currentEnergy.set(seed.id, clamped);
|
||||
// 对每个当前活跃节点,传播能量到邻居
|
||||
for (const [nodeId, energy] of currentEnergy) {
|
||||
const neighbors = adjacencyMap.get(nodeId);
|
||||
if (!Array.isArray(neighbors) || neighbors.length === 0) continue;
|
||||
|
||||
for (const neighbor of neighbors) {
|
||||
if (!neighbor?.targetId) continue;
|
||||
let propagated =
|
||||
energy * (Number(neighbor.strength) || 0) * opts.decayFactor;
|
||||
|
||||
// 抑制边:传递负能量
|
||||
if (neighbor.edgeType === INHIBIT_EDGE_TYPE) {
|
||||
propagated = -Math.abs(propagated);
|
||||
}
|
||||
|
||||
// 累加到邻居节点
|
||||
const existing = nextEnergy.get(neighbor.targetId) || 0;
|
||||
nextEnergy.set(neighbor.targetId, existing + propagated);
|
||||
}
|
||||
}
|
||||
|
||||
// 累积结果(所有步骤的最大能量)
|
||||
/** @type {Map<string, number>} */
|
||||
const result = new Map(currentEnergy);
|
||||
|
||||
// Step 1~N: 逐步扩散
|
||||
for (let step = 0; step < opts.maxSteps; step++) {
|
||||
/** @type {Map<string, number>} */
|
||||
const nextEnergy = new Map();
|
||||
|
||||
// 对每个当前活跃节点,传播能量到邻居
|
||||
for (const [nodeId, energy] of currentEnergy) {
|
||||
const neighbors = adjacencyMap.get(nodeId);
|
||||
if (!neighbors) continue;
|
||||
|
||||
for (const neighbor of neighbors) {
|
||||
// 计算传播能量
|
||||
let propagated = energy * neighbor.strength * opts.decayFactor;
|
||||
|
||||
// 抑制边:传递负能量
|
||||
if (neighbor.edgeType === INHIBIT_EDGE_TYPE) {
|
||||
propagated = -Math.abs(propagated);
|
||||
}
|
||||
|
||||
// 累加到邻居节点
|
||||
const existing = nextEnergy.get(neighbor.targetId) || 0;
|
||||
nextEnergy.set(neighbor.targetId, existing + propagated);
|
||||
}
|
||||
}
|
||||
|
||||
// 钳位 + 过滤低能量
|
||||
for (const [nodeId, energy] of nextEnergy) {
|
||||
const clamped = clampEnergy(energy, opts);
|
||||
if (Math.abs(clamped) < opts.minEnergy) {
|
||||
nextEnergy.delete(nodeId);
|
||||
} else {
|
||||
nextEnergy.set(nodeId, clamped);
|
||||
}
|
||||
}
|
||||
|
||||
// 动态剪枝:只保留 Top-K
|
||||
if (nextEnergy.size > opts.topK) {
|
||||
const sorted = [...nextEnergy.entries()]
|
||||
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]));
|
||||
|
||||
nextEnergy.clear();
|
||||
for (let i = 0; i < opts.topK && i < sorted.length; i++) {
|
||||
nextEnergy.set(sorted[i][0], sorted[i][1]);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新累积结果(取各步骤最大绝对值能量)
|
||||
for (const [nodeId, energy] of nextEnergy) {
|
||||
const existing = result.get(nodeId) || 0;
|
||||
if (Math.abs(energy) > Math.abs(existing)) {
|
||||
result.set(nodeId, energy);
|
||||
}
|
||||
}
|
||||
|
||||
// 准备下一步
|
||||
currentEnergy = nextEnergy;
|
||||
|
||||
// 如果没有活跃节点了,提前终止
|
||||
if (currentEnergy.size === 0) break;
|
||||
// 钳位 + 过滤低能量
|
||||
for (const [nodeId, energy] of nextEnergy) {
|
||||
const clamped = clampEnergy(energy, opts);
|
||||
if (Math.abs(clamped) < opts.minEnergy) {
|
||||
nextEnergy.delete(nodeId);
|
||||
} else {
|
||||
nextEnergy.set(nodeId, clamped);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
// 动态剪枝:只保留 Top-K
|
||||
if (nextEnergy.size > opts.topK) {
|
||||
const sorted = [...nextEnergy.entries()].sort(
|
||||
(a, b) => Math.abs(b[1]) - Math.abs(a[1]),
|
||||
);
|
||||
|
||||
nextEnergy.clear();
|
||||
for (let i = 0; i < opts.topK && i < sorted.length; i++) {
|
||||
nextEnergy.set(sorted[i][0], sorted[i][1]);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新累积结果(取各步骤最大绝对值能量)
|
||||
for (const [nodeId, energy] of nextEnergy) {
|
||||
const existing = result.get(nodeId) || 0;
|
||||
if (Math.abs(energy) > Math.abs(existing)) {
|
||||
result.set(nodeId, energy);
|
||||
}
|
||||
}
|
||||
|
||||
// 准备下一步
|
||||
currentEnergy = nextEnergy;
|
||||
|
||||
// 如果没有活跃节点了,提前终止
|
||||
if (currentEnergy.size === 0) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +149,7 @@ export function propagateActivation(adjacencyMap, seedNodes, options = {}) {
|
||||
* @returns {number}
|
||||
*/
|
||||
function clampEnergy(energy, opts) {
|
||||
return Math.max(opts.minEnergy_clamp, Math.min(opts.maxEnergy, energy));
|
||||
return Math.max(opts.minEnergy_clamp, Math.min(opts.maxEnergy, energy));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,10 +161,13 @@ function clampEnergy(energy, opts) {
|
||||
* @returns {Array<{nodeId: string, energy: number}>} 按能量降序排列
|
||||
*/
|
||||
export function diffuseAndRank(adjacencyMap, seeds, options = {}) {
|
||||
const energyMap = propagateActivation(adjacencyMap, seeds, options);
|
||||
const energyMap = propagateActivation(adjacencyMap, seeds, options);
|
||||
|
||||
return [...energyMap.entries()]
|
||||
.filter(([_, energy]) => energy > 0) // 只返回正能量(被激活的)
|
||||
.map(([nodeId, energy]) => ({ nodeId, energy }))
|
||||
.sort((a, b) => b.energy - a.energy);
|
||||
return [...energyMap.entries()]
|
||||
.filter(([_, energy]) => energy > 0)
|
||||
.map(([nodeId, energy]) => ({ nodeId, energy }))
|
||||
.sort((a, b) => {
|
||||
if (b.energy !== a.energy) return b.energy - a.energy;
|
||||
return String(a.nodeId).localeCompare(String(b.nodeId));
|
||||
});
|
||||
}
|
||||
|
||||
102
extractor.js
102
extractor.js
@@ -22,18 +22,22 @@ import { RELATION_TYPES } from "./schema.js";
|
||||
*
|
||||
* @param {object} params
|
||||
* @param {object} params.graph - 当前图状态
|
||||
* @param {Array<{role: string, content: string}>} params.messages - 要处理的对话消息
|
||||
* @param {number} params.startSeq - 起始楼层号
|
||||
* @param {Array<{seq?: number, role: string, content: string}>} params.messages - 要处理的对话消息
|
||||
* @param {number} params.startSeq - 本批处理的首个 assistant 消息 chat 索引
|
||||
* @param {number} params.endSeq - 本批处理的末个 assistant 消息 chat 索引
|
||||
* @param {number} [params.lastProcessedSeq] - 上次处理到的 chat 索引
|
||||
* @param {object[]} params.schema - 节点类型 Schema
|
||||
* @param {object} params.embeddingConfig - Embedding API 配置
|
||||
* @param {string} [params.extractPrompt] - 自定义提取提示词
|
||||
* @param {object} [params.v2Options] - v2 增强选项
|
||||
* @returns {Promise<{success: boolean, newNodes: number, updatedNodes: number, newEdges: number, newNodeIds: string[]}>}
|
||||
* @returns {Promise<{success: boolean, newNodes: number, updatedNodes: number, newEdges: number, newNodeIds: string[], processedRange: [number, number]}>}
|
||||
*/
|
||||
export async function extractMemories({
|
||||
graph,
|
||||
messages,
|
||||
startSeq,
|
||||
endSeq,
|
||||
lastProcessedSeq = -1,
|
||||
schema,
|
||||
embeddingConfig,
|
||||
extractPrompt,
|
||||
@@ -46,17 +50,33 @@ export async function extractMemories({
|
||||
updatedNodes: 0,
|
||||
newEdges: 0,
|
||||
newNodeIds: [],
|
||||
processedRange: [lastProcessedSeq, lastProcessedSeq],
|
||||
};
|
||||
}
|
||||
|
||||
const enablePreciseConflict = v2Options.enablePreciseConflict ?? true;
|
||||
const conflictThreshold = v2Options.conflictThreshold ?? 0.85;
|
||||
|
||||
console.log(`[ST-BME] 提取开始: 楼层 ${startSeq}, ${messages.length} 条消息`);
|
||||
const effectiveStartSeq = Number.isFinite(startSeq)
|
||||
? startSeq
|
||||
: (messages.find((m) => Number.isFinite(m.seq))?.seq ??
|
||||
lastProcessedSeq + 1);
|
||||
const effectiveEndSeq = Number.isFinite(endSeq)
|
||||
? endSeq
|
||||
: ([...messages].reverse().find((m) => Number.isFinite(m.seq))?.seq ??
|
||||
effectiveStartSeq);
|
||||
const currentSeq = effectiveEndSeq;
|
||||
|
||||
console.log(
|
||||
`[ST-BME] 提取开始: chat[${effectiveStartSeq}..${effectiveEndSeq}], ${messages.length} 条消息`,
|
||||
);
|
||||
|
||||
// 构建对话文本
|
||||
const dialogueText = messages
|
||||
.map((m) => `[${m.role}]: ${m.content}`)
|
||||
.map((m) => {
|
||||
const seqLabel = Number.isFinite(m.seq) ? `#${m.seq}` : "#?";
|
||||
return `${seqLabel} [${m.role}]: ${m.content}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
// 构建当前图概览(让 LLM 知道已有哪些节点,避免重复)
|
||||
@@ -89,7 +109,7 @@ export async function extractMemories({
|
||||
maxRetries: 2,
|
||||
});
|
||||
|
||||
if (!result || !result.operations) {
|
||||
if (!result || !Array.isArray(result.operations)) {
|
||||
console.warn("[ST-BME] 提取 LLM 未返回有效操作");
|
||||
return {
|
||||
success: false,
|
||||
@@ -97,6 +117,7 @@ export async function extractMemories({
|
||||
updatedNodes: 0,
|
||||
newEdges: 0,
|
||||
newNodeIds: [],
|
||||
processedRange: [lastProcessedSeq, lastProcessedSeq],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,6 +128,7 @@ export async function extractMemories({
|
||||
result.operations,
|
||||
embeddingConfig,
|
||||
conflictThreshold,
|
||||
effectiveEndSeq,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +144,7 @@ export async function extractMemories({
|
||||
const createdId = handleCreate(
|
||||
graph,
|
||||
op,
|
||||
startSeq,
|
||||
currentSeq,
|
||||
schema,
|
||||
refMap,
|
||||
stats,
|
||||
@@ -131,7 +153,7 @@ export async function extractMemories({
|
||||
break;
|
||||
}
|
||||
case "update":
|
||||
handleUpdate(graph, op, stats);
|
||||
handleUpdate(graph, op, currentSeq, stats);
|
||||
break;
|
||||
case "delete":
|
||||
handleDelete(graph, op, stats);
|
||||
@@ -150,15 +172,22 @@ export async function extractMemories({
|
||||
// 为新建节点生成 embedding
|
||||
await generateNodeEmbeddings(graph, embeddingConfig);
|
||||
|
||||
// 更新处理进度
|
||||
graph.lastProcessedSeq =
|
||||
startSeq + messages.filter((m) => m.role === "assistant").length;
|
||||
|
||||
console.log(
|
||||
`[ST-BME] 提取完成: 新建 ${stats.newNodes}, 更新 ${stats.updatedNodes}, 新边 ${stats.newEdges}`,
|
||||
// 更新处理进度:统一记录为已处理到的末个 chat 索引
|
||||
graph.lastProcessedSeq = Math.max(
|
||||
graph.lastProcessedSeq ?? -1,
|
||||
effectiveEndSeq,
|
||||
);
|
||||
|
||||
return { success: true, ...stats, newNodeIds };
|
||||
console.log(
|
||||
`[ST-BME] 提取完成: 新建 ${stats.newNodes}, 更新 ${stats.updatedNodes}, 新边 ${stats.newEdges}, lastProcessedSeq=${graph.lastProcessedSeq}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
...stats,
|
||||
newNodeIds,
|
||||
processedRange: [effectiveStartSeq, effectiveEndSeq],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,7 +246,7 @@ function handleCreate(graph, op, seq, schema, refMap, stats) {
|
||||
/**
|
||||
* 处理 update 操作
|
||||
*/
|
||||
function handleUpdate(graph, op, stats) {
|
||||
function handleUpdate(graph, op, currentSeq, stats) {
|
||||
if (!op.nodeId) {
|
||||
console.warn("[ST-BME] update 操作缺少 nodeId");
|
||||
return;
|
||||
@@ -233,8 +262,10 @@ function handleUpdate(graph, op, stats) {
|
||||
const nextFields = { ...previousFields, ...(op.fields || {}) };
|
||||
const changeSummary = buildFieldChangeSummary(previousFields, nextFields);
|
||||
|
||||
const updateSeq = Number.isFinite(op.seq) ? op.seq : currentSeq;
|
||||
const updated = updateNode(graph, op.nodeId, {
|
||||
fields: op.fields || {},
|
||||
seq: Math.max(previousNode.seq || 0, updateSeq),
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
@@ -242,19 +273,22 @@ function handleUpdate(graph, op, stats) {
|
||||
const node = getNode(graph, op.nodeId);
|
||||
if (node) {
|
||||
node.embedding = null;
|
||||
node.seq = Math.max(node.seq || 0, op.seq || 0);
|
||||
node.seq = Math.max(node.seq || 0, updateSeq);
|
||||
node.seqRange = [
|
||||
Math.min(node.seqRange?.[0] ?? node.seq, op.seq || node.seq),
|
||||
Math.max(node.seqRange?.[1] ?? node.seq, op.seq || node.seq),
|
||||
Math.min(node.seqRange?.[0] ?? node.seq, updateSeq),
|
||||
Math.max(node.seqRange?.[1] ?? node.seq, updateSeq),
|
||||
];
|
||||
}
|
||||
|
||||
// v2 Graphiti: 标记旧的 updates/temporal_update 边为失效
|
||||
const oldEdges = graph.edges.filter(
|
||||
(e) =>
|
||||
e.toId === op.nodeId &&
|
||||
(e.relation === "updates" || e.relation === "temporal_update") &&
|
||||
!e.invalidAt,
|
||||
!e.invalidAt &&
|
||||
((e.relation === "updates" && e.toId === op.nodeId) ||
|
||||
(e.relation === "temporal_update" &&
|
||||
e.toId === op.nodeId &&
|
||||
op.sourceNodeId &&
|
||||
e.fromId === op.sourceNodeId)),
|
||||
);
|
||||
for (const e of oldEdges) {
|
||||
invalidateEdge(e);
|
||||
@@ -284,7 +318,7 @@ function handleUpdate(graph, op, stats) {
|
||||
previousNode.id,
|
||||
status: "resolved",
|
||||
},
|
||||
seq: op.seq || previousNode.seq || 0,
|
||||
seq: updateSeq,
|
||||
importance: Math.max(
|
||||
4,
|
||||
Math.min(8, op.importance ?? previousNode.importance ?? 5),
|
||||
@@ -379,7 +413,10 @@ function handleLinks(graph, sourceId, links, refMap, stats) {
|
||||
async function generateNodeEmbeddings(graph, embeddingConfig) {
|
||||
if (!embeddingConfig?.apiUrl) return;
|
||||
|
||||
const needsEmbedding = graph.nodes.filter((n) => !n.embedding && !n.archived);
|
||||
const needsEmbedding = graph.nodes.filter(
|
||||
(n) =>
|
||||
!n.archived && (!Array.isArray(n.embedding) || n.embedding.length === 0),
|
||||
);
|
||||
|
||||
if (needsEmbedding.length === 0) return;
|
||||
|
||||
@@ -410,7 +447,9 @@ async function generateNodeEmbeddings(graph, embeddingConfig) {
|
||||
* 构建图谱概览文本(给 LLM 看)
|
||||
*/
|
||||
function buildGraphOverview(graph, schema) {
|
||||
const activeNodes = graph.nodes.filter((n) => !n.archived);
|
||||
const activeNodes = graph.nodes
|
||||
.filter((n) => !n.archived)
|
||||
.sort((a, b) => (a.seq || 0) - (b.seq || 0));
|
||||
if (activeNodes.length === 0) return "";
|
||||
|
||||
const lines = [];
|
||||
@@ -502,8 +541,11 @@ async function mem0ConflictCheck(
|
||||
operations,
|
||||
embeddingConfig,
|
||||
threshold,
|
||||
fallbackSeq,
|
||||
) {
|
||||
const activeNodes = getActiveNodes(graph).filter((n) => n.embedding);
|
||||
const activeNodes = getActiveNodes(graph).filter(
|
||||
(n) => Array.isArray(n.embedding) && n.embedding.length > 0,
|
||||
);
|
||||
if (activeNodes.length === 0) return;
|
||||
|
||||
for (const op of operations) {
|
||||
@@ -553,7 +595,8 @@ async function mem0ConflictCheck(
|
||||
);
|
||||
op.action = "update";
|
||||
op.nodeId = decision.targetId;
|
||||
op.sourceNodeId = topMatch.id;
|
||||
op.sourceNodeId = op.sourceNodeId || topMatch.id;
|
||||
op.seq = Number.isFinite(op.seq) ? op.seq : fallbackSeq;
|
||||
if (decision.mergedFields) {
|
||||
op.fields = { ...op.fields, ...decision.mergedFields };
|
||||
}
|
||||
@@ -627,7 +670,12 @@ export async function generateSynopsis({ graph, schema, currentSeq }) {
|
||||
if (existingSynopsis) {
|
||||
updateNode(graph, existingSynopsis.id, {
|
||||
fields: { summary: result.summary, scope: `楼 1 ~ ${currentSeq}` },
|
||||
seq: Math.max(existingSynopsis.seq || 0, currentSeq),
|
||||
});
|
||||
existingSynopsis.seqRange = [
|
||||
Math.min(existingSynopsis.seqRange?.[0] ?? currentSeq, currentSeq),
|
||||
Math.max(existingSynopsis.seqRange?.[1] ?? currentSeq, currentSeq),
|
||||
];
|
||||
existingSynopsis.embedding = null;
|
||||
console.log("[ST-BME] 全局概要已更新");
|
||||
} else {
|
||||
|
||||
535
graph.js
535
graph.js
@@ -4,17 +4,17 @@
|
||||
/**
|
||||
* 图状态版本号
|
||||
*/
|
||||
const GRAPH_VERSION = 2;
|
||||
const GRAPH_VERSION = 3;
|
||||
|
||||
/**
|
||||
* 生成 UUID v4
|
||||
*/
|
||||
function uuid() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,13 +22,13 @@ function uuid() {
|
||||
* @returns {GraphState}
|
||||
*/
|
||||
export function createEmptyGraph() {
|
||||
return {
|
||||
version: GRAPH_VERSION,
|
||||
lastProcessedSeq: 0,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
lastRecallResult: null,
|
||||
};
|
||||
return {
|
||||
version: GRAPH_VERSION,
|
||||
lastProcessedSeq: -1,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
lastRecallResult: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 节点操作 ====================
|
||||
@@ -39,33 +39,33 @@ export function createEmptyGraph() {
|
||||
* @returns {object} 新节点
|
||||
*/
|
||||
export function createNode({
|
||||
type,
|
||||
fields = {},
|
||||
seq = 0,
|
||||
seqRange = null,
|
||||
importance = 5.0,
|
||||
clusters = [],
|
||||
type,
|
||||
fields = {},
|
||||
seq = 0,
|
||||
seqRange = null,
|
||||
importance = 5.0,
|
||||
clusters = [],
|
||||
}) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: uuid(),
|
||||
type,
|
||||
level: 0,
|
||||
parentId: null,
|
||||
childIds: [],
|
||||
seq,
|
||||
seqRange: seqRange || [seq, seq],
|
||||
archived: false,
|
||||
fields,
|
||||
embedding: null,
|
||||
importance: Math.max(0, Math.min(10, importance)),
|
||||
accessCount: 0,
|
||||
lastAccessTime: now,
|
||||
createdTime: now,
|
||||
prevId: null,
|
||||
nextId: null,
|
||||
clusters,
|
||||
};
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: uuid(),
|
||||
type,
|
||||
level: 0,
|
||||
parentId: null,
|
||||
childIds: [],
|
||||
seq,
|
||||
seqRange: seqRange || [seq, seq],
|
||||
archived: false,
|
||||
fields,
|
||||
embedding: null,
|
||||
importance: Math.max(0, Math.min(10, importance)),
|
||||
accessCount: 0,
|
||||
lastAccessTime: now,
|
||||
createdTime: now,
|
||||
prevId: null,
|
||||
nextId: null,
|
||||
clusters,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,19 +75,19 @@ export function createNode({
|
||||
* @returns {object} 添加的节点
|
||||
*/
|
||||
export function addNode(graph, node) {
|
||||
// 同类型节点的时间链表:连接到最后一个同类型节点
|
||||
const sameTypeNodes = graph.nodes
|
||||
.filter(n => n.type === node.type && !n.archived && n.level === 0)
|
||||
.sort((a, b) => a.seq - b.seq);
|
||||
// 同类型节点的时间链表:连接到最后一个同类型节点
|
||||
const sameTypeNodes = graph.nodes
|
||||
.filter((n) => n.type === node.type && !n.archived && n.level === 0)
|
||||
.sort((a, b) => a.seq - b.seq);
|
||||
|
||||
if (sameTypeNodes.length > 0) {
|
||||
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
|
||||
lastNode.nextId = node.id;
|
||||
node.prevId = lastNode.id;
|
||||
}
|
||||
if (sameTypeNodes.length > 0) {
|
||||
const lastNode = sameTypeNodes[sameTypeNodes.length - 1];
|
||||
lastNode.nextId = node.id;
|
||||
node.prevId = lastNode.id;
|
||||
}
|
||||
|
||||
graph.nodes.push(node);
|
||||
return node;
|
||||
graph.nodes.push(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,7 +97,7 @@ export function addNode(graph, node) {
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getNode(graph, nodeId) {
|
||||
return graph.nodes.find(n => n.id === nodeId) || null;
|
||||
return graph.nodes.find((n) => n.id === nodeId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,16 +108,16 @@ export function getNode(graph, nodeId) {
|
||||
* @returns {boolean} 是否找到并更新
|
||||
*/
|
||||
export function updateNode(graph, nodeId, updates) {
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node) return false;
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node) return false;
|
||||
|
||||
if (updates.fields) {
|
||||
node.fields = { ...node.fields, ...updates.fields };
|
||||
delete updates.fields;
|
||||
}
|
||||
if (updates.fields) {
|
||||
node.fields = { ...node.fields, ...updates.fields };
|
||||
delete updates.fields;
|
||||
}
|
||||
|
||||
Object.assign(node, updates);
|
||||
return true;
|
||||
Object.assign(node, updates);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,39 +127,41 @@ export function updateNode(graph, nodeId, updates) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function removeNode(graph, nodeId) {
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node) return false;
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node) return false;
|
||||
|
||||
// 修复时间链表
|
||||
if (node.prevId) {
|
||||
const prev = getNode(graph, node.prevId);
|
||||
if (prev) prev.nextId = node.nextId;
|
||||
}
|
||||
if (node.nextId) {
|
||||
const next = getNode(graph, node.nextId);
|
||||
if (next) next.prevId = node.prevId;
|
||||
// 修复时间链表
|
||||
if (node.prevId) {
|
||||
const prev = getNode(graph, node.prevId);
|
||||
if (prev) prev.nextId = node.nextId;
|
||||
}
|
||||
if (node.nextId) {
|
||||
const next = getNode(graph, node.nextId);
|
||||
if (next) next.prevId = node.prevId;
|
||||
}
|
||||
|
||||
// 递归删除子节点
|
||||
for (const childId of node.childIds) {
|
||||
removeNode(graph, childId);
|
||||
}
|
||||
|
||||
// 从父节点中移除引用
|
||||
if (node.parentId) {
|
||||
const parent = getNode(graph, node.parentId);
|
||||
if (parent) {
|
||||
parent.childIds = parent.childIds.filter((id) => id !== nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
// 递归删除子节点
|
||||
for (const childId of node.childIds) {
|
||||
removeNode(graph, childId);
|
||||
}
|
||||
// 删除相关边
|
||||
graph.edges = graph.edges.filter(
|
||||
(e) => e.fromId !== nodeId && e.toId !== nodeId,
|
||||
);
|
||||
|
||||
// 从父节点中移除引用
|
||||
if (node.parentId) {
|
||||
const parent = getNode(graph, node.parentId);
|
||||
if (parent) {
|
||||
parent.childIds = parent.childIds.filter(id => id !== nodeId);
|
||||
}
|
||||
}
|
||||
// 删除节点本身
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== nodeId);
|
||||
|
||||
// 删除相关边
|
||||
graph.edges = graph.edges.filter(e => e.fromId !== nodeId && e.toId !== nodeId);
|
||||
|
||||
// 删除节点本身
|
||||
graph.nodes = graph.nodes.filter(n => n.id !== nodeId);
|
||||
|
||||
return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,11 +171,11 @@ export function removeNode(graph, nodeId) {
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function getActiveNodes(graph, typeFilter = null) {
|
||||
let nodes = graph.nodes.filter(n => !n.archived);
|
||||
if (typeFilter) {
|
||||
nodes = nodes.filter(n => n.type === typeFilter);
|
||||
}
|
||||
return nodes;
|
||||
let nodes = graph.nodes.filter((n) => !n.archived);
|
||||
if (typeFilter) {
|
||||
nodes = nodes.filter((n) => n.type === typeFilter);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,12 +186,20 @@ export function getActiveNodes(graph, typeFilter = null) {
|
||||
* @param {string} primaryKeyField - 主键字段名(默认 'name')
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function findLatestNode(graph, type, primaryKeyValue, primaryKeyField = 'name') {
|
||||
const candidates = graph.nodes.filter(
|
||||
n => n.type === type && !n.archived && n.fields[primaryKeyField] === primaryKeyValue,
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates.sort((a, b) => b.seq - a.seq)[0];
|
||||
export function findLatestNode(
|
||||
graph,
|
||||
type,
|
||||
primaryKeyValue,
|
||||
primaryKeyField = "name",
|
||||
) {
|
||||
const candidates = graph.nodes.filter(
|
||||
(n) =>
|
||||
n.type === type &&
|
||||
!n.archived &&
|
||||
n.fields[primaryKeyField] === primaryKeyValue,
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
return candidates.sort((a, b) => b.seq - a.seq)[0];
|
||||
}
|
||||
|
||||
// ==================== 边操作 ====================
|
||||
@@ -199,20 +209,26 @@ export function findLatestNode(graph, type, primaryKeyValue, primaryKeyField = '
|
||||
* @param {object} params
|
||||
* @returns {object} 新边
|
||||
*/
|
||||
export function createEdge({ fromId, toId, relation = 'related', strength = 0.8, edgeType = 0 }) {
|
||||
return {
|
||||
id: uuid(),
|
||||
fromId,
|
||||
toId,
|
||||
relation,
|
||||
strength: Math.max(0, Math.min(1, strength)),
|
||||
edgeType,
|
||||
createdTime: Date.now(),
|
||||
// Graphiti 启发的时序字段
|
||||
validAt: Date.now(), // 关系生效时间
|
||||
invalidAt: null, // 关系失效时间(null = 当前有效)
|
||||
expiredAt: null, // 系统标记过期时间
|
||||
};
|
||||
export function createEdge({
|
||||
fromId,
|
||||
toId,
|
||||
relation = "related",
|
||||
strength = 0.8,
|
||||
edgeType = 0,
|
||||
}) {
|
||||
return {
|
||||
id: uuid(),
|
||||
fromId,
|
||||
toId,
|
||||
relation,
|
||||
strength: Math.max(0, Math.min(1, strength)),
|
||||
edgeType,
|
||||
createdTime: Date.now(),
|
||||
// Graphiti 启发的时序字段
|
||||
validAt: Date.now(), // 关系生效时间
|
||||
invalidAt: null, // 关系失效时间(null = 当前有效)
|
||||
expiredAt: null, // 系统标记过期时间
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,23 +238,42 @@ export function createEdge({ fromId, toId, relation = 'related', strength = 0.8,
|
||||
* @returns {object|null} 添加的边或 null
|
||||
*/
|
||||
export function addEdge(graph, edge) {
|
||||
const from = getNode(graph, edge.fromId);
|
||||
const to = getNode(graph, edge.toId);
|
||||
if (!from || !to) return null;
|
||||
if (edge.fromId === edge.toId) return null;
|
||||
const from = getNode(graph, edge.fromId);
|
||||
const to = getNode(graph, edge.toId);
|
||||
if (!from || !to) return null;
|
||||
if (edge.fromId === edge.toId) return null;
|
||||
|
||||
// 检查重复边
|
||||
const existing = graph.edges.find(
|
||||
e => e.fromId === edge.fromId && e.toId === edge.toId && e.relation === edge.relation,
|
||||
const isCurrentEdgeValid = (candidate) => {
|
||||
if (candidate.invalidAt) return false;
|
||||
if (candidate.expiredAt) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
// 对当前有效边去重;历史边保留,避免历史污染当前检索
|
||||
const existing = graph.edges.find(
|
||||
(e) =>
|
||||
e.fromId === edge.fromId &&
|
||||
e.toId === edge.toId &&
|
||||
e.relation === edge.relation &&
|
||||
isCurrentEdgeValid(e),
|
||||
);
|
||||
if (existing) {
|
||||
existing.strength = Math.max(existing.strength, edge.strength ?? 0);
|
||||
existing.validAt = Math.max(
|
||||
existing.validAt || 0,
|
||||
edge.validAt || Date.now(),
|
||||
);
|
||||
if (existing) {
|
||||
// 更新已有边的强度
|
||||
existing.strength = Math.max(existing.strength, edge.strength);
|
||||
return existing;
|
||||
if (edge.invalidAt) {
|
||||
existing.invalidAt = edge.invalidAt;
|
||||
}
|
||||
if (edge.expiredAt) {
|
||||
existing.expiredAt = edge.expiredAt;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
graph.edges.push(edge);
|
||||
return edge;
|
||||
graph.edges.push(edge);
|
||||
return edge;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,10 +283,10 @@ export function addEdge(graph, edge) {
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function removeEdge(graph, edgeId) {
|
||||
const idx = graph.edges.findIndex(e => e.id === edgeId);
|
||||
if (idx === -1) return false;
|
||||
graph.edges.splice(idx, 1);
|
||||
return true;
|
||||
const idx = graph.edges.findIndex((e) => e.id === edgeId);
|
||||
if (idx === -1) return false;
|
||||
graph.edges.splice(idx, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,7 +296,7 @@ export function removeEdge(graph, edgeId) {
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function getOutEdges(graph, nodeId) {
|
||||
return graph.edges.filter(e => e.fromId === nodeId);
|
||||
return graph.edges.filter((e) => e.fromId === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -271,7 +306,7 @@ export function getOutEdges(graph, nodeId) {
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function getInEdges(graph, nodeId) {
|
||||
return graph.edges.filter(e => e.toId === nodeId);
|
||||
return graph.edges.filter((e) => e.toId === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -281,7 +316,7 @@ export function getInEdges(graph, nodeId) {
|
||||
* @returns {object[]}
|
||||
*/
|
||||
export function getNodeEdges(graph, nodeId) {
|
||||
return graph.edges.filter(e => e.fromId === nodeId || e.toId === nodeId);
|
||||
return graph.edges.filter((e) => e.fromId === nodeId || e.toId === nodeId);
|
||||
}
|
||||
|
||||
// ==================== 查询辅助 ====================
|
||||
@@ -292,27 +327,27 @@ export function getNodeEdges(graph, nodeId) {
|
||||
* @returns {Map<string, Array<{targetId: string, strength: number, edgeType: number}>>}
|
||||
*/
|
||||
export function buildAdjacencyMap(graph) {
|
||||
const adj = new Map();
|
||||
const adj = new Map();
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
// 正向
|
||||
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
|
||||
adj.get(edge.fromId).push({
|
||||
targetId: edge.toId,
|
||||
strength: edge.strength,
|
||||
edgeType: edge.edgeType,
|
||||
});
|
||||
for (const edge of graph.edges) {
|
||||
if (!isEdgeActive(edge)) continue;
|
||||
|
||||
// 反向(图扩散是双向的)
|
||||
if (!adj.has(edge.toId)) adj.set(edge.toId, []);
|
||||
adj.get(edge.toId).push({
|
||||
targetId: edge.fromId,
|
||||
strength: edge.strength,
|
||||
edgeType: edge.edgeType,
|
||||
});
|
||||
}
|
||||
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
|
||||
adj.get(edge.fromId).push({
|
||||
targetId: edge.toId,
|
||||
strength: edge.strength,
|
||||
edgeType: edge.edgeType,
|
||||
});
|
||||
|
||||
return adj;
|
||||
if (!adj.has(edge.toId)) adj.set(edge.toId, []);
|
||||
adj.get(edge.toId).push({
|
||||
targetId: edge.fromId,
|
||||
strength: edge.strength,
|
||||
edgeType: edge.edgeType,
|
||||
});
|
||||
}
|
||||
|
||||
return adj;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -322,32 +357,34 @@ export function buildAdjacencyMap(graph) {
|
||||
* @returns {Map}
|
||||
*/
|
||||
export function buildTemporalAdjacencyMap(graph) {
|
||||
const adj = new Map();
|
||||
const now = Date.now();
|
||||
const adj = new Map();
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
// 跳过已失效的边
|
||||
if (edge.invalidAt && edge.invalidAt <= now) continue;
|
||||
if (edge.expiredAt) continue;
|
||||
for (const edge of graph.edges) {
|
||||
if (!isEdgeActive(edge)) continue;
|
||||
|
||||
// 正向
|
||||
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
|
||||
adj.get(edge.fromId).push({
|
||||
targetId: edge.toId,
|
||||
strength: edge.strength,
|
||||
edgeType: edge.edgeType,
|
||||
});
|
||||
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
|
||||
adj.get(edge.fromId).push({
|
||||
targetId: edge.toId,
|
||||
strength: edge.strength,
|
||||
edgeType: edge.edgeType,
|
||||
});
|
||||
|
||||
// 反向
|
||||
if (!adj.has(edge.toId)) adj.set(edge.toId, []);
|
||||
adj.get(edge.toId).push({
|
||||
targetId: edge.fromId,
|
||||
strength: edge.strength,
|
||||
edgeType: edge.edgeType,
|
||||
});
|
||||
}
|
||||
if (!adj.has(edge.toId)) adj.set(edge.toId, []);
|
||||
adj.get(edge.toId).push({
|
||||
targetId: edge.fromId,
|
||||
strength: edge.strength,
|
||||
edgeType: edge.edgeType,
|
||||
});
|
||||
}
|
||||
|
||||
return adj;
|
||||
return adj;
|
||||
}
|
||||
|
||||
function isEdgeActive(edge, now = Date.now()) {
|
||||
if (!edge) return false;
|
||||
if (edge.invalidAt && edge.invalidAt <= now) return false;
|
||||
if (edge.expiredAt && edge.expiredAt <= now) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,7 +392,10 @@ export function buildTemporalAdjacencyMap(graph) {
|
||||
* @param {object} edge
|
||||
*/
|
||||
export function invalidateEdge(edge) {
|
||||
if (!edge) return;
|
||||
if (!edge.invalidAt) {
|
||||
edge.invalidAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -364,21 +404,21 @@ export function invalidateEdge(edge) {
|
||||
* @returns {object}
|
||||
*/
|
||||
export function getGraphStats(graph) {
|
||||
const activeNodes = graph.nodes.filter(n => !n.archived);
|
||||
const archivedNodes = graph.nodes.filter(n => n.archived);
|
||||
const typeCounts = {};
|
||||
for (const node of activeNodes) {
|
||||
typeCounts[node.type] = (typeCounts[node.type] || 0) + 1;
|
||||
}
|
||||
const activeNodes = graph.nodes.filter((n) => !n.archived);
|
||||
const archivedNodes = graph.nodes.filter((n) => n.archived);
|
||||
const typeCounts = {};
|
||||
for (const node of activeNodes) {
|
||||
typeCounts[node.type] = (typeCounts[node.type] || 0) + 1;
|
||||
}
|
||||
|
||||
return {
|
||||
totalNodes: graph.nodes.length,
|
||||
activeNodes: activeNodes.length,
|
||||
archivedNodes: archivedNodes.length,
|
||||
totalEdges: graph.edges.length,
|
||||
lastProcessedSeq: graph.lastProcessedSeq,
|
||||
typeCounts,
|
||||
};
|
||||
return {
|
||||
totalNodes: graph.nodes.length,
|
||||
activeNodes: activeNodes.length,
|
||||
archivedNodes: archivedNodes.length,
|
||||
totalEdges: graph.edges.length,
|
||||
lastProcessedSeq: graph.lastProcessedSeq,
|
||||
typeCounts,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 序列化 ====================
|
||||
@@ -389,7 +429,7 @@ export function getGraphStats(graph) {
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeGraph(graph) {
|
||||
return JSON.stringify(graph);
|
||||
return JSON.stringify(graph);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -398,40 +438,75 @@ export function serializeGraph(graph) {
|
||||
* @returns {GraphState}
|
||||
*/
|
||||
export function deserializeGraph(json) {
|
||||
try {
|
||||
const data = typeof json === 'string' ? JSON.parse(json) : json;
|
||||
try {
|
||||
const data = typeof json === "string" ? JSON.parse(json) : json;
|
||||
|
||||
if (!data || data.version === undefined) {
|
||||
return createEmptyGraph();
|
||||
}
|
||||
|
||||
// 版本迁移
|
||||
if (data.version < GRAPH_VERSION) {
|
||||
console.log(`[ST-BME] 图版本迁移 v${data.version} → v${GRAPH_VERSION}`);
|
||||
|
||||
// v1→v2 迁移:给旧边补充时序字段
|
||||
if (data.version < 2 && data.edges) {
|
||||
for (const edge of data.edges) {
|
||||
if (edge.validAt === undefined) edge.validAt = edge.createdTime || Date.now();
|
||||
if (edge.invalidAt === undefined) edge.invalidAt = null;
|
||||
if (edge.expiredAt === undefined) edge.expiredAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
data.version = GRAPH_VERSION;
|
||||
}
|
||||
|
||||
// 确保字段完整
|
||||
data.nodes = data.nodes || [];
|
||||
data.edges = data.edges || [];
|
||||
data.lastProcessedSeq = data.lastProcessedSeq || 0;
|
||||
data.lastRecallResult = data.lastRecallResult || null;
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error('[ST-BME] 图反序列化失败:', e);
|
||||
return createEmptyGraph();
|
||||
if (!data || data.version === undefined) {
|
||||
return createEmptyGraph();
|
||||
}
|
||||
|
||||
if (data.version < GRAPH_VERSION) {
|
||||
console.log(`[ST-BME] 图版本迁移 v${data.version} → v${GRAPH_VERSION}`);
|
||||
|
||||
if (data.version < 2 && data.edges) {
|
||||
for (const edge of data.edges) {
|
||||
if (edge.validAt === undefined)
|
||||
edge.validAt = edge.createdTime || Date.now();
|
||||
if (edge.invalidAt === undefined) edge.invalidAt = null;
|
||||
if (edge.expiredAt === undefined) edge.expiredAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.version < 3) {
|
||||
if (typeof data.lastProcessedSeq !== "number") {
|
||||
data.lastProcessedSeq = -1;
|
||||
}
|
||||
for (const node of data.nodes || []) {
|
||||
if (!Array.isArray(node.seqRange)) {
|
||||
const seq = Number.isFinite(node.seq) ? node.seq : 0;
|
||||
node.seqRange = [seq, seq];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data.version = GRAPH_VERSION;
|
||||
}
|
||||
|
||||
data.nodes = (data.nodes || []).map((node) => {
|
||||
const seq = Number.isFinite(node.seq) ? node.seq : 0;
|
||||
return {
|
||||
level: 0,
|
||||
parentId: null,
|
||||
childIds: [],
|
||||
accessCount: 0,
|
||||
lastAccessTime: node.createdTime || Date.now(),
|
||||
prevId: null,
|
||||
nextId: null,
|
||||
clusters: [],
|
||||
...node,
|
||||
seq,
|
||||
seqRange: Array.isArray(node.seqRange) ? node.seqRange : [seq, seq],
|
||||
};
|
||||
});
|
||||
data.edges = (data.edges || []).map((edge) => ({
|
||||
createdTime: Date.now(),
|
||||
validAt: edge?.createdTime || Date.now(),
|
||||
invalidAt: null,
|
||||
expiredAt: null,
|
||||
...edge,
|
||||
}));
|
||||
data.lastProcessedSeq = Number.isFinite(data.lastProcessedSeq)
|
||||
? data.lastProcessedSeq
|
||||
: -1;
|
||||
data.lastRecallResult = Array.isArray(data.lastRecallResult)
|
||||
? data.lastRecallResult
|
||||
: null;
|
||||
|
||||
return data;
|
||||
} catch (e) {
|
||||
console.error("[ST-BME] 图反序列化失败:", e);
|
||||
return createEmptyGraph();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -440,11 +515,11 @@ export function deserializeGraph(json) {
|
||||
* @returns {string} JSON 字符串
|
||||
*/
|
||||
export function exportGraph(graph) {
|
||||
const exportData = {
|
||||
...graph,
|
||||
nodes: graph.nodes.map(n => ({ ...n, embedding: null })),
|
||||
};
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
const exportData = {
|
||||
...graph,
|
||||
nodes: graph.nodes.map((n) => ({ ...n, embedding: null })),
|
||||
};
|
||||
return JSON.stringify(exportData, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,10 +528,10 @@ export function exportGraph(graph) {
|
||||
* @returns {GraphState}
|
||||
*/
|
||||
export function importGraph(json) {
|
||||
const graph = deserializeGraph(json);
|
||||
// 导入的节点需要重新生成 embedding
|
||||
for (const node of graph.nodes) {
|
||||
node.embedding = null;
|
||||
}
|
||||
return graph;
|
||||
const graph = deserializeGraph(json);
|
||||
// 导入的节点需要重新生成 embedding
|
||||
for (const node of graph.nodes) {
|
||||
node.embedding = null;
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
154
index.js
154
index.js
@@ -30,7 +30,7 @@ import {
|
||||
} from "./graph.js";
|
||||
import { estimateTokens, formatInjection } from "./injector.js";
|
||||
import { retrieve } from "./retriever.js";
|
||||
import { DEFAULT_NODE_SCHEMA } from "./schema.js";
|
||||
import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js";
|
||||
|
||||
const MODULE_NAME = "st_bme";
|
||||
const GRAPH_METADATA_KEY = "st_bme_graph";
|
||||
@@ -121,15 +121,23 @@ let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗
|
||||
// ==================== 设置管理 ====================
|
||||
|
||||
function getSettings() {
|
||||
if (!extension_settings[MODULE_NAME]) {
|
||||
extension_settings[MODULE_NAME] = { ...defaultSettings };
|
||||
}
|
||||
return extension_settings[MODULE_NAME];
|
||||
const mergedSettings = {
|
||||
...defaultSettings,
|
||||
...(extension_settings[MODULE_NAME] || {}),
|
||||
};
|
||||
extension_settings[MODULE_NAME] = mergedSettings;
|
||||
return mergedSettings;
|
||||
}
|
||||
|
||||
function getSchema() {
|
||||
const settings = getSettings();
|
||||
return settings.nodeTypeSchema || DEFAULT_NODE_SCHEMA;
|
||||
const schema = settings.nodeTypeSchema || DEFAULT_NODE_SCHEMA;
|
||||
const validation = validateSchema(schema);
|
||||
if (!validation.valid) {
|
||||
console.warn("[ST-BME] Schema 非法,回退到默认 Schema:", validation.errors);
|
||||
return DEFAULT_NODE_SCHEMA;
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
function getEmbeddingConfig() {
|
||||
@@ -192,14 +200,15 @@ const DEFAULT_TRIGGER_KEYWORDS = [
|
||||
"来到",
|
||||
];
|
||||
|
||||
function getSmartTriggerDecision(chat, lastProcessed, settings) {
|
||||
export function getSmartTriggerDecision(chat, lastProcessed, settings) {
|
||||
const pendingMessages = chat
|
||||
.slice(lastProcessed + 1)
|
||||
.slice(Math.max(0, (lastProcessed ?? -1) + 1))
|
||||
.filter((msg) => !msg.is_system)
|
||||
.map((msg) => ({
|
||||
role: msg.is_user ? "user" : "assistant",
|
||||
content: msg.mes || "",
|
||||
}));
|
||||
}))
|
||||
.filter((msg) => msg.content.trim().length > 0);
|
||||
|
||||
if (pendingMessages.length === 0) {
|
||||
return { triggered: false, score: 0, reasons: [] };
|
||||
@@ -266,6 +275,18 @@ function getSmartTriggerDecision(chat, lastProcessed, settings) {
|
||||
};
|
||||
}
|
||||
|
||||
function clampInt(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) {
|
||||
const num = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.min(max, Math.max(min, num));
|
||||
}
|
||||
|
||||
function clampFloat(value, fallback, min = 0, max = 1) {
|
||||
const num = Number.parseFloat(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.min(max, Math.max(min, num));
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取管线:处理未提取的对话楼层
|
||||
*/
|
||||
@@ -279,7 +300,7 @@ async function runExtraction() {
|
||||
const chat = context.chat;
|
||||
if (!chat || chat.length === 0) return;
|
||||
|
||||
// 找出 assistant 楼层序号
|
||||
// lastProcessedSeq / startSeq / endSeq 统一使用 chat 数组索引语义
|
||||
const assistantTurns = [];
|
||||
for (let i = 0; i < chat.length; i++) {
|
||||
if (chat[i].is_user === false && !chat[i].is_system) {
|
||||
@@ -287,40 +308,44 @@ async function runExtraction() {
|
||||
}
|
||||
}
|
||||
|
||||
const lastProcessed = currentGraph.lastProcessedSeq;
|
||||
const unprocessedStarts = assistantTurns.filter((i) => i > lastProcessed);
|
||||
const lastProcessed = Number.isFinite(currentGraph.lastProcessedSeq)
|
||||
? currentGraph.lastProcessedSeq
|
||||
: -1;
|
||||
const unprocessedAssistantTurns = assistantTurns.filter(
|
||||
(i) => i > lastProcessed,
|
||||
);
|
||||
|
||||
if (unprocessedStarts.length === 0) return;
|
||||
if (unprocessedAssistantTurns.length === 0) return;
|
||||
|
||||
const extractEvery = clampInt(settings.extractEvery, 1, 1, 50);
|
||||
const smartTriggerDecision = settings.enableSmartTrigger
|
||||
? getSmartTriggerDecision(chat, lastProcessed, settings)
|
||||
: { triggered: false, score: 0, reasons: [] };
|
||||
|
||||
// 按 extractEvery 批次处理;若启用智能触发,则允许提前提取
|
||||
if (
|
||||
unprocessedStarts.length < settings.extractEvery &&
|
||||
unprocessedAssistantTurns.length < extractEvery &&
|
||||
!smartTriggerDecision.triggered
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const batchAssistantTurns = smartTriggerDecision.triggered
|
||||
? unprocessedAssistantTurns
|
||||
: unprocessedAssistantTurns.slice(0, extractEvery);
|
||||
const startIdx = batchAssistantTurns[0];
|
||||
const endIdx = batchAssistantTurns[batchAssistantTurns.length - 1];
|
||||
|
||||
isExtracting = true;
|
||||
|
||||
try {
|
||||
// 收集要处理的消息
|
||||
const startIdx = unprocessedStarts[0];
|
||||
const endIdx = unprocessedStarts[unprocessedStarts.length - 1];
|
||||
|
||||
// 包含上下文
|
||||
const contextStart = Math.max(
|
||||
0,
|
||||
startIdx - settings.extractContextTurns * 2,
|
||||
);
|
||||
const contextTurns = clampInt(settings.extractContextTurns, 2, 0, 20);
|
||||
const contextStart = Math.max(0, startIdx - contextTurns * 2);
|
||||
const messages = [];
|
||||
for (let i = contextStart; i <= endIdx && i < chat.length; i++) {
|
||||
const msg = chat[i];
|
||||
if (msg.is_system) continue;
|
||||
messages.push({
|
||||
seq: i,
|
||||
role: msg.is_user ? "user" : "assistant",
|
||||
content: msg.mes || "",
|
||||
});
|
||||
@@ -336,7 +361,9 @@ async function runExtraction() {
|
||||
const result = await extractMemories({
|
||||
graph: currentGraph,
|
||||
messages,
|
||||
startSeq: endIdx,
|
||||
startSeq: startIdx,
|
||||
endSeq: endIdx,
|
||||
lastProcessedSeq: lastProcessed,
|
||||
schema: getSchema(),
|
||||
embeddingConfig: getEmbeddingConfig(),
|
||||
extractPrompt: settings.extractPrompt || undefined,
|
||||
@@ -478,7 +505,7 @@ async function runRecall() {
|
||||
});
|
||||
|
||||
// 格式化注入文本
|
||||
const injectionText = formatInjection(result, getSchema());
|
||||
const injectionText = formatInjection(result, getSchema()).trim();
|
||||
lastInjectionContent = injectionText;
|
||||
|
||||
if (injectionText) {
|
||||
@@ -486,16 +513,16 @@ async function runRecall() {
|
||||
console.log(
|
||||
`[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`,
|
||||
);
|
||||
|
||||
// 使用 ST 的 extension prompt API 注入
|
||||
context.setExtensionPrompt(
|
||||
MODULE_NAME,
|
||||
injectionText,
|
||||
1, // extension_prompt_types.IN_PROMPT
|
||||
settings.injectDepth,
|
||||
);
|
||||
}
|
||||
|
||||
// 无结果时也要清空旧注入,避免脏 prompt 残留
|
||||
context.setExtensionPrompt(
|
||||
MODULE_NAME,
|
||||
injectionText,
|
||||
1, // extension_prompt_types.IN_PROMPT
|
||||
clampInt(settings.injectDepth, 4, 0, 9999),
|
||||
);
|
||||
|
||||
// 保存召回结果和访问强化
|
||||
currentGraph.lastRecallResult = result.selectedNodeIds;
|
||||
saveGraphToChat();
|
||||
@@ -665,7 +692,13 @@ function bindSettingsUI() {
|
||||
$("#st_bme_extract_every")
|
||||
.val(settings.extractEvery)
|
||||
.on("input", function () {
|
||||
settings.extractEvery = Math.max(1, parseInt($(this).val()) || 1);
|
||||
settings.extractEvery = clampInt($(this).val(), 1, 1, 50);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$("#st_bme_extract_context_turns")
|
||||
.val(settings.extractContextTurns)
|
||||
.on("input", function () {
|
||||
settings.extractContextTurns = clampInt($(this).val(), 2, 0, 20);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
@@ -685,11 +718,24 @@ function bindSettingsUI() {
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
$("#st_bme_recall_top_k")
|
||||
.val(settings.recallTopK)
|
||||
.on("input", function () {
|
||||
settings.recallTopK = clampInt($(this).val(), 15, 1, 100);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$("#st_bme_recall_max_nodes")
|
||||
.val(settings.recallMaxNodes)
|
||||
.on("input", function () {
|
||||
settings.recallMaxNodes = clampInt($(this).val(), 8, 1, 50);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
// 注入深度
|
||||
$("#st_bme_inject_depth")
|
||||
.val(settings.injectDepth)
|
||||
.on("input", function () {
|
||||
settings.injectDepth = Math.max(0, parseInt($(this).val()) || 4);
|
||||
settings.injectDepth = clampInt($(this).val(), 4, 0, 9999);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
@@ -697,19 +743,19 @@ function bindSettingsUI() {
|
||||
$("#st_bme_graph_weight")
|
||||
.val(settings.graphWeight)
|
||||
.on("input", function () {
|
||||
settings.graphWeight = parseFloat($(this).val()) || 0.6;
|
||||
settings.graphWeight = clampFloat($(this).val(), 0.6, 0, 1);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$("#st_bme_vector_weight")
|
||||
.val(settings.vectorWeight)
|
||||
.on("input", function () {
|
||||
settings.vectorWeight = parseFloat($(this).val()) || 0.3;
|
||||
settings.vectorWeight = clampFloat($(this).val(), 0.3, 0, 1);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$("#st_bme_importance_weight")
|
||||
.val(settings.importanceWeight)
|
||||
.on("input", function () {
|
||||
settings.importanceWeight = parseFloat($(this).val()) || 0.1;
|
||||
settings.importanceWeight = clampFloat($(this).val(), 0.1, 0, 1);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
@@ -754,7 +800,13 @@ function bindSettingsUI() {
|
||||
$("#st_bme_evo_neighbors")
|
||||
.val(settings.evoNeighborCount)
|
||||
.on("input", function () {
|
||||
settings.evoNeighborCount = Math.max(1, parseInt($(this).val()) || 5);
|
||||
settings.evoNeighborCount = clampInt($(this).val(), 5, 1, 20);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$("#st_bme_evo_consolidate_every")
|
||||
.val(settings.evoConsolidateEvery)
|
||||
.on("input", function () {
|
||||
settings.evoConsolidateEvery = clampInt($(this).val(), 50, 1, 500);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
@@ -768,7 +820,7 @@ function bindSettingsUI() {
|
||||
$("#st_bme_conflict_threshold")
|
||||
.val(settings.conflictThreshold)
|
||||
.on("input", function () {
|
||||
settings.conflictThreshold = parseFloat($(this).val()) || 0.85;
|
||||
settings.conflictThreshold = clampFloat($(this).val(), 0.85, 0.5, 0.99);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
@@ -782,7 +834,7 @@ function bindSettingsUI() {
|
||||
$("#st_bme_synopsis_every")
|
||||
.val(settings.synopsisEveryN)
|
||||
.on("input", function () {
|
||||
settings.synopsisEveryN = Math.max(1, parseInt($(this).val()) || 5);
|
||||
settings.synopsisEveryN = clampInt($(this).val(), 5, 1, 100);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
@@ -815,6 +867,12 @@ function bindSettingsUI() {
|
||||
settings.triggerPatterns = $(this).val();
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$("#st_bme_smart_trigger_threshold")
|
||||
.val(settings.smartTriggerThreshold)
|
||||
.on("input", function () {
|
||||
settings.smartTriggerThreshold = clampInt($(this).val(), 2, 1, 10);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
// P2: 主动遗忘
|
||||
$("#st_bme_sleep_cycle")
|
||||
@@ -826,7 +884,13 @@ function bindSettingsUI() {
|
||||
$("#st_bme_forget_threshold")
|
||||
.val(settings.forgetThreshold)
|
||||
.on("input", function () {
|
||||
settings.forgetThreshold = parseFloat($(this).val()) || 0.5;
|
||||
settings.forgetThreshold = clampFloat($(this).val(), 0.5, 0.1, 1);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
$("#st_bme_sleep_every")
|
||||
.val(settings.sleepEveryN)
|
||||
.on("input", function () {
|
||||
settings.sleepEveryN = clampInt($(this).val(), 10, 1, 200);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
@@ -840,7 +904,7 @@ function bindSettingsUI() {
|
||||
$("#st_bme_prob_chance")
|
||||
.val(settings.probRecallChance)
|
||||
.on("input", function () {
|
||||
settings.probRecallChance = parseFloat($(this).val()) || 0.15;
|
||||
settings.probRecallChance = clampFloat($(this).val(), 0.15, 0.01, 0.5);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
|
||||
@@ -854,7 +918,7 @@ function bindSettingsUI() {
|
||||
$("#st_bme_reflect_every")
|
||||
.val(settings.reflectEveryN)
|
||||
.on("input", function () {
|
||||
settings.reflectEveryN = Math.max(3, parseInt($(this).val()) || 10);
|
||||
settings.reflectEveryN = clampInt($(this).val(), 10, 1, 200);
|
||||
saveSettingsDebounced();
|
||||
});
|
||||
}
|
||||
|
||||
25
injector.js
25
injector.js
@@ -13,6 +13,7 @@ import { getSchemaType } from "./schema.js";
|
||||
export function formatInjection(retrievalResult, schema) {
|
||||
const { coreNodes, recallNodes, groupedRecallNodes } = retrievalResult;
|
||||
const parts = [];
|
||||
const appended = new Set();
|
||||
|
||||
// ========== Core 常驻注入 ==========
|
||||
if (coreNodes.length > 0) {
|
||||
@@ -24,7 +25,7 @@ export function formatInjection(retrievalResult, schema) {
|
||||
const typeDef = getSchemaType(schema, typeId);
|
||||
if (!typeDef) continue;
|
||||
|
||||
const table = formatTable(nodes, typeDef);
|
||||
const table = formatTable(nodes, typeDef, appended);
|
||||
if (table) parts.push(table);
|
||||
}
|
||||
}
|
||||
@@ -90,7 +91,7 @@ function appendBucket(parts, title, nodes, schema) {
|
||||
const typeDef = getSchemaType(schema, typeId);
|
||||
if (!typeDef) continue;
|
||||
|
||||
const table = formatTable(groupedNodes, typeDef);
|
||||
const table = formatTable(groupedNodes, typeDef, appended);
|
||||
if (table) parts.push(table);
|
||||
}
|
||||
}
|
||||
@@ -98,12 +99,22 @@ function appendBucket(parts, title, nodes, schema) {
|
||||
/**
|
||||
* 将同类型节点格式化为 Markdown 表格
|
||||
*/
|
||||
function formatTable(nodes, typeDef) {
|
||||
if (nodes.length === 0) return "";
|
||||
function formatTable(nodes, typeDef, appended = new Set()) {
|
||||
if (!Array.isArray(nodes) || nodes.length === 0) return "";
|
||||
|
||||
const uniqueNodes = nodes.filter((node) => {
|
||||
if (!node?.id || appended.has(node.id)) return false;
|
||||
appended.add(node.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (uniqueNodes.length === 0) return "";
|
||||
|
||||
// 确定要展示的列(有实际数据的列)
|
||||
const activeCols = typeDef.columns.filter((col) =>
|
||||
nodes.some((n) => n.fields[col.name]),
|
||||
uniqueNodes.some(
|
||||
(n) => n.fields?.[col.name] != null && n.fields[col.name] !== "",
|
||||
),
|
||||
);
|
||||
|
||||
if (activeCols.length === 0) return "";
|
||||
@@ -113,9 +124,9 @@ function formatTable(nodes, typeDef) {
|
||||
const separator = `| ${activeCols.map(() => "---").join(" | ")} |`;
|
||||
|
||||
// 数据行
|
||||
const rows = nodes.map((node) => {
|
||||
const rows = uniqueNodes.map((node) => {
|
||||
const cells = activeCols.map((col) => {
|
||||
const val = node.fields[col.name] || "";
|
||||
const val = node.fields?.[col.name] ?? "";
|
||||
// 转义管道符,限制单元格长度
|
||||
return String(val)
|
||||
.replace(/\|/g, "\\|")
|
||||
|
||||
135
retriever.js
135
retriever.js
@@ -54,7 +54,12 @@ export async function retrieve({
|
||||
const enableProbRecall = options.enableProbRecall ?? false;
|
||||
const probRecallChance = options.probRecallChance ?? 0.15;
|
||||
|
||||
let activeNodes = getActiveNodes(graph);
|
||||
let activeNodes = getActiveNodes(graph).filter(
|
||||
(node) =>
|
||||
!node.archived &&
|
||||
Array.isArray(node.seqRange) &&
|
||||
Number.isFinite(node.seqRange[1]),
|
||||
);
|
||||
|
||||
// v2 ⑦: 认知边界过滤(RoleRAG 启发)
|
||||
if (enableVisibility && visibilityFilter) {
|
||||
@@ -62,6 +67,8 @@ export async function retrieve({
|
||||
}
|
||||
|
||||
const nodeCount = activeNodes.length;
|
||||
const normalizedTopK = Math.max(1, topK);
|
||||
const normalizedMaxRecallNodes = Math.max(1, maxRecallNodes);
|
||||
console.log(
|
||||
`[ST-BME] 检索开始: ${nodeCount} 个活跃节点${enableVisibility ? " (认知边界已启用)" : ""}`,
|
||||
);
|
||||
@@ -81,7 +88,7 @@ export async function retrieve({
|
||||
userMessage,
|
||||
activeNodes,
|
||||
embeddingConfig,
|
||||
topK,
|
||||
normalizedTopK,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,6 +136,9 @@ export async function retrieve({
|
||||
maxSteps: 2,
|
||||
decayFactor: 0.6,
|
||||
topK: 100,
|
||||
}).filter((item) => {
|
||||
const node = getNode(graph, item.nodeId);
|
||||
return node && !node.archived;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -203,14 +213,19 @@ export async function retrieve({
|
||||
candidateNodes,
|
||||
graph,
|
||||
schema,
|
||||
maxRecallNodes,
|
||||
normalizedMaxRecallNodes,
|
||||
);
|
||||
} else {
|
||||
// 中等图:直接取 Top-N
|
||||
selectedNodeIds = scoredNodes.slice(0, topK).map((s) => s.nodeId);
|
||||
selectedNodeIds = scoredNodes
|
||||
.slice(0, Math.min(normalizedTopK, scoredNodes.length))
|
||||
.map((s) => s.nodeId);
|
||||
}
|
||||
|
||||
selectedNodeIds = reconstructSceneNodeIds(graph, selectedNodeIds, topK + 6);
|
||||
selectedNodeIds = reconstructSceneNodeIds(
|
||||
graph,
|
||||
selectedNodeIds,
|
||||
normalizedTopK + 6,
|
||||
);
|
||||
|
||||
// 访问强化
|
||||
const selectedNodes = selectedNodeIds
|
||||
@@ -225,15 +240,19 @@ export async function retrieve({
|
||||
// 未被选中的高重要性节点有概率随机激活
|
||||
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",
|
||||
);
|
||||
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() < probRecallChance) {
|
||||
if (Math.random() < probability) {
|
||||
selectedNodeIds.push(c.id);
|
||||
console.log(
|
||||
`[ST-BME] 概率触发: ${c.fields?.name || c.fields?.summary || c.id}`,
|
||||
@@ -261,7 +280,7 @@ async function vectorPreFilter(
|
||||
if (!queryVec) return [];
|
||||
|
||||
const candidates = activeNodes
|
||||
.filter((n) => n.embedding)
|
||||
.filter((n) => Array.isArray(n.embedding) && n.embedding.length > 0)
|
||||
.map((n) => ({ nodeId: n.id, embedding: n.embedding }));
|
||||
|
||||
return searchSimilar(queryVec, candidates, topK);
|
||||
@@ -277,19 +296,21 @@ async function vectorPreFilter(
|
||||
*/
|
||||
function extractEntityAnchors(userMessage, activeNodes) {
|
||||
const anchors = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const node of activeNodes) {
|
||||
// 检查 name 字段
|
||||
const name = node.fields?.name;
|
||||
if (name && userMessage.includes(name)) {
|
||||
anchors.push({ nodeId: node.id, entity: name });
|
||||
continue;
|
||||
}
|
||||
const candidates = [node.fields?.name, node.fields?.title]
|
||||
.filter((value) => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length >= 2);
|
||||
|
||||
// 检查 title 字段
|
||||
const title = node.fields?.title;
|
||||
if (title && userMessage.includes(title)) {
|
||||
anchors.push({ nodeId: node.id, entity: title });
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,16 +391,20 @@ async function llmRecall(
|
||||
* @returns {object[]}
|
||||
*/
|
||||
function filterByVisibility(nodes, characterName) {
|
||||
if (!characterName || typeof characterName !== "string") return nodes;
|
||||
return nodes.filter((node) => {
|
||||
// 没有 visibility 字段 → 对所有人可见
|
||||
if (!node.fields?.visibility) return true;
|
||||
// visibility 是数组 → 检查当前角色是否在列表中
|
||||
if (Array.isArray(node.fields.visibility)) {
|
||||
return node.fields.visibility.includes(characterName);
|
||||
return (
|
||||
node.fields.visibility.includes(characterName) ||
|
||||
node.fields.visibility.includes("*")
|
||||
);
|
||||
}
|
||||
// visibility 是字符串(逗号分隔)→ 解析后检查
|
||||
if (typeof node.fields.visibility === "string") {
|
||||
const visibleTo = node.fields.visibility.split(",").map((s) => s.trim());
|
||||
const visibleTo = node.fields.visibility
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return visibleTo.includes(characterName) || visibleTo.includes("*");
|
||||
}
|
||||
return true;
|
||||
@@ -391,15 +416,16 @@ function filterByVisibility(nodes, characterName) {
|
||||
* 分离常驻注入(Core)和召回注入(Recall)
|
||||
*/
|
||||
function buildResult(graph, selectedNodeIds, schema) {
|
||||
const coreNodes = []; // 常驻注入
|
||||
const recallNodes = []; // 召回注入
|
||||
const coreNodes = [];
|
||||
const recallNodes = [];
|
||||
const selectedSet = new Set(uniqueNodeIds(selectedNodeIds));
|
||||
|
||||
// 常驻注入节点(alwaysInject=true 的类型)
|
||||
const alwaysInjectTypes = new Set(
|
||||
schema.filter((s) => s.alwaysInject).map((s) => s.id),
|
||||
);
|
||||
|
||||
const activeNodes = getActiveNodes(graph);
|
||||
const activeNodes = getActiveNodes(graph).filter((node) => !node.archived);
|
||||
|
||||
for (const node of activeNodes) {
|
||||
if (alwaysInjectTypes.has(node.type)) {
|
||||
@@ -407,21 +433,23 @@ function buildResult(graph, selectedNodeIds, schema) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const nodeId of selectedNodeIds) {
|
||||
for (const nodeId of selectedSet) {
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node) continue;
|
||||
if (!node || node.archived) continue;
|
||||
if (!alwaysInjectTypes.has(node.type)) {
|
||||
recallNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
coreNodes.sort(compareNodeRecallOrder);
|
||||
recallNodes.sort(compareNodeRecallOrder);
|
||||
const groupedRecallNodes = groupRecallNodes(recallNodes);
|
||||
|
||||
return {
|
||||
coreNodes,
|
||||
recallNodes,
|
||||
groupedRecallNodes,
|
||||
selectedNodeIds: [...selectedNodeIds],
|
||||
selectedNodeIds: [...selectedSet],
|
||||
stats: {
|
||||
totalActive: activeNodes.length,
|
||||
coreCount: coreNodes.length,
|
||||
@@ -439,14 +467,15 @@ function reconstructSceneNodeIds(graph, seedNodeIds, limit = 16) {
|
||||
const seen = new Set();
|
||||
|
||||
function push(nodeId) {
|
||||
if (!nodeId || seen.has(nodeId)) return;
|
||||
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 seedNodeIds) {
|
||||
for (const nodeId of uniqueNodeIds(seedNodeIds)) {
|
||||
if (selected.length >= limit) break;
|
||||
push(nodeId);
|
||||
const node = getNode(graph, nodeId);
|
||||
if (!node) continue;
|
||||
@@ -455,26 +484,24 @@ function reconstructSceneNodeIds(graph, seedNodeIds, limit = 16) {
|
||||
expandEventScene(graph, node, push);
|
||||
} else if (node.type === "character" || node.type === "location") {
|
||||
const relatedEvents = getNodeEdges(graph, node.id)
|
||||
.filter((e) => !e.invalidAt)
|
||||
.filter(isUsableSceneEdge)
|
||||
.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)
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
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);
|
||||
@@ -501,11 +528,27 @@ function expandEventScene(graph, eventNode, push) {
|
||||
|
||||
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))
|
||||
.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 groupRecallNodes(nodes) {
|
||||
return {
|
||||
state: nodes.filter((n) => n.type === "character" || n.type === "location"),
|
||||
|
||||
433
schema.js
433
schema.js
@@ -5,8 +5,8 @@
|
||||
* 压缩模式
|
||||
*/
|
||||
export const COMPRESSION_MODE = {
|
||||
NONE: 'none',
|
||||
HIERARCHICAL: 'hierarchical',
|
||||
NONE: "none",
|
||||
HIERARCHICAL: "hierarchical",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -22,172 +22,189 @@ export const COMPRESSION_MODE = {
|
||||
* - compression: 压缩配置
|
||||
*/
|
||||
export const DEFAULT_NODE_SCHEMA = [
|
||||
{
|
||||
id: 'event',
|
||||
label: '事件',
|
||||
tableName: 'event_table',
|
||||
columns: [
|
||||
{ name: 'summary', hint: '事件摘要,包含因果关系和结果', required: true },
|
||||
{ name: 'participants', hint: '参与角色名,逗号分隔', required: false },
|
||||
{ name: 'status', hint: '事件状态:ongoing/resolved/blocked', required: false },
|
||||
],
|
||||
alwaysInject: true,
|
||||
latestOnly: false,
|
||||
forceUpdate: true,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.HIERARCHICAL,
|
||||
threshold: 9,
|
||||
fanIn: 3,
|
||||
maxDepth: 10,
|
||||
keepRecentLeaves: 6,
|
||||
instruction: '将事件节点压缩为高价值的剧情里程碑摘要。保留因果关系、不可逆结果和未解决的伏笔。',
|
||||
},
|
||||
{
|
||||
id: "event",
|
||||
label: "事件",
|
||||
tableName: "event_table",
|
||||
columns: [
|
||||
{ name: "summary", hint: "事件摘要,包含因果关系和结果", required: true },
|
||||
{ name: "participants", hint: "参与角色名,逗号分隔", required: false },
|
||||
{
|
||||
name: "status",
|
||||
hint: "事件状态:ongoing/resolved/blocked",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
alwaysInject: true,
|
||||
latestOnly: false,
|
||||
forceUpdate: true,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.HIERARCHICAL,
|
||||
threshold: 9,
|
||||
fanIn: 3,
|
||||
maxDepth: 10,
|
||||
keepRecentLeaves: 6,
|
||||
instruction:
|
||||
"将事件节点压缩为高价值的剧情里程碑摘要。保留因果关系、不可逆结果和未解决的伏笔。",
|
||||
},
|
||||
{
|
||||
id: 'character',
|
||||
label: '角色',
|
||||
tableName: 'character_table',
|
||||
columns: [
|
||||
{ name: 'name', hint: '角色名(仅规范名称)', required: true },
|
||||
{ name: 'traits', hint: '稳定的性格特征和外貌标记', required: false },
|
||||
{ name: 'state', hint: '当前状态或处境', required: false },
|
||||
{ name: 'goal', hint: '当前目标或动机', required: false },
|
||||
{ name: 'inventory', hint: '携带或拥有的关键物品', required: false },
|
||||
{ name: 'core_note', hint: '值得长期记住的关键备注', required: false },
|
||||
],
|
||||
alwaysInject: false,
|
||||
latestOnly: true,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "character",
|
||||
label: "角色",
|
||||
tableName: "character_table",
|
||||
columns: [
|
||||
{ name: "name", hint: "角色名(仅规范名称)", required: true },
|
||||
{ name: "traits", hint: "稳定的性格特征和外貌标记", required: false },
|
||||
{ name: "state", hint: "当前状态或处境", required: false },
|
||||
{ name: "goal", hint: "当前目标或动机", required: false },
|
||||
{ name: "inventory", hint: "携带或拥有的关键物品", required: false },
|
||||
{ name: "core_note", hint: "值得长期记住的关键备注", required: false },
|
||||
],
|
||||
alwaysInject: false,
|
||||
latestOnly: true,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: "",
|
||||
},
|
||||
{
|
||||
id: 'location',
|
||||
label: '地点',
|
||||
tableName: 'location_table',
|
||||
columns: [
|
||||
{ name: 'name', hint: '地点名称(仅规范名称)', required: true },
|
||||
{ name: 'state', hint: '当前状态或环境条件', required: false },
|
||||
{ name: 'features', hint: '重要特征、资源或服务', required: false },
|
||||
{ name: 'danger', hint: '危险等级或威胁', required: false },
|
||||
],
|
||||
alwaysInject: false,
|
||||
latestOnly: true,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "location",
|
||||
label: "地点",
|
||||
tableName: "location_table",
|
||||
columns: [
|
||||
{ name: "name", hint: "地点名称(仅规范名称)", required: true },
|
||||
{ name: "state", hint: "当前状态或环境条件", required: false },
|
||||
{ name: "features", hint: "重要特征、资源或服务", required: false },
|
||||
{ name: "danger", hint: "危险等级或威胁", required: false },
|
||||
],
|
||||
alwaysInject: false,
|
||||
latestOnly: true,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: "",
|
||||
},
|
||||
{
|
||||
id: 'rule',
|
||||
label: '规则',
|
||||
tableName: 'rule_table',
|
||||
columns: [
|
||||
{ name: 'title', hint: '简短规则名', required: true },
|
||||
{ name: 'constraint', hint: '不可违反的规则内容', required: true },
|
||||
{ name: 'scope', hint: '适用范围/场景', required: false },
|
||||
{ name: 'status', hint: '当前有效性:active/suspended/revoked', required: false },
|
||||
],
|
||||
alwaysInject: true,
|
||||
latestOnly: false,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rule",
|
||||
label: "规则",
|
||||
tableName: "rule_table",
|
||||
columns: [
|
||||
{ name: "title", hint: "简短规则名", required: true },
|
||||
{ name: "constraint", hint: "不可违反的规则内容", required: true },
|
||||
{ name: "scope", hint: "适用范围/场景", required: false },
|
||||
{
|
||||
name: "status",
|
||||
hint: "当前有效性:active/suspended/revoked",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
alwaysInject: true,
|
||||
latestOnly: false,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: "",
|
||||
},
|
||||
{
|
||||
id: 'thread',
|
||||
label: '主线',
|
||||
tableName: 'thread_table',
|
||||
columns: [
|
||||
{ name: 'title', hint: '主线名称', required: true },
|
||||
{ name: 'summary', hint: '当前进展摘要', required: false },
|
||||
{ name: 'status', hint: '状态:active/completed/abandoned', required: false },
|
||||
],
|
||||
alwaysInject: true,
|
||||
latestOnly: false,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.HIERARCHICAL,
|
||||
threshold: 6,
|
||||
fanIn: 3,
|
||||
maxDepth: 5,
|
||||
keepRecentLeaves: 3,
|
||||
instruction: '将主线节点压缩为阶段性进展摘要。保留关键转折和当前目标。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "thread",
|
||||
label: "主线",
|
||||
tableName: "thread_table",
|
||||
columns: [
|
||||
{ name: "title", hint: "主线名称", required: true },
|
||||
{ name: "summary", hint: "当前进展摘要", required: false },
|
||||
{
|
||||
name: "status",
|
||||
hint: "状态:active/completed/abandoned",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
alwaysInject: true,
|
||||
latestOnly: false,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.HIERARCHICAL,
|
||||
threshold: 6,
|
||||
fanIn: 3,
|
||||
maxDepth: 5,
|
||||
keepRecentLeaves: 3,
|
||||
instruction: "将主线节点压缩为阶段性进展摘要。保留关键转折和当前目标。",
|
||||
},
|
||||
// ====== v2 新增节点类型 ======
|
||||
{
|
||||
id: 'synopsis',
|
||||
label: '全局概要',
|
||||
tableName: 'synopsis_table',
|
||||
columns: [
|
||||
{ name: 'summary', hint: '当前故事的全局概要(前情提要)', required: true },
|
||||
{ name: 'scope', hint: '概要覆盖的楼层范围', required: false },
|
||||
],
|
||||
alwaysInject: true, // 常驻注入(MemoRAG 启发)
|
||||
latestOnly: true, // 只保留最新版本
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: '',
|
||||
},
|
||||
},
|
||||
// ====== v2 新增节点类型 ======
|
||||
{
|
||||
id: "synopsis",
|
||||
label: "全局概要",
|
||||
tableName: "synopsis_table",
|
||||
columns: [
|
||||
{
|
||||
name: "summary",
|
||||
hint: "当前故事的全局概要(前情提要)",
|
||||
required: true,
|
||||
},
|
||||
{ name: "scope", hint: "概要覆盖的楼层范围", required: false },
|
||||
],
|
||||
alwaysInject: true, // 常驻注入(MemoRAG 启发)
|
||||
latestOnly: true, // 只保留最新版本
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.NONE,
|
||||
threshold: 0,
|
||||
fanIn: 0,
|
||||
maxDepth: 0,
|
||||
keepRecentLeaves: 0,
|
||||
instruction: "",
|
||||
},
|
||||
{
|
||||
id: 'reflection',
|
||||
label: '反思',
|
||||
tableName: 'reflection_table',
|
||||
columns: [
|
||||
{ name: 'insight', hint: '对角色行为或情节的元认知反思', required: true },
|
||||
{ name: 'trigger', hint: '触发反思的事件/矛盾', required: false },
|
||||
{ name: 'suggestion', hint: '对后续叙事的建议', required: false },
|
||||
],
|
||||
alwaysInject: false, // 需要被召回
|
||||
latestOnly: false,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.HIERARCHICAL,
|
||||
threshold: 6,
|
||||
fanIn: 3,
|
||||
maxDepth: 3,
|
||||
keepRecentLeaves: 3,
|
||||
instruction: '将反思条目合并为高层次的叙事指导原则。',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "reflection",
|
||||
label: "反思",
|
||||
tableName: "reflection_table",
|
||||
columns: [
|
||||
{ name: "insight", hint: "对角色行为或情节的元认知反思", required: true },
|
||||
{ name: "trigger", hint: "触发反思的事件/矛盾", required: false },
|
||||
{ name: "suggestion", hint: "对后续叙事的建议", required: false },
|
||||
],
|
||||
alwaysInject: false, // 需要被召回
|
||||
latestOnly: false,
|
||||
forceUpdate: false,
|
||||
compression: {
|
||||
mode: COMPRESSION_MODE.HIERARCHICAL,
|
||||
threshold: 6,
|
||||
fanIn: 3,
|
||||
maxDepth: 3,
|
||||
keepRecentLeaves: 3,
|
||||
instruction: "将反思条目合并为高层次的叙事指导原则。",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 规范化的关系类型
|
||||
*/
|
||||
export const RELATION_TYPES = [
|
||||
'related', // 一般关联
|
||||
'involved_in', // 参与事件
|
||||
'occurred_at', // 发生于地点
|
||||
'advances', // 推进主线
|
||||
'updates', // 更新实体状态
|
||||
'contradicts', // 矛盾/冲突(用于抑制边)
|
||||
'evolves', // A-MEM 进化链接(新→旧)
|
||||
'temporal_update', // 时序更新(Graphiti:新状态替代旧状态)
|
||||
"related", // 一般关联
|
||||
"involved_in", // 参与事件
|
||||
"occurred_at", // 发生于地点
|
||||
"advances", // 推进主线
|
||||
"updates", // 更新实体状态
|
||||
"contradicts", // 矛盾/冲突(用于抑制边)
|
||||
"evolves", // A-MEM 进化链接(新→旧)
|
||||
"temporal_update", // 时序更新(Graphiti:新状态替代旧状态)
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -196,46 +213,80 @@ export const RELATION_TYPES = [
|
||||
* @returns {{valid: boolean, errors: string[]}}
|
||||
*/
|
||||
export function validateSchema(schema) {
|
||||
const errors = [];
|
||||
const errors = [];
|
||||
|
||||
if (!Array.isArray(schema) || schema.length === 0) {
|
||||
errors.push('Schema 必须是非空数组');
|
||||
return { valid: false, errors };
|
||||
if (!Array.isArray(schema) || schema.length === 0) {
|
||||
errors.push("Schema 必须是非空数组");
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
const ids = new Set();
|
||||
const tableNames = new Set();
|
||||
|
||||
for (const type of schema) {
|
||||
if (!type || typeof type !== "object") {
|
||||
errors.push("Schema 类型定义必须是对象");
|
||||
continue;
|
||||
}
|
||||
|
||||
const ids = new Set();
|
||||
const tableNames = new Set();
|
||||
|
||||
for (const type of schema) {
|
||||
if (!type.id || typeof type.id !== 'string') {
|
||||
errors.push('每种类型必须有 id');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ids.has(type.id)) {
|
||||
errors.push(`类型 ID 重复:${type.id}`);
|
||||
}
|
||||
ids.add(type.id);
|
||||
|
||||
if (!type.tableName || typeof type.tableName !== 'string') {
|
||||
errors.push(`类型 ${type.id}:缺少 tableName`);
|
||||
} else if (tableNames.has(type.tableName)) {
|
||||
errors.push(`表名重复:${type.tableName}`);
|
||||
} else {
|
||||
tableNames.add(type.tableName);
|
||||
}
|
||||
|
||||
if (!Array.isArray(type.columns) || type.columns.length === 0) {
|
||||
errors.push(`类型 ${type.id}:至少需要一个列`);
|
||||
}
|
||||
|
||||
const hasRequired = type.columns?.some(c => c.required);
|
||||
if (!hasRequired) {
|
||||
errors.push(`类型 ${type.id}:至少需要一个 required 列`);
|
||||
}
|
||||
if (!type.id || typeof type.id !== "string") {
|
||||
errors.push("每种类型必须有 id");
|
||||
continue;
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
if (ids.has(type.id)) {
|
||||
errors.push(`类型 ID 重复:${type.id}`);
|
||||
}
|
||||
ids.add(type.id);
|
||||
|
||||
if (!type.label || typeof type.label !== "string") {
|
||||
errors.push(`类型 ${type.id}:缺少 label`);
|
||||
}
|
||||
|
||||
if (!type.tableName || typeof type.tableName !== "string") {
|
||||
errors.push(`类型 ${type.id}:缺少 tableName`);
|
||||
} else if (tableNames.has(type.tableName)) {
|
||||
errors.push(`表名重复:${type.tableName}`);
|
||||
} else {
|
||||
tableNames.add(type.tableName);
|
||||
}
|
||||
|
||||
if (!Array.isArray(type.columns) || type.columns.length === 0) {
|
||||
errors.push(`类型 ${type.id}:至少需要一个列`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const columnNames = new Set();
|
||||
for (const column of type.columns) {
|
||||
if (!column?.name || typeof column.name !== "string") {
|
||||
errors.push(`类型 ${type.id}:存在缺少 name 的列定义`);
|
||||
continue;
|
||||
}
|
||||
if (columnNames.has(column.name)) {
|
||||
errors.push(`类型 ${type.id}:列名重复 ${column.name}`);
|
||||
}
|
||||
columnNames.add(column.name);
|
||||
}
|
||||
|
||||
const hasRequired = type.columns.some((c) => c?.required);
|
||||
if (!hasRequired) {
|
||||
errors.push(`类型 ${type.id}:至少需要一个 required 列`);
|
||||
}
|
||||
|
||||
if (type.latestOnly) {
|
||||
const hasPrimaryLikeField = ["name", "title", "summary"].some(
|
||||
(fieldName) =>
|
||||
type.columns.some((column) => column?.name === fieldName),
|
||||
);
|
||||
if (!hasPrimaryLikeField) {
|
||||
errors.push(
|
||||
`类型 ${type.id}:latestOnly 类型至少需要 name/title/summary 之一作为主标识字段`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,5 +296,5 @@ export function validateSchema(schema) {
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getSchemaType(schema, typeId) {
|
||||
return schema.find(t => t.id === typeId) || null;
|
||||
return schema.find((t) => t.id === typeId) || null;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,22 @@
|
||||
id="st_bme_extract_every"
|
||||
class="text_pole"
|
||||
min="1"
|
||||
max="10"
|
||||
max="50"
|
||||
value="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="st-bme-row">
|
||||
<label for="st_bme_extract_context_turns">提取上下文轮数</label>
|
||||
<input
|
||||
type="number"
|
||||
id="st_bme_extract_context_turns"
|
||||
class="text_pole"
|
||||
min="0"
|
||||
max="20"
|
||||
value="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="st-bme-hr" />
|
||||
@@ -49,6 +61,30 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="st-bme-row">
|
||||
<label for="st_bme_recall_top_k">召回候选上限</label>
|
||||
<input
|
||||
type="number"
|
||||
id="st_bme_recall_top_k"
|
||||
class="text_pole"
|
||||
min="1"
|
||||
max="100"
|
||||
value="15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="st-bme-row">
|
||||
<label for="st_bme_recall_max_nodes">LLM 精确召回上限</label>
|
||||
<input
|
||||
type="number"
|
||||
id="st_bme_recall_max_nodes"
|
||||
class="text_pole"
|
||||
min="1"
|
||||
max="50"
|
||||
value="8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="st-bme-row">
|
||||
<label for="st_bme_inject_depth">注入深度</label>
|
||||
<input
|
||||
@@ -138,6 +174,18 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="st-bme-row st-bme-indent">
|
||||
<label for="st_bme_evo_consolidate_every">整理频率</label>
|
||||
<input
|
||||
type="number"
|
||||
id="st_bme_evo_consolidate_every"
|
||||
class="text_pole"
|
||||
min="1"
|
||||
max="500"
|
||||
value="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="st-bme-row">
|
||||
<label class="checkbox_label" for="st_bme_precise_conflict">
|
||||
<input type="checkbox" id="st_bme_precise_conflict" />
|
||||
@@ -215,6 +263,18 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="st-bme-row st-bme-indent">
|
||||
<label for="st_bme_smart_trigger_threshold">触发阈值</label>
|
||||
<input
|
||||
type="number"
|
||||
id="st_bme_smart_trigger_threshold"
|
||||
class="text_pole"
|
||||
min="1"
|
||||
max="10"
|
||||
value="2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="st-bme-row">
|
||||
<label class="checkbox_label" for="st_bme_sleep_cycle">
|
||||
<input type="checkbox" id="st_bme_sleep_cycle" />
|
||||
@@ -233,6 +293,17 @@
|
||||
value="0.5"
|
||||
/>
|
||||
</div>
|
||||
<div class="st-bme-row st-bme-indent">
|
||||
<label for="st_bme_sleep_every">每 N 次提取遗忘</label>
|
||||
<input
|
||||
type="number"
|
||||
id="st_bme_sleep_every"
|
||||
class="text_pole"
|
||||
min="1"
|
||||
max="200"
|
||||
value="10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="st-bme-row">
|
||||
<label class="checkbox_label" for="st_bme_prob_recall">
|
||||
|
||||
76
tests/graph-retrieval.mjs
Normal file
76
tests/graph-retrieval.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { diffuseAndRank } from "../diffusion.js";
|
||||
import {
|
||||
addEdge,
|
||||
addNode,
|
||||
buildTemporalAdjacencyMap,
|
||||
createEdge,
|
||||
createEmptyGraph,
|
||||
createNode,
|
||||
invalidateEdge,
|
||||
} from "../graph.js";
|
||||
|
||||
const graph = createEmptyGraph();
|
||||
|
||||
const event1 = createNode({
|
||||
type: "event",
|
||||
seq: 1,
|
||||
fields: { summary: "初始事件" },
|
||||
importance: 5,
|
||||
});
|
||||
const event2 = createNode({
|
||||
type: "event",
|
||||
seq: 2,
|
||||
fields: { summary: "后续事件" },
|
||||
importance: 6,
|
||||
});
|
||||
const character = createNode({
|
||||
type: "character",
|
||||
seq: 2,
|
||||
fields: { name: "艾琳", state: "警觉" },
|
||||
importance: 7,
|
||||
});
|
||||
|
||||
addNode(graph, event1);
|
||||
addNode(graph, event2);
|
||||
addNode(graph, character);
|
||||
|
||||
const currentEdge = createEdge({
|
||||
fromId: event2.id,
|
||||
toId: character.id,
|
||||
relation: "involved_in",
|
||||
strength: 0.9,
|
||||
});
|
||||
assert.ok(addEdge(graph, currentEdge));
|
||||
|
||||
const historicalEdge = createEdge({
|
||||
fromId: event1.id,
|
||||
toId: character.id,
|
||||
relation: "involved_in",
|
||||
strength: 0.4,
|
||||
});
|
||||
assert.ok(addEdge(graph, historicalEdge));
|
||||
invalidateEdge(historicalEdge);
|
||||
|
||||
const replacementEdge = createEdge({
|
||||
fromId: event1.id,
|
||||
toId: character.id,
|
||||
relation: "involved_in",
|
||||
strength: 0.7,
|
||||
});
|
||||
assert.ok(addEdge(graph, replacementEdge));
|
||||
assert.notEqual(replacementEdge.id, historicalEdge.id);
|
||||
|
||||
const adjacencyMap = buildTemporalAdjacencyMap(graph);
|
||||
const event1Neighbors = adjacencyMap.get(event1.id) || [];
|
||||
assert.equal(event1Neighbors.length, 1);
|
||||
assert.equal(event1Neighbors[0].targetId, character.id);
|
||||
assert.equal(event1Neighbors[0].strength, 0.7);
|
||||
|
||||
const diffusion = diffuseAndRank(adjacencyMap, [
|
||||
{ id: event2.id, energy: 1 },
|
||||
{ id: event2.id, energy: 0.5 },
|
||||
]);
|
||||
assert.ok(diffusion.some((item) => item.nodeId === character.id));
|
||||
|
||||
console.log("graph-retrieval tests passed");
|
||||
@@ -1,102 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vm from "node:vm";
|
||||
|
||||
function getSmartTriggerDecision(chat, lastProcessed, settings) {
|
||||
const DEFAULT_TRIGGER_KEYWORDS = [
|
||||
"突然",
|
||||
"没想到",
|
||||
"原来",
|
||||
"其实",
|
||||
"发现",
|
||||
"背叛",
|
||||
"死亡",
|
||||
"复活",
|
||||
"恢复记忆",
|
||||
"失忆",
|
||||
"告白",
|
||||
"暴露",
|
||||
"秘密",
|
||||
"计划",
|
||||
"规则",
|
||||
"契约",
|
||||
"位置",
|
||||
"地点",
|
||||
"离开",
|
||||
"来到",
|
||||
];
|
||||
|
||||
const pendingMessages = chat
|
||||
.slice(lastProcessed + 1)
|
||||
.filter((msg) => !msg.is_system)
|
||||
.map((msg) => ({
|
||||
role: msg.is_user ? "user" : "assistant",
|
||||
content: msg.mes || "",
|
||||
}));
|
||||
|
||||
if (pendingMessages.length === 0) {
|
||||
return { triggered: false, score: 0, reasons: [] };
|
||||
}
|
||||
|
||||
const reasons = [];
|
||||
let score = 0;
|
||||
const combinedText = pendingMessages.map((m) => m.content).join("\n");
|
||||
|
||||
const keywordHits = DEFAULT_TRIGGER_KEYWORDS.filter((keyword) =>
|
||||
combinedText.includes(keyword),
|
||||
async function loadSmartTriggerDecision() {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const indexPath = path.resolve(__dirname, "../index.js");
|
||||
const source = await fs.readFile(indexPath, "utf8");
|
||||
const keywordMatch = source.match(
|
||||
/const DEFAULT_TRIGGER_KEYWORDS = \[[\s\S]*?\];/m,
|
||||
);
|
||||
if (keywordHits.length > 0) {
|
||||
score += Math.min(2, keywordHits.length);
|
||||
reasons.push(`关键词: ${keywordHits.slice(0, 3).join(", ")}`);
|
||||
const fnMatch = source.match(
|
||||
/export function getSmartTriggerDecision\(chat, lastProcessed, settings\) \{[\s\S]*?^\}/m,
|
||||
);
|
||||
|
||||
if (!keywordMatch || !fnMatch) {
|
||||
throw new Error("无法从 index.js 提取 smart trigger 实现");
|
||||
}
|
||||
|
||||
const customPatterns = String(settings.triggerPatterns || "")
|
||||
.split(/\r?\n|,/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
for (const pattern of customPatterns) {
|
||||
try {
|
||||
const regex = new RegExp(pattern, "i");
|
||||
if (regex.test(combinedText)) {
|
||||
score += 2;
|
||||
reasons.push(`自定义触发: ${pattern}`);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid regex
|
||||
}
|
||||
}
|
||||
|
||||
const roleSwitchCount = pendingMessages.reduce((count, message, index) => {
|
||||
if (index === 0) return count;
|
||||
return count + (message.role !== pendingMessages[index - 1].role ? 1 : 0);
|
||||
}, 0);
|
||||
if (roleSwitchCount >= 2) {
|
||||
score += 1;
|
||||
reasons.push("多轮往返互动");
|
||||
}
|
||||
|
||||
const punctuationHits = (combinedText.match(/[!?!?]/g) || []).length;
|
||||
if (punctuationHits >= 2) {
|
||||
score += 1;
|
||||
reasons.push("情绪/冲突波动");
|
||||
}
|
||||
|
||||
const entityLikeHits =
|
||||
combinedText.match(
|
||||
/[A-Z][a-z]{2,}|[\u4e00-\u9fff]{2,6}(先生|小姐|王国|城|镇|村|学院|组织|公司|小队|军团)/g,
|
||||
) || [];
|
||||
if (entityLikeHits.length > 0) {
|
||||
score += 1;
|
||||
reasons.push("疑似新实体/新地点");
|
||||
}
|
||||
|
||||
const threshold = Math.max(1, settings.smartTriggerThreshold || 2);
|
||||
return {
|
||||
triggered: score >= threshold,
|
||||
score,
|
||||
reasons,
|
||||
};
|
||||
const context = vm.createContext({});
|
||||
const script = new vm.Script(`
|
||||
${keywordMatch[0]}
|
||||
${fnMatch[0].replace("export function", "function")}
|
||||
this.getSmartTriggerDecision = getSmartTriggerDecision;
|
||||
`);
|
||||
script.runInContext(context);
|
||||
return context.getSmartTriggerDecision;
|
||||
}
|
||||
|
||||
const getSmartTriggerDecision = await loadSmartTriggerDecision();
|
||||
|
||||
const noTrigger = getSmartTriggerDecision(
|
||||
[
|
||||
{ is_user: true, mes: "今天天气不错。" },
|
||||
@@ -129,4 +63,29 @@ const customTrigger = getSmartTriggerDecision(
|
||||
assert.equal(customTrigger.triggered, true);
|
||||
assert.ok(customTrigger.reasons.some((r) => r.includes("自定义触发")));
|
||||
|
||||
const ignoresProcessedMessages = getSmartTriggerDecision(
|
||||
[
|
||||
{ is_user: true, mes: "之前突然出现了秘密。" },
|
||||
{ is_user: false, mes: "这已经处理过。" },
|
||||
{ is_user: true, mes: "现在只是平静地走路。" },
|
||||
{ is_user: false, mes: "没有新的异常。" },
|
||||
],
|
||||
1,
|
||||
{ triggerPatterns: "", smartTriggerThreshold: 2 },
|
||||
);
|
||||
assert.equal(ignoresProcessedMessages.triggered, false);
|
||||
assert.equal(ignoresProcessedMessages.score, 0);
|
||||
|
||||
const ignoresBlankAndInvalidRegex = getSmartTriggerDecision(
|
||||
[
|
||||
{ is_system: true, mes: "系统消息" },
|
||||
{ is_user: true, mes: " " },
|
||||
{ is_user: false, mes: "Alpha城发生了什么?!" },
|
||||
],
|
||||
-1,
|
||||
{ triggerPatterns: "([\n真相", smartTriggerThreshold: 2 },
|
||||
);
|
||||
assert.equal(ignoresBlankAndInvalidRegex.triggered, true);
|
||||
assert.ok(ignoresBlankAndInvalidRegex.reasons.includes("情绪/冲突波动"));
|
||||
|
||||
console.log("smart-trigger tests passed");
|
||||
|
||||
Reference in New Issue
Block a user