feat(ai): issue verified application preview decisions

This commit is contained in:
caoxiaozhu
2026-07-14 14:37:53 +08:00
parent a662cfe6c3
commit 5b24630710
32 changed files with 1976 additions and 217 deletions

View File

@@ -209,6 +209,7 @@
- `expense_cases`:统一费用事件和当前阶段。 - `expense_cases`:统一费用事件和当前阶段。
- `expense_case_links`:连接申请、报销、票据、审批、付款、凭证和外部对象。 - `expense_case_links`:连接申请、报销、票据、审批、付款、凭证和外部对象。
- `business_events`append-only 业务事件账本。 - `business_events`append-only 业务事件账本。
- `ai_application_preview_decisions`:申请落单前由服务端重新规范化并短期签发的预览建议;不创建空 Expense Case不保存建议明文动作成功后一次消费。
- `ai_decisions`:结构化 AI 建议与版本证据。 - `ai_decisions`:结构化 AI 建议与版本证据。
- `ai_decision_feedback`:接受、修改、拒绝和忽略。 - `ai_decision_feedback`:接受、修改、拒绝和忽略。
- `workflow_outcomes`:退回、补件、审批、付款、审计和最终结果。 - `workflow_outcomes`:退回、补件、审批、付款、审计和最终结果。
@@ -250,6 +251,8 @@
-`ReimbursementRequest` 进入只读兼容和迁移状态,停止新增第二套业务编排。 -`ReimbursementRequest` 进入只读兼容和迁移状态,停止新增第二套业务编排。
- 现有 `risk_flags_json` 保持读取兼容,新审批、付款、归档和关系事件写入结构化表。 - 现有 `risk_flags_json` 保持读取兼容,新审批、付款、归档和关系事件写入结构化表。
- API 新字段优先追加,不在同一阶段破坏现有前端契约。 - API 新字段优先追加,不在同一阶段破坏现有前端契约。
- 从未成功签发 `decision_id` 的旧申请预览,或签发接口失败且服务端没有当前会话有效决策时,保存/提交继续走现有 `client_observed` 路径;当前会话一旦存在有效服务端决策,省略 `decision_id` 必须拒绝,不能主动降级绕过核验。兼容路径不得在用户已经编辑后把最终值反向登记成“原始建议”。
- 新预览使用 30 分钟有效的服务端 `decision_id`。保存草稿或提交成功后一次消费;保存草稿会尽力签发基于已保存事实的新 `decision_id`,供后续提交继续核验,但续签属于业务提交后的派生能力,续签失败不能把已成功保存返回为失败。相同动作请求 ID、动作和最终指纹允许安全重放其他重放拒绝。
- 迁移桥接期集中维护 migration-owned 表集合;所有 legacy bootstrap 只能创建集合之外的表。标准启动在 `alembic upgrade head` 前只读核对 revision 与自有表集合发现漂移时拒绝启动不自动猜测、stamp 或修复。 - 迁移桥接期集中维护 migration-owned 表集合;所有 legacy bootstrap 只能创建集合之外的表。标准启动在 `alembic upgrade head` 前只读核对 revision 与自有表集合发现漂移时拒绝启动不自动猜测、stamp 或修复。
- 所有表结构通过 Alembic 迁移,不继续在请求路径执行 DDL。 - 所有表结构通过 Alembic 迁移,不继续在请求路径执行 DDL。
@@ -268,6 +271,8 @@
- 已初始化系统的 Bootstrap 状态只返回脱敏信息Bootstrap 写入、系统设置、模型连通性、缓存、审计与系统日志等敏感管理面由平台管理员权限保护Vite 本地 Setup 桥在初始化完成后锁定重新配置入口。 - 已初始化系统的 Bootstrap 状态只返回脱敏信息Bootstrap 写入、系统设置、模型连通性、缓存、审计与系统日志等敏感管理面由平台管理员权限保护Vite 本地 Setup 桥在初始化完成后锁定重新配置入口。
- P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。 - P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。
- Expense Case 用户态查询只返回流程摘要Case 基础状态、关系类型、事件类型、操作人、发生时间及白名单业务载荷;不返回关联资源 ID、幂等键、correlation、causation、聚合标识或 Outbox 投递状态。有权查看入口单据的本人、当前审批人、财务和管理员可查看整 Case 的安全摘要,无权限与跨租户查询继续以 404 隐藏资源存在性。 - Expense Case 用户态查询只返回流程摘要Case 基础状态、关系类型、事件类型、操作人、发生时间及白名单业务载荷;不返回关联资源 ID、幂等键、correlation、causation、聚合标识或 Outbox 投递状态。有权查看入口单据的本人、当前审批人、财务和管理员可查看整 Case 的安全摘要,无权限与跨租户查询继续以 404 隐藏资源存在性。
- 申请预览签发与消费同时绑定 `tenant_id`、actor、Bearer 登录会话和会话 ID动作入口按这些服务端事实锁行校验不能只凭随机 UUID 授权。浏览器提供的模型来源、`finalValue`、用户和角色声明均不作为可信事实。
- 预览与学习账本中的字段值使用独立、可版本化的费用申请密钥计算 HMAC-SHA256 指纹,密钥目录和文件权限分别为 `0700``0600`;核验旧决策必须使用其签发版本,缺失版本直接拒绝,不静默生成替代密钥。字段指纹同时编码“字段是否存在”,避免新增或删除字段被误判为采纳;训练资格仍保持关闭,直至审批、付款或人工核验闭环完成。
- 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。 - 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。
- 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。 - 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。
@@ -278,6 +283,7 @@
- Qdrant/few-shot 不可用:使用 stable Prompt 和基础规则,不阻塞主流程。 - Qdrant/few-shot 不可用:使用 stable Prompt 和基础规则,不阻塞主流程。
- 外部支付/ERP/税务连接器不可用:事件进入 retryable 状态,保留人工处理入口和幂等键。 - 外部支付/ERP/税务连接器不可用:事件进入 retryable 状态,保留人工处理入口和幂等键。
- 记忆冲突或可信度不足:只展示建议,不自动应用。 - 记忆冲突或可信度不足:只展示建议,不自动应用。
- 预览签发超时或不可用:仍展示本地预览并允许编辑、保存和提交,内部标记为降级;此时只记录 `client_observed` 行为证据,不升级为服务端核验或训练样本。
- Savings 证据不足:保留机会状态,不进入已实现节省。 - Savings 证据不足:保留机会状态,不进入已实现节省。
## 算法与公式 ## 算法与公式
@@ -515,7 +521,10 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
- 2026-07-14克隆库联调将持久开发库以只读 `pg_dump` 流式恢复到一次性 tmpfs PostgreSQL 17原 40 张表、4 张费用单、105 名员工、248 条预算和 62 个 Agent 资产完整保留。升级到 `20260713_0002` 后首次 dry-run 识别 4 张旧单apply 创建 4 组 Case/Link/Event重复 apply 创建 0 条;隔离后端完成登录、`/auth/me`、旧单时间线 200 和登出,内部回填字段未出现在 API。临时数据库和隔离进程已清理持久库复查仍为原数据签名且没有 migration-owned 表。 - 2026-07-14克隆库联调将持久开发库以只读 `pg_dump` 流式恢复到一次性 tmpfs PostgreSQL 17原 40 张表、4 张费用单、105 名员工、248 条预算和 62 个 Agent 资产完整保留。升级到 `20260713_0002` 后首次 dry-run 识别 4 张旧单apply 创建 4 组 Case/Link/Event重复 apply 创建 0 条;隔离后端完成登录、`/auth/me`、旧单时间线 200 和登出,内部回填字段未出现在 API。临时数据库和隔离进程已清理持久库复查仍为原数据签名且没有 migration-owned 表。
- 2026-07-14安全复核与质量验证交叉审查后补齐超长租户幂等键稳定哈希、Link/Case 租户一致性、孤立 Case 冲突、URL 路由参数覆盖防护和部分批次失败进度摘要;前端将快照语义明确为“纳入时状态/节点”。容器内 65 项后端定向测试、11 项前端测试、Ruff 和 Vite 生产构建通过。全库代码体积门禁仍被本次未修改的 `RiskRuleGenerationService` 807 行存量问题阻断,未混入当前功能提交。 - 2026-07-14安全复核与质量验证交叉审查后补齐超长租户幂等键稳定哈希、Link/Case 租户一致性、孤立 Case 冲突、URL 路由参数覆盖防护和部分批次失败进度摘要;前端将快照语义明确为“纳入时状态/节点”。容器内 65 项后端定向测试、11 项前端测试、Ruff 和 Vite 生产构建通过。全库代码体积门禁仍被本次未修改的 `RiskRuleGenerationService` 807 行存量问题阻断,未混入当前功能提交。
- 2026-07-14AI 行为闭环首个切片):新增 `AIDecision``AIDecisionFeedback``WorkflowOutcome` 三类独立事实,把申请预填建议、用户确认或显式字段纠正、草稿保存或申请提交结果通过稳定 `decision_id` 关联。只有已认证的申请预览快速入口显式传入服务端 `CurrentUserContext` 时才写账本;通用 User Agent、模板预览和单据详情编辑路径均不写入避免伪造身份和非 AI 操作污染样本。 - 2026-07-14AI 行为闭环首个切片):新增 `AIDecision``AIDecisionFeedback``WorkflowOutcome` 三类独立事实,把申请预填建议、用户确认或显式字段纠正、草稿保存或申请提交结果通过稳定 `decision_id` 关联。只有已认证的申请预览快速入口显式传入服务端 `CurrentUserContext` 时才写账本;通用 User Agent、模板预览和单据详情编辑路径均不写入避免伪造身份和非 AI 操作污染样本。
- 2026-07-14字段纠正与隐私边界申请表编辑器在当前会话中维护首次建议值与最终显式编辑值多次修改保留最初建议改回原值会移除差异日期调整同时记录 `time` 与派生 `days`。服务端最终值始终从解析后的申请 facts 重建,不信任客户端 `finalValue`;持久化层只保存字段名和 SHA-256 值指纹不复制事由、地点、人员、金额等原始值原始对话、Prompt、模型全文、附件和支付敏感信息也不进入学习账本。 - 2026-07-14字段纠正与隐私边界申请表编辑器在当前会话中维护首次建议值与最终显式编辑值多次修改保留最初建议改回原值会移除差异日期调整同时记录 `time` 与派生 `days`。服务端最终值始终从解析后的申请 facts 重建,不信任客户端 `finalValue`;持久化层只保存字段名和带密钥版本的 HMAC-SHA256 值指纹不复制事由、地点、人员、金额等原始值原始对话、Prompt、模型全文、附件和支付敏感信息也不进入学习账本。
- 2026-07-14可信度边界浏览器编辑轨迹统一标记为 `verification_status=client_observed``trust_level=behavioral_analytics_only``training_eligible=false`,只能用于受限产品行为统计和后续人工核验,不能直接作为模型训练、审计结论、风险自动放行或企业记忆激活依据。后续需增加服务端预览登记并返回不可伪造的 `decision_id`,再实现拒绝/忽略、审批/退回/付款结果追踪、记忆候选与下次建议变化 - 2026-07-14可信度边界浏览器编辑轨迹标记为 `verification_status=client_observed``trust_level=behavioral_analytics_only``training_eligible=false`,只能用于受限产品统计;服务端签发预览消费后提升为 `server_verified`,仅证明“服务端签发快照与最终业务事实的差异已核验,不证明浏览器声称的模型来源,也仍不直接训练模型、自动放行风险或激活企业记忆
- 2026-07-14事务与一致性学习三表与申请、预算及 Business Event 使用同一 Session在最终 commit 前 `flush`,失败整体回滚;提交场景通过提交服务的事务内回调关联真实 `application_submitted` 事件。Case、Business Event 和 Decision 使用包含 `tenant_id + expense_case_id` 的复合外键,既拒绝跨租户,也拒绝同租户跨 Case 串联。前端估算使用消息级请求版本和输入指纹,只合并估算派生字段,旧响应不会覆盖后续编辑。 - 2026-07-14事务与一致性学习三表与申请、预算及 Business Event 使用同一 Session在最终 commit 前 `flush`,失败整体回滚;提交场景通过提交服务的事务内回调关联真实 `application_submitted` 事件。Case、Business Event 和 Decision 使用包含 `tenant_id + expense_case_id` 的复合外键,既拒绝跨租户,也拒绝同租户跨 Case 串联。前端估算使用消息级请求版本和输入指纹,只合并估算派生字段,旧响应不会覆盖后续编辑。
- 2026-07-140003 迁移与验证):新增 `20260714_0003_ai_learning_loop.py`三张学习账本表纳入集中迁移所有权AgentRun 与旧 ExpenseClaim 保持带索引软引用以兼容空库迁移顺序。一次性 tmpfs PostgreSQL 17 的升级、重复升级、跨租户/同租户跨 Case 外键拒绝、降级和再次升级 4 项通过;容器内学习账本、入口边界和迁移所有权定向 29 项,日期联动及异步估算关键场景 5 项通过。持久开发库只读复查仍为 `40|4|105|248|62`7 张 migration-owned 表数量为 0。 - 2026-07-140003 迁移与验证):新增 `20260714_0003_ai_learning_loop.py`三张学习账本表纳入集中迁移所有权AgentRun 与旧 ExpenseClaim 保持带索引软引用以兼容空库迁移顺序。一次性 tmpfs PostgreSQL 17 的升级、重复升级、跨租户/同租户跨 Case 外键拒绝、降级和再次升级 4 项通过;容器内学习账本、入口边界和迁移所有权定向 29 项,日期联动及异步估算关键场景 5 项通过。持久开发库只读复查仍为 `40|4|105|248|62`7 张 migration-owned 表数量为 0。
- 2026-07-14服务端预览决策新增认证的 `POST /api/v1/reimbursements/application-previews`。服务端从原始申请文本和当前登录身份重新解析字段、重跑规则测算,再签发 30 分钟有效的 `decision_id`浏览器返回的模型来源和建议值不被直接认证。预览绑定租户、actor、登录会话和 conversation字段只保存独立版本密钥生成的 HMAC 指纹;字段存在性也纳入指纹,新增、删除和改值都能由服务端识别。
- 2026-07-14消费与续签快速保存/提交显式携带动作类型、`decision_id` 和稳定请求 ID。服务端锁行校验后在 Claim、Expense Case、Business Event、AIDecision、Feedback、Outcome 的同一事务中一次消费;当前会话已有有效决策时禁止省略 ID 降级。保存草稿提交成功后再尽力签发基于服务端事实的新 `decision_id`,续签失败只返回无新 ID不反转已成功业务动作相同请求安全重放不会重复建单或重复写学习账本。
- 2026-07-140004 迁移与验证):新增 `20260714_0004_ai_application_preview_decisions.py`,预览决策表和正式 Decision 复合租户外键纳入集中迁移所有权。一次性隔离 PostgreSQL 17 完整 upgrade、重复 upgrade、约束/索引、downgrade 和再次 upgrade 4 项通过;容器内新增决策安全用例 9 项、旧快速保存/提交 4 项、申请学习账本 9 项、迁移与所有权 26 项通过且条件型 PostgreSQL 用例 1 项跳过3 组前端定向测试及 Vite 生产构建通过。持久开发库只读确认 8 张 migration-owned 表数量仍为 0。

