From 4c5c343acaf1a1fd2dd9e3d7573d32b6ccef2d68 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sat, 4 Jul 2026 23:27:08 +0800 Subject: [PATCH 01/13] feat(aihr): structured digest answer with layered mobile presentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈「检索返回一堆文字不便总结」修复。后端:检索命中后新增 RAG 生成环节 searchDigest——chat 模型 temperature=0 把 top5 片段压 成结构化概要(直接回答≤60字+3-6条可执行要点+0-3条注意红线),硬约束 不得编造、片段答不了则明示无依据;模型未配置/超时/解析失败回退 片段原文拼接,15s 超时不拖垮检索。SearchResponse 增 keyPoints/ cautions 字段。移动端:sopPanel 结果改三层——概要卡(回答大字+要点 列表+警示色块)默认唯一展开;「查看依据」折叠原文片段,单条3行截断 点击展开;「听一遍」按钮把概要送 TTS 播报(独立播放状态,关面板即停, 未配 tts 提示降级)。候选人端知识 tab 同套概要卡+依据折叠,消除 原文倾泻。实测:「客服接待基础流程」4s 返回概要+5要点+3红线。 --- .../org/dromara/aihr/domain/AihrSopDto.java | 4 +- .../aihr/service/AihrSopSeedService.java | 111 ++- mobile/src/App.vue | 658 +++++++++++++----- 3 files changed, 613 insertions(+), 160 deletions(-) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java index a40f67c1..0780b77d 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java @@ -65,7 +65,9 @@ public final class AihrSopDto { List docs, List snippets, List training, - List records + List records, + List keyPoints, + List cautions ) { } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java index 60d5424f..1f9a1786 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrSopSeedService.java @@ -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 hits) { + private SearchResponse responseFromHits(String category, String queryText, List hits) { KnowledgeHit first = hits.get(0); String responseCategory = isBlank(category) ? first.category() : category; List 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 hits) { + Optional 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 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 jsonStrings(JsonNode node, int maxItems, int maxChars) { + if (!node.isArray()) { + return List.of(); + } + List 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 keyPoints, List cautions) { + } + private static String dbCategory(String category) { return "sop".equalsIgnoreCase(Optional.ofNullable(category).orElse("").trim()) ? "" : category; } diff --git a/mobile/src/App.vue b/mobile/src/App.vue index 84c89245..17295742 100644 --- a/mobile/src/App.vue +++ b/mobile/src/App.vue @@ -52,7 +52,7 @@

- +
- {{ reviewMessage }} - -
-
- - {{ selectedReview.trainee }} · {{ selectedReview.score }}分 - {{ selectedReview.time }} · {{ selectedReview.scene }} - - {{ selectedReview.status }} -
- -
-

- {{ item.label }} - {{ item.value }} - {{ item.note }} -

-
- -
-

- {{ turn.label }} - {{ turn.text }} -

-
- -
- 导师改写 -

{{ selectedReview.mentorRewrite }}

- AI点评 -

{{ selectedReview.aiComment }}

-
- - - {{ reviewMessage }} -
@@ -192,7 +154,7 @@ @@ -345,44 +321,6 @@ -
-
- 三角色对练 - {{ practiceResult.totalScore }}分 - {{ practiceStatus === 'starting' ? '准备中' : '进行中' }} -
- -
- {{ practiceResult.aiComment }} -

{{ practiceResult.mentorRewrite }}

-
- - -
-
- -
-
-

- {{ turn.label }} - {{ turn.text }} -

-
-