fix(knowledge): reject irrelevant formal citations

This commit is contained in:
key
2026-07-31 01:02:48 +08:00
parent 218d6e38c9
commit ca61b1f36f
5 changed files with 90 additions and 6 deletions
@@ -28,6 +28,23 @@ public final class AihrFormalPolicyClassifier {
"车辆被占位", "车辆堵住", "停车冲突", "安全险情", "突发事件" "车辆被占位", "车辆堵住", "停车冲突", "安全险情", "突发事件"
); );
private static final List<EvidenceProfile> EVIDENCE_PROFILES = List.of(
evidence(List.of("日常巡检"), List.of("日常巡检", "巡检", "巡视", "巡查", "检查频次")),
evidence(List.of("强降雨", "防汛"), List.of("强降雨", "暴雨", "防汛", "汛期", "预警")),
evidence(List.of("大件垃圾", "装修垃圾", "装修产生"),
List.of("大件垃圾", "装修垃圾", "垃圾清运", "清运", "装修废料")),
evidence(List.of("报修受理"), List.of("报修", "维修", "回访")),
evidence(List.of("物业费账单", "计费期间", "费用构成"),
List.of("物业费", "物业服务费", "账单", "计费", "收费标准")),
evidence(List.of("分期缴费"), List.of("分期", "缓缴")),
evidence(List.of("长期无人居住", "空置房"), List.of("无人居住", "空置房", "空关房", "空房")),
evidence(List.of("维修一直没进展"), List.of("维修", "报修", "催办", "进展")),
evidence(List.of("欠费", "催费"), List.of("欠费", "催费", "物业费", "物业服务费")),
evidence(List.of("车辆被占位", "车辆堵住", "停车冲突"),
List.of("占用他人车位", "占位", "堵住", "车辆停放", "停车")),
evidence(List.of("安全险情", "突发事件"), List.of("险情", "突发事件", "应急", "上报"))
);
private AihrFormalPolicyClassifier() { private AihrFormalPolicyClassifier() {
} }
@@ -46,7 +63,33 @@ public final class AihrFormalPolicyClassifier {
return containsAny(value, FORMAL_TERMS) || containsAny(value, CONTROLLED_DOMAINS); return containsAny(value, FORMAL_TERMS) || containsAny(value, CONTROLLED_DOMAINS);
} }
/**
* A formal source is not sufficient by itself: the cited fragment must also contain
* a controlled-domain anchor for the question. Unknown policy domains keep the
* governance-only behavior until a deterministic profile is defined.
*/
public static boolean isRelevantFormalEvidence(String question, String evidenceText) {
String query = question == null ? "" : question.trim();
String evidence = evidenceText == null ? "" : evidenceText.trim();
if (query.isEmpty() || evidence.isEmpty()) {
return false;
}
for (EvidenceProfile profile : EVIDENCE_PROFILES) {
if (containsAny(query, profile.questionSignals())) {
return containsAny(evidence, profile.evidenceSignals());
}
}
return true;
}
private static EvidenceProfile evidence(List<String> questionSignals, List<String> evidenceSignals) {
return new EvidenceProfile(questionSignals, evidenceSignals);
}
private static boolean containsAny(String value, List<String> terms) { private static boolean containsAny(String value, List<String> terms) {
return terms.stream().anyMatch(value::contains); return terms.stream().anyMatch(value::contains);
} }
private record EvidenceProfile(List<String> questionSignals, List<String> evidenceSignals) {
}
} }
@@ -311,7 +311,8 @@ public class AihrKnowledgeQueryService {
SearchResponse legacy = sopService.searchAuthorized(new SearchRequest( SearchResponse legacy = sopService.searchAuthorized(new SearchRequest(
request.queryText(), request.category(), request.position(), retrievalSource, request.limit()), request.queryText(), request.category(), request.position(), retrievalSource, request.limit()),
principal == null ? "" : principal.extPartyId(), spaceIds); principal == null ? "" : principal.extPartyId(), spaceIds);
List<Citation> citations = citations(app.tenantId(), spaceIds, legacy.snippets(), formalPolicyOnly); List<Citation> citations = citations(
app.tenantId(), spaceIds, legacy.snippets(), formalPolicyOnly, request.queryText());
boolean noEvidence = citations.isEmpty(); boolean noEvidence = citations.isEmpty();
String answer = formalPolicyOnly && noEvidence String answer = formalPolicyOnly && noEvidence
? "当前已批准且在有效期内的正式资料中没有找到足够依据,不能使用访谈、经验材料或草稿补全政策答案。" ? "当前已批准且在有效期内的正式资料中没有找到足够依据,不能使用访谈、经验材料或草稿补全政策答案。"
@@ -590,7 +591,7 @@ 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) { boolean formalPolicyOnly, String queryText) {
if (snippets == null || snippets.isEmpty()) { if (snippets == null || snippets.isEmpty()) {
return List.of(); return List.of();
} }
@@ -638,11 +639,20 @@ public class AihrKnowledgeQueryService {
formalSourceClause), formalSourceClause),
(rs, rowNum) -> new Citation( (rs, rowNum) -> new Citation(
rs.getString("space_code"), "DOCUMENT", rs.getString("doc_id"), rs.getString("title"), rs.getString("space_code"), "DOCUMENT", rs.getString("doc_id"), rs.getString("title"),
truncate(rs.getString("content"), 500), rs.getLong("fragment_id") rs.getString("content"), rs.getLong("fragment_id")
), args.toArray()); ), args.toArray());
Map<Long, Citation> byId = new LinkedHashMap<>(); Map<Long, Citation> byId = new LinkedHashMap<>();
rows.forEach(row -> byId.put(row.fragmentId(), row)); rows.forEach(row -> byId.put(row.fragmentId(), row));
return fragmentIds.stream().map(byId::get).filter(java.util.Objects::nonNull).toList(); return fragmentIds.stream()
.map(byId::get)
.filter(java.util.Objects::nonNull)
.filter(row -> !formalPolicyOnly
|| AihrFormalPolicyClassifier.isRelevantFormalEvidence(queryText, row.snippet()))
.map(row -> new Citation(
row.spaceCode(), row.sourceType(), row.docId(), row.title(),
truncate(row.snippet(), 500), row.fragmentId()
))
.toList();
} }
private List<String> spaceCodes(String tenantId, Set<Long> spaceIds) { private List<String> spaceCodes(String tenantId, Set<Long> spaceIds) {
@@ -2916,8 +2916,14 @@ public class AihrSopSeedService {
category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly); category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly);
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits( List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(
category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly), limitValue); category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly), limitValue);
return rerankHits(queryText, rrf(textHits, vectorHits( List<KnowledgeHit> ranked = rerankHits(queryText, rrf(textHits, vectorHits(
category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly), limitValue)); category, queryText, limitValue, allowedKnowledgeIds, formalPolicyOnly), limitValue));
if (!formalPolicyOnly) {
return ranked;
}
return ranked.stream()
.filter(hit -> AihrFormalPolicyClassifier.isRelevantFormalEvidence(queryText, hit.content()))
.toList();
} }
private static String knowledgeScopeSql(String alias, Set<Long> allowedKnowledgeIds) { private static String knowledgeScopeSql(String alias, Set<Long> allowedKnowledgeIds) {
@@ -47,4 +47,29 @@ class AihrFormalPolicyClassifierTest {
assertTrue(AihrFormalPolicyClassifier.requiresFormalSource( assertTrue(AihrFormalPolicyClassifier.requiresFormalSource(
"mobile_uni_agent_formal_policy", "继续说明")); "mobile_uni_agent_formal_policy", "继续说明"));
} }
@Test
void formalEvidenceMustMatchTheControlledDomainQuestion() {
String installmentQuestion = "住户提出分期缴费时,申请条件、审批角色和留痕要求是什么?";
assertFalse(AihrFormalPolicyClassifier.isRelevantFormalEvidence(
installmentQuestion, "姿态端正,自然大方,工作手机应保持畅通。"));
assertFalse(AihrFormalPolicyClassifier.isRelevantFormalEvidence(
installmentQuestion, "节水型小区申报后,由相关部门组织评价。"));
assertFalse(AihrFormalPolicyClassifier.isRelevantFormalEvidence(
installmentQuestion, "车辆停放不得占用他人车位。"));
assertFalse(AihrFormalPolicyClassifier.isRelevantFormalEvidence(
installmentQuestion, "欠费住户由生活顾问定期提醒缴费。"));
assertTrue(AihrFormalPolicyClassifier.isRelevantFormalEvidence(
installmentQuestion, "申请分期缴费须由项目负责人审批,并保留审批记录。"));
assertTrue(AihrFormalPolicyClassifier.isRelevantFormalEvidence(
"装修产生的废料该找谁、怎么约清运、能不能收费?",
"装修垃圾应登记后预约清运,收费标准须公示。"));
assertTrue(AihrFormalPolicyClassifier.isRelevantFormalEvidence(
"车辆被占位或堵住时,物业怎么协调才不会升级冲突?",
"车辆停放不得占用他人车位,发现占位应先联系车主。"));
assertFalse(AihrFormalPolicyClassifier.isRelevantFormalEvidence(
installmentQuestion, " "));
}
} }
+1 -1
View File
@@ -58,7 +58,7 @@ curl -fsS "$API_BASE/api/aihr/agent/messages/media" \
文字问题去空白后必须为 1–1000 字;媒体接口的 `question` 为空时使用“请分析附件内容”。请求不接受身份、角色、`toolCode`、知识空间 ID、SQL 或任意 URL。响应继续使用若依信封,`data.status` 只会是 `COMPLETED/NEEDS_INPUT/NEEDS_CONFIRMATION/NO_EVIDENCE/FORBIDDEN/UNAVAILABLE/FAILED`,按钮分别读取 `actionDraft/clarification/resources/nextActions`,不得解析 `answer` 猜状态。 文字问题去空白后必须为 1–1000 字;媒体接口的 `question` 为空时使用“请分析附件内容”。请求不接受身份、角色、`toolCode`、知识空间 ID、SQL 或任意 URL。响应继续使用若依信封,`data.status` 只会是 `COMPLETED/NEEDS_INPUT/NEEDS_CONFIRMATION/NO_EVIDENCE/FORBIDDEN/UNAVAILABLE/FAILED`,按钮分别读取 `actionDraft/clarification/resources/nextActions`,不得解析 `answer` 猜状态。
政策边界在检索前由服务端确定性执行:明确存在版本/规则冲突时返回 `NEEDS_INPUT`,不得自行裁决或拼接;明确无依据时返回 `NO_EVIDENCE`,不得用经验补全政策事实;跨项目、他人敏感信息、无权附件或未发布制度返回 `FORBIDDEN`。移动端 Agent 对涉及制度、权限、费用、安全职责或必留记录的问题,服务端会在规划改写前固定为正式政策模式,客户端不能选择或绕过;MySQL 模糊/全文召回、Qdrant 命中回表和最终引用装配均只接受 `aihr_knowledge_source_governance` 中当前有效的 `FORMAL_POLICY + APPROVED` 附件,并校验审批时 `docId` 快照一致、版本和原文件 SHA-256 登记完整。正式问题不混入个人/项目记忆;治理表缺失、未审批、已失效、版本或哈希为空,以及最终引用重校验失败时统一返回 `NO_EVIDENCE`。普通“经验话术怎么说”等建议问题仍可检索经授权经验材料,但不得包装为制度。访谈、经验萃取、seed 或 AI 输出不得登记为正式政策来源。 政策边界在检索前由服务端确定性执行:明确存在版本/规则冲突时返回 `NEEDS_INPUT`,不得自行裁决或拼接;明确无依据时返回 `NO_EVIDENCE`,不得用经验补全政策事实;跨项目、他人敏感信息、无权附件或未发布制度返回 `FORBIDDEN`。移动端 Agent 对涉及制度、权限、费用、安全职责或必留记录的问题,服务端会在规划改写前固定为正式政策模式,客户端不能选择或绕过;MySQL 模糊/全文召回、Qdrant 命中回表和最终引用装配均只接受 `aihr_knowledge_source_governance` 中当前有效的 `FORMAL_POLICY + APPROVED` 附件,并校验审批时 `docId` 快照一致、版本和原文件 SHA-256 登记完整。已覆盖的试点政策领域还会在进入模型前和最终引用装配时执行问题领域锚点相关性复核,只有来源合规且与问题领域匹配的片段才能参与回答。正式问题不混入个人/项目记忆;治理表缺失、未审批、已失效、版本或哈希为空、领域不相关,以及最终引用重校验失败时统一返回 `NO_EVIDENCE`。普通“经验话术怎么说”等建议问题仍可检索经授权经验材料,但不得包装为制度。访谈、经验萃取、seed 或 AI 输出不得登记为正式政策来源。
全网意图在 `externalConsent=false` 时以业务成功响应返回 `NEEDS_INPUT`,用户同意后原请求携带 `externalConsent=true` 重试。确认式记忆先取得 `actionDraft.draftId`,再提交卡片中可编辑的完整 `draft`: 全网意图在 `externalConsent=false` 时以业务成功响应返回 `NEEDS_INPUT`,用户同意后原请求携带 `externalConsent=true` 重试。确认式记忆先取得 `actionDraft.draftId`,再提交卡片中可编辑的完整 `draft`: