fix(knowledge): focus formal citation snippets

This commit is contained in:
key
2026-07-31 01:17:15 +08:00
parent ca61b1f36f
commit 72c56e7c96
4 changed files with 64 additions and 7 deletions
@@ -74,12 +74,50 @@ public final class AihrFormalPolicyClassifier {
if (query.isEmpty() || evidence.isEmpty()) { if (query.isEmpty() || evidence.isEmpty()) {
return false; return false;
} }
for (EvidenceProfile profile : EVIDENCE_PROFILES) { EvidenceProfile profile = matchingProfile(query);
if (containsAny(query, profile.questionSignals())) { return profile == null || containsAny(evidence, profile.evidenceSignals());
return containsAny(evidence, profile.evidenceSignals()); }
}
/**
* Keep the visible citation centered on the same deterministic anchor used
* by the formal-evidence gate instead of returning an unrelated document prefix.
*/
public static String relevantEvidenceSnippet(String question, String evidenceText, int maxLength) {
String evidence = evidenceText == null ? "" : evidenceText.trim();
if (evidence.isEmpty() || maxLength <= 0) {
return "";
} }
return true; if (evidence.length() <= maxLength) {
return evidence;
}
EvidenceProfile profile = matchingProfile(question == null ? "" : question.trim());
if (profile == null || maxLength < 3) {
return evidence.substring(0, maxLength);
}
int anchorIndex = profile.evidenceSignals().stream()
.mapToInt(evidence::indexOf)
.filter(index -> index >= 0)
.min()
.orElse(-1);
if (anchorIndex < 0) {
return evidence.substring(0, maxLength);
}
int windowLength = maxLength - 2;
int start = Math.max(0, anchorIndex - Math.min(120, windowLength / 3));
int end = Math.min(evidence.length(), start + windowLength);
if (end == evidence.length()) {
start = Math.max(0, end - windowLength);
}
return (start > 0 ? "…" : "")
+ evidence.substring(start, end).trim()
+ (end < evidence.length() ? "…" : "");
}
private static EvidenceProfile matchingProfile(String question) {
return EVIDENCE_PROFILES.stream()
.filter(profile -> containsAny(question, profile.questionSignals()))
.findFirst()
.orElse(null);
} }
private static EvidenceProfile evidence(List<String> questionSignals, List<String> evidenceSignals) { private static EvidenceProfile evidence(List<String> questionSignals, List<String> evidenceSignals) {
@@ -650,7 +650,10 @@ public class AihrKnowledgeQueryService {
|| AihrFormalPolicyClassifier.isRelevantFormalEvidence(queryText, row.snippet())) || AihrFormalPolicyClassifier.isRelevantFormalEvidence(queryText, row.snippet()))
.map(row -> new Citation( .map(row -> new Citation(
row.spaceCode(), row.sourceType(), row.docId(), row.title(), row.spaceCode(), row.sourceType(), row.docId(), row.title(),
truncate(row.snippet(), 500), row.fragmentId() formalPolicyOnly
? AihrFormalPolicyClassifier.relevantEvidenceSnippet(queryText, row.snippet(), 500)
: truncate(row.snippet(), 500),
row.fragmentId()
)) ))
.toList(); .toList();
} }
@@ -72,4 +72,20 @@ class AihrFormalPolicyClassifierTest {
assertFalse(AihrFormalPolicyClassifier.isRelevantFormalEvidence( assertFalse(AihrFormalPolicyClassifier.isRelevantFormalEvidence(
installmentQuestion, " ")); installmentQuestion, " "));
} }
@Test
void formalCitationSnippetKeepsTheMatchedAnchorVisible() {
String question = "装修产生的废料该找谁、怎么约清运、能不能收费?";
String evidence = "无关前言。".repeat(120)
+ "装修垃圾应登记后预约清运,收费标准须公示。"
+ "无关附录。".repeat(120);
String snippet = AihrFormalPolicyClassifier.relevantEvidenceSnippet(question, evidence, 500);
assertTrue(snippet.length() <= 500);
assertTrue(snippet.contains("装修垃圾"));
assertTrue(snippet.contains("清运"));
assertTrue(snippet.startsWith("…"));
assertTrue(snippet.endsWith("…"));
}
} }
+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`: