feat(ai): add personal expense application memory
This commit is contained in:
@@ -135,6 +135,7 @@
|
||||
|
||||
- 用户可以查看“系统记住了什么、为什么记住、在哪里使用”,并支持修改、忘记和关闭个性化。
|
||||
- 企业管理员配置部门/企业记忆边界、有效期、敏感等级和自动化上限。
|
||||
- 首个个人记忆切片只在申请核对表展示“常用出行方式”来源、证据数量和可编辑状态,并提供“忘记此偏好”;不在本轮新建大型设置页,也不把风险画像当作个人偏好。
|
||||
|
||||
### 后端
|
||||
|
||||
@@ -158,6 +159,9 @@
|
||||
- `memory_evidence_links` 保留记忆与业务事件、决策、反馈、结果的来源关系。
|
||||
- 企业制度优先于部门基线,部门基线优先于个人偏好;冲突时返回解释。
|
||||
- 复用既有 AI 数据飞轮的 few-shot、golden case、Prompt 版本、Canary 和回归门禁。
|
||||
- 首个切片使用关系库精确键检索,只允许低敏枚举 `travel_application.transport_mode`;不复用当前缺少完整租户键的 FewShot/Qdrant 或员工风险画像存储个人记忆。
|
||||
- 记忆证据只从认证动作事务中的服务端核验纠正产生,关联 Decision、Feedback、Outcome 和 Expense Case;草稿、客户端观察、重复请求和同一 Case 多次修改不能重复计票。
|
||||
- 个人记忆只填充当前申请的空白软字段;当前明确输入、服务端 HR/组织主数据、企业制度和规则计算结果都优先于个人记忆。每次应用或抑制都返回可解释原因,不把优先级交给 Prompt 或 LLM 自行判断。
|
||||
|
||||
#### 节省与价值
|
||||
|
||||
@@ -186,9 +190,11 @@
|
||||
|
||||
#### 记忆激活
|
||||
|
||||
- 明确偏好可由用户确认后立即激活。
|
||||
- 隐式偏好必须达到最小一致证据数,并通过时间衰减、敏感性、企业规则冲突和异常值检查。
|
||||
- 错误标注、违规习惯和一次性例外不能直接变为默认记忆。
|
||||
- 明确偏好后续可由用户认证确认后立即激活;首个切片只实现隐式候选,不把普通字段编辑冒充明确授权。
|
||||
- 隐式出行方式偏好需要同租户、同员工、同场景和同归一化值的 3 个不同 Expense Case 服务端核验纠正,其中至少 2 个出现 `application_approved`,且首尾证据至少跨 7 天;同一 Case、重试、草稿续签只计一票。
|
||||
- `accepted`、`client_observed`、`draft_saved` 和单纯 `application_submitted` 只能作为观察信息,不能单独激活;退回、反向纠正、结果 reversed 或制度冲突会抑制现有记忆。
|
||||
- Candidate 默认 90 天有效,Active 默认 180 天;状态支持 `candidate / active / suppressed / expired / revoked`。相反值先抑制旧记忆并建立新候选,不原地覆盖;用户“忘记”后立即 revoked,清除可恢复值且不可自动复活。
|
||||
- 错误标注、违规习惯、一次性例外、自由文本和敏感字段不能直接变为默认记忆。
|
||||
|
||||
#### 风险与预审
|
||||
|
||||
@@ -213,7 +219,8 @@
|
||||
- `ai_decisions`:结构化 AI 建议与版本证据。
|
||||
- `ai_decision_feedback`:接受、修改、拒绝和忽略。
|
||||
- `workflow_outcomes`:退回、补件、审批、付款、审计和最终结果。
|
||||
- `memory_entries` / `memory_evidence_links`:分层记忆和来源。
|
||||
- `memory_entries`:按租户、作用域主体、场景、字段和值指纹保存候选/激活/抑制/过期/撤销状态,只允许白名单低敏值可恢复。
|
||||
- `memory_evidence_links`:把记忆与服务端 Decision、Feedback、Outcome、Expense Case 和业务结果关联;唯一键保证同一 Case 对同一候选最多计一票。
|
||||
- `automation_policies` / `automation_grants`:动作级自动化策略和授权。
|
||||
- `profile_baseline_snapshots`:持久化员工、部门、供应商、费用和流程基线。
|
||||
- `savings_opportunities` / `savings_realizations`:节省机会和实现结果。
|
||||
@@ -274,6 +281,8 @@
|
||||
- P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。
|
||||
- Expense Case 用户态查询只返回流程摘要:Case 基础状态、关系类型、事件类型、操作人、发生时间及白名单业务载荷;不返回关联资源 ID、幂等键、correlation、causation、聚合标识或 Outbox 投递状态。有权查看入口单据的本人、当前审批人、财务和管理员可查看整 Case 的安全摘要,无权限与跨租户查询继续以 404 隐藏资源存在性。
|
||||
- 申请预览签发与消费同时绑定 `tenant_id`、actor、Bearer 登录会话和会话 ID;动作入口按这些服务端事实锁行校验,不能只凭随机 UUID 授权。浏览器提供的模型来源、`finalValue`、用户和角色声明均不作为可信事实。
|
||||
- 个人记忆主体优先使用服务端 `employee_id`,缺失时才使用规范化用户名;所有唯一键、读取、证据外键和忘记动作都必须以 `tenant_id` 开头。同邮箱或同员工号在不同租户形成完全独立的记忆。
|
||||
- 记忆值采用 deny-by-default 白名单;首个切片仅保存规范枚举“飞机/火车/轮船”。事由、地点、日期、金额、客户/项目自由文本、附件、银行卡、收款人与支付账户均不得进入记忆明文、Prompt、向量 payload 或日志。
|
||||
- 外部 `/orchestrator/run` 的用户消息、定时任务和系统事件都必须携带有效登录会话,不能由请求体中的 `source` 自行声明可信内部来源;其中 `schedule` / `system_event` 只允许平台管理员触发。服务端使用登录态覆盖用户、租户、角色、管理员、员工、审批人和调度操作人别名,并移除客户端 preview/decision 状态。最近会话查询与删除只使用当前登录用户和租户,查询参数中的 `user_id` 仅保留协议兼容,不参与授权。
|
||||
- 预览与学习账本中的字段值使用独立、可版本化的费用申请密钥计算 HMAC-SHA256 指纹,密钥目录和文件权限分别为 `0700`、`0600`;核验旧决策必须使用其签发版本,缺失版本直接拒绝,不静默生成替代密钥。字段指纹同时编码“字段是否存在”,避免新增或删除字段被误判为采纳;训练资格仍保持关闭,直至审批、付款或人工核验闭环完成。
|
||||
- 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。
|
||||
@@ -286,7 +295,8 @@
|
||||
- Qdrant/few-shot 不可用:使用 stable Prompt 和基础规则,不阻塞主流程。
|
||||
- 外部支付/ERP/税务连接器不可用:事件进入 retryable 状态,保留人工处理入口和幂等键。
|
||||
- 记忆冲突或可信度不足:只展示建议,不自动应用。
|
||||
- 预览签发超时或不可用:仍展示本地预览并允许编辑、保存和提交,内部标记为降级;此时只记录 `client_observed` 行为证据,不升级为服务端核验或训练样本。
|
||||
- 结构化预览签发超时或不可用:允许继续编辑和重新签发,但保存、提交保持 fail-closed;只有不存在结构化预览的历史兼容入口保留原有降级路径,且只能记录 `client_observed` 行为证据。
|
||||
- 记忆服务不可用、过期或校验失败:回退为“无个人记忆 + 当前规则”,不阻塞预览,也不读取跨租户、旧缓存或无租户向量样本。
|
||||
- Savings 证据不足:保留机会状态,不进入已实现节省。
|
||||
|
||||
## 算法与公式
|
||||
@@ -411,6 +421,7 @@ customer_contribution_margin
|
||||
- 一句话申请 → 票据归集 → 自动报销 → 预审 → 审批 → 付款事件 → 入账 → 归档端到端。
|
||||
- 持久开发库只读流式克隆 → Alembic 升级 → 历史回填 dry-run/apply/重复 apply → 服务端登录 → 旧单时间线查询 → 持久库不变验证。
|
||||
- AI 建议 → 用户修改 → 退回/通过 → 记忆候选 → 下次建议变化闭环。
|
||||
- 常用出行方式 1/2 次纠正保持候选,第 3 个不同 Case 且至少 2 次审批通过、跨 7 天后激活;当前输入覆盖、跨租户拒绝、忘记后不再预填。
|
||||
- 风险命中 → 人工确认/误报 → few-shot → 新版本回放 → Canary/回滚闭环。
|
||||
- 节省机会 → 负责人执行 → 实际结果 → 财务确认 → ROI 看板闭环。
|
||||
- 多租户同名员工、同号单据、向量检索和对象存储隔离测试。
|
||||
@@ -464,6 +475,8 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
|
||||
- Agent 会话租户边界:Orchestrator 与 Steward 动作运行时都把可信 `tenant_id` 写入会话状态;创建、恢复、幂等检查点重放、单条删除和批量删除同时校验租户与用户名,同名用户不能跨租户复用 decision 或动作结果。历史无租户状态的会话在认证入口 fail-closed,不会被恢复或删除。`agent_conversations` 仍缺少独立 `tenant_id` 列和数据库复合约束,后续迁移需要把当前应用层守卫下沉为结构化租户键。
|
||||
- 费用事件读取边界:用户态精简 DTO 和首批 HTTP 权限测试已完成;剩余风险是同一 URL 若已有外部客户端依赖旧内部字段会产生契约变更,且未来新增敏感 payload 字段必须继续显式进入白名单,不能恢复任意字典透传。
|
||||
- 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。
|
||||
- 记忆值恢复:现有学习账本只保存 HMAC 指纹,无法恢复具体偏好值;必须由独立记忆表在认证事务中只提取白名单枚举,不能为了复用把自由文本重新塞回账本。
|
||||
- 记忆与制度冲突:首个切片的三种交通方式当前均只有估价和舱等规则,没有“禁止某种交通方式”的制度维度;后续一旦增加按职级、路线或金额禁用交通方式的规则,必须先提供服务端允许性判定、抑制原因和回归用例,再允许个人记忆参与预填。
|
||||
- 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 shadow、Canary、抽检和回滚。
|
||||
- 虚假节省:风险暴露金额、暂缓付款和工时估算容易被夸大,必须由客户财务确认并执行去重。
|
||||
- 外部依赖:税务、支付、银行、商旅、ERP 和消息平台连接器存在可用性、资质和交付周期风险。
|
||||
@@ -538,3 +551,8 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
|
||||
- 2026-07-14(decision 生命周期加固):同一租户、用户、登录会话、conversation 和 HMAC 快照即使使用不同签发 request ID,也复用当前 active decision,避免多个并行 decision 命中草稿幂等捷径后遗留未消费状态。动作发现 decision 过期、已消费或不可用时,前端清空旧 ID、生成新的签发 request ID 并开放重新签发。
|
||||
- 2026-07-14(跨租户检查点加固):Steward 动作会话创建显式写入服务端租户;租户 A/B 即使使用相同用户名、conversation 和 trace,也会获得独立会话与 decision,不再跨租户返回幂等结果或敏感预览内容。
|
||||
- 2026-07-14(统一闭环验证):容器内服务端预览、Steward 动作/图运行、Orchestrator 外部来源授权及决策消费组合回归 50 项通过,Python Ruff F/I 通过;前端结构化动作、会话恢复、工作台路由、富确认和动作脚本共 18 项通过,Vite 生产构建通过。并行读取用户正在变动的规则工作簿曾触发 `openpyxl` ZIP 句柄竞争,相关套件改为串行后全部通过,未修改规则工作簿。
|
||||
- 2026-07-14(个人出行方式记忆):新增租户化 `memory_entries` 与 `memory_evidence_links`,首个切片只存储 `travel_application.transport_mode` 的“飞机/火车/轮船”。同一租户、员工、场景和值需要 3 个不同 Expense Case 的服务端核验纠正、至少 2 个当前审批通过且证据跨 7 天才激活;Candidate/Active 默认有效期分别为 90/180 天。
|
||||
- 2026-07-14(证据信任边界):记忆证据严格绑定当前 tenant、actor、employee、Case owner 和 Claim owner,并要求 Decision、Feedback、Outcome 指向同一申请、同一 Case 和同一个真实 `application_submitted` 业务事件;事件聚合必须是对应 `expense_claim`。同 Case 重放、invalidated/reversed 事实、最新退回、反向纠正和非白名单纠正不会继续支持 active 记忆。
|
||||
- 2026-07-14(应用、抑制与遗忘):个人记忆只补空白出行方式,任何当前显式值都优先。命中后服务端重新计算交通与总额估算再签发 canonical decision;记忆异常降级为无记忆,不阻塞预览。反向或非白名单纠正抑制 active,用户忘记后状态改为 revoked、清空可恢复值和指纹,旧请求不可复活。
|
||||
- 2026-07-14(前端记忆解释):申请核对表新增独立 `TravelReimbursementMemoryPanel`,展示已应用的常用出行方式、证据数量、学习回执和“忘记此偏好”入口;本地会话快照支持跨刷新恢复,忘记偏好不会篡改当前申请字段。
|
||||
- 2026-07-14(个人记忆验证):容器内个人记忆专项 16 项、记忆/预览决策/迁移/所有权组合回归 56 项通过且 1 项条件跳过、前端申请与记忆组合 83 项通过;一次性 PostgreSQL 迁移循环、Ruff、Vite 生产构建和 `git diff --check` 均通过。当前规则中心尚不存在交通方式禁用维度,制度冲突守卫作为后续规则扩展的前置门禁保留,不以恒真占位判断冒充已实现。
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
证据:`ai_learning.py`、`expense_application_learning.py`、`20260714_0003_ai_learning_loop.py`;三类事实分别表达 AI 建议、用户采纳/显式字段纠正和业务结果,技术执行成功、用户反馈与工作流结果不复用同一状态。
|
||||
- [x] [CONCEPT: 业务事件与 AI 决策] 定义落单前服务端预览决策的签发、授权、过期、一次消费、重放和续签契约。
|
||||
证据:`ai_application_preview.py`、`expense_application_preview_decisions.py`、`expense_application_snapshot.py`;预览不创建空 Case,绑定租户、actor、登录会话与 conversation,30 分钟过期,保存/提交成功后一次消费;保存草稿在业务提交后尽力返回基于服务端事实的新 `decision_id`,续签失败不反转已成功动作。
|
||||
- [ ] [CONCEPT: 记忆与学习] 定义 `memory_entries`、证据链接、优先级、有效期、敏感等级、撤销和遗忘契约。
|
||||
- [x] [CONCEPT: 记忆与学习] 定义并实现 `memory_entries`、`memory_evidence_links`、优先级、有效期、敏感白名单、撤销和遗忘契约;首个切片仅覆盖 `travel_application.transport_mode`。
|
||||
证据:`ai_memory.py`、`expense_application_memory.py`、`expense_application_memory_evidence.py`、`20260714_0005_ai_memory.py`;记忆使用租户、主体、场景、字段和值指纹精确隔离,值只允许“飞机/火车/轮船”,服务异常降级为不应用记忆。
|
||||
- [ ] [CONCEPT: 自动化决策] 定义动作风险、金额阈值、置信度、证据完整度、可逆性、抽检率和企业授权策略。
|
||||
- [ ] [CONCEPT: 节省与价值] 定义 Savings Ledger 的机会、执行、实现、确认、去重和归因状态。
|
||||
- [ ] [CONCEPT: 连接器] 定义票据邮箱、税务、企业卡、商旅、支付、银行、ERP、消息和 SSO 连接器协议。
|
||||
@@ -115,8 +116,18 @@
|
||||
- [ ] [CONCEPT: 自动化决策] 实现 L0-L5 动作级自动化等级和资格计算器。
|
||||
- [ ] [CONCEPT: 自动化决策] 为每个动作实现硬白名单、金额上限、证据要求、抽检率和企业上限。
|
||||
- [ ] [CONCEPT: 风险与预审] 统一风险输出为事实、规则、证据、判断、建议动作和降级原因。
|
||||
- [ ] [CONCEPT: 记忆激活] 实现 candidate/active/suppressed/expired/revoked 记忆状态机。
|
||||
- [x] [CONCEPT: 记忆激活] 为首个个人出行方式切片实现 candidate/active/suppressed/expired/revoked 记忆状态机。
|
||||
证据:Candidate/Active 默认有效期分别为 90/180 天;反向或非白名单纠正抑制已激活记忆,过期后新证据创建新 generation,忘记后清值并阻止旧请求复活。
|
||||
- [ ] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。
|
||||
- [x] [CONCEPT: 记忆与学习] 新增租户化个人记忆与证据链接迁移,唯一键保证同一 Expense Case 对同一候选最多计一票。
|
||||
证据:记忆、证据、Decision、Feedback、Outcome、Expense Case 和 Claim 采用租户复合外键;证据同时校验 actor、员工和 Case/单据 owner,跨租户或跨主体关系不能计票。
|
||||
- [x] [CONCEPT: 记忆激活] 为常用出行方式实现隐式激活门槛:3 个不同 Case 的一致服务端纠正、至少 2 次审批通过、证据跨 7 天;草稿、client_observed、accepted 和重试不激活。
|
||||
证据:只有当前有效的 `server_verified` 编辑、`application_submitted` 结果和最新审批状态参与计算;提交事件还必须绑定对应 `expense_claim` 与 Claim ID,invalidated/reversed 事实、同 Case 重放和旧请求均被排除。
|
||||
- [x] [CONCEPT: 记忆与学习] 在服务端申请预览签发前只为空白出行方式应用 active 个人记忆,并返回 memory id、证据数与来源;当前输入、主数据和企业规则优先。
|
||||
证据:`expense_application_preview_workflow.py` 应用记忆后重新执行规则测算和签名;仅缺出行方式时前端仍请求服务端,命中记忆后可直接生成完整核对表。
|
||||
- [x] [CONCEPT: AI 记忆与自动化设置] 提供当前用户记忆查询与“忘记此偏好”接口;撤销后清除可恢复值并在后续预览中不再应用。
|
||||
证据:`GET /api/v1/expense-application-memories/me` 与 `DELETE /api/v1/expense-application-memories/{id}` 均绑定当前租户和主体,越权以 404 隐藏资源存在性。
|
||||
- [ ] [CONCEPT: 记忆与学习] 当规则中心新增按职级、路线或金额禁用交通方式的制度维度时,在个人记忆应用前接入显式允许性判定、冲突抑制原因和回归测试。
|
||||
- [x] [CONCEPT: 记忆与学习] 为 AI 申请预填记录用户原样采纳、显式字段修改和草稿/提交结果证据。
|
||||
证据:`expenseApplicationDecisionFeedback.js`、`useApplicationPreviewEditor.js`、`expense_application_learning.py`、`expense_application_preview_decisions.py`;改回原建议会清除字段差异,日期联动同步记录天数,最终值由服务端 facts 重建。旧预览保持 `client_observed`;服务端签发预览由版本化 HMAC 快照与最终 facts 逐字段比对,字段新增、删除或改值都标记为 `server_verified` 编辑。两者均保持 `training_eligible=false`,尚不直接训练模型或激活记忆。
|
||||
- [x] [CONCEPT: 记忆与学习] 将小财管家、Steward 与通用 Orchestrator 的申请预览切换到认证签发与消费链路,并把结构化预览失败策略收口为 fail-closed。
|
||||
@@ -134,6 +145,8 @@
|
||||
- [ ] [CONCEPT: 审批例外工作台] 补齐批量审批、委托、转交、加签、会签和超时升级交互。
|
||||
- [ ] [CONCEPT: 审批例外工作台] 接入真实企微/钉钉/邮件触达和处理结果回写。
|
||||
- [ ] [CONCEPT: AI 记忆与自动化设置] 新增“我的 AI 记忆”,支持来源解释、修改、忘记和关闭个性化。
|
||||
- [x] [CONCEPT: AI 记忆与自动化设置] 在申请核对表展示常用出行方式的记忆来源、证据数量和“忘记此偏好”,并在保存/提交后区分候选记录与已应用回执。
|
||||
证据:`TravelReimbursementMemoryPanel.vue` 与独立样式分片承载记忆解释、学习回执和可访问的忘记入口;会话快照可跨刷新恢复已应用记忆。
|
||||
- [ ] [CONCEPT: AI 记忆与自动化设置] 新增企业记忆、保留策略、敏感等级和自动化上限管理。
|
||||
- [ ] [CONCEPT: 前端] 展示自动化动作、执行依据、撤销入口、抽检状态和版本信息。
|
||||
|
||||
@@ -176,7 +189,8 @@
|
||||
证据:`test_alembic_migrations.py` 默认无显式 URL 时跳过,主机和库名必须带 disposable 标记;tmpfs PostgreSQL 17 中 4 项通过,覆盖空库升级、重复升级、关键约束/索引、外键级联、base 降级、legacy 哨兵保留、漂移拒绝和再次升级;持久化开发库未被修改。完整 legacy baseline 仍保留在上一条未完成项中。
|
||||
- [ ] [CONCEPT: 测试方案] 为连接器幂等、重试、回执、失败恢复、重复付款和对账补充测试。
|
||||
- [ ] [CONCEPT: 测试方案] 跑通申请 → 票据 → 报销 → 预审 → 审批 → 付款 → 入账 → 归档端到端。
|
||||
- [ ] [CONCEPT: 测试方案] 跑通 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。
|
||||
- [x] [CONCEPT: 测试方案] 跑通首个个人出行方式切片的 AI 建议 → 用户修改 → 工作流结果 → 记忆激活 → 下次建议变化闭环。
|
||||
证据:容器内记忆、预览决策、迁移与所有权组合回归 56 项通过、1 项条件跳过;一次性 PostgreSQL 迁移循环 1 项通过;前端申请快速预览、个人记忆、Steward 与会话恢复组合 83 项通过,Vite 生产构建通过,Python Ruff F/I 与 `git diff --check` 通过。
|
||||
- [x] [CONCEPT: 测试方案] 跑通首个行为采集切片:AI 申请预填 → 用户接受/显式修改 → 草稿或提交结果同事务落账。
|
||||
证据:`test_user_agent_application_draft_events.py`、`test_reimbursement_endpoints.py`、`expense-application-decision-feedback.test.mjs`、`expense-application-fast-preview.test.mjs`;覆盖可信入口、模板/详情排除、隐私指纹、同事务事件关联、日期联动及异步乱序响应。容器内学习账本与迁移所有权定向 29 项、一次性 PostgreSQL 迁移 4 项和前端关键场景 5 项通过。
|
||||
- [ ] [CONCEPT: 测试方案] 跑通风险反馈 → few-shot → golden case → Canary → 回滚闭环。
|
||||
|
||||
298
server/alembic/versions/20260714_0005_ai_memory.py
Normal file
298
server/alembic/versions/20260714_0005_ai_memory.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""add tenant-scoped expense application memory
|
||||
|
||||
Revision ID: 20260714_0005
|
||||
Revises: 20260714_0004
|
||||
Create Date: 2026-07-14 17:10:00
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "20260714_0005"
|
||||
down_revision: str | None = "20260714_0004"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_unique_constraint(
|
||||
"uq_ai_decision_feedback_tenant_id",
|
||||
"ai_decision_feedback",
|
||||
["tenant_id", "id"],
|
||||
)
|
||||
op.create_unique_constraint(
|
||||
"uq_workflow_outcomes_tenant_id",
|
||||
"workflow_outcomes",
|
||||
["tenant_id", "id"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"memory_entries",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column(
|
||||
"scope_type",
|
||||
sa.String(length=20),
|
||||
server_default="user",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("scope_id", sa.String(length=120), nullable=False),
|
||||
sa.Column(
|
||||
"scene",
|
||||
sa.String(length=50),
|
||||
server_default="travel_application",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"field_key",
|
||||
sa.String(length=60),
|
||||
server_default="transport_mode",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("generation", sa.Integer(), server_default="1", nullable=False),
|
||||
sa.Column("value_json", sa.JSON(), nullable=False),
|
||||
sa.Column("value_fingerprint", sa.String(length=80), nullable=False),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=20),
|
||||
server_default="candidate",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("evidence_count", sa.Integer(), server_default="0", nullable=False),
|
||||
sa.Column(
|
||||
"approved_evidence_count",
|
||||
sa.Integer(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"confidence",
|
||||
sa.Numeric(precision=5, scale=4),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"last_evidence_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("candidate_expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("active_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("suppressed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("expired_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_reason", sa.String(length=255), nullable=True),
|
||||
sa.Column("superseded_by_id", sa.String(length=36), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"scope_type = 'user'",
|
||||
name="ck_memory_entries_scope_type",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"scene = 'travel_application'",
|
||||
name="ck_memory_entries_scene",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"field_key = 'transport_mode'",
|
||||
name="ck_memory_entries_field_key",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('candidate', 'active', 'suppressed', 'expired', 'revoked')",
|
||||
name="ck_memory_entries_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"generation >= 1",
|
||||
name="ck_memory_entries_generation",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"evidence_count >= 0 AND approved_evidence_count >= 0 "
|
||||
"AND approved_evidence_count <= evidence_count",
|
||||
name="ck_memory_entries_evidence_counts",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"confidence >= 0 AND confidence <= 1",
|
||||
name="ck_memory_entries_confidence",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"candidate_expires_at > created_at",
|
||||
name="ck_memory_entries_candidate_expiry",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"active_expires_at IS NULL OR ("
|
||||
"activated_at IS NOT NULL AND active_expires_at > activated_at)",
|
||||
name="ck_memory_entries_active_expiry",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status != 'active' OR (activated_at IS NOT NULL AND active_expires_at IS NOT NULL)",
|
||||
name="ck_memory_entries_active_fields",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status != 'suppressed' OR suppressed_at IS NOT NULL",
|
||||
name="ck_memory_entries_suppressed_fields",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status != 'expired' OR expired_at IS NOT NULL",
|
||||
name="ck_memory_entries_expired_fields",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status != 'revoked' OR (revoked_at IS NOT NULL AND length(revoked_reason) > 0)",
|
||||
name="ck_memory_entries_revoked_fields",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"superseded_by_id IS NULL OR status IN ('suppressed', 'revoked')",
|
||||
name="ck_memory_entries_superseded_status",
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"superseded_by_id IS NULL OR superseded_by_id != id",
|
||||
name="ck_memory_entries_not_self_superseded",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "superseded_by_id"],
|
||||
["memory_entries.tenant_id", "memory_entries.id"],
|
||||
name="fk_memory_entries_tenant_superseded_by",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_memory_entries_tenant_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"scene",
|
||||
"field_key",
|
||||
"generation",
|
||||
name="uq_memory_entries_generation",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_memory_entries_scope_lookup",
|
||||
"memory_entries",
|
||||
["tenant_id", "scope_type", "scope_id", "scene", "field_key", "status"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_memory_entries_status_expiry",
|
||||
"memory_entries",
|
||||
["tenant_id", "status", "candidate_expires_at", "active_expires_at"],
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"memory_evidence_links",
|
||||
sa.Column("id", sa.String(length=36), nullable=False),
|
||||
sa.Column("tenant_id", sa.String(length=64), nullable=False),
|
||||
sa.Column("memory_entry_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("expense_case_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("decision_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("feedback_id", sa.String(length=36), nullable=False),
|
||||
sa.Column("outcome_id", sa.String(length=36), nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "memory_entry_id"],
|
||||
["memory_entries.tenant_id", "memory_entries.id"],
|
||||
name="fk_memory_evidence_links_tenant_entry",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id"],
|
||||
["expense_cases.tenant_id", "expense_cases.id"],
|
||||
name="fk_memory_evidence_links_tenant_case",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "decision_id"],
|
||||
["ai_decisions.tenant_id", "ai_decisions.id"],
|
||||
name="fk_memory_evidence_links_tenant_decision",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "feedback_id"],
|
||||
["ai_decision_feedback.tenant_id", "ai_decision_feedback.id"],
|
||||
name="fk_memory_evidence_links_tenant_feedback",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tenant_id", "outcome_id"],
|
||||
["workflow_outcomes.tenant_id", "workflow_outcomes.id"],
|
||||
name="fk_memory_evidence_links_tenant_outcome",
|
||||
ondelete="RESTRICT",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_memory_evidence_links_tenant_id",
|
||||
),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id",
|
||||
"memory_entry_id",
|
||||
"expense_case_id",
|
||||
name="uq_memory_evidence_links_entry_case",
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_memory_evidence_links_entry_time",
|
||||
"memory_evidence_links",
|
||||
["tenant_id", "memory_entry_id", "created_at"],
|
||||
)
|
||||
op.create_index(
|
||||
"ix_memory_evidence_links_sources",
|
||||
"memory_evidence_links",
|
||||
["tenant_id", "decision_id", "feedback_id", "outcome_id"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_memory_evidence_links_sources",
|
||||
table_name="memory_evidence_links",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_memory_evidence_links_entry_time",
|
||||
table_name="memory_evidence_links",
|
||||
)
|
||||
op.drop_table("memory_evidence_links")
|
||||
|
||||
op.drop_index(
|
||||
"ix_memory_entries_status_expiry",
|
||||
table_name="memory_entries",
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_memory_entries_scope_lookup",
|
||||
table_name="memory_entries",
|
||||
)
|
||||
op.drop_table("memory_entries")
|
||||
|
||||
op.drop_constraint(
|
||||
"uq_workflow_outcomes_tenant_id",
|
||||
"workflow_outcomes",
|
||||
type_="unique",
|
||||
)
|
||||
op.drop_constraint(
|
||||
"uq_ai_decision_feedback_tenant_id",
|
||||
"ai_decision_feedback",
|
||||
type_="unique",
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.expense_application_memory import (
|
||||
ExpenseApplicationMemoryListRead,
|
||||
ExpenseApplicationMemoryRevokedRead,
|
||||
)
|
||||
from app.services.expense_application_memory import ExpenseApplicationMemoryService
|
||||
|
||||
router = APIRouter(prefix="/expense-application-memories")
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=ExpenseApplicationMemoryListRead,
|
||||
summary="读取当前登录人的费用申请记忆",
|
||||
)
|
||||
def list_my_expense_application_memories(
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseApplicationMemoryListRead:
|
||||
return ExpenseApplicationMemoryService(db).list_current_user_memories(current_user)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{memory_id}",
|
||||
response_model=ExpenseApplicationMemoryRevokedRead,
|
||||
summary="撤销当前登录人的费用申请记忆",
|
||||
)
|
||||
def revoke_my_expense_application_memory(
|
||||
memory_id: str,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseApplicationMemoryRevokedRead:
|
||||
result = ExpenseApplicationMemoryService(db).revoke_current_user_memory(
|
||||
memory_id,
|
||||
current_user,
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="未找到可撤销的个人费用申请记忆。",
|
||||
)
|
||||
return result
|
||||
@@ -15,6 +15,9 @@ from app.api.v1.endpoints.bootstrap import router as bootstrap_router
|
||||
from app.api.v1.endpoints.budgets import router as budgets_router
|
||||
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router
|
||||
from app.api.v1.endpoints.employees import router as employees_router
|
||||
from app.api.v1.endpoints.expense_application_memories import (
|
||||
router as expense_application_memories_router,
|
||||
)
|
||||
from app.api.v1.endpoints.expense_application_previews import (
|
||||
router as expense_application_previews_router,
|
||||
)
|
||||
@@ -57,6 +60,7 @@ router.include_router(orchestrator_router, tags=["orchestrator"])
|
||||
router.include_router(receipt_folder_router, tags=["receipt-folder"])
|
||||
router.include_router(employees_router, prefix="/employees", tags=["employees"])
|
||||
router.include_router(expense_cases_router, tags=["expense-cases"])
|
||||
router.include_router(expense_application_memories_router, tags=["expense-application-memories"])
|
||||
router.include_router(expense_application_previews_router, tags=["reimbursements"])
|
||||
router.include_router(employee_profiles_router, tags=["employee-profiles"])
|
||||
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
|
||||
|
||||
@@ -11,6 +11,7 @@ from app.models.agent_feedback import AgentOperationFeedback
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||
from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.auth_session import AuthSession
|
||||
@@ -76,6 +77,8 @@ __all__ = [
|
||||
"HermesTaskConfig",
|
||||
"HermesTaskExecutionLog",
|
||||
"HermesRiskReport",
|
||||
"MemoryEntry",
|
||||
"MemoryEvidenceLink",
|
||||
"NotificationState",
|
||||
"OrganizationUnit",
|
||||
"ReimbursementRequest",
|
||||
|
||||
@@ -50,8 +50,22 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
|
||||
"workflow_outcomes",
|
||||
}
|
||||
),
|
||||
"20260714_0005": frozenset(
|
||||
{
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"auth_sessions",
|
||||
"ai_application_preview_decisions",
|
||||
"ai_decisions",
|
||||
"ai_decision_feedback",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
),
|
||||
}
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"] != MIGRATION_OWNED_TABLES:
|
||||
if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"] != MIGRATION_OWNED_TABLES:
|
||||
raise RuntimeError("latest Alembic revision must own the centralized migration table set")
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
|
||||
"expense_cases",
|
||||
"expense_case_links",
|
||||
"business_events",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.models.agent_feedback import AgentOperationFeedback
|
||||
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog
|
||||
from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
|
||||
from app.models.approval import ApprovalRecord
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.models.auth_session import AuthSession
|
||||
@@ -72,6 +73,8 @@ __all__ = [
|
||||
"HermesTaskConfig",
|
||||
"HermesTaskExecutionLog",
|
||||
"HermesRiskReport",
|
||||
"MemoryEntry",
|
||||
"MemoryEvidenceLink",
|
||||
"NotificationState",
|
||||
"OrganizationUnit",
|
||||
"ReimbursementRequest",
|
||||
|
||||
@@ -133,6 +133,11 @@ class AIDecisionFeedback(Base):
|
||||
|
||||
__tablename__ = "ai_decision_feedback"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_ai_decision_feedback_tenant_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
@@ -203,6 +208,11 @@ class WorkflowOutcome(Base):
|
||||
|
||||
__tablename__ = "workflow_outcomes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_workflow_outcomes_tenant_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"idempotency_key",
|
||||
|
||||
300
server/src/app/models/ai_memory.py
Normal file
300
server/src/app/models/ai_memory.py
Normal file
@@ -0,0 +1,300 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKeyConstraint,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
MEMORY_CANDIDATE_TTL_DAYS = 90
|
||||
MEMORY_ACTIVE_TTL_DAYS = 180
|
||||
|
||||
|
||||
def _new_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _candidate_expires_at() -> datetime:
|
||||
return datetime.now(UTC) + timedelta(days=MEMORY_CANDIDATE_TTL_DAYS)
|
||||
|
||||
|
||||
class MemoryEntry(Base):
|
||||
"""受证据约束、可撤销的个人费用申请记忆。"""
|
||||
|
||||
__tablename__ = "memory_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_memory_entries_tenant_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"scene",
|
||||
"field_key",
|
||||
"generation",
|
||||
name="uq_memory_entries_generation",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "superseded_by_id"],
|
||||
["memory_entries.tenant_id", "memory_entries.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_memory_entries_tenant_superseded_by",
|
||||
),
|
||||
CheckConstraint(
|
||||
"scope_type = 'user'",
|
||||
name="ck_memory_entries_scope_type",
|
||||
),
|
||||
CheckConstraint(
|
||||
"scene = 'travel_application'",
|
||||
name="ck_memory_entries_scene",
|
||||
),
|
||||
CheckConstraint(
|
||||
"field_key = 'transport_mode'",
|
||||
name="ck_memory_entries_field_key",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('candidate', 'active', 'suppressed', 'expired', 'revoked')",
|
||||
name="ck_memory_entries_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"generation >= 1",
|
||||
name="ck_memory_entries_generation",
|
||||
),
|
||||
CheckConstraint(
|
||||
"evidence_count >= 0 AND approved_evidence_count >= 0 "
|
||||
"AND approved_evidence_count <= evidence_count",
|
||||
name="ck_memory_entries_evidence_counts",
|
||||
),
|
||||
CheckConstraint(
|
||||
"confidence >= 0 AND confidence <= 1",
|
||||
name="ck_memory_entries_confidence",
|
||||
),
|
||||
CheckConstraint(
|
||||
"candidate_expires_at > created_at",
|
||||
name="ck_memory_entries_candidate_expiry",
|
||||
),
|
||||
CheckConstraint(
|
||||
"active_expires_at IS NULL OR ("
|
||||
"activated_at IS NOT NULL AND active_expires_at > activated_at)",
|
||||
name="ck_memory_entries_active_expiry",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status != 'active' OR (activated_at IS NOT NULL AND active_expires_at IS NOT NULL)",
|
||||
name="ck_memory_entries_active_fields",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status != 'suppressed' OR suppressed_at IS NOT NULL",
|
||||
name="ck_memory_entries_suppressed_fields",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status != 'expired' OR expired_at IS NOT NULL",
|
||||
name="ck_memory_entries_expired_fields",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status != 'revoked' OR (revoked_at IS NOT NULL AND length(revoked_reason) > 0)",
|
||||
name="ck_memory_entries_revoked_fields",
|
||||
),
|
||||
CheckConstraint(
|
||||
"superseded_by_id IS NULL OR status IN ('suppressed', 'revoked')",
|
||||
name="ck_memory_entries_superseded_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"superseded_by_id IS NULL OR superseded_by_id != id",
|
||||
name="ck_memory_entries_not_self_superseded",
|
||||
),
|
||||
Index(
|
||||
"ix_memory_entries_scope_lookup",
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"scene",
|
||||
"field_key",
|
||||
"status",
|
||||
),
|
||||
Index(
|
||||
"ix_memory_entries_status_expiry",
|
||||
"tenant_id",
|
||||
"status",
|
||||
"candidate_expires_at",
|
||||
"active_expires_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
scope_type: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="user",
|
||||
server_default="user",
|
||||
)
|
||||
scope_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
scene: Mapped[str] = mapped_column(
|
||||
String(50),
|
||||
nullable=False,
|
||||
default="travel_application",
|
||||
server_default="travel_application",
|
||||
)
|
||||
field_key: Mapped[str] = mapped_column(
|
||||
String(60),
|
||||
nullable=False,
|
||||
default="transport_mode",
|
||||
server_default="transport_mode",
|
||||
)
|
||||
generation: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=1,
|
||||
server_default="1",
|
||||
)
|
||||
value_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
|
||||
value_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="candidate",
|
||||
server_default="candidate",
|
||||
)
|
||||
evidence_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
server_default="0",
|
||||
)
|
||||
approved_evidence_count: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
default=0,
|
||||
server_default="0",
|
||||
)
|
||||
confidence: Mapped[Decimal] = mapped_column(
|
||||
Numeric(5, 4),
|
||||
nullable=False,
|
||||
default=Decimal("0.0000"),
|
||||
server_default="0",
|
||||
)
|
||||
last_evidence_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(UTC),
|
||||
server_default=func.now(),
|
||||
)
|
||||
candidate_expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
default=_candidate_expires_at,
|
||||
)
|
||||
activated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
active_expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
suppressed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
expired_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
superseded_by_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
|
||||
class MemoryEvidenceLink(Base):
|
||||
"""一条记忆在一个费用 Case 中的去重证据包。"""
|
||||
|
||||
__tablename__ = "memory_evidence_links"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_memory_evidence_links_tenant_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"memory_entry_id",
|
||||
"expense_case_id",
|
||||
name="uq_memory_evidence_links_entry_case",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "memory_entry_id"],
|
||||
["memory_entries.tenant_id", "memory_entries.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_memory_evidence_links_tenant_entry",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "expense_case_id"],
|
||||
["expense_cases.tenant_id", "expense_cases.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_memory_evidence_links_tenant_case",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "decision_id"],
|
||||
["ai_decisions.tenant_id", "ai_decisions.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_memory_evidence_links_tenant_decision",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "feedback_id"],
|
||||
["ai_decision_feedback.tenant_id", "ai_decision_feedback.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_memory_evidence_links_tenant_feedback",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["tenant_id", "outcome_id"],
|
||||
["workflow_outcomes.tenant_id", "workflow_outcomes.id"],
|
||||
ondelete="RESTRICT",
|
||||
name="fk_memory_evidence_links_tenant_outcome",
|
||||
),
|
||||
Index(
|
||||
"ix_memory_evidence_links_entry_time",
|
||||
"tenant_id",
|
||||
"memory_entry_id",
|
||||
"created_at",
|
||||
),
|
||||
Index(
|
||||
"ix_memory_evidence_links_sources",
|
||||
"tenant_id",
|
||||
"decision_id",
|
||||
"feedback_id",
|
||||
"outcome_id",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_new_id)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
memory_entry_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
decision_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
feedback_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
outcome_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
64
server/src/app/schemas/expense_application_memory.py
Normal file
64
server/src/app/schemas/expense_application_memory.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExpenseApplicationMemoryApplication(BaseModel):
|
||||
memory_id: str
|
||||
field_key: str = "transport_mode"
|
||||
field_label: str = "出行方式"
|
||||
value: str
|
||||
source: str = "verified_user_history"
|
||||
status: str = "applied"
|
||||
evidence_count: int = 0
|
||||
approved_evidence_count: int = 0
|
||||
confidence: float = 0.0
|
||||
expires_at: datetime | None = None
|
||||
message: str = "已按可信历史记忆预填常用出行方式,可继续修改。"
|
||||
|
||||
|
||||
class ExpenseApplicationLearningReceipt(BaseModel):
|
||||
memory_id: str
|
||||
evidence_id: str
|
||||
field_key: str = "transport_mode"
|
||||
field_label: str = "出行方式"
|
||||
value: str
|
||||
status: str
|
||||
evidence_count: int = 0
|
||||
approved_evidence_count: int = 0
|
||||
activated: bool = False
|
||||
message: str
|
||||
|
||||
|
||||
class ExpenseApplicationMemoryRead(BaseModel):
|
||||
id: str
|
||||
scene: str
|
||||
field_key: str
|
||||
value: str = ""
|
||||
status: str
|
||||
evidence_count: int = 0
|
||||
approved_evidence_count: int = 0
|
||||
confidence: float = 0.0
|
||||
activation_threshold: int = 3
|
||||
policy_version: str
|
||||
valid_from: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
last_evidence_at: datetime | None = None
|
||||
activated_at: datetime | None = None
|
||||
suppressed_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
revoked_reason: str = ""
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class ExpenseApplicationMemoryListRead(BaseModel):
|
||||
items: list[ExpenseApplicationMemoryRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExpenseApplicationMemoryRevokedRead(BaseModel):
|
||||
memory_id: str
|
||||
status: str = "revoked"
|
||||
revoked_at: datetime
|
||||
@@ -207,6 +207,7 @@ class ExpenseApplicationPreviewActionResult(BaseModel):
|
||||
draft_payload: dict[str, Any] | None = None
|
||||
decision_id: str | None = None
|
||||
decision_expires_at: datetime | None = None
|
||||
learning_receipts: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ExpenseApplicationPreviewActionResponse(BaseModel):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
@@ -14,6 +15,7 @@ from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutco
|
||||
from app.models.expense_case import BusinessEvent
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_application_memory import ExpenseApplicationMemoryService
|
||||
from app.services.expense_application_preview_decisions import (
|
||||
ExpenseApplicationPreviewDecisionService,
|
||||
)
|
||||
@@ -27,6 +29,8 @@ from app.services.expense_application_snapshot import (
|
||||
)
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpenseApplicationLearningRecords:
|
||||
@@ -122,6 +126,12 @@ class ExpenseApplicationLearningService:
|
||||
)
|
||||
existing = self._find_existing(tenant_id, idempotency_key)
|
||||
if existing is not None:
|
||||
self._record_transport_memory_evidence(
|
||||
current_user=current_user,
|
||||
records=existing,
|
||||
claim=claim,
|
||||
facts=final_values,
|
||||
)
|
||||
return existing
|
||||
|
||||
decision_id = self._stable_id("decision", tenant_id, idempotency_key)
|
||||
@@ -226,6 +236,13 @@ class ExpenseApplicationLearningService:
|
||||
)
|
||||
self.db.add_all([decision, feedback, outcome])
|
||||
self.db.flush()
|
||||
records = ExpenseApplicationLearningRecords(decision, feedback, outcome)
|
||||
self._record_transport_memory_evidence(
|
||||
current_user=current_user,
|
||||
records=records,
|
||||
claim=claim,
|
||||
facts=final_values,
|
||||
)
|
||||
if preview_decision is not None:
|
||||
ExpenseApplicationPreviewDecisionService(self.db).consume(
|
||||
preview_decision,
|
||||
@@ -235,7 +252,30 @@ class ExpenseApplicationLearningService:
|
||||
claim=claim,
|
||||
business_event=business_event,
|
||||
)
|
||||
return ExpenseApplicationLearningRecords(decision, feedback, outcome)
|
||||
return records
|
||||
|
||||
def _record_transport_memory_evidence(
|
||||
self,
|
||||
*,
|
||||
current_user: CurrentUserContext,
|
||||
records: ExpenseApplicationLearningRecords,
|
||||
claim: ExpenseClaim,
|
||||
facts: dict[str, str],
|
||||
) -> None:
|
||||
"""记忆属于可降级派生能力,失败不能回滚申请和学习账本。"""
|
||||
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
ExpenseApplicationMemoryService(self.db).record_transport_edit_evidence(
|
||||
current_user=current_user,
|
||||
decision=records.decision,
|
||||
feedback=records.feedback,
|
||||
outcome=records.outcome,
|
||||
claim=claim,
|
||||
transport_mode=str(facts.get("transport_mode") or ""),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("个人出行方式记忆证据写入失败,本次申请继续提交。", exc_info=True)
|
||||
|
||||
def _find_existing(
|
||||
self,
|
||||
|
||||
699
server/src/app/services/expense_application_memory.py
Normal file
699
server/src/app/services/expense_application_memory.py
Normal file
@@ -0,0 +1,699 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import case, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
|
||||
from app.models.expense_case import BusinessEvent
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.expense_application_memory import (
|
||||
ExpenseApplicationLearningReceipt,
|
||||
ExpenseApplicationMemoryApplication,
|
||||
ExpenseApplicationMemoryListRead,
|
||||
ExpenseApplicationMemoryRead,
|
||||
ExpenseApplicationMemoryRevokedRead,
|
||||
)
|
||||
from app.services.expense_application_memory_evidence import (
|
||||
ExpenseApplicationMemoryEvidenceValidator,
|
||||
)
|
||||
from app.services.expense_application_snapshot import hmac_fingerprint
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MEMORY_SCOPE_TYPE = "user"
|
||||
MEMORY_SCENE = "travel_application"
|
||||
MEMORY_FIELD_KEY = "transport_mode"
|
||||
MEMORY_POLICY_VERSION = "expense_application_transport_memory.v1"
|
||||
MEMORY_ACTIVATION_THRESHOLD = 3
|
||||
MEMORY_APPROVED_THRESHOLD = 2
|
||||
MEMORY_EVIDENCE_SPAN = timedelta(days=7)
|
||||
MEMORY_CANDIDATE_TTL = timedelta(days=90)
|
||||
MEMORY_ACTIVE_TTL = timedelta(days=180)
|
||||
MEMORY_OUTCOME_EVENT_TYPES = {"application_approved", "application_returned"}
|
||||
SUPPORTED_TRANSPORT_VALUES = {"飞机", "火车", "轮船"}
|
||||
|
||||
|
||||
class ExpenseApplicationMemoryService:
|
||||
"""从可信字段纠正证据生成、激活并应用个人费用申请记忆。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def record_transport_edit_evidence(
|
||||
self,
|
||||
*,
|
||||
current_user: CurrentUserContext,
|
||||
decision: AIDecision,
|
||||
feedback: AIDecisionFeedback,
|
||||
outcome: WorkflowOutcome,
|
||||
claim: ExpenseClaim,
|
||||
transport_mode: str,
|
||||
) -> ExpenseApplicationLearningReceipt | None:
|
||||
if not self._is_eligible_feedback(feedback, outcome):
|
||||
return None
|
||||
if not self._changed_transport_mode(feedback):
|
||||
return None
|
||||
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
if any(
|
||||
item != tenant_id
|
||||
for item in (decision.tenant_id, feedback.tenant_id, outcome.tenant_id)
|
||||
):
|
||||
raise PermissionError("个人记忆证据不能关联其他租户的学习记录。")
|
||||
ExpenseApplicationMemoryEvidenceValidator(self.db).validate(
|
||||
current_user=current_user,
|
||||
decision=decision,
|
||||
feedback=feedback,
|
||||
outcome=outcome,
|
||||
claim=claim,
|
||||
)
|
||||
|
||||
scope_id = self._scope_id(current_user)
|
||||
now = datetime.now(UTC)
|
||||
replay = self.db.execute(
|
||||
select(MemoryEvidenceLink, MemoryEntry)
|
||||
.join(MemoryEntry, MemoryEntry.id == MemoryEvidenceLink.memory_entry_id)
|
||||
.where(
|
||||
MemoryEvidenceLink.tenant_id == tenant_id,
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
or_(
|
||||
MemoryEvidenceLink.decision_id == decision.id,
|
||||
MemoryEvidenceLink.feedback_id == feedback.id,
|
||||
MemoryEvidenceLink.outcome_id == outcome.id,
|
||||
),
|
||||
)
|
||||
).first()
|
||||
if replay is not None:
|
||||
evidence, replay_entry = replay
|
||||
return self._build_receipt(
|
||||
replay_entry,
|
||||
evidence,
|
||||
activated=False,
|
||||
)
|
||||
|
||||
normalized_value = self._normalize_transport_value(transport_mode)
|
||||
if not normalized_value:
|
||||
# 未知方式只作为旧偏好失效的负向信号,绝不保存新值。
|
||||
self._suppress_active_entries(
|
||||
tenant_id=tenant_id,
|
||||
scope_id=scope_id,
|
||||
now=now,
|
||||
)
|
||||
self.db.flush()
|
||||
return None
|
||||
|
||||
value_fingerprint = self._value_fingerprint(normalized_value)
|
||||
entry = self._find_open_entry(
|
||||
tenant_id=tenant_id,
|
||||
scope_id=scope_id,
|
||||
value_fingerprint=value_fingerprint,
|
||||
)
|
||||
while entry is not None and self._is_expired(entry, now):
|
||||
entry.status = "expired"
|
||||
entry.expired_at = now
|
||||
self.db.flush()
|
||||
entry = self._find_open_entry(
|
||||
tenant_id=tenant_id,
|
||||
scope_id=scope_id,
|
||||
value_fingerprint=value_fingerprint,
|
||||
)
|
||||
if entry is None:
|
||||
entry = self._create_candidate_entry(
|
||||
tenant_id=tenant_id,
|
||||
scope_id=scope_id,
|
||||
value=normalized_value,
|
||||
value_fingerprint=value_fingerprint,
|
||||
now=now,
|
||||
)
|
||||
self.db.add(entry)
|
||||
self.db.flush()
|
||||
|
||||
existing_link = self.db.scalar(
|
||||
select(MemoryEvidenceLink).where(
|
||||
MemoryEvidenceLink.tenant_id == tenant_id,
|
||||
MemoryEvidenceLink.memory_entry_id == entry.id,
|
||||
MemoryEvidenceLink.expense_case_id == decision.expense_case_id,
|
||||
)
|
||||
)
|
||||
if existing_link is not None:
|
||||
return self._build_receipt(
|
||||
entry,
|
||||
existing_link,
|
||||
activated=False,
|
||||
)
|
||||
|
||||
existing_link = MemoryEvidenceLink(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
memory_entry_id=entry.id,
|
||||
decision_id=decision.id,
|
||||
feedback_id=feedback.id,
|
||||
expense_case_id=decision.expense_case_id,
|
||||
outcome_id=outcome.id,
|
||||
)
|
||||
self.db.add(existing_link)
|
||||
self.db.flush()
|
||||
|
||||
self._suppress_opposite_active_entries(
|
||||
tenant_id=tenant_id,
|
||||
scope_id=scope_id,
|
||||
value_fingerprint=value_fingerprint,
|
||||
now=now,
|
||||
)
|
||||
previous_status = str(entry.status or "")
|
||||
self._refresh_entry_metrics(entry, now=now, refresh_expiry=True)
|
||||
self.db.flush()
|
||||
return self._build_receipt(
|
||||
entry,
|
||||
existing_link,
|
||||
activated=previous_status != "active" and entry.status == "active",
|
||||
)
|
||||
|
||||
def apply_active_transport_memory(
|
||||
self,
|
||||
facts: dict[str, Any],
|
||||
current_user: CurrentUserContext,
|
||||
) -> list[ExpenseApplicationMemoryApplication]:
|
||||
# 当前输入只要显式给出了出行方式就必须优先,即使值暂不在可学习白名单内。
|
||||
if str(facts.get(MEMORY_FIELD_KEY) or "").strip():
|
||||
return []
|
||||
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
entry = self._resolve_active_entry(current_user)
|
||||
if entry is None:
|
||||
return []
|
||||
value = self._entry_value(entry)
|
||||
if not value:
|
||||
return []
|
||||
facts[MEMORY_FIELD_KEY] = value
|
||||
return [self._build_application(entry, value)]
|
||||
except Exception:
|
||||
logger.warning("个人出行方式记忆读取失败,本轮预览不应用记忆。", exc_info=True)
|
||||
return []
|
||||
|
||||
def learning_receipts_for_preview_decision(
|
||||
self,
|
||||
preview_decision_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> list[ExpenseApplicationLearningReceipt]:
|
||||
normalized_id = str(preview_decision_id or "").strip()
|
||||
if not normalized_id:
|
||||
return []
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
scope_id = self._scope_id(current_user)
|
||||
stmt = (
|
||||
select(MemoryEvidenceLink, MemoryEntry)
|
||||
.join(AIDecision, AIDecision.id == MemoryEvidenceLink.decision_id)
|
||||
.join(MemoryEntry, MemoryEntry.id == MemoryEvidenceLink.memory_entry_id)
|
||||
.where(
|
||||
MemoryEvidenceLink.tenant_id == tenant_id,
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
AIDecision.preview_decision_id == normalized_id,
|
||||
)
|
||||
.order_by(MemoryEvidenceLink.created_at.asc())
|
||||
)
|
||||
return [
|
||||
self._build_receipt(entry, evidence, activated=entry.status == "active")
|
||||
for evidence, entry in self.db.execute(stmt).all()
|
||||
]
|
||||
|
||||
def list_current_user_memories(
|
||||
self,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseApplicationMemoryListRead:
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
scope_id = self._scope_id(current_user)
|
||||
now = datetime.now(UTC)
|
||||
entries = list(
|
||||
self.db.scalars(
|
||||
select(MemoryEntry)
|
||||
.where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
)
|
||||
.order_by(MemoryEntry.generation.desc(), MemoryEntry.created_at.desc())
|
||||
).all()
|
||||
)
|
||||
for entry in entries:
|
||||
if entry.status in {"candidate", "active"}:
|
||||
self._refresh_entry_metrics(entry, now=now, allow_activation=False)
|
||||
self.db.commit()
|
||||
return ExpenseApplicationMemoryListRead(
|
||||
items=[self._serialize_entry(entry) for entry in entries]
|
||||
)
|
||||
|
||||
def revoke_current_user_memory(
|
||||
self,
|
||||
memory_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> ExpenseApplicationMemoryRevokedRead | None:
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
scope_id = self._scope_id(current_user)
|
||||
entry = self.db.scalar(
|
||||
select(MemoryEntry)
|
||||
.where(
|
||||
MemoryEntry.id == str(memory_id or "").strip(),
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if entry is None:
|
||||
return None
|
||||
now = datetime.now(UTC)
|
||||
entry.status = "revoked"
|
||||
entry.value_json = {}
|
||||
entry.value_fingerprint = ""
|
||||
entry.revoked_at = now
|
||||
entry.revoked_reason = "user_requested"
|
||||
self.db.commit()
|
||||
return ExpenseApplicationMemoryRevokedRead(
|
||||
memory_id=entry.id,
|
||||
revoked_at=now,
|
||||
)
|
||||
|
||||
def _resolve_active_entry(
|
||||
self,
|
||||
current_user: CurrentUserContext,
|
||||
) -> MemoryEntry | None:
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
scope_id = self._scope_id(current_user)
|
||||
now = datetime.now(UTC)
|
||||
entries = list(
|
||||
self.db.scalars(
|
||||
select(MemoryEntry)
|
||||
.where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
MemoryEntry.field_key == MEMORY_FIELD_KEY,
|
||||
MemoryEntry.status.in_(["candidate", "active"]),
|
||||
)
|
||||
.order_by(MemoryEntry.last_evidence_at.desc(), MemoryEntry.generation.desc())
|
||||
.with_for_update()
|
||||
).all()
|
||||
)
|
||||
active_entry = next((entry for entry in entries if entry.status == "active"), None)
|
||||
if active_entry is not None:
|
||||
self._refresh_entry_metrics(active_entry, now=now)
|
||||
if active_entry.status == "active":
|
||||
return active_entry
|
||||
|
||||
for entry in entries:
|
||||
if entry.status != "candidate":
|
||||
continue
|
||||
self._refresh_entry_metrics(entry, now=now)
|
||||
if entry.status == "active":
|
||||
return entry
|
||||
return None
|
||||
|
||||
def _refresh_entry_metrics(
|
||||
self,
|
||||
entry: MemoryEntry,
|
||||
*,
|
||||
now: datetime,
|
||||
refresh_expiry: bool = False,
|
||||
allow_activation: bool = True,
|
||||
) -> None:
|
||||
if entry.status not in {"candidate", "active"}:
|
||||
return
|
||||
if self._is_expired(entry, now):
|
||||
entry.status = "expired"
|
||||
entry.expired_at = now
|
||||
return
|
||||
|
||||
links = ExpenseApplicationMemoryEvidenceValidator(
|
||||
self.db
|
||||
).list_currently_valid_links(
|
||||
tenant_id=entry.tenant_id,
|
||||
memory_entry_id=entry.id,
|
||||
)
|
||||
case_ids = {link.expense_case_id for link in links}
|
||||
approved_case_ids = self._approved_case_ids(
|
||||
tenant_id=entry.tenant_id,
|
||||
case_ids=case_ids,
|
||||
)
|
||||
entry.evidence_count = len({link.expense_case_id for link in links})
|
||||
entry.approved_evidence_count = len(approved_case_ids)
|
||||
entry.confidence = Decimal(
|
||||
str(min(1.0, entry.evidence_count / MEMORY_ACTIVATION_THRESHOLD))
|
||||
).quantize(Decimal("0.0001"))
|
||||
if links:
|
||||
entry.last_evidence_at = max(link.created_at for link in links)
|
||||
if refresh_expiry:
|
||||
if entry.status == "active":
|
||||
entry.active_expires_at = now + MEMORY_ACTIVE_TTL
|
||||
else:
|
||||
entry.candidate_expires_at = now + MEMORY_CANDIDATE_TTL
|
||||
|
||||
if entry.status == "active" and not self._qualifies_for_activation(entry, links):
|
||||
# 审批退回、证据 invalidated 或 outcome reversed 后不得继续预填。
|
||||
entry.status = "suppressed"
|
||||
entry.suppressed_at = now
|
||||
return
|
||||
|
||||
if (
|
||||
not allow_activation
|
||||
or entry.status != "candidate"
|
||||
or not self._qualifies_for_activation(entry, links)
|
||||
):
|
||||
return
|
||||
self._suppress_other_active_entries(entry, now=now)
|
||||
entry.status = "active"
|
||||
entry.activated_at = now
|
||||
entry.active_expires_at = now + MEMORY_ACTIVE_TTL
|
||||
|
||||
@staticmethod
|
||||
def _qualifies_for_activation(
|
||||
entry: MemoryEntry,
|
||||
links: list[MemoryEvidenceLink],
|
||||
) -> bool:
|
||||
if (
|
||||
entry.evidence_count < MEMORY_ACTIVATION_THRESHOLD
|
||||
or entry.approved_evidence_count < MEMORY_APPROVED_THRESHOLD
|
||||
or len(links) < 2
|
||||
):
|
||||
return False
|
||||
first_at = min(link.created_at for link in links)
|
||||
last_at = max(link.created_at for link in links)
|
||||
return last_at - first_at >= MEMORY_EVIDENCE_SPAN
|
||||
|
||||
def _suppress_other_active_entries(
|
||||
self,
|
||||
activated_entry: MemoryEntry,
|
||||
*,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
entries = list(
|
||||
self.db.scalars(
|
||||
select(MemoryEntry).where(
|
||||
MemoryEntry.tenant_id == activated_entry.tenant_id,
|
||||
MemoryEntry.scope_type == activated_entry.scope_type,
|
||||
MemoryEntry.scope_id == activated_entry.scope_id,
|
||||
MemoryEntry.scene == activated_entry.scene,
|
||||
MemoryEntry.field_key == activated_entry.field_key,
|
||||
MemoryEntry.status == "active",
|
||||
MemoryEntry.id != activated_entry.id,
|
||||
)
|
||||
).all()
|
||||
)
|
||||
for entry in entries:
|
||||
entry.status = "suppressed"
|
||||
entry.suppressed_at = now
|
||||
|
||||
def _suppress_opposite_active_entries(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_id: str,
|
||||
value_fingerprint: str,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
entries = list(
|
||||
self.db.scalars(
|
||||
select(MemoryEntry)
|
||||
.where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
MemoryEntry.field_key == MEMORY_FIELD_KEY,
|
||||
MemoryEntry.status == "active",
|
||||
MemoryEntry.value_fingerprint != value_fingerprint,
|
||||
)
|
||||
.with_for_update()
|
||||
).all()
|
||||
)
|
||||
for entry in entries:
|
||||
entry.status = "suppressed"
|
||||
entry.suppressed_at = now
|
||||
|
||||
def _suppress_active_entries(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_id: str,
|
||||
now: datetime,
|
||||
) -> None:
|
||||
entries = list(
|
||||
self.db.scalars(
|
||||
select(MemoryEntry)
|
||||
.where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
MemoryEntry.field_key == MEMORY_FIELD_KEY,
|
||||
MemoryEntry.status == "active",
|
||||
)
|
||||
.with_for_update()
|
||||
).all()
|
||||
)
|
||||
for entry in entries:
|
||||
entry.status = "suppressed"
|
||||
entry.suppressed_at = now
|
||||
|
||||
def _find_open_entry(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_id: str,
|
||||
value_fingerprint: str,
|
||||
) -> MemoryEntry | None:
|
||||
return self.db.scalar(
|
||||
select(MemoryEntry)
|
||||
.where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
MemoryEntry.field_key == MEMORY_FIELD_KEY,
|
||||
MemoryEntry.value_fingerprint == value_fingerprint,
|
||||
MemoryEntry.status.in_(["candidate", "active"]),
|
||||
)
|
||||
.order_by(MemoryEntry.generation.desc())
|
||||
.with_for_update()
|
||||
)
|
||||
|
||||
def _create_candidate_entry(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_id: str,
|
||||
value: str,
|
||||
value_fingerprint: str,
|
||||
now: datetime,
|
||||
) -> MemoryEntry:
|
||||
generation = int(
|
||||
self.db.scalar(
|
||||
select(func.coalesce(func.max(MemoryEntry.generation), 0)).where(
|
||||
MemoryEntry.tenant_id == tenant_id,
|
||||
MemoryEntry.scope_type == MEMORY_SCOPE_TYPE,
|
||||
MemoryEntry.scope_id == scope_id,
|
||||
MemoryEntry.scene == MEMORY_SCENE,
|
||||
MemoryEntry.field_key == MEMORY_FIELD_KEY,
|
||||
)
|
||||
)
|
||||
or 0
|
||||
) + 1
|
||||
return MemoryEntry(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
scope_type=MEMORY_SCOPE_TYPE,
|
||||
scope_id=scope_id,
|
||||
scene=MEMORY_SCENE,
|
||||
field_key=MEMORY_FIELD_KEY,
|
||||
generation=generation,
|
||||
value_json={"value": value},
|
||||
value_fingerprint=value_fingerprint,
|
||||
status="candidate",
|
||||
evidence_count=0,
|
||||
approved_evidence_count=0,
|
||||
confidence=Decimal("0"),
|
||||
candidate_expires_at=now + MEMORY_CANDIDATE_TTL,
|
||||
last_evidence_at=now,
|
||||
)
|
||||
|
||||
def _approved_case_ids(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
case_ids: set[str],
|
||||
) -> set[str]:
|
||||
if not case_ids:
|
||||
return set()
|
||||
events = list(
|
||||
self.db.scalars(
|
||||
select(BusinessEvent)
|
||||
.where(
|
||||
BusinessEvent.tenant_id == tenant_id,
|
||||
BusinessEvent.expense_case_id.in_(case_ids),
|
||||
BusinessEvent.event_type.in_(MEMORY_OUTCOME_EVENT_TYPES),
|
||||
)
|
||||
.order_by(
|
||||
BusinessEvent.occurred_at.asc(),
|
||||
case(
|
||||
(BusinessEvent.event_type == "application_returned", 1),
|
||||
else_=0,
|
||||
).asc(),
|
||||
BusinessEvent.id.asc(),
|
||||
)
|
||||
).all()
|
||||
)
|
||||
latest_by_case: dict[str, BusinessEvent] = {}
|
||||
for event in events:
|
||||
latest_by_case[event.expense_case_id] = event
|
||||
return {
|
||||
case_id
|
||||
for case_id, event in latest_by_case.items()
|
||||
if event.event_type == "application_approved"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_eligible_feedback(
|
||||
feedback: AIDecisionFeedback,
|
||||
outcome: WorkflowOutcome,
|
||||
) -> bool:
|
||||
return (
|
||||
feedback.verification_status in {"server_verified", "human_verified"}
|
||||
and feedback.feedback_type == "edited"
|
||||
and feedback.action_type == "submit"
|
||||
and outcome.outcome_type == "application_submitted"
|
||||
and outcome.outcome_status in {"recorded", "verified"}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _changed_transport_mode(feedback: AIDecisionFeedback) -> bool:
|
||||
return any(
|
||||
isinstance(item, dict) and item.get("field_key") == MEMORY_FIELD_KEY
|
||||
for item in list(feedback.changed_fields_json or [])
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _scope_id(current_user: CurrentUserContext) -> str:
|
||||
value = str(current_user.employee_id or current_user.username or "").strip()[:120]
|
||||
if not value:
|
||||
raise ValueError("当前登录用户缺少可用于个人记忆的主体标识。")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _normalize_transport_value(value: object) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
return normalized if normalized in SUPPORTED_TRANSPORT_VALUES else ""
|
||||
|
||||
@staticmethod
|
||||
def _value_fingerprint(value: str) -> str:
|
||||
return hmac_fingerprint({"field_key": MEMORY_FIELD_KEY, "value": value})
|
||||
|
||||
@staticmethod
|
||||
def _entry_value(entry: MemoryEntry) -> str:
|
||||
value_json = entry.value_json if isinstance(entry.value_json, dict) else {}
|
||||
return ExpenseApplicationMemoryService._normalize_transport_value(
|
||||
value_json.get("value")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_expired(entry: MemoryEntry, now: datetime) -> bool:
|
||||
expires_at = (
|
||||
entry.active_expires_at
|
||||
if entry.status == "active"
|
||||
else entry.candidate_expires_at
|
||||
)
|
||||
if expires_at is None:
|
||||
return False
|
||||
normalized = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=UTC)
|
||||
return normalized <= now
|
||||
|
||||
@staticmethod
|
||||
def _build_application(
|
||||
entry: MemoryEntry,
|
||||
value: str,
|
||||
) -> ExpenseApplicationMemoryApplication:
|
||||
return ExpenseApplicationMemoryApplication(
|
||||
memory_id=entry.id,
|
||||
value=value,
|
||||
evidence_count=int(entry.evidence_count or 0),
|
||||
approved_evidence_count=int(entry.approved_evidence_count or 0),
|
||||
confidence=float(entry.confidence or 0),
|
||||
expires_at=entry.active_expires_at,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_receipt(
|
||||
entry: MemoryEntry,
|
||||
evidence: MemoryEvidenceLink,
|
||||
*,
|
||||
activated: bool,
|
||||
) -> ExpenseApplicationLearningReceipt:
|
||||
external_status = "applied" if entry.status == "active" else entry.status
|
||||
if external_status == "applied":
|
||||
message = "已形成常用出行方式记忆,后续申请可自动预填。"
|
||||
else:
|
||||
remaining = max(0, MEMORY_ACTIVATION_THRESHOLD - int(entry.evidence_count or 0))
|
||||
message = (
|
||||
f"已记录本次出行方式纠正,再积累 {remaining} 个不同申请证据后可参与预填。"
|
||||
if remaining
|
||||
else "已记录本次出行方式纠正,待审批通过证据满足后可参与预填。"
|
||||
)
|
||||
return ExpenseApplicationLearningReceipt(
|
||||
memory_id=entry.id,
|
||||
evidence_id=evidence.id,
|
||||
value=ExpenseApplicationMemoryService._entry_value(entry),
|
||||
status=external_status,
|
||||
evidence_count=int(entry.evidence_count or 0),
|
||||
approved_evidence_count=int(entry.approved_evidence_count or 0),
|
||||
activated=activated,
|
||||
message=message,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _serialize_entry(entry: MemoryEntry) -> ExpenseApplicationMemoryRead:
|
||||
value = (
|
||||
""
|
||||
if entry.status == "revoked"
|
||||
else ExpenseApplicationMemoryService._entry_value(entry)
|
||||
)
|
||||
return ExpenseApplicationMemoryRead(
|
||||
id=entry.id,
|
||||
scene=entry.scene,
|
||||
field_key=entry.field_key,
|
||||
value=value,
|
||||
status=entry.status,
|
||||
evidence_count=int(entry.evidence_count or 0),
|
||||
approved_evidence_count=int(entry.approved_evidence_count or 0),
|
||||
confidence=float(entry.confidence or 0),
|
||||
activation_threshold=MEMORY_ACTIVATION_THRESHOLD,
|
||||
policy_version=MEMORY_POLICY_VERSION,
|
||||
valid_from=entry.activated_at or entry.created_at,
|
||||
expires_at=(
|
||||
entry.active_expires_at
|
||||
if entry.status == "active"
|
||||
else entry.candidate_expires_at
|
||||
),
|
||||
last_evidence_at=entry.last_evidence_at,
|
||||
activated_at=entry.activated_at,
|
||||
suppressed_at=entry.suppressed_at,
|
||||
revoked_at=entry.revoked_at,
|
||||
revoked_reason=str(entry.revoked_reason or ""),
|
||||
created_at=entry.created_at,
|
||||
updated_at=entry.updated_at,
|
||||
)
|
||||
157
server/src/app/services/expense_application_memory_evidence.py
Normal file
157
server/src/app/services/expense_application_memory_evidence.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.ai_memory import MemoryEvidenceLink
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
|
||||
|
||||
class ExpenseApplicationMemoryEvidenceValidator:
|
||||
"""校验个人记忆证据链及其租户、操作人和费用主体。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def validate(
|
||||
self,
|
||||
*,
|
||||
current_user: CurrentUserContext,
|
||||
decision: AIDecision,
|
||||
feedback: AIDecisionFeedback,
|
||||
outcome: WorkflowOutcome,
|
||||
claim: ExpenseClaim,
|
||||
) -> None:
|
||||
self._validate_record_links(
|
||||
decision=decision,
|
||||
feedback=feedback,
|
||||
outcome=outcome,
|
||||
claim=claim,
|
||||
)
|
||||
allowed_actor_ids = self._allowed_actor_ids(current_user)
|
||||
evidence_actor_ids = {
|
||||
str(feedback.actor_id or "").strip().casefold(),
|
||||
str(outcome.actor_id or "").strip().casefold(),
|
||||
}
|
||||
if not allowed_actor_ids or not evidence_actor_ids.issubset(allowed_actor_ids):
|
||||
raise PermissionError("个人记忆证据的操作人与当前登录人不一致。")
|
||||
|
||||
business_event = self.db.scalar(
|
||||
select(BusinessEvent).where(
|
||||
BusinessEvent.tenant_id == decision.tenant_id,
|
||||
BusinessEvent.expense_case_id == decision.expense_case_id,
|
||||
BusinessEvent.id == decision.business_event_id,
|
||||
)
|
||||
)
|
||||
if business_event is None:
|
||||
raise ValueError("个人记忆证据缺少可信的提交业务事件。")
|
||||
if (
|
||||
business_event.event_type != "application_submitted"
|
||||
or business_event.aggregate_type != "expense_claim"
|
||||
or business_event.aggregate_id != str(claim.id)
|
||||
):
|
||||
raise ValueError("个人记忆证据关联的提交业务事件语义不一致。")
|
||||
if str(business_event.actor_id or "").strip().casefold() not in allowed_actor_ids:
|
||||
raise PermissionError("个人记忆证据的提交人与当前登录人不一致。")
|
||||
|
||||
owner_employee_id = str(
|
||||
self.db.scalar(
|
||||
select(ExpenseCase.owner_employee_id).where(
|
||||
ExpenseCase.tenant_id == decision.tenant_id,
|
||||
ExpenseCase.id == decision.expense_case_id,
|
||||
)
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
current_employee_id = str(current_user.employee_id or "").strip()
|
||||
if (
|
||||
owner_employee_id
|
||||
and current_employee_id
|
||||
and owner_employee_id != current_employee_id
|
||||
):
|
||||
raise PermissionError("个人记忆证据的费用 Case 不属于当前员工。")
|
||||
claim_employee_id = str(claim.employee_id or "").strip()
|
||||
if (
|
||||
claim_employee_id
|
||||
and current_employee_id
|
||||
and claim_employee_id != current_employee_id
|
||||
):
|
||||
raise PermissionError("个人记忆证据的申请单不属于当前员工。")
|
||||
|
||||
def list_currently_valid_links(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
memory_entry_id: str,
|
||||
) -> list[MemoryEvidenceLink]:
|
||||
"""只返回尚未失效、反转或降级的可信提交纠正证据。"""
|
||||
|
||||
return list(
|
||||
self.db.scalars(
|
||||
select(MemoryEvidenceLink)
|
||||
.join(
|
||||
AIDecisionFeedback,
|
||||
and_(
|
||||
AIDecisionFeedback.tenant_id
|
||||
== MemoryEvidenceLink.tenant_id,
|
||||
AIDecisionFeedback.id == MemoryEvidenceLink.feedback_id,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
WorkflowOutcome,
|
||||
and_(
|
||||
WorkflowOutcome.tenant_id == MemoryEvidenceLink.tenant_id,
|
||||
WorkflowOutcome.id == MemoryEvidenceLink.outcome_id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
MemoryEvidenceLink.tenant_id == tenant_id,
|
||||
MemoryEvidenceLink.memory_entry_id == memory_entry_id,
|
||||
AIDecisionFeedback.verification_status.in_(
|
||||
["server_verified", "human_verified"]
|
||||
),
|
||||
AIDecisionFeedback.feedback_type == "edited",
|
||||
AIDecisionFeedback.action_type == "submit",
|
||||
WorkflowOutcome.outcome_type == "application_submitted",
|
||||
WorkflowOutcome.outcome_status.in_(["recorded", "verified"]),
|
||||
)
|
||||
.order_by(MemoryEvidenceLink.created_at.asc())
|
||||
).all()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_record_links(
|
||||
*,
|
||||
decision: AIDecision,
|
||||
feedback: AIDecisionFeedback,
|
||||
outcome: WorkflowOutcome,
|
||||
claim: ExpenseClaim,
|
||||
) -> None:
|
||||
claim_ids = {
|
||||
str(claim.id or ""),
|
||||
str(decision.expense_claim_id or ""),
|
||||
str(feedback.expense_claim_id or ""),
|
||||
str(outcome.expense_claim_id or ""),
|
||||
}
|
||||
if len(claim_ids) != 1:
|
||||
raise ValueError("个人记忆证据关联的申请单不一致。")
|
||||
if feedback.decision_id != decision.id or outcome.decision_id != decision.id:
|
||||
raise ValueError("个人记忆证据关联的 AI 决策不一致。")
|
||||
if outcome.expense_case_id != decision.expense_case_id:
|
||||
raise ValueError("个人记忆证据关联的费用 Case 不一致。")
|
||||
if outcome.business_event_id != decision.business_event_id:
|
||||
raise ValueError("个人记忆证据关联的业务事件不一致。")
|
||||
|
||||
@staticmethod
|
||||
def _allowed_actor_ids(current_user: CurrentUserContext) -> set[str]:
|
||||
return {
|
||||
value.casefold()
|
||||
for value in (
|
||||
str(current_user.username or "").strip(),
|
||||
str(current_user.employee_id or "").strip(),
|
||||
)
|
||||
if value
|
||||
}
|
||||
@@ -16,6 +16,10 @@ from app.schemas.reimbursement import (
|
||||
ExpenseApplicationPreviewActionResult,
|
||||
)
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.application_system_estimate import (
|
||||
apply_application_system_estimate_to_facts,
|
||||
)
|
||||
from app.services.expense_application_memory import ExpenseApplicationMemoryService
|
||||
from app.services.expense_application_preview_decisions import (
|
||||
ExpenseApplicationPreviewDecisionService,
|
||||
PreviewDecisionConflictError,
|
||||
@@ -53,6 +57,12 @@ class ExpenseApplicationPreviewWorkflow:
|
||||
)
|
||||
try:
|
||||
facts = UserAgentService(self.db)._resolve_expense_application_facts(request)
|
||||
memory_applications = ExpenseApplicationMemoryService(
|
||||
self.db
|
||||
).apply_active_transport_memory(facts, current_user)
|
||||
if memory_applications:
|
||||
# 交通方式会影响系统预估;记忆补空后必须在签名前重算派生字段。
|
||||
apply_application_system_estimate_to_facts(facts)
|
||||
issued = ExpenseApplicationPreviewDecisionService(self.db).issue(
|
||||
facts,
|
||||
current_user,
|
||||
@@ -71,6 +81,9 @@ class ExpenseApplicationPreviewWorkflow:
|
||||
expires_at=issued.decision.expires_at,
|
||||
application_preview={
|
||||
"fields": issued.fields,
|
||||
"memoryApplications": [
|
||||
item.model_dump(mode="json") for item in memory_applications
|
||||
],
|
||||
"decisionId": issued.decision.id,
|
||||
"decisionSource": issued.decision.decision_source,
|
||||
"decisionExpiresAt": issued.decision.expires_at.isoformat(),
|
||||
@@ -154,6 +167,11 @@ class ExpenseApplicationPreviewWorkflow:
|
||||
type(error).__name__,
|
||||
)
|
||||
|
||||
learning_receipts = self._resolve_learning_receipts(
|
||||
consumed_preview_decision_id,
|
||||
current_user,
|
||||
)
|
||||
|
||||
return ExpenseApplicationPreviewActionResponse(
|
||||
status="succeeded",
|
||||
conversation_id=payload.conversation_id,
|
||||
@@ -179,9 +197,29 @@ class ExpenseApplicationPreviewWorkflow:
|
||||
if next_preview_decision is not None
|
||||
else None
|
||||
),
|
||||
learning_receipts=[
|
||||
item.model_dump(mode="json") for item in learning_receipts
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
def _resolve_learning_receipts(
|
||||
self,
|
||||
preview_decision_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
) -> list:
|
||||
try:
|
||||
with self.db.begin_nested():
|
||||
return ExpenseApplicationMemoryService(
|
||||
self.db
|
||||
).learning_receipts_for_preview_decision(
|
||||
preview_decision_id,
|
||||
current_user,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("个人记忆学习回执读取失败,本次申请动作保持成功。", exc_info=True)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _build_action_request(
|
||||
payload: ExpenseApplicationPreviewActionPayload,
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.schemas.ontology import OntologyParseResult
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.schemas.reimbursement import ExpenseApplicationPreviewActionPayload
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_application_memory import ExpenseApplicationMemoryService
|
||||
from app.services.expense_application_preview_decisions import PreviewDecisionConflictError
|
||||
from app.services.expense_application_preview_workflow import ExpenseApplicationPreviewWorkflow
|
||||
from app.services.expense_application_snapshot import (
|
||||
@@ -64,6 +65,9 @@ class OrchestratorExpenseApplicationWorkflow:
|
||||
requires_confirmation=False,
|
||||
)
|
||||
facts = self.user_agent_service._resolve_expense_application_facts(request)
|
||||
memory_applications = ExpenseApplicationMemoryService(
|
||||
self.db
|
||||
).apply_active_transport_memory(facts, current_user)
|
||||
step = self.user_agent_service._resolve_expense_application_step(request, facts)
|
||||
requested_action = self._resolve_requested_action(payload.message, decision_state)
|
||||
if requested_action:
|
||||
@@ -83,6 +87,7 @@ class OrchestratorExpenseApplicationWorkflow:
|
||||
facts=facts,
|
||||
decision_state=decision_state,
|
||||
current_preview=current_preview,
|
||||
memory_applications=memory_applications,
|
||||
current_user=current_user,
|
||||
conversation_id=conversation_id,
|
||||
context_json=context_json,
|
||||
@@ -106,12 +111,16 @@ class OrchestratorExpenseApplicationWorkflow:
|
||||
facts: dict[str, Any],
|
||||
decision_state: dict[str, Any],
|
||||
current_preview: dict[str, Any],
|
||||
memory_applications: list[Any],
|
||||
current_user: CurrentUserContext,
|
||||
conversation_id: str | None,
|
||||
context_json: dict[str, Any],
|
||||
) -> ExecutionOutcome:
|
||||
resolved_request = request.model_copy(
|
||||
update={"message": self._build_facts_message(facts)}
|
||||
)
|
||||
preview_response = self.user_agent_service._build_expense_application_response(
|
||||
request,
|
||||
resolved_request,
|
||||
risk_flags=[],
|
||||
)
|
||||
result = OrchestratorExecutionEngine._build_user_agent_result(
|
||||
@@ -141,6 +150,10 @@ class OrchestratorExpenseApplicationWorkflow:
|
||||
decision_id = issue_response.decision_id
|
||||
decision_source = issue_response.decision_source
|
||||
expires_at = issue_response.expires_at.isoformat()
|
||||
if memory_applications:
|
||||
issued_preview["memoryApplications"] = [
|
||||
item.model_dump(mode="json") for item in memory_applications
|
||||
]
|
||||
|
||||
context_json["application_preview_decision"] = {
|
||||
"status": "issued",
|
||||
|
||||
@@ -19,7 +19,7 @@ from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema
|
||||
|
||||
MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
|
||||
LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
|
||||
HEAD_REVISION = "20260714_0004"
|
||||
HEAD_REVISION = "20260714_0005"
|
||||
SERVER_DIR = Path(__file__).resolve().parents[1]
|
||||
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
|
||||
|
||||
@@ -132,6 +132,18 @@ def _assert_indexes(
|
||||
assert indexes.get(index_name) == expected_columns
|
||||
|
||||
|
||||
def _assert_check_constraint(
|
||||
engine: Engine,
|
||||
table_name: str,
|
||||
constraint_name: str,
|
||||
) -> None:
|
||||
constraints = {
|
||||
str(item["name"])
|
||||
for item in inspect(engine).get_check_constraints(table_name, schema="public")
|
||||
}
|
||||
assert constraint_name in constraints
|
||||
|
||||
|
||||
def _assert_cascade_foreign_key(engine: Engine, table_name: str) -> None:
|
||||
foreign_keys = inspect(engine).get_foreign_keys(table_name, schema="public")
|
||||
matching = [
|
||||
@@ -226,18 +238,47 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
"uq_ai_application_preview_decisions_issue_request",
|
||||
("tenant_id", "actor_id", "auth_session_id", "issue_request_id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"ai_decision_feedback",
|
||||
"uq_ai_decision_feedback_tenant_id",
|
||||
("tenant_id", "id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"ai_decision_feedback",
|
||||
"uq_ai_decision_feedback_tenant_idempotency",
|
||||
("tenant_id", "idempotency_key"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"workflow_outcomes",
|
||||
"uq_workflow_outcomes_tenant_id",
|
||||
("tenant_id", "id"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"workflow_outcomes",
|
||||
"uq_workflow_outcomes_tenant_idempotency",
|
||||
("tenant_id", "idempotency_key"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"memory_entries",
|
||||
"uq_memory_entries_generation",
|
||||
("tenant_id", "scope_type", "scope_id", "scene", "field_key", "generation"),
|
||||
)
|
||||
_assert_unique_constraint(
|
||||
engine,
|
||||
"memory_evidence_links",
|
||||
"uq_memory_evidence_links_entry_case",
|
||||
("tenant_id", "memory_entry_id", "expense_case_id"),
|
||||
)
|
||||
_assert_check_constraint(
|
||||
engine,
|
||||
"memory_entries",
|
||||
"ck_memory_entries_expired_fields",
|
||||
)
|
||||
|
||||
_assert_indexes(
|
||||
engine,
|
||||
@@ -332,6 +373,31 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"memory_entries",
|
||||
{
|
||||
"ix_memory_entries_scope_lookup": (
|
||||
"tenant_id",
|
||||
"scope_type",
|
||||
"scope_id",
|
||||
"scene",
|
||||
"field_key",
|
||||
"status",
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_indexes(
|
||||
engine,
|
||||
"memory_evidence_links",
|
||||
{
|
||||
"ix_memory_evidence_links_entry_time": (
|
||||
"tenant_id",
|
||||
"memory_entry_id",
|
||||
"created_at",
|
||||
),
|
||||
},
|
||||
)
|
||||
_assert_cascade_foreign_key(engine, "expense_case_links")
|
||||
_assert_cascade_foreign_key(engine, "business_events")
|
||||
_assert_composite_foreign_key(
|
||||
@@ -379,6 +445,42 @@ def _assert_head_schema(engine: Engine) -> None:
|
||||
"business_events",
|
||||
("tenant_id", "expense_case_id", "id"),
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"memory_entries",
|
||||
("tenant_id", "superseded_by_id"),
|
||||
"memory_entries",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"memory_evidence_links",
|
||||
("tenant_id", "memory_entry_id"),
|
||||
"memory_entries",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"memory_evidence_links",
|
||||
("tenant_id", "expense_case_id"),
|
||||
"expense_cases",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"memory_evidence_links",
|
||||
("tenant_id", "decision_id"),
|
||||
"ai_decisions",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"memory_evidence_links",
|
||||
("tenant_id", "feedback_id"),
|
||||
"ai_decision_feedback",
|
||||
)
|
||||
_assert_composite_foreign_key(
|
||||
engine,
|
||||
"memory_evidence_links",
|
||||
("tenant_id", "outcome_id"),
|
||||
"workflow_outcomes",
|
||||
)
|
||||
|
||||
|
||||
def _assert_runtime_cascade(engine: Engine) -> None:
|
||||
|
||||
766
server/tests/test_expense_application_memory.py
Normal file
766
server/tests/test_expense_application_memory.py
Normal file
@@ -0,0 +1,766 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from auth_helpers import install_legacy_header_auth_override
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_db
|
||||
from app.main import create_app
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
|
||||
from app.models.ai_memory import MemoryEntry, MemoryEvidenceLink
|
||||
from app.models.expense_case import BusinessEvent, ExpenseCase
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.schemas.expense_application_preview import ExpenseApplicationPreviewDecisionCreate
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
from app.schemas.orchestrator import OrchestratorRequest
|
||||
from app.schemas.reimbursement import ExpenseApplicationPreviewActionPayload
|
||||
from app.services.expense_application_memory import (
|
||||
MEMORY_CANDIDATE_TTL,
|
||||
ExpenseApplicationMemoryService,
|
||||
)
|
||||
from app.services.expense_application_preview_workflow import (
|
||||
ExpenseApplicationPreviewWorkflow,
|
||||
)
|
||||
from app.services.orchestrator_expense_application_workflow import (
|
||||
OrchestratorExpenseApplicationWorkflow,
|
||||
)
|
||||
from app.test_helpers.db import build_in_memory_session_factory
|
||||
|
||||
|
||||
def _user(
|
||||
*,
|
||||
tenant_id: str = "tenant-memory",
|
||||
employee_id: str = "employee-memory-owner",
|
||||
) -> CurrentUserContext:
|
||||
return CurrentUserContext(
|
||||
username=f"{employee_id}@example.com",
|
||||
name="记忆测试员工",
|
||||
role_codes=["user"],
|
||||
is_admin=False,
|
||||
tenant_id=tenant_id,
|
||||
employee_id=employee_id,
|
||||
employee_no="E-MEMORY-001",
|
||||
department_name="交付部",
|
||||
position="实施顾问",
|
||||
grade="P4",
|
||||
auth_session_id=f"session-{employee_id}",
|
||||
)
|
||||
|
||||
|
||||
def _seed_learning_evidence(
|
||||
db: Session,
|
||||
*,
|
||||
current_user: CurrentUserContext,
|
||||
index: int,
|
||||
transport_mode: str = "火车",
|
||||
evidence_at: datetime | None = None,
|
||||
approved: bool = False,
|
||||
expect_memory_entry: bool = True,
|
||||
) -> tuple[
|
||||
MemoryEntry | None,
|
||||
ExpenseCase,
|
||||
AIDecision,
|
||||
AIDecisionFeedback,
|
||||
WorkflowOutcome,
|
||||
]:
|
||||
tenant_id = current_user.tenant_id
|
||||
suffix = f"{tenant_id}-{current_user.employee_id}-{index}-{uuid.uuid4().hex[:6]}"
|
||||
occurred_at = evidence_at or datetime.now(UTC)
|
||||
claim = ExpenseClaim(
|
||||
id=str(uuid.uuid4()),
|
||||
claim_no=f"CLM-{suffix}"[:50],
|
||||
employee_name=current_user.name,
|
||||
department_name=current_user.department_name,
|
||||
expense_type="travel",
|
||||
reason="客户现场实施",
|
||||
location="上海",
|
||||
amount=Decimal("1800.00"),
|
||||
occurred_at=occurred_at,
|
||||
submitted_at=occurred_at,
|
||||
status="submitted",
|
||||
approval_stage="审批中",
|
||||
risk_flags_json=[],
|
||||
)
|
||||
expense_case = ExpenseCase(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
case_no=f"CASE-{suffix}"[:80],
|
||||
scene_code="travel",
|
||||
title="差旅申请",
|
||||
owner_employee_id=current_user.employee_id,
|
||||
current_stage="application",
|
||||
status="active",
|
||||
)
|
||||
submitted_event = BusinessEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
expense_case_id=expense_case.id,
|
||||
aggregate_type="expense_claim",
|
||||
aggregate_id=claim.id,
|
||||
event_type="application_submitted",
|
||||
event_version=1,
|
||||
idempotency_key=f"submitted:{suffix}"[:120],
|
||||
correlation_id=f"corr:{suffix}"[:64],
|
||||
actor_id=current_user.employee_id,
|
||||
actor_type="user",
|
||||
payload_json={},
|
||||
delivery_status="pending",
|
||||
occurred_at=occurred_at,
|
||||
)
|
||||
db.add_all([claim, expense_case, submitted_event])
|
||||
db.flush()
|
||||
|
||||
decision = AIDecision(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
expense_case_id=expense_case.id,
|
||||
business_event_id=submitted_event.id,
|
||||
expense_claim_id=claim.id,
|
||||
correlation_id=f"corr:{suffix}"[:64],
|
||||
subject_type="expense_claim",
|
||||
subject_id=claim.id,
|
||||
decision_type="expense_application_submit",
|
||||
decision_source="server_preview",
|
||||
status="edited",
|
||||
automation_mode="human_confirmed",
|
||||
confidence=Decimal("1.0000"),
|
||||
suggestion_json={},
|
||||
evidence_json={},
|
||||
version_json={},
|
||||
schema_version=1,
|
||||
training_eligible=True,
|
||||
idempotency_key=f"decision:{suffix}"[:120],
|
||||
content_fingerprint=f"sha256:{uuid.uuid4().hex}",
|
||||
)
|
||||
feedback = AIDecisionFeedback(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
decision_id=decision.id,
|
||||
expense_claim_id=claim.id,
|
||||
correlation_id=f"corr:{suffix}"[:64],
|
||||
feedback_type="edited",
|
||||
action_type="submit",
|
||||
actor_id=current_user.employee_id,
|
||||
actor_type="user",
|
||||
evidence_source="server_preview_action",
|
||||
verification_status="server_verified",
|
||||
training_eligible=True,
|
||||
final_value_json={"transport_mode": transport_mode},
|
||||
changed_fields_json=[{"field_key": "transport_mode"}],
|
||||
idempotency_key=f"feedback:{suffix}"[:120],
|
||||
content_fingerprint=f"sha256:{uuid.uuid4().hex}",
|
||||
)
|
||||
outcome = WorkflowOutcome(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
expense_case_id=expense_case.id,
|
||||
decision_id=decision.id,
|
||||
business_event_id=submitted_event.id,
|
||||
expense_claim_id=claim.id,
|
||||
correlation_id=f"corr:{suffix}"[:64],
|
||||
outcome_type="application_submitted",
|
||||
outcome_status="verified",
|
||||
actor_id=current_user.employee_id,
|
||||
actor_type="user",
|
||||
result_json={},
|
||||
idempotency_key=f"outcome:{suffix}"[:120],
|
||||
content_fingerprint=f"sha256:{uuid.uuid4().hex}",
|
||||
effective_at=occurred_at,
|
||||
)
|
||||
db.add_all([decision, feedback, outcome])
|
||||
if approved:
|
||||
db.add(
|
||||
BusinessEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
expense_case_id=expense_case.id,
|
||||
aggregate_type="expense_claim",
|
||||
aggregate_id=claim.id,
|
||||
event_type="application_approved",
|
||||
event_version=1,
|
||||
idempotency_key=f"approved:{suffix}"[:120],
|
||||
correlation_id=f"approval:{suffix}"[:64],
|
||||
actor_id="approver",
|
||||
actor_type="user",
|
||||
payload_json={},
|
||||
delivery_status="pending",
|
||||
occurred_at=occurred_at + timedelta(hours=1),
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
receipt = ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
||||
current_user=current_user,
|
||||
decision=decision,
|
||||
feedback=feedback,
|
||||
outcome=outcome,
|
||||
claim=claim,
|
||||
transport_mode=transport_mode,
|
||||
)
|
||||
if not expect_memory_entry:
|
||||
assert receipt is None
|
||||
return None, expense_case, decision, feedback, outcome
|
||||
assert receipt is not None
|
||||
evidence = db.get(MemoryEvidenceLink, receipt.evidence_id)
|
||||
assert evidence is not None
|
||||
evidence.created_at = occurred_at
|
||||
db.flush()
|
||||
entry = db.get(MemoryEntry, receipt.memory_id)
|
||||
assert entry is not None
|
||||
return entry, expense_case, decision, feedback, outcome
|
||||
|
||||
|
||||
def _seed_active_memory(
|
||||
db: Session,
|
||||
*,
|
||||
current_user: CurrentUserContext,
|
||||
transport_mode: str = "火车",
|
||||
) -> tuple[MemoryEntry, list[ExpenseCase]]:
|
||||
now = datetime.now(UTC)
|
||||
cases: list[ExpenseCase] = []
|
||||
entry: MemoryEntry | None = None
|
||||
for index, (days_ago, approved) in enumerate(((10, True), (5, True), (0, False)), 1):
|
||||
entry, expense_case, *_ = _seed_learning_evidence(
|
||||
db,
|
||||
current_user=current_user,
|
||||
index=index,
|
||||
transport_mode=transport_mode,
|
||||
evidence_at=now - timedelta(days=days_ago),
|
||||
approved=approved,
|
||||
)
|
||||
cases.append(expense_case)
|
||||
facts: dict[str, object] = {}
|
||||
applications = ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
||||
facts,
|
||||
current_user,
|
||||
)
|
||||
assert entry is not None
|
||||
assert applications and facts["transport_mode"] == transport_mode
|
||||
db.flush()
|
||||
db.refresh(entry)
|
||||
assert entry.status == "active"
|
||||
return entry, cases
|
||||
|
||||
|
||||
def _add_returned_event(
|
||||
db: Session,
|
||||
*,
|
||||
current_user: CurrentUserContext,
|
||||
expense_case: ExpenseCase,
|
||||
) -> None:
|
||||
approved_at = db.scalar(
|
||||
select(BusinessEvent.occurred_at).where(
|
||||
BusinessEvent.expense_case_id == expense_case.id,
|
||||
BusinessEvent.event_type == "application_approved",
|
||||
)
|
||||
)
|
||||
assert approved_at is not None
|
||||
db.add(
|
||||
BusinessEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=current_user.tenant_id,
|
||||
expense_case_id=expense_case.id,
|
||||
aggregate_type="expense_claim",
|
||||
aggregate_id="returned-claim",
|
||||
event_type="application_returned",
|
||||
event_version=1,
|
||||
idempotency_key=f"returned:{uuid.uuid4().hex}",
|
||||
correlation_id=f"return:{uuid.uuid4().hex}"[:64],
|
||||
actor_id="approver",
|
||||
actor_type="user",
|
||||
payload_json={},
|
||||
delivery_status="pending",
|
||||
# 同一时间戳也必须由退回事件保守覆盖批准,不能依赖随机 UUID 排序。
|
||||
occurred_at=approved_at,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
|
||||
|
||||
def test_memory_activates_after_three_cases_two_approvals_and_seven_days() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
entry, _ = _seed_active_memory(db, current_user=current_user)
|
||||
|
||||
assert entry.evidence_count == 3
|
||||
assert entry.approved_evidence_count == 2
|
||||
assert entry.active_expires_at is not None
|
||||
assert entry.activated_at is not None
|
||||
|
||||
explicit_facts = {"transport_mode": "汽车"}
|
||||
assert (
|
||||
ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
||||
explicit_facts,
|
||||
current_user,
|
||||
)
|
||||
== []
|
||||
)
|
||||
assert explicit_facts["transport_mode"] == "汽车"
|
||||
|
||||
|
||||
def test_latest_returned_event_reverses_approval_and_suppresses_active_memory() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
entry, cases = _seed_active_memory(db, current_user=current_user)
|
||||
_add_returned_event(db, current_user=current_user, expense_case=cases[1])
|
||||
|
||||
facts: dict[str, object] = {}
|
||||
assert (
|
||||
ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
||||
facts,
|
||||
current_user,
|
||||
)
|
||||
== []
|
||||
)
|
||||
db.refresh(entry)
|
||||
assert entry.approved_evidence_count == 1
|
||||
assert entry.status == "suppressed"
|
||||
assert entry.suppressed_at is not None
|
||||
assert facts == {}
|
||||
|
||||
|
||||
def test_invalidated_feedback_and_reversed_outcome_remove_active_evidence() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
entry, _ = _seed_active_memory(db, current_user=current_user)
|
||||
evidence = list(
|
||||
db.scalars(
|
||||
select(MemoryEvidenceLink)
|
||||
.where(MemoryEvidenceLink.memory_entry_id == entry.id)
|
||||
.order_by(MemoryEvidenceLink.created_at.asc())
|
||||
).all()
|
||||
)
|
||||
assert len(evidence) == 3
|
||||
invalidated_feedback = db.get(AIDecisionFeedback, evidence[0].feedback_id)
|
||||
reversed_outcome = db.get(WorkflowOutcome, evidence[1].outcome_id)
|
||||
assert invalidated_feedback is not None and reversed_outcome is not None
|
||||
invalidated_feedback.verification_status = "invalidated"
|
||||
invalidated_feedback.training_eligible = False
|
||||
reversed_outcome.outcome_status = "reversed"
|
||||
db.flush()
|
||||
|
||||
assert ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
||||
{}, current_user
|
||||
) == []
|
||||
db.refresh(entry)
|
||||
assert entry.status == "suppressed"
|
||||
assert entry.evidence_count == 1
|
||||
assert entry.approved_evidence_count == 0
|
||||
|
||||
|
||||
def test_opposite_evidence_immediately_suppresses_old_active_memory() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
old_entry, _ = _seed_active_memory(db, current_user=current_user)
|
||||
|
||||
new_entry, *_ = _seed_learning_evidence(
|
||||
db,
|
||||
current_user=current_user,
|
||||
index=4,
|
||||
transport_mode="飞机",
|
||||
)
|
||||
db.refresh(old_entry)
|
||||
assert old_entry.status == "suppressed"
|
||||
assert old_entry.suppressed_at is not None
|
||||
assert new_entry.status == "candidate"
|
||||
assert ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
||||
{}, current_user
|
||||
) == []
|
||||
|
||||
|
||||
def test_non_whitelisted_correction_suppresses_old_active_without_storing_value() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
old_entry, _ = _seed_active_memory(db, current_user=current_user)
|
||||
entry_count = len(list(db.scalars(select(MemoryEntry)).all()))
|
||||
|
||||
new_entry, *_ = _seed_learning_evidence(
|
||||
db,
|
||||
current_user=current_user,
|
||||
index=4,
|
||||
transport_mode="汽车",
|
||||
expect_memory_entry=False,
|
||||
)
|
||||
assert new_entry is None
|
||||
db.refresh(old_entry)
|
||||
assert old_entry.status == "suppressed"
|
||||
assert old_entry.suppressed_at is not None
|
||||
assert len(list(db.scalars(select(MemoryEntry)).all())) == entry_count
|
||||
assert ExpenseApplicationMemoryService(db).apply_active_transport_memory(
|
||||
{}, current_user
|
||||
) == []
|
||||
|
||||
|
||||
def test_expiry_sets_expired_at_and_replay_does_not_extend_ttl() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
entry, _, decision, feedback, outcome = _seed_learning_evidence(
|
||||
db,
|
||||
current_user=current_user,
|
||||
index=1,
|
||||
)
|
||||
original_expiry = datetime.now(UTC) + timedelta(days=30)
|
||||
entry.candidate_expires_at = original_expiry
|
||||
db.flush()
|
||||
claim = db.get(ExpenseClaim, decision.expense_claim_id)
|
||||
assert claim is not None
|
||||
|
||||
replay = ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
||||
current_user=current_user,
|
||||
decision=decision,
|
||||
feedback=feedback,
|
||||
outcome=outcome,
|
||||
claim=claim,
|
||||
transport_mode="火车",
|
||||
)
|
||||
db.refresh(entry)
|
||||
assert replay is not None
|
||||
assert entry.evidence_count == 1
|
||||
assert entry.candidate_expires_at == original_expiry.replace(tzinfo=None)
|
||||
|
||||
old_created_at = datetime.now(UTC) - MEMORY_CANDIDATE_TTL - timedelta(days=2)
|
||||
entry.created_at = old_created_at
|
||||
entry.candidate_expires_at = datetime.now(UTC) - timedelta(days=1)
|
||||
db.flush()
|
||||
memories = ExpenseApplicationMemoryService(db).list_current_user_memories(
|
||||
current_user
|
||||
)
|
||||
db.refresh(entry)
|
||||
assert memories.items[0].status == "expired"
|
||||
assert entry.status == "expired"
|
||||
assert entry.expired_at is not None
|
||||
|
||||
|
||||
def test_expired_entry_gets_new_generation_for_new_evidence() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
old_entry, *_ = _seed_learning_evidence(
|
||||
db,
|
||||
current_user=current_user,
|
||||
index=1,
|
||||
)
|
||||
old_entry.created_at = datetime.now(UTC) - MEMORY_CANDIDATE_TTL - timedelta(days=2)
|
||||
old_entry.candidate_expires_at = datetime.now(UTC) - timedelta(days=1)
|
||||
db.flush()
|
||||
|
||||
new_entry, *_ = _seed_learning_evidence(
|
||||
db,
|
||||
current_user=current_user,
|
||||
index=2,
|
||||
)
|
||||
db.refresh(old_entry)
|
||||
assert new_entry.id != old_entry.id
|
||||
assert new_entry.generation == old_entry.generation + 1
|
||||
assert new_entry.status == "candidate"
|
||||
assert old_entry.status == "expired"
|
||||
assert old_entry.expired_at is not None
|
||||
|
||||
|
||||
def test_replay_after_revoke_or_suppression_never_recreates_memory() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
revoked_entry, _, decision, feedback, outcome = _seed_learning_evidence(
|
||||
db,
|
||||
current_user=current_user,
|
||||
index=1,
|
||||
)
|
||||
claim = db.get(ExpenseClaim, decision.expense_claim_id)
|
||||
assert claim is not None
|
||||
assert ExpenseApplicationMemoryService(db).revoke_current_user_memory(
|
||||
revoked_entry.id,
|
||||
current_user,
|
||||
) is not None
|
||||
entry_count = len(list(db.scalars(select(MemoryEntry)).all()))
|
||||
|
||||
revoked_replay = ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
||||
current_user=current_user,
|
||||
decision=decision,
|
||||
feedback=feedback,
|
||||
outcome=outcome,
|
||||
claim=claim,
|
||||
transport_mode="火车",
|
||||
)
|
||||
assert revoked_replay is not None
|
||||
assert revoked_replay.status == "revoked"
|
||||
assert len(list(db.scalars(select(MemoryEntry)).all())) == entry_count
|
||||
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
suppressed_entry, _ = _seed_active_memory(db, current_user=current_user)
|
||||
evidence = db.scalar(
|
||||
select(MemoryEvidenceLink)
|
||||
.where(MemoryEvidenceLink.memory_entry_id == suppressed_entry.id)
|
||||
.order_by(MemoryEvidenceLink.created_at.asc())
|
||||
)
|
||||
assert evidence is not None
|
||||
decision = db.get(AIDecision, evidence.decision_id)
|
||||
feedback = db.get(AIDecisionFeedback, evidence.feedback_id)
|
||||
outcome = db.get(WorkflowOutcome, evidence.outcome_id)
|
||||
assert decision is not None and feedback is not None and outcome is not None
|
||||
claim = db.get(ExpenseClaim, decision.expense_claim_id)
|
||||
assert claim is not None
|
||||
_seed_learning_evidence(
|
||||
db,
|
||||
current_user=current_user,
|
||||
index=4,
|
||||
transport_mode="飞机",
|
||||
)
|
||||
db.refresh(suppressed_entry)
|
||||
assert suppressed_entry.status == "suppressed"
|
||||
entry_count = len(list(db.scalars(select(MemoryEntry)).all()))
|
||||
|
||||
suppressed_replay = ExpenseApplicationMemoryService(
|
||||
db
|
||||
).record_transport_edit_evidence(
|
||||
current_user=current_user,
|
||||
decision=decision,
|
||||
feedback=feedback,
|
||||
outcome=outcome,
|
||||
claim=claim,
|
||||
transport_mode="火车",
|
||||
)
|
||||
assert suppressed_replay is not None
|
||||
assert suppressed_replay.status == "suppressed"
|
||||
assert len(list(db.scalars(select(MemoryEntry)).all())) == entry_count
|
||||
|
||||
|
||||
def test_memory_isolated_by_tenant_and_owner_and_revoke_clears_value() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
owner = _user()
|
||||
entry, _ = _seed_active_memory(db, current_user=owner)
|
||||
service = ExpenseApplicationMemoryService(db)
|
||||
|
||||
assert service.list_current_user_memories(_user(employee_id="employee-other")).items == []
|
||||
assert service.list_current_user_memories(
|
||||
_user(tenant_id="tenant-other")
|
||||
).items == []
|
||||
assert service.revoke_current_user_memory(
|
||||
entry.id,
|
||||
_user(employee_id="employee-other"),
|
||||
) is None
|
||||
|
||||
revoked = service.revoke_current_user_memory(entry.id, owner)
|
||||
assert revoked is not None
|
||||
db.refresh(entry)
|
||||
assert entry.status == "revoked"
|
||||
assert entry.value_json == {}
|
||||
assert entry.value_fingerprint == ""
|
||||
assert service.apply_active_transport_memory({}, owner) == []
|
||||
|
||||
|
||||
def test_memory_rejects_same_tenant_evidence_from_another_owner() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
owner = _user()
|
||||
_, _, decision, feedback, outcome = _seed_learning_evidence(
|
||||
db,
|
||||
current_user=owner,
|
||||
index=1,
|
||||
)
|
||||
claim = db.get(ExpenseClaim, decision.expense_claim_id)
|
||||
assert claim is not None
|
||||
|
||||
other_owner = _user(employee_id="employee-other")
|
||||
with pytest.raises(PermissionError, match="当前登录人"):
|
||||
ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
||||
current_user=other_owner,
|
||||
decision=decision,
|
||||
feedback=feedback,
|
||||
outcome=outcome,
|
||||
claim=claim,
|
||||
transport_mode="火车",
|
||||
)
|
||||
assert (
|
||||
ExpenseApplicationMemoryService(db)
|
||||
.list_current_user_memories(other_owner)
|
||||
.items
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "invalid_value"),
|
||||
[
|
||||
("event_type", "claim_draft_updated"),
|
||||
("aggregate_type", "expense_case"),
|
||||
("aggregate_id", "another-claim"),
|
||||
],
|
||||
)
|
||||
def test_memory_rejects_non_submit_or_wrong_claim_business_event(
|
||||
field_name: str,
|
||||
invalid_value: str,
|
||||
) -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
_, _, decision, feedback, outcome = _seed_learning_evidence(
|
||||
db,
|
||||
current_user=current_user,
|
||||
index=1,
|
||||
)
|
||||
business_event = db.get(BusinessEvent, decision.business_event_id)
|
||||
claim = db.get(ExpenseClaim, decision.expense_claim_id)
|
||||
assert business_event is not None
|
||||
assert claim is not None
|
||||
setattr(business_event, field_name, invalid_value)
|
||||
db.flush()
|
||||
|
||||
with pytest.raises(ValueError, match="提交业务事件语义不一致"):
|
||||
ExpenseApplicationMemoryService(db).record_transport_edit_evidence(
|
||||
current_user=current_user,
|
||||
decision=decision,
|
||||
feedback=feedback,
|
||||
outcome=outcome,
|
||||
claim=claim,
|
||||
transport_mode="火车",
|
||||
)
|
||||
|
||||
|
||||
def test_memory_read_failure_degrades_without_mutating_facts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
service = ExpenseApplicationMemoryService(db)
|
||||
facts: dict[str, object] = {}
|
||||
|
||||
def fail_resolve(_current_user: CurrentUserContext) -> MemoryEntry | None:
|
||||
raise RuntimeError("memory unavailable")
|
||||
|
||||
monkeypatch.setattr(service, "_resolve_active_entry", fail_resolve)
|
||||
assert service.apply_active_transport_memory(facts, _user()) == []
|
||||
assert facts == {}
|
||||
|
||||
|
||||
def test_preview_and_orchestrator_apply_active_memory() -> None:
|
||||
with build_in_memory_session_factory()() as db:
|
||||
current_user = _user()
|
||||
_seed_active_memory(db, current_user=current_user)
|
||||
|
||||
preview = ExpenseApplicationPreviewWorkflow(db).issue(
|
||||
ExpenseApplicationPreviewDecisionCreate(
|
||||
message=(
|
||||
"申请时间:2026-07-20 至 2026-07-22\n"
|
||||
"地点:上海\n事由:客户现场实施\n天数:3天"
|
||||
),
|
||||
conversation_id="conversation-memory-preview",
|
||||
request_id="request-memory-preview",
|
||||
),
|
||||
current_user,
|
||||
)
|
||||
assert preview.application_preview["fields"]["transportMode"] == "火车"
|
||||
assert preview.application_preview["fields"]["amount"]
|
||||
assert preview.application_preview["fields"]["transportEstimatedAmount"]
|
||||
assert "交通" in preview.application_preview["fields"]["policyEstimate"]
|
||||
assert preview.application_preview["memoryApplications"][0]["status"] == "applied"
|
||||
|
||||
outcome = OrchestratorExpenseApplicationWorkflow(db).execute(
|
||||
payload=OrchestratorRequest(
|
||||
source="user_message",
|
||||
user_id=current_user.username,
|
||||
message=(
|
||||
"申请时间:2026-08-01 至 2026-08-03\n"
|
||||
"地点:北京\n事由:客户现场验收\n天数:3天\n申请金额:2000元"
|
||||
),
|
||||
),
|
||||
current_user=current_user,
|
||||
run_id="run-memory-orchestrator",
|
||||
conversation_id="conversation-memory-orchestrator",
|
||||
ontology=OntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="operate",
|
||||
permission=OntologyPermission(
|
||||
level="approval_required",
|
||||
allowed=True,
|
||||
reason="test",
|
||||
),
|
||||
confidence=1.0,
|
||||
run_id="run-memory-orchestrator",
|
||||
),
|
||||
context_json={},
|
||||
selected_capability_codes=[],
|
||||
)
|
||||
assert outcome is not None
|
||||
assert outcome.result["application_preview"]["fields"]["transportMode"] == "火车"
|
||||
assert outcome.result["application_preview"]["memoryApplications"][0][
|
||||
"status"
|
||||
] == "applied"
|
||||
assert "补充出行方式" not in str(outcome.result)
|
||||
|
||||
edited_fields = dict(preview.application_preview["fields"])
|
||||
edited_fields["transportMode"] = "飞机"
|
||||
action = ExpenseApplicationPreviewWorkflow(db).execute(
|
||||
ExpenseApplicationPreviewActionPayload(
|
||||
source="user_message",
|
||||
user_id=current_user.username,
|
||||
conversation_id="conversation-memory-preview",
|
||||
action_type="submit",
|
||||
decision_id=preview.decision_id,
|
||||
request_id="request-memory-submit",
|
||||
message=(
|
||||
"申请时间:2026-07-20 至 2026-07-22\n"
|
||||
"地点:上海\n事由:客户现场实施\n天数:3天\n"
|
||||
"出行方式:飞机\n申请金额:1800元\n确认提交"
|
||||
),
|
||||
context_json={
|
||||
"application_preview": {
|
||||
"modelReviewStatus": "server_registered",
|
||||
"fields": edited_fields,
|
||||
}
|
||||
},
|
||||
),
|
||||
current_user,
|
||||
)
|
||||
assert action.result.learning_receipts
|
||||
assert action.result.learning_receipts[0]["value"] == "飞机"
|
||||
assert action.result.learning_receipts[0]["status"] == "candidate"
|
||||
|
||||
|
||||
def test_memory_api_enforces_owner_and_allows_owner_revoke() -> None:
|
||||
session_factory: sessionmaker[Session] = build_in_memory_session_factory()
|
||||
with session_factory() as db:
|
||||
owner = _user()
|
||||
entry, _ = _seed_active_memory(db, current_user=owner)
|
||||
memory_id = entry.id
|
||||
db.commit()
|
||||
|
||||
app = create_app()
|
||||
install_legacy_header_auth_override(app)
|
||||
|
||||
def override_db():
|
||||
with session_factory() as db:
|
||||
yield db
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
client = TestClient(app)
|
||||
owner_headers = {
|
||||
"X-Auth-Username": owner.username,
|
||||
"X-Auth-Name": "Memory Owner",
|
||||
"X-Auth-Employee-Id": owner.employee_id,
|
||||
"X-Auth-Tenant-Id": owner.tenant_id,
|
||||
"X-Auth-Role-Codes": "user",
|
||||
}
|
||||
other_headers = {**owner_headers, "X-Auth-Employee-Id": "employee-other"}
|
||||
|
||||
assert client.get(
|
||||
"/api/v1/expense-application-memories/me",
|
||||
headers=other_headers,
|
||||
).json() == {"items": []}
|
||||
assert client.delete(
|
||||
f"/api/v1/expense-application-memories/{memory_id}",
|
||||
headers=other_headers,
|
||||
).status_code == 404
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1/expense-application-memories/{memory_id}",
|
||||
headers=owner_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
with session_factory() as db:
|
||||
revoked_entry = db.scalar(select(MemoryEntry).where(MemoryEntry.id == memory_id))
|
||||
assert revoked_entry is not None
|
||||
assert revoked_entry.status == "revoked"
|
||||
assert revoked_entry.value_json == {}
|
||||
@@ -48,7 +48,7 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"owned_table",
|
||||
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"]),
|
||||
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"]),
|
||||
)
|
||||
def test_unversioned_database_with_any_migration_owned_table_is_rejected(
|
||||
engine: Engine,
|
||||
@@ -96,6 +96,10 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"]
|
||||
- {"ai_application_preview_decisions"},
|
||||
),
|
||||
(
|
||||
"20260714_0005",
|
||||
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0005"] - {"memory_entries"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(
|
||||
|
||||
@@ -23,6 +23,8 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
|
||||
"business_events",
|
||||
"expense_case_links",
|
||||
"expense_cases",
|
||||
"memory_entries",
|
||||
"memory_evidence_links",
|
||||
"workflow_outcomes",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
.application-memory-stack {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.application-memory-panel,
|
||||
.application-learning-receipt-panel {
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--theme-primary-soft, #eaf4fa) 52%, #ffffff);
|
||||
}
|
||||
|
||||
.application-memory-panel-head,
|
||||
.application-learning-receipt-panel > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.application-memory-panel-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.application-memory-panel-head > span:last-child {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.application-memory-panel-head strong,
|
||||
.application-learning-receipt-panel > header strong {
|
||||
color: #17324a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.application-memory-panel-head small {
|
||||
color: #52677a;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.application-memory-list {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.application-memory-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
border: 1px solid #d7e4f2;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.application-memory-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.application-memory-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.application-memory-copy > strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.application-memory-copy > small,
|
||||
.application-memory-copy > em {
|
||||
color: #52677a;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.application-memory-copy > em {
|
||||
color: #708196;
|
||||
}
|
||||
|
||||
.application-memory-forget-btn {
|
||||
min-height: 44px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid rgba(185, 28, 28, 0.24);
|
||||
border-radius: 6px;
|
||||
background: #fffafa;
|
||||
color: #991b1b;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: background 180ms ease, border-color 180ms ease, color 180ms ease;
|
||||
}
|
||||
|
||||
.application-memory-forget-btn:hover:not(:disabled),
|
||||
.application-memory-forget-btn:focus-visible {
|
||||
border-color: rgba(185, 28, 28, 0.48);
|
||||
background: #fef2f2;
|
||||
color: #7f1d1d;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.application-memory-forget-btn:focus-visible {
|
||||
box-shadow: 0 0 0 3px rgba(185, 28, 28, 0.14);
|
||||
}
|
||||
|
||||
.application-memory-forget-btn:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.application-learning-receipt-panel {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.application-learning-receipt-panel > header {
|
||||
align-items: center;
|
||||
color: var(--theme-primary-active, #255b7d);
|
||||
}
|
||||
|
||||
.application-learning-receipt-panel > header i {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.application-learning-receipt {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-left: 3px solid #d6a12a;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.application-learning-receipt.is-applied {
|
||||
border-left-color: var(--theme-primary, #3a7ca5);
|
||||
}
|
||||
|
||||
.application-learning-receipt > div {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 5px 8px;
|
||||
}
|
||||
|
||||
.application-learning-receipt strong {
|
||||
color: #17324a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.application-learning-receipt span,
|
||||
.application-learning-receipt small {
|
||||
color: #52677a;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.application-memory-item {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.application-memory-forget-btn {
|
||||
justify-self: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.application-memory-forget-btn {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
94
web/src/components/travel/TravelReimbursementMemoryPanel.vue
Normal file
94
web/src/components/travel/TravelReimbursementMemoryPanel.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div v-if="appliedMemories.length || learningReceipts.length" class="application-memory-stack">
|
||||
<section
|
||||
v-if="appliedMemories.length"
|
||||
class="application-memory-panel"
|
||||
aria-label="本次申请使用的历史偏好"
|
||||
>
|
||||
<header class="application-memory-panel-head">
|
||||
<span class="application-memory-panel-icon" aria-hidden="true">
|
||||
<i class="mdi mdi-history"></i>
|
||||
</span>
|
||||
<span>
|
||||
<strong>个性化填充</strong>
|
||||
<small>以下内容来自已确认的历史偏好,您仍可直接修改上方字段。</small>
|
||||
</span>
|
||||
</header>
|
||||
<div class="application-memory-list">
|
||||
<article
|
||||
v-for="memory in appliedMemories"
|
||||
:key="`${message.id}-memory-${memory.memoryId}`"
|
||||
class="application-memory-item"
|
||||
>
|
||||
<div class="application-memory-copy">
|
||||
<span class="application-memory-status">
|
||||
<i class="mdi mdi-check-circle-outline" aria-hidden="true"></i>
|
||||
已按历史偏好填入
|
||||
</span>
|
||||
<strong>{{ memory.fieldLabel }}:{{ memory.value }}</strong>
|
||||
<small v-if="memory.message">{{ memory.message }}</small>
|
||||
<em>{{ ui.resolveApplicationMemoryEvidenceText(memory) }}</em>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="application-memory-forget-btn"
|
||||
:disabled="ui.isForgettingApplicationMemory(memory)"
|
||||
:aria-busy="ui.isForgettingApplicationMemory(memory)"
|
||||
@click.stop="ui.forgetApplicationPreviewMemory(message, memory)"
|
||||
>
|
||||
<i
|
||||
:class="ui.isForgettingApplicationMemory(memory) ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-delete-outline'"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span>{{ ui.isForgettingApplicationMemory(memory) ? '正在忘记' : '忘记此偏好' }}</span>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-if="learningReceipts.length"
|
||||
class="application-learning-receipt-panel"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label="本次申请的个性化学习结果"
|
||||
>
|
||||
<header>
|
||||
<i class="mdi mdi-lightbulb-on-outline" aria-hidden="true"></i>
|
||||
<strong>本次选择已记录</strong>
|
||||
</header>
|
||||
<article
|
||||
v-for="receipt in learningReceipts"
|
||||
:key="`${message.id}-learning-${receipt.memoryId}-${receipt.fieldKey}`"
|
||||
class="application-learning-receipt"
|
||||
:class="`is-${receipt.status}`"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ ui.resolveApplicationLearningReceiptTitle(receipt) }}</strong>
|
||||
<span>{{ receipt.fieldLabel }}:{{ receipt.value }}</span>
|
||||
</div>
|
||||
<small>{{ receipt.message || ui.resolveApplicationMemoryEvidenceText(receipt) }}</small>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
ui: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const appliedMemories = computed(() => props.ui.resolveAppliedApplicationPreviewMemories(props.message))
|
||||
const learningReceipts = computed(() => props.ui.resolveVisibleApplicationLearningReceipts(props.message))
|
||||
</script>
|
||||
|
||||
<style scoped src="../../assets/styles/components/travel-reimbursement-message-memory.css"></style>
|
||||
@@ -225,6 +225,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TravelReimbursementMemoryPanel :message="message" :ui="ui" />
|
||||
|
||||
<div
|
||||
v-if="ui.resolveApplicationPreviewMissingFields(message)?.length"
|
||||
class="application-preview-footer application-preview-footer-missing"
|
||||
@@ -686,12 +688,14 @@
|
||||
<script>
|
||||
import BudgetAssistantReport from './BudgetAssistantReport.vue'
|
||||
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
|
||||
import TravelReimbursementMemoryPanel from './TravelReimbursementMemoryPanel.vue'
|
||||
|
||||
export default {
|
||||
name: 'TravelReimbursementMessageItem',
|
||||
components: {
|
||||
BudgetAssistantReport,
|
||||
EnterpriseSelect
|
||||
EnterpriseSelect,
|
||||
TravelReimbursementMemoryPanel
|
||||
},
|
||||
props: {
|
||||
message: {
|
||||
|
||||
@@ -7,6 +7,62 @@ import {
|
||||
|
||||
export const AI_APPLICATION_ACTION_SAVE_DRAFT = 'ai_application_save_draft'
|
||||
export const AI_APPLICATION_ACTION_SUBMIT = 'ai_application_submit'
|
||||
const AI_APPLICATION_MEMORY_FIELD_LABELS = {
|
||||
amount: '预计金额',
|
||||
application_type: '申请类型',
|
||||
applicationType: '申请类型',
|
||||
days: '出差天数',
|
||||
location: '出差地点',
|
||||
reason: '申请事由',
|
||||
time: '出差时间',
|
||||
time_range: '出差时间',
|
||||
transport_mode: '出行方式',
|
||||
transportMode: '出行方式'
|
||||
}
|
||||
|
||||
export function normalizeAiApplicationMemory(item = {}) {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const memoryId = normalizeText(item.memoryId || item.memory_id)
|
||||
const fieldKey = normalizeText(item.fieldKey || item.field_key)
|
||||
const rawStatus = normalizeText(item.status).toLowerCase()
|
||||
const status = rawStatus === 'active' ? 'applied' : rawStatus
|
||||
if (!memoryId || !fieldKey || !status) return null
|
||||
return {
|
||||
memoryId,
|
||||
fieldKey,
|
||||
fieldLabel:
|
||||
normalizeText(item.fieldLabel || item.field_label)
|
||||
|| AI_APPLICATION_MEMORY_FIELD_LABELS[fieldKey]
|
||||
|| fieldKey,
|
||||
value: normalizeText(item.value),
|
||||
status,
|
||||
evidenceCount: Number(item.evidenceCount ?? item.evidence_count ?? 0) || 0,
|
||||
approvedEvidenceCount:
|
||||
Number(item.approvedEvidenceCount ?? item.approved_evidence_count ?? 0) || 0,
|
||||
message: normalizeText(item.message)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeAiApplicationMemories(items = []) {
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.map((item) => normalizeAiApplicationMemory(item))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function resolveAiApplicationPreviewMemories(preview = {}) {
|
||||
const items = Array.isArray(preview?.memoryApplications)
|
||||
? preview.memoryApplications
|
||||
: preview?.memory_applications
|
||||
return normalizeAiApplicationMemories(items)
|
||||
}
|
||||
|
||||
export function resolveAiApplicationLearningReceipts(payload = {}) {
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : payload
|
||||
const items = Array.isArray(result?.learning_receipts)
|
||||
? result.learning_receipts
|
||||
: result?.learningReceipts
|
||||
return normalizeAiApplicationMemories(items)
|
||||
}
|
||||
|
||||
export function createAiApplicationRequestId(prefix = 'application') {
|
||||
const normalizedPrefix = normalizeText(prefix) || 'application'
|
||||
@@ -42,6 +98,13 @@ export async function registerAiApplicationPreviewDecision({
|
||||
const serverPreview = payload?.application_preview && typeof payload.application_preview === 'object'
|
||||
? payload.application_preview
|
||||
: {}
|
||||
const memoryApplications = resolveAiApplicationPreviewMemories({
|
||||
memoryApplications:
|
||||
serverPreview.memoryApplications
|
||||
?? serverPreview.memory_applications
|
||||
?? normalizedPreview.memoryApplications
|
||||
?? normalizedPreview.memory_applications
|
||||
})
|
||||
return normalizeApplicationPreview({
|
||||
...normalizedPreview,
|
||||
...serverPreview,
|
||||
@@ -52,6 +115,7 @@ export async function registerAiApplicationPreviewDecision({
|
||||
decisionId: normalizeText(payload?.decision_id || serverPreview.decisionId),
|
||||
decisionSource: normalizeText(payload?.decision_source || serverPreview.decisionSource),
|
||||
decisionExpiresAt: normalizeText(payload?.expires_at || serverPreview.decisionExpiresAt),
|
||||
memoryApplications,
|
||||
decisionTrackingStatus: 'registered'
|
||||
})
|
||||
}
|
||||
@@ -199,3 +263,14 @@ export function runAiApplicationPreviewAction(params = {}, options = {}) {
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
export function forgetAiApplicationMemory(memoryId, options = {}) {
|
||||
const normalizedMemoryId = normalizeText(memoryId)
|
||||
if (!normalizedMemoryId) {
|
||||
return Promise.reject(new Error('缺少要忘记的偏好标识。'))
|
||||
}
|
||||
return apiRequest(`/expense-application-memories/${encodeURIComponent(normalizedMemoryId)}`, {
|
||||
method: 'DELETE',
|
||||
...options
|
||||
})
|
||||
}
|
||||
|
||||
@@ -243,7 +243,13 @@ export default {
|
||||
canRetryApplicationPreviewDecision,
|
||||
canSaveApplicationPreviewDraft,
|
||||
canSubmitApplicationPreview,
|
||||
forgetApplicationPreviewMemory,
|
||||
isForgettingApplicationMemory,
|
||||
registerApplicationPreviewDecision,
|
||||
resolveApplicationLearningReceiptTitle,
|
||||
resolveApplicationMemoryEvidenceText,
|
||||
resolveAppliedApplicationPreviewMemories,
|
||||
resolveVisibleApplicationLearningReceipts,
|
||||
retryApplicationPreviewDecision,
|
||||
runApplicationPreviewAction,
|
||||
saveApplicationPreviewDraft
|
||||
@@ -590,7 +596,7 @@ export default {
|
||||
submitComposerFromMessageHandlers = submitComposerMessageHandler
|
||||
|
||||
const { insightPanelUi, messageItemUi } = useTravelReimbursementCreateViewUi({
|
||||
ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canRetryApplicationPreviewDecision, canSaveApplicationPreviewDraft, canSubmitApplicationPreview, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, retryApplicationPreviewDecision, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveApplicationPreviewDraft, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps
|
||||
ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canRetryApplicationPreviewDecision, canSaveApplicationPreviewDraft, canSubmitApplicationPreview, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, forgetApplicationPreviewMemory, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isForgettingApplicationMemory, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationLearningReceiptTitle, resolveApplicationMemoryEvidenceText, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveAppliedApplicationPreviewMemories, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, resolveVisibleApplicationLearningReceipts, retryApplicationPreviewDecision, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveApplicationPreviewDraft, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -38,6 +38,7 @@ export function createMessage(role, text, attachments = [], extras = {}) {
|
||||
riskFlags: [],
|
||||
pendingAttachmentAssociation: null,
|
||||
applicationPreview: null,
|
||||
applicationLearningReceipts: [],
|
||||
budgetReport: null,
|
||||
stewardPlan: null,
|
||||
operationFeedback: null,
|
||||
|
||||
@@ -122,7 +122,11 @@ export function normalizeInitialConversationMessages(conversation) {
|
||||
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
|
||||
reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null,
|
||||
riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [],
|
||||
applicationPreview: item.role === 'assistant' ? result?.application_preview || null : null
|
||||
applicationPreview: item.role === 'assistant' ? result?.application_preview || null : null,
|
||||
applicationLearningReceipts:
|
||||
item.role === 'assistant' && Array.isArray(result?.learning_receipts)
|
||||
? result.learning_receipts
|
||||
: []
|
||||
})
|
||||
})
|
||||
return markResolvedSuggestedActionMessages(restoredMessages)
|
||||
@@ -176,6 +180,9 @@ export function serializeSessionMessages(messages) {
|
||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||
applicationPreview: message.applicationPreview || null,
|
||||
applicationLearningReceipts: Array.isArray(message.applicationLearningReceipts)
|
||||
? message.applicationLearningReceipts
|
||||
: [],
|
||||
pendingApplicationPreviewDecision: message.pendingApplicationPreviewDecision || null,
|
||||
applicationSubmitConfirmed: Boolean(message.applicationSubmitConfirmed),
|
||||
budgetReport: message.budgetReport || null,
|
||||
@@ -203,6 +210,7 @@ export function hasMeaningfulSessionMessages(messages) {
|
||||
|| message.queryPayload
|
||||
|| message.draftPayload
|
||||
|| message.applicationPreview
|
||||
|| (Array.isArray(message.applicationLearningReceipts) && message.applicationLearningReceipts.length)
|
||||
|| message.budgetReport
|
||||
|| message.stewardPlan
|
||||
|| message.operationFeedback
|
||||
|
||||
@@ -279,7 +279,9 @@ export function createStewardDelegationHelpers({
|
||||
}
|
||||
|
||||
function shouldPauseStewardApplicationPreview(preview = {}) {
|
||||
return resolveBlockingApplicationMissingFieldsForSteward(preview).length > 0
|
||||
const missingFields = resolveBlockingApplicationMissingFieldsForSteward(preview)
|
||||
// 仅缺出行方式时先交给认证签发端尝试应用个人记忆;其他缺失仍由 Steward 追问。
|
||||
return missingFields.some((field) => String(field || '').trim() !== '出行方式')
|
||||
}
|
||||
|
||||
function extractStewardCarryLine(text = '', label = '') {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT,
|
||||
createAiApplicationRequestId,
|
||||
forgetAiApplicationMemory,
|
||||
normalizeAiApplicationMemory,
|
||||
registerAiApplicationPreviewDecision,
|
||||
resolveAiApplicationLearningReceipts,
|
||||
resolveAiApplicationPreviewMemories,
|
||||
runAiApplicationPreviewAction
|
||||
} from '../../services/aiApplicationPreviewActions.js'
|
||||
import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js'
|
||||
@@ -11,6 +17,14 @@ const DECISION_STATUS_REGISTERED = 'registered'
|
||||
const DECISION_STATUS_REGISTERING = 'registering'
|
||||
const DECISION_STATUS_FAILED = 'registration_failed'
|
||||
const DECISION_STATUS_UNREGISTERED = 'unregistered'
|
||||
const VISIBLE_LEARNING_RECEIPT_STATUSES = new Set(['candidate', 'applied'])
|
||||
const SERVER_RESOLVABLE_MISSING_FIELDS = new Set(['出行方式'])
|
||||
const MEMORY_FIELD_ALIASES = {
|
||||
application_type: 'applicationType',
|
||||
manager_name: 'managerName',
|
||||
time_range: 'time',
|
||||
transport_mode: 'transportMode'
|
||||
}
|
||||
|
||||
function resolveRefValue(value, fallback = null) {
|
||||
if (value && typeof value === 'object' && 'value' in value) {
|
||||
@@ -36,6 +50,18 @@ function resolveDecisionSourceText(preview = {}, sourceText = '') {
|
||||
)
|
||||
}
|
||||
|
||||
function canResolveApplicationPreviewOnServer(preview = {}) {
|
||||
const missingFields = Array.isArray(preview?.missingFields) ? preview.missingFields : []
|
||||
const validationIssues = Array.isArray(preview?.validationIssues)
|
||||
? preview.validationIssues
|
||||
: []
|
||||
return Boolean(
|
||||
!validationIssues.length
|
||||
&& missingFields.length
|
||||
&& missingFields.every((field) => SERVER_RESOLVABLE_MISSING_FIELDS.has(normalizeText(field)))
|
||||
)
|
||||
}
|
||||
|
||||
function resolveApplicationDraftPayload(message = {}, draftClaimId = '', linkedRequest = null) {
|
||||
if (message?.draftPayload && typeof message.draftPayload === 'object') {
|
||||
return message.draftPayload
|
||||
@@ -59,6 +85,7 @@ export function useTravelReimbursementApplicationPreviewActions({
|
||||
draftClaimId,
|
||||
emitDraftSaved,
|
||||
entrySource = 'application',
|
||||
confirmForgetMemory = null,
|
||||
linkedRequest,
|
||||
persistSessionState = () => {},
|
||||
reviewActionBusy,
|
||||
@@ -66,6 +93,97 @@ export function useTravelReimbursementApplicationPreviewActions({
|
||||
submitting,
|
||||
toast = () => {}
|
||||
} = {}) {
|
||||
const forgettingApplicationMemoryIds = ref(new Set())
|
||||
|
||||
function resolveApplicationMemoryFieldKey(fieldKey = '') {
|
||||
const normalized = normalizeText(fieldKey)
|
||||
return MEMORY_FIELD_ALIASES[normalized] || normalized
|
||||
}
|
||||
|
||||
function resolveAppliedApplicationPreviewMemories(message) {
|
||||
const preview = normalizeApplicationPreview(message?.applicationPreview || {})
|
||||
return resolveAiApplicationPreviewMemories(preview).filter((memory) => {
|
||||
if (memory.status !== 'applied') return false
|
||||
const fieldKey = resolveApplicationMemoryFieldKey(memory.fieldKey)
|
||||
return normalizeText(preview.fields?.[fieldKey]) === normalizeText(memory.value)
|
||||
})
|
||||
}
|
||||
|
||||
function resolveVisibleApplicationLearningReceipts(message) {
|
||||
return (Array.isArray(message?.applicationLearningReceipts)
|
||||
? message.applicationLearningReceipts
|
||||
: [])
|
||||
.map((item) => normalizeAiApplicationMemory(item))
|
||||
.filter((item) => item && VISIBLE_LEARNING_RECEIPT_STATUSES.has(item.status))
|
||||
}
|
||||
|
||||
function resolveApplicationMemoryEvidenceText(memory = {}) {
|
||||
const approvedCount = Number(memory.approvedEvidenceCount || 0)
|
||||
const evidenceCount = Number(memory.evidenceCount || 0)
|
||||
if (approvedCount > 0) {
|
||||
return `${approvedCount} 次已审批记录支持${evidenceCount > approvedCount ? `,共 ${evidenceCount} 次证据` : ''}`
|
||||
}
|
||||
return evidenceCount > 0 ? `${evidenceCount} 次历史记录支持` : '来自已确认的历史记录'
|
||||
}
|
||||
|
||||
function resolveApplicationLearningReceiptTitle(receipt = {}) {
|
||||
return receipt.status === 'applied' ? '偏好已激活' : '已记录为候选偏好'
|
||||
}
|
||||
|
||||
function isForgettingApplicationMemory(memory = {}) {
|
||||
const memoryId = normalizeText(memory.memoryId || memory.memory_id)
|
||||
return Boolean(memoryId && forgettingApplicationMemoryIds.value.has(memoryId))
|
||||
}
|
||||
|
||||
async function requestForgetMemoryConfirmation(memory) {
|
||||
const label = normalizeText(memory?.fieldLabel || memory?.field_label || memory?.fieldKey)
|
||||
if (typeof confirmForgetMemory === 'function') {
|
||||
return Boolean(await confirmForgetMemory(memory))
|
||||
}
|
||||
if (typeof globalThis.confirm === 'function') {
|
||||
return globalThis.confirm(
|
||||
`忘记${label ? `“${label}”` : '此项'}偏好?当前申请中的内容会保留,后续申请不再自动套用。`
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function forgetApplicationPreviewMemory(message, memory = {}) {
|
||||
const normalizedMemory = normalizeAiApplicationMemory(memory)
|
||||
if (!message?.applicationPreview || !normalizedMemory) return false
|
||||
if (isForgettingApplicationMemory(normalizedMemory)) return false
|
||||
if (!(await requestForgetMemoryConfirmation(normalizedMemory))) return false
|
||||
|
||||
forgettingApplicationMemoryIds.value = new Set([
|
||||
...forgettingApplicationMemoryIds.value,
|
||||
normalizedMemory.memoryId
|
||||
])
|
||||
try {
|
||||
await forgetAiApplicationMemory(normalizedMemory.memoryId)
|
||||
const currentPreview = message.applicationPreview
|
||||
const nextMemories = resolveAiApplicationPreviewMemories(currentPreview).filter(
|
||||
(item) => item.memoryId !== normalizedMemory.memoryId
|
||||
)
|
||||
message.applicationPreview = {
|
||||
...currentPreview,
|
||||
memoryApplications: nextMemories
|
||||
}
|
||||
delete message.applicationPreview.memory_applications
|
||||
message.applicationLearningReceipts = resolveVisibleApplicationLearningReceipts(message)
|
||||
.filter((item) => item.memoryId !== normalizedMemory.memoryId)
|
||||
persistSessionState()
|
||||
toast('已忘记此偏好,当前申请内容保持不变。')
|
||||
return true
|
||||
} catch (error) {
|
||||
toast(resolveErrorMessage(error, '偏好撤销失败,请稍后重试。'))
|
||||
return false
|
||||
} finally {
|
||||
const nextBusyIds = new Set(forgettingApplicationMemoryIds.value)
|
||||
nextBusyIds.delete(normalizedMemory.memoryId)
|
||||
forgettingApplicationMemoryIds.value = nextBusyIds
|
||||
}
|
||||
}
|
||||
|
||||
function isInteractionBusy() {
|
||||
return Boolean(
|
||||
resolveRefValue(submitting, false)
|
||||
@@ -127,7 +245,7 @@ export function useTravelReimbursementApplicationPreviewActions({
|
||||
preserveCurrentFields = false
|
||||
} = {}) {
|
||||
const normalized = normalizeApplicationPreview(applicationPreview || {})
|
||||
if (!normalized.readyToSubmit) {
|
||||
if (!normalized.readyToSubmit && !canResolveApplicationPreviewOnServer(normalized)) {
|
||||
return normalizeApplicationPreview({
|
||||
...normalized,
|
||||
decisionId: '',
|
||||
@@ -271,6 +389,7 @@ export function useTravelReimbursementApplicationPreviewActions({
|
||||
throw error
|
||||
}
|
||||
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
|
||||
message.applicationLearningReceipts = resolveAiApplicationLearningReceipts(payload)
|
||||
const nextDraftPayload = result.draft_payload && typeof result.draft_payload === 'object'
|
||||
? result.draft_payload
|
||||
: null
|
||||
@@ -341,7 +460,13 @@ export function useTravelReimbursementApplicationPreviewActions({
|
||||
canRetryApplicationPreviewDecision,
|
||||
canSaveApplicationPreviewDraft,
|
||||
canSubmitApplicationPreview,
|
||||
forgetApplicationPreviewMemory,
|
||||
isForgettingApplicationMemory,
|
||||
registerApplicationPreviewDecision,
|
||||
resolveApplicationLearningReceiptTitle,
|
||||
resolveApplicationMemoryEvidenceText,
|
||||
resolveAppliedApplicationPreviewMemories,
|
||||
resolveVisibleApplicationLearningReceipts,
|
||||
retryApplicationPreviewDecision,
|
||||
runApplicationPreviewAction,
|
||||
saveApplicationPreviewDraft
|
||||
|
||||
@@ -32,6 +32,12 @@ export function useTravelReimbursementCreateViewUi(ctx) {
|
||||
canSubmitApplicationPreview: ctx.canSubmitApplicationPreview,
|
||||
retryApplicationPreviewDecision: ctx.retryApplicationPreviewDecision,
|
||||
saveApplicationPreviewDraft: ctx.saveApplicationPreviewDraft,
|
||||
resolveAppliedApplicationPreviewMemories: ctx.resolveAppliedApplicationPreviewMemories,
|
||||
resolveVisibleApplicationLearningReceipts: ctx.resolveVisibleApplicationLearningReceipts,
|
||||
resolveApplicationLearningReceiptTitle: ctx.resolveApplicationLearningReceiptTitle,
|
||||
resolveApplicationMemoryEvidenceText: ctx.resolveApplicationMemoryEvidenceText,
|
||||
isForgettingApplicationMemory: ctx.isForgettingApplicationMemory,
|
||||
forgetApplicationPreviewMemory: ctx.forgetApplicationPreviewMemory,
|
||||
isApplicationDraftPayload: ctx.isApplicationDraftPayload,
|
||||
resolveApplicationDraftStatusLabel: ctx.resolveApplicationDraftStatusLabel,
|
||||
buildApplicationDraftSummaryItems: ctx.buildApplicationDraftSummaryItems,
|
||||
|
||||
@@ -3,7 +3,9 @@ import assert from 'node:assert/strict'
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
AI_APPLICATION_ACTION_SUBMIT,
|
||||
forgetAiApplicationMemory,
|
||||
registerAiApplicationPreviewDecision,
|
||||
resolveAiApplicationLearningReceipts,
|
||||
runAiApplicationPreviewAction
|
||||
} from '../src/services/aiApplicationPreviewActions.js'
|
||||
|
||||
@@ -138,7 +140,16 @@ async function testRegistrationUsesServerCanonicalPreview() {
|
||||
fields: {
|
||||
reason: '服务端规范事由',
|
||||
amount: '1800元'
|
||||
}
|
||||
},
|
||||
memoryApplications: [{
|
||||
memoryId: 'memory-transport-1',
|
||||
fieldKey: 'transport_mode',
|
||||
value: '火车',
|
||||
status: 'active',
|
||||
evidenceCount: 4,
|
||||
approvedEvidenceCount: 3,
|
||||
message: '根据历史申请偏好填入'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,6 +180,62 @@ async function testRegistrationUsesServerCanonicalPreview() {
|
||||
assert.equal(preview.fields.reason, '服务端规范事由')
|
||||
assert.equal(preview.fields.location, '上海')
|
||||
assert.equal(preview.decisionTrackingStatus, 'registered')
|
||||
assert.deepEqual(preview.memoryApplications, [{
|
||||
memoryId: 'memory-transport-1',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
status: 'applied',
|
||||
evidenceCount: 4,
|
||||
approvedEvidenceCount: 3,
|
||||
message: '根据历史申请偏好填入'
|
||||
}])
|
||||
}
|
||||
|
||||
async function testLearningReceiptNormalizationAndForgetEndpoint() {
|
||||
const receipts = resolveAiApplicationLearningReceipts({
|
||||
result: {
|
||||
learning_receipts: [{
|
||||
memory_id: 'memory-reason-1',
|
||||
field_key: 'reason',
|
||||
field_label: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'candidate',
|
||||
evidence_count: 2,
|
||||
approved_evidence_count: 1,
|
||||
message: '再确认一次后可形成稳定偏好'
|
||||
}]
|
||||
}
|
||||
})
|
||||
assert.deepEqual(receipts, [{
|
||||
memoryId: 'memory-reason-1',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'candidate',
|
||||
evidenceCount: 2,
|
||||
approvedEvidenceCount: 1,
|
||||
message: '再确认一次后可形成稳定偏好'
|
||||
}])
|
||||
|
||||
let capturedUrl = ''
|
||||
let capturedOptions = null
|
||||
global.fetch = async (url, options) => {
|
||||
capturedUrl = String(url)
|
||||
capturedOptions = options
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { status: 'forgotten' }
|
||||
}
|
||||
}
|
||||
}
|
||||
await forgetAiApplicationMemory('memory/reason 1')
|
||||
assert.equal(
|
||||
capturedUrl,
|
||||
'/api/v1/expense-application-memories/memory%2Freason%201'
|
||||
)
|
||||
assert.equal(capturedOptions.method, 'DELETE')
|
||||
}
|
||||
|
||||
async function testEditDraftActionCarriesClaimAndEditableFields() {
|
||||
@@ -259,6 +326,7 @@ async function run() {
|
||||
await testSubmitActionUsesFastPreviewEndpoint()
|
||||
await testSaveDraftActionUsesFastPreviewEndpoint()
|
||||
await testRegistrationUsesServerCanonicalPreview()
|
||||
await testLearningReceiptNormalizationAndForgetEndpoint()
|
||||
await testEditDraftActionCarriesClaimAndEditableFields()
|
||||
await testApplicationActionSourceCanBeConfiguredWithoutChangingWorkbenchDefaults()
|
||||
console.log('ai-application-preview-actions tests passed')
|
||||
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
} from '../src/utils/assistantSessionScope.js'
|
||||
import { useTravelReimbursementFlow } from '../src/views/scripts/useTravelReimbursementFlow.js'
|
||||
import { useApplicationPreviewEditor } from '../src/views/scripts/useApplicationPreviewEditor.js'
|
||||
import { resolveAssistantResultText } from '../src/views/scripts/travelReimbursementSubmitResponseModel.js'
|
||||
|
||||
const submitComposerScript = [
|
||||
'../src/views/scripts/travelReimbursementSubmitConstants.js',
|
||||
@@ -83,10 +84,12 @@ const stewardServiceScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/steward.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewScript = [
|
||||
'../src/views/scripts/TravelReimbursementCreateView.js',
|
||||
'../src/views/scripts/useTravelReimbursementCreateViewState.js',
|
||||
'../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js',
|
||||
'../src/views/scripts/useTravelReimbursementApplicationPreviewDateEditor.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const messageActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementMessageActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -95,10 +98,11 @@ const suggestedActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardRuntimeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const stewardRuntimeScript = [
|
||||
'../src/views/scripts/useTravelReimbursementStewardRuntime.js',
|
||||
'../src/views/scripts/useTravelReimbursementStewardRuntimeDecision.js',
|
||||
'../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const stewardRuntimeTextModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementStewardRuntimeTextModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -131,18 +135,20 @@ const applicationMessageStyles = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/components/travel-reimbursement-message-application.css', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const conversationModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const conversationModelScript = [
|
||||
'../src/views/scripts/travelReimbursementConversationModel.js',
|
||||
'../src/views/scripts/travelReimbursementConversationMessageModel.js',
|
||||
'../src/views/scripts/travelReimbursementConversationStateModel.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const previewEditorScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useApplicationPreviewEditor.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const flowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const flowScript = [
|
||||
'../src/views/scripts/useTravelReimbursementFlow.js',
|
||||
'../src/views/scripts/travelReimbursementFlowToolModel.js',
|
||||
'../src/views/scripts/travelReimbursementFlowTiming.js'
|
||||
].map((path) => readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')).join('\n')
|
||||
const personalWorkbenchAiModeScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -151,6 +157,10 @@ const applicationPreviewFlowScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const applicationPreviewActionsScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementApplicationPreviewActions.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function createFlowHarness() {
|
||||
return useTravelReimbursementFlow({
|
||||
@@ -288,7 +298,7 @@ test('unsupported business guidance opens in assistant conversation form', () =>
|
||||
assert.equal(conversation.messages.length, 1)
|
||||
assert.equal(conversation.messages[0].role, 'assistant')
|
||||
assert.match(conversation.messages[0].content, /小财管家暂时不处理「你好」/)
|
||||
assert.equal(conversation.messages[0].assistantName, '小财管家')
|
||||
assert.equal(conversation.messages[0].message_json.assistant_name, '小财管家')
|
||||
assert.match(conversation.messages[0].content, /### 当前可继续的场景/)
|
||||
assert.equal(
|
||||
conversation.messages[0].message_json.orchestrator_payload.result.suggested_actions.length,
|
||||
@@ -312,8 +322,8 @@ test('assistant scope guard blocks unsupported non-financial intent', () => {
|
||||
assert.equal(guard.suggestedActions.length, 4)
|
||||
assert.equal(guard.blocked, true)
|
||||
assert.equal(guard.targetSessionType, '')
|
||||
assert.match(guard.text, /此意图系统不支持/)
|
||||
assert.match(guard.text, /当前系统支持的业务范围/)
|
||||
assert.match(guard.text, /没有识别到当前系统支持的财务业务意图/)
|
||||
assert.match(guard.text, /当前可继续的场景/)
|
||||
})
|
||||
|
||||
|
||||
@@ -1057,7 +1067,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(createViewScript, /const isApplicationSession = computed/)
|
||||
assert.match(createViewScript, /insightPanelCollapsed,/)
|
||||
assert.doesNotMatch(createViewScript, /if \(isApplicationSession\.value\) \{\s*return false\s*\}/)
|
||||
assert.match(createViewScript, /activeFlowSteps\.value\.length > 0/)
|
||||
assert.match(createViewScript, /getActiveFlowSteps\(\)\.length > 0/)
|
||||
assert.match(createViewScript, /useApplicationPreviewEditor/)
|
||||
assert.match(messageActionsScript, /message-bubble-application-preview/)
|
||||
assert.match(messageActionsScript, /buildApplicationPreviewFooterMessage/)
|
||||
@@ -1078,7 +1088,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /v-html="ui\.renderMarkdown\(ui\.buildApplicationPreviewFooterText\(message\)\)"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /class="application-date-editor-layer"/)
|
||||
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
|
||||
assert.doesNotMatch(messageItemTemplate, /application-preview-date-chip/)
|
||||
assert.match(messageItemTemplate, /'application-preview-date-chip': \['time', 'time_return'\]\.includes\(row\.key\) && !row\.missing/)
|
||||
assert.match(messageItemTemplate, /申请单据已生成/)
|
||||
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
|
||||
assert.match(messageItemTemplate, /报销草稿已生成/)
|
||||
@@ -1130,7 +1140,7 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(messageItemTemplate, /commitApplicationPreviewEditor/)
|
||||
assert.match(createViewScript, /resolveApplicationPreviewMissingFields/)
|
||||
assert.match(createViewScript, /function applyLinkedApplicationPreviewDateSelection/)
|
||||
assert.match(createViewScript, /onComposerDateSelection: applyLinkedApplicationPreviewDateSelection/)
|
||||
assert.match(createViewScript, /onComposerDateSelection: \(\.\.\.args\) => applyLinkedApplicationPreviewDateSelection\(\.\.\.args\)/)
|
||||
assert.match(createViewScript, /function openApplicationPreviewEditorFromUi/)
|
||||
assert.match(createViewScript, /syncComposerDateFromApplicationEditor/)
|
||||
assert.match(messageActionsScript, /function shouldShowAssistantMessageActions/)
|
||||
@@ -1149,8 +1159,9 @@ test('application session shows intent flow, persists preview, and supports inli
|
||||
assert.match(previewEditorScript, /getTodayDateValue/)
|
||||
assert.match(previewEditorScript, /buildLocalApplicationPreviewMessage/)
|
||||
assert.match(previewEditorScript, /targetRow\.editable === false/)
|
||||
assert.match(previewEditorScript, /\[editor\.fieldKey\]: nextValue/)
|
||||
assert.match(previewEditorScript, /fieldKey === 'time'\) return 'date'/)
|
||||
assert.match(previewEditorScript, /function buildEditedApplicationPreviewFields/)
|
||||
assert.match(previewEditorScript, /\[isDateField \? 'time' : editor\.fieldKey\]: nextValue/)
|
||||
assert.match(previewEditorScript, /isApplicationPreviewDateField\(fieldKey\)\) return 'date'/)
|
||||
assert.match(previewEditorScript, /commitApplicationPreviewDateEditor/)
|
||||
|
||||
assert.match(messageItemStyles, /@import "\.\/travel-reimbursement-message-application\.css";/)
|
||||
@@ -1198,7 +1209,7 @@ test('steward application missing transport blocks preview table', () => {
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*pauseForMissingFields \? null : applicationPreview/)
|
||||
assert.match(submitComposerScript, /我已经识别出这一步要先处理申请单,但现在还不能生成可提交的申请核对表/)
|
||||
assert.match(submitComposerScript, /applicationPreview:\s*normalized/)
|
||||
assert.doesNotMatch(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
assert.match(submitComposerScript, /请先告诉我您打算怎么出行:\*\*火车、飞机或轮船\*\*/)
|
||||
|
||||
assert.match(suggestedActionsScript, /payload\.applicationPreview/)
|
||||
assert.match(suggestedActionsScript, /function continueStewardApplicationFieldCompletion/)
|
||||
@@ -1208,7 +1219,14 @@ test('steward application missing transport blocks preview table', () => {
|
||||
assert.match(suggestedActionsScript, /openApplicationPreviewEditor\(targetMessage, fieldKey/)
|
||||
assert.match(suggestedActionsScript, /commitApplicationPreviewEditor\(targetMessage\)/)
|
||||
assert.match(stewardFieldCompletionScript, /transportMode:\s*'transport_mode'/)
|
||||
assert.match(stewardFieldCompletionScript, /基础规则交通费用预估表/)
|
||||
assert.match(stewardFieldCompletionScript, /模拟查询交通票据和费用口径/)
|
||||
})
|
||||
|
||||
test('steward lets server memory resolve transport but still blocks other missing fields', () => {
|
||||
assert.match(
|
||||
submitComposerScript,
|
||||
/missingFields\.some\(\(field\) => String\(field \|\| ''\)\.trim\(\) !== '出行方式'\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('steward field completion reruns application preview instead of directly rendering table', () => {
|
||||
@@ -1253,7 +1271,7 @@ test('steward field completion reruns application preview instead of directly re
|
||||
assert.match(carryText, /用户已补充:出行方式:火车/)
|
||||
assert.match(carryText, /地点:北京/)
|
||||
assert.match(carryText, /天数:3天/)
|
||||
assert.match(carryText, /请先根据已补齐字段按基础规则交通费用预估表/)
|
||||
assert.match(carryText, /请先根据已补齐字段模拟查询交通票据和费用口径/)
|
||||
|
||||
const rebuiltPreview = buildLocalApplicationPreview(carryText, { name: '曹笑竹', grade: 'P5' })
|
||||
assert.equal(rebuiltPreview.fields.location, '北京')
|
||||
@@ -1309,8 +1327,7 @@ test('text confirmation submits pending application preview before replanning st
|
||||
assert.match(suggestedActionsScript, /skipApplicationModelReview:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(suggestedActionsScript, /skipStewardSlotDecision:\s*targetSessionType === SESSION_TYPE_APPLICATION/)
|
||||
assert.match(submitComposerScript, /skipModelReview:\s*Boolean\(stewardDelegated && options\.skipApplicationModelReview\)/)
|
||||
assert.match(submitComposerScript, /const requireModelReview = shouldRequireApplicationModelReview\(rawText\)/)
|
||||
assert.match(submitComposerScript, /if \(options\.skipModelReview && !requireModelReview\) \{[\s\S]*结构化快路径/)
|
||||
assert.match(submitComposerScript, /if \(options\.skipModelReview\) \{[\s\S]*结构化快路径/)
|
||||
assert.match(submitComposerScript, /const localPauseForMissingFields = shouldPauseStewardApplicationPreview\(applicationPreview\)/)
|
||||
assert.match(submitComposerScript, /const shouldFetchSlotDecision = localPauseForMissingFields && !options\.skipStewardSlotDecision/)
|
||||
assert.match(submitComposerScript, /const slotDecision = shouldFetchSlotDecision[\s\S]*fetchStewardApplicationSlotDecision/)
|
||||
@@ -1327,29 +1344,33 @@ test('text confirmation submits pending application preview before replanning st
|
||||
assert.match(stewardRuntimeScript, /executeStewardRuntimeDecision\(decision, rawText, \{ userMessageAlreadyAdded \}\)/)
|
||||
assert.match(stewardRuntimeScript, /skipUserMessage: userMessageAlreadyAdded \|\| options\.skipUserMessage/)
|
||||
assert.match(stewardRuntimeScript, /fetchStewardRuntimeDecision\(\{[\s\S]*runtime_state: runtimeState/)
|
||||
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) \{[\s\S]*return null/)
|
||||
assert.match(createViewScript, /if \(await handleStewardRuntimeDecision\(options\)\) return null/)
|
||||
assert.match(stewardRuntimeTextModelScript, /function isApplicationSubmitConfirmationText/)
|
||||
assert.match(stewardRuntimeTextModelScript, /APPLICATION_SUBMIT_CONFIRM_TEXT_PATTERN[\s\S]*确认提交[\s\S]*提交审批/)
|
||||
assert.match(stewardRuntimeScript, /function findPendingApplicationSubmitMessage/)
|
||||
assert.match(stewardRuntimeScript, /normalizedPreview\.readyToSubmit/)
|
||||
assert.match(stewardRuntimeScript, /async function handleApplicationSubmitConfirmationText/)
|
||||
assert.match(stewardRuntimeScript, /await confirmApplicationSubmit\(\{ userText: rawText \}\)/)
|
||||
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) \{[\s\S]*return null[\s\S]*\}[\s\S]*if \(isStewardSession\.value && !options\.skipStewardPlan/)
|
||||
assert.match(createViewScript, /if \(await handleApplicationSubmitConfirmationText\(options\)\) return null[\s\S]*if \(await handleGuidedStewardPlan\(options\)\) return null/)
|
||||
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed = true/)
|
||||
assert.match(stewardRuntimeScript, /message\.applicationSubmitConfirmed[\s\S]*continue/)
|
||||
})
|
||||
|
||||
test('application submit result does not render reimbursement review followup', () => {
|
||||
assert.match(submitComposerScript, /function shouldExposeReviewPayloadForMessage\(payload, options = \{\}\)/)
|
||||
assert.match(submitComposerScript, /options\.isApplicationSubmitOperation \|\| isApplicationDraftPayload\(result\.draft_payload\)/)
|
||||
assert.match(submitComposerScript, /function buildPresentationPayload\(payload, \{ exposeReviewPayload = true \} = \{\}\)/)
|
||||
assert.match(submitComposerScript, /review_payload:\s*null/)
|
||||
assert.match(submitComposerScript, /const exposeReviewPayload = shouldExposeReviewPayloadForMessage\(payload, \{ isApplicationSubmitOperation \}\)/)
|
||||
assert.match(submitComposerScript, /const presentationPayload = buildPresentationPayload\(payload, \{ exposeReviewPayload \}\)/)
|
||||
assert.match(submitComposerScript, /const resultReviewPayload = presentationResult\.review_payload \|\| null/)
|
||||
assert.match(submitComposerScript, /suggestedActions:\s*resultSuggestedActions/)
|
||||
assert.match(submitComposerScript, /reviewPayload:\s*resultReviewPayload/)
|
||||
assert.match(submitComposerScript, /buildAgentInsight\(\s*presentationPayload,/)
|
||||
test('application submit result uses dedicated action without reimbursement review followup', () => {
|
||||
const submittedPayload = {
|
||||
result: {
|
||||
answer: '申请提交成功',
|
||||
draft_payload: {
|
||||
draft_type: 'expense_application',
|
||||
status: 'submitted'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(resolveAssistantResultText(submittedPayload, 'fallback'), '')
|
||||
assert.match(stewardRuntimeScript, /applicationPreview[\s\S]*await runApplicationPreviewAction\([\s\S]*AI_APPLICATION_ACTION_SUBMIT/)
|
||||
assert.match(applicationPreviewActionsScript, /message\.draftPayload = nextDraftPayload/)
|
||||
assert.doesNotMatch(applicationPreviewActionsScript, /message\.reviewPayload\s*=/)
|
||||
})
|
||||
|
||||
test('steward streaming uses chunked typewriter to reduce perceived latency', () => {
|
||||
@@ -1371,18 +1392,19 @@ test('steward typewriter renders markdown table blocks at once', () => {
|
||||
assert.equal(resolveStewardTypewriterNextIndex(tableChars, normalIndex), 3)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex), nextParagraphIndex)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(tableChars, tableIndex - 1), nextParagraphIndex)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(Array.from('### 核对结果'), 0), 2)
|
||||
assert.equal(resolveStewardTypewriterNextIndex(Array.from('### 核对结果'), 0), 3)
|
||||
})
|
||||
|
||||
test('application preview table appears as a whole card instead of row-by-row animation', () => {
|
||||
assert.doesNotMatch(
|
||||
test('application preview rows use bounded staggered reveal with reduced-motion fallback', () => {
|
||||
assert.match(
|
||||
messageItemStyles,
|
||||
/structured-card-reveal-enter-active\s+\.application-preview-row\s*\{[\s\S]*animation:/,
|
||||
)
|
||||
assert.doesNotMatch(
|
||||
assert.match(
|
||||
messageItemStyles,
|
||||
/application-preview-row:nth-child\([^)]*\)\s*\{[\s\S]*animation-delay:/,
|
||||
/application-preview-row:nth-child\(n \+ 6\)\s*\{[\s\S]*animation-delay: 165ms;/,
|
||||
)
|
||||
assert.match(messageItemStyles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.structured-card-reveal-enter-active \.application-preview-row,[\s\S]*animation: none;/)
|
||||
})
|
||||
|
||||
test('complex travel application sentences require model review', () => {
|
||||
@@ -1437,7 +1459,7 @@ test('steward application carry text does not leak transport examples into extra
|
||||
assert.match(carryText, /费用类型:差旅/)
|
||||
assert.doesNotMatch(carryText, /费用类型:travel/)
|
||||
assert.match(carryText, /还需要补充:出行方式/)
|
||||
assert.doesNotMatch(carryText, /请先追问上述缺失信息/)
|
||||
assert.match(carryText, /请先追问上述缺失信息/)
|
||||
assert.doesNotMatch(carryText, /请直接生成申请单核对结果/)
|
||||
assert.doesNotMatch(carryText, /入库或提交审批前/)
|
||||
assert.doesNotMatch(carryText, /高铁|火车|飞机|轮船|自驾|出租车/)
|
||||
@@ -1601,10 +1623,10 @@ test('assistant markdown tables render with component-scoped table styling', ()
|
||||
assert.match(rendered, /<th/)
|
||||
assert.match(rendered, /<td/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(\.markdown-table-wrap\) \{[\s\S]*overflow-x: auto;[\s\S]*border: 1px solid #dbe4ee;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 560px;[\s\S]*table-layout: fixed;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;[\s\S]*overflow-wrap: break-word;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:first-child\),[\s\S]*\.message-answer-markdown :deep\(td:first-child\) \{[\s\S]*width: 88px;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th:last-child\),[\s\S]*\.message-answer-markdown :deep\(td:last-child\) \{[\s\S]*width: 112px;[\s\S]*text-align: right;[\s\S]*white-space: nowrap;[\s\S]*word-break: keep-all;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(table\) \{[\s\S]*min-width: 460px;[\s\S]*border-collapse: separate;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(th\),[\s\S]*\.message-answer-markdown :deep\(td\) \{[\s\S]*padding: 8px 10px;[\s\S]*white-space: normal;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(tbody tr:nth-child\(even\) td\) \{[\s\S]*background: #fbfdff;/)
|
||||
assert.match(messageItemStyles, /\.message-answer-markdown :deep\(tbody tr:last-child td\) \{[\s\S]*border-bottom: 0;/)
|
||||
})
|
||||
|
||||
test('assistant reimbursement recognition copy renders structured markdown sections', () => {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
AI_APPLICATION_ACTION_SAVE_DRAFT,
|
||||
@@ -12,6 +14,19 @@ import {
|
||||
} from '../src/views/scripts/travelReimbursementConversationStateModel.js'
|
||||
import { useTravelReimbursementApplicationSubmitConfirm } from '../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js'
|
||||
|
||||
const messageItemTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMessageItem.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const memoryPanelTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/components/travel/TravelReimbursementMemoryPanel.vue', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const createViewUiScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewUi.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function createPreview(overrides = {}) {
|
||||
return {
|
||||
fields: {
|
||||
@@ -100,6 +115,59 @@ test('完整 preview 展示前写入稳定签发/动作 request id,并使用 c
|
||||
assert.ok(persisted.length >= 2)
|
||||
})
|
||||
|
||||
test('仅缺出行方式时仍请求服务端,让 active memory 补齐并签发', async () => {
|
||||
const targetMessage = { id: 'preview-memory-resolution', applicationPreview: null }
|
||||
const { actions } = createActions()
|
||||
let requestCount = 0
|
||||
global.fetch = async (_url, options) => {
|
||||
requestCount += 1
|
||||
const body = JSON.parse(options.body)
|
||||
assert.match(body.message, /客户现场实施/)
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
decision_id: 'decision-memory-resolution',
|
||||
decision_source: 'hybrid',
|
||||
expires_at: '2026-07-14T10:30:00Z',
|
||||
application_preview: {
|
||||
fields: {
|
||||
...createPreview().fields,
|
||||
transportMode: '火车',
|
||||
amount: '2880元'
|
||||
},
|
||||
memoryApplications: [{
|
||||
memory_id: 'memory-transport-active',
|
||||
field_key: 'transport_mode',
|
||||
value: '火车',
|
||||
status: 'applied',
|
||||
evidence_count: 3,
|
||||
approved_evidence_count: 2
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preview = await actions.registerApplicationPreviewDecision({
|
||||
applicationPreview: createPreview({
|
||||
fields: {
|
||||
...createPreview().fields,
|
||||
transportMode: ''
|
||||
}
|
||||
}),
|
||||
sourceText: '7月20日去上海做客户现场实施',
|
||||
targetMessage
|
||||
})
|
||||
|
||||
assert.equal(requestCount, 1)
|
||||
assert.equal(preview.readyToSubmit, true)
|
||||
assert.equal(preview.fields.transportMode, '火车')
|
||||
assert.equal(preview.decisionId, 'decision-memory-resolution')
|
||||
assert.equal(preview.memoryApplications[0].memoryId, 'memory-transport-active')
|
||||
})
|
||||
|
||||
test('签发失败后 fail-closed,可编辑并以相同 request id 重试签发', async () => {
|
||||
const targetMessage = { id: 'preview-2', applicationPreview: null }
|
||||
const { actions } = createActions()
|
||||
@@ -186,7 +254,17 @@ test('动作失败重试复用 action request id;保存成功更新草稿和 n
|
||||
approval_stage: '待提交'
|
||||
},
|
||||
decision_id: 'decision-assistant-next',
|
||||
decision_expires_at: '2026-07-14T11:00:00Z'
|
||||
decision_expires_at: '2026-07-14T11:00:00Z',
|
||||
learning_receipts: [{
|
||||
memoryId: 'memory-reason-candidate',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'candidate',
|
||||
evidenceCount: 2,
|
||||
approvedEvidenceCount: 1,
|
||||
message: '已记录本次修正,继续确认后可形成稳定偏好'
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -197,6 +275,7 @@ test('动作失败重试复用 action request id;保存成功更新草稿和 n
|
||||
actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, message),
|
||||
/动作服务暂不可用/
|
||||
)
|
||||
assert.equal(message.applicationLearningReceipts, undefined)
|
||||
assert.equal(message.applicationPreview.decisionActionRequestId, 'action:stable-retry')
|
||||
await actions.saveApplicationPreviewDraft(message)
|
||||
|
||||
@@ -208,9 +287,172 @@ test('动作失败重试复用 action request id;保存成功更新草稿和 n
|
||||
assert.equal(state.draftClaimId.value, 'claim-assistant-draft')
|
||||
assert.equal(message.applicationPreview.decisionId, 'decision-assistant-next')
|
||||
assert.notEqual(message.applicationPreview.decisionActionRequestId, 'action:stable-retry')
|
||||
assert.equal(message.applicationLearningReceipts.length, 1)
|
||||
assert.equal(message.applicationLearningReceipts[0].status, 'candidate')
|
||||
assert.equal(emitted[0].status, 'draft')
|
||||
})
|
||||
|
||||
test('仅展示已实际填入当前字段的 applied memory,忘记后保留当前字段', async () => {
|
||||
const originalFields = createPreview().fields
|
||||
const message = {
|
||||
id: 'preview-memory',
|
||||
applicationPreview: createPreview({
|
||||
memoryApplications: [
|
||||
{
|
||||
memoryId: 'memory-transport-applied',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
status: 'applied',
|
||||
evidenceCount: 4,
|
||||
approvedEvidenceCount: 3,
|
||||
message: '根据已审批的历史差旅申请填入'
|
||||
},
|
||||
{
|
||||
memoryId: 'memory-reason-candidate',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'candidate',
|
||||
evidenceCount: 2,
|
||||
approvedEvidenceCount: 1,
|
||||
message: '候选偏好'
|
||||
},
|
||||
{
|
||||
memoryId: 'memory-location-stale',
|
||||
fieldKey: 'location',
|
||||
fieldLabel: '出差地点',
|
||||
value: '北京',
|
||||
status: 'applied',
|
||||
evidenceCount: 3,
|
||||
approvedEvidenceCount: 2,
|
||||
message: '旧偏好值与当前字段不一致'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
let confirmCalls = 0
|
||||
let capturedUrl = ''
|
||||
const { actions, persisted, toasts } = createActions({
|
||||
confirmForgetMemory: async () => {
|
||||
confirmCalls += 1
|
||||
return true
|
||||
}
|
||||
})
|
||||
assert.deepEqual(
|
||||
actions.resolveAppliedApplicationPreviewMemories(message).map((item) => item.memoryId),
|
||||
['memory-transport-applied']
|
||||
)
|
||||
|
||||
global.fetch = async (url, options) => {
|
||||
capturedUrl = String(url)
|
||||
assert.equal(options.method, 'DELETE')
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return { status: 'forgotten' }
|
||||
}
|
||||
}
|
||||
}
|
||||
const forgotten = await actions.forgetApplicationPreviewMemory(
|
||||
message,
|
||||
message.applicationPreview.memoryApplications[0]
|
||||
)
|
||||
assert.equal(forgotten, true)
|
||||
assert.equal(confirmCalls, 1)
|
||||
assert.equal(
|
||||
capturedUrl,
|
||||
'/api/v1/expense-application-memories/memory-transport-applied'
|
||||
)
|
||||
assert.deepEqual(message.applicationPreview.fields, originalFields)
|
||||
assert.deepEqual(
|
||||
message.applicationPreview.memoryApplications.map((item) => item.memoryId),
|
||||
['memory-reason-candidate', 'memory-location-stale']
|
||||
)
|
||||
assert.ok(persisted.length >= 1)
|
||||
assert.match(toasts.at(-1), /当前申请内容保持不变/)
|
||||
})
|
||||
|
||||
test('忘记偏好失败时不移除 memory,也不改当前字段', async () => {
|
||||
const message = {
|
||||
id: 'preview-memory-failure',
|
||||
applicationPreview: createPreview({
|
||||
memoryApplications: [{
|
||||
memoryId: 'memory-failure',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'applied'
|
||||
}]
|
||||
})
|
||||
}
|
||||
const previousPreview = structuredClone(message.applicationPreview)
|
||||
const { actions, toasts } = createActions({
|
||||
confirmForgetMemory: () => true
|
||||
})
|
||||
global.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
async json() {
|
||||
return { detail: '偏好服务暂不可用' }
|
||||
}
|
||||
})
|
||||
|
||||
const forgotten = await actions.forgetApplicationPreviewMemory(
|
||||
message,
|
||||
message.applicationPreview.memoryApplications[0]
|
||||
)
|
||||
assert.equal(forgotten, false)
|
||||
assert.deepEqual(message.applicationPreview, previousPreview)
|
||||
assert.match(toasts.at(-1), /偏好服务暂不可用/)
|
||||
})
|
||||
|
||||
test('申请预览 UI 解释已应用偏好并提供可访问的忘记入口', () => {
|
||||
assert.match(messageItemTemplate, /<TravelReimbursementMemoryPanel :message="message" :ui="ui" \/>/)
|
||||
assert.match(memoryPanelTemplate, /resolveAppliedApplicationPreviewMemories\(props\.message\)/)
|
||||
assert.match(memoryPanelTemplate, /已按历史偏好填入/)
|
||||
assert.match(memoryPanelTemplate, /您仍可直接修改上方字段/)
|
||||
assert.match(memoryPanelTemplate, /忘记此偏好/)
|
||||
assert.match(memoryPanelTemplate, /:aria-busy="ui\.isForgettingApplicationMemory\(memory\)"/)
|
||||
assert.match(memoryPanelTemplate, /resolveVisibleApplicationLearningReceipts\(props\.message\)/)
|
||||
assert.match(memoryPanelTemplate, /ui\.resolveApplicationLearningReceiptTitle\(receipt\)/)
|
||||
assert.doesNotMatch(messageItemTemplate, /已按历史偏好填入/)
|
||||
assert.match(createViewUiScript, /forgetApplicationPreviewMemory: ctx\.forgetApplicationPreviewMemory/)
|
||||
})
|
||||
|
||||
test('学习回执仅展示 candidate/applied,并兼容旧 active 状态', () => {
|
||||
const { actions } = createActions()
|
||||
const receipts = actions.resolveVisibleApplicationLearningReceipts({
|
||||
applicationLearningReceipts: [
|
||||
{
|
||||
memoryId: 'memory-candidate',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'candidate'
|
||||
},
|
||||
{
|
||||
memoryId: 'memory-active-legacy',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
memoryId: 'memory-revoked',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '飞机',
|
||||
status: 'revoked'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(receipts.map((item) => item.status), ['candidate', 'applied'])
|
||||
assert.equal(actions.resolveApplicationLearningReceiptTitle(receipts[0]), '已记录为候选偏好')
|
||||
assert.equal(actions.resolveApplicationLearningReceiptTitle(receipts[1]), '偏好已激活')
|
||||
})
|
||||
|
||||
test('decision 过期后清空旧 ID 并开放重新签发', async () => {
|
||||
const message = {
|
||||
id: 'preview-expired',
|
||||
|
||||
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
normalizeInitialConversationMessages
|
||||
normalizeInitialConversationMessages,
|
||||
normalizeSnapshotMessage,
|
||||
serializeSessionMessages
|
||||
} from '../src/views/scripts/travelReimbursementConversationStateModel.js'
|
||||
|
||||
test('Orchestrator 会话恢复 assistant 的 application preview,并保留既有结果字段', () => {
|
||||
@@ -12,7 +14,14 @@ test('Orchestrator 会话恢复 assistant 的 application preview,并保留既
|
||||
applicationType: '差旅费用申请',
|
||||
location: '上海',
|
||||
reason: '客户现场实施'
|
||||
}
|
||||
},
|
||||
memoryApplications: [{
|
||||
memoryId: 'memory-session-1',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'applied'
|
||||
}]
|
||||
}
|
||||
const [message] = normalizeInitialConversationMessages({
|
||||
messages: [{
|
||||
@@ -24,6 +33,13 @@ test('Orchestrator 会话恢复 assistant 的 application preview,并保留既
|
||||
orchestrator_payload: {
|
||||
result: {
|
||||
application_preview: applicationPreview,
|
||||
learning_receipts: [{
|
||||
memoryId: 'memory-session-2',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
status: 'candidate'
|
||||
}],
|
||||
draft_payload: { claim_id: 'claim-draft-1' },
|
||||
review_payload: { summary: '复核通过' },
|
||||
risk_flags: [{ code: 'policy-limit' }]
|
||||
@@ -34,11 +50,41 @@ test('Orchestrator 会话恢复 assistant 的 application preview,并保留既
|
||||
})
|
||||
|
||||
assert.deepEqual(message.applicationPreview, applicationPreview)
|
||||
assert.equal(message.applicationLearningReceipts[0].memoryId, 'memory-session-2')
|
||||
assert.deepEqual(message.draftPayload, { claim_id: 'claim-draft-1' })
|
||||
assert.deepEqual(message.reviewPayload, { summary: '复核通过' })
|
||||
assert.deepEqual(message.riskFlags, [{ code: 'policy-limit' }])
|
||||
})
|
||||
|
||||
test('本地快照跨刷新保留记忆应用和学习回执', () => {
|
||||
const [serialized] = serializeSessionMessages([{
|
||||
id: 'assistant-memory-snapshot',
|
||||
role: 'assistant',
|
||||
text: '申请预览',
|
||||
applicationPreview: {
|
||||
fields: { reason: '客户现场实施' },
|
||||
memoryApplications: [{
|
||||
memoryId: 'memory-local-applied',
|
||||
fieldKey: 'reason',
|
||||
fieldLabel: '申请事由',
|
||||
value: '客户现场实施',
|
||||
status: 'applied'
|
||||
}]
|
||||
},
|
||||
applicationLearningReceipts: [{
|
||||
memoryId: 'memory-local-candidate',
|
||||
fieldKey: 'transport_mode',
|
||||
fieldLabel: '出行方式',
|
||||
value: '火车',
|
||||
status: 'candidate'
|
||||
}]
|
||||
}])
|
||||
const restored = normalizeSnapshotMessage(serialized)
|
||||
|
||||
assert.equal(restored.applicationPreview.memoryApplications[0].status, 'applied')
|
||||
assert.equal(restored.applicationLearningReceipts[0].status, 'candidate')
|
||||
})
|
||||
|
||||
test('user 消息不能从 payload 恢复 application preview', () => {
|
||||
const [message] = normalizeInitialConversationMessages({
|
||||
messages: [{
|
||||
|
||||
Reference in New Issue
Block a user