mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +08:00
fix(memory): isolate recall ledger by owner
This commit is contained in:
parent
c59b6b6448
commit
dfae7edbe0
@ -324,11 +324,11 @@ public class AgentService {
|
|||||||
*/
|
*/
|
||||||
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
public String chat(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
|
||||||
if (isDshAgent(agentId)) {
|
if (isDshAgent(agentId)) {
|
||||||
return collectChatResult(chatStructuredStream(agentId, message, conversationId,
|
return collectChatResult(chatStructuredStream(agentId, message, conversationId,
|
||||||
"", null, origin != null ? origin : ChatOrigin.EMPTY)).content();
|
"", null, origin != null ? origin : ChatOrigin.EMPTY)).content();
|
||||||
}
|
}
|
||||||
|
trackMemoryRecalls(agentId, message, origin);
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
try {
|
try {
|
||||||
@ -365,13 +365,13 @@ public class AgentService {
|
|||||||
|
|
||||||
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
public Flux<String> chatStream(Long agentId, String message, String conversationId, ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
|
||||||
if (isDshAgent(agentId)) {
|
if (isDshAgent(agentId)) {
|
||||||
return chatStructuredStream(agentId, message, conversationId, "", null,
|
return chatStructuredStream(agentId, message, conversationId, "", null,
|
||||||
origin != null ? origin : ChatOrigin.EMPTY)
|
origin != null ? origin : ChatOrigin.EMPTY)
|
||||||
.filter(delta -> delta.content() != null)
|
.filter(delta -> delta.content() != null)
|
||||||
.map(StreamDelta::content);
|
.map(StreamDelta::content);
|
||||||
}
|
}
|
||||||
|
trackMemoryRecalls(agentId, message, origin);
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
// Capture the origin into a request-scoped holder; cleared on Flux
|
// Capture the origin into a request-scoped holder; cleared on Flux
|
||||||
// termination so the next reactive subscriber doesn't inherit stale state.
|
// termination so the next reactive subscriber doesn't inherit stale state.
|
||||||
@ -408,7 +408,7 @@ public class AgentService {
|
|||||||
String requesterId, String thinkingLevel,
|
String requesterId, String thinkingLevel,
|
||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
memoryRecallTracker.trackRecalls(agentId, message);
|
trackMemoryRecalls(agentId, message, origin);
|
||||||
if (isDshAgent(agentId)) {
|
if (isDshAgent(agentId)) {
|
||||||
AgentEntity dshAgent = getAgent(agentId);
|
AgentEntity dshAgent = getAgent(agentId);
|
||||||
return withLifecycleFlux(agentId, message, conversationId,
|
return withLifecycleFlux(agentId, message, conversationId,
|
||||||
@ -468,7 +468,7 @@ public class AgentService {
|
|||||||
|
|
||||||
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
public String execute(Long agentId, String goal, String conversationId, ChatOrigin origin) {
|
||||||
clearAutoRecordedForNewTurn(conversationId);
|
clearAutoRecordedForNewTurn(conversationId);
|
||||||
memoryRecallTracker.trackRecalls(agentId, goal);
|
trackMemoryRecalls(agentId, goal, origin);
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
try {
|
try {
|
||||||
@ -495,7 +495,7 @@ public class AgentService {
|
|||||||
|
|
||||||
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
public String chatWithReplay(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload, ChatOrigin origin) {
|
String toolCallPayload, ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
trackMemoryRecalls(agentId, userMessage, origin);
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
ChatOriginHolder.set(origin != null ? origin : ChatOrigin.EMPTY);
|
||||||
try {
|
try {
|
||||||
@ -543,7 +543,7 @@ public class AgentService {
|
|||||||
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
public Flux<StreamDelta> chatWithReplayStream(Long agentId, String userMessage, String conversationId,
|
||||||
String toolCallPayload, String requesterId,
|
String toolCallPayload, String requesterId,
|
||||||
ChatOrigin origin) {
|
ChatOrigin origin) {
|
||||||
memoryRecallTracker.trackRecalls(agentId, userMessage);
|
trackMemoryRecalls(agentId, userMessage, origin);
|
||||||
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
BaseAgent agent = getOrBuildAgentForConversation(agentId, conversationId);
|
||||||
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
ChatOrigin captured = origin != null ? origin : ChatOrigin.EMPTY;
|
||||||
return Flux.defer(() -> {
|
return Flux.defer(() -> {
|
||||||
@ -755,6 +755,13 @@ public class AgentService {
|
|||||||
return "dsh".equalsIgnoreCase(entity.getRuntimeType());
|
return "dsh".equalsIgnoreCase(entity.getRuntimeType());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void trackMemoryRecalls(Long agentId, String message, ChatOrigin origin) {
|
||||||
|
String ownerKey = memoryProperties.isLifecycleMediatorEnabled()
|
||||||
|
? memoryOwnerResolver.resolve(origin != null ? origin : ChatOrigin.EMPTY)
|
||||||
|
: null;
|
||||||
|
memoryRecallTracker.trackRecalls(agentId, message, ownerKey);
|
||||||
|
}
|
||||||
|
|
||||||
private void validateDshConfiguration(AgentEntity agent) {
|
private void validateDshConfiguration(AgentEntity agent) {
|
||||||
if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return;
|
if (!"dsh".equalsIgnoreCase(agent.getRuntimeType())) return;
|
||||||
if (dshRuntimeService == null) {
|
if (dshRuntimeService == null) {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import vip.mate.memory.MemoryProperties;
|
import vip.mate.memory.MemoryProperties;
|
||||||
|
import vip.mate.memory.identity.MemoryScope;
|
||||||
import vip.mate.memory.model.MemoryRecallEntity;
|
import vip.mate.memory.model.MemoryRecallEntity;
|
||||||
import vip.mate.memory.repository.MemoryRecallMapper;
|
import vip.mate.memory.repository.MemoryRecallMapper;
|
||||||
|
|
||||||
@ -55,9 +56,21 @@ public class MemoryRecallService {
|
|||||||
* 记录一次文件召回
|
* 记录一次文件召回
|
||||||
*/
|
*/
|
||||||
public void recordRecall(Long agentId, String filename, String snippetText, String userQueryHash) {
|
public void recordRecall(Long agentId, String filename, String snippetText, String userQueryHash) {
|
||||||
|
recordRecall(agentId, filename, snippetText, userQueryHash, null, MemoryScope.TEAM);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owner-aware recall ledger write. Shared legacy rows keep a null owner key. */
|
||||||
|
public void recordRecall(Long agentId, String filename, String snippetText, String userQueryHash,
|
||||||
|
String ownerKey, String scope) {
|
||||||
if (agentId == null || filename == null || filename.isBlank()) {
|
if (agentId == null || filename == null || filename.isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
String effectiveScope = normalizeScope(scope);
|
||||||
|
String effectiveOwner = MemoryScope.PERSONAL.equals(effectiveScope) ? ownerKey : null;
|
||||||
|
if (MemoryScope.PERSONAL.equals(effectiveScope)
|
||||||
|
&& (effectiveOwner == null || effectiveOwner.isBlank())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 写库前硬截断:覆盖所有调用路径(含 trackActiveRetrieval 透传的外部 filename),
|
// 写库前硬截断:覆盖所有调用路径(含 trackActiveRetrieval 透传的外部 filename),
|
||||||
// 防 filename 突破 VARCHAR(256) 导致写入失败(#461)
|
// 防 filename 突破 VARCHAR(256) 导致写入失败(#461)
|
||||||
filename = truncateFilename(filename);
|
filename = truncateFilename(filename);
|
||||||
@ -67,12 +80,13 @@ public class MemoryRecallService {
|
|||||||
? snippetText.substring(0, 200)
|
? snippetText.substring(0, 200)
|
||||||
: snippetText;
|
: snippetText;
|
||||||
|
|
||||||
MemoryRecallEntity existing = recallMapper.selectOne(
|
LambdaQueryWrapper<MemoryRecallEntity> existingQuery = new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
|
||||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||||
.eq(MemoryRecallEntity::getFilename, filename)
|
.eq(MemoryRecallEntity::getFilename, filename)
|
||||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
.eq(MemoryRecallEntity::getScope, effectiveScope)
|
||||||
.last("LIMIT 1"));
|
.eq(MemoryRecallEntity::getDeleted, 0);
|
||||||
|
applyOwnerIdentity(existingQuery, effectiveOwner, effectiveScope);
|
||||||
|
MemoryRecallEntity existing = recallMapper.selectOne(existingQuery.last("LIMIT 1"));
|
||||||
|
|
||||||
LocalDateTime now = LocalDateTime.now();
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
|
||||||
@ -103,6 +117,8 @@ public class MemoryRecallService {
|
|||||||
entity.setLastRecalledAt(now);
|
entity.setLastRecalledAt(now);
|
||||||
entity.setPromoted(false);
|
entity.setPromoted(false);
|
||||||
entity.setScore(0.0);
|
entity.setScore(0.0);
|
||||||
|
entity.setOwnerKey(effectiveOwner);
|
||||||
|
entity.setScope(effectiveScope);
|
||||||
entity.setCreateTime(now);
|
entity.setCreateTime(now);
|
||||||
entity.setUpdateTime(now);
|
entity.setUpdateTime(now);
|
||||||
entity.setDeleted(0);
|
entity.setDeleted(0);
|
||||||
@ -115,12 +131,13 @@ public class MemoryRecallService {
|
|||||||
} catch (org.springframework.dao.DuplicateKeyException e) {
|
} catch (org.springframework.dao.DuplicateKeyException e) {
|
||||||
// 并发插入冲突,重新查询后更新(不递归,避免 StackOverflow)
|
// 并发插入冲突,重新查询后更新(不递归,避免 StackOverflow)
|
||||||
log.debug("[MemoryRecall] Concurrent insert for {}, falling back to update", filename);
|
log.debug("[MemoryRecall] Concurrent insert for {}, falling back to update", filename);
|
||||||
MemoryRecallEntity retry = recallMapper.selectOne(
|
LambdaQueryWrapper<MemoryRecallEntity> retryQuery = new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
|
||||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||||
.eq(MemoryRecallEntity::getFilename, filename)
|
.eq(MemoryRecallEntity::getFilename, filename)
|
||||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
.eq(MemoryRecallEntity::getScope, effectiveScope)
|
||||||
.last("LIMIT 1"));
|
.eq(MemoryRecallEntity::getDeleted, 0);
|
||||||
|
applyOwnerIdentity(retryQuery, effectiveOwner, effectiveScope);
|
||||||
|
MemoryRecallEntity retry = recallMapper.selectOne(retryQuery.last("LIMIT 1"));
|
||||||
if (retry != null) {
|
if (retry != null) {
|
||||||
retry.setRecallCount(retry.getRecallCount() + 1);
|
retry.setRecallCount(retry.getRecallCount() + 1);
|
||||||
retry.setDailyCount(retry.getDailyCount() + 1);
|
retry.setDailyCount(retry.getDailyCount() + 1);
|
||||||
@ -157,6 +174,9 @@ public class MemoryRecallService {
|
|||||||
return recallMapper.selectList(
|
return recallMapper.selectList(
|
||||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||||
|
// Current Dream writes shared MEMORY.md. Keep PERSONAL
|
||||||
|
// candidates out until consolidation itself is owner-aware.
|
||||||
|
.in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
|
||||||
.eq(MemoryRecallEntity::getPromoted, false)
|
.eq(MemoryRecallEntity::getPromoted, false)
|
||||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
.eq(MemoryRecallEntity::getDeleted, 0)
|
||||||
.orderByDesc(MemoryRecallEntity::getScore));
|
.orderByDesc(MemoryRecallEntity::getScore));
|
||||||
@ -280,10 +300,12 @@ public class MemoryRecallService {
|
|||||||
long total = recallMapper.selectCount(
|
long total = recallMapper.selectCount(
|
||||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||||
|
.in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
|
||||||
.eq(MemoryRecallEntity::getDeleted, 0));
|
.eq(MemoryRecallEntity::getDeleted, 0));
|
||||||
long promoted = recallMapper.selectCount(
|
long promoted = recallMapper.selectCount(
|
||||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||||
|
.in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
|
||||||
.eq(MemoryRecallEntity::getPromoted, true)
|
.eq(MemoryRecallEntity::getPromoted, true)
|
||||||
.eq(MemoryRecallEntity::getDeleted, 0));
|
.eq(MemoryRecallEntity::getDeleted, 0));
|
||||||
long pending = total - promoted;
|
long pending = total - promoted;
|
||||||
@ -307,6 +329,7 @@ public class MemoryRecallService {
|
|||||||
List<MemoryRecallEntity> candidates = recallMapper.selectList(
|
List<MemoryRecallEntity> candidates = recallMapper.selectList(
|
||||||
new LambdaQueryWrapper<MemoryRecallEntity>()
|
new LambdaQueryWrapper<MemoryRecallEntity>()
|
||||||
.eq(MemoryRecallEntity::getAgentId, agentId)
|
.eq(MemoryRecallEntity::getAgentId, agentId)
|
||||||
|
.in(MemoryRecallEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
|
||||||
.eq(MemoryRecallEntity::getDeleted, 0)
|
.eq(MemoryRecallEntity::getDeleted, 0)
|
||||||
.orderByDesc(MemoryRecallEntity::getScore));
|
.orderByDesc(MemoryRecallEntity::getScore));
|
||||||
|
|
||||||
@ -374,4 +397,23 @@ public class MemoryRecallService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String normalizeScope(String scope) {
|
||||||
|
if (MemoryScope.PERSONAL.equals(scope) || MemoryScope.GLOBAL.equals(scope)) {
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
return MemoryScope.TEAM;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyOwnerIdentity(LambdaQueryWrapper<MemoryRecallEntity> query,
|
||||||
|
String ownerKey, String scope) {
|
||||||
|
if (MemoryScope.PERSONAL.equals(scope)) {
|
||||||
|
query.eq(MemoryRecallEntity::getOwnerKey, ownerKey);
|
||||||
|
} else {
|
||||||
|
// V137 left legacy shared recall rows with NULL while newer rows may
|
||||||
|
// use the workspace-file empty-string sentinel. Treat both as shared.
|
||||||
|
query.and(w -> w.isNull(MemoryRecallEntity::getOwnerKey)
|
||||||
|
.or().eq(MemoryRecallEntity::getOwnerKey, ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.scheduling.annotation.Async;
|
import org.springframework.scheduling.annotation.Async;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.memory.identity.MemoryScope;
|
||||||
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
||||||
import vip.mate.workspace.document.repository.WorkspaceFileMapper;
|
import vip.mate.workspace.document.repository.WorkspaceFileMapper;
|
||||||
|
|
||||||
@ -44,15 +45,32 @@ public class MemoryRecallTracker {
|
|||||||
*/
|
*/
|
||||||
@Async
|
@Async
|
||||||
public void trackRecalls(Long agentId, String userQuery) {
|
public void trackRecalls(Long agentId, String userQuery) {
|
||||||
|
trackRecalls(agentId, userQuery, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track only the shared files plus PERSONAL files visible to {@code ownerKey}.
|
||||||
|
* The owner and scope are copied into the recall ledger so downstream Dream
|
||||||
|
* processing cannot collapse two owners' same-named files into one candidate.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void trackRecalls(Long agentId, String userQuery, String ownerKey) {
|
||||||
try {
|
try {
|
||||||
// 精确复现 buildSystemPrompt 的注入条件
|
LambdaQueryWrapper<WorkspaceFileEntity> query = new LambdaQueryWrapper<WorkspaceFileEntity>()
|
||||||
List<WorkspaceFileEntity> injectedFiles = workspaceFileMapper.selectList(
|
|
||||||
new LambdaQueryWrapper<WorkspaceFileEntity>()
|
|
||||||
.eq(WorkspaceFileEntity::getAgentId, agentId)
|
.eq(WorkspaceFileEntity::getAgentId, agentId)
|
||||||
.eq(WorkspaceFileEntity::getEnabled, true)
|
.eq(WorkspaceFileEntity::getEnabled, true)
|
||||||
.isNotNull(WorkspaceFileEntity::getContent)
|
.isNotNull(WorkspaceFileEntity::getContent)
|
||||||
.ne(WorkspaceFileEntity::getContent, "")
|
.ne(WorkspaceFileEntity::getContent, "");
|
||||||
.orderByAsc(WorkspaceFileEntity::getSortOrder));
|
if (ownerKey == null || ownerKey.isBlank()) {
|
||||||
|
query.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL);
|
||||||
|
} else {
|
||||||
|
query.and(w -> w
|
||||||
|
.in(WorkspaceFileEntity::getScope, MemoryScope.TEAM, MemoryScope.GLOBAL)
|
||||||
|
.or(p -> p.eq(WorkspaceFileEntity::getScope, MemoryScope.PERSONAL)
|
||||||
|
.eq(WorkspaceFileEntity::getOwnerKey, ownerKey)));
|
||||||
|
}
|
||||||
|
List<WorkspaceFileEntity> injectedFiles = workspaceFileMapper.selectList(
|
||||||
|
query.orderByAsc(WorkspaceFileEntity::getSortOrder));
|
||||||
|
|
||||||
if (injectedFiles.isEmpty()) {
|
if (injectedFiles.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
@ -62,6 +80,10 @@ public class MemoryRecallTracker {
|
|||||||
int trackedCount = 0;
|
int trackedCount = 0;
|
||||||
|
|
||||||
for (WorkspaceFileEntity file : injectedFiles) {
|
for (WorkspaceFileEntity file : injectedFiles) {
|
||||||
|
// Defence in depth for custom mappers/tests and future query refactors.
|
||||||
|
if (!isVisibleToOwner(file, ownerKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
String content = file.getContent();
|
String content = file.getContent();
|
||||||
if (content == null || content.isBlank()) {
|
if (content == null || content.isBlank()) {
|
||||||
continue;
|
continue;
|
||||||
@ -71,10 +93,12 @@ public class MemoryRecallTracker {
|
|||||||
|
|
||||||
if (filename.startsWith("memory/") && filename.endsWith(".md")) {
|
if (filename.startsWith("memory/") && filename.endsWith(".md")) {
|
||||||
// daily note: 按 ## 标题拆分为独立片段
|
// daily note: 按 ## 标题拆分为独立片段
|
||||||
trackedCount += trackDailyNoteSnippets(agentId, filename, content, queryHash);
|
trackedCount += trackDailyNoteSnippets(agentId, filename, content, queryHash,
|
||||||
|
file.getOwnerKey(), file.getScope());
|
||||||
} else {
|
} else {
|
||||||
// 非 daily note (PROFILE.md, MEMORY.md 等): 文件级追踪
|
// 非 daily note (PROFILE.md, MEMORY.md 等): 文件级追踪
|
||||||
recallService.recordRecall(agentId, filename, content, queryHash);
|
recallService.recordRecall(agentId, filename, content, queryHash,
|
||||||
|
file.getOwnerKey(), file.getScope());
|
||||||
trackedCount++;
|
trackedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,7 +112,8 @@ public class MemoryRecallTracker {
|
|||||||
/**
|
/**
|
||||||
* 将 daily note 按 ## 标题拆分为独立片段,分别追踪
|
* 将 daily note 按 ## 标题拆分为独立片段,分别追踪
|
||||||
*/
|
*/
|
||||||
private int trackDailyNoteSnippets(Long agentId, String filename, String content, String queryHash) {
|
private int trackDailyNoteSnippets(Long agentId, String filename, String content, String queryHash,
|
||||||
|
String ownerKey, String scope) {
|
||||||
Matcher matcher = SECTION_PATTERN.matcher(content);
|
Matcher matcher = SECTION_PATTERN.matcher(content);
|
||||||
List<Integer> sectionStarts = new java.util.ArrayList<>();
|
List<Integer> sectionStarts = new java.util.ArrayList<>();
|
||||||
while (matcher.find()) {
|
while (matcher.find()) {
|
||||||
@ -97,7 +122,7 @@ public class MemoryRecallTracker {
|
|||||||
|
|
||||||
if (sectionStarts.isEmpty()) {
|
if (sectionStarts.isEmpty()) {
|
||||||
// 没有 ## 标题,整个文件作为一个片段
|
// 没有 ## 标题,整个文件作为一个片段
|
||||||
recallService.recordRecall(agentId, filename, content.trim(), queryHash);
|
recallService.recordRecall(agentId, filename, content.trim(), queryHash, ownerKey, scope);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +131,7 @@ public class MemoryRecallTracker {
|
|||||||
if (sectionStarts.get(0) > 0) {
|
if (sectionStarts.get(0) > 0) {
|
||||||
String preamble = content.substring(0, sectionStarts.get(0)).trim();
|
String preamble = content.substring(0, sectionStarts.get(0)).trim();
|
||||||
if (!preamble.isEmpty()) {
|
if (!preamble.isEmpty()) {
|
||||||
recallService.recordRecall(agentId, filename + "#preamble", preamble, queryHash);
|
recallService.recordRecall(agentId, filename + "#preamble", preamble, queryHash, ownerKey, scope);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -119,7 +144,7 @@ public class MemoryRecallTracker {
|
|||||||
// 从 ## 标题行提取 section 标识
|
// 从 ## 标题行提取 section 标识
|
||||||
String firstLine = snippet.contains("\n") ? snippet.substring(0, snippet.indexOf('\n')).trim() : snippet;
|
String firstLine = snippet.contains("\n") ? snippet.substring(0, snippet.indexOf('\n')).trim() : snippet;
|
||||||
String sectionKey = filename + "#" + sanitizeSectionKey(firstLine);
|
String sectionKey = filename + "#" + sanitizeSectionKey(firstLine);
|
||||||
recallService.recordRecall(agentId, sectionKey, snippet, queryHash);
|
recallService.recordRecall(agentId, sectionKey, snippet, queryHash, ownerKey, scope);
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -149,17 +174,33 @@ public class MemoryRecallTracker {
|
|||||||
*/
|
*/
|
||||||
@Async
|
@Async
|
||||||
public void trackActiveRetrieval(Long agentId, String filename, String content) {
|
public void trackActiveRetrieval(Long agentId, String filename, String content) {
|
||||||
|
trackActiveRetrieval(agentId, filename, content, null, MemoryScope.TEAM);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void trackActiveRetrieval(Long agentId, String filename, String content,
|
||||||
|
String ownerKey, String scope) {
|
||||||
try {
|
try {
|
||||||
if (agentId == null || filename == null || content == null || content.isBlank()) {
|
if (agentId == null || filename == null || content == null || content.isBlank()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
recallService.recordRecall(agentId, filename, content, "__active_read__");
|
recallService.recordRecall(agentId, filename, content, "__active_read__", ownerKey, scope);
|
||||||
log.debug("[MemoryRecall] Tracked active retrieval: agent={}, file={}", agentId, filename);
|
log.debug("[MemoryRecall] Tracked active retrieval: agent={}, file={}", agentId, filename);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[MemoryRecall] Failed to track active retrieval for agent={}: {}", agentId, e.getMessage());
|
log.warn("[MemoryRecall] Failed to track active retrieval for agent={}: {}", agentId, e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static boolean isVisibleToOwner(WorkspaceFileEntity file, String ownerKey) {
|
||||||
|
String scope = file.getScope();
|
||||||
|
if (scope == null || scope.isBlank() || MemoryScope.TEAM.equals(scope) || MemoryScope.GLOBAL.equals(scope)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return MemoryScope.PERSONAL.equals(scope)
|
||||||
|
&& ownerKey != null && !ownerKey.isBlank()
|
||||||
|
&& ownerKey.equals(file.getOwnerKey());
|
||||||
|
}
|
||||||
|
|
||||||
private String sha256Short(String text) {
|
private String sha256Short(String text) {
|
||||||
if (text == null || text.isBlank()) return null;
|
if (text == null || text.isBlank()) return null;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -115,7 +115,8 @@ public class WorkspaceMemoryTool {
|
|||||||
|
|
||||||
// 追踪主动检索信号(比被动注入更强的"真实需要"指标)
|
// 追踪主动检索信号(比被动注入更强的"真实需要"指标)
|
||||||
String content = file.getContent() != null ? file.getContent() : "";
|
String content = file.getContent() != null ? file.getContent() : "";
|
||||||
memoryRecallTracker.trackActiveRetrieval(parsedAgentId, filename, content);
|
memoryRecallTracker.trackActiveRetrieval(parsedAgentId, filename, content,
|
||||||
|
file.getOwnerKey(), file.getScope());
|
||||||
|
|
||||||
JSONObject result = new JSONObject();
|
JSONObject result = new JSONObject();
|
||||||
result.set("agentId", String.valueOf(agentId));
|
result.set("agentId", String.valueOf(agentId));
|
||||||
@ -282,7 +283,8 @@ public class WorkspaceMemoryTool {
|
|||||||
// PERSONAL row when present) so PERSONAL hits track correctly.
|
// PERSONAL row when present) so PERSONAL hits track correctly.
|
||||||
WorkspaceFileEntity file = workspaceFileService.getVisibleFile(parsedAgentId, hit.filename(), ownerKey);
|
WorkspaceFileEntity file = workspaceFileService.getVisibleFile(parsedAgentId, hit.filename(), ownerKey);
|
||||||
if (file != null && file.getContent() != null) {
|
if (file != null && file.getContent() != null) {
|
||||||
memoryRecallTracker.trackActiveRetrieval(parsedAgentId, hit.filename(), file.getContent());
|
memoryRecallTracker.trackActiveRetrieval(parsedAgentId, hit.filename(), file.getContent(),
|
||||||
|
file.getOwnerKey(), file.getScope());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import vip.mate.workspace.conversation.repository.ConversationMapper;
|
|||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.ArgumentMatchers.isNull;
|
||||||
import static org.mockito.Mockito.lenient;
|
import static org.mockito.Mockito.lenient;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
@ -74,7 +75,7 @@ class LifecycleRecallCountIT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// trackRecalls: exactly 10 times (once per chat call)
|
// trackRecalls: exactly 10 times (once per chat call)
|
||||||
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
|
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any(), isNull());
|
||||||
|
|
||||||
// Mediator is not invoked when flag is off
|
// Mediator is not invoked when flag is off
|
||||||
verify(memoryManager, never()).prefetchAll(any(), any(), any());
|
verify(memoryManager, never()).prefetchAll(any(), any(), any());
|
||||||
@ -92,11 +93,12 @@ class LifecycleRecallCountIT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// trackRecalls: still exactly 10 times — NOT 20 (D4: mediator does not call trackRecalls)
|
// trackRecalls: still exactly 10 times — NOT 20 (D4: mediator does not call trackRecalls)
|
||||||
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
|
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any(), eq("system"));
|
||||||
|
|
||||||
// Mediator IS invoked
|
// Mediator IS invoked
|
||||||
verify(memoryManager, times(10)).prefetchAll(eq(1L), any(), any());
|
verify(memoryManager, times(10)).prefetchAll(eq(1L), any(), any());
|
||||||
verify(memoryManager, times(10)).syncAll(eq(1L), eq("conv-1"), any(), any());
|
verify(memoryManager, times(10)).syncAll(
|
||||||
|
eq(1L), eq("conv-1"), any(), any(), eq("system"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -116,7 +118,8 @@ class LifecycleRecallCountIT {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Total: 10 trackRecalls calls regardless of flag state
|
// Total: 10 trackRecalls calls regardless of flag state
|
||||||
verify(memoryRecallTracker, times(10)).trackRecalls(eq(1L), any());
|
verify(memoryRecallTracker, times(5)).trackRecalls(eq(1L), any(), isNull());
|
||||||
|
verify(memoryRecallTracker, times(5)).trackRecalls(eq(1L), any(), eq("system"));
|
||||||
|
|
||||||
// Mediator only called for the ON rounds
|
// Mediator only called for the ON rounds
|
||||||
verify(memoryManager, times(5)).prefetchAll(eq(1L), any(), any());
|
verify(memoryManager, times(5)).prefetchAll(eq(1L), any(), any());
|
||||||
|
|||||||
@ -0,0 +1,123 @@
|
|||||||
|
package vip.mate.memory.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import vip.mate.memory.MemoryProperties;
|
||||||
|
import vip.mate.memory.identity.MemoryScope;
|
||||||
|
import vip.mate.memory.model.MemoryRecallEntity;
|
||||||
|
import vip.mate.memory.repository.MemoryRecallMapper;
|
||||||
|
import vip.mate.workspace.document.model.WorkspaceFileEntity;
|
||||||
|
import vip.mate.workspace.document.repository.WorkspaceFileMapper;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
class MemoryRecallOwnerIsolationTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void initLambdaCache() {
|
||||||
|
TableInfoHelper.initTableInfo(
|
||||||
|
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
||||||
|
MemoryRecallEntity.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("tracker records shared and current-owner files but rejects another owner's row")
|
||||||
|
void trackerPropagatesOnlyVisibleOwnerIdentity() {
|
||||||
|
MemoryRecallService recallService = mock(MemoryRecallService.class);
|
||||||
|
WorkspaceFileMapper fileMapper = mock(WorkspaceFileMapper.class);
|
||||||
|
WorkspaceFileEntity shared = file("MEMORY.md", "shared", "", MemoryScope.TEAM);
|
||||||
|
WorkspaceFileEntity ownerA = file("PROFILE.md", "owner-a", "user:a", MemoryScope.PERSONAL);
|
||||||
|
WorkspaceFileEntity ownerB = file("PROFILE.md", "owner-b", "user:b", MemoryScope.PERSONAL);
|
||||||
|
when(fileMapper.selectList(any())).thenReturn(List.of(shared, ownerA, ownerB));
|
||||||
|
|
||||||
|
new MemoryRecallTracker(recallService, fileMapper)
|
||||||
|
.trackRecalls(7L, "what do you remember?", "user:a");
|
||||||
|
|
||||||
|
verify(recallService).recordRecall(eq(7L), eq("MEMORY.md"), eq("shared"),
|
||||||
|
anyString(), eq(""), eq(MemoryScope.TEAM));
|
||||||
|
verify(recallService).recordRecall(eq(7L), eq("PROFILE.md"), eq("owner-a"),
|
||||||
|
anyString(), eq("user:a"), eq(MemoryScope.PERSONAL));
|
||||||
|
verify(recallService, never()).recordRecall(eq(7L), eq("PROFILE.md"), eq("owner-b"),
|
||||||
|
anyString(), eq("user:b"), eq(MemoryScope.PERSONAL));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("owner-aware ledger insert persists PERSONAL scope and owner")
|
||||||
|
void recordRecallPersistsPersonalIdentity() {
|
||||||
|
MemoryRecallMapper mapper = mock(MemoryRecallMapper.class);
|
||||||
|
when(mapper.selectOne(any())).thenReturn(null);
|
||||||
|
MemoryRecallService service = new MemoryRecallService(mapper, new MemoryProperties(), new ObjectMapper());
|
||||||
|
|
||||||
|
service.recordRecall(7L, "PROFILE.md", "private preference", "query-hash",
|
||||||
|
"user:a", MemoryScope.PERSONAL);
|
||||||
|
|
||||||
|
ArgumentCaptor<MemoryRecallEntity> inserted = ArgumentCaptor.forClass(MemoryRecallEntity.class);
|
||||||
|
verify(mapper).insert(inserted.capture());
|
||||||
|
assertEquals("user:a", inserted.getValue().getOwnerKey());
|
||||||
|
assertEquals(MemoryScope.PERSONAL, inserted.getValue().getScope());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("legacy recordRecall overload remains shared and ownerless")
|
||||||
|
void legacyRecordRecallRemainsShared() {
|
||||||
|
MemoryRecallMapper mapper = mock(MemoryRecallMapper.class);
|
||||||
|
when(mapper.selectOne(any())).thenReturn(null);
|
||||||
|
MemoryRecallService service = new MemoryRecallService(mapper, new MemoryProperties(), new ObjectMapper());
|
||||||
|
|
||||||
|
service.recordRecall(7L, "MEMORY.md", "shared memory", "query-hash");
|
||||||
|
|
||||||
|
ArgumentCaptor<MemoryRecallEntity> inserted = ArgumentCaptor.forClass(MemoryRecallEntity.class);
|
||||||
|
verify(mapper).insert(inserted.capture());
|
||||||
|
assertNull(inserted.getValue().getOwnerKey());
|
||||||
|
assertEquals(MemoryScope.TEAM, inserted.getValue().getScope());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("shared Dream candidate query excludes PERSONAL scope")
|
||||||
|
@SuppressWarnings({"rawtypes", "unchecked"})
|
||||||
|
void dreamCandidatesStaySharedOnly() {
|
||||||
|
MemoryRecallMapper mapper = mock(MemoryRecallMapper.class);
|
||||||
|
when(mapper.selectList(any())).thenReturn(List.of());
|
||||||
|
MemoryRecallService service = new MemoryRecallService(mapper, new MemoryProperties(), new ObjectMapper());
|
||||||
|
|
||||||
|
service.listCandidates(7L);
|
||||||
|
|
||||||
|
ArgumentCaptor<LambdaQueryWrapper<MemoryRecallEntity>> query =
|
||||||
|
ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||||
|
verify(mapper).selectList(query.capture());
|
||||||
|
query.getValue().getSqlSegment();
|
||||||
|
String parameters = query.getValue().getParamNameValuePairs().values().toString();
|
||||||
|
assertTrue(parameters.contains(MemoryScope.TEAM));
|
||||||
|
assertTrue(parameters.contains(MemoryScope.GLOBAL));
|
||||||
|
assertFalse(parameters.contains(MemoryScope.PERSONAL));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkspaceFileEntity file(String filename, String content, String ownerKey, String scope) {
|
||||||
|
WorkspaceFileEntity file = new WorkspaceFileEntity();
|
||||||
|
file.setFilename(filename);
|
||||||
|
file.setContent(content);
|
||||||
|
file.setOwnerKey(ownerKey);
|
||||||
|
file.setScope(scope);
|
||||||
|
file.setEnabled(true);
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user