diff --git a/index.js b/index.js index 442e56c..9d00f88 100644 --- a/index.js +++ b/index.js @@ -11126,6 +11126,8 @@ async function handleExtractionSuccess( await generateReflection({ graph: currentGraph, currentSeq: endIdx, + schema: getSchema(), + embeddingConfig: getEmbeddingConfig(), settings, signal, }); diff --git a/maintenance/compressor.js b/maintenance/compressor.js index 073a2d2..8adc669 100644 --- a/maintenance/compressor.js +++ b/maintenance/compressor.js @@ -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, diff --git a/maintenance/consolidator.js b/maintenance/consolidator.js index a965303..e450207 100644 --- a/maintenance/consolidator.js +++ b/maintenance/consolidator.js @@ -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: [] }; diff --git a/maintenance/extractor.js b/maintenance/extractor.js index 6a5ccd0..6186755 100644 --- a/maintenance/extractor.js +++ b/maintenance/extractor.js @@ -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: [] }; diff --git a/maintenance/task-graph-stats.js b/maintenance/task-graph-stats.js new file mode 100644 index 0000000..15727e2 --- /dev/null +++ b/maintenance/task-graph-stats.js @@ -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, + }), + }; +} diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index b68deca..4fb3876 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -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,