The client never selects this mode. It is only enabled for the authenticated mobile
+ * agent entry point, and the retrieval layer then accepts approved formal sources only.
+ */
+public final class AihrFormalPolicyClassifier {
+
+ public static final String MOBILE_AGENT_SOURCE = "mobile_uni_agent";
+ public static final String FORMAL_POLICY_SOURCE = "mobile_uni_agent_formal_policy";
+
+ private static final List FORMAL_TERMS = List.of(
+ "制度", "规定", "要求", "依据", "条款", "适用范围", "版本", "生效",
+ "权限", "授权", "审批", "申请条件", "责任主体", "岗位职责", "预案",
+ "频次", "路线", "留痕", "记录哪些", "计费", "费用构成", "查询入口",
+ "公示", "合规", "必须"
+ );
+
+ private static final List CONTROLLED_DOMAINS = List.of(
+ "日常巡检", "强降雨", "防汛", "大件垃圾", "装修垃圾", "装修产生", "报修受理",
+ "物业费账单", "分期缴费", "空置房", "长期无人居住", "维修一直没进展",
+ "车辆被占位", "车辆堵住", "停车冲突", "安全险情", "突发事件"
+ );
+
+ private AihrFormalPolicyClassifier() {
+ }
+
+ public static boolean requiresFormalSource(String source, String question) {
+ String normalizedSource = source == null ? "" : source.trim().toLowerCase(Locale.ROOT);
+ if (FORMAL_POLICY_SOURCE.equals(normalizedSource)) {
+ return true;
+ }
+ if (!normalizedSource.startsWith(MOBILE_AGENT_SOURCE)) {
+ return false;
+ }
+ String value = question == null ? "" : question.trim();
+ if (value.isEmpty()) {
+ return false;
+ }
+ return containsAny(value, FORMAL_TERMS) || containsAny(value, CONTROLLED_DOMAINS);
+ }
+
+ private static boolean containsAny(String value, List terms) {
+ return terms.stream().anyMatch(value::contains);
+ }
+}
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java
index 56ab9665..9675c1df 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java
@@ -143,7 +143,9 @@ public class AihrKnowledgeQueryService {
} else {
boolean candidateIntent = "QA".equals(context.intent())
&& memoryService.isProjectMemoryCandidateIntent(principal, request.queryText());
- memoryRecall = "QA".equals(context.intent()) && !candidateIntent
+ boolean formalPolicyRequest = AihrFormalPolicyClassifier.requiresFormalSource(
+ routed.source(), routed.queryText());
+ memoryRecall = "QA".equals(context.intent()) && !candidateIntent && !formalPolicyRequest
? memoryService.recall(principal, routed.queryText(), routed.limit())
: ServiceMemoryRecall.empty();
if (memoryRecall == null) {
@@ -175,7 +177,8 @@ public class AihrKnowledgeQueryService {
}
}
MemoryCandidateResponse candidate = context.broadcastMessageId() != null
- || !"QA".equals(context.intent()) || hasText(routed.toolCode()) ? null
+ || !"QA".equals(context.intent()) || hasText(routed.toolCode())
+ || AihrFormalPolicyClassifier.requiresFormalSource(routed.source(), routed.queryText()) ? null
: memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), response.requestId(),
new SourceSnapshot(sourceType(request.source()), truncate(request.queryText(), 4000),
null, null, null, null, null));
@@ -299,17 +302,26 @@ public class AihrKnowledgeQueryService {
String requestId = UUID.randomUUID().toString().replace("-", "");
long started = System.nanoTime();
List scopeCodes = spaceCodes(app.tenantId(), spaceIds);
+ boolean formalPolicyOnly = AihrFormalPolicyClassifier.requiresFormalSource(
+ request.source(), request.queryText());
+ String retrievalSource = formalPolicyOnly
+ ? AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE
+ : request.source();
try {
SearchResponse legacy = sopService.searchAuthorized(new SearchRequest(
- request.queryText(), request.category(), request.position(), request.source(), request.limit()),
+ request.queryText(), request.category(), request.position(), retrievalSource, request.limit()),
principal == null ? "" : principal.extPartyId(), spaceIds);
- List citations = citations(app.tenantId(), spaceIds, legacy.snippets());
+ List citations = citations(app.tenantId(), spaceIds, legacy.snippets(), formalPolicyOnly);
boolean noEvidence = citations.isEmpty();
+ String answer = formalPolicyOnly && noEvidence
+ ? "当前已批准且在有效期内的正式资料中没有找到足够依据,不能使用访谈、经验材料或草稿补全政策答案。"
+ + "请补充经审核的正式来源后再查询。"
+ : legacy.answer();
String status = noEvidence ? "NO_EVIDENCE" : "SUCCESS";
long latency = elapsedMillis(started);
auditService.record(requestId, principal, app, displayQuery, scopeCodes,
citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, latency, legacy.promptVersion());
- return new QueryResponse(requestId, displayQuery, legacy.answer(), citations, scopeCodes,
+ return new QueryResponse(requestId, displayQuery, answer, citations, scopeCodes,
noEvidence, legacy.promptVersion(), legacy, null);
} catch (RuntimeException ex) {
auditService.record(requestId, principal, app, displayQuery, scopeCodes, List.of(),
@@ -577,7 +589,8 @@ public class AihrKnowledgeQueryService {
);
}
- private List citations(String tenantId, Set allowedSpaceIds, List snippets) {
+ private List citations(String tenantId, Set allowedSpaceIds, List snippets,
+ boolean formalPolicyOnly) {
if (snippets == null || snippets.isEmpty()) {
return List.of();
}
@@ -590,6 +603,26 @@ public class AihrKnowledgeQueryService {
args.add(tenantId);
args.addAll(allowedSpaceIds);
args.addAll(fragmentIds);
+ String formalSourceClause = formalPolicyOnly ? """
+ and exists (
+ select 1
+ from aihr_knowledge_attach governed_attach
+ join aihr_knowledge_source_governance governance
+ on governance.tenant_id = governed_attach.tenant_id
+ and governance.attachment_id = governed_attach.id
+ where governed_attach.tenant_id = f.tenant_id
+ and governed_attach.knowledge_id = f.knowledge_id
+ and governed_attach.doc_id = f.doc_id
+ and governed_attach.status = 2
+ and governance.source_doc_id = f.doc_id
+ and governance.authority_type = 'FORMAL_POLICY'
+ and governance.lifecycle_status = 'APPROVED'
+ and governance.effective_date <= current_date()
+ and (governance.expires_date is null or governance.expires_date >= current_date())
+ and char_length(trim(governance.source_version)) > 0
+ and governance.content_sha256 regexp '^[0-9a-f]{64}$'
+ )
+ """ : "";
List rows = jdbcTemplate.query("""
select k.code as space_code, f.doc_id, coalesce(a.name, k.name) as title,
f.content, f.id as fragment_id
@@ -600,7 +633,9 @@ public class AihrKnowledgeQueryService {
where f.tenant_id = ?
and f.knowledge_id in (%s)
and f.id in (%s)
- """.formatted(placeholders(allowedSpaceIds.size()), placeholders(fragmentIds.size())),
+ %s
+ """.formatted(placeholders(allowedSpaceIds.size()), placeholders(fragmentIds.size()),
+ formalSourceClause),
(rs, rowNum) -> new Citation(
rs.getString("space_code"), "DOCUMENT", rs.getString("doc_id"), rs.getString("title"),
truncate(rs.getString("content"), 500), rs.getLong("fragment_id")
diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java
index 85fe518c..82d75ab4 100644
--- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java
+++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java
@@ -40,6 +40,7 @@ import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeSpaceDto.UnbindDocumentResponse;
import org.dromara.aihr.knowledge.parse.KnowledgeDocumentParser;
import org.dromara.aihr.knowledge.parse.TikaKnowledgeDocumentParser;
+import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
import org.dromara.aihr.personal.service.EnterpriseKnowledgeAccessPolicy.EnterpriseKnowledgeGrant;
import org.dromara.aihr.personal.support.PersonalOwner;
import org.dromara.common.core.exception.ServiceException;
@@ -214,7 +215,9 @@ public class AihrSopSeedService {
if (isBlank(queryText)) {
return withReviewId(noEvidenceResponse("", "sop"), source, requester);
}
- SearchResponse response = dbSearch("", queryText, request == null ? null : request.limit(), allowedKnowledgeIds);
+ boolean formalPolicyOnly = AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE.equals(source);
+ SearchResponse response = dbSearch(
+ "", queryText, request == null ? null : request.limit(), allowedKnowledgeIds, formalPolicyOnly);
if (response == null || isNoEvidenceAnswer(response.answer())) {
recordKnowledgeGap(queryText, "sop", position, source);
response = noEvidenceResponse(queryText, "sop");
@@ -2511,8 +2514,14 @@ public class AihrSopSeedService {
}
private SearchResponse dbSearch(String category, String queryText, Integer limit, Set allowedKnowledgeIds) {
+ return dbSearch(category, queryText, limit, allowedKnowledgeIds, false);
+ }
+
+ private SearchResponse dbSearch(String category, String queryText, Integer limit,
+ Set allowedKnowledgeIds, boolean formalPolicyOnly) {
try {
- List hits = dbHits(category, queryText, limit, allowedKnowledgeIds);
+ List hits = dbHits(
+ category, queryText, limit, allowedKnowledgeIds, formalPolicyOnly);
if (hits.isEmpty()) {
return null;
}
@@ -2897,10 +2906,18 @@ public class AihrSopSeedService {
}
private List dbHits(String category, String queryText, Integer limit, Set allowedKnowledgeIds) {
+ return dbHits(category, queryText, limit, allowedKnowledgeIds, false);
+ }
+
+ private List dbHits(String category, String queryText, Integer limit,
+ Set allowedKnowledgeIds, boolean formalPolicyOnly) {
int limitValue = limitValue(limit);
- List keywordHits = likeHits(category, queryText, limitValue, allowedKnowledgeIds);
- List textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue, allowedKnowledgeIds), limitValue);
- return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue, allowedKnowledgeIds), limitValue));
+ List keywordHits = likeHits(
+ category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly);
+ List textHits = rrf(keywordHits, fulltextHits(
+ category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly), limitValue);
+ return rerankHits(queryText, rrf(textHits, vectorHits(
+ category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly), limitValue));
}
private static String knowledgeScopeSql(String alias, Set allowedKnowledgeIds) {
@@ -2920,6 +2937,32 @@ public class AihrSopSeedService {
}
}
+ private static String formalSourceScopeSql(String fragmentAlias, boolean formalPolicyOnly) {
+ if (!formalPolicyOnly) {
+ return "";
+ }
+ return """
+ and exists (
+ select 1
+ from aihr_knowledge_attach governed_attach
+ join aihr_knowledge_source_governance governance
+ on governance.tenant_id = governed_attach.tenant_id
+ and governance.attachment_id = governed_attach.id
+ where governed_attach.tenant_id = %1$s.tenant_id
+ and governed_attach.knowledge_id = %1$s.knowledge_id
+ and governed_attach.doc_id = %1$s.doc_id
+ and governed_attach.status = 2
+ and governance.source_doc_id = %1$s.doc_id
+ and governance.authority_type = 'FORMAL_POLICY'
+ and governance.lifecycle_status = 'APPROVED'
+ and governance.effective_date <= current_date()
+ and (governance.expires_date is null or governance.expires_date >= current_date())
+ and char_length(trim(governance.source_version)) > 0
+ and governance.content_sha256 regexp '^[0-9a-f]{64}$'
+ )
+ """.formatted(fragmentAlias);
+ }
+
private FeedbackFilterResult filterDownFeedback(String queryText, List hits) {
return filterDownFeedback(hits, downFeedbackFragmentIds(queryText));
}
@@ -2963,6 +3006,11 @@ public class AihrSopSeedService {
}
private List vectorHits(String category, String queryText, int limit, Set allowedKnowledgeIds) {
+ return vectorHits(category, queryText, limit, allowedKnowledgeIds, false);
+ }
+
+ private List vectorHits(String category, String queryText, int limit,
+ Set allowedKnowledgeIds, boolean formalPolicyOnly) {
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
try {
List embeddings = callEmbeddings(runtime, List.of(queryText));
@@ -2972,7 +3020,7 @@ public class AihrSopSeedService {
List matches = queryQdrant(category, embeddings.get(0), limit, allowedKnowledgeIds);
List hits = new ArrayList<>();
for (VectorMatch match : matches) {
- hydrateQdrantHit(match, allowedKnowledgeIds).ifPresent(hits::add);
+ hydrateQdrantHit(match, allowedKnowledgeIds, formalPolicyOnly).ifPresent(hits::add);
}
if (!hits.isEmpty()) {
return hits;
@@ -2989,6 +3037,11 @@ public class AihrSopSeedService {
}
private List fulltextHits(String category, String queryText, int limit, Set allowedKnowledgeIds) {
+ return fulltextHits(category, queryText, limit, allowedKnowledgeIds, false);
+ }
+
+ private List fulltextHits(String category, String queryText, int limit,
+ Set allowedKnowledgeIds, boolean formalPolicyOnly) {
String sql = """
select f.id as fragment_id,
coalesce(a.name, i.name) as title,
@@ -3002,12 +3055,14 @@ public class AihrSopSeedService {
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id
where f.tenant_id = ?
+ %s
%s
and (? = '' or i.name = ?)
and match(f.content) against (? in natural language mode)
order by score desc, f.idx asc
limit ?
- """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds));
+ """.formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
+ formalSourceScopeSql("f", formalPolicyOnly));
List