From 24da833fa66121fec8b762703759b186daa14cd7 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sun, 19 Jul 2026 08:19:24 +0800 Subject: [PATCH] feat(personal-assistant): add confirmed project memory --- .../domain/AihrKnowledgeQueryDto.java | 31 +- .../AihrKnowledgeQueryAuditService.java | 21 + .../service/AihrKnowledgeQueryService.java | 99 +- .../aihr/memory/AihrMemoryController.java | 45 + .../dromara/aihr/memory/AihrMemoryDto.java | 125 ++ .../aihr/memory/AihrMemoryService.java | 1199 +++++++++++++++++ .../memory/AihrServiceMemoryController.java | 43 + .../AihrKnowledgeQueryServiceTest.java | 216 ++- .../aihr/memory/AihrMemoryServiceTest.java | 553 ++++++++ .../script/sql/aihr_service_memory_mysql8.sql | 100 ++ .../aihr_20260719_service_memory_mysql8.sql | 128 ++ docs/API_INTEGRATION.md | 53 + docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 14 +- docs/DEMO_ACCEPTANCE.md | 2 + docs/DEV_SETUP.md | 7 + docs/个人AI助理阶段二专项TechSpec.md | 36 +- .../components/chat/MemoryCandidateCard.vue | 518 +++++++ mobile-uni/src/pages.json | 10 +- .../src/pages/user/assistant/memories.vue | 342 +++++ mobile-uni/src/pages/user/profile/index.vue | 16 + mobile-uni/src/pages/user/sop/index.vue | 135 +- mobile-uni/src/pages/user/web-ai/index.vue | 10 +- mobile-uni/src/services/knowledge.ts | 36 +- mobile-uni/src/services/personal-assistant.ts | 54 + mobile-uni/src/types/api.ts | 74 + .../tests/customer-feedback-closure.test.mjs | 8 +- mobile-uni/tests/personal-assistant.test.mjs | 143 ++ scripts/personal-assistant-smoke.sh | 62 + scripts/release-preflight.sh | 5 +- scripts/reset-dev-db.sh | 1 + scripts/tests/aihr-schema-migrations.test.sh | 11 +- 31 files changed, 4037 insertions(+), 60 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryController.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryDto.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrServiceMemoryController.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/memory/AihrMemoryServiceTest.java create mode 100644 backend/script/sql/aihr_service_memory_mysql8.sql create mode 100644 backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql create mode 100644 mobile-uni/src/components/chat/MemoryCandidateCard.vue create mode 100644 mobile-uni/src/pages/user/assistant/memories.vue create mode 100644 mobile-uni/src/services/personal-assistant.ts create mode 100644 mobile-uni/tests/personal-assistant.test.mjs create mode 100755 scripts/personal-assistant-smoke.sh diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java index 9c7482cf..18f92f7f 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java @@ -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 resources + List resources, + MemoryCandidateResponse memoryCandidate ) { + public QueryResponse(String requestId, String queryText, String answer, List citations, + List usedSpaceCodes, boolean noEvidence, String promptVersion, + SearchResponse legacy, Object data, String conversationId, Long contextVersion, + String intent, String rewrittenQuery, List 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 citations, List 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); } } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java index 44586423..5c6f3759 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryAuditService.java @@ -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 spaceCodes, List 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 values) { try { return objectMapper.writeValueAsString(values == null ? List.of() : values); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java index 25f100cd..c3a92cf4 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java @@ -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 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 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 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 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 finalSourceTypes(QueryResponse response, ServiceMemoryRecall recall, + MemoryCandidateResponse candidate) { + LinkedHashSet 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 resources(String tenantId, Set spaceIds, List 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) { } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryController.java new file mode 100644 index 00000000..79af43cc --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryController.java @@ -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> candidates( + @RequestParam(value = "status", defaultValue = "DRAFT") String status + ) { + return R.ok(memoryService.candidates(status)); + } + + @SaCheckLogin + @PostMapping("/memory-candidates/{id}/confirm") + public R confirm(@PathVariable Long id, @RequestBody ConfirmMemoryRequest request) { + return R.ok(memoryService.confirm(id, request)); + } + + @SaCheckLogin + @PostMapping("/memory-candidates/{id}/dismiss") + public R dismiss(@PathVariable Long id) { + return R.ok(memoryService.dismiss(id)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryDto.java new file mode 100644 index 00000000..4287443b --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryDto.java @@ -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 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 versions + ) { + public ServiceMemoryResponse { + versions = versions == null ? List.of() : List.copyOf(versions); + } + } + + public record ServiceMemoryVersionResponse( + Integer version, + Map 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 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 + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryService.java new file mode 100644 index 00000000..0892dec7 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrMemoryService.java @@ -0,0 +1,1199 @@ +package org.dromara.aihr.memory; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.ConfirmMemoryResponse; +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.memory.AihrMemoryDto.ServiceMemoryResponse; +import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryVersionResponse; +import org.dromara.aihr.service.AihrSensitiveText; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +@EnableScheduling +@RequiredArgsConstructor +@Slf4j +public class AihrMemoryService { + + private static final String PROJECT_SERVICE = "PROJECT_SERVICE"; + private static final Set CANDIDATE_STATUSES = Set.of("DRAFT", "NEEDS_INPUT"); + private static final Set MEMORY_STATUSES = Set.of("OPEN", "IN_PROGRESS", "RESOLVED", "VOID"); + private static final Pattern IDEMPOTENCY_KEY = Pattern.compile("[A-Za-z0-9._:-]{8,100}"); + private static final Pattern BUILDING = Pattern.compile("([A-Za-z0-9一二三四五六七八九十-]{1,8})(栋|幢|号楼)"); + private static final Pattern UNIT = Pattern.compile("([0-9一二三四五六七八九十]{1,4})单元"); + private static final Pattern ROOM = Pattern.compile( + "(?:(?:房号|房间|室号)\\s*[::]?\\s*([A-Za-z0-9-]{2,12})|(? RECALL_KEYWORDS = Set.of( + "维修", "投诉", "养老", "电梯", "保洁", "门禁", "漏水", "噪音", "停车", "装修", "回访", "跟进"); + private static final TypeReference> STRING_LIST = new TypeReference<>() { }; + private static final TypeReference> SNAPSHOT_MAP = new TypeReference<>() { }; + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + private final AihrKnowledgePrincipalResolver principalResolver; + + /** Query-path integration degrades to ordinary knowledge search until the migration is installed. */ + public MemoryCandidateResponse detectForQuery(AihrKnowledgePrincipal principal, String queryText, + String conversationId, String requestId) { + if (!hasText(requestId)) { + return null; + } + try { + MemoryCandidateResponse continued = continueRecentCandidate(principal, queryText, conversationId); + if (continued != null) { + return continued; + } + CandidatePlan plan = detectPlan(principal, queryText, LocalDateTime.now().withNano(0)); + if (plan == null) { + return null; + } + return saveCandidate(principal, plan, conversationId, requestId); + } catch (RuntimeException ex) { + log.warn("service memory candidate unavailable; query answer remains usable"); + return null; + } + } + + /** Pure intent check used only to decide whether a no-knowledge-space query may continue to confirmation. */ + public boolean isProjectMemoryCandidateIntent(AihrKnowledgePrincipal principal, String queryText) { + return detectPlan(principal, queryText, LocalDateTime.now().withNano(0)) != null; + } + + public List candidates(String rawStatus) { + AihrKnowledgePrincipal principal = principalResolver.current(); + String status = upper(rawStatus); + if (!CANDIDATE_STATUSES.contains(status)) { + throw badRequest("status 仅支持 DRAFT/NEEDS_INPUT"); + } + expireCandidates(principal); + return jdbcTemplate.query(""" + select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, + target_id, target_version, version, confirm_idempotency_key, confirm_request_hash, expires_at + from aihr_memory_candidate + where tenant_id = ? and owner_user_id = ? and status = ? and expires_at > now() + order by create_time desc, id desc + limit 100 + """, (rs, rowNum) -> candidateResponse(mapCandidate(rs)), + principal.tenantId(), principal.userId(), status); + } + + @Transactional(rollbackFor = Exception.class) + public ConfirmMemoryResponse confirm(Long candidateId, ConfirmMemoryRequest request) { + AihrKnowledgePrincipal principal = principalResolver.current(); + requireCandidateId(candidateId); + if (request == null || request.expectedVersion() == null || request.expectedVersion() < 1) { + throw badRequest("expectedVersion 必填且必须为正整数"); + } + String idempotencyKey = clean(request.idempotencyKey()); + if (!IDEMPOTENCY_KEY.matcher(idempotencyKey).matches()) { + throw badRequest("idempotencyKey 长度必须为 8-100,且只能包含字母、数字、点、下划线、冒号或连字符"); + } + if (request.draft() == null) { + throw badRequest("draft 必填"); + } + + CandidateRow candidate = requireCandidateForUpdate(principal, candidateId); + MemoryDraft normalized = normalizeDraft(request.draft(), candidate.memoryType()); + if (!hasText(normalized.projectCode()) && hasText(candidate.projectCode())) { + normalized = copyWithProjectCode(normalized, candidate.projectCode()); + } + boolean enableReminder = Boolean.TRUE.equals(request.enableReminder()); + String requestHash = hash(new ConfirmFingerprint( + candidateId, request.expectedVersion(), normalized, enableReminder)); + + if ("CONFIRMED".equals(candidate.status())) { + if (idempotencyKey.equals(candidate.confirmIdempotencyKey()) + && requestHash.equals(candidate.confirmRequestHash())) { + return new ConfirmMemoryResponse(PROJECT_SERVICE, candidate.targetId(), candidate.targetVersion()); + } + throw conflict("幂等键或确认内容与原请求不一致"); + } + if (candidate.expiresAt() == null || !candidate.expiresAt().isAfter(LocalDateTime.now())) { + // Do not update then throw inside this transaction: rollback would undo the state change. + // The hourly lifecycle guard (and candidate listing) performs the idempotent expiry/cleanup. + throw conflict("记忆候选已过期,请重新识别"); + } + if (!"DRAFT".equals(candidate.status())) { + throw conflict("当前候选信息不完整或状态已变化"); + } + if (candidate.version() != request.expectedVersion().intValue()) { + throw conflict("记忆候选已更新,请刷新后重试"); + } + if (!PROJECT_SERVICE.equals(candidate.targetDomain()) + || !Set.of("RESIDENT_NEED", "FOLLOW_UP").contains(candidate.memoryType())) { + throw conflict("当前候选不属于项目服务记忆"); + } + + String projectCode = requireAuthorizedProject(principal, normalized.projectCode()); + if (!hasText(candidate.projectCode()) || !candidate.projectCode().equals(projectCode)) { + throw conflict("候选项目已变化,请重新补充项目后确认"); + } + requireLocation(normalized); + if (!hasText(normalized.summary())) { + throw badRequest("summary 必填"); + } + LocalDateTime occurredAt = parseDateTime(normalized.occurredAt(), "occurredAt", false); + LocalDateTime followUpAt = enableReminder + ? parseDateTime(normalized.followUpAt(), "followUpAt", false) + : null; + String status = initialStatus(normalized.status(), candidate.memoryType()); + MemoryDraft confirmedInput = new MemoryDraft( + projectCode, normalized.buildingName(), normalized.unitName(), normalized.roomNo(), + defaultCategory(normalized.category(), candidate.memoryType()), + defaultTitle(normalized.title(), candidate.memoryType(), normalized), normalized.summary(), + normalized.detail(), status, format(occurredAt), format(followUpAt)); + MemoryDraft persisted = projectSearchDraft(candidate.memoryType(), confirmedInput); + + long memoryId = insertServiceMemory(principal, candidate, persisted, occurredAt, followUpAt); + jdbcTemplate.update(""" + insert into aihr_service_memory_version + (tenant_id, project_code, memory_id, version, snapshot_json, change_type, + change_reason, operator_user_id, create_time) + values (?, ?, ?, 1, ?, 'CONFIRM', null, ?, now()) + """, principal.tenantId(), projectCode, memoryId, json(persisted), principal.userId()); + try { + int updated = jdbcTemplate.update(""" + update aihr_memory_candidate + set status = 'CONFIRMED', target_id = ?, target_version = 1, version = version + 1, + confirm_idempotency_key = ?, confirm_request_hash = ?, confirmed_at = now(), + draft_json = null, missing_fields_json = null, update_time = now() + where tenant_id = ? and owner_user_id = ? and id = ? and status = 'DRAFT' and version = ? + """, memoryId, idempotencyKey, requestHash, principal.tenantId(), principal.userId(), + candidateId, request.expectedVersion()); + if (updated != 1) { + throw conflict("记忆候选已更新,请刷新后重试"); + } + } catch (DuplicateKeyException ex) { + throw conflict("该 idempotencyKey 已用于其他确认请求"); + } + return new ConfirmMemoryResponse(PROJECT_SERVICE, memoryId, 1); + } + + @Transactional(rollbackFor = Exception.class) + public MemoryCandidateResponse dismiss(Long candidateId) { + AihrKnowledgePrincipal principal = principalResolver.current(); + requireCandidateId(candidateId); + CandidateRow row = requireCandidateForUpdate(principal, candidateId); + if ("DISMISSED".equals(row.status()) || "EXPIRED".equals(row.status())) { + if (hasText(row.draftJson()) || hasText(row.missingFieldsJson())) { + jdbcTemplate.update(""" + update aihr_memory_candidate set draft_json = null, missing_fields_json = null + where tenant_id = ? and owner_user_id = ? and id = ? + """, principal.tenantId(), principal.userId(), candidateId); + } + return new MemoryCandidateResponse(row.id(), row.version(), row.status(), row.memoryType(), + row.targetDomain(), null, List.of(), format(row.expiresAt())); + } + if ("CONFIRMED".equals(row.status())) { + throw conflict("已确认的记忆不能再忽略"); + } + int updated = jdbcTemplate.update(""" + update aihr_memory_candidate + set status = 'DISMISSED', draft_json = null, missing_fields_json = null, + version = version + 1, dismissed_at = now(), update_time = now() + where tenant_id = ? and owner_user_id = ? and id = ? and version = ? + and status in ('DRAFT', 'NEEDS_INPUT') + """, principal.tenantId(), principal.userId(), candidateId, row.version()); + if (updated != 1) { + throw conflict("记忆候选已更新,请刷新后重试"); + } + return new MemoryCandidateResponse(row.id(), row.version() + 1, "DISMISSED", row.memoryType(), + row.targetDomain(), null, List.of(), format(row.expiresAt())); + } + + public List serviceMemories(String requestedProjectCode, String rawStatus, + String dateFrom, String dateTo, String buildingName, + String unitName, String roomNo, Integer requestedLimit) { + AihrKnowledgePrincipal principal = principalResolver.current(); + List projectCodes = authorizedProjects(principal, requestedProjectCode); + if (projectCodes.isEmpty()) { + return List.of(); + } + int limit = requestedLimit == null ? 50 : requestedLimit; + if (limit < 1 || limit > 100) { + throw badRequest("limit 仅支持 1-100"); + } + String status = upper(rawStatus); + if (hasText(status) && !MEMORY_STATUSES.contains(status)) { + throw badRequest("status 仅支持 OPEN/IN_PROGRESS/RESOLVED/VOID"); + } + LocalDate from = parseDate(dateFrom, "dateFrom"); + LocalDate to = parseDate(dateTo, "dateTo"); + if (from != null && to != null && from.isAfter(to)) { + throw badRequest("dateFrom 不能晚于 dateTo"); + } + + StringBuilder sql = new StringBuilder(""" + select id, project_code, memory_type, subject_type, building_name, unit_name, room_no, + category, title, summary, detail, status, occurred_at, follow_up_at, + source_conversation_id, source_request_id, created_by, confirmed_by, version, + create_time, update_time + from aihr_service_memory + where tenant_id = ? and project_code in (%s) + """.formatted(placeholders(projectCodes.size()))); + List args = new ArrayList<>(); + args.add(principal.tenantId()); + args.addAll(projectCodes); + if (hasText(status)) { + sql.append(" and status = ?"); + args.add(status); + } + if (from != null) { + sql.append(" and occurred_at >= ?"); + args.add(Timestamp.valueOf(from.atStartOfDay())); + } + if (to != null) { + sql.append(" and occurred_at < ?"); + args.add(Timestamp.valueOf(to.plusDays(1).atStartOfDay())); + } + appendExactFilter(sql, args, "building_name", buildingName, 100); + appendExactFilter(sql, args, "unit_name", unitName, 100); + appendExactFilter(sql, args, "room_no", roomNo, 100); + sql.append(" order by occurred_at desc, id desc limit ?"); + args.add(limit); + return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> mapServiceMemory(rs, List.of()), args.toArray()); + } + + public ServiceMemoryResponse serviceMemory(Long id) { + AihrKnowledgePrincipal principal = principalResolver.current(); + if (id == null || id <= 0) { + throw notFound(); + } + List projectCodes = authorizedProjects(principal, null); + if (projectCodes.isEmpty()) { + throw notFound(); + } + List args = new ArrayList<>(); + args.add(principal.tenantId()); + args.add(id); + args.addAll(projectCodes); + List rows = jdbcTemplate.query(""" + select id, project_code, memory_type, subject_type, building_name, unit_name, room_no, + category, title, summary, detail, status, occurred_at, follow_up_at, + source_conversation_id, source_request_id, created_by, confirmed_by, version, + create_time, update_time + from aihr_service_memory + where tenant_id = ? and id = ? and project_code in (%s) + limit 1 + """.formatted(placeholders(projectCodes.size())), + (rs, rowNum) -> mapServiceMemory(rs, List.of()), args.toArray()); + if (rows.isEmpty()) { + throw notFound(); + } + ServiceMemoryResponse row = rows.get(0); + List versions = jdbcTemplate.query(""" + select version, snapshot_json, change_type, change_reason, operator_user_id, create_time + from aihr_service_memory_version + where tenant_id = ? and project_code = ? and memory_id = ? + order by version desc + """, (rs, rowNum) -> new ServiceMemoryVersionResponse( + rs.getInt("version"), readSnapshot(rs.getString("snapshot_json")), rs.getString("change_type"), + rs.getString("change_reason"), rs.getLong("operator_user_id"), format(rs.getTimestamp("create_time")) + ), principal.tenantId(), row.projectCode(), id); + return copyWithVersions(row, versions); + } + + /** Query-path recall is read-only and fails closed when project scope is absent. */ + public ServiceMemoryRecall recall(AihrKnowledgePrincipal principal, String rawQuery, Integer requestedLimit) { + if (principal == null || principal.projectCodes().isEmpty() || !looksLikeMemoryQuery(rawQuery)) { + return ServiceMemoryRecall.empty(); + } + try { + String projectCode = resolveProjectCode(principal.projectCodes(), rawQuery); + if (!hasText(projectCode)) { + return ServiceMemoryRecall.empty(); + } + List projectCodes = List.of(projectCode); + int limit = Math.min(5, Math.max(1, requestedLimit == null ? 5 : requestedLimit)); + List terms = recallTerms(rawQuery); + if (terms.isEmpty()) { + return ServiceMemoryRecall.empty(); + } + StringBuilder sql = new StringBuilder(""" + select id, project_code, title, summary, status, occurred_at, update_time + from aihr_service_memory + where tenant_id = ? and project_code in (%s) and status <> 'VOID' + """.formatted(placeholders(projectCodes.size()))); + List args = new ArrayList<>(); + args.add(principal.tenantId()); + args.addAll(projectCodes); + // ponytail: bounded LIKE recall is enough for the first relational slice; add FTS/Qdrant after corpus growth. + sql.append(" and ("); + for (int i = 0; i < terms.size(); i++) { + if (i > 0) { + sql.append(" or "); + } + sql.append("concat_ws(' ', building_name, unit_name, room_no, category, title, summary, detail) like ?"); + args.add("%" + terms.get(i) + "%"); + } + sql.append(")"); + sql.append(" order by occurred_at desc, id desc limit ?"); + args.add(limit); + List items = jdbcTemplate.query(sql.toString(), (rs, rowNum) -> + new ServiceMemoryRecallItem( + rs.getLong("id"), rs.getString("project_code"), rs.getString("title"), + rs.getString("summary"), rs.getString("status"), format(rs.getTimestamp("occurred_at")), + format(rs.getTimestamp("update_time")) + ), args.toArray()); + return items.isEmpty() ? ServiceMemoryRecall.empty() : new ServiceMemoryRecall(items, recallAnswer(items)); + } catch (DataAccessException ex) { + log.warn("service memory recall unavailable; enterprise knowledge query continues"); + return ServiceMemoryRecall.empty(); + } + } + + static CandidatePlan detectPlan(AihrKnowledgePrincipal principal, String rawQuery, LocalDateTime now) { + String query = clean(rawQuery); + if (query.isEmpty() || query.length() > 1000) { + return null; + } + boolean explicit = containsAny(query, "记一下", "记录一下", "帮我记", "保存一下", "备忘"); + boolean resident = containsAny(query, "住户", "业主", "房号") || (BUILDING.matcher(query).find() && UNIT.matcher(query).find()); + boolean actionable = containsAny(query, "需要", "希望", "想要", "要求", "咨询", "报修", "投诉", "跟进", "回访", "联系", "处理", "提醒"); + if (!actionable || (!explicit && !resident) || (!explicit && looksLikeQuestion(query))) { + return null; + } + + String memoryType = containsAny(query, "跟进", "回访", "联系", "提醒", "后续处理") + ? "FOLLOW_UP" : "RESIDENT_NEED"; + String projectCode = resolveProjectCode(principal == null ? Set.of() : principal.projectCodes(), query); + String building = match(BUILDING, query, 0); + String unit = match(UNIT, query, 0); + String room = matchRoom(query); + List missing = new ArrayList<>(); + if (!hasText(projectCode)) { + missing.add("projectCode"); + } + if (!hasText(building)) { + missing.add("buildingName"); + } + if (!hasText(unit)) { + missing.add("unitName"); + } + if (!hasText(room)) { + missing.add("roomNo"); + } + String summary = query; + for (String location : List.of(clean(building), clean(unit), clean(room))) { + if (!location.isEmpty()) { + summary = summary.replace(location, ""); + } + } + summary = truncate(redactMemoryText(summary).replaceAll("\\s+", " ").trim(), 1000); + if (summary.isEmpty()) { + missing.add("summary"); + } + LocalDateTime occurredAt = (now == null ? LocalDateTime.now() : now).withNano(0); + MemoryDraft draft = new MemoryDraft( + projectCode, building, unit, room, + "FOLLOW_UP".equals(memoryType) ? "待跟进" : "住户需求", + defaultTitle("", memoryType, new MemoryDraft(projectCode, building, unit, room, + "", "", summary, "", "", format(occurredAt), null)), + summary, "", "FOLLOW_UP".equals(memoryType) ? "IN_PROGRESS" : "OPEN", + format(occurredAt), null); + String status = missing.isEmpty() ? "DRAFT" : "NEEDS_INPUT"; + return new CandidatePlan(projectCode, memoryType, status, draft, List.copyOf(missing), explicit ? 0.92 : 0.78); + } + + static CandidatePlan continuePlan(AihrKnowledgePrincipal principal, String memoryType, String existingProjectCode, + MemoryDraft existing, String rawQuery) { + if (existing == null || !hasText(rawQuery)) { + return null; + } + String query = clean(rawQuery); + if (looksLikeQuestion(query) || detectPlan(principal, query, LocalDateTime.now().withNano(0)) != null) { + return null; + } + String resolvedProject = hasText(existingProjectCode) ? existingProjectCode + : resolveProjectCode(principal == null ? Set.of() : principal.projectCodes(), query); + String building = hasText(existing.buildingName()) ? existing.buildingName() : match(BUILDING, query, 0); + String unit = hasText(existing.unitName()) ? existing.unitName() : match(UNIT, query, 0); + String room = hasText(existing.roomNo()) ? existing.roomNo() : matchRoom(query); + String summary = clean(existing.summary()); + if (summary.isEmpty() && containsAny(query, "需要", "希望", "想要", "要求", "咨询", "报修", "投诉", "跟进", "回访")) { + summary = truncate(redactMemoryText(query), 1000); + } + boolean progressed = (!hasText(existingProjectCode) && hasText(resolvedProject)) + || (!hasText(existing.buildingName()) && hasText(building)) + || (!hasText(existing.unitName()) && hasText(unit)) + || (!hasText(existing.roomNo()) && hasText(room)) + || (!hasText(existing.summary()) && hasText(summary)); + if (!progressed) { + return null; + } + List missing = new ArrayList<>(); + if (!hasText(resolvedProject)) missing.add("projectCode"); + if (!hasText(building)) missing.add("buildingName"); + if (!hasText(unit)) missing.add("unitName"); + if (!hasText(room)) missing.add("roomNo"); + if (!hasText(summary)) missing.add("summary"); + MemoryDraft mergedSeed = new MemoryDraft( + resolvedProject, building, unit, room, existing.category(), existing.title(), summary, + existing.detail(), existing.status(), existing.occurredAt(), existing.followUpAt()); + String title = hasText(existing.title()) && !existing.title().contains("待补充位置") + ? existing.title() : defaultTitle("", memoryType, mergedSeed); + MemoryDraft merged = new MemoryDraft( + resolvedProject, building, unit, room, defaultCategory(existing.category(), memoryType), title, + summary, existing.detail(), initialStatus(existing.status(), memoryType), + existing.occurredAt(), existing.followUpAt()); + return new CandidatePlan(resolvedProject, memoryType, missing.isEmpty() ? "DRAFT" : "NEEDS_INPUT", + merged, List.copyOf(missing), 0.92); + } + + private MemoryCandidateResponse continueRecentCandidate(AihrKnowledgePrincipal principal, String queryText, + String conversationId) { + String conversation = truncate(clean(conversationId), 64); + if (conversation.isEmpty()) { + return null; + } + List rows = jdbcTemplate.query(""" + select id, project_code, memory_type, draft_json, version, expires_at + from aihr_memory_candidate + where tenant_id = ? and owner_user_id = ? and source_conversation_id = ? + and status = 'NEEDS_INPUT' and expires_at > now() + order by update_time desc, id desc + limit 1 + """, (rs, rowNum) -> new ContinuationRow( + rs.getLong("id"), rs.getString("project_code"), rs.getString("memory_type"), + rs.getString("draft_json"), rs.getInt("version"), toLocalDateTime(rs.getTimestamp("expires_at")) + ), principal.tenantId(), principal.userId(), conversation); + if (rows.isEmpty()) { + return null; + } + ContinuationRow row = rows.get(0); + CandidatePlan merged = continuePlan( + principal, row.memoryType(), row.projectCode(), readDraft(row.draftJson()), queryText); + if (merged == null) { + return null; + } + String candidateHash = hash(new CandidateFingerprint( + merged.memoryType(), merged.projectCode(), merged.draft())); + int updated = jdbcTemplate.update(""" + update aihr_memory_candidate + set project_code = ?, status = ?, draft_json = ?, missing_fields_json = ?, + candidate_hash = ?, version = version + 1, update_time = now() + where tenant_id = ? and owner_user_id = ? and id = ? and status = 'NEEDS_INPUT' and version = ? + """, hasText(merged.projectCode()) ? merged.projectCode() : null, merged.status(), + json(merged.draft()), json(merged.missingFields()), candidateHash, + principal.tenantId(), principal.userId(), row.id(), row.version()); + if (updated != 1) { + return null; + } + return new MemoryCandidateResponse( + row.id(), row.version() + 1, merged.status(), merged.memoryType(), PROJECT_SERVICE, + merged.draft(), merged.missingFields(), format(row.expiresAt())); + } + + private MemoryCandidateResponse saveCandidate(AihrKnowledgePrincipal principal, CandidatePlan plan, + String conversationId, String requestId) { + String sourceConversationId = truncate(clean(conversationId), 64); + String sourceRequestId = truncate(clean(requestId), 64); + String candidateHash = hash(new CandidateFingerprint(plan.memoryType(), plan.projectCode(), plan.draft())); + LocalDateTime expiresAt = LocalDateTime.now().withNano(0).plusHours(24); + KeyHolder key = new GeneratedKeyHolder(); + try { + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + insert into aihr_memory_candidate + (tenant_id, owner_user_id, source_conversation_id, source_request_id, candidate_hash, + project_code, memory_type, target_domain, status, draft_json, missing_fields_json, + confidence, version, expires_at, create_time, update_time) + values (?, ?, ?, ?, ?, ?, ?, 'PROJECT_SERVICE', ?, ?, ?, ?, 1, ?, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, principal.tenantId()); + statement.setLong(2, principal.userId()); + statement.setString(3, sourceConversationId); + statement.setString(4, sourceRequestId); + statement.setString(5, candidateHash); + if (hasText(plan.projectCode())) { + statement.setString(6, plan.projectCode()); + } else { + statement.setNull(6, Types.VARCHAR); + } + statement.setString(7, plan.memoryType()); + statement.setString(8, plan.status()); + statement.setString(9, json(plan.draft())); + statement.setString(10, json(plan.missingFields())); + statement.setDouble(11, plan.confidence()); + statement.setTimestamp(12, Timestamp.valueOf(expiresAt)); + return statement; + }, key); + } catch (DuplicateKeyException duplicate) { + return findCandidateBySource(principal, sourceRequestId, candidateHash); + } + Number id = key.getKey(); + if (id == null) { + throw new ServiceException("创建记忆候选失败", 500); + } + return new MemoryCandidateResponse(id.longValue(), 1, plan.status(), plan.memoryType(), PROJECT_SERVICE, + plan.draft(), plan.missingFields(), format(expiresAt)); + } + + private MemoryCandidateResponse findCandidateBySource(AihrKnowledgePrincipal principal, String sourceRequestId, + String candidateHash) { + List rows = jdbcTemplate.query(""" + select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, + target_id, target_version, version, confirm_idempotency_key, confirm_request_hash, expires_at + from aihr_memory_candidate + where tenant_id = ? and owner_user_id = ? and source_request_id = ? and candidate_hash = ? + limit 1 + """, (rs, rowNum) -> mapCandidate(rs), principal.tenantId(), principal.userId(), + sourceRequestId, candidateHash); + if (rows.isEmpty()) { + throw conflict("同源记忆候选正在处理,请稍后重试"); + } + return candidateResponse(rows.get(0)); + } + + private CandidateRow requireCandidateForUpdate(AihrKnowledgePrincipal principal, Long id) { + List rows = jdbcTemplate.query(""" + select id, project_code, memory_type, target_domain, status, draft_json, missing_fields_json, + target_id, target_version, version, confirm_idempotency_key, confirm_request_hash, expires_at + from aihr_memory_candidate + where tenant_id = ? and owner_user_id = ? and id = ? + limit 1 for update + """, (rs, rowNum) -> mapCandidate(rs), principal.tenantId(), principal.userId(), id); + if (rows.isEmpty()) { + throw new ServiceException("记忆候选不存在或无权访问", 404); + } + return rows.get(0); + } + + private long insertServiceMemory(AihrKnowledgePrincipal principal, CandidateRow candidate, MemoryDraft draft, + LocalDateTime occurredAt, LocalDateTime followUpAt) { + KeyHolder key = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement statement = connection.prepareStatement(""" + insert into aihr_service_memory + (tenant_id, project_code, memory_type, subject_type, building_name, unit_name, room_no, + category, title, summary, detail, status, occurred_at, follow_up_at, + source_conversation_id, source_request_id, created_by, confirmed_by, version, + create_time, update_time) + select tenant_id, ?, memory_type, 'LOCATION', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + source_conversation_id, source_request_id, owner_user_id, ?, 1, now(), now() + from aihr_memory_candidate + where tenant_id = ? and owner_user_id = ? and id = ? and status = 'DRAFT' + """, Statement.RETURN_GENERATED_KEYS); + statement.setString(1, draft.projectCode()); + statement.setString(2, draft.buildingName()); + statement.setString(3, draft.unitName()); + statement.setString(4, draft.roomNo()); + statement.setString(5, draft.category()); + statement.setString(6, draft.title()); + statement.setString(7, draft.summary()); + statement.setString(8, draft.detail()); + statement.setString(9, draft.status()); + statement.setTimestamp(10, Timestamp.valueOf(occurredAt)); + if (followUpAt == null) { + statement.setNull(11, Types.TIMESTAMP); + } else { + statement.setTimestamp(11, Timestamp.valueOf(followUpAt)); + } + statement.setLong(12, principal.userId()); + statement.setString(13, principal.tenantId()); + statement.setLong(14, principal.userId()); + statement.setLong(15, candidate.id()); + return statement; + }, key); + Number id = key.getKey(); + if (id == null) { + throw conflict("记忆候选状态已变化"); + } + return id.longValue(); + } + + private void expireCandidates(AihrKnowledgePrincipal principal) { + jdbcTemplate.update(""" + update aihr_memory_candidate + set status = 'EXPIRED', draft_json = null, missing_fields_json = null, + version = version + 1, update_time = now() + where tenant_id = ? and owner_user_id = ? and status in ('DRAFT', 'NEEDS_INPUT') + and expires_at <= now() + """, principal.tenantId(), principal.userId()); + } + + /** + * Hourly lifecycle guard: expires abandoned drafts and removes terminal draft content. The cleanup is global, + * contains no user-controlled SQL, and is intentionally independent from a request transaction. + */ + @Scheduled( + fixedDelayString = "${aihr.memory.candidate-cleanup-interval-ms:3600000}", + initialDelayString = "${aihr.memory.candidate-cleanup-initial-delay-ms:60000}" + ) + public void cleanupCandidateDrafts() { + try { + jdbcTemplate.update(""" + update aihr_memory_candidate + set status = 'EXPIRED', draft_json = null, missing_fields_json = null, + version = version + 1, update_time = now() + where status in ('DRAFT', 'NEEDS_INPUT') and expires_at <= now() + """); + jdbcTemplate.update(""" + 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) + """); + } catch (DataAccessException ex) { + log.warn("service memory candidate cleanup unavailable"); + } + } + + private List authorizedProjects(AihrKnowledgePrincipal principal, String requestedProjectCode) { + List allowed = principal.projectCodes().stream().map(AihrMemoryService::clean) + .filter(AihrMemoryService::hasText).distinct().sorted().toList(); + String requested = clean(requestedProjectCode); + if (requested.isEmpty()) { + return allowed; + } + if (!allowed.contains(requested)) { + throw new ServiceException("项目记忆不存在或无权访问", HttpStatus.FORBIDDEN); + } + return List.of(requested); + } + + private static String requireAuthorizedProject(AihrKnowledgePrincipal principal, String rawProjectCode) { + String projectCode = clean(rawProjectCode); + if (projectCode.isEmpty() || principal.projectCodes().isEmpty()) { + throw conflict("当前身份未解析到唯一可用项目"); + } + boolean allowed = principal.projectCodes().stream().map(AihrMemoryService::clean) + .anyMatch(projectCode::equals); + if (!allowed) { + throw new ServiceException("项目记忆不存在或无权访问", HttpStatus.FORBIDDEN); + } + return projectCode; + } + + private static MemoryDraft normalizeDraft(MemoryDraft draft, String memoryType) { + return new MemoryDraft( + field(draft.projectCode(), 100, "projectCode"), + locationField(draft.buildingName(), BUILDING_VALUE, "buildingName"), + locationField(draft.unitName(), UNIT_VALUE, "unitName"), + locationField(draft.roomNo(), ROOM_VALUE, "roomNo"), + safeText(draft.category(), 50, "category"), + safeText(draft.title(), 500, "title"), + safeText(draft.summary(), 1000, "summary"), + safeText(draft.detail(), 4000, "detail"), + upper(draft.status()), + normalizeDateTime(draft.occurredAt(), "occurredAt"), + normalizeOptionalDateTime(draft.followUpAt(), "followUpAt") + ); + } + + 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 void requireLocation(MemoryDraft draft) { + List missing = new ArrayList<>(); + if (!hasText(draft.buildingName())) missing.add("buildingName"); + if (!hasText(draft.unitName())) missing.add("unitName"); + if (!hasText(draft.roomNo())) missing.add("roomNo"); + if (!missing.isEmpty()) { + throw badRequest("房屋定位不完整,请补充 " + String.join(",", missing)); + } + } + + private static String locationField(String value, Pattern format, String fieldName) { + String normalized = clean(value); + if (normalized.isEmpty()) { + return ""; + } + if (!format.matcher(normalized).matches() + || ID_CARD.matcher(normalized).find() + || NAMED_RESIDENT.matcher(normalized).find() + || !normalized.equals(AihrSensitiveText.forModel(normalized))) { + throw badRequest(fieldName + " 格式不正确或包含敏感信息"); + } + return normalized; + } + + private static String initialStatus(String rawStatus, String memoryType) { + String status = hasText(rawStatus) ? upper(rawStatus) + : ("FOLLOW_UP".equals(memoryType) ? "IN_PROGRESS" : "OPEN"); + if (!Set.of("OPEN", "IN_PROGRESS", "RESOLVED").contains(status)) { + throw badRequest("新确认记忆状态仅支持 OPEN/IN_PROGRESS/RESOLVED"); + } + return status; + } + + private static String defaultCategory(String category, String memoryType) { + return hasText(category) ? category : ("FOLLOW_UP".equals(memoryType) ? "待跟进" : "住户需求"); + } + + private static String defaultTitle(String title, String memoryType, MemoryDraft draft) { + if (hasText(title)) { + return title; + } + String location = clean(draft.buildingName()) + clean(draft.unitName()) + clean(draft.roomNo()); + return ("FOLLOW_UP".equals(memoryType) ? "跟进事项:" : "住户需求:") + + (location.isEmpty() ? "待补充位置" : location); + } + + /** + * The shared/searchable record never copies client free text. It is rebuilt from validated location fields and a + * closed service taxonomy, so an unrecognised name or health phrase cannot enter FULLTEXT even if a detector misses. + */ + static MemoryDraft projectSearchDraft(String memoryType, MemoryDraft input) { + String source = String.join(" ", clean(input.category()), clean(input.title()), clean(input.summary()), + clean(input.detail())); + String category; + if (containsAny(source, "养老", "助餐", "家政", "陪诊", "代购")) { + category = "养老服务"; + } else if (containsAny(source, "漏水", "水管", "马桶", "下水", "维修", "报修", "电梯", "门禁", "空调", "暖气", "燃气", "供水", "供电")) { + category = "报修维修"; + } else if (containsAny(source, "投诉", "噪音", "建议")) { + category = "投诉建议"; + } else if (containsAny(source, "停车", "车位", "充电桩")) { + category = "停车服务"; + } else if (containsAny(source, "保洁", "清洁", "卫生")) { + category = "保洁服务"; + } else if (containsAny(source, "装修", "施工")) { + category = "装修管理"; + } else if ("FOLLOW_UP".equals(memoryType) + || containsAny(source, "回访", "跟进", "联系", "预约")) { + category = "跟进事项"; + } else { + category = "服务需求"; + } + String location = clean(input.buildingName()) + clean(input.unitName()) + clean(input.roomNo()); + String title = location + category; + String summary = "FOLLOW_UP".equals(memoryType) + ? category + "待跟进" + : ("服务需求".equals(category) ? "住户提出服务需求" : "住户提出" + category + "需求"); + return new MemoryDraft( + input.projectCode(), input.buildingName(), input.unitName(), input.roomNo(), category, title, summary, "", + input.status(), input.occurredAt(), input.followUpAt()); + } + + private static String resolveProjectCode(Set rawProjectCodes, String query) { + List projectCodes = rawProjectCodes == null ? List.of() : rawProjectCodes.stream() + .map(AihrMemoryService::clean).filter(AihrMemoryService::hasText).distinct().toList(); + if (projectCodes.size() == 1) { + return projectCodes.get(0); + } + List mentioned = projectCodes.stream().filter(code -> mentionsProjectCode(query, code)).toList(); + return mentioned.size() == 1 ? mentioned.get(0) : ""; + } + + private static boolean mentionsProjectCode(String query, String projectCode) { + if (projectCode.matches("[A-Za-z0-9_-]+")) { + return Pattern.compile("(? recallTerms(String rawQuery) { + String query = clean(rawQuery); + LinkedHashSet terms = new LinkedHashSet<>(); + String building = match(BUILDING, query, 0); + String unit = match(UNIT, query, 0); + String room = matchRoom(query); + if (hasText(building)) terms.add(building); + if (hasText(unit)) terms.add(unit); + if (hasText(room)) terms.add(room); + RECALL_KEYWORDS.stream().filter(query::contains).sorted().forEach(terms::add); + return terms.stream().limit(6).toList(); + } + + private static boolean looksLikeMemoryQuery(String query) { + String value = clean(query); + if (containsAny(value, "记一下", "记录一下", "帮我记", "保存一下", "备忘")) { + return false; + } + boolean domainTerm = containsAny(value, "住户", "业主", "房号", "项目记录", "服务记录", "需求", + "跟进", "回访", "维修", "投诉", "养老", "漏水", "门禁", "停车") + || BUILDING.matcher(value).find(); + boolean completeLocation = BUILDING.matcher(value).find() + && UNIT.matcher(value).find() + && hasText(matchRoom(value)); + boolean explicitRecall = containsAny(value, "查一下", "查询", "查找", "找一下", "之前", "上次", + "历史", "哪些", "有没有", "进展", "状态", "项目记录", "服务记录", "记录里", "还记得"); + return domainTerm && (completeLocation || explicitRecall); + } + + private static boolean looksLikeQuestion(String query) { + return query.endsWith("?") || query.endsWith("?") + || containsAny(query, "什么", "哪些", "怎么", "如何", "是否", "吗", "查询", "查一下"); + } + + private static String recallAnswer(List items) { + StringBuilder answer = new StringBuilder("已找到当前项目权限内的相关记录:"); + for (ServiceMemoryRecallItem item : items) { + answer.append("\n- [").append(statusLabel(item.status())).append("] ") + .append(item.title()).append(":").append(item.summary()) + .append("(").append(displayDateTime(item.occurredAt())).append(")"); + } + return answer.toString(); + } + + private static String statusLabel(String status) { + return switch (clean(status)) { + case "OPEN" -> "待处理"; + case "IN_PROGRESS" -> "跟进中"; + case "RESOLVED" -> "已完成"; + case "VOID" -> "已作废"; + default -> "处理中"; + }; + } + + private static String displayDateTime(String value) { + String cleaned = clean(value).replace('T', ' '); + return cleaned.length() > 16 ? cleaned.substring(0, 16) : cleaned; + } + + private static void appendExactFilter(StringBuilder sql, List args, String column, + String rawValue, int max) { + String value = field(rawValue, max, column); + if (hasText(value)) { + sql.append(" and ").append(column).append(" = ?"); + args.add(value); + } + } + + private CandidateRow mapCandidate(ResultSet rs) throws SQLException { + return new CandidateRow( + rs.getLong("id"), rs.getString("project_code"), rs.getString("memory_type"), + rs.getString("target_domain"), rs.getString("status"), rs.getString("draft_json"), + rs.getString("missing_fields_json"), (Long) rs.getObject("target_id"), + (Integer) rs.getObject("target_version"), rs.getInt("version"), + rs.getString("confirm_idempotency_key"), rs.getString("confirm_request_hash"), + toLocalDateTime(rs.getTimestamp("expires_at")) + ); + } + + private MemoryCandidateResponse candidateResponse(CandidateRow row) { + return new MemoryCandidateResponse(row.id(), row.version(), row.status(), row.memoryType(), + row.targetDomain(), readDraft(row.draftJson()), readStrings(row.missingFieldsJson()), + format(row.expiresAt())); + } + + private ServiceMemoryResponse mapServiceMemory(ResultSet rs, List versions) + throws SQLException { + return new ServiceMemoryResponse( + rs.getLong("id"), rs.getString("project_code"), rs.getString("memory_type"), + rs.getString("subject_type"), rs.getString("building_name"), rs.getString("unit_name"), + rs.getString("room_no"), rs.getString("category"), rs.getString("title"), + rs.getString("summary"), rs.getString("detail"), rs.getString("status"), + format(rs.getTimestamp("occurred_at")), format(rs.getTimestamp("follow_up_at")), + rs.getString("source_conversation_id"), rs.getString("source_request_id"), + rs.getLong("created_by"), rs.getLong("confirmed_by"), rs.getInt("version"), + format(rs.getTimestamp("create_time")), format(rs.getTimestamp("update_time")), versions + ); + } + + private static ServiceMemoryResponse copyWithVersions(ServiceMemoryResponse row, + List versions) { + return new ServiceMemoryResponse( + row.id(), row.projectCode(), row.memoryType(), row.subjectType(), row.buildingName(), row.unitName(), + row.roomNo(), row.category(), row.title(), row.summary(), row.detail(), row.status(), row.occurredAt(), + row.followUpAt(), row.sourceConversationId(), row.sourceRequestId(), row.createdBy(), row.confirmedBy(), + row.version(), row.createdAt(), row.updatedAt(), versions + ); + } + + private MemoryDraft readDraft(String json) { + if (!hasText(json)) { + return null; + } + try { + return objectMapper.readValue(json, MemoryDraft.class); + } catch (Exception ex) { + throw new ServiceException("记忆候选草稿无法读取", 500); + } + } + + private List readStrings(String json) { + if (!hasText(json)) { + return List.of(); + } + try { + List values = objectMapper.readValue(json, STRING_LIST); + return values == null ? List.of() : List.copyOf(values); + } catch (Exception ex) { + throw new ServiceException("记忆候选缺失字段无法读取", 500); + } + } + + private Map readSnapshot(String json) { + if (!hasText(json)) { + return Map.of(); + } + try { + Map snapshot = objectMapper.readValue(json, SNAPSHOT_MAP); + return snapshot == null ? Map.of() : snapshot; + } catch (Exception ex) { + throw new ServiceException("项目记忆版本快照无法读取", 500); + } + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new ServiceException("记忆数据序列化失败", 500); + } + } + + private String hash(Object value) { + return sha256(json(value)); + } + + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + throw new IllegalStateException("SHA-256 unavailable", ex); + } + } + + private static String normalizeDateTime(String value, String field) { + LocalDateTime dateTime = parseDateTime(value, field, false); + return format(dateTime); + } + + private static String normalizeOptionalDateTime(String value, String field) { + LocalDateTime dateTime = parseDateTime(value, field, true); + return format(dateTime); + } + + private static LocalDateTime parseDateTime(String raw, String field, boolean optional) { + String value = clean(raw); + if (value.isEmpty()) { + if (optional) { + return null; + } + throw badRequest(field + " 必填"); + } + try { + return OffsetDateTime.parse(value).toLocalDateTime().withNano(0); + } catch (DateTimeParseException ignored) { + try { + return LocalDateTime.parse(value).withNano(0); + } catch (DateTimeParseException ex) { + throw badRequest(field + " 必须为 ISO-8601 时间"); + } + } + } + + private static LocalDate parseDate(String raw, String field) { + String value = clean(raw); + if (value.isEmpty()) { + return null; + } + try { + return LocalDate.parse(value); + } catch (DateTimeParseException ex) { + throw badRequest(field + " 必须为 yyyy-MM-dd"); + } + } + + private static String format(Timestamp value) { + return value == null ? null : format(value.toLocalDateTime()); + } + + private static String format(LocalDateTime value) { + return value == null ? null : value.withNano(0).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + private static LocalDateTime toLocalDateTime(Timestamp value) { + return value == null ? null : value.toLocalDateTime(); + } + + private static String field(String raw, int max, String name) { + String value = clean(raw); + if (value.length() > max) { + throw badRequest(name + " 长度不能超过 " + max); + } + return value; + } + + private static String safeText(String raw, int max, String name) { + String value = field(raw, max, name); + rejectSearchSensitiveText(value, name); + return redactMemoryText(value); + } + + private static void rejectSearchSensitiveText(String value, String fieldName) { + String text = clean(value); + if (text.isEmpty()) { + return; + } + if (HEALTH_SENSITIVE.matcher(text).find() + || EXPLICIT_PERSON_NAME.matcher(text).find() + || NAMED_RESIDENT.matcher(text).find()) { + throw badRequest(fieldName + " 不得包含住户姓名或健康信息,请仅保留服务需求"); + } + } + + private static String redactMemoryText(String value) { + String redacted = clean(AihrSensitiveText.forModel(clean(value))); + redacted = ID_CARD.matcher(redacted).replaceAll("[证件号已脱敏]"); + return NAMED_RESIDENT.matcher(redacted).replaceAll("匿名住户"); + } + + private static String match(Pattern pattern, String text, int group) { + Matcher matcher = pattern.matcher(text); + return matcher.find() ? clean(matcher.group(group)) : ""; + } + + private static String matchRoom(String text) { + Matcher matcher = ROOM.matcher(text); + if (!matcher.find()) { + return ""; + } + return clean(matcher.group(1) == null ? matcher.group(2) : matcher.group(1)); + } + + private static boolean containsAny(String value, String... needles) { + for (String needle : needles) { + if (value.contains(needle)) { + return true; + } + } + return false; + } + + private static String placeholders(int size) { + return String.join(",", java.util.Collections.nCopies(size, "?")); + } + + private static String truncate(String value, int max) { + String text = value == null ? "" : value; + return text.length() <= max ? text : text.substring(0, max); + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static String upper(String value) { + return clean(value).toUpperCase(Locale.ROOT); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private static void requireCandidateId(Long id) { + if (id == null || id <= 0) { + throw new ServiceException("记忆候选不存在或无权访问", 404); + } + } + + private static ServiceException badRequest(String message) { + return new ServiceException(message, HttpStatus.BAD_REQUEST); + } + + private static ServiceException conflict(String message) { + return new ServiceException(message, HttpStatus.CONFLICT); + } + + private static ServiceException notFound() { + return new ServiceException("项目记忆不存在或无权访问", 404); + } + + record CandidatePlan( + String projectCode, + String memoryType, + String status, + MemoryDraft draft, + List missingFields, + double confidence + ) { + } + + private record CandidateRow( + Long id, + String projectCode, + String memoryType, + String targetDomain, + String status, + String draftJson, + String missingFieldsJson, + Long targetId, + Integer targetVersion, + int version, + String confirmIdempotencyKey, + String confirmRequestHash, + LocalDateTime expiresAt + ) { + } + + private record CandidateFingerprint(String memoryType, String projectCode, MemoryDraft draft) { + } + + private record ConfirmFingerprint(Long candidateId, Long expectedVersion, MemoryDraft draft, + boolean enableReminder) { + } + + private record ContinuationRow( + Long id, + String projectCode, + String memoryType, + String draftJson, + int version, + LocalDateTime expiresAt + ) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrServiceMemoryController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrServiceMemoryController.java new file mode 100644 index 00000000..8fb97988 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/AihrServiceMemoryController.java @@ -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( + @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 detail(@PathVariable Long id) { + return R.ok(memoryService.serviceMemory(id)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java index 853547e0..7929b90b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java @@ -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)); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/memory/AihrMemoryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/memory/AihrMemoryServiceTest.java new file mode 100644 index 00000000..1e45d07c --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/memory/AihrMemoryServiceTest.java @@ -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 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 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 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 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 List query(String sql, RowMapper 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 List query(String sql, RowMapper 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; + } + } +} diff --git a/backend/script/sql/aihr_service_memory_mysql8.sql b/backend/script/sql/aihr_service_memory_mysql8.sql new file mode 100644 index 00000000..e034c596 --- /dev/null +++ b/backend/script/sql/aihr_service_memory_mysql8.sql @@ -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; diff --git a/backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql b/backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql new file mode 100644 index 00000000..f236491e --- /dev/null +++ b/backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql @@ -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; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 406c8c09..6a710a90 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -14,6 +14,7 @@ | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | | 问师傅与 SOP 知识库 `/pages/user/sop/index`、`/knowledge/sop` | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最近 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。旧客户端和外部 API 保持单轮无状态。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED` | +| 工作助手确认式项目记忆 `/pages/user/sop/index`、`/pages/user/assistant/memories` | 既有 `POST /api/knowledge/query` 可选返回 `memoryCandidate`;`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm`、`POST .../{id}/dismiss`、`GET /api/aihr/service-memories`、`GET /api/aihr/service-memories/{id}` | 2026-07-19 最小切片只识别 `RESIDENT_NEED/FOLLOW_UP` 并保存到 `PROJECT_SERVICE`。用户文字或 ASR 转写进入同一候选检测;候选草稿不是长期记忆,只有当前员工显式确认后才按服务端解析出的 `tenant_id + project_code` 写入项目服务记录和版本表。多项目无法唯一判断时返回 `NEEDS_INPUT`,不能显示确认按钮;确认携带 `expectedVersion + idempotencyKey`,同键同内容重放返回原结果,同键不同内容或旧版本返回冲突。提醒默认关闭;首批不创建个人知识库、Qdrant collection,也不把个人想法写入项目域 | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-15 线上已用 `/api/open/v1` 前缀完成配置、dry-run 和覆盖同步:开放平台返回公司 17、部门 963、员工 3417;生产 `aihr_org_snapshot` 为 3417 行,`phoneLinked=3392`、`maskedPhone=25`、`suspectText=0`,在职 2943、离职 474。当前剩余 25 人手机号不可用属于上游数据质量问题;功能发布不再被组织同步能力阻塞 | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`)。prod 默认关闭,试点期只有同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 才启用固定码。 | @@ -220,3 +221,55 @@ Content-Type: application/json 成功响应包含 `requestId`、`answer`、`citations`、`usedSpaceCodes`、`noEvidence` 和兼容 `legacy` 数据。引用携带 `spaceCode/sourceType/docId/title/snippet/fragmentId`;客户端不得把无引用回答包装成有知识依据的正式答案。 空间、授权和应用管理接口统一位于 `/api/knowledge/admin`:`spaces`、`spaces/{id}/grants`、`spaces/{id}/documents`、`apps`、`apps/{id}/spaces`、`apps/{id}/rotate-token`。`DELETE /spaces/{id}/documents/{attachId}` 只解绑当前空间成员;其他空间仍引用同一 OSS 时原文件不会删除。仅知识平台管理角色可调用,API_TOKEN 创建或轮换后只返回一次明文。完整初始化、令牌保管、legacy 迁移与回滚步骤见 [KNOWLEDGE_PLATFORM_RUNBOOK.md](KNOWLEDGE_PLATFORM_RUNBOOK.md)。 + +## 工作助手项目服务记忆最小切片 + +该切片复用上面的短会话请求,不新增另一套问答 session。登录员工说“记一下:3 栋 2 单元 1201 室住户想了解养老服务,十天后回访”时,`POST /api/knowledge/query` 在正常回答字段之外可返回: + +```json +{ + "memoryCandidate": { + "id": 301, + "version": 1, + "status": "DRAFT", + "memoryType": "RESIDENT_NEED", + "targetDomain": "PROJECT_SERVICE", + "draft": { + "buildingName": "3栋", + "unitName": "2单元", + "roomNo": "1201", + "category": "养老服务咨询", + "summary": "住户希望了解社区养老服务", + "occurredAt": "2026-07-19T14:00:00+08:00", + "followUpAt": null + }, + "missingFields": [], + "expiresAt": "2026-07-20T14:00:00+08:00" + } +} +``` + +确认请求只提交用户在卡片上确认或修改的草稿;`projectCode` 即使传入也不能作为授权依据: + +```http +POST /api/aihr/personal-assistant/memory-candidates/301/confirm +Authorization: Bearer +Content-Type: application/json + +{ + "expectedVersion": 1, + "idempotencyKey": "memory-confirm-301-", + "enableReminder": false, + "draft": { + "buildingName": "3栋", + "unitName": "2单元", + "roomNo": "1201", + "category": "养老服务咨询", + "summary": "住户希望了解社区养老服务", + "occurredAt": "2026-07-19T14:00:00+08:00", + "followUpAt": null + } +} +``` + +服务端必须在事务中锁定候选,重新解析当前员工有效项目,写入 `aihr_service_memory` 与 `aihr_service_memory_version` 后再把候选改为 `CONFIRMED`。`DRAFT` 默认 24 小时过期;`NEEDS_INPUT` 只用于继续追问。列表、详情和后续问答召回必须同时过滤当前租户和授权 `project_code`,不得因为记录由本人创建而绕过项目权限。 diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index c3df99b4..7cff3b90 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -14,6 +14,8 @@ 上述事实只证明本批版本和 schema 已发布。正式 OTP、真机能力、角色权限正反例和严格试点窗口仍需按发布后回归及 `DEMO_ACCEPTANCE.md` 验收。 +2026-07-19 增量在上述基线上新增“项目服务记忆最小切片”:候选信息只有在当前员工明确确认后,才会写入其已授权项目的服务记录;本批不包含个人想法/经验的长期知识库、跨项目共享、自动提醒或向量化个人资料。迁移后结构门禁由 `35/35` 增至 `38/38`。 + ## 发布前置条件 1. 确认发布窗口、DB 备份和回滚负责人;迁移脚本包含 `ALTER TABLE`,不能在无备份状态执行。 @@ -48,9 +50,11 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_unify_all_collations_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql ``` -顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移必须最后执行,它会读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则,并将 19 张发布表统一到同一规则,兼容历史生产库与全新 MySQL 8 数据库。 +顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移最后创建候选、正式记录和版本留痕三张表,同样对齐该规则,兼容历史生产库与全新 MySQL 8 数据库。 ## 迁移后只读核验 @@ -74,7 +78,8 @@ WHERE table_schema = DATABASE() 'aihr_incentive_rule', 'aihr_points_ledger', 'aihr_candidate_material', 'aihr_interview_result', 'aihr_candidate_employee_link', 'aihr_position_responsibility', 'aihr_sop_applicability', - 'aihr_onboard_task', 'aihr_qualification_gate' + 'aihr_onboard_task', 'aihr_qualification_gate', + 'aihr_memory_candidate', 'aihr_service_memory', 'aihr_service_memory_version' ) ORDER BY table_name; @@ -117,14 +122,15 @@ GROUP BY question_type ORDER BY question_type; ``` -预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、两张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表和四张岗位/SOP/任务/资格契约表均存在;会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表为空是允许的;这只证明 schema/seed 迁移完成,不证明正式岗位规则、资格 gating 或试点完成。 +预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、两张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表,以及三张项目服务记忆表均存在;会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新服务记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。 ## 迁移后应用回归 1. 重启 `wygj-aihr.service`,确认状态为 `active`。 2. 检查根站、`/h5/`、租户接口和员工首页 API 的 HTTP 与业务码均为 `200`。 3. 用正式测试账号完成员工端训练、主管复盘和管理端场景/Prompt 只读检查;不要使用开发固定验证码验证生产安全策略。 -4. 迁移完成后再按同一正式时间窗运行: +4. 用具备单一项目授权的正式测试账号,在“工作助手”说出一条包含房号、住户需求和跟进时间的测试信息;确认候选卡前数据库不得出现正式服务记录,确认后只能写入该账号已授权的 `project_code`。随后新开对话查询该事项,必须能召回;双击确认不得重复写入。测试结束保留审计证据,不直接删除生产业务记录。 +5. 迁移完成后再按同一正式时间窗运行: ```bash AIHR_PILOT_TENANT_ID=000000 \ diff --git a/docs/DEMO_ACCEPTANCE.md b/docs/DEMO_ACCEPTANCE.md index e43b07e1..5fc08f34 100644 --- a/docs/DEMO_ACCEPTANCE.md +++ b/docs/DEMO_ACCEPTANCE.md @@ -21,6 +21,7 @@ | 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看案例详情/回放原始音频 | 已完成语音转写、原始音频 OSS 留痕、整理、入库、学习点、主管点评和按项目权限查看持久化转写/摘要详情;预渲染视频样片仍未接入,不把音频回放当作“查看样片”完成证据 | | 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 | | 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 点“批量导入”选择少量文件或 ZIP | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态、批次进度和事件列表;页面不出现“选择目录”“服务端导入” | +| 8 | 员工端工作助手 `/h5/#/pages/user/sop/index` | 语音或文字说“记一下:3 栋 2 单元 1201 室住户想了解养老服务,十天后回访” → 查看候选卡 → 先暂不保存,再重新生成并确认 → 打开“我的资料与记忆” → 新对话按房号查询 | 一级只显示“工作助手 / 查全网”;候选卡可修改、暂不保存、确认保存,提醒默认关闭;暂不保存不写正式记录,确认后只写当前授权项目且双击不重复;新对话能按项目权限召回并展示来源。该项只验收项目服务记忆最小切片,不代表完整个人资料库已交付 | ## AI陪练二期增量脚本 @@ -48,5 +49,6 @@ ## 不演示 - 不演示 SSO、权限配置、知识图谱、数字人视频生成、绩效挂钩。 +- 不把“项目服务记忆最小切片”演示成完整个人助理:个人文档/网页收藏、个人经验知识库、PPT 生成、自动提醒和项目服务记忆 Qdrant 向量化仍不在本批范围。 - 不承诺管理端 AI 面试、案例沉淀已写入正式业务数据库;三角色对练(管理端与移动端)记录写入 `aihr_practice_session`;候选人补充资料写 `sys_oss` + `aihr_candidate_material`,HR 可审核状态,但不做复杂审批流转;SOP 检索与文档上传使用 `aihr_knowledge_*` 本地表,失败时回退 seed。 - 大模型不作为演示硬依赖:模型管理已启用 chat 模型时,三角色对练为真实 LLM 生成与评分(asr/tts 配置后语音输入/播报可用);未配置或现场调用失败时全链路自动回退 seed,演示不中断。 diff --git a/docs/DEV_SETUP.md b/docs/DEV_SETUP.md index 47a88620..2b6bbe36 100644 --- a/docs/DEV_SETUP.md +++ b/docs/DEV_SETUP.md @@ -219,6 +219,7 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true R - SOP知识库:进入 `/knowledge/sop`,可上传 txt/md/PDF/Word/Excel/PPT 文档入库;点击“检索” → “生成训练题”,应看到“已完成闭环”、命中数据库 SOP 原文片段和训练题;数据库不可用时页面回退 seed。 - 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;页面只保留“批量导入”和“刷新”,不应出现“选择目录”“服务端导入”或目录导入任务面板。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;支持多文件和 ZIP,暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。 - uni-app 员工端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`,手机号登录(dev 验证码固定 `123456`)。今日页进入问师傅、练习、案例素材和个人页;练习页应能完成开始练习、提交回应、结束评分、每日三题提交,个人页同步训练历史和成长证据包。每日三题正式按组织快照 `hire_date` 判断入职三个月窗口;本地 Demo 若快照尚无该字段,仅由 `dev/local` profile 且 `application-dev.yml` 的 `aihr.practice.allow-legacy-daily-drill-fallback=true` 启用训练次数回退,生产 profile 即使误传环境变量也强制关闭。 +- 工作助手项目服务记忆:`/h5/#/pages/user/sop/index` 一级入口只保留“工作助手 / 查全网”,既有文字、ASR、媒体、数据工具和 30 分钟/最近 6 轮短会话链保持不变;`/h5/#/pages/user/assistant/memories` 展示待确认候选和当前项目可见的服务记录。首批只落 `aihr_memory_candidate`、`aihr_service_memory`、`aihr_service_memory_version` 三张关系表,不依赖新的 Qdrant collection。候选卡未确认前不得查询到正式记录,提醒默认关闭。 - 移动端登录后若组织快照接口返回 `401/403`,只降级为手动岗位确认,不应清除手机号登录态;岗位确认页仍需允许员工选择“生活顾问”后继续进入业务页。 - uni-app 主管端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/supervisor/index/index`,应看到团队概览、团队画像、团队预警、指派专项、待复盘和复盘详情;从团队画像派专项后,最近专项应立即回读新记录。 - uni-app 候选人端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/candidate/index/index`,手机号登录(dev 验证码固定 `123456`)→ “开始面试/面试练习”完成答题评分;“补充资料”选择 PDF/Word/图片后上传,应看到资料状态。Codex 内置浏览器不支持本地文件选择时,用真实 HTTP multipart smoke 代替浏览器文件选择。 @@ -228,6 +229,12 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true R ```bash ./scripts/demo-check.sh +bash scripts/tests/aihr-schema-migrations.test.sh +node --test mobile-uni/tests/personal-assistant.test.mjs +API_BASE=https://wygj-api.localhost ./scripts/personal-assistant-smoke.sh +# 登录后只读核验:TOKEN= API_BASE=https://wygj-api.localhost ./scripts/personal-assistant-smoke.sh ``` +工作助手记忆本地人工回归至少覆盖:以有且只有一个 `project_code` 的员工登录;说出完整房屋定位、需求和时间后看到 `DRAFT` 卡;刷新“我的资料与记忆”仍能恢复;点击“暂不保存”不产生正式记录;重新生成后双击“确认保存”只写一条记录和一个初始版本;新开对话可按房号或事项召回。再用无项目、多个项目无法唯一判断及另一项目账号做反例,分别应为安全拒绝/`NEEDS_INPUT`/不可见。 + 完整演示脚本与录屏兜底见 [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md)。 diff --git a/docs/个人AI助理阶段二专项TechSpec.md b/docs/个人AI助理阶段二专项TechSpec.md index 7be3ba0c..660476fb 100644 --- a/docs/个人AI助理阶段二专项TechSpec.md +++ b/docs/个人AI助理阶段二专项TechSpec.md @@ -70,7 +70,7 @@ | 全文检索 | 使用个人片段表 MySQL Fulltext | 查询必须包含 `tenant_id + owner_user_id` | | 向量检索 | 新建 `aihr_personal_knowledge` collection | Qdrant filter 必须包含 `tenant_id + owner_user_id` | | 企业知识检索 | 调用现有企业 RAG 服务 | 继续执行企业知识密级、项目和角色权限 | -| 项目业务记忆 | 独立 `aihr_service_memory` 关系表与同名 Qdrant collection | 查询必须包含 `tenant_id + project_id`,不得写入个人或企业知识表 | +| 项目业务记忆 | 独立 `aihr_service_memory` 关系表;关系库检索为 2026-07-19 最小切片,Qdrant 仅列为后续增强 | 查询必须包含 `tenant_id + project_code`,不得写入个人或企业知识表 | 禁止将个人资料写入: @@ -87,7 +87,7 @@ → AUTO: 意图路由只在三个内部授权域中选择;MANUAL: 校验用户指定的内部范围 → 校验日期、itemIds、知识范围和配额 → personal: 个人 Fulltext + 个人 Qdrant(强制 owner filter) - → project_service: 项目业务记忆 Fulltext + 独立 Qdrant(强制 project filter) + → project_service: 首批用关系库检索并强制 project_code filter;后续再叠加独立 Qdrant → enterprise: 企业 Fulltext + 企业 Qdrant(强制企业权限) → mixed: 所选域分别检索并授权后融合,不做跨域原始结果直连 → Rerank @@ -197,8 +197,8 @@ org.dromara.aihr.knowledge.parse ### 3.4 项目业务记忆权限 -1. `project_id` 必须由当前登录员工的有效项目任职解析,客户端不得指定越权项目。 -2. 项目业务记忆的列表、详情、修改和召回必须同时过滤 `tenant_id + project_id`,创建人不因“本人记录”获得跨项目访问权。 +1. `project_code` 必须由当前登录员工的有效项目任职解析,沿用 `aihr_org_snapshot.project_code` 和现有知识授权口径;客户端不得指定越权项目。 +2. 项目业务记忆的列表、详情、修改和召回必须同时过滤 `tenant_id + project_code`,创建人不因“本人记录”获得跨项目访问权。 3. 住户姓名、电话、身份证件和健康信息不作为自由文本检索字段;首批优先保存房屋定位、需求摘要、跟进状态和必要说明。 4. 发送外部模型前先由服务端提取并令牌化可识别信息;模型输出不得覆盖服务端保存的主体定位。 5. 员工修改已确认记录必须携带 `expectedVersion`,成功后新增版本快照,不原地抹除历史。 @@ -305,8 +305,8 @@ Message 保存: | 字段 | 类型 | 说明 | |---|---|---| | id | bigint PK | 候选 ID | -| tenant_id/owner_user_id/project_id | varchar/bigint | 当前身份与候选项目范围 | -| source_session_id/source_message_id | bigint | 来源会话与消息 | +| tenant_id/owner_user_id/project_code | varchar | 当前身份与候选项目范围;项目键使用现有组织快照的 `project_code`,不是自造数值 ID | +| source_conversation_id/source_request_id | varchar(64) | 来源短会话与本次问答请求;当前「问」链没有持久化消息 ID | | candidate_hash | varchar(64) | 来源 + 规范化草稿哈希,用于防重复 | | memory_type | varchar(30) | PERSONAL_NOTE / WORK_IDEA / RESIDENT_NEED / FOLLOW_UP | | target_domain | varchar(30) | PERSONAL / PROJECT_SERVICE | @@ -314,11 +314,13 @@ Message 保存: | draft_json | json | 可编辑结构化草稿;不得包含未脱敏模型原始响应 | | missing_fields_json | json | 仍需追问的字段 | | confidence | decimal(5,4) | 只决定是否建议展示,不决定自动保存 | +| version | int | 乐观锁版本,从 1 开始 | +| confirm_idempotency_key/confirm_request_hash | varchar | 确认写入的稳定幂等键及请求内容哈希 | | target_id | bigint null | 确认后生成的个人 item 或业务记忆 ID | | expires_at/confirmed_at | datetime null | 草稿过期与确认时间 | | create_time/update_time | datetime | 审计时间 | -约束:`UNIQUE(tenant_id, owner_user_id, source_message_id, candidate_hash)`;DRAFT 默认 24 小时过期,DISMISSED/EXPIRED 的 `draft_json` 在 24 小时内物理清理。 +约束:`UNIQUE(tenant_id, owner_user_id, source_request_id, candidate_hash)`;DRAFT 默认 24 小时过期,DISMISSED/EXPIRED 的 `draft_json` 在 24 小时内物理清理。 ### 4.8 `aihr_service_memory` @@ -327,7 +329,7 @@ Message 保存: | 字段 | 类型 | 说明 | |---|---|---| | id | bigint PK | 业务记忆 ID | -| tenant_id/project_id | varchar/bigint | 租户与项目数据范围 | +| tenant_id/project_code | varchar | 租户与现有项目授权键 | | subject_type | varchar(20) | RESIDENT / LOCATION | | resident_external_id | varchar(100) null | 仅权威住户主数据接入后保存;不得自行生成 | | building_name/unit_name/room_no | varchar | 员工确认的结构化房屋定位 | @@ -336,12 +338,12 @@ Message 保存: | status | varchar(20) | OPEN / IN_PROGRESS / RESOLVED / VOID | | occurred_at | datetime | 事件发生或获知时间 | | follow_up_at | datetime null | 用户单独确认的跟进时间;空值不产生提醒 | -| source_session_id/source_message_id | bigint | 可追溯来源 | +| source_conversation_id/source_request_id | varchar(64) | 可追溯来源 | | created_by/confirmed_by | bigint | 记录人与确认人 | -| version | int | 乐观锁版本,从 1 开始 | +| version | int | 当前乐观锁版本,从 1 开始 | | create_time/update_time | datetime | 审计时间 | -索引至少包含 `(tenant_id, project_id, status, follow_up_at)`、`(tenant_id, project_id, building_name, unit_name, room_no)`;全文检索只覆盖 `title/summary/detail`,向量 payload 强制包含 `tenant_id + project_id + memory_id + status`。 +索引至少包含 `(tenant_id, project_code, status, follow_up_at)`、`(tenant_id, project_code, building_name, unit_name, room_no)`;首批关系库检索覆盖 `title/summary/detail`,后续若启用向量检索,payload 强制包含 `tenant_id + project_code + memory_id + status`。 ### 4.9 `aihr_service_memory_version` @@ -351,11 +353,12 @@ Message 保存: ### 5.1 Qdrant -- collections:个人资料使用 `aihr_personal_knowledge`;项目业务记忆使用 `aihr_service_memory`,两者不混 collection。 +- 完整阶段目标:个人资料使用 `aihr_personal_knowledge` collection;项目业务记忆使用独立 `aihr_service_memory` collection,两者不混 collection。 +- 2026-07-19 项目服务记忆最小切片只使用 MySQL 关系库,不创建新的 Qdrant collection;不得因未做向量化而回退到无权限过滤的企业 collection。 - 向量维度:跟随当前启用的 vector 模型;模型或维度变化使用独立重建任务。 - 个人 payload:`tenant_id`、`owner_user_id`、`space_id`、`item_id`、`fragment_id`、`source_type`、`captured_at`;必建 index 为 `tenant_id`、`owner_user_id`、`item_id`。 -- 项目业务记忆 payload:`tenant_id`、`project_id`、`memory_id`、`status`、`occurred_at`、`updated_at`;必建 index 为 `tenant_id`、`project_id`、`memory_id`、`status`。 -- 个人查询无 owner filter、项目业务查询无 project filter 时,检索服务直接拒绝执行并记录安全日志。 +- 项目业务记忆后续 payload:`tenant_id`、`project_code`、`memory_id`、`status`、`occurred_at`、`updated_at`;必建 index 为 `tenant_id`、`project_code`、`memory_id`、`status`。 +- 个人查询无 owner filter、项目业务查询无 project_code filter 时,检索服务直接拒绝执行并记录安全日志。 - `aihr_memory_candidate` 的任何状态都不写 Qdrant;只有关系库确认事务提交成功后才异步索引目标记录。 ### 5.2 OSS @@ -508,7 +511,7 @@ Message 保存: "targetDomain": "PROJECT_SERVICE", "memoryType": "RESIDENT_NEED", "subject": { - "projectId": 88, + "projectCode": "YC-RESIDENTIAL-03", "buildingName": "3栋", "unitName": "2单元", "roomNo": "1201" @@ -520,7 +523,7 @@ Message 保存: } ``` -服务端必须忽略请求中的 `projectId` 作为授权依据,仅用它与当前登录态可访问项目做一致性校验。确认成功返回 `targetDomain/targetId/version`;同一 `idempotencyKey` 重放必须返回同一结果,不重复创建记录。 +服务端必须忽略请求中的 `projectCode` 作为授权依据,仅用它与当前登录态可访问项目做一致性校验;客户端可省略该字段。确认成功返回 `targetDomain/targetId/version`;同一 `idempotencyKey` 重放必须返回同一结果,不重复创建记录,同一键配不同请求体必须拒绝。 ## 7. 网页采集安全 @@ -759,6 +762,7 @@ src/services/personal-assistant.ts API 客户端 - `backend/script/sql/aihr_personal_knowledge_mysql8.sql` - `backend/script/sql/aihr_service_memory_mysql8.sql` +- `backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql`(项目服务记忆最小切片生产增量) - `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/**` - `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/**`(候选识别、确认编排与项目业务记忆) - `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/**` diff --git a/mobile-uni/src/components/chat/MemoryCandidateCard.vue b/mobile-uni/src/components/chat/MemoryCandidateCard.vue new file mode 100644 index 00000000..b696e2e5 --- /dev/null +++ b/mobile-uni/src/components/chat/MemoryCandidateCard.vue @@ -0,0 +1,518 @@ +