feat(knowledge): add short-term ask memory

This commit is contained in:
2026-07-18 21:22:49 +08:00
parent 2eda757483
commit 064816a784
33 changed files with 1348 additions and 99 deletions
+2 -2
View File
@@ -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`)。
## 业务文档
+2 -2
View File
@@ -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 的渐进迁移边界 |
@@ -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<QueryResponse> 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);
}
}
@@ -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<String> 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<Resource> resources
) {
public QueryResponse(String requestId, String queryText, String answer, List<Citation> citations,
List<String> 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());
}
}
}
@@ -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<String> 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<Turn> 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<Citation> citations) {
if (!context.stateful()) {
return null;
}
List<Turn> turns = new ArrayList<>(context.turns());
List<String> docIds = citations == null ? List.of() : citations.stream()
.map(Citation::docId).filter(AihrKnowledgeConversationService::hasText).distinct().limit(5).toList();
List<String> 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<ConversationRow> 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<Turn> 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<Turn> readTurns(String json) {
if (!hasText(json)) {
return List.of();
}
try {
List<Turn> 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<Turn> 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<Turn> turns,
String rewrittenQuery,
String intent,
String toolCode
) {
}
public record Turn(
String question,
String rewrittenQuery,
String intent,
String answer,
List<String> docIds,
List<String> 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) {
}
}
@@ -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<Long> 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<Long> 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<Long> 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<Long> 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<Long> spaceIds = accessService.resolveInternalSpaceIds(principal, app, List.of(), "READ");
if (spaceIds.isEmpty()) {
return null;
}
List<Object> args = new ArrayList<>();
args.add(principal.tenantId());
args.add(attachmentId);
args.addAll(spaceIds);
List<Long> 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<Long> spaceIds, QueryRequest request) {
Set<Long> spaceIds, QueryRequest request, String displayQuery) {
String requestId = UUID.randomUUID().toString().replace("-", "");
long started = System.nanoTime();
List<String> 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<Long> spaceIds,
String question, ConversationContext context, QueryResponse response) {
List<Resource> 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<Resource> resources(String tenantId, Set<Long> spaceIds, List<Citation> citations, String intent) {
if (!isResourceIntent(intent) || spaceIds.isEmpty() || citations == null || citations.isEmpty()) {
return List.of();
}
List<String> docIds = citations.stream().map(Citation::docId)
.filter(AihrKnowledgeQueryService::hasText).distinct().toList();
if (docIds.isEmpty()) {
return List.of();
}
List<Object> args = new ArrayList<>();
args.add(tenantId);
args.addAll(spaceIds);
args.addAll(docIds);
List<AttachmentResource> 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<Resource> 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<Citation> citations(String tenantId, Set<Long> allowedSpaceIds, List<SnippetResponse> 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) {
}
}
@@ -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");
@@ -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));
}
}
@@ -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);
}
}
@@ -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
@@ -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"));
@@ -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 '租户编号',
@@ -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;
@@ -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='知识问答短期会话';
@@ -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;
@@ -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 已实际操作登录、两轮追问、资料卡点击和“新对话”;资料卡请求命中受保护内容接口,未发现遮挡、溢出或底栏覆盖。
- 上述是本地工程验证,不等于生产发布、真机文件权限验证或正式业务验收。
+1 -1
View File
@@ -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` 才启用固定码。 |
-4
View File
@@ -122,10 +122,6 @@
## Future Enhancements
### 问师傅短期多轮与资料交付
当前页面只有微信式消息外观,服务端查询仍是单轮 RAG。短期会话、指代改写、问答/原文件/视频/数据工具意图路由和受控资料下载已进入实施,完整范围与安全边界见 [问师傅多轮会话与原始资料交付设计](20260718/问师傅多轮会话与原始资料交付设计.md)。完成后从活动待办移除,并把最终契约同步到 `API_INTEGRATION.md`。
### Model Classification Version Governance
Current behavior:
+2 -1
View File
@@ -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',
+15 -1
View File
@@ -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 <API_TOKEN>`。查询日志只保存问题哈希、有效空间、来源类型、状态、耗时和提示版本,不保存完整问题或令牌。
内部统一入口为 `POST /api/knowledge/query`,内部移动端可带 `conversationId/contextVersion` 启用最近 6 轮、30 分钟不活跃过期的短期会话;外部入口 `POST /api/open/knowledge/query` 始终无状态。内部原文件/视频通过 `GET /api/knowledge/resources/{attachmentId}/content` 在下载时重新鉴权。外部请求使用 `Authorization: Bearer <API_TOKEN>`。查询日志只保存问题哈希、有效空间、来源类型、状态、耗时和提示版本,不保存完整问题或令牌。
`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
```
真实环境验证器从环境变量读取令牌,绝不写入命令脚本或仓库:
+1 -1
View File
@@ -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) | 银城/美途独立租户、多知识空间、多调用应用统一问答、授权交集、受控数据工具和安全验收的完整需求方案 |
@@ -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) => {
@@ -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 '生成中';
+219 -6
View File
@@ -3,7 +3,10 @@
<view class="chat-head">
<view class="chat-head-row">
<text class="chat-title">问 · 数字师傅</text>
<text class="chat-context">{{ positionLabel }} · 住宅项目</text>
<view class="chat-head-actions">
<text class="chat-context">{{ positionLabel }} · 住宅项目</text>
<button class="new-chat-button" :disabled="loading" @click="startNewConversation">新对话</button>
</view>
</view>
<view class="source-switch chat-source-switch" aria-label="问答来源">
<button class="source-option active" aria-current="page">企业问师傅</button>
@@ -59,6 +62,10 @@
</view>
<view v-if="msg.voiceStatus === 'unavailable' || msg.transcriptOpen" class="bubble master transcript-bubble">
<text
v-if="msg.result.rewrittenQuery && msg.result.rewrittenQuery !== msg.result.queryText"
class="context-used"
>已结合本次对话理解</text>
<text class="bubble-text">{{ msg.result.answer || '未生成直接答案,请查看引用依据。' }}</text>
<view v-if="msg.result.keyPoints?.length" class="bubble-keypoints">
<text v-for="(point, index) in msg.result.keyPoints" :key="point" class="bubble-keypoint">
@@ -116,6 +123,32 @@
<text class="transcript-collapse-text">收起全文 ▲</text>
</view>
</view>
<view v-if="msg.result.resources?.length" class="resource-list">
<view
v-for="resource in msg.result.resources"
:key="resource.attachmentId"
class="resource-card"
:class="{ video: resource.type === 'VIDEO' }"
@click="openKnowledgeResource(resource)"
>
<view class="resource-icon">{{ resource.type === 'VIDEO' ? '▶' : '文' }}</view>
<view class="resource-copy">
<text class="resource-title">{{ resource.title }}</text>
<text class="resource-meta">
{{ resource.loading ? '正在安全读取…' : resource.type === 'VIDEO' ? (resource.localUrl ? '已加载,可直接播放' : '点击加载操作视频') : '点击查看或下载原文件' }}
</text>
</view>
<text class="resource-arrow">›</text>
<video
v-if="resource.type === 'VIDEO' && resource.localUrl"
class="resource-video"
:src="resource.localUrl"
controls
@click.stop
/>
</view>
</view>
</view>
</view>
</template>
@@ -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<SystemMsg>({ 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 {
@@ -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 = '';
+35 -7
View File
@@ -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<UnifiedKnowledgeResponse>({
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<typeof uni.uploadFile> | null) => void
onUploadTask?: (task: ReturnType<typeof uni.uploadFile> | null) => void,
conversation?: KnowledgeConversationState
) => new Promise<KnowledgeSearchResponse>((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<UnifiedKnowledgeResponse>({
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 || []
};
};
-1
View File
@@ -1,4 +1,3 @@
import type { ApiPayload } from '@/types/api';
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
export type WorkReportType = 'CASE' | 'VIDEO' | 'SOP' | 'KNOWLEDGE';
+24
View File
@@ -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 {
+27
View File
@@ -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, /已结合本次对话理解/);
});
+31 -21
View File
@@ -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, /<text class="map-title">\{\{ tile\.title \}\}<\/text>/);
assert.match(source, /title:\s*'问老师傅'/);
assert.match(source, /title:\s*'刻意练习'/);
assert.match(source, /shortTitle:\s*'问'/);
assert.match(source, /<text class="voice-title">问老师傅<\/text>/);
assert.match(source, /<text class="question-board-title">问题榜<\/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, /<ChatComposer/);
assert.match(practiceSource, /scenario-card employee-card/);
});
@@ -122,13 +123,13 @@ test('主管可切换到学员显示模式且组织角色保持不变', async ()
assert.doesNotMatch(supervisorProfile, /setStorageSync\(roleKey,\s*'user'/);
});
test('员工首页保留师傅区、语音条、今日安排、我的地图四段结构', async () => {
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<string, string>\(\)/);
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\(\)\)/);
+2 -1
View File
@@ -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)"
+2 -1
View File
@@ -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"
+132
View File
@@ -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');