fix(knowledge): gate policy answers by formal sources

This commit is contained in:
key
2026-07-31 00:28:41 +08:00
parent a3689591e7
commit bf703b7853
16 changed files with 470 additions and 20 deletions
@@ -15,6 +15,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService.MediaMode;
import org.dromara.aihr.webai.AihrWebAiDto;
import org.dromara.aihr.webai.AihrWebAiService;
@@ -133,8 +134,9 @@ public class AihrAgentOrchestrator {
MediaMode mode = needsKnowledge(request.question())
? MediaMode.MEDIA_WITH_KNOWLEDGE
: MediaMode.MEDIA_ONLY;
String source = agentKnowledgeSource(request.question());
QueryResponse response = queryService.queryInternalMedia(new QueryRequest(
request.question(), List.of(), "sop", null, "mobile_uni_agent", 5, null,
request.question(), List.of(), "sop", null, source, 5, null,
request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId()
), file, mode);
return fromResponse(plan, response, "MEDIA");
@@ -145,8 +147,9 @@ public class AihrAgentOrchestrator {
case MY_CURRENT_TASKS, MY_PRACTICE_SUMMARY, TEAM_PRACTICE_SUMMARY -> plan.tool().name();
default -> null;
};
String source = agentKnowledgeSource(request.question());
QueryResponse response = queryService.queryInternal(new QueryRequest(
plan.rewrittenRequest(), List.of(), "sop", null, "mobile_uni_agent", 5, toolCode,
plan.rewrittenRequest(), List.of(), "sop", null, source, 5, toolCode,
request.conversationId(), request.contextVersion(), request.projectCode(), request.broadcastMessageId()
));
return fromResponse(plan, response, toolCode == null ? "ENTERPRISE_KNOWLEDGE" : "LIVE_DATA");
@@ -216,6 +219,13 @@ public class AihrAgentOrchestrator {
"故障", "漏水", "电梯", "现场").stream().anyMatch(value::contains);
}
private static String agentKnowledgeSource(String question) {
return AihrFormalPolicyClassifier.requiresFormalSource(
AihrFormalPolicyClassifier.MOBILE_AGENT_SOURCE, question)
? AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE
: AihrFormalPolicyClassifier.MOBILE_AGENT_SOURCE;
}
private static boolean isConflictBoundaryRequest(String question) {
String value = question == null ? "" : question.trim();
return value.contains("一份") && value.contains("另一份")
@@ -0,0 +1,52 @@
package org.dromara.aihr.knowledge.service;
import java.util.List;
import java.util.Locale;
/**
* Server-side routing guard for questions that can change an employee's understanding of
* policy, authority, fees, safety duties or required records.
*
* <p>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.</p>
*/
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<String> FORMAL_TERMS = List.of(
"制度", "规定", "要求", "依据", "条款", "适用范围", "版本", "生效",
"权限", "授权", "审批", "申请条件", "责任主体", "岗位职责", "预案",
"频次", "路线", "留痕", "记录哪些", "计费", "费用构成", "查询入口",
"公示", "合规", "必须"
);
private static final List<String> 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<String> terms) {
return terms.stream().anyMatch(value::contains);
}
}
@@ -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<String> 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<Citation> citations = citations(app.tenantId(), spaceIds, legacy.snippets());
List<Citation> 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<Citation> citations(String tenantId, Set<Long> allowedSpaceIds, List<SnippetResponse> snippets) {
private List<Citation> citations(String tenantId, Set<Long> allowedSpaceIds, List<SnippetResponse> 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<Citation> 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")
@@ -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<Long> allowedKnowledgeIds) {
return dbSearch(category, queryText, limit, allowedKnowledgeIds, false);
}
private SearchResponse dbSearch(String category, String queryText, Integer limit,
Set<Long> allowedKnowledgeIds, boolean formalPolicyOnly) {
try {
List<KnowledgeHit> hits = dbHits(category, queryText, limit, allowedKnowledgeIds);
List<KnowledgeHit> hits = dbHits(
category, queryText, limit, allowedKnowledgeIds, formalPolicyOnly);
if (hits.isEmpty()) {
return null;
}
@@ -2897,10 +2906,18 @@ public class AihrSopSeedService {
}
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit, Set<Long> allowedKnowledgeIds) {
return dbHits(category, queryText, limit, allowedKnowledgeIds, false);
}
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit,
Set<Long> allowedKnowledgeIds, boolean formalPolicyOnly) {
int limitValue = limitValue(limit);
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue, allowedKnowledgeIds);
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue, allowedKnowledgeIds), limitValue);
return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue, allowedKnowledgeIds), limitValue));
List<KnowledgeHit> keywordHits = likeHits(
category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly);
List<KnowledgeHit> 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<Long> 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<KnowledgeHit> hits) {
return filterDownFeedback(hits, downFeedbackFragmentIds(queryText));
}
@@ -2963,6 +3006,11 @@ public class AihrSopSeedService {
}
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit, Set<Long> allowedKnowledgeIds) {
return vectorHits(category, queryText, limit, allowedKnowledgeIds, false);
}
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit,
Set<Long> allowedKnowledgeIds, boolean formalPolicyOnly) {
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
try {
List<String> embeddings = callEmbeddings(runtime, List.of(queryText));
@@ -2972,7 +3020,7 @@ public class AihrSopSeedService {
List<VectorMatch> matches = queryQdrant(category, embeddings.get(0), limit, allowedKnowledgeIds);
List<KnowledgeHit> 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<KnowledgeHit> fulltextHits(String category, String queryText, int limit, Set<Long> allowedKnowledgeIds) {
return fulltextHits(category, queryText, limit, allowedKnowledgeIds, false);
}
private List<KnowledgeHit> fulltextHits(String category, String queryText, int limit,
Set<Long> 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<Object> args = new ArrayList<>();
args.add(queryText);
args.add(tenantId());
@@ -3033,6 +3088,11 @@ public class AihrSopSeedService {
}
private List<KnowledgeHit> likeHits(String category, String queryText, int limit, Set<Long> allowedKnowledgeIds) {
return likeHits(category, queryText, limit, allowedKnowledgeIds, false);
}
private List<KnowledgeHit> likeHits(String category, String queryText, int limit,
Set<Long> allowedKnowledgeIds, boolean formalPolicyOnly) {
List<String> terms = searchTerms(queryText);
if (terms.isEmpty()) {
return List.of();
@@ -3056,12 +3116,14 @@ public class AihrSopSeedService {
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id
where f.tenant_id = ?
%s
%s
and (? = '' or i.name = ?)
and (%s)
order by score desc, f.idx asc
limit ?
""".formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds), String.join(" or ", whereParts));
""".formatted(String.join(" + ", scoreParts), knowledgeScopeSql("f", allowedKnowledgeIds),
formalSourceScopeSql("f", formalPolicyOnly), String.join(" or ", whereParts));
List<Object> args = new ArrayList<>();
terms.stream().map(AihrSopSeedService::likePattern).forEach(args::add);
args.add(tenantId());
@@ -3087,6 +3149,11 @@ public class AihrSopSeedService {
}
private Optional<KnowledgeHit> hydrateQdrantHit(VectorMatch match, Set<Long> allowedKnowledgeIds) {
return hydrateQdrantHit(match, allowedKnowledgeIds, false);
}
private Optional<KnowledgeHit> hydrateQdrantHit(VectorMatch match, Set<Long> allowedKnowledgeIds,
boolean formalPolicyOnly) {
String sql = """
select f.id as fragment_id,
coalesce(a.name, i.name) as title,
@@ -3099,11 +3166,13 @@ public class AihrSopSeedService {
join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id
left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id
where f.tenant_id = ?
%s
%s
and f.doc_id = ?
and f.idx = ?
limit 1
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds));
""".formatted(knowledgeScopeSql("f", allowedKnowledgeIds),
formalSourceScopeSql("f", formalPolicyOnly));
List<Object> args = new ArrayList<>();
args.add(tenantId());
addKnowledgeScopeArgs(args, allowedKnowledgeIds);
@@ -9,6 +9,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.CurrentTaskSummary;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
@@ -65,6 +66,16 @@ class AihrAgentOrchestratorTest {
assertTrue(result.sourceSummary().isEmpty());
}
@Test
void originalPolicyQuestionSelectsFormalSourceBeforePlannerRewrite() {
var query = new CapturingQueryService(response("正式制度答案", null, false));
var service = orchestrator(query);
service.handle(request("住户提出分期缴费时,申请条件、审批角色和留痕要求是什么?"));
assertEquals(AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE, query.lastRequest.source());
}
@Test
void explicitRuleConflictStopsBeforeKnowledgeChoosesASide() {
var query = new CapturingQueryService(response("unused", null, false));
@@ -9,6 +9,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAccessService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeAppService;
import org.dromara.aihr.knowledge.service.AihrFormalPolicyClassifier;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryAuditService;
import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService;
@@ -147,6 +148,47 @@ class AihrKnowledgeQueryServiceTest {
assertEquals(List.of("mt_customer_service"), result.usedSpaceCodes());
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void mobilePolicyQuestionUsesFormalRegistryAndFailsClosedWhenCitationRevalidationFindsNone() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var sop = mock(AihrSopSeedService.class);
var jdbc = mock(JdbcTemplate.class);
var memory = mock(AihrMemoryService.class);
var principal = new AihrKnowledgePrincipal(
"000000", 1L, "app_user", "employee-1", Set.of("employee"), Set.of(), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L));
when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class)))
.thenReturn(List.of("sop"));
when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenReturn(List.of());
when(sop.searchAuthorized(any(), any(), any())).thenReturn(
new SearchResponse("分期缴费", "sop", "经验材料给出的分期建议", "", List.of(),
List.of(new SnippetResponse("经验萃取", "不应进入正式答案", 99L)),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
var service = new AihrKnowledgeQueryService(
resolver, appService, access, sop, mock(AihrKnowledgeQueryAuditService.class), jdbc,
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class),
memory);
var result = service.queryInternal(new QueryRequest(
"住户提出分期缴费时,申请条件、审批角色和留痕要求是什么?",
List.of(), "sop", null, "mobile_uni_agent", 5, null));
var requestCaptor = ArgumentCaptor.forClass(SearchRequest.class);
verify(sop).searchAuthorized(requestCaptor.capture(), any(), eq(Set.of(9L)));
assertEquals(AihrFormalPolicyClassifier.FORMAL_POLICY_SOURCE, requestCaptor.getValue().source());
assertTrue(result.noEvidence());
assertTrue(result.citations().isEmpty());
assertTrue(result.answer().contains("不能使用访谈、经验材料或草稿"));
verify(memory, never()).recall(any(), anyString(), any());
verify(memory, never()).detectForQuery(any(), anyString(), any(), anyString(), any());
}
@Test
void internalToolFailureNeverFallsBackToRag() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
@@ -0,0 +1,50 @@
package org.dromara.aihr.knowledge.service;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
class AihrFormalPolicyClassifierTest {
@Test
void augustFirstNormalAndSynonymQuestionsRequireFormalSources() {
List<String> questions = List.of(
"不同住宅业态的日常巡检频次、路线和留痕要求分别是什么?",
"收到强降雨预警后,生活顾问应按什么顺序巡查、上报和闭环?",
"大件垃圾和装修垃圾应如何登记、分类、清运和说明费用?",
"报修受理后,生活顾问需要记录哪些信息并如何完成回访闭环?",
"物业费账单的费用构成、计费期间和查询入口如何解释?",
"住户提出分期缴费时,申请条件、审批角色和留痕要求是什么?",
"长期无人居住的房屋平时怎么做安全查看?",
"维修一直没进展,管家应该怎么催办并给业主回话?",
"业主一直欠费又说服务差,日常怎么沟通才合规?",
"装修产生的废料该找谁、怎么约清运、能不能收费?",
"车辆被占位或堵住时,物业怎么协调才不会升级冲突?",
"现场突然有安全险情,第一时间该找谁、留下什么记录?"
);
questions.forEach(question -> assertTrue(
AihrFormalPolicyClassifier.requiresFormalSource("mobile_uni_agent", question),
question
));
}
@Test
void adviceAndOtherEntryPointsDoNotGainFormalPolicySemantics() {
assertFalse(AihrFormalPolicyClassifier.requiresFormalSource(
"mobile_uni_agent", "催费话术怎么说?"));
assertFalse(AihrFormalPolicyClassifier.requiresFormalSource(
"mobile_uni_agent", "可以帮我写一句催费话术吗?"));
assertFalse(AihrFormalPolicyClassifier.requiresFormalSource(
"mobile_uni_agent", "图片里有什么?"));
assertFalse(AihrFormalPolicyClassifier.requiresFormalSource(
"knowledge_admin", "这个制度有哪些要求?"));
assertTrue(AihrFormalPolicyClassifier.requiresFormalSource(
"mobile_uni_agent_formal_policy", "继续说明"));
}
}
@@ -490,6 +490,12 @@ public class AihrSopSeedServiceTest {
assertTrue(code.contains("searchAuthorized(SearchRequest request, String requesterExtPartyId, Set<Long> allowedKnowledgeIds)"));
assertTrue(code.contains(".knowledge_id in ("));
assertTrue(code.contains("qdrantMatchAny(\"knowledge_id\", knowledgeIds)"));
assertTrue(code.contains("aihr_knowledge_source_governance"));
assertTrue(code.contains("governance.source_doc_id"));
assertTrue(code.contains("governance.authority_type = 'FORMAL_POLICY'"));
assertTrue(code.contains("governance.lifecycle_status = 'APPROVED'"));
assertTrue(code.contains("governance.effective_date <= current_date()"));
assertTrue(code.contains("governance.content_sha256 regexp '^[0-9a-f]{64}$'"));
AihrSopSeedService service = new AihrSopSeedService(
new ObjectMapper(), mock(JdbcTemplate.class), null, "", null, null, mock(AihrModelSeedService.class));