diff --git a/src/core/prompts/l1-dedup.ts b/src/core/prompts/l1-dedup.ts index a04bda30..bd921948 100644 --- a/src/core/prompts/l1-dedup.ts +++ b/src/core/prompts/l1-dedup.ts @@ -65,7 +65,7 @@ export const CONFLICT_DETECTION_SYSTEM_PROMPT = `你是记忆冲突检测器。 - target_ids:要删除替换的旧记忆 ID **数组**(可以 1 条或多条)。store/skip 时省略或为空。 - merged_content:merge/update 时的最终记忆文本。store/skip 时省略。 - merged_type:merge/update 后记忆应归属的 type。根据合并后内容本质判断。 -- merged_priority:merge/update 后的新优先级(0-100 整数,merge/update 时必填)。合并后信息更完整、更确定,通常应**酌情提升** priority(例如两条 priority 70 的记忆合并后可提升到 80)。参考标准:80-100(核心特质/重要事件),60-79(一般偏好/普通活动),<60(次要信息)。 +- merged_priority:merge/update 后的新优先级(0-100 整数,merge/update 时必填)。不要因为合并本身提高优先级;merged_priority 必须小于或等于参与合并/更新的新记忆与 target_ids 对应旧记忆中的最高 priority。参考标准:80-100(核心特质/重要事件),60-79(一般偏好/普通活动),<60(次要信息)。 - merged_timestamps:合并后的时间戳数组。收集新记忆 + 所有被合并旧记忆的时间戳,去重排序。`; // ============================ diff --git a/src/core/record/l1-dedup.test.ts b/src/core/record/l1-dedup.test.ts new file mode 100644 index 00000000..f353ca2a --- /dev/null +++ b/src/core/record/l1-dedup.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { batchDedup } from "./l1-dedup.js"; +import type { ExtractedMemory } from "./l1-writer.js"; +import type { IMemoryStore, L1FtsResult } from "../store/types.js"; +import type { LLMRunner } from "../types.js"; + +describe("batchDedup", () => { + it("caps merged priority at the strongest source memory priority", async () => { + const newMemory: ExtractedMemory & { record_id: string } = { + record_id: "new_1", + content: "User asked the agent to only reply OK during diagnostic testing.", + type: "instruction", + priority: 75, + source_message_ids: ["msg_1"], + metadata: {}, + scene_name: "diagnostic testing", + }; + + const candidate: L1FtsResult = { + record_id: "old_1", + content: "User asked the assistant not to sound cold during a style adjustment.", + type: "instruction", + priority: 80, + scene_name: "style adjustment", + score: 1, + timestamp_str: "2026-05-25T08:28:48.298Z", + timestamp_start: "2026-05-25T08:28:48.298Z", + timestamp_end: "2026-05-25T08:28:48.298Z", + session_key: "session-a", + session_id: "thread-a", + metadata_json: "{}", + }; + + const vectorStore = { + countL1: () => 1, + isFtsAvailable: () => true, + searchL1Fts: async () => [candidate], + } as unknown as IMemoryStore; + + const llmRunner: LLMRunner = { + async run() { + return JSON.stringify([ + { + record_id: "new_1", + action: "merge", + target_ids: ["old_1"], + merged_content: "User wants extremely concise assistant replies and OK-style confirmations.", + merged_type: "instruction", + merged_priority: 95, + merged_timestamps: ["2026-05-25T08:28:48.298Z"], + }, + ]); + }, + }; + + const decisions = await batchDedup({ + memories: [newMemory], + config: {}, + vectorStore, + llmRunner, + }); + + expect(decisions).toHaveLength(1); + expect(decisions[0].action).toBe("merge"); + expect(decisions[0].merged_priority).toBe(80); + }); +}); diff --git a/src/core/record/l1-dedup.ts b/src/core/record/l1-dedup.ts index 0fb6bf62..bd64f341 100644 --- a/src/core/record/l1-dedup.ts +++ b/src/core/record/l1-dedup.ts @@ -178,7 +178,7 @@ async function runLlmJudgment( } const decisions = parseBatchResult(result, memories, logger); - return decisions; + return capMergedPriorities(decisions, matches, logger); } catch (err) { logger?.warn?.( `${TAG} Batch conflict detection failed, defaulting all to store: ${err instanceof Error ? err.message : String(err)}`, @@ -386,6 +386,53 @@ function parseBatchResult( } } +/** + * Deterministic guardrail: merge/update can combine evidence, but must not + * amplify priority beyond the strongest source memory involved. + */ +function capMergedPriorities( + decisions: DedupDecision[], + matches: CandidateMatch[], + logger?: Logger, +): DedupDecision[] { + const matchByRecordId = new Map(matches.map((m) => [m.newMemory.record_id, m])); + + return decisions.map((decision) => { + if ( + (decision.action !== "merge" && decision.action !== "update") || + typeof decision.merged_priority !== "number" + ) { + return decision; + } + + const match = matchByRecordId.get(decision.record_id); + if (!match) return decision; + + const targetIds = new Set(decision.target_ids); + const sourcePriorities = [ + match.newMemory.priority, + ...match.candidates + .filter((candidate) => targetIds.has(candidate.id)) + .map((candidate) => candidate.priority), + ].filter((priority) => Number.isFinite(priority)); + + const maxSourcePriority = Math.max(...sourcePriorities); + if (!Number.isFinite(maxSourcePriority) || decision.merged_priority <= maxSourcePriority) { + return decision; + } + + logger?.debug?.( + `${TAG} Capping merged priority for ${decision.record_id}: ` + + `${decision.merged_priority} → ${maxSourcePriority}`, + ); + + return { + ...decision, + merged_priority: maxSourcePriority, + }; + }); +} + /** * Fallback: store all memories when parsing fails. */