diff --git a/maintenance/extractor.js b/maintenance/extractor.js index 4f88489..7cea1e4 100644 --- a/maintenance/extractor.js +++ b/maintenance/extractor.js @@ -1239,6 +1239,8 @@ export async function extractMemories({ const newNodeIds = []; // v2: 收集新建节点 ID(用于进化引擎) const updatedNodeIds = []; const refMap = new Map(); + const pendingLinkJobs = []; + const suppressedDefaultPairKeys = new Set(); const operationErrors = []; const normalizedBatchStoryTime = normalizedResult?.batchStoryTime || null; @@ -1246,7 +1248,7 @@ export async function extractMemories({ try { switch (op.action) { case "create": { - const createdId = handleCreate( + const createResult = handleCreate( graph, op, currentSeq, @@ -1258,7 +1260,15 @@ export async function extractMemories({ ownershipWarnings, normalizedBatchStoryTime, ); - if (createdId) newNodeIds.push(createdId); + if (createResult?.nodeId) { + queueOperationLinks(pendingLinkJobs, createResult.nodeId, op.links); + } + if (createResult?.created === true && createResult.nodeId) { + newNodeIds.push(createResult.nodeId); + } + if (createResult?.updated === true && createResult.nodeId) { + updatedNodeIds.push(createResult.nodeId); + } break; } case "update": @@ -1273,7 +1283,10 @@ export async function extractMemories({ ownershipWarnings, normalizedBatchStoryTime, ); - if (updatedNodeId) updatedNodeIds.push(updatedNodeId); + if (updatedNodeId) { + updatedNodeIds.push(updatedNodeId); + queueOperationLinks(pendingLinkJobs, updatedNodeId, op.links); + } } break; case "delete": @@ -1305,6 +1318,19 @@ export async function extractMemories({ }; } + applyPendingLinks(graph, pendingLinkJobs, refMap, stats, { + suppressedDefaultPairKeys, + }); + applyDefaultBatchEdges( + graph, + [...new Set([...newNodeIds, ...updatedNodeIds])], + stats, + settings, + { + suppressedDefaultPairKeys, + }, + ); + // 为新建节点生成 embedding。失败不应回滚整批图谱写入。 try { await generateNodeEmbeddings(graph, embeddingConfig, signal); @@ -1416,11 +1442,7 @@ function handleCreate( if (op.ref) refMap.set(op.ref, existing.id); - // 处理关联边 - if (op.links) { - handleLinks(graph, existing.id, op.links, refMap, stats); - } - return null; + return { nodeId: existing.id, created: false, updated: true }; } } @@ -1443,12 +1465,7 @@ function handleCreate( refMap.set(op.ref, node.id); } - // 处理关联边 - if (op.links) { - handleLinks(graph, node.id, op.links, refMap, stats); - } - - return node.id; + return { nodeId: node.id, created: true, updated: false }; } /** @@ -1541,9 +1558,7 @@ function handleUpdate( strength: op.temporalStrength ?? 0.95, edgeType: 0, }); - if (addEdge(graph, temporalEdge)) { - stats.newEdges++; - } + addEdgeWithStats(graph, temporalEdge, stats); } if (changeSummary) { @@ -1583,14 +1598,209 @@ function handleUpdate( edgeType: 0, scope: updateEventNode.scope, }); - if (addEdge(graph, updateEdge)) { - stats.newEdges++; - } + addEdgeWithStats(graph, updateEdge, stats); } } return updated ? op.nodeId : ""; } +function addEdgeWithStats(graph, edge, stats) { + const addedEdge = addEdge(graph, edge); + if (addedEdge === edge) { + stats.newEdges++; + } + return addedEdge; +} + +function buildUndirectedPairKey(leftId, rightId) { + const normalizedLeft = String(leftId || "").trim(); + const normalizedRight = String(rightId || "").trim(); + if (!normalizedLeft || !normalizedRight || normalizedLeft === normalizedRight) { + return ""; + } + return [normalizedLeft, normalizedRight].sort().join("::"); +} + +function queueOperationLinks(pendingLinkJobs, sourceId, links) { + if (!sourceId || !Array.isArray(links) || links.length === 0) return; + pendingLinkJobs.push({ + sourceId: String(sourceId || ""), + links, + }); +} + +function resolveLinkTargetId(link = {}, refMap = new Map()) { + let targetId = link.targetNodeId || null; + if (!targetId && link.targetRef) { + targetId = refMap.get(link.targetRef); + } + return targetId ? String(targetId) : ""; +} + +function shouldInvalidateLink(link = {}) { + const action = String(link?.action || "").trim().toLowerCase(); + return ( + link?.remove === true || + link?.delete === true || + link?.unlink === true || + link?.invalidate === true || + action === "remove" || + action === "delete" || + action === "unlink" || + action === "invalidate" + ); +} + +function isEdgeCurrentlyActive(edge) { + if (!edge) return false; + if (edge.invalidAt) return false; + if (edge.expiredAt) return false; + return true; +} + +function resolveLinkRelation(link = {}, { fallback = "related" } = {}) { + const relation = String(link?.relation || "").trim(); + if (RELATION_TYPES.includes(relation)) { + return relation; + } + return fallback; +} + +function invalidateLinksBetween(graph, sourceId, targetId, relation = "related") { + if (!sourceId || !targetId) return 0; + let changed = 0; + for (const edge of Array.isArray(graph?.edges) ? graph.edges : []) { + if (!isEdgeCurrentlyActive(edge)) continue; + if (edge.relation !== relation) continue; + const sameDirection = edge.fromId === sourceId && edge.toId === targetId; + const reverseDirection = edge.fromId === targetId && edge.toId === sourceId; + if (!sameDirection && !reverseDirection) continue; + invalidateEdge(edge); + changed += 1; + } + return changed; +} + +function handleLinks(graph, sourceId, links, refMap, stats, options = {}) { + const suppressedDefaultPairKeys = + options?.suppressedDefaultPairKeys instanceof Set + ? options.suppressedDefaultPairKeys + : null; + const sourceNode = getNode(graph, sourceId); + const sourceScope = normalizeMemoryScope(sourceNode?.scope); + for (const link of links) { + const targetId = resolveLinkTargetId(link, refMap); + if (!targetId) continue; + + if (shouldInvalidateLink(link)) { + const relation = resolveLinkRelation(link, { fallback: "related" }); + if (suppressedDefaultPairKeys && relation === "related") { + const pairKey = buildUndirectedPairKey(sourceId, targetId); + if (pairKey) { + suppressedDefaultPairKeys.add(pairKey); + } + } + invalidateLinksBetween(graph, sourceId, targetId, relation); + continue; + } + + const relation = resolveLinkRelation(link, { fallback: "related" }); + const edgeType = relation === "contradicts" ? 255 : 0; + const edge = createEdge({ + fromId: sourceId, + toId: targetId, + relation, + strength: link.strength ?? 0.8, + edgeType, + scope: link.scope || sourceScope, + }); + + addEdgeWithStats(graph, edge, stats); + } +} + +function applyPendingLinks( + graph, + pendingLinkJobs, + refMap, + stats, + { suppressedDefaultPairKeys = null } = {}, +) { + for (const job of Array.isArray(pendingLinkJobs) ? pendingLinkJobs : []) { + if (!job?.sourceId || !Array.isArray(job?.links) || job.links.length === 0) { + continue; + } + handleLinks(graph, job.sourceId, job.links, refMap, stats, { + suppressedDefaultPairKeys, + }); + } +} + +function hasActiveEdgeBetween(graph, leftId, rightId) { + if (!leftId || !rightId) return false; + return (Array.isArray(graph?.edges) ? graph.edges : []).some((edge) => { + if (!isEdgeCurrentlyActive(edge)) return false; + return ( + (edge.fromId === leftId && edge.toId === rightId) || + (edge.fromId === rightId && edge.toId === leftId) + ); + }); +} + +function applyDefaultBatchEdges( + graph, + nodeIds, + stats, + settings = {}, + { suppressedDefaultPairKeys = null } = {}, +) { + if (settings?.extractDefaultBatchRelatedEdges === false) { + return 0; + } + const strength = Math.max( + 0, + Math.min(1, Number(settings?.extractDefaultBatchRelatedEdgeStrength) || 0.25), + ); + if (strength <= 0) return 0; + + const orderedIds = [...new Set( + (Array.isArray(nodeIds) ? nodeIds : []) + .map((nodeId) => String(nodeId || "").trim()) + .filter(Boolean), + )].filter((nodeId) => { + const node = getNode(graph, nodeId); + return Boolean(node) && node.archived !== true; + }); + + let createdCount = 0; + for (let leftIndex = 0; leftIndex < orderedIds.length; leftIndex += 1) { + for (let rightIndex = leftIndex + 1; rightIndex < orderedIds.length; rightIndex += 1) { + const sourceId = orderedIds[leftIndex]; + const targetId = orderedIds[rightIndex]; + const pairKey = buildUndirectedPairKey(sourceId, targetId); + if (suppressedDefaultPairKeys?.has(pairKey)) { + continue; + } + if (hasActiveEdgeBetween(graph, sourceId, targetId)) { + continue; + } + const sourceNode = getNode(graph, sourceId); + const edge = createEdge({ + fromId: sourceId, + toId: targetId, + relation: "related", + strength, + edgeType: 0, + scope: normalizeMemoryScope(sourceNode?.scope), + }); + if (addEdgeWithStats(graph, edge, stats) === edge) { + createdCount += 1; + } + } + } + return createdCount; +} + function buildFieldChangeSummary(previousFields = {}, nextFields = {}) { const changes = []; const keys = new Set([ @@ -1622,44 +1832,6 @@ function handleDelete(graph, op, stats) { } } -/** - * 处理关联边 - */ -function handleLinks(graph, sourceId, links, refMap, stats) { - const sourceNode = getNode(graph, sourceId); - const sourceScope = normalizeMemoryScope(sourceNode?.scope); - for (const link of links) { - let targetId = link.targetNodeId || null; - - // 通过 ref 解析目标节点 - if (!targetId && link.targetRef) { - targetId = refMap.get(link.targetRef); - } - - if (!targetId) continue; - - // 验证关系类型 - const relation = RELATION_TYPES.includes(link.relation) - ? link.relation - : "related"; - - const edgeType = relation === "contradicts" ? 255 : 0; - - const edge = createEdge({ - fromId: sourceId, - toId: targetId, - relation, - strength: link.strength ?? 0.8, - edgeType, - scope: link.scope || sourceScope, - }); - - if (addEdge(graph, edge)) { - stats.newEdges++; - } - } -} - function resolveOperationScope( graph, op, diff --git a/prompting/default-task-profile-templates.js b/prompting/default-task-profile-templates.js index b4b3e78..3b21ed7 100644 --- a/prompting/default-task-profile-templates.js +++ b/prompting/default-task-profile-templates.js @@ -165,7 +165,7 @@ export const DEFAULT_TASK_PROFILE_TEMPLATES = { "role": "user", "sourceKey": "", "sourceField": "", - "content": "请只输出一个合法 JSON 对象:\n{\n \"thought\": \"简要分析这批对话里真正值得入图的变化\",\n \"batchStoryTime\": {\n \"label\": \"第二天清晨\",\n \"tense\": \"ongoing\",\n \"relation\": \"after\",\n \"anchorLabel\": \"昨夜冲突之后\",\n \"confidence\": \"high\",\n \"advancesActiveTimeline\": true\n },\n \"operations\": [\n {\n \"action\": \"create\",\n \"type\": \"event\",\n \"fields\": {\"title\": \"简短事件名\", \"summary\": \"...\", \"participants\": \"...\", \"status\": \"ongoing\"},\n \"scope\": {\"layer\": \"objective\", \"regionPrimary\": \"主地区\", \"regionPath\": [\"上级地区\", \"主地区\"], \"regionSecondary\": [\"次级地区\"]},\n \"storyTime\": {\"label\": \"第二天清晨\", \"tense\": \"ongoing\", \"relation\": \"same\", \"confidence\": \"high\"},\n \"importance\": 6,\n \"ref\": \"evt1\"\n },\n {\n \"action\": \"create\",\n \"type\": \"pov_memory\",\n \"fields\": {\"summary\": \"这个角色会怎么记住这件事\", \"belief\": \"她认为发生了什么\", \"emotion\": \"情绪\", \"attitude\": \"态度\", \"certainty\": \"unsure\", \"about\": \"evt1\"},\n \"scope\": {\"layer\": \"pov\", \"ownerType\": \"character\", \"ownerId\": \"角色名\", \"ownerName\": \"角色名\", \"regionPrimary\": \"主地区\", \"regionPath\": [\"上级地区\", \"主地区\"]},\n \"storyTime\": {\"label\": \"第二天清晨\", \"tense\": \"ongoing\", \"relation\": \"same\", \"confidence\": \"high\"}\n }\n ],\n \"cognitionUpdates\": [\n {\n \"ownerType\": \"character\",\n \"ownerName\": \"艾琳\",\n \"ownerNodeId\": \"char-1\",\n \"knownRefs\": [\"evt1\", \"char2\"],\n \"mistakenRefs\": [\"evt2\"],\n \"visibility\": [\n {\"ref\": \"evt1\", \"score\": 1.0, \"reason\": \"direct witness\"},\n {\"ref\": \"thread-1\", \"score\": 0.55, \"reason\": \"heard nearby\"}\n ]\n }\n ],\n \"regionUpdates\": {\n \"activeRegionHint\": \"钟楼\",\n \"adjacency\": [\n {\"region\": \"钟楼\", \"adjacent\": [\"旧城区\", \"内廷\"]}\n ]\n }\n}\n如果要更新已有节点,可使用 {\"action\":\"update\",\"nodeId\":\"existing-node-id\",\"fields\":{...},\"scope\":{...}}。\nknownRefs / mistakenRefs / visibility.ref 优先引用同批 ref,没有 ref 再引用已有 nodeId。\n如果这一批主叙事时间能判断,尽量填写 batchStoryTime;operations[].storyTime 可以单独覆盖,不写时视为继承本批主时间。\n如果这批对话没有值得入图的新信息,返回 {\"thought\":\"...\", \"operations\": [], \"cognitionUpdates\": [], \"regionUpdates\": {}}。", + "content": "请只输出一个合法 JSON 对象:\n{\n \"thought\": \"简要分析这批对话里真正值得入图的变化\",\n \"batchStoryTime\": {\n \"label\": \"第二天清晨\",\n \"tense\": \"ongoing\",\n \"relation\": \"after\",\n \"anchorLabel\": \"昨夜冲突之后\",\n \"confidence\": \"high\",\n \"advancesActiveTimeline\": true\n },\n \"operations\": [\n {\n \"action\": \"create\",\n \"type\": \"event\",\n \"fields\": {\"title\": \"简短事件名\", \"summary\": \"...\", \"participants\": \"...\", \"status\": \"ongoing\"},\n \"scope\": {\"layer\": \"objective\", \"regionPrimary\": \"主地区\", \"regionPath\": [\"上级地区\", \"主地区\"], \"regionSecondary\": [\"次级地区\"]},\n \"storyTime\": {\"label\": \"第二天清晨\", \"tense\": \"ongoing\", \"relation\": \"same\", \"confidence\": \"high\"},\n \"importance\": 6,\n \"ref\": \"evt1\",\n \"links\": [{\"targetRef\": \"char-1\", \"relation\": \"involved_in\", \"strength\": 0.85}]\n },\n {\n \"action\": \"create\",\n \"type\": \"pov_memory\",\n \"fields\": {\"summary\": \"这个角色会怎么记住这件事\", \"belief\": \"她认为发生了什么\", \"emotion\": \"情绪\", \"attitude\": \"态度\", \"certainty\": \"unsure\", \"about\": \"evt1\"},\n \"scope\": {\"layer\": \"pov\", \"ownerType\": \"character\", \"ownerId\": \"角色名\", \"ownerName\": \"角色名\", \"regionPrimary\": \"主地区\", \"regionPath\": [\"上级地区\", \"主地区\"]},\n \"storyTime\": {\"label\": \"第二天清晨\", \"tense\": \"ongoing\", \"relation\": \"same\", \"confidence\": \"high\"}\n }\n ],\n \"cognitionUpdates\": [\n {\n \"ownerType\": \"character\",\n \"ownerName\": \"艾琳\",\n \"ownerNodeId\": \"char-1\",\n \"knownRefs\": [\"evt1\", \"char2\"],\n \"mistakenRefs\": [\"evt2\"],\n \"visibility\": [\n {\"ref\": \"evt1\", \"score\": 1.0, \"reason\": \"direct witness\"},\n {\"ref\": \"thread-1\", \"score\": 0.55, \"reason\": \"heard nearby\"}\n ]\n }\n ],\n \"regionUpdates\": {\n \"activeRegionHint\": \"钟楼\",\n \"adjacency\": [\n {\"region\": \"钟楼\", \"adjacent\": [\"旧城区\", \"内廷\"]}\n ]\n }\n}\n如果要更新已有节点,可使用 {\"action\":\"update\",\"nodeId\":\"existing-node-id\",\"fields\":{...},\"scope\":{...}}。\n同批节点之间会自动产生默认弱关联边(related, strength 0.25)。如需加强连接或指定关系类型,可在 operation 里写 \"links\": [{\"targetRef\":\"同批ref或已有nodeId\", \"relation\":\"involved_in\", \"strength\":0.85}]。如需移除不合理的默认关联,写 {\"targetRef\":\"...\", \"relation\":\"related\", \"remove\":true}。\nknownRefs / mistakenRefs / visibility.ref 优先引用同批 ref,没有 ref 再引用已有 nodeId。\n如果这一批主叙事时间能判断,尽量填写 batchStoryTime;operations[].storyTime 可以单独覆盖,不写时视为继承本批主时间。\n如果这批对话没有值得入图的新信息,返回 {\"thought\":\"...\", \"operations\": [], \"cognitionUpdates\": [], \"regionUpdates\": {}}。", "injectionMode": "relative", "order": 12 }, @@ -177,7 +177,7 @@ export const DEFAULT_TASK_PROFILE_TEMPLATES = { "role": "user", "sourceKey": "", "sourceField": "", - "content": "我对你的执行标准是这样的——\n- 先帮我做事件分级,再决定要不要建节点:\n · A级(转折点):关系质变、告白、背叛、决裂、不可逆改变、重大选择 -> importance 8-10,必记\n · B级(推进点):新信息、新联系、阶段性完成、有意义的位置移动 -> importance 5-7,按信息量建节点\n · C级(填充):日常对话、重复行为、无后续影响的闲聊 -> 通常不单独建节点\n- 每批帮我收敛成少量高价值操作就好;通常 1 个 event,加上必要的 update、必要的 POV 和记忆认知更新就够了。\n- 客观事实帮我优先用 event / character / location / thread / rule / synopsis / reflection。\n- 主观记忆统一使用 type = pov_memory,不要拿 character / location / event 去伪装第一视角记忆。\n- 客观节点 scope.layer 必须是 objective;POV 节点 scope.layer 必须是 pov,并且必须写 ownerType / ownerId / ownerName。\n- 涉及到的角色都尽量尝试补 cognitionUpdates,不只限当前角色和用户。\n- cognitionUpdates 只表达谁明确知道、谁误解、谁低置信可见;不要帮我写成第二份事实节点。\n- 多角色场景里,pov_memory 和 cognitionUpdates 必须写清具体人物;不要把角色卡名当作 POV owner。\n- 用户 POV 不等于角色已知事实;它是我作为用户/玩家侧的感受、承诺、偏见和长期互动背景。\n- batchStoryTime 表示这批主叙事所处的剧情时间;只有明确推进主叙事时才把 advancesActiveTimeline 设为 true。\n- operations[].storyTime 写节点自己的剧情时间;帮我区分\"故事里什么时候发生\"和\"聊天里什么时候被提到\"。\n- flashback / future / hypothetical 可以写时间,但通常不要推进当前活动时间轴。\n- 地区能判断才写 scope.regionPrimary / regionPath / regionSecondary;判断不出来就帮我留空。\n- 角色、地点等 latestOnly 节点如果图里已有同名同作用域节点,优先帮我 update,不要重复 create。\n\n客观层字段方面我的要求是——\n- event.title 只写简短事件名,6-10 字。\n- event.summary 用白描复述事实,150 字以内,不抒情不评价。\n- participants 用逗号分隔参与者。\n- character / location 的字段也用白描,不写主观评价。\n\nPOV 记忆字段方面我的要求是——\npov_memory 要像角色真的会留下的记忆痕迹,不是客观事件的换个说法。\n\n- **summary**:帮我写\"这个角色会怎么记住这件事\"\n · 不是客观事件摘要,是主观记忆痕迹\n · 用角色的人格语气(温柔?冷淡?戏谑?怯懦?警觉?)\n · 可以是碎念、独白、关系定位、感官片段——看角色性格\n · 只包含角色真实看到、听到、感受到的内容(非全知)\n · 示例:\n × \"角色A和用户在咖啡馆聊天,谈到了工作\"(客观复述,我不要这种)\n √ \"他今天一直在揉太阳穴。我问他要不要换个话题,他说没事。他说没事的时候眼睛没看我。\"(主观记忆,我要这种)\n\n- **belief**:角色认为发生了什么\n · 可能与客观事实不同——这正是 POV 价值所在\n · 如果角色误解了真相,belief 要帮我反映这个误解\n\n- **emotion**:当时最强烈的情感\n · 帮我写具体感受,不写\"开心\"\"难过\"这种标签\n · 示例:\n × \"开心\"\n √ \"胸口像被什么顶着,想说点什么又说不出来\"\n\n- **attitude**:角色对这件事或相关人的态度(可能发生了变化)\n\n- **certainty**:\n · certain = 亲历确认,非常肯定\n · unsure = 间接得知或只看到片段\n · mistaken = 明确误解了事实\n\n- **about**:关联的事件或实体,优先引用同批 ref,没有 ref 再用简短标签\n\nvisibility.score 取 0..1;1 表示亲历或明确得知,0.5 左右表示间接听闻,0.2 左右表示远远瞥见。\n时间推不出来就留空,不允许为了补全格式硬编剧情时间标签。\n\n以下是我特别不想看到的——\n- 编造对话里没有的事件、地区、想法、认知状态或邻接关系。\n- 把角色 POV、用户 POV、客观事实混成同一个节点。\n- 让 POV 记忆拥有该视角不可能知道的信息(全知错误)。\n- 所有角色的 POV 都用同一种语气写(应该各有各的人格印记)。\n- POV summary 写成客观事件的换皮复述。\n- emotion 只写标签词,不写具体感受。\n- 只为显得全面就给所有角色都硬写 POV 或 cognitionUpdates。\n- 把 cognitionUpdates 当硬白名单或第二份世界事实表。\n- 把后面才说到的事情误判成后面才发生,或把未来计划当成已经发生的当前事实。\n- 把角色卡名、群像统称或旁白身份当成具体 POV owner。\n- 地区不确定却硬写一个像地区的词。\n- 为了显得全面而生成很多低价值碎节点。\n- 直接复制原文,或写成文学化修辞。", + "content": "我对你的执行标准是这样的——\n- 先帮我做事件分级,再决定要不要建节点:\n · A级(转折点):关系质变、告白、背叛、决裂、不可逆改变、重大选择 -> importance 8-10,必记\n · B级(推进点):新信息、新联系、阶段性完成、有意义的位置移动 -> importance 5-7,按信息量建节点\n · C级(填充):日常对话、重复行为、无后续影响的闲聊 -> 通常不单独建节点\n- 每批帮我收敛成少量高价值操作就好;通常 1 个 event,加上必要的 update、必要的 POV 和记忆认知更新就够了。\n- 客观事实帮我优先用 event / character / location / thread / rule / synopsis / reflection。\n- 主观记忆统一使用 type = pov_memory,不要拿 character / location / event 去伪装第一视角记忆。\n- 客观节点 scope.layer 必须是 objective;POV 节点 scope.layer 必须是 pov,并且必须写 ownerType / ownerId / ownerName。\n- 涉及到的角色都尽量尝试补 cognitionUpdates,不只限当前角色和用户。\n- cognitionUpdates 只表达谁明确知道、谁误解、谁低置信可见;不要帮我写成第二份事实节点。\n- 多角色场景里,pov_memory 和 cognitionUpdates 必须写清具体人物;不要把角色卡名当作 POV owner。\n- 用户 POV 不等于角色已知事实;它是我作为用户/玩家侧的感受、承诺、偏见和长期互动背景。\n- batchStoryTime 表示这批主叙事所处的剧情时间;只有明确推进主叙事时才把 advancesActiveTimeline 设为 true。\n- operations[].storyTime 写节点自己的剧情时间;帮我区分\"故事里什么时候发生\"和\"聊天里什么时候被提到\"。\n- flashback / future / hypothetical 可以写时间,但通常不要推进当前活动时间轴。\n- 地区能判断才写 scope.regionPrimary / regionPath / regionSecondary;判断不出来就帮我留空。\n- 角色、地点等 latestOnly 节点如果图里已有同名同作用域节点,优先帮我 update,不要重复 create。\n\n关联边(links)方面——\n- 同批次创建或更新的节点之间,系统会自动建立默认弱关联(related, strength 0.25),你不需要手动写这些。\n- 你需要做的是:\n · 如果两个节点之间有明确的强关系(例如角色参与事件、事件发生在某地点),请在 links 里显式声明,写清 relation 和 strength(0.5~1.0)\n · 如果两个同批节点其实没有关联(只是恰好同批提取),请用 remove:true 移除默认弱边\n · 支持的 relation 类型:related(一般关联)、involved_in(参与事件)、occurred_at(发生于地点)、advances(推进主线)、updates(更新实体状态)、contradicts(矛盾/冲突)\n- 不要为每对节点都写 links——只在关系明确且有意义时才写。\n- 跨批次要关联已有节点时,targetRef 写已有的 nodeId。\n\n客观层字段方面我的要求是——\n- event.title 只写简短事件名,6-10 字。\n- event.summary 用白描复述事实,150 字以内,不抒情不评价。\n- participants 用逗号分隔参与者。\n- character / location 的字段也用白描,不写主观评价。\n\nPOV 记忆字段方面我的要求是——\npov_memory 要像角色真的会留下的记忆痕迹,不是客观事件的换个说法。\n\n- **summary**:帮我写\"这个角色会怎么记住这件事\"\n · 不是客观事件摘要,是主观记忆痕迹\n · 用角色的人格语气(温柔?冷淡?戏谑?怯懦?警觉?)\n · 可以是碎念、独白、关系定位、感官片段——看角色性格\n · 只包含角色真实看到、听到、感受到的内容(非全知)\n · 示例:\n × \"角色A和用户在咖啡馆聊天,谈到了工作\"(客观复述,我不要这种)\n √ \"他今天一直在揉太阳穴。我问他要不要换个话题,他说没事。他说没事的时候眼睛没看我。\"(主观记忆,我要这种)\n\n- **belief**:角色认为发生了什么\n · 可能与客观事实不同——这正是 POV 价值所在\n · 如果角色误解了真相,belief 要帮我反映这个误解\n\n- **emotion**:当时最强烈的情感\n · 帮我写具体感受,不写\"开心\"\"难过\"这种标签\n · 示例:\n × \"开心\"\n √ \"胸口像被什么顶着,想说点什么又说不出来\"\n\n- **attitude**:角色对这件事或相关人的态度(可能发生了变化)\n\n- **certainty**:\n · certain = 亲历确认,非常肯定\n · unsure = 间接得知或只看到片段\n · mistaken = 明确误解了事实\n\n- **about**:关联的事件或实体,优先引用同批 ref,没有 ref 再用简短标签\n\nvisibility.score 取 0..1;1 表示亲历或明确得知,0.5 左右表示间接听闻,0.2 左右表示远远瞥见。\n时间推不出来就留空,不允许为了补全格式硬编剧情时间标签。\n\n以下是我特别不想看到的——\n- 编造对话里没有的事件、地区、想法、认知状态或邻接关系。\n- 把角色 POV、用户 POV、客观事实混成同一个节点。\n- 让 POV 记忆拥有该视角不可能知道的信息(全知错误)。\n- 所有角色的 POV 都用同一种语气写(应该各有各的人格印记)。\n- POV summary 写成客观事件的换皮复述。\n- emotion 只写标签词,不写具体感受。\n- 只为显得全面就给所有角色都硬写 POV 或 cognitionUpdates。\n- 把 cognitionUpdates 当硬白名单或第二份世界事实表。\n- 把后面才说到的事情误判成后面才发生,或把未来计划当成已经发生的当前事实。\n- 把角色卡名、群像统称或旁白身份当成具体 POV owner。\n- 地区不确定却硬写一个像地区的词。\n- 为了显得全面而生成很多低价值碎节点。\n- 直接复制原文,或写成文学化修辞。\n- 滥用 links 关联边,导致图结构混乱或不合理。", "injectionMode": "relative", "order": 13 } diff --git a/tests/p0-regressions.mjs b/tests/p0-regressions.mjs index b4b804b..84346fe 100644 --- a/tests/p0-regressions.mjs +++ b/tests/p0-regressions.mjs @@ -2767,6 +2767,156 @@ async function testExtractorPropagatesLlmFailureReason() { } } +async function testExtractorAddsWeakDefaultRelatedEdgeForSameBatchNodes() { + const graph = createEmptyGraph(); + const restoreOverrides = pushTestOverrides({ + llm: { + async callLLMForJSON() { + return { + operations: [ + { + type: "event", + id: "evt1", + title: "钟楼初见", + summary: "两人在钟楼第一次正式见面。", + participants: "艾琳, 用户", + }, + { + type: "event", + id: "evt2", + title: "钟楼密谈", + summary: "见面后立刻进入短暂密谈。", + participants: "艾琳, 用户", + }, + ], + }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: [{ seq: 10, role: "assistant", content: "测试批次默认弱连边" }], + startSeq: 10, + endSeq: 10, + schema, + embeddingConfig: null, + settings: {}, + }); + + assert.equal(result.success, true); + assert.equal(result.newNodes, 2); + assert.equal(result.newEdges, 1); + const activeEdges = graph.edges.filter((edge) => !edge.invalidAt && !edge.expiredAt); + assert.equal(activeEdges.length, 1); + assert.equal(activeEdges[0]?.relation, "related"); + assert.equal(activeEdges[0]?.strength, 0.25); + } finally { + restoreOverrides(); + } +} + +async function testExtractorExplicitLinksOverrideDefaultBatchWeakEdge() { + const graph = createEmptyGraph(); + const restoreOverrides = pushTestOverrides({ + llm: { + async callLLMForJSON() { + return { + operations: [ + { + type: "event", + id: "evt1", + title: "档案室发现", + summary: "发现了一份关键档案。", + participants: "艾琳", + links: [{ targetRef: "evt2", relation: "related", strength: 0.91 }], + }, + { + type: "event", + id: "evt2", + title: "档案内容核对", + summary: "紧接着对档案内容进行核对。", + participants: "艾琳", + }, + ], + }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: [{ seq: 11, role: "assistant", content: "测试显式 links 覆盖默认弱边" }], + startSeq: 11, + endSeq: 11, + schema, + embeddingConfig: null, + settings: {}, + }); + + assert.equal(result.success, true); + assert.equal(result.newNodes, 2); + assert.equal(result.newEdges, 1); + const activeEdges = graph.edges.filter((edge) => !edge.invalidAt && !edge.expiredAt); + assert.equal(activeEdges.length, 1); + assert.equal(activeEdges[0]?.relation, "related"); + assert.equal(activeEdges[0]?.strength, 0.91); + } finally { + restoreOverrides(); + } +} + +async function testExtractorExplicitRemoveSuppressesDefaultBatchWeakEdge() { + const graph = createEmptyGraph(); + const restoreOverrides = pushTestOverrides({ + llm: { + async callLLMForJSON() { + return { + operations: [ + { + type: "event", + id: "evt1", + title: "花园交错", + summary: "两条线索在花园中短暂交错。", + participants: "艾琳, 守卫", + links: [{ targetRef: "evt2", relation: "related", remove: true }], + }, + { + type: "event", + id: "evt2", + title: "花园分流", + summary: "随后两条线索各自分流。", + participants: "艾琳, 守卫", + }, + ], + }; + }, + }, + }); + + try { + const result = await extractMemories({ + graph, + messages: [{ seq: 12, role: "assistant", content: "测试显式移除默认弱边" }], + startSeq: 12, + endSeq: 12, + schema, + embeddingConfig: null, + settings: {}, + }); + + assert.equal(result.success, true); + assert.equal(result.newNodes, 2); + assert.equal(result.newEdges, 0); + const activeEdges = graph.edges.filter((edge) => !edge.invalidAt && !edge.expiredAt); + assert.equal(activeEdges.length, 0); + } finally { + restoreOverrides(); + } +} + async function testConsolidatorMergeUpdatesSeqRange() { const graph = createEmptyGraph(); const target = createNode({ @@ -7208,6 +7358,9 @@ await testExtractorFailsOnUnknownOperation(); await testExtractorNormalizesFlatCreateOperation(); await testExtractorNormalizesArrayPayloadAndPreservesScopeField(); await testExtractorPropagatesLlmFailureReason(); +await testExtractorAddsWeakDefaultRelatedEdgeForSameBatchNodes(); +await testExtractorExplicitLinksOverrideDefaultBatchWeakEdge(); +await testExtractorExplicitRemoveSuppressesDefaultBatchWeakEdge(); await testConsolidatorMergeUpdatesSeqRange(); await testConsolidatorMergeFallbackKeepsNodeWhenTargetMissing(); await testBatchJournalVectorDeltaCapturesRecoveryFields();