diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md
index 728018e..890829a 100644
--- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md
+++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md
@@ -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` 均通过。当前规则中心尚不存在交通方式禁用维度,制度冲突守卫作为后续规则扩展的前置门禁保留,不以恒真占位判断冒充已实现。
diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md
index 139f6e6..5b96f7f 100644
--- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md
+++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md
@@ -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 → 回滚闭环。
diff --git a/server/alembic/versions/20260714_0005_ai_memory.py b/server/alembic/versions/20260714_0005_ai_memory.py
new file mode 100644
index 0000000..246db3c
--- /dev/null
+++ b/server/alembic/versions/20260714_0005_ai_memory.py
@@ -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",
+ )
diff --git a/server/src/app/api/v1/endpoints/expense_application_memories.py b/server/src/app/api/v1/endpoints/expense_application_memories.py
new file mode 100644
index 0000000..c03c125
--- /dev/null
+++ b/server/src/app/api/v1/endpoints/expense_application_memories.py
@@ -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
diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py
index 31192b3..485c68c 100644
--- a/server/src/app/api/v1/router.py
+++ b/server/src/app/api/v1/router.py
@@ -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"])
diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py
index 92ded5d..cf91f0e 100644
--- a/server/src/app/db/base.py
+++ b/server/src/app/db/base.py
@@ -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",
diff --git a/server/src/app/db/migration_preflight.py b/server/src/app/db/migration_preflight.py
index b1b51b7..81ad94a 100644
--- a/server/src/app/db/migration_preflight.py
+++ b/server/src/app/db/migration_preflight.py
@@ -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")
diff --git a/server/src/app/db/schema_ownership.py b/server/src/app/db/schema_ownership.py
index 36edbdf..3fc2ef9 100644
--- a/server/src/app/db/schema_ownership.py
+++ b/server/src/app/db/schema_ownership.py
@@ -13,6 +13,8 @@ MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
"expense_cases",
"expense_case_links",
"business_events",
+ "memory_entries",
+ "memory_evidence_links",
"workflow_outcomes",
}
)
diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py
index c4bbbdb..c77e0a7 100644
--- a/server/src/app/models/__init__.py
+++ b/server/src/app/models/__init__.py
@@ -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",
diff --git a/server/src/app/models/ai_learning.py b/server/src/app/models/ai_learning.py
index 5eb2758..dc631df 100644
--- a/server/src/app/models/ai_learning.py
+++ b/server/src/app/models/ai_learning.py
@@ -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",
diff --git a/server/src/app/models/ai_memory.py b/server/src/app/models/ai_memory.py
new file mode 100644
index 0000000..dcd8388
--- /dev/null
+++ b/server/src/app/models/ai_memory.py
@@ -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(),
+ )
diff --git a/server/src/app/schemas/expense_application_memory.py b/server/src/app/schemas/expense_application_memory.py
new file mode 100644
index 0000000..dc76faa
--- /dev/null
+++ b/server/src/app/schemas/expense_application_memory.py
@@ -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
diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py
index 4a70c33..4a8ecff 100644
--- a/server/src/app/schemas/reimbursement.py
+++ b/server/src/app/schemas/reimbursement.py
@@ -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):
diff --git a/server/src/app/services/expense_application_learning.py b/server/src/app/services/expense_application_learning.py
index 3147b47..6e162e6 100644
--- a/server/src/app/services/expense_application_learning.py
+++ b/server/src/app/services/expense_application_learning.py
@@ -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,
diff --git a/server/src/app/services/expense_application_memory.py b/server/src/app/services/expense_application_memory.py
new file mode 100644
index 0000000..6350e90
--- /dev/null
+++ b/server/src/app/services/expense_application_memory.py
@@ -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,
+ )
diff --git a/server/src/app/services/expense_application_memory_evidence.py b/server/src/app/services/expense_application_memory_evidence.py
new file mode 100644
index 0000000..12adfdb
--- /dev/null
+++ b/server/src/app/services/expense_application_memory_evidence.py
@@ -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
+ }
diff --git a/server/src/app/services/expense_application_preview_workflow.py b/server/src/app/services/expense_application_preview_workflow.py
index 2385a40..a48f966 100644
--- a/server/src/app/services/expense_application_preview_workflow.py
+++ b/server/src/app/services/expense_application_preview_workflow.py
@@ -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,
diff --git a/server/src/app/services/orchestrator_expense_application_workflow.py b/server/src/app/services/orchestrator_expense_application_workflow.py
index c3c934d..70f910e 100644
--- a/server/src/app/services/orchestrator_expense_application_workflow.py
+++ b/server/src/app/services/orchestrator_expense_application_workflow.py
@@ -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",
diff --git a/server/tests/test_alembic_migrations.py b/server/tests/test_alembic_migrations.py
index 98a3dbf..f0cc988 100644
--- a/server/tests/test_alembic_migrations.py
+++ b/server/tests/test_alembic_migrations.py
@@ -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:
diff --git a/server/tests/test_expense_application_memory.py b/server/tests/test_expense_application_memory.py
new file mode 100644
index 0000000..c12f8d2
--- /dev/null
+++ b/server/tests/test_expense_application_memory.py
@@ -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 == {}
diff --git a/server/tests/test_migration_preflight.py b/server/tests/test_migration_preflight.py
index ef89de2..57df395 100644
--- a/server/tests/test_migration_preflight.py
+++ b/server/tests/test_migration_preflight.py
@@ -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(
diff --git a/server/tests/test_schema_ownership.py b/server/tests/test_schema_ownership.py
index 0c38f65..5be8ec7 100644
--- a/server/tests/test_schema_ownership.py
+++ b/server/tests/test_schema_ownership.py
@@ -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",
}
)
diff --git a/web/src/assets/styles/components/travel-reimbursement-message-memory.css b/web/src/assets/styles/components/travel-reimbursement-message-memory.css
new file mode 100644
index 0000000..bfa5be5
--- /dev/null
+++ b/web/src/assets/styles/components/travel-reimbursement-message-memory.css
@@ -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;
+ }
+}
diff --git a/web/src/components/travel/TravelReimbursementMemoryPanel.vue b/web/src/components/travel/TravelReimbursementMemoryPanel.vue
new file mode 100644
index 0000000..5d29fb7
--- /dev/null
+++ b/web/src/components/travel/TravelReimbursementMemoryPanel.vue
@@ -0,0 +1,94 @@
+
+