feat(ai): issue verified application preview decisions
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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、构建、端到端和未覆盖风险证据。
|
||||
|
||||
|
||||
@@ -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")
|
||||
230
server/src/app/api/v1/endpoints/expense_application_previews.py
Normal file
230
server/src/app/api/v1/endpoints/expense_application_previews.py
Normal file
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext, get_current_user, get_db
|
||||
from app.schemas.expense_application_preview import (
|
||||
ExpenseApplicationPreviewDecisionCreate,
|
||||
ExpenseApplicationPreviewDecisionRead,
|
||||
)
|
||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||
from app.schemas.reimbursement import (
|
||||
ExpenseApplicationPreviewActionPayload,
|
||||
ExpenseApplicationPreviewActionResponse,
|
||||
ExpenseApplicationPreviewActionResult,
|
||||
)
|
||||
from app.schemas.user_agent import UserAgentRequest
|
||||
from app.services.expense_application_preview_decisions import (
|
||||
ExpenseApplicationPreviewDecisionService,
|
||||
PreviewDecisionConflictError,
|
||||
)
|
||||
from app.services.expense_application_request_context import (
|
||||
build_trusted_expense_application_context,
|
||||
)
|
||||
from app.services.user_agent import UserAgentService
|
||||
|
||||
router = APIRouter(prefix="/reimbursements")
|
||||
logger = logging.getLogger(__name__)
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/application-previews",
|
||||
response_model=ExpenseApplicationPreviewDecisionRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="签发费用申请预览决策",
|
||||
description=(
|
||||
"服务端重新解析申请原文、重算规则字段并签发短期 decision_id;"
|
||||
"不接受客户端来源声明作为可信 AI 证据。"
|
||||
),
|
||||
)
|
||||
def issue_expense_application_preview_decision(
|
||||
payload: ExpenseApplicationPreviewDecisionCreate,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseApplicationPreviewDecisionRead:
|
||||
run_id = f"application-preview-decision:{payload.request_id}"
|
||||
context_json = build_trusted_expense_application_context(current_user)
|
||||
request = UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=current_user.username or current_user.name,
|
||||
message=payload.message,
|
||||
ontology=OntologyParseResult(run_id=run_id),
|
||||
context_json=context_json,
|
||||
tool_payload={},
|
||||
selected_capability_codes=[],
|
||||
degraded=False,
|
||||
requires_confirmation=False,
|
||||
)
|
||||
try:
|
||||
facts = UserAgentService(db)._resolve_expense_application_facts(request)
|
||||
issued = ExpenseApplicationPreviewDecisionService(db).issue(
|
||||
facts,
|
||||
current_user,
|
||||
conversation_id=payload.conversation_id or "",
|
||||
request_id=payload.request_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(issued.decision)
|
||||
except PreviewDecisionConflictError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
return ExpenseApplicationPreviewDecisionRead(
|
||||
decision_id=issued.decision.id,
|
||||
decision_source=issued.decision.decision_source,
|
||||
expires_at=issued.decision.expires_at,
|
||||
application_preview={
|
||||
"fields": issued.fields,
|
||||
"decisionId": issued.decision.id,
|
||||
"decisionSource": issued.decision.decision_source,
|
||||
"decisionExpiresAt": issued.decision.expires_at.isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/application-preview-action",
|
||||
response_model=ExpenseApplicationPreviewActionResponse,
|
||||
summary="按申请核对预览快速保存或提交申请单",
|
||||
description=(
|
||||
"用于 AI 工作台已完成表格核对后的轻量建单/提交流程,避免重复进入通用 Orchestrator 编排。"
|
||||
),
|
||||
)
|
||||
def run_application_preview_action(
|
||||
payload: ExpenseApplicationPreviewActionPayload,
|
||||
db: DbSession,
|
||||
current_user: CurrentUser,
|
||||
) -> ExpenseApplicationPreviewActionResponse:
|
||||
context_json = build_trusted_expense_application_context(
|
||||
current_user,
|
||||
payload.context_json,
|
||||
)
|
||||
if payload.action_type == "save_draft":
|
||||
context_json["application_action"] = "save_draft"
|
||||
context_json["application_save_mode"] = True
|
||||
elif payload.action_type == "submit":
|
||||
context_json.pop("application_action", None)
|
||||
context_json.pop("application_save_mode", None)
|
||||
run_id = f"application-preview-action:{payload.conversation_id or current_user.username}"
|
||||
request = UserAgentRequest(
|
||||
run_id=run_id,
|
||||
user_id=current_user.username or current_user.name,
|
||||
message=payload.message,
|
||||
ontology=OntologyParseResult(
|
||||
scenario="expense",
|
||||
intent="operate",
|
||||
permission=OntologyPermission(
|
||||
level="approval_required",
|
||||
allowed=True,
|
||||
reason="application preview fast action",
|
||||
),
|
||||
confidence=1.0,
|
||||
run_id=run_id,
|
||||
),
|
||||
context_json=context_json,
|
||||
tool_payload={},
|
||||
selected_capability_codes=[],
|
||||
degraded=False,
|
||||
requires_confirmation=False,
|
||||
)
|
||||
try:
|
||||
user_agent_service = UserAgentService(db)
|
||||
facts = user_agent_service._resolve_expense_application_facts(request)
|
||||
resolved_step = user_agent_service._resolve_expense_application_step(request, facts)
|
||||
resolved_action = "save_draft" if resolved_step == "draft" else "submit"
|
||||
if payload.action_type and payload.action_type != resolved_action:
|
||||
raise ValueError("动作类型与申请内容不一致。")
|
||||
preview_decision_service = ExpenseApplicationPreviewDecisionService(db)
|
||||
preview_decision = None
|
||||
if payload.decision_id:
|
||||
preview_decision = preview_decision_service.require_for_action(
|
||||
payload.decision_id,
|
||||
current_user,
|
||||
conversation_id=payload.conversation_id or "",
|
||||
request_id=payload.request_id or "",
|
||||
action_type=resolved_action,
|
||||
final_values=facts,
|
||||
)
|
||||
else:
|
||||
preview_decision_service.reject_active_decision_bypass(
|
||||
current_user,
|
||||
conversation_id=payload.conversation_id or "",
|
||||
)
|
||||
consumed_preview_decision_id = preview_decision.id if preview_decision is not None else ""
|
||||
user_agent_response = user_agent_service._build_expense_application_response(
|
||||
request,
|
||||
risk_flags=[],
|
||||
learning_current_user=current_user,
|
||||
learning_preview_decision=preview_decision,
|
||||
learning_action_request_id=payload.request_id or "",
|
||||
)
|
||||
except PreviewDecisionConflictError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
except ValueError as error:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||
|
||||
next_preview_decision = None
|
||||
if (
|
||||
preview_decision is not None
|
||||
and resolved_action == "save_draft"
|
||||
and user_agent_response.draft_payload is not None
|
||||
):
|
||||
try:
|
||||
next_preview_decision = (
|
||||
ExpenseApplicationPreviewDecisionService(db)
|
||||
.issue(
|
||||
facts,
|
||||
current_user,
|
||||
conversation_id=payload.conversation_id or "",
|
||||
request_id=(f"next:{consumed_preview_decision_id}:{payload.request_id or ''}")[
|
||||
:120
|
||||
],
|
||||
decision_source="server_draft",
|
||||
)
|
||||
.decision
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(next_preview_decision)
|
||||
except Exception as error:
|
||||
# 业务动作已经在 UserAgent 内提交;续签仅是派生能力,失败不能反转成功结果。
|
||||
db.rollback()
|
||||
next_preview_decision = None
|
||||
logger.warning(
|
||||
"费用申请草稿已保存,但后续预览决策签发失败:decision_id=%s error_type=%s",
|
||||
consumed_preview_decision_id,
|
||||
type(error).__name__,
|
||||
)
|
||||
|
||||
return ExpenseApplicationPreviewActionResponse(
|
||||
status="succeeded",
|
||||
conversation_id=payload.conversation_id,
|
||||
result=ExpenseApplicationPreviewActionResult(
|
||||
message=user_agent_response.answer,
|
||||
answer=user_agent_response.answer,
|
||||
suggested_actions=[
|
||||
action.model_dump(mode="json") for action in user_agent_response.suggested_actions
|
||||
],
|
||||
risk_flags=user_agent_response.risk_flags,
|
||||
requires_confirmation=user_agent_response.requires_confirmation,
|
||||
draft_payload=(
|
||||
user_agent_response.draft_payload.model_dump(mode="json")
|
||||
if user_agent_response.draft_payload is not None
|
||||
else None
|
||||
),
|
||||
decision_id=(next_preview_decision.id if next_preview_decision is not None else None),
|
||||
decision_expires_at=(
|
||||
next_preview_decision.expires_at if next_preview_decision is not None else None
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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"])
|
||||
|
||||
56
server/src/app/core/expense_application_fingerprint_keys.py
Normal file
56
server/src/app/core/expense_application_fingerprint_keys.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import SERVER_DIR
|
||||
|
||||
FINGERPRINT_KEY_DIRECTORY = SERVER_DIR / ".secrets" / "expense-application-fingerprints"
|
||||
ACTIVE_FINGERPRINT_KEY_VERSION = "v1"
|
||||
KEY_BYTES = 32
|
||||
_VERSION_PATTERN = re.compile(r"^[a-zA-Z0-9._-]{1,32}$")
|
||||
|
||||
|
||||
def get_expense_application_fingerprint_key(
|
||||
version: str,
|
||||
*,
|
||||
create: bool,
|
||||
) -> bytes:
|
||||
normalized_version = str(version or "").strip()
|
||||
if not _VERSION_PATTERN.fullmatch(normalized_version):
|
||||
raise ValueError("费用申请指纹密钥版本无效。")
|
||||
key_path = FINGERPRINT_KEY_DIRECTORY / f"{normalized_version}.key"
|
||||
if not key_path.exists():
|
||||
if not create:
|
||||
raise ValueError("费用申请指纹密钥版本不可用,不能核验旧预览。")
|
||||
_create_key_atomically(key_path)
|
||||
if key_path.is_symlink() or not key_path.is_file():
|
||||
raise ValueError("费用申请指纹密钥文件无效。")
|
||||
os.chmod(key_path, 0o600)
|
||||
encoded = key_path.read_text(encoding="utf-8").strip()
|
||||
try:
|
||||
key = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||
except (binascii.Error, ValueError, UnicodeError) as error:
|
||||
raise ValueError("费用申请指纹密钥内容无效。") from error
|
||||
if len(key) != KEY_BYTES:
|
||||
raise ValueError("费用申请指纹密钥长度无效。")
|
||||
return key
|
||||
|
||||
|
||||
def _create_key_atomically(key_path: Path) -> None:
|
||||
key_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.chmod(key_path.parent, 0o700)
|
||||
encoded = base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES))
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
try:
|
||||
descriptor = os.open(key_path, flags, 0o600)
|
||||
except FileExistsError:
|
||||
return
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
stream.write(encoded)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
105
server/src/app/models/ai_application_preview.py
Normal file
105
server/src/app/models/ai_application_preview.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import CheckConstraint, DateTime, Index, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.db.base_class import Base
|
||||
|
||||
|
||||
class AIApplicationPreviewDecision(Base):
|
||||
"""费用申请落单前,由服务端签发并短期持有的不可变建议快照。"""
|
||||
|
||||
__tablename__ = "ai_application_preview_decisions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"id",
|
||||
name="uq_ai_application_preview_decisions_tenant_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"actor_id",
|
||||
"auth_session_id",
|
||||
"issue_request_id",
|
||||
name="uq_ai_application_preview_decisions_issue_request",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('issued', 'consumed', 'expired', 'revoked')",
|
||||
name="ck_ai_application_preview_decisions_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"decision_source IN ('heuristic', 'rule', 'hybrid', 'server_draft')",
|
||||
name="ck_ai_application_preview_decisions_source",
|
||||
),
|
||||
CheckConstraint(
|
||||
"consumed_action IS NULL OR consumed_action IN ('save_draft', 'submit')",
|
||||
name="ck_ai_application_preview_decisions_consumed_action",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status != 'consumed' OR ("
|
||||
"consumed_action IS NOT NULL AND consumed_request_id IS NOT NULL AND "
|
||||
"consumed_claim_id IS NOT NULL AND consumed_business_event_id IS NOT NULL AND "
|
||||
"consumed_final_fingerprint IS NOT NULL AND consumed_at IS NOT NULL)",
|
||||
name="ck_ai_application_preview_decisions_consumed_fields",
|
||||
),
|
||||
CheckConstraint(
|
||||
"expires_at > created_at",
|
||||
name="ck_ai_application_preview_decisions_expiry",
|
||||
),
|
||||
Index(
|
||||
"ix_ai_application_preview_decisions_actor_status_expiry",
|
||||
"tenant_id",
|
||||
"actor_id",
|
||||
"status",
|
||||
"expires_at",
|
||||
),
|
||||
Index(
|
||||
"ix_ai_application_preview_decisions_conversation",
|
||||
"tenant_id",
|
||||
"conversation_id",
|
||||
"created_at",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
primary_key=True,
|
||||
default=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
tenant_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
actor_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
auth_session_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
conversation_id: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
||||
decision_type: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
decision_source: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
field_keys_json: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
field_fingerprints_json: Mapped[dict[str, str]] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
)
|
||||
snapshot_fingerprint: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
fingerprint_key_version: Mapped[str] = mapped_column(String(40), nullable=False)
|
||||
source_version_json: Mapped[dict[str, Any]] = mapped_column(
|
||||
JSON,
|
||||
nullable=False,
|
||||
default=dict,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="issued")
|
||||
issue_request_id: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
consumed_action: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
consumed_request_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
consumed_claim_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
consumed_business_event_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
consumed_final_fingerprint: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
19
server/src/app/schemas/expense_application_preview.py
Normal file
19
server/src/app/schemas/expense_application_preview.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExpenseApplicationPreviewDecisionCreate(BaseModel):
|
||||
message: str = Field(min_length=1, max_length=4000)
|
||||
conversation_id: str | None = Field(default=None, max_length=120)
|
||||
request_id: str = Field(min_length=1, max_length=120)
|
||||
|
||||
|
||||
class ExpenseApplicationPreviewDecisionRead(BaseModel):
|
||||
decision_id: str
|
||||
decision_source: str
|
||||
expires_at: datetime
|
||||
application_preview: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
276
server/src/app/services/expense_application_preview_decisions.py
Normal file
276
server/src/app/services/expense_application_preview_decisions.py
Normal file
@@ -0,0 +1,276 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import CurrentUserContext
|
||||
from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.expense_case import BusinessEvent
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.services.expense_application_snapshot import (
|
||||
FINGERPRINT_KEY_VERSION,
|
||||
field_fingerprints,
|
||||
hmac_fingerprint,
|
||||
preview_response_fields,
|
||||
safe_fact_snapshot,
|
||||
)
|
||||
from app.services.expense_cases import ExpenseCaseService
|
||||
|
||||
PREVIEW_DECISION_TTL = timedelta(minutes=30)
|
||||
|
||||
|
||||
class PreviewDecisionConflictError(ValueError):
|
||||
"""预览决策已过期、已消费或与当前动作不一致。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IssuedPreviewDecision:
|
||||
decision: AIApplicationPreviewDecision
|
||||
fields: dict[str, str]
|
||||
|
||||
|
||||
class ExpenseApplicationPreviewDecisionService:
|
||||
"""签发、授权和消费费用申请预览决策,不保存明文建议值。"""
|
||||
|
||||
def __init__(self, db: Session) -> None:
|
||||
self.db = db
|
||||
|
||||
def issue(
|
||||
self,
|
||||
facts: dict[str, Any],
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
conversation_id: str,
|
||||
request_id: str,
|
||||
decision_source: str | None = None,
|
||||
) -> IssuedPreviewDecision:
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
actor_id = self._actor_id(current_user)
|
||||
auth_session_id = self._auth_session_id(current_user)
|
||||
normalized_request_id = self._required_text(request_id, "预览签发请求 ID", 120)
|
||||
values = safe_fact_snapshot(facts)
|
||||
if not values.get("time") and not values.get("location") and not values.get("reason"):
|
||||
raise ValueError("未识别到可签发的费用申请字段。")
|
||||
|
||||
existing = self.db.scalar(
|
||||
select(AIApplicationPreviewDecision).where(
|
||||
AIApplicationPreviewDecision.tenant_id == tenant_id,
|
||||
AIApplicationPreviewDecision.actor_id == actor_id,
|
||||
AIApplicationPreviewDecision.auth_session_id == auth_session_id,
|
||||
AIApplicationPreviewDecision.issue_request_id == normalized_request_id,
|
||||
)
|
||||
)
|
||||
if existing is not None:
|
||||
snapshot_fingerprint = hmac_fingerprint(
|
||||
values,
|
||||
key_version=existing.fingerprint_key_version,
|
||||
allow_key_creation=False,
|
||||
)
|
||||
if existing.snapshot_fingerprint != snapshot_fingerprint:
|
||||
raise PreviewDecisionConflictError("同一预览签发请求不能对应不同内容。")
|
||||
return IssuedPreviewDecision(existing, preview_response_fields(facts))
|
||||
|
||||
now = datetime.now(UTC)
|
||||
source = decision_source or self._decision_source(facts)
|
||||
snapshot_fingerprint = hmac_fingerprint(
|
||||
values,
|
||||
key_version=FINGERPRINT_KEY_VERSION,
|
||||
)
|
||||
decision = AIApplicationPreviewDecision(
|
||||
tenant_id=tenant_id,
|
||||
actor_id=actor_id,
|
||||
auth_session_id=auth_session_id,
|
||||
conversation_id=str(conversation_id or "").strip()[:120],
|
||||
decision_type="expense_application_prefill",
|
||||
decision_source=source,
|
||||
field_keys_json=sorted(values),
|
||||
field_fingerprints_json=field_fingerprints(
|
||||
values,
|
||||
key_version=FINGERPRINT_KEY_VERSION,
|
||||
),
|
||||
snapshot_fingerprint=snapshot_fingerprint,
|
||||
fingerprint_key_version=FINGERPRINT_KEY_VERSION,
|
||||
source_version_json={
|
||||
"schema_version": 1,
|
||||
"rule_name_fingerprint": hmac_fingerprint(
|
||||
values.get("rule_name", ""),
|
||||
key_version=FINGERPRINT_KEY_VERSION,
|
||||
),
|
||||
"rule_version_fingerprint": hmac_fingerprint(
|
||||
values.get("rule_version", ""),
|
||||
key_version=FINGERPRINT_KEY_VERSION,
|
||||
),
|
||||
},
|
||||
status="issued",
|
||||
issue_request_id=normalized_request_id,
|
||||
expires_at=now + PREVIEW_DECISION_TTL,
|
||||
)
|
||||
self.db.add(decision)
|
||||
self.db.flush()
|
||||
return IssuedPreviewDecision(decision, preview_response_fields(facts))
|
||||
|
||||
def require_for_action(
|
||||
self,
|
||||
decision_id: str,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
conversation_id: str,
|
||||
request_id: str,
|
||||
action_type: str,
|
||||
final_values: dict[str, Any],
|
||||
) -> AIApplicationPreviewDecision:
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
actor_id = self._actor_id(current_user)
|
||||
auth_session_id = self._auth_session_id(current_user)
|
||||
normalized_id = self._required_text(decision_id, "预览决策 ID", 36)
|
||||
normalized_request_id = self._required_text(request_id, "动作请求 ID", 120)
|
||||
normalized_action = self._normalize_action(action_type)
|
||||
decision = self.db.scalar(
|
||||
select(AIApplicationPreviewDecision)
|
||||
.where(
|
||||
AIApplicationPreviewDecision.id == normalized_id,
|
||||
AIApplicationPreviewDecision.tenant_id == tenant_id,
|
||||
AIApplicationPreviewDecision.actor_id == actor_id,
|
||||
AIApplicationPreviewDecision.auth_session_id == auth_session_id,
|
||||
)
|
||||
.with_for_update()
|
||||
)
|
||||
if decision is None:
|
||||
raise ValueError("预览决策不存在或不属于当前登录会话。")
|
||||
if decision.conversation_id != str(conversation_id or "").strip()[:120]:
|
||||
raise ValueError("预览决策不属于当前会话。")
|
||||
|
||||
final_fingerprint = self._final_fingerprint(decision, final_values)
|
||||
if decision.status == "consumed":
|
||||
if (
|
||||
decision.consumed_action == normalized_action
|
||||
and decision.consumed_request_id == normalized_request_id
|
||||
and decision.consumed_final_fingerprint == final_fingerprint
|
||||
):
|
||||
return decision
|
||||
raise PreviewDecisionConflictError("该预览决策已被其他动作消费,请重新生成预览。")
|
||||
if decision.status != "issued":
|
||||
raise PreviewDecisionConflictError("该预览决策当前不可使用,请重新生成预览。")
|
||||
if self._as_utc(decision.expires_at) <= datetime.now(UTC):
|
||||
raise PreviewDecisionConflictError("该预览决策已过期,请重新生成预览。")
|
||||
return decision
|
||||
|
||||
def reject_active_decision_bypass(
|
||||
self,
|
||||
current_user: CurrentUserContext,
|
||||
*,
|
||||
conversation_id: str,
|
||||
) -> None:
|
||||
"""同一登录会话已有有效签发时,不允许通过省略 decision_id 降级。"""
|
||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||
actor_id = self._actor_id(current_user)
|
||||
auth_session_id = str(current_user.auth_session_id or "").strip()[:120]
|
||||
# 旧调用没有会话标识,也不具备签发 decision_id 的前提,继续走非可信兼容路径。
|
||||
if not auth_session_id:
|
||||
return
|
||||
normalized_conversation_id = str(conversation_id or "").strip()[:120]
|
||||
active_decision = self.db.scalar(
|
||||
select(AIApplicationPreviewDecision)
|
||||
.where(
|
||||
AIApplicationPreviewDecision.tenant_id == tenant_id,
|
||||
AIApplicationPreviewDecision.actor_id == actor_id,
|
||||
AIApplicationPreviewDecision.auth_session_id == auth_session_id,
|
||||
AIApplicationPreviewDecision.conversation_id == normalized_conversation_id,
|
||||
AIApplicationPreviewDecision.status == "issued",
|
||||
AIApplicationPreviewDecision.expires_at > datetime.now(UTC),
|
||||
)
|
||||
.order_by(AIApplicationPreviewDecision.created_at.desc())
|
||||
.with_for_update()
|
||||
)
|
||||
if active_decision is not None:
|
||||
raise PreviewDecisionConflictError(
|
||||
"当前会话已有服务端签发预览,动作必须携带 decision_id。"
|
||||
)
|
||||
|
||||
def consume(
|
||||
self,
|
||||
decision: AIApplicationPreviewDecision,
|
||||
*,
|
||||
request_id: str,
|
||||
action_type: str,
|
||||
final_values: dict[str, Any],
|
||||
claim: ExpenseClaim,
|
||||
business_event: BusinessEvent,
|
||||
) -> None:
|
||||
normalized_request_id = self._required_text(request_id, "动作请求 ID", 120)
|
||||
normalized_action = self._normalize_action(action_type)
|
||||
final_fingerprint = self._final_fingerprint(decision, final_values)
|
||||
if decision.status == "consumed":
|
||||
if (
|
||||
decision.consumed_action == normalized_action
|
||||
and decision.consumed_request_id == normalized_request_id
|
||||
and decision.consumed_claim_id == claim.id
|
||||
and decision.consumed_business_event_id == business_event.id
|
||||
and decision.consumed_final_fingerprint == final_fingerprint
|
||||
):
|
||||
return
|
||||
raise PreviewDecisionConflictError("该预览决策已被消费。")
|
||||
if decision.status != "issued":
|
||||
raise PreviewDecisionConflictError("该预览决策当前不可消费。")
|
||||
decision.status = "consumed"
|
||||
decision.consumed_action = normalized_action
|
||||
decision.consumed_request_id = normalized_request_id
|
||||
decision.consumed_claim_id = claim.id
|
||||
decision.consumed_business_event_id = business_event.id
|
||||
decision.consumed_final_fingerprint = final_fingerprint
|
||||
decision.consumed_at = datetime.now(UTC)
|
||||
self.db.flush()
|
||||
|
||||
@staticmethod
|
||||
def _final_fingerprint(
|
||||
decision: AIApplicationPreviewDecision,
|
||||
final_values: dict[str, Any],
|
||||
) -> str:
|
||||
return hmac_fingerprint(
|
||||
safe_fact_snapshot(final_values),
|
||||
key_version=decision.fingerprint_key_version,
|
||||
allow_key_creation=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decision_source(facts: dict[str, Any]) -> str:
|
||||
has_rule = bool(str(facts.get("rule_version") or "").strip())
|
||||
has_transport_estimate = bool(str(facts.get("transport_estimate_source") or "").strip())
|
||||
if has_rule and has_transport_estimate:
|
||||
return "hybrid"
|
||||
if has_rule:
|
||||
return "rule"
|
||||
return "heuristic"
|
||||
|
||||
@staticmethod
|
||||
def _actor_id(current_user: CurrentUserContext) -> str:
|
||||
return str(current_user.employee_id or current_user.username or "").strip()[:120]
|
||||
|
||||
@staticmethod
|
||||
def _auth_session_id(current_user: CurrentUserContext) -> str:
|
||||
session_id = str(current_user.auth_session_id or "").strip()[:120]
|
||||
if not session_id:
|
||||
raise ValueError("当前登录会话缺少可验证的会话标识,请重新登录。")
|
||||
return session_id
|
||||
|
||||
@staticmethod
|
||||
def _required_text(value: object, label: str, limit: int) -> str:
|
||||
normalized = str(value or "").strip()[:limit]
|
||||
if not normalized:
|
||||
raise ValueError(f"{label}不能为空。")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _normalize_action(action_type: str) -> str:
|
||||
normalized = str(action_type or "").strip().lower()
|
||||
if normalized not in {"save_draft", "submit"}:
|
||||
raise ValueError("不支持的预览决策动作。")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _as_utc(value: datetime) -> datetime:
|
||||
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
||||
@@ -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
|
||||
195
server/src/app/services/expense_application_snapshot.py
Normal file
195
server/src/app/services/expense_application_snapshot.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.core.expense_application_fingerprint_keys import (
|
||||
ACTIVE_FINGERPRINT_KEY_VERSION,
|
||||
get_expense_application_fingerprint_key,
|
||||
)
|
||||
|
||||
FINGERPRINT_KEY_VERSION = ACTIVE_FINGERPRINT_KEY_VERSION
|
||||
|
||||
FACT_KEYS = (
|
||||
"application_type",
|
||||
"time",
|
||||
"location",
|
||||
"reason",
|
||||
"days",
|
||||
"transport_mode",
|
||||
"amount",
|
||||
"grade",
|
||||
"department",
|
||||
"position",
|
||||
"manager_name",
|
||||
"lodging_daily_cap",
|
||||
"subsidy_daily_cap",
|
||||
"transport_policy",
|
||||
"policy_estimate",
|
||||
"matched_city",
|
||||
"rule_name",
|
||||
"rule_version",
|
||||
"hotel_amount",
|
||||
"allowance_amount",
|
||||
"transport_estimated_amount",
|
||||
"transport_estimate_source",
|
||||
"transport_estimate_confidence",
|
||||
"policy_total_amount",
|
||||
)
|
||||
|
||||
PREVIEW_FIELD_ALIASES = {
|
||||
"applicationType": "application_type",
|
||||
"application_type": "application_type",
|
||||
"time": "time",
|
||||
"time_return": "time",
|
||||
"location": "location",
|
||||
"reason": "reason",
|
||||
"days": "days",
|
||||
"transportMode": "transport_mode",
|
||||
"transport_mode": "transport_mode",
|
||||
"amount": "amount",
|
||||
"grade": "grade",
|
||||
"department": "department",
|
||||
"position": "position",
|
||||
"managerName": "manager_name",
|
||||
"manager_name": "manager_name",
|
||||
}
|
||||
|
||||
PREVIEW_RESPONSE_KEYS = {
|
||||
"application_type": "applicationType",
|
||||
"time": "time",
|
||||
"location": "location",
|
||||
"reason": "reason",
|
||||
"days": "days",
|
||||
"transport_mode": "transportMode",
|
||||
"amount": "amount",
|
||||
"grade": "grade",
|
||||
"department": "department",
|
||||
"position": "position",
|
||||
"manager_name": "managerName",
|
||||
"lodging_daily_cap": "lodgingDailyCap",
|
||||
"subsidy_daily_cap": "subsidyDailyCap",
|
||||
"transport_policy": "transportPolicy",
|
||||
"policy_estimate": "policyEstimate",
|
||||
"matched_city": "matchedCity",
|
||||
"rule_name": "ruleName",
|
||||
"rule_version": "ruleVersion",
|
||||
"hotel_amount": "hotelAmount",
|
||||
"allowance_amount": "allowanceAmount",
|
||||
"transport_estimated_amount": "transportEstimatedAmount",
|
||||
"transport_estimate_source": "transportEstimateSource",
|
||||
"transport_estimate_confidence": "transportEstimateConfidence",
|
||||
"policy_total_amount": "policyTotalAmount",
|
||||
}
|
||||
|
||||
|
||||
def safe_text(value: object, limit: int = 500) -> str:
|
||||
return str(value or "").strip()[:limit]
|
||||
|
||||
|
||||
def safe_fact_snapshot(facts: dict[str, Any]) -> dict[str, str]:
|
||||
return {key: value for key in FACT_KEYS if (value := safe_text(facts.get(key)))}
|
||||
|
||||
|
||||
def canonical_preview_fields(preview: dict[str, Any]) -> dict[str, str]:
|
||||
fields = preview.get("fields") if isinstance(preview, dict) else None
|
||||
if not isinstance(fields, dict):
|
||||
return {}
|
||||
values: dict[str, str] = {}
|
||||
for alias, field_key in PREVIEW_FIELD_ALIASES.items():
|
||||
value = safe_text(fields.get(alias))
|
||||
if value:
|
||||
values[field_key] = value
|
||||
return values
|
||||
|
||||
|
||||
def preview_response_fields(facts: dict[str, Any]) -> dict[str, str]:
|
||||
snapshot = safe_fact_snapshot(facts)
|
||||
return {
|
||||
response_key: snapshot[field_key]
|
||||
for field_key, response_key in PREVIEW_RESPONSE_KEYS.items()
|
||||
if field_key in snapshot
|
||||
}
|
||||
|
||||
|
||||
def hmac_fingerprint(
|
||||
payload: object,
|
||||
*,
|
||||
key_version: str = FINGERPRINT_KEY_VERSION,
|
||||
allow_key_creation: bool = True,
|
||||
) -> str:
|
||||
serialized = _serialize(payload)
|
||||
secret_key = get_expense_application_fingerprint_key(
|
||||
key_version,
|
||||
create=allow_key_creation,
|
||||
)
|
||||
digest = hmac.new(
|
||||
secret_key,
|
||||
b"expense-application-preview:v1:" + serialized,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return f"hmac-sha256:{digest}"
|
||||
|
||||
|
||||
def content_fingerprint(payload: object) -> str:
|
||||
return f"sha256:{hashlib.sha256(_serialize(payload)).hexdigest()}"
|
||||
|
||||
|
||||
def field_value_fingerprint(
|
||||
value: str,
|
||||
*,
|
||||
present: bool,
|
||||
key_version: str = FINGERPRINT_KEY_VERSION,
|
||||
allow_key_creation: bool = True,
|
||||
) -> str:
|
||||
return hmac_fingerprint(
|
||||
{"present": present, "value": value if present else ""},
|
||||
key_version=key_version,
|
||||
allow_key_creation=allow_key_creation,
|
||||
)
|
||||
|
||||
|
||||
def field_fingerprints(
|
||||
values: dict[str, str],
|
||||
*,
|
||||
key_version: str = FINGERPRINT_KEY_VERSION,
|
||||
allow_key_creation: bool = True,
|
||||
) -> dict[str, str]:
|
||||
return {
|
||||
key: field_value_fingerprint(
|
||||
value,
|
||||
present=True,
|
||||
key_version=key_version,
|
||||
allow_key_creation=allow_key_creation,
|
||||
)
|
||||
for key, value in sorted(values.items())
|
||||
}
|
||||
|
||||
|
||||
def snapshot_reference(
|
||||
values: dict[str, str],
|
||||
*,
|
||||
key_version: str = FINGERPRINT_KEY_VERSION,
|
||||
allow_key_creation: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"field_keys": sorted(values),
|
||||
"value_fingerprint": hmac_fingerprint(
|
||||
values,
|
||||
key_version=key_version,
|
||||
allow_key_creation=allow_key_creation,
|
||||
),
|
||||
"fingerprint_key_version": key_version,
|
||||
}
|
||||
|
||||
|
||||
def _serialize(payload: object) -> bytes:
|
||||
return json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
420
server/tests/test_expense_application_preview_decisions.py
Normal file
420
server/tests/test_expense_application_preview_decisions.py
Normal file
@@ -0,0 +1,420 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from auth_helpers import install_legacy_header_auth_override
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.deps import get_db
|
||||
from app.core import expense_application_fingerprint_keys as fingerprint_keys
|
||||
from app.db.base import Base
|
||||
from app.main import create_app
|
||||
from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||
from app.models.ai_learning import AIDecision, AIDecisionFeedback
|
||||
from app.models.employee import Employee
|
||||
from app.models.financial_record import ExpenseClaim
|
||||
from app.models.role import Role
|
||||
from app.services.expense_application_preview_decisions import (
|
||||
ExpenseApplicationPreviewDecisionService,
|
||||
)
|
||||
|
||||
|
||||
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
|
||||
engine = create_engine(
|
||||
"sqlite+pysqlite:///:memory:",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
app = create_app()
|
||||
install_legacy_header_auth_override(app)
|
||||
|
||||
def override_db() -> Generator[Session, None, None]:
|
||||
db = session_factory()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
return TestClient(app), session_factory
|
||||
|
||||
|
||||
def seed_employee(db: Session) -> None:
|
||||
role = Role(id="role-preview-user", role_code="user", name="员工")
|
||||
employee = Employee(
|
||||
id="employee-preview-owner",
|
||||
employee_no="E-PREVIEW-001",
|
||||
name="张三",
|
||||
email="preview-owner@example.com",
|
||||
position="实施顾问",
|
||||
grade="P4",
|
||||
roles=[role],
|
||||
)
|
||||
db.add_all([role, employee])
|
||||
db.commit()
|
||||
|
||||
|
||||
def auth_headers(*, session_id: str = "session-preview-owner") -> dict[str, str]:
|
||||
return {
|
||||
"X-Auth-Username": "preview-owner@example.com",
|
||||
"X-Auth-Name": "Zhang San",
|
||||
"X-Auth-Employee-No": "E-PREVIEW-001",
|
||||
"X-Auth-Employee-Id": "employee-preview-owner",
|
||||
"X-Auth-Session-Id": session_id,
|
||||
"X-Auth-Tenant-Id": "tenant-preview",
|
||||
"X-Auth-Grade": "P4",
|
||||
"X-Auth-Role-Codes": "user",
|
||||
}
|
||||
|
||||
|
||||
def issue_preview(
|
||||
client: TestClient,
|
||||
*,
|
||||
request_id: str = "issue-preview-1",
|
||||
include_transport: bool = True,
|
||||
) -> dict:
|
||||
transport_line = "出行方式:火车\n" if include_transport else ""
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-previews",
|
||||
headers=auth_headers(),
|
||||
json={
|
||||
"message": (
|
||||
"申请时间:2026-07-20 至 2026-07-22\n"
|
||||
"地点:上海\n事由:客户现场实施\n天数:3天\n"
|
||||
f"{transport_line}申请金额:1800元"
|
||||
),
|
||||
"conversation_id": "conversation-preview-1",
|
||||
"request_id": request_id,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
|
||||
|
||||
def build_action_payload(issued: dict, *, reason: str = "客户现场实施") -> dict:
|
||||
fields = {
|
||||
**issued["application_preview"]["fields"],
|
||||
"reason": reason,
|
||||
}
|
||||
return {
|
||||
"source": "user_message",
|
||||
"user_id": "forged-user@example.com",
|
||||
"conversation_id": "conversation-preview-1",
|
||||
"action_type": "save_draft",
|
||||
"decision_id": issued["decision_id"],
|
||||
"request_id": "action-preview-save-1",
|
||||
"message": (
|
||||
"费用申请保存草稿\n申请时间:2026-07-20 至 2026-07-22\n"
|
||||
f"地点:上海\n事由:{reason}\n申请金额:1800元\n保存草稿"
|
||||
),
|
||||
"context_json": {
|
||||
"application_action": "save_draft",
|
||||
"application_save_mode": True,
|
||||
"application_preview": {
|
||||
"modelReviewStatus": "server_registered",
|
||||
"fields": fields,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_legacy_action_payload() -> dict:
|
||||
return build_action_payload(
|
||||
{
|
||||
"decision_id": "legacy-placeholder",
|
||||
"application_preview": {
|
||||
"fields": {
|
||||
"time": "2026-07-20 至 2026-07-22",
|
||||
"location": "上海",
|
||||
"reason": "客户现场实施",
|
||||
"days": "3天",
|
||||
"transportMode": "火车",
|
||||
"amount": "1800元",
|
||||
"grade": "P4",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_server_preview_decision_is_consumed_with_verified_feedback() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
issued = issue_preview(client)
|
||||
action_payload = build_action_payload(issued)
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=auth_headers(),
|
||||
json=action_payload,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
result = response.json()["result"]
|
||||
assert result["draft_payload"]["claim_id"]
|
||||
assert result["decision_id"]
|
||||
assert result["decision_id"] != issued["decision_id"]
|
||||
retry_response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=auth_headers(),
|
||||
json=action_payload,
|
||||
)
|
||||
assert retry_response.status_code == 200, retry_response.text
|
||||
assert (
|
||||
retry_response.json()["result"]["draft_payload"]["claim_id"]
|
||||
== result["draft_payload"]["claim_id"]
|
||||
)
|
||||
assert retry_response.json()["result"]["decision_id"] == result["decision_id"]
|
||||
|
||||
with session_factory() as db:
|
||||
consumed = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||||
next_decision = db.get(AIApplicationPreviewDecision, result["decision_id"])
|
||||
decision = db.scalar(select(AIDecision))
|
||||
feedback = db.scalar(select(AIDecisionFeedback))
|
||||
assert consumed is not None
|
||||
assert consumed.status == "consumed"
|
||||
assert consumed.consumed_action == "save_draft"
|
||||
assert consumed.field_fingerprints_json
|
||||
assert "客户现场实施" not in str(consumed.field_fingerprints_json)
|
||||
assert next_decision is not None
|
||||
assert next_decision.status == "issued"
|
||||
assert next_decision.decision_source == "server_draft"
|
||||
assert decision is not None
|
||||
assert decision.preview_decision_id == consumed.id
|
||||
assert decision.suggestion_json["value_fingerprint"].startswith("hmac-sha256:")
|
||||
assert feedback is not None
|
||||
assert feedback.verification_status == "server_verified"
|
||||
assert feedback.feedback_type == "accepted"
|
||||
assert feedback.training_eligible is False
|
||||
assert len(list(db.scalars(select(AIDecision)).all())) == 1
|
||||
|
||||
|
||||
def test_server_preview_decision_detects_server_side_field_edit() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
issued = issue_preview(client, request_id="issue-preview-edited")
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=auth_headers(),
|
||||
json=build_action_payload(issued, reason="客户现场验收"),
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
with session_factory() as db:
|
||||
feedback = db.scalar(select(AIDecisionFeedback))
|
||||
assert feedback is not None
|
||||
assert feedback.feedback_type == "edited"
|
||||
assert feedback.verification_status == "server_verified"
|
||||
assert feedback.changed_fields_json == [
|
||||
{
|
||||
"field_key": "reason",
|
||||
"suggested_value_fingerprint": feedback.changed_fields_json[0][
|
||||
"suggested_value_fingerprint"
|
||||
],
|
||||
"final_value_fingerprint": feedback.changed_fields_json[0][
|
||||
"final_value_fingerprint"
|
||||
],
|
||||
}
|
||||
]
|
||||
assert "客户现场" not in str(feedback.changed_fields_json)
|
||||
|
||||
|
||||
def test_server_preview_decision_detects_field_added_after_issuance() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
issued = issue_preview(
|
||||
client,
|
||||
request_id="issue-preview-added-field",
|
||||
include_transport=False,
|
||||
)
|
||||
payload = build_action_payload(issued)
|
||||
payload["message"] = payload["message"].replace(
|
||||
"申请金额:1800元",
|
||||
"出行方式:飞机\n申请金额:1800元",
|
||||
)
|
||||
payload["context_json"]["application_preview"]["fields"]["transportMode"] = "飞机"
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=auth_headers(),
|
||||
json=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
with session_factory() as db:
|
||||
feedback = db.scalar(select(AIDecisionFeedback))
|
||||
assert feedback is not None
|
||||
assert feedback.feedback_type == "edited"
|
||||
assert {item["field_key"] for item in feedback.changed_fields_json} >= {"transport_mode"}
|
||||
|
||||
|
||||
def test_server_preview_decision_rejects_cross_session_replay_without_writes() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
issued = issue_preview(client, request_id="issue-preview-cross-session")
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=auth_headers(session_id="another-session"),
|
||||
json=build_action_payload(issued),
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "不属于当前登录会话" in response.json()["detail"]
|
||||
with session_factory() as db:
|
||||
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||||
assert decision is not None
|
||||
assert decision.status == "issued"
|
||||
assert list(db.scalars(select(ExpenseClaim)).all()) == []
|
||||
assert list(db.scalars(select(AIDecision)).all()) == []
|
||||
|
||||
|
||||
def test_active_server_preview_cannot_downgrade_by_omitting_decision_id() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
issued = issue_preview(client, request_id="issue-preview-downgrade")
|
||||
payload = build_action_payload(issued)
|
||||
payload.pop("decision_id")
|
||||
payload.pop("request_id")
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=auth_headers(),
|
||||
json=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == 409, response.text
|
||||
assert "必须携带 decision_id" in response.json()["detail"]
|
||||
with session_factory() as db:
|
||||
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||||
assert decision is not None
|
||||
assert decision.status == "issued"
|
||||
assert list(db.scalars(select(ExpenseClaim)).all()) == []
|
||||
assert list(db.scalars(select(AIDecision)).all()) == []
|
||||
|
||||
|
||||
def test_legacy_action_without_active_server_decision_keeps_client_observed_path() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
payload = build_legacy_action_payload()
|
||||
payload.pop("decision_id")
|
||||
payload.pop("request_id")
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=auth_headers(),
|
||||
json=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
with session_factory() as db:
|
||||
feedback = db.scalar(select(AIDecisionFeedback))
|
||||
assert feedback is not None
|
||||
assert feedback.verification_status == "client_observed"
|
||||
assert feedback.training_eligible is False
|
||||
|
||||
|
||||
def test_missing_fingerprint_key_rejects_action_without_business_writes() -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
issued = issue_preview(client, request_id="issue-preview-missing-key")
|
||||
with session_factory() as db:
|
||||
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||||
assert decision is not None
|
||||
decision.fingerprint_key_version = "missing-test-key"
|
||||
db.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=auth_headers(),
|
||||
json=build_action_payload(issued),
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
assert "密钥版本不可用" in response.json()["detail"]
|
||||
with session_factory() as db:
|
||||
decision = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||||
assert decision is not None
|
||||
assert decision.status == "issued"
|
||||
assert list(db.scalars(select(ExpenseClaim)).all()) == []
|
||||
assert list(db.scalars(select(AIDecision)).all()) == []
|
||||
|
||||
|
||||
def test_draft_save_stays_successful_when_next_decision_issuance_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
client, session_factory = build_client()
|
||||
with session_factory() as db:
|
||||
seed_employee(db)
|
||||
|
||||
original_issue = ExpenseApplicationPreviewDecisionService.issue
|
||||
|
||||
def fail_server_draft_issue(
|
||||
service: ExpenseApplicationPreviewDecisionService,
|
||||
*args: object,
|
||||
**kwargs: object,
|
||||
) -> object:
|
||||
if kwargs.get("decision_source") == "server_draft":
|
||||
raise RuntimeError("simulated next decision failure")
|
||||
return original_issue(service, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
ExpenseApplicationPreviewDecisionService,
|
||||
"issue",
|
||||
fail_server_draft_issue,
|
||||
)
|
||||
issued = issue_preview(client, request_id="issue-preview-next-failure")
|
||||
response = client.post(
|
||||
"/api/v1/reimbursements/application-preview-action",
|
||||
headers=auth_headers(),
|
||||
json=build_action_payload(issued),
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["result"]["draft_payload"]["claim_id"]
|
||||
assert response.json()["result"]["decision_id"] is None
|
||||
with session_factory() as db:
|
||||
consumed = db.get(AIApplicationPreviewDecision, issued["decision_id"])
|
||||
assert consumed is not None
|
||||
assert consumed.status == "consumed"
|
||||
assert len(list(db.scalars(select(ExpenseClaim)).all())) == 1
|
||||
assert len(list(db.scalars(select(AIDecision)).all())) == 1
|
||||
|
||||
|
||||
def test_fingerprint_key_is_created_with_private_permissions(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
key_directory = tmp_path / "fingerprint-keys"
|
||||
monkeypatch.setattr(
|
||||
fingerprint_keys,
|
||||
"FINGERPRINT_KEY_DIRECTORY",
|
||||
key_directory,
|
||||
)
|
||||
|
||||
key = fingerprint_keys.get_expense_application_fingerprint_key(
|
||||
"permission-test",
|
||||
create=True,
|
||||
)
|
||||
|
||||
key_path = key_directory / "permission-test.key"
|
||||
assert len(key) == fingerprint_keys.KEY_BYTES
|
||||
assert stat.S_IMODE(key_directory.stat().st_mode) == 0o700
|
||||
assert stat.S_IMODE(key_path.stat().st_mode) == 0o600
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 : [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user