feat(ai): unify verified expense application workflow
This commit is contained in:
@@ -253,6 +253,8 @@
|
|||||||
- API 新字段优先追加,不在同一阶段破坏现有前端契约。
|
- API 新字段优先追加,不在同一阶段破坏现有前端契约。
|
||||||
- 从未成功签发 `decision_id` 的旧申请预览,或签发接口失败且服务端没有当前会话有效决策时,保存/提交继续走现有 `client_observed` 路径;当前会话一旦存在有效服务端决策,省略 `decision_id` 必须拒绝,不能主动降级绕过核验。兼容路径不得在用户已经编辑后把最终值反向登记成“原始建议”。
|
- 从未成功签发 `decision_id` 的旧申请预览,或签发接口失败且服务端没有当前会话有效决策时,保存/提交继续走现有 `client_observed` 路径;当前会话一旦存在有效服务端决策,省略 `decision_id` 必须拒绝,不能主动降级绕过核验。兼容路径不得在用户已经编辑后把最终值反向登记成“原始建议”。
|
||||||
- 新预览使用 30 分钟有效的服务端 `decision_id`。保存草稿或提交成功后一次消费;保存草稿会尽力签发基于已保存事实的新 `decision_id`,供后续提交继续核验,但续签属于业务提交后的派生能力,续签失败不能把已成功保存返回为失败。相同动作请求 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 或修复。
|
- 迁移桥接期集中维护 migration-owned 表集合;所有 legacy bootstrap 只能创建集合之外的表。标准启动在 `alembic upgrade head` 前只读核对 revision 与自有表集合,发现漂移时拒绝启动,不自动猜测、stamp 或修复。
|
||||||
- 所有表结构通过 Alembic 迁移,不继续在请求路径执行 DDL。
|
- 所有表结构通过 Alembic 迁移,不继续在请求路径执行 DDL。
|
||||||
|
|
||||||
@@ -272,6 +274,7 @@
|
|||||||
- P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。
|
- P0 数据契约即引入最小 `tenant_id`、数据库约束、行级过滤、向量库命名空间和对象存储前缀隔离;删除传播、数据导出和私有部署加固可在商业化阶段继续完善。
|
||||||
- Expense Case 用户态查询只返回流程摘要:Case 基础状态、关系类型、事件类型、操作人、发生时间及白名单业务载荷;不返回关联资源 ID、幂等键、correlation、causation、聚合标识或 Outbox 投递状态。有权查看入口单据的本人、当前审批人、财务和管理员可查看整 Case 的安全摘要,无权限与跨租户查询继续以 404 隐藏资源存在性。
|
- Expense Case 用户态查询只返回流程摘要:Case 基础状态、关系类型、事件类型、操作人、发生时间及白名单业务载荷;不返回关联资源 ID、幂等键、correlation、causation、聚合标识或 Outbox 投递状态。有权查看入口单据的本人、当前审批人、财务和管理员可查看整 Case 的安全摘要,无权限与跨租户查询继续以 404 隐藏资源存在性。
|
||||||
- 申请预览签发与消费同时绑定 `tenant_id`、actor、Bearer 登录会话和会话 ID;动作入口按这些服务端事实锁行校验,不能只凭随机 UUID 授权。浏览器提供的模型来源、`finalValue`、用户和角色声明均不作为可信事实。
|
- 申请预览签发与消费同时绑定 `tenant_id`、actor、Bearer 登录会话和会话 ID;动作入口按这些服务端事实锁行校验,不能只凭随机 UUID 授权。浏览器提供的模型来源、`finalValue`、用户和角色声明均不作为可信事实。
|
||||||
|
- 外部 `/orchestrator/run` 的用户消息、定时任务和系统事件都必须携带有效登录会话,不能由请求体中的 `source` 自行声明可信内部来源;其中 `schedule` / `system_event` 只允许平台管理员触发。服务端使用登录态覆盖用户、租户、角色、管理员、员工、审批人和调度操作人别名,并移除客户端 preview/decision 状态。最近会话查询与删除只使用当前登录用户和租户,查询参数中的 `user_id` 仅保留协议兼容,不参与授权。
|
||||||
- 预览与学习账本中的字段值使用独立、可版本化的费用申请密钥计算 HMAC-SHA256 指纹,密钥目录和文件权限分别为 `0700`、`0600`;核验旧决策必须使用其签发版本,缺失版本直接拒绝,不静默生成替代密钥。字段指纹同时编码“字段是否存在”,避免新增或删除字段被误判为采纳;训练资格仍保持关闭,直至审批、付款或人工核验闭环完成。
|
- 预览与学习账本中的字段值使用独立、可版本化的费用申请密钥计算 HMAC-SHA256 指纹,密钥目录和文件权限分别为 `0700`、`0600`;核验旧决策必须使用其签发版本,缺失版本直接拒绝,不静默生成替代密钥。字段指纹同时编码“字段是否存在”,避免新增或删除字段被误判为采纳;训练资格仍保持关闭,直至审批、付款或人工核验闭环完成。
|
||||||
- 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。
|
- 自动化权限按动作、金额、场景、风险和有效期授予,不使用全局“允许 Agent 自动执行”开关。
|
||||||
- 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。
|
- 收款账户变更、资金支付、制度发布、高风险驳回和敏感主数据变更执行双人或更高等级复核。
|
||||||
@@ -458,6 +461,7 @@ docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv \
|
|||||||
- 历史证据边界:旧单快照只表达回填时可确认的当前状态,迁移前逐节点办理过程仍以原单据和既有审计为准;后续分析不得把快照事件误当成历史审批事实。
|
- 历史证据边界:旧单快照只表达回填时可确认的当前状态,迁移前逐节点办理过程仍以原单据和既有审计为准;后续分析不得把快照事件误当成历史审批事实。
|
||||||
- 认证和租户:当前客户端身份头不适合自动化和 SaaS,多租户、记忆和高风险动作开发前必须修复。
|
- 认证和租户:当前客户端身份头不适合自动化和 SaaS,多租户、记忆和高风险动作开发前必须修复。
|
||||||
- 会话运维:不透明会话已经替代客户端身份头,但仍需补充定时清理、活跃会话查看/全部退出、密钥轮换策略、登录限流和企业 SSO;当前 `tenant_id` 仍是最小契约,不代表跨租户查询守卫已经完成。
|
- 会话运维:不透明会话已经替代客户端身份头,但仍需补充定时清理、活跃会话查看/全部退出、密钥轮换策略、登录限流和企业 SSO;当前 `tenant_id` 仍是最小契约,不代表跨租户查询守卫已经完成。
|
||||||
|
- Agent 会话租户边界:Orchestrator 与 Steward 动作运行时都把可信 `tenant_id` 写入会话状态;创建、恢复、幂等检查点重放、单条删除和批量删除同时校验租户与用户名,同名用户不能跨租户复用 decision 或动作结果。历史无租户状态的会话在认证入口 fail-closed,不会被恢复或删除。`agent_conversations` 仍缺少独立 `tenant_id` 列和数据库复合约束,后续迁移需要把当前应用层守卫下沉为结构化租户键。
|
||||||
- 费用事件读取边界:用户态精简 DTO 和首批 HTTP 权限测试已完成;剩余风险是同一 URL 若已有外部客户端依赖旧内部字段会产生契约变更,且未来新增敏感 payload 字段必须继续显式进入白名单,不能恢复任意字典透传。
|
- 费用事件读取边界:用户态精简 DTO 和首批 HTTP 权限测试已完成;剩余风险是同一 URL 若已有外部客户端依赖旧内部字段会产生契约变更,且未来新增敏感 payload 字段必须继续显式进入白名单,不能恢复任意字典透传。
|
||||||
- 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。
|
- 反馈投毒:一次点击或违规习惯不能直接成为记忆,需要候选态、最小样本、制度约束和结果权重。
|
||||||
- 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 shadow、Canary、抽检和回滚。
|
- 自动化失控:高准确率不代表高风险动作可以无人值守,必须按动作授权并支持 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(服务端预览决策):新增认证的 `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(消费与续签):快速保存/提交显式携带动作类型、`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(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 句柄竞争,相关套件改为串行后全部通过,未修改规则工作簿。
|
||||||
|
|||||||
@@ -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 数据整体回滚。
|
证据:`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 申请预览快速入口的用户、租户、角色与管理员身份强制绑定到服务端会话,拒绝请求体伪造身份编辑他人申请。
|
- [x] [CONCEPT: 权限与安全] 将 AI 申请预览快速入口的用户、租户、角色与管理员身份强制绑定到服务端会话,拒绝请求体伪造身份编辑他人申请。
|
||||||
证据:`reimbursements.py`、`test_reimbursement_endpoints.py`;对抗用例修复前返回 200,修复后返回 400,且目标申请和费用事件保持不变。
|
证据:`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 表。
|
- [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 通过。
|
证据:`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` 提供显式、幂等且不伪造办理历史的费用事件快照回填。
|
- [x] [CONCEPT: 兼容策略] 为迁移前已有 `ExpenseClaim` 提供显式、幂等且不伪造办理历史的费用事件快照回填。
|
||||||
@@ -117,7 +119,8 @@
|
|||||||
- [ ] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。
|
- [ ] [CONCEPT: 记忆激活] 实现用户、部门、企业记忆优先级、冲突解释、时间衰减和最小样本要求。
|
||||||
- [x] [CONCEPT: 记忆与学习] 为 AI 申请预填记录用户原样采纳、显式字段修改和草稿/提交结果证据。
|
- [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`,尚不直接训练模型或激活记忆。
|
证据:`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: 记忆与学习] 从字段接受/修改/拒绝、退回、审批覆盖、付款和审计结果生成记忆证据。
|
||||||
- [ ] [CONCEPT: 记忆与学习] 将已确认 few-shot 扩展到报销预审和审批辅助,并按租户、场景、制度版本过滤。
|
- [ ] [CONCEPT: 记忆与学习] 将已确认 few-shot 扩展到报销预审和审批辅助,并按租户、场景、制度版本过滤。
|
||||||
- [ ] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚。
|
- [ ] [CONCEPT: 风险与预审] 完成 golden case、Prompt/规则版本、Canary、回归门禁和自动回滚。
|
||||||
@@ -187,6 +190,8 @@
|
|||||||
- [ ] [CONCEPT: 测试方案] 补充其余前端组件、键盘操作、移动真实接口和完整浏览器关键流程验证。
|
- [ ] [CONCEPT: 测试方案] 补充其余前端组件、键盘操作、移动真实接口和完整浏览器关键流程验证。
|
||||||
- [x] [CONCEPT: 测试方案] 验证服务端预览决策签发、字段新增/改值差异判定、跨会话拒绝、无 ID 降级防绕过、安全重放、独立密钥权限、续签失败容错、旧路径兼容、会话恢复和真实 PostgreSQL 迁移。
|
- [x] [CONCEPT: 测试方案] 验证服务端预览决策签发、字段新增/改值差异判定、跨会话拒绝、无 ID 降级防绕过、安全重放、独立密钥权限、续签失败容错、旧路径兼容、会话恢复和真实 PostgreSQL 迁移。
|
||||||
证据:容器内新增决策安全用例 9 项、旧快速保存/提交 4 项、申请学习账本 9 项、迁移与所有权 26 项通过且条件型 PostgreSQL 用例 1 项跳过;一次性 PostgreSQL 17 迁移 4 项、前端定向 3 组及 Vite 生产构建已通过。临时 PostgreSQL 已清理,持久开发库 8 张 migration-owned 表数量仍为 0。
|
证据:容器内新增决策安全用例 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: 测试方案] 所有后端、集成和迁移测试在当前主应用容器内执行,单条命令最大超时 60s。
|
||||||
- [ ] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据。
|
- [ ] [CONCEPT: 指标与验收] 记录测试、lint、typecheck、构建、端到端和未覆盖风险证据。
|
||||||
|
|
||||||
|
|||||||
@@ -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,重复签发、过期和失败均有确定的安全处理。
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
@@ -11,24 +10,18 @@ from app.schemas.expense_application_preview import (
|
|||||||
ExpenseApplicationPreviewDecisionCreate,
|
ExpenseApplicationPreviewDecisionCreate,
|
||||||
ExpenseApplicationPreviewDecisionRead,
|
ExpenseApplicationPreviewDecisionRead,
|
||||||
)
|
)
|
||||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
|
||||||
from app.schemas.reimbursement import (
|
from app.schemas.reimbursement import (
|
||||||
ExpenseApplicationPreviewActionPayload,
|
ExpenseApplicationPreviewActionPayload,
|
||||||
ExpenseApplicationPreviewActionResponse,
|
ExpenseApplicationPreviewActionResponse,
|
||||||
ExpenseApplicationPreviewActionResult,
|
|
||||||
)
|
)
|
||||||
from app.schemas.user_agent import UserAgentRequest
|
|
||||||
from app.services.expense_application_preview_decisions import (
|
from app.services.expense_application_preview_decisions import (
|
||||||
ExpenseApplicationPreviewDecisionService,
|
|
||||||
PreviewDecisionConflictError,
|
PreviewDecisionConflictError,
|
||||||
)
|
)
|
||||||
from app.services.expense_application_request_context import (
|
from app.services.expense_application_preview_workflow import (
|
||||||
build_trusted_expense_application_context,
|
ExpenseApplicationPreviewWorkflow,
|
||||||
)
|
)
|
||||||
from app.services.user_agent import UserAgentService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/reimbursements")
|
router = APIRouter(prefix="/reimbursements")
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||||
|
|
||||||
@@ -48,48 +41,13 @@ def issue_expense_application_preview_decision(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
) -> ExpenseApplicationPreviewDecisionRead:
|
) -> 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:
|
try:
|
||||||
facts = UserAgentService(db)._resolve_expense_application_facts(request)
|
return ExpenseApplicationPreviewWorkflow(db).issue(payload, current_user)
|
||||||
issued = ExpenseApplicationPreviewDecisionService(db).issue(
|
|
||||||
facts,
|
|
||||||
current_user,
|
|
||||||
conversation_id=payload.conversation_id or "",
|
|
||||||
request_id=payload.request_id,
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(issued.decision)
|
|
||||||
except PreviewDecisionConflictError as error:
|
except PreviewDecisionConflictError as error:
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|
||||||
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(
|
@router.post(
|
||||||
"/application-preview-action",
|
"/application-preview-action",
|
||||||
@@ -104,127 +62,9 @@ def run_application_preview_action(
|
|||||||
db: DbSession,
|
db: DbSession,
|
||||||
current_user: CurrentUser,
|
current_user: CurrentUser,
|
||||||
) -> ExpenseApplicationPreviewActionResponse:
|
) -> 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:
|
try:
|
||||||
user_agent_service = UserAgentService(db)
|
return ExpenseApplicationPreviewWorkflow(db).execute(payload, current_user)
|
||||||
facts = user_agent_service._resolve_expense_application_facts(request)
|
|
||||||
resolved_step = user_agent_service._resolve_expense_application_step(request, facts)
|
|
||||||
resolved_action = "save_draft" if resolved_step == "draft" else "submit"
|
|
||||||
if payload.action_type and payload.action_type != resolved_action:
|
|
||||||
raise ValueError("动作类型与申请内容不一致。")
|
|
||||||
preview_decision_service = ExpenseApplicationPreviewDecisionService(db)
|
|
||||||
preview_decision = None
|
|
||||||
if payload.decision_id:
|
|
||||||
preview_decision = preview_decision_service.require_for_action(
|
|
||||||
payload.decision_id,
|
|
||||||
current_user,
|
|
||||||
conversation_id=payload.conversation_id or "",
|
|
||||||
request_id=payload.request_id or "",
|
|
||||||
action_type=resolved_action,
|
|
||||||
final_values=facts,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
preview_decision_service.reject_active_decision_bypass(
|
|
||||||
current_user,
|
|
||||||
conversation_id=payload.conversation_id or "",
|
|
||||||
)
|
|
||||||
consumed_preview_decision_id = preview_decision.id if preview_decision is not None else ""
|
|
||||||
user_agent_response = user_agent_service._build_expense_application_response(
|
|
||||||
request,
|
|
||||||
risk_flags=[],
|
|
||||||
learning_current_user=current_user,
|
|
||||||
learning_preview_decision=preview_decision,
|
|
||||||
learning_action_request_id=payload.request_id or "",
|
|
||||||
)
|
|
||||||
except PreviewDecisionConflictError as error:
|
except PreviewDecisionConflictError as error:
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
|
||||||
|
|
||||||
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
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ from typing import Annotated
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy.orm import Session
|
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.common import ErrorResponse
|
||||||
from app.schemas.orchestrator import (
|
from app.schemas.orchestrator import (
|
||||||
ConversationDeleteResponse,
|
ConversationDeleteResponse,
|
||||||
@@ -18,6 +23,11 @@ from app.services.orchestrator import OrchestratorService
|
|||||||
|
|
||||||
router = APIRouter(prefix="/orchestrator")
|
router = APIRouter(prefix="/orchestrator")
|
||||||
DbSession = Annotated[Session, Depends(get_db)]
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
|
||||||
|
OptionalCurrentUser = Annotated[
|
||||||
|
CurrentUserContext | None,
|
||||||
|
Depends(get_optional_current_user),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@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:
|
try:
|
||||||
return OrchestratorService(db).run(payload)
|
return OrchestratorService(db).run(payload, current_user=current_user)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from 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="返回当前用户最近一段可恢复的对话会话及完整消息历史。",
|
description="返回当前用户最近一段可恢复的对话会话及完整消息历史。",
|
||||||
)
|
)
|
||||||
def get_latest_conversation(
|
def get_latest_conversation(
|
||||||
user_id: Annotated[str, Query(min_length=1, description="当前用户 ID。")],
|
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
session_type: Annotated[str | None, Query(description="会话类型,例如 expense / knowledge。")] = None,
|
current_user: CurrentUser,
|
||||||
prefer_recoverable: Annotated[bool, Query(description="是否优先返回最近一条可恢复的会话。")] = False,
|
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:
|
) -> ConversationLookupResponse:
|
||||||
service = AgentConversationService(db)
|
service = AgentConversationService(db)
|
||||||
conversation = service.get_latest_conversation_for_user(
|
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",
|
source="user_message",
|
||||||
session_type=session_type,
|
session_type=session_type,
|
||||||
prefer_recoverable=prefer_recoverable,
|
prefer_recoverable=prefer_recoverable,
|
||||||
@@ -75,12 +111,18 @@ def get_latest_conversation(
|
|||||||
)
|
)
|
||||||
def delete_single_conversation(
|
def delete_single_conversation(
|
||||||
conversation_id: str,
|
conversation_id: str,
|
||||||
user_id: Annotated[str, Query(min_length=1, description="当前用户 ID。")],
|
|
||||||
db: DbSession,
|
db: DbSession,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
user_id: Annotated[
|
||||||
|
str | None,
|
||||||
|
Query(description="兼容旧客户端;服务端始终使用当前登录用户。"),
|
||||||
|
] = None,
|
||||||
) -> ConversationDeleteResponse:
|
) -> ConversationDeleteResponse:
|
||||||
|
del user_id
|
||||||
deleted_count = AgentConversationService(db).delete_conversation(
|
deleted_count = AgentConversationService(db).delete_conversation(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
user_id=user_id,
|
user_id=current_user.username,
|
||||||
|
tenant_id=current_user.tenant_id,
|
||||||
source="user_message",
|
source="user_message",
|
||||||
)
|
)
|
||||||
return ConversationDeleteResponse(deleted_count=deleted_count)
|
return ConversationDeleteResponse(deleted_count=deleted_count)
|
||||||
@@ -93,12 +135,20 @@ def delete_single_conversation(
|
|||||||
description="删除当前用户在智能体工作台中的全部历史会话,用于显式开启全新对话。",
|
description="删除当前用户在智能体工作台中的全部历史会话,用于显式开启全新对话。",
|
||||||
)
|
)
|
||||||
def delete_user_conversations(
|
def delete_user_conversations(
|
||||||
user_id: Annotated[str, Query(min_length=1, description="当前用户 ID。")],
|
|
||||||
db: DbSession,
|
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:
|
) -> ConversationDeleteResponse:
|
||||||
deleted_count = AgentConversationService(db).delete_user_conversations(
|
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",
|
source="user_message",
|
||||||
session_type=session_type,
|
session_type=session_type,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -160,7 +160,16 @@ class StewardActionExecuteRequest(BaseModel):
|
|||||||
action_step: StewardActionStep | None = Field(default=None, description="规划侧生成的动作步骤快照。")
|
action_step: StewardActionStep | None = Field(default=None, description="规划侧生成的动作步骤快照。")
|
||||||
confirmed: bool = Field(default=False, description="用户是否已确认执行该动作。")
|
confirmed: bool = Field(default=False, description="用户是否已确认执行该动作。")
|
||||||
context_json: dict[str, Any] = Field(default_factory=dict, 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):
|
class StewardActionExecuteResponse(BaseModel):
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.models.agent_conversation import AgentConversation, AgentConversationMe
|
|||||||
from app.services.settings import SettingsService
|
from app.services.settings import SettingsService
|
||||||
|
|
||||||
STATEFUL_CONTEXT_KEYS = (
|
STATEFUL_CONTEXT_KEYS = (
|
||||||
|
"tenant_id",
|
||||||
"session_type",
|
"session_type",
|
||||||
"entry_source",
|
"entry_source",
|
||||||
"request_context",
|
"request_context",
|
||||||
@@ -21,6 +22,7 @@ STATEFUL_CONTEXT_KEYS = (
|
|||||||
"review_form_values",
|
"review_form_values",
|
||||||
"steward_state",
|
"steward_state",
|
||||||
"business_time_context",
|
"business_time_context",
|
||||||
|
"application_preview_decision",
|
||||||
)
|
)
|
||||||
REVIEW_FLOW_CONTEXT_KEYS = {
|
REVIEW_FLOW_CONTEXT_KEYS = {
|
||||||
"draft_claim_id",
|
"draft_claim_id",
|
||||||
@@ -83,12 +85,18 @@ class AgentConversationService:
|
|||||||
|
|
||||||
normalized_id = str(conversation_id or "").strip()
|
normalized_id = str(conversation_id or "").strip()
|
||||||
normalized_user_id = str(user_id or "").strip() or None
|
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_session_type = str(context_json.get("session_type") or "").strip() or "expense"
|
||||||
incoming_draft_claim_id = self._resolve_draft_claim_id(context_json)
|
incoming_draft_claim_id = self._resolve_draft_claim_id(context_json)
|
||||||
conversation = self.get_conversation(normalized_id) if normalized_id else None
|
conversation = self.get_conversation(normalized_id) if normalized_id else None
|
||||||
if conversation is not None and conversation.user_id != normalized_user_id:
|
if conversation is not None and conversation.user_id != normalized_user_id:
|
||||||
normalized_id = ""
|
normalized_id = ""
|
||||||
conversation = None
|
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:
|
if conversation is not None:
|
||||||
existing_session_type = str((conversation.state_json or {}).get("session_type") or "").strip() or "expense"
|
existing_session_type = str((conversation.state_json or {}).get("session_type") or "").strip() or "expense"
|
||||||
if existing_session_type != incoming_session_type:
|
if existing_session_type != incoming_session_type:
|
||||||
@@ -189,6 +197,7 @@ class AgentConversationService:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
tenant_id: str | None = None,
|
||||||
source: str | None = "user_message",
|
source: str | None = "user_message",
|
||||||
session_type: str | None = None,
|
session_type: str | None = None,
|
||||||
prefer_recoverable: bool = False,
|
prefer_recoverable: bool = False,
|
||||||
@@ -204,6 +213,13 @@ class AgentConversationService:
|
|||||||
stmt = stmt.where(AgentConversation.source == source)
|
stmt = stmt.where(AgentConversation.source == source)
|
||||||
stmt = stmt.order_by(AgentConversation.updated_at.desc(), AgentConversation.created_at.desc())
|
stmt = stmt.order_by(AgentConversation.updated_at.desc(), AgentConversation.created_at.desc())
|
||||||
conversations = list(self.db.scalars(stmt).all())
|
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()
|
normalized_session_type = str(session_type or "").strip()
|
||||||
if not normalized_session_type:
|
if not normalized_session_type:
|
||||||
return conversations[0] if conversations else None
|
return conversations[0] if conversations else None
|
||||||
@@ -448,6 +464,7 @@ class AgentConversationService:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
user_id: str | None,
|
user_id: str | None,
|
||||||
|
tenant_id: str | None = None,
|
||||||
source: str | None = "user_message",
|
source: str | None = "user_message",
|
||||||
session_type: str | None = None,
|
session_type: str | None = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -459,6 +476,13 @@ class AgentConversationService:
|
|||||||
if source:
|
if source:
|
||||||
stmt = stmt.where(AgentConversation.source == source)
|
stmt = stmt.where(AgentConversation.source == source)
|
||||||
conversations = list(self.db.scalars(stmt).all())
|
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()
|
normalized_session_type = str(session_type or "").strip()
|
||||||
if normalized_session_type:
|
if normalized_session_type:
|
||||||
conversations = [
|
conversations = [
|
||||||
@@ -513,6 +537,7 @@ class AgentConversationService:
|
|||||||
*,
|
*,
|
||||||
conversation_id: str | None,
|
conversation_id: str | None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
|
tenant_id: str | None = None,
|
||||||
source: str | None = "user_message",
|
source: str | None = "user_message",
|
||||||
) -> int:
|
) -> int:
|
||||||
normalized_id = str(conversation_id or "").strip()
|
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:
|
if normalized_user_id and str(conversation.user_id or "").strip() != normalized_user_id:
|
||||||
return 0
|
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()
|
normalized_source = str(source or "").strip()
|
||||||
if normalized_source and str(conversation.source or "").strip() != normalized_source:
|
if normalized_source and str(conversation.source or "").strip() != normalized_source:
|
||||||
return 0
|
return 0
|
||||||
@@ -609,6 +641,14 @@ class AgentConversationService:
|
|||||||
return len(value) == 0
|
return len(value) == 0
|
||||||
return False
|
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
|
@staticmethod
|
||||||
def _resolve_title(context_json: dict[str, Any]) -> str | None:
|
def _resolve_title(context_json: dict[str, Any]) -> str | None:
|
||||||
request_context = context_json.get("request_context")
|
request_context = context_json.get("request_context")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
from app.models.ai_application_preview import AIApplicationPreviewDecision
|
from app.models.ai_application_preview import AIApplicationPreviewDecision
|
||||||
|
from app.models.auth_session import AuthSession
|
||||||
from app.models.expense_case import BusinessEvent
|
from app.models.expense_case import BusinessEvent
|
||||||
from app.models.financial_record import ExpenseClaim
|
from app.models.financial_record import ExpenseClaim
|
||||||
from app.services.expense_application_snapshot import (
|
from app.services.expense_application_snapshot import (
|
||||||
@@ -51,6 +52,13 @@ class ExpenseApplicationPreviewDecisionService:
|
|||||||
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
tenant_id = ExpenseCaseService.normalize_tenant_id(current_user.tenant_id)
|
||||||
actor_id = self._actor_id(current_user)
|
actor_id = self._actor_id(current_user)
|
||||||
auth_session_id = self._auth_session_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)
|
normalized_request_id = self._required_text(request_id, "预览签发请求 ID", 120)
|
||||||
values = safe_fact_snapshot(facts)
|
values = safe_fact_snapshot(facts)
|
||||||
if not values.get("time") and not values.get("location") and not values.get("reason"):
|
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))
|
return IssuedPreviewDecision(existing, preview_response_fields(facts))
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
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(
|
snapshot_fingerprint = hmac_fingerprint(
|
||||||
values,
|
values,
|
||||||
key_version=FINGERPRINT_KEY_VERSION,
|
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(
|
decision = AIApplicationPreviewDecision(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
actor_id=actor_id,
|
actor_id=actor_id,
|
||||||
auth_session_id=auth_session_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_type="expense_application_prefill",
|
||||||
decision_source=source,
|
decision_source=source,
|
||||||
field_keys_json=sorted(values),
|
field_keys_json=sorted(values),
|
||||||
|
|||||||
222
server/src/app/services/expense_application_preview_workflow.py
Normal file
222
server/src/app/services/expense_application_preview_workflow.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
from app.core.agent_enums import (
|
from app.core.agent_enums import (
|
||||||
AgentAssetStatus,
|
AgentAssetStatus,
|
||||||
AgentAssetType,
|
AgentAssetType,
|
||||||
@@ -25,21 +26,24 @@ from app.schemas.orchestrator import (
|
|||||||
from app.schemas.user_agent import UserAgentRequest
|
from app.schemas.user_agent import UserAgentRequest
|
||||||
from app.services.agent_assets import AgentAssetService
|
from app.services.agent_assets import AgentAssetService
|
||||||
from app.services.agent_conversations import AgentConversationService
|
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_foundation import AgentFoundationService
|
||||||
from app.services.agent_runs import AgentRunService
|
from app.services.agent_runs import AgentRunService
|
||||||
from app.services.agent_traces import AgentTraceService
|
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.knowledge import KnowledgeService
|
||||||
from app.services.ontology import SemanticOntologyService
|
from app.services.ontology import SemanticOntologyService
|
||||||
from app.services.orchestrator_execution import ExecutionOutcome, OrchestratorExecutionEngine
|
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.orchestrator_expense_query import OrchestratorDatabaseQueryBuilder
|
||||||
|
from app.services.user_agent import UserAgentService
|
||||||
from app.services.user_agent_application import (
|
from app.services.user_agent_application import (
|
||||||
APPLICATION_CONTEXT_VALUES,
|
APPLICATION_CONTEXT_VALUES,
|
||||||
APPLICATION_SHORT_CONFIRMATIONS,
|
APPLICATION_SHORT_CONFIRMATIONS,
|
||||||
APPLICATION_SUBMIT_KEYWORDS,
|
APPLICATION_SUBMIT_KEYWORDS,
|
||||||
)
|
)
|
||||||
from app.services.user_agent import UserAgentService
|
|
||||||
|
|
||||||
logger = get_logger("app.services.orchestrator")
|
logger = get_logger("app.services.orchestrator")
|
||||||
|
|
||||||
@@ -51,6 +55,7 @@ SCENARIO_TO_DOMAIN = {
|
|||||||
"unknown": "system",
|
"unknown": "system",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class OrchestratorService:
|
class OrchestratorService:
|
||||||
def __init__(self, db: Session) -> None:
|
def __init__(self, db: Session) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
@@ -62,6 +67,7 @@ class OrchestratorService:
|
|||||||
self.trace_service = AgentTraceService(db)
|
self.trace_service = AgentTraceService(db)
|
||||||
self.ontology_service = SemanticOntologyService(db)
|
self.ontology_service = SemanticOntologyService(db)
|
||||||
self.user_agent_service = UserAgentService(db)
|
self.user_agent_service = UserAgentService(db)
|
||||||
|
self.expense_application_workflow = OrchestratorExpenseApplicationWorkflow(db)
|
||||||
self.database_query_builder = OrchestratorDatabaseQueryBuilder(db)
|
self.database_query_builder = OrchestratorDatabaseQueryBuilder(db)
|
||||||
self.execution_engine = OrchestratorExecutionEngine(
|
self.execution_engine = OrchestratorExecutionEngine(
|
||||||
db=db,
|
db=db,
|
||||||
@@ -73,7 +79,14 @@ class OrchestratorService:
|
|||||||
trace_service=self.trace_service,
|
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()
|
AgentFoundationService(self.db).ensure_foundation_ready()
|
||||||
context_json = self._hydrate_user_context(
|
context_json = self._hydrate_user_context(
|
||||||
user_id=payload.user_id,
|
user_id=payload.user_id,
|
||||||
@@ -230,6 +243,24 @@ class OrchestratorService:
|
|||||||
route_json["task_code"] = task_asset.code
|
route_json["task_code"] = task_asset.code
|
||||||
route_json["task_name"] = task_asset.name
|
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:
|
if ontology.permission.level == AgentPermissionLevel.FORBIDDEN.value:
|
||||||
outcome = ExecutionOutcome(
|
outcome = ExecutionOutcome(
|
||||||
status=AgentRunStatus.BLOCKED.value,
|
status=AgentRunStatus.BLOCKED.value,
|
||||||
@@ -246,6 +277,11 @@ class OrchestratorService:
|
|||||||
route_reason = "permission_forbidden"
|
route_reason = "permission_forbidden"
|
||||||
route_json["stage"] = "blocked"
|
route_json["stage"] = "blocked"
|
||||||
route_json["route_reason"] = route_reason
|
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:
|
elif ontology.clarification_required:
|
||||||
if selected_agent == AgentName.USER_AGENT.value and ontology.scenario == "expense":
|
if selected_agent == AgentName.USER_AGENT.value and ontology.scenario == "expense":
|
||||||
clarification_response = self.user_agent_service.respond(
|
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:
|
def _record_trace_event(self, **kwargs: Any) -> None:
|
||||||
self.trace_service.record_event_safe(**kwargs)
|
self.trace_service.record_event_safe(**kwargs)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -6,7 +6,9 @@ from typing import Any
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api.deps import CurrentUserContext
|
from app.api.deps import CurrentUserContext
|
||||||
|
from app.schemas.expense_application_preview import ExpenseApplicationPreviewDecisionCreate
|
||||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
||||||
|
from app.schemas.reimbursement import ExpenseApplicationPreviewActionPayload
|
||||||
from app.schemas.steward import (
|
from app.schemas.steward import (
|
||||||
StewardActionExecuteRequest,
|
StewardActionExecuteRequest,
|
||||||
StewardActionExecuteResponse,
|
StewardActionExecuteResponse,
|
||||||
@@ -14,6 +16,7 @@ from app.schemas.steward import (
|
|||||||
)
|
)
|
||||||
from app.schemas.user_agent import UserAgentRequest
|
from app.schemas.user_agent import UserAgentRequest
|
||||||
from app.services.attachment_association_jobs import AttachmentAssociationJobRunner
|
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.expense_claims import ExpenseClaimService
|
||||||
from app.services.steward_intent_registry import (
|
from app.services.steward_intent_registry import (
|
||||||
all_noop_actions,
|
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"}
|
REIMBURSEMENT_SIDE_EFFECT_ACTIONS = {"create_reimbursement_draft", "link_existing_application", "associate_attachments"}
|
||||||
NOOP_ACTIONS = {
|
NOOP_ACTIONS = {
|
||||||
"fill_application_fields",
|
"fill_application_fields",
|
||||||
"build_application_preview",
|
|
||||||
"fill_reimbursement_fields",
|
"fill_reimbursement_fields",
|
||||||
"build_reimbursement_preview",
|
"build_reimbursement_preview",
|
||||||
"validate_required_fields",
|
"validate_required_fields",
|
||||||
@@ -97,7 +99,8 @@ class StewardActionExecutor:
|
|||||||
)
|
)
|
||||||
|
|
||||||
task = request.task
|
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:
|
if task is None and action_type not in noop_actions:
|
||||||
return self._blocked(
|
return self._blocked(
|
||||||
action_type,
|
action_type,
|
||||||
@@ -132,6 +135,8 @@ class StewardActionExecutor:
|
|||||||
return intent.executor(self, request, current_user, trace)
|
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":
|
if action_type == "run_duplicate_precheck":
|
||||||
return self._run_duplicate_precheck(request, current_user, trace)
|
return self._run_duplicate_precheck(request, current_user, trace)
|
||||||
if action_type in APPLICATION_SIDE_EFFECT_ACTIONS:
|
if action_type in APPLICATION_SIDE_EFFECT_ACTIONS:
|
||||||
@@ -155,10 +160,55 @@ class StewardActionExecutor:
|
|||||||
) -> StewardActionExecuteResponse:
|
) -> StewardActionExecuteResponse:
|
||||||
"""registry 入口:分发申请类副作用动作。"""
|
"""registry 入口:分发申请类副作用动作。"""
|
||||||
action_type = self._normalize_action_type(request.action_type)
|
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":
|
if action_type == "run_duplicate_precheck":
|
||||||
return self._run_duplicate_precheck(request, current_user, trace)
|
return self._run_duplicate_precheck(request, current_user, trace)
|
||||||
return self._execute_application_action(request, current_user, action_type, 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(
|
def _dispatch_reimbursement_action(
|
||||||
self,
|
self,
|
||||||
request: StewardActionExecuteRequest,
|
request: StewardActionExecuteRequest,
|
||||||
@@ -236,43 +286,70 @@ class StewardActionExecutor:
|
|||||||
trace=[*trace, self._trace("blocked", reason="precheck_not_passed")],
|
trace=[*trace, self._trace("blocked", reason="precheck_not_passed")],
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = self._build_application_user_agent_request(
|
decision_id = str(request.decision_id or "").strip()
|
||||||
request,
|
if not decision_id:
|
||||||
current_user,
|
return self._blocked(
|
||||||
action_type=action_type,
|
action_type,
|
||||||
force_submit_message=action_type == "submit_application",
|
"保存或提交申请必须携带服务端签发的 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:
|
try:
|
||||||
user_agent_response = UserAgentService(self.db)._build_expense_application_response(
|
workflow_response = ExpenseApplicationPreviewWorkflow(self.db).execute(
|
||||||
payload,
|
payload,
|
||||||
risk_flags=[],
|
current_user,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return self._failed(action_type, str(exc), trace)
|
return self._failed(action_type, str(exc), trace)
|
||||||
|
|
||||||
draft_payload = (
|
result_payload = workflow_response.result.model_dump(mode="json")
|
||||||
user_agent_response.draft_payload.model_dump(mode="json")
|
draft_payload = result_payload.get("draft_payload")
|
||||||
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,
|
|
||||||
}
|
|
||||||
status = "succeeded" if draft_payload is not None else "blocked"
|
status = "succeeded" if draft_payload is not None else "blocked"
|
||||||
blocked_reasons = [] if draft_payload is not None else ["application_not_persisted"]
|
blocked_reasons = [] if draft_payload is not None else ["application_not_persisted"]
|
||||||
return StewardActionExecuteResponse(
|
return StewardActionExecuteResponse(
|
||||||
action_type=action_type,
|
action_type=action_type,
|
||||||
status=status,
|
status=status,
|
||||||
message=user_agent_response.answer,
|
message=workflow_response.result.message,
|
||||||
blocked_reasons=blocked_reasons,
|
blocked_reasons=blocked_reasons,
|
||||||
result_payload=result_payload,
|
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(
|
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")
|
suffix = task_id or datetime.now(UTC).strftime("%Y%m%d%H%M%S%f")
|
||||||
return f"steward-action:{action_type}:{suffix}"
|
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
|
@staticmethod
|
||||||
def _trace(stage: str, **extra: Any) -> dict[str, Any]:
|
def _trace(stage: str, **extra: Any) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ from app.services.steward_action_executor import StewardActionExecutor
|
|||||||
|
|
||||||
ACTION_CHECKPOINT_KEY = "steward_action_checkpoint"
|
ACTION_CHECKPOINT_KEY = "steward_action_checkpoint"
|
||||||
TERMINAL_ACTION_STATUSES = {"succeeded", "blocked", "failed"}
|
TERMINAL_ACTION_STATUSES = {"succeeded", "blocked", "failed"}
|
||||||
|
DECISION_BOUND_APPLICATION_ACTIONS = {
|
||||||
|
"build_application_preview",
|
||||||
|
"save_application_draft",
|
||||||
|
"submit_application",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class StewardGraphActionState(TypedDict, total=False):
|
class StewardGraphActionState(TypedDict, total=False):
|
||||||
@@ -171,6 +176,7 @@ class StewardGraphActionRuntime:
|
|||||||
user_id=current_user.username,
|
user_id=current_user.username,
|
||||||
source="user_message",
|
source="user_message",
|
||||||
context_json={
|
context_json={
|
||||||
|
"tenant_id": current_user.tenant_id,
|
||||||
"session_type": "steward",
|
"session_type": "steward",
|
||||||
"entry_source": "steward_action_executor",
|
"entry_source": "steward_action_executor",
|
||||||
"steward_state": dict((request.context_json or {}).get("steward_state") or {}),
|
"steward_state": dict((request.context_json or {}).get("steward_state") or {}),
|
||||||
@@ -190,6 +196,9 @@ class StewardGraphActionRuntime:
|
|||||||
if trace_id:
|
if trace_id:
|
||||||
return trace_id
|
return trace_id
|
||||||
action_type = str(request.action_type or "").strip()
|
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()
|
task_id = str(request.task.task_id if request.task is not None else "").strip()
|
||||||
if action_type and task_id:
|
if action_type and task_id:
|
||||||
return f"{action_type}:{task_id}"
|
return f"{action_type}:{task_id}"
|
||||||
|
|||||||
@@ -198,6 +198,21 @@ def test_server_preview_decision_is_consumed_with_verified_feedback() -> None:
|
|||||||
assert len(list(db.scalars(select(AIDecision)).all())) == 1
|
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:
|
def test_server_preview_decision_detects_server_side_field_edit() -> None:
|
||||||
client, session_factory = build_client()
|
client, session_factory = build_client()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
407
server/tests/test_orchestrator_auth_endpoints.py
Normal file
407
server/tests/test_orchestrator_auth_endpoints.py
Normal file
@@ -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",
|
||||||
|
}
|
||||||
@@ -4,13 +4,16 @@ from datetime import UTC, date, datetime
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserContext
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.models.agent_asset import AgentAsset
|
from app.models.agent_asset import AgentAsset
|
||||||
from app.models.agent_run import AgentRun
|
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.employee import Employee
|
||||||
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
|
||||||
from app.schemas.ontology import OntologyParseResult, OntologyPermission
|
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 "当前仅返回确认摘要" not in submitted.result["answer"]
|
||||||
assert "申请单据已生成,并已进入审批流程" in submitted.result["answer"]
|
assert "申请单据已生成,并已进入审批流程" in submitted.result["answer"]
|
||||||
assert submitted.result["draft_payload"]["status"] == "submitted"
|
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"
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from app.api.deps import get_db
|
|||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.models.agent_conversation import AgentConversation
|
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.employee import Employee
|
||||||
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
from app.models.expense_case import BusinessEvent, ExpenseCaseLink
|
||||||
from app.models.financial_record import ExpenseClaim
|
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-username": "zhangsan@example.com",
|
||||||
"x-auth-name": "Zhang San",
|
"x-auth-name": "Zhang San",
|
||||||
"x-auth-employee-no": "E90001",
|
"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-role-codes": "user",
|
||||||
|
"x-auth-department": "Delivery",
|
||||||
"x-auth-position": "Engineer",
|
"x-auth-position": "Engineer",
|
||||||
"x-auth-grade": "P4",
|
"x-auth-grade": "P4",
|
||||||
"x-auth-manager-name": "Leader",
|
"x-auth-manager-name": "Leader",
|
||||||
@@ -130,6 +136,33 @@ def claim_count(db: Session) -> int:
|
|||||||
return len(db.scalars(select(ExpenseClaim)).all())
|
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:
|
def seed_approved_application(db: Session) -> None:
|
||||||
application = ExpenseClaim(
|
application = ExpenseClaim(
|
||||||
id="application-action-approved",
|
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"
|
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:
|
def test_steward_action_executor_reuses_checkpoint_for_duplicate_trace_without_duplicate_draft() -> None:
|
||||||
client, session_factory = build_client()
|
client, session_factory = build_client()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
seed_employee(db)
|
seed_employee(db)
|
||||||
|
|
||||||
|
issued = issue_application_preview(
|
||||||
|
client,
|
||||||
|
conversation_id="conv-action-draft",
|
||||||
|
client_trace_id="trace-build-draft-preview",
|
||||||
|
)
|
||||||
request_payload = {
|
request_payload = {
|
||||||
"action_type": "save_application_draft",
|
"action_type": "save_application_draft",
|
||||||
"message": "2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车,保存草稿",
|
"message": "2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车,保存草稿",
|
||||||
"conversation_id": "conv-action-draft",
|
"conversation_id": "conv-action-draft",
|
||||||
"client_trace_id": "trace-save-draft",
|
"client_trace_id": "trace-save-draft",
|
||||||
|
"decision_id": issued["decision_id"],
|
||||||
"task": base_application_task("save_draft"),
|
"task": base_application_task("save_draft"),
|
||||||
}
|
}
|
||||||
first_response = client.post(
|
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:
|
with session_factory() as db:
|
||||||
seed_employee(db)
|
seed_employee(db)
|
||||||
|
|
||||||
|
issued = issue_application_preview(
|
||||||
|
client,
|
||||||
|
conversation_id="conv-action-save",
|
||||||
|
client_trace_id="trace-build-save-preview",
|
||||||
|
)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/v1/steward/actions/execute",
|
"/api/v1/steward/actions/execute",
|
||||||
headers=auth_headers(),
|
headers=auth_headers(),
|
||||||
json={
|
json={
|
||||||
"action_type": "save_application_draft",
|
"action_type": "save_application_draft",
|
||||||
"message": "2026-02-20 至 2026-02-23,去上海出差,辅助国网仿生产服务器部署,交通火车,保存草稿",
|
"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"),
|
"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["draft_type"] == "expense_application"
|
||||||
assert draft_payload["status"] == "draft"
|
assert draft_payload["status"] == "draft"
|
||||||
assert draft_payload["claim_no"].startswith("A")
|
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:
|
with session_factory() as db:
|
||||||
claim = db.scalars(select(ExpenseClaim)).one()
|
claim = db.scalars(select(ExpenseClaim)).one()
|
||||||
assert claim.status == "draft"
|
assert claim.status == "draft"
|
||||||
assert claim.reason == "辅助国网仿生产服务器部署"
|
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(
|
event = db.scalars(
|
||||||
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
select(BusinessEvent).where(BusinessEvent.aggregate_id == claim.id)
|
||||||
).one()
|
).one()
|
||||||
assert event.event_type == "claim_draft_created"
|
assert event.event_type == "claim_draft_created"
|
||||||
assert event.actor_id == "zhangsan@example.com"
|
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(
|
link = db.scalars(
|
||||||
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
|
select(ExpenseCaseLink).where(ExpenseCaseLink.resource_id == claim.id)
|
||||||
).one()
|
).one()
|
||||||
assert link.relation_type == "application"
|
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:
|
def test_steward_action_executor_creates_reimbursement_draft_from_action_step() -> None:
|
||||||
client, session_factory = build_client()
|
client, session_factory = build_client()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
@@ -1131,6 +1131,52 @@
|
|||||||
margin: 0;
|
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) {
|
.message-answer-markdown :deep(a) {
|
||||||
color: var(--theme-primary-active, #255b7d);
|
color: var(--theme-primary-active, #255b7d);
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
|
|||||||
@@ -246,11 +246,47 @@
|
|||||||
<span class="application-preview-missing-suffix">。补齐后我再帮您提交申请。</span>
|
<span class="application-preview-missing-suffix">。补齐后我再帮您提交申请。</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="ui.buildApplicationPreviewFooterText(message)"
|
v-else-if="ui.canSubmitApplicationPreview(message) && ui.buildApplicationPreviewFooterText(message)"
|
||||||
class="application-preview-footer message-answer-content message-answer-markdown"
|
class="application-preview-footer message-answer-content message-answer-markdown"
|
||||||
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
|
v-html="ui.renderMarkdown(ui.buildApplicationPreviewFooterText(message))"
|
||||||
@click="ui.handleAssistantMarkdownClick($event, message)"
|
@click="ui.handleAssistantMarkdownClick($event, message)"
|
||||||
></div>
|
></div>
|
||||||
|
<div
|
||||||
|
v-else-if="message.applicationPreview.readyToSubmit"
|
||||||
|
class="application-preview-decision-status"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<span v-if="message.applicationPreview.decisionTrackingStatus === 'registering'">
|
||||||
|
正在由服务端核验并签发申请预览,请稍候。
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
当前申请预览尚未通过服务端签发。为避免重复或错误建单,保存与提交已暂停。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="message.applicationPreview.readyToSubmit"
|
||||||
|
class="application-preview-action-row"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="ui.canRetryApplicationPreviewDecision(message)"
|
||||||
|
type="button"
|
||||||
|
class="application-preview-secondary-btn"
|
||||||
|
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
|
||||||
|
@click="ui.retryApplicationPreviewDecision(message)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-refresh"></i>
|
||||||
|
<span>重新签发</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="application-preview-secondary-btn"
|
||||||
|
:disabled="!ui.canSaveApplicationPreviewDraft(message)"
|
||||||
|
@click="ui.saveApplicationPreviewDraft(message)"
|
||||||
|
>
|
||||||
|
<i class="mdi mdi-content-save-outline"></i>
|
||||||
|
<span>保存草稿</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ export function useWorkbenchAiActionRouter({
|
|||||||
|
|
||||||
const actionType = String(action?.action_type || '').trim()
|
const actionType = String(action?.action_type || '').trim()
|
||||||
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
|
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) {
|
if (actionPayload.steward_execute_action) {
|
||||||
return executeInlineStewardAction(action, sourceMessage)
|
return executeInlineStewardAction(action, sourceMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ export function buildAiApplicationPreviewActionPayload({
|
|||||||
currentUser = {},
|
currentUser = {},
|
||||||
conversationId = '',
|
conversationId = '',
|
||||||
draftPayload = null,
|
draftPayload = null,
|
||||||
requestId = ''
|
requestId = '',
|
||||||
|
entrySource = 'workbench_ai_inline',
|
||||||
|
source = 'workbench',
|
||||||
|
requestSource = 'user_message'
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const normalizedPreview = normalizeApplicationPreview(applicationPreview || {})
|
const normalizedPreview = normalizeApplicationPreview(applicationPreview || {})
|
||||||
const message = buildAiApplicationPreviewActionText(actionType, normalizedPreview)
|
const message = buildAiApplicationPreviewActionText(actionType, normalizedPreview)
|
||||||
@@ -128,7 +131,7 @@ export function buildAiApplicationPreviewActionPayload({
|
|||||||
: []
|
: []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: 'user_message',
|
source: normalizeText(requestSource) || 'user_message',
|
||||||
user_id: username,
|
user_id: username,
|
||||||
conversation_id: normalizeText(conversationId) || null,
|
conversation_id: normalizeText(conversationId) || null,
|
||||||
action_type: isSubmit ? 'submit' : 'save_draft',
|
action_type: isSubmit ? 'submit' : 'save_draft',
|
||||||
@@ -158,8 +161,8 @@ export function buildAiApplicationPreviewActionPayload({
|
|||||||
finance_owner_name: resolveUserValue(currentUser, 'financeOwnerName', 'finance_owner_name'),
|
finance_owner_name: resolveUserValue(currentUser, 'financeOwnerName', 'finance_owner_name'),
|
||||||
...buildClientTimeContext(),
|
...buildClientTimeContext(),
|
||||||
session_type: 'application',
|
session_type: 'application',
|
||||||
entry_source: 'workbench_ai_inline',
|
entry_source: normalizeText(entrySource) || 'workbench_ai_inline',
|
||||||
source: 'workbench',
|
source: normalizeText(source) || 'workbench',
|
||||||
document_type: 'expense_application',
|
document_type: 'expense_application',
|
||||||
application_stage: 'expense_application',
|
application_stage: 'expense_application',
|
||||||
user_input_text: message,
|
user_input_text: message,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useTravelReimbursementMessageActions } from './useTravelReimbursementMe
|
|||||||
import { useTravelReimbursementSuggestedActions } from './useTravelReimbursementSuggestedActions.js'
|
import { useTravelReimbursementSuggestedActions } from './useTravelReimbursementSuggestedActions.js'
|
||||||
import { useStewardPlanFlow } from './useStewardPlanFlow.js'
|
import { useStewardPlanFlow } from './useStewardPlanFlow.js'
|
||||||
import { useTravelReimbursementApplicationSubmitConfirm } from './useTravelReimbursementApplicationSubmitConfirm.js'
|
import { useTravelReimbursementApplicationSubmitConfirm } from './useTravelReimbursementApplicationSubmitConfirm.js'
|
||||||
|
import { useTravelReimbursementApplicationPreviewActions } from './useTravelReimbursementApplicationPreviewActions.js'
|
||||||
import { useTravelReimbursementStewardRuntimeDecision } from './useTravelReimbursementStewardRuntimeDecision.js'
|
import { useTravelReimbursementStewardRuntimeDecision } from './useTravelReimbursementStewardRuntimeDecision.js'
|
||||||
import { useTravelReimbursementApplicationPreviewDateEditor } from './useTravelReimbursementApplicationPreviewDateEditor.js'
|
import { useTravelReimbursementApplicationPreviewDateEditor } from './useTravelReimbursementApplicationPreviewDateEditor.js'
|
||||||
import { buildStewardFieldItems, formatStewardMissingFieldList, formatStewardOntologyFields } from './stewardPlanModel.js'
|
import { buildStewardFieldItems, formatStewardMissingFieldList, formatStewardOntologyFields } from './stewardPlanModel.js'
|
||||||
@@ -238,6 +239,27 @@ export default {
|
|||||||
calculateTravelReimbursement,
|
calculateTravelReimbursement,
|
||||||
currentUser
|
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 applyLinkedApplicationPreviewDateSelection = () => false
|
||||||
let openApplicationPreviewEditorFromUi = openApplicationPreviewEditor
|
let openApplicationPreviewEditorFromUi = openApplicationPreviewEditor
|
||||||
@@ -376,7 +398,7 @@ export default {
|
|||||||
resolveCurrentUserId
|
resolveCurrentUserId
|
||||||
})
|
})
|
||||||
const { confirmPendingAttachmentAssociationInternal, submitComposerInternal } = useTravelReimbursementSubmitComposer({
|
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
|
let submitComposerFromMessageHandlers = null
|
||||||
async function submitComposer(options = {}) {
|
async function submitComposer(options = {}) {
|
||||||
@@ -551,7 +573,7 @@ export default {
|
|||||||
|
|
||||||
|
|
||||||
const { closeApplicationSubmitConfirm, confirmApplicationSubmit, openApplicationSubmitConfirm, resolveStewardMissingFieldItems } = useTravelReimbursementApplicationSubmitConfirm({
|
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({
|
const { handleApplicationSubmitConfirmationText, handleStewardRuntimeDecision } = useTravelReimbursementStewardRuntimeDecision({
|
||||||
@@ -568,7 +590,7 @@ export default {
|
|||||||
submitComposerFromMessageHandlers = submitComposerMessageHandler
|
submitComposerFromMessageHandlers = submitComposerMessageHandler
|
||||||
|
|
||||||
const { insightPanelUi, messageItemUi } = useTravelReimbursementCreateViewUi({
|
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 {
|
return {
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ export function normalizeInitialConversationMessages(conversation) {
|
|||||||
queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null,
|
queryPayload: item.role === 'assistant' ? normalizeExpenseQueryPayload(result?.query_payload) : null,
|
||||||
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
|
draftPayload: item.role === 'assistant' ? result?.draft_payload || messageJson?.draft_payload || null : null,
|
||||||
reviewPayload: item.role === 'assistant' ? result?.review_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)
|
return markResolvedSuggestedActionMessages(restoredMessages)
|
||||||
@@ -135,6 +136,15 @@ export function normalizeSnapshotMessage(message) {
|
|||||||
delete extras.role
|
delete extras.role
|
||||||
delete extras.text
|
delete extras.text
|
||||||
delete extras.attachments
|
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)
|
return createMessage(role, text, attachments, extras)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,12 +174,14 @@ export function serializeSessionMessages(messages) {
|
|||||||
draftPayload: message.draftPayload || null,
|
draftPayload: message.draftPayload || null,
|
||||||
reviewPayload: message.reviewPayload || null,
|
reviewPayload: message.reviewPayload || null,
|
||||||
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
riskFlags: Array.isArray(message.riskFlags) ? message.riskFlags : [],
|
||||||
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
pendingAttachmentAssociation: message.pendingAttachmentAssociation || null,
|
||||||
applicationPreview: message.applicationPreview || null,
|
applicationPreview: message.applicationPreview || null,
|
||||||
budgetReport: message.budgetReport || null,
|
pendingApplicationPreviewDecision: message.pendingApplicationPreviewDecision || null,
|
||||||
stewardPlan: message.stewardPlan || null,
|
applicationSubmitConfirmed: Boolean(message.applicationSubmitConfirmed),
|
||||||
operationFeedback: message.operationFeedback || null,
|
budgetReport: message.budgetReport || null,
|
||||||
assistantName: message.assistantName || '',
|
stewardPlan: message.stewardPlan || null,
|
||||||
|
operationFeedback: message.operationFeedback || null,
|
||||||
|
assistantName: message.assistantName || '',
|
||||||
assistantVariant: message.assistantVariant || '',
|
assistantVariant: message.assistantVariant || '',
|
||||||
isWelcome: Boolean(message.isWelcome),
|
isWelcome: Boolean(message.isWelcome),
|
||||||
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
welcomeQuickActions: Array.isArray(message.welcomeQuickActions) ? message.welcomeQuickActions : []
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export async function handleLocalApplicationPreviewFlow({
|
|||||||
options,
|
options,
|
||||||
persistSessionState,
|
persistSessionState,
|
||||||
rawText,
|
rawText,
|
||||||
|
registerApplicationPreviewDecision = async ({ applicationPreview }) => applicationPreview,
|
||||||
replaceMessage,
|
replaceMessage,
|
||||||
resetFlowRun,
|
resetFlowRun,
|
||||||
resetStewardDelegatedInsightState,
|
resetStewardDelegatedInsightState,
|
||||||
@@ -108,7 +109,7 @@ export async function handleLocalApplicationPreviewFlow({
|
|||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(
|
const { applicationPreview: generatedApplicationPreview, meta } = await buildApplicationPreviewWithModelReview(
|
||||||
rawText,
|
rawText,
|
||||||
selectedBusinessTimeContext,
|
selectedBusinessTimeContext,
|
||||||
effectiveSessionType,
|
effectiveSessionType,
|
||||||
@@ -120,7 +121,7 @@ export async function handleLocalApplicationPreviewFlow({
|
|||||||
let applicationDateConflict = null
|
let applicationDateConflict = null
|
||||||
try {
|
try {
|
||||||
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
const existingClaims = await fetchExpenseClaims({ page: 1, pageSize: 100 })
|
||||||
applicationDateConflict = findOverlappingApplicationClaim(applicationPreview, existingClaims)
|
applicationDateConflict = findOverlappingApplicationClaim(generatedApplicationPreview, existingClaims)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to check overlapping application dates:', error)
|
console.warn('Failed to check overlapping application dates:', error)
|
||||||
}
|
}
|
||||||
@@ -157,7 +158,7 @@ export async function handleLocalApplicationPreviewFlow({
|
|||||||
{
|
{
|
||||||
sessionType: effectiveSessionType,
|
sessionType: effectiveSessionType,
|
||||||
rawText,
|
rawText,
|
||||||
applicationPreview,
|
applicationPreview: generatedApplicationPreview,
|
||||||
stewardContinuation: options.stewardContinuation || null
|
stewardContinuation: options.stewardContinuation || null
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -179,6 +180,7 @@ export async function handleLocalApplicationPreviewFlow({
|
|||||||
Date.now() - reviewStartedAt
|
Date.now() - reviewStartedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
let applicationPreview = generatedApplicationPreview
|
||||||
if (stewardDelegated) {
|
if (stewardDelegated) {
|
||||||
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
|
const fallbackStewardApplicationText = buildStewardApplicationPreviewMessage(
|
||||||
applicationPreview,
|
applicationPreview,
|
||||||
@@ -197,6 +199,13 @@ export async function handleLocalApplicationPreviewFlow({
|
|||||||
const pauseForMissingFields = slotDecision
|
const pauseForMissingFields = slotDecision
|
||||||
? String(slotDecision.next_action || '').trim() === 'ask_user'
|
? String(slotDecision.next_action || '').trim() === 'ask_user'
|
||||||
: localPauseForMissingFields
|
: localPauseForMissingFields
|
||||||
|
if (!pauseForMissingFields) {
|
||||||
|
applicationPreview = await registerApplicationPreviewDecision({
|
||||||
|
applicationPreview,
|
||||||
|
sourceText: rawText,
|
||||||
|
targetMessage: pendingMessage
|
||||||
|
})
|
||||||
|
}
|
||||||
const stewardApplicationText = buildStewardSlotDecisionMessage(
|
const stewardApplicationText = buildStewardSlotDecisionMessage(
|
||||||
slotDecision,
|
slotDecision,
|
||||||
applicationPreview,
|
applicationPreview,
|
||||||
@@ -221,6 +230,11 @@ export async function handleLocalApplicationPreviewFlow({
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
applicationPreview = await registerApplicationPreviewDecision({
|
||||||
|
applicationPreview,
|
||||||
|
sourceText: rawText,
|
||||||
|
targetMessage: pendingMessage
|
||||||
|
})
|
||||||
replaceMessage(pendingMessage.id, createMessage(
|
replaceMessage(pendingMessage.id, createMessage(
|
||||||
'assistant',
|
'assistant',
|
||||||
buildLocalApplicationPreviewMessage(applicationPreview),
|
buildLocalApplicationPreviewMessage(applicationPreview),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '../../utils/travelApplicationPlanning.js'
|
} from '../../utils/travelApplicationPlanning.js'
|
||||||
import { SESSION_TYPE_APPLICATION } from './travelReimbursementConversationModel.js'
|
import { SESSION_TYPE_APPLICATION } from './travelReimbursementConversationModel.js'
|
||||||
import { useTravelReimbursementStewardFollowupFlow } from './travelReimbursementStewardFollowupFlow.js'
|
import { useTravelReimbursementStewardFollowupFlow } from './travelReimbursementStewardFollowupFlow.js'
|
||||||
|
import { AI_APPLICATION_ACTION_SUBMIT } from './useTravelReimbursementApplicationPreviewActions.js'
|
||||||
|
|
||||||
export function useTravelReimbursementApplicationSubmitConfirm({
|
export function useTravelReimbursementApplicationSubmitConfirm({
|
||||||
activeSessionType,
|
activeSessionType,
|
||||||
@@ -23,6 +24,7 @@ export function useTravelReimbursementApplicationSubmitConfirm({
|
|||||||
nextTick,
|
nextTick,
|
||||||
persistSessionState,
|
persistSessionState,
|
||||||
reviewActionBusy,
|
reviewActionBusy,
|
||||||
|
runApplicationPreviewAction,
|
||||||
scrollToBottom,
|
scrollToBottom,
|
||||||
submitComposer,
|
submitComposer,
|
||||||
submitting,
|
submitting,
|
||||||
@@ -103,31 +105,36 @@ export function useTravelReimbursementApplicationSubmitConfirm({
|
|||||||
const stewardSubmitContinuation = message?.stewardContinuation || null
|
const stewardSubmitContinuation = message?.stewardContinuation || null
|
||||||
reviewActionBusy.value = true
|
reviewActionBusy.value = true
|
||||||
try {
|
try {
|
||||||
const payload = await submitComposer({
|
const payload = applicationPreview
|
||||||
rawText: applicationSubmitText,
|
? await runApplicationPreviewAction(
|
||||||
userText: String(options.userText || '').trim() || '确认提交',
|
AI_APPLICATION_ACTION_SUBMIT,
|
||||||
skipUserMessage: Boolean(options.skipUserMessage),
|
message
|
||||||
pendingText: '正在提交费用申请...',
|
)
|
||||||
systemGenerated: true,
|
: await submitComposer({
|
||||||
skipScopeGuard: true,
|
rawText: applicationSubmitText,
|
||||||
skipStewardPlan: true,
|
userText: String(options.userText || '').trim() || '确认提交',
|
||||||
stewardContinuation: stewardSubmitContinuation,
|
skipUserMessage: Boolean(options.skipUserMessage),
|
||||||
sessionTypeOverride: SESSION_TYPE_APPLICATION,
|
pendingText: '正在提交费用申请...',
|
||||||
feedbackOperationType: 'submit_application',
|
systemGenerated: true,
|
||||||
extraContext: {
|
skipScopeGuard: true,
|
||||||
application_preview: applicationPreview,
|
skipStewardPlan: true,
|
||||||
user_input_text: applicationSubmitText,
|
stewardContinuation: stewardSubmitContinuation,
|
||||||
...(applicationEditClaimId
|
sessionTypeOverride: SESSION_TYPE_APPLICATION,
|
||||||
? {
|
feedbackOperationType: 'submit_application',
|
||||||
application_edit_claim_id: applicationEditClaimId,
|
extraContext: {
|
||||||
application_edit_claim_no: String(linkedRequest.value?.claimNo || linkedRequest.value?.id || '').trim(),
|
application_preview: applicationPreview,
|
||||||
application_edit_mode: true,
|
user_input_text: applicationSubmitText,
|
||||||
draft_claim_id: applicationEditClaimId,
|
...(applicationEditClaimId
|
||||||
selected_claim_id: 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 draftPayload = payload?.result?.draft_payload || {}
|
||||||
const claimNo = String(draftPayload.claim_no || '').trim()
|
const claimNo = String(draftPayload.claim_no || '').trim()
|
||||||
const claimId = String(draftPayload.claim_id || '').trim()
|
const claimId = String(draftPayload.claim_id || '').trim()
|
||||||
@@ -140,6 +147,7 @@ export function useTravelReimbursementApplicationSubmitConfirm({
|
|||||||
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
|
approvalStage: String(draftPayload.approval_stage || '直属领导审批').trim(),
|
||||||
documentType: 'application'
|
documentType: 'application'
|
||||||
})
|
})
|
||||||
|
persistSessionState()
|
||||||
}
|
}
|
||||||
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
|
const planningText = buildTravelPlanningNudgeMessage(applicationPreview, draftPayload)
|
||||||
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
|
const planningActions = buildTravelPlanningSuggestedActions(applicationPreview, draftPayload).map((action) => ({
|
||||||
@@ -162,6 +170,8 @@ export function useTravelReimbursementApplicationSubmitConfirm({
|
|||||||
if (stewardFollowup) {
|
if (stewardFollowup) {
|
||||||
await pushStewardContinuationMessage(stewardFollowup)
|
await pushStewardContinuationMessage(stewardFollowup)
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast(String(error?.message || '').trim() || '申请提交失败,请稍后重试。')
|
||||||
} finally {
|
} finally {
|
||||||
reviewActionBusy.value = false
|
reviewActionBusy.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ export function useTravelReimbursementCreateViewUi(ctx) {
|
|||||||
canApplyApplicationPreviewDateSelection: ctx.canApplyApplicationPreviewDateSelection,
|
canApplyApplicationPreviewDateSelection: ctx.canApplyApplicationPreviewDateSelection,
|
||||||
handleApplicationPreviewEditorKeydown: ctx.handleApplicationPreviewEditorKeydown,
|
handleApplicationPreviewEditorKeydown: ctx.handleApplicationPreviewEditorKeydown,
|
||||||
buildApplicationPreviewFooterText: ctx.buildApplicationPreviewFooterText,
|
buildApplicationPreviewFooterText: ctx.buildApplicationPreviewFooterText,
|
||||||
|
canRetryApplicationPreviewDecision: ctx.canRetryApplicationPreviewDecision,
|
||||||
|
canSaveApplicationPreviewDraft: ctx.canSaveApplicationPreviewDraft,
|
||||||
|
canSubmitApplicationPreview: ctx.canSubmitApplicationPreview,
|
||||||
|
retryApplicationPreviewDecision: ctx.retryApplicationPreviewDecision,
|
||||||
|
saveApplicationPreviewDraft: ctx.saveApplicationPreviewDraft,
|
||||||
isApplicationDraftPayload: ctx.isApplicationDraftPayload,
|
isApplicationDraftPayload: ctx.isApplicationDraftPayload,
|
||||||
resolveApplicationDraftStatusLabel: ctx.resolveApplicationDraftStatusLabel,
|
resolveApplicationDraftStatusLabel: ctx.resolveApplicationDraftStatusLabel,
|
||||||
buildApplicationDraftSummaryItems: ctx.buildApplicationDraftSummaryItems,
|
buildApplicationDraftSummaryItems: ctx.buildApplicationDraftSummaryItems,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
normalizeExpenseQueryPayload,
|
normalizeExpenseQueryPayload,
|
||||||
persistSessionState,
|
persistSessionState,
|
||||||
props,
|
props,
|
||||||
|
registerApplicationPreviewDecision,
|
||||||
recognizeOcrFiles,
|
recognizeOcrFiles,
|
||||||
refreshCurrentUserFromBackend,
|
refreshCurrentUserFromBackend,
|
||||||
refreshFlowRunDetail,
|
refreshFlowRunDetail,
|
||||||
@@ -329,6 +330,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
options,
|
options,
|
||||||
persistSessionState,
|
persistSessionState,
|
||||||
rawText,
|
rawText,
|
||||||
|
registerApplicationPreviewDecision,
|
||||||
replaceMessage,
|
replaceMessage,
|
||||||
resetFlowRun,
|
resetFlowRun,
|
||||||
resetStewardDelegatedInsightState,
|
resetStewardDelegatedInsightState,
|
||||||
|
|||||||
@@ -223,11 +223,44 @@ async function testEditDraftActionCarriesClaimAndEditableFields() {
|
|||||||
assert.deepEqual(body.context_json.application_editable_fields, ['reason', 'time', 'location', 'transportMode'])
|
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() {
|
async function run() {
|
||||||
await testSubmitActionUsesFastPreviewEndpoint()
|
await testSubmitActionUsesFastPreviewEndpoint()
|
||||||
await testSaveDraftActionUsesFastPreviewEndpoint()
|
await testSaveDraftActionUsesFastPreviewEndpoint()
|
||||||
await testRegistrationUsesServerCanonicalPreview()
|
await testRegistrationUsesServerCanonicalPreview()
|
||||||
await testEditDraftActionCarriesClaimAndEditableFields()
|
await testEditDraftActionCarriesClaimAndEditableFields()
|
||||||
|
await testApplicationActionSourceCanBeConfiguredWithoutChangingWorkbenchDefaults()
|
||||||
console.log('ai-application-preview-actions tests passed')
|
console.log('ai-application-preview-actions tests passed')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ const suggestedActionsScript = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSuggestedActions.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
const stewardRuntimeScript = readFileSync(
|
const submitConfirmScript = readFileSync(
|
||||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementStewardRuntime.js', import.meta.url)),
|
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'
|
'utf8'
|
||||||
)
|
)
|
||||||
const messageItemStyles = readFileSync(
|
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(createViewTemplate, /@confirm="confirmApplicationSubmit"/)
|
||||||
assert.match(createViewScript, /const APPLICATION_SUBMIT_HREF = '#application-submit'/)
|
assert.match(createViewScript, /const APPLICATION_SUBMIT_HREF = '#application-submit'/)
|
||||||
assert.match(
|
assert.match(
|
||||||
createViewScript,
|
messageHandlersScript,
|
||||||
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
|
/href === APPLICATION_SUBMIT_HREF[\s\S]*openApplicationSubmitConfirm\(message\)/
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
stewardRuntimeScript,
|
submitConfirmScript,
|
||||||
/async function confirmApplicationSubmit\(options = \{\}\)[\s\S]*const applicationSubmitText[\s\S]*rawText: applicationSubmitText[\s\S]*systemGenerated: true[\s\S]*skipScopeGuard: true/
|
/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(
|
assert.match(
|
||||||
stewardRuntimeScript,
|
submitConfirmScript,
|
||||||
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = await submitComposer/
|
/applicationSubmitConfirmDialog\.value = \{[\s\S]*open: false,[\s\S]*message: null[\s\S]*\}[\s\S]*const payload = applicationPreview/
|
||||||
)
|
)
|
||||||
assert.match(
|
assert.match(
|
||||||
stewardRuntimeScript,
|
submitConfirmScript,
|
||||||
/emit\('draft-saved', \{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
|
/emitDraftSaved\(\{[\s\S]*status: 'submitted'[\s\S]*documentType: 'application'/
|
||||||
)
|
)
|
||||||
assert.match(stewardRuntimeScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
|
assert.match(submitConfirmScript, /buildTravelPlanningNudgeMessage\(applicationPreview, draftPayload\)/)
|
||||||
assert.match(stewardRuntimeScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
|
assert.match(submitConfirmScript, /buildTravelPlanningSuggestedActions\(applicationPreview, draftPayload\)/)
|
||||||
assert.match(stewardRuntimeScript, /meta:\s*\['行程规划推荐'\]/)
|
assert.match(submitConfirmScript, /meta:\s*\['行程规划推荐'\]/)
|
||||||
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
|
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_GENERATE/)
|
||||||
assert.match(suggestedActionsScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
|
assert.match(suggestedActionsScript, /buildTravelPlanningRecommendation\(sourcePreview, sourceDraftPayload\)/)
|
||||||
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_SKIP/)
|
assert.match(suggestedActionsScript, /TRAVEL_PLANNING_ACTION_SKIP/)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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 () => {
|
test('workbench steward application side effect opens signed preview before submit', () => {
|
||||||
const requests = []
|
let previewPayload = null
|
||||||
const originalFetch = globalThis.fetch
|
const router = useWorkbenchAiActionRouter({
|
||||||
globalThis.fetch = async (_url, options = {}) => {
|
aiExpenseDraft: { value: null },
|
||||||
const body = JSON.parse(String(options.body || '{}'))
|
applicationFlow: {
|
||||||
requests.push(body)
|
isInlineSuggestedActionDisabled: () => false,
|
||||||
if (body.action_type === 'run_duplicate_precheck') {
|
executeInlineApplicationPreviewAction: () => {}
|
||||||
return {
|
},
|
||||||
ok: true,
|
assistantDraft: { value: '' },
|
||||||
async json() {
|
attachmentFlow: { confirmAiAttachmentAssociation: () => {} },
|
||||||
return {
|
emit: () => {},
|
||||||
action_type: 'run_duplicate_precheck',
|
expenseFlow: {
|
||||||
status: 'succeeded',
|
linkAiExpenseApplication: () => {},
|
||||||
message: '未发现重复或冲突申请,可以继续提交。',
|
pushInlineExpenseSceneSelectionPrompt: () => {},
|
||||||
result_payload: {
|
startAiApplicationPreviewFromAction: (payload) => {
|
||||||
status: 'ok',
|
previewPayload = payload
|
||||||
blocking: false
|
},
|
||||||
}
|
startAiExpenseDraft: () => {}
|
||||||
}
|
},
|
||||||
}
|
focusAiModeInput: () => {},
|
||||||
}
|
hasInlineAttachmentOcrDetails: () => false,
|
||||||
}
|
resolveLatestInlineUserPrompt: () => '',
|
||||||
return {
|
selectedFiles: { value: [] },
|
||||||
ok: true,
|
startInlineConversation: () => {},
|
||||||
async json() {
|
toast: () => {},
|
||||||
return {
|
toggleInlineAttachmentOcrDetails: () => {}
|
||||||
action_type: 'submit_application',
|
})
|
||||||
status: 'succeeded',
|
|
||||||
message: '申请已提交审批。',
|
|
||||||
result_payload: {
|
|
||||||
draft_payload: {
|
|
||||||
claim_id: 'claim-app-1',
|
|
||||||
claim_no: 'A1BCDEF2'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
router.handleInlineSuggestedAction({
|
||||||
const messages = []
|
label: '确认提交申请',
|
||||||
let messageSeq = 0
|
action_type: 'switch_session',
|
||||||
const createInlineMessage = (role, content, options = {}) => ({
|
payload: {
|
||||||
id: options.id || `msg-${++messageSeq}`,
|
steward_execute_action: true,
|
||||||
role,
|
steward_action_type: 'submit_application',
|
||||||
content,
|
steward_current_task: {
|
||||||
pending: Boolean(options.pending),
|
task_id: 'task-app-1',
|
||||||
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : []
|
task_type: 'expense_application',
|
||||||
})
|
requested_action: 'submit'
|
||||||
const replaceInlineMessage = (id, nextMessage) => {
|
},
|
||||||
const index = messages.findIndex((item) => item.id === id)
|
carry_text: '2026-02-20 至 2026-02-23,去上海出差,交通火车,直接提交'
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
await router.handleInlineSuggestedAction({
|
assert.equal(previewPayload?.steward_action_type, 'submit_application')
|
||||||
label: '确认提交申请',
|
assert.equal(previewPayload?.steward_current_task?.task_type, 'expense_application')
|
||||||
action_type: 'switch_session',
|
assert.match(previewPayload?.carry_text || '', /直接提交/)
|
||||||
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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('workbench steward continue-next-task reimbursement prefills ontology and forwards remaining tasks', () => {
|
test('workbench steward continue-next-task reimbursement prefills ontology and forwards remaining tasks', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user