feat(aihr): structured digest answer with layered mobile presentation

用户反馈「检索返回一堆文字不便总结」修复。后端:检索命中后新增
RAG 生成环节 searchDigest——chat 模型 temperature=0 把 top5 片段压
成结构化概要(直接回答≤60字+3-6条可执行要点+0-3条注意红线),硬约束
不得编造、片段答不了则明示无依据;模型未配置/超时/解析失败回退
片段原文拼接,15s 超时不拖垮检索。SearchResponse 增 keyPoints/
cautions 字段。移动端:sopPanel 结果改三层——概要卡(回答大字+要点
列表+警示色块)默认唯一展开;「查看依据」折叠原文片段,单条3行截断
点击展开;「听一遍」按钮把概要送 TTS 播报(独立播放状态,关面板即停,
未配 tts 提示降级)。候选人端知识 tab 同套概要卡+依据折叠,消除
原文倾泻。实测:「客服接待基础流程」4s 返回概要+5要点+3红线。
This commit is contained in:
2026-07-04 23:27:08 +08:00
parent e26e3c75cc
commit 4c5c343aca
3 changed files with 613 additions and 160 deletions
@@ -65,7 +65,9 @@ public final class AihrSopDto {
List<DocResponse> docs,
List<SnippetResponse> snippets,
List<String> training,
List<RecordResponse> records
List<RecordResponse> records,
List<String> keyPoints,
List<String> cautions
) {
}
@@ -123,7 +123,9 @@ public class AihrSopSeedService {
seed.docs(),
seed.snippets(),
seed.training(),
records()
records(),
List.of(),
List.of()
);
}
@@ -1726,7 +1728,7 @@ public class AihrSopSeedService {
}
}
private static SearchResponse responseFromHits(String category, String queryText, List<KnowledgeHit> hits) {
private SearchResponse responseFromHits(String category, String queryText, List<KnowledgeHit> hits) {
KnowledgeHit first = hits.get(0);
String responseCategory = isBlank(category) ? first.category() : category;
List<SnippetResponse> snippets = hits.stream()
@@ -1736,18 +1738,119 @@ public class AihrSopSeedService {
for (KnowledgeHit hit : hits) {
docs.putIfAbsent(hit.title(), new DocResponse(hit.title(), hit.description(), "命中 " + scoreText(hit.score()), "pill-success"));
}
SearchDigest digest = searchDigest(queryText, hits);
return new SearchResponse(
queryText,
responseCategory,
"根据已入库 SOP:" + first.content(),
digest != null ? digest.answer() : "根据已入库 SOP:" + first.content(),
"引用 " + docs.size() + " 份文档 / " + snippets.size() + " 个片段",
List.copyOf(docs.values()),
snippets,
List.of("题 1:请按命中 SOP 复述处理步骤。", "题 2:哪些信息必须留痕并可追溯?"),
records()
records(),
digest != null ? digest.keyPoints() : List.of(),
digest != null ? digest.cautions() : List.of()
);
}
/**
* RAG 生成环节:把命中片段交给 chat 模型压成「直接回答+要点+注意事项」结构化概要。
* 模型未配置、超时或解析失败返回 null,由调用方回退片段原文拼接——检索永不因概要失败而报错。
*/
private SearchDigest searchDigest(String queryText, List<KnowledgeHit> hits) {
Optional<ChatRuntime> runtime = chatRuntime();
if (runtime.isEmpty()) {
return null;
}
StringBuilder context = new StringBuilder();
int index = 1;
for (KnowledgeHit hit : hits.stream().limit(5).toList()) {
context.append("片段").append(index++).append("(来源:").append(hit.title()).append("):\n")
.append(truncate(hit.content(), 1500)).append("\n\n");
}
try {
String content = callDigestModel(runtime.get(), queryText, context.toString());
JsonNode root = objectMapper.readTree(jsonObjectText(content));
String answer = root.path("answer").asText("");
if (isBlank(answer)) {
return null;
}
return new SearchDigest(
truncate(answer, 200),
jsonStrings(root.path("keyPoints"), 6, 120),
jsonStrings(root.path("cautions"), 3, 120)
);
} catch (Exception e) {
log.warn("search digest failed, keep raw answer: {}", e.getMessage());
return null;
}
}
private String callDigestModel(ChatRuntime runtime, String queryText, String context) throws Exception {
ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName());
body.put("temperature", 0);
body.put("stream", false);
ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject();
system.put("role", "system");
system.put("content", "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。"
+ "answer 是对问题的直接回答(不超过60字,含关键数字/时限);keyPoints 是3到6条可执行要点(每条一句话,含具体数字);"
+ "cautions 是0到3条注意事项或红线。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;"
+ "若片段无法回答问题,answer 固定为「未在已入库 SOP 中找到明确依据」且 keyPoints 为空数组。");
ObjectNode user = messages.addObject();
user.put("role", "user");
user.put("content", """
员工的问题:%s
检索命中的 SOP 片段:
%s
""".formatted(queryText, context));
HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
.timeout(Duration.ofSeconds(15))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
if (!isBlank(runtime.apiKey())) {
builder.header("Authorization", "Bearer " + runtime.apiKey());
}
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build()
.send(builder.build(), HttpResponse.BodyHandlers.ofString());
if (!ok(response.statusCode())) {
throw new IllegalStateException("digest HTTP " + response.statusCode());
}
JsonNode choices = objectMapper.readTree(response.body()).path("choices");
if (!choices.isArray() || choices.isEmpty()) {
throw new IllegalStateException("digest response missing choices");
}
return choices.get(0).path("message").path("content").asText();
}
private static List<String> jsonStrings(JsonNode node, int maxItems, int maxChars) {
if (!node.isArray()) {
return List.of();
}
List<String> values = new ArrayList<>();
for (JsonNode item : node) {
String text = item.asText("").strip();
if (!text.isEmpty()) {
values.add(truncate(text, maxChars));
}
if (values.size() >= maxItems) {
break;
}
}
return List.copyOf(values);
}
private record SearchDigest(String answer, List<String> keyPoints, List<String> cautions) {
}
private static String dbCategory(String category) {
return "sop".equalsIgnoreCase(Optional.ofNullable(category).orElse("").trim()) ? "" : category;
}