From bf703b7853028dffd47f9c02e46b0d59ec45b305 Mon Sep 17 00:00:00 2001 From: key Date: Fri, 31 Jul 2026 00:28:41 +0800 Subject: [PATCH] fix(knowledge): gate policy answers by formal sources --- .../aihr/agent/AihrAgentOrchestrator.java | 14 ++- .../service/AihrFormalPolicyClassifier.java | 52 ++++++++++ .../service/AihrKnowledgeQueryService.java | 49 ++++++++-- .../aihr/service/AihrSopSeedService.java | 87 +++++++++++++++-- .../aihr/agent/AihrAgentOrchestratorTest.java | 11 +++ .../AihrKnowledgeQueryServiceTest.java | 42 ++++++++ .../AihrFormalPolicyClassifierTest.java | 50 ++++++++++ .../aihr/service/AihrSopSeedServiceTest.java | 6 ++ ...730_aug1_formal_source_registry_mysql8.sql | 96 +++++++++++++++++++ ...mal_knowledge_source_governance_mysql8.sql | 62 ++++++++++++ docs/API_INTEGRATION.md | 2 +- docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 5 +- scripts/demo-check.sh | 1 + scripts/release-preflight.sh | 1 + scripts/reset-dev-db.sh | 1 + scripts/tests/aihr-schema-migrations.test.sh | 11 +++ 16 files changed, 470 insertions(+), 20 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrFormalPolicyClassifier.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrFormalPolicyClassifierTest.java create mode 100644 backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql create mode 100644 backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java index c5af8277..3105c87a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/agent/AihrAgentOrchestrator.java @@ -15,6 +15,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary; import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode; import org.dromara.aihr.webai.AihrWebAiDto; import org.dromara.aihr.webai.AihrWebAiService; @@ -133,8 +134,9 @@ public class AihrAgentOrchestrator { MediaMode mode = needsKnowledge(request.question()) ? MediaMode.MEDIA_WITH_KNOWLEDGE : MediaMode.MEDIA_ONLY; + String source = agentKnowledgeSource(request.question()); QueryResponse response = queryService.queryInternalMedia(new QueryRequest( - request.question(), List.of(), "sop", null, "mobile_uni_agent", 5, null, + request.question(), List.of(), "sop", null, source, 5, null, request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId() ), file, mode); return fromResponse(plan, response, "MEDIA"); @@ -145,8 +147,9 @@ public class AihrAgentOrchestrator { case MY_CURRENT_TASKS, MY_PRACTICE_SUMMARY, TEAM_PRACTICE_SUMMARY -> plan.tool().name(); default -> null; }; + String source = agentKnowledgeSource(request.question()); QueryResponse response = queryService.queryInternal(new QueryRequest( - plan.rewrittenRequest(), List.of(), "sop", null, "mobile_uni_agent", 5, toolCode, + plan.rewrittenRequest(), List.of(), "sop", null, source, 5, toolCode, request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId() )); return fromResponse(plan, response, toolCode == null ? "ENTERPRISE_KNOWLEDGE" : "LIVE_DATA"); @@ -216,6 +219,13 @@ public class AihrAgentOrchestrator { "故障", "漏水", "电梯", "现场").stream().anyMatch(value::contains); } + private static String agentKnowledgeSource(String question) { + return AihrFormalPolicyClassifier.requiresFormalSource( + AihrFormalPolicyClassifier.MOBILE_AGENT_SOURCE, question) + ? AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE + : AihrFormalPolicyClassifier.MOBILE_AGENT_SOURCE; + } + private static boolean isConflictBoundaryRequest(String question) { String value = question == null ? "" : question.trim(); return value.contains("一份") && value.contains("另一份") diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrFormalPolicyClassifier.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrFormalPolicyClassifier.java new file mode 100644 index 00000000..d97178ce --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrFormalPolicyClassifier.java @@ -0,0 +1,52 @@ +package org.dromara.aihr.knowledge.service; + +import java.util.List; +import java.util.Locale; + +/** + * Server-side routing guard for questions that can change an employee's understanding of + * policy, authority, fees, safety duties or required records. + * + *

The client never selects this mode. It is only enabled for the authenticated mobile + * agent entry point, and the retrieval layer then accepts approved formal sources only.

+ */ +public final class AihrFormalPolicyClassifier { + + public static final String MOBILE_AGENT_SOURCE = "mobile_uni_agent"; + public static final String FORMAL_POLICY_SOURCE = "mobile_uni_agent_formal_policy"; + + private static final List FORMAL_TERMS = List.of( + "制度", "规定", "要求", "依据", "条款", "适用范围", "版本", "生效", + "权限", "授权", "审批", "申请条件", "责任主体", "岗位职责", "预案", + "频次", "路线", "留痕", "记录哪些", "计费", "费用构成", "查询入口", + "公示", "合规", "必须" + ); + + private static final List CONTROLLED_DOMAINS = List.of( + "日常巡检", "强降雨", "防汛", "大件垃圾", "装修垃圾", "装修产生", "报修受理", + "物业费账单", "分期缴费", "空置房", "长期无人居住", "维修一直没进展", + "车辆被占位", "车辆堵住", "停车冲突", "安全险情", "突发事件" + ); + + private AihrFormalPolicyClassifier() { + } + + public static boolean requiresFormalSource(String source, String question) { + String normalizedSource = source == null ? "" : source.trim().toLowerCase(Locale.ROOT); + if (FORMAL_POLICY_SOURCE.equals(normalizedSource)) { + return true; + } + if (!normalizedSource.startsWith(MOBILE_AGENT_SOURCE)) { + return false; + } + String value = question == null ? "" : question.trim(); + if (value.isEmpty()) { + return false; + } + return containsAny(value, FORMAL_TERMS) || containsAny(value, CONTROLLED_DOMAINS); + } + + private static boolean containsAny(String value, List terms) { + return terms.stream().anyMatch(value::contains); + } +} 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 56ab9665..9675c1df 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 @@ -143,7 +143,9 @@ public class AihrKnowledgeQueryService { } else { boolean candidateIntent = "QA".equals(context.intent()) && memoryService.isProjectMemoryCandidateIntent(principal, request.queryText()); - memoryRecall = "QA".equals(context.intent()) && !candidateIntent + boolean formalPolicyRequest = AihrFormalPolicyClassifier.requiresFormalSource( + routed.source(), routed.queryText()); + memoryRecall = "QA".equals(context.intent()) && !candidateIntent && !formalPolicyRequest ? memoryService.recall(principal, routed.queryText(), routed.limit()) : ServiceMemoryRecall.empty(); if (memoryRecall == null) { @@ -175,7 +177,8 @@ public class AihrKnowledgeQueryService { } } MemoryCandidateResponse candidate = context.broadcastMessageId() != null - || !"QA".equals(context.intent()) || hasText(routed.toolCode()) ? null + || !"QA".equals(context.intent()) || hasText(routed.toolCode()) + || AihrFormalPolicyClassifier.requiresFormalSource(routed.source(), routed.queryText()) ? null : memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), response.requestId(), new SourceSnapshot(sourceType(request.source()), truncate(request.queryText(), 4000), null, null, null, null, null)); @@ -299,17 +302,26 @@ public class AihrKnowledgeQueryService { String requestId = UUID.randomUUID().toString().replace("-", ""); long started = System.nanoTime(); List scopeCodes = spaceCodes(app.tenantId(), spaceIds); + boolean formalPolicyOnly = AihrFormalPolicyClassifier.requiresFormalSource( + request.source(), request.queryText()); + String retrievalSource = formalPolicyOnly + ? AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE + : request.source(); try { SearchResponse legacy = sopService.searchAuthorized(new SearchRequest( - request.queryText(), request.category(), request.position(), request.source(), request.limit()), + request.queryText(), request.category(), request.position(), retrievalSource, request.limit()), principal == null ? "" : principal.extPartyId(), spaceIds); - List citations = citations(app.tenantId(), spaceIds, legacy.snippets()); + List citations = citations(app.tenantId(), spaceIds, legacy.snippets(), formalPolicyOnly); boolean noEvidence = citations.isEmpty(); + String answer = formalPolicyOnly && noEvidence + ? "当前已批准且在有效期内的正式资料中没有找到足够依据,不能使用访谈、经验材料或草稿补全政策答案。" + + "请补充经审核的正式来源后再查询。" + : legacy.answer(); String status = noEvidence ? "NO_EVIDENCE" : "SUCCESS"; long latency = elapsedMillis(started); auditService.record(requestId, principal, app, displayQuery, scopeCodes, citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, latency, legacy.promptVersion()); - return new QueryResponse(requestId, displayQuery, legacy.answer(), citations, scopeCodes, + return new QueryResponse(requestId, displayQuery, answer, citations, scopeCodes, noEvidence, legacy.promptVersion(), legacy, null); } catch (RuntimeException ex) { auditService.record(requestId, principal, app, displayQuery, scopeCodes, List.of(), @@ -577,7 +589,8 @@ public class AihrKnowledgeQueryService { ); } - private List citations(String tenantId, Set allowedSpaceIds, List snippets) { + private List citations(String tenantId, Set allowedSpaceIds, List snippets, + boolean formalPolicyOnly) { if (snippets == null || snippets.isEmpty()) { return List.of(); } @@ -590,6 +603,26 @@ public class AihrKnowledgeQueryService { args.add(tenantId); args.addAll(allowedSpaceIds); args.addAll(fragmentIds); + String formalSourceClause = formalPolicyOnly ? """ + and exists ( + select 1 + from aihr_knowledge_attach governed_attach + join aihr_knowledge_source_governance governance + on governance.tenant_id = governed_attach.tenant_id + and governance.attachment_id = governed_attach.id + where governed_attach.tenant_id = f.tenant_id + and governed_attach.knowledge_id = f.knowledge_id + and governed_attach.doc_id = f.doc_id + and governed_attach.status = 2 + and governance.source_doc_id = f.doc_id + and governance.authority_type = 'FORMAL_POLICY' + and governance.lifecycle_status = 'APPROVED' + and governance.effective_date <= current_date() + and (governance.expires_date is null or governance.expires_date >= current_date()) + and char_length(trim(governance.source_version)) > 0 + and governance.content_sha256 regexp '^[0-9a-f]{64}$' + ) + """ : ""; List rows = jdbcTemplate.query(""" select k.code as space_code, f.doc_id, coalesce(a.name, k.name) as title, f.content, f.id as fragment_id @@ -600,7 +633,9 @@ public class AihrKnowledgeQueryService { where f.tenant_id = ? and f.knowledge_id in (%s) and f.id in (%s) - """.formatted(placeholders(allowedSpaceIds.size()), placeholders(fragmentIds.size())), + %s + """.formatted(placeholders(allowedSpaceIds.size()), placeholders(fragmentIds.size()), + formalSourceClause), (rs, rowNum) -> new Citation( rs.getString("space_code"), "DOCUMENT", rs.getString("doc_id"), rs.getString("title"), truncate(rs.getString("content"), 500), rs.getLong("fragment_id") diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index 85fe518c..82d75ab4 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -40,6 +40,7 @@ import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse; import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UnbindDocumentResponse; import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser; import org.dromara.aihr.knowledge.parse.TikaKnowledgeDocumentParser; +import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; import org.dromara.aihr.personal.service.EnterpriseKnowledgeAccessPolicy.EnterpriseKnowledgeGrant; import org.dromara.aihr.personal.support.PersonalOwner; import org.dromara.common.core.exception.ServiceException; @@ -214,7 +215,9 @@ public class AihrSopSeedService { if (isBlank(queryText)) { return withReviewId(noEvidenceResponse("", "sop"), source, requester); } - SearchResponse response = dbSearch("", queryText, request == null ? null : request.limit(), allowedKnowledgeIds); + boolean formalPolicyOnly = AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE.equals(source); + SearchResponse response = dbSearch( + "", queryText, request == null ? null : request.limit(), allowedKnowledgeIds, formalPolicyOnly); if (response == null || isNoEvidenceAnswer(response.answer())) { recordKnowledgeGap(queryText, "sop", position, source); response = noEvidenceResponse(queryText, "sop"); @@ -2511,8 +2514,14 @@ public class AihrSopSeedService { } private SearchResponse dbSearch(String category, String queryText, Integer limit, Set allowedKnowledgeIds) { + return dbSearch(category, queryText, limit, allowedKnowledgeIds, false); + } + + private SearchResponse dbSearch(String category, String queryText, Integer limit, + Set allowedKnowledgeIds, boolean formalPolicyOnly) { try { - List hits = dbHits(category, queryText, limit, allowedKnowledgeIds); + List hits = dbHits( + category, queryText, limit, allowedKnowledgeIds, formalPolicyOnly); if (hits.isEmpty()) { return null; } @@ -2897,10 +2906,18 @@ public class AihrSopSeedService { } private List dbHits(String category, String queryText, Integer limit, Set allowedKnowledgeIds) { + return dbHits(category, queryText, limit, allowedKnowledgeIds, false); + } + + private List dbHits(String category, String queryText, Integer limit, + Set allowedKnowledgeIds, boolean formalPolicyOnly) { int limitValue = limitValue(limit); - List keywordHits = likeHits(category, queryText, limitValue, allowedKnowledgeIds); - List textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue, allowedKnowledgeIds), limitValue); - return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue, allowedKnowledgeIds), limitValue)); + List keywordHits = likeHits( + category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly); + List textHits = rrf(keywordHits, fulltextHits( + category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly), limitValue); + return rerankHits(queryText, rrf(textHits, vectorHits( + category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly), limitValue)); } private static String knowledgeScopeSql(String alias, Set allowedKnowledgeIds) { @@ -2920,6 +2937,32 @@ public class AihrSopSeedService { } } + private static String formalSourceScopeSql(String fragmentAlias, boolean formalPolicyOnly) { + if (!formalPolicyOnly) { + return ""; + } + return """ + and exists ( + select 1 + from aihr_knowledge_attach governed_attach + join aihr_knowledge_source_governance governance + on governance.tenant_id = governed_attach.tenant_id + and governance.attachment_id = governed_attach.id + where governed_attach.tenant_id = %1$s.tenant_id + and governed_attach.knowledge_id = %1$s.knowledge_id + and governed_attach.doc_id = %1$s.doc_id + and governed_attach.status = 2 + and governance.source_doc_id = %1$s.doc_id + and governance.authority_type = 'FORMAL_POLICY' + and governance.lifecycle_status = 'APPROVED' + and governance.effective_date <= current_date() + and (governance.expires_date is null or governance.expires_date >= current_date()) + and char_length(trim(governance.source_version)) > 0 + and governance.content_sha256 regexp '^[0-9a-f]{64}$' + ) + """.formatted(fragmentAlias); + } + private FeedbackFilterResult filterDownFeedback(String queryText, List hits) { return filterDownFeedback(hits, downFeedbackFragmentIds(queryText)); } @@ -2963,6 +3006,11 @@ public class AihrSopSeedService { } private List vectorHits(String category, String queryText, int limit, Set allowedKnowledgeIds) { + return vectorHits(category, queryText, limit, allowedKnowledgeIds, false); + } + + private List vectorHits(String category, String queryText, int limit, + Set allowedKnowledgeIds, boolean formalPolicyOnly) { for (EmbeddingRuntime runtime : embeddingRuntimes()) { try { List embeddings = callEmbeddings(runtime, List.of(queryText)); @@ -2972,7 +3020,7 @@ public class AihrSopSeedService { List matches = queryQdrant(category, embeddings.get(0), limit, allowedKnowledgeIds); List hits = new ArrayList<>(); for (VectorMatch match : matches) { - hydrateQdrantHit(match, allowedKnowledgeIds).ifPresent(hits::add); + hydrateQdrantHit(match, allowedKnowledgeIds, formalPolicyOnly).ifPresent(hits::add); } if (!hits.isEmpty()) { return hits; @@ -2989,6 +3037,11 @@ public class AihrSopSeedService { } private List fulltextHits(String category, String queryText, int limit, Set allowedKnowledgeIds) { + return fulltextHits(category, queryText, limit, allowedKnowledgeIds, false); + } + + private List fulltextHits(String category, String queryText, int limit, + Set allowedKnowledgeIds, boolean formalPolicyOnly) { String sql = """ select f.id as fragment_id, coalesce(a.name, i.name) as title, @@ -3002,12 +3055,14 @@ public class AihrSopSeedService { join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id where f.tenant_id = ? + %s %s and (? = '' or i.name = ?) and match(f.content) against (? in natural language mode) order by score desc, f.idx asc limit ? - """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds)); + """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds), + formalSourceScopeSql("f", formalPolicyOnly)); List args = new ArrayList<>(); args.add(queryText); args.add(tenantId()); @@ -3033,6 +3088,11 @@ public class AihrSopSeedService { } private List likeHits(String category, String queryText, int limit, Set allowedKnowledgeIds) { + return likeHits(category, queryText, limit, allowedKnowledgeIds, false); + } + + private List likeHits(String category, String queryText, int limit, + Set allowedKnowledgeIds, boolean formalPolicyOnly) { List terms = searchTerms(queryText); if (terms.isEmpty()) { return List.of(); @@ -3056,12 +3116,14 @@ public class AihrSopSeedService { join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id where f.tenant_id = ? + %s %s and (? = '' or i.name = ?) and (%s) order by score desc, f.idx asc limit ? - """.formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds), String.join(" or ", whereParts)); + """.formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds), + formalSourceScopeSql("f", formalPolicyOnly), String.join(" or ", whereParts)); List args = new ArrayList<>(); terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add); args.add(tenantId()); @@ -3087,6 +3149,11 @@ public class AihrSopSeedService { } private Optional hydrateQdrantHit(VectorMatch match, Set allowedKnowledgeIds) { + return hydrateQdrantHit(match, allowedKnowledgeIds, false); + } + + private Optional hydrateQdrantHit(VectorMatch match, Set allowedKnowledgeIds, + boolean formalPolicyOnly) { String sql = """ select f.id as fragment_id, coalesce(a.name, i.name) as title, @@ -3099,11 +3166,13 @@ public class AihrSopSeedService { join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id where f.tenant_id = ? + %s %s and f.doc_id = ? and f.idx = ? limit 1 - """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds)); + """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds), + formalSourceScopeSql("f", formalPolicyOnly)); List args = new ArrayList<>(); args.add(tenantId()); addKnowledgeScopeArgs(args, allowedKnowledgeIds); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java index eb4904a1..2b3e3a08 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/agent/AihrAgentOrchestratorTest.java @@ -9,6 +9,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; +import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary; import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse; import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft; @@ -65,6 +66,16 @@ class AihrAgentOrchestratorTest { assertTrue(result.sourceSummary().isEmpty()); } + @Test + void originalPolicyQuestionSelectsFormalSourceBeforePlannerRewrite() { + var query = new CapturingQueryService(response("正式制度答案", null, false)); + var service = orchestrator(query); + + service.handle(request("住户提出分期缴费时,申请条件、审批角色和留痕要求是什么?")); + + assertEquals(AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE, query.lastRequest.source()); + } + @Test void explicitRuleConflictStopsBeforeKnowledgeChoosesASide() { var query = new CapturingQueryService(response("unused", null, false)); 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 b14ab944..b7900151 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 @@ -9,6 +9,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService; import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService; +import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryAuditService; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; @@ -147,6 +148,47 @@ class AihrKnowledgeQueryServiceTest { assertEquals(List.of("mt_customer_service"), result.usedSpaceCodes()); } + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void mobilePolicyQuestionUsesFormalRegistryAndFailsClosedWhenCitationRevalidationFindsNone() { + 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 principal = new AihrKnowledgePrincipal( + "000000", 1L, "app_user", "employee-1", Set.of("employee"), Set.of(), "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(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of()); + when(sop.searchAuthorized(any(), any(), any())).thenReturn( + new SearchResponse("分期缴费", "sop", "经验材料给出的分期建议", "", List.of(), + List.of(new SnippetResponse("经验萃取", "不应进入正式答案", 99L)), + 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), + memory); + + var result = service.queryInternal(new QueryRequest( + "住户提出分期缴费时,申请条件、审批角色和留痕要求是什么?", + List.of(), "sop", null, "mobile_uni_agent", 5, null)); + + var requestCaptor = ArgumentCaptor.forClass(SearchRequest.class); + verify(sop).searchAuthorized(requestCaptor.capture(), any(), eq(Set.of(9L))); + assertEquals(AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE, requestCaptor.getValue().source()); + assertTrue(result.noEvidence()); + assertTrue(result.citations().isEmpty()); + assertTrue(result.answer().contains("不能使用访谈、经验材料或草稿")); + verify(memory, never()).recall(any(), anyString(), any()); + verify(memory, never()).detectForQuery(any(), anyString(), any(), anyString(), any()); + } + @Test void internalToolFailureNeverFallsBackToRag() { var resolver = mock(AihrKnowledgePrincipalResolver.class); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrFormalPolicyClassifierTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrFormalPolicyClassifierTest.java new file mode 100644 index 00000000..999c6235 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrFormalPolicyClassifierTest.java @@ -0,0 +1,50 @@ +package org.dromara.aihr.knowledge.service; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@Tag("dev") +class AihrFormalPolicyClassifierTest { + + @Test + void augustFirstNormalAndSynonymQuestionsRequireFormalSources() { + List questions = List.of( + "不同住宅业态的日常巡检频次、路线和留痕要求分别是什么?", + "收到强降雨预警后,生活顾问应按什么顺序巡查、上报和闭环?", + "大件垃圾和装修垃圾应如何登记、分类、清运和说明费用?", + "报修受理后,生活顾问需要记录哪些信息并如何完成回访闭环?", + "物业费账单的费用构成、计费期间和查询入口如何解释?", + "住户提出分期缴费时,申请条件、审批角色和留痕要求是什么?", + "长期无人居住的房屋平时怎么做安全查看?", + "维修一直没进展,管家应该怎么催办并给业主回话?", + "业主一直欠费又说服务差,日常怎么沟通才合规?", + "装修产生的废料该找谁、怎么约清运、能不能收费?", + "车辆被占位或堵住时,物业怎么协调才不会升级冲突?", + "现场突然有安全险情,第一时间该找谁、留下什么记录?" + ); + + questions.forEach(question -> assertTrue( + AihrFormalPolicyClassifier.requiresFormalSource("mobile_uni_agent", question), + question + )); + } + + @Test + void adviceAndOtherEntryPointsDoNotGainFormalPolicySemantics() { + assertFalse(AihrFormalPolicyClassifier.requiresFormalSource( + "mobile_uni_agent", "催费话术怎么说?")); + assertFalse(AihrFormalPolicyClassifier.requiresFormalSource( + "mobile_uni_agent", "可以帮我写一句催费话术吗?")); + assertFalse(AihrFormalPolicyClassifier.requiresFormalSource( + "mobile_uni_agent", "图片里有什么?")); + assertFalse(AihrFormalPolicyClassifier.requiresFormalSource( + "knowledge_admin", "这个制度有哪些要求?")); + assertTrue(AihrFormalPolicyClassifier.requiresFormalSource( + "mobile_uni_agent_formal_policy", "继续说明")); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java index f964f1ab..44491259 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java @@ -490,6 +490,12 @@ public class AihrSopSeedServiceTest { assertTrue(code.contains("searchAuthorized(SearchRequest request, String requesterExtPartyId, Set allowedKnowledgeIds)")); assertTrue(code.contains(".knowledge_id in (")); assertTrue(code.contains("qdrantMatchAny(\"knowledge_id\", knowledgeIds)")); + assertTrue(code.contains("aihr_knowledge_source_governance")); + assertTrue(code.contains("governance.source_doc_id")); + assertTrue(code.contains("governance.authority_type = 'FORMAL_POLICY'")); + assertTrue(code.contains("governance.lifecycle_status = 'APPROVED'")); + assertTrue(code.contains("governance.effective_date <= current_date()")); + assertTrue(code.contains("governance.content_sha256 regexp '^[0-9a-f]{64}$'")); AihrSopSeedService service = new AihrSopSeedService( new ObjectMapper(), mock(JdbcTemplate.class), null, "", null, null, mock(AihrModelSeedService.class)); diff --git a/backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql b/backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql new file mode 100644 index 00000000..62b9836a --- /dev/null +++ b/backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql @@ -0,0 +1,96 @@ +-- 2026-08-01 受控内部试点正式来源白名单。 +-- 必须先独立核验原文件 SHA-256,再执行本文件;本文件只会命中精确的附件 ID + doc_id。 +-- 访谈、经验萃取、AI 生成内容和草稿不得加入此清单。 + +INSERT INTO aihr_knowledge_source_governance ( + tenant_id, source_id, attachment_id, source_doc_id, authority_type, lifecycle_status, + source_version, effective_date, expires_date, applicability_scope, content_sha256, + approval_basis, approved_by, approved_time +) +SELECT a.tenant_id, source_seed.source_id, a.id, a.doc_id, 'FORMAL_POLICY', 'APPROVED', + source_seed.source_version, source_seed.effective_date, source_seed.expires_date, + source_seed.applicability_scope, source_seed.content_sha256, + '2026-08-01受控内部试点正式来源白名单', 'PROJECT_SPONSOR_DELEGATION', '2026-07-30 23:59:00' +FROM aihr_knowledge_attach a +JOIN ( + SELECT 1883 attachment_id, '017e4a5dfadd40279bbe5f947cfdf5e1' source_doc_id, + 'FORMAL-ZZKF01-20240919' source_id, '2024-09-19' source_version, + DATE '2024-09-19' effective_date, NULL expires_date, + '住宅物业/生活顾问网格化作业' applicability_scope, + 'f79a9a3f29f73d518761aa3e8707928a10b8a9ce6b56dd53f30ef73219c0c9db' content_sha256 + UNION ALL SELECT 1884, 'd544749eb13b456aaa06f0fd6c84dfc0', + 'FORMAL-ZZKF02-20240919', '2024-09-19', DATE '2024-09-19', NULL, + '住宅物业/客户报事报修作业', + '1cbecb71e28750a033c683928745ef0de3e2cf688ee8d838806f098b4a0e0182' + UNION ALL SELECT 1885, '77a84c25c1c14b80b74c6211a76a3fa1', + 'FORMAL-ZZKF03-20240919', '2024-09-19', DATE '2024-09-19', NULL, + '住宅物业/客户投诉处理作业', + 'eb6b7efa269cb46feba14d7aeb65f41bbb15352a05cdb8d092c781e9a4328ed9' + UNION ALL SELECT 1886, '22a68eda3b6e476192224ad54a47d0a3', + 'FORMAL-ZZKF05-20240919', '2024-09-19', DATE '2024-09-19', NULL, + '住宅物业/装修管理作业', + 'bf822957ba738ef5075e465ed5da4a279e1f37a0b141109425049a72b0c71550' + UNION ALL SELECT 1922, '4e484a61c8ab473cadab75e61a672fe8', + 'FORMAL-ZZBJ06-20240919', '2024-09-19', DATE '2024-09-19', NULL, + '住宅物业/垃圾收集和清运作业', + 'df3a35f6c6a624fc06c7807f6b8b7cf9e7e3c17bba974ae1c5850cf7c2483c2c' + UNION ALL SELECT 1927, '954f717921ed43b5882ec8be7a9886ab', + 'FORMAL-ZZBJ12-20240919', '2024-09-19', DATE '2024-09-19', NULL, + '住宅物业/突发事件应急作业', + '739db239f623854b89c8a57a43484f86d244d2d126e0d623f2773b430d63b7b5' + UNION ALL SELECT 1932, 'f448a336c14c4d6790e423abf7beb4fd', + 'FORMAL-ZZAQ04-20240919', '2024-09-19', DATE '2024-09-19', NULL, + '住宅物业/车辆管理作业', + '3d1e74a9abec8c2da43c95f42a877e41a93f22aa794ad75b65a1d5056a71b4f2' + UNION ALL SELECT 1947, 'c3402e7a86504b188807f548277576ce', + 'FORMAL-YC-2026-33', '银城物业[2026]第33号', DATE '2026-05-26', NULL, + '银城物业各事业部、公司/2026年汛期防台防汛', + '824e6355f5506ab113919f4744a13a68c9356af0e946347bb159804e0e32feb8' + UNION ALL SELECT 1984, 'f5bd66670ee345aa96e03c328e0753e8', + 'FORMAL-LIFE-ADVISOR-HANDBOOK-20220928', '2022-09-28', DATE '2022-09-28', NULL, + '生活顾问岗位学习与作业参考', + '362b9a2f01454a16eaf8fcd2cf76fe283f0dc2ced99533ade4a6ae8cc1f480fa' + UNION ALL SELECT 1990, '31b7249eaa3c4b8d8ca59738edc1fc37', + 'FORMAL-NJ-HOUSING-CONTRACT-2025-134', '宁房办[2025]134号', DATE '2025-12-16', NULL, + '南京市住宅小区物业服务合同示范文本适用事项', + '9a61349811d292d7d2e75e66384e60a0e2e30f66b92d8a43557127e07b983534' +) source_seed + ON source_seed.attachment_id = a.id + AND source_seed.source_doc_id = a.doc_id +WHERE a.tenant_id = '000000' + AND a.status = 2 +ON DUPLICATE KEY UPDATE + source_doc_id = VALUES(source_doc_id), + authority_type = VALUES(authority_type), + lifecycle_status = VALUES(lifecycle_status), + source_version = VALUES(source_version), + effective_date = VALUES(effective_date), + expires_date = VALUES(expires_date), + applicability_scope = VALUES(applicability_scope), + content_sha256 = VALUES(content_sha256), + approval_basis = VALUES(approval_basis), + approved_by = VALUES(approved_by), + approved_time = VALUES(approved_time); + +-- 失败关闭:10 个精确来源未全部注册时让 mysql 进程返回非零。 +DROP TEMPORARY TABLE IF EXISTS aihr_aug1_formal_source_assert; +CREATE TEMPORARY TABLE aihr_aug1_formal_source_assert ( + assertion_value tinyint NOT NULL +); +INSERT INTO aihr_aug1_formal_source_assert (assertion_value) +SELECT CASE WHEN COUNT(*) = 10 THEN 1 ELSE NULL END +FROM aihr_knowledge_source_governance +WHERE tenant_id = '000000' + AND source_id IN ( + 'FORMAL-ZZKF01-20240919', 'FORMAL-ZZKF02-20240919', 'FORMAL-ZZKF03-20240919', + 'FORMAL-ZZKF05-20240919', 'FORMAL-ZZBJ06-20240919', 'FORMAL-ZZBJ12-20240919', + 'FORMAL-ZZAQ04-20240919', 'FORMAL-YC-2026-33', + 'FORMAL-LIFE-ADVISOR-HANDBOOK-20220928', 'FORMAL-NJ-HOUSING-CONTRACT-2025-134' + ) + AND authority_type = 'FORMAL_POLICY' + AND lifecycle_status = 'APPROVED' + AND effective_date <= current_date() + AND (expires_date IS NULL OR expires_date >= current_date()) + AND CHAR_LENGTH(TRIM(source_version)) > 0 + AND content_sha256 REGEXP '^[0-9a-f]{64}$'; +DROP TEMPORARY TABLE aihr_aug1_formal_source_assert; diff --git a/backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql b/backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql new file mode 100644 index 00000000..27ffaaa5 --- /dev/null +++ b/backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql @@ -0,0 +1,62 @@ +-- 正式知识来源治理:附件级权威性、发布状态、版本、生效期和原文件哈希。 +-- 只创建治理契约,不把任何既有附件自动认定为正式来源。 +-- 可重复执行;生产请求只读校验,不允许在运行时补表。 + +CREATE TABLE IF NOT EXISTS `aihr_knowledge_source_governance` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `source_id` varchar(80) NOT NULL COMMENT '稳定来源编号', + `attachment_id` bigint NOT NULL COMMENT '知识附件ID', + `source_doc_id` varchar(80) NOT NULL COMMENT '审批时附件文档ID快照', + `authority_type` varchar(30) NOT NULL COMMENT 'FORMAL_POLICY/TRAINING/EXPERIENCE/INTERVIEW/DRAFT', + `lifecycle_status` varchar(20) NOT NULL DEFAULT 'DRAFT' COMMENT 'DRAFT/APPROVED/REJECTED/RETIRED', + `source_version` varchar(80) NOT NULL COMMENT '正式版本或发文号', + `effective_date` date NOT NULL COMMENT '生效日期', + `expires_date` date DEFAULT NULL COMMENT '失效日期', + `applicability_scope` varchar(500) NOT NULL COMMENT '适用范围', + `content_sha256` char(64) NOT NULL COMMENT '审批时原文件SHA-256', + `approval_basis` varchar(255) NOT NULL COMMENT '审批依据或受控试点决策', + `approved_by` varchar(100) DEFAULT NULL COMMENT '审批主体', + `approved_time` datetime DEFAULT NULL COMMENT '审批时间', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_knowledge_source_id` (`tenant_id`, `source_id`), + UNIQUE KEY `uk_aihr_knowledge_source_attachment` (`tenant_id`, `attachment_id`), + KEY `idx_aihr_knowledge_source_retrieval` + (`tenant_id`, `authority_type`, `lifecycle_status`, `effective_date`, `expires_date`), + KEY `idx_aihr_knowledge_source_doc` (`tenant_id`, `source_doc_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + COMMENT='AIHR知识附件来源治理'; + +-- 与租户事实表保持同一排序规则,兼容历史库和全新 MySQL 8 数据库。 +SET @formal_source_target_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 @formal_source_target_collation := COALESCE(@formal_source_target_collation, @@collation_database); +SET @formal_source_table_collation := ( + SELECT table_collation FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name = 'aihr_knowledge_source_governance' + LIMIT 1 +); +SET @formal_source_ddl := IF( + @formal_source_table_collation = @formal_source_target_collation, + 'SELECT 1', + CONCAT( + 'ALTER TABLE aihr_knowledge_source_governance ', + 'CONVERT TO CHARACTER SET utf8mb4 COLLATE ', + @formal_source_target_collation + ) +); +PREPARE aihr_formal_source_stmt FROM @formal_source_ddl; +EXECUTE aihr_formal_source_stmt; +DEALLOCATE PREPARE aihr_formal_source_stmt; + +SET @formal_source_target_collation := NULL; +SET @formal_source_table_collation := NULL; +SET @formal_source_ddl := NULL; diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index de916155..3176b339 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -58,7 +58,7 @@ curl -fsS "$API_BASE/api/aihr/agent/messages/media" \ 文字问题去空白后必须为 1–1000 字;媒体接口的 `question` 为空时使用“请分析附件内容”。请求不接受身份、角色、`toolCode`、知识空间 ID、SQL 或任意 URL。响应继续使用若依信封,`data.status` 只会是 `COMPLETED/NEEDS_INPUT/NEEDS_CONFIRMATION/NO_EVIDENCE/FORBIDDEN/UNAVAILABLE/FAILED`,按钮分别读取 `actionDraft/clarification/resources/nextActions`,不得解析 `answer` 猜状态。 -政策边界在检索前由服务端确定性执行:明确存在版本/规则冲突时返回 `NEEDS_INPUT`,不得自行裁决或拼接;明确无依据时返回 `NO_EVIDENCE`,不得用经验补全政策事实;跨项目、他人敏感信息、无权附件或未发布制度返回 `FORBIDDEN`。已经取得授权片段时,摘要模型的拒答文本不能抹掉这些片段,服务端会回退为受控原片段摘要并保留引用;但“有引用”只证明检索命中,不证明该文件已被业务登记为正式制度。正式政策验收必须另行核对来源版本、生效日、适用范围和审核结论,访谈、经验萃取、seed 或 AI 输出不得登记为 `formalPolicy=true`。 +政策边界在检索前由服务端确定性执行:明确存在版本/规则冲突时返回 `NEEDS_INPUT`,不得自行裁决或拼接;明确无依据时返回 `NO_EVIDENCE`,不得用经验补全政策事实;跨项目、他人敏感信息、无权附件或未发布制度返回 `FORBIDDEN`。移动端 Agent 对涉及制度、权限、费用、安全职责或必留记录的问题,服务端会在规划改写前固定为正式政策模式,客户端不能选择或绕过;MySQL 模糊/全文召回、Qdrant 命中回表和最终引用装配均只接受 `aihr_knowledge_source_governance` 中当前有效的 `FORMAL_POLICY + APPROVED` 附件,并校验审批时 `docId` 快照一致、版本和原文件 SHA-256 登记完整。正式问题不混入个人/项目记忆;治理表缺失、未审批、已失效、版本或哈希为空,以及最终引用重校验失败时统一返回 `NO_EVIDENCE`。普通“经验话术怎么说”等建议问题仍可检索经授权经验材料,但不得包装为制度。访谈、经验萃取、seed 或 AI 输出不得登记为正式政策来源。 全网意图在 `externalConsent=false` 时以业务成功响应返回 `NEEDS_INPUT`,用户同意后原请求携带 `externalConsent=true` 重试。确认式记忆先取得 `actionDraft.draftId`,再提交卡片中可编辑的完整 `draft`: diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index 14cc6329..9cf95d19 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -129,6 +129,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_search_qwen_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_tenant_org_governance_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_knowledge_category_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260723_broadcast_targeting_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_direct_feedback_mysql8.sql @@ -142,7 +143,9 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql ``` -顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 +8 月 1 日受控内部试点的正式来源注册是独立的业务数据变更,不随通用 schema 自动执行。先逐一核对附件 `id + doc_id`、原文件 SHA-256、发文号/版本、生效日期和适用范围,再由已授权负责人执行 `backend/script/sql/ops/aihr_20260730_aug1_formal_source_registry_mysql8.sql`。执行后必须只读确认恰好 10 条 `FORMAL_POLICY + APPROVED` 记录;任何缺失都保持无正式依据,不得把访谈、经验萃取、AI 生成内容或草稿补进白名单。 + +顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;五岗位题库之后的内容运营迁移为每个场景补齐唯一的初始 Rubric、五个稳定评分维度与训练时评分规则快照列,并只修正仍处于待训练状态的旧每日题场景编号,不重写已完成记录。紧随其后的成长目录迁移只在既有场景和会话表上增加岗位/层级/能力项/审核状态与会话快照,以及复盘会话专用的 `growth_confirmation_level/growth_confirmed_by/growth_confirmed_time`;它只允许记录主管对当前会话下一训练层级的确认,不改写原训练快照或人事结论,并补齐/修复派发请求的 `(tenant_id,request_key,ext_party_id)` 唯一键与最近内容过滤索引。它保留既有启停可用性和历史会话,不把迁移结果写成业务内容签字。生产环境不得设置 `AIHR_PRACTICE_RUNTIME_SCHEMA_BOOTSTRAP=true`:应用请求只读校验表、列和关键索引,任何缺失都必须先执行本节正式 SQL,不能由任一用户请求触发补列、补索引或状态回填。岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引;知识分类迁移新增空间内分类表及附件成员的可空 `category_id`,不迁移、删除或扩大任何空间授权与检索范围;正式知识来源治理迁移只创建附件级权威类型、生命周期、版本、生效期、适用范围和原文件哈希契约,不自动批准任何历史附件。移动端政策问答只使用有效的 `FORMAL_POLICY + APPROVED` 注册项,治理表缺失、来源未批准、已失效、版本或哈希为空时均按无正式依据关闭。银城大喇叭 M0 迁移创建消息、阅读和不可变 v1 快照三表,为已存在消息补一次快照,并补发布请求键/载荷哈希、撤回原因及 `(tenant_id,published_by,publish_request_key)` 唯一约束。紧接着 M1 定向迁移补充必读与目标载荷字段,以及规则快照、命中对象快照两表;它只决定提醒/必读对象,不改变全租户公开频道的可见性。直通车迁移创建点对点反馈表,并给正式租户 `000000` 幂等预置总裁、财务、人力、审计、运营五个处理角色;人员仍须由管理员按职责分配,业务匿名不抹除内部审计身份。公司文件迁移创建异步提炼表并给消息增加可空 `attachment_id`,不迁移或公开已有文件;紧随其后的多岗位解读迁移只在附件表增加生成状态、经引用校验的结构化结果和规则版本,并建立异步队列索引,不回填旧文件、不改变文件 READY 或消息发布状态。随后,消息追问迁移只给短期会话增加可空 `broadcast_message_id`;它不保存消息正文,并用于约束同一会话不能切换公司消息。历史普通会话保持 `NULL`。Agent 迁移只新增最小运行审计表,不保存问题、答案、密钥或模型推理。个人资料迁移最后创建独立 `aihr_personal_*` 表,并从当前租户既有 MinIO 配置派生私有 `personal-minio` 配置;`ruoyi-personal` bucket 必须由受控运维流程创建和验证,不能在文档或日志中输出访问密钥。它们同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 ## 迁移后只读核验 diff --git a/scripts/demo-check.sh b/scripts/demo-check.sh index 8ee9bb09..5a50dd10 100755 --- a/scripts/demo-check.sh +++ b/scripts/demo-check.sh @@ -584,6 +584,7 @@ contains backend/script/sql/update/aihr_20260714_brd_sop_seed_mysql8.sql "sop_da contains backend/script/sql/update/aihr_20260714_prompt_template_seed_mysql8.sql "summary_card" contains backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql "aihr_knowledge_gap" contains backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql "aihr_knowledge_answer_feedback" +contains backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql "aihr_knowledge_source_governance" contains backend/script/sql/aihr_practice_mysql8.sql "aihr_prompt_template" contains backend/script/sql/aihr_practice_mysql8.sql "summary_card" contains frontend/src/views/train/practice.vue "训练前预习卡" diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index 4761110e..6af002e9 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -219,6 +219,7 @@ FROM ( UNION ALL SELECT 'aihr_knowledge_query_log' UNION ALL SELECT 'aihr_knowledge_conversation' UNION ALL SELECT 'aihr_knowledge_admin_audit' + UNION ALL SELECT 'aihr_knowledge_source_governance' UNION ALL SELECT 'aihr_agent_run' UNION ALL SELECT 'aihr_learning_question' UNION ALL SELECT 'aihr_practice_question_feedback' diff --git a/scripts/reset-dev-db.sh b/scripts/reset-dev-db.sh index 931debed..c349a47d 100755 --- a/scripts/reset-dev-db.sh +++ b/scripts/reset-dev-db.sh @@ -21,6 +21,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/ry_workflow.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260729_media_reprocess_mysql8.sql" +docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql" diff --git a/scripts/tests/aihr-schema-migrations.test.sh b/scripts/tests/aihr-schema-migrations.test.sh index 110cd3f5..249251c1 100755 --- a/scripts/tests/aihr-schema-migrations.test.sh +++ b/scripts/tests/aihr-schema-migrations.test.sh @@ -448,6 +448,17 @@ test "$knowledge_category_code_index" = '0|tenant_id,knowledge_id,code|0' knowledge_category_collation="$(mysql "$DB" -N -B -e "SELECT table_collation FROM information_schema.tables WHERE table_schema='$DB' AND table_name='aihr_knowledge_category';")" test "$knowledge_category_collation" = 'utf8mb4_general_ci' +formal_source_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260730_formal_knowledge_source_governance_mysql8.sql" +test -f "$formal_source_migration" +mysql "$DB" < "$formal_source_migration" +mysql "$DB" < "$formal_source_migration" +formal_source_columns="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY ordinal_position) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_knowledge_source_governance' AND column_name IN ('tenant_id','source_id','attachment_id','source_doc_id','authority_type','lifecycle_status','source_version','effective_date','expires_date','applicability_scope','content_sha256','approval_basis','approved_by','approved_time');")" +test "$formal_source_columns" = 'tenant_id,source_id,attachment_id,source_doc_id,authority_type,lifecycle_status,source_version,effective_date,expires_date,applicability_scope,content_sha256,approval_basis,approved_by,approved_time' +formal_source_attachment_unique="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_knowledge_source_governance' AND index_name='uk_aihr_knowledge_source_attachment';")" +test "$formal_source_attachment_unique" = '0|tenant_id,attachment_id' +formal_source_collation="$(mysql "$DB" -N -B -e "SELECT table_collation FROM information_schema.tables WHERE table_schema='$DB' AND table_name='aihr_knowledge_source_governance';")" +test "$formal_source_collation" = 'utf8mb4_general_ci' + broadcast_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260722_broadcast_mysql8.sql" test -f "$broadcast_migration" mysql "$DB" < "$broadcast_migration"