fix(knowledge): bind summary cards to cited evidence

This commit is contained in:
2026-07-25 01:45:41 +08:00
parent 80af8a0870
commit f545983de8
13 changed files with 211 additions and 151 deletions
@@ -32,7 +32,7 @@ public final class AihrSopDto {
public record AuthorizedKnowledgeHit(Long fragmentId, String title, String content) {
}
public record SummaryCardRequest(String queryText, String category) {
public record SummaryCardRequest(String queryText, String category, List<Long> fragmentIds) {
}
public record SopReviewRequest(Boolean usable, String reviewer, String note) {
@@ -115,11 +115,12 @@ public class AihrSopSeedService {
private static final Set<String> FEEDBACK_REASON_CODES = Set.of(
"no_answer", "not_specific", "wrong_reference", "outdated", "not_applicable"
);
private static final String SOP_ANSWER_PROMPT_VERSION = "builtin-sop_answer-v1";
private static final String SOP_ANSWER_PROMPT_VERSION = "builtin-sop_answer-v2";
private static final String SOP_ANSWER_SYSTEM_PROMPT = "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。"
+ "answer 必须结论先行,60字内直接告诉员工现在怎么做;keyPoints 必须是3到5条可执行步骤,每条以「一、」「二、」「三、」「四、」「五、」开头,包含责任人、动作、时限或留痕要求;"
+ "answer 必须结论先行,60字内直接告诉员工片段能够支持的做法;keyPoints 必须是1到5条有依据的可执行要点,每条以「一、」「二、」「三、」「四、」「五、」开头;"
+ "cautions 是0到3条注意事项或红线。引用片段由系统后置展示,不要在 answer 中堆大段原文。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;"
+ "若片段无法回答问题,answer 固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。";
+ "只要片段包含与问题相关的操作、适用范围、用量或安全要求,就回答这些已支持内容,并明确缺少的完整流程;"
+ "只有全部片段与问题完全无关时,answer 才固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。";
private static final String SOP_ANSWER_USER_TEMPLATE = """
员工的问题:{{queryText}}
@@ -531,19 +532,10 @@ public class AihrSopSeedService {
if (isBlank(queryText)) {
throw new ServiceException("请先输入问题");
}
String category = request == null ? "sop" : firstNonBlank(request.category(), "sop");
List<KnowledgeHit> hits;
try {
hits = dbHits(dbCategory(category), queryText, 5, allowedKnowledgeIds);
} catch (DataAccessException e) {
hits = List.of();
}
if (hits.isEmpty()) {
throw new ServiceException("知识库中未检索到相关内容");
}
List<KnowledgeHit> hits = summaryCardHits(requiredSummaryFragmentIds(request), allowedKnowledgeIds);
Optional<ChatRuntime> runtime = chatRuntime();
if (runtime.isEmpty()) {
return evidenceSummaryCard(queryText, hits);
throw new ServiceException("总结卡模型未配置或未启用,请联系管理员");
}
StringBuilder context = new StringBuilder();
@@ -557,8 +549,8 @@ public class AihrSopSeedService {
JsonNode root = objectMapper.readTree(jsonObjectText(content));
String title = truncate(root.path("title").asText("").strip(), 14);
List<CardStep> steps = jsonCardSteps(root.path("steps"));
if (isBlank(title) || steps.isEmpty()) {
throw new IllegalStateException("summary card missing title or steps");
if (isBlank(title) || !hasRequiredSummarySteps(steps)) {
throw new IllegalStateException("summary card response does not match the required structure");
}
return new SummaryCardResponse(
title,
@@ -568,98 +560,69 @@ public class AihrSopSeedService {
jsonStrings(root.path("reminders"), 5, 20)
);
} catch (Exception e) {
log.warn("summary card model unavailable, falling back to retrieved evidence: {}", e.getClass().getSimpleName());
return evidenceSummaryCard(queryText, hits);
log.warn("summary card model failed: {}", e.getClass().getSimpleName());
throw new ServiceException("总结卡生成暂不可用,请稍后重试");
}
}
/**
* 模型未配置或暂时不可用时,直接把已命中的 SOP 原文整理成卡片。
* 该降级路径只摘录检索证据,不生成话术或异议回应,避免把推测内容冒充制度要求。
*/
static SummaryCardResponse evidenceSummaryCard(String queryText, List<KnowledgeHit> hits) {
List<CardStep> steps = new ArrayList<>();
Set<String> seenEvidence = new LinkedHashSet<>();
Set<String> sourceTitles = new LinkedHashSet<>();
List<KnowledgeHit> relevantHits = summaryEvidenceHits(queryText, hits);
if (relevantHits.isEmpty()) {
throw new ServiceException("检索片段与问题相关度不足,暂不能生成总结卡");
}
for (KnowledgeHit hit : relevantHits) {
if (!isBlank(hit.title())) {
sourceTitles.add(hit.title().strip());
}
String content = Optional.ofNullable(hit.content()).orElse("");
for (String candidate : content.split("[。!?!?;;\\r\\n]+")) {
String evidence = candidate
.replaceFirst("^[\\s\\-—–•·*\\d一二三四五六七八九十、.()()]+", "")
.replaceAll("\\s+", " ")
.strip();
if (evidence.length() < 4 || !seenEvidence.add(evidence)) {
continue;
}
steps.add(new CardStep("原文" + (steps.size() + 1), truncate(evidence, 40)));
if (steps.size() >= 5) {
break;
}
}
if (steps.size() >= 5) {
break;
}
}
if (steps.isEmpty()) {
throw new ServiceException("检索片段中没有可生成总结卡的文字内容");
}
String normalizedQuery = Optional.ofNullable(queryText).orElse("")
.replaceAll("[\\s??!!。]+", "")
.strip();
String title = isBlank(normalizedQuery) ? "SOP原文总结卡" : truncate(normalizedQuery, 14);
List<String> reminders = sourceTitles.stream()
.limit(3)
.map(source -> "来源:" + truncate(source, 15))
.toList();
return new SummaryCardResponse(title, List.copyOf(steps), List.of(), List.of(), reminders);
static boolean hasRequiredSummarySteps(List<CardStep> steps) {
return Optional.ofNullable(steps).orElse(List.of()).stream().map(CardStep::label).toList()
.equals(List.of("问题", "建议", "标准"));
}
private static List<KnowledgeHit> summaryEvidenceHits(String queryText, List<KnowledgeHit> hits) {
List<KnowledgeHit> candidates = Optional.ofNullable(hits).orElse(List.of()).stream().limit(5).toList();
if (candidates.isEmpty()) {
return List.of();
static List<Long> requiredSummaryFragmentIds(SummaryCardRequest request) {
LinkedHashSet<Long> ids = new LinkedHashSet<>();
for (Long id : Optional.ofNullable(request == null ? null : request.fragmentIds()).orElse(List.of())) {
if (id != null && id > 0) {
ids.add(id);
}
}
Set<String> queryBigrams = evidenceBigrams(queryText);
if (queryBigrams.isEmpty()) {
return List.of(candidates.get(0));
if (ids.isEmpty()) {
throw new ServiceException("总结卡需要当前回答的引用片段,请重新查询");
}
Map<KnowledgeHit, Integer> scores = new LinkedHashMap<>();
int bestScore = 0;
for (KnowledgeHit hit : candidates) {
String evidence = evidenceText(firstNonBlank(hit.title(), "") + " " + firstNonBlank(hit.content(), ""));
int score = (int) queryBigrams.stream().filter(evidence::contains).count();
scores.put(hit, score);
bestScore = Math.max(bestScore, score);
if (ids.size() > 5) {
throw new ServiceException("总结卡最多使用 5 条引用片段");
}
if (bestScore == 0) {
return List.of();
}
int threshold = bestScore >= 3 ? Math.max(2, (bestScore + 1) / 2) : bestScore;
return scores.entrySet().stream()
.filter(entry -> entry.getValue() >= threshold)
.limit(MAX_EVIDENCE_SNIPPETS)
.map(Map.Entry::getKey)
.toList();
return List.copyOf(ids);
}
private static Set<String> evidenceBigrams(String value) {
String normalized = evidenceText(value);
if (normalized.length() < 2) {
return Set.of();
private List<KnowledgeHit> summaryCardHits(List<Long> fragmentIds, Set<Long> allowedKnowledgeIds) {
String placeholders = String.join(",", java.util.Collections.nCopies(fragmentIds.size(), "?"));
String sql = """
select f.id as fragment_id,
coalesce(a.name, i.name) as title,
i.name as category,
coalesce(i.description, '') as description,
f.doc_id,
f.content,
f.idx
from aihr_knowledge_fragment f
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 = ?
and f.id in (%s)
%s
""".formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds));
List<Object> args = new ArrayList<>();
args.add(tenantId());
args.addAll(fragmentIds);
addKnowledgeScopeArgs(args, allowedKnowledgeIds);
List<KnowledgeHit> rows = jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit(
rs.getLong("fragment_id"),
rs.getString("title"),
rs.getString("category"),
rs.getString("description"),
rs.getString("doc_id"),
rs.getString("content"),
rs.getInt("idx"),
1.0
), args.toArray());
Map<Long, KnowledgeHit> byId = rows.stream()
.collect(java.util.stream.Collectors.toMap(KnowledgeHit::fragmentId, hit -> hit));
if (byId.size() != fragmentIds.size()) {
throw new ServiceException("引用片段已失效或无权访问,请重新查询后再生成总结卡", 403);
}
Set<String> bigrams = new LinkedHashSet<>();
for (int index = 0; index < normalized.length() - 1; index++) {
bigrams.add(normalized.substring(index, index + 2));
}
return Set.copyOf(bigrams);
return fragmentIds.stream().map(byId::get).toList();
}
@Transactional(rollbackFor = Exception.class)
@@ -519,56 +519,61 @@ public class AihrSopSeedServiceTest {
@Test
@Tag("dev")
public void summaryCardFallsBackToRetrievedEvidenceWithoutInventingScripts() {
List<AihrSopSeedService.KnowledgeHit> hits = List.of(
new AihrSopSeedService.KnowledgeHit(
1L,
"投诉处理 SOP.pdf",
"sop",
"",
"doc-1",
"先记录业主诉求、房号和联系方式。30分钟内反馈首个处理节点,并保留工单记录。",
1,
1.0
),
new AihrSopSeedService.KnowledgeHit(
2L,
"报修跟进 SOP.pdf",
"sop",
"",
"doc-2",
"派单后确认责任人和完成时限;复查通过后再关闭工单。",
1,
0.9
)
);
public void summaryCardRequiresBoundEvidenceFragments() {
ServiceException error = assertThrows(ServiceException.class,
() -> AihrSopSeedService.requiredSummaryFragmentIds(
new AihrSopDto.SummaryCardRequest("厕所打扫的 SOP 是什么?", "sop", List.of())
));
AihrSopDto.SummaryCardResponse response = AihrSopSeedService.evidenceSummaryCard(
"业主投诉漏水,第一步怎么处理?",
hits
);
assertFalse(response.title().isBlank());
assertTrue(response.steps().size() >= 2);
assertTrue(response.steps().stream().anyMatch(step -> step.desc().contains("记录业主诉求")));
assertFalse(response.steps().stream().anyMatch(step -> step.desc().contains("确认责任人")));
assertTrue(response.scripts().isEmpty());
assertTrue(response.objections().isEmpty());
assertEquals(List.of("来源:投诉处理 SOP.pdf"), response.reminders());
assertTrue(error.getMessage().contains("引用片段"));
}
@Test
@Tag("dev")
public void summaryCardRejectsUnrelatedEvidenceInsteadOfPackagingFirstHit() {
List<AihrSopSeedService.KnowledgeHit> hits = List.of(
new AihrSopSeedService.KnowledgeHit(1L, "信息安全制度.pdf", "sop", "", "doc-1",
"员工电脑必须启用屏幕锁定并定期更新密码。", 1, 1.0)
);
public void summaryCardDeduplicatesAndCapsBoundEvidenceFragments() {
assertEquals(List.of(10L, 20L), AihrSopSeedService.requiredSummaryFragmentIds(
new AihrSopDto.SummaryCardRequest(
"厕所打扫的 SOP 是什么?",
"sop",
Arrays.asList(10L, null, 10L, 20L)
)
));
ServiceException error = assertThrows(ServiceException.class,
() -> AihrSopSeedService.evidenceSummaryCard("停车费催缴怎么沟通?", hits));
() -> AihrSopSeedService.requiredSummaryFragmentIds(
new AihrSopDto.SummaryCardRequest(
"厕所打扫的 SOP 是什么?",
"sop",
List.of(1L, 2L, 3L, 4L, 5L, 6L)
)
));
assertTrue(error.getMessage().contains("相关度不足"));
assertTrue(error.getMessage().contains("最多使用 5 条"));
}
@Test
@Tag("dev")
public void summaryCardRequestCarriesCurrentAnswerFragmentIds() {
AihrSopDto.SummaryCardRequest request = new AihrSopDto.SummaryCardRequest(
"厕所打扫的 SOP 是什么?",
"sop",
List.of(101293L, 105626L)
);
assertEquals(List.of(101293L, 105626L), request.fragmentIds());
}
@Test
@Tag("dev")
public void summaryCardRequiresProblemSuggestionAndStandardInOrder() {
assertTrue(AihrSopSeedService.hasRequiredSummarySteps(List.of(
new AihrSopDto.CardStep("问题", "待处理问题"),
new AihrSopDto.CardStep("建议", "下一步动作"),
new AihrSopDto.CardStep("标准", "SOP依据")
)));
assertFalse(AihrSopSeedService.hasRequiredSummarySteps(List.of(
new AihrSopDto.CardStep("原文1", "无结构片段")
)));
}
@Test