feat(assistant): unify confirmed memory capture

This commit is contained in:
2026-07-20 02:30:06 +08:00
parent 46a6156d8c
commit 0a5fc9a463
32 changed files with 949 additions and 242 deletions
+5 -6
View File
@@ -2,7 +2,7 @@
## 项目定位
本项目已升级为**银城员工端 APP**,按“陪练 → 大喇叭+知识学习平台 → 北森对接”三阶段推进。正式验收主线仍是阶段一试点;2026-07-18 已按客户明确要求额外发布问题榜/积分、岗位考试、全网 AI 和多租户知识空间等能力,但这不等于阶段二/三整体验收。`position` 沿用现有值,MVP 资料已归档;任何结论都要区分生产部署、正式账号/真机验证和严格试点。
本项目是**银城员工端 APP**,按“陪练 → 大喇叭+知识学习平台 → 北森对接”三阶段推进。正式验收主线仍是阶段一试点;提前发布的阶段二/三单项能力不等于整体验收。`position` 沿用现有值,MVP 资料已归档;结论必须区分生产部署、正式账号/真机验证和严格试点。
## 工程边界
@@ -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`,内部登录端可带 `conversationId/contextVersion` 使用 30 分钟、最近 6 轮的短期会话,原文件/视频只通过 `/api/knowledge/resources/{attachmentId}/content` 重新鉴权交付;总结卡复用 `/api/knowledge/summary-card`,失败不兜假数据。外部知识 API 保持无状态。
- 用户侧只改 `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`,失败不兜假数据。显式“记一下/帮我记/保存一下”复用同一 query 返回确认卡,确认后按 `PRIVATE/COMPANY` 写 `aihr_assistant_capture`;COMPANY 只表示 `PENDING` 待流转,不得冒充已派单或已送达。外部知识 API 保持无状态。
- 不要把三端概括成全模态:员工对练/每日题和问师傅支持文字/语音,问师傅图片/视频只用于本次分析,案例传音频;候选面试支持文字/语音,资料可传文档/图片但图片不参与评分;主管写文字复盘/指派并回听录音。员工对练图像/视频情境、服务录像回放和方言正式验收仍未完成;语音继续复用 `services/speech.ts` 与 `/api/ai/asr`。
- 问题榜奖励只记积分/学习学分,不得擅自增加现金、提现、预算或税务语义;选最佳答案和考试提交必须保持请求幂等。
- 全网 AI 与企业问师傅必须分入口、分来源和分免责声明;提供方仅允许公网 HTTPS,修改地址/密钥后必须连接测试成功才能启用,未配置时明确不可用且不生成假答案。
@@ -56,7 +56,7 @@
- 移动端员工训练闭环:员工端首页“开始训练”复用 `POST /api/train/practice/start`、`/turn`、`/finish`,请求必须带移动端登录返回的 `Authorization` 与 `clientid`;`mode=mobile` 完成后写入 `aihr_practice_session`,并更新主管端首页完训率、“待复盘对练”、员工训练历史和能力画像。主管端复盘详情查 `GET /api/aihr/mobile/practice/reviews/{id}`,标记复盘用 `POST /api/aihr/mobile/practice/reviews/{id}/reviewed`。
- 正式试点 CSV 走 `GET /api/train/practice/export?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD`,起止日必填;训练、校准和 SOP 评审必须按同一窗口、唯一在职组织身份统计,完训定义为每人至少 10 次。CSV 应保留原始校准命中/SOP 可用计数,严格预检用 `AIHR_PILOT_START_DATE`、`AIHR_PILOT_END_DATE` 与 `AIHR_PILOT_STRICT=true ./scripts/demo-check.sh`;不允许用历史 seed、开发身份或四舍五入比率伪造正式试点通过。
- 移动端候选人闭环:候选人端“开始面试/面试练习”复用 `POST /api/recruit/interview/start`、`/answer`、`/finish`;“补充资料”走认证接口 `POST /api/aihr/mobile/candidate/materials`(multipart `file` + `candidateId/candidateName/materialType`)和 `GET /api/aihr/mobile/candidate/materials`,文件先写 `sys_oss`/MinIO,再写 `aihr_candidate_material` 状态 `待审核`。HR 审核复用管理端 `/recruit/interview` 页,接口为 `GET /api/aihr/hr/candidate/materials` 和 `POST /api/aihr/hr/candidate/materials/{id}/review`,状态只用 `待审核/已通过/已驳回`。上传接口不要放进 class-level `@SaIgnore` 的 `AihrMobileController`。
- 当前业务页已跑通可演示闭环;后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 已接真 LLM(`AihrPracticeLlmService`,评分 temperature=0 结构化输出),seed 剧本是剧情锚点与兜底,改对练逻辑时必须保留"未配置/失败回退 seed"的降级链,不要让演示依赖外部 API。
- 后端 API 对接先看 `docs/API_INTEGRATION.md`。三角色对练 `/turn`/`/finish` 使用 `AihrPracticeLlmService`,必须保留“未配置/失败回退 seed”的降级链。
- 对练语音走 `POST /api/ai/asr`(≤5MB multipart)和 `POST /api/ai/tts`(文本≤300字,返回 base64 dataURL);TTS 保留旧 `voice` 字符串,并支持 `voiceProfile={role,voice?,speed?,emotion?}`,当前老师傅/业主/面试官分音色,业主按对练情绪分调整语气与语速。模型走 `aihr_model_config` 的 `asr`/`tts` 类目(`category` 全集:chat/vector/rerank/asr/tts/vision)。移动端 `getUserMedia/MediaRecorder` 不可用或麦克风权限失败时,用隐藏 `audio/*` file input 选择/录制音频后继续走同一 ASR 接口,不要退回只有文字输入。multipart 头部的 filename/contentType 已做 CRLF 清洗,新增外发 HTTP 时同样注意。
- 后端业务代码不要塞进上游 `ruoyi-demo`;自有 API 放在 `backend/ruoyi-modules/ruoyi-aihr`,再接入 `ruoyi-admin`。
- AI 面试页、三角色对练页、案例沉淀页、SOP 知识库页和移动端三端首页已是 API 优先 + 本地 fallback;SOP 知识库优先查 `aihr_knowledge_fragment` 的 MySQL Fulltext,vector 模型和 Qdrant 可用时混合召回,`category=rerank` 模型启用时融合后语义重排(失败保持 RRF 顺序);支持 `.txt/.md/.markdown/.pdf/.doc/.docx/.xls/.xlsx/.ppt/.pptx`、图片 `.jpg/.jpeg/.png/.gif/.webp/.bmp` 及视频 `.mp4/.mov/.avi/.mkv/.webm/.m4v` 上传解析入库。
@@ -66,14 +66,13 @@
- 向量库状态接口是 `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_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`)。
- 本项目 AI 表前缀用 `aihr_*`:知识库是 `aihr_knowledge_info/attach/fragment`,问师傅短期会话是 `aihr_knowledge_conversation`,确认式采集是 `aihr_memory_candidate` / `aihr_assistant_capture`(旧项目记录兼容 `aihr_service_memory/version`),上传队列是 `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`)。
## 业务文档
- 总入口:`README.md`
- 文档索引:`docs/README.md`
- Figma 设计资产清单:`docs/FIGMA_DESIGN_INVENTORY.md`;页面实现应复用既有变量与组件,以清单中的正式画板为视觉基准,不按单页自由发挥。
- Figma 视觉验收必须逐张正式画板生成并人工查看当次截图,按 `docs/FIGMA_DESIGN_INVENTORY.md` 的“正式画板逐页视觉验收标准”检查信息架构、栅格、字号/换行、固定底栏/安全区、溢出和 active/disabled 状态;仅枚举节点或图层不算视觉通过。结构、视觉、功能、发布和生产验证必须分别记录。
- Figma 实现与逐页视觉验收以 `docs/FIGMA_DESIGN_INVENTORY.md` 为准;复用既有变量/组件并人工查看当次截图,结构、视觉、功能、发布和生产验证分别记录。
- MVP 演示与验收:`docs/DEMO_ACCEPTANCE.md`
- 阶段交付归档:`docs/archive/2026-07-mvp-delivery/`
- 分阶段总纲:`docs/银城员工端APP分阶段实施总纲.md`
+1 -1
View File
@@ -11,7 +11,7 @@
- 品牌:前端标题为“物业AI人力资源系统”,侧栏 logo 使用银城物业图标。
- 基础链路:租户列表、验证码、`000000 / admin / admin123` 加密登录已验证。
- 管理端:除 AI 面试、训练、案例、SOP 和资料处理外,已包含常见难题、问题榜运营、知识空间、全网 AI 配置、组织权限和成长激励。
- 用户侧:`mobile-uni/` 承载员工、候选人和主管三端;已接手机号登录、问师傅文字/语音/附件与播报、30 分钟短期多轮追问、原文件/视频受控交付、三级字号、训练/题目反馈、开放问题榜与积分、岗位考试、案例、候选面试/资料、主管复盘/派发及学员模式。旧 `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 名项目经理;人数属于时点事实,发布或验收前应重新查询。
@@ -59,14 +59,14 @@ public class AihrKnowledgeQueryService {
if (hasText(routed.toolCode())) {
response = queryDataTool(principal, app, routed, request.queryText());
} else {
memoryRecall = "QA".equals(context.intent())
boolean candidateIntent = "QA".equals(context.intent())
&& memoryService.isProjectMemoryCandidateIntent(principal, request.queryText());
memoryRecall = "QA".equals(context.intent()) && !candidateIntent
? memoryService.recall(principal, routed.queryText(), routed.limit())
: ServiceMemoryRecall.empty();
if (memoryRecall == null) {
memoryRecall = ServiceMemoryRecall.empty();
}
boolean candidateIntent = "QA".equals(context.intent())
&& memoryService.isProjectMemoryCandidateIntent(principal, request.queryText());
try {
spaceIds = accessService.resolveInternalSpaceIds(
principal, app, request.spaceCodes(), "READ");
@@ -82,16 +82,19 @@ public class AihrKnowledgeQueryService {
spaceIds = Set.of();
}
if (response == null) {
response = queryDocuments(principal, app, spaceIds, routed, request.queryText());
response = candidateIntent
? emptyProjectResponse(request.queryText())
: queryDocuments(principal, app, spaceIds, routed, request.queryText());
finalizeAudit = candidateIntent;
}
if (!memoryRecall.items().isEmpty()) {
response = mergeServiceMemories(memoryRecall, response);
finalizeAudit = true;
}
}
QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response);
MemoryCandidateResponse candidate = !"QA".equals(context.intent()) || hasText(routed.toolCode()) ? null
: memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), completed.requestId());
: memoryService.detectForQuery(principal, request.queryText(), context.conversationId(), response.requestId());
QueryResponse completed = complete(principal, app, spaceIds, request.queryText(), context, response);
QueryResponse result = completed.withMemoryCandidate(candidate);
if (finalizeAudit || candidate != null) {
List<String> sourceTypes = finalSourceTypes(result, memoryRecall, candidate);
@@ -230,12 +233,17 @@ public class AihrKnowledgeQueryService {
private QueryResponse mergeServiceMemories(ServiceMemoryRecall recall, QueryResponse response) {
List<Citation> citations = new ArrayList<>(response.citations());
recall.items().forEach(item -> citations.add(new Citation(
PROJECT_SERVICE, PROJECT_SERVICE, "service-memory:" + item.id(), item.title(), item.summary(), null,
PROJECT_SERVICE, item.status(), item.occurredAt(), item.updatedAt()
)));
recall.items().forEach(item -> {
String domain = hasText(item.domain()) ? item.domain() : PROJECT_SERVICE;
String docPrefix = PROJECT_SERVICE.equals(domain) ? "service-memory:" : "assistant-capture:";
citations.add(new Citation(
domain, domain, docPrefix + item.id(), item.title(), item.summary(), null,
domain, item.status(), item.occurredAt(), item.updatedAt()
));
});
LinkedHashSet<String> usedScopes = new LinkedHashSet<>(response.usedSpaceCodes());
usedScopes.add(PROJECT_SERVICE);
recall.items().stream().map(item -> hasText(item.domain()) ? item.domain() : PROJECT_SERVICE)
.forEach(usedScopes::add);
String answer = response.noEvidence() || response.citations().isEmpty()
? recall.answer()
: response.answer() + "\n\n" + recall.answer();
@@ -258,12 +266,13 @@ public class AihrKnowledgeQueryService {
MemoryCandidateResponse candidate) {
LinkedHashSet<String> sourceTypes = new LinkedHashSet<>();
boolean hasDocument = response.citations().stream()
.anyMatch(citation -> !PROJECT_SERVICE.equals(citation.domain()));
.anyMatch(citation -> !Set.of(PROJECT_SERVICE, "PERSONAL", "COMPANY").contains(citation.domain()));
if (hasDocument) {
sourceTypes.add("DOCUMENT");
}
if (recall != null && !recall.items().isEmpty()) {
sourceTypes.add(PROJECT_SERVICE);
recall.items().stream().map(item -> hasText(item.domain()) ? item.domain() : PROJECT_SERVICE)
.forEach(sourceTypes::add);
}
if (candidate != null) {
sourceTypes.add("MEMORY_CANDIDATE");
@@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.AssistantCaptureResponse;
import org.dromara.common.core.domain.R;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -42,4 +43,13 @@ public class AihrMemoryController {
public R<MemoryCandidateResponse> dismiss(@PathVariable Long id) {
return R.ok(memoryService.dismiss(id));
}
@SaCheckLogin
@GetMapping("/assistant-captures")
public R<List<AssistantCaptureResponse>> captures(
@RequestParam(value = "saveScope", required = false) String saveScope,
@RequestParam(value = "limit", defaultValue = "50") Integer limit
) {
return R.ok(memoryService.assistantCaptures(saveScope, limit));
}
}
@@ -44,8 +44,13 @@ public final class AihrMemoryDto {
Long expectedVersion,
String idempotencyKey,
MemoryDraft draft,
Boolean enableReminder
Boolean enableReminder,
String saveScope
) {
public ConfirmMemoryRequest(Long expectedVersion, String idempotencyKey, MemoryDraft draft,
Boolean enableReminder) {
this(expectedVersion, idempotencyKey, draft, enableReminder, null);
}
}
public record ConfirmMemoryResponse(
@@ -55,6 +60,24 @@ public final class AihrMemoryDto {
) {
}
public record AssistantCaptureResponse(
Long id,
String saveScope,
String suggestedType,
String projectCode,
String buildingName,
String unitName,
String roomNo,
String category,
String summary,
String detail,
String occurredAt,
String followUpAt,
String deliveryStatus,
String createdAt
) {
}
public record ServiceMemoryResponse(
Long id,
String projectCode,
@@ -119,7 +142,12 @@ public final class AihrMemoryDto {
String summary,
String status,
String occurredAt,
String updatedAt
String updatedAt,
String domain
) {
public ServiceMemoryRecallItem(Long id, String projectCode, String title, String summary,
String status, String occurredAt, String updatedAt) {
this(id, projectCode, title, summary, status, occurredAt, updatedAt, "PROJECT_SERVICE");
}
}
}
@@ -8,6 +8,7 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryRequest;
import org.dromara.aihr.memory.AihrMemoryDto.ConfirmMemoryResponse;
import org.dromara.aihr.memory.AihrMemoryDto.AssistantCaptureResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryCandidateResponse;
import org.dromara.aihr.memory.AihrMemoryDto.MemoryDraft;
import org.dromara.aihr.memory.AihrMemoryDto.ServiceMemoryRecall;
@@ -57,6 +58,8 @@ import java.util.regex.Pattern;
public class AihrMemoryService {
private static final String PROJECT_SERVICE = "PROJECT_SERVICE";
private static final String ASSISTANT_CAPTURE = "ASSISTANT_CAPTURE";
private static final Set<String> CAPTURE_SCOPES = Set.of("PRIVATE", "COMPANY");
private static final Set<String> CANDIDATE_STATUSES = Set.of("DRAFT", "NEEDS_INPUT");
private static final Set<String> MEMORY_STATUSES = Set.of("OPEN", "IN_PROGRESS", "RESOLVED", "VOID");
private static final Pattern IDEMPOTENCY_KEY = Pattern.compile("[A-Za-z0-9._:-]{8,100}");
@@ -64,6 +67,8 @@ public class AihrMemoryService {
private static final Pattern UNIT = Pattern.compile("([0-9一二三四五六七八九十]{1,4})单元");
private static final Pattern ROOM = Pattern.compile(
"(?:(?:房号|房间|室号)\\s*[::]?\\s*([A-Za-z0-9-]{2,12})|(?<![0-9])([0-9]{3,6})\\s*(?:室|户))");
private static final Pattern CONTEXTUAL_ROOM = Pattern.compile(
"(?:单元|栋|幢|号楼)\\s*([0-9]{3,6})(?![0-9])(?!\\s*(?:年|月|日|分钟|小时|元|块|人|次))");
private static final Pattern BUILDING_VALUE = Pattern.compile(
"[A-Za-z0-9一二三四五六七八九十-]{1,8}(?:栋|幢|号楼)");
private static final Pattern UNIT_VALUE = Pattern.compile(
@@ -79,7 +84,8 @@ public class AihrMemoryService {
+ "病史|病情|用药|过敏|诊断|确诊|患有|患者|疾病|住院|手术|慢性病|"
+ "行动不便|失能|半失能|卧床|轮椅|认知障碍");
private static final Set<String> RECALL_KEYWORDS = Set.of(
"维修", "投诉", "养老", "电梯", "保洁", "门禁", "漏水", "噪音", "停车", "装修", "回访", "跟进");
"维修", "投诉", "养老", "电梯", "保洁", "门禁", "漏水", "噪音", "停车", "装修", "回访", "跟进",
"家政", "助餐", "陪诊", "代购", "想法", "线索", "案例");
private static final TypeReference<List<String>> STRING_LIST = new TypeReference<>() { };
private static final TypeReference<Map<String, Object>> SNAPSHOT_MAP = new TypeReference<>() { };
@@ -104,6 +110,9 @@ public class AihrMemoryService {
}
return saveCandidate(principal, plan, conversationId, requestId);
} catch (RuntimeException ex) {
if (detectPlan(principal, queryText, LocalDateTime.now().withNano(0)) != null) {
throw new ServiceException("记忆功能暂不可用,请稍后重试", 503);
}
log.warn("service memory candidate unavailable; query answer remains usable");
return null;
}
@@ -153,13 +162,20 @@ public class AihrMemoryService {
normalized = copyWithProjectCode(normalized, candidate.projectCode());
}
boolean enableReminder = Boolean.TRUE.equals(request.enableReminder());
String saveScope = upper(request.saveScope());
if (ASSISTANT_CAPTURE.equals(candidate.targetDomain())) {
saveScope = saveScope.isEmpty() ? "COMPANY" : saveScope;
if (!CAPTURE_SCOPES.contains(saveScope)) {
throw badRequest("saveScope 仅支持 PRIVATE/COMPANY");
}
}
String requestHash = hash(new ConfirmFingerprint(
candidateId, request.expectedVersion(), normalized, enableReminder));
candidateId, request.expectedVersion(), normalized, enableReminder, saveScope));
if ("CONFIRMED".equals(candidate.status())) {
if (idempotencyKey.equals(candidate.confirmIdempotencyKey())
&& requestHash.equals(candidate.confirmRequestHash())) {
return new ConfirmMemoryResponse(PROJECT_SERVICE, candidate.targetId(), candidate.targetVersion());
return new ConfirmMemoryResponse(candidate.targetDomain(), candidate.targetId(), candidate.targetVersion());
}
throw conflict("幂等键或确认内容与原请求不一致");
}
@@ -174,6 +190,10 @@ public class AihrMemoryService {
if (candidate.version() != request.expectedVersion().intValue()) {
throw conflict("记忆候选已更新,请刷新后重试");
}
if (ASSISTANT_CAPTURE.equals(candidate.targetDomain())) {
return confirmAssistantCapture(principal, candidate, normalized, request, saveScope,
idempotencyKey, requestHash);
}
if (!PROJECT_SERVICE.equals(candidate.targetDomain())
|| !Set.of("RESIDENT_NEED", "FOLLOW_UP").contains(candidate.memoryType())) {
throw conflict("当前候选不属于项目服务记忆");
@@ -224,6 +244,41 @@ public class AihrMemoryService {
return new ConfirmMemoryResponse(PROJECT_SERVICE, memoryId, 1);
}
public List<AssistantCaptureResponse> assistantCaptures(String rawSaveScope, Integer requestedLimit) {
AihrKnowledgePrincipal principal = principalResolver.current();
String saveScope = upper(rawSaveScope);
if (!saveScope.isEmpty() && !CAPTURE_SCOPES.contains(saveScope)) {
throw badRequest("saveScope 仅支持 PRIVATE/COMPANY");
}
int limit = requestedLimit == null ? 50 : requestedLimit;
if (limit < 1 || limit > 100) {
throw badRequest("limit 仅支持 1-100");
}
String scopeClause = saveScope.isEmpty() ? "" : " and save_scope = ?";
List<Object> args = new ArrayList<>(List.of(principal.tenantId(), principal.userId()));
if (!saveScope.isEmpty()) args.add(saveScope);
args.add(limit);
try {
return jdbcTemplate.query("""
select id, save_scope, suggested_type, project_code, building_name, unit_name, room_no,
category, summary, detail, occurred_at, follow_up_at, delivery_status, create_time
from aihr_assistant_capture
where tenant_id = ? and owner_user_id = ?%s
order by create_time desc, id desc
limit ?
""".formatted(scopeClause), (rs, rowNum) -> new AssistantCaptureResponse(
rs.getLong("id"), rs.getString("save_scope"), rs.getString("suggested_type"),
rs.getString("project_code"), rs.getString("building_name"), rs.getString("unit_name"),
rs.getString("room_no"), rs.getString("category"), rs.getString("summary"),
rs.getString("detail"), format(rs.getTimestamp("occurred_at")),
format(rs.getTimestamp("follow_up_at")),
rs.getString("delivery_status"), format(rs.getTimestamp("create_time"))
), args.toArray());
} catch (DataAccessException ex) {
throw new ServiceException("统一采集数据库迁移未执行", 503);
}
}
@Transactional(rollbackFor = Exception.class)
public MemoryCandidateResponse dismiss(Long candidateId) {
AihrKnowledgePrincipal principal = principalResolver.current();
@@ -348,53 +403,111 @@ public class AihrMemoryService {
return copyWithVersions(row, versions);
}
/** Query-path recall is read-only and fails closed when project scope is absent. */
/** Query-path recall is read-only. Personal captures are owner-scoped; shared records are project-scoped. */
public ServiceMemoryRecall recall(AihrKnowledgePrincipal principal, String rawQuery, Integer requestedLimit) {
if (principal == null || principal.projectCodes().isEmpty() || !looksLikeMemoryQuery(rawQuery)) {
if (principal == null || !looksLikeMemoryQuery(rawQuery)) {
return ServiceMemoryRecall.empty();
}
try {
String projectCode = resolveProjectCode(principal.projectCodes(), rawQuery);
if (!hasText(projectCode)) {
return ServiceMemoryRecall.empty();
}
List<String> projectCodes = List.of(projectCode);
int limit = Math.min(5, Math.max(1, requestedLimit == null ? 5 : requestedLimit));
List<String> terms = recallTerms(rawQuery);
if (terms.isEmpty()) {
return ServiceMemoryRecall.empty();
List<ServiceMemoryRecallItem> items = new ArrayList<>();
try {
items.addAll(recallAssistantCaptures(principal, terms, limit));
} catch (DataAccessException ex) {
log.warn("assistant capture recall unavailable; project memory recall continues");
}
String projectCode = resolveProjectCode(principal.projectCodes(), rawQuery);
if (hasText(projectCode) && !terms.isEmpty()) {
try {
items.addAll(recallProjectMemories(principal, projectCode, terms, limit));
} catch (DataAccessException ex) {
log.warn("project memory recall unavailable; personal recall continues");
}
}
items.sort(java.util.Comparator.comparing(
ServiceMemoryRecallItem::occurredAt,
java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())
));
if (items.size() > limit) {
items = new ArrayList<>(items.subList(0, limit));
}
return items.isEmpty() ? ServiceMemoryRecall.empty() : new ServiceMemoryRecall(items, recallAnswer(items));
}
private List<ServiceMemoryRecallItem> recallAssistantCaptures(AihrKnowledgePrincipal principal,
List<String> terms, int limit) {
StringBuilder sql = new StringBuilder("""
select id, project_code, save_scope, category, building_name, unit_name, room_no,
summary, delivery_status, occurred_at, update_time
from aihr_assistant_capture
where tenant_id = ? and owner_user_id = ?
""");
List<Object> args = new ArrayList<>(List.of(principal.tenantId(), principal.userId()));
appendRecallLike(sql, args, terms,
"concat_ws(' ', building_name, unit_name, room_no, category, summary, detail)");
sql.append(" order by occurred_at desc, id desc limit ?");
args.add(limit);
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> {
String location = clean(rs.getString("building_name")) + clean(rs.getString("unit_name"))
+ clean(rs.getString("room_no"));
String category = clean(rs.getString("category"));
String scope = rs.getString("save_scope");
return new ServiceMemoryRecallItem(
rs.getLong("id"), rs.getString("project_code"),
category + (location.isEmpty() ? "" : ":" + location), rs.getString("summary"),
rs.getString("delivery_status"), format(rs.getTimestamp("occurred_at")),
format(rs.getTimestamp("update_time")), "PRIVATE".equals(scope) ? "PERSONAL" : "COMPANY"
);
}, args.toArray());
}
private List<ServiceMemoryRecallItem> recallProjectMemories(AihrKnowledgePrincipal principal,
String projectCode, List<String> terms, int limit) {
StringBuilder sql = new StringBuilder("""
select id, project_code, title, summary, status, occurred_at, update_time
from aihr_service_memory
where tenant_id = ? and project_code in (%s) and status <> 'VOID'
""".formatted(placeholders(projectCodes.size())));
List<Object> args = new ArrayList<>();
args.add(principal.tenantId());
args.addAll(projectCodes);
// ponytail: bounded LIKE recall is enough for the first relational slice; add FTS/Qdrant after corpus growth.
sql.append(" and (");
for (int i = 0; i < terms.size(); i++) {
if (i > 0) {
sql.append(" or ");
}
sql.append("concat_ws(' ', building_name, unit_name, room_no, category, title, summary, detail) like ?");
args.add("%" + terms.get(i) + "%");
}
sql.append(")");
where tenant_id = ? and project_code = ? and status <> 'VOID'
""");
List<Object> args = new ArrayList<>(List.of(principal.tenantId(), projectCode));
appendRecallLike(sql, args, terms,
"concat_ws(' ', building_name, unit_name, room_no, category, title, summary, detail)");
sql.append(" order by occurred_at desc, id desc limit ?");
args.add(limit);
List<ServiceMemoryRecallItem> items = jdbcTemplate.query(sql.toString(), (rs, rowNum) ->
return jdbcTemplate.query(sql.toString(), (rs, rowNum) ->
new ServiceMemoryRecallItem(
rs.getLong("id"), rs.getString("project_code"), rs.getString("title"),
rs.getString("summary"), rs.getString("status"), format(rs.getTimestamp("occurred_at")),
format(rs.getTimestamp("update_time"))
format(rs.getTimestamp("update_time")), PROJECT_SERVICE
), args.toArray());
return items.isEmpty() ? ServiceMemoryRecall.empty() : new ServiceMemoryRecall(items, recallAnswer(items));
} catch (DataAccessException ex) {
log.warn("service memory recall unavailable; enterprise knowledge query continues");
return ServiceMemoryRecall.empty();
}
private static void appendRecallLike(StringBuilder sql, List<Object> args, List<String> terms,
String searchableExpression) {
if (terms.isEmpty()) {
return;
}
List<String> locations = terms.stream().filter(AihrMemoryService::isLocationRecallTerm).toList();
List<String> keywords = terms.stream().filter(term -> !isLocationRecallTerm(term)).toList();
for (String location : locations) {
sql.append(" and ").append(searchableExpression).append(" like ?");
args.add("%" + location + "%");
}
if (!keywords.isEmpty()) {
sql.append(" and (");
for (int i = 0; i < keywords.size(); i++) {
if (i > 0) sql.append(" or ");
sql.append(searchableExpression).append(" like ?");
args.add("%" + keywords.get(i) + "%");
}
sql.append(")");
}
}
private static boolean isLocationRecallTerm(String term) {
String value = clean(term);
return BUILDING_VALUE.matcher(value).matches()
|| UNIT_VALUE.matcher(value).matches()
|| ROOM_VALUE.matcher(value).matches();
}
static CandidatePlan detectPlan(AihrKnowledgePrincipal principal, String rawQuery, LocalDateTime now) {
@@ -403,14 +516,13 @@ public class AihrMemoryService {
return null;
}
boolean explicit = containsAny(query, "记一下", "记录一下", "帮我记", "保存一下", "备忘");
boolean resident = containsAny(query, "住户", "业主", "房号") || (BUILDING.matcher(query).find() && UNIT.matcher(query).find());
boolean resident = containsAny(query, "住户", "业主", "房号") || BUILDING.matcher(query).find();
boolean actionable = containsAny(query, "需要", "希望", "想要", "要求", "咨询", "报修", "投诉", "跟进", "回访", "联系", "处理", "提醒");
if (!actionable || (!explicit && !resident) || (!explicit && looksLikeQuestion(query))) {
if ((!explicit && (!actionable || !resident)) || (!explicit && looksLikeQuestion(query))) {
return null;
}
String memoryType = containsAny(query, "跟进", "回访", "联系", "提醒", "后续处理")
? "FOLLOW_UP" : "RESIDENT_NEED";
String memoryType = suggestedType(query);
String projectCode = resolveProjectCode(principal == null ? Set.of() : principal.projectCodes(), query);
String building = match(BUILDING, query, 0);
String unit = match(UNIT, query, 0);
@@ -434,20 +546,20 @@ public class AihrMemoryService {
summary = summary.replace(location, "");
}
}
summary = truncate(redactMemoryText(summary).replaceAll("\\s+", " ").trim(), 1000);
summary = truncate(redactMemoryText(stripCaptureCommand(summary)).replaceAll("\\s+", " ").trim(), 1000);
if (summary.isEmpty()) {
missing.add("summary");
}
LocalDateTime occurredAt = (now == null ? LocalDateTime.now() : now).withNano(0);
String category = suggestedCategory(memoryType);
MemoryDraft draft = new MemoryDraft(
projectCode, building, unit, room,
"FOLLOW_UP".equals(memoryType) ? "待跟进" : "住户需求",
category,
defaultTitle("", memoryType, new MemoryDraft(projectCode, building, unit, room,
"", "", summary, "", "", format(occurredAt), null)),
category, "", summary, "", "", format(occurredAt), null)),
summary, "", "FOLLOW_UP".equals(memoryType) ? "IN_PROGRESS" : "OPEN",
format(occurredAt), null);
String status = missing.isEmpty() ? "DRAFT" : "NEEDS_INPUT";
return new CandidatePlan(projectCode, memoryType, status, draft, List.copyOf(missing), explicit ? 0.92 : 0.78);
return new CandidatePlan(projectCode, memoryType, "DRAFT", draft, List.copyOf(missing), explicit ? 0.92 : 0.78);
}
static CandidatePlan continuePlan(AihrKnowledgePrincipal principal, String memoryType, String existingProjectCode,
@@ -456,23 +568,33 @@ public class AihrMemoryService {
return null;
}
String query = clean(rawQuery);
if (looksLikeQuestion(query) || detectPlan(principal, query, LocalDateTime.now().withNano(0)) != null) {
if (looksLikeQuestion(query) || containsAny(query, "另外记", "再记一条", "新的一条", "另记一条")) {
return null;
}
CandidatePlan fresh = detectPlan(principal, query, LocalDateTime.now().withNano(0));
if (fresh != null && !sameCapture(memoryType, existing, fresh)) {
return null;
}
String resolvedProject = hasText(existingProjectCode) ? existingProjectCode
: resolveProjectCode(principal == null ? Set.of() : principal.projectCodes(), query);
String building = hasText(existing.buildingName()) ? existing.buildingName() : match(BUILDING, query, 0);
String unit = hasText(existing.unitName()) ? existing.unitName() : match(UNIT, query, 0);
String room = hasText(existing.roomNo()) ? existing.roomNo() : matchRoom(query);
String buildingFromQuery = match(BUILDING, query, 0);
String unitFromQuery = match(UNIT, query, 0);
String roomFromQuery = matchRoom(query);
String building = hasText(buildingFromQuery) ? buildingFromQuery : existing.buildingName();
String unit = hasText(unitFromQuery) ? unitFromQuery : existing.unitName();
String room = hasText(roomFromQuery) ? roomFromQuery : existing.roomNo();
String summary = clean(existing.summary());
if (summary.isEmpty() && containsAny(query, "需要", "希望", "想要", "要求", "咨询", "报修", "投诉", "跟进", "回访")) {
summary = truncate(redactMemoryText(query), 1000);
if (fresh != null && hasText(fresh.draft().summary())) {
summary = fresh.draft().summary();
} else if (summary.isEmpty() && containsAny(query, "需要", "希望", "想要", "要求", "咨询", "报修", "投诉", "跟进", "回访")) {
summary = truncate(redactMemoryText(stripCaptureCommand(query)), 1000);
}
boolean progressed = (!hasText(existingProjectCode) && hasText(resolvedProject))
|| (!hasText(existing.buildingName()) && hasText(building))
|| (!hasText(existing.unitName()) && hasText(unit))
|| (!hasText(existing.roomNo()) && hasText(room))
|| (!hasText(existing.summary()) && hasText(summary));
|| (!hasText(existing.summary()) && hasText(summary))
|| fresh != null;
if (!progressed) {
return null;
}
@@ -491,7 +613,7 @@ public class AihrMemoryService {
resolvedProject, building, unit, room, defaultCategory(existing.category(), memoryType), title,
summary, existing.detail(), initialStatus(existing.status(), memoryType),
existing.occurredAt(), existing.followUpAt());
return new CandidatePlan(resolvedProject, memoryType, missing.isEmpty() ? "DRAFT" : "NEEDS_INPUT",
return new CandidatePlan(resolvedProject, fresh == null ? memoryType : fresh.memoryType(), "DRAFT",
merged, List.copyOf(missing), 0.92);
}
@@ -505,7 +627,7 @@ public class AihrMemoryService {
select id, project_code, memory_type, draft_json, version, expires_at
from aihr_memory_candidate
where tenant_id = ? and owner_user_id = ? and source_conversation_id = ?
and status = 'NEEDS_INPUT' and expires_at > now()
and status in ('DRAFT', 'NEEDS_INPUT') and expires_at > now()
order by update_time desc, id desc
limit 1
""", (rs, rowNum) -> new ContinuationRow(
@@ -527,7 +649,7 @@ public class AihrMemoryService {
update aihr_memory_candidate
set project_code = ?, status = ?, draft_json = ?, missing_fields_json = ?,
candidate_hash = ?, version = version + 1, update_time = now()
where tenant_id = ? and owner_user_id = ? and id = ? and status = 'NEEDS_INPUT' and version = ?
where tenant_id = ? and owner_user_id = ? and id = ? and status in ('DRAFT', 'NEEDS_INPUT') and version = ?
""", hasText(merged.projectCode()) ? merged.projectCode() : null, merged.status(),
json(merged.draft()), json(merged.missingFields()), candidateHash,
principal.tenantId(), principal.userId(), row.id(), row.version());
@@ -535,7 +657,7 @@ public class AihrMemoryService {
return null;
}
return new MemoryCandidateResponse(
row.id(), row.version() + 1, merged.status(), merged.memoryType(), PROJECT_SERVICE,
row.id(), row.version() + 1, merged.status(), merged.memoryType(), ASSISTANT_CAPTURE,
merged.draft(), merged.missingFields(), format(row.expiresAt()));
}
@@ -553,7 +675,7 @@ public class AihrMemoryService {
(tenant_id, owner_user_id, source_conversation_id, source_request_id, candidate_hash,
project_code, memory_type, target_domain, status, draft_json, missing_fields_json,
confidence, version, expires_at, create_time, update_time)
values (?, ?, ?, ?, ?, ?, ?, 'PROJECT_SERVICE', ?, ?, ?, ?, 1, ?, now(), now())
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, now(), now())
""", Statement.RETURN_GENERATED_KEYS);
statement.setString(1, principal.tenantId());
statement.setLong(2, principal.userId());
@@ -566,11 +688,12 @@ public class AihrMemoryService {
statement.setNull(6, Types.VARCHAR);
}
statement.setString(7, plan.memoryType());
statement.setString(8, plan.status());
statement.setString(9, json(plan.draft()));
statement.setString(10, json(plan.missingFields()));
statement.setDouble(11, plan.confidence());
statement.setTimestamp(12, Timestamp.valueOf(expiresAt));
statement.setString(8, ASSISTANT_CAPTURE);
statement.setString(9, plan.status());
statement.setString(10, json(plan.draft()));
statement.setString(11, json(plan.missingFields()));
statement.setDouble(12, plan.confidence());
statement.setTimestamp(13, Timestamp.valueOf(expiresAt));
return statement;
}, key);
} catch (DuplicateKeyException duplicate) {
@@ -580,7 +703,7 @@ public class AihrMemoryService {
if (id == null) {
throw new ServiceException("创建记忆候选失败", 500);
}
return new MemoryCandidateResponse(id.longValue(), 1, plan.status(), plan.memoryType(), PROJECT_SERVICE,
return new MemoryCandidateResponse(id.longValue(), 1, plan.status(), plan.memoryType(), ASSISTANT_CAPTURE,
plan.draft(), plan.missingFields(), format(expiresAt));
}
@@ -614,6 +737,90 @@ public class AihrMemoryService {
return rows.get(0);
}
private ConfirmMemoryResponse confirmAssistantCapture(AihrKnowledgePrincipal principal, CandidateRow candidate,
MemoryDraft draft, ConfirmMemoryRequest request,
String saveScope, String idempotencyKey,
String requestHash) {
if (!hasText(draft.summary())) {
throw badRequest("summary 必填");
}
String projectCode = clean(draft.projectCode());
if (hasText(projectCode) && !principal.projectCodes().contains(projectCode)) {
throw new ServiceException("所属项目不存在或无权访问", HttpStatus.FORBIDDEN);
}
LocalDateTime occurredAt = parseDateTime(draft.occurredAt(), "occurredAt", false);
LocalDateTime followUpAt = Boolean.TRUE.equals(request.enableReminder())
? parseDateTime(draft.followUpAt(), "followUpAt", false)
: null;
String targetDomain = "PRIVATE".equals(saveScope) ? "PERSONAL" : "COMPANY";
String deliveryStatus = "PRIVATE".equals(saveScope) ? "NOT_REQUIRED" : "PENDING";
long captureId;
try {
captureId = insertAssistantCapture(principal, candidate, draft, projectCode, saveScope,
deliveryStatus, occurredAt, followUpAt, idempotencyKey, requestHash);
int updated = jdbcTemplate.update("""
update aihr_memory_candidate
set status = 'CONFIRMED', target_domain = ?, target_id = ?, target_version = 1,
version = version + 1, confirm_idempotency_key = ?, confirm_request_hash = ?,
confirmed_at = now(), draft_json = null, missing_fields_json = null, update_time = now()
where tenant_id = ? and owner_user_id = ? and id = ? and status = 'DRAFT'
and target_domain = 'ASSISTANT_CAPTURE' and version = ?
""", targetDomain, captureId, idempotencyKey, requestHash, principal.tenantId(),
principal.userId(), candidate.id(), request.expectedVersion());
if (updated != 1) throw conflict("确认卡已更新,请刷新后重试");
} catch (DuplicateKeyException ex) {
throw conflict("该 idempotencyKey 已用于其他确认请求");
} catch (DataAccessException ex) {
throw new ServiceException("统一采集数据库迁移未执行", 503);
}
return new ConfirmMemoryResponse(targetDomain, captureId, 1);
}
private long insertAssistantCapture(AihrKnowledgePrincipal principal, CandidateRow candidate, MemoryDraft draft,
String projectCode, String saveScope, String deliveryStatus,
LocalDateTime occurredAt, LocalDateTime followUpAt,
String idempotencyKey, String requestHash) {
KeyHolder key = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement("""
insert into aihr_assistant_capture
(tenant_id, owner_user_id, project_code, save_scope, suggested_type,
building_name, unit_name, room_no, category, summary, detail, occurred_at,
follow_up_at, source_conversation_id, source_request_id, delivery_status,
confirm_idempotency_key, confirm_request_hash, version, create_time, update_time)
select tenant_id, owner_user_id, ?, ?, memory_type, ?, ?, ?, ?, ?, ?, ?, ?,
source_conversation_id, source_request_id, ?, ?, ?, 1, now(), now()
from aihr_memory_candidate
where tenant_id = ? and owner_user_id = ? and id = ? and status = 'DRAFT'
""", Statement.RETURN_GENERATED_KEYS);
if (hasText(projectCode)) statement.setString(1, projectCode); else statement.setNull(1, Types.VARCHAR);
statement.setString(2, saveScope);
nullable(statement, 3, draft.buildingName());
nullable(statement, 4, draft.unitName());
nullable(statement, 5, draft.roomNo());
statement.setString(6, defaultCategory(draft.category(), candidate.memoryType()));
statement.setString(7, draft.summary());
nullable(statement, 8, draft.detail());
statement.setTimestamp(9, Timestamp.valueOf(occurredAt));
if (followUpAt == null) statement.setNull(10, Types.TIMESTAMP);
else statement.setTimestamp(10, Timestamp.valueOf(followUpAt));
statement.setString(11, deliveryStatus);
statement.setString(12, idempotencyKey);
statement.setString(13, requestHash);
statement.setString(14, principal.tenantId());
statement.setLong(15, principal.userId());
statement.setLong(16, candidate.id());
return statement;
}, key);
Number id = key.getKey();
if (id == null) throw conflict("确认卡状态已变化");
return id.longValue();
}
private static void nullable(PreparedStatement statement, int index, String value) throws SQLException {
if (hasText(value)) statement.setString(index, value); else statement.setNull(index, Types.VARCHAR);
}
private long insertServiceMemory(AihrKnowledgePrincipal principal, CandidateRow candidate, MemoryDraft draft,
LocalDateTime occurredAt, LocalDateTime followUpAt) {
KeyHolder key = new GeneratedKeyHolder();
@@ -776,7 +983,7 @@ public class AihrMemoryService {
}
private static String defaultCategory(String category, String memoryType) {
return hasText(category) ? category : ("FOLLOW_UP".equals(memoryType) ? "待跟进" : "住户需求");
return hasText(category) ? category : suggestedCategory(memoryType);
}
private static String defaultTitle(String title, String memoryType, MemoryDraft draft) {
@@ -784,10 +991,51 @@ public class AihrMemoryService {
return title;
}
String location = clean(draft.buildingName()) + clean(draft.unitName()) + clean(draft.roomNo());
return ("FOLLOW_UP".equals(memoryType) ? "跟进事项:" : "住户需求:")
return suggestedCategory(memoryType) + ":"
+ (location.isEmpty() ? "待补充位置" : location);
}
private static String suggestedType(String query) {
if (containsAny(query, "已解决", "处理完成", "已经恢复", "成功处理", "优秀案例", "经验案例")) {
return "CASE";
}
if (containsAny(query, "我的想法", "我有个想法", "灵感", "心得", "个人经验", "我认为")) {
return "PERSONAL_NOTE";
}
if (containsAny(query, "用户画像", "住户画像", "独居", "空巢", "高龄", "老奶奶", "老爷爷")
|| Pattern.compile("[0-9一二三四五六七八九十百]{1,3}岁").matcher(query).find()) {
return "RESIDENT_PROFILE";
}
if (containsAny(query, "养老", "助餐", "家政", "陪诊", "代购", "上门服务", "保洁服务")) {
return "SERVICE_LEAD";
}
if (containsAny(query, "跟进", "回访", "联系", "提醒", "后续处理")) {
return "FOLLOW_UP";
}
return "PROJECT_NOTE";
}
private static String suggestedCategory(String memoryType) {
return switch (memoryType) {
case "SERVICE_LEAD" -> "服务线索";
case "RESIDENT_PROFILE" -> "住户画像";
case "CASE" -> "优秀案例";
case "PERSONAL_NOTE" -> "个人想法";
case "FOLLOW_UP" -> "待跟进事项";
default -> "工作记录";
};
}
private static boolean sameCapture(String existingType, MemoryDraft existing, CandidatePlan fresh) {
if (!clean(existingType).equals(fresh.memoryType())) return false;
String oldBuilding = clean(existing.buildingName());
String newBuilding = clean(fresh.draft().buildingName());
if (!oldBuilding.isEmpty() && !newBuilding.isEmpty() && !oldBuilding.equals(newBuilding)) return false;
String oldRoom = clean(existing.roomNo());
String newRoom = clean(fresh.draft().roomNo());
return oldRoom.isEmpty() || newRoom.isEmpty() || oldRoom.equals(newRoom) || !hasText(existing.unitName());
}
/**
* The shared/searchable record never copies client free text. It is rebuilt from validated location fields and a
* closed service taxonomy, so an unrecognised name or health phrase cannot enter FULLTEXT even if a detector misses.
@@ -861,7 +1109,7 @@ public class AihrMemoryService {
return false;
}
boolean domainTerm = containsAny(value, "住户", "业主", "房号", "项目记录", "服务记录", "需求",
"跟进", "回访", "维修", "投诉", "养老", "漏水", "门禁", "停车")
"跟进", "回访", "维修", "投诉", "养老", "漏水", "门禁", "停车", "保洁", "想法", "线索", "案例")
|| BUILDING.matcher(value).find();
boolean completeLocation = BUILDING.matcher(value).find()
&& UNIT.matcher(value).find()
@@ -877,15 +1125,27 @@ public class AihrMemoryService {
}
private static String recallAnswer(List<ServiceMemoryRecallItem> items) {
StringBuilder answer = new StringBuilder("已找到当前项目权限内的相关记录:");
StringBuilder answer = new StringBuilder("已找到你有权限查看的相关记录:");
for (ServiceMemoryRecallItem item : items) {
answer.append("\n- [").append(statusLabel(item.status())).append("] ")
answer.append("\n- [").append(recallStatusLabel(item)).append("] ")
.append(item.title()).append(":").append(item.summary())
.append("(").append(displayDateTime(item.occurredAt())).append(")");
}
return answer.toString();
}
private static String recallStatusLabel(ServiceMemoryRecallItem item) {
return switch (clean(item.domain())) {
case "PERSONAL" -> "仅自己";
case "COMPANY" -> switch (clean(item.status())) {
case "DELIVERED" -> "公司已流转";
case "FAILED" -> "公司流转待重试";
default -> "公司待流转";
};
default -> statusLabel(item.status());
};
}
private static String statusLabel(String status) {
return switch (clean(status)) {
case "OPEN" -> "待处理";
@@ -1092,6 +1352,13 @@ public class AihrMemoryService {
return NAMED_RESIDENT.matcher(redacted).replaceAll("匿名住户");
}
private static String stripCaptureCommand(String value) {
return clean(value).replaceFirst(
"^(?:请|麻烦)?(?:帮我)?(?:记一下|记录一下|保存一下|备忘)(?:这件事)?[::,,、\\s]*",
""
);
}
private static String match(Pattern pattern, String text, int group) {
Matcher matcher = pattern.matcher(text);
return matcher.find() ? clean(matcher.group(group)) : "";
@@ -1099,10 +1366,9 @@ public class AihrMemoryService {
private static String matchRoom(String text) {
Matcher matcher = ROOM.matcher(text);
if (!matcher.find()) {
return "";
}
return clean(matcher.group(1) == null ? matcher.group(2) : matcher.group(1));
if (matcher.find()) return clean(matcher.group(1) == null ? matcher.group(2) : matcher.group(1));
Matcher contextual = CONTEXTUAL_ROOM.matcher(text);
return contextual.find() ? clean(contextual.group(1)) : "";
}
private static boolean containsAny(String value, String... needles) {
@@ -1184,7 +1450,7 @@ public class AihrMemoryService {
}
private record ConfirmFingerprint(Long candidateId, Long expectedVersion, MemoryDraft draft,
boolean enableReminder) {
boolean enableReminder, String saveScope) {
}
private record ContinuationRow(
@@ -252,7 +252,7 @@ class AihrKnowledgeQueryServiceTest {
var draft = new MemoryDraft("P1", "3栋", "2单元", "1201", "住户需求", "养老咨询",
"住户希望了解养老服务", "", "OPEN", "2026-07-19T08:30:00", null);
var candidate = new MemoryCandidateResponse(
31L, 1, "DRAFT", "RESIDENT_NEED", "PROJECT_SERVICE", draft, List.of(),
31L, 1, "DRAFT", "SERVICE_LEAD", "ASSISTANT_CAPTURE", draft, List.of(),
"2026-07-20T08:30:00");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
@@ -262,6 +262,8 @@ class AihrKnowledgeQueryServiceTest {
new SearchResponse("住户需求", "sop", "当前资料中没有足够依据", "", List.of(), List.of(),
List.of(), List.of(), List.of(), List.of(), "test-v1", null));
when(memory.recall(any(), anyString(), any())).thenReturn(ServiceMemoryRecall.empty());
when(memory.isProjectMemoryCandidateIntent(principal,
"记一下,3栋2单元1201室住户希望了解养老服务")).thenReturn(true);
when(memory.detectForQuery(eq(principal), anyString(), any(), anyString())).thenReturn(candidate);
var service = new AihrKnowledgeQueryService(resolver, appService, access, sop,
audit, jdbc, mock(AihrKnowledgeDataToolService.class),
@@ -271,12 +273,15 @@ class AihrKnowledgeQueryServiceTest {
"记一下,3栋2单元1201室住户希望了解养老服务", List.of(), "sop", null, "mobile", 5, null));
assertEquals(candidate, result.memoryCandidate());
assertTrue(result.answer().isEmpty());
assertTrue(result.citations().isEmpty());
verify(sop, never()).searchAuthorized(any(), any(), any());
assertTrue(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(result)
.contains("\"memoryCandidate\""));
verify(memory).detectForQuery(eq(principal), eq("记一下,3栋2单元1201室住户希望了解养老服务"),
any(), eq(result.requestId()));
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(), eq(List.of("sop")),
eq(List.of("MEMORY_CANDIDATE")), eq("SUCCESS"), anyLong(), eq("test-v1"));
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(), eq(List.of()),
eq(List.of("MEMORY_CANDIDATE")), eq("SUCCESS"), anyLong(), eq("service-memory-v1"));
}
@Test
@@ -429,6 +434,42 @@ class AihrKnowledgeQueryServiceTest {
eq("service-memory-v1"));
}
@Test
void ownerScopedAssistantCaptureCanBeRecalledWithoutKnowledgeSpace() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
var appService = mock(AihrKnowledgeAppService.class);
var access = mock(AihrKnowledgeAccessService.class);
var memory = mock(AihrMemoryService.class);
var audit = mock(AihrKnowledgeQueryAuditService.class);
var principal = new AihrKnowledgePrincipal("000000", 7L, "app_user", "employee-7",
Set.of("employee"), Set.of("P1"), "app");
var app = new AuthenticatedApp(1L, "000000", "yc_mobile", "员工端", "SESSION", 60, null);
var item = new ServiceMemoryRecallItem(
601L, "P1", "服务线索:三栋3203", "需要保洁服务。", "NOT_REQUIRED",
"2026-07-20T01:13:00", "2026-07-20T01:13:00", "PERSONAL");
when(resolver.current()).thenReturn(principal);
when(appService.requireSessionApp("000000", "app")).thenReturn(app);
when(access.resolveInternalSpaceIds(principal, app, List.of(), "READ"))
.thenThrow(new ServiceException("no scope", 403));
when(memory.recall(eq(principal), anyString(), any())).thenReturn(
new ServiceMemoryRecall(List.of(item), "已找到你有权限查看的相关记录:需要保洁服务。"));
var service = new AihrKnowledgeQueryService(resolver, appService, access,
mock(AihrSopSeedService.class), audit, mock(JdbcTemplate.class),
mock(AihrKnowledgeDataToolService.class), mock(AihrKnowledgeConversationService.class), memory);
var result = service.queryInternal(new QueryRequest(
"查一下之前三栋3203的保洁记录", List.of(), "sop", null, "mobile", 5, null));
assertFalse(result.noEvidence());
assertTrue(result.answer().contains("需要保洁服务"));
assertEquals("PERSONAL", result.citations().get(0).domain());
assertEquals("assistant-capture:601", result.citations().get(0).docId());
assertEquals(List.of("PERSONAL"), result.usedSpaceCodes());
verify(audit).recordFinal(eq(result.requestId()), eq(principal), eq(app), anyString(),
eq(List.of("PERSONAL")), eq(List.of("PERSONAL")), eq("SUCCESS"), anyLong(),
eq("service-memory-v1"));
}
@Test
void resourceDownloadRecomputesCurrentApplicationAndPrincipalScope() {
var resolver = mock(AihrKnowledgePrincipalResolver.class);
@@ -39,13 +39,13 @@ class AihrMemoryServiceTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 7, 19, 8, 30);
@Test
void singleProjectCompleteResidentNeedBecomesDraftWithoutInventingIdentity() {
void singleProjectCompleteServiceLeadBecomesDraftWithoutInventingIdentity() {
var plan = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"记一下,3栋2单元1201室住户希望了解养老服务,电话13812345678", NOW);
assertNotNull(plan);
assertEquals("DRAFT", plan.status());
assertEquals("RESIDENT_NEED", plan.memoryType());
assertEquals("SERVICE_LEAD", plan.memoryType());
assertEquals("P1", plan.projectCode());
assertEquals("3栋", plan.draft().buildingName());
assertEquals("2单元", plan.draft().unitName());
@@ -55,7 +55,7 @@ class AihrMemoryServiceTest {
}
@Test
void emptyOrAmbiguousProjectScopeFailsClosedAsNeedsInput() {
void missingProjectIsOptionalForUnifiedCapture() {
var noProject = AihrMemoryService.detectPlan(principal(Set.of()),
"记一下,3栋2单元1201室住户希望了解养老服务", NOW);
var ambiguous = AihrMemoryService.detectPlan(principal(Set.of("P1", "P2")),
@@ -65,8 +65,8 @@ class AihrMemoryServiceTest {
var overlappingCode = AihrMemoryService.detectPlan(principal(Set.of("P1", "P10")),
"记一下,P10项目3栋2单元1201室住户希望了解养老服务", NOW);
assertEquals("NEEDS_INPUT", noProject.status());
assertEquals("NEEDS_INPUT", ambiguous.status());
assertEquals("DRAFT", noProject.status());
assertEquals("DRAFT", ambiguous.status());
assertTrue(noProject.missingFields().contains("projectCode"));
assertTrue(ambiguous.missingFields().contains("projectCode"));
assertEquals("DRAFT", explicit.status());
@@ -75,6 +75,72 @@ class AihrMemoryServiceTest {
assertEquals("P10", overlappingCode.projectCode());
}
@Test
void explicitCaptureAcceptsBareRoomAndKeepsMissingLocationOptional() {
var compact = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"帮我记一下三栋3203需要保洁服务。", NOW);
var complete = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"记一下三栋二单元3103需要保洁服务。", NOW);
assertNotNull(compact);
assertEquals("DRAFT", compact.status());
assertEquals("SERVICE_LEAD", compact.memoryType());
assertEquals("三栋", compact.draft().buildingName());
assertEquals("3203", compact.draft().roomNo());
assertEquals("需要保洁服务。", compact.draft().summary());
assertTrue(compact.missingFields().contains("unitName"));
assertNotNull(complete);
assertEquals("DRAFT", complete.status());
assertEquals("二单元", complete.draft().unitName());
assertEquals("3103", complete.draft().roomNo());
assertFalse(complete.missingFields().contains("roomNo"));
}
@Test
void repeatedExplicitCaptureUpdatesOriginalInsteadOfCreatingAnotherCard() {
var first = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"帮我记一下三栋3203需要保洁服务。", NOW);
var merged = AihrMemoryService.continuePlan(principal(Set.of("P1")), first.memoryType(),
first.projectCode(), first.draft(), "记一下三栋二单元3103需要保洁服务。");
assertNotNull(merged);
assertEquals("DRAFT", merged.status());
assertEquals("二单元", merged.draft().unitName());
assertEquals("3103", merged.draft().roomNo());
}
@Test
void unifiedCaptureCanBeSavedPrivatelyWithoutCompleteLocation() throws Exception {
ObjectMapper mapper = new ObjectMapper();
MemoryDraft draft = new MemoryDraft(
"P1", "三栋", "", "3203", "服务线索", "服务线索:三栋3203",
"需要保洁服务", "", "OPEN", "2026-07-19T08:30:00", null);
ConfirmJdbc jdbc = new ConfirmJdbc(mapper.writeValueAsString(draft), 1,
LocalDateTime.now().plusHours(12), "ASSISTANT_CAPTURE", "SERVICE_LEAD");
AihrKnowledgePrincipalResolver resolver = mock(AihrKnowledgePrincipalResolver.class);
when(resolver.current()).thenReturn(principal(Set.of("P1")));
AihrMemoryService service = new AihrMemoryService(jdbc, mapper, resolver);
var response = service.confirm(7L,
new ConfirmMemoryRequest(1L, "capture-confirm-private-001", draft, false, "PRIVATE"));
assertEquals("PERSONAL", response.targetDomain());
assertEquals(501L, response.targetId());
assertEquals("CONFIRMED", jdbc.status);
assertEquals("PERSONAL", jdbc.targetDomain);
}
@Test
void contextualRoomParserDoesNotTreatYearDurationOrMoneyAsRoom() {
var plan = AihrMemoryService.detectPlan(principal(Set.of("P1")),
"记一下三栋2026年保洁预算300元,20分钟后再联系。", NOW);
assertNotNull(plan);
assertEquals("", plan.draft().roomNo());
assertTrue(plan.missingFields().contains("roomNo"));
}
@Test
void ordinaryQuestionDoesNotCreateCandidate() {
assertNull(AihrMemoryService.detectPlan(principal(Set.of("P1")), "住户有哪些跟进需求?", NOW));
@@ -349,6 +415,9 @@ class AihrMemoryServiceTest {
assertTrue(schema.contains("uk_aihr_memory_candidate_confirm_key"));
assertTrue(schema.contains("idx_aihr_memory_candidate_expiry"));
assertTrue(schema.contains("idx_aihr_memory_candidate_cleanup"));
assertTrue(schema.contains("aihr_assistant_capture"));
assertTrue(schema.contains("idx_aihr_assistant_capture_delivery"));
assertTrue(migration.contains("aihr_assistant_capture"));
assertTrue(migration.contains("table_name = 'aihr_knowledge_info'"));
assertTrue(migration.contains("index_name = 'idx_aihr_memory_candidate_expiry'"));
assertTrue(migration.contains("index_name = 'idx_aihr_memory_candidate_cleanup'"));
@@ -399,6 +468,8 @@ class AihrMemoryServiceTest {
private int version;
private final LocalDateTime expiresAt;
private String status = "DRAFT";
private String targetDomain = "PROJECT_SERVICE";
private String memoryType = "RESIDENT_NEED";
private String confirmKey;
private String confirmHash;
private Long targetId;
@@ -421,6 +492,13 @@ class AihrMemoryServiceTest {
this.expiresAt = expiresAt;
}
private ConfirmJdbc(String draftJson, int version, LocalDateTime expiresAt,
String targetDomain, String memoryType) {
this(draftJson, version, expiresAt);
this.targetDomain = targetDomain;
this.memoryType = memoryType;
}
@Override
public <T> List<T> query(String sql, RowMapper<T> rowMapper, Object... args) {
if (sql.contains("from aihr_service_memory")) {
@@ -451,8 +529,8 @@ class AihrMemoryServiceTest {
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(7L);
when(rs.getString("project_code")).thenReturn("P1");
when(rs.getString("memory_type")).thenReturn("RESIDENT_NEED");
when(rs.getString("target_domain")).thenReturn("PROJECT_SERVICE");
when(rs.getString("memory_type")).thenAnswer(ignored -> memoryType);
when(rs.getString("target_domain")).thenAnswer(ignored -> targetDomain);
when(rs.getString("status")).thenReturn(status);
when(rs.getString("draft_json")).thenReturn(draftJson);
when(rs.getString("missing_fields_json")).thenReturn("[]");
@@ -495,10 +573,12 @@ class AihrMemoryServiceTest {
}
if (sql.contains("set status = 'CONFIRMED'")) {
status = "CONFIRMED";
targetId = ((Number) args[0]).longValue();
boolean genericCapture = sql.contains("target_domain = ?");
if (genericCapture) targetDomain = String.valueOf(args[0]);
targetId = ((Number) args[genericCapture ? 1 : 0]).longValue();
targetVersion = 1;
confirmKey = String.valueOf(args[1]);
confirmHash = String.valueOf(args[2]);
confirmKey = String.valueOf(args[genericCapture ? 2 : 1]);
confirmHash = String.valueOf(args[genericCapture ? 3 : 2]);
version++;
return 1;
}
@@ -78,6 +78,39 @@ CREATE TABLE IF NOT EXISTS `aihr_service_memory_version` (
KEY `idx_aihr_service_memory_version_scope` (`tenant_id`, `project_code`, `memory_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目服务记忆版本快照';
CREATE TABLE IF NOT EXISTS `aihr_assistant_capture` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '统一采集记录ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`owner_user_id` bigint NOT NULL COMMENT '记录员工',
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份确认的项目编码',
`save_scope` varchar(20) NOT NULL COMMENT 'PRIVATE/COMPANY',
`suggested_type` varchar(30) NOT NULL COMMENT 'SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE',
`building_name` varchar(100) DEFAULT NULL COMMENT '可选楼栋',
`unit_name` varchar(100) DEFAULT NULL COMMENT '可选单元',
`room_no` varchar(100) DEFAULT NULL COMMENT '可选房号',
`category` varchar(50) NOT NULL COMMENT '系统建议分类',
`summary` varchar(1000) NOT NULL COMMENT '用户确认后的最小必要摘要',
`detail` text COMMENT '用户确认后的必要补充',
`occurred_at` datetime NOT NULL COMMENT '发生或获知时间',
`follow_up_at` datetime DEFAULT NULL COMMENT '用户确认的跟进时间',
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID',
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
`delivery_status` varchar(20) NOT NULL COMMENT 'NOT_REQUIRED/PENDING/DELIVERED/FAILED',
`delivery_attempts` int NOT NULL DEFAULT 0 COMMENT '企业流转尝试次数',
`next_delivery_at` datetime DEFAULT NULL COMMENT '下次流转时间',
`external_record_id` varchar(100) DEFAULT NULL COMMENT '外部系统记录ID',
`last_error` varchar(500) DEFAULT NULL COMMENT '最近一次公开错误',
`confirm_idempotency_key` varchar(100) NOT NULL COMMENT '确认幂等键',
`confirm_request_hash` char(64) NOT NULL COMMENT '确认请求规范化哈希',
`version` int NOT NULL DEFAULT 1 COMMENT '记录版本',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_assistant_capture_confirm` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
KEY `idx_aihr_assistant_capture_owner` (`tenant_id`, `owner_user_id`, `save_scope`, `create_time`),
KEY `idx_aihr_assistant_capture_delivery` (`delivery_status`, `next_delivery_at`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作助手确认式统一采集记录';
-- 生产旧库与 MySQL 8 新库的排序规则可能不同;以现有租户主表为事实源动态对齐。
SET @aihr_memory_collation := (
SELECT collation_name
@@ -95,6 +128,8 @@ SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory` CONVERT TO CHA
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory_version` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_assistant_capture` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := NULL;
SET @aihr_memory_collation := NULL;
@@ -78,6 +78,50 @@ CREATE TABLE IF NOT EXISTS `aihr_service_memory_version` (
KEY `idx_aihr_service_memory_version_scope` (`tenant_id`, `project_code`, `memory_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目服务记忆版本快照';
CREATE TABLE IF NOT EXISTS `aihr_assistant_capture` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '统一采集记录ID',
`tenant_id` varchar(20) NOT NULL COMMENT '租户编号',
`owner_user_id` bigint NOT NULL COMMENT '记录员工',
`project_code` varchar(100) DEFAULT NULL COMMENT '经当前身份确认的项目编码',
`save_scope` varchar(20) NOT NULL COMMENT 'PRIVATE/COMPANY',
`suggested_type` varchar(30) NOT NULL COMMENT '系统建议类型',
`building_name` varchar(100) DEFAULT NULL COMMENT '可选楼栋',
`unit_name` varchar(100) DEFAULT NULL COMMENT '可选单元',
`room_no` varchar(100) DEFAULT NULL COMMENT '可选房号',
`category` varchar(50) NOT NULL COMMENT '系统建议分类',
`summary` varchar(1000) NOT NULL COMMENT '用户确认后的最小必要摘要',
`detail` text COMMENT '用户确认后的必要补充',
`occurred_at` datetime NOT NULL COMMENT '发生或获知时间',
`follow_up_at` datetime DEFAULT NULL COMMENT '用户确认的跟进时间',
`source_conversation_id` varchar(64) NOT NULL DEFAULT '' COMMENT '来源会话ID',
`source_request_id` varchar(64) NOT NULL COMMENT '来源问答请求ID',
`delivery_status` varchar(20) NOT NULL COMMENT 'NOT_REQUIRED/PENDING/DELIVERED/FAILED',
`delivery_attempts` int NOT NULL DEFAULT 0 COMMENT '企业流转尝试次数',
`next_delivery_at` datetime DEFAULT NULL COMMENT '下次流转时间',
`external_record_id` varchar(100) DEFAULT NULL COMMENT '外部系统记录ID',
`last_error` varchar(500) DEFAULT NULL COMMENT '最近一次公开错误',
`confirm_idempotency_key` varchar(100) NOT NULL COMMENT '确认幂等键',
`confirm_request_hash` char(64) NOT NULL COMMENT '确认请求规范化哈希',
`version` int NOT NULL DEFAULT 1 COMMENT '记录版本',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_time` datetime NOT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_aihr_assistant_capture_confirm` (`tenant_id`, `owner_user_id`, `confirm_idempotency_key`),
KEY `idx_aihr_assistant_capture_owner` (`tenant_id`, `owner_user_id`, `save_scope`, `create_time`),
KEY `idx_aihr_assistant_capture_delivery` (`delivery_status`, `next_delivery_at`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='工作助手确认式统一采集记录';
SET @aihr_memory_ddl := IF(
EXISTS(
SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'aihr_assistant_capture'
AND column_name = 'follow_up_at'
),
'SELECT 1',
'ALTER TABLE `aihr_assistant_capture` ADD COLUMN `follow_up_at` datetime DEFAULT NULL COMMENT ''用户确认的跟进时间'' AFTER `occurred_at`'
);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := IF(
EXISTS(
SELECT 1 FROM information_schema.statistics
@@ -123,6 +167,8 @@ SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory` CONVERT TO CHA
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_service_memory_version` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := CONCAT('ALTER TABLE `aihr_assistant_capture` CONVERT TO CHARACTER SET utf8mb4 COLLATE ', @aihr_memory_collation);
PREPARE aihr_memory_stmt FROM @aihr_memory_ddl; EXECUTE aihr_memory_stmt; DEALLOCATE PREPARE aihr_memory_stmt;
SET @aihr_memory_ddl := NULL;
SET @aihr_memory_collation := NULL;
@@ -7,6 +7,7 @@
> v1.3 更新(2026-07-11):阶段二新增员工个人 AI 助理,首批做个人资料/链接收藏、时间检索、带引用问答和工作整理;PPT 生成后置为 P1;北森等外部个人数据归阶段三。
> v1.4 更新(2026-07-19):个人助理增加确认式长期记忆,并把一级入口收敛为“工作助手 / 查全网”;内部个人资料、项目记录和企业知识默认自动路由,来源在答案中透明展示。
> v1.5 更新(2026-07-19):“工作上报”由传统表单主路径改为微信式语音/照片/视频会话,AI 整理并按需追问,用户确认后才提交既有审核链。
> v1.6 更新(2026-07-19):“记一下”升级为确认式统一采集。系统先建议服务线索、住户画像、优秀案例、个人想法、待跟进事项或工作记录,用户选择仅自己保存或提交公司处理;位置字段缺失不阻断保存,公司处理在正式接口接入前只标记待流转。
> 与《银城员工端APP分阶段实施总纲》《AI陪练二期开发推进计划》的关系:本方案是增量修订,不推翻既有阶段划分;§4.2 回写清单已于 2026-07-08 执行——总纲升 v0.3、二期计划升 v0.8。BRD 已升至 v1.4,同步个人助理与工作上报交互定案;BRD 4.4 师徒演练等旧口径仍待后续版本继续修订。
---
@@ -160,7 +161,7 @@
| P0 | 个人资料问答与总结 | 可选择个人资料/企业知识范围;答案展示引用来源,权限不足或无结果时明确提示 |
| P0 | 时间与主题检索 | 支持“从哪天到哪天”“哪些资料与保洁管理相关”等查询 |
| P0 | 工作问题与灵感整理 | 将零散输入整理为结论、行动项、待确认事项,不替员工自动对外发送 |
| P0 | 确认式长期记忆 | 从文字/ASR 转写识别住户需求、待跟进事项和个人经验;必须经用户确认,个人经验写个人域,住户需求/跟进事项写项目业务域 |
| P0 | 确认式统一采集 | 从文字/ASR 转写建议服务线索、住户画像、优秀案例、个人想法、待跟进事项和工作记录;缺少项目/楼栋/单元/房号仍可保存;用户明确选择仅自己保存或提交公司处理 |
| P1 | 多资料对比、周报/月报、汇报提纲 | 输出可编辑结构,保留每个结论的资料来源 |
| P1 | PPT 生成与导出 | 先确认大纲和模板再生成;不作为阶段二首批上线阻塞项 |
@@ -169,6 +170,7 @@
- 个人资料默认私有,分享给团队或沉淀企业知识库需员工明确操作并进入审核链。
- 企业密级权限继续生效;个人助理不得因“个人收藏”绕过账号等级 × 知识密级控制。
- 工作助手自动路由只覆盖已授权内部域;内部无命中时提示用户是否“查全网”,不得自动转发原问题或住户/项目数据。
- “提交公司处理”先写可靠 `PENDING` 流转记录;外部线索/工单接口、凭证和回执未确定时不得假装已送达。优秀案例可作为建议类型,但正式工作上报仍需进入既有确认与审核链。
- 网页内容保存原链接、抓取时间与正文快照;外链抓取需增加安全、版权和内容失效检查。
- 阶段三接入北森后,才允许在授权范围内分析考勤、请假、任职等 HR 数据;阶段二不复制或模拟这些外部数据。
+16 -15
View File
@@ -14,7 +14,7 @@
| 案例沉淀 `/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`、`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` |
| 工作助手确认式项目记忆 `/pages/user/sop/index`、`/pages/user/assistant/memories` | 既有 `POST /api/knowledge/query` 可选返回 `memoryCandidate`;`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm`、`POST .../{id}/dismiss`、`GET /api/aihr/service-memories`、`GET /api/aihr/service-memories/{id}` | 2026-07-19 最小切片只识别 `RESIDENT_NEED/FOLLOW_UP` 并保存到 `PROJECT_SERVICE`。用户文字或 ASR 转写进入同一候选检测;候选草稿不是长期记忆,只有当前员工显式确认后才按服务端解析出的 `tenant_id + project_code` 写入项目服务记录和版本表。多项目无法唯一判断时返回 `NEEDS_INPUT`,不能显示确认按钮;确认携带 `expectedVersion + idempotencyKey`,同键同内容重放返回原结果,同键不同内容或旧版本返回冲突。提醒默认关闭;首批不创建个人知识库、Qdrant collection,也不把个人想法写入项目域 |
| 工作助手确认式统一采集 `/pages/user/sop/index`、`/pages/user/assistant/memories` | 既有 `POST /api/knowledge/query` 可选返回 `memoryCandidate`;`GET /api/aihr/personal-assistant/memory-candidates?status=DRAFT`、`POST /api/aihr/personal-assistant/memory-candidates/{id}/confirm`、`POST .../{id}/dismiss`、`GET /api/aihr/personal-assistant/assistant-captures?limit=50`;旧项目记录继续使用 `/api/aihr/service-memories` | 文字或 ASR 转写复用同一候选检测。显式“记一下”生成 `ASSISTANT_CAPTURE/DRAFT`,项目和房屋位置缺失只作可选提示;系统建议 `SERVICE_LEAD/RESIDENT_PROFILE/CASE/PERSONAL_NOTE/FOLLOW_UP/PROJECT_NOTE`。确认必须带 `expectedVersion + idempotencyKey + saveScope(PRIVATE|COMPANY)`;PRIVATE 仅本人可见,COMPANY 写入 PENDING 待流转,正式外部接收端未配置前不得显示已送达。提醒默认关闭且单独确认;音色不在本次范围 |
| 资料处理 `/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` 才启用固定码。 |
@@ -222,9 +222,9 @@ Content-Type: application/json
空间、授权和应用管理接口统一位于 `/api/knowledge/admin`:`spaces`、`spaces/{id}/grants`、`spaces/{id}/documents`、`apps`、`apps/{id}/spaces`、`apps/{id}/rotate-token`。`DELETE /spaces/{id}/documents/{attachId}` 只解绑当前空间成员;其他空间仍引用同一 OSS 时原文件不会删除。仅知识平台管理角色可调用,API_TOKEN 创建或轮换后只返回一次明文。完整初始化、令牌保管、legacy 迁移与回滚步骤见 [KNOWLEDGE_PLATFORM_RUNBOOK.md](KNOWLEDGE_PLATFORM_RUNBOOK.md)。
## 工作助手项目服务记忆最小切片
## 工作助手确认式统一采集
该切片复用上面的短会话请求,不新增另一套问答 session。登录员工说“记一下:3 栋 2 单元 1201 室住户想了解养老服务,十天后回访”时,`POST /api/knowledge/query` 在正常回答字段之外可返回:
该链路复用上面的短会话请求,不新增另一套问答 session。登录员工说“帮我记一下三栋 3203 需要保洁服务”时,`POST /api/knowledge/query` 在正常回答字段之外可返回;缺少单元不会阻断用户保存:
```json
{
@@ -232,18 +232,18 @@ Content-Type: application/json
"id": 301,
"version": 1,
"status": "DRAFT",
"memoryType": "RESIDENT_NEED",
"targetDomain": "PROJECT_SERVICE",
"memoryType": "SERVICE_LEAD",
"targetDomain": "ASSISTANT_CAPTURE",
"draft": {
"buildingName": "3栋",
"unitName": "2单元",
"roomNo": "1201",
"category": "养老服务咨询",
"summary": "住户希望了解社区养老服务",
"unitName": "",
"roomNo": "3203",
"category": "服务线索",
"summary": "需要保洁服务",
"occurredAt": "2026-07-19T14:00:00+08:00",
"followUpAt": null
},
"missingFields": [],
"missingFields": ["unitName"],
"expiresAt": "2026-07-20T14:00:00+08:00"
}
}
@@ -259,17 +259,18 @@ Content-Type: application/json
{
"expectedVersion": 1,
"idempotencyKey": "memory-confirm-301-<stable-uuid>",
"saveScope": "COMPANY",
"enableReminder": false,
"draft": {
"buildingName": "3栋",
"unitName": "2单元",
"roomNo": "1201",
"category": "养老服务咨询",
"summary": "住户希望了解社区养老服务",
"unitName": "",
"roomNo": "3203",
"category": "服务线索",
"summary": "需要保洁服务",
"occurredAt": "2026-07-19T14:00:00+08:00",
"followUpAt": null
}
}
```
服务端必须在事务中锁定候选,重新解析当前员工有效项目,写入 `aihr_service_memory` 与 `aihr_service_memory_version` 后再把候选改为 `CONFIRMED`。`DRAFT` 默认 24 小时过期;`NEEDS_INPUT` 只用于继续追问。列表、详情和后续问答召回必须同时过滤当前租户和授权 `project_code`,不得因为记录由本人创建而绕过项目权限。
服务端在事务中锁定候选,写入 `aihr_assistant_capture` 后才把候选改为 `CONFIRMED`。`PRIVATE` 对应 `deliveryStatus=NOT_REQUIRED`;`COMPANY` 对应 `PENDING`。`DRAFT` 默认 24 小时过期,列表查询强制过滤当前租户与 owner。`GET /api/aihr/personal-assistant/assistant-captures?saveScope=COMPANY&limit=50` 只展示本人的企业流转状态;PENDING 不是已生成工单或已上报成功。后续 `/api/knowledge/query` 可受控召回确认记录,引用域分别为 `PERSONAL / COMPANY / PROJECT_SERVICE`;个人和公司采集按 `tenant_id + owner_user_id` 过滤,旧项目记录继续按项目权限过滤。
+4
View File
@@ -21,6 +21,10 @@
“已部署”不等于“正式试点通过”。当前可以确认生产包和基础运行状态,不能把未执行的正式账号、真机和业务窗口验收写成已完成。
### 未发布工作区增量(2026-07-20)
工作助手“记一下”统一采集已完成本地编码、数据库迁移、目标测试、390×844 视觉交互和真实 HTTP 召回验证;`PRIVATE` 仅本人可见,`COMPANY` 只写 `PENDING`。该增量尚未提交或部署,不属于上表的生产包;外部线索/工单投递、正式回执、个人文件/网页知识空间仍未实现。
## 2. 三端输入与 BRD 匹配
| 角色端 | 当前能力 | BRD 判断 |
+5 -5
View File
@@ -14,7 +14,7 @@
上述事实只证明本批版本和 schema 已发布。正式 OTP、真机能力、角色权限正反例和严格试点窗口仍需按发布后回归及 `DEMO_ACCEPTANCE.md` 验收。
2026-07-19 增量在上述基线上新增“项目服务记忆最小切片”:候选信息只有在当前员工明确确认后,才会写入其已授权项目的服务记录;本批不包含个人想法/经验的长期知识库、跨项目共享、自动提醒或向量化个人资料。同日的“工作上报语音会话化”把既有 `aihr_work_report` 纳入结构门禁,并补充 `request_key/request_hash` 和租户内提交人唯一索引,用于确认上报的幂等重试,不新建草稿或会话表。全网 AI 提供方加密主密钥改为数据库内部配置后,发布门禁统一为 `40/40`。
2026-07-20 待发布增量把“项目服务记忆最小切片”扩展为工作助手确认式统一采集:显式“记一下”生成候选,用户确认后按 `PRIVATE/COMPANY` 写入 `aihr_assistant_capture`;个人记录仅本人可见,公司记录先保持 `PENDING`,正式接收端和回执尚未实现。旧 `aihr_service_memory/version` 继续兼容项目记录;本批仍不包含个人文件/网页知识空间、自动派单或向量化个人资料。同批“工作上报语音会话化”只为既有 `aihr_work_report` 补 `request_key/request_hash` 和唯一索引。加入统一采集表后,待发布结构门禁为 `41/41`。
## 发布前置条件
@@ -57,7 +57,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_20260719_web_search_qwen_mysql8.sql
```
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、正式记录和版本留痕三张表;工作上报幂等迁移随后只补原表列与唯一索引。两者同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、统一采集、兼容项目记录和版本留痕四张表;工作上报幂等迁移随后只补原表列与唯一索引。两者同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
## 迁移后只读核验
@@ -82,7 +82,7 @@ WHERE table_schema = DATABASE()
'aihr_candidate_material', 'aihr_interview_result', 'aihr_candidate_employee_link',
'aihr_position_responsibility', 'aihr_sop_applicability',
'aihr_onboard_task', 'aihr_qualification_gate',
'aihr_memory_candidate', 'aihr_service_memory', 'aihr_service_memory_version',
'aihr_memory_candidate', 'aihr_assistant_capture', 'aihr_service_memory', 'aihr_service_memory_version',
'aihr_work_report'
)
ORDER BY table_name;
@@ -128,14 +128,14 @@ GROUP BY question_type
ORDER BY question_type;
```
预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、三张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表,以及三张项目服务记忆表和原工作上报表均存在;工作上报幂等字段为 `2/2`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新服务记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。
预期是三张场景/Rubric 表、岗位题库、训练反馈表、五张考试表、三张全网检索表、问题榜/激励六张表、三张证据表、知识平台治理表、三张候选人/面试表、四张岗位/SOP/任务/资格契约表,以及四张记忆表和原工作上报表均存在;工作上报幂等字段为 `2/2`,会话证据字段为 `8/8`,专项批量/内容快照字段为 `8/8`,候选资料审核字段为 `2/2`,面试复核字段为 `6/6`,字段查询覆盖所有列,内置 Prompt 数量为 `6`。五个岗位各有 `20` 个场景,`daily/special` 各有 `100` 道题;其中 `14` 个未完成正式审核的高风险场景及其 `28` 道题保持禁用。岗位/SOP/任务/资格表及新记忆表为空是允许的;这只证明 schema/seed 迁移完成,不证明已有员工确认过记忆、完整个人知识库或严格试点完成。
## 迁移后应用回归
1. 重启 `wygj-aihr.service`,确认状态为 `active`。
2. 检查根站、`/h5/`、租户接口和员工首页 API 的 HTTP 与业务码均为 `200`。
3. 用正式测试账号完成员工端训练、主管复盘和管理端场景/Prompt 只读检查;不要使用开发固定验证码验证生产安全策略。
4. 用具备单一项目授权的正式测试账号,在“工作助手”说出一条包含房号、住户需求和跟进时间的测试信息;确认候选卡前数据库不得出现正式服务记录,确认后只能写入该账号已授权的 `project_code`。随后新开对话查询该事项,必须能召回;双击确认不得重复写入。测试结束保留审计证据,不直接删除生产业务记录。
4. 用正式测试账号在“工作助手”说“帮我记一下三栋 3203 需要保洁服务”;确认前 `aihr_assistant_capture` 不得新增。分别验证 `PRIVATE` 仅本人可召回、`COMPANY` 只显示 `PENDING` 且没有虚假送达回执;新开对话按房号查询必须只命中对应记录,双击确认不得重复写入。测试结束保留审计证据,不直接删除生产业务记录。
5. 用员工测试账号进入“工作上报”,确认首屏为语音会话而非传统表单;完成一次整理、建议类型修改和显式确认。确认前 `aihr_work_report` 行数不变,重复提交同一 `request_key` 只有一条 `PENDING` 记录,“我的上报”返回同一 ID;普通员工审核仍为 `403`,审核通过不自动写入案例、知识或积分表。
6. 迁移完成后再按同一正式时间窗运行:
+2 -2
View File
@@ -21,7 +21,7 @@
| 5 | 案例沉淀 `/knowledge/cases` | 上传真实语音 → ASR 转写 → AI 整理 → 送审 → 入库 → 查看案例详情/回放原始音频 | 已完成语音转写、原始音频 OSS 留痕、整理、入库、学习点、主管点评和按项目权限查看持久化转写/摘要详情;预渲染视频样片仍未接入,不把音频回放当作“查看样片”完成证据 |
| 6 | SOP知识库 `/knowledge/sop` | 上传 txt/md/PDF/Word/Excel/PPT 文档或使用 seed 文档 → 检索 → 生成训练题 | 已完成闭环、引用 SOP 原文片段、训练题已生成 |
| 7 | 资料处理 `/knowledge/processing` | 打开页面 → 查看解析任务 → 查看处理链路 → 点“批量导入”选择少量文件或 ZIP | 可看到资料总量、等待/解析中/完成/失败、片段数、向量化状态、批次进度和事件列表;页面不出现“选择目录”“服务端导入” |
| 8 | 员工端工作助手 `/h5/#/pages/user/sop/index` | 语音或文字说“记一下:3 栋 2 单元 1201 室住户想了解养老服务,十天后回访” → 查看候选卡 → 先暂不保存,再重新生成并确认 → 打开“我的资料与记忆” → 新对话按房号查询 | 一级只显示“工作助手 / 查全网”;候选卡可修改、暂不保存、确认保存,提醒默认关闭;暂不保存不写正式记录,确认后只写当前授权项目且双击不重复;新对话能按项目权限召回并展示来源。该项只验收项目服务记忆最小切片,不代表完整个人资料库已交付 |
| 8 | 员工端工作助手 `/h5/#/pages/user/sop/index` | 语音或文字说“帮我记一下三栋 3203 需要保洁服务” → 查看系统建议卡 → 在缺少单元时分别验证“暂不保存”和“仅自己保存” → 再建一条选择“提交公司处理” → 打开“我的资料与记忆” | 一级只显示“工作助手 / 查全网”;位置缺失是可选提示而非阻断;卡片可修改、暂不保存、仅自己保存、提交公司处理,提醒默认关闭;同一会话重复描述更新原卡,“另外记一条”才生成新卡;个人记录显示仅自己,公司记录显示待流转,不能显示已送达 |
## AI陪练二期增量脚本
@@ -49,6 +49,6 @@
## 不演示
- 不演示 SSO、权限配置、知识图谱、数字人视频生成、绩效挂钩。
- 不把“项目服务记忆最小切片”演示成完整个人助理:个人文档/网页收藏、个人经验知识库、PPT 生成、自动提醒和项目服务记忆 Qdrant 向量化仍不在本批范围。
- 不把“确认式统一采集”演示成完整个人助理:个人文档/网页收藏、个人知识库向量化、PPT 生成仍不在本批范围;COMPANY/PENDING 也不代表正式线索或工单接口已经投递成功。
- 不承诺管理端 AI 面试、案例沉淀已写入正式业务数据库;三角色对练(管理端与移动端)记录写入 `aihr_practice_session`;候选人补充资料写 `sys_oss` + `aihr_candidate_material`,HR 可审核状态,但不做复杂审批流转;SOP 检索与文档上传使用 `aihr_knowledge_*` 本地表,失败时回退 seed。
- 大模型不作为演示硬依赖:模型管理已启用 chat 模型时,三角色对练为真实 LLM 生成与评分(asr/tts 配置后语音输入/播报可用);未配置或现场调用失败时全链路自动回退 seed,演示不中断。
+2 -2
View File
@@ -218,7 +218,7 @@ RELEASE_REMOTE_URL=https://peilian.njzhmj.top RELEASE_VERIFY_REMOTE_MATCH=true R
- SOP知识库:进入 `/knowledge/sop`,可上传 txt/md/PDF/Word/Excel/PPT 文档入库;点击“检索” → “生成训练题”,应看到“已完成闭环”、命中数据库 SOP 原文片段和训练题;数据库不可用时页面回退 seed。
- 资料处理:进入 `/knowledge/processing`,应看到资料总量、解析任务表、处理链路、规则与风险;页面只保留“批量导入”和“刷新”,不应出现“选择目录”“服务端导入”或目录导入任务面板。**批量导入走异步队列**:提交即返回,页面出现“本次批量上传”进度面板(排队/加工中/完成/失败 + 单条重试),后台 worker 并发 2 逐条解析入库;支持多文件和 ZIP,暂存目录默认 `./.data/staging`(`aihr.upload.staging` 覆盖)。
- uni-app 员工端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/user/today/index`,手机号登录(dev 验证码固定 `123456`)。今日页进入问师傅、练习、案例素材和个人页;练习页应能完成开始练习、提交回应、结束评分、每日三题提交,个人页同步训练历史和成长证据包。每日三题正式按组织快照 `hire_date` 判断入职三个月窗口;本地 Demo 若快照尚无该字段,仅由 `dev/local` profile 且 `application-dev.yml` 的 `aihr.practice.allow-legacy-daily-drill-fallback=true` 启用训练次数回退,生产 profile 即使误传环境变量也强制关闭。
- 工作助手项目服务记忆:`/h5/#/pages/user/sop/index` 一级入口只保留“工作助手 / 查全网”,既有文字、ASR、媒体、数据工具和 30 分钟/最近 6 轮短会话链保持不变;`/h5/#/pages/user/assistant/memories` 展示待确认候选和当前项目可见的服务记录。首批只落 `aihr_memory_candidate`、`aihr_service_memory`、`aihr_service_memory_version` 三张关系表,不依赖新的 Qdrant collection。候选卡未确认前不得查询到正式记录,提醒默认关闭。
- 工作助手确认式统一采集:`/h5/#/pages/user/sop/index` 一级入口只保留“工作助手 / 查全网”,既有文字、ASR、媒体、数据工具和 30 分钟/最近 6 轮短会话链保持不变;显式“记一下/帮我记/保存一下”返回 `DRAFT` 确认卡,位置缺失只作可选提示。确认后写 `aihr_assistant_capture`:`PRIVATE/NOT_REQUIRED` 仅本人可见,`COMPANY/PENDING` 只表示待流转;旧项目记录继续使用 `aihr_service_memory/version`。`/h5/#/pages/user/assistant/memories` 展示待确认候选和本人已确认记录;不依赖新的 Qdrant collection,提醒默认关闭。
- 移动端登录后若组织快照接口返回 `401/403`,只降级为手动岗位确认,不应清除手机号登录态;岗位确认页仍需允许员工选择“生活顾问”后继续进入业务页。
- uni-app 主管端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/supervisor/index/index`,应看到团队概览、团队画像、团队预警、指派专项、待复盘和复盘详情;从团队画像派专项后,最近专项应立即回读新记录。
- uni-app 候选人端:进入 `https://wygj-mobile-uni.localhost/h5/#/pages/candidate/index/index`,手机号登录(dev 验证码固定 `123456`)→ “开始面试/面试练习”完成答题评分;“补充资料”选择 PDF/Word/图片后上传,应看到资料状态。Codex 内置浏览器不支持本地文件选择时,用真实 HTTP multipart smoke 代替浏览器文件选择。
@@ -234,6 +234,6 @@ API_BASE=https://wygj-api.localhost ./scripts/personal-assistant-smoke.sh
# 登录后只读核验:TOKEN=<mobile-access-token> API_BASE=https://wygj-api.localhost ./scripts/personal-assistant-smoke.sh
```
工作助手记忆本地人工回归至少覆盖:以有且只有一个 `project_code` 的员工登录;说出完整房屋定位、需求和时间后看到 `DRAFT` 卡;刷新“我的资料与记忆”仍能恢复;点击“暂不保存”不产生正式记录;重新生成后双击“确认保存”只写一条记录和一个初始版本;新开对话可按房号或事项召回。再用无项目、多个项目无法唯一判断及另一项目账号做反例,分别应为安全拒绝/`NEEDS_INPUT`/不可见。
工作助手记忆本地人工回归至少覆盖:登录后说“帮我记一下三栋 3203 需要保洁服务”,即使缺少单元也应看到 `DRAFT` 卡;“暂不保存”不产生正式记录;“仅自己保存”后只由本人召回;“提交公司处理”只显示 `PENDING`,不得显示已送达;同一会话重复描述更新原卡,“另外记一条”才新建;双击确认不得重复写入。再用另一账号验证个人记录不可见,并验证按精确房号查询不会混入同楼栋其他房号。
完整演示脚本与录屏兜底见 [DEMO_ACCEPTANCE.md](DEMO_ACCEPTANCE.md)。
+2 -2
View File
@@ -21,8 +21,8 @@
| [superpowers/plans/2026-07-16-multi-tenant-knowledge-platform.md](superpowers/plans/2026-07-16-multi-tenant-knowledge-platform.md) | 多租户多知识空间统一问答平台按 schema、授权、检索、上传、数据工具、管理端、多端联调和发布拆分的实施计划 |
| [AI人力资源系统项目规划方案与AI接口说明.md](AI人力资源系统项目规划方案与AI接口说明.md) | 对外会议版口径、数字师傅定位、AI 接口和供应商规划 |
| [银城员工端APP分阶段实施总纲.md](银城员工端APP分阶段实施总纲.md) | 三阶段总纲:陪练 → 大喇叭+知识学习平台(含学模块、开放问题榜、员工个人 AI 助理) → 北森对接;身份三轴模型与防遗漏分发策略 |
| [个人AI助理阶段二专项TechSpec.md](个人AI助理阶段二专项TechSpec.md) | 员工个人知识空间的物理分域、数据模型、API、检索融合、权限、安全、删除和测试规格 |
| [个人AI助理阶段二开发推进计划.md](个人AI助理阶段二开发推进计划.md) | 个人 AI 助理阶段二按 P0/P1 拆分的逐任务实施、测试、提交和试点闸门 |
| [个人AI助理阶段二专项TechSpec.md](个人AI助理阶段二专项TechSpec.md) | 工作助手确认式统一采集的当前契约,以及后续个人知识空间的数据分域、检索、权限、安全和测试规格 |
| [个人AI助理阶段二开发推进计划.md](个人AI助理阶段二开发推进计划.md) | 后续个人资料/网页/PPT 能力的 P0/P1 任务计划;“记一下”当前实现以专项 TechSpec 为准 |
| [prototypes/personal-assistant-work-helper-voice-first-v2.png](prototypes/personal-assistant-work-helper-voice-first-v2.png) | 个人助理并入「问 · 数字师傅」后的语音优先会话、自动内部路由与确认记忆卡实现基准 |
| [prototypes/work-report-voice-conversation-v1.png](prototypes/work-report-voice-conversation-v1.png) | 工作上报从传统表单升级为微信式语音/照片/视频采集、AI 整理追问和确认审核的高保真实现基准 |
| [WORK_REPORT_IMPLEMENTATION_AGENT_PROMPT.md](WORK_REPORT_IMPLEMENTATION_AGENT_PROMPT.md) | 可直接交给独立开发 Agent 的工作上报实现 Prompt,包含文件边界、API、交互修订、测试和视觉验收口径 |
+54 -36
View File
@@ -1,6 +1,6 @@
# 员工个人 AI 助理 / 个人知识空间专项 TechSpec
> 版本:v1.1 | 日期:2026-07-19
> 版本:v1.2 | 日期:2026-07-19
>
> 归属:《银城员工端 APP 分阶段实施总纲》阶段二
>
@@ -9,6 +9,10 @@
> 实施边界:首批交付个人资料收藏、检索、带引用问答、工作整理,以及“识别候选记忆 → 用户确认 → 分域持久化 → 可追溯召回”闭环;PPT 生成为 P1;北森、考勤等外部数据归阶段三。
>
> v1.1 需求补充:客户明确提出,员工在对话中描述住户需求、待跟进事项或个人经验时,个人助理应识别值得长期保留的信息,返回可编辑确认卡;仅在用户确认后写入对应记忆域,后续问答可按权限召回。模型不得直接修改数据库,提醒必须单独确认。
>
> v1.2 实施收口:显式“记一下 / 帮我记 / 保存一下”统一生成 `ASSISTANT_CAPTURE` 确认卡;楼栋、单元、房号和项目均为可补充信息,不再阻断保存。用户必须选择“仅自己保存 / 提交公司处理”;公司处理首批只可靠写入待流转队列,未配置正式接收接口前不得显示“已送达”。音色不在本次范围。
当前代码状态(2026-07-20):候选检测、六类系统建议、同会话合并、可编辑确认、PRIVATE/COMPANY 持久化、可选跟进时间、“我的资料与记忆”列表,以及确认记录按 owner/项目权限参与后续问答召回均已实现。召回引用明确区分 `PERSONAL / COMPANY / PROJECT_SERVICE`。外部线索/工单投递 Worker、正式回执、统一采集转工作上报确认卡,以及个人文件/网页知识空间仍是后续工作,开发者不得从 `PENDING` 状态推断这些能力已完成。
## 0. 交互设计基准
@@ -22,8 +26,8 @@
2. 工作助手自动在已授权的个人资料、项目记录和企业知识中路由;首屏主路径不要求一线员工选择资料域,高级筛选只能放入次级入口。
3. 会话保持语音优先:输入区默认“按住说话”,键盘为切换能力;师傅回复优先显示语音条,正文可通过“转文字”展开。
4. ASR 转写以普通用户消息进入同一会话;候选检测不得绕过当前会话、登录态或项目权限。
5. 确认卡至少展示类型、对象、事项、保存位置、可见范围、明确回访日期和“加入跟进提醒”选项。
6. “修改 / 暂不保存 / 确认保存”必须是三个独立动作;只有“确认保存”可以触发长期持久化,保存和提醒分别确认。
5. 确认卡至少展示系统建议类型、对象、事项、可见范围、明确回访日期和“加入跟进提醒”选项;缺少房号等信息只作弱提示。
6. `ASSISTANT_CAPTURE` 卡必须提供“修改 / 暂不保存 / 仅自己保存 / 提交公司处理”四个明确动作;旧项目服务卡继续保留“确认保存”。保存和提醒分别确认。
7. 回答或保存结果只展示实际使用的来源,不得暗示已查询未命中的资料,更不得自动把内部内容转发到全网。
若设计图中的示例文案、间距或字段与本文的数据权限、安全、状态机或 API 约束冲突,以本文约束为准;视觉实现应继续复用现有 `ChatComposer`、女性数字师傅头像和「今日 / 练 / 问 / 我」底栏。
@@ -40,8 +44,8 @@
4. 默认由“工作助手”在个人资料、项目业务记忆和企业知识之间自动路由并展示实际来源;资料范围只作为次级高级筛选。
5. 输出结论、行动项和待确认事项,并逐条展示引用来源。
6. 删除个人资料时同步清理关系库、对象存储和向量索引。
7. 从文字或 ASR 转写中识别“住户需求、待跟进事项、个人经验”三类候选记忆,返回可编辑确认卡。
8. 用户确认后,将个人经验写入本人私有空间,将住户需求与跟进事项写入项目业务记忆;两域分别鉴权和检索。
7. 从文字或 ASR 转写中识别服务线索、住户画像、优秀案例、个人想法、待跟进事项和工作记录,返回可编辑确认卡。
8. 用户确认保存范围:`PRIVATE` 仅本人可见;`COMPANY` 进入企业待流转队列。项目服务旧记录继续按项目权限独立保存和检索。
9. 后续问答可召回已确认记忆,并展示来源消息、记录时间、当前状态和可见范围。
10. 对确认后的修改保留版本历史;提醒时间与长期保存分别确认。
@@ -55,6 +59,7 @@
- 不复用企业知识表增加 `scope_type` 来承载个人数据。
- 不把普通聊天历史自动升级为长期记忆,不在用户确认前写入个人或项目长期记忆表。
- 不允许 LLM 直接执行 SQL、更新业务实体、创建提醒或对外发送消息;模型只生成结构化候选草稿。
- 不把 `COMPANY/PENDING` 描述成已生成工单、已上报或已送达;正式接收端、认证和回执协议确定后才启用投递 Worker。
- 不把住户服务事实只存入员工私人空间;这类记录属于项目业务域,员工调岗后仍按项目权限保留。
- 阶段二首批不替代权威房屋、住户、工单或 CRM 主数据;未接入权威主数据时不得伪造住户身份。
@@ -71,6 +76,7 @@
| 向量检索 | 新建 `aihr_personal_knowledge` collection | Qdrant filter 必须包含 `tenant_id + owner_user_id` |
| 企业知识检索 | 调用现有企业 RAG 服务 | 继续执行企业知识密级、项目和角色权限 |
| 项目业务记忆 | 独立 `aihr_service_memory` 关系表;关系库检索为 2026-07-19 最小切片,Qdrant 仅列为后续增强 | 查询必须包含 `tenant_id + project_code`,不得写入个人或企业知识表 |
| 对话统一采集 | `aihr_memory_candidate` 暂存确认卡,确认后写 `aihr_assistant_capture` | `PRIVATE` 仅按 owner 查询;`COMPANY` 以 outbox 状态流转,不允许前端伪造已送达 |
禁止将个人资料写入:
@@ -146,13 +152,12 @@ org.dromara.aihr.knowledge.parse
文字输入 / 语音 ASR 转写
→ 规则预筛(明确“记一下”等意图,或同时出现对象 + 需求/跟进事实)
→ 服务端先提取并令牌化楼栋、单元、房号、姓名、电话等标识
→ LLM 仅输出符合 JSON Schema 的候选类型、摘要、缺失字段和置信度
→ 服务端恢复标识并校验当前租户、项目、字段完整性、重复记录和权限
→ 信息不足:在会话中追问,不允许确认
→ 信息完整:持久化 DRAFT 候选并返回可编辑确认卡
→ 用户修改并确认(携带幂等键和 expectedVersion)
→ 确定性 Service 按 PERSONAL / PROJECT_SERVICE 分域写关系库
→ 事务提交后异步写检索索引
→ 当前 P0 用确定性规则建议类型和分类;后续模型只能输出符合 JSON Schema 的候选建议
→ 服务端校验当前租户、项目、重复记录、权限和敏感字段
→ 显式“记一下”始终持久化 DRAFT;项目/楼栋/单元/房号缺失只列为可选补充项
→ 用户修改并选择 PRIVATE / COMPANY(携带幂等键和 expectedVersion)
→ 确定性 Service 写 `aihr_assistant_capture`;PRIVATE 记为 NOT_REQUIRED,COMPANY 记为 PENDING
→ 正式企业接收端接入后,由独立投递 Worker 消费 PENDING 并记录回执;当前不得假投递
→ 如用户另行确认 followUpAt,则进入“今日安排”;不默认创建提醒
```
@@ -161,7 +166,7 @@ org.dromara.aihr.knowledge.parse
1. 候选检测可自动发生,但任何置信度都不得跳过用户确认。
2. DRAFT 候选只为跨刷新恢复确认卡,不进入检索和回答;忽略或过期后清理草稿内容。
3. 同一来源消息和候选哈希在有效期内只展示一次,避免重复打扰。
4. 当前仓库没有权威房屋/住户主数据。首批允许保存经员工确认的项目、楼栋、单元、房号定位;声称“某位住户”但缺少可区分房号或权威主体 ID 时必须追问。
4. 当前仓库没有权威房屋/住户主数据。首批允许保存经员工确认的可选项目、楼栋、单元、房号定位;缺少这些字段仍可保存,但不得据此虚构或合并住户身份。
5. 关系数据库是事实源,向量索引只负责召回;索引写入失败不得丢失已确认记录,可重试重建。
## 3. 身份、权限与数据隔离
@@ -308,8 +313,8 @@ Message 保存:
| tenant_id/owner_user_id/project_code | varchar | 当前身份与候选项目范围;项目键使用现有组织快照的 `project_code`,不是自造数值 ID |
| source_conversation_id/source_request_id | varchar(64) | 来源短会话与本次问答请求;当前「问」链没有持久化消息 ID |
| candidate_hash | varchar(64) | 来源 + 规范化草稿哈希,用于防重复 |
| memory_type | varchar(30) | PERSONAL_NOTE / WORK_IDEA / RESIDENT_NEED / FOLLOW_UP |
| target_domain | varchar(30) | PERSONAL / PROJECT_SERVICE |
| memory_type | varchar(30) | SERVICE_LEAD / RESIDENT_PROFILE / CASE / PERSONAL_NOTE / FOLLOW_UP / PROJECT_NOTE |
| target_domain | varchar(30) | 新候选为 ASSISTANT_CAPTURE;确认后记录结果映射为 PERSONAL / COMPANY;旧记录兼容 PROJECT_SERVICE |
| status | varchar(20) | NEEDS_INPUT / DRAFT / CONFIRMED / DISMISSED / EXPIRED |
| draft_json | json | 可编辑结构化草稿;不得包含未脱敏模型原始响应 |
| missing_fields_json | json | 仍需追问的字段 |
@@ -322,7 +327,16 @@ Message 保存:
约束:`UNIQUE(tenant_id, owner_user_id, source_request_id, candidate_hash)`;DRAFT 默认 24 小时过期,DISMISSED/EXPIRED 的 `draft_json` 在 24 小时内物理清理。
### 4.8 `aihr_service_memory`
### 4.8 `aihr_assistant_capture`
用户确认后的统一采集事实源。核心字段为 `tenant_id/owner_user_id/project_code/save_scope/suggested_type/building_name/unit_name/room_no/category/summary/detail/occurred_at/follow_up_at/source_conversation_id/source_request_id/delivery_status/delivery_attempts/next_delivery_at/external_record_id/last_error/confirm_idempotency_key/confirm_request_hash/version/create_time/update_time`。
- `save_scope=PRIVATE` 时 `delivery_status=NOT_REQUIRED`,查询必须同时带 `tenant_id + owner_user_id`。
- `save_scope=COMPANY` 时首批写 `delivery_status=PENDING`;`DELIVERED` 只能由拿到外部成功回执的投递 Worker 更新。
- `confirm_idempotency_key` 在租户和 owner 范围唯一,确认事务先写采集记录再关闭候选,任一步失败整体回滚。
- 表内不保存原始录音、模型原始输出或伪造的住户主体 ID;音色不属于本表和本次实现。
### 4.9 `aihr_service_memory`
项目共享的住户需求和待跟进事实,不放入个人知识表。
@@ -345,7 +359,7 @@ Message 保存:
索引至少包含 `(tenant_id, project_code, status, follow_up_at)`、`(tenant_id, project_code, building_name, unit_name, room_no)`;首批关系库检索覆盖 `title/summary/detail`,后续若启用向量检索,payload 强制包含 `tenant_id + project_code + memory_id + status`。
### 4.9 `aihr_service_memory_version`
### 4.10 `aihr_service_memory_version`
每次确认修改、状态变更或作废都追加版本:`memory_id/version/snapshot_json/change_type/change_reason/operator_user_id/create_time`。快照只保存业务字段,不复制姓名、电话等无关标识;`UNIQUE(memory_id, version)`。
@@ -354,7 +368,7 @@ Message 保存:
### 5.1 Qdrant
- 完整阶段目标:个人资料使用 `aihr_personal_knowledge` collection;项目业务记忆使用独立 `aihr_service_memory` collection,两者不混 collection。
- 2026-07-19 项目服务记忆最小切片只使用 MySQL 关系库,不创建新的 Qdrant collection;不得因未做向量化而回退到无权限过滤的企业 collection。
- 确认式统一采集和兼容项目服务记录只使用 MySQL 关系库,不创建新的 Qdrant collection;不得因未做向量化而回退到无权限过滤的企业 collection。
- 向量维度:跟随当前启用的 vector 模型;模型或维度变化使用独立重建任务。
- 个人 payload:`tenant_id`、`owner_user_id`、`space_id`、`item_id`、`fragment_id`、`source_type`、`captured_at`;必建 index 为 `tenant_id`、`owner_user_id`、`item_id`。
- 项目业务记忆后续 payload:`tenant_id`、`project_code`、`memory_id`、`status`、`occurred_at`、`updated_at`;必建 index 为 `tenant_id`、`project_code`、`memory_id`、`status`。
@@ -498,6 +512,7 @@ Message 保存:
| GET | `/memory-candidates?status=DRAFT` | 恢复当前用户尚未过期的确认卡 |
| POST | `/memory-candidates/{id}/confirm` | 提交编辑后的草稿、`expectedVersion`、`idempotencyKey`;分域写入 |
| POST | `/memory-candidates/{id}/dismiss` | 明确不保存;同源候选本会话不再提示 |
| GET | `/assistant-captures?saveScope=PRIVATE|COMPANY&limit=50` | 查看本人的已确认记录及公司流转状态 |
| GET | `/api/aihr/service-memories` | 按当前项目、状态、日期、楼栋/单元/房号查询 |
| GET | `/api/aihr/service-memories/{id}` | 项目权限内查看详情、来源和版本 |
| PATCH | `/api/aihr/service-memories/{id}` | 携带 `expectedVersion` 修改必要字段或状态并生成版本 |
@@ -508,22 +523,22 @@ Message 保存:
{
"idempotencyKey": "01J-memory-confirm-301",
"expectedVersion": 1,
"targetDomain": "PROJECT_SERVICE",
"memoryType": "RESIDENT_NEED",
"subject": {
"saveScope": "COMPANY",
"draft": {
"projectCode": "YC-RESIDENTIAL-03",
"buildingName": "3栋",
"unitName": "2单元",
"roomNo": "1201"
},
"category": "养老服务咨询",
"roomNo": "1201",
"category": "服务线索",
"summary": "住户希望了解社区可提供的养老服务",
"occurredAt": "2026-07-19T14:00:00+08:00",
"followUpAt": null
},
"enableReminder": false
}
```
服务端必须忽略请求中的 `projectCode` 作为授权依据,仅用它与当前登录态可访问项目做一致性校验;客户端可省略该字段。确认成功返回 `targetDomain/targetId/version`;同一 `idempotencyKey` 重放必须返回同一结果,不重复创建记录,同一键配不同请求体必须拒绝。
服务端必须忽略请求中的 `projectCode` 作为授权依据,仅用它与当前登录态可访问项目做一致性校验;客户端可省略该字段。确认成功返回 `targetDomain=PERSONAL|COMPANY/targetId/version`;同一 `idempotencyKey` 重放必须返回同一结果,不重复创建记录,同一键配不同请求体必须拒绝。
## 7. 网页采集安全
@@ -595,11 +610,12 @@ Message 保存:
### 9.3 候选与业务记忆生命周期
- NEEDS_INPUT/DRAFT 候选默认 24 小时过期,不进入任何检索索引。
- DRAFT 候选默认 24 小时过期,不进入任何检索索引;NEEDS_INPUT 仅兼容旧候选。
- DISMISSED/EXPIRED 候选的结构化草稿在 24 小时内清理,只保留候选 ID、状态、来源消息 ID 和时间戳用于防重复。
- 个人记忆沿用个人 item 删除链路。
- 项目业务记忆不因创建员工离职而删除;按项目业务保留策略管理,首批只允许 `VOID` 作废并保留版本。
- `follow_up_at` 到期只进入员工端“今日安排”查询;推送通知、短信或自动派单不在首批范围。
- 统一采集 `PRIVATE` 记录只按 owner 保存;`COMPANY/PENDING` 保留到企业流转成功或人工关闭,失败只更新重试状态,不丢记录。
## 10. 前端设计
@@ -629,8 +645,8 @@ src/services/personal-assistant.ts API 客户端
- “资料范围”是次级设置而非一级 Tab;手动模式才允许选择个人资料、项目记录或企业知识。
- “查全网”保持独立入口和接口。内部资料无命中时只显示“是否查全网”,必须由用户点击后才进入外部查询;包含住户或项目内部信息时不得自动带入原问题。
- 每条用户文字或 ASR 转写完成后可触发候选检测;普通问答不得频繁弹卡,同源候选被忽略后本会话不再提示。
- 确认卡必须展示类型、对象定位、摘要、保存范围、记录时间和可选跟进时间,并提供“修改 / 暂不保存 / 确认保存”。
- 信息不足时显示追问,不展示可确认按钮;“某位住户”但无法唯一定位时不得保存为 RESIDENT。
- 统一采集确认卡必须展示系统建议类型、对象定位、摘要、保存范围、记录时间和可选跟进时间,并提供“修改 / 暂不保存 / 仅自己保存 / 提交公司处理”。
- 项目、楼栋、单元或房号不足时只显示“可继续补充”,不阻断保存;未接入权威住户主数据时不得把模糊描述保存成确定身份。
- 保存与提醒是两个独立选择;未填写 `followUpAt` 不创建提醒或今日任务。
- 项目业务记忆引用必须展示来源时间、当前状态和最后更新时间;历史状态不能冒充当前事实。
- 删除前明确提示会删除附件、解析正文和搜索索引。
@@ -649,7 +665,7 @@ src/services/personal-assistant.ts API 客户端
| `PERSONAL_NO_CITATION` | 无足够资料支撑 | 不生成业务结论 |
| `ENTERPRISE_SCOPE_FORBIDDEN` | 无企业知识范围权限 | 保留个人结果,提示企业范围不可用 |
| `PERSONAL_AI_DISABLED` | AI 成本闸门关闭 | 仍允许收藏和关键词检索,暂停摘要/问答 |
| `MEMORY_CANDIDATE_NEEDS_INPUT` | 对象或必要字段不完整 | 继续追问,不展示确认按钮 |
| `MEMORY_CANDIDATE_NEEDS_INPUT` | 旧候选对象或必要字段不完整 | 兼容展示为可编辑卡;统一采集的新 DRAFT 不以位置缺失阻断保存 |
| `MEMORY_CANDIDATE_EXPIRED` | 确认草稿已过期 | 基于原消息重新识别,不直接写入 |
| `MEMORY_CONFIRM_CONFLICT` | 版本冲突或幂等键对应不同内容 | 刷新确认卡,禁止覆盖 |
| `SERVICE_MEMORY_PROJECT_FORBIDDEN` | 当前用户无项目访问权 | 统一显示不可访问,不泄露记录存在性 |
@@ -669,8 +685,10 @@ src/services/personal-assistant.ts API 客户端
- prompt injection 文本不得改变系统指令。
- 规则预筛不会把普通问答误判为候选;明确“记一下”必定进入候选检测。
- 模型候选输出不能覆盖服务端解析的 tenant、project、楼栋、单元和房号。
- NEEDS_INPUT、DRAFT、CONFIRMED、DISMISSED、EXPIRED 状态转换只允许规定方向。
- DRAFT、CONFIRMED、DISMISSED、EXPIRED 状态转换只允许规定方向;NEEDS_INPUT 仅兼容旧候选。
- 同一确认幂等键只生成一个目标记录;版本冲突不覆盖较新业务记忆。
- 裸房号只在楼栋/单元上下文后识别,年份、时长、金额不能误判为房号。
- 相同会话中重复表达同一显式记录更新原卡;“另外记 / 再记一条 / 新的一条”生成新卡。
### 12.2 集成测试
@@ -694,8 +712,8 @@ src/services/personal-assistant.ts API 客户端
6. 检查引用徽标和原始来源。
7. 删除资料并确认列表、问答和下载均不可再访问。
8. 切换另一手机号,确认看不到前一用户的资料、标题和会话。
9. 语音描述一条住户需求,检查信息不足时先追问,字段完整后出现确认卡。
10. 分别执行“暂不保存”和“确认保存”,验证前者不进入检索,后者可按楼栋/日期召回并显示来源、状态和更新时间。
9. 语音说“帮我记一下三栋 3203 需要保洁服务”,检查缺少单元时仍出现可确认卡且只作可选提示。
10. 分别执行“暂不保存 / 仅自己保存 / 提交公司处理”,验证前者不落长期表、私有记录仅本人可见、公司记录显示 PENDING 而非已送达。
11. 确认时不设置跟进日期,验证不会生成今日任务;另建一条带 `followUpAt` 的记录,验证只出现一次。
12. 双击确认或重放请求,验证只创建一条记录;修改后验证版本递增且旧状态不被当作当前答案。
@@ -762,7 +780,7 @@ src/services/personal-assistant.ts API 客户端
- `backend/script/sql/aihr_personal_knowledge_mysql8.sql`
- `backend/script/sql/aihr_service_memory_mysql8.sql`
- `backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql`(项目服务记忆最小切片生产增量)
- `backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql`(确认候选、统一采集和兼容项目服务记录的生产增量)
- `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/personal/**`
- `backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/memory/**`(候选识别、确认编排与项目业务记忆)
- `backend/ruoyi-modules/ruoyi-aihr/src/test/java/org/dromara/aihr/personal/**`
@@ -792,10 +810,10 @@ src/services/personal-assistant.ts API 客户端
5. 个人资料默认私有,分享和企业入库走 P1 审核链。
6. PPT 是 P1,不阻塞阶段二首批。
7. 北森、考勤等外部个人数据属于阶段三。
8. 对话历史不是长期记忆;只有用户确认的候选才能进入个人或项目业务记忆。
9. 个人经验写个人域,住户需求和跟进事项写项目业务域,不因创建员工调岗而丢失。
8. 对话历史不是长期记忆;只有用户确认的候选才能进入统一采集或兼容项目业务记忆。
9. 统一采集由用户选择 PRIVATE / COMPANY;COMPANY 先进入可靠待流转状态,不能用前端成功提示代替外部回执。
10. 模型只生成结构化草稿,主体解析、授权、幂等、版本和数据库写入全部由确定性服务完成。
11. 保存和提醒分别确认;未设置 `followUpAt` 不产生今日任务、推送或自动派单。
12. 当前没有权威住户主数据时不伪造主体 ID;无法唯一定位的住户信息必须先追问。
12. 当前没有权威住户主数据时不伪造主体 ID;无法唯一定位不阻断记录一般事项,但不得将其解释为确定住户档案。
13. 用户界面不暴露系统的三域架构作为一级选择;一级只保留“工作助手 / 查全网”,内部来源默认自动路由并以引用透明展示。
14. 公网查询永不由 AUTO 触发;必须由用户明确进入“查全网”,且不得自动携带住户或项目内部信息。
@@ -10,6 +10,8 @@
**交互设计基准:** [工作助手语音优先与确认记忆设计图](prototypes/personal-assistant-work-helper-voice-first-v2.png)。该图是 Task 10 的实现参考,不是当前生产截图;安全、权限、状态机和 API 冲突时以 [专项 TechSpec](个人AI助理阶段二专项TechSpec.md) 为准。
**当前边界:** Task 10 的“记一下”已复用 `/api/knowledge/query`、`aihr_memory_candidate` 和 `aihr_assistant_capture` 完成本地闭环;本计划其余个人文件、网页、独立 `/ask`、分享和 PPT 任务仍未实施,不得按计划文本推断已完成。
---
## 0. 执行边界
@@ -1,11 +1,12 @@
# 物业行业 AI 人力资源系统 · 业务需求文档(BRD)
> 版本:v1.4(统一版)| 日期:2026-07-19
> 版本:v1.5(统一版)| 日期:2026-07-19
> 定位:本文是本项目的**唯一事实源(Single Source of Truth)**,收敛此前所有讨论与文档,取代分散的局部材料作为业务需求基准。
> v1.1 变更:第 5 章数据模型方向补充"HR 主干已对齐《手册》卷1 第9章"结论(见 5.2)。
> v1.2 变更:新增阶段二“员工个人 AI 助理 / 个人知识空间”需求(见 4.8),明确个人资料、网页链接、时间检索、来源引用、PPT 增强项和阶段三外部 HR 数据边界。
> v1.3 变更:个人助理新增确认式长期记忆;一级交互收敛为“工作助手 / 查全网”,内部个人资料、项目业务记录和企业知识默认自动路由并透明展示来源。
> v1.4 变更:员工“工作上报”改为复用数字师傅的微信式语音会话,AI 只整理可编辑草稿,用户确认后进入原有审核链,审核通过仍不自动入知识库。
> v1.5 变更:“记一下”收敛为统一采集确认卡,系统建议分类,用户选择仅自己保存或提交公司处理;项目和房屋位置缺失不阻断保存,正式企业接口未接入前只展示待流转。
>
> **收敛来源**:
> - 《物业管家与生活顾问 AI 陪练系统建设方案》(原始业务方案,陪练视角)
@@ -160,7 +161,7 @@
- **个人资料收藏**:员工可保存个人工作文字、PDF/Word/Excel/PPT、图片和网页链接;系统记录来源、收藏时间、主题、解析状态和原始附件,支持删除与导出。
- **个人检索与问答**:支持按日期范围、主题和来源查询个人内容,例如“上周收藏了哪些保洁管理资料”;基于选定资料总结、分析和提炼行动建议,回答必须展示引用来源。
- **内部知识自动路由**:员工默认只使用“工作助手”,系统在个人资料、项目业务记录和企业知识之间按意图和权限自动路由并展示实际引用;“资料范围”仅作为次级手动筛选,不要求普通员工预先理解数据域。
- **确认式长期记忆**:系统可从文字或语音转写中识别住户需求、待跟进事项和个人经验,但只能生成可编辑候选卡;用户确认后,个人经验写入本人私有域,住户需求和跟进事项写入项目业务域,后续召回必须展示来源、时间和当前状态。
- **确认式统一采集**:显式“记一下/帮我记/保存一下”从文字或语音转写生成可编辑候选卡,系统建议服务线索、住户画像、优秀案例、个人想法、待跟进事项或工作记录;项目/楼栋/单元/房号缺失只作可选提示。用户必须选择“仅自己保存”或“提交公司处理”,后者在正式接收接口接入前仅进入待流转状态,不得宣称已生成工单或已上报。
- **工作思路整理**:对日常冲突、工作灵感和零散想法生成结论、行动项、待确认事项;AI 仅辅助整理,不自动代替员工对外发布或作出管理决策。
- **内容生成**:阶段二首批提供摘要、提纲和可编辑文字;多资料对比、周报/月报和 PPT 生成作为阶段二增强项,PPT 先确认大纲与模板再生成。
- **分享与入库**:个人资料默认本人可见;分享给团队或沉淀为企业知识时必须由本人明确发起,经过权限校验、脱敏、审核和版本治理。
@@ -1,12 +1,13 @@
# 物业行业 AI 人力资源系统 · 开发规格(Tech Spec)
> 版本:v1.5 | 日期:2026-07-19
> 版本:v1.6 | 日期:2026-07-19
> 定位:**开发层唯一依据**。回答"怎么建"——工程结构、数据表、API 契约、Prompt 规格、集成适配。
> v1.1 变更:数据模型对齐《手册》卷1 第9章 HR 主干,补 3 张 backbone 表(职位职责/雇员绩效/雇用终止),新增附录 D 对照表。
> v1.2 变更:第 6 章按 Codex 设计评审重写——状态机补异常/终止态、COACH_CHECK 工程化、评分引擎分层(P0 单 LLM / P1 融合)、澄清"单场对练评分 vs 跨期能力画像"两套评分、Prompt 加固 + RAG 硬约束、新增 6.5 P0/P1 切分。
> v1.3 变更:Qdrant 作为一期向量索引层落地;SOP 知识库检索升级为 MySQL Fulltext + Qdrant RRF 融合,MySQL/MinIO 仍是事实源。
> v1.4 变更:移动端拆为独立 `mobile/` Vue/Vite Web/H5 工程,承载员工端、候选人端、主管端;后台管理端继续在 `frontend/`。
> v1.5 变更:当前用户侧以 `mobile-uni/` 为准;员工工作上报复用 `ChatComposer` 改为语音优先会话,增加无状态 AI 整理草稿契约,确认后继续提交既有 `aihr_work_report` 审核链。
> v1.6 变更:工作助手显式“记一下”复用 `/api/knowledge/query` 的 `memoryCandidate`,确认后写 `aihr_assistant_capture`;位置字段改为可选,增加 PRIVATE/COMPANY 范围、跟进时间和待流转状态。音色不在本次实现范围。
> 配套:需求见[《物业AI人力资源系统业务需求文档BRD》](物业AI人力资源系统业务需求文档BRD.md);2026-07 MVP 执行计划已归档到[《AI人力资源系统一期MVP版作战清单》](archive/2026-07-mvp-delivery/AI人力资源系统一期MVP版作战清单.md)。
> **优先级图例**:`P0`=2026-07-05 MVP 演示必需 · `P1`=一期必需 · `P2`=二期/推迟。
> 决策基线:若依基座 / 集中式前后端分离 / 本地登录 / 公有大模型API / 一期RAG / 组织人员外部同步(MVP 用快照) / 数据范围以项目为主体。
@@ -111,7 +111,7 @@
| MVP | 收藏个人文字、PDF/Word/Excel/PPT、图片和网页链接;自动提取正文、标题、来源、收藏时间和主题标签 | 阶段二首批 |
| MVP | 按日期范围、主题、来源查询“我哪些天收藏了什么”;基于选定资料问答、总结、提炼工作想法,答案必须带来源 | 阶段二首批 |
| MVP | 将冲突处理、工作灵感、保洁管理等问题交给个人助理分析;允许同时引用个人资料与有权限的企业知识 | 阶段二首批 |
| MVP | 从文字或语音中识别住户需求、待跟进事项和个人经验,返回可编辑确认卡;确认后个人经验写个人域,住户需求/跟进事项写项目业务域 | 阶段二首批 |
| MVP | 显式“记一下/帮我记/保存一下”生成可编辑确认卡;用户选择仅自己保存或提交公司处理,后者在正式接收端接入前只显示待流转 | 已完成本地实现,待发布验证 |
| 增强 | 多份资料对比、周报/月报、结构化汇报提纲、个人知识标签和长期记忆 | 阶段二后半 |
| 增强 | 将总结导出为 PPT/汇报材料;先生成可编辑大纲,再生成文件,不把“一键 PPT”作为首批阻塞项 | 阶段二后半/P1 |
| 外部数据 | 北森、考勤、请假、组织任职等个人数据接入后再做跨系统分析 | 阶段三 |
@@ -1,24 +1,16 @@
<template>
<view class="memory-card" :class="{ compact }">
<template v-if="candidate.status === 'NEEDS_INPUT'">
<view class="memory-head">
<text class="memory-title">还需要补充信息</text>
<text class="memory-tag muted">暂不保存</text>
</view>
<text class="needs-input-copy">{{ missingPrompt }}</text>
</template>
<template v-else-if="completed">
<template v-if="completed">
<view class="memory-head">
<text class="memory-title">已确认保存</text>
<text class="memory-tag success">{{ targetLabel }}</text>
</view>
<text class="saved-copy">这条事项已保存,后续工作助手可按权限查询。</text>
<text class="saved-copy">{{ completedCopy }}</text>
</template>
<template v-else-if="candidate.status === 'DRAFT'">
<template v-else-if="candidate.status === 'DRAFT' || candidate.status === 'NEEDS_INPUT'">
<view class="memory-head">
<text class="memory-title">发现一条可保存的信息</text>
<text class="memory-title">已帮你整理</text>
<text class="memory-tag">{{ memoryTypeLabel }}</text>
</view>
@@ -58,8 +50,8 @@
<text class="row-value">{{ draft.summary || '待补充' }}</text>
</view>
<view class="memory-row">
<text class="row-label">保存到</text>
<text class="row-value">{{ targetLabel }}</text>
<text class="row-label">保存范围</text>
<text class="row-value">{{ assistantCandidate ? '由你确认' : targetLabel }}</text>
</view>
<view class="memory-row">
<text class="row-label">记录时间</text>
@@ -67,6 +59,8 @@
</view>
</view>
<text v-if="optionalPrompt" class="optional-copy">{{ optionalPrompt }}</text>
<button
class="reminder-switch"
role="switch"
@@ -83,14 +77,28 @@
</view>
</picker>
<text class="memory-note">确认后才会长期保存;暂不保存不会写入个人资料或项目记录。</text>
<text class="memory-note">
{{ assistantCandidate ? '仅自己保存不会提交公司;提交公司处理后进入企业流转。' : '确认后才会长期保存;暂不保存不会写入项目记录。' }}
</text>
<text v-if="errorText" class="memory-error">{{ errorText }}</text>
<view class="memory-actions">
<button v-if="!editing" class="memory-action plain" :disabled="submitting" @click="editing = true">修改</button>
<button v-else class="memory-action plain" :disabled="submitting" @click="editing = false">完成修改</button>
<button class="memory-action outline" :disabled="submitting" @click="dismiss">暂不保存</button>
<button class="memory-action primary" :disabled="submitting" @click="confirm">确认保存</button>
<button
v-if="assistantCandidate"
class="memory-action scope private"
:disabled="submitting"
@click="confirm('PRIVATE')"
>仅自己保存</button>
<button
v-if="assistantCandidate"
class="memory-action scope company"
:disabled="submitting"
@click="confirm('COMPANY')"
>提交公司处理</button>
<button v-else class="memory-action primary" :disabled="submitting" @click="confirm()">确认保存</button>
</view>
</template>
@@ -147,6 +155,7 @@ const editing = ref(false);
const reminderEnabled = ref(false);
const submitting = ref(false);
const completed = ref(false);
const confirmedTarget = ref('');
const errorText = ref('');
const idempotencyKey = ref(createMemoryIdempotencyKey(props.candidate.id));
@@ -158,17 +167,28 @@ const missingProjectLocationLabels = computed(() => [
{ label: '单元', value: draft.value.unitName },
{ label: '房号', value: draft.value.roomNo }
].filter((item) => !item.value?.trim()).map((item) => item.label));
const assistantCandidate = computed(() => props.candidate.targetDomain === 'ASSISTANT_CAPTURE');
const memoryTypeLabel = computed(() => ({
RESIDENT_NEED: '住户需求',
SERVICE_LEAD: '服务线索',
RESIDENT_PROFILE: '住户画像',
CASE: '优秀案例',
PROJECT_NOTE: '工作记录',
SERVICE_FACT: '服务事实',
FOLLOW_UP: '待跟进事项',
PERSONAL_NOTE: '个人事项'
PERSONAL_NOTE: '个人想法'
}[props.candidate.memoryType] || '工作事项'));
const targetLabel = computed(() =>
props.candidate.targetDomain === 'PROJECT_SERVICE' ? '项目服务记录' : '我的资料与记忆'
);
const targetLabel = computed(() => ({
PROJECT_SERVICE: '项目服务记录',
PERSONAL: '仅自己可见',
COMPANY: '公司处理中'
}[confirmedTarget.value || props.candidate.targetDomain] || '我的资料与记忆'));
const completedCopy = computed(() => confirmedTarget.value === 'COMPANY'
? '已提交公司处理,可在“我的资料与记忆”中查看流转状态。'
: '这条信息已保存,后续工作助手可按权限查询。');
const terminalStatusTitle = computed(() => ({
CONFIRMED: '这条事项已保存',
@@ -176,7 +196,7 @@ const terminalStatusTitle = computed(() => ({
EXPIRED: '这条确认卡已过期'
}[props.candidate.status] || '这条确认卡已结束'));
const missingPrompt = computed(() => {
const optionalPrompt = computed(() => {
const labels: Record<string, string> = {
projectCode: '所属项目',
buildingName: '楼栋',
@@ -187,7 +207,7 @@ const missingPrompt = computed(() => {
occurredAt: '发生时间'
};
const fields = (props.candidate.missingFields || []).map((item) => labels[item] || item);
return fields.length ? `请继续告诉我:${fields.join('、')}。信息补齐后再请你确认保存。` : '请继续补充对象或事项信息,信息补齐后再请你确认保存。';
return fields.length ? `可继续补充:${fields.join('、')}(不影响先保存)` : '';
});
const displayDate = (value: string | null) => value ? value.replace('T', ' ').slice(0, 16) : '未填写';
@@ -204,14 +224,14 @@ const changeFollowUpDate = (event: { detail?: { value?: string } }) => {
draft.value.followUpAt = value ? `${value}T09:00:00+08:00` : null;
};
const confirm = async () => {
const confirm = async (saveScope: '' | 'PRIVATE' | 'COMPANY' = '') => {
if (submitting.value || completed.value) return;
if (!draft.value.category || !draft.value.summary || !draft.value.occurredAt) {
errorText.value = '请先补全事项、摘要和记录时间';
editing.value = true;
return;
}
if (props.candidate.targetDomain === 'PROJECT_SERVICE' && missingProjectLocationLabels.value.length) {
if (!assistantCandidate.value && props.candidate.targetDomain === 'PROJECT_SERVICE' && missingProjectLocationLabels.value.length) {
errorText.value = `项目服务记录还需补充:${missingProjectLocationLabels.value.join('、')}`;
editing.value = true;
return;
@@ -231,10 +251,12 @@ const confirm = async () => {
...draft.value,
followUpAt: reminderEnabled.value ? draft.value.followUpAt : null
},
enableReminder: reminderEnabled.value
enableReminder: reminderEnabled.value,
saveScope: saveScope || undefined
});
if (requestAccountKey !== currentAccountKey()) return;
completed.value = true;
confirmedTarget.value = result.targetDomain;
emit('confirmed', result);
} catch (error) {
errorText.value = error instanceof Error ? error.message : '保存失败,请稍后重试';
@@ -267,6 +289,7 @@ watch(
reminderEnabled.value = false;
editing.value = false;
completed.value = false;
confirmedTarget.value = '';
errorText.value = '';
idempotencyKey.value = createMemoryIdempotencyKey(props.candidate.id);
}
@@ -451,7 +474,7 @@ watch(
.memory-note,
.memory-error,
.needs-input-copy,
.optional-copy,
.saved-copy {
display: block;
margin-top: 10px;
@@ -467,12 +490,20 @@ watch(
color: #c8001a;
}
.needs-input-copy,
.optional-copy,
.saved-copy {
color: #596474;
font-size: 14px;
}
.optional-copy {
padding: 9px 10px;
border-radius: 10px;
background: #f7f8fa;
color: #7a8491;
font-size: 12px;
}
.memory-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -510,6 +541,20 @@ watch(
color: #ffffff;
}
.memory-action.scope {
border: 1px solid #e60012;
}
.memory-action.scope.private {
background: #ffffff;
color: #d70016;
}
.memory-action.scope.company {
background: #e60012;
color: #ffffff;
}
@media (max-width: 360px) {
.memory-card {
padding: 14px;
@@ -5,7 +5,7 @@
<view class="subpage-heading">
<text class="employee-kicker">工作助手</text>
<text class="page-title">我的资料与记忆</text>
<text class="page-subtitle">首版仅展示待确认事项和项目跟进;完整个人资料库将在后续开放。</text>
<text class="page-subtitle">查看仅自己保存的信息、公司处理进度和项目跟进。</text>
</view>
</view>
@@ -38,7 +38,35 @@
</view>
<view v-else class="empty-card employee-card">
<text class="empty-title">暂无待确认事项</text>
<text class="empty-copy">在工作助手里说“记一下”,识别到完整信息后会先让你确认。</text>
<text class="empty-copy">在工作助手里说“记一下”,系统整理后会先让你选择保存范围。</text>
</view>
<view class="section-head followup-head">
<view>
<text class="section-title">我的记录</text>
<text class="section-note">仅自己保存的内容不会提交公司</text>
</view>
</view>
<view v-if="captures.length" class="service-list">
<view v-for="item in captures" :key="item.id" class="service-card employee-card">
<view class="service-head">
<text class="service-category">{{ captureTypeLabel(item.suggestedType) }}</text>
<text class="service-status" :class="{ done: item.saveScope === 'PRIVATE' }">
{{ captureScopeLabel(item) }}
</text>
</view>
<text class="service-subject">{{ captureSubject(item) || item.category }}</text>
<text class="service-summary">{{ item.summary }}</text>
<view class="service-meta">
<text>记录 {{ displayDate(item.occurredAt) }}</text>
<text v-if="item.followUpAt">跟进 {{ displayDate(item.followUpAt) }}</text>
<text v-if="item.saveScope === 'COMPANY'">{{ deliveryLabel(item.deliveryStatus) }}</text>
</view>
</view>
</view>
<view v-else class="empty-card employee-card">
<text class="empty-title">暂无已保存记录</text>
<text class="empty-copy">确认保存后的个人想法、服务线索和工作事项会显示在这里。</text>
</view>
<view class="section-head followup-head">
@@ -74,9 +102,9 @@
<script setup lang="ts">
import { ref } from 'vue';
import { onHide, onShow, onUnload } from '@dcloudio/uni-app';
import type { MemoryCandidate, ServiceMemory } from '@/types/api';
import type { AssistantCapture, MemoryCandidate, ServiceMemory } from '@/types/api';
import MemoryCandidateCard from '@/components/chat/MemoryCandidateCard.vue';
import { getMemoryCandidates, getServiceMemories } from '@/services/personal-assistant';
import { getAssistantCaptures, getMemoryCandidates, getServiceMemories } from '@/services/personal-assistant';
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { ensureEmployeePosition } from '@/services/position';
import { resetPageScroll } from '@/services/navigation';
@@ -87,6 +115,7 @@ const loggedIn = ref(false);
const loading = ref(false);
const message = ref('');
const candidates = ref<MemoryCandidate[]>([]);
const captures = ref<AssistantCapture[]>([]);
const serviceMemories = ref<ServiceMemory[]>([]);
const requests = createPageRequestScope();
@@ -122,41 +151,52 @@ const serviceSubject = (item: ServiceMemory) => {
].filter(Boolean).join('');
};
const captureSubject = (item: AssistantCapture) => [item.buildingName, item.unitName, item.roomNo]
.filter(Boolean).join('');
const captureTypeLabel = (type = '') => ({
SERVICE_LEAD: '服务线索',
RESIDENT_PROFILE: '住户画像',
CASE: '优秀案例',
PERSONAL_NOTE: '个人想法',
FOLLOW_UP: '待跟进事项',
PROJECT_NOTE: '工作记录'
}[type] || '工作记录');
const captureScopeLabel = (item: AssistantCapture) => item.saveScope === 'PRIVATE' ? '仅自己' : '公司处理';
const deliveryLabel = (status = '') => ({
PENDING: '待流转',
DELIVERED: '已流转',
FAILED: '流转待重试'
}[status] || '处理中');
const removeCandidate = (candidateId: number, toast: string) => {
candidates.value = candidates.value.filter((item) => item.id !== candidateId);
uni.showToast({ title: toast, icon: 'none' });
void loadServiceMemories();
};
const loadServiceMemories = async () => {
const request = requests.begin('service-memories');
try {
const result = await getServiceMemories();
if (requests.isCurrent(request)) serviceMemories.value = result;
} catch (error) {
if (requests.isCurrent(request)) {
message.value = error instanceof Error ? error.message : '项目跟进读取失败';
}
}
void load();
};
const load = async () => {
loggedIn.value = isLoggedIn();
candidates.value = [];
captures.value = [];
serviceMemories.value = [];
message.value = '';
if (!loggedIn.value) return;
if (!(await ensureEmployeePosition(path))) return;
const request = requests.begin('memory-page');
loading.value = true;
const [candidateResult, serviceResult] = await Promise.allSettled([
const [candidateResult, captureResult, serviceResult] = await Promise.allSettled([
getMemoryCandidates('DRAFT'),
getAssistantCaptures(),
getServiceMemories()
]);
if (!requests.isCurrent(request)) return;
if (candidateResult.status === 'fulfilled') candidates.value = candidateResult.value;
if (captureResult.status === 'fulfilled') captures.value = captureResult.value;
if (serviceResult.status === 'fulfilled') serviceMemories.value = serviceResult.value;
const errors = [candidateResult, serviceResult]
const errors = [candidateResult, captureResult, serviceResult]
.filter((item): item is PromiseRejectedResult => item.status === 'rejected')
.map((item) => item.reason instanceof Error ? item.reason.message : '读取失败');
message.value = errors.join(';');
+4 -1
View File
@@ -61,7 +61,10 @@
</button>
</view>
<view v-if="msg.voiceStatus === 'unavailable' || msg.transcriptOpen" class="bubble master transcript-bubble">
<view
v-if="(msg.voiceStatus === 'unavailable' || msg.transcriptOpen) && (msg.result.answer || msg.result.snippets?.length || !msg.result.memoryCandidate)"
class="bubble master transcript-bubble"
>
<text
v-if="msg.result.rewrittenQuery && msg.result.rewrittenQuery !== msg.result.queryText"
class="context-used"
@@ -1,4 +1,5 @@
import type {
AssistantCapture,
MemoryCandidate,
MemoryConfirmRequest,
MemoryConfirmResult,
@@ -44,6 +45,13 @@ export const dismissMemoryCandidate = (candidateId: number) =>
timeout: 15000
});
export const getAssistantCaptures = async () =>
rowsOf(await apiRequest<AssistantCapture[] | { rows?: AssistantCapture[]; records?: AssistantCapture[] }>({
url: '/api/aihr/personal-assistant/assistant-captures?limit=50',
method: 'GET',
timeout: 15000
}));
export const getServiceMemories = async () => {
const rows = rowsOf(await apiRequest<RawServiceMemory[] | { rows?: RawServiceMemory[]; records?: RawServiceMemory[] }>({
url: '/api/aihr/service-memories',
+19 -1
View File
@@ -193,7 +193,7 @@ export interface UnifiedKnowledgeResponse {
export type MemoryCandidateStatus = 'DRAFT' | 'NEEDS_INPUT' | 'CONFIRMED' | 'DISMISSED' | 'EXPIRED' | string;
export type MemoryTargetDomain = 'PERSONAL' | 'PROJECT_SERVICE' | string;
export type MemoryTargetDomain = 'ASSISTANT_CAPTURE' | 'PERSONAL' | 'COMPANY' | 'PROJECT_SERVICE' | string;
export interface MemoryCandidateDraft {
projectCode: string | null;
@@ -225,6 +225,7 @@ export interface MemoryConfirmRequest {
idempotencyKey: string;
draft: MemoryCandidateDraft;
enableReminder: boolean;
saveScope?: 'PRIVATE' | 'COMPANY';
}
export interface MemoryConfirmResult {
@@ -233,6 +234,23 @@ export interface MemoryConfirmResult {
version: number;
}
export interface AssistantCapture {
id: number;
saveScope: 'PRIVATE' | 'COMPANY';
suggestedType: string;
projectCode?: string | null;
buildingName?: string | null;
unitName?: string | null;
roomNo?: string | null;
category: string;
summary: string;
detail?: string | null;
occurredAt: string;
followUpAt?: string | null;
deliveryStatus: 'NOT_REQUIRED' | 'PENDING' | 'DELIVERED' | 'FAILED' | string;
createdAt: string;
}
export interface ServiceMemory {
id: number;
version: number;
+6 -2
View File
@@ -14,9 +14,13 @@ test('问师傅为文字、媒体和数据工具请求携带同一短期会话
});
test('问师傅支持显式新对话、版本回写和受控原文件或视频卡', async () => {
const page = await read('../src/pages/user/sop/index.vue');
const [page, header] = await Promise.all([
read('../src/pages/user/sop/index.vue'),
read('../src/components/chat/AskSourceHeader.vue')
]);
assert.match(page, /@click="startNewConversation">新对话/);
assert.match(page, /@new-conversation="startNewConversation"/);
assert.match(header, /@click="emit\('newConversation'\)">新对话/);
assert.match(page, /const contextVersion = ref\(0\)/);
assert.match(page, /applyConversationState\(nextResult\)/);
assert.match(page, /authenticatedFileUrl\(resource\.contentUrl\)/);
+22 -1
View File
@@ -88,6 +88,27 @@ test('员工端首页、问师傅和训练页接入当前共享基座', async ()
assert.match(practiceSource, /scenario-card employee-card/);
});
test('工作助手与查全网使用一致的来源切换头部', async () => {
const [sopSource, webAiSource, headerSource] = await Promise.all([
pageSource('../src/pages/user/sop/index.vue'),
pageSource('../src/pages/user/web-ai/index.vue'),
pageSource('../src/components/chat/AskSourceHeader.vue')
]);
for (const page of [sopSource, webAiSource]) {
assert.match(page, /import AskSourceHeader from ['"]@\/components\/chat\/AskSourceHeader\.vue['"]/);
assert.match(page, /<AskSourceHeader\b/);
assert.doesNotMatch(page, /(?:^|\n)\s*\.chat-source-switch(?:\s+\.source-option(?:\.active)?)?\s*\{/);
}
assert.match(headerSource, /<text class="head-title">问 · 数字师傅<\/text>/);
assert.match(headerSource, /\.source-option\s*\{[^}]*height:\s*44px/);
assert.match(headerSource, /\.new-chat-button\s*\{[^}]*min-height:\s*44px/);
assert.match(headerSource, /@click="emit\('newConversation'\)"/);
assert.doesNotMatch(sopSource, /(?:^|\n)\s*\.source-switch\s*\{/);
assert.doesNotMatch(sopSource, /(?:^|\n)\s*\.source-option(?:\.active)?\s*\{/);
});
test('登录后按组织身份切换主管首页', async () => {
const source = await pageSource('../src/services/position.ts');
@@ -198,7 +219,7 @@ test('员工问师傅页显示当前岗位而不是固定生活顾问', async ()
assert.match(source, /getSelectedPosition/);
assert.match(source, /const positionLabel = ref\(getSelectedPosition\(\)\)/);
assert.match(source, /\{\{ positionLabel \}\} · 住宅项目/);
assert.match(source, /:context="`\$\{positionLabel\} · 住宅项目`"/);
assert.doesNotMatch(source, /sop-context">管家(生活顾问) · 住宅项目/);
});
+27 -5
View File
@@ -26,15 +26,17 @@ test('个人助理客户端使用确认式记忆接口且确认请求携带稳
assert.match(service, /\$\{candidateBase\}\/\$\{candidateId\}\/confirm/);
assert.match(service, /\$\{candidateBase\}\/\$\{candidateId\}\/dismiss/);
assert.match(service, /\/api\/aihr\/service-memories/);
assert.match(service, /\/api\/aihr\/personal-assistant\/assistant-captures/);
assert.match(card, /const idempotencyKey = ref\(createMemoryIdempotencyKey\(props\.candidate\.id\)\)/);
assert.match(card, /if \(submitting\.value \|\| completed\.value\) return/);
assert.match(card, /expectedVersion: props\.candidate\.version/);
assert.match(card, /enableReminder: reminderEnabled\.value/);
assert.match(card, /saveScope: saveScope \|\| undefined/);
assert.match(card, /\.\.\.value/);
for (const field of ['projectCode', 'title', 'detail', 'status']) assert.match(card, new RegExp(field));
});
test('候选确认卡区分待补充和草稿,提醒默认关闭并保留三个动作', async () => {
test('统一采集确认卡把缺失字段作为可选提示并让用户选择保存范围', async () => {
const card = await read('../src/components/chat/MemoryCandidateCard.vue');
assert.match(card, /candidate\.status === 'NEEDS_INPUT'/);
@@ -43,6 +45,10 @@ test('候选确认卡区分待补充和草稿,提醒默认关闭并保留三
assert.match(card, />修改</);
assert.match(card, />暂不保存</);
assert.match(card, />确认保存</);
assert.match(card, />仅自己保存</);
assert.match(card, />提交公司处理</);
assert.match(card, /可继续补充:/);
assert.doesNotMatch(card, />还需要补充信息</);
assert.match(card, /v-if="reminderEnabled"/);
for (const field of ['buildingName', 'unitName', 'roomNo']) {
assert.match(card, new RegExp(`value: draft\\.value\\.${field}`));
@@ -65,15 +71,31 @@ test('工作助手页面展示任务过期后不会消费待执行查询或数
assert.match(page, /ensureEmployeePosition\('\/pages\/user\/sop\/index'\)\) \|\| !isCurrentQuestion\(\)/);
});
test('我的资料与记忆展示个人记录和公司流转状态', async () => {
const [page, service] = await Promise.all([
read('../src/pages/user/assistant/memories.vue'),
read('../src/services/personal-assistant.ts')
]);
assert.match(page, /getAssistantCaptures/);
assert.match(page, />我的记录</);
assert.match(page, /仅自己保存的内容不会提交公司/);
assert.match(page, /deliveryLabel\(item\.deliveryStatus\)/);
assert.match(service, /assistant-captures\?limit=50/);
});
test('工作助手保留原问答链并只提供显式查全网入口', async () => {
const [page, knowledge] = await Promise.all([
const [page, header, knowledge] = await Promise.all([
read('../src/pages/user/sop/index.vue'),
read('../src/components/chat/AskSourceHeader.vue'),
read('../src/services/knowledge.ts')
]);
assert.match(page, />工作助手<\/button>/);
assert.match(page, />查全网<\/button>/);
assert.match(header, />\s*工作助手\s*<\/button>/);
assert.match(header, />\s*查全网\s*<\/button>/);
assert.match(page, /@select-web="openWebAi"/);
assert.match(page, /<MemoryCandidateCard/);
assert.match(page, /msg\.result\.answer \|\| msg\.result\.snippets\?\.length \|\| !msg\.result\.memoryCandidate/);
assert.match(page, /msg\.result\.noEvidence/);
assert.match(page, /@click="openWebAi">去查全网/);
assert.match(knowledge, /url: '\/api\/knowledge\/query'/);
@@ -137,7 +159,7 @@ test('我的页面和路由提供首版资料与记忆入口', async () => {
assert.match(profile, /openMemories/);
assert.match(memories, /待确认/);
assert.match(memories, /项目跟进/);
assert.match(memories, /首版仅展示/);
assert.match(memories, /仅展示当前账号有权限查看的项目服务记录/);
assert.match(memories, /项目:\{\{ item\.projectCode \}\}/);
assert.match(pages, /pages\/user\/assistant\/memories/);
});
+2 -1
View File
@@ -155,6 +155,7 @@ FROM (
UNION ALL SELECT 'aihr_onboard_task'
UNION ALL SELECT 'aihr_qualification_gate'
UNION ALL SELECT 'aihr_memory_candidate'
UNION ALL SELECT 'aihr_assistant_capture'
UNION ALL SELECT 'aihr_service_memory'
UNION ALL SELECT 'aihr_service_memory_version'
UNION ALL SELECT 'aihr_work_report'
@@ -210,7 +211,7 @@ REMOTE
[[ "$work_report_unique_index" = "0|tenant_id,submitter_user_id,request_key|0" ]] \
|| fail "remote work-report unique index invalid: expected unique full columns tenant_id,submitter_user_id,request_key; got ${work_report_unique_index:-missing}"
echo "remote_schema=39/39 $remote_ssh:$remote_db"
echo "remote_schema=41/41 $remote_ssh:$remote_db"
echo "remote_work_report_idempotency=3/3 $remote_ssh:$remote_db"
}
+5 -3
View File
@@ -189,8 +189,10 @@ service_memory_migration="$ROOT_DIR/backend/script/sql/update/aihr_20260719_serv
test -f "$service_memory_migration"
mysql "$DB" < "$service_memory_migration"
mysql "$DB" < "$service_memory_migration"
service_memory_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_memory_candidate','aihr_service_memory','aihr_service_memory_version');")"
test "$service_memory_tables" = 'aihr_memory_candidate,aihr_service_memory,aihr_service_memory_version'
service_memory_tables="$(mysql "$DB" -N -B -e "SELECT GROUP_CONCAT(table_name ORDER BY table_name) FROM information_schema.tables WHERE table_schema='$DB' AND table_name IN ('aihr_memory_candidate','aihr_assistant_capture','aihr_service_memory','aihr_service_memory_version');")"
test "$service_memory_tables" = 'aihr_assistant_capture,aihr_memory_candidate,aihr_service_memory,aihr_service_memory_version'
assistant_capture_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND table_name='aihr_assistant_capture' AND column_name IN ('save_scope','suggested_type','delivery_status','confirm_idempotency_key','confirm_request_hash');")"
test "$assistant_capture_columns" = '5'
service_memory_version_columns="$(mysql "$DB" -N -B -e "SELECT COUNT(*) FROM information_schema.columns WHERE table_schema='$DB' AND ((table_name = 'aihr_memory_candidate' AND column_name IN ('version','confirm_idempotency_key','confirm_request_hash','source_conversation_id','source_request_id')) OR (table_name = 'aihr_service_memory' AND column_name IN ('project_code','version')) OR (table_name = 'aihr_service_memory_version' AND column_name IN ('memory_id','version')));")"
test "$service_memory_version_columns" = '9'
@@ -226,4 +228,4 @@ fi
work_report_failed_repair_index="$(mysql "$DB" -N -B -e "SELECT CONCAT(MIN(non_unique),'|',GROUP_CONCAT(column_name ORDER BY seq_in_index),'|',SUM(sub_part IS NOT NULL)) FROM information_schema.statistics WHERE table_schema='$DB' AND table_name='aihr_work_report' AND index_name='uk_aihr_work_report_request';")"
test "$work_report_failed_repair_index" = '1|tenant_id,request_key,submitter_user_id|1'
echo "PASS: AIHR legacy schema migrations are idempotent, including practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, project service memory, and work-report idempotency"
echo "PASS: AIHR legacy schema migrations are idempotent, including practice evidence, knowledge feedback, candidate interview review, position/SOP qualification contracts, assistant capture, and work-report idempotency"