mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(memory): add Dreaming recall tracking and scored emergence
This commit is contained in:
parent
dd5ec7ff3c
commit
015abcef96
@ -11,6 +11,7 @@ import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.repository.AgentMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.event.ModelConfigChangedEvent;
|
||||
import vip.mate.memory.service.MemoryRecallTracker;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -31,6 +32,7 @@ public class AgentService {
|
||||
|
||||
private final AgentMapper agentMapper;
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final MemoryRecallTracker memoryRecallTracker;
|
||||
|
||||
/** 运行时 Agent 实例缓存(agentId -> BaseAgent) */
|
||||
private final Map<Long, BaseAgent> agentInstances = new ConcurrentHashMap<>();
|
||||
@ -73,11 +75,13 @@ public class AgentService {
|
||||
// ==================== 运行时入口 ====================
|
||||
|
||||
public String chat(Long agentId, String message, String conversationId) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chat(message, conversationId);
|
||||
}
|
||||
|
||||
public Flux<String> chatStream(Long agentId, String message, String conversationId) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chatStream(message, conversationId);
|
||||
}
|
||||
@ -88,6 +92,7 @@ public class AgentService {
|
||||
|
||||
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
|
||||
String requesterId) {
|
||||
memoryRecallTracker.trackRecalls(agentId, message);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
|
||||
if (agent instanceof StructuredStreamCapable capable) {
|
||||
@ -101,6 +106,7 @@ public class AgentService {
|
||||
}
|
||||
|
||||
public String execute(Long agentId, String goal, String conversationId) {
|
||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.execute(goal, conversationId);
|
||||
}
|
||||
@ -116,6 +122,7 @@ public class AgentService {
|
||||
*/
|
||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chatWithReplay(userMessage, conversationId, toolCallPayload);
|
||||
}
|
||||
@ -130,6 +137,7 @@ public class AgentService {
|
||||
|
||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||
String toolCallPayload, String requesterId) {
|
||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
||||
BaseAgent agent = getOrBuildAgent(agentId);
|
||||
return agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload,
|
||||
requesterId != null ? requesterId : "");
|
||||
|
||||
@ -38,4 +38,15 @@ public class MemoryProperties {
|
||||
|
||||
/** 构建对话 transcript 时的最大消息数(防止过长) */
|
||||
private int maxTranscriptMessages = 30;
|
||||
|
||||
// ==================== Dreaming 配置 ====================
|
||||
|
||||
/** 启用定时 Dreaming(自动执行记忆整合) */
|
||||
private boolean dreamingEnabled = true;
|
||||
|
||||
/** Dreaming 调度 cron 表达式(Spring 6 字段格式,默认每天凌晨 3 点) */
|
||||
private String dreamingCron = "0 0 3 * * ?";
|
||||
|
||||
/** 记忆召回评分阈值,低于此分的候选不进入 LLM 整合 */
|
||||
private double emergenceScoreThreshold = 0.4;
|
||||
}
|
||||
|
||||
@ -0,0 +1,55 @@
|
||||
package vip.mate.memory.config;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Memory 模块 Schema 迁移
|
||||
* <p>
|
||||
* 确保 mate_memory_recall 表存在(兼容已有部署)。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Order(201)
|
||||
@RequiredArgsConstructor
|
||||
public class MemorySchemaMigration implements ApplicationRunner {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
try {
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS mate_memory_recall (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
agent_id BIGINT NOT NULL,
|
||||
filename VARCHAR(256) NOT NULL,
|
||||
snippet_hash VARCHAR(64),
|
||||
snippet_preview VARCHAR(512),
|
||||
recall_count INT NOT NULL DEFAULT 0,
|
||||
daily_count INT NOT NULL DEFAULT 0,
|
||||
query_hashes TEXT,
|
||||
score DOUBLE NOT NULL DEFAULT 0.0,
|
||||
last_recalled_at DATETIME,
|
||||
promoted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted INT NOT NULL DEFAULT 0
|
||||
)
|
||||
""");
|
||||
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_memory_recall_agent ON mate_memory_recall(agent_id)");
|
||||
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_memory_recall_agent_file ON mate_memory_recall(agent_id, filename)");
|
||||
jdbcTemplate.execute("CREATE INDEX IF NOT EXISTS idx_memory_recall_score ON mate_memory_recall(agent_id, score)");
|
||||
log.info("[MemorySchemaMigration] mate_memory_recall schema migration completed");
|
||||
} catch (Exception e) {
|
||||
log.warn("[MemorySchemaMigration] Migration failed (table may already exist): {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
package vip.mate.memory.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 记忆召回追踪实体
|
||||
* <p>
|
||||
* 记录 workspace 文件在对话上下文注入时的召回信息,
|
||||
* 用于加权评分驱动的记忆整合(Dreaming)。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_memory_recall")
|
||||
public class MemoryRecallEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** 关联 Agent ID */
|
||||
private Long agentId;
|
||||
|
||||
/** 文件名,如 "memory/2026-04-01.md"、"MEMORY.md" */
|
||||
private String filename;
|
||||
|
||||
/** 被召回片段的 SHA-256 哈希(可为 null,文件级追踪时不填) */
|
||||
private String snippetHash;
|
||||
|
||||
/** 片段预览(前 200 字符,方便调试) */
|
||||
private String snippetPreview;
|
||||
|
||||
/** 累计召回次数 */
|
||||
private Integer recallCount;
|
||||
|
||||
/** 当日召回次数(每轮 dreaming 重置) */
|
||||
private Integer dailyCount;
|
||||
|
||||
/** 不同 user query hash 的 JSON 数组 */
|
||||
private String queryHashes;
|
||||
|
||||
/** 加权评分 */
|
||||
private Double score;
|
||||
|
||||
/** 最近一次召回时间 */
|
||||
private LocalDateTime lastRecalledAt;
|
||||
|
||||
/** 是否已提升到 MEMORY.md */
|
||||
private Boolean promoted;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package vip.mate.memory.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.memory.model.MemoryRecallEntity;
|
||||
|
||||
/**
|
||||
* 记忆召回追踪 Mapper
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Mapper
|
||||
public interface MemoryRecallMapper extends BaseMapper<MemoryRecallEntity> {
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package vip.mate.memory.scheduler;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.service.MemoryEmergenceService;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Dreaming 定时调度器
|
||||
* <p>
|
||||
* 按配置的 cron 表达式定期执行记忆整合,
|
||||
* 遍历所有启用的 Agent,对每个 Agent 执行评分驱动的 emergence。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DreamingScheduler {
|
||||
|
||||
private final AgentService agentService;
|
||||
private final MemoryEmergenceService emergenceService;
|
||||
private final MemoryProperties properties;
|
||||
|
||||
@Scheduled(cron = "${mate.memory.dreaming-cron:0 0 3 * * ?}")
|
||||
public void runDreaming() {
|
||||
if (!properties.isDreamingEnabled()) {
|
||||
log.debug("[Dreaming] Scheduled dreaming is disabled, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("[Dreaming] Starting scheduled dreaming cycle");
|
||||
List<AgentEntity> agents = agentService.listAgents();
|
||||
|
||||
int success = 0;
|
||||
int failed = 0;
|
||||
|
||||
for (AgentEntity agent : agents) {
|
||||
if (!Boolean.TRUE.equals(agent.getEnabled())) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
emergenceService.consolidate(agent.getId());
|
||||
success++;
|
||||
} catch (Exception e) {
|
||||
failed++;
|
||||
log.warn("[Dreaming] Failed for agent={} ({}): {}",
|
||||
agent.getId(), agent.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
log.info("[Dreaming] Cycle completed: {} succeeded, {} failed", success, failed);
|
||||
}
|
||||
}
|
||||
@ -15,11 +15,13 @@ import vip.mate.agent.prompt.PromptLoader;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.model.MemoryRecallEntity;
|
||||
import vip.mate.workspace.document.WorkspaceFileService;
|
||||
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 记忆整合服务
|
||||
@ -39,6 +41,7 @@ public class MemoryEmergenceService {
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final MemoryProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final MemoryRecallService recallService;
|
||||
|
||||
/**
|
||||
* 执行记忆整合:将 daily notes 中的重复模式提炼到 MEMORY.md
|
||||
@ -84,13 +87,34 @@ public class MemoryEmergenceService {
|
||||
// 3. 读取现有 MEMORY.md
|
||||
String memoryContent = readFileContentSafe(agentId, "MEMORY.md");
|
||||
|
||||
// 4. 构建 prompt 并调用 LLM
|
||||
// 4. 计算召回评分(必须在 resetDailyCounts 之前,否则 velocity 信号被清零)
|
||||
List<MemoryRecallEntity> scoredCandidates = recallService.computeScores(agentId);
|
||||
boolean hasScoredCandidates = !scoredCandidates.isEmpty();
|
||||
|
||||
// 5. 评分快照完成后再重置 dailyCount,为下一轮积累
|
||||
recallService.resetDailyCounts(agentId);
|
||||
|
||||
String systemPrompt = PromptLoader.loadPrompt("memory/emergence-system");
|
||||
String userTemplate = PromptLoader.loadPrompt("memory/emergence-user");
|
||||
String userPrompt = userTemplate
|
||||
.replace("{memory}", memoryContent)
|
||||
.replace("{day_range}", String.valueOf(properties.getEmergenceDayRange()))
|
||||
.replace("{daily_notes}", dailyNotes);
|
||||
String userPrompt;
|
||||
|
||||
if (hasScoredCandidates) {
|
||||
// 使用评分增强的 prompt
|
||||
String candidatesText = formatScoredCandidates(scoredCandidates);
|
||||
String userTemplate = PromptLoader.loadPrompt("memory/emergence-scored-user");
|
||||
userPrompt = userTemplate
|
||||
.replace("{memory}", memoryContent)
|
||||
.replace("{scored_candidates}", candidatesText)
|
||||
.replace("{day_range}", String.valueOf(properties.getEmergenceDayRange()))
|
||||
.replace("{daily_notes}", dailyNotes);
|
||||
log.info("[Memory] Emergence with {} scored candidates for agent={}", scoredCandidates.size(), agentId);
|
||||
} else {
|
||||
// 冷启动:回退到原有纯 LLM 逻辑
|
||||
String userTemplate = PromptLoader.loadPrompt("memory/emergence-user");
|
||||
userPrompt = userTemplate
|
||||
.replace("{memory}", memoryContent)
|
||||
.replace("{day_range}", String.valueOf(properties.getEmergenceDayRange()))
|
||||
.replace("{daily_notes}", dailyNotes);
|
||||
}
|
||||
|
||||
String llmResponse;
|
||||
try {
|
||||
@ -122,6 +146,19 @@ public class MemoryEmergenceService {
|
||||
workspaceFileService.saveFile(agentId, "MEMORY.md", newContent);
|
||||
String reason = root.path("reason").asText("");
|
||||
log.info("[Memory] Emergence completed for agent={}: {}", agentId, reason);
|
||||
|
||||
// 逐候选检查:只有内容被 LLM 实际采纳(出现在新 MEMORY.md 中)的才标记为已提升
|
||||
if (hasScoredCandidates) {
|
||||
List<Long> promotedIds = scoredCandidates.stream()
|
||||
.filter(c -> candidateAdoptedInMemory(c, newContent))
|
||||
.map(MemoryRecallEntity::getId)
|
||||
.collect(Collectors.toList());
|
||||
if (!promotedIds.isEmpty()) {
|
||||
recallService.markPromoted(promotedIds);
|
||||
}
|
||||
log.info("[Memory] Promoted {}/{} recall candidates for agent={}",
|
||||
promotedIds.size(), scoredCandidates.size(), agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
@ -129,6 +166,48 @@ public class MemoryEmergenceService {
|
||||
}
|
||||
}
|
||||
|
||||
private String formatScoredCandidates(List<MemoryRecallEntity> candidates) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (MemoryRecallEntity entry : candidates) {
|
||||
sb.append(String.format("### %s (score=%.2f, recalls=%d)\n",
|
||||
entry.getFilename(), entry.getScore(), entry.getRecallCount()));
|
||||
if (entry.getSnippetPreview() != null) {
|
||||
sb.append(entry.getSnippetPreview());
|
||||
sb.append("\n\n");
|
||||
}
|
||||
}
|
||||
return sb.toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断候选片段是否被 LLM 实际采纳到新 MEMORY.md 中。
|
||||
* 通过检查片段预览中的关键短语(取前 3 个非空行的前 20 字符)是否出现在新内容中。
|
||||
*/
|
||||
private boolean candidateAdoptedInMemory(MemoryRecallEntity candidate, String newMemoryContent) {
|
||||
String preview = candidate.getSnippetPreview();
|
||||
if (preview == null || preview.isBlank() || newMemoryContent == null) {
|
||||
return false;
|
||||
}
|
||||
// 从 snippet 提取关键短语进行匹配
|
||||
String[] lines = preview.split("\n");
|
||||
int matched = 0;
|
||||
int checked = 0;
|
||||
for (String line : lines) {
|
||||
String trimmed = line.trim();
|
||||
if (trimmed.isEmpty() || trimmed.startsWith("#")) continue;
|
||||
if (checked >= 3) break;
|
||||
checked++;
|
||||
// 取行的核心内容(去掉 markdown 标记),检查是否出现在新 MEMORY.md 中
|
||||
String key = trimmed.replaceAll("^[-*>]+\\s*", "");
|
||||
if (key.length() > 20) key = key.substring(0, 20);
|
||||
if (key.length() >= 5 && newMemoryContent.contains(key)) {
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
// 至少有 1 个关键短语命中才算采纳
|
||||
return matched > 0;
|
||||
}
|
||||
|
||||
private ChatModel buildChatModel() {
|
||||
ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
|
||||
return agentGraphBuilder.buildRuntimeChatModel(defaultModel);
|
||||
|
||||
@ -0,0 +1,257 @@
|
||||
package vip.mate.memory.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.memory.model.MemoryRecallEntity;
|
||||
import vip.mate.memory.repository.MemoryRecallMapper;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 记忆召回追踪与评分服务
|
||||
* <p>
|
||||
* 记录 workspace 文件的召回频率、查询多样性等信号,
|
||||
* 计算加权评分用于 Dreaming 记忆整合。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MemoryRecallService {
|
||||
|
||||
private final MemoryRecallMapper recallMapper;
|
||||
private final MemoryProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private static final int MAX_QUERY_HASHES = 32;
|
||||
|
||||
/**
|
||||
* 记录一次文件召回
|
||||
*/
|
||||
public void recordRecall(Long agentId, String filename, String snippetText, String userQueryHash) {
|
||||
if (agentId == null || filename == null || filename.isBlank()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String snippetHash = sha256(snippetText);
|
||||
String preview = snippetText != null && snippetText.length() > 200
|
||||
? snippetText.substring(0, 200)
|
||||
: snippetText;
|
||||
|
||||
MemoryRecallEntity existing = recallMapper.selectOne(
|
||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||
.eq(MemoryRecallEntity::getFilename, filename)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
||||
.last("LIMIT 1"));
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
if (existing != null) {
|
||||
existing.setRecallCount(existing.getRecallCount() + 1);
|
||||
existing.setDailyCount(existing.getDailyCount() + 1);
|
||||
existing.setLastRecalledAt(now);
|
||||
existing.setSnippetHash(snippetHash);
|
||||
existing.setSnippetPreview(preview);
|
||||
|
||||
// 追加 query hash(去重,最多 MAX_QUERY_HASHES 个)
|
||||
if (userQueryHash != null) {
|
||||
List<String> hashes = parseQueryHashes(existing.getQueryHashes());
|
||||
if (!hashes.contains(userQueryHash) && hashes.size() < MAX_QUERY_HASHES) {
|
||||
hashes.add(userQueryHash);
|
||||
}
|
||||
existing.setQueryHashes(toJson(hashes));
|
||||
}
|
||||
|
||||
recallMapper.updateById(existing);
|
||||
} else {
|
||||
MemoryRecallEntity entity = new MemoryRecallEntity();
|
||||
entity.setAgentId(agentId);
|
||||
entity.setFilename(filename);
|
||||
entity.setSnippetHash(snippetHash);
|
||||
entity.setSnippetPreview(preview);
|
||||
entity.setRecallCount(1);
|
||||
entity.setDailyCount(1);
|
||||
entity.setLastRecalledAt(now);
|
||||
entity.setPromoted(false);
|
||||
entity.setScore(0.0);
|
||||
entity.setCreateTime(now);
|
||||
entity.setUpdateTime(now);
|
||||
entity.setDeleted(0);
|
||||
|
||||
if (userQueryHash != null) {
|
||||
entity.setQueryHashes(toJson(List.of(userQueryHash)));
|
||||
}
|
||||
|
||||
recallMapper.insert(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置所有记录的 dailyCount(在每轮 dreaming 开始时调用)
|
||||
*/
|
||||
public void resetDailyCounts(Long agentId) {
|
||||
recallMapper.update(null,
|
||||
new LambdaUpdateWrapper<MemoryRecallEntity>()
|
||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
||||
.set(MemoryRecallEntity::getDailyCount, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取未提升的候选列表
|
||||
*/
|
||||
public List<MemoryRecallEntity> listCandidates(Long agentId) {
|
||||
return recallMapper.selectList(
|
||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||
.eq(MemoryRecallEntity::getPromoted, false)
|
||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
||||
.orderByDesc(MemoryRecallEntity::getScore));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算加权评分,返回超过阈值的高分候选
|
||||
*/
|
||||
public List<MemoryRecallEntity> computeScores(Long agentId) {
|
||||
List<MemoryRecallEntity> candidates = listCandidates(agentId);
|
||||
if (candidates.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
// 归一化参数
|
||||
int maxRecallCount = candidates.stream()
|
||||
.mapToInt(MemoryRecallEntity::getRecallCount)
|
||||
.max().orElse(1);
|
||||
int maxQueryDiversity = candidates.stream()
|
||||
.mapToInt(e -> parseQueryHashes(e.getQueryHashes()).size())
|
||||
.max().orElse(1);
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
double halfLifeDays = 7.0;
|
||||
double threshold = properties.getEmergenceScoreThreshold();
|
||||
|
||||
for (MemoryRecallEntity entry : candidates) {
|
||||
// 1. 频率 (0.30)
|
||||
double frequency = (double) entry.getRecallCount() / Math.max(maxRecallCount, 1);
|
||||
|
||||
// 2. 时效性 (0.25) — 指数衰减
|
||||
double recency = 0.0;
|
||||
if (entry.getLastRecalledAt() != null) {
|
||||
long daysSinceRecall = ChronoUnit.DAYS.between(entry.getLastRecalledAt(), now);
|
||||
recency = Math.exp(-0.693 * daysSinceRecall / halfLifeDays); // ln(2) ≈ 0.693
|
||||
}
|
||||
|
||||
// 3. 查询多样性 (0.20)
|
||||
int queryCount = parseQueryHashes(entry.getQueryHashes()).size();
|
||||
double diversity = (double) queryCount / Math.max(maxQueryDiversity, 1);
|
||||
|
||||
// 4. 内容新鲜度 (0.15) — 根据文件名日期
|
||||
double freshness = computeFreshness(entry.getFilename(), now);
|
||||
|
||||
// 5. 召回速度 (0.10) — dailyCount / recallCount
|
||||
double velocity = entry.getRecallCount() > 0
|
||||
? (double) entry.getDailyCount() / entry.getRecallCount()
|
||||
: 0.0;
|
||||
|
||||
double score = 0.30 * frequency
|
||||
+ 0.25 * recency
|
||||
+ 0.20 * diversity
|
||||
+ 0.15 * freshness
|
||||
+ 0.10 * velocity;
|
||||
|
||||
entry.setScore(score);
|
||||
recallMapper.updateById(entry);
|
||||
}
|
||||
|
||||
return candidates.stream()
|
||||
.filter(e -> e.getScore() >= threshold)
|
||||
.sorted(Comparator.comparingDouble(MemoryRecallEntity::getScore).reversed())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记候选为已提升
|
||||
*/
|
||||
public void markPromoted(List<Long> ids) {
|
||||
if (ids == null || ids.isEmpty()) return;
|
||||
recallMapper.update(null,
|
||||
new LambdaUpdateWrapper<MemoryRecallEntity>()
|
||||
.in(MemoryRecallEntity::getId, ids)
|
||||
.set(MemoryRecallEntity::getPromoted, true));
|
||||
}
|
||||
|
||||
// ==================== 内部工具方法 ====================
|
||||
|
||||
private double computeFreshness(String filename, LocalDateTime now) {
|
||||
// 从 "memory/2026-04-01.md" 或 "memory/2026-04-01.md#section" 提取日期
|
||||
if (filename == null || !filename.startsWith("memory/")) {
|
||||
return 0.5; // 非 daily note 给中间值
|
||||
}
|
||||
try {
|
||||
String datePart = filename.replace("memory/", "");
|
||||
// 剥离 #anchor(片段级追踪产生的 section key)
|
||||
int hashIdx = datePart.indexOf('#');
|
||||
if (hashIdx > 0) {
|
||||
datePart = datePart.substring(0, hashIdx);
|
||||
}
|
||||
datePart = datePart.replace(".md", "");
|
||||
String[] parts = datePart.split("-");
|
||||
if (parts.length == 3) {
|
||||
LocalDateTime fileDate = LocalDateTime.of(
|
||||
Integer.parseInt(parts[0]),
|
||||
Integer.parseInt(parts[1]),
|
||||
Integer.parseInt(parts[2]),
|
||||
0, 0);
|
||||
long daysAgo = ChronoUnit.DAYS.between(fileDate, now);
|
||||
return Math.max(0, 1.0 - (double) daysAgo / 30.0); // 30 天线性衰减
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
private List<String> parseQueryHashes(String json) {
|
||||
if (json == null || json.isBlank()) return new ArrayList<>();
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
private String toJson(List<String> list) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(list);
|
||||
} catch (Exception e) {
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
private String sha256(String text) {
|
||||
if (text == null || text.isBlank()) return null;
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (byte b : hash) {
|
||||
hex.append(String.format("%02x", b));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,151 @@
|
||||
package vip.mate.memory.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
||||
import vip.mate.workspace.document.repository.WorkspaceFileMapper;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 记忆召回追踪器
|
||||
* <p>
|
||||
* 在每次对话时异步记录哪些 workspace 文件/片段被实际注入到上下文中。
|
||||
* 追踪粒度为片段级(daily note 按 ## 标题拆分),而非文件级。
|
||||
*
|
||||
* @author MateClaw Team
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class MemoryRecallTracker {
|
||||
|
||||
private final MemoryRecallService recallService;
|
||||
private final WorkspaceFileMapper workspaceFileMapper;
|
||||
|
||||
/** 用于拆分 daily note 中的二级标题片段 */
|
||||
private static final Pattern SECTION_PATTERN = Pattern.compile("(?m)^## .+");
|
||||
|
||||
/**
|
||||
* 异步追踪一次对话中被实际注入到 system prompt 的文件片段。
|
||||
* <p>
|
||||
* 仅追踪满足 buildSystemPrompt 注入条件的文件(enabled=true, content 非空)。
|
||||
* 对 daily note 类文件(memory/*.md),按 ## 标题拆分为独立片段追踪。
|
||||
*
|
||||
* @param agentId Agent ID
|
||||
* @param userQuery 用户消息(用于计算 query hash)
|
||||
*/
|
||||
@Async
|
||||
public void trackRecalls(Long agentId, String userQuery) {
|
||||
try {
|
||||
// 精确复现 buildSystemPrompt 的注入条件
|
||||
List<WorkspaceFileEntity> injectedFiles = workspaceFileMapper.selectList(
|
||||
new LambdaQueryWrapper<WorkspaceFileEntity>()
|
||||
.eq(WorkspaceFileEntity::getAgentId, agentId)
|
||||
.eq(WorkspaceFileEntity::getEnabled, true)
|
||||
.isNotNull(WorkspaceFileEntity::getContent)
|
||||
.ne(WorkspaceFileEntity::getContent, "")
|
||||
.orderByAsc(WorkspaceFileEntity::getSortOrder));
|
||||
|
||||
if (injectedFiles.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
String queryHash = sha256Short(userQuery);
|
||||
int trackedCount = 0;
|
||||
|
||||
for (WorkspaceFileEntity file : injectedFiles) {
|
||||
String content = file.getContent();
|
||||
if (content == null || content.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String filename = file.getFilename();
|
||||
|
||||
if (filename.startsWith("memory/") && filename.endsWith(".md")) {
|
||||
// daily note: 按 ## 标题拆分为独立片段
|
||||
trackedCount += trackDailyNoteSnippets(agentId, filename, content, queryHash);
|
||||
} else {
|
||||
// 非 daily note (PROFILE.md, MEMORY.md 等): 文件级追踪
|
||||
recallService.recordRecall(agentId, filename, content, queryHash);
|
||||
trackedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("[MemoryRecall] Tracked {} snippets for agent={}", trackedCount, agentId);
|
||||
} catch (Exception e) {
|
||||
log.warn("[MemoryRecall] Failed to track recalls for agent={}: {}", agentId, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 daily note 按 ## 标题拆分为独立片段,分别追踪
|
||||
*/
|
||||
private int trackDailyNoteSnippets(Long agentId, String filename, String content, String queryHash) {
|
||||
Matcher matcher = SECTION_PATTERN.matcher(content);
|
||||
List<Integer> sectionStarts = new java.util.ArrayList<>();
|
||||
while (matcher.find()) {
|
||||
sectionStarts.add(matcher.start());
|
||||
}
|
||||
|
||||
if (sectionStarts.isEmpty()) {
|
||||
// 没有 ## 标题,整个文件作为一个片段
|
||||
recallService.recordRecall(agentId, filename, content.trim(), queryHash);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
// 如果 ## 前有内容,作为第一个片段
|
||||
if (sectionStarts.get(0) > 0) {
|
||||
String preamble = content.substring(0, sectionStarts.get(0)).trim();
|
||||
if (!preamble.isEmpty()) {
|
||||
recallService.recordRecall(agentId, filename + "#preamble", preamble, queryHash);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < sectionStarts.size(); i++) {
|
||||
int start = sectionStarts.get(i);
|
||||
int end = (i + 1 < sectionStarts.size()) ? sectionStarts.get(i + 1) : content.length();
|
||||
String snippet = content.substring(start, end).trim();
|
||||
if (!snippet.isEmpty()) {
|
||||
// 从 ## 标题行提取 section 标识
|
||||
String firstLine = snippet.contains("\n") ? snippet.substring(0, snippet.indexOf('\n')).trim() : snippet;
|
||||
String sectionKey = filename + "#" + sanitizeSectionKey(firstLine);
|
||||
recallService.recordRecall(agentId, sectionKey, snippet, queryHash);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private String sanitizeSectionKey(String heading) {
|
||||
// "## Some Title" -> "some-title"
|
||||
return heading.replaceFirst("^#+\\s*", "")
|
||||
.toLowerCase()
|
||||
.replaceAll("[^a-z0-9\\u4e00-\\u9fff]+", "-")
|
||||
.replaceAll("^-|-$", "");
|
||||
}
|
||||
|
||||
private String sha256Short(String text) {
|
||||
if (text == null || text.isBlank()) return null;
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(text.getBytes(StandardCharsets.UTF_8));
|
||||
StringBuilder hex = new StringBuilder();
|
||||
for (int i = 0; i < 8 && i < hash.length; i++) {
|
||||
hex.append(String.format("%02x", hash[i]));
|
||||
}
|
||||
return hex.toString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -409,3 +409,25 @@ ALTER TABLE mate_message ADD COLUMN IF NOT EXISTS metadata JSON;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_guard_audit_conv ON mate_tool_guard_audit_log(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_guard_audit_time ON mate_tool_guard_audit_log(create_time);
|
||||
|
||||
-- 记忆召回追踪表(Dreaming 评分驱动记忆整合)
|
||||
CREATE TABLE IF NOT EXISTS mate_memory_recall (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
agent_id BIGINT NOT NULL,
|
||||
filename VARCHAR(256) NOT NULL,
|
||||
snippet_hash VARCHAR(64),
|
||||
snippet_preview VARCHAR(512),
|
||||
recall_count INT NOT NULL DEFAULT 0,
|
||||
daily_count INT NOT NULL DEFAULT 0,
|
||||
query_hashes TEXT,
|
||||
score DOUBLE NOT NULL DEFAULT 0.0,
|
||||
last_recalled_at DATETIME,
|
||||
promoted BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_recall_agent ON mate_memory_recall(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_recall_agent_file ON mate_memory_recall(agent_id, filename);
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_recall_score ON mate_memory_recall(agent_id, score);
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
## 现有 MEMORY.md 内容
|
||||
```
|
||||
{memory}
|
||||
```
|
||||
|
||||
## 高分召回候选(按评分排序)
|
||||
|
||||
以下文件在对话中被频繁引用,评分基于召回频率、时效性、查询多样性等维度自动计算。
|
||||
**优先整合评分高的内容**,它们是用户实际在对话中反复使用的记忆。
|
||||
|
||||
{scored_candidates}
|
||||
|
||||
## 最近 {day_range} 天的 daily notes(补充参考)
|
||||
|
||||
{daily_notes}
|
||||
|
||||
---
|
||||
|
||||
请分析以上内容,优先将高分召回候选中的稳定信息整合到 MEMORY.md 中。
|
||||
低分或未被引用的内容仅作补充参考。严格输出 JSON 格式。
|
||||
Loading…
Reference in New Issue
Block a user