fix(knowledge): bind summary cards to cited evidence
This commit is contained in:
+1
-1
@@ -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) {
|
||||
|
||||
+60
-97
@@ -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)
|
||||
|
||||
+47
-42
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user