From d5f1d19306da6d0f3683ad51545d96b2c9475a85 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 6 Apr 2026 23:35:27 +0800 Subject: [PATCH] feat(memory): add active retrieval tracking, multi-gate filtering, DREAMS.md diary and dreaming status API --- .../vip/mate/memory/MemoryProperties.java | 9 +++ .../memory/controller/MemoryController.java | 40 ++++++++++ .../memory/scheduler/DreamingScheduler.java | 7 ++ .../service/MemoryEmergenceService.java | 58 ++++++++++++++ .../memory/service/MemoryRecallService.java | 78 ++++++++++++++++++- .../memory/service/MemoryRecallTracker.java | 18 +++++ .../tool/builtin/WorkspaceMemoryTool.java | 8 +- 7 files changed, 216 insertions(+), 2 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java b/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java index 2f697c91..08de9752 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/MemoryProperties.java @@ -49,4 +49,13 @@ public class MemoryProperties { /** 记忆召回评分阈值,低于此分的候选不进入 LLM 整合 */ private double emergenceScoreThreshold = 0.4; + + /** 最少召回次数门控(低于此值直接跳过评分) */ + private int emergenceMinRecallCount = 3; + + /** 最少不同查询数门控(低于此值直接跳过评分) */ + private int emergenceMinUniqueQueries = 2; + + /** 候选最大年龄(天),超过此值不参与评分。0=不限 */ + private int emergenceMaxAgeDays = 30; } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java b/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java index 61fbb730..b95ebd03 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/controller/MemoryController.java @@ -7,8 +7,14 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.memory.service.MemoryEmergenceService; +import vip.mate.memory.service.MemoryRecallService; import vip.mate.memory.service.MemorySummarizationService; +import vip.mate.memory.scheduler.DreamingScheduler; +import vip.mate.workspace.document.WorkspaceFileService; +import vip.mate.workspace.document.model.WorkspaceFileEntity; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -27,6 +33,9 @@ public class MemoryController { private final MemoryEmergenceService emergenceService; private final MemorySummarizationService summarizationService; + private final MemoryRecallService recallService; + private final DreamingScheduler dreamingScheduler; + private final WorkspaceFileService workspaceFileService; @Operation(summary = "手动触发记忆整合(daily notes → MEMORY.md)") @PostMapping("/{agentId}/emergence") @@ -54,4 +63,35 @@ public class MemoryController { return R.fail("记忆提取失败: " + e.getMessage()); } } + + // ==================== Dreaming 状态 API ==================== + + @Operation(summary = "查询 Dreaming 状态(配置、统计、上次运行时间)") + @GetMapping("/{agentId}/dreaming/status") + public R> getDreamingStatus(@PathVariable Long agentId) { + Map status = recallService.getDreamingStatus(agentId); + status.put("lastRunTime", dreamingScheduler.getLastRunTime()); + return R.ok(status); + } + + @Operation(summary = "查询召回候选列表(含评分详情)") + @GetMapping("/{agentId}/dreaming/candidates") + public R>> getDreamingCandidates(@PathVariable Long agentId) { + return R.ok(recallService.listCandidatesWithDetails(agentId)); + } + + @Operation(summary = "查询 DREAMS.md 整合日记") + @GetMapping("/{agentId}/dreaming/dreams") + public R> getDreams(@PathVariable Long agentId) { + WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "DREAMS.md"); + Map result = new LinkedHashMap<>(); + if (file != null && file.getContent() != null) { + result.put("content", file.getContent()); + result.put("updateTime", file.getUpdateTime()); + } else { + result.put("content", null); + result.put("message", "尚未生成 DREAMS.md(需先运行一次 emergence)"); + } + return R.ok(result); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java index 35c311c2..c10ba4f8 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/scheduler/DreamingScheduler.java @@ -1,5 +1,6 @@ package vip.mate.memory.scheduler; +import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -9,6 +10,7 @@ import vip.mate.agent.model.AgentEntity; import vip.mate.memory.MemoryProperties; import vip.mate.memory.service.MemoryEmergenceService; +import java.time.LocalDateTime; import java.util.List; /** @@ -28,6 +30,10 @@ public class DreamingScheduler { private final MemoryEmergenceService emergenceService; private final MemoryProperties properties; + /** 上次 dreaming 执行时间(供状态 API 读取) */ + @Getter + private volatile LocalDateTime lastRunTime; + @Scheduled(cron = "${mate.memory.dreaming-cron:0 0 3 * * ?}") public void runDreaming() { if (!properties.isDreamingEnabled()) { @@ -55,6 +61,7 @@ public class DreamingScheduler { } } + lastRunTime = LocalDateTime.now(); log.info("[Dreaming] Cycle completed: {} succeeded, {} failed", success, failed); } } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java index dc35f3ad..6abaae4c 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryEmergenceService.java @@ -158,6 +158,9 @@ public class MemoryEmergenceService { } log.info("[Memory] Promoted {}/{} recall candidates for agent={}", promotedIds.size(), scoredCandidates.size(), agentId); + + // 写入 DREAMS.md 整合日记 + appendDreamDiary(agentId, scoredCandidates, promotedIds); } } } @@ -166,6 +169,61 @@ public class MemoryEmergenceService { } } + /** + * 将本轮 dreaming 结果追加到 DREAMS.md 整合日记 + */ + private void appendDreamDiary(Long agentId, List allCandidates, List promotedIds) { + try { + java.util.Set promotedSet = new java.util.HashSet<>(promotedIds); + + List promoted = allCandidates.stream() + .filter(c -> promotedSet.contains(c.getId())) + .sorted(java.util.Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) + .toList(); + List kept = allCandidates.stream() + .filter(c -> !promotedSet.contains(c.getId())) + .sorted(java.util.Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed()) + .toList(); + + String timestamp = java.time.LocalDateTime.now() + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); + + StringBuilder diary = new StringBuilder(); + diary.append("## ").append(timestamp).append(" Dreaming\n\n"); + diary.append(String.format("**评分候选**: %d 条(阈值 %.1f)\n", + allCandidates.size(), properties.getEmergenceScoreThreshold())); + diary.append(String.format("**实际整合**: %d 条\n\n", promoted.size())); + + if (!promoted.isEmpty()) { + diary.append("### 已整合\n"); + for (MemoryRecallEntity c : promoted) { + diary.append(String.format("- `%s` (score=%.2f, recalls=%d)\n", + c.getFilename(), c.getScore(), c.getRecallCount())); + } + diary.append("\n"); + } + + if (!kept.isEmpty()) { + diary.append("### 未整合(保留下轮)\n"); + for (MemoryRecallEntity c : kept) { + diary.append(String.format("- `%s` (score=%.2f, recalls=%d)\n", + c.getFilename(), c.getScore(), c.getRecallCount())); + } + diary.append("\n"); + } + + // 读取现有 DREAMS.md,追加到开头(最新在最上面) + String existing = readFileContentSafe(agentId, "DREAMS.md"); + String newContent = existing.isBlank() + ? "# Dreaming 整合日记\n\n" + diary + : existing + "\n" + diary; + workspaceFileService.saveFile(agentId, "DREAMS.md", newContent); + log.info("[Memory] Dream diary appended for agent={}", agentId); + } catch (Exception e) { + log.warn("[Memory] Failed to write dream diary for agent={}: {}", agentId, e.getMessage()); + } + } + private String formatScoredCandidates(List candidates) { StringBuilder sb = new StringBuilder(); for (MemoryRecallEntity entry : candidates) { diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java index 047a9a6b..4a627737 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallService.java @@ -131,6 +131,30 @@ public class MemoryRecallService { return Collections.emptyList(); } + LocalDateTime now = LocalDateTime.now(); + + // 前置硬门控:不满足的直接跳过评分 + int minRecallCount = properties.getEmergenceMinRecallCount(); + int minUniqueQueries = properties.getEmergenceMinUniqueQueries(); + int maxAgeDays = properties.getEmergenceMaxAgeDays(); + + candidates = candidates.stream().filter(e -> { + // 门控 1:最少召回次数 + if (e.getRecallCount() < minRecallCount) return false; + // 门控 2:最少不同查询数 + if (parseQueryHashes(e.getQueryHashes()).size() < minUniqueQueries) return false; + // 门控 3:最大年龄 + if (maxAgeDays > 0 && e.getCreateTime() != null) { + long ageDays = ChronoUnit.DAYS.between(e.getCreateTime(), now); + if (ageDays > maxAgeDays) return false; + } + return true; + }).collect(Collectors.toCollection(ArrayList::new)); + + if (candidates.isEmpty()) { + return Collections.emptyList(); + } + // 归一化参数 int maxRecallCount = candidates.stream() .mapToInt(MemoryRecallEntity::getRecallCount) @@ -139,7 +163,6 @@ public class MemoryRecallService { .mapToInt(e -> parseQueryHashes(e.getQueryHashes()).size()) .max().orElse(1); - LocalDateTime now = LocalDateTime.now(); double halfLifeDays = 7.0; double threshold = properties.getEmergenceScoreThreshold(); @@ -193,6 +216,59 @@ public class MemoryRecallService { .set(MemoryRecallEntity::getPromoted, true)); } + // ==================== 查询方法(供 API 使用) ==================== + + /** + * 获取 Agent 的 dreaming 统计摘要 + */ + public Map getDreamingStatus(Long agentId) { + long total = recallMapper.selectCount( + new LambdaQueryWrapper() + .eq(MemoryRecallEntity::getAgentId, agentId) + .eq(MemoryRecallEntity::getDeleted, 0)); + long promoted = recallMapper.selectCount( + new LambdaQueryWrapper() + .eq(MemoryRecallEntity::getAgentId, agentId) + .eq(MemoryRecallEntity::getPromoted, true) + .eq(MemoryRecallEntity::getDeleted, 0)); + long pending = total - promoted; + + Map status = new LinkedHashMap<>(); + status.put("dreamingEnabled", properties.isDreamingEnabled()); + status.put("dreamingCron", properties.getDreamingCron()); + status.put("scoreThreshold", properties.getEmergenceScoreThreshold()); + status.put("minRecallCount", properties.getEmergenceMinRecallCount()); + status.put("minUniqueQueries", properties.getEmergenceMinUniqueQueries()); + status.put("totalRecallEntries", total); + status.put("promotedCount", promoted); + status.put("pendingCandidates", pending); + return status; + } + + /** + * 获取带详情的候选列表(供 API 使用) + */ + public List> listCandidatesWithDetails(Long agentId) { + List candidates = recallMapper.selectList( + new LambdaQueryWrapper() + .eq(MemoryRecallEntity::getAgentId, agentId) + .eq(MemoryRecallEntity::getDeleted, 0) + .orderByDesc(MemoryRecallEntity::getScore)); + + return candidates.stream().map(c -> { + Map item = new LinkedHashMap<>(); + item.put("filename", c.getFilename()); + item.put("score", c.getScore()); + item.put("recallCount", c.getRecallCount()); + item.put("dailyCount", c.getDailyCount()); + item.put("queryCount", parseQueryHashes(c.getQueryHashes()).size()); + item.put("promoted", c.getPromoted()); + item.put("lastRecalledAt", c.getLastRecalledAt()); + item.put("snippetPreview", c.getSnippetPreview()); + return item; + }).collect(Collectors.toList()); + } + // ==================== 内部工具方法 ==================== private double computeFreshness(String filename, LocalDateTime now) { diff --git a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java index 3ba00062..0eaae68a 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/service/MemoryRecallTracker.java @@ -134,6 +134,24 @@ public class MemoryRecallTracker { .replaceAll("^-|-$", ""); } + /** + * 追踪 Agent 通过 WorkspaceMemoryTool 主动读取文件的信号。 + * 这是比被动注入更强的"真实需要"指标。 + * 使用固定 queryHash 以区分主动检索和被动注入。 + */ + @Async + public void trackActiveRetrieval(Long agentId, String filename, String content) { + try { + if (agentId == null || filename == null || content == null || content.isBlank()) { + return; + } + recallService.recordRecall(agentId, filename, content, "__active_read__"); + log.debug("[MemoryRecall] Tracked active retrieval: agent={}, file={}", agentId, filename); + } catch (Exception e) { + log.warn("[MemoryRecall] Failed to track active retrieval for agent={}: {}", agentId, e.getMessage()); + } + } + private String sha256Short(String text) { if (text == null || text.isBlank()) return null; try { diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java index 01ef2dd4..34b18559 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/WorkspaceMemoryTool.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; +import vip.mate.memory.service.MemoryRecallTracker; import vip.mate.workspace.document.WorkspaceFileService; import vip.mate.workspace.document.model.WorkspaceFileEntity; @@ -26,6 +27,7 @@ import java.util.List; public class WorkspaceMemoryTool { private final WorkspaceFileService workspaceFileService; + private final MemoryRecallTracker memoryRecallTracker; @Tool(description = """ 列出指定 Agent 的数据库工作区记忆文件。 @@ -84,12 +86,16 @@ public class WorkspaceMemoryTool { return error("工作区文件不存在: " + filename); } + // 追踪主动检索信号(比被动注入更强的"真实需要"指标) + String content = file.getContent() != null ? file.getContent() : ""; + memoryRecallTracker.trackActiveRetrieval(agentId, filename, content); + JSONObject result = new JSONObject(); result.set("agentId", agentId); result.set("filename", file.getFilename()); result.set("enabled", Boolean.TRUE.equals(file.getEnabled())); result.set("fileSize", file.getFileSize()); - result.set("content", file.getContent() != null ? file.getContent() : ""); + result.set("content", content); result.set("updateTime", file.getUpdateTime() != null ? file.getUpdateTime().toString() : null); return JSONUtil.toJsonPrettyStr(result); }