feat(memory): dream-v2 E2 — Fact query tools + FactMemoryProvider

This commit is contained in:
matevip 2026-04-21 04:58:52 +08:00
parent acc6f448db
commit d983a1e02e
5 changed files with 277 additions and 0 deletions

View File

@ -0,0 +1,80 @@
package vip.mate.memory.fact.provider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.fact.model.FactEntity;
import vip.mate.memory.fact.projection.FactProjectionBuilder;
import vip.mate.memory.fact.query.FactQueryService;
import vip.mate.memory.fact.tool.FactQueryTool;
import vip.mate.memory.spi.MemoryProvider;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* Read-only SPI provider that surfaces fact projection in the memory lifecycle.
* <p>
* - prefetch: returns relevant facts for the user query (injected into prompt)
* - syncTurn: bumps use_count for recalled facts
* - onMemoryWrite: triggers incremental projection rebuild
* - getToolBeans: exposes FactQueryTool to agents
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class FactMemoryProvider implements MemoryProvider {
private final FactQueryService queryService;
private final FactProjectionBuilder projectionBuilder;
private final FactQueryTool factQueryTool;
private final MemoryProperties properties;
@Override
public String id() { return "fact_store"; }
@Override
public int order() { return 200; }
@Override
public boolean isAvailable() {
return properties.getFact().isProjectionEnabled();
}
@Override
public String prefetch(Long agentId, String userQuery) {
if (!properties.getFact().isProjectionEnabled()) return "";
List<FactEntity> facts = queryService.recallRelevant(agentId, userQuery);
if (facts.isEmpty()) return "";
// Bump usage
queryService.bumpUseCount(facts.stream().map(FactEntity::getId).toList());
String block = facts.stream()
.map(f -> String.format("- %s %s %s", f.getSubject(), f.getPredicate(), f.getObjectValue()))
.collect(Collectors.joining("\n"));
return "<facts>\n" + block + "\n</facts>";
}
@Override
public void syncTurn(Long agentId, String conversationId, String userMessage, String assistantReply) {
// No-op bumpUseCount already called in prefetch
}
@Override
public void onMemoryWrite(Long agentId, String target, String action, String content) {
if (!properties.getFact().isProjectionEnabled()) return;
// Incremental rebuild for the changed file
projectionBuilder.rebuildOne(agentId, target, content);
}
@Override
public List<Object> getToolBeans() {
if (!properties.getFact().isProjectionEnabled()) return Collections.emptyList();
return List.of(factQueryTool);
}
}

View File

@ -0,0 +1,100 @@
package vip.mate.memory.fact.query;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import vip.mate.memory.fact.model.FactContradictionEntity;
import vip.mate.memory.fact.model.FactEntity;
import vip.mate.memory.fact.model.FactEntityRefEntity;
import vip.mate.memory.fact.repository.FactMapper;
import java.time.LocalDateTime;
import java.util.List;
/**
* Query service for the fact projection.
* Read-only + bumpUseCount (the only accumulated column writer).
*
* @author MateClaw Team
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class FactQueryService {
private final FactMapper factMapper;
private final vip.mate.memory.fact.repository.FactEntityRefMapper refMapper;
private final vip.mate.memory.fact.repository.FactContradictionMapper contradictionMapper;
/**
* Probe facts by entity name (subject or object match).
*/
public List<FactEntity> probe(Long agentId, String entity) {
return factMapper.selectList(
new LambdaQueryWrapper<FactEntity>()
.eq(FactEntity::getAgentId, agentId)
.eq(FactEntity::getDeleted, 0)
.and(w -> w.like(FactEntity::getSubject, entity)
.or().like(FactEntity::getObjectValue, entity))
.orderByDesc(FactEntity::getTrust)
.last("LIMIT 20"));
}
/**
* Find related facts via entity references (multi-hop).
*/
public List<FactEntity> related(Long agentId, String entity, int hops) {
// Find fact IDs that reference this entity
List<FactEntityRefEntity> refs = refMapper.selectList(
new LambdaQueryWrapper<FactEntityRefEntity>()
.like(FactEntityRefEntity::getEntityName, entity)
.last("LIMIT 50"));
List<Long> factIds = refs.stream().map(FactEntityRefEntity::getFactId).distinct().toList();
if (factIds.isEmpty()) return List.of();
return factMapper.selectList(
new LambdaQueryWrapper<FactEntity>()
.eq(FactEntity::getAgentId, agentId)
.eq(FactEntity::getDeleted, 0)
.in(FactEntity::getId, factIds)
.orderByDesc(FactEntity::getTrust)
.last("LIMIT 20"));
}
/**
* List unresolved contradictions for an agent.
*/
public List<FactContradictionEntity> listContradictions(Long agentId) {
return contradictionMapper.selectList(
new LambdaQueryWrapper<FactContradictionEntity>()
.eq(FactContradictionEntity::getAgentId, agentId)
.isNull(FactContradictionEntity::getResolution)
.eq(FactContradictionEntity::getDeleted, 0)
.orderByDesc(FactContradictionEntity::getCreateTime)
.last("LIMIT 50"));
}
/**
* Recall relevant facts for a query (used by FactMemoryProvider.prefetch).
*/
public List<FactEntity> recallRelevant(Long agentId, String query) {
return factMapper.selectList(
new LambdaQueryWrapper<FactEntity>()
.eq(FactEntity::getAgentId, agentId)
.eq(FactEntity::getDeleted, 0)
.and(w -> w.like(FactEntity::getSubject, query)
.or().like(FactEntity::getObjectValue, query)
.or().like(FactEntity::getPredicate, query))
.orderByDesc(FactEntity::getTrust)
.last("LIMIT 10"));
}
/**
* Bump use_count for fact IDs (the ONLY writer of accumulated columns).
*/
public void bumpUseCount(List<Long> ids) {
if (ids == null || ids.isEmpty()) return;
factMapper.bumpUseCount(ids, LocalDateTime.now());
}
}

