From 54754b55022da3ead25801962ca45fef4421049a Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Tue, 14 Jul 2026 17:16:23 +0800 Subject: [PATCH] feat(ai): add personal expense application memory --- .../CONCEPT.md | 28 +- .../TODO.md | 20 +- .../versions/20260714_0005_ai_memory.py | 298 +++++++ .../endpoints/expense_application_memories.py | 51 ++ server/src/app/api/v1/router.py | 4 + server/src/app/db/base.py | 3 + server/src/app/db/migration_preflight.py | 16 +- server/src/app/db/schema_ownership.py | 2 + server/src/app/models/__init__.py | 3 + server/src/app/models/ai_learning.py | 10 + server/src/app/models/ai_memory.py | 300 +++++++ .../app/schemas/expense_application_memory.py | 64 ++ server/src/app/schemas/reimbursement.py | 1 + .../services/expense_application_learning.py | 42 +- .../services/expense_application_memory.py | 699 ++++++++++++++++ .../expense_application_memory_evidence.py | 157 ++++ .../expense_application_preview_workflow.py | 38 + ...chestrator_expense_application_workflow.py | 15 +- server/tests/test_alembic_migrations.py | 104 ++- .../tests/test_expense_application_memory.py | 766 ++++++++++++++++++ server/tests/test_migration_preflight.py | 6 +- server/tests/test_schema_ownership.py | 2 + .../travel-reimbursement-message-memory.css | 203 +++++ .../travel/TravelReimbursementMemoryPanel.vue | 94 +++ .../travel/TravelReimbursementMessageItem.vue | 6 +- .../services/aiApplicationPreviewActions.js | 75 ++ .../scripts/TravelReimbursementCreateView.js | 8 +- ...elReimbursementConversationMessageModel.js | 1 + ...avelReimbursementConversationStateModel.js | 10 +- ...velReimbursementSubmitStewardDelegation.js | 4 +- ...lReimbursementApplicationPreviewActions.js | 127 ++- .../useTravelReimbursementCreateViewUi.js | 6 + .../ai-application-preview-actions.test.mjs | 70 +- .../expense-application-fast-preview.test.mjs | 126 +-- ...stant-application-preview-actions.test.mjs | 244 +++++- ...ment-conversation-preview-restore.test.mjs | 50 +- 36 files changed, 3579 insertions(+), 74 deletions(-) create mode 100644 server/alembic/versions/20260714_0005_ai_memory.py create mode 100644 server/src/app/api/v1/endpoints/expense_application_memories.py create mode 100644 server/src/app/models/ai_memory.py create mode 100644 server/src/app/schemas/expense_application_memory.py create mode 100644 server/src/app/services/expense_application_memory.py create mode 100644 server/src/app/services/expense_application_memory_evidence.py create mode 100644 server/tests/test_expense_application_memory.py create mode 100644 web/src/assets/styles/components/travel-reimbursement-message-memory.css create mode 100644 web/src/components/travel/TravelReimbursementMemoryPanel.vue 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 @@ + + + + + diff --git a/web/src/components/travel/TravelReimbursementMessageItem.vue b/web/src/components/travel/TravelReimbursementMessageItem.vue index 667b799..6d0a0a4 100644 --- a/web/src/components/travel/TravelReimbursementMessageItem.vue +++ b/web/src/components/travel/TravelReimbursementMessageItem.vue @@ -225,6 +225,8 @@ + +