mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
feat: migrate compress/consolidation/reflection graphStats to shared ranking core + prompt node refs
- New maintenance/task-graph-stats.js: shared helper wrapping rankNodesForTaskContext + createPromptNodeReferenceMap - extractor.js: extract and reflection graphStats now use buildTaskGraphStats - compressor.js: compression graphStats uses shared helper, excludes current batch from relevant ranking - consolidator.js: consolidation graphStats uses shared helper, excludes new nodes from relevant ranking - index.js: passes schema + embeddingConfig into generateReflection - p0-regressions.mjs: added graphStats assertions for compress/consolidation/reflection (prompt-facing refs, no raw UUIDs)
This commit is contained in:
2
index.js
2
index.js
@@ -11126,6 +11126,8 @@ async function handleExtractionSuccess(
|
||||
await generateReflection({
|
||||
graph: currentGraph,
|
||||
currentSeq: endIdx,
|
||||
schema: getSchema(),
|
||||
embeddingConfig: getEmbeddingConfig(),
|
||||
settings,
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from "../prompting/prompt-builder.js";
|
||||
import { getSTContextForPrompt } from "../host/st-context.js";
|
||||
import { applyTaskRegex } from "../prompting/task-regex.js";
|
||||
import { buildTaskGraphStats } from "./task-graph-stats.js";
|
||||
import { isDirectVectorConfig } from "../vector/vector-index.js";
|
||||
|
||||
function createAbortError(message = "操作已终止") {
|
||||
@@ -109,6 +110,30 @@ function normalizeCompressionFieldValue(value) {
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
function buildCompressionRankingQueryText(nodes = [], typeDef = {}) {
|
||||
const typeLabel = String(typeDef?.label || typeDef?.id || "节点").trim() || "节点";
|
||||
const lines = (Array.isArray(nodes) ? nodes : [])
|
||||
.map((node, index) => {
|
||||
const fieldsText = Object.entries(node?.fields || {})
|
||||
.map(([key, value]) => {
|
||||
const normalizedValue = normalizeCompressionFieldValue(value);
|
||||
return normalizedValue ? `${key}: ${normalizedValue}` : "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
const storyTimeLabel = describeNodeStoryTime(node);
|
||||
return [
|
||||
`${typeLabel}#${index + 1}`,
|
||||
storyTimeLabel ? `剧情时间=${storyTimeLabel}` : "",
|
||||
fieldsText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
})
|
||||
.filter(Boolean);
|
||||
return lines.length > 0 ? [`压缩批次 ${typeLabel}`, ...lines].join("\n") : "";
|
||||
}
|
||||
|
||||
function buildCompressionFallbackSummary(batch = []) {
|
||||
return batch
|
||||
.map((node) =>
|
||||
@@ -187,6 +212,7 @@ export async function compressType({
|
||||
graph,
|
||||
typeDef,
|
||||
embeddingConfig,
|
||||
schema = [],
|
||||
force = false,
|
||||
customPrompt,
|
||||
signal,
|
||||
@@ -211,6 +237,7 @@ export async function compressType({
|
||||
typeDef,
|
||||
level,
|
||||
embeddingConfig,
|
||||
schema,
|
||||
force,
|
||||
customPrompt,
|
||||
signal,
|
||||
@@ -235,6 +262,7 @@ async function compressLevel({
|
||||
typeDef,
|
||||
level,
|
||||
embeddingConfig,
|
||||
schema = [],
|
||||
force,
|
||||
customPrompt,
|
||||
signal,
|
||||
@@ -271,6 +299,9 @@ async function compressLevel({
|
||||
const summaryResult = await summarizeBatch(
|
||||
batch,
|
||||
typeDef,
|
||||
graph,
|
||||
embeddingConfig,
|
||||
schema,
|
||||
customPrompt,
|
||||
signal,
|
||||
settings,
|
||||
@@ -476,6 +507,9 @@ function migrateBatchEdges(graph, batch, compressedNode) {
|
||||
async function summarizeBatch(
|
||||
nodes,
|
||||
typeDef,
|
||||
graph,
|
||||
embeddingConfig,
|
||||
schema = [],
|
||||
customPrompt,
|
||||
signal,
|
||||
settings = {},
|
||||
@@ -493,13 +527,35 @@ async function summarizeBatch(
|
||||
|
||||
const instruction =
|
||||
typeDef.compression.instruction || "将以下节点压缩总结为一条精炼记录。";
|
||||
const excludedNodeIds = new Set(
|
||||
(Array.isArray(nodes) ? nodes : []).map((node) => String(node?.id || "").trim()),
|
||||
);
|
||||
const compressionGraphStats = await buildTaskGraphStats({
|
||||
graph,
|
||||
schema: Array.isArray(schema) && schema.length > 0 ? schema : [typeDef],
|
||||
userMessage: buildCompressionRankingQueryText(nodes, typeDef),
|
||||
recentMessages: [],
|
||||
embeddingConfig,
|
||||
signal,
|
||||
activeNodes: getActiveNodes(graph).filter(
|
||||
(node) => !excludedNodeIds.has(String(node?.id || "").trim()),
|
||||
),
|
||||
rankingOptions: {
|
||||
topK: 12,
|
||||
diffusionTopK: 48,
|
||||
enableContextQueryBlend: false,
|
||||
enableMultiIntent: true,
|
||||
maxTextLength: 1200,
|
||||
},
|
||||
relevantHeading: "与当前压缩批次最相关的既有节点",
|
||||
});
|
||||
|
||||
const compressPromptBuild = await buildTaskPrompt(settings, "compress", {
|
||||
taskName: "compress",
|
||||
nodeContent: nodeDescriptions,
|
||||
candidateNodes: nodeDescriptions,
|
||||
currentRange: `${nodes[0]?.seq ?? "?"} ~ ${nodes[nodes.length - 1]?.seq ?? "?"}`,
|
||||
graphStats: `node_count=${nodes.length}, node_type=${typeDef.id}`,
|
||||
graphStats: compressionGraphStats.graphStats,
|
||||
...getSTContextForPrompt(),
|
||||
});
|
||||
const compressRegexInput = { entries: [] };
|
||||
@@ -581,6 +637,7 @@ export async function compressAll(
|
||||
graph,
|
||||
typeDef,
|
||||
embeddingConfig,
|
||||
schema,
|
||||
force,
|
||||
customPrompt,
|
||||
signal,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "../prompting/prompt-builder.js";
|
||||
import { getSTContextForPrompt } from "../host/st-context.js";
|
||||
import { applyTaskRegex } from "../prompting/task-regex.js";
|
||||
import { buildTaskGraphStats } from "./task-graph-stats.js";
|
||||
import {
|
||||
buildNodeVectorText,
|
||||
findSimilarNodesByText,
|
||||
@@ -132,6 +133,27 @@ function canMergeTemporalScopedMemories(leftNode, rightNode) {
|
||||
return isStoryTimeCompatible(leftNode, rightNode).compatible;
|
||||
}
|
||||
|
||||
function buildConsolidationRankingQueryText(newEntries = []) {
|
||||
return (Array.isArray(newEntries) ? newEntries : [])
|
||||
.map((entry, index) => {
|
||||
const node = entry?.node;
|
||||
const fieldsText = Object.entries(node?.fields || {})
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join(", ");
|
||||
const storyTimeLabel = describeNodeStoryTime(node);
|
||||
return [
|
||||
`新记忆#${index + 1}`,
|
||||
`类型=${String(node?.type || "").trim()}`,
|
||||
storyTimeLabel ? `剧情时间=${storyTimeLabel}` : "",
|
||||
fieldsText,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export async function analyzeAutoConsolidationGate({
|
||||
graph,
|
||||
newNodeIds,
|
||||
@@ -297,6 +319,7 @@ export async function consolidateMemories({
|
||||
graph,
|
||||
newNodeIds,
|
||||
embeddingConfig,
|
||||
schema = [],
|
||||
options = {},
|
||||
customPrompt,
|
||||
signal,
|
||||
@@ -491,13 +514,33 @@ export async function consolidateMemories({
|
||||
}
|
||||
|
||||
const userPrompt = userPromptSections.join("\n\n");
|
||||
const newNodeIdSet = new Set(newEntries.map((entry) => String(entry?.id || "").trim()));
|
||||
const consolidationGraphStats = await buildTaskGraphStats({
|
||||
graph,
|
||||
schema,
|
||||
userMessage: buildConsolidationRankingQueryText(newEntries),
|
||||
recentMessages: [],
|
||||
embeddingConfig,
|
||||
signal,
|
||||
activeNodes: activeNodes.filter(
|
||||
(node) => !newNodeIdSet.has(String(node?.id || "").trim()),
|
||||
),
|
||||
rankingOptions: {
|
||||
topK: 12,
|
||||
diffusionTopK: 48,
|
||||
enableContextQueryBlend: false,
|
||||
enableMultiIntent: true,
|
||||
maxTextLength: 1200,
|
||||
},
|
||||
relevantHeading: "与本轮整合最相关的既有节点",
|
||||
});
|
||||
|
||||
let decision;
|
||||
const consolidationPromptBuild = await buildTaskPrompt(settings, "consolidation", {
|
||||
taskName: "consolidation",
|
||||
candidateNodes: userPrompt,
|
||||
candidateText: userPrompt,
|
||||
graphStats: `new_entries=${newEntries.length}, threshold=${conflictThreshold}`,
|
||||
graphStats: consolidationGraphStats.graphStats,
|
||||
...getSTContextForPrompt(),
|
||||
});
|
||||
const consolidationRegexInput = { entries: [] };
|
||||
|
||||
@@ -41,12 +41,11 @@ import {
|
||||
buildTaskLlmPayload,
|
||||
buildTaskPrompt,
|
||||
} from "../prompting/prompt-builder.js";
|
||||
import { createPromptNodeReferenceMap } from "../prompting/prompt-node-references.js";
|
||||
import { RELATION_TYPES } from "../graph/schema.js";
|
||||
import { applyTaskRegex } from "../prompting/task-regex.js";
|
||||
import { rankNodesForTaskContext } from "../retrieval/shared-ranking.js";
|
||||
import { getSTContextForPrompt, getSTContextSnapshot } from "../host/st-context.js";
|
||||
import { buildExtractionInputContext } from "./extraction-context.js";
|
||||
import { buildTaskGraphStats } from "./task-graph-stats.js";
|
||||
import {
|
||||
aliasSetMatchesValue,
|
||||
buildUserPovAliasNormalizedSet,
|
||||
@@ -174,36 +173,20 @@ function buildExtractRankingQueryText(messages = []) {
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function buildExtractRelevantNodeReferenceMap(scoredNodes = [], schema = [], maxCount = 6) {
|
||||
const typeLabelById = new Map(
|
||||
(Array.isArray(schema) ? schema : []).map((typeDef) => [
|
||||
String(typeDef?.id || "").trim(),
|
||||
String(typeDef?.label || typeDef?.id || "").trim(),
|
||||
]),
|
||||
);
|
||||
const relevantNodes = (Array.isArray(scoredNodes) ? scoredNodes : [])
|
||||
.filter((entry) =>
|
||||
entry?.node &&
|
||||
!entry.node.archived &&
|
||||
((Number(entry?.vectorScore) || 0) > 0 ||
|
||||
(Number(entry?.graphScore) || 0) > 0 ||
|
||||
(Number(entry?.lexicalScore) || 0) > 0),
|
||||
)
|
||||
.slice(0, Math.max(1, maxCount));
|
||||
return createPromptNodeReferenceMap(relevantNodes, {
|
||||
prefix: "G",
|
||||
maxLength: 28,
|
||||
buildMeta: ({ entry, node }) => ({
|
||||
typeLabel:
|
||||
typeLabelById.get(String(node?.type || "").trim()) ||
|
||||
String(node?.type || "节点").trim() ||
|
||||
"节点",
|
||||
score:
|
||||
Math.round(
|
||||
(Number(entry?.weightedScore ?? entry?.finalScore) || 0) * 1000,
|
||||
) / 1000,
|
||||
}),
|
||||
});
|
||||
function buildReflectionRankingQueryText({
|
||||
eventSummary = "",
|
||||
characterSummary = "",
|
||||
threadSummary = "",
|
||||
contradictionSummary = "",
|
||||
} = {}) {
|
||||
return [
|
||||
eventSummary ? `最近事件:\n${eventSummary}` : "",
|
||||
characterSummary ? `近期角色状态:\n${characterSummary}` : "",
|
||||
threadSummary ? `当前主线:\n${threadSummary}` : "",
|
||||
contradictionSummary ? `已知矛盾:\n${contradictionSummary}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function isAbortError(error) {
|
||||
@@ -959,30 +942,25 @@ export async function extractMemories({
|
||||
: structuredMessages;
|
||||
|
||||
const extractGraphRankingQuery = buildExtractRankingQueryText(structuredMessages);
|
||||
const extractGraphRanking =
|
||||
graph?.nodes?.some((node) => !node?.archived) && extractGraphRankingQuery
|
||||
? await rankNodesForTaskContext({
|
||||
graph,
|
||||
userMessage: extractGraphRankingQuery,
|
||||
recentMessages: [],
|
||||
embeddingConfig,
|
||||
signal,
|
||||
options: {
|
||||
topK: 12,
|
||||
diffusionTopK: 48,
|
||||
enableContextQueryBlend: false,
|
||||
enableMultiIntent: true,
|
||||
maxTextLength: 1200,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const extractGraphRelevantNodes = buildExtractRelevantNodeReferenceMap(
|
||||
extractGraphRanking?.scoredNodes,
|
||||
const extractGraphStats = await buildTaskGraphStats({
|
||||
graph,
|
||||
schema,
|
||||
);
|
||||
|
||||
// 构建当前图概览(让 LLM 知道已有哪些节点,避免重复)
|
||||
const graphOverview = buildGraphOverview(graph, schema, extractGraphRelevantNodes);
|
||||
userMessage: extractGraphRankingQuery,
|
||||
recentMessages: [],
|
||||
embeddingConfig,
|
||||
signal,
|
||||
rankingOptions: {
|
||||
topK: 12,
|
||||
diffusionTopK: 48,
|
||||
enableContextQueryBlend: false,
|
||||
enableMultiIntent: true,
|
||||
maxTextLength: 1200,
|
||||
},
|
||||
relevantHeading: "与当前提取片段最相关的既有节点",
|
||||
});
|
||||
const extractGraphRanking = extractGraphStats.ranking;
|
||||
const extractGraphRelevantNodes = extractGraphStats.relevantReferenceMap;
|
||||
const graphOverview = extractGraphStats.graphStats;
|
||||
|
||||
// 构建 Schema 描述
|
||||
const schemaDescription = buildSchemaDescription(schema);
|
||||
@@ -1839,40 +1817,6 @@ async function generateNodeEmbeddings(graph, embeddingConfig, signal) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建图谱概览文本(给 LLM 看)
|
||||
*/
|
||||
function buildGraphOverview(graph, schema, relevantReferenceMap = null) {
|
||||
const activeNodes = graph.nodes
|
||||
.filter((n) => !n.archived)
|
||||
.sort((a, b) => (a.seq || 0) - (b.seq || 0));
|
||||
if (activeNodes.length === 0) return "";
|
||||
|
||||
const lines = [];
|
||||
lines.push("### 图谱节点统计");
|
||||
for (const typeDef of schema) {
|
||||
const nodesOfType = activeNodes.filter((n) => n.type === typeDef.id);
|
||||
if (nodesOfType.length === 0) continue;
|
||||
|
||||
lines.push(` - ${typeDef.label}: ${nodesOfType.length}`);
|
||||
}
|
||||
|
||||
const references = Array.isArray(relevantReferenceMap?.references)
|
||||
? relevantReferenceMap.references
|
||||
: [];
|
||||
if (references.length > 0) {
|
||||
lines.push("", "### 与当前提取片段最相关的既有节点");
|
||||
for (const reference of references) {
|
||||
const typeLabel = String(reference?.meta?.typeLabel || reference?.meta?.type || "节点").trim() || "节点";
|
||||
const label = String(reference?.meta?.label || "—").trim() || "—";
|
||||
const score = Number(reference?.meta?.score || 0).toFixed(3);
|
||||
lines.push(` - [${reference.key}|${typeLabel}] ${label} (score=${score})`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Schema 描述文本
|
||||
*/
|
||||
@@ -2140,6 +2084,8 @@ export async function generateSynopsis({
|
||||
export async function generateReflection({
|
||||
graph,
|
||||
currentSeq,
|
||||
schema = [],
|
||||
embeddingConfig,
|
||||
customPrompt,
|
||||
signal,
|
||||
settings = {},
|
||||
@@ -2189,6 +2135,27 @@ export async function generateReflection({
|
||||
const contradictionSummary = contradictEdges
|
||||
.map((e) => `${e.fromId} -> ${e.toId} (${e.relation})`)
|
||||
.join("\n");
|
||||
const reflectionGraphStats = await buildTaskGraphStats({
|
||||
graph,
|
||||
schema,
|
||||
userMessage: buildReflectionRankingQueryText({
|
||||
eventSummary,
|
||||
characterSummary,
|
||||
threadSummary,
|
||||
contradictionSummary,
|
||||
}),
|
||||
recentMessages: [],
|
||||
embeddingConfig,
|
||||
signal,
|
||||
rankingOptions: {
|
||||
topK: 12,
|
||||
diffusionTopK: 48,
|
||||
enableContextQueryBlend: false,
|
||||
enableMultiIntent: true,
|
||||
maxTextLength: 1200,
|
||||
},
|
||||
relevantHeading: "与当前反思最相关的既有节点",
|
||||
});
|
||||
|
||||
const reflectionPromptBuild = await buildTaskPrompt(settings, "reflection", {
|
||||
taskName: "reflection",
|
||||
@@ -2196,7 +2163,7 @@ export async function generateReflection({
|
||||
characterSummary: characterSummary || "(无)",
|
||||
threadSummary: threadSummary || "(无)",
|
||||
contradictionSummary: contradictionSummary || "(无)",
|
||||
graphStats: `event=${recentEvents.length}, character=${recentCharacters.length}, thread=${recentThreads.length}`,
|
||||
graphStats: reflectionGraphStats.graphStats,
|
||||
...getSTContextForPrompt(),
|
||||
});
|
||||
const reflectionRegexInput = { entries: [] };
|
||||
|
||||
203
maintenance/task-graph-stats.js
Normal file
203
maintenance/task-graph-stats.js
Normal file
@@ -0,0 +1,203 @@
|
||||
import { getActiveNodes } from "../graph/graph.js";
|
||||
import { createPromptNodeReferenceMap } from "../prompting/prompt-node-references.js";
|
||||
import { rankNodesForTaskContext } from "../retrieval/shared-ranking.js";
|
||||
|
||||
const DEFAULT_TYPE_LABELS = Object.freeze({
|
||||
event: "事件",
|
||||
character: "角色",
|
||||
location: "地点",
|
||||
rule: "规则",
|
||||
thread: "主线",
|
||||
synopsis: "全局概要",
|
||||
reflection: "反思",
|
||||
pov_memory: "主观记忆",
|
||||
});
|
||||
|
||||
function createTypeLabelMap(schema = []) {
|
||||
return new Map(
|
||||
(Array.isArray(schema) ? schema : [])
|
||||
.filter((typeDef) => String(typeDef?.id || "").trim())
|
||||
.map((typeDef) => [
|
||||
String(typeDef?.id || "").trim(),
|
||||
String(typeDef?.label || typeDef?.id || "").trim(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveTypeLabel(typeId = "", typeLabelMap = new Map()) {
|
||||
const normalizedTypeId = String(typeId || "").trim();
|
||||
return (
|
||||
typeLabelMap.get(normalizedTypeId) ||
|
||||
DEFAULT_TYPE_LABELS[normalizedTypeId] ||
|
||||
normalizedTypeId ||
|
||||
"节点"
|
||||
);
|
||||
}
|
||||
|
||||
function listGraphTypeCounts(activeNodes = [], schema = [], typeLabelMap = new Map()) {
|
||||
const safeActiveNodes = Array.isArray(activeNodes) ? activeNodes : [];
|
||||
if (Array.isArray(schema) && schema.length > 0) {
|
||||
return schema
|
||||
.map((typeDef) => {
|
||||
const typeId = String(typeDef?.id || "").trim();
|
||||
const count = safeActiveNodes.filter((node) => node?.type === typeId).length;
|
||||
return {
|
||||
typeId,
|
||||
label: resolveTypeLabel(typeId, typeLabelMap),
|
||||
count,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.count > 0);
|
||||
}
|
||||
|
||||
const countMap = new Map();
|
||||
for (const node of safeActiveNodes) {
|
||||
const typeId = String(node?.type || "").trim();
|
||||
if (!typeId) continue;
|
||||
countMap.set(typeId, (countMap.get(typeId) || 0) + 1);
|
||||
}
|
||||
return [...countMap.entries()]
|
||||
.map(([typeId, count]) => ({
|
||||
typeId,
|
||||
label: resolveTypeLabel(typeId, typeLabelMap),
|
||||
count,
|
||||
}))
|
||||
.sort((left, right) => left.typeId.localeCompare(right.typeId));
|
||||
}
|
||||
|
||||
export function buildRelevantNodeReferenceMap(
|
||||
scoredNodes = [],
|
||||
schema = [],
|
||||
{
|
||||
maxCount = 6,
|
||||
prefix = "G",
|
||||
maxLength = 28,
|
||||
} = {},
|
||||
) {
|
||||
const typeLabelMap = createTypeLabelMap(schema);
|
||||
const relevantNodes = (Array.isArray(scoredNodes) ? scoredNodes : [])
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry?.node &&
|
||||
!entry.node.archived &&
|
||||
((Number(entry?.vectorScore) || 0) > 0 ||
|
||||
(Number(entry?.graphScore) || 0) > 0 ||
|
||||
(Number(entry?.lexicalScore) || 0) > 0),
|
||||
)
|
||||
.slice(0, Math.max(1, maxCount));
|
||||
|
||||
return createPromptNodeReferenceMap(relevantNodes, {
|
||||
prefix,
|
||||
maxLength,
|
||||
buildMeta: ({ entry, node }) => ({
|
||||
typeLabel: resolveTypeLabel(node?.type, typeLabelMap),
|
||||
score:
|
||||
Math.round((Number(entry?.weightedScore ?? entry?.finalScore) || 0) * 1000) /
|
||||
1000,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function buildGraphOverview(
|
||||
graph,
|
||||
schema = [],
|
||||
relevantReferenceMap = null,
|
||||
{
|
||||
relevantHeading = "与当前任务最相关的既有节点",
|
||||
} = {},
|
||||
) {
|
||||
const activeNodes = graph?.nodes
|
||||
?.filter((node) => node && !node.archived)
|
||||
?.sort((left, right) => (left.seq || 0) - (right.seq || 0));
|
||||
if (!Array.isArray(activeNodes) || activeNodes.length === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const typeLabelMap = createTypeLabelMap(schema);
|
||||
const typeCounts = listGraphTypeCounts(activeNodes, schema, typeLabelMap);
|
||||
const lines = ["### 图谱节点统计"];
|
||||
|
||||
for (const entry of typeCounts) {
|
||||
lines.push(` - ${entry.label}: ${entry.count}`);
|
||||
}
|
||||
|
||||
const references = Array.isArray(relevantReferenceMap?.references)
|
||||
? relevantReferenceMap.references
|
||||
: [];
|
||||
if (references.length > 0) {
|
||||
lines.push("", `### ${String(relevantHeading || "与当前任务最相关的既有节点").trim() || "与当前任务最相关的既有节点"}`);
|
||||
for (const reference of references) {
|
||||
const typeLabel =
|
||||
String(reference?.meta?.typeLabel || reference?.meta?.type || "节点").trim() ||
|
||||
"节点";
|
||||
const label = String(reference?.meta?.label || "—").trim() || "—";
|
||||
const score = Number(reference?.meta?.score || 0).toFixed(3);
|
||||
lines.push(` - [${reference.key}|${typeLabel}] ${label} (score=${score})`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function normalizeActiveNodes(graph, activeNodes = null) {
|
||||
if (Array.isArray(activeNodes)) {
|
||||
return activeNodes.filter((node) => node && !node.archived);
|
||||
}
|
||||
return getActiveNodes(graph).filter((node) => node && !node.archived);
|
||||
}
|
||||
|
||||
export async function buildTaskGraphStats({
|
||||
graph,
|
||||
schema = [],
|
||||
userMessage = "",
|
||||
recentMessages = [],
|
||||
embeddingConfig,
|
||||
signal,
|
||||
activeNodes = null,
|
||||
rankingOptions = {},
|
||||
relevantHeading = "与当前任务最相关的既有节点",
|
||||
maxRelevantNodes = 6,
|
||||
prefix = "G",
|
||||
maxLabelLength = 28,
|
||||
} = {}) {
|
||||
const normalizedActiveNodes = normalizeActiveNodes(graph, activeNodes);
|
||||
const normalizedUserMessage = String(userMessage || "").trim();
|
||||
|
||||
let ranking = null;
|
||||
if (graph && normalizedActiveNodes.length > 0 && normalizedUserMessage) {
|
||||
ranking = await rankNodesForTaskContext({
|
||||
graph,
|
||||
userMessage: normalizedUserMessage,
|
||||
recentMessages,
|
||||
embeddingConfig,
|
||||
signal,
|
||||
options: {
|
||||
activeNodes: normalizedActiveNodes,
|
||||
topK: 12,
|
||||
diffusionTopK: 48,
|
||||
enableContextQueryBlend: false,
|
||||
enableMultiIntent: true,
|
||||
maxTextLength: 1200,
|
||||
...rankingOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const relevantReferenceMap = buildRelevantNodeReferenceMap(
|
||||
ranking?.scoredNodes,
|
||||
schema,
|
||||
{
|
||||
maxCount: maxRelevantNodes,
|
||||
prefix,
|
||||
maxLength: maxLabelLength,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
ranking,
|
||||
relevantReferenceMap,
|
||||
graphStats: buildGraphOverview(graph, schema, relevantReferenceMap, {
|
||||
relevantHeading,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1997,14 +1997,34 @@ async function testCompressTypeAcceptsTopLevelFieldsResult() {
|
||||
keepRecentLeaves: 0,
|
||||
},
|
||||
};
|
||||
const compressionSchema = [
|
||||
typeDef,
|
||||
{
|
||||
id: "thread",
|
||||
label: "主线",
|
||||
columns: [{ name: "title" }, { name: "summary" }, { name: "status" }],
|
||||
},
|
||||
];
|
||||
const first = makeEvent(1, "事件甲");
|
||||
const second = makeEvent(2, "事件乙");
|
||||
const relatedThread = createNode({
|
||||
type: "thread",
|
||||
seq: 3,
|
||||
fields: {
|
||||
title: "事件甲余波",
|
||||
summary: "Alice 被卷入的后续波动。",
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
addNode(graph, first);
|
||||
addNode(graph, second);
|
||||
addNode(graph, relatedThread);
|
||||
|
||||
const captured = [];
|
||||
const restoreOverrides = pushTestOverrides({
|
||||
llm: {
|
||||
async callLLMForJSON() {
|
||||
async callLLMForJSON(params = {}) {
|
||||
captured.push(params);
|
||||
return {
|
||||
title: "压缩事件",
|
||||
summary: "顶层返回的合并摘要",
|
||||
@@ -2020,8 +2040,12 @@ async function testCompressTypeAcceptsTopLevelFieldsResult() {
|
||||
graph,
|
||||
typeDef,
|
||||
embeddingConfig: null,
|
||||
schema: compressionSchema,
|
||||
force: true,
|
||||
settings: {},
|
||||
settings: {
|
||||
taskProfilesVersion: 3,
|
||||
taskProfiles: createDefaultTaskProfiles(),
|
||||
},
|
||||
});
|
||||
assert.equal(result.created, 1);
|
||||
const compressed = graph.nodes.find(
|
||||
@@ -2029,6 +2053,21 @@ async function testCompressTypeAcceptsTopLevelFieldsResult() {
|
||||
);
|
||||
assert.equal(compressed?.fields?.summary, "顶层返回的合并摘要");
|
||||
assert.equal(compressed?.fields?.title, "压缩事件");
|
||||
assert.equal(captured.length, 1);
|
||||
const graphStatsBlock = (Array.isArray(captured[0].promptMessages)
|
||||
? captured[0].promptMessages
|
||||
: []
|
||||
).find((message) => message.sourceKey === "graphStats");
|
||||
assert.ok(graphStatsBlock, "compress graphStats block should exist");
|
||||
const graphStatsContent = String(graphStatsBlock.content || "");
|
||||
assert.match(graphStatsContent, /### 图谱节点统计/);
|
||||
assert.match(graphStatsContent, /事件: 2/);
|
||||
assert.match(graphStatsContent, /主线: 1/);
|
||||
assert.match(graphStatsContent, /\[G1\|主线\] 事件甲余波/);
|
||||
assert.doesNotMatch(
|
||||
graphStatsContent,
|
||||
new RegExp(relatedThread.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||
);
|
||||
} finally {
|
||||
restoreOverrides();
|
||||
}
|
||||
@@ -2060,17 +2099,22 @@ async function testConsolidatorMergeFallbackKeepsNodeWhenTargetMissing() {
|
||||
addNode(graph, target);
|
||||
addNode(graph, incoming);
|
||||
|
||||
const captured = [];
|
||||
const restoreOverrides = pushTestOverrides({
|
||||
embedding: {
|
||||
async embedBatch() {
|
||||
return [[0.2, 0.3]];
|
||||
},
|
||||
async embedText() {
|
||||
return [0.2, 0.3];
|
||||
},
|
||||
searchSimilar() {
|
||||
return [{ nodeId: target.id, score: 0.99 }];
|
||||
},
|
||||
},
|
||||
llm: {
|
||||
async callLLMForJSON() {
|
||||
async callLLMForJSON(params = {}) {
|
||||
captured.push(params);
|
||||
return {
|
||||
results: [
|
||||
{
|
||||
@@ -2095,13 +2139,31 @@ async function testConsolidatorMergeFallbackKeepsNodeWhenTargetMissing() {
|
||||
apiUrl: "https://example.com/v1",
|
||||
model: "text-embedding-3-small",
|
||||
},
|
||||
settings: {},
|
||||
schema,
|
||||
settings: {
|
||||
taskProfilesVersion: 3,
|
||||
taskProfiles: createDefaultTaskProfiles(),
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(stats.merged, 0);
|
||||
assert.equal(stats.kept, 1);
|
||||
assert.equal(incoming.archived, false);
|
||||
assert.deepEqual(target.embedding, [0.9, 0.1]);
|
||||
assert.equal(captured.length, 1);
|
||||
const graphStatsBlock = (Array.isArray(captured[0].promptMessages)
|
||||
? captured[0].promptMessages
|
||||
: []
|
||||
).find((message) => message.sourceKey === "graphStats");
|
||||
assert.ok(graphStatsBlock, "consolidation graphStats block should exist");
|
||||
const graphStatsContent = String(graphStatsBlock.content || "");
|
||||
assert.match(graphStatsContent, /### 图谱节点统计/);
|
||||
assert.match(graphStatsContent, /事件: 2/);
|
||||
assert.match(graphStatsContent, /\[G1\|事件\] 旧记忆/);
|
||||
assert.doesNotMatch(
|
||||
graphStatsContent,
|
||||
new RegExp(target.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||
);
|
||||
} finally {
|
||||
restoreOverrides();
|
||||
}
|
||||
@@ -2260,6 +2322,9 @@ async function testConsolidatorMergeUpdatesSeqRange() {
|
||||
async embedBatch() {
|
||||
return [[0.4, 0.5]];
|
||||
},
|
||||
async embedText() {
|
||||
return [0.4, 0.5];
|
||||
},
|
||||
searchSimilar() {
|
||||
return [{ nodeId: target.id, score: 0.99 }];
|
||||
},
|
||||
@@ -6212,17 +6277,26 @@ async function testReflectionUsesPromptMessagesWithoutFallbackSystemPrompt() {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const threadNode = createNode({
|
||||
type: "thread",
|
||||
seq: 5,
|
||||
fields: {
|
||||
title: "信任危机",
|
||||
status: "active",
|
||||
},
|
||||
});
|
||||
addNode(
|
||||
graph,
|
||||
createNode({
|
||||
type: "thread",
|
||||
seq: 5,
|
||||
fields: {
|
||||
title: "信任危机",
|
||||
status: "active",
|
||||
},
|
||||
}),
|
||||
threadNode,
|
||||
);
|
||||
const reflectionSchema = [
|
||||
...schema,
|
||||
{
|
||||
id: "thread",
|
||||
label: "主线",
|
||||
columns: [{ name: "title" }, { name: "status" }],
|
||||
},
|
||||
];
|
||||
|
||||
const captured = [];
|
||||
const restoreOverrides = pushTestOverrides({
|
||||
@@ -6243,6 +6317,7 @@ async function testReflectionUsesPromptMessagesWithoutFallbackSystemPrompt() {
|
||||
const result = await generateReflection({
|
||||
graph,
|
||||
currentSeq: 5,
|
||||
schema: reflectionSchema,
|
||||
settings: {
|
||||
taskProfilesVersion: 3,
|
||||
taskProfiles: createDefaultTaskProfiles(),
|
||||
@@ -6254,6 +6329,21 @@ async function testReflectionUsesPromptMessagesWithoutFallbackSystemPrompt() {
|
||||
assert.equal(Array.isArray(captured[0].promptMessages), true);
|
||||
assert.ok(captured[0].promptMessages.length > 0);
|
||||
assert.equal(captured[0].systemPrompt, "");
|
||||
const graphStatsBlock = (Array.isArray(captured[0].promptMessages)
|
||||
? captured[0].promptMessages
|
||||
: []
|
||||
).find((message) => message.sourceKey === "graphStats");
|
||||
assert.ok(graphStatsBlock, "reflection graphStats block should exist");
|
||||
const graphStatsContent = String(graphStatsBlock.content || "");
|
||||
assert.match(graphStatsContent, /### 图谱节点统计/);
|
||||
assert.match(graphStatsContent, /事件: 2/);
|
||||
assert.match(graphStatsContent, /角色: 1/);
|
||||
assert.match(graphStatsContent, /主线: 1/);
|
||||
assert.match(graphStatsContent, /\[G1\|主线\] 信任危机/);
|
||||
assert.doesNotMatch(
|
||||
graphStatsContent,
|
||||
new RegExp(threadNode.id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||
);
|
||||
const reflectionNode = graph.nodes.find((node) => node.id === result);
|
||||
assert.equal(
|
||||
reflectionNode?.fields?.insight,
|
||||
|
||||
Reference in New Issue
Block a user