From 064816a784dad535f2320c4321f73f2e6e364d69 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sat, 18 Jul 2026 21:22:49 +0800 Subject: [PATCH] feat(knowledge): add short-term ask memory --- AGENTS.md | 4 +- README.md | 4 +- .../AihrKnowledgeQueryController.java | 24 +- .../domain/AihrKnowledgeQueryDto.java | 29 +- .../AihrKnowledgeConversationService.java | 371 ++++++++++++++++++ .../service/AihrKnowledgeQueryService.java | 181 +++++++-- .../aihr/learning/AihrLearningService.java | 2 +- .../AihrKnowledgeQueryServiceTest.java | 116 +++++- .../AihrKnowledgeConversationServiceTest.java | 108 +++++ .../learning/AihrLearningContractTest.java | 2 + .../AihrKnowledgePlatformSchemaTest.java | 7 +- backend/script/sql/aihr_knowledge_mysql8.sql | 16 + .../aihr_20260717_learning_closure_mysql8.sql | 2 +- ...20260718_knowledge_conversation_mysql8.sql | 17 + ...260718_release_collation_compat_mysql8.sql | 2 + .../问师傅多轮会话与原始资料交付设计.md | 15 +- docs/API_INTEGRATION.md | 2 +- docs/BACKLOG.md | 4 - docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md | 3 +- docs/KNOWLEDGE_PLATFORM_RUNBOOK.md | 16 +- docs/README.md | 2 +- .../src/components/chat/ChatComposer.vue | 4 +- mobile-uni/src/pages/user/practice/index.vue | 6 - mobile-uni/src/pages/user/sop/index.vue | 225 ++++++++++- mobile-uni/src/pages/user/today/index.vue | 1 - mobile-uni/src/services/knowledge.ts | 42 +- mobile-uni/src/services/work-report.ts | 1 - mobile-uni/src/types/api.ts | 24 ++ mobile-uni/tests/ask-memory.test.mjs | 27 ++ mobile-uni/tests/mobile-user-pages.test.mjs | 52 ++- scripts/release-preflight.sh | 3 +- scripts/tests/aihr-release-collation.test.sh | 3 +- scripts/verify-ask-memory-local.mjs | 132 +++++++ 33 files changed, 1348 insertions(+), 99 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationService.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationServiceTest.java create mode 100644 backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql create mode 100644 mobile-uni/tests/ask-memory.test.mjs create mode 100644 scripts/verify-ask-memory-local.mjs diff --git a/AGENTS.md b/AGENTS.md index 48576785..4a68fb5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ ## 当前业务边界 - 管理端业务路由集中在 `frontend/src/router/index.ts`,覆盖首页、AI 面试、训练运营、问题榜、案例、知识空间、SOP、资料处理、系统配置、组织权限和成长激励;侧栏过滤在 `frontend/src/layout/components/Sidebar/index.vue`,不要恢复若依默认全量菜单。 -- 用户侧只改 `mobile-uni/src/pages/*`;员工、候选人、主管首页分别为 `/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index`,旧 `/h5/user|candidate|supervisor|employee|admin` 只做兼容重定向。底栏固定「今日/练/问/我」;问师傅文字和现场附件查询复用 `/api/knowledge/query`、`/api/knowledge/query-media`,总结卡复用 `/api/knowledge/summary-card`,失败不兜假数据。 +- 用户侧只改 `mobile-uni/src/pages/*`;员工、候选人、主管首页分别为 `/pages/user/today/index`、`/pages/candidate/index/index`、`/pages/supervisor/index/index`,旧 `/h5/user|candidate|supervisor|employee|admin` 只做兼容重定向。底栏固定「今日/练/问/我」;问师傅文字和现场附件查询复用 `/api/knowledge/query`、`/api/knowledge/query-media`,内部登录端可带 `conversationId/contextVersion` 使用 30 分钟、最近 6 轮的短期会话,原文件/视频只通过 `/api/knowledge/resources/{attachmentId}/content` 重新鉴权交付;总结卡复用 `/api/knowledge/summary-card`,失败不兜假数据。外部知识 API 保持无状态。 - 不要把三端概括成全模态:员工对练/每日题和问师傅支持文字/语音,问师傅图片/视频只用于本次分析,案例传音频;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管写文字复盘/指派并回听录音。员工对练图像/视频情境、服务录像回放和方言正式验收仍未完成;语音继续复用 `services/speech.ts` 与 `/api/ai/asr`。 - 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。 - 全网 AI 与企业问师傅必须分入口、分来源和分免责声明;提供方仅允许公网 HTTPS,修改地址/密钥后必须连接测试成功才能启用,未配置时明确不可用且不生成假答案。 @@ -66,7 +66,7 @@ - 向量库状态接口是 `GET /api/knowledge/doc/vector-index-status`,重建接口是 `POST /api/knowledge/doc/rebuild-vector-index`;外部 embedding 成功但 Qdrant collection 维度不一致时不要静默降级,走状态提示和重建。 - 资料处理接口是 `GET /api/knowledge/processing/overview`,直接聚合 `aihr_knowledge_attach`、`aihr_knowledge_fragment` 和 `sys_oss.ext1`;页面只保留“批量导入”,走 `POST /api/knowledge/doc/upload-async`,不要恢复“选择目录”“服务端导入”或目录导入任务面板。`POST /api/knowledge/doc/import-local`、`POST /api/knowledge/doc/import-local-task`、`GET /api/knowledge/doc/import-tasks` 和 `POST /api/knowledge/doc/import-tasks/{id}/cancel` 仅保留为后端运维/调试接口;目录只能是 `aihr.import.root` 下的相对路径。 - `ruoyi-ai` 只按 `docs/RUOYI_AI_INCREMENTAL_MIGRATION.md` 分片迁移到 `ruoyi-aihr`,不要整包搬 `ruoyi-chat`;已落地页面保持真实接口/模型优先与 seed fallback,不能退回静态壳。 -- 本项目 AI 表前缀用 `aihr_*`:知识库是 `aihr_knowledge_info/attach/fragment`,上传队列是 `aihr_knowledge_upload_item`,导入任务是 `aihr_knowledge_import_task`,模型配置是 `aihr_model_provider/config`,移动端训练记录是 `aihr_practice_session`,候选人补充资料是 `aihr_candidate_material`,组织人员快照是 `aihr_org_snapshot`(本地 reset 有 Demo seed;生产/试点从 `docs/open-org-sync-api-design-v1.md` 对应开放组织系统同步,入口 `POST /api/aihr/org/sync`)。 +- 本项目 AI 表前缀用 `aihr_*`:知识库是 `aihr_knowledge_info/attach/fragment`,问师傅短期会话是 `aihr_knowledge_conversation`,上传队列是 `aihr_knowledge_upload_item`,导入任务是 `aihr_knowledge_import_task`,模型配置是 `aihr_model_provider/config`,移动端训练记录是 `aihr_practice_session`,候选人补充资料是 `aihr_candidate_material`,组织人员快照是 `aihr_org_snapshot`(本地 reset 有 Demo seed;生产/试点从 `docs/open-org-sync-api-design-v1.md` 对应开放组织系统同步,入口 `POST /api/aihr/org/sync`)。 ## 业务文档 diff --git a/README.md b/README.md index 1da07648..ab3a1401 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ - 品牌:前端标题为“物业AI人力资源系统”,侧栏 logo 使用银城物业图标。 - 基础链路:租户列表、验证码、`000000 / admin / admin123` 加密登录已验证。 - 管理端:除 AI 面试、训练、案例、SOP 和资料处理外,已包含常见难题、问题榜运营、知识空间、全网 AI 配置、组织权限和成长激励。 -- 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;已接手机号登录、问师傅文字/语音/附件与播报、三级字号、训练/题目反馈、开放问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式。旧 `mobile/` 只用于历史回溯。 +- 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;已接手机号登录、问师傅文字/语音/附件与播报、30 分钟短期多轮追问、原文件/视频受控交付、三级字号、训练/题目反馈、开放问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式。旧 `mobile/` 只用于历史回溯。 - 英雄路径:AI 面试(真模型出题/评分)、三角色对练(真 LLM 客户回复+评分,asr/tts 配置后支持语音)、案例沉淀(真实语音上传+ASR 转写+模型整理)均为真模型优先、未配置或失败回退本地兜底;SOP 知识库已接中文关键词 + MySQL Fulltext + Qdrant 混合召回 + rerank 重排,支持 txt/md/PDF/Word/Excel/PPT/图片/视频上传解析入库(图片走 vision OCR,视频走 ffmpeg 抽音轨转写+关键帧描述),资料处理中心支持 ZIP 异步展开后逐文件入队;模型配置页可查看向量库维度/点数并一键重建。 - 资料处理:已新增解析任务状态页,按 `aihr_knowledge_attach` 和 `aihr_knowledge_fragment` 展示等待解析、解析中、已完成、失败、片段数和向量化状态;任务列表按当前筛选结果前端分页(10/20/50 条,筛选或刷新回到第一页)。页面只保留“批量导入”,通过异步队列处理多文件或 ZIP,不提供浏览器目录选择和服务端目录导入入口。后端目录导入接口仅保留为运维/调试能力。 - 组织人员:`aihr_org_snapshot` 本地 reset 保留演示 seed;生产通过 `POST /api/aihr/org/sync` 同步外部组织。最近记录的生产快照为 3417 人、2938 人在职、3392 个手机号映射、175 名在职主管和 20 名项目经理;人数属于时点事实,发布或验收前应重新查询。 @@ -52,7 +52,7 @@ | [本地运行手册](docs/DEV_SETUP.md) | 端口、账号、启动和基础验证 | | [MVP 演示与验收清单](docs/DEMO_ACCEPTANCE.md) | MVP 演示脚本、录屏兜底和验收口径 | | [后端 API 对接指南](docs/API_INTEGRATION.md) | 后端模块落点、正式试点 CSV、移动端训练/复盘、候选资料上传/审核和 SOP 上传解析接口 | -| [问师傅多轮会话与原始资料交付设计](docs/20260718/问师傅多轮会话与原始资料交付设计.md) | 当前单轮 RAG 缺口、短期会话、指代改写、意图路由、原文件/视频安全交付及验收边界 | +| [问师傅多轮会话与原始资料交付设计](docs/20260718/问师傅多轮会话与原始资料交付设计.md) | 已实现的短期会话、指代改写、意图路由、原文件/视频安全交付及分层验收记录 | | [BRD 功能审查记录](docs/BRD_IMPLEMENTATION_AUDIT.md) | 现阶段结论、三端输入匹配矩阵、权限、迁移、线上资源和未完成项证据 | | [BRD 生产迁移 Runbook](docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md) | 生产 schema/内置内容迁移、只读预检、发布和回滚边界 | | [ruoyi-ai 能力分片迁移计划](docs/RUOYI_AI_INCREMENTAL_MIGRATION.md) | 知识库、模型能力、文档解析、RAG、chat 的渐进迁移边界 | diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java index a59d6548..3a437ca6 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/controller/AihrKnowledgeQueryController.java @@ -1,12 +1,16 @@ package org.dromara.aihr.knowledge.controller; import cn.dev33.satoken.annotation.SaCheckLogin; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; import org.dromara.common.core.domain.R; +import org.dromara.system.service.ISysOssService; import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -15,6 +19,7 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; import java.util.List; @RestController @@ -23,6 +28,7 @@ import java.util.List; public class AihrKnowledgeQueryController { private final AihrKnowledgeQueryService queryService; + private final ISysOssService ossService; @SaCheckLogin @PostMapping("/query") @@ -34,13 +40,27 @@ public class AihrKnowledgeQueryController { @PostMapping(value = "/query-media", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public R queryMedia(@RequestPart("file") MultipartFile file, @RequestParam(value = "queryText", required = false) String queryText, - @RequestParam(value = "position", required = false) String position) { + @RequestParam(value = "position", required = false) String position, + @RequestParam(value = "conversationId", required = false) String conversationId, + @RequestParam(value = "contextVersion", required = false) Long contextVersion) { String question = queryText == null || queryText.isBlank() ? "请根据附件现场情况,告诉我应该按什么 SOP 处理" : queryText.trim(); QueryRequest request = new QueryRequest( - question, List.of(), "sop", position, "mobile_uni_media", 5, null + question, List.of(), "sop", position, "mobile_uni_media", 5, null, + conversationId, contextVersion ); return R.ok(queryService.queryInternalMedia(request, file)); } + + @SaCheckLogin + @GetMapping("/resources/{attachmentId}/content") + public void resource(@PathVariable Long attachmentId, HttpServletResponse response) throws IOException { + Long ossId = queryService.authorizedResourceOssId(attachmentId); + if (ossId == null) { + response.sendError(HttpServletResponse.SC_NOT_FOUND, "资料不存在或无权访问"); + return; + } + ossService.download(ossId, response); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java index 5f473390..9c7482cf 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/domain/AihrKnowledgeQueryDto.java @@ -16,8 +16,14 @@ public final class AihrKnowledgeQueryDto { String position, String source, Integer limit, - String toolCode + String toolCode, + String conversationId, + Long contextVersion ) { + public QueryRequest(String queryText, List spaceCodes, String category, String position, + String source, Integer limit, String toolCode) { + this(queryText, spaceCodes, category, position, source, limit, toolCode, null, null); + } } public record Citation( @@ -30,6 +36,14 @@ public final class AihrKnowledgeQueryDto { ) { } + public record Resource( + Long attachmentId, + String title, + String type, + String contentUrl + ) { + } + public record QueryResponse( String requestId, String queryText, @@ -39,7 +53,18 @@ public final class AihrKnowledgeQueryDto { boolean noEvidence, String promptVersion, SearchResponse legacy, - Object data + Object data, + String conversationId, + Long contextVersion, + String intent, + String rewrittenQuery, + List resources ) { + public QueryResponse(String requestId, String queryText, String answer, List citations, + List usedSpaceCodes, boolean noEvidence, String promptVersion, + SearchResponse legacy, Object data) { + this(requestId, queryText, answer, citations, usedSpaceCodes, noEvidence, promptVersion, + legacy, data, null, null, "QA", queryText, List.of()); + } } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationService.java new file mode 100644 index 00000000..9705a034 --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationService.java @@ -0,0 +1,371 @@ +package org.dromara.aihr.knowledge.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; +import org.dromara.aihr.service.AihrModelSeedService; +import org.dromara.aihr.service.AihrSensitiveText; +import org.dromara.common.core.constant.HttpStatus; +import org.dromara.common.core.exception.ServiceException; +import org.springframework.dao.DataAccessException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AihrKnowledgeConversationService { + + private static final int MAX_TURNS = 6; + private static final int MAX_QUESTION = 600; + private static final int MAX_ANSWER = 800; + private static final int MAX_MODEL_CONTEXT = 6000; + private static final long CLEANUP_INTERVAL_MILLIS = 5 * 60 * 1000L; + private static final Pattern CONVERSATION_ID = Pattern.compile("[A-Za-z0-9_-]{8,64}"); + private static final Set INTENTS = Set.of("QA", "FILE", "VIDEO", "DATA_TOOL"); + + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper; + private final AihrModelSeedService modelService; + private final AtomicLong nextCleanupAt = new AtomicLong(); + + public ConversationContext prepare(AihrKnowledgePrincipal principal, AuthenticatedApp app, QueryRequest request) { + String conversationId = clean(request.conversationId()); + if (conversationId.isBlank()) { + return stateless(request.queryText(), request.toolCode()); + } + if (!CONVERSATION_ID.matcher(conversationId).matches()) { + throw new ServiceException("conversationId 格式无效", HttpStatus.BAD_REQUEST); + } + + ConversationRow row; + try { + row = findRow(principal, app, conversationId); + purgeExpiredLazily(principal, app, conversationId); + } catch (DataAccessException ex) { + log.warn("knowledge conversation unavailable, using stateless query(处理错误已隐藏)"); + RewritePlan plan = fallbackPlan(List.of(), request.queryText(), request.toolCode()); + return new ConversationContext(conversationId, false, false, 0L, List.of(), + plan.rewrittenQuery(), plan.intent(), plan.toolCode()); + } + + LocalDateTime now = LocalDateTime.now(); + boolean expired = row != null && (row.expiresTime() == null || !row.expiresTime().isAfter(now)); + long storedVersion = row == null ? 0L : row.version(); + Long expectedVersion = request.contextVersion(); + if (!expired && row != null && (expectedVersion == null || expectedVersion != storedVersion)) { + throw conflict(); + } + + List turns = row == null || expired ? List.of() : readTurns(row.contextJson()); + RewritePlan plan = rewrite(turns, request.queryText(), request.toolCode()); + return new ConversationContext(conversationId, true, row != null, storedVersion, turns, + plan.rewrittenQuery(), plan.intent(), plan.toolCode()); + } + + public static ConversationContext stateless(String queryText, String explicitToolCode) { + RewritePlan plan = fallbackPlan(List.of(), queryText, explicitToolCode); + return new ConversationContext(null, false, false, 0L, List.of(), + plan.rewrittenQuery(), plan.intent(), plan.toolCode()); + } + + public Long append(AihrKnowledgePrincipal principal, AuthenticatedApp app, ConversationContext context, + String question, String answer, List citations) { + if (!context.stateful()) { + return null; + } + List turns = new ArrayList<>(context.turns()); + List docIds = citations == null ? List.of() : citations.stream() + .map(Citation::docId).filter(AihrKnowledgeConversationService::hasText).distinct().limit(5).toList(); + List titles = citations == null ? List.of() : citations.stream() + .map(Citation::title).filter(AihrKnowledgeConversationService::hasText) + .map(title -> sanitize(title, 200)).distinct().limit(5).toList(); + turns.add(new Turn( + sanitize(question, MAX_QUESTION), + sanitize(context.rewrittenQuery(), MAX_QUESTION), + context.intent(), + sanitize(answer, MAX_ANSWER), + docIds, + titles + )); + if (turns.size() > MAX_TURNS) { + turns = new ArrayList<>(turns.subList(turns.size() - MAX_TURNS, turns.size())); + } + + long nextVersion = context.storedVersion() + 1L; + try { + String json = objectMapper.writeValueAsString(turns); + int updated; + if (context.existingRow()) { + updated = jdbcTemplate.update(""" + update aihr_knowledge_conversation + set version = ?, context_json = ?, expires_time = date_add(now(), interval 30 minute), + update_time = now() + where tenant_id = ? and app_id = ? and user_id = ? and conversation_id = ? and version = ? + """, nextVersion, json, principal.tenantId(), app.id(), principal.userId(), + context.conversationId(), context.storedVersion()); + } else { + updated = jdbcTemplate.update(""" + insert into aihr_knowledge_conversation + (tenant_id, app_id, user_id, conversation_id, version, context_json, + expires_time, create_time, update_time) + values (?, ?, ?, ?, ?, ?, date_add(now(), interval 30 minute), now(), now()) + """, principal.tenantId(), app.id(), principal.userId(), context.conversationId(), + nextVersion, json); + } + if (updated != 1) { + throw conflict(); + } + return nextVersion; + } catch (DataIntegrityViolationException ex) { + throw conflict(); + } catch (ServiceException ex) { + throw ex; + } catch (Exception ex) { + log.warn("knowledge conversation persistence failed, answer remains stateless(处理错误已隐藏)"); + return null; + } + } + + private ConversationRow findRow(AihrKnowledgePrincipal principal, AuthenticatedApp app, String conversationId) { + List rows = jdbcTemplate.query(""" + select version, context_json, expires_time + from aihr_knowledge_conversation + where tenant_id = ? and app_id = ? and user_id = ? and conversation_id = ? + limit 1 + """, (rs, rowNum) -> new ConversationRow( + rs.getLong("version"), + rs.getString("context_json"), + toLocalDateTime(rs.getTimestamp("expires_time")) + ), principal.tenantId(), app.id(), principal.userId(), conversationId); + return rows.isEmpty() ? null : rows.get(0); + } + + private void purgeExpiredLazily(AihrKnowledgePrincipal principal, AuthenticatedApp app, String conversationId) { + long now = System.currentTimeMillis(); + long due = nextCleanupAt.get(); + if (now < due || !nextCleanupAt.compareAndSet(due, now + CLEANUP_INTERVAL_MILLIS)) { + return; + } + try { + jdbcTemplate.update(""" + delete from aihr_knowledge_conversation + where expires_time <= now() + and not (tenant_id = ? and app_id = ? and user_id = ? and conversation_id = ?) + order by expires_time + limit 500 + """, principal.tenantId(), app.id(), principal.userId(), conversationId); + } catch (DataAccessException ex) { + log.debug("knowledge conversation cleanup unavailable(处理错误已隐藏)"); + } + } + + private RewritePlan rewrite(List turns, String queryText, String explicitToolCode) { + RewritePlan fallback = fallbackPlan(turns, queryText, explicitToolCode); + if (turns.isEmpty() || hasText(explicitToolCode)) { + return fallback; + } + String history; + try { + history = objectMapper.writeValueAsString(turns); + } catch (Exception ex) { + return fallback; + } + String userPrompt = truncate("历史对话:" + history + "\n当前问题:" + queryText, MAX_MODEL_CONTEXT); + String systemPrompt = """ + 你是企业知识问答的查询改写器。只输出 JSON,不要解释: + {"rewrittenQuery":"可独立检索的问题","intent":"QA|FILE|VIDEO|DATA_TOOL","toolCode":null} + 只能依据历史对话消解“这个、那份、原文件、相关视频”等指代,不得补造主题。 + FILE 表示索要原始文件,VIDEO 表示索要操作视频,DATA_TOOL 只允许 MY_PRACTICE_SUMMARY 或 TEAM_PRACTICE_SUMMARY。 + 其他问题一律 QA。rewrittenQuery 不超过 600 字。 + """; + return modelService.tryChat(systemPrompt, userPrompt, 0.0) + .map(this::parsePlan) + .filter(java.util.Objects::nonNull) + .orElse(fallback); + } + + private RewritePlan parsePlan(String raw) { + try { + int start = raw.indexOf('{'); + int end = raw.lastIndexOf('}'); + if (start < 0 || end <= start) { + return null; + } + JsonNode root = objectMapper.readTree(raw.substring(start, end + 1)); + String rewritten = clean(root.path("rewrittenQuery").asText()); + String intent = clean(root.path("intent").asText()).toUpperCase(Locale.ROOT); + String toolCode = clean(root.path("toolCode").asText()); + if (rewritten.isBlank() || rewritten.length() > MAX_QUESTION || !INTENTS.contains(intent)) { + return null; + } + if ("DATA_TOOL".equals(intent)) { + toolCode = allowedTool(toolCode); + if (toolCode == null) { + return null; + } + } else { + toolCode = null; + } + return new RewritePlan(rewritten, intent, toolCode); + } catch (Exception ex) { + return null; + } + } + + private List readTurns(String json) { + if (!hasText(json)) { + return List.of(); + } + try { + List turns = objectMapper.readValue(json, new TypeReference<>() { }); + if (turns == null || turns.isEmpty()) { + return List.of(); + } + return turns.size() <= MAX_TURNS ? List.copyOf(turns) + : List.copyOf(turns.subList(turns.size() - MAX_TURNS, turns.size())); + } catch (Exception ex) { + log.warn("knowledge conversation context is invalid, resetting current session(处理错误已隐藏)"); + return List.of(); + } + } + + static RewritePlan fallbackPlan(List turns, String rawQuery, String explicitToolCode) { + String query = clean(rawQuery); + String explicit = allowedTool(explicitToolCode); + if (explicit != null) { + return new RewritePlan(query, "DATA_TOOL", explicit); + } + String intent = fallbackIntent(query); + String toolCode = "DATA_TOOL".equals(intent) ? fallbackTool(query) : null; + if ("DATA_TOOL".equals(intent) && toolCode == null) { + intent = "QA"; + } + String rewritten = query; + if (!turns.isEmpty() && looksLikeFollowup(query, intent)) { + Turn last = turns.get(turns.size() - 1); + String topic = hasText(last.rewrittenQuery()) ? last.rewrittenQuery() : last.question(); + if (hasText(topic)) { + rewritten = truncate(topic + ";用户追问:" + query, MAX_QUESTION); + } + } + return new RewritePlan(rewritten, intent, toolCode); + } + + private static String fallbackIntent(String query) { + String value = clean(query).toLowerCase(Locale.ROOT); + if (containsAny(value, "视频", "录像", "演示片", "操作片")) { + return "VIDEO"; + } + if (containsAny(value, "原文件", "源文件", "原文", "附件", "文件给我", "下载文件", "发我文件")) { + return "FILE"; + } + if (fallbackTool(value) != null) { + return "DATA_TOOL"; + } + return "QA"; + } + + private static String fallbackTool(String query) { + String value = clean(query); + if (containsAny(value, "团队训练概况", "团队训练情况", "团队完训")) { + return "TEAM_PRACTICE_SUMMARY"; + } + if (containsAny(value, "我的训练概况", "我的训练情况", "我训练了", "我的完训")) { + return "MY_PRACTICE_SUMMARY"; + } + return null; + } + + private static String allowedTool(String value) { + String tool = clean(value).toUpperCase(Locale.ROOT); + return "MY_PRACTICE_SUMMARY".equals(tool) || "TEAM_PRACTICE_SUMMARY".equals(tool) ? tool : null; + } + + private static boolean looksLikeFollowup(String query, String intent) { + return query.length() <= 100 && ("FILE".equals(intent) || "VIDEO".equals(intent) + || containsAny(query, "这个", "这份", "那个", "那份", "刚才", "上面", "对应", "相关", "它", "那怎么办")); + } + + private static boolean containsAny(String value, String... needles) { + for (String needle : needles) { + if (value.contains(needle)) { + return true; + } + } + return false; + } + + private static ServiceException conflict() { + return new ServiceException("对话上下文已更新,请刷新后重试", HttpStatus.CONFLICT); + } + + private static String sanitize(String value, int max) { + return truncate(AihrSensitiveText.forModel(clean(value)), max); + } + + private static LocalDateTime toLocalDateTime(Timestamp timestamp) { + return timestamp == null ? null : timestamp.toLocalDateTime(); + } + + private static String truncate(String value, int max) { + String text = value == null ? "" : value; + return text.length() <= max ? text : text.substring(0, max); + } + + private static String clean(String value) { + return value == null ? "" : value.trim(); + } + + private static boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + public record ConversationContext( + String conversationId, + boolean stateful, + boolean existingRow, + long storedVersion, + List turns, + String rewrittenQuery, + String intent, + String toolCode + ) { + } + + public record Turn( + String question, + String rewrittenQuery, + String intent, + String answer, + List docIds, + List titles + ) { + public Turn { + docIds = docIds == null ? List.of() : List.copyOf(docIds); + titles = titles == null ? List.of() : List.copyOf(titles); + } + } + + record RewritePlan(String rewrittenQuery, String intent, String toolCode) { + } + + private record ConversationRow(long version, String contextJson, LocalDateTime expiresTime) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java index 8023d489..25f100cd 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/knowledge/service/AihrKnowledgeQueryService.java @@ -9,6 +9,8 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Citation; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryResponse; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.Resource; +import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService.ToolResult; import org.dromara.aihr.service.AihrSopSeedService; import org.dromara.common.core.constant.HttpStatus; @@ -37,17 +39,24 @@ public class AihrKnowledgeQueryService { private final AihrKnowledgeQueryAuditService auditService; private final JdbcTemplate jdbcTemplate; private final AihrKnowledgeDataToolService dataToolService; + private final AihrKnowledgeConversationService conversationService; public QueryResponse queryInternal(QueryRequest rawRequest) { QueryRequest request = validate(rawRequest); AihrKnowledgePrincipal principal = principalResolver.current(); AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); - if (hasText(request.toolCode())) { - return queryDataTool(principal, app, request); + ConversationContext context = conversationContext(principal, app, request); + QueryRequest routed = routedRequest(request, context); + Set spaceIds = Set.of(); + QueryResponse response; + if (hasText(routed.toolCode())) { + response = queryDataTool(principal, app, routed, request.queryText()); + } else { + spaceIds = accessService.resolveInternalSpaceIds( + principal, app, request.spaceCodes(), "READ"); + response = queryDocuments(principal, app, spaceIds, routed, request.queryText()); } - Set spaceIds = accessService.resolveInternalSpaceIds( - principal, app, request.spaceCodes(), "READ"); - return queryDocuments(principal, app, spaceIds, request); + return complete(principal, app, spaceIds, request.queryText(), context, response); } public QueryResponse queryInternalMedia(QueryRequest rawRequest, MultipartFile file) { @@ -59,36 +68,59 @@ public class AihrKnowledgeQueryService { AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); Set spaceIds = accessService.resolveInternalSpaceIds( principal, app, request.spaceCodes(), "READ"); + ConversationContext context = conversationContext(principal, app, request); String extracted = sopService.extractQuestionMedia(file, request.queryText()); - String context = extracted == null ? "" : extracted.trim(); - if (context.isBlank()) { + String extractedContext = extracted == null ? "" : extracted.trim(); + if (extractedContext.isBlank()) { throw new ServiceException("附件中没有可用于查询的现场信息", HttpStatus.BAD_REQUEST); } - String combinedQuery = truncate(request.queryText(), 400) - + "\n附件现场信息:" + truncate(context, 520); + String combinedQuery = truncate(context.rewrittenQuery(), 400) + + "\n附件现场信息:" + truncate(extractedContext, 520); QueryResponse response = queryDocuments(principal, app, spaceIds, new QueryRequest( combinedQuery, request.spaceCodes(), request.category(), request.position(), - request.source(), request.limit(), null - )); - return new QueryResponse( - response.requestId(), request.queryText(), response.answer(), response.citations(), - response.usedSpaceCodes(), response.noEvidence(), response.promptVersion(), response.legacy(), response.data() - ); + request.source(), request.limit(), null, request.conversationId(), request.contextVersion() + ), request.queryText()); + return complete(principal, app, spaceIds, request.queryText(), context, response); } public QueryResponse queryExternal(AuthenticatedApp app, QueryRequest rawRequest) { QueryRequest request = validate(rawRequest); - if (hasText(request.toolCode())) { - throw new ServiceException("外部调用应用不能使用内部数据工具", HttpStatus.FORBIDDEN); + if (hasText(request.toolCode()) || hasText(request.conversationId()) || request.contextVersion() != null) { + throw new ServiceException("外部调用应用不支持数据工具或对话状态", HttpStatus.FORBIDDEN); } return TenantHelper.dynamic(app.tenantId(), () -> { Set spaceIds = accessService.resolveExternalSpaceIds(app, request.spaceCodes()); - return queryDocuments(null, app, spaceIds, request); + return queryDocuments(null, app, spaceIds, request, request.queryText()); }); } + public Long authorizedResourceOssId(Long attachmentId) { + if (attachmentId == null || attachmentId <= 0) { + return null; + } + AihrKnowledgePrincipal principal = principalResolver.current(); + AuthenticatedApp app = appService.requireSessionApp(principal.tenantId(), principal.clientKey()); + Set spaceIds = accessService.resolveInternalSpaceIds(principal, app, List.of(), "READ"); + if (spaceIds.isEmpty()) { + return null; + } + List args = new ArrayList<>(); + args.add(principal.tenantId()); + args.add(attachmentId); + args.addAll(spaceIds); + List rows = jdbcTemplate.queryForList(""" + select a.oss_id + from aihr_knowledge_attach a + join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id + where a.tenant_id = ? and a.id = ? and a.oss_id is not null and a.status = 2 + and a.knowledge_id in (%s) and k.status = 'ACTIVE' + limit 1 + """.formatted(placeholders(spaceIds.size())), Long.class, args.toArray()); + return rows.isEmpty() ? null : rows.get(0); + } + private QueryResponse queryDocuments(AihrKnowledgePrincipal principal, AuthenticatedApp app, - Set spaceIds, QueryRequest request) { + Set spaceIds, QueryRequest request, String displayQuery) { String requestId = UUID.randomUUID().toString().replace("-", ""); long started = System.nanoTime(); List scopeCodes = spaceCodes(app.tenantId(), spaceIds); @@ -100,12 +132,12 @@ public class AihrKnowledgeQueryService { boolean noEvidence = citations.isEmpty(); String status = noEvidence ? "NO_EVIDENCE" : "SUCCESS"; long latency = elapsedMillis(started); - auditService.record(requestId, principal, app, request.queryText(), scopeCodes, + auditService.record(requestId, principal, app, displayQuery, scopeCodes, citations.isEmpty() ? List.of() : List.of("DOCUMENT"), status, latency, legacy.promptVersion()); - return new QueryResponse(requestId, request.queryText(), legacy.answer(), citations, scopeCodes, + return new QueryResponse(requestId, displayQuery, legacy.answer(), citations, scopeCodes, noEvidence, legacy.promptVersion(), legacy, null); } catch (RuntimeException ex) { - auditService.record(requestId, principal, app, request.queryText(), scopeCodes, List.of(), + auditService.record(requestId, principal, app, displayQuery, scopeCodes, List.of(), ex instanceof ServiceException serviceEx && serviceEx.getCode() != null && serviceEx.getCode() < 500 ? "REJECTED" : "FAILED", elapsedMillis(started), null); @@ -113,18 +145,19 @@ public class AihrKnowledgeQueryService { } } - private QueryResponse queryDataTool(AihrKnowledgePrincipal principal, AuthenticatedApp app, QueryRequest request) { + private QueryResponse queryDataTool(AihrKnowledgePrincipal principal, AuthenticatedApp app, QueryRequest request, + String displayQuery) { String requestId = UUID.randomUUID().toString().replace("-", ""); long started = System.nanoTime(); try { ToolResult result = dataToolService.execute(principal, request.toolCode()); Citation citation = new Citation(null, "DATA_TOOL", result.toolCode(), result.title(), result.window(), null); - auditService.record(requestId, principal, app, request.queryText(), List.of(), + auditService.record(requestId, principal, app, displayQuery, List.of(), List.of("DATA_TOOL:" + result.toolCode()), "SUCCESS", elapsedMillis(started), "data-tool-v1"); - return new QueryResponse(requestId, request.queryText(), result.answer(), List.of(citation), List.of(), + return new QueryResponse(requestId, displayQuery, result.answer(), List.of(citation), List.of(), false, "data-tool-v1", null, result.data()); } catch (RuntimeException ex) { - auditService.record(requestId, principal, app, request.queryText(), List.of(), + auditService.record(requestId, principal, app, displayQuery, List.of(), List.of("DATA_TOOL:" + request.toolCode()), ex instanceof ServiceException serviceEx && serviceEx.getCode() != null && serviceEx.getCode() < 500 ? "REJECTED" : "FAILED", @@ -133,6 +166,83 @@ public class AihrKnowledgeQueryService { } } + private QueryResponse complete(AihrKnowledgePrincipal principal, AuthenticatedApp app, Set spaceIds, + String question, ConversationContext context, QueryResponse response) { + List resources = resources(app.tenantId(), spaceIds, response.citations(), context.intent()); + String answer = resourceAnswer(context.intent(), resources, response.answer()); + boolean noEvidence = isResourceIntent(context.intent()) ? resources.isEmpty() : response.noEvidence(); + Long nextVersion = context.stateful() + ? conversationService.append(principal, app, context, question, answer, response.citations()) + : null; + if (nextVersion == null && context.stateful()) { + nextVersion = context.storedVersion(); + } + return new QueryResponse( + response.requestId(), question, answer, response.citations(), response.usedSpaceCodes(), noEvidence, + response.promptVersion(), response.legacy(), response.data(), context.conversationId(), nextVersion, + context.intent(), context.rewrittenQuery(), resources + ); + } + + private List resources(String tenantId, Set spaceIds, List citations, String intent) { + if (!isResourceIntent(intent) || spaceIds.isEmpty() || citations == null || citations.isEmpty()) { + return List.of(); + } + List docIds = citations.stream().map(Citation::docId) + .filter(AihrKnowledgeQueryService::hasText).distinct().toList(); + if (docIds.isEmpty()) { + return List.of(); + } + List args = new ArrayList<>(); + args.add(tenantId); + args.addAll(spaceIds); + args.addAll(docIds); + List rows = jdbcTemplate.query(""" + select a.id, a.name + from aihr_knowledge_attach a + join aihr_knowledge_info k on k.id = a.knowledge_id and k.tenant_id = a.tenant_id + where a.tenant_id = ? and a.knowledge_id in (%s) and a.doc_id in (%s) + and a.oss_id is not null and a.status = 2 and k.status = 'ACTIVE' + order by a.id + """.formatted(placeholders(spaceIds.size()), placeholders(docIds.size())), + (rs, rowNum) -> new AttachmentResource(rs.getLong("id"), rs.getString("name")), args.toArray()); + return rows.stream() + .filter(row -> "VIDEO".equals(intent) == isVideo(row.title())) + .map(row -> new Resource(row.attachmentId(), row.title(), intent, + "/api/knowledge/resources/" + row.attachmentId() + "/content")) + .distinct() + .limit(5) + .toList(); + } + + private static String resourceAnswer(String intent, List resources, String fallback) { + if ("FILE".equals(intent)) { + return resources.isEmpty() + ? "当前授权资料中未找到可交付的相关原文件。" + : "已找到相关原文件,请点击下方资料卡查看或下载。"; + } + if ("VIDEO".equals(intent)) { + return resources.isEmpty() + ? "当前授权资料中未找到相关操作视频。" + : "已找到相关操作视频,请点击下方视频卡查看。"; + } + return fallback; + } + + private ConversationContext conversationContext(AihrKnowledgePrincipal principal, AuthenticatedApp app, + QueryRequest request) { + return hasText(request.conversationId()) + ? conversationService.prepare(principal, app, request) + : AihrKnowledgeConversationService.stateless(request.queryText(), request.toolCode()); + } + + private static QueryRequest routedRequest(QueryRequest request, ConversationContext context) { + return new QueryRequest( + context.rewrittenQuery(), request.spaceCodes(), request.category(), request.position(), request.source(), + request.limit(), context.toolCode(), request.conversationId(), request.contextVersion() + ); + } + private List citations(String tenantId, Set allowedSpaceIds, List snippets) { if (snippets == null || snippets.isEmpty()) { return List.of(); @@ -193,7 +303,11 @@ public class AihrKnowledgeQueryService { if (codes.size() > 20) { throw new ServiceException("单次最多选择 20 个知识空间", HttpStatus.BAD_REQUEST); } - return new QueryRequest(query, codes, request.category(), request.position(), request.source(), limit, request.toolCode()); + if (request.contextVersion() != null && request.contextVersion() < 0) { + throw new ServiceException("contextVersion 不能小于 0", HttpStatus.BAD_REQUEST); + } + return new QueryRequest(query, codes, request.category(), request.position(), request.source(), limit, + request.toolCode(), request.conversationId(), request.contextVersion()); } private static long elapsedMillis(long started) { @@ -212,4 +326,17 @@ public class AihrKnowledgeQueryService { private static boolean hasText(String value) { return value != null && !value.isBlank(); } + + private static boolean isResourceIntent(String intent) { + return "FILE".equals(intent) || "VIDEO".equals(intent); + } + + private static boolean isVideo(String title) { + String value = title == null ? "" : title.toLowerCase(java.util.Locale.ROOT); + return value.endsWith(".mp4") || value.endsWith(".mov") || value.endsWith(".avi") + || value.endsWith(".mkv") || value.endsWith(".webm") || value.endsWith(".m4v"); + } + + private record AttachmentResource(Long attachmentId, String title) { + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningService.java index e6ac7d11..0d2351f3 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/learning/AihrLearningService.java @@ -402,7 +402,7 @@ public class AihrLearningService { KEY idx_aihr_question_feedback_verdict (tenant_id, verdict, update_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci """); - ensureAssignmentColumn("content_version", "ALTER TABLE aihr_practice_assignment ADD COLUMN content_version varchar(30) DEFAULT 'v1' AFTER request_key"); + ensureAssignmentColumn("content_version", "ALTER TABLE aihr_practice_assignment ADD COLUMN content_version varchar(30) DEFAULT 'v1'"); ensureAssignmentColumn("question_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN question_snapshot varchar(1000) DEFAULT NULL AFTER content_version"); ensureAssignmentColumn("reference_answer_snapshot", "ALTER TABLE aihr_practice_assignment ADD COLUMN reference_answer_snapshot varchar(2000) DEFAULT NULL AFTER question_snapshot"); ensureAssignmentColumn("content_hash", "ALTER TABLE aihr_practice_assignment ADD COLUMN content_hash char(64) DEFAULT NULL AFTER reference_answer_snapshot"); diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java index 7f6c7643..853547e0 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/AihrKnowledgeQueryServiceTest.java @@ -2,6 +2,7 @@ package org.dromara.aihr.knowledge; 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.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; @@ -11,24 +12,30 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryAuditService; import org.dromara.aihr.knowledge.service.AihrKnowledgeQueryService; import org.dromara.aihr.knowledge.service.AihrKnowledgeDataToolService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService; +import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext; import org.dromara.aihr.service.AihrSopSeedService; import org.dromara.common.core.exception.ServiceException; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; import org.springframework.mock.web.MockMultipartFile; import java.util.List; import java.util.Set; +import java.sql.ResultSet; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.eq; @Tag("dev") class AihrKnowledgeQueryServiceTest { @@ -101,7 +108,7 @@ class AihrKnowledgeQueryServiceTest { List.of(), List.of(), List.of(), List.of(), "test-v1", null)); var service = new AihrKnowledgeQueryService(mock(AihrKnowledgePrincipalResolver.class), mock(AihrKnowledgeAppService.class), access, sop, mock(AihrKnowledgeQueryAuditService.class), jdbc, - mock(AihrKnowledgeDataToolService.class)); + mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class)); var result = service.queryExternal(app, new QueryRequest("公开服务", List.of("mt_customer_service"), null, null, "miniapp", 5, null)); @@ -121,7 +128,8 @@ class AihrKnowledgeQueryServiceTest { when(appService.requireSessionApp("000000", "app")).thenReturn(app); when(tools.execute(principal, "MY_PRACTICE_SUMMARY")).thenThrow(new ServiceException("tool failed", 500)); var service = new AihrKnowledgeQueryService(resolver, appService, mock(AihrKnowledgeAccessService.class), sop, - mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), tools); + mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), tools, + mock(AihrKnowledgeConversationService.class)); assertThrows(ServiceException.class, () -> service.queryInternal( new QueryRequest("我的训练", List.of(), null, null, "mobile", 5, "MY_PRACTICE_SUMMARY"))); @@ -148,7 +156,8 @@ class AihrKnowledgeQueryServiceTest { new SearchResponse("现场问题", "sop", "按电梯困人流程处理", "", List.of(), List.of(), List.of(), List.of(), List.of(), List.of(), "test-v1", null)); var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, - mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class)); + mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), + mock(AihrKnowledgeConversationService.class)); var result = service.queryInternalMedia( new QueryRequest("电梯这里该怎么办", List.of(), "sop", "保安", "mobile_uni_media", 5, null), @@ -161,6 +170,104 @@ class AihrKnowledgeQueryServiceTest { assertEquals(true, requestCaptor.getValue().queryText().contains("电梯门无法打开")); } + @Test + void fileFollowupUsesConversationRewriteAndReturnsOnlyAuthorizedAttachment() throws Exception { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var access = mock(AihrKnowledgeAccessService.class); + var sop = mock(AihrSopSeedService.class); + var jdbc = mock(JdbcTemplate.class); + var conversations = mock(AihrKnowledgeConversationService.class); + var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", + Set.of("employee"), Set.of(), "app"); + var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + var request = new QueryRequest("把这个 SOP 原文件给我", List.of(), "sop", "保安", + "mobile_uni_sop", 5, null, "conversation_123", 1L); + var context = new ConversationContext("conversation_123", true, true, 1L, List.of(), + "自动扶梯困人应急操作 SOP 原文件", "FILE", null); + when(resolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "app")).thenReturn(app); + when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L)); + when(conversations.prepare(principal, app, request)).thenReturn(context); + when(conversations.append(any(), any(), any(), anyString(), anyString(), any())).thenReturn(2L); + when(jdbc.queryForList(any(String.class), any(Class.class), any(Object[].class))) + .thenReturn(List.of("yc_property_sop")); + when(sop.searchAuthorized(any(), any(), any())).thenReturn( + new SearchResponse("原文件", "sop", "已命中", "", List.of(), + List.of(new SnippetResponse("扶梯 SOP", "立即停梯", 88L)), + List.of(), List.of(), List.of(), List.of(), "test-v1", null)); + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { + String sql = invocation.getArgument(0); + RowMapper mapper = invocation.getArgument(1); + ResultSet rs = mock(ResultSet.class); + if (sql.contains("from aihr_knowledge_fragment")) { + when(rs.getString("space_code")).thenReturn("yc_property_sop"); + when(rs.getString("doc_id")).thenReturn("lift-doc"); + when(rs.getString("title")).thenReturn("自动扶梯困人应急操作 SOP.pdf"); + when(rs.getString("content")).thenReturn("立即停梯并设置警示"); + when(rs.getLong("fragment_id")).thenReturn(88L); + } else { + when(rs.getLong("id")).thenReturn(321L); + when(rs.getString("name")).thenReturn("自动扶梯困人应急操作 SOP.pdf"); + } + return List.of(mapper.mapRow(rs, 0)); + }); + var service = new AihrKnowledgeQueryService(resolver, appService, access, sop, + mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), conversations); + + var result = service.queryInternal(request); + + var searchCaptor = ArgumentCaptor.forClass(SearchRequest.class); + verify(sop).searchAuthorized(searchCaptor.capture(), any(), any()); + assertEquals("自动扶梯困人应急操作 SOP 原文件", searchCaptor.getValue().queryText()); + assertEquals("FILE", result.intent()); + assertEquals(2L, result.contextVersion()); + assertEquals(1, result.resources().size()); + assertEquals(321L, result.resources().get(0).attachmentId()); + assertEquals("/api/knowledge/resources/321/content", result.resources().get(0).contentUrl()); + } + + @Test + void resourceDownloadRecomputesCurrentApplicationAndPrincipalScope() { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var access = mock(AihrKnowledgeAccessService.class); + var jdbc = mock(JdbcTemplate.class); + var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", + Set.of("employee"), Set.of(), "app"); + var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + when(resolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "app")).thenReturn(app); + when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of(9L)); + when(jdbc.queryForList(anyString(), eq(Long.class), any(Object[].class))).thenReturn(List.of(777L)); + var service = new AihrKnowledgeQueryService(resolver, appService, access, mock(AihrSopSeedService.class), + mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), + mock(AihrKnowledgeConversationService.class)); + + assertEquals(777L, service.authorizedResourceOssId(321L)); + verify(access).resolveInternalSpaceIds(principal, app, List.of(), "READ"); + } + + @Test + void resourceDownloadFailsClosedWhenCurrentScopeIsEmpty() { + var resolver = mock(AihrKnowledgePrincipalResolver.class); + var appService = mock(AihrKnowledgeAppService.class); + var access = mock(AihrKnowledgeAccessService.class); + var jdbc = mock(JdbcTemplate.class); + var principal = new AihrKnowledgePrincipal("000000", 1L, "app_user", "employee-1", + Set.of("employee"), Set.of(), "app"); + var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + when(resolver.current()).thenReturn(principal); + when(appService.requireSessionApp("000000", "app")).thenReturn(app); + when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ")).thenReturn(Set.of()); + var service = new AihrKnowledgeQueryService(resolver, appService, access, mock(AihrSopSeedService.class), + mock(AihrKnowledgeQueryAuditService.class), jdbc, mock(AihrKnowledgeDataToolService.class), + mock(AihrKnowledgeConversationService.class)); + + assertEquals(null, service.authorizedResourceOssId(321L)); + verify(jdbc, never()).queryForList(anyString(), eq(Long.class), any(Object[].class)); + } + private static AihrKnowledgeQueryService service( AihrKnowledgePrincipalResolver resolver, AihrKnowledgeAppService appService, @@ -168,6 +275,7 @@ class AihrKnowledgeQueryServiceTest { AihrSopSeedService sop ) { return new AihrKnowledgeQueryService(resolver, appService, access, sop, - mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class)); + mock(AihrKnowledgeQueryAuditService.class), mock(JdbcTemplate.class), mock(AihrKnowledgeDataToolService.class), + mock(AihrKnowledgeConversationService.class)); } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationServiceTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationServiceTest.java new file mode 100644 index 00000000..265b95da --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/knowledge/service/AihrKnowledgeConversationServiceTest.java @@ -0,0 +1,108 @@ +package org.dromara.aihr.knowledge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeAppDto.AuthenticatedApp; +import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; +import org.dromara.aihr.knowledge.domain.AihrKnowledgeQueryDto.QueryRequest; +import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.ConversationContext; +import org.dromara.aihr.knowledge.service.AihrKnowledgeConversationService.Turn; +import org.dromara.aihr.service.AihrModelSeedService; +import org.dromara.common.core.exception.ServiceException; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@Tag("dev") +class AihrKnowledgeConversationServiceTest { + + @Test + void fileFollowupUsesLastResolvedTopicWhenModelIsUnavailable() { + Turn previous = new Turn("电梯困人怎么办", "自动扶梯困人应急操作 SOP", "QA", + "立即停梯并设置警示。", List.of("lift-1"), List.of("自动扶梯困人应急操作 SOP")); + + var plan = AihrKnowledgeConversationService.fallbackPlan( + List.of(previous), "把这个 SOP 原文件给我", null); + + assertEquals("FILE", plan.intent()); + assertEquals(true, plan.rewrittenQuery().contains("自动扶梯困人应急操作 SOP")); + assertEquals(true, plan.rewrittenQuery().contains("原文件")); + } + + @Test + void naturalLanguageDataToolStaysInsideWhitelist() { + var team = AihrKnowledgeConversationService.fallbackPlan(List.of(), "查询团队训练概况", null); + var unrelated = AihrKnowledgeConversationService.fallbackPlan(List.of(), "查询员工工资明细", null); + + assertEquals("DATA_TOOL", team.intent()); + assertEquals("TEAM_PRACTICE_SUMMARY", team.toolCode()); + assertEquals("QA", unrelated.intent()); + assertEquals(null, unrelated.toolCode()); + } + + @Test + void unavailableConversationTableFallsBackToStatelessQuery() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), any(Object[].class))) + .thenThrow(new DataAccessResourceFailureException("missing table")); + var service = new AihrKnowledgeConversationService(jdbc, new ObjectMapper(), mock(AihrModelSeedService.class)); + + ConversationContext context = service.prepare(principal(), app(), new QueryRequest( + "电梯怎么办", List.of(), "sop", null, "test", 5, null, + "conversation_123", 0L)); + + assertFalse(context.stateful()); + assertEquals("电梯怎么办", context.rewrittenQuery()); + } + + @Test + void missingExpiredRowRestartsEvenWhenClientStillHoldsAnOldVersion() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.query(anyString(), any(org.springframework.jdbc.core.RowMapper.class), any(Object[].class))) + .thenReturn(List.of()); + var service = new AihrKnowledgeConversationService(jdbc, new ObjectMapper(), mock(AihrModelSeedService.class)); + + ConversationContext context = service.prepare(principal(), app(), new QueryRequest( + "重新开始", List.of(), "sop", null, "test", 5, null, + "conversation_123", 9L)); + + assertTrue(context.stateful()); + assertFalse(context.existingRow()); + assertEquals(0L, context.storedVersion()); + } + + @Test + void staleOptimisticWriteIsRejected() { + JdbcTemplate jdbc = mock(JdbcTemplate.class); + when(jdbc.update(anyString(), any(Object[].class))).thenReturn(0); + var service = new AihrKnowledgeConversationService(jdbc, new ObjectMapper(), mock(AihrModelSeedService.class)); + var context = new ConversationContext("conversation_123", true, true, 2L, List.of(), + "电梯怎么办", "QA", null); + + ServiceException error = assertThrows(ServiceException.class, + () -> service.append(principal(), app(), context, "电梯怎么办", "先停梯", List.of())); + + assertEquals(409, error.getCode()); + } + + private static AihrKnowledgePrincipal principal() { + return new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7", + Set.of("employee"), Set.of("P1"), "app"); + } + + private static AuthenticatedApp app() { + return new AuthenticatedApp(3L, "000000", "yc_mobile", "员工端", "SESSION", 60, null); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java index 926b9b03..94fc0261 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/learning/AihrLearningContractTest.java @@ -63,6 +63,8 @@ class AihrLearningContractTest { assertTrue(migration.contains("publish_request_key")); assertTrue(migration.contains("uk_aihr_onboard_exam_target")); assertTrue(migration.contains("uk_aihr_onboard_exam_publish_request")); + assertTrue(migration.contains("ADD COLUMN content_version")); + assertTrue(!migration.contains("content_version varchar(30) DEFAULT ''v1'' COMMENT ''派发内容版本'' AFTER request_key")); } @Test diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrKnowledgePlatformSchemaTest.java b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrKnowledgePlatformSchemaTest.java index 0c7cad70..7fadca96 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrKnowledgePlatformSchemaTest.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/service/AihrKnowledgePlatformSchemaTest.java @@ -17,7 +17,9 @@ class AihrKnowledgePlatformSchemaTest { String sql = Files.readString(repoRoot().resolve("backend/script/sql/aihr_knowledge_mysql8.sql")); String migration = Files.readString(repoRoot().resolve( "backend/script/sql/update/aihr_20260716_knowledge_space_platform_mysql8.sql")); - String schema = sql + migration; + String conversationMigration = Files.readString(repoRoot().resolve( + "backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql")); + String schema = sql + migration + conversationMigration; assertTrue(schema.contains("`code` varchar(100)")); assertTrue(schema.contains("uk_aihr_knowledge_info_code")); @@ -25,6 +27,9 @@ class AihrKnowledgePlatformSchemaTest { assertTrue(schema.contains("aihr_knowledge_app")); assertTrue(schema.contains("aihr_knowledge_app_space")); assertTrue(schema.contains("aihr_knowledge_query_log")); + assertTrue(schema.contains("aihr_knowledge_conversation")); + assertTrue(schema.contains("uk_aihr_knowledge_conversation")); + assertTrue(schema.contains("expires_time")); assertTrue(schema.contains("space_codes_json")); assertTrue(schema.contains("token_hash")); assertFalse(schema.contains("token_plain")); diff --git a/backend/script/sql/aihr_knowledge_mysql8.sql b/backend/script/sql/aihr_knowledge_mysql8.sql index 3eb57793..62cef8c4 100644 --- a/backend/script/sql/aihr_knowledge_mysql8.sql +++ b/backend/script/sql/aihr_knowledge_mysql8.sql @@ -231,6 +231,22 @@ CREATE TABLE IF NOT EXISTS `aihr_knowledge_query_log` ( KEY `idx_aihr_query_status` (`tenant_id`, `status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识问答最小化审计日志'; +CREATE TABLE IF NOT EXISTS `aihr_knowledge_conversation` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `app_id` bigint NOT NULL COMMENT '内部调用应用ID', + `user_id` bigint NOT NULL COMMENT '内部用户ID', + `conversation_id` varchar(64) NOT NULL COMMENT '客户端随机会话ID', + `version` bigint NOT NULL DEFAULT 0 COMMENT '乐观并发版本', + `context_json` json DEFAULT NULL COMMENT '最近六轮脱敏短期上下文', + `expires_time` datetime NOT NULL COMMENT '不活跃过期时间', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_knowledge_conversation` (`tenant_id`, `app_id`, `user_id`, `conversation_id`), + KEY `idx_aihr_knowledge_conversation_expire` (`tenant_id`, `expires_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识问答短期会话'; + CREATE TABLE IF NOT EXISTS `aihr_knowledge_admin_audit` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', diff --git a/backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql b/backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql index 3c8aa8d3..8f62dba8 100644 --- a/backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql +++ b/backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql @@ -8,7 +8,7 @@ SET @content_version_exists = ( WHERE table_schema = @aihr_schema AND table_name = 'aihr_practice_assignment' AND column_name = 'content_version' ); SET @content_version_ddl = IF(@content_version_exists = 0, - 'ALTER TABLE aihr_practice_assignment ADD COLUMN content_version varchar(30) DEFAULT ''v1'' COMMENT ''派发内容版本'' AFTER request_key', + 'ALTER TABLE aihr_practice_assignment ADD COLUMN content_version varchar(30) DEFAULT ''v1'' COMMENT ''派发内容版本''', 'SELECT 1'); PREPARE aihr_content_version_stmt FROM @content_version_ddl; EXECUTE aihr_content_version_stmt; diff --git a/backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql b/backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql new file mode 100644 index 00000000..76ba78ad --- /dev/null +++ b/backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql @@ -0,0 +1,17 @@ +-- 问师傅短期多轮会话。只保存最近六轮的脱敏截断 JSON,30 分钟不活跃过期。 + +CREATE TABLE IF NOT EXISTS `aihr_knowledge_conversation` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) NOT NULL COMMENT '租户编号', + `app_id` bigint NOT NULL COMMENT '内部调用应用ID', + `user_id` bigint NOT NULL COMMENT '内部用户ID', + `conversation_id` varchar(64) NOT NULL COMMENT '客户端随机会话ID', + `version` bigint NOT NULL DEFAULT 0 COMMENT '乐观并发版本', + `context_json` json DEFAULT NULL COMMENT '最近六轮脱敏短期上下文', + `expires_time` datetime NOT NULL COMMENT '不活跃过期时间', + `create_time` datetime NOT NULL COMMENT '创建时间', + `update_time` datetime NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_knowledge_conversation` (`tenant_id`, `app_id`, `user_id`, `conversation_id`), + KEY `idx_aihr_knowledge_conversation_expire` (`tenant_id`, `expires_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='知识问答短期会话'; diff --git a/backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql b/backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql index aae2e4bf..63e49e46 100644 --- a/backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql +++ b/backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql @@ -25,6 +25,8 @@ SET @aihr_release_ddl := CONCAT('ALTER TABLE `aihr_knowledge_query_log` CONVERT PREPARE aihr_release_stmt FROM @aihr_release_ddl; EXECUTE aihr_release_stmt; DEALLOCATE PREPARE aihr_release_stmt; SET @aihr_release_ddl := CONCAT('ALTER TABLE `aihr_knowledge_admin_audit` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_release_collation); PREPARE aihr_release_stmt FROM @aihr_release_ddl; EXECUTE aihr_release_stmt; DEALLOCATE PREPARE aihr_release_stmt; +SET @aihr_release_ddl := CONCAT('ALTER TABLE `aihr_knowledge_conversation` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_release_collation); +PREPARE aihr_release_stmt FROM @aihr_release_ddl; EXECUTE aihr_release_stmt; DEALLOCATE PREPARE aihr_release_stmt; SET @aihr_release_ddl := CONCAT('ALTER TABLE `aihr_learning_question` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_release_collation); PREPARE aihr_release_stmt FROM @aihr_release_ddl; EXECUTE aihr_release_stmt; DEALLOCATE PREPARE aihr_release_stmt; diff --git a/docs/20260718/问师傅多轮会话与原始资料交付设计.md b/docs/20260718/问师傅多轮会话与原始资料交付设计.md index b150ef97..8d7316e5 100644 --- a/docs/20260718/问师傅多轮会话与原始资料交付设计.md +++ b/docs/20260718/问师傅多轮会话与原始资料交付设计.md @@ -1,14 +1,14 @@ # 问师傅多轮会话与原始资料交付设计 -> 状态:实施基线,尚未代表代码完成、生产发布或业务验收。 +> 状态:代码、自动化、本地真实 HTTP、390×844 移动端视觉与交互已验证;生产发布与正式业务验收仍须分别记录。 > > 目标:让“问·数字师傅”能理解本次连续对话中的指代,并在当前授权范围内交付命中的原文件或操作视频。 ## 1. 当前事实与问题 -`mobile-uni/src/pages/user/sop/index.vue` 会在页面内展示消息列表,但每次文字查询只向 `POST /api/knowledge/query` 发送当前 `queryText`;附件查询同样只向 `POST /api/knowledge/query-media` 发送当前问题和本次附件。页面刷新、重新进入或服务端生成答案时都没有可用的对话上下文。 +升级前,`mobile-uni/src/pages/user/sop/index.vue` 只在页面内展示消息列表,每次文字或附件查询只提交当前问题,服务端没有可用的对话上下文。 -因此当前能力是“微信式界面 + 单轮无状态 RAG”,不是多轮智能会话。典型失败包括: +因此升级前的能力是“微信式界面 + 单轮无状态 RAG”,不是多轮智能会话。典型失败包括: - 用户先问“自动扶梯困人怎么办”,再说“把这个 SOP 原文件给我”,第二句无法确定“这个”指什么; - 用户说“有没有操作视频”,系统仍按普通知识问答检索,可能返回另一份语义相近但无关的 SOP; @@ -45,7 +45,7 @@ | `expires_time` | 30 分钟不活跃过期时间 | | `create_time`、`update_time` | 审计时间 | -约束:单个用户问题最多 600 字、单个答案最多 800 字、送入模型的会话上下文最多 6000 字;过期记录惰性清理。唯一键为 `tenant_id + app_id + user_id + conversation_id`。客户端不能传租户、应用或用户身份。 +约束:单个用户问题最多 600 字、单个答案最多 800 字、送入模型的会话上下文最多 6000 字;服务端至多每 5 分钟惰性批量清理 500 条过期记录。唯一键为 `tenant_id + app_id + user_id + conversation_id`。客户端不能传租户、应用或用户身份。 ## 4. 查询契约 @@ -129,3 +129,10 @@ 安全与回归还需覆盖:跨用户/跨租户会话不可读、旧版本冲突、30 分钟过期、模型关闭、OSS 缺失、视频类型过滤、外部 API 保持无状态、旧客户端单轮查询不回退。 完成结论分为:代码实现、自动化测试、本地 HTTP、移动端视觉与交互、生产部署、生产真实多轮、正式业务验收;这些层级不得互相替代。 + +## 10. 当前验证记录 + +- 后端定向合同与服务测试 `20/20` 通过;移动端完整单元测试、类型检查和 H5 构建通过。 +- 本地手机号真实登录后完成“报修第一步 → 把对应原文件给我 → 有对应操作视频吗”三轮:上下文版本依次递增,第二轮路由为 `FILE` 并下载受保护原文件,第三轮无视频时返回空资源和明确说明;旧版本请求返回 `409`。 +- 390×844 H5 已实际操作登录、两轮追问、资料卡点击和“新对话”;资料卡请求命中受保护内容接口,未发现遮挡、溢出或底栏覆盖。 +- 上述是本地工程验证,不等于生产发布、真机文件权限验证或正式业务验收。 diff --git a/docs/API_INTEGRATION.md b/docs/API_INTEGRATION.md index 80c7ccb4..406c8c09 100644 --- a/docs/API_INTEGRATION.md +++ b/docs/API_INTEGRATION.md @@ -13,7 +13,7 @@ | 对练语音 | `POST /api/ai/asr`(multipart 字段 `file`,≤5MB)、`POST /api/ai/tts`(JSON `{text≤300字, voice?, voiceProfile?:{role,voice?,speed?,emotion?}}`,旧 `voice` 兼容;成功返回 `ossId`,客户端播放地址为受控的内联 `data:audio/*`,不返回原始 OSS URL)、`GET /api/aihr/mobile/oss/{ossId}` | 走 OpenAI-compatible audio 接口;生产 SiliconFlow CosyVoice2 按角色映射老师傅/业主/面试官音色,业主对练再按已有情绪分切换平静/严肃/强烈语气和语速,设备语音降级同步调整语速与音高;模型管理需启用 `category=asr/tts` 配置;移动端优先用浏览器录音,`getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用 `audio/*` file input 选择/录制音频后继续调同一 ASR 接口;ASR/TTS 未配置或失败返回 fail,前端降级设备语音或文本;训练/每日题录音只通过 `audioOssId` 走受保护下载,历史客户端传入的 HTTP `audioUrl` 不再回显;TTS 成功音频仍写入 `sys_oss` 留痕,同时用内联 data URL 保持旧客户端可播放;音频下载按员工本人或主管项目范围授权,系统管理端保持后台访问 | | 案例沉淀 `/knowledge/cases` | `GET /api/knowledge/case/capabilities`、`POST /api/knowledge/case/upload`、`/organize`、`/curate`、`GET /records`、`GET /records/{caseId}`、`POST /records/{caseId}/review`、`GET /records/{caseId}/media` | `/capabilities` 返回服务端判定的案例提交/查看能力,移动端不再向普通员工展示无权提交的素材表单;`/upload` 改为 multipart 真实语音上传并走 ASR,服务端只接受 MP3/WAV/M4A/WebM/OGG/AAC/FLAC,成功后原始音频写入 `sys_oss`,案例记录只保存 `mediaOssId` 供受保护媒体接口读取,不向客户端回传原始 `mediaUrl`;`/organize` 用真实转写调 chat 模型整理案例,未配置模型时按真实 transcript 本地结构化,并从背景外的真实摘要项提取学习点;APP 用户的项目范围从 `aihr_org_snapshot` 登录身份解析,上传、整理、入库、列表和详情均按项目范围校验,未完成正式组织映射时安全拒绝,不接受前端伪造项目范围;员工列表/详情只返回 `已入库` 案例,管理端系统用户保留全局运营视图;移动端和管理端案例详情通过受保护媒体接口回放原始音频,主管/项目负责人可提交脱敏点评;预渲染视频样片仍待正式媒体资产接入 | | 案例媒体安全 | `GET /api/knowledge/case/records/{caseId}/media` | 案例详情只返回 `mediaOssId`,不返回原始 `sys_oss.url`;媒体下载会重复执行登录、后台角色或 APP 项目范围校验,再由服务端流式读取 OSS。管理端与 `mobile-uni` 通过鉴权 blob/temp 文件播放,关闭详情页时释放本地对象 URL | -| 问师傅与 SOP 知识库 `/pages/user/sop/index`、`/knowledge/sop` | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 当前文字/现场附件查询已走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;搜索返回 `reviewId` 与 `promptVersion`,员工反馈回传并保存该评审批次。现状仍是每次只提交当前问题的单轮 RAG,页面消息列表不等于服务端对话记忆;多轮指代、意图路由和原文件/视频交付按 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md) 实施。员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED`;`position-sop` 保留一期生活顾问学习导航语义 | +| 问师傅与 SOP 知识库 `/pages/user/sop/index`、`/knowledge/sop` | `POST /api/knowledge/query`、`POST /api/knowledge/query-media`、`GET /api/knowledge/resources/{attachmentId}/content`、`POST /api/knowledge/answer-feedback`、`GET /api/knowledge/position-sop`、`GET /api/aihr/mobile/onboard/tasks`、`POST /api/aihr/mobile/onboard/tasks/{id}/complete`、`GET /api/aihr/mobile/qualification`、`POST /api/knowledge/doc/upload` | 文字/现场附件查询走统一知识空间授权和 MySQL Fulltext + Qdrant 混合召回;内部登录端可选传 `conversationId/contextVersion`,服务端保留 30 分钟不活跃过期、最近 6 轮脱敏截断上下文,用现有 chat 模型或保守规则输出 `QA/FILE/VIDEO/DATA_TOOL` 和 `rewrittenQuery`,旧版本冲突返回 `409`。原文件/视频仅从当前命中且仍有授权的附件返回,内容接口每次重新计算租户、应用和主体权限;无资源时明确返回空列表,不生成假链接。旧客户端和外部 API 保持单轮无状态。搜索继续返回 `reviewId/promptVersion`,员工反馈保存评审批次;详细边界见 [专项设计](20260718/问师傅多轮会话与原始资料交付设计.md)。员工学习页按当前 APP 身份读取正式岗前/入职任务,资格证据无正式数据时明确返回 `NOT_CONFIGURED` | | 资料处理 `/knowledge/processing` | `GET /api/knowledge/processing/overview`、`POST /api/knowledge/doc/upload-async`、`GET /api/knowledge/doc/upload-items`、`POST /api/knowledge/doc/upload-items/{id}/retry` | 已接入解析任务状态聚合;页面只保留“批量导入”,接口暂存+入队即秒回,后台 worker(并发 2)逐条解析/归类/向量化;ZIP 在 worker 内安全解压后把支持的子文件继续入同一批次队列,页面按批次轮询进度、失败可单文件重试;不提供浏览器目录选择或服务端目录导入入口 | | 组织人员同步 | `POST /api/aihr/org/sync` | 从开放组织同步系统的 `/api/open/v1/sync/snapshot` 拉取 `company/department/employee` 快照,分页参数使用 `limit`;员工手机号只落 `person_phone` 用于移动端身份映射,不在组织列表响应暴露;岗位识别 `position/job_title/post/job_name/role/title` 等字段。`dryRun` 必须显式传入 `true`(预检)或 `false`(写入),省略或传 `null` 直接拒绝,避免空请求意外写库;默认 `replaceExisting=true`,写入前必须先用 `{"dryRun":true}`;dry-run 不访问本地快照表、不执行 DDL/写库,返回 `phoneLinked/maskedPhone/suspectText/warnings` 且 `syncedCount=0`。非 dry-run 覆盖写入遇到员工被跳过、手机号不完整、脱敏手机号或疑似乱码时默认拒绝,只有确认 dry-run 结果后显式传 `allowPartialReplace=true` 才允许覆盖;重复 `ext_party_id` 始终拒绝写入,因为数据库唯一键会折叠重复身份;`replaceExisting=false` 不触发不完整快照覆盖闸门,但仍拒绝重复身份。2026-07-15 线上已用 `/api/open/v1` 前缀完成配置、dry-run 和覆盖同步:开放平台返回公司 17、部门 963、员工 3417;生产 `aihr_org_snapshot` 为 3417 行,`phoneLinked=3392`、`maskedPhone=25`、`suspectText=0`,在职 2943、离职 474。当前剩余 25 人手机号不可用属于上游数据质量问题;功能发布不再被组织同步能力阻塞 | | 移动端手机号登录 | `GET /resource/sms/code`、`POST /auth/mobile/sms-login` | 已复用 sms4j 阿里云配置 `config1` 和 RuoYi `sms` 授权策略;短信发送成功后才写 Redis 验证码;手机号不存在时自动注册 `app_user`;`aihr.sms.dev-fixed-code` 非空时不真发短信、验证码固定(dev 默认 `123456`)。prod 默认关闭,试点期只有同时设置 `AIHR_SMS_DEV_FIXED_CODE` 与 `AIHR_SMS_PROD_FIXED_CODE_ENABLED=true` 才启用固定码。 | diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 021e7a71..eb58e988 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -122,10 +122,6 @@ ## Future Enhancements -### 问师傅短期多轮与资料交付 - -当前页面只有微信式消息外观,服务端查询仍是单轮 RAG。短期会话、指代改写、问答/原文件/视频/数据工具意图路由和受控资料下载已进入实施,完整范围与安全边界见 [问师傅多轮会话与原始资料交付设计](20260718/问师傅多轮会话与原始资料交付设计.md)。完成后从活动待办移除,并把最终契约同步到 `API_INTEGRATION.md`。 - ### Model Classification Version Governance Current behavior: diff --git a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md index bc9e0319..efc7ed1a 100644 --- a/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md +++ b/docs/BRD_PRODUCTION_MIGRATION_RUNBOOK.md @@ -46,6 +46,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_learning_closure_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_practice_five_position_catalog_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260717_web_ai_question_reward_mysql8.sql +mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_knowledge_conversation_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260718_release_collation_compat_mysql8.sql ``` @@ -64,7 +65,7 @@ WHERE table_schema = DATABASE() 'aihr_practice_audio', 'aihr_practice_audio_upload', 'aihr_practice_calibration', 'aihr_knowledge_gap', 'aihr_knowledge_answer_feedback', 'aihr_knowledge_space_grant', 'aihr_knowledge_app', 'aihr_knowledge_app_space', - 'aihr_knowledge_query_log', 'aihr_knowledge_admin_audit', + 'aihr_knowledge_query_log', 'aihr_knowledge_admin_audit', 'aihr_knowledge_conversation', 'aihr_practice_question_feedback', 'aihr_onboard_exam', 'aihr_onboard_exam_target', 'aihr_onboard_exam_question', 'aihr_onboard_exam_attempt', 'aihr_onboard_exam_answer', diff --git a/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md b/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md index f0cbac75..3c8477c0 100644 --- a/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md +++ b/docs/KNOWLEDGE_PLATFORM_RUNBOOK.md @@ -135,7 +135,14 @@ where a.id is null and o.ext1 in ('aihr-knowledge','aihr-knowledge-staging'); ## 6. 查询、日志与限流观察 -内部统一入口为 `POST /api/knowledge/query`,外部入口为 `POST /api/open/knowledge/query`。外部请求使用 `Authorization: Bearer `。查询日志只保存问题哈希、有效空间、来源类型、状态、耗时和提示版本,不保存完整问题或令牌。 +内部统一入口为 `POST /api/knowledge/query`,内部移动端可带 `conversationId/contextVersion` 启用最近 6 轮、30 分钟不活跃过期的短期会话;外部入口 `POST /api/open/knowledge/query` 始终无状态。内部原文件/视频通过 `GET /api/knowledge/resources/{attachmentId}/content` 在下载时重新鉴权。外部请求使用 `Authorization: Bearer `。查询日志只保存问题哈希、有效空间、来源类型、状态、耗时和提示版本,不保存完整问题或令牌。 + +`aihr_knowledge_conversation` 只保存脱敏截断后的短期上下文,过期记录由查询流量每 5 分钟惰性清理最多 500 条。可只读观察积压: + +```sql +select count(*) total, sum(expires_time <= now()) expired, min(expires_time) oldest_expiry +from aihr_knowledge_conversation; +``` ```sql select tenant_id, app_id, status, source_types, count(*) calls, @@ -154,6 +161,13 @@ order by last_time desc; ```bash bash scripts/tests/provision-knowledge-platform.test.sh node --test scripts/tests/knowledge-platform-security.test.mjs +npm --prefix mobile-uni run test:unit +``` + +本地手机号登录、多轮改写、原文件下载、视频空结果和旧版本冲突可执行: + +```bash +AIHR_BASE_URL='http://127.0.0.1:8080' node scripts/verify-ask-memory-local.mjs ``` 真实环境验证器从环境变量读取令牌,绝不写入命令脚本或仓库: diff --git a/docs/README.md b/docs/README.md index 3526ef13..9540b4d1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ | [FIGMA需求覆盖与版本偏差审计-20260717.md](FIGMA需求覆盖与版本偏差审计-20260717.md) | 旧版功能、已确认需求、Figma 实际画板与当前实现的四方对照,以及防止遗漏旧功能和误拉后续阶段的事实源规则 | | [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md) | 一期 MVP 演示脚本、录屏兜底、MVP 演示流验收清单 | | [API_INTEGRATION.md](API_INTEGRATION.md) | 后端 API 对接顺序、正式试点 CSV、移动端训练记录/复盘详情、候选资料上传/审核、SOP 上传解析接口 | -| [20260718/问师傅多轮会话与原始资料交付设计.md](20260718/问师傅多轮会话与原始资料交付设计.md) | 问师傅从单轮 RAG 升级为短期多轮问答、指代改写、意图路由和受控原文件/视频交付的实施基线 | +| [20260718/问师傅多轮会话与原始资料交付设计.md](20260718/问师傅多轮会话与原始资料交付设计.md) | 问师傅短期多轮问答、指代改写、意图路由和受控原文件/视频交付的实现与验收记录 | | [KNOWLEDGE_PLATFORM_RUNBOOK.md](KNOWLEDGE_PLATFORM_RUNBOOK.md) | 银城/美途知识空间初始化、授权、令牌、内容迁移、监控验证和安全回滚手册 | | [RUOYI_AI_INCREMENTAL_MIGRATION.md](RUOYI_AI_INCREMENTAL_MIGRATION.md) | 从 `ageerle/ruoyi-ai` 分片迁移知识库、模型能力、文档解析、Qdrant/RAG 和 chat 的执行边界 | | [superpowers/specs/2026-07-16-multi-tenant-knowledge-platform-design.md](superpowers/specs/2026-07-16-multi-tenant-knowledge-platform-design.md) | 银城/美途独立租户、多知识空间、多调用应用统一问答、授权交集、受控数据工具和安全验收的完整需求方案 | diff --git a/mobile-uni/src/components/chat/ChatComposer.vue b/mobile-uni/src/components/chat/ChatComposer.vue index e0e96bae..ab4fcfa7 100644 --- a/mobile-uni/src/components/chat/ChatComposer.vue +++ b/mobile-uni/src/components/chat/ChatComposer.vue @@ -164,7 +164,9 @@ const sendDraft = () => { const panelEmit = (event: 'pick-media' | 'pick-audio' | 'tool') => { panelOpen.value = false; - emit(event); + if (event === 'pick-media') emit('pick-media'); + else if (event === 'pick-audio') emit('pick-audio'); + else emit('tool'); }; const panelAction = (key: string) => { diff --git a/mobile-uni/src/pages/user/practice/index.vue b/mobile-uni/src/pages/user/practice/index.vue index 79a5588c..4051baff 100644 --- a/mobile-uni/src/pages/user/practice/index.vue +++ b/mobile-uni/src/pages/user/practice/index.vue @@ -485,12 +485,6 @@ const styleOptions = [ { value: 'professional', label: '更专业' } ]; -const roleLabel = (role: PracticeRole) => { - if (role === 'customer') return '业主'; - if (role === 'coach') return '老师傅'; - return '我'; -}; - const speechButtonLabel = (key: string) => { if (speechState.value.key !== key) return '语音播报'; if (speechState.value.status === 'loading') return '生成中'; diff --git a/mobile-uni/src/pages/user/sop/index.vue b/mobile-uni/src/pages/user/sop/index.vue index 6b0185f3..2bbb9e0c 100644 --- a/mobile-uni/src/pages/user/sop/index.vue +++ b/mobile-uni/src/pages/user/sop/index.vue @@ -3,7 +3,10 @@ 问 · 数字师傅 - {{ positionLabel }} · 住宅项目 + + {{ positionLabel }} · 住宅项目 + + @@ -59,6 +62,10 @@ + 已结合本次对话理解 {{ msg.result.answer || '未生成直接答案,请查看引用依据。' }} @@ -116,6 +123,32 @@ 收起全文 ▲ + + + + {{ resource.type === 'VIDEO' ? '▶' : '文' }} + + {{ resource.title }} + + {{ resource.loading ? '正在安全读取…' : resource.type === 'VIDEO' ? (resource.localUrl ? '已加载,可直接播放' : '点击加载操作视频') : '点击查看或下载原文件' }} + + + › + + @@ -237,7 +270,9 @@ import { nextTick, ref } from 'vue'; import { onHide, onShow, onUnload } from '@dcloudio/uni-app'; import type { AnswerFeedbackVerdict, + KnowledgeConversationState, KnowledgeMediaSelectedFile, + KnowledgeResource, KnowledgeSearchResponse, SpeechPlaybackStatus, SummaryCardResponse @@ -260,6 +295,7 @@ import { roleKey } from '@/services/auth'; import { resetPageScroll } from '@/services/navigation'; +import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api'; import { ensureEmployeePosition } from '@/services/position'; import { chooseSpeechAudio, createSpeechPlaybackController, measureAudioDuration, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech'; import { downloadSummaryCardImage } from '@/services/summary-card-image'; @@ -323,6 +359,14 @@ const pendingSopQueryKey = 'aihr_sop_pending_query'; const pendingToolKey = 'aihr_sop_pending_tool'; const currentRole = ref(String(uni.getStorageSync(roleKey) || '')); const supervisorLearner = ref(isSupervisorLearnerMode()); +const createConversationId = () => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 12)}`; +}; +const conversationId = ref(createConversationId()); +const contextVersion = ref(0); let msgSeq = 0; let voiceTranscriptionGeneration = 0; let abortVoiceTranscription: (() => void) | null = null; @@ -363,6 +407,41 @@ const replaceMsg = (id: number, next: ChatMsg | null) => { const pushSystem = (text: string) => pushMsg({ role: 'system', text }); +const conversationState = (): KnowledgeConversationState => ({ + conversationId: conversationId.value, + contextVersion: contextVersion.value +}); + +const applyConversationState = (result: KnowledgeSearchResponse) => { + if (result.conversationId === conversationId.value && typeof result.contextVersion === 'number') { + contextVersion.value = result.contextVersion; + } +}; + +const releaseMessageResources = () => { + messages.value.forEach((msg) => { + if (msg.role !== 'master') return; + (msg.result.resources || []).forEach((resource) => { + if (resource.localUrl) releaseAuthenticatedFileUrl(resource.localUrl); + resource.localUrl = undefined; + }); + }); +}; + +const resetConversation = (notify: boolean) => { + cancelPendingMediaSearch(); + releaseMessageResources(); + speechPlayback.stop(); + messages.value = []; + summaryCard.value = null; + selectedMedia.value = null; + conversationId.value = createConversationId(); + contextVersion.value = 0; + if (notify) pushSystem('已开始新对话,后续问题将从新的上下文理解'); +}; + +const startNewConversation = () => resetConversation(true); + const refreshAuthState = () => { loggedIn.value = isLoggedIn(); positionLabel.value = getSelectedPosition(); @@ -420,9 +499,10 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {}) const nextResult = media ? await searchKnowledgeWithMedia(media, text, getSelectedPosition(), (task) => { if (generation === knowledgeSearchGeneration) activeMediaUploadTask = task; - }) - : await searchKnowledge(text); + }, conversationState()) + : await searchKnowledge(text, getSelectedPosition(), conversationState()); if (generation !== knowledgeSearchGeneration) return; + applyConversationState(nextResult); const spoken = (nextResult.answer || '').trim(); replaceMsg(pending.id, { id: pending.id, @@ -480,8 +560,9 @@ const runDataTool = async (toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMM loading.value = true; const generation = ++knowledgeSearchGeneration; try { - const nextResult = await queryKnowledgeTool(effectiveToolCode, question); + const nextResult = await queryKnowledgeTool(effectiveToolCode, question, conversationState()); if (generation !== knowledgeSearchGeneration) return; + applyConversationState(nextResult); replaceMsg(pending.id, { id: pending.id, role: 'master', @@ -626,6 +707,36 @@ const playVoice = (msg: MasterMsg) => { void speechPlayback.toggle(`answer-${msg.id}`, spokenTextOf(msg)); }; +const openKnowledgeResource = async (resource: KnowledgeResource) => { + if (resource.loading) return; + if (resource.type === 'VIDEO' && resource.localUrl) return; + resource.loading = true; + try { + const localUrl = await authenticatedFileUrl(resource.contentUrl); + if (resource.localUrl && resource.localUrl !== localUrl) { + releaseAuthenticatedFileUrl(resource.localUrl); + } + resource.localUrl = localUrl; + if (resource.type === 'FILE') { + if (typeof document !== 'undefined') { + const link = document.createElement('a'); + link.href = localUrl; + link.download = resource.title || '资料文件'; + link.target = '_blank'; + document.body.appendChild(link); + link.click(); + link.remove(); + } else { + uni.openDocument({ filePath: localUrl, showMenu: true }); + } + } + } catch (error) { + uni.showToast({ title: requestErrorText(error, '资料读取失败'), icon: 'none' }); + } finally { + resource.loading = false; + } +}; + const snippetExcerpt = (text = '') => { const value = text.trim(); return value.length > 260 ? `${value.slice(0, 260)}...` : value; @@ -750,6 +861,9 @@ const openWebAi = () => { const handleShow = async () => { resetPageScroll(); refreshAuthState(); + if (!loggedIn.value && messages.value.length) { + resetConversation(false); + } if (loggedIn.value && !(await ensureEmployeePosition('/pages/user/sop/index'))) { return; } @@ -784,6 +898,7 @@ onHide(() => { }); onUnload(() => { + releaseMessageResources(); speechPlayback.destroy(); composerRef.value?.teardown(); cancelVoiceTranscription(); @@ -1873,6 +1988,23 @@ onUnload(() => { font-size: 12px; } +.chat-head-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.new-chat-button { + margin: 0; + padding: 3px 9px; + border: 1px solid #e3e6ea; + border-radius: 999px; + background: #ffffff; + color: #5a6270; + font-size: 12px; + line-height: 1.5; +} + .chat-source-switch { display: flex; padding: 3px; @@ -1971,7 +2103,7 @@ onUnload(() => { border-radius: 6px; background: rgba(200, 0, 26, 0.08); color: #a04a00; - font-size: 11px; + font-size: 12px; } .bubble.user .bubble-tag { @@ -2013,6 +2145,13 @@ onUnload(() => { font-size: 13px; } +.context-used { + display: block; + margin-bottom: 6px; + color: #74839a; + font-size: 12px; +} + .bubble-actions { display: flex; flex-wrap: wrap; @@ -2062,6 +2201,80 @@ onUnload(() => { border-top: 1px dashed #eceff2; } +.resource-list { + display: flex; + flex-direction: column; + gap: 8px; + width: min(280px, 100%); +} + +.resource-card { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 16px; + gap: 9px; + align-items: center; + width: 100%; + padding: 10px; + border: 1px solid #e3e7ed; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 1px 3px rgba(20, 24, 31, 0.06); +} + +.resource-card.video { + border-color: #f1cbd0; +} + +.resource-icon { + display: flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + border-radius: 9px; + background: #fff0f2; + color: #c8001a; + font-size: 14px; + font-weight: 800; +} + +.resource-copy { + min-width: 0; +} + +.resource-title, +.resource-meta { + display: block; +} + +.resource-title { + color: #232a33; + font-size: 13px; + font-weight: 700; + line-height: 1.4; + word-break: break-word; +} + +.resource-meta { + margin-top: 3px; + color: #8a93a0; + font-size: 12px; +} + +.resource-arrow { + color: #a4abb5; + font-size: 22px; +} + +.resource-video { + grid-column: 1 / -1; + width: 100%; + height: 156px; + margin-top: 2px; + border-radius: 8px; + background: #111111; +} + .citation { padding: 8px 10px; border-left: 3px solid #f0c0c5; @@ -2080,7 +2293,7 @@ onUnload(() => { display: block; margin-top: 2px; color: #98a1ad; - font-size: 11px; + font-size: 12px; } .citation-content { diff --git a/mobile-uni/src/pages/user/today/index.vue b/mobile-uni/src/pages/user/today/index.vue index 17928664..a9d0f056 100644 --- a/mobile-uni/src/pages/user/today/index.vue +++ b/mobile-uni/src/pages/user/today/index.vue @@ -174,7 +174,6 @@ const positionProfile = ref(getSelectedPositionProfile()); const quickQuestions = computed(() => positionProfile.value.quickQuestions); const primaryTipQuestion = computed(() => quickQuestions.value[0] || '岗位SOP'); const pendingSopQueryKey = 'aihr_sop_pending_query'; -const pendingToolKey = 'aihr_sop_pending_tool'; const pendingAssignmentKey = 'aihr_pending_assignment_id'; const requests = createPageRequestScope(); let visibleAccountKey = ''; diff --git a/mobile-uni/src/services/knowledge.ts b/mobile-uni/src/services/knowledge.ts index d2e24f53..16e7e54b 100644 --- a/mobile-uni/src/services/knowledge.ts +++ b/mobile-uni/src/services/knowledge.ts @@ -2,6 +2,7 @@ import type { AnswerFeedbackVerdict, KnowledgeSearchResponse, KnowledgeMediaSelectedFile, + KnowledgeConversationState, UnifiedKnowledgeResponse, KnowledgeSnippet, PositionSopSummary, @@ -53,7 +54,11 @@ const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({ content: snippet.content || snippet.text || '' }); -export const searchKnowledge = async (queryText: string, position = getSelectedPosition()) => { +export const searchKnowledge = async ( + queryText: string, + position = getSelectedPosition(), + conversation?: KnowledgeConversationState +) => { const response = await apiRequest({ url: '/api/knowledge/query', method: 'POST', @@ -61,7 +66,9 @@ export const searchKnowledge = async (queryText: string, position = getSelectedP queryText, category: 'sop', position, - limit: 5 + limit: 5, + conversationId: conversation?.conversationId, + contextVersion: conversation?.contextVersion }, timeout: 30000 }); @@ -72,14 +79,22 @@ export const searchKnowledgeWithMedia = ( file: KnowledgeMediaSelectedFile, queryText: string, position = getSelectedPosition(), - onUploadTask?: (task: ReturnType | null) => void + onUploadTask?: (task: ReturnType | null) => void, + conversation?: KnowledgeConversationState ) => new Promise((resolve, reject) => { const task = uni.uploadFile({ url: apiUrl('/api/knowledge/query-media'), filePath: file.path, name: 'file', header: authHeaders(false), - formData: { queryText, position }, + formData: { + queryText, + position, + ...(conversation ? { + conversationId: conversation.conversationId, + contextVersion: String(conversation.contextVersion) + } : {}) + }, timeout: 360000, success: (response) => { try { @@ -97,12 +112,20 @@ export const searchKnowledgeWithMedia = ( export const queryKnowledgeTool = async ( toolCode: 'MY_PRACTICE_SUMMARY' | 'TEAM_PRACTICE_SUMMARY', - queryText: string + queryText: string, + conversation?: KnowledgeConversationState ) => { const response = await apiRequest({ url: '/api/knowledge/query', method: 'POST', - data: { queryText, source: 'mobile_uni_tool', limit: 5, toolCode }, + data: { + queryText, + source: 'mobile_uni_tool', + limit: 5, + toolCode, + conversationId: conversation?.conversationId, + contextVersion: conversation?.contextVersion + }, timeout: 30000 }); return normalizeUnifiedResponse(response); @@ -132,7 +155,12 @@ const normalizeUnifiedResponse = (response: UnifiedKnowledgeResponse): Knowledge usedSpaceCodes: response.usedSpaceCodes || [], noEvidence: response.noEvidence, promptVersion: response.promptVersion, - data: response.data + data: response.data, + conversationId: response.conversationId, + contextVersion: response.contextVersion, + intent: response.intent, + rewrittenQuery: response.rewrittenQuery, + resources: response.resources || [] }; }; diff --git a/mobile-uni/src/services/work-report.ts b/mobile-uni/src/services/work-report.ts index ae05e64f..f36f9b2b 100644 --- a/mobile-uni/src/services/work-report.ts +++ b/mobile-uni/src/services/work-report.ts @@ -1,4 +1,3 @@ -import type { ApiPayload } from '@/types/api'; import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api'; export type WorkReportType = 'CASE' | 'VIDEO' | 'SOP' | 'KNOWLEDGE'; diff --git a/mobile-uni/src/types/api.ts b/mobile-uni/src/types/api.ts index dc854ce1..f4af57ff 100644 --- a/mobile-uni/src/types/api.ts +++ b/mobile-uni/src/types/api.ts @@ -126,6 +126,11 @@ export interface KnowledgeSearchResponse { usedSpaceCodes?: string[]; noEvidence?: boolean; data?: unknown; + conversationId?: string | null; + contextVersion?: number | null; + intent?: 'QA' | 'FILE' | 'VIDEO' | 'DATA_TOOL' | string; + rewrittenQuery?: string; + resources?: KnowledgeResource[]; } export interface KnowledgeMediaSelectedFile { @@ -144,6 +149,20 @@ export interface KnowledgeCitation { fragmentId?: number | null; } +export interface KnowledgeResource { + attachmentId: number; + title: string; + type: 'FILE' | 'VIDEO'; + contentUrl: string; + localUrl?: string; + loading?: boolean; +} + +export interface KnowledgeConversationState { + conversationId: string; + contextVersion: number; +} + export interface UnifiedKnowledgeResponse { requestId: string; queryText: string; @@ -154,6 +173,11 @@ export interface UnifiedKnowledgeResponse { promptVersion?: string | null; legacy?: KnowledgeSearchResponse | null; data?: unknown; + conversationId?: string | null; + contextVersion?: number | null; + intent?: 'QA' | 'FILE' | 'VIDEO' | 'DATA_TOOL' | string; + rewrittenQuery?: string; + resources?: KnowledgeResource[]; } export interface CaseCapabilities { diff --git a/mobile-uni/tests/ask-memory.test.mjs b/mobile-uni/tests/ask-memory.test.mjs new file mode 100644 index 00000000..49031b03 --- /dev/null +++ b/mobile-uni/tests/ask-memory.test.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const read = (path) => readFile(new URL(path, import.meta.url), 'utf8'); + +test('问师傅为文字、媒体和数据工具请求携带同一短期会话版本', async () => { + const service = await read('../src/services/knowledge.ts'); + + assert.match(service, /conversationId:\s*conversation\?\.conversationId/); + assert.match(service, /contextVersion:\s*conversation\?\.contextVersion/); + assert.match(service, /conversationId:\s*conversation\.conversationId/); + assert.match(service, /resources:\s*response\.resources \|\| \[\]/); +}); + +test('问师傅支持显式新对话、版本回写和受控原文件或视频卡', async () => { + const page = await read('../src/pages/user/sop/index.vue'); + + assert.match(page, /@click="startNewConversation">新对话/); + assert.match(page, /const contextVersion = ref\(0\)/); + assert.match(page, /applyConversationState\(nextResult\)/); + assert.match(page, /authenticatedFileUrl\(resource\.contentUrl\)/); + assert.match(page, /releaseAuthenticatedFileUrl\(resource\.localUrl\)/); + assert.match(page, /resource\.type === 'VIDEO' && resource\.localUrl/); + assert.match(page, /msg\.result\.rewrittenQuery !== msg\.result\.queryText/); + assert.match(page, /已结合本次对话理解/); +}); diff --git a/mobile-uni/tests/mobile-user-pages.test.mjs b/mobile-uni/tests/mobile-user-pages.test.mjs index 4b56d55a..c2ccedfe 100644 --- a/mobile-uni/tests/mobile-user-pages.test.mjs +++ b/mobile-uni/tests/mobile-user-pages.test.mjs @@ -22,14 +22,14 @@ test('SOP 查询框不再覆盖 textarea 的 box-sizing', async () => { assert.equal(queryPanelStyle.includes('box-sizing'), false); }); -test('员工首页地图主标题保留真实工具名称', async () => { +test('员工首页核心工具保留真实业务入口', async () => { const source = await pageSource('../src/pages/user/today/index.vue'); - assert.match(source, /class="map-symbol"/); - assert.match(source, /\{\{ tile\.title \}\}<\/text>/); - assert.match(source, /title:\s*'问老师傅'/); - assert.match(source, /title:\s*'刻意练习'/); - assert.match(source, /shortTitle:\s*'问'/); + assert.match(source, /问老师傅<\/text>/); + assert.match(source, /问题榜<\/text>/); + assert.match(source, /@click="goSop"/); + assert.match(source, /const goPractice = \(\) =>/); + assert.match(source, /const openQuestions = \(\) =>/); }); test('训练页提交失败会回滚本次乐观追加的对话', async () => { @@ -74,7 +74,7 @@ test('共享视觉基座包含员工端主色和卡片圆角 token', async () => assert.match(source, /\.employee-card\s*\{/); }); -test('员工端三页接入共享页面和卡片基座', async () => { +test('员工端首页、问师傅和训练页接入当前共享基座', async () => { const [todaySource, sopSource, practiceSource] = await Promise.all([ pageSource('../src/pages/user/today/index.vue'), pageSource('../src/pages/user/sop/index.vue'), @@ -83,7 +83,8 @@ test('员工端三页接入共享页面和卡片基座', async () => { assert.match(todaySource, /employee-page-shell/); assert.match(todaySource, /section-card employee-card/); - assert.match(sopSource, /query-panel employee-card/); + assert.match(sopSource, /sop-chat-page/); + assert.match(sopSource, / { +test('员工首页保留师傅区、语音条、今日安排和问题榜结构', async () => { const source = await pageSource('../src/pages/user/today/index.vue'); assert.match(source, /mentor-hero/); assert.match(source, /voice-panel/); assert.match(source, /今日安排/); - assert.match(source, /我的地图/); + assert.match(source, /问题榜/); assert.match(source, /brand-bar/); assert.match(source, /ask-card/); assert.match(source, /小贴士/); @@ -319,14 +320,17 @@ test('主管专项从正式团队名单按姓名多选并一次批量派发', as }); test('问师傅支持现场媒体、答案播报和总结卡图片下载', async () => { - const [source, knowledgeService, speechService, imageService] = await Promise.all([ + const [source, composerSource, knowledgeService, speechService, imageService] = await Promise.all([ pageSource('../src/pages/user/sop/index.vue'), + pageSource('../src/components/chat/ChatComposer.vue'), pageSource('../src/services/knowledge.ts'), pageSource('../src/services/speech.ts'), pageSource('../src/services/summary-card-image.ts') ]); - assert.match(source, /上传图片或视频/); + assert.match(source, /@pick-media="pickMedia"/); + assert.match(composerSource, /图片\/视频/); + assert.match(composerSource, /仅用于本次现场分析/); assert.match(source, /searchKnowledgeWithMedia/); assert.match(source, /toggleAnswerSpeech/); assert.match(source, /toggleSummarySpeech/); @@ -340,18 +344,21 @@ test('问师傅支持现场媒体、答案播报和总结卡图片下载', async }); test('问师傅恢复旧版语音提问并在录音不可用时回退选择音频', async () => { - const [source, speechService] = await Promise.all([ + const [source, composerSource, speechService] = await Promise.all([ pageSource('../src/pages/user/sop/index.vue'), + pageSource('../src/components/chat/ChatComposer.vue'), pageSource('../src/services/speech.ts') ]); - assert.match(source, /语音提问/); - assert.match(source, /toggleVoiceRecording/); + assert.match(composerSource, /按住 说话/); + assert.match(composerSource, /startHold/); + assert.match(composerSource, /endHold/); + assert.match(composerSource, /navigator\.mediaDevices\?\.getUserMedia/); + assert.match(composerSource, /emit\('pick-audio'\)/); + assert.match(composerSource, /const teardown = \(\) =>/); assert.match(source, /pickVoiceAudio/); assert.match(source, /transcribeSpeechBlob/); assert.match(source, /transcribeSpeechFile/); - assert.match(source, /navigator\.mediaDevices\?\.getUserMedia/); - assert.match(source, /cancelVoiceRecording/); assert.match(speechService, /\/api\/ai\/asr/); }); @@ -359,7 +366,10 @@ test('生成的每日题和对练对话恢复语音播报入口', async () => { const source = await pageSource('../src/pages/user/practice/index.vue'); assert.match(source, /daily-question-voice/); - assert.match(source, /turn\.role !== 'trainee'/); + assert.match(source, /turn\.role === 'customer'/); + assert.match(source, /turn\.role === 'coach'/); + assert.match(source, /playTurnVoice/); + assert.match(source, /speakGenerated/); assert.match(source, /createSpeechPlaybackController/); assert.match(source, /停止播报/); }); @@ -383,10 +393,9 @@ test('语音、媒体和私密画像在切页或退出后不会继续写回', as pageSource('../src/services/knowledge.ts') ]); - assert.match(sopSource, /onHide\(\(\) => \{[\s\S]*speechPlayback\.stop\(\);[\s\S]*cancelVoiceRecording\(\);[\s\S]*cancelPendingMediaSearch\(\);/); + assert.match(sopSource, /onHide\(\(\) => \{[\s\S]*speechPlayback\.stop\(\);[\s\S]*composerRef\.value\?\.teardown\(\);[\s\S]*cancelVoiceTranscription\(\);[\s\S]*cancelPendingMediaSearch\(\);/); assert.match(sopSource, /activeMediaUploadTask\?\.abort\(\)/); assert.match(sopSource, /generation !== knowledgeSearchGeneration/); - assert.match(sopSource, /voiceStarting\.value/); assert.match(sopSource, /abortVoiceTranscription\?\.\(\)/); assert.match(sopSource, /generation !== voiceTranscriptionGeneration/); assert.match(practiceSource, /onHide\(\(\) => \{[\s\S]*speechPlayback\.stop\(\);[\s\S]*cancelRecording\(\);/); @@ -409,7 +418,8 @@ test('播报复用已合成音频且总结图按内容高度生成并及时释 assert.match(speechService, /const sourceCache = new Map\(\)/); assert.match(speechService, /const maxCachedSources = 4/); - assert.match(speechService, /const cachedSource = sourceCache\.get\(value\)/); + assert.match(speechService, /const sourceKey = cacheKey\(value, profile\)/); + assert.match(speechService, /const cachedSource = sourceCache\.get\(sourceKey\)/); assert.match(speechService, /sourceCache\.clear\(\)/); assert.match(speechService, /registerAbort\?\.\(\(\) => task\.abort\(\)\)/); assert.match(speechService, /registerAbort\?\.\(\(\) => xhr\.abort\(\)\)/); diff --git a/scripts/release-preflight.sh b/scripts/release-preflight.sh index f82a0510..a3960da0 100755 --- a/scripts/release-preflight.sh +++ b/scripts/release-preflight.sh @@ -131,6 +131,7 @@ FROM ( UNION ALL SELECT 'aihr_knowledge_app' UNION ALL SELECT 'aihr_knowledge_app_space' UNION ALL SELECT 'aihr_knowledge_query_log' + UNION ALL SELECT 'aihr_knowledge_conversation' UNION ALL SELECT 'aihr_knowledge_admin_audit' UNION ALL SELECT 'aihr_learning_question' UNION ALL SELECT 'aihr_practice_question_feedback' @@ -162,7 +163,7 @@ REMOTE )" || fail "remote schema check failed: $remote_ssh:$remote_db" [[ -z "$missing" ]] || fail "remote schema missing required tables: $(printf '%s' "$missing" | tr '\n' ', ' | sed 's/, $//')" - echo "remote_schema=34/34 $remote_ssh:$remote_db" + echo "remote_schema=35/35 $remote_ssh:$remote_db" } changed_files="$(git status --porcelain)" diff --git a/scripts/tests/aihr-release-collation.test.sh b/scripts/tests/aihr-release-collation.test.sh index fdfc5296..4d2b48fe 100755 --- a/scripts/tests/aihr-release-collation.test.sh +++ b/scripts/tests/aihr-release-collation.test.sh @@ -27,6 +27,7 @@ CREATE TABLE aihr_knowledge_app (tenant_id varchar(20), label varchar(20)) ENGIN CREATE TABLE aihr_knowledge_app_space (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB; CREATE TABLE aihr_knowledge_query_log (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB; CREATE TABLE aihr_knowledge_admin_audit (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB; +CREATE TABLE aihr_knowledge_conversation (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB; CREATE TABLE aihr_learning_question (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB; CREATE TABLE aihr_practice_question_feedback (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB; CREATE TABLE aihr_onboard_exam (tenant_id varchar(20), label varchar(20)) ENGINE=InnoDB; @@ -53,6 +54,6 @@ aligned_columns="$(mysql "$DB" -N -B -e " AND column_name IN ('tenant_id', 'label') AND collation_name='utf8mb4_general_ci'; ")" -test "$aligned_columns" = '36' +test "$aligned_columns" = '38' echo "PASS: AIHR release tables inherit the target database collation and the migration is idempotent" diff --git a/scripts/verify-ask-memory-local.mjs b/scripts/verify-ask-memory-local.mjs new file mode 100644 index 00000000..f3de3a85 --- /dev/null +++ b/scripts/verify-ask-memory-local.mjs @@ -0,0 +1,132 @@ +#!/usr/bin/env node + +import crypto from 'node:crypto'; + +const baseUrl = process.env.AIHR_BASE_URL || 'http://127.0.0.1:8080'; +const phone = process.env.AIHR_VERIFY_PHONE || '13900000001'; +const smsCode = process.env.AIHR_VERIFY_SMS_CODE || '123456'; +const clientId = '428a8310cd442757ae699df5d894f051'; + +async function request(endpoint, { method = 'GET', body, token } = {}) { + const response = await fetch(`${baseUrl}${endpoint}`, { + method, + headers: { + clientid: clientId, + 'Content-Language': 'zh_CN', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(body ? { 'Content-Type': 'application/json;charset=utf-8' } : {}) + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(30_000) + }); + const text = await response.text(); + let payload; + try { + payload = JSON.parse(text); + } catch { + throw new Error(`${endpoint} 返回了非 JSON 响应(HTTP ${response.status})`); + } + if (!response.ok || Number(payload.code) !== 200) { + throw new Error(`${endpoint} 失败:HTTP ${response.status} / ${payload.code} ${payload.msg || ''}`); + } + return payload.data; +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +await request(`/resource/sms/code?phonenumber=${encodeURIComponent(phone)}`); +const login = await request('/auth/mobile/sms-login', { + method: 'POST', + body: { phonenumber: phone, smsCode, tenantId: '000000' } +}); +const token = login?.access_token; +assert(token, '手机号登录未返回 access_token'); + +const conversationId = crypto.randomUUID(); +const first = await request('/api/knowledge/query', { + method: 'POST', + token, + body: { + queryText: '报修受理后第一步应该做什么?', + category: 'sop', + position: '生活顾问', + limit: 5, + conversationId, + contextVersion: 0 + } +}); +assert(first?.conversationId === conversationId, '首问没有返回同一 conversationId'); +assert(Number(first?.contextVersion) === 1, '首问 contextVersion 未从 0 递增到 1'); +assert(first?.intent === 'QA', `首问意图错误:${first?.intent}`); +assert(!first?.noEvidence, '首问未召回已授权的报修依据'); + +const followup = await request('/api/knowledge/query', { + method: 'POST', + token, + body: { + queryText: '把对应原文件给我', + category: 'sop', + position: '生活顾问', + limit: 5, + conversationId, + contextVersion: first.contextVersion + } +}); +assert(followup?.conversationId === conversationId, '追问没有保持同一 conversationId'); +assert(Number(followup?.contextVersion) === 2, '追问 contextVersion 未从 1 递增到 2'); +assert(followup?.intent === 'FILE', `追问意图错误:${followup?.intent}`); +assert(String(followup?.rewrittenQuery || '').includes('报修'), '追问没有结合首问主题重写'); +assert(Array.isArray(followup?.resources) && followup.resources.length > 0, '追问未返回授权原文件'); + +const resource = followup.resources[0]; +assert(resource?.contentUrl?.startsWith('/api/knowledge/resources/'), '原文件没有使用受保护内容地址'); +const download = await fetch(`${baseUrl}${resource.contentUrl}`, { + headers: { clientid: clientId, Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(30_000) +}); +assert(download.ok, `原文件下载失败:HTTP ${download.status}`); +assert((await download.arrayBuffer()).byteLength > 0, '原文件下载内容为空'); + +const videoFollowup = await request('/api/knowledge/query', { + method: 'POST', + token, + body: { + queryText: '有对应操作视频吗?', + category: 'sop', + position: '生活顾问', + limit: 5, + conversationId, + contextVersion: followup.contextVersion + } +}); +assert(Number(videoFollowup?.contextVersion) === 3, '视频追问 contextVersion 未递增到 3'); +assert(videoFollowup?.intent === 'VIDEO', `视频追问意图错误:${videoFollowup?.intent}`); +assert(Array.isArray(videoFollowup?.resources) && videoFollowup.resources.length === 0, '无视频时返回了伪造资源'); +assert(String(videoFollowup?.answer || '').includes('未找到'), '无视频时没有明确说明未找到'); + +const staleResponse = await fetch(`${baseUrl}/api/knowledge/query`, { + method: 'POST', + headers: { + clientid: clientId, + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json;charset=utf-8' + }, + body: JSON.stringify({ + queryText: '再说一遍', + category: 'sop', + conversationId, + contextVersion: first.contextVersion + }), + signal: AbortSignal.timeout(30_000) +}); +const stalePayload = await staleResponse.json(); +assert(Number(stalePayload.code) === 409, `旧上下文版本未被拒绝:${stalePayload.code}`); + +console.log('PASS 手机号真实登录'); +console.log('PASS 首问召回有依据答案,contextVersion=1'); +console.log(`PASS 追问识别 FILE 并重写为:${followup.rewrittenQuery}`); +console.log(`PASS 授权原文件下载:${resource.title}`); +console.log('PASS 视频追问无匹配时明确返回空资源'); +console.log('PASS 旧 contextVersion 返回 409');