From 211f85d981eabab74ce1fb23552c46cdaec3a0f6 Mon Sep 17 00:00:00 2001 From: caoxiaozhu Date: Tue, 14 Jul 2026 16:03:05 +0800 Subject: [PATCH] feat(ai): unify verified expense application workflow --- .../CONCEPT.md | 10 + .../TODO.md | 7 +- ...chestrator-auth-preview-decision-bypass.md | 13 + .../endpoints/expense_application_previews.py | 168 +------- .../src/app/api/v1/endpoints/orchestrator.py | 74 +++- server/src/app/schemas/steward.py | 11 +- .../src/app/services/agent_conversations.py | 40 ++ .../expense_application_preview_decisions.py | 35 +- .../expense_application_preview_workflow.py | 222 ++++++++++ server/src/app/services/orchestrator.py | 92 +++- ...chestrator_expense_application_workflow.py | 358 +++++++++++++++ .../app/services/steward_action_executor.py | 131 ++++-- .../services/steward_graph_action_runtime.py | 9 + ...t_expense_application_preview_decisions.py | 15 + .../tests/test_orchestrator_auth_endpoints.py | 407 ++++++++++++++++++ server/tests/test_orchestrator_review_flow.py | 137 +++++- server/tests/test_steward_action_executor.py | 229 +++++++++- .../travel-reimbursement-message-item.css | 46 ++ .../travel/TravelReimbursementMessageItem.vue | 38 +- .../useWorkbenchAiActionRouter.js | 9 + .../services/aiApplicationPreviewActions.js | 11 +- .../scripts/TravelReimbursementCreateView.js | 28 +- ...avelReimbursementConversationStateModel.js | 26 +- ...avelReimbursementSubmitLocalPreviewFlow.js | 20 +- ...lReimbursementApplicationPreviewActions.js | 354 +++++++++++++++ ...elReimbursementApplicationSubmitConfirm.js | 60 +-- .../useTravelReimbursementCreateViewUi.js | 5 + .../useTravelReimbursementSubmitComposer.js | 2 + .../ai-application-preview-actions.test.mjs | 33 ++ ...e-application-submit-rich-confirm.test.mjs | 28 +- ...stant-application-preview-actions.test.mjs | 339 +++++++++++++++ ...ment-conversation-preview-restore.test.mjs | 59 +++ web/tests/workbench-ai-action-router.test.mjs | 182 ++------ 33 files changed, 2793 insertions(+), 405 deletions(-) create mode 100644 document/development/2026-07-14/dev-logs/bugs/orchestrator-auth-preview-decision-bypass.md create mode 100644 server/src/app/services/expense_application_preview_workflow.py create mode 100644 server/src/app/services/orchestrator_expense_application_workflow.py create mode 100644 server/tests/test_orchestrator_auth_endpoints.py create mode 100644 web/src/views/scripts/useTravelReimbursementApplicationPreviewActions.js create mode 100644 web/tests/financial-assistant-application-preview-actions.test.mjs create mode 100644 web/tests/travel-reimbursement-conversation-preview-restore.test.mjs diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md index 64b9cb4..728018e 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/CONCEPT.md @@ -253,6 +253,8 @@ - API 新字段优先追加,不在同一阶段破坏现有前端契约。 - 从未成功签发 `decision_id` 的旧申请预览,或签发接口失败且服务端没有当前会话有效决策时,保存/提交继续走现有 `client_observed` 路径;当前会话一旦存在有效服务端决策,省略 `decision_id` 必须拒绝,不能主动降级绕过核验。兼容路径不得在用户已经编辑后把最终值反向登记成“原始建议”。 - 新预览使用 30 分钟有效的服务端 `decision_id`。保存草稿或提交成功后一次消费;保存草稿会尽力签发基于已保存事实的新 `decision_id`,供后续提交继续核验,但续签属于业务提交后的派生能力,续签失败不能把已成功保存返回为失败。相同动作请求 ID、动作和最终指纹允许安全重放,其他重放拒绝。 +- AI 工作台、小财管家和通用 Orchestrator 的结构化申请预览统一复用服务端签发/消费工作流。小财管家签发失败时保持可编辑和可重试,但保存、提交必须 fail-closed,不再降级到旧 Orchestrator 副作用;只有没有结构化预览的历史消息保留旧兼容入口。 +- Orchestrator 将已签发 decision 保存在服务端会话状态,后续保存/提交只消费该状态,不采信浏览器回传的 preview 或 decision。保存草稿返回的新 decision 继续写回会话,服务端消息中的结构化预览可在刷新或换端后恢复。 - 迁移桥接期集中维护 migration-owned 表集合;所有 legacy bootstrap 只能创建集合之外的表。标准启动在 `alembic upgrade head` 前只读核对 revision 与自有表集合,发现漂移时拒绝启动,不自动猜测、stamp 或修复。 - 所有表结构通过 Alembic 迁移,不继续在请求路径执行 DDL。 @@ -272,6 +274,7 @@ - P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。 - Expense Case 用户态查询只返回流程摘要:Case 基础状态、关系类型、事件类型、操作人、发生时间及白名单业务载荷;不返回关联资源 ID、幂等键、correlation、causation、聚合标识或 Outbox 投递状态。有权查看入口单据的本人、当前审批人、财务和管理员可查看整 Case 的安全摘要,无权限与跨租户查询继续以 404 隐藏资源存在性。 - 申请预览签发与消费同时绑定 `tenant_id`、actor、Bearer 登录会话和会话 ID;动作入口按这些服务端事实锁行校验,不能只凭随机 UUID 授权。浏览器提供的模型来源、`finalValue`、用户和角色声明均不作为可信事实。 +- 外部 `/orchestrator/run` 的用户消息、定时任务和系统事件都必须携带有效登录会话,不能由请求体中的 `source` 自行声明可信内部来源;其中 `schedule` / `system_event` 只允许平台管理员触发。服务端使用登录态覆盖用户、租户、角色、管理员、员工、审批人和调度操作人别名,并移除客户端 preview/decision 状态。最近会话查询与删除只使用当前登录用户和租户,查询参数中的 `user_id` 仅保留协议兼容,不参与授权。 - 预览与学习账本中的字段值使用独立、可版本化的费用申请密钥计算 HMAC-SHA256 指纹,密钥目录和文件权限分别为 `0700`、`0600`;核验旧决策必须使用其签发版本,缺失版本直接拒绝,不静默生成替代密钥。字段指纹同时编码“字段是否存在”,避免新增或删除字段被误判为采纳;训练资格仍保持关闭,直至审批、付款或人工核验闭环完成。 - 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。 - 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。 @@ -458,6 +461,7 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \ - 历史证据边界:旧单快照只表达回填时可确认的当前状态,迁移前逐节点办理过程仍以原单据和既有审计为准;后续分析不得把快照事件误当成历史审批事实。 - 认证和租户:当前客户端身份头不适合自动化和 SaaS,多租户、记忆和高风险动作开发前必须修复。 - 会话运维:不透明会话已经替代客户端身份头,但仍需补充定时清理、活跃会话查看/全部退出、密钥轮换策略、登录限流和企业 SSO;当前 `tenant_id` 仍是最小契约,不代表跨租户查询守卫已经完成。 +- Agent 会话租户边界:Orchestrator 与 Steward 动作运行时都把可信 `tenant_id` 写入会话状态;创建、恢复、幂等检查点重放、单条删除和批量删除同时校验租户与用户名,同名用户不能跨租户复用 decision 或动作结果。历史无租户状态的会话在认证入口 fail-closed,不会被恢复或删除。`agent_conversations` 仍缺少独立 `tenant_id` 列和数据库复合约束,后续迁移需要把当前应用层守卫下沉为结构化租户键。 - 费用事件读取边界:用户态精简 DTO 和首批 HTTP 权限测试已完成;剩余风险是同一 URL 若已有外部客户端依赖旧内部字段会产生契约变更,且未来新增敏感 payload 字段必须继续显式进入白名单,不能恢复任意字典透传。 - 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。 - 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 shadow、Canary、抽检和回滚。 @@ -528,3 +532,9 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \ - 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。 +- 2026-07-14(多入口统一决策闭环):抽取 `ExpenseApplicationPreviewWorkflow`,认证签发、锁行消费、动作幂等、学习落账和草稿续签不再由 HTTP 端点重复编排。Steward、小财管家结构化预览和通用 Orchestrator 均复用该工作流;用户纠正字段后仍消费原始 decision,使服务端能够把建议值与最终业务事实记为 `server_verified` 接受或纠正证据。 +- 2026-07-14(Orchestrator 身份与会话边界):外部用户消息、定时任务和系统事件统一强制认证,`schedule` / `system_event` 进一步要求平台管理员;登录态覆盖请求中的身份、租户和调度操作人别名,堵住匿名或普通用户伪造来源触发 Hermes 管理任务的路径。会话创建、恢复和删除同时绑定可信租户与用户名,历史无租户状态会话 fail-closed。申请 decision 只从服务端会话恢复,客户端 preview/decision 在进入编排前被移除;保存草稿后的 next decision 回写会话并支持刷新/换端恢复结构化预览。 +- 2026-07-14(小财管家 fail-closed):完整预览展示前先使用稳定 request ID 签发 canonical decision;签发失败时允许继续编辑和以同一 ID 重试,但禁止保存/提交且不回退旧副作用链路。保存/提交失败重试复用稳定 action request ID,保存成功同步草稿信息与 next decision。 +- 2026-07-14(decision 生命周期加固):同一租户、用户、登录会话、conversation 和 HMAC 快照即使使用不同签发 request ID,也复用当前 active decision,避免多个并行 decision 命中草稿幂等捷径后遗留未消费状态。动作发现 decision 过期、已消费或不可用时,前端清空旧 ID、生成新的签发 request ID 并开放重新签发。 +- 2026-07-14(跨租户检查点加固):Steward 动作会话创建显式写入服务端租户;租户 A/B 即使使用相同用户名、conversation 和 trace,也会获得独立会话与 decision,不再跨租户返回幂等结果或敏感预览内容。 +- 2026-07-14(统一闭环验证):容器内服务端预览、Steward 动作/图运行、Orchestrator 外部来源授权及决策消费组合回归 50 项通过,Python Ruff F/I 通过;前端结构化动作、会话恢复、工作台路由、富确认和动作脚本共 18 项通过,Vite 生产构建通过。并行读取用户正在变动的规则工作簿曾触发 `openpyxl` ZIP 句柄竞争,相关套件改为串行后全部通过,未修改规则工作簿。 diff --git a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md index 5791485..139f6e6 100644 --- a/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md +++ b/document/development/2026-07-13/feature/ai-expense-closed-loop-and-value-proof/TODO.md @@ -81,6 +81,8 @@ 证据:`expense_application_draft_events.py`、`user_agent_application.py`、`reimbursements.py`、`steward_action_executor.py`、`test_user_agent_application_draft_events.py`、`test_reimbursement_endpoints.py`、`test_steward_action_executor.py`;完全相同 HTTP 保存重放复用同一草稿和事件,同一 run 内不同快照分别留痕,事件失败后草稿与 Case 数据整体回滚。 - [x] [CONCEPT: 权限与安全] 将 AI 申请预览快速入口的用户、租户、角色与管理员身份强制绑定到服务端会话,拒绝请求体伪造身份编辑他人申请。 证据:`reimbursements.py`、`test_reimbursement_endpoints.py`;对抗用例修复前返回 200,修复后返回 400,且目标申请和费用事件保持不变。 +- [x] [CONCEPT: 权限与安全] 收口通用 Orchestrator 用户消息和会话管理的认证边界,拒绝客户端身份与 decision 注入。 + 证据:`orchestrator.py`、`agent_conversations.py`、`orchestrator_expense_application_workflow.py`、`steward_graph_action_runtime.py`、`test_orchestrator_auth_endpoints.py`、`test_steward_action_executor.py`、`test_orchestrator_review_flow.py`;用户消息、定时任务和系统事件无认证均返回 401,普通用户触发 `schedule` / `system_event` 返回 403,登录态覆盖请求身份和调度操作人别名;Orchestrator 会话创建/恢复/删除及 Steward 幂等检查点同时校验租户与用户名,申请动作只消费服务端会话 decision。 - [x] [CONCEPT: 兼容策略] 建立迁移桥接:服务启动先执行 Alembic,旧 metadata bootstrap 排除 migration-owned 表。 证据:`server_start.sh`、`schema_ownership.py`、`migration_preflight.py`、`20260713_0001_expense_case_business_events.py`、`20260713_0002_auth_sessions.py`;容器 Shell/静态检查及一次性 PostgreSQL 完整 upgrade/downgrade/re-upgrade 通过。 - [x] [CONCEPT: 兼容策略] 为迁移前已有 `ExpenseClaim` 提供显式、幂等且不伪造办理历史的费用事件快照回填。 @@ -117,7 +119,8 @@ - [ ] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。 - [x] [CONCEPT: 记忆与学习] 为 AI 申请预填记录用户原样采纳、显式字段修改和草稿/提交结果证据。 证据:`expenseApplicationDecisionFeedback.js`、`useApplicationPreviewEditor.js`、`expense_application_learning.py`、`expense_application_preview_decisions.py`;改回原建议会清除字段差异,日期联动同步记录天数,最终值由服务端 facts 重建。旧预览保持 `client_observed`;服务端签发预览由版本化 HMAC 快照与最终 facts 逐字段比对,字段新增、删除或改值都标记为 `server_verified` 编辑。两者均保持 `training_eligible=false`,尚不直接训练模型或激活记忆。 -- [ ] [CONCEPT: 记忆与学习] 将小财管家/通用 Orchestrator 的申请预览切换到认证签发与消费链路;在此之前不得把其客户端 preview 升级为服务端核验证据。 +- [x] [CONCEPT: 记忆与学习] 将小财管家、Steward 与通用 Orchestrator 的申请预览切换到认证签发与消费链路,并把结构化预览失败策略收口为 fail-closed。 + 证据:`expense_application_preview_workflow.py`、`orchestrator_expense_application_workflow.py`、`steward_action_executor.py`、`useTravelReimbursementApplicationPreviewActions.js`;签发/动作请求 ID 可稳定重试,草稿续签回写服务端会话,字段接受/纠正按 `server_verified` 落账,未签发结构化预览不能保存或提交。 - [ ] [CONCEPT: 记忆与学习] 从字段接受/修改/拒绝、退回、审批覆盖、付款和审计结果生成记忆证据。 - [ ] [CONCEPT: 记忆与学习] 将已确认 few-shot 扩展到报销预审和审批辅助,并按租户、场景、制度版本过滤。 - [ ] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚。 @@ -187,6 +190,8 @@ - [ ] [CONCEPT: 测试方案] 补充其余前端组件、键盘操作、移动真实接口和完整浏览器关键流程验证。 - [x] [CONCEPT: 测试方案] 验证服务端预览决策签发、字段新增/改值差异判定、跨会话拒绝、无 ID 降级防绕过、安全重放、独立密钥权限、续签失败容错、旧路径兼容、会话恢复和真实 PostgreSQL 迁移。 证据:容器内新增决策安全用例 9 项、旧快速保存/提交 4 项、申请学习账本 9 项、迁移与所有权 26 项通过且条件型 PostgreSQL 用例 1 项跳过;一次性 PostgreSQL 17 迁移 4 项、前端定向 3 组及 Vite 生产构建已通过。临时 PostgreSQL 已清理,持久开发库 8 张 migration-owned 表数量仍为 0。 +- [x] [CONCEPT: 测试方案] 验证小财管家、Steward、通用 Orchestrator 的统一预览闭环、认证绑定、fail-closed、稳定重试和跨刷新恢复。 + 证据:容器内后端组合回归 50 项通过,覆盖服务端预览、Steward 动作/图运行、跨租户 checkpoint、Orchestrator 匿名/普通用户/管理员来源授权及决策消费,Python Ruff F/I 通过;前端结构化动作、会话恢复、工作台路由、富确认和 `ai-application-preview-actions` 共 18 项通过,Vite 生产构建通过。共享规则工作簿相关套件按容器内串行执行,避免并行读取正在变动的 XLSX 产生非业务性 ZIP 竞争。 - [ ] [CONCEPT: 测试方案] 所有后端、集成和迁移测试在当前主应用容器内执行,单条命令最大超时 60s。 - [ ] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据。 diff --git a/document/development/2026-07-14/dev-logs/bugs/orchestrator-auth-preview-decision-bypass.md b/document/development/2026-07-14/dev-logs/bugs/orchestrator-auth-preview-decision-bypass.md new file mode 100644 index 0000000..88e8c64 --- /dev/null +++ b/document/development/2026-07-14/dev-logs/bugs/orchestrator-auth-preview-decision-bypass.md @@ -0,0 +1,13 @@ +# Orchestrator 未认证与费用申请决策绕过 + +日期:2026-07-14 +文档路径:document/development/2026-07-14/dev-logs/bugs/orchestrator-auth-preview-decision-bypass.md + +## 修复记录 + +- 15:43:记录 bug 修复:Orchestrator 未认证与费用申请决策绕过。 + - Git 提交检查:`git fetch --all --prune` 成功,`origin/main` 没有新提交;本地 ahead 9 条,依次为 `5b246307` 服务端申请预览决策、`a662cfe6` 申请反馈账本、`5ed34c2b` 历史费用 Case 回填、`11275e4b` 迁移所有权安全、`1347366b` 安全时间线与草稿事件、`22669a90` 统一费用时间线、`a616b30c` AI 申请提交事务、`653eda05` Bearer 会话、`661990b2` 事务型费用事件;工作树为 dirty,未执行合并或变基。 + - 修改:`orchestrator.py` 的所有外部来源改为强制认证,`schedule` / `system_event` 进一步要求平台管理员;`OrchestratorService` 用登录态覆盖客户端身份与调度操作人别名并丢弃 preview/decision 注入。`agent_conversations.py` 与 Steward 动作运行时使用可信租户状态隔离会话创建、恢复、幂等检查点和删除;统一申请预览工作流要求 Steward、工作台和小财管家先签发再保存/提交,并复用同会话同快照的 active decision。 + - 操作:把 Orchestrator 申请 decision 保存到服务端会话,草稿保存后的 next decision 继续回写;工作台 Steward 申请动作改为先打开结构化签发预览。过期/已消费 decision 会清空旧 ID 并进入可重新签发状态;租户 A/B 使用相同用户名、conversation 和 trace 时创建独立会话与 decision。所有验证均在 `x-financial-local-linux` 容器 `/app` 内执行,未修改七个用户规则工作簿。 + - 验证:后端组合回归 50 项通过,覆盖服务端预览、Steward 动作/图运行、跨租户 checkpoint、Orchestrator 匿名/普通用户/管理员来源授权与 decision 消费,Python Ruff F/I 通过;前端结构化动作、会话恢复、工作台动作路由、富确认和动作脚本 18 项通过,Vite 生产构建通过。共享工作簿并行读取曾触发 `openpyxl` ZIP 竞争,串行复跑相关套件后全部通过。 + - 影响:未登录用户不能再通过伪造 `source` 调用 Orchestrator,普通用户也不能伪装调度来源触发 Hermes 管理任务;同名用户不能跨租户恢复、删除或重放 Steward 会话检查点。申请保存/提交只消费当前登录会话签发的 decision,重复签发、过期和失败均有确定的安全处理。 diff --git a/server/src/app/api/v1/endpoints/expense_application_previews.py b/server/src/app/api/v1/endpoints/expense_application_previews.py index a52d669..fb25d76 100644 --- a/server/src/app/api/v1/endpoints/expense_application_previews.py +++ b/server/src/app/api/v1/endpoints/expense_application_previews.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, status @@ -11,24 +10,18 @@ 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.expense_application_preview_workflow import ( + ExpenseApplicationPreviewWorkflow, ) -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)] @@ -48,48 +41,13 @@ def issue_expense_application_preview_decision( 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) + return ExpenseApplicationPreviewWorkflow(db).issue(payload, current_user) 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", @@ -104,127 +62,9 @@ def run_application_preview_action( 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 "", - ) + return ExpenseApplicationPreviewWorkflow(db).execute(payload, current_user) except PreviewDecisionConflictError as error: - db.rollback() raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error except ValueError as error: - db.rollback() raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error - - next_preview_decision = None - if ( - preview_decision is not None - and resolved_action == "save_draft" - and user_agent_response.draft_payload is not None - ): - try: - next_preview_decision = ( - ExpenseApplicationPreviewDecisionService(db) - .issue( - facts, - current_user, - conversation_id=payload.conversation_id or "", - request_id=(f"next:{consumed_preview_decision_id}:{payload.request_id or ''}")[ - :120 - ], - decision_source="server_draft", - ) - .decision - ) - db.commit() - db.refresh(next_preview_decision) - except Exception as error: - # 业务动作已经在 UserAgent 内提交;续签仅是派生能力,失败不能反转成功结果。 - db.rollback() - next_preview_decision = None - logger.warning( - "费用申请草稿已保存,但后续预览决策签发失败:decision_id=%s error_type=%s", - consumed_preview_decision_id, - type(error).__name__, - ) - - return ExpenseApplicationPreviewActionResponse( - status="succeeded", - conversation_id=payload.conversation_id, - result=ExpenseApplicationPreviewActionResult( - message=user_agent_response.answer, - answer=user_agent_response.answer, - suggested_actions=[ - action.model_dump(mode="json") for action in user_agent_response.suggested_actions - ], - risk_flags=user_agent_response.risk_flags, - requires_confirmation=user_agent_response.requires_confirmation, - draft_payload=( - user_agent_response.draft_payload.model_dump(mode="json") - if user_agent_response.draft_payload is not None - else None - ), - decision_id=(next_preview_decision.id if next_preview_decision is not None else None), - decision_expires_at=( - next_preview_decision.expires_at if next_preview_decision is not None else None - ), - ), - ) diff --git a/server/src/app/api/v1/endpoints/orchestrator.py b/server/src/app/api/v1/endpoints/orchestrator.py index 9fbbfe4..754c7a5 100644 --- a/server/src/app/api/v1/endpoints/orchestrator.py +++ b/server/src/app/api/v1/endpoints/orchestrator.py @@ -5,7 +5,12 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.orm import Session -from app.api.deps import get_db +from app.api.deps import ( + CurrentUserContext, + get_current_user, + get_db, + get_optional_current_user, +) from app.schemas.common import ErrorResponse from app.schemas.orchestrator import ( ConversationDeleteResponse, @@ -18,6 +23,11 @@ from app.services.orchestrator import OrchestratorService router = APIRouter(prefix="/orchestrator") DbSession = Annotated[Session, Depends(get_db)] +CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)] +OptionalCurrentUser = Annotated[ + CurrentUserContext | None, + Depends(get_optional_current_user), +] @router.post( @@ -32,9 +42,24 @@ DbSession = Annotated[Session, Depends(get_db)] } }, ) -def run_orchestrator(payload: OrchestratorRequest, db: DbSession) -> OrchestratorResponse: +def run_orchestrator( + payload: OrchestratorRequest, + db: DbSession, + current_user: OptionalCurrentUser, +) -> OrchestratorResponse: + if current_user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="请先登录后再使用智能助手。", + headers={"WWW-Authenticate": "Bearer"}, + ) + if payload.source in {"schedule", "system_event"} and not current_user.is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有平台管理员可以从外部触发调度或系统事件。", + ) try: - return OrchestratorService(db).run(payload) + return OrchestratorService(db).run(payload, current_user=current_user) except ValueError as exc: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc @@ -46,14 +71,25 @@ def run_orchestrator(payload: OrchestratorRequest, db: DbSession) -> Orchestrato description="返回当前用户最近一段可恢复的对话会话及完整消息历史。", ) def get_latest_conversation( - user_id: Annotated[str, Query(min_length=1, description="当前用户 ID。")], db: DbSession, - session_type: Annotated[str | None, Query(description="会话类型,例如 expense / knowledge。")] = None, - prefer_recoverable: Annotated[bool, Query(description="是否优先返回最近一条可恢复的会话。")] = False, + current_user: CurrentUser, + user_id: Annotated[ + str | None, + Query(description="兼容旧客户端;服务端始终使用当前登录用户。"), + ] = None, + session_type: Annotated[ + str | None, + Query(description="会话类型,例如 expense / knowledge。"), + ] = None, + prefer_recoverable: Annotated[ + bool, + Query(description="是否优先返回最近一条可恢复的会话。"), + ] = False, ) -> ConversationLookupResponse: service = AgentConversationService(db) conversation = service.get_latest_conversation_for_user( - user_id=user_id, + user_id=current_user.username, + tenant_id=current_user.tenant_id, source="user_message", session_type=session_type, prefer_recoverable=prefer_recoverable, @@ -75,12 +111,18 @@ def get_latest_conversation( ) def delete_single_conversation( conversation_id: str, - user_id: Annotated[str, Query(min_length=1, description="当前用户 ID。")], db: DbSession, + current_user: CurrentUser, + user_id: Annotated[ + str | None, + Query(description="兼容旧客户端;服务端始终使用当前登录用户。"), + ] = None, ) -> ConversationDeleteResponse: + del user_id deleted_count = AgentConversationService(db).delete_conversation( conversation_id=conversation_id, - user_id=user_id, + user_id=current_user.username, + tenant_id=current_user.tenant_id, source="user_message", ) return ConversationDeleteResponse(deleted_count=deleted_count) @@ -93,12 +135,20 @@ def delete_single_conversation( description="删除当前用户在智能体工作台中的全部历史会话,用于显式开启全新对话。", ) def delete_user_conversations( - user_id: Annotated[str, Query(min_length=1, description="当前用户 ID。")], db: DbSession, - session_type: Annotated[str | None, Query(description="可选,会话类型,例如 expense / knowledge。")] = None, + current_user: CurrentUser, + user_id: Annotated[ + str | None, + Query(description="兼容旧客户端;服务端始终使用当前登录用户。"), + ] = None, + session_type: Annotated[ + str | None, + Query(description="可选,会话类型,例如 expense / knowledge。"), + ] = None, ) -> ConversationDeleteResponse: deleted_count = AgentConversationService(db).delete_user_conversations( - user_id=user_id, + user_id=current_user.username, + tenant_id=current_user.tenant_id, source="user_message", session_type=session_type, ) diff --git a/server/src/app/schemas/steward.py b/server/src/app/schemas/steward.py index aaf2108..dd4692d 100644 --- a/server/src/app/schemas/steward.py +++ b/server/src/app/schemas/steward.py @@ -160,7 +160,16 @@ class StewardActionExecuteRequest(BaseModel): action_step: StewardActionStep | None = Field(default=None, description="规划侧生成的动作步骤快照。") confirmed: bool = Field(default=False, description="用户是否已确认执行该动作。") context_json: dict[str, Any] = Field(default_factory=dict, description="前端或运行时补充上下文。") - client_trace_id: str = Field(default="", description="前端幂等或追踪 ID。") + client_trace_id: str = Field( + default="", + max_length=120, + description="前端稳定幂等 ID;申请预览签发、保存和提交动作必须显式提供。", + ) + decision_id: str = Field( + default="", + max_length=36, + description="服务端签发的申请预览决策 ID;保存和提交申请时必须显式提供。", + ) class StewardActionExecuteResponse(BaseModel): diff --git a/server/src/app/services/agent_conversations.py b/server/src/app/services/agent_conversations.py index e115e00..e6a2ef2 100644 --- a/server/src/app/services/agent_conversations.py +++ b/server/src/app/services/agent_conversations.py @@ -11,6 +11,7 @@ from app.models.agent_conversation import AgentConversation, AgentConversationMe from app.services.settings import SettingsService STATEFUL_CONTEXT_KEYS = ( + "tenant_id", "session_type", "entry_source", "request_context", @@ -21,6 +22,7 @@ STATEFUL_CONTEXT_KEYS = ( "review_form_values", "steward_state", "business_time_context", + "application_preview_decision", ) REVIEW_FLOW_CONTEXT_KEYS = { "draft_claim_id", @@ -83,12 +85,18 @@ class AgentConversationService: normalized_id = str(conversation_id or "").strip() normalized_user_id = str(user_id or "").strip() or None + incoming_tenant_id = self._normalize_tenant_id(context_json.get("tenant_id")) incoming_session_type = str(context_json.get("session_type") or "").strip() or "expense" incoming_draft_claim_id = self._resolve_draft_claim_id(context_json) conversation = self.get_conversation(normalized_id) if normalized_id else None if conversation is not None and conversation.user_id != normalized_user_id: normalized_id = "" conversation = None + if conversation is not None and incoming_tenant_id: + existing_tenant_id = self._conversation_tenant_id(conversation) + if existing_tenant_id != incoming_tenant_id: + normalized_id = "" + conversation = None if conversation is not None: existing_session_type = str((conversation.state_json or {}).get("session_type") or "").strip() or "expense" if existing_session_type != incoming_session_type: @@ -189,6 +197,7 @@ class AgentConversationService: self, *, user_id: str | None, + tenant_id: str | None = None, source: str | None = "user_message", session_type: str | None = None, prefer_recoverable: bool = False, @@ -204,6 +213,13 @@ class AgentConversationService: stmt = stmt.where(AgentConversation.source == source) stmt = stmt.order_by(AgentConversation.updated_at.desc(), AgentConversation.created_at.desc()) conversations = list(self.db.scalars(stmt).all()) + normalized_tenant_id = self._normalize_tenant_id(tenant_id) + if normalized_tenant_id: + conversations = [ + conversation + for conversation in conversations + if self._conversation_tenant_id(conversation) == normalized_tenant_id + ] normalized_session_type = str(session_type or "").strip() if not normalized_session_type: return conversations[0] if conversations else None @@ -448,6 +464,7 @@ class AgentConversationService: self, *, user_id: str | None, + tenant_id: str | None = None, source: str | None = "user_message", session_type: str | None = None, ) -> int: @@ -459,6 +476,13 @@ class AgentConversationService: if source: stmt = stmt.where(AgentConversation.source == source) conversations = list(self.db.scalars(stmt).all()) + normalized_tenant_id = self._normalize_tenant_id(tenant_id) + if normalized_tenant_id: + conversations = [ + conversation + for conversation in conversations + if self._conversation_tenant_id(conversation) == normalized_tenant_id + ] normalized_session_type = str(session_type or "").strip() if normalized_session_type: conversations = [ @@ -513,6 +537,7 @@ class AgentConversationService: *, conversation_id: str | None, user_id: str | None = None, + tenant_id: str | None = None, source: str | None = "user_message", ) -> int: normalized_id = str(conversation_id or "").strip() @@ -527,6 +552,13 @@ class AgentConversationService: if normalized_user_id and str(conversation.user_id or "").strip() != normalized_user_id: return 0 + normalized_tenant_id = self._normalize_tenant_id(tenant_id) + if ( + normalized_tenant_id + and self._conversation_tenant_id(conversation) != normalized_tenant_id + ): + return 0 + normalized_source = str(source or "").strip() if normalized_source and str(conversation.source or "").strip() != normalized_source: return 0 @@ -609,6 +641,14 @@ class AgentConversationService: return len(value) == 0 return False + @staticmethod + def _normalize_tenant_id(value: Any) -> str: + return str(value or "").strip() + + @classmethod + def _conversation_tenant_id(cls, conversation: AgentConversation) -> str: + return cls._normalize_tenant_id((conversation.state_json or {}).get("tenant_id")) + @staticmethod def _resolve_title(context_json: dict[str, Any]) -> str | None: request_context = context_json.get("request_context") diff --git a/server/src/app/services/expense_application_preview_decisions.py b/server/src/app/services/expense_application_preview_decisions.py index 4e81613..b5d2a54 100644 --- a/server/src/app/services/expense_application_preview_decisions.py +++ b/server/src/app/services/expense_application_preview_decisions.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext from app.models.ai_application_preview import AIApplicationPreviewDecision +from app.models.auth_session import AuthSession from app.models.expense_case import BusinessEvent from app.models.financial_record import ExpenseClaim from app.services.expense_application_snapshot import ( @@ -51,6 +52,13 @@ class ExpenseApplicationPreviewDecisionService: 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) + # 真实 Bearer 会话存在时锁住会话行,串行化同一登录会话内的并发签发, + # 避免两个不同 request_id 同时越过 active snapshot 查询。 + self.db.scalar( + select(AuthSession.id) + .where(AuthSession.id == auth_session_id) + .with_for_update() + ) 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"): @@ -75,16 +83,39 @@ class ExpenseApplicationPreviewDecisionService: return IssuedPreviewDecision(existing, preview_response_fields(facts)) now = datetime.now(UTC) - source = decision_source or self._decision_source(facts) + normalized_conversation_id = str(conversation_id or "").strip()[:120] snapshot_fingerprint = hmac_fingerprint( values, key_version=FINGERPRINT_KEY_VERSION, ) + active_same_snapshot = 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 > now, + AIApplicationPreviewDecision.fingerprint_key_version + == FINGERPRINT_KEY_VERSION, + AIApplicationPreviewDecision.snapshot_fingerprint == snapshot_fingerprint, + ) + .order_by(AIApplicationPreviewDecision.created_at.desc()) + .with_for_update() + ) + if active_same_snapshot is not None: + return IssuedPreviewDecision( + active_same_snapshot, + preview_response_fields(facts), + ) + + source = decision_source or self._decision_source(facts) decision = AIApplicationPreviewDecision( tenant_id=tenant_id, actor_id=actor_id, auth_session_id=auth_session_id, - conversation_id=str(conversation_id or "").strip()[:120], + conversation_id=normalized_conversation_id, decision_type="expense_application_prefill", decision_source=source, field_keys_json=sorted(values), diff --git a/server/src/app/services/expense_application_preview_workflow.py b/server/src/app/services/expense_application_preview_workflow.py new file mode 100644 index 0000000..2385a40 --- /dev/null +++ b/server/src/app/services/expense_application_preview_workflow.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import logging + +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +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 + +logger = logging.getLogger(__name__) + + +class ExpenseApplicationPreviewWorkflow: + """编排费用申请预览的签发、消费和草稿续签。""" + + def __init__(self, db: Session) -> None: + self.db = db + + def issue( + self, + payload: ExpenseApplicationPreviewDecisionCreate, + current_user: CurrentUserContext, + ) -> ExpenseApplicationPreviewDecisionRead: + run_id = f"application-preview-decision:{payload.request_id}" + 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=build_trusted_expense_application_context(current_user), + tool_payload={}, + selected_capability_codes=[], + degraded=False, + requires_confirmation=False, + ) + try: + facts = UserAgentService(self.db)._resolve_expense_application_facts(request) + issued = ExpenseApplicationPreviewDecisionService(self.db).issue( + facts, + current_user, + conversation_id=payload.conversation_id or "", + request_id=payload.request_id, + ) + self.db.commit() + self.db.refresh(issued.decision) + except (PreviewDecisionConflictError, ValueError): + self.db.rollback() + raise + + 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(), + }, + ) + + def execute( + self, + payload: ExpenseApplicationPreviewActionPayload, + current_user: CurrentUserContext, + ) -> ExpenseApplicationPreviewActionResponse: + request = self._build_action_request(payload, current_user) + try: + user_agent_service = UserAgentService(self.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(self.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, ValueError): + self.db.rollback() + raise + + 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(self.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 + ) + self.db.commit() + self.db.refresh(next_preview_decision) + except Exception as error: + # 主业务动作已提交;续签失败只能降级,不能反转保存草稿结果。 + self.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 + ), + ), + ) + + @staticmethod + def _build_action_request( + payload: ExpenseApplicationPreviewActionPayload, + current_user: CurrentUserContext, + ) -> UserAgentRequest: + 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}" + return 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, + ) diff --git a/server/src/app/services/orchestrator.py b/server/src/app/services/orchestrator.py index 2d2d7b2..238f86e 100644 --- a/server/src/app/services/orchestrator.py +++ b/server/src/app/services/orchestrator.py @@ -6,6 +6,7 @@ from typing import Any from sqlalchemy.orm import Session +from app.api.deps import CurrentUserContext from app.core.agent_enums import ( AgentAssetStatus, AgentAssetType, @@ -25,21 +26,24 @@ from app.schemas.orchestrator import ( from app.schemas.user_agent import UserAgentRequest from app.services.agent_assets import AgentAssetService from app.services.agent_conversations import AgentConversationService -from app.services.auth import AuthService -from app.services.expense_claims import ExpenseClaimService from app.services.agent_foundation import AgentFoundationService from app.services.agent_runs import AgentRunService from app.services.agent_traces import AgentTraceService +from app.services.auth import AuthService +from app.services.expense_claims import ExpenseClaimService from app.services.knowledge import KnowledgeService from app.services.ontology import SemanticOntologyService from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine +from app.services.orchestrator_expense_application_workflow import ( + OrchestratorExpenseApplicationWorkflow, +) from app.services.orchestrator_expense_query import OrchestratorDatabaseQueryBuilder +from app.services.user_agent import UserAgentService from app.services.user_agent_application import ( APPLICATION_CONTEXT_VALUES, APPLICATION_SHORT_CONFIRMATIONS, APPLICATION_SUBMIT_KEYWORDS, ) -from app.services.user_agent import UserAgentService logger = get_logger("app.services.orchestrator") @@ -51,6 +55,7 @@ SCENARIO_TO_DOMAIN = { "unknown": "system", } + class OrchestratorService: def __init__(self, db: Session) -> None: self.db = db @@ -62,6 +67,7 @@ class OrchestratorService: self.trace_service = AgentTraceService(db) self.ontology_service = SemanticOntologyService(db) self.user_agent_service = UserAgentService(db) + self.expense_application_workflow = OrchestratorExpenseApplicationWorkflow(db) self.database_query_builder = OrchestratorDatabaseQueryBuilder(db) self.execution_engine = OrchestratorExecutionEngine( db=db, @@ -73,7 +79,14 @@ class OrchestratorService: trace_service=self.trace_service, ) - def run(self, payload: OrchestratorRequest) -> OrchestratorResponse: + def run( + self, + payload: OrchestratorRequest, + *, + current_user: CurrentUserContext | None = None, + ) -> OrchestratorResponse: + if current_user is not None: + payload = self._build_authenticated_user_payload(payload, current_user) AgentFoundationService(self.db).ensure_foundation_ready() context_json = self._hydrate_user_context( user_id=payload.user_id, @@ -230,6 +243,24 @@ class OrchestratorService: route_json["task_code"] = task_asset.code route_json["task_name"] = task_asset.name + authenticated_application_outcome = None + if ( + is_expense_application_context + and current_user is not None + and ontology.permission.level != AgentPermissionLevel.FORBIDDEN.value + ): + authenticated_application_outcome = ( + self.expense_application_workflow.execute( + payload=payload, + current_user=current_user, + run_id=run.run_id, + conversation_id=conversation_id, + ontology=ontology, + context_json=context_json, + selected_capability_codes=selected_capability_codes, + ) + ) + if ontology.permission.level == AgentPermissionLevel.FORBIDDEN.value: outcome = ExecutionOutcome( status=AgentRunStatus.BLOCKED.value, @@ -246,6 +277,11 @@ class OrchestratorService: route_reason = "permission_forbidden" route_json["stage"] = "blocked" route_json["route_reason"] = route_reason + elif authenticated_application_outcome is not None: + outcome = authenticated_application_outcome + route_reason = "authenticated_application_preview_workflow" + route_json["stage"] = "application_preview_workflow" + route_json["route_reason"] = route_reason elif ontology.clarification_required: if selected_agent == AgentName.USER_AGENT.value and ontology.scenario == "expense": clarification_response = self.user_agent_service.respond( @@ -502,6 +538,54 @@ class OrchestratorService: ), ) + @staticmethod + def _build_authenticated_user_payload( + payload: OrchestratorRequest, + current_user: CurrentUserContext, + ) -> OrchestratorRequest: + """用登录态覆盖所有身份字段,并丢弃客户端伪造的可信预览状态。""" + context_json = dict(payload.context_json or {}) + for key in ( + "application_preview", + "application_preview_decision", + "decision_id", + "preview_decision_id", + "auth_session_id", + "requested_by_username", + "requested_by_name", + "actor", + "actor_id", + ): + context_json.pop(key, None) + context_json.update( + { + "tenant_id": current_user.tenant_id, + "role_codes": list(current_user.role_codes), + "is_admin": current_user.is_admin, + "username": current_user.username, + "user_id": current_user.username, + "name": current_user.name, + "department": current_user.department_name, + "department_name": current_user.department_name, + "cost_center": current_user.cost_center, + "position": current_user.position, + "grade": current_user.grade, + "employee_no": current_user.employee_no, + "employee_id": current_user.employee_id, + "manager_name": current_user.manager_name, + "requested_by_username": current_user.username, + "requested_by_name": current_user.name, + "actor": current_user.username, + "actor_id": current_user.username, + } + ) + return payload.model_copy( + update={ + "user_id": current_user.username, + "context_json": context_json, + } + ) + def _record_trace_event(self, **kwargs: Any) -> None: self.trace_service.record_event_safe(**kwargs) diff --git a/server/src/app/services/orchestrator_expense_application_workflow.py b/server/src/app/services/orchestrator_expense_application_workflow.py new file mode 100644 index 0000000..c3c934d --- /dev/null +++ b/server/src/app/services/orchestrator_expense_application_workflow.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import re +from typing import Any + +from sqlalchemy.orm import Session + +from app.api.deps import CurrentUserContext +from app.core.agent_enums import AgentRunStatus +from app.schemas.expense_application_preview import ExpenseApplicationPreviewDecisionCreate +from app.schemas.ontology import OntologyParseResult +from app.schemas.orchestrator import OrchestratorRequest +from app.schemas.reimbursement import ExpenseApplicationPreviewActionPayload +from app.schemas.user_agent import UserAgentRequest +from app.services.expense_application_preview_decisions import PreviewDecisionConflictError +from app.services.expense_application_preview_workflow import ExpenseApplicationPreviewWorkflow +from app.services.expense_application_snapshot import ( + canonical_preview_fields, + content_fingerprint, + preview_response_fields, + safe_fact_snapshot, +) +from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine +from app.services.user_agent import UserAgentService +from app.services.user_agent_application import ( + APPLICATION_SAVE_DRAFT_KEYWORDS, + APPLICATION_SHORT_CONFIRMATIONS, + APPLICATION_SUBMIT_KEYWORDS, +) + + +class OrchestratorExpenseApplicationWorkflow: + """为通用 Orchestrator 提供认证的申请预览签发、消费和会话状态编排。""" + + def __init__(self, db: Session) -> None: + self.db = db + self.user_agent_service = UserAgentService(db) + + def execute( + self, + *, + payload: OrchestratorRequest, + current_user: CurrentUserContext, + run_id: str, + conversation_id: str | None, + ontology: OntologyParseResult, + context_json: dict[str, Any], + selected_capability_codes: list[str], + ) -> ExecutionOutcome | None: + application_context = dict(context_json or {}) + decision_state = self._resolve_decision_state(application_context) + # 申请事实只允许来自服务端会话历史、当前输入和登录态,不能反向信任客户端 preview。 + application_context.pop("application_preview", None) + application_context.pop("application_preview_decision", None) + request = UserAgentRequest( + run_id=run_id, + user_id=current_user.username, + message=payload.message or "", + ontology=ontology, + context_json=application_context, + tool_payload={}, + selected_capability_codes=selected_capability_codes, + degraded=False, + requires_confirmation=False, + ) + facts = self.user_agent_service._resolve_expense_application_facts(request) + step = self.user_agent_service._resolve_expense_application_step(request, facts) + requested_action = self._resolve_requested_action(payload.message, decision_state) + if requested_action: + decision_preview = decision_state.get("application_preview") + if isinstance(decision_preview, dict): + for key, value in canonical_preview_fields(decision_preview).items(): + if not str(facts.get(key) or "").strip(): + facts[key] = value + step = "draft" if requested_action == "save_draft" else "submitted" + if step not in {"preview", "draft", "submitted"}: + return None + + current_preview = {"fields": preview_response_fields(facts)} + if step == "preview": + return self._issue_or_refresh_preview( + request=request, + facts=facts, + decision_state=decision_state, + current_preview=current_preview, + current_user=current_user, + conversation_id=conversation_id, + context_json=context_json, + ) + return self._execute_action( + payload=payload, + current_user=current_user, + conversation_id=conversation_id, + application_context=application_context, + context_json=context_json, + decision_state=decision_state, + current_preview=current_preview, + facts=facts, + step=step, + ) + + def _issue_or_refresh_preview( + self, + *, + request: UserAgentRequest, + facts: dict[str, Any], + decision_state: dict[str, Any], + current_preview: dict[str, Any], + current_user: CurrentUserContext, + conversation_id: str | None, + context_json: dict[str, Any], + ) -> ExecutionOutcome: + preview_response = self.user_agent_service._build_expense_application_response( + request, + risk_flags=[], + ) + result = OrchestratorExecutionEngine._build_user_agent_result( + preview_response, + degraded=False, + ) + if self._is_issued_state(decision_state): + issued_preview = { + **current_preview, + "decisionId": str(decision_state.get("decision_id") or "").strip(), + "decisionSource": str(decision_state.get("decision_source") or "").strip(), + "decisionExpiresAt": str(decision_state.get("expires_at") or "").strip(), + } + decision_id = issued_preview["decisionId"] + decision_source = issued_preview["decisionSource"] + expires_at = issued_preview["decisionExpiresAt"] + else: + issue_response = ExpenseApplicationPreviewWorkflow(self.db).issue( + ExpenseApplicationPreviewDecisionCreate( + message=self._build_facts_message(facts), + conversation_id=conversation_id, + request_id=self._build_issue_request_id(conversation_id, facts), + ), + current_user, + ) + issued_preview = dict(issue_response.application_preview or {}) + decision_id = issue_response.decision_id + decision_source = issue_response.decision_source + expires_at = issue_response.expires_at.isoformat() + + context_json["application_preview_decision"] = { + "status": "issued", + "decision_id": decision_id, + "decision_source": decision_source, + "expires_at": expires_at, + "application_preview": issued_preview, + } + result.update( + { + "application_preview": issued_preview, + "decision_id": decision_id, + "decision_source": decision_source, + "decision_expires_at": expires_at, + } + ) + return ExecutionOutcome( + status=AgentRunStatus.SUCCEEDED.value, + result=result, + degraded=False, + tool_count=0, + failed_tool_count=0, + ) + + def _execute_action( + self, + *, + payload: OrchestratorRequest, + current_user: CurrentUserContext, + conversation_id: str | None, + application_context: dict[str, Any], + context_json: dict[str, Any], + decision_state: dict[str, Any], + current_preview: dict[str, Any], + facts: dict[str, Any], + step: str, + ) -> ExecutionOutcome: + action_type = "save_draft" if step == "draft" else "submit" + decision_id = str(decision_state.get("decision_id") or "").strip() + if not self._is_issued_state(decision_state) or not decision_id: + context_json["application_preview_decision"] = {"status": "missing"} + return self._build_blocked_outcome( + "当前申请还没有经过服务端核对,请重新生成申请预览后再继续。" + ) + + action_preview = { + **current_preview, + "decisionId": decision_id, + "decisionSource": str(decision_state.get("decision_source") or "").strip(), + "decisionExpiresAt": str(decision_state.get("expires_at") or "").strip(), + } + action_context = {**application_context, "application_preview": action_preview} + if action_type == "save_draft": + action_context["application_action"] = "save_draft" + action_context["application_save_mode"] = True + + try: + action_response = ExpenseApplicationPreviewWorkflow(self.db).execute( + ExpenseApplicationPreviewActionPayload( + source=payload.source, + user_id=current_user.username, + conversation_id=conversation_id, + action_type=action_type, + decision_id=decision_id, + request_id=self._build_action_request_id(decision_id, action_type), + message=self._build_action_message(action_type, facts), + context_json=action_context, + ), + current_user, + ) + except (PreviewDecisionConflictError, ValueError) as error: + context_json["application_preview_decision"] = { + "status": "invalid", + "reason": str(error), + } + return self._build_blocked_outcome(str(error)) + + result = action_response.result.model_dump(mode="json") + result["degraded"] = False + next_decision_id = str(action_response.result.decision_id or "").strip() + if next_decision_id: + self._store_next_decision( + context_json=context_json, + action_preview=action_preview, + decision_id=next_decision_id, + expires_at=action_response.result.decision_expires_at, + result=result, + ) + else: + context_json["application_preview_decision"] = { + "status": "consumed", + "decision_id": decision_id, + "action_type": action_type, + } + return ExecutionOutcome( + status=AgentRunStatus.SUCCEEDED.value, + result=result, + degraded=False, + tool_count=0, + failed_tool_count=0, + ) + + @staticmethod + def _store_next_decision( + *, + context_json: dict[str, Any], + action_preview: dict[str, Any], + decision_id: str, + expires_at: Any, + result: dict[str, Any], + ) -> None: + next_preview = { + **action_preview, + "decisionId": decision_id, + "decisionSource": "server_draft", + "decisionExpiresAt": expires_at.isoformat() if expires_at else "", + } + context_json["application_preview_decision"] = { + "status": "issued", + "decision_id": decision_id, + "decision_source": "server_draft", + "expires_at": next_preview["decisionExpiresAt"], + "application_preview": next_preview, + } + result["application_preview"] = next_preview + + @staticmethod + def _resolve_decision_state(context_json: dict[str, Any]) -> dict[str, Any]: + state = context_json.get("application_preview_decision") + if isinstance(state, dict): + return dict(state) + conversation_state = context_json.get("conversation_state") + if isinstance(conversation_state, dict): + state = conversation_state.get("application_preview_decision") + if isinstance(state, dict): + return dict(state) + return {} + + @staticmethod + def _is_issued_state(state: dict[str, Any]) -> bool: + return ( + str(state.get("status") or "").strip() == "issued" + and bool(str(state.get("decision_id") or "").strip()) + ) + + @classmethod + def _resolve_requested_action( + cls, + message: str | None, + decision_state: dict[str, Any], + ) -> str: + if not cls._is_issued_state(decision_state): + return "" + compact_message = re.sub(r"\s+", "", str(message or "")) + if any(keyword in compact_message for keyword in APPLICATION_SAVE_DRAFT_KEYWORDS): + return "save_draft" + if ( + any(keyword in compact_message for keyword in APPLICATION_SUBMIT_KEYWORDS) + or compact_message in APPLICATION_SHORT_CONFIRMATIONS + ): + return "submit" + return "" + + @staticmethod + def _build_facts_message(facts: dict[str, Any]) -> str: + labels = ( + ("application_type", "申请类型"), + ("time", "申请时间"), + ("location", "地点"), + ("reason", "事由"), + ("days", "天数"), + ("transport_mode", "出行方式"), + ("amount", "系统预估费用"), + ) + return "\n".join( + f"{label}:{value}" + for key, label in labels + if (value := str(facts.get(key) or "").strip()) + ) + + @classmethod + def _build_action_message(cls, action_type: str, facts: dict[str, Any]) -> str: + action_text = "保存草稿" if action_type == "save_draft" else "确认提交" + return f"{cls._build_facts_message(facts)}\n\n{action_text}" + + @staticmethod + def _build_issue_request_id( + conversation_id: str | None, + facts: dict[str, Any], + ) -> str: + fingerprint = content_fingerprint(safe_fact_snapshot(facts)).split(":", 1)[-1][:24] + conversation_key = str(conversation_id or "new").strip() or "new" + return f"orchestrator-preview:{conversation_key}:{fingerprint}"[:120] + + @staticmethod + def _build_action_request_id(decision_id: str, action_type: str) -> str: + return f"orchestrator-action:{decision_id}:{action_type}"[:120] + + @staticmethod + def _build_blocked_outcome(message: str) -> ExecutionOutcome: + normalized_message = str(message or "").strip() or "申请预览状态已失效,请重新生成。" + return ExecutionOutcome( + status=AgentRunStatus.BLOCKED.value, + result={ + "message": normalized_message, + "answer": normalized_message, + "suggested_actions": [], + "risk_flags": [], + "requires_confirmation": True, + "degraded": False, + }, + degraded=False, + tool_count=0, + failed_tool_count=0, + ) diff --git a/server/src/app/services/steward_action_executor.py b/server/src/app/services/steward_action_executor.py index ccc782f..f4c7d0d 100644 --- a/server/src/app/services/steward_action_executor.py +++ b/server/src/app/services/steward_action_executor.py @@ -6,7 +6,9 @@ from typing import Any from sqlalchemy.orm import Session from app.api.deps import CurrentUserContext +from app.schemas.expense_application_preview import ExpenseApplicationPreviewDecisionCreate from app.schemas.ontology import OntologyParseResult, OntologyPermission +from app.schemas.reimbursement import ExpenseApplicationPreviewActionPayload from app.schemas.steward import ( StewardActionExecuteRequest, StewardActionExecuteResponse, @@ -14,6 +16,7 @@ from app.schemas.steward import ( ) from app.schemas.user_agent import UserAgentRequest from app.services.attachment_association_jobs import AttachmentAssociationJobRunner +from app.services.expense_application_preview_workflow import ExpenseApplicationPreviewWorkflow from app.services.expense_claims import ExpenseClaimService from app.services.steward_intent_registry import ( all_noop_actions, @@ -40,7 +43,6 @@ APPLICATION_SIDE_EFFECT_ACTIONS = {"save_application_draft", "submit_application REIMBURSEMENT_SIDE_EFFECT_ACTIONS = {"create_reimbursement_draft", "link_existing_application", "associate_attachments"} NOOP_ACTIONS = { "fill_application_fields", - "build_application_preview", "fill_reimbursement_fields", "build_reimbursement_preview", "validate_required_fields", @@ -97,7 +99,8 @@ class StewardActionExecutor: ) task = request.task - noop_actions = NOOP_ACTIONS | all_noop_actions() + # 申请预览已经接入服务端决策签发,不得再被注册表中的历史 NOOP 声明吞掉。 + noop_actions = (NOOP_ACTIONS | all_noop_actions()) - {"build_application_preview"} if task is None and action_type not in noop_actions: return self._blocked( action_type, @@ -132,6 +135,8 @@ class StewardActionExecutor: return intent.executor(self, request, current_user, trace) # 兼容回退:注册表未命中时按旧逻辑分发 + if action_type == "build_application_preview": + return self._issue_application_preview(request, current_user, trace) if action_type == "run_duplicate_precheck": return self._run_duplicate_precheck(request, current_user, trace) if action_type in APPLICATION_SIDE_EFFECT_ACTIONS: @@ -155,10 +160,55 @@ class StewardActionExecutor: ) -> StewardActionExecuteResponse: """registry 入口:分发申请类副作用动作。""" action_type = self._normalize_action_type(request.action_type) + if action_type == "build_application_preview": + return self._issue_application_preview(request, current_user, trace) if action_type == "run_duplicate_precheck": return self._run_duplicate_precheck(request, current_user, trace) return self._execute_application_action(request, current_user, action_type, trace) + def _issue_application_preview( + self, + request: StewardActionExecuteRequest, + current_user: CurrentUserContext, + trace: list[dict[str, Any]], + ) -> StewardActionExecuteResponse: + request_id = self._resolve_required_client_trace_id(request) + if not request_id: + return self._blocked( + "build_application_preview", + "生成申请核对表需要稳定的 client_trace_id。", + blocked_reasons=["missing_client_trace_id"], + trace=[*trace, self._trace("blocked", reason="missing_client_trace_id")], + ) + + try: + issued = ExpenseApplicationPreviewWorkflow(self.db).issue( + ExpenseApplicationPreviewDecisionCreate( + message=self._resolve_message(request), + conversation_id=request.conversation_id, + request_id=request_id, + ), + current_user, + ) + except ValueError as exc: + return self._failed("build_application_preview", str(exc), trace) + + result_payload = issued.model_dump(mode="json") + return StewardActionExecuteResponse( + action_type="build_application_preview", + status="succeeded", + message="申请核对表已生成,请确认后再保存或提交。", + result_payload=result_payload, + trace=[ + *trace, + self._trace( + "completed", + service="ExpenseApplicationPreviewWorkflow", + decision_id=issued.decision_id, + ), + ], + ) + def _dispatch_reimbursement_action( self, request: StewardActionExecuteRequest, @@ -236,43 +286,70 @@ class StewardActionExecutor: trace=[*trace, self._trace("blocked", reason="precheck_not_passed")], ) - payload = self._build_application_user_agent_request( - request, - current_user, - action_type=action_type, - force_submit_message=action_type == "submit_application", + decision_id = str(request.decision_id or "").strip() + if not decision_id: + return self._blocked( + action_type, + "保存或提交申请必须携带服务端签发的 decision_id。", + blocked_reasons=["missing_decision_id"], + trace=[*trace, self._trace("blocked", reason="missing_decision_id")], + ) + request_id = self._resolve_required_client_trace_id(request) + if not request_id: + return self._blocked( + action_type, + "保存或提交申请需要稳定的 client_trace_id。", + blocked_reasons=["missing_client_trace_id"], + trace=[*trace, self._trace("blocked", reason="missing_client_trace_id")], + ) + + resolved_action = "save_draft" if action_type == "save_application_draft" else "submit" + message = self._resolve_message(request) + if resolved_action == "submit" and "确认提交" not in message and "直接提交" not in message: + message = "\n".join([message, "确认提交"]).strip() + if resolved_action == "save_draft" and "保存草稿" not in message: + message = "\n".join([message, "保存草稿"]).strip() + payload = ExpenseApplicationPreviewActionPayload( + source="steward_action", + user_id=current_user.username, + conversation_id=request.conversation_id, + action_type=resolved_action, + decision_id=decision_id, + request_id=request_id, + message=message, + context_json=self._build_application_context_json( + request, + current_user, + action_type, + ), ) try: - user_agent_response = UserAgentService(self.db)._build_expense_application_response( + workflow_response = ExpenseApplicationPreviewWorkflow(self.db).execute( payload, - risk_flags=[], + current_user, ) except ValueError as exc: return self._failed(action_type, str(exc), trace) - draft_payload = ( - user_agent_response.draft_payload.model_dump(mode="json") - if user_agent_response.draft_payload is not None - else None - ) - result_payload = { - "answer": user_agent_response.answer, - "suggested_actions": [ - action.model_dump(mode="json") - for action in user_agent_response.suggested_actions - ], - "requires_confirmation": user_agent_response.requires_confirmation, - "draft_payload": draft_payload, - } + result_payload = workflow_response.result.model_dump(mode="json") + draft_payload = result_payload.get("draft_payload") status = "succeeded" if draft_payload is not None else "blocked" blocked_reasons = [] if draft_payload is not None else ["application_not_persisted"] return StewardActionExecuteResponse( action_type=action_type, status=status, - message=user_agent_response.answer, + message=workflow_response.result.message, blocked_reasons=blocked_reasons, result_payload=result_payload, - trace=[*trace, self._trace("completed", service="UserAgentService")], + trace=[ + *trace, + self._trace( + "completed", + service="ExpenseApplicationPreviewWorkflow", + decision_id=decision_id, + renewed_decision_id=workflow_response.result.decision_id or "", + ), + ], ) def _execute_reimbursement_action( @@ -568,6 +645,10 @@ class StewardActionExecutor: suffix = task_id or datetime.now(UTC).strftime("%Y%m%d%H%M%S%f") return f"steward-action:{action_type}:{suffix}" + @staticmethod + def _resolve_required_client_trace_id(request: StewardActionExecuteRequest) -> str: + return str(request.client_trace_id or "").strip() + @staticmethod def _trace(stage: str, **extra: Any) -> dict[str, Any]: return { diff --git a/server/src/app/services/steward_graph_action_runtime.py b/server/src/app/services/steward_graph_action_runtime.py index e9d9979..1adf98e 100644 --- a/server/src/app/services/steward_graph_action_runtime.py +++ b/server/src/app/services/steward_graph_action_runtime.py @@ -14,6 +14,11 @@ from app.services.steward_action_executor import StewardActionExecutor ACTION_CHECKPOINT_KEY = "steward_action_checkpoint" TERMINAL_ACTION_STATUSES = {"succeeded", "blocked", "failed"} +DECISION_BOUND_APPLICATION_ACTIONS = { + "build_application_preview", + "save_application_draft", + "submit_application", +} class StewardGraphActionState(TypedDict, total=False): @@ -171,6 +176,7 @@ class StewardGraphActionRuntime: user_id=current_user.username, source="user_message", context_json={ + "tenant_id": current_user.tenant_id, "session_type": "steward", "entry_source": "steward_action_executor", "steward_state": dict((request.context_json or {}).get("steward_state") or {}), @@ -190,6 +196,9 @@ class StewardGraphActionRuntime: if trace_id: return trace_id action_type = str(request.action_type or "").strip() + if action_type in DECISION_BOUND_APPLICATION_ACTIONS: + # 这些动作的 request_id 同时用于决策签发/消费,禁止生成不可重放的隐式 ID。 + return "" task_id = str(request.task.task_id if request.task is not None else "").strip() if action_type and task_id: return f"{action_type}:{task_id}" diff --git a/server/tests/test_expense_application_preview_decisions.py b/server/tests/test_expense_application_preview_decisions.py index c504162..a7fb8b9 100644 --- a/server/tests/test_expense_application_preview_decisions.py +++ b/server/tests/test_expense_application_preview_decisions.py @@ -198,6 +198,21 @@ def test_server_preview_decision_is_consumed_with_verified_feedback() -> None: assert len(list(db.scalars(select(AIDecision)).all())) == 1 +def test_same_snapshot_with_different_issue_request_reuses_active_decision() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_employee(db) + + first = issue_preview(client, request_id="issue-preview-same-snapshot-1") + second = issue_preview(client, request_id="issue-preview-same-snapshot-2") + + assert second["decision_id"] == first["decision_id"] + with session_factory() as db: + decisions = list(db.scalars(select(AIApplicationPreviewDecision)).all()) + assert len(decisions) == 1 + assert decisions[0].status == "issued" + + def test_server_preview_decision_detects_server_side_field_edit() -> None: client, session_factory = build_client() with session_factory() as db: diff --git a/server/tests/test_orchestrator_auth_endpoints.py b/server/tests/test_orchestrator_auth_endpoints.py new file mode 100644 index 0000000..61e167b --- /dev/null +++ b/server/tests/test_orchestrator_auth_endpoints.py @@ -0,0 +1,407 @@ +from __future__ import annotations + +from collections.abc import Generator + +import pytest +from fastapi import FastAPI +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 ( + CurrentUserContext, + get_current_user, + get_db, + get_optional_current_user, +) +from app.db.base import Base +from app.main import create_app +from app.models.agent_conversation import AgentConversation +from app.schemas.orchestrator import ( + OrchestratorRequest, + OrchestratorResponse, + OrchestratorTraceSummary, +) +from app.services.orchestrator import OrchestratorService + + +@pytest.fixture +def http_context() -> Generator[ + tuple[TestClient, FastAPI, sessionmaker[Session]], + None, + None, +]: + engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False) + app = create_app() + + def override_db() -> Generator[Session, None, None]: + with session_factory() as db: + yield db + + app.dependency_overrides[get_db] = override_db + client = TestClient(app) + try: + yield client, app, session_factory + finally: + client.close() + app.dependency_overrides.clear() + engine.dispose() + + +def authenticated_user() -> CurrentUserContext: + return CurrentUserContext( + username="signed-in@example.com", + name="登录用户", + role_codes=["user"], + is_admin=False, + tenant_id="tenant-signed-in", + department_name="财务共享部", + cost_center="CC-SIGNED-IN", + position="费用专员", + grade="P5", + employee_no="E-SIGNED-IN", + manager_name="直属经理", + employee_id="employee-signed-in", + auth_session_id="session-signed-in", + ) + + +def install_authenticated_user(app: FastAPI) -> CurrentUserContext: + current_user = authenticated_user() + app.dependency_overrides[get_current_user] = lambda: current_user + app.dependency_overrides[get_optional_current_user] = lambda: current_user + return current_user + + +def install_admin_user(app: FastAPI) -> CurrentUserContext: + current_user = authenticated_user() + current_user.role_codes = ["admin"] + current_user.is_admin = True + app.dependency_overrides[get_current_user] = lambda: current_user + app.dependency_overrides[get_optional_current_user] = lambda: current_user + return current_user + + +@pytest.mark.parametrize("source", ["user_message", "system_event", "schedule"]) +def test_orchestrator_run_requires_authentication_for_every_external_source( + http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], + source: str, +) -> None: + client, _, _ = http_context + + response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": source, + "user_id": "forged-user@example.com", + "message": "帮我申请差旅费用", + "context_json": {}, + }, + ) + + assert response.status_code == 401 + assert response.headers["www-authenticate"] == "Bearer" + assert response.json()["detail"] == "请先登录后再使用智能助手。" + + +@pytest.mark.parametrize("source", ["system_event", "schedule"]) +def test_non_admin_cannot_trigger_privileged_orchestrator_sources( + http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], + source: str, +) -> None: + client, app, _ = http_context + install_authenticated_user(app) + + response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": source, + "user_id": "forged-admin@example.com", + "message": "运行全局风险扫描", + "context_json": { + "role_codes": ["admin"], + "is_admin": True, + }, + }, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "只有平台管理员可以从外部触发调度或系统事件。" + + +@pytest.mark.parametrize("source", ["system_event", "schedule"]) +def test_admin_can_trigger_privileged_orchestrator_sources( + http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], + monkeypatch: pytest.MonkeyPatch, + source: str, +) -> None: + client, app, _ = http_context + expected_user = install_admin_user(app) + captured: dict[str, object] = {} + + def fake_run( + self: OrchestratorService, + payload: OrchestratorRequest, + *, + current_user: CurrentUserContext | None = None, + ) -> OrchestratorResponse: + del self + captured["source"] = payload.source + captured["current_user"] = current_user + return OrchestratorResponse( + run_id="run-admin-source", + selected_agent="hermes", + route_reason="test_admin_source", + permission_level="read", + status="succeeded", + result={}, + requires_confirmation=False, + trace_summary=OrchestratorTraceSummary( + scenario="system", + intent="risk_check", + ), + ) + + monkeypatch.setattr(OrchestratorService, "run", fake_run) + + response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": source, + "message": "运行全局风险扫描", + "context_json": {}, + }, + ) + + assert response.status_code == 200, response.text + assert captured["source"] == source + assert captured["current_user"] == expected_user + + +@pytest.mark.parametrize("source", ["user_message", "system_event", "schedule"]) +def test_authenticated_payload_overrides_all_scheduler_identity_aliases( + source: str, +) -> None: + current_user = authenticated_user() + payload = OrchestratorRequest( + source=source, + user_id="forged-user@example.com", + message="测试身份覆盖", + context_json={ + "username": "forged-user@example.com", + "user_id": "forged-user@example.com", + "tenant_id": "tenant-forged", + "role_codes": ["admin"], + "is_admin": True, + "requested_by_username": "forged-requester@example.com", + "requested_by_name": "伪造请求人", + "actor": "forged-actor@example.com", + "actor_id": "forged-actor-id", + "auth_session_id": "forged-session", + }, + ) + + trusted = OrchestratorService._build_authenticated_user_payload(payload, current_user) + + assert trusted.user_id == current_user.username + assert trusted.context_json["tenant_id"] == current_user.tenant_id + assert trusted.context_json["role_codes"] == current_user.role_codes + assert trusted.context_json["is_admin"] is current_user.is_admin + assert trusted.context_json["requested_by_username"] == current_user.username + assert trusted.context_json["requested_by_name"] == current_user.name + assert trusted.context_json["actor"] == current_user.username + assert trusted.context_json["actor_id"] == current_user.username + assert "auth_session_id" not in trusted.context_json + + +def test_authenticated_run_passes_server_identity_to_orchestrator( + http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + client, app, _ = http_context + expected_user = install_authenticated_user(app) + captured: dict[str, object] = {} + + def fake_run( + self: OrchestratorService, + payload: OrchestratorRequest, + *, + current_user: CurrentUserContext | None = None, + ) -> OrchestratorResponse: + del self + captured["payload"] = payload + captured["current_user"] = current_user + return OrchestratorResponse( + run_id="run-auth-binding", + conversation_id="conversation-auth-binding", + selected_agent="user_agent", + route_reason="test", + permission_level="user", + status="succeeded", + result={}, + requires_confirmation=False, + trace_summary=OrchestratorTraceSummary( + scenario="expense", + intent="operate", + ), + ) + + monkeypatch.setattr(OrchestratorService, "run", fake_run) + + response = client.post( + "/api/v1/orchestrator/run", + json={ + "source": "user_message", + "user_id": "forged-user@example.com", + "message": "保存申请草稿", + "context_json": { + "username": "forged-user@example.com", + "tenant_id": "tenant-forged", + "role_codes": ["admin"], + "is_admin": True, + "employee_no": "E-FORGED", + "auth_session_id": "session-forged", + }, + }, + ) + + assert response.status_code == 200, response.text + assert captured["current_user"] == expected_user + assert captured["payload"].user_id == "forged-user@example.com" + assert captured["payload"].context_json["tenant_id"] == "tenant-forged" + + +def test_latest_conversation_uses_authenticated_username( + http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], +) -> None: + client, app, session_factory = http_context + current_user = install_authenticated_user(app) + with session_factory() as db: + db.add_all( + [ + AgentConversation( + conversation_id="conversation-signed-in", + user_id=current_user.username, + source="user_message", + title="登录用户会话", + state_json={ + "tenant_id": current_user.tenant_id, + "session_type": "expense", + }, + ), + AgentConversation( + conversation_id="conversation-same-user-other-tenant", + user_id=current_user.username, + source="user_message", + title="其他租户同名用户会话", + state_json={ + "tenant_id": "tenant-other", + "session_type": "expense", + "application_preview_decision": { + "decision_id": "decision-other-tenant", + "application_preview": { + "fields": {"reason": "其他租户敏感项目"} + }, + }, + }, + ), + AgentConversation( + conversation_id="conversation-forged-user", + user_id="forged-user@example.com", + source="user_message", + title="伪造用户会话", + state_json={ + "tenant_id": "tenant-forged", + "session_type": "expense", + }, + ), + ] + ) + db.commit() + + response = client.get( + "/api/v1/orchestrator/conversations/latest", + params={"user_id": "forged-user@example.com", "session_type": "expense"}, + ) + + assert response.status_code == 200, response.text + payload = response.json() + assert payload["found"] is True + assert payload["conversation"]["conversation_id"] == "conversation-signed-in" + assert payload["conversation"]["user_id"] == current_user.username + + +def test_delete_conversations_uses_authenticated_username( + http_context: tuple[TestClient, FastAPI, sessionmaker[Session]], +) -> None: + client, app, session_factory = http_context + current_user = install_authenticated_user(app) + with session_factory() as db: + db.add_all( + [ + AgentConversation( + conversation_id="conversation-owner-single", + user_id=current_user.username, + source="user_message", + state_json={ + "tenant_id": current_user.tenant_id, + "session_type": "expense", + }, + ), + AgentConversation( + conversation_id="conversation-owner-bulk", + user_id=current_user.username, + source="user_message", + state_json={ + "tenant_id": current_user.tenant_id, + "session_type": "expense", + }, + ), + AgentConversation( + conversation_id="conversation-same-user-other-tenant", + user_id=current_user.username, + source="user_message", + state_json={ + "tenant_id": "tenant-other", + "session_type": "expense", + }, + ), + AgentConversation( + conversation_id="conversation-attacker", + user_id="forged-user@example.com", + source="user_message", + state_json={ + "tenant_id": "tenant-forged", + "session_type": "expense", + }, + ), + ] + ) + db.commit() + + single_response = client.delete( + "/api/v1/orchestrator/conversations/conversation-owner-single", + params={"user_id": "forged-user@example.com"}, + ) + bulk_response = client.delete( + "/api/v1/orchestrator/conversations", + params={"user_id": "forged-user@example.com", "session_type": "expense"}, + ) + + assert single_response.status_code == 200, single_response.text + assert single_response.json()["deleted_count"] == 1 + assert bulk_response.status_code == 200, bulk_response.text + assert bulk_response.json()["deleted_count"] == 1 + with session_factory() as db: + remaining = list(db.scalars(select(AgentConversation)).all()) + assert {item.conversation_id for item in remaining} == { + "conversation-attacker", + "conversation-same-user-other-tenant", + } diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index 031c76d..2b627f0 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -4,13 +4,16 @@ from datetime import UTC, date, datetime from decimal import Decimal import pytest -from sqlalchemy import create_engine +from sqlalchemy import create_engine, select from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.pool import StaticPool +from app.api.deps import CurrentUserContext from app.db.base import Base from app.models.agent_asset import AgentAsset from app.models.agent_run import AgentRun +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, ExpenseClaimItem from app.schemas.ontology import OntologyParseResult, OntologyPermission @@ -881,3 +884,135 @@ def test_orchestrator_application_submit_bypasses_generic_operation_block( assert "当前仅返回确认摘要" not in submitted.result["answer"] assert "申请单据已生成,并已进入审批流程" in submitted.result["answer"] assert submitted.result["draft_payload"]["status"] == "submitted" + + +def test_authenticated_orchestrator_application_draft_consumes_server_preview_decision( + monkeypatch, +) -> None: + monkeypatch.setattr( + "app.services.runtime_chat.RuntimeChatService.complete", + lambda *_args, **_kwargs: None, + ) + + def parse_application_for_run(self, request, run_id): # noqa: ANN001 + return OntologyParseResult( + scenario="expense", + intent="operate", + entities=[], + permission=OntologyPermission( + level="approval_required", + allowed=True, + reason="费用申请由可信预览工作流执行。", + ), + confidence=0.99, + missing_slots=[], + ambiguity=[], + clarification_required=False, + clarification_question=None, + run_id=run_id, + ) + + monkeypatch.setattr( + "app.services.ontology.SemanticOntologyService.parse_for_run", + parse_application_for_run, + ) + session_factory = build_session_factory() + current_user = CurrentUserContext( + username="trusted-application@example.com", + name="可信申请员工", + role_codes=["user"], + is_admin=False, + tenant_id="tenant-orchestrator-preview", + department_name="交付部", + position="实施顾问", + grade="P4", + employee_no="E-ORCH-001", + employee_id="employee-orchestrator-preview", + manager_name="陈硕", + auth_session_id="session-orchestrator-preview", + ) + context_json = { + "session_type": "application", + "entry_source": "application", + "user_id": "forged@example.com", + "tenant_id": "forged-tenant", + "is_admin": True, + "manager_name": "伪造审批人", + } + with session_factory() as db: + service = OrchestratorService(db) + first = service.run( + OrchestratorRequest( + source="user_message", + user_id="forged@example.com", + message=( + "发生时间:2026-05-25\n" + "地点:上海\n" + "事由:支持上海国网服务器部署\n" + "天数:3天" + ), + context_json=context_json, + ), + current_user=current_user, + ) + preview = service.run( + OrchestratorRequest( + source="user_message", + user_id="forged@example.com", + conversation_id=first.conversation_id, + message="飞机", + context_json=context_json, + ), + current_user=current_user, + ) + + decision_id = str(preview.result.get("decision_id") or "") + assert preview.status == "blocked" + assert preview.requires_confirmation is True + assert decision_id + assert preview.result["application_preview"]["decisionId"] == decision_id + decision = db.get(AIApplicationPreviewDecision, decision_id) + assert decision is not None + assert decision.status == "issued" + assert decision.actor_id == current_user.employee_id + assert decision.tenant_id == current_user.tenant_id + conversation = service.conversation_service.get_conversation(first.conversation_id or "") + assert conversation is not None + assert conversation.state_json["application_preview_decision"]["decision_id"] == decision_id + saved = service.run( + OrchestratorRequest( + source="user_message", + user_id="another-forged@example.com", + conversation_id=first.conversation_id, + message="保存草稿", + context_json={ + **context_json, + "application_preview_decision": { + "decision_id": "forged-decision", + "status": "issued", + }, + }, + ), + current_user=current_user, + ) + + assert saved.status == "succeeded", saved.result + assert saved.result["draft_payload"]["status"] == "draft" + assert saved.result["draft_payload"]["draft_type"] == "expense_application" + assert saved.result["draft_payload"]["claim_no"] + assert saved.result["decision_id"] + assert saved.result["decision_id"] != decision_id + db.refresh(decision) + assert decision.status == "consumed" + assert decision.consumed_action == "save_draft" + learning_decision = db.scalar( + select(AIDecision).where(AIDecision.preview_decision_id == decision_id) + ) + assert learning_decision is not None + feedback = db.scalar( + select(AIDecisionFeedback).where( + AIDecisionFeedback.decision_id == learning_decision.id + ) + ) + assert feedback is not None + assert feedback.feedback_type == "accepted" diff --git a/server/tests/test_steward_action_executor.py b/server/tests/test_steward_action_executor.py index 7b2b22e..9ee2913 100644 --- a/server/tests/test_steward_action_executor.py +++ b/server/tests/test_steward_action_executor.py @@ -13,6 +13,8 @@ from app.api.deps import get_db from app.db.base import Base from app.main import create_app from app.models.agent_conversation import AgentConversation +from app.models.ai_application_preview import AIApplicationPreviewDecision +from app.models.ai_learning import AIDecisionFeedback from app.models.employee import Employee from app.models.expense_case import BusinessEvent, ExpenseCaseLink from app.models.financial_record import ExpenseClaim @@ -72,7 +74,11 @@ def auth_headers() -> dict[str, str]: "x-auth-username": "zhangsan@example.com", "x-auth-name": "Zhang San", "x-auth-employee-no": "E90001", + "x-auth-employee-id": "steward-action-employee", + "x-auth-session-id": "session-steward-action", + "x-auth-tenant-id": "tenant-steward-action", "x-auth-role-codes": "user", + "x-auth-department": "Delivery", "x-auth-position": "Engineer", "x-auth-grade": "P4", "x-auth-manager-name": "Leader", @@ -130,6 +136,33 @@ def claim_count(db: Session) -> int: return len(db.scalars(select(ExpenseClaim)).all()) +def issue_application_preview( + client: TestClient, + *, + conversation_id: str, + client_trace_id: str, + task: dict[str, object] | None = None, +) -> dict[str, object]: + response = client.post( + "/api/v1/steward/actions/execute", + headers=auth_headers(), + json={ + "action_type": "build_application_preview", + "message": ( + "2026-02-20 至 2026-02-23,去上海出差," + "辅助国网仿生产服务器部署,交通火车,申请金额3000元" + ), + "conversation_id": conversation_id, + "client_trace_id": client_trace_id, + "task": task or base_application_task(), + }, + ) + assert response.status_code == 200, response.text + payload = response.json() + assert payload["status"] == "succeeded", payload + return payload["result_payload"] + + def seed_approved_application(db: Session) -> None: application = ExpenseClaim( id="application-action-approved", @@ -249,16 +282,108 @@ def test_steward_action_executor_records_pending_interrupt_in_conversation_state assert checkpoint["actions"]["trace-submit-pending"]["status"] == "needs_confirmation" +def test_steward_action_executor_builds_canonical_application_preview_decision() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_employee(db) + + first = issue_application_preview( + client, + conversation_id="conv-action-preview", + client_trace_id="trace-build-preview", + ) + second = issue_application_preview( + client, + conversation_id="conv-action-preview", + client_trace_id="trace-build-preview", + ) + + assert first["decision_id"] + assert first["decision_id"] == second["decision_id"] + assert first["application_preview"]["decisionId"] == first["decision_id"] + assert first["application_preview"]["fields"]["location"] == "上海市" + assert second["idempotent_replay"] is True + + +def test_steward_action_checkpoint_does_not_replay_across_tenants() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_employee(db) + + shared_conversation_id = "conv-action-cross-tenant" + shared_trace_id = "trace-build-cross-tenant" + tenant_a_headers = { + **auth_headers(), + "x-auth-tenant-id": "tenant-steward-a", + } + tenant_b_headers = { + **auth_headers(), + "x-auth-tenant-id": "tenant-steward-b", + } + request_payload = { + "action_type": "build_application_preview", + "message": ( + "2026-02-20 至 2026-02-23,去上海出差," + "辅助国网仿生产服务器部署,交通火车,申请金额3000元" + ), + "conversation_id": shared_conversation_id, + "client_trace_id": shared_trace_id, + "task": base_application_task(), + } + + tenant_a_response = client.post( + "/api/v1/steward/actions/execute", + headers=tenant_a_headers, + json=request_payload, + ) + tenant_b_response = client.post( + "/api/v1/steward/actions/execute", + headers=tenant_b_headers, + json=request_payload, + ) + + assert tenant_a_response.status_code == 200, tenant_a_response.text + assert tenant_b_response.status_code == 200, tenant_b_response.text + tenant_a_payload = tenant_a_response.json() + tenant_b_payload = tenant_b_response.json() + assert tenant_a_payload["status"] == "succeeded" + assert tenant_b_payload["status"] == "succeeded" + assert tenant_b_payload["result_payload"].get("idempotent_replay") is not True + assert ( + tenant_a_payload["result_payload"]["decision_id"] + != tenant_b_payload["result_payload"]["decision_id"] + ) + + with session_factory() as db: + decisions = list(db.scalars(select(AIApplicationPreviewDecision)).all()) + assert {decision.tenant_id for decision in decisions} == { + "tenant-steward-a", + "tenant-steward-b", + } + conversations = list(db.scalars(select(AgentConversation)).all()) + assert len(conversations) == 2 + assert {conversation.state_json.get("tenant_id") for conversation in conversations} == { + "tenant-steward-a", + "tenant-steward-b", + } + + def test_steward_action_executor_reuses_checkpoint_for_duplicate_trace_without_duplicate_draft() -> None: client, session_factory = build_client() with session_factory() as db: seed_employee(db) + issued = issue_application_preview( + client, + conversation_id="conv-action-draft", + client_trace_id="trace-build-draft-preview", + ) request_payload = { "action_type": "save_application_draft", "message": "2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车,保存草稿", "conversation_id": "conv-action-draft", "client_trace_id": "trace-save-draft", + "decision_id": issued["decision_id"], "task": base_application_task("save_draft"), } first_response = client.post( @@ -325,12 +450,20 @@ def test_steward_action_executor_saves_application_draft_from_action_step() -> N with session_factory() as db: seed_employee(db) + issued = issue_application_preview( + client, + conversation_id="conv-action-save", + client_trace_id="trace-build-save-preview", + ) response = client.post( "/api/v1/steward/actions/execute", headers=auth_headers(), json={ "action_type": "save_application_draft", "message": "2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车,保存草稿", + "conversation_id": "conv-action-save", + "client_trace_id": "trace-save-application-action", + "decision_id": issued["decision_id"], "task": base_application_task("save_draft"), }, ) @@ -342,22 +475,116 @@ def test_steward_action_executor_saves_application_draft_from_action_step() -> N assert draft_payload["draft_type"] == "expense_application" assert draft_payload["status"] == "draft" assert draft_payload["claim_no"].startswith("A") + assert payload["result_payload"]["decision_id"] + assert payload["result_payload"]["decision_id"] != issued["decision_id"] with session_factory() as db: claim = db.scalars(select(ExpenseClaim)).one() assert claim.status == "draft" assert claim.reason == "辅助国网仿生产服务器部署" + consumed_decision = db.get(AIApplicationPreviewDecision, issued["decision_id"]) + assert consumed_decision is not None + assert consumed_decision.status == "consumed" + feedback = db.scalars(select(AIDecisionFeedback)).one() + assert feedback.verification_status == "server_verified" event = db.scalars( select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id) ).one() assert event.event_type == "claim_draft_created" assert event.actor_id == "zhangsan@example.com" - assert event.correlation_id == "steward-action:save_application_draft:task_app_001" + assert event.correlation_id == "application-preview-action:conv-action-save" link = db.scalars( select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id) ).one() assert link.relation_type == "application" +def test_steward_action_executor_blocks_save_without_decision_and_stable_trace() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_employee(db) + + response = client.post( + "/api/v1/steward/actions/execute", + headers=auth_headers(), + json={ + "action_type": "save_application_draft", + "message": "保存申请草稿", + "task": base_application_task("save_draft"), + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "blocked" + assert payload["blocked_reasons"] == ["missing_decision_id"] + + issued = issue_application_preview( + client, + conversation_id="conv-action-missing-trace", + client_trace_id="trace-build-missing-trace", + ) + missing_trace_response = client.post( + "/api/v1/steward/actions/execute", + headers=auth_headers(), + json={ + "action_type": "save_application_draft", + "message": "保存申请草稿", + "conversation_id": "conv-action-missing-trace", + "decision_id": issued["decision_id"], + "task": base_application_task("save_draft"), + }, + ) + missing_trace_payload = missing_trace_response.json() + assert missing_trace_payload["status"] == "blocked" + assert missing_trace_payload["blocked_reasons"] == ["missing_client_trace_id"] + with session_factory() as db: + assert claim_count(db) == 0 + + +def test_steward_action_executor_submits_verified_application_after_confirmation_and_precheck() -> None: + client, session_factory = build_client() + with session_factory() as db: + seed_employee(db) + + issued = issue_application_preview( + client, + conversation_id="conv-action-verified-submit", + client_trace_id="trace-build-submit-preview", + task=base_application_task("submit"), + ) + response = client.post( + "/api/v1/steward/actions/execute", + headers=auth_headers(), + json={ + "action_type": "submit_application", + "message": ( + "2026-02-20 至 2026-02-23,去上海出差," + "辅助国网仿生产服务器部署,交通火车,直接提交" + ), + "conversation_id": "conv-action-verified-submit", + "client_trace_id": "trace-submit-verified-application", + "decision_id": issued["decision_id"], + "task": base_application_task("submit"), + "confirmed": True, + "context_json": { + "precheck_result": { + "status": "ok", + "blocking": False, + } + }, + }, + ) + + assert response.status_code == 200, response.text + payload = response.json() + assert payload["status"] == "succeeded" + assert payload["result_payload"]["draft_payload"]["status"] == "submitted" + assert payload["result_payload"]["decision_id"] is None + with session_factory() as db: + claim = db.scalars(select(ExpenseClaim)).one() + assert claim.status == "submitted" + + def test_steward_action_executor_creates_reimbursement_draft_from_action_step() -> None: client, session_factory = build_client() with session_factory() as db: diff --git a/web/src/assets/styles/components/travel-reimbursement-message-item.css b/web/src/assets/styles/components/travel-reimbursement-message-item.css index 59415f0..2028a8d 100644 --- a/web/src/assets/styles/components/travel-reimbursement-message-item.css +++ b/web/src/assets/styles/components/travel-reimbursement-message-item.css @@ -1131,6 +1131,52 @@ margin: 0; } +.application-preview-decision-status { + margin-top: 28px; + padding: 10px 12px; + border: 1px solid rgba(217, 119, 6, 0.2); + border-radius: 10px; + background: rgba(245, 158, 11, 0.08); + color: #92400e; + font-size: 12px; + font-weight: 720; + line-height: 1.6; +} + +.application-preview-action-row { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + margin-top: 14px; +} + +.application-preview-secondary-btn { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 32px; + padding: 0 12px; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3); + border-radius: 8px; + background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08); + color: var(--theme-primary-active, #255b7d); + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.application-preview-secondary-btn:hover:not(:disabled), +.application-preview-secondary-btn:focus-visible { + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.52); + background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14); +} + +.application-preview-secondary-btn:disabled { + cursor: not-allowed; + opacity: 0.42; +} + .message-answer-markdown :deep(a) { color: var(--theme-primary-active, #255b7d); font-weight: 850; diff --git a/web/src/components/travel/TravelReimbursementMessageItem.vue b/web/src/components/travel/TravelReimbursementMessageItem.vue index bd38ae5..667b799 100644 --- a/web/src/components/travel/TravelReimbursementMessageItem.vue +++ b/web/src/components/travel/TravelReimbursementMessageItem.vue @@ -246,11 +246,47 @@ 。补齐后我再帮您提交申请。 +
+ + 正在由服务端核验并签发申请预览,请稍候。 + + + 当前申请预览尚未通过服务端签发。为避免重复或错误建单,保存与提交已暂停。 + +
+
+ + +
diff --git a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js index 76f06ce..e2e1ae3 100644 --- a/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js +++ b/web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js @@ -53,6 +53,15 @@ export function useWorkbenchAiActionRouter({ const actionType = String(action?.action_type || '').trim() const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {} + if ( + actionPayload.steward_execute_action + && String(actionPayload.steward_current_task?.task_type || '').trim() === 'expense_application' + ) { + // 费用申请必须先进入结构化预览签发流程,不能从 Steward 计划直接执行保存/提交。 + aiExpenseDraft.value = null + void expenseFlow.startAiApplicationPreviewFromAction(actionPayload) + return + } if (actionPayload.steward_execute_action) { return executeInlineStewardAction(action, sourceMessage) } diff --git a/web/src/services/aiApplicationPreviewActions.js b/web/src/services/aiApplicationPreviewActions.js index b2786e3..2d829ae 100644 --- a/web/src/services/aiApplicationPreviewActions.js +++ b/web/src/services/aiApplicationPreviewActions.js @@ -105,7 +105,10 @@ export function buildAiApplicationPreviewActionPayload({ currentUser = {}, conversationId = '', draftPayload = null, - requestId = '' + requestId = '', + entrySource = 'workbench_ai_inline', + source = 'workbench', + requestSource = 'user_message' } = {}) { const normalizedPreview = normalizeApplicationPreview(applicationPreview || {}) const message = buildAiApplicationPreviewActionText(actionType, normalizedPreview) @@ -128,7 +131,7 @@ export function buildAiApplicationPreviewActionPayload({ : [] return { - source: 'user_message', + source: normalizeText(requestSource) || 'user_message', user_id: username, conversation_id: normalizeText(conversationId) || null, action_type: isSubmit ? 'submit' : 'save_draft', @@ -158,8 +161,8 @@ export function buildAiApplicationPreviewActionPayload({ finance_owner_name: resolveUserValue(currentUser, 'financeOwnerName', 'finance_owner_name'), ...buildClientTimeContext(), session_type: 'application', - entry_source: 'workbench_ai_inline', - source: 'workbench', + entry_source: normalizeText(entrySource) || 'workbench_ai_inline', + source: normalizeText(source) || 'workbench', document_type: 'expense_application', application_stage: 'expense_application', user_input_text: message, diff --git a/web/src/views/scripts/TravelReimbursementCreateView.js b/web/src/views/scripts/TravelReimbursementCreateView.js index 288a327..93a5315 100644 --- a/web/src/views/scripts/TravelReimbursementCreateView.js +++ b/web/src/views/scripts/TravelReimbursementCreateView.js @@ -29,6 +29,7 @@ import { useTravelReimbursementMessageActions } from './useTravelReimbursementMe import { useTravelReimbursementSuggestedActions } from './useTravelReimbursementSuggestedActions.js' import { useStewardPlanFlow } from './useStewardPlanFlow.js' import { useTravelReimbursementApplicationSubmitConfirm } from './useTravelReimbursementApplicationSubmitConfirm.js' +import { useTravelReimbursementApplicationPreviewActions } from './useTravelReimbursementApplicationPreviewActions.js' import { useTravelReimbursementStewardRuntimeDecision } from './useTravelReimbursementStewardRuntimeDecision.js' import { useTravelReimbursementApplicationPreviewDateEditor } from './useTravelReimbursementApplicationPreviewDateEditor.js' import { buildStewardFieldItems, formatStewardMissingFieldList, formatStewardOntologyFields } from './stewardPlanModel.js' @@ -238,6 +239,27 @@ export default { calculateTravelReimbursement, currentUser }) + const { + canRetryApplicationPreviewDecision, + canSaveApplicationPreviewDraft, + canSubmitApplicationPreview, + registerApplicationPreviewDecision, + retryApplicationPreviewDecision, + runApplicationPreviewAction, + saveApplicationPreviewDraft + } = useTravelReimbursementApplicationPreviewActions({ + conversationId, + currentUser, + draftClaimId, + emitDraftSaved: (payload) => emit('draft-saved', payload), + entrySource: props.entrySource, + linkedRequest, + persistSessionState, + reviewActionBusy, + sessionSwitchBusy, + submitting, + toast + }) let applyLinkedApplicationPreviewDateSelection = () => false let openApplicationPreviewEditorFromUi = openApplicationPreviewEditor @@ -376,7 +398,7 @@ export default { resolveCurrentUserId }) const { confirmPendingAttachmentAssociationInternal, submitComposerInternal } = useTravelReimbursementSubmitComposer({ - MAX_ATTACHMENTS, activeReviewPayload, activeSessionType, adjustComposerTextareaHeight, attachedFiles, buildAgentInsight, buildClientTimeContext, buildComposerBusinessTimeContext, buildComposerFilePreviews, buildDraftAssociationQueryPayload, buildErrorInsight, buildExpenseIntentConfirmationActions, buildExpenseIntentConfirmationMessage, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, buildMessageMeta, buildOcrDocumentsFromReviewPayload, buildOcrSummaryFromDocuments, buildReviewFormContextFromPayload, clearAttachedFiles, clearFlowSimulationTimers, completeFlowResult, completeFlowStep, composerBusinessTimeDraftTouched, composerBusinessTimeTags, composerDraft, composerUploadIntent, conversationId, createMessage, currentInsight, currentUser, refreshCurrentUserFromBackend, draftClaimId, extractReviewAttachmentNames, failCurrentFlowStep, fetchExpenseClaims, fileInputRef, flowRunId, insightPanelCollapsed, isKnowledgeSession, linkedRequest, mergeBusinessTimeIntoExtraContext, mergeFilePreviews, mergeFilesWithLimit, mergeUploadAttachmentNames, mergeUploadOcrDocuments, messages, nextTick, normalizeExpenseQueryPayload, persistSessionState, props, recognizeOcrFiles, refreshFlowRunDetail, rememberFilePreviews, replaceMessage, resolveComposerDisplaySubmitText, resetFlowRun, resolveComposerSubmitText, reviewInlineForm, runOrchestrator, scrollToBottom, sessionSwitchBusy, shouldRequestExpenseIntentConfirmation, shouldRequestExpenseSceneSelection, startExpenseClaimDraftFlowStep, startExpenseIntentConfirmationFlowPreview, startExpenseSceneSelectionFlowPreview, startFlowStep, startSemanticFlowPreview, submitting, syncComposerFilesToDraft, emitOperationCompleted, emitDraftSaved: (payload) => emit('draft-saved', payload), emitRequestUpdated: (payload) => emit('request-updated', payload), toast + MAX_ATTACHMENTS, activeReviewPayload, activeSessionType, adjustComposerTextareaHeight, attachedFiles, buildAgentInsight, buildClientTimeContext, buildComposerBusinessTimeContext, buildComposerFilePreviews, buildDraftAssociationQueryPayload, buildErrorInsight, buildExpenseIntentConfirmationActions, buildExpenseIntentConfirmationMessage, buildExpenseSceneSelectionActions, buildExpenseSceneSelectionMessage, buildMessageMeta, buildOcrDocumentsFromReviewPayload, buildOcrSummaryFromDocuments, buildReviewFormContextFromPayload, clearAttachedFiles, clearFlowSimulationTimers, completeFlowResult, completeFlowStep, composerBusinessTimeDraftTouched, composerBusinessTimeTags, composerDraft, composerUploadIntent, conversationId, createMessage, currentInsight, currentUser, refreshCurrentUserFromBackend, draftClaimId, extractReviewAttachmentNames, failCurrentFlowStep, fetchExpenseClaims, fileInputRef, flowRunId, insightPanelCollapsed, isKnowledgeSession, linkedRequest, mergeBusinessTimeIntoExtraContext, mergeFilePreviews, mergeFilesWithLimit, mergeUploadAttachmentNames, mergeUploadOcrDocuments, messages, nextTick, normalizeExpenseQueryPayload, persistSessionState, props, recognizeOcrFiles, refreshFlowRunDetail, registerApplicationPreviewDecision, rememberFilePreviews, replaceMessage, resolveComposerDisplaySubmitText, resetFlowRun, resolveComposerSubmitText, reviewInlineForm, runOrchestrator, scrollToBottom, sessionSwitchBusy, shouldRequestExpenseIntentConfirmation, shouldRequestExpenseSceneSelection, startExpenseClaimDraftFlowStep, startExpenseIntentConfirmationFlowPreview, startExpenseSceneSelectionFlowPreview, startFlowStep, startSemanticFlowPreview, submitting, syncComposerFilesToDraft, emitOperationCompleted, emitDraftSaved: (payload) => emit('draft-saved', payload), emitRequestUpdated: (payload) => emit('request-updated', payload), toast }) let submitComposerFromMessageHandlers = null async function submitComposer(options = {}) { @@ -551,7 +573,7 @@ export default { const { closeApplicationSubmitConfirm, confirmApplicationSubmit, openApplicationSubmitConfirm, resolveStewardMissingFieldItems } = useTravelReimbursementApplicationSubmitConfirm({ - activeSessionType, applicationSubmitConfirmDialog, buildStewardFieldItems, createMessage, emitDraftSaved: (payload) => emit('draft-saved', payload), formatStewardMissingFieldList, formatStewardOntologyFields, linkedRequest, messages, nextTick, persistSessionState, reviewActionBusy, scrollToBottom, submitComposer, submitting, toast + activeSessionType, applicationSubmitConfirmDialog, buildStewardFieldItems, createMessage, emitDraftSaved: (payload) => emit('draft-saved', payload), formatStewardMissingFieldList, formatStewardOntologyFields, linkedRequest, messages, nextTick, persistSessionState, reviewActionBusy, runApplicationPreviewAction, scrollToBottom, submitComposer, submitting, toast }) const { handleApplicationSubmitConfirmationText, handleStewardRuntimeDecision } = useTravelReimbursementStewardRuntimeDecision({ @@ -568,7 +590,7 @@ export default { submitComposerFromMessageHandlers = submitComposerMessageHandler const { insightPanelUi, messageItemUi } = useTravelReimbursementCreateViewUi({ - ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps + ASSISTANT_DISPLAY_NAME, DATE_INPUT_FORMAT, REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OTHER_OPTION, activeFlowSteps, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, activeReviewPayload, activeSessionType, aiAvatar, appendExpenseQueryRiskToConversation, appendReviewRiskBriefToConversation, applicationPreviewEditor, askHotKnowledgeQuestion, buildApplicationDraftSummaryItems, buildApplicationPreviewFooterText, buildExpenseQueryHint, buildExpenseQueryWindowLabel, buildMessageBubbleClass, buildReviewMainMessageText, buildReviewNextStepRichCopyForMessage, buildReviewPlainFollowupForMessage, buildReviewPrimaryButtonLabel, canApplyApplicationPreviewDateSelection, canOpenDraftDetail, canPreviewActiveReviewDocument, canRetryApplicationPreviewDecision, canSaveApplicationPreviewDraft, canSubmitApplicationPreview, canUseInlineSaveDraft, clearInlineReviewFieldError, commitApplicationPreviewDateEditor, commitApplicationPreviewEditor, commitInlineReviewEditor, copyAssistantMessage, currentInsight, currentIntentLabel, deleteSessionBusy, flowOverallStatusText, flowOverallStatusTone, flowRefreshBusy, flowRunId, flowTotalDurationText, formatFlowStepDuration, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, goReviewDocument, handleAssistantMarkdownClick, handleExpenseQueryRecordClick, handleInlineSaveDraft, handleReviewAction, handleSuggestedAction, isApplicationDraftPayload, isApplicationPreviewDateEditorOpen, isApplicationPreviewEditing, isKnowledgeSession, isMessageFeedbackSelected, isReviewDocumentDrawer, isReviewFlowDrawer, isReviewOverviewDrawer, isReviewRiskDrawer, isSuggestedActionSelected, openActiveReviewDocumentPreview, openApplicationDraftDetail, openApplicationPreviewEditorFromUi, openInlineReviewEditor, refreshFlowRunDetail, renderMarkdown, resolveApplicationDraftStatusLabel, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, resolveApplicationPreviewMissingFields, resolveApplicationPreviewRows, resolveFlowStepDetail, resolveFlowStepStatusLabel, resolveKnowledgeRankLabel, resolveKnowledgeRankTone, resolveReimbursementDraftClaimNo, resolveReviewFooterActions, resolveStewardMissingFieldItems, retryApplicationPreviewDecision, reviewActionBusy, reviewCategoryOptions, reviewDocumentCount, reviewDocumentDrawerAvailable, reviewDocumentDrawerIcon, reviewDrawerMode, reviewDrawerTitle, reviewFactCards, reviewFlowDrawerAvailable, reviewFlowDrawerIcon, reviewHasUnsavedChanges, reviewInlineEditorKey, reviewInlineErrors, reviewInlineForm, reviewInlinePendingFiles, reviewOtherCategoryOpen, reviewOtherCategoryOptions, reviewOverviewDrawerAvailable, reviewPanelConfidence, reviewRiskDrawerAvailable, reviewRiskDrawerIcon, reviewRiskEmpty, reviewRiskItems, reviewRiskSummary, reviewSelectedOtherCategory, runShortcut, saveApplicationPreviewDraft, saveInlineReviewChanges, selectInlineScene, selectReviewCategory, selectReviewOtherCategory, sessionSwitchBusy, setApplicationPreviewDateMode, setExpenseQueryPage, shiftExpenseQueryPage, shouldShowAssistantMessageActions, shouldShowDraftSavedCard, speakAssistantMessage, submitOperationFeedbackForMessage, submitting, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewFlowDrawer, toggleReviewRiskDrawer, userAvatar, visibleFlowSteps }) return { diff --git a/web/src/views/scripts/travelReimbursementConversationStateModel.js b/web/src/views/scripts/travelReimbursementConversationStateModel.js index 338c263..e1e2d7f 100644 --- a/web/src/views/scripts/travelReimbursementConversationStateModel.js +++ b/web/src/views/scripts/travelReimbursementConversationStateModel.js @@ -121,7 +121,8 @@ export function normalizeInitialConversationMessages(conversation) { queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null, draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null, reviewPayload: item.role === 'assistant' ? result?.review_payload || null : null, - riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [] + riskFlags: item.role === 'assistant' && Array.isArray(result?.risk_flags) ? result.risk_flags : [], + applicationPreview: item.role === 'assistant' ? result?.application_preview || null : null }) }) return markResolvedSuggestedActionMessages(restoredMessages) @@ -135,6 +136,15 @@ export function normalizeSnapshotMessage(message) { delete extras.role delete extras.text delete extras.attachments + if (!extras.applicationPreview && extras.pendingApplicationPreviewDecision) { + extras.applicationPreview = { + ...extras.pendingApplicationPreviewDecision, + decisionId: '', + decisionTrackingStatus: 'registration_failed', + decisionRegistrationError: '上次签发请求未完成,请重新签发。' + } + } + delete extras.pendingApplicationPreviewDecision return createMessage(role, text, attachments, extras) } @@ -164,12 +174,14 @@ export function serializeSessionMessages(messages) { draftPayload: message.draftPayload || null, reviewPayload: message.reviewPayload || null, riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [], - pendingAttachmentAssociation: message.pendingAttachmentAssociation || null, - applicationPreview: message.applicationPreview || null, - budgetReport: message.budgetReport || null, - stewardPlan: message.stewardPlan || null, - operationFeedback: message.operationFeedback || null, - assistantName: message.assistantName || '', + pendingAttachmentAssociation: message.pendingAttachmentAssociation || null, + applicationPreview: message.applicationPreview || null, + pendingApplicationPreviewDecision: message.pendingApplicationPreviewDecision || null, + applicationSubmitConfirmed: Boolean(message.applicationSubmitConfirmed), + budgetReport: message.budgetReport || null, + stewardPlan: message.stewardPlan || null, + operationFeedback: message.operationFeedback || null, + assistantName: message.assistantName || '', assistantVariant: message.assistantVariant || '', isWelcome: Boolean(message.isWelcome), welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : [] diff --git a/web/src/views/scripts/travelReimbursementSubmitLocalPreviewFlow.js b/web/src/views/scripts/travelReimbursementSubmitLocalPreviewFlow.js index 3e6728c..0b44a0b 100644 --- a/web/src/views/scripts/travelReimbursementSubmitLocalPreviewFlow.js +++ b/web/src/views/scripts/travelReimbursementSubmitLocalPreviewFlow.js @@ -34,6 +34,7 @@ export async function handleLocalApplicationPreviewFlow({ options, persistSessionState, rawText, + registerApplicationPreviewDecision = async ({ applicationPreview }) => applicationPreview, replaceMessage, resetFlowRun, resetStewardDelegatedInsightState, @@ -108,7 +109,7 @@ export async function handleLocalApplicationPreviewFlow({ submitting.value = true try { - const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview( + const { applicationPreview: generatedApplicationPreview, meta } = await buildApplicationPreviewWithModelReview( rawText, selectedBusinessTimeContext, effectiveSessionType, @@ -120,7 +121,7 @@ export async function handleLocalApplicationPreviewFlow({ let applicationDateConflict = null try { const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 }) - applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims) + applicationDateConflict = findOverlappingApplicationClaim(generatedApplicationPreview, existingClaims) } catch (error) { console.warn('Failed to check overlapping application dates:', error) } @@ -157,7 +158,7 @@ export async function handleLocalApplicationPreviewFlow({ { sessionType: effectiveSessionType, rawText, - applicationPreview, + applicationPreview: generatedApplicationPreview, stewardContinuation: options.stewardContinuation || null } ) @@ -179,6 +180,7 @@ export async function handleLocalApplicationPreviewFlow({ Date.now() - reviewStartedAt ) } + let applicationPreview = generatedApplicationPreview if (stewardDelegated) { const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage( applicationPreview, @@ -197,6 +199,13 @@ export async function handleLocalApplicationPreviewFlow({ const pauseForMissingFields = slotDecision ? String(slotDecision.next_action || '').trim() === 'ask_user' : localPauseForMissingFields + if (!pauseForMissingFields) { + applicationPreview = await registerApplicationPreviewDecision({ + applicationPreview, + sourceText: rawText, + targetMessage: pendingMessage + }) + } const stewardApplicationText = buildStewardSlotDecisionMessage( slotDecision, applicationPreview, @@ -221,6 +230,11 @@ export async function handleLocalApplicationPreviewFlow({ } ) } else { + applicationPreview = await registerApplicationPreviewDecision({ + applicationPreview, + sourceText: rawText, + targetMessage: pendingMessage + }) replaceMessage(pendingMessage.id, createMessage( 'assistant', buildLocalApplicationPreviewMessage(applicationPreview), diff --git a/web/src/views/scripts/useTravelReimbursementApplicationPreviewActions.js b/web/src/views/scripts/useTravelReimbursementApplicationPreviewActions.js new file mode 100644 index 0000000..02b9a46 --- /dev/null +++ b/web/src/views/scripts/useTravelReimbursementApplicationPreviewActions.js @@ -0,0 +1,354 @@ +import { + AI_APPLICATION_ACTION_SAVE_DRAFT, + AI_APPLICATION_ACTION_SUBMIT, + createAiApplicationRequestId, + registerAiApplicationPreviewDecision, + runAiApplicationPreviewAction +} from '../../services/aiApplicationPreviewActions.js' +import { normalizeApplicationPreview } from '../../utils/expenseApplicationPreview.js' + +const DECISION_STATUS_REGISTERED = 'registered' +const DECISION_STATUS_REGISTERING = 'registering' +const DECISION_STATUS_FAILED = 'registration_failed' +const DECISION_STATUS_UNREGISTERED = 'unregistered' + +function resolveRefValue(value, fallback = null) { + if (value && typeof value === 'object' && 'value' in value) { + return value.value ?? fallback + } + return value ?? fallback +} + +function normalizeText(value) { + return String(value || '').trim() +} + +function resolveErrorMessage(error, fallback) { + return normalizeText(error?.message || error) || fallback +} + +function resolveDecisionSourceText(preview = {}, sourceText = '') { + return normalizeText( + sourceText + || preview.decisionSourceText + || preview.sourceText + || preview.originalMessage + ) +} + +function resolveApplicationDraftPayload(message = {}, draftClaimId = '', linkedRequest = null) { + if (message?.draftPayload && typeof message.draftPayload === 'object') { + return message.draftPayload + } + const request = resolveRefValue(linkedRequest, {}) || {} + const claimId = normalizeText( + draftClaimId + || request.claimId + || request.claim_id + ) + if (!claimId) return null + return { + claim_id: claimId, + claim_no: normalizeText(request.claimNo || request.claim_no || request.id) + } +} + +export function useTravelReimbursementApplicationPreviewActions({ + conversationId, + currentUser, + draftClaimId, + emitDraftSaved, + entrySource = 'application', + linkedRequest, + persistSessionState = () => {}, + reviewActionBusy, + sessionSwitchBusy, + submitting, + toast = () => {} +} = {}) { + function isInteractionBusy() { + return Boolean( + resolveRefValue(submitting, false) + || resolveRefValue(reviewActionBusy, false) + || resolveRefValue(sessionSwitchBusy, false) + ) + } + + function isRegisteredApplicationPreview(message) { + const preview = normalizeApplicationPreview(message?.applicationPreview || {}) + return Boolean( + preview.readyToSubmit + && normalizeText(preview.decisionId) + && preview.decisionTrackingStatus === DECISION_STATUS_REGISTERED + ) + } + + function canSubmitApplicationPreview(message) { + return isRegisteredApplicationPreview(message) + } + + function canSaveApplicationPreviewDraft(message) { + return !isInteractionBusy() && isRegisteredApplicationPreview(message) + } + + function canRetryApplicationPreviewDecision(message) { + if (isInteractionBusy() || !message?.applicationPreview) return false + if (message.applicationSubmitConfirmed) return false + if (normalizeText(message?.draftPayload?.status) === 'submitted') return false + const preview = normalizeApplicationPreview(message.applicationPreview) + return Boolean( + preview.readyToSubmit + && !normalizeText(preview.decisionId) + && [DECISION_STATUS_FAILED, DECISION_STATUS_UNREGISTERED, 'degraded', 'consumed', ''].includes( + normalizeText(preview.decisionTrackingStatus) + ) + ) + } + + function buildPendingDecisionPreview(applicationPreview = {}, sourceText = '') { + const normalized = normalizeApplicationPreview(applicationPreview) + return normalizeApplicationPreview({ + ...normalized, + decisionId: '', + decisionIssueRequestId: normalizeText(normalized.decisionIssueRequestId) + || createAiApplicationRequestId('preview'), + decisionActionRequestId: normalizeText(normalized.decisionActionRequestId) + || createAiApplicationRequestId('action'), + decisionSourceText: resolveDecisionSourceText(normalized, sourceText), + decisionTrackingStatus: DECISION_STATUS_REGISTERING, + decisionRegistrationError: '' + }) + } + + async function registerApplicationPreviewDecision({ + applicationPreview, + sourceText = '', + targetMessage = null, + preserveCurrentFields = false + } = {}) { + const normalized = normalizeApplicationPreview(applicationPreview || {}) + if (!normalized.readyToSubmit) { + return normalizeApplicationPreview({ + ...normalized, + decisionId: '', + decisionSourceText: resolveDecisionSourceText(normalized, sourceText), + decisionTrackingStatus: DECISION_STATUS_UNREGISTERED + }) + } + if ( + normalizeText(normalized.decisionId) + && normalized.decisionTrackingStatus === DECISION_STATUS_REGISTERED + ) { + return normalized + } + + const pendingPreview = buildPendingDecisionPreview(normalized, sourceText) + if (targetMessage) { + // 网络请求前先把幂等键写入本地会话;超时或刷新后仍可用同一请求继续签发。 + targetMessage.pendingApplicationPreviewDecision = pendingPreview + persistSessionState() + } + + try { + const registered = await registerAiApplicationPreviewDecision({ + applicationPreview: pendingPreview, + conversationId: normalizeText(resolveRefValue(conversationId, '')), + message: pendingPreview.decisionSourceText, + requestId: pendingPreview.decisionIssueRequestId + }) + const nextPreview = normalizeApplicationPreview({ + ...registered, + ...(preserveCurrentFields ? { fields: pendingPreview.fields } : {}), + decisionIssueRequestId: pendingPreview.decisionIssueRequestId, + decisionActionRequestId: pendingPreview.decisionActionRequestId, + decisionSourceText: pendingPreview.decisionSourceText, + decisionTrackingStatus: DECISION_STATUS_REGISTERED, + decisionRegistrationError: '' + }) + if (targetMessage) { + delete targetMessage.pendingApplicationPreviewDecision + targetMessage.applicationPreview = nextPreview + persistSessionState() + } + return nextPreview + } catch (error) { + const failedPreview = normalizeApplicationPreview({ + ...pendingPreview, + decisionId: '', + decisionSource: '', + decisionExpiresAt: '', + decisionTrackingStatus: DECISION_STATUS_FAILED, + decisionRegistrationError: resolveErrorMessage(error, '服务端暂未完成申请预览签发。') + }) + if (targetMessage) { + delete targetMessage.pendingApplicationPreviewDecision + targetMessage.applicationPreview = failedPreview + persistSessionState() + } + return failedPreview + } + } + + async function retryApplicationPreviewDecision(message) { + if (!canRetryApplicationPreviewDecision(message)) return false + reviewActionBusy.value = true + try { + const preview = await registerApplicationPreviewDecision({ + applicationPreview: message.applicationPreview, + sourceText: resolveDecisionSourceText(message.applicationPreview), + targetMessage: message, + preserveCurrentFields: true + }) + if (preview.decisionTrackingStatus === DECISION_STATUS_REGISTERED) { + message.text = message.text || '' + toast('申请预览已重新签发,可以保存或提交。') + return true + } + toast(preview.decisionRegistrationError || '申请预览签发失败,请稍后重试。') + return false + } finally { + reviewActionBusy.value = false + persistSessionState() + } + } + + async function runApplicationPreviewAction(actionType, message) { + if (!message?.applicationPreview) { + throw new Error('当前没有可处理的申请预览。') + } + let preview = normalizeApplicationPreview(message.applicationPreview) + if (!preview.readyToSubmit) { + throw new Error(`请先补充:${preview.missingFields.join('、') || '申请必填信息'}。`) + } + if ( + !normalizeText(preview.decisionId) + || preview.decisionTrackingStatus !== DECISION_STATUS_REGISTERED + ) { + // structured preview 必须持有服务端签发结果,禁止降级为客户端观察动作。 + throw new Error('申请预览尚未通过服务端签发,请先点击“重新签发”。') + } + + const actionRequestId = normalizeText(preview.decisionActionRequestId) + || createAiApplicationRequestId('action') + preview = normalizeApplicationPreview({ + ...preview, + decisionActionRequestId: actionRequestId + }) + message.applicationPreview = preview + persistSessionState() + + let payload + try { + payload = await runAiApplicationPreviewAction({ + actionType, + applicationPreview: preview, + currentUser: resolveRefValue(currentUser, {}) || {}, + conversationId: normalizeText(resolveRefValue(conversationId, '')), + draftPayload: resolveApplicationDraftPayload( + message, + normalizeText(resolveRefValue(draftClaimId, '')), + linkedRequest + ), + requestId: actionRequestId, + entrySource: normalizeText(entrySource) || 'application', + source: 'financial_assistant' + }) + } catch (error) { + const errorText = resolveErrorMessage(error, '申请动作执行失败。') + if (/已过期|已被.*消费|不可使用|不存在或不属于|重新生成预览/.test(errorText)) { + message.applicationPreview = normalizeApplicationPreview({ + ...preview, + decisionId: '', + decisionSource: '', + decisionExpiresAt: '', + decisionTrackingStatus: DECISION_STATUS_FAILED, + decisionRegistrationError: `${errorText} 请重新签发后继续。`, + decisionIssueRequestId: createAiApplicationRequestId('preview'), + decisionActionRequestId: '' + }) + persistSessionState() + } + throw error + } + const result = payload?.result && typeof payload.result === 'object' ? payload.result : {} + const nextDraftPayload = result.draft_payload && typeof result.draft_payload === 'object' + ? result.draft_payload + : null + if (nextDraftPayload) { + message.draftPayload = nextDraftPayload + const nextClaimId = normalizeText(nextDraftPayload.claim_id || nextDraftPayload.claimId) + if (nextClaimId && draftClaimId && typeof draftClaimId === 'object' && 'value' in draftClaimId) { + draftClaimId.value = nextClaimId + } + } + + const nextDecisionId = normalizeText(result.decision_id) + const isSaveDraft = actionType === AI_APPLICATION_ACTION_SAVE_DRAFT + message.applicationPreview = normalizeApplicationPreview({ + ...preview, + decisionId: nextDecisionId, + decisionSource: nextDecisionId ? 'server_draft' : '', + decisionExpiresAt: normalizeText(result.decision_expires_at), + decisionTrackingStatus: nextDecisionId + ? DECISION_STATUS_REGISTERED + : isSaveDraft + ? DECISION_STATUS_FAILED + : 'consumed', + decisionRegistrationError: isSaveDraft && !nextDecisionId + ? '草稿已保存,但后续申请预览签发失败,请重新签发后再提交。' + : '', + decisionIssueRequestId: isSaveDraft && !nextDecisionId + ? createAiApplicationRequestId('preview') + : '', + decisionActionRequestId: nextDecisionId ? createAiApplicationRequestId('action') : '' + }) + persistSessionState() + return payload + } + + async function saveApplicationPreviewDraft(message) { + if (!canSaveApplicationPreviewDraft(message)) return null + reviewActionBusy.value = true + try { + const payload = await runApplicationPreviewAction( + AI_APPLICATION_ACTION_SAVE_DRAFT, + message + ) + const draftPayload = payload?.result?.draft_payload || {} + const claimId = normalizeText(draftPayload.claim_id || draftPayload.claimId) + const claimNo = normalizeText(draftPayload.claim_no || draftPayload.claimNo) + if (claimId || claimNo) { + emitDraftSaved?.({ + claimId, + claimNo, + status: 'draft', + approvalStage: normalizeText(draftPayload.approval_stage) || '待提交', + documentType: 'application' + }) + } + toast('申请草稿已保存。') + return payload + } catch (error) { + toast(resolveErrorMessage(error, '申请草稿保存失败,请稍后重试。')) + return null + } finally { + reviewActionBusy.value = false + persistSessionState() + } + } + + return { + canRetryApplicationPreviewDecision, + canSaveApplicationPreviewDraft, + canSubmitApplicationPreview, + registerApplicationPreviewDecision, + retryApplicationPreviewDecision, + runApplicationPreviewAction, + saveApplicationPreviewDraft + } +} + +export { + AI_APPLICATION_ACTION_SAVE_DRAFT, + AI_APPLICATION_ACTION_SUBMIT +} diff --git a/web/src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js b/web/src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js index 5b1b4c8..6f4de53 100644 --- a/web/src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js +++ b/web/src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js @@ -9,6 +9,7 @@ import { } from '../../utils/travelApplicationPlanning.js' import { SESSION_TYPE_APPLICATION } from './travelReimbursementConversationModel.js' import { useTravelReimbursementStewardFollowupFlow } from './travelReimbursementStewardFollowupFlow.js' +import { AI_APPLICATION_ACTION_SUBMIT } from './useTravelReimbursementApplicationPreviewActions.js' export function useTravelReimbursementApplicationSubmitConfirm({ activeSessionType, @@ -23,6 +24,7 @@ export function useTravelReimbursementApplicationSubmitConfirm({ nextTick, persistSessionState, reviewActionBusy, + runApplicationPreviewAction, scrollToBottom, submitComposer, submitting, @@ -103,31 +105,36 @@ export function useTravelReimbursementApplicationSubmitConfirm({ const stewardSubmitContinuation = message?.stewardContinuation || null reviewActionBusy.value = true try { - const payload = await submitComposer({ - rawText: applicationSubmitText, - userText: String(options.userText || '').trim() || '确认提交', - skipUserMessage: Boolean(options.skipUserMessage), - pendingText: '正在提交费用申请...', - systemGenerated: true, - skipScopeGuard: true, - skipStewardPlan: true, - stewardContinuation: stewardSubmitContinuation, - sessionTypeOverride: SESSION_TYPE_APPLICATION, - feedbackOperationType: 'submit_application', - extraContext: { - application_preview: applicationPreview, - user_input_text: applicationSubmitText, - ...(applicationEditClaimId - ? { - application_edit_claim_id: applicationEditClaimId, - application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(), - application_edit_mode: true, - draft_claim_id: applicationEditClaimId, - selected_claim_id: applicationEditClaimId - } - : {}) - } - }) + const payload = applicationPreview + ? await runApplicationPreviewAction( + AI_APPLICATION_ACTION_SUBMIT, + message + ) + : await submitComposer({ + rawText: applicationSubmitText, + userText: String(options.userText || '').trim() || '确认提交', + skipUserMessage: Boolean(options.skipUserMessage), + pendingText: '正在提交费用申请...', + systemGenerated: true, + skipScopeGuard: true, + skipStewardPlan: true, + stewardContinuation: stewardSubmitContinuation, + sessionTypeOverride: SESSION_TYPE_APPLICATION, + feedbackOperationType: 'submit_application', + extraContext: { + application_preview: applicationPreview, + user_input_text: applicationSubmitText, + ...(applicationEditClaimId + ? { + application_edit_claim_id: applicationEditClaimId, + application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(), + application_edit_mode: true, + draft_claim_id: applicationEditClaimId, + selected_claim_id: applicationEditClaimId + } + : {}) + } + }) const draftPayload = payload?.result?.draft_payload || {} const claimNo = String(draftPayload.claim_no || '').trim() const claimId = String(draftPayload.claim_id || '').trim() @@ -140,6 +147,7 @@ export function useTravelReimbursementApplicationSubmitConfirm({ approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(), documentType: 'application' }) + persistSessionState() } const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload) const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({ @@ -162,6 +170,8 @@ export function useTravelReimbursementApplicationSubmitConfirm({ if (stewardFollowup) { await pushStewardContinuationMessage(stewardFollowup) } + } catch (error) { + toast(String(error?.message || '').trim() || '申请提交失败,请稍后重试。') } finally { reviewActionBusy.value = false } diff --git a/web/src/views/scripts/useTravelReimbursementCreateViewUi.js b/web/src/views/scripts/useTravelReimbursementCreateViewUi.js index a9018c2..d65602b 100644 --- a/web/src/views/scripts/useTravelReimbursementCreateViewUi.js +++ b/web/src/views/scripts/useTravelReimbursementCreateViewUi.js @@ -27,6 +27,11 @@ export function useTravelReimbursementCreateViewUi(ctx) { canApplyApplicationPreviewDateSelection: ctx.canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown: ctx.handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText: ctx.buildApplicationPreviewFooterText, + canRetryApplicationPreviewDecision: ctx.canRetryApplicationPreviewDecision, + canSaveApplicationPreviewDraft: ctx.canSaveApplicationPreviewDraft, + canSubmitApplicationPreview: ctx.canSubmitApplicationPreview, + retryApplicationPreviewDecision: ctx.retryApplicationPreviewDecision, + saveApplicationPreviewDraft: ctx.saveApplicationPreviewDraft, isApplicationDraftPayload: ctx.isApplicationDraftPayload, resolveApplicationDraftStatusLabel: ctx.resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems: ctx.buildApplicationDraftSummaryItems, diff --git a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js index 27e18cd..4793257 100644 --- a/web/src/views/scripts/useTravelReimbursementSubmitComposer.js +++ b/web/src/views/scripts/useTravelReimbursementSubmitComposer.js @@ -74,6 +74,7 @@ export function useTravelReimbursementSubmitComposer(ctx) { normalizeExpenseQueryPayload, persistSessionState, props, + registerApplicationPreviewDecision, recognizeOcrFiles, refreshCurrentUserFromBackend, refreshFlowRunDetail, @@ -329,6 +330,7 @@ export function useTravelReimbursementSubmitComposer(ctx) { options, persistSessionState, rawText, + registerApplicationPreviewDecision, replaceMessage, resetFlowRun, resetStewardDelegatedInsightState, diff --git a/web/tests/ai-application-preview-actions.test.mjs b/web/tests/ai-application-preview-actions.test.mjs index a7b8549..33b4601 100644 --- a/web/tests/ai-application-preview-actions.test.mjs +++ b/web/tests/ai-application-preview-actions.test.mjs @@ -223,11 +223,44 @@ async function testEditDraftActionCarriesClaimAndEditableFields() { assert.deepEqual(body.context_json.application_editable_fields, ['reason', 'time', 'location', 'transportMode']) } +async function testApplicationActionSourceCanBeConfiguredWithoutChangingWorkbenchDefaults() { + const requests = [] + global.fetch = async (_url, options) => { + requests.push(JSON.parse(options.body)) + return { + ok: true, + async json() { + return { status: 'succeeded', result: {} } + } + } + } + + await runAiApplicationPreviewAction({ + actionType: AI_APPLICATION_ACTION_SAVE_DRAFT, + applicationPreview: { fields: { reason: '默认工作台来源' } } + }) + await runAiApplicationPreviewAction({ + actionType: AI_APPLICATION_ACTION_SAVE_DRAFT, + applicationPreview: { fields: { reason: '小财管家来源' } }, + entrySource: 'application', + source: 'financial_assistant', + requestSource: 'assistant_preview_action' + }) + + assert.equal(requests[0].source, 'user_message') + assert.equal(requests[0].context_json.entry_source, 'workbench_ai_inline') + assert.equal(requests[0].context_json.source, 'workbench') + assert.equal(requests[1].source, 'assistant_preview_action') + assert.equal(requests[1].context_json.entry_source, 'application') + assert.equal(requests[1].context_json.source, 'financial_assistant') +} + async function run() { await testSubmitActionUsesFastPreviewEndpoint() await testSaveDraftActionUsesFastPreviewEndpoint() await testRegistrationUsesServerCanonicalPreview() await testEditDraftActionCarriesClaimAndEditableFields() + await testApplicationActionSourceCanBeConfiguredWithoutChangingWorkbenchDefaults() console.log('ai-application-preview-actions tests passed') } diff --git a/web/tests/expense-application-submit-rich-confirm.test.mjs b/web/tests/expense-application-submit-rich-confirm.test.mjs index d95a471..a5c7490 100644 --- a/web/tests/expense-application-submit-rich-confirm.test.mjs +++ b/web/tests/expense-application-submit-rich-confirm.test.mjs @@ -17,8 +17,12 @@ const suggestedActionsScript = readFileSync( fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)), 'utf8' ) -const stewardRuntimeScript = readFileSync( - fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)), +const submitConfirmScript = readFileSync( + fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js', import.meta.url)), + 'utf8' +) +const messageHandlersScript = readFileSync( + fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementCreateViewMessageHandlers.js', import.meta.url)), 'utf8' ) const messageItemStyles = readFileSync( @@ -44,24 +48,24 @@ test('expense application submit uses rich text link and confirm dialog', () => assert.match(createViewTemplate, /@confirm="confirmApplicationSubmit"/) assert.match(createViewScript, /const APPLICATION_SUBMIT_HREF = '#application-submit'/) assert.match( - createViewScript, + messageHandlersScript, /href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/ ) assert.match( - stewardRuntimeScript, - /async function confirmApplicationSubmit\(options = \{\}\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/ + submitConfirmScript, + /async function confirmApplicationSubmit\(options = \{\}\)[\s\S]*const applicationSubmitText[\s\S]*applicationPreview[\s\S]*runApplicationPreviewAction[\s\S]*AI_APPLICATION_ACTION_SUBMIT[\s\S]*submitComposer/ ) assert.match( - stewardRuntimeScript, - /applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/ + submitConfirmScript, + /applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = applicationPreview/ ) assert.match( - stewardRuntimeScript, - /emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/ + submitConfirmScript, + /emitDraftSaved\(\{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/ ) - assert.match(stewardRuntimeScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/) - assert.match(stewardRuntimeScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/) - assert.match(stewardRuntimeScript, /meta:\s*\['行程规划推荐'\]/) + assert.match(submitConfirmScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/) + assert.match(submitConfirmScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/) + assert.match(submitConfirmScript, /meta:\s*\['行程规划推荐'\]/) assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_GENERATE/) assert.match(suggestedActionsScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/) assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_SKIP/) diff --git a/web/tests/financial-assistant-application-preview-actions.test.mjs b/web/tests/financial-assistant-application-preview-actions.test.mjs new file mode 100644 index 0000000..63df051 --- /dev/null +++ b/web/tests/financial-assistant-application-preview-actions.test.mjs @@ -0,0 +1,339 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + AI_APPLICATION_ACTION_SAVE_DRAFT, + AI_APPLICATION_ACTION_SUBMIT, + useTravelReimbursementApplicationPreviewActions +} from '../src/views/scripts/useTravelReimbursementApplicationPreviewActions.js' +import { + normalizeSnapshotMessage, + serializeSessionMessages +} from '../src/views/scripts/travelReimbursementConversationStateModel.js' +import { useTravelReimbursementApplicationSubmitConfirm } from '../src/views/scripts/useTravelReimbursementApplicationSubmitConfirm.js' + +function createPreview(overrides = {}) { + return { + fields: { + applicationType: '差旅费用申请', + time: '2026-07-20 至 2026-07-22', + location: '上海', + reason: '客户现场实施', + days: '3天', + transportMode: '火车', + amount: '1800元' + }, + ...overrides + } +} + +function createActions(overrides = {}) { + const persisted = [] + const emitted = [] + const toasts = [] + const state = { + conversationId: { value: 'conversation-financial-assistant' }, + currentUser: { value: { username: 'zhangsan', name: '张三' } }, + draftClaimId: { value: '' }, + linkedRequest: { value: null }, + reviewActionBusy: { value: false }, + sessionSwitchBusy: { value: false }, + submitting: { value: false }, + emitDraftSaved: (payload) => emitted.push(payload), + entrySource: 'application', + persistSessionState: () => persisted.push(Date.now()), + toast: (message) => toasts.push(message), + ...overrides + } + return { + actions: useTravelReimbursementApplicationPreviewActions(state), + emitted, + persisted, + state, + toasts + } +} + +test('完整 preview 展示前写入稳定签发/动作 request id,并使用 canonical decision', async () => { + const targetMessage = { id: 'preview-1', applicationPreview: null } + const { actions, persisted } = createActions() + let pendingIssueRequestId = '' + let pendingActionRequestId = '' + + global.fetch = async (_url, options) => { + const body = JSON.parse(options.body) + pendingIssueRequestId = targetMessage.pendingApplicationPreviewDecision?.decisionIssueRequestId + pendingActionRequestId = targetMessage.pendingApplicationPreviewDecision?.decisionActionRequestId + assert.equal(body.request_id, pendingIssueRequestId) + assert.ok(pendingActionRequestId.startsWith('action:')) + assert.equal(targetMessage.applicationPreview, null) + return { + ok: true, + async json() { + return { + decision_id: 'decision-assistant-1', + decision_source: 'rule', + expires_at: '2026-07-14T10:30:00Z', + application_preview: { + fields: { + reason: '服务端规范事由', + location: '上海' + } + } + } + } + } + } + + const preview = await actions.registerApplicationPreviewDecision({ + applicationPreview: createPreview(), + sourceText: '7月20日去上海做客户现场实施,坐火车', + targetMessage + }) + + assert.equal(preview.decisionId, 'decision-assistant-1') + assert.equal(preview.decisionTrackingStatus, 'registered') + assert.equal(preview.fields.reason, '服务端规范事由') + assert.equal(preview.decisionIssueRequestId, pendingIssueRequestId) + assert.equal(preview.decisionActionRequestId, pendingActionRequestId) + assert.equal(targetMessage.pendingApplicationPreviewDecision, undefined) + assert.ok(persisted.length >= 2) +}) + +test('签发失败后 fail-closed,可编辑并以相同 request id 重试签发', async () => { + const targetMessage = { id: 'preview-2', applicationPreview: null } + const { actions } = createActions() + const requestIds = [] + let attempt = 0 + global.fetch = async (_url, options) => { + attempt += 1 + requestIds.push(JSON.parse(options.body).request_id) + if (attempt === 1) { + return { + ok: false, + status: 503, + async json() { + return { detail: '签发服务暂不可用' } + } + } + } + return { + ok: true, + async json() { + return { + decision_id: 'decision-assistant-retry', + application_preview: { fields: { reason: '服务端规范事由' } } + } + } + } + } + + const failed = await actions.registerApplicationPreviewDecision({ + applicationPreview: createPreview(), + sourceText: '去上海做客户现场实施', + targetMessage + }) + assert.equal(failed.decisionTrackingStatus, 'registration_failed') + assert.equal(failed.decisionId, '') + assert.equal(actions.canSaveApplicationPreviewDraft(targetMessage), false) + await assert.rejects( + actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, targetMessage), + /尚未通过服务端签发/ + ) + + targetMessage.applicationPreview.fields.reason = '用户修正后的事由' + const retried = await actions.retryApplicationPreviewDecision(targetMessage) + assert.equal(retried, true) + assert.equal(requestIds[1], requestIds[0]) + assert.equal(targetMessage.applicationPreview.fields.reason, '用户修正后的事由') + assert.equal(targetMessage.applicationPreview.decisionId, 'decision-assistant-retry') +}) + +test('动作失败重试复用 action request id;保存成功更新草稿和 next decision', async () => { + const message = { + id: 'preview-3', + applicationPreview: createPreview({ + decisionId: 'decision-assistant-action', + decisionTrackingStatus: 'registered', + decisionActionRequestId: 'action:stable-retry' + }), + draftPayload: null + } + const { actions, emitted, state } = createActions() + const actionBodies = [] + let attempt = 0 + global.fetch = async (_url, options) => { + attempt += 1 + actionBodies.push(JSON.parse(options.body)) + if (attempt === 1) { + return { + ok: false, + status: 503, + async json() { + return { detail: '动作服务暂不可用' } + } + } + } + return { + ok: true, + async json() { + return { + status: 'succeeded', + result: { + draft_payload: { + claim_id: 'claim-assistant-draft', + claim_no: 'AP-20260714-001', + approval_stage: '待提交' + }, + decision_id: 'decision-assistant-next', + decision_expires_at: '2026-07-14T11:00:00Z' + } + } + } + } + } + + await assert.rejects( + actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, message), + /动作服务暂不可用/ + ) + assert.equal(message.applicationPreview.decisionActionRequestId, 'action:stable-retry') + await actions.saveApplicationPreviewDraft(message) + + assert.equal(actionBodies[0].request_id, 'action:stable-retry') + assert.equal(actionBodies[1].request_id, 'action:stable-retry') + assert.equal(actionBodies[1].context_json.entry_source, 'application') + assert.equal(actionBodies[1].context_json.source, 'financial_assistant') + assert.equal(message.draftPayload.claim_id, 'claim-assistant-draft') + assert.equal(state.draftClaimId.value, 'claim-assistant-draft') + assert.equal(message.applicationPreview.decisionId, 'decision-assistant-next') + assert.notEqual(message.applicationPreview.decisionActionRequestId, 'action:stable-retry') + assert.equal(emitted[0].status, 'draft') +}) + +test('decision 过期后清空旧 ID 并开放重新签发', async () => { + const message = { + id: 'preview-expired', + applicationPreview: createPreview({ + decisionId: 'decision-expired', + decisionTrackingStatus: 'registered', + decisionIssueRequestId: 'preview:old', + decisionActionRequestId: 'action:expired' + }) + } + const { actions } = createActions() + global.fetch = async () => ({ + ok: false, + status: 409, + async json() { + return { detail: '该预览决策已过期,请重新生成预览。' } + } + }) + + await assert.rejects( + actions.runApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, message), + /已过期/ + ) + assert.equal(message.applicationPreview.decisionId, '') + assert.equal(message.applicationPreview.decisionTrackingStatus, 'registration_failed') + assert.notEqual(message.applicationPreview.decisionIssueRequestId, 'preview:old') + assert.equal(message.applicationPreview.decisionActionRequestId, '') + assert.equal(actions.canRetryApplicationPreviewDecision(message), true) +}) + +test('本地快照保留签发与动作 request id,并把中断中的签发恢复为可重试状态', () => { + const pending = createPreview({ + decisionIssueRequestId: 'preview:recover', + decisionActionRequestId: 'action:recover', + decisionTrackingStatus: 'registering' + }) + const serialized = serializeSessionMessages([{ + id: 'preview-4', + role: 'assistant', + text: '正在签发', + attachments: [], + pendingApplicationPreviewDecision: pending + }]) + assert.equal( + serialized[0].pendingApplicationPreviewDecision.decisionIssueRequestId, + 'preview:recover' + ) + + const restored = normalizeSnapshotMessage(serialized[0]) + assert.equal(restored.applicationPreview.decisionIssueRequestId, 'preview:recover') + assert.equal(restored.applicationPreview.decisionActionRequestId, 'action:recover') + assert.equal(restored.applicationPreview.decisionTrackingStatus, 'registration_failed') +}) + +function createSubmitConfirm({ message, runApplicationPreviewAction, submitComposer }) { + const applicationSubmitConfirmDialog = { value: { open: true, message } } + const reviewActionBusy = { value: false } + const toasts = [] + const api = useTravelReimbursementApplicationSubmitConfirm({ + activeSessionType: { value: 'application' }, + applicationSubmitConfirmDialog, + buildStewardFieldItems: () => [], + createMessage: (role, text, _attachments, extras = {}) => ({ role, text, ...extras }), + emitDraftSaved: () => {}, + formatStewardMissingFieldList: () => '', + formatStewardOntologyFields: () => '', + linkedRequest: { value: null }, + messages: { value: [] }, + nextTick: (callback) => callback?.(), + persistSessionState: () => {}, + reviewActionBusy, + runApplicationPreviewAction, + scrollToBottom: () => {}, + submitComposer, + submitting: { value: false }, + toast: (text) => toasts.push(text) + }) + return { api, toasts } +} + +test('structured preview 只走认证 action;未签发失败时也不降级到 Orchestrator', async () => { + let actionCalls = 0 + let orchestratorCalls = 0 + const unsignedMessage = { + applicationPreview: createPreview({ + decisionTrackingStatus: 'registration_failed', + decisionId: '' + }) + } + const { api, toasts } = createSubmitConfirm({ + message: unsignedMessage, + runApplicationPreviewAction: async () => { + actionCalls += 1 + throw new Error('申请预览尚未通过服务端签发') + }, + submitComposer: async () => { + orchestratorCalls += 1 + return null + } + }) + + await api.confirmApplicationSubmit() + assert.equal(actionCalls, 1) + assert.equal(orchestratorCalls, 0) + assert.match(toasts[0], /尚未通过服务端签发/) +}) + +test('旧消息没有 structured preview 时保留 Orchestrator fallback', async () => { + let actionCalls = 0 + let orchestratorCalls = 0 + const { api } = createSubmitConfirm({ + message: { text: '旧版确认消息' }, + runApplicationPreviewAction: async () => { + actionCalls += 1 + return null + }, + submitComposer: async () => { + orchestratorCalls += 1 + return { status: 'succeeded', result: {} } + } + }) + + await api.confirmApplicationSubmit() + assert.equal(actionCalls, 0) + assert.equal(orchestratorCalls, 1) +}) diff --git a/web/tests/travel-reimbursement-conversation-preview-restore.test.mjs b/web/tests/travel-reimbursement-conversation-preview-restore.test.mjs new file mode 100644 index 0000000..b079b69 --- /dev/null +++ b/web/tests/travel-reimbursement-conversation-preview-restore.test.mjs @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + normalizeInitialConversationMessages +} from '../src/views/scripts/travelReimbursementConversationStateModel.js' + +test('Orchestrator 会话恢复 assistant 的 application preview,并保留既有结果字段', () => { + const applicationPreview = { + decision_id: 'decision-session-1', + fields: { + applicationType: '差旅费用申请', + location: '上海', + reason: '客户现场实施' + } + } + const [message] = normalizeInitialConversationMessages({ + messages: [{ + id: 'assistant-message-1', + role: 'assistant', + content: '申请预览已生成', + created_at: '2026-07-14T08:00:00Z', + message_json: { + orchestrator_payload: { + result: { + application_preview: applicationPreview, + draft_payload: { claim_id: 'claim-draft-1' }, + review_payload: { summary: '复核通过' }, + risk_flags: [{ code: 'policy-limit' }] + } + } + } + }] + }) + + assert.deepEqual(message.applicationPreview, applicationPreview) + assert.deepEqual(message.draftPayload, { claim_id: 'claim-draft-1' }) + assert.deepEqual(message.reviewPayload, { summary: '复核通过' }) + assert.deepEqual(message.riskFlags, [{ code: 'policy-limit' }]) +}) + +test('user 消息不能从 payload 恢复 application preview', () => { + const [message] = normalizeInitialConversationMessages({ + messages: [{ + id: 'user-message-1', + role: 'user', + content: '保存草稿', + message_json: { + orchestrator_payload: { + result: { + application_preview: { fields: { reason: '伪造预览' } } + } + } + } + }] + }) + + assert.equal(message.applicationPreview, null) +}) diff --git a/web/tests/workbench-ai-action-router.test.mjs b/web/tests/workbench-ai-action-router.test.mjs index 4e22669..bef30e4 100644 --- a/web/tests/workbench-ai-action-router.test.mjs +++ b/web/tests/workbench-ai-action-router.test.mjs @@ -305,148 +305,52 @@ test('workbench standalone draft action asks before creating a new reimbursement }) }) -test('workbench steward executable submit action runs precheck before submit and writes result message', async () => { - const requests = [] - const originalFetch = globalThis.fetch - globalThis.fetch = async (_url, options = {}) => { - const body = JSON.parse(String(options.body || '{}')) - requests.push(body) - if (body.action_type === 'run_duplicate_precheck') { - return { - ok: true, - async json() { - return { - action_type: 'run_duplicate_precheck', - status: 'succeeded', - message: '未发现重复或冲突申请,可以继续提交。', - result_payload: { - status: 'ok', - blocking: false - } - } - } - } - } - return { - ok: true, - async json() { - return { - action_type: 'submit_application', - status: 'succeeded', - message: '申请已提交审批。', - result_payload: { - draft_payload: { - claim_id: 'claim-app-1', - claim_no: 'A1BCDEF2' - } - } - } - } - } - } +test('workbench steward application side effect opens signed preview before submit', () => { + let previewPayload = null + const router = useWorkbenchAiActionRouter({ + aiExpenseDraft: { value: null }, + applicationFlow: { + isInlineSuggestedActionDisabled: () => false, + executeInlineApplicationPreviewAction: () => {} + }, + assistantDraft: { value: '' }, + attachmentFlow: { confirmAiAttachmentAssociation: () => {} }, + emit: () => {}, + expenseFlow: { + linkAiExpenseApplication: () => {}, + pushInlineExpenseSceneSelectionPrompt: () => {}, + startAiApplicationPreviewFromAction: (payload) => { + previewPayload = payload + }, + startAiExpenseDraft: () => {} + }, + focusAiModeInput: () => {}, + hasInlineAttachmentOcrDetails: () => false, + resolveLatestInlineUserPrompt: () => '', + selectedFiles: { value: [] }, + startInlineConversation: () => {}, + toast: () => {}, + toggleInlineAttachmentOcrDetails: () => {} + }) - try { - const messages = [] - let messageSeq = 0 - const createInlineMessage = (role, content, options = {}) => ({ - id: options.id || `msg-${++messageSeq}`, - role, - content, - pending: Boolean(options.pending), - suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : [] - }) - const replaceInlineMessage = (id, nextMessage) => { - const index = messages.findIndex((item) => item.id === id) - if (index >= 0) { - messages.splice(index, 1, nextMessage) - } - } - let persisted = false - const router = useWorkbenchAiActionRouter({ - aiExpenseDraft: { value: null }, - applicationFlow: { - isInlineSuggestedActionDisabled: () => false, - executeInlineApplicationPreviewAction: () => {} - }, - assistantDraft: { value: '' }, - attachmentFlow: { - confirmAiAttachmentAssociation: () => {} - }, - conversationMessages: { value: messages }, - createInlineMessage, - emit: () => {}, - expenseFlow: { - linkAiExpenseApplication: () => {}, - pushInlineExpenseSceneSelectionPrompt: () => {}, - startAiApplicationPreviewFromAction: () => {}, - startAiExpenseDraft: () => {} - }, - focusAiModeInput: () => {}, - hasInlineAttachmentOcrDetails: () => false, - persistCurrentConversation: () => { - persisted = true - }, - replaceInlineMessage, - resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交', - scrollInlineConversationToBottom: () => {}, - selectedFiles: { value: [] }, - startInlineConversation: () => {}, - toast: () => {}, - toggleInlineAttachmentOcrDetails: () => {} - }) - const sourceMessage = { - suggestedActionsLocked: false + router.handleInlineSuggestedAction({ + label: '确认提交申请', + action_type: 'switch_session', + payload: { + steward_execute_action: true, + steward_action_type: 'submit_application', + steward_current_task: { + task_id: 'task-app-1', + task_type: 'expense_application', + requested_action: 'submit' + }, + carry_text: '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交' } + }) - await router.handleInlineSuggestedAction({ - label: '确认提交申请', - action_type: 'switch_session', - payload: { - steward_execute_action: true, - steward_plan_id: 'plan-submit-1', - steward_action_type: 'submit_application', - steward_action_requires_confirmation: true, - steward_action_step: { - step_id: 'task-app-1:05', - action_type: 'submit_application', - requires_confirmation: true - }, - steward_current_task: { - task_id: 'task-app-1', - task_type: 'expense_application', - assigned_agent: 'application_assistant', - title: '上海出差申请', - summary: '2026-02-20 至 2026-02-23 去上海出差,交通火车。', - requested_action: 'submit', - ontology_fields: { - expense_type: 'travel', - time_range: '2026-02-20 至 2026-02-23', - location: '上海', - reason: '辅助国网仿生产服务器部署', - transport_mode: 'train' - }, - missing_fields: [], - action_steps: [ - { step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck' }, - { step_id: 'task-app-1:05', action_type: 'submit_application', requires_confirmation: true } - ] - }, - carry_text: '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交' - } - }, sourceMessage) - - assert.equal(requests.length, 2) - assert.equal(requests[0].action_type, 'run_duplicate_precheck') - assert.equal(requests[1].action_type, 'submit_application') - assert.equal(requests[1].confirmed, true) - assert.equal(requests[1].context_json.precheck_result.status, 'ok') - assert.equal(sourceMessage.suggestedActionsLocked, true) - assert.equal(persisted, true) - assert.match(messages.at(-1).content, /申请已提交审批/) - assert.equal(messages.at(-1).suggestedActions[0].action_type, 'open_application_detail') - } finally { - globalThis.fetch = originalFetch - } + assert.equal(previewPayload?.steward_action_type, 'submit_application') + assert.equal(previewPayload?.steward_current_task?.task_type, 'expense_application') + assert.match(previewPayload?.carry_text || '', /直接提交/) }) test('workbench steward continue-next-task reimbursement prefills ontology and forwards remaining tasks', () => {