feat(memory): route summarized typed facts into structured memory

This commit is contained in:
matevip 2026-05-30 07:36:20 +08:00
parent de98368b4e
commit f1c55b80e8
3 changed files with 143 additions and 1 deletions

View File

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

View File

@ -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`: 简要说明判断理由

View File

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