Forráskód Böngészése

Add sanitized SmartBizTrip student edition

Eileen 1 hete
commit
802bf05bad
100 módosított fájl, 11435 hozzáadás és 0 törlés
  1. 15 0
      .dockerignore
  2. 26 0
      .env.example
  3. 5 0
      .env.mysql.example
  4. 48 0
      .gitignore
  5. 1 0
      .python-version
  6. 119 0
      README.md
  7. 265 0
      a2a_server/ticket_server.py
  8. 412 0
      a2a_server/trip_server.py
  9. 366 0
      a2a_server/weather_server.py
  10. 1 0
      agent_learn/A2A_base/__init__.py
  11. 1 0
      agent_learn/A2A_base/a2a_case/__init__.py
  12. 61 0
      agent_learn/A2A_base/a2a_case/main.py
  13. 153 0
      agent_learn/A2A_base/a2a_case/multi_intents.py
  14. 23 0
      agent_learn/A2A_base/a2a_case/router_A2Aagent_Server.py
  15. 46 0
      agent_learn/A2A_base/a2a_case/ticket_agent.py
  16. 52 0
      agent_learn/A2A_base/a2a_case/weather_agent.py
  17. 1 0
      agent_learn/A2A_base/a2a_mcp_collaboration/__init__.py
  18. 71 0
      agent_learn/A2A_base/a2a_mcp_collaboration/a2a_weather_agent.py
  19. 16 0
      agent_learn/A2A_base/a2a_mcp_collaboration/main_client.py
  20. 17 0
      agent_learn/A2A_base/a2a_mcp_collaboration/mcp_weather_tool_agent.py
  21. 1 0
      agent_learn/A2A_base/a2a_serial/__init__.py
  22. 71 0
      agent_learn/A2A_base/a2a_serial/main_orchestrator.py
  23. 46 0
      agent_learn/A2A_base/a2a_serial/ticket_agent.py
  24. 52 0
      agent_learn/A2A_base/a2a_serial/weather_agent.py
  25. 12 0
      agent_learn/A2A_base/concepts/01_agent_skill.py
  26. 21 0
      agent_learn/A2A_base/concepts/02_agent_card.py
  27. 13 0
      agent_learn/A2A_base/concepts/03_task.py
  28. 7 0
      agent_learn/A2A_base/concepts/04_task_state.py
  29. 15 0
      agent_learn/A2A_base/concepts/05_task_status.py
  30. 26 0
      agent_learn/A2A_base/concepts/06_a2a_server.py
  31. 46 0
      agent_learn/A2A_base/concepts/07_artifacts_server.py
  32. 14 0
      agent_learn/A2A_base/concepts/08_artifacts_client.py
  33. 10 0
      agent_learn/A2A_base/concepts/09_agent_network.py
  34. 26 0
      agent_learn/A2A_base/concepts/10_agent_router.py
  35. 1 0
      agent_learn/A2A_base/concepts/__init__.py
  36. 1 0
      agent_learn/__init__.py
  37. 69 0
      agent_learn/agent_types/C01_ToolUsePattern.py
  38. 106 0
      agent_learn/agent_types/C02_ReActPattern.py
  39. 71 0
      agent_learn/agent_types/C03_ReflectionPattern.py
  40. 129 0
      agent_learn/agent_types/C04_PlanningPattern.py
  41. 140 0
      agent_learn/agent_types/C05_MultiAgent.py
  42. 1 0
      agent_learn/agent_types/__init__.py
  43. 13 0
      agent_learn/config.py
  44. 1 0
      agent_learn/mcp_base/__init__.py
  45. 1 0
      agent_learn/mcp_base/python_a2a/__init__.py
  46. 82 0
      agent_learn/mcp_base/python_a2a/client_agent.py
  47. 38 0
      agent_learn/mcp_base/python_a2a/client_raw.py
  48. 66 0
      agent_learn/mcp_base/python_a2a/server_a2a.py
  49. 1 0
      agent_learn/mcp_base/sse/__init__.py
  50. 69 0
      agent_learn/mcp_base/sse/client_agent.py
  51. 34 0
      agent_learn/mcp_base/sse/client_raw.py
  52. 53 0
      agent_learn/mcp_base/sse/server_sse.py
  53. 1 0
      agent_learn/mcp_base/stdio/__init__.py
  54. 79 0
      agent_learn/mcp_base/stdio/client_agent.py
  55. 42 0
      agent_learn/mcp_base/stdio/client_raw.py
  56. 39 0
      agent_learn/mcp_base/stdio/server_stdio.py
  57. 1 0
      agent_learn/mcp_base/streamable/__init__.py
  58. 84 0
      agent_learn/mcp_base/streamable/client_agent.py
  59. 63 0
      agent_learn/mcp_base/streamable/client_raw.py
  60. 53 0
      agent_learn/mcp_base/streamable/server_streamable.py
  61. 142 0
      api_server.py
  62. 1275 0
      chat_service.py
  63. 42 0
      compose.yaml
  64. 169 0
      config.py
  65. 63 0
      create_logger.py
  66. 5 0
      docker/milvus/embedEtcd.yaml
  67. 353 0
      main_prompts.py
  68. 341 0
      mcp_server/mcp_ticket_server.py
  69. 374 0
      mcp_server/mcp_trip_server.py
  70. 350 0
      mcp_server/mcp_weather_server.py
  71. 430 0
      memory.py
  72. 35 0
      pyproject.toml
  73. 34 0
      requirements.txt
  74. 162 0
      sql/create_all_tables.sql
  75. 438 0
      sql/init_meeting_package_rag.py
  76. 120 0
      sql/insert_may01_data.sql
  77. 519 0
      static/index.html
  78. 57 0
      test.py
  79. 0 0
      tests/__init__.py
  80. 31 0
      tests/run_offline.py
  81. 52 0
      tests/test_chat_reply.py
  82. 37 0
      tests/test_city_weather_date.py
  83. 289 0
      tests/test_mcp_servers.py
  84. 189 0
      tests/test_review_fixes.py
  85. 111 0
      tests/test_student_release.py
  86. 15 0
      tests/test_ticket_server.py
  87. 15 0
      tests/test_trip_server.py
  88. 15 0
      tests/test_weather_server.py
  89. 51 0
      tests/test_weather_tool_parameters.py
  90. 47 0
      utils/format.py
  91. 469 0
      utils/spider_weather.py
  92. 1483 0
      uv.lock
  93. 30 0
      学生版检查说明.txt
  94. 66 0
      课件示例/项目实现/02-02.py
  95. 22 0
      课件示例/项目实现/02-17.py
  96. 17 0
      课件示例/项目实现/02-20.py
  97. 19 0
      课件示例/项目实现/02-30.py
  98. 183 0
      课件示例/项目实现/02-31.py
  99. 46 0
      课件示例/项目实现/03-02.py
  100. 42 0
      课件示例/项目实现/03-03.py

+ 15 - 0
.dockerignore

@@ -0,0 +1,15 @@
+.env
+.env.*
+!.env.example
+!.env.mysql.example
+.venv/
+venv/
+__pycache__/
+*.py[cod]
+logs/
+*.log
+*.zip
+.git/
+.DS_Store
+.idea/
+.vscode/

+ 26 - 0
.env.example

@@ -0,0 +1,26 @@
+# 首次配置时,将本文件复制为 .env,再填写自己的密钥和数据库密码。
+# 本文件可提交到 Git;填写真实凭据后的 .env 不应提交。
+
+# DeepSeek 大模型:模型名称可按账号可用模型调整,以下与项目默认值一致。
+DEEPSEEK_BASE_URL=https://api.deepseek.com
+DEEPSEEK_API_KEY=your_deepseek_api_key
+DEEPSEEK_MODEL=deepseek-v4-flash
+
+# MySQL:连接本机 3306 端口;账号、密码和库名应与实际数据库一致。
+# 使用项目 Docker MySQL 时,应与 .env.mysql 中对应配置保持一致。
+MYSQL_HOST=localhost
+MYSQL_USER=smart_yoyage
+MYSQL_PASSWORD=your_mysql_password
+MYSQL_DATABASE=biztrip_rag
+
+# 阿里云 DashScope:会务套餐向量检索需要配置。
+DASHSCOPE_API_KEY=your_dashscope_api_key
+
+# 天气 API:填写本人账号的密钥和 API Host。
+QWEATHER_API_KEY=your_qweather_api_key
+QWEATHER_API_HOST=your_qweather_host
+
+# Milvus 地址在 config.py 中配置。
+
+# api 使用天气接口;database 使用 SQL 样例,课堂演示可选后者。
+WEATHER_SOURCE=api

+ 5 - 0
.env.mysql.example

@@ -0,0 +1,5 @@
+# 复制为 .env.mysql 后填写个人密码;MYSQL_PASSWORD 与 .env 保持一致。
+MYSQL_ROOT_PASSWORD=your_mysql_root_password
+MYSQL_DATABASE=biztrip_rag
+MYSQL_USER=smart_yoyage
+MYSQL_PASSWORD=your_mysql_password

+ 48 - 0
.gitignore

@@ -0,0 +1,48 @@
+# 本地环境与密钥(保留可提交的示例配置)
+.env
+.env.*
+!.env.example
+!.env.mysql.example
+.venv/
+venv/
+env/
+
+# Python 缓存、构建与测试产物
+__pycache__/
+*.py[cod]
+.pytest_cache/
+.mypy_cache/
+.ruff_cache/
+.coverage
+.coverage.*
+htmlcov/
+coverage.xml
+build/
+dist/
+*.egg-info/
+
+# 运行日志与临时文件
+logs/
+*.log
+*.log.*
+*.tmp
+*.swp
+*.swo
+*~
+
+# 操作系统与编辑器的本机配置
+.DS_Store
+._*
+Thumbs.db
+Desktop.ini
+.idea/
+.vscode/
+
+# 本地课件、设计资料、备份及生成结果
+*源码备份*.zip
+/docs/
+/output/
+/A2A无路径小脚本说明.md
+/课件脚本提取核对清单.md
+/agent_learn/extraction_manifest.json
+/agent_learn/verification/

+ 1 - 0
.python-version

@@ -0,0 +1 @@
+3.12.11

+ 119 - 0
README.md

@@ -0,0 +1,119 @@
+# SmartBizTrip 企业差旅智能体
+
+本项目用于学习意图识别、任务规划、A2A 子助手协作、MCP 工具调用和对话记忆。预订功能为模拟演示,不会实际下单或出票。
+
+## 1. 准备 Python 环境
+
+安装 uv 后,在项目根目录(能看到本文件和 `pyproject.toml`)打开终端:
+
+```sh
+uv sync --locked
+uv run python --version
+```
+
+看到 Python 3.12.x,并且依赖安装无报错后继续。保留 `pyproject.toml`、`uv.lock` 和 `.python-version`;不需要复制他人的 `.venv`。
+
+## 2. 填写自己的配置
+
+macOS / Linux:
+
+```sh
+cp .env.example .env
+cp .env.mysql.example .env.mysql
+```
+
+Windows PowerShell:
+
+```powershell
+Copy-Item .env.example .env
+Copy-Item .env.mysql.example .env.mysql
+```
+
+编辑这两个文件,把 `your_...` 替换成自己的配置:
+
+- `DEEPSEEK_API_KEY`:聊天模型密钥;`DEEPSEEK_MODEL` 填自己的账号可用的模型名。
+- `MYSQL_PASSWORD`:两个文件中保持一致;数据库账号、库名也应一致。
+- `QWEATHER_API_KEY`、`QWEATHER_API_HOST`:使用天气 API 时填写。
+- `DASHSCOPE_API_KEY`:使用会务套餐向量检索时填写,不能用聊天模型密钥代替。
+- `WEATHER_SOURCE=database`:使用 SQL 中的天气样例;`api` 使用天气接口。
+
+示例配置不含真实密钥。填写后的 `.env`、`.env.mysql` 只保留在自己的电脑,不提交或发送给别人。
+
+## 3. 先运行离线测试
+
+```sh
+uv run python -B -m tests.run_offline
+```
+
+看到 `OK` 后继续。此命令使用模拟对象并阻止网络连接,不消耗真实模型额度。需要数据库或 A2A 服务的集成测试会显示 `skipped`,这不代表它们已通过联网验证。
+
+## 4. 启动数据服务
+
+使用本项目的 Docker 配置时,先打开 Docker,再运行:
+
+```sh
+docker compose up -d mysql milvus
+docker compose ps
+```
+
+看到所需服务为 `healthy` 后继续。此 Compose 只启动 MySQL 和 Milvus,不启动 Python 服务。
+
+SQL 样例主要是 **2026-05-01,北京到成都、成都当地资源**。首次使用空数据卷会执行建表和样例导入;已有数据卷不会因为重启自动重新导入。`sql/create_all_tables.sql` 含删库重建语句,只用于准备教学样例库,不要对已有重要数据执行。
+
+仅学习会务套餐检索时,填写向量模型配置并启动 Milvus 后,再手动运行:
+
+```sh
+uv run python sql/init_meeting_package_rag.py
+```
+
+看到插入成功和样例检索结果后再查询会务套餐。此脚本会删除并重建同名集合,不是增量导入。
+
+## 5. 分别启动 Python 服务
+
+每条命令使用一个终端,均在项目根目录执行。目录名称可以修改,不必叫 `SmartBizTrip`。
+
+先启动 MCP 工具服务,看到各自监听端口后继续:
+
+```sh
+uv run python -m mcp_server.mcp_ticket_server
+uv run python -m mcp_server.mcp_weather_server
+uv run python -m mcp_server.mcp_trip_server
+```
+
+再启动 A2A 子助手:
+
+```sh
+uv run python -m a2a_server.ticket_server
+uv run python -m a2a_server.weather_server
+uv run python -m a2a_server.trip_server
+```
+
+最后启动网页 API:
+
+```sh
+uv run api_server.py
+```
+
+看到 API 监听 `127.0.0.1:8080` 后,浏览器打开 `http://127.0.0.1:8080`。
+
+| 服务 | 端口 |
+| --- | --- |
+| 网页 API | 8080 |
+| 天气 / 票务 / 差旅 A2A | 5005 / 5006 / 5007 |
+| 票务 / 天气 / 差旅 MCP | 8001 / 8002 / 8003 |
+| MySQL / Milvus | 3306 / 19530 |
+
+导入样例数据后,可询问:“查询 2026 年 5 月 1 日北京到成都的二等座高铁票”。若使用天气数据库模式,可询问同一天的成都天气。其他日期不保证有样例数据。
+
+## 6. 阅读代码
+
+从 `api_server.py` 的启动与路由开始,再看 `ChatService.chat_stream()`,然后追踪票务助手和 MCP 的 `query_train`。
+
+```text
+网页 → API → ChatService → A2A 子助手 → MCP 工具 → 数据源
+网页 ← SSE ← 主助手汇总 ← 子助手回复 ← 工具结果 ← 数据源
+```
+
+`agent_learn/` 是独立概念示例;`课件示例/项目实现/` 中的编号文件是随课程逐步组合的片段,部分依赖前面章节的导入、类或变量,不应逐个当完整程序运行。完整项目以根目录及 `a2a_server/`、`mcp_server/` 为准;课件中的 Docker 全服务部署片段需要按章节整合,根目录 Compose 仅负责数据服务。
+
+若要运行需要真实服务的测试,先准备对应服务和自己的配置,再设置 `RUN_ONLINE_TESTS=1`。这些测试可能调用模型;平时练习使用离线测试命令即可。

+ 265 - 0
a2a_server/ticket_server.py

@@ -0,0 +1,265 @@
+# 【启动 S3】本文件是独立的 A2A 子助手进程:监听 5006,调用 MCP 工具服务 8001。
+# 启动时先执行模块顶层配置、模型对象和代理卡片定义,再进入 __main__ 创建服务器。
+# 类/函数定义本身不会执行查询;run_server 开始监听后,有 Task 到达才进入 handle_task。
+# 主流程 7:接收任务 → 加载工具 → 模型选择工具和参数 → 执行器调用工具(主流程 8)。
+# 主流程 9:工具结果交回模型生成回复 → 封装 Task 状态及 artifacts → 返回主助手。
+
+"""
+需求:实现基于A2A的统一票务服务器,处理用户的高铁票、航班和会展门票查询及预订请求
+
+架构说明:
+    本服务器是 SmartBizTrip 系统中的另一个子代理(Sub-Agent),负责处理所有票务相关任务。
+    它运行在独立的进程中(localhost:5006),通过 A2A(Agent2Agent)协议与主助手通信。
+
+    与 weather_server 一样,本模块使用参数化工具调用:
+    - 模型选择工具并提取参数,MCP 服务内部负责 SQL 查询
+    - ticket_server 使用 LangChain Agent + MCP Tools 模式(工具调用模式)
+      LLM 直接决定调用哪个 MCP 工具(高铁票查询/航班查询/会展活动查询/预定),
+      然后由 LangChain 的 AgentExecutor 自动完成工具调用和结果处理
+
+    工作流程:
+    1. 主助手通过 A2A 协议向本服务器发送任务(Task)
+    2. 本服务器收到任务后,提取用户的自然语言查询
+    3. 使用 LangChain Agent(基于工具调用的 Agent)处理查询:
+       a. LLM 分析用户输入,决定调用哪个 MCP 工具
+       b. LangChain 自动调用 MCP Server 的工具(端口 8001)
+       c. 工具返回结果后,LLM 将结果格式化为友好的中文回复
+    4. 将结果返回给主助手
+
+    涉及的关键技术:
+    - LangChain Agent: 一种让 LLM 自主选择和使用工具的框架
+    - Tool Calling Agent: 一种 Agent 类型,LLM 以结构化格式调用工具
+    - MCP Tools: MCP Server 提供的工具,本系统中包括高铁票查询、航班查询等
+    - AgentExecutor: LangChain 的执行器,负责运行 Agent 循环(思考→调用工具→处理结果→继续)
+
+    MCP Server(端口 8001)提供的工具包括:
+    - 高铁票查询工具
+    - 航班查询工具
+    - 会展门票查询工具
+    - 高铁票预定工具
+    - 航班预定工具
+    - 会展门票预定工具
+"""
+
+# 单次票务任务示例:主助手发送“北京到上海高铁”
+# → handle_task → to_langchain_tool → StructuredTool → AgentExecutor.invoke
+# → 模型选择 query_train 及城市/日期/座席参数 → MCP :8001 → MySQL
+# → 工具返回 JSON → 模型整理文字 → task.artifacts → 主助手。
+# 查询和预订工具按需要选择,不会把注册的六个工具依次全部执行。
+
+# ==================== 导入依赖 ====================
+import asyncio  # 异步IO库
+
+# from mcp import ClientSession  # MCP 客户端会话
+# from mcp.client.streamable_http import streamablehttp_client  # MCP HTTP 流式客户端
+
+from python_a2a import MCPClient, MCPTools
+from python_a2a import A2AServer, run_server, AgentCard, AgentSkill, TaskStatus, TaskState
+from python_a2a import to_langchain_tool
+# A2AServer: A2A 服务器基类
+# run_server: 启动服务器的函数
+# AgentCard: 代理卡片
+# AgentSkill: 代理技能
+# TaskStatus: 任务状态对象
+# TaskState: 任务状态枚举(COMPLETED / FAILED / INPUT_REQUIRED)
+
+from langchain_openai import ChatOpenAI  # LangChain 的大模型接口
+from langchain_core.tools import StructuredTool
+from langchain_core.prompts import ChatPromptTemplate  # 提示模板
+# from langchain_mcp_adapters.tools import load_mcp_tools  # 从 MCP 会话加载工具
+from langchain.agents import create_tool_calling_agent, AgentExecutor
+# create_tool_calling_agent: 创建基于工具调用的 Agent
+# AgentExecutor: Agent 执行器,负责运行 Agent 循环
+
+from datetime import datetime  # 时间处理
+import pytz  # 时区库
+
+from config import Config  # 项目配置
+from create_logger import logger  # 日志模块
+
+# 1. 初始化大模型
+
+conf = Config()
+
+# 初始化LLM
+# 【启动 S3.1】创建模型客户端;模型推理在后面的 invoke/ainvoke 才发生。
+llm = ChatOpenAI(
+    model=conf.model_name,
+    base_url=conf.base_url,
+    api_key=conf.api_key,
+    temperature=conf.temperature
+)
+# 2. 定义AgentCard,6个skill
+
+# ==================== Agent Card(代理卡片) ====================
+# 描述票务代理的能力、技能等信息
+# 卡片用于说明和展示能力;当前主助手通过 Config.intent 的映射选择代理。
+# TODO 这里的6个skill作用:是给主路由在选择子Agent的时候作为参考使用的,和MCP层的工具没有对应的关系。可以将6个skill合成2个,
+# 1. 查询票务(高铁票、航班、会展活动) 2. 预定票(高铁票、航班、会展活动)
+# 【启动 S3.2】定义对外说明。skills 是能力介绍,实际可执行工具来自 MCP。
+# 主助手当前按 Config.intent 路由;卡片声明的能力也不等于此方法已实现逐片输出。
+# 卡片字段说明:name 是代理标识;url 是访问地址;description/skills 向调用方介绍能力。
+# 这里描述技能不会自动生成对应 Python 函数,实际工具仍由 MCP 服务注册和提供。
+# 主助手 Config.intent 中的名称、AgentNetwork 的注册名称和这里的 name 要相互对应。
+agent_card = AgentCard(
+    name="TicketAssistant",  # 代理名称
+    description="基于 LangChain 提供票务查询和预订服务的统一助手",  # 代理描述
+    url="http://localhost:5006",  # 代理的访问地址
+    version="2.0.0",  # 版本号
+    capabilities={"streaming": True, "memory": True},  # 支持的能力
+    skills=[
+        # 技能1:高铁票查询
+        AgentSkill(
+            name="query train tickets",
+            description="查询高铁票,支持指定出发城市、到达城市、日期和座位类型",
+            examples=["高铁票 北京 上海 2025-07-31", "北京到广州的高铁 明天 二等座"]
+        ),
+        # 技能2:航班查询
+        AgentSkill(
+            name="query flight tickets",
+            description="查询航班,支持指定出发城市、到达城市、日期和舱位类型",
+            examples=["航班 北京 上海 2025-07-31", "北京到深圳的航班 后天 经济舱"]
+        ),
+        # 技能3:会展门票查询
+        AgentSkill(
+            name="query event tickets",
+            description="查询会展门票,支持指定城市、活动名称、日期和票档类型",
+            examples=["会展活动 成都企业数字化转型峰会 2026-05-01", "上海大型展会参展门票 2026-06-10"]
+        ),
+        # 技能4:高铁票预定
+        AgentSkill(
+            name="order train tickets",
+            description="根据车次、座位类型和数量预定高铁票",
+            examples=["预定G1次列车 2025-11-15 北京到上海 二等座 1张"]
+        ),
+        # 技能5:航班预定
+        AgentSkill(
+            name="order flight tickets",
+            description="根据航班号、舱位类型和数量预定航班",
+            examples=["预定MU5101 2025-12-11 上海到北京 公务舱 2张"]
+        ),
+        # 技能6:会展门票预定
+        AgentSkill(
+            name="order event tickets",
+            description="根据活动名称、场地、日期、票档和数量预定会展门票",
+            examples=["预定成都企业数字化转型峰会 2026-05-01 成都 VIP商务席 2张"]
+        )
+    ]
+)
+
+MCP_URL = 'http://127.0.0.1:8001'
+
+
+# 3. 定义类,继承A2AServer
+class TicketServer(A2AServer):
+    # 4. 实现初始化
+    def __init__(self):
+        # 【启动 S3.3】将卡片交给 A2AServer;父类提供通信框架,本类负责实现 handle_task。
+        # 输入:模块加载时创建的 agent_card。super() 将当前实例交给父类初始化。
+        # 本类继承了 A2A 通信框架,只覆盖业务回调 handle_task;此时还没有用户查询需要执行。
+        super().__init__(agent_card=agent_card)
+
+    # 5. 实现handle_task
+    # 接收用户的请求,使用langchain-agent自动去调用对应的工具集
+    def handle_task(self, task):
+        # 5.1 从task里面获取message
+        # 【主流程 7.1】A2A 框架收到任务后调用此方法;先从 task.message 取输入。
+        # 输入 task 来自主助手的 A2A 请求;它包含任务编号、message,处理后还会带上 status/artifacts。
+        # 本方法不是被 ChatService 直接当本地 Python 方法调用,而是由此进程的 A2A 框架接收请求后触发。
+        # 输出仍是 task 对象,只是在原对象上填写了结果状态和产物。
+        # (task.message or {}) 在消息为空时退回空字典,再用 get 避免直接按键访问报 KeyError。
+        # 与天气、差旅助手一致,取出 content 中的文本交给模型。
+        content = (task.message or {}).get("content", {})
+        query = content.get("text", "") if isinstance(content, dict) else ""
+        # 5.2 获取MCP Server所有的工具集(tools) 
+        # 【主流程 7.2】先请求 MCP 工具定义,取得工具名、说明、参数结构和调用包装器。
+        # 加载定义并不等于已查票;只有执行器调用选中的工具时才执行查询。
+        tools = to_langchain_tool(MCP_URL)  ## 向票务工具服务获取可用工具,把它们包装成LangChain执行器可以使用的工具对象
+        # python-a2a 0.5.4 返回单参数 Tool;保留 MCP JSON Schema,以支持查票的多个命名参数。
+        # 【主流程 7.3】保留多个具名参数的结构,例如 departure_city、arrival_city、date。
+        # func 仍是原 MCP 调用函数,StructuredTool 负责让参数结构与执行器匹配。
+        tools = [StructuredTool(
+            # 工具对象的五个字段各有用途:name 用于匹配模型选中的工具,description 帮助模型理解用途;
+            # func 保存真正发起 MCP 调用的函数;args_schema 规定参数名/类型;metadata 保留原工具信息。
+            # 列表里的每一项转换成独立 StructuredTool,模型随后可在这些工具中选择。
+            name=tool.name,
+            description=tool.description,
+            func=tool.func,
+            args_schema=tool.metadata['parameters'],
+            metadata=tool.metadata,
+        ) for tool in tools]
+        # 5.3 构建提示词模板(prompt)
+        # 提示模板分三部分:system 给规则和当前日期,human 放本次输入,placeholder 放执行过程消息。
+        # {input}/{current_date} 由 invoke/ainvoke 的参数提供;{agent_scratchpad} 由 AgentExecutor 随工具调用更新。
+        # 占位符不是立刻执行的代码,只是在实际请求模型前被替换成消息内容。
+        prompt = ChatPromptTemplate.from_messages([
+            ("system",
+             "你是一个票务预定助手,能够调用工具来完成高铁票、航班或会展票的预定。你需要仔细分析工具需要的参数,然后从用户提供的信息中提取信息。如果用户提供的信息不足以提取到调用工具所有必要参数,则向用户追问,以获取该信息。不能自己编撰参数。预订工具仅用于模拟演示,回复必须明确说明模拟、未实际下单,不得声称已出票或生成保单。当前日期{current_date}"),
+            ("human", "{input}"),
+            ("placeholder", "{agent_scratchpad}"),
+        ])
+        # 5.4 构建Agent(tools + prompt + llm)
+        # 【主流程 7.4】组装模型、工具和提示词;agent_scratchpad 保存工具调用/返回消息。
+        # Agent 决定下一步,AgentExecutor 驱动循环并实际调用工具。
+        agent = create_tool_calling_agent(llm, tools, prompt) ## agent 组织提示词和模型调用,决定下一步动作
+        # agent 负责根据输入和已有工具结果产生下一步动作;executor 负责运行这个过程。
+        # 若模型请求工具,executor 按工具名找到对象并调用 func,再把返回值交回模型;直到得到最终回答。
+        # verbose=True 打印执行过程,便于观察调用轨迹,不代表业务结果会自动保存到记忆或数据库。
+        agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)  ## agent_executor负责跑执行过程,把模型选择的工具真正调用起来,再把结果交给模型
+        # 5.5 执行请求Agent的invoke  这里,真正开始处理请求
+        try:
+            # 【主流程 7.5 → 8】从这里开始运行工具调用循环;必要时可调用多次工具。
+            result = agent_executor.invoke({'input': query,
+                                            'current_date':datetime.strftime(datetime.now(), '%Y-%m-%d')
+                                            })
+            # invoke 返回字典,output 键里才是 Agent 最终回复文本;它不是原始 SQL 行列表。
+            # 若缺少信息,模型可能直接追问并结束,不会保证每次任务都调用一个工具。
+            output = result['output']
+            # 5.6 根据返回结果封装Task.Status和artifacts
+            # ① 缺少参数
+            # 【主流程 9.1】当前用回复中的关键词区分追问,否则标为 COMPLETED。
+            # 这是文字判断,不是业务成功校验;预订工具返回的仍然是模拟结果。
+            # 这个 if 检查的是文本中是否出现“缺少”两个字,不能识别所有可能的追问表达。
+            # 命中就把追问放进 status.message;未命中才进入 else 填写正常产物。
+            if "缺少" in output:
+                task.status = TaskStatus(
+                    state=TaskState.INPUT_REQUIRED,
+                    message={"role": "agent", "content": {"text": output}}
+                )
+            else:
+                # ② 运行成功
+                task.status = TaskStatus(
+                    state=TaskState.COMPLETED,
+                    message={"role": "agent", "content": {"text": "运行成功"}}
+                )
+                # 产物结构是列表→parts 列表→文本项;主助手从 artifacts[0]["parts"][0]["text"] 读取 output。
+                # status 中的“运行成功”只说明此回调走完,真正给用户的文本在 artifacts。
+                task.artifacts = [{"parts": [{"type": "text", "text": output}]}]
+            # 【主流程 9.2】把同一个 Task 连同状态或结果交回 A2A 框架,再由框架发送给主助手。
+            return task
+        # 这个 try 从执行器 invoke 才开始;前面的工具加载和 Agent 构造异常不在此 try 的捕获范围。
+        # 被捕获的执行异常写入 FAILED 状态,让主助手能读取错误文本。
+        except Exception as e:
+            # ③ 运行失败
+            task.status = TaskStatus(TaskState.FAILED,
+
+                                     message={"role": "agent", "content": {"text": f"agent执行失败:{e}"}}
+                                     )
+            return task
+if __name__ == "__main__":
+    # 创建票务查询服务器实例
+    ticket_server = TicketServer()
+
+    # 打印服务器信息,方便确认启动状态
+    print("\n=== 服务器信息 ===")
+    print(f"名称: {ticket_server.agent_card.name}")
+    print(f"描述: {ticket_server.agent_card.description}")
+    print("\n技能:")
+    # 启动时只打印卡片中声明的技能,帮助人工核对名称和配置;不会依次调用这些技能。
+    for skill in ticket_server.agent_card.skills:
+        print(f"- {skill.name}: {skill.description}")
+
+    # 启动 A2A 服务器,监听 5006 端口
+    # 主助手会通过 http://localhost:5006 连接本服务器
+    # 【启动 S3.4】开始监听 A2A 请求;后续 handle_task 按请求触发,不按函数定义顺序执行。
+    run_server(ticket_server, host="127.0.0.1", port=5006)

+ 412 - 0
a2a_server/trip_server.py

@@ -0,0 +1,412 @@
+# 【启动 S3】本文件是独立的 A2A 子助手进程:监听 5007,调用 MCP 工具服务 8003。
+# 启动时先执行模块顶层配置、模型对象和代理卡片定义,再进入 __main__ 创建服务器。
+# 类/函数定义本身不会执行查询;run_server 开始监听后,有 Task 到达才进入 handle_task。
+# 主流程 7:接收任务 → 加载工具 → 模型选择工具和参数 → 执行器调用工具(主流程 8)。
+# 主流程 9:工具结果交回模型生成回复 → 封装 Task 状态及 artifacts → 返回主助手。
+
+"""
+需求:实现基于A2A的差旅管家服务器,处理用户的商务用车、会务套餐、保险查询及预订请求
+
+架构说明:
+    本服务器是 SmartBizTrip 系统中的又一个子代理(Sub-Agent),负责处理所有差旅相关任务。
+    它运行在独立的进程中(localhost:5007),通过 A2A(Agent2Agent)协议与主助手通信。
+
+    与 ticket_server.py 和 weather_server.py 保持一致的架构模式:
+    - 使用 LangChain Agent + MCP Tools 模式
+    - LLM 从自然语言中提取参数(城市、日期、类型等)
+    - 调用 MCP Server 的参数化工具(端口 8003)
+    - MCP Server 返回结果后,LLM 将结果格式化为友好的中文回复
+
+    工作流程:
+    1. 主助手通过 A2A 协议向本服务器发送任务(Task)
+    2. 本服务器收到任务后,提取用户的自然语言查询
+    3. 使用 LangChain Agent 处理查询:
+       a. LLM 分析用户输入,决定调用哪个 MCP 工具
+       b. LangChain 自动调用 MCP Server 的工具(端口 8003)
+       c. 工具返回结果后,LLM 将结果格式化为友好的中文回复
+    4. 将结果返回给主助手
+
+    MCP Server(端口 8003)提供的工具包括:
+    - 商务用车查询工具、会务套餐查询工具、保险查询工具
+    - 商务用车预订工具、会务套餐预订工具、保险购买工具
+
+架构图(一次请求的调用关系):
+
+    主助手 → [A2A :5007] TripServer.handle_task
+                              ↓
+                    query_trip → 加载 MCP 工具定义
+                              ↓
+                    AgentExecutor → 模型选择工具和参数
+                              ↓
+                    [MCP :8003] 执行所选工具
+                              ├─ 用车/保险查询 → MySQL
+                              ├─ 会务套餐查询 → Embedding + Milvus
+                              └─ 预订工具 → 模拟结果
+                              ↓
+                    工具结果 → 模型整理 → Task → 主助手
+
+请求/响应流向:
+    用户 → 主助手 → [A2A] → TripServer.handle_task()
+                                  ↓
+                          query_trip() → LangChain Agent (LLM)
+                                  ↓
+                          MCP Tools → MCP Server :8003
+                                  ↓
+                          MCP Server 返回工具结果
+                                  ↓
+                          LLM 格式化为中文回复
+                                  ↓
+                          handle_task() 设置 TaskStatus
+                                  ↓
+                          主助手 → 用户
+"""
+
+# ==================== 导入依赖 ====================
+import asyncio  # 异步 IO 库
+
+# create_tool_calling_agent: 创建基于工具调用的 Agent
+# AgentExecutor: Agent 执行器,负责运行 Agent 循环
+from datetime import datetime  # 时间处理
+
+import pytz  # 时区库
+from langchain.agents import AgentExecutor, create_tool_calling_agent
+from langchain_core.prompts import ChatPromptTemplate  # 提示模板
+from langchain_core.tools import StructuredTool  # 【新改动1】支持多个具名参数的工具
+from langchain_mcp_adapters.tools import load_mcp_tools  # 从 MCP 会话加载工具
+
+# A2AServer: A2A 服务器基类
+# run_server: 启动服务器的函数
+# AgentCard: 代理卡片
+# AgentSkill: 代理技能
+# TaskStatus: 任务状态对象
+# TaskState: 任务状态枚举(COMPLETED / FAILED / INPUT_REQUIRED)
+from langchain_openai import ChatOpenAI  # LangChain 的大模型接口
+from python_a2a import (
+    A2AServer,
+    AgentCard,
+    AgentSkill,
+    MCPClient,
+    TaskState,
+    TaskStatus,
+    run_server,
+    to_langchain_tool,
+)
+
+from config import Config  # 项目配置
+from create_logger import logger  # 日志模块
+
+conf = Config()  # 全局配置实例
+
+# ==================== 初始化大模型 ====================
+# 创建一个 LLM 实例,供 Agent 使用
+# 【启动 S3.1】创建模型客户端;模型推理在后面的 invoke/ainvoke 才发生。
+llm = ChatOpenAI(
+    model=conf.model_name,
+    base_url=conf.base_url,
+    api_key=conf.api_key,
+    temperature=conf.temperature,
+)
+
+# ==================== MCP 客户端配置 ====================
+# MCP Server 运行在 8003 端口,提供差旅相关的工具(查询 + 预订)
+MCP_URL = "http://127.0.0.1:8003"
+
+
+# ==================== 差旅查询函数 ====================
+async def query_trip(conversation: str) -> dict:
+    """
+    通过 LangChain Agent + MCP Tools 执行差旅查询或预订
+
+    与 ticket_server.py 的 handle_task 一样使用工具调用模式:
+    1. 连接 MCP Server 加载所有可用的工具
+    2. 创建一个 LangChain Agent,让它自主选择调用哪个工具
+    3. AgentExecutor 负责执行 Agent 循环
+
+    参数:
+        conversation (str): 用户的查询内容,例如:
+            "我想租一辆车,北京,明天"
+            "有没有成都客户拜访的会务套餐"
+            "买一份差旅保险"
+            "帮我预订北京政企拜访3日套餐"
+
+    返回值:
+        dict: 查询结果,格式为:
+            - {"status": "success", "message": "格式化后的查询结果"}
+            - {"status": "error", "message": "错误信息"}
+    """
+    # 调用者:本文件的 handle_task。conversation 是整个任务文本,可能包含历史和当前需求。
+    # 输出:字典 {"status": "success"/"error", "message": "回复或错误文本"},由 handle_task 再转成 A2A 状态。
+    # 这里的 success 表示执行器返回了结果,不等于工具一定查到了数据。
+    try:
+        # 【主流程 7.2】连接 MCP 读取工具定义;此处尚未执行具体业务查询。
+        tools = to_langchain_tool(MCP_URL)
+        # 【新改动1】保留 MCP 参数定义,将单参数 Tool 转为 StructuredTool,
+        # 使取车城市、还车城市、日期等参数能按名称传给原工具函数。
+        # 【主流程 7.3】把工具参数结构交给 StructuredTool,保留原 MCP 调用函数。
+        # 模型输出参数名称和值,执行器按照这个结构传参。
+        tools = [
+            StructuredTool(
+                # 工具对象的五个字段各有用途:name 用于匹配模型选中的工具,description 帮助模型理解用途;
+                # func 保存真正发起 MCP 调用的函数;args_schema 规定参数名/类型;metadata 保留原工具信息。
+                # 列表里的每一项转换成独立 StructuredTool,模型随后可在这些工具中选择。
+                name=tool.name,
+                description=tool.description,
+                func=tool.func,
+                args_schema=tool.metadata["parameters"],
+                metadata=tool.metadata,
+            )
+            for tool in tools
+        ]
+        # 定义 Agent 的系统 Prompt
+        # 提示模板分三部分:system 给规则和当前日期,human 放本次输入,placeholder 放执行过程消息。
+        # {input}/{current_date} 由 invoke/ainvoke 的参数提供;{agent_scratchpad} 由 AgentExecutor 随工具调用更新。
+        # 占位符不是立刻执行的代码,只是在实际请求模型前被替换成消息内容。
+        prompt = ChatPromptTemplate.from_messages(
+            [
+                (
+                    "system",
+                    """你是一个差旅管家助手,能够调用工具来完成商务用车查询/预定、会务套餐查询/预订、保险查询/购买。
+你需要仔细分析用户的问题,从问题中提取工具需要的参数,然后调用对应的工具。
+如果用户提供的信息不足以提取到调用工具所有必要参数,则向用户追问,以获取该信息。不能自己编撰参数。预订工具仅用于模拟演示,回复必须明确说明模拟、未实际下单,不得声称已出票或生成保单。
+
+查询到结果后,请用清晰的中文格式化输出,格式如下:
+- 商务用车:取车城市 到 还车城市 日期: 车型XX,公司XX,价格XX元/天,余量XX
+- 会务套餐:城市 套餐名称: 天数XX天,价格XX元,余位XX/总数XX,亮点XX,出发日期列表
+- 保险:保险类型: 名称XX,价格XX元/份,保障范围XX,保险公司XX
+预定成功后请直接告知用户预定结果。
+当前日期是{current_date}。""",
+                ),
+                ("human", "{input}"),  # 用户输入会替换到这里
+                ("placeholder", "{agent_scratchpad}"),  # Agent 思考过程的占位符
+            ]
+        )
+
+        # 创建基于工具调用的 Agent
+        # 【主流程 7.4】组装 Agent,随后创建执行器;此时尚未调用模型处理本次请求。
+        # agent_scratchpad 承载本轮工具调用与工具返回消息,不是跨轮用户记忆。
+        agent = create_tool_calling_agent(llm, tools, prompt)
+
+        # 创建 Agent 执行器
+        # agent 负责根据输入和已有工具结果产生下一步动作;executor 负责运行这个过程。
+        # 若模型请求工具,executor 按工具名找到对象并调用 func,再把返回值交回模型;直到得到最终回答。
+        # verbose=True 打印执行过程,便于观察调用轨迹,不代表业务结果会自动保存到记忆或数据库。
+        agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
+
+        # 获取当前日期,注入到 Prompt 中
+        current_date = datetime.now(pytz.timezone("Asia/Shanghai")).strftime("%Y-%m-%d")
+
+        # 执行 Agent
+        # 【主流程 7.5 → 8】执行器开始循环:问模型 → 执行所选工具 → 将结果交回模型。
+        # await 等待本次循环的最终结果;此处返回一个完整回复,不直接向主助手推送每个片段。
+        response = await agent_executor.ainvoke(
+            {"input": conversation, "current_date": current_date}
+        )
+
+        # ainvoke 已经完成整轮工具循环,response["output"] 是模型整理后的文字。
+        # 将它包装成普通字典返回给 handle_task,后者再决定 COMPLETED、INPUT_REQUIRED 或 FAILED。
+        return {"status": "success", "message": response["output"]}
+
+    except ExceptionGroup as eg:
+        # MCP 工具内部使用 anyio.create_task_group(),失败时抛出 ExceptionGroup
+        first_exc = eg.exceptions[0] if eg.exceptions else eg
+        logger.error(f"差旅 MCP 查询出错:{first_exc}")
+        return {"status": "error", "message": f"差旅 MCP 查询出错:{first_exc}"}
+    except BaseException as e:
+        # 兜底捕获(包括 ExceptionGroup 等 BaseException 子类)
+        logger.error(f"差旅 MCP 查询出错:{str(e)}")
+        return {"status": "error", "message": f"差旅 MCP 查询出错:{str(e)}"}
+
+
+# ==================== Agent Card(代理卡片) ====================
+# 描述差旅管家代理的能力、技能等信息
+# 【启动 S3.2】定义对外说明。skills 是能力介绍,实际可执行工具来自 MCP。
+# 主助手当前按 Config.intent 路由;卡片声明的能力也不等于此方法已实现逐片输出。
+# 卡片字段说明:name 是代理标识;url 是访问地址;description/skills 向调用方介绍能力。
+# 这里描述技能不会自动生成对应 Python 函数,实际工具仍由 MCP 服务注册和提供。
+# 主助手 Config.intent 中的名称、AgentNetwork 的注册名称和这里的 name 要相互对应。
+agent_card = AgentCard(
+    name="TripAssistant",  # 代理名称
+    description="基于 LangChain 提供差旅管家服务的统一助手,支持商务用车、会务套餐、保险的查询与预订",  # 代理描述
+    url="http://localhost:5007",  # 代理的访问地址
+    version="1.0.0",  # 版本号
+    capabilities={"streaming": True, "memory": True},  # 支持的能力
+    skills=[
+        # 技能1:商务用车查询
+        AgentSkill(
+            name="query car rental",
+            description="查询商务用车信息,支持指定取车城市、还车城市、日期和车型",
+            examples=[
+                "商务用车 成都 成都 2026-05-01",
+                "成都明天安排一辆商务SUV接送客户",
+            ],
+        ),
+        # 技能2:会务套餐查询(语义搜索)
+        AgentSkill(
+            name="query meeting package",
+            description="通过语义搜索查询会务套餐信息,支持自然语言描述需求(如成都办数字化转型峰会,需要接送和酒店)和可选的城市过滤",
+            examples=[
+                "成都办数字化转型峰会,需要接送和酒店",
+                "适合销售团队的短途会务套餐",
+                "客户接待晚宴方案",
+                "北京政企拜访会务套餐",
+            ],
+        ),
+        # 技能3:保险查询
+        AgentSkill(
+            name="query insurance",
+            description="查询差旅保险产品,支持指定保险类型和日期",
+            examples=["差旅保险 2025-08-01", "买一份综合型差旅保险"],
+        ),
+        # 技能4:商务用车预订
+        AgentSkill(
+            name="order car rental",
+            description="根据日期、车型和数量预订商务用车服务",
+            examples=["预订商务用车 2025-08-01 SUV 1辆"],
+        ),
+        # 技能5:会务套餐预订
+        AgentSkill(
+            name="order meeting package",
+            description="根据日期、套餐名称和人数预订会务套餐",
+            examples=["预订成都客户拜访2日套餐 2025-08-01 2人"],
+        ),
+        # 技能6:保险购买
+        AgentSkill(
+            name="order insurance",
+            description="根据保险类型、日期和份数购买差旅保险",
+            examples=["购买综合型保险 2025-08-01 1份"],
+        ),
+    ],
+)
+
+
+# ==================== 差旅查询服务器类 ====================
+class TripServer(A2AServer):
+    """
+    差旅管家 A2A 服务器
+
+    这个类继承自 A2AServer,负责处理所有差旅相关的任务。
+    与票务子助手一样使用工具调用;本类的具体流程如下:
+    1. 接收来自主助手的任务(Task)
+    2. 从任务中提取用户的查询内容
+    3. 调用 query_trip 函数执行查询
+    4. 将查询结果设置到任务状态中,返回给主助手
+    """
+
+    def __init__(self):
+        """
+        初始化差旅查询服务器
+        """
+        # 【启动 S3.3】将卡片交给 A2AServer;父类提供通信框架,本类负责实现 handle_task。
+        # 输入:模块加载时创建的 agent_card。super() 将当前实例交给父类初始化。
+        # 本类继承了 A2A 通信框架,只覆盖业务回调 handle_task;此时还没有用户查询需要执行。
+        super().__init__(agent_card=agent_card)  # 调用父类初始化,注册 Agent Card
+
+    def handle_task(self, task):
+        """
+        处理来自 A2A 客户端的任务 —— 本服务器的核心方法
+
+        完整流程:
+        1. 提取输入:从任务消息中获取用户的查询内容
+        2. 调用 MCP 查询:通过 query_trip 函数执行查询
+        3. 根据查询结果设置任务状态:
+           - 成功:COMPLETED 状态,结果放在 task.artifacts
+           - 需要输入:INPUT_REQUIRED 状态,追问信息放在 task.status.message
+           - 失败:FAILED 状态,错误信息放在 task.status.message
+
+        参数:
+            task: A2A 任务对象
+
+        返回值:
+            task: 处理后的任务对象(已设置状态和结果)
+        """
+        # ========== 步骤1:提取输入 ==========
+        # 【主流程 7.1】A2A 框架收到任务后调用此方法;先从 task.message 取输入。
+        # 输入 task 来自主助手的 A2A 请求;它包含任务编号、message,处理后还会带上 status/artifacts。
+        # 本方法不是被 ChatService 直接当本地 Python 方法调用,而是由此进程的 A2A 框架接收请求后触发。
+        # 输出仍是 task 对象,只是在原对象上填写了结果状态和产物。
+        # 先取 message 内的 content;下一行确认它是字典,再取 text。
+        # 消息为空、content 类型不对或没有 text 时,本实现使用空字符串交给后续处理。
+        content = (task.message or {}).get("content", {})
+        conversation = content.get("text", "") if isinstance(content, dict) else ""
+        logger.info(f"用户查询: {conversation}")
+
+        try:
+            # ========== 步骤2:调用 MCP 查询 ==========
+            # 同步 handle_task 用 asyncio.run 驱动异步查询,等查询结束后才设置任务状态。
+            trip_result = asyncio.run(query_trip(conversation))
+            logger.info(f"MCP 查询返回: {trip_result}")
+
+            # ========== 步骤3:根据结果设置任务状态 ==========
+            # 这里按查询函数返回的 status 分支;先确认执行器是否正常返回,再从 message 检查是否为追问。
+            if trip_result.get("status") == "success":
+                result_text = trip_result.get("message", "")
+
+                # 检查是否是追问消息
+                # 【主流程 9.1】用回复关键词判断是否需要补充信息;否则把文本放入 artifacts。
+                # COMPLETED 表示处理结束,仍需结合结果文本判断有无数据或是否仅为模拟。
+                # 任意一个关键词出现就视为需要补信息;只是当前示例的字符串规则,不是模型返回的专门状态字段。
+                if "请提供" in result_text or "请确认" in result_text:
+                    task.status = TaskStatus(
+                        state=TaskState.INPUT_REQUIRED,
+                        message={"role": "agent", "content": {"text": result_text}},
+                    )
+                else:
+                    # 成功文本放到 artifacts 的第一个 part;主助手按同一层级提取文字。
+                    # 其他状态的解释文本放在 status.message,因此调用方需要同时处理这两种返回位置。
+                    task.artifacts = [
+                        {"parts": [{"type": "text", "text": result_text}]}
+                    ]
+                    task.status = TaskStatus(state=TaskState.COMPLETED)
+
+            # 查询函数已捕获到错误时走这个分支;未知 status 走后面的 else,同样标为 FAILED。
+            elif trip_result.get("status") == "error":
+                task.status = TaskStatus(
+                    state=TaskState.FAILED,
+                    message={
+                        "role": "agent",
+                        "content": {
+                            "text": trip_result.get("message", "查询失败,请重试。")
+                        },
+                    },
+                )
+            else:
+                task.status = TaskStatus(
+                    state=TaskState.FAILED,
+                    message={
+                        "role": "agent",
+                        "content": {"text": "查询失败,请重试或提供更多细节。"},
+                    },
+                )
+
+            # 【主流程 9.2】返回带有状态和结果的 Task,A2A 框架再把它传回主助手。
+            return task
+
+        except Exception as e:
+            logger.error(f"查询失败: {str(e)}")
+            task.status = TaskStatus(
+                state=TaskState.FAILED,
+                message={
+                    "role": "agent",
+                    "content": {"text": f"查询失败: {str(e)} 请重试或提供更多细节。"},
+                },
+            )
+            return task
+
+
+# ==================== 主函数:启动服务器 ====================
+if __name__ == "__main__":
+    # 创建差旅查询服务器实例
+    trip_server = TripServer()
+
+    # 打印服务器信息
+    print("\n=== 服务器信息 ===")
+    print(f"名称: {trip_server.agent_card.name}")
+    print(f"描述: {trip_server.agent_card.description}")
+    print("\n技能:")
+    # 启动时只打印卡片中声明的技能,帮助人工核对名称和配置;不会依次调用这些技能。
+    for skill in trip_server.agent_card.skills:
+        print(f"- {skill.name}: {skill.description}")
+
+    # 启动 A2A 服务器,监听 5007 端口
+    # 【启动 S3.4】开始监听 A2A 请求;后续 handle_task 按请求触发,不按函数定义顺序执行。
+    run_server(trip_server, host="127.0.0.1", port=5007)

+ 366 - 0
a2a_server/weather_server.py

@@ -0,0 +1,366 @@
+# 【启动 S3】本文件是独立的 A2A 子助手进程:监听 5005,调用 MCP 工具服务 8002。
+# 启动时先执行模块顶层配置、模型对象和代理卡片定义,再进入 __main__ 创建服务器。
+# 类/函数定义本身不会执行查询;run_server 开始监听后,有 Task 到达才进入 handle_task。
+# 主流程 7:接收任务 → 加载工具 → 模型选择工具和参数 → 执行器调用工具(主流程 8)。
+# 主流程 9:工具结果交回模型生成回复 → 封装 Task 状态及 artifacts → 返回主助手。
+
+"""
+需求:实现基于A2A的天气查询服务器,处理用户的天气查询请求并返回结果
+
+架构说明:
+    本服务器是 SmartBizTrip 系统中的一个子代理(Sub-Agent),负责处理天气查询任务。
+    它运行在独立的进程中(localhost:5005),通过 A2A(Agent2Agent)协议与主助手通信。
+
+    工作流程(与票务 Agent 保持一致的架构模式):
+    1. 主助手通过 A2A 协议向本服务器发送任务(Task)
+    2. 本服务器收到任务后,提取用户的自然语言查询
+    3. 使用 LangChain Agent(基于工具调用的 Agent)处理查询:
+       a. LLM 分析用户输入,从自然语言中提取参数(城市、日期等)
+       b. 调用 MCP Server 的参数化工具(query_weather)
+       c. MCP Server 内部根据参数拼接 SQL 并执行查询
+       d. 工具返回结果后,LLM 将结果格式化为友好的中文回复
+    4. 将结果返回给主助手
+
+    与旧模式(Text-to-SQL)的区别:
+    - 旧模式:LLM 直接生成 SQL 语句 → MCP 执行原始 SQL
+      问题:SQL 注入风险、LLM 生成的 SQL 可能带有代码块标记、容易出错
+    - 新模式:LLM 从自然语言中提取参数(city、date)→ MCP 内部拼 SQL
+      优势:参数化查询(安全)、SQL 由 MCP 统一管理(可控)、容错性好
+
+    涉及的关键技术:
+    - LangChain Agent: 让 LLM 自主选择和使用工具的框架
+    - Tool Calling Agent: LLM 以结构化格式调用工具
+    - MCP Tools: MCP Server 提供的参数化工具(city、start_date、end_date)
+    - AgentExecutor: 负责运行 Agent 循环(思考→调用工具→处理结果→继续)
+"""
+
+# ==================== 导入依赖 ====================
+import asyncio  # 异步 IO 库,用于在同步方法中调用异步 MCP 客户端
+
+from python_a2a import A2AServer, run_server, AgentCard, AgentSkill, TaskStatus, TaskState
+# A2AServer: A2A 服务器基类,我们需要继承它来实现自己的天气查询服务器
+# run_server: 启动 A2A 服务器的函数
+# AgentCard: 代理卡片,描述本代理的能力、技能等信息
+# AgentSkill: 代理技能,描述本代理能做什么
+# TaskStatus: 任务状态,标记任务是完成、失败还是需要输入
+# TaskState: 任务状态枚举(COMPLETED / FAILED / INPUT_REQUIRED)
+# to_langchain_tool: 从 MCP 服务器 URL 加载工具为 LangChain 工具
+
+from langchain_openai import ChatOpenAI  # LangChain 的大模型接口
+from langchain_core.prompts import ChatPromptTemplate  # LangChain 的提示模板
+from langchain_core.tools import StructuredTool
+from python_a2a import to_langchain_tool  # 从 MCP URL 加载工具
+from langchain.agents import create_tool_calling_agent, AgentExecutor
+# create_tool_calling_agent: 创建基于工具调用的 Agent
+# AgentExecutor: Agent 执行器,负责运行 Agent 循环
+
+from config import Config  # 项目配置(模型地址、API Key等)
+from datetime import datetime  # 时间处理,用于获取当前日期
+import pytz  # 时区库,用于转换到 Asia/Shanghai 时区
+
+from create_logger import logger  # 日志模块
+
+
+conf = Config()
+
+# 初始化LLM
+# 【启动 S3.1】创建模型客户端;模型推理在后面的 invoke/ainvoke 才发生。
+llm = ChatOpenAI(
+    model=conf.model_name,
+    base_url=conf.base_url,
+    api_key=conf.api_key,
+    temperature=conf.temperature
+)
+
+# MCP 服务器运行在 8002 端口
+MCP_URL = "http://127.0.0.1:8002"
+
+
+# ==================== Agent Card(代理卡片) ====================
+# Agent Card 是 A2A 协议中描述一个代理能力的元数据
+# 它告诉主助手:我是谁、我能做什么、我运行在哪里、我有哪些技能
+# 主助手用卡片展示能力;当前业务路由依据 Config.intent 中的意图映射。
+# 【启动 S3.2】定义对外说明。skills 是能力介绍,实际可执行工具来自 MCP。
+# 主助手当前按 Config.intent 路由;卡片声明的能力也不等于此方法已实现逐片输出。
+# 卡片字段说明:name 是代理标识;url 是访问地址;description/skills 向调用方介绍能力。
+# 这里描述技能不会自动生成对应 Python 函数,实际工具仍由 MCP 服务注册和提供。
+# 主助手 Config.intent 中的名称、AgentNetwork 的注册名称和这里的 name 要相互对应。
+agent_card = AgentCard(
+    name="WeatherQueryAssistant",  # 代理名称
+    description="基于LangChain提供天气查询服务的助手",  # 代理描述
+    url="http://localhost:5005",  # 代理的访问地址
+    version="2.0.0",  # 版本号(升级为2.0.0,表示架构变更)
+    capabilities={"streaming": True, "memory": True},  # 支持的能力:流式输出、记忆
+    skills=[  # 技能列表:描述本代理具体能做什么
+        AgentSkill(
+            name="execute weather query",  # 技能名称
+            description="执行天气查询,返回天气数据库结果,支持自然语言输入,支持单天和范围查询",  # 技能描述
+            examples=["北京 2025-07-30 天气", "上海未来5天", "今天天气如何"]  # 使用示例
+        )
+    ]
+)
+
+
+# ==================== 天气查询函数 ====================
+async def query_weather(conversation: str) -> dict:
+    """
+    通过 LangChain Agent + MCP Tools 执行天气查询
+
+    与票务 Agent(ticket_server.py)使用完全相同的架构模式:
+    1. 连接 MCP Server 加载所有可用的工具
+    2. 创建一个 LangChain Agent,让它自主选择调用哪个工具
+    3. AgentExecutor 负责执行 Agent 循环:
+       - LLM 分析用户输入,从自然语言中提取参数(城市、日期等)
+       - 调用 MCP Server 的参数化工具(如 query_weather(city="北京", start_date="2025-07-30"))
+       - MCP Server 内部根据参数拼接 SQL 并执行
+       - 工具返回结果后,LLM 将结果格式化为友好的中文回复
+
+    与旧模式(Text-to-SQL)的对比:
+    旧模式流程:
+        用户输入 "北京明天天气" → LLM 生成 SQL → MCP 执行原始 SQL
+    新模式流程:
+        用户输入 "北京明天天气" → LLM 提取 city="北京", date="明天" →
+        MCP 工具接收参数 → MCP 内部拼 SQL → 执行 → LLM 格式化回复
+
+    参数:
+        conversation (str): 用户的查询内容,例如:
+            "北京明天天气怎么样?"
+            "上海未来3天的天气"
+            "北京2025-07-30的天气"
+
+    返回值:
+        dict: 查询结果,格式为:
+            - {"status": "success", "message": "格式化后的查询结果"}  # 查询成功
+            - {"status": "error", "message": "错误信息"}  # 查询失败
+    """
+    # 调用者:本文件的 handle_task。conversation 是整个任务文本,可能包含历史和当前需求。
+    # 输出:字典 {"status": "success"/"error", "message": "回复或错误文本"},由 handle_task 再转成 A2A 状态。
+    # 这里的 success 表示执行器返回了结果,不等于工具一定查到了数据。
+    try:
+        # 从 MCP Server 加载所有可用工具
+        # to_langchain_tool 会自动将 MCP Server 提供的工具转换为 LangChain 工具对象
+        # 当前只有一个工具:query_weather(city, start_date, end_date),日期区间含首尾。
+        # 【主流程 7.2】连接 MCP 读取工具定义;此处尚未执行具体业务查询。
+        mcp_tools = to_langchain_tool(MCP_URL)
+
+        # python-a2a 0.5.4 默认生成单输入 Tool,天气查询却需要城市和日期等多个参数。
+        # StructuredTool 按参数名称传值,例如 city="北京"、start_date="2026-09-26"。
+        # 【主流程 7.3】把工具参数结构交给 StructuredTool,保留原 MCP 调用函数。
+        # 模型输出参数名称和值,执行器按照这个结构传参。
+        tools = []
+        for mcp_tool in mcp_tools:
+            weather_tool = StructuredTool(
+                # 工具对象的五个字段各有用途:name 用于匹配模型选中的工具,description 帮助模型理解用途;
+                # func 保存真正发起 MCP 调用的函数;args_schema 规定参数名/类型;metadata 保留原工具信息。
+                # 列表里的每一项转换成独立 StructuredTool,模型随后可在这些工具中选择。
+                name=mcp_tool.name,                        # 工具名称
+                description=mcp_tool.description,          # 告诉模型工具能做什么
+                func=mcp_tool.func,                        # 保留原来调用 MCP 的函数
+                args_schema=mcp_tool.metadata['parameters'],  # 城市、日期等参数的格式
+                metadata=mcp_tool.metadata,                # 保留工具的附加信息
+            )
+            tools.append(weather_tool)
+
+        # 定义 Agent 的系统 Prompt
+        # 这个 Prompt 告诉 LLM:你是谁、你能做什么、如何提取参数、输出格式要求
+        # 提示模板分三部分:system 给规则和当前日期,human 放本次输入,placeholder 放执行过程消息。
+        # {input}/{current_date} 由 invoke/ainvoke 的参数提供;{agent_scratchpad} 由 AgentExecutor 随工具调用更新。
+        # 占位符不是立刻执行的代码,只是在实际请求模型前被替换成消息内容。
+        prompt = ChatPromptTemplate.from_messages([
+            ("system", """你是一个天气查询助手,能够调用工具来查询天气信息。
+你需要仔细分析用户的问题,从问题中提取工具需要的参数(城市、日期等),然后调用对应的查询工具。
+如果用户提供的信息不足以提取到调用工具的所有必要参数,则向用户追问,以获取该信息。不能自己编撰参数。
+注意:
+- 用户可能使用相对时间,如"明天"、"后天"、"今天"、"未来3天"等,请根据当前日期转换为具体日期(YYYY-MM-DD格式)。
+- 如果用户只给了城市没给日期,需要追问日期。
+- 如果用户只给了日期没给城市,需要追问城市。
+查询到结果后,请用清晰的中文格式化输出天气信息,包括城市、日期、天气状况、温度、湿度、风向、降水量等。
+如果未查到数据,请回复"未找到相关天气数据,请确认或修改查询条件。"
+当前日期是{current_date}。"""),
+            ("human", "{input}"),  # 用户输入会替换到这里
+            ("placeholder", "{agent_scratchpad}"),  # Agent 思考过程的占位符
+        ])
+
+        # 创建基于工具调用的 Agent
+        # create_tool_calling_agent 会创建一个能自主选择和使用工具的 Agent
+        # LLM 以结构化格式(函数调用)来使用工具,而不是用自然语言描述
+        # 【主流程 7.4】组装 Agent,随后创建执行器;此时尚未调用模型处理本次请求。
+        # agent_scratchpad 承载本轮工具调用与工具返回消息,不是跨轮用户记忆。
+        agent = create_tool_calling_agent(llm, tools, prompt)
+
+        # 创建 Agent 执行器
+        # AgentExecutor 负责运行 Agent 循环:
+        # 1. 将用户输入发给 LLM
+        # 2. LLM 决定调用哪个工具 + 提供参数
+        # 3. 执行工具调用
+        # 4. 将工具结果返回给 LLM
+        # 5. LLM 生成最终回复
+        # agent 负责根据输入和已有工具结果产生下一步动作;executor 负责运行这个过程。
+        # 若模型请求工具,executor 按工具名找到对象并调用 func,再把返回值交回模型;直到得到最终回答。
+        # verbose=True 打印执行过程,便于观察调用轨迹,不代表业务结果会自动保存到记忆或数据库。
+        agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
+
+        # 获取当前日期,注入到 Prompt 中(用户可能说"明天"、"后天"等相对时间)
+        current_date = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d')
+
+        # 执行 Agent,传入用户输入和当前日期
+        # 【主流程 7.5 → 8】执行器开始循环:问模型 → 执行所选工具 → 将结果交回模型。
+        # await 等待本次循环的最终结果;此处返回一个完整回复,不直接向主助手推送每个片段。
+        response = await agent_executor.ainvoke({
+            "input": conversation,           # 用户输入的查询内容
+            "current_date": current_date     # 当前日期,用于相对时间转换
+        })
+
+        # AgentExecutor 的 response["output"] 就是 LLM 生成的最终回复
+        # ainvoke 已经完成整轮工具循环,response["output"] 是模型整理后的文字。
+        # 将它包装成普通字典返回给 handle_task,后者再决定 COMPLETED、INPUT_REQUIRED 或 FAILED。
+        return {"status": "success", "message": response["output"]}
+
+    except Exception as e:
+        # MCP 连接失败、工具调用异常等情况的捕获
+        logger.error(f"天气 MCP 查询出错:{str(e)}")
+        return {"status": "error", "message": f"天气 MCP 查询出错:{str(e)}"}
+
+
+
+# ==================== 天气查询服务器类 ====================
+class WeatherQueryServer(A2AServer):
+    """
+    天气查询 A2A 服务器
+
+    这个类继承自 A2AServer,实现了天气查询的完整流程。
+    与旧模式(Text-to-SQL)不同,现在使用 LangChain Agent + MCP Tools 模式:
+    - LLM 负责从自然语言中提取参数(城市、日期)
+    - MCP Server 内部根据参数拼接 SQL 并执行(参数化查询,更安全)
+
+    任务状态说明:
+    - COMPLETED: 任务成功完成,结果放在 task.artifacts 中
+    - FAILED: 任务失败,错误信息放在 task.status.message 中
+    - INPUT_REQUIRED: 需要用户补充输入,追问信息放在 task.status.message 中
+    """
+
+    def __init__(self):
+        """
+        初始化天气查询服务器
+
+        调用父类的 __init__ 注册 Agent Card。
+        """
+        # 【启动 S3.3】将卡片交给 A2AServer;父类提供通信框架,本类负责实现 handle_task。
+        # 输入:模块加载时创建的 agent_card。super() 将当前实例交给父类初始化。
+        # 本类继承了 A2A 通信框架,只覆盖业务回调 handle_task;此时还没有用户查询需要执行。
+        super().__init__(agent_card=agent_card)  # 调用父类初始化,注册 Agent Card
+
+    def handle_task(self, task):
+        """
+        处理来自 A2A 客户端的任务 —— 本服务器的核心方法
+
+        当主助手通过 A2A 协议发送任务过来时,这个方法会被调用。
+        它负责完成从"接收任务"到"返回结果"的完整流程。
+
+        完整流程:
+        1. 提取输入:从任务消息中获取用户的查询内容
+        2. 调用 MCP 查询:通过 query_weather 函数执行查询
+           - query_weather 内部使用 LangChain Agent + MCP Tools
+           - LLM 自动从自然语言中提取参数(城市、日期)
+           - MCP Server 根据参数拼接 SQL 并执行
+           - LLM 将结果格式化为友好的中文回复
+        3. 根据查询结果设置任务状态:
+           - 成功:COMPLETED 状态,结果放在 task.artifacts
+           - 需要输入:INPUT_REQUIRED 状态,追问信息放在 task.status.message
+           - 失败:FAILED 状态,错误信息放在 task.status.message
+
+        参数:
+            task: A2A 任务对象,包含:
+                - task.message: 客户端发送的消息(包含用户输入)
+                - task.artifacts: 用于存放任务结果(输出)
+                - task.status: 任务状态(完成/失败/需要输入)
+
+        返回值:
+            task: 处理后的任务对象(已设置状态和结果)
+        """
+        # ========== 步骤1:提取输入 ==========
+        # 从任务消息中获取内容(A2A 协议中,消息以字典格式存储)
+        # 【主流程 7.1】A2A 框架收到任务后调用此方法;先从 task.message 取输入。
+        # 输入 task 来自主助手的 A2A 请求;它包含任务编号、message,处理后还会带上 status/artifacts。
+        # 本方法不是被 ChatService 直接当本地 Python 方法调用,而是由此进程的 A2A 框架接收请求后触发。
+        # 输出仍是 task 对象,只是在原对象上填写了结果状态和产物。
+        # 先取 message 内的 content;下一行确认它是字典,再取 text。
+        # 消息为空、content 类型不对或没有 text 时,本实现使用空字符串交给后续处理。
+        content = (task.message or {}).get("content", {})
+        # 提取对话内容,即客户端发起的任务中的用户输入
+        conversation = content.get("text", "") if isinstance(content, dict) else ""
+        logger.info(f"对话历史及用户问题: {conversation}")
+
+        try:
+            # ========== 步骤2:调用 MCP 查询 ==========
+            # query_weather 内部会:
+            # 1. 连接 MCP Server 加载所有工具
+            # 2. 创建 LangChain Agent
+            # 3. Agent 自动从自然语言中提取参数(城市、日期等)
+            # 4. 调用 MCP 的参数化工具(query_weather(city="北京", start_date="2025-07-30"))
+            # 5. MCP Server 内部拼接 SQL 并执行
+            # 6. LLM 将结果格式化为友好的中文回复
+            # 同步 handle_task 用 asyncio.run 驱动异步查询,等查询结束后才设置任务状态。
+            weather_result = asyncio.run(query_weather(conversation))
+            logger.info(f"MCP 查询返回: {weather_result}")
+
+            # ========== 步骤3:根据结果设置任务状态 ==========
+            # 这里按查询函数返回的 status 分支;先确认执行器是否正常返回,再从 message 检查是否为追问。
+            if weather_result.get("status") == "success":
+                result_text = weather_result.get("message", "")
+
+                # 检查是否是追问消息(LLM 发现信息不足时会追问)
+                # 【主流程 9.1】用回复关键词判断是否需要补充信息;否则把文本放入 artifacts。
+                # COMPLETED 表示处理结束,仍需结合结果文本判断有无数据或是否仅为模拟。
+                # 任意一个关键词出现就视为需要补信息;只是当前示例的字符串规则,不是模型返回的专门状态字段。
+                if "请提供" in result_text or "请确认" in result_text:
+                    # 需要用户补充信息
+                    task.status = TaskStatus(
+                        state=TaskState.INPUT_REQUIRED,
+                        message={"role": "agent", "content": {"text": result_text}}
+                    )
+                else:
+                    # 查询成功,将结果放入任务产物
+                    # 成功文本放到 artifacts 的第一个 part;主助手按同一层级提取文字。
+                    # 其他状态的解释文本放在 status.message,因此调用方需要同时处理这两种返回位置。
+                    task.artifacts = [{"parts": [{"type": "text", "text": result_text}]}]
+                    task.status = TaskStatus(state=TaskState.COMPLETED)
+
+            # 查询函数已捕获到错误时走这个分支;未知 status 走后面的 else,同样标为 FAILED。
+            elif weather_result.get("status") == "error":
+                # MCP 查询出错:设置为失败状态
+                task.status = TaskStatus(
+                    state=TaskState.FAILED,
+                    message={"role": "agent", "content": {"text": weather_result.get("message", "查询失败,请重试。")}}
+                )
+            else:
+                # 未知的状态码,也视为失败
+                task.status = TaskStatus(
+                    state=TaskState.FAILED,
+                    message={"role": "agent", "content": {"text": "查询失败,请重试或提供更多细节。"}}
+                )
+
+            # 【主流程 9.2】返回带有状态和结果的 Task,A2A 框架再把它传回主助手。
+            return task
+
+        except Exception as e:
+            # 捕获所有异常,确保即使出错也能返回有效的任务状态
+            logger.error(f"查询失败: {str(e)}")
+            task.status = TaskStatus(
+                state=TaskState.FAILED,
+                message={"role": "agent",
+                         "content": {"text": f"查询失败: {str(e)} 请重试或提供更多细节。"}}
+            )
+            return task
+
+if __name__ == "__main__":
+    weather_server = WeatherQueryServer()
+    print("\n=== 服务器信息 ===")
+    print(f"名称: {weather_server.agent_card.name}")
+    print(f"描述: {weather_server.agent_card.description}")
+    print("\n技能:")
+    # 启动时只打印卡片中声明的技能,帮助人工核对名称和配置;不会依次调用这些技能。
+    for skill in weather_server.agent_card.skills:
+        print(f"- {skill.name}: {skill.description}")
+    # 【启动 S3.4】开始监听 A2A 请求;后续 handle_task 按请求触发,不按函数定义顺序执行。
+    run_server(weather_server, host="127.0.0.1", port=5005)

+ 1 - 0
agent_learn/A2A_base/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 1 - 0
agent_learn/A2A_base/a2a_case/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 61 - 0
agent_learn/A2A_base/a2a_case/main.py

@@ -0,0 +1,61 @@
+import asyncio
+from python_a2a import AgentNetwork, AIAgentRouter, A2AClient, Task, Message, MessageRole, TextContent
+import json
+import uuid
+
+from time import sleep
+
+
+async def main():
+    # 1. 创建 AgentNetwork
+    network = AgentNetwork(name="TravelAgentNetwork")
+    # 添加票务代理
+    network.add("TicketAgent", "http://127.0.0.1:5009")
+    # 添加天气代理
+    network.add("WeatherAgent", "http://127.0.0.1:5008")
+    print("[主控日志] AgentNetwork 初始化完成,已添加代理:")
+    for agent_info in network.list_agents():
+        print(json.dumps(agent_info, indent=4, ensure_ascii=False))
+    print("-" * 50)
+
+    # 2. 创建 AIAgentRouter
+    router=AIAgentRouter(
+        llm_client=A2AClient("http://localhost:5555"),  # 假设使用router_A2Aagent_Server.py的LLM服务器
+        agent_network=network)
+
+    # 3. 测试查询
+    queries = [
+        "帮我查下北京的天气",  # 应该路由到 WeatherAgent
+        "预订一张从北京到上海的高铁票"  # 应该路由到 TicketAgent
+    ]
+    for query in queries:
+        print(f"[主控日志] 用户查询: '{query}'")
+        # 使用路由器选择代理
+        agent_name, confidence = router.route_query(query)
+        print(f"[主控日志] 路由结果: {agent_name} (置信度: {confidence})")
+        if agent_name:
+            # 获取代理客户端
+            agent_client = network.get_agent(agent_name)
+            if agent_client:
+                # 创建消息和任务
+                message = Message(
+                    content=TextContent(text=query),
+                    role=MessageRole.USER
+                )
+                task = Task(
+                    id="task-" + str(uuid.uuid4()),
+                    message=message.to_dict())
+
+                # 发送任务并获取响应
+                try:
+                    result = await agent_client.send_task_async(task)
+                    sleep(5)
+                    print("[主控日志] 收到完整响应:")
+                    print(json.dumps(result.to_dict(), indent=4, ensure_ascii=False))
+                except:
+                    print("[主控日志] 错误:无法连接到代理")
+
+
+if __name__ == "__main__":
+    # 请确保 ticket_agent.py 和 weather_agent.py 正在运行...
+    asyncio.run(main())

+ 153 - 0
agent_learn/A2A_base/a2a_case/multi_intents.py

@@ -0,0 +1,153 @@
+# 步骤1:导入所需的库和模块
+import asyncio  # 导入 asyncio 库,用于实现异步和并发操作
+from python_a2a import AgentNetwork, AIAgentRouter, A2AClient, Task, Message, MessageRole, TextContent # 从 python_a2a 库导入 Agent 协作所需的核心类和对象
+from langchain_openai import ChatOpenAI  # 导入 LangChain 的 ChatOpenAI,用于与大语言模型交互
+from langchain_core.prompts import PromptTemplate  # 导入 LangChain 的 PromptTemplate,用于定义提示模板
+from langchain_core.output_parsers import StrOutputParser  # 导入 LangChain 的 StrOutputParser,用于解析 LLM 输出为字符串
+import json  # 导入 json 库,用于处理 JSON 格式的数据
+import uuid  # 导入 uuid 库,用于生成唯一的任务 ID
+from time import sleep  # 导入 sleep 函数,用于模拟处理延迟
+from agent_learn.config import Config # 导入自定义的 Config 类,用于加载配置信息
+import re  # 导入 re 模块,用于正则表达式处理
+
+# 步骤2:初始化配置和LLM
+# 2.1 从配置文件加载配置
+conf = Config()
+
+# 2.2 配置 LLM 用于分解查询
+decompose_llm = ChatOpenAI(
+    model=conf.model_name,
+    api_key=conf.api_key,
+    base_url=conf.base_url,
+    temperature=0.1,
+    streaming=True  #启用流式处理
+)
+
+# 2.3 定义分解查询的提示模板
+decompose_prompt = PromptTemplate.from_template(""" 
+将以下用户查询分解为独立的子查询,每个子查询对应一个单一意图。 
+返回 JSON 格式的列表:{{"sub_queries": ["子查询1", "子查询2", ...]}}
+示例:
+查询: "预订票,查天气"
+输出: {{"sub_queries": ["预订票", "查天气"]}}
+查询: {query}
+""")
+
+# 2.4 构建分解链
+decompose_chain = decompose_prompt | decompose_llm | StrOutputParser()
+
+
+# 步骤3:主函数,执行多意图协作流程
+async def main():
+    # 3.1 创建 AgentNetwork
+    network = AgentNetwork(name="TravelAgentNetwork")
+
+    # 3.2 添加专家代理到网络中
+    network.add("TicketAgent", "http://127.0.0.1:5009")  # 添加票务代理的名称和URL
+    network.add("WeatherAgent", "http://127.0.0.1:5008")  # 添加天气代理的名称和URL
+
+    # 3.3 打印网络初始化信息
+    print("[主控日志] AgentNetwork 初始化完成,已添加代理:")
+    for agent_info in network.list_agents():
+        print(json.dumps(agent_info, indent=4, ensure_ascii=False))
+    print("-" * 50)
+
+    # 3.4 创建 AIAgentRouter
+    router = AIAgentRouter(
+        llm_client=A2AClient("http://localhost:5555"),
+        agent_network=network  # 将 AgentNetwork 实例传递给路由器,以便它能知道有哪些 Agent 可用
+    )
+
+    # 3.5 定义测试查询列表(包括多意图)
+    queries = [
+        "帮我查下北京的天气",
+        "预订一张从北京到上海的高铁票",
+        "预订一张从北京到上海的高铁票,查询一下北京天气"  # 多意图查询
+    ]
+
+    # 3.6 循环处理每个查询
+    for query in queries:
+        print(f"[主控日志] 用户查询: '{query}'")
+
+        # 3.7 使用 LLM 分解查询为子查询
+        try:
+            decompose_response = decompose_chain.invoke({"query": query})  # 调用分解链,将查询传给LLM进行分解
+            print(f'decompose_response-->{decompose_response}')
+            # 使用正则表达式清理LLM输出中的JSON标记
+            decompose_response = re.sub(r'^```json\n|\n```$', '', decompose_response.strip())
+            decompose_data = json.loads(decompose_response)
+            # 从JSON中获取子查询列表,如果失败则使用原始查询
+            sub_queries = decompose_data.get("sub_queries", [query])
+        except Exception as e:
+            print(f"[主控日志] 分解错误: {str(e)},使用原始查询")
+            sub_queries = [query]  # 发生错误时,将原始查询作为唯一的子查询
+        print(f"[主控日志] query分解子任务结果: {sub_queries}")
+
+        # 3.8 收集子查询任务
+        tasks = []  # 创建一个空列表,用于存放所有要并行执行的异步任务
+        agent_names = []  # 创建一个空列表,用于记录每个任务对应的Agent名称
+        confidences = []  # 创建一个空列表,用于记录路由的置信度
+        for sub_query in sub_queries:  # 遍历所有分解出的子查询
+            agent_name, sub_confidence = router.route_query(sub_query)  # 调用路由器,为每个子查询选择最合适的Agent
+            print(f"[主控日志] 子查询 '{sub_query}' 路由结果: {agent_name} (置信度: {sub_confidence})")
+            if agent_name and sub_confidence >= 0.5:  # 如果找到合适的Agent且置信度足够高
+                agent_client = network.get_agent(agent_name)  # 从网络中获取该Agent的客户端实例
+                if agent_client:
+                    message = Message(  # 创建一个A2A消息对象
+                        content=TextContent(text=sub_query),
+                        role=MessageRole.USER
+                    )
+                    task = Task(  # 创建一个A2A任务对象
+                        id="task-" + str(uuid.uuid4()),
+                        message=message.to_dict()
+                    )
+                    # 记录所有task:因为没有使用 await,那么这个异步函数不会被执行,会返回一个未被处理的协程对象,但里面的代码不会运行
+                    tasks.append(agent_client.send_task_async(task))
+                    agent_names.append(agent_name)
+                    confidences.append(sub_confidence)
+
+        # 3.9 计算整体置信度
+        confidence = sum(confidences) / len(confidences) if confidences else 0.1
+        print("===========所有子查询置信度的平均值==============")
+        print(confidence)
+
+        # 3.10 并行执行任务
+        if tasks:
+            # 使用 asyncio.gather 并行执行所有任务,并收集结果
+            results = await asyncio.gather(*tasks, return_exceptions=True)
+            print("[主控日志]检查query拆解任务之后的所有 任务结果:")
+            print(results)
+
+            # 3.11 处理和打印任务结果
+            for i, result in enumerate(results):
+                if isinstance(result, Exception):  # 检查结果是否是异常(任务执行失败)
+                    print(f"[主控日志] {agent_names[i]} 处理错误: {str(result)}")
+                else:  # 任务成功完成
+                    print(f"[主控日志] {agent_names[i]} 收到完整响应:")
+                    print(json.dumps(result.to_dict(), indent=4, ensure_ascii=False)) # 格式化打印完整的A2A任务响应
+
+                    # 3.12 解析 artifacts 中的 parts
+                    print(f"\n[主控日志] {agent_names[i]} 解析 artifacts 中的 parts:")
+                    for artifact in result.artifacts:
+                        if "parts" in artifact:  # 检查是否存在 parts 字段
+                            for part in artifact["parts"]:
+                                part_type = part.get("type")  # 获取 part 的类型
+                                if part_type == "text":
+                                    print(f"Text 结果: {part.get('text')}")
+                                elif part_type == "error":
+                                    print(f"Error 消息: {part.get('message')}")
+                                elif part_type == "function_response":  # 如果类型是函数响应
+                                    print(f"Function Response: name={part.get('name')}, response={part.get('response')}")
+                                else:  # 其他未知类型
+                                    print(f"未知类型: {part}")
+        else:  # 如果任务列表为空
+            print("[主控日志] 未找到合适代理")
+
+        print("-" * 50)
+        sleep(1)  # 暂停1秒,避免请求过快
+
+
+# 步骤4:程序入口
+if __name__ == "__main__":
+    # 请确保 ticket_agent.py 和 weather_agent.py 正在运行...
+    asyncio.run(main())

+ 23 - 0
agent_learn/A2A_base/a2a_case/router_A2Aagent_Server.py

@@ -0,0 +1,23 @@
+from langchain_openai import ChatOpenAI
+from python_a2a import run_server
+from python_a2a.langchain import to_a2a_server
+import asyncio
+from agent_learn.config import Config
+
+conf = Config()
+
+async def main():
+    # 创建LangChain LLM
+    llm = ChatOpenAI(base_url=conf.base_url,
+                     api_key=conf.api_key,
+                     model=conf.model_name,
+                     temperature=0.1,
+                     streaming=True)
+    # 转换为A2A服务器
+    llm_server = to_a2a_server(llm)
+    print(llm_server.agent_card)
+    # 启动服务器
+    run_server(llm_server, port=5555)
+
+if __name__ == '__main__':
+    asyncio.run(main())

+ 46 - 0
agent_learn/A2A_base/a2a_case/ticket_agent.py

@@ -0,0 +1,46 @@
+from python_a2a import A2AServer, run_server, AgentCard, AgentSkill, TaskStatus, TaskState
+
+ticket_card = AgentCard(
+    name="TicketAgentServer",
+    description="一个可以预订票务的专家 Agent。",
+    url="http://127.0.0.1:5009",
+    version="1.0.0",
+    skills=[AgentSkill(name="book_ticket", description="预订票务")]
+)
+
+class TicketServer(A2AServer):
+    def __init__(self):
+        super().__init__(agent_card=ticket_card)
+
+    def handle_task(self, task):
+        print("收到A2A任务的task:=>", task)
+        query = (task.message or {}).get("content", {}).get("text", "")
+        print(f"[{self.agent_card.name} 日志] 收到 A2A 任务: '{query}'")
+
+        if "上海" in query and "北京" in query:
+        # 这里的结果可以来自于 MCP 模块,这里我们直接模拟结果
+            # 本例只演示北京、上海两座城市,按请求中的先后顺序确定方向。
+            if query.index("北京") < query.index("上海"):
+                route = "北京到上海"
+            else:
+                route = "上海到北京"
+            train_result = f"模拟预订成功:{route},G1001,10车1A(不生成真实订单)"
+        else:
+            train_result = "请输入明确的出发地和目的地。"
+
+        print(f"[{self.agent_card.name} 日志] 返回结果: {train_result}")
+        task.artifacts = [{"parts": [{"type": "text", "text": train_result}]}]
+        if "上海" in query and "北京" in query:
+            task.status = TaskStatus(state=TaskState.COMPLETED)
+        else:
+            task.status = TaskStatus(state=TaskState.INPUT_REQUIRED)
+        print(f"[{self.agent_card.name} 日志] 任务处理完毕")
+        print(f"[{self.agent_card.name} 日志] 输出结果task: {task}")
+        print(f"[{self.agent_card.name} 日志] 输出结果task.artifacts: {task.artifacts}")
+        return task
+
+
+if __name__ == "__main__":
+    server = TicketServer()
+    print(f"[{server.agent_card.name}] 启动成功,服务地址: {server.agent_card.url}")
+    run_server(server, host="127.0.0.1", port=5009)

+ 52 - 0
agent_learn/A2A_base/a2a_case/weather_agent.py

@@ -0,0 +1,52 @@
+import json
+from python_a2a import A2AServer, run_server, AgentCard, AgentSkill, TaskStatus, TaskState
+
+# A2A Agent 的名片
+agent_card = AgentCard(
+    name="WeatherAgentServer",
+    description="一个天气预报查询的专家 Agent",
+    url="http://127.0.0.1:5008",
+    skills=[AgentSkill(name="query", description="接受天气查询查询",examples=["天气北京"])]
+)
+
+
+class WeatherAgentServer(A2AServer):
+    def __init__(self):
+        super().__init__(agent_card=agent_card)
+
+    def handle_task(self, task):
+        print("收到A2A任务的task:=>", task)
+        query = (task.message or {}).get("content", {}).get("text", "")
+        print(f"[{self.agent_card.name} 日志] 收到 A2A 任务: '{query}'")
+        # 决策:如果查询包含“天气”,就调用 MCP 工具
+        if "天气" in query:
+            print(f"[{self.agent_card.name} 日志] 决策:任务需要天气数据,准备调用工具...")
+            try:
+                # 这里的结果可以来自于 MCP 模块,这里我们直接模拟结果
+                weather_result = {"温度": 30, "天气": "晴天"}
+                print(f"[{self.agent_card.name} 日志] 从 MCP 工具获得结果: '{weather_result}'")
+                # 将结果保存为任务 artifacts,artifacts是任务的输出结果
+                task.artifacts = [{"parts": [{"type": "text", "text": json.dumps(weather_result, ensure_ascii=False)}]}]
+            except Exception as e:
+                error_msg = f"调用 工具失败: {e}"
+                print(f"[{self.agent_card.name} 日志] {error_msg}")
+                task.artifacts = [{"parts": [{"type": "text", "text": error_msg}]}]
+                task.status = TaskStatus(state=TaskState.FAILED)
+                return task
+        else:
+            task.artifacts = [{"parts": [{"type": "text", "text": "无法理解的任务"}]}]
+
+        if "天气" in query:
+            task.status = TaskStatus(state=TaskState.COMPLETED)
+        else:
+            task.status = TaskStatus(state=TaskState.INPUT_REQUIRED)
+        print(f"[{self.agent_card.name} 日志] 任务处理完毕")
+        print(f"[{self.agent_card.name} 日志] 输出结果task: {task}")
+        print(f"[{self.agent_card.name} 日志] 输出结果task.artifacts: {task.artifacts}")
+        return task
+
+
+if __name__ == "__main__":
+    server = WeatherAgentServer()
+    print(f"[{server.agent_card.name}] 已启动,在 {server.agent_card.url}")
+    run_server(server, host="127.0.0.1", port=5008)

+ 1 - 0
agent_learn/A2A_base/a2a_mcp_collaboration/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 71 - 0
agent_learn/A2A_base/a2a_mcp_collaboration/a2a_weather_agent.py

@@ -0,0 +1,71 @@
+from python_a2a import A2AServer, run_server, AgentCard, AgentSkill, TaskStatus, TaskState
+from python_a2a.mcp import MCPClient
+import asyncio
+
+# A2A Agent 的名片
+agent_card = AgentCard(
+    name="WeatherServer",
+    description="用来查询天气",
+    url="http://127.0.0.1:8005",
+    skills=[AgentSkill(name="查询天气", description="查询指定城市的天气")]
+)
+
+
+class WeatherServer(A2AServer):
+    def __init__(self):
+        super().__init__(agent_card=agent_card)
+        # 在内部,它拥有一个 MCP 客户端,用于连接下游工具
+        self.mcp_url = "http://127.0.0.1:6005"
+
+    async def query_weather(self, city):
+        # 每次查询创建并关闭客户端,避免跨 asyncio.run 复用连接。
+        client = MCPClient(self.mcp_url)
+        try:
+            return await client.call_tool("get_weather", city=city)
+        finally:
+            await client.close()
+
+    def handle_task(self, task):
+        print("收到A2A任务的task:=>", task)
+        query = (task.message or {}).get("content", {}).get("text", "")
+        print(f"[{self.agent_card.name} 日志] 收到 A2A 任务: '{query}'")
+
+        # 决策:如果查询包含“天气”,就调用 MCP 工具
+        if "天气" in query:
+            # 本例只演示北京、上海;缺少城市时先追问。
+            if "北京" in query:
+                city = "北京"
+            elif "上海" in query:
+                city = "上海"
+            else:
+                task.artifacts = [{"parts": [{"type": "text", "text": "请提供城市,例如北京或上海。"}]}]
+                task.status = TaskStatus(state=TaskState.INPUT_REQUIRED)
+                return task
+            print(f"[{self.agent_card.name} 日志] 决策:任务需要天气数据,准备调用 MCP 工具...")
+
+            # 异步调用 MCP 工具
+            try:
+                # 在同步方法中运行异步代码的标准方式
+                weather_result = asyncio.run(self.query_weather(city))
+                print(f"[{self.agent_card.name} 日志] 从 MCP 工具获得结果: '{weather_result}'")
+                task.artifacts = [{"parts": [{"type": "text", "text": weather_result}]}]
+            except Exception as e:
+                error_msg = f"调用 MCP 工具失败: {e}"
+                print(f"[{self.agent_card.name} 日志] {error_msg}")
+                task.artifacts = [{"parts": [{"type": "text", "text": error_msg}]}]
+                task.status = TaskStatus(state=TaskState.FAILED)
+                return task
+        else:
+            task.artifacts = [{"parts": [{"type": "text", "text": "无法理解的任务"}]}]
+
+        task.status = TaskStatus(state=TaskState.COMPLETED if "天气" in query else TaskState.INPUT_REQUIRED)
+        print(f"[{self.agent_card.name} 日志] 任务完成,结果已返回给 A2A")
+        print("task:=>", task)
+        print("task.artifacts:=>", task.artifacts)
+        return task
+
+
+if __name__ == "__main__":
+    server = WeatherServer()
+    print(f"[{server.agent_card.name}] 已启动,在 {server.agent_card.url}")
+    run_server(server, host="127.0.0.1", port=8005)

+ 16 - 0
agent_learn/A2A_base/a2a_mcp_collaboration/main_client.py

@@ -0,0 +1,16 @@
+import asyncio
+from python_a2a import A2AClient
+
+async def main():
+    # 创建客户端
+    agent_client = A2AClient("http://127.0.0.1:8005")
+    print("[主客户端日志] 准备向天气 Agent 发送任务...")
+
+    # 发起 A2A 调用
+    query = "请帮我查一下北京的天气"
+    result = agent_client.ask(query)
+    print(f"[主客户端日志] 收到最终结果: '{result}'")
+
+if __name__ == "__main__":
+    # 请确保 mcp_weather_tool_agent.py 和 a2a_weather_agent.py 正在运行...
+    asyncio.run(main())

+ 17 - 0
agent_learn/A2A_base/a2a_mcp_collaboration/mcp_weather_tool_agent.py

@@ -0,0 +1,17 @@
+import uvicorn
+from python_a2a.mcp import FastMCP, create_fastapi_app
+
+mcp = FastMCP(name="WeatherTool")
+
+@mcp.tool(name="get_weather", description="获取城市天气")
+def get_weather(city: str) -> str:
+    print(f"[MCP 工具 Agent 日志] 收到工具调用,查询城市: {city}")
+    if city == "北京":
+        return "北京今天阳光明媚,29°C"
+    return f"找不到 {city} 的天气"
+
+
+if __name__ == "__main__":
+    app = create_fastapi_app(mcp)
+    print("[MCP 工具 Agent] 已启动,在 http://127.0.0.1:6005")
+    uvicorn.run(app, host="127.0.0.1", port=6005)

+ 1 - 0
agent_learn/A2A_base/a2a_serial/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 71 - 0
agent_learn/A2A_base/a2a_serial/main_orchestrator.py

@@ -0,0 +1,71 @@
+import asyncio
+from python_a2a import AgentNetwork, A2AClient, Task, Message, MessageRole, TextContent
+import json
+import uuid
+from time import sleep
+
+
+async def main():
+    # 步骤1:初始化 AgentNetwork 并注册专家 Agent
+    # 1.1 创建 AgentNetwork 实例,用于管理 Agent 集合
+    network = AgentNetwork(name="TravelOrchestrator")
+    # 1.2 添加票务 Agent
+    network.add("TicketAgent", "http://127.0.0.1:5009")
+    # 1.3 添加天气 Agent
+    network.add("WeatherAgent", "http://127.0.0.1:5008")
+    print("[主控日志] AgentNetwork 初始化完成,已添加专家代理:")
+    for agent_info in network.list_agents():
+        print(json.dumps(agent_info, indent=4, ensure_ascii=False))
+    print("-" * 50)
+
+    # 步骤2:执行串行任务流
+    # 2.1 任务一:查询天气
+    weather_query = "北京的天气怎么样"  # 用户请求
+    print(f"[主控日志] 串行任务第一步:向 WeatherAgent 查询天气 -> '{weather_query}'")
+    # 获取 WeatherAgent 客户端
+    weather_client = network.get_agent("WeatherAgent")
+    # 创建消息和任务
+    message_weather = Message(content=TextContent(text=weather_query), role=MessageRole.USER)
+    task_weather = Task(id="task-" + str(uuid.uuid4()), message=message_weather.to_dict())
+
+    # 发送任务并等待结果,这是串行执行的关键
+    weather_result_task = await weather_client.send_task_async(task_weather)
+    sleep(1)  # 模拟处理延迟
+
+    # 从返回的任务中解析出天气信息
+    weather_info = "未知天气"
+    try:
+        # 获取 artifacts 中的文本部分
+        weather_parts = weather_result_task.artifacts[0]["parts"]
+        if weather_parts and weather_parts[0].get("type") == "text":
+            weather_info = weather_parts[0].get("text")
+            print(f"[主控日志] 收到 WeatherAgent 的结果: '{weather_info}'")
+    except Exception as e:
+        print(f"[主控日志] 解析天气结果出错: {e}")
+
+    # 2.2 任务二:根据天气结果预订高铁票
+    # 这是将一个 Agent 的输出作为另一个 Agent 的输入的关键步骤
+    print(f"\n[主控日志] 串行任务第二步:根据天气结果决定预订高铁票")
+    ticket_query = f"预订一张从北京到上海的高铁票,当前天气是:{weather_info}"
+    print(f"[主控日志] 传递给 TicketAgent 的查询为: '{ticket_query}'")
+    # 获取 TicketAgent 客户端
+    ticket_client = network.get_agent("TicketAgent")
+    # 创建新的任务
+    message_ticket = Message(content=TextContent(text=ticket_query), role=MessageRole.USER)
+    task_ticket = Task(id="task-" + str(uuid.uuid4()), message=message_ticket.to_dict())
+
+    # 发送任务并获取最终结果
+    ticket_result_task = await ticket_client.send_task_async(task_ticket)
+    sleep(1)  # 模拟处理延迟
+
+    # 打印最终结果
+    print(f"\n[主控日志] 收到 TicketAgent 的最终结果:")
+    print(json.dumps(ticket_result_task.to_dict(), indent=4, ensure_ascii=False))
+    print("-" * 50)
+
+    print("[主控日志] 所有串行任务完成!")
+
+
+if __name__ == "__main__":
+    # 请确保 weather_agent.py 和 ticket_agent.py 正在运行...
+    asyncio.run(main())

+ 46 - 0
agent_learn/A2A_base/a2a_serial/ticket_agent.py

@@ -0,0 +1,46 @@
+from python_a2a import A2AServer, run_server, AgentCard, AgentSkill, TaskStatus, TaskState
+
+ticket_card = AgentCard(
+    name="TicketAgentServer",
+    description="一个可以预订票务的专家 Agent。",
+    url="http://127.0.0.1:5009",
+    version="1.0.0",
+    skills=[AgentSkill(name="book_ticket", description="预订票务")]
+)
+
+class TicketServer(A2AServer):
+    def __init__(self):
+        super().__init__(agent_card=ticket_card)
+
+    def handle_task(self, task):
+        print("收到A2A任务的task:=>", task)
+        query = (task.message or {}).get("content", {}).get("text", "")
+        print(f"[{self.agent_card.name} 日志] 收到 A2A 任务: '{query}'")
+
+        if "上海" in query and "北京" in query:
+        # 这里的结果可以来自于 MCP 模块,这里我们直接模拟结果
+            # 本例只演示北京、上海两座城市,按请求中的先后顺序确定方向。
+            if query.index("北京") < query.index("上海"):
+                route = "北京到上海"
+            else:
+                route = "上海到北京"
+            train_result = f"模拟预订成功:{route},G1001,10车1A(不生成真实订单)"
+        else:
+            train_result = "请输入明确的出发地和目的地。"
+
+        print(f"[{self.agent_card.name} 日志] 返回结果: {train_result}")
+        task.artifacts = [{"parts": [{"type": "text", "text": train_result}]}]
+        if "上海" in query and "北京" in query:
+            task.status = TaskStatus(state=TaskState.COMPLETED)
+        else:
+            task.status = TaskStatus(state=TaskState.INPUT_REQUIRED)
+        print(f"[{self.agent_card.name} 日志] 任务处理完毕")
+        print(f"[{self.agent_card.name} 日志] 输出结果task: {task}")
+        print(f"[{self.agent_card.name} 日志] 输出结果task.artifacts: {task.artifacts}")
+        return task
+
+
+if __name__ == "__main__":
+    server = TicketServer()
+    print(f"[{server.agent_card.name}] 启动成功,服务地址: {server.agent_card.url}")
+    run_server(server, host="127.0.0.1", port=5009)

+ 52 - 0
agent_learn/A2A_base/a2a_serial/weather_agent.py

@@ -0,0 +1,52 @@
+import json
+from python_a2a import A2AServer, run_server, AgentCard, AgentSkill, TaskStatus, TaskState
+
+# A2A Agent 的名片
+agent_card = AgentCard(
+    name="WeatherAgentServer",
+    description="一个天气预报查询的专家 Agent",
+    url="http://127.0.0.1:5008",
+    skills=[AgentSkill(name="query", description="接受天气查询查询",examples=["天气北京"])]
+)
+
+
+class WeatherAgentServer(A2AServer):
+    def __init__(self):
+        super().__init__(agent_card=agent_card)
+
+    def handle_task(self, task):
+        print("收到A2A任务的task:=>", task)
+        query = (task.message or {}).get("content", {}).get("text", "")
+        print(f"[{self.agent_card.name} 日志] 收到 A2A 任务: '{query}'")
+        # 决策:如果查询包含“天气”,就调用 MCP 工具
+        if "天气" in query:
+            print(f"[{self.agent_card.name} 日志] 决策:任务需要天气数据,准备调用工具...")
+            try:
+                # 这里的结果可以来自于 MCP 模块,这里我们直接模拟结果
+                weather_result = {"温度": 30, "天气": "晴天"}
+                print(f"[{self.agent_card.name} 日志] 从 MCP 工具获得结果: '{weather_result}'")
+                # 将结果保存为任务 artifacts,artifacts是任务的输出结果
+                task.artifacts = [{"parts": [{"type": "text", "text": json.dumps(weather_result, ensure_ascii=False)}]}]
+            except Exception as e:
+                error_msg = f"调用 工具失败: {e}"
+                print(f"[{self.agent_card.name} 日志] {error_msg}")
+                task.artifacts = [{"parts": [{"type": "text", "text": error_msg}]}]
+                task.status = TaskStatus(state=TaskState.FAILED)
+                return task
+        else:
+            task.artifacts = [{"parts": [{"type": "text", "text": "无法理解的任务"}]}]
+
+        if "天气" in query:
+            task.status = TaskStatus(state=TaskState.COMPLETED)
+        else:
+            task.status = TaskStatus(state=TaskState.INPUT_REQUIRED)
+        print(f"[{self.agent_card.name} 日志] 任务处理完毕")
+        print(f"[{self.agent_card.name} 日志] 输出结果task: {task}")
+        print(f"[{self.agent_card.name} 日志] 输出结果task.artifacts: {task.artifacts}")
+        return task
+
+
+if __name__ == "__main__":
+    server = WeatherAgentServer()
+    print(f"[{server.agent_card.name}] 已启动,在 {server.agent_card.url}")
+    run_server(server, host="127.0.0.1", port=5008)

+ 12 - 0
agent_learn/A2A_base/concepts/01_agent_skill.py

@@ -0,0 +1,12 @@
+from python_a2a import  AgentSkill
+# 定义一个代理技能
+ticket_skill = AgentSkill(
+    name="book_ticket",
+    description="预订高铁票的技能",
+    examples=["预订从上海到北京的高铁票"],
+    input_modes=["text/plain"],
+    output_modes=["text/plain"]
+)
+
+print(ticket_skill)
+print(ticket_skill.to_dict())

+ 21 - 0
agent_learn/A2A_base/concepts/02_agent_card.py

@@ -0,0 +1,21 @@
+from python_a2a import AgentCard, AgentSkill
+# 创建一个代理技能
+ticket_skill = AgentSkill(
+    name="book_ticket",
+    description="预订高铁票的技能",
+    examples=["预订从上海到北京的高铁票"],
+    input_modes=["text/plain"],
+    output_modes=["text/plain"]
+)
+# 创建代理卡片
+agent_card = AgentCard(
+    name="TicketAgent",
+    description="一个可以预订票务的代理",
+    url="http://127.0.0.1:5009",
+    version="1.0.0",
+    skills=[ticket_skill],
+    capabilities={"streaming": True}
+)
+# 打印代理卡片的字典表示(用于序列化)
+print(agent_card)
+print(agent_card.to_dict())

+ 13 - 0
agent_learn/A2A_base/concepts/03_task.py

@@ -0,0 +1,13 @@
+from python_a2a import Task, Message, MessageRole, TextContent
+
+# 创建任务
+message = Message(content=TextContent(text="查询天气"), role=MessageRole.USER)
+task = Task(message=message.to_dict())
+print(task)
+
+# Task(
+# id='5e63778f-a912-4f07-a757-4b47614d0d1b', 
+# session_id='7211f559-d616-4f56-82d2-c74976f86390', 
+# status=TaskStatus(state=<TaskState.SUBMITTED: 'submitted'>, message=None, timestamp='2025-10-24T23:20:44.444053'), 
+# message={'content': {'text': '查询天气', 'type': <ContentType.TEXT: 'text'>}, 'role': 'user', 'message_id': '8a3c1015-0871-4886-b70d-cf1434ad1caf'}, 
+# history=[], artifacts=[], metadata={})

+ 7 - 0
agent_learn/A2A_base/concepts/04_task_state.py

@@ -0,0 +1,7 @@
+from python_a2a import TaskState  # 只需相关导入
+# 检查任务状态
+if TaskState.COMPLETED == "completed":
+    print("任务完成")
+state = TaskState.SUBMITTED
+print("转换后的状态值:", state.value)
+print(state)

+ 15 - 0
agent_learn/A2A_base/concepts/05_task_status.py

@@ -0,0 +1,15 @@
+from python_a2a import TaskStatus, TaskState
+
+status_completed = TaskStatus(
+    state=TaskState.COMPLETED,
+    message={"info": "任务成功完成"}
+)
+
+status_failed = TaskStatus(
+    state=TaskState.FAILED,
+    message={"error": "无法处理请求"}
+)
+
+# 打印字典表示
+print("完成状态:", status_completed.to_dict())
+print("失败状态:", status_failed.to_dict())

+ 26 - 0
agent_learn/A2A_base/concepts/06_a2a_server.py

@@ -0,0 +1,26 @@
+from python_a2a import A2AServer, run_server, AgentCard, AgentSkill, TaskStatus, TaskState
+
+# 定义代理卡片
+ticket_card = AgentCard(
+    name="TicketAgentServer",
+    description="票务代理",
+    url="http://127.0.0.1:5009",
+    skills=[AgentSkill(name="book_ticket", description="预订票务")]
+)
+
+# 自定义 A2AServer 子类
+class TicketServer(A2AServer):
+    def __init__(self):
+        super().__init__(agent_card=ticket_card)
+
+    def handle_task(self, task):
+        print(f"任务状态:{task.status.state}")
+        task.artifacts = [{"parts": [{"type": "text", "text": "已收到任务"}]}]
+        task.status = TaskStatus(state=TaskState.COMPLETED)
+        return task
+
+# 启动服务器
+if __name__ == "__main__":
+    server = TicketServer()
+    print(f"[{server.agent_card.name}] 创建服务成功")
+    run_server(server, host="127.0.0.1", port=5009, debug=False)

+ 46 - 0
agent_learn/A2A_base/concepts/07_artifacts_server.py

@@ -0,0 +1,46 @@
+from python_a2a import A2AServer, run_server, AgentCard, AgentSkill, TaskStatus, TaskState
+
+# 定义代理卡片
+ticket_card = AgentCard(
+    name="TicketAgentServer",
+    description="票务代理",
+    url="http://127.0.0.1:5010",
+    skills=[AgentSkill(name="book_ticket", description="预订票务")]
+)
+
+# 自定义 A2AServer 子类
+class TicketServer(A2AServer):
+    def __init__(self):
+        super().__init__(agent_card=ticket_card)
+
+    def handle_task(self, task):
+        print("收到A2A任务的task:=>", task)
+        #默认写法:获取任务内容
+        query = (task.message or {}).get("content", {}).get("text", "")
+
+        if "上海" in query and "北京" in query:
+        # 这里的结果可以来自于 MCP 模块,这里我们直接模拟结果
+            # 本例只演示北京、上海两座城市,按请求中的先后顺序确定方向。
+            if query.index("北京") < query.index("上海"):
+                route = "北京到上海"
+            else:
+                route = "上海到北京"
+            train_result = f"模拟预订成功:{route},G1001,10车1A(不生成真实订单)"
+        else:
+            train_result = "请输入明确的出发地和目的地。"
+
+        task.artifacts = [{"parts": [{"type": "text", "text": train_result}]}]
+        if "上海" in query and "北京" in query:
+            task.status = TaskStatus(state=TaskState.COMPLETED)
+        else:
+            task.status = TaskStatus(state=TaskState.INPUT_REQUIRED)
+        print(f"[{self.agent_card.name} 日志] 任务处理完毕")
+        print(f"[{self.agent_card.name} 日志] 输出结果task: {task}")
+        print(f"[{self.agent_card.name} 日志] 输出结果task.artifacts: {task.artifacts}")
+        return task
+
+# 启动服务器
+if __name__ == "__main__":
+    server = TicketServer()
+    print(f"[{server.agent_card.name}] 启动成功,服务地址: {server.agent_card.url}")
+    run_server(server, host="127.0.0.1", port=5010, debug=False)

+ 14 - 0
agent_learn/A2A_base/concepts/08_artifacts_client.py

@@ -0,0 +1,14 @@
+import asyncio
+from python_a2a import A2AClient
+
+async def main():
+    ticket_client = A2AClient("http://127.0.0.1:5010")
+
+    #预订高铁票
+    ticket_query = "预订一张从北京到上海的高铁票"
+    print(f"[主控客户端日志]预订票务 -> '{ticket_query}'")
+    ticket_result = ticket_client.ask(ticket_query)
+    print(f"[主控客户端日志] 收到票务预订结果: {ticket_result}")
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 10 - 0
agent_learn/A2A_base/concepts/09_agent_network.py

@@ -0,0 +1,10 @@
+from python_a2a import AgentNetwork
+network = AgentNetwork(name="MyNetwork")
+network.add("TicketAgent", "http://127.0.0.1:5010")
+
+print(f"agent network-->{network.agent_cards}")
+print('*'*80)
+
+# 调用
+client = network.get_agent("TicketAgent")
+print(client.ask("预订一张从北京到上海的高铁票"))

+ 26 - 0
agent_learn/A2A_base/concepts/10_agent_router.py

@@ -0,0 +1,26 @@
+from python_a2a import AIAgentRouter, AgentNetwork, Message, TextContent, MessageRole
+from langchain_openai import ChatOpenAI
+from agent_learn.config import Config
+
+conf=Config()
+
+# 创建网络
+network = AgentNetwork(name="MyNetwork")
+network.add("TicketAgent", "http://127.0.0.1:5010")
+
+# 创建模型
+llm = ChatOpenAI(base_url=conf.base_url,
+                 api_key=conf.api_key,
+                 model=conf.model_name,
+                 temperature=0.1)
+
+# 用一个小适配类,把 A2A 的 send_message 转成 LangChain 的 invoke。
+class LangChainRouterClient:
+    def send_message(self, message):
+        answer = llm.invoke(message.content.text)
+        return Message(content=TextContent(text=answer.content), role=MessageRole.AGENT)
+
+# 创建路由器
+router = AIAgentRouter(llm_client=LangChainRouterClient(), agent_network=network)
+agent_name, confidence = router.route_query("预订票")
+print(agent_name, confidence)

+ 1 - 0
agent_learn/A2A_base/concepts/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 1 - 0
agent_learn/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 69 - 0
agent_learn/agent_types/C01_ToolUsePattern.py

@@ -0,0 +1,69 @@
+from langchain_core.prompts import ChatPromptTemplate
+from langchain_openai import ChatOpenAI
+from langchain_core.tools import tool
+from langchain.agents import AgentExecutor, create_tool_calling_agent, create_react_agent
+from agent_learn.config import Config
+
+conf = Config()
+
+# 1.创建模型
+llm = ChatOpenAI(base_url=conf.base_url,
+                 api_key=conf.api_key,
+                 model=conf.model_name,
+                 temperature=0.1)
+
+# 2.定义工具
+@tool
+def multiply(a: int, b: int) -> int:
+    """用于计算两个整数的乘积。"""
+    print(f"正在执行乘法: {a} * {b}")
+
+    return a * b
+
+@tool
+def search_weather(city: str) -> str:
+    """返回指定城市的教学模拟天气,不查询实时数据。"""
+    print(f"正在查询天气: {city}")
+    if "北京" in city:
+        return "北京今天是晴天,气温25摄氏度。"
+    elif "上海" in city:
+        return "上海今天是阴天,有小雨,气温22摄氏度。"
+    else:
+        return f"抱歉,我没有'{city}'的天气信息。"
+
+# 将工具列表放入一个变量
+tools = [multiply, search_weather]
+
+
+# 3.定义一个提示模板,用于控制Agent的思考过程和工具调用
+tool_use_prompt = ChatPromptTemplate.from_messages([
+    ("system", "你是一个强大的AI助手,可以访问和使用各种工具来回答问题。请根据用户的问题,决定是否需要调用工具。当需要调用工具时,请使用正确的JSON格式。"),
+    ("user", "{input}"),
+    ("placeholder", "{agent_scratchpad}") # 这个占位符保存工具调用与结果消息
+])
+
+# 4.创建一个 LLM 能够识别和使用的 Agent
+# 使用 create_tool_calling_agent 函数,它能让 LLM 自动判断何时以及如何调用工具
+tool_calling_agent = create_tool_calling_agent(llm, tools, tool_use_prompt)
+
+# 5.创建 Agent Executor
+# AgentExecutor 负责 Agent 和工具之间的协调
+tool_use_executor = AgentExecutor(
+    agent=tool_calling_agent,
+    tools=tools,
+    verbose=True  # 开启 verbose 模式,可以打印详细的执行过程
+)
+
+# 6.通用的执行函数,用于运行agent并打印结果
+def run_agent_and_print(agent_executor, query):
+    """一个通用函数,用于运行Agent并打印结果。"""
+    print(f"--- 运行Agent,查询: {query} ---")
+    response = agent_executor.invoke({"input": query})
+    print(f"\n--- Agent响应: ---")
+    print(response.get("output", "没有找到输出。"))
+    print("-" * 30 + "\n")
+
+
+if __name__ == "__main__":
+    run_agent_and_print(tool_use_executor, "上海今天的天气怎么样?")
+    run_agent_and_print(tool_use_executor, "30乘以5等于多少? 上海天气怎么样")

+ 106 - 0
agent_learn/agent_types/C02_ReActPattern.py

@@ -0,0 +1,106 @@
+from langchain_openai import ChatOpenAI
+from langchain_core.tools import tool
+from langchain_core.prompts import ChatPromptTemplate
+from langchain.agents import AgentExecutor, create_react_agent
+from agent_learn.config import Config
+
+conf = Config()
+
+# 1.创建模型
+llm = ChatOpenAI(base_url=conf.base_url,
+                 api_key=conf.api_key,
+                 model=conf.model_name,
+                 temperature=0.1)
+
+# 2.定义工具
+# 关键修改:重写 multiply 工具,使其只接受一个字符串参数,并在内部解析。
+@tool
+def multiply(numbers_str: str) -> int | str:
+    """用于计算两个整数的乘积。
+
+    参数:
+        numbers_str (str): 包含两个整数的字符串,用逗号分隔,例如:"100,25"。
+    返回:
+        int: 两个整数的乘积。
+    """
+    print(f"正在执行乘法: {numbers_str}")
+    try:
+        a_str, b_str = numbers_str.split(',')
+        a = int(a_str.strip())
+        b = int(b_str.strip())
+        return a * b
+    except ValueError:
+        return "输入的格式不正确,请确保是两个用逗号分隔的整数,例如:'100,25'"
+
+@tool
+def search_weather(city: str) -> str:
+    """返回指定城市的教学模拟天气,不查询实时数据。"""
+    print(f"正在查询天气: {city}")
+    if "北京" in city:
+        return "北京今天是晴天,气温25摄氏度。"
+    elif "上海" in city:
+        return "上海今天是阴天,有小雨,气温22摄氏度。"
+    else:
+        return f"抱歉,我没有'{city}'的天气信息。"
+
+# 将工具列表放入一个变量
+tools = [multiply, search_weather]
+
+# 3.自定义 ReAct 风格的 Prompt
+react_prompt_template = """你是一个有用的 AI 助手,可以访问以下工具:
+
+{tools}
+
+请根据用户输入一步步推理,并按以下规则操作:
+1. 每次输出只能包含一个动作(Action 和 Action Input)或一个最终答案(Final Answer)。
+2. 如果用户输入包含多个任务,依次处理每个任务,不要一次性输出所有步骤。
+3. 每次行动前,说明你的思考(Thought),并选择合适的工具或直接给出最终答案。
+4. 如果需要使用工具,格式必须为:
+   Thought: [你的思考]
+   Action: [工具名称]
+   Action Input: [工具的输入参数,例如对于multiply工具,使用'100,25'格式]
+5. 如果可以直接回答或所有任务都完成,格式为:
+   Thought: [你的思考]
+   Final Answer: [最终答案]
+
+可用的工具名称有: {tool_names}
+
+用户输入: {input}
+{agent_scratchpad}
+"""
+
+react_prompt = ChatPromptTemplate.from_template(react_prompt_template)
+
+# 4.创建 ReAct 风格的 Agent
+react_agent = create_react_agent(llm, tools, react_prompt)
+
+# 5.创建 Agent Executor
+react_executor = AgentExecutor(
+    agent=react_agent,
+    tools=tools,
+    verbose=True,
+    handle_parsing_errors=True  # 启用错误处理,自动重试解析错误
+)
+
+# 6: 运行并测试 Agent
+if __name__ == "__main__":
+    # 测试用例1: 查询天气
+    print("--- 运行Agent,查询: 上海今天的天气怎么样? ---")
+    response_weather = react_executor.invoke({"input": "上海今天的天气怎么样?"})
+    print(f"\n--- Agent响应: ---")
+    print(response_weather.get("output", "没有找到输出。"))
+    print("-" * 30 + "\n")
+
+    # 测试用例2: 数学计算
+    print("--- 运行Agent,查询: 100乘以25等于多少? ---")
+    response_math = react_executor.invoke({"input": "100乘以25等于多少?"})
+    print(f"\n--- Agent响应: ---")
+    print(response_math.get("output", "没有找到输出。"))
+    print("-" * 30 + "\n")
+
+    # 测试用例3: 包含多个任务
+    print("--- 运行Agent,查询: 100乘以25等于多少? 上海的天气如何? ---")
+    response_multi = react_executor.invoke({"input": "100乘以25等于多少? 上海的天气如何?"})
+    print(f"\n--- Agent响应: ---")
+    print(response_multi.get("output", "没有找到输出。"))
+    print("-" * 30 + "\n")

+ 71 - 0
agent_learn/agent_types/C03_ReflectionPattern.py

@@ -0,0 +1,71 @@
+from langchain_openai import ChatOpenAI
+from langchain_core.prompts import ChatPromptTemplate
+from langchain_core.output_parsers import StrOutputParser
+from agent_learn.config import Config
+
+conf = Config()
+
+# 1.创建模型
+llm = ChatOpenAI(base_url=conf.base_url,
+                 api_key=conf.api_key,
+                 model=conf.model_name,
+                 temperature=0.1)
+
+# 3.初始响应 Prompt: 用于生成第一次的回答
+initial_response_prompt = ChatPromptTemplate.from_template(
+    "请根据以下问题给出你的初步回答: {question}"
+)
+initial_response_chain = initial_response_prompt | llm | StrOutputParser()
+
+# 4.反思 Prompt: 用于接收用户反馈并优化回答
+reflection_prompt = ChatPromptTemplate.from_template(
+    """你是一个专业的、善于反思的AI助手。你之前给出了以下回答:
+---
+{previous_response}
+---
+现在,你收到了用户对你的回答给出的反馈:
+---
+{user_feedback}
+---
+请根据用户的反馈,认真反思你之前的回答,并生成一个更准确、更完善的新回答。
+新回答:"""
+)
+reflection_chain = reflection_prompt | llm | StrOutputParser()
+
+
+# 5.模拟反馈改写过程
+def reflect_and_refine(query: str, feedback: str):
+    """模拟一轮反馈改写过程,从初始响应到优化后的响应。"""
+
+    print("--- 启动反思模式 ---")
+    print(f"用户查询: {query}")
+
+    # LLM 生成初步响应
+    print("\n生成初步响应...")
+    initial_response = initial_response_chain.invoke({"question": query})
+    print(f"LLM 初步响应:\n{initial_response}")
+
+    # 模拟用户反馈
+    print(f"\n用户反馈:\n{feedback}")
+
+    # LLM 进行反思,并生成新的回答
+    print("\nLLM 正在反思并生成新响应...")
+    refined_response = reflection_chain.invoke({
+        "previous_response": initial_response,
+        "user_feedback": feedback
+    })
+
+    print("\n--- LLM 经过反思后的新响应 ---")
+    print(refined_response)
+
+    return refined_response
+
+
+# 6.运行并测试
+if __name__ == "__main__":
+    # 模拟用户查询
+    initial_question = "请用一句话介绍一下 LangChain。"
+    # 模拟用户反馈,指出初步回答的不足
+    user_feedback_text = "你的回答太简单了,请更详细地解释一下 LangChain 的核心概念,比如 Agent 和 Chain 的区别。"
+    # 运行反射过程
+    reflect_and_refine(initial_question, user_feedback_text)

+ 129 - 0
agent_learn/agent_types/C04_PlanningPattern.py

@@ -0,0 +1,129 @@
+from langchain_openai import ChatOpenAI
+from langchain_core.tools import tool
+from langchain_core.prompts import ChatPromptTemplate
+from langchain_core.output_parsers import StrOutputParser
+from langchain.agents import AgentExecutor, create_react_agent
+from agent_learn.config import Config
+
+conf = Config()
+
+# 1.创建模型
+llm = ChatOpenAI(base_url=conf.base_url,
+                 api_key=conf.api_key,
+                 model=conf.model_name,
+                 temperature=0.1)
+
+
+# 2.定义工具
+@tool
+def multiply(numbers_str: str) -> int | str:
+    """用于计算两个整数的乘积。
+
+    参数:
+        numbers_str (str): 包含两个整数的字符串,用逗号分隔,例如:"100,25"。
+    返回:
+        int: 两个整数的乘积。
+    """
+    print(f"正在执行乘法: {numbers_str}")
+    try:
+        a_str, b_str = numbers_str.split(',')
+        a = int(a_str.strip())
+        b = int(b_str.strip())
+        return a * b
+    except ValueError:
+        return "输入的格式不正确,请确保是两个用逗号分隔的整数,例如:'100,25'"
+
+
+@tool
+def search_weather(city: str) -> str:
+    """返回指定城市的教学模拟天气,不查询实时数据。"""
+    print(f"正在查询天气: {city}")
+    if "北京" in city:
+        return "北京今天是晴天,气温25摄氏度。"
+    elif "上海" in city:
+        return "上海今天是阴天,有小雨,气温22摄氏度。"
+    else:
+        return f"抱歉,我没有'{city}'的天气信息。"
+
+
+# 将工具列表放入一个变量
+tools = [multiply, search_weather]
+
+# 3.定义规划器 (Planner) 和执行者 (Executor) 的 Prompt
+# 3.1 规划器的 Prompt
+# 规划器的职责是分析用户任务,并将其分解成一系列简单的、可执行的子任务。
+planner_prompt = ChatPromptTemplate.from_template(
+    """你是一个任务规划师,你的工作是将用户提出的一个复杂任务分解成一系列清晰、可执行的步骤。
+    你的输出应该是一个简单的任务列表,每行一个任务。
+
+    例子:
+    用户任务: "请先查上海的天气,然后计算20乘以30。"
+    任务列表:
+    - 查找上海的天气
+    - 计算20乘以30的结果
+
+    用户任务: {user_input}
+    任务列表:
+    """
+)
+# 规划器链,它只负责生成文本化的任务列表
+planner_chain = planner_prompt | llm | StrOutputParser()
+
+# 3.2 执行者的 Prompt
+# 执行者的职责是执行单个任务。在这里,我们使用 ReAct 模式作为执行者,因为它能根据任务描述选择并调用正确的工具。
+# 注意:我们使用一个简化版的 ReAct Prompt,因为它只需要处理单个任务。
+executor_react_prompt_template = """你是一个专业的工具执行者,可以访问以下工具:
+
+{tools}
+
+根据你的思考(Thought)决定下一步的行动(Action)。你的行动必须遵循以下格式:
+Thought: 我需要思考如何完成任务。
+Action: [工具名称]
+Action Input: [工具的输入参数,对于multiply工具,请使用'100,25'这样的格式]
+
+可用的工具名称有: {tool_names}
+
+当你获取了所有必要信息并可以给出最终答案时,请以以下格式结束:
+Thought: 我已经有了最终答案。
+Final Answer: [最终答案]
+
+请执行以下任务:
+{input}
+{agent_scratchpad}
+"""
+executor_prompt = ChatPromptTemplate.from_template(executor_react_prompt_template)
+
+# 4.创建 ReAct Agent 作为执行者
+executor_agent = create_react_agent(llm, tools, executor_prompt)
+executor_executor = AgentExecutor(
+    agent=executor_agent,
+    tools=tools,
+    verbose=True,
+    handle_parsing_errors=True  # 启用错误处理,自动重试解析错误
+)
+
+
+# 5.定义并运行规划模式的工作流
+def execute_planning_pattern(query: str):
+    print("--- 启动规划模式 ---")
+
+    # 规划器分解任务
+    print("\n规划器正在分解任务...")
+    plan = planner_chain.invoke({"user_input": query})
+    tasks = [task.strip() for task in plan.split('\n') if task.strip()]
+    print("规划器生成的任务列表:")
+    for i, task in enumerate(tasks):
+        print(f"  {i + 1}. {task}")
+
+    # 执行者逐一执行任务
+    print("\n执行者正在逐一执行任务...")
+    for i, task in enumerate(tasks):
+        print(f"\n--- 执行任务 {i + 1}: {task} ---")
+        executor_executor.invoke({"input": task})
+
+    print("\n--- 所有任务执行完毕!---")
+
+
+if __name__ == "__main__":
+    test_query = "请先计算 50 乘以 60 的结果,然后告诉我上海的天气怎么样?"
+    execute_planning_pattern(test_query)

+ 140 - 0
agent_learn/agent_types/C05_MultiAgent.py

@@ -0,0 +1,140 @@
+from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder, SystemMessagePromptTemplate, \
+    HumanMessagePromptTemplate  # 导入所有必需的 Prompt 类
+from langchain_openai import ChatOpenAI
+from langchain_core.tools import tool
+from langchain.agents import AgentExecutor, create_tool_calling_agent
+from langchain_core.output_parsers import StrOutputParser
+from agent_learn.config import Config
+
+conf = Config()
+
+# 1.创建模型
+llm = ChatOpenAI(base_url=conf.base_url,
+                 api_key=conf.api_key,
+                 model=conf.model_name,
+                 temperature=0.1)
+
+
+# 2.定义工具
+# 2.1 计算工具
+@tool
+def multiply(a: int, b: int) -> int:
+    """用于计算两个整数的乘积。
+
+    参数:
+        a (int): 第一个整数。
+        b (int): 第二个整数。
+    """
+    print(f"\n[计算专家] -> 正在执行乘法: {a} * {b}")
+    return a * b
+
+
+@tool
+def add(a: int, b: int) -> int:
+    """用于计算两个整数的和。
+
+    参数:
+        a (int): 第一个整数。
+        b (int): 第二个整数。
+    """
+    print(f"\n[计算专家] -> 正在执行加法: {a} + {b}")
+    return a + b
+
+
+# 2.2 信息查询工具
+@tool
+def search_weather(city: str) -> str:
+    """返回指定城市的教学模拟天气,不查询实时数据。"""
+    print(f"正在查询天气: {city}")
+    if "北京" in city:
+        return "北京今天是晴天,气温25摄氏度。"
+    elif "上海" in city:
+        return "上海今天是阴天,有小雨,气温22摄氏度。"
+    else:
+        return f"抱歉,我没有'{city}'的天气信息。"
+
+
+@tool
+def get_current_date() -> str:
+    """用于获取当前日期。"""
+    print("\n[信息专家] -> 正在获取当前日期...")
+    import datetime
+    return datetime.date.today().strftime("%Y年%m月%d日")
+
+
+# 3 创建两个独立的 Agent
+# 3.1 创建“计算专家” Agent
+math_tools = [multiply, add]
+# 创建完整的 Tool Calling Prompt
+# 这包括一个系统消息,一个用户消息占位符,以及一个工具调用与结果消息的占位符。
+math_prompt = ChatPromptTemplate.from_messages([
+    SystemMessagePromptTemplate.from_template("你是一个强大的数学计算专家,可以访问和使用各种数学工具。"),
+    HumanMessagePromptTemplate.from_template("{input}"),
+    MessagesPlaceholder(variable_name="agent_scratchpad")
+])
+# 创建 math_Agent
+math_agent = create_tool_calling_agent(llm, math_tools, math_prompt)
+# 创建 math Agent Executor
+math_executor = AgentExecutor(
+    agent=math_agent,
+    tools=math_tools,
+    verbose=True
+)
+
+# 3.2 创建“信息专家” Agent
+info_tools = [search_weather, get_current_date]
+# 手动创建完整的 Tool Calling Prompt
+info_prompt = ChatPromptTemplate.from_messages([
+    SystemMessagePromptTemplate.from_template("你是一个强大的信息查询专家,可以访问和使用各种查询工具。"),
+    HumanMessagePromptTemplate.from_template("{input}"),
+    MessagesPlaceholder(variable_name="agent_scratchpad")
+])
+# 创建 info Agent
+info_agent = create_tool_calling_agent(llm, info_tools, info_prompt)
+# 创建 info Agent Executor
+info_executor = AgentExecutor(
+    agent=info_agent,
+    tools=info_tools,
+    verbose=True
+)
+
+# 4.协调和总结工作流
+def multi_agent_workflow(query: str, math_task: str, info_task: str):
+    print("--- 启动多智能体协作流程 ---")
+    print(f"\n用户原始请求: {query}")
+
+    # 4.1 让“计算专家”执行任务
+    print("\n[主程序] -> 将任务分配给计算专家...")
+    math_result = math_executor.invoke({"input": math_task}).get("output")
+    print(f"\n[主程序] -> 计算专家返回结果: {math_result}")
+
+    # 4.2 让“信息专家”执行任务
+    print("\n[主程序] -> 将任务分配给信息专家...")
+    info_result = info_executor.invoke({"input": info_task}).get("output")
+    print(f"\n[主程序] -> 信息专家返回结果: {info_result}")
+
+    # 4.3 使用 LLM 进行最终结果总结
+    print("\n[主程序] -> 使用大模型进行最终总结...")
+    summarize_prompt = ChatPromptTemplate.from_messages([
+        ("system", "你是一个善于总结和整合信息的助手。请根据以下信息,为用户原始请求生成一个完整的回答。"),
+        ("human",
+         "用户请求: {query}\n\n计算结果: {math_result}\n\n信息查询结果: {info_result}\n\n请整合以上信息,生成一个连贯的最终回答。")
+    ])
+    summarize_chain = summarize_prompt | llm | StrOutputParser()
+    final_answer = summarize_chain.invoke({
+        "query": query, "math_result": math_result, "info_result": info_result
+    })
+
+    print("\n--- 协作流程已完成!---")
+    print(f"最终综合回答:\n{final_answer}")
+    return final_answer
+
+
+if __name__ == "__main__":
+    # 定义用户原始请求和分配给每个Agent的子任务
+    original_query = "请先计算 25 乘以 4,然后告诉我北京今天的天气和当前日期。"
+    math_task = "计算 25 乘以 4"
+    info_task = "查询北京今天的天气和当前日期"
+
+    # 启动工作流
+    multi_agent_workflow(original_query, math_task, info_task)

+ 1 - 0
agent_learn/agent_types/__init__.py

@@ -0,0 +1 @@
+# 五种 Agent 模式的独立教学示例。

+ 13 - 0
agent_learn/config.py

@@ -0,0 +1,13 @@
+from pathlib import Path  # 导入 Path,供本文件使用
+import os  # 导入 os,用于环境变量及进程信息
+from dotenv import load_dotenv  # 导入 load_dotenv,供本文件使用
+
+load_dotenv(Path(__file__).resolve().parents[1] / ".env")  # 读取项目已有 .env;不覆盖已设置的系统环境变量
+
+class Config:  # 定义 Config 类,封装本例需要的服务或适配逻辑
+    def __init__(self):  # 定义函数:初始化当前对象,并按需调用父类初始化
+        self.base_url = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")  # 读取已有 DeepSeek 接口地址,未配置时使用默认地址
+        self.api_key = os.getenv("DEEPSEEK_API_KEY")  # 从环境读取密钥,不把密钥写入源码或日志
+        self.model_name = os.getenv("DEEPSEEK_MODEL", "deepseek-v4-flash")  # 读取项目模型名称,与主项目的默认值保持一致
+        if not self.api_key:  # 判断 not self.api_key,满足时进入此分支
+            raise RuntimeError("请在项目根目录 .env 中配置 DEEPSEEK_API_KEY;本示例不内置密钥。")  # 报告明确的错误,避免失败被误当作成功

+ 1 - 0
agent_learn/mcp_base/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 1 - 0
agent_learn/mcp_base/python_a2a/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 82 - 0
agent_learn/mcp_base/python_a2a/client_agent.py

@@ -0,0 +1,82 @@
+import asyncio
+import json
+import logging
+from langchain.agents import create_tool_calling_agent, AgentExecutor
+from langchain_core.prompts import ChatPromptTemplate
+from langchain_openai import ChatOpenAI
+from python_a2a.mcp import MCPClient
+from langchain_core.tools import tool
+from agent_learn.config import Config
+
+conf = Config()
+
+# 配置日志
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+logger = logging.getLogger(__name__)
+
+# 创建模型
+llm = ChatOpenAI(base_url=conf.base_url,
+                 api_key=conf.api_key,
+                 model=conf.model_name,
+                 temperature=0.1)
+
+async def test_mcp_tools():
+    # 连接到服务端,端口 8010
+    client = MCPClient("http://localhost:8010")
+    try:
+        # 步骤 1:获取可用工具列表
+        tools = await client.get_tools()
+        logger.info("可用工具列表:")
+        for tool_info in tools:
+            print(tool_info)
+            logger.info(f"- {tool_info.get('name', '未知')}: {tool_info.get('description', '无描述')}")
+
+        # 用两个无参数函数,把 MCP 工具交给 Agent 使用
+        @tool("get_weather")
+        async def get_weather_tool() -> str:
+            """获取教学示例中的北京天气,不查询实时天气。"""
+            return await client.call_tool("get_weather")
+
+        @tool("query_high_frequency_question")
+        async def query_high_frequency_question_tool() -> str:
+            """获取教学示例中的高频问题和答案。"""
+            return await client.call_tool("query_high_frequency_question")
+
+        tools = [get_weather_tool, query_high_frequency_question_tool]
+
+        # 创建prompt模板
+        prompt_template = ChatPromptTemplate.from_messages([
+            ("system", "你是一个乐于助人的助手,能够调用工具回答用户问题。工具不需要传参。"),
+            ("human", "{input}"),
+            ("placeholder", "{agent_scratchpad}"),
+        ])
+
+        # 构建工具调用代理
+        agent = create_tool_calling_agent(llm, tools, prompt_template)
+
+        # 创建代理执行器
+        agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
+
+        # 代理调用
+        print("MCP客户端启动,输入'quit'退出")
+        while True:
+            # 接收用户查询
+            query = input("\nQuery: ").strip()
+            if query.lower() == "quit":
+                break
+            # 发送用户查询给代理,并打印
+            try:
+                response = await agent_executor.ainvoke({"input": query})
+                print(f"response-->{response}")
+            except Exception as e:
+                print(f"工具调用失败:{e}")
+    except Exception as e:
+        logger.error(f"MCP 客户端出错:{str(e)}", exc_info=True)
+    finally:
+        await client.close()
+
+async def main():
+    await test_mcp_tools()
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 38 - 0
agent_learn/mcp_base/python_a2a/client_raw.py

@@ -0,0 +1,38 @@
+import asyncio
+import logging
+from python_a2a.mcp import MCPClient
+
+# 配置日志
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+logger = logging.getLogger(__name__)
+
+async def test_mcp_tools():
+    # 连接到服务端,端口 8010
+    client = MCPClient("http://localhost:8010")
+    try:
+        # 步骤 1:获取可用工具列表
+        tools = await client.get_tools()
+        logger.info("可用工具列表:")
+        for tool in tools:
+            print(tool)
+            logger.info(f"- {tool.get('name', '未知')}: {tool.get('description', '无描述')}")
+
+        # 步骤 2:调用查询高频问题工具
+        result_qhf = await client.call_tool("query_high_frequency_question")
+        logger.info(f"高频问题查询结果:{result_qhf}")
+
+        # 步骤 3:调用查询天气工具
+        result_weather = await client.call_tool("get_weather")
+        logger.info(f"天气查询结果:{result_weather}")
+
+    except Exception as e:
+        logger.error(f"MCP 客户端出错:{str(e)}", exc_info=True)
+    finally:
+        await client.close()
+
+async def main():
+    await test_mcp_tools()
+
+
+if __name__ == "__main__":
+    asyncio.run(main())

+ 66 - 0
agent_learn/mcp_base/python_a2a/server_a2a.py

@@ -0,0 +1,66 @@
+import logging
+import uvicorn
+from python_a2a.mcp import FastMCP
+from python_a2a.mcp import create_fastapi_app
+
+# 配置日志,方便调试
+logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
+logger = logging.getLogger(__name__)
+
+# 创建 MCP 服务器实例
+mcp = FastMCP(
+    name="MyMCPTools",
+    description="提供高频问题和天气查询工具",
+    version="1.0.0"
+)
+
+# 定义工具 1:查询高频问题
+@mcp.tool(
+    name="query_high_frequency_question",
+    description="获取知识库中的高频问答,返回 JSON 数据",
+)
+async def query_high_frequency_question() -> str:
+    """
+    query_high_frequency_question 不需要任何传参
+    查询高频问题并返回答案
+    返回示例:[{"question_id": 1, "question_text": "恐龙怎么灭绝", "answer_text": "小行星撞击", ...}]
+    """
+    try:
+        logger.info("调用查询高频问题的工具(无参数)")
+        return '{"status": "success", "data": [{"question_id": 1, "question_text": "恐龙是怎么灭绝的?", "answer_text": "可能是小行星撞击", "category": "历史", "frequency_score": 0.9}]}'
+    except Exception as e:
+        logger.error(f"查询高频问题出错: {str(e)}")
+        raise
+
+# 定义工具 2:查询天气
+@mcp.tool(
+    name="get_weather",
+    description="查询天气",
+)
+async def get_weather() -> str:
+    """
+    get_weather 不需要任何传参
+    查询天气并返回结果
+    返回示例:{"status": "success", "data": "北京的天气是多云"}
+    """
+    try:
+        logger.info("调用查询天气的工具(无参数)")
+        return '{"status": "success", "data": "北京的天气是多云"}'
+    except Exception as e:
+        logger.error(f"查询天气出错: {str(e)}")
+        raise
+
+# 启动服务器
+def start_server():
+    logger.info("=== MCP 服务器信息 ===")
+    logger.info(f"名称: {mcp.name}")
+    logger.info(f"描述: {mcp.description}")
+
+    port = 8010
+    app = create_fastapi_app(mcp)
+    logger.info(f"启动 MCP 服务器于 http://localhost:{port}")
+    uvicorn.run(app, host="0.0.0.0", port=port)
+
+
+if __name__ == "__main__":
+    start_server()

+ 1 - 0
agent_learn/mcp_base/sse/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 69 - 0
agent_learn/mcp_base/sse/client_agent.py

@@ -0,0 +1,69 @@
+import json
+import asyncio
+from langchain_openai import ChatOpenAI
+from mcp import ClientSession
+from mcp.client.sse import sse_client
+from langchain_mcp_adapters.tools import load_mcp_tools
+from langchain.agents import create_tool_calling_agent, AgentExecutor
+from langchain_core.prompts import ChatPromptTemplate
+from agent_learn.config import Config
+
+conf = Config()
+
+# 创建模型
+llm = ChatOpenAI(base_url=conf.base_url,
+                 api_key=conf.api_key,
+                 model=conf.model_name,
+                 temperature=0.1)
+
+# MCP server URL for SSE connection
+server_url = "http://localhost:8001/sse"
+
+# 定义mcp客户端
+mcp_client = None
+
+# Main async function: connect, load tools, create agent, run chat loop
+async def run_agent():
+    global mcp_client
+    # 启动 MCP server,通过 SSE 建立异步连接。
+    async with sse_client(url=server_url) as streams:
+        # 使用读写通道创建 MCP 会话
+        async with ClientSession(*streams) as session:
+            await session.initialize()
+            # 动态创建一个临时类 MCPClientHolder,把 session 放进去。这样就可以在函数外部通过 mcp_client.session 调用 MCP 工具
+            mcp_client = type("MCPClientHolder", (), {"session": session})()
+
+            # 从 session 自动获取 MCP server 提供的工具列表。
+            tools = await load_mcp_tools(session)
+            # print(f"tools-->{tools}")
+
+            # 创建prompt模板
+            prompt_template = ChatPromptTemplate.from_messages([
+                ("system", "你是一个乐于助人的助手,能够调用工具回答用户问题。"),
+                ("human", "{input}"),
+                ("placeholder", "{agent_scratchpad}"),
+            ])
+
+            # 构建工具调用代理
+            agent = create_tool_calling_agent(llm, tools, prompt_template)
+
+            # 创建代理执行器
+            agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
+
+            # 代理调用
+            print("MCP客户端启动,输入'quit'退出")
+            while True:
+                # 接收用户查询
+                query = input("\nQuery: ").strip()
+                if query.lower() == "quit":
+                    break
+                # 发送用户查询给代理,并打印
+                try:
+                    response = await agent_executor.ainvoke({"input": query})
+                    print(f"response-->{response}")
+                except Exception:
+                    print("解析有问题")
+    return
+
+if __name__ == "__main__":
+    asyncio.run(run_agent())

+ 34 - 0
agent_learn/mcp_base/sse/client_raw.py

@@ -0,0 +1,34 @@
+import asyncio
+from mcp import ClientSession
+from mcp.client.sse import sse_client
+from langchain_mcp_adapters.tools import load_mcp_tools
+
+# MCP server URL for SSE connection
+server_url = "http://localhost:8001/sse"
+
+# 定义mcp客户端
+mcp_client = None
+
+# 主要的异步函数run_agent
+async def run():
+    global mcp_client
+    # 启动 MCP server,通过 SSE 建立异步连接。
+    async with sse_client(url=server_url) as streams:
+        # 使用读写通道创建 MCP 会话
+        async with ClientSession(*streams) as session:
+            await session.initialize()
+            # 动态创建一个临时类 MCPClientHolder,把 session 放进去。这样就可以在函数外部通过 mcp_client.session 调用 MCP 工具
+            mcp_client = type("MCPClientHolder", (), {"session": session})()
+
+            # 从 session 自动获取 MCP server 提供的工具列表。
+            tools = await load_mcp_tools(session)
+            # print(f"tools-->{tools}")
+
+            # 调用 MCP server 的 get_weather 工具
+            response=await session.call_tool("get_weather", arguments={})
+            print(f"response-->{response}")
+
+
+# 启动运行agent
+if __name__ == "__main__":
+    asyncio.run(run())

+ 53 - 0
agent_learn/mcp_base/sse/server_sse.py

@@ -0,0 +1,53 @@
+from mcp.server.fastmcp import FastMCP
+
+
+# 在创建FastMCP实例时指定host和port
+mcp = FastMCP("sdg", log_level="ERROR", host="127.0.0.1", port=8001)
+
+@mcp.tool(
+    name="query_high_frequency_question",
+    description="从知识库中检索常见问题解答(FAQ),返回包含问题和答案的结构化JSON数据。",
+)
+async def query_high_frequency_question() -> str:
+    """
+    高频问题查询
+    """
+    try:
+        print("调用查询高频问题的tool成功!!")
+        return "高频问题是: 恐龙是怎么灭绝的?"
+    except Exception as e:
+        print(f"Unexpected error in question retrieval: {str(e)}")
+        raise
+
+@mcp.tool(
+    name="get_weather",
+    description="查询天气"
+)
+async def get_weather() -> str:
+    """
+    查询天气的tools
+    """
+    try:
+        print("调用查询天气的tools")
+        return "北京的天气是多云"
+    except Exception as e:
+        print(f"Unexpected error in question retrieval: {str(e)}")
+        raise
+
+
+def main():
+    print("正在启动MCP SSE服务器...")
+    print("SSE端点: http://localhost:8001/sse")
+    print("按 Ctrl+C 停止服务器")
+
+    try:
+        # 运行SSE服务器
+        mcp.run(transport="sse")
+    except KeyboardInterrupt:
+        print("\n服务器已停止")
+    except Exception as e:
+        print(f"服务器启动失败: {e}")
+
+
+if __name__ == "__main__":
+    main()

+ 1 - 0
agent_learn/mcp_base/stdio/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 79 - 0
agent_learn/mcp_base/stdio/client_agent.py

@@ -0,0 +1,79 @@
+from pathlib import Path
+import os
+import sys
+sys.path.append(os.path.join(os.path.dirname(__file__), "../../.."))
+import asyncio
+from langchain_mcp_adapters.tools import load_mcp_tools
+from langchain_openai import ChatOpenAI
+from langchain.agents import create_tool_calling_agent, AgentExecutor
+from langchain_core.prompts import ChatPromptTemplate
+from mcp import ClientSession, StdioServerParameters
+from mcp.client.stdio import stdio_client
+from agent_learn.config import Config
+
+conf = Config()
+
+# 创建模型
+llm = ChatOpenAI(base_url=conf.base_url,
+                 api_key=conf.api_key,
+                 model=conf.model_name,
+                 temperature=0.1)
+
+# 配置mcp服务器脚本路径
+server_script = str(Path(__file__).with_name("server_stdio.py").resolve())
+
+# 配置mcp服务器启动参数
+server_params = StdioServerParameters(
+    command=sys.executable,
+    args=[server_script],
+)
+
+# 定义mcp客户端
+mcp_client = None
+
+# 主要的异步函数run_agent
+async def run_agent():
+    global mcp_client
+    # 启动 MCP server,并通过标准输入输出建立异步连接。
+    async with stdio_client(server_params) as (read, write):
+        # 使用读写通道创建 MCP 会话。
+        async with ClientSession(read, write) as session:
+            # 初始化会话
+            await session.initialize()
+            # 动态创建一个临时类 MCPClientHolder,把 session 放进去。这样就可以在函数外部通过 mcp_client.session 调用 MCP 工具
+            mcp_client = type("MCPClientHolder", (), {"session": session})()
+
+            # 从 session 自动获取 MCP server 提供的工具列表
+            tools = await load_mcp_tools(session)
+            # print(f"tools-->{tools}")
+
+            # 创建prompt模板
+            prompt_template = ChatPromptTemplate.from_messages([
+                ("system", "你是一个乐于助人的助手,能够调用工具回答用户问题。"),
+                ("human", "{input}"),
+                ("placeholder", "{agent_scratchpad}"),
+            ])
+
+            # 构建工具调用代理
+            agent = create_tool_calling_agent(llm, tools, prompt_template)
+
+            # 创建代理执行器
+            agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
+
+            # 代理调用
+            print("MCP客户端启动,输入'quit'退出")
+            while True:
+                # 接收用户查询
+                query = input("\nQuery: ").strip()
+                if query.lower() == "quit":
+                    break
+                # 发送用户查询给代理,并打印  
+                try:
+                    response = await agent_executor.ainvoke({"input": query})
+                    print(f"response-->{response}")
+                except Exception:
+                    print("解析有问题")
+    return
+
+if __name__ == "__main__":
+    asyncio.run(run_agent())

+ 42 - 0
agent_learn/mcp_base/stdio/client_raw.py

@@ -0,0 +1,42 @@
+from pathlib import Path
+import sys
+import asyncio
+from langchain_mcp_adapters.tools import load_mcp_tools
+from mcp import ClientSession, StdioServerParameters
+from mcp.client.stdio import stdio_client
+
+# 配置mcp服务器脚本路径
+server_script = str(Path(__file__).with_name("server_stdio.py").resolve())
+
+# 配置mcp服务器启动参数
+server_params = StdioServerParameters(
+    command=sys.executable,
+    args=[server_script],
+)
+
+# 定义mcp客户端
+mcp_client = None
+
+# 主要的异步函数run_agent
+async def run():
+    global mcp_client
+    # 启动 MCP server,并通过标准输入输出建立异步连接。
+    async with stdio_client(server_params) as (read, write):
+        # 使用读写通道创建 MCP 会话。
+        async with ClientSession(read, write) as session:
+            await session.initialize()
+            # 动态创建一个临时类 MCPClientHolder,把 session 放进去。这样就可以在函数外部通过 mcp_client.session 调用 MCP 工具
+            mcp_client = type("MCPClientHolder", (), {"session": session})()
+
+            # 从 session 自动获取 MCP server 提供的工具列表。
+            tools = await load_mcp_tools(session)
+            print(f"tools-->{tools}")
+
+            # 调用 MCP server 的 get_weather 工具
+            response = await session.call_tool("get_weather", arguments={})
+            print(f"response-->{response}")
+    return
+
+# 启动运行
+if __name__ == "__main__":
+    asyncio.run(run())

+ 39 - 0
agent_learn/mcp_base/stdio/server_stdio.py

@@ -0,0 +1,39 @@
+import sys
+from mcp.server.fastmcp import FastMCP
+
+mcp = FastMCP("sdg", log_level="ERROR")
+
+@mcp.tool(
+    name="query_high_frequency_question",
+    description="从知识库中检索常见问题解答(FAQ),返回包含问题和答案的结构化JSON数据。",
+)
+async def query_high_frequency_question() -> str:
+    """
+    高频问题查询
+    """
+    try:
+        print("调用查询高频问题的tool成功!!", file=sys.stderr)
+        return "高频问题是: 恐龙是怎么灭绝的?"
+    except Exception as e:
+        print(f"Unexpected error in question retrieval: {str(e)}", file=sys.stderr)
+        raise
+
+
+@mcp.tool(
+    name="get_weather",
+    description="查询天气"
+)
+async def get_weather() -> str:
+    """
+    查询天气的tools
+    """
+    try:
+        print("调用查询天气的tools", file=sys.stderr)
+        return "北京的天气是多云"
+    except Exception as e:
+        print(f"Unexpected error in question retrieval: {str(e)}", file=sys.stderr)
+        raise
+
+
+if __name__ == "__main__":
+    mcp.run(transport="stdio")

+ 1 - 0
agent_learn/mcp_base/streamable/__init__.py

@@ -0,0 +1 @@
+# 将此目录标记为 Python 包,便于通过 python -m 运行课件示例。

+ 84 - 0
agent_learn/mcp_base/streamable/client_agent.py

@@ -0,0 +1,84 @@
+import json
+import logging
+import asyncio
+from langchain_openai import ChatOpenAI
+from mcp import ClientSession
+from mcp.client.streamable_http import streamablehttp_client
+from langchain_mcp_adapters.tools import load_mcp_tools
+from langchain.agents import create_tool_calling_agent, AgentExecutor
+from langchain_core.prompts import ChatPromptTemplate
+from agent_learn.config import Config
+
+conf = Config()
+
+# 创建模型
+llm = ChatOpenAI(base_url=conf.base_url,
+                 api_key=conf.api_key,
+                 model=conf.model_name,
+                 temperature=0.1)
+
+# MCP 服务器的 Streamable-HTTP 连接地址
+server_url = "http://127.0.0.1:8001/mcp"
+
+# 配置日志
+logging.basicConfig(
+    level=logging.DEBUG,  # 提高日志级别以捕获更多信息
+    format='[客户端] %(asctime)s - %(levelname)s - %(message)s'
+)
+
+# 定义mcp客户端
+mcp_client = None
+
+async def run_agent():
+    global mcp_client
+    logging.info(f"准备连接到 Streamable-HTTP 服务器: {server_url}")
+    # 启动 MCP server,通过streamable建立连接
+    async with streamablehttp_client(server_url) as (read, write, _):
+        logging.info("连接已成功建立!")
+        # 使用读写通道创建 MCP 会话
+        async with ClientSession(read, write) as session:
+            try:
+                await session.initialize()
+                logging.info("会话初始化成功,可以开始加载工具。")
+                # 动态创建一个临时类 MCPClientHolder,把 session 放进去。这样就可以在函数外部通过 mcp_client.session 调用 MCP 工具
+                mcp_client = type("MCPClientHolder", (), {"session": session})()
+
+                # 从 session 自动获取 MCP server 提供的工具列表。
+                tools = await load_mcp_tools(session)
+                # print(f"tools-->{tools}")
+
+                # 创建 agent 的提示模板
+                prompt = ChatPromptTemplate.from_messages([
+                    ("system", "你是一个乐于助人的助手,能够调用工具回答用户问题。"),
+                    ("human", "{input}"),
+                    ("placeholder", "{agent_scratchpad}"),
+                ])
+
+                # 构建工具调用代理
+                agent = create_tool_calling_agent(llm, tools, prompt)
+
+                # 创建代理执行器
+                agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
+
+                # 代理调用
+                print("MCP客户端启动,输入'quit'退出")
+                while True:
+                    query = input("\nQuery: ").strip()
+                    if query.lower() == "quit":
+                        break
+                    # 发送用户查询到 agent 并打印格式化响应
+                    logging.info(f"处理用户查询: {query}")
+                    try:
+                        response = await agent_executor.ainvoke({"input": query})
+                        print(f"response-->{response}")
+                    except Exception:
+                        print("解析有问题")
+            except Exception as e:
+                logging.error(f"会话初始化或工具调用时发生错误: {e}", exc_info=True)
+                raise
+
+if __name__ == "__main__":
+    try:
+        asyncio.run(run_agent())
+    except Exception as e:
+        logging.error(f"客户端运行失败: {e}", exc_info=True)

+ 63 - 0
agent_learn/mcp_base/streamable/client_raw.py

@@ -0,0 +1,63 @@
+import asyncio
+import logging
+from langchain_mcp_adapters.tools import load_mcp_tools
+from mcp import ClientSession
+from mcp.client.streamable_http import streamablehttp_client
+
+# 定义服务器地址
+server_url = "http://127.0.0.1:8001/mcp"
+
+# 定义mcp客户端
+mcp_client = None
+
+# 配置日志
+logging.basicConfig(
+    level=logging.DEBUG,  # 提高日志级别以捕获更多信息
+    format='[客户端] %(asctime)s - %(levelname)s - %(message)s'
+)
+
+async def main():
+    global mcp_client
+    logging.info(f"准备连接到 Streamable-HTTP 服务器: {server_url}")
+    try:
+        # 启动 MCP server,通过streamable建立连接
+        async with streamablehttp_client(server_url) as (read, write, _):
+            logging.info("连接已成功建立!")
+            # 使用读写通道创建 MCP 会话
+            async with ClientSession(read, write) as session:
+                try:
+                    await session.initialize()
+                    logging.info("会话初始化成功,可以开始调用工具。")
+                    # 动态创建一个临时类 MCPClientHolder,把 session 放进去。这样就可以在函数外部通过 mcp_client.session 调用 MCP 工具
+                    mcp_client = type("MCPClientHolder", (), {"session": session})()
+
+                    # 从 session 自动获取 MCP server 提供的工具列表。
+                    tools = await load_mcp_tools(session)
+                    # print(f"tools-->{tools}")
+
+                    # 调用远程工具
+                    logging.info("--> 正在调用工具: query_high_frequency_question")
+                    response = await session.call_tool("query_high_frequency_question", {})
+                    print(f"response-->{response}")
+                    logging.info(f"<-- 收到响应: {response}")
+
+                    print("-" * 30)
+
+                    logging.info("--> 正在调用工具: get_weather")
+                    response = await session.call_tool("get_weather", {})
+                    print(f"response-->{response}")
+                    logging.info(f"<-- 收到响应: {response}")
+                except Exception as e:
+                    logging.error(f"调用工具时发生错误: {e}", exc_info=True)
+                    raise
+    except Exception as e:
+        logging.error(f"连接或会话初始化时发生错误: {e}", exc_info=True)
+        logging.error("请确认服务端脚本已启动并运行在 http://127.0.0.1:8001/mcp")
+        raise
+
+
+if __name__ == "__main__":
+    try:
+        asyncio.run(main())
+    except Exception as e:
+        logging.error(f"客户端运行失败: {e}", exc_info=True)

+ 53 - 0
agent_learn/mcp_base/streamable/server_streamable.py

@@ -0,0 +1,53 @@
+from mcp.server.fastmcp import FastMCP
+
+# 创建 MCP 实例,指定服务名称、日志级别、主机和端口
+mcp = FastMCP("sdg", log_level="ERROR", host="127.0.0.1", port=8001)
+
+@mcp.tool(
+    name="query_high_frequency_question",
+    description="从知识库中检索常见问题解答(FAQ),返回包含问题和答案的结构化JSON数据。",
+)
+async def query_high_frequency_question() -> str:
+    """
+    高频问题查询
+    """
+    try:
+        print("调用查询高频问题的tool成功!!")
+        return "高频问题是: 恐龙是怎么灭绝的?"
+    except Exception as e:
+        print(f"Unexpected error in question retrieval: {str(e)}")
+        raise
+
+@mcp.tool(
+    name="get_weather",
+    description="查询天气"
+)
+async def get_weather() -> str:
+    """
+    查询天气的tools
+    """
+    try:
+        print("调用查询天气的tools")
+        return "北京的天气是多云"
+    except Exception as e:
+        print(f"Unexpected error in question retrieval: {str(e)}")
+        raise
+
+
+def main():
+    """
+    启动 Streamable HTTP 服务器。
+    """
+    print("正在启动MCP Streamable服务器...")
+    print("服务器将在 http://localhost:8001 上运行")
+    print("按 Ctrl+C 停止服务器")
+    try:
+        mcp.run(transport="streamable-http")  # 使用 streamable-http 传输方式
+    except KeyboardInterrupt:
+        print("\n服务器已停止")
+    except Exception as e:
+        print(f"服务器启动失败: {e}")
+
+
+if __name__ == "__main__":
+    main()

+ 142 - 0
api_server.py

@@ -0,0 +1,142 @@
+# 代码阅读顺序(编号表示调用阶段,不是源文件从上往下的行号)
+# 启动 S1:各进程导入 config.py / create_logger.py,准备配置和日志。
+# 启动 S2:先准备数据服务,再分别启动 mcp_server 下的工具服务(8001/8002/8003)。
+# 启动 S3:分别启动 a2a_server 下的子助手(天气 5005、票务 5006、差旅 5007)。
+# 启动 S4:运行本文件,创建 ChatService 并监听 8080;浏览器打开首页。
+# 以上服务各自在独立进程运行;本文件不会自动启动 MCP、A2A 或数据库。
+# 一次网页对话的主流程:
+# 1 网页发送 → 2 API 接收 → 3 记录用户消息 → 4 意图识别 → 5 判断是否规划
+# → 6 主助手分派 → 7 A2A 子助手选工具 → 8 MCP 执行查询/模拟预订
+# → 9 A2A 返回 → 10 主助手整理并输出片段 → 11 保存完整回复 → 12 结束网页接收。
+# 第 5 步需要追问时跳过 6~9,输出追问并保存;子助手在第 7 步也可能追问而不调用工具。
+# 复杂任务会重复 6~9;第 10 步的片段会边生成边传到网页。
+# 各文件中的“内部步骤”“记忆”“辅助”等编号,只描述该函数或独立脚本的局部顺序。
+
+"""
+需求:SmartBizTrip FastAPI后端服务器,提供REST API接口
+"""
+import json
+import os
+import sys
+
+# 路径解释:__file__ 是当前 api_server.py 的位置,dirname 取其目录,.. 指向上一级。
+# 把上一级放入模块搜索路径,便于依赖以 SmartBizTrip.xxx 形式导入同一项目。
+# 这只影响 Python 查找模块,不会切换进程工作目录,也不会启动任何服务。
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
+
+import uvicorn
+from fastapi import FastAPI
+from fastapi.responses import FileResponse, StreamingResponse
+from pydantic import BaseModel
+
+# 【启动 S4.1】导入类时先执行依赖模块的顶层语句;函数体要等调用才执行。
+from chat_service import ChatService
+
+# app 是 HTTP 应用对象,后面的 @app.get/@app.post 都把“路径→函数”登记到这个对象上。
+# 装饰器在模块加载时注册函数;访问对应 URL 时,框架才调用该函数体。
+app = FastAPI(title="SmartBizTrip API", description="基于A2A的企业差旅智能体")
+
+# 全局服务实例
+# 【启动 S4.2】创建一个全局对话服务:注册 A2A 客户端、创建模型对象、尝试恢复记忆。
+# 该实例供所有请求共用;当前代码没有按用户或会话隔离记忆。
+chat_service = ChatService()
+
+
+# 【启动 S4.3】定义请求结构并注册下面的路由;此时尚未处理用户消息。
+# FastAPI 会把请求 JSON 校验为模型对象,再调用对应接口。
+class ChatRequest(BaseModel):
+    # 请求示例:{"message": "查询北京到上海的高铁"}。
+    # 字段名 message 要与网页 JSON.stringify 中的字段名一致。
+    message: str
+
+
+class ProfileRequest(BaseModel):
+    profile: dict
+    # 默认 False:保留旧偏好,只补充/覆盖提交的键;True:旧偏好整体换成本次 profile。
+    replace: bool = False
+
+
+@app.get("/")
+async def index():
+    """返回前端页面"""
+    # 【启动 S4.5】浏览器 GET / 时返回 static/index.html,页面脚本再加载代理卡片和记忆。
+    static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
+    # 返回文件响应对象,由框架读取并发送 HTML;此函数不会在 Python 中执行网页 JavaScript。
+    # JavaScript 要等浏览器收到页面后运行,随后调用 /api/agents、/api/memory。
+    return FileResponse(os.path.join(static_dir, "index.html"))
+
+
+@app.post("/api/chat") # FastAPI 找到对应的处理函数
+async def chat(request: ChatRequest):
+    """发送消息,获取回复"""
+    # 【非流式入口】POST /api/chat 等待 ChatService.chat() 完整结束后一次性返回 JSON。
+    # 它与网页默认使用的 /api/chat/stream 是两个入口,一次请求只走其中一个。
+    # request 是校验后的 ChatRequest 对象,request.message 取出其中的字符串。
+    # await 在这里等待业务方法给出完整字符串;response 不是 HTTP 响应,而是助手回复文本。
+    response = await chat_service.chat(request.message)
+    return {"status": "success", "message": response}
+
+
+async def sse_generator(message: str):
+    """SSE 生成器,将业务层产出的文本片段封装成事件。"""
+    # 【主流程 2.2 → 3】开始消费业务层异步生成器;每次得到一个 chunk 才向网页发送一次。
+    # chunk 可能是一段文字,并不保证一个字;等待业务处理期间也不保证立即有输出。
+    # 异步生成器可以多次 yield,不能按“await 后只拿一次返回值”的方式消费。
+    # async for 每拿到一段就执行一次循环体;业务生成器完成后才执行循环后面的 [DONE]。
+    async for chunk in chat_service.chat_stream(message):
+        # SSE 格式:每行以 "data: " 开头,用空行分隔
+        # 返回链第 1 层:业务文本 chunk → {"chunk": chunk} → JSON 文本 → SSE 事件。
+        # json.dumps 会转义文本内部换行,避免它把一条 data 事件意外拆开;ensure_ascii=False 保留中文。
+        # 末尾两个换行用于分隔事件;yield 把本次事件交给 StreamingResponse 后暂时停在这里。
+        yield f"data: {json.dumps({'chunk': chunk}, ensure_ascii=False)}\n\n"
+    # 发送结束标记
+    # 【主流程 12.1】业务生成器正常结束后发送结束标记,再结束 HTTP 响应流。
+    yield "data: [DONE]\n\n"
+
+
+@app.post("/api/chat/stream")
+async def chat_stream(request: ChatRequest):
+    """发送消息,流式获取回复(SSE)"""
+    # 【主流程 2.1】接收网页的 {message: ...},把生成器交给 StreamingResponse。
+    # 这里的 chat_stream 是 HTTP 接口;真正处理业务的是 ChatService.chat_stream。
+    # 创建生成器并不立即执行其函数体,响应开始迭代它时才进入 sse_generator。
+    # 这里返回的是“边迭代边发送”的响应对象;还没有把整段回复提前拼好。
+    # media_type 告诉客户端响应体采用 SSE 文本格式,前端仍需自行解析 data 行。
+    return StreamingResponse(sse_generator(request.message), media_type="text/event-stream")
+
+
+@app.get("/api/memory")
+async def get_memory():
+    """获取记忆状态"""
+    # 【旁路:查看记忆 1 → 2】读取当前共享服务的记忆摘要,返回给网页侧栏。
+    # 外层 status/data 是本项目约定的 JSON 字段,不是 Python 的函数返回状态。
+    # 网页通过 response.json() 得到字典对应的对象,再用 data.data 读取记忆内容。
+    return {"status": "success", "data": chat_service.get_memory_state()}
+
+
+@app.post("/api/memory/clear")
+async def clear_memory():
+    """清空记忆"""
+    # 【旁路:清空记忆 1 → 2】网页确认后进入此接口,清理内存并尝试删除数据库记忆。
+    chat_service.clear_memory()
+    return {"status": "success", "message": "记忆已清空"}
+
+
+@app.post("/api/memory/profile")
+async def update_profile(request: ProfileRequest):
+    """更新用户偏好"""
+    # 【旁路:保存偏好 1 → 2】replace=True 表示用本次完整表单替换旧偏好。
+    chat_service.update_user_profile(request.profile, replace=request.replace)
+    return {"status": "success", "message": "用户偏好已更新"}
+
+
+@app.get("/api/agents")
+async def get_agents():
+    """获取代理卡片信息"""
+    # 【旁路:代理卡片 1 → 2】读取各代理能力说明,供网页展示。卡片不是工具执行结果。
+    return {"status": "success", "data": chat_service.get_agent_cards()}
+
+
+if __name__ == "__main__":
+    # 【启动 S4.4】只有直接运行本文件才进入这里,开始监听端口并等待请求。
+    uvicorn.run(app, host="127.0.0.1", port=8080, log_level="info")

+ 1275 - 0
chat_service.py

@@ -0,0 +1,1275 @@
+# 本文件接续 api_server.py 顶部的主流程 3~11;网页默认入口为 ChatService.chat_stream。
+# 阅读时先找 chat_stream,再顺着其调用进入 intent_agent、规划方法和代理调用方法。
+# chat 是非流式入口,两者互为替代;它们不是先后调用关系。
+# 主助手按计划分派任务;子助手中的 AgentExecutor 才执行“模型选工具 → 工具返回 → 再问模型”的循环。
+
+"""
+需求:SmartBizTrip核心对话服务,供CLI和API共同调用
+
+架构说明:
+    本模块实现了企业差旅智能体的核心对话流程,采用优化的 Planning + ReAct 架构:
+    1. 意图识别(intent_agent):分析用户输入,识别出用户想做什么(查天气/查航班/查接待资源等)
+    2. 启发式路由(_should_skip_planning):快速判断任务复杂度
+       - 单意图或独立多意图:跳过规划,直接执行(省去 1 次 LLM 调用)
+       - 含依赖关系的复杂任务:调用 planning_agent 生成执行计划
+    3. ReAct循环(react_loop):针对复杂任务,逐步执行每个步骤
+       - Action(行动):直接调用对应的 agent 执行查询(跳过 Thought 推理,省去每步 1 次 LLM 调用)
+       - Observation(观察):记录执行结果,供后续步骤参考
+    4. 结果汇总:所有步骤完成后,生成一条连贯的最终回复
+
+整体流程:
+    用户输入 → 意图识别 → [跳过规划 / 调用planning_agent] → [直接执行 / ReAct循环] → 返回回复
+
+A2A架构说明:
+    Agent2Agent (A2A) 是一种代理间通信协议。本系统通过 AgentNetwork 管理多个子代理:
+    - WeatherQueryAssistant(天气查询代理):运行在 localhost:5005
+    - TicketAssistant(票务代理):运行在 localhost:5006
+    - TripAssistant(差旅管家代理):运行在 localhost:5007
+    主助手通过 A2A 协议向子代理发送任务请求,获取查询结果后整合回复用户。
+
+架构图:
+
++─────────────────────────────────────────────────────────────────────────────+
+│                           ChatService  类                                   │
+│                                                                             │
+│  ┌────────────────── 成员变量 ──────────────────┐                           │
+│  │  agent_urls{}          ── 子代理地址映射       │                           │
+│  │  agent_network         ── AgentNetwork 实例   │                           │
+│  │  llm                   ── ChatOpenAI (LLM)    │                           │
+│  │  memory                ── ConversationMemory  │                           │
+│  │  messages[]            ── 当前对话消息列表     │                           │
+│  │  conversation_history  ── 完整对话字符串       │                           │
+│  │  _available_tools_text ── 工具列表缓存         │                           │
+│  └───────────────────────────────────────────────┘                           │
+│                                                                             │
+│  ┌────────────────── 方法分层 ──────────────────┐                           │
+│  │                                               │                           │
+│  │  [入口层]                                     │                           │
+│  │  chat()               ── 标准对话入口          │                           │
+│  │  chat_stream()        ── 流式对话入口          │                           │
+│  │                                               │                           │
+│  │  [智能决策层]                                 │                           │
+│  │  intent_agent()       ── 意图识别 (LLM)       │                           │
+│  │  _should_skip_planning() ── 启发式路由规则     │                           │
+│  │  planning_agent()     ── 任务规划 (LLM)       │                           │
+│  │                                               │                           │
+│  │  [执行层]                                     │                           │
+│  │  react_loop()         ── ReAct 循环 (标准)    │                           │
+│  │  _react_loop_stream() ── ReAct 循环 (流式)    │                           │
+│  │  execute_step()       ── 执行单个计划步骤      │                           │
+│  │                                               │                           │
+│  │  [代理调用层]                                 │                           │
+│  │  _call_agent_intent()       ── 统一调用 (标准) │                           │
+│  │  _call_agent_intent_stream() ── 统一调用 (流式)│                           │
+│  │                                               │                           │
+│  │  [辅助层]                                     │                           │
+│  │  get_available_tools()  ── 动态工具列表        │                           │
+│  │  get_agent_cards()      ── 获取代理卡片        │                           │
+│  │  _init_db_and_load_memory() ── DB初始化+加载   │                           │
+│  │  update_user_profile()  ── 更新用户偏好        │                           │
+│  │  get_memory_state()     ── 记忆状态摘要        │                           │
+│  │  clear_memory()         ── 清空所有记忆        │                           │
+│  └───────────────────────────────────────────────┘                           │
+└─────────────────────────────────────────────────────────────────────────────+
+
+请求处理流程 (chat / chat_stream,图中 1~6 为函数内部阶段,跨文件编号见 #【主流程】):
+
+    用户输入
+       │
+       ▼
+    ┌─────────────────┐
+    │ 1. 写入记忆       │  memory.add_message("user", input)
+    └────────┬────────┘
+             ▼
+    ┌─────────────────┐
+    │ 2. 意图识别       │  intent_agent() → LLM → (intents, queries, follow_up)
+    └────────┬────────┘
+             ▼
+    ┌──────────────────────────────────────────────┐
+    │ 3. 特殊情况判断                                │
+    │    out_of_scope?  → 返回追问                   │
+    │    follow_up?     → 返回追问                   │
+    └────────────────────┬─────────────────────────┘
+                         ▼
+    ┌──────────────────────────────────────────────┐
+    │ 4. 任务规划                                    │
+    │    _should_skip_planning()?                   │
+    │      ├─ YES → plan = {need_plan: false}       │
+    │      └─ NO  → planning_agent() → LLM 生成计划 │
+    └────────────────────┬─────────────────────────┘
+                         ▼
+    ┌──────────────────────────────────────────────┐
+    │ 5. 执行                                        │
+    │    need_plan?                                 │
+    │      ├─ YES → react_loop()                    │
+    │      │         ├─ 按 depends_on 分组           │
+    │      │         ├─ 组内并行 execute_step()      │
+    │      │         │    → _call_agent_intent()    │
+    │      │         └─ 汇总 → LLM 生成连贯回复      │
+    │      │                                        │
+    │      └─ NO  → 遍历 intents                    │
+    │                → _call_agent_intent() 每个意图 │
+    │                → 拼接所有回复                  │
+    └────────────────────┬─────────────────────────┘
+                         ▼
+    ┌─────────────────┐
+    │ 6. 写入记忆       │  memory.add_message("assistant", response)
+    └────────┬────────┘
+             ▼
+         返回回复
+
+_call_agent_intent() 内部路由:
+
+    intent
+       │
+       ├─ city_service → 尝试查询天气 → city_service_prompt() + LLM → 生成建议
+       │
+       ├─ flight/train/event/car_rental/...
+       │     │
+       │     ├─ 提取实体 → memory.extract_entities()
+       │     ├─ 更新上下文 → memory.update_task_context()
+       │     ├─ A2A → agent.send_task_async(task)
+       │     │        ├─ WeatherQueryAssistant → summarize_weather_prompt() + LLM
+       │     │        ├─ TicketQueryAssistant/TripAssistant → 总结;TicketAssistant → 原文
+       │     │        │   (流式版本把 TicketAssistant 也纳入总结)
+       │     │        └─ 其他 → 直接返回原始结果
+       │     └─ 返回总结结果
+       │
+       └─ 未识别 → "暂不支持此意图"
+
+子代理调用关系:
+
+    ChatService (由 API :8080 调用)
+       │
+       ├── [A2A :5005] → WeatherQueryAssistant  (天气查询)
+       ├── [A2A :5006] → TicketAssistant        (票务预定)
+       ├── [A2A :5007] → TripAssistant          (差旅管家: 商务用车/会务套餐/保险)
+       │
+       └── [直连 LLM]  → city_service (先尝试查询天气,再生成城市接待建议)
+
+优化策略总结:
+
+    1. 启发式路由   → 简单任务跳过 planning_agent,省 1 次 LLM 调用
+    2. 省略 Thought → ReAct 循环中跳过 Thought 推理,每步省 1 次 LLM 调用
+    3. 并行执行     → 无依赖步骤通过 asyncio.gather() 并发执行
+    4. 工具说明     → get_available_tools 可整理代理卡片;当前对话主线未调用
+"""
+
+# ==================== 导入依赖 ====================
+import asyncio  # 异步IO库,用于并发调用多个 agent(异步网络请求)
+import json  # JSON处理,用于解析大模型的JSON输出和序列化数据
+import re  # 正则表达式,用于清理大模型返回的JSON(去掉代码块标记)
+import uuid  # 唯一标识符生成,用于给每个任务分配唯一ID
+from datetime import datetime  # 时间处理,用于获取当前日期和时间戳
+
+import mysql.connector  # MySQL 数据库驱动,用于记忆持久化
+import pytz  # 时区库,用于将时间转换到 Asia/Shanghai 时区
+from langchain_core.prompts import ChatPromptTemplate  # LangChain 的提示模板类
+
+# AgentNetwork: 代理网络管理器,负责注册和获取子代理
+# A2AClient: A2A 客户端,支持自定义超时
+# TextContent: 消息文本内容包装
+# Message: A2A 消息对象,包含角色和内容
+# MessageRole: 消息角色枚举(USER/ASSISTANT)
+# Task: A2A 任务对象,用于向子代理发送请求
+from langchain_openai import ChatOpenAI  # LangChain 的大模型接口,兼容 OpenAI API 格式
+
+# python_a2a 是 A2A 协议的 Python 客户端库
+from python_a2a import AgentNetwork, Message, MessageRole, Task, TextContent
+from python_a2a.client import A2AClient
+
+# 导入项目内部模块
+from config import Config  # 配置模块(模型地址、API Key、意图映射等)
+from create_logger import logger  # 日志模块,用于记录运行日志
+from main_prompts import SmartBizTripPrompts  # Prompt 模板管理
+from memory import ConversationMemory  # 记忆管理(短期记忆、用户偏好、任务上下文)
+
+conf = Config()  # 全局配置实例,包含模型参数、意图映射等
+
+
+class ChatService:
+    """
+    SmartBizTrip 对话服务类
+
+    这是整个系统的核心类,负责处理用户的所有输入并生成回复。
+    内部流程为:意图识别 → 任务规划 → 执行(直接/ReAct循环)→ 返回回复
+
+    使用示例:
+        service = ChatService()          # 创建服务实例
+        response = await service.chat("北京明天天气怎么样?")  # 发送用户输入
+        print(response)                  # 打印助手回复
+    """
+
+    def __init__(self):
+        """
+        初始化 ChatService 实例(启动 S4.2)
+
+        完成以下初始化工作:
+        1. 配置子代理网络(注册天气、票务和差旅代理的客户端)
+        2. 初始化大模型连接
+        3. 初始化记忆管理器
+        4. 初始化对话历史和工具列表缓存
+        """
+        # ========== 1. 配置 A2A 代理网络 ==========
+        # 记录每个子代理的访问地址
+        # 调用者:api_server.py 创建全局 ChatService() 时自动进入。
+        # 输入:没有业务问题参数,使用模块级 conf 配置。输出:初始化好的 self,不返回聊天文本。
+        # self.xxx 是服务实例上保留的状态,后面的每次请求都能读取;方法里的普通局部变量只属于本次调用。
+        self.agent_urls = {
+            "WeatherQueryAssistant": "http://localhost:5005",  # 天气查询代理的地址
+            "TicketAssistant": "http://localhost:5006",        # 票务代理的地址
+            "TripAssistant": "http://localhost:5007"           # 差旅管家代理的地址
+        }
+        # 创建代理网络实例,给它起个名字
+        self.agent_network = AgentNetwork(name="企业差旅助手网络")
+        # 向网络中注册子代理,使用自定义超时(120秒),避免子代理 LLM+MCP 调用链超时
+        # 这里只登记通信客户端;不会在当前进程创建或启动远端子助手。
+        self.agent_network.add("WeatherQueryAssistant", A2AClient("http://localhost:5005", timeout=120))
+        self.agent_network.add("TicketAssistant", A2AClient("http://localhost:5006", timeout=120))
+        self.agent_network.add("TripAssistant", A2AClient("http://localhost:5007", timeout=120))
+
+        # ========== 2. 初始化大模型连接 ==========
+        # 使用 LangChain 的 ChatOpenAI 接口连接大模型
+        # 实际模型与地址来自 Config;创建对象尚未发送模型推理请求。
+        self.llm = ChatOpenAI(
+            model=conf.model_name,     # 配置指定的模型名称
+            api_key=conf.api_key,      # API 密钥
+            base_url=conf.base_url,    # API 基础地址
+            temperature=0.1            # 温度参数:越小输出越确定、越稳定(0.1表示比较严谨)
+        )
+
+        # ========== 3. 初始化记忆和对话状态 ==========
+        # self.memory 负责结构化记忆与持久化;不是大模型的内部记忆。
+        # short_term_limit=10 限制消息条数,通常一轮用户+助手就占两条。
+        self.memory = ConversationMemory(short_term_limit=10)  # 短期记忆最多保留10条对话
+        self.messages = []           # 对话消息列表,格式:[{"role": "user/assistant", "content": "..."}]
+        self.conversation_history = ""  # 完整对话历史字符串,用于调试和展示
+        self._available_tools_text = "" # 保存整理后的工具说明,当前对话主线未读取它
+
+        # 城市提取 prompt,用于从城市接待建议查询中提取目的地城市
+        # 把城市提取规则保存成模板,后续传入 conversation_history/query 后才能得到本轮模型输入。
+        # 创建模板是本地对象操作;实际推理发生在 _fetch_weather_for_city_service 的 chain.invoke。
+        self._city_extract_prompt = ChatPromptTemplate.from_template(
+"""从以下用户查询和对话历史中提取目的地城市(用于天气查询)。规则:
+- 只输出城市名称,不要输出其他内容。
+- 如果查询中没有明确城市,尝试从对话历史中推断。
+- 如果无法确定城市,输出"未知"。
+
+对话历史:{conversation_history}
+用户查询:{query}
+""")
+
+        # ========== 4. 初始化数据库连接,加载持久化记忆 ==========
+        self._init_db_and_load_memory()
+
+    def get_available_tools(self) -> str:
+        """
+        从 A2A 代理网络中动态获取可用工具列表,格式化为文本
+
+        这是 ReAct 架构的关键:告诉大模型当前有哪些工具/agent 可以调用,
+        让大模型在推理时从这些工具中选择合适的。
+
+        本方法从已注册的代理读取卡片;新增代理仍需注册和配置路由。
+        当前 chat/chat_stream 没有调用本方法,也没有把其结果注入主流程提示词。
+
+        返回值:
+            str: 格式化的工具列表文本,例如:
+                - Agent: WeatherQueryAssistant, 查询天气信息
+                  Skill: 天气查询: 根据城市查询天气
+                - Agent: TicketQueryAssistant, 查询票务信息
+                  Skill: 票务查询: 查询航班、高铁票等
+        """
+        # 【辅助】按需读取代理卡片并整理说明;当前 chat/chat_stream 主线没有调用此方法。
+        # 此方法赋值 _available_tools_text,但本方法自身没有“缓存非空就直接返回”的判断。
+        # 输入:self.agent_network 中已经注册的代理。输出:多行字符串,描述代理名、技能和说明。
+        # 函数名中的 tools 指整理出的能力说明文本,与 A2A 子助手加载到的 StructuredTool 对象不同。
+        try:
+            # 调用 get_agent_cards() 获取所有在线 agent 的卡片信息
+            cards = self.get_agent_cards()
+            lines = []
+            for card in cards:
+                # 每个 agent 一行,包含名称和描述
+                lines.append(f"- Agent: {card['name']}, {card['description']}")
+                # 每个 agent 的技能作为子项列出
+                for skill in card['skills']:
+                    lines.append(f"  Skill: {skill}")
+            self._available_tools_text = "\n".join(lines)
+        except Exception as e:
+            # 如果 agent server 没有启动或网络不通,使用默认的工具列表作为降级方案
+            logger.warning(f"获取可用工具列表失败: {e}")
+            self._available_tools_text = (
+                "- WeatherQueryAssistant: 查询天气信息\n"
+                "- TicketQueryAssistant: 查询票务信息(航班/高铁票/会展门票)\n"
+                "- TicketAssistant: 预定票务\n"
+                "- TripAssistant: 差旅管家(商务用车/会务套餐/保险的查询与预订)"
+            )
+        return self._available_tools_text
+
+    def _init_db_and_load_memory(self):
+        """
+        初始化数据库连接,并从数据库加载持久化记忆
+
+        启动流程:
+        1. 创建 MySQL 连接,注入到 memory 实例
+        2. 从数据库加载用户偏好、查询历史、短期对话
+        """
+        # 【启动 S4.2.1】尝试连接数据库,依次恢复偏好、查询历史、短期消息。
+        # 连接失败会保留内存模式;这不表示依赖数据库的业务查询也能运行。
+        # 调用者:__init__。输入:conf 中的数据库参数;输出:通过修改 self.memory/self.messages 恢复状态。
+        # 三种 load 方法各自读取对应表;此方法不创建表,不把 SQL 初始化文件自动执行一遍。
+        try:
+            db_conn = mysql.connector.connect(
+                host=conf.host,
+                user=conf.user,
+                password=conf.password,
+                database=conf.database
+            )
+            self.memory.set_db_connection(db_conn)
+            logger.info("数据库连接初始化成功")
+
+            # 从数据库恢复记忆数据
+            self.memory.load_profile_from_db()
+            self.memory.load_entities_from_db()
+            self.memory.load_messages_from_db()
+
+            # 将加载的短期消息同步到 messages 列表
+            for msg in self.memory.short_term_messages:
+                self.messages.append({"role": msg["role"], "content": msg["content"]})
+
+            logger.info(f"记忆加载完成: 偏好={len(self.memory.user_profile)}项, "
+                        f"历史={len(self.memory.entity_history)}条, 对话={len(self.memory.short_term_messages)}条")
+        except Exception as e:
+            logger.warning(f"数据库连接初始化失败: {e}")
+            self.memory.set_db_connection(None)
+
+    async def _fetch_weather_for_city_service(self, query_str: str) -> str:
+        """
+        为城市接待建议查询目的地天气,返回简要天气信息。
+        如果无法确定目的地,返回空字符串。
+
+        参数:
+            query_str (str): 用户的接待资源查询内容
+
+        返回值:
+            str: 天气信息摘要,或空字符串
+        """
+        # 【主流程 6:城市建议子分支,内部步骤 1】提取目的地;未知城市直接返回空天气。
+        # 调用者:两个 _call_agent_intent 版本中的 city_service 分支。
+        # 输入:用户接待需求文本;输出:天气代理产物文本或空字符串。
+        # 顺序:先让模型提取城市 → 判断城市是否存在 → 构造天气任务 → 等待代理 → 取回产物。
+        # 该函数自身不会生成接待建议,结果作为 weather_info 交给外层的建议模板。
+        try:
+            # 从查询中提取目的地城市
+            chain = self._city_extract_prompt | self.llm
+            city = chain.invoke({
+                "conversation_history": self.memory.get_short_term_text(),
+                "query": query_str
+            }).content.strip()
+
+            if city == "未知" or not city:
+                logger.info(f"无法从查询中提取目的地城市: {query_str}")
+                return ""
+
+            logger.info(f"从接待资源查询中提取到城市: {city}")
+
+            # 通过天气代理查询该城市天气
+            agent = self.agent_network.get_agent("WeatherQueryAssistant")
+            if agent is None:
+                return ""
+
+            # 保留接待需求里的日期,让天气助手按用户要求查询;没给日期才默认明天。
+            # 【内部步骤 2】保留需求中的日期,构造天气 Task,发给 WeatherQueryAssistant。
+            weather_query = (
+                f"请查询{city}的天气,日期以用户接待需求为准;仅在需求未提供日期时查询明天。\n"
+                f"用户接待需求:{query_str}"
+            )
+            msg = Message(content=TextContent(text=weather_query), role=MessageRole.USER)
+            task = Task(id="task-" + str(uuid.uuid4()), message=msg.to_dict())
+
+            raw_response = await asyncio.get_event_loop().run_in_executor(
+                None, lambda: asyncio.run(agent.send_task_async(task))
+            )
+
+            # 【内部步骤 3】任务完成且有产物时取回文本;未完成或没有产物时返回空串。
+            # 产物本身仍可能描述“无数据”,不能将非空文本等同于有效天气数据。
+            if raw_response.status.state == 'completed' and raw_response.artifacts:
+                return raw_response.artifacts[0]['parts'][0]['text']
+            return ""
+
+        except Exception as e:
+            logger.warning(f"查询接待资源天气失败: {e}")
+            return ""
+
+    def update_user_profile(self, profile: dict, replace: bool = False):
+        """
+        更新用户偏好设置,由前端 API 直接调用
+
+        参数:
+            profile (dict): 用户偏好键值对,如 {"seat_type": "二等座", "cabin_type": "经济舱"}
+                           默认合并;replace=True 时以网页提交的完整偏好替换旧值
+        """
+        # 【旁路:偏好更新】交给 memory 合并或替换,并尝试持久化;完成后由 API 返回状态。
+        # 调用者:POST /api/memory/profile。输入:偏好字典和是否整体替换。
+        # 数据交给 ConversationMemory.update_profile;后续 intent_agent 再将偏好文本放入模型输入。
+        self.memory.update_profile(profile, replace=replace)
+        logger.info(f"更新用户偏好: {profile}")
+
+    def intent_agent(self, user_input: str):
+        """
+        意图识别:分析用户输入,判断用户想做什么
+
+        工作流程:
+        1. 将用户输入、对话历史、用户偏好等上下文组装后发送给大模型
+        2. 大模型返回 JSON,包含识别到的意图列表、改写后的查询、追问消息
+
+        支持的意图类型:
+            - weather: 天气查询
+            - flight: 航班查询
+            - train: 高铁票查询
+            - event: 会展门票查询
+            - order: 票务预定
+            - city_service: 城市接待建议
+            - out_of_scope: 超出系统能力范围
+
+        参数:
+            user_input (str): 用户本次的输入内容
+
+        返回值:
+            tuple: (intents, user_queries, follow_up_message)
+                - intents (list): 识别到的意图列表,如 ["weather", "flight"]
+                - user_queries (dict): 改写后的查询,如 {"weather": "北京明天天气", "flight": "北京到上海航班"}
+                - follow_up_message (str): 追问消息,当意图不明确时用于追问用户
+        """
+        # 组装 Prompt 模板 + 大模型,形成完整的处理链
+        # 【主流程 4.1】获取提示模板并与模型组成调用链;此时还没有查天气或票务。
+        # 调用者:chat 或 chat_stream,每轮正常处理先调用一次。
+        # 输入:user_input 字符串,例如“北京明天天气怎样”;输出:三个值组成的元组。
+        # 示意:(["weather"], {"weather": "北京某日期天气"}, ""),具体内容由模型生成。
+        # 调用方用同样顺序接到 intents、user_queries、follow_up_message 三个变量中。
+        chain = SmartBizTripPrompts.intent_prompt() | self.llm
+
+        # 计算上海时区的当前日期,填入 intent_prompt 的 current_date 占位符。
+        current_date = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d')
+
+        # 调用大模型,传入所有上下文信息
+        # 【主流程 4.2】实际请求模型,结合模板引用的历史、偏好、任务上下文和本轮问题。
+        # 这是同步 invoke;即使外层是 async 方法,这次调用也不会自动变成非阻塞。
+        intent_response = chain.invoke({
+            "conversation_history": self.memory.get_short_term_text(),  # 短期对话历史
+            "query": user_input,          # 用户本次输入
+            "current_date": current_date, # 供模板解析今天、明天等相对日期
+            "user_profile": self.memory.get_profile_text(),  # 用户偏好(如"二等座"、"经济舱")
+            "task_context": json.dumps(self.memory.current_task, ensure_ascii=False)  # 当前任务上下文
+        }).content.strip()  # .content 获取文本内容,.strip() 去掉首尾空白
+        logger.info(f"意图识别原始响应: {intent_response}")
+
+        # 大模型有时会返回 ```json ... ``` 格式的代码块,需要用正则去掉
+        # 清理的是字符串边缘的 Markdown 代码围栏,便于 json.loads 解析。
+        # 这不校验 JSON 字段类型,也不会自动修复漏引号、漏逗号等结构错误。
+        intent_response = re.sub(r'^```json\s*|\s*```$', '', intent_response).strip()
+        logger.info(f"清理后响应: {intent_response}")
+
+        # 将 JSON 字符串解析为 Python 字典
+        # 【主流程 4.3】把模型文本解析成意图、改写查询和追问;无效 JSON 交由对话入口捕获。
+        intent_output = json.loads(intent_response)
+        # dict.get(key, default) 在字段缺失时使用默认值;字段存在但类型不对时不会自动纠正类型。
+        # intents 用列表保留多个需求,user_queries 用字典按意图找到对应的查询内容。
+        intents = intent_output.get("intents", [])  # 意图列表,如 ["weather", "flight"]
+        user_queries = intent_output.get("user_queries", {})  # 改写后的查询字典
+        follow_up_message = intent_output.get("follow_up_message", "")  # 追问消息
+        # 先确认是字符串才 strip 去空白;模型若给了其他类型,这里将追问归为空串。
+        # 这样外层可以统一用 follow_up_message != "" 判断是否需要先回复用户。
+        follow_up_message = follow_up_message.strip() if isinstance(follow_up_message, str) else ""
+        # 两项条件都满足才补充默认问候:识别为范围外,而且模型没有生成可用回复。
+        # 已有追问时保留模型原文;不会覆盖每一条普通业务问题。
+        if "out_of_scope" in intents and not follow_up_message:
+            follow_up_message = (
+                "你好!我是企业差旅助手,可以协助查询天气、航班、高铁票、会展门票,"
+                "以及商务用车、会务套餐和保险,并提供城市接待建议。"
+                "查询和预订功能需要相应服务及数据源可用。"
+                "你可以先告诉我出发地、目的地和出行日期。"
+            )
+        logger.info(f"intents: {intents}||user_queries: {user_queries}||follow_up_message: {follow_up_message} ")
+
+        return intents, user_queries, follow_up_message
+
+    def _should_skip_planning(self, intents: list) -> bool:
+        """
+        启发式判断:是否可以跳过 planning_agent,直接执行
+
+        判断规则(满足任一即跳过):
+        1. 未包含 complex,且意图数不超过一个时直接执行
+        2. 多意图全部命中下方 independent_intents 集合时直接执行;
+           当前直接执行分支使用 for + await,按意图顺序串行调用
+
+        需要进入 planning 的情况:
+        - complex 意图无论有几项都进入规划
+        - 多意图中出现不在 independent_intents 集合内的类型(如 order)
+        - 单独的 order 或 trip_order 仍会跳过规划;本方法不直接分析自然语言依赖
+
+        优化收益:省去 1 次 LLM 调用(planning_agent),大幅降低简单请求的响应时间
+
+        参数:
+            intents (list): 识别到的意图列表
+
+        返回值:
+            bool: True 表示跳过 planning_agent,False 表示需要调用 planning_agent
+        """
+        # complex 必须进入规划,即使意图列表只有一项。
+        # 【主流程 5.1】按意图标签做规则判断。规则返回 True 才跳过 planning_agent。
+        # 依赖是否表达为 complex 取决于前面的意图识别,不是这个函数再次理解用户原话。
+        # 输入:意图字符串列表。输出:布尔值,True=直接执行,False=还要询问规划模型。
+        # 注意这里不是在生成 plan,返回值也不表示任务已完成;它仅决定下一段代码走哪条路。
+        # 第一个判断优先级最高:即使列表只有 ["complex"],也在这里返回 False。
+        # return 会直接离开整个函数,不会再去检查下面的“单意图”规则。
+        if "complex" in intents:
+            return False
+
+        # 规则1:单意图直接跳过
+        if len(intents) <= 1:
+            return True
+
+        # 规则2:多意图但全部为独立查询类,无需分步规划
+        independent_intents = {"weather", "flight", "train", "event", "city_service",
+                               "car_rental", "meeting_package", "insurance", "trip_order"}
+        # 逐项检查:只要发现一个不在集合里的意图就返回 False。
+        # 所有项都检查完仍没提前返回,才执行循环后的 return True;集合成员检查不调用模型。
+        for intent in intents:
+            if intent not in independent_intents:
+                # 存在需要分步的意图(如 order),不跳过
+                # TODO 如果大模型认为意图非常复杂,使用complex。这里命中False
+                return False
+        return True
+
+    def planning_agent(self, intents: list, user_queries: dict) -> dict:
+        """
+        任务规划:直接进行任务的拆解
+
+        注意:在调用此方法前会先通过 _should_skip_planning 做启发式判断,
+        只有复杂任务才会进入此方法,省去不必要的 LLM 调用。
+
+        参数:
+            intents (list): 识别到的意图列表,如 ["weather", "flight", "city_service"]
+            user_queries (dict): 改写后的查询字典,如 {"weather": "...", "flight": "..."}
+
+        返回值:
+            dict: 规划结果,格式为:
+                - 简单任务:{"need_plan": false, "reason": "单意图,直接查询即可", "steps": []}
+                - 复杂任务:{"need_plan": true, "reason": "多意图需要分步执行",
+                            "steps": [{"step": 1, "action": "...", "intent": "weather", "depends_on": 0}, ...]}
+        """
+        # 【主流程 5.2:可选】只有规则未跳过时才调用规划模型,返回 need_plan 和 steps。
+        # 即使调用了规划模型,模型仍可能返回 need_plan=False,随后走直接执行分支。
+        # 调用者:chat/chat_stream 的规划分支。输入:意图列表和按意图保存的改写查询。
+        # 输出:Python 字典,例如 {"need_plan": True, "steps": [{"step": 1, "intent": "weather", "depends_on": 0}]}。
+        # step 是步骤编号;intent 用来找代理;action 描述动作;depends_on 指要先拿到哪个步骤的结果。
+        chain = SmartBizTripPrompts.planning_prompt() | self.llm
+
+        # 调用参数中的 query 从 messages 最后一条取;对话入口刚加入了本轮用户消息。
+        # json.dumps 把意图列表/查询字典变成提示词中的文本,并不是向子代理发送请求。
+        planning_response = chain.invoke({
+            "conversation_history": self.memory.get_short_term_text(),
+            "query": self.messages[-1]["content"] if self.messages else "",
+            "intents": json.dumps(intents, ensure_ascii=False),
+            "user_queries": json.dumps(user_queries, ensure_ascii=False)
+        }).content.strip()
+        logger.info(f"规划响应: {planning_response}")
+
+        planning_response = re.sub(r'^```json\s*|\s*```$', '', planning_response).strip()
+        # 这里只将模型文本变成字典;步骤编号、依赖存在性和循环依赖要到 _execute_plan 再检查。
+        plan = json.loads(planning_response)
+        return plan
+
+    async def _call_agent_intent(self, intent: str, query_str: str) -> str:
+        """
+        统一调用 agent 的底层逻辑
+
+        这是一个私有方法(以 _ 开头),不直接对外暴露,供以下两个场景复用:
+        1. 简单任务直接执行时(chat 方法的 else 分支)
+        2. ReAct 循环中的步骤执行时(execute_step 方法)
+
+        这样做的好处:避免了代码重复,修改 agent 调用逻辑时只需改一处。
+
+        处理逻辑说明:
+        - 城市接待建议先尝试调用天气子代理,再让主模型根据天气生成建议
+        - 如果是其他意图,通过 A2A 协议调用对应的子代理
+        - 收到结果后,根据代理类型做适当的总结(天气/票务需要总结,其他直接返回)
+
+        参数:
+            intent (str): 意图类型,如 "weather"、"flight"、"city_service"
+            query_str (str): 查询内容,如 "北京明天天气"
+
+        返回值:
+            str: agent 返回的结果文本
+        """
+        # 根据意图类型,从配置中查找对应的 agent 名称
+        # 【主流程 6.1】把 intent 映射成子助手名称;city_service 走单独的城市建议分支。
+        # 调用者:非流式 chat 的直接分支,或复杂计划的 execute_step。
+        # 输入:intent 决定交给谁,query_str 告诉对方具体做什么;输出:一次性返回一个字符串。
+        # 例如 intent="train" 时,配置映射得到 TicketAssistant,再创建 Task 发给票务服务。
+        # 查的是本地映射字典。例如 weather→WeatherQueryAssistant,train/flight→TicketAssistant。
+        # 没有映射时得到 None;city_service 虽不在映射里,仍会被下方单独的 if 分支处理。
+        agent_name = conf.intent.get(intent)
+
+        # ========== 情况1:城市接待建议 ===========
+        # 城市建议没有独立的接待代理;先尝试查天气,再由主模型生成建议。
+        # 天气是否可用取决于子代理和数据源,不能仅凭调用就认定查询成功。
+        if intent == "city_service":
+            chain = SmartBizTripPrompts.city_service_prompt() | self.llm
+            weather_info = await self._fetch_weather_for_city_service(query_str)
+            return chain.invoke({"query": query_str, "weather_info": weather_info}).content.strip()
+
+        # ========== 情况2:需要调用子代理 ===========
+        elif agent_name:
+            # 对于这些查询意图,保存意图类型和整段查询文本到记忆中
+            # 这样后续对话可以引用这些实体(多轮对话场景)
+            # extract_entities 内部已自动持久化到数据库
+            if intent in ["flight", "train", "event", "car_rental", "meeting_package", "insurance"]:
+                # 这里先记查询历史,再更新当前任务,随后才实际查询。
+                # 因此“查询历史里有一条记录”不代表该次业务查询已成功。
+                self.memory.extract_entities(intent, query_str)
+                self.memory.update_task_context({"type": intent, "query": query_str})
+
+            # 从代理网络中获取对应的 agent 实例
+            # 【主流程 6.2】取得 A2A 客户端,准备携带历史和本轮查询的 Task。
+            agent = self.agent_network.get_agent(agent_name)
+            # 检查是否取到了客户端对象;即使对象存在,远端服务也可能未启动,通信时仍可能抛异常。
+            if agent is None:
+                # agent 未注册或不可用时,返回友好提示
+                logger.warning(f"未找到代理:{agent_name}")
+                return f"抱歉,{agent_name} 暂时不可用,请稍后重试。"
+
+            # 构建发送给 agent 的消息:结构化传递对话历史和当前查询
+            # 给子助手的 TextContent 内部放一段 JSON 字符串,包含 history 和 query。
+            # 这两个字段是业务内容;外层 A2A Message/Task 另外负责协议封装。
+            agent_input = json.dumps({
+                "history": self.memory.get_short_term_text(),
+                "query": query_str
+            }, ensure_ascii=False)
+            msg = Message(content=TextContent(text=agent_input), role=MessageRole.USER)
+            # Message 用角色和 TextContent 包装文本,to_dict 将它转换为 Task 所接收的消息字典。
+            # UUID 给每次代理任务生成独立标识,便于区分请求;它不是订单号或用户会话号。
+            task = Task(id="task-" + str(uuid.uuid4()), message=msg.to_dict())
+
+            # 通过 A2A 协议异步发送任务给子代理
+            # 线程池运行独立事件循环来等待 A2A;当前协程用 await 等待其返回。
+            # 这里只把这段 A2A 调用移入线程池,并未把所有同步模型/数据库调用异步化。
+            try:
+                # 【主流程 6.3 → 7】经 A2A 把任务送到独立子助手进程,等待其完成工具调用。
+                raw_response = await asyncio.get_event_loop().run_in_executor(
+                    # None 表示使用默认线程池;lambda 是稍后由线程执行的无参数函数。
+                    # 线程内 asyncio.run 创建事件循环并运行 send_task_async;外层 await 等待线程结果。
+                    # 两层返回最终都汇合到 raw_response,也就是带状态和产物的 A2A Task。
+                    None, lambda: asyncio.run(agent.send_task_async(task))
+                )
+                logger.info(f"{agent_name} 原始响应: {raw_response}")
+
+                # 解析 agent 返回结果
+                # 【主流程 9】取回 task.artifacts 中的文本,或读取状态里的追问/失败信息。
+                # COMPLETED 只代表代理处理结束;仍要看返回内容,不能据此认定有数据或已真实下单。
+                if raw_response.status.state == 'completed' and raw_response.artifacts:
+                    # 任务成功完成,从 artifacts 中提取结果文本
+                    # 数据路径:artifacts 是产物列表 → 第一个产物的 parts 列表 → 第一个 part 的 text。
+                    # 当前只取第一个文本 part;不会在这里遍历、合并所有产物。
+                    agent_result = raw_response.artifacts[0]['parts'][0]['text']
+                elif raw_response.status.message:
+                    # 有消息但可能失败了,尝试从消息中提取文本
+                    agent_result = raw_response.status.message.get('content', {}).get('text', str(raw_response.status.message))
+                else:
+                    agent_result = f"查询失败:{raw_response.status.message or '未知错误'}"
+            except Exception as e:
+                # agent 服务不可用(如网络不通、服务崩溃),返回友好错误提示
+                logger.error(f"{agent_name} 调用异常: {str(e)}")
+                return f"{agent_name} 服务暂时不可用:{str(e)}"
+
+            # 根据代理类型,使用对应的总结 prompt 处理结果
+            # 【主流程 10】按代理类型整理结果。流式版本在 astream 时持续产出文本片段。
+            if agent_name == "WeatherQueryAssistant":
+                chain = SmartBizTripPrompts.summarize_weather_prompt() | self.llm
+                return chain.invoke({"query": query_str, "raw_response": agent_result}).content.strip()
+            # 非流式版本此处仅匹配这两个名称;当前 TicketAssistant 会走下面的原文返回分支。
+            # 流式版本则将 TicketAssistant 也纳入票务总结,两个入口的处理细节并不完全相同。
+            elif agent_name == "TicketQueryAssistant" or agent_name == "TripAssistant":
+                chain = SmartBizTripPrompts.summarize_ticket_prompt() | self.llm
+                return chain.invoke({"query": query_str, "raw_response": agent_result}).content.strip()
+            else:
+                # 其他代理直接返回原始结果
+                return agent_result
+
+        # ========== 情况3:未识别的意图 ===========
+        else:
+            return "暂不支持此意图。"
+
+    async def execute_step(self, step: dict, user_queries: dict,
+                           previous_results: list | None = None) -> str:
+        """
+        执行单个计划步骤(供 ReAct 循环调用)
+
+        这个方法负责将规划中的单个步骤转化为实际的执行:
+        1. 从步骤中提取意图和查询内容
+        2. 调用 _call_agent_intent 执行实际的查询
+
+        参数:
+            step (dict): 计划中的单个步骤,格式如:
+                {"step": 1, "action": "查询天气", "intent": "weather", "depends_on": 0}
+            user_queries (dict): 改写后的查询字典
+
+        返回值:
+            str: 该步骤的执行结果
+        """
+        # 【计划执行 P4】取出当前步骤的意图和查询;必要时将动作及前置结果一起传给子助手。
+        # 调用者:_execute_plan 批量调度时执行。
+        # 输入:step 是当前步骤字典,user_queries 是意图→查询的映射,previous_results 是祖先步骤结果列表。
+        # 输出:当前步骤的结果字符串。它不会自行决定再增加步骤,只执行规划里已有的一步。
+        intent = step.get("intent", "")
+        # 优先从 user_queries 中获取改写后的查询,如果没有则用用户原始输入
+        # 按当前步骤 intent 查找改写查询;没有该键时退回最后一条消息文本,再没有则用空串。
+        # 同一意图的多个计划步骤可能取到同一段查询,后面的 action 用于区分各步要完成的动作。
+        query_str = user_queries.get(intent, self.messages[-1]["content"] if self.messages else "")
+        logger.info(f"执行步骤:{step.get('action', '')},意图:{intent}")
+
+        # 将执行动作和前置结果传给子代理,不额外增加 Thought 模型调用。
+        # 有前置结果或有动作说明时,把查询升级为结构化 JSON 文本。
+        # previous_results 是已执行步骤的结果,不是尚未执行的计划;由子助手阅读并决定工具参数。
+        if previous_results or step.get("action"):
+            query_str = json.dumps({
+                "query": query_str,
+                "action": step.get("action", ""),
+                "previous_results": previous_results or [],
+                "instruction": "依据前置结果完成当前动作;结果不足或失败时请说明或追问,不得编造参数。",
+            }, ensure_ascii=False)
+
+        # 委托给统一的 agent 调用方法
+        # await 等待一个步骤完成,return 把它的文本交回 _execute_plan 的 gather 收集。
+        return await self._call_agent_intent(intent, query_str)
+
+    async def _execute_plan(self, steps: list, user_queries: dict) -> list:
+        """先校验依赖,再按就绪批次执行;后续步骤携带前置结果。"""
+        # 【计划执行 P1】先校验所有步骤号:必须是唯一正整数;depends_on=0 表示没有前置步骤。
+        # 当前每步只接收一个直接前置步骤编号,后续会沿这条依赖链向前收集结果。
+        # 调用者:react_loop 或 _react_loop_stream;两种入口共用同一个执行计划的方法。
+        # 输入:steps 列表与 user_queries 字典;输出:含 step/description/result 的结果列表。
+        # 这里分为两个阶段:先完整排批次,再实际跑任务。pending/scheduled 属于排批次阶段,completed 属于执行阶段。
+        # 例:1 无依赖,2 依赖 1,3 无依赖 → 第一批 1 和 3,第二批 2;步骤 2 会收到步骤 1 的结果。
+        # pending 以步骤号为键,方便后面快速判断依赖是否存在,以及删除已经排入批次的步骤。
+        pending = {}
+        for step in steps:
+            number = step.get("step")
+            # 用 type(...) is int 也会拒绝 True/False;在 Python 中 bool 是 int 子类,但这里不允许把它当步骤号。
+            # 编号小于等于 0 或重复也会直接抛错;0 专门保留为“无依赖”的标记。
+            if type(number) is not int or number <= 0 or number in pending:
+                raise ValueError("计划步骤序号必须为不重复的正整数")
+            pending[number] = step
+        for step in steps:
+            dep = step.get("depends_on", 0)
+            # 依赖为 0 可以通过;非 0 依赖必须在本次计划里找到对应步骤。
+            # 此时 pending 已收集所有步骤,所以前置步骤写在输入列表后面也可以被找到。
+            if type(dep) is not int or (dep != 0 and dep not in pending):
+                raise ValueError("计划包含不存在的前置步骤")
+
+        # 完整校验后才执行,避免发现循环依赖前已执行部分动作。
+        # 【计划执行 P2】只计算执行批次,尚未调用代理。scheduled 记录已安排的步骤。
+        # 例如 1→2,另有独立步骤 3,则批次为 [1,3]、[2];不存在的依赖和环会提前报错。
+        batches = []
+        # 集合先放 0:这样所有 depends_on=0 的步骤在第一轮就满足“前置已安排”。
+        scheduled = {0}
+        # 每轮选出一批就绪步骤;只要还有未安排步骤就继续。
+        # 此阶段不执行业务,只修改 pending/scheduled/batches 三个排程容器。
+        while pending:
+            # 列表推导式逐个查看尚未安排的步骤,只保留其直接前置已在 scheduled 中的项。
+            # 同一轮 ready 生成后才更新 scheduled,所以链条 1→2 不会在这一轮同时被加入 ready。
+            ready = [step for step in pending.values()
+                     if step.get("depends_on", 0) in scheduled]
+            # pending 还有步骤却没有任何一步可安排,说明剩余依赖形成环,例如 1→2→1 或自己依赖自己。
+            # 抛出异常退出,避免一边执行一边才发现计划根本无法完成。
+            if not ready:
+                raise ValueError("计划存在循环依赖")
+            batches.append(ready)
+            for step in ready:
+                scheduled.add(step["step"])
+                # 删除的是“待安排字典”里的键,不删除原始 steps 列表,也不是删除业务结果。
+                del pending[step["step"]]
+
+        # 【计划执行 P3】依赖全部验证通过后才真正执行;completed 保存已执行结果。
+        completed = {}
+        results = []
+        # 重新建立完整步骤索引;前面的 pending 已经排空,追溯依赖时要用这份完整索引。
+        by_number = {step["step"]: step for step in steps}
+        # 外层循环按批次串行;本批 await gather 返回后才会进入下一批。
+        for batch in batches:
+            tasks = []
+            for step in batch:
+                # 每个步骤单独创建祖先列表,避免把另一个并行步骤的前置结果混进来。
+                ancestors = []
+                dep = step.get("depends_on", 0)
+                # 向前追溯所有祖先,再反转为“最早前置 → 直接前置”的顺序传给当前步骤。
+                while dep:
+                    # 先拿直接前置的结果,再把 dep 改为它的前置;一直追到 0 结束。
+                    # 由于批次已正确排列,执行到这里时这些前置结果都已放进 completed。
+                    ancestors.append(completed[dep])
+                    dep = by_number[dep].get("depends_on", 0)
+                # 调用 async def 先得到协程对象,append 只收集它;真正调度在后面的 gather。
+                # reversed 把“最近前置→最早前置”倒过来,list 将反向迭代器变成可传递的列表。
+                tasks.append(self.execute_step(step, user_queries, list(reversed(ancestors))))
+            # 【计划执行 P4 → 主流程 6~9】同一批并发调度,整批返回后才进入下一批。
+            # 列表中的协程在 gather 时才被调度;返回结果与 tasks 的位置对应,不按完成先后排序。
+            # return_exceptions=True:普通子任务异常作为结果项返回,便于同批其他任务完成。
+            # 它不是在此处自动重试,也不会把数据库/同步模型调用全部变成并行计算。
+            outputs = await asyncio.gather(*tasks, return_exceptions=True)
+            # 【计划执行 P5】异常转成失败文本并记录;后续依赖步骤仍会收到该文本继续执行。
+            # 这里没有依据失败内容自动取消后续步骤,也没有动态重规划。
+            # zip 按位置把原步骤和对应返回值配对,即便步骤 3 比步骤 1 更早完成也不会配错。
+            for step, output in zip(batch, outputs):
+                if isinstance(output, Exception):
+                    output = f"执行失败:{output}"
+                result = {"step": step["step"],
+                          "description": step.get("description", step.get("action", "")),
+                          "result": output}
+                # 同一份 result 一方面按步骤号保存给后续依赖读取,一方面加入 results 用于最终汇总。
+                completed[step["step"]] = result # completed 给后续步骤查找前置结果
+                results.append(result)
+        return results
+
+    async def react_loop(self, steps: list, user_queries: dict) -> str:
+        """
+        ReAct 循环:按规划步骤逐步执行,直接行动(Action),跳过 Thought 推理
+
+        执行流程:
+        1. 按依赖关系分组步骤
+        2. 无依赖的步骤并行执行
+        3. 记录每步结果(Observation)
+        4. 最终汇总生成连贯回复
+
+        优化:省略 Thought LLM 调用(plan 已确定动作,Thought 无额外决策价值),
+        省去每步 1 次 LLM 调用,多步骤场景下显著减少响应时间。
+
+        参数:
+            steps (list): 计划步骤列表,如:
+                [{"step": 1, "action": "...", "intent": "flight", "depends_on": 0},
+                 {"step": 2, "action": "...", "intent": "weather", "depends_on": 0}]
+            user_queries (dict): 改写后的查询字典
+
+        返回值:
+            str: 综合所有步骤结果的最终回复
+        """
+        # 【主流程 5:复杂分支 → 6~9】先完成整个计划;每步通过非流式代理调用获得结果。
+        # 随后进入主流程 10:多个结果由模型汇总,单个结果直接返回;并非数据库逐条推送。
+        # 调用者:非流式 chat 的 need_plan=True 分支。
+        # 输入:计划步骤和查询字典;输出:完整最终回复字符串。执行步骤并不在本方法内逐个展开,而是交给 _execute_plan。
+        step_results = await self._execute_plan(steps, user_queries)
+        # 提取每步文本供单步骤返回;step_results 本身仍保留编号和说明,供多步骤汇总使用。
+        observations = [item["result"] for item in step_results]
+
+        # ========== 所有步骤完成后的最终汇总 ==========
+        # 多个步骤才额外调用汇总模型;一步直接返回该结果,零步返回“暂无结果”。
+        if len(step_results) > 1:
+            # 多个步骤:使用专门的汇总 prompt,将各步骤结果整合成一条连贯回复
+            summary_chain = SmartBizTripPrompts.react_summary_prompt() | self.llm
+            # 把编号、动作说明和结果拼成多行文本,让汇总模型知道每段结果来自哪一步。
+            all_obs = "\n".join([f"步骤{s['step']} ({s['description']}): {s['result']}" for s in step_results])
+            final_response = summary_chain.invoke({
+                "query": self.messages[-1]["content"] if self.messages else "",
+                "all_observations": all_obs
+            }).content.strip()
+        else:
+            # 只有一个步骤:直接使用该步骤的结果
+            final_response = observations[0] if observations else "暂无结果"
+
+        return final_response
+
+    async def chat(self, user_input: str) -> str:
+        """
+        处理用户输入的主方法 —— 整个对话流程的入口
+
+        完整流程:
+        1. 记录用户消息到记忆中
+        2. 意图识别:判断用户想做什么
+        3. 处理特殊情况(超出范围 / 需要追问)
+        4. 任务规划:判断是简单任务还是复杂任务
+        5. 执行:简单任务直接执行,复杂任务进入 ReAct 循环
+        6. 记录回复到记忆中
+        7. 返回最终回复
+
+        参数:
+            user_input (str): 用户的输入内容
+
+        返回值:
+            str: 助手的回复内容
+
+        使用示例:
+            service = ChatService()
+            response = await service.chat("北京明天天气怎么样?")
+        """
+        # 将用户消息保存到记忆中(短期记忆、对话历史等)
+        # 【主流程 3】先保存本轮用户消息,让意图识别能读取最新历史。
+        # short_term_messages 有长度限制;messages 和 conversation_history 在此处持续追加。
+        # 调用者:POST /api/chat。输入:用户文本;输出:return 一个完整字符串。
+        # 正常路径中 response 最终总是汇总后的回复;异常路径改为返回 error_message。
+        # 与流式版本不同,这个方法不使用 yield,HTTP 调用方必须等全部业务处理结束。
+        # 这一步位于下方 try 之前;先记录用户消息,再做意图识别。
+        # 后续读取短期历史时已经包含当前问题;写入历史不意味着后面的模型调用一定成功。
+        self.memory.add_message("user", user_input)
+        self.messages.append({"role": "user", "content": user_input})
+        self.conversation_history += f"\nUser: {user_input}"
+
+        try:
+            # ========== 步骤1:意图识别 ==========
+            # 【主流程 4】调用意图识别,取得意图列表、各意图查询和追问。
+            intents, user_queries, follow_up_message = self.intent_agent(user_input)
+
+            # ========== 步骤2:处理特殊情况 ==========
+            if "out_of_scope" in intents:
+                # 用户输入超出系统能力范围,返回追问消息
+                response = follow_up_message
+            elif follow_up_message != "":
+                # 意图不明确,需要追问用户
+                response = follow_up_message
+            else:
+                # ========== 步骤3:任务规划 ==========
+                # 启发式判断:单意图或独立多意图跳过 planning_agent,省去1次LLM调用
+                # 【主流程 5】先用规则判断是否需要规划,再按 need_plan 选择执行分支。
+                if self._should_skip_planning(intents):
+                    plan = {"need_plan": False, "reason": "启发式判断:任务简单,可直接执行", "steps": []}
+                    logger.info(f"跳过规划: {plan['reason']}")
+                else:
+                    plan = self.planning_agent(intents, user_queries)
+                # 只读取规划结论;缺字段时按 False 处理。后面 if need_plan 才决定是否执行 steps。
+                need_plan = plan.get("need_plan", False)
+                logger.info(f"规划结果: need_plan={need_plan}, reason={plan.get('reason', '')}")
+
+                if need_plan:
+                    # ========== 复杂任务:进入 ReAct 循环 ==========
+                    # 获取计划步骤,逐步执行
+                    steps = plan.get("steps", [])
+                    response = await self.react_loop(steps, user_queries)
+                else:
+                    # ========== 简单任务:直接执行 ==========
+                    # 对每个识别到的意图,直接调用对应的处理逻辑
+                    responses = []    # 收集每个意图的回复
+                    routed_agents = []  # 记录路由到了哪些 agent
+
+                    # 【主流程 6:直接分支】逐个意图等待处理完成;这里是串行,不是并发。
+                    for intent in intents:
+                        logger.info(f"处理意图:{intent}")
+                        # 获取改写后的查询内容
+                        # 用当前意图取对应查询;当前代码缺键时退回空字典,并没有在这里自动补齐查询。
+                        query_str = user_queries.get(intent, {})
+                        # 统一调用 agent 处理
+                        result = await self._call_agent_intent(intent, query_str)
+                        responses.append(result)
+                        # 记录配置直接映射的代理;城市建议内部调用的天气代理不计入此列表。
+                        if intent != "city_service" and conf.intent.get(intent):
+                            routed_agents.append(conf.intent[intent])
+
+                    # 将多个意图的回复用空行拼接成最终回复
+                    response = "\n\n".join(responses)
+                    if routed_agents:
+                        logger.info(f"路由到代理:{routed_agents}")
+
+            # 将助手回复也保存到记忆中
+            # 【主流程 11】已有完整回复后再存助手消息,为下一轮提供上下文。
+            # 成功走完分支后保存整段 response;只保存最终拼接文本,不为每个流式片段新增一条消息。
+            self.memory.add_message("assistant", response)
+            self.conversation_history += f"\nAssistant: {response}"
+            self.messages.append({"role": "assistant", "content": response})
+            return response
+
+        # 【异常返回】意图或规划 JSON 无法解析时记录并返回提示,不继续后面的业务步骤。
+        except json.JSONDecodeError as e:
+            # 大模型返回的不是有效 JSON(虽然概率低,但偶尔会发生)
+            logger.error(f"意图识别JSON解析失败")
+            error_message = f"意图识别JSON解析失败:{str(e)}。请重试。"
+            self.memory.add_message("assistant", error_message)
+            self.messages.append({"role": "assistant", "content": error_message})
+            return error_message
+        except Exception as e:
+            # 捕获其他所有异常(网络错误、服务不可用等)
+            logger.error(f"处理异常: {str(e)}")
+            error_message = f"处理失败:{str(e)}。请重试。"
+            self.memory.add_message("assistant", error_message)
+            self.messages.append({"role": "assistant", "content": error_message})
+            return error_message
+
+    def get_agent_cards(self) -> list:
+        """
+        获取所有代理的卡片信息
+
+        Agent Card 是 A2A 协议中描述一个 agent 能力的方式,
+        包含:名称、技能列表、描述、地址、状态等。
+
+        返回值:
+            list: 代理卡片列表,每个卡片格式为:
+                {
+                    "name": "WeatherQueryAssistant",
+                    "skills": ["天气查询: 根据城市查询天气"],
+                    "description": "天气查询助手",
+                    "url": "http://localhost:5005",
+                    "status": "在线"
+                }
+        """
+        # 【旁路:代理卡片】读取每个已登记代理的卡片,转换成网页需要的字典列表。
+        # status 的“在线”是这里写入的展示文本,不能证明该代理下游的 MCP/数据库可用。
+        # 调用者:/api/agents 接口或 get_available_tools。
+        # 输入:已注册的代理名称;输出:可被 JSON 序列化的字典列表,供网页绘制卡片。
+        cards = []
+        for agent_name in self.agent_network.agents.keys():
+            agent_card = self.agent_network.get_agent_card(agent_name)
+            agent_url = self.agent_urls.get(agent_name, "未知地址")
+            cards.append({
+                "name": agent_name,
+                # 将每个技能对象格式化为 "技能名: 技能描述" 的字符串
+                "skills": [s.name + ": " + s.description for s in agent_card.skills],
+                "description": agent_card.description,
+                "url": agent_url,
+                "status": "在线"
+            })
+        return cards
+
+    def get_memory_state(self) -> dict:
+        """
+        获取当前记忆状态的摘要信息(用于调试和展示)
+
+        返回值:
+            dict: 包含以下信息:
+                - short_term_messages: 最近5条对话消息
+                - user_profile: 用户偏好
+                - current_task: 当前任务上下文
+                - entity_history: 最近5条实体记录
+        """
+        # 【旁路:记忆展示】只取最近 5 条消息和查询记录,返回给网页侧栏;不请求模型。
+        # 调用者:GET /api/memory。输出包含四个字段:最近对话、偏好、当前任务、最近查询历史。
+        # 这里把角色标记 user/assistant 转成“用户/助手”供展示,底层记忆中仍保留原始角色标记。
+        return {
+            "short_term_messages": [
+                {"role": "用户" if m["role"] == "user" else "助手", "content": m["content"], "timestamp": m["timestamp"]}
+                for m in self.memory.short_term_messages[-5:]
+            ],
+            "user_profile": self.memory.user_profile,
+            "current_task": self.memory.current_task,
+            "entity_history": self.memory.entity_history[-5:]
+        }
+
+    def clear_memory(self):
+        """
+        清空所有记忆(短期记忆、用户偏好、任务上下文等)
+
+        网页调用 /api/memory/clear 接口时调用,用于重置对话状态。
+        memory.clear() 内部会自动清理数据库中的持久化数据。
+        """
+        # 【旁路:清空】先清理 memory 管理的数据,再清理 ChatService 自身维护的两份历史。
+        # 调用者:POST /api/memory/clear。没有聊天文本输入,也不返回回复内容。
+        # self.memory、self.messages、self.conversation_history 是三处状态,所以需要分别清理。
+        self.memory.clear()
+        self.messages.clear()
+        self.conversation_history = ""
+
+    async def chat_stream(self, user_input: str):
+        """
+        流式处理用户输入的主方法 —— 决策主线与 chat 相同,最终回复通过 yield 分片返回
+
+        完整流程:
+        1. 记录用户消息到记忆中
+        2. 意图识别:判断用户想做什么
+        3. 处理特殊情况(超出范围 / 需要追问)
+        4. 任务规划:判断是简单任务还是复杂任务
+        5. 执行:简单任务直接执行,复杂任务进入 ReAct 循环
+        6. 最终回复通过 yield 分片返回,再保存拼接后的完整回复
+
+        参数:
+            user_input (str): 用户的输入内容
+
+        Yields:
+            str: 助手回复的文本片段
+        """
+        # 【主流程 3】先保存本轮用户消息,让意图识别能读取最新历史。
+        # short_term_messages 有长度限制;messages 和 conversation_history 在此处持续追加。
+        # 调用者:api_server.sse_generator 的 async for。输入:本轮用户文本;输出:多次 yield 的字符串。
+        # 顺序:记用户消息 → 识别意图 → 追问或规划/执行 → 边产出边收集回复 → 记助手消息。
+        # yield 只是把当前片段交给调用者,不表示本函数结束;外层继续取值时会从下一行接着运行。
+        # 片段传递共三层:模型/下层生成器 → 本方法 → API 的 SSE 生成器 → 浏览器。
+        # 这一步位于下方 try 之前;先记录用户消息,再做意图识别。
+        # 后续读取短期历史时已经包含当前问题;写入历史不意味着后面的模型调用一定成功。
+        self.memory.add_message("user", user_input)
+        self.messages.append({"role": "user", "content": user_input})
+        self.conversation_history += f"\nUser: {user_input}"
+
+        try:
+            # 【主流程 4】调用意图识别,取得意图列表、各意图查询和追问。
+            intents, user_queries, follow_up_message = self.intent_agent(user_input)
+
+            # 【主流程 5:提前返回分支】超出范围或需要补信息时直接 yield 回复,跳过代理/工具。
+            if "out_of_scope" in intents or follow_up_message != "":
+                response = follow_up_message
+                # 追问分支只产出一次整段文字;继续取值时仍会向后运行到保存助手消息的部分。
+                yield response
+            else:
+                # 【主流程 5】先用规则判断是否需要规划,再按 need_plan 选择执行分支。
+                if self._should_skip_planning(intents):
+                    plan = {"need_plan": False, "reason": "启发式判断:任务简单,可直接执行", "steps": []}
+                    logger.info(f"跳过规划(stream): {plan['reason']}")
+                else:
+                    plan = self.planning_agent(intents, user_queries)
+                # 只读取规划结论;缺字段时按 False 处理。后面 if need_plan 才决定是否执行 steps。
+                need_plan = plan.get("need_plan", False)
+                logger.info(f"规划结果: need_plan={need_plan}, reason={plan.get('reason', '')}")
+
+                if need_plan:
+                    steps = plan.get("steps", [])
+                    # 【主流程 10】一边转发片段,一边保留副本;结束后拼成 response 供第 11 步保存。
+                    parts = []
+                    async for chunk in self._react_loop_stream(steps, user_queries):
+                        parts.append(chunk)
+                        yield chunk
+                    response = "".join(parts)
+                else:
+                    responses = []
+                    # 【主流程 6:直接分支】逐个意图等待处理完成;这里是串行,不是并发。
+                    for intent in intents:
+                        # 已有前一个意图结果时先输出两个换行;这里的 if 不会跳出整个意图循环。
+                        if responses:
+                            yield "\n\n"
+                        logger.info(f"处理意图:{intent}")
+                        # 用当前意图取对应查询;当前代码缺键时退回空字典,并没有在这里自动补齐查询。
+                        query_str = user_queries.get(intent, {})
+                        # parts 收集当前意图片段;responses 收集各意图全文。每次进入新意图都要新建 parts。
+                        parts = []
+                        async for chunk in self._call_agent_intent_stream(intent, query_str):
+                            parts.append(chunk)
+                            yield chunk
+                        # 空串 join 把当前意图的片段按顺序拼成全文,加入 responses 后才处理下一个意图。
+                        responses.append("".join(parts))
+
+                    # 所有意图结束后用空行连接,形成待保存的完整助手回复。
+                    response = "\n\n".join(responses)
+
+            # 【主流程 11】已有完整回复后再存助手消息,为下一轮提供上下文。
+            # 成功走完分支后保存整段 response;只保存最终拼接文本,不为每个流式片段新增一条消息。
+            self.memory.add_message("assistant", response)
+            self.conversation_history += f"\nAssistant: {response}"
+            self.messages.append({"role": "assistant", "content": response})
+
+        # 【异常返回】意图或规划 JSON 无法解析时记录并返回提示,不继续后面的业务步骤。
+        except json.JSONDecodeError as e:
+            logger.error("意图识别JSON解析失败")
+            error_message = f"意图识别JSON解析失败:{str(e)}。请重试。"
+            self.memory.add_message("assistant", error_message)
+            self.messages.append({"role": "assistant", "content": error_message})
+            yield error_message
+        except Exception as e:
+            logger.error(f"处理异常: {str(e)}")
+            error_message = f"处理失败:{str(e)}。请重试。"
+            self.memory.add_message("assistant", error_message)
+            self.messages.append({"role": "assistant", "content": error_message})
+            yield error_message
+
+    async def _call_agent_intent_stream(self, intent: str, query_str: str):
+        """
+        流式版本的 _call_agent_intent,仅在生成最终回复时使用流式输出
+        调用方需要自行收集 yield 的片段拼接为完整回复
+        """
+        # 【主流程 6.1】把 intent 映射成子助手名称;city_service 走单独的城市建议分支。
+        # 调用者:chat_stream 的直接分支。输入:意图与查询;输出:异步生成器产生的若干字符串片段。
+        # 这是含 yield 的 async def,调用后得到生成器;外层通过 async for 才让它逐段运行。
+        # A2A 请求在这里仍先等完整 Task 返回,随后主模型 astream 整理时才分片输出。
+        # 查的是本地映射字典。例如 weather→WeatherQueryAssistant,train/flight→TicketAssistant。
+        # 没有映射时得到 None;city_service 虽不在映射里,仍会被下方单独的 if 分支处理。
+        agent_name = conf.intent.get(intent)
+
+        if intent == "city_service":
+            chain = SmartBizTripPrompts.city_service_prompt() | self.llm
+            weather_info = await self._fetch_weather_for_city_service(query_str)
+            async for chunk in chain.astream({"query": query_str, "weather_info": weather_info}):
+                yield chunk.content if hasattr(chunk, 'content') else str(chunk)
+
+        elif agent_name:
+            if intent in ["flight", "train", "event", "car_rental", "meeting_package", "insurance"]:
+                # 这里先记查询历史,再更新当前任务,随后才实际查询。
+                # 因此“查询历史里有一条记录”不代表该次业务查询已成功。
+                self.memory.extract_entities(intent, query_str)
+                self.memory.update_task_context({"type": intent, "query": query_str})
+
+            # 【主流程 6.2】取得 A2A 客户端,准备携带历史和本轮查询的 Task。
+            agent = self.agent_network.get_agent(agent_name)  # 按名称取出已注册的 A2A 客户端
+            # 检查是否取到了客户端对象;即使对象存在,远端服务也可能未启动,通信时仍可能抛异常。
+            if agent is None:
+                logger.warning(f"未找到代理:{agent_name}")
+                yield f"抱歉,{agent_name} 暂时不可用,请稍后重试。"
+            else:
+                # 流式版本拼接历史文本;非流式版本使用包含 history/query 的 JSON 文本。
+                # 本路径把历史和当前改写查询直接拼成对话文本,而不是非流式版本的 history/query JSON。
+                # 原始用户消息已在记忆中,因此追加的是用于本意图处理的改写查询。
+                chat_history = self.memory.get_short_term_text() + f'\nUser: {query_str}'
+                msg = Message(content=TextContent(text=chat_history), role=MessageRole.USER)
+                # Message 用角色和 TextContent 包装文本,to_dict 将它转换为 Task 所接收的消息字典。
+                # UUID 给每次代理任务生成独立标识,便于区分请求;它不是订单号或用户会话号。
+                task = Task(id="task-" + str(uuid.uuid4()), message=msg.to_dict())
+
+                try:  ## 异步调用的调度安排
+                    # 【主流程 6.3 → 7】经 A2A 把任务送到独立子助手进程,等待其完成工具调用。
+                    raw_response = await asyncio.get_event_loop().run_in_executor(
+                        # None 表示使用默认线程池;lambda 是稍后由线程执行的无参数函数。
+                        # 线程内 asyncio.run 创建事件循环并运行 send_task_async;外层 await 等待线程结果。
+                        # 两层返回最终都汇合到 raw_response,也就是带状态和产物的 A2A Task。
+                        None, lambda: asyncio.run(agent.send_task_async(task))
+                    )
+                    logger.info(f"{agent_name} 原始响应: {raw_response}")
+
+                    # 【主流程 9】取回 task.artifacts 中的文本,或读取状态里的追问/失败信息。
+                    # COMPLETED 只代表代理处理结束;仍要看返回内容,不能据此认定有数据或已真实下单。
+                    if raw_response.status.state == 'completed' and raw_response.artifacts:
+                        # 数据路径:artifacts 是产物列表 → 第一个产物的 parts 列表 → 第一个 part 的 text。
+                        # 当前只取第一个文本 part;不会在这里遍历、合并所有产物。
+                        agent_result = raw_response.artifacts[0]['parts'][0]['text']
+                    elif raw_response.status.message:
+                        agent_result = raw_response.status.message.get('content', {}).get('text', str(raw_response.status.message))
+                    else:
+                        agent_result = f"查询失败:{raw_response.status.message or '未知错误'}"
+                except Exception as e:
+                    logger.error(f"{agent_name} 调用异常: {str(e)}")
+                    agent_result = f"{agent_name} 服务暂时不可用:{str(e)}"
+
+                # 【主流程 10】按代理类型整理结果。流式版本在 astream 时持续产出文本片段。
+                if agent_name == "WeatherQueryAssistant":
+                    chain = SmartBizTripPrompts.summarize_weather_prompt() | self.llm
+                elif agent_name in ("TicketQueryAssistant", "TicketAssistant", "TripAssistant"):
+                    chain = SmartBizTripPrompts.summarize_ticket_prompt() | self.llm
+                else:
+                    yield agent_result
+                    return
+
+                # astream 返回模型消息片段;取 content 后交给外层,外层再封装 SSE。
+                # hasattr 用于判断片段是否有 content 属性,没有时才退回 str(chunk)。
+                async for chunk in chain.astream({"query": query_str, "raw_response": agent_result}):
+                    yield chunk.content if hasattr(chunk, 'content') else str(chunk)
+
+        else:
+            yield "暂不支持此意图。"
+
+    async def _react_loop_stream(self, steps: list, user_queries: dict):
+        """
+        流式版本的 ReAct 循环,直接行动(跳过 Thought 推理),最终汇总时流式输出
+
+        优化:与 react_loop 一致,省略 Thought LLM 调用,减少每步 1 次 LLM 请求。
+        调用方需要自行收集 yield 的片段拼接为完整回复。
+        """
+        # 【主流程 5:复杂分支 → 6~9】先完成整个计划;每步通过非流式代理调用获得结果。
+        # 随后进入主流程 10:多个结果由模型汇总,单个结果直接返回;并非数据库逐条推送。
+        # 调用者:chat_stream 的 need_plan=True 分支。
+        # 输入:计划步骤和查询字典;输出:供外层消费的文本片段。
+        # 先 await 全部步骤结果,再由汇总模型输出;只有一步时直接 yield 那一步的完整文本。
+        step_results = await self._execute_plan(steps, user_queries)
+        # 提取每步文本供单步骤返回;step_results 本身仍保留编号和说明,供多步骤汇总使用。
+        observations = [item["result"] for item in step_results]
+
+        # 多个步骤才额外调用汇总模型;一步直接返回该结果,零步返回“暂无结果”。
+        if len(step_results) > 1:
+            summary_chain = SmartBizTripPrompts.react_summary_prompt() | self.llm
+            # 把编号、动作说明和结果拼成多行文本,让汇总模型知道每段结果来自哪一步。
+            all_obs = "\n".join([f"步骤{s['step']} ({s['description']}): {s['result']}" for s in step_results])
+            async for chunk in summary_chain.astream({
+                "query": self.messages[-1]["content"] if self.messages else "",
+                "all_observations": all_obs
+            }):
+                yield chunk.content if hasattr(chunk, 'content') else str(chunk)
+        else:
+            yield observations[0] if observations else "暂无结果"

+ 42 - 0
compose.yaml

@@ -0,0 +1,42 @@
+name: smartbiztrip
+services:
+  mysql:
+    image: mysql:8.4
+    env_file:
+      - .env.mysql
+    ports:
+      - "127.0.0.1:3306:3306"
+    volumes:
+      - mysql_data:/var/lib/mysql
+      - ./sql/create_all_tables.sql:/docker-entrypoint-initdb.d/01-schema.sql:ro
+      - ./sql/insert_may01_data.sql:/docker-entrypoint-initdb.d/02-data.sql:ro
+    healthcheck:
+      test: ["CMD-SHELL", 'MYSQL_PWD="$$MYSQL_PASSWORD" mysql --protocol=TCP -h 127.0.0.1 -u "$$MYSQL_USER" "$$MYSQL_DATABASE" -e "SELECT COUNT(*) FROM train_tickets" >/dev/null 2>&1']
+      interval: 5s
+      timeout: 5s
+      retries: 30
+      start_period: 30s
+  milvus:
+    image: milvusdb/milvus:v3.0.1
+    command: ["milvus", "run", "standalone"]
+    environment:
+      ETCD_USE_EMBED: "true"
+      ETCD_DATA_DIR: /var/lib/milvus/etcd
+      ETCD_CONFIG_PATH: /milvus/configs/embedEtcd.yaml
+      COMMON_STORAGETYPE: local
+      DEPLOY_MODE: STANDALONE
+    ports:
+      - "127.0.0.1:19530:19530"
+      - "127.0.0.1:9091:9091"
+    volumes:
+      - milvus_data:/var/lib/milvus
+      - ./docker/milvus/embedEtcd.yaml:/milvus/configs/embedEtcd.yaml:ro
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
+      interval: 10s
+      timeout: 5s
+      retries: 12
+      start_period: 90s
+volumes:
+  mysql_data:
+  milvus_data:

+ 169 - 0
config.py

@@ -0,0 +1,169 @@
+# 【启动 S1】各服务先导入本模块,再创建 Config 对象读取配置。
+# 导入时执行 load_dotenv;Config.__init__ 中的属性赋值要等 Config() 时才执行。
+# 这里只准备参数,不会启动模型服务、MySQL、Milvus 或天气采集程序。
+
+"""
+需求:管理SmartBizTrip项目的配置信息,包括大模型、数据库、日志等配置
+思路步骤:
+1. 定义项目根目录路径
+2. 设置环境变量(生产/测试/开发/预生产)
+3. 创建Config类管理所有配置项
+4. 配置大模型参数(API地址、密钥、模型名称)
+5. 配置数据库参数(主机、用户名、密码、数据库名)
+6. 配置日志文件路径
+7. 配置票务查询接口地址
+8. 配置意图映射字典
+9. 实现根据环境获取不同数据库配置的方法
+"""
+
+import os
+from dotenv import load_dotenv
+
+# 【启动 S1.1】从本文件同目录的 .env 加载变量;默认不覆盖进程已有的环境变量。
+load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), '.env'))
+
+# 本文件所在目录的上一级(代码资料包目录);不是 SmartBizTrip 目录本身。
+project_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
+
+
+# 生产环境
+# env = "prod"
+# 测试环境
+# 这里保留环境标签示例;当前 Config.__init__ 并没有依据 env 切换配置。
+env = "test"
+# 开发环境
+# env = "dev"
+# 预生产环境
+# env = "pre_prod"
+
+
+
+#定义配置文件
+class Config:
+
+    def __init__(self):
+        # 大模型配置
+        # 【启动 S1.2】os.getenv 优先取环境变量,未设置时才用第二个参数作为默认值。
+        # 没有默认值的 API Key 可能是 None;后面创建客户端或调用服务时才使用这些参数。
+        # 调用者:主服务、A2A 服务、MCP 服务和辅助脚本各自创建配置实例。
+        # 每个实例把当时的环境变量读到 self 属性中;修改 .env 文件不等于已运行进程中的实例会自动更新。
+        # 下面按模型、数据库、日志、路由、天气、向量检索分组配置,各调用方只使用自己需要的字段。
+        # base_url 决定模型请求发到哪个兼容接口;api_key 用于认证;model_name 指定模型。
+        # 这三者要配套;设置模型名本身不会在本地下载或加载模型权重。
+        self.base_url = os.getenv('DEEPSEEK_BASE_URL', 'https://api.deepseek.com')
+        self.api_key = os.getenv('DEEPSEEK_API_KEY')
+        self.model_name = os.getenv('DEEPSEEK_MODEL', 'deepseek-v4-flash')
+
+        # 数据库配置
+        # MySQL 连接需要主机、用户名、密码、数据库名。这里读取参数,不会创建用户、库或表。
+        self.host = os.getenv('MYSQL_HOST', 'localhost')
+        self.user = os.getenv('MYSQL_USER', 'smart_yoyage')
+        self.password = os.getenv('MYSQL_PASSWORD', '')
+        self.database = 'biztrip_rag'
+        # 上一行设置代码默认库名,这一行允许 MYSQL_DATABASE 覆盖它;最终使用第二次赋值后的值。
+        self.database = os.getenv('MYSQL_DATABASE', self.database)
+
+        # 日志配置
+        # 用当前配置文件的位置计算绝对日志路径,避免从不同目录启动时把日志写到不同文件夹。
+        self.log_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs', 'app.log')
+
+        # # 票务查询的12306接口地址
+        # self.url_123 = ""
+
+        # 路由:意图和对应的Agent的对应关系
+        # 【主流程 6 的路由表】多个票务意图映射到同一个 TicketAssistant。
+        # 映射值必须与 ChatService 注册的名称一致;city_service 由对话服务单独处理。
+        self.intent = {
+            "weather": "WeatherQueryAssistant",
+            "flight": "TicketAssistant",
+            "train": "TicketAssistant",
+            "event": "TicketAssistant",
+            "order": "TicketAssistant",
+            "car_rental": "TripAssistant",
+            "meeting_package": "TripAssistant",
+            "insurance": "TripAssistant",
+            "trip_order": "TripAssistant",
+        }
+
+        # A2A 子助手创建模型时读取这个值;主 ChatService 当前另行写了 temperature=0.1。
+        self.temperature = 0.1
+
+        # 天气数据源配置
+        # 当前值为 "api",查询时直接调用和风天气。
+        # MCP 代码中其余值走数据库分支;采集脚本需要另外运行。
+        self.weather_source = os.getenv("WEATHER_SOURCE", "api")
+
+        # 和风天气 API 配置--注意顺序
+        self.weather_api_key = os.getenv("QWEATHER_API_KEY", "")
+        # self.weather_base_url = "https://your_qweather_host/v7/weather/3d"
+        self.weather_api_host = os.getenv("QWEATHER_API_HOST", "your_qweather_host")
+        # 先确定 weather_api_host,再拼出预报 URL;/3d 选择的是配置中的三天预报接口。
+        # 日期范围是查询过滤条件,不能凭扩大查询日期自动扩大此接口返回的预报范围。
+        self.weather_base_url = (f"https://{self.weather_api_host}/v7/weather/3d")
+        
+        self.weather_timezone = "Asia/Shanghai"
+
+        # 天气监控城市列表(城市名 -> 城市代码)
+        # 采集脚本遍历这个字典:键用于本地存储的城市名称,值用于天气接口的 location 参数。
+        # 它不是 MCP 直接查询时允许的城市白名单;直接查询会另外通过城市查找接口获取 ID。
+        self.weather_city_codes = {
+            "北京": "101010100",
+            "成都": "101270101"
+        }
+
+        # 天气定时更新调度时间(北京时间)
+        self.weather_schedule_time = "01:00"
+
+        # Milvus 向量数据库配置
+        self.milvus_host = "localhost"
+        self.milvus_port = 19530
+        # 集合名相当于向量库里要检索的那张集合;必须与初始化脚本创建的名称一致。
+        self.meeting_package_collection = "meeting_packages"
+
+        # Qwen Embedding API 配置
+        self.embedding_url = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
+        # 向量模型与聊天模型是两种用途,这里读取独立的 DASHSCOPE_API_KEY,不会复用 DEEPSEEK_API_KEY。
+        self.embedding_api_key = os.getenv('DASHSCOPE_API_KEY')
+        # 向量维度需要与 sql/init_meeting_package_rag.py 建表时的 DIMENSION 一致。
+        self.embedding_dim = 1024
+
+    #
+    # def get_mysql_config(self,env):
+    #     """
+    #     通过不同的环境获取不同的数据库配置
+    #     :return:
+    #     """
+    #     if env == 'prod':
+    #         # 数据库配置 生产
+    #         self.host = 'localhost'
+    #         self.user = 'root'
+    #         self.password = 'root'
+    #         self.database = 'biztrip_rag'
+    #     elif env == 'dev':
+    #         # 数据库配置 开发
+    #         self.host = 'localhost1'
+    #         self.user = 'root1'
+    #         self.password = 'root1'
+    #         self.database = 'biztrip_rag'
+    #     elif env == 'test':
+    #         # 数据库配置 测试
+    #         self.host = 'localhost2'
+    #         self.user = 'root2'
+    #         self.password = 'root2'
+    #         self.database = 'biztrip_rag'
+    #     else:
+    #         # 数据库配置 预生产
+    #         self.host = 'localhost3'
+    #         self.user = 'root3'
+    #         self.password = 'root3'
+    #         self.database = 'biztrip_rag'
+    #
+    #     return self.host, self.user, self.password, self.database
+
+
+# 【独立演示入口】主项目只导入 Config,不运行这个区块。
+# 仅打印日志路径,避免输出数据库凭据。
+if __name__ == '__main__':
+    print(Config().log_file)
+    # ('localhost', 'root', 'root', 'biztrip_rag')
+    # ('localhost', 'root2', 'root2', 'biztrip_rag')

+ 63 - 0
create_logger.py

@@ -0,0 +1,63 @@
+# 【启动 S1】导入本模块时,文件末尾会调用 setup_logger 创建全局 logger。
+# 日志仅记录执行过程;出现“调用成功”等文本仍需结合真实返回内容判断业务结果。
+
+"""
+需求:为SmartBizTrip项目创建和配置日志记录器
+思路步骤:
+1. 导入必要的模块(logging、os、Config)
+2. 定义setup_logger函数配置日志记录器
+3. 创建日志文件夹(如果不存在)
+4. 获取并配置日志记录器(设置级别、防止重复输出)
+5. 定义日志格式
+6. 创建控制台处理器(输出INFO级别日志)
+7. 创建文件处理器(输出DEBUG级别日志)
+8. 将处理器添加到日志记录器中(避免重复添加)
+9. 初始化并返回配置好的日志记录器
+10. 创建项目的全局日志记录器实例
+"""
+import logging
+import os
+
+from config import Config
+
+
+def setup_logger(name, log_file='logs/app.log'):
+    # 创建日志文件夹
+    # 【日志内部步骤 1】确保日志目录存在,再取得同名 logger 并设置日志级别。
+    # 输入 name 用于区分日志来源,log_file 指定文件位置;输出配置好的 logging.Logger。
+    # 调用方以后用 logger.info/debug/error 写日志时,不必每次重新创建文件处理器。
+    # 先取日志文件的目录再创建;exist_ok=True 允许目录已经存在,不会因此报错。
+    os.makedirs(os.path.dirname(log_file), exist_ok=True)
+
+    # 获取日志记录器
+    logger = logging.getLogger(name)
+    logger.setLevel(logging.DEBUG)
+    # 防止重复输出的关键!
+    # 禁止把消息继续传给更上层 logger,避免本 logger 已输出后,根 logger 又输出同一条。
+    logger.propagate = False
+
+    # 定义日志格式
+    # 格式串里的 name/asctime/levelname/message 由 logging 在写每条记录时填入。
+    formatter = logging.Formatter('%(name)s - %(asctime)s - %(levelname)s - %(message)s')
+
+    # 创建控制台处理器
+    # 【日志内部步骤 2】控制台处理器接收 INFO 及以上级别;日志文件处理器接收 DEBUG 及以上级别。
+    console_handler = logging.StreamHandler()
+    console_handler.setFormatter(formatter)
+    console_handler.setLevel(logging.INFO)  # 消息先通过 logger 级别筛选,再通过 handler 级别筛选
+
+    # 创建文件处理器
+    # mode="a" 表示追加到现有日志,encoding="utf-8" 用于保存中文;不是每次启动清空文件。
+    file_handler = logging.FileHandler(filename=log_file, encoding="utf-8", mode="a")
+    file_handler.setFormatter(formatter)
+    file_handler.setLevel(logging.DEBUG)
+
+    # 将处理器添加到日志记录器中
+    # 【日志内部步骤 3】仅在没有处理器时挂载,避免同一条日志重复输出到多个相同处理器。
+    if not logger.handlers:  # 先进行判断,再进行添加。避免重复添加处理器
+        logger.addHandler(console_handler)
+        logger.addHandler(file_handler)
+
+    return logger
+
+logger = setup_logger('SmartBizTrip', Config().log_file)

+ 5 - 0
docker/milvus/embedEtcd.yaml

@@ -0,0 +1,5 @@
+listen-client-urls: http://0.0.0.0:2379
+advertise-client-urls: http://127.0.0.1:2379
+quota-backend-bytes: 4294967296
+auto-compaction-mode: revision
+auto-compaction-retention: '1000'

+ 353 - 0
main_prompts.py

@@ -0,0 +1,353 @@
+# 这是提示模板库,不是项目启动入口。主入口在 api_server.py。
+# 内部顺序:1 调用静态方法创建模板 → 2 用 prompt | llm 组装调用链
+# → 3 调用方 invoke/astream 填入变量并请求模型 → 4 调用方解析或转发模型输出。
+# 下面方法按场景选用,不会从第一个方法到最后一个方法依次执行。
+# 提示词正文参与模型输入,本次仅补充代码说明,不改动正文。
+
+"""
+需求:定义SmartBizTrip项目中使用的各种提示模板,用于不同场景的对话处理
+
+什么是 Prompt Template(提示模板)?
+    提示模板是一种可复用的文本模板,其中包含固定内容和可变变量(用 {变量名} 表示)。
+    例如:模板 "你好,{name}!" 在填入 name="张三" 后会变成 "你好,张三!"。
+
+为什么使用模板类管理?
+    1. 集中管理:所有 prompt 定义在同一个文件中,方便查找和修改
+    2. 可复用:同一个模板可以被多处调用
+    3. 参数化:通过变量注入不同上下文,避免字符串拼接
+
+本项目中的 Prompt 分类:
+    1. 意图识别类:intent_prompt —— 识别用户想做什么
+    2. 结果总结类:summarize_weather_prompt、summarize_ticket_prompt —— 将原始数据转化为友好回复
+    3. 内容生成类:city_service_prompt —— 直接生成城市接待建议
+    4. 任务规划类:planning_prompt —— 判断任务复杂度并生成执行计划(Planning + ReAct 架构)
+    5. ReAct推理类:react_prompt、react_summary_prompt —— 逐步推理和最终汇总
+"""
+
+from langchain_core.prompts import ChatPromptTemplate  # LangChain 的聊天提示模板类
+
+
+# @staticmethod 表示这些方法无需实例状态,可以直接用 SmartBizTripPrompts.intent_prompt() 调用。
+# Python 先定义类和方法,直到调用具体方法时才创建对应模板;方法的排列顺序不是推理顺序。
+class SmartBizTripPrompts:
+    """
+    SmartBizTrip 提示模板管理类
+
+    这个类定义了系统中所有用到的 Prompt 模板,每个模板都是一个静态方法,
+    返回一个 ChatPromptTemplate 对象。
+
+    使用方式:
+        prompt = SmartBizTripPrompts.intent_prompt()  # 获取意图识别模板
+        chain = prompt | llm                         # 组装成处理链
+        result = chain.invoke({"query": "北京天气"})  # 调用并传入变量
+    """
+
+    # ==================== 意图识别 ====================
+
+    @staticmethod
+    def intent_prompt():
+        """
+        意图识别提示模板 —— 让大模型分析用户输入,判断用户想做什么
+
+        输入变量:
+            - user_profile: 用户偏好(如"二等座"、"经济舱")
+            - task_context: 当前任务上下文(如之前查过什么)
+            - conversation_history: 对话历史(最近几轮对话)
+            - query: 用户本次输入
+
+        输出格式(JSON):
+            {
+                "intents": ["weather", "flight"],           # 识别到的意图列表
+                "user_queries": {"weather": "...", ...},    # 改写后的查询(可能结合历史补充信息)
+                "follow_up_message": ""                     # 追问消息(意图不明确时使用)
+            }
+
+        支持的意图类型:
+            weather / flight / train / event / order / car_rental / meeting_package / insurance / trip_order / city_service / out_of_scope
+        """
+        # 【主流程 4】意图识别模板;返回模板对象,由 ChatService.intent_agent 传入上下文并调用模型。
+        # 模板中的双花括号用于表示 JSON 的字面花括号,单花括号中的变量由调用方填入。
+        # 模板变量:user_profile=偏好文本,task_context=任务 JSON 文本,conversation_history=历史对话,query=当前问题。
+        # 返回模板对象后,ChatService.intent_agent 负责调用模型并解析 JSON;模板不直接返回 intents。
+        # current_date 由 ChatService 传入,供模型理解今天、明天等相对日期。
+        return ChatPromptTemplate.from_template(
+"""
+系统提示:
+角色:您是一个专业的差旅意图识别专家,
+任务:基于用户查询、对话历史和用户偏好,识别其意图,用于调用专门的agent server来执行;为方便后续的agent server处理,可以基于对话历史对用户查询进行改写,使问题更明确。
+严格遵守规则:
+- 支持意图:['weather' (天气查询), 'flight' (航班查询), 'train' (高铁票查询), 'event' (会展门票查询), 'order' (票务预定), 'car_rental' (商务用车查询), 'meeting_package' (会务套餐查询), 'insurance' (保险查询), 'trip_order' (差旅预订), 'city_service' (城市接待建议)] 或其组合(如 ['weather', 'flight'])。如果意图是差旅相关,但是非常复杂的返回意图 'complex'。如果意图超出范围,返回意图 'out_of_scope'。
+- 注意票务预定和票务查询要区分开,涉及到订票时则为order,只是查询则为flight、train或event。
+- **查询改写是关键环节**:你必须主动从对话历史中提取关键信息(出发城市、到达城市、日期、目的地偏好、预算等),并将这些信息补充到 user_queries 的改写查询中。即使当前查询很简短(如"好的"、"明天"、"成都"),也要结合历史形成完整的查询描述。例如:用户先说"我下周去成都参加数字化转型峰会",再说"有没有带接送和酒店的便宜套餐",改写后应为"成都数字化转型峰会接送酒店会务套餐,预算较低"。
+- **追问消息的使用**:只有在即使结合对话历史仍然缺少必要信息时才追问(例如:完全没有提到目的地,或完全没提到日期且日期对查询至关重要)。如果历史中已有足够信息,不要追问。
+- **非业务查询的回复**:当意图为 out_of_scope 时,follow_up_message 必须是非空的、直接给用户的回复。问候或询问能力时,友好介绍差旅助手的功能;询问对话历史时,根据已有历史回答,不要编造;其他无关问题说明服务范围并引导用户提出差旅需求。此规则不受上面的追问限制。
+- 输出严格为JSON:{{"intents": ["intent1", "intent2"], "user_queries": {{"intent1": "user_query1", "intent2": "user_query2"}}, "follow_up_message": "追问消息"}}。绝对不要添加额外文本!
+- 不论用户问什么,严格按规则输出意图,不要有自己的考虑。
+
+当前日期:{current_date}
+用户偏好:{user_profile}
+当前任务上下文:{task_context}
+对话历史:{conversation_history}
+用户查询:{query}
+""")
+
+    # ==================== 结果总结 ====================
+
+    @staticmethod
+    def summarize_weather_prompt():
+        """
+        天气结果总结提示模板 —— 将天气 agent 返回的原始数据转化为用户友好的天气描述
+
+        输入变量:
+            - query: 用户查询(如"北京明天天气")
+            - raw_response: 天气 agent 返回的原始数据
+
+        使用场景:
+            原始数据可能是 JSON 格式或结构化数据,直接展示给用户不好看,
+            所以用这个 prompt 让大模型翻译成自然语言。
+
+        示例输出:
+            "根据最新数据,北京2025-07-31的天气预报为晴天,气温25-32度,湿度45%,东南风2级..."
+        """
+        # 【主流程 10:天气分支】把原始天气结果和用户需求一起交给模型整理。
+        # query 告诉模型用户具体想查什么,raw_response 是天气代理的产物或失败/追问文本。
+        # 先查工具再总结,顺序不能倒过来理解;这个模板不会自己连接天气 API。
+        return ChatPromptTemplate.from_template(
+"""
+系统提示:您是一位专业的天气预报员,以生动、准确的风格总结天气信息。基于查询和结果:
+- 核心描述点:城市、日期、温度范围、天气描述、湿度、风向、降水等。
+- 如果结果为空或者意思为需要补充数据,则委婉提示"未找到数据,请确认城市/日期"
+- 语气:专业预报,如"根据最新数据,北京2025-07-31的天气预报为..."。
+- 保持中文,100-150字。
+- 如果查询无关,返回"请提供天气相关查询。"
+
+查询:{query}
+结果:{raw_response}
+""")
+
+    @staticmethod
+    def summarize_ticket_prompt():
+        """
+        票务结果总结提示模板 —— 将票务 agent 返回的原始数据转化为用户友好的票务推荐
+
+        输入变量:
+            - query: 用户查询(如"北京到上海航班")
+            - raw_response: 票务 agent 返回的原始数据
+
+        使用场景:
+            和天气总结类似,将结构化的票务数据(航班号、价格、时间等)
+            翻译成顾问式的推荐语言。
+
+        示例输出:
+            "为您推荐北京到上海的航班选项:MU5101航班,起飞时间08:30,经济舱价格1280元,余票充足..."
+        """
+        # 【主流程 10:票务/差旅分支】整理已有结果;模板本身不查询数据库或创建订单。
+        # query 保留筛选需求,raw_response 提供已取得的票务或差旅结果。
+        # 调用方传的是文本,模型把它整理为可读答复;模板中的规则需要由模型遵守,并不等价于 Python 校验逻辑。
+        return ChatPromptTemplate.from_template(
+"""
+系统提示:您是一位专业的差旅顾问,以热情、精确的风格总结票务信息。基于查询和结果:
+- 核心描述点:出发/到达、时间、类型、价格、剩余座位等。
+- 只总结下方“查询”要求的当前业务。忽略历史对话和原始结果中其他业务的附带说明,例如查票时不要补充“天气无法提供”。
+- 预订工具仅用于模拟;必须保留模拟标识及未实际下单的说明,不得声称已经出票或生成保单。
+- 不要承诺或引导用户在本项目进行真实预订;后续操作也只能是查询或模拟预订。
+- 如果结果为空或者意思为需要补充数据,则委婉提示"未找到数据,请确认或修改条件"
+- 语气:顾问式,如"为您推荐北京到上海的航班选项..."。
+- 保持中文,100-150字。
+- 如果查询无关,返回"请提供票务相关查询。"
+
+
+查询:{query}
+结果:{raw_response}
+""")
+
+    # ==================== 内容生成 ====================
+
+    @staticmethod
+    def city_service_prompt():
+        """
+        城市接待建议提示模板 —— 让大模型直接生成城市接待建议内容
+
+        输入变量:
+            - query: 用户查询(如"推荐几个北京接待资源")
+            - weather_info: 目的地天气信息(可选,为空时不展示天气相关描述)
+
+        特点:
+            城市接待建议不需要调用外部 agent,大模型本身就有足够的知识来生成推荐。
+            当提供 weather_info 时,应基于真实天气给出出行建议;
+            当 weather_info 为空时,不得虚构任何天气描述。
+        """
+        # 【主流程 10:城市建议分支】输入 query 和天气查询结果,由模型生成接待建议。
+        # query 是接待需求;weather_info 是天气辅助函数返回的文本,也可能为空或表示无数据。
+        # 模型在这里根据这些输入给出建议;这是生成建议的模板,不是接待资源数据库查询工具。
+        return ChatPromptTemplate.from_template(
+"""
+系统提示:您是一位差旅专家,基于用户查询生成城市接待建议。规则:
+- 推荐3-5个接待资源,包含描述、理由、注意事项。
+- 基于槽位:城市、偏好。
+- 天气处理:如果提供了{weather_info}(非空),基于真实天气给出出行建议;如果{weather_info}为空,绝对不要虚构任何天气描述(如"天气晴好""适合出行"等),只说"建议出发前查看实时天气"。
+- 语气:热情推荐,如"推荐您在北京安排国贸客户拜访与商务午餐..."。
+- 备注:内容生成,仅供参考。
+- 保持中文,150-250字。
+
+查询:{query}
+目的地天气:{weather_info}
+""")
+
+    # ==================== 任务规划 ====================
+
+    @staticmethod
+    def planning_prompt():
+        """
+        任务规划提示模板 —— 让大模型判断任务复杂度并生成执行计划
+
+        这是 Planning + ReAct 架构的关键 prompt,它让大模型扮演"规划师"的角色。
+
+        输入变量:
+            - conversation_history: 对话历史
+            - query: 用户当前输入
+            - intents: 识别到的意图(JSON 字符串)
+            - user_queries: 改写后的查询(JSON 字符串)
+
+        输出格式(JSON):
+            简单任务:{"need_plan": false, "reason": "单意图,直接查询即可", "steps": []}
+            复杂任务:{"need_plan": true, "reason": "多意图需要分步",
+                      "steps": [{"step": 1, "action": "查询天气", "intent": "weather", "depends_on": 0}, ...]}
+
+        判断标准:
+            - 简单任务:只有一个意图,直接就能执行
+            - 复杂任务:多个意图且有关联、需要多步推理、步骤间有依赖关系
+
+        示例:
+            用户输入:"北京明天天气怎么样?"
+            → 简单任务,need_plan=false
+
+            用户输入:"帮我查北京到上海的航班,再看下那边天气,推荐几个接待资源"
+            → 复杂任务,need_plan=true,steps=[查询航班, 查询天气, 推荐接待资源]
+        """
+        # 【主流程 5.2】生成 need_plan 和 steps;真正校验、调度和执行步骤的是 ChatService。
+        # query 是本轮原始输入,intents/user_queries 已被 json.dumps 转成文本,conversation_history 用于理解前文。
+        # 模型输出里的 steps 才是待执行计划;正文要求是对模型的约束,Python 还会再次验证编号和依赖。
+        # 模板里 {{ 和 }} 用于输出 JSON 的字面括号,避免把示例 JSON 的字段误当成模板变量。
+        return ChatPromptTemplate.from_template(
+"""
+系统提示:您是一位任务规划专家,负责评估用户请求的复杂度并制定执行计划。
+
+将任务拆解为有序步骤,每个步骤指定:
+- step: 步骤序号(从1开始)
+- action: 具体动作(如"调用WeatherQueryAssistant查询北京天气")
+- intent: 对应的意图(如weather/flight/train/event/order/city_service)
+- depends_on: 依赖的前置步骤序号(无依赖则为0)
+
+对话历史:{conversation_history}
+当前用户查询:{query}
+识别到的意图:{intents}
+用户查询改写:{user_queries}
+
+输出严格为JSON,不要添加额外文本:{{"need_plan": true, "reason": "原因", "steps": [{{"step": 1, "action": "...", "intent": "...", "depends_on": 0}}, ...]}}
+""")
+
+    # ==================== ReAct 推理 ====================
+
+    @staticmethod
+    def react_prompt():
+        """
+        ReAct 推理提示模板 —— 按 Thought-Action-Observation 格式逐步推理
+
+        注意:当前版本已优化性能,react_loop 中跳过了 Thought LLM 调用(plan 已确定
+        动作,Thought 无额外决策价值),此模板暂时不在主流程中使用,保留供差旅参考。
+
+        什么是 ReAct?
+            ReAct = Reasoning(推理)+ Acting(行动)
+            大模型在每一步执行前先"思考"(Thought),然后选择工具执行(Action),
+            最后观察结果(Observation),再决定下一步。
+
+        输入变量:
+            - available_tools: 当前可用的工具/agent 列表(动态获取,不是写死的)
+            - plan_steps: 完整的任务计划
+            - observations: 已完成步骤的结果
+            - current_step: 当前步骤号
+            - step_description: 当前步骤的描述
+            - query: 用户原始输入
+
+        ReAct 循环的工作方式:
+            Thought: "我需要查询北京到上海的航班,应该调用票务代理"
+            Action: "调用TicketQueryAssistant"
+            Action Input: "{'departure': '北京', 'arrival': '上海'}"
+            → 系统执行 Action,得到 Observation
+            → 继续下一个 Thought...
+
+        这种方式的优势:
+            1. 让大模型有"思考"过程,而不是直接盲目执行
+            2. 每一步都能参考之前的结果做调整
+            3. 某步失败时,模型可以灵活应对
+        """
+        # 【保留模板】当前 ChatService 主线未调用此方法;不能把它当成必经的推理步骤。
+        # 若其他代码使用这个模板,需要提供 available_tools、plan_steps、observations、current_step、step_description、query。
+        # 它描述逐步 Thought/Action 的文本格式;当前 ChatService 没有调用它,因而不会因为定义在这里就产生额外推理。
+        return ChatPromptTemplate.from_template(
+"""
+系统提示:你是一位企业差旅智能体,需要按照计划逐步完成任务。
+
+可用工具:
+{available_tools}
+
+当前任务计划:
+{plan_steps}
+
+已完成步骤的结果:
+{observations}
+
+当前步骤:{current_step}
+步骤描述:{step_description}
+用户原始查询:{query}
+
+请按照以下格式进行推理和行动:
+
+Thought: 分析当前情况,确定需要采取的行动
+Action: 从可用工具列表中选择合适的工具
+Action Input: 工具所需输入
+
+执行完行动后,你会得到 Observation,然后继续推理或给出最终回复。
+""")
+
+    @staticmethod
+    def react_summary_prompt():
+        """
+        ReAct 最终汇总提示模板 —— 将所有步骤的结果整合成一条连贯回复
+
+        输入变量:
+            - query: 用户原始输入
+            - all_observations: 所有步骤的执行结果
+
+        使用场景:
+            当 ReAct 循环中执行了多个步骤(如查航班 + 查天气 + 推荐接待资源),
+            不能简单地把三个结果拼在一起返回,需要用这个 prompt 让大模型
+            整合成一条连贯、通顺的回复。
+
+        示例:
+            输入:步骤1(查航班): 航班暂不可用
+                 步骤2(查天气): 未找到数据
+                 步骤3(推荐接待资源): 陆家嘴会客区、张江园区参访、商务晚餐...
+            输出:"您好!目前航班查询暂时遇到技术问题...不过我可以为您推荐上海的商务接待资源:陆家嘴会客区、张江园区参访、商务晚餐..."
+        """
+        # 【主流程 10:复杂计划汇总】全部计划步骤结束后使用;一个步骤时直接返回结果。
+        # query 给出最初目标,all_observations 是“步骤号+动作说明+结果”的多行文本。
+        # 用于把多个步骤结果整合为一次回复;模型收到的失败信息和模拟声明也包含在这段文本里。
+        return ChatPromptTemplate.from_template(
+"""
+系统提示:你是一位专业的差旅顾问,需要根据所有查询结果生成最终回复。
+
+用户原始查询:{query}
+
+各步骤执行结果:
+{all_observations}
+
+请综合以上结果,生成一条完整、连贯的中文回复,150-300字,语气专业热情。
+保留模拟预订及未实际下单的说明,不得将模拟结果表述为真实订单、出票或保单。
+""")
+
+
+# 【独立演示】直接运行本文件只打印意图模板,不启动服务器,也不请求模型。
+if __name__ == '__main__':
+    print(SmartBizTripPrompts.intent_prompt())

+ 341 - 0
mcp_server/mcp_ticket_server.py

@@ -0,0 +1,341 @@
+# 【启动 S2】独立 MCP 工具服务,监听 8001;供 A2A 子助手调用。
+# 本文件负责执行工具,不调用聊天模型来决定下一步;工具名和参数由上游传入。
+# 启动顺序:创建 FastMCP → 创建业务服务 → 注册工具 → 转成 FastAPI 应用 → 监听端口。
+# 主流程 8:收到工具调用 → 进入对应包装函数 → 业务方法访问数据源/生成模拟结果
+# → 返回工具结果 → A2A 执行器交给模型整理 → 主流程 9 返回主助手。
+
+"""
+票务 MCP Server 模块
+====================
+功能:为企业差旅智能体提供票务相关的 MCP 工具(Tool)
+  - 高铁票查询 / 预定
+  - 航班查询 / 预定
+  - 会展门票查询 / 预定
+
+技术要点:
+  1. 使用 MySQL 参数化查询(%s 占位符),防止 SQL 注入
+  2. 通过 FastMCP 框架将 Python 方法注册为 MCP 工具,供 Agent 调用
+  3. 自定义 JSON 编码器处理 date/datetime/Decimal 等特殊类型
+"""
+
+import mysql.connector
+import json
+from datetime import date, datetime, timedelta
+from decimal import Decimal
+from python_a2a import FastMCP, create_fastapi_app  # MCP 框架:FastMCP 用于定义工具,create_fastapi_app 转为 FastAPI 服务
+import uvicorn  # ASGI 服务器,用于运行 FastAPI 应用
+
+from config import Config
+from create_logger import logger
+from utils.format import DateEncoder, default_encoder
+
+# 加载配置文件中的数据库连接参数
+conf = Config()
+
+
+# ============================================================
+# 票务服务类 —— 封装所有票务相关的数据库查询和预定逻辑
+# ============================================================
+class TicketService:
+    """
+    票务服务类,负责高铁票、航班、会展门票的查询与预定。
+    所有查询方法统一通过 _execute_query 执行参数化 SQL,返回标准 JSON 字符串。
+    """
+
+    def __init__(self):
+        """初始化方法:建立 MySQL 数据库连接"""
+        # 【启动 S2.2 内部】建立并保存连接;后续查询复用 self.conn。
+        # 构造函数由服务启动函数调用,一次建立数据库连接并放入 self.conn。
+        # self 是业务服务对象;MCP 包装函数接收参数后调用的是这个对象的方法。
+        # 成功返回连接对象只说明连接建立,具体表是否存在、查询是否有数据还要等 execute。
+        self.conn = mysql.connector.connect(
+            host=conf.host,            # 数据库主机地址,如 "localhost" 或 "192.168.1.100"
+            user=conf.user,            # 数据库用户名
+            password=conf.password,    # 数据库密码
+            database=conf.database     # 数据库名称
+        )
+
+    def _execute_query(self, sql: str, params: list = None) -> str:
+        """
+        执行参数化查询的通用方法。
+
+        参数:
+            sql:    SQL 语句,使用 %s 作为参数占位符(注意不是 Python 的 % 格式化)
+            params: 参数列表,按顺序填充 %s 占位符
+
+        返回:
+            JSON 字符串,包含三种可能的结构:
+              - {"status": "success", "data": [...]}       查询到数据
+              - {"status": "no_data", "message": "..."}    无匹配数据
+              - {"status": "error", "message": "..."}      SQL 执行异常
+
+        关键制度要点:
+          - dictionary=True:cursor 返回字典而非元组,便于通过列名访问
+          - default_encoder:将日期/时间转为字符串,Decimal 转为浮点数
+          - DateEncoder:json.dumps 的自定义编码器类
+        """
+        # 【主流程 8.3,数据库内部步骤 1】创建字典游标,SQL 与参数分别传给 execute。
+        # %s 是数据库驱动的参数占位符;参数顺序要与 SQL 中占位符顺序一致。
+        try:
+            # 创建游标,dictionary=True 使结果以字典列表形式返回
+            # 游标是这一次数据库操作的执行入口;dictionary=True 让每行结果按列名取值。
+            # 例:{"city": "北京", "fx_date": 日期对象},比按 results[0][1] 这样的列位置读取更直观。
+            cursor = self.conn.cursor(dictionary=True)
+            # 执行参数化查询 —— params 中的值会自动转义,防止 SQL 注入
+            # SQL 字符串描述查哪些表和条件,参数列表只提供值,二者由驱动组合。
+            # 不要把这里的 %s 当成 Python 字符串格式化去替换;当前代码没有将用户输入拼进 SQL 语法。
+            cursor.execute(sql, params or [])
+            # 【主流程 8.3,数据库内部步骤 2】取回全部匹配行,再关闭此次查询的游标。
+            results = cursor.fetchall()
+            # 关闭当前游标,self.conn 仍保留供下一次工具调用使用;这里没有关闭整条数据库连接。
+            cursor.close()
+
+            # 遍历结果,转换为 JSON 能处理的字符串或数值
+            # 【主流程 8.3,数据库内部步骤 3】把日期/时间转为字符串,Decimal 转为 float。
+            # 这些值随后才能放入 JSON;转换 Decimal 为 float 可能丢失部分精度。
+            for result in results:
+                # 外层循环逐行,内层循环逐列。items() 每次给出列名 key 和该列的值 value。
+                # 给 result[key] 重新赋值是在替换该列的 Python 表示,没有修改数据库里的原始数据。
+                for key, value in result.items():
+                    if isinstance(value, (date, datetime, timedelta, Decimal)):
+                        result[key] = default_encoder(value)
+
+            # 根据是否有数据返回不同的 JSON 结构
+            # 【主流程 8.4】把 success/no_data 和数据序列化为 JSON 文本交回工具调用方。
+            # 异常分支返回 error 文本;工具成功返回文本本身不代表查询有结果。
+            return json.dumps(
+                {"status": "success", "data": results} if results
+                else {"status": "no_data", "message": "未找到票务数据,请确认查询条件。"},
+                cls=DateEncoder, ensure_ascii=False  # ensure_ascii=False 保证中文正常显示
+            )
+        # 查询、取值或序列化出错时进入异常分支,返回含 status="error" 的 JSON 文本。
+        # 由于错误被转换为普通返回值,上层还需要检查内容,不能只根据“函数正常返回”判断业务成功。
+        except Exception as e:  # noqa: BLE001
+            # 记录错误日志并返回错误 JSON
+            logger.error(f"票务查询错误: {str(e)}")
+            return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)
+
+    def query_train(self, departure_city: str, arrival_city: str, date: str, seat_type: str = None) -> str:
+        """
+        查询高铁票。
+
+        参数:
+            departure_city: 出发城市,如 "北京"
+            arrival_city:   到达城市,如 "上海"
+            date:           出发日期,格式 "YYYY-MM-DD"
+            seat_type:      座位类型(可选),如 "一等座"、"二等座"
+
+        返回:
+            JSON 字符串,包含车次、时间、价格、余票等信息
+
+        SQL 说明:
+          - WHERE 条件使用 %s 占位符,参数通过 params 列表传入
+          - 如果指定了 seat_type,动态追加 AND 条件
+          - DATE(departure_time) 提取日期部分进行匹配
+        """
+        # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
+        # 调用者:同名 MCP 包装函数。输入:出发城市、到达城市、日期;输出:查询结果的 JSON 字符串。
+        # 数据源:MySQL 的 train_tickets 表,SQL 写在代码中,由参数决定筛选值。
+        # 例如 seat_type="二等座" 才追加座席条件;未提供时保留该日各座席类型。
+        sql = ("SELECT id, departure_city, arrival_city, departure_time, arrival_time, "
+               "train_number, seat_type, price, remaining_seats FROM train_tickets "
+               "WHERE departure_city = %s AND arrival_city = %s AND DATE(departure_time) = %s")
+        # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
+        params = [departure_city, arrival_city, date]
+        # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
+        if seat_type:
+            sql += " AND seat_type = %s"
+            # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
+            params.append(seat_type)
+        # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
+        return self._execute_query(sql, params)
+
+    def query_flight(self, departure_city: str, arrival_city: str, date: str, cabin_type: str = None) -> str:
+        """
+        查询航班。
+
+        参数:
+            departure_city: 出发城市
+            arrival_city:   到达城市
+            date:           出发日期,格式 "YYYY-MM-DD"
+            cabin_type:     舱位类型(可选),如 "经济舱"、"商务舱"
+
+        返回:
+            JSON 字符串,包含航班号、时间、价格、余票等信息
+        """
+        # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
+        # 调用者:同名 MCP 包装函数。输入:出发城市、到达城市、日期;输出:查询结果的 JSON 字符串。
+        # 数据源:MySQL 的 flight_tickets 表,SQL 写在代码中,由参数决定筛选值。
+        # 例如 cabin_type="经济舱" 才追加舱位条件;未提供时查询所有符合基础条件的舱位。
+        sql = ("SELECT id, departure_city, arrival_city, departure_time, arrival_time, "
+               "flight_number, cabin_type, price, remaining_seats FROM flight_tickets "
+               "WHERE departure_city = %s AND arrival_city = %s AND DATE(departure_time) = %s")
+        # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
+        params = [departure_city, arrival_city, date]
+        # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
+        if cabin_type:
+            sql += " AND cabin_type = %s"
+            # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
+            params.append(cabin_type)
+        # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
+        return self._execute_query(sql, params)
+
+    def query_event(self, city: str, event_name: str, date: str, ticket_type: str = None) -> str:
+        """
+        查询会展活动/会展票。
+
+        参数:
+            city:        举办城市,如 "北京"
+            event_name:  活动名称,如 "成都企业数字化转型峰会"
+            date:        举办日期,格式 "YYYY-MM-DD"
+            ticket_type: 票类型(可选),如 "VIP"、"普通票"
+
+        返回:
+            JSON 字符串,包含活动名称、场馆、时间、票价、余票等信息
+        """
+        # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
+        # 调用者:同名 MCP 包装函数。输入:举办城市、活动名、日期;输出:查询结果的 JSON 字符串。
+        # 数据源:MySQL 的 event_tickets 表,SQL 写在代码中,由参数决定筛选值。
+        # event_name 使用等值匹配;这不是语义检索,也没有使用 LIKE 做模糊匹配。
+        sql = ("SELECT id, event_name, city, venue, start_time, end_time, "
+               "ticket_type, price, remaining_seats FROM event_tickets "
+               "WHERE city = %s AND event_name = %s AND DATE(start_time) = %s")
+        # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
+        params = [city, event_name, date]
+        # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
+        if ticket_type:
+            sql += " AND ticket_type = %s"
+            # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
+            params.append(ticket_type)
+        # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
+        return self._execute_query(sql, params)
+
+    # --------------------------------------------------------
+    # 预定方法(演示环境:仅模拟返回,未实际写入数据库)
+    # --------------------------------------------------------
+
+    def order_train(self, departure_date: str, train_number: str, seat_type: str, number: int) -> str:
+        """模拟高铁票预定,返回预定成功信息"""
+        # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
+        # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
+        # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
+        # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
+        return f"高铁票预定模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。日期:{departure_date},车次:{train_number},座位:{seat_type},数量:{number}张。"
+
+    def order_flight(self, departure_date: str, flight_number: str, cabin_type: str, number: int) -> str:
+        """模拟航班预定,返回预定成功信息"""
+        # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
+        # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
+        # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
+        # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
+        return f"航班预定模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。日期:{departure_date},航班号:{flight_number},舱位:{cabin_type},数量:{number}张。"
+
+    def order_event(self, start_date: str, event_name: str, venue: str, ticket_type: str, number: int) -> str:
+        """模拟会展门票预定,返回预定成功信息"""
+        # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
+        # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
+        # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
+        # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
+        return f"会展票预定模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。日期:{start_date},活动名称:{event_name},场馆:{venue},票类型:{ticket_type},数量:{number}张。"
+
+
+def create_ticket_mcp_server():
+    # 【启动 S2.1】创建工具注册表。下面的 @tool 在定义包装函数时完成注册。
+    # 注册时不会运行包装函数体;只有收到该工具的调用请求才执行函数体。
+    # 这是服务启动函数。内部定义的工具包装函数会记住当前的 service 对象,后续每次调用可继续使用它。
+    # 这叫闭包:包装函数虽然在外层函数内部定义,却能读取外层的 service。
+    # FastMCP 注册这些函数后,HTTP 请求按工具名选择其中一个,不按源代码排列顺序逐个执行。
+    ticket_mcp = FastMCP(
+        name="TicketTools",
+    )
+
+    # 【启动 S2.2】创建可被所有工具包装函数引用的业务服务,并建立 MySQL 连接。
+    service = TicketService()
+
+    # 注册查询工具
+    @ticket_mcp.tool(
+        name="query_train",
+        description="查询高铁票信息,参数:departure_city(出发城市), arrival_city(到达城市), date(日期), seat_type(座位类型,可选)"
+    )
+    def query_train(departure_city: str, arrival_city: str, date: str, seat_type: str = None) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 query_train 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TicketService.query_train 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        return service.query_train(departure_city, arrival_city, date, seat_type)
+
+    @ticket_mcp.tool(
+        name="query_flight",
+        description="查询航班信息,参数:departure_city(出发城市), arrival_city(到达城市), date(日期), cabin_type(舱位类型,可选)"
+    )
+    def query_flight(departure_city: str, arrival_city: str, date: str, cabin_type: str = None) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 query_flight 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TicketService.query_flight 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        return service.query_flight(departure_city, arrival_city, date, cabin_type)
+
+    @ticket_mcp.tool(
+        name="query_event",
+        description="查询会展门票信息,参数:city(城市), event_name(活动名称), date(日期), ticket_type(票类型,可选)"
+    )
+    def query_event(city: str, event_name: str, date: str, ticket_type: str = None) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 query_event 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TicketService.query_event 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        return service.query_event(city, event_name, date, ticket_type)
+
+    # 注册预定工具
+    @ticket_mcp.tool(
+        name="order_train",
+        description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据车次号、日期、座位类型和数量预定高铁票。参数:train_number(车次号,如G123), departure_date(出发日期,格式YYYY-MM-DD), seat_type(座位类型,如二等座/一等座/商务座), number(订票数量)"
+    )
+    def order_train(departure_date: str, train_number: str, seat_type: str, number: int) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 order_train 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TicketService.order_train 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        return service.order_train(departure_date, train_number, seat_type, number)
+
+    @ticket_mcp.tool(
+        name="order_flight",
+        description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据航班号、日期、舱位类型和数量预定航班。参数:flight_number(航班号,如CA4101), departure_date(出发日期,格式YYYY-MM-DD), cabin_type(舱位类型,如经济舱/商务舱/头等舱), number(订票数量)"
+    )
+    def order_flight(departure_date: str, flight_number: str, cabin_type: str, number: int) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 order_flight 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TicketService.order_flight 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        return service.order_flight(departure_date, flight_number, cabin_type, number)
+
+    @ticket_mcp.tool(
+        name="order_event",
+        description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据活动名称、日期、场馆、票类型和数量预定会展门票。参数:event_name(活动名称), start_date(举办日期,格式YYYY-MM-DD), venue(会展场馆名), ticket_type(票类型,如VIP/普通票), number(订票数量)"
+    )
+    def order_event(start_date: str, event_name: str, venue: str, ticket_type: str, number: int) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 order_event 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TicketService.order_event 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        return service.order_event(start_date, event_name, venue, ticket_type, number)
+
+    logger.info("=== 票务MCP服务器信息 ===")
+    logger.info(f"名称: {ticket_mcp.name}")
+
+    try:
+        print("服务器已启动,请访问 http://127.0.0.1:8001/mcp")
+        # 【启动 S2.3】工具注册结束后构建 HTTP 应用;下一行 uvicorn.run 开始监听并等待请求。
+        ticket_mcp_server = create_fastapi_app(ticket_mcp)
+        uvicorn.run(ticket_mcp_server, host="0.0.0.0", port=8001)
+    except Exception as e:
+        print(f"服务器启动失败: {e}")
+
+
+if __name__ == '__main__':
+    # service = TicketService()
+    # train = service.query_train("北京", "成都", "2026-05-01", "二等座")
+    # print(train)
+    # flight = service.query_flight("北京", "成都", "2026-05-01", "头等舱")
+    # print(flight)
+    create_ticket_mcp_server()

+ 374 - 0
mcp_server/mcp_trip_server.py

@@ -0,0 +1,374 @@
+# 【启动 S2】独立 MCP 工具服务,监听 8003;供 A2A 子助手调用。
+# 本文件负责执行工具,不调用聊天模型来决定下一步;工具名和参数由上游传入。
+# 启动顺序:创建 FastMCP → 创建业务服务 → 注册工具 → 转成 FastAPI 应用 → 监听端口。
+# 主流程 8:收到工具调用 → 进入对应包装函数 → 业务方法访问数据源/生成模拟结果
+# → 返回工具结果 → A2A 执行器交给模型整理 → 主流程 9 返回主助手。
+
+import json  # JSON 处理
+from datetime import date, datetime, timedelta  # 时间处理
+from decimal import Decimal  # 高精度小数类型
+
+import mysql.connector  # MySQL 数据库驱动
+import requests  # HTTP 请求,用于调用 Qwen Embedding API
+import uvicorn  # ASGI 服务器,用于运行 FastAPI 应用
+from pymilvus import Collection, connections  # Milvus 向量数据库客户端
+from python_a2a import (  # MCP 框架:FastMCP 用于定义工具,create_fastapi_app 转为 FastAPI 服务
+    FastMCP,
+    create_fastapi_app,
+)
+from config import Config  # 项目配置
+from create_logger import logger  # 日志模块
+from utils.format import DateEncoder, default_encoder  # 日期格式化工具
+
+conf = Config()
+
+
+def get_embedding(text: str) -> list:
+    """调用 Qwen Embedding API 生成文本向量"""
+    # 【检索内部步骤 2】把查询文本发给向量模型,得到可与套餐向量比较的一组数值。
+    # 查询和入库需使用一致的模型与维度;向量接口不同于聊天模型接口。
+    # 调用者:search_meeting_packages_in_milvus。输入是一段用户需求文本,输出是浮点数列表。
+    # payload.input 虽是列表,此处只放一条 text,因此最后读取 data[0] 的 embedding。
+    import requests
+    headers = {
+        "Authorization": f"Bearer {conf.embedding_api_key}",
+        "Content-Type": "application/json"
+    }
+    payload = {
+        "model": "text-embedding-v3",
+        "input": [text],
+        "dimensions": conf.embedding_dim
+    }
+    # POST 将 payload 编码为 JSON,Authorization 使用独立的向量服务凭证。
+    # 这里的请求发生在真正检索时,创建 TripService 本身不会提前生成这些向量。
+    response = requests.post(conf.embedding_url, headers=headers, json=payload, timeout=30)
+    response.raise_for_status()
+    return response.json()["data"][0]["embedding"]
+
+
+def search_meeting_packages_in_milvus(query_text: str, city: str = None, limit: int = 5) -> list:
+    """在 Milvus 中进行语义搜索,找到最匹配的会务套餐"""
+    # 【检索内部步骤 1】连接并加载已建好的集合;本函数不会自动创建或灌入套餐数据。
+    # 输入:query_text 描述套餐需求,city 是可选城市,limit 限制最多返回多少个候选。
+    # 输出:普通 Python 字典列表,尚未 json.dumps;外层 query_meeting_package 会再包装状态并序列化。
+    # 本函数检索 sql/init_meeting_package_rag.py 事先写入的集合,不从 MySQL 动态同步套餐。
+    from pymilvus import connections, Collection
+    connections.connect(alias="default", host=conf.milvus_host, port=conf.milvus_port)
+    collection = Collection(conf.meeting_package_collection)
+    # 把集合准备为可查询状态;Collection(name) 只是取得集合对象,并未创建新套餐数据。
+    collection.load()
+
+    # 【检索内部步骤 2 → 3】先求查询向量,再按 COSINE 相似度取前 limit 条。
+    # city 有值时额外过滤城市;相似度是检索分数,不能当成事实正确率或预订成功率。
+    results = collection.search(
+        # search 的 data 是“查询向量列表”;当前只有一个查询向量,所以外层结果通常只有一组命中。
+        data=[get_embedding(query_text)],
+        # 指定对 embedding 向量字段检索;COSINE 与初始化脚本建立索引时的度量方式保持一致。
+        anns_field="embedding",
+        param={"metric_type": "COSINE", "params": {"nprobe": 10}},
+        limit=limit,
+        output_fields=["package_id", "package_name", "city", "days", "price",
+                       "total_seats", "remaining_seats", "agency", "rating",
+                       "departure_dates", "highlights"],
+        # 城市不为空才追加标量过滤;没有城市就传 None,允许在整个集合内检索。
+        # 城市值先做 JSON 字符串转义,避免引号改变过滤表达式的结构。
+        expr=f"city == {json.dumps(city, ensure_ascii=False)}" if city else None
+    )
+
+    # 【检索内部步骤 4】从命中项选取返回字段;虽然检索请求取了更多字段,实际只输出下列字段。
+    package_list = []
+    # 外层循环遍历每个查询向量对应的命中组,内层遍历该组候选;hit.entity 读取存储字段。
+    for hits in results:
+        for hit in hits:
+            package_list.append({
+                "package_id": hit.entity.get("package_id"),
+                "package_name": hit.entity.get("package_name"),
+                "city": hit.entity.get("city"),
+                "days": hit.entity.get("days"),
+                "price": hit.entity.get("price"),
+                # COSINE 下 hit.distance 表示该检索度量的分数;四舍五入到四位用于展示,不参与二次筛选。
+                "similarity": round(hit.distance, 4),
+            })
+    return package_list
+
+
+# ==================== 差旅服务类 ====================
+class TripService:
+    """
+    差旅查询与预订服务类
+
+    混合数据源架构:
+    - 商务用车、保险:从 MySQL 数据库真实查询
+    - 会务套餐:从 Milvus 向量数据库语义检索(RAG)
+    """
+
+    def __init__(self):
+        # 建立 MySQL 数据库连接
+        # 【启动 S2.2 内部】建立并保存连接;后续查询复用 self.conn。
+        # 构造函数由服务启动函数调用,一次建立数据库连接并放入 self.conn。
+        # self 是业务服务对象;MCP 包装函数接收参数后调用的是这个对象的方法。
+        # 成功返回连接对象只说明连接建立,具体表是否存在、查询是否有数据还要等 execute。
+        self.conn = mysql.connector.connect(
+            host=conf.host,
+            user=conf.user,
+            password=conf.password,
+            database=conf.database
+        )
+
+    def _execute_query(self, sql: str, params: list = None) -> str:
+        """
+        执行 SQL 查询,返回 JSON 格式的结果
+
+        参数:
+            sql (str): SQL 查询语句,使用 %s 占位符
+            params (list): SQL 参数列表
+
+        返回值:
+            str: JSON 字符串
+        """
+        # 【主流程 8.3,数据库内部步骤 1】创建字典游标,SQL 与参数分别传给 execute。
+        # %s 是数据库驱动的参数占位符;参数顺序要与 SQL 中占位符顺序一致。
+        try:
+            # 游标是这一次数据库操作的执行入口;dictionary=True 让每行结果按列名取值。
+            # 例:{"city": "北京", "fx_date": 日期对象},比按 results[0][1] 这样的列位置读取更直观。
+            cursor = self.conn.cursor(dictionary=True)
+            # SQL 字符串描述查哪些表和条件,参数列表只提供值,二者由驱动组合。
+            # 不要把这里的 %s 当成 Python 字符串格式化去替换;当前代码没有将用户输入拼进 SQL 语法。
+            cursor.execute(sql, params or [])
+            # 【主流程 8.3,数据库内部步骤 2】取回全部匹配行,再关闭此次查询的游标。
+            results = cursor.fetchall()
+            # 关闭当前游标,self.conn 仍保留供下一次工具调用使用;这里没有关闭整条数据库连接。
+            cursor.close()
+
+            # 处理特殊类型(日期、Decimal 等)
+            # 【主流程 8.3,数据库内部步骤 3】把日期/时间转为字符串,Decimal 转为 float。
+            # 这些值随后才能放入 JSON;转换 Decimal 为 float 可能丢失部分精度。
+            for result in results:
+                # 外层循环逐行,内层循环逐列。items() 每次给出列名 key 和该列的值 value。
+                # 给 result[key] 重新赋值是在替换该列的 Python 表示,没有修改数据库里的原始数据。
+                for key, value in result.items():
+                    if isinstance(value, (date, datetime, timedelta, Decimal)):
+                        result[key] = default_encoder(value)
+
+            if results:
+                response = {"status": "success", "data": results}
+            else:
+                response = {"status": "no_data", "message": "未找到差旅数据,请确认查询条件。"}
+
+            # 【主流程 8.4】把 success/no_data 和数据序列化为 JSON 文本交回工具调用方。
+            # 异常分支返回 error 文本;工具成功返回文本本身不代表查询有结果。
+            return json.dumps(response, cls=DateEncoder, ensure_ascii=False)
+
+        # 查询、取值或序列化出错时进入异常分支,返回含 status="error" 的 JSON 文本。
+        # 由于错误被转换为普通返回值,上层还需要检查内容,不能只根据“函数正常返回”判断业务成功。
+        except Exception as e:
+            logger.error(f"差旅查询错误: {str(e)}")
+            return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)
+
+    # ========== 商务用车查询(MySQL) ==========
+
+    def query_car_rental(self, pickup_city: str, return_city: str, date: str, car_type: str = None) -> str:
+        """
+        查询商务用车信息(从 MySQL car_rentals 表查询)
+        """
+        # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
+        # 调用者:同名 MCP 包装函数。输入:取车城市、还车城市、取车日期;输出:查询结果的 JSON 字符串。
+        # 数据源:MySQL 的 car_rentals 表,SQL 写在代码中,由参数决定筛选值。
+        # pickup_city 与 return_city 可以不同;car_type 只有有值时才增加车型筛选。
+        logger.info(f"查询商务用车: {pickup_city} -> {return_city}, {date}, {car_type}")
+        sql = ("SELECT id, company, pickup_city, return_city, pickup_date, car_type, "
+               "car_model, price_per_day, total_available, transmission, seats, deposit "
+               "FROM car_rentals "
+               "WHERE pickup_city = %s AND return_city = %s AND pickup_date = %s")
+        # 参数列表的顺序与 SQL 占位符顺序一致;列表中的日期仍按传入值交给数据库比较。
+        params = [pickup_city, return_city, date]
+        # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
+        if car_type:
+            sql += " AND car_type = %s"
+            # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
+            params.append(car_type)
+        # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
+        return self._execute_query(sql, params)
+
+    # ========== 会务套餐查询(Milvus RAG) ==========
+
+    def query_meeting_package(self, query_text: str, city: str = None) -> str:
+        """
+        查询会务套餐信息(从 Milvus 向量数据库进行语义检索)
+
+        参数:
+            query_text (str): 用户查询描述,如"成都办数字化转型峰会,需要接送和酒店"、"适合销售团队的短途会务套餐"
+            city (str, optional): 城市过滤条件
+
+        返回值:
+            str: JSON 字符串,包含匹配的会务套餐列表
+        """
+        # 【主流程 8.2:会务套餐分支】进入 Milvus 语义检索;用车与保险则走 MySQL。
+        # 这里只检索候选套餐,不调用聊天模型生成回答;结果交给 A2A 子助手再整理。
+        # 输入 query_text 负责语义相似度,city 负责精确城市过滤,两者用途不同。
+        # 固定 limit=5 最多取五个候选;列表非空返回 success,空列表返回 no_data,抛异常返回 error。
+        # 本实现没有另外设置相似度阈值,因而返回候选不等于每条都满足用户全部要求。
+        logger.info(f"查询会务套餐: query='{query_text}', city='{city}'")
+        try:
+            package_list = search_meeting_packages_in_milvus(query_text, city, limit=5)
+
+            if package_list:
+                return json.dumps({"status": "success", "data": package_list}, ensure_ascii=False)
+            else:
+                return json.dumps({
+                    "status": "no_data",
+                    "message": f"未找到匹配的会务套餐。{'请确认城市名称,' if city else ''}或尝试其他搜索条件。"
+                }, ensure_ascii=False)
+
+        except Exception as e:
+            logger.error(f"会务套餐 RAG 查询错误: {str(e)}")
+            return json.dumps({"status": "error", "message": f"会务套餐查询出错:{str(e)}"}, ensure_ascii=False)
+
+    # ========== 保险查询(MySQL) ==========
+
+    def query_insurance(self, insurance_type: str = None) -> str:
+        """
+        查询差旅保险产品信息(从 MySQL insurances 表查询)
+        """
+        # 【主流程 8.2】将已传入的参数组织成固定 SQL 和参数列表。
+        # 调用者:同名 MCP 包装函数。输入:可选保险类型;输出:查询结果的 JSON 字符串。
+        # 数据源:MySQL 的 insurances 表,SQL 写在代码中,由参数决定筛选值。
+        # 无类型参数时查询表内所有产品;有类型时追加 WHERE insurance_type = %s。
+        logger.info(f"查询保险: {insurance_type}")
+        sql = ("SELECT id, insurance_type, name, company, coverage, price, "
+               "duration_days, max_coverage, medical_coverage, baggage_coverage, flight_delay "
+               "FROM insurances")
+        params = []
+        # 可选条件有值才追加;同时追加参数,保证条件与参数一一对应。
+        if insurance_type:
+            sql += " WHERE insurance_type = %s"
+            # 必须在追加可选 SQL 条件时同时追加对应值,否则占位符数量与参数数量会不一致。
+            params.append(insurance_type)
+        # 【主流程 8.3】进入通用查询方法;结果沿当前调用栈原路返回 MCP 包装函数。
+        return self._execute_query(sql, params)
+
+    # ========== 预订方法 ==========
+
+    def order_car_rental(self, date: str, car_type: str, number: int) -> str:
+        """预订商务用车"""
+        # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
+        # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
+        # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
+        # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
+        logger.info(f"预订商务用车: {date}, {car_type}, {number}辆")
+        # 【新改动2】明确返回模拟结果,避免被解释为真实下单或库存扣减。
+        return f"模拟预订成功,未实际下单,未创建订单,未扣减库存。日期:{date},车型:{car_type},数量:{number}辆。"
+
+    def order_meeting_package(self, date: str, package_name: str, number: int) -> str:
+        """预订会务套餐"""
+        # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
+        # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
+        # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
+        # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
+        logger.info(f"预订会务套餐: {date}, {package_name}, {number}人")
+        return f"会务套餐预订模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。套餐名称:{package_name},日期:{date},人数:{number}人。"
+
+    def order_insurance(self, insurance_type: str, date: str, number: int) -> str:
+        """购买差旅保险"""
+        # 【主流程 8.2:模拟预订分支】只构造演示文本(部分方法同时记日志)。
+        # 这里没有写订单、扣库存、支付、出票或生成保单的动作。
+        # 参数用于填充下方 f-string,例如日期、名称、类型和数量会直接显示在演示回复里。
+        # 返回字符串后本方法立即结束;它没有再调用查询方法核对库存,也没有生成可持久化的订单对象。
+        logger.info(f"购买保险: {insurance_type}, {date}, {number}份")
+        return f"差旅保险购买模拟成功,未实际下单、未创建订单、未扣减库存、未出票或生成保单。类型:{insurance_type},日期:{date},份数:{number}份。"
+
+
+ # ========== 注册查询工具 ==========
+def create_trip_mcp_server():
+    """
+    创建并启动差旅管家 MCP 服务器
+    """
+    # 【启动 S2.1】创建工具注册表。下面的 @tool 在定义包装函数时完成注册。
+    # 注册时不会运行包装函数体;只有收到该工具的调用请求才执行函数体。
+    # 这是服务启动函数。内部定义的工具包装函数会记住当前的 service 对象,后续每次调用可继续使用它。
+    # 这叫闭包:包装函数虽然在外层函数内部定义,却能读取外层的 service。
+    # FastMCP 注册这些函数后,HTTP 请求按工具名选择其中一个,不按源代码排列顺序逐个执行。
+    trip_mcp = FastMCP(name="TripTools")
+
+    # 【启动 S2.2】创建可被所有工具包装函数引用的业务服务,并建立 MySQL 连接。
+    service = TripService()
+
+    @trip_mcp.tool(
+        name="query_car_rental",
+        description="查询商务用车信息,参数:pickup_city(取车城市), return_city(还车城市), date(日期,格式YYYY-MM-DD), car_type(车型类型,可选:经济型/SUV/豪华型/MPV)"
+    )
+    def query_car_rental(pickup_city: str, return_city: str, date: str, car_type: str = None) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 query_car_rental 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TripService.query_car_rental 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        return service.query_car_rental(pickup_city, return_city, date, car_type)
+
+    @trip_mcp.tool(
+        name="query_meeting_package",
+        description="查询会务套餐信息(语义搜索),参数:query_text(查询描述,如'成都办数字化转型峰会,需要接送和酒店'),city(城市过滤,可选)"
+    )
+    def query_meeting_package(query_text: str, city: str = None) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 query_meeting_package 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TripService.query_meeting_package 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        return service.query_meeting_package(query_text, city)
+
+    @trip_mcp.tool(
+        name="query_insurance",
+        description="查询差旅保险产品,参数:insurance_type(保险类型,可选:综合型/意外型/医疗型/境外型)"
+    )
+    def query_insurance(insurance_type: str = None) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 query_insurance 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TripService.query_insurance 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        return service.query_insurance(insurance_type)
+
+    # ========== 注册预订工具 ==========
+
+    # 【新改动2】向智能体说明该工具仅用于模拟测试,可以响应模拟预订请求。
+    @trip_mcp.tool(
+        name="order_car_rental",
+        description="根据日期、车型和数量模拟预订商务用车,仅用于测试,不实际下单、不创建订单、不扣减库存。参数:date(商务用车日期,格式YYYY-MM-DD), car_type(车型,如经济型/SUV/豪华型/MPV), number(商务用车数量)"
+    )
+    def order_car_rental(date: str, car_type: str, number: int) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 order_car_rental 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TripService.order_car_rental 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        logger.info(f"正在预订商务用车: {date}, {car_type}, {number}辆")
+        return service.order_car_rental(date, car_type, number)
+
+    @trip_mcp.tool(
+        name="order_meeting_package",
+        description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据出发日期、会务套餐名称和人数预订会务套餐。参数:date(出发日期,格式YYYY-MM-DD), package_name(会务套餐名称,需与查询结果中的套餐名称一致), number(预订人数)"
+    )
+    def order_meeting_package(date: str, package_name: str, number: int) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 order_meeting_package 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TripService.order_meeting_package 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        logger.info(f"正在预订会务套餐: {date}, {package_name}, {number}人")
+        return service.order_meeting_package(date, package_name, number)
+
+    @trip_mcp.tool(
+        name="order_insurance",
+        description="仅模拟演示,不实际下单、不创建订单、不扣减库存、不出票或生成保单。根据保险类型、生效日期和份数购买差旅保险。参数:insurance_type(保险类型,如综合型/意外型/医疗型/境外型), date(生效日期,格式YYYY-MM-DD), number(购买份数)"
+    )
+    def order_insurance(insurance_type: str, date: str, number: int) -> str:
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 order_insurance 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 TripService.order_insurance 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        logger.info(f"正在购买保险: {insurance_type}, {date}, {number}份")
+        return service.order_insurance(insurance_type, date, number)
+
+    # 【启动 S2.3】工具注册结束后构建 HTTP 应用;下一行 uvicorn.run 开始监听并等待请求。
+    app = create_fastapi_app(mcp_server=trip_mcp)
+    uvicorn.run(app, host="0.0.0.0", port=8003)
+
+
+if __name__ == '__main__':
+    create_trip_mcp_server()

+ 350 - 0
mcp_server/mcp_weather_server.py

@@ -0,0 +1,350 @@
+# 【启动 S2】独立 MCP 工具服务,监听 8002;供 A2A 子助手调用。
+# 本文件负责执行工具,不调用聊天模型来决定下一步;工具名和参数由上游传入。
+# 启动顺序:创建 FastMCP → 创建业务服务 → 注册工具 → 转成 FastAPI 应用 → 监听端口。
+# 主流程 8:收到工具调用 → 进入对应包装函数 → 业务方法访问数据源/生成模拟结果
+# → 返回工具结果 → A2A 执行器交给模型整理 → 主流程 9 返回主助手。
+
+# ============================================================
+# MCP 天气服务模块
+# 功能:提供天气查询的 MCP(Model Context Protocol)工具接口
+# 支持两种数据源:MySQL 数据库 / 和风天气 API(通过配置切换)
+# 启动入口:文件末尾 __main__;运行环境需要能导入 SmartBizTrip 包
+# 访问地址:http://127.0.0.1:8002/mcp
+#
+# 调用流程图:
+#
+#   AI 智能体(如 Claude)
+#        │
+#        ▼
+#   ┌─────────────────────────────┐
+#   │  MCP Server (端口 8002)      │
+#   │  create_weather_mcp_server() │
+#   │  FastMCP("WeatherTools")     │
+#   └──────────────┬──────────────┘
+#                  │
+#                  ▼
+#           query_weather(city, start_date, end_date)
+#                  │
+#        ┌─────────┴─────────┐
+#        │ 检查 weather_source│
+#        └─────────┬─────────┘
+#                  │
+#        ┌─────────┴─────────┐
+#        │                   │
+#     "api"               其他("db")
+#        │                   │
+#        ▼                   ▼
+#   ┌──────────────┐  ┌──────────────────┐
+#   │ fetch_weather│  │ WeatherService   │
+#   │ _from_api()  │  │ .query_weather() │
+#   └──────┬───────┘  └────────┬─────────┘
+#          │                   │
+#          ▼                   ▼
+#   ① geo接口:城市→ID     SQL查询 weather_data 表
+#          │           SELECT ... FROM weather_data
+#          ▼            WHERE city = %s AND
+#   ② 天气API:用ID获取数据    fx_date BETWEEN %s AND %s
+#          │                   │
+#          ▼                   ▼
+#   ③ 过滤日期范围         遍历结果集(字典格式)
+#   ④ 字段名映射           处理 date/Decimal 类型
+#      tempMax→temp_max      │
+#      textDay→text_day      ▼
+#                     json.dumps() 序列化为 JSON 字符串
+#          │                   │
+#          └─────────┬─────────┘
+#                    ▼
+#           JSON 字符串返回给 AI 智能体
+#
+#   返回格式示例:
+#   {
+#     "status": "success",
+#     "data": [
+#       {
+#         "city": "成都",
+#         "fx_date": "2026-04-29",
+#         "temp_max": 28,
+#         "temp_min": 18,
+#         "text_day": "多云",
+#         "text_night": "晴",
+#         "humidity": 65,
+#         "wind_dir_day": "南风",
+#         "wind_scale_day": "3",
+#         "precip": 0.0
+#       }
+#     ]
+#   }
+#
+# 数据源切换:修改 config.py 中的 weather_source 配置
+#   weather_source = "api"  → 使用和风天气实时 API
+#   weather_source = "db"   → 使用本地 MySQL 数据库
+# ============================================================
+
+import mysql.connector  # MySQL 数据库驱动,用于连接和查询本地天气数据
+import json             # JSON 序列化/反序列化,将 Python 字典转为 JSON 字符串返回
+from datetime import date, datetime, timedelta  # 日期类型,处理数据库返回的时间字段
+from decimal import Decimal                     # 高精度数字类型,处理数据库返回的数值字段
+from python_a2a import FastMCP, create_fastapi_app  # MCP 框架:FastMCP 用于定义工具,create_fastapi_app 转为 FastAPI 服务
+import uvicorn  # ASGI 服务器,用于运行 FastAPI 应用
+
+from config import Config                       # 项目配置类,读取数据库/API 等配置信息
+from create_logger import logger                # 日志记录器,统一输出运行日志
+from utils.format import DateEncoder, default_encoder  # 自定义 JSON 编码器,处理 date/Decimal 等特殊类型
+import requests  # HTTP 请求库,用于调用和风天气 API
+
+# 加载全局配置对象
+conf = Config()
+
+
+class WeatherService:
+    """
+    天气服务类:负责从 MySQL 数据库查询天气数据
+    工作原理:在初始化时建立数据库长连接,后续查询复用该连接
+    """
+    def __init__(self):
+        # 建立 MySQL 数据库连接,使用 config.py 中的配置
+        # 【启动 S2.2 内部】建立并保存连接;后续查询复用 self.conn。
+        # 构造函数由服务启动函数调用,一次建立数据库连接并放入 self.conn。
+        # self 是业务服务对象;MCP 包装函数接收参数后调用的是这个对象的方法。
+        # 成功返回连接对象只说明连接建立,具体表是否存在、查询是否有数据还要等 execute。
+        self.conn = mysql.connector.connect(
+            host=conf.host,          # 数据库主机地址,如 "localhost"
+            user=conf.user,          # 数据库用户名
+            password=conf.password,  # 数据库密码
+            database=conf.database   # 数据库名称
+        )
+
+    def query_weather(self, city: str, start_date: str, end_date: str) -> str:
+        """
+        参数化查询数据库天气数据,返回 JSON 字符串
+
+        参数:
+            city: 城市名称,如 "成都"
+            start_date: 开始日期,格式 "YYYY-MM-DD"
+            end_date: 结束日期,格式 "YYYY-MM-DD"
+
+        返回:
+            JSON 字符串,包含 status(状态)和 data(天气数据列表)
+        """
+        # 【主流程 8.3,数据库内部步骤 1】创建字典游标,SQL 与参数分别传给 execute。
+        # %s 是数据库驱动的参数占位符;参数顺序要与 SQL 中占位符顺序一致。
+        # 调用者:MCP 包装函数在数据库分支时调用。city 是城市名,日期区间由 start_date/end_date 指定。
+        # start_date=end_date 表示查某一天;BETWEEN 包含开始和结束日期,ORDER BY fx_date 按日期升序返回。
+        # SELECT 明确选择天气字段,所以结果不包含 weather_data 表的全部列。
+        try:
+            # 创建游标,dictionary=True 使返回结果为字典而非元组
+            # 游标是这一次数据库操作的执行入口;dictionary=True 让每行结果按列名取值。
+            # 例:{"city": "北京", "fx_date": 日期对象},比按 results[0][1] 这样的列位置读取更直观。
+            cursor = self.conn.cursor(dictionary=True)
+
+            # 参数化 SQL 查询:使用 %s 占位符防止 SQL 注入攻击
+            # 查询 weather_data 表中指定城市和时间范围内的天气记录
+            sql = ("SELECT city, fx_date, temp_max, temp_min, text_day, text_night, "
+                   "humidity, wind_dir_day, wind_scale_day, precip FROM weather_data "
+                   "WHERE city = %s AND fx_date BETWEEN %s AND %s ORDER BY fx_date")
+            # 执行查询,参数以列表形式传入,自动转义防止注入
+            # SQL 字符串描述查哪些表和条件,参数列表只提供值,二者由驱动组合。
+            # 不要把这里的 %s 当成 Python 字符串格式化去替换;当前代码没有将用户输入拼进 SQL 语法。
+            cursor.execute(sql, [city, start_date, end_date])
+
+            # 获取所有匹配的查询结果
+            # 【主流程 8.3,数据库内部步骤 2】取回全部匹配行,再关闭此次查询的游标。
+            results = cursor.fetchall()
+            # 关闭当前游标,self.conn 仍保留供下一次工具调用使用;这里没有关闭整条数据库连接。
+            cursor.close()  # 关闭游标释放资源
+
+            # 处理特殊类型字段:date/Decimal 等类型无法直接 JSON 序列化,需要转换
+            # 【主流程 8.3,数据库内部步骤 3】把日期/时间转为字符串,Decimal 转为 float。
+            # 这些值随后才能放入 JSON;转换 Decimal 为 float 可能丢失部分精度。
+            for result in results:
+                # 外层循环逐行,内层循环逐列。items() 每次给出列名 key 和该列的值 value。
+                # 给 result[key] 重新赋值是在替换该列的 Python 表示,没有修改数据库里的原始数据。
+                for key, value in result.items():
+                    if isinstance(value, (date, datetime, timedelta, Decimal)):
+                        result[key] = default_encoder(value)  # 日期/时间转为字符串,Decimal 转为 float
+
+            # 根据是否有结果返回不同格式:
+            # - 有数据:status="success" + data 列表
+            # - 无数据:status="no_data" + 提示信息
+            # ensure_ascii=False 保证中文正常显示而非 Unicode 转义
+            # 【主流程 8.4】把 success/no_data 和数据序列化为 JSON 文本交回工具调用方。
+            # 异常分支返回 error 文本;工具成功返回文本本身不代表查询有结果。
+            return json.dumps(
+                {"status": "success", "data": results} if results
+                else {"status": "no_data", "message": "未找到天气数据,请确认城市和日期。"},
+                cls=DateEncoder, ensure_ascii=False
+            )
+        # 查询、取值或序列化出错时进入异常分支,返回含 status="error" 的 JSON 文本。
+        # 由于错误被转换为普通返回值,上层还需要检查内容,不能只根据“函数正常返回”判断业务成功。
+        except Exception as e:
+            # 捕获异常,记录日志并返回错误信息
+            logger.error(f"天气查询错误: {str(e)}")
+            return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)
+
+
+def fetch_weather_from_api(city: str, start_date: str, end_date: str) -> str:
+    """
+    从和风天气 API 获取天气数据
+
+    工作流程:
+        1. 先用城市名调用 geo 接口获取 location_id
+        2. 再用 location_id 调用天气预报接口获取数据
+        3. 过滤出指定日期范围内的数据,格式化后返回
+
+    参数:
+        city: 城市名称,如 "成都"
+        start_date: 开始日期,格式 "YYYY-MM-DD"
+        end_date: 结束日期,格式 "YYYY-MM-DD"
+
+    返回:
+        JSON 字符串,格式与数据库查询一致
+    """
+    # 【天气 API 内部步骤 1】用城市名查 location_id;找不到城市时立即返回 no_data。
+    # 调用者:MCP 包装函数在 api 分支时调用。输入仍是城市和日期区间。
+    # 输出与数据库分支一样采用 JSON 文本,让上游可以按同一种 status/data/message 结构读取。
+    # 两次 requests.get 按先后执行:没有城市 ID,就不会请求天气预报。
+    logger.info(f"从和风天气API获取数据: {city}, {start_date} ~ {end_date}")
+
+    # 设置请求头,传入和风天气 API Key(认证凭证)
+    # X-QW-Api-Key 是请求认证头;密钥从配置读取,城市与日期不是放在这个头里。
+    headers = {
+        "X-QW-Api-Key": conf.weather_api_key
+    }
+
+    # 第一步:城市名 → 地理位置 ID(和风天气需要先查城市 ID)
+    geo_url = f"https://{conf.weather_api_host}/geo/v2/city/lookup?location={city}"
+    geo_resp = requests.get(geo_url, headers=headers, timeout=15)
+    geo_resp.raise_for_status()
+    # 把 HTTP 响应体解析为 Python 字典;HTTP 响应对象与解析后的字典是两个不同层次。
+    geo_data = geo_resp.json()
+
+    # 如果 API 没有找到该城市,直接返回错误信息
+    # 没有 location 字段或列表为空都提前返回 no_data,避免下一行取第一个元素时越界。
+    if not geo_data.get("location"):
+        return json.dumps({"status": "no_data", "message": f"未找到城市:{city}"}, ensure_ascii=False)
+
+    # 取第一个匹配的城市 ID
+    # 若城市查找返回多个候选,本实现直接采用第一个候选,并不再询问用户选择。
+    location_id = geo_data["location"][0]["id"]
+
+    # 第二步:用 location_id 获取天气预报数据
+    # 【天气 API 内部步骤 2】用 location_id 请求预报;可查范围受配置 URL 的预报天数限制。
+    weather_url = f"{conf.weather_base_url}?location={location_id}"
+    weather_resp = requests.get(weather_url, headers=headers, timeout=15)
+    weather_resp.raise_for_status()
+    weather_data = weather_resp.json()
+
+    # 第三步:过滤日期范围,并将 API 返回的字段映射为数据库同名字段
+    results = []
+    # 【天气 API 内部步骤 3】仅保留请求区间内的返回日期,统一字段名后交回 MCP。
+    # 有返回数据也不保证覆盖整个请求区间;代码未在这里检查日期是否齐全。
+    for day in weather_data.get("daily", []):
+        # 字符串比较即可,因为日期格式都是 "YYYY-MM-DD"
+        # 只有规范的 YYYY-MM-DD 格式才适合这种字符串日期比较;当前函数没有额外进行日期格式校验。
+        # 不在区间内的 day 不进入 results,继续处理下一天。
+        if start_date <= day.get("fxDate", "") <= end_date:
+            # 将 API 的驼峰字段统一成数据库风格字段,并把温度/湿度/降水量转成数值。
+            # 部分字段缺失时 get 默认 0 或空串,这是代码的兜底值,不能理解成接口确认的天气数值。
+            results.append({
+                "city": city,                              # 城市名
+                "fx_date": day.get("fxDate"),              # 预报日期
+                "temp_max": int(day.get("tempMax", 0)),    # 最高温度(转为整数)
+                "temp_min": int(day.get("tempMin", 0)),    # 最低温度(转为整数)
+                "text_day": day.get("textDay", ""),        # 白天天气描述
+                "text_night": day.get("textNight", ""),    # 夜间天气描述
+                "humidity": int(day.get("humidity", 0)),   # 湿度百分比
+                "wind_dir_day": day.get("windDirDay", ""), # 白天风向
+                "wind_scale_day": day.get("windScaleDay", ""),  # 白天风力等级
+                "precip": float(day.get("precip", 0))      # 降水量(保留浮点)
+            })
+
+    # 根据是否有结果返回对应格式
+    if results:
+        return json.dumps({"status": "success", "data": results}, ensure_ascii=False)
+    return json.dumps({"status": "no_data", "message": "未找到天气数据。"}, ensure_ascii=False)
+
+def create_weather_mcp_server():
+    """
+    创建并启动天气 MCP 服务器
+
+    MCP(Model Context Protocol):一种协议,允许 AI 模型(如 Claude)通过标准接口调用外部工具
+    本模块将天气查询功能封装为 MCP 工具,供 AI 智能体调用
+    """
+    # 创建 FastMCP 实例,相当于定义一个工具服务
+    # TODO 1.构建FastMCP实例
+    # 【启动 S2.1】创建工具注册表。下面的 @tool 在定义包装函数时完成注册。
+    # 注册时不会运行包装函数体;只有收到该工具的调用请求才执行函数体。
+    # 这是服务启动函数。内部定义的工具包装函数会记住当前的 service 对象,后续每次调用可继续使用它。
+    # 这叫闭包:包装函数虽然在外层函数内部定义,却能读取外层的 service。
+    # FastMCP 注册这些函数后,HTTP 请求按工具名选择其中一个,不按源代码排列顺序逐个执行。
+    weather_mcp = FastMCP(
+        name="WeatherTools",  # 工具服务名称,AI 调用时会看到这个名称
+    )
+
+    # 实例化数据库查询服务
+    # 【启动 S2.2】创建可被所有工具包装函数引用的业务服务,并建立 MySQL 连接。
+    # API 模式不创建数据库连接;只有数据库模式才需要 WeatherService。
+    service = WeatherService() if conf.weather_source != "api" else None
+
+    # TODO 2.基于MCP实例对象,使用装饰器的方式注册可用工具
+    # TODO description非常重要,尤其是日期的格式,需要明确指定。不然调用API可能会失败
+    # 使用 @tool 装饰器将函数注册为 MCP 工具
+    # name: 工具的唯一标识,AI 通过这个名字调用
+    # description: 工具说明,AI 会阅读这段描述来判断是否使用该工具
+    @weather_mcp.tool(
+        name="query_weather",
+        description="查询天气数据,参数:city(城市), start_date(开始日期YYYY-MM-DD), end_date(结束日期YYYY-MM-DD)"
+    )
+    def query_weather(city: str, start_date: str, end_date: str) -> str:
+        """
+        MCP 工具入口函数:根据配置选择从数据库或 API 获取天气数据
+
+        参数:
+            city: 城市名称,如 "成都"
+            start_date: 开始日期,格式 "YYYY-MM-DD"
+            end_date: 结束日期,格式 "YYYY-MM-DD"
+
+        返回:
+            JSON 字符串,包含天气数据
+        """
+        # 【主流程 8.1】客户端选中本工具时才进入这里;把命名参数转交业务方法。
+        # 调用者:MCP 框架按工具名 query_weather 分派到这里。
+        # 输入:函数签名中的命名参数;输出:业务方法返回的字符串,再由 MCP 框架封装为工具响应。
+        # 同名的 WeatherService.query_weather 是业务方法;当前嵌套函数只是对外暴露的工具入口。
+        logger.info(f"执行天气查询: {city}, {start_date} ~ {end_date}")
+
+        # 根据 config.py 中的 weather_source 配置决定数据源:
+        # - "api": 调用和风天气实时 API
+        # - 其他值(如 "db"): 查询本地 MySQL 数据库
+        # 【主流程 8.2】这是互斥分支:api 走远程预报接口,其余值走 MySQL。
+        if conf.weather_source == "api":
+            return fetch_weather_from_api(city, start_date, end_date)
+        return service.query_weather(city, start_date, end_date)
+
+    # 打印服务启动信息到日志
+    logger.info("=== 天气MCP服务器信息 ===")
+    logger.info(f"名称: {weather_mcp.name}")
+
+    # TODO 3.启动fastmcp server : 把MCP Server对象转成fastapi服务, 通过uvicorn启动fastapi
+    try:
+        # 提示用户访问地址
+        print("服务器已启动,请访问 http://127.0.0.1:8002/mcp")
+
+        # 将 FastMCP 对象转为 FastAPI 应用
+        # 【启动 S2.3】工具注册结束后构建 HTTP 应用;下一行 uvicorn.run 开始监听并等待请求。
+        weather_mcp_server = create_fastapi_app(weather_mcp)
+
+        # 使用 uvicorn 启动 HTTP 服务
+        # host="0.0.0.0" 表示允许外部访问,port=8002 是服务端口
+        uvicorn.run(weather_mcp_server, host="0.0.0.0", port=8002)
+    except Exception as e:
+        # 捕获启动异常
+        print(f"服务器启动失败: {e}")
+
+if __name__ == '__main__':
+    # 调试用法:直接调用 API 测试,不启动 MCP 服务
+    # api = fetch_weather_from_api(city="成都", start_date="2026-04-28", end_date="2026-05-01")
+    # print(api)
+
+    # 启动 MCP 天气服务器
+    create_weather_mcp_server()

+ 430 - 0
memory.py

@@ -0,0 +1,430 @@
+# 记忆是被 ChatService 调用的辅助模块,不是独立服务。
+# 启动 S4.2:创建空记忆 → 注入数据库连接 → 恢复偏好、查询历史和短期消息。
+# 主流程 3/11:add_message → 截取最近消息 → 尝试写入数据库。
+# 主流程 4/6:读取历史和偏好;查询类意图另外记录整段查询和当前任务。
+# 旁路:网页保存偏好或清空记忆时才调用对应方法;下面各方法不是依次全执行。
+# 当前对象和数据库表均未使用 user_id/session_id 区分用户;current_task 仅保存在内存。
+
+"""
+需求:实现SmartBizTrip的记忆管理模块,包括短期对话记忆、用户偏好记忆和当前任务上下文
+支持 MySQL 数据库持久化,服务重启后自动恢复记忆数据
+
+架构图:
+
+┌────────────────────────────────────────────────────────────────────────────┐
+│                         ConversationMemory  类                             │
+│                                                                            │
+│  ┌─────────────────── 内存层 (Memory) ───────────────────┐                │
+│  │                                                        │                │
+│  │  short_term_messages[]  ── 短期对话 (最多10条)         │                │
+│  │  user_profile{}         ── 用户偏好 (座位/舱位等)      │                │
+│  │  current_task{}         ── 当前任务上下文 (类型/城市)   │                │
+│  │  entity_history[]       ── 提取的实体历史 (最多20条)    │                │
+│  └────────────────────┬──────────────────────────────────┘                │
+│                       │ add / update / get                                 │
+│                       ▼                                                    │
+│  ┌─────────────────── 持久层 (MySQL) ────────────────────┐                │
+│  │                                                        │                │
+│  │  _db_conn ── mysql.connector 连接                      │                │
+│  │  set_db_connection() / _ensure_db()  连接管理          │                │
+│  │                                                        │                │
+│  │  ┌──────────────────────────────────────────────┐     │                │
+│  │  │  user_profiles 表                             │     │                │
+│  │  │  (profile_key PK, profile_value)              │     │                │
+│  │  │  save_profile_to_db()  ← INSERT ... UPSERT   │     │                │
+│  │  │  load_profile_from_db()  ← SELECT            │     │                │
+│  │  └──────────────────────────────────────────────┘     │                │
+│  │  ┌──────────────────────────────────────────────┐     │                │
+│  │  │  query_history 表                             │     │                │
+│  │  │  (id PK, intent_type, query_content,          │     │                │
+│  │  │   query_time)                                 │     │                │
+│  │  │  save_entity_to_db()   ← INSERT               │     │                │
+│  │  │  load_entities_from_db() ← SELECT ORDER BY    │     │                │
+│  │  └──────────────────────────────────────────────┘     │                │
+│  │  ┌──────────────────────────────────────────────┐     │                │
+│  │  │  short_term_messages 表                       │     │                │
+│  │  │  (id PK, role, content, message_time,         │     │                │
+│  │  │   message_order)                              │     │                │
+│  │  │  save_messages_to_db()   ← DELETE + INSERT    │     │                │
+│  │  │  load_messages_from_db() ← SELECT ORDER BY    │     │                │
+│  │  └──────────────────────────────────────────────┘     │                │
+│  │                                                        │                │
+│  │  clear_all_from_db()  ← 清空三张表                     │                │
+│  └────────────────────────────────────────────────────────┘                │
+│                                                                            │
+│  ┌─────────────────── 序列化层 ──────────────────────────┐                │
+│  │                                                        │                │
+│  │  to_dict()   → 导出记忆为 dict (实体仅取最近5条)       │                │
+│  │  from_dict() ← 从 dict 恢复记忆 (@classmethod)         │                │
+│  └────────────────────────────────────────────────────────┘                │
+└────────────────────────────────────────────────────────────────────────────┘
+
+数据流向:
+
+    用户消息 → add_message() → short_term_messages[] → save_messages_to_db()
+                                                      → MySQL: short_term_messages 表
+                                                      → (DELETE + 批量 INSERT)
+
+    偏好更新 → update_profile() → user_profile{}     → save_profile_to_db()
+                                                      → MySQL: user_profiles 表
+                                                      → (INSERT ... ON DUPLICATE KEY UPDATE)
+
+    实体提取 → extract_entities() → entity_history[]  → save_entity_to_db()
+                                                      → MySQL: query_history 表
+                                                      → (单条 INSERT)
+
+    服务重启 → load_profile_from_db()   → 恢复 user_profile{}
+              load_messages_from_db()   → 恢复 short_term_messages[]
+              load_entities_from_db()   → 恢复 entity_history[]
+
+方法分类:
+
+    写操作 (Write)          读操作 (Read)           序列化 (Serialize)
+    ──────────────────     ──────────────────      ──────────────────
+    add_message()          get_short_term_text()   to_dict()
+    update_profile()       get_profile_text()      from_dict()
+    update_task_context()
+    extract_entities()
+    clear()
+
+    持久化 (DB Write)       加载 (DB Read)          连接管理
+    ──────────────────     ──────────────────      ──────────────────
+    save_messages_to_db()  load_messages_from_db() set_db_connection()
+    save_profile_to_db()   load_profile_from_db()  _ensure_db()
+    save_entity_to_db()    load_entities_from_db()
+    clear_all_from_db()
+"""
+from datetime import datetime
+import json
+import mysql.connector
+import pytz
+
+
+class ConversationMemory:
+    """管理对话记忆的类,包括短期对话、用户偏好和任务上下文"""
+
+    def __init__(self, short_term_limit: int = 10):
+        # 【记忆 M1:初始化】先创建空容器,数据库连接由 ChatService 稍后注入。
+        # 由 ChatService 创建。short_term_messages 按时间顺序保存消息,user_profile 按键保存偏好。
+        # current_task 保存最近正在处理的任务字段,entity_history 保存查询记录;它们用途不同,不会自动相互同步。
+        # _db_conn 初始为 None,表示尚未启用持久化;内存操作仍可以先进行。
+        self.short_term_messages = []  # 短期对话消息列表,最多保留short_term_limit条
+        self.user_profile = {}  # 用户偏好,如 {"座位喜好": "二等座", "cabin_type": "经济舱"}
+        self.current_task = {}  # 当前任务上下文,如 {"type": "train", "departure_city": "北京", "arrival_city": "上海"}
+        self.short_term_limit = short_term_limit  # 短期记忆最大长度
+        self.entity_history = []  # 历史提取的关键实体
+        self._db_conn = None  # 数据库连接
+
+    def set_db_connection(self, db_conn):
+        """设置数据库连接(由 ChatService 注入)"""
+        # 【记忆 M2:连接注入】保存已有连接,不在此方法里创建数据库连接。
+        # 调用方传入已经建立的 MySQL 连接,也可以传 None 表示只使用内存。
+        # 这是保存引用:后面的读写方法使用同一个连接对象,而不是每次复制一条连接。
+        self._db_conn = db_conn
+
+    def _ensure_db(self):
+        """确保数据库连接有效,断开则自动重连"""
+        # 【持久化内部步骤 1】每次读写前检查连接;有连接对象时通过 ping 尝试重连。
+        # 由各个数据库读写方法在执行 SQL 前调用;正常完成不需要返回数据。
+        # 没有连接就抛 RuntimeError;已有连接则 ping 检查并允许驱动重连,失败时转成统一错误。
+        if self._db_conn is None:
+            raise RuntimeError("数据库连接未初始化")
+        try:
+            self._db_conn.ping(reconnect=True)
+        except Exception:
+            raise RuntimeError("数据库连接已断开")
+
+    def add_message(self, role: str, content: str):
+        """添加消息到短期记忆,并持久化到数据库"""
+        # 【主流程 3/11,记忆内部步骤 1】追加角色、文本和时间;用户消息和助手消息分别调用一次。
+        # 输入 role 是 user/assistant,content 是要保存的全文;本方法通过修改列表产生作用,没有业务返回值。
+        # 用户输入时调用一次,助手最终回复时再调用一次;时间戳用于显示消息记录时间。
+        self.short_term_messages.append({
+            "role": role,
+            "content": content,
+            "timestamp": datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%H:%M:%S')
+        })
+        # 超过限制则移除最旧的消息
+        # 【记忆内部步骤 2】只保留最近 short_term_limit 条消息;“条”按消息计,不按对话轮数计。
+        if len(self.short_term_messages) > self.short_term_limit:
+            # 负数切片从末尾向前数,保留最后 N 条;不是按角色各保留 N 条,也不按自然日划分。
+            self.short_term_messages = self.short_term_messages[-self.short_term_limit:]
+        # 持久化到数据库
+        self.save_messages_to_db()
+
+    def get_short_term_text(self) -> str:
+        """获取短期对话的文本格式,用于意图识别和代理调用"""
+        # 【主流程 4/6】按消息列表顺序拼成 User/Assistant 文本,供模型和子助手理解上下文。
+        # 输入来自实例里的短期消息列表,输出一个用换行连接的字符串。
+        # 例如两条消息变成“User: 北京明天天气
+        # Assistant: ...”;模型收到的是这段文本,不是数据库游标。
+        lines = []
+        for msg in self.short_term_messages:
+            role_label = "User" if msg["role"] == "user" else "Assistant"
+            lines.append(f"{role_label}: {msg['content']}")
+        return '\n'.join(lines)
+
+    def update_profile(self, profile_update: dict, replace: bool = False):
+        """更新用户偏好,并持久化到数据库"""
+        # 【偏好内部步骤 1】先留存旧值,再按 replace 选择整体替换或增量合并。
+        # 调用方给出 profile_update 字典;replace=True 是整体替换,False 是按键合并。
+        # 例如旧值 {"座席": "二等座", "舱位": "经济舱"},只提交新座席时,合并保留舱位,替换会移除舱位。
+        # copy 保存旧字典的浅拷贝,用于写入过程抛出异常时恢复;不是再次向数据库读取旧值。
+        previous = self.user_profile.copy()
+        if replace:
+            self.user_profile = dict(profile_update)
+        else:
+            self.user_profile.update(profile_update)
+        try:
+            # 【偏好内部步骤 2】尝试写库;若异常向外抛出,就恢复内存中的旧偏好。
+            # 当前保存方法只有 replace=True 时会继续抛出写库异常;合并模式失败只打印信息。
+            self.save_profile_to_db(replace=replace)
+        except Exception:
+            # 恢复内存后用单独的 raise 把原异常继续向上抛,让 API 调用方知道保存失败。
+            self.user_profile = previous
+            raise
+
+    def update_task_context(self, task_update: dict):
+        """更新当前任务上下文"""
+        # 【主流程 6,记忆旁路】合并当前任务字段;此方法不写数据库,也不调用模型提取字段。
+        # dict.update 将本次字段覆盖到 current_task;未提交的旧键仍保留。
+        # 当前 ChatService 通常只提交 type/query,不能把它理解为已自动提取出完整行程字段。
+        self.current_task.update(task_update)
+
+    def extract_entities(self, intent_type: str, query: str):
+        """记录意图类型和查询文本到历史,并持久化到数据库。"""
+        # 【主流程 6,查询历史内部步骤 1】实际保存的是意图类型和整段 query。
+        # 方法名虽然叫 extract_entities,这里没有再解析城市、日期,也没有额外模型调用。
+        # 输入 intent_type 记录是哪类查询,query 记录该次查询文本。
+        # 先追加内存历史,再裁剪为最多 20 条,最后单独向 query_history 表追加记录。
+        self.entity_history.append({
+            "type": intent_type,
+            "query": query,
+            "timestamp": datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S')
+        })
+        # 只保留最近20条实体记录
+        if len(self.entity_history) > 20:
+            self.entity_history = self.entity_history[-20:]
+        # 持久化单条到数据库
+        self.save_entity_to_db(intent_type, query)
+
+    def get_profile_text(self) -> str:
+        """获取用户偏好的文本描述,用于注入到prompt中"""
+        # 【主流程 4】把偏好键值对转成可放入提示词的文本;没有偏好时返回固定说明。
+        # 字典非空时逐对转换为“键: 值”,再用中文逗号连接;返回给 intent_agent 放进提示模板。
+        if not self.user_profile:
+            return "无已知的用户偏好"
+        items = [f"{k}: {v}" for k, v in self.user_profile.items()]
+        return ",".join(items)
+
+    def clear(self):
+        """清空所有记忆,同时清除数据库数据"""
+        # 【清空内部步骤 1】先清空四类内存状态,再尝试删除数据库中的三张记忆表数据。
+        # 本方法先清内存再调用 clear_all_from_db;数据库清理失败时该方法不会自动恢复原内存。
+        # 因此这是代码当前执行顺序,不是跨内存和数据库的一笔原子事务。
+        self.short_term_messages = []
+        self.user_profile = {}
+        self.current_task = {}
+        self.entity_history = []
+        self.clear_all_from_db()
+
+    def to_dict(self) -> dict:
+        """导出记忆为字典,用于序列化"""
+        # 【辅助导出】把当前内存整理成字典,查询历史仅取最近 5 条;不是完整数据库备份。
+        # 返回便于传递的普通字典;列表和字典多数仍引用原对象,没有在此处做完整深拷贝。
+        return {
+            "short_term_messages": self.short_term_messages,
+            "user_profile": self.user_profile,
+            "current_task": self.current_task,
+            "entity_history": self.entity_history[-5:]  # 只导出最近5条
+        }
+
+    @classmethod
+    def from_dict(cls, data: dict) -> 'ConversationMemory':
+        """从字典导入记忆"""
+        # 【辅助导入 1 → 2】创建新对象并填入字典中的数据,不建立连接,也不自动写回数据库。
+        # @classmethod 使第一个参数 cls 指向类;cls() 创建一个新记忆实例。
+        # 与普通方法的 self 不同,这里无需先有实例;缺失字段使用空列表或空字典补齐。
+        memory = cls()
+        memory.short_term_messages = data.get("short_term_messages", [])
+        memory.user_profile = data.get("user_profile", {})
+        memory.current_task = data.get("current_task", {})
+        memory.entity_history = data.get("entity_history", [])
+        return memory
+
+    # ==================== 数据库持久化 ====================
+
+    def save_profile_to_db(self, replace: bool = False):
+        """将用户偏好持久化到数据库(UPSERT)"""
+        # 【偏好内部步骤 2】无连接则仅保留内存;有连接时检查连接并开始写偏好。
+        # 调用者 update_profile 已修改内存,此处负责同步偏好表。
+        # 每个键对应一条记录;数据库用唯一键判断 INSERT 还是 UPDATE,最后统一 commit。
+        if self._db_conn is None:
+            return
+        # 先给 cursor 一个明确初值;即使创建游标之前就失败,finally 也能判断是否需要关闭。
+        cursor = None
+        try:
+            self._ensure_db()
+            cursor = self._db_conn.cursor()
+            # 替换模式先删除旧偏好,保证网页删除的键不会残留;合并模式只做 UPSERT。
+            if replace:
+                cursor.execute("DELETE FROM user_profiles")
+            for key, value in self.user_profile.items():
+                cursor.execute(
+                    "INSERT INTO user_profiles (profile_key, profile_value) "
+                    "VALUES (%s, %s) ON DUPLICATE KEY UPDATE profile_value = %s",
+                    # 三个参数依次对应 INSERT 的键、INSERT 的值、冲突时 UPDATE 的值。
+                    # value 转成字符串写库,因此重新加载后不会自动恢复为原来的数字或列表类型。
+                    (key, str(value), str(value))
+                )
+            # 【偏好内部步骤 3】本轮写入提交后才完成;异常分支 rollback,finally 关闭游标。
+            self._db_conn.commit()
+        except Exception as e:
+            # 回滚本次尚未提交的数据库修改;后面的 if replace 决定是否继续向调用方抛出异常。
+            self._db_conn.rollback()
+            if replace:
+                raise
+            print(f"保存用户偏好到数据库失败: {e}")
+        # finally 在正常和异常路径都会执行;只负责释放游标,不改变本轮成功或失败的结果。
+        finally:
+            if cursor is not None:
+                cursor.close()
+
+    def load_profile_from_db(self):
+        """从数据库加载用户偏好"""
+        # 【启动恢复 1】读取数据库偏好,重建内存中的 user_profile 字典。
+        # 服务初始化时调用。返回的 rows 是字典列表,最后的字典推导式把它改成“偏好键→偏好值”。
+        # 这里只 SELECT,不需要提交写事务;加载结果会整体替换当前 user_profile。
+        if self._db_conn is None:
+            return
+        try:
+            self._ensure_db()
+            cursor = self._db_conn.cursor(dictionary=True)
+            cursor.execute("SELECT profile_key, profile_value FROM user_profiles")
+            rows = cursor.fetchall()
+            cursor.close()
+            self.user_profile = {row["profile_key"]: row["profile_value"] for row in rows}
+        except Exception as e:
+            print(f"从数据库加载用户偏好失败: {e}")
+
+    def save_entity_to_db(self, intent_type: str, query: str):
+        """将单条查询实体持久化到数据库"""
+        # 【查询历史内部步骤 2】每次新增一条历史记录;内存只保留 20 条不代表表中也只存 20 条。
+        # 输入是本次查询类型和文本;query_content 保存成 JSON,query_time 单独保存完整日期时间。
+        # 此处只追加一条记录,不会因为内存裁剪而删除旧数据库历史。
+        if self._db_conn is None:
+            return
+        try:
+            self._ensure_db()
+            cursor = self._db_conn.cursor()
+            now = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%Y-%m-%d %H:%M:%S')
+            cursor.execute(
+                "INSERT INTO query_history (intent_type, query_content, query_time) "
+                "VALUES (%s, %s, %s)",
+                (intent_type, json.dumps({"query": query}, ensure_ascii=False), now)
+            )
+            self._db_conn.commit()
+            cursor.close()
+        except Exception as e:
+            print(f"保存查询实体到数据库失败: {e}")
+
+    def load_entities_from_db(self, limit: int = 20):
+        """从数据库加载查询历史,按时间倒序取最近N条"""
+        # 【启动恢复 2】数据库先取最新的 limit 条,再反转为从旧到新的阅读顺序。
+        # limit 限制加载条数,默认 20。先取最新记录,再恢复为最旧在前、最新在后的列表。
+        # 每条 query_content 单独尝试 json.loads;某条解析失败时,该条 query 使用空串,继续处理其余记录。
+        if self._db_conn is None:
+            return
+        try:
+            self._ensure_db()
+            cursor = self._db_conn.cursor(dictionary=True)
+            cursor.execute(
+                "SELECT intent_type, query_content, query_time FROM query_history "
+                "ORDER BY query_time DESC LIMIT %s",
+                # 逗号表示只有一个元素的元组,用作 SQL 参数容器;(limit) 仍只是普通数值。
+                (limit,)
+            )
+            rows = cursor.fetchall()
+            cursor.close()
+            # 按时间正序排列(倒序取出来后翻转)
+            # SQL 用 DESC 取最近记录,reverse 原地反转列表,恢复聊天中习惯的时间正序。
+            rows.reverse()
+            self.entity_history = []
+            for row in rows:
+                try:
+                    query_data = json.loads(row["query_content"])
+                    query_text = query_data.get("query", "")
+                except Exception:
+                    query_text = ""
+                self.entity_history.append({
+                    "type": row["intent_type"],
+                    "query": query_text,
+                    "timestamp": row["query_time"].strftime('%Y-%m-%d %H:%M:%S') if row["query_time"] else ""
+                })
+        except Exception as e:
+            print(f"从数据库加载查询历史失败: {e}")
+
+    def save_messages_to_db(self):
+        """将短期对话覆盖写入数据库(先删除旧数据,再写入当前列表)"""
+        # 【主流程 3/11,记忆内部步骤 3】无连接时跳过;有连接时用当前短期列表覆盖表内数据。
+        # 保存的是截取后的最近消息,不是完整聊天记录;message_order 用于恢复原顺序。
+        # 保存策略是当前短期消息的整表快照:DELETE 旧快照 → 逐条 INSERT → commit。
+        # 与 query_history 的“每次追加”不同,short_term_messages 表不会在此路径保留无限历史。
+        if self._db_conn is None:
+            return
+        try:
+            self._ensure_db()
+            cursor = self._db_conn.cursor()
+            # 【记忆内部步骤 4】先删旧列表,再按序插入当前列表,最后 commit。
+            cursor.execute("DELETE FROM short_term_messages")
+            # enumerate 同时给出下标 i 和消息 msg;i 从 0 开始,写入 message_order 供加载时排序。
+            for i, msg in enumerate(self.short_term_messages):
+                cursor.execute(
+                    "INSERT INTO short_term_messages (role, content, message_time, message_order) "
+                    "VALUES (%s, %s, %s, %s)",
+                    (msg["role"], msg["content"], msg["timestamp"], i)
+                )
+            self._db_conn.commit()
+            cursor.close()
+        except Exception as e:
+            print(f"保存短期对话到数据库失败: {e}")
+
+    def load_messages_from_db(self):
+        """从数据库加载短期对话"""
+        # 【启动恢复 3】按 message_order 恢复短期消息;随后 ChatService 同步到自己的 messages。
+        # 按 message_order 升序还原保存时的列表次序,而不是按格式化后的时间字符串排序。
+        # 若没有查到记录,列表推导式得到 [],短期记忆恢复为空列表。
+        if self._db_conn is None:
+            return
+        try:
+            self._ensure_db()
+            cursor = self._db_conn.cursor(dictionary=True)
+            cursor.execute(
+                "SELECT role, content, message_time FROM short_term_messages ORDER BY message_order ASC"
+            )
+            rows = cursor.fetchall()
+            cursor.close()
+            self.short_term_messages = [
+                {"role": row["role"], "content": row["content"], "timestamp": row["message_time"]}
+                for row in rows
+            ]
+        except Exception as e:
+            print(f"从数据库加载短期对话失败: {e}")
+
+    def clear_all_from_db(self):
+        """清空数据库中所有记忆数据"""
+        # 【清空内部步骤 2】删除三张记忆表的记录并提交;不删除天气、票务等业务数据。
+        # 三条 DELETE 依次清除短期消息、查询历史、用户偏好,最后统一提交。
+        # 这三条语句没有 WHERE 用户条件,影响的是这些表中的所有记录。
+        if self._db_conn is None:
+            return
+        try:
+            self._ensure_db()
+            cursor = self._db_conn.cursor()
+            cursor.execute("DELETE FROM short_term_messages")
+            cursor.execute("DELETE FROM query_history")
+            cursor.execute("DELETE FROM user_profiles")
+            self._db_conn.commit()
+            cursor.close()
+        except Exception as e:
+            print(f"清空数据库记忆失败: {e}")

+ 35 - 0
pyproject.toml

@@ -0,0 +1,35 @@
+[project]
+name = "smartbiztrip"
+version = "0.1.0"
+description = "企业差旅 Agent 教学项目"
+readme = "README.md"
+# 课程统一使用 Python 3.12,具体版本见 .python-version。
+requires-python = ">=3.12.11,<3.13"
+dependencies = [
+    # 大模型调用与 Agent 编排
+    "langchain==0.3.26",
+    "langchain-core==0.3.86",
+    "langchain-openai==0.3.28",
+    # A2A 通信与 MCP 工具适配
+    "python-a2a==0.5.4",
+    "langchain-mcp-adapters==0.1.11",
+    "mcp==1.18.0",
+    # Web 后台与数据模型
+    "fastapi==0.116.1",
+    "uvicorn==0.35.0",
+    "pydantic==2.13.5",
+    # 数据库客户端;MySQL 和 Milvus 服务仍需单独准备
+    "mysql-connector-python==9.4.0",
+    "pymilvus==3.0.1",
+    # HTTP 请求、本地配置和时区
+    "requests==2.34.2",
+    "python-dotenv==1.2.3",
+    "pytz==2025.2",
+    # 天气定时采集
+    "schedule==1.2.2",
+    "socksio>=1.0.0",
+]
+
+[tool.uv]
+# 直接运行项目中的脚本,不将当前目录构建成安装包。
+package = false

+ 34 - 0
requirements.txt

@@ -0,0 +1,34 @@
+# SmartBizTrip 项目 Python 依赖
+# 基于当前代码导入和本地 .venv 的已安装版本整理;参考 Python 3.12.11。
+# 固定项目直接依赖及关键 MCP 版本;间接依赖由安装器解析,并非完整环境锁文件。
+# 使用 uv 安装:uv pip install --python .venv/bin/python -r requirements.txt
+# 使用 pip 安装(环境内需有 pip):python -m pip install -r requirements.txt
+# MySQL、Milvus 服务需单独启动;API Key 保存在本地 .env 中。
+
+# 大模型调用与 Agent 编排
+langchain==0.3.26
+langchain-core==0.3.86
+langchain-openai==0.3.28
+
+# A2A 通信与 MCP 工具适配
+python-a2a==0.5.4
+langchain-mcp-adapters==0.1.11
+mcp==1.18.0
+
+# Web 后台与数据模型
+fastapi==0.116.1
+uvicorn==0.35.0
+pydantic==2.13.5
+
+# 关系数据库与向量数据库客户端
+mysql-connector-python==9.4.0
+pymilvus==3.0.1
+
+# HTTP 请求、本地配置和时区
+requests==2.34.2
+python-dotenv==1.2.3
+pytz==2025.2
+
+# 天气定时采集 utils/spider_weather.py 使用;整理时本地 .venv 尚未安装。
+# 版本来源:https://pypi.org/project/schedule/1.2.2/
+schedule==1.2.2

+ 162 - 0
sql/create_all_tables.sql

@@ -0,0 +1,162 @@
+-- ============================================================
+-- SmartBizTrip 全量建表脚本
+-- 合并所有业务表的 CREATE TABLE 语句
+-- 数据库: biztrip_rag
+-- ============================================================
+
+SET NAMES utf8mb4;
+
+DROP DATABASE IF EXISTS biztrip_rag;
+CREATE DATABASE biztrip_rag CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+USE biztrip_rag;
+
+-- ==================== 高铁票表 ====================
+DROP TABLE IF EXISTS train_tickets;
+CREATE TABLE train_tickets (
+    id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键,自增,唯一标识每条记录',
+    departure_city VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '出发城市(如"北京")',
+    arrival_city VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '到达城市(如"上海")',
+    departure_time DATETIME NOT NULL COMMENT '出发时间',
+    arrival_time DATETIME NOT NULL COMMENT '到达时间',
+    train_number VARCHAR(20) NOT NULL COMMENT '火车车次(如"G1001")',
+    seat_type VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '座位类型(如"二等座")',
+    total_seats INT NOT NULL COMMENT '总座位数',
+    remaining_seats INT NOT NULL COMMENT '剩余座位数',
+    price DECIMAL(10, 2) NOT NULL COMMENT '票价(如 553.50)',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间,自动记录插入时间',
+    UNIQUE KEY unique_train (departure_time, train_number, seat_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='高铁票信息表';
+
+-- ==================== 航班票务表 ====================
+DROP TABLE IF EXISTS flight_tickets;
+CREATE TABLE flight_tickets (
+    id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键,自增,唯一标识每条记录',
+    departure_city VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '出发城市',
+    arrival_city VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '到达城市',
+    departure_time DATETIME NOT NULL COMMENT '出发时间',
+    arrival_time DATETIME NOT NULL COMMENT '到达时间',
+    flight_number VARCHAR(20) NOT NULL COMMENT '航班号(如"CA1234")',
+    cabin_type VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '舱位类型(如"经济舱")',
+    total_seats INT NOT NULL COMMENT '总座位数',
+    remaining_seats INT NOT NULL COMMENT '剩余座位数',
+    price DECIMAL(10, 2) NOT NULL COMMENT '票价',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间,自动记录插入时间',
+    UNIQUE KEY unique_flight (departure_time, flight_number, cabin_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='航班票务信息表';
+
+-- ==================== 会展门票表 ====================
+DROP TABLE IF EXISTS event_tickets;
+CREATE TABLE event_tickets (
+    id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键,自增',
+    event_name VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '活动名称',
+    city VARCHAR(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '举办城市',
+    venue VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '场馆',
+    start_time DATETIME NOT NULL COMMENT '开始时间',
+    end_time DATETIME NOT NULL COMMENT '结束时间',
+    ticket_type VARCHAR(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '票类型(如"VIP")',
+    total_seats INT NOT NULL COMMENT '总座位数',
+    remaining_seats INT NOT NULL COMMENT '剩余座位数',
+    price DECIMAL(10, 2) NOT NULL COMMENT '票价',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY unique_event (start_time, event_name, ticket_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='会展门票信息表';
+
+-- ==================== 天气数据表 ====================
+DROP TABLE IF EXISTS weather_data;
+CREATE TABLE IF NOT EXISTS weather_data (
+    id INT AUTO_INCREMENT PRIMARY KEY,
+    city VARCHAR(50) NOT NULL COMMENT '城市名称',
+    fx_date DATE NOT NULL COMMENT '预报日期',
+    sunrise TIME COMMENT '日出时间',
+    sunset TIME COMMENT '日落时间',
+    moonrise TIME COMMENT '月升时间',
+    moonset TIME COMMENT '月落时间',
+    moon_phase VARCHAR(20) COMMENT '月相名称',
+    moon_phase_icon VARCHAR(10) COMMENT '月相图标代码',
+    temp_max INT COMMENT '最高温度',
+    temp_min INT COMMENT '最低温度',
+    icon_day VARCHAR(10) COMMENT '白天天气图标代码',
+    text_day VARCHAR(20) COMMENT '白天天气描述',
+    icon_night VARCHAR(10) COMMENT '夜间天气图标代码',
+    text_night VARCHAR(20) COMMENT '夜间天气描述',
+    wind360_day INT COMMENT '白天风向360角度',
+    wind_dir_day VARCHAR(20) COMMENT '白天风向',
+    wind_scale_day VARCHAR(10) COMMENT '白天风力等级',
+    wind_speed_day INT COMMENT '白天风速 (km/h)',
+    wind360_night INT COMMENT '夜间风向360角度',
+    wind_dir_night VARCHAR(20) COMMENT '夜间风向',
+    wind_scale_night VARCHAR(10) COMMENT '夜间风力等级',
+    wind_speed_night INT COMMENT '夜间风速 (km/h)',
+    precip DECIMAL(5,1) COMMENT '降水量 (mm)',
+    uv_index INT COMMENT '紫外线指数',
+    humidity INT COMMENT '相对湿度 (%)',
+    pressure INT COMMENT '大气压强 (hPa)',
+    vis INT COMMENT '能见度 (km)',
+    cloud INT COMMENT '云量 (%)',
+    update_time DATETIME COMMENT '数据更新时间',
+    UNIQUE KEY unique_city_date (city, fx_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='天气数据表';
+
+-- ==================== 商务用车表 ====================
+DROP TABLE IF EXISTS car_rentals;
+CREATE TABLE car_rentals (
+    id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键,自增',
+    company VARCHAR(50) NOT NULL COMMENT '商务用车公司名称',
+    pickup_city VARCHAR(50) NOT NULL COMMENT '取车城市',
+    return_city VARCHAR(50) NOT NULL COMMENT '还车城市',
+    pickup_date DATE NOT NULL COMMENT '取车日期',
+    car_type VARCHAR(20) NOT NULL COMMENT '车型分类:经济型/SUV/豪华型/MPV',
+    car_model VARCHAR(50) NOT NULL COMMENT '具体车型',
+    price_per_day DECIMAL(8,2) NOT NULL COMMENT '每日租金(元)',
+    total_available INT NOT NULL DEFAULT 0 COMMENT '可用车辆数',
+    transmission VARCHAR(20) NOT NULL COMMENT '变速箱:自动挡/手动挡',
+    seats INT NOT NULL COMMENT '座位数',
+    deposit DECIMAL(8,2) NOT NULL DEFAULT 0 COMMENT '押金(元)',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY unique_car (company, car_model, pickup_date)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商务用车信息表';
+
+-- ==================== 保险表 ====================
+DROP TABLE IF EXISTS insurances;
+CREATE TABLE insurances (
+    id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键,自增',
+    insurance_type VARCHAR(20) NOT NULL COMMENT '保险类型:综合型/意外型/医疗型/境外型',
+    name VARCHAR(100) NOT NULL COMMENT '保险产品名称',
+    company VARCHAR(50) NOT NULL COMMENT '保险公司',
+    coverage TEXT NOT NULL COMMENT '保障范围说明',
+    price DECIMAL(8,2) NOT NULL COMMENT '价格(元/份)',
+    duration_days INT NOT NULL COMMENT '保障天数',
+    max_coverage DECIMAL(10,2) NOT NULL COMMENT '最高赔付金额(元)',
+    medical_coverage DECIMAL(10,2) DEFAULT 0 COMMENT '医疗保障额度(元)',
+    baggage_coverage DECIMAL(10,2) DEFAULT 0 COMMENT '行李保障额度(元)',
+    flight_delay BOOLEAN DEFAULT FALSE COMMENT '是否包含航班延误',
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    UNIQUE KEY unique_insurance (name)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='差旅保险信息表';
+
+-- ==================== 用户偏好表 ====================
+CREATE TABLE IF NOT EXISTS user_profiles (
+    profile_key VARCHAR(50) NOT NULL COMMENT '偏好键名',
+    profile_value VARCHAR(200) NOT NULL COMMENT '偏好值',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (profile_key)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户偏好';
+
+-- ==================== 查询历史表 ====================
+CREATE TABLE IF NOT EXISTS query_history (
+    id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    intent_type VARCHAR(30) NOT NULL COMMENT '意图类型:weather/flight/train等',
+    query_content TEXT NOT NULL COMMENT '查询内容JSON',
+    query_time DATETIME NOT NULL COMMENT '查询时间',
+    INDEX idx_time (query_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='查询历史';
+
+-- ==================== 短期对话表 ====================
+CREATE TABLE IF NOT EXISTS short_term_messages (
+    id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键',
+    role VARCHAR(10) NOT NULL COMMENT '消息角色:user 或 assistant',
+    content TEXT NOT NULL COMMENT '消息内容',
+    message_time VARCHAR(10) NOT NULL COMMENT '时间戳 HH:MM:SS',
+    message_order INT NOT NULL COMMENT '消息顺序号',
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短期对话';

+ 438 - 0
sql/init_meeting_package_rag.py

@@ -0,0 +1,438 @@
+# 【数据准备 D:独立脚本】在会务套餐检索前准备 Milvus 集合;不在每轮聊天中执行。
+# 运行顺序:D1 读取配置和样例数据 → D2 删除重建集合及索引
+# → D3 为套餐生成向量并入库 → D4 用样例查询验证检索。
+# 注意 D2 会删除同名集合及其已有数据;本文件不是增量更新脚本。
+
+"""
+需求:初始化会务套餐 RAG 系统
+思路:
+1. 定义会务套餐数据(城市、线路名称、天数、价格、方案亮点等)
+2. 使用 Qwen 的 Embedding API 将每个套餐的方案亮点生成向量
+3. 在本地 Milvus 中创建 Collection 并插入数据
+4. 支持后续通过语义搜索匹配用户意图的会务套餐
+"""
+
+import json
+import os
+import sys
+import requests
+from pymilvus import connections, Collection, CollectionSchema, FieldSchema, DataType, utility
+
+# 将项目根目录加入 Python 路径,以便导入配置
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+# ==================== Qwen Embedding API ====================
+# 使用 DashScope 的 text-embedding-v3 模型生成向量
+# 返回 1024 维向量
+from config import Config
+# 【数据准备 D1】读取向量服务配置;下面 MEETING_PACKAGES 是脚本内置的样例数据。
+conf = Config()
+DASHSCOPE_API_KEY = conf.embedding_api_key  # 向量模型使用独立的 DashScope API Key
+EMBEDDING_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
+
+
+def get_embedding(text: str) -> list:
+    """
+    调用 Qwen Embedding API 生成文本向量
+
+    参数:
+        text (str): 需要生成向量的文本
+
+    返回值:
+        list: 1024 维向量
+    """
+    # 【数据准备 D3/D4 共用】文本 → 向量接口 → 1024 维数值列表。
+    # 入库的套餐文本和后续用户查询必须用兼容的模型及维度才能比较。
+    # 输入 text 是拼好的套餐文本,或测试用的查询文本;输出一条 1024 维向量。
+    # 同样的模型用于入库和查询,使两类文本被映射到可相互比较的向量空间。
+    # 调用可能产生外部 API 请求;这个函数没有本地缓存,重复调用也会再次请求。
+    headers = {
+        "Authorization": f"Bearer {DASHSCOPE_API_KEY}",
+        "Content-Type": "application/json"
+    }
+    payload = {
+        "model": "text-embedding-v3",
+        "input": [text],
+        "dimensions": 1024
+    }
+    response = requests.post(EMBEDDING_URL, headers=headers, json=payload, timeout=30)
+    result = response.json()
+    # 响应 data 是按输入文本顺序排列的列表;当前 input 只包含一个 text,因此取索引 0。
+    return result["data"][0]["embedding"]
+
+
+# ==================== 会务套餐数据 ====================
+# 这是用于入库的本地套餐样例列表,每个字典是一条套餐;定义列表不会向数据库写入。
+# package_id 是业务编号,embedding 尚未在这些样例中生成;后面 insert_meeting_packages 才逐条补上。
+MEETING_PACKAGES = [
+    {
+        "package_id": "CD001",
+        "package_name": "成都数字化转型峰会3日会务套餐",
+        "city": "成都",
+        "days": 3,
+        "price": 2380.00,
+        "total_seats": 80,
+        "remaining_seats": 26,
+        "agency": "携程商旅",
+        "rating": 4.8,
+        "departure_dates": ["2026-05-01", "2026-06-01", "2026-06-15"],
+        "highlights": "覆盖机场接送、会场签到、四星商务酒店、数字化转型主论坛、企业案例圆桌、成都高新区企业参访,适合中高层管理者集中参会"
+    },
+    {
+        "package_id": "CD002",
+        "package_name": "成都客户拜访2日接待套餐",
+        "city": "成都",
+        "days": 2,
+        "price": 1280.00,
+        "total_seats": 40,
+        "remaining_seats": 18,
+        "agency": "同程商旅",
+        "rating": 4.7,
+        "departure_dates": ["2026-05-01", "2026-06-01", "2026-06-08"],
+        "highlights": "包含机场/高铁站接送、客户会议室预订、商务午餐、企业展厅参观、晚间轻接待路线,适合销售团队与客户成功团队使用"
+    },
+    {
+        "package_id": "CD003",
+        "package_name": "成都团队共创工作坊2日套餐",
+        "city": "成都",
+        "days": 2,
+        "price": 980.00,
+        "total_seats": 60,
+        "remaining_seats": 31,
+        "agency": "会小二",
+        "rating": 4.6,
+        "departure_dates": ["2026-05-02", "2026-06-03", "2026-06-20"],
+        "highlights": "提供创意会议空间、分组讨论物料、茶歇、团队共创主持人、晚间团队餐,可按部门目标设计议程"
+    },
+    {
+        "package_id": "BJ001",
+        "package_name": "北京政企拜访3日差旅套餐",
+        "city": "北京",
+        "days": 3,
+        "price": 2680.00,
+        "total_seats": 50,
+        "remaining_seats": 14,
+        "agency": "国旅运通商旅",
+        "rating": 4.8,
+        "departure_dates": ["2026-05-06", "2026-06-02", "2026-06-16"],
+        "highlights": "覆盖政企客户拜访、会议室协调、商务酒店、用车排班、材料打印与接待动线,适合法务、销售和公共事务团队"
+    },
+    {
+        "package_id": "BJ002",
+        "package_name": "北京研发交流2日套餐",
+        "city": "北京",
+        "days": 2,
+        "price": 1580.00,
+        "total_seats": 35,
+        "remaining_seats": 12,
+        "agency": "飞猪企业商旅",
+        "rating": 4.7,
+        "departure_dates": ["2026-05-10", "2026-06-05"],
+        "highlights": "安排技术交流会议、园区参访、商务茶歇、投影与白板设备、晚间简餐,适合研发团队跨城对齐方案"
+    },
+    {
+        "package_id": "SH001",
+        "package_name": "上海总部培训4日会务套餐",
+        "city": "上海",
+        "days": 4,
+        "price": 3280.00,
+        "total_seats": 90,
+        "remaining_seats": 37,
+        "agency": "锦江会务",
+        "rating": 4.9,
+        "departure_dates": ["2026-05-08", "2026-06-01", "2026-06-18"],
+        "highlights": "包含培训教室、签到系统、四星酒店、浦东机场接送、讲师休息室、分组研讨区,适合新人训和区域经理培训"
+    },
+    {
+        "package_id": "SH002",
+        "package_name": "上海大型展会参展3日套餐",
+        "city": "上海",
+        "days": 3,
+        "price": 2980.00,
+        "total_seats": 70,
+        "remaining_seats": 22,
+        "agency": "携程商旅",
+        "rating": 4.8,
+        "departure_dates": ["2026-05-12", "2026-06-10"],
+        "highlights": "覆盖展馆通行、展位物料运输、酒店班车、客户邀约晚宴、现场证件管理,适合市场与渠道团队参展"
+    },
+    {
+        "package_id": "HZ001",
+        "package_name": "杭州电商标杆研学3日套餐",
+        "city": "杭州",
+        "days": 3,
+        "price": 2180.00,
+        "total_seats": 45,
+        "remaining_seats": 16,
+        "agency": "浙商会务",
+        "rating": 4.7,
+        "departure_dates": ["2026-05-09", "2026-06-06", "2026-06-21"],
+        "highlights": "安排电商园区参访、直播基地交流、业务复盘会议、团队晚餐和西湖周边轻接待,适合运营与增长团队"
+    },
+    {
+        "package_id": "SZ001",
+        "package_name": "深圳创新企业参访3日套餐",
+        "city": "深圳",
+        "days": 3,
+        "price": 2580.00,
+        "total_seats": 55,
+        "remaining_seats": 20,
+        "agency": "南方商旅",
+        "rating": 4.8,
+        "departure_dates": ["2026-05-11", "2026-06-09"],
+        "highlights": "覆盖科技园企业参访、创新产品交流、商务用车、酒店会议室、闭门分享会,适合产品、战略和投资团队"
+    },
+    {
+        "package_id": "GZ001",
+        "package_name": "广州供应链展会3日套餐",
+        "city": "广州",
+        "days": 3,
+        "price": 2280.00,
+        "total_seats": 65,
+        "remaining_seats": 28,
+        "agency": "广之旅商旅",
+        "rating": 4.6,
+        "departure_dates": ["2026-05-15", "2026-06-12"],
+        "highlights": "适配采购团队参展,包含展馆接送、供应商洽谈室、商务午餐、样品寄送协助和晚间客户接待"
+    },
+    {
+        "package_id": "XA001",
+        "package_name": "西安制造业客户答谢2日套餐",
+        "city": "西安",
+        "days": 2,
+        "price": 1380.00,
+        "total_seats": 50,
+        "remaining_seats": 19,
+        "agency": "陕旅会务",
+        "rating": 4.6,
+        "departure_dates": ["2026-05-18", "2026-06-14"],
+        "highlights": "包含客户答谢晚宴、工厂参观车辆、会议设备、伴手礼管理和历史文化轻接待路线,适合制造业销售团队"
+    },
+    {
+        "package_id": "QD001",
+        "package_name": "青岛销售年会3日套餐",
+        "city": "青岛",
+        "days": 3,
+        "price": 2480.00,
+        "total_seats": 120,
+        "remaining_seats": 48,
+        "agency": "海滨会务",
+        "rating": 4.7,
+        "departure_dates": ["2026-05-20", "2026-06-20"],
+        "highlights": "支持百人销售年会、主会场搭建、分会场讨论、颁奖晚宴、团队拓展和海边商务晚餐"
+    },
+    {
+        "package_id": "CQ001",
+        "package_name": "重庆渠道大会2日套餐",
+        "city": "重庆",
+        "days": 2,
+        "price": 1180.00,
+        "total_seats": 100,
+        "remaining_seats": 42,
+        "agency": "山城会务",
+        "rating": 4.6,
+        "departure_dates": ["2026-05-16", "2026-06-11"],
+        "highlights": "覆盖渠道商签到、主论坛、圆桌沟通、商务晚宴、酒店会议室和机场接送,适合区域渠道大会"
+    },
+    {
+        "package_id": "NJ001",
+        "package_name": "南京项目交付驻场5日套餐",
+        "city": "南京",
+        "days": 5,
+        "price": 3680.00,
+        "total_seats": 30,
+        "remaining_seats": 9,
+        "agency": "苏宁商旅",
+        "rating": 4.7,
+        "departure_dates": ["2026-05-13", "2026-06-07"],
+        "highlights": "适合项目交付团队驻场,包含长住商务酒店、每日通勤用车、会议室、打印服务、加班简餐和周报复盘空间"
+    },
+    {
+        "package_id": "XM001",
+        "package_name": "厦门高管闭门会3日套餐",
+        "city": "厦门",
+        "days": 3,
+        "price": 3980.00,
+        "total_seats": 25,
+        "remaining_seats": 7,
+        "agency": "建发商旅",
+        "rating": 4.9,
+        "departure_dates": ["2026-05-22", "2026-06-18"],
+        "highlights": "面向高管闭门战略会,包含海景会议室、贵宾接送、保密签到、商务晚宴、会议纪要服务和安静住宿环境"
+    },
+]
+
+
+# ==================== Milvus 初始化 ====================
+MILVUS_HOST = "localhost"
+MILVUS_PORT = 19530
+COLLECTION_NAME = "meeting_packages"
+DIMENSION = 1024  # text-embedding-v3 输出维度
+
+
+def create_meeting_package_collection():
+    """
+    在 Milvus 中创建会务套餐 Collection
+
+    字段设计:
+    - id: 主键(自增)
+    - package_id: 套餐编号(字符串,如 YN001)
+    - package_name: 套餐名称
+    - city: 城市
+    - days: 天数
+    - price: 价格
+    - total_seats: 总座位
+    - remaining_seats: 剩余座位
+    - agency: 差旅服务商
+    - rating: 评分
+    - departure_dates: 出发日期列表(JSON 字符串)
+    - highlights: 方案亮点(用于生成向量)
+    - embedding: 向量(1024 维)
+    """
+    # 连接 Milvus
+    # 【数据准备 D2.1】先连接 Milvus,再检查同名集合。
+    # 由文件末尾主程序首先调用;输出新建好的 Collection 对象,交给下一步入库函数。
+    # 普通 FieldSchema 保存套餐字段,FLOAT_VECTOR 字段承载语义检索向量。
+    # 已有集合删除后,新的自增 id 会重新由 Milvus 分配;它与样例中的 package_id 不是一个字段。
+    connections.connect(alias="default", host=MILVUS_HOST, port=MILVUS_PORT)
+    print(f"已连接 Milvus: {MILVUS_HOST}:{MILVUS_PORT}")
+
+    # 如果已存在则删除
+    # 【数据准备 D2.2】已有集合会被删除后重建;此操作不会保留旧集合中的套餐。
+    if utility.has_collection(COLLECTION_NAME):
+        print(f"集合 {COLLECTION_NAME} 已存在,删除重建")
+        utility.drop_collection(COLLECTION_NAME)
+
+    # 定义字段
+    # 【数据准备 D2.3】普通字段保存套餐信息,embedding 字段保存用于相似度检索的向量。
+    fields = [
+        FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
+        FieldSchema(name="package_id", dtype=DataType.VARCHAR, max_length=20),
+        FieldSchema(name="package_name", dtype=DataType.VARCHAR, max_length=200),
+        FieldSchema(name="city", dtype=DataType.VARCHAR, max_length=50),
+        FieldSchema(name="days", dtype=DataType.INT64),
+        FieldSchema(name="price", dtype=DataType.FLOAT),
+        FieldSchema(name="total_seats", dtype=DataType.INT64),
+        FieldSchema(name="remaining_seats", dtype=DataType.INT64),
+        FieldSchema(name="agency", dtype=DataType.VARCHAR, max_length=100),
+        FieldSchema(name="rating", dtype=DataType.FLOAT),
+        FieldSchema(name="departure_dates", dtype=DataType.VARCHAR, max_length=200),
+        FieldSchema(name="highlights", dtype=DataType.VARCHAR, max_length=1000),
+        FieldSchema(name="embedding", dtype=DataType.FLOAT_VECTOR, dim=DIMENSION),
+    ]
+
+    # 字段列表组装成集合结构;Collection(name, schema) 才将这个结构用于创建集合。
+    schema = CollectionSchema(fields, description="会务套餐信息表(RAG 向量检索)")
+    collection = Collection(COLLECTION_NAME, schema)
+    print(f"集合 {COLLECTION_NAME} 创建成功")
+
+    # 创建向量索引
+    # IVF_FLAT 是选用的向量索引类型;COSINE 是相似度度量;nlist 控制索引分组参数。
+    # 这些参数影响检索方式,不是套餐数量、向量维度或模型生成的 temperature。
+    index_params = {
+        "index_type": "IVF_FLAT",
+        "metric_type": "COSINE",
+        "params": {"nlist": 64}
+    }
+    # 【数据准备 D2.4】为 embedding 创建 COSINE 索引,然后返回集合给下一步使用。
+    collection.create_index(field_name="embedding", index_params=index_params)
+    print("向量索引创建成功 (IVF_FLAT, COSINE)")
+
+    # 返回的是可操作该集合的客户端对象;不是套餐列表,也不是已经生成好的向量。
+    return collection
+
+
+def insert_meeting_packages(collection):
+    """
+    将会务套餐数据生成向量后插入 Milvus
+    """
+    # 【数据准备 D3.1】依次处理套餐:拼检索文本 → 请求向量 → 将向量和原字段放入 rows。
+    # 输入 collection 是上一步返回的集合;无业务返回值,效果是将 rows 插入并刷入存储。
+    # 每个样例先组成 embedding_text,再调用向量接口,最后把向量与业务字段放在同一行中。
+    # 全部样例处理完以后才调用一次 collection.insert,前面的 rows.append 仅修改 Python 列表。
+    print(f"\n开始为 {len(MEETING_PACKAGES)} 个会务套餐生成向量...")
+    rows = []
+    # enumerate 给出从 0 开始的索引 i 和当前套餐 group,进度显示用 i+1 转成从 1 开始的计数。
+    for i, group in enumerate(MEETING_PACKAGES):
+        # 使用"城市 + 套餐名称 + 方案亮点"作为 embedding 输入文本
+        # 这是入库向量对应的文本;查询时会把用户需求另行转成向量与它比较。
+        # 只把城市、名称和亮点拼成向量文本;价格、余位等普通字段随后原样保存,不通过向量生成。
+        embedding_text = f"{group['city']} {group['package_name']} {group['highlights']}"
+
+        if (i + 1) % 5 == 0:
+            print(f"  已处理 {i + 1}/{len(MEETING_PACKAGES)} 个套餐...")
+
+        embedding = get_embedding(embedding_text)
+
+        rows.append({
+            "package_id": group["package_id"],
+            "package_name": group["package_name"],
+            "city": group["city"],
+            "days": group["days"],
+            "price": group["price"],
+            "total_seats": group["total_seats"],
+            "remaining_seats": group["remaining_seats"],
+            "agency": group["agency"],
+            "rating": group["rating"],
+            # 出发日期原来是列表,集合 schema 对应字段是 VARCHAR,所以先编码成 JSON 字符串保存。
+            "departure_dates": json.dumps(group["departure_dates"], ensure_ascii=False),
+            "highlights": group["highlights"],
+            "embedding": embedding,
+        })
+
+    # 批量插入
+    # 【数据准备 D3.2】全部向量准备完才批量插入,并 flush;这里没有逐条重试或断点续写。
+    collection.insert(rows)
+    # flush 要求刷入待持久化数据;它与后续查询前的 load 用途不同,load 是准备读取检索。
+    collection.flush()  # 刷盘
+    print(f"成功插入 {len(MEETING_PACKAGES)} 个会务套餐数据")
+
+
+def test_search(collection):
+    """
+    测试语义搜索:用自然语言查询会务套餐
+    """
+    # 测试查询
+    # 【数据准备 D4.1】每条测试查询也先向量化,然后检索;本函数会实际调用向量接口。
+    # 输入是已入库的 collection;输出方式为 print,便于人查看检索候选。
+    # 每个 query 单独求向量,检索结果按“查询向量→命中列表”组织,所以有两层 for。
+    # limit=2 限制候选条数,不表示验证了两个测试用例,也不是准确率阈值。
+    test_queries = [
+        "成都办数字化转型峰会,需要接送和酒店",
+        "上海大型展会参展,需要展馆通行和客户晚宴",
+        "适合销售团队的青岛年会方案",
+        "深圳创新企业参访,最好包含闭门分享",
+        "南京项目驻场,需要长住酒店和每日通勤",
+    ]
+
+    print("\n=== 语义搜索测试 ===")
+    # 外层依次执行测试需求;前一条查询完成以后才开始下一条,这里没有并发任务调度。
+    for query in test_queries:
+        print(f"\n查询: {query}")
+        query_embedding = get_embedding(query)
+
+        collection.load()
+        # 【数据准备 D4.2】取相似度靠前的两条并打印,供人工核对;不是自动质量评分。
+        results = collection.search(
+            data=[query_embedding],
+            anns_field="embedding",
+            param={"metric_type": "COSINE", "params": {"nprobe": 10}},
+            limit=2,
+            output_fields=["package_id", "package_name", "city", "days", "price", "rating", "agency", "highlights"]
+        )
+
+        for hits in results:
+            for hit in hits:
+                print(f"  [{hit.entity.get('package_id')}] {hit.entity.get('package_name')} "
+                      f"- {hit.entity.get('city')} {hit.entity.get('days')}天 "
+                      f"¥{hit.entity.get('price')} 评分{hit.entity.get('rating')} "
+                      f"距离: {hit.distance:.4f}")
+
+
+# 【数据准备入口】仅直接运行本脚本时按 D2 → D3 → D4 执行;导入不会进入此区块。
+if __name__ == '__main__':
+    # 本文件的实际运行入口:先拿到集合对象,再把同一个对象依次传给入库和测试函数。
+    # 上面的函数定义在加载文件时被创建,但直到这里调用才真正连接服务、生成向量和写入数据。
+    collection = create_meeting_package_collection()
+    insert_meeting_packages(collection)
+    test_search(collection)

+ 120 - 0
sql/insert_may01_data.sql

@@ -0,0 +1,120 @@
+-- ============================================================
+-- SmartBizTrip 5月1日企业差旅数据插入脚本
+-- 包含:高铁票(北京→成都)、航班票务(北京→成都)、
+--       会展门票(成都)、商务用车(成都)、保险、天气(成都)
+-- ============================================================
+
+SET NAMES utf8mb4;
+
+USE biztrip_rag;
+
+-- ==================== 高铁票:北京 → 成都(2026-05-01) ====================
+INSERT INTO train_tickets (departure_city, arrival_city, departure_time, arrival_time, train_number, seat_type, total_seats, remaining_seats, price) VALUES
+-- G字头高铁:北京西→成都东
+('北京', '成都', '2026-05-01 06:30:00', '2026-05-01 14:45:00', 'G351', '二等座', 1000, 128, 796.00),
+('北京', '成都', '2026-05-01 06:30:00', '2026-05-01 14:45:00', 'G351', '一等座', 400, 35, 1273.50),
+('北京', '成都', '2026-05-01 06:30:00', '2026-05-01 14:45:00', 'G351', '商务座', 50, 5, 2389.00),
+('北京', '成都', '2026-05-01 08:00:00', '2026-05-01 16:20:00', 'G349', '二等座', 1000, 67, 796.00),
+('北京', '成都', '2026-05-01 08:00:00', '2026-05-01 16:20:00', 'G349', '一等座', 400, 18, 1273.50),
+('北京', '成都', '2026-05-01 08:00:00', '2026-05-01 16:20:00', 'G349', '商务座', 50, 2, 2389.00),
+('北京', '成都', '2026-05-01 10:15:00', '2026-05-01 18:30:00', 'G89',  '二等座', 1000, 203, 796.00),
+('北京', '成都', '2026-05-01 10:15:00', '2026-05-01 18:30:00', 'G89',  '一等座', 400, 52, 1273.50),
+('北京', '成都', '2026-05-01 10:15:00', '2026-05-01 18:30:00', 'G89',  '商务座', 50, 8, 2389.00),
+('北京', '成都', '2026-05-01 13:40:00', '2026-05-01 21:55:00', 'G557', '二等座', 1000, 312, 796.00),
+('北京', '成都', '2026-05-01 13:40:00', '2026-05-01 21:55:00', 'G557', '一等座', 400, 89, 1273.50),
+('北京', '成都', '2026-05-01 16:00:00', '2026-05-02 00:15:00', 'G327','二等座', 1000, 156, 796.00),
+('北京', '成都', '2026-05-01 16:00:00', '2026-05-02 00:15:00', 'G327','一等座', 400, 41, 1273.50),
+-- D字头动车:北京西→成都东(夜车)
+('北京', '成都', '2026-05-01 20:30:00', '2026-05-02 08:10:00', 'D19',  '二等座', 800, 445, 623.00),
+('北京', '成都', '2026-05-01 20:30:00', '2026-05-02 08:10:00', 'D19',  '一等卧', 400, 98, 892.00);
+
+-- ==================== 航班票务:北京 → 成都(2026-05-01) ====================
+INSERT INTO flight_tickets (departure_city, arrival_city, departure_time, arrival_time, flight_number, cabin_type, total_seats, remaining_seats, price) VALUES
+-- 国航航班
+('北京', '成都', '2026-05-01 07:20:00', '2026-05-01 10:15:00', 'CA4116', '经济舱', 200, 12, 1580.00),
+('北京', '成都', '2026-05-01 07:20:00', '2026-05-01 10:15:00', 'CA4116', '商务舱', 40, 3, 3860.00),
+('北京', '成都', '2026-05-01 09:40:00', '2026-05-01 12:30:00', 'CA4108', '经济舱', 220, 28, 1620.00),
+('北京', '成都', '2026-05-01 09:40:00', '2026-05-01 12:30:00', 'CA4108', '商务舱', 40, 6, 3980.00),
+('北京', '成都', '2026-05-01 09:40:00', '2026-05-01 12:30:00', 'CA4108', '头等舱', 16, 1, 6280.00),
+('北京', '成都', '2026-05-01 13:10:00', '2026-05-01 16:00:00', 'CA4112', '经济舱', 200, 45, 1550.00),
+('北京', '成都', '2026-05-01 13:10:00', '2026-05-01 16:00:00', 'CA4112', '商务舱', 40, 9, 3780.00),
+-- 东航航班
+('北京', '成都', '2026-05-01 08:30:00', '2026-05-01 11:25:00', 'MU5448', '经济舱', 190, 34, 1490.00),
+('北京', '成都', '2026-05-01 08:30:00', '2026-05-01 11:25:00', 'MU5448', '商务舱', 36, 7, 3650.00),
+('北京', '成都', '2026-05-01 15:50:00', '2026-05-01 18:45:00', 'MU5452', '经济舱', 200, 52, 1420.00),
+('北京', '成都', '2026-05-01 15:50:00', '2026-05-01 18:45:00', 'MU5452', '商务舱', 36, 11, 3520.00),
+-- 南航航班
+('北京', '成都', '2026-05-01 06:50:00', '2026-05-01 09:40:00', 'CZ6181', '经济舱', 210, 18, 1650.00),
+('北京', '成都', '2026-05-01 06:50:00', '2026-05-01 09:40:00', 'CZ6181', '商务舱', 42, 4, 4020.00),
+('北京', '成都', '2026-05-01 11:00:00', '2026-05-01 13:50:00', 'CZ6185', '经济舱', 210, 67, 1520.00),
+('北京', '成都', '2026-05-01 11:00:00', '2026-05-01 13:50:00', 'CZ6185', '商务舱', 42, 14, 3720.00),
+('北京', '成都', '2026-05-01 18:30:00', '2026-05-01 21:25:00', 'CZ6189', '经济舱', 210, 89, 1380.00),
+-- 川航航班
+('北京', '成都', '2026-05-01 07:55:00', '2026-05-01 10:50:00', '3U8886', '经济舱', 180, 23, 1460.00),
+('北京', '成都', '2026-05-01 07:55:00', '2026-05-01 10:50:00', '3U8886', '商务舱', 32, 5, 3580.00),
+('北京', '成都', '2026-05-01 14:20:00', '2026-05-01 17:10:00', '3U8892', '经济舱', 180, 41, 1510.00),
+('北京', '成都', '2026-05-01 20:00:00', '2026-05-01 22:55:00', '3U8896', '经济舱', 180, 72, 1350.00);
+
+-- ==================== 会展门票:成都(2026-05-01) ====================
+INSERT INTO event_tickets (event_name, city, venue, start_time, end_time, ticket_type, total_seats, remaining_seats, price) VALUES
+('成都企业数字化转型峰会', '成都', '成都世纪城新国际会展中心', '2026-05-01 09:00:00', '2026-05-01 17:30:00', '标准参会证', 1200, 318, 680.00),
+('成都企业数字化转型峰会', '成都', '成都世纪城新国际会展中心', '2026-05-01 09:00:00', '2026-05-01 17:30:00', 'VIP商务席', 300, 42, 1680.00),
+('成都企业数字化转型峰会', '成都', '成都世纪城新国际会展中心', '2026-05-01 09:00:00', '2026-05-01 17:30:00', '圆桌嘉宾席', 80, 12, 2980.00),
+('西部智能制造大会', '成都', '中国西部国际博览城', '2026-05-01 09:30:00', '2026-05-01 17:00:00', '展区通票', 2000, 640, 480.00),
+('西部智能制造大会', '成都', '中国西部国际博览城', '2026-05-01 09:30:00', '2026-05-01 17:00:00', '论坛联票', 600, 156, 980.00),
+('西部智能制造大会', '成都', '中国西部国际博览城', '2026-05-01 09:30:00', '2026-05-01 17:00:00', '采购洽谈席', 150, 33, 1880.00),
+('企业采购与供应链论坛', '成都', '成都香格里拉大酒店会议中心', '2026-05-01 13:30:00', '2026-05-01 18:00:00', '普通席', 500, 121, 380.00),
+('企业采购与供应链论坛', '成都', '成都香格里拉大酒店会议中心', '2026-05-01 13:30:00', '2026-05-01 18:00:00', '商务席', 180, 39, 880.00),
+('企业采购与供应链论坛', '成都', '成都香格里拉大酒店会议中心', '2026-05-01 13:30:00', '2026-05-01 18:00:00', '闭门会席位', 60, 9, 1680.00);
+
+-- ==================== 商务用车:成都(2026-05-01) ====================
+INSERT INTO car_rentals (company, pickup_city, return_city, pickup_date, car_type, car_model, price_per_day, total_available, transmission, seats, deposit) VALUES
+-- 神州商务用车 · 成都
+('神州商务用车', '成都', '成都', '2026-05-01', '经济型', '丰田卡罗拉', 168.00, 15, '自动挡', 5, 3000),
+('神州商务用车', '成都', '成都', '2026-05-01', '经济型', '大众朗逸', 158.00, 18, '自动挡', 5, 3000),
+('神州商务用车', '成都', '成都', '2026-05-01', 'SUV', '哈弗H6', 298.00, 10, '自动挡', 5, 5000),
+('神州商务用车', '成都', '成都', '2026-05-01', 'SUV', '丰田RAV4', 338.00, 8, '自动挡', 5, 5500),
+('神州商务用车', '成都', '成都', '2026-05-01', '豪华型', '宝马3系', 520.00, 4, '自动挡', 5, 10000),
+('神州商务用车', '成都', '成都', '2026-05-01', 'MPV', '别克GL8', 458.00, 6, '自动挡', 7, 8000),
+-- 一嗨商务用车 · 成都
+('一嗨商务用车', '成都', '成都', '2026-05-01', '经济型', '长安逸动', 138.00, 20, '自动挡', 5, 2500),
+('一嗨商务用车', '成都', '成都', '2026-05-01', '经济型', '比亚迪秦Plus', 148.00, 16, '自动挡', 5, 2500),
+('一嗨商务用车', '成都', '成都', '2026-05-01', 'SUV', '长安CS75 Plus', 278.00, 12, '自动挡', 5, 5000),
+('一嗨商务用车', '成都', '成都', '2026-05-01', 'SUV', '坦克300', 398.00, 5, '自动挡', 5, 7000),
+('一嗨商务用车', '成都', '成都', '2026-05-01', '豪华型', '蔚来ES6', 518.00, 3, '自动挡', 5, 9000),
+('一嗨商务用车', '成都', '成都', '2026-05-01', 'MPV', '广汽传祺M8', 428.00, 7, '自动挡', 7, 7500),
+-- 携程商务用车 · 成都
+('携程商务用车', '成都', '成都', '2026-05-01', '经济型', '大众捷达', 128.00, 22, '手动挡', 5, 2000),
+('携程商务用车', '成都', '成都', '2026-05-01', '经济型', '吉利帝豪', 118.00, 25, '手动挡', 5, 2000),
+('携程商务用车', '成都', '成都', '2026-05-01', 'SUV', '理想L7', 388.00, 4, '自动挡', 5, 6500),
+('携程商务用车', '成都', '成都', '2026-05-01', '豪华型', '奔驰C级', 620.00, 2, '自动挡', 5, 12000);
+
+-- ==================== 差旅保险 ====================
+INSERT INTO insurances (insurance_type, name, company, coverage, price, duration_days, max_coverage, medical_coverage, baggage_coverage, flight_delay) VALUES
+-- 综合型
+('综合型', '平安差旅综合保障计划', '平安保险', '意外伤害50万+医疗费用10万+行李丢失5000+航班延误800元', 88.00, 7, 500000.00, 100000.00, 5000.00, TRUE),
+('综合型', '太平洋全方位差旅保障', '太平洋保险', '意外伤害30万+医疗费用5万+行李丢失3000+航班延误500元+紧急救援', 75.00, 7, 300000.00, 50000.00, 3000.00, TRUE),
+('综合型', '中国人寿尊享差旅计划', '中国人寿', '意外伤害100万+医疗费用20万+行李丢失1万+航班延误1000元+紧急救援+住院津贴', 168.00, 7, 1000000.00, 200000.00, 10000.00, TRUE),
+('综合型', '人保财险差旅无忧计划', '中国人保', '意外伤害20万+医疗费用3万+航班延误600元', 55.00, 7, 200000.00, 30000.00, 0.00, TRUE),
+-- 意外型
+('意外型', '平安差旅意外伤害保险', '平安保险', '意外伤害30万+意外医疗2万+紧急救援', 35.00, 7, 300000.00, 20000.00, 0.00, FALSE),
+('意外型', '太保差旅意外保障', '太平洋保险', '意外伤害20万+意外医疗1万', 25.00, 7, 200000.00, 10000.00, 0.00, FALSE),
+('意外型', '国寿出行意外险', '中国人寿', '意外伤害50万+意外医疗5万+骨折保障', 48.00, 7, 500000.00, 50000.00, 0.00, FALSE),
+-- 医疗型
+('医疗型', '平安差旅医疗保障', '平安保险', '医疗费用10万+住院津贴100元/天+紧急医疗转运+门诊直付', 55.00, 7, 100000.00, 100000.00, 0.00, FALSE),
+('医疗型', '太保海外医疗保障计划', '太平洋保险', '医疗费用50万+住院津贴200元/天+紧急救援+全球SOS服务+门诊直付', 128.00, 7, 500000.00, 500000.00, 0.00, FALSE),
+('医疗型', '人保差旅医疗保险', '中国人保', '医疗费用5万+住院津贴50元/天+紧急救援', 38.00, 7, 50000.00, 50000.00, 0.00, FALSE),
+-- 境外型
+('境外型', '安联全球差旅保障计划', '安联保险', '意外伤害50万+医疗费用100万+行李丢失8000+航班延误1000元+全球紧急救援+签证拒签补偿', 198.00, 14, 5000000.00, 1000000.00, 8000.00, TRUE),
+('境外型', '美亚万国差旅保障', '美亚保险', '意外伤害30万+医疗费用50万+行李丢失5000+航班延误800元+个人责任+紧急救援', 158.00, 14, 3000000.00, 500000.00, 5000.00, TRUE),
+('境外型', '平安境外差旅综合保障', '平安保险', '意外伤害100万+医疗费用200万+行李丢失1万+航班延误1500元+全球SOS+差旅证件丢失', 268.00, 14, 10000000.00, 2000000.00, 10000.00, TRUE);
+
+-- ==================== 天气数据:成都及周边(2026-05-01) ====================
+INSERT INTO weather_data (city, fx_date, sunrise, sunset, moonrise, moonset, moon_phase, moon_phase_icon, temp_max, temp_min, icon_day, text_day, icon_night, text_night, wind360_day, wind_dir_day, wind_scale_day, wind_speed_day, wind360_night, wind_dir_night, wind_scale_night, wind_speed_night, precip, uv_index, humidity, pressure, vis, cloud) VALUES
+-- 成都市区
+('成都', '2026-05-01', '06:18', '19:32', '22:45', '08:12', '盈凸月', 'wx045', 28, 18, '101', '多云', '150', '阴', 135, '东南风', '2级', 14, 180, '南风', '1级', 8, 1.2, 6, 65, 1012, 12, 55),
+-- 成都周边城市(都江堰/青城山/乐山/德阳/绵阳)
+('都江堰', '2026-05-01', '06:20', '19:34', '22:47', '08:14', '盈凸月', 'wx045', 25, 16, '305', '小雨', '305', '小雨', 120, '东南风', '2级', 15, 150, '南风', '1级', 10, 8.5, 4, 78, 1010, 8, 80),
+('乐山', '2026-05-01', '06:16', '19:30', '22:43', '08:10', '盈凸月', 'wx045', 29, 19, '100', '晴', '100', '晴', 160, '南风', '1级', 10, 190, '南风', '1级', 6, 0.0, 7, 55, 1013, 15, 30),
+('德阳', '2026-05-01', '06:17', '19:31', '22:44', '08:11', '盈凸月', 'wx045', 27, 17, '101', '多云', '101', '多云', 145, '东南风', '2级', 12, 170, '南风', '1级', 7, 0.3, 6, 60, 1012, 13, 45),
+('绵阳', '2026-05-01', '06:15', '19:30', '22:43', '08:09', '盈凸月', 'wx045', 26, 16, '102', '晴间多云', '150', '阴', 130, '东南风', '1级', 9, 160, '南风', '1级', 6, 0.0, 5, 58, 1014, 14, 40);

+ 519 - 0
static/index.html

@@ -0,0 +1,519 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>基于A2A的SmartBizTrip企业差旅助手</title>
+    <!-- CSS 控制颜色、布局和动画;不会发起业务请求。实际调用逻辑在页面末尾的 script。 -->
+    <style>
+        :root {
+            color-scheme: light;
+            --blue: #2563eb;
+            --blue-dark: #1d4ed8;
+            --text: #1e293b;
+            --muted: #64748b;
+            --line: #e5eaf1;
+            --canvas: #f5f7fb;
+        }
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+            background: var(--canvas); color: var(--text);
+            height: 100vh; height: 100dvh; display: flex; flex-direction: column;
+            -webkit-font-smoothing: antialiased;
+        }
+        button, input { font: inherit; }
+        button { cursor: pointer; transition: background .18s, border-color .18s, box-shadow .18s; }
+        button:focus-visible, summary:focus-visible { outline: 3px solid #93b4ff; outline-offset: 3px; }
+        .header {
+            min-height: 78px; padding: 16px 32px; background: #fff;
+            border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 14px;
+        }
+        .brand-mark {
+            width: 42px; height: 42px; flex: 0 0 42px; display: grid; place-items: center;
+            border-radius: 12px; background: var(--blue); color: #fff;
+            box-shadow: 0 4px 10px #2563eb20;
+        }
+        .header h1 { font-size: 21px; font-weight: 700; letter-spacing: -.6px; }
+        .header p { font-size: 12px; color: var(--muted); margin-top: 3px; }
+        .header-label { margin-left: auto; font-size: 12px; color: var(--muted); border-left: 1px solid var(--line); padding-left: 20px; }
+        .main { display: flex; flex: 1; min-height: 0; gap: 22px; padding: 24px 28px 16px; overflow: hidden; }
+        .chat-area {
+            flex: 2; min-width: 0; display: flex; flex-direction: column; background: #fff;
+            border: 1px solid var(--line); border-radius: 16px; overflow: hidden;
+            box-shadow: 0 3px 16px #1e293b04;
+        }
+        .chat-heading { padding: 20px 26px; border-bottom: 1px solid var(--line); }
+        .chat-heading h2 { font-size: 16px; font-weight: 600; }
+        .chat-heading p { font-size: 12px; color: var(--muted); margin-top: 6px; }
+        .chat-messages { flex: 1; min-height: 0; overflow-y: auto; padding: 28px; }
+        .chat-messages > .loading {
+            max-width: 500px; margin: clamp(28px, 10vh, 100px) auto 0; padding: 28px 20px;
+            line-height: 1.8; color: var(--muted); font-size: 14px;
+        }
+        .chat-messages > .loading::before {
+            content: "开启高效差旅"; display: block; color: var(--text); font-size: 27px;
+            font-weight: 650; letter-spacing: -.5px; margin-bottom: 12px;
+        }
+        .chat-messages > .loading::after {
+            content: "天气查询  ·  交通票务  ·  商务用车  ·  会务安排";
+            display: block; font-size: 12px; color: var(--muted); margin-top: 24px;
+            padding: 14px 8px; border-top: 1px solid var(--line);
+        }
+        .message { margin-bottom: 24px; display: flex; gap: 10px; align-items: flex-start; }
+        .message.user { justify-content: flex-end; }
+        .message.assistant { justify-content: flex-start; }
+        .message.assistant::before {
+            content: "S"; flex: 0 0 30px; height: 30px; border-radius: 9px;
+            display: grid; place-items: center; color: var(--blue); background: #edf3ff;
+            font-size: 14px; font-weight: 700; margin-top: 3px;
+        }
+        .message-bubble { max-width: 82%; padding: 13px 17px; border-radius: 12px; font-size: 14px; line-height: 1.85; white-space: pre-wrap; overflow-wrap: anywhere; }
+        .message.user .message-bubble { background: var(--blue); color: #fff; border-bottom-right-radius: 4px; }
+        .message.assistant .message-bubble { background: #f6f8fc; color: var(--text); border: 1px solid #edf0f5; border-top-left-radius: 4px; }
+        .chat-input-area { padding: 20px 24px; background: #fff; border-top: 1px solid var(--line); display: flex; gap: 12px; }
+        .chat-input-area input {
+            flex: 1; min-width: 0; padding: 14px 16px; border-radius: 10px;
+            border: 1px solid #dce3ed; background: #fafbfd; color: var(--text); font-size: 14px; outline: none;
+            transition: border-color .18s, box-shadow .18s;
+        }
+        input::placeholder { color: #78869a; }
+        .chat-input-area input:focus { border-color: var(--blue); background: #fff; box-shadow: 0 0 0 3px #2563eb12; }
+        .chat-input-area button { padding: 12px 24px; border-radius: 10px; border: 1px solid var(--blue); background: var(--blue); color: #fff; font-size: 14px; font-weight: 600; }
+        .chat-input-area button:hover { background: var(--blue-dark); }
+        .chat-input-area button:disabled { background: #a9bee8; border-color: #a9bee8; cursor: not-allowed; }
+        .sidebar { flex: 1; min-width: 290px; max-width: 360px; overflow-y: auto; padding: 2px 5px 10px 0; }
+        .section-title { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 650; color: #475569; margin: 2px 0 13px; }
+        .section-title::before { content: ""; width: 3px; height: 13px; border-radius: 3px; background: var(--blue); }
+        .agent-card { background: #fff; border-radius: 12px; padding: 16px 18px; margin-bottom: 10px; border: 1px solid var(--line); }
+        .agent-card h3 { font-size: 13px; font-weight: 650; color: var(--text); margin-bottom: 7px; overflow-wrap: anywhere; }
+        .agent-card p { font-size: 12px; line-height: 1.7; color: var(--muted); margin-bottom: 7px; }
+        .agent-card .skill { font-size: 12px; line-height: 1.6; color: #526176; padding: 2px 0; }
+        .agent-card .status { display: inline-block; font-size: 11px; color: #17714b; background: #edf8f1; border-radius: 5px; padding: 3px 7px; margin-top: 9px; }
+        .memory-section { margin-bottom: 10px; border: 1px solid var(--line); border-radius: 10px; background: #fff; padding: 0 14px; }
+        .memory-section summary { cursor: pointer; font-size: 12px; font-weight: 600; color: #475569; padding: 14px 0; }
+        .memory-section summary:hover { color: var(--blue); }
+        .memory-item { background: #f8fafc; border-radius: 7px; padding: 10px; margin-bottom: 9px; border: 1px solid #edf1f6; overflow-wrap: anywhere; }
+        .memory-item p { font-size: 12px; line-height: 1.75; color: #526176; margin-bottom: 4px; white-space: pre-wrap; }
+        .memory-item .timestamp { font-size: 10px; color: var(--muted); }
+        /* Preference rows are generated with inline styles; override their presentation only. */
+        #profileEditor .memory-item { padding: 8px; gap: 6px !important; }
+        #profileEditor input { min-width: 0; width: 0; padding: 7px 8px !important; border-radius: 5px !important; border-color: #dce3ed !important; background: #fff !important; color: var(--text) !important; outline: none; }
+        #profileEditor input:focus { border-color: var(--blue) !important; box-shadow: 0 0 0 2px #2563eb12; }
+        #profileEditor button { color: var(--blue) !important; border-color: #ccdafa !important; border-radius: 6px !important; min-height: 30px; }
+        #profileEditor button:hover { background: #eff5ff !important; }
+        #profileEditor .memory-item button { flex-shrink: 0; color: #64748b !important; border-color: #dce3ed !important; }
+        .clear-btn { width: 100%; padding: 10px; margin: 3px 0 24px; border-radius: 8px; border: 1px solid #dce3ed; background: #fff; color: #526176; font-size: 12px; }
+        .clear-btn:hover { background: #eff5ff; border-color: #b9cff8; color: var(--blue); }
+        .loading { color: var(--muted); font-size: 13px; text-align: center; padding: 12px; }
+        .typing-indicator { display: inline-flex; gap: 4px; align-items: center; }
+        .typing-indicator span { width: 5px; height: 5px; background: var(--blue); border-radius: 50%; animation: blink 1.4s infinite both; }
+        .typing-indicator span:nth-child(2) { animation-delay: .2s; }
+        .typing-indicator span:nth-child(3) { animation-delay: .4s; }
+        @keyframes blink { 0%, 80%, 100% { opacity: .2; } 40% { opacity: 1; } }
+        .cursor { display: inline-block; width: 2px; height: 1em; background: var(--blue); margin-left: 2px; animation: cursor-blink .8s infinite; vertical-align: text-bottom; }
+        @keyframes cursor-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
+        .footer { text-align: center; padding: 0 16px 12px; font-size: 11px; color: var(--muted); }
+        ::-webkit-scrollbar { width: 5px; height: 5px; }
+        ::-webkit-scrollbar-track { background: transparent; }
+        ::-webkit-scrollbar-thumb { background: #ccd5e2; border-radius: 4px; }
+        @media (max-width: 1000px) {
+            .main { padding: 16px; gap: 16px; }
+            .sidebar { min-width: 260px; max-width: 300px; }
+            .chat-messages { padding: 20px; }
+            .chat-input-area { padding: 16px; }
+        }
+        @media (max-width: 680px) {
+            body { height: auto; min-height: 100dvh; }
+            .header { padding: 14px 16px; }
+            .header-label { display: none; }
+            .main { flex-direction: column; overflow: visible; padding: 12px; gap: 20px; }
+            .chat-area { flex: none; height: 72dvh; min-height: 420px; }
+            .chat-heading { padding: 16px; }
+            .chat-messages { padding: 16px; }
+            .chat-messages > .loading { margin-top: 24px; padding: 20px 0; }
+            .chat-messages > .loading::before { font-size: 23px; }
+            .sidebar { flex: none; width: 100%; max-width: none; min-width: 0; overflow: visible; padding: 0; }
+            .message-bubble { max-width: 85%; }
+            .message.assistant .message-bubble { max-width: calc(100% - 40px); }
+            .chat-input-area { padding: 12px; gap: 8px; }
+            .chat-input-area button { padding: 12px 18px; }
+            .footer { padding-top: 8px; }
+        }
+        @media (prefers-reduced-motion: reduce) {
+            *, *::before, *::after { transition: none !important; animation: none !important; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <div class="brand-mark" aria-hidden="true"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="7" width="18" height="14" rx="3"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12a24 24 0 0 0 18 0M10 13h4"/></svg></div>
+        <div><h1>SmartBizTrip</h1><p>企业差旅智能助手</p></div>
+        <span class="header-label">企业差旅工作台</span>
+    </div>
+
+    <div class="main">
+        <!-- 页面结构 1:消息列表和输入区;发送按钮与 Enter 键都调用 sendMessage。 -->
+        <div class="chat-area">
+            <div class="chat-heading"><h2>差旅助手</h2><p>从出行查询到会务安排,让差旅准备更轻松</p></div>
+            <div class="chat-messages" id="chatMessages">
+                <div class="loading">欢迎体验智能对话!输入问题即可开始</div>
+            </div>
+            <div class="chat-input-area">
+                <input type="text" id="chatInput" aria-label="输入差旅问题" placeholder="描述您的差旅需求,按 Enter 发送…" autocomplete="off">
+                <button id="sendBtn" onclick="sendMessage()">发送</button>
+            </div>
+        </div>
+
+        <!-- 页面结构 2:代理卡片和记忆侧栏;数据由下方脚本调用 API 后填入。 -->
+        <div class="sidebar">
+            <div class="section-title">助手能力</div>
+            <div id="agentCards"></div>
+
+            <button class="clear-btn" onclick="clearMemory()">清空记忆</button>
+
+            <div class="section-title">对话记忆</div>
+            <div id="memoryPanel">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+    </div>
+
+    <div class="footer">Powered by 企业差旅中心 | 基于Agent2Agent的企业差旅助手系统 v7.5</div>
+
+    <script>
+        // 阅读顺序对应 api_server.py 顶部:本页负责主流程 1(发送)和 12(结束接收)。
+        // 后端第 10 步生成片段时,本页同时读取并刷新气泡;第 11 步保存记忆后才结束响应。
+        // 【页面初始化 1】脚本位于 HTML 元素之后,此时可以按 id 取得消息区、输入框和按钮。
+        const chatMessages = document.getElementById('chatMessages');
+        // 变量保存 DOM 元素的引用,后面修改 chatInput.value 或 sendBtn.disabled 会作用到页面上的同一个元素。
+        // getElementById 中的名称需要与上方 HTML 的 id 保持一致,不是访问后端变量。
+        const chatInput = document.getElementById('chatInput');
+        const sendBtn = document.getElementById('sendBtn');
+
+        // 【页面初始化 2】绑定键盘事件;这里只注册回调,按下 Enter 时才调用 sendMessage。
+        chatInput.addEventListener('keydown', (e) => {
+            // e 是浏览器提供的键盘事件;只有按 Enter 且没同时按 Shift,才进入发送分支。
+            // 注册监听器时不会执行此分支,它会在后续按键事件到来时触发。
+            if (e.key === 'Enter' && !e.shiftKey) {
+                // 阻止本次按键的默认行为,然后复用发送按钮也在调用的 sendMessage。
+                e.preventDefault();
+                sendMessage();
+            }
+        });
+
+        // 【界面辅助】创建消息气泡并返回 bubble,供 sendMessage 后续持续更新内容。
+        function addMessage(role, content) {
+            // 调用者:发送消息、收到响应或展示错误的代码。role 决定气泡样式,content 是初始显示文本。
+            // 返回值是新创建的 bubble 元素;sendMessage 保存这个引用,以后把新片段更新到同一个气泡。
+            const loading = chatMessages.querySelector('.loading');
+            // 只有找到欢迎/加载提示时才删除;已有聊天消息时查不到该元素,不影响继续创建气泡。
+            if (loading) loading.remove();
+
+            const div = document.createElement('div');
+            div.className = `message ${role}`;
+            const bubble = document.createElement('div');
+            bubble.className = 'message-bubble';
+            bubble.innerHTML = escapeHtml(content);
+            // 先把 bubble 放进外层 message div,再把外层 div 放进 chatMessages,元素才真正出现在页面上。
+            div.appendChild(bubble);
+            chatMessages.appendChild(div);
+            chatMessages.scrollTop = chatMessages.scrollHeight;
+            return bubble;
+        }
+
+        // 【界面辅助】发送后显示等待提示,收到响应或遇到错误时由 hideTyping 移除。
+        function showTyping() {
+            // 创建一条临时助手消息,固定 id 为 typingIndicator,方便 hideTyping 精确找到并移除。
+            // 这只是“等待响应”的界面提示,与模型是否正在推理或工具是否已经运行不是同一个状态。
+            const div = document.createElement('div');
+            div.className = 'message assistant';
+            div.id = 'typingIndicator';
+            div.innerHTML = `<div class="message-bubble"><div class="typing-indicator"><span></span><span></span><span></span></div> 思考中,请等待...</div>`;
+            chatMessages.appendChild(div);
+            chatMessages.scrollTop = chatMessages.scrollHeight;
+        }
+
+        // 【界面辅助】移除等待提示;没找到提示元素时不做处理。
+        function hideTyping() {
+            // 从 DOM 查找临时提示,存在才 remove;重复调用也不会因为元素已不存在而报错。
+            const indicator = document.getElementById('typingIndicator');
+            if (indicator) indicator.remove();
+        }
+
+        // 【界面辅助】先按文本写入临时元素,再读取转义后的 HTML,避免把回复文本当作标签解析。
+        function escapeHtml(text) {
+            // 先把 text 写到一个临时 div 的 textContent,浏览器按文本处理,再读取转义后的 innerHTML。
+            // 例如回复中的 <b> 会作为文本显示;真正使用 innerHTML 的地方仍可自行追加光标等界面标记。
+            const div = document.createElement('div');
+            div.textContent = text;
+            return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+        }
+
+        async function sendMessage() {
+            // 入口有两处:点击发送按钮,或按 Enter。它不是页面加载时自动执行的聊天请求。
+            // 局部 fullText 保存这次助手回复,reader/decoder/buffer 只服务于本次响应流。
+            // await 会等待异步结果再执行下一行;期间浏览器仍能处理其他事件,并不是暂停整个页面。
+            // 【主流程 1.1】读取输入,空文本直接结束;随后显示用户消息并禁用发送按钮。
+            const text = chatInput.value.trim();
+            // trim 后空串会提前结束本次函数,所以不会清空输入框、显示等待提示或发送请求。
+            if (!text || sendBtn.disabled) return;
+
+            // 把用户刚提交的文本留在局部 text 中,再清空输入框;后续 POST 仍发送原 text。
+            // 上面的禁用状态检查同时阻止按 Enter 重复提交尚未结束的请求。
+            chatInput.value = '';
+            sendBtn.disabled = true;
+            addMessage('user', text);
+            showTyping();
+
+            let fullText = '';
+            try {
+                // 【主流程 1.2 → 2】把消息作为 JSON POST 到 API。fetch 返回响应后再继续读取响应体。
+                // 这里使用 fetch 的流读取能力消费 SSE 格式文本,不是创建 EventSource。
+                const response = await fetch('/api/chat/stream', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    // 前端对象转换为 JSON 请求体,字段 message 对应后端 ChatRequest.message。
+                    // Content-Type 指定请求体格式;它与后端响应的 text/event-stream 是两个方向的类型。
+                    body: JSON.stringify({ message: text })
+                });
+                if (!response.ok) throw new Error('聊天请求失败');
+
+                // 收到 HTTP 响应对象后先撤下等待提示,再创建空助手气泡;此时未必已经拿到回复正文片段。
+                hideTyping();
+                const bubble = addMessage('assistant', '');
+
+                // 【返回过程 10 → 网页】取得字节流读取器;TextDecoder 把 UTF-8 字节转换为文字。
+                const reader = response.body.getReader();
+                const decoder = new TextDecoder();
+                // buffer 是未处理完的一行 SSE 文本,fullText 是已解析出的助手回复,二者不能混用。
+                // 例如本次网络块只收到 data: {"chu,先暂存,等下个网络块补齐再解析。
+                let buffer = '';
+
+                // 【接收内部步骤 1】循环等待新的网络字节块;字节块不保证刚好是一个完整 SSE 事件。
+                while (true) {
+                    // 【读流细节 1】解构本次读取结果:done 表示流结束,value 是本次收到的字节数组。
+                    // 一次读取可能含多个事件,也可能只有半个事件;不能直接把 value 当成 JSON 字符串。
+                    const { done, value } = await reader.read();
+                    // 【读流细节 2】break 只退出当前 while,随后继续执行去光标和 finally 等代码;不是退出整个 sendMessage。
+                    if (done) break;
+
+                    // 【接收内部步骤 2】保留未读完的字节和文本:decoder 处理跨块的中文字符,buffer 保留未结束的一行。
+                    // 本后端把每个事件写为一行 data: JSON,再跟一个空行,因此这里按行解析。
+                    buffer += decoder.decode(value, { stream: true });
+                    // 【读流细节 3】按换行拆出已接收文本。最后一项可能不完整,下一行将它从 lines 取走并继续缓存。
+                    // 如果刚好以换行结尾,最后一项就是空串,buffer 也会恢复为空。
+                    const lines = buffer.split('\n');
+                    // pop 会从数组移除最后一项并返回它;因此下方 for 只处理前面完整的行。
+                    // || 在取不到内容时使用空串,方便下一次用 += 接续新文本。
+                    buffer = lines.pop() || '';
+
+                    // 【读流细节 4】逐行解析一批网络数据,内层 for 结束后回到外层 while 继续读取下一块。
+                    for (const line of lines) {
+                        const trimmed = line.trim();
+                        // continue 只跳过当前这一行,继续检查下一行;空行和非 data 行都不会成为聊天内容。
+                        if (!trimmed.startsWith('data: ')) continue;
+                        // 从第 6 个字符开始保留,去掉前缀 data: 加一个空格;得到 JSON 文本或 [DONE] 标记。
+                        const data = trimmed.slice(6);
+                        // 【主流程 12.2】结束标记不显示在气泡中;当前实现跳过它,直到 reader.read() 的 done 为 true 才退出。
+                        if (data === '[DONE]') continue;
+
+                        try {
+                            // 【接收内部步骤 3】解析后端 {chunk: ...},累计成全文并重新绘制气泡;不是每次创建新消息。
+                            const parsed = JSON.parse(data);
+                            // 【读流细节 5】chunk 非空才更新显示;空片段被忽略,继续等后续内容。
+                            // 这里收到的是 API 封装后的对象,不再是 A2A Task 或数据库结果。
+                            if (parsed.chunk) {
+                                // 把新片段追加到已有正文末尾;例如“北京”加“明天晴”变为“北京明天晴”,不插入额外换行。
+                                fullText += parsed.chunk;
+                                // 每次用累计全文重绘同一个 bubble,再追加闪烁光标;显示效果像逐步打字。
+                                bubble.innerHTML = escapeHtml(fullText) + '<span class="cursor"></span>';
+                                chatMessages.scrollTop = chatMessages.scrollHeight;
+                            }
+                        } catch (e) { /* 当前 data 行不是合法 JSON 时跳过它,后续行仍继续解析。 */ }
+                    }
+                }
+
+                // 【主流程 12.3】响应流结束,去掉光标;若没有任何片段则显示空回复提示。
+                bubble.innerHTML = escapeHtml(fullText);
+                // 流已结束但没有解析出任何 chunk 时显示空回复提示;不把 [DONE] 本身算成正文。
+                if (!fullText) {
+                    bubble.innerHTML = '<span style="color:#888;">(未收到回复)</span>';
+                }
+
+            } catch (err) {
+                hideTyping();
+                // 外层 catch 处理 HTTP 失败状态、网络异常和读流异常。
+                addMessage('assistant', '请求失败,请检查网络连接。');
+            } finally {
+                // 【主流程 12.4】无论成功还是异常,都恢复按钮和输入焦点。
+                sendBtn.disabled = false;
+                chatInput.focus();
+            }
+
+            // 【主流程 12.5】本轮接收结束后刷新记忆侧栏,显示后端保存的对话。
+            loadMemory();
+        }
+
+        // 【页面初始化 3a】请求代理卡片后渲染侧栏;与 loadMemory 各自异步完成。
+        async function loadAgents() {
+            // 请求 GET /api/agents,response.json() 解析响应体,data.data 才是卡片列表。
+            // map 把每张卡片转换为 HTML 字符串,join('') 合成一段再替换 agentCards 内容。
+            try {
+                const response = await fetch('/api/agents');
+                const data = await response.json();
+                const container = document.getElementById('agentCards');
+                container.innerHTML = data.data.map(agent => `
+                    <div class="agent-card">
+                        <h3>${escapeHtml(agent.name)}</h3>
+                        <p>${escapeHtml(agent.description)}</p>
+                        ${agent.skills.map(s => `<div class="skill">• ${escapeHtml(s)}</div>`).join('')}
+                        <div class="status">● ${escapeHtml(agent.status)}</div>
+                    </div>
+                `).join('');
+            } catch (err) {
+                console.error('Failed to load agents:', err);
+            }
+        }
+
+        // 【页面初始化 3b / 主流程 12.5】请求记忆摘要并重建侧栏,包含历史、偏好、当前任务和查询记录。
+        async function loadMemory() {
+            // 请求 GET /api/memory,m 是后端返回的记忆摘要,不是直接读取数据库。
+            // 模板字符串一次重建短期对话、偏好、当前任务和查询历史四块内容;空列表/空字典显示“无”。
+            // 这会替换整个 memoryPanel,因此表单行也由返回数据重新创建。
+            try {
+                const response = await fetch('/api/memory');
+                const data = await response.json();
+                // 解包项目约定的外层响应 {status, data};后续 m.user_profile 等字段与后端 get_memory_state 一一对应。
+                const m = data.data;
+                const container = document.getElementById('memoryPanel');
+                container.innerHTML = `
+                    <details class="memory-section" open>
+                        <summary>短期对话</summary>
+                        ${m.short_term_messages.map(msg => `
+                            <div class="memory-item">
+                                <p><strong>${escapeHtml(msg.role)}:</strong> ${escapeHtml(msg.content)}</p>
+                                <div class="timestamp">${escapeHtml(msg.timestamp)}</div>
+                            </div>
+                        `).join('') || '<div class="memory-item"><p>无</p></div>'}
+                    </details>
+                    <details class="memory-section" open>
+                        <summary>用户偏好</summary>
+                        <div id="profileEditor">
+                            ${Object.keys(m.user_profile).length > 0
+                                ? Object.entries(m.user_profile).map(([k, v]) => `
+                                    <div class="memory-item" style="display:flex;gap:8px;align-items:center;">
+                                        <input type="text" class="profile-key" value="${escapeHtml(k)}" placeholder="键名" style="flex:1;padding:4px 8px;border-radius:4px;border:1px solid #0f3460;background:#1a1a2e;color:#e0e0e0;font-size:12px;">
+                                        <input type="text" class="profile-value" value="${escapeHtml(v)}" placeholder="值" style="flex:1;padding:4px 8px;border-radius:4px;border:1px solid #0f3460;background:#1a1a2e;color:#e0e0e0;font-size:12px;">
+                                        <button onclick="removeProfileItem(this)" style="padding:2px 8px;border-radius:4px;border:1px solid #e94560;background:transparent;color:#e94560;font-size:12px;cursor:pointer;">&times;</button>
+                                    </div>
+                                `).join('')
+                                : ''}
+                            <div style="display:flex;gap:8px;margin:8px 0;">
+                                <button onclick="addProfileRow()" style="flex:1;padding:6px;border-radius:4px;border:1px dashed #0f3460;background:transparent;color:#888;font-size:12px;cursor:pointer;">+ 添加偏好</button>
+                                <button onclick="saveProfile()" style="flex:1;padding:6px;border-radius:4px;border:1px solid #e94560;background:transparent;color:#e94560;font-size:12px;cursor:pointer;">保存全部</button>
+                            </div>
+                        </div>
+                    </details>
+                    <details class="memory-section">
+                        <summary>当前任务</summary>
+                        ${Object.keys(m.current_task).length > 0
+                            ? Object.entries(m.current_task).map(([k, v]) => `
+                                <div class="memory-item"><p><strong>${escapeHtml(k)}:</strong> ${escapeHtml(v)}</p></div>
+                            `).join('')
+                            : '<div class="memory-item"><p>无</p></div>'}
+                    </details>
+                    <details class="memory-section">
+                        <summary>查询历史</summary>
+                        ${m.entity_history.map(e => `
+                            <div class="memory-item">
+                                <p><strong>${escapeHtml(e.type)}:</strong> ${escapeHtml(e.query)}</p>
+                                <div class="timestamp">${escapeHtml(e.timestamp)}</div>
+                            </div>
+                        `).join('') || '<div class="memory-item"><p>无</p></div>'}
+                    </details>
+                `;
+            } catch (err) {
+                console.error('Failed to load memory:', err);
+            }
+        }
+
+        // 【清空分支 1 → 2 → 3】用户确认 → 请求清空后端记忆 → 更新聊天区并重新加载侧栏。
+        async function clearMemory() {
+            // 只有浏览器 confirm 返回 true 才发送清理请求;取消会 return,不改变当前内容。
+            // await fetch 后先替换聊天区提示,再调用 loadMemory 刷新侧栏,这两个显示区域分别更新。
+            if (!confirm('确定要清空所有记忆吗?')) return;
+            try {
+                await fetch('/api/memory/clear', { method: 'POST' });
+                chatMessages.innerHTML = '<div class="loading">记忆已清空,欢迎体验智能对话!</div>';
+                loadMemory();
+            } catch (err) {
+                console.error('Failed to clear memory:', err);
+            }
+        }
+
+        // ========== 用户偏好编辑 ==========
+
+        // 【偏好编辑 1a】仅在网页增加一行,暂不写后端;点击“保存全部”才提交。
+        function addProfileRow() {
+            // 仅向网页中增加一组键/值输入框;输入框还不是后端偏好。
+            // insertBefore 把新行放在最后的操作按钮区域之前,随后 focus 让用户直接输入键名。
+            const editor = document.getElementById('profileEditor');
+            const row = document.createElement('div');
+            row.className = 'memory-item';
+            row.style.cssText = 'display:flex;gap:8px;align-items:center;';
+            row.innerHTML = `
+                <input type="text" class="profile-key" placeholder="键名" style="flex:1;padding:4px 8px;border-radius:4px;border:1px solid #0f3460;background:#1a1a2e;color:#e0e0e0;font-size:12px;">
+                <input type="text" class="profile-value" placeholder="值" style="flex:1;padding:4px 8px;border-radius:4px;border:1px solid #0f3460;background:#1a1a2e;color:#e0e0e0;font-size:12px;">
+                <button onclick="removeProfileItem(this)" style="padding:2px 8px;border-radius:4px;border:1px solid #e94560;background:transparent;color:#e94560;font-size:12px;cursor:pointer;">&times;</button>
+            `;
+            editor.insertBefore(row, editor.lastElementChild);
+            row.querySelector('.profile-key').focus();
+        }
+
+        // 【偏好编辑 1b】仅移除网页这一行,后续完整保存时才同步删除后端对应偏好。
+        function removeProfileItem(btn) {
+            // btn 来自 onclick 中的 this,指被点击的删除按钮;parentElement 是它所在的偏好整行。
+            // 删除 DOM 行后,saveProfile 再收集表单时就不会包含它。
+            btn.parentElement.remove();
+        }
+
+        // 【偏好编辑 2】收集非空键和值组成 profile;相同键后出现的值覆盖先出现的值。
+        async function saveProfile() {
+            // 先收集界面上的键和值,再整体提交到后端;删除行、添加行都在这一步一起保存。
+            // keys 与 values 按 DOM 顺序对应,使用相同下标 i 取出同一行的键和值。
+            const editor = document.getElementById('profileEditor');
+            const keys = editor.querySelectorAll('.profile-key');
+            const values = editor.querySelectorAll('.profile-value');
+            const profile = {};
+            for (let i = 0; i < keys.length; i++) {
+                const k = keys[i].value.trim();
+                const v = values[i].value.trim();
+                // 键和值都非空才加入提交对象;空行被跳过,重名键由后出现的值覆盖。
+                if (k && v) profile[k] = v;
+            }
+            try {
+                // 【偏好编辑 3】replace: true 表示整体替换,确保网页已删除的偏好不会留在后端。
+                const response = await fetch('/api/memory/profile', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify({ profile, replace: true })
+                });
+                // 这里明确检查 HTTP 状态。非成功状态主动 throw,进入下方 catch 显示保存失败提示。
+                if (!response.ok) throw new Error('保存偏好失败,请重试');
+                // 刷新侧边栏显示
+                loadMemory();
+            } catch (err) {
+                console.error('Failed to save profile:', err);
+                alert('保存偏好失败,请重试。');
+            }
+        }
+
+        // 【页面初始化 3】发起两个独立请求;函数没有相互 await,因此完成顺序不固定。
+        // 脚本加载到末尾才真正发起初始化请求。前面 function 声明定义行为,但不会自动执行函数体。
+        // 这里两次调用都未 await,分别发起后继续运行;各自结果到达时再更新对应侧栏。
+        loadAgents();
+        loadMemory();
+    </script>
+</body>
+</html>

+ 57 - 0
test.py

@@ -0,0 +1,57 @@
+from config import Config
+
+conf = Config()
+import requests
+
+url = conf.weather_base_url
+
+headers = {"X-QW-Api-Key": conf.weather_api_key, "Accept-Encoding": "gzip, deflate, br"}
+
+chengdu = [
+    101270101, 101270102, 101270103, 101270104, 101270105,
+    101270106, 101270107, 101270108, 101270109, 101270110,
+    101270111, 101270112, 101270113, 101270114, 101270115,
+    101270116, 101270117, 101270118, 101270119, 101270120,
+    101270121
+]
+
+beijing = [
+    101010100,
+    101010200,
+    101010300,
+    101010400,
+    101010500,
+    101010600,
+    101010700,
+    101010800,
+    101010900,
+    101011000,
+    101011100,
+    101011200,
+    101011300,
+    101011400,
+    101011500,
+    101011600,
+    101011700
+]
+
+
+params = {
+    'location': '101270101'
+}
+
+def main():
+    """手动验证天气 API;导入本文件不会发送请求。"""
+    if not conf.weather_api_key or conf.weather_api_key.startswith("your_"):
+        raise RuntimeError("请先在 .env 中填写自己的 QWEATHER_API_KEY。")
+    if conf.weather_api_host == "your_qweather_host":
+        raise RuntimeError("请先在 .env 中填写自己的 QWEATHER_API_HOST。")
+    response = requests.get(url, headers=headers, params=params, timeout=15)
+    response.raise_for_status()
+    print(f"状态码: {response.status_code}")
+    print("响应内容:")
+    print(response.text)
+
+
+if __name__ == "__main__":
+    main()

+ 0 - 0
tests/__init__.py


+ 31 - 0
tests/run_offline.py

@@ -0,0 +1,31 @@
+"""运行离线单元测试。不会调用真实 API、访问数据库或启动服务。"""
+import os
+import socket
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+
+def main():
+    root = Path(__file__).resolve().parents[1]
+    sys.path.insert(0, str(root))
+    # 仅测试进程使用占位凭据,实际请求由网络拦截阻止。
+    os.environ["DEEPSEEK_API_KEY"] = "offline-test-placeholder"
+    os.environ["DASHSCOPE_API_KEY"] = "offline-test-placeholder"
+    os.environ["QWEATHER_API_KEY"] = "offline-test-placeholder"
+    os.environ["RUN_ONLINE_TESTS"] = "0"
+
+    def no_network(*args, **kwargs):
+        raise RuntimeError("离线测试禁止网络连接,请使用模拟对象。")
+
+    with patch.object(socket.socket, "connect", no_network), \
+         patch.object(socket.socket, "connect_ex", no_network), \
+         patch.object(socket, "create_connection", no_network):
+        suite = unittest.defaultTestLoader.discover(str(root / "tests"), top_level_dir=str(root))
+        result = unittest.TextTestRunner(verbosity=2).run(suite)
+    return 0 if result.wasSuccessful() else 1
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 52 - 0
tests/test_chat_reply.py

@@ -0,0 +1,52 @@
+import json
+import unittest
+from unittest.mock import Mock
+
+from langchain_core.messages import AIMessage
+from langchain_core.runnables import RunnableLambda
+
+from chat_service import ChatService
+
+
+class ChatReplyTests(unittest.IsolatedAsyncioTestCase):
+    def make_service(self, follow_up):
+        service = ChatService.__new__(ChatService)
+        payload = {"intents": ["out_of_scope"], "user_queries": {}}
+        payload.update(follow_up)
+        service.llm = RunnableLambda(
+            lambda _: AIMessage(content=json.dumps(payload))
+        )
+        service.memory = Mock(current_task={})
+        service.memory.get_short_term_text.return_value = ""
+        service.memory.get_profile_text.return_value = ""
+        service.messages = []
+        service.conversation_history = ""
+        return service
+
+    async def test_empty_out_of_scope_reply_has_fallback(self):
+        for payload in ({}, {"follow_up_message": ""},
+                        {"follow_up_message": "  "}, {"follow_up_message": None}):
+            for streaming in (False, True):
+                with self.subTest(payload=payload, streaming=streaming):
+                    service = self.make_service(payload)
+                    if streaming:
+                        reply = "".join([part async for part in service.chat_stream("你好")])
+                    else:
+                        reply = await service.chat("你好")
+                    self.assertIn("企业差旅助手", reply)
+                    self.assertEqual(service.messages[-1],
+                                     {"role": "assistant", "content": reply})
+                    service.memory.add_message.assert_called_with("assistant", reply)
+
+    async def test_existing_reply_is_preserved(self):
+        for streaming in (False, True):
+            service = self.make_service({"follow_up_message": "你刚才说要去北京。"})
+            if streaming:
+                reply = "".join([part async for part in service.chat_stream("我要去哪里?")])
+            else:
+                reply = await service.chat("我要去哪里?")
+            self.assertEqual(reply, "你刚才说要去北京。")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 37 - 0
tests/test_city_weather_date.py

@@ -0,0 +1,37 @@
+"""接待建议应将用户指定的日期传给天气助手。"""
+import unittest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, Mock
+
+from langchain_core.messages import AIMessage
+from langchain_core.prompts import ChatPromptTemplate
+from langchain_core.runnables import RunnableLambda
+
+from chat_service import ChatService
+from memory import ConversationMemory
+
+
+class CityWeatherDateTests(unittest.IsolatedAsyncioTestCase):
+    async def test_preserves_explicit_and_relative_dates(self):
+        for question in ["2026年9月26日在北京接待客户", "后天在北京接待客户"]:
+            service = ChatService.__new__(ChatService)
+            service.memory = ConversationMemory()
+            service._city_extract_prompt = ChatPromptTemplate.from_template("{query}")
+            service.llm = RunnableLambda(lambda _: AIMessage(content="北京"))
+            agent = Mock()
+            agent.send_task_async = AsyncMock(return_value=SimpleNamespace(
+                status=SimpleNamespace(state="completed"),
+                artifacts=[{"parts": [{"text": "北京天气结果"}]}],
+            ))
+            service.agent_network = Mock()
+            service.agent_network.get_agent.return_value = agent
+
+            result = await service._fetch_weather_for_city_service(question)
+
+            self.assertEqual(result, "北京天气结果")
+            sent_task = agent.send_task_async.await_args.args[0]
+            self.assertIn(question, sent_task.message["content"]["text"])
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 289 - 0
tests/test_mcp_servers.py

@@ -0,0 +1,289 @@
+"""
+MCP 服务器测试模块
+
+测试内容:
+1. 格式编码工具(纯函数测试,零依赖)
+2. MCP 服务器集成测试(需要数据库连接)
+
+运行方式:
+    cd SmartBizTrip
+    python -m tests.test_mcp_servers
+"""
+
+import unittest
+import json
+import sys
+import os
+from datetime import date, datetime, timedelta
+from decimal import Decimal
+
+# 确保能导入 SmartBizTrip 模块
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
+
+
+# ==================== 1. 格式编码工具测试(纯函数,零依赖) ====================
+class TestFormatEncoder(unittest.TestCase):
+    """测试 default_encoder 函数和 DateEncoder 类"""
+
+    def setUp(self):
+        from utils.format import default_encoder, DateEncoder
+        self.default_encoder_func = default_encoder
+        self.date_encoder_cls = DateEncoder
+
+    def test_encode_datetime(self):
+        """测试 datetime 编码"""
+        dt = datetime(2025, 7, 30, 14, 30, 0)
+        result = self.default_encoder_func(dt)
+        self.assertEqual(result, '2025-07-30 14:30:00')
+
+    def test_encode_date(self):
+        """测试 date 编码"""
+        d = date(2025, 7, 30)
+        result = self.default_encoder_func(d)
+        self.assertEqual(result, '2025-07-30')
+
+    def test_encode_timedelta(self):
+        """测试 timedelta 编码"""
+        td = timedelta(hours=5, minutes=30)
+        result = self.default_encoder_func(td)
+        self.assertEqual(result, '5:30:00')
+
+    def test_encode_decimal(self):
+        """测试 Decimal 编码"""
+        dec = Decimal('123.45')
+        result = self.default_encoder_func(dec)
+        self.assertIsInstance(result, float)
+        self.assertAlmostEqual(result, 123.45)
+
+    def test_encode_unknown(self):
+        """测试未知类型原样返回"""
+        obj = "hello"
+        result = self.default_encoder_func(obj)
+        self.assertEqual(result, "hello")
+
+    def test_date_encoder_json_dumps(self):
+        """测试 DateEncoder 类与 json.dumps 配合使用"""
+        data = {
+            "date": date(2025, 7, 30),
+            "datetime": datetime(2025, 7, 30, 14, 30, 0),
+            "value": Decimal('99.99')
+        }
+        result = json.dumps(data, cls=self.date_encoder_cls)
+        parsed = json.loads(result)
+        self.assertEqual(parsed["date"], "2025-07-30")
+        self.assertEqual(parsed["datetime"], "2025-07-30 14:30:00")
+        self.assertEqual(parsed["value"], 99.99)
+
+
+# ==================== 2. MCP 服务器集成测试(需要数据库) ====================
+
+@unittest.skipUnless(os.getenv("RUN_ONLINE_TESTS") == "1", "需要数据库;设置 RUN_ONLINE_TESTS=1 后运行")
+class TestWeatherMCPIntegration(unittest.TestCase):
+    """集成测试:天气 MCP 服务器"""
+
+    @classmethod
+    def setUpClass(cls):
+        from mcp_server.mcp_weather_server import WeatherService
+        cls.service = WeatherService()
+
+    def test_query_weather_single_day(self):
+        """使用 insert_may01_data.sql 中的成都天气样例。"""
+        result = self.service.query_weather("成都", "2026-05-01", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        first = parsed["data"][0]
+        self.assertEqual(first["city"], "成都")
+        self.assertEqual(first["fx_date"], "2026-05-01")
+        self.assertEqual(first["temp_max"], 28)
+        self.assertEqual(first["temp_min"], 18)
+
+    def test_query_weather_date_range(self):
+        """范围查询只返回已有日期的数据,不会补造缺失日期。"""
+        result = self.service.query_weather("成都", "2026-04-29", "2026-05-03")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertEqual([day["fx_date"] for day in parsed["data"]], ["2026-05-01"])
+        for day in parsed["data"]:
+            self.assertEqual(day["city"], "成都")
+            self.assertGreaterEqual(day["fx_date"], "2026-04-29")
+            self.assertLessEqual(day["fx_date"], "2026-05-03")
+        # 同一城市,排除样例日期后应返回无数据。
+        outside = self.service.query_weather("成都", "2026-05-02", "2026-05-03")
+        self.assertEqual(json.loads(outside)["status"], "no_data")
+
+    def test_query_weather_chengdu(self):
+        """测试成都天气查询"""
+        result = self.service.query_weather("成都", "2026-05-01", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        self.assertEqual(parsed["data"][0]["city"], "成都")
+
+    def test_query_weather_no_data(self):
+        """测试无数据情况"""
+        result = self.service.query_weather("火星", "2026-05-01", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "no_data")
+
+    # def test_weather_mcp_server_config(self):
+    #     """验证天气 MCP 服务器配置"""
+    #     from mcp_server.mcp_weather_server import create_weather_mcp_server
+    #     server = create_weather_mcp_server()
+    #     self.assertEqual(server.name, "WeatherTools")
+
+
+@unittest.skipUnless(os.getenv("RUN_ONLINE_TESTS") == "1", "需要数据库;设置 RUN_ONLINE_TESTS=1 后运行")
+class TestTicketMCPIntegration(unittest.TestCase):
+    """集成测试:票务 MCP 服务器"""
+
+    @classmethod
+    def setUpClass(cls):
+        from mcp_server.mcp_ticket_server import TicketService
+        cls.service = TicketService()
+
+    def test_query_train(self):
+        """测试高铁票查询——北京到成都 2026-05-01"""
+        result = self.service.query_train("北京", "成都", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        first = parsed["data"][0]
+        self.assertEqual(first["departure_city"], "北京")
+        self.assertEqual(first["arrival_city"], "成都")
+        self.assertIn("train_number", first)
+
+    def test_query_train_with_seat(self):
+        """测试带座位类型的高铁票查询——北京到成都 二等座"""
+        result = self.service.query_train("北京", "成都", "2026-05-01", "二等座")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        for item in parsed["data"]:
+            self.assertEqual(item["seat_type"], "二等座")
+
+    def test_query_train_no_data(self):
+        """测试无数据情况——北京到上海无数据"""
+        result = self.service.query_train("北京", "上海", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "no_data")
+
+    def test_query_flight(self):
+        """测试航班查询——北京到成都 2026-05-01"""
+        result = self.service.query_flight("北京", "成都", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        first = parsed["data"][0]
+        self.assertEqual(first["departure_city"], "北京")
+        self.assertEqual(first["arrival_city"], "成都")
+
+    def test_query_flight_with_cabin(self):
+        """测试带舱位类型的航班查询——头等舱"""
+        result = self.service.query_flight("北京", "成都", "2026-05-01", "头等舱")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        for item in parsed["data"]:
+            self.assertEqual(item["cabin_type"], "头等舱")
+
+    def test_query_event(self):
+        """测试会展门票查询——成都企业数字化转型峰会 成都 2026-05-01"""
+        result = self.service.query_event("成都", "成都企业数字化转型峰会", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        first = parsed["data"][0]
+        self.assertEqual(first["event_name"], "成都企业数字化转型峰会")
+        self.assertEqual(first["city"], "成都")
+        self.assertEqual(first["venue"], "成都世纪城新国际会展中心")
+
+    def test_query_event_dengzaiqi(self):
+        """测试会展门票查询——西部智能制造大会 成都"""
+        result = self.service.query_event("成都", "西部智能制造大会", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        self.assertEqual(parsed["data"][0]["venue"], "中国西部国际博览城")
+
+    def test_query_event_no_data(self):
+        """测试无数据情况——不存在的活动名称"""
+        result = self.service.query_event("成都", "不存在的企业峰会", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "no_data")
+
+    # def test_ticket_mcp_server_config(self):
+    #     """验证票务 MCP 服务器配置"""
+    #     from mcp_server.mcp_ticket_server import create_ticket_mcp_server
+    #     server = create_ticket_mcp_server()
+    #     self.assertEqual(server.name, "TicketTools")
+
+
+@unittest.skipUnless(os.getenv("RUN_ONLINE_TESTS") == "1", "需要数据库;设置 RUN_ONLINE_TESTS=1 后运行")
+class TestTripMCPIntegration(unittest.TestCase):
+    """集成测试:差旅 MCP 服务器"""
+
+    @classmethod
+    def setUpClass(cls):
+        from mcp_server.mcp_trip_server import TripService
+        cls.service = TripService()
+
+    def test_query_car_rental(self):
+        """测试商务用车查询——成都 2026-05-01"""
+        result = self.service.query_car_rental("成都", "成都", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        first = parsed["data"][0]
+        self.assertEqual(first["pickup_city"], "成都")
+        self.assertEqual(first["return_city"], "成都")
+
+    def test_query_car_rental_with_type(self):
+        """测试带车型的商务用车查询——SUV"""
+        result = self.service.query_car_rental("成都", "成都", "2026-05-01", "SUV")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        for item in parsed["data"]:
+            self.assertEqual(item["car_type"], "SUV")
+
+    def test_query_car_rental_no_data(self):
+        """测试无数据情况——北京无商务用车数据"""
+        result = self.service.query_car_rental("北京", "北京", "2026-05-01")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "no_data")
+
+    def test_query_insurance(self):
+        """测试保险查询(全部)"""
+        result = self.service.query_insurance()
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+
+    def test_query_insurance_with_type(self):
+        """测试带类型的保险查询——综合型"""
+        result = self.service.query_insurance("综合型")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        for item in parsed["data"]:
+            self.assertEqual(item["insurance_type"], "综合型")
+
+    def test_query_insurance_jingwai(self):
+        """测试境外型保险查询"""
+        result = self.service.query_insurance("境外型")
+        parsed = json.loads(result)
+        self.assertEqual(parsed["status"], "success")
+        self.assertGreater(len(parsed["data"]), 0)
+        for item in parsed["data"]:
+            self.assertEqual(item["insurance_type"], "境外型")
+
+    # def test_trip_mcp_server_config(self):
+    #     """验证差旅 MCP 服务器配置"""
+    #     from mcp_server.mcp_trip_server import create_trip_mcp_server
+    #     server = create_trip_mcp_server()
+    #     self.assertEqual(server.name, "TripTools")
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)

+ 189 - 0
tests/test_review_fixes.py

@@ -0,0 +1,189 @@
+"""业务流程回归测试:使用模拟模型、代理和数据库,不访问外部服务。"""
+
+import asyncio
+import json
+import sys
+import unittest
+from pathlib import Path
+from unittest.mock import AsyncMock, Mock
+
+from langchain_core.messages import AIMessage
+from langchain_core.runnables import RunnableLambda
+
+from chat_service import ChatService
+from memory import ConversationMemory
+
+
+class FlowTests(unittest.IsolatedAsyncioTestCase):
+    def service(self):
+        service = ChatService.__new__(ChatService)
+        service.memory = ConversationMemory()
+        service.messages = []
+        service.conversation_history = ""
+        service.llm = RunnableLambda(lambda _: AIMessage(content="汇总结果"))
+        return service
+
+    async def test_city_stream_supplies_weather_even_when_unavailable(self):
+        for weather in ("成都小雨", ""):
+            service = self.service()
+            prompts = []
+
+            def model(prompt):
+                prompts.append(prompt.to_string())
+                return AIMessage(content="接待建议")
+
+            service.llm = RunnableLambda(model)
+            service._fetch_weather_for_city_service = AsyncMock(return_value=weather)
+            service.intent_agent = Mock(
+                return_value=(["city_service"], {"city_service": "成都接待"}, "")
+            )
+            reply = "".join([x async for x in service.chat_stream("成都接待")])
+            self.assertEqual(reply, "接待建议")
+            self.assertIn("目的地天气:" + weather, prompts[0])
+            service._fetch_weather_for_city_service.assert_awaited_once_with("成都接待")
+
+    async def test_multi_intent_stream_matches_saved_reply(self):
+        service = self.service()
+        service.intent_agent = Mock(
+            return_value=(
+                ["weather", "train"],
+                {"weather": "天气", "train": "高铁"},
+                "",
+            )
+        )
+
+        async def call(intent, query):
+            yield query
+
+        service._call_agent_intent_stream = call
+        reply = "".join([x async for x in service.chat_stream("天气和高铁")])
+        self.assertEqual(reply, "天气\n\n高铁")
+        self.assertEqual(service.messages[-1]["content"], reply)
+
+    async def test_dependencies_reach_later_agent_in_both_modes(self):
+        for streaming in (False, True):
+            service = self.service()
+            calls = []
+
+            async def call(intent, query):
+                calls.append((intent, query))
+                return "航班MU123,800元" if intent == "flight" else "模拟预订"
+
+            service._call_agent_intent = call
+            # 刻意传入逆序计划,验证实际执行仍满足依赖。
+            steps = [
+                {
+                    "step": 2,
+                    "intent": "order",
+                    "action": "订最便宜的航班",
+                    "depends_on": 1,
+                },
+                {"step": 1, "intent": "flight", "action": "查询航班", "depends_on": 0},
+            ]
+            queries = {"flight": "查询航班", "order": "预订查询结果"}
+            if streaming:
+                _ = [x async for x in service._react_loop_stream(steps, queries)]
+            else:
+                await service.react_loop(steps, queries)
+            self.assertEqual([x[0] for x in calls], ["flight", "order"])
+            payload = json.loads(calls[1][1])
+            self.assertEqual(
+                payload["previous_results"][0]["result"], "航班MU123,800元"
+            )
+            self.assertEqual(payload["action"], "订最便宜的航班")
+
+    async def test_invalid_plans_execute_nothing(self):
+        for steps in (
+            [{"step": 1, "depends_on": 2}],
+            [{"step": 1, "depends_on": 2}, {"step": 2, "depends_on": 1}],
+            [{"step": 1}, {"step": 1}],
+        ):
+            service = self.service()
+            service.execute_step = AsyncMock()
+            with self.assertRaises(ValueError):
+                await service._execute_plan(steps, {})
+            service.execute_step.assert_not_called()
+
+    async def test_independent_steps_still_run_concurrently(self):
+        service = self.service()
+        started = set()
+        ready = asyncio.Event()
+
+        async def execute(step, queries, previous):
+            started.add(step["step"])
+            if len(started) == 2:
+                ready.set()
+            await asyncio.wait_for(ready.wait(), timeout=1)
+            return "ok"
+
+        service.execute_step = execute
+        result = await service._execute_plan([{"step": 1}, {"step": 2}], {})
+        self.assertEqual(len(result), 2)
+
+    def test_complex_goes_to_planning(self):
+        self.assertFalse(self.service()._should_skip_planning(["complex"]))
+        self.assertTrue(self.service()._should_skip_planning(["weather"]))
+
+
+class ProfileTests(unittest.TestCase):
+    def test_replace_deletes_and_merge_keeps_other_keys(self):
+        memory = ConversationMemory()
+        memory.update_profile({"座位": "二等座", "舱位": "经济舱"})
+        memory.update_profile({"座位": "一等座"})
+        self.assertIn("舱位", memory.user_profile)
+        memory.update_profile({"座位": "一等座"}, replace=True)
+        self.assertEqual(memory.user_profile, {"座位": "一等座"})
+        memory.update_profile({}, replace=True)
+        self.assertEqual(memory.user_profile, {})
+
+    def test_database_replace_deletes_stale_rows_and_commits(self):
+        memory = ConversationMemory()
+        db = Mock()
+        memory.set_db_connection(db)
+        memory.update_profile({"座位": "二等座"}, replace=True)
+        calls = db.cursor.return_value.execute.call_args_list
+        self.assertEqual(calls[0].args, ("DELETE FROM user_profiles",))
+        self.assertEqual(calls[1].args[1], ("座位", "二等座", "二等座"))
+        db.commit.assert_called_once()
+        memory.update_profile({}, replace=True)
+        self.assertEqual(
+            db.cursor.return_value.execute.call_args.args,
+            ("DELETE FROM user_profiles",),
+        )
+
+    def test_failed_replace_rolls_back_memory_and_database(self):
+        memory = ConversationMemory()
+        memory.user_profile = {"座位": "二等座"}
+        db = Mock()
+        db.cursor.return_value.execute.side_effect = RuntimeError("模拟数据库失败")
+        memory.set_db_connection(db)
+        with self.assertRaises(RuntimeError):
+            memory.update_profile({}, replace=True)
+        self.assertEqual(memory.user_profile, {"座位": "二等座"})
+        db.rollback.assert_called_once()
+        db.commit.assert_not_called()
+
+
+class SimulationTests(unittest.TestCase):
+    def test_all_booking_tools_explicitly_report_simulation_without_database(self):
+        sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+        from mcp_server.mcp_ticket_server import TicketService
+        from mcp_server.mcp_trip_server import TripService
+
+        ticket = TicketService.__new__(TicketService)
+        trip = TripService.__new__(TripService)
+        replies = [
+            ticket.order_train("2026-10-01", "G1", "二等座", 1),
+            ticket.order_flight("2026-10-01", "MU123", "经济舱", 1),
+            ticket.order_event("2026-10-01", "活动", "场馆", "普通票", 1),
+            trip.order_car_rental("2026-10-01", "SUV", 1),
+            trip.order_meeting_package("2026-10-01", "套餐", 1),
+            trip.order_insurance("综合型", "2026-10-01", 1),
+        ]
+        for reply in replies:
+            self.assertIn("模拟", reply)
+            self.assertIn("未实际下单", reply)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 111 - 0
tests/test_student_release.py

@@ -0,0 +1,111 @@
+"""学生版的小修正回归测试:使用模拟对象,不调用真实模型、天气或数据库。"""
+import contextlib
+import importlib
+import io
+import json
+import os
+import runpy
+import unittest
+from datetime import datetime, timedelta
+from pathlib import Path
+from unittest.mock import Mock, patch
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class StudentReleaseTests(unittest.TestCase):
+    def test_course_config_uses_matching_environment_names(self):
+        with patch.dict(os.environ, {
+            "DEEPSEEK_API_KEY": "offline-chat-placeholder",
+            "DASHSCOPE_API_KEY": "offline-embedding-placeholder",
+            "QWEATHER_API_KEY": "offline-weather-placeholder",
+            "QWEATHER_API_HOST": "weather.example.invalid",
+        }):
+            namespace = runpy.run_path(str(ROOT / "课件示例/项目实现/02-02.py"))
+            config = namespace["Config"]()
+        self.assertEqual(config.api_key, "offline-chat-placeholder")
+        self.assertEqual(config.embedding_api_key, "offline-embedding-placeholder")
+        self.assertEqual(config.weather_api_key, "offline-weather-placeholder")
+        self.assertEqual(config.weather_api_host, "weather.example.invalid")
+
+    def test_intent_prompt_receives_current_date(self):
+        from main_prompts import SmartBizTripPrompts
+        text = SmartBizTripPrompts.intent_prompt().format(
+            query="明天天气", current_date="2026-10-09", user_profile="",
+            task_context="{}", conversation_history="",
+        )
+        self.assertIn("当前日期:2026-10-09", text)
+
+    def test_weather_timezone_and_update_boundary(self):
+        from utils import spider_weather as spider
+        now = spider.TZ.localize(datetime(2026, 10, 9, 12))
+        with patch.object(spider, "datetime") as clock:
+            clock.now.return_value = now
+            self.assertFalse(spider.should_update_data((now-timedelta(hours=23, minutes=59)).replace(tzinfo=None)))
+            self.assertTrue(spider.should_update_data((now-timedelta(hours=24)).replace(tzinfo=None)))
+            self.assertTrue(spider.should_update_data(None))
+            self.assertTrue(spider.should_update_data(now, force_update=True))
+
+    def test_config_demo_runs_without_missing_method(self):
+        with contextlib.redirect_stdout(io.StringIO()):
+            runpy.run_path(str(ROOT / "config.py"), run_name="__main__")
+
+    def test_ticket_task_passes_text_to_executor(self):
+        from a2a_server import ticket_server as ticket
+        from python_a2a import Task, Message, TextContent, MessageRole
+        executor = Mock()
+        executor.invoke.return_value = {"output": "未找到符合条件的车次"}
+        message = Message(content=TextContent(text="北京到成都高铁"), role=MessageRole.USER)
+        task = Task(id="offline-ticket-test", message=message.to_dict())
+        with patch.object(ticket, "to_langchain_tool", return_value=[]), \
+             patch.object(ticket, "create_tool_calling_agent"), \
+             patch.object(ticket, "AgentExecutor", return_value=executor):
+            result = ticket.TicketServer().handle_task(task)
+        self.assertEqual(executor.invoke.call_args.args[0]["input"], "北京到成都高铁")
+        self.assertEqual(result.artifacts[0]["parts"][0]["text"], "未找到符合条件的车次")
+
+    def test_weather_api_start_does_not_connect_mysql(self):
+        from mcp_server import mcp_weather_server as weather
+        with patch.object(weather.conf, "weather_source", "api"), \
+             patch.object(weather, "WeatherService") as db, \
+             patch.object(weather, "create_fastapi_app", return_value="mock-app"), \
+             patch.object(weather.uvicorn, "run") as serve, \
+             contextlib.redirect_stdout(io.StringIO()):
+            weather.create_weather_mcp_server()
+        db.assert_not_called()
+        serve.assert_called_once_with("mock-app", host="0.0.0.0", port=8002)
+
+    def test_weather_http_calls_have_timeout_and_status_check(self):
+        from mcp_server import mcp_weather_server as weather
+        geo, forecast = Mock(), Mock()
+        geo.json.return_value = {"location": [{"id": "101010100"}]}
+        forecast.json.return_value = {"daily": []}
+        with patch.object(weather.requests, "get", side_effect=[geo, forecast]) as get:
+            result = json.loads(weather.fetch_weather_from_api("北京", "2026-10-09", "2026-10-09"))
+        self.assertEqual(result["status"], "no_data")
+        self.assertTrue(all(c.kwargs["timeout"] == 15 for c in get.call_args_list))
+        geo.raise_for_status.assert_called_once()
+        forecast.raise_for_status.assert_called_once()
+
+    def test_city_filter_keeps_quotes_inside_literal(self):
+        from mcp_server import mcp_trip_server as trip
+        collection = Mock()
+        collection.search.return_value = []
+        city = '成都" or city != "北京'
+        with patch("pymilvus.connections.connect"), \
+             patch("pymilvus.Collection", return_value=collection), \
+             patch.object(trip, "get_embedding", return_value=[0.0]):
+            trip.search_meeting_packages_in_milvus("会务", city)
+        self.assertEqual(collection.search.call_args.kwargs["expr"],
+                         "city == " + json.dumps(city, ensure_ascii=False))
+
+    def test_manual_weather_script_does_not_request_on_import(self):
+        with patch("requests.get") as get:
+            runpy.run_path(str(ROOT / "test.py"), run_name="manual_weather_import_test")
+        get.assert_not_called()
+
+    def test_imports_stay_in_this_copy(self):
+        for name in ("config", "chat_service", "mcp_server.mcp_weather_server",
+                     "mcp_server.mcp_ticket_server", "mcp_server.mcp_trip_server"):
+            module = importlib.import_module(name)
+            self.assertTrue(Path(module.__file__).resolve().is_relative_to(ROOT))

+ 15 - 0
tests/test_ticket_server.py

@@ -0,0 +1,15 @@
+import os
+import unittest
+
+from a2a_server import weather_server
+from python_a2a import A2AClient
+
+
+@unittest.skipUnless(os.getenv("RUN_ONLINE_TESTS") == "1", "需要启动 A2A/MCP 和模型服务")
+class TestWeatherServer(unittest.TestCase):
+
+    def test_query(self):
+        client = A2AClient("http://127.0.0.1:5006")
+        result = client.ask("帮我查询北京到成都,5月1号的高铁票")
+        print(result)
+        assert result

+ 15 - 0
tests/test_trip_server.py

@@ -0,0 +1,15 @@
+import os
+import unittest
+
+from a2a_server import weather_server
+from python_a2a import A2AClient
+
+
+@unittest.skipUnless(os.getenv("RUN_ONLINE_TESTS") == "1", "需要启动 A2A/MCP 和模型服务")
+class TestWeatherServer(unittest.TestCase):
+
+    def test_query(self):
+        client = A2AClient("http://127.0.0.1:5007")
+        result = client.ask("帮我查询成都5月1号的商务用车信息,成都取车,成都还车")
+        print(result)
+        assert result

+ 15 - 0
tests/test_weather_server.py

@@ -0,0 +1,15 @@
+import os
+import unittest
+
+from a2a_server import weather_server
+from python_a2a import A2AClient
+
+
+@unittest.skipUnless(os.getenv("RUN_ONLINE_TESTS") == "1", "需要启动 A2A/MCP 和模型服务")
+class TestWeatherServer(unittest.TestCase):
+
+    def test_query(self):
+        client = A2AClient("http://127.0.0.1:5005")
+        result = client.ask("帮我查一下北京明天的天气")
+        print(result)
+        assert result

+ 51 - 0
tests/test_weather_tool_parameters.py

@@ -0,0 +1,51 @@
+"""验证天气子助手能将多个命名参数传给 MCP 工具,不访问外部服务。"""
+
+import unittest
+from unittest.mock import Mock, patch
+
+from langchain_core.tools import Tool
+
+from a2a_server import weather_server
+
+
+class WeatherToolTests(unittest.IsolatedAsyncioTestCase):
+    async def test_city_and_date_range_reach_mcp_as_named_parameters(self):
+        parameters = {
+            "city": "北京",
+            "start_date": "2026-09-26",
+            "end_date": "2026-09-28",
+        }
+        mcp_call = Mock(return_value="北京天气查询结果")
+        # 复现 python-a2a 0.5.4 适配器返回的单输入 Tool。
+        adapter_tool = Tool(
+            name="query_weather",
+            description="查询指定城市和日期范围的天气",
+            func=mcp_call,
+            metadata={"parameters": {
+                "type": "object",
+                "properties": {key: {"type": "string"} for key in parameters},
+                "required": list(parameters),
+            }},
+        )
+
+        class ToolCallingExecutor:
+            def __init__(self, *, agent, tools, verbose):
+                self.tool = tools[0]
+
+            async def ainvoke(self, inputs):
+                # 模拟模型的结构化工具调用,使用真实 LangChain 参数分发逻辑。
+                return {"output": await self.tool.ainvoke(parameters)}
+
+        with (
+            patch.object(weather_server, "to_langchain_tool", return_value=[adapter_tool]),
+            patch.object(weather_server, "create_tool_calling_agent"),
+            patch.object(weather_server, "AgentExecutor", ToolCallingExecutor),
+        ):
+            result = await weather_server.query_weather("北京未来三天天气")
+
+        self.assertEqual(result, {"status": "success", "message": "北京天气查询结果"})
+        mcp_call.assert_called_once_with(**parameters)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 47 - 0
utils/format.py

@@ -0,0 +1,47 @@
+# 【主流程 8.3 的辅助转换】MCP 数据库查询结果含有日期、时间或 Decimal 时调用。
+# 先在查询方法中用 default_encoder 逐字段转换,再由 json.dumps/DateEncoder 完成序列化。
+
+"""
+需求:实现数据格式化工具,处理日期、时间和数值类型的序列化
+思路步骤:
+1. 导入必要的模块和库
+2. 实现default_encoder函数(格式化单个对象)
+3. 创建DateEncoder类(自定义JSON编码器,处理非标准类型序列化)
+4. 重写DateEncoder.default方法(处理序列化时的默认对象转换)
+"""
+import json
+from datetime import date, datetime, timedelta
+from decimal import Decimal
+
+
+def default_encoder(obj):  # 定义编码器方法,用于格式化单个对象
+    # 【转换内部步骤 1】datetime 是 date 的子类,先判断 datetime 才能保留时分秒。
+    # 这些判断是互斥返回分支:命中一种类型就 return,不会继续依次转换所有类型。
+    # 输入 obj 是某一列值,不是整条结果列表;输出是转换后的单个值。
+    # 日期对象用 strftime 格式化,时间差用 str,Decimal 用 float;普通 JSON 可处理的值原样返回。
+    if isinstance(obj, datetime):  # 检查是否为datetime,返回带时间的格式化字符串
+        # %Y/%m/%d 分别是年/月/日,%H/%M/%S 是时/分/秒;格式化结果是字符串。
+        return obj.strftime('%Y-%m-%d %H:%M:%S')
+    if isinstance(obj, date):  # 检查是否为date,返回日期格式化字符串
+        return obj.strftime('%Y-%m-%d')
+    if isinstance(obj, timedelta):  # 检查是否为timedelta,转换为字符串
+        return str(obj)
+    if isinstance(obj, Decimal):  # 检查是否为Decimal,转换为浮点数
+        # TODO 更优的方案 str(obj)
+        return float(obj)
+    return obj  # 否则返回原对象
+
+# 定义自定义JSON编码器类,继承自json.JSONEncoder,用于处理非标准类型序列化
+class DateEncoder(json.JSONEncoder):
+    def default(self, obj):  # 重写default方法,处理序列化时的默认对象转换
+        # 【转换内部步骤 2】json.dumps 遇到默认无法编码的对象时才回调此方法。
+        # 仍不支持的类型交给父类报错;Decimal 当前转为 float,可能损失精度。
+        # 此类继承 json.JSONEncoder;传给 json.dumps(cls=DateEncoder) 后,框架才会按需调用 default。
+        # super().default(obj) 保留父类对未知类型的报错行为,避免静默把无法编码的值变成不明确内容。
+        if isinstance(obj, (date, datetime)):  # 检查对象是否为date或datetime类型,对于datetime返回带时间的字符串,对于date返回日期字符串
+            return obj.strftime('%Y-%m-%d %H:%M:%S') if isinstance(obj, datetime) else obj.strftime('%Y-%m-%d')
+        if isinstance(obj, timedelta):  # 检查对象是否为timedelta类型,将时间差转换为字符串
+            return str(obj)
+        if isinstance(obj, Decimal):  # 检查对象是否为Decimal类型,将Decimal转换为浮点数以兼容JSON
+            return float(obj)
+        return super().default(obj)  # 对于其他类型,调用父类默认方法

+ 469 - 0
utils/spider_weather.py

@@ -0,0 +1,469 @@
+# 【数据准备 C:独立天气采集脚本】给数据库天气查询分支准备数据,不由网页每次查询自动触发。
+# C1 读取配置 → C2 进入 __main__ 建表 → C3 执行一次 update_weather
+# → C4 setup_scheduler 注册任务并持续检查是否到时。
+# update_weather 内部:连库 → 遍历城市 → 查更新时间 → 判断是否过期
+# → 按需请求天气 API → 逐日写库 → 关闭连接。
+
+"""
+需求:实现天气数据爬虫,定期从第三方API获取天气数据并存储到MySQL数据库
+思路步骤:
+1. 导入必要的模块和库
+2. 配置API密钥、城市代码和数据库连接参数
+3. 实现fetch_weather_data函数(从API获取天气数据)
+4. 实现get_latest_update_time函数(获取数据库中最新更新时间)
+5. 实现should_update_data函数(判断是否需要更新数据)
+6. 实现store_weather_data函数(将天气数据存储到数据库)
+7. 实现update_weather函数(更新指定城市的天气数据)
+8. 实现setup_scheduler函数(设置定时任务)
+9. 主函数(创建表、执行一次更新、启动定时任务)
+"""
+
+import gzip
+import json
+import os
+import sys
+import time
+from datetime import datetime
+
+import mysql.connector
+import pytz
+import requests
+import schedule
+
+sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
+from config import Config, env
+
+# 【数据准备 C1】读取配置并解析时区;这属于模块顶层代码,导入时也会执行。
+cfg = Config()
+# 把配置中的时区名称转换为 pytz 时区对象;只有库识别的名称才能成功。
+# 这是模块加载阶段执行的语句,名称无效会在进入下方 __main__ 之前抛异常。
+TZ = pytz.timezone(cfg.weather_timezone)
+
+
+def get_db_config():
+    # 【连接内部步骤 1】组装 mysql.connector.connect 所需的关键字参数。
+    # 由 connect_db 和主入口共用,统一数据库连接参数;charset 指定连接使用的字符集。
+    # 返回字典不会连接数据库,连接动作要等 mysql.connector.connect(**字典)。
+    return {
+        "host": cfg.host,
+        "user": cfg.user,
+        "password": cfg.password,
+        "database": cfg.database,
+        "charset": "utf8mb4",
+    }
+
+
+def connect_db():
+    # 【连接内部步骤 2】** 把字典展开成 host=... 等参数,返回数据库连接。
+    # 返回 MySQL 连接对象,调用方再通过 conn.cursor 创建执行 SQL 的游标。
+    # 本函数没有捕获连接异常,失败会交给调用方处理,不会返回一个伪造的空连接。
+    return mysql.connector.connect(**get_db_config())
+
+
+def fetch_weather_data(city, location):
+    """
+    从第三方API拿取天气数据。
+    :param city:
+    :param location:
+    :return: 天气数据
+    """
+    # 【更新内部步骤 4.1】用配置的城市代码请求天气;timeout=10 限制本次网络等待。
+    # 输入 city 用于日志说明,location 是天气服务的城市代码;二者不能互相替代。
+    # 成功时返回天气 JSON 对应的字典;已捕获的请求/解析异常通常返回 None,供调用方跳过写入。
+    headers = {"X-QW-Api-Key": cfg.weather_api_key, "Accept-Encoding": "gzip"}
+    url = f"{cfg.weather_base_url}?location={location}"
+    try:
+        response = requests.get(url, headers=headers, timeout=10)
+        print(response)
+        # <Response [200]>
+        # 检查 HTTP 是否返回失败状态,失败则抛请求异常,交给下方 requests.RequestException 分支。
+        # HTTP 成功不等于天气接口的业务 code 成功,业务 code 在 store_weather_data 里另外判断。
+        response.raise_for_status()
+        print(222222222, response.raise_for_status())
+        # 222222222 None
+        print(11111111, response.headers.get("Content-Encoding"))
+        # 11111111 gzip
+        # 【更新内部步骤 4.2】解析返回体。requests 可能已经解压响应,重复解压失败时走 BadGzipFile 回退。
+        if response.headers.get("Content-Encoding") == "gzip":
+            print("gzip")
+            # gzip
+            # 使用.decompress方法时候报错了
+            data = gzip.decompress(response.content).decode("utf-8")
+            print(1111, data)
+        else:
+            data = response.text
+
+        # exit()
+        # 把解码后的 JSON 文本转成字典;字典中的 daily 是后续逐日入库的原始数据。
+        return json.loads(data)
+    except requests.RequestException as e:
+        print(f"请求 {city} 天气数据失败: {e}")
+        return None
+    except json.JSONDecodeError as e:
+        print(f"{city} JSON 解析错误: {e}, 响应内容: {response.text[:500]}...")
+        return None
+    except gzip.BadGzipFile:
+        print(f"{city} 数据未正确解压,尝试直接解析: {response.text[:500]}...")
+        return json.loads(response.text) if response.text else None
+
+
+def get_latest_update_time(cursor, city):
+    """
+    拿取 这个 城市city 在数据表weather_data中最新更新时间。
+    :param cursor:
+    :param city:
+    :return: datetime对象 或 None
+
+    fetchone() 返回单个元组,元素顺序与 SELECT 列顺序一致:
+        # cursor.execute("SELECT city, MAX(update_time) FROM weather_data WHERE city = %s", (city,))
+        # result = cursor.fetchone()
+        # result => ("北京", datetime.datetime(2026, 4, 28, 10, 30, 0))
+        # result[0] => "北京"
+        # result[1] => datetime.datetime(2026, 4, 28, 10, 30, 0)
+
+        # 如果查询无结果(表中无该城市数据):
+        # result => (None,)
+
+    fetchall() 返回元组列表,适用于多行结果:
+        # cursor.execute("SELECT city, update_time FROM weather_data WHERE city = %s", (city,))
+        # results = cursor.fetchall()
+        # results => [
+        #     ("北京", datetime.datetime(2026, 4, 27, 10, 30, 0)),
+        #     ("北京", datetime.datetime(2026, 4, 28, 10, 30, 0))
+        # ]
+    """
+    # 【更新内部步骤 2】只读该城市最新 update_time,供下一步判断是否需要更新。
+    # 输入 cursor 是已有游标,city 用于过滤该城市;输出最近更新时间或 None。
+    # MAX 是 SQL 聚合,所以正常查询只得到一行一个值,用 fetchone 而不是 fetchall。
+    cursor.execute("SELECT MAX(update_time) FROM weather_data WHERE city = %s", (city,))
+    # 结果形如 (时间值,);即使该城市没有记录,MAX 通常仍得到一行 (None,)。
+    # 下方读取 result[0],把没有可用时间的情况统一返回 None。
+    result = cursor.fetchone()
+    # 本查询只返回一个聚合值,result 示例 => (datetime.datetime(2026, 4, 28, 10, 30, 0),)
+    # 若无数据 => (None,)
+    return result[0] if result[0] else None
+
+
+def should_update_data(latest_time, force_update=False):
+    """
+    如果没有更新时间 或者 更新时间大于24小时 或者 force_update=True,就返回True,就去更新数据库中的这个城市的天气数据。
+    其他情况返回False,表示不更新数据库中的数据。
+    :param latest_time: 某个城市最新的更新时间
+    :param force_update: 默认否
+    :return: 是 或这 否 true or false
+    """
+    # 【更新内部步骤 3】强制更新或没有记录时直接返回 True,否则检查是否已满 24 小时。
+    # 调用者 update_weather 为每个城市调用一次。force_update=True 强制返回 True;否则看最近更新时间。
+    # 返回布尔值只是更新决定,本函数不发 HTTP 请求,也不写入天气记录。
+    if force_update:
+        return True
+    if not latest_time:
+        return True
+    current_time = datetime.now(TZ)
+    # 数据库的无时区时间按本地时区解释;已有时区的时间转换到同一时区再比较。
+    latest_time = TZ.localize(latest_time) if latest_time.tzinfo is None else latest_time.astimezone(TZ)
+    # 时间差先换算成秒,再除以 3600 得到小时;满 24 小时才返回 True,不是每次启动都强制更新。
+    return (current_time - latest_time).total_seconds() / 3600 >= 24
+
+
+def store_weather_data(conn, cursor, city, data):
+    """
+    把从第三方拿到的天气数据(json)解析出来,组装成可以插入到mysql天气表中的insert语句。然后执行插入动作。
+    :param conn:
+    :param cursor:
+    :param city:
+    :param data:
+    :return:
+    """
+
+    # {"code":"200",
+    # "updateTime":"2026-02-04T10:42+08:00",
+    # "fxLink":"https://www.qweather.com/weather/beijing-101010100.html",
+    # "daily":[
+    #     {"fxDate":"2026-02-04","sunrise":"07:21","sunset":"17:37","moonrise":"20:21",
+    #     "moonset":"08:41","moonPhase":"亏凸月","moonPhaseIcon":"805","tempMax":"12",
+    #     "tempMin":"-2","iconDay":"100","textDay":"晴","iconNight":"150","textNight":"晴",
+    #     "wind360Day":"225","windDirDay":"西南风","windScaleDay":"1-3","windSpeedDay":"3",
+    #     "wind360Night":"0","windDirNight":"北风","windScaleNight":"1-3","windSpeedNight":"16",
+    #     "humidity":"29","precip":"0.0","pressure":"1016","vis":"25","cloud":"0","uvIndex":"3"}
+    #     。。。。
+    # "refer":{"sources":["QWeather"],"license":["QWeather Developers License"]}}
+
+    # 【更新内部步骤 5.1】先检查接口业务码;无效数据提前 return,不执行写库。
+    # 输入 conn 用于提交/回滚,cursor 用于执行 SQL,city 标识写入城市,data 是接口返回字典。
+    # 没有直接返回成功列表;通过逐条 SQL 写入和日志体现结果,由 update_weather 继续下一城市。
+    # or 有短路行为:data 为空时不再调用 data.get,直接进入跳过分支。
+    # 接口 code 在这里按字符串 "200" 比较,与 HTTP status_code 的整数 200 是不同字段。
+    if not data or data.get("code") != "200":
+        print(f"{city} 数据无效,跳过存储。")
+        return
+
+    # 取逐日预报列表;没有 daily 就得到空列表,下面 for 不执行,不会写入任何天气行。
+    daily_data = data.get("daily", [])
+    update_time = datetime.fromisoformat(
+        data.get("updateTime").replace("+08:00", "+08:00")
+    ).astimezone(TZ).replace(tzinfo=None)
+
+    # 【更新内部步骤 5.2】按 daily 中的每一天组装字段,通过城市+日期的唯一键插入或更新。
+    for day in daily_data:
+        # {"fxDate":"2026-02-04","sunrise":"07:21","sunset":"17:37","moonrise":"20:21",
+        #     #     "moonset":"08:41","moonPhase":"亏凸月","moonPhaseIcon":"805","tempMax":"12",
+        #     #     "tempMin":"-2","iconDay":"100","textDay":"晴","iconNight":"150","textNight":"晴",
+        #     #     "wind360Day":"225","windDirDay":"西南风","windScaleDay":"1-3","windSpeedDay":"3",
+        #     #     "wind360Night":"0","windDirNight":"北风","windScaleNight":"1-3","windSpeedNight":"16",
+        #     #     "humidity":"29","precip":"0.0","pressure":"1016","vis":"25","cloud":"0","uvIndex":"3"}
+        fx_date = datetime.strptime(day["fxDate"], "%Y-%m-%d").date()
+        # 元组中的值按后面 INSERT 列的顺序排列;参数顺序与占位符数量都要对应。
+        # SQL 字符串本身包含 %s,占位符的实际值由 cursor.execute 的第二个参数提供。
+        values = (
+            city,
+            fx_date,
+            day.get("sunrise"),
+            day.get("sunset"),
+            day.get("moonrise"),
+            day.get("moonset"),
+            day.get("moonPhase"),
+            day.get("moonPhaseIcon"),
+            day.get("tempMax"),
+            day.get("tempMin"),
+            day.get("iconDay"),
+            day.get("textDay"),
+            day.get("iconNight"),
+            day.get("textNight"),
+            day.get("wind360Day"),
+            day.get("windDirDay"),
+            day.get("windScaleDay"),
+            day.get("windSpeedDay"),
+            day.get("wind360Night"),
+            day.get("windDirNight"),
+            day.get("windScaleNight"),
+            day.get("windSpeedNight"),
+            day.get("precip"),
+            day.get("uvIndex"),
+            day.get("humidity"),
+            day.get("pressure"),
+            day.get("vis"),
+            day.get("cloud"),
+            update_time,
+        )
+        # upsert (Update + Insert): 插入或更新操作,一条SQL同时处理两种情况
+        # 原理:依赖表的唯一键约束(本表中为 UNIQUE KEY unique_city_date(city, fx_date))
+        #   - INSERT 路径:表中不存在该 (city, fx_date) 组合 → 直接插入新行
+        #   - UPDATE 路径:表中已存在该 (city, fx_date) 组合 → 用新值覆盖旧值(ON DUPLICATE KEY UPDATE)
+        #
+        # 示例:
+        #   首次拉取 ("北京", 2026-04-28) → INSERT 新行
+        #   再次拉取 ("北京", 2026-04-28) → UPDATE 该行所有天气字段,同时更新 update_time
+        #
+        # VALUES(col) 表示 "要插入的那份新值",而非表中原有的旧值
+        insert_query = """
+                       INSERT INTO weather_data (city, fx_date, sunrise, sunset, moonrise, moonset, moon_phase,
+                                                 moon_phase_icon,
+                                                 temp_max, temp_min, icon_day, text_day, icon_night, text_night,
+                                                 wind360_day, wind_dir_day, wind_scale_day, wind_speed_day,
+                                                 wind360_night, wind_dir_night, wind_scale_night, wind_speed_night,
+                                                 precip, uv_index, humidity, pressure, vis, cloud, update_time)
+                       VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
+                               %s, %s, %s, %s, %s, %s, %s)
+                       ON DUPLICATE KEY UPDATE sunrise          = VALUES(sunrise),
+                                               sunset           = VALUES(sunset),
+                                               moonrise         = VALUES(moonrise),
+                                               moonset          = VALUES(moonset),
+                                               moon_phase       = VALUES(moon_phase),
+                                               moon_phase_icon  = VALUES(moon_phase_icon),
+                                               temp_max         = VALUES(temp_max),
+                                               temp_min         = VALUES(temp_min),
+                                               icon_day         = VALUES(icon_day),
+                                               text_day         = VALUES(text_day),
+                                               icon_night       = VALUES(icon_night),
+                                               text_night       = VALUES(text_night),
+                                               wind360_day      = VALUES(wind360_day),
+                                               wind_dir_day     = VALUES(wind_dir_day),
+                                               wind_scale_day   = VALUES(wind_scale_day),
+                                               wind_speed_day   = VALUES(wind_speed_day),
+                                               wind360_night    = VALUES(wind360_night),
+                                               wind_dir_night   = VALUES(wind_dir_night),
+                                               wind_scale_night = VALUES(wind_scale_night),
+                                               wind_speed_night = VALUES(wind_speed_night),
+                                               precip           = VALUES(precip),
+                                               uv_index         = VALUES(uv_index),
+                                               humidity         = VALUES(humidity),
+                                               pressure         = VALUES(pressure),
+                                               vis              = VALUES(vis),
+                                               cloud            = VALUES(cloud),
+                                               update_time      = VALUES(update_time) \
+                       """
+        try:
+            # 【更新内部步骤 5.3】逐天写入并提交;本天失败则回滚本天事务,不撤销此前已提交日期。
+            cursor.execute(insert_query, values)
+            print(
+                f"{city} {fx_date} 数据写入/更新成功: {day.get('textDay')}, 影响行数: {cursor.rowcount}"
+            )
+            # 本天 INSERT/UPDATE 成功后提交事务,下一天的写入属于后续操作。
+            conn.commit()
+            print(f"{city} 事务提交完成。")
+        except mysql.connector.Error as e:
+            print(f"{city} {fx_date} 数据库错误: {e}")
+            # 本天执行异常时撤销尚未提交的修改;except 结束后外层 for 仍可继续处理下一天。
+            conn.rollback()
+            print(f"{city} 事务回滚。")
+
+
+def update_weather(force_update=False):
+    """
+    遍历配置中的所有城市,判断是否需要拉取最新天气数据并入库。
+
+    流程图:
+        开始
+         │
+         ├── connect_db() ──► 建立 MySQL 连接
+         │
+         ├── 遍历 cfg.weather_city_codes 中的每个 (city, location)
+         │    │
+         │    ├── get_latest_update_time(cursor, city)
+         │    │    └── SELECT MAX(update_time) FROM weather_data
+         │    │         └──► latest_time = datetime / None
+         │    │
+         │    ├── should_update_data(latest_time, force_update) 判断:
+         │    │    ├── force_update == True       ──► True(强制更新)
+         │    │    ├── latest_time == None         ──► True(首次拉取)
+         │    │    └── 距今 > 24 小时              ──► True(数据过期)
+         │    │    └── 其他情况                    ──► False(跳过)
+         │    │
+         │    ├── True 分支(需要更新):
+         │    │    ├── fetch_weather_data(city, location)
+         │    │    │    └──► HTTP GET → 和风天气 API → gzip 解压 → 返回 JSON dict
+         │    │    │
+         │    │    ├── data 有效?
+         │    │    │    ├── 是 ──► store_weather_data(conn, cursor, city, data)
+         │    │    │    │          └──► 解析 daily 数组 → 逐条 UPSERT → commit
+         │    │    │    └── 否 ──► 跳过该城市(打印警告)
+         │    │
+         │    └── False 分支(数据已最新):
+         │         └──► 打印 "数据已为最新,无需更新"
+         │
+         └── cursor.close() / conn.close() ──► 结束
+    """
+    # 【数据准备 C3,内部步骤 1】建立连接并遍历配置城市;每个城市分别判断更新条件。
+    # 由主入口先调用一次,之后可能由 schedule 在到期时调用。force_update 默认 False。
+    # 城市之间串行处理:获取更新时间 → 决定是否拉取 → 检查返回 → 存储,再处理下一城市。
+    conn = connect_db()
+    cursor = conn.cursor()
+
+    for city, location in cfg.weather_city_codes.items():
+        # 拿取 这个 城市city 在数据表weather_data中最新更新时间。
+        latest_time = get_latest_update_time(cursor, city)
+        # 如果没有更新时间 或者 更新时间大于24小时 或者 force_update=True,就返回True,就去更新数据库中的这个城市的天气数据。
+        # 其他情况返回False,表示不更新数据库中的数据。
+        # True 才进入 HTTP 拉取分支;False 只打印“已最新”,然后循环自然进入下一个城市。
+        if should_update_data(latest_time, force_update):
+            print(f"开始更新 {city} 天气数据...")
+            # 从第三方API拿取天气数据
+            data = fetch_weather_data(city, location)
+            # 先检查是否取得非空返回值;真正的天气业务 code 仍由 store_weather_data 继续验证。
+            if data:
+                # 把从第三方拿到的天气数据(json)解析出来,组装成可以插入到mysql天气表中的insert语句。然后执行插入动作。
+                store_weather_data(conn, cursor, city, data)
+        else:
+            print(f"{city} 数据已为最新,无需更新。最新更新时间: {latest_time}")
+
+    # 【更新内部步骤 6】正常完成循环后释放游标和连接。
+    cursor.close()
+    conn.close()
+
+
+def setup_scheduler():
+    # 仅当 weather_source 等于 "api" 时跳过定时调度;这不会取消此前已执行的更新。
+    # 【数据准备 C4.1】api 模式仅跳过这里的定时循环;__main__ 前面的建表和首次更新仍会执行。
+    # 由主入口在首次更新以后调用。api 模式立即 return,其他模式注册每日任务并进入常驻循环。
+    # 函数只有一直运行,schedule.run_pending 才有机会检查到期任务;注册动作不会创建系统定时任务。
+    if cfg.weather_source == "api":
+        return
+
+    # 【数据准备 C4.2】注册每天的运行时间;当前 .at 未显式传入时区,按运行进程的本地时间调度。
+    # do 接收的是函数对象 update_weather,没有括号;只有任务到期时才会调用它。
+    # 如果写成 do(update_weather()) 就会先立即执行函数,再把返回值交给调度器,这与当前逻辑不同。
+    schedule.every().day.at(cfg.weather_schedule_time).do(update_weather)
+    # 【数据准备 C4.3】每 60 秒检查到期任务,不是每 60 秒都请求一次天气。进程退出后调度停止。
+    while True:
+        schedule.run_pending()
+        # 暂停当前线程 60 秒,降低检查频率;这会阻塞此采集脚本线程,不是 async 的协程暂停。
+        time.sleep(60)
+
+
+if __name__ == "__main__":
+    # 初始检查和更新
+    # 【数据准备 C2】直接运行时先确保 weather_data 表存在,再调用首次更新和调度器。
+    with mysql.connector.connect(**get_db_config()) as conn:
+        cursor = conn.cursor()
+        cursor.execute("""
+                       CREATE TABLE IF NOT EXISTS weather_data
+                       (
+                           id               INT AUTO_INCREMENT PRIMARY KEY,
+                           city             VARCHAR(50) NOT NULL COMMENT '城市名称',
+                           fx_date          DATE        NOT NULL COMMENT '预报日期',
+                           sunrise          TIME COMMENT '日出时间',
+                           sunset           TIME COMMENT '日落时间',
+                           moonrise         TIME COMMENT '月升时间',
+                           moonset          TIME COMMENT '月落时间',
+                           moon_phase       VARCHAR(20) COMMENT '月相名称',
+                           moon_phase_icon  VARCHAR(10) COMMENT '月相图标代码',
+                           temp_max         INT COMMENT '最高温度',
+                           temp_min         INT COMMENT '最低温度',
+                           icon_day         VARCHAR(10) COMMENT '白天天气图标代码',
+                           text_day         VARCHAR(20) COMMENT '白天天气描述',
+                           icon_night       VARCHAR(10) COMMENT '夜间天气图标代码',
+                           text_night       VARCHAR(20) COMMENT '夜间天气描述',
+                           wind360_day      INT COMMENT '白天风向360角度',
+                           wind_dir_day     VARCHAR(20) COMMENT '白天风向',
+                           wind_scale_day   VARCHAR(10) COMMENT '白天风力等级',
+                           wind_speed_day   INT COMMENT '白天风速 (km/h)',
+                           wind360_night    INT COMMENT '夜间风向360角度',
+                           wind_dir_night   VARCHAR(20) COMMENT '夜间风向',
+                           wind_scale_night VARCHAR(10) COMMENT '夜间风力等级',
+                           wind_speed_night INT COMMENT '夜间风速 (km/h)',
+                           precip           DECIMAL(5, 1) COMMENT '降水量 (mm)',
+                           uv_index         INT COMMENT '紫外线指数',
+                           humidity         INT COMMENT '相对湿度 (%)',
+                           pressure         INT COMMENT '大气压强 (hPa)',
+                           vis              INT COMMENT '能见度 (km)',
+                           cloud            INT COMMENT '云量 (%)',
+                           update_time      DATETIME COMMENT '数据更新时间',
+                           UNIQUE KEY unique_city_date (city, fx_date)
+                       ) ENGINE = InnoDB
+                         DEFAULT CHARSET = utf8mb4
+                         COLLATE = utf8mb4_unicode_ci COMMENT ='天气数据表'
+                       """)
+        conn.commit()
+
+    # 立即执行一次更新
+    update_weather()
+    # 我们全国有2000个城市
+
+    # 启动定时任务
+    setup_scheduler()
+
+# 以下是另一种按查询触发的缓存设计思路,当前脚本未实现:
+# 1 用户来查询某个城市 比如 北京的天气数据。
+# 2 去数据库中查询北京天气数据,select weather_content,update_time from weather_data where city = "北京" and date = "2026-03-04"
+# 3 检验这条数据的update_time,如果是1小时之前的,就从第三方api拉取最新数据,返回给用户。同时更新mysql数据。
+# 4 如果这条数据是1小时之内更新的,就直接返回给用户。
+# 5 做一个缓存,缓存的key是 city+date,设置过期时间ttl,当前时间-更新时间(1小时之内)
+
+# 以上是第一种方式
+# 其他方式:
+# 从redis中查,有就用,没有就查第三方api,缓存redis,设置ttl=1小时。
+
+
+# {"code":"200",
+# "updateTime":"2026-02-04T10:42+08:00",
+# "fxLink":"https://www.qweather.com/weather/beijing-101010100.html",
+# "daily":[
+#     {"fxDate":"2026-02-04","sunrise":"07:21","sunset":"17:37","moonrise":"20:21",
+#     "moonset":"08:41","moonPhase":"亏凸月","moonPhaseIcon":"805","tempMax":"12",
+#     "tempMin":"-2","iconDay":"100","textDay":"晴","iconNight":"150","textNight":"晴",
+#     "wind360Day":"225","windDirDay":"西南风","windScaleDay":"1-3","windSpeedDay":"3",
+#     "wind360Night":"0","windDirNight":"北风","windScaleNight":"1-3","windSpeedNight":"16",
+#     "humidity":"29","precip":"0.0","pressure":"1016","vis":"25","cloud":"0","uvIndex":"3"}
+#     。。。。
+# "refer":{"sources":["QWeather"],"license":["QWeather Developers License"]}}

+ 1483 - 0
uv.lock

@@ -0,0 +1,1483 @@
+version = 1
+revision = 3
+requires-python = ">=3.12.11, <3.13"
+resolution-markers = [
+    "sys_platform == 'win32'",
+    "sys_platform == 'emscripten'",
+    "sys_platform != 'emscripten' and sys_platform != 'win32'",
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.14.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "aiohappyeyeballs" },
+    { name = "aiosignal" },
+    { name = "attrs" },
+    { name = "frozenlist" },
+    { name = "multidict" },
+    { name = "propcache" },
+    { name = "typing-extensions" },
+    { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" },
+    { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" },
+    { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" },
+    { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" },
+    { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" },
+    { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" },
+    { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" },
+    { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" },
+    { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" },
+    { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" },
+    { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "frozenlist" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "annotated-types"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
+]
+
+[[package]]
+name = "anthropic"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "docstring-parser" },
+    { name = "httpx2" },
+    { name = "jiter" },
+    { name = "pydantic" },
+    { name = "sniffio" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/65/b8/f4de0e90bbd641e86a1d6b20e017442033a2c1d2f5381799f82057115bd7/anthropic-1.8.0.tar.gz", hash = "sha256:9c1783ed90f409617749a61c5ab98e20624a572626f2e0a15cea03ed8e1401e5", size = 1221762, upload-time = "2026-09-22T16:25:38.167Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5b/18/5d25a703b66ba34e9277f875f692a3bea3b0ff4d47ee5d188521a01cdd2a/anthropic-1.8.0-py3-none-any.whl", hash = "sha256:79a4516a21e64fd7b15be1a49ebf544bd6376c96a971a77365358823a2717bc6", size = 1348276, upload-time = "2026-09-22T16:25:36.515Z" },
+]
+
+[[package]]
+name = "anyio"
+version = "4.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "idna" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
+]
+
+[[package]]
+name = "attrs"
+version = "26.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
+]
+
+[[package]]
+name = "blinker"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
+]
+
+[[package]]
+name = "boto3"
+version = "1.43.102"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "botocore" },
+    { name = "jmespath" },
+    { name = "s3transfer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9a/e6/2a24a2013beecdd9a381bcf7a11981478a1f5f78e37908c0fddba3cd915f/boto3-1.43.102.tar.gz", hash = "sha256:440bdd9fbf88d57809b68839fb690f19e4db65fa5539e60cc803ebd7b9e5e8fc", size = 112696, upload-time = "2026-09-24T19:25:18.329Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/46/ea/5bb0e8a8a4b04221b054365a5904fd63b6bcc22edcf4aa75a253f946b86e/boto3-1.43.102-py3-none-any.whl", hash = "sha256:b2a06cbbcb2cb0484f126172eab0881252f01007e8f7e0607ecac131cff9f744", size = 140042, upload-time = "2026-09-24T19:25:16.822Z" },
+]
+
+[[package]]
+name = "botocore"
+version = "1.43.102"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "jmespath" },
+    { name = "python-dateutil" },
+    { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/12/97d043f9bfdd54984f8d562169a9ac800c7b94a5b5fdb9b7d7b46659ebd2/botocore-1.43.102.tar.gz", hash = "sha256:3239f3c8611f0fbfb1a3fda77fb70e18f87bec102edf38f67e40902c56639c10", size = 16232455, upload-time = "2026-09-24T19:25:13.238Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5a/95/e2791a9dfc989e3580ce087085a45a67987e693f364e2461904d962e3951/botocore-1.43.102-py3-none-any.whl", hash = "sha256:78d9dcee19950444b4ff0250125aff735da4f7d6db46eb1e9aca82dc8f101a66", size = 15926988, upload-time = "2026-09-24T19:25:09.891Z" },
+]
+
+[[package]]
+name = "cachetools"
+version = "7.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/29/2c/3f18755527b03ca9ff6be724bd5370cb777c76a87f17301377cf04a4729b/cachetools-7.2.0.tar.gz", hash = "sha256:bcac1a1b8da6909994a2957238a57b8140dab7c5c5c69a43669654fe87a33c1d", size = 41129, upload-time = "2026-09-16T20:48:27.209Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cc/bb/1c6e7a89b11da19f137e4ef5a5c5fe47a5bb686449ba343944ccf8fb34b2/cachetools-7.2.0-py3-none-any.whl", hash = "sha256:3045213f186b89fdd95d94441354c4bd87c570b7a38d8c3fd1d9dd37f6dc90d8", size = 16918, upload-time = "2026-09-16T20:48:25.575Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.7.22"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.5.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" },
+    { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" },
+    { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" },
+    { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" },
+    { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" },
+    { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" },
+    { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" },
+    { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" },
+    { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" },
+    { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" },
+    { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" },
+    { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" },
+    { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" },
+    { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" },
+    { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" },
+    { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "distro"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
+]
+
+[[package]]
+name = "docstring-parser"
+version = "0.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" },
+]
+
+[[package]]
+name = "fastapi"
+version = "0.116.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pydantic" },
+    { name = "starlette" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" },
+]
+
+[[package]]
+name = "flask"
+version = "3.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "blinker" },
+    { name = "click" },
+    { name = "itsdangerous" },
+    { name = "jinja2" },
+    { name = "markupsafe" },
+    { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" },
+    { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" },
+    { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" },
+    { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" },
+    { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" },
+    { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
+]
+
+[[package]]
+name = "grpcio"
+version = "1.84.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3f/4f/4435c0aae54657258d9cfcba78598f3d9e5fe4c82ff18d78558567b90faf/grpcio-1.84.0.tar.gz", hash = "sha256:19aaf172fc2edbefccce3f6e92c5150975dbe56c45744e9e87cf72ebdf85bfbe", size = 13493876, upload-time = "2026-09-14T06:59:33.291Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0a/c1/4c9a2e0e6b0aaf02781404cad2f79211f989f2c827cf672a4a48d1604d3e/grpcio-1.84.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:b5c6f20d657ae09ae4e30d9d3a21edd13f1219d58cc6f999b9d1bb63be9c1baa", size = 6415756, upload-time = "2026-09-14T06:57:39.345Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/57/131e7007bdee9acb77a8dbe8a16fa9fef75f88c1695242d8ee0993ac2d3d/grpcio-1.84.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:406583b4e8fb2282ebd392e12b963e601c1f82e07125a8c2cb5b144e7e024796", size = 12339195, upload-time = "2026-09-14T06:57:42.373Z" },
+    { url = "https://files.pythonhosted.org/packages/db/d1/a7b7cda98fcab9b3d2916204a872d87371158a7a34e41768f524584fb64d/grpcio-1.84.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fbdbcd06986ede3ce584083b1dc2afe6808e8943e5cf50ad11183c03aceda25a", size = 6984468, upload-time = "2026-09-14T06:57:45.035Z" },
+    { url = "https://files.pythonhosted.org/packages/19/81/c5be83e3ac9416f73c4c51fe1ea9c41a0c42fc3509e3505faa46f5046abe/grpcio-1.84.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:23e6e8e8a75cff88e0a793bfd3becea03a13e2763ae90c1ff573bc19ca5b429a", size = 7749432, upload-time = "2026-09-14T06:57:47.395Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/bf/258cd7c0a7ed92745dc93c31666d462d05b702807a689744bd49fb833bde/grpcio-1.84.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b44f0a0fc7bc6677d38cc80bca1a32814ce6c8f200fb8b3c1a61c9d77eaefbf3", size = 7156115, upload-time = "2026-09-14T06:57:49.657Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/4b/7f829418dbfcf91b875e55e2973f1059a95decb4f081313416317ef04ec1/grpcio-1.84.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:210e4c32f907045eb8158273e60c6ab69a3947697df6245dbda381f26c59485b", size = 7708010, upload-time = "2026-09-14T06:57:52.496Z" },
+    { url = "https://files.pythonhosted.org/packages/34/f0/9932e2fec6a04205f8bf3f8f4d2020479dcdac88feb6f93822ed31bf0eba/grpcio-1.84.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a71d24f40b0cc6798feaa978c7411dc1135b7018e9fc0442db611c139bf58344", size = 8759980, upload-time = "2026-09-14T06:57:55.312Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/5c/b67407c6dbc480dfc0715f6eccdb1061e7c88d85f9a330a241d357a538c5/grpcio-1.84.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f6c972474ce691aca74e58d17625450cef153dc4760364cadeb167983ea6d589", size = 8124904, upload-time = "2026-09-14T06:57:58.569Z" },
+    { url = "https://files.pythonhosted.org/packages/02/37/2bfdae2df8dfcfc0df619b628e0c7153ce703adae827243f44720322ccc1/grpcio-1.84.0-cp312-cp312-win32.whl", hash = "sha256:0d532ade4486dad9b302ffa4d4683d67561051c26d17c4023322845e9fa10140", size = 4478915, upload-time = "2026-09-14T06:58:00.714Z" },
+    { url = "https://files.pythonhosted.org/packages/85/2c/309268b7b39f6deb2342f634841e105623a0b67982e8b10ec516782ff1c6/grpcio-1.84.0-cp312-cp312-win_amd64.whl", hash = "sha256:49717e857899f4136d7657bf5aded61ac479110a075438290923a4d86af7cd02", size = 5253534, upload-time = "2026-09-14T06:58:03.336Z" },
+]
+
+[[package]]
+name = "h11"
+version = "0.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
+]
+
+[[package]]
+name = "httpcore"
+version = "1.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
+]
+
+[[package]]
+name = "httpcore2"
+version = "2.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "h11" },
+    { name = "truststore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" },
+]
+
+[[package]]
+name = "httpx"
+version = "0.28.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "certifi" },
+    { name = "httpcore" },
+    { name = "idna" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
+]
+
+[[package]]
+name = "httpx-sse"
+version = "0.4.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
+]
+
+[[package]]
+name = "httpx2"
+version = "2.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio", marker = "sys_platform != 'emscripten'" },
+    { name = "httpcore2", marker = "sys_platform != 'emscripten'" },
+    { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" },
+    { name = "idna" },
+    { name = "truststore", marker = "sys_platform != 'emscripten'" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" },
+]
+
+[[package]]
+name = "httpx2-jsfetch"
+version = "1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
+]
+
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "jiter"
+version = "0.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/1f/8176d92e001f86505424b41664032ae26a882bc9ca41a32c803f373f9195/jiter-0.17.0.tar.gz", hash = "sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12", size = 229037, upload-time = "2026-09-12T15:14:14.253Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/aa/f8/07bd8c3a23f7a8a6875e6a820bbffe1483a18f18f9398a91b5495123176e/jiter-0.17.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91", size = 291633, upload-time = "2026-09-12T15:11:49.431Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/5e/0de4c6f84ffefa6809ffc2d550b9a314365acf7e7ec9b6c7375d49047900/jiter-0.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4", size = 321695, upload-time = "2026-09-12T15:11:52.727Z" },
+    { url = "https://files.pythonhosted.org/packages/20/ac/befe2e82065bee37a0252081666ed2f48c1ac5f5c6c318c2de8168ba393d/jiter-0.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1", size = 341967, upload-time = "2026-09-12T15:11:54.231Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/cd/9797c1e529746750ae589da7c1a8c24373f00d88e11a989f9e5eb1959079/jiter-0.17.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8", size = 326546, upload-time = "2026-09-12T15:11:55.41Z" },
+    { url = "https://files.pythonhosted.org/packages/d9/fd/e6914c38d6347bab4ebff2b1f0c0f191db276e7a1d5c376176757da42fe3/jiter-0.17.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63", size = 340995, upload-time = "2026-09-12T15:11:58.211Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/7d/611b3abf6f88945b5474da5cdc6d1a185e805ac9bf446bb7766dcda6ea87/jiter-0.17.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a", size = 352188, upload-time = "2026-09-12T15:11:59.414Z" },
+    { url = "https://files.pythonhosted.org/packages/52/f8/b6e513ecbdf3b3cebe587c2279281ecf775b729a58cf4cc7bdf898ded029/jiter-0.17.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797", size = 345025, upload-time = "2026-09-12T15:12:00.697Z" },
+    { url = "https://files.pythonhosted.org/packages/28/a8/fe26d06c5a6c5a4cfe703c5154c8a140da1305671eb3681aba9422d4f393/jiter-0.17.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812", size = 329180, upload-time = "2026-09-12T15:12:01.831Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/58/e6d66a26af40a20e62486feb7e222fd50f6e7aaa4f107abd89675dcc835b/jiter-0.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a", size = 335805, upload-time = "2026-09-12T15:12:03.056Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/3e/96520aa2fef5ef831d95483a902140bfab83dcac9eaa74f7df61b5e50a1b/jiter-0.17.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44", size = 484121, upload-time = "2026-09-12T15:12:04.414Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/8f/5d9d92fe538bf36ff481a2278c48147e59c1cf8eb2f7be665260665febe5/jiter-0.17.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31", size = 521310, upload-time = "2026-09-12T15:12:05.612Z" },
+    { url = "https://files.pythonhosted.org/packages/50/06/a09f979b22e652afbc3de66c709b2ba92edcef555f7535ab937c86b4f21a/jiter-0.17.0-cp312-cp312-win32.whl", hash = "sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd", size = 185029, upload-time = "2026-09-12T15:12:06.994Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/d9/98265a005b2473ec2be5a84e2b64c2f65382c673879f1574845cd4bcd77c/jiter-0.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3", size = 227381, upload-time = "2026-09-12T15:12:08.823Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/11/2e05bf5a56e57a543ebb8f585074adf09383e99d7b062dac92eab1f4d57f/jiter-0.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8", size = 183610, upload-time = "2026-09-12T15:12:10.374Z" },
+    { url = "https://files.pythonhosted.org/packages/17/31/4bb27f54333d3b9ef1e5bd3312dc0b4bbe59c68bb0885fdb40583a6b1567/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c", size = 288415, upload-time = "2026-09-12T15:14:08.455Z" },
+    { url = "https://files.pythonhosted.org/packages/28/30/879570ecf82574eaea77c5eb10309f4b630dece5f2a556e9814a90ba3f2d/jiter-0.17.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2", size = 279113, upload-time = "2026-09-12T15:14:10.117Z" },
+    { url = "https://files.pythonhosted.org/packages/77/7a/1f0b8a35fbd079a4f1752c31a15dc99cf277f863747c459be0af39e900e5/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5", size = 303708, upload-time = "2026-09-12T15:14:11.445Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/8b/d76219ebdbcf3d4209d9d21a0810db4c8d0a6f88e3ee87d30bdea4e90d30/jiter-0.17.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff", size = 307147, upload-time = "2026-09-12T15:14:12.897Z" },
+]
+
+[[package]]
+name = "jmespath"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
+]
+
+[[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 = "jsonschema"
+version = "4.26.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "attrs" },
+    { name = "jsonschema-specifications" },
+    { name = "referencing" },
+    { name = "rpds-py" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
+]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "referencing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
+]
+
+[[package]]
+name = "langchain"
+version = "0.3.26"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "langchain-text-splitters" },
+    { name = "langsmith" },
+    { name = "pydantic" },
+    { name = "pyyaml" },
+    { name = "requests" },
+    { name = "sqlalchemy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7f/13/a9931800ee42bbe0f8850dd540de14e80dda4945e7ee36e20b5d5964286e/langchain-0.3.26.tar.gz", hash = "sha256:8ff034ee0556d3e45eff1f1e96d0d745ced57858414dba7171c8ebdbeb5580c9", size = 10226808, upload-time = "2025-06-20T22:23:01.174Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f1/f2/c09a2e383283e3af1db669ab037ac05a45814f4b9c472c48dc24c0cef039/langchain-0.3.26-py3-none-any.whl", hash = "sha256:361bb2e61371024a8c473da9f9c55f4ee50f269c5ab43afdb2b1309cb7ac36cf", size = 1012336, upload-time = "2025-06-20T22:22:58.874Z" },
+]
+
+[[package]]
+name = "langchain-core"
+version = "0.3.86"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "jsonpatch" },
+    { name = "langsmith" },
+    { name = "packaging" },
+    { name = "pydantic" },
+    { name = "pyyaml" },
+    { name = "tenacity" },
+    { name = "typing-extensions" },
+    { name = "uuid-utils" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fe/8d/d54586b8f65c6fc209db93916ff9e919e1cc14bad8fe66880ea4d7ea9d6c/langchain_core-0.3.86.tar.gz", hash = "sha256:671cbc96a325fe47f7dbab421236ada2d437bc4bfad0038102264885d0b462e2", size = 603154, upload-time = "2026-05-07T16:48:08.14Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0c/93/ba19ca54701c6118e68f8785949b6c0eab1df3a5cfa5310508cc86877994/langchain_core-0.3.86-py3-none-any.whl", hash = "sha256:7d2a1c50d2d2a139dbc6465cd339f32d14aa43db5ac9bd232e5b567a238709e8", size = 461306, upload-time = "2026-05-07T16:48:06.283Z" },
+]
+
+[[package]]
+name = "langchain-mcp-adapters"
+version = "0.1.11"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "mcp" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/4e/b84af2e379edfb51db78edcfc6eab7dca798f2ce9d74b73e29f5f207685c/langchain_mcp_adapters-0.1.11.tar.gz", hash = "sha256:a217c49086b162344749f7f99a148fc12482e2da8e0260b2e35fc93afb31b38d", size = 23061, upload-time = "2025-10-03T14:53:13.98Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1e/cc/5f9b23cce308b2c30246e31712bf1a53ae49d97bab8b3d9bc9cfe364f82c/langchain_mcp_adapters-0.1.11-py3-none-any.whl", hash = "sha256:7b35921e9487bcb3ea3d94bf10341316ac897e2997e8a16032ae514834a9685d", size = 15751, upload-time = "2025-10-03T14:53:12.358Z" },
+]
+
+[[package]]
+name = "langchain-openai"
+version = "0.3.28"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core" },
+    { name = "openai" },
+    { name = "tiktoken" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6b/1d/90cd764c62d5eb822113d3debc3abe10c8807d2c0af90917bfe09acd6f86/langchain_openai-0.3.28.tar.gz", hash = "sha256:6c669548dbdea325c034ae5ef699710e2abd054c7354fdb3ef7bf909dc739d9e", size = 753951, upload-time = "2025-07-14T10:50:44.076Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/91/56/75f3d84b69b8bdae521a537697375e1241377627c32b78edcae337093502/langchain_openai-0.3.28-py3-none-any.whl", hash = "sha256:4cd6d80a5b2ae471a168017bc01b2e0f01548328d83532400a001623624ede67", size = 70571, upload-time = "2025-07-14T10:50:42.492Z" },
+]
+
+[[package]]
+name = "langchain-text-splitters"
+version = "0.3.11"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "langchain-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/11/43/dcda8fd25f0b19cb2835f2f6bb67f26ad58634f04ac2d8eae00526b0fa55/langchain_text_splitters-0.3.11.tar.gz", hash = "sha256:7a50a04ada9a133bbabb80731df7f6ddac51bc9f1b9cab7fa09304d71d38a6cc", size = 46458, upload-time = "2025-08-31T23:02:58.316Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/58/0d/41a51b40d24ff0384ec4f7ab8dd3dcea8353c05c973836b5e289f1465d4f/langchain_text_splitters-0.3.11-py3-none-any.whl", hash = "sha256:cf079131166a487f1372c8ab5d0bfaa6c0a4291733d9c43a34a16ac9bcd6a393", size = 33845, upload-time = "2025-08-31T23:02:57.195Z" },
+]
+
+[[package]]
+name = "langsmith"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "distro" },
+    { name = "httpx2" },
+    { 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/7d/bb/4f39a8d864618aab01c4fc374a9f579ad6e0f3c91f32cfbd86f0180cb9e1/langsmith-0.14.0.tar.gz", hash = "sha256:294a02addc2358fab390156b74fa942e2dd40b711f6507c5f26b5c208cc89e15", size = 4951467, upload-time = "2026-09-21T18:59:37.964Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/4d/be/d55279f7bfc43763fd479aa526d6f952fa93aaf4d86a40919f78726691cc/langsmith-0.14.0-py3-none-any.whl", hash = "sha256:1a89e52c24edd6f4c24d8cfb32857814ceb5b9bec344a2850e86ab38475de362", size = 805011, upload-time = "2026-09-21T18:59:36.199Z" },
+]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
+    { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
+    { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
+    { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
+]
+
+[[package]]
+name = "mcp"
+version = "1.18.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "httpx" },
+    { name = "httpx-sse" },
+    { name = "jsonschema" },
+    { name = "pydantic" },
+    { name = "pydantic-settings" },
+    { name = "python-multipart" },
+    { name = "pywin32", marker = "sys_platform == 'win32'" },
+    { name = "sse-starlette" },
+    { name = "starlette" },
+    { name = "uvicorn", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1a/e0/fe34ce16ea2bacce489ab859abd1b47ae28b438c3ef60b9c5eee6c02592f/mcp-1.18.0.tar.gz", hash = "sha256:aa278c44b1efc0a297f53b68df865b988e52dd08182d702019edcf33a8e109f6", size = 482926, upload-time = "2025-10-16T19:19:55.125Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1b/44/f5970e3e899803823826283a70b6003afd46f28e082544407e24575eccd3/mcp-1.18.0-py3-none-any.whl", hash = "sha256:42f10c270de18e7892fdf9da259029120b1ea23964ff688248c69db9d72b1d0a", size = 168762, upload-time = "2025-10-16T19:19:53.2Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.9.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" },
+    { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" },
+    { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" },
+    { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" },
+    { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" },
+    { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" },
+    { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" },
+    { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" },
+    { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" },
+    { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" },
+    { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" },
+    { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" },
+    { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" },
+    { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" },
+]
+
+[[package]]
+name = "mysql-connector-python"
+version = "9.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/77/2b45e6460d05b1f1b7a4c8eb79a50440b4417971973bb78c9ef6cad630a6/mysql_connector_python-9.4.0.tar.gz", hash = "sha256:d111360332ae78933daf3d48ff497b70739aa292ab0017791a33e826234e743b", size = 12185532, upload-time = "2025-07-22T08:02:05.788Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/03/7c/a543fb17c2dfa6be8548dfdc5879a0c7924cd5d1c79056c48472bb8fe858/mysql_connector_python-9.4.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:4efa3898a24aba6a4bfdbf7c1f5023c78acca3150d72cc91199cca2ccd22f76f", size = 17503693, upload-time = "2025-07-22T07:58:08.96Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/6e/c22fbee05f5cfd6ba76155b6d45f6261d8d4c1e36e23de04e7f25fbd01a4/mysql_connector_python-9.4.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:665c13e7402235162e5b7a2bfdee5895192121b64ea455c90a81edac6a48ede5", size = 18371987, upload-time = "2025-07-22T07:58:13.273Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/fd/f426f5f35a3d3180c7f84d1f96b4631be2574df94ca1156adab8618b236c/mysql_connector_python-9.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:815aa6cad0f351c1223ef345781a538f2e5e44ef405fdb3851eb322bd9c4ca2b", size = 33516214, upload-time = "2025-07-22T07:58:18.967Z" },
+    { url = "https://files.pythonhosted.org/packages/45/5a/1b053ae80b43cd3ccebc4bb99a98826969b3b0f8adebdcc2530750ad76ed/mysql_connector_python-9.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b3436a2c8c0ec7052932213e8d01882e6eb069dbab33402e685409084b133a1c", size = 33918565, upload-time = "2025-07-22T07:58:25.28Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/69/36b989de675d98ba8ff7d45c96c30c699865c657046f2e32db14e78f13d9/mysql_connector_python-9.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:57b0c224676946b70548c56798d5023f65afa1ba5b8ac9f04a143d27976c7029", size = 16392563, upload-time = "2025-07-22T07:58:29.623Z" },
+    { url = "https://files.pythonhosted.org/packages/36/34/b6165e15fd45a8deb00932d8e7d823de7650270873b4044c4db6688e1d8f/mysql_connector_python-9.4.0-py2.py3-none-any.whl", hash = "sha256:56e679169c704dab279b176fab2a9ee32d2c632a866c0f7cd48a8a1e2cf802c4", size = 406574, upload-time = "2025-07-22T07:59:08.394Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.5.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" },
+    { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" },
+    { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" },
+    { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" },
+    { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" },
+    { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" },
+]
+
+[[package]]
+name = "openai"
+version = "1.109.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "distro" },
+    { name = "httpx" },
+    { name = "jiter" },
+    { name = "pydantic" },
+    { name = "sniffio" },
+    { name = "tqdm" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c6/a1/a303104dc55fc546a3f6914c842d3da471c64eec92043aef8f652eb6c524/openai-1.109.1.tar.gz", hash = "sha256:d173ed8dbca665892a6db099b4a2dfac624f94d20a93f46eb0b56aae940ed869", size = 564133, upload-time = "2025-09-24T13:00:53.075Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1d/2a/7dd3d207ec669cacc1f186fd856a0f61dbc255d24f6fdc1a6715d6051b0f/openai-1.109.1-py3-none-any.whl", hash = "sha256:6bcaf57086cf59159b8e27447e4e7dd019db5d29a438072fbd49c290c7e65315", size = 948627, upload-time = "2025-09-24T13:00:50.754Z" },
+]
+
+[[package]]
+name = "orjson"
+version = "3.12.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" },
+    { url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" },
+    { url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" },
+    { url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" },
+    { url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" },
+    { url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
+]
+
+[[package]]
+name = "pandas"
+version = "3.0.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "numpy" },
+    { name = "python-dateutil" },
+    { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/77/4c/597d588c055d4373cff19cbbc32d4dd046c7be8fadee957585b5ba9e5b24/pandas-3.0.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dac2d65e9087e8e7b5a45fe15c4920911a221df061ab629943ce016489145c7", size = 10397411, upload-time = "2026-09-17T23:20:49.465Z" },
+    { url = "https://files.pythonhosted.org/packages/18/8f/48907c7c707b61a8e5018c32e1a3f70623209bfb59020a2f6196159d3aa7/pandas-3.0.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9dab635a549e58a053c7b0fa054dc0bd7be22f0ed9a720f4a85d5fb993276172", size = 10051279, upload-time = "2026-09-17T23:20:52.409Z" },
+    { url = "https://files.pythonhosted.org/packages/67/fa/613d867c3d9554a61bafdec6f79565c8a3e73235feb52cc4a72ad2e0fa6a/pandas-3.0.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3dccb584123b399c07562ac4d62543e90ede49ddf8ce3c13ffc64cbe828c281", size = 10303429, upload-time = "2026-09-17T23:20:55.597Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/67/0c0f18e38d7f2d2af8c24b3315bc4046e73bbdd4a5540405506671ad0c0d/pandas-3.0.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0704044b676496b8350e023b09f174a26772456c974a2b11c36bebb558c9490d", size = 10788193, upload-time = "2026-09-17T23:20:58.617Z" },
+    { url = "https://files.pythonhosted.org/packages/39/53/1b57f3162501fe36687e4870e1b918a6458ca7386b173af75663ace95857/pandas-3.0.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e7c1905ef02c3d6d43d9dbd5b6ccb4da4870a0b0c821bbc103fbdb6f3ad2707b", size = 11323501, upload-time = "2026-09-17T23:21:01.911Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/d2/b1182e8d39100369d7f13f4a125a3fb6b096fef112c46d0566c25781ff68/pandas-3.0.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:569e114072b24fc4970c12e2b4bab252671668a40b324318903380cab0254c0c", size = 11838173, upload-time = "2026-09-17T23:21:04.85Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/33/5b717af24d2f27995e51e0875a269dddd373045216e34cf62e3aa764eaa1/pandas-3.0.6-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:2a8fc94be2ee5f1d86f97aacd8cc566f81680b6498e76f3007421bb5d98151bf", size = 7118499, upload-time = "2026-09-17T23:21:07.661Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/2a/14b3b17cd75cef4a1ee1af4234eb41cc1afe4103b98c2d80e8916abfd42b/pandas-3.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:3ef908d28590b3f42d7070e7ad8f9b34b442b260b7f3c1afb57e0040c58cdb1b", size = 9661439, upload-time = "2026-09-17T23:21:10.959Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/11/3d580a604a1e35d69f6676847bd12db7d14344bc677b717f4413e79c5d0d/pandas-3.0.6-cp312-cp312-win_arm64.whl", hash = "sha256:f4e7c52eb108d752e7592268108fd3e98efd76d83a3125cdd06c621c2e44359b", size = 8959806, upload-time = "2026-09-17T23:21:13.851Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.5.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" },
+    { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" },
+    { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" },
+    { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" },
+    { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" },
+    { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" },
+    { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" },
+    { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" },
+    { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" },
+    { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" },
+    { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "7.36.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/89/5b8517baa72f84a67b8a307ba953c91057af618bf40bf676f3c03551f8f0/protobuf-7.36.2.tar.gz", hash = "sha256:497d0463ff3316681da6c0b9e8d06cb465d61abce00b613ab42226175644d1bb", size = 512737, upload-time = "2026-09-17T20:07:59.326Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/32/72/98342feb672507c8f3a69e34b4fa8961f608edba5c1a48a6f47156d92cb5/protobuf-7.36.2-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:cbc70b17ee27e28894c7fee8bb04be1abead49e936bc70eb60052531eee2079e", size = 456039, upload-time = "2026-09-17T20:07:51.542Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/ea/91fdf7c2b8bbd49cde056f00a9df6773532987e1c00fe2830b895af95c7e/protobuf-7.36.2-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:e11e1f0180583a2af89db6a2ecd9e8dc40aa6d2988ca175bfd0e6d12ea72d74e", size = 344219, upload-time = "2026-09-17T20:07:52.914Z" },
+    { url = "https://files.pythonhosted.org/packages/17/ab/5fd5f8ece73fad885c5a09aa849b32d70472f954ba3a92d3bb5974ea953b/protobuf-7.36.2-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:f4fee11ec330d238b34a05c9b675f693c20415d1c5bd7d5320cc2f8a798eb9cf", size = 357223, upload-time = "2026-09-17T20:07:53.985Z" },
+    { url = "https://files.pythonhosted.org/packages/db/f3/3996583dd2906297a637af12114deddf7658af6e683fedb83be061983fb5/protobuf-7.36.2-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:89f23aa53c24553a2416fd4fd1ec06f74fa42b14b546d8883128813f775bbfd2", size = 343223, upload-time = "2026-09-17T20:07:54.931Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/1b/dcc64f358fcb51811b58ae40b3d28f820725f116d86487cc20bd4b130701/protobuf-7.36.2-cp310-abi3-win32.whl", hash = "sha256:912c1221170e16c08d1f086762f563dd61ff83c18b5fa6652952dfaded66f728", size = 442998, upload-time = "2026-09-17T20:07:55.826Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/55/b77bda4e5e5f5971fb51b07663694690e9afdb9402136c16a522bd621cad/protobuf-7.36.2-cp310-abi3-win_amd64.whl", hash = "sha256:a300819d441e078a5608c0d3c709796bb548136058fda017ae51d425b44fd353", size = 456514, upload-time = "2026-09-17T20:07:57.188Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/04/d52c7016b04b6c5108f26691f9d33ec82a9b65d041f1a9c771137693d618/protobuf-7.36.2-py3-none-any.whl", hash = "sha256:bdb3a345d48db958e6ce1f18e508beb0cc981d64f24088427549c866cd039f1e", size = 179806, upload-time = "2026-09-17T20:07:58.211Z" },
+]
+
+[[package]]
+name = "pydantic"
+version = "2.13.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "annotated-types" },
+    { name = "pydantic-core" },
+    { name = "typing-extensions" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
+]
+
+[[package]]
+name = "pydantic-core"
+version = "2.46.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
+    { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
+    { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
+    { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
+    { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
+    { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
+    { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
+    { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
+    { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
+    { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
+    { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
+]
+
+[[package]]
+name = "pydantic-settings"
+version = "2.15.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "pydantic" },
+    { name = "python-dotenv" },
+    { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
+]
+
+[[package]]
+name = "pymilvus"
+version = "3.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "cachetools" },
+    { name = "grpcio" },
+    { name = "orjson" },
+    { name = "pandas" },
+    { name = "protobuf" },
+    { name = "python-dotenv" },
+    { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/78/6bd0dba340706bc63346af96f0ebe36ff17f75404f5de935fda94d476c98/pymilvus-3.0.1.tar.gz", hash = "sha256:c02389059088b18d6e598cd175541e445c772fab4926c5e527c4913be34887f1", size = 347593, upload-time = "2026-07-29T14:55:43.061Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/60/9d/7011887b29f452905745e8bd321f404068d5bfe78fe84e42c0b7cd81a065/pymilvus-3.0.1-py3-none-any.whl", hash = "sha256:c5a8d5c1fa1de7b416e3529d383d8cc2e7da2170433ffa4a2d9087e14f70171a", size = 386820, upload-time = "2026-07-29T14:55:44.279Z" },
+]
+
+[[package]]
+name = "python-a2a"
+version = "0.5.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "aiohttp" },
+    { name = "anthropic" },
+    { name = "boto3" },
+    { name = "botocore" },
+    { name = "fastapi" },
+    { name = "flask" },
+    { name = "httpx" },
+    { name = "langchain" },
+    { name = "openai" },
+    { name = "pydantic" },
+    { name = "requests" },
+    { name = "uvicorn" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/95/780470a2773ebfc26236db83479fc2c9ff74c9c3946b6523cdec3e76dcdd/python_a2a-0.5.4.tar.gz", hash = "sha256:e840c80cb5a78a540275aa5260ea3a450958df29d3fd5472134761031c697306", size = 198967, upload-time = "2025-05-06T12:39:24.185Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/54/67/8b48ef8fb9e9b6048a1b9fae4ce510a12e997e00d4ea6fa2e736bccc2745/python_a2a-0.5.4-py3-none-any.whl", hash = "sha256:d6b58b16e78c69b63f178889fa70d983874f4eec5863445891c1c46c18bce638", size = 165556, upload-time = "2025-05-06T12:39:21.871Z" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
+]
+
+[[package]]
+name = "python-multipart"
+version = "0.0.32"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
+]
+
+[[package]]
+name = "pytz"
+version = "2025.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
+]
+
+[[package]]
+name = "pywin32"
+version = "312"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" },
+    { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+    { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+    { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+    { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+    { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+    { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+    { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+]
+
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "attrs" },
+    { name = "rpds-py" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
+]
+
+[[package]]
+name = "regex"
+version = "2026.9.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/5c/f403115361de25809e8f785686ec7096e30fef73be9ae35aa51da4e80abb/regex-2026.9.10.tar.gz", hash = "sha256:1e321e2c84f0e52c457f5ea5944f796d6e8e09cb99738ea98dcc1bfe402a128d", size = 417072, upload-time = "2026-09-09T21:00:21.521Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/32/c8/bfbe893e90ee0148bd2860dd086f09b5d2080ca2b125f740c2e118c16982/regex-2026.9.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880ac684c27176464c00c3fdc456116364f5ebc70da07aad0c2d4a7ba45e98db", size = 496609, upload-time = "2026-09-09T20:57:09.987Z" },
+    { url = "https://files.pythonhosted.org/packages/8c/ac/56d5ae6efb759255c3b3db650a4be25f96a844ea3613b91e1e189a3b7294/regex-2026.9.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9d36b03dc362aa40ffaaec9d9bd75e87763529563ec008c43b0e07782f5be7a", size = 297024, upload-time = "2026-09-09T20:57:11.35Z" },
+    { url = "https://files.pythonhosted.org/packages/60/4b/0f2d5f6bbb791cc10f22f0ed16c487e630dde8fa8fa0bd92a2bfe21a4b20/regex-2026.9.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:866de9f98df0611d7b62b3a8729d3284a64c0cc6edd90bb95a533e443a4939cb", size = 291905, upload-time = "2026-09-09T20:57:13.127Z" },
+    { url = "https://files.pythonhosted.org/packages/89/51/3fb5fe0d32f4cf0bc982286722c729a8d6f522d2fa2d5d14a702d9fc87f8/regex-2026.9.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c66d54042a14a503907d81861b8a5235e6d1f03d4fbc1d8767f652eaf957ac1", size = 800055, upload-time = "2026-09-09T20:57:14.68Z" },
+    { url = "https://files.pythonhosted.org/packages/89/46/ee507bd2f9d4420f26a594b35c551d7194b66f5d7897f63730fae6ec05c1/regex-2026.9.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:032da15431c890d376f53547f0a6219f4f4cd19f3e4f11bdc321453b5bd207e4", size = 871133, upload-time = "2026-09-09T20:57:16.674Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/75/cbaa90689684f91b1bc017e7f8c6d9425c6bd299108db02482dc51376d8a/regex-2026.9.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:23ac9a28180f274d7dd7651fa131ad5b02d343b75df4b040737f0356223895dd", size = 919627, upload-time = "2026-09-09T20:57:18.402Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/f5/dcdf5e0d898024005cfcce631e3e934d111dfbe177ca0b7f253ae8a735a2/regex-2026.9.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e67f8843f0e4b931f1fa860bf3bbe4134b714c0155cc5c7c0d7ea450230aae0", size = 804587, upload-time = "2026-09-09T20:57:19.859Z" },
+    { url = "https://files.pythonhosted.org/packages/73/70/eedfe81c29bae266a06ab4250978361a9bccd474704d88d4f4ef4506dff8/regex-2026.9.10-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bb7774924f8cd69f49cba0b3c2d679a6326f777e0e67d130ad5203e4df53f0d3", size = 777320, upload-time = "2026-09-09T20:57:21.62Z" },
+    { url = "https://files.pythonhosted.org/packages/ca/e5/86b207077efbcd91305700488f170b7eb1e1c54721cea74175273aa3b9a4/regex-2026.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c32480f3371b75068decaf9e5da72c224e953830dd71e36e06cf80e30ea39d8", size = 790572, upload-time = "2026-09-09T20:57:23.048Z" },
+    { url = "https://files.pythonhosted.org/packages/b2/92/f622c3b2323f4c035b98e80221740a442127ad7993135b814f52057430db/regex-2026.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d2d377fd1cad611b806cdd732d86b65f536c768209890cb442556548daa65a23", size = 865485, upload-time = "2026-09-09T20:57:24.658Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/be/34bd621d3d6ac906ad67e57ed56c40cd45f7d51b9c0328335e97a7cb8ecb/regex-2026.9.10-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c014641157e9049b0603b8daa5343bd408d9b757b709aaa0f373cd3fab2d7944", size = 767925, upload-time = "2026-09-09T20:57:26.268Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/28/ddbf7cba86f2adf5038c6c16aa829636ffc6e437f81bb0cbf302899cea5e/regex-2026.9.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1562aabd9d4eb09bd88a62ad97ed06800094b529ac43419e43020b9cefec79b0", size = 858800, upload-time = "2026-09-09T20:57:27.901Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/f1/e8d7656ff3d3bd32e881d32f540b4981c79dee61908d6b790a45966e6895/regex-2026.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2479171edccced52ef02b899558f88ab2c235fe05b93180fdcae1670aacd89e1", size = 791648, upload-time = "2026-09-09T20:57:29.786Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/65/eac1a79115c8475d8ff539602eb54031564d719fdea5a599c4b0a1a26d1b/regex-2026.9.10-cp312-cp312-win32.whl", hash = "sha256:239620b0e0681669367c0e218c8eb2551d9f8fe3b9fccfc8d0003377804e8348", size = 267326, upload-time = "2026-09-09T20:57:31.782Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/d4/4dcd0a05d3e97ca829165df1c39717f899d1d484dac8a6032439f2cb8d6d/regex-2026.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4db7d00c4afbfbb55b8e17b1e371da11418ea9389b030acec63c1fa4c7ad4b86", size = 277933, upload-time = "2026-09-09T20:57:33.648Z" },
+    { url = "https://files.pythonhosted.org/packages/55/f8/22617a80dee28f2451011eae36bc26b3d78c4994ba87b5281d60acf9b6c0/regex-2026.9.10-cp312-cp312-win_arm64.whl", hash = "sha256:c25a754bb81a2edcfc3b65eda50f017d736f818112ed43e8aafd595cb00678ae", size = 277447, upload-time = "2026-09-09T20:57:35.156Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "certifi" },
+    { name = "charset-normalizer" },
+    { name = "idna" },
+    { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+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 = "rpds-py"
+version = "2026.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" },
+    { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" },
+    { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" },
+    { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" },
+    { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" },
+    { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" },
+    { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" },
+    { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" },
+    { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" },
+    { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" },
+]
+
+[[package]]
+name = "s3transfer"
+version = "0.19.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "botocore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/43/35e4d8aa320bffe8287fe8f65f578fa2d2db0a64212f0e710dce58267854/s3transfer-0.19.2.tar.gz", hash = "sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993", size = 165592, upload-time = "2026-07-22T19:30:44.432Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/bc/e7/5c595c75e9f41a44f30e526eda465ea0b4eec93470e074e4a111b253f13a/s3transfer-0.19.2-py3-none-any.whl", hash = "sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25", size = 90216, upload-time = "2026-07-22T19:30:43.251Z" },
+]
+
+[[package]]
+name = "schedule"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0c/91/b525790063015759f34447d4cf9d2ccb52cdee0f1dd6ff8764e863bcb74c/schedule-1.2.2.tar.gz", hash = "sha256:15fe9c75fe5fd9b9627f3f19cc0ef1420508f9f9a46f45cd0769ef75ede5f0b7", size = 26452, upload-time = "2024-06-18T20:03:14.633Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/20/a7/84c96b61fd13205f2cafbe263cdb2745965974bdf3e0078f121dfeca5f02/schedule-1.2.2-py3-none-any.whl", hash = "sha256:5bef4a2a0183abf44046ae0d164cadcac21b1db011bdd8102e4a0c1e91e06a7d", size = 12220, upload-time = "2024-05-25T18:41:59.121Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "smartbiztrip"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+    { name = "fastapi" },
+    { name = "langchain" },
+    { name = "langchain-core" },
+    { name = "langchain-mcp-adapters" },
+    { name = "langchain-openai" },
+    { name = "mcp" },
+    { name = "mysql-connector-python" },
+    { name = "pydantic" },
+    { name = "pymilvus" },
+    { name = "python-a2a" },
+    { name = "python-dotenv" },
+    { name = "pytz" },
+    { name = "requests" },
+    { name = "schedule" },
+    { name = "socksio" },
+    { name = "uvicorn" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "fastapi", specifier = "==0.116.1" },
+    { name = "langchain", specifier = "==0.3.26" },
+    { name = "langchain-core", specifier = "==0.3.86" },
+    { name = "langchain-mcp-adapters", specifier = "==0.1.11" },
+    { name = "langchain-openai", specifier = "==0.3.28" },
+    { name = "mcp", specifier = "==1.18.0" },
+    { name = "mysql-connector-python", specifier = "==9.4.0" },
+    { name = "pydantic", specifier = "==2.13.5" },
+    { name = "pymilvus", specifier = "==3.0.1" },
+    { name = "python-a2a", specifier = "==0.5.4" },
+    { name = "python-dotenv", specifier = "==1.2.3" },
+    { name = "pytz", specifier = "==2025.2" },
+    { name = "requests", specifier = "==2.34.2" },
+    { name = "schedule", specifier = "==1.2.2" },
+    { name = "socksio", specifier = ">=1.0.0" },
+    { name = "uvicorn", specifier = "==0.35.0" },
+]
+
+[[package]]
+name = "sniffio"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
+]
+
+[[package]]
+name = "socksio"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/5c/48a7d9495be3d1c651198fd99dbb6ce190e2274d0f28b9051307bdec6b85/socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac", size = 19055, upload-time = "2020-04-17T15:50:34.664Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/37/c3/6eeb6034408dac0fa653d126c9204ade96b819c936e136c5e8a6897eee9c/socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3", size = 12763, upload-time = "2020-04-17T15:50:31.878Z" },
+]
+
+[[package]]
+name = "sqlalchemy"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ba/29/8de56a7f78fb0d71fe4f91ee43401a24ba8777e6d4f72683f268c9e0c72e/sqlalchemy-2.1.0.tar.gz", hash = "sha256:37600e4641c435a7e4549f4e6df79d9dfc096bb9712a10dba8925921bc87bc3c", size = 10502427, upload-time = "2026-09-24T20:12:49.737Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/db/f4/2fc46a1739a0ea1f653e1c1cb0e0a37e43cb9a5d27d609e72105a31b64e9/sqlalchemy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:713dba4aae624bf7a903e35ee653af713629098ff05c99dd41668d72b2e5abda", size = 2458587, upload-time = "2026-09-24T20:36:03.089Z" },
+    { url = "https://files.pythonhosted.org/packages/06/76/e25ff72ea35d99c0d357877cb368f7a7c12313e5b4cf49c3f67cc0581569/sqlalchemy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a394b67ae3723cc97cf83d1e72770f11584fe4b55b108a29211bfb873d5eab6d", size = 4653124, upload-time = "2026-09-24T20:45:07.024Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/d8/e9562c41c0efc9a8b9d0e44f97b08d8d88d1716891453b4d5c83ef1239b6/sqlalchemy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83f680cd3e4f54215c1a46271530f7869ae878763b61096c497bf82964abd17b", size = 4701916, upload-time = "2026-09-24T20:48:16.875Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/2b/b148492f670cb343a0c165335c04333de4aa3dd00f43eba9d80aea883590/sqlalchemy-2.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:73e513e8e878e9f20b8707b84f6dff6646d8eed62a7783f9df968e1b373d244f", size = 4351858, upload-time = "2026-09-24T21:07:57.657Z" },
+    { url = "https://files.pythonhosted.org/packages/fc/db/fe5f85872967ec8b234fac64c62cd3affa662081f60b609672dac849198e/sqlalchemy-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e1726e9da236fcf838cb8dbfb4e72d17a1ed93956b211c52e40c33f18852f663", size = 4577009, upload-time = "2026-09-24T20:45:09.469Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/01/774d813bb5682dfc5e5403739ae3ab86e55cfaf328e93b5154f95202f278/sqlalchemy-2.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ba44748f6f22acfee9a7e4444d5fb2901e3b914ad23d6f9ca1104349e926ea92", size = 4346268, upload-time = "2026-09-24T21:07:59.812Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/4d/b7ebe30507ca5e0b0f561c36b111ce30f211f036667d8de4c5a252d97127/sqlalchemy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c7fcb399c088a082b5d9187db1468bb71514ba5389809a57da7229d51613b6f", size = 4650657, upload-time = "2026-09-24T20:48:18.927Z" },
+    { url = "https://files.pythonhosted.org/packages/99/39/232e7853085158edcceda128e05c0e3a7570be606f4bde270247281c945f/sqlalchemy-2.1.0-cp312-cp312-win32.whl", hash = "sha256:ba8462d6e178af73a24c5eaea20fa325ac24c2941c6367ef59d5ccb20d6b9169", size = 2369272, upload-time = "2026-09-24T20:36:43.329Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/48/512fdd8a9c2078abc8378195051d7a1d7c2f15545c453138052030b80534/sqlalchemy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:46c7fcf4b3aac35947572c9598a35e101c2c8e01999ada5c7097a7666dd0ccf2", size = 2421594, upload-time = "2026-09-24T20:36:44.815Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/06/e274c2fa3d64e61bd55ab5de254ad31b07b9bfe108f33c24ff9de2465df1/sqlalchemy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:b87ed75fd74f49da2d637348ff2ef4453e15a8b3e798b06f52c5e57357fa49da", size = 2381171, upload-time = "2026-09-24T20:27:15.52Z" },
+    { url = "https://files.pythonhosted.org/packages/25/8a/9178cd9411c57f16a1e73b0a75bca9270cd471aafdccae2dd8b0b491c9bd/sqlalchemy-2.1.0-py3-none-any.whl", hash = "sha256:8728cbc7544d05c5cab9bf50749636f128e4db4828a20ce5952355c4f33d6e2e", size = 2047583, upload-time = "2026-09-24T20:36:44.055Z" },
+]
+
+[[package]]
+name = "sse-starlette"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/db/3c/fa6517610dc641262b77cc7bf994ecd17465812c1b0585fe33e11be758ab/sse_starlette-3.0.3.tar.gz", hash = "sha256:88cfb08747e16200ea990c8ca876b03910a23b547ab3bd764c0d8eb81019b971", size = 21943, upload-time = "2025-10-30T18:44:20.117Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/23/a0/984525d19ca5c8a6c33911a0c164b11490dd0f90ff7fd689f704f84e9a11/sse_starlette-3.0.3-py3-none-any.whl", hash = "sha256:af5bf5a6f3933df1d9c7f8539633dc8444ca6a97ab2e2a7cd3b6e431ac03a431", size = 11765, upload-time = "2025-10-30T18:44:18.834Z" },
+]
+
+[[package]]
+name = "starlette"
+version = "0.47.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "anyio" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" },
+]
+
+[[package]]
+name = "tenacity"
+version = "9.1.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
+]
+
+[[package]]
+name = "tiktoken"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "regex" },
+    { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/62/167a842aa0429d45f5e797354fd4343a96f6043d67d0513c675c7b8d36e6/tiktoken-0.14.0.tar.gz", hash = "sha256:231dec90efcdccf1b565a1416107736f1e09b1a08fe736ef9d6363e626d03874", size = 38898, upload-time = "2026-08-17T19:49:49.514Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/8c/da/e273746b9d24a63c776bc60fba914351573ad9c575b52601eb5e60632564/tiktoken-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8e947aefe98ef74cce94923f90e48c98fe34eb1ec0a6bfdfadfc5a96359bfc36", size = 1094408, upload-time = "2026-08-17T19:48:49.269Z" },
+    { url = "https://files.pythonhosted.org/packages/69/9f/fe6b1aca23331aa5271df5a4bd07bf68a7059254d47faee1b8272592a777/tiktoken-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cebe67765569df3dafac8474e4eccf5c19d24140492567a5e58a11445732a4", size = 1038499, upload-time = "2026-08-17T19:48:50.666Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/35/e9f47647c9e163bd1de30fe1a491669b7248cfc67b7404c35c009a701e1a/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:7db45b98e94adf4173a5cd7422b150999a7ee11ff847783a14f6e1b80cc38cb6", size = 1186355, upload-time = "2026-08-17T19:48:51.93Z" },
+    { url = "https://files.pythonhosted.org/packages/51/11/9976ad86980a00cdef05e730a0127a2578a1bc6d11644d8d47246de2eb26/tiktoken-0.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7896eea257fe497a2b7134474d909156c6744ce8da35bce88011a960e008aa0d", size = 1204197, upload-time = "2026-08-17T19:48:53.18Z" },
+    { url = "https://files.pythonhosted.org/packages/d4/9c/7035b0bcfaa68d1ee4803fc5be5214ad865669b05bd20e7105ae8a18afc6/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b950248272f1b303dc32986396e2dccfa10cf6d1e83ec8f0bba1776660305482", size = 1250635, upload-time = "2026-08-17T19:48:54.392Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/1d/69cabf18bed7f4366da076735816abce0d4db3fae491ae338a6612128777/tiktoken-0.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3de75343041a1c57333b1e707ac8a9769738241d7d6a55d39e12cf84548337c6", size = 1316085, upload-time = "2026-08-17T19:48:55.525Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/bd/a2e884fb1402cba5be08836590320012b2d8ada0e2eef9911a64df4bcd2d/tiktoken-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:087538c080e5ff421abd3a0785ed63c5111d06af98e6cd0d374dbe5969147ca3", size = 941208, upload-time = "2026-08-17T19:48:56.938Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.70.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/0d/ea/b2a5bd54b28a324dae8211928b2d730b6547500342c7e6c6dea08bd0a485/tqdm-4.70.1.tar.gz", hash = "sha256:cefd0eca11b2a37a3aee776544d4f4ae913f02688135b5556b8788dfa474afc4", size = 171846, upload-time = "2026-09-11T07:25:16.601Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a7/03/921a3d3c75785aca9ebfbfcabfbc3a1be12e2ab5265deb026d55a5a3f83e/tqdm-4.70.1-py3-none-any.whl", hash = "sha256:c293e525e6fef9c20e8728fd4612df02a0aa31bb5fe91ecd93e123b1b7bffa73", size = 80199, upload-time = "2026-09-11T07:25:14.599Z" },
+]
+
+[[package]]
+name = "truststore"
+version = "0.10.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.16.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
+]
+
+[[package]]
+name = "typing-inspection"
+version = "0.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
+]
+
+[[package]]
+name = "tzdata"
+version = "2026.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
+]
+
+[[package]]
+name = "uuid-utils"
+version = "0.17.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/4c/80/cf6934a2030a5f6763f604314c1105f851d90aa1fe344c2692c3b88a9d95/uuid_utils-0.17.1.tar.gz", hash = "sha256:10c51d54ecdf0617640e505eae6d2e6443d8e414d4f9d6e8d43949a450c56e6b", size = 43323, upload-time = "2026-09-08T11:29:35.42Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/97/82/a509ef8b3c24b48099a4fba00f9e1b3f38417e0a7bda39cefd4b1e841388/uuid_utils-0.17.1-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7558c84414785d6ea54a186a3c68267eb8f33e2a0d792698bd9fa50d62150f77", size = 557142, upload-time = "2026-09-08T11:28:08.586Z" },
+    { url = "https://files.pythonhosted.org/packages/27/60/d48897c1bb6562c4b68a90c998f89d4eb8996d01193c930b3f6f04258619/uuid_utils-0.17.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:4f04ba62482858a975d1d38cbb5ade181faae59e8ddc1499eacc9b6b6def3115", size = 287053, upload-time = "2026-09-08T11:28:10.12Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/9d/6506e6c4ca08300ad7415c3b76e29bd36b491dec818ea58d9e8bf88031d3/uuid_utils-0.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:66ce3e84067261b2721dde6539427f4eb9286289769332ff07c859a661f87694", size = 320760, upload-time = "2026-09-08T11:28:11.3Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/dd/013786821eca0808282b82bb1e66a18a6b70061e1ce2f77fc9be06b88a36/uuid_utils-0.17.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cdcb565ac4d8a005833e7c389867977e19670f1bee6ee9f8811872b86225274", size = 329130, upload-time = "2026-09-08T11:28:12.522Z" },
+    { url = "https://files.pythonhosted.org/packages/60/45/ba376e0a69eb4a0bb2dfba1ad2bafc2e6729971d480701cc778719a4205d/uuid_utils-0.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7a93b046552730b843e9d8784691cf8b0b3d5430dd9d053ade33383ccb2574ff", size = 444047, upload-time = "2026-09-08T11:28:13.938Z" },
+    { url = "https://files.pythonhosted.org/packages/02/1e/2abb7d9e3062a7889142818e2a2ae758a54a1c3b6cf8a86877e051b3d1dc/uuid_utils-0.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d45f93f362d39f63ba14bbd9e4e1dd89fbed4de9bbef6bb428a379bd86571da2", size = 323927, upload-time = "2026-09-08T11:28:15.235Z" },
+    { url = "https://files.pythonhosted.org/packages/20/00/c6753d6f2dbcd9d78436e291534b5d81f42c1b50c4d3224ddf231095fde2/uuid_utils-0.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d847fb9b46b5c208f3b8d5ca6082a4ac819ec2ffe36de3548c489e8fa551bd", size = 345877, upload-time = "2026-09-08T11:28:16.62Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/78/6f65d3105a0588b38cdcf338397bcb63c2d88d3581698a316def47e4445e/uuid_utils-0.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71f7ec7a1b54c1f84d6f8fd4e1110a66b3d1b936dbb17cda2927442621fc1e3d", size = 499635, upload-time = "2026-09-08T11:28:17.912Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/5b/ff56d55fde9991c332e6df2ac1c560be6dd7e2302ab51180a185cfad830d/uuid_utils-0.17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a140db8866a45b1ce40d91164ff48d2722ffc3b2a39bfdb412bd770c7ea88be3", size = 605502, upload-time = "2026-09-08T11:28:19.198Z" },
+    { url = "https://files.pythonhosted.org/packages/09/16/d34f26cd4dad48671c7441fcf370076911dd9e7edcb3e5264e11da7fce7d/uuid_utils-0.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7b296b67bf880085daa11ee496d385b941a3bf3e3d5691db7178d21f18f56f9b", size = 563186, upload-time = "2026-09-08T11:28:20.684Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/ef/b9bd9b412865c08e6769044cdda6e0a3db4827c88a4a2612e42e68714ca2/uuid_utils-0.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:61378863a5e9410fa6e816364442b9d35cccf94fa83e3f67997d0ff57d901d0e", size = 528667, upload-time = "2026-09-08T11:28:21.984Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/b6/981a84e9d0653e2d5fc205ba2b895d80c8e3bdb7138995f4c04b7cce8bd8/uuid_utils-0.17.1-cp312-cp312-win32.whl", hash = "sha256:a550b3963960012ff3266bdb3079abf4cc3f6363201e63a649d5d3cd44fe52cd", size = 169155, upload-time = "2026-09-08T11:28:23.427Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/49/85a39cef2de6948364d25f5e1452119e6a70ddb5a910eeac717f02c5eabf/uuid_utils-0.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:3d6ccaebaa3b2ff2e59d11d70c64e97ba572120162a00c25d6f8e5e0c1004b9a", size = 175220, upload-time = "2026-09-08T11:28:24.582Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/f6/8c1c11bbf7cfa901c7c9621bcdcbb4bf432c5f20dce27e76a3ea29f0307a/uuid_utils-0.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:3ca89347a01ddac94727578369feacc0969d9fc033b2c17ff7919121ee441e2f", size = 172646, upload-time = "2026-09-08T11:28:25.723Z" },
+]
+
+[[package]]
+name = "uvicorn"
+version = "0.35.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "click" },
+    { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
+]
+
+[[package]]
+name = "websockets"
+version = "17.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" },
+    { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" },
+    { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" },
+    { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" },
+    { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" },
+    { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" },
+    { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" },
+    { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" },
+    { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" },
+    { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" },
+    { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" },
+    { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" },
+]
+
+[[package]]
+name = "werkzeug"
+version = "3.1.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
+]
+
+[[package]]
+name = "xxhash"
+version = "4.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f6/a5/1386f35da1475fcaeef42581deae73417c6d2a6a0b2d2e8914de18844dcd/xxhash-4.0.1.tar.gz", hash = "sha256:d55bf4ef10eb09b8b6866790e083d26d087d84caa3cc0946ba87c3ca7ecaf7b7", size = 101513, upload-time = "2026-08-17T08:24:08.557Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/26/6c/dc7cffeadd06336cd934947187cd38abb263103bbc552ca0f55fe4ff595a/xxhash-4.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1ee523f51718e41753f04f7102bb4dc55a18d2ea5cbaceef8ec7ca08571bd428", size = 38444, upload-time = "2026-08-17T08:21:54.332Z" },
+    { url = "https://files.pythonhosted.org/packages/75/c9/cf736f6db8c3273af18925061572db0d4357818a9ce425f4b5fb0021918e/xxhash-4.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:515a822c73abbf6a0b7c70976d9662be342835c9d78b8dc7c023411f39c35dbc", size = 36195, upload-time = "2026-08-17T08:35:13.004Z" },
+    { url = "https://files.pythonhosted.org/packages/da/a2/ca1929354b6851529d0148f7f335b5e2b0281f83bab3e19f0896dc579796/xxhash-4.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f5d031f35962e5483a613214e61f09fe24ab523062c3646d592dc16c4a217451", size = 253113, upload-time = "2026-08-17T08:20:52.152Z" },
+    { url = "https://files.pythonhosted.org/packages/de/bb/542005206af59518bc8d78a210f1e0172217bc53beb32f64a5b632e72b6b/xxhash-4.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0264844a09b538c894e5eff25313d941deb4dedec2131b98418a71a3c9944e", size = 276525, upload-time = "2026-08-17T08:21:01.886Z" },
+    { url = "https://files.pythonhosted.org/packages/1b/df/607cff25dcb0f1d35c3b04493f6ad8471edb03fd4eacbdcc5ceddef1f3e9/xxhash-4.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1642907941ee4b75aacc3db688af52ea02ca2305ab22af7ee686ed726b332684", size = 297703, upload-time = "2026-08-17T08:21:57.958Z" },
+    { url = "https://files.pythonhosted.org/packages/15/ba/9d2275eea0b9d9c6b02921be23f7588356c60df95c763b25f0e045894d43/xxhash-4.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4af350bc3f329970c0e3a59af84a8a30998bf8a9167eb50cd48e59baaa1d7bec", size = 280252, upload-time = "2026-08-17T08:20:47.299Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/aa/2299d9f6369e550aef2abb64945e39daa34412725aa46a20d99b74d76f67/xxhash-4.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8ba782ca3bf1e81492611152b9a0d5264971339e95e34d69de0ac2c926be496d", size = 511041, upload-time = "2026-08-17T08:20:36.771Z" },
+    { url = "https://files.pythonhosted.org/packages/83/97/31bd8b8279e6935a0719f6910ced15e9d5a2cd554b253f6027ce1b5a1c2c/xxhash-4.0.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:237b8f63a2a0fcfb1ffc06e21dad23add44e6d354b2b014364a1d41e419a4dee", size = 261812, upload-time = "2026-08-17T08:22:00.469Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/c1/d180a2da23c105d8e0b02d54f9f5841013fc81c233010ec781e31f1aee4c/xxhash-4.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:81507a68ba84c55241fb61cce1469f473a5da4205fc8ef6f698e5948eea8dd88", size = 339878, upload-time = "2026-08-17T08:35:17.626Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/3d/f584cd3172fe934f0f5a0a3917d0d7ce781f74d794fd43bb72be71c3ef6f/xxhash-4.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1ea31d61bcd2cd2f3ec4ca80a64187bbd7948f490b63cf0dcbc6e717b4c1e9", size = 272871, upload-time = "2026-08-17T08:20:56.067Z" },
+    { url = "https://files.pythonhosted.org/packages/34/50/2c7956b2b551682e00b9aebce9ceb0a991a131d65f9850c09f5f9760be2e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:06713a5aaf1d0905c5579416c020c02e42b3ceb931e86c7d3b7fb85403dee3f3", size = 301440, upload-time = "2026-08-17T08:21:35.911Z" },
+    { url = "https://files.pythonhosted.org/packages/eb/a2/0739f6482184a8026f4b022718f5f815d352059312e80696825433f0a8e7/xxhash-4.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8cda075b10bb3917b002c74a04f9e02b7d13b5bf732571404d51c52b11c7329", size = 260157, upload-time = "2026-08-17T08:22:01.416Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/25/b31a7bcf1d7d116842812e54f9b944843b4236ea4fa85634e8259f342212/xxhash-4.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c10b9206753b64aa791b35b201485477525b26fdec5bf86e8364c388a03e2592", size = 278233, upload-time = "2026-08-17T08:21:15.674Z" },
+    { url = "https://files.pythonhosted.org/packages/db/e8/5293bae090fc6119dbc5fcf5c4cc0e1536394b52d73b7904d033836c73db/xxhash-4.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f3e1a44af01b6692de0ec6caba5f0bf93ceb36896e02b7fc00952c6ea7ef39e1", size = 330270, upload-time = "2026-08-17T08:20:51.128Z" },
+    { url = "https://files.pythonhosted.org/packages/72/9e/e2ab12d40921f3f34c9317637d65e011aeababf8288356ea8d527de2c1d0/xxhash-4.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6fc415b5568bd9accc7187f1729a99707330c0a67a8b9f93c1149ed573ed75d", size = 478555, upload-time = "2026-08-17T08:22:04.183Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/32/c6148d39a49efa95f39b4cf0d41ef35a487f3b30f6fb1fc8fe8d8eab577e/xxhash-4.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96d8de55029d42251945531f6aa7590c32b48163c66a43bf29d8657d7446a377", size = 258174, upload-time = "2026-08-17T08:35:21.18Z" },
+    { url = "https://files.pythonhosted.org/packages/8f/fb/0b04b68d6c5bc71c7a2c344f1287327b67e607f28fbcfd937697caca64b6/xxhash-4.0.1-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:0163b5d259de23ae9e07b7eabf435ce4704f6f205589a2b154e6af4be985ce1b", size = 20767, upload-time = "2026-08-17T08:21:00.806Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/be/476092aba34d1fcd313e1613a3bb3bc692f253d167b54bc90049043b5034/xxhash-4.0.1-cp312-cp312-win32.whl", hash = "sha256:1216f7ba5683f17a89eb7dcb4bc50a0b743dfe1902278d7b3d0786f538118433", size = 34669, upload-time = "2026-08-17T08:21:49.486Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/02/f9413d94fae43cec6d1a74c4f12156c6f4a7f5fd50e1d34defebdee3dec9/xxhash-4.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c2d525a3afabcd8e3549d85fc7e111fde6bc302d06a1893fe73adb79823415e", size = 37073, upload-time = "2026-08-17T08:22:04.886Z" },
+    { url = "https://files.pythonhosted.org/packages/c1/83/6fe93c1b95acf962bc61a246df09dc2dcce895ccfc1080c9f48d0b652b92/xxhash-4.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:86b2b12bec60c678ed8f5cca0258ad93a8928ebddb6ca7732f0875afe1451d1a", size = 33299, upload-time = "2026-08-17T08:35:12.708Z" },
+    { url = "https://files.pythonhosted.org/packages/86/79/9127ff42a887a348dc4ce3211cf1a962836887adee6f57078132bfba78b4/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:ff48915bf1871a1f19f74c11834c6329443d306cedc0c05fe7fe617810422a80", size = 31836, upload-time = "2026-08-17T08:36:28.261Z" },
+    { url = "https://files.pythonhosted.org/packages/0a/e6/f238693bfdd642adb59c99683964d46d9947fe721ff44d3bd850ae675407/xxhash-4.0.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a76345f5aceb4ec404918edf9c7f2b5507db864dc0d7455982009ac0890b57b", size = 34453, upload-time = "2026-08-17T08:23:49.795Z" },
+    { url = "https://files.pythonhosted.org/packages/40/4b/796ace33cdfb75c91ba6d11615c3bd436355b9f3103e05865bbee9abce57/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d86f9e81f3e84e00131ac7c54caf5119ae4ddd82c09c31cff597c813ce1ee2", size = 38488, upload-time = "2026-08-17T08:23:59.901Z" },
+    { url = "https://files.pythonhosted.org/packages/ad/23/2d549e5d5d7759eaf9ac2d2d2ab81ff60f1bb2b52cdaae8e5ec5c6524354/xxhash-4.0.1-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deca2a30d983d240b8375ec2ee0a4288e72042827fc61df2f7671f8467e4cb2f", size = 38206, upload-time = "2026-08-17T08:36:32.193Z" },
+    { url = "https://files.pythonhosted.org/packages/79/98/1ee576b27f78e6107ee4ea8ac03e8a52888dff256e57d560f8282c195563/xxhash-4.0.1-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:7c343ee174d417a44d0c3355602c0cbbfa52a04d1bbbf1723378c7d2c8f60626", size = 37127, upload-time = "2026-08-17T08:23:42.705Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.25.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "idna" },
+    { name = "multidict" },
+    { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" },
+    { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" },
+    { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" },
+    { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" },
+    { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" },
+    { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" },
+    { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" },
+    { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" },
+    { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" },
+    { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" },
+    { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" },
+    { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" },
+]
+
+[[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/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" },
+]

+ 30 - 0
学生版检查说明.txt

@@ -0,0 +1,30 @@
+学生版检查说明(2026-10-09)
+
+一、分发范围
+本目录保留项目主体、中文注释、概念示例、课件片段、SQL 样例和依赖锁文件。
+未包含个人 .env / .env.mysql、.venv、日志、Python 缓存、编辑器配置、旧备份压缩包。
+.env.example 与 .env.mysql.example 中的密钥、密码均为占位内容,学生需自行填写。
+
+二、已做的小修正
+1. 测试脚本不再硬编码天气 API Key 或账号专属 Host,改为读取配置;导入脚本不会自动请求接口。
+2. 调整项目导入路径,使副本不依赖外层目录必须叫 SmartBizTrip;相关课件和测试的导入同步修正。
+3. 修正天气时区名称、更新时间的时区处理,以及配置演示入口调用不存在方法的问题。
+4. 意图提示词接收当前日期;票务助手从 Task 中提取文本后再交给模型。
+5. 天气 API 模式启动时不连接 MySQL;天气请求增加超时和 HTTP 状态检查。
+6. 会务套餐城市过滤值进行引号转义;向量接口检查 HTTP 状态。
+7. 网页偏好值转义引号,阻止请求未完成时重复提交,并检查聊天 HTTP 失败状态。
+8. 需要数据库或在线模型的测试默认跳过;新增隔离网络的离线测试入口。
+9. 对齐课件中的聊天模型/天气配置变量名,修正课件测试引用已不存在的票务函数。
+10. 补充 README、忽略规则和回归测试;未升级依赖,未改变项目主体架构。
+
+三、验证结果
+项目测试:51 项,其中 30 项通过,21 项需要外部服务的测试跳过。
+课件测试:24 项,其中 13 项通过,11 项需要外部服务的测试跳过。
+全部 112 个 Python 文件通过语法检查;课件编号文件仍可能是需按章节组合的代码片段。
+前端 JavaScript 语法及引号转义、重复发送、HTTP 失败和按钮恢复检查通过。
+分发文件已检查已知私有凭据残留、常见密钥形状、本机个人路径和不应分发的文件。
+
+四、验证边界
+本次使用现有 Python 环境完成离线检查,没有调用真实模型/天气 API,没有启动或重置数据库。
+未证明外部服务联调成功;学生需按 README 配置自己的凭据和数据服务。
+预订工具仍是模拟演示,未新增实际订单、支付、出票或库存扣减功能。

+ 66 - 0
课件示例/项目实现/02-02.py

@@ -0,0 +1,66 @@
+import os
+from dotenv import load_dotenv
+
+load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))
+
+# 项目根目录
+project_root = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..')
+
+# 配置读取学生已准备好的参数。
+
+#定义配置文件
+class Config:
+
+    def __init__(self):
+        # 大模型配置
+        self.base_url = os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
+        self.api_key = os.getenv("DEEPSEEK_API_KEY")
+        self.model_name = os.getenv("DEEPSEEK_MODEL", "deepseek-v4-flash")
+
+        # 数据库配置
+        self.host = os.getenv("MYSQL_HOST", "localhost")
+        self.user = os.getenv("MYSQL_USER", "smart_yoyage")
+        self.password = os.getenv("MYSQL_PASSWORD", "")
+        self.database = 'biztrip_rag'
+
+        # 日志配置
+        self.log_file = os.path.join(os.path.dirname(__file__), 'logs', 'app.log')
+
+        # 票务查询的12306接口地址
+        self.url_123 = ""
+
+        self.intent = {
+            "weather": "WeatherQueryAssistant",
+            "flight": "TicketAssistant",
+            "train": "TicketAssistant",
+            "event": "TicketAssistant",
+            "order": "TicketAssistant",
+            "car_rental": "TripAssistant",
+            "meeting_package": "TripAssistant",
+            "insurance": "TripAssistant",
+            "trip_order": "TripAssistant",
+        }
+
+        self.temperature = 0.1
+
+        # 天气数据源配置
+        # 可选值:"database"(从数据库获取) / "api"(直接从和风API获取)
+        self.weather_source = os.getenv("WEATHER_SOURCE", "database")
+        self.weather_api_key = os.getenv("QWEATHER_API_KEY", "")
+        self.weather_api_host = os.getenv("QWEATHER_API_HOST", "")
+        self.weather_base_url = f"https://{self.weather_api_host}/v7/weather/3d"
+        self.weather_city_codes = {"北京": "101010100", "成都": "101270101"}
+        self.weather_timezone = "Asia/Shanghai"
+        self.weather_schedule_time = "01:00"
+
+        # 向量接口使用独立密钥,不使用对话模型的密钥。
+        self.milvus_host = os.getenv("MILVUS_HOST", "localhost")
+        self.milvus_port = int(os.getenv("MILVUS_PORT", "19530"))
+        self.meeting_package_collection = "meeting_packages"
+        self.embedding_url = "https://dashscope.aliyuncs.com/compatible-mode/v1/embeddings"
+        self.embedding_api_key = os.getenv("DASHSCOPE_API_KEY", "")
+        self.embedding_dim = 1024
+
+
+if __name__ == '__main__':
+    print(Config().log_file)

+ 22 - 0
课件示例/项目实现/02-17.py

@@ -0,0 +1,22 @@
+import requests
+import mysql.connector
+from datetime import datetime
+import schedule
+import time
+import json
+import gzip
+import pytz
+
+from config import Config
+
+conf = Config()
+API_KEY = conf.weather_api_key
+city_codes = conf.weather_city_codes
+BASE_URL = conf.weather_base_url
+TZ = pytz.timezone('Asia/Shanghai')
+
+db_config = {
+    "host": conf.host, "user": conf.user,
+    "password": conf.password, "database": conf.database,
+    "charset": "utf8mb4"
+}

+ 17 - 0
课件示例/项目实现/02-20.py

@@ -0,0 +1,17 @@
+def fetch_weather_data(city, location):
+    headers = {
+        "X-QW-Api-Key": API_KEY,
+        "Accept-Encoding": "gzip"
+    }
+    url = f"{BASE_URL}?location={location}"
+    try:
+        response = requests.get(url, headers=headers, timeout=10)
+        response.raise_for_status()
+        # requests 已自动解压响应,不要再次 gzip.decompress。
+        return response.json()
+    except requests.RequestException as e:
+        print(f"请求 {city} 天气数据失败: {e}")
+        return None
+    except json.JSONDecodeError as e:
+        print(f"{city} JSON 解析错误: {e}, 响应内容: {response.text[:500]}...")
+        return None

+ 19 - 0
课件示例/项目实现/02-30.py

@@ -0,0 +1,19 @@
+from datetime import datetime, timedelta
+import time
+import schedule
+
+now = datetime.now()
+trigger_time = (now + timedelta(seconds=20)).strftime("%H:%M:%S")
+
+print(f"[测试日志] 当前时间: {now}")
+print(f"[测试日志] 设置任务在 {trigger_time} 触发 update_weather")
+
+# 使用 lambda 延迟执行
+schedule.every().day.at(trigger_time).do(lambda: print("任务已触发!"))
+
+# 运行 60 秒以观察任务触发
+end_time = now + timedelta(seconds=60)
+while datetime.now() < end_time:
+    schedule.run_pending()
+    print(f"[测试日志] 检查待执行任务: {datetime.now()}")
+    time.sleep(1)

+ 183 - 0
课件示例/项目实现/02-31.py

@@ -0,0 +1,183 @@
+import requests
+import mysql.connector
+from datetime import datetime
+import schedule
+import time
+import json
+import gzip
+import pytz
+
+from config import Config
+
+conf = Config()
+API_KEY = conf.weather_api_key
+city_codes = conf.weather_city_codes
+BASE_URL = conf.weather_base_url
+TZ = pytz.timezone('Asia/Shanghai')
+
+db_config = {
+    "host": conf.host, "user": conf.user,
+    "password": conf.password, "database": conf.database,
+    "charset": "utf8mb4"
+}
+
+def connect_db():
+    return mysql.connector.connect(**db_config)
+
+def fetch_weather_data(city, location):
+    headers = {
+        "X-QW-Api-Key": API_KEY,
+        "Accept-Encoding": "gzip"
+    }
+    url = f"{BASE_URL}?location={location}"
+    try:
+        response = requests.get(url, headers=headers, timeout=10)
+        response.raise_for_status()
+        # requests 已自动解压响应,不要再次 gzip.decompress。
+        return response.json()
+    except requests.RequestException as e:
+        print(f"请求 {city} 天气数据失败: {e}")
+        return None
+    except json.JSONDecodeError as e:
+        print(f"{city} JSON 解析错误: {e}, 响应内容: {response.text[:500]}...")
+        return None
+
+def get_latest_update_time(cursor, city):
+    cursor.execute("SELECT MAX(update_time) FROM weather_data WHERE city = %s", (city,))
+    result = cursor.fetchone()
+    return result[0] if result[0] else None
+
+def should_update_data(latest_time, force_update=False):
+    if force_update:
+        return True
+    if not latest_time:
+        return True
+    current_time = datetime.now(TZ)
+    latest_time = latest_time.replace(tzinfo=TZ)
+    return (current_time - latest_time).total_seconds() / 3600 >= 24
+
+def store_weather_data(conn, cursor, city, data):
+    if not data or data.get("code") != "200":
+        print(f"{city} 数据无效,跳过存储。")
+        return
+
+    daily_data = data.get("daily", [])
+    update_time = datetime.fromisoformat(data.get("updateTime").replace("+08:00", "+08:00")).replace(tzinfo=TZ)
+
+    for day in daily_data:
+        fx_date = datetime.strptime(day["fxDate"], "%Y-%m-%d").date()
+        values = (
+            city, fx_date,
+            day.get("sunrise"), day.get("sunset"),
+            day.get("moonrise"), day.get("moonset"),
+            day.get("moonPhase"), day.get("moonPhaseIcon"),
+            day.get("tempMax"), day.get("tempMin"),
+            day.get("iconDay"), day.get("textDay"),
+            day.get("iconNight"), day.get("textNight"),
+            day.get("wind360Day"), day.get("windDirDay"), day.get("windScaleDay"), day.get("windSpeedDay"),
+            day.get("wind360Night"), day.get("windDirNight"), day.get("windScaleNight"), day.get("windSpeedNight"),
+            day.get("precip"), day.get("uvIndex"),
+            day.get("humidity"), day.get("pressure"),
+            day.get("vis"), day.get("cloud"),
+            update_time
+        )
+        insert_query = """
+        INSERT INTO weather_data (
+            city, fx_date, sunrise, sunset, moonrise, moonset, moon_phase, moon_phase_icon,
+            temp_max, temp_min, icon_day, text_day, icon_night, text_night,
+            wind360_day, wind_dir_day, wind_scale_day, wind_speed_day,
+            wind360_night, wind_dir_night, wind_scale_night, wind_speed_night,
+            precip, uv_index, humidity, pressure, vis, cloud, update_time
+        ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+        ON DUPLICATE KEY UPDATE
+            sunrise = VALUES(sunrise), sunset = VALUES(sunset), moonrise = VALUES(moonrise),
+            moonset = VALUES(moonset), moon_phase = VALUES(moon_phase), moon_phase_icon = VALUES(moon_phase_icon),
+            temp_max = VALUES(temp_max), temp_min = VALUES(temp_min), icon_day = VALUES(icon_day),
+            text_day = VALUES(text_day), icon_night = VALUES(icon_night), text_night = VALUES(text_night),
+            wind360_day = VALUES(wind360_day), wind_dir_day = VALUES(wind_dir_day), wind_scale_day = VALUES(wind_scale_day),
+            wind_speed_day = VALUES(wind_speed_day), wind360_night = VALUES(wind360_night),
+            wind_dir_night = VALUES(wind_dir_night), wind_scale_night = VALUES(wind_scale_night),
+            wind_speed_night = VALUES(wind_speed_night), precip = VALUES(precip), uv_index = VALUES(uv_index),
+            humidity = VALUES(humidity), pressure = VALUES(pressure), vis = VALUES(vis),
+            cloud = VALUES(cloud), update_time = VALUES(update_time)
+        """
+        try:
+            cursor.execute(insert_query, values)
+            print(f"{city} {fx_date} 数据写入/更新成功: {day.get('textDay')}, 影响行数: {cursor.rowcount}")
+            conn.commit()
+            print(f"{city} 事务提交完成。")
+        except mysql.connector.Error as e:
+            print(f"{city} {fx_date} 数据库错误: {e}")
+            conn.rollback()
+            print(f"{city} 事务回滚。")
+
+def update_weather(force_update=False):
+    conn = connect_db()
+    cursor = conn.cursor()
+
+    for city, location in city_codes.items():
+        latest_time = get_latest_update_time(cursor, city)
+        if should_update_data(latest_time, force_update):
+            print(f"开始更新 {city} 天气数据...")
+            data = fetch_weather_data(city, location)
+            if data:
+                store_weather_data(conn, cursor, city, data)
+        else:
+            print(f"{city} 数据已为最新,无需更新。最新更新时间: {latest_time}")
+
+    cursor.close()
+    conn.close()
+
+def setup_scheduler():
+    # 北京时间 1:00 对应 PDT 前一天的 16:00(夏令时)
+    schedule.every().day.at("16:00").do(update_weather)
+    while True:
+        schedule.run_pending()
+        time.sleep(60)
+
+if __name__ == "__main__":
+    # 初始检查和更新
+    with mysql.connector.connect(**db_config) as conn:
+        cursor = conn.cursor()
+        cursor.execute("""
+        CREATE TABLE IF NOT EXISTS weather_data (
+            id INT AUTO_INCREMENT PRIMARY KEY,
+            city VARCHAR(50) NOT NULL COMMENT '城市名称',
+            fx_date DATE NOT NULL COMMENT '预报日期',
+            sunrise TIME COMMENT '日出时间',
+            sunset TIME COMMENT '日落时间',
+            moonrise TIME COMMENT '月升时间',
+            moonset TIME COMMENT '月落时间',
+            moon_phase VARCHAR(20) COMMENT '月相名称',
+            moon_phase_icon VARCHAR(10) COMMENT '月相图标代码',
+            temp_max INT COMMENT '最高温度',
+            temp_min INT COMMENT '最低温度',
+            icon_day VARCHAR(10) COMMENT '白天天气图标代码',
+            text_day VARCHAR(20) COMMENT '白天天气描述',
+            icon_night VARCHAR(10) COMMENT '夜间天气图标代码',
+            text_night VARCHAR(20) COMMENT '夜间天气描述',
+            wind360_day INT COMMENT '白天风向360角度',
+            wind_dir_day VARCHAR(20) COMMENT '白天风向',
+            wind_scale_day VARCHAR(10) COMMENT '白天风力等级',
+            wind_speed_day INT COMMENT '白天风速 (km/h)',
+            wind360_night INT COMMENT '夜间风向360角度',
+            wind_dir_night VARCHAR(20) COMMENT '夜间风向',
+            wind_scale_night VARCHAR(10) COMMENT '夜间风力等级',
+            wind_speed_night INT COMMENT '夜间风速 (km/h)',
+            precip DECIMAL(5,1) COMMENT '降水量 (mm)',
+            uv_index INT COMMENT '紫外线指数',
+            humidity INT COMMENT '相对湿度 (%)',
+            pressure INT COMMENT '大气压强 (hPa)',
+            vis INT COMMENT '能见度 (km)',
+            cloud INT COMMENT '云量 (%)',
+            update_time DATETIME COMMENT '数据更新时间',
+            UNIQUE KEY unique_city_date (city, fx_date)
+        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='天气数据表'
+        """)
+        conn.commit()
+
+    # 立即执行一次更新
+    update_weather()
+
+    # 启动定时任务
+    setup_scheduler()

+ 46 - 0
课件示例/项目实现/03-02.py

@@ -0,0 +1,46 @@
+import mysql.connector
+import json
+from datetime import date, datetime, timedelta
+from decimal import Decimal
+from python_a2a import FastMCP, create_fastapi_app
+import uvicorn
+
+from config import Config
+from create_logger import logger
+from utils.format import DateEncoder, default_encoder
+
+conf = Config()
+
+
+# 天气服务类
+class WeatherService:
+    def __init__(self):
+        self.conn = mysql.connector.connect(
+            host=conf.host,
+            user=conf.user,
+            password=conf.password,
+            database=conf.database
+        )
+
+    # 执行参数化查询,返回JSON字符串
+    def query_weather(self, city: str, start_date: str, end_date: str) -> str:
+        try:
+            cursor = self.conn.cursor(dictionary=True)
+            sql = ("SELECT city, fx_date, temp_max, temp_min, text_day, text_night, "
+                   "humidity, wind_dir_day, wind_scale_day, precip FROM weather_data "
+                   "WHERE city = %s AND fx_date BETWEEN %s AND %s ORDER BY fx_date")
+            cursor.execute(sql, [city, start_date, end_date])
+            results = cursor.fetchall()
+            cursor.close()
+            for result in results:
+                for key, value in result.items():
+                    if isinstance(value, (date, datetime, timedelta, Decimal)):
+                        result[key] = default_encoder(value)
+            return json.dumps(
+                {"status": "success", "data": results} if results
+                else {"status": "no_data", "message": "未找到天气数据,请确认城市和日期。"},
+                cls=DateEncoder, ensure_ascii=False
+            )
+        except Exception as e:
+            logger.error(f"天气查询错误: {str(e)}")
+            return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False)

+ 42 - 0
课件示例/项目实现/03-03.py

@@ -0,0 +1,42 @@
+import requests
+
+def fetch_weather_from_api(city: str, start_date: str, end_date: str) -> str:
+    """从和风天气 API 获取天气数据"""
+    logger.info(f"从和风天气API获取数据: {city}, {start_date} ~ {end_date}")
+
+    headers = {"X-QW-Api-Key": conf.weather_api_key}
+
+    # 城市转地理位置
+    geo_url = f"https://{conf.weather_api_host}/geo/v2/city/lookup?location={city}"
+    geo_resp = requests.get(geo_url, headers=headers, timeout=15)
+    geo_data = geo_resp.json()
+    if not geo_data.get("location"):
+        return json.dumps({"status": "no_data", "message": f"未找到城市:{city}"}, ensure_ascii=False)
+
+    location_id = geo_data["location"][0]["id"]
+
+    # 获取天气预报
+    weather_url = f"{conf.weather_base_url}?location={location_id}"
+    weather_resp = requests.get(weather_url, headers=headers, timeout=15)
+    weather_data = weather_resp.json()
+
+    # 过滤日期范围并格式化
+    results = []
+    for day in weather_data.get("daily", []):
+        if start_date <= day.get("fxDate", "") <= end_date:
+            results.append({
+                "city": city,
+                "fx_date": day.get("fxDate"),
+                "temp_max": int(day.get("tempMax", 0)),
+                "temp_min": int(day.get("tempMin", 0)),
+                "text_day": day.get("textDay", ""),
+                "text_night": day.get("textNight", ""),
+                "humidity": int(day.get("humidity", 0)),
+                "wind_dir_day": day.get("windDirDay", ""),
+                "wind_scale_day": day.get("windScaleDay", ""),
+                "precip": float(day.get("precip", 0))
+            })
+
+    if results:
+        return json.dumps({"status": "success", "data": results}, ensure_ascii=False)
+    return json.dumps({"status": "no_data", "message": "未找到天气数据。"}, ensure_ascii=False)

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott