mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 14:20:35 +08:00
feat: 同批次节点默认弱关联边 + LLM 可增强/移除 + prompt 更新
- extractor: 批次操作完成后统一处理 links,再补默认弱 related 边(0.25) - extractor: 支持 remove/delete/unlink/invalidate 语义显式移除边 - extractor: update 操作现在也能处理 links - extractor: 边计数改为仅统计真正新增的边 - prompt: 输出格式示例加 links 字段,补 links/remove 语法说明 - prompt: 行为规则加关联边使用规范段落 - tests: 3 条回归覆盖默认弱边、显式覆盖、显式移除
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user