feat(aihr): bind SOP direct-answer digest to sop_answer prompt template

- Seed aihr_prompt_template with code=sop_answer (conclusion-first + numbered
  steps + citations appended), matching the built-in prompt
- Digest prefers the enabled sop_answer template, falls back to the built-in
  constant, and logs prompt_version; answer/keyPoints/cautions contract unchanged
This commit is contained in:
2026-07-08 14:52:02 +08:00
parent 7fd61b6ca3
commit 8d78bc69d1
2 changed files with 60 additions and 15 deletions
@@ -92,6 +92,17 @@ public class AihrSopSeedService {
private static final String LOCAL_EMBEDDING_BASE_URL = "local"; private static final String LOCAL_EMBEDDING_BASE_URL = "local";
private static final int LOCAL_EMBEDDING_DIMENSION = 1536; private static final int LOCAL_EMBEDDING_DIMENSION = 1536;
private static final String NO_CLEAR_SOP_EVIDENCE = "未在已入库 SOP 中找到明确依据"; 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 static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final Map<String, SopSeed> seeds = buildSeeds(); private final Map<String, SopSeed> seeds = buildSeeds();
@@ -2035,16 +2046,19 @@ public class AihrSopSeedService {
.append(truncate(hit.content(), 1500)).append("\n\n"); .append(truncate(hit.content(), 1500)).append("\n\n");
} }
try { 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)); JsonNode root = objectMapper.readTree(jsonObjectText(content));
String answer = root.path("answer").asText(""); String answer = root.path("answer").asText("");
if (isBlank(answer)) { if (isBlank(answer)) {
return null; return null;
} }
log.info("sop answer prompt_version={}", result.promptVersion());
return new SearchDigest( return new SearchDigest(
truncate(answer, 200), truncate(answer, 200),
jsonStrings(root.path("keyPoints"), 6, 120), jsonStrings(root.path("keyPoints"), 6, 120),
jsonStrings(root.path("cautions"), 3, 120) jsonStrings(root.path("cautions"), 3, 120),
result.promptVersion()
); );
} catch (Exception e) { } catch (Exception e) {
log.warn("search digest failed, keep raw answer: {}", e.getMessage()); 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(); ObjectNode body = objectMapper.createObjectNode();
body.put("model", runtime.modelName()); body.put("model", runtime.modelName());
body.put("temperature", 0); body.put("temperature", 0);
@@ -2061,19 +2076,11 @@ public class AihrSopSeedService {
ArrayNode messages = body.putArray("messages"); ArrayNode messages = body.putArray("messages");
ObjectNode system = messages.addObject(); ObjectNode system = messages.addObject();
system.put("role", "system"); system.put("role", "system");
system.put("content", "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。" system.put("content", prompt.systemPrompt());
+ "answer 是对问题的直接回答(不超过60字,含关键数字/时限);keyPoints 是3到6条可执行要点(每条一句话,含具体数字);"
+ "cautions 是0到3条注意事项或红线。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;"
+ "若片段无法回答问题,answer 固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。");
ObjectNode user = messages.addObject(); ObjectNode user = messages.addObject();
user.put("role", "user"); user.put("role", "user");
user.put("content", """ user.put("content", renderPrompt(prompt.template(), queryText, context));
员工的问题:%s
检索命中的 SOP 片段:
%s
""".formatted(queryText, context));
HttpRequest.Builder builder = HttpRequest.newBuilder() HttpRequest.Builder builder = HttpRequest.newBuilder()
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions")) .uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
@@ -2094,7 +2101,35 @@ public class AihrSopSeedService {
if (!choices.isArray() || choices.isEmpty()) { if (!choices.isArray() || choices.isEmpty()) {
throw new IllegalStateException("digest response missing choices"); 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<PromptTemplate> 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 { private String callSummaryCardModel(ChatRuntime runtime, String queryText, String context) throws Exception {
@@ -2199,7 +2234,13 @@ public class AihrSopSeedService {
return List.copyOf(values); return List.copyOf(values);
} }
private record SearchDigest(String answer, List<String> keyPoints, List<String> cautions) { private record SearchDigest(String answer, List<String> keyPoints, List<String> 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) { private static String dbCategory(String category) {
@@ -357,6 +357,10 @@ VALUES
'你是物业一线员工的SOP总结卡助手。只返回JSON,不要markdown。固定输出title、steps、objections、scripts、reminders。steps必须且只包含问题、建议、标准三项。', '你是物业一线员工的SOP总结卡助手。只返回JSON,不要markdown。固定输出title、steps、objections、scripts、reminders。steps必须且只包含问题、建议、标准三项。',
'员工问题:{{queryText}}。知识片段:{{context}}。请生成可拆分展示的总结卡,问题写员工当前要处理的核心问题,建议写下一步动作,标准写SOP依据和红线。', '员工问题:{{queryText}}。知识片段:{{context}}。请生成可拆分展示的总结卡,问题写员工当前要处理的核心问题,建议写下一步动作,标准写SOP依据和红线。',
'queryText,context', 'v1', 1, '总结卡固定问题/建议/标准三段式', NOW(), NOW()), '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', ('000000', 'mentor_rewrite', '导师改写', 'practice', 'text',
'你是物业管家带教师傅。只基于本场对话给出一段更稳妥的话术,不评价人格。', '你是物业管家带教师傅。只基于本场对话给出一段更稳妥的话术,不评价人格。',
'场景:{{scenarioName}}。目标:{{goal}}。员工原话:{{traineeText}}。请改写成可直接说出口的版本,80字以内。', '场景:{{scenarioName}}。目标:{{goal}}。员工原话:{{traineeText}}。请改写成可直接说出口的版本,80字以内。',