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 f71a8b45..30b1230f 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 @@ -92,6 +92,17 @@ public class AihrSopSeedService { private static final String LOCAL_EMBEDDING_BASE_URL = "local"; private static final int LOCAL_EMBEDDING_DIMENSION = 1536; private static final String NO_CLEAR_SOP_EVIDENCE = "未在已入库 SOP 中找到明确依据"; + private static final String SOP_ANSWER_PROMPT_VERSION = "builtin-sop_answer-v1"; + private static final String SOP_ANSWER_SYSTEM_PROMPT = "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。" + + "answer 必须结论先行,60字内直接告诉员工现在怎么做;keyPoints 必须是3到5条可执行步骤,每条以「一、」「二、」「三、」「四、」「五、」开头,包含责任人、动作、时限或留痕要求;" + + "cautions 是0到3条注意事项或红线。引用片段由系统后置展示,不要在 answer 中堆大段原文。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;" + + "若片段无法回答问题,answer 固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。"; + private static final String SOP_ANSWER_USER_TEMPLATE = """ + 员工的问题:{{queryText}} + + 检索命中的 SOP 片段: + {{context}} + """; private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); private final Map seeds = buildSeeds(); @@ -2035,16 +2046,19 @@ public class AihrSopSeedService { .append(truncate(hit.content(), 1500)).append("\n\n"); } try { - String content = callDigestModel(runtime.get(), queryText, context.toString()); + DigestModelResult result = callDigestModel(runtime.get(), queryText, context.toString()); + String content = result.content(); JsonNode root = objectMapper.readTree(jsonObjectText(content)); String answer = root.path("answer").asText(""); if (isBlank(answer)) { return null; } + log.info("sop answer prompt_version={}", result.promptVersion()); return new SearchDigest( truncate(answer, 200), jsonStrings(root.path("keyPoints"), 6, 120), - jsonStrings(root.path("cautions"), 3, 120) + jsonStrings(root.path("cautions"), 3, 120), + result.promptVersion() ); } catch (Exception e) { log.warn("search digest failed, keep raw answer: {}", e.getMessage()); @@ -2052,7 +2066,8 @@ public class AihrSopSeedService { } } - private String callDigestModel(ChatRuntime runtime, String queryText, String context) throws Exception { + private DigestModelResult callDigestModel(ChatRuntime runtime, String queryText, String context) throws Exception { + PromptTemplate prompt = sopAnswerPrompt(); ObjectNode body = objectMapper.createObjectNode(); body.put("model", runtime.modelName()); body.put("temperature", 0); @@ -2061,19 +2076,11 @@ public class AihrSopSeedService { 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 固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。"); + system.put("content", prompt.systemPrompt()); ObjectNode user = messages.addObject(); user.put("role", "user"); - user.put("content", """ - 员工的问题:%s - - 检索命中的 SOP 片段: - %s - """.formatted(queryText, context)); + user.put("content", renderPrompt(prompt.template(), queryText, context)); HttpRequest.Builder builder = HttpRequest.newBuilder() .uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions")) @@ -2094,7 +2101,35 @@ public class AihrSopSeedService { if (!choices.isArray() || choices.isEmpty()) { throw new IllegalStateException("digest response missing choices"); } - return choices.get(0).path("message").path("content").asText(); + return new DigestModelResult(choices.get(0).path("message").path("content").asText(), prompt.promptVersion()); + } + + private PromptTemplate sopAnswerPrompt() { + try { + List rows = jdbcTemplate.query(""" + SELECT system_prompt, template, version + FROM aihr_prompt_template + WHERE tenant_id = ? AND code = 'sop_answer' AND enabled = 1 + ORDER BY version DESC, id DESC + LIMIT 1 + """, (rs, rowNum) -> new PromptTemplate( + firstNonBlank(rs.getString("system_prompt"), SOP_ANSWER_SYSTEM_PROMPT), + firstNonBlank(rs.getString("template"), SOP_ANSWER_USER_TEMPLATE), + "sop_answer:" + firstNonBlank(rs.getString("version"), "v1") + ), TENANT_ID); + if (!rows.isEmpty()) { + return rows.get(0); + } + } catch (DataAccessException ignored) { + // prompt table is optional in demo fallback; built-in prompt keeps SOP search usable. + } + return new PromptTemplate(SOP_ANSWER_SYSTEM_PROMPT, SOP_ANSWER_USER_TEMPLATE, SOP_ANSWER_PROMPT_VERSION); + } + + private static String renderPrompt(String template, String queryText, String context) { + return firstNonBlank(template, SOP_ANSWER_USER_TEMPLATE) + .replace("{{queryText}}", firstNonBlank(queryText, "")) + .replace("{{context}}", firstNonBlank(context, "")); } private String callSummaryCardModel(ChatRuntime runtime, String queryText, String context) throws Exception { @@ -2199,7 +2234,13 @@ public class AihrSopSeedService { return List.copyOf(values); } - private record SearchDigest(String answer, List keyPoints, List cautions) { + private record SearchDigest(String answer, List keyPoints, List cautions, String promptVersion) { + } + + private record DigestModelResult(String content, String promptVersion) { + } + + private record PromptTemplate(String systemPrompt, String template, String promptVersion) { } private static String dbCategory(String category) { diff --git a/backend/script/sql/aihr_practice_mysql8.sql b/backend/script/sql/aihr_practice_mysql8.sql index 95055ec9..f2678d36 100644 --- a/backend/script/sql/aihr_practice_mysql8.sql +++ b/backend/script/sql/aihr_practice_mysql8.sql @@ -357,6 +357,10 @@ VALUES '你是物业一线员工的SOP总结卡助手。只返回JSON,不要markdown。固定输出title、steps、objections、scripts、reminders。steps必须且只包含问题、建议、标准三项。', '员工问题:{{queryText}}。知识片段:{{context}}。请生成可拆分展示的总结卡,问题写员工当前要处理的核心问题,建议写下一步动作,标准写SOP依据和红线。', 'queryText,context', 'v1', 1, '总结卡固定问题/建议/标准三段式', NOW(), NOW()), +('000000', 'sop_answer', 'SOP问答直给', 'knowledge', 'json_answer', + '你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。answer 必须结论先行,60字内直接告诉员工现在怎么做;keyPoints 必须是3到5条可执行步骤,每条以「一、」「二、」「三、」「四、」「五、」开头,包含责任人、动作、时限或留痕要求;cautions 是0到3条注意事项或红线。引用片段由系统后置展示,不要在 answer 中堆大段原文。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;若片段无法回答问题,answer 固定为「未在已入库 SOP 中找到明确依据」且 keyPoints 为空数组。', + '员工的问题:{{queryText}}\n\n检索命中的 SOP 片段:\n{{context}}', + 'queryText,context', 'v1', 1, 'SOP问答结论先行+编号步骤+引用后置', NOW(), NOW()), ('000000', 'mentor_rewrite', '导师改写', 'practice', 'text', '你是物业管家带教师傅。只基于本场对话给出一段更稳妥的话术,不评价人格。', '场景:{{scenarioName}}。目标:{{goal}}。员工原话:{{traineeText}}。请改写成可直接说出口的版本,80字以内。',