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:
+3
-1
@@ -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
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
+107
-4
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user