From 721839c847e2256e7d65ea2bcb9496e62d71c00d Mon Sep 17 00:00:00 2001 From: key Date: Mon, 3 Aug 2026 22:34:18 +0800 Subject: [PATCH] feat(aihr): harden finance RAG retrieval and evidence gates --- .../domain/AihrKnowledgeQueryDto.java | 46 +- .../AihrKnowledgeQueryAuditService.java | 39 ++ .../service/AihrKnowledgeQueryService.java | 117 ++-- .../service/AihrRagQueryPlanner.java | 236 ++++++++ .../aihr/service/AihrSopSeedService.java | 525 ++++++++++++++++-- .../service/AihrRagQueryPlannerTest.java | 61 ++ .../aihr/service/AihrSopSeedServiceTest.java | 49 +- ...hr_20260815_rag_candidate_trace_mysql8.sql | 29 + docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 1 + mobile-uni/src/pages/user/assistant/index.vue | 2 +- .../src/pages/user/assistant/sessions.vue | 2 +- mobile-uni/src/pages/user/sop/index.vue | 8 +- mobile-uni/src/services/agent.ts | 2 + mobile-uni/src/services/knowledge.ts | 2 + mobile-uni/src/types/api.ts | 4 + scripts/evaluate-knowledge-quality.mjs | 135 ++++- scripts/evaluate-knowledge-quality.test.mjs | 46 +- scripts/release-preflight.sh | 4 +- scripts/reset-dev-db.sh | 1 + scripts/tests/aihr-schema-migrations.test.sh | 9 + scripts/verify-finance-rag-local.mjs | 77 +++ .../retrieval-finance-regression.json | 64 +++ 22 files changed, 1306 insertions(+), 153 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrRagQueryPlanner.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrRagQueryPlannerTest.java create mode 100644 backend/script/sql/update/aihr_20260815_rag_candidate_trace_mysql8.sql create mode 100644 scripts/verify-finance-rag-local.mjs create mode 100644 tests/fixtures/data_quality/retrieval-finance-regression.json 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 0649e735..7f2102bd 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 @@ -57,22 +57,46 @@ public final class AihrKnowledgeQueryDto { String updatedAt, String mediaType, String detailRef, - LocatorSummary locatorSummary + LocatorSummary locatorSummary, + String sourceAuthority, + String sourceKind ) { + public Citation(String spaceCode, String sourceType, String docId, String title, String snippet, + Long fragmentId, String domain, String status, String occurredAt, String updatedAt, + String mediaType, String detailRef, LocatorSummary locatorSummary) { + this(spaceCode, sourceType, docId, title, snippet, fragmentId, domain, status, occurredAt, + updatedAt, mediaType, detailRef, locatorSummary, null, null); + } + 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, - null, null, null); + null, null, null, null, null); } public Citation(String spaceCode, String sourceType, String docId, String title, String snippet, Long fragmentId, String domain, String status, String occurredAt, String updatedAt) { this(spaceCode, sourceType, docId, title, snippet, fragmentId, domain, status, occurredAt, - updatedAt, null, null, null); + updatedAt, null, null, null, null, null); } } + public record QueryEntity(Double amount, String currency, String comparator, String amountBasis) {} + + public record QueryPlan(String originalQuery, String normalizedQuery, List alternateQueries, + String intent, String domain, QueryEntity entities, + List requiredAuthorities, boolean needsClarification, + List evidenceNeeds, List clarificationReasons) {} + + public record ChannelContribution(String channel, int channelRank, Double rawScore, + Double fusionScore, String queryVariant) {} + + public record RetrievalCandidate(Long fragmentId, String title, String sourceAuthority, String sourceKind, + Integer candidateRank, Double fusionScore, Double rerankScore, + String rerankModel, boolean rerankApplied, boolean selectedForEvidence, + String decisionReason, List contributions) {} + public record LocatorSummary(Integer pageNumber, Integer slideNumber, Integer paragraphStart, Integer paragraphEnd, String sheetName, Integer rowStart, Integer rowEnd, Long startMs, Long endMs, Long frameMs) {} @@ -127,8 +151,20 @@ public final class AihrKnowledgeQueryDto { String rewrittenQuery, List resources, MemoryCandidateResponse memoryCandidate, - BroadcastContext broadcastContext + BroadcastContext broadcastContext, + QueryPlan queryPlan, + List retrievalCandidates ) { + 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, + MemoryCandidateResponse memoryCandidate, BroadcastContext broadcastContext) { + this(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence, promptVersion, + legacy, data, conversationId, contextVersion, intent, rewrittenQuery, resources, + memoryCandidate, broadcastContext, null, List.of()); + } + public QueryResponse(String requestId, String queryText, String answer, List citations, List usedSpaceCodes, boolean noEvidence, String promptVersion, SearchResponse legacy, Object data, String conversationId, Long contextVersion, @@ -157,7 +193,7 @@ public final class AihrKnowledgeQueryDto { public QueryResponse withMemoryCandidate(MemoryCandidateResponse candidate) { return new QueryResponse(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence, promptVersion, legacy, data, conversationId, contextVersion, intent, rewrittenQuery, - resources, candidate, broadcastContext); + resources, candidate, broadcastContext, queryPlan, retrievalCandidates); } } } 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 d1482ed6..8c566b67 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 @@ -7,6 +7,8 @@ import lombok.extern.slf4j.Slf4j; import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.domain.AihrSopDto.SnippetResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.ChannelContribution; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.RetrievalCandidate; import org.springframework.dao.DataAccessException; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @@ -105,6 +107,37 @@ public class AihrKnowledgeQueryAuditService { recordEvidence(requestId, tenantId, snippets, fragmentIds); } + public void recordCandidates(String requestId, String tenantId, List candidates) { + if (candidates == null || candidates.isEmpty()) return; + for (RetrievalCandidate candidate : candidates) { + if (candidate == null || candidate.fragmentId() == null || candidate.fragmentId() <= 0) continue; + List contributions = candidate.contributions() == null + ? List.of() : candidate.contributions(); + for (ChannelContribution contribution : contributions) { + try { + jdbcTemplate.update(""" + insert into aihr_query_candidate_trace + (tenant_id, request_id, fragment_id, candidate_rank, channel, channel_rank, + raw_score, fusion_score, rerank_score, rerank_model, rerank_applied, + source_authority, source_kind, query_variant, selected_for_evidence, + decision_reason, create_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now()) + """, tenantId, requestId, candidate.fragmentId(), candidate.candidateRank(), + normalizedChannel(contribution.channel()), contribution.channelRank(), + contribution.rawScore(), candidate.fusionScore(), candidate.rerankScore(), + bounded(candidate.rerankModel(), 100), candidate.rerankApplied(), + bounded(candidate.sourceAuthority(), 30), bounded(candidate.sourceKind(), 30), + bounded(contribution.queryVariant(), 30), candidate.selectedForEvidence(), + bounded(candidate.decisionReason(), 64)); + } catch (DataAccessException ex) { + log.warn("knowledge query candidate trace write failed for request {} fragment {}", + requestId, candidate.fragmentId()); + break; + } + } + } + } + private static String normalizedChannel(String value) { if (value == null || value.isBlank()) { return "UNKNOWN"; @@ -113,6 +146,12 @@ public class AihrKnowledgeQueryAuditService { return normalized.length() > 20 ? normalized.substring(0, 20) : normalized; } + private static String bounded(String value, int max) { + if (value == null || value.isBlank()) return null; + String normalized = value.trim(); + return normalized.length() <= max ? normalized : normalized.substring(0, max); + } + 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 3d05f47a..0c1de14d 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 @@ -22,6 +22,7 @@ import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall; import org.dromara.aihr.memory.AihrMemoryService; import org.dromara.aihr.service.AihrModelSeedService; import org.dromara.aihr.service.AihrSopSeedService; +import org.dromara.aihr.service.AihrSopSeedService.SearchExecution; import org.dromara.common.core.constant.HttpStatus; import org.dromara.common.core.enums.UserType; import org.dromara.common.core.exception.ServiceException; @@ -368,11 +369,17 @@ public class AihrKnowledgeQueryService { principal == null ? Set.of() : principal.roles()); SearchRequest searchRequest = new SearchRequest( request.queryText(), request.category(), request.position(), retrievalSource, request.limit()); - SearchResponse legacy = retrievalQuery.equals(request.queryText()) - ? sopService.searchAuthorized(searchRequest, - principal == null ? "" : principal.extPartyId(), spaceIds) - : sopService.searchAuthorized(searchRequest, - principal == null ? "" : principal.extPartyId(), spaceIds, retrievalQuery); + SearchExecution execution = sopService.searchAuthorizedDetailed(searchRequest, + principal == null ? "" : principal.extPartyId(), spaceIds, retrievalQuery); + if (execution == null) { + SearchResponse fallback = retrievalQuery.equals(request.queryText()) + ? sopService.searchAuthorized(searchRequest, + principal == null ? "" : principal.extPartyId(), spaceIds) + : sopService.searchAuthorized(searchRequest, + principal == null ? "" : principal.extPartyId(), spaceIds, retrievalQuery); + execution = new SearchExecution(fallback, null, List.of()); + } + SearchResponse legacy = execution.response(); List citations = citations( app.tenantId(), spaceIds, legacy.snippets(), formalPolicyOnly, request.queryText()); boolean noEvidence = citations.isEmpty(); @@ -386,8 +393,10 @@ public class AihrKnowledgeQueryService { citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, latency, legacy.promptVersion()); auditService.recordEvidence(requestId, app.tenantId(), legacy.snippets(), citations.stream() .map(Citation::fragmentId).filter(Objects::nonNull).toList()); + auditService.recordCandidates(requestId, app.tenantId(), execution.candidates()); return new QueryResponse(requestId, displayQuery, answer, citations, scopeCodes, - noEvidence, legacy.promptVersion(), legacy, null); + noEvidence, legacy.promptVersion(), legacy, null, null, null, "QA", request.queryText(), + List.of(), null, null, execution.queryPlan(), execution.candidates()); } catch (RuntimeException ex) { auditService.record(requestId, principal, app, displayQuery, scopeCodes, List.of(), ex instanceof ServiceException serviceEx && serviceEx.getCode() != null && serviceEx.getCode() < 500 @@ -419,7 +428,8 @@ public class AihrKnowledgeQueryService { documentResponse.usedSpaceCodes(), false, BROADCAST_PROMPT_VERSION, documentResponse.legacy(), null, documentResponse.conversationId(), documentResponse.contextVersion(), "QA", documentResponse.rewrittenQuery(), documentResponse.resources(), documentResponse.memoryCandidate(), - new BroadcastContext(broadcast.messageId(), broadcast.title(), broadcast.publishedAt()) + new BroadcastContext(broadcast.messageId(), broadcast.title(), broadcast.publishedAt()), + documentResponse.queryPlan(), documentResponse.retrievalCandidates() ); } @@ -536,7 +546,8 @@ public class AihrKnowledgeQueryService { return new QueryResponse( response.requestId(), question, answer, citations, response.usedSpaceCodes(), noEvidence, response.promptVersion(), response.legacy(), response.data(), context.conversationId(), nextVersion, - context.intent(), context.rewrittenQuery(), resources, response.memoryCandidate(), response.broadcastContext() + context.intent(), context.rewrittenQuery(), resources, response.memoryCandidate(), response.broadcastContext(), + response.queryPlan(), response.retrievalCandidates() ); } @@ -564,7 +575,8 @@ public class AihrKnowledgeQueryService { String ref = citationDetailService.issue(citation, tenantId); return new Citation(citation.spaceCode(), citation.sourceType(), citation.docId(), citation.title(), citation.snippet(), citation.fragmentId(), citation.domain(), citation.status(), citation.occurredAt(), - citation.updatedAt(), location.sourceKind(), ref, location.summary()); + citation.updatedAt(), location.sourceKind(), ref, location.summary(), + citation.sourceAuthority(), citation.sourceKind()); }).toList(); } @@ -611,7 +623,7 @@ public class AihrKnowledgeQueryService { 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() + response.memoryCandidate(), response.broadcastContext(), response.queryPlan(), response.retrievalCandidates() ); } @@ -720,26 +732,7 @@ 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}$' - ) - """ : ""; + String formalSourceClause = ""; List rows = jdbcTemplate.query(""" select k.code as space_code, f.doc_id, coalesce(( @@ -756,39 +749,39 @@ public class AihrKnowledgeQueryService { and governed_title_chunk.published_fragment_id = f.id limit 1 ), a.name, k.name) as title, - f.content, f.id as fragment_id - from aihr_knowledge_fragment f - join aihr_knowledge_info k on k.id = f.knowledge_id and k.tenant_id = f.tenant_id - left join aihr_knowledge_attach a - on a.tenant_id = f.tenant_id and a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id - where f.tenant_id = ? - and f.knowledge_id in (%s) - and f.id in (%s) - and exists ( - select 1 - from aihr_chunk_revision governed_chunk - join aihr_data_asset governed_asset - on governed_asset.tenant_id = governed_chunk.tenant_id - and governed_asset.id = governed_chunk.asset_id - and coalesce(governed_asset.published_version_id, governed_asset.current_version_id) = governed_chunk.version_id - join aihr_dataset_membership governed_dataset - on governed_dataset.tenant_id = governed_chunk.tenant_id - and governed_dataset.version_id = governed_chunk.version_id - and governed_dataset.dataset_code = 'production' - and governed_dataset.status = 'ACTIVE' - where governed_chunk.tenant_id = f.tenant_id - and governed_chunk.published_fragment_id = f.id - and governed_asset.lifecycle_status = 'PUBLISHED' - and governed_asset.trust_level = 'HUMAN_VERIFIED' - and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date()) - and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date()) - ) - %s + f.content, f.id as fragment_id, governed_asset.source_authority, + governed_asset.source_type, governed_asset.source_name + from aihr_knowledge_fragment f + join aihr_knowledge_info k on k.id = f.knowledge_id and k.tenant_id = f.tenant_id + left join aihr_knowledge_attach a + on a.tenant_id = f.tenant_id and a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id + join aihr_chunk_revision governed_chunk + on governed_chunk.tenant_id = f.tenant_id and governed_chunk.published_fragment_id = f.id + join aihr_data_asset governed_asset + on governed_asset.tenant_id = governed_chunk.tenant_id + and governed_asset.id = governed_chunk.asset_id + and coalesce(governed_asset.published_version_id, governed_asset.current_version_id) = governed_chunk.version_id + join aihr_dataset_membership governed_dataset + on governed_dataset.tenant_id = governed_chunk.tenant_id + and governed_dataset.version_id = governed_chunk.version_id + and governed_dataset.dataset_code = 'production' + and governed_dataset.status = 'ACTIVE' + where f.tenant_id = ? + and f.knowledge_id in (%s) + and f.id in (%s) + and governed_asset.lifecycle_status = 'PUBLISHED' + and governed_asset.trust_level = 'HUMAN_VERIFIED' + and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date()) + and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date()) + %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"), - rs.getString("content"), rs.getLong("fragment_id") + rs.getString("content"), rs.getLong("fragment_id"), "ENTERPRISE", null, null, null, + null, null, null, rs.getString("source_authority"), + AihrRagQueryPlanner.sourceKind(rs.getString("source_authority"), rs.getString("source_type"), + rs.getString("source_name")) ), args.toArray()); Map byId = new LinkedHashMap<>(); rows.forEach(row -> byId.put(row.fragmentId(), row)); @@ -796,13 +789,15 @@ public class AihrKnowledgeQueryService { .map(byId::get) .filter(java.util.Objects::nonNull) .filter(row -> !formalPolicyOnly - || AihrFormalPolicyClassifier.isRelevantFormalEvidence(queryText, row.snippet())) + || (Set.of("FORMAL_POLICY", "OPERATING_MANUAL", "APPROVED_SOP").contains(row.sourceKind()) + && AihrFormalPolicyClassifier.isRelevantFormalEvidence(queryText, row.snippet()))) .map(row -> new Citation( row.spaceCode(), row.sourceType(), row.docId(), row.title(), formalPolicyOnly ? AihrFormalPolicyClassifier.relevantEvidenceSnippet(queryText, row.snippet(), 500) : truncate(row.snippet(), 500), - row.fragmentId() + row.fragmentId(), row.domain(), row.status(), row.occurredAt(), row.updatedAt(), + row.mediaType(), row.detailRef(), row.locatorSummary(), row.sourceAuthority(), row.sourceKind() )) .toList(); } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrRagQueryPlanner.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrRagQueryPlanner.java new file mode 100644 index 00000000..55453f37 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrRagQueryPlanner.java @@ -0,0 +1,236 @@ +package org.dromara.aihr.knowledge.service; + +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryEntity; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryPlan; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Deterministic, authorization-free planning for bounded enterprise knowledge retrieval. */ +public final class AihrRagQueryPlanner { + + public static final String FINANCE_REIMBURSEMENT = "FINANCE_REIMBURSEMENT"; + public static final String FINANCE_POLICY = "FINANCE_POLICY"; + public static final String GENERAL_KNOWLEDGE = "GENERAL_KNOWLEDGE"; + private static final List FORMAL_AUTHORITIES = + List.of("FORMAL_POLICY", "OPERATING_MANUAL", "APPROVED_SOP"); + private static final Pattern ARABIC_AMOUNT = Pattern.compile( + "(? BUSINESS_TERMS = List.of( + "费用报销", "报销流程", "发票登记", "财务复审", "零星采购", "小额采购", "个人借款", + "采购审批", "报销单", "借款", "报销", "发票", "采购", "自采", "财务", "报备", "审批", + "材料", "流程", "额度", "月累计", "单笔" + ); + private static final List STOP_PHRASES = List.of( + "我有个", "告诉我", "请告诉", "请问", "帮我", "怎么走", "怎么", "如何", "一下", "这个", + "那个", "有没有", "能不能", "可以吗", "是什么", "的", "以下", "以内", "以上", "不超过" + ); + private static final Set STOP_TERMS = Set.of( + "我有", "有个", "个报", "告诉", "诉我", "怎么", "么走", "以下", "以内", "以上", "块钱", + "这个", "那个", "一下", "请问", "能不", "不能", "可以", "以吗" + ); + + private AihrRagQueryPlanner() {} + + public static QueryPlan plan(String rawQuery) { + String original = clean(rawQuery); + QueryEntity entities = entities(original); + String domain = domain(original); + boolean finance = isFormalFinanceDomain(domain); + String normalized = normalize(original, domain, entities); + List alternates = alternates(original, domain); + List evidenceNeeds = evidenceNeeds(original, domain, entities); + List reasons = new ArrayList<>(); + if (FINANCE_REIMBURSEMENT.equals(domain) && entities.amount() != null + && "UNKNOWN".equals(entities.amountBasis())) { + reasons.add("金额口径不明确:请确认是单笔金额还是月累计额度"); + } + return new QueryPlan( + original, + normalized, + alternates, + "KNOWLEDGE_QA", + domain, + entities, + finance ? FORMAL_AUTHORITIES : List.of(), + !reasons.isEmpty(), + evidenceNeeds, + List.copyOf(reasons) + ); + } + + public static List sparseTerms(String queryText) { + String original = clean(queryText).toLowerCase(Locale.ROOT); + if (original.isBlank()) return List.of(); + LinkedHashSet terms = new LinkedHashSet<>(); + for (String businessTerm : BUSINESS_TERMS) { + if (original.contains(businessTerm)) terms.add(businessTerm); + } + Matcher amountMatcher = ARABIC_AMOUNT.matcher(original); + StringBuffer withoutAmounts = new StringBuffer(); + while (amountMatcher.find()) { + terms.add(amountMatcher.group(1) + "元"); + amountMatcher.appendReplacement(withoutAmounts, " "); + } + amountMatcher.appendTail(withoutAmounts); + String text = normalizeChineseAmounts(withoutAmounts.toString()) + .replaceAll("[^\\p{IsHan}A-Za-z0-9_-]+", " "); + for (String stop : STOP_PHRASES) text = text.replace(stop, " "); + Matcher hanMatcher = HAN_RUN.matcher(text); + while (hanMatcher.find() && terms.size() < 24) { + String run = hanMatcher.group(); + if (!STOP_TERMS.contains(run)) terms.add(run); + for (int i = 0; i + 2 <= run.length() && terms.size() < 24; i++) { + String term = run.substring(i, i + 2); + if (!STOP_TERMS.contains(term)) terms.add(term); + } + } + Matcher latinMatcher = LATIN_TERM.matcher(text); + while (latinMatcher.find() && terms.size() < 24) terms.add(latinMatcher.group().toLowerCase(Locale.ROOT)); + return terms.stream() + .filter(term -> !term.matches("\\d+")) + .filter(term -> !STOP_TERMS.contains(term)) + .limit(24) + .toList(); + } + + public static boolean isFormalFinanceDomain(String domain) { + return FINANCE_REIMBURSEMENT.equals(domain) || FINANCE_POLICY.equals(domain); + } + + public static String sourceKind(String sourceAuthority, String sourceType, String sourceName) { + String authority = clean(sourceAuthority).toUpperCase(Locale.ROOT); + String type = clean(sourceType).toUpperCase(Locale.ROOT); + String name = clean(sourceName).toLowerCase(Locale.ROOT); + if (authority.contains("EXPERIENCE") || authority.contains("EMPLOYEE_SUBMISSION") + || type.contains("INTERVIEW")) return "EXPERIENCE"; + if (authority.contains("CASE") || type.contains("CASE")) return "CASE"; + if (name.contains("操作手册") || authority.contains("OPERATING_MANUAL")) return "OPERATING_MANUAL"; + if (name.contains("sop") || authority.contains("APPROVED_SOP")) return "APPROVED_SOP"; + if (authority.contains("POLICY") || authority.contains("COMPANY_POLICY") + || authority.contains("FORMAL")) return "FORMAL_POLICY"; + return "REFERENCE_MATERIAL"; + } + + public static boolean authorityAllowed(QueryPlan plan, String sourceKind) { + return plan == null || plan.requiredAuthorities().isEmpty() + || plan.requiredAuthorities().contains(sourceKind); + } + + public static boolean domainMatches(QueryPlan plan, String title, String content) { + if (plan == null || !isFormalFinanceDomain(plan.domain())) return true; + String source = clean(title) + " " + clean(content); + if (FINANCE_REIMBURSEMENT.equals(plan.domain())) { + return containsAny(source, "费用报销", "发票登记", "报销单", "财务复审", "个人借款") + || (containsAny(title, "报销", "发票", "财务", "借款") + && containsAny(source, "流程", "提交", "登记", "审批", "复审")); + } + return containsAny(title, "财务", "报销", "借款", "发票", "采购") + || containsAny(source, "财务管理", "费用报销", "个人借款", "采购审批"); + } + + private static String domain(String query) { + if (containsAny(query, "报销", "走账") && containsAny(query, "采购", "自采", "发票", "费用", "垫付")) { + return FINANCE_REIMBURSEMENT; + } + if (containsAny(query, "报销", "借款", "发票", "财务", "采购", "自采", "报备", "审批")) { + return FINANCE_POLICY; + } + return GENERAL_KNOWLEDGE; + } + + private static QueryEntity entities(String query) { + Double amount = null; + Matcher arabic = ARABIC_AMOUNT.matcher(query); + if (arabic.find()) amount = Double.valueOf(arabic.group(1)); + if (amount == null) { + Matcher chinese = CHINESE_HUNDREDS.matcher(query); + if (chinese.find()) amount = chineseDigit(chinese.group(1)) * 100.0; + } + String comparator = containsAny(query, "以下", "以内", "不超过", "最多") ? "LTE" + : containsAny(query, "超过", "大于") ? "GT" + : containsAny(query, "以上", "不少于", "至少") ? "GTE" : "UNSPECIFIED"; + String basis = containsAny(query, "单笔", "每笔", "一笔") ? "PER_TRANSACTION" + : containsAny(query, "月累计", "每月", "当月", "一个月", "月度") ? "MONTHLY_TOTAL" : "UNKNOWN"; + return new QueryEntity(amount, amount == null ? null : "CNY", comparator, basis); + } + + private static String normalize(String original, String domain, QueryEntity entities) { + if (!isFormalFinanceDomain(domain)) return original; + LinkedHashSet parts = new LinkedHashSet<>(); + if (containsAny(original, "采购", "自采", "零星", "小额")) parts.add("零星采购"); + parts.add(FINANCE_REIMBURSEMENT.equals(domain) ? "费用报销流程" : "财务制度"); + if (containsAny(original, "发票")) parts.add("发票登记"); + if (entities.amount() != null) { + String amount = entities.amount() % 1 == 0 + ? String.valueOf(entities.amount().longValue()) : String.valueOf(entities.amount()); + parts.add(amount + "元" + comparatorText(entities.comparator())); + } + if (!"UNKNOWN".equals(entities.amountBasis())) parts.add(entities.amountBasis()); + return String.join(" ", parts); + } + + private static List alternates(String original, String domain) { + if (!isFormalFinanceDomain(domain)) return List.of(); + LinkedHashSet values = new LinkedHashSet<>(); + values.add("费用报销 发票登记 报销单 财务复审"); + if (containsAny(original, "采购", "自采", "零星", "小额")) values.add("零星采购 自采 费用报销"); + if (containsAny(original, "借款")) values.add("个人借款 费用报销冲抵"); + return values.stream().limit(2).toList(); + } + + private static List evidenceNeeds(String original, String domain, QueryEntity entities) { + if (!isFormalFinanceDomain(domain)) return List.of("DIRECT_ANSWER"); + LinkedHashSet needs = new LinkedHashSet<>(); + if (entities.amount() != null) { + needs.add("AMOUNT_THRESHOLD"); + needs.add("AMOUNT_BASIS"); + needs.add("APPLICABILITY"); + } + if (containsAny(original, "怎么", "如何", "流程", "走账", "报销")) needs.add("ENTRY_STEPS"); + needs.add("REQUIRED_MATERIALS"); + needs.add("APPROVAL_REVIEW"); + needs.add("EXCEPTIONS"); + return List.copyOf(needs); + } + + private static String normalizeChineseAmounts(String value) { + Matcher matcher = CHINESE_HUNDREDS.matcher(value); + StringBuffer output = new StringBuffer(); + while (matcher.find()) matcher.appendReplacement(output, chineseDigit(matcher.group(1)) + "00元"); + matcher.appendTail(output); + return output.toString(); + } + + private static int chineseDigit(String value) { + return "一二三四五六七八九".indexOf(value) + 1; + } + + private static String comparatorText(String comparator) { + return switch (Optional.ofNullable(comparator).orElse("")) { + case "LTE" -> "以下"; + case "GT" -> "以上"; + case "GTE" -> "及以上"; + default -> ""; + }; + } + + private static boolean containsAny(String value, String... terms) { + String source = clean(value).toLowerCase(Locale.ROOT); + for (String term : terms) if (source.contains(term.toLowerCase(Locale.ROOT))) return true; + return false; + } + + private static String clean(String value) { + return Optional.ofNullable(value).orElse("").trim(); + } +} 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 ee938f30..de4f3d70 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 @@ -46,6 +46,10 @@ import org.dromara.aihr.knowledge.parse.ParsedDocument.LocatedSegment; import org.dromara.aihr.knowledge.parse.AihrExtractionQuality; import org.dromara.aihr.knowledge.parse.TikaKnowledgeDocumentParser; import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier; +import org.dromara.aihr.knowledge.service.AihrRagQueryPlanner; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.ChannelContribution; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryPlan; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.RetrievalCandidate; import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycle.UsageType; import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycle.ReasonCode; import org.dromara.aihr.knowledge.quality.AihrKnowledgeLifecycleService; @@ -126,6 +130,10 @@ public class AihrSopSeedService { private static final String LOCAL_EMBEDDING_BASE_URL = "local"; private static final int LOCAL_EMBEDDING_DIMENSION = 1536; private static final int MAX_EVIDENCE_SNIPPETS = 3; + private static final int RETRIEVAL_CANDIDATE_K = 30; + private static final int RETRIEVAL_RERANK_K = 50; + private static final int RETRIEVAL_EVIDENCE_K = 3; + private static final int RETRIEVAL_DISPLAY_K = 3; private static final KnowledgeDocumentParser KNOWLEDGE_DOCUMENT_PARSER = new TikaKnowledgeDocumentParser(); private static final String NO_CLEAR_SOP_EVIDENCE = "未在已发布 SOP 中找到明确依据"; private static final String LEGACY_NO_CLEAR_SOP_EVIDENCE = "未在已入库 SOP 中找到明确依据"; @@ -227,11 +235,16 @@ public class AihrSopSeedService { public SearchResponse searchAuthorized(SearchRequest request, String requesterExtPartyId, Set allowedKnowledgeIds) { String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim(); - return searchAuthorized(request, requesterExtPartyId, allowedKnowledgeIds, queryText); + return searchAuthorizedDetailed(request, requesterExtPartyId, allowedKnowledgeIds, queryText).response(); } public SearchResponse searchAuthorized(SearchRequest request, String requesterExtPartyId, Set allowedKnowledgeIds, String retrievalQueryText) { + return searchAuthorizedDetailed(request, requesterExtPartyId, allowedKnowledgeIds, retrievalQueryText).response(); + } + + public SearchExecution searchAuthorizedDetailed(SearchRequest request, String requesterExtPartyId, + Set allowedKnowledgeIds, String retrievalQueryText) { if (allowedKnowledgeIds == null || allowedKnowledgeIds.isEmpty()) { throw new ServiceException("当前请求没有可访问的知识空间", 403); } @@ -240,18 +253,20 @@ public class AihrSopSeedService { String position = firstNonBlank(request == null ? null : request.position(), "生活顾问"); String source = normalizeSearchSource(request == null ? null : request.source()); String requester = isBlank(requesterExtPartyId) ? "" : requesterExtPartyId.trim(); + QueryPlan plan = AihrRagQueryPlanner.plan(queryText); if (isBlank(queryText)) { - return withReviewId(noEvidenceResponse("", "sop"), source, requester); + return new SearchExecution(withReviewId(noEvidenceResponse("", "sop"), source, requester), plan, List.of()); } boolean formalPolicyOnly = AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE.equals(source); - SearchResponse response = dbSearch( + SearchExecution execution = dbSearchDetailed( "", queryText, retrievalQuery, request == null ? null : request.limit(), allowedKnowledgeIds, - formalPolicyOnly); + formalPolicyOnly, plan); + SearchResponse response = execution.response(); if (response == null || isNoEvidenceAnswer(response.answer())) { recordKnowledgeGap(queryText, "sop", position, source); - response = noEvidenceResponse(queryText, "sop"); + if (response == null) response = noEvidenceResponse(queryText, "sop"); } - return withReviewId(response, source, requester); + return new SearchExecution(withReviewId(response, source, requester), plan, execution.candidates()); } /** @@ -3418,6 +3433,10 @@ public class AihrSopSeedService { + String.join(",", java.util.Collections.nCopies(allowedKnowledgeIds.size(), "?")) + ")"; } + private static String sqlPlaceholders(int count) { + return String.join(",", java.util.Collections.nCopies(count, "?")); + } + private static void addKnowledgeScopeArgs(List args, Set allowedKnowledgeIds) { if (allowedKnowledgeIds != null) { args.addAll(allowedKnowledgeIds); @@ -3431,21 +3450,24 @@ public class AihrSopSeedService { 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}$' + from aihr_chunk_revision governed_chunk + join aihr_data_asset governed_asset + on governed_asset.tenant_id = governed_chunk.tenant_id + and governed_asset.id = governed_chunk.asset_id + and coalesce(governed_asset.published_version_id, governed_asset.current_version_id) = governed_chunk.version_id + join aihr_dataset_membership governed_dataset + on governed_dataset.tenant_id = governed_chunk.tenant_id + and governed_dataset.version_id = governed_chunk.version_id + and governed_dataset.dataset_code = 'production' + and governed_dataset.status = 'ACTIVE' + where governed_chunk.tenant_id = %1$s.tenant_id + and governed_chunk.published_fragment_id = %1$s.id + and governed_asset.lifecycle_status = 'PUBLISHED' + and governed_asset.trust_level = 'HUMAN_VERIFIED' + and upper(governed_asset.source_authority) in + ('COMPANY_POLICY', 'FORMAL_POLICY', 'OPERATING_MANUAL', 'APPROVED_SOP') + and (governed_asset.effective_from is null or governed_asset.effective_from <= current_date()) + and (governed_asset.effective_to is null or governed_asset.effective_to >= current_date()) ) """.formatted(fragmentAlias); } @@ -3473,6 +3495,305 @@ public class AihrSopSeedService { } } + private SearchExecution dbSearchDetailed(String category, String queryText, String retrievalQueryText, + Integer displayLimit, Set allowedKnowledgeIds, + boolean formalPolicyOnly, QueryPlan plan) { + try { + boolean requiresFormal = formalPolicyOnly || !plan.requiredAuthorities().isEmpty(); + List candidates = retrieveCandidates( + category, queryText, retrievalQueryText, allowedKnowledgeIds, plan, requiresFormal); + if (candidates.isEmpty()) return new SearchExecution(null, plan, List.of()); + enrichCandidateGovernance(candidates); + Set rejectedFeedback = downFeedbackFragmentIds(queryText); + for (CandidateAccumulator candidate : candidates) { + if (candidate.hit.fragmentId() != null && rejectedFeedback.contains(candidate.hit.fragmentId())) { + candidate.reject("REJECTED_DOWNSTREAM_FEEDBACK"); + } else if (requiresFormal && !formalSourceKind(candidate.sourceKind)) { + candidate.reject("REJECTED_SOURCE_AUTHORITY"); + } else if (!AihrRagQueryPlanner.domainMatches(plan, candidate.hit.title(), candidate.hit.content())) { + candidate.reject("REJECTED_DOMAIN_MISMATCH"); + } + } + List eligible = candidates.stream() + .filter(candidate -> candidate.decisionReason == null) + .sorted(Comparator.comparingDouble(CandidateAccumulator::fusionScore).reversed()) + .toList(); + List rerankPool = eligible.stream().limit(RETRIEVAL_RERANK_K).toList(); + eligible.stream().skip(RETRIEVAL_RERANK_K) + .forEach(candidate -> candidate.reject("OUTSIDE_RERANK_K")); + List ranked = rerankCandidates(queryText, rerankPool); + for (int index = 0; index < ranked.size(); index++) ranked.get(index).candidateRank = index + 1; + + List evidence = ranked.stream().limit(RETRIEVAL_EVIDENCE_K).toList(); + if (plan.needsClarification()) { + evidence.forEach(candidate -> candidate.reject("NEEDS_CLARIFICATION_AMOUNT_BASIS")); + String message = "已找到正式的费用报销资料,但当前问题中的金额口径不明确:请确认“" + + amountText(plan) + "”是单笔金额还是月累计额度。当前资料不能据此推导零星采购必然可以报销。"; + return new SearchExecution(clarificationResponse(queryText, category, message), plan, + candidateTraces(candidates)); + } + if (requiresFormal && evidence.isEmpty()) { + return new SearchExecution(noEvidenceResponse(queryText, category, + List.of("没有找到当前有效且经过人工审核的正式制度、操作手册或已批准 SOP。")), + plan, candidateTraces(candidates)); + } + if (requiresAmountEvidence(plan) && !supportsAmountRule(plan, evidence)) { + evidence.forEach(candidate -> candidate.reject("INSUFFICIENT_AMOUNT_EVIDENCE")); + String message = "已找到正式的费用报销操作资料,但当前正式资料没有证明“" + amountText(plan) + + "”对应零星采购的适用额度和口径。请由财务负责人确认单笔/月累计规则后再办理。"; + return new SearchExecution(clarificationResponse(queryText, category, message), plan, + candidateTraces(candidates)); + } + if (!hasRequiredFinanceEvidence(plan, evidence)) { + evidence.forEach(candidate -> candidate.reject("INSUFFICIENT_REQUIRED_EVIDENCE")); + return new SearchExecution(noEvidenceResponse(queryText, category, + List.of("正式资料尚未同时覆盖报销入口、必要材料和审批/复审要求。")), + plan, candidateTraces(candidates)); + } + evidence.forEach(candidate -> { + candidate.selectedForEvidence = true; + candidate.decisionReason = "SELECTED_EVIDENCE"; + }); + ranked.stream().skip(evidence.size()).forEach(candidate -> { + if (candidate.decisionReason == null) candidate.decisionReason = "OUTSIDE_EVIDENCE_K"; + }); + SearchResponse response = responseFromHits(category, queryText, + evidence.stream().map(candidate -> candidate.hit).toList(), List.of(), displayLimitValue(displayLimit)); + return new SearchExecution(response, plan, candidateTraces(candidates)); + } catch (DataAccessException ex) { + return new SearchExecution(null, plan, List.of()); + } + } + + private List retrieveCandidates(String category, String queryText, + String retrievalQueryText, + Set allowedKnowledgeIds, QueryPlan plan, + boolean includePublishedCandidates) { + List variants = queryVariants(queryText, retrievalQueryText, plan); + Map candidates = new LinkedHashMap<>(); + for (QueryVariant variant : variants) { + addCandidateHits(candidates, likeHits(category, variant.text(), RETRIEVAL_CANDIDATE_K, + allowedKnowledgeIds, false, includePublishedCandidates), "KEYWORD", variant.code(), true); + addCandidateHits(candidates, fulltextHits(category, variant.text(), RETRIEVAL_CANDIDATE_K, + allowedKnowledgeIds, false, includePublishedCandidates), "FULLTEXT", variant.code(), true); + } + Map> vectorHits = vectorHits( + category, variants, RETRIEVAL_CANDIDATE_K, allowedKnowledgeIds, false, includePublishedCandidates); + for (QueryVariant variant : variants) { + addCandidateHits(candidates, vectorHits.getOrDefault(variant.code(), List.of()), + "VECTOR", variant.code(), false); + } + applyFamilyFusion(candidates.values(), true); + applyFamilyFusion(candidates.values(), false); + return candidates.values().stream() + .peek(CandidateAccumulator::finishFusion) + .sorted(Comparator.comparingDouble(CandidateAccumulator::fusionScore).reversed()) + .toList(); + } + + private static List queryVariants(String queryText, String retrievalQueryText, QueryPlan plan) { + LinkedHashMap variants = new LinkedHashMap<>(); + addQueryVariant(variants, "ORIGINAL", queryText); + if (!Objects.equals(cleanQuery(queryText), cleanQuery(retrievalQueryText))) { + addQueryVariant(variants, "GLOSSARY", retrievalQueryText); + } + addQueryVariant(variants, "NORMALIZED", plan.normalizedQuery()); + for (int index = 0; index < plan.alternateQueries().size(); index++) { + addQueryVariant(variants, "ALTERNATE_" + (index + 1), plan.alternateQueries().get(index)); + } + return variants.entrySet().stream().map(entry -> new QueryVariant(entry.getKey(), entry.getValue())).toList(); + } + + private static void addQueryVariant(Map variants, String code, String value) { + String query = cleanQuery(value); + if (query.isBlank() || variants.values().stream().anyMatch(existing -> cleanQuery(existing).equals(query))) return; + variants.put(code, value.trim()); + } + + private static String cleanQuery(String value) { + return Optional.ofNullable(value).orElse("").replaceAll("\\s+", " ").trim().toLowerCase(Locale.ROOT); + } + + private static void addCandidateHits(Map candidates, List hits, + String channel, String queryVariant, boolean sparse) { + for (int index = 0; index < hits.size(); index++) { + KnowledgeHit hit = hits.get(index); + String key = hit.fragmentId() == null ? hit.docId() + "#" + hit.idx() : String.valueOf(hit.fragmentId()); + CandidateAccumulator candidate = candidates.computeIfAbsent(key, ignored -> new CandidateAccumulator(hit)); + candidate.add(channel, index + 1, hit.score(), queryVariant, sparse); + } + } + + private static void applyFamilyFusion(java.util.Collection candidates, boolean sparse) { + List ranked = candidates.stream() + .filter(candidate -> sparse ? candidate.sparseSignal > 0 : candidate.vectorSignal > 0) + .sorted(Comparator.comparingDouble((CandidateAccumulator candidate) -> + sparse ? candidate.sparseSignal : candidate.vectorSignal) + .reversed()) + .toList(); + for (int index = 0; index < ranked.size(); index++) { + double contribution = reciprocalRank(index + 1); + if (sparse) ranked.get(index).sparseFusion = contribution; + else ranked.get(index).vectorFusion = contribution; + } + } + + private static double reciprocalRank(int rank) { + return 1.0 / (60 + Math.max(1, rank)); + } + + private void enrichCandidateGovernance(List candidates) { + List fragmentIds = candidates.stream().map(candidate -> candidate.hit.fragmentId()) + .filter(Objects::nonNull).distinct().toList(); + if (fragmentIds.isEmpty()) return; + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(fragmentIds); + jdbcTemplate.query(""" + select f.id fragment_id, asset.source_authority, asset.source_type, asset.source_name + from aihr_knowledge_fragment f + join aihr_chunk_revision chunk + on chunk.tenant_id = f.tenant_id and chunk.published_fragment_id = f.id + join aihr_data_asset asset + on asset.tenant_id = chunk.tenant_id and asset.id = chunk.asset_id + and coalesce(asset.published_version_id, asset.current_version_id) = chunk.version_id + where f.tenant_id = ? and f.id in (%s) + """.formatted(sqlPlaceholders(fragmentIds.size())), rs -> { + Long fragmentId = rs.getLong("fragment_id"); + candidates.stream().filter(candidate -> Objects.equals(candidate.hit.fragmentId(), fragmentId)) + .findFirst().ifPresent(candidate -> { + candidate.sourceAuthority = rsString(rs, "source_authority"); + candidate.sourceKind = AihrRagQueryPlanner.sourceKind(candidate.sourceAuthority, + rsString(rs, "source_type"), rsString(rs, "source_name")); + }); + }, args.toArray()); + } + + private static String rsString(java.sql.ResultSet rs, String column) { + try { + return rs.getString(column); + } catch (java.sql.SQLException ex) { + throw new IllegalStateException(ex); + } + } + + private List rerankCandidates(String queryText, List candidates) { + if (candidates.size() < 2) return candidates; + Optional runtime = rerankRuntime(); + if (runtime.isEmpty()) return candidates; + try { + ObjectNode body = objectMapper.createObjectNode(); + body.put("model", runtime.get().modelName()); + body.put("query", AihrSensitiveText.forModel(queryText)); + body.put("top_n", candidates.size()); + body.put("return_documents", false); + ArrayNode documents = body.putArray("documents"); + candidates.forEach(candidate -> documents.add( + AihrSensitiveText.forModel(truncate(candidate.hit.content(), 2000)))); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(normalizeBaseUrl(runtime.get().baseUrl()) + "/rerank")) + .timeout(Duration.ofSeconds(30)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + runtime.get().apiKey()) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) + .build(); + HttpResponse response = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)) + .build().send(request, HttpResponse.BodyHandlers.ofString()); + if (!ok(response.statusCode())) throw new IllegalStateException("rerank HTTP " + response.statusCode()); + JsonNode results = objectMapper.readTree(response.body()).path("results"); + if (!results.isArray() || results.isEmpty()) return candidates; + List reranked = new ArrayList<>(); + for (JsonNode item : results) { + int index = item.path("index").asInt(-1); + if (index < 0 || index >= candidates.size()) continue; + CandidateAccumulator candidate = candidates.get(index); + candidate.rerankApplied = true; + candidate.rerankModel = runtime.get().modelName(); + if (item.path("relevance_score").isNumber()) { + candidate.rerankScore = item.path("relevance_score").asDouble(); + } + if (!reranked.contains(candidate)) reranked.add(candidate); + } + for (CandidateAccumulator candidate : candidates) if (!reranked.contains(candidate)) reranked.add(candidate); + return reranked; + } catch (Exception ex) { + log.warn("candidate rerank failed, keep family RRF order(处理错误已隐藏)"); + return candidates; + } + } + + private static boolean formalSourceKind(String sourceKind) { + return Set.of("FORMAL_POLICY", "OPERATING_MANUAL", "APPROVED_SOP").contains(sourceKind); + } + + private static boolean requiresAmountEvidence(QueryPlan plan) { + return plan != null && AihrRagQueryPlanner.isFormalFinanceDomain(plan.domain()) + && plan.entities() != null && plan.entities().amount() != null; + } + + private static boolean supportsAmountRule(QueryPlan plan, List evidence) { + if (!requiresAmountEvidence(plan)) return true; + String amount = amountValue(plan); + String content = evidence.stream().map(candidate -> candidate.hit.content()).filter(Objects::nonNull) + .reduce("", (left, right) -> left + " " + right); + boolean amountFound = content.contains(amount + "元") || content.contains(amount + " 元") + || content.contains(chineseAmount(plan.entities().amount())); + boolean basisFound = "PER_TRANSACTION".equals(plan.entities().amountBasis()) + ? containsAny(content, "单笔", "每笔", "一笔") + : "MONTHLY_TOTAL".equals(plan.entities().amountBasis()) + ? containsAny(content, "月累计", "每月", "当月", "月度额度") : false; + return amountFound && basisFound; + } + + private static boolean hasRequiredFinanceEvidence(QueryPlan plan, List evidence) { + if (plan == null || !AihrRagQueryPlanner.isFormalFinanceDomain(plan.domain())) return !evidence.isEmpty(); + String content = evidence.stream().map(candidate -> candidate.hit.content()).filter(Objects::nonNull) + .reduce("", (left, right) -> left + " " + right); + return containsAny(content, "费用报销", "报销单", "发票登记") + && containsAny(content, "发票", "票据", "附件") + && containsAny(content, "提交", "审批", "复审", "流程结束"); + } + + private static String amountValue(QueryPlan plan) { + double value = plan.entities().amount(); + return value % 1 == 0 ? String.valueOf((long) value) : String.valueOf(value); + } + + private static String amountText(QueryPlan plan) { + if (plan == null || plan.entities() == null || plan.entities().amount() == null) return "该金额"; + String comparator = switch (Optional.ofNullable(plan.entities().comparator()).orElse("")) { + case "LTE" -> "以下"; + case "GT" -> "以上"; + case "GTE" -> "及以上"; + default -> ""; + }; + return amountValue(plan) + "元" + comparator; + } + + private static String chineseAmount(Double amount) { + if (amount == null || amount % 100 != 0 || amount < 100 || amount > 900) return ""; + return "一二三四五六七八九".substring((int) (amount / 100) - 1, (int) (amount / 100)) + "百"; + } + + private static int displayLimitValue(Integer limit) { + if (limit == null) return RETRIEVAL_DISPLAY_K; + return Math.max(1, Math.min(limit, RETRIEVAL_DISPLAY_K)); + } + + private static SearchResponse clarificationResponse(String queryText, String category, String message) { + return new SearchResponse(queryText, firstNonBlank(category, "sop"), message, "正式证据不足,未生成流程", + List.of(), List.of(), List.of(), records(), List.of(), List.of(message), null, null); + } + + private static List candidateTraces(List candidates) { + return candidates.stream() + .sorted(Comparator.comparing((CandidateAccumulator candidate) -> candidate.candidateRank, + Comparator.nullsLast(Integer::compareTo)) + .thenComparing(Comparator.comparingDouble(CandidateAccumulator::fusionScore).reversed())) + .map(CandidateAccumulator::toTrace) + .toList(); + } + private static String normalizeFeedbackRequestId(String value) { if (isBlank(value)) { return null; @@ -3571,6 +3892,14 @@ public class AihrSopSeedService { return "and " + AihrKnowledgeRolloutService.servingFragmentExistsSql(fragmentAlias); } + private static String candidateLifecycleScopeSql(String fragmentAlias, boolean includePublishedCandidates) { + if (!includePublishedCandidates) { + return servingLifecycleScopeSql(fragmentAlias); + } + return "and (" + AihrKnowledgeRolloutService.servingFragmentExistsSql(fragmentAlias) + + " or " + publishedLifecycleExistsSql(fragmentAlias) + ")"; + } + private static String publishedLifecycleExistsSql(String fragmentAlias) { return """ exists ( @@ -3664,6 +3993,40 @@ public class AihrSopSeedService { return List.of(); } + private Map> vectorHits(String category, List variants, int limit, + Set allowedKnowledgeIds, + boolean formalPolicyOnly) { + return vectorHits(category, variants, limit, allowedKnowledgeIds, formalPolicyOnly, false); + } + + private Map> vectorHits(String category, List variants, int limit, + Set allowedKnowledgeIds, + boolean formalPolicyOnly, + boolean includePublishedCandidates) { + if (variants == null || variants.isEmpty()) return Map.of(); + List queries = variants.stream().map(QueryVariant::text).toList(); + for (EmbeddingRuntime runtime : embeddingRuntimes()) { + try { + List embeddings = callEmbeddings(runtime, queries); + if (embeddings.size() != variants.size()) continue; + Map> results = new LinkedHashMap<>(); + for (int index = 0; index < variants.size(); index++) { + List hits = new ArrayList<>(); + for (VectorMatch match : queryQdrant( + category, embeddings.get(index), limit, allowedKnowledgeIds)) { + hydrateQdrantHit(match, allowedKnowledgeIds, formalPolicyOnly, includePublishedCandidates) + .ifPresent(hits::add); + } + results.put(variants.get(index).code(), List.copyOf(hits)); + } + return results; + } catch (Exception ignored) { + // Vector recall is additive; try the next configured runtime before sparse-only fallback. + } + } + return Map.of(); + } + private List fulltextHits(String category, String queryText, int limit) { return fulltextHits(category, queryText, limit, null); } @@ -3674,6 +4037,12 @@ public class AihrSopSeedService { private List fulltextHits(String category, String queryText, int limit, Set allowedKnowledgeIds, boolean formalPolicyOnly) { + return fulltextHits(category, queryText, limit, allowedKnowledgeIds, formalPolicyOnly, false); + } + + private List fulltextHits(String category, String queryText, int limit, + Set allowedKnowledgeIds, boolean formalPolicyOnly, + boolean includePublishedCandidates) { String sql = """ select f.id as fragment_id, coalesce(a.name, i.name) as title, @@ -3695,7 +4064,8 @@ public class AihrSopSeedService { order by score desc, f.idx asc limit ? """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds), - formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f")); + formalSourceScopeSql("f", formalPolicyOnly), + candidateLifecycleScopeSql("f", includePublishedCandidates)); List args = new ArrayList<>(); args.add(queryText); args.add(tenantId()); @@ -3727,6 +4097,12 @@ public class AihrSopSeedService { private List likeHits(String category, String queryText, int limit, Set allowedKnowledgeIds, boolean formalPolicyOnly) { + return likeHits(category, queryText, limit, allowedKnowledgeIds, formalPolicyOnly, false); + } + + private List likeHits(String category, String queryText, int limit, + Set allowedKnowledgeIds, boolean formalPolicyOnly, + boolean includePublishedCandidates) { List terms = searchTerms(queryText); if (terms.isEmpty()) { return List.of(); @@ -3758,7 +4134,8 @@ public class AihrSopSeedService { order by score desc, f.idx asc limit ? """.formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds), - formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f"), + formalSourceScopeSql("f", formalPolicyOnly), + candidateLifecycleScopeSql("f", includePublishedCandidates), String.join(" or ", whereParts)); List args = new ArrayList<>(); terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add); @@ -3791,6 +4168,12 @@ public class AihrSopSeedService { private Optional hydrateQdrantHit(VectorMatch match, Set allowedKnowledgeIds, boolean formalPolicyOnly) { + return hydrateQdrantHit(match, allowedKnowledgeIds, formalPolicyOnly, false); + } + + private Optional hydrateQdrantHit(VectorMatch match, Set allowedKnowledgeIds, + boolean formalPolicyOnly, + boolean includePublishedCandidates) { String sql = """ select f.id as fragment_id, coalesce(a.name, i.name) as title, @@ -3811,7 +4194,8 @@ public class AihrSopSeedService { and f.idx = ? limit 1 """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds), - formalSourceScopeSql("f", formalPolicyOnly), servingLifecycleScopeSql("f")); + formalSourceScopeSql("f", formalPolicyOnly), + candidateLifecycleScopeSql("f", includePublishedCandidates)); List args = new ArrayList<>(); args.add(tenantId()); addKnowledgeScopeArgs(args, allowedKnowledgeIds); @@ -3862,15 +4246,21 @@ public class AihrSopSeedService { } private SearchResponse responseFromHits(String category, String queryText, List hits, List extraCautions) { + return responseFromHits(category, queryText, hits, extraCautions, RETRIEVAL_DISPLAY_K); + } + + private SearchResponse responseFromHits(String category, String queryText, List hits, + List extraCautions, int displayLimit) { List evidenceHits = evidenceHits(queryText, hits); KnowledgeHit first = evidenceHits.get(0); String responseCategory = isBlank(category) ? first.category() : category; - List snippets = evidenceHits.stream() + List displayHits = displayHits(evidenceHits, displayLimit); + List snippets = displayHits.stream() .map(hit -> new SnippetResponse("第 " + hit.idx() + " 段:" + hit.title(), displaySnippetText(hit.content()), hit.fragmentId(), hit.score(), hit.retrievalChannel())) .toList(); Map docs = new LinkedHashMap<>(); - for (KnowledgeHit hit : evidenceHits) { + for (KnowledgeHit hit : displayHits) { docs.putIfAbsent(hit.title(), new DocResponse(hit.title(), hit.description(), "命中 " + scoreText(hit.score()), "pill-success")); } SearchDigest digest = searchDigest(queryText, evidenceHits); @@ -3914,6 +4304,11 @@ public class AihrSopSeedService { return hits.stream().limit(MAX_EVIDENCE_SNIPPETS).toList(); } + static List displayHits(List evidenceHits, Integer limit) { + if (evidenceHits == null || evidenceHits.isEmpty()) return List.of(); + return evidenceHits.stream().limit(displayLimitValue(limit)).toList(); + } + private static boolean queryMentionsTitle(String queryText, String title) { String query = evidenceText(queryText); String docTitle = evidenceText(title); @@ -4201,26 +4596,7 @@ public class AihrSopSeedService { } private static List searchTerms(String queryText) { - String cleaned = Optional.ofNullable(queryText).orElse("") - .toLowerCase() - .replaceAll("[^\\p{IsHan}\\p{Alnum}]+", " ") - .trim(); - if (cleaned.isEmpty()) { - return List.of(); - } - LinkedHashSet terms = new LinkedHashSet<>(); - for (String token : cleaned.split("\\s+")) { - if (token.length() >= 2 && token.length() <= 16) { - terms.add(token); - } - for (int i = 0; i + 2 <= token.length() && terms.size() < 12; i++) { - terms.add(token.substring(i, i + 2)); - } - if (terms.size() >= 12) { - break; - } - } - return terms.stream().limit(12).toList(); + return AihrRagQueryPlanner.sparseTerms(queryText); } private static String likePattern(String term) { @@ -5474,6 +5850,63 @@ public class AihrSopSeedService { static record FeedbackFilterResult(List hits, List cautions) { } + public record SearchExecution(SearchResponse response, QueryPlan queryPlan, + List candidates) { + public SearchExecution { + candidates = candidates == null ? List.of() : List.copyOf(candidates); + } + } + + private record QueryVariant(String code, String text) {} + + private static final class CandidateAccumulator { + private final KnowledgeHit hit; + private final List contributions = new ArrayList<>(); + private double sparseSignal; + private double vectorSignal; + private double sparseFusion; + private double vectorFusion; + private double fusionScore; + private String sourceAuthority = "UNKNOWN"; + private String sourceKind = "REFERENCE_MATERIAL"; + private Integer candidateRank; + private Double rerankScore; + private String rerankModel; + private boolean rerankApplied; + private boolean selectedForEvidence; + private String decisionReason; + + private CandidateAccumulator(KnowledgeHit hit) { + this.hit = hit; + } + + private void add(String channel, int channelRank, double rawScore, String queryVariant, boolean sparse) { + double signal = reciprocalRank(channelRank); + contributions.add(new ChannelContribution(channel, channelRank, rawScore, signal, queryVariant)); + if (sparse) sparseSignal = Math.max(sparseSignal, signal); + else vectorSignal = Math.max(vectorSignal, signal); + } + + private void finishFusion() { + fusionScore = sparseFusion + vectorFusion; + } + + private double fusionScore() { + return fusionScore; + } + + private void reject(String reason) { + selectedForEvidence = false; + if (decisionReason == null || "OUTSIDE_EVIDENCE_K".equals(decisionReason)) decisionReason = reason; + } + + private RetrievalCandidate toTrace() { + return new RetrievalCandidate(hit.fragmentId(), hit.title(), sourceAuthority, sourceKind, + candidateRank, fusionScore, rerankScore, rerankModel, rerankApplied, selectedForEvidence, + firstNonBlank(decisionReason, "CANDIDATE"), List.copyOf(contributions)); + } + } + private record KnowledgeConfig(long knowledgeId, int blockSize, int overlap) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrRagQueryPlannerTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrRagQueryPlannerTest.java new file mode 100644 index 00000000..990317d3 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrRagQueryPlannerTest.java @@ -0,0 +1,61 @@ +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.assertj.core.api.Assertions.assertThat; + +@Tag("dev") +class AihrRagQueryPlannerTest { + + @Test + void removesNumericNgramNoiseButPreservesTypedAmountAndBusinessIntent() { + var query = "我有个报销500块钱,告诉我怎么走流程。"; + + var plan = AihrRagQueryPlanner.plan(query); + var terms = AihrRagQueryPlanner.sparseTerms(query); + + assertThat(plan.originalQuery()).isEqualTo(query); + assertThat(plan.domain()).isEqualTo(AihrRagQueryPlanner.FINANCE_POLICY); + assertThat(plan.entities().amount()).isEqualTo(500D); + assertThat(plan.entities().currency()).isEqualTo("CNY"); + assertThat(terms).contains("500元", "报销", "流程"); + assertThat(terms).doesNotContain("50", "00", "销5", "0块", "我有", "告诉"); + } + + @Test + void plansAmbiguousPurchaseThresholdAsFormalFinanceClarification() { + List queries = List.of( + "500元以下的零星采购怎么报销?", + "五百块的小额自采怎么走账?" + ); + + for (String query : queries) { + var plan = AihrRagQueryPlanner.plan(query); + assertThat(plan.domain()).isEqualTo(AihrRagQueryPlanner.FINANCE_REIMBURSEMENT); + assertThat(plan.entities().amount()).isEqualTo(500D); + assertThat(plan.entities().amountBasis()).isEqualTo("UNKNOWN"); + assertThat(plan.needsClarification()).isTrue(); + assertThat(plan.requiredAuthorities()) + .containsExactly("FORMAL_POLICY", "OPERATING_MANUAL", "APPROVED_SOP"); + assertThat(plan.evidenceNeeds()) + .contains("AMOUNT_THRESHOLD", "AMOUNT_BASIS", "ENTRY_STEPS", "REQUIRED_MATERIALS", + "APPROVAL_REVIEW", "EXCEPTIONS"); + } + } + + @Test + void preservesExplicitAmountBasisAndRejectsExperienceAsFormalAuthority() { + var plan = AihrRagQueryPlanner.plan("月累计500元以内的零星采购怎么报销?"); + + assertThat(plan.entities().amountBasis()).isEqualTo("MONTHLY_TOTAL"); + assertThat(plan.needsClarification()).isFalse(); + assertThat(AihrRagQueryPlanner.sourceKind("EMPLOYEE_SUBMISSION", "INTERVIEW", "员工访谈")) + .isEqualTo("EXPERIENCE"); + assertThat(AihrRagQueryPlanner.authorityAllowed(plan, "EXPERIENCE")).isFalse(); + assertThat(AihrRagQueryPlanner.sourceKind("COMPANY_POLICY", "PDF", "费用报销操作手册.pdf")) + .isEqualTo("OPERATING_MANUAL"); + } +} 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 384880a3..173f248f 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 @@ -152,6 +152,20 @@ public class AihrSopSeedServiceTest { assertEquals(3L, evidence.get(2).fragmentId()); } + @Test + @Tag("dev") + public void displayLimitDoesNotReduceEvidencePool() { + List evidence = AihrSopSeedService.evidenceHits( + "费用报销的发票怎么登记?", + List.of(hit(1L, "费用报销操作手册"), hit(2L, "费用报销操作手册"), hit(3L, "费用报销操作手册"))); + + List displayed = AihrSopSeedService.displayHits(evidence, 1); + + assertEquals(3, evidence.size()); + assertEquals(1, displayed.size()); + assertEquals(1L, displayed.get(0).fragmentId()); + } + @Test @Tag("dev") public void allDownVotedHitsAreSuppressed() { @@ -667,12 +681,14 @@ 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}$'")); + assertTrue(code.contains("governed_dataset.dataset_code = 'production'")); + assertTrue(code.contains("governed_dataset.status = 'ACTIVE'")); + assertTrue(code.contains("governed_asset.lifecycle_status = 'PUBLISHED'")); + assertTrue(code.contains("governed_asset.trust_level = 'HUMAN_VERIFIED'")); + assertTrue(code.contains("upper(governed_asset.source_authority) in")); + assertTrue(code.contains("'COMPANY_POLICY', 'FORMAL_POLICY', 'OPERATING_MANUAL', 'APPROVED_SOP'")); + assertTrue(code.contains("governed_asset.effective_from is null")); + assertTrue(code.contains("governed_asset.effective_to is null")); AihrSopSeedService service = new AihrSopSeedService( new ObjectMapper(), mock(JdbcTemplate.class), null, "", null, null, mock(AihrModelSeedService.class)); @@ -680,6 +696,27 @@ public class AihrSopSeedServiceTest { () -> service.searchAuthorized(new AihrSopDto.SearchRequest("问题", "sop", null, null, 5), "", Set.of())); } + @Test + @Tag("dev") + public void formalCandidateRecallIncludesPublishedGovernedFragmentsWithoutChangingDefaultRollout() throws Exception { + Method candidateScope = AihrSopSeedService.class.getDeclaredMethod( + "candidateLifecycleScopeSql", String.class, boolean.class); + Method servingScope = AihrSopSeedService.class.getDeclaredMethod("servingLifecycleScopeSql", String.class); + Method publishedScope = AihrSopSeedService.class.getDeclaredMethod("publishedLifecycleExistsSql", String.class); + candidateScope.setAccessible(true); + servingScope.setAccessible(true); + publishedScope.setAccessible(true); + + String serving = (String) servingScope.invoke(null, "f"); + String published = (String) publishedScope.invoke(null, "f"); + String defaultCandidates = (String) candidateScope.invoke(null, "f", false); + String formalCandidates = (String) candidateScope.invoke(null, "f", true); + + assertEquals(serving, defaultCandidates); + assertTrue(formalCandidates.contains(serving.substring("and ".length()))); + assertTrue(formalCandidates.contains(published)); + } + @Test @Tag("dev") public void legacySearchControllerDelegatesToAuthorizedQueryService() throws Exception { diff --git a/backend/script/sql/update/aihr_20260815_rag_candidate_trace_mysql8.sql b/backend/script/sql/update/aihr_20260815_rag_candidate_trace_mysql8.sql new file mode 100644 index 00000000..4455376f --- /dev/null +++ b/backend/script/sql/update/aihr_20260815_rag_candidate_trace_mysql8.sql @@ -0,0 +1,29 @@ +-- Generation-safe RAG candidate observability. Additive and idempotent on MySQL 8. + +CREATE TABLE IF NOT EXISTS `aihr_query_candidate_trace` ( + `id` bigint NOT NULL AUTO_INCREMENT, + `tenant_id` varchar(20) NOT NULL, + `request_id` varchar(64) NOT NULL, + `fragment_id` bigint NOT NULL, + `candidate_rank` int DEFAULT NULL COMMENT 'Rank after rerank, null when rejected before rerank', + `channel` varchar(20) NOT NULL COMMENT 'KEYWORD/FULLTEXT/VECTOR', + `channel_rank` int NOT NULL, + `raw_score` decimal(18,10) DEFAULT NULL, + `fusion_score` decimal(18,10) DEFAULT NULL, + `rerank_score` decimal(18,10) DEFAULT NULL, + `rerank_model` varchar(100) DEFAULT NULL, + `rerank_applied` tinyint(1) NOT NULL DEFAULT 0, + `source_authority` varchar(30) DEFAULT NULL, + `source_kind` varchar(30) DEFAULT NULL, + `query_variant` varchar(30) NOT NULL COMMENT 'ORIGINAL/NORMALIZED/GLOSSARY/ALTERNATE_n', + `selected_for_evidence` tinyint(1) NOT NULL DEFAULT 0, + `decision_reason` varchar(64) NOT NULL, + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_query_candidate_channel` + (`tenant_id`, `request_id`, `fragment_id`, `channel`, `query_variant`), + KEY `idx_aihr_query_candidate_request` (`tenant_id`, `request_id`, `candidate_rank`), + KEY `idx_aihr_query_candidate_reason` (`tenant_id`, `decision_reason`, `create_time`), + KEY `idx_aihr_query_candidate_fragment` (`tenant_id`, `fragment_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci + COMMENT='Pre-generation retrieval candidates, channel contributions, rerank and gate decisions'; diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index bbbb8504..31545956 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -166,6 +166,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_20260812_pipeline_run_sampling_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260813_golden_calibration_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260814_knowledge_privacy_derivative_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260815_rag_candidate_trace_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql diff --git a/mobile-uni/src/pages/user/assistant/index.vue b/mobile-uni/src/pages/user/assistant/index.vue index 86eaab99..c780df67 100644 --- a/mobile-uni/src/pages/user/assistant/index.vue +++ b/mobile-uni/src/pages/user/assistant/index.vue @@ -288,7 +288,7 @@ const addOutlineSection = () => outline.value?.sections.push({ title: '', bullet const addOutlineBullet = (sectionIndex: number) => outline.value?.sections[sectionIndex]?.bullets.push(''); const citationLabel = (citation: PersonalCitation) => { - if (citation.domain === 'ENTERPRISE') return '企业 SOP'; + if (citation.domain === 'ENTERPRISE') return '企业知识'; return citation.sourceType === 'URL' ? '外部网页' : '我的资料'; }; diff --git a/mobile-uni/src/pages/user/assistant/sessions.vue b/mobile-uni/src/pages/user/assistant/sessions.vue index 401adfc6..3220912d 100644 --- a/mobile-uni/src/pages/user/assistant/sessions.vue +++ b/mobile-uni/src/pages/user/assistant/sessions.vue @@ -39,7 +39,7 @@ const confirmDelete = (id: string) => uni.showModal({ title: '删除会话', con const remove = async (id: string) => { loading.value = true; try { await deletePersonalSession(id); detail.value = undefined; await loadList(); } catch (error) { message.value = error instanceof Error ? error.message : '删除会话失败'; } finally { loading.value = false; } }; const scopeText = (scope: string) => scope.includes('ENTERPRISE') ? '我的资料 + 企业知识' : '我的资料'; const citationLabel = (citation: PersonalCitation) => citation.domain === 'ENTERPRISE' - ? '企业 SOP' + ? '企业知识' : citation.sourceType === 'URL' ? '外部网页' : '我的资料'; const openCitation = (citation: PersonalCitation) => { if (citation.itemId) uni.navigateTo({ url: personalItemRoute(citation.itemId) }); diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue index b54274b3..cc72af70 100644 --- a/mobile-uni/src/pages/user/sop/index.vue +++ b/mobile-uni/src/pages/user/sop/index.vue @@ -1326,7 +1326,13 @@ const citationDomainLabel = (snippet: KnowledgeSearchResponse['snippets'][number if (snippet.domain === 'PROJECT_SERVICE') return '项目记录'; if (snippet.domain === 'PERSONAL') return '我的资料'; if (snippet.sourceType === 'DATA_TOOL') return '数据工具'; - return '企业 SOP'; + return ({ + FORMAL_POLICY: '正式制度', + OPERATING_MANUAL: '操作手册', + APPROVED_SOP: '已批准 SOP', + EXPERIENCE: '经验材料', + CASE: '案例' + }[snippet.sourceKind || ''] || '企业知识'); }; const serviceMemoryStatusLabel = (status?: string | null) => ({ diff --git a/mobile-uni/src/services/agent.ts b/mobile-uni/src/services/agent.ts index 05d56bec..b7d6584c 100644 --- a/mobile-uni/src/services/agent.ts +++ b/mobile-uni/src/services/agent.ts @@ -86,6 +86,8 @@ export const normalizeAgentResponse = ( fragmentId: citation.fragmentId, spaceCode: citation.spaceCode, sourceType: citation.sourceType, + sourceAuthority: citation.sourceAuthority, + sourceKind: citation.sourceKind, domain: citation.domain, status: citation.status, occurredAt: citation.occurredAt, diff --git a/mobile-uni/src/services/knowledge.ts b/mobile-uni/src/services/knowledge.ts index 5858dadf..517cb882 100644 --- a/mobile-uni/src/services/knowledge.ts +++ b/mobile-uni/src/services/knowledge.ts @@ -276,6 +276,8 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge fragmentId: item.fragmentId, spaceCode: item.spaceCode, sourceType: item.sourceType, + sourceAuthority: item.sourceAuthority, + sourceKind: item.sourceKind, domain: item.domain, status: item.status, occurredAt: item.occurredAt, diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index 2645a806..0b892a0a 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -115,6 +115,8 @@ export interface KnowledgeSnippet { fragmentId?: number | null; spaceCode?: string | null; sourceType?: 'DOCUMENT' | 'DATA_TOOL' | string; + sourceAuthority?: string | null; + sourceKind?: 'FORMAL_POLICY' | 'OPERATING_MANUAL' | 'APPROVED_SOP' | 'EXPERIENCE' | 'CASE' | string | null; domain?: 'PERSONAL' | 'PROJECT_SERVICE' | 'ENTERPRISE' | string | null; status?: string | null; occurredAt?: string | null; @@ -171,6 +173,8 @@ export interface KnowledgeMediaSelectedFile { export interface KnowledgeCitation { spaceCode?: string | null; sourceType: 'DOCUMENT' | 'DATA_TOOL' | string; + sourceAuthority?: string | null; + sourceKind?: 'FORMAL_POLICY' | 'OPERATING_MANUAL' | 'APPROVED_SOP' | 'EXPERIENCE' | 'CASE' | string | null; docId?: string | null; title: string; snippet: string; diff --git a/scripts/evaluate-knowledge-quality.mjs b/scripts/evaluate-knowledge-quality.mjs index add0cfa5..5ce75291 100644 --- a/scripts/evaluate-knowledge-quality.mjs +++ b/scripts/evaluate-knowledge-quality.mjs @@ -11,6 +11,29 @@ function hitSource(hit) { return typeof hit === 'object' && hit !== null ? String(hit.sourceName || hit.title || '') : ''; } +export function normalizeSourceName(value) { + return String(value || '') + .replace(/^第\s*\d+\s*段[::]\s*/u, '') + .replace(/\.(?:pdf|docx?|pptx?|xlsx?|txt|md)$/iu, '') + .replace(/\s+/gu, '') + .toLowerCase(); +} + +function sourceMatches(actual, expected) { + const left = normalizeSourceName(actual); + const right = normalizeSourceName(expected); + return Boolean(left && right && (left.includes(right) || right.includes(left))); +} + +function requiredSourceMatches(hit, source, authorities, kinds) { + if (!sourceMatches(hitSource(hit), source)) return false; + if (typeof hit !== 'object' || hit === null) return authorities.length === 0 && kinds.length === 0; + const authority = String(hit.sourceAuthority || '').toUpperCase(); + const kind = String(hit.sourceKind || '').toUpperCase(); + return (authorities.length === 0 || authorities.includes(authority)) + && (kinds.length === 0 || kinds.includes(kind)); +} + export function reciprocalRank(retrieved, relevant) { const relevantSet = new Set(relevant.map(String)); const rank = retrieved.findIndex((hit) => relevantSet.has(hitId(hit))); @@ -27,38 +50,68 @@ export function recallAtK(retrieved, relevant) { export function ndcgAtK(retrieved, relevant) { const relevantSet = new Set(relevant.map(String)); if (relevantSet.size === 0) return 0; - const dcg = retrieved.reduce((sum, hit, index) => - sum + (relevantSet.has(hitId(hit)) ? 1 / Math.log2(index + 2) : 0), 0); + const credited = new Set(); + const dcg = retrieved.reduce((sum, hit, index) => { + const id = hitId(hit); + if (!relevantSet.has(id) || credited.has(id)) return sum; + credited.add(id); + return sum + 1 / Math.log2(index + 2); + }, 0); const idealLength = Math.min(relevantSet.size, retrieved.length); const ideal = Array.from({ length: idealLength }, (_, index) => 1 / Math.log2(index + 2)) .reduce((sum, value) => sum + value, 0); return ideal === 0 ? 0 : dcg / ideal; } -export function evaluateCase(retrieved, testCase, k) { +export function evaluateCase(retrieved, testCase, k, actualNoEvidence = retrieved.length === 0) { const ranked = retrieved.slice(0, k); const relevant = testCase.relevantFragmentIds || []; const forbiddenIds = new Set((testCase.forbiddenFragmentIds || []).map(String)); - const forbiddenSources = new Set((testCase.forbiddenSourceNames || []).map((value) => value.toLowerCase())); - const requiredSources = new Set((testCase.requiredSourceNames || []).map((value) => value.toLowerCase())); - const returnedSources = new Set(ranked.map(hitSource).filter(Boolean).map((value) => value.toLowerCase())); - const forbiddenLeak = ranked.some((hit) => forbiddenIds.has(hitId(hit)) - || forbiddenSources.has(hitSource(hit).toLowerCase())); - const missingRequiredSource = requiredSources.size > 0 - && ![...requiredSources].some((source) => returnedSources.has(source)); + const forbiddenSources = testCase.forbiddenSourceNames || []; + const requiredSources = testCase.requiredSourceNames || []; + const requiredAuthorities = (testCase.requiredSourceAuthorities || []).map((value) => String(value).toUpperCase()); + const requiredKinds = (testCase.requiredSourceKinds || []).map((value) => String(value).toUpperCase()); + const visibleEvidence = ranked.filter((hit) => typeof hit !== 'object' || hit === null + || hit.selectedForEvidence === undefined || hit.selectedForEvidence === true); + const forbiddenLeak = visibleEvidence.some((hit) => forbiddenIds.has(hitId(hit)) + || forbiddenSources.some((source) => sourceMatches(hitSource(hit), source))); + const missingRequiredSource = requiredSources.length > 0 + && !requiredSources.some((source) => ranked.some((hit) => + requiredSourceMatches(hit, source, requiredAuthorities, requiredKinds))); + const sourceRanking = requiredSources.length > 0 && relevant.length === 0 + ? ranked.map((hit, index) => requiredSources.some((source) => + requiredSourceMatches(hit, source, requiredAuthorities, requiredKinds)) + ? normalizeSourceName(hitSource(hit)) : `__nonmatching_source_${index}`) : null; + const normalizedRelevantSources = requiredSources.map(normalizeSourceName); + const metricHits = sourceRanking || ranked; + const metricRelevant = sourceRanking ? normalizedRelevantSources : relevant; const expectedNoEvidence = Boolean(testCase.expectedNoEvidence); + const retrievalEvaluated = metricRelevant.length > 0; return { id: testCase.id, caseType: testCase.caseType || (expectedNoEvidence ? 'no-answer' : 'answerable'), - answerable: !expectedNoEvidence, - recallAtK: expectedNoEvidence ? null : recallAtK(ranked, relevant), - reciprocalRank: expectedNoEvidence ? null : reciprocalRank(ranked, relevant), - ndcgAtK: expectedNoEvidence ? null : ndcgAtK(ranked, relevant), + answerable: retrievalEvaluated, + retrievalEvaluated, + expectedNoEvidence, + recallAtK: retrievalEvaluated ? recallAtK(metricHits, metricRelevant) : null, + recallAt5: retrievalEvaluated ? recallAtK(metricHits.slice(0, 5), metricRelevant) : null, + recallAt10: retrievalEvaluated ? recallAtK(metricHits.slice(0, 10), metricRelevant) : null, + recallAt20: retrievalEvaluated ? recallAtK(metricHits.slice(0, 20), metricRelevant) : null, + reciprocalRank: retrievalEvaluated ? reciprocalRank(metricHits, metricRelevant) : null, + ndcgAtK: retrievalEvaluated ? ndcgAtK(metricHits, metricRelevant) : null, forbiddenLeak, wrongSource: forbiddenLeak || missingRequiredSource, returned: ranked.length, - noEvidence: ranked.length === 0, - noAnswerFalsePositive: expectedNoEvidence && ranked.length > 0 + noEvidence: actualNoEvidence, + noAnswerFalsePositive: expectedNoEvidence && !actualNoEvidence, + topCandidates: ranked.slice(0, 5).map((hit, index) => ({ + rank: index + 1, + fragmentId: typeof hit === 'object' && hit !== null ? hit.fragmentId : hit, + sourceName: hitSource(hit), + sourceAuthority: typeof hit === 'object' && hit !== null ? hit.sourceAuthority : null, + sourceKind: typeof hit === 'object' && hit !== null ? hit.sourceKind : null, + selectedForEvidence: typeof hit === 'object' && hit !== null ? hit.selectedForEvidence : undefined + })) }; } @@ -71,8 +124,8 @@ function average(rows, key) { } export function summarize(results) { - const answerable = results.filter((row) => row.answerable); - const noAnswer = results.filter((row) => !row.answerable); + const answerable = results.filter((row) => row.retrievalEvaluated ?? row.answerable); + const noAnswer = results.filter((row) => row.expectedNoEvidence ?? !row.answerable); const byCaseType = Object.fromEntries([...new Set(results.map((row) => row.caseType))].sort().map((caseType) => { const rows = results.filter((row) => row.caseType === caseType); return [caseType, { cases: rows.length, forbiddenLeakageRate: rate(rows, (row) => row.forbiddenLeak) }]; @@ -82,6 +135,9 @@ export function summarize(results) { answerableCases: answerable.length, noAnswerCases: noAnswer.length, recallAtK: average(answerable, 'recallAtK'), + recallAt5: average(answerable, 'recallAt5'), + recallAt10: average(answerable, 'recallAt10'), + recallAt20: average(answerable, 'recallAt20'), mrr: average(answerable, 'reciprocalRank'), ndcgAtK: average(answerable, 'ndcgAtK'), forbiddenLeakageRate: rate(results, (row) => row.forbiddenLeak), @@ -155,8 +211,10 @@ export function validateDataset(dataset) { for (const item of dataset.cases) { if (!item.id || ids.has(item.id) || !item.query) throw new Error(`invalid or duplicate case: ${item.id || ''}`); ids.add(item.id); - if (!item.expectedNoEvidence && (!Array.isArray(item.relevantFragmentIds) || item.relevantFragmentIds.length === 0)) { - throw new Error(`answerable case must define relevantFragmentIds: ${item.id}`); + if (!item.expectedNoEvidence + && (!Array.isArray(item.relevantFragmentIds) || item.relevantFragmentIds.length === 0) + && (!Array.isArray(item.requiredSourceNames) || item.requiredSourceNames.length === 0)) { + throw new Error(`answerable case must define relevantFragmentIds or requiredSourceNames: ${item.id}`); } if (item.expectedNoEvidence && (item.relevantFragmentIds || []).length > 0) { throw new Error(`no-answer case cannot define relevantFragmentIds: ${item.id}`); @@ -168,23 +226,42 @@ async function requestSearch(options, testCase, fetchImpl) { const headers = { 'content-type': 'application/json' }; if (options.token) headers.Authorization = options.token.startsWith('Bearer ') ? options.token : `Bearer ${options.token}`; if (options.clientid) headers.clientid = options.clientid; - const response = await fetchImpl(`${options.baseUrl.replace(/\/$/, '')}/api/knowledge/search`, { + const response = await fetchImpl(`${options.baseUrl.replace(/\/$/, '')}/api/knowledge/query`, { method: 'POST', headers, body: JSON.stringify({ queryText: testCase.query, category: testCase.category || undefined, position: testCase.position || undefined, - source: 'knowledge_search', - limit: options.k + source: testCase.source || 'mobile_uni_agent', + // Response display remains capped at 10; retrievalCandidates is the decoupled pre-generation pool. + limit: Math.min(options.k, 10) }) }); const body = await response.json(); if (!response.ok || body.code !== 200) { - throw new Error(`case ${testCase.id} search failed with HTTP ${response.status} code ${body.code}`); + throw new Error(`case ${testCase.id} search failed with HTTP ${response.status} code ${body.code}: ${body.msg || ''}`); } - const snippets = body.data?.snippets || []; - return snippets.filter((snippet) => snippet.fragmentId !== null && snippet.fragmentId !== undefined) - .map((snippet) => ({ fragmentId: snippet.fragmentId, sourceName: snippet.title || '' })); + const candidates = body.data?.retrievalCandidates; + const fallback = body.data?.citations || body.data?.legacy?.snippets || []; + const rows = Array.isArray(candidates) && candidates.length > 0 + ? [...candidates].sort((left, right) => { + const leftRank = Number.isInteger(left.candidateRank) ? left.candidateRank : Number.MAX_SAFE_INTEGER; + const rightRank = Number.isInteger(right.candidateRank) ? right.candidateRank : Number.MAX_SAFE_INTEGER; + return leftRank - rightRank; + }) + : fallback; + return { + noEvidence: Boolean(body.data?.noEvidence), + retrieved: rows.filter((item) => item.fragmentId !== null && item.fragmentId !== undefined) + .map((item) => ({ + fragmentId: item.fragmentId, + sourceName: item.title || '', + sourceAuthority: item.sourceAuthority || null, + sourceKind: item.sourceKind || null, + candidateRank: item.candidateRank, + selectedForEvidence: item.selectedForEvidence + })) + }; } export async function run(options, dependencies = {}) { @@ -197,8 +274,8 @@ export async function run(options, dependencies = {}) { const fetchImpl = dependencies.fetchImpl || globalThis.fetch; const results = []; for (const testCase of dataset.cases) { - const retrieved = await requestSearch(options, testCase, fetchImpl); - results.push(evaluateCase(retrieved, testCase, options.k)); + const result = await requestSearch(options, testCase, fetchImpl); + results.push(evaluateCase(result.retrieved, testCase, options.k, result.noEvidence)); } const summary = summarize(results); const failures = thresholdFailures(summary, options); diff --git a/scripts/evaluate-knowledge-quality.test.mjs b/scripts/evaluate-knowledge-quality.test.mjs index d60adb27..54a22a68 100644 --- a/scripts/evaluate-knowledge-quality.test.mjs +++ b/scripts/evaluate-knowledge-quality.test.mjs @@ -1,13 +1,15 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - evaluateCase, ndcgAtK, parseArgs, recallAtK, reciprocalRank, summarize, thresholdFailures, validateDataset + evaluateCase, ndcgAtK, normalizeSourceName, parseArgs, recallAtK, reciprocalRank, summarize, + thresholdFailures, validateDataset } from './evaluate-knowledge-quality.mjs'; test('ranking metrics use fragment ids and preserve top-k order', () => { assert.equal(recallAtK(['a', 'b', 'c'], ['b', 'd']), 0.5); assert.equal(reciprocalRank(['a', 'b'], ['b']), 0.5); assert.ok(ndcgAtK(['b', 'a'], ['b', 'c']) > 0.61); + assert.equal(ndcgAtK(['b', 'b', 'b'], ['b']), 1); }); test('forbidden fragments and sources are surfaced as leakage', () => { @@ -25,7 +27,7 @@ test('forbidden fragments and sources are surfaced as leakage', () => { test('no-answer cases measure false positives separately from answerable recall', () => { const answerable = evaluateCase([{ fragmentId: 1, sourceName: 'SOP' }], { id: 'answerable', relevantFragmentIds: [1], requiredSourceNames: ['SOP'] - }, 5); + }, 5, false); const noAnswer = evaluateCase([{ fragmentId: 9, sourceName: 'Unrelated' }], { id: 'no-answer', expectedNoEvidence: true, relevantFragmentIds: [], caseType: 'no-answer' }, 5); @@ -35,6 +37,45 @@ test('no-answer cases measure false positives separately from answerable recall' assert.equal(summary.noAnswerPrecision, 0); }); +test('candidate recall is independent from the final no-evidence gate and display prefixes', () => { + const result = evaluateCase([ + { + fragmentId: 122126, + sourceName: '第 3 段:银城物业费用报销发票管理操作手册.pdf', + sourceAuthority: 'COMPANY_POLICY', + sourceKind: 'OPERATING_MANUAL', + selectedForEvidence: false + } + ], { + id: 'finance', + requiredSourceNames: ['银城物业费用报销发票管理操作手册'], + requiredSourceAuthorities: ['COMPANY_POLICY'], + requiredSourceKinds: ['OPERATING_MANUAL'], + expectedNoEvidence: false + }, 20, true); + assert.equal(result.recallAt5, 1); + assert.equal(result.noEvidence, true); + assert.equal(normalizeSourceName('第 12 段:制度.PDF'), '制度'); +}); + +test('same-name ungoverned copies do not satisfy formal source recall', () => { + const result = evaluateCase([ + { + fragmentId: 102169, + sourceName: '银城物业费用报销发票管理操作手册.pdf', + sourceAuthority: 'UNKNOWN', + sourceKind: 'REFERENCE_MATERIAL' + } + ], { + id: 'finance-authority', + requiredSourceNames: ['银城物业费用报销发票管理操作手册'], + requiredSourceAuthorities: ['COMPANY_POLICY'], + requiredSourceKinds: ['OPERATING_MANUAL'] + }, 20, true); + assert.equal(result.recallAt20, 0); + assert.equal(result.wrongSource, true); +}); + test('threshold failures can fail a release gate', () => { const failures = thresholdFailures({ recallAtK: 0.7, mrr: 0.5, ndcgAtK: 0.6, forbiddenLeakageRate: 0.1, @@ -49,6 +90,7 @@ test('threshold failures can fail a release gate', () => { test('dataset contract distinguishes answerable and no-answer cases', () => { assert.doesNotThrow(() => validateDataset({ schemaVersion: 1, cases: [ { id: 'a', query: 'q', relevantFragmentIds: [1] }, + { id: 'source', query: 'q-source', requiredSourceNames: ['正式手册'] }, { id: 'b', query: 'q2', relevantFragmentIds: [], expectedNoEvidence: true } ] })); assert.throws(() => validateDataset({ schemaVersion: 1, cases: [ diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index a1ed49d6..9f53d23d 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -275,6 +275,7 @@ FROM ( UNION ALL SELECT 'aihr_review_batch' UNION ALL SELECT 'aihr_review_batch_item' UNION ALL SELECT 'aihr_knowledge_generation' + UNION ALL SELECT 'aihr_query_candidate_trace' UNION ALL SELECT 'aihr_generation_version' UNION ALL SELECT 'aihr_version_diff' UNION ALL SELECT 'aihr_version_rollback' @@ -948,7 +949,8 @@ REMOTE [[ "$media_reprocess_contract" = "source_attach_id|tenant_id,source_attach_id,id" ]] \ || fail "remote media-reprocess schema is invalid: got ${media_reprocess_contract:-missing}" - echo "remote_schema=82/82 $remote_ssh:$remote_db" + echo "remote_schema=83/83 $remote_ssh:$remote_db" + echo "remote_rag_candidate_trace=1/1 $remote_ssh:$remote_db" echo "remote_media_reprocess_contract=1/1 $remote_ssh:$remote_db" echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db" echo "remote_learning_task_question_bank=6/6 $remote_ssh:$remote_db" diff --git a/scripts/reset-dev-db.sh b/scripts/reset-dev-db.sh index b24ec8f8..16bbd844 100755 --- a/scripts/reset-dev-db.sh +++ b/scripts/reset-dev-db.sh @@ -36,6 +36,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/update/aihr_20260812_pipeline_run_sampling_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260813_golden_calibration_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260814_knowledge_privacy_derivative_mysql8.sql" +docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260815_rag_candidate_trace_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260815_knowledge_rollout_compat_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260816_knowledge_version_pointers_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260817_knowledge_review_assistance_mysql8.sql" diff --git a/scripts/tests/aihr-schema-migrations.test.sh b/scripts/tests/aihr-schema-migrations.test.sh index 45fb8315..445e0140 100755 --- a/scripts/tests/aihr-schema-migrations.test.sh +++ b/scripts/tests/aihr-schema-migrations.test.sh @@ -655,4 +655,13 @@ test "$agent_run_unique" = '0|run_id' agent_user_index="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(column_name ORDER BY seq_in_index) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_agent_run' AND index_name='idx_aihr_agent_run_user';")" test "$agent_user_index" = 'tenant_id,user_id,create_time' +rag_candidate_trace_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260815_rag_candidate_trace_mysql8.sql" +test -f "$rag_candidate_trace_migration" +mysql "$DB" < "$rag_candidate_trace_migration" +mysql "$DB" < "$rag_candidate_trace_migration" +rag_candidate_trace_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_query_candidate_trace';")" +test "$rag_candidate_trace_columns" = 'id,tenant_id,request_id,fragment_id,candidate_rank,channel,channel_rank,raw_score,fusion_score,rerank_score,rerank_model,rerank_applied,source_authority,source_kind,query_variant,selected_for_evidence,decision_reason,create_time' +rag_candidate_trace_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_query_candidate_trace' AND index_name='uk_aihr_query_candidate_channel';")" +test "$rag_candidate_trace_unique" = '0|tenant_id,request_id,fragment_id,channel,query_variant' + echo "PASS: AIHR legacy schema migrations are idempotent, including Agent run audit, practice growth curriculum snapshots, knowledge-space categories, practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, assistant capture, work-report idempotency, direct feedback, broadcast publish/withdraw audit with targeting, delivery controls, topic evidence and file access audit, v1 snapshots, and trusted broadcast question context" diff --git a/scripts/verify-finance-rag-local.mjs b/scripts/verify-finance-rag-local.mjs new file mode 100644 index 00000000..8e718d50 --- /dev/null +++ b/scripts/verify-finance-rag-local.mjs @@ -0,0 +1,77 @@ +#!/usr/bin/env node + +import { run } from './evaluate-knowledge-quality.mjs'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const baseUrl = process.env.AIHR_BASE_URL || 'https://wygj-api.localhost'; +const clientid = '428a8310cd442757ae699df5d894f051'; +const dataset = new URL('../tests/fixtures/data_quality/retrieval-finance-regression.json', import.meta.url); + +function localUniqueActivePhone() { + const sql = ` + select min(s.person_phone) + from aihr_org_snapshot s + where s.tenant_id = '000000' + and lower(coalesce(s.employment_status, 'active')) in ('active', '在职') + and s.person_phone regexp '^[0-9]{11}$' + and (select count(distinct p.ext_party_id) from aihr_org_snapshot p + where p.tenant_id = s.tenant_id and p.person_phone = s.person_phone + and lower(coalesce(p.employment_status, 'active')) in ('active', '在职')) = 1 + and (select count(distinct e.person_phone) from aihr_org_snapshot e + where e.tenant_id = s.tenant_id and e.ext_party_id = s.ext_party_id + and lower(coalesce(e.employment_status, 'active')) in ('active', '在职')) = 1`; + return execFileSync('docker', [ + 'exec', 'wygj-mysql', 'mysql', '-uroot', '-proot', '--default-character-set=utf8mb4', + 'ry-vue', '-Nse', sql + ], { encoding: 'utf8' }).trim(); +} + +const phone = process.env.AIHR_VERIFY_PHONE || localUniqueActivePhone(); +if (!phone) throw new Error('no bidirectionally unique active local employee is available'); + +execFileSync('docker', [ + 'exec', 'wygj-redis', 'redis-cli', '-a', 'ruoyi123', 'del', + `{global:rate_limit:/resource/sms/code:${phone}}:value`, + `global:rate_limit:/resource/sms/code:${phone}`, + `{global:rate_limit:/resource/sms/code:${phone}}:permits` +], { stdio: 'ignore' }); + +if (new URL(baseUrl).hostname.endsWith('.localhost')) { + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; +} + +async function api(endpoint, init = {}) { + const response = await fetch(`${baseUrl.replace(/\/$/, '')}${endpoint}`, { + ...init, + headers: { clientid, ...(init.headers || {}) }, + signal: AbortSignal.timeout(30_000) + }); + const payload = await response.json(); + if (!response.ok || Number(payload.code) !== 200) { + throw new Error(`${endpoint} failed: HTTP ${response.status}, code ${payload.code}, ${payload.msg || ''}`); + } + return payload.data; +} + +await api(`/resource/sms/code?phonenumber=${encodeURIComponent(phone)}`); +const login = await api('/auth/mobile/sms-login', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + phonenumber: phone, + smsCode: process.env.AIHR_VERIFY_SMS_CODE || '123456', + tenantId: '000000' + }) +}); +if (!login?.access_token) throw new Error('mobile login returned no access token'); + +const result = await run({ + dataset: fileURLToPath(dataset), + baseUrl, + token: login.access_token, + clientid, + k: 20 +}); +console.log(JSON.stringify(result, null, 2)); +if (result.thresholdPassed === false) process.exitCode = 2; diff --git a/tests/fixtures/data_quality/retrieval-finance-regression.json b/tests/fixtures/data_quality/retrieval-finance-regression.json new file mode 100644 index 00000000..ef6978ff --- /dev/null +++ b/tests/fixtures/data_quality/retrieval-finance-regression.json @@ -0,0 +1,64 @@ +{ + "schemaVersion": 1, + "datasetCode": "finance-reimbursement-rag-regression-v1", + "cases": [ + { + "id": "finance-500-colloquial", + "query": "我有个报销500块钱,告诉我怎么走流程。", + "caseType": "formal-finance-ambiguous-basis", + "requiredSourceNames": ["银城物业费用报销发票管理操作手册"], + "requiredSourceAuthorities": ["COMPANY_POLICY"], + "requiredSourceKinds": ["OPERATING_MANUAL"], + "forbiddenSourceNames": ["200块私了", "500块维修"], + "expectedNoEvidence": true + }, + { + "id": "finance-500-small-purchase", + "query": "500元以下的零星采购怎么报销?", + "caseType": "formal-finance-ambiguous-basis", + "requiredSourceNames": ["银城物业费用报销发票管理操作手册"], + "requiredSourceAuthorities": ["COMPANY_POLICY"], + "requiredSourceKinds": ["OPERATING_MANUAL"], + "forbiddenSourceNames": ["200块私了", "500块维修"], + "expectedNoEvidence": true + }, + { + "id": "finance-500-chinese-amount", + "query": "五百块的小额自采怎么走账?", + "caseType": "formal-finance-ambiguous-basis", + "requiredSourceNames": ["银城物业费用报销发票管理操作手册"], + "requiredSourceAuthorities": ["COMPANY_POLICY"], + "requiredSourceKinds": ["OPERATING_MANUAL"], + "forbiddenSourceNames": ["200块私了", "500块维修"], + "expectedNoEvidence": true + }, + { + "id": "finance-private-settlement-no-answer", + "query": "200块维修费用能不能直接私了后报销?", + "caseType": "no-answer", + "expectedNoEvidence": true, + "forbiddenSourceNames": ["200块私了", "500块维修"] + }, + { + "id": "finance-over-limit-unsupported", + "query": "零星采购超过500元怎么报销?", + "caseType": "unsupported-threshold", + "expectedNoEvidence": true, + "forbiddenSourceNames": ["200块私了", "500块维修"] + }, + { + "id": "finance-monthly-basis-conflict", + "query": "月累计500元以内的自采都能报销吗?", + "caseType": "amount-basis-conflict", + "expectedNoEvidence": true, + "forbiddenSourceNames": ["员工访谈", "200块私了", "500块维修"] + }, + { + "id": "finance-per-transaction-basis-conflict", + "query": "单笔500元以内的自采都能报销吗?", + "caseType": "amount-basis-conflict", + "expectedNoEvidence": true, + "forbiddenSourceNames": ["员工访谈", "200块私了", "500块维修"] + } + ] +}