diff --git a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/MemorySelector.java b/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/MemorySelector.java index 70f06c05..a6b8756d 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/MemorySelector.java +++ b/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/MemorySelector.java @@ -14,11 +14,10 @@ import work.slhaf.partner.framework.agent.interaction.flow.RunningFlowContext; import work.slhaf.partner.framework.agent.model.pojo.Message; import work.slhaf.partner.module.memory.runtime.MemoryRuntime; import work.slhaf.partner.module.memory.runtime.exception.MemoryLookupException; -import work.slhaf.partner.module.memory.selector.evaluator.SliceSelectEvaluator; +import work.slhaf.partner.module.memory.selector.evaluator.MemoryRecallEvaluator; import work.slhaf.partner.module.memory.selector.evaluator.entity.EvaluatorInput; -import work.slhaf.partner.module.memory.selector.extractor.MemorySelectExtractor; +import work.slhaf.partner.module.memory.selector.extractor.MemoryRecallCueExtractor; import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorInput; -import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorMatchData; import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorResult; import work.slhaf.partner.runtime.PartnerRunningFlowContext; @@ -36,9 +35,9 @@ public class MemorySelector extends AbstractAgentModule.Running(candidates.values())) .build(); - return sliceSelectEvaluator.execute(evaluatorInput); + return memoryRecallEvaluator.execute(evaluatorInput); } - private void setMemoryCandidates(LinkedHashMap candidates, - List matches) { - for (ExtractorMatchData match : matches) { + private void setMemoryCandidates(LinkedHashMap candidates, List matches) { + for (ExtractorResult.ExtractorMatchData match : matches) { try { List recalledSlices = switch (match.getType()) { - case ExtractorMatchData.Constant.TOPIC -> + case ExtractorResult.ExtractorMatchData.Constant.TOPIC -> memoryRuntime.queryActivatedMemoryByTopicPath(match.getText()); - case ExtractorMatchData.Constant.DATE -> + case ExtractorResult.ExtractorMatchData.Constant.DATE -> memoryRuntime.queryActivatedMemoryByDate(LocalDate.parse(match.getText())); default -> List.of(); }; diff --git a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/evaluator/SliceSelectEvaluator.java b/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/evaluator/MemoryRecallEvaluator.java similarity index 51% rename from Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/evaluator/SliceSelectEvaluator.java rename to Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/evaluator/MemoryRecallEvaluator.java index 29ebd4d4..d26cad93 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/evaluator/SliceSelectEvaluator.java +++ b/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/evaluator/MemoryRecallEvaluator.java @@ -25,7 +25,74 @@ import java.util.Locale; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; -public class SliceSelectEvaluator extends AbstractAgentModule.Sub> implements ActivateModel { +public class MemoryRecallEvaluator extends AbstractAgentModule.Sub> implements ActivateModel { + + private static final String MODULE_PROMPT = """ + 你负责在记忆切片召回流程中,对单个候选 memory slice 做保留评估。你的任务不是重新提取检索线索,也不是在多个切片之间排序,而是基于当前新输入、近期 communication 语境,以及当前这个 memory slice 本身,判断:这段切片是否值得保留进入后续记忆上下文。 + + 你会收到: + - 一条结构化上下文消息,其中包含当前活跃的 communication 域内容; + - 一条任务消息,其中包含: + - new_inputs:一组按时间顺序累积的新输入,每条输入附带 interval-to-first; + - memory_slice:当前正在评估的单个候选切片,其中包括: + - slice_summary:该切片的摘要; + - source_messages:该切片对应的部分原始消息。若消息较多,只会展示前两条、中间四条、末尾两条,并在中间插入省略说明。 + + 你的任务: + - 判断当前这个 memory slice 是否与本轮输入明显相关,并且值得保留; + - 如果值得保留,则返回 passed=true; + - 如果不值得保留,则返回 passed=false。 + + 评估目标: + - 这里的“值得保留”不是指“勉强有点关系”,而是指:这段切片很可能对理解当前输入、承接当前话题、补充当前回指对象、或支持接下来的回应有实际帮助。 + - 你评估的是“当前这个切片自身是否值得保留”,而不是“是否存在某个相关记忆主题”。 + - 不要把切片所属主题近期很活跃,误当成它当前就应被保留。 + + 核心判断原则: + - new_inputs 应整体理解,不要只看最后一句;如果多条输入共同收敛到某个具体关注点,应按整体意图判断。 + - communication 域用于辅助理解当前输入是否在承接、回指或延续近期对话中的某个对象、某段讨论、某个比较目标。 + - memory_slice 是当前唯一需要评估的对象;判断重点在于它是否真正贴合当前输入,而不是它是否看起来“总体上像是相关的”。 + - slice_summary 与 source_messages 应结合起来看;如果 summary 看似相关,但 source_messages 显示其实际讨论重点并不一致,则不应仅凭 summary 通过。 + - source_messages 可能被截断展示,因此你可以基于已展示内容做审慎判断;若现有内容已足以看出明显不贴合,则应直接拒绝,不要自行脑补缺失内容。 + + 何时应通过: + - 当前输入明显在追问、延续、回指、比较、复盘这段切片所对应的那段讨论; + - 当前输入中的代词、简称、模糊表达,结合 communication 与该切片内容后,可以较明确地对应到这段切片; + - 当前输入虽然没有复述切片内容,但它正在继续推进这段切片中的核心议题; + - 当前输入需要补足的背景、上下文或前情,正是该切片能够提供的; + - 该切片与当前输入在关注点上是同一条线,而不只是共享一些表层关键词。 + + 何时不应通过: + - 该切片只是在宽泛主题上相关,但与当前输入的具体关注点并不一致; + - 当前输入谈的是某条主题中的一个更具体方向,而该切片实际对应的是同主题下的另一支内容; + - 该切片只是“最近讨论过”“当前活跃过”,但当前输入并没有明显指向它; + - 该切片与当前输入只有表层关键词重合,缺乏稳定的语义承接; + - 只能看出“可能有一点关系”,但不足以认为它对当前轮次真的有帮助; + - 当前输入是在排除、收窄或转离该切片所对应的方向; + - 仅凭切片摘要中的高层概括词、抽象标签、热门主题,就武断通过; + - 仅因为 communication 中存在宽泛相关话题,就把当前切片也一起保留。 + + 关于评估尺度: + - 通过标准应偏保守,宁可少保留,也不要把低质量、弱相关、误方向的切片混入后续上下文。 + - “属于同一个大主题”不等于“值得保留”。 + - “曾经讨论过类似内容”不等于“当前就在指向这段切片”。 + - “能勉强联想到”不等于“应通过”。 + - 只有当你能够比较稳定地判断:这段切片对当前输入确实有帮助时,才应通过。 + + 你不应做的事: + - 不要重新选择别的切片; + - 不要把别的可能更相关的记忆当成当前切片通过的理由; + - 不要扩展用户话题; + - 不要回答用户问题; + - 不要总结整个对话; + - 不要输出解释、理由、附加字段或额外文本; + - 不要因为不确定就倾向通过。 + + 输出要求: + - 严格按照 EvaluatorBatchResult 对应结构输出; + - 结果中只表达当前这个 memory slice 是否通过; + - 不要输出除结构要求之外的任何内容。 + """; @InjectCapability private ActionCapability actionCapability; @@ -45,16 +112,12 @@ public class SliceSelectEvaluator extends AbstractAgentModule.Sub result = new ArrayList<>(); CountDownLatch latch = new CountDownLatch(preparedSlices.size()); - Message contextMessage = cognitionCapability.contextWorkspace().resolve(List.of( - ContextBlock.FocusedDomain.MEMORY - )).encodeToMessage(); - for (ActivatedMemorySlice slice : preparedSlices) { executor.execute(() -> { try { EvaluatorBatchInput batchInput = new EvaluatorBatchInput(evaluatorInput.getInputs(), slice); List messages = List.of( - contextMessage, + resolveContextMessage(), resolveTaskMessage(batchInput) ); formattedChat(messages, EvaluatorBatchResult.class) @@ -74,11 +137,18 @@ public class SliceSelectEvaluator extends AbstractAgentModule.Sub modulePrompt() { + return List.of(new Message(Message.Character.SYSTEM, MODULE_PROMPT)); + } + @Override @NotNull public String modelKey() { diff --git a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/MemoryRecallCueExtractor.java b/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/MemoryRecallCueExtractor.java new file mode 100644 index 00000000..d572553c --- /dev/null +++ b/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/MemoryRecallCueExtractor.java @@ -0,0 +1,182 @@ +package work.slhaf.partner.module.memory.selector.extractor; + +import kotlin.Unit; +import org.jetbrains.annotations.NotNull; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import work.slhaf.partner.core.cognition.CognitionCapability; +import work.slhaf.partner.core.cognition.ContextBlock; +import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability; +import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule; +import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule; +import work.slhaf.partner.framework.agent.model.ActivateModel; +import work.slhaf.partner.framework.agent.model.pojo.Message; +import work.slhaf.partner.framework.agent.support.Result; +import work.slhaf.partner.module.TaskBlock; +import work.slhaf.partner.module.memory.runtime.MemoryRuntime; +import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorInput; +import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorResult; + +import java.time.format.DateTimeFormatter; +import java.util.List; + +public class MemoryRecallCueExtractor extends AbstractAgentModule.Sub implements ActivateModel { + + private static final String MODULE_PROMPT = """ + 你负责在记忆召回前,根据当前新输入与现有语境,提取本次值得检索的记忆线索。你的任务不是直接召回记忆内容,也不是总结对话,而是从当前输入中识别出:接下来应优先尝试检索哪些记忆主题路径,或是否应按具体日期检索记忆。 + + 你会收到: + - 一条结构化上下文消息,其中包含当前活跃的 communication 域与 memory 域内容; + - 一条任务消息,其中包含: + - new_inputs:一组按时间顺序累积的新输入,每条输入附带 interval-to-first; + - current_date:当前日期; + - memory_topic_tree:当前可用的记忆主题树结构。 + + 你的任务: + - 基于 new_inputs、当前语境与已有记忆主题树,提取本次记忆召回最值得尝试的匹配项; + - 匹配项只允许有两类:topic 或 date; + - topic 用于表示应优先检索的记忆主题路径; + - date 用于表示应优先检索的具体日期; + - 若当前输入不足以支持稳定的记忆检索线索,则返回空列表。 + + 提取原则: + - 你的目标是提取“可用于后续召回”的线索,而不是复述输入内容本身。 + - new_inputs 应整体理解,不要只抓最后一句;如果多条输入共同收敛到同一记忆方向,应提取更稳定的主题线索。 + - communication 域用于判断当前输入是否在承接近期某段对话、某个旧话题或某个已出现过的指代对象。 + - memory 域用于辅助判断当前输入与哪些已激活记忆方向明显相关;只有在这种相关性明确时才使用,不要机械复述 memory 域内容。 + - memory_topic_tree 是 topic 提取的主要参照;topic 应尽量贴近主题树中已有的层级与命名,不要随意发明与主题树无关的新路径。 + - 如果输入只能支持较上层的主题方向,则输出较短路径;不要为了显得具体而伪造下层节点。 + - 如果输入同时指向多个可能的记忆方向,只保留最有召回价值、最稳定的少量结果,不要泛化扩散。 + + 关于 topic: + - topic 表示一个记忆主题路径。 + - topic 的 text 必须是纯路径文本,使用 `->` 连接层级,例如 A->B->C。 + - topic 应尽量对齐 memory_topic_tree 中已有的节点表达、层级关系与命名习惯。 + - topic 应体现“当前输入最可能在回指、延续或需要补充回忆的主题”。 + - 不要输出过于空泛、没有检索价值的 topic,例如“聊天”“问题”“内容”“事情”这类抽象词。 + - 不要在 topic 中附加解释、括号说明、标签注释或额外文字。 + + 关于 date: + - date 表示一个明确的记忆日期。 + - date 的 text 必须是可被 Java LocalDate.parse 正常解析的日期文本,即 yyyy-MM-dd。 + - 只有在输入中存在明确日期,或结合 current_date 后可以稳定推断出具体某一天时,才输出 date。 + - 像“今天”“昨天”“前天”“上周六”这类表达,只有在能够稳定落到某个具体日期时才可输出。 + - 对于“最近”“前几天”“那段时间”“之前”“上次”这类无法稳定定位到某一天的表达,不要输出 date。 + + 何时应提取 topic: + - 当前输入明显在延续某个已出现过的话题; + - 当前输入在追问、回指、比较、复盘某个过去讨论过的方向; + - 当前输入虽然表面简短,但结合 communication 或 memory 上下文后,可以明确看出其所指主题; + - 当前输入本身就在讨论一个具有长期记忆意义、适合按主题检索的内容。 + + 何时应提取 date: + - 当前输入明确提到了某个具体日期; + - 当前输入使用相对日期表达,但结合 current_date 可以稳定还原到具体某一天; + - 当前输入中的回忆目标明显依赖某个特定日期,且该日期能够被明确确定。 + + 何时不应轻易输出: + - 当前输入只是即时情绪、临时感叹或泛泛回应,没有形成稳定的记忆检索方向; + - 只能看出模糊相关性,无法判断应检索哪个主题; + - 只能看出模糊时间范围,无法稳定确定到某一天; + - 仅凭 memory 域里恰好出现过某个内容,但当前输入并没有明显指向它; + - 仅凭表层关键词联想出某条路径,但缺乏足够语境支持。 + + 关于 matches 列表: + - 每个 match 只能是 topic 或 date 两种类型之一。 + - 可以同时输出 topic 与 date;如果两者都对当前记忆召回明显有帮助,则都可以保留。 + - 不要输出语义重复或明显冗余的 match。 + - 如果已经能够稳定定位到较具体的 topic,通常不要再同时输出它的宽泛父路径,除非父路径本身也具有独立检索价值。 + - 若没有足够明确、足够稳定的匹配项,返回空的 matches 列表。 + + 其他约束: + - 你不是在生成记忆内容,只是在提取检索线索。 + - 不要回答用户问题,不要总结输入,不要解释推理过程。 + - 不要输出除 topic / date 之外的类型。 + - 不要编造上下文中不存在的主题、事实或日期。 + - 不要为了凑结果而输出低质量匹配项。 + + 输出要求: + - 严格按照 ExtractorResult 对应结构输出。 + - matches 中每一项都必须只包含 type 与 text。 + - type 只能是 "topic" 或 "date"。 + - topic 的 text 必须是 `A->B->C` 形式的纯路径文本。 + - date 的 text 必须是 yyyy-MM-dd 形式的日期文本。 + """; + + @InjectCapability + private CognitionCapability cognitionCapability; + @InjectModule + private MemoryRuntime memoryRuntime; + + @Override + protected ExtractorResult doExecute(ExtractorInput input) { + ExtractorResult extractorResult; + List messages = List.of( + resolveContextMessage(), + resolveTaskMessage(input) + ); + Result result = formattedChat( + messages, + ExtractorResult.class + ); + extractorResult = result.fold( + value -> value, + exception -> { + ExtractorResult fallback = new ExtractorResult(); + fallback.setMatches(List.of()); + return fallback; + } + ); + return fix(extractorResult); + } + + private Message resolveTaskMessage(ExtractorInput input) { + return new TaskBlock() { + @Override + protected void fillXml(@NotNull Document document, @NotNull Element root) { + appendChildElement(document, root, "new_inputs", (inputsElement) -> { + appendListElement(document, inputsElement, "inputs", "input", input.getInputs(), (inputElement, entry) -> { + inputElement.setAttribute("interval-to-first", String.valueOf(entry.getOffsetMillis())); + inputElement.setTextContent(entry.getContent()); + return Unit.INSTANCE; + }); + return Unit.INSTANCE; + }); + appendTextElement(document, root, "current_date", input.getDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + appendTextElement(document, root, "memory_topic_tree", input.getTopic_tree()); + } + }.encodeToMessage(); + } + + private Message resolveContextMessage() { + return cognitionCapability.contextWorkspace().resolve(List.of( + ContextBlock.FocusedDomain.COMMUNICATION, ContextBlock.FocusedDomain.MEMORY + )).encodeToMessage(); + } + + private ExtractorResult fix(ExtractorResult extractorResult) { + extractorResult.getMatches().forEach(m -> { + if (m.getType().equals(ExtractorResult.ExtractorMatchData.Constant.DATE)) { + return; + } + m.setText(memoryRuntime.fixTopicPath(m.getText())); + }); + if (extractorResult.getMatches().isEmpty()) { + return extractorResult; + } + extractorResult.getMatches().removeIf(m -> m.getText().split("->")[0].isEmpty()); + return extractorResult; + } + + @Override + @NotNull + public List modulePrompt() { + return List.of(new Message(Message.Character.SYSTEM, MODULE_PROMPT)); + } + + @NotNull + @Override + public String modelKey() { + return "topic_extractor"; + } +} diff --git a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/MemorySelectExtractor.java b/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/MemorySelectExtractor.java deleted file mode 100644 index 00431c0b..00000000 --- a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/MemorySelectExtractor.java +++ /dev/null @@ -1,94 +0,0 @@ -package work.slhaf.partner.module.memory.selector.extractor; - -import kotlin.Unit; -import org.jetbrains.annotations.NotNull; -import org.w3c.dom.Document; -import org.w3c.dom.Element; -import work.slhaf.partner.core.cognition.CognitionCapability; -import work.slhaf.partner.core.cognition.ContextBlock; -import work.slhaf.partner.framework.agent.factory.capability.annotation.InjectCapability; -import work.slhaf.partner.framework.agent.factory.component.abstracts.AbstractAgentModule; -import work.slhaf.partner.framework.agent.factory.component.annotation.InjectModule; -import work.slhaf.partner.framework.agent.model.ActivateModel; -import work.slhaf.partner.framework.agent.model.pojo.Message; -import work.slhaf.partner.framework.agent.support.Result; -import work.slhaf.partner.module.TaskBlock; -import work.slhaf.partner.module.memory.runtime.MemoryRuntime; -import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorInput; -import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorMatchData; -import work.slhaf.partner.module.memory.selector.extractor.entity.ExtractorResult; - -import java.time.format.DateTimeFormatter; -import java.util.List; - -public class MemorySelectExtractor extends AbstractAgentModule.Sub implements ActivateModel { - @InjectCapability - private CognitionCapability cognitionCapability; - @InjectModule - private MemoryRuntime memoryRuntime; - - @Override - protected ExtractorResult doExecute(ExtractorInput input) { - ExtractorResult extractorResult; - List messages = List.of( - resolveContextMessage(), - resolveTaskMessage(input) - ); - Result result = formattedChat( - messages, - ExtractorResult.class - ); - extractorResult = result.fold( - value -> value, - exception -> { - ExtractorResult fallback = new ExtractorResult(); - fallback.setMatches(List.of()); - return fallback; - } - ); - return fix(extractorResult); - } - - private Message resolveTaskMessage(ExtractorInput input) { - return new TaskBlock() { - @Override - protected void fillXml(@NotNull Document document, @NotNull Element root) { - appendChildElement(document, root, "new_inputs", (inputsElement) -> { - appendListElement(document, inputsElement, "inputs", "input", input.getInputs(), (inputElement, entry) -> { - inputElement.setAttribute("interval-to-first", String.valueOf(entry.getOffsetMillis())); - inputElement.setTextContent(entry.getContent()); - return Unit.INSTANCE; - }); - return Unit.INSTANCE; - }); - appendTextElement(document, root, "current_date", input.getDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); - appendTextElement(document, root, "memory_topic_tree", input.getTopic_tree()); - } - }.encodeToMessage(); - } - - private Message resolveContextMessage() { - return cognitionCapability.contextWorkspace().resolve(List.of( - ContextBlock.FocusedDomain.COMMUNICATION, ContextBlock.FocusedDomain.MEMORY - )).encodeToMessage(); - } - - private ExtractorResult fix(ExtractorResult extractorResult) { - extractorResult.getMatches().forEach(m -> { - if (m.getType().equals(ExtractorMatchData.Constant.DATE)) { - return; - } - m.setText(memoryRuntime.fixTopicPath(m.getText())); - }); - if (extractorResult.getMatches().isEmpty()) { - return extractorResult; - } - extractorResult.getMatches().removeIf(m -> m.getText().split("->")[0].isEmpty()); - return extractorResult; - } - - @Override - public String modelKey() { - return "topic_extractor"; - } -} diff --git a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/entity/ExtractorMatchData.java b/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/entity/ExtractorMatchData.java deleted file mode 100644 index 12f64aac..00000000 --- a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/entity/ExtractorMatchData.java +++ /dev/null @@ -1,14 +0,0 @@ -package work.slhaf.partner.module.memory.selector.extractor.entity; - -import lombok.Data; - -@Data -public class ExtractorMatchData { - private String type; - private String text; - - public static class Constant { - public static final String DATE = "date"; - public static final String TOPIC = "topic"; - } -} diff --git a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/entity/ExtractorResult.java b/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/entity/ExtractorResult.java index 24fd11fb..a7e1eabd 100644 --- a/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/entity/ExtractorResult.java +++ b/Partner-Core/src/main/java/work/slhaf/partner/module/memory/selector/extractor/entity/ExtractorResult.java @@ -7,4 +7,15 @@ import java.util.List; @Data public class ExtractorResult { private List matches; + + @Data + public static class ExtractorMatchData { + private String type; + private String text; + + public static class Constant { + public static final String DATE = "date"; + public static final String TOPIC = "topic"; + } + } }