View File

@@ -39,6 +39,8 @@
- [ ] [CONCEPT: 数据与契约] 定义 `business_events` 事件信封、事件词典、correlation ID、幂等键和版本策略。 - [ ] [CONCEPT: 数据与契约] 定义 `business_events` 事件信封、事件词典、correlation ID、幂等键和版本策略。
- [x] [CONCEPT: 业务事件与 AI 决策] 定义 `ai_decisions``ai_decision_feedback``workflow_outcomes` 契约。 - [x] [CONCEPT: 业务事件与 AI 决策] 定义 `ai_decisions``ai_decision_feedback``workflow_outcomes` 契约。
证据:`ai_learning.py``expense_application_learning.py``20260714_0003_ai_learning_loop.py`;三类事实分别表达 AI 建议、用户采纳/显式字段纠正和业务结果,技术执行成功、用户反馈与工作流结果不复用同一状态。 证据:`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、登录会话与 conversation30 分钟过期,保存/提交成功后一次消费;保存草稿在业务提交后尽力返回基于服务端事实的新 `decision_id`,续签失败不反转已成功动作。
- [ ] [CONCEPT: 记忆与学习] 定义 `memory_entries`、证据链接、优先级、有效期、敏感等级、撤销和遗忘契约。 - [ ] [CONCEPT: 记忆与学习] 定义 `memory_entries`、证据链接、优先级、有效期、敏感等级、撤销和遗忘契约。
- [ ] [CONCEPT: 自动化决策] 定义动作风险、金额阈值、置信度、证据完整度、可逆性、抽检率和企业授权策略。 - [ ] [CONCEPT: 自动化决策] 定义动作风险、金额阈值、置信度、证据完整度、可逆性、抽检率和企业授权策略。
- [ ] [CONCEPT: 节省与价值] 定义 Savings Ledger 的机会、执行、实现、确认、去重和归因状态。 - [ ] [CONCEPT: 节省与价值] 定义 Savings Ledger 的机会、执行、实现、确认、去重和归因状态。
@@ -67,6 +69,8 @@
证据:`20260713_0001_expense_case_business_events.py`;一次性 PostgreSQL 17 已验证表、唯一约束、复合索引、外键级联、降级和再次升级。 证据:`20260713_0001_expense_case_business_events.py`;一次性 PostgreSQL 17 已验证表、唯一约束、复合索引、外键级联、降级和再次升级。
- [x] [CONCEPT: 业务事件与 AI 决策] 新增 `ai_decisions``ai_decision_feedback``workflow_outcomes` 表及迁移。 - [x] [CONCEPT: 业务事件与 AI 决策] 新增 `ai_decisions``ai_decision_feedback``workflow_outcomes` 表及迁移。
证据:`20260714_0003_ai_learning_loop.py``schema_ownership.py``migration_preflight.py`三表具备租户幂等约束Case/Event/Decision 使用包含费用 Case 的复合租户外键。一次性 PostgreSQL 17 完整升级、重复升级、跨租户及同租户跨 Case 外键拒绝、降级和再次升级 4 项通过,持久开发库未迁移。 证据:`20260714_0003_ai_learning_loop.py``schema_ownership.py``migration_preflight.py`三表具备租户幂等约束Case/Event/Decision 使用包含费用 Case 的复合租户外键。一次性 PostgreSQL 17 完整升级、重复升级、跨租户及同租户跨 Case 外键拒绝、降级和再次升级 4 项通过,持久开发库未迁移。
- [x] [CONCEPT: 业务事件与 AI 决策] 新增服务端预览决策表、认证签发接口和正式 Decision 关联迁移。
证据:`20260714_0004_ai_application_preview_decisions.py``expense_application_previews.py``reimbursements.py`;动作消费与 Claim、Case、Business Event 和学习三表同事务,跨登录会话重放被拒绝;独立版本密钥生成的 HMAC 指纹不保存低熵字段明文,并编码字段存在性。只有从未成功签发的旧调用可无 ID 降级,当前会话已有有效决策时省略 ID 会返回 409。
- [ ] [CONCEPT: 数据与契约] 为申请、票据、草稿、提交、退回、审批、付款和归档接入统一 correlation ID。 - [ ] [CONCEPT: 数据与契约] 为申请、票据、草稿、提交、退回、审批、付款和归档接入统一 correlation ID。
- [ ] [CONCEPT: 业务事件与 AI 决策] 建立事务 Outbox申请、提交、退回、审批、支付和入账状态与事件同事务提交消费端按事件 ID 幂等处理。 - [ ] [CONCEPT: 业务事件与 AI 决策] 建立事务 Outbox申请、提交、退回、审批、支付和入账状态与事件同事务提交消费端按事件 ID 幂等处理。
- [x] [CONCEPT: 业务事件与 AI 决策] 完成首批草稿、提交、退回、审批、申请转报销、付款和申请归档事件旁写,具备 correlation、causation、幂等键及事务回滚。 - [x] [CONCEPT: 业务事件与 AI 决策] 完成首批草稿、提交、退回、审批、申请转报销、付款和申请归档事件旁写,具备 correlation、causation、幂等键及事务回滚。
@@ -112,7 +116,8 @@
- [ ] [CONCEPT: 记忆激活] 实现 candidate/active/suppressed/expired/revoked 记忆状态机。 - [ ] [CONCEPT: 记忆激活] 实现 candidate/active/suppressed/expired/revoked 记忆状态机。
- [ ] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。 - [ ] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。
- [x] [CONCEPT: 记忆与学习] 为 AI 申请预填记录用户原样采纳、显式字段修改和草稿/提交结果证据。 - [x] [CONCEPT: 记忆与学习] 为 AI 申请预填记录用户原样采纳、显式字段修改和草稿/提交结果证据。
证据:`expenseApplicationDecisionFeedback.js``useApplicationPreviewEditor.js``expense_application_learning.py`;改回原建议会清除字段差异,日期联动同步记录天数,最终值由服务端 facts 重建,客户端 `finalValue` 不作为事实源。账本只保存字段名和值指纹,证据标记为 `client_observed` `training_eligible=false`,尚不直接训练模型或激活记忆。 证据:`expenseApplicationDecisionFeedback.js``useApplicationPreviewEditor.js``expense_application_learning.py``expense_application_preview_decisions.py`;改回原建议会清除字段差异,日期联动同步记录天数,最终值由服务端 facts 重建。旧预览保持 `client_observed`;服务端签发预览由版本化 HMAC 快照与最终 facts 逐字段比对,字段新增、删除或改值都标记为 `server_verified` 编辑。两者均保持 `training_eligible=false`,尚不直接训练模型或激活记忆。
- [ ] [CONCEPT: 记忆与学习] 将小财管家/通用 Orchestrator 的申请预览切换到认证签发与消费链路;在此之前不得把其客户端 preview 升级为服务端核验证据。
- [ ] [CONCEPT: 记忆与学习] 从字段接受/修改/拒绝、退回、审批覆盖、付款和审计结果生成记忆证据。 - [ ] [CONCEPT: 记忆与学习] 从字段接受/修改/拒绝、退回、审批覆盖、付款和审计结果生成记忆证据。
- [ ] [CONCEPT: 记忆与学习] 将已确认 few-shot 扩展到报销预审和审批辅助,并按租户、场景、制度版本过滤。 - [ ] [CONCEPT: 记忆与学习] 将已确认 few-shot 扩展到报销预审和审批辅助,并按租户、场景、制度版本过滤。
- [ ] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚。 - [ ] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚。
@@ -180,6 +185,8 @@
- [x] [CONCEPT: 测试方案] 在现有开发数据的只读一次性克隆上验证迁移、历史回填和真实认证时间线链路。 - [x] [CONCEPT: 测试方案] 在现有开发数据的只读一次性克隆上验证迁移、历史回填和真实认证时间线链路。
证据:源库与克隆初始签名均为 40 张表、4 张费用单、105 名员工、248 条预算、62 个 Agent 资产;克隆升级后 dry-run 为 eligible=4apply created=4重复 apply created=0四条事件均为 system/suppressed登录、`/auth/me`、旧单时间线和登出均返回 200持久库复查不变且仍无 migration-owned 表。 证据:源库与克隆初始签名均为 40 张表、4 张费用单、105 名员工、248 条预算、62 个 Agent 资产;克隆升级后 dry-run 为 eligible=4apply created=4重复 apply created=0四条事件均为 system/suppressed登录、`/auth/me`、旧单时间线和登出均返回 200持久库复查不变且仍无 migration-owned 表。
- [ ] [CONCEPT: 测试方案] 补充其余前端组件、键盘操作、移动真实接口和完整浏览器关键流程验证。 - [ ] [CONCEPT: 测试方案] 补充其余前端组件、键盘操作、移动真实接口和完整浏览器关键流程验证。
- [x] [CONCEPT: 测试方案] 验证服务端预览决策签发、字段新增/改值差异判定、跨会话拒绝、无 ID 降级防绕过、安全重放、独立密钥权限、续签失败容错、旧路径兼容、会话恢复和真实 PostgreSQL 迁移。
证据:容器内新增决策安全用例 9 项、旧快速保存/提交 4 项、申请学习账本 9 项、迁移与所有权 26 项通过且条件型 PostgreSQL 用例 1 项跳过;一次性 PostgreSQL 17 迁移 4 项、前端定向 3 组及 Vite 生产构建已通过。临时 PostgreSQL 已清理,持久开发库 8 张 migration-owned 表数量仍为 0。
- [ ] [CONCEPT: 测试方案] 所有后端、集成和迁移测试在当前主应用容器内执行,单条命令最大超时 60s。 - [ ] [CONCEPT: 测试方案] 所有后端、集成和迁移测试在当前主应用容器内执行,单条命令最大超时 60s。
- [ ] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据。 - [ ] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据。

View File

@@ -0,0 +1,138 @@
"""add server-issued expense application preview decisions
Revision ID: 20260714_0004
Revises: 20260714_0003
Create Date: 2026-07-14 15:30:00
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20260714_0004"
down_revision: str | None = "20260714_0003"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"ai_application_preview_decisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=64), nullable=False),
sa.Column("actor_id", sa.String(length=120), nullable=False),
sa.Column("auth_session_id", sa.String(length=120), nullable=False),
sa.Column("conversation_id", sa.String(length=120), nullable=False),
sa.Column("decision_type", sa.String(length=80), nullable=False),
sa.Column("decision_source", sa.String(length=20), nullable=False),
sa.Column("field_keys_json", sa.JSON(), nullable=False),
sa.Column("field_fingerprints_json", sa.JSON(), nullable=False),
sa.Column("snapshot_fingerprint", sa.String(length=80), nullable=False),
sa.Column("fingerprint_key_version", sa.String(length=40), nullable=False),
sa.Column("source_version_json", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=20), nullable=False),
sa.Column("issue_request_id", sa.String(length=120), nullable=False),
sa.Column("consumed_action", sa.String(length=30), nullable=True),
sa.Column("consumed_request_id", sa.String(length=120), nullable=True),
sa.Column("consumed_claim_id", sa.String(length=36), nullable=True),
sa.Column("consumed_business_event_id", sa.String(length=36), nullable=True),
sa.Column("consumed_final_fingerprint", sa.String(length=80), nullable=True),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("consumed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.CheckConstraint(
"status IN ('issued', 'consumed', 'expired', 'revoked')",
name="ck_ai_application_preview_decisions_status",
),
sa.CheckConstraint(
"decision_source IN ('heuristic', 'rule', 'hybrid', 'server_draft')",
name="ck_ai_application_preview_decisions_source",
),
sa.CheckConstraint(
"consumed_action IS NULL OR consumed_action IN ('save_draft', 'submit')",
name="ck_ai_application_preview_decisions_consumed_action",
),
sa.CheckConstraint(
"status != 'consumed' OR ("
"consumed_action IS NOT NULL AND consumed_request_id IS NOT NULL AND "
"consumed_claim_id IS NOT NULL AND consumed_business_event_id IS NOT NULL AND "
"consumed_final_fingerprint IS NOT NULL AND consumed_at IS NOT NULL)",
name="ck_ai_application_preview_decisions_consumed_fields",
),
sa.CheckConstraint(
"expires_at > created_at",
name="ck_ai_application_preview_decisions_expiry",
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"id",
name="uq_ai_application_preview_decisions_tenant_id",
),
sa.UniqueConstraint(
"tenant_id",
"actor_id",
"auth_session_id",
"issue_request_id",
name="uq_ai_application_preview_decisions_issue_request",
),
)
op.create_index(
"ix_ai_application_preview_decisions_actor_status_expiry",
"ai_application_preview_decisions",
["tenant_id", "actor_id", "status", "expires_at"],
)
op.create_index(
"ix_ai_application_preview_decisions_conversation",
"ai_application_preview_decisions",
["tenant_id", "conversation_id", "created_at"],
)
op.add_column(
"ai_decisions",
sa.Column("preview_decision_id", sa.String(length=36), nullable=True),
)
op.create_foreign_key(
"fk_ai_decisions_tenant_preview_decision",
"ai_decisions",
"ai_application_preview_decisions",
["tenant_id", "preview_decision_id"],
["tenant_id", "id"],
ondelete="RESTRICT",
)
op.create_unique_constraint(
"uq_ai_decisions_tenant_preview_decision",
"ai_decisions",
["tenant_id", "preview_decision_id"],
)
def downgrade() -> None:
op.drop_constraint(
"uq_ai_decisions_tenant_preview_decision",
"ai_decisions",
type_="unique",
)
op.drop_constraint(
"fk_ai_decisions_tenant_preview_decision",
"ai_decisions",
type_="foreignkey",
)
op.drop_column("ai_decisions", "preview_decision_id")
op.drop_index(
"ix_ai_application_preview_decisions_conversation",
table_name="ai_application_preview_decisions",
)
op.drop_index(
"ix_ai_application_preview_decisions_actor_status_expiry",
table_name="ai_application_preview_decisions",
)
op.drop_table("ai_application_preview_decisions")

View File

@@ -0,0 +1,230 @@
from __future__ import annotations
import logging
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_preview import (
ExpenseApplicationPreviewDecisionCreate,
ExpenseApplicationPreviewDecisionRead,
)
from app.schemas.ontology import OntologyParseResult, OntologyPermission
from app.schemas.reimbursement import (
ExpenseApplicationPreviewActionPayload,
ExpenseApplicationPreviewActionResponse,
ExpenseApplicationPreviewActionResult,
)
from app.schemas.user_agent import UserAgentRequest
from app.services.expense_application_preview_decisions import (
ExpenseApplicationPreviewDecisionService,
PreviewDecisionConflictError,
)
from app.services.expense_application_request_context import (
build_trusted_expense_application_context,
)
from app.services.user_agent import UserAgentService
router = APIRouter(prefix="/reimbursements")
logger = logging.getLogger(__name__)
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.post(
"/application-previews",
response_model=ExpenseApplicationPreviewDecisionRead,
status_code=status.HTTP_201_CREATED,
summary="签发费用申请预览决策",
description=(
"服务端重新解析申请原文、重算规则字段并签发短期 decision_id"
"不接受客户端来源声明作为可信 AI 证据。"
),
)
def issue_expense_application_preview_decision(
payload: ExpenseApplicationPreviewDecisionCreate,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseApplicationPreviewDecisionRead:
run_id = f"application-preview-decision:{payload.request_id}"
context_json = build_trusted_expense_application_context(current_user)
request = UserAgentRequest(
run_id=run_id,
user_id=current_user.username or current_user.name,
message=payload.message,
ontology=OntologyParseResult(run_id=run_id),
context_json=context_json,
tool_payload={},
selected_capability_codes=[],
degraded=False,
requires_confirmation=False,
)
try:
facts = UserAgentService(db)._resolve_expense_application_facts(request)
issued = ExpenseApplicationPreviewDecisionService(db).issue(
facts,
current_user,
conversation_id=payload.conversation_id or "",
request_id=payload.request_id,
)
db.commit()
db.refresh(issued.decision)
except PreviewDecisionConflictError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
return ExpenseApplicationPreviewDecisionRead(
decision_id=issued.decision.id,
decision_source=issued.decision.decision_source,
expires_at=issued.decision.expires_at,
application_preview={
"fields": issued.fields,
"decisionId": issued.decision.id,
"decisionSource": issued.decision.decision_source,
"decisionExpiresAt": issued.decision.expires_at.isoformat(),
},
)
@router.post(
"/application-preview-action",
response_model=ExpenseApplicationPreviewActionResponse,
summary="按申请核对预览快速保存或提交申请单",
description=(
"用于 AI 工作台已完成表格核对后的轻量建单/提交流程,避免重复进入通用 Orchestrator 编排。"
),
)
def run_application_preview_action(
payload: ExpenseApplicationPreviewActionPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseApplicationPreviewActionResponse:
context_json = build_trusted_expense_application_context(
current_user,
payload.context_json,
)
if payload.action_type == "save_draft":
context_json["application_action"] = "save_draft"
context_json["application_save_mode"] = True
elif payload.action_type == "submit":
context_json.pop("application_action", None)
context_json.pop("application_save_mode", None)
run_id = f"application-preview-action:{payload.conversation_id or current_user.username}"
request = UserAgentRequest(
run_id=run_id,
user_id=current_user.username or current_user.name,
message=payload.message,
ontology=OntologyParseResult(
scenario="expense",
intent="operate",
permission=OntologyPermission(
level="approval_required",
allowed=True,
reason="application preview fast action",
),
confidence=1.0,
run_id=run_id,
),
context_json=context_json,
tool_payload={},
selected_capability_codes=[],
degraded=False,
requires_confirmation=False,
)
try:
user_agent_service = UserAgentService(db)
facts = user_agent_service._resolve_expense_application_facts(request)
resolved_step = user_agent_service._resolve_expense_application_step(request, facts)
resolved_action = "save_draft" if resolved_step == "draft" else "submit"
if payload.action_type and payload.action_type != resolved_action:
raise ValueError("动作类型与申请内容不一致。")
preview_decision_service = ExpenseApplicationPreviewDecisionService(db)
preview_decision = None
if payload.decision_id:
preview_decision = preview_decision_service.require_for_action(
payload.decision_id,
current_user,
conversation_id=payload.conversation_id or "",
request_id=payload.request_id or "",
action_type=resolved_action,
final_values=facts,
)
else:
preview_decision_service.reject_active_decision_bypass(
current_user,
conversation_id=payload.conversation_id or "",
)
consumed_preview_decision_id = preview_decision.id if preview_decision is not None else ""
user_agent_response = user_agent_service._build_expense_application_response(
request,
risk_flags=[],
learning_current_user=current_user,
learning_preview_decision=preview_decision,
learning_action_request_id=payload.request_id or "",
)
except PreviewDecisionConflictError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
except ValueError as error:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
next_preview_decision = None
if (
preview_decision is not None
and resolved_action == "save_draft"
and user_agent_response.draft_payload is not None
):
try:
next_preview_decision = (
ExpenseApplicationPreviewDecisionService(db)
.issue(
facts,
current_user,
conversation_id=payload.conversation_id or "",
request_id=(f"next:{consumed_preview_decision_id}:{payload.request_id or ''}")[
:120
],
decision_source="server_draft",
)
.decision
)
db.commit()
db.refresh(next_preview_decision)
except Exception as error:
# 业务动作已经在 UserAgent 内提交;续签仅是派生能力,失败不能反转成功结果。
db.rollback()
next_preview_decision = None
logger.warning(
"费用申请草稿已保存但后续预览决策签发失败decision_id=%s error_type=%s",
consumed_preview_decision_id,
type(error).__name__,
)
return ExpenseApplicationPreviewActionResponse(
status="succeeded",
conversation_id=payload.conversation_id,
result=ExpenseApplicationPreviewActionResult(
message=user_agent_response.answer,
answer=user_agent_response.answer,
suggested_actions=[
action.model_dump(mode="json") for action in user_agent_response.suggested_actions
],
risk_flags=user_agent_response.risk_flags,
requires_confirmation=user_agent_response.requires_confirmation,
draft_payload=(
user_agent_response.draft_payload.model_dump(mode="json")
if user_agent_response.draft_payload is not None
else None
),
decision_id=(next_preview_decision.id if next_preview_decision is not None else None),
decision_expires_at=(
next_preview_decision.expires_at if next_preview_decision is not None else None
),
),
)

View File

@@ -10,11 +10,7 @@ from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.api.pagination import PageNumber, PageSize, page_payload, wants_page from app.api.pagination import PageNumber, PageSize, page_payload, wants_page
from app.schemas.budget import BudgetClaimAnalysisRead from app.schemas.budget import BudgetClaimAnalysisRead
from app.schemas.common import ErrorResponse, PaginatedResponse from app.schemas.common import ErrorResponse, PaginatedResponse
from app.schemas.ontology import OntologyParseResult, OntologyPermission
from app.schemas.reimbursement import ( from app.schemas.reimbursement import (
ExpenseApplicationPreviewActionPayload,
ExpenseApplicationPreviewActionResponse,
ExpenseApplicationPreviewActionResult,
ExpenseClaimActionResponse, ExpenseClaimActionResponse,
ExpenseClaimApprovalPayload, ExpenseClaimApprovalPayload,
ExpenseClaimAttachmentActionResponse, ExpenseClaimAttachmentActionResponse,
@@ -31,13 +27,11 @@ from app.schemas.reimbursement import (
TravelReimbursementCalculatorRequest, TravelReimbursementCalculatorRequest,
TravelReimbursementCalculatorResponse, TravelReimbursementCalculatorResponse,
) )
from app.schemas.user_agent import UserAgentRequest
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.document_numbering import is_application_claim_no from app.services.document_numbering import is_application_claim_no
from app.services.expense_claims import ExpenseClaimService from app.services.expense_claims import ExpenseClaimService
from app.services.reimbursement import ReimbursementService from app.services.reimbursement import ReimbursementService
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
from app.services.user_agent import UserAgentService
router = APIRouter() router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)] DbSession = Annotated[Session, Depends(get_db)]
@@ -95,100 +89,6 @@ def calculate_travel_reimbursement(
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
def _build_application_preview_action_context(
payload: ExpenseApplicationPreviewActionPayload,
current_user: CurrentUserContext,
) -> dict[str, object]:
context_json = dict(payload.context_json or {})
context_json.setdefault("session_type", "application")
context_json.setdefault("entry_source", "workbench_ai_inline")
context_json.setdefault("document_type", "expense_application")
context_json.setdefault("application_stage", "expense_application")
# 身份与权限字段只能来自服务端会话,不能保留请求体中的同名值。
context_json.update(
{
"tenant_id": current_user.tenant_id,
"role_codes": current_user.role_codes,
"is_admin": current_user.is_admin,
"username": current_user.username,
"name": current_user.name,
"department_name": current_user.department_name,
"position": current_user.position,
"grade": current_user.grade,
"employee_no": current_user.employee_no,
"manager_name": current_user.manager_name,
}
)
return context_json
@router.post(
"/application-preview-action",
response_model=ExpenseApplicationPreviewActionResponse,
summary="按申请核对预览快速保存或提交申请单",
description=(
"用于 AI 工作台已完成表格核对后的轻量建单/提交流程,"
"避免重复进入通用 Orchestrator 编排。"
),
)
def run_application_preview_action(
payload: ExpenseApplicationPreviewActionPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseApplicationPreviewActionResponse:
context_json = _build_application_preview_action_context(payload, current_user)
run_id = f"application-preview-action:{payload.conversation_id or current_user.username}"
request = UserAgentRequest(
run_id=run_id,
user_id=current_user.username or current_user.name,
message=payload.message,
ontology=OntologyParseResult(
scenario="expense",
intent="operate",
permission=OntologyPermission(
level="approval_required",
allowed=True,
reason="application preview fast action",
),
confidence=1.0,
run_id=run_id,
),
context_json=context_json,
tool_payload={},
selected_capability_codes=[],
degraded=False,
requires_confirmation=False,
)
try:
user_agent_response = UserAgentService(db)._build_expense_application_response(
request,
risk_flags=[],
learning_current_user=current_user,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
return ExpenseApplicationPreviewActionResponse(
status="succeeded",
conversation_id=payload.conversation_id,
result=ExpenseApplicationPreviewActionResult(
message=user_agent_response.answer,
answer=user_agent_response.answer,
suggested_actions=[
action.model_dump(mode="json")
for action in user_agent_response.suggested_actions
],
risk_flags=user_agent_response.risk_flags,
requires_confirmation=user_agent_response.requires_confirmation,
draft_payload=(
user_agent_response.draft_payload.model_dump(mode="json")
if user_agent_response.draft_payload is not None
else None
),
),
)
@router.get( @router.get(
"/claims", "/claims",
response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead], response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead],

View File

@@ -6,17 +6,24 @@ from app.api.v1.endpoints.agent_feedback import router as agent_feedback_router
from app.api.v1.endpoints.agent_runs import router as agent_runs_router from app.api.v1.endpoints.agent_runs import router as agent_runs_router
from app.api.v1.endpoints.agent_traces import router as agent_traces_router from app.api.v1.endpoints.agent_traces import router as agent_traces_router
from app.api.v1.endpoints.analytics import router as analytics_router from app.api.v1.endpoints.analytics import router as analytics_router
from app.api.v1.endpoints.attachment_association_jobs import router as attachment_association_jobs_router from app.api.v1.endpoints.attachment_association_jobs import (
router as attachment_association_jobs_router,
)
from app.api.v1.endpoints.audit_logs import router as audit_logs_router from app.api.v1.endpoints.audit_logs import router as audit_logs_router
from app.api.v1.endpoints.auth import router as auth_router from app.api.v1.endpoints.auth import router as auth_router
from app.api.v1.endpoints.bootstrap import router as bootstrap_router 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.budgets import router as budgets_router
from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.expense_cases import router as expense_cases_router
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_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_previews import (
router as expense_application_previews_router,
)
from app.api.v1.endpoints.expense_cases import router as expense_cases_router
from app.api.v1.endpoints.health import router as health_router from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.knowledge import router as knowledge_router from app.api.v1.endpoints.knowledge import router as knowledge_router
from app.api.v1.endpoints.linked_reimbursement_draft_jobs import router as linked_reimbursement_draft_jobs_router from app.api.v1.endpoints.linked_reimbursement_draft_jobs import (
router as linked_reimbursement_draft_jobs_router,
)
from app.api.v1.endpoints.notification_states import router as notification_states_router from app.api.v1.endpoints.notification_states import router as notification_states_router
from app.api.v1.endpoints.ocr import router as ocr_router from app.api.v1.endpoints.ocr import router as ocr_router
from app.api.v1.endpoints.ontology import router as ontology_router from app.api.v1.endpoints.ontology import router as ontology_router
@@ -50,6 +57,7 @@ router.include_router(orchestrator_router, tags=["orchestrator"])
router.include_router(receipt_folder_router, tags=["receipt-folder"]) router.include_router(receipt_folder_router, tags=["receipt-folder"])
router.include_router(employees_router, prefix="/employees", tags=["employees"]) router.include_router(employees_router, prefix="/employees", tags=["employees"])
router.include_router(expense_cases_router, tags=["expense-cases"]) router.include_router(expense_cases_router, tags=["expense-cases"])
router.include_router(expense_application_previews_router, tags=["reimbursements"])
router.include_router(employee_profiles_router, tags=["employee-profiles"]) router.include_router(employee_profiles_router, tags=["employee-profiles"])
router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"]) router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"])
router.include_router(risk_observations_router, tags=["risk-observations"]) router.include_router(risk_observations_router, tags=["risk-observations"])

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import base64
import binascii
import os
import re
import secrets
from pathlib import Path
from app.core.config import SERVER_DIR
FINGERPRINT_KEY_DIRECTORY = SERVER_DIR / ".secrets" / "expense-application-fingerprints"
ACTIVE_FINGERPRINT_KEY_VERSION = "v1"
KEY_BYTES = 32
_VERSION_PATTERN = re.compile(r"^[a-zA-Z0-9._-]{1,32}$")
def get_expense_application_fingerprint_key(
version: str,
*,
create: bool,
) -> bytes:
normalized_version = str(version or "").strip()
if not _VERSION_PATTERN.fullmatch(normalized_version):
raise ValueError("费用申请指纹密钥版本无效。")
key_path = FINGERPRINT_KEY_DIRECTORY / f"{normalized_version}.key"
if not key_path.exists():
if not create:
raise ValueError("费用申请指纹密钥版本不可用,不能核验旧预览。")
_create_key_atomically(key_path)
if key_path.is_symlink() or not key_path.is_file():
raise ValueError("费用申请指纹密钥文件无效。")
os.chmod(key_path, 0o600)
encoded = key_path.read_text(encoding="utf-8").strip()
try:
key = base64.urlsafe_b64decode(encoded.encode("ascii"))
except (binascii.Error, ValueError, UnicodeError) as error:
raise ValueError("费用申请指纹密钥内容无效。") from error
if len(key) != KEY_BYTES:
raise ValueError("费用申请指纹密钥长度无效。")
return key
def _create_key_atomically(key_path: Path) -> None:
key_path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(key_path.parent, 0o700)
encoded = base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES))
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
try:
descriptor = os.open(key_path, flags, 0o600)
except FileExistsError:
return
with os.fdopen(descriptor, "wb") as stream:
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())

View File

@@ -9,6 +9,7 @@ from app.models.agent_asset import (
from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog 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_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.approval import ApprovalRecord from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
@@ -53,6 +54,7 @@ __all__ = [
"AgentRun", "AgentRun",
"AgentToolCall", "AgentToolCall",
"AgentTraceEvent", "AgentTraceEvent",
"AIApplicationPreviewDecision",
"AIDecision", "AIDecision",
"AIDecisionFeedback", "AIDecisionFeedback",
"ApprovalRecord", "ApprovalRecord",

View File

@@ -38,8 +38,20 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = {
"workflow_outcomes", "workflow_outcomes",
} }
), ),
"20260714_0004": frozenset(
{
"expense_cases",
"expense_case_links",
"business_events",
"auth_sessions",
"ai_application_preview_decisions",
"ai_decisions",
"ai_decision_feedback",
"workflow_outcomes",
}
),
} }
if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"] != MIGRATION_OWNED_TABLES: if MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"] != MIGRATION_OWNED_TABLES:
raise RuntimeError("latest Alembic revision must own the centralized migration table set") raise RuntimeError("latest Alembic revision must own the centralized migration table set")

View File

@@ -7,6 +7,7 @@ from app.db.base import Base
MIGRATION_OWNED_TABLES: frozenset[str] = frozenset( MIGRATION_OWNED_TABLES: frozenset[str] = frozenset(
{ {
"auth_sessions", "auth_sessions",
"ai_application_preview_decisions",
"ai_decisions", "ai_decisions",
"ai_decision_feedback", "ai_decision_feedback",
"expense_cases", "expense_cases",

View File

@@ -7,6 +7,7 @@ from app.models.agent_asset import (
from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_conversation import AgentConversation, AgentConversationMessage
from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_feedback import AgentOperationFeedback
from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog 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_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.approval import ApprovalRecord from app.models.approval import ApprovalRecord
from app.models.audit_log import AuditLog from app.models.audit_log import AuditLog
@@ -49,6 +50,7 @@ __all__ = [
"AgentRun", "AgentRun",
"AgentToolCall", "AgentToolCall",
"AgentTraceEvent", "AgentTraceEvent",
"AIApplicationPreviewDecision",
"ApprovalRecord", "ApprovalRecord",
"AuditLog", "AuditLog",
"AuthSession", "AuthSession",

View File

@@ -0,0 +1,105 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import CheckConstraint, DateTime, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
class AIApplicationPreviewDecision(Base):
"""费用申请落单前,由服务端签发并短期持有的不可变建议快照。"""
__tablename__ = "ai_application_preview_decisions"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"id",
name="uq_ai_application_preview_decisions_tenant_id",
),
UniqueConstraint(
"tenant_id",
"actor_id",
"auth_session_id",
"issue_request_id",
name="uq_ai_application_preview_decisions_issue_request",
),
CheckConstraint(
"status IN ('issued', 'consumed', 'expired', 'revoked')",
name="ck_ai_application_preview_decisions_status",
),
CheckConstraint(
"decision_source IN ('heuristic', 'rule', 'hybrid', 'server_draft')",
name="ck_ai_application_preview_decisions_source",
),
CheckConstraint(
"consumed_action IS NULL OR consumed_action IN ('save_draft', 'submit')",
name="ck_ai_application_preview_decisions_consumed_action",
),
CheckConstraint(
"status != 'consumed' OR ("
"consumed_action IS NOT NULL AND consumed_request_id IS NOT NULL AND "
"consumed_claim_id IS NOT NULL AND consumed_business_event_id IS NOT NULL AND "
"consumed_final_fingerprint IS NOT NULL AND consumed_at IS NOT NULL)",
name="ck_ai_application_preview_decisions_consumed_fields",
),
CheckConstraint(
"expires_at > created_at",
name="ck_ai_application_preview_decisions_expiry",
),
Index(
"ix_ai_application_preview_decisions_actor_status_expiry",
"tenant_id",
"actor_id",
"status",
"expires_at",
),
Index(
"ix_ai_application_preview_decisions_conversation",
"tenant_id",
"conversation_id",
"created_at",
),
)
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
auth_session_id: Mapped[str] = mapped_column(String(120), nullable=False)
conversation_id: Mapped[str] = mapped_column(String(120), nullable=False, default="")
decision_type: Mapped[str] = mapped_column(String(80), nullable=False)
decision_source: Mapped[str] = mapped_column(String(20), nullable=False)
field_keys_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
field_fingerprints_json: Mapped[dict[str, str]] = mapped_column(
JSON,
nullable=False,
default=dict,
)
snapshot_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
fingerprint_key_version: Mapped[str] = mapped_column(String(40), nullable=False)
source_version_json: Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=False,
default=dict,
)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="issued")
issue_request_id: Mapped[str] = mapped_column(String(120), nullable=False)
consumed_action: Mapped[str | None] = mapped_column(String(30), nullable=True)
consumed_request_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
consumed_claim_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
consumed_business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
consumed_final_fingerprint: Mapped[str | None] = mapped_column(String(80), nullable=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)

View File

@@ -44,6 +44,20 @@ class AIDecision(Base):
"idempotency_key", "idempotency_key",
name="uq_ai_decisions_tenant_idempotency", name="uq_ai_decisions_tenant_idempotency",
), ),
UniqueConstraint(
"tenant_id",
"preview_decision_id",
name="uq_ai_decisions_tenant_preview_decision",
),
ForeignKeyConstraint(
["tenant_id", "preview_decision_id"],
[
"ai_application_preview_decisions.tenant_id",
"ai_application_preview_decisions.id",
],
ondelete="RESTRICT",
name="fk_ai_decisions_tenant_preview_decision",
),
ForeignKeyConstraint( ForeignKeyConstraint(
["tenant_id", "expense_case_id"], ["tenant_id", "expense_case_id"],
["expense_cases.tenant_id", "expense_cases.id"], ["expense_cases.tenant_id", "expense_cases.id"],
@@ -90,6 +104,7 @@ class AIDecision(Base):
expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False) expense_case_id: Mapped[str] = mapped_column(String(36), nullable=False)
business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True) business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
expense_claim_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True) expense_claim_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
preview_decision_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
agent_run_id: Mapped[str | None] = mapped_column(String(120), nullable=True) agent_run_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
correlation_id: Mapped[str] = mapped_column(String(64), nullable=False) correlation_id: Mapped[str] = mapped_column(String(64), nullable=False)
subject_type: Mapped[str] = mapped_column(String(50), nullable=False) subject_type: Mapped[str] = mapped_column(String(50), nullable=False)

View File

@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class ExpenseApplicationPreviewDecisionCreate(BaseModel):
message: str = Field(min_length=1, max_length=4000)
conversation_id: str | None = Field(default=None, max_length=120)
request_id: str = Field(min_length=1, max_length=120)
class ExpenseApplicationPreviewDecisionRead(BaseModel):
decision_id: str
decision_source: str
expires_at: datetime
application_preview: dict[str, Any] = Field(default_factory=dict)

View File

@@ -191,6 +191,9 @@ class ExpenseApplicationPreviewActionPayload(BaseModel):
source: str = Field(default="user_message", max_length=80) source: str = Field(default="user_message", max_length=80)
user_id: str | None = Field(default=None, max_length=120) user_id: str | None = Field(default=None, max_length=120)
conversation_id: str | None = Field(default=None, max_length=120) conversation_id: str | None = Field(default=None, max_length=120)
action_type: str | None = Field(default=None, pattern="^(save_draft|submit)$")
decision_id: str | None = Field(default=None, max_length=36)
request_id: str | None = Field(default=None, max_length=120)
message: str = Field(min_length=1, max_length=4000) message: str = Field(min_length=1, max_length=4000)
context_json: dict[str, Any] = Field(default_factory=dict) context_json: dict[str, Any] = Field(default_factory=dict)
@@ -202,6 +205,8 @@ class ExpenseApplicationPreviewActionResult(BaseModel):
risk_flags: list[str] = Field(default_factory=list) risk_flags: list[str] = Field(default_factory=list)
requires_confirmation: bool = False requires_confirmation: bool = False
draft_payload: dict[str, Any] | None = None draft_payload: dict[str, Any] | None = None
decision_id: str | None = None
decision_expires_at: datetime | None = None
class ExpenseApplicationPreviewActionResponse(BaseModel): class ExpenseApplicationPreviewActionResponse(BaseModel):

View File

@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
@@ -11,10 +9,22 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome
from app.models.expense_case import BusinessEvent from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.schemas.user_agent import UserAgentRequest from app.schemas.user_agent import UserAgentRequest
from app.services.expense_application_preview_decisions import (
ExpenseApplicationPreviewDecisionService,
)
from app.services.expense_application_snapshot import (
PREVIEW_FIELD_ALIASES,
content_fingerprint,
field_value_fingerprint,
hmac_fingerprint,
safe_fact_snapshot,
snapshot_reference,
)
from app.services.expense_cases import ExpenseCaseService from app.services.expense_cases import ExpenseCaseService
@@ -28,50 +38,6 @@ class ExpenseApplicationLearningRecords:
class ExpenseApplicationLearningService: class ExpenseApplicationLearningService:
"""把 AI 申请预填、显式用户编辑和工作流结果原子写入学习账本。""" """把 AI 申请预填、显式用户编辑和工作流结果原子写入学习账本。"""
_FACT_KEYS = (
"application_type",
"time",
"location",
"reason",
"days",
"transport_mode",
"amount",
"grade",
"department",
"position",
"manager_name",
"lodging_daily_cap",
"subsidy_daily_cap",
"transport_policy",
"policy_estimate",
"matched_city",
"rule_name",
"rule_version",
"hotel_amount",
"allowance_amount",
"transport_estimated_amount",
"transport_estimate_source",
"transport_estimate_confidence",
"policy_total_amount",
)
_FIELD_ALIASES = {
"applicationType": "application_type",
"application_type": "application_type",
"time": "time",
"time_return": "time",
"location": "location",
"reason": "reason",
"days": "days",
"transportMode": "transport_mode",
"transport_mode": "transport_mode",
"amount": "amount",
"grade": "grade",
"department": "department",
"position": "position",
"managerName": "manager_name",
"manager_name": "manager_name",
}
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
@@ -84,10 +50,14 @@ class ExpenseApplicationLearningService:
*, *,
action_type: str, action_type: str,
business_event: BusinessEvent, business_event: BusinessEvent,
preview_decision: AIApplicationPreviewDecision | None = None,
action_request_id: str = "",
) -> ExpenseApplicationLearningRecords | None: ) -> ExpenseApplicationLearningRecords | None:
normalized_action = self._normalize_action_type(action_type) normalized_action = self._normalize_action_type(action_type)
preview = self._application_preview(payload) preview = self._application_preview(payload)
if not self._is_learning_eligible_preview(preview): if not self._is_learning_eligible_preview(preview):
if preview_decision is not None:
raise ValueError("服务端预览决策缺少可核验的申请字段。")
return None return None
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id) tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
expense_case = ExpenseCaseService(self.db).ensure_case_for_claim( expense_case = ExpenseCaseService(self.db).ensure_case_for_claim(
@@ -101,20 +71,48 @@ class ExpenseApplicationLearningService:
raise PermissionError("AI 学习记录不能关联其他租户或费用事件的业务事件。") raise PermissionError("AI 学习记录不能关联其他租户或费用事件的业务事件。")
final_values = self._safe_fact_snapshot(facts) final_values = self._safe_fact_snapshot(facts)
if preview_decision is not None:
final_reference = self._snapshot_reference(
final_values,
key_version=preview_decision.fingerprint_key_version,
allow_key_creation=False,
)
changed_field_references = self._verified_changed_field_references(
preview_decision,
final_values,
)
suggestion_reference = {
"field_keys": list(preview_decision.field_keys_json or []),
"value_fingerprint": preview_decision.snapshot_fingerprint,
"fingerprint_key_version": preview_decision.fingerprint_key_version,
}
evidence_source = "server_preview_decision"
verification_status = "server_verified"
else:
final_reference = self._snapshot_reference(final_values)
changed_fields = self._safe_changed_fields(payload, final_values) changed_fields = self._safe_changed_fields(payload, final_values)
suggested_values = dict(final_values) suggested_values = dict(final_values)
for item in changed_fields: for item in changed_fields:
suggested_values[item["field_key"]] = item["suggested_value"] suggested_values[item["field_key"]] = item["suggested_value"]
changed_field_references = self._changed_field_references(changed_fields)
suggestion_reference = self._snapshot_reference(suggested_values)
evidence_source = (
"client_explicit_field_edit"
if changed_field_references
else "client_action_confirmation"
)
verification_status = "client_observed"
feedback_type = "edited" if changed_fields else "accepted" feedback_type = "edited" if changed_field_references else "accepted"
correlation_id = ExpenseCaseService.normalize_correlation_id(payload.run_id) correlation_id = ExpenseCaseService.normalize_correlation_id(payload.run_id)
fingerprint_payload = { fingerprint_payload = {
"tenant_id": tenant_id, "tenant_id": tenant_id,
"expense_claim_id": claim.id, "expense_claim_id": claim.id,
"action_type": normalized_action, "action_type": normalized_action,
"suggested_values": suggested_values, "preview_decision_id": preview_decision.id if preview_decision is not None else None,
"final_values": final_values, "suggestion_reference": suggestion_reference,
"changed_fields": changed_fields, "final_reference": final_reference,
"changed_fields": changed_field_references,
} }
content_fingerprint = self._fingerprint(fingerprint_payload) content_fingerprint = self._fingerprint(fingerprint_payload)
idempotency_key = self._idempotency_key( idempotency_key = self._idempotency_key(
@@ -133,24 +131,38 @@ class ExpenseApplicationLearningService:
expense_case_id=expense_case.id, expense_case_id=expense_case.id,
business_event_id=business_event.id, business_event_id=business_event.id,
expense_claim_id=claim.id, expense_claim_id=claim.id,
preview_decision_id=(preview_decision.id if preview_decision is not None else None),
agent_run_id=str(payload.run_id or "").strip() or None, agent_run_id=str(payload.run_id or "").strip() or None,
correlation_id=correlation_id, correlation_id=correlation_id,
subject_type="expense_claim", subject_type="expense_claim",
subject_id=claim.id, subject_id=claim.id,
decision_type="expense_application_prefill", decision_type="expense_application_prefill",
decision_source=self._decision_source(preview, facts), decision_source=(
preview_decision.decision_source
if preview_decision is not None
else self._decision_source(preview, facts)
),
status="executed", status="executed",
automation_mode="prefill", automation_mode="prefill",
confidence=self._confidence(payload), confidence=self._confidence(payload),
suggestion_json=self._snapshot_reference(suggested_values), suggestion_json=suggestion_reference,
evidence_json={ evidence_json={
"evidence_source": "client_preview_edit_trace", "evidence_source": (
"trust_level": "behavioral_analytics_only", "server_preview_decision"
if preview_decision is not None
else "client_preview_edit_trace"
),
"trust_level": (
"server_snapshot_verified"
if preview_decision is not None
else "behavioral_analytics_only"
),
"model_refined": bool(preview.get("modelRefined")), "model_refined": bool(preview.get("modelRefined")),
"model_review_status": self._safe_text(preview.get("modelReviewStatus"), 40), "model_review_status": self._safe_text(preview.get("modelReviewStatus"), 40),
}, },
version_json={ version_json={
"schema_version": 1, "schema_version": 1,
"fingerprint_key_version": suggestion_reference.get("fingerprint_key_version"),
"parse_strategy": self._safe_text(preview.get("parseStrategy"), 60), "parse_strategy": self._safe_text(preview.get("parseStrategy"), 60),
"rule_name": self._safe_text(facts.get("rule_name"), 120), "rule_name": self._safe_text(facts.get("rule_name"), 120),
"rule_version": self._safe_text(facts.get("rule_version"), 80), "rule_version": self._safe_text(facts.get("rule_version"), 80),
@@ -170,28 +182,22 @@ class ExpenseApplicationLearningService:
action_type=normalized_action, action_type=normalized_action,
actor_id=str(current_user.username or "anonymous").strip() or "anonymous", actor_id=str(current_user.username or "anonymous").strip() or "anonymous",
actor_type="user", actor_type="user",
evidence_source=( evidence_source=evidence_source,
"client_explicit_field_edit" verification_status=verification_status,
if changed_fields
else "client_action_confirmation"
),
verification_status="client_observed",
training_eligible=False, training_eligible=False,
final_value_json=self._snapshot_reference(final_values), final_value_json=final_reference,
changed_fields_json=self._changed_field_references(changed_fields), changed_fields_json=changed_field_references,
idempotency_key=f"feedback:{idempotency_key}"[:120], idempotency_key=f"feedback:{idempotency_key}"[:120],
content_fingerprint=self._fingerprint( content_fingerprint=self._fingerprint(
{ {
"decision_id": decision_id, "decision_id": decision_id,
"feedback_type": feedback_type, "feedback_type": feedback_type,
"final_values": final_values, "final_reference": final_reference,
"changed_fields": changed_fields, "changed_fields": changed_field_references,
} }
), ),
) )
outcome_type = ( outcome_type = "application_submitted" if normalized_action == "submit" else "draft_saved"
"application_submitted" if normalized_action == "submit" else "draft_saved"
)
outcome = WorkflowOutcome( outcome = WorkflowOutcome(
id=self._stable_id("outcome", tenant_id, idempotency_key), id=self._stable_id("outcome", tenant_id, idempotency_key),
tenant_id=tenant_id, tenant_id=tenant_id,
@@ -206,9 +212,7 @@ class ExpenseApplicationLearningService:
actor_type="user", actor_type="user",
result_json={ result_json={
"claim_status": "submitted" if normalized_action == "submit" else "draft", "claim_status": "submitted" if normalized_action == "submit" else "draft",
"approval_stage": ( "approval_stage": ("直属领导审批" if normalized_action == "submit" else "待提交"),
"直属领导审批" if normalized_action == "submit" else "待提交"
),
"feedback_type": feedback_type, "feedback_type": feedback_type,
}, },
idempotency_key=f"outcome:{idempotency_key}"[:120], idempotency_key=f"outcome:{idempotency_key}"[:120],
@@ -222,6 +226,15 @@ class ExpenseApplicationLearningService:
) )
self.db.add_all([decision, feedback, outcome]) self.db.add_all([decision, feedback, outcome])
self.db.flush() self.db.flush()
if preview_decision is not None:
ExpenseApplicationPreviewDecisionService(self.db).consume(
preview_decision,
request_id=action_request_id,
action_type=normalized_action,
final_values=final_values,
claim=claim,
business_event=business_event,
)
return ExpenseApplicationLearningRecords(decision, feedback, outcome) return ExpenseApplicationLearningRecords(decision, feedback, outcome)
def _find_existing( def _find_existing(
@@ -255,11 +268,7 @@ class ExpenseApplicationLearningService:
@classmethod @classmethod
def _safe_fact_snapshot(cls, facts: dict[str, str]) -> dict[str, str]: def _safe_fact_snapshot(cls, facts: dict[str, str]) -> dict[str, str]:
return { return safe_fact_snapshot(facts)
key: cls._safe_text(facts.get(key), 500)
for key in cls._FACT_KEYS
if cls._safe_text(facts.get(key), 500)
}
@classmethod @classmethod
def _safe_changed_fields( def _safe_changed_fields(
@@ -279,7 +288,7 @@ class ExpenseApplicationLearningService:
if not isinstance(raw_item, dict): if not isinstance(raw_item, dict):
continue continue
alias = cls._safe_text(raw_item.get("fieldKey"), 60) alias = cls._safe_text(raw_item.get("fieldKey"), 60)
field_key = cls._FIELD_ALIASES.get(alias) field_key = PREVIEW_FIELD_ALIASES.get(alias)
if not field_key or field_key not in final_values: if not field_key or field_key not in final_values:
continue continue
suggested_value = cls._safe_text(raw_item.get("suggestedValue"), 500) suggested_value = cls._safe_text(raw_item.get("suggestedValue"), 500)
@@ -309,11 +318,55 @@ class ExpenseApplicationLearningService:
return str(preview.get("modelReviewStatus") or "").strip().lower() != "template" return str(preview.get("modelReviewStatus") or "").strip().lower() != "template"
@classmethod @classmethod
def _snapshot_reference(cls, values: dict[str, str]) -> dict[str, Any]: def _snapshot_reference(
return { cls,
"field_keys": sorted(values), values: dict[str, str],
"value_fingerprint": cls._fingerprint(values), *,
key_version: str | None = None,
allow_key_creation: bool = True,
) -> dict[str, Any]:
if key_version is None:
return snapshot_reference(values)
return snapshot_reference(
values,
key_version=key_version,
allow_key_creation=allow_key_creation,
)
@staticmethod
def _verified_changed_field_references(
decision: AIApplicationPreviewDecision,
final_values: dict[str, str],
) -> list[dict[str, str]]:
fingerprints = decision.field_fingerprints_json or {}
changes: list[dict[str, str]] = []
suggested_field_keys = set(decision.field_keys_json or [])
final_field_keys = set(final_values)
for field_key in sorted(suggested_field_keys | final_field_keys):
suggested_fingerprint = str(fingerprints.get(field_key) or "").strip()
if not suggested_fingerprint:
suggested_fingerprint = field_value_fingerprint(
"",
present=False,
key_version=decision.fingerprint_key_version,
allow_key_creation=False,
)
final_fingerprint = field_value_fingerprint(
final_values.get(field_key, ""),
present=field_key in final_values,
key_version=decision.fingerprint_key_version,
allow_key_creation=False,
)
if suggested_fingerprint == final_fingerprint:
continue
changes.append(
{
"field_key": field_key,
"suggested_value_fingerprint": suggested_fingerprint,
"final_value_fingerprint": final_fingerprint,
} }
)
return changes
@classmethod @classmethod
def _changed_field_references( def _changed_field_references(
@@ -323,8 +376,8 @@ class ExpenseApplicationLearningService:
return [ return [
{ {
"field_key": item["field_key"], "field_key": item["field_key"],
"suggested_value_fingerprint": cls._fingerprint(item["suggested_value"]), "suggested_value_fingerprint": hmac_fingerprint(item["suggested_value"]),
"final_value_fingerprint": cls._fingerprint(item["final_value"]), "final_value_fingerprint": hmac_fingerprint(item["final_value"]),
} }
for item in changes for item in changes
] ]
@@ -363,14 +416,7 @@ class ExpenseApplicationLearningService:
@staticmethod @staticmethod
def _fingerprint(payload: object) -> str: def _fingerprint(payload: object) -> str:
serialized = json.dumps( return content_fingerprint(payload)
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
return f"sha256:{hashlib.sha256(serialized.encode('utf-8')).hexdigest()}"
@staticmethod @staticmethod
def _idempotency_key(action_type: str, claim_id: str, fingerprint: str) -> str: def _idempotency_key(action_type: str, claim_id: str, fingerprint: str) -> str:

View File

@@ -0,0 +1,276 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim
from app.services.expense_application_snapshot import (
FINGERPRINT_KEY_VERSION,
field_fingerprints,
hmac_fingerprint,
preview_response_fields,
safe_fact_snapshot,
)
from app.services.expense_cases import ExpenseCaseService
PREVIEW_DECISION_TTL = timedelta(minutes=30)
class PreviewDecisionConflictError(ValueError):
"""预览决策已过期、已消费或与当前动作不一致。"""
@dataclass(frozen=True)
class IssuedPreviewDecision:
decision: AIApplicationPreviewDecision
fields: dict[str, str]
class ExpenseApplicationPreviewDecisionService:
"""签发、授权和消费费用申请预览决策,不保存明文建议值。"""
def __init__(self, db: Session) -> None:
self.db = db
def issue(
self,
facts: dict[str, Any],
current_user: CurrentUserContext,
*,
conversation_id: str,
request_id: str,
decision_source: str | None = None,
) -> IssuedPreviewDecision:
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
actor_id = self._actor_id(current_user)
auth_session_id = self._auth_session_id(current_user)
normalized_request_id = self._required_text(request_id, "预览签发请求 ID", 120)
values = safe_fact_snapshot(facts)
if not values.get("time") and not values.get("location") and not values.get("reason"):
raise ValueError("未识别到可签发的费用申请字段。")
existing = self.db.scalar(
select(AIApplicationPreviewDecision).where(
AIApplicationPreviewDecision.tenant_id == tenant_id,
AIApplicationPreviewDecision.actor_id == actor_id,
AIApplicationPreviewDecision.auth_session_id == auth_session_id,
AIApplicationPreviewDecision.issue_request_id == normalized_request_id,
)
)
if existing is not None:
snapshot_fingerprint = hmac_fingerprint(
values,
key_version=existing.fingerprint_key_version,
allow_key_creation=False,
)
if existing.snapshot_fingerprint != snapshot_fingerprint:
raise PreviewDecisionConflictError("同一预览签发请求不能对应不同内容。")
return IssuedPreviewDecision(existing, preview_response_fields(facts))
now = datetime.now(UTC)
source = decision_source or self._decision_source(facts)
snapshot_fingerprint = hmac_fingerprint(
values,
key_version=FINGERPRINT_KEY_VERSION,
)
decision = AIApplicationPreviewDecision(
tenant_id=tenant_id,
actor_id=actor_id,
auth_session_id=auth_session_id,
conversation_id=str(conversation_id or "").strip()[:120],
decision_type="expense_application_prefill",
decision_source=source,
field_keys_json=sorted(values),
field_fingerprints_json=field_fingerprints(
values,
key_version=FINGERPRINT_KEY_VERSION,
),
snapshot_fingerprint=snapshot_fingerprint,
fingerprint_key_version=FINGERPRINT_KEY_VERSION,
source_version_json={
"schema_version": 1,
"rule_name_fingerprint": hmac_fingerprint(
values.get("rule_name", ""),
key_version=FINGERPRINT_KEY_VERSION,
),
"rule_version_fingerprint": hmac_fingerprint(
values.get("rule_version", ""),
key_version=FINGERPRINT_KEY_VERSION,
),
},
status="issued",
issue_request_id=normalized_request_id,
expires_at=now + PREVIEW_DECISION_TTL,
)
self.db.add(decision)
self.db.flush()
return IssuedPreviewDecision(decision, preview_response_fields(facts))
def require_for_action(
self,
decision_id: str,
current_user: CurrentUserContext,
*,
conversation_id: str,
request_id: str,
action_type: str,
final_values: dict[str, Any],
) -> AIApplicationPreviewDecision:
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
actor_id = self._actor_id(current_user)
auth_session_id = self._auth_session_id(current_user)
normalized_id = self._required_text(decision_id, "预览决策 ID", 36)
normalized_request_id = self._required_text(request_id, "动作请求 ID", 120)
normalized_action = self._normalize_action(action_type)
decision = self.db.scalar(
select(AIApplicationPreviewDecision)
.where(
AIApplicationPreviewDecision.id == normalized_id,
AIApplicationPreviewDecision.tenant_id == tenant_id,
AIApplicationPreviewDecision.actor_id == actor_id,
AIApplicationPreviewDecision.auth_session_id == auth_session_id,
)
.with_for_update()
)
if decision is None:
raise ValueError("预览决策不存在或不属于当前登录会话。")
if decision.conversation_id != str(conversation_id or "").strip()[:120]:
raise ValueError("预览决策不属于当前会话。")
final_fingerprint = self._final_fingerprint(decision, final_values)
if decision.status == "consumed":
if (
decision.consumed_action == normalized_action
and decision.consumed_request_id == normalized_request_id
and decision.consumed_final_fingerprint == final_fingerprint
):
return decision
raise PreviewDecisionConflictError("该预览决策已被其他动作消费,请重新生成预览。")
if decision.status != "issued":
raise PreviewDecisionConflictError("该预览决策当前不可使用,请重新生成预览。")
if self._as_utc(decision.expires_at) <= datetime.now(UTC):
raise PreviewDecisionConflictError("该预览决策已过期,请重新生成预览。")
return decision
def reject_active_decision_bypass(
self,
current_user: CurrentUserContext,
*,
conversation_id: str,
) -> None:
"""同一登录会话已有有效签发时,不允许通过省略 decision_id 降级。"""
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
actor_id = self._actor_id(current_user)
auth_session_id = str(current_user.auth_session_id or "").strip()[:120]
# 旧调用没有会话标识,也不具备签发 decision_id 的前提,继续走非可信兼容路径。
if not auth_session_id:
return
normalized_conversation_id = str(conversation_id or "").strip()[:120]
active_decision = self.db.scalar(
select(AIApplicationPreviewDecision)
.where(
AIApplicationPreviewDecision.tenant_id == tenant_id,
AIApplicationPreviewDecision.actor_id == actor_id,
AIApplicationPreviewDecision.auth_session_id == auth_session_id,
AIApplicationPreviewDecision.conversation_id == normalized_conversation_id,
AIApplicationPreviewDecision.status == "issued",
AIApplicationPreviewDecision.expires_at > datetime.now(UTC),
)
.order_by(AIApplicationPreviewDecision.created_at.desc())
.with_for_update()
)
if active_decision is not None:
raise PreviewDecisionConflictError(
"当前会话已有服务端签发预览,动作必须携带 decision_id。"
)
def consume(
self,
decision: AIApplicationPreviewDecision,
*,
request_id: str,
action_type: str,
final_values: dict[str, Any],
claim: ExpenseClaim,
business_event: BusinessEvent,
) -> None:
normalized_request_id = self._required_text(request_id, "动作请求 ID", 120)
normalized_action = self._normalize_action(action_type)
final_fingerprint = self._final_fingerprint(decision, final_values)
if decision.status == "consumed":
if (
decision.consumed_action == normalized_action
and decision.consumed_request_id == normalized_request_id
and decision.consumed_claim_id == claim.id
and decision.consumed_business_event_id == business_event.id
and decision.consumed_final_fingerprint == final_fingerprint
):
return
raise PreviewDecisionConflictError("该预览决策已被消费。")
if decision.status != "issued":
raise PreviewDecisionConflictError("该预览决策当前不可消费。")
decision.status = "consumed"
decision.consumed_action = normalized_action
decision.consumed_request_id = normalized_request_id
decision.consumed_claim_id = claim.id
decision.consumed_business_event_id = business_event.id
decision.consumed_final_fingerprint = final_fingerprint
decision.consumed_at = datetime.now(UTC)
self.db.flush()
@staticmethod
def _final_fingerprint(
decision: AIApplicationPreviewDecision,
final_values: dict[str, Any],
) -> str:
return hmac_fingerprint(
safe_fact_snapshot(final_values),
key_version=decision.fingerprint_key_version,
allow_key_creation=False,
)
@staticmethod
def _decision_source(facts: dict[str, Any]) -> str:
has_rule = bool(str(facts.get("rule_version") or "").strip())
has_transport_estimate = bool(str(facts.get("transport_estimate_source") or "").strip())
if has_rule and has_transport_estimate:
return "hybrid"
if has_rule:
return "rule"
return "heuristic"
@staticmethod
def _actor_id(current_user: CurrentUserContext) -> str:
return str(current_user.employee_id or current_user.username or "").strip()[:120]
@staticmethod
def _auth_session_id(current_user: CurrentUserContext) -> str:
session_id = str(current_user.auth_session_id or "").strip()[:120]
if not session_id:
raise ValueError("当前登录会话缺少可验证的会话标识,请重新登录。")
return session_id
@staticmethod
def _required_text(value: object, label: str, limit: int) -> str:
normalized = str(value or "").strip()[:limit]
if not normalized:
raise ValueError(f"{label}不能为空。")
return normalized
@staticmethod
def _normalize_action(action_type: str) -> str:
normalized = str(action_type or "").strip().lower()
if normalized not in {"save_draft", "submit"}:
raise ValueError("不支持的预览决策动作。")
return normalized
@staticmethod
def _as_utc(value: datetime) -> datetime:
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Any
from app.api.deps import CurrentUserContext
def build_trusted_expense_application_context(
current_user: CurrentUserContext,
base_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
context_json = dict(base_context or {})
context_json.setdefault("session_type", "application")
context_json.setdefault("entry_source", "workbench_ai_inline")
context_json.setdefault("document_type", "expense_application")
context_json.setdefault("application_stage", "expense_application")
# 身份与权限字段必须覆盖请求体中的同名值。
context_json.update(
{
"tenant_id": current_user.tenant_id,
"role_codes": current_user.role_codes,
"is_admin": current_user.is_admin,
"username": current_user.username,
"name": current_user.name,
"department_name": current_user.department_name,
"position": current_user.position,
"grade": current_user.grade,
"employee_no": current_user.employee_no,
"manager_name": current_user.manager_name,
}
)
return context_json

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
import hashlib
import hmac
import json
from typing import Any
from app.core.expense_application_fingerprint_keys import (
ACTIVE_FINGERPRINT_KEY_VERSION,
get_expense_application_fingerprint_key,
)
FINGERPRINT_KEY_VERSION = ACTIVE_FINGERPRINT_KEY_VERSION
FACT_KEYS = (
"application_type",
"time",
"location",
"reason",
"days",
"transport_mode",
"amount",
"grade",
"department",
"position",
"manager_name",
"lodging_daily_cap",
"subsidy_daily_cap",
"transport_policy",
"policy_estimate",
"matched_city",
"rule_name",
"rule_version",
"hotel_amount",
"allowance_amount",
"transport_estimated_amount",
"transport_estimate_source",
"transport_estimate_confidence",
"policy_total_amount",
)
PREVIEW_FIELD_ALIASES = {
"applicationType": "application_type",
"application_type": "application_type",
"time": "time",
"time_return": "time",
"location": "location",
"reason": "reason",
"days": "days",
"transportMode": "transport_mode",
"transport_mode": "transport_mode",
"amount": "amount",
"grade": "grade",
"department": "department",
"position": "position",
"managerName": "manager_name",
"manager_name": "manager_name",
}
PREVIEW_RESPONSE_KEYS = {
"application_type": "applicationType",
"time": "time",
"location": "location",
"reason": "reason",
"days": "days",
"transport_mode": "transportMode",
"amount": "amount",
"grade": "grade",
"department": "department",
"position": "position",
"manager_name": "managerName",
"lodging_daily_cap": "lodgingDailyCap",
"subsidy_daily_cap": "subsidyDailyCap",
"transport_policy": "transportPolicy",
"policy_estimate": "policyEstimate",
"matched_city": "matchedCity",
"rule_name": "ruleName",
"rule_version": "ruleVersion",
"hotel_amount": "hotelAmount",
"allowance_amount": "allowanceAmount",
"transport_estimated_amount": "transportEstimatedAmount",
"transport_estimate_source": "transportEstimateSource",
"transport_estimate_confidence": "transportEstimateConfidence",
"policy_total_amount": "policyTotalAmount",
}
def safe_text(value: object, limit: int = 500) -> str:
return str(value or "").strip()[:limit]
def safe_fact_snapshot(facts: dict[str, Any]) -> dict[str, str]:
return {key: value for key in FACT_KEYS if (value := safe_text(facts.get(key)))}
def canonical_preview_fields(preview: dict[str, Any]) -> dict[str, str]:
fields = preview.get("fields") if isinstance(preview, dict) else None
if not isinstance(fields, dict):
return {}
values: dict[str, str] = {}
for alias, field_key in PREVIEW_FIELD_ALIASES.items():
value = safe_text(fields.get(alias))
if value:
values[field_key] = value
return values
def preview_response_fields(facts: dict[str, Any]) -> dict[str, str]:
snapshot = safe_fact_snapshot(facts)
return {
response_key: snapshot[field_key]
for field_key, response_key in PREVIEW_RESPONSE_KEYS.items()
if field_key in snapshot
}
def hmac_fingerprint(
payload: object,
*,
key_version: str = FINGERPRINT_KEY_VERSION,
allow_key_creation: bool = True,
) -> str:
serialized = _serialize(payload)
secret_key = get_expense_application_fingerprint_key(
key_version,
create=allow_key_creation,
)
digest = hmac.new(
secret_key,
b"expense-application-preview:v1:" + serialized,
hashlib.sha256,
).hexdigest()
return f"hmac-sha256:{digest}"
def content_fingerprint(payload: object) -> str:
return f"sha256:{hashlib.sha256(_serialize(payload)).hexdigest()}"
def field_value_fingerprint(
value: str,
*,
present: bool,
key_version: str = FINGERPRINT_KEY_VERSION,
allow_key_creation: bool = True,
) -> str:
return hmac_fingerprint(
{"present": present, "value": value if present else ""},
key_version=key_version,
allow_key_creation=allow_key_creation,
)
def field_fingerprints(
values: dict[str, str],
*,
key_version: str = FINGERPRINT_KEY_VERSION,
allow_key_creation: bool = True,
) -> dict[str, str]:
return {
key: field_value_fingerprint(
value,
present=True,
key_version=key_version,
allow_key_creation=allow_key_creation,
)
for key, value in sorted(values.items())
}
def snapshot_reference(
values: dict[str, str],
*,
key_version: str = FINGERPRINT_KEY_VERSION,
allow_key_creation: bool = True,
) -> dict[str, Any]:
return {
"field_keys": sorted(values),
"value_fingerprint": hmac_fingerprint(
values,
key_version=key_version,
allow_key_creation=allow_key_creation,
),
"fingerprint_key_version": key_version,
}
def _serialize(payload: object) -> bytes:
return json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")

View File

@@ -8,6 +8,7 @@ from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.expense_case import BusinessEvent from app.models.expense_case import BusinessEvent
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
from app.schemas.reimbursement import TravelReimbursementCalculatorRequest from app.schemas.reimbursement import TravelReimbursementCalculatorRequest
@@ -646,6 +647,8 @@ class UserAgentApplicationPersistenceMixin:
*, *,
action_type: str, action_type: str,
business_event: BusinessEvent, business_event: BusinessEvent,
preview_decision: AIApplicationPreviewDecision | None = None,
action_request_id: str = "",
) -> None: ) -> None:
if trusted_current_user is None: if trusted_current_user is None:
return return
@@ -656,6 +659,8 @@ class UserAgentApplicationPersistenceMixin:
trusted_current_user, trusted_current_user,
action_type=action_type, action_type=action_type,
business_event=business_event, business_event=business_event,
preview_decision=preview_decision,
action_request_id=action_request_id,
) )
@staticmethod @staticmethod
@@ -727,6 +732,8 @@ class UserAgentApplicationPersistenceMixin:
*, *,
submit: bool, submit: bool,
learning_current_user: CurrentUserContext | None = None, learning_current_user: CurrentUserContext | None = None,
learning_preview_decision: AIApplicationPreviewDecision | None = None,
learning_action_request_id: str = "",
) -> ExpenseClaim: ) -> ExpenseClaim:
current_user = self._build_application_current_user(payload) current_user = self._build_application_current_user(payload)
previous_status = str(claim.status or "").strip() previous_status = str(claim.status or "").strip()
@@ -771,6 +778,8 @@ class UserAgentApplicationPersistenceMixin:
learning_current_user, learning_current_user,
action_type="save_draft", action_type="save_draft",
business_event=draft_event, business_event=draft_event,
preview_decision=learning_preview_decision,
action_request_id=learning_action_request_id,
) )
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
@@ -794,6 +803,8 @@ class UserAgentApplicationPersistenceMixin:
learning_current_user, learning_current_user,
action_type="submit", action_type="submit",
business_event=event, business_event=event,
preview_decision=learning_preview_decision,
action_request_id=learning_action_request_id,
) )
), ),
) )
@@ -811,6 +822,8 @@ class UserAgentApplicationPersistenceMixin:
*, *,
submit: bool, submit: bool,
learning_current_user: CurrentUserContext | None = None, learning_current_user: CurrentUserContext | None = None,
learning_preview_decision: AIApplicationPreviewDecision | None = None,
learning_action_request_id: str = "",
) -> ExpenseClaim: ) -> ExpenseClaim:
current_user = self._build_application_current_user(payload) current_user = self._build_application_current_user(payload)
access_policy = ExpenseClaimAccessPolicy(self.db) access_policy = ExpenseClaimAccessPolicy(self.db)
@@ -885,6 +898,8 @@ class UserAgentApplicationPersistenceMixin:
learning_current_user, learning_current_user,
action_type="save_draft", action_type="save_draft",
business_event=draft_event, business_event=draft_event,
preview_decision=learning_preview_decision,
action_request_id=learning_action_request_id,
) )
self.db.commit() self.db.commit()
self.db.refresh(claim) self.db.refresh(claim)
@@ -904,6 +919,8 @@ class UserAgentApplicationPersistenceMixin:
learning_current_user, learning_current_user,
action_type="submit", action_type="submit",
business_event=event, business_event=event,
preview_decision=learning_preview_decision,
action_request_id=learning_action_request_id,
) )
), ),
) )
@@ -1286,6 +1303,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
*, *,
risk_flags: list[str], risk_flags: list[str],
learning_current_user: CurrentUserContext | None = None, learning_current_user: CurrentUserContext | None = None,
learning_preview_decision: AIApplicationPreviewDecision | None = None,
learning_action_request_id: str = "",
) -> UserAgentResponse: ) -> UserAgentResponse:
facts = self._resolve_expense_application_facts(payload) facts = self._resolve_expense_application_facts(payload)
step = self._resolve_expense_application_step(payload, facts) step = self._resolve_expense_application_step(payload, facts)
@@ -1299,6 +1318,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
editable_claim, editable_claim,
submit=step == "submitted", submit=step == "submitted",
learning_current_user=learning_current_user, learning_current_user=learning_current_user,
learning_preview_decision=learning_preview_decision,
learning_action_request_id=learning_action_request_id,
) )
facts["application_edit_mode"] = "true" facts["application_edit_mode"] = "true"
elif step == "submitted": elif step == "submitted":
@@ -1312,6 +1333,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
facts, facts,
submit=True, submit=True,
learning_current_user=learning_current_user, learning_current_user=learning_current_user,
learning_preview_decision=learning_preview_decision,
learning_action_request_id=learning_action_request_id,
) )
else: else:
application_claim = self._create_expense_application_record( application_claim = self._create_expense_application_record(
@@ -1319,6 +1342,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat
facts, facts,
submit=False, submit=False,
learning_current_user=learning_current_user, learning_current_user=learning_current_user,
learning_preview_decision=learning_preview_decision,
learning_action_request_id=learning_action_request_id,
) )
if application_claim is not None: if application_claim is not None:
facts["application_no"] = application_claim.claim_no facts["application_no"] = application_claim.claim_no

View File

@@ -24,6 +24,8 @@ def _read_test_user_headers(
employee_no: Annotated[str | None, Header(alias="X-Auth-Employee-No")] = None, employee_no: Annotated[str | None, Header(alias="X-Auth-Employee-No")] = None,
manager_name: Annotated[str | None, Header(alias="X-Auth-Manager-Name")] = None, manager_name: Annotated[str | None, Header(alias="X-Auth-Manager-Name")] = None,
tenant_id: Annotated[str | None, Header(alias="X-Auth-Tenant-Id")] = None, tenant_id: Annotated[str | None, Header(alias="X-Auth-Tenant-Id")] = None,
employee_id: Annotated[str | None, Header(alias="X-Auth-Employee-Id")] = None,
auth_session_id: Annotated[str | None, Header(alias="X-Auth-Session-Id")] = None,
) -> CurrentUserContext: ) -> CurrentUserContext:
normalized_username = str(username or "").strip() normalized_username = str(username or "").strip()
normalized_name = str(name or normalized_username).strip() normalized_name = str(name or normalized_username).strip()
@@ -52,4 +54,6 @@ def _read_test_user_headers(
grade=str(grade or "").strip(), grade=str(grade or "").strip(),
employee_no=str(employee_no or "").strip(), employee_no=str(employee_no or "").strip(),
manager_name=str(manager_name or "").strip(), manager_name=str(manager_name or "").strip(),
employee_id=str(employee_id or "").strip(),
auth_session_id=str(auth_session_id or "").strip(),
) )

View File

@@ -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() MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip()
LEGACY_PROBE_TABLE = "legacy_migration_probe_records" LEGACY_PROBE_TABLE = "legacy_migration_probe_records"
HEAD_REVISION = "20260714_0003" HEAD_REVISION = "20260714_0004"
SERVER_DIR = Path(__file__).resolve().parents[1] SERVER_DIR = Path(__file__).resolve().parents[1]
ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini" ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini"
@@ -214,6 +214,18 @@ def _assert_head_schema(engine: Engine) -> None:
"uq_ai_decisions_tenant_idempotency", "uq_ai_decisions_tenant_idempotency",
("tenant_id", "idempotency_key"), ("tenant_id", "idempotency_key"),
) )
_assert_unique_constraint(
engine,
"ai_decisions",
"uq_ai_decisions_tenant_preview_decision",
("tenant_id", "preview_decision_id"),
)
_assert_unique_constraint(
engine,
"ai_application_preview_decisions",
"uq_ai_application_preview_decisions_issue_request",
("tenant_id", "actor_id", "auth_session_id", "issue_request_id"),
)
_assert_unique_constraint( _assert_unique_constraint(
engine, engine,
"ai_decision_feedback", "ai_decision_feedback",
@@ -265,6 +277,23 @@ def _assert_head_schema(engine: Engine) -> None:
"ix_auth_sessions_tenant_username": ("tenant_id", "username"), "ix_auth_sessions_tenant_username": ("tenant_id", "username"),
}, },
) )
_assert_indexes(
engine,
"ai_application_preview_decisions",
{
"ix_ai_application_preview_decisions_actor_status_expiry": (
"tenant_id",
"actor_id",
"status",
"expires_at",
),
"ix_ai_application_preview_decisions_conversation": (
"tenant_id",
"conversation_id",
"created_at",
),
},
)
_assert_indexes( _assert_indexes(
engine, engine,
"ai_decisions", "ai_decisions",
@@ -305,6 +334,12 @@ def _assert_head_schema(engine: Engine) -> None:
) )
_assert_cascade_foreign_key(engine, "expense_case_links") _assert_cascade_foreign_key(engine, "expense_case_links")
_assert_cascade_foreign_key(engine, "business_events") _assert_cascade_foreign_key(engine, "business_events")
_assert_composite_foreign_key(
engine,
"ai_decisions",
("tenant_id", "preview_decision_id"),
"ai_application_preview_decisions",
)
_assert_composite_foreign_key( _assert_composite_foreign_key(
engine, engine,
"ai_decisions", "ai_decisions",

View File

@@ -0,0 +1,420 @@
from __future__ import annotations
import stat
from collections.abc import Generator
from pathlib import Path
import pytest
from auth_helpers import install_legacy_header_auth_override
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.core import expense_application_fingerprint_keys as fingerprint_keys
from app.db.base import Base
from app.main import create_app
from app.models.ai_application_preview import AIApplicationPreviewDecision
from app.models.ai_learning import AIDecision, AIDecisionFeedback
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.models.role import Role
from app.services.expense_application_preview_decisions import (
ExpenseApplicationPreviewDecisionService,
)
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
Base.metadata.create_all(bind=engine)
app = create_app()
install_legacy_header_auth_override(app)
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def seed_employee(db: Session) -> None:
role = Role(id="role-preview-user", role_code="user", name="员工")
employee = Employee(
id="employee-preview-owner",
employee_no="E-PREVIEW-001",
name="张三",
email="preview-owner@example.com",
position="实施顾问",
grade="P4",
roles=[role],
)
db.add_all([role, employee])
db.commit()
def auth_headers(*, session_id: str = "session-preview-owner") -> dict[str, str]:
return {
"X-Auth-Username": "preview-owner@example.com",
"X-Auth-Name": "Zhang San",
"X-Auth-Employee-No": "E-PREVIEW-001",
"X-Auth-Employee-Id": "employee-preview-owner",
"X-Auth-Session-Id": session_id,
"X-Auth-Tenant-Id": "tenant-preview",
"X-Auth-Grade": "P4",
"X-Auth-Role-Codes": "user",
}
def issue_preview(
client: TestClient,
*,
request_id: str = "issue-preview-1",
include_transport: bool = True,
) -> dict:
transport_line = "出行方式:火车\n" if include_transport else ""
response = client.post(
"/api/v1/reimbursements/application-previews",
headers=auth_headers(),
json={
"message": (
"申请时间2026-07-20 至 2026-07-22\n"
"地点:上海\n事由:客户现场实施\n天数3天\n"
f"{transport_line}申请金额1800元"
),
"conversation_id": "conversation-preview-1",
"request_id": request_id,
},
)
assert response.status_code == 201, response.text
return response.json()
def build_action_payload(issued: dict, *, reason: str = "客户现场实施") -> dict:
fields = {
**issued["application_preview"]["fields"],
"reason": reason,
}
return {
"source": "user_message",
"user_id": "forged-user@example.com",
"conversation_id": "conversation-preview-1",
"action_type": "save_draft",
"decision_id": issued["decision_id"],
"request_id": "action-preview-save-1",
"message": (
"费用申请保存草稿\n申请时间2026-07-20 至 2026-07-22\n"
f"地点:上海\n事由:{reason}\n申请金额1800元\n保存草稿"
),
"context_json": {
"application_action": "save_draft",
"application_save_mode": True,
"application_preview": {
"modelReviewStatus": "server_registered",
"fields": fields,
},
},
}
def build_legacy_action_payload() -> dict:
return build_action_payload(
{
"decision_id": "legacy-placeholder",
"application_preview": {
"fields": {
"time": "2026-07-20 至 2026-07-22",
"location": "上海",
"reason": "客户现场实施",
"days": "3天",
"transportMode": "火车",
"amount": "1800元",
"grade": "P4",
}
},
}
)
def test_server_preview_decision_is_consumed_with_verified_feedback() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client)
action_payload = build_action_payload(issued)
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=action_payload,
)
assert response.status_code == 200, response.text
result = response.json()["result"]
assert result["draft_payload"]["claim_id"]
assert result["decision_id"]
assert result["decision_id"] != issued["decision_id"]
retry_response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=action_payload,
)
assert retry_response.status_code == 200, retry_response.text
assert (
retry_response.json()["result"]["draft_payload"]["claim_id"]
== result["draft_payload"]["claim_id"]
)
assert retry_response.json()["result"]["decision_id"] == result["decision_id"]
with session_factory() as db:
consumed = db.get(AIApplicationPreviewDecision, issued["decision_id"])
next_decision = db.get(AIApplicationPreviewDecision, result["decision_id"])
decision = db.scalar(select(AIDecision))
feedback = db.scalar(select(AIDecisionFeedback))
assert consumed is not None
assert consumed.status == "consumed"
assert consumed.consumed_action == "save_draft"
assert consumed.field_fingerprints_json
assert "客户现场实施" not in str(consumed.field_fingerprints_json)
assert next_decision is not None
assert next_decision.status == "issued"
assert next_decision.decision_source == "server_draft"
assert decision is not None
assert decision.preview_decision_id == consumed.id
assert decision.suggestion_json["value_fingerprint"].startswith("hmac-sha256:")
assert feedback is not None
assert feedback.verification_status == "server_verified"
assert feedback.feedback_type == "accepted"
assert feedback.training_eligible is False
assert len(list(db.scalars(select(AIDecision)).all())) == 1
def test_server_preview_decision_detects_server_side_field_edit() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-edited")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=build_action_payload(issued, reason="客户现场验收"),
)
assert response.status_code == 200, response.text
with session_factory() as db:
feedback = db.scalar(select(AIDecisionFeedback))
assert feedback is not None
assert feedback.feedback_type == "edited"
assert feedback.verification_status == "server_verified"
assert feedback.changed_fields_json == [
{
"field_key": "reason",
"suggested_value_fingerprint": feedback.changed_fields_json[0][
"suggested_value_fingerprint"
],
"final_value_fingerprint": feedback.changed_fields_json[0][
"final_value_fingerprint"
],
}
]
assert "客户现场" not in str(feedback.changed_fields_json)
def test_server_preview_decision_detects_field_added_after_issuance() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(
client,
request_id="issue-preview-added-field",
include_transport=False,
)
payload = build_action_payload(issued)
payload["message"] = payload["message"].replace(
"申请金额1800元",
"出行方式:飞机\n申请金额1800元",
)
payload["context_json"]["application_preview"]["fields"]["transportMode"] = "飞机"
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=payload,
)
assert response.status_code == 200, response.text
with session_factory() as db:
feedback = db.scalar(select(AIDecisionFeedback))
assert feedback is not None
assert feedback.feedback_type == "edited"
assert {item["field_key"] for item in feedback.changed_fields_json} >= {"transport_mode"}
def test_server_preview_decision_rejects_cross_session_replay_without_writes() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-cross-session")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(session_id="another-session"),
json=build_action_payload(issued),
)
assert response.status_code == 400
assert "不属于当前登录会话" in response.json()["detail"]
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
assert decision.status == "issued"
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(AIDecision)).all()) == []
def test_active_server_preview_cannot_downgrade_by_omitting_decision_id() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-downgrade")
payload = build_action_payload(issued)
payload.pop("decision_id")
payload.pop("request_id")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=payload,
)
assert response.status_code == 409, response.text
assert "必须携带 decision_id" in response.json()["detail"]
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
assert decision.status == "issued"
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(AIDecision)).all()) == []
def test_legacy_action_without_active_server_decision_keeps_client_observed_path() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
payload = build_legacy_action_payload()
payload.pop("decision_id")
payload.pop("request_id")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=payload,
)
assert response.status_code == 200, response.text
with session_factory() as db:
feedback = db.scalar(select(AIDecisionFeedback))
assert feedback is not None
assert feedback.verification_status == "client_observed"
assert feedback.training_eligible is False
def test_missing_fingerprint_key_rejects_action_without_business_writes() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
issued = issue_preview(client, request_id="issue-preview-missing-key")
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
decision.fingerprint_key_version = "missing-test-key"
db.commit()
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=build_action_payload(issued),
)
assert response.status_code == 400, response.text
assert "密钥版本不可用" in response.json()["detail"]
with session_factory() as db:
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert decision is not None
assert decision.status == "issued"
assert list(db.scalars(select(ExpenseClaim)).all()) == []
assert list(db.scalars(select(AIDecision)).all()) == []
def test_draft_save_stays_successful_when_next_decision_issuance_fails(
monkeypatch: pytest.MonkeyPatch,
) -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
original_issue = ExpenseApplicationPreviewDecisionService.issue
def fail_server_draft_issue(
service: ExpenseApplicationPreviewDecisionService,
*args: object,
**kwargs: object,
) -> object:
if kwargs.get("decision_source") == "server_draft":
raise RuntimeError("simulated next decision failure")
return original_issue(service, *args, **kwargs)
monkeypatch.setattr(
ExpenseApplicationPreviewDecisionService,
"issue",
fail_server_draft_issue,
)
issued = issue_preview(client, request_id="issue-preview-next-failure")
response = client.post(
"/api/v1/reimbursements/application-preview-action",
headers=auth_headers(),
json=build_action_payload(issued),
)
assert response.status_code == 200, response.text
assert response.json()["result"]["draft_payload"]["claim_id"]
assert response.json()["result"]["decision_id"] is None
with session_factory() as db:
consumed = db.get(AIApplicationPreviewDecision, issued["decision_id"])
assert consumed is not None
assert consumed.status == "consumed"
assert len(list(db.scalars(select(ExpenseClaim)).all())) == 1
assert len(list(db.scalars(select(AIDecision)).all())) == 1
def test_fingerprint_key_is_created_with_private_permissions(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
key_directory = tmp_path / "fingerprint-keys"
monkeypatch.setattr(
fingerprint_keys,
"FINGERPRINT_KEY_DIRECTORY",
key_directory,
)
key = fingerprint_keys.get_expense_application_fingerprint_key(
"permission-test",
create=True,
)
key_path = key_directory / "permission-test.key"
assert len(key) == fingerprint_keys.KEY_BYTES
assert stat.S_IMODE(key_directory.stat().st_mode) == 0o700
assert stat.S_IMODE(key_path.stat().st_mode) == 0o600

View File

@@ -48,7 +48,7 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng
@pytest.mark.parametrize( @pytest.mark.parametrize(
"owned_table", "owned_table",
sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"]), sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"]),
) )
def test_unversioned_database_with_any_migration_owned_table_is_rejected( def test_unversioned_database_with_any_migration_owned_table_is_rejected(
engine: Engine, engine: Engine,
@@ -91,6 +91,11 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set(
"20260714_0003", "20260714_0003",
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"] - {"ai_decisions"}, MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"] - {"ai_decisions"},
), ),
(
"20260714_0004",
MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"]
- {"ai_application_preview_decisions"},
),
], ],
) )
def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected( def test_known_revision_with_missing_or_unexpected_owned_tables_is_rejected(

View File

@@ -16,6 +16,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None:
assert MIGRATION_OWNED_TABLES == frozenset( assert MIGRATION_OWNED_TABLES == frozenset(
{ {
"ai_application_preview_decisions",
"ai_decision_feedback", "ai_decision_feedback",
"ai_decisions", "ai_decisions",
"auth_sessions", "auth_sessions",

View File

@@ -441,14 +441,14 @@ def test_ai_application_draft_records_explicit_field_correction() -> None:
assert decision is not None assert decision is not None
assert decision.training_eligible is False assert decision.training_eligible is False
assert decision.suggestion_json["field_keys"] assert decision.suggestion_json["field_keys"]
assert decision.suggestion_json["value_fingerprint"].startswith("sha256:") assert decision.suggestion_json["value_fingerprint"].startswith("hmac-sha256:")
assert feedback is not None assert feedback is not None
assert feedback.feedback_type == "edited" assert feedback.feedback_type == "edited"
assert feedback.verification_status == "client_observed" assert feedback.verification_status == "client_observed"
assert feedback.training_eligible is False assert feedback.training_eligible is False
assert feedback.changed_fields_json[0]["field_key"] == "reason" assert feedback.changed_fields_json[0]["field_key"] == "reason"
assert feedback.changed_fields_json[0]["suggested_value_fingerprint"].startswith( assert feedback.changed_fields_json[0]["suggested_value_fingerprint"].startswith(
"sha256:" "hmac-sha256:"
) )
assert "客户拜访" not in str(feedback.changed_fields_json) assert "客户拜访" not in str(feedback.changed_fields_json)
assert "客户现场实施" not in str(feedback.final_value_json) assert "客户现场实施" not in str(feedback.final_value_json)

View File

@@ -13,6 +13,8 @@ import { fetchExpenseClaims } from '../../services/reimbursements.js'
import { import {
AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT, AI_APPLICATION_ACTION_SUBMIT,
createAiApplicationRequestId,
registerAiApplicationPreviewDecision,
runAiApplicationPreviewAction runAiApplicationPreviewAction
} from '../../services/aiApplicationPreviewActions.js' } from '../../services/aiApplicationPreviewActions.js'
import { import {
@@ -374,9 +376,20 @@ export function useWorkbenchAiApplicationPreviewFlow({
return false return false
} }
const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview) let normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview)
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim() const userText = String(options.userText || (isSubmit ? '直接提交' : '保存草稿')).trim()
const actionRequestId = String(
normalizedPreview.decisionActionRequestId || createAiApplicationRequestId('action')
).trim()
if (normalizedPreview.decisionId) {
normalizedPreview = normalizeApplicationPreview({
...normalizedPreview,
decisionActionRequestId: actionRequestId
})
targetMessage.applicationPreview = normalizedPreview
persistCurrentConversation()
}
if (isSubmit && !normalizedPreview.readyToSubmit) { if (isSubmit && !normalizedPreview.readyToSubmit) {
if (!options.skipUserMessage) { if (!options.skipUserMessage) {
@@ -454,12 +467,27 @@ export function useWorkbenchAiApplicationPreviewFlow({
applicationPreview: normalizedPreview, applicationPreview: normalizedPreview,
currentUser: currentUser.value || {}, currentUser: currentUser.value || {},
conversationId: conversationId.value, conversationId: conversationId.value,
draftPayload: targetMessage.draftPayload || options.draftPayload || null draftPayload: targetMessage.draftPayload || options.draftPayload || null,
requestId: actionRequestId
}) })
const draftPayload = extractInlineApplicationDraftPayload(payload) const draftPayload = extractInlineApplicationDraftPayload(payload)
if (draftPayload) { if (draftPayload) {
targetMessage.draftPayload = draftPayload targetMessage.draftPayload = draftPayload
} }
const nextDecisionId = String(payload?.result?.decision_id || '').trim()
targetMessage.applicationPreview = normalizeApplicationPreview({
...(targetMessage.applicationPreview || normalizedPreview),
...(nextDecisionId
? {
decisionId: nextDecisionId,
decisionExpiresAt: String(payload?.result?.decision_expires_at || '').trim(),
decisionTrackingStatus: 'registered'
}
: {
decisionTrackingStatus: normalizedPreview.decisionId ? 'consumed' : 'legacy'
}),
decisionActionRequestId: ''
})
targetMessage.suggestedActions = [] targetMessage.suggestedActions = []
const detailActions = buildInlineApplicationDetailAction(draftPayload) const detailActions = buildInlineApplicationDetailAction(draftPayload)
const nextTaskAction = buildApplicationPreviewNextTaskAction(targetMessage) const nextTaskAction = buildApplicationPreviewNextTaskAction(targetMessage)
@@ -598,11 +626,26 @@ export function useWorkbenchAiApplicationPreviewFlow({
ontologyFields: options.ontologyFields ontologyFields: options.ontologyFields
}) })
) )
const content = buildLocalApplicationPreviewMessage(preview) let registeredPreview = preview
try {
registeredPreview = await registerAiApplicationPreviewDecision({
applicationPreview: preview,
conversationId: conversationId.value,
message: previewSourceText,
requestId: createAiApplicationRequestId('preview')
})
} catch (error) {
console.warn('Application preview decision registration failed:', error)
registeredPreview = normalizeApplicationPreview({
...preview,
decisionTrackingStatus: 'degraded'
})
}
const content = buildLocalApplicationPreviewMessage(registeredPreview)
const previewMessage = createInlineMessage('assistant', content, { const previewMessage = createInlineMessage('assistant', content, {
id: pendingMessage.id, id: pendingMessage.id,
applicationPreview: preview, applicationPreview: registeredPreview,
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview), suggestedActions: buildInlineApplicationPreviewSuggestedActions(registeredPreview),
requestedSubmit: Boolean(options.requestedSubmit), requestedSubmit: Boolean(options.requestedSubmit),
submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation), submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation),
stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [], stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [],

View File

@@ -8,6 +8,54 @@ import {
export const AI_APPLICATION_ACTION_SAVE_DRAFT = 'ai_application_save_draft' export const AI_APPLICATION_ACTION_SAVE_DRAFT = 'ai_application_save_draft'
export const AI_APPLICATION_ACTION_SUBMIT = 'ai_application_submit' export const AI_APPLICATION_ACTION_SUBMIT = 'ai_application_submit'
export function createAiApplicationRequestId(prefix = 'application') {
const normalizedPrefix = normalizeText(prefix) || 'application'
if (typeof globalThis.crypto?.randomUUID === 'function') {
return `${normalizedPrefix}:${globalThis.crypto.randomUUID()}`
}
return `${normalizedPrefix}:${Date.now()}:${Math.random().toString(16).slice(2)}`
}
export function resolveAiApplicationPreviewDecisionId(preview = {}) {
return normalizeText(preview?.decisionId || preview?.decision_id)
}
export async function registerAiApplicationPreviewDecision({
applicationPreview,
conversationId = '',
message = '',
requestId = ''
} = {}, options = {}) {
const normalizedPreview = normalizeApplicationPreview(applicationPreview || {})
const issueRequestId = normalizeText(requestId) || createAiApplicationRequestId('preview')
const payload = await apiRequest('/reimbursements/application-previews', {
method: 'POST',
body: JSON.stringify({
message: normalizeText(message),
conversation_id: normalizeText(conversationId) || null,
request_id: issueRequestId
}),
timeoutMs: 30000,
timeoutMessage: '申请预览校验超时,已切换为普通核对模式。',
...options
})
const serverPreview = payload?.application_preview && typeof payload.application_preview === 'object'
? payload.application_preview
: {}
return normalizeApplicationPreview({
...normalizedPreview,
...serverPreview,
fields: {
...(normalizedPreview.fields || {}),
...(serverPreview.fields || {})
},
decisionId: normalizeText(payload?.decision_id || serverPreview.decisionId),
decisionSource: normalizeText(payload?.decision_source || serverPreview.decisionSource),
decisionExpiresAt: normalizeText(payload?.expires_at || serverPreview.decisionExpiresAt),
decisionTrackingStatus: 'registered'
})
}
function normalizeText(value) { function normalizeText(value) {
return String(value || '').trim() return String(value || '').trim()
} }
@@ -56,7 +104,8 @@ export function buildAiApplicationPreviewActionPayload({
applicationPreview, applicationPreview,
currentUser = {}, currentUser = {},
conversationId = '', conversationId = '',
draftPayload = null draftPayload = null,
requestId = ''
} = {}) { } = {}) {
const normalizedPreview = normalizeApplicationPreview(applicationPreview || {}) const normalizedPreview = normalizeApplicationPreview(applicationPreview || {})
const message = buildAiApplicationPreviewActionText(actionType, normalizedPreview) const message = buildAiApplicationPreviewActionText(actionType, normalizedPreview)
@@ -72,6 +121,8 @@ export function buildAiApplicationPreviewActionPayload({
: [] : []
const draftClaimId = normalizeText(draftPayload?.claim_id || draftPayload?.claimId) const draftClaimId = normalizeText(draftPayload?.claim_id || draftPayload?.claimId)
const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT
const decisionId = resolveAiApplicationPreviewDecisionId(normalizedPreview)
const actionRequestId = normalizeText(requestId || normalizedPreview.decisionActionRequestId)
const applicationEditableFields = Array.isArray(normalizedPreview.editableFields) const applicationEditableFields = Array.isArray(normalizedPreview.editableFields)
? normalizedPreview.editableFields.map((field) => normalizeText(field)).filter(Boolean) ? normalizedPreview.editableFields.map((field) => normalizeText(field)).filter(Boolean)
: [] : []
@@ -80,6 +131,9 @@ export function buildAiApplicationPreviewActionPayload({
source: 'user_message', source: 'user_message',
user_id: username, user_id: username,
conversation_id: normalizeText(conversationId) || null, conversation_id: normalizeText(conversationId) || null,
action_type: isSubmit ? 'submit' : 'save_draft',
...(decisionId ? { decision_id: decisionId } : {}),
...(decisionId && actionRequestId ? { request_id: actionRequestId } : {}),
message, message,
context_json: { context_json: {
role_codes: roleCodes, role_codes: roleCodes,

View File

@@ -257,6 +257,15 @@ function normalizeMessage(message = {}) {
content: safeString(message.content), content: safeString(message.content),
pending: false, pending: false,
feedback: safeString(message.feedback), feedback: safeString(message.feedback),
text: safeString(message.text || message.content),
applicationPreview: message.applicationPreview && typeof message.applicationPreview === 'object'
? message.applicationPreview
: null,
draftPayload: message.draftPayload && typeof message.draftPayload === 'object'
? message.draftPayload
: null,
requestedSubmit: Boolean(message.requestedSubmit),
submitRequiresConfirmation: Boolean(message.submitRequiresConfirmation),
stewardPlan: message.stewardPlan && typeof message.stewardPlan === 'object' stewardPlan: message.stewardPlan && typeof message.stewardPlan === 'object'
? { ? {
...message.stewardPlan, ...message.stewardPlan,

View File

@@ -3,6 +3,7 @@ import assert from 'node:assert/strict'
import { import {
AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT, AI_APPLICATION_ACTION_SUBMIT,
registerAiApplicationPreviewDecision,
runAiApplicationPreviewAction runAiApplicationPreviewAction
} from '../src/services/aiApplicationPreviewActions.js' } from '../src/services/aiApplicationPreviewActions.js'
@@ -34,6 +35,7 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
await runAiApplicationPreviewAction({ await runAiApplicationPreviewAction({
actionType: AI_APPLICATION_ACTION_SUBMIT, actionType: AI_APPLICATION_ACTION_SUBMIT,
applicationPreview: { applicationPreview: {
decisionId: 'decision-fast-submit',
fields: { fields: {
applicationType: '差旅费用申请', applicationType: '差旅费用申请',
time: '2026-07-01 至 2026-07-03', time: '2026-07-01 至 2026-07-03',
@@ -55,7 +57,8 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
} }
}, },
currentUser: { username: 'zhangsan@example.com', name: '张三' }, currentUser: { username: 'zhangsan@example.com', name: '张三' },
conversationId: 'conversation-fast-submit' conversationId: 'conversation-fast-submit',
requestId: 'action-fast-submit'
}) })
assert.equal(capturedUrl, '/api/v1/reimbursements/application-preview-action') assert.equal(capturedUrl, '/api/v1/reimbursements/application-preview-action')
@@ -63,6 +66,9 @@ async function testSubmitActionUsesFastPreviewEndpoint() {
const body = JSON.parse(capturedOptions.body) const body = JSON.parse(capturedOptions.body)
assert.equal(body.context_json.session_type, 'application') assert.equal(body.context_json.session_type, 'application')
assert.equal(body.context_json.application_stage, 'expense_application') assert.equal(body.context_json.application_stage, 'expense_application')
assert.equal(body.action_type, 'submit')
assert.equal(body.decision_id, 'decision-fast-submit')
assert.equal(body.request_id, 'action-fast-submit')
assert.equal(body.context_json.application_preview.fields.transportMode, '火车') assert.equal(body.context_json.application_preview.fields.transportMode, '火车')
assert.deepEqual(body.context_json.application_preview.aiDecisionFeedback.changedFields, [ assert.deepEqual(body.context_json.application_preview.aiDecisionFeedback.changedFields, [
{ {
@@ -110,6 +116,59 @@ async function testSaveDraftActionUsesFastPreviewEndpoint() {
assert.equal(body.context_json.application_action, 'save_draft') assert.equal(body.context_json.application_action, 'save_draft')
assert.equal(body.context_json.application_save_mode, true) assert.equal(body.context_json.application_save_mode, true)
assert.equal(body.context_json.application_stage, 'expense_application') assert.equal(body.context_json.application_stage, 'expense_application')
assert.equal(body.action_type, 'save_draft')
assert.equal('decision_id' in body, false)
assert.equal('request_id' in body, false)
}
async function testRegistrationUsesServerCanonicalPreview() {
let capturedUrl = ''
let capturedOptions = null
global.fetch = async (url, options) => {
capturedUrl = String(url)
capturedOptions = options
return {
ok: true,
async json() {
return {
decision_id: 'decision-issued-1',
decision_source: 'rule',
expires_at: '2026-07-14T10:30:00Z',
application_preview: {
fields: {
reason: '服务端规范事由',
amount: '1800元'
}
}
}
}
}
}
const preview = await registerAiApplicationPreviewDecision({
applicationPreview: {
fields: {
reason: '客户端事由',
location: '上海'
}
},
conversationId: 'conversation-issued-1',
message: '去上海做客户现场实施',
requestId: 'preview-request-1'
})
assert.equal(capturedUrl, '/api/v1/reimbursements/application-previews')
assert.equal(capturedOptions.method, 'POST')
assert.deepEqual(JSON.parse(capturedOptions.body), {
message: '去上海做客户现场实施',
conversation_id: 'conversation-issued-1',
request_id: 'preview-request-1'
})
assert.equal(preview.decisionId, 'decision-issued-1')
assert.equal(preview.decisionSource, 'rule')
assert.equal(preview.fields.reason, '服务端规范事由')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.decisionTrackingStatus, 'registered')
} }
async function testEditDraftActionCarriesClaimAndEditableFields() { async function testEditDraftActionCarriesClaimAndEditableFields() {
@@ -167,6 +226,7 @@ async function testEditDraftActionCarriesClaimAndEditableFields() {
async function run() { async function run() {
await testSubmitActionUsesFastPreviewEndpoint() await testSubmitActionUsesFastPreviewEndpoint()
await testSaveDraftActionUsesFastPreviewEndpoint() await testSaveDraftActionUsesFastPreviewEndpoint()
await testRegistrationUsesServerCanonicalPreview()
await testEditDraftActionCarriesClaimAndEditableFields() await testEditDraftActionCarriesClaimAndEditableFields()
console.log('ai-application-preview-actions tests passed') console.log('ai-application-preview-actions tests passed')
} }

View File

@@ -101,3 +101,45 @@ test('AI workbench conversation store preserves stewardRemainingTasks on message
const persistedMessage = conversation.messages.find((m) => m.id === 'a1') const persistedMessage = conversation.messages.find((m) => m.id === 'a1')
assert.deepEqual(persistedMessage.stewardRemainingTasks, remainingTasks) assert.deepEqual(persistedMessage.stewardRemainingTasks, remainingTasks)
}) })
test('AI workbench conversation store preserves preview decision and retry context', () => {
installLocalStorageMock()
const user = { username: 'preview-user' }
const applicationPreview = {
decisionId: 'decision-preview-1',
decisionActionRequestId: 'action-preview-1',
fields: { reason: '客户现场实施' },
aiDecisionFeedback: {
schemaVersion: 1,
revision: 1,
changedFields: [
{ fieldKey: 'reason', suggestedValue: '客户拜访', finalValue: '客户现场实施' }
]
}
}
saveAiWorkbenchConversation(user, {
id: 'conv-preview-decision',
updatedAt: Date.now(),
messages: [
{ id: 'u1', role: 'user', content: '发起出差申请' },
{
id: 'a1',
role: 'assistant',
content: '申请核对表',
text: '申请核对表',
applicationPreview,
draftPayload: { claim_id: 'claim-preview-1' },
requestedSubmit: true,
submitRequiresConfirmation: true
}
]
})
const history = loadAiWorkbenchConversationHistory(user)
const restored = history[0].messages.find((message) => message.id === 'a1')
assert.deepEqual(restored.applicationPreview, applicationPreview)
assert.equal(restored.draftPayload.claim_id, 'claim-preview-1')
assert.equal(restored.requestedSubmit, true)
assert.equal(restored.submitRequiresConfirmation, true)
})

View File

@@ -4,6 +4,7 @@ import { trackApplicationPreviewFieldEdit } from '../src/utils/expenseApplicatio
function testTracksFirstExplicitEdit() { function testTracksFirstExplicitEdit() {
const previous = { const previous = {
decisionId: 'decision-feedback-1',
fields: { fields: {
reason: '客户拜访', reason: '客户拜访',
location: '上海' location: '上海'
@@ -27,12 +28,13 @@ function testTracksFirstExplicitEdit() {
} }
]) ])
assert.equal(tracked.aiDecisionFeedback.revision, 1) assert.equal(tracked.aiDecisionFeedback.revision, 1)
assert.equal(tracked.decisionId, 'decision-feedback-1')
} }
function testKeepsOriginalSuggestionAcrossRepeatedEdits() { function testKeepsOriginalSuggestionAcrossRepeatedEdits() {
const first = trackApplicationPreviewFieldEdit( const first = trackApplicationPreviewFieldEdit(
{ fields: { transportMode: '火车' } }, { decisionId: 'decision-feedback-2', fields: { transportMode: '火车' } },
{ fields: { transportMode: '飞机' } }, { decisionId: 'decision-feedback-2', fields: { transportMode: '飞机' } },
'transportMode' 'transportMode'
) )
const second = trackApplicationPreviewFieldEdit( const second = trackApplicationPreviewFieldEdit(
@@ -48,6 +50,7 @@ function testKeepsOriginalSuggestionAcrossRepeatedEdits() {
finalValue: '自驾' finalValue: '自驾'
} }
]) ])
assert.equal(second.decisionId, 'decision-feedback-2')
} }
function testRemovesDiffWhenUserRestoresSuggestion() { function testRemovesDiffWhenUserRestoresSuggestion() {