4 Commits

Author SHA1 Message Date
caoxiaozhu
a0f6d9f702 chore: 更新 .env.example、财务规则表与 AI 意图规划开发文档 2026-06-24 21:59:15 +08:00
caoxiaozhu
bb681aa1f3 chore(repo): 移除误跟踪的 egg-info 构建产物并新增忽略规则
server/src/x_financial_server.egg-info/ 为 uv/pip 构建产物,此前被误跟踪。
本次 git rm --cached 移除跟踪(本地保留),.gitignore 新增 *.egg-info/ 规则避免后续误入库。
2026-06-24 21:59:09 +08:00
caoxiaozhu
bc560145a4 feat(web): AI 工作台意图规划与规划思考模型
- 新增 workbenchAiIntentPlannerModel,基于 LLM function_call 解析建单/草稿/提交意图,区分 model 与 rule_fallback 来源
- 新增 workbenchAiPlanningThinkingModel 合并规划思考事件流,按 eventId 去重合并
- application gate/preview 模型接入意图规划,usePersonalWorkbenchAiMode/useWorkbenchAiStewardFlow/useWorkbenchAiActionRouter 链路适配,支持上下文提交
- steward 服务与 stewardPlanModel 适配新动作结构,receipt-folder-view 微调样式
- 新增 intent-planner-model/application-context-submit/steward-actions-service 测试,更新 gate-model/action-router/plan-message-copy/fast-preview 测试
2026-06-24 21:58:46 +08:00
caoxiaozhu
5311c99d69 refactor(server): steward 决策链路改用 LangGraph 编排
- 新增 StewardGraphPlannerService,用 LangGraph 状态图编排意图识别→流程判断→模型/规则分支→兜底,替代原 planner 内线性调用
- 新增 StewardGraphRuntimeService 编排运行时决策与槽位决策;StewardActionContracts/Executor 统一动作合约与执行
- steward_intent_agent/application_fact_resolver/runtime_chat 适配图执行器,config 暴露图相关开关
- pyproject/uv.lock 新增 langgraph 依赖
- 新增 graph_planner/graph_runtime/action_executor 测试,更新 intent_agent/planner/fact_resolver/runtime_chat/reimbursement 测试
2026-06-24 21:58:35 +08:00
60 changed files with 6321 additions and 401 deletions

View File

@@ -31,6 +31,7 @@ ONLYOFFICE_PUBLIC_URL=http://127.0.0.1:8082
ONLYOFFICE_BACKEND_URL=http://127.0.0.1:8000
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice
HERMES_AGENT_SHARED_TOKEN=change-me-hermes
STEWARD_AGENT_RUNTIME=langgraph
POSTGRES_HOST=127.0.0.1
POSTGRES_PORT=5432

1
.gitignore vendored
View File

@@ -7,6 +7,7 @@ web/.vite/
.omc/
.omx/
.claude/
*.egg-info/
.codex/*
!.codex/skills/
.codex/skills/*

View File

@@ -0,0 +1,146 @@
# AI 意图规划器
## 功能一句话
把用户一句自然语言先交给大模型识别成“动作和步骤”的结构化计划,再由前端/后端确定性执行器逐步校验和执行;规则只作为模型不可用或结构非法时的兜底。
## 背景
用户真实表达往往不是一个简单关键词,而是一个目标:
```text
去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交
```
这句话包含至少四层语义:
- 业务对象:差旅费用申请。
- 已给字段:地点、事由、交通方式。
- 用户动作:直接提交,不只是生成草稿。
- 执行步骤:生成申请核对表、校验必填字段、查重、提交审批。
如果继续用前端规则直接判断,会越来越像关键词路由;正确方向是让大模型先拆成结构化计划,系统只执行经过白名单校验的步骤。
## 目标
- 大模型作为复杂意图的主识别器,输出结构化 `intent plan`
- `intent plan` 明确描述用户要执行的动作、业务字段、缺失字段和步骤列表。
- 执行器只认计划中的白名单步骤,不直接相信模型文本。
- 字段齐全且用户明确“直接提交”时,系统自动走完整链路。
- 字段缺失、风险阻断、重复申请或低置信度时,系统停在核对表或风险提示。
- 本地规则只作为 `rule_fallback`,不得伪装成模型判断。
## 非目标
- 不让大模型直接写数据库、提交审批或绑定附件。
- 不引入 LangChain 高层 Agent 来替代现有业务服务。
- 不让 LangGraph 直接接管模型供应商密钥、数据库写入或审批副作用。
- 不绕过已有申请预览、规则测算、重复申请核查和提交接口。
- 不把所有财务场景一次性改完;第一阶段先覆盖个人工作台 AI 模式里的差旅申请链路。
## 结构化计划契约
前端执行器消费统一结构:
```json
{
"source": "llm_function_call",
"intent": "create_travel_application",
"requestedAction": "submit",
"confidence": 0.91,
"sourceText": "去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交",
"slots": {
"location": "上海",
"reason": "辅助国网仿生产服务器部署",
"transportMode": "火车"
},
"missingFields": [],
"steps": [
"build_application_preview",
"validate_required_fields",
"run_duplicate_precheck",
"submit_application"
]
}
```
## 步骤白名单
- `build_application_preview`:生成申请核对表,复用现有申请预览能力。
- `validate_required_fields`:用确定性代码校验必填字段和字段格式。
- `run_duplicate_precheck`:提交前查询同日期或重叠申请单。
- `submit_application`:调用现有申请提交动作;只有用户明确要求提交且前置校验通过才执行。
- `save_application_draft`:调用现有申请草稿保存能力。
- `create_reimbursement_draft`:调用现有报销草稿创建能力。
- `associate_attachments`:调用现有附件关联 runner缺少 `receipt_ids` 或匹配不唯一时阻断,不能假成功。
- `link_existing_application`:调用现有报销草稿创建链路并写入申请关联 flag申请单不存在、未审批或已被报销时阻断。
## 数据流
```text
用户输入
steward LLM function calling
模型计划归一化与白名单校验
可执行 intent plan
申请/报销/附件等确定性执行器
核对表、风险提示、草稿或提交结果
```
当模型不可用、超时或返回不可执行结构时:
```text
模型失败
本地 rule_fallback 生成保守计划
同一个执行器继续处理
```
## 安全边界
- 模型只负责“理解”和“拆步骤”,不拥有副作用权限。
- 执行器必须校验 `intent``steps` 是否在白名单内;未知 action 必须阻断,不能调用业务服务。
- 提交前必须走 `readyToSubmit` 和重复申请 precheck。
- 提交类副作用必须带用户确认和 precheck 通过证据。
- 地点、日期、事由等字段仍由现有申请预览和规则校验模块判断。
- 模型置信度低、流程冲突或必填字段缺失时,不自动提交。
## 第一阶段落地
- 新增前端 planner model把后端 `StewardPlanResponse` 映射成前端 `intent plan`
- 个人工作台 AI 模式在命中差旅申请候选时,优先调用现有 `/steward/plans` 获取模型计划。
- 模型计划可执行时,按 `steps` 生成申请核对表并在条件满足时自动提交。
- 模型不可用或计划不可执行时,使用本地 `rule_fallback` 保持体验不倒退。
- 保留既有普通小财管家路径,用来处理无法直接执行或更复杂的多任务场景。
## LangGraph 迁移方向
后端小财管家的规划入口、槽位决策入口和运行时动作决策入口已经开始迁入 LangGraph。
后续不再继续把感知、规划、补字段、运行时动作判断堆进自研 `if/else` 编排,而是按 node 拆分:
- `model_intent_node`:模型 function calling 规划。
- `slot_tool_decision_node`:字段缺口与追问判断,失败时进入规则兜底。
- `runtime_memory_context_node`:合并前端运行时状态和持久化 `steward_state`
- `runtime_tool_decision_node`:用户补充、确认、取消、继续执行等运行时判断。
- `runtime_action_decision_node`:已选流程确认等不需要模型的确定性行动路由。
- `action_plan_node`:生成白名单 `StewardActionStep`,当前已覆盖申请预览、保存草稿、直接提交和报销草稿。
- `human_review_node`:保存草稿、提交审批、关联申请单前的确认点。
- `action_execute_node`:调用现有确定性业务服务;当前已由 `/steward/actions/execute``StewardGraphActionRuntime``StewardActionExecutor` 提供 checkpoint、pending interrupt 和幂等重放。
- `persist_state_node`:合并 conversation state 和 steward state。
当前 `plans` 已返回服务端 `action_steps``slot-decisions` / `runtime-decisions` 已默认走 `StewardGraphRuntime`
如果 LangGraph 图节点异常,服务端会退回原有 Agent 和规则兜底,避免框架层故障影响用户会话。
详细迁移计划见 `LANGGRAPH_RUNTIME_MIGRATION.md`
## 测试策略
- planner model 单元测试模型计划归一化、rule fallback、政策咨询排除。
- 前端静态接线测试:主流程必须先请求 steward plan再 fallback。
- 申请预览回归测试:`直接提交` 不得污染事由;缺日期不得伪造字段。
- 构建验证:`npm --prefix web run build`

View File

@@ -0,0 +1,420 @@
# 小财管家 LangGraph Runtime 迁移计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 把小财管家的感知、规划、记忆、确认和行动逐步迁到 LangGraph runtime减少自研流程编排里的隐藏状态和分支 bug。
**Architecture:** 保留现有 FastAPI API、Pydantic schema、`RuntimeChatService` 模型供应商抽象和业务服务LangGraph 只接管 agent 编排。副作用动作必须通过白名单 action node 调用现有业务服务,保存草稿、提交和关联单据都不能由模型文本直接触发。
**Tech Stack:** Python 3.11、FastAPI、Pydantic、LangGraph 1.x、SQLAlchemy、现有 `AgentConversationService` / `StewardFlowStateService`
---
## 当前状态
已经完成第一阶段接入:
- `server/src/app/services/steward_graph_planner.py`
- 已新增 `StewardGraphPlannerService`
- 已用 LangGraph `StateGraph` 编排 `prepare_context -> detect_model_intent -> done/off_topic/rule_fallback`
- `server/src/app/services/steward_graph_runtime.py`
- 已新增 `StewardGraphRuntime`
- 槽位决策已编排为 `slot_prepare_context -> slot_tool_decision -> done/rule_fallback`
- 运行时决策已编排为 `runtime_memory_context -> runtime_action_decision/runtime_tool_decision -> done/rule_fallback`
- 图内失败会降级到原有规则兜底;端点层图运行异常会回退旧 Agent。
- `server/src/app/services/steward_action_contracts.py`
- 已新增 `StewardActionPlanBuilder`
- 已把申请、报销、保存草稿、直接提交规划成 `StewardActionStep` 白名单动作。
- `StewardGraphPlannerService` 已增加 `attach_action_steps` 节点,计划返回前统一补全动作列表。
- `server/src/app/services/steward_action_executor.py`
- 已新增 `StewardActionExecutor`
- 已接入 `/steward/actions/execute`,支持未知动作拒绝、缺字段阻断、提交确认门禁、重复申请 precheck、申请/报销草稿真实执行、关联申请单和附件关联。
- 申请动作复用 `UserAgentService` 的申请保存/提交能力,报销草稿复用 `ExpenseClaimService.save_or_submit_from_ontology()`
- `server/src/app/services/steward_graph_action_runtime.py`
- 已新增 `StewardGraphActionRuntime`
- 已用 LangGraph `StateGraph` 编排 `action_checkpoint_load -> action_execute_node -> action_checkpoint_persist`
- 已通过 `conversation_id + client_trace_id``AgentConversation.state_json` 中记录 checkpoint、pending interrupt 和幂等重放结果。
- `web/src/services/steward.js` / `web/src/views/scripts/stewardPlanModel.js`
- 前端已新增 `executeStewardAction()` 服务方法。
- steward suggested action 已携带服务端可执行 action step旧申请预览流仍保留兜底。
- `web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js`
- AI 工作台点击 `steward_execute_action` 时会调用 `/steward/actions/execute`
- 直接提交会先执行 `run_duplicate_precheck`precheck 通过后再提交申请。
- `server/src/app/api/v1/endpoints/steward.py`
- `/steward/plans``/steward/plans/stream` 默认使用 `StewardGraphPlannerService`
- `/steward/slot-decisions``/steward/runtime-decisions` 默认使用 `StewardGraphRuntime`
- `STEWARD_AGENT_RUNTIME=legacy` 可回退旧 `StewardPlannerService`
- `server/pyproject.toml` / `server/uv.lock`
- 已加入 `langgraph>=1.2.0,<2.0.0`
- `server/tests/test_steward_graph_planner.py`
- 已覆盖默认 LangGraph、显式 LangGraph、legacy 回退、模型成功计划和模型失败后规则兜底。
- `server/tests/test_steward_graph_runtime.py`
- 已覆盖槽位工具节点、图内规则兜底、运行时记忆合并、确定性行动选择和端点级 legacy 兜底。
- `server/tests/test_application_fact_resolver.py`
- 已覆盖 `交通火车` 不污染申请事由,以及 `高铁往返` 这类业务描述仍保留。
当前已完成规划入口、slot decision、runtime decision、基础 action contract、第一版 action executor、前端点击执行闭环以及 action runtime 的 checkpoint / interrupt / 幂等重放。
真实外部模型连通性仍需要用当前 MiniMax / backup 配置再回放确认。
## 官方能力边界
LangGraph 官方文档强调它是低层 agent orchestration runtime重点能力是 durable execution、streaming、human-in-the-loop 和 persistence。
本项目只采用这些底层编排能力:
- Graph API用 state、node、edge 表达流程node 可以是 LLM 调用,也可以是普通业务代码。
- Persistence用 checkpointer 保存 thread scoped state用 store 保存跨线程长期信息。
- Interrupt在需要用户确认时暂停 graph等待外部输入后恢复。
参考:
- <https://docs.langchain.com/oss/python/langgraph/overview>
- <https://docs.langchain.com/oss/python/langgraph/graph-api>
- <https://docs.langchain.com/oss/python/langgraph/persistence>
- <https://docs.langchain.com/oss/python/langgraph/interrupts>
## 总体目标架构
```text
用户输入 / 前端上下文 / 附件
StewardGraphRuntime
├── prepare_context_node
├── model_intent_node
├── slot_decision_node
├── flow_guard_node
├── action_plan_node
├── human_review_node
├── action_execute_node
├── persist_state_node
└── response_adapter_node
现有前端协议StewardPlanResponse / StewardRuntimeDecisionResponse / StewardSlotDecisionResponse
```
关键原则:
- API 响应协议稳定,前端不因为 runtime 替换被迫大改。
- 模型只负责理解、拆步骤、给候选 action不直接写库。
- 所有副作用节点必须调用现有业务服务,并保留确认和审计。
- `RuntimeChatService` 继续是唯一模型调用入口LangGraph 不直接管理供应商密钥。
- `StewardFlowStateService``AgentConversationService` 是 checkpoint 落库的第一候选,不另起一套状态源。
## Runtime State 草案
第一版 graph state 用 `TypedDict`,后续需要持久化时再评估 Pydantic schema。
```python
class StewardGraphState(TypedDict, total=False):
request: StewardPlanRequest
conversation_id: str
thread_id: str
message: str
base_date: date
steward_state: dict[str, Any]
model_call_traces: list[dict[str, Any]]
intent_result: StewardIntentAgentResult | None
slot_decision: StewardSlotDecisionResponse | None
runtime_decision: StewardRuntimeDecisionResponse | None
action_plan: list[StewardActionStep]
pending_interrupt: dict[str, Any]
action_result: dict[str, Any]
response: StewardPlanResponse | StewardRuntimeDecisionResponse | StewardSlotDecisionResponse
fallback_reason: str
```
`thread_id` 推荐使用现有 `conversation_id`,这样 LangGraph checkpoint、业务会话和前端会话能对齐。
## 节点边界
### 感知节点
- `prepare_context_node`
- 输入:`StewardPlanRequest` 或 runtime decision request。
- 输出清洗后的消息、base date、当前 conversation state。
- 禁止:模型调用、数据库写入。
- `model_intent_node`
- 调用:`StewardIntentAgent.detect()`
- 约束:保留 `timeout_seconds=10``max_attempts=3``use_failure_cooldown=False`
- 失败:只写入 `fallback_reason``model_call_traces`,不吞掉错误上下文。
### 决策节点
- `slot_decision_node`
- 迁移对象:`StewardSlotDecisionAgent.decide()`
- 目标:让字段缺口判断进入同一个 graph state不再由前端和后端多个入口各自推断。
- `runtime_decision_node`
- 迁移对象:`StewardRuntimeDecisionAgent.decide()`
- 目标:用户补字段、确认流程、取消当前动作、继续下一任务都走 graph routing。
- `flow_guard_node`
- 负责申请/报销歧义、重复单据、时间重叠、低置信度、off-topic。
- 低置信度或强冲突必须进入确认或阻断,不允许继续 action 执行。
### 行动节点
- `action_plan_node`
- 输出白名单步骤,例如 `build_application_preview``save_application_draft``submit_application`
- 禁止输出自由文本 action。
- `human_review_node`
- 对保存草稿、提交审批、关联申请单等副作用动作生成 interrupt payload。
- 第一阶段可继续返回前端确认动作;第二阶段再接 LangGraph `interrupt()`
- `action_execute_node`
- 只能调用现有业务服务。
- 必须幂等,或在执行前检查动作是否已完成。
- 提交类动作必须检查 `confirmation_required` 和用户确认来源。
- `persist_state_node`
- 合并 `StewardFlowStateService.merge_plan()` / `merge_state()`
- 后续接入 LangGraph checkpointer 后,这里负责业务状态与 checkpoint 的一致性。
### 响应节点
- `response_adapter_node`
- 输出现有 Pydantic response。
- 必须保留 `model_call_traces``planning_source``steward_state`
## 分阶段计划
### Phase 0规划入口接入 LangGraph
状态:已完成。
完成标准:
- [x] 默认 runtime 为 `langgraph`
- [x] `STEWARD_AGENT_RUNTIME=legacy` 可回退。
- [x] `/steward/plans` 真实接口仍返回兼容的 `StewardPlanResponse`
- [x] 模型失败后仍按 10s * 3 次再规则降级。
### Phase 1修通模型成功路径
目标:让真实保存草稿话术至少能走一次 `llm_function_call` 成功路径。
文件:
- 修改:`server/src/app/services/runtime_chat.py`
- 修改:`server/src/app/services/settings.py`
- 修改:`server/tests/test_runtime_chat_service.py`
- 测试:`server/tests/test_steward_intent_agent.py`
步骤:
- [x] 写失败测试:模拟 main provider 超时但 backup provider 可用时,`complete_with_tool_call()` 返回 backup 的 tool call。
- [x] 确认 backup function calling 会在 main 失败后返回 tool call不等待 main 重试结束。
- [ ] 写失败测试MiniMax 返回非 OpenAI tool call 兼容格式时,服务端能记录清晰 trace 并降级。
- [x] 容器内运行:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-local-linux \
timeout 60s /tmp/x-financial-server-venv/bin/pytest -q \
server/tests/test_runtime_chat_service.py \
server/tests/test_steward_intent_agent.py
```
验收:
- [ ] 真实 `/api/v1/steward/plans` 对保存草稿原句返回 `planning_source=llm_function_call`
- [x] `model_call_traces` 能区分 main 失败、backup 成功或全失败。
### Phase 2slot/runtime decision 迁入 graph
目标:把字段缺口和运行时动作判断从独立 agent 类迁进同一个 graph runtime。
状态:已完成。
文件:
- 已创建:`server/src/app/services/steward_graph_runtime.py`
- 已修改:`server/src/app/api/v1/endpoints/steward.py`
- 已测试:`server/tests/test_steward_graph_runtime.py`
- 保留测试:`server/tests/test_steward_slot_decision_agent.py`
- 保留测试:`server/tests/test_steward_runtime_decision_agent.py`
步骤:
- [x] 写失败测试:`StewardGraphRuntime.decide_slot()` 调用 slot node 后返回与现有 `StewardSlotDecisionAgent.decide()` 等价的 response。
- [x] 写失败测试:`StewardGraphRuntime.decide_runtime()` 在用户补充字段时合并 `steward_state`
- [x]`StewardSlotDecisionAgent` 包成 `slot_tool_decision` 节点。
- [x]`StewardRuntimeDecisionAgent` 包成 `runtime_tool_decision` 节点。
- [x] 把已选流程确认和当前运行时记忆归一化拆成 `runtime_memory_context` / `runtime_action_decision` 节点。
- [x] endpoint 在 `STEWARD_AGENT_RUNTIME=langgraph` 时使用 graph runtime。
- [x] endpoint 在 graph runtime 异常时回退旧 Agent避免框架层失效影响会话。
验收:
- [x] `slot-decisions``runtime-decisions``plans` 三个入口都由 LangGraph runtime 路由。
- [x] legacy 回退仍可用。
- [x] 图内工具节点失败时仍有可执行规则兜底。
### Phase 3action node 标准化
目标:把保存草稿、提交审批、关联申请单这些副作用动作变成显式 action node。
状态:基础 action contract、第一版 action executor、前端点击执行闭环、LangGraph action node、checkpoint、pending interrupt 和幂等重放已完成;后续只剩 durable checkpointer 抽象与更完整 trace UI。
文件:
- 已创建:`server/src/app/services/steward_action_executor.py`
- 已创建:`server/src/app/services/steward_action_contracts.py`
- 已创建:`server/src/app/services/steward_graph_action_runtime.py`
- 已修改:`server/src/app/api/v1/endpoints/steward.py`
- 已修改:`server/src/app/services/steward_graph_planner.py`
- 已修改:`server/src/app/schemas/steward.py`
- 已修改:`web/src/services/steward.js`
- 已修改:`web/src/views/scripts/stewardPlanModel.js`
- 已修改:`web/src/composables/workbenchAiMode/useWorkbenchAiActionRouter.js`
- 已修改:`web/src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js`
- 已测试:`server/tests/test_steward_action_executor.py`
- 已测试:`server/tests/test_steward_graph_planner.py`
- 已测试:`server/tests/test_steward_planner.py`
- 已测试:`web/tests/workbench-ai-intent-planner-model.test.mjs`
- 已测试:`web/tests/steward-actions-service.test.mjs`
- 已测试:`web/tests/steward-plan-message-copy.test.mjs`
- 已测试:`web/tests/workbench-ai-action-router.test.mjs`
白名单 action
- `build_application_preview`
- `save_application_draft`
- `submit_application`
- `link_existing_application`
- `create_reimbursement_draft`
- `associate_attachments`
步骤:
- [x] 写失败测试:申请直接提交计划必须输出 `fill_application_fields -> build_application_preview -> validate_required_fields -> run_duplicate_precheck -> submit_application`
- [x] 写失败测试:保存草稿计划必须输出 `save_application_draft`,并在字段缺失时阻断后续副作用。
- [x] 写失败测试:报销计划必须输出 `fill_reimbursement_fields -> build_reimbursement_preview -> validate_required_fields -> create_reimbursement_draft`
- [x]`StewardTask``StewardPlanResponse` 中增加 `action_steps`
- [x] 实现 `StewardActionPlanBuilder`,由服务端确定性生成白名单 action step。
- [x] 在 LangGraph planner 中新增 `attach_action_steps` 节点。
- [x] 前端 planner model 优先消费服务端 `task.action_steps`,旧响应才回退本地推导。
- [x] 写失败测试:未知 action step 被拒绝,不调用任何业务服务。
- [x] 写失败测试:`submit_application` 缺少确认来源时返回 `needs_confirmation`
- [x] 写失败测试:`submit_application` 必须看到 precheck 通过后才允许真实提交。
- [x] 实现 action registry只允许白名单 action。
- [x] 接入现有申请保存草稿、申请提交和报销草稿服务。
- [x] 前端新增 `executeStewardAction()` 并让 suggested action 携带服务端 action step。
- [x] AI 工作台点击 suggested action 时调用 `StewardActionExecutor`;直接提交先串行执行 precheck再把结果交给 submit。
- [x] 接入 `link_existing_application` 和附件关联的真实执行。
- [x] 把 action executor 包成 LangGraph `action_execute_node`,并接入 checkpoint / interrupt 恢复。
验收:
- [x] 模型不能通过自由文本触发数据库写入,服务端只输出白名单 `StewardActionStep`
- [x] 申请提交动作没有确认时返回 `needs_confirmation`,不会写库。
- [x] 申请提交动作没有 precheck 通过证据时被阻断。
- [x] 保存申请草稿和创建报销草稿通过现有业务服务真实入库,测试覆盖。
- [x] 前端点击可执行 steward action 会真实调用 executor并把结果回写到会话消息。
- [x] 基础副作用动作有 `client_trace_id` 幂等键、conversation checkpoint 和 pending interrupt 记录。
### Phase 4checkpoint 与 human-in-the-loop
目标:让 LangGraph 负责暂停、恢复和跨轮状态,而不是只靠前端消息状态。
文件:
- 已创建:`server/src/app/services/steward_graph_action_runtime.py`
- 已复用:`server/src/app/models/agent_conversation.py`
- 已测试:`server/tests/test_steward_action_executor.py`
步骤:
- [x] 设计 `conversation_id -> thread_id` 映射,当前直接以 `conversation_id` 作为 graph action thread。
- [x] 实现基于数据库的 checkpoint先复用 `AgentConversation.state_json`
- [x] 在提交审批缺确认时生成 pending interrupt payload。
- [x] 用户确认后用同一 `client_trace_id` / `conversation_id` 继续执行;终态请求会幂等重放。
验收:
- [x] 刷新页面后,后端仍保留当前等待确认的 action checkpoint。
- [x] 同一个 action 不会因为重复点击或重试重复写库。
- [ ] 前端完整恢复 UI 仍需继续把 checkpoint 状态展示为可恢复按钮。
### Phase 5可观测性与 legacy 收敛
目标:让 agent 规划过程可解释、可测试、可回放,并逐步删除重复手写编排。
文件:
- 修改:`server/src/app/services/agent_traces.py`
- 修改:`server/src/app/models/agent_run.py`
- 修改:`document/development/Agent链路追踪中心/CONCEPT.md`
- 测试:`server/tests/test_agent_traces.py`
步骤:
- [ ] 每个 graph node 写入 trace event输入摘要、输出摘要、耗时、错误。
- [ ] 前端显示“模型规划中 / 字段判断中 / 等待确认 / 执行动作”阶段。
- [ ] 对 legacy planner 增加废弃标记和删除条件。
- [ ] 删除重复的手写分支前,先保留至少一轮灰度开关。
验收:
- 任何一次用户请求都能从 trace 看出走过哪些 node。
- 删除 legacy 前LangGraph 路径覆盖当前核心申请/报销链路测试。
## 回退策略
- 环境变量:
```bash
STEWARD_AGENT_RUNTIME=legacy
```
- 回退后:
- `/steward/plans` 使用旧 `StewardPlannerService`
- 新 action node 和 checkpoint 不应影响 legacy。
- 回退原因必须写入当天工作日志。
## 验证矩阵
每个阶段至少跑:
```bash
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-local-linux \
timeout 60s /tmp/x-financial-server-venv/bin/pytest -q \
server/tests/test_steward_graph_planner.py \
server/tests/test_steward_planner.py \
server/tests/test_steward_intent_agent.py \
server/tests/test_runtime_chat_service.py
```
涉及前端执行器时补跑:
```bash
node --test web/tests/workbench-ai-intent-planner-model.test.mjs \
web/tests/workbench-ai-application-gate-model.test.mjs \
web/tests/steward-actions-service.test.mjs \
web/tests/steward-plan-message-copy.test.mjs
npm --prefix web run build
```
每次完成后执行:
```bash
git diff --check
docker exec -w /app -e SERVER_VENV_DIR=/tmp/x-financial-server-venv x-financial-local-linux \
/tmp/x-financial-server-venv/bin/python -m pip check
```
## 不做的事
- 不引入 LangChain 高层 Agent 来替代现有业务服务。
- 不让模型直接调用数据库写入、提交审批或绑定附件。
- 不为了接框架重写申请、报销、OCR、票据夹、审批规则。
- 不在没有测试覆盖时删除 legacy planner。
## 风险与处理
- 模型连通性不稳定:先修 provider 和 backup再扩大 graph 节点。
- checkpoint 与业务 state 双写不一致:先复用 `AgentConversation.state_json`,不要新增第二个业务状态源。
- action node 副作用重复执行:每个 action 必须有幂等键和执行前状态检查。
- LangGraph 传递依赖影响 WebSocket当前 `pip check` 通过;后续若流式接口异常,先核对 `websockets` 版本。
- 节点内继续堆大块逻辑:每个 node 只做一类事,超过 300 行先拆文件。

View File

@@ -0,0 +1,32 @@
# AI 意图规划器 TODO
## 第一阶段
- [x] 落地 AI 意图规划器概念文档,明确大模型负责拆计划、业务代码负责执行。
- [x] 新增前端 planner model统一模型计划和本地 fallback 的输出结构。
- [x] 个人工作台 AI 模式先请求 steward 模型计划,再使用本地 fallback。
- [x] 差旅申请直提链路改为消费 `intent plan.steps`,不再直接消费正则识别结果。
- [x] 补齐 planner model 与主流程接线回归测试。
- [x] 跑前端构建和定向测试。
- [x] 后端 `/steward/plans` 增加 `requested_action` 输出,减少前端从原句推断“提交/保存”的比例。
- [x] 引入 LangGraph并默认用 `StewardGraphPlannerService` 接管 `/steward/plans` 规划编排。
- [x] 落地 LangGraph runtime 迁移计划文档:`LANGGRAPH_RUNTIME_MIGRATION.md`
- [x] 新增 `StewardGraphRuntime`,接管 `slot-decisions``runtime-decisions` 的 LangGraph 路由。
- [x] 为 LangGraph runtime 增加图内规则兜底和端点级 legacy Agent 兜底。
- [x]`/steward/plans` 增加服务端白名单 `action_steps`,覆盖申请、报销、保存草稿和直接提交的基础动作规划。
- [x] 前端 planner model 优先消费服务端 `task.action_steps`,旧响应才回退本地步骤推导。
- [x] 新增 `/steward/actions/execute``StewardActionExecutor`,把未知 action 拒绝、提交确认门禁、precheck 阻断、申请草稿保存、申请提交和报销草稿创建接到现有业务服务。
- [x] 前端新增 `executeStewardAction()` 服务方法,并让 steward suggested action 携带服务端可执行 action step。
- [x] AI 工作台点击可执行 steward action 时调用 `/steward/actions/execute`;申请直接提交会先跑 `run_duplicate_precheck`,通过后再提交。
- [x] `complete_with_tool_call()` 增加 main 失败后 backup tool-call 成功路径测试,保证模型成功路径能从 backup 返回 `llm_function_call`
- [x] 新增 `StewardGraphActionRuntime`,把 action executor 包成 LangGraph `action_execute_node`,并用 `conversation_id + client_trace_id``AgentConversation.state_json` 中持久化 checkpoint。
- [x] 对提交确认生成 pending interrupt重复 client trace 直接重放 checkpoint避免重复保存草稿或重复提交。
- [x] `link_existing_application` 接入现有报销草稿创建链路并写入申请关联 flag。
- [x] `associate_attachments` 接入现有附件关联 runner按 receipt_ids 归集到可匹配报销草稿。
## 后续阶段
- [ ] 用真实 MiniMax / backup 配置再回放 `/steward/plans`,确认真实环境返回 `planning_source=llm_function_call`,不是规则兜底。
- [ ] 将当前 `AgentConversation.state_json` checkpoint 抽象为可替换的 durable checkpointer并补恢复/清理策略。
- [ ] 为每个 LangGraph node 写入可追溯 trace展示模型调用、规则降级、等待确认和动作执行结果。
- [ ] 在 LangGraph 路径覆盖申请/报销核心链路后,再逐步删除旧 `StewardPlannerService` 的重复编排。

View File

@@ -214,6 +214,188 @@
- 操作:重启当前实际运行容器 `x-financial-local-linux` 并确认 `/api/v1/health` 正常;真实 5173 `/api/v1/ocr/recognize` 重新上传 `2月23_上海-武汉.pdf` 后返回 `document_type=train_ticket``preview_kind=image``receipt_preview_url=/receipt-folder/.../preview`,对应 `/preview` 响应 `content-type: image/png`
- 影响AI 会话上传 PDF 火车票后,附件条和预览弹窗都会走统一预览资产判定;后续其它入口只要使用 `documentPreviewAssets.js`,就不会再各自维护一套 PDF/图片判断。
- 12:52我重新修改了一键关联票据对话框的 UI 样式,并根据“卡片过大、不够精致”的反馈进行了尺寸收敛与精细化微调。
- Git 提交检查:`git fetch --all --prune` 成功执行;`HEAD..origin/main``origin/main..HEAD` 均无提交。
- 修改:在 `receipt-folder-view.css` 中,为弹窗外框加上轻量圆角 (6px) 与精致投影;将弹窗 header padding 收至 `20px 24px 12px 24px`body padding 收至 `0 24px 20px 24px`,确保紧凑挺拔。
- 修改:将列表行间距设为 `8px`,每个条目高度收敛至 `52px`,内边距调整为 `10px 14px`,文件名与说明文本大小降为 `13px``11.5px`,以求更加精致细腻。
- 修改:将底部操作区的按钮高度设为标准 `36px`,水平内边距设为 `16px`,字重保持专业的 `500`,使比例更协调,消除挤压感的同时保留小巧玲珑感。
- 操作:执行前端生产编译构建 `npm run build` 并运行 `node --test web/tests/receipt-folder-view.test.mjs`
- 验证:`npm run build` 构建成功,单元测试通过,`git diff --check` 无输出。
- 影响:弹窗彻底摆脱了原本的拥挤挤压感,同时避免了尺度过大、粗糙的问题,整体显得小巧精致、清爽洗练。
- 13:03我把 AI 工作台里的“去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交”接成首轮完整申请链路,不再落回一步步追问。
- Git 提交检查:`git fetch --all --prune` 已在 Git 写权限升级后成功执行;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮只触碰 AI 工作台申请意图与测试文件。
- 修改:`workbenchAiApplicationGateModel.js` 新增 `resolveInlineTravelApplicationRequest()`,识别“出差/差旅/交通 + 具体地点或交通方式”的申请意图,并排除查标准、问制度、查进度等咨询类请求。
- 修改:`usePersonalWorkbenchAiMode.js` 在已有申请表动作之后、报销创建入口之前接入差旅申请识别;命中后直接调用 `startAiApplicationPreview()`,并把同句里的“直接提交”转成 `autoSubmit`
- 修改:`useWorkbenchAiApplicationPreviewFlow.js` 支持申请预览生成后自动提交;只有 `normalizeApplicationPreview(preview).readyToSubmit` 为真时才带 `confirmed: true` 复用现有提交前核查和提交动作,缺日期等必填项时仍停在核对表。
- 修改:`workbench-ai-application-gate-model.test.mjs``expense-application-fast-preview.test.mjs` 增加回归测试,锁定紧凑差旅直提句子的识别、分流和自动提交接线。
- 验证:`node --test web/tests/workbench-ai-application-gate-model.test.mjs` 通过 4/4`node --test --test-name-pattern "direct-submit" web/tests/expense-application-fast-preview.test.mjs` 通过 2/2`git diff --check -- ...` 无输出;`npm --prefix web run build` 构建通过。
- 验证补充:整跑 `node --test web/tests/expense-application-fast-preview.test.mjs` 时,本次新增用例通过,但该文件仍有 12 个既有失败,集中在其它文案/静态结构断言,已单独记录为遗留问题。
- 影响用户把差旅目的地、事由、交通方式和“直接提交”放在一句话里时AI 工作台会直接生成申请核对表;字段齐全时自动进入提交前核查和提交,不再用多轮问答确认同一件事。
- 13:20我先落了 AI 意图规划器文档,再把个人工作台 AI 模式改成“模型计划优先、规则兜底、执行器按步骤执行”的结构。
- Git 提交检查:`git fetch --all --prune` 首次被 `.git/FETCH_HEAD` 权限拦截,随后在升级权限下成功执行;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮新增 `AI意图规划器` 文档和 planner 接线。
- 修改:新增 `document/development/AI意图规划器/CONCEPT.md``TODO.md`,明确大模型负责识别动作与步骤,业务执行器负责白名单校验、必填校验、查重和提交。
- 修改:新增 `workbenchAiIntentPlannerModel.js`,把后端 `StewardPlanResponse` 或本地 fallback 统一归一为 `intent plan`,计划中包含 `intent``requestedAction``slots``missingFields``steps`
- 修改:`useWorkbenchAiStewardFlow.js` 增加 `resolveInlineExecutionPlan()`,复用现有 `/steward/plans` 大模型 function calling 能力,返回模型计划;调用失败或超时时由前端 fallback 接管。
- 修改:`usePersonalWorkbenchAiMode.js` 不再直接消费正则识别结果,而是先尝试 steward 模型计划,再用 `buildRuleFallbackWorkbenchAiIntentPlan()` 兜底,最后通过 `resolveExecutableTravelApplicationPlan()` 驱动申请预览和自动提交;模型规划等待期间会锁住发送态,避免重复触发。
- 修改:`workbench-ai-intent-planner-model.test.mjs` 增加模型计划归一化、fallback、政策咨询排除和主流程接线测试`expense-application-fast-preview.test.mjs` 的直提断言同步为 planner 语义。
- 验证:`node --test web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-application-gate-model.test.mjs` 通过 8/8`node --test --test-name-pattern "direct-submit" web/tests/expense-application-fast-preview.test.mjs` 通过 2/2`npm --prefix web run build` 构建通过;`git diff --check -- ...` 无输出。
- 验证补充:整跑 `expense-application-fast-preview.test.mjs` 仍有 12 个既有失败,当前与本次 planner 新增用例无关;本轮未在真实页面输入框回放模型返回路径。
- 影响:复杂意图链路现在有了清晰的模型计划适配层;后续扩展报销、附件归集、关联申请单时,可以继续让模型输出 steps再由确定性执行器逐步执行。
- 13:35我补修了 AI 意图规划入口过窄的问题让“2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。”这种轻微改写也会进入计划执行链路。
- Git 提交检查:`git fetch --all --prune` 普通沙箱首次失败,错误是 `.git/FETCH_HEAD: Operation not permitted`;随后在升级权限下成功执行。当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮只继续修改 AI 工作台意图规划、申请预览执行和对应测试。
- 修改:`workbenchAiIntentPlannerModel.js` 新增 `shouldRequestWorkbenchAiIntentPlan()`,把差旅、申请、交通方式、保存草稿、直接提交等财务执行候选先送进 steward 计划器;同时新增 `save_application_draft` step并把 `requestedAction=save_draft` 归一成可执行的 `autoSaveDraft`
- 修改:`workbenchAiApplicationGateModel.js` 放宽本地兜底识别,支持“上海出差”这种目的地前置写法和单独出现的“火车/高铁/飞机”等交通方式。
- 修改:`usePersonalWorkbenchAiMode.js` 不再只在 fallback 已经完整命中时才规划;只要 `shouldRequestWorkbenchAiIntentPlan()` 认为是财务执行候选,就先走模型计划,模型失败才用本地 fallback。
- 修改:`useWorkbenchAiApplicationPreviewFlow.js` 在生成申请核对表后支持 `autoSaveDraft`,直接复用现有 `AI_APPLICATION_ACTION_SAVE_DRAFT` 保存草稿动作;自动提交仍只在 `readyToSubmit` 为真时执行。
- 验证:`node --test web/tests/workbench-ai-intent-planner-model.test.mjs` 通过 5/5`node --test --test-name-pattern direct-submit web/tests/expense-application-fast-preview.test.mjs` 通过 2/2`node --test web/tests/workbench-ai-application-gate-model.test.mjs` 通过 4/4`npm --prefix web run build` 构建通过;`git diff --check -- ...` 无输出;尾随空白扫描无命中。
- 验证补充:用 Node 直接解析用户原句,结果为 `requestedAction=save_draft`steps 为 `build_application_preview -> validate_required_fields -> save_application_draft`,可执行参数为 `autoSubmit=false``autoSaveDraft=true`
- 影响用户一句话给日期、地点、事由、交通方式和“保存草稿”时AI 工作台会进入模型规划/兜底规划,生成申请核对表后自动保存草稿,不再因为表述顺序变化回到逐步问答。
- 13:58我按用户反馈重构了 AI 工作台意图入口,让用户话语进入后先走大模型 function calling 规划,再按模型拆出的动作和字段执行,规则只保留为模型失败后的降级。
- Git 提交检查:`git fetch --all --prune` 普通沙箱首次失败,错误是 `.git/FETCH_HEAD: Operation not permitted`;随后在升级权限下成功执行。当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮继续修改 steward function calling、AI 工作台 planner、申请预览字段接力和测试。
- 修改:后端 `StewardTask` 增加 `requested_action``StewardIntentAgent` 的 tool schema 要求模型输出 `preview/save_draft/submit``StewardModelPlanBuilder` 将模型动作写入任务,缺省时才按原文保守推断。
- 修改:`usePersonalWorkbenchAiMode.js` 将入口从“先构造 fallback 再规划”改成 `executeModelPlannedWorkbenchIntent()`:先调用 `resolveInlineExecutionPlan()`,再把模型 plan 归一为可执行申请;只有模型失败时才执行 `buildRuleFallbackWorkbenchAiIntentPlan()`,并保留模型识别到 reimbursement task 后进入原报销关联门。
- 修改:`workbenchAiIntentPlannerModel.js` 保留模型拆出的 canonical ontology fields`shouldRequestWorkbenchAiIntentPlan()` 改为普通有效输入都先进入 planner不再把政策查询等话术排除在规划入口外。
- 修改:`workbenchAiApplicationPreviewModel.js``useWorkbenchAiApplicationPreviewFlow.js` 支持把模型拆出的 `time_range/location/reason/transport_mode` 转为申请预览 ontology再生成核对表避免继续只靠本地文本解析导致地点和事由粘连。
- 测试:先让 `workbench-ai-intent-planner-model.test.mjs` 红灯失败,确认旧实现缺少 `ontologyFields`、政策查询被排除、入口仍携带 `fallbackIntentPlan`;随后实现并跑绿。
- 验证:`node --test web/tests/workbench-ai-intent-planner-model.test.mjs` 通过 7/7`node --test --test-name-pattern direct-submit web/tests/expense-application-fast-preview.test.mjs` 通过 2/2`node --test web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-application-gate-model.test.mjs` 通过 11/11容器 `x-financial-local-linux``pytest -q server/tests/test_steward_intent_agent.py server/tests/test_steward_planner.py` 通过 27/27容器内 `py_compile` 通过;`npm --prefix web run build` 构建通过;`git diff --check` 无输出,尾随空白扫描无命中。
- 验证补充:用 Node 直接归一模型 plan用户原句输出 `requestedAction=save_draft``ontologyFields.time_range=2026-02-20 至 2026-02-23``location=上海``reason=国网仿生产服务器部署``transport_mode=火车``autoSaveDraft=true`
- 影响AI 工作台不会再“瞬间靠规则识别并执行”;可执行链路的第一步是模型 function calling 规划,模型负责拆动作和字段,前端执行器只消费结构化 plan 并调用确定性申请/报销流程。
- 14:33我按“每次先经过大模型规划10s 超时、最多 3 次重试后才规则降级”的要求收紧了 AI 工作台意图规划链路。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮继续修改 steward planner/runtime、申请字段解析、AI 工作台等待时间和相关测试。
- 修改:`StewardPlannerService.build_plan()` 改成先调用模型 intent agent再根据模型结果或失败原因进入 off-topic/rule fallback不再只对多任务或显式动作走模型。
- 修改:`StewardIntentAgent` 的 function calling 调用改为 `timeout_seconds=10``max_attempts=3`,并通过 `RuntimeChatService.complete_with_tool_call(use_failure_cooldown=False)` 避免第 1 次失败后第 2、3 次被 cooldown 跳过。
- 修改:`useWorkbenchAiStewardFlow.js` 将 inline execution plan 等待时间从 12s 调到 35s覆盖后端 3 次 10s 模型等待,避免前端提前本地 fallback。
- 修改:`ApplicationFactResolver``StewardPlannerExtractionMixin` 的规则降级补齐 `requested_action`、完整日期范围和申请事由清洗;即使模型 3 次超时也能把“保存草稿”“2026-02-20 至 2026-02-23”“国网仿生产服务器部署”“火车”传给执行器。
- 测试:先新增红灯测试覆盖“单一出差描述也要先调用模型”“模型调用参数为 10s/3 次”“tool-call 规划可关闭 cooldown 连续重试”“前端等待 35s”“规则降级保留保存草稿和日期范围”随后实现并跑绿。
- 验证:容器内 `pytest -q server/tests/test_runtime_chat_service.py server/tests/test_steward_intent_agent.py server/tests/test_steward_planner.py` 通过 38/38前端 `node --test web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-application-gate-model.test.mjs` 通过 11/11`npm --prefix web run build` 通过;`git diff --check -- ...` 无输出。
- 真实接口验证:重启 `x-financial-local-linux` 后直接请求 5173 的 `/api/v1/steward/plans`,原句耗时 32.08sMiniMax `attempt=1/2/3` 均约 10.2s 超时后才返回 `planning_source=rule_fallback`;返回任务已包含 `requested_action=save_draft`、完整 `time_range=2026-02-20 至 2026-02-23``location=上海``reason=国网仿生产服务器部署``transport_mode=train`
- 影响:用户不会再看到毫秒级规则直出;当前模型服务超时时会先完整等待三次,再用可执行的基础规则计划继续流程。
- 15:14我把小财管家规划入口第一阶段迁到 LangGraph默认用框架接管规划编排同时保留 legacy 回退。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮新增 LangGraph 依赖、graph planner、runtime 开关和测试。
- 修改:新增 `StewardGraphPlannerService`,用 LangGraph `StateGraph` 编排 `prepare_context -> detect_model_intent -> off_topic/rule_fallback/完成`,不再把规划主流程继续写在一个手工 `if/else` 方法里。
- 修改:`/steward/plans``/steward/plans/stream` 的 planner 工厂默认返回 `StewardGraphPlannerService``STEWARD_AGENT_RUNTIME=legacy` 可回退到旧 `StewardPlannerService`
- 修改:`server/pyproject.toml``server/uv.lock``egg-info` 同步加入 `langgraph>=1.2,<2.0``.env.example` 增加 `STEWARD_AGENT_RUNTIME=langgraph`
- 测试:先新增 `test_steward_graph_planner.py` 红灯测试,覆盖 LangGraph planner 的模型成功路径、模型无 tool call 后规则降级、默认 LangGraph、显式启用 LangGraph 和 legacy 回退;随后实现并跑绿。
- 验证:容器内 `pytest -q server/tests/test_steward_graph_planner.py server/tests/test_steward_planner.py server/tests/test_steward_intent_agent.py server/tests/test_runtime_chat_service.py server/tests/test_config_settings_reload.py` 通过 45/45`ruff check --ignore E501 ...` 通过;`git diff --check` 无输出;`pip check` 无 broken requirements。
- 真实接口验证:通过真实 5173 `/api/v1/steward/plans` 回放保存草稿原句,服务端仍先等待 MiniMax 3 次 10s 超时,再返回 `rule_fallback`;返回字段包含 `requested_action=save_draft`、完整日期范围、上海、事由和 `transport_mode=train`。容器内直接探针确认默认 planner 类型为 `StewardGraphPlannerService`
- 影响:小财管家的规划骨架已经由 LangGraph 承接;后续可以继续把 slot/runtime decision、checkpoint 记忆、human-in-the-loop 和 action node 迁到框架里,而不是继续扩写自研分支编排。
- 15:22我按“先落文档文档完了再继续开发”的要求补齐 LangGraph 后续迁移文档。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮只修改 AI 意图规划器文档和当天工作日志。
- 修改:新增 `LANGGRAPH_RUNTIME_MIGRATION.md`,记录当前已完成的 LangGraph 第一阶段、目标架构、runtime state 草案、节点边界、Phase 1-5 迁移计划、回退策略、验证矩阵和风险处理。
- 修改:更新 `CONCEPT.md`,删除“非目标:不新增 LangGraph”的过期表述改为不引入 LangChain 高层 Agent、不让 LangGraph 直接接管密钥或副作用,并补充后续 node 迁移方向。
- 修改:更新 `TODO.md`,把 `requested_action`、LangGraph 接入和迁移文档标为完成并新增模型成功路径、slot/runtime node、action node、checkpoint、人审中断和 trace 的后续事项。
- 操作:查询 LangGraph 官方文档,确认 Graph API、persistence 和 interrupt 的能力边界;读取当前 `StewardGraphPlannerService` 与已有 AI 意图规划文档后再落文档。
- 验证:`rg` 确认旧“不能新增 LangGraph”说法没有残留`git diff --check -- document/development/AI意图规划器/...` 无输出;新增迁移文档 356 行。
- 影响:后续开发有了明确的执行顺序和验收标准;下一步应先按文档 Phase 1 修通真实 `llm_function_call` 成功路径,再迁 slot/runtime/action/checkpoint。
- 15:31我继续把小财管家会话内的槽位识别、运行时记忆合并和行动决策入口迁到 LangGraph并加了图内和端点级双层兜底。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮新增 graph runtime、runtime 测试并更新 AI 意图规划器文档。
- 修改:新增 `StewardGraphRuntime`,用 LangGraph `StateGraph` 编排 `slot_prepare_context -> slot_tool_decision -> done/rule_fallback`,把原 `StewardSlotDecisionAgent` 包成图节点;工具节点异常时会带 `langgraph_slot_decision` 失败 trace 进入规则兜底。
- 修改:`StewardGraphRuntime` 运行时图新增 `runtime_memory_context -> runtime_action_decision/runtime_tool_decision -> done/rule_fallback`,先归一化并合并 `steward_state`,已选流程确认走确定性 action 节点,不再调用模型;普通补字段/确认继续由 runtime tool 节点判断。
- 修改:`/steward/slot-decisions``/steward/runtime-decisions` 默认使用 `StewardGraphRuntime`;如果 LangGraph 实例化或执行异常,端点 helper 会退回旧 `StewardSlotDecisionAgent` / `StewardRuntimeDecisionAgent`,保证框架失效时仍能实际响应。
- 测试:先新增 `test_steward_graph_runtime.py` 红灯测试,覆盖槽位工具节点、图内规则兜底、运行时记忆合并、确定性行动选择,以及端点 helper 在 graph runtime 失败后 legacy 兜底;随后实现并跑绿。
- 文档:更新 `LANGGRAPH_RUNTIME_MIGRATION.md` 的当前状态和 Phase 2 完成项;更新 `CONCEPT.md` 的 LangGraph 节点命名;更新 `TODO.md` 勾掉 slot/runtime graph runtime 迁移,保留副作用 action node、checkpoint、人审中断和 trace 后续事项。
- 验证:容器内 `pytest -q server/tests/test_steward_graph_runtime.py server/tests/test_steward_graph_planner.py server/tests/test_steward_slot_decision_agent.py server/tests/test_steward_runtime_decision_agent.py server/tests/test_steward_planner.py server/tests/test_steward_intent_agent.py server/tests/test_runtime_chat_service.py server/tests/test_config_settings_reload.py` 通过 59/59`ruff check --ignore E501 ...` 通过;`git diff --check` 无输出;`pip check` 无 broken requirements。
- 影响:会话内“识别缺什么、读记忆、判断补字段/继续/确认”的入口已经被 LangGraph 接管;当前仍没有让 LangGraph 直接执行保存草稿、提交审批、关联申请单等副作用动作。
- 15:53我把 `/steward/plans` 的基础业务动作规划补成服务端白名单 `action_steps`,让申请、报销、保存草稿和直接提交都有明确动作序列。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮新增 action contract、扩展 steward schema、更新前端 planner model、修复申请事由清洗并同步文档。
- 修改:`StewardTask``StewardPlanResponse` 增加 `action_steps`;新增 `StewardActionStep``StewardActionType``StewardActionStatus`,把可执行动作显式收进 Pydantic 协议。
- 修改:新增 `StewardActionPlanBuilder`,由服务端确定性生成白名单动作:申请直接提交输出 `fill_application_fields -> build_application_preview -> validate_required_fields -> run_duplicate_precheck -> submit_application`;保存草稿输出 `save_application_draft`;报销输出 `fill_reimbursement_fields -> build_reimbursement_preview -> validate_required_fields -> create_reimbursement_draft`
- 修改:`StewardGraphPlannerService` 增加 `attach_action_steps` 节点,模型成功、模型失败兜底和 off-topic 都统一经过动作规划节点legacy `StewardPlannerService` 返回前也补同一套 action contract。
- 修改:`workbenchAiIntentPlannerModel.js` 优先消费服务端 `task.action_steps`,旧响应才回退本地 `requested_action` 推导;这样后端 LangGraph 规划出的动作能真正驱动前端执行计划。
- 修改:`ApplicationFactResolver` 修复“交通火车”污染申请事由的问题,同时保留“高铁往返”这类业务描述;新增解析回归测试。
- 测试:先新增红灯测试,覆盖申请直接提交 action steps、保存草稿 action steps、报销 action steps、前端优先消费服务端 action steps、`交通火车` 事由清洗;随后实现并跑绿。
- 验证:容器内 `pytest -q server/tests/test_application_fact_resolver.py server/tests/test_steward_graph_runtime.py server/tests/test_steward_graph_planner.py server/tests/test_steward_slot_decision_agent.py server/tests/test_steward_runtime_decision_agent.py server/tests/test_steward_planner.py server/tests/test_steward_intent_agent.py server/tests/test_runtime_chat_service.py server/tests/test_config_settings_reload.py` 通过 66/66前端 `node --test web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-application-gate-model.test.mjs` 通过 12/12`npm --prefix web run build` 通过;`ruff check --ignore E501 ...` 通过;`git diff --check` 无输出。
- 真实接口验证:重启 `x-financial-local-linux` 后回放 `2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交`,真实 5173 `/api/v1/steward/plans` 返回 `requested_action=submit`,字段为 `time_range=2026-02-20 至 2026-02-23``location=上海``reason=辅助国网仿生产服务器部署``transport_mode=train`,动作序列为 `detect_intent:completed -> fill_application_fields:planned -> build_application_preview:planned -> validate_required_fields:planned -> run_duplicate_precheck:planned -> submit_application:pending_confirmation`
- 影响:基础业务已经不是“只识别一个 requested_action”了后端计划会直接给出可执行动作序列当前仍只规划动作不直接执行保存草稿或提交审批。
- 16:13我把小财管家的白名单 action 从“只规划”推进到“可执行 executor”让申请草稿、申请提交门禁和报销草稿能通过统一 `/steward/actions/execute` 入口处理。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮新增 action executor、执行接口、前端执行服务和相关测试。
- 修改:新增 `StewardActionExecuteRequest` / `StewardActionExecuteResponse`,并在 `steward.py` 暴露 `/steward/actions/execute`;新增 `StewardActionExecutor`,统一处理未知 action 拒绝、缺字段阻断、提交确认门禁、precheck 阻断和业务服务调用。
- 修改:`save_application_draft` / `submit_application` 复用现有 `UserAgentService` 申请保存/提交能力;`create_reimbursement_draft` 复用 `ExpenseClaimService.save_or_submit_from_ontology()`,避免再写一套入库逻辑。
- 修改:`associate_attachments``link_existing_application` 保留在执行白名单里,但在真实接线完成前统一返回阻断,不允许以 noop 形式“假成功”或产生隐式副作用。
- 修改:前端 `steward.js` 新增 `executeStewardAction()``stewardPlanModel.js` 保留服务端 `action_steps`,并在 suggested action payload 中携带最终可执行 action step旧申请预览流继续保留为兜底。
- 修改:`useWorkbenchAiActionRouter.js` 接入 `steward_execute_action` 点击执行;申请直接提交会先调用 `run_duplicate_precheck`precheck 通过后再把结果交给 `submit_application`,执行结果回写到当前 AI 会话。
- 修改:`test_reimbursement_endpoints.py` 两个申请快路径断言从旧 `AP-` 前缀同步到当前短单号 `A...` 规范。
- 文档:更新 `LANGGRAPH_RUNTIME_MIGRATION.md``TODO.md``CONCEPT.md`,标记第一版 action registry / executor 完成,并把 checkpoint、interrupt、关联申请和附件真实执行列为后续。
- 验证:容器内 `pytest -q server/tests/test_steward_action_executor.py` 通过 5/5迁移集合 `pytest -q server/tests/test_application_fact_resolver.py server/tests/test_steward_action_executor.py server/tests/test_steward_graph_runtime.py server/tests/test_steward_graph_planner.py server/tests/test_steward_slot_decision_agent.py server/tests/test_steward_runtime_decision_agent.py server/tests/test_steward_planner.py server/tests/test_steward_intent_agent.py server/tests/test_runtime_chat_service.py server/tests/test_config_settings_reload.py` 通过 71/71。
- 验证:前端 `node --test web/tests/workbench-ai-action-router.test.mjs web/tests/steward-actions-service.test.mjs web/tests/steward-plan-message-copy.test.mjs web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-application-gate-model.test.mjs` 通过 21/21`npm --prefix web run build` 通过;`ruff check --ignore E501 ...` 通过;`git diff --check` 无输出。
- 真实接口验证:重启 `x-financial-local-linux` 后,真实 5173 `/api/v1/steward/plans` 仍返回直接提交 action 链;`/api/v1/steward/actions/execute` 对未确认的 `submit_application` 返回 `needs_confirmation`,没有执行入库副作用。
- 影响:基础业务链路已经具备“规划 action -> 执行器门禁 -> 复用现有业务服务”的后端闭环;下一步需要把 executor 包进 LangGraph checkpoint / interrupt并补齐关联申请和附件关联的真实执行。
- 16:40我把 action executor 包成 LangGraph 可执行节点,并补完 checkpoint、人工确认中断、幂等重放、关联申请单和附件关联三阶段收尾。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮新增 `StewardGraphActionRuntime`,扩展 action executor 测试并更新 AI 意图规划器文档。
- 修改:新增 `StewardGraphActionRuntime`,用 LangGraph `StateGraph` 编排 `action_checkpoint_load -> action_execute_node -> action_checkpoint_persist`,并把 `/steward/actions/execute` 默认切到这个 graph runtime。
- 修改:复用 `AgentConversation.state_json` 持久化 `steward_action_checkpoint`;同一 `conversation_id + client_trace_id` 的已完成 action 会直接重放终态结果,`needs_confirmation` 会保存 pending interrupt后续同 trace 完成后清理对应中断。
- 修改:`link_existing_application` 走报销草稿真实创建链路并写入申请关联风险标记;`associate_attachments` 接入 `AttachmentAssociationJobRunner`,无 `receipt_ids` 时阻断,避免假成功。
- 修改:为 `complete_with_tool_call()` 补上主模型失败后 backup 返回 tool call 的回归测试,锁住“模型规划失败优先重试/切 backup再降级规则”的成功路径。
- 文档:更新 `LANGGRAPH_RUNTIME_MIGRATION.md``TODO.md``CONCEPT.md`,把 action node、checkpoint、pending interrupt、幂等重放、关联申请单和附件关联标为已完成同时保留真实模型配置未修通的验收缺口。
- 验证:容器内 `pytest -q server/tests/test_steward_action_executor.py` 通过 9/9`pytest -q server/tests/test_runtime_chat_service.py::test_runtime_chat_complete_with_tool_call_fails_over_to_backup_before_retrying_main` 通过 1/1。
- 验证:迁移集合 `pytest -q server/tests/test_application_fact_resolver.py server/tests/test_steward_action_executor.py server/tests/test_steward_graph_runtime.py server/tests/test_steward_graph_planner.py server/tests/test_steward_slot_decision_agent.py server/tests/test_steward_runtime_decision_agent.py server/tests/test_steward_planner.py server/tests/test_steward_intent_agent.py server/tests/test_runtime_chat_service.py server/tests/test_config_settings_reload.py` 通过 76/76附件/关联任务 `pytest -q server/tests/test_attachment_association_jobs.py server/tests/test_linked_reimbursement_draft_jobs.py` 通过 7/7。
- 验证:前端 `node --test web/tests/workbench-ai-action-router.test.mjs web/tests/steward-actions-service.test.mjs web/tests/steward-plan-message-copy.test.mjs web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-application-gate-model.test.mjs` 通过 21/21`ruff check --ignore E501 ...` 通过;`npm --prefix web run build` 通过;`git diff --check` 无输出。
- 真实接口验证:重启 `x-financial-local-linux` 后,真实 5173 `/steward/actions/execute` 对同一未确认 `submit_application` trace 第二次返回 `needs_confirmation``result_payload.idempotent_replay=true`,证明 checkpoint 重放生效。
- 真实接口验证:真实 5173 `/steward/plans` 回放“2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿”仍因 backup skipped、MiniMax 三次失败后进入 `rule_fallback`,动作链和字段可用,但 `planning_source=llm_function_call` 仍需修模型配置。
- 影响申请、报销、保存草稿、直接提交、关联申请单、附件关联已经具备“LangGraph 规划/行动节点 -> 业务服务执行 -> checkpoint/interrupt 兜底”的基础可测闭环;真实大模型成功路径还差外部模型连通性。
- 16:53我修复 AI 模式输入后按回车“看起来没响应”的问题,把模型规划等待期改成先显示对话反馈。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮只改 AI 模式前端等待反馈和对应测试/日志。
- 根因:`executeModelPlannedWorkbenchIntent()` 在等待 `/steward/plans` 返回前只设置 `sending=true`,没有先激活对话、清空输入框、展示用户气泡或 pending 卡片;真实 MiniMax 三次超时会让用户看到 30 多秒“无响应”。
- 修改:`usePersonalWorkbenchAiMode.js` 新增 `startModelPlanningConversation()`,进入模型规划前立即推入用户消息和“正在识别意图,准备拆解申请、报销和附件任务。”的 pending 卡片,并持久化到最近对话。
- 修改:`startModelPlannedApplicationPreview()` 把规划 pending 的 `pendingMessageId` 传给申请预览;`useWorkbenchAiApplicationPreviewFlow.js` 支持复用这张 pending 卡片,避免重复用户消息和重复等待卡。
- 测试:先新增 `workbench AI mode shows a visible planning response before waiting for steward model plan` 红灯测试,确认当前缺少即时反馈;实现后同文件 9/9 通过。
- 验证:前端相关集合 `node --test web/tests/workbench-ai-action-router.test.mjs web/tests/steward-actions-service.test.mjs web/tests/steward-plan-message-copy.test.mjs web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-application-gate-model.test.mjs web/tests/workbench-ai-composer-components.test.mjs` 通过 31/31`npm --prefix web run build` 通过;`git diff --check` 无输出。
- 真页验证:使用本地 5173 和本地模拟员工账号登录 AI 模式输入“2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿”并按回车1.5 秒内看到用户气泡、输入框清空和 pending 卡片;约 38 秒后仍正常保存草稿,草稿单号 `AZ8QSX9QA`
- 影响:用户按回车后不再空等模型规划,长耗时仍存在但前端会立即反馈“已接收并正在规划”;真实 `llm_function_call` 成功路径仍依赖模型配置修复。
- 16:59我修复了 AI 模式普通问候被拆成两条助手消息的问题,把规划、工具/行动整理和最终回复收敛到同一张助手卡片。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮只继续修改 AI 模式前端消息复用、思考事件合并和对应测试。
- 根因16:53 的等待态修复先创建了模型规划 pending 卡片,但普通管家回复路径会把这张卡片替换成“已完成意图识别,继续为您整理回复。”,随后 `requestInlineAssistantReply()` 又新建第二张助手卡片;所以用户输入“你好”时会看到两段会话。
- 修改:`usePersonalWorkbenchAiMode.js` 删除中间态独立回复,普通管家回复也把 `pendingMessageId` 传给 `requestInlineAssistantReply()`,让同一轮输入继续复用规划 pending 卡片。
- 修改:`useWorkbenchAiStewardFlow.js` 支持 `requestInlineAssistantReply(prompt, entry, files, options)`,当传入 `pendingMessageId` 时替换原 pending 卡片而不是追加新消息;新增 `mergeInlineThinkingEvents()` 合并模型规划和管家回复思考事件,避免同一事件重复或丢失。
- 测试:先新增红灯测试 `workbench AI mode reuses planning pending message for regular steward replies`,确认普通回复必须复用规划 pending实现后同文件 10/10 通过。
- 验证:前端集合 `node --test web/tests/workbench-ai-action-router.test.mjs web/tests/steward-actions-service.test.mjs web/tests/steward-plan-message-copy.test.mjs web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-application-gate-model.test.mjs web/tests/workbench-ai-composer-components.test.mjs` 通过 32/32`npm --prefix web run build` 通过;`git diff --check` 无输出。
- 真页验证:本地 5173 AI 模式输入“你好”后1.5 秒内只有 1 张 pending 助手卡;最终回复后 `assistantCardCount=1``pendingCards=0`、思考区标题为“小财业务思考5 条”,截图保存为 `/tmp/x-financial-ai-single-message-hello.png`
- 影响:一次用户输入现在对应一条助手消息;意图识别、规划等待、普通回复整理都会落在同一个思考区域,不再把“已完成意图识别”单独展示成一轮会话。
- 17:26我把 AI 模式的规划等待态从静态“思考中”改成持续追加过程摘要,让用户能看到意图判断、字段抽取、动作规划和兜底策略正在推进。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有多项既有未提交改动,本轮只继续修改 AI 模式前端 thinking 事件、流式规划接线和对应测试。
- 根因:`executeModelPlannedWorkbenchIntent()` 虽然会先创建 pending 卡片,但 `resolveInlineExecutionPlan()` 仍走非流式 `/steward/plans`,模型等待期间只有一条静态 thinking等计划返回后才一次性显示完整步骤用户会感觉像假思考。
- 修改:新增 `workbenchAiPlanningThinkingModel.js`,集中维护可复用的 thinking 事件合并、完成态转换、初始意图判断和阶段性进度计划,避免继续把主 composable 写大。
- 修改:`usePersonalWorkbenchAiMode.js` 在模型规划 pending 卡片上启动可取消的阶段性进度:判断办理意图、抽取关键信息、规划执行步骤、匹配业务工具、准备兜底策略;模型返回或进入 fallback 后会清理定时器。
- 修改:`useWorkbenchAiStewardFlow.js` 支持 `resolveInlineExecutionPlan(prompt, entry, files, { pendingMessageId })`;传入 pending id 时改走 `/steward/plans/stream`,只把服务端 thinking 实时写回当前卡片,不把 answer delta 提前塞进规划消息。
- 修改:`useWorkbenchAiApplicationPreviewFlow.js` 生成申请核对表时保留并完成前置规划 thinking再追加“整理申请表字段 / 同步费用测算”,避免申请表阶段把前面的规划过程清掉。
- 测试:新增 `workbench AI mode streams planning thinking into the pending message`,锁定阶段性 thinking、可取消定时器、流式规划接线和申请预览 thinking 合并;同文件 11/11 通过。
- 验证:相关前端集合 `node --test web/tests/workbench-ai-action-router.test.mjs web/tests/steward-actions-service.test.mjs web/tests/steward-plan-message-copy.test.mjs web/tests/workbench-ai-intent-planner-model.test.mjs web/tests/workbench-ai-application-gate-model.test.mjs web/tests/workbench-ai-composer-components.test.mjs` 通过 33/33`npm --prefix web run build` 通过;`git diff --check` 无输出。
- 真页验证:本地 5173 干净会话输入“2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。”后thinking 数量按时间增长:点击后 2 条、1.1 秒 3 条、2.5 秒 4 条、5.7 秒 5 条、9.6 秒 6 条;约 36 秒后仍成功保存草稿,草稿单号 `AZ4A98BSF`,截图保存为 `/tmp/x-financial-ai-progressive-thinking.png`
- 验证补充:我额外跑了 `workbench-ai-mode-switch.test.mjs`,该文件仍有 2 个既有静态断言失败,断言点还停在旧的内联附件卡片和旧的 `selectedFiles` watch 结构;本轮相关测试和真页回放不受影响。
- 影响:用户现在不再只看到静态“思考中”,而是能在同一张思考卡片里持续看到业务意图、槽位、动作链和兜底策略的推进过程;服务端流式 thinking 到达后也会合并进同一个列表。
- 17:40我修复了 AI 工作台保存申请草稿后,再输入“提交这个单据”会重新进入意图规划的问题。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有大量既有未提交改动,本轮只补 AI 工作台申请上下文提交识别、申请预览执行分支和对应测试。
- 根因:`resolveInlineApplicationPreviewTextAction()` 只识别“提交 / 确认提交 / 直接提交”等精确词,用户输入“提交这个单据”会绕过当前申请核对表上下文,继续进入 `shouldRequestWorkbenchAiIntentPlan()` 和小财管家规划,导致再次识别意图并等待模型。
- 修改:`workbenchAiApplicationGateModel.js` 把“提交这个单据 / 提交这个申请单 / 提交当前单据 / 提交刚才的草稿”等上下文指代短句映射为 `AI_APPLICATION_ACTION_SUBMIT`;同类保存指代短句也映射到保存草稿动作。
- 修改:`useWorkbenchAiApplicationPreviewFlow.js` 在提交动作里新增“已保存草稿 + 上下文提交短句”快路径;当最近申请核对表已有 `draftPayload.claim_id` 或单号,且用户明确说提交当前/刚才的单据时,跳过二次确认弹窗,直接执行提交前核查和 `/reimbursements/application-preview-action`,并带上 `application_edit_claim_id` 复用原草稿。
- 测试:先新增 `workbench-ai-application-context-submit.test.mjs` 红灯测试,复现“已有草稿 + 提交这个单据”只打开确认弹窗、不调用提交接口;实现后该测试通过,并确认 payload 写入 `application_edit_claim_id=claim-saved-draft`
- 验证:`node --test web/tests/workbench-ai-application-context-submit.test.mjs` 通过;`node --test web/tests/workbench-ai-application-gate-model.test.mjs` 通过;相邻 `workbench-ai-action-router.test.mjs``workbench-ai-intent-planner-model.test.mjs``ai-application-preview-actions.test.mjs` 均通过;本轮改动文件 `git diff --check -- ...` 无输出。
- 验证补充:`workbench-ai-mode-expense-scene-action.test.mjs``expense-application-fast-preview.test.mjs` 仍有既有静态断言失败,主要还在扫描旧 `PersonalWorkbenchAiMode.vue` 或过窄源码片段;新增的上下文提交行为测试已覆盖真实执行路径。
- 影响用户先说“2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车保存草稿”草稿保存后再说“提交这个单据”前端不会再重新规划而会把“这个单据”绑定到最近申请草稿并提交到审批链路。
- 17:48我修复了“完整出差信息但未显式说申请/报销”时AI 模式停在候选流程确认、不直接推进申请预览的问题。
- Git 提交检查:`git fetch --all --prune` 成功;当前 upstream 为 `origin/main``HEAD..@{u}``@{u}..HEAD` 均未输出新提交;工作区仍有大量既有未提交改动,本轮只继续修改 `workbenchAiIntentPlannerModel.js` 和对应意图规划测试。
- 根因:后端已经返回 `pending_flow_confirmation`,且候选流只有 `travel_application / 先发起出差申请`,但前端 `normalizeWorkbenchAiIntentPlan()` 只识别 `tasks`,没有把这个唯一候选流转成可执行申请计划,最后又回到普通 steward 回复路径。
- 修改:`workbenchAiIntentPlannerModel.js` 增加 pending flow 候选解析;当 `pending_flow_confirmation.status=pending` 且唯一候选为“先发起出差申请”时,直接生成 `create_travel_application` 计划,包含 `build_application_preview`、必填校验、模型抽取的时间/地点/事由/交通方式。
- 测试:先新增红灯用例 `workbench AI intent planner turns single application candidate flow into executable preview payload`,复现该计划原本返回 `null`;实现后同文件 12/12 通过。
- 验证:`node --test web/tests/workbench-ai-intent-planner-model.test.mjs``node --test web/tests/workbench-ai-application-gate-model.test.mjs``node --test web/tests/workbench-ai-application-context-submit.test.mjs``node --test web/tests/steward-plan-model-pending-flow.test.mjs` 均通过;`git diff --check -- web/src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js web/tests/workbench-ai-intent-planner-model.test.mjs` 无输出。
- 影响用户输入“2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车”时如果门禁已查明没有可关联申请单前端会把“应先申请单据”直接落到申请预览链路不再表现成只会反复识别意图。
## 遗留问题
- 09:41当前 Skill 是新建在项目级 `.codex/skills` 目录里,本轮可以通过文件检查验证结构,但是否被未来会话自动加载还依赖 Codex 对项目 Skill 的刷新机制。建议后续新开会话或下一次任务时确认 Skill 列表是否出现 `agent-change-log`
@@ -236,6 +418,26 @@
- 11:39本轮新增的附件归集回归测试还没有在容器内真正执行原因仍是 Docker socket 权限拒绝。建议 Docker 权限恢复后优先运行 `server/tests/test_attachment_association_jobs.py` 新增两条测试,并在 5173 重新打开刚才那张坏票据验证自动修复是否生效。
- 11:55当前实际运行容器仍叫 `x-financial-local-linux`,且容器内 `poppler-data` 未安装、`mutool` 不存在;本轮文本层兜底已恢复 OCR 字段,但 PDF 图片预览仍会带转图失败 warning。12:04 已在当前运行容器补齐依赖并刷新本批票据预览;建议后续仍按最新 compose 统一到 `local-x-financial-linux`,避免旧容器继续抢占 5173。
- 12:23本轮没有拿到可用的浏览器自动化插件来生成真页截图已用前端构建、组件测试和真实 5173 OCR/preview 接口替代验证。建议用户侧刷新页面后重新上传同类 PDF若历史会话里旧附件卡片仍停留在旧状态则重新选择附件触发 OCR 状态刷新。
- 13:03`expense-application-fast-preview.test.mjs` 整文件仍有 12 个既有失败,失败点包括 unsupported business guidance 文案、旧申请 session 静态结构、typewriter/table 静态断言等;本次新增的直提申请用例已通过。建议后续单独清理这些陈旧断言,避免掩盖真正的申请链路回归。
- 13:20本轮完成了模型计划接线和结构测试但没有在真实 5173 页面输入框回放 `/steward/plans` 返回 `llm_function_call` 的端到端路径。建议后续用真实模型配置测试一次“去上海出差...直接提交”,确认模型返回任务计划后前端进入申请核对表。
- 13:35本轮已经用模型规划入口、fallback 解析和前端构建验证了“保存草稿”变体,但还没有在真实 5173 输入框回放一次完整点击/保存草稿链路。建议后续用当前句子做真页验证,确认草稿保存后出现申请单详情入口。
- 13:58本轮已经用容器后端测试和前端模型计划归一测试验证 function calling contract但还没有在真实 5173 页面观察 `/steward/plans` 的网络返回是否包含新的 `requested_action`。建议后续打开浏览器 DevTools 或后端日志,用真实模型配置回放一次保存草稿话术。
- 14:33真实接口已确认模型必经和 3 次重试,但当前主模型 MiniMax 连续超时backup 槽位 API key 未解密/未配置,因此仍然进入 `rule_fallback`。建议下一步先修复模型连通性或配置可用 backup再确认返回 `planning_source=llm_function_call` 的成功路径。
- 14:33用户提出 LangChain 方向是合理的但本轮没有直接引入新依赖。15:14 已完成 LangGraph 第一阶段迁移;后续仍要继续补 memory/checkpoint、action node 和人审中断能力。
- 15:14LangGraph 第一阶段只替换了 `/steward/plans` 的规划编排骨架,还没有把 slot decision、runtime decision、草稿保存/提交 action 和跨轮 checkpoint 迁入 graph。建议下一步按节点边界继续拆迁避免又在 LangGraph node 内写回大块自研流程。
- 15:14`langgraph` 依赖把 `websockets` 锁到 15.0.1;当前 `pip check` 和 steward 相关测试通过,但如果后续实时流或 WebSocket 功能异常,需要优先核对这个依赖变化。
- 15:22本轮只补迁移文档没有继续实现 Phase 1-5。建议下一轮严格按 `LANGGRAPH_RUNTIME_MIGRATION.md` 从模型成功路径开始推进,不要跳过文档直接扩写 graph node。
- 15:31当前 `StewardGraphRuntime` 已覆盖 slot/runtime decision但保存草稿、提交审批、关联申请单等副作用动作还只是下游既有服务执行并未接成 LangGraph 白名单 action node。建议下一步优先实现 `steward_action_nodes.py` / action contract再接 checkpoint 和 human-in-the-loop。
- 15:53当前已完成服务端 action contract但还没有实现真正执行副作用的 action registry / action node`save_application_draft``submit_application``create_reimbursement_draft` 仍需要下一步接到现有业务服务并补幂等、确认来源和 trace。建议下一步先做 action registry再接 checkpoint/human-in-the-loop。
- 16:13第一版 action executor 和 AI 工作台点击执行闭环已可执行申请/报销基础副作用,但还没有接入 LangGraph checkpoint / interrupt也没有给所有副作用写持久化幂等 trace。16:40 已完成后端 action graph runtime、pending interrupt 和幂等重放,剩余前端恢复 UI 还需单独做。
- 16:13`link_existing_application``associate_attachments` 已进入执行白名单并在未接线时阻断,但真实执行还没有统一接入 action executor。16:40 已接入真实执行和回归测试,后续仍需在真页补充可视化恢复/继续动作。
- 16:40真实 `/steward/plans` 仍未跑到 `planning_source=llm_function_call`,当前阻塞点是 backup skipped、MiniMax 三次失败;代码层 backup tool-call 成功路径已有测试,但环境层还要修模型配置或连通性。
- 16:40后端已把 pending interrupt 写进 conversation checkpoint但 AI 工作台还没有在刷新或重新进入会话时从 checkpoint 主动恢复“继续/确认”按钮;建议下一步补前端恢复 UI。
- 16:53`usePersonalWorkbenchAiMode.js` 当前已超过 800 行,后续继续加 AI 模式功能前建议拆出模型规划等待态、普通对话启动和申请预览桥接 composable避免继续扩大主运行时文件。
- 16:59本轮没有新增独立遗留风险仍沿用 16:40 的真实模型连通性问题和 16:53 的主 composable 过大问题。建议后续先修 `llm_function_call` 成功路径,再拆分 AI 模式前端运行时文件。
- 17:26`workbench-ai-mode-switch.test.mjs` 仍有 2 个旧静态断言失败,分别断言附件卡片还在主模板内联、`selectedFiles` watch 还在主 AI 模式文件里;当前真实代码已经拆成组件/composable。建议后续单独整理这个老测试文件避免它继续误报已迁移结构。
- 17:40`workbench-ai-mode-expense-scene-action.test.mjs``expense-application-fast-preview.test.mjs` 仍有多条旧静态断言失败,失败点主要是代码已拆到 composable 后测试还读取旧 Vue 单文件或固定片段。建议后续把这些静态断言改成导入 composable/model 的行为测试,避免真实功能修复被旧结构误报淹没。
- 17:48本轮修复已用模型归一化和相邻前端测试覆盖但还没有在真实 5173 页面输入最新无动作话术截图回放。建议下一步刷新 AI 工作台用“2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车”验证是否直接进入申请核对表。
## TODO
@@ -264,3 +466,28 @@
- [x] ~~重新跑截图同批火车票 PDF 的 OCR 接口,确认不再返回“其他单据/空字段”。~~(完成于 11:55证据5173 `/api/v1/ocr/recognize` 返回 `2月20_武汉-上海.pdf``2月23_上海-武汉.pdf` 均为 `火车/高铁票`,并写回票据夹字段)
- [x] ~~重建或补齐当前运行容器的 `poppler-data` / `mupdf-tools`,确认 `mutool` 可用后再上传同类中文 PDF目标是同时恢复 PNG 预览和 OCR 字段。~~(完成于 12:04证据`apt-get install poppler-data mupdf-tools` 成功,`/usr/bin/mutool` 可用;两条 `/preview` 返回 `image/png`,并写入 `preview_kind=image`
- [x] ~~统一 AI 会话附件、票据夹和报销附件的预览类型判定,避免会话上传卡片继续把已生成 PNG 预览的 PDF 当成 PDF 展示。~~(完成于 12:23证据新增 `documentPreviewAssets.js`;前端相关测试 24/24 通过,真实 OCR 返回 `preview_kind=image``/preview``image/png`
- [x] ~~重新设计一键关联票据的弹窗 UI调整内边距、行间距、按钮尺寸与悬浮效果并按精致化要求收敛尺寸以恢复精致感。~~(完成于 12:52证据`npm run build` 构建成功,`receipt-folder-view.test.mjs` 测试通过,样式已应用)
- [ ] 单独修复 `expense-application-fast-preview.test.mjs` 里 12 个既有失败或拆分陈旧静态断言恢复整文件可作为申请链路回归套件使用。来源13:03 差旅申请直提链路验证)
- [ ] 在真实 5173 AI 工作台输入“去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交”,确认模型计划路径命中 `/steward/plans`并在缺日期时停在申请核对表、日期补齐后进入提交前核查。来源13:20 AI 意图规划器接线)
- [x] ~~在真实 5173 AI 工作台输入“2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。”确认模型计划或 fallback 计划生成申请核对表,并自动保存草稿后给出详情入口。~~(完成于 16:53证据1.5 秒内显示用户气泡和 pending 卡,约 38 秒后保存草稿 `AZ8QSX9QA`
- [ ] 在真实 5173 网络面板或后端日志核对 `/steward/plans` 返回 `planning_source=llm_function_call``tasks[0].requested_action=save_draft` 和完整 `ontology_fields` 后,再确认前端没有走 rule fallback。来源13:58 模型优先规划重构)
- [x] ~~确认真实 `/steward/plans` 不再毫秒级规则直出,并在模型不可用时按 10s * 3 次后降级。~~(完成于 14:33证据真实 5173 API 回放耗时 32.08sMiniMax attempt 1/2/3 均超时后才 `rule_fallback`
- [ ] 修复 MiniMax 连通性或配置可用 backup 模型,再用同一句保存草稿话术确认 `/steward/plans` 返回 `planning_source=llm_function_call`来源14:33 模型必经链路验证16:40 已补代码层 backup tool-call 测试,但真实环境仍 fallback
- [x] ~~评估 LangChain/LangGraph 是否作为 steward agent 层框架,引入前明确 memory、tools/action、planner、runtime state、observability 和依赖风险。~~(完成于 15:14证据已引入 LangGraph 第一阶段并默认接管 `/steward/plans` 规划编排45 个容器测试通过)
- [x] ~~将 `StewardSlotDecisionAgent` 和 `StewardRuntimeDecisionAgent` 继续迁入 LangGraph 节点,形成统一 steward graph runtime。~~(完成于 15:31证据新增 `StewardGraphRuntime`,容器内 graph runtime 相关回归 59/59 通过)
- [x] ~~为 `/steward/plans` 增加服务端白名单 `action_steps`,覆盖申请、报销、保存草稿和直接提交的基础动作规划。~~(完成于 15:53证据新增 `StewardActionPlanBuilder`,真实 5173 回放返回完整 action steps容器内相关回归 66/66 通过)
- [x] ~~为 LangGraph steward runtime 增加 checkpoint/persistence 设计,复用现有 conversation state支持暂停、恢复和可观测 trace。~~(完成于 16:40证据新增 `StewardGraphActionRuntime`,同一 `conversation_id + client_trace_id` 重放返回 `idempotent_replay=true`,未确认 action 写入 pending interrupt
- [ ] 修通真实 `llm_function_call` 成功路径,用真实模型配置验证 `/steward/plans` 不再进入 rule fallback。来源15:22 LangGraph 迁移文档action node、checkpoint 和 human-in-the-loop 后端部分已于 16:40 完成)
- [x] ~~实现 LangGraph 白名单 action node把保存草稿、提交审批、关联申请单、创建报销草稿和附件关联接入显式 action contract。~~(完成于 16:40证据`StewardGraphActionRuntime` 默认接管 `/steward/actions/execute`action executor 9/9 通过,关联任务回归 7/7 通过)
- [x] ~~实现 action registry 的未知 action 拒绝、确认来源校验、重复申请 precheck 阻断和基础业务执行。~~(完成于 16:13证据新增 `StewardActionExecutor`,容器内 action executor 5/5 和迁移回归 71/71 通过;前端点击执行回归 21/21 通过;真实 5173 未确认 submit 返回 `needs_confirmation`
- [x] ~~为第一版 `StewardActionExecutor` 增加 checkpoint / interrupt 恢复、持久化 trace 和幂等键。~~(完成于 16:40证据`StewardGraphActionRuntime` 持久化 `steward_action_checkpoint`,重复 trace 不重复建单)
- [x] ~~补齐 `link_existing_application` 和 `associate_attachments` 的真实 action executor 接线与回归测试。~~(完成于 16:40证据申请关联报销草稿测试和附件关联 runner 测试已通过)
- [ ] 前端 AI 工作台从 `steward_action_checkpoint.pending_interrupt` 恢复继续/确认按钮,避免刷新页面后只剩后端 checkpoint、用户看不到可继续 action。来源16:40 action graph runtime
- [ ] 拆分 `usePersonalWorkbenchAiMode.js` 的模型规划等待态和对话启动逻辑,优先把文件降回 800 行以内。来源16:53 回车无反馈修复)
- [x] ~~修复 AI 模式普通回复复用规划 pending 卡片,确保一次用户输入只生成一条助手消息。~~(完成于 16:59证据真页输入“你好”最终 `assistantCardCount=1`,前端相关测试 32/32 通过)
- [x] ~~让 AI 模式模型规划等待期间持续追加 thinking 事件,而不是静态显示“思考中”后一次性出现步骤。~~(完成于 17:26证据真页干净会话 thinking 数量从 2 条逐步增长到 6 条,相关前端测试 33/33 通过)
- [x] ~~修复 AI 工作台“保存草稿”后的上下文提交短句,让“提交这个单据”复用最近申请草稿而不是重新规划。~~(完成于 17:40证据`workbench-ai-application-context-submit.test.mjs``workbench-ai-application-gate-model.test.mjs` 通过)
- [x] ~~修复“完整出差信息但未说申请/报销”时只停在候选流程确认的问题,让唯一的“先发起出差申请”候选流直接进入申请预览计划。~~(完成于 17:48证据`workbench-ai-intent-planner-model.test.mjs` 新增回归通过,相邻 4 组前端测试通过)
- [ ] 单独修复或重写 `workbench-ai-mode-switch.test.mjs` 的旧静态结构断言,使它适配 `WorkbenchAiFileStrip` 和 OCR composable 拆分后的真实代码。来源17:26 额外测试发现)
- [ ] 单独修复或重写 `workbench-ai-mode-expense-scene-action.test.mjs``expense-application-fast-preview.test.mjs` 中继续扫描旧 Vue 单文件的静态断言,改为覆盖 composable/model 的行为测试。来源17:40 上下文提交验证)
- [ ] 在真实 5173 AI 工作台回放“2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车”确认唯一申请候选流直接生成申请核对表。来源17:48 无动作话术直进申请预览修复)

View File

@@ -20,6 +20,7 @@ dependencies = [
"email-validator>=2.2.0,<3.0.0",
"python-multipart>=0.0.20,<1.0.0",
"jieba>=0.42.1,<0.43.0",
"langgraph>=1.2.0,<2.0.0",
"openpyxl>=3.1.5,<4.0.0",
"lightrag-hku>=1.4.16,<1.5.0",
"qdrant-client>=1.18.0,<2.0.0",

View File

@@ -10,10 +10,13 @@ from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_db
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.core.config import get_settings
from app.models.financial_record import ExpenseClaim
from app.schemas.common import ErrorResponse
from app.schemas.steward import (
StewardActionExecuteRequest,
StewardActionExecuteResponse,
StewardPlanRequest,
StewardPlanResponse,
StewardRuntimeDecisionRequest,
@@ -27,6 +30,9 @@ from app.services.expense_claim_draft_flow import APPROVED_APPLICATION_LINK_STAT
from app.services.expense_claims import ExpenseClaimService
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_flow_state import StewardFlowStateService
from app.services.steward_graph_action_runtime import StewardGraphActionRuntime
from app.services.steward_graph_planner import StewardGraphPlannerService
from app.services.steward_graph_runtime import StewardGraphRuntime
from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_off_topic_agent import StewardOffTopicAgent
from app.services.steward_planner import StewardPlannerService
@@ -35,6 +41,8 @@ from app.services.steward_slot_decision_agent import StewardSlotDecisionAgent
router = APIRouter(prefix="/steward")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
StewardPlannerLike = StewardPlannerService | StewardGraphPlannerService
@router.post(
@@ -69,7 +77,7 @@ def create_steward_slot_decision(
payload: StewardSlotDecisionRequest,
db: DbSession,
) -> StewardSlotDecisionResponse:
return StewardSlotDecisionAgent(RuntimeChatService(db)).decide(payload)
return _decide_steward_slot(payload, RuntimeChatService(db))
@router.post(
@@ -83,10 +91,27 @@ def create_steward_runtime_decision(
db: DbSession,
) -> StewardRuntimeDecisionResponse:
hydrated_payload = _hydrate_runtime_decision_payload(db, payload)
decision = StewardRuntimeDecisionAgent(RuntimeChatService(db)).decide(hydrated_payload)
decision = _decide_steward_runtime(hydrated_payload, RuntimeChatService(db))
return _attach_runtime_conversation_state(db, hydrated_payload, decision)
@router.post(
"/actions/execute",
response_model=StewardActionExecuteResponse,
summary="执行小财管家白名单动作",
description=(
"按 LangGraph 规划出的 action step 执行确定性业务动作;"
"当 action 未知、缺字段、缺确认或预检查未通过时直接阻断并返回结构化兜底结果。"
),
)
def execute_steward_action(
payload: StewardActionExecuteRequest,
db: DbSession,
current_user: CurrentUser,
) -> StewardActionExecuteResponse:
return StewardGraphActionRuntime(db).execute(payload, current_user)
@router.post(
"/plans/stream",
summary="流式生成小财管家任务计划",
@@ -101,7 +126,7 @@ async def stream_steward_plan(payload: StewardPlanRequest, db: DbSession) -> Str
async def _iter_steward_plan_events(
payload: StewardPlanRequest,
planner: StewardPlannerService,
planner: StewardPlannerLike,
db: Session,
) -> AsyncIterator[str]:
yield _encode_stream_event(
@@ -135,18 +160,53 @@ def _encode_stream_event(event: str, data: dict[str, Any]) -> str:
return json.dumps({"event": event, "data": data}, ensure_ascii=False) + "\n"
def _build_steward_planner(db: Session) -> StewardPlannerService:
def _build_steward_planner(db: Session) -> StewardPlannerLike:
runtime_chat = RuntimeChatService(db)
if get_settings().steward_agent_runtime.strip().lower() == "langgraph":
return StewardGraphPlannerService(
intent_agent=StewardIntentAgent(runtime_chat),
off_topic_agent=StewardOffTopicAgent(runtime_chat),
)
return StewardPlannerService(
intent_agent=StewardIntentAgent(runtime_chat),
off_topic_agent=StewardOffTopicAgent(runtime_chat),
)
def _decide_steward_slot(
payload: StewardSlotDecisionRequest,
runtime_chat: Any,
) -> StewardSlotDecisionResponse:
legacy_agent = StewardSlotDecisionAgent(runtime_chat)
if not _should_use_langgraph_runtime():
return legacy_agent.decide(payload)
try:
return StewardGraphRuntime(runtime_chat).decide_slot(payload)
except Exception:
return legacy_agent.decide(payload)
def _decide_steward_runtime(
payload: StewardRuntimeDecisionRequest,
runtime_chat: Any,
) -> StewardRuntimeDecisionResponse:
legacy_agent = StewardRuntimeDecisionAgent(runtime_chat)
if not _should_use_langgraph_runtime():
return legacy_agent.decide(payload)
try:
return StewardGraphRuntime(runtime_chat).decide_runtime(payload)
except Exception:
return legacy_agent.decide(payload)
def _should_use_langgraph_runtime() -> bool:
return get_settings().steward_agent_runtime.strip().lower() == "langgraph"
def _hydrate_required_application_gate(
db: Session,
payload: StewardPlanRequest,
planner: StewardPlannerService,
planner: StewardPlannerLike,
) -> StewardPlanRequest:
context_json = dict(payload.context_json or {})
required_gate = context_json.get("required_application_gate")

View File

@@ -73,6 +73,7 @@ class Settings(BaseSettings):
onlyoffice_backend_url: str = Field(default="", alias="ONLYOFFICE_BACKEND_URL")
onlyoffice_jwt_secret: str = Field(default="", alias="ONLYOFFICE_JWT_SECRET")
hermes_agent_shared_token: str = Field(default="", alias="HERMES_AGENT_SHARED_TOKEN")
steward_agent_runtime: str = Field(default="langgraph", alias="STEWARD_AGENT_RUNTIME")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
log_dir: str = Field(default="logs", alias="LOG_DIR")

View File

@@ -4,11 +4,11 @@ from typing import Any, Literal
from pydantic import BaseModel, Field
StewardTaskType = Literal["expense_application", "reimbursement"]
StewardAssignedAgent = Literal["application_assistant", "reimbursement_assistant"]
StewardPlanningSource = Literal["llm_function_call", "rule_fallback"]
StewardPlanNextAction = Literal["confirm_flow", "confirm_task", "delegate_task", "none"]
StewardRequestedAction = Literal["preview", "save_draft", "submit"]
StewardSlotDecisionSource = Literal["llm_function_call", "rule_fallback"]
StewardSlotNextAction = Literal["ask_user", "render_preview"]
StewardRuntimeDecisionSource = Literal["llm_function_call", "rule_fallback"]
@@ -22,6 +22,22 @@ StewardRuntimeNextAction = Literal[
"cancel_current_action",
"no_op",
]
StewardActionType = Literal[
"detect_intent",
"fill_application_fields",
"build_application_preview",
"fill_reimbursement_fields",
"build_reimbursement_preview",
"validate_required_fields",
"run_duplicate_precheck",
"save_application_draft",
"submit_application",
"link_existing_application",
"create_reimbursement_draft",
"associate_attachments",
]
StewardActionStatus = Literal["completed", "planned", "pending_confirmation", "blocked"]
StewardActionExecutionStatus = Literal["succeeded", "blocked", "needs_confirmation", "failed"]
StewardTaskStatus = Literal[
"planned",
"needs_confirmation",
@@ -58,6 +74,17 @@ class StewardThinkingEvent(BaseModel):
status: str = Field(default="completed", description="事件状态。")
class StewardActionStep(BaseModel):
step_id: str = Field(description="动作步骤 ID。")
action_type: StewardActionType = Field(description="白名单动作类型。")
label: str = Field(description="用户可见动作名称。")
target_task_id: str = Field(default="", description="关联的小财管家任务 ID。")
status: StewardActionStatus = Field(default="planned", description="动作规划状态。")
requires_confirmation: bool = Field(default=False, description="执行前是否需要用户确认。")
depends_on: list[str] = Field(default_factory=list, description="前置动作步骤 ID。")
payload: dict[str, Any] = Field(default_factory=dict, description="动作执行需要的确定性载荷。")
class StewardTask(BaseModel):
task_id: str = Field(description="小财管家任务 ID。")
task_type: StewardTaskType = Field(description="任务类型。")
@@ -66,9 +93,11 @@ class StewardTask(BaseModel):
summary: str = Field(description="任务摘要。")
status: StewardTaskStatus = Field(default="needs_confirmation", description="任务状态。")
confidence: float = Field(default=0.0, ge=0.0, le=1.0, description="识别置信度。")
requested_action: StewardRequestedAction = Field(default="preview", description="用户希望执行的动作:预览、保存草稿或提交。")
ontology_fields: dict[str, str] = Field(default_factory=dict, description="归一化后的业务本体字段。")
missing_fields: list[str] = Field(default_factory=list, description="仍缺失的本体字段。")
confirmation_required: bool = Field(default=True, description="执行前是否需要用户确认。")
action_steps: list[StewardActionStep] = Field(default_factory=list, description="当前任务的白名单动作步骤。")
class StewardAttachmentGroup(BaseModel):
@@ -120,6 +149,7 @@ class StewardPlanResponse(BaseModel):
summary: str = Field(description="计划摘要。")
thinking_events: list[StewardThinkingEvent] = Field(default_factory=list, description="过程摘要事件。")
tasks: list[StewardTask] = Field(default_factory=list, description="拆解后的任务。")
action_steps: list[StewardActionStep] = Field(default_factory=list, description="本计划的全量白名单动作步骤。")
attachment_groups: list[StewardAttachmentGroup] = Field(default_factory=list, description="附件归集建议。")
confirmation_groups: list[StewardConfirmationAction] = Field(default_factory=list, description="等待用户确认的动作。")
pending_flow_confirmation: StewardPendingFlowConfirmation = Field(
@@ -134,6 +164,30 @@ class StewardPlanResponse(BaseModel):
)
class StewardActionExecuteRequest(BaseModel):
action_type: str = Field(description="待执行的白名单动作类型。未知动作会被 executor 阻断。")
message: str = Field(default="", description="触发该动作的用户原始话术。")
plan_id: str = Field(default="", description="关联的小财管家计划 ID。")
conversation_id: str | None = Field(default=None, description="关联会话 ID。")
task: StewardTask | None = Field(default=None, description="动作所属任务快照。")
action_step: StewardActionStep | None = Field(default=None, description="规划侧生成的动作步骤快照。")
confirmed: bool = Field(default=False, description="用户是否已确认执行该动作。")
context_json: dict[str, Any] = Field(default_factory=dict, description="前端或运行时补充上下文。")
client_trace_id: str = Field(default="", description="前端幂等或追踪 ID。")
class StewardActionExecuteResponse(BaseModel):
action_type: str = Field(description="实际处理的动作类型。")
status: StewardActionExecutionStatus = Field(description="动作执行状态。")
execution_source: str = Field(default="langgraph_action", description="执行来源或兜底来源。")
fallback_used: bool = Field(default=False, description="是否走了规则兜底执行。")
requires_confirmation: bool = Field(default=False, description="是否仍需用户确认。")
message: str = Field(description="面向用户展示的执行结果。")
blocked_reasons: list[str] = Field(default_factory=list, description="阻断原因。")
result_payload: dict[str, Any] = Field(default_factory=dict, description="业务服务返回的结构化结果。")
trace: list[dict[str, Any]] = Field(default_factory=list, description="动作执行轨迹。")
class StewardSlotOption(BaseModel):
label: str = Field(description="用户可见选项文案。")
value: str = Field(description="写回本体字段的选项值。")

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import re
from datetime import date, timedelta
CITY_NAMES = (
"北京",
"上海",
@@ -34,6 +33,16 @@ MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(
ISO_DATE_PATTERN = re.compile(
r"(?P<year>\d{4})[-/年](?P<month>\d{1,2})[-/月](?P<day>\d{1,2})(?:日)?"
)
ISO_DATE_RANGE_PATTERN = re.compile(
r"(?P<start_year>\d{4})[-/年](?P<start_month>\d{1,2})[-/月](?P<start_day>\d{1,2})(?:日)?"
r"(?:至|到|~||—|--)"
r"(?P<end_year>\d{4})[-/年](?P<end_month>\d{1,2})[-/月](?P<end_day>\d{1,2})(?:日)?"
)
MONTH_DAY_RANGE_PATTERN = re.compile(
r"(?P<start_month>\d{1,2})\s*月\s*(?P<start_day>\d{1,2})\s*(?:日|号)?"
r"(?:至|到|~||—|--|-)"
r"(?:(?P<end_month>\d{1,2})\s*月\s*)?(?P<end_day>\d{1,2})\s*(?:日|号)?"
)
class ApplicationFactResolver:
@@ -60,6 +69,38 @@ class ApplicationFactResolver:
if "后天" in compact:
return (base_date + timedelta(days=2)).isoformat()
iso_range = ISO_DATE_RANGE_PATTERN.search(compact)
if iso_range:
start_date = ApplicationFactResolver.safe_date(
int(iso_range.group("start_year")),
int(iso_range.group("start_month")),
int(iso_range.group("start_day")),
)
end_date = ApplicationFactResolver.safe_date(
int(iso_range.group("end_year")),
int(iso_range.group("end_month")),
int(iso_range.group("end_day")),
)
if start_date and end_date:
return f"{start_date}{end_date}"
month_day_range = MONTH_DAY_RANGE_PATTERN.search(compact)
if month_day_range:
start_month = int(month_day_range.group("start_month"))
end_month = int(month_day_range.group("end_month") or start_month)
start_date = ApplicationFactResolver.safe_date(
base_date.year,
start_month,
int(month_day_range.group("start_day")),
)
end_date = ApplicationFactResolver.safe_date(
base_date.year,
end_month,
int(month_day_range.group("end_day")),
)
if start_date and end_date:
return f"{start_date}{end_date}"
iso_match = ISO_DATE_PATTERN.search(compact)
if iso_match:
return ApplicationFactResolver.safe_date(
@@ -100,6 +141,9 @@ class ApplicationFactResolver:
def extract_reason(segment: str, task_type: str) -> str:
cleaned = re.sub(r"\s+", "", segment).strip(",。;; ")
if task_type == "expense_application":
normalized = strip_application_non_reason_tokens(cleaned)
if normalized:
return normalized
match = re.search(r"(辅助|支持|协助|支撑|参加|拜访|调研|实施|部署|审核).+", cleaned)
if match:
return strip_trailing_connectors(match.group(0))
@@ -130,6 +174,47 @@ def strip_trailing_connectors(value: str) -> str:
return re.sub(r"(?:并且|而且|同时|另外|还需要|需要)$", "", cleaned).strip(",。;; ")
def strip_application_non_reason_tokens(value: str) -> str:
cleaned = str(value or "").strip(",。;; ")
if not cleaned:
return ""
preserved_transport_phrases: dict[str, str] = {}
def preserve_transport_phrase(match: re.Match[str]) -> str:
placeholder = f"__TRANSPORT_REASON_{len(preserved_transport_phrases)}__"
preserved_transport_phrases[placeholder] = match.group(0)
return placeholder
cleaned = re.sub(
r"(?:高铁|动车|火车|飞机|机票|航班|轮船|出租车|的士|网约车|打车|地铁|公交)往返",
preserve_transport_phrase,
cleaned,
)
cleaned = ISO_DATE_RANGE_PATTERN.sub("", cleaned)
cleaned = MONTH_DAY_RANGE_PATTERN.sub("", cleaned)
cleaned = ISO_DATE_PATTERN.sub("", cleaned)
cleaned = MONTH_DAY_PATTERN.sub("", cleaned)
cleaned = re.sub(r"昨天|前天|明天|后天|今天|上周|上月|下周|下月|近期|月底", "", cleaned)
cleaned = re.sub(r"[0-9一二两三四五六七八九十]+天", "", cleaned)
cleaned = re.sub(fr"(?:去|到|赴|前往)(?:{'|'.join(CITY_NAMES)})", "", cleaned)
cleaned = re.sub(fr"(?:出差|差旅)(?:{'|'.join(CITY_NAMES)})", "", cleaned)
cleaned = re.sub(fr"(?:{'|'.join(CITY_NAMES)})(?=出差|差旅)", "", cleaned)
cleaned = re.sub(r"出差|差旅|费用申请|出差申请|差旅申请", "", cleaned)
cleaned = re.sub(r"交通方式?|出行方式?", "", cleaned)
cleaned = re.sub(r"高铁|动车|火车|飞机|机票|航班|轮船|出租车|的士|网约车|打车|地铁|公交", "", cleaned)
cleaned = re.sub(
r"保存草稿|存草稿|先保存|直接提交|提交申请|确认提交|提交审批|保存|提交|申请|发起|创建|我想要|我想|我要|请帮我|帮我",
"",
cleaned,
)
cleaned = re.sub(r"(^|[,。;;、])(?:交通|出行)(?=$|[,。;;、])", r"\1", cleaned)
for placeholder, phrase in preserved_transport_phrases.items():
cleaned = cleaned.replace(placeholder, phrase)
cleaned = re.sub(r"[,。;;、]+", "", cleaned).strip(",。;;、的 ")
return strip_trailing_connectors(cleaned)
def resolve_application_facts(segment: str, task_type: str, base_date: date) -> dict[str, str]:
fields = {
"expense_type": ApplicationFactResolver.infer_expense_type(segment, task_type),

View File

@@ -244,6 +244,7 @@ class RuntimeChatService:
timeout_seconds: int | None = None,
slot_timeouts: dict[str, int] | None = None,
max_attempts: int | None = None,
use_failure_cooldown: bool = True,
) -> RuntimeToolCallResult:
configs: list[dict[str, str]] = []
calls: list[RuntimeChatCallTrace] = []
@@ -272,7 +273,7 @@ class RuntimeChatService:
for attempt in range(1, resolved_max_attempts + 1):
for config in configs:
cache_key = self._build_slot_cache_key(config)
if _slot_failure_until.get(cache_key, 0.0) > monotonic():
if use_failure_cooldown and _slot_failure_until.get(cache_key, 0.0) > monotonic():
logger.info(
"Skip runtime chat tool slot=%s provider=%s because it is in cooldown",
config["slot"],
@@ -330,9 +331,10 @@ class RuntimeChatService:
)
except Exception as exc:
duration_ms = int((monotonic() - started) * 1000)
_slot_failure_until[cache_key] = (
monotonic() + DEFAULT_RUNTIME_CHAT_FAILURE_COOLDOWN_SECONDS
)
if use_failure_cooldown:
_slot_failure_until[cache_key] = (
monotonic() + DEFAULT_RUNTIME_CHAT_FAILURE_COOLDOWN_SECONDS
)
calls.append(
RuntimeChatCallTrace(
slot=config["slot"],

View File

@@ -0,0 +1,224 @@
from __future__ import annotations
from typing import Any
from app.schemas.steward import (
StewardActionStatus,
StewardActionStep,
StewardPlanResponse,
StewardTask,
)
class StewardActionPlanBuilder:
"""把小财管家任务转换为确定性的白名单动作步骤。"""
def attach_action_steps(self, plan: StewardPlanResponse) -> StewardPlanResponse:
if not plan.tasks:
return plan.model_copy(update={"action_steps": []})
tasks: list[StewardTask] = []
plan_steps = [self._build_detect_intent_step(plan)]
for task in plan.tasks:
task_steps = self.build_task_action_steps(task)
tasks.append(task.model_copy(update={"action_steps": task_steps}))
plan_steps.extend(task_steps)
return plan.model_copy(update={"tasks": tasks, "action_steps": plan_steps})
def build_task_action_steps(self, task: StewardTask) -> list[StewardActionStep]:
if task.task_type == "expense_application":
return self._build_application_steps(task)
return self._build_reimbursement_steps(task)
def _build_application_steps(self, task: StewardTask) -> list[StewardActionStep]:
steps = [
self._build_task_step(
task,
1,
"fill_application_fields",
"填充申请字段",
payload=self._field_payload(task),
),
self._build_task_step(
task,
2,
"build_application_preview",
"展示申请核对表",
depends_on_index=1,
payload={"task_id": task.task_id, "ontology_fields": task.ontology_fields},
),
self._build_task_step(
task,
3,
"validate_required_fields",
"校验申请必填字段",
status=self._validation_status(task),
depends_on_index=2,
payload={"missing_fields": task.missing_fields},
),
]
if task.requested_action == "save_draft":
steps.append(
self._build_task_step(
task,
4,
"save_application_draft",
"保存申请草稿",
status=self._side_effect_status(task, requires_confirmation=False),
depends_on_index=3,
payload={
"task_id": task.task_id,
"requested_action": task.requested_action,
"ontology_fields": task.ontology_fields,
},
)
)
elif task.requested_action == "submit":
steps.append(
self._build_task_step(
task,
4,
"run_duplicate_precheck",
"检查重复或冲突申请",
status=self._side_effect_status(task, requires_confirmation=False),
depends_on_index=3,
payload={"task_id": task.task_id, "precheck_type": "travel_overlap"},
)
)
steps.append(
self._build_task_step(
task,
5,
"submit_application",
"提交申请审批",
status=self._side_effect_status(task, requires_confirmation=True),
requires_confirmation=not bool(task.missing_fields),
depends_on_index=4,
payload={
"task_id": task.task_id,
"requested_action": task.requested_action,
"confirmation_required": True,
},
)
)
return steps
def _build_reimbursement_steps(self, task: StewardTask) -> list[StewardActionStep]:
steps = [
self._build_task_step(
task,
1,
"fill_reimbursement_fields",
"填充报销字段",
payload=self._field_payload(task),
),
self._build_task_step(
task,
2,
"build_reimbursement_preview",
"展示报销核对表",
depends_on_index=1,
payload={"task_id": task.task_id, "ontology_fields": task.ontology_fields},
),
self._build_task_step(
task,
3,
"validate_required_fields",
"校验报销必填字段",
status=self._validation_status(task),
depends_on_index=2,
payload={"missing_fields": task.missing_fields},
),
self._build_task_step(
task,
4,
"create_reimbursement_draft",
"创建报销草稿",
status=self._side_effect_status(task, requires_confirmation=False),
depends_on_index=3,
payload={
"task_id": task.task_id,
"requested_action": task.requested_action,
"ontology_fields": task.ontology_fields,
},
),
]
if task.ontology_fields.get("attachments"):
steps.append(
self._build_task_step(
task,
5,
"associate_attachments",
"关联报销附件",
status=self._side_effect_status(task, requires_confirmation=False),
depends_on_index=4,
payload={"task_id": task.task_id, "attachments": task.ontology_fields["attachments"]},
)
)
return steps
@staticmethod
def _build_detect_intent_step(plan: StewardPlanResponse) -> StewardActionStep:
return StewardActionStep(
step_id="plan:00:detect_intent",
action_type="detect_intent",
label="识别业务意图",
status="completed",
payload={
"planning_source": plan.planning_source,
"plan_status": plan.plan_status,
},
)
def _build_task_step(
self,
task: StewardTask,
index: int,
action_type: str,
label: str,
*,
status: StewardActionStatus = "planned",
requires_confirmation: bool = False,
depends_on_index: int | None = None,
payload: dict[str, Any] | None = None,
) -> StewardActionStep:
step_id = self._step_id(task, index)
depends_on = [self._step_id(task, depends_on_index)] if depends_on_index is not None else []
return StewardActionStep(
step_id=step_id,
action_type=action_type, # type: ignore[arg-type]
label=label,
target_task_id=task.task_id,
status=status,
requires_confirmation=requires_confirmation,
depends_on=depends_on,
payload=payload or {},
)
@staticmethod
def _field_payload(task: StewardTask) -> dict[str, Any]:
return {
"task_id": task.task_id,
"task_type": task.task_type,
"requested_action": task.requested_action,
"ontology_fields": task.ontology_fields,
"missing_fields": task.missing_fields,
}
@staticmethod
def _validation_status(task: StewardTask) -> StewardActionStatus:
return "blocked" if task.missing_fields else "planned"
@staticmethod
def _side_effect_status(
task: StewardTask,
*,
requires_confirmation: bool,
) -> StewardActionStatus:
if task.missing_fields:
return "blocked"
return "pending_confirmation" if requires_confirmation else "planned"
@staticmethod
def _step_id(task: StewardTask, index: int) -> str:
return f"{task.task_id}:{index:02d}"

View File

@@ -0,0 +1,570 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.schemas.ontology import OntologyParseResult, OntologyPermission
from app.schemas.steward import (
StewardActionExecuteRequest,
StewardActionExecuteResponse,
StewardTask,
)
from app.schemas.user_agent import UserAgentRequest
from app.services.attachment_association_jobs import AttachmentAssociationJobRunner
from app.services.expense_claims import ExpenseClaimService
from app.services.user_agent import UserAgentService
from app.services.user_agent_application_dates import resolve_application_days_from_time_range
SUPPORTED_ACTIONS = {
"fill_application_fields",
"build_application_preview",
"fill_reimbursement_fields",
"build_reimbursement_preview",
"validate_required_fields",
"run_duplicate_precheck",
"save_application_draft",
"submit_application",
"create_reimbursement_draft",
"link_existing_application",
"associate_attachments",
}
APPLICATION_SIDE_EFFECT_ACTIONS = {"save_application_draft", "submit_application"}
REIMBURSEMENT_SIDE_EFFECT_ACTIONS = {"create_reimbursement_draft", "link_existing_application"}
NOOP_ACTIONS = {
"fill_application_fields",
"build_application_preview",
"fill_reimbursement_fields",
"build_reimbursement_preview",
"validate_required_fields",
}
TRANSPORT_MODE_LABELS = {
"train": "火车",
"rail": "火车",
"high_speed_rail": "火车",
"flight": "飞机",
"airplane": "飞机",
"plane": "飞机",
"ship": "轮船",
"boat": "轮船",
"taxi": "出租车",
}
EXPENSE_TYPE_LABELS = {
"travel": "差旅费",
"transport": "交通费",
"hotel": "住宿费",
"meal": "业务招待费",
"meeting": "会务费",
"office": "办公用品费",
"other": "其他费用",
}
APPLICATION_TYPE_LABELS = {
"travel": "差旅费用申请",
"travel_application": "差旅费用申请",
"transport": "交通费用申请",
"hotel": "住宿费用申请",
"meeting": "会务费用申请",
"office": "办公费用申请",
}
class StewardActionExecutor:
"""执行 LangGraph 规划出的确定性白名单动作。"""
def __init__(self, db: Session) -> None:
self.db = db
def execute(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
) -> StewardActionExecuteResponse:
action_type = self._normalize_action_type(request.action_type)
trace = [self._trace("received", action_type=action_type, plan_id=request.plan_id)]
if action_type not in SUPPORTED_ACTIONS:
return self._blocked(
action_type,
f"不支持的小财管家动作:{action_type or '空动作'}",
trace=[*trace, self._trace("blocked", reason="unsupported_action")],
)
task = request.task
if task is None and action_type not in NOOP_ACTIONS:
return self._blocked(
action_type,
"动作缺少任务快照,无法安全执行。",
trace=[*trace, self._trace("blocked", reason="missing_task")],
)
blocked_reasons = self._resolve_task_blockers(task)
if blocked_reasons:
return self._blocked(
action_type,
"当前任务仍有必填信息缺失,已停止执行动作。",
blocked_reasons=blocked_reasons,
trace=[*trace, self._trace("blocked", reason="missing_fields")],
)
if action_type in NOOP_ACTIONS:
return StewardActionExecuteResponse(
action_type=action_type,
status="succeeded",
message="动作已确认,无需调用外部业务服务。",
result_payload={
"task_id": task.task_id if task is not None else "",
"action_type": action_type,
},
trace=[*trace, self._trace("completed", mode="noop")],
)
if action_type == "run_duplicate_precheck":
return self._run_duplicate_precheck(request, current_user, trace)
if action_type in APPLICATION_SIDE_EFFECT_ACTIONS:
return self._execute_application_action(request, current_user, action_type, trace)
if action_type in REIMBURSEMENT_SIDE_EFFECT_ACTIONS:
return self._execute_reimbursement_action(request, current_user, action_type, trace)
if action_type == "associate_attachments":
return self._execute_associate_attachments_action(request, current_user, trace)
return self._blocked(
action_type,
f"动作 {action_type} 暂未接入执行器。",
trace=[*trace, self._trace("blocked", reason="unwired_action")],
)
def _run_duplicate_precheck(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
payload = self._build_application_user_agent_request(
request,
current_user,
action_type="submit_application",
force_submit_message=False,
)
service = UserAgentService(self.db)
facts = service._resolve_expense_application_facts(payload)
duplicate = service._find_duplicate_expense_application_record(payload, facts)
if duplicate is not None:
result_payload = {
"status": "blocked",
"blocking": True,
"duplicate_claim_id": str(duplicate.id or ""),
"duplicate_claim_no": str(duplicate.claim_no or ""),
"duplicate_stage": str(duplicate.approval_stage or ""),
}
return StewardActionExecuteResponse(
action_type="run_duplicate_precheck",
status="blocked",
message="检测到同一申请人、同一申请类型、同一时间段已有申请单,已停止直接提交。",
blocked_reasons=["duplicate_application"],
result_payload=result_payload,
trace=[*trace, self._trace("blocked", reason="duplicate_application")],
)
result_payload = {"status": "ok", "blocking": False}
return StewardActionExecuteResponse(
action_type="run_duplicate_precheck",
status="succeeded",
message="未发现重复或冲突申请,可以继续提交。",
result_payload=result_payload,
trace=[*trace, self._trace("completed", mode="duplicate_precheck")],
)
def _execute_application_action(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
action_type: str,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
if action_type == "submit_application" and not request.confirmed:
return StewardActionExecuteResponse(
action_type=action_type,
status="needs_confirmation",
requires_confirmation=True,
message="提交申请前需要用户确认。请确认申请核对表无误后再提交。",
trace=[*trace, self._trace("needs_confirmation")],
)
if action_type == "submit_application":
precheck_blocker = self._resolve_submit_precheck_blocker(request.context_json)
if precheck_blocker:
return self._blocked(
action_type,
precheck_blocker,
blocked_reasons=["precheck_not_passed"],
trace=[*trace, self._trace("blocked", reason="precheck_not_passed")],
)
payload = self._build_application_user_agent_request(
request,
current_user,
action_type=action_type,
force_submit_message=action_type == "submit_application",
)
try:
user_agent_response = UserAgentService(self.db)._build_expense_application_response(
payload,
risk_flags=[],
)
except ValueError as exc:
return self._failed(action_type, str(exc), trace)
draft_payload = (
user_agent_response.draft_payload.model_dump(mode="json")
if user_agent_response.draft_payload is not None
else None
)
result_payload = {
"answer": user_agent_response.answer,
"suggested_actions": [
action.model_dump(mode="json")
for action in user_agent_response.suggested_actions
],
"requires_confirmation": user_agent_response.requires_confirmation,
"draft_payload": draft_payload,
}
status = "succeeded" if draft_payload is not None else "blocked"
blocked_reasons = [] if draft_payload is not None else ["application_not_persisted"]
return StewardActionExecuteResponse(
action_type=action_type,
status=status,
message=user_agent_response.answer,
blocked_reasons=blocked_reasons,
result_payload=result_payload,
trace=[*trace, self._trace("completed", service="UserAgentService")],
)
def _execute_reimbursement_action(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
action_type: str,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
context_json = self._build_reimbursement_context_json(request, current_user)
run_id = self._build_run_id(request, action_type)
ontology = OntologyParseResult(
scenario="expense",
intent="draft",
permission=OntologyPermission(
level="draft_write",
allowed=True,
reason="小财管家白名单动作创建报销草稿。",
),
confidence=1.0,
run_id=run_id,
)
try:
result = ExpenseClaimService(self.db).save_or_submit_from_ontology(
run_id=run_id,
user_id=current_user.username,
message=self._resolve_message(request),
ontology=ontology,
context_json=context_json,
)
except ValueError as exc:
return self._failed(action_type, str(exc), trace)
persisted = str(result.get("claim_id") or "").strip() and str(result.get("status") or "").strip() == "draft"
return StewardActionExecuteResponse(
action_type=action_type,
status="succeeded" if persisted else "blocked",
message=str(result.get("message") or "报销草稿已生成。").strip(),
blocked_reasons=[] if persisted else ["reimbursement_not_persisted"],
result_payload=result,
trace=[*trace, self._trace("completed", service="ExpenseClaimService")],
)
def _execute_associate_attachments_action(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
receipt_ids = self._resolve_receipt_ids(request)
if not receipt_ids:
return self._blocked(
"associate_attachments",
"关联附件前需要先提供票据夹 receipt_ids。",
blocked_reasons=["missing_receipt_ids"],
trace=[*trace, self._trace("blocked", reason="missing_receipt_ids")],
)
try:
result = AttachmentAssociationJobRunner(self.db).run(
receipt_ids=receipt_ids,
current_user=current_user,
)
except ValueError as exc:
return self._blocked(
"associate_attachments",
str(exc),
blocked_reasons=["attachment_association_blocked"],
trace=[*trace, self._trace("blocked", reason="attachment_association_blocked")],
)
except Exception as exc:
return self._failed("associate_attachments", str(exc), trace)
return StewardActionExecuteResponse(
action_type="associate_attachments",
status="succeeded",
message=str(
result.get("message")
or f"已自动关联到 {result.get('claim_no') or '报销草稿'}"
).strip(),
result_payload={
**dict(result or {}),
"receipt_ids": receipt_ids,
},
trace=[*trace, self._trace("completed", service="AttachmentAssociationJobRunner")],
)
def _build_application_user_agent_request(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
*,
action_type: str,
force_submit_message: bool,
) -> UserAgentRequest:
run_id = self._build_run_id(request, action_type)
context_json = self._build_application_context_json(request, current_user, action_type)
message = self._resolve_message(request)
if force_submit_message and "确认提交" not in message and "直接提交" not in message:
message = "\n".join([message, "确认提交"]).strip()
ontology = OntologyParseResult(
scenario="expense",
intent="operate",
permission=OntologyPermission(
level="approval_required",
allowed=True,
reason="小财管家白名单动作执行申请操作。",
),
confidence=1.0,
run_id=run_id,
)
return UserAgentRequest(
run_id=run_id,
user_id=current_user.username,
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={},
selected_capability_codes=[],
degraded=False,
requires_confirmation=False,
)
def _build_application_context_json(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
action_type: str,
) -> dict[str, Any]:
fields = self._resolve_ontology_fields(request.task)
preview_fields = {
"applicationType": self._resolve_application_type_label(fields.get("expense_type")),
"time": str(fields.get("time_range") or ""),
"location": str(fields.get("location") or ""),
"reason": str(fields.get("reason") or ""),
"days": self._resolve_days_text(fields.get("time_range")),
"transportMode": self._resolve_transport_label(fields.get("transport_mode")),
"amount": str(fields.get("amount") or ""),
"applicant": current_user.name,
"department": current_user.department_name,
"position": current_user.position,
"grade": current_user.grade,
"managerName": current_user.manager_name,
}
context_json = {
**dict(request.context_json or {}),
"session_type": "application",
"entry_source": "steward_action_executor",
"document_type": "expense_application",
"application_stage": "expense_application",
"role_codes": current_user.role_codes,
"is_admin": current_user.is_admin,
"username": current_user.username,
"name": current_user.name,
"department_name": current_user.department_name,
"position": current_user.position,
"grade": current_user.grade,
"employee_no": current_user.employee_no,
"manager_name": current_user.manager_name,
"application_preview": {"fields": preview_fields},
}
if action_type == "save_application_draft":
context_json["application_action"] = "save_draft"
context_json["application_save_mode"] = True
return context_json
def _build_reimbursement_context_json(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
) -> dict[str, Any]:
fields = self._resolve_ontology_fields(request.task)
review_form_values = {
"expense_type": self._resolve_expense_type_label(fields.get("expense_type")),
"time_range": str(fields.get("time_range") or ""),
"occurred_date": str(fields.get("time_range") or ""),
"location": str(fields.get("location") or ""),
"reason": str(fields.get("reason") or ""),
"amount": str(fields.get("amount") or ""),
"transport_mode": self._resolve_transport_label(fields.get("transport_mode")),
"attachments": str(fields.get("attachments") or ""),
}
return {
**dict(request.context_json or {}),
"session_type": "expense",
"entry_source": "steward_action_executor",
"review_action": "save_draft",
"review_form_values": review_form_values,
"user_input_text": self._resolve_message(request),
"role_codes": current_user.role_codes,
"is_admin": current_user.is_admin,
"username": current_user.username,
"name": current_user.name,
"department_name": current_user.department_name,
"position": current_user.position,
"grade": current_user.grade,
"employee_no": current_user.employee_no,
"manager_name": current_user.manager_name,
}
@staticmethod
def _resolve_receipt_ids(request: StewardActionExecuteRequest) -> list[str]:
context_json = dict(request.context_json or {})
raw_values = context_json.get("receipt_ids") or context_json.get("receiptIds") or []
if isinstance(raw_values, str):
raw_values = [item.strip() for item in raw_values.split(",")]
if not isinstance(raw_values, list):
raw_values = []
receipt_ids = [
str(item or "").strip()
for item in raw_values
if str(item or "").strip()
]
if receipt_ids:
return list(dict.fromkeys(receipt_ids))
fields = StewardActionExecutor._resolve_ontology_fields(request.task)
raw_receipt = str(fields.get("receipt_ids") or fields.get("receiptIds") or "").strip()
if not raw_receipt:
return []
return list(dict.fromkeys(item.strip() for item in raw_receipt.split(",") if item.strip()))
@staticmethod
def _resolve_submit_precheck_blocker(context_json: dict[str, Any]) -> str:
precheck = context_json.get("precheck_result") or context_json.get("precheckResult")
if not isinstance(precheck, dict):
return "提交申请前需要先完成重复/冲突预检查。"
if bool(precheck.get("blocking")):
return str(precheck.get("summary") or "重复/冲突预检查未通过,已停止提交。").strip()
status = str(precheck.get("status") or "").strip().lower()
if status not in {"ok", "passed", "succeeded"}:
return "重复/冲突预检查未通过,已停止提交。"
return ""
@staticmethod
def _resolve_task_blockers(task: StewardTask | None) -> list[str]:
if task is None:
return []
return [
str(field or "").strip()
for field in list(task.missing_fields or [])
if str(field or "").strip()
]
@staticmethod
def _resolve_ontology_fields(task: StewardTask | None) -> dict[str, str]:
if task is None or not isinstance(task.ontology_fields, dict):
return {}
return {
str(key or "").strip(): str(value or "").strip()
for key, value in task.ontology_fields.items()
if str(key or "").strip() and str(value or "").strip()
}
@staticmethod
def _resolve_message(request: StewardActionExecuteRequest) -> str:
message = str(request.message or "").strip()
if message:
return message
if request.task is not None:
return str(request.task.summary or request.task.title or "").strip()
return "小财管家动作执行"
@staticmethod
def _resolve_transport_label(value: Any) -> str:
text = str(value or "").strip()
return TRANSPORT_MODE_LABELS.get(text.lower(), text)
@staticmethod
def _resolve_application_type_label(value: Any) -> str:
text = str(value or "").strip()
return APPLICATION_TYPE_LABELS.get(text.lower(), text or "费用申请")
@staticmethod
def _resolve_expense_type_label(value: Any) -> str:
text = str(value or "").strip()
return EXPENSE_TYPE_LABELS.get(text.lower(), text or "其他费用")
@staticmethod
def _resolve_days_text(value: Any) -> str:
days = resolve_application_days_from_time_range(str(value or ""))
return f"{days}" if days else ""
@staticmethod
def _normalize_action_type(value: str) -> str:
return str(value or "").strip()
@staticmethod
def _build_run_id(request: StewardActionExecuteRequest, action_type: str) -> str:
trace_id = str(request.client_trace_id or "").strip()
if trace_id:
return f"steward-action:{trace_id}"
task_id = str(request.task.task_id if request.task is not None else "").strip()
suffix = task_id or datetime.now(UTC).strftime("%Y%m%d%H%M%S%f")
return f"steward-action:{action_type}:{suffix}"
@staticmethod
def _trace(stage: str, **extra: Any) -> dict[str, Any]:
return {
"stage": stage,
"at": datetime.now(UTC).isoformat(),
**extra,
}
@staticmethod
def _blocked(
action_type: str,
message: str,
*,
blocked_reasons: list[str] | None = None,
trace: list[dict[str, Any]] | None = None,
) -> StewardActionExecuteResponse:
return StewardActionExecuteResponse(
action_type=action_type,
status="blocked",
execution_source="rule_fallback",
fallback_used=True,
message=message,
blocked_reasons=blocked_reasons or [message],
trace=trace or [],
)
def _failed(
self,
action_type: str,
message: str,
trace: list[dict[str, Any]],
) -> StewardActionExecuteResponse:
return StewardActionExecuteResponse(
action_type=action_type,
status="failed",
message=message or "动作执行失败。",
blocked_reasons=[message] if message else [],
trace=[*trace, self._trace("failed", reason=message)],
)

View File

@@ -0,0 +1,204 @@
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any, TypedDict
from langgraph.graph import END, START, StateGraph
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.models.agent_conversation import AgentConversation
from app.schemas.steward import StewardActionExecuteRequest, StewardActionExecuteResponse
from app.services.agent_conversations import AgentConversationService
from app.services.steward_action_executor import StewardActionExecutor
ACTION_CHECKPOINT_KEY = "steward_action_checkpoint"
TERMINAL_ACTION_STATUSES = {"succeeded", "blocked", "failed"}
class StewardGraphActionState(TypedDict, total=False):
request: StewardActionExecuteRequest
current_user: CurrentUserContext
conversation: AgentConversation | None
trace_id: str
existing_result: dict[str, Any]
response: StewardActionExecuteResponse
class StewardGraphActionRuntime:
"""用 LangGraph 包装小财管家白名单动作执行、checkpoint 和幂等重放。"""
def __init__(self, db: Session) -> None:
self.db = db
self.executor = StewardActionExecutor(db)
self._graph = self._build_graph()
def execute(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
) -> StewardActionExecuteResponse:
final_state = self._graph.invoke(
{
"request": request,
"current_user": current_user,
}
)
response = final_state.get("response")
if not isinstance(response, StewardActionExecuteResponse):
raise RuntimeError("LangGraph action runtime 未生成有效执行结果。")
return response
def _build_graph(self):
graph = StateGraph(StewardGraphActionState)
graph.add_node("action_checkpoint_load", self._load_checkpoint)
graph.add_node("action_execute_node", self._execute_action)
graph.add_node("action_checkpoint_persist", self._persist_checkpoint)
graph.add_edge(START, "action_checkpoint_load")
graph.add_conditional_edges(
"action_checkpoint_load",
self._route_after_checkpoint_load,
{
"replay": "action_checkpoint_persist",
"execute": "action_execute_node",
},
)
graph.add_edge("action_execute_node", "action_checkpoint_persist")
graph.add_edge("action_checkpoint_persist", END)
return graph.compile()
def _load_checkpoint(self, state: StewardGraphActionState) -> dict[str, Any]:
request = state["request"]
conversation = self._get_or_create_conversation(request, state["current_user"])
trace_id = self._resolve_trace_id(request)
if conversation is None or not trace_id:
return {
"conversation": conversation,
"trace_id": trace_id,
}
checkpoint = self._resolve_checkpoint(conversation)
existing = dict(checkpoint.get("actions", {}).get(trace_id) or {})
existing_response = existing.get("response")
status = str(existing.get("status") or "").strip()
if isinstance(existing_response, dict) and (
status in TERMINAL_ACTION_STATUSES
or (status == "needs_confirmation" and not request.confirmed)
):
response = StewardActionExecuteResponse.model_validate(existing_response)
response.result_payload = {
**dict(response.result_payload or {}),
"idempotent_replay": True,
}
response.trace = [
*list(response.trace or []),
self._trace("checkpoint_replay", client_trace_id=trace_id),
]
return {
"conversation": conversation,
"trace_id": trace_id,
"existing_result": existing,
"response": response,
}
return {
"conversation": conversation,
"trace_id": trace_id,
"existing_result": existing,
}
@staticmethod
def _route_after_checkpoint_load(state: StewardGraphActionState) -> str:
if isinstance(state.get("response"), StewardActionExecuteResponse):
return "replay"
return "execute"
def _execute_action(self, state: StewardGraphActionState) -> dict[str, StewardActionExecuteResponse]:
response = self.executor.execute(state["request"], state["current_user"])
return {"response": response}
def _persist_checkpoint(self, state: StewardGraphActionState) -> dict[str, StewardActionExecuteResponse]:
response = state["response"]
conversation = state.get("conversation")
trace_id = str(state.get("trace_id") or "").strip()
if conversation is None or not trace_id:
return {"response": response}
checkpoint = self._resolve_checkpoint(conversation)
actions = dict(checkpoint.get("actions") or {})
request = state["request"]
response_payload = response.model_dump(mode="json")
actions[trace_id] = {
"client_trace_id": trace_id,
"action_type": response.action_type,
"status": response.status,
"request": request.model_dump(mode="json"),
"response": response_payload,
"updated_at": datetime.now(UTC).isoformat(),
}
checkpoint["actions"] = actions
if response.status == "needs_confirmation":
checkpoint["pending_interrupt"] = {
"client_trace_id": trace_id,
"action_type": response.action_type,
"message": response.message,
"requires_confirmation": True,
"updated_at": datetime.now(UTC).isoformat(),
}
elif str(checkpoint.get("pending_interrupt", {}).get("client_trace_id") or "") == trace_id:
checkpoint["pending_interrupt"] = {}
conversation.state_json = {
**dict(conversation.state_json or {}),
ACTION_CHECKPOINT_KEY: checkpoint,
}
conversation.updated_at = datetime.now(UTC)
self.db.add(conversation)
self.db.commit()
return {"response": response}
def _get_or_create_conversation(
self,
request: StewardActionExecuteRequest,
current_user: CurrentUserContext,
) -> AgentConversation | None:
conversation_id = str(request.conversation_id or "").strip()
if not conversation_id:
return None
return AgentConversationService(self.db).get_or_create_conversation(
conversation_id=conversation_id,
user_id=current_user.username,
source="user_message",
context_json={
"session_type": "steward",
"entry_source": "steward_action_executor",
"steward_state": dict((request.context_json or {}).get("steward_state") or {}),
},
)
@staticmethod
def _resolve_checkpoint(conversation: AgentConversation) -> dict[str, Any]:
checkpoint = dict((conversation.state_json or {}).get(ACTION_CHECKPOINT_KEY) or {})
checkpoint.setdefault("actions", {})
checkpoint.setdefault("pending_interrupt", {})
return checkpoint
@staticmethod
def _resolve_trace_id(request: StewardActionExecuteRequest) -> str:
trace_id = str(request.client_trace_id or "").strip()
if trace_id:
return trace_id
action_type = str(request.action_type or "").strip()
task_id = str(request.task.task_id if request.task is not None else "").strip()
if action_type and task_id:
return f"{action_type}:{task_id}"
return ""
@staticmethod
def _trace(stage: str, **extra: Any) -> dict[str, Any]:
return {
"stage": stage,
"at": datetime.now(UTC).isoformat(),
**extra,
}

View File

@@ -0,0 +1,220 @@
from __future__ import annotations
from datetime import date
from typing import Any, TypedDict
from langgraph.graph import END, START, StateGraph
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse
from app.services.steward_action_contracts import StewardActionPlanBuilder
from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER
from app.services.steward_intent_agent import StewardIntentAgent, StewardIntentAgentResult
from app.services.steward_model_plan_builder import StewardModelPlanBuilder
from app.services.steward_off_topic_agent import StewardOffTopicAgent
from app.services.steward_planner_extraction import StewardPlannerExtractionMixin
from app.services.steward_planner_fallback import StewardPlannerFallbackMixin
class StewardGraphState(TypedDict, total=False):
request: StewardPlanRequest
message: str
base_date: date
scenario: str | None
should_use_model: bool
intent_result: StewardIntentAgentResult | None
plan: StewardPlanResponse
model_call_traces: list[dict[str, Any]]
fallback_reason: str
class StewardGraphPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtractionMixin):
"""用 LangGraph 编排小财管家的意图识别、流程判断和兜底路径。"""
def __init__(
self,
intent_agent: StewardIntentAgent | None = None,
off_topic_agent: StewardOffTopicAgent | None = None,
) -> None:
self.intent_agent = intent_agent
self.off_topic_agent = off_topic_agent
self._graph = self._build_graph()
def build_plan(self, request: StewardPlanRequest) -> StewardPlanResponse:
final_state = self._graph.invoke(
{
"request": request,
"model_call_traces": [],
"fallback_reason": "",
}
)
plan = final_state.get("plan")
if not isinstance(plan, StewardPlanResponse):
raise RuntimeError("LangGraph 小财管家规划未生成有效计划。")
return plan
def _build_graph(self):
graph = StateGraph(StewardGraphState)
graph.add_node("prepare_context", self._prepare_context)
graph.add_node("detect_model_intent", self._detect_model_intent)
graph.add_node("build_off_topic_plan", self._build_off_topic_graph_plan)
graph.add_node("build_rule_fallback_plan", self._build_rule_fallback_graph_plan)
graph.add_node("attach_action_steps", self._attach_action_steps)
graph.add_edge(START, "prepare_context")
graph.add_conditional_edges(
"prepare_context",
self._route_after_prepare_context,
{
"model": "detect_model_intent",
"off_topic": "build_off_topic_plan",
"fallback": "build_rule_fallback_plan",
},
)
graph.add_conditional_edges(
"detect_model_intent",
self._route_after_model_intent,
{
"done": "attach_action_steps",
"off_topic": "build_off_topic_plan",
"fallback": "build_rule_fallback_plan",
},
)
graph.add_edge("build_off_topic_plan", "attach_action_steps")
graph.add_edge("build_rule_fallback_plan", "attach_action_steps")
graph.add_edge("attach_action_steps", END)
return graph.compile()
def _prepare_context(self, state: StewardGraphState) -> dict[str, Any]:
request = state["request"]
message = self._clean_text(request.message)
if not message:
raise ValueError("小财管家需要一段任务描述。")
base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
return {
"message": message,
"base_date": base_date,
"scenario": self._classify_irrelevant_input(message, request),
"should_use_model": bool(
self.intent_agent is not None
and self._should_use_model_intent_recognition(message, base_date, request)
),
}
@staticmethod
def _route_after_prepare_context(state: StewardGraphState) -> str:
if state.get("should_use_model"):
return "model"
if state.get("scenario") is not None:
return "off_topic"
return "fallback"
def _detect_model_intent(self, state: StewardGraphState) -> dict[str, Any]:
request = state["request"]
message = state["message"]
base_date = state["base_date"]
model_call_traces: list[dict[str, Any]] = []
if self.intent_agent is None:
return {}
try:
intent_result = self.intent_agent.detect(
request,
base_date=base_date,
canonical_fields=list(BUSINESS_CANONICAL_FIELD_ORDER),
)
if intent_result is None:
return {
"model_call_traces": self._last_intent_call_traces(model_call_traces),
"fallback_reason": (
"主模型未返回可用的 function calling 计划,已切换到规则兜底。"
),
}
model_call_traces = intent_result.model_call_traces
llm_plan = StewardModelPlanBuilder(self).build(
intent_result,
request=request,
base_date=base_date,
)
if llm_plan is None:
return {
"model_call_traces": self._last_intent_call_traces(model_call_traces),
"fallback_reason": (
"主模型未返回可用的 function calling 计划,已切换到规则兜底。"
),
}
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return {
"plan": self._build_pending_flow_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=(
"主模型返回了直接任务,但当前话术没有明确申请或报销动作;"
"服务端已改为候选流程确认,避免误入申请流程。"
),
planning_source="llm_function_call",
),
"model_call_traces": model_call_traces,
}
return {
"intent_result": intent_result,
"plan": llm_plan,
"model_call_traces": model_call_traces,
}
except Exception as exc:
return {
"model_call_traces": self._last_intent_call_traces(model_call_traces),
"fallback_reason": f"主模型 function calling 调用失败,已切换到规则兜底:{exc}",
}
@staticmethod
def _route_after_model_intent(state: StewardGraphState) -> str:
if isinstance(state.get("plan"), StewardPlanResponse):
return "done"
if state.get("scenario") is not None:
return "off_topic"
return "fallback"
def _build_off_topic_graph_plan(
self,
state: StewardGraphState,
) -> dict[str, StewardPlanResponse]:
return {
"plan": self._build_off_topic_plan(
state["request"],
scenario=str(state["scenario"] or ""),
model_call_traces=state.get("model_call_traces"),
fallback_reason=str(state.get("fallback_reason") or ""),
)
}
def _build_rule_fallback_graph_plan(
self,
state: StewardGraphState,
) -> dict[str, StewardPlanResponse]:
return {
"plan": self._build_rule_fallback_plan(
state["request"],
base_date=state["base_date"],
model_call_traces=state.get("model_call_traces"),
fallback_reason=str(state.get("fallback_reason") or ""),
)
}
@staticmethod
def _attach_action_steps(state: StewardGraphState) -> dict[str, StewardPlanResponse]:
plan = state.get("plan")
if not isinstance(plan, StewardPlanResponse):
raise RuntimeError("LangGraph 小财管家动作规划缺少有效计划。")
return {"plan": StewardActionPlanBuilder().attach_action_steps(plan)}
def _last_intent_call_traces(
self,
fallback_traces: list[dict[str, Any]],
) -> list[dict[str, Any]]:
return getattr(self.intent_agent, "last_call_traces", []) or fallback_traces

View File

@@ -0,0 +1,214 @@
from __future__ import annotations
from typing import Any, TypedDict
from langgraph.graph import END, START, StateGraph
from app.schemas.steward import (
StewardRuntimeDecisionRequest,
StewardRuntimeDecisionResponse,
StewardSlotDecisionRequest,
StewardSlotDecisionResponse,
)
from app.services.runtime_chat import RuntimeChatService
from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent
from app.services.steward_slot_decision_agent import StewardSlotDecisionAgent
class StewardSlotGraphState(TypedDict, total=False):
request: StewardSlotDecisionRequest
normalized_request: StewardSlotDecisionRequest
decision: StewardSlotDecisionResponse
model_call_traces: list[dict[str, Any]]
fallback_reason: str
class StewardRuntimeGraphState(TypedDict, total=False):
request: StewardRuntimeDecisionRequest
normalized_request: StewardRuntimeDecisionRequest
action_decision: StewardRuntimeDecisionResponse
decision: StewardRuntimeDecisionResponse
model_call_traces: list[dict[str, Any]]
fallback_reason: str
class StewardGraphRuntime:
"""用 LangGraph 编排会话内槽位、记忆和行动决策。"""
def __init__(self, runtime_chat_service: RuntimeChatService) -> None:
self.slot_agent = StewardSlotDecisionAgent(runtime_chat_service)
self.runtime_agent = StewardRuntimeDecisionAgent(runtime_chat_service)
self._slot_graph = self._build_slot_graph()
self._runtime_graph = self._build_runtime_graph()
def decide_slot(self, request: StewardSlotDecisionRequest) -> StewardSlotDecisionResponse:
final_state = self._slot_graph.invoke(
{
"request": request,
"model_call_traces": [],
"fallback_reason": "",
}
)
decision = final_state.get("decision")
if not isinstance(decision, StewardSlotDecisionResponse):
raise RuntimeError("LangGraph 槽位决策未生成有效结果。")
return decision
def decide_runtime(self, request: StewardRuntimeDecisionRequest) -> StewardRuntimeDecisionResponse:
final_state = self._runtime_graph.invoke(
{
"request": request,
"model_call_traces": [],
"fallback_reason": "",
}
)
decision = final_state.get("decision")
if not isinstance(decision, StewardRuntimeDecisionResponse):
raise RuntimeError("LangGraph 运行时决策未生成有效结果。")
return decision
def _build_slot_graph(self):
graph = StateGraph(StewardSlotGraphState)
graph.add_node("slot_prepare_context", self._slot_prepare_context)
graph.add_node("slot_tool_decision", self._slot_tool_decision)
graph.add_node("slot_rule_fallback", self._slot_rule_fallback)
graph.add_edge(START, "slot_prepare_context")
graph.add_edge("slot_prepare_context", "slot_tool_decision")
graph.add_conditional_edges(
"slot_tool_decision",
self._route_after_slot_tool_decision,
{
"done": END,
"fallback": "slot_rule_fallback",
},
)
graph.add_edge("slot_rule_fallback", END)
return graph.compile()
def _build_runtime_graph(self):
graph = StateGraph(StewardRuntimeGraphState)
graph.add_node("runtime_memory_context", self._runtime_memory_context)
graph.add_node("runtime_action_decision", self._runtime_action_decision)
graph.add_node("runtime_tool_decision", self._runtime_tool_decision)
graph.add_node("runtime_rule_fallback", self._runtime_rule_fallback)
graph.add_edge(START, "runtime_memory_context")
graph.add_conditional_edges(
"runtime_memory_context",
self._route_after_runtime_memory_context,
{
"action": "runtime_action_decision",
"tool": "runtime_tool_decision",
},
)
graph.add_edge("runtime_action_decision", END)
graph.add_conditional_edges(
"runtime_tool_decision",
self._route_after_runtime_tool_decision,
{
"done": END,
"fallback": "runtime_rule_fallback",
},
)
graph.add_edge("runtime_rule_fallback", END)
return graph.compile()
def _slot_prepare_context(self, state: StewardSlotGraphState) -> dict[str, Any]:
return {
"normalized_request": self.slot_agent._normalize_request(state["request"]),
}
def _slot_tool_decision(self, state: StewardSlotGraphState) -> dict[str, Any]:
request = state.get("normalized_request") or state["request"]
try:
return {"decision": self.slot_agent.decide(request)}
except Exception as exc:
return {
"model_call_traces": [
*state.get("model_call_traces", []),
self._build_failure_trace("langgraph_slot_decision", exc),
],
"fallback_reason": f"LangGraph 槽位工具节点失败,已切换规则兜底:{exc}",
}
@staticmethod
def _route_after_slot_tool_decision(state: StewardSlotGraphState) -> str:
if isinstance(state.get("decision"), StewardSlotDecisionResponse):
return "done"
return "fallback"
def _slot_rule_fallback(self, state: StewardSlotGraphState) -> dict[str, StewardSlotDecisionResponse]:
request = state.get("normalized_request") or self.slot_agent._normalize_request(state["request"])
return {
"decision": self.slot_agent._build_rule_fallback(
request,
state.get("model_call_traces", []),
)
}
def _runtime_memory_context(self, state: StewardRuntimeGraphState) -> dict[str, Any]:
request = self.runtime_agent._normalize_request(state["request"])
action_decision = self.runtime_agent._build_selected_flow_decision(request, [])
update: dict[str, Any] = {"normalized_request": request}
if action_decision is not None:
update["action_decision"] = action_decision
return update
@staticmethod
def _route_after_runtime_memory_context(state: StewardRuntimeGraphState) -> str:
if isinstance(state.get("action_decision"), StewardRuntimeDecisionResponse):
return "action"
return "tool"
def _runtime_action_decision(
self,
state: StewardRuntimeGraphState,
) -> dict[str, StewardRuntimeDecisionResponse]:
return {"decision": state["action_decision"]}
def _runtime_tool_decision(self, state: StewardRuntimeGraphState) -> dict[str, Any]:
request = state.get("normalized_request") or state["request"]
try:
return {"decision": self.runtime_agent.decide(request)}
except Exception as exc:
return {
"model_call_traces": [
*state.get("model_call_traces", []),
self._build_failure_trace("langgraph_runtime_decision", exc),
],
"fallback_reason": f"LangGraph 运行时工具节点失败,已切换规则兜底:{exc}",
}
@staticmethod
def _route_after_runtime_tool_decision(state: StewardRuntimeGraphState) -> str:
if isinstance(state.get("decision"), StewardRuntimeDecisionResponse):
return "done"
return "fallback"
def _runtime_rule_fallback(
self,
state: StewardRuntimeGraphState,
) -> dict[str, StewardRuntimeDecisionResponse]:
request = state.get("normalized_request") or self.runtime_agent._normalize_request(state["request"])
decision = self.runtime_agent._build_rule_fallback(
request,
state.get("model_call_traces", []),
)
return {
"decision": self.runtime_agent._attach_updated_steward_state(
decision,
request,
)
}
@staticmethod
def _build_failure_trace(slot: str, exc: Exception) -> dict[str, Any]:
return {
"slot": slot,
"provider": "langgraph",
"model": "",
"attempt": 1,
"status": "failed",
"error": str(exc),
}

View File

@@ -42,8 +42,9 @@ class StewardIntentAgent:
},
max_tokens=1800,
temperature=0.1,
timeout_seconds=45,
max_attempts=1,
timeout_seconds=10,
max_attempts=3,
use_failure_cooldown=False,
)
self.last_call_traces = result.calls_as_dicts()
if result.tool_call is None or result.tool_call.name != STEWARD_INTENT_FUNCTION_NAME:
@@ -113,6 +114,8 @@ class StewardIntentAgent:
"candidate_flows 同时给出 travel_application 和 travel_reimbursementtasks 保持空数组。"
"所有 ontology_fields 只能使用调用方给出的 canonical_ontology_fields"
"如果输入里出现 occurred_date、transport_type、reason_value 等别名,必须映射为 canonical 字段。"
"每个 task 必须输出 requested_action用户只是要求整理/发起但未说保存或提交时为 preview"
"用户说保存草稿、先保存、存草稿时为 save_draft用户说直接提交、提交申请、确认提交时为 submit。"
"相对日期必须以 base_date 为准转换为明确日期。"
"thinking_events 只能是面向用户的过程摘要,不能暴露内部推理链。"
"如果用户输入与出差、费用、报销、申请等财务事项完全无关"
@@ -165,6 +168,10 @@ class StewardIntentAgent:
"minimum": 0,
"maximum": 1,
},
"requested_action": {
"type": "string",
"enum": ["preview", "save_draft", "submit"],
},
"ontology_fields": {
"type": "object",
"additionalProperties": {"type": "string"},
@@ -182,6 +189,7 @@ class StewardIntentAgent:
"title",
"summary",
"confidence",
"requested_action",
"ontology_fields",
"missing_fields",
],

View File

@@ -157,6 +157,10 @@ class StewardModelPlanBuilder:
task_type=task_type,
fields=fields,
)
requested_action = self._sanitize_requested_action(
raw_task.get("requested_action"),
fallback_text=request.message,
)
tasks.append(
StewardTask(
task_id=task_id,
@@ -171,6 +175,7 @@ class StewardModelPlanBuilder:
fields=fields,
task_type=task_type,
),
requested_action=requested_action, # type: ignore[arg-type]
ontology_fields=fields,
missing_fields=missing_fields,
confirmation_required=True,
@@ -441,6 +446,17 @@ class StewardModelPlanBuilder:
missing_fields.append(key)
return missing_fields
def _sanitize_requested_action(self, raw_action: Any, *, fallback_text: str = "") -> str:
action = self.planner._clean_text(raw_action)
if action in {"preview", "save_draft", "submit"}:
return action
compact = self.planner._clean_text(fallback_text).replace(" ", "")
if any(keyword in compact for keyword in ("直接提交", "提交申请", "确认提交", "提交审批")):
return "submit"
if any(keyword in compact for keyword in ("保存草稿", "存草稿", "先保存")):
return "save_draft"
return "preview"
def _resolve_model_confidence(
self,
value: Any,
@@ -459,6 +475,8 @@ class StewardModelPlanBuilder:
if not cleaned:
return ""
if key == "time_range":
if re.search(r"(?:至|到|~||—|--|-).*\d", cleaned):
return cleaned
return self.planner._extract_time_range(cleaned, base_date) or cleaned
if key == "expense_type":
return self._normalize_expense_type_value(cleaned)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from typing import Any
from app.schemas.steward import StewardPlanRequest, StewardPlanResponse
from app.services.steward_action_contracts import StewardActionPlanBuilder
from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER
from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_model_plan_builder import StewardModelPlanBuilder
@@ -28,10 +29,7 @@ class StewardPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtractio
raise ValueError("小财管家需要一段任务描述。")
base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
# 业务无关输入拦截(纯数字、问候、闲聊、乱码等):在进入 LLM/规则兜底之前直接返回 off_topic 计划。
scenario = self._classify_irrelevant_input(message, request)
if scenario is not None:
return self._build_off_topic_plan(request, scenario=scenario)
model_call_traces: list[dict[str, Any]] = []
fallback_reason = ""
if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request):
@@ -50,26 +48,44 @@ class StewardPlannerService(StewardPlannerFallbackMixin, StewardPlannerExtractio
)
if llm_plan is not None:
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return self._build_pending_flow_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=(
"主模型返回了直接任务,但当前话术没有明确申请或报销动作;"
"服务端已改为候选流程确认,避免误入申请流程。"
),
planning_source="llm_function_call",
return self._with_action_steps(
self._build_pending_flow_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=(
"主模型返回了直接任务,但当前话术没有明确申请或报销动作;"
"服务端已改为候选流程确认,避免误入申请流程。"
),
planning_source="llm_function_call",
)
)
return llm_plan
return self._with_action_steps(llm_plan)
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
fallback_reason = "主模型未返回可用的 function calling 计划,已切换到规则兜底。"
except Exception as exc:
model_call_traces = getattr(self.intent_agent, "last_call_traces", []) or model_call_traces
fallback_reason = f"主模型 function calling 调用失败,已切换到规则兜底:{exc}"
return self._build_rule_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=fallback_reason,
if scenario is not None:
return self._with_action_steps(
self._build_off_topic_plan(
request,
scenario=scenario,
model_call_traces=model_call_traces,
fallback_reason=fallback_reason,
)
)
return self._with_action_steps(
self._build_rule_fallback_plan(
request,
base_date=base_date,
model_call_traces=model_call_traces,
fallback_reason=fallback_reason,
)
)
@staticmethod
def _with_action_steps(plan: StewardPlanResponse) -> StewardPlanResponse:
return StewardActionPlanBuilder().attach_action_steps(plan)

View File

@@ -133,6 +133,7 @@ class StewardPlannerExtractionMixin:
summary=self._build_task_summary(draft.segment, fields),
status="needs_confirmation",
confidence=self._resolve_task_confidence(draft.segment, fields, draft.task_type),
requested_action=self._resolve_requested_action(draft.segment), # type: ignore[arg-type]
ontology_fields=fields,
missing_fields=missing_fields,
confirmation_required=True,
@@ -205,6 +206,15 @@ class StewardPlannerExtractionMixin:
def _extract_transport_mode(segment: str) -> str:
return ApplicationFactResolver.extract_transport_mode(segment)
@staticmethod
def _resolve_requested_action(segment: str) -> str:
compact = re.sub(r"\s+", "", segment)
if re.search(r"直接提交|提交申请|确认提交|提交审批", compact):
return "submit"
if re.search(r"保存草稿|存草稿|先保存|保存", compact):
return "save_draft"
return "preview"
@staticmethod
def _resolve_missing_fields(task_type: str, fields: dict[str, str]) -> list[str]:
required = ["expense_type", "time_range", "reason"]
@@ -575,4 +585,3 @@ class StewardPlannerExtractionMixin:
@staticmethod
def _clean_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()

View File

@@ -34,9 +34,8 @@ class StewardPlannerFallbackMixin:
base_date: date,
request: StewardPlanRequest,
) -> bool:
if self._looks_like_ambiguous_travel_flow(message, base_date, request):
return False
return self._has_multiple_financial_demands(message)
return bool(message.strip())
@staticmethod
def _is_business_irrelevant_input(message: str, request: StewardPlanRequest) -> bool:
@@ -105,12 +104,14 @@ class StewardPlannerFallbackMixin:
request: StewardPlanRequest,
*,
scenario: str,
model_call_traces: list[dict[str, Any]] | None = None,
fallback_reason: str = "",
) -> StewardPlanResponse:
"""业务无关输入的兜底计划根据场景给出对应引导off_business 场景可由 LLM 增强。"""
base_summary = self._default_off_topic_summary(scenario)
thinking_event = self._build_off_topic_thinking_event(scenario)
suggested_prompts = self._off_topic_suggested_prompts(scenario)
model_call_traces: list[dict[str, Any]] = []
traces = list(model_call_traces or [])
# 仅对 off_business 场景尝试让 LLM 生成多样化引导;问候/无意义场景用规则模板即可。
if (
@@ -121,24 +122,36 @@ class StewardPlannerFallbackMixin:
llm_result = self.off_topic_agent.generate(request, scenario=scenario)
if llm_result is not None and llm_result.response_text:
base_summary = llm_result.response_text
model_call_traces = llm_result.model_call_traces
traces = llm_result.model_call_traces
except Exception:
# 失败时静默回退到规则模板
pass
thinking_events = [thinking_event]
if fallback_reason:
thinking_events.insert(
0,
StewardThinkingEvent(
event_id="intent_agent_rule_fallback",
stage="rule_fallback",
title="意图识别智能体进入兜底模式",
content=fallback_reason,
),
)
return StewardPlanResponse(
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="off_topic",
planning_source="rule_fallback",
next_action="none",
summary=base_summary,
thinking_events=[thinking_event],
thinking_events=thinking_events,
tasks=[],
attachment_groups=[],
confirmation_groups=[],
candidate_flows=[],
suggested_prompts=suggested_prompts,
model_call_traces=model_call_traces,
model_call_traces=traces,
)
@staticmethod
@@ -435,4 +448,3 @@ class StewardPlannerFallbackMixin:
)
return drafts

View File

@@ -1,97 +0,0 @@
Metadata-Version: 2.4
Name: x-financial-server
Version: 0.1.0
Summary: Backend service for X-Financial reimbursement and approval platform.
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Requires-Dist: fastapi<1.0.0,>=0.115.0
Requires-Dist: uvicorn[standard]<1.0.0,>=0.30.0
Requires-Dist: sqlalchemy<3.0.0,>=2.0.36
Requires-Dist: alembic<2.0.0,>=1.14.0
Requires-Dist: psycopg[binary]<4.0.0,>=3.2.0
Requires-Dist: PyJWT<3.0.0,>=2.9.0
Requires-Dist: pydantic-settings<3.0.0,>=2.6.0
Requires-Dist: python-dotenv<2.0.0,>=1.0.1
Requires-Dist: email-validator<3.0.0,>=2.2.0
Requires-Dist: python-multipart<1.0.0,>=0.0.20
Requires-Dist: openpyxl<4.0.0,>=3.1.5
Requires-Dist: lightrag-hku<1.5.0,>=1.4.16
Requires-Dist: qdrant-client<2.0.0,>=1.18.0
Provides-Extra: dev
Requires-Dist: pytest<9.0.0,>=8.3.0; extra == "dev"
Requires-Dist: httpx<1.0.0,>=0.28.0; extra == "dev"
Requires-Dist: ruff<1.0.0,>=0.8.0; extra == "dev"
Provides-Extra: redis
Requires-Dist: redis<6.0.0,>=5.2.0; extra == "redis"
# Server
后端已按 `FastAPI + PostgreSQL + SQLAlchemy + Alembic` 起好基础工程。
## 为什么先选 PostgreSQL
这个项目是报销、审批、员工、流程、审计记录为主,核心特点是:
- 强事务
- 多表关联明显
- 审批流和审计日志需要一致性
- 后续大概率要做复杂查询、统计和条件筛选
这类系统优先选关系型数据库更合适,`PostgreSQL` 是当前默认推荐。
## Redis 要不要现在上
现在 **不是必须**。
先不把 Redis 作为启动前置,原因很直接:
- 当前第一阶段先把核心业务表、接口、权限、审批流跑通
- 如果一开始就把 Redis 绑死,会增加部署和排障复杂度
Redis 更适合后面这些场景:
- 登录态 / token 黑名单
- 热点数据缓存
- 限流
- 分布式锁
- 消息队列 / 后台任务
所以现在的策略是:
- 主数据库:`PostgreSQL`
- Redis`可选能力`,配置已预留,但不是必需依赖
## 目录
- `src/app/`:应用代码
- `alembic/`:数据库迁移
- `tests/`:测试
## 启动
1. 创建虚拟环境并安装依赖
```bash
cd server
python -m venv .venv
.venv\\Scripts\\activate
pip install -e .[dev]
```
2. 在项目根目录准备环境变量
```bash
copy ..\\.env.example ..\\.env
```
3. 启动服务
```bash
uvicorn app.main:app --reload --app-dir src
```
## 迁移
```bash
alembic upgrade head
```

View File

@@ -1,139 +0,0 @@
README.md
pyproject.toml
src/app/__init__.py
src/app/main.py
src/app/api/__init__.py
src/app/api/deps.py
src/app/api/router.py
src/app/api/v1/__init__.py
src/app/api/v1/router.py
src/app/api/v1/endpoints/__init__.py
src/app/api/v1/endpoints/agent_assets.py
src/app/api/v1/endpoints/agent_runs.py
src/app/api/v1/endpoints/audit_logs.py
src/app/api/v1/endpoints/auth.py
src/app/api/v1/endpoints/bootstrap.py
src/app/api/v1/endpoints/employees.py
src/app/api/v1/endpoints/health.py
src/app/api/v1/endpoints/knowledge.py
src/app/api/v1/endpoints/ocr.py
src/app/api/v1/endpoints/ontology.py
src/app/api/v1/endpoints/orchestrator.py
src/app/api/v1/endpoints/reimbursements.py
src/app/api/v1/endpoints/settings.py
src/app/api/v1/endpoints/system_logs.py
src/app/core/__init__.py
src/app/core/admin_secret.py
src/app/core/agent_enums.py
src/app/core/bootstrap.py
src/app/core/config.py
src/app/core/logging.py
src/app/core/openapi.py
src/app/core/secret_box.py
src/app/core/security.py
src/app/db/__init__.py
src/app/db/base.py
src/app/db/base_class.py
src/app/db/session.py
src/app/middleware/__init__.py
src/app/middleware/logging.py
src/app/models/__init__.py
src/app/models/agent_asset.py
src/app/models/agent_conversation.py
src/app/models/agent_run.py
src/app/models/approval.py
src/app/models/audit_log.py
src/app/models/employee.py
src/app/models/employee_change_log.py
src/app/models/financial_record.py
src/app/models/organization.py
src/app/models/reimbursement.py
src/app/models/role.py
src/app/models/system_model_setting.py
src/app/models/system_setting.py
src/app/models/system_setting_secret.py
src/app/repositories/__init__.py
src/app/repositories/agent_asset.py
src/app/repositories/agent_run.py
src/app/repositories/audit_log.py
src/app/repositories/employee.py
src/app/repositories/reimbursement.py
src/app/repositories/settings.py
src/app/schemas/__init__.py
src/app/schemas/agent_asset.py
src/app/schemas/agent_run.py
src/app/schemas/audit_log.py
src/app/schemas/auth.py
src/app/schemas/bootstrap.py
src/app/schemas/common.py
src/app/schemas/employee.py
src/app/schemas/knowledge.py
src/app/schemas/ocr.py
src/app/schemas/ontology.py
src/app/schemas/orchestrator.py
src/app/schemas/reimbursement.py
src/app/schemas/settings.py
src/app/schemas/system_log.py
src/app/schemas/user_agent.py
src/app/services/__init__.py
src/app/services/agent_asset_spreadsheet.py
src/app/services/agent_assets.py
src/app/services/agent_conversations.py
src/app/services/agent_foundation.py
src/app/services/agent_runs.py
src/app/services/audit.py
src/app/services/auth.py
src/app/services/document_intelligence.py
src/app/services/employee.py
src/app/services/employee_seed.py
src/app/services/expense_claims.py
src/app/services/expense_rule_runtime.py
src/app/services/hermes_sync.py
src/app/services/knowledge.py
src/app/services/knowledge_index_tasks.py
src/app/services/knowledge_normalizer.py
src/app/services/knowledge_rag.py
src/app/services/knowledge_scheduler.py
src/app/services/knowledge_sync.py
src/app/services/model_connectivity.py
src/app/services/ocr.py
src/app/services/ontology.py
src/app/services/orchestrator.py
src/app/services/reimbursement.py
src/app/services/runtime_chat.py
src/app/services/settings.py
src/app/services/system_hermes.py
src/app/services/system_logs.py
src/app/services/user_agent.py
src/x_financial_server.egg-info/PKG-INFO
src/x_financial_server.egg-info/SOURCES.txt
src/x_financial_server.egg-info/dependency_links.txt
src/x_financial_server.egg-info/requires.txt
src/x_financial_server.egg-info/top_level.txt
tests/test_agent_asset_onlyoffice_key.py
tests/test_agent_asset_service.py
tests/test_agent_asset_spreadsheet_import.py
tests/test_agent_foundation_endpoints.py
tests/test_agent_runs_service.py
tests/test_auth_service.py
tests/test_config_settings_reload.py
tests/test_document_intelligence.py
tests/test_employee_service.py
tests/test_env_file_precedence.py
tests/test_expense_claim_service.py
tests/test_imports.py
tests/test_knowledge_normalizer.py
tests/test_knowledge_onlyoffice_config.py
tests/test_knowledge_rag_service.py
tests/test_knowledge_service.py
tests/test_ocr_endpoints.py
tests/test_ocr_service.py
tests/test_ontology_service.py
tests/test_openapi_schema.py
tests/test_reimbursement_endpoints.py
tests/test_runtime_chat_service.py
tests/test_server_start_dependencies.py
tests/test_settings_persistence.py
tests/test_settings_service.py
tests/test_system_logs_service.py
tests/test_user_agent_service.py

View File

@@ -1,21 +0,0 @@
fastapi<1.0.0,>=0.115.0
uvicorn[standard]<1.0.0,>=0.30.0
sqlalchemy<3.0.0,>=2.0.36
alembic<2.0.0,>=1.14.0
psycopg[binary]<4.0.0,>=3.2.0
PyJWT<3.0.0,>=2.9.0
pydantic-settings<3.0.0,>=2.6.0
python-dotenv<2.0.0,>=1.0.1
email-validator<3.0.0,>=2.2.0
python-multipart<1.0.0,>=0.0.20
openpyxl<4.0.0,>=3.1.5
lightrag-hku<1.5.0,>=1.4.16
qdrant-client<2.0.0,>=1.18.0
[dev]
pytest<9.0.0,>=8.3.0
httpx<1.0.0,>=0.28.0
ruff<1.0.0,>=0.8.0
[redis]
redis<6.0.0,>=5.2.0

View File

@@ -22,6 +22,17 @@ def test_application_fact_resolver_extracts_travel_application_fields() -> None:
assert facts["transport_mode"] == "train"
def test_application_fact_resolver_drops_transport_prompt_from_application_reason() -> None:
facts = resolve_application_facts(
"2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交",
"expense_application",
date(2026, 6, 24),
)
assert facts["reason"] == "辅助国网仿生产服务器部署"
assert facts["transport_mode"] == "train"
def test_application_fact_resolver_preserves_reimbursement_transport_semantics() -> None:
facts = resolve_application_facts(
"报销昨天去北京客户现场沟通产生的出租车费用",

View File

@@ -939,7 +939,7 @@ def test_application_preview_action_submits_without_orchestrator_run(monkeypatch
assert draft_payload["draft_type"] == "expense_application"
assert draft_payload["status"] == "submitted"
assert draft_payload["approval_stage"] == "直属领导审批"
assert draft_payload["claim_no"].startswith("AP-")
assert draft_payload["claim_no"].startswith("A")
with session_factory() as db:
claim = db.get(ExpenseClaim, draft_payload["claim_id"])
@@ -1015,7 +1015,7 @@ def test_application_preview_action_saves_draft_with_detail_reference(monkeypatc
assert draft_payload["status"] == "draft"
assert draft_payload["approval_stage"] == "待提交"
assert draft_payload["claim_id"]
assert draft_payload["claim_no"].startswith("AP-")
assert draft_payload["claim_no"].startswith("A")
with session_factory() as db:
claim = db.get(ExpenseClaim, draft_payload["claim_id"])

View File

@@ -242,6 +242,50 @@ def test_runtime_chat_supports_single_pass_fast_failover(monkeypatch) -> None:
assert calls == [("main", 8), ("backup", 20)]
def test_runtime_chat_complete_with_tool_call_fails_over_to_backup_before_retrying_main(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
calls: list[str] = []
def fake_load_chat_slot(slot: str):
return {
"slot": slot,
"provider": "MiniMax" if slot == "main" else "GLM",
"endpoint": "https://example.com/v1",
"model": "main-model" if slot == "main" else "backup-model",
"apiKey": "secret",
}
def fake_request_chat_tool_call(config, messages, *, tools, tool_choice, max_tokens, temperature, timeout_seconds):
del messages, tools, tool_choice, max_tokens, temperature, timeout_seconds
calls.append(config["slot"])
if config["slot"] == "main":
raise RuntimeError("main tool call unavailable")
return runtime_chat_module.RuntimeChatToolCall(
name="submit_steward_intent_plan",
arguments={"tasks": [{"task_type": "expense_application"}]},
)
monkeypatch.setattr(service, "_load_chat_slot", fake_load_chat_slot)
monkeypatch.setattr(service, "_request_chat_tool_call", fake_request_chat_tool_call)
result = service.complete_with_tool_call(
[{"role": "user", "content": "保存草稿"}],
tools=[{"type": "function", "function": {"name": "submit_steward_intent_plan"}}],
tool_choice={"type": "function", "function": {"name": "submit_steward_intent_plan"}},
max_attempts=3,
use_failure_cooldown=False,
)
assert result.tool_call is not None
assert result.tool_call.name == "submit_steward_intent_plan"
assert result.tool_call.arguments["tasks"][0]["task_type"] == "expense_application"
assert calls == ["main", "backup"]
assert [item.status for item in result.calls] == ["failed", "succeeded"]
def test_runtime_chat_skips_slot_during_cooldown(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
@@ -271,3 +315,51 @@ def test_runtime_chat_skips_slot_during_cooldown(monkeypatch) -> None:
assert service.complete([{"role": "user", "content": "hello"}], max_attempts=1) == "backup answer"
assert service.complete([{"role": "user", "content": "hello again"}], max_attempts=1) == "backup answer"
assert calls == ["main", "backup", "backup"]
def test_runtime_chat_tool_call_can_retry_without_failure_cooldown(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
calls: list[str] = []
def fake_load_chat_slot(slot: str):
return {
"slot": slot,
"provider": slot,
"endpoint": "https://example.com/v1",
"model": f"{slot}-model",
"apiKey": "secret",
}
def fake_request_chat_tool_call(
config,
messages,
*,
tools,
tool_choice,
max_tokens,
temperature,
timeout_seconds,
):
del messages, tools, tool_choice, max_tokens, temperature, timeout_seconds
calls.append(config["slot"])
raise RuntimeError("tool call timeout")
monkeypatch.setattr(service, "_load_chat_slot", fake_load_chat_slot)
monkeypatch.setattr(service, "_request_chat_tool_call", fake_request_chat_tool_call)
monkeypatch.setattr("app.services.runtime_chat.sleep", lambda *_args, **_kwargs: None)
result = service.complete_with_tool_call(
[{"role": "user", "content": "hello"}],
tools=[{"type": "function", "function": {"name": "submit_steward_intent_plan"}}],
tool_choice={"type": "function", "function": {"name": "submit_steward_intent_plan"}},
slot_priority=("main",),
max_attempts=3,
use_failure_cooldown=False,
)
assert result.tool_call is None
assert calls == ["main", "main", "main"]
assert [item.status for item in result.calls] == ["failed", "failed", "failed"]

View File

@@ -0,0 +1,455 @@
from __future__ import annotations
from collections.abc import Generator
from datetime import UTC, datetime
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app
from app.models.agent_conversation import AgentConversation
from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim
from app.services import attachment_association_jobs as attachment_jobs_module
def build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def build_client() -> tuple[TestClient, sessionmaker[Session]]:
session_factory = build_session_factory()
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), session_factory
def seed_employee(db: Session) -> None:
manager = Employee(
id="steward-action-manager",
employee_no="E90000",
name="李总",
email="leader@example.com",
position="部门负责人",
grade="P7",
)
employee = Employee(
id="steward-action-employee",
employee_no="E90001",
name="张三",
email="zhangsan@example.com",
position="实施工程师",
grade="P4",
manager=manager,
)
db.add_all([manager, employee])
db.commit()
def auth_headers() -> dict[str, str]:
return {
"x-auth-username": "zhangsan@example.com",
"x-auth-name": "Zhang San",
"x-auth-employee-no": "E90001",
"x-auth-role-codes": "user",
"x-auth-position": "Engineer",
"x-auth-grade": "P4",
"x-auth-manager-name": "Leader",
}
def base_application_task(requested_action: str = "save_draft") -> dict[str, object]:
return {
"task_id": "task_app_001",
"task_type": "expense_application",
"assigned_agent": "application_assistant",
"title": "上海出差申请",
"summary": "2026-02-20 至 2026-02-23 去上海出差,辅助国网仿生产服务器部署,火车出行。",
"status": "needs_confirmation",
"confidence": 0.96,
"requested_action": requested_action,
"ontology_fields": {
"expense_type": "travel",
"time_range": "2026-02-20 至 2026-02-23",
"location": "上海",
"reason": "辅助国网仿生产服务器部署",
"transport_mode": "train",
},
"missing_fields": [],
"confirmation_required": requested_action == "submit",
"action_steps": [],
}
def base_reimbursement_task() -> dict[str, object]:
return {
"task_id": "task_reim_001",
"task_type": "reimbursement",
"assigned_agent": "reimbursement_assistant",
"title": "客户现场交通费报销",
"summary": "2026-03-04 打车去客户现场,交通费 32 元。",
"status": "needs_confirmation",
"confidence": 0.9,
"requested_action": "save_draft",
"ontology_fields": {
"expense_type": "transport",
"time_range": "2026-03-04",
"location": "客户现场",
"reason": "客户现场沟通",
"amount": "32元",
"transport_mode": "taxi",
},
"missing_fields": [],
"confirmation_required": False,
"action_steps": [],
}
def claim_count(db: Session) -> int:
return len(db.scalars(select(ExpenseClaim)).all())
def seed_approved_application(db: Session) -> None:
application = ExpenseClaim(
id="application-action-approved",
claim_no="AAPPROVED1",
employee_id="steward-action-employee",
employee_name="张三",
department_id="dept-delivery",
department_name="交付部",
project_code=None,
expense_type="travel_application",
reason="辅助国网仿生产服务器部署",
location="上海",
amount=3000,
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=None,
status="approved",
approval_stage="已完成",
risk_flags_json=[],
)
db.add(application)
db.commit()
def test_steward_action_executor_rejects_unknown_action_without_creating_claim() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
before_count = claim_count(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "delete_all_claims",
"message": "请执行未知动作",
"task": base_application_task(),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "blocked"
assert payload["action_type"] == "delete_all_claims"
assert "不支持" in payload["message"]
with session_factory() as db:
assert claim_count(db) == before_count
def test_steward_action_executor_blocks_attachment_action_without_receipts() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
before_count = claim_count(db)
task = base_reimbursement_task()
task["ontology_fields"] = {
**task["ontology_fields"],
"attachments": "taxi.png",
}
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "associate_attachments",
"message": "关联附件 taxi.png",
"task": task,
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "blocked"
assert "receipt_id" in payload["message"] or "票据" in payload["message"]
with session_factory() as db:
assert claim_count(db) == before_count
def test_steward_action_executor_records_pending_interrupt_in_conversation_state() -> 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": "submit_application",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交",
"conversation_id": "conv-action-submit",
"client_trace_id": "trace-submit-pending",
"task": base_application_task("submit"),
"confirmed": False,
"context_json": {
"precheck_result": {
"status": "ok",
"blocking": False,
}
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "needs_confirmation"
with session_factory() as db:
conversation = db.scalar(
select(AgentConversation).where(
AgentConversation.conversation_id == "conv-action-submit"
)
)
assert conversation is not None
checkpoint = conversation.state_json["steward_action_checkpoint"]
assert checkpoint["pending_interrupt"]["client_trace_id"] == "trace-submit-pending"
assert checkpoint["pending_interrupt"]["action_type"] == "submit_application"
assert checkpoint["actions"]["trace-submit-pending"]["status"] == "needs_confirmation"
def test_steward_action_executor_reuses_checkpoint_for_duplicate_trace_without_duplicate_draft() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
request_payload = {
"action_type": "save_application_draft",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车保存草稿",
"conversation_id": "conv-action-draft",
"client_trace_id": "trace-save-draft",
"task": base_application_task("save_draft"),
}
first_response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json=request_payload,
)
second_response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json=request_payload,
)
assert first_response.status_code == 200
assert second_response.status_code == 200
first_payload = first_response.json()
second_payload = second_response.json()
assert first_payload["status"] == "succeeded"
assert second_payload["status"] == "succeeded"
assert (
first_payload["result_payload"]["draft_payload"]["claim_id"]
== second_payload["result_payload"]["draft_payload"]["claim_id"]
)
assert second_payload["result_payload"]["idempotent_replay"] is True
with session_factory() as db:
assert claim_count(db) == 1
def test_steward_action_executor_requires_confirmation_before_submit_side_effect() -> 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": "submit_application",
"message": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交",
"task": base_application_task("submit"),
"confirmed": False,
"context_json": {
"precheck_result": {
"status": "ok",
"blocking": False,
}
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "needs_confirmation"
assert payload["requires_confirmation"] is True
with session_factory() as db:
assert claim_count(db) == 0
def test_steward_action_executor_saves_application_draft_from_action_step() -> 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": "2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车保存草稿",
"task": base_application_task("save_draft"),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
draft_payload = payload["result_payload"]["draft_payload"]
assert draft_payload["draft_type"] == "expense_application"
assert draft_payload["status"] == "draft"
assert draft_payload["claim_no"].startswith("A")
with session_factory() as db:
claim = db.scalars(select(ExpenseClaim)).one()
assert claim.status == "draft"
assert claim.reason == "辅助国网仿生产服务器部署"
def test_steward_action_executor_creates_reimbursement_draft_from_action_step() -> 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": "create_reimbursement_draft",
"message": "2026-03-04打车去客户现场交通费32元保存草稿",
"task": base_reimbursement_task(),
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["status"] == "draft"
assert payload["result_payload"]["claim_id"]
with session_factory() as db:
claim = db.scalars(select(ExpenseClaim)).one()
assert claim.status == "draft"
assert claim.expense_type == "transport"
assert claim.reason == "客户现场沟通"
def test_steward_action_executor_links_application_when_creating_reimbursement_draft() -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
seed_approved_application(db)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "link_existing_application",
"message": "关联申请单 AAPPROVED1并保存报销草稿",
"task": base_reimbursement_task(),
"context_json": {
"application_claim_id": "application-action-approved",
"application_claim_no": "AAPPROVED1",
"application_reason": "辅助国网仿生产服务器部署",
"application_location": "上海",
"application_business_time": "2026-02-20 至 2026-02-23",
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["status"] == "draft"
with session_factory() as db:
claims = db.scalars(select(ExpenseClaim)).all()
reimbursement = next(claim for claim in claims if claim.id != "application-action-approved")
assert reimbursement.status == "draft"
link_flags = [
flag
for flag in list(reimbursement.risk_flags_json or [])
if isinstance(flag, dict) and flag.get("source") == "application_link"
]
assert link_flags
assert link_flags[0]["application_claim_no"] == "AAPPROVED1"
def test_steward_action_executor_associates_receipt_attachments(monkeypatch) -> None:
client, session_factory = build_client()
with session_factory() as db:
seed_employee(db)
calls: list[dict[str, object]] = []
def fake_run(self, *, receipt_ids, current_user):
calls.append({
"receipt_ids": list(receipt_ids),
"username": current_user.username,
})
return {
"claim_id": "claim-associated",
"claim_no": "BX-20260220-001",
"uploaded_count": 2,
"skipped_count": 0,
}
monkeypatch.setattr(attachment_jobs_module.AttachmentAssociationJobRunner, "run", fake_run)
response = client.post(
"/api/v1/steward/actions/execute",
headers=auth_headers(),
json={
"action_type": "associate_attachments",
"message": "把两张火车票关联到报销草稿",
"task": base_reimbursement_task(),
"context_json": {
"receipt_ids": ["receipt-001", "receipt-002"],
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "succeeded"
assert payload["result_payload"]["claim_no"] == "BX-20260220-001"
assert calls == [
{
"receipt_ids": ["receipt-001", "receipt-002"],
"username": "zhangsan@example.com",
}
]

View File

@@ -0,0 +1,234 @@
from __future__ import annotations
from app.api.v1.endpoints import steward as steward_endpoint
from app.core.config import get_settings
from app.schemas.steward import StewardPlanRequest
from app.services.steward_graph_planner import StewardGraphPlannerService
from app.services.steward_intent_agent import StewardIntentAgentResult
from app.services.steward_planner import StewardPlannerService
class GraphTravelApplicationIntentAgent:
def __init__(self) -> None:
self.calls = 0
def detect(self, request, *, base_date, canonical_fields):
self.calls += 1
return StewardIntentAgentResult(
payload={
"thinking_events": [
{
"stage": "task_split",
"title": "识别出差申请草稿",
"content": "模型识别到用户要创建上海出差申请,并保存草稿。",
}
],
"tasks": [
{
"task_type": "expense_application",
"title": "上海出差申请",
"summary": (
"2026-02-20 至 2026-02-23 前往上海,"
"国网仿生产服务器部署,火车出行。"
),
"requested_action": "save_draft",
"confidence": 0.95,
"ontology_fields": {
"time_range": "2026-02-20 至 2026-02-23",
"location": "上海",
"expense_type": "差旅",
"reason": "国网仿生产服务器部署",
"transport_type": "火车",
},
"missing_fields": [],
}
],
"attachment_groups": [],
},
model_call_traces=[
{
"slot": "main",
"provider": "MiniMax",
"model": "abab-test",
"attempt": 1,
"status": "succeeded",
}
],
)
class GraphSubmitTravelApplicationIntentAgent:
def __init__(self) -> None:
self.calls = 0
def detect(self, request, *, base_date, canonical_fields):
self.calls += 1
return StewardIntentAgentResult(
payload={
"thinking_events": [
{
"stage": "task_split",
"title": "识别出差申请提交",
"content": "模型识别到用户要创建上海出差申请,并直接提交。",
}
],
"tasks": [
{
"task_type": "expense_application",
"title": "上海出差申请",
"summary": (
"2026-02-20 至 2026-02-23 前往上海,"
"辅助国网仿生产服务器部署,火车出行。"
),
"requested_action": "submit",
"confidence": 0.96,
"ontology_fields": {
"time_range": "2026-02-20 至 2026-02-23",
"location": "上海",
"expense_type": "差旅",
"reason": "辅助国网仿生产服务器部署",
"transport_mode": "火车",
},
"missing_fields": [],
}
],
"attachment_groups": [],
},
model_call_traces=[
{
"slot": "main",
"provider": "MiniMax",
"model": "abab-test",
"attempt": 1,
"status": "succeeded",
}
],
)
class GraphEmptyIntentAgent:
def __init__(self) -> None:
self.calls = 0
def detect(self, request, *, base_date, canonical_fields):
self.calls += 1
return None
def test_langgraph_planner_preserves_llm_save_draft_plan() -> None:
intent_agent = GraphTravelApplicationIntentAgent()
service = StewardGraphPlannerService(intent_agent=intent_agent)
result = service.build_plan(
StewardPlanRequest(
message="2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿",
client_now_iso="2026-02-10T09:00:00+08:00",
)
)
assert intent_agent.calls == 1
assert result.planning_source == "llm_function_call"
assert result.tasks[0].requested_action == "save_draft"
assert result.tasks[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23"
assert result.tasks[0].ontology_fields["transport_mode"] == "train"
assert result.model_call_traces[0]["provider"] == "MiniMax"
def test_langgraph_planner_builds_submit_action_steps_for_application() -> None:
intent_agent = GraphSubmitTravelApplicationIntentAgent()
service = StewardGraphPlannerService(intent_agent=intent_agent)
result = service.build_plan(
StewardPlanRequest(
message="2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交",
client_now_iso="2026-02-10T09:00:00+08:00",
)
)
assert intent_agent.calls == 1
assert result.planning_source == "llm_function_call"
assert result.action_steps[0].action_type == "detect_intent"
assert [step.action_type for step in result.tasks[0].action_steps] == [
"fill_application_fields",
"build_application_preview",
"validate_required_fields",
"run_duplicate_precheck",
"submit_application",
]
assert result.tasks[0].action_steps[0].payload["ontology_fields"]["location"] == "上海"
assert result.tasks[0].action_steps[-1].requires_confirmation is True
assert result.tasks[0].action_steps[-1].status == "pending_confirmation"
def test_langgraph_planner_falls_back_when_model_returns_no_tool_call() -> None:
intent_agent = GraphEmptyIntentAgent()
service = StewardGraphPlannerService(intent_agent=intent_agent)
result = service.build_plan(
StewardPlanRequest(
message="2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿",
client_now_iso="2026-02-10T09:00:00+08:00",
)
)
assert intent_agent.calls == 1
assert result.planning_source == "rule_fallback"
assert result.tasks[0].requested_action == "save_draft"
assert result.tasks[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23"
assert result.tasks[0].ontology_fields["transport_mode"] == "train"
assert result.model_call_traces == []
def test_langgraph_planner_rule_fallback_builds_save_draft_action_steps() -> None:
intent_agent = GraphEmptyIntentAgent()
service = StewardGraphPlannerService(intent_agent=intent_agent)
result = service.build_plan(
StewardPlanRequest(
message="2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿",
client_now_iso="2026-02-10T09:00:00+08:00",
)
)
assert result.planning_source == "rule_fallback"
assert result.tasks[0].requested_action == "save_draft"
assert [step.action_type for step in result.tasks[0].action_steps] == [
"fill_application_fields",
"build_application_preview",
"validate_required_fields",
"save_application_draft",
]
assert result.tasks[0].action_steps[-1].status == "planned"
def test_build_steward_planner_uses_langgraph_runtime_when_enabled(monkeypatch) -> None:
monkeypatch.setenv("STEWARD_AGENT_RUNTIME", "langgraph")
get_settings.cache_clear()
try:
planner = steward_endpoint._build_steward_planner(db=object())
finally:
get_settings.cache_clear()
assert isinstance(planner, StewardGraphPlannerService)
def test_build_steward_planner_defaults_to_langgraph_runtime(monkeypatch) -> None:
monkeypatch.delenv("STEWARD_AGENT_RUNTIME", raising=False)
get_settings.cache_clear()
try:
planner = steward_endpoint._build_steward_planner(db=object())
finally:
get_settings.cache_clear()
assert isinstance(planner, StewardGraphPlannerService)
def test_build_steward_planner_can_fall_back_to_legacy_runtime(monkeypatch) -> None:
monkeypatch.setenv("STEWARD_AGENT_RUNTIME", "legacy")
get_settings.cache_clear()
try:
planner = steward_endpoint._build_steward_planner(db=object())
finally:
get_settings.cache_clear()
assert isinstance(planner, StewardPlannerService)

View File

@@ -0,0 +1,268 @@
from __future__ import annotations
from typing import Any
import pytest
from app.api.v1.endpoints import steward as steward_endpoint
from app.core.config import get_settings
from app.schemas.steward import (
StewardRuntimeDecisionRequest,
StewardSlotDecisionRequest,
)
from app.services.runtime_chat import (
RuntimeChatCallTrace,
RuntimeChatToolCall,
RuntimeToolCallResult,
)
from app.services.steward_graph_runtime import StewardGraphRuntime
from app.services.steward_runtime_decision_agent import STEWARD_RUNTIME_DECISION_FUNCTION_NAME
from app.services.steward_slot_decision_agent import STEWARD_SLOT_DECISION_FUNCTION_NAME
class _FakeRuntime:
def __init__(
self,
payloads: dict[str, dict[str, Any] | None] | None = None,
*,
fail_functions: set[str] | None = None,
) -> None:
self.payloads = payloads or {}
self.fail_functions = fail_functions or set()
self.called_functions: list[str] = []
self.last_messages: list[dict[str, Any]] = []
def complete_with_tool_call(self, messages, tools, tool_choice, **kwargs):
function_name = str(tool_choice["function"]["name"])
self.called_functions.append(function_name)
self.last_messages = messages
if function_name in self.fail_functions:
raise RuntimeError(f"{function_name} failed")
payload = self.payloads.get(function_name)
if payload is None:
return RuntimeToolCallResult(tool_call=None, calls=[])
return RuntimeToolCallResult(
tool_call=RuntimeChatToolCall(name=function_name, arguments=payload),
calls=[
RuntimeChatCallTrace(
slot=function_name,
provider="fake",
model="fake",
attempt=1,
status="succeeded",
)
],
)
class _FailingGraphRuntime:
def __init__(self, runtime_chat_service) -> None:
self.runtime_chat_service = runtime_chat_service
def decide_slot(self, request):
raise RuntimeError("langgraph runtime unavailable")
def decide_runtime(self, request):
raise RuntimeError("langgraph runtime unavailable")
@pytest.fixture(autouse=True)
def _clear_settings_cache():
get_settings.cache_clear()
yield
get_settings.cache_clear()
def test_graph_runtime_routes_slot_decision_through_langgraph_tool_node() -> None:
runtime = _FakeRuntime(
{
STEWARD_SLOT_DECISION_FUNCTION_NAME: {
"next_action": "ask_user",
"required_fields": ["expense_type", "time_range", "location", "reason", "transport_mode"],
"missing_fields": ["transport_mode"],
"question": "请问您这次打算怎么出行?",
"options": [
{"field_key": "transport_mode", "label": "火车", "value": "火车"},
{"field_key": "transport_mode", "label": "飞机", "value": "飞机"},
],
"rationale": "出行方式会影响交通费用测算。",
}
}
)
result = StewardGraphRuntime(runtime).decide_slot(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署",
ontology_fields={
"expense_type": "travel",
"time_range": "2026-02-20 至 2026-02-23",
"location": "上海",
"reason": "国网仿生产服务器部署",
},
missing_fields=["transport_mode"],
)
)
assert result.decision_source == "llm_function_call"
assert result.next_action == "ask_user"
assert result.missing_fields == ["transport_mode"]
assert runtime.called_functions == [STEWARD_SLOT_DECISION_FUNCTION_NAME]
def test_graph_runtime_slot_graph_falls_back_when_tool_node_fails() -> None:
runtime = _FakeRuntime(fail_functions={STEWARD_SLOT_DECISION_FUNCTION_NAME})
result = StewardGraphRuntime(runtime).decide_slot(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="上海出差,辅助国网仿生产部署",
ontology_fields={
"expense_type": "travel",
"location": "上海",
"reason": "辅助国网仿生产部署",
},
missing_fields=["transport_mode"],
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "ask_user"
assert result.missing_fields == ["transport_mode"]
assert any(
trace.get("slot") == "langgraph_slot_decision"
and trace.get("status") == "failed"
for trace in result.model_call_traces
)
def test_graph_runtime_merges_memory_before_runtime_action_node() -> None:
runtime = _FakeRuntime({STEWARD_RUNTIME_DECISION_FUNCTION_NAME: None})
result = StewardGraphRuntime(runtime).decide_runtime(
StewardRuntimeDecisionRequest(
user_message="我坐高铁",
runtime_state={},
context_json={
"conversation_state": {
"steward_state": {
"active_flow": "travel_application",
"flows": {
"travel_application": {
"flow_id": "travel_application",
"intent": "travel_application_create",
"fields": {
"expense_type": "travel",
"time_range": "2026-07-02",
"location": "北京",
"reason": "客户现场支撑",
},
"missing_fields": ["transport_mode"],
}
},
}
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "fill_current_slot"
assert result.field_key == "transport_mode"
assert result.field_value == "我坐高铁"
assert result.steward_state["flows"]["travel_application"]["fields"]["transport_mode"] == "我坐高铁"
assert result.steward_state["flows"]["travel_application"]["missing_fields"] == []
assert runtime.called_functions == [STEWARD_RUNTIME_DECISION_FUNCTION_NAME]
assert "steward_state" in runtime.last_messages[-1]["content"]
def test_graph_runtime_selected_flow_action_node_skips_model_call() -> None:
runtime = _FakeRuntime()
result = StewardGraphRuntime(runtime).decide_runtime(
StewardRuntimeDecisionRequest(
user_message="补办出差申请",
runtime_state={
"steward_state": {
"active_flow": "",
"pending_flow_confirmation": {
"status": "pending",
"candidate_flows": [
{"flow_id": "travel_application", "label": "补办出差申请"},
{"flow_id": "travel_reimbursement", "label": "发起费用报销"},
],
},
"flows": {
"travel_application": {
"flow_id": "travel_application",
"intent": "travel_application_create",
"status": "pending_flow_confirmation",
"fields": {
"time_range": "2026-02-20",
"location": "上海",
"expense_type": "travel",
"reason": "辅助国网仿生产环境部署",
},
"missing_fields": ["transport_mode"],
}
},
}
},
)
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "continue_selected_flow"
assert result.target_task_id == "travel_application"
assert result.steward_state["active_flow"] == "travel_application"
assert runtime.called_functions == []
def test_slot_endpoint_helper_falls_back_to_legacy_agent_when_langgraph_runtime_fails(monkeypatch) -> None:
monkeypatch.setenv("STEWARD_AGENT_RUNTIME", "langgraph")
get_settings.cache_clear()
monkeypatch.setattr(steward_endpoint, "StewardGraphRuntime", _FailingGraphRuntime)
runtime = _FakeRuntime({STEWARD_SLOT_DECISION_FUNCTION_NAME: None})
result = steward_endpoint._decide_steward_slot(
StewardSlotDecisionRequest(
task_type="expense_application",
user_message="上海出差,辅助国网仿生产部署",
ontology_fields={
"expense_type": "travel",
"location": "上海",
"reason": "辅助国网仿生产部署",
},
missing_fields=["transport_mode"],
),
runtime,
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "ask_user"
assert runtime.called_functions == [STEWARD_SLOT_DECISION_FUNCTION_NAME]
def test_runtime_endpoint_helper_falls_back_to_legacy_agent_when_langgraph_runtime_fails(monkeypatch) -> None:
monkeypatch.setenv("STEWARD_AGENT_RUNTIME", "langgraph")
get_settings.cache_clear()
monkeypatch.setattr(steward_endpoint, "StewardGraphRuntime", _FailingGraphRuntime)
runtime = _FakeRuntime({STEWARD_RUNTIME_DECISION_FUNCTION_NAME: None})
result = steward_endpoint._decide_steward_runtime(
StewardRuntimeDecisionRequest(
user_message="确认",
runtime_state={
"pending_steward_action": {
"message_id": "msg-next-task",
"target_task_id": "task-reimbursement-meal",
}
},
),
runtime,
)
assert result.decision_source == "rule_fallback"
assert result.next_action == "continue_next_task"
assert result.target_message_id == "msg-next-task"
assert runtime.called_functions == [STEWARD_RUNTIME_DECISION_FUNCTION_NAME]

View File

@@ -2,6 +2,24 @@ from app.services.steward_intent_agent import (
STEWARD_INTENT_FUNCTION_NAME,
StewardIntentAgent,
)
from app.schemas.steward import StewardPlanRequest
class _NoToolCallRuntimeChatService:
def __init__(self) -> None:
self.kwargs = {}
def complete_with_tool_call(self, messages, **kwargs):
self.kwargs = kwargs
class _Result:
tool_call = None
@staticmethod
def calls_as_dicts():
return []
return _Result()
def test_steward_intent_tool_schema_supports_pending_flow_confirmation() -> None:
@@ -12,9 +30,16 @@ def test_steward_intent_tool_schema_supports_pending_flow_confirmation() -> None
function_schema = schema["function"]
assert function_schema["name"] == STEWARD_INTENT_FUNCTION_NAME
properties = function_schema["parameters"]["properties"]
task_schema = properties["tasks"]["items"]
pending_schema = properties["pending_flow_confirmation"]
candidate_schema = pending_schema["properties"]["candidate_flows"]["items"]
assert task_schema["properties"]["requested_action"]["enum"] == [
"preview",
"save_draft",
"submit",
]
assert "requested_action" in task_schema["required"]
assert "pending_flow_confirmation" in properties
assert pending_schema["properties"]["status"]["enum"] == ["none", "pending"]
assert candidate_schema["properties"]["flow_id"]["enum"] == [
@@ -28,3 +53,18 @@ def test_steward_intent_tool_schema_supports_pending_flow_confirmation() -> None
"reason",
"transport_mode",
]
def test_steward_intent_agent_uses_ten_second_timeout_and_three_attempts() -> None:
runtime_chat = _NoToolCallRuntimeChatService()
agent = StewardIntentAgent(runtime_chat)
agent.detect(
StewardPlanRequest(message="2026-02-20 至 2026-02-23上海出差火车保存草稿。"),
base_date=__import__("datetime").date(2026, 6, 24),
canonical_fields=["expense_type", "time_range", "location", "reason", "transport_mode"],
)
assert runtime_chat.kwargs["timeout_seconds"] == 10
assert runtime_chat.kwargs["max_attempts"] == 3
assert runtime_chat.kwargs["use_failure_cooldown"] is False

View File

@@ -135,6 +135,7 @@ class ApplicationFunctionCallingIntentAgent:
"task_type": "expense_application",
"title": "北京出差申请",
"summary": "明天前往北京出差3天支撑国网仿生产部署。",
"requested_action": "save_draft",
"confidence": 0.94,
"ontology_fields": {
"time_range": "明天",
@@ -151,6 +152,52 @@ class ApplicationFunctionCallingIntentAgent:
)
class SingleTravelApplicationFunctionCallingIntentAgent:
def __init__(self) -> None:
self.calls = 0
def detect(self, request, *, base_date, canonical_fields):
self.calls += 1
return StewardIntentAgentResult(
payload={
"thinking_events": [
{
"stage": "task_split",
"title": "识别出差申请草稿",
"content": "模型识别到用户要创建上海出差申请,并保存草稿。",
}
],
"tasks": [
{
"task_type": "expense_application",
"title": "上海出差申请",
"summary": "2026-02-20 至 2026-02-23 前往上海,国网仿生产服务器部署,火车出行。",
"requested_action": "save_draft",
"confidence": 0.95,
"ontology_fields": {
"time_range": "2026-02-20 至 2026-02-23",
"location": "上海",
"expense_type": "差旅",
"reason": "国网仿生产服务器部署",
"transport_mode": "火车",
},
"missing_fields": [],
}
],
"attachment_groups": [],
},
model_call_traces=[
{
"slot": "main",
"provider": "OpenAI Compatible",
"model": "gpt-test",
"attempt": 1,
"status": "succeeded",
}
],
)
class PendingFlowFunctionCallingIntentAgent:
def detect(self, request, *, base_date, canonical_fields):
return StewardIntentAgentResult(
@@ -255,6 +302,17 @@ def _create_steward_test_client_with_db():
return TestClient(app), TestingSessionLocal, app
def _build_fast_rule_fallback_steward_planner(_db):
return StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent())
def _patch_steward_endpoint_planner(monkeypatch) -> None:
monkeypatch.setattr(
"app.api.v1.endpoints.steward._build_steward_planner",
_build_fast_rule_fallback_steward_planner,
)
def _build_endpoint_application_claim(
*,
claim_no: str = "AP-202602-001",
@@ -341,6 +399,7 @@ def test_steward_planner_enforces_application_transport_gap_after_function_calli
result = StewardPlannerService(intent_agent=ApplicationFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "llm_function_call"
assert result.tasks[0].requested_action == "save_draft"
assert result.tasks[0].missing_fields == ["transport_mode"]
gap_events = [event for event in result.thinking_events if event.stage == "business_gap_check"]
assert gap_events
@@ -356,7 +415,7 @@ def test_steward_planner_returns_pending_flow_confirmation_from_llm() -> None:
result = StewardPlannerService(intent_agent=PendingFlowFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.planning_source == "llm_function_call"
assert result.next_action == "confirm_flow"
assert result.plan_status == "needs_flow_confirmation"
assert result.pending_flow_confirmation.status == "pending"
@@ -364,12 +423,12 @@ def test_steward_planner_returns_pending_flow_confirmation_from_llm() -> None:
"travel_application",
"travel_reimbursement",
]
assert result.candidate_flows[0].ontology_fields["time_range"] == "2026-02-20"
assert result.candidate_flows[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23"
assert result.candidate_flows[0].ontology_fields["location"] == "上海"
assert "申请" in result.summary and "报销" in result.summary
def test_steward_planner_skips_llm_for_single_ambiguous_travel_flow() -> None:
def test_steward_planner_tries_llm_before_rule_fallback_for_single_ambiguous_travel_flow() -> None:
payload = StewardPlanRequest(
message="\u0032\u6708\u0032\u0030-\u0032\u0033\u65e5\u53bb\u4e0a\u6d77\u51fa\u5dee\u8f85\u52a9\u56fd\u7f51\u4eff\u751f\u4ea7\u73af\u5883\u90e8\u7f72",
client_now_iso="2026-06-15T09:30:00+08:00",
@@ -379,7 +438,7 @@ def test_steward_planner_skips_llm_for_single_ambiguous_travel_flow() -> None:
result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload)
assert intent_agent.calls == 0
assert intent_agent.calls == 1
assert result.planning_source == "rule_fallback"
assert result.next_action == "confirm_flow"
assert result.plan_status == "needs_flow_confirmation"
@@ -404,6 +463,37 @@ def test_steward_planner_uses_llm_for_multi_financial_demands() -> None:
assert result.model_call_traces[0]["status"] == "succeeded"
def test_steward_planner_uses_llm_for_single_explicit_travel_save_draft() -> None:
payload = StewardPlanRequest(
message="2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。",
client_now_iso="2026-06-24T14:20:00+08:00",
)
intent_agent = SingleTravelApplicationFunctionCallingIntentAgent()
result = StewardPlannerService(intent_agent=intent_agent).build_plan(payload)
assert intent_agent.calls == 1
assert result.planning_source == "llm_function_call"
assert result.tasks[0].requested_action == "save_draft"
assert result.tasks[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23"
assert result.tasks[0].ontology_fields["reason"] == "国网仿生产服务器部署"
assert result.model_call_traces[0]["status"] == "succeeded"
def test_steward_planner_rule_fallback_keeps_save_draft_action_and_date_range() -> None:
payload = StewardPlanRequest(
message="2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。",
client_now_iso="2026-06-24T14:20:00+08:00",
)
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.tasks[0].requested_action == "save_draft"
assert result.tasks[0].ontology_fields["time_range"] == "2026-02-20 至 2026-02-23"
assert result.tasks[0].ontology_fields["reason"] == "国网仿生产服务器部署"
def test_steward_planner_overrides_llm_direct_application_for_ambiguous_travel_flow() -> None:
payload = StewardPlanRequest(
message="2月20-23日去上海出差辅助国网仿生产环境部署",
@@ -412,7 +502,7 @@ def test_steward_planner_overrides_llm_direct_application_for_ambiguous_travel_f
result = StewardPlannerService(intent_agent=AmbiguousApplicationFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.planning_source == "llm_function_call"
assert result.next_action == "confirm_flow"
assert result.plan_status == "needs_flow_confirmation"
assert result.tasks == []
@@ -557,6 +647,34 @@ def test_steward_planner_keeps_bare_reimbursement_intent_generic() -> None:
assert task.ontology_fields.get("expense_type") == "other"
assert "reason" not in task.ontology_fields
assert task.missing_fields == ["time_range", "reason"]
assert [step.action_type for step in task.action_steps] == [
"fill_reimbursement_fields",
"build_reimbursement_preview",
"validate_required_fields",
"create_reimbursement_draft",
]
assert task.action_steps[-1].status == "blocked"
def test_steward_planner_builds_reimbursement_action_steps() -> None:
payload = StewardPlanRequest(
message="我要报销昨天客户现场沟通的交通费",
user_id="u001",
client_now_iso="2026-06-04T09:30:00+08:00",
context_json={"review_form_values": {"amount": "128.50"}},
)
result = StewardPlannerService().build_plan(payload)
assert result.tasks[0].task_type == "reimbursement"
assert [step.action_type for step in result.tasks[0].action_steps] == [
"fill_reimbursement_fields",
"build_reimbursement_preview",
"validate_required_fields",
"create_reimbursement_draft",
]
assert result.tasks[0].action_steps[0].payload["ontology_fields"]["amount"] == "128.50"
assert result.tasks[0].action_steps[-1].status == "planned"
def test_steward_planner_treats_future_travel_without_apply_word_as_application() -> None:
@@ -636,7 +754,8 @@ def test_steward_planner_builds_travel_attachment_group_with_exclusions() -> Non
assert len(attachment_actions) == 1
def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
def test_steward_stream_endpoint_emits_thinking_before_plan(monkeypatch) -> None:
_patch_steward_endpoint_planner(monkeypatch)
client = TestClient(create_app())
with client.stream(
@@ -660,7 +779,8 @@ def test_steward_stream_endpoint_emits_thinking_before_plan() -> None:
assert events[-1]["data"]["tasks"][0]["ontology_fields"]["time_range"] == "2026-06-03"
def test_steward_plan_endpoint_persists_application_and_reimbursement_state() -> None:
def test_steward_plan_endpoint_persists_application_and_reimbursement_state(monkeypatch) -> None:
_patch_steward_endpoint_planner(monkeypatch)
client = TestClient(create_app())
response = client.post(
@@ -685,7 +805,8 @@ def test_steward_plan_endpoint_persists_application_and_reimbursement_state() ->
assert all("invented_field" not in flow["fields"] for flow in state["flows"].values())
def test_steward_plan_endpoint_queries_applications_before_ambiguous_travel_choice() -> None:
def test_steward_plan_endpoint_queries_applications_before_ambiguous_travel_choice(monkeypatch) -> None:
_patch_steward_endpoint_planner(monkeypatch)
client, SessionLocal, app = _create_steward_test_client_with_db()
try:
response = client.post(

663
server/uv.lock generated
View File

@@ -933,6 +933,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jieba"
version = "0.42.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/cb/18eeb235f833b726522d7ebed54f2278ce28ba9438e3135ab0278d9792a2/jieba-0.42.1.tar.gz", hash = "sha256:055ca12f62674fafed09427f176506079bc135638a14e23e25be909131928db2", size = 19214172, upload-time = "2020-01-20T14:27:23.5Z" }
[[package]]
name = "json-repair"
version = "0.59.10"
@@ -942,6 +948,143 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/87/49b20c6b81493d55c311f711ed87319d0fbad8bd0bbfbe36e52103af36bd/json_repair-0.59.10-py3-none-any.whl", hash = "sha256:5468fa3eaadcc9b4a5646776bc4176e2fe5f374b5848a15f468cce3b60e3db0e", size = 47742, upload-time = "2026-05-14T06:41:49.812Z" },
]
[[package]]
name = "jsonpatch"
version = "1.33"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpointer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699, upload-time = "2023-06-26T12:07:29.144Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" },
]
[[package]]
name = "jsonpointer"
version = "3.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" },
]
[[package]]
name = "langchain-core"
version = "1.4.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "uuid-utils" },
]
sdist = { url = "https://files.pythonhosted.org/packages/12/e3/bea6d0080acf183332f24dcd74c208aee5857cf8f783c3fb0bd86027d8fb/langchain_core-1.4.8.tar.gz", hash = "sha256:5bf1f8411077c904182ad8f975943d36adcbf579c4e017b3a118b719229ebf9a", size = 957974, upload-time = "2026-06-18T19:39:23.636Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/d6/bdf6f0481cc57ef300d6b1eb48cf1400c0409be715d6eb3cabadd1142a09/langchain_core-1.4.8-py3-none-any.whl", hash = "sha256:d84c28b05e3ba8d4271d0827aad5b592ccdaaf986e76768c23503f0a2045e8aa", size = 557416, upload-time = "2026-06-18T19:39:21.902Z" },
]
[[package]]
name = "langchain-protocol"
version = "0.0.18"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d2/59/b5959aea96faa9146e2e49a7a22882b3528c62efafe9a6a95beab30c2305/langchain_protocol-0.0.18.tar.gz", hash = "sha256:ec3e11782f1ed0c9db38e5a9ed01b0e7a0d3fba406faa8aef6594b73c56a63e6", size = 6150, upload-time = "2026-06-18T17:08:26.959Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/2e/d82db9eec13ad0f72e7aaad5c4bc730ab111934fdc83c85523206eb9b0a0/langchain_protocol-0.0.18-py3-none-any.whl", hash = "sha256:70b53a86fbf9cedc863555effe44da192ab02d556ddbf2cf95b8873adcf41b5a", size = 7221, upload-time = "2026-06-18T17:08:25.996Z" },
]
[[package]]
name = "langgraph"
version = "1.2.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ name = "langgraph-checkpoint" },
{ name = "langgraph-prebuilt" },
{ name = "langgraph-sdk" },
{ name = "pydantic" },
{ name = "xxhash" },
]
sdist = { url = "https://files.pythonhosted.org/packages/02/7a/ea09b05bb0cbddfa43bd34fc581357e87fc3f21a751cc0d419688c3106da/langgraph-1.2.6.tar.gz", hash = "sha256:f9b45a34f13930c94d96cdb76277447ad2cc70ec2d18cd2764d7fdadb36cdc1b", size = 714400, upload-time = "2026-06-18T20:58:21.514Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/32/772db1b00a9fe42f50320d1aa20caefb76e621eff1f7218b9918093d631d/langgraph-1.2.6-py3-none-any.whl", hash = "sha256:1cf94d3ca124f84f77ce408fa1b06c3dee680a8aafffe364a8fd5d7d03eb8695", size = 246132, upload-time = "2026-06-18T20:58:20.335Z" },
]
[[package]]
name = "langgraph-checkpoint"
version = "4.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ name = "ormsgpack" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" },
]
[[package]]
name = "langgraph-prebuilt"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ name = "langgraph-checkpoint" },
]
sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" },
]
[[package]]
name = "langgraph-sdk"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "langchain-core" },
{ name = "langchain-protocol" },
{ name = "orjson" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b4/2b/bd8ac26d4e97f6df88ef05ce5b6a38945a3903e1025d926f4752aa88aa97/langgraph_sdk-0.4.2.tar.gz", hash = "sha256:b88f0f5f6328ac0680d6790614a905b2bcfa257f2276dba4e38f0e86db0aa738", size = 348327, upload-time = "2026-06-01T17:51:19.856Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/05/aac507337cceae773c2cc9ab91eb6301963af7aeeb55b4217a00e15aff17/langgraph_sdk-0.4.2-py3-none-any.whl", hash = "sha256:75fa5096c1177ce39c847096a8fe3745ffd480ddb412995f836e9f5f884c43dd", size = 160521, upload-time = "2026-06-01T17:51:18.849Z" },
]
[[package]]
name = "langsmith"
version = "0.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "orjson", marker = "platform_python_implementation != 'PyPy'" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "requests-toolbelt" },
{ name = "sniffio" },
{ name = "typing-extensions" },
{ name = "uuid-utils" },
{ name = "websockets" },
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/26/b72987d947278f63ec1e85f01ce85ca7ab2621c7efc0845d4a3a8e5d5dfb/langsmith-0.9.1.tar.gz", hash = "sha256:e5eb905224d156bcece4985285c55b51fffcb06c9353b2c4adb42e1c48b0d05d", size = 4557557, upload-time = "2026-06-23T17:04:23.233Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/42/13c67eb24cddb368df2c8af13e319c86b1d270c90f5b8c9e8baed3593e04/langsmith-0.9.1-py3-none-any.whl", hash = "sha256:1160bf667af63d9bc081821f1df351cb84f7875740858f2a97ffef62b21800a9", size = 578856, upload-time = "2026-06-23T17:04:21.47Z" },
]
[[package]]
name = "lightrag-hku"
version = "1.4.16"
@@ -1286,6 +1429,122 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "orjson"
version = "3.11.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" },
{ url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" },
{ url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" },
{ url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" },
{ url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" },
{ url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" },
{ url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" },
{ url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" },
{ url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" },
{ url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" },
{ url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" },
{ url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" },
{ url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" },
{ url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" },
{ url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" },
{ url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" },
{ url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" },
{ url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" },
{ url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" },
{ url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" },
{ url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" },
{ url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" },
{ url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" },
{ url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" },
{ url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" },
{ url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" },
{ url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" },
{ url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" },
{ url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" },
{ url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" },
{ url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" },
{ url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" },
{ url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" },
{ url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" },
{ url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" },
{ url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" },
{ url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" },
{ url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" },
{ url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" },
{ url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" },
{ url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" },
{ url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" },
{ url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" },
{ url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" },
{ url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" },
{ url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" },
{ url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" },
{ url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" },
{ url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" },
{ url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" },
{ url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" },
{ url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" },
{ url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" },
{ url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" },
{ url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" },
]
[[package]]
name = "ormsgpack"
version = "1.12.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" },
{ url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" },
{ url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" },
{ url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" },
{ url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" },
{ url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" },
{ url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" },
{ url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" },
{ url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" },
{ url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" },
{ url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" },
{ url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" },
{ url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" },
{ url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" },
{ url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" },
{ url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" },
{ url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" },
{ url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" },
{ url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" },
{ url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" },
{ url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" },
{ url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" },
{ url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" },
{ url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" },
{ url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" },
{ url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" },
{ url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" },
{ url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" },
{ url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" },
{ url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" },
{ url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" },
{ url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" },
{ url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" },
{ url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" },
{ url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" },
{ url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" },
{ url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" },
]
[[package]]
name = "packaging"
version = "26.2"
@@ -2057,6 +2316,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "requests-toolbelt"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
]
[[package]]
name = "ruff"
version = "0.15.13"
@@ -2277,6 +2548,93 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "uuid-utils"
version = "0.16.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/5a/5da7ae85b38e3eddba0be3e8e4328f90882fe92989728e6fb552963d4c42/uuid_utils-0.16.2.tar.gz", hash = "sha256:fa637e4f314ad5b59ff6d8e809d506443d68bef30bfaecdfcfe02cce689abb2f", size = 42962, upload-time = "2026-06-18T13:36:48.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/96/4023966d42fd9bbf9e2a8ce2b25930113688128b569f68bc4697cb18181d/uuid_utils-0.16.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:fadd23eee409237fb8637a35796a6e108873c28b40f7de89a36685f18ca055ad", size = 567776, upload-time = "2026-06-18T13:35:02.902Z" },
{ url = "https://files.pythonhosted.org/packages/fc/30/764d2a76e8e7688abd5577e6024787c13692095eb1230fd1936f27205cd9/uuid_utils-0.16.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:79c5a3bd4301257b9a524efd16baf61ea65cd0d1b60b47d80f20b151fd65a09f", size = 288938, upload-time = "2026-06-18T13:35:04.285Z" },
{ url = "https://files.pythonhosted.org/packages/45/ef/58077250fe04eda4a3f9fba8c35be8d0937b7d3e02302ac1a6d942b77dae/uuid_utils-0.16.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90903ab7fcdfb0300390c15f5a68cb91f15139d9a1a93f134c783d7a973fa269", size = 327387, upload-time = "2026-06-18T13:35:05.406Z" },
{ url = "https://files.pythonhosted.org/packages/c6/fd/a9172970fa07ce0b9148ccc679a99540375c7bb32f8fbd72cf1e6cca43ef/uuid_utils-0.16.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f7a44f8250ec178c0af703c3f1b6e81865a771272ae735ca403f27c95c62f132", size = 334212, upload-time = "2026-06-18T13:35:06.611Z" },
{ url = "https://files.pythonhosted.org/packages/ca/58/d8fb393b44ad0b719d96a5b7809d0ee727f7e266d9e88a4da235cbfbf9f8/uuid_utils-0.16.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9e97ab941660f781a8e45f15aba9ee01b40dbb96adb5c43617c1671a4604b25b", size = 450379, upload-time = "2026-06-18T13:35:07.97Z" },
{ url = "https://files.pythonhosted.org/packages/00/70/b3cf708e8942e6494742404a66f1586195a20c8fd235bdc79f385db383f1/uuid_utils-0.16.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a30b6a5790acb854e4b65fae7875e5d3c6f8076fa9c91dac43ff9e28380bc52", size = 327231, upload-time = "2026-06-18T13:35:09.327Z" },
{ url = "https://files.pythonhosted.org/packages/a8/9c/4c5b16e752a2402259a3a9d1371227025e5b85182024c82a446cbe3ed6ea/uuid_utils-0.16.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5dfc3e9e75139a84898771d31958ece6cdee8e8f127700aa8aa26a4f1a348d57", size = 353455, upload-time = "2026-06-18T13:35:10.67Z" },
{ url = "https://files.pythonhosted.org/packages/0c/0f/3b14c47fab1544bcfb92d28bc468160a4fc6ff342d0e6defa8ff40d5e4bd/uuid_utils-0.16.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0529b1ef0788f663e1211d221b59a38ec67f9b084f1ea5342ba84358b3d87e98", size = 504028, upload-time = "2026-06-18T13:35:12.006Z" },
{ url = "https://files.pythonhosted.org/packages/39/79/1a133214626eb0e18c51ef196946b1263d65b578ffee432ad1b7afffa5d3/uuid_utils-0.16.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:cae08df8695f4b01fce2a8ab50e9e310971276d85dfc7103e977bed52d365094", size = 609803, upload-time = "2026-06-18T13:35:13.286Z" },
{ url = "https://files.pythonhosted.org/packages/87/49/22bff932af63764b4cad9c01299ed64c60d101962988efc13964b4165345/uuid_utils-0.16.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f69658c42411540cf58be958a47e317fd2302cc0b613ea5cff1e60d87be2846d", size = 569512, upload-time = "2026-06-18T13:35:14.661Z" },
{ url = "https://files.pythonhosted.org/packages/3f/98/371cc1f332f7463b9cfac0a66f984af00f4e3ada4a196b20879e35404e8b/uuid_utils-0.16.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:503f020acc7dbeb39c47fa33cf2971cf5960fa11f8394513fac461762a90c556", size = 532855, upload-time = "2026-06-18T13:35:15.99Z" },
{ url = "https://files.pythonhosted.org/packages/f1/06/f7ef7f6dabf68021eb6e961c09d16d517ed7587cedfff18969ba7f61798c/uuid_utils-0.16.2-cp311-cp311-win32.whl", hash = "sha256:aab7cdf28a3e2859ca4f40a3e3bf53eb35895039c80d4d8d8c5e15b90346c55c", size = 169971, upload-time = "2026-06-18T13:35:17.294Z" },
{ url = "https://files.pythonhosted.org/packages/75/8b/1e4b51c075eaadd23828b708249374db0bc40146f7b673027942d3383f45/uuid_utils-0.16.2-cp311-cp311-win_amd64.whl", hash = "sha256:71192a59d473f3f638e2a238905046e2942006ad90ac5ec10d578e58ff9a08ce", size = 176464, upload-time = "2026-06-18T13:35:18.459Z" },
{ url = "https://files.pythonhosted.org/packages/d4/71/18a43b6e632adf3cb3cf5db777ea03f9d3b2b259de65de5e41419004c2a1/uuid_utils-0.16.2-cp311-cp311-win_arm64.whl", hash = "sha256:ea175649789f1e93edbf1a0440cab18c9838977703917221777691d8d988d7bf", size = 176056, upload-time = "2026-06-18T13:35:19.826Z" },
{ url = "https://files.pythonhosted.org/packages/fd/07/294b72a572218bf6e92355203b832b3356c58a7e1e0b92a034497d15bef9/uuid_utils-0.16.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:6f064dc54c6abecb09eb104d953bfb079f3c395e0d6b18899979f852d1083549", size = 560726, upload-time = "2026-06-18T13:35:21.053Z" },
{ url = "https://files.pythonhosted.org/packages/e5/3c/1095b6ab574a7fa69136d47bab5a43f320a8f00a0ecb96059fd49b1747b2/uuid_utils-0.16.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:dd7aa18db5cc826d482d876a826fee445839701f81f78567e7c74b4458d57a84", size = 288065, upload-time = "2026-06-18T13:35:22.547Z" },
{ url = "https://files.pythonhosted.org/packages/4c/9d/6404d48fe71def0733c9568d96043b2e1945e2e4205c4eb525db3da42ba3/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc25ad320c9b44c2d3ed33aff4f85b0b277bef4ff79b12c01ee58b52ea44be1d", size = 322946, upload-time = "2026-06-18T13:35:23.648Z" },
{ url = "https://files.pythonhosted.org/packages/74/00/8a009762015a134aa04b5451400e0ec9832ccd598ed4845f9aecb0be6299/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d0ca752d51d1004caff65fccffd44b32a26cb099b546e0512cfa09facb683d6c", size = 330186, upload-time = "2026-06-18T13:35:24.757Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b0/1613bb98ac11234145aa5bc1de618be536818fef05dec595efb3e2b37097/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8323136bb02355c1b973492ab98b0722206dfdedfb148e4115c35fcdf3889bad", size = 444583, upload-time = "2026-06-18T13:35:25.999Z" },
{ url = "https://files.pythonhosted.org/packages/93/66/83e62c7a152bbbb8b30ac58eaad81f3860ba2fba91a334c50f223f9ce878/uuid_utils-0.16.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9bf8bfdffb22f620635580b17fd178272f30a9841b824b19b935c8db64bf09b6", size = 323064, upload-time = "2026-06-18T13:35:27.356Z" },
{ url = "https://files.pythonhosted.org/packages/15/37/c1b2faaf3a9d7952f321a9fee3ad74e05b25878bd9b7cd6b0398fe77f279/uuid_utils-0.16.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:61454f2139424a6cff14eca7849c28b3350f261453b74075aa20fe99592dbb16", size = 347967, upload-time = "2026-06-18T13:35:28.538Z" },
{ url = "https://files.pythonhosted.org/packages/24/d8/cdf79b242e41ae47b7cd617ac5d48f15ce44e81da8000379c757091ae5f8/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:725110434a1d482a639a9ac467a24f1cb531d84ab52e454a13fe145b10b42cae", size = 499187, upload-time = "2026-06-18T13:35:30.042Z" },
{ url = "https://files.pythonhosted.org/packages/be/10/978d5ad82bc0fe7ff02d5be6f1eb83b090849f0a95bf8438593565273b7a/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8197870739a3094990743a80f075fa0b17beafd6c187e5f360e021d90a12a6d1", size = 605696, upload-time = "2026-06-18T13:35:31.289Z" },
{ url = "https://files.pythonhosted.org/packages/3a/28/e382ee44a592e35b80397b493bf3fbbdb8e30a64eaaefc7dabc246aeb253/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e10a02b3a31ed44c7c9a96abde335f5fa222735e73f3081d693414377eb3b016", size = 564975, upload-time = "2026-06-18T13:35:32.419Z" },
{ url = "https://files.pythonhosted.org/packages/a3/d0/f6011dbe4e5d751a8494715e014019cb5b242d8cd6dbec1cfec3d3fb2e81/uuid_utils-0.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd32dbca0792b9683160151dc07fad11b915020eed7c82b43faf0862c2ff06a0", size = 528462, upload-time = "2026-06-18T13:35:33.685Z" },
{ url = "https://files.pythonhosted.org/packages/42/7f/279e6159c37f43feb9dd70218b49a26696cefddaef1db7f4b79895eaf5d5/uuid_utils-0.16.2-cp312-cp312-win32.whl", hash = "sha256:dcdfcab60562d12dd43c1a6f495b1d089e41f0e10fac37d94db285d72b678c23", size = 167047, upload-time = "2026-06-18T13:35:34.862Z" },
{ url = "https://files.pythonhosted.org/packages/47/38/f72f7bed062601448ec2db47351e6c1faccd78fd693bbc6e067299d1fa11/uuid_utils-0.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:97ee6f5e803ea571f5f6da42efc97d8c5a13f121043680177f8470529b94e855", size = 173821, upload-time = "2026-06-18T13:35:36.117Z" },
{ url = "https://files.pythonhosted.org/packages/37/61/8a025284a31c85b7c0c5319e96868c2c09dea3fc5f676c979a4cd4baf2e7/uuid_utils-0.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:72cfd9ff1e8a7c371a044687e77eb873721c4a9f4814e453439bfba595b84303", size = 172206, upload-time = "2026-06-18T13:35:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/ed/a1/3b48859953ee74fc26628ca5d9e5f848209655a0a8c934032fc596035976/uuid_utils-0.16.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c19b7d595d12923da682ed13d313c2333b9ebf214e65a47a24927a8a3a81b191", size = 560753, upload-time = "2026-06-18T13:35:38.531Z" },
{ url = "https://files.pythonhosted.org/packages/ba/1c/77635489de5454f2a25411030f78d31931dbdc0c86114da00adb9b91f120/uuid_utils-0.16.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:909e26fa2451c8db31b9ed1d3c8e4ecf513b6d1619db4205997fe99eb6b4ef4f", size = 288056, upload-time = "2026-06-18T13:35:39.966Z" },
{ url = "https://files.pythonhosted.org/packages/ad/0e/8e799537ea458abaefb0f5c3b3b05304d3faf413feb0997605a3f8ae2484/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27271b37fbc6812bb1542c4b8e22ee00223a6bf7f62b1f38d3bcf8e92f6d9acd", size = 323196, upload-time = "2026-06-18T13:35:41.534Z" },
{ url = "https://files.pythonhosted.org/packages/e8/92/4e5b412d4710617fb83ed77b361f5fa6247b99bde2fa6ee07ddf851b59d1/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dc4b9d96a2c689d664cf3fc7f7db46b82d2821fb2ce8a4f0798fc0a92c1569f8", size = 330858, upload-time = "2026-06-18T13:35:42.709Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e3/8173202b7cfcfeb4a588c5f8b85d3e2b44973384eb33167ee25c5c78867f/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3bf41b696b0fe808df1b4091c70273a52ea033b0fe97341cd67ecd76d22bb3a", size = 444813, upload-time = "2026-06-18T13:35:43.917Z" },
{ url = "https://files.pythonhosted.org/packages/37/0d/c3918356932ce467b11e954d0c93697fb4652cf664957e3d9521f7ece22f/uuid_utils-0.16.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fcc329be41bb6534ecb03e50596179ab76c7643ced33d13c66967d5ae1869663", size = 322828, upload-time = "2026-06-18T13:35:45.134Z" },
{ url = "https://files.pythonhosted.org/packages/f0/80/4020556682441b62a25b7d07798812115fca97d417a3498d5af6dce36504/uuid_utils-0.16.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4125bf6ed3ae443c05e140f8585d174b9d647295b12034d5ec94ae2ae38edefa", size = 347909, upload-time = "2026-06-18T13:35:46.364Z" },
{ url = "https://files.pythonhosted.org/packages/48/2f/a1e87e268df98f6740af81abf225532c173a971c64df0258c84b630e35a7/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:840b21e609a9b203eee06bdc73e18397154447a9814a8e78d9b68e5104d9802f", size = 499469, upload-time = "2026-06-18T13:35:47.584Z" },
{ url = "https://files.pythonhosted.org/packages/25/75/5a1f297a09556c27d9617c44ab0510de5f3a70120df236f66b9d0fdd1976/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5119bec75f56bd028d97472f72b1ed723a0d60b09a48017dc70a3cb1892ed081", size = 606160, upload-time = "2026-06-18T13:35:48.963Z" },
{ url = "https://files.pythonhosted.org/packages/7c/de/140f1d2a161320d1ac9073a03b9eb31fe35ae70f56f8971ec1fb45c14a44/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9fe600ab7d3d4eb56986e814042c917e728ac92cd8a41f099a6b59b84d8bf9e6", size = 564856, upload-time = "2026-06-18T13:35:50.244Z" },
{ url = "https://files.pythonhosted.org/packages/01/3b/9a5fe6691f8f6d72899cdc2713ffbd845b8c6981eeeab66d98a71b721116/uuid_utils-0.16.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44020a4532229ccfbba353138539774686350dda71cf4368e257973dd8ba403", size = 528376, upload-time = "2026-06-18T13:35:51.825Z" },
{ url = "https://files.pythonhosted.org/packages/87/ad/47c93dcabd00f6749803a00be361c75d7079c78ad5e67077dee63d30b687/uuid_utils-0.16.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:280d4f1f22dd2e79c1cc31ffc7fc26dc3534ffc114dedcdd29cc8489c5ce9c98", size = 98033, upload-time = "2026-06-18T13:35:53.385Z" },
{ url = "https://files.pythonhosted.org/packages/c0/fd/8de85eeb8dd59354ad46e897ab0d0f0fe6bc48702239a6c9f2613f961c8e/uuid_utils-0.16.2-cp313-cp313-win32.whl", hash = "sha256:4942b26ad12c5187bac52b7fb4685040139ff0df9a19cde33e5025326f6180fc", size = 167054, upload-time = "2026-06-18T13:35:54.495Z" },
{ url = "https://files.pythonhosted.org/packages/86/b3/b5ba393fbe5142eb9d5db23d4b9b16dde2a4e1aee6f2fcb7fadef97e419a/uuid_utils-0.16.2-cp313-cp313-win_amd64.whl", hash = "sha256:01f81c71cf2185de0707e9d2f248e17025ba50af0acd3cbf51cd8aea96c2e0be", size = 173481, upload-time = "2026-06-18T13:35:55.684Z" },
{ url = "https://files.pythonhosted.org/packages/b2/79/4e5d63d605b13201ae9af6fcc36ec77949cccc99486c430c016d8f8ed274/uuid_utils-0.16.2-cp313-cp313-win_arm64.whl", hash = "sha256:c1dbe65ce6d46c5f645356d64bfb2de7564e2426ca8c9b1a0a401d6f7ae5cc22", size = 172197, upload-time = "2026-06-18T13:35:56.817Z" },
{ url = "https://files.pythonhosted.org/packages/89/3a/0e5a0c1e1e3243cf5f12efd2b88a33e63c38b6a79483d3c84b2f5e7265cf/uuid_utils-0.16.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:617955f4b3f649617c0388127d8a257202189d5cc3c720313f8b207df1cdb2a4", size = 566227, upload-time = "2026-06-18T13:35:57.925Z" },
{ url = "https://files.pythonhosted.org/packages/28/b3/2b6f9d6832e939aaf2b2ba89ff70b3994cfa3ae9b14daac3329eb9202ef8/uuid_utils-0.16.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:0aa2569908bdb21ccb216cd6bd06cb934351ee65ea7cd5e351e19f633a99b577", size = 290301, upload-time = "2026-06-18T13:35:59.467Z" },
{ url = "https://files.pythonhosted.org/packages/f5/27/8bb31429884b9f340f964ed70b68bfd81cec61f6e6877633f6a014358e78/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4af7673e84e1ec6029f18d3a0408095c471c4e2691b6e46b4e1f0a2051734ba", size = 325409, upload-time = "2026-06-18T13:36:00.786Z" },
{ url = "https://files.pythonhosted.org/packages/1f/87/3b59aa97e788ca4fa46e2a3856ef567b51e03fd7fbf27d39ce36e46478b6/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ecadf55ed6b8fb72e7966b52fd02919e7d7bb8e7bffeaf285803b82e774debfb", size = 332071, upload-time = "2026-06-18T13:36:02.043Z" },
{ url = "https://files.pythonhosted.org/packages/1c/21/8c21bf6cf3ce9447b73cee6a38ca63c9bb2f3145259422646bae8e8ddc21/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:026b96b2f1e6b004579e030692d2f6568ccd0b29d40687213c31694abf570c78", size = 447075, upload-time = "2026-06-18T13:36:03.305Z" },
{ url = "https://files.pythonhosted.org/packages/95/43/77e83019effe1a5ab7169a2d4bf1bd654bebd850b81c8a937b96bd6b5c9c/uuid_utils-0.16.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:273679723e88544dd2de0564ab7f2fddfa2270faf05cabfdf63c275be67ec2a1", size = 325061, upload-time = "2026-06-18T13:36:04.972Z" },
{ url = "https://files.pythonhosted.org/packages/f3/a6/7bf6e0165dc191c09bc4e8c011de5463d64c5a651ed38ad6698bfc552a52/uuid_utils-0.16.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec5b1a338b92d1eb121e9eaf06ae3db1b9a5cd794ce318a475f6dc6f9e89c3a8", size = 350302, upload-time = "2026-06-18T13:36:06.172Z" },
{ url = "https://files.pythonhosted.org/packages/45/66/260836aaef14b8254bc449b3163fedec06ef0a0bba0d6a999c918479b2f9/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e75f9429d4533ce275c98bc68bf47fb237ae7b32c954266dabc5edab0c7d682e", size = 501834, upload-time = "2026-06-18T13:36:07.469Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0b/84c1542bf8c465b456f742318ad83eace63551e7f603b06c817b726670af/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f3cca9ca5e2c2dfd7b885f0d34c10b993a070d3593f3cdfef785195da36fb0f", size = 607406, upload-time = "2026-06-18T13:36:08.913Z" },
{ url = "https://files.pythonhosted.org/packages/48/7f/1024c22657a0c0572c4fd5189fad3127cb46731fb26fad3be1e8a4a64972/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1ef8c561fdf88fec205e3d54037824cfe2addce16b509a8d2ecb69daa904cbb7", size = 567623, upload-time = "2026-06-18T13:36:10.14Z" },
{ url = "https://files.pythonhosted.org/packages/15/0e/ad7424a6444e3e108a22781c2e164e82752da5db23ccc5cba8b4470c3164/uuid_utils-0.16.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e3acb5e1451232381daea01645a98c69de4bb9ad88d77a1f7c1df4d83d54e62", size = 530659, upload-time = "2026-06-18T13:36:11.649Z" },
{ url = "https://files.pythonhosted.org/packages/69/60/cf1666d0dbd6fa869b6de3b85a17254ff0ab10ed286fd59366148bf08e89/uuid_utils-0.16.2-cp314-cp314-win32.whl", hash = "sha256:b5f8e7d0bb2c6e6180176237f92d2e949626e04fcf701c49d73f128e1f64e1d1", size = 169272, upload-time = "2026-06-18T13:36:12.846Z" },
{ url = "https://files.pythonhosted.org/packages/fe/5e/111908bdc7287b2589e9a9f10be8e0358844fb4a0554677cbbe0ade49766/uuid_utils-0.16.2-cp314-cp314-win_amd64.whl", hash = "sha256:bf922bad7df257336b594d316a1657df569860bb5389602919001fa6fb17f06e", size = 175435, upload-time = "2026-06-18T13:36:14.114Z" },
{ url = "https://files.pythonhosted.org/packages/ce/5d/b3bd7415622060dd17d587545e3c037f83dc0dffb8880ac798ca7936f630/uuid_utils-0.16.2-cp314-cp314-win_arm64.whl", hash = "sha256:fad82e6482129c58ba9b00da6c247ab6e767645ab17981599229cce19d7b2ce9", size = 173553, upload-time = "2026-06-18T13:36:15.561Z" },
{ url = "https://files.pythonhosted.org/packages/92/43/401acf6fc0e0665dd11a095a28f6d22708c6f8f148c326cfc5b0b1ae9882/uuid_utils-0.16.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:e0609e7e906c08386b7f33141254df05dcab24f1c4884150988dc7a287516aca", size = 567548, upload-time = "2026-06-18T13:36:16.848Z" },
{ url = "https://files.pythonhosted.org/packages/4e/2c/cc2bb8273d414d651acafccc3705a8843c130a541fcce65fbeaac22266ba/uuid_utils-0.16.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9ad2adeb941292fe02e1e5c70b80a5746c45b1b77594506c2a1421455d8384f9", size = 291348, upload-time = "2026-06-18T13:36:18.145Z" },
{ url = "https://files.pythonhosted.org/packages/4e/a8/fdadd7ada0de53dbc03f719da0948cc275abd24d8013a26e42e50d3665c1/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d906c00f965d5c5f4812d0086dc49bf813285ea84c97e8816405200e146f805b", size = 325495, upload-time = "2026-06-18T13:36:19.417Z" },
{ url = "https://files.pythonhosted.org/packages/16/42/e397a1eda06b20dd3a206e3a55b346ff2caad23906586801a87359530864/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a59205fc15463dd0f978f14df14307737e3d4e8ef4aefa29a9d0fa766d84d16b", size = 332301, upload-time = "2026-06-18T13:36:20.747Z" },
{ url = "https://files.pythonhosted.org/packages/46/be/12d3df7bd824e3ce71630c022184a5aecfea92b0a7fa70459542b237777a/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aac82500329ffaf2788dac36cf133e1e4e23b6d5e1118274ea6749c3b512f4f1", size = 446760, upload-time = "2026-06-18T13:36:22.198Z" },
{ url = "https://files.pythonhosted.org/packages/f7/10/0c5d1dd6874fa35e2cb66a8499ce303eb8678bef226951182603bd30017d/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d8257329f26905f009aed694bd3b17f334f43748b03134dc7bc99d6c5b4e371", size = 325781, upload-time = "2026-06-18T13:36:23.566Z" },
{ url = "https://files.pythonhosted.org/packages/04/e2/9ebb8414875e5c14737fa7145a023458c9b15754f1d129cefe7824197256/uuid_utils-0.16.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e04b5c10c6fcf9d9801084d1e86c9d7ada7eb48fe07ee4ae5e7fe5b1a852db8a", size = 351189, upload-time = "2026-06-18T13:36:25.09Z" },
{ url = "https://files.pythonhosted.org/packages/1b/5c/168d1f4d30b33c08365debfe4176c2f713a0940f1f11a64128a186d050c6/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3d4805c4739dd06d539f8f4fa94f5aaf26eca4b3ece1ef134d4ff904c6b08dcf", size = 501866, upload-time = "2026-06-18T13:36:26.31Z" },
{ url = "https://files.pythonhosted.org/packages/aa/8d/003865d5ed5bf82ece80bd61edb2692985f7548051749fd10f34edb16705/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:76632d2e16e26de777851ec07961ceaea14e65167d0603a0b17fb169fa9ca37b", size = 607632, upload-time = "2026-06-18T13:36:27.704Z" },
{ url = "https://files.pythonhosted.org/packages/ea/52/6102f21f28323b27122a6aa3d4cea183b4fc401868c5c40767e1b9f53beb/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c02f85f49c9c2abbf247a8622458c30232332a28711755aa191da5f38015af6", size = 568216, upload-time = "2026-06-18T13:36:29.377Z" },
{ url = "https://files.pythonhosted.org/packages/68/50/644e4e55f47048d12bc20665fac85bc1fecbed9c892acfb91626abf8ad8d/uuid_utils-0.16.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f668035ea9faa763e8f1ea42040e8439db88cf2517056d47c348a62a257a1d02", size = 531370, upload-time = "2026-06-18T13:36:30.804Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5d/d98d99f601d70cc00287dce5aadef9c199912f0d64343962542f35e7db59/uuid_utils-0.16.2-cp314-cp314t-win32.whl", hash = "sha256:62b8841895eff1c0afbaf5f0050411667231160478c8ff9f411742abffd3b619", size = 169424, upload-time = "2026-06-18T13:36:32.246Z" },
{ url = "https://files.pythonhosted.org/packages/a6/af/c0d482bdd637a8a742d3274cec462b770919f032e179216f2fc2851afaf9/uuid_utils-0.16.2-cp314-cp314t-win_amd64.whl", hash = "sha256:e9064805881c30dd80a4189a0da7130e3d684de353ea36edd99c1b994bdf429e", size = 175544, upload-time = "2026-06-18T13:36:33.75Z" },
{ url = "https://files.pythonhosted.org/packages/86/fc/aff8b0456e8a63672fa89ea9c773f7547a31ff7b596a40f226bf148921a3/uuid_utils-0.16.2-cp314-cp314t-win_arm64.whl", hash = "sha256:3324bac95084e63e28553c92fac5a0394c636a76e03e50a7dab0c0bbddf87fa5", size = 173972, upload-time = "2026-06-18T13:36:35.076Z" },
{ url = "https://files.pythonhosted.org/packages/0e/48/8c9fee7d75571f2f4b2386eac798fe5f826155d13797f7c86d45eb3fdc23/uuid_utils-0.16.2-pp311-pypy311_pp73-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8b8e325e61f918caf74ca540e3384b81e6e22aea782e57f615d15fc9773b96c8", size = 571003, upload-time = "2026-06-18T13:36:36.42Z" },
{ url = "https://files.pythonhosted.org/packages/de/78/754eaaa49509be6fdb705de61d1e3889de32002132d5f00e8c1e5d212da3/uuid_utils-0.16.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9282677ebf2ea5b437c20d16e75bcd7629bdc205018f95557b33b76868d8bb5b", size = 290244, upload-time = "2026-06-18T13:36:38.066Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e2/bfcbcf7eb9dfb17701104c569ed771eb359737bc70b7309e439610d089ef/uuid_utils-0.16.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2e9ca7f5e215373cc9c147172170a0b1a4ab0dee9cc62fe446d9b075f31e3241", size = 328551, upload-time = "2026-06-18T13:36:39.605Z" },
{ url = "https://files.pythonhosted.org/packages/72/bf/bbdbc39d1421953edcee0bad13a1893521a636eccf381580f53e530a4feb/uuid_utils-0.16.2-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43cc72a92694d08ade8faadacf928857d9cceb84b449473246ae4e4f263d7d22", size = 335468, upload-time = "2026-06-18T13:36:41Z" },
{ url = "https://files.pythonhosted.org/packages/04/2a/e8d4e6f1f2d2e567cf6e3202d125afe7da52ad7680bba048b106c09f01b9/uuid_utils-0.16.2-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511b5fde12d29c37a9badd399af62105bb2f4696aa10eb18be74e7b9ca84413a", size = 450984, upload-time = "2026-06-18T13:36:42.635Z" },
{ url = "https://files.pythonhosted.org/packages/6a/da/ddb1dcf0fe9bfcb0dfcddec8ae52c8f95e7088e44719f58477f5fb2c5586/uuid_utils-0.16.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:585d3adf73afa60348bf2bd529491c640a692350e76d8ff3974455e273aadfe7", size = 327940, upload-time = "2026-06-18T13:36:44.138Z" },
{ url = "https://files.pythonhosted.org/packages/37/fb/39305fbfffee1fdaccdb88fc0499ac9dcb7289a77ebc31938dcdd933cf95/uuid_utils-0.16.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ae5fa2007fd26d26f7b09e76259d5ca99bec191616207ca929f8dca12da08129", size = 355368, upload-time = "2026-06-18T13:36:45.682Z" },
{ url = "https://files.pythonhosted.org/packages/b4/70/b708edc3b776d7624b4354f43d443f14d951d3ac4d7d8867d94f2e59c3ae/uuid_utils-0.16.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:9b4520521aa46a2582fe1829c535fe60b78999b89257db998df3816eb895bdf3", size = 178221, upload-time = "2026-06-18T13:36:47.291Z" },
]
[[package]]
name = "uvicorn"
version = "0.47.0"
@@ -2454,61 +2812,44 @@ wheels = [
[[package]]
name = "websockets"
version = "16.0"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]
@@ -2519,6 +2860,8 @@ dependencies = [
{ name = "alembic" },
{ name = "email-validator" },
{ name = "fastapi" },
{ name = "jieba" },
{ name = "langgraph" },
{ name = "lightrag-hku" },
{ name = "openpyxl" },
{ name = "psycopg", extra = ["binary"] },
@@ -2547,6 +2890,8 @@ requires-dist = [
{ name = "email-validator", specifier = ">=2.2.0,<3.0.0" },
{ name = "fastapi", specifier = ">=0.115.0,<1.0.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.0,<1.0.0" },
{ name = "jieba", specifier = ">=0.42.1,<0.43.0" },
{ name = "langgraph", specifier = ">=1.2.0,<2.0.0" },
{ name = "lightrag-hku", specifier = ">=1.4.16,<1.5.0" },
{ name = "openpyxl", specifier = ">=3.1.5,<4.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2.0,<4.0.0" },
@@ -2572,6 +2917,144 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
]
[[package]]
name = "xxhash"
version = "3.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/f4/7bd35089ff1f8e2c96baa2dce05775a122aacd2e3830a73165e27a4d0848/xxhash-3.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fdc7d06929ae28dda98297a18eef7b0fd38991a3b405d8d7b55c9ef24c296958", size = 33423, upload-time = "2026-04-25T11:05:47.628Z" },
{ url = "https://files.pythonhosted.org/packages/a3/26/4e00c88a6a2c8a759cfb77d2a9a405f901e8aa66e60ef1fd0aeb35edda48/xxhash-3.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea6daa712f4e094a30830cf01e9b47d03b24d05cc9dab8609f0d9a9db8454712", size = 30857, upload-time = "2026-04-25T11:05:49.189Z" },
{ url = "https://files.pythonhosted.org/packages/82/2f/eeb942c17a5a761a8f01cb9180a0b76bfb62a2c39e6f46b1f9001899027a/xxhash-3.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9e6c0d843f1daf85ea23aeb053579135552bde575b7b98af20bfc667b6e4548d", size = 194702, upload-time = "2026-04-25T11:05:50.457Z" },
{ url = "https://files.pythonhosted.org/packages/0e/fd/96f132c08b1e5951c68691d3b9ec351ec2edc028f6a01fcd294f46b9d9f0/xxhash-3.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:363c139bf15e1ac5f136b981d3c077eb551299b1effede7f12faa010b8590a60", size = 213613, upload-time = "2026-04-25T11:05:52.571Z" },
{ url = "https://files.pythonhosted.org/packages/82/89/d4e92b796c5ed052d29ed324dbfc1dc1188e0c4bf64bebbf0f8fc20698df/xxhash-3.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a778b25874cb0f862eaab5986bff4ca49ffb0def7c0a34c237b948b3c6c775b2", size = 236726, upload-time = "2026-04-25T11:05:54.395Z" },
{ url = "https://files.pythonhosted.org/packages/40/f1/81fc4361921dc6e557a9c60cb3712f36d244d06eeeb71cd2f4252ac42678/xxhash-3.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e1860f1e43d40e9d904cf22d93e587ea42e010ebce4160877e46bcab4bc232a", size = 212443, upload-time = "2026-04-25T11:05:56.334Z" },
{ url = "https://files.pythonhosted.org/packages/6a/d0/afeddd4cff50a332f50d4b8a2e8857673153ab0564ef472fcdeb0b5430df/xxhash-3.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9122ad6f867c4a0f5e655f5c3bdf89103852009dbb442a3d23e688b9e699e800", size = 445793, upload-time = "2026-04-25T11:05:58.953Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d0/3c91e4e6a05ca4d7df8e39ec3a75b713609258ec84705ab34be6430826a1/xxhash-3.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d9110d0c3fb02679972837a033251fd186c529aa62f19c132fc909c74052b8", size = 193937, upload-time = "2026-04-25T11:06:00.546Z" },
{ url = "https://files.pythonhosted.org/packages/4e/3a/a6b0772d9801dd4bea4ca4fd34734d6e9b51a711c8a611a24a79de26a878/xxhash-3.7.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:347a93f2b4ce67ce61959665e32a7447c380f8347e55e100daa23766baacf0e5", size = 285188, upload-time = "2026-04-25T11:06:01.96Z" },
{ url = "https://files.pythonhosted.org/packages/6c/f8/cf8e31fd7282230fe7367cd501a2e75b4b67b222bfc7eacccfc20d2652cb/xxhash-3.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:acbb48679ddf3852c45280c10ff10d52ca2cd1da2e552fb81db1ff786c75d0e4", size = 210966, upload-time = "2026-04-25T11:06:03.453Z" },
{ url = "https://files.pythonhosted.org/packages/cc/f0/fd36cc4a81bf52ee5633275daae2b93dd958aace67fd4f5d466ec83b5f35/xxhash-3.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fe14c356f8b23ad811dc026077a6d4abccdaa7bce5ca98579605550657b6fcfb", size = 241994, upload-time = "2026-04-25T11:06:05.264Z" },
{ url = "https://files.pythonhosted.org/packages/08/e1/67f5d9c9369be42eaf99ba02c01bf14c5ecd67087b02567960bfcee43b63/xxhash-3.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f420ad3d41e38194353a498bbc9561fd5a9973a27b536ce46d8583479cf44335", size = 198707, upload-time = "2026-04-25T11:06:07.044Z" },
{ url = "https://files.pythonhosted.org/packages/50/17/a4c865ca22d2da6b1bc7d739bf88cab209533cf52ba06ca9da27c3039bee/xxhash-3.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:693d02c6dc7d1aa0a45921d54cd8c1ff629e09dfdc2238471507af1f7a1c6f04", size = 210917, upload-time = "2026-04-25T11:06:08.853Z" },
{ url = "https://files.pythonhosted.org/packages/49/8b/453b35810d697abac3c96bde3528bece685869227da274eb80a4a4d4a119/xxhash-3.7.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:14bf7a54e43825ec131ee7fe3c60e142e7c2c1e676ad0f93fc893432d15414af", size = 275772, upload-time = "2026-04-25T11:06:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ad/4eed7eab07fd3ee6678f416190f0413d097ab5d7c1278906bf1e9549d789/xxhash-3.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ae3a39a4d96bdb6f8d154fd7f490c4ad06f0532fcd2bb656052a9a7762cf5d31", size = 414068, upload-time = "2026-04-25T11:06:12.511Z" },
{ url = "https://files.pythonhosted.org/packages/d3/4e/fd6f8a680ba248fdb83054fa71a8bfa3891225200de1708b888ef2c49829/xxhash-3.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1cc07c639e3a77ef1d32987464d3e408565b8a3be57b545d3542b191054d9923", size = 191459, upload-time = "2026-04-25T11:06:14.07Z" },
{ url = "https://files.pythonhosted.org/packages/50/7c/8cb34b3bed4f44ca6827a534d50833f9bc6c006e83b0eb410ac9fa0793bd/xxhash-3.7.0-cp311-cp311-win32.whl", hash = "sha256:3281ba1d1e60ee7a382a7b958513ba03c2c0d5fcbd9a6f7517c0a81251a23422", size = 30628, upload-time = "2026-04-25T11:06:15.802Z" },
{ url = "https://files.pythonhosted.org/packages/0b/47/a49767bd7b40782bedae9ff0721bfe1d7e4dd9dc1585dea684e57ba67c20/xxhash-3.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:a7f25baec4c5d851d40718d6fae52285b31683093d4ff5207e63ab306ccf14a5", size = 31461, upload-time = "2026-04-25T11:06:17.104Z" },
{ url = "https://files.pythonhosted.org/packages/7c/c6/3957bfacfb706bd687be246dfa8dd60f8df97c44186d229f7fd6e26c4b7e/xxhash-3.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:4c2454448ce847c72635827bb75c15c5a3434b03ee1afd28cb6dc6fb2597d830", size = 27746, upload-time = "2026-04-25T11:06:18.716Z" },
{ url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" },
{ url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" },
{ url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" },
{ url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" },
{ url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" },
{ url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" },
{ url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" },
{ url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" },
{ url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" },
{ url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" },
{ url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" },
{ url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" },
{ url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" },
{ url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" },
{ url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" },
{ url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" },
{ url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" },
{ url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" },
{ url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" },
{ url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" },
{ url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" },
{ url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" },
{ url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" },
{ url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" },
{ url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" },
{ url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" },
{ url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" },
{ url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" },
{ url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" },
{ url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" },
{ url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" },
{ url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" },
{ url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" },
{ url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" },
{ url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" },
{ url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" },
{ url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" },
{ url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" },
{ url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" },
{ url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" },
{ url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" },
{ url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" },
{ url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" },
{ url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" },
{ url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" },
{ url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" },
{ url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" },
{ url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" },
{ url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" },
{ url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" },
{ url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" },
{ url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" },
{ url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" },
{ url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" },
{ url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" },
{ url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" },
{ url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" },
{ url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" },
{ url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" },
{ url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" },
{ url = "https://files.pythonhosted.org/packages/0e/cc/431db584f6fbb9312e40a173af027644e5580d39df1f73603cbb9dca4d6b/xxhash-3.7.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", size = 36644, upload-time = "2026-04-25T11:08:00.658Z" },
{ url = "https://files.pythonhosted.org/packages/bc/01/255ec513e0a705d1f9a61413e78dfce4e3235203f0ed525a24c2b4b56345/xxhash-3.7.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", size = 35003, upload-time = "2026-04-25T11:08:02.338Z" },
{ url = "https://files.pythonhosted.org/packages/68/70/c55fc33c93445b44d8fc5a17b41ed99e3cebe92bcf8396809e63fc9a1165/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", size = 29655, upload-time = "2026-04-25T11:08:03.701Z" },
{ url = "https://files.pythonhosted.org/packages/c2/72/ff8de73df000d74467d12a59ce6d6e2b2a368b978d41ab7b1fba5ed442be/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", size = 30664, upload-time = "2026-04-25T11:08:05.011Z" },
{ url = "https://files.pythonhosted.org/packages/b6/91/08416d9bd9bc3bf39d831abe8a5631ac2db5141dfd6fe81c3fe59a1f9264/xxhash-3.7.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", size = 33317, upload-time = "2026-04-25T11:08:06.413Z" },
{ url = "https://files.pythonhosted.org/packages/0e/3b/86b1caa4dee10a99f4bf9521e623359341c5e50d05158fa10c275b2bd079/xxhash-3.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", size = 33457, upload-time = "2026-04-25T11:08:08.099Z" },
{ url = "https://files.pythonhosted.org/packages/ed/38/98ea14ad1517e1461292a65906951458d520689782bfbae111050145bdba/xxhash-3.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", size = 30894, upload-time = "2026-04-25T11:08:09.429Z" },
{ url = "https://files.pythonhosted.org/packages/61/a2/074654d0b893606541199993c7db70067d9fc63b748e0d60020a52a1bd36/xxhash-3.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", size = 194409, upload-time = "2026-04-25T11:08:10.91Z" },
{ url = "https://files.pythonhosted.org/packages/e2/26/6d2a1afc468189f77ca28c32e1c83e1b9da1178231e05641dbc1b350e332/xxhash-3.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", size = 213135, upload-time = "2026-04-25T11:08:12.575Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0e/d8aecf95e09c42547453137be74d2f7b8b14e08f5177fa2fab6144a19061/xxhash-3.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", size = 236379, upload-time = "2026-04-25T11:08:14.206Z" },
{ url = "https://files.pythonhosted.org/packages/f2/74/8140e8210536b3dd0cc816c4faaeb5ba6e63e8125ab25af4bcddd6a037b3/xxhash-3.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", size = 212447, upload-time = "2026-04-25T11:08:15.79Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/462001d2903b4bee5a5689598a0a55e5e7cd1ac7f4247a5545cff10d3ebb/xxhash-3.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", size = 445660, upload-time = "2026-04-25T11:08:17.441Z" },
{ url = "https://files.pythonhosted.org/packages/23/09/2bd1ed7f8689b20e51727952cac8329d50c694dc32b2eba06ba5bc742b37/xxhash-3.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", size = 194076, upload-time = "2026-04-25T11:08:19.134Z" },
{ url = "https://files.pythonhosted.org/packages/c9/6e/692302cd0a5f4ac4e6289f37fa888dc2e1e07750b68fe3e4bfe939b8cea3/xxhash-3.7.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", size = 284990, upload-time = "2026-04-25T11:08:20.618Z" },
{ url = "https://files.pythonhosted.org/packages/05/d9/e54b159b3d9df7999d2a7c676ce7b323d1b5588a64f8f51ed8172567bd87/xxhash-3.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", size = 210590, upload-time = "2026-04-25T11:08:22.24Z" },
{ url = "https://files.pythonhosted.org/packages/50/93/0e0df1a3a196ced4ca71de76d65ead25d8e87bbfb87b64306ea47a40c00d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", size = 241442, upload-time = "2026-04-25T11:08:23.844Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a9/d917a7a814e90b218f8a0d37967105eea91bf752c3303683c99a1f7bfc1f/xxhash-3.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", size = 198356, upload-time = "2026-04-25T11:08:25.99Z" },
{ url = "https://files.pythonhosted.org/packages/89/5e/f2ba1877c39469abbefc72991d6ebdcbd4c0880db01ae8cb1f553b0c537d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", size = 210898, upload-time = "2026-04-25T11:08:27.608Z" },
{ url = "https://files.pythonhosted.org/packages/90/c6/be56b58e73de531f39a10de1355bb77ceb663900dc4bf2d6d3002a9c3f9e/xxhash-3.7.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", size = 275519, upload-time = "2026-04-25T11:08:29.301Z" },
{ url = "https://files.pythonhosted.org/packages/92/e2/17ddc85d5765b9c709f192009ed8f5a1fc876f4eb35bba7c307b5b1169f9/xxhash-3.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", size = 414191, upload-time = "2026-04-25T11:08:31.16Z" },
{ url = "https://files.pythonhosted.org/packages/9c/42/85f5b79f4bf1ec7ba052491164adfd4f4e9519f5dc7246de4fbd64a1bd56/xxhash-3.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", size = 191604, upload-time = "2026-04-25T11:08:32.862Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d0/6127b623aa4cca18d8b7743592b048d689fd6c6e37ff26a22cddf6cd9d7f/xxhash-3.7.0-cp314-cp314-win32.whl", hash = "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", size = 31271, upload-time = "2026-04-25T11:08:34.651Z" },
{ url = "https://files.pythonhosted.org/packages/64/4f/44fc4788568004c43921701cbc127f48218a1eede2c9aea231115323564d/xxhash-3.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", size = 32284, upload-time = "2026-04-25T11:08:35.987Z" },
{ url = "https://files.pythonhosted.org/packages/6d/77/18bb895eb60a49453d16e17d67990e5caff557c78eafc90ad4e2eabf4570/xxhash-3.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", size = 28701, upload-time = "2026-04-25T11:08:37.767Z" },
{ url = "https://files.pythonhosted.org/packages/45/a0/46f72244570c550fbbb7db1ef554183dd5ebe9136385f30e032b781ae8f6/xxhash-3.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", size = 33646, upload-time = "2026-04-25T11:08:39.109Z" },
{ url = "https://files.pythonhosted.org/packages/4a/3a/453846a7eceea11e75def361eed01ec6a0205b9822c19927ed364ccae7cc/xxhash-3.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", size = 31125, upload-time = "2026-04-25T11:08:40.467Z" },
{ url = "https://files.pythonhosted.org/packages/bd/3e/49434aba738885d512f9e486db1bdd19db28dfa40372b56da26ef7a4e738/xxhash-3.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", size = 196633, upload-time = "2026-04-25T11:08:41.943Z" },
{ url = "https://files.pythonhosted.org/packages/a4/e9/006cb6127baeb9f8abe6d15e62faa01349f09b34e2bfd65175b2422d026b/xxhash-3.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", size = 215899, upload-time = "2026-04-25T11:08:43.645Z" },
{ url = "https://files.pythonhosted.org/packages/27/e4/cc57d72e66df0ae29b914335f1c6dcf61e8f3746ddf0ae3c471aa4f15e00/xxhash-3.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", size = 238116, upload-time = "2026-04-25T11:08:45.698Z" },
{ url = "https://files.pythonhosted.org/packages/af/78/3531d4a3fd8a0038cc6be1f265a69c1b3587f557a10b677dd736de2202c1/xxhash-3.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", size = 215012, upload-time = "2026-04-25T11:08:47.355Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f6/259fb1eaaec921f59b17203b0daee69829761226d3b980d5191d7723dd83/xxhash-3.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", size = 448534, upload-time = "2026-04-25T11:08:49.149Z" },
{ url = "https://files.pythonhosted.org/packages/7b/16/a66d0eaf6a7e68532c07714361ddc904c663ec940f3b028c1ae4a21a7b9d/xxhash-3.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", size = 196217, upload-time = "2026-04-25T11:08:50.805Z" },
{ url = "https://files.pythonhosted.org/packages/8d/ef/d2efc7fc51756dc52509109d1a25cefc859d74bc4b19a167b12dbd8c2786/xxhash-3.7.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", size = 286906, upload-time = "2026-04-25T11:08:52.418Z" },
{ url = "https://files.pythonhosted.org/packages/fc/67/25decd1d4a4018582ec4db2a868a2b7e40640f4adb20dfeb19ac923aa825/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", size = 213057, upload-time = "2026-04-25T11:08:54.105Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5d/17651eb29d06786cdc40c60ae3d27d645aa5d61d2eca6237a7ba0b94789b/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", size = 243886, upload-time = "2026-04-25T11:08:56.109Z" },
{ url = "https://files.pythonhosted.org/packages/8a/d4/174d9cf7502243d586e6a9ae842b1ae23026620995114f85f1380e588bc9/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", size = 201015, upload-time = "2026-04-25T11:08:57.777Z" },
{ url = "https://files.pythonhosted.org/packages/91/8c/2254e2d06c3ac5e6fe22eaf3da791b87ea823ae9f2c17b4af66755c5752d/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", size = 213457, upload-time = "2026-04-25T11:08:59.826Z" },
{ url = "https://files.pythonhosted.org/packages/79/a2/e3daa762545921173e3360f3b4ff7fc63c2d27359f7230ec1a7a74e117f6/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", size = 277738, upload-time = "2026-04-25T11:09:01.423Z" },
{ url = "https://files.pythonhosted.org/packages/e1/4c/e186da2c46b87f5204640e008d42730bf3c1ee9f0efb71ae1ebcdfeac681/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", size = 417127, upload-time = "2026-04-25T11:09:03.592Z" },
{ url = "https://files.pythonhosted.org/packages/17/28/3798e15007a3712d0da3d3fe70f8e11916569858b5cc371053bc26270832/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", size = 193962, upload-time = "2026-04-25T11:09:06.228Z" },
{ url = "https://files.pythonhosted.org/packages/ad/95/a26baa93b5241fd7630998816a4ec47a5a0bad193b3f8fc8f3593e1a4a67/xxhash-3.7.0-cp314-cp314t-win32.whl", hash = "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", size = 31643, upload-time = "2026-04-25T11:09:08.153Z" },
{ url = "https://files.pythonhosted.org/packages/44/36/5454f13c447e395f9b06a3e91274c59f503d31fad84e1836efe3bdb71f6a/xxhash-3.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", size = 32522, upload-time = "2026-04-25T11:09:09.534Z" },
{ url = "https://files.pythonhosted.org/packages/74/35/698e7e3ff38e22992ea24870a511d8762474fb6783627a2910ff22a185c2/xxhash-3.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", size = 28807, upload-time = "2026-04-25T11:09:11.234Z" },
{ url = "https://files.pythonhosted.org/packages/54/c1/e57ac7317b1f58a92bab692da6d497e2a7ce44735b224e296347a7ecc754/xxhash-3.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ad3aa71e12ee634f22b39a0ff439357583706e50765f17f05550f92dbf128a23", size = 31232, upload-time = "2026-04-25T11:10:21.51Z" },
{ url = "https://files.pythonhosted.org/packages/4f/4e/075559bd712bc62e84915ea46bbee859f935d285659082c129bdbff679dd/xxhash-3.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5de686e73690cdaf72b96d4fa083c230ec9020bcc2627ce6316138e2cf2fe2d1", size = 28553, upload-time = "2026-04-25T11:10:23.1Z" },
{ url = "https://files.pythonhosted.org/packages/92/ca/a9c78cb384d4b033b0c58196bd5c8509873cabe76389e195127b0302a741/xxhash-3.7.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7fbec49f5341bbdea0c471f7d1e2fb41ae8925af9b6f28025c28defd8eb94274", size = 41109, upload-time = "2026-04-25T11:10:25.022Z" },
{ url = "https://files.pythonhosted.org/packages/bd/b1/dfe2629f7c77eb2fa234c72ff537cdd64939763df704e256446ed364a16d/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b542c347c2089f43dc5a6db31d2a6f3cdb04ee33505ec6e9f653834dbb0bde", size = 36307, upload-time = "2026-04-25T11:10:26.949Z" },
{ url = "https://files.pythonhosted.org/packages/e7/f7/5a484afce0f48dd8083208b42e4911f290a82c7b52458ef2927e4d421a45/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a169a036bed0995e090d1493b283cc2cc8a6f5046821086b843abefff80643bc", size = 32534, upload-time = "2026-04-25T11:10:29.01Z" },
{ url = "https://files.pythonhosted.org/packages/0f/5f/4acfcd490db9780cf36c58534d828003c564cde5350220a1c783c4d10776/xxhash-3.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ec101643395d7f21405b640f728f6f627e6986557027d740f2f9b220955edafe", size = 31552, upload-time = "2026-04-25T11:10:30.727Z" },
]
[[package]]
name = "yarl"
version = "1.24.2"
@@ -2670,3 +3153,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" },
{ url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" },
]
[[package]]
name = "zstandard"
version = "0.25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" },
{ url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" },
{ url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" },
{ url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" },
{ url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" },
{ url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" },
{ url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" },
{ url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" },
{ url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" },
{ url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" },
{ url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" },
{ url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" },
{ url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" },
{ url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" },
{ url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" },
{ url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" },
{ url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" },
{ url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" },
{ url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" },
{ url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" },
{ url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" },
{ url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" },
{ url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" },
{ url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" },
{ url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" },
{ url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" },
{ url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" },
{ url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" },
{ url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" },
{ url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" },
{ url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" },
{ url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" },
{ url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" },
{ url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" },
{ url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" },
{ url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" },
{ url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" },
{ url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" },
{ url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" },
{ url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" },
{ url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" },
{ url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" },
{ url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" },
{ url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" },
{ url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" },
{ url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" },
{ url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" },
{ url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" },
{ url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" },
{ url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" },
{ url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" },
{ url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" },
{ url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" },
{ url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" },
{ url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" },
{ url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" },
{ url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" },
]

View File

@@ -549,59 +549,156 @@
}
.associate-hint {
margin: 0;
margin: 0 0 2px 0;
color: #64748b;
font-size: 13px;
line-height: 1.4;
}
.receipt-checkbox-list,
.draft-choice-list {
max-height: 360px;
max-height: 340px;
display: grid;
gap: 8px;
overflow-y: auto;
padding: 1px;
}
.receipt-checkbox-list :deep(.el-checkbox),
.draft-choice {
min-height: 50px;
display: flex !important;
min-height: 52px;
align-items: center;
margin-right: 0;
padding: 9px 10px;
border: 1px solid #dbe4ee;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
transition: all 150ms ease;
box-sizing: border-box;
}
.receipt-checkbox-list :deep(.el-checkbox:hover),
.draft-choice:hover {
border-color: #cbd5e1;
background: #f8fafc;
}
.receipt-checkbox-list :deep(.el-checkbox.is-checked) {
border-color: var(--theme-primary);
background: rgba(58, 124, 165, 0.04);
}
.receipt-checkbox-list :deep(.el-checkbox__label) {
flex: 1;
display: grid;
gap: 3px;
gap: 2px;
color: #0f172a;
font-weight: 750;
font-size: 13px;
font-weight: 600;
line-height: 1.35;
}
.receipt-checkbox-list small,
.draft-choice small {
color: #64748b;
font-size: 12px;
font-weight: 650;
font-size: 11.5px;
font-weight: 400;
line-height: 1.35;
}
.draft-choice {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 9px;
gap: 10px;
cursor: pointer;
}
.draft-choice.active {
border-color: rgba(58, 124, 165, .42);
background: rgba(58, 124, 165, .07);
border-color: var(--theme-primary);
background: rgba(58, 124, 165, 0.04);
}
.draft-choice span {
display: grid;
gap: 3px;
gap: 2px;
}
.draft-choice strong {
color: #0f172a;
font-size: 13px;
font-weight: 600;
line-height: 1.35;
}
/* Global styles for the association dialog */
:global(.receipt-associate-dialog.el-dialog) {
border-radius: 6px;
box-shadow: 0 16px 36px rgba(15, 23, 42, 0.12);
}
:global(.receipt-associate-dialog .el-dialog__header) {
padding: 20px 24px 12px 24px;
margin-right: 0;
}
:global(.receipt-associate-dialog .el-dialog__headerbtn) {
top: 20px;
right: 24px;
}
:global(.receipt-associate-dialog .el-dialog__title) {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
:global(.receipt-associate-dialog .el-dialog__body) {
padding: 0 24px 20px 24px;
}
:global(.receipt-associate-dialog .el-dialog__footer) {
padding: 12px 24px 16px 24px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
display: flex;
justify-content: flex-end;
gap: 8px;
}
:global(.receipt-associate-dialog .el-dialog__footer .apply-btn),
:global(.receipt-associate-dialog .el-dialog__footer .ghost-btn) {
min-height: 36px;
height: 36px;
padding: 0 16px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
transition: all 150ms ease;
}
:global(.receipt-associate-dialog .el-dialog__footer .ghost-btn) {
border-color: #cbd5e1;
color: #334155;
background: #fff;
}
:global(.receipt-associate-dialog .el-dialog__footer .ghost-btn:hover) {
background: #f8fafc;
border-color: #94a3b8;
color: #0f172a;
}
:global(.receipt-associate-dialog .el-dialog__footer .apply-btn) {
background: var(--theme-primary);
border-color: var(--theme-primary);
color: #fff;
}
:global(.receipt-associate-dialog .el-dialog__footer .apply-btn:hover:not(:disabled)) {
background: var(--theme-primary-active);
border-color: var(--theme-primary-active);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
@media (max-width: 1180px) {

View File

@@ -35,7 +35,20 @@ import { useWorkbenchAiMessageActions } from './useWorkbenchAiMessageActions.js'
import { useWorkbenchAiMessageExpansion } from './useWorkbenchAiMessageExpansion.js'
import { useWorkbenchAiSessionCommands } from './useWorkbenchAiSessionCommands.js'
import { useWorkbenchAiStewardFlow } from './useWorkbenchAiStewardFlow.js'
import { isReimbursementCreationIntent } from './workbenchAiApplicationGateModel.js'
import {
isReimbursementCreationIntent
} from './workbenchAiApplicationGateModel.js'
import {
buildRuleFallbackWorkbenchAiIntentPlan,
normalizeWorkbenchAiIntentPlan,
resolveExecutableTravelApplicationPlan,
shouldRequestWorkbenchAiIntentPlan
} from './workbenchAiIntentPlannerModel.js'
import {
buildInitialModelPlanningThinkingEvents,
buildModelPlanningProgressSchedule,
mergeWorkbenchAiThinkingEvents
} from './workbenchAiPlanningThinkingModel.js'
const AI_SEARCH_CONVERSATION_ID = 'ai-search'
const INLINE_ANSWER_STREAM_CHUNK_SIZE = 6
@@ -251,11 +264,16 @@ export function usePersonalWorkbenchAiMode(props, emit) {
applicationFlow,
assistantDraft,
attachmentFlow,
conversationMessages,
createInlineMessage,
emit,
expenseFlow,
focusAiModeInput,
hasInlineAttachmentOcrDetails,
persistCurrentConversation,
replaceInlineMessage,
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
selectedFiles,
startInlineConversation,
toast,
@@ -560,6 +578,131 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return false
}
function isModelPlannedReimbursementTask(modelPlan = {}) {
const tasks = Array.isArray(modelPlan?.tasks) ? modelPlan.tasks : []
return tasks.some((task) => {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
return taskType === 'reimbursement' || assignedAgent === 'reimbursement_assistant'
})
}
function updateModelPlanningThinkingEvent(messageId, event) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message) {
return
}
const currentPlan = message.stewardPlan || {}
message.stewardPlan = {
...currentPlan,
streamStatus: 'streaming',
thinkingEvents: mergeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(message), [event])
}
persistCurrentConversation()
scrollInlineConversationToBottom({ force: inlineConversationAutoScrollPinned.value })
}
function startModelPlanningProgressUpdates(messageId) {
const timerIds = buildModelPlanningProgressSchedule().map(({ delayMs, event }) => (
globalThis.setTimeout(() => {
updateModelPlanningThinkingEvent(messageId, event)
}, delayMs)
))
return () => {
timerIds.forEach((timerId) => globalThis.clearTimeout(timerId))
}
}
function startModelPlanningConversation(cleanPrompt, entry = {}) {
if (conversationId.value === AI_SEARCH_CONVERSATION_ID) {
conversationId.value = ''
conversationMessages.value = []
activeConversationTitle.value = ''
}
activateInlineConversation({
title: entry.label || cleanPrompt.slice(0, 18) || '新对话'
})
inlineConversationAutoScrollPinned.value = true
conversationMessages.value.push(createInlineMessage('user', cleanPrompt))
assistantDraft.value = ''
removeWorkbenchDateTag()
closeWorkbenchDatePicker()
filesFlow.clearAiModeFiles()
const pendingMessage = createInlineMessage('assistant', '正在识别意图,准备拆解申请、报销和附件任务。', {
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: buildInitialModelPlanningThinkingEvents()
}
})
conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
persistCurrentConversation()
return pendingMessage
}
function startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage = null) {
void applicationFlow.startAiApplicationPreview(
travelApplicationRequest.expenseType,
travelApplicationRequest.expenseTypeLabel,
travelApplicationRequest.sourceText,
{
userMessage: travelApplicationRequest.sourceText,
pushUserMessage: !plannerPendingMessage,
pendingMessageId: plannerPendingMessage?.id,
ontologyFields: travelApplicationRequest.ontologyFields,
autoSubmit: travelApplicationRequest.autoSubmit,
autoSaveDraft: travelApplicationRequest.autoSaveDraft
}
)
}
async function executeModelPlannedWorkbenchIntent(cleanPrompt, entry = {}, files = []) {
let intentPlan = null
let modelPlan = null
const plannerPendingMessage = startModelPlanningConversation(cleanPrompt, entry)
const stopPlanningProgressUpdates = startModelPlanningProgressUpdates(plannerPendingMessage.id)
sending.value = true
try {
modelPlan = await stewardFlow.resolveInlineExecutionPlan(cleanPrompt, entry, files, {
pendingMessageId: plannerPendingMessage.id
})
intentPlan = normalizeWorkbenchAiIntentPlan(modelPlan, { prompt: cleanPrompt })
} catch (error) {
console.warn('AI mode intent planner failed, using local fallback:', error)
const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan(cleanPrompt)
const ruleRequest = resolveExecutableTravelApplicationPlan(rulePlan)
if (ruleRequest) {
sending.value = false
startModelPlannedApplicationPreview(ruleRequest, plannerPendingMessage)
return
}
} finally {
stopPlanningProgressUpdates()
sending.value = false
}
const travelApplicationRequest = resolveExecutableTravelApplicationPlan(intentPlan)
if (travelApplicationRequest) {
startModelPlannedApplicationPreview(travelApplicationRequest, plannerPendingMessage)
return
}
if (isModelPlannedReimbursementTask(modelPlan) || isReimbursementCreationIntent(cleanPrompt)) {
replaceInlineMessage(plannerPendingMessage.id, createInlineMessage('assistant', '已识别为报销任务,正在进入报销流程。', {
id: plannerPendingMessage.id,
stewardPlan: {
streamStatus: 'completed',
thinkingEvents: resolveInlineThinkingEvents(plannerPendingMessage).map((item) => ({ ...item, status: 'completed' }))
}
}))
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || cleanPrompt)
return
}
void stewardFlow.requestInlineAssistantReply(cleanPrompt, entry, files, { pendingMessageId: plannerPendingMessage.id })
}
function handleAiAnswerMarkdownClick(event) {
const target = event?.target
const link = target?.closest?.('a[href^="#ai-open-document-detail:"], a[href^="#ai-open-application-detail:"]')
@@ -594,6 +737,11 @@ export function usePersonalWorkbenchAiMode(props, emit) {
return
}
if (shouldRequestWorkbenchAiIntentPlan(cleanPrompt)) {
void executeModelPlannedWorkbenchIntent(cleanPrompt, entry, files)
return
}
if (isReimbursementCreationIntent(cleanPrompt)) {
void expenseFlow.startAiReimbursementAssociationGate(cleanPrompt, entry.label || '我要报销')
return

View File

@@ -2,6 +2,7 @@ import {
AI_APPLICATION_ACTION_SAVE_DRAFT,
AI_APPLICATION_ACTION_SUBMIT
} from '../../services/aiApplicationPreviewActions.js'
import { executeStewardAction } from '../../services/steward.js'
import { buildAiDocumentDetailRequest } from '../../utils/aiDocumentDetailReference.js'
import {
mergeComposerPrefill,
@@ -25,11 +26,16 @@ export function useWorkbenchAiActionRouter({
applicationFlow,
assistantDraft,
attachmentFlow,
conversationMessages,
createInlineMessage,
emit,
expenseFlow,
focusAiModeInput,
hasInlineAttachmentOcrDetails,
persistCurrentConversation,
replaceInlineMessage,
resolveLatestInlineUserPrompt,
scrollInlineConversationToBottom,
selectedFiles,
startInlineConversation,
toast,
@@ -45,6 +51,9 @@ export function useWorkbenchAiActionRouter({
const actionType = String(action?.action_type || '').trim()
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
if (actionPayload.steward_execute_action) {
return executeInlineStewardAction(action, sourceMessage)
}
if (actionType === AI_ATTACHMENT_OCR_DETAIL_ACTION) {
if (!hasInlineAttachmentOcrDetails(sourceMessage)) {
toast('当前消息没有可查看的附件识别明细。')
@@ -162,6 +171,191 @@ export function useWorkbenchAiActionRouter({
}, action?.payload?.carry_files ? Array.from(selectedFiles.value) : [])
}
async function executeInlineStewardAction(action = {}, sourceMessage = null) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const actionType = String(actionPayload.steward_action_type || '').trim()
if (!actionType || !actionPayload.steward_current_task) {
toast('当前动作缺少可执行任务快照,请重新发起规划。')
return false
}
if (sourceMessage?.suggestedActionsLocked) {
return true
}
if (sourceMessage) {
sourceMessage.suggestedActionsLocked = true
}
const pendingMessage = appendStewardActionMessage(
`正在执行:${String(action.label || '小财管家动作').trim() || '小财管家动作'}...`,
{ pending: true }
)
try {
let precheckResult = null
if (actionType === 'submit_application') {
precheckResult = await executeStewardAction(
buildStewardActionExecutePayload(action, 'run_duplicate_precheck')
)
if (!isSuccessfulStewardPrecheck(precheckResult)) {
finalizeStewardActionMessage(pendingMessage, buildStewardActionResultText(precheckResult), {
suggestedActions: []
})
return true
}
}
const contextJson = precheckResult
? { precheck_result: precheckResult.result_payload || precheckResult.resultPayload || {} }
: {}
const result = await executeStewardAction(
buildStewardActionExecutePayload(action, actionType, contextJson)
)
finalizeStewardActionMessage(pendingMessage, buildStewardActionResultText(result), {
suggestedActions: buildStewardActionResultActions(result)
})
return true
} catch (error) {
if (sourceMessage) {
sourceMessage.suggestedActionsLocked = false
}
finalizeStewardActionMessage(
pendingMessage,
`动作执行失败:${String(error?.message || '请稍后重试。').trim()}`
)
return false
}
}
function buildStewardActionExecutePayload(action = {}, actionType = '', contextJson = {}) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const currentTask = actionPayload.steward_current_task || null
return {
action_type: actionType,
message: resolveStewardActionMessage(action),
plan_id: String(actionPayload.steward_plan_id || '').trim(),
conversation_id: String(actionPayload.conversation_id || actionPayload.conversationId || '').trim() || null,
task: currentTask,
action_step: resolveStewardActionStep(actionPayload, actionType),
confirmed: true,
context_json: contextJson,
client_trace_id: buildStewardActionClientTraceId(actionPayload, actionType)
}
}
function resolveStewardActionMessage(action = {}) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
return String(
actionPayload.carry_text ||
actionPayload.original_message ||
resolveLatestInlineUserPrompt?.() ||
action.label ||
''
).trim()
}
function resolveStewardActionStep(actionPayload = {}, actionType = '') {
if (String(actionPayload.steward_action_step?.action_type || '').trim() === actionType) {
return actionPayload.steward_action_step
}
const steps = Array.isArray(actionPayload.steward_current_task?.action_steps)
? actionPayload.steward_current_task.action_steps
: []
return steps.find((step) => String(step?.action_type || '').trim() === actionType) || null
}
function buildStewardActionClientTraceId(actionPayload = {}, actionType = '') {
const planId = String(actionPayload.steward_plan_id || 'steward').trim() || 'steward'
const taskId = String(actionPayload.steward_action_task_id || actionPayload.steward_current_task?.task_id || 'task').trim() || 'task'
return `${planId}:${taskId}:${actionType}:${Date.now()}`
}
function isSuccessfulStewardPrecheck(result = {}) {
const status = String(result?.status || '').trim()
const payload = result?.result_payload || result?.resultPayload || {}
return status === 'succeeded' && payload?.blocking !== true
}
function appendStewardActionMessage(content, options = {}) {
if (!conversationMessages?.value || typeof createInlineMessage !== 'function') {
toast(String(content || '').trim())
return null
}
const message = createInlineMessage('assistant', content, options)
conversationMessages.value.push(message)
persistStewardActionConversation()
return message
}
function finalizeStewardActionMessage(pendingMessage, content, options = {}) {
if (!conversationMessages?.value || typeof createInlineMessage !== 'function') {
toast(String(content || '').trim())
return
}
const finalMessage = createInlineMessage('assistant', content, {
...options,
id: pendingMessage?.id
})
if (pendingMessage?.id && typeof replaceInlineMessage === 'function') {
replaceInlineMessage(pendingMessage.id, finalMessage)
} else if (pendingMessage?.id) {
const index = conversationMessages.value.findIndex((item) => item.id === pendingMessage.id)
if (index >= 0) {
conversationMessages.value.splice(index, 1, finalMessage)
} else {
conversationMessages.value.push(finalMessage)
}
} else {
conversationMessages.value.push(finalMessage)
}
persistStewardActionConversation()
}
function persistStewardActionConversation() {
persistCurrentConversation?.()
scrollInlineConversationToBottom?.({ force: true })
}
function buildStewardActionResultText(result = {}) {
const status = String(result?.status || '').trim()
const message = String(result?.message || '').trim()
if (status === 'succeeded') {
return message || '动作已完成。'
}
if (status === 'needs_confirmation') {
return message || '这一步还需要您确认后才能继续。'
}
if (status === 'blocked') {
return ['这一步暂时不能继续。', message].filter(Boolean).join('\n\n')
}
if (status === 'failed') {
return `动作执行失败:${message || '请稍后重试。'}`
}
return message || '动作已返回结果。'
}
function buildStewardActionResultActions(result = {}) {
if (String(result?.status || '').trim() !== 'succeeded') {
return []
}
const resultPayload = result?.result_payload || result?.resultPayload || {}
const draftPayload = resultPayload?.draft_payload || resultPayload?.draftPayload || resultPayload
const claimNo = String(draftPayload?.claim_no || draftPayload?.claimNo || '').trim()
const claimId = String(draftPayload?.claim_id || draftPayload?.claimId || '').trim()
if (!claimNo && !claimId) {
return []
}
return [{
label: claimNo ? `查看单据 ${claimNo}` : '查看单据',
description: '打开刚生成的单据详情。',
icon: 'mdi mdi-open-in-new',
action_type: 'open_application_detail',
payload: {
claim_id: claimId,
claim_no: claimNo
}
}]
}
return {
handleInlineSuggestedAction
}

View File

@@ -27,6 +27,10 @@ import {
extractInlineApplicationDraftPayload
} from './workbenchAiApplicationPreviewModel.js'
import { AI_ATTACHMENT_ASSOCIATION_CONFIRM_ACTION } from './workbenchAiMessageModel.js'
import {
completeWorkbenchAiThinkingEvents,
mergeWorkbenchAiThinkingEvents
} from './workbenchAiPlanningThinkingModel.js'
import {
isOrphanInlineApplicationPreviewMessage,
resolveInlineApplicationPreviewTextAction,
@@ -202,6 +206,26 @@ export function useWorkbenchAiApplicationPreviewFlow({
].join('\n\n')
}
function hasSavedInlineApplicationDraft(message = {}, options = {}) {
const draftPayload = message?.draftPayload || options.draftPayload || null
if (!draftPayload || typeof draftPayload !== 'object') {
return false
}
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
const claimNo = String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
const status = String(draftPayload.status || '').trim().toLowerCase()
return Boolean((claimId || claimNo) && status !== 'submitted')
}
function isContextualInlineApplicationSubmitText(text = '') {
const normalized = String(text || '').replace(/\s+/g, '').trim()
return Boolean(
normalized &&
/提交/.test(normalized) &&
/(这个|这张|当前|上面|上述|刚才|申请单|单据|草稿|领导|审核|审批)/.test(normalized)
)
}
function resolveLatestInlineApplicationPreviewMessage() {
return resolveLatestApplicationPreviewMessage(conversationMessages.value)
}
@@ -334,7 +358,12 @@ export function useWorkbenchAiApplicationPreviewFlow({
return true
}
if (isSubmit && !options.confirmed) {
const shouldSubmitSavedDraftDirectly = isSubmit &&
!options.confirmed &&
hasSavedInlineApplicationDraft(targetMessage, options) &&
isContextualInlineApplicationSubmitText(userText)
if (isSubmit && !options.confirmed && !shouldSubmitSavedDraftDirectly) {
requestInlineApplicationSubmitConfirmation(targetMessage, { ...options, userText })
return true
}
@@ -474,11 +503,16 @@ export function useWorkbenchAiApplicationPreviewFlow({
pushInlineUserMessage(options.userMessage || '确认发起出差申请')
}
const existingPendingMessage = options.pendingMessageId
? conversationMessages.value.find((item) => item.id === options.pendingMessageId)
: null
const previousThinkingEvents = completeWorkbenchAiThinkingEvents(resolveInlineThinkingEvents(existingPendingMessage))
const pendingMessage = createInlineMessage('assistant', '正在生成申请核对表,请稍等...', {
id: options.pendingMessageId,
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: [
thinkingEvents: mergeWorkbenchAiThinkingEvents(previousThinkingEvents, [
{
eventId: 'application-preview-build',
title: '整理申请表字段',
@@ -491,19 +525,25 @@ export function useWorkbenchAiApplicationPreviewFlow({
content: '正在刷新差旅规则和费用测算,完成后会直接展示核对表。',
status: 'pending'
}
]
])
}
})
conversationMessages.value.push(pendingMessage)
if (options.pendingMessageId) {
replaceInlineMessage(options.pendingMessageId, pendingMessage)
} else {
conversationMessages.value.push(pendingMessage)
}
persistCurrentConversation()
scrollInlineConversationToBottom()
try {
const preview = await refreshApplicationPreviewEstimate(
buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {})
buildInlineApplicationPreview(expenseTypeLabel || expenseType, previewSourceText, currentUser.value || {}, {
ontologyFields: options.ontologyFields
})
)
const content = buildLocalApplicationPreviewMessage(preview)
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', content, {
const previewMessage = createInlineMessage('assistant', content, {
id: pendingMessage.id,
applicationPreview: preview,
suggestedActions: buildInlineApplicationPreviewSuggestedActions(preview),
@@ -512,7 +552,20 @@ export function useWorkbenchAiApplicationPreviewFlow({
thinkingEvents: completeInlineThinkingEvents(resolveInlineThinkingEvents(pendingMessage))
},
text: content
}))
})
replaceInlineMessage(pendingMessage.id, previewMessage)
if (options.autoSubmit && normalizeApplicationPreview(preview).readyToSubmit) {
await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SUBMIT, previewMessage, {
confirmed: true,
skipUserMessage: true,
userText: options.userMessage || '直接提交'
})
} else if (options.autoSaveDraft) {
await executeInlineApplicationPreviewAction(AI_APPLICATION_ACTION_SAVE_DRAFT, previewMessage, {
skipUserMessage: true,
userText: options.userMessage || '保存草稿'
})
}
} catch (error) {
replaceInlineMessage(pendingMessage.id, createInlineMessage('assistant', error?.message || '申请核对表生成失败,请稍后重试。', {
id: pendingMessage.id,

View File

@@ -21,6 +21,10 @@ import {
import {
buildInlineAttachmentOcrDetails
} from './workbenchAiMessageModel.js'
import {
completeWorkbenchAiThinkingEvents,
mergeWorkbenchAiThinkingEvents
} from './workbenchAiPlanningThinkingModel.js'
function shouldCheckAiRequiredApplicationGate(prompt) {
const compact = String(prompt || '').replace(/\s+/g, '')
@@ -187,13 +191,16 @@ export function useWorkbenchAiStewardFlow({
return planRequest
}
function handleInlineStewardStreamEvent(messageId, event) {
function handleInlineStewardStreamEvent(messageId, event, options = {}) {
const message = conversationMessages.value.find((item) => item.id === messageId)
if (!message) {
return
}
if (event?.event === 'answer_delta') {
if (options.includeAnswerDelta === false) {
return
}
const data = event?.data && typeof event.data === 'object' ? event.data : {}
const shouldAutoScroll = inlineConversationAutoScrollPinned.value
appendInlineMessageContent(message, data.delta || data.content || data.text || '')
@@ -226,16 +233,23 @@ export function useWorkbenchAiStewardFlow({
scrollInlineConversationToBottom({ force: shouldAutoScroll })
}
async function fetchInlineStewardPlan(messageId, payload) {
async function fetchInlineStewardPlan(messageId, payload, options = {}) {
const {
includeAnswerDelta = true,
idleTimeoutMs = 90000,
timeoutMs = 0,
timeoutMessage = '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。'
} = options
try {
return await fetchStewardPlanStream(
payload,
{
onEvent: (event) => handleInlineStewardStreamEvent(messageId, event)
onEvent: (event) => handleInlineStewardStreamEvent(messageId, event, { includeAnswerDelta })
},
{
idleTimeoutMs: 90000,
timeoutMessage: '小财管家仍在规划任务,已停止等待。您可以稍后继续追问。'
idleTimeoutMs,
timeoutMs,
timeoutMessage
}
)
} catch (error) {
@@ -249,23 +263,67 @@ export function useWorkbenchAiStewardFlow({
}
}
async function requestInlineAssistantReply(prompt, entry = {}, files = []) {
async function resolveInlineExecutionPlan(prompt, entry = {}, files = [], options = {}) {
const receiptContext = await collectAiModeReceiptContext(files)
const planRequest = buildStewardPlanRequest({
rawText: prompt,
files,
currentUser: currentUser.value || {},
conversationId: conversationId.value,
stewardState: stewardState.value
})
planRequest.context_json = {
...planRequest.context_json,
entry_source: 'workbench_ai_inline_execution_plan',
source: entry.source || 'workbench',
attachment_names: receiptContext.attachmentNames,
attachment_count: receiptContext.attachmentCount,
ocr_summary: receiptContext.ocrSummary,
ocr_documents: receiptContext.ocrDocuments,
ocr_source_file_names: receiptContext.ocrSourceFileNames,
requested_execution_mode: 'plan_then_execute',
...(receiptContext.ocrError ? { ocr_error: receiptContext.ocrError } : {})
}
await attachAiRequiredApplicationGate(planRequest, prompt)
const planningMessageId = String(options.pendingMessageId || '').trim()
if (planningMessageId) {
return fetchInlineStewardPlan(planningMessageId, planRequest, {
includeAnswerDelta: false,
idleTimeoutMs: 45000,
timeoutMs: 35000,
timeoutMessage: '小财管家意图规划超时,已切换到本地保守计划。'
})
}
return fetchStewardPlan(planRequest, {
timeoutMs: 35000,
timeoutMessage: '小财管家意图规划超时,已切换到本地保守计划。'
})
}
async function requestInlineAssistantReply(prompt, entry = {}, files = [], options = {}) {
let shouldAutoScrollOnFinish = true
const reusablePendingMessageId = String(options.pendingMessageId || '').trim()
const reusablePendingMessage = reusablePendingMessageId
? conversationMessages.value.find((item) => item.id === reusablePendingMessageId)
: null
const previousThinkingEvents = resolveInlineThinkingEvents(reusablePendingMessage)
const completedPreviousThinkingEvents = completeWorkbenchAiThinkingEvents(previousThinkingEvents)
const pendingMessage = createInlineMessage('assistant', '', {
id: reusablePendingMessageId || undefined,
pending: true,
stewardPlan: {
streamStatus: 'streaming',
thinkingEvents: [
thinkingEvents: mergeWorkbenchAiThinkingEvents(completedPreviousThinkingEvents, [
{
eventId: 'init',
title: '小财管家正在接入业务流程',
content: '正在识别您的意图、上下文和附件信息。',
status: 'running'
}
]
])
}
})
conversationMessages.value.push(pendingMessage)
reusablePendingMessageId ? replaceInlineMessage(reusablePendingMessageId, pendingMessage) : conversationMessages.value.push(pendingMessage)
scrollInlineConversationToBottom()
try {
@@ -303,8 +361,8 @@ export function useWorkbenchAiStewardFlow({
})
const previousThinkingEvents = resolveInlineThinkingEvents(pendingMessage)
const nextThinkingEvents = normalizedPlan.thinkingEvents.length
? normalizedPlan.thinkingEvents
: previousThinkingEvents.map((item) => ({ ...item, status: 'completed' }))
? mergeWorkbenchAiThinkingEvents(previousThinkingEvents, normalizedPlan.thinkingEvents)
: completeWorkbenchAiThinkingEvents(previousThinkingEvents)
const previousConversationId = conversationId.value
const nextConversationId = String(normalizedPlan.conversationId || '').trim()
if (nextConversationId) {
@@ -374,6 +432,7 @@ export function useWorkbenchAiStewardFlow({
buildRequiredApplicationMissingText,
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates,
resolveInlineExecutionPlan,
requestInlineAssistantReply
}
}

View File

@@ -17,15 +17,45 @@ export function isReimbursementCreationIntent(prompt = '') {
)
}
export function resolveInlineTravelApplicationRequest(prompt = '') {
const sourceText = String(prompt || '').trim()
const compact = sourceText.replace(/\s+/g, '')
if (!compact) {
return null
}
if (/标准|制度|规则|政策|口径|怎么|如何|能不能|可以吗|查一下|查询|状态|进度|多少钱|多少/.test(compact)) {
return null
}
const hasTravelIntent = /出差|差旅|差旅费|交通/.test(compact)
const hasTravelDetail = (
/去[\u4e00-\u9fa5A-Za-z0-9]{2,24}/.test(compact) ||
/[\u4e00-\u9fa5A-Za-z0-9]{2,24}出差/.test(compact) ||
/地点[:]?[\u4e00-\u9fa5A-Za-z0-9]{2,24}/.test(compact) ||
/交通(?:方式)?[:]?(火车|高铁|动车|飞机|航班|轮船|轮渡|汽车|网约车|自驾)/.test(compact) ||
/(火车|高铁|动车|飞机|航班|轮船|轮渡|汽车|网约车|自驾)/.test(compact)
)
if (!hasTravelIntent || !hasTravelDetail) {
return null
}
return {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText,
autoSubmit: /直接提交|提交申请|确认提交|提交审批/.test(compact)
}
}
export function resolveInlineApplicationPreviewTextAction(text = '') {
const normalized = String(text || '').replace(/\s+/g, '').trim()
if (!normalized) {
return ''
}
if (/^(保存草稿|保存|存草稿|先保存)$/.test(normalized)) {
if (/^(保存草稿|保存|存草稿|先保存|保存这个单据|保存这个申请|保存这个申请单|保存这张单据|保存这张申请单|保存当前单据|保存当前申请|保存上面的单据|保存刚才的草稿)$/.test(normalized)) {
return AI_APPLICATION_ACTION_SAVE_DRAFT
}
if (/^(提交|提交申请|确认提交|提交审批|直接提交)$/.test(normalized)) {
if (/^(提交|提交申请|确认提交|提交审批|直接提交|提交这个单据|提交这个申请|提交这个申请单|提交这张单据|提交这张申请单|提交当前单据|提交当前申请|提交上面的单据|提交刚才保存的草稿|提交刚才的草稿|把这个单据提交|把这个申请单提交|提交到领导审批|提交给领导审核)$/.test(normalized)) {
return AI_APPLICATION_ACTION_SUBMIT
}
return ''

View File

@@ -1,6 +1,7 @@
import {
buildApplicationTemplatePreview,
buildLocalApplicationPreview,
buildModelRefinedApplicationPreview,
normalizeApplicationPreview
} from '../../utils/expenseApplicationPreview.js'
import {
@@ -187,11 +188,74 @@ export function normalizeInlineApplicationTypeLabel(expenseTypeLabel, fallback =
return `${label}申请`
}
export function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '', currentUser = {}) {
function buildOntologyTimeRangeFromCanonical(value = '') {
const raw = String(value || '').trim()
if (!raw) {
return {}
}
const normalized = raw.replace(/\s+/g, ' ')
const [startDate, endDate] = normalized
.split(/\s*(?:至|到|~|--|—)\s*/)
.map((item) => item.trim())
.filter(Boolean)
if (/^20\d{2}-\d{1,2}-\d{1,2}$/.test(startDate || '')) {
return {
raw,
start_date: startDate,
end_date: /^20\d{2}-\d{1,2}-\d{1,2}$/.test(endDate || '') ? endDate : startDate
}
}
return { raw }
}
function buildEntityFromCanonicalField(type, value) {
const normalizedValue = String(value || '').trim()
if (!type || !normalizedValue) {
return null
}
return {
type,
value: normalizedValue,
normalized_value: normalizedValue
}
}
function buildApplicationOntologyFromCanonicalFields(fields = {}) {
const source = fields && typeof fields === 'object' ? fields : {}
const entityKeys = [
'expense_type',
'location',
'reason',
'amount',
'transport_mode',
'customer_name',
'merchant_name',
'department_name',
'employee_name',
'employee_no'
]
return {
parse_strategy: 'llm_primary',
time_range: buildOntologyTimeRangeFromCanonical(source.time_range),
entities: entityKeys
.map((key) => buildEntityFromCanonicalField(key, source[key]))
.filter(Boolean)
}
}
export function buildInlineApplicationPreview(expenseTypeLabel, sourceText = '', currentUser = {}, options = {}) {
const rawText = String(sourceText || '').trim()
const preview = rawText
const basePreview = rawText
? buildLocalApplicationPreview(rawText, currentUser)
: buildApplicationTemplatePreview(currentUser)
const preview = options.ontologyFields && typeof options.ontologyFields === 'object'
? buildModelRefinedApplicationPreview(
basePreview,
buildApplicationOntologyFromCanonicalFields(options.ontologyFields),
rawText,
currentUser
)
: basePreview
const normalized = normalizeApplicationPreview(preview)
return normalizeApplicationPreview({
...normalized,

View File

@@ -0,0 +1,276 @@
import { resolveInlineTravelApplicationRequest } from './workbenchAiApplicationGateModel.js'
export const WORKBENCH_AI_INTENT_SOURCE_MODEL = 'llm_function_call'
export const WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK = 'rule_fallback'
export const WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW = 'build_application_preview'
export const WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS = 'validate_required_fields'
export const WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT = 'save_application_draft'
export const WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK = 'run_duplicate_precheck'
export const WORKBENCH_AI_STEP_SUBMIT_APPLICATION = 'submit_application'
const TRAVEL_APPLICATION_INTENT = 'create_travel_application'
function normalizePromptAction(prompt = '') {
const compact = String(prompt || '').replace(/\s+/g, '')
if (/直接提交|提交申请|确认提交|提交审批/.test(compact)) {
return 'submit'
}
if (/保存草稿|保存|存草稿|先保存/.test(compact)) {
return 'save_draft'
}
return 'preview'
}
function normalizePlannerSource(value = '') {
return String(value || '').trim() === WORKBENCH_AI_INTENT_SOURCE_MODEL
? WORKBENCH_AI_INTENT_SOURCE_MODEL
: WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK
}
function normalizeSlotKey(key = '') {
const normalized = String(key || '').trim()
if (['time_range', 'business_time', 'occurred_date', 'application_time'].includes(normalized)) {
return 'timeRange'
}
if (['transport_mode', 'transportType', 'transport_type', 'trafficMode'].includes(normalized)) {
return 'transportMode'
}
if (['business_reason', 'businessPurpose', 'purpose'].includes(normalized)) {
return 'reason'
}
return normalized
}
function normalizeSlots(rawSlots = {}) {
if (!rawSlots || typeof rawSlots !== 'object') {
return {}
}
return Object.entries(rawSlots).reduce((slots, [key, value]) => {
const normalizedKey = normalizeSlotKey(key)
const normalizedValue = String(value || '').trim()
if (normalizedKey && normalizedValue) {
slots[normalizedKey] = normalizedValue
}
return slots
}, {})
}
const ONTOLOGY_FIELD_ALIASES = {
business_time: 'time_range',
occurred_date: 'time_range',
application_time: 'time_range',
transportType: 'transport_mode',
transport_type: 'transport_mode',
trafficMode: 'transport_mode',
business_reason: 'reason',
businessPurpose: 'reason',
purpose: 'reason'
}
const SUPPORTED_ONTOLOGY_FIELDS = new Set([
'expense_type',
'time_range',
'location',
'reason',
'amount',
'transport_mode',
'attachments',
'customer_name',
'merchant_name',
'department_name',
'employee_name',
'employee_no'
])
function normalizeOntologyFields(rawFields = {}) {
if (!rawFields || typeof rawFields !== 'object') {
return {}
}
return Object.entries(rawFields).reduce((fields, [key, value]) => {
const normalizedKey = ONTOLOGY_FIELD_ALIASES[String(key || '').trim()] || String(key || '').trim()
const normalizedValue = String(value || '').trim()
if (SUPPORTED_ONTOLOGY_FIELDS.has(normalizedKey) && normalizedValue) {
fields[normalizedKey] = normalizedValue
}
return fields
}, {})
}
function buildApplicationSteps(requestedAction = 'preview') {
const steps = [
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS
]
if (requestedAction === 'submit') {
steps.push(
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
WORKBENCH_AI_STEP_SUBMIT_APPLICATION
)
} else if (requestedAction === 'save_draft') {
steps.push(WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT)
}
return steps
}
function normalizeServerApplicationSteps(rawSteps = []) {
if (!Array.isArray(rawSteps)) {
return []
}
const mappedSteps = rawSteps
.map((step) => String(step?.action_type || step?.actionType || '').trim())
.map((actionType) => {
if (actionType === WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW) {
return WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW
}
if (actionType === WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS) {
return WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS
}
if (actionType === WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT) {
return WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT
}
if (actionType === WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK) {
return WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK
}
if (actionType === WORKBENCH_AI_STEP_SUBMIT_APPLICATION) {
return WORKBENCH_AI_STEP_SUBMIT_APPLICATION
}
return ''
})
.filter(Boolean)
return [...new Set(mappedSteps)]
}
function findModelTravelApplicationTask(rawPlan = {}) {
const tasks = Array.isArray(rawPlan?.tasks) ? rawPlan.tasks : []
return tasks.find((task) => {
const taskType = String(task?.task_type || task?.taskType || '').trim()
const assignedAgent = String(task?.assigned_agent || task?.assignedAgent || '').trim()
return taskType === 'expense_application' || assignedAgent === 'application_assistant'
}) || null
}
function resolveCandidateFlows(rawPlan = {}) {
const pendingFlow = rawPlan?.pending_flow_confirmation || rawPlan?.pendingFlowConfirmation || {}
const pendingCandidates = pendingFlow?.candidate_flows || pendingFlow?.candidateFlows
const rootCandidates = rawPlan?.candidate_flows || rawPlan?.candidateFlows
if (Array.isArray(pendingCandidates)) {
return pendingCandidates
}
return Array.isArray(rootCandidates) ? rootCandidates : []
}
function findSingleApplicationCandidateFlow(rawPlan = {}) {
const pendingFlow = rawPlan?.pending_flow_confirmation || rawPlan?.pendingFlowConfirmation || {}
if (String(pendingFlow?.status || '').trim() !== 'pending') {
return null
}
const candidateFlows = resolveCandidateFlows(rawPlan)
if (candidateFlows.length !== 1) {
return null
}
const [candidate] = candidateFlows
const flowId = String(candidate?.flow_id || candidate?.flowId || '').trim()
const label = String(candidate?.label || '').trim()
if (flowId === 'travel_application' && /先发起出差申请/.test(label)) {
return candidate
}
return null
}
export function normalizeWorkbenchAiIntentPlan(rawPlan = {}, options = {}) {
const prompt = String(options.prompt || rawPlan?.sourceText || rawPlan?.source_text || '').trim()
const task = findModelTravelApplicationTask(rawPlan)
if (!task) {
const candidateFlow = findSingleApplicationCandidateFlow(rawPlan)
if (!candidateFlow) {
return null
}
const ontologyFields = normalizeOntologyFields(candidateFlow.ontology_fields || candidateFlow.ontologyFields)
const requestedAction = normalizePromptAction(prompt)
return {
source: normalizePlannerSource(rawPlan.planning_source || rawPlan.planningSource),
intent: TRAVEL_APPLICATION_INTENT,
requestedAction,
confidence: Number(candidateFlow.confidence || rawPlan.confidence || 0),
sourceText: prompt,
ontologyFields,
slots: normalizeSlots(ontologyFields),
missingFields: Array.isArray(candidateFlow.missing_fields || candidateFlow.missingFields)
? candidateFlow.missing_fields || candidateFlow.missingFields
: [],
steps: buildApplicationSteps(requestedAction)
}
}
const rawOntologyFields = task.ontology_fields || task.ontologyFields || rawPlan.slots
const ontologyFields = normalizeOntologyFields(rawOntologyFields)
const requestedAction = String(
task.requested_action ||
task.requestedAction ||
rawPlan.requested_action ||
rawPlan.requestedAction ||
''
).trim() || normalizePromptAction(prompt)
const serverSteps = normalizeServerApplicationSteps(task.action_steps || task.actionSteps)
return {
source: normalizePlannerSource(rawPlan.planning_source || rawPlan.planningSource),
intent: TRAVEL_APPLICATION_INTENT,
requestedAction,
confidence: Number(task.confidence || rawPlan.confidence || 0),
sourceText: prompt,
ontologyFields,
slots: normalizeSlots(ontologyFields),
missingFields: Array.isArray(task.missing_fields || task.missingFields)
? task.missing_fields || task.missingFields
: [],
steps: serverSteps.length ? serverSteps : buildApplicationSteps(requestedAction)
}
}
export function buildRuleFallbackWorkbenchAiIntentPlan(prompt = '') {
const request = resolveInlineTravelApplicationRequest(prompt)
if (!request) {
return null
}
const requestedAction = request.autoSubmit ? 'submit' : normalizePromptAction(prompt)
return {
source: WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK,
intent: TRAVEL_APPLICATION_INTENT,
requestedAction,
confidence: 0.72,
sourceText: request.sourceText,
ontologyFields: {},
slots: {},
missingFields: [],
steps: buildApplicationSteps(requestedAction)
}
}
export function shouldRequestWorkbenchAiIntentPlan(prompt = '') {
const compact = String(prompt || '').replace(/\s+/g, '')
if (!compact) {
return false
}
if (compact.length < 2 || /^[\d\s.,,。:;!?-]+$/.test(compact)) {
return false
}
return true
}
export function resolveExecutableTravelApplicationPlan(plan = null) {
if (!plan || plan.intent !== TRAVEL_APPLICATION_INTENT) {
return null
}
if (!Array.isArray(plan.steps) || !plan.steps.includes(WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW)) {
return null
}
return {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: String(plan.sourceText || '').trim(),
ontologyFields: normalizeOntologyFields(plan.ontologyFields || {}),
autoSubmit: plan.steps.includes(WORKBENCH_AI_STEP_SUBMIT_APPLICATION),
autoSaveDraft: plan.steps.includes(WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT)
}
}

View File

@@ -0,0 +1,80 @@
export function mergeWorkbenchAiThinkingEvents(...groups) {
const merged = []
const indexById = new Map()
groups.flat().filter(Boolean).forEach((event) => {
const eventId = String(event.eventId || event.event_id || '').trim()
if (!eventId || !indexById.has(eventId)) {
if (eventId) {
indexById.set(eventId, merged.length)
}
merged.push(event)
return
}
const index = indexById.get(eventId)
merged.splice(index, 1, {
...merged[index],
...event,
eventId
})
})
return merged
}
export function completeWorkbenchAiThinkingEvents(events = []) {
return events.map((event) => ({
...event,
status: event.status === 'failed' ? 'failed' : 'completed'
}))
}
export function buildInitialModelPlanningThinkingEvents() {
return [
{
eventId: 'model-planning-intent',
title: '判断办理意图',
content: '正在判断这句话是要办申请、做报销、处理附件,还是普通咨询。',
status: 'running'
}
]
}
export function buildModelPlanningProgressSchedule() {
return [
{
delayMs: 900,
event: {
eventId: 'model-planning-slots',
title: '抽取关键信息',
content: '正在整理日期、地点、事由、交通方式、附件和“保存/提交”等动作线索。',
status: 'running'
}
},
{
delayMs: 2200,
event: {
eventId: 'model-planning-steps',
title: '规划执行步骤',
content: '正在生成可执行动作序列,例如填充申请表、校验必填项、保存草稿或发起提交。',
status: 'running'
}
},
{
delayMs: 5200,
event: {
eventId: 'model-planning-tools',
title: '匹配业务工具',
content: '正在把模型计划映射到白名单工具,避免未确认的提交或附件关联直接产生副作用。',
status: 'running'
}
},
{
delayMs: 9000,
event: {
eventId: 'model-planning-fallback',
title: '准备兜底策略',
content: '如果模型等待过久或调用失败,会按保守规则继续识别,保证申请、报销和草稿流程还能推进。',
status: 'running'
}
}
]
}

View File

@@ -24,6 +24,14 @@ export function fetchStewardRuntimeDecision(payload, options = {}) {
})
}
export function executeStewardAction(payload, options = {}) {
return apiRequest('/steward/actions/execute', {
method: 'POST',
body: JSON.stringify(payload),
...options
})
}
export async function fetchStewardPlanStream(payload, handlers = {}, options = {}) {
const {
timeoutMs = 0,

View File

@@ -28,6 +28,13 @@ const AGENT_LABELS = {
expense: '报销助手'
}
const EXECUTABLE_STEWARD_ACTION_TYPES = new Set([
'save_application_draft',
'submit_application',
'create_reimbursement_draft',
'associate_attachments'
])
export function buildStewardPlanRequest({
rawText = '',
files = [],
@@ -102,10 +109,12 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
summary: String(item.summary || ''),
status: String(item.status || ''),
confidence: Number(item.confidence || 0),
requestedAction: String(item.requested_action || item.requestedAction || ''),
ontologyFields: item.ontology_fields || item.ontologyFields || {},
missingFields,
missingFieldItems: buildStewardFieldItems(missingFields, taskType),
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true
confirmationRequired: item.confirmation_required ?? item.confirmationRequired ?? true,
actionSteps: normalizeStewardActionSteps(item.action_steps || item.actionSteps)
}
})
: [],
@@ -137,6 +146,26 @@ export function normalizeStewardPlan(rawPlan = {}, options = {}) {
}
}
function normalizeStewardActionSteps(rawSteps = []) {
if (!Array.isArray(rawSteps)) {
return []
}
return rawSteps
.map((step) => ({
step_id: String(step?.step_id || step?.stepId || ''),
action_type: String(step?.action_type || step?.actionType || ''),
label: String(step?.label || ''),
target_task_id: String(step?.target_task_id || step?.targetTaskId || ''),
status: String(step?.status || 'planned'),
requires_confirmation: Boolean(step?.requires_confirmation ?? step?.requiresConfirmation),
depends_on: Array.isArray(step?.depends_on || step?.dependsOn)
? step.depends_on || step.dependsOn
: [],
payload: step?.payload && typeof step.payload === 'object' ? step.payload : {}
}))
.filter((step) => step.step_id && step.action_type)
}
export function buildStewardPlanMessageText(plan) {
const normalized = normalizeStewardPlan(plan)
if (isOffTopicPlan(normalized)) {
@@ -353,6 +382,7 @@ export function buildStewardSuggestedActions(plan) {
const targetSessionType = actionType === 'confirm_create_application'
? SESSION_TYPE_APPLICATION
: SESSION_TYPE_EXPENSE
const executableStep = resolveExecutableStewardActionStep(task)
return [
{
label: buildNextActionLabel(actionType, task),
@@ -372,6 +402,7 @@ export function buildStewardSuggestedActions(plan) {
steward_confirmation_id: String(action.confirmation_id || action.confirmationId || ''),
steward_plan_id: normalized.planId,
steward_next_task_id: task?.taskId || '',
...buildStewardExecuteActionPayload(executableStep, task),
steward_current_task: buildStewardTaskPayload(task),
steward_remaining_task_count: normalized.tasks.filter((item) => item.taskId !== task?.taskId).length,
steward_remaining_tasks: buildRemainingTaskPayload(normalized, task?.taskId)
@@ -380,6 +411,26 @@ export function buildStewardSuggestedActions(plan) {
]
}
function resolveExecutableStewardActionStep(task = null) {
const steps = Array.isArray(task?.actionSteps || task?.action_steps)
? task.actionSteps || task.action_steps
: []
return [...steps].reverse().find((step) => EXECUTABLE_STEWARD_ACTION_TYPES.has(String(step.action_type || step.actionType || ''))) || null
}
function buildStewardExecuteActionPayload(step, task) {
if (!step) {
return {}
}
return {
steward_execute_action: true,
steward_action_type: String(step.action_type || step.actionType || ''),
steward_action_step: step,
steward_action_requires_confirmation: Boolean(step.requires_confirmation ?? step.requiresConfirmation),
steward_action_task_id: task?.taskId || task?.task_id || ''
}
}
function normalizePendingFlowConfirmation(rawPlan = {}) {
const rawPending = rawPlan.pending_flow_confirmation || rawPlan.pendingFlowConfirmation || {}
const rawCandidates = Array.isArray(rawPlan.candidate_flows || rawPlan.candidateFlows)
@@ -779,7 +830,9 @@ function buildStewardTaskPayload(task) {
title: task.title || '',
summary: task.summary || '',
assigned_agent: task.assignedAgent || task.assigned_agent || '',
requested_action: task.requestedAction || task.requested_action || '',
ontology_fields: task.ontologyFields || task.ontology_fields || {},
missing_fields: task.missingFields || task.missing_fields || []
missing_fields: task.missingFields || task.missing_fields || [],
action_steps: task.actionSteps || task.action_steps || []
}
}

View File

@@ -51,6 +51,9 @@ import {
import {
shouldUseBudgetCompileReport
} from '../src/views/scripts/budgetAssistantReportModel.js'
import {
buildInlineApplicationPreview
} from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
import { resolveStewardTypewriterNextIndex } from '../src/views/scripts/stewardTypewriter.js'
import {
ASSISTANT_SCOPE_ACTION_SWITCH,
@@ -140,6 +143,14 @@ const flowScript = readFileSync(
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
'utf8'
)
const personalWorkbenchAiModeScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
)
const applicationPreviewFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
'utf8'
)
function createFlowHarness() {
return useTravelReimbursementFlow({
@@ -241,6 +252,32 @@ test('application intent uses local preview instead of immediate orchestrator ca
assert.match(buildLocalApplicationPreviewMessage(preview), /点击对应行即可直接编辑/)
})
test('AI workbench routes compact travel direct-submit planner into application preview auto submit', () => {
assert.match(personalWorkbenchAiModeScript, /buildRuleFallbackWorkbenchAiIntentPlan/)
assert.match(personalWorkbenchAiModeScript, /normalizeWorkbenchAiIntentPlan/)
assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan/)
assert.match(
personalWorkbenchAiModeScript,
/async function executeModelPlannedWorkbenchIntent\(cleanPrompt, entry = \{\}, files = \[\]\)/
)
assert.match(
personalWorkbenchAiModeScript,
/modelPlan = await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files\)/
)
assert.match(
personalWorkbenchAiModeScript,
/const rulePlan = buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/
)
assert.match(
personalWorkbenchAiModeScript,
/applicationFlow\.startAiApplicationPreview\([\s\S]*travelApplicationRequest\.expenseType[\s\S]*travelApplicationRequest\.expenseTypeLabel[\s\S]*travelApplicationRequest\.sourceText[\s\S]*ontologyFields:\s*travelApplicationRequest\.ontologyFields[\s\S]*autoSubmit:\s*travelApplicationRequest\.autoSubmit/
)
assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/)
assert.match(applicationPreviewFlowScript, /if \(options\.autoSubmit && normalizeApplicationPreview\(preview\)\.readyToSubmit\)/)
assert.match(applicationPreviewFlowScript, /confirmed:\s*true/)
assert.match(applicationPreviewFlowScript, /skipUserMessage:\s*true/)
})
test('unsupported business guidance opens in assistant conversation form', () => {
const conversation = buildUnsupportedBusinessScopeConversation('你好')
@@ -366,6 +403,20 @@ test('application preview renders ordered editable rows and submit text uses edi
assert.match(buildApplicationPreviewSubmitText(editedPreview), /系统预估费用1900元/)
})
test('application preview keeps compact direct-submit command out of business reason', () => {
const preview = buildInlineApplicationPreview(
'差旅费',
'去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
{ grade: 'P5' }
)
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '辅助国网仿生产服务器部署')
assert.equal(preview.fields.transportMode, '火车')
assert.equal(preview.readyToSubmit, false)
assert.deepEqual(preview.missingFields, ['出发时间', '天数'])
})
test('application estimate builds deterministic mock transport amount and total', () => {
const trainEstimate = buildMockApplicationTransportEstimate({ transportMode: '高铁', location: '上海' })
const datedTrainEstimate = buildMockApplicationTransportEstimate({

View File

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import { executeStewardAction } from '../src/services/steward.js'
async function testExecuteStewardActionUsesActionEndpoint() {
let capturedUrl = ''
let capturedOptions = null
global.fetch = async (url, options) => {
capturedUrl = String(url)
capturedOptions = options
return {
ok: true,
async json() {
return {
action_type: 'save_application_draft',
status: 'succeeded',
message: '申请草稿已保存。',
result_payload: {
draft_payload: {
claim_id: 'claim-action-draft',
claim_no: 'A12345678',
status: 'draft'
}
}
}
}
}
}
const payload = await executeStewardAction({
action_type: 'save_application_draft',
message: '保存草稿',
task: {
task_id: 'task-app-1',
task_type: 'expense_application'
}
})
assert.equal(capturedUrl, '/api/v1/steward/actions/execute')
assert.equal(capturedOptions.method, 'POST')
assert.equal(JSON.parse(capturedOptions.body).action_type, 'save_application_draft')
assert.equal(payload.status, 'succeeded')
}
async function run() {
await testExecuteStewardActionUsesActionEndpoint()
console.log('steward actions service tests passed')
}
run().catch((error) => {
console.error(error)
process.exit(1)
})

View File

@@ -71,3 +71,55 @@ test('steward plan summary guides bare reimbursement intent into scene selection
assert.match(action.description, /先进入报销助手选择具体费用类型/)
assert.equal(action.payload.carry_text, '我要报销')
})
test('steward suggested action carries server executable application action step', () => {
const plan = {
plan_id: 'plan-application-submit',
tasks: [
{
task_id: 'task-app-1',
task_type: 'expense_application',
title: '上海出差申请',
assigned_agent: 'application_assistant',
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:01', action_type: 'fill_application_fields', status: 'planned' },
{ step_id: 'task-app-1:02', action_type: 'build_application_preview', status: 'planned' },
{ step_id: 'task-app-1:03', action_type: 'validate_required_fields', status: 'planned' },
{ step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck', status: 'planned' },
{
step_id: 'task-app-1:05',
action_type: 'submit_application',
status: 'pending_confirmation',
requires_confirmation: true
}
]
}
],
confirmation_groups: [
{
confirmation_id: 'confirm-task-app-1',
action_type: 'confirm_create_application',
target_task_id: 'task-app-1'
}
],
next_action: 'confirm_task'
}
const [action] = buildStewardSuggestedActions(plan)
assert.equal(action.payload.steward_execute_action, true)
assert.equal(action.payload.steward_action_type, 'submit_application')
assert.equal(action.payload.steward_action_step.step_id, 'task-app-1:05')
assert.equal(action.payload.steward_action_requires_confirmation, true)
assert.equal(action.payload.steward_current_task.requested_action, 'submit')
assert.equal(action.payload.steward_current_task.action_steps.at(-1).action_type, 'submit_application')
})

View File

@@ -245,3 +245,147 @@ test('workbench standalone draft action asks before creating a new reimbursement
label: '独立新建报销单'
})
})
test('workbench steward executable submit action runs precheck before submit and writes result message', async () => {
const requests = []
const originalFetch = globalThis.fetch
globalThis.fetch = async (_url, options = {}) => {
const body = JSON.parse(String(options.body || '{}'))
requests.push(body)
if (body.action_type === 'run_duplicate_precheck') {
return {
ok: true,
async json() {
return {
action_type: 'run_duplicate_precheck',
status: 'succeeded',
message: '未发现重复或冲突申请,可以继续提交。',
result_payload: {
status: 'ok',
blocking: false
}
}
}
}
}
return {
ok: true,
async json() {
return {
action_type: 'submit_application',
status: 'succeeded',
message: '申请已提交审批。',
result_payload: {
draft_payload: {
claim_id: 'claim-app-1',
claim_no: 'A1BCDEF2'
}
}
}
}
}
}
try {
const messages = []
let messageSeq = 0
const createInlineMessage = (role, content, options = {}) => ({
id: options.id || `msg-${++messageSeq}`,
role,
content,
pending: Boolean(options.pending),
suggestedActions: Array.isArray(options.suggestedActions) ? options.suggestedActions : []
})
const replaceInlineMessage = (id, nextMessage) => {
const index = messages.findIndex((item) => item.id === id)
if (index >= 0) {
messages.splice(index, 1, nextMessage)
}
}
let persisted = false
const router = useWorkbenchAiActionRouter({
aiExpenseDraft: { value: null },
applicationFlow: {
isInlineSuggestedActionDisabled: () => false,
executeInlineApplicationPreviewAction: () => {}
},
assistantDraft: { value: '' },
attachmentFlow: {
confirmAiAttachmentAssociation: () => {}
},
conversationMessages: { value: messages },
createInlineMessage,
emit: () => {},
expenseFlow: {
linkAiExpenseApplication: () => {},
pushInlineExpenseSceneSelectionPrompt: () => {},
startAiApplicationPreviewFromAction: () => {},
startAiExpenseDraft: () => {}
},
focusAiModeInput: () => {},
hasInlineAttachmentOcrDetails: () => false,
persistCurrentConversation: () => {
persisted = true
},
replaceInlineMessage,
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23去上海出差交通火车直接提交',
scrollInlineConversationToBottom: () => {},
selectedFiles: { value: [] },
startInlineConversation: () => {},
toast: () => {},
toggleInlineAttachmentOcrDetails: () => {}
})
const sourceMessage = {
suggestedActionsLocked: false
}
await router.handleInlineSuggestedAction({
label: '确认提交申请',
action_type: 'switch_session',
payload: {
steward_execute_action: true,
steward_plan_id: 'plan-submit-1',
steward_action_type: 'submit_application',
steward_action_requires_confirmation: true,
steward_action_step: {
step_id: 'task-app-1:05',
action_type: 'submit_application',
requires_confirmation: true
},
steward_current_task: {
task_id: 'task-app-1',
task_type: 'expense_application',
assigned_agent: 'application_assistant',
title: '上海出差申请',
summary: '2026-02-20 至 2026-02-23 去上海出差,交通火车。',
requested_action: 'submit',
ontology_fields: {
expense_type: 'travel',
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: 'train'
},
missing_fields: [],
action_steps: [
{ step_id: 'task-app-1:04', action_type: 'run_duplicate_precheck' },
{ step_id: 'task-app-1:05', action_type: 'submit_application', requires_confirmation: true }
]
},
carry_text: '2026-02-20 至 2026-02-23去上海出差交通火车直接提交'
}
}, sourceMessage)
assert.equal(requests.length, 2)
assert.equal(requests[0].action_type, 'run_duplicate_precheck')
assert.equal(requests[1].action_type, 'submit_application')
assert.equal(requests[1].confirmed, true)
assert.equal(requests[1].context_json.precheck_result.status, 'ok')
assert.equal(sourceMessage.suggestedActionsLocked, true)
assert.equal(persisted, true)
assert.match(messages.at(-1).content, /申请已提交审批/)
assert.equal(messages.at(-1).suggestedActions[0].action_type, 'open_application_detail')
} finally {
globalThis.fetch = originalFetch
}
})

View File

@@ -0,0 +1,166 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { useWorkbenchAiApplicationPreviewFlow } from '../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js'
function createRef(value) {
return { value }
}
function createInlineMessage(role, content, options = {}) {
return {
id: options.id || `msg-${Math.random().toString(16).slice(2)}`,
role,
content,
text: content,
paragraphs: String(content || '').split(/\n+/).filter(Boolean),
pending: Boolean(options.pending),
...options
}
}
function buildApplicationPreviewFlowHarness(messages) {
const conversationMessages = createRef(messages)
const applicationSubmitConfirmOpen = createRef(false)
const applicationSubmitConfirmContext = createRef(null)
const persisted = createRef(0)
const flow = useWorkbenchAiApplicationPreviewFlow({
activateInlineConversation: () => {},
applicationPreviewEditor: createRef({}),
applicationSubmitConfirmContext,
applicationSubmitConfirmOpen,
assistantDraft: createRef(''),
cancelApplicationPreviewEditor: () => {},
clearAiModeFiles: () => {},
closeWorkbenchDatePicker: () => {},
commitApplicationPreviewEditor: async () => true,
conversationId: createRef('conversation-context-submit'),
conversationMessages,
conversationStarted: createRef(true),
createInlineMessage,
currentUser: createRef({ username: 'zhangsan@example.com', name: '张三' }),
handleApplicationPreviewEditorKeydown: () => {},
inlineConversationAutoScrollPinned: createRef(true),
isApplicationPreviewEditing: createRef(false),
openApplicationPreviewEditor: () => {},
persistCurrentConversation: () => { persisted.value += 1 },
pushInlineApplicationActionUserMessage: (text) => {
conversationMessages.value.push(createInlineMessage('user', text))
},
pushInlineUserMessage: (text) => {
conversationMessages.value.push(createInlineMessage('user', text))
},
refreshApplicationPreviewEstimate: async (preview) => preview,
removeWorkbenchDateTag: () => {},
replaceInlineMessage: (id, nextMessage) => {
const index = conversationMessages.value.findIndex((item) => item.id === id)
if (index >= 0) {
conversationMessages.value.splice(index, 1, nextMessage)
} else {
conversationMessages.value.push(nextMessage)
}
},
resolveApplicationPreviewEditorDateMax: () => '',
resolveApplicationPreviewEditorDateMin: () => '',
resolveApplicationPreviewEditorControl: () => null,
resolveApplicationPreviewEditorOptions: () => [],
resolveInlineThinkingEvents: (message) => message?.stewardPlan?.thinkingEvents || [],
resolveLatestInlineUserPrompt: () => '2026-02-20 至 2026-02-23去上海出差交通火车保存草稿',
scrollInlineConversationToBottom: () => {},
sending: createRef(false),
toast: () => {}
})
return {
applicationSubmitConfirmOpen,
conversationMessages,
flow,
persisted
}
}
test('workbench saved application draft can be submitted by contextual text without re-planning', async () => {
const originalFetch = globalThis.fetch
const requests = []
globalThis.fetch = async (url, options = {}) => {
const normalizedUrl = String(url)
if (normalizedUrl.includes('/reimbursements/claims')) {
return {
ok: true,
async json() {
return { items: [] }
}
}
}
if (normalizedUrl.includes('/reimbursements/application-preview-action')) {
const body = JSON.parse(String(options.body || '{}'))
requests.push({ url: normalizedUrl, body })
return {
ok: true,
async json() {
return {
status: 'succeeded',
result: {
draft_payload: {
claim_id: 'claim-saved-draft',
claim_no: 'A20260220',
status: 'submitted',
approval_stage: '直属领导审批'
}
}
}
}
}
}
throw new Error(`unexpected request: ${normalizedUrl}`)
}
try {
const previewMessage = createInlineMessage('assistant', '申请核对表', {
id: 'application-preview-1',
applicationPreview: {
readyToSubmit: true,
fields: {
applicationType: '差旅费用申请',
time: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
days: '4天',
transportMode: '火车',
amount: '1200元'
},
missingFields: [],
validationIssues: []
},
draftPayload: {
claim_id: 'claim-saved-draft',
claim_no: 'A20260220',
status: 'draft'
}
})
const harness = buildApplicationPreviewFlowHarness([
createInlineMessage('user', '2026-02-20 至 2026-02-23去上海出差交通火车保存草稿'),
previewMessage,
createInlineMessage('assistant', '### 申请草稿已保存', {
draftPayload: previewMessage.draftPayload
})
])
const handled = harness.flow.handleInlineApplicationPreviewTextAction(
'提交这个单据',
createRef(false)
)
assert.equal(handled, true)
await new Promise((resolve) => setTimeout(resolve, 0))
assert.equal(harness.applicationSubmitConfirmOpen.value, false)
assert.equal(requests.length, 1)
assert.equal(requests[0].body.context_json.application_edit_claim_id, 'claim-saved-draft')
assert.equal(requests[0].body.context_json.application_edit_mode, true)
assert.match(harness.conversationMessages.value.at(-1).content, /申请单据已生成/)
assert.ok(harness.persisted.value > 0)
} finally {
globalThis.fetch = originalFetch
}
})

View File

@@ -4,6 +4,7 @@ import test from 'node:test'
import {
isOrphanInlineApplicationPreviewMessage,
isReimbursementCreationIntent,
resolveInlineTravelApplicationRequest,
resolveInlineApplicationPreviewTextAction,
resolveLatestApplicationPreviewMessage,
resolveLatestOrphanApplicationPreviewMessage
@@ -28,9 +29,24 @@ test('workbench application gate resolves save and submit text actions consisten
assert.equal(resolveInlineApplicationPreviewTextAction(' 先保存 '), AI_APPLICATION_ACTION_SAVE_DRAFT)
assert.equal(resolveInlineApplicationPreviewTextAction('确认提交'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('直接提交'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('提交这个单据'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('提交这个申请单'), AI_APPLICATION_ACTION_SUBMIT)
assert.equal(resolveInlineApplicationPreviewTextAction('继续修改'), '')
})
test('workbench application gate detects compact travel application direct submit intent', () => {
const request = resolveInlineTravelApplicationRequest('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
assert.deepEqual(request, {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
autoSubmit: true
})
assert.equal(resolveInlineTravelApplicationRequest('去上海出差,辅助国网仿生产服务器部署,交通火车')?.autoSubmit, false)
assert.equal(resolveInlineTravelApplicationRequest('帮我查询上海差旅标准'), null)
})
test('workbench application gate resolves latest live or orphan preview message', () => {
const messages = [
{ id: 'user-1', role: 'user', content: '2月去上海出差' },

View File

@@ -0,0 +1,331 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import {
WORKBENCH_AI_INTENT_SOURCE_MODEL,
WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK,
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT,
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
WORKBENCH_AI_STEP_SUBMIT_APPLICATION,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
buildRuleFallbackWorkbenchAiIntentPlan,
normalizeWorkbenchAiIntentPlan,
resolveExecutableTravelApplicationPlan,
shouldRequestWorkbenchAiIntentPlan
} from '../src/composables/workbenchAiMode/workbenchAiIntentPlannerModel.js'
import { buildInlineApplicationPreview } from '../src/composables/workbenchAiMode/workbenchAiApplicationPreviewModel.js'
const personalWorkbenchAiModeScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/usePersonalWorkbenchAiMode.js', import.meta.url)),
'utf8'
)
const stewardFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiStewardFlow.js', import.meta.url)),
'utf8'
)
const applicationPreviewFlowScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/useWorkbenchAiApplicationPreviewFlow.js', import.meta.url)),
'utf8'
)
const planningThinkingModelScript = readFileSync(
fileURLToPath(new URL('../src/composables/workbenchAiMode/workbenchAiPlanningThinkingModel.js', import.meta.url)),
'utf8'
)
test('workbench AI intent planner normalizes model travel application submit plan into executable steps', () => {
const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'llm_function_call',
tasks: [{
task_type: 'expense_application',
assigned_agent: 'application_assistant',
requested_action: 'submit',
confidence: 0.91,
ontology_fields: {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: '火车'
}
}]
}, {
prompt: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交'
})
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_MODEL)
assert.equal(plan.intent, 'create_travel_application')
assert.equal(plan.requestedAction, 'submit')
assert.deepEqual(plan.steps, [
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
WORKBENCH_AI_STEP_SUBMIT_APPLICATION
])
assert.deepEqual(plan.ontologyFields, {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: '火车'
})
assert.deepEqual(plan.slots, {
timeRange: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transportMode: '火车'
})
})
test('workbench AI intent planner prefers server action steps when present', () => {
const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'llm_function_call',
tasks: [{
task_type: 'expense_application',
assigned_agent: 'application_assistant',
requested_action: 'submit',
confidence: 0.91,
ontology_fields: {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: '火车'
},
action_steps: [
{ action_type: 'fill_application_fields' },
{ action_type: 'build_application_preview' },
{ action_type: 'validate_required_fields' },
{ action_type: 'save_application_draft' }
]
}]
}, {
prompt: '2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车直接提交'
})
assert.deepEqual(plan.steps, [
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT
])
})
test('workbench AI intent planner falls back to rule plan for compact travel direct submit', () => {
const plan = buildRuleFallbackWorkbenchAiIntentPlan('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
assert.equal(plan.intent, 'create_travel_application')
assert.equal(plan.requestedAction, 'submit')
assert.deepEqual(plan.steps, [
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
WORKBENCH_AI_STEP_RUN_DUPLICATE_PRECHECK,
WORKBENCH_AI_STEP_SUBMIT_APPLICATION
])
})
test('workbench AI intent planner detects compact travel save-draft variant before rules are enough', () => {
const prompt = '2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。'
const plan = buildRuleFallbackWorkbenchAiIntentPlan(prompt)
assert.equal(shouldRequestWorkbenchAiIntentPlan(prompt), true)
assert.equal(shouldRequestWorkbenchAiIntentPlan('帮我查询上海差旅标准'), true)
assert.equal(shouldRequestWorkbenchAiIntentPlan('1'), false)
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
assert.equal(plan.intent, 'create_travel_application')
assert.equal(plan.requestedAction, 'save_draft')
assert.deepEqual(plan.steps, [
WORKBENCH_AI_STEP_BUILD_APPLICATION_PREVIEW,
WORKBENCH_AI_STEP_VALIDATE_REQUIRED_FIELDS,
WORKBENCH_AI_STEP_SAVE_APPLICATION_DRAFT
])
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: prompt,
ontologyFields: {},
autoSubmit: false,
autoSaveDraft: true
})
})
test('workbench AI intent planner turns model fields and action into executable application preview payload', () => {
const prompt = '2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。'
const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'llm_function_call',
tasks: [{
task_type: 'expense_application',
assigned_agent: 'application_assistant',
requested_action: 'save_draft',
confidence: 0.95,
ontology_fields: {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '国网仿生产服务器部署',
transport_mode: '火车'
},
missing_fields: []
}]
}, { prompt })
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: prompt,
ontologyFields: {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '国网仿生产服务器部署',
transport_mode: '火车'
},
autoSubmit: false,
autoSaveDraft: true
})
})
test('workbench AI intent planner turns single application candidate flow into executable preview payload', () => {
const prompt = '2026-02-20 至 2026-02-23去上海出差辅助国网仿生产服务器部署交通火车'
const plan = normalizeWorkbenchAiIntentPlan({
planning_source: 'rule_fallback',
plan_status: 'needs_flow_confirmation',
pending_flow_confirmation: {
status: 'pending',
candidate_flows: [{
flow_id: 'travel_application',
label: '先发起出差申请',
confidence: 0.86,
ontology_fields: {
expense_type: 'travel',
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: '火车'
},
missing_fields: []
}]
}
}, { prompt })
assert.equal(plan.source, WORKBENCH_AI_INTENT_SOURCE_RULE_FALLBACK)
assert.equal(plan.intent, 'create_travel_application')
assert.equal(plan.requestedAction, 'preview')
assert.deepEqual(resolveExecutableTravelApplicationPlan(plan), {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: prompt,
ontologyFields: {
expense_type: 'travel',
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '辅助国网仿生产服务器部署',
transport_mode: '火车'
},
autoSubmit: false,
autoSaveDraft: false
})
})
test('workbench AI application preview prefers model ontology fields over local text guesses', () => {
const preview = buildInlineApplicationPreview(
'差旅费',
'2026-02-20 至 2026-02-23上海出差国网仿生产服务器部署火车保存草稿。',
{ name: '李文静', grade: 'P5', location: '武汉' },
{
ontologyFields: {
time_range: '2026-02-20 至 2026-02-23',
location: '上海',
reason: '国网仿生产服务器部署',
transport_mode: '火车'
}
}
)
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.location, '上海')
assert.equal(preview.fields.reason, '国网仿生产服务器部署')
assert.equal(preview.fields.transportMode, '火车')
})
test('workbench AI intent planner rejects policy question and resolves executable application request', () => {
assert.equal(buildRuleFallbackWorkbenchAiIntentPlan('帮我查询上海差旅标准'), null)
const request = resolveExecutableTravelApplicationPlan(
buildRuleFallbackWorkbenchAiIntentPlan('去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交')
)
assert.deepEqual(request, {
expenseType: 'travel',
expenseTypeLabel: '差旅费',
sourceText: '去上海出差,辅助国网仿生产服务器部署,交通火车,直接提交',
ontologyFields: {},
autoSubmit: true,
autoSaveDraft: false
})
})
test('workbench AI mode asks steward model plan before fallback execution', () => {
assert.match(stewardFlowScript, /async function resolveInlineExecutionPlan\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
assert.match(stewardFlowScript, /fetchStewardPlan\(planRequest/)
assert.match(stewardFlowScript, /timeoutMs:\s*35000/)
assert.match(personalWorkbenchAiModeScript, /async function executeModelPlannedWorkbenchIntent\(cleanPrompt, entry = \{\}, files = \[\]\)/)
assert.match(personalWorkbenchAiModeScript, /await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{/)
assert.match(personalWorkbenchAiModeScript, /normalizeWorkbenchAiIntentPlan\(modelPlan,\s*\{\s*prompt:\s*cleanPrompt/)
assert.match(personalWorkbenchAiModeScript, /buildRuleFallbackWorkbenchAiIntentPlan\(cleanPrompt\)/)
assert.match(personalWorkbenchAiModeScript, /shouldRequestWorkbenchAiIntentPlan\(cleanPrompt\)/)
assert.match(personalWorkbenchAiModeScript, /resolveExecutableTravelApplicationPlan\(intentPlan\)/)
assert.doesNotMatch(personalWorkbenchAiModeScript, /fallbackIntentPlan/)
assert.match(personalWorkbenchAiModeScript, /autoSaveDraft:\s*travelApplicationRequest\.autoSaveDraft/)
assert.match(personalWorkbenchAiModeScript, /ontologyFields:\s*travelApplicationRequest\.ontologyFields/)
assert.match(applicationPreviewFlowScript, /options\.autoSaveDraft/)
assert.match(applicationPreviewFlowScript, /ontologyFields:\s*options\.ontologyFields/)
assert.match(applicationPreviewFlowScript, /executeInlineApplicationPreviewAction\(AI_APPLICATION_ACTION_SAVE_DRAFT/)
assert.match(applicationPreviewFlowScript, /buildInlineApplicationPreview\([\s\S]*ontologyFields:\s*options\.ontologyFields/)
assert.doesNotMatch(personalWorkbenchAiModeScript, /const travelApplicationRequest = resolveInlineTravelApplicationRequest\(cleanPrompt\)/)
})
test('workbench AI mode shows a visible planning response before waiting for steward model plan', () => {
assert.match(personalWorkbenchAiModeScript, /function startModelPlanningConversation\(cleanPrompt, entry = \{\}\)/)
assert.match(personalWorkbenchAiModeScript, /conversationMessages\.value\.push\(createInlineMessage\('user', cleanPrompt\)\)/)
assert.match(personalWorkbenchAiModeScript, /正在识别意图,准备拆解申请、报销和附件任务/)
assert.match(
personalWorkbenchAiModeScript,
/const plannerPendingMessage = startModelPlanningConversation\(cleanPrompt, entry\)[\s\S]*await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{/
)
assert.match(
personalWorkbenchAiModeScript,
/pendingMessageId:\s*plannerPendingMessage\?\.id/
)
assert.match(applicationPreviewFlowScript, /options\.pendingMessageId/)
})
test('workbench AI mode streams planning thinking into the pending message', () => {
assert.match(planningThinkingModelScript, /buildModelPlanningProgressSchedule/)
assert.match(planningThinkingModelScript, /判断办理意图/)
assert.match(planningThinkingModelScript, /抽取关键信息/)
assert.match(planningThinkingModelScript, /规划执行步骤/)
assert.match(planningThinkingModelScript, /准备兜底策略/)
assert.match(personalWorkbenchAiModeScript, /function startModelPlanningProgressUpdates\(messageId\)/)
assert.match(personalWorkbenchAiModeScript, /globalThis\.setTimeout\(\(\) => \{\s*updateModelPlanningThinkingEvent\(messageId, event\)/)
assert.match(personalWorkbenchAiModeScript, /const stopPlanningProgressUpdates = startModelPlanningProgressUpdates\(plannerPendingMessage\.id\)/)
assert.match(personalWorkbenchAiModeScript, /stopPlanningProgressUpdates\(\)/)
assert.match(
personalWorkbenchAiModeScript,
/await stewardFlow\.resolveInlineExecutionPlan\(cleanPrompt, entry, files,\s*\{\s*pendingMessageId:\s*plannerPendingMessage\.id\s*\}\)/
)
assert.match(stewardFlowScript, /async function resolveInlineExecutionPlan\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
assert.match(stewardFlowScript, /fetchInlineStewardPlan\(planningMessageId, planRequest,\s*\{[\s\S]*includeAnswerDelta:\s*false/)
assert.match(applicationPreviewFlowScript, /mergeWorkbenchAiThinkingEvents\(previousThinkingEvents,\s*\[/)
})
test('workbench AI mode reuses planning pending message for regular steward replies', () => {
assert.match(
personalWorkbenchAiModeScript,
/stewardFlow\.requestInlineAssistantReply\(cleanPrompt, entry, files,\s*\{\s*pendingMessageId:\s*plannerPendingMessage\.id\s*\}\)/
)
assert.doesNotMatch(
personalWorkbenchAiModeScript,
/replaceInlineMessage\(plannerPendingMessage\.id,\s*createInlineMessage\('assistant', '已完成意图识别,继续为您整理回复。'/
)
assert.match(stewardFlowScript, /async function requestInlineAssistantReply\(prompt, entry = \{\}, files = \[\], options = \{\}\)/)
assert.match(stewardFlowScript, /const reusablePendingMessageId = String\(options\.pendingMessageId \|\| ''\)\.trim\(\)/)
assert.match(stewardFlowScript, /reusablePendingMessageId \? replaceInlineMessage\(reusablePendingMessageId, pendingMessage\) : conversationMessages\.value\.push\(pendingMessage\)/)
})