feat(knowledge): add short-term ask memory
This commit is contained in:
@@ -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`)。
|
||||
|
||||
## 业务文档
|
||||
|
||||
|
||||
@@ -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 的渐进迁移边界 |
|
||||
|
||||
+22
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+371
@@ -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) {
|
||||
}
|
||||
}
|
||||
+154
-27
@@ -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);
|
||||
}
|
||||
Set<Long> spaceIds = accessService.resolveInternalSpaceIds(
|
||||
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");
|
||||
return queryDocuments(principal, app, spaceIds, request);
|
||||
response = queryDocuments(principal, app, spaceIds, routed, request.queryText());
|
||||
}
|
||||
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) {
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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");
|
||||
|
||||
+112
-4
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+108
@@ -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);
|
||||
}
|
||||
}
|
||||
+2
@@ -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
|
||||
|
||||
+6
-1
@@ -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 已实际操作登录、两轮追问、资料卡点击和“新对话”;资料卡请求命中受保护内容接口,未发现遮挡、溢出或底栏覆盖。
|
||||
- 上述是本地工程验证,不等于生产发布、真机文件权限验证或正式业务验收。
|
||||
|
||||
@@ -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` 才启用固定码。 |
|
||||
|
||||
@@ -122,10 +122,6 @@
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 问师傅短期多轮与资料交付
|
||||
|
||||
当前页面只有微信式消息外观,服务端查询仍是单轮 RAG。短期会话、指代改写、问答/原文件/视频/数据工具意图路由和受控资料下载已进入实施,完整范围与安全边界见 [问师傅多轮会话与原始资料交付设计](20260718/问师傅多轮会话与原始资料交付设计.md)。完成后从活动待办移除,并把最终契约同步到 `API_INTEGRATION.md`。
|
||||
|
||||
### Model Classification Version Governance
|
||||
|
||||
Current behavior:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 '生成中';
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<view class="chat-head">
|
||||
<view class="chat-head-row">
|
||||
<text class="chat-title">问 · 数字师傅</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 = '';
|
||||
|
||||
@@ -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,4 +1,3 @@
|
||||
import type { ApiPayload } from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
|
||||
export type WorkReportType = 'CASE' | 'VIDEO' | 'SOP' | 'KNOWLEDGE';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, /已结合本次对话理解/);
|
||||
});
|
||||
@@ -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\(\)\)/);
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user