feat(aihr): on-demand summary card with infographic bottom sheet
P0-2 按甲方钦定交互落地(反馈#2:显式按钮+小窗+预期等待):新端点
POST /api/knowledge/summary-card 复用检索命中片段调 chat 模型
(temperature=0,30s超时)生成结构化卡{title,steps,objections,scripts,
reminders},无命中/无模型/失败均明确报错可重试,不兜假数据;检索
底层拆出 dbHits() 避免总结卡重复触发轻概要 LLM。移动端:概要卡下
「✨生成总结卡·约10秒生成」按钮→底部弹层(拖条+X+深遮罩),四区块
信息图排版(蓝序号四步法/橙块异议/绿引用话术/✓提醒)对照设计基准,
430px 两列 375px 单列;听一遍(独立 TTS 状态)+开始跟读(问练焊接);
SOP 面板配色统一蓝色系。「保存图片」需 html2canvas 依赖本轮不做。
实测:端点4s返回结构化卡;浏览器弹层与 summary-modal 设计图逐区块
一致。
This commit is contained in:
+7
@@ -7,6 +7,8 @@ import org.dromara.aihr.domain.AihrSopDto.LocalImportTaskResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.ProcessingOverviewResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadEnqueueResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadItemResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
@@ -61,6 +63,11 @@ public class AihrSopController {
|
||||
return R.ok(sopSeedService.search(request));
|
||||
}
|
||||
|
||||
@PostMapping("/summary-card")
|
||||
public R<SummaryCardResponse> summaryCard(@RequestBody SummaryCardRequest request) {
|
||||
return R.ok(sopSeedService.summaryCard(request));
|
||||
}
|
||||
|
||||
@PostMapping("/doc/upload")
|
||||
public R<UploadResponse> uploadDoc(@RequestPart("file") MultipartFile file, @RequestParam("category") String category) {
|
||||
return R.ok(sopSeedService.uploadDoc(file, category));
|
||||
|
||||
+12
@@ -10,6 +10,18 @@ public final class AihrSopDto {
|
||||
public record SearchRequest(String queryText, String category, Integer limit) {
|
||||
}
|
||||
|
||||
public record SummaryCardRequest(String queryText, String category) {
|
||||
}
|
||||
|
||||
public record SummaryCardResponse(String title, List<CardStep> steps, List<CardObjection> objections, List<String> scripts, List<String> reminders) {
|
||||
}
|
||||
|
||||
public record CardStep(String label, String desc) {
|
||||
}
|
||||
|
||||
public record CardObjection(String concern, String response) {
|
||||
}
|
||||
|
||||
public record UploadResponse(String docId, Long ossId, String fileName, String category, Integer fragments, String summary, List<String> tags, List<SnippetResponse> snippets) {
|
||||
}
|
||||
|
||||
|
||||
+145
-4
@@ -6,6 +6,8 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.aihr.domain.AihrSopDto.CategoryStatResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.CardObjection;
|
||||
import org.dromara.aihr.domain.AihrSopDto.CardStep;
|
||||
import org.dromara.aihr.domain.AihrSopDto.DocResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.LocalImportItemResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.LocalImportRequest;
|
||||
@@ -21,6 +23,8 @@ import org.dromara.aihr.domain.AihrSopDto.RecordResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SearchResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SnippetResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardRequest;
|
||||
import org.dromara.aihr.domain.AihrSopDto.SummaryCardResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.UploadResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorIndexStatusResponse;
|
||||
import org.dromara.aihr.domain.AihrSopDto.VectorizeResponse;
|
||||
@@ -129,6 +133,55 @@ public class AihrSopSeedService {
|
||||
);
|
||||
}
|
||||
|
||||
public SummaryCardResponse summaryCard(SummaryCardRequest request) {
|
||||
String queryText = request == null ? "" : Optional.ofNullable(request.queryText()).orElse("").trim();
|
||||
if (isBlank(queryText)) {
|
||||
throw new ServiceException("请先输入问题");
|
||||
}
|
||||
String category = request == null ? "sop" : firstNonBlank(request.category(), "sop");
|
||||
List<KnowledgeHit> hits;
|
||||
try {
|
||||
hits = dbHits(dbCategory(category), queryText, 5);
|
||||
} catch (DataAccessException e) {
|
||||
hits = List.of();
|
||||
}
|
||||
if (hits.isEmpty()) {
|
||||
throw new ServiceException("知识库中未检索到相关内容");
|
||||
}
|
||||
Optional<ChatRuntime> runtime = chatRuntime();
|
||||
if (runtime.isEmpty()) {
|
||||
throw new ServiceException("未配置对话模型,无法生成总结卡");
|
||||
}
|
||||
|
||||
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 = callSummaryCardModel(runtime.get(), queryText, context.toString());
|
||||
JsonNode root = objectMapper.readTree(jsonObjectText(content));
|
||||
String title = truncate(root.path("title").asText("").strip(), 14);
|
||||
List<CardStep> steps = jsonCardSteps(root.path("steps"));
|
||||
if (isBlank(title) || steps.isEmpty()) {
|
||||
throw new IllegalStateException("summary card missing title or steps");
|
||||
}
|
||||
return new SummaryCardResponse(
|
||||
title,
|
||||
steps,
|
||||
jsonCardObjections(root.path("objections")),
|
||||
jsonStrings(root.path("scripts"), 3, 90),
|
||||
jsonStrings(root.path("reminders"), 5, 20)
|
||||
);
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.warn("summary card failed: {}", e.getMessage());
|
||||
throw new ServiceException("总结卡生成失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
public UploadResponse uploadDoc(MultipartFile file, String category) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new ServiceException("上传文件不能为空");
|
||||
@@ -1567,10 +1620,7 @@ public class AihrSopSeedService {
|
||||
|
||||
private SearchResponse dbSearch(String category, String queryText, Integer limit) {
|
||||
try {
|
||||
int limitValue = limitValue(limit);
|
||||
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
|
||||
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue);
|
||||
List<KnowledgeHit> hits = rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue));
|
||||
List<KnowledgeHit> hits = dbHits(category, queryText, limit);
|
||||
if (hits.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
@@ -1580,6 +1630,13 @@ public class AihrSopSeedService {
|
||||
}
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> dbHits(String category, String queryText, Integer limit) {
|
||||
int limitValue = limitValue(limit);
|
||||
List<KnowledgeHit> keywordHits = likeHits(category, queryText, limitValue);
|
||||
List<KnowledgeHit> textHits = rrf(keywordHits, fulltextHits(category, queryText, limitValue), limitValue);
|
||||
return rerankHits(queryText, rrf(textHits, vectorHits(category, queryText, limitValue), limitValue));
|
||||
}
|
||||
|
||||
private List<KnowledgeHit> vectorHits(String category, String queryText, int limit) {
|
||||
for (EmbeddingRuntime runtime : embeddingRuntimes()) {
|
||||
try {
|
||||
@@ -1831,6 +1888,54 @@ public class AihrSopSeedService {
|
||||
return choices.get(0).path("message").path("content").asText();
|
||||
}
|
||||
|
||||
private String callSummaryCardModel(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、代码块或解释。
|
||||
输出格式固定为:
|
||||
{"title":"XX总结卡","steps":[{"label":"步骤名","desc":"一句话"}],"objections":[{"concern":"异议","response":"应对"}],"scripts":["可直接开口的完整话术"],"reminders":["注意事项"]}
|
||||
字段约束:title ≤14字;steps 3-5条,label ≤6字,desc ≤20字;objections 2-4条,concern ≤8字,response ≤20字;scripts 2-3条;reminders 3-5条,每条≤20字。
|
||||
硬约束:只能基于给定片段内容归纳,不得编造片段中没有的流程、时限、承诺、金额、政策或话术。
|
||||
""");
|
||||
|
||||
ObjectNode user = messages.addObject();
|
||||
user.put("role", "user");
|
||||
user.put("content", """
|
||||
员工的问题:%s
|
||||
|
||||
检索命中的知识片段:
|
||||
%s
|
||||
""".formatted(queryText, context));
|
||||
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder()
|
||||
.uri(URI.create(normalizeBaseUrl(runtime.baseUrl()) + "/chat/completions"))
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)));
|
||||
if (!isBlank(runtime.apiKey())) {
|
||||
builder.header("Authorization", "Bearer " + runtime.apiKey());
|
||||
}
|
||||
HttpResponse<String> response = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build()
|
||||
.send(builder.build(), HttpResponse.BodyHandlers.ofString());
|
||||
if (!ok(response.statusCode())) {
|
||||
throw new IllegalStateException("summary card HTTP " + response.statusCode());
|
||||
}
|
||||
JsonNode choices = objectMapper.readTree(response.body()).path("choices");
|
||||
if (!choices.isArray() || choices.isEmpty()) {
|
||||
throw new IllegalStateException("summary card response missing choices");
|
||||
}
|
||||
return choices.get(0).path("message").path("content").asText();
|
||||
}
|
||||
|
||||
private static List<String> jsonStrings(JsonNode node, int maxItems, int maxChars) {
|
||||
if (!node.isArray()) {
|
||||
return List.of();
|
||||
@@ -1848,6 +1953,42 @@ public class AihrSopSeedService {
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
private static List<CardStep> jsonCardSteps(JsonNode node) {
|
||||
if (!node.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<CardStep> values = new ArrayList<>();
|
||||
for (JsonNode item : node) {
|
||||
String label = item.path("label").asText("").strip();
|
||||
String desc = item.path("desc").asText("").strip();
|
||||
if (!label.isEmpty() && !desc.isEmpty()) {
|
||||
values.add(new CardStep(truncate(label, 6), truncate(desc, 20)));
|
||||
}
|
||||
if (values.size() >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
private static List<CardObjection> jsonCardObjections(JsonNode node) {
|
||||
if (!node.isArray()) {
|
||||
return List.of();
|
||||
}
|
||||
List<CardObjection> values = new ArrayList<>();
|
||||
for (JsonNode item : node) {
|
||||
String concern = item.path("concern").asText("").strip();
|
||||
String response = item.path("response").asText("").strip();
|
||||
if (!concern.isEmpty() && !response.isEmpty()) {
|
||||
values.add(new CardObjection(truncate(concern, 8), truncate(response, 20)));
|
||||
}
|
||||
if (values.size() >= 4) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return List.copyOf(values);
|
||||
}
|
||||
|
||||
private record SearchDigest(String answer, List<String> keyPoints, List<String> cautions) {
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user