mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(memory): add active retrieval tracking, multi-gate filtering, DREAMS.md diary and dreaming status API
This commit is contained in:
parent
015abcef96
commit
d5f1d19306
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user