feat(personal-assistant): add confirmed project memory

This commit is contained in:
2026-07-19 08:19:24 +08:00
parent 151cc26caf
commit 24da833fa6
31 changed files with 4037 additions and 60 deletions
@@ -1,6 +1,7 @@
package org.dromara.aihr.knowledge.domain;
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import java.util.List;
@@ -32,8 +33,17 @@ public final class AihrKnowledgeQueryDto {
String docId,
String title,
String snippet,
Long fragmentId
Long fragmentId,
String domain,
String status,
String occurredAt,
String updatedAt
) {
public Citation(String spaceCode, String sourceType, String docId, String title, String snippet,
Long fragmentId) {
this(spaceCode, sourceType, docId, title, snippet, fragmentId,
"DOCUMENT".equals(sourceType) ? "ENTERPRISE" : sourceType, null, null, null);
}
}
public record Resource(
@@ -58,13 +68,28 @@ public final class AihrKnowledgeQueryDto {
Long contextVersion,
String intent,
String rewrittenQuery,
List<Resource> resources
List<Resource> resources,
MemoryCandidateResponse memoryCandidate
) {
public QueryResponse(String requestId, String queryText, String answer, List<Citation> citations,
List<String> usedSpaceCodes, boolean noEvidence, String promptVersion,
SearchResponse legacy, Object data, String conversationId, Long contextVersion,
String intent, String rewrittenQuery, List<Resource> resources) {
this(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence, promptVersion,
legacy, data, conversationId, contextVersion, intent, rewrittenQuery, resources, null);
}
public QueryResponse(String requestId, String queryText, String answer, List<Citation> citations,
List<String> usedSpaceCodes, boolean noEvidence, String promptVersion,
SearchResponse legacy, Object data) {
this(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence, promptVersion,
legacy, data, null, null, "QA", queryText, List.of());
legacy, data, null, null, "QA", queryText, List.of(), null);
}
public QueryResponse withMemoryCandidate(MemoryCandidateResponse candidate) {
return new QueryResponse(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence,
promptVersion, legacy, data, conversationId, contextVersion, intent, rewrittenQuery,
resources, candidate);
}
}
}
@@ -37,6 +37,27 @@ public class AihrKnowledgeQueryAuditService {
}
}
/** Updates an earlier document audit or inserts the final fused-source audit when no document query ran. */
public void recordFinal(String requestId, AihrKnowledgePrincipal principal, AuthenticatedApp app, String question,
List<String> spaceCodes, List<String> sourceTypes, String status, long latencyMs,
String promptVersion) {
try {
int updated = jdbcTemplate.update("""
update aihr_knowledge_query_log
set used_space_codes_json = ?, source_types_json = ?, status = ?,
latency_ms = greatest(latency_ms, ?), prompt_version = ?
where request_id = ? and tenant_id = ? and app_id = ?
""", json(spaceCodes), json(sourceTypes), status, latencyMs, promptVersion,
requestId, app.tenantId(), app.id());
if (updated == 0) {
record(requestId, principal, app, question, spaceCodes, sourceTypes, status, latencyMs,
promptVersion);
}
} catch (DataAccessException ex) {
log.warn("knowledge query final audit write failed for request {}", requestId);
}
}
private String json(List<String> values) {
try {
return objectMapper.writeValueAsString(values == null ? List.of() : values);
@@ -12,6 +12,9 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Resource;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
import org.dromara.aihr.memory.AihrMemoryService;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
@@ -40,23 +43,64 @@ public class AihrKnowledgeQueryService {
private final JdbcTemplate jdbcTemplate;
private final AihrKnowledgeDataToolService dataToolService;
private final AihrKnowledgeConversationService conversationService;
private final AihrMemoryService memoryService;
public QueryResponse queryInternal(QueryRequest rawRequest) {
long queryStarted = System.nanoTime();
QueryRequest request = validate(rawRequest);
AihrKnowledgePrincipal principal = principalResolver.current();
AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey());
ConversationContext context = conversationContext(principal, app, request);
QueryRequest routed = routedRequest(request, context);
Set<Long> spaceIds = Set.of();
QueryResponse response;
ServiceMemoryRecall memoryRecall = ServiceMemoryRecall.empty();
boolean finalizeAudit = false;
QueryResponse response = null;
if (hasText(routed.toolCode())) {
response = queryDataTool(principal, app, routed, request.queryText());
} else {
spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
response = queryDocuments(principal, app, spaceIds, routed, request.queryText());
memoryRecall = "QA".equals(context.intent())
? memoryService.recall(principal, routed.queryText(), routed.limit())
: ServiceMemoryRecall.empty();
if (memoryRecall == null) {
memoryRecall = ServiceMemoryRecall.empty();
}
boolean candidateIntent = "QA".equals(context.intent())
&& memoryService.isProjectMemoryCandidateIntent(principal, request.queryText());
try {
spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
} catch (ServiceException ex) {
if (ex.getCode() != null && ex.getCode() == HttpStatus.FORBIDDEN
&& request.spaceCodes().isEmpty()
&& (!memoryRecall.items().isEmpty() || candidateIntent)) {
response = emptyProjectResponse(request.queryText());
finalizeAudit = true;
} else {
throw ex;
}
spaceIds = Set.of();
}
if (response == null) {
response = queryDocuments(principal, app, spaceIds, routed, request.queryText());
}
if (!memoryRecall.items().isEmpty()) {
response = mergeServiceMemories(memoryRecall, response);
finalizeAudit = true;
}
}
return complete(principal, app, spaceIds, request.queryText(), context, response);
QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response);
MemoryCandidateResponse candidate = !"QA".equals(context.intent()) || hasText(routed.toolCode()) ? null
: memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), completed.requestId());
QueryResponse result = completed.withMemoryCandidate(candidate);
if (finalizeAudit || candidate != null) {
List<String> sourceTypes = finalSourceTypes(result, memoryRecall, candidate);
auditService.recordFinal(
result.requestId(), principal, app, request.queryText(), result.usedSpaceCodes(), sourceTypes,
result.noEvidence() && candidate == null ? "NO_EVIDENCE" : "SUCCESS",
elapsedMillis(queryStarted), result.promptVersion());
}
return result;
}
public QueryResponse queryInternalMedia(QueryRequest rawRequest, MultipartFile file) {
@@ -184,6 +228,49 @@ public class AihrKnowledgeQueryService {
);
}
private QueryResponse mergeServiceMemories(ServiceMemoryRecall recall, QueryResponse response) {
List<Citation> citations = new ArrayList<>(response.citations());
recall.items().forEach(item -> citations.add(new Citation(
PROJECT_SERVICE, PROJECT_SERVICE, "service-memory:" + item.id(), item.title(), item.summary(), null,
PROJECT_SERVICE, item.status(), item.occurredAt(), item.updatedAt()
)));
LinkedHashSet<String> usedScopes = new LinkedHashSet<>(response.usedSpaceCodes());
usedScopes.add(PROJECT_SERVICE);
String answer = response.noEvidence() || response.citations().isEmpty()
? recall.answer()
: response.answer() + "\n\n" + recall.answer();
return new QueryResponse(
response.requestId(), response.queryText(), answer, List.copyOf(citations), List.copyOf(usedScopes),
false, response.promptVersion(), response.legacy(), response.data(), response.conversationId(),
response.contextVersion(), response.intent(), response.rewrittenQuery(), response.resources(),
response.memoryCandidate()
);
}
private static QueryResponse emptyProjectResponse(String queryText) {
return new QueryResponse(
UUID.randomUUID().toString().replace("-", ""), queryText, "", List.of(), List.of(),
true, "service-memory-v1", null, null
);
}
private static List<String> finalSourceTypes(QueryResponse response, ServiceMemoryRecall recall,
MemoryCandidateResponse candidate) {
LinkedHashSet<String> sourceTypes = new LinkedHashSet<>();
boolean hasDocument = response.citations().stream()
.anyMatch(citation -> !PROJECT_SERVICE.equals(citation.domain()));
if (hasDocument) {
sourceTypes.add("DOCUMENT");
}
if (recall != null && !recall.items().isEmpty()) {
sourceTypes.add(PROJECT_SERVICE);
}
if (candidate != null) {
sourceTypes.add("MEMORY_CANDIDATE");
}
return List.copyOf(sourceTypes);
}
private List<Resource> resources(String tenantId, Set<Long> spaceIds, List<Citation> citations, String intent) {
if (!isResourceIntent(intent) || spaceIds.isEmpty() || citations == null || citations.isEmpty()) {
return List.of();
@@ -337,6 +424,8 @@ public class AihrKnowledgeQueryService {
|| value.endsWith(".mkv") || value.endsWith(".webm") || value.endsWith(".m4v");
}
private static final String PROJECT_SERVICE = "PROJECT_SERVICE";
private record AttachmentResource(Long attachmentId, String title) {
}
}
@@ -0,0 +1,45 @@
package org.dromara.aihr.memory;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/aihr/personal-assistant")
public class AihrMemoryController {
private final AihrMemoryService memoryService;
@SaCheckLogin
@GetMapping("/memory-candidates")
public R<List<MemoryCandidateResponse>> candidates(
@RequestParam(value = "status", defaultValue = "DRAFT") String status
) {
return R.ok(memoryService.candidates(status));
}
@SaCheckLogin
@PostMapping("/memory-candidates/{id}/confirm")
public R<ConfirmMemoryResponse> confirm(@PathVariable Long id, @RequestBody ConfirmMemoryRequest request) {
return R.ok(memoryService.confirm(id, request));
}
@SaCheckLogin
@PostMapping("/memory-candidates/{id}/dismiss")
public R<MemoryCandidateResponse> dismiss(@PathVariable Long id) {
return R.ok(memoryService.dismiss(id));
}
}
@@ -0,0 +1,125 @@
package org.dromara.aihr.memory;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class AihrMemoryDto {
private AihrMemoryDto() {
}
public record MemoryDraft(
String projectCode,
String buildingName,
String unitName,
String roomNo,
String category,
String title,
String summary,
String detail,
String status,
String occurredAt,
String followUpAt
) {
}
public record MemoryCandidateResponse(
Long id,
Integer version,
String status,
String memoryType,
String targetDomain,
MemoryDraft draft,
List<String> missingFields,
String expiresAt
) {
public MemoryCandidateResponse {
missingFields = missingFields == null ? List.of() : List.copyOf(missingFields);
}
}
public record ConfirmMemoryRequest(
Long expectedVersion,
String idempotencyKey,
MemoryDraft draft,
Boolean enableReminder
) {
}
public record ConfirmMemoryResponse(
String targetDomain,
Long targetId,
Integer version
) {
}
public record ServiceMemoryResponse(
Long id,
String projectCode,
String memoryType,
String subjectType,
String buildingName,
String unitName,
String roomNo,
String category,
String title,
String summary,
String detail,
String status,
String occurredAt,
String followUpAt,
String sourceConversationId,
String sourceRequestId,
Long createdBy,
Long confirmedBy,
Integer version,
String createdAt,
String updatedAt,
List<ServiceMemoryVersionResponse> versions
) {
public ServiceMemoryResponse {
versions = versions == null ? List.of() : List.copyOf(versions);
}
}
public record ServiceMemoryVersionResponse(
Integer version,
Map<String, Object> snapshot,
String changeType,
String changeReason,
Long operatorUserId,
String createdAt
) {
public ServiceMemoryVersionResponse {
snapshot = snapshot == null ? Map.of()
: Collections.unmodifiableMap(new LinkedHashMap<>(snapshot));
}
}
public record ServiceMemoryRecall(
List<ServiceMemoryRecallItem> items,
String answer
) {
public ServiceMemoryRecall {
items = items == null ? List.of() : List.copyOf(items);
answer = answer == null ? "" : answer;
}
public static ServiceMemoryRecall empty() {
return new ServiceMemoryRecall(List.of(), "");
}
}
public record ServiceMemoryRecallItem(
Long id,
String projectCode,
String title,
String summary,
String status,
String occurredAt,
String updatedAt
) {
}
}
@@ -0,0 +1,43 @@
package org.dromara.aihr.memory;
import cn.dev33.satoken.annotation.SaCheckLogin;
import lombok.RequiredArgsConstructor;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryResponse;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/aihr/service-memories")
public class AihrServiceMemoryController {
private final AihrMemoryService memoryService;
@SaCheckLogin
@GetMapping
public R<List<ServiceMemoryResponse>> list(
@RequestParam(value = "projectCode", required = false) String projectCode,
@RequestParam(value = "status", required = false) String status,
@RequestParam(value = "dateFrom", required = false) String dateFrom,
@RequestParam(value = "dateTo", required = false) String dateTo,
@RequestParam(value = "buildingName", required = false) String buildingName,
@RequestParam(value = "unitName", required = false) String unitName,
@RequestParam(value = "roomNo", required = false) String roomNo,
@RequestParam(value = "limit", defaultValue = "50") Integer limit
) {
return R.ok(memoryService.serviceMemories(
projectCode, status, dateFrom, dateTo, buildingName, unitName, roomNo, limit));
}
@SaCheckLogin
@GetMapping("/{id}")
public R<ServiceMemoryResponse> detail(@PathVariable Long id) {
return R.ok(memoryService.serviceMemory(id));
}
}
@@ -14,6 +14,11 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext;
import org.dromara.aihr.memory.AihrMemoryService;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecallItem;
import org.dromara.aihr.service.AihrSopSeedService;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
@@ -29,7 +34,10 @@ import java.sql.ResultSet;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -108,7 +116,8 @@ class AihrKnowledgeQueryServiceTest {
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(mock(AihrKnowledgePrincipalResolver.class),
mock(AihrKnowledgeAppService.class), access, sop, mock(AihrKnowledgeQueryAuditService.class), jdbc,
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class),
mock(AihrMemoryService.class));
var result = service.queryExternal(app,
new QueryRequest("公开服务", List.of("mt_customer_service"), null, null, "miniapp", 5, null));
@@ -129,7 +138,7 @@ class AihrKnowledgeQueryServiceTest {
when(tools.execute(principal, "MY_PRACTICE_SUMMARY")).thenThrow(new ServiceException("tool failed", 500));
var service = new AihrKnowledgeQueryService(resolver, appService, mock(AihrKnowledgeAccessService.class), sop,
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), tools,
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
assertThrows(ServiceException.class, () -> service.queryInternal(
new QueryRequest("我的训练", List.of(), null, null, "mobile", 5, "MY_PRACTICE_SUMMARY")));
@@ -157,7 +166,7 @@ class AihrKnowledgeQueryServiceTest {
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
var result = service.queryInternalMedia(
new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null),
@@ -213,7 +222,8 @@ class AihrKnowledgeQueryServiceTest {
return List.of(mapper.mapRow(rs, 0));
});
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations);
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations,
mock(AihrMemoryService.class));
var result = service.queryInternal(request);
@@ -227,6 +237,198 @@ class AihrKnowledgeQueryServiceTest {
assertEquals("/api/knowledge/resources/321/content", result.resources().get(0).contentUrl());
}
@Test
void unifiedQueryReturnsMemoryCandidateAtResponseRoot() throws Exception {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var draft = new MemoryDraft("P1", "3栋", "2单元", "1201", "住户需求", "养老咨询",
"住户希望了解养老服务", "", "OPEN", "2026-07-19T08:30:00", null);
var candidate = new MemoryCandidateResponse(
31L, 1, "DRAFT", "RESIDENT_NEED", "PROJECT_SERVICE", draft, List.of(),
"2026-07-20T08:30:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))).thenReturn(List.of("sop"));
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
new SearchResponse("住户需求", "sop", "当前资料中没有足够依据", "", List.of(), List.of(),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty());
when(memory.detectForQuery(eq(principal), anyString(), any(), anyString())).thenReturn(candidate);
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
audit, jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"记一下,3栋2单元1201室住户希望了解养老服务", List.of(), "sop", null, "mobile", 5, null));
assertEquals(candidate, result.memoryCandidate());
assertTrue(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(result)
.contains("\"memoryCandidate\""));
verify(memory).detectForQuery(eq(principal), eq("记一下,3栋2单元1201室住户希望了解养老服务"),
any(), eq(result.requestId()));
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(), eq(List.of("sop")),
eq(List.of("MEMORY_CANDIDATE")), eq("SUCCESS"), anyLong(), eq("test-v1"));
}
@Test
void firstCandidateCanBeCreatedWithoutEnterpriseKnowledgeSpace() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var draft = new MemoryDraft("P1", "3栋", "2单元", "1201", "住户需求", "养老咨询",
"住户希望了解养老服务", "", "OPEN", "2026-07-19T08:30:00", null);
var candidate = new MemoryCandidateResponse(
31L, 1, "DRAFT", "RESIDENT_NEED", "PROJECT_SERVICE", draft, List.of(),
"2026-07-20T08:30:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ"))
.thenThrow(new ServiceException("no scope", 403));
when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty());
when(memory.isProjectMemoryCandidateIntent(principal,
"记一下,3栋2单元1201室住户希望了解养老服务")).thenReturn(true);
when(memory.detectForQuery(eq(principal), anyString(), any(), anyString())).thenReturn(candidate);
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, audit,
mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"记一下,3栋2单元1201室住户希望了解养老服务", List.of(), "sop", null, "mobile", 5, null));
assertEquals(candidate, result.memoryCandidate());
verify(sop, never()).searchAuthorized(any(), any(), any());
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(), eq(List.of()),
eq(List.of("MEMORY_CANDIDATE")), eq("SUCCESS"), anyLong(), eq("service-memory-v1"));
}
@Test
void explicitUnauthorizedKnowledgeSpaceStillRejectsCandidateIntent() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var memory = mock(AihrMemoryService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of("private-space"), "READ"))
.thenThrow(new ServiceException("no scope", 403));
when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty());
when(memory.isProjectMemoryCandidateIntent(any(), anyString())).thenReturn(true);
var service = new AihrKnowledgeQueryService(resolver, appService, access,
mock(AihrSopSeedService.class), mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class),
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), memory);
assertThrows(ServiceException.class, () -> service.queryInternal(new QueryRequest(
"记一下,3栋2单元1201室住户希望了解养老服务", List.of("private-space"),
"sop", null, "mobile", 5, null)));
verify(memory, never()).detectForQuery(any(), anyString(), any(), anyString());
}
@Test
void confirmedProjectMemoryIsRecalledWithCurrentStatusAndProjectCitation() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))).thenReturn(List.of("sop"));
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
new SearchResponse("跟进", "sop", "按照养老咨询 SOP 进行回访", "", List.of(),
List.of(new SnippetResponse("养老咨询 SOP", "记录需求并安排回访", 88L)),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
RowMapper<?> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getString("space_code")).thenReturn("sop");
when(rs.getString("doc_id")).thenReturn("elder-care-sop");
when(rs.getString("title")).thenReturn("养老咨询 SOP");
when(rs.getString("content")).thenReturn("记录需求并安排回访");
when(rs.getLong("fragment_id")).thenReturn(88L);
return List.of(mapper.mapRow(rs, 0));
});
var item = new ServiceMemoryRecallItem(
501L, "P1", "3栋2单元1201跟进", "明天回访养老服务需求", "IN_PROGRESS",
"2026-07-19T08:30:00", "2026-07-19T09:00:00");
when(memory.recall(eq(principal), anyString(), any())).thenReturn(
new ServiceMemoryRecall(List.of(item), "已找到当前项目权限内的相关记录"));
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
audit, jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"3栋有哪些住户跟进事项", List.of(), "sop", null, "mobile", 5, null));
assertFalse(result.noEvidence());
var projectCitation = result.citations().stream()
.filter(citation -> "PROJECT_SERVICE".equals(citation.domain()))
.findFirst().orElseThrow();
assertEquals("IN_PROGRESS", projectCitation.status());
assertEquals("service-memory:501", projectCitation.docId());
assertTrue(result.usedSpaceCodes().contains("PROJECT_SERVICE"));
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(),
eq(List.of("sop", "PROJECT_SERVICE")), eq(List.of("DOCUMENT", "PROJECT_SERVICE")),
eq("SUCCESS"), anyLong(), eq("test-v1"));
}
@Test
void projectOnlyRecallWithoutKnowledgeSpaceStillWritesFinalAudit() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var item = new ServiceMemoryRecallItem(
501L, "P1", "3栋2单元1201跟进", "明天回访养老服务需求", "IN_PROGRESS",
"2026-07-19T08:30:00", "2026-07-19T09:00:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ"))
.thenThrow(new ServiceException("no scope", 403));
when(memory.recall(eq(principal), anyString(), any())).thenReturn(
new ServiceMemoryRecall(List.of(item), "已找到当前项目权限内的相关记录"));
var service = new AihrKnowledgeQueryService(resolver, appService, access,
mock(AihrSopSeedService.class), audit, mock(JdbcTemplate.class),
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"3栋有哪些住户跟进事项", List.of(), "sop", null, "mobile", 5, null));
assertFalse(result.noEvidence());
assertEquals(List.of("PROJECT_SERVICE"), result.usedSpaceCodes());
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(),
eq(List.of("PROJECT_SERVICE")), eq(List.of("PROJECT_SERVICE")), eq("SUCCESS"), anyLong(),
eq("service-memory-v1"));
}
@Test
void resourceDownloadRecomputesCurrentApplicationAndPrincipalScope() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
@@ -242,7 +444,7 @@ class AihrKnowledgeQueryServiceTest {
when(jdbc.queryForList(anyString(), eq(Long.class), any(Object[].class))).thenReturn(List.of(777L));
var service = new AihrKnowledgeQueryService(resolver, appService, access, mock(AihrSopSeedService.class),
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
assertEquals(777L, service.authorizedResourceOssId(321L));
verify(access).resolveInternalSpaceIds(principal, app, List.of(), "READ");
@@ -262,7 +464,7 @@ class AihrKnowledgeQueryServiceTest {
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of());
var service = new AihrKnowledgeQueryService(resolver, appService, access, mock(AihrSopSeedService.class),
mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
assertEquals(null, service.authorizedResourceOssId(321L));
verify(jdbc, never()).queryForList(anyString(), eq(Long.class), any(Object[].class));
@@ -276,6 +478,6 @@ class AihrKnowledgeQueryServiceTest {
) {
return new AihrKnowledgeQueryService(resolver, appService, access, sop,
mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class),
mock(AihrKnowledgeConversationService.class));
mock(AihrKnowledgeConversationService.class), mock(AihrMemoryService.class));
}
}
@@ -0,0 +1,553 @@
package org.dromara.aihr.memory;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryVersionResponse;
import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.KeyHolder;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.ResultSet;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@Tag("dev")
class AihrMemoryServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 7, 19, 8, 30);
@Test
void singleProjectCompleteResidentNeedBecomesDraftWithoutInventingIdentity() {
var plan = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"记一下,3栋2单元1201室住户希望了解养老服务,电话13812345678", NOW);
assertNotNull(plan);
assertEquals("DRAFT", plan.status());
assertEquals("RESIDENT_NEED", plan.memoryType());
assertEquals("P1", plan.projectCode());
assertEquals("3栋", plan.draft().buildingName());
assertEquals("2单元", plan.draft().unitName());
assertEquals("1201", plan.draft().roomNo());
assertFalse(plan.draft().summary().contains("13812345678"));
assertTrue(plan.missingFields().isEmpty());
}
@Test
void emptyOrAmbiguousProjectScopeFailsClosedAsNeedsInput() {
var noProject = AihrMemoryService.detectPlan(principal(Set.of()),
"记一下,3栋2单元1201室住户希望了解养老服务", NOW);
var ambiguous = AihrMemoryService.detectPlan(principal(Set.of("P1", "P2")),
"记一下,3栋2单元1201室住户希望了解养老服务", NOW);
var explicit = AihrMemoryService.detectPlan(principal(Set.of("P1", "P2")),
"记一下,P2项目3栋2单元1201室住户希望了解养老服务", NOW);
var overlappingCode = AihrMemoryService.detectPlan(principal(Set.of("P1", "P10")),
"记一下,P10项目3栋2单元1201室住户希望了解养老服务", NOW);
assertEquals("NEEDS_INPUT", noProject.status());
assertEquals("NEEDS_INPUT", ambiguous.status());
assertTrue(noProject.missingFields().contains("projectCode"));
assertTrue(ambiguous.missingFields().contains("projectCode"));
assertEquals("DRAFT", explicit.status());
assertEquals("P2", explicit.projectCode());
assertEquals("DRAFT", overlappingCode.status());
assertEquals("P10", overlappingCode.projectCode());
}
@Test
void ordinaryQuestionDoesNotCreateCandidate() {
assertNull(AihrMemoryService.detectPlan(principal(Set.of("P1")), "住户有哪些跟进需求?", NOW));
assertNull(AihrMemoryService.detectPlan(principal(Set.of("P1")), "电梯困人应该怎么处理?", NOW));
}
@Test
void sameConversationLocationFollowupUpgradesOriginalNeedsInputCandidate() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft incomplete = new MemoryDraft(
"P1", "", "", "", "住户需求", "住户需求:待补充位置", "住户希望了解养老服务",
"", "OPEN", "2026-07-19T08:30:00", null);
ContinuationJdbc jdbc = new ContinuationJdbc(mapper.writeValueAsString(incomplete));
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
var response = service.detectForQuery(
principal(Set.of("P1")), "3栋2单元1201室", "conversation_123", "request-next");
assertNotNull(response);
assertEquals(33L, response.id());
assertEquals(2, response.version());
assertEquals("DRAFT", response.status());
assertEquals("3栋", response.draft().buildingName());
assertEquals("2单元", response.draft().unitName());
assertEquals("1201", response.draft().roomNo());
assertEquals(1, jdbc.continuationUpdates);
assertEquals(0, jdbc.candidateInserts);
}
@Test
void continuationDoesNotConsumeQuestionsOrFreshMemoryIntents() {
MemoryDraft incomplete = new MemoryDraft(
"P1", "", "", "", "住户需求", "住户需求:待补充位置", "住户希望了解养老服务",
"", "OPEN", "2026-07-19T08:30:00", null);
AihrKnowledgePrincipal principal = principal(Set.of("P1"));
assertNull(AihrMemoryService.continuePlan(
principal, "RESIDENT_NEED", "P1", incomplete, "3栋电梯怎么处理?"));
assertNull(AihrMemoryService.continuePlan(
principal, "RESIDENT_NEED", "P1", incomplete, "记一下3栋2单元1201室住户报修漏水"));
assertNotNull(AihrMemoryService.continuePlan(
principal, "RESIDENT_NEED", "P1", incomplete, "3栋2单元1201室"));
}
@Test
void confirmLocksCandidateCreatesVersionOneAndReplaysSameKeyOnlyOnce() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
MemoryDraft clientDraft = copyWithProjectCode(draft, null);
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ConfirmMemoryRequest request = new ConfirmMemoryRequest(1L, "memory-confirm-001", clientDraft, false);
String recallQuery = "查一下3栋2单元1201室住户养老需求";
assertTrue(service.recall(principal(Set.of("P1")), recallQuery, 5).items().isEmpty());
var first = service.confirm(7L, request);
var replay = service.confirm(7L, request);
var recalled = service.recall(principal(Set.of("P1")), recallQuery, 5);
assertEquals(1, recalled.items().size());
assertTrue(recalled.answer().contains("[待处理]"));
assertTrue(recalled.answer().contains("2026-07-19 08:30"));
assertFalse(recalled.answer().contains("[OPEN]"));
assertTrue(service.recall(principal(Set.of("P1", "P2")), recallQuery, 5).items().isEmpty());
assertEquals(1,
service.recall(principal(Set.of("P1", "P2")), "查一下P1项目3栋2单元1201室住户养老需求", 5)
.items().size());
assertEquals(first, replay);
assertEquals(501L, first.targetId());
assertEquals(1, first.version());
assertEquals(1, jdbc.memoryInsertCount);
assertEquals(1, jdbc.versionInsertCount);
assertTrue(jdbc.selectedForUpdate);
assertEquals("000000", jdbc.lastSelectArgs[0]);
assertEquals(7L, jdbc.lastSelectArgs[1]);
ConfirmMemoryRequest changed = new ConfirmMemoryRequest(
1L, "memory-confirm-001",
copyWithProjectCode(draft("住户希望了解养老和助餐服务"), null), false);
ServiceException conflict = assertThrows(ServiceException.class, () -> service.confirm(7L, changed));
assertEquals(409, conflict.getCode());
assertEquals(1, jdbc.memoryInsertCount);
}
@Test
void ordinarySopQuestionsNeverSweepRecentProjectMemories() {
ObjectMapper mapper = new ObjectMapper();
ConfirmJdbc jdbc = new ConfirmJdbc("{}", 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
AihrKnowledgePrincipal principal = principal(Set.of("P1"));
assertTrue(service.recall(principal, "住户需求如何处理?", 5).items().isEmpty());
assertTrue(service.recall(principal, "业主投诉怎么处理?", 5).items().isEmpty());
assertEquals(0, jdbc.recallQueryCount);
assertTrue(service.recall(principal, "3栋2单元1201室住户有什么需求?", 5).items().isEmpty());
assertEquals(1, jdbc.recallQueryCount);
}
@Test
void versionSnapshotPreservesNullableReminderWithoutAllowingMutation() {
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("status", "OPEN");
snapshot.put("followUpAt", null);
ServiceMemoryVersionResponse response = new ServiceMemoryVersionResponse(
1, snapshot, "CONFIRM", null, 7L, "2026-07-19T08:30:00");
assertTrue(response.snapshot().containsKey("followUpAt"));
assertNull(response.snapshot().get("followUpAt"));
assertThrows(UnsupportedOperationException.class,
() -> response.snapshot().put("status", "RESOLVED"));
}
@Test
void staleExpectedVersionCannotOverwriteDraft() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 2);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-002", draft, false)));
assertEquals(409, error.getCode());
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void expiredConfirmationRejectsWithoutPerformingARolledBackStatusUpdate() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(
mapper.writeValueAsString(draft), 1, LocalDateTime.now().minusMinutes(1));
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-expired", draft, false)));
assertEquals(409, error.getCode());
assertEquals("DRAFT", jdbc.status);
assertEquals(0, jdbc.expiredUpdateCount);
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void confirmationRejectsSensitiveValueDisguisedAsRoomNumber() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft persistedDraft = draft("住户希望了解养老服务");
MemoryDraft unsafeDraft = new MemoryDraft(
"P1", "3栋", "2单元", "13812345678", persistedDraft.category(), persistedDraft.title(),
persistedDraft.summary(), persistedDraft.detail(), persistedDraft.status(),
persistedDraft.occurredAt(), persistedDraft.followUpAt());
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(persistedDraft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-unsafe", unsafeDraft, false)));
assertEquals(400, error.getCode());
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void confirmationRejectsResidentNameAndHealthInformationFromSearchableText() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft persistedDraft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(persistedDraft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
List<String> unsafeSummaries = List.of(
"住户姓名:张三,希望了解养老服务",
"住户患有高血压,希望了解养老服务"
);
for (int i = 0; i < unsafeSummaries.size(); i++) {
MemoryDraft unsafeDraft = new MemoryDraft(
"P1", persistedDraft.buildingName(), persistedDraft.unitName(), persistedDraft.roomNo(),
persistedDraft.category(), persistedDraft.title(), unsafeSummaries.get(i), persistedDraft.detail(),
persistedDraft.status(), persistedDraft.occurredAt(), persistedDraft.followUpAt());
int index = i;
ServiceException error = assertThrows(ServiceException.class, () -> service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-private-" + index, unsafeDraft, false)));
assertEquals(400, error.getCode());
}
assertEquals(0, jdbc.memoryInsertCount);
}
@Test
void normalServiceSummariesAreNotMistakenForResidentNames() throws Exception {
List<String> normalSummaries = List.of(
"业主家里漏水", "住户养老咨询", "业主报修漏水", "业主马桶需要维修");
for (int i = 0; i < normalSummaries.size(); i++) {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft(normalSummaries.get(i));
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
var response = service.confirm(7L,
new ConfirmMemoryRequest(1L, "memory-confirm-normal-" + i, draft, false));
assertEquals(501L, response.targetId());
assertEquals(1, jdbc.memoryInsertCount);
}
}
@Test
void sharedSearchDraftNeverCopiesClientFreeText() {
MemoryDraft raw = new MemoryDraft(
"P1", "3栋", "2单元", "1201", "养老咨询", "张三高血压跟进",
"张三患有高血压,希望了解养老服务", "联系电话13812345678",
"OPEN", "2026-07-19T08:30:00", null);
MemoryDraft searchable = AihrMemoryService.projectSearchDraft("RESIDENT_NEED", raw);
assertEquals("养老服务", searchable.category());
assertEquals("3栋2单元1201养老服务", searchable.title());
assertEquals("住户提出养老服务需求", searchable.summary());
assertEquals("", searchable.detail());
assertFalse((searchable.title() + searchable.summary()).contains("张三"));
assertFalse((searchable.title() + searchable.summary()).contains("高血压"));
}
@Test
void dismissReturnsNoRetainedDraft() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = draft("住户希望了解养老服务");
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1);
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
var response = service.dismiss(7L);
assertEquals("DISMISSED", response.status());
assertNull(response.draft());
assertTrue(response.missingFields().isEmpty());
assertTrue(jdbc.dismissClearedDraft);
}
@Test
void schemaAndControllersKeepSecurityAndLifecycleContracts() throws Exception {
String serviceSource = Files.readString(source("AihrMemoryService.java"));
String schema = Files.readString(repoPath("backend/script/sql/aihr_service_memory_mysql8.sql"));
String migration = Files.readString(repoPath(
"backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql"));
assertTrue(serviceSource.contains("limit 1 for update"));
assertTrue(serviceSource.contains("plusHours(24)"));
assertTrue(serviceSource.contains("@Scheduled"));
assertTrue(serviceSource.contains("draft_json = null"));
assertTrue(serviceSource.contains("where tenant_id = ? and owner_user_id = ? and id = ?"));
assertTrue(serviceSource.contains("project_code in (%s)"));
assertFalse(serviceSource.contains("project_id"));
assertTrue(schema.contains("`project_code` varchar(100) NOT NULL"));
assertTrue(schema.contains("source_conversation_id"));
assertTrue(schema.contains("source_request_id"));
assertTrue(schema.contains("uk_aihr_memory_candidate_confirm_key"));
assertTrue(schema.contains("idx_aihr_memory_candidate_expiry"));
assertTrue(schema.contains("idx_aihr_memory_candidate_cleanup"));
assertTrue(migration.contains("table_name = 'aihr_knowledge_info'"));
assertTrue(migration.contains("index_name = 'idx_aihr_memory_candidate_expiry'"));
assertTrue(migration.contains("index_name = 'idx_aihr_memory_candidate_cleanup'"));
assertTrue(migration.contains("CONVERT TO CHARACTER SET utf8mb4 COLLATE"));
assertTrue(AihrMemoryController.class.getMethod("confirm", Long.class, ConfirmMemoryRequest.class)
.isAnnotationPresent(cn.dev33.satoken.annotation.SaCheckLogin.class));
assertTrue(AihrServiceMemoryController.class.getMethod("detail", Long.class)
.isAnnotationPresent(cn.dev33.satoken.annotation.SaCheckLogin.class));
}
private static MemoryDraft draft(String summary) {
return new MemoryDraft(
"P1", "3栋", "2单元", "1201", "养老咨询", "3栋2单元1201住户需求",
summary, "", "OPEN", "2026-07-19T08:30:00", null);
}
private static MemoryDraft copyWithProjectCode(MemoryDraft draft, String projectCode) {
return new MemoryDraft(
projectCode, draft.buildingName(), draft.unitName(), draft.roomNo(), draft.category(), draft.title(),
draft.summary(), draft.detail(), draft.status(), draft.occurredAt(), draft.followUpAt());
}
private static AihrKnowledgePrincipal principal(Set<String> projectCodes) {
return new AihrKnowledgePrincipal(
"000000", 7L, "app_user", "employee-7", Set.of("employee"), projectCodes, "app");
}
private static Path source(String fileName) {
Path direct = Path.of("src/main/java/org/dromara/aihr/memory", fileName);
return Files.exists(direct) ? direct
: repoPath("backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/" + fileName);
}
private static Path repoPath(String value) {
Path cursor = Path.of("").toAbsolutePath();
for (int depth = 0; depth < 8 && cursor != null; depth++, cursor = cursor.getParent()) {
Path candidate = cursor.resolve(value);
if (Files.exists(candidate)) {
return candidate;
}
}
throw new IllegalStateException("repository file not found: " + value);
}
private static final class ConfirmJdbc extends JdbcTemplate {
private final String draftJson;
private int version;
private final LocalDateTime expiresAt;
private String status = "DRAFT";
private String confirmKey;
private String confirmHash;
private Long targetId;
private Integer targetVersion;
private boolean selectedForUpdate;
private Object[] lastSelectArgs;
private int memoryInsertCount;
private int versionInsertCount;
private int expiredUpdateCount;
private boolean dismissClearedDraft;
private int recallQueryCount;
private ConfirmJdbc(String draftJson, int version) {
this(draftJson, version, LocalDateTime.now().plusHours(12));
}
private ConfirmJdbc(String draftJson, int version, LocalDateTime expiresAt) {
this.draftJson = draftJson;
this.version = version;
this.expiresAt = expiresAt;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (sql.contains("from aihr_service_memory")) {
recallQueryCount++;
if (memoryInsertCount == 0) {
return List.of();
}
try {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(501L);
when(rs.getString("project_code")).thenReturn("P1");
when(rs.getString("title")).thenReturn("3栋2单元1201住户需求");
when(rs.getString("summary")).thenReturn("住户希望了解养老服务");
when(rs.getString("status")).thenReturn("OPEN");
when(rs.getTimestamp("occurred_at")).thenReturn(Timestamp.valueOf(NOW));
when(rs.getTimestamp("update_time")).thenReturn(Timestamp.valueOf(NOW));
return List.of(rowMapper.mapRow(rs, 0));
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
if (!sql.contains("from aihr_memory_candidate")) {
return List.of();
}
try {
selectedForUpdate = sql.contains("for update");
lastSelectArgs = args;
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(7L);
when(rs.getString("project_code")).thenReturn("P1");
when(rs.getString("memory_type")).thenReturn("RESIDENT_NEED");
when(rs.getString("target_domain")).thenReturn("PROJECT_SERVICE");
when(rs.getString("status")).thenReturn(status);
when(rs.getString("draft_json")).thenReturn(draftJson);
when(rs.getString("missing_fields_json")).thenReturn("[]");
when(rs.getObject("target_id")).thenReturn(targetId);
when(rs.getObject("target_version")).thenReturn(targetVersion);
when(rs.getInt("version")).thenReturn(version);
when(rs.getString("confirm_idempotency_key")).thenReturn(confirmKey);
when(rs.getString("confirm_request_hash")).thenReturn(confirmHash);
when(rs.getTimestamp("expires_at")).thenReturn(Timestamp.valueOf(expiresAt));
return List.of(rowMapper.mapRow(rs, 0));
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
@Override
public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) {
memoryInsertCount++;
generatedKeyHolder.getKeyList().add(Map.of("GENERATED_KEY", 501L));
return 1;
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("insert into aihr_service_memory_version")) {
versionInsertCount++;
return 1;
}
if (sql.contains("set status = 'EXPIRED'")) {
status = "EXPIRED";
version++;
expiredUpdateCount++;
return 1;
}
if (sql.contains("set status = 'DISMISSED'")) {
status = "DISMISSED";
version++;
dismissClearedDraft = sql.contains("draft_json = null");
return 1;
}
if (sql.contains("set status = 'CONFIRMED'")) {
status = "CONFIRMED";
targetId = ((Number) args[0]).longValue();
targetVersion = 1;
confirmKey = String.valueOf(args[1]);
confirmHash = String.valueOf(args[2]);
version++;
return 1;
}
return 1;
}
}
private static final class ContinuationJdbc extends JdbcTemplate {
private final String draftJson;
private int continuationUpdates;
private int candidateInserts;
private ContinuationJdbc(String draftJson) {
this.draftJson = draftJson;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (!sql.contains("source_conversation_id = ?")) {
return List.of();
}
try {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(33L);
when(rs.getString("project_code")).thenReturn("P1");
when(rs.getString("memory_type")).thenReturn("RESIDENT_NEED");
when(rs.getString("draft_json")).thenReturn(draftJson);
when(rs.getInt("version")).thenReturn(1);
when(rs.getTimestamp("expires_at"))
.thenReturn(Timestamp.valueOf(LocalDateTime.now().plusHours(12)));
return List.of(rowMapper.mapRow(rs, 0));
} catch (Exception error) {
throw new IllegalStateException(error);
}
}
@Override
public int update(String sql, Object... args) {
if (sql.contains("update aihr_memory_candidate")) {
continuationUpdates++;
}
return 1;
}
@Override
public int update(PreparedStatementCreator psc, KeyHolder generatedKeyHolder) {
candidateInserts++;
return 1;
}
}
}