diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md index 8718d40..64b9cb4 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md @@ -209,6 +209,7 @@ - `expense_cases`:统一费用事件和当前阶段。 - `expense_case_links`:连接申请、报销、票据、审批、付款、凭证和外部对象。 - `business_events`:append-only 业务事件账本。 +- `ai_application_preview_decisions`:申请落单前由服务端重新规范化并短期签发的预览建议;不创建空 Expense Case,不保存建议明文,动作成功后一次消费。 - `ai_decisions`:结构化 AI 建议与版本证据。 - `ai_decision_feedback`:接受、修改、拒绝和忽略。 - `workflow_outcomes`:退回、补件、审批、付款、审计和最终结果。 @@ -250,6 +251,8 @@ - 旧 `ReimbursementRequest` 进入只读兼容和迁移状态,停止新增第二套业务编排。 - 现有 `risk_flags_json` 保持读取兼容,新审批、付款、归档和关系事件写入结构化表。 - API 新字段优先追加,不在同一阶段破坏现有前端契约。 +- 从未成功签发 `decision_id` 的旧申请预览,或签发接口失败且服务端没有当前会话有效决策时,保存/提交继续走现有 `client_observed` 路径;当前会话一旦存在有效服务端决策,省略 `decision_id` 必须拒绝,不能主动降级绕过核验。兼容路径不得在用户已经编辑后把最终值反向登记成“原始建议”。 +- 新预览使用 30 分钟有效的服务端 `decision_id`。保存草稿或提交成功后一次消费;保存草稿会尽力签发基于已保存事实的新 `decision_id`,供后续提交继续核验,但续签属于业务提交后的派生能力,续签失败不能把已成功保存返回为失败。相同动作请求 ID、动作和最终指纹允许安全重放,其他重放拒绝。 - 迁移桥接期集中维护 migration-owned 表集合;所有 legacy bootstrap 只能创建集合之外的表。标准启动在 `alembic upgrade head` 前只读核对 revision 与自有表集合,发现漂移时拒绝启动,不自动猜测、stamp 或修复。 - 所有表结构通过 Alembic 迁移,不继续在请求路径执行 DDL。 @@ -268,6 +271,8 @@ - 已初始化系统的 Bootstrap 状态只返回脱敏信息,Bootstrap 写入、系统设置、模型连通性、缓存、审计与系统日志等敏感管理面由平台管理员权限保护;Vite 本地 Setup 桥在初始化完成后锁定重新配置入口。 - P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。 - Expense Case 用户态查询只返回流程摘要:Case 基础状态、关系类型、事件类型、操作人、发生时间及白名单业务载荷;不返回关联资源 ID、幂等键、correlation、causation、聚合标识或 Outbox 投递状态。有权查看入口单据的本人、当前审批人、财务和管理员可查看整 Case 的安全摘要,无权限与跨租户查询继续以 404 隐藏资源存在性。 +- 申请预览签发与消费同时绑定 `tenant_id`、actor、Bearer 登录会话和会话 ID;动作入口按这些服务端事实锁行校验,不能只凭随机 UUID 授权。浏览器提供的模型来源、`finalValue`、用户和角色声明均不作为可信事实。 +- 预览与学习账本中的字段值使用独立、可版本化的费用申请密钥计算 HMAC-SHA256 指纹,密钥目录和文件权限分别为 `0700`、`0600`;核验旧决策必须使用其签发版本,缺失版本直接拒绝,不静默生成替代密钥。字段指纹同时编码“字段是否存在”,避免新增或删除字段被误判为采纳;训练资格仍保持关闭,直至审批、付款或人工核验闭环完成。 - 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。 - 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。 @@ -278,6 +283,7 @@ - Qdrant/few-shot 不可用:使用 stable Prompt 和基础规则,不阻塞主流程。 - 外部支付/ERP/税务连接器不可用:事件进入 retryable 状态,保留人工处理入口和幂等键。 - 记忆冲突或可信度不足:只展示建议,不自动应用。 +- 预览签发超时或不可用:仍展示本地预览并允许编辑、保存和提交,内部标记为降级;此时只记录 `client_observed` 行为证据,不升级为服务端核验或训练样本。 - 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(安全复核与质量验证):交叉审查后补齐超长租户幂等键稳定哈希、Link/Case 租户一致性、孤立 Case 冲突、URL 路由参数覆盖防护和部分批次失败进度摘要;前端将快照语义明确为“纳入时状态/节点”。容器内 65 项后端定向测试、11 项前端测试、Ruff 和 Vite 生产构建通过。全库代码体积门禁仍被本次未修改的 `RiskRuleGenerationService` 807 行存量问题阻断,未混入当前功能提交。 - 2026-07-14(AI 行为闭环首个切片):新增 `AIDecision`、`AIDecisionFeedback` 和 `WorkflowOutcome` 三类独立事实,把申请预填建议、用户确认或显式字段纠正、草稿保存或申请提交结果通过稳定 `decision_id` 关联。只有已认证的申请预览快速入口显式传入服务端 `CurrentUserContext` 时才写账本;通用 User Agent、模板预览和单据详情编辑路径均不写入,避免伪造身份和非 AI 操作污染样本。 -- 2026-07-14(字段纠正与隐私边界):申请表编辑器在当前会话中维护首次建议值与最终显式编辑值,多次修改保留最初建议,改回原值会移除差异;日期调整同时记录 `time` 与派生 `days`。服务端最终值始终从解析后的申请 facts 重建,不信任客户端 `finalValue`;持久化层只保存字段名和 SHA-256 值指纹,不复制事由、地点、人员、金额等原始值,原始对话、Prompt、模型全文、附件和支付敏感信息也不进入学习账本。 -- 2026-07-14(可信度边界):浏览器编辑轨迹统一标记为 `verification_status=client_observed`、`trust_level=behavioral_analytics_only` 且 `training_eligible=false`,只能用于受限的产品行为统计和后续人工核验,不能直接作为模型训练、审计结论、风险自动放行或企业记忆激活依据。后续需增加服务端预览登记并返回不可伪造的 `decision_id`,再实现拒绝/忽略、审批/退回/付款结果追踪、记忆候选与下次建议变化。 +- 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`,只能用于受限产品统计;服务端签发预览消费后提升为 `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(0003 迁移与验证):新增 `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-14(0004 迁移与验证):新增 `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。 diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md index bd80fe1..5791485 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md @@ -39,6 +39,8 @@ - [ ] [CONCEPT: 数据与契约] 定义 `business_events` 事件信封、事件词典、correlation ID、幂等键和版本策略。 - [x] [CONCEPT: 业务事件与 AI 决策] 定义 `ai_decisions`、`ai_decision_feedback` 和 `workflow_outcomes` 契约。 证据:`ai_learning.py`、`expense_application_learning.py`、`20260714_0003_ai_learning_loop.py`;三类事实分别表达 AI 建议、用户采纳/显式字段纠正和业务结果,技术执行成功、用户反馈与工作流结果不复用同一状态。 +- [x] [CONCEPT: 业务事件与 AI 决策] 定义落单前服务端预览决策的签发、授权、过期、一次消费、重放和续签契约。 + 证据:`ai_application_preview.py`、`expense_application_preview_decisions.py`、`expense_application_snapshot.py`;预览不创建空 Case,绑定租户、actor、登录会话与 conversation,30 分钟过期,保存/提交成功后一次消费;保存草稿在业务提交后尽力返回基于服务端事实的新 `decision_id`,续签失败不反转已成功动作。 - [ ] [CONCEPT: 记忆与学习] 定义 `memory_entries`、证据链接、优先级、有效期、敏感等级、撤销和遗忘契约。 - [ ] [CONCEPT: 自动化决策] 定义动作风险、金额阈值、置信度、证据完整度、可逆性、抽检率和企业授权策略。 - [ ] [CONCEPT: 节省与价值] 定义 Savings Ledger 的机会、执行、实现、确认、去重和归因状态。 @@ -67,6 +69,8 @@ 证据:`20260713_0001_expense_case_business_events.py`;一次性 PostgreSQL 17 已验证表、唯一约束、复合索引、外键级联、降级和再次升级。 - [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 项通过,持久开发库未迁移。 +- [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: 业务事件与 AI 决策] 建立事务 Outbox:申请、提交、退回、审批、支付和入账状态与事件同事务提交,消费端按事件 ID 幂等处理。 - [x] [CONCEPT: 业务事件与 AI 决策] 完成首批草稿、提交、退回、审批、申请转报销、付款和申请归档事件旁写,具备 correlation、causation、幂等键及事务回滚。 @@ -112,7 +116,8 @@ - [ ] [CONCEPT: 记忆激活] 实现 candidate/active/suppressed/expired/revoked 记忆状态机。 - [ ] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。 - [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: 记忆与学习] 将已确认 few-shot 扩展到报销预审和审批辅助,并按租户、场景、制度版本过滤。 - [ ] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚。 @@ -180,6 +185,8 @@ - [x] [CONCEPT: 测试方案] 在现有开发数据的只读一次性克隆上验证迁移、历史回填和真实认证时间线链路。 证据:源库与克隆初始签名均为 40 张表、4 张费用单、105 名员工、248 条预算、62 个 Agent 资产;克隆升级后 dry-run 为 eligible=4,apply created=4,重复 apply created=0,四条事件均为 system/suppressed;登录、`/auth/me`、旧单时间线和登出均返回 200,持久库复查不变且仍无 migration-owned 表。 - [ ] [CONCEPT: 测试方案] 补充其余前端组件、键盘操作、移动真实接口和完整浏览器关键流程验证。 +- [x] [CONCEPT: 测试方案] 验证服务端预览决策签发、字段新增/改值差异判定、跨会话拒绝、无 ID 降级防绕过、安全重放、独立密钥权限、续签失败容错、旧路径兼容、会话恢复和真实 PostgreSQL 迁移。 + 证据:容器内新增决策安全用例 9 项、旧快速保存/提交 4 项、申请学习账本 9 项、迁移与所有权 26 项通过且条件型 PostgreSQL 用例 1 项跳过;一次性 PostgreSQL 17 迁移 4 项、前端定向 3 组及 Vite 生产构建已通过。临时 PostgreSQL 已清理,持久开发库 8 张 migration-owned 表数量仍为 0。 - [ ] [CONCEPT: 测试方案] 所有后端、集成和迁移测试在当前主应用容器内执行,单条命令最大超时 60s。 - [ ] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据。 diff --git a/server/alembic/versions/20260714_0004_ai_application_preview_decisions.py b/server/alembic/versions/20260714_0004_ai_application_preview_decisions.py new file mode 100644 index 0000000..e5f5abc --- /dev/null +++ b/server/alembic/versions/20260714_0004_ai_application_preview_decisions.py @@ -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") diff --git a/server/src/app/api/v1/endpoints/expense_application_previews.py b/server/src/app/api/v1/endpoints/expense_application_previews.py new file mode 100644 index 0000000..a52d669 --- /dev/null +++ b/server/src/app/api/v1/endpoints/expense_application_previews.py @@ -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 + ), + ), + ) diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index 97e30f7..eb5800d 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -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.schemas.budget import BudgetClaimAnalysisRead from app.schemas.common import ErrorResponse, PaginatedResponse -from app.schemas.ontology import OntologyParseResult, OntologyPermission from app.schemas.reimbursement import ( - ExpenseApplicationPreviewActionPayload, - ExpenseApplicationPreviewActionResponse, - ExpenseApplicationPreviewActionResult, ExpenseClaimActionResponse, ExpenseClaimApprovalPayload, ExpenseClaimAttachmentActionResponse, @@ -31,13 +27,11 @@ from app.schemas.reimbursement import ( TravelReimbursementCalculatorRequest, TravelReimbursementCalculatorResponse, ) -from app.schemas.user_agent import UserAgentRequest from app.services.budget import BudgetService from app.services.document_numbering import is_application_claim_no from app.services.expense_claims import ExpenseClaimService from app.services.reimbursement import ReimbursementService from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService -from app.services.user_agent import UserAgentService router = APIRouter() 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 -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( "/claims", response_model=list[ExpenseClaimRead] | PaginatedResponse[ExpenseClaimRead], diff --git a/server/src/app/api/v1/router.py b/server/src/app/api/v1/router.py index d33519d..31192b3 100644 --- a/server/src/app/api/v1/router.py +++ b/server/src/app/api/v1/router.py @@ -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_traces import router as agent_traces_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.auth import router as auth_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.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.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.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.ocr import router as ocr_router from app.api.v1.endpoints.ontology import router as ontology_router @@ -27,7 +34,7 @@ from app.api.v1.endpoints.risk_observations import router as risk_observations_r from app.api.v1.endpoints.settings import router as settings_router from app.api.v1.endpoints.steward import router as steward_router from app.api.v1.endpoints.system_logs import router as system_logs_router - + router = APIRouter() router.include_router(health_router, tags=["health"]) router.include_router(bootstrap_router, tags=["bootstrap"]) @@ -50,6 +57,7 @@ router.include_router(orchestrator_router, tags=["orchestrator"]) router.include_router(receipt_folder_router, tags=["receipt-folder"]) router.include_router(employees_router, prefix="/employees", tags=["employees"]) router.include_router(expense_cases_router, tags=["expense-cases"]) +router.include_router(expense_application_previews_router, tags=["reimbursements"]) router.include_router(employee_profiles_router, tags=["employee-profiles"]) router.include_router(reimbursements_router, prefix="/reimbursements", tags=["reimbursements"]) router.include_router(risk_observations_router, tags=["risk-observations"]) diff --git a/server/src/app/core/expense_application_fingerprint_keys.py b/server/src/app/core/expense_application_fingerprint_keys.py new file mode 100644 index 0000000..ff0aed1 --- /dev/null +++ b/server/src/app/core/expense_application_fingerprint_keys.py @@ -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()) diff --git a/server/src/app/db/base.py b/server/src/app/db/base.py index 5bfe3be..92ded5d 100644 --- a/server/src/app/db/base.py +++ b/server/src/app/db/base.py @@ -9,6 +9,7 @@ from app.models.agent_asset import ( from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog +from app.models.ai_application_preview import AIApplicationPreviewDecision from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog @@ -53,6 +54,7 @@ __all__ = [ "AgentRun", "AgentToolCall", "AgentTraceEvent", + "AIApplicationPreviewDecision", "AIDecision", "AIDecisionFeedback", "ApprovalRecord", diff --git a/server/src/app/db/migration_preflight.py b/server/src/app/db/migration_preflight.py index a8372d4..b1b51b7 100644 --- a/server/src/app/db/migration_preflight.py +++ b/server/src/app/db/migration_preflight.py @@ -38,8 +38,20 @@ MIGRATION_OWNED_TABLES_BY_REVISION: dict[str, frozenset[str]] = { "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") diff --git a/server/src/app/db/schema_ownership.py b/server/src/app/db/schema_ownership.py index e2ca2c5..36edbdf 100644 --- a/server/src/app/db/schema_ownership.py +++ b/server/src/app/db/schema_ownership.py @@ -7,6 +7,7 @@ from app.db.base import Base MIGRATION_OWNED_TABLES: frozenset[str] = frozenset( { "auth_sessions", + "ai_application_preview_decisions", "ai_decisions", "ai_decision_feedback", "expense_cases", diff --git a/server/src/app/models/__init__.py b/server/src/app/models/__init__.py index 90f5f17..c4bbbdb 100644 --- a/server/src/app/models/__init__.py +++ b/server/src/app/models/__init__.py @@ -7,6 +7,7 @@ from app.models.agent_asset import ( from app.models.agent_conversation import AgentConversation, AgentConversationMessage from app.models.agent_feedback import AgentOperationFeedback from app.models.agent_run import AgentRun, AgentToolCall, AgentTraceEvent, SemanticParseLog +from app.models.ai_application_preview import AIApplicationPreviewDecision from app.models.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome from app.models.approval import ApprovalRecord from app.models.audit_log import AuditLog @@ -49,6 +50,7 @@ __all__ = [ "AgentRun", "AgentToolCall", "AgentTraceEvent", + "AIApplicationPreviewDecision", "ApprovalRecord", "AuditLog", "AuthSession", diff --git a/server/src/app/models/ai_application_preview.py b/server/src/app/models/ai_application_preview.py new file mode 100644 index 0000000..ee17f2f --- /dev/null +++ b/server/src/app/models/ai_application_preview.py @@ -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(), + ) diff --git a/server/src/app/models/ai_learning.py b/server/src/app/models/ai_learning.py index 1f60e9b..5eb2758 100644 --- a/server/src/app/models/ai_learning.py +++ b/server/src/app/models/ai_learning.py @@ -44,6 +44,20 @@ class AIDecision(Base): "idempotency_key", 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( ["tenant_id", "expense_case_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) 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) + preview_decision_id: Mapped[str | None] = mapped_column(String(36), nullable=True) agent_run_id: Mapped[str | None] = mapped_column(String(120), nullable=True) correlation_id: Mapped[str] = mapped_column(String(64), nullable=False) subject_type: Mapped[str] = mapped_column(String(50), nullable=False) diff --git a/server/src/app/schemas/expense_application_preview.py b/server/src/app/schemas/expense_application_preview.py new file mode 100644 index 0000000..77e5823 --- /dev/null +++ b/server/src/app/schemas/expense_application_preview.py @@ -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) diff --git a/server/src/app/schemas/reimbursement.py b/server/src/app/schemas/reimbursement.py index d33c9f6..4a70c33 100644 --- a/server/src/app/schemas/reimbursement.py +++ b/server/src/app/schemas/reimbursement.py @@ -191,6 +191,9 @@ class ExpenseApplicationPreviewActionPayload(BaseModel): source: str = Field(default="user_message", max_length=80) user_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) context_json: dict[str, Any] = Field(default_factory=dict) @@ -202,6 +205,8 @@ class ExpenseApplicationPreviewActionResult(BaseModel): risk_flags: list[str] = Field(default_factory=list) requires_confirmation: bool = False draft_payload: dict[str, Any] | None = None + decision_id: str | None = None + decision_expires_at: datetime | None = None class ExpenseApplicationPreviewActionResponse(BaseModel): diff --git a/server/src/app/services/expense_application_learning.py b/server/src/app/services/expense_application_learning.py index ef0f617..3147b47 100644 --- a/server/src/app/services/expense_application_learning.py +++ b/server/src/app/services/expense_application_learning.py @@ -1,7 +1,5 @@ from __future__ import annotations -import hashlib -import json import uuid from dataclasses import dataclass from decimal import Decimal @@ -11,10 +9,22 @@ 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.ai_learning import AIDecision, AIDecisionFeedback, WorkflowOutcome from app.models.expense_case import BusinessEvent from app.models.financial_record import ExpenseClaim from app.schemas.user_agent import UserAgentRequest +from app.services.expense_application_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 @@ -28,50 +38,6 @@ class ExpenseApplicationLearningRecords: class ExpenseApplicationLearningService: """把 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: self.db = db @@ -84,10 +50,14 @@ class ExpenseApplicationLearningService: *, action_type: str, business_event: BusinessEvent, + preview_decision: AIApplicationPreviewDecision | None = None, + action_request_id: str = "", ) -> ExpenseApplicationLearningRecords | None: normalized_action = self._normalize_action_type(action_type) preview = self._application_preview(payload) if not self._is_learning_eligible_preview(preview): + if preview_decision is not None: + raise ValueError("服务端预览决策缺少可核验的申请字段。") return None tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id) expense_case = ExpenseCaseService(self.db).ensure_case_for_claim( @@ -101,20 +71,48 @@ class ExpenseApplicationLearningService: raise PermissionError("AI 学习记录不能关联其他租户或费用事件的业务事件。") final_values = self._safe_fact_snapshot(facts) - changed_fields = self._safe_changed_fields(payload, final_values) - suggested_values = dict(final_values) - for item in changed_fields: - suggested_values[item["field_key"]] = item["suggested_value"] + 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) + suggested_values = dict(final_values) + for item in changed_fields: + 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) fingerprint_payload = { "tenant_id": tenant_id, "expense_claim_id": claim.id, "action_type": normalized_action, - "suggested_values": suggested_values, - "final_values": final_values, - "changed_fields": changed_fields, + "preview_decision_id": preview_decision.id if preview_decision is not None else None, + "suggestion_reference": suggestion_reference, + "final_reference": final_reference, + "changed_fields": changed_field_references, } content_fingerprint = self._fingerprint(fingerprint_payload) idempotency_key = self._idempotency_key( @@ -133,24 +131,38 @@ class ExpenseApplicationLearningService: expense_case_id=expense_case.id, business_event_id=business_event.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, correlation_id=correlation_id, subject_type="expense_claim", subject_id=claim.id, 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", automation_mode="prefill", confidence=self._confidence(payload), - suggestion_json=self._snapshot_reference(suggested_values), + suggestion_json=suggestion_reference, evidence_json={ - "evidence_source": "client_preview_edit_trace", - "trust_level": "behavioral_analytics_only", + "evidence_source": ( + "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_review_status": self._safe_text(preview.get("modelReviewStatus"), 40), }, version_json={ "schema_version": 1, + "fingerprint_key_version": suggestion_reference.get("fingerprint_key_version"), "parse_strategy": self._safe_text(preview.get("parseStrategy"), 60), "rule_name": self._safe_text(facts.get("rule_name"), 120), "rule_version": self._safe_text(facts.get("rule_version"), 80), @@ -170,28 +182,22 @@ class ExpenseApplicationLearningService: action_type=normalized_action, actor_id=str(current_user.username or "anonymous").strip() or "anonymous", actor_type="user", - evidence_source=( - "client_explicit_field_edit" - if changed_fields - else "client_action_confirmation" - ), - verification_status="client_observed", + evidence_source=evidence_source, + verification_status=verification_status, training_eligible=False, - final_value_json=self._snapshot_reference(final_values), - changed_fields_json=self._changed_field_references(changed_fields), + final_value_json=final_reference, + changed_fields_json=changed_field_references, idempotency_key=f"feedback:{idempotency_key}"[:120], content_fingerprint=self._fingerprint( { "decision_id": decision_id, "feedback_type": feedback_type, - "final_values": final_values, - "changed_fields": changed_fields, + "final_reference": final_reference, + "changed_fields": changed_field_references, } ), ) - outcome_type = ( - "application_submitted" if normalized_action == "submit" else "draft_saved" - ) + outcome_type = "application_submitted" if normalized_action == "submit" else "draft_saved" outcome = WorkflowOutcome( id=self._stable_id("outcome", tenant_id, idempotency_key), tenant_id=tenant_id, @@ -206,9 +212,7 @@ class ExpenseApplicationLearningService: actor_type="user", result_json={ "claim_status": "submitted" if normalized_action == "submit" else "draft", - "approval_stage": ( - "直属领导审批" if normalized_action == "submit" else "待提交" - ), + "approval_stage": ("直属领导审批" if normalized_action == "submit" else "待提交"), "feedback_type": feedback_type, }, idempotency_key=f"outcome:{idempotency_key}"[:120], @@ -222,6 +226,15 @@ class ExpenseApplicationLearningService: ) self.db.add_all([decision, feedback, outcome]) 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) def _find_existing( @@ -255,11 +268,7 @@ class ExpenseApplicationLearningService: @classmethod def _safe_fact_snapshot(cls, facts: dict[str, str]) -> dict[str, str]: - return { - key: cls._safe_text(facts.get(key), 500) - for key in cls._FACT_KEYS - if cls._safe_text(facts.get(key), 500) - } + return safe_fact_snapshot(facts) @classmethod def _safe_changed_fields( @@ -279,7 +288,7 @@ class ExpenseApplicationLearningService: if not isinstance(raw_item, dict): continue 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: continue 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" @classmethod - def _snapshot_reference(cls, values: dict[str, str]) -> dict[str, Any]: - return { - "field_keys": sorted(values), - "value_fingerprint": cls._fingerprint(values), - } + def _snapshot_reference( + cls, + values: dict[str, str], + *, + 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 def _changed_field_references( @@ -323,8 +376,8 @@ class ExpenseApplicationLearningService: return [ { "field_key": item["field_key"], - "suggested_value_fingerprint": cls._fingerprint(item["suggested_value"]), - "final_value_fingerprint": cls._fingerprint(item["final_value"]), + "suggested_value_fingerprint": hmac_fingerprint(item["suggested_value"]), + "final_value_fingerprint": hmac_fingerprint(item["final_value"]), } for item in changes ] @@ -363,14 +416,7 @@ class ExpenseApplicationLearningService: @staticmethod def _fingerprint(payload: object) -> str: - serialized = json.dumps( - payload, - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - default=str, - ) - return f"sha256:{hashlib.sha256(serialized.encode('utf-8')).hexdigest()}" + return content_fingerprint(payload) @staticmethod def _idempotency_key(action_type: str, claim_id: str, fingerprint: str) -> str: diff --git a/server/src/app/services/expense_application_preview_decisions.py b/server/src/app/services/expense_application_preview_decisions.py new file mode 100644 index 0000000..4e81613 --- /dev/null +++ b/server/src/app/services/expense_application_preview_decisions.py @@ -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) diff --git a/server/src/app/services/expense_application_request_context.py b/server/src/app/services/expense_application_request_context.py new file mode 100644 index 0000000..4fac38d --- /dev/null +++ b/server/src/app/services/expense_application_request_context.py @@ -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 diff --git a/server/src/app/services/expense_application_snapshot.py b/server/src/app/services/expense_application_snapshot.py new file mode 100644 index 0000000..d5a79e2 --- /dev/null +++ b/server/src/app/services/expense_application_snapshot.py @@ -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") diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index 3a5c4a8..c66e865 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -8,6 +8,7 @@ from sqlalchemy import or_, select from sqlalchemy.exc import IntegrityError 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.schemas.reimbursement import TravelReimbursementCalculatorRequest @@ -646,6 +647,8 @@ class UserAgentApplicationPersistenceMixin: *, action_type: str, business_event: BusinessEvent, + preview_decision: AIApplicationPreviewDecision | None = None, + action_request_id: str = "", ) -> None: if trusted_current_user is None: return @@ -656,6 +659,8 @@ class UserAgentApplicationPersistenceMixin: trusted_current_user, action_type=action_type, business_event=business_event, + preview_decision=preview_decision, + action_request_id=action_request_id, ) @staticmethod @@ -727,6 +732,8 @@ class UserAgentApplicationPersistenceMixin: *, submit: bool, learning_current_user: CurrentUserContext | None = None, + learning_preview_decision: AIApplicationPreviewDecision | None = None, + learning_action_request_id: str = "", ) -> ExpenseClaim: current_user = self._build_application_current_user(payload) previous_status = str(claim.status or "").strip() @@ -771,6 +778,8 @@ class UserAgentApplicationPersistenceMixin: learning_current_user, action_type="save_draft", business_event=draft_event, + preview_decision=learning_preview_decision, + action_request_id=learning_action_request_id, ) self.db.commit() self.db.refresh(claim) @@ -794,6 +803,8 @@ class UserAgentApplicationPersistenceMixin: learning_current_user, action_type="submit", business_event=event, + preview_decision=learning_preview_decision, + action_request_id=learning_action_request_id, ) ), ) @@ -811,6 +822,8 @@ class UserAgentApplicationPersistenceMixin: *, submit: bool, learning_current_user: CurrentUserContext | None = None, + learning_preview_decision: AIApplicationPreviewDecision | None = None, + learning_action_request_id: str = "", ) -> ExpenseClaim: current_user = self._build_application_current_user(payload) access_policy = ExpenseClaimAccessPolicy(self.db) @@ -885,6 +898,8 @@ class UserAgentApplicationPersistenceMixin: learning_current_user, action_type="save_draft", business_event=draft_event, + preview_decision=learning_preview_decision, + action_request_id=learning_action_request_id, ) self.db.commit() self.db.refresh(claim) @@ -904,6 +919,8 @@ class UserAgentApplicationPersistenceMixin: learning_current_user, action_type="submit", 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], learning_current_user: CurrentUserContext | None = None, + learning_preview_decision: AIApplicationPreviewDecision | None = None, + learning_action_request_id: str = "", ) -> UserAgentResponse: facts = self._resolve_expense_application_facts(payload) step = self._resolve_expense_application_step(payload, facts) @@ -1299,6 +1318,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat editable_claim, submit=step == "submitted", 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" elif step == "submitted": @@ -1312,6 +1333,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat facts, submit=True, learning_current_user=learning_current_user, + learning_preview_decision=learning_preview_decision, + learning_action_request_id=learning_action_request_id, ) else: application_claim = self._create_expense_application_record( @@ -1319,6 +1342,8 @@ class UserAgentApplicationMixin(UserAgentApplicationSlotMixin, UserAgentApplicat facts, submit=False, 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: facts["application_no"] = application_claim.claim_no diff --git a/server/tests/auth_helpers.py b/server/tests/auth_helpers.py index c320275..6a24512 100644 --- a/server/tests/auth_helpers.py +++ b/server/tests/auth_helpers.py @@ -24,6 +24,8 @@ def _read_test_user_headers( employee_no: Annotated[str | None, Header(alias="X-Auth-Employee-No")] = 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, + 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: normalized_username = str(username or "").strip() normalized_name = str(name or normalized_username).strip() @@ -52,4 +54,6 @@ def _read_test_user_headers( grade=str(grade or "").strip(), employee_no=str(employee_no 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(), ) diff --git a/server/tests/test_alembic_migrations.py b/server/tests/test_alembic_migrations.py index d262d29..98a3dbf 100644 --- a/server/tests/test_alembic_migrations.py +++ b/server/tests/test_alembic_migrations.py @@ -19,7 +19,7 @@ from app.db.schema_ownership import MIGRATION_OWNED_TABLES, create_legacy_schema MIGRATION_TEST_DATABASE_URL = os.getenv("MIGRATION_TEST_DATABASE_URL", "").strip() LEGACY_PROBE_TABLE = "legacy_migration_probe_records" -HEAD_REVISION = "20260714_0003" +HEAD_REVISION = "20260714_0004" SERVER_DIR = Path(__file__).resolve().parents[1] ALEMBIC_INI_PATH = SERVER_DIR / "alembic.ini" @@ -214,6 +214,18 @@ def _assert_head_schema(engine: Engine) -> None: "uq_ai_decisions_tenant_idempotency", ("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( engine, "ai_decision_feedback", @@ -265,6 +277,23 @@ def _assert_head_schema(engine: Engine) -> None: "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( engine, "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, "business_events") + _assert_composite_foreign_key( + engine, + "ai_decisions", + ("tenant_id", "preview_decision_id"), + "ai_application_preview_decisions", + ) _assert_composite_foreign_key( engine, "ai_decisions", diff --git a/server/tests/test_expense_application_preview_decisions.py b/server/tests/test_expense_application_preview_decisions.py new file mode 100644 index 0000000..c504162 --- /dev/null +++ b/server/tests/test_expense_application_preview_decisions.py @@ -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 diff --git a/server/tests/test_migration_preflight.py b/server/tests/test_migration_preflight.py index 19be09d..ef89de2 100644 --- a/server/tests/test_migration_preflight.py +++ b/server/tests/test_migration_preflight.py @@ -48,7 +48,7 @@ def test_unversioned_database_without_migration_owned_tables_is_safe(engine: Eng @pytest.mark.parametrize( "owned_table", - sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0003"]), + sorted(MIGRATION_OWNED_TABLES_BY_REVISION["20260714_0004"]), ) def test_unversioned_database_with_any_migration_owned_table_is_rejected( engine: Engine, @@ -91,6 +91,11 @@ def test_known_revision_requires_and_accepts_its_exact_owned_table_set( "20260714_0003", 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( diff --git a/server/tests/test_schema_ownership.py b/server/tests/test_schema_ownership.py index e2d5c00..0c38f65 100644 --- a/server/tests/test_schema_ownership.py +++ b/server/tests/test_schema_ownership.py @@ -16,6 +16,7 @@ def test_create_legacy_schema_never_creates_migration_owned_tables() -> None: assert MIGRATION_OWNED_TABLES == frozenset( { + "ai_application_preview_decisions", "ai_decision_feedback", "ai_decisions", "auth_sessions", diff --git a/server/tests/test_user_agent_application_draft_events.py b/server/tests/test_user_agent_application_draft_events.py index 8da21ec..239f54e 100644 --- a/server/tests/test_user_agent_application_draft_events.py +++ b/server/tests/test_user_agent_application_draft_events.py @@ -441,14 +441,14 @@ def test_ai_application_draft_records_explicit_field_correction() -> None: assert decision is not None assert decision.training_eligible is False 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.feedback_type == "edited" assert feedback.verification_status == "client_observed" assert feedback.training_eligible is False assert feedback.changed_fields_json[0]["field_key"] == "reason" 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.final_value_json) diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js index 74ea402..4b1a535 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js @@ -13,6 +13,8 @@ import { fetchExpenseClaims } from '../../services/reimbursements.js' import { AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT, + createAiApplicationRequestId, + registerAiApplicationPreviewDecision, runAiApplicationPreviewAction } from '../../services/aiApplicationPreviewActions.js' import { @@ -374,9 +376,20 @@ export function useWorkbenchAiApplicationPreviewFlow({ return false } - const normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview) + let normalizedPreview = normalizeApplicationPreview(targetMessage.applicationPreview) const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT 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 (!options.skipUserMessage) { @@ -454,12 +467,27 @@ export function useWorkbenchAiApplicationPreviewFlow({ applicationPreview: normalizedPreview, currentUser: currentUser.value || {}, conversationId: conversationId.value, - draftPayload: targetMessage.draftPayload || options.draftPayload || null + draftPayload: targetMessage.draftPayload || options.draftPayload || null, + requestId: actionRequestId }) const draftPayload = extractInlineApplicationDraftPayload(payload) if (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 = [] const detailActions = buildInlineApplicationDetailAction(draftPayload) const nextTaskAction = buildApplicationPreviewNextTaskAction(targetMessage) @@ -598,11 +626,26 @@ export function useWorkbenchAiApplicationPreviewFlow({ 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, { id: pendingMessage.id, - applicationPreview: preview, - suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview), + applicationPreview: registeredPreview, + suggestedActions: buildInlineApplicationPreviewSuggestedActions(registeredPreview), requestedSubmit: Boolean(options.requestedSubmit), submitRequiresConfirmation: Boolean(options.submitRequiresConfirmation), stewardRemainingTasks: Array.isArray(options.stewardRemainingTasks) ? options.stewardRemainingTasks : [], diff --git a/web/src/services/aiApplicationPreviewActions.js b/web/src/services/aiApplicationPreviewActions.js index 9ee2a74..b2786e3 100644 --- a/web/src/services/aiApplicationPreviewActions.js +++ b/web/src/services/aiApplicationPreviewActions.js @@ -8,6 +8,54 @@ import { export const AI_APPLICATION_ACTION_SAVE_DRAFT = 'ai_application_save_draft' 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) { return String(value || '').trim() } @@ -56,7 +104,8 @@ export function buildAiApplicationPreviewActionPayload({ applicationPreview, currentUser = {}, conversationId = '', - draftPayload = null + draftPayload = null, + requestId = '' } = {}) { const normalizedPreview = normalizeApplicationPreview(applicationPreview || {}) const message = buildAiApplicationPreviewActionText(actionType, normalizedPreview) @@ -72,6 +121,8 @@ export function buildAiApplicationPreviewActionPayload({ : [] const draftClaimId = normalizeText(draftPayload?.claim_id || draftPayload?.claimId) const isSubmit = actionType === AI_APPLICATION_ACTION_SUBMIT + const decisionId = resolveAiApplicationPreviewDecisionId(normalizedPreview) + const actionRequestId = normalizeText(requestId || normalizedPreview.decisionActionRequestId) const applicationEditableFields = Array.isArray(normalizedPreview.editableFields) ? normalizedPreview.editableFields.map((field) => normalizeText(field)).filter(Boolean) : [] @@ -80,6 +131,9 @@ export function buildAiApplicationPreviewActionPayload({ source: 'user_message', user_id: username, conversation_id: normalizeText(conversationId) || null, + action_type: isSubmit ? 'submit' : 'save_draft', + ...(decisionId ? { decision_id: decisionId } : {}), + ...(decisionId && actionRequestId ? { request_id: actionRequestId } : {}), message, context_json: { role_codes: roleCodes, diff --git a/web/src/utils/aiWorkbenchConversationStore.js b/web/src/utils/aiWorkbenchConversationStore.js index 99aebcb..af6390a 100644 --- a/web/src/utils/aiWorkbenchConversationStore.js +++ b/web/src/utils/aiWorkbenchConversationStore.js @@ -257,6 +257,15 @@ function normalizeMessage(message = {}) { content: safeString(message.content), pending: false, 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' ? { ...message.stewardPlan, diff --git a/web/tests/ai-application-preview-actions.test.mjs b/web/tests/ai-application-preview-actions.test.mjs index f0668bd..a7b8549 100644 --- a/web/tests/ai-application-preview-actions.test.mjs +++ b/web/tests/ai-application-preview-actions.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict' import { AI_APPLICATION_ACTION_SAVE_DRAFT, AI_APPLICATION_ACTION_SUBMIT, + registerAiApplicationPreviewDecision, runAiApplicationPreviewAction } from '../src/services/aiApplicationPreviewActions.js' @@ -34,6 +35,7 @@ async function testSubmitActionUsesFastPreviewEndpoint() { await runAiApplicationPreviewAction({ actionType: AI_APPLICATION_ACTION_SUBMIT, applicationPreview: { + decisionId: 'decision-fast-submit', fields: { applicationType: '差旅费用申请', time: '2026-07-01 至 2026-07-03', @@ -55,7 +57,8 @@ async function testSubmitActionUsesFastPreviewEndpoint() { } }, 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') @@ -63,6 +66,9 @@ async function testSubmitActionUsesFastPreviewEndpoint() { const body = JSON.parse(capturedOptions.body) assert.equal(body.context_json.session_type, '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.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_save_mode, true) 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() { @@ -167,6 +226,7 @@ async function testEditDraftActionCarriesClaimAndEditableFields() { async function run() { await testSubmitActionUsesFastPreviewEndpoint() await testSaveDraftActionUsesFastPreviewEndpoint() + await testRegistrationUsesServerCanonicalPreview() await testEditDraftActionCarriesClaimAndEditableFields() console.log('ai-application-preview-actions tests passed') } diff --git a/web/tests/ai-workbench-conversation-store.test.mjs b/web/tests/ai-workbench-conversation-store.test.mjs index 0d49a3e..ed4d7a8 100644 --- a/web/tests/ai-workbench-conversation-store.test.mjs +++ b/web/tests/ai-workbench-conversation-store.test.mjs @@ -101,3 +101,45 @@ test('AI workbench conversation store preserves stewardRemainingTasks on message const persistedMessage = conversation.messages.find((m) => m.id === 'a1') 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) +}) diff --git a/web/tests/expense-application-decision-feedback.test.mjs b/web/tests/expense-application-decision-feedback.test.mjs index bad8c8e..da59d9c 100644 --- a/web/tests/expense-application-decision-feedback.test.mjs +++ b/web/tests/expense-application-decision-feedback.test.mjs @@ -4,6 +4,7 @@ import { trackApplicationPreviewFieldEdit } from '../src/utils/expenseApplicatio function testTracksFirstExplicitEdit() { const previous = { + decisionId: 'decision-feedback-1', fields: { reason: '客户拜访', location: '上海' @@ -27,12 +28,13 @@ function testTracksFirstExplicitEdit() { } ]) assert.equal(tracked.aiDecisionFeedback.revision, 1) + assert.equal(tracked.decisionId, 'decision-feedback-1') } function testKeepsOriginalSuggestionAcrossRepeatedEdits() { const first = trackApplicationPreviewFieldEdit( - { fields: { transportMode: '火车' } }, - { fields: { transportMode: '飞机' } }, + { decisionId: 'decision-feedback-2', fields: { transportMode: '火车' } }, + { decisionId: 'decision-feedback-2', fields: { transportMode: '飞机' } }, 'transportMode' ) const second = trackApplicationPreviewFieldEdit( @@ -48,6 +50,7 @@ function testKeepsOriginalSuggestionAcrossRepeatedEdits() { finalValue: '自驾' } ]) + assert.equal(second.decisionId, 'decision-feedback-2') } function testRemovesDiffWhenUserRestoresSuggestion() {