fix(memory): P1 review fixes — close 4 semantic gaps in data truth layer

This commit is contained in:
matevip 2026-04-21 17:34:10 +08:00
parent 13a3394ffd
commit 84c8f8f9a0
5 changed files with 89 additions and 40 deletions

View File

@ -101,14 +101,14 @@ public class AgentService {
memoryRecallTracker.trackRecalls(agentId, message); memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgent(agentId);
return withLifecycleSync(agentId, message, conversationId, return withLifecycleSync(agentId, message, conversationId,
() -> agent.chat(message, conversationId)); (msg, convId) -> agent.chat(msg, convId));
} }
public Flux<String> chatStream(Long agentId, String message, String conversationId) { public Flux<String> chatStream(Long agentId, String message, String conversationId) {
memoryRecallTracker.trackRecalls(agentId, message); memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgent(agentId);
return withLifecycleFlux(agentId, message, conversationId, return withLifecycleFlux(agentId, message, conversationId,
() -> agent.chatStream(message, conversationId), (msg, convId) -> agent.chatStream(msg, convId),
chunk -> chunk); chunk -> chunk);
} }
@ -141,7 +141,7 @@ public class AgentService {
if (agent instanceof StructuredStreamCapable capable) { if (agent instanceof StructuredStreamCapable capable) {
return withLifecycleFlux(agentId, message, conversationId, return withLifecycleFlux(agentId, message, conversationId,
() -> capable.chatStructuredStream(message, conversationId, (msg, convId) -> capable.chatStructuredStream(msg, convId,
requesterId != null ? requesterId : "") requesterId != null ? requesterId : "")
.doFinally(signal -> ThinkingLevelHolder.clear()), .doFinally(signal -> ThinkingLevelHolder.clear()),
StreamDelta::content); StreamDelta::content);
@ -150,7 +150,7 @@ public class AgentService {
// 降级不支持结构化流的 Agent包装为纯内容流 // 降级不支持结构化流的 Agent包装为纯内容流
ThinkingLevelHolder.clear(); ThinkingLevelHolder.clear();
return withLifecycleFlux(agentId, message, conversationId, return withLifecycleFlux(agentId, message, conversationId,
() -> agent.chatStream(message, conversationId) (msg, convId) -> agent.chatStream(msg, convId)
.map(chunk -> new StreamDelta(chunk, null)), .map(chunk -> new StreamDelta(chunk, null)),
StreamDelta::content); StreamDelta::content);
} }
@ -159,7 +159,7 @@ public class AgentService {
memoryRecallTracker.trackRecalls(agentId, goal); memoryRecallTracker.trackRecalls(agentId, goal);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgent(agentId);
return withLifecycleSync(agentId, goal, conversationId, 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); memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgent(agentId);
return withLifecycleSync(agentId, userMessage, conversationId, 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); memoryRecallTracker.trackRecalls(agentId, userMessage);
BaseAgent agent = getOrBuildAgent(agentId); BaseAgent agent = getOrBuildAgent(agentId);
return withLifecycleFlux(agentId, userMessage, conversationId, return withLifecycleFlux(agentId, userMessage, conversationId,
() -> agent.chatWithReplayStream(userMessage, conversationId, toolCallPayload, (msg, convId) -> agent.chatWithReplayStream(msg, convId, toolCallPayload,
requesterId != null ? requesterId : ""), requesterId != null ? requesterId : ""),
StreamDelta::content); StreamDelta::content);
} }
@ -231,15 +231,20 @@ public class AgentService {
/** /**
* Wraps a synchronous agent call with lifecycle mediator hooks. * Wraps a synchronous agent call with lifecycle mediator hooks.
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior). * When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior).
*
* P1-1 fix: prefetchAll result is now prepended to userMessage as &lt;memory-context&gt; block.
* P1-4 fix: N/A for sync (no cancel/error signal issue).
*/ */
private String withLifecycleSync(Long agentId, String message, String conversationId, private String withLifecycleSync(Long agentId, String message, String conversationId,
Supplier<String> plainInvoke) { java.util.function.BiFunction<String, String, String> invoke) {
if (!memoryProperties.isLifecycleMediatorEnabled()) { if (!memoryProperties.isLifecycleMediatorEnabled()) {
return plainInvoke.get(); return invoke.apply(message, conversationId);
} }
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message); TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message);
lifecycleMediator.beforeLlmCall(ctx); String memoryContext = lifecycleMediator.beforeLlmCall(ctx);
String result = plainInvoke.get(); // 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 : ""); lifecycleMediator.afterLlmCall(ctx, result != null ? result : "");
return result; return result;
} }
@ -247,24 +252,38 @@ public class AgentService {
/** /**
* Wraps a streaming agent call with lifecycle mediator hooks. * Wraps a streaming agent call with lifecycle mediator hooks.
* When lifecycleMediatorEnabled is off, runs plainInvoke directly (Phase 0 behavior). * 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 <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId, private <T> Flux<T> withLifecycleFlux(Long agentId, String message, String conversationId,
Supplier<Flux<T>> plainInvoke, java.util.function.BiFunction<String, String, Flux<T>> invoke,
Function<T, String> contentExtractor) { Function<T, String> contentExtractor) {
if (!memoryProperties.isLifecycleMediatorEnabled()) { if (!memoryProperties.isLifecycleMediatorEnabled()) {
return plainInvoke.get(); return invoke.apply(message, conversationId);
} }
TurnContext ctx = new TurnContext(agentId, conversationId, conversationId, 0, message); 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(); StringBuilder reply = new StringBuilder();
return plainInvoke.get() return invoke.apply(enrichedMessage, conversationId)
.doOnNext(item -> { .doOnNext(item -> {
String text = contentExtractor.apply(item); String text = contentExtractor.apply(item);
if (text != null) { if (text != null) {
reply.append(text); 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;
} }
// ==================== 内部方法 ==================== // ==================== 内部方法 ====================

View File

@ -100,17 +100,14 @@ public class FactController {
content = content + "\n" + marker + "\n"; content = content + "\n" + marker + "\n";
} }
workspaceFileService.saveFile(agentId, filename, content); 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 // Do NOT directly write mate_fact let projection rebuild handle visibility.
// because FactProjectionBuilder skips sections with Forgotten: metadata // The rebuild will either skip the Forgotten section (removing the fact)
projectionBuilder.rebuildOne(agentId, filename, // or soft-delete it via deleteByAgentIdAndSourceRefNotIn.
file != null ? file.getContent() : "");
// Soft-delete the fact immediately (rebuild will also handle it)
fact.setDeleted(1);
fact.setUpdateTime(LocalDateTime.now());
factMapper.updateById(fact);
log.info("[Fact] Forgotten fact {} for agent={} by {}", factId, agentId, userId); log.info("[Fact] Forgotten fact {} for agent={} by {}", factId, agentId, userId);
return R.ok(null); return R.ok(null);
@ -132,24 +129,35 @@ public class FactController {
return R.fail("Fact not found"); 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 sourceRef = fact.getSourceRef();
String[] parts = sourceRef.split("#", 2); String[] parts = sourceRef.split("#", 2);
String filename = parts[0]; String filename = parts[0];
String sectionKey = parts.length > 1 ? parts[1] : null;
WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename); WorkspaceFileEntity file = workspaceFileService.getFile(agentId, filename);
if (file != null && file.getContent() != null) { if (file != null && file.getContent() != null) {
String marker = "> UserFeedback: " + kind + " " + LocalDate.now(); 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 // Do NOT directly write mate_fact.trust let projection rebuild derive it from canonical metadata.
double delta = kind.equals("HELPFUL") ? 0.1 : -0.2; log.info("[Fact] Feedback {} on fact {} for agent={}", kind, factId, agentId);
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);
return R.ok(null); return R.ok(null);
} }

View File

@ -3,6 +3,8 @@ package vip.mate.memory.fact.extraction;
/** /**
* A single fact extracted from canonical memory content. * 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 * @author MateClaw Team
*/ */
public record ExtractedFact( public record ExtractedFact(
@ -12,5 +14,6 @@ public record ExtractedFact(
String predicate, String predicate,
String objectValue, String objectValue,
double confidence, double confidence,
double trust,
String extractedBy String extractedBy
) {} ) {}

View File

@ -23,6 +23,8 @@ public class PatternEntityExtractor implements EntityExtractor {
private static final Pattern SECTION_HEADER = Pattern.compile("^## (.+)$", Pattern.MULTILINE); 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 KV_BULLET = Pattern.compile("^- \\*\\*(.+?)\\*\\*:\\s*(.+)$", Pattern.MULTILINE);
private static final Pattern FORGOTTEN_MARKER = Pattern.compile("^> Forgotten:", 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 @Override
public List<ExtractedFact> extract(Long agentId, String filename, String content) { public List<ExtractedFact> 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) // Skip forgotten sections (rfc-038 L3: projection excludes Forgotten metadata)
if (FORGOTTEN_MARKER.matcher(section).find()) continue; 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 // Extract key-value bullets: - **key**: value
Matcher kvMatcher = KV_BULLET.matcher(section); Matcher kvMatcher = KV_BULLET.matcher(section);
while (kvMatcher.find()) { while (kvMatcher.find()) {
@ -45,7 +50,7 @@ public class PatternEntityExtractor implements EntityExtractor {
String value = kvMatcher.group(2).trim(); String value = kvMatcher.group(2).trim();
if (value.isBlank() || value.equals(":")) continue; if (value.isBlank() || value.equals(":")) continue;
String sourceRef = filename + "#" + toSlug(key); 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 // 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(); String firstLine = body.split("\n")[0].replaceAll("^[-*>]+\\s*", "").trim();
if (firstLine.length() >= 5) { 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; 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) { private String inferCategory(String filename) {
if (filename.contains("user")) return "user_pref"; if (filename.contains("user")) return "user_pref";
if (filename.contains("project")) return "project"; if (filename.contains("project")) return "project";

View File

@ -119,8 +119,9 @@ public class FactProjectionBuilder {
existing.setObjectValue(fact.objectValue()); existing.setObjectValue(fact.objectValue());
existing.setConfidence(fact.confidence()); existing.setConfidence(fact.confidence());
existing.setExtractedBy(fact.extractedBy()); existing.setExtractedBy(fact.extractedBy());
// Apply trust time decay (half-life from config, default 60 days) // Trust derived from canonical feedback metadata, then time-decayed
existing.setTrust(applyTimeDecay(existing.getTrust(), existing.getUpdateTime(), now)); double baseTrust = fact.trust();
existing.setTrust(applyTimeDecay(baseTrust, existing.getUpdateTime(), now));
existing.setUpdateTime(now); existing.setUpdateTime(now);
existing.setDeleted(0); // un-delete if previously soft-deleted existing.setDeleted(0); // un-delete if previously soft-deleted
factMapper.updateById(existing); factMapper.updateById(existing);
@ -133,7 +134,7 @@ public class FactProjectionBuilder {
entity.setPredicate(fact.predicate()); entity.setPredicate(fact.predicate());
entity.setObjectValue(fact.objectValue()); entity.setObjectValue(fact.objectValue());
entity.setConfidence(fact.confidence()); entity.setConfidence(fact.confidence());
entity.setTrust(0.5); entity.setTrust(fact.trust());
entity.setUseCount(0); entity.setUseCount(0);
entity.setExtractedBy(fact.extractedBy()); entity.setExtractedBy(fact.extractedBy());
entity.setCreateTime(now); entity.setCreateTime(now);