feat(aihr): return explicit no-evidence SOP answers and log knowledge gaps

- Empty/unanswerable SOP queries now return the fixed 「未在已入库 SOP 中找到明确依据」
  response instead of falling back to seed answers (no fake answers)
- Record knowledge_gap (question/category/position/source) whenever search
  yields no evidence — feeds the phase-two open question board
- Extract NO_CLEAR_SOP_EVIDENCE constant shared by digest prompt and checks
This commit is contained in:
2026-07-08 12:22:33 +08:00
parent 2bdf527e26
commit eeffbaf752
@@ -91,6 +91,7 @@ public class AihrSopSeedService {
private static final String LOCAL_EMBEDDING_MODEL = "local-hash-v1";
private static final String LOCAL_EMBEDDING_BASE_URL = "local";
private static final int LOCAL_EMBEDDING_DIMENSION = 1536;
private static final String NO_CLEAR_SOP_EVIDENCE = "未在已入库 SOP 中找到明确依据";
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final Map<String, SopSeed> seeds = buildSeeds();
@@ -120,25 +121,19 @@ public class AihrSopSeedService {
String category = isBlank(requestedCategory) ? seed.category() : requestedCategory;
String position = firstNonBlank(request == null ? null : request.position(), "生活顾问");
String source = normalizeSearchSource(request == null ? null : request.source());
String queryText = request == null || request.queryText() == null || request.queryText().isBlank() ? seed.question() : request.queryText();
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
if (isBlank(queryText)) {
return withReviewId(noEvidenceResponse("", category), source);
}
SearchResponse dbResponse = dbSearch(dbCategory(category), queryText, request == null ? null : request.limit());
if (dbResponse != null && !dbResponse.snippets().isEmpty()) {
if (dbResponse != null) {
if (isNoEvidenceAnswer(dbResponse.answer())) {
recordKnowledgeGap(queryText, category, position, source);
}
return withReviewId(dbResponse, source);
}
recordKnowledgeGap(queryText, category, position, source);
return withReviewId(new SearchResponse(
queryText,
seed.category(),
seed.answer(),
seed.reference(),
seed.docs(),
seed.snippets(),
seed.training(),
records(),
List.of(),
List.of(),
null
), source);
return withReviewId(noEvidenceResponse(queryText, category), source);
}
public SopReviewResponse reviewSearch(Long id, SopReviewRequest request) {
@@ -2003,17 +1998,23 @@ public class AihrSopSeedService {
docs.putIfAbsent(hit.title(), new DocResponse(hit.title(), hit.description(), "命中 " + scoreText(hit.score()), "pill-success"));
}
SearchDigest digest = searchDigest(queryText, hits);
if (digest == null) {
return noEvidenceResponse(queryText, responseCategory);
}
if (isNoEvidenceAnswer(digest.answer())) {
return noEvidenceResponse(queryText, responseCategory, digest.cautions());
}
return new SearchResponse(
queryText,
responseCategory,
digest != null ? digest.answer() : "根据已入库 SOP:" + first.content(),
digest.answer(),
"引用 " + docs.size() + " 份文档 / " + snippets.size() + " 个片段",
List.copyOf(docs.values()),
snippets,
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
records(),
digest != null ? digest.keyPoints() : List.of(),
digest != null ? digest.cautions() : List.of(),
digest.keyPoints(),
digest.cautions(),
null
);
}
@@ -2063,7 +2064,7 @@ public class AihrSopSeedService {
system.put("content", "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。"
+ "answer 是对问题的直接回答(不超过60字,含关键数字/时限);keyPoints 是3到6条可执行要点(每条一句话,含具体数字);"
+ "cautions 是0到3条注意事项或红线。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;"
+ "若片段无法回答问题,answer 固定为「未在已入库 SOP 中找到明确依据」且 keyPoints 为空数组。");
+ "若片段无法回答问题,answer 固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。");
ObjectNode user = messages.addObject();
user.put("role", "user");
@@ -2247,6 +2248,30 @@ public class AihrSopSeedService {
return score <= 0 ? "片段" : String.format("%.2f", score);
}
private static boolean isNoEvidenceAnswer(String answer) {
return NO_CLEAR_SOP_EVIDENCE.equals(Optional.ofNullable(answer).orElse("").trim());
}
private static SearchResponse noEvidenceResponse(String queryText, String category) {
return noEvidenceResponse(queryText, category, List.of());
}
private static SearchResponse noEvidenceResponse(String queryText, String category, List<String> cautions) {
return new SearchResponse(
queryText,
firstNonBlank(category, "sop"),
NO_CLEAR_SOP_EVIDENCE,
"未找到可引用片段",
List.of(),
List.of(),
List.of(),
records(),
List.of(),
cautions,
null
);
}
private KnowledgeConfig knowledgeConfig(String category) {
List<KnowledgeConfig> rows = jdbcTemplate.query("""
select id, coalesce(text_block_size, 800) as block_size, coalesce(overlap_char, 120) as overlap_char