View File

@ -0,0 +1,9 @@
package vip.mate.memory.fact.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.memory.fact.model.FactContradictionEntity;
@Mapper
public interface FactContradictionMapper extends BaseMapper<FactContradictionEntity> {
}

View File

@ -0,0 +1,9 @@
package vip.mate.memory.fact.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import vip.mate.memory.fact.model.FactEntityRefEntity;
@Mapper
public interface FactEntityRefMapper extends BaseMapper<FactEntityRefEntity> {
}

View File

@ -0,0 +1,79 @@
package vip.mate.memory.fact.tool;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import vip.mate.memory.MemoryProperties;
import vip.mate.memory.fact.model.FactContradictionEntity;
import vip.mate.memory.fact.model.FactEntity;
import vip.mate.memory.fact.query.FactQueryService;
import java.util.List;
import java.util.stream.Collectors;
/**
* Agent tools for querying the fact projection.
* Read-only no fact_add / fact_remove / fact_update tools (core invariant D1).
*
* @author MateClaw Team
*/
@Component
@RequiredArgsConstructor
public class FactQueryTool {
private final FactQueryService queryService;
private final MemoryProperties properties;
@Tool(description = "Probe facts about an entity. Returns relevant facts where the entity appears as subject or object.")
public String fact_probe(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Entity name to search for") String entity) {
if (!properties.getFact().isProjectionEnabled()) {
return "Fact projection is disabled.";
}
List<FactEntity> facts = queryService.probe(agentId, entity);
if (facts.isEmpty()) return "No facts found for entity: " + entity;
// Bump use count
queryService.bumpUseCount(facts.stream().map(FactEntity::getId).toList());
return facts.stream()
.map(f -> String.format("- %s %s %s (trust=%.2f)", f.getSubject(), f.getPredicate(), f.getObjectValue(), f.getTrust()))
.collect(Collectors.joining("\n"));
}
@Tool(description = "Find facts related to an entity via entity references (multi-hop graph query).")
public String fact_related(
@ToolParam(description = "Agent ID") Long agentId,
@ToolParam(description = "Entity name") String entity,
@ToolParam(description = "Number of hops (1-3)") int hops) {
if (!properties.getFact().isProjectionEnabled()) {
return "Fact projection is disabled.";
}
List<FactEntity> facts = queryService.related(agentId, entity, Math.min(hops, 3));
if (facts.isEmpty()) return "No related facts found for: " + entity;
queryService.bumpUseCount(facts.stream().map(FactEntity::getId).toList());
return facts.stream()
.map(f -> String.format("- %s %s %s (trust=%.2f)", f.getSubject(), f.getPredicate(), f.getObjectValue(), f.getTrust()))
.collect(Collectors.joining("\n"));
}
@Tool(description = "List unresolved fact contradictions detected during Dream consolidation.")
public String fact_list_contradictions(
@ToolParam(description = "Agent ID") Long agentId) {
if (!properties.getFact().isProjectionEnabled()) {
return "Fact projection is disabled.";
}
List<FactContradictionEntity> contradictions = queryService.listContradictions(agentId);
if (contradictions.isEmpty()) return "No unresolved contradictions.";
return contradictions.stream()
.map(c -> String.format("- Contradiction #%d: factA=%d vs factB=%d — %s",
c.getId(), c.getFactAId(), c.getFactBId(),
c.getDescription() != null ? c.getDescription() : ""))
.collect(Collectors.joining("\n"));
}
}