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:
+56
-15
@@ -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字以内。',
|
||||||
|
|||||||
Reference in New Issue
Block a user