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:
2026-07-05 03:21:37 +08:00
parent 9af439dc2a
commit 617847f7e0
4 changed files with 773 additions and 6 deletions
@@ -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));
@@ -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) {
}
@@ -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) {
}