From f545983de830722f03711fb29187df8c1a8ca1f6 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sat, 25 Jul 2026 01:45:41 +0800 Subject: [PATCH] fix(knowledge): bind summary cards to cited evidence --- .../org/dromara/aihr/domain/AihrSopDto.java | 2 +- .../aihr/service/AihrSopSeedService.java | 157 +++++++----------- .../aihr/service/AihrSopSeedServiceTest.java | 89 +++++----- ...aihr_20260725_deepseek_v4_model_mysql8.sql | 30 ++++ ..._answer_partial_evidence_prompt_mysql8.sql | 19 +++ docs/API_INTEGRATION.md | 2 +- docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 2 + frontend/src/views/system/model/index.vue | 4 +- mobile-uni/src/pages/user/sop/index.vue | 17 +- mobile-uni/src/services/knowledge.ts | 7 +- mobile-uni/tests/mobile-user-pages.test.mjs | 9 +- scripts/reset-dev-db.sh | 2 + scripts/tests/aihr-schema-migrations.test.sh | 22 ++- 13 files changed, 211 insertions(+), 151 deletions(-) create mode 100644 backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql create mode 100644 backend/script/sql/update/aihr_20260725_sop_answer_partial_evidence_prompt_mysql8.sql diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java index ba2d5f39..662e1e4c 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/domain/AihrSopDto.java @@ -32,7 +32,7 @@ public final class AihrSopDto { public record AuthorizedKnowledgeHit(Long fragmentId, String title, String content) { } - public record SummaryCardRequest(String queryText, String category) { + public record SummaryCardRequest(String queryText, String category, List fragmentIds) { } public record SopReviewRequest(Boolean usable, String reviewer, String note) { 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 96d948f7..e23d3d4a 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 @@ -115,11 +115,12 @@ public class AihrSopSeedService { private static final Set FEEDBACK_REASON_CODES = Set.of( "no_answer", "not_specific", "wrong_reference", "outdated", "not_applicable" ); - private static final String SOP_ANSWER_PROMPT_VERSION = "builtin-sop_answer-v1"; + private static final String SOP_ANSWER_PROMPT_VERSION = "builtin-sop_answer-v2"; private static final String SOP_ANSWER_SYSTEM_PROMPT = "你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。" - + "answer 必须结论先行,60字内直接告诉员工现在怎么做;keyPoints 必须是3到5条可执行步骤,每条以「一、」「二、」「三、」「四、」「五、」开头,包含责任人、动作、时限或留痕要求;" + + "answer 必须结论先行,60字内直接告诉员工片段能够支持的做法;keyPoints 必须是1到5条有依据的可执行要点,每条以「一、」「二、」「三、」「四、」「五、」开头;" + "cautions 是0到3条注意事项或红线。引用片段由系统后置展示,不要在 answer 中堆大段原文。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;" - + "若片段无法回答问题,answer 固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。"; + + "只要片段包含与问题相关的操作、适用范围、用量或安全要求,就回答这些已支持内容,并明确缺少的完整流程;" + + "只有全部片段与问题完全无关时,answer 才固定为「" + NO_CLEAR_SOP_EVIDENCE + "」且 keyPoints 为空数组。"; private static final String SOP_ANSWER_USER_TEMPLATE = """ 员工的问题:{{queryText}} @@ -531,19 +532,10 @@ public class AihrSopSeedService { if (isBlank(queryText)) { throw new ServiceException("请先输入问题"); } - String category = request == null ? "sop" : firstNonBlank(request.category(), "sop"); - List hits; - try { - hits = dbHits(dbCategory(category), queryText, 5, allowedKnowledgeIds); - } catch (DataAccessException e) { - hits = List.of(); - } - if (hits.isEmpty()) { - throw new ServiceException("知识库中未检索到相关内容"); - } + List hits = summaryCardHits(requiredSummaryFragmentIds(request), allowedKnowledgeIds); Optional runtime = chatRuntime(); if (runtime.isEmpty()) { - return evidenceSummaryCard(queryText, hits); + throw new ServiceException("总结卡模型未配置或未启用,请联系管理员"); } StringBuilder context = new StringBuilder(); @@ -557,8 +549,8 @@ public class AihrSopSeedService { JsonNode root = objectMapper.readTree(jsonObjectText(content)); String title = truncate(root.path("title").asText("").strip(), 14); List steps = jsonCardSteps(root.path("steps")); - if (isBlank(title) || steps.isEmpty()) { - throw new IllegalStateException("summary card missing title or steps"); + if (isBlank(title) || !hasRequiredSummarySteps(steps)) { + throw new IllegalStateException("summary card response does not match the required structure"); } return new SummaryCardResponse( title, @@ -568,98 +560,69 @@ public class AihrSopSeedService { jsonStrings(root.path("reminders"), 5, 20) ); } catch (Exception e) { - log.warn("summary card model unavailable, falling back to retrieved evidence: {}", e.getClass().getSimpleName()); - return evidenceSummaryCard(queryText, hits); + log.warn("summary card model failed: {}", e.getClass().getSimpleName()); + throw new ServiceException("总结卡生成暂不可用,请稍后重试"); } } - /** - * 模型未配置或暂时不可用时,直接把已命中的 SOP 原文整理成卡片。 - * 该降级路径只摘录检索证据,不生成话术或异议回应,避免把推测内容冒充制度要求。 - */ - static SummaryCardResponse evidenceSummaryCard(String queryText, List hits) { - List steps = new ArrayList<>(); - Set seenEvidence = new LinkedHashSet<>(); - Set sourceTitles = new LinkedHashSet<>(); - List relevantHits = summaryEvidenceHits(queryText, hits); - if (relevantHits.isEmpty()) { - throw new ServiceException("检索片段与问题相关度不足,暂不能生成总结卡"); - } - for (KnowledgeHit hit : relevantHits) { - if (!isBlank(hit.title())) { - sourceTitles.add(hit.title().strip()); - } - String content = Optional.ofNullable(hit.content()).orElse(""); - for (String candidate : content.split("[。!?!?;;\\r\\n]+")) { - String evidence = candidate - .replaceFirst("^[\\s\\-—–•·*\\d一二三四五六七八九十、.()()]+", "") - .replaceAll("\\s+", " ") - .strip(); - if (evidence.length() < 4 || !seenEvidence.add(evidence)) { - continue; - } - steps.add(new CardStep("原文" + (steps.size() + 1), truncate(evidence, 40))); - if (steps.size() >= 5) { - break; - } - } - if (steps.size() >= 5) { - break; - } - } - if (steps.isEmpty()) { - throw new ServiceException("检索片段中没有可生成总结卡的文字内容"); - } - - String normalizedQuery = Optional.ofNullable(queryText).orElse("") - .replaceAll("[\\s??!!。]+", "") - .strip(); - String title = isBlank(normalizedQuery) ? "SOP原文总结卡" : truncate(normalizedQuery, 14); - List reminders = sourceTitles.stream() - .limit(3) - .map(source -> "来源:" + truncate(source, 15)) - .toList(); - return new SummaryCardResponse(title, List.copyOf(steps), List.of(), List.of(), reminders); + static boolean hasRequiredSummarySteps(List steps) { + return Optional.ofNullable(steps).orElse(List.of()).stream().map(CardStep::label).toList() + .equals(List.of("问题", "建议", "标准")); } - private static List summaryEvidenceHits(String queryText, List hits) { - List candidates = Optional.ofNullable(hits).orElse(List.of()).stream().limit(5).toList(); - if (candidates.isEmpty()) { - return List.of(); + static List requiredSummaryFragmentIds(SummaryCardRequest request) { + LinkedHashSet ids = new LinkedHashSet<>(); + for (Long id : Optional.ofNullable(request == null ? null : request.fragmentIds()).orElse(List.of())) { + if (id != null && id > 0) { + ids.add(id); + } } - Set queryBigrams = evidenceBigrams(queryText); - if (queryBigrams.isEmpty()) { - return List.of(candidates.get(0)); + if (ids.isEmpty()) { + throw new ServiceException("总结卡需要当前回答的引用片段,请重新查询"); } - Map scores = new LinkedHashMap<>(); - int bestScore = 0; - for (KnowledgeHit hit : candidates) { - String evidence = evidenceText(firstNonBlank(hit.title(), "") + " " + firstNonBlank(hit.content(), "")); - int score = (int) queryBigrams.stream().filter(evidence::contains).count(); - scores.put(hit, score); - bestScore = Math.max(bestScore, score); + if (ids.size() > 5) { + throw new ServiceException("总结卡最多使用 5 条引用片段"); } - if (bestScore == 0) { - return List.of(); - } - int threshold = bestScore >= 3 ? Math.max(2, (bestScore + 1) / 2) : bestScore; - return scores.entrySet().stream() - .filter(entry -> entry.getValue() >= threshold) - .limit(MAX_EVIDENCE_SNIPPETS) - .map(Map.Entry::getKey) - .toList(); + return List.copyOf(ids); } - private static Set evidenceBigrams(String value) { - String normalized = evidenceText(value); - if (normalized.length() < 2) { - return Set.of(); + private List summaryCardHits(List fragmentIds, Set allowedKnowledgeIds) { + String placeholders = String.join(",", java.util.Collections.nCopies(fragmentIds.size(), "?")); + String sql = """ + select f.id as fragment_id, + coalesce(a.name, i.name) as title, + i.name as category, + coalesce(i.description, '') as description, + f.doc_id, + f.content, + f.idx + from aihr_knowledge_fragment f + join aihr_knowledge_info i on i.id = f.knowledge_id and i.tenant_id = f.tenant_id + left join aihr_knowledge_attach a on a.knowledge_id = f.knowledge_id and a.doc_id = f.doc_id and a.tenant_id = f.tenant_id + where f.tenant_id = ? + and f.id in (%s) + %s + """.formatted(placeholders, knowledgeScopeSql("f", allowedKnowledgeIds)); + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(fragmentIds); + addKnowledgeScopeArgs(args, allowedKnowledgeIds); + List rows = jdbcTemplate.query(sql, (rs, rowNum) -> new KnowledgeHit( + rs.getLong("fragment_id"), + rs.getString("title"), + rs.getString("category"), + rs.getString("description"), + rs.getString("doc_id"), + rs.getString("content"), + rs.getInt("idx"), + 1.0 + ), args.toArray()); + Map byId = rows.stream() + .collect(java.util.stream.Collectors.toMap(KnowledgeHit::fragmentId, hit -> hit)); + if (byId.size() != fragmentIds.size()) { + throw new ServiceException("引用片段已失效或无权访问,请重新查询后再生成总结卡", 403); } - Set bigrams = new LinkedHashSet<>(); - for (int index = 0; index < normalized.length() - 1; index++) { - bigrams.add(normalized.substring(index, index + 2)); - } - return Set.copyOf(bigrams); + return fragmentIds.stream().map(byId::get).toList(); } @Transactional(rollbackFor = Exception.class) diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java index a62a19ac..93293d19 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrSopSeedServiceTest.java @@ -519,56 +519,61 @@ public class AihrSopSeedServiceTest { @Test @Tag("dev") - public void summaryCardFallsBackToRetrievedEvidenceWithoutInventingScripts() { - List hits = List.of( - new AihrSopSeedService.KnowledgeHit( - 1L, - "投诉处理 SOP.pdf", - "sop", - "", - "doc-1", - "先记录业主诉求、房号和联系方式。30分钟内反馈首个处理节点,并保留工单记录。", - 1, - 1.0 - ), - new AihrSopSeedService.KnowledgeHit( - 2L, - "报修跟进 SOP.pdf", - "sop", - "", - "doc-2", - "派单后确认责任人和完成时限;复查通过后再关闭工单。", - 1, - 0.9 - ) - ); + public void summaryCardRequiresBoundEvidenceFragments() { + ServiceException error = assertThrows(ServiceException.class, + () -> AihrSopSeedService.requiredSummaryFragmentIds( + new AihrSopDto.SummaryCardRequest("厕所打扫的 SOP 是什么?", "sop", List.of()) + )); - AihrSopDto.SummaryCardResponse response = AihrSopSeedService.evidenceSummaryCard( - "业主投诉漏水,第一步怎么处理?", - hits - ); - - assertFalse(response.title().isBlank()); - assertTrue(response.steps().size() >= 2); - assertTrue(response.steps().stream().anyMatch(step -> step.desc().contains("记录业主诉求"))); - assertFalse(response.steps().stream().anyMatch(step -> step.desc().contains("确认责任人"))); - assertTrue(response.scripts().isEmpty()); - assertTrue(response.objections().isEmpty()); - assertEquals(List.of("来源:投诉处理 SOP.pdf"), response.reminders()); + assertTrue(error.getMessage().contains("引用片段")); } @Test @Tag("dev") - public void summaryCardRejectsUnrelatedEvidenceInsteadOfPackagingFirstHit() { - List hits = List.of( - new AihrSopSeedService.KnowledgeHit(1L, "信息安全制度.pdf", "sop", "", "doc-1", - "员工电脑必须启用屏幕锁定并定期更新密码。", 1, 1.0) - ); + public void summaryCardDeduplicatesAndCapsBoundEvidenceFragments() { + assertEquals(List.of(10L, 20L), AihrSopSeedService.requiredSummaryFragmentIds( + new AihrSopDto.SummaryCardRequest( + "厕所打扫的 SOP 是什么?", + "sop", + Arrays.asList(10L, null, 10L, 20L) + ) + )); ServiceException error = assertThrows(ServiceException.class, - () -> AihrSopSeedService.evidenceSummaryCard("停车费催缴怎么沟通?", hits)); + () -> AihrSopSeedService.requiredSummaryFragmentIds( + new AihrSopDto.SummaryCardRequest( + "厕所打扫的 SOP 是什么?", + "sop", + List.of(1L, 2L, 3L, 4L, 5L, 6L) + ) + )); - assertTrue(error.getMessage().contains("相关度不足")); + assertTrue(error.getMessage().contains("最多使用 5 条")); + } + + @Test + @Tag("dev") + public void summaryCardRequestCarriesCurrentAnswerFragmentIds() { + AihrSopDto.SummaryCardRequest request = new AihrSopDto.SummaryCardRequest( + "厕所打扫的 SOP 是什么?", + "sop", + List.of(101293L, 105626L) + ); + + assertEquals(List.of(101293L, 105626L), request.fragmentIds()); + } + + @Test + @Tag("dev") + public void summaryCardRequiresProblemSuggestionAndStandardInOrder() { + assertTrue(AihrSopSeedService.hasRequiredSummarySteps(List.of( + new AihrSopDto.CardStep("问题", "待处理问题"), + new AihrSopDto.CardStep("建议", "下一步动作"), + new AihrSopDto.CardStep("标准", "SOP依据") + ))); + assertFalse(AihrSopSeedService.hasRequiredSummarySteps(List.of( + new AihrSopDto.CardStep("原文1", "无结构片段") + ))); } @Test diff --git a/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql b/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql new file mode 100644 index 00000000..1cbd0e74 --- /dev/null +++ b/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql @@ -0,0 +1,30 @@ +-- DeepSeek 当前接口仅接受 deepseek-v4-flash / deepseek-v4-pro。 +-- 默认切换到已验证可返回结构化 JSON 的 flash;若目标配置已存在,则保留目标配置并停用旧名称。 + +UPDATE aihr_model_config legacy +LEFT JOIN aihr_model_config current + ON current.tenant_id = legacy.tenant_id + AND current.category = legacy.category + AND current.model_name = 'deepseek-v4-flash' +SET legacy.model_name = 'deepseek-v4-flash', + legacy.update_time = NOW() +WHERE legacy.category = 'chat' + AND legacy.provider_code = 'deepseek' + AND legacy.model_name = 'deepseek-chat' + AND current.id IS NULL; + +UPDATE aihr_model_config legacy +JOIN aihr_model_config current + ON current.tenant_id = legacy.tenant_id + AND current.category = legacy.category + AND current.model_name = 'deepseek-v4-flash' +SET current.enabled = GREATEST(current.enabled, legacy.enabled), + current.model_show = IF(legacy.model_show = 'Y', 'Y', current.model_show), + current.update_time = NOW(), + legacy.enabled = 0, + legacy.model_show = 'N', + legacy.update_time = NOW() +WHERE legacy.category = 'chat' + AND legacy.provider_code = 'deepseek' + AND legacy.model_name = 'deepseek-chat' + AND legacy.id <> current.id; diff --git a/backend/script/sql/update/aihr_20260725_sop_answer_partial_evidence_prompt_mysql8.sql b/backend/script/sql/update/aihr_20260725_sop_answer_partial_evidence_prompt_mysql8.sql new file mode 100644 index 00000000..b2e9c2ea --- /dev/null +++ b/backend/script/sql/update/aihr_20260725_sop_answer_partial_evidence_prompt_mysql8.sql @@ -0,0 +1,19 @@ +-- 只要命中片段含有可执行依据,就回答已支持的部分;仅在片段完全无关时返回无依据。 +-- 新增 v2,不覆盖租户自定义版本;可重复执行。 + +INSERT INTO `aihr_prompt_template` + (`tenant_id`, `code`, `name`, `category`, `output_format`, `system_prompt`, `template`, `variables`, `version`, `enabled`, `description`, `create_time`, `update_time`) +VALUES +('000000', 'sop_answer', 'SOP问答直给', 'knowledge', 'json_answer', + '你是物业一线员工的SOP助手。只返回 JSON,不要 markdown。字段固定为 answer, keyPoints, cautions。answer 必须结论先行,60字内直接告诉员工片段能够支持的做法;keyPoints 必须是1到5条有依据的可执行要点,每条以「一、」「二、」「三、」「四、」「五、」开头;cautions 是0到3条注意事项或红线。引用片段由系统后置展示,不要在 answer 中堆大段原文。硬约束:只能基于给定片段内容作答,片段中没有的信息不得编造;只要片段包含与问题相关的操作、适用范围、用量或安全要求,就回答这些已支持内容,并明确缺少的完整流程;只有全部片段与问题完全无关时,answer 才固定为「未在已入库 SOP 中找到明确依据」且 keyPoints 为空数组。', + '员工的问题:{{queryText}}\n\n检索命中的 SOP 片段:\n{{context}}', + 'queryText,context', 'v2', 1, 'SOP问答支持部分证据并明确缺口', NOW(), NOW()) +ON DUPLICATE KEY UPDATE + `name` = VALUES(`name`), + `category` = VALUES(`category`), + `system_prompt` = VALUES(`system_prompt`), + `template` = VALUES(`template`), + `variables` = VALUES(`variables`), + `enabled` = VALUES(`enabled`), + `description` = VALUES(`description`), + `update_time` = NOW(); diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index f90a181a..b0d6e197 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -123,7 +123,7 @@ SOP 文档上传第三片已经落最小后端边界: | 向量库状态与重建 | `GET /api/knowledge/doc/vector-index-status`、`POST /api/knowledge/doc/rebuild-vector-index` | 模型配置页展示当前 vector 模型维度、Qdrant collection 维度、点数和片段向量数;维度不一致时可一键清空旧 embedding、删除 collection 并按当前模型重建 | | Qdrant 向量索引 | 同一上传接口内机会性执行 | embedding 写入 MySQL 后尽力 upsert 到 Qdrant;同名文档替换会尽力删除旧 points;Qdrant 不可用不影响上传和 MySQL 检索;外部 embedding 成功但 collection 维度不一致时不静默降级为 `local-hash-v1`,通过状态接口和重建入口处理 | | 混合检索 | `POST /api/knowledge/search` | 先跑中文关键词 `LIKE` 打分和 MySQL Fulltext,再生成 query embedding 走 Qdrant,RRF 融合后交给 `category='rerank'` 模型(如硅基流动 bge-reranker-v2-m3)按语义相关性重排;rerank 未配置或失败保持 RRF 顺序,Qdrant 或外部向量接口不可用时保留关键词/全文检索。命中后若 chat 模型可用,自动生成结构化轻概要写入响应 `answer/keyPoints/cautions`(≤60字直答+3-6要点+0-3注意;temperature=0,15s 超时,硬约束不得编造、答不了明示无依据);概要失败回退片段原文拼接,检索不报错 | -| 检索总结卡 | `POST /api/knowledge/summary-card`(JSON `{queryText, category}`) | 按需生成信息图式总结卡 `{title, steps[], objections[], scripts[], reminders[]}`:复用检索命中片段(最多5条)调 chat 模型,temperature=0、30s 超时;无命中/无 chat 模型/生成失败均抛明确错误供前端重试,不兜假数据。移动端查SOP结果页「生成总结卡」按钮触发,底部弹层展示并可 TTS 朗读 | +| 检索总结卡 | `POST /api/knowledge/summary-card`(JSON `{queryText, category, fragmentIds}`) | `fragmentIds` 必须来自当前回答且最多5条;服务端按当前身份与知识空间重新鉴权后,只使用这些引用片段生成 `{title, steps[], objections[], scripts[], reminders[]}`,不再二次检索。chat 模型未配置、调用失败或未返回固定「问题/建议/标准」三段结构时抛明确错误供前端重试,不把原文冒充核心步骤。移动端查SOP结果页「生成总结卡」按钮触发,底部弹层展示并可 TTS 朗读 | | 解析状态聚合 | `GET /api/knowledge/processing/overview` | 聚合 `aihr_knowledge_attach.status`、fragment 数、embedding 数、`sys_oss.ext1.fileSize`,生成资料处理页指标、分类、任务、链路和事件列表 | | 服务端目录导入(运维) | `POST /api/knowledge/doc/import-local` | JSON `{ directory, category, limit }`;`directory` 只能是 `AIHR_IMPORT_ROOT` / `aihr.import.root` 下的相对目录,默认根目录为 `./.data/import`;逐文件复用上传解析链路,同步执行,仅保留给运维/调试,不在资料处理页暴露 | | 服务端导入任务(运维) | `POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks`、`POST /api/knowledge/doc/import-tasks/{id}/cancel` | 启动后台目录导入并返回任务;任务写入 `aihr_knowledge_import_task`,调用方可查询总数、成功数、失败数、当前文件和进度,运行中任务可取消;仅保留给运维/调试,不在资料处理页暴露 | diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index bc064a69..233e1f00 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -104,6 +104,8 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_broadcast_attachment_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260722_broadcast_question_context_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260724_agent_orchestration_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260725_sop_answer_partial_evidence_prompt_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/aihr_personal_knowledge_mysql8.sql ``` diff --git a/frontend/src/views/system/model/index.vue b/frontend/src/views/system/model/index.vue index 86fa1cb4..2cb5221b 100644 --- a/frontend/src/views/system/model/index.vue +++ b/frontend/src/views/system/model/index.vue @@ -262,7 +262,7 @@ - + @@ -460,7 +460,7 @@ const seedModels: ModelConfig[] = [ { id: -1, category: 'chat', - modelName: 'deepseek-chat', + modelName: 'deepseek-v4-flash', providerCode: 'deepseek', modelDescribe: '用于通用对话与 RAG 生成', modelShow: 'Y', diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue index 90310bb9..6e8f98bf 100644 --- a/mobile-uni/src/pages/user/sop/index.vue +++ b/mobile-uni/src/pages/user/sop/index.vue @@ -161,7 +161,7 @@ - @@ -1332,11 +1332,15 @@ const buildSummaryCard = async (msg: MasterMsg) => { } if (summaryLoading.value) return; const value = (msg.result.queryText || '').trim(); - if (!value) return; + const fragmentIds = summaryFragmentIds(msg); + if (!value || !fragmentIds.length) { + pushSystem('当前回答没有可用于总结卡的引用片段,请重新提问'); + return; + } summaryLoading.value = true; summaryCard.value = null; try { - summaryCard.value = await generateSummaryCard(value); + summaryCard.value = await generateSummaryCard({ queryText: value, fragmentIds }); } catch (error) { pushSystem(requestErrorText(error, '总结卡生成失败')); } finally { @@ -1344,6 +1348,13 @@ const buildSummaryCard = async (msg: MasterMsg) => { } }; +const summaryFragmentIds = (msg: MasterMsg) => Array.from(new Set( + (msg.result.snippets || []) + .filter((item) => !item.sourceType || item.sourceType === 'DOCUMENT') + .map((item) => item.fragmentId) + .filter((id): id is number => typeof id === 'number' && Number.isSafeInteger(id) && id > 0) +)).slice(0, 5); + const closeSummary = () => { if (speechState.value.key === 'summary') speechPlayback.stop(); summaryCard.value = null; diff --git a/mobile-uni/src/services/knowledge.ts b/mobile-uni/src/services/knowledge.ts index 25a58a3c..f81eefe7 100644 --- a/mobile-uni/src/services/knowledge.ts +++ b/mobile-uni/src/services/knowledge.ts @@ -250,13 +250,14 @@ export const submitAnswerFeedback = (params: { timeout: 15000 }); -export const generateSummaryCard = (queryText: string) => +export const generateSummaryCard = (params: { queryText: string; fragmentIds: number[] }) => apiRequest({ url: '/api/knowledge/summary-card', method: 'POST', data: { - queryText, - category: 'sop' + queryText: params.queryText, + category: 'sop', + fragmentIds: params.fragmentIds }, timeout: 45000 }); diff --git a/mobile-uni/tests/mobile-user-pages.test.mjs b/mobile-uni/tests/mobile-user-pages.test.mjs index 79cf3229..0d1b8e2e 100644 --- a/mobile-uni/tests/mobile-user-pages.test.mjs +++ b/mobile-uni/tests/mobile-user-pages.test.mjs @@ -286,13 +286,20 @@ test('超大字模式下今日安排将文字和操作按钮纵向排布', async }); test('问师傅页保留搜索框、答案区和总结卡入口', async () => { - const source = await pageSource('../src/pages/user/sop/index.vue'); + const [source, service] = await Promise.all([ + pageSource('../src/pages/user/sop/index.vue'), + pageSource('../src/services/knowledge.ts') + ]); assert.match(source, /query-panel/); assert.match(source, /answer-panel/); assert.match(source, /summary-panel/); assert.match(source, /MY_PRACTICE_SUMMARY/); assert.match(source, /TEAM_PRACTICE_SUMMARY/); + assert.match(source, /const summaryFragmentIds = \(msg: MasterMsg\)/); + assert.match(source, /generateSummaryCard\(\{ queryText: value, fragmentIds \}\)/); + assert.match(service, /fragmentIds: number\[\]/); + assert.match(service, /fragmentIds: params\.fragmentIds/); }); test('SOP 没解决先收集结构化原因再提交', async () => { diff --git a/scripts/reset-dev-db.sh b/scripts/reset-dev-db.sh index f0b2f168..7747efe9 100755 --- a/scripts/reset-dev-db.sh +++ b/scripts/reset-dev-db.sh @@ -22,7 +22,9 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_personal_knowledge_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql" +docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql" +docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260725_sop_answer_partial_evidence_prompt_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260723_practice_content_operations_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/update/aihr_20260724_practice_growth_curriculum_mysql8.sql" diff --git a/scripts/tests/aihr-schema-migrations.test.sh b/scripts/tests/aihr-schema-migrations.test.sh index f1e50f27..11fe3858 100755 --- a/scripts/tests/aihr-schema-migrations.test.sh +++ b/scripts/tests/aihr-schema-migrations.test.sh @@ -74,8 +74,28 @@ CREATE TABLE aihr_prompt_template ( SQL mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_prompt_template_seed_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260714_prompt_template_seed_mysql8.sql" +partial_evidence_prompt_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260725_sop_answer_partial_evidence_prompt_mysql8.sql" +mysql "$DB" < "$partial_evidence_prompt_migration" +mysql "$DB" < "$partial_evidence_prompt_migration" prompt_template_count="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM aihr_prompt_template WHERE tenant_id='000000';")" -test "$prompt_template_count" = '6' +test "$prompt_template_count" = '7' +partial_evidence_prompt_count="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM aihr_prompt_template WHERE tenant_id='000000' AND code='sop_answer' AND version='v2' AND enabled=1 AND system_prompt LIKE '%只要片段包含与问题相关的操作%';")" +test "$partial_evidence_prompt_count" = '1' + +mysql "$DB" < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql" +mysql "$DB" <<'SQL' +INSERT INTO aihr_model_config + (tenant_id, category, model_name, provider_code, model_show, enabled, create_time, update_time) +VALUES + ('000000', 'chat', 'deepseek-chat', 'deepseek', 'Y', 1, NOW(), NOW()), + ('100001', 'chat', 'deepseek-chat', 'deepseek', 'Y', 1, NOW(), NOW()), + ('100001', 'chat', 'deepseek-v4-flash', 'deepseek', 'N', 0, NOW(), NOW()); +SQL +deepseek_model_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260725_deepseek_v4_model_mysql8.sql" +mysql "$DB" < "$deepseek_model_migration" +mysql "$DB" < "$deepseek_model_migration" +deepseek_model_rows="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(CONCAT(tenant_id, ':', model_name, ':', enabled, ':', model_show) ORDER BY tenant_id, model_name) FROM aihr_model_config WHERE provider_code='deepseek';")" +test "$deepseek_model_rows" = '000000:deepseek-v4-flash:1:Y,100001:deepseek-chat:0:N,100001:deepseek-v4-flash:1:Y' mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql" mysql "$DB" < "$ROOT_DIR/backend/script/sql/update/aihr_20260715_knowledge_feedback_mysql8.sql"