feat(personal-assistant): add confirmed project memory
This commit is contained in:
+28
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -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);
|
||||
|
||||
+94
-5
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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));
|
||||
}
|
||||
}
|
||||
+125
@@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
+1199
File diff suppressed because it is too large
Load Diff
+43
@@ -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));
|
||||
}
|
||||
}
|
||||
+209
-7
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+553
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
-- 个人助理项目服务记忆最小切片:候选确认、项目事实与版本快照。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_memory_candidate` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '候选ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`owner_user_id` bigint NOT NULL COMMENT '候选所属用户',
|
||||
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID,无状态问答为空串',
|
||||
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
|
||||
`candidate_hash` char(64) NOT NULL COMMENT '规范化候选SHA-256',
|
||||
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份解析的真实项目编码',
|
||||
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
|
||||
`target_domain` varchar(30) NOT NULL DEFAULT 'PROJECT_SERVICE' COMMENT '目标记忆域',
|
||||
`status` varchar(20) NOT NULL COMMENT 'NEEDS_INPUT/DRAFT/CONFIRMED/DISMISSED/EXPIRED',
|
||||
`draft_json` json DEFAULT NULL COMMENT '可编辑的结构化草稿,不保存模型原始输出',
|
||||
`missing_fields_json` json DEFAULT NULL COMMENT '待补充字段',
|
||||
`confidence` decimal(5,4) NOT NULL DEFAULT 0 COMMENT '只用于展示建议',
|
||||
`target_id` bigint DEFAULT NULL COMMENT '确认后项目记忆ID',
|
||||
`target_version` int DEFAULT NULL COMMENT '确认结果版本',
|
||||
`version` int NOT NULL DEFAULT 1 COMMENT '候选乐观锁版本',
|
||||
`confirm_idempotency_key` varchar(100) DEFAULT NULL COMMENT '确认幂等键',
|
||||
`confirm_request_hash` char(64) DEFAULT NULL COMMENT '确认请求规范化哈希',
|
||||
`expires_at` datetime NOT NULL COMMENT '草稿过期时间',
|
||||
`confirmed_at` datetime DEFAULT NULL COMMENT '确认时间',
|
||||
`dismissed_at` datetime DEFAULT NULL COMMENT '忽略时间',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_memory_candidate_source` (`tenant_id`, `owner_user_id`, `source_request_id`, `candidate_hash`),
|
||||
UNIQUE KEY `uk_aihr_memory_candidate_confirm_key` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
|
||||
KEY `idx_aihr_memory_candidate_owner` (`tenant_id`, `owner_user_id`, `status`, `expires_at`),
|
||||
KEY `idx_aihr_memory_candidate_expiry` (`status`, `expires_at`),
|
||||
KEY `idx_aihr_memory_candidate_cleanup` (`status`, `update_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人助理长期记忆候选';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_service_memory` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '项目服务记忆ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`project_code` varchar(100) NOT NULL COMMENT '真实项目编码',
|
||||
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
|
||||
`subject_type` varchar(20) NOT NULL DEFAULT 'LOCATION' COMMENT '当前无权威住户主数据,仅定位到房屋',
|
||||
`building_name` varchar(100) NOT NULL COMMENT '楼栋',
|
||||
`unit_name` varchar(100) NOT NULL COMMENT '单元',
|
||||
`room_no` varchar(100) NOT NULL COMMENT '房号',
|
||||
`category` varchar(50) NOT NULL COMMENT '需求分类',
|
||||
`title` varchar(500) NOT NULL COMMENT '标题',
|
||||
`summary` varchar(1000) NOT NULL COMMENT '最小必要摘要',
|
||||
`detail` text COMMENT '必要说明',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/IN_PROGRESS/RESOLVED/VOID',
|
||||
`occurred_at` datetime NOT NULL COMMENT '发生或获知时间',
|
||||
`follow_up_at` datetime DEFAULT NULL COMMENT '用户单独确认的跟进时间',
|
||||
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID',
|
||||
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
|
||||
`created_by` bigint NOT NULL COMMENT '记录人',
|
||||
`confirmed_by` bigint NOT NULL COMMENT '确认人',
|
||||
`version` int NOT NULL DEFAULT 1 COMMENT '当前版本',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_service_memory_scope` (`tenant_id`, `project_code`, `status`, `follow_up_at`),
|
||||
KEY `idx_aihr_service_memory_location` (`tenant_id`, `project_code`, `building_name`, `unit_name`, `room_no`),
|
||||
KEY `idx_aihr_service_memory_source` (`tenant_id`, `source_request_id`),
|
||||
FULLTEXT KEY `ft_aihr_service_memory_text` (`title`, `summary`, `detail`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目共享服务记忆';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_service_memory_version` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '版本ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`project_code` varchar(100) NOT NULL COMMENT '项目编码快照',
|
||||
`memory_id` bigint NOT NULL COMMENT '项目服务记忆ID',
|
||||
`version` int NOT NULL COMMENT '版本号',
|
||||
`snapshot_json` json NOT NULL COMMENT '业务字段快照',
|
||||
`change_type` varchar(30) NOT NULL COMMENT 'CONFIRM/UPDATE/STATUS/VOID',
|
||||
`change_reason` varchar(500) DEFAULT NULL COMMENT '变更原因',
|
||||
`operator_user_id` bigint NOT NULL COMMENT '操作人',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_service_memory_version` (`memory_id`, `version`),
|
||||
KEY `idx_aihr_service_memory_version_scope` (`tenant_id`, `project_code`, `memory_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目服务记忆版本快照';
|
||||
|
||||
-- 生产旧库与 MySQL 8 新库的排序规则可能不同;以现有租户主表为事实源动态对齐。
|
||||
SET @aihr_memory_collation := (
|
||||
SELECT collation_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'aihr_knowledge_info'
|
||||
AND column_name = 'tenant_id'
|
||||
LIMIT 1
|
||||
);
|
||||
SET @aihr_memory_collation := COALESCE(@aihr_memory_collation, @@collation_database);
|
||||
|
||||
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_memory_candidate` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
|
||||
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
|
||||
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
|
||||
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
|
||||
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory_version` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
|
||||
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
|
||||
|
||||
SET @aihr_memory_ddl := NULL;
|
||||
SET @aihr_memory_collation := NULL;
|
||||
@@ -0,0 +1,128 @@
|
||||
-- 2026-07-19 项目服务记忆增量迁移。与初始化脚本保持同构,可重复执行。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_memory_candidate` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '候选ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`owner_user_id` bigint NOT NULL COMMENT '候选所属用户',
|
||||
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID,无状态问答为空串',
|
||||
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
|
||||
`candidate_hash` char(64) NOT NULL COMMENT '规范化候选SHA-256',
|
||||
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份解析的真实项目编码',
|
||||
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
|
||||
`target_domain` varchar(30) NOT NULL DEFAULT 'PROJECT_SERVICE' COMMENT '目标记忆域',
|
||||
`status` varchar(20) NOT NULL COMMENT 'NEEDS_INPUT/DRAFT/CONFIRMED/DISMISSED/EXPIRED',
|
||||
`draft_json` json DEFAULT NULL COMMENT '可编辑的结构化草稿,不保存模型原始输出',
|
||||
`missing_fields_json` json DEFAULT NULL COMMENT '待补充字段',
|
||||
`confidence` decimal(5,4) NOT NULL DEFAULT 0 COMMENT '只用于展示建议',
|
||||
`target_id` bigint DEFAULT NULL COMMENT '确认后项目记忆ID',
|
||||
`target_version` int DEFAULT NULL COMMENT '确认结果版本',
|
||||
`version` int NOT NULL DEFAULT 1 COMMENT '候选乐观锁版本',
|
||||
`confirm_idempotency_key` varchar(100) DEFAULT NULL COMMENT '确认幂等键',
|
||||
`confirm_request_hash` char(64) DEFAULT NULL COMMENT '确认请求规范化哈希',
|
||||
`expires_at` datetime NOT NULL COMMENT '草稿过期时间',
|
||||
`confirmed_at` datetime DEFAULT NULL COMMENT '确认时间',
|
||||
`dismissed_at` datetime DEFAULT NULL COMMENT '忽略时间',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_memory_candidate_source` (`tenant_id`, `owner_user_id`, `source_request_id`, `candidate_hash`),
|
||||
UNIQUE KEY `uk_aihr_memory_candidate_confirm_key` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
|
||||
KEY `idx_aihr_memory_candidate_owner` (`tenant_id`, `owner_user_id`, `status`, `expires_at`),
|
||||
KEY `idx_aihr_memory_candidate_expiry` (`status`, `expires_at`),
|
||||
KEY `idx_aihr_memory_candidate_cleanup` (`status`, `update_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='个人助理长期记忆候选';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_service_memory` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '项目服务记忆ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`project_code` varchar(100) NOT NULL COMMENT '真实项目编码',
|
||||
`memory_type` varchar(30) NOT NULL COMMENT 'RESIDENT_NEED/FOLLOW_UP',
|
||||
`subject_type` varchar(20) NOT NULL DEFAULT 'LOCATION' COMMENT '当前无权威住户主数据,仅定位到房屋',
|
||||
`building_name` varchar(100) NOT NULL COMMENT '楼栋',
|
||||
`unit_name` varchar(100) NOT NULL COMMENT '单元',
|
||||
`room_no` varchar(100) NOT NULL COMMENT '房号',
|
||||
`category` varchar(50) NOT NULL COMMENT '需求分类',
|
||||
`title` varchar(500) NOT NULL COMMENT '标题',
|
||||
`summary` varchar(1000) NOT NULL COMMENT '最小必要摘要',
|
||||
`detail` text COMMENT '必要说明',
|
||||
`status` varchar(20) NOT NULL DEFAULT 'OPEN' COMMENT 'OPEN/IN_PROGRESS/RESOLVED/VOID',
|
||||
`occurred_at` datetime NOT NULL COMMENT '发生或获知时间',
|
||||
`follow_up_at` datetime DEFAULT NULL COMMENT '用户单独确认的跟进时间',
|
||||
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID',
|
||||
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
|
||||
`created_by` bigint NOT NULL COMMENT '记录人',
|
||||
`confirmed_by` bigint NOT NULL COMMENT '确认人',
|
||||
`version` int NOT NULL DEFAULT 1 COMMENT '当前版本',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
`update_time` datetime NOT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_aihr_service_memory_scope` (`tenant_id`, `project_code`, `status`, `follow_up_at`),
|
||||
KEY `idx_aihr_service_memory_location` (`tenant_id`, `project_code`, `building_name`, `unit_name`, `room_no`),
|
||||
KEY `idx_aihr_service_memory_source` (`tenant_id`, `source_request_id`),
|
||||
FULLTEXT KEY `ft_aihr_service_memory_text` (`title`, `summary`, `detail`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目共享服务记忆';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `aihr_service_memory_version` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '版本ID',
|
||||
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
|
||||
`project_code` varchar(100) NOT NULL COMMENT '项目编码快照',
|
||||
`memory_id` bigint NOT NULL COMMENT '项目服务记忆ID',
|
||||
`version` int NOT NULL COMMENT '版本号',
|
||||
`snapshot_json` json NOT NULL COMMENT '业务字段快照',
|
||||
`change_type` varchar(30) NOT NULL COMMENT 'CONFIRM/UPDATE/STATUS/VOID',
|
||||
`change_reason` varchar(500) DEFAULT NULL COMMENT '变更原因',
|
||||
`operator_user_id` bigint NOT NULL COMMENT '操作人',
|
||||
`create_time` datetime NOT NULL COMMENT '创建时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_aihr_service_memory_version` (`memory_id`, `version`),
|
||||
KEY `idx_aihr_service_memory_version_scope` (`tenant_id`, `project_code`, `memory_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目服务记忆版本快照';
|
||||
|
||||
SET @aihr_memory_ddl := IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'aihr_memory_candidate'
|
||||
AND index_name = 'idx_aihr_memory_candidate_expiry'
|
||||
),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_memory_candidate` ADD KEY `idx_aihr_memory_candidate_expiry` (`status`, `expires_at`)'
|
||||
);
|
||||
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
|
||||
|
||||
SET @aihr_memory_ddl := IF(
|
||||
EXISTS(
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE() AND table_name = 'aihr_memory_candidate'
|
||||
AND index_name = 'idx_aihr_memory_candidate_cleanup'
|
||||
),
|
||||
'SELECT 1',
|
||||
'ALTER TABLE `aihr_memory_candidate` ADD KEY `idx_aihr_memory_candidate_cleanup` (`status`, `update_time`)'
|
||||
);
|
||||
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
|
||||
|
||||
-- 清理早于本迁移产生的终态候选正文;幂等键与来源 ID 保留用于防重。
|
||||
UPDATE `aihr_memory_candidate`
|
||||
SET `draft_json` = NULL, `missing_fields_json` = NULL
|
||||
WHERE `status` IN ('CONFIRMED', 'DISMISSED', 'EXPIRED')
|
||||
AND `draft_json` IS NOT NULL
|
||||
AND `update_time` <= DATE_SUB(NOW(), INTERVAL 24 HOUR);
|
||||
|
||||
SET @aihr_memory_collation := (
|
||||
SELECT collation_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'aihr_knowledge_info'
|
||||
AND column_name = 'tenant_id'
|
||||
LIMIT 1
|
||||
);
|
||||
SET @aihr_memory_collation := COALESCE(@aihr_memory_collation, @@collation_database);
|
||||
|
||||
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_memory_candidate` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
|
||||
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
|
||||
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
|
||||
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
|
||||
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory_version` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
|
||||
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
|
||||
|
||||
SET @aihr_memory_ddl := NULL;
|
||||
SET @aihr_memory_collation := NULL;
|
||||
Reference in New Issue
Block a user