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);
BaseAgent agent = getOrBuildAgent(agentId);
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) {
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 &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,
Supplier<String> plainInvoke) {
java.util.function.BiFunction<String, String, String> 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 <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) {
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;
}
// ==================== 内部方法 ====================

View File

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

View File

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

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 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<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)
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";

View File

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