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 }} -

-
-