fix(knowledge): gate policy answers by formal sources
This commit is contained in:
+12
-2
@@ -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("另一份")
|
||||
|
||||
+52
@@ -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);
|
||||
}
|
||||
}
|
||||
+42
-7
@@ -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")
|
||||
|
||||
+78
-9
@@ -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);
|
||||
|
||||
+11
@@ -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));
|
||||
|
||||
+42
@@ -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);
|
||||
|
||||
+50
@@ -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", "继续说明"));
|
||||
}
|
||||
}
|
||||
+6
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user