mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
feat(memory): route summarized typed facts into structured memory
This commit is contained in:
parent
de98368b4e
commit
f1c55b80e8
@ -46,6 +46,11 @@ public class MemorySummarizationService {
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final MemoryProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final StructuredMemoryService structuredMemoryService;
|
||||
|
||||
/** Typed-memory categories the summarizer may route entries into. */
|
||||
private static final java.util.Set<String> STRUCTURED_TYPES =
|
||||
java.util.Set.of("user", "feedback", "project", "reference");
|
||||
|
||||
/** Per-agent 锁,防止并发写入 */
|
||||
private final ConcurrentHashMap<Long, ReentrantLock> agentLocks = new ConcurrentHashMap<>();
|
||||
@ -192,6 +197,40 @@ public class MemorySummarizationService {
|
||||
log.info("[Memory] Updated PROFILE.md for agent={}", agentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Structured entries: route typed facts (especially volatile project /
|
||||
// reference facts kept out of the always-on MEMORY.md) into structured
|
||||
// memory so they become query-conditioned recallable, instead of being
|
||||
// stranded in daily notes that only the agent's tools can reach.
|
||||
applyStructuredEntries(agentId, root.path("structured_entries"));
|
||||
}
|
||||
|
||||
private void applyStructuredEntries(Long agentId, JsonNode entriesNode) {
|
||||
if (entriesNode == null || !entriesNode.isArray() || entriesNode.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int written = 0;
|
||||
for (JsonNode entry : entriesNode) {
|
||||
String type = entry.path("type").asText("").trim().toLowerCase();
|
||||
String key = entry.path("key").asText("").trim();
|
||||
String content = entry.path("content").asText("").trim();
|
||||
if (!STRUCTURED_TYPES.contains(type) || key.isEmpty() || content.isEmpty()) {
|
||||
log.debug("[Memory] Skipping invalid structured entry (type={}, key={}) for agent={}",
|
||||
type, key, agentId);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
structuredMemoryService.remember(agentId, type, key, content, "auto-summary");
|
||||
written++;
|
||||
} catch (Exception e) {
|
||||
log.warn("[Memory] Failed to write structured entry '{}' (type={}) for agent={}: {}",
|
||||
key, type, agentId, e.getMessage());
|
||||
}
|
||||
}
|
||||
if (written > 0) {
|
||||
log.info("[Memory] Routed {} structured entr{} for agent={}",
|
||||
written, written == 1 ? "y" : "ies", agentId);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildTranscript(List<MessageEntity> messages) {
|
||||
|
||||
@ -36,12 +36,18 @@ MEMORY.md 与 PROFILE.md 会被**无条件注入每一次对话的系统提示**
|
||||
"daily_entry": null,
|
||||
"memory_update": null,
|
||||
"profile_update": null,
|
||||
"structured_entries": null,
|
||||
"reason": "简要说明判断理由"
|
||||
}
|
||||
|
||||
字段说明:
|
||||
- `should_update`: 布尔值,是否有任何需要更新的内容。如果为 false,其余字段应为 null
|
||||
- `daily_entry`: 字符串或 null。要追加到今日 daily note 的内容(markdown 格式,以时间戳开头如 "## HH:mm ...")
|
||||
- `memory_update`: 字符串或 null。MEMORY.md 的完整新内容(已合并现有内容,不是增量)。仅当有需要新增或修改的稳定信息时才填写
|
||||
- `memory_update`: 字符串或 null。MEMORY.md 的完整新内容(已合并现有内容,不是增量)。仅当有需要新增或修改的**跨项目稳定**信息时才填写
|
||||
- `profile_update`: 字符串或 null。PROFILE.md 的完整新内容(已合并现有内容,不是增量)。仅当用户身份/偏好有显著变化时才填写
|
||||
- `structured_entries`: 数组或 null。把适合按条目检索的**具体事实**路由到结构化记忆,每个元素形如 `{"type": "...", "key": "...", "content": "..."}`:
|
||||
- `type` 取值:`user`(用户偏好/专长/沟通风格/角色)、`feedback`(被纠正的行为或确认的做法,含原因)、`project`(具体项目的代号/名称/技术栈/指标/预算/团队/约束/单项目决策)、`reference`(外部系统指针,如某看板/频道/文档地址)
|
||||
- `key`: 稳定的英文蛇形命名,便于后续更新同一条目(如 `project_codename`、`project_tech_stack`、`preferred_output_format`)
|
||||
- `content`: 一两句话陈述该事实
|
||||
- **重要**:上面「记忆分层纪律」要求不进 MEMORY.md 的项目易变事实(代号、技术栈、单项目指标/预算/团队等),应放在这里(`type=project`),这样才能在后续对话中按问题被召回;不要让它们只停留在 daily note。
|
||||
- `reason`: 简要说明判断理由
|
||||
|
||||
@ -0,0 +1,97 @@
|
||||
package vip.mate.memory.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import vip.mate.agent.AgentGraphBuilder;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.memory.MemoryProperties;
|
||||
import vip.mate.workspace.conversation.ConversationService;
|
||||
import vip.mate.workspace.document.WorkspaceFileService;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* The conversation summarizer routes typed facts it extracts into structured
|
||||
* memory (the query-conditioned recall channel), so project/reference facts kept
|
||||
* out of the always-on MEMORY.md still become recallable instead of being
|
||||
* stranded in daily notes. Valid entries are written; malformed ones are skipped.
|
||||
*/
|
||||
class MemorySummarizationStructuredRoutingTest {
|
||||
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
private MemorySummarizationService newService(StructuredMemoryService structured) {
|
||||
return new MemorySummarizationService(
|
||||
mock(ConversationService.class),
|
||||
mock(WorkspaceFileService.class),
|
||||
mock(ModelConfigService.class),
|
||||
mock(AgentGraphBuilder.class),
|
||||
mock(MemoryProperties.class),
|
||||
mapper,
|
||||
structured);
|
||||
}
|
||||
|
||||
private void invokeApply(MemorySummarizationService svc, long agentId, String entriesJson) throws Exception {
|
||||
JsonNode node = mapper.readTree(entriesJson);
|
||||
Method m = MemorySummarizationService.class
|
||||
.getDeclaredMethod("applyStructuredEntries", Long.class, JsonNode.class);
|
||||
m.setAccessible(true);
|
||||
m.invoke(svc, agentId, node);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("valid typed entries are routed to structured memory")
|
||||
void routesValidEntries() throws Exception {
|
||||
StructuredMemoryService structured = mock(StructuredMemoryService.class);
|
||||
MemorySummarizationService svc = newService(structured);
|
||||
|
||||
invokeApply(svc, 1000000001L, """
|
||||
[
|
||||
{"type": "project", "key": "project_codename", "content": "项目代号:云梯计划"},
|
||||
{"type": "user", "key": "preferred_output_format", "content": "偏好表格输出"}
|
||||
]
|
||||
""");
|
||||
|
||||
verify(structured).remember(1000000001L, "project", "project_codename", "项目代号:云梯计划", "auto-summary");
|
||||
verify(structured).remember(1000000001L, "user", "preferred_output_format", "偏好表格输出", "auto-summary");
|
||||
verifyNoMoreInteractions(structured);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("malformed or unknown-type entries are skipped")
|
||||
void skipsInvalidEntries() throws Exception {
|
||||
StructuredMemoryService structured = mock(StructuredMemoryService.class);
|
||||
MemorySummarizationService svc = newService(structured);
|
||||
|
||||
invokeApply(svc, 1000000001L, """
|
||||
[
|
||||
{"type": "secret", "key": "k", "content": "bad type"},
|
||||
{"type": "project", "key": "", "content": "missing key"},
|
||||
{"type": "project", "key": "ok_key", "content": ""},
|
||||
{"type": "project", "key": "good", "content": "kept"}
|
||||
]
|
||||
""");
|
||||
|
||||
// Only the last, fully-valid entry is written.
|
||||
verify(structured).remember(1000000001L, "project", "good", "kept", "auto-summary");
|
||||
verifyNoMoreInteractions(structured);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null / non-array structured_entries is a no-op")
|
||||
void noopForNullOrNonArray() throws Exception {
|
||||
StructuredMemoryService structured = mock(StructuredMemoryService.class);
|
||||
MemorySummarizationService svc = newService(structured);
|
||||
|
||||
invokeApply(svc, 1000000001L, "null");
|
||||
invokeApply(svc, 1000000001L, "\"not-an-array\"");
|
||||
invokeApply(svc, 1000000001L, "[]");
|
||||
|
||||
verifyNoInteractions(structured);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user