feat(memory): add active retrieval tracking, multi-gate filtering, DREAMS.md diary and dreaming status API

This commit is contained in:
matevip 2026-04-06 23:35:27 +08:00
parent 015abcef96
commit d5f1d19306
7 changed files with 216 additions and 2 deletions

View File

@ -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;
}

View File

@ -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<Map<String, Object>> getDreamingStatus(@PathVariable Long agentId) {
Map<String, Object> status = recallService.getDreamingStatus(agentId);
status.put("lastRunTime", dreamingScheduler.getLastRunTime());
return R.ok(status);
}
@Operation(summary = "查询召回候选列表(含评分详情)")
@GetMapping("/{agentId}/dreaming/candidates")
public R<List<Map<String, Object>>> getDreamingCandidates(@PathVariable Long agentId) {
return R.ok(recallService.listCandidatesWithDetails(agentId));
}
@Operation(summary = "查询 DREAMS.md 整合日记")
@GetMapping("/{agentId}/dreaming/dreams")
public R<Map<String, Object>> getDreams(@PathVariable Long agentId) {
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, "DREAMS.md");
Map<String, Object> 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);
}
}

View File

@ -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);
}
}

View File

@ -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<MemoryRecallEntity> allCandidates, List<Long> promotedIds) {
try {
java.util.Set<Long> promotedSet = new java.util.HashSet<>(promotedIds);
List<MemoryRecallEntity> promoted = allCandidates.stream()
.filter(c -> promotedSet.contains(c.getId()))
.sorted(java.util.Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed())
.toList();
List<MemoryRecallEntity> 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<MemoryRecallEntity> candidates) {
StringBuilder sb = new StringBuilder();
for (MemoryRecallEntity entry : candidates) {

View File

@ -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<String, Object> getDreamingStatus(Long agentId) {
long total = recallMapper.selectCount(
new LambdaQueryWrapper<MemoryRecallEntity>()
.eq(MemoryRecallEntity::getAgentId, agentId)
.eq(MemoryRecallEntity::getDeleted, 0));
long promoted = recallMapper.selectCount(
new LambdaQueryWrapper<MemoryRecallEntity>()
.eq(MemoryRecallEntity::getAgentId, agentId)
.eq(MemoryRecallEntity::getPromoted, true)
.eq(MemoryRecallEntity::getDeleted, 0));
long pending = total - promoted;
Map<String, Object> 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<Map<String, Object>> listCandidatesWithDetails(Long agentId) {
List<MemoryRecallEntity> candidates = recallMapper.selectList(
new LambdaQueryWrapper<MemoryRecallEntity>()
.eq(MemoryRecallEntity::getAgentId, agentId)
.eq(MemoryRecallEntity::getDeleted, 0)
.orderByDesc(MemoryRecallEntity::getScore));
return candidates.stream().map(c -> {
Map<String, Object> 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) {

View File

@ -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 {

View File

@ -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);
}