From 84c8f8f9a0412ab7c18c9fe57ecb1a5c53f7a8f1 Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 21 Apr 2026 17:34:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20P1=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20close=204=20semantic=20gaps=20in=20data=20truth=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/vip/mate/agent/AgentService.java | 51 +++++++++++++------ .../fact/controller/FactController.java | 46 ++++++++++------- .../memory/fact/extraction/ExtractedFact.java | 3 ++ .../extraction/PatternEntityExtractor.java | 22 +++++++- .../projection/FactProjectionBuilder.java | 7 +-- 5 files changed, 89 insertions(+), 40 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 72ebff01..a7886f2e 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -101,14 +101,14 @@ public class AgentService { memoryRecallTracker.trackRecalls(agentId, message); BaseAgent agent = getOrBuildAgent(agentId); return withLifecycleSync(agentId, message, conversationId, - () -> agent.chat(message, conversationId)); + (msg, convId) -> agent.chat(msg, convId)); } public Flux chatStream(Long agentId, String message, String conversationId) { memoryRecallTracker.trackRecalls(agentId, message); BaseAgent agent = getOrBuildAgent(agentId); return withLifecycleFlux(agentId, message, conversationId, - () -> agent.chatStream(message, conversationId), + (msg, convId) -> agent.chatStream(msg, convId), chunk -> chunk); } @@ -141,7 +141,7 @@ public class AgentService { if (agent instanceof StructuredStreamCapable capable) { return withLifecycleFlux(agentId, message, conversationId, - () -> capable.chatStructuredStream(message, conversationId, + (msg, convId) -> capable.chatStructuredStream(msg, convId, requesterId != null ? requesterId : "") .doFinally(signal -> ThinkingLevelHolder.clear()), StreamDelta::content); @@ -150,7 +150,7 @@ public class AgentService { // 降级:不支持结构化流的 Agent,包装为纯内容流 ThinkingLevelHolder.clear(); return withLifecycleFlux(agentId, message, conversationId, - () -> agent.chatStream(message, conversationId) + (msg, convId) -> agent.chatStream(msg, convId) .map(chunk -> new StreamDelta(chunk, null)), StreamDelta::content); } @@ -159,7 +159,7 @@ public class AgentService { memoryRecallTracker.trackRecalls(agentId, goal); BaseAgent agent = getOrBuildAgent(agentId); return withLifecycleSync(agentId, goal, conversationId, - () -> agent.execute(goal, conversationId)); + (msg, convId) -> agent.execute(msg, convId)); } /** @@ -176,7 +176,7 @@ public class AgentService { memoryRecallTracker.trackRecalls(agentId, userMessage); BaseAgent agent = getOrBuildAgent(agentId); return withLifecycleSync(agentId, userMessage, conversationId, - () -> agent.chatWithReplay(userMessage, conversationId, toolCallPayload)); + (msg, convId) -> agent.chatWithReplay(msg, convId, toolCallPayload)); } /** @@ -192,7 +192,7 @@ public class AgentService { memoryRecallTracker.trackRecalls(agentId, userMessage); BaseAgent agent = getOrBuildAgent(agentId); return withLifecycleFlux(agentId, userMessage, conversationId, - () -> agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload, + (msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload, requesterId != null ? requesterId : ""), StreamDelta::content); } @@ -231,15 +231,20 @@ public class AgentService { /** * Wraps a synchronous agent call with lifecycle mediator hooks. * When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior). + * + * P1-1 fix: prefetchAll result is now prepended to userMessage as <memory-context> block. + * P1-4 fix: N/A for sync (no cancel/error signal issue). */ private String withLifecycleSync(Long agentId, String message, String conversationId, - Supplier plainInvoke) { + java.util.function.BiFunction invoke) { if (!memoryProperties.isLifecycleMediatorEnabled()) { - return plainInvoke.get(); + return invoke.apply(message, conversationId); } TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message); - lifecycleMediator.beforeLlmCall(ctx); - String result = plainInvoke.get(); + String memoryContext = lifecycleMediator.beforeLlmCall(ctx); + // Inject memory context into the user message (RFC-037 §3.3) + String enrichedMessage = injectMemoryContext(message, memoryContext); + String result = invoke.apply(enrichedMessage, conversationId); lifecycleMediator.afterLlmCall(ctx, result != null ? result : ""); return result; } @@ -247,24 +252,38 @@ public class AgentService { /** * Wraps a streaming agent call with lifecycle mediator hooks. * When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior). + * + * P1-1 fix: prefetchAll result is now prepended to userMessage. + * P1-4 fix: afterLlmCall only fires on COMPLETE signal, not on cancel/error. */ private Flux withLifecycleFlux(Long agentId, String message, String conversationId, - Supplier> plainInvoke, + java.util.function.BiFunction> invoke, Function contentExtractor) { if (!memoryProperties.isLifecycleMediatorEnabled()) { - return plainInvoke.get(); + return invoke.apply(message, conversationId); } TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message); - lifecycleMediator.beforeLlmCall(ctx); + String memoryContext = lifecycleMediator.beforeLlmCall(ctx); + String enrichedMessage = injectMemoryContext(message, memoryContext); StringBuilder reply = new StringBuilder(); - return plainInvoke.get() + return invoke.apply(enrichedMessage, conversationId) .doOnNext(item -> { String text = contentExtractor.apply(item); if (text != null) { reply.append(text); } }) - .doFinally(signal -> lifecycleMediator.afterLlmCall(ctx, reply.toString())); + .doOnComplete(() -> lifecycleMediator.afterLlmCall(ctx, reply.toString())) + .doOnError(e -> log.debug("[Memory] Stream error, skipping afterLlmCall: {}", e.getMessage())); + } + + /** + * Prepend memory-context block to user message if non-empty. + * Does not pollute build-time system prompt snapshot. + */ + private String injectMemoryContext(String message, String memoryContext) { + if (memoryContext == null || memoryContext.isBlank()) return message; + return memoryContext + "\n\n" + message; } // ==================== 内部方法 ==================== diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/controller/FactController.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/controller/FactController.java index fc143f49..373b9240 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/controller/FactController.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/controller/FactController.java @@ -100,17 +100,14 @@ public class FactController { content = content + "\n" + marker + "\n"; } workspaceFileService.saveFile(agentId, filename, content); + // Rebuild projection from the UPDATED canonical content (not stale file object) + // Forgotten section will be skipped by PatternEntityExtractor + projectionBuilder.rebuildOne(agentId, filename, content); } - // Trigger projection rebuild — the forgotten fact will be excluded - // because FactProjectionBuilder skips sections with Forgotten: metadata - projectionBuilder.rebuildOne(agentId, filename, - file != null ? file.getContent() : ""); - - // Soft-delete the fact immediately (rebuild will also handle it) - fact.setDeleted(1); - fact.setUpdateTime(LocalDateTime.now()); - factMapper.updateById(fact); + // Do NOT directly write mate_fact — let projection rebuild handle visibility. + // The rebuild will either skip the Forgotten section (removing the fact) + // or soft-delete it via deleteByAgentIdAndSourceRefNotIn. log.info("[Fact] Forgotten fact {} for agent={} by {}", factId, agentId, userId); return R.ok(null); @@ -132,24 +129,35 @@ public class FactController { return R.fail("Fact not found"); } - // Write feedback metadata to canonical source + // Write feedback metadata to the specific canonical section (not file tail) String sourceRef = fact.getSourceRef(); String[] parts = sourceRef.split("#", 2); String filename = parts[0]; + String sectionKey = parts.length > 1 ? parts[1] : null; WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); if (file != null && file.getContent() != null) { String marker = "> UserFeedback: " + kind + " " + LocalDate.now(); - workspaceFileService.saveFile(agentId, filename, file.getContent() + "\n" + marker + "\n"); + String content = file.getContent(); + if (sectionKey != null) { + String sectionHeader = "## " + sectionKey; + int idx = content.indexOf(sectionHeader); + if (idx >= 0) { + int nextSection = content.indexOf("\n## ", idx + sectionHeader.length()); + int insertAt = nextSection > 0 ? nextSection : content.length(); + content = content.substring(0, insertAt) + "\n" + marker + "\n" + content.substring(insertAt); + } else { + content = content + "\n" + marker + "\n"; + } + } else { + content = content + "\n" + marker + "\n"; + } + workspaceFileService.saveFile(agentId, filename, content); + // Rebuild projection from updated canonical — trust will be derived from metadata + projectionBuilder.rebuildOne(agentId, filename, content); } - // Adjust trust score - double delta = kind.equals("HELPFUL") ? 0.1 : -0.2; - double newTrust = Math.max(0.0, Math.min(1.0, (fact.getTrust() != null ? fact.getTrust() : 0.5) + delta)); - fact.setTrust(newTrust); - fact.setUpdateTime(LocalDateTime.now()); - factMapper.updateById(fact); - - log.info("[Fact] Feedback {} on fact {} for agent={}, trust={}", kind, factId, agentId, newTrust); + // Do NOT directly write mate_fact.trust — let projection rebuild derive it from canonical metadata. + log.info("[Fact] Feedback {} on fact {} for agent={}", kind, factId, agentId); return R.ok(null); } diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/extraction/ExtractedFact.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/extraction/ExtractedFact.java index c055146a..fd148caa 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/extraction/ExtractedFact.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/extraction/ExtractedFact.java @@ -3,6 +3,8 @@ package vip.mate.memory.fact.extraction; /** * A single fact extracted from canonical memory content. * + * @param trust derived from UserFeedback metadata in the canonical section + * (0.5 base + helpful*0.1 - unhelpful*0.2, clamped [0,1]) * @author MateClaw Team */ public record ExtractedFact( @@ -12,5 +14,6 @@ public record ExtractedFact( String predicate, String objectValue, double confidence, + double trust, String extractedBy ) {} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/extraction/PatternEntityExtractor.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/extraction/PatternEntityExtractor.java index b05091c5..8671cda5 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/extraction/PatternEntityExtractor.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/extraction/PatternEntityExtractor.java @@ -23,6 +23,8 @@ public class PatternEntityExtractor implements EntityExtractor { private static final Pattern SECTION_HEADER = Pattern.compile("^## (.+)$", Pattern.MULTILINE); private static final Pattern KV_BULLET = Pattern.compile("^- \\*\\*(.+?)\\*\\*:\\s*(.+)$", Pattern.MULTILINE); private static final Pattern FORGOTTEN_MARKER = Pattern.compile("^> Forgotten:", Pattern.MULTILINE); + private static final Pattern FEEDBACK_HELPFUL = Pattern.compile("^> UserFeedback: HELPFUL", Pattern.MULTILINE); + private static final Pattern FEEDBACK_UNHELPFUL = Pattern.compile("^> UserFeedback: UNHELPFUL", Pattern.MULTILINE); @Override public List extract(Long agentId, String filename, String content) { @@ -38,6 +40,9 @@ public class PatternEntityExtractor implements EntityExtractor { // Skip forgotten sections (rfc-038 L3: projection excludes Forgotten metadata) if (FORGOTTEN_MARKER.matcher(section).find()) continue; + // Derive trust from UserFeedback metadata in this section + double trust = deriveTrust(section); + // Extract key-value bullets: - **key**: value Matcher kvMatcher = KV_BULLET.matcher(section); while (kvMatcher.find()) { @@ -45,7 +50,7 @@ public class PatternEntityExtractor implements EntityExtractor { String value = kvMatcher.group(2).trim(); if (value.isBlank() || value.equals(":")) continue; String sourceRef = filename + "#" + toSlug(key); - facts.add(new ExtractedFact(sourceRef, category, key, "is", value, 0.9, "pattern")); + facts.add(new ExtractedFact(sourceRef, category, key, "is", value, 0.9, trust, "pattern")); } // Extract section heading facts from structured files @@ -61,7 +66,7 @@ public class PatternEntityExtractor implements EntityExtractor { String firstLine = body.split("\n")[0].replaceAll("^[-*>]+\\s*", "").trim(); if (firstLine.length() >= 5) { - facts.add(new ExtractedFact(sourceRef, category, heading, "has", firstLine, 0.8, "pattern")); + facts.add(new ExtractedFact(sourceRef, category, heading, "has", firstLine, 0.8, trust, "pattern")); } } } @@ -69,6 +74,19 @@ public class PatternEntityExtractor implements EntityExtractor { return facts; } + /** + * Derive trust score from UserFeedback metadata in a section. + * Base 0.5, HELPFUL +0.1 each, UNHELPFUL -0.2 each, clamped [0,1]. + */ + private double deriveTrust(String section) { + double trust = 0.5; + Matcher helpful = FEEDBACK_HELPFUL.matcher(section); + while (helpful.find()) trust += 0.1; + Matcher unhelpful = FEEDBACK_UNHELPFUL.matcher(section); + while (unhelpful.find()) trust -= 0.2; + return Math.max(0.0, Math.min(1.0, trust)); + } + private String inferCategory(String filename) { if (filename.contains("user")) return "user_pref"; if (filename.contains("project")) return "project"; diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/projection/FactProjectionBuilder.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/projection/FactProjectionBuilder.java index b0b1ef96..f43bc25f 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/projection/FactProjectionBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/projection/FactProjectionBuilder.java @@ -119,8 +119,9 @@ public class FactProjectionBuilder { existing.setObjectValue(fact.objectValue()); existing.setConfidence(fact.confidence()); existing.setExtractedBy(fact.extractedBy()); - // Apply trust time decay (half-life from config, default 60 days) - existing.setTrust(applyTimeDecay(existing.getTrust(), existing.getUpdateTime(), now)); + // Trust derived from canonical feedback metadata, then time-decayed + double baseTrust = fact.trust(); + existing.setTrust(applyTimeDecay(baseTrust, existing.getUpdateTime(), now)); existing.setUpdateTime(now); existing.setDeleted(0); // un-delete if previously soft-deleted factMapper.updateById(existing); @@ -133,7 +134,7 @@ public class FactProjectionBuilder { entity.setPredicate(fact.predicate()); entity.setObjectValue(fact.objectValue()); entity.setConfidence(fact.confidence()); - entity.setTrust(0.5); + entity.setTrust(fact.trust()); entity.setUseCount(0); entity.setExtractedBy(fact.extractedBy()); entity.setCreateTime(now);