fenghantong commited on
Commit ·
ffda755
1
Parent(s): ea1b59d
Deploy RouteOpt Agent
Browse files- .env.example +28 -0
- .gitignore +8 -0
- README.md +155 -7
- app.py +254 -0
- docs/作业要求.txt +13 -0
- environment.yml +10 -0
- outputs/.gitkeep +1 -0
- requirements.txt +3 -0
- routeopt_agent/__init__.py +5 -0
- routeopt_agent/agent.py +438 -0
- routeopt_agent/geo_tools.py +300 -0
- routeopt_agent/llm_client.py +187 -0
- routeopt_agent/models.py +69 -0
- routeopt_agent/parsing.py +130 -0
- routeopt_agent/report.py +216 -0
- routeopt_agent/solver.py +189 -0
- routeopt_agent/viz.py +64 -0
- run.sh +44 -0
.env.example
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 推荐:自动选择已配置 key 的提供商;没有 key 时退回 Pollinations。
|
| 2 |
+
LLM_PROVIDER=auto
|
| 3 |
+
|
| 4 |
+
# 如果本机代理导致 Python requests 报 ProxyError,保持 1。
|
| 5 |
+
# 如果你的网络必须走代理才能访问外网,改成 0。
|
| 6 |
+
LLM_IGNORE_PROXY=1
|
| 7 |
+
|
| 8 |
+
# 方案 A:Groq,国际网络通常比较稳,OpenAI-compatible。
|
| 9 |
+
# GROQ_API_KEY=gsk_xxx
|
| 10 |
+
# GROQ_MODEL=openai/gpt-oss-20b
|
| 11 |
+
|
| 12 |
+
# 方案 B:SiliconFlow,国内网络通常更友好,OpenAI-compatible。
|
| 13 |
+
# SILICONFLOW_API_KEY=sk-xxx
|
| 14 |
+
# SILICONFLOW_MODEL=THUDM/GLM-Z1-9B-0414
|
| 15 |
+
|
| 16 |
+
# 方案 C:Google Gemini,官方免费额度,但国内网络可能需要代理。
|
| 17 |
+
# GEMINI_API_KEY=AIza...
|
| 18 |
+
# GEMINI_MODEL=gemini-2.5-flash
|
| 19 |
+
|
| 20 |
+
# 方案 D:OpenRouter,可选免费模型,但不同模型 tool calling 支持不完全一致。
|
| 21 |
+
# OPENROUTER_API_KEY=sk-or-v1-xxx
|
| 22 |
+
# OPENROUTER_MODEL=qwen/qwen3-32b:free
|
| 23 |
+
|
| 24 |
+
# 方案 E:任意 OpenAI-compatible 服务。
|
| 25 |
+
# LLM_PROVIDER=custom
|
| 26 |
+
# OPENAI_COMPATIBLE_BASE_URL=https://example.com/v1/chat/completions
|
| 27 |
+
# LLM_API_KEY=sk-xxx
|
| 28 |
+
# LLM_MODEL=your-model-name
|
.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.DS_Store
|
| 4 |
+
.env
|
| 5 |
+
cache/
|
| 6 |
+
outputs/*.pdf
|
| 7 |
+
outputs/*.png
|
| 8 |
+
!outputs/.gitkeep
|
README.md
CHANGED
|
@@ -1,14 +1,162 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
-
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
-
short_description:
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: RouteOpt Agent
|
| 3 |
+
emoji: 🧭
|
| 4 |
+
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
python_version: 3.11
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
short_description: Tool-calling route optimizer
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# RouteOpt Agent
|
| 14 |
+
|
| 15 |
+
基于公开大模型 API + 轻量工具调用的小规模路线优化智能体。它把用户自然语言转成路线优化任务,调用地理编码和路径矩阵工具,再用本地算法求解访问顺序,并自动生成 PDF 报告。
|
| 16 |
+
|
| 17 |
+
## 一键运行
|
| 18 |
+
|
| 19 |
+
```bash
|
| 20 |
+
chmod +x run.sh
|
| 21 |
+
./run.sh
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
启动后打开:
|
| 25 |
+
|
| 26 |
+
```text
|
| 27 |
+
http://localhost:7860
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
首次运行会创建 conda 环境 `routeopt-agent` 并安装依赖。脚本会先尝试使用 `environment.yml` 创建标准环境;如果 conda 镜像下载不稳定,会自动克隆本机 base 环境,再用 pip 安装 `requirements.txt`。
|
| 31 |
+
|
| 32 |
+
## LLM 配置
|
| 33 |
+
|
| 34 |
+
默认配置不需要你注册任何网站,也不需要提供 API token:
|
| 35 |
+
|
| 36 |
+
- LLM:Pollinations OpenAI-compatible API,无需 token。失败时系统自动切换到本地解析和本地报告模板。
|
| 37 |
+
- 地理编码:OpenStreetMap Nominatim 公共接口,内置缓存并限制请求频率。
|
| 38 |
+
- 路径矩阵:OSRM public demo server。失败时自动切换到直线距离近似矩阵。
|
| 39 |
+
- 部署:Hugging Face Spaces 免费 Gradio Space。
|
| 40 |
+
|
| 41 |
+
如果本机出现 `ProxyError('Unable to connect to proxy')`,说明 Python 请求走了不可用代理。默认 `LLM_IGNORE_PROXY=1` 会让 LLM 请求忽略系统代理;如果你的网络必须使用代理访问外网,可在 `.env` 中改成:
|
| 42 |
+
|
| 43 |
+
```bash
|
| 44 |
+
LLM_IGNORE_PROXY=0
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
更稳定的做法是使用正规 API key 服务,并把 key 放到 `.env` 或 Hugging Face Secrets。老师访问公开页面时不需要知道 token。
|
| 48 |
+
|
| 49 |
+
复制配置模板:
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
cp .env.example .env
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
推荐方案:
|
| 56 |
+
|
| 57 |
+
```bash
|
| 58 |
+
# Groq:国际网络通常较稳,OpenAI-compatible
|
| 59 |
+
LLM_PROVIDER=groq
|
| 60 |
+
GROQ_API_KEY=gsk_xxx
|
| 61 |
+
GROQ_MODEL=openai/gpt-oss-20b
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
```bash
|
| 65 |
+
# SiliconFlow:国内网络通常更友好,OpenAI-compatible
|
| 66 |
+
LLM_PROVIDER=siliconflow
|
| 67 |
+
SILICONFLOW_API_KEY=sk-xxx
|
| 68 |
+
SILICONFLOW_MODEL=THUDM/GLM-Z1-9B-0414
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
# Gemini:Google 官方免费额度,国内网络可能需要代理
|
| 73 |
+
LLM_PROVIDER=gemini
|
| 74 |
+
GEMINI_API_KEY=AIza...
|
| 75 |
+
GEMINI_MODEL=gemini-2.5-flash
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
也可以用 `LLM_PROVIDER=auto`,程序会按已配置的 key 自动选择:Groq -> SiliconFlow -> Gemini -> OpenRouter;没有 key 时退回 Pollinations。
|
| 79 |
+
|
| 80 |
+
## 推荐演示输入
|
| 81 |
+
|
| 82 |
+
自然语言需求:
|
| 83 |
+
|
| 84 |
+
```text
|
| 85 |
+
我从上海交通大学闵行校区出发,想一天内逛完徐家汇、人民广场、外滩、陆家嘴,最后回到起点,尽量总时间短。
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
起点:
|
| 89 |
+
|
| 90 |
+
```text
|
| 91 |
+
上海交通大学闵行校区
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
目的地:
|
| 95 |
+
|
| 96 |
+
```text
|
| 97 |
+
徐家汇
|
| 98 |
+
人民广场
|
| 99 |
+
外滩
|
| 100 |
+
陆家嘴
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
这个样例的上海地点内置了离线坐标兜底,所以即使 Nominatim 临时限流,也能完成演示。
|
| 104 |
+
|
| 105 |
+
也可以使用北京样例:
|
| 106 |
+
|
| 107 |
+
```text
|
| 108 |
+
我从北京邮电大学出发,想一天内逛完国家大剧院、鼓楼、北海、国家植物园,最后回到起点,尽量总时间短。
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
## 输入失败时如何处理
|
| 112 |
+
|
| 113 |
+
页面不会直接抛出 Python 错误;如果任务无法完成,会说明失败发生在哪个工具,以及应该如何修改输入。
|
| 114 |
+
|
| 115 |
+
常见原因:
|
| 116 |
+
|
| 117 |
+
- 起点或目的地为空。
|
| 118 |
+
- 目的地里只有起点,或者重复地点过多。
|
| 119 |
+
- 地点不在内置演示坐标中,同时在线地理编码服务超时、限流或找不到结果。
|
| 120 |
+
- 地名太泛,例如只写“鼓楼”“植物园”,在某些城市可能有多个匹配。
|
| 121 |
+
|
| 122 |
+
当前内置演示城市:上海、北京。
|
| 123 |
+
|
| 124 |
+
如果要演示其他城市,建议把地点写得更完整,例如“南京夫子庙”“广州塔”“深圳大学粤海校区”。如果在线地理编码仍然失败,可以直接输入坐标:
|
| 125 |
+
|
| 126 |
+
```text
|
| 127 |
+
某景点@39.9042,116.4074
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
## Agent Workflow
|
| 131 |
+
|
| 132 |
+
1. `LLM tool_call: submit_route_task`:把自然语言和表单提示抽取成结构化任务。
|
| 133 |
+
2. `geocode_places`:把地点转换为经纬度。
|
| 134 |
+
3. `build_route_matrix`:调用 OSRM 获取距离/时间矩阵。
|
| 135 |
+
4. `solve_route`:用 Held-Karp 动态规划精确求解小规模 TSP。
|
| 136 |
+
5. `LLM compose_summary`:生成中文解释。
|
| 137 |
+
6. `generate_pdf_report`:输出包含问题定义、工具调用轨迹、算法和结果的 PDF。
|
| 138 |
+
|
| 139 |
+
## 公开部署到 Hugging Face Spaces
|
| 140 |
+
|
| 141 |
+
1. 打开 Hugging Face,新建一个 Space。
|
| 142 |
+
2. Space SDK 选择 `Gradio`。
|
| 143 |
+
3. Visibility 可选 `Public`。
|
| 144 |
+
4. 上传本仓库所有文件,或把仓库推送到 Space。
|
| 145 |
+
5. Space 会根据 `requirements.txt` 自动安装依赖,入口文件是 `app.py`。
|
| 146 |
+
|
| 147 |
+
老师访问 Space 链接即可运行,不需要知道任何 token。
|
| 148 |
+
|
| 149 |
+
## 文件结构
|
| 150 |
+
|
| 151 |
+
```text
|
| 152 |
+
app.py # Gradio 页面入口
|
| 153 |
+
routeopt_agent/agent.py # Agent 编排和工具调用轨迹
|
| 154 |
+
routeopt_agent/geo_tools.py# 地理编码、OSRM 矩阵、兜底矩阵
|
| 155 |
+
routeopt_agent/solver.py # Held-Karp 路线优化
|
| 156 |
+
routeopt_agent/report.py # PDF 报告生成
|
| 157 |
+
routeopt_agent/parsing.py # 本地自然语言解析兜底
|
| 158 |
+
routeopt_agent/viz.py # SVG 路线示意图
|
| 159 |
+
environment.yml # 本地 conda 环境
|
| 160 |
+
requirements.txt # Hugging Face Spaces 依赖
|
| 161 |
+
run.sh # 一键运行脚本
|
| 162 |
+
```
|
app.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import socket
|
| 5 |
+
import traceback
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import gradio as gr
|
| 9 |
+
|
| 10 |
+
from routeopt_agent import RouteOptAgent
|
| 11 |
+
from routeopt_agent.geo_tools import builtin_city_summary, builtin_place_examples
|
| 12 |
+
from routeopt_agent.llm_client import get_llm_config
|
| 13 |
+
from routeopt_agent.solver import format_km, format_minutes
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
agent = RouteOptAgent()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
SAMPLE_REQUEST = "我从上海交通大学闵行校区出发,想一天内逛完徐家汇、人民广场、外滩、陆家嘴,最后回到起点,尽量总时间短。"
|
| 20 |
+
SAMPLE_DESTINATIONS = "徐家汇\n人民广场\n外滩\n陆家嘴"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def run_route_agent(
|
| 24 |
+
raw_request: str,
|
| 25 |
+
start_place: str,
|
| 26 |
+
destination_places: str,
|
| 27 |
+
objective_label: str,
|
| 28 |
+
return_to_start: bool,
|
| 29 |
+
fixed_end_place: str,
|
| 30 |
+
city_hint: str,
|
| 31 |
+
use_llm: bool,
|
| 32 |
+
) -> tuple[Any, ...]:
|
| 33 |
+
objective = "distance" if "距离" in objective_label else "time"
|
| 34 |
+
try:
|
| 35 |
+
result = agent.run(
|
| 36 |
+
raw_request=raw_request,
|
| 37 |
+
start_hint=start_place,
|
| 38 |
+
destinations_hint=destination_places,
|
| 39 |
+
objective_hint=objective,
|
| 40 |
+
return_to_start_hint=return_to_start,
|
| 41 |
+
fixed_end_hint=fixed_end_place,
|
| 42 |
+
city_hint=city_hint,
|
| 43 |
+
use_llm=use_llm,
|
| 44 |
+
)
|
| 45 |
+
except Exception as exc:
|
| 46 |
+
error_md = build_user_error_message(exc, city_hint)
|
| 47 |
+
return error_md, "", [], [], [], trace_to_rows(agent.trace), None
|
| 48 |
+
|
| 49 |
+
solution = result.solution
|
| 50 |
+
warning_md = ""
|
| 51 |
+
if result.warnings:
|
| 52 |
+
warning_md = "\n\n**运行提示**\n" + "\n".join(f"- {item}" for item in result.warnings)
|
| 53 |
+
|
| 54 |
+
summary = (
|
| 55 |
+
f"**最优访问顺序**:{' → '.join(solution.route_names)}\n\n"
|
| 56 |
+
f"**总距离**:{format_km(solution.total_distance_meters)} \n"
|
| 57 |
+
f"**预计驾驶时间**:{format_minutes(solution.total_duration_seconds)} \n"
|
| 58 |
+
f"**优化算法**:{solution.algorithm}\n\n"
|
| 59 |
+
f"{result.summary_markdown}"
|
| 60 |
+
f"{warning_md}"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
route_rows = [[idx + 1, name] for idx, name in enumerate(solution.route_names)]
|
| 64 |
+
point_rows = [
|
| 65 |
+
[idx, point.name, f"{point.lat:.6f}", f"{point.lon:.6f}", point.source, point.display_name]
|
| 66 |
+
for idx, point in enumerate(result.points)
|
| 67 |
+
]
|
| 68 |
+
trace_rows = [
|
| 69 |
+
[
|
| 70 |
+
event.step,
|
| 71 |
+
event.tool,
|
| 72 |
+
event.status,
|
| 73 |
+
compact_for_ui(event.arguments),
|
| 74 |
+
event.result,
|
| 75 |
+
]
|
| 76 |
+
for event in result.trace
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
summary,
|
| 81 |
+
result.route_svg,
|
| 82 |
+
route_rows,
|
| 83 |
+
solution.leg_rows,
|
| 84 |
+
point_rows,
|
| 85 |
+
trace_rows,
|
| 86 |
+
result.pdf_path,
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def compact_for_ui(value: Any) -> str:
|
| 91 |
+
text = str(value)
|
| 92 |
+
if len(text) > 260:
|
| 93 |
+
return text[:260] + "..."
|
| 94 |
+
return text
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def trace_to_rows(trace: list[Any]) -> list[list[Any]]:
|
| 98 |
+
return [
|
| 99 |
+
[
|
| 100 |
+
event.step,
|
| 101 |
+
event.tool,
|
| 102 |
+
event.status,
|
| 103 |
+
compact_for_ui(event.arguments),
|
| 104 |
+
event.result,
|
| 105 |
+
]
|
| 106 |
+
for event in trace
|
| 107 |
+
]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def build_user_error_message(exc: Exception, city_hint: str) -> str:
|
| 111 |
+
message = str(exc).strip() or exc.__class__.__name__
|
| 112 |
+
suggestions = [
|
| 113 |
+
"确认起点和目的地都已填写,且目的地至少有一个不同于起点的地点。",
|
| 114 |
+
"地点名尽量写完整,例如加上城市、区县、校区或景区全称。",
|
| 115 |
+
f"当前内置演示坐标覆盖城市:{builtin_city_summary()}。如果输入其他城市,系统会依赖在线地理编码,可能受网络、限流或地名歧义影响。",
|
| 116 |
+
"如果在线地理编码不稳定,可以把地点写成 `地点名@纬度,经度`,例如 `某景点@39.9042,116.4074`。",
|
| 117 |
+
]
|
| 118 |
+
if not city_hint.strip():
|
| 119 |
+
suggestions.insert(1, "城市/区域提示为空。建议填写类似 `北京,中国` 或 `上海,中国`,能显著降低地点歧义。")
|
| 120 |
+
|
| 121 |
+
return (
|
| 122 |
+
"**本次没有完成路线优化,但系统已定位到失败原因。**\n\n"
|
| 123 |
+
f"**失败原因**:\n\n{message}\n\n"
|
| 124 |
+
"**可以这样处理**:\n"
|
| 125 |
+
+ "\n".join(f"- {item}" for item in suggestions)
|
| 126 |
+
+ "\n\n"
|
| 127 |
+
"<details><summary>技术调试信息</summary>\n\n"
|
| 128 |
+
f"```text\n{traceback.format_exc()[-1800:]}\n```\n</details>"
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def runtime_status_text() -> str:
|
| 133 |
+
try:
|
| 134 |
+
config = get_llm_config()
|
| 135 |
+
key_text = "已配置 key" if config.api_key else "无 key"
|
| 136 |
+
llm_text = f"{config.provider} / {config.model}({key_text})"
|
| 137 |
+
except Exception as exc:
|
| 138 |
+
llm_text = f"LLM 配置异常:{exc}"
|
| 139 |
+
return (
|
| 140 |
+
f"当前 LLM:`{llm_text}` \n"
|
| 141 |
+
f"内置演示城市:`{builtin_city_summary()}` \n"
|
| 142 |
+
f"内置地点示例:{builtin_place_examples()} \n"
|
| 143 |
+
"未知地点可用手动坐标格式:`地点名@纬度,经度`。"
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
with gr.Blocks(title="RouteOpt Agent") as demo:
|
| 148 |
+
gr.Markdown(
|
| 149 |
+
"# RouteOpt Agent\n"
|
| 150 |
+
"基于公开大模型 API + 轻量工具调用的小规模路线优化智能体。"
|
| 151 |
+
)
|
| 152 |
+
gr.Markdown(runtime_status_text())
|
| 153 |
+
with gr.Row():
|
| 154 |
+
with gr.Column(scale=4):
|
| 155 |
+
raw_request = gr.Textbox(
|
| 156 |
+
label="自然语言需求",
|
| 157 |
+
value=SAMPLE_REQUEST,
|
| 158 |
+
lines=4,
|
| 159 |
+
placeholder="例如:我从交大闵行出发,想逛完外滩、陆家嘴、人民广场,最后回到起点,尽量总时间短。",
|
| 160 |
+
)
|
| 161 |
+
start_place = gr.Textbox(label="起点", value="上海交通大学闵行校区")
|
| 162 |
+
destination_places = gr.Textbox(
|
| 163 |
+
label="目的地(每行一个,建议 3-8 个)",
|
| 164 |
+
value=SAMPLE_DESTINATIONS,
|
| 165 |
+
lines=6,
|
| 166 |
+
)
|
| 167 |
+
with gr.Row():
|
| 168 |
+
objective = gr.Radio(
|
| 169 |
+
label="优化目标",
|
| 170 |
+
choices=["最短时间", "最短距离"],
|
| 171 |
+
value="最短时间",
|
| 172 |
+
)
|
| 173 |
+
return_to_start = gr.Checkbox(label="最后回到起点", value=True)
|
| 174 |
+
with gr.Row():
|
| 175 |
+
fixed_end_place = gr.Textbox(label="固定终点(不回起点时可填)", value="")
|
| 176 |
+
city_hint = gr.Textbox(label="城市/区域提示", value="上海,中国")
|
| 177 |
+
use_llm = gr.Checkbox(label="启用 LLM 工具调用解析与总结", value=True)
|
| 178 |
+
run_button = gr.Button("开始优化并生成 PDF", variant="primary")
|
| 179 |
+
|
| 180 |
+
with gr.Column(scale=6):
|
| 181 |
+
summary = gr.Markdown(label="求解结果")
|
| 182 |
+
route_svg = gr.HTML(label="路线示意图")
|
| 183 |
+
|
| 184 |
+
with gr.Tab("路线"):
|
| 185 |
+
route_table = gr.Dataframe(
|
| 186 |
+
headers=["顺序", "地点"],
|
| 187 |
+
datatype=["number", "str"],
|
| 188 |
+
interactive=False,
|
| 189 |
+
)
|
| 190 |
+
legs_table = gr.Dataframe(
|
| 191 |
+
headers=["段", "从", "到", "距离", "时间"],
|
| 192 |
+
datatype=["number", "str", "str", "str", "str"],
|
| 193 |
+
interactive=False,
|
| 194 |
+
)
|
| 195 |
+
with gr.Tab("工具调用"):
|
| 196 |
+
trace_table = gr.Dataframe(
|
| 197 |
+
headers=["步骤", "工具", "状态", "参数", "结果摘要"],
|
| 198 |
+
datatype=["number", "str", "str", "str", "str"],
|
| 199 |
+
interactive=False,
|
| 200 |
+
wrap=True,
|
| 201 |
+
)
|
| 202 |
+
with gr.Tab("地理编码"):
|
| 203 |
+
points_table = gr.Dataframe(
|
| 204 |
+
headers=["序号", "地点", "纬度", "经度", "数据源", "匹配名称"],
|
| 205 |
+
datatype=["number", "str", "str", "str", "str", "str"],
|
| 206 |
+
interactive=False,
|
| 207 |
+
wrap=True,
|
| 208 |
+
)
|
| 209 |
+
with gr.Tab("报告"):
|
| 210 |
+
pdf_file = gr.File(label="PDF 报告")
|
| 211 |
+
|
| 212 |
+
run_button.click(
|
| 213 |
+
run_route_agent,
|
| 214 |
+
inputs=[
|
| 215 |
+
raw_request,
|
| 216 |
+
start_place,
|
| 217 |
+
destination_places,
|
| 218 |
+
objective,
|
| 219 |
+
return_to_start,
|
| 220 |
+
fixed_end_place,
|
| 221 |
+
city_hint,
|
| 222 |
+
use_llm,
|
| 223 |
+
],
|
| 224 |
+
outputs=[
|
| 225 |
+
summary,
|
| 226 |
+
route_svg,
|
| 227 |
+
route_table,
|
| 228 |
+
legs_table,
|
| 229 |
+
points_table,
|
| 230 |
+
trace_table,
|
| 231 |
+
pdf_file,
|
| 232 |
+
],
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def find_available_port(start_port: int, attempts: int = 20) -> int:
|
| 237 |
+
for port in range(start_port, start_port + attempts):
|
| 238 |
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
| 239 |
+
try:
|
| 240 |
+
sock.bind(("0.0.0.0", port))
|
| 241 |
+
return port
|
| 242 |
+
except OSError:
|
| 243 |
+
continue
|
| 244 |
+
return start_port
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
if __name__ == "__main__":
|
| 248 |
+
requested_port = int(os.getenv("GRADIO_SERVER_PORT") or os.getenv("PORT") or "7860")
|
| 249 |
+
server_name = os.getenv("GRADIO_SERVER_NAME")
|
| 250 |
+
if not server_name:
|
| 251 |
+
server_name = "0.0.0.0" if os.getenv("SPACE_ID") else "127.0.0.1"
|
| 252 |
+
port = find_available_port(requested_port)
|
| 253 |
+
print(f"RouteOpt Agent is starting on http://localhost:{port}")
|
| 254 |
+
demo.queue().launch(server_name=server_name, server_port=port)
|
docs/作业要求.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
基于公开可访问的大模型 API / 网页端(如豆包 、GPT、Claude、通义千问等,禁止使用内部未公开模型),通过提示词工程 + 轻量工具调用等方式,训练一个通用问题优化智能体
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
1、智能体要可公开访问;
|
| 6 |
+
|
| 7 |
+
2、智能体要解决一个问题,解决过程要录制视频MP4;
|
| 8 |
+
|
| 9 |
+
3、智能体求解过程要自己输出一个详细文档PDF;
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
提交:可访问链接+MP4视频+PDF文档。
|
environment.yml
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: routeopt-agent
|
| 2 |
+
channels:
|
| 3 |
+
- conda-forge
|
| 4 |
+
dependencies:
|
| 5 |
+
- python=3.11
|
| 6 |
+
- pip
|
| 7 |
+
- pip:
|
| 8 |
+
- gradio>=4.44.0
|
| 9 |
+
- requests>=2.31.0
|
| 10 |
+
- reportlab>=4.2.0
|
outputs/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.44.0
|
| 2 |
+
requests>=2.31.0
|
| 3 |
+
reportlab>=4.2.0
|
routeopt_agent/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RouteOpt Agent package."""
|
| 2 |
+
|
| 3 |
+
from .agent import RouteOptAgent
|
| 4 |
+
|
| 5 |
+
__all__ = ["RouteOptAgent"]
|
routeopt_agent/agent.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from typing import Any, Callable
|
| 5 |
+
|
| 6 |
+
from .geo_tools import build_route_matrix, geocode_places
|
| 7 |
+
from .llm_client import LLMError, chat_completion, extract_json_from_text, extract_tool_arguments
|
| 8 |
+
from .models import AgentResult, GeoPoint, Objective, RouteMatrix, RouteSolution, RouteTask, ToolEvent
|
| 9 |
+
from .parsing import dedupe_preserve_order, heuristic_extract_task, normalize_place_lines
|
| 10 |
+
from .report import generate_pdf_report
|
| 11 |
+
from .solver import format_km, format_minutes, solve_route
|
| 12 |
+
from .viz import build_route_svg
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
TASK_TOOL_SCHEMA: dict[str, Any] = {
|
| 16 |
+
"type": "function",
|
| 17 |
+
"function": {
|
| 18 |
+
"name": "submit_route_task",
|
| 19 |
+
"description": "Extract a route optimization task from user text and structured hints.",
|
| 20 |
+
"parameters": {
|
| 21 |
+
"type": "object",
|
| 22 |
+
"properties": {
|
| 23 |
+
"start_place": {
|
| 24 |
+
"type": "string",
|
| 25 |
+
"description": "The route start place. Keep it specific enough for geocoding.",
|
| 26 |
+
},
|
| 27 |
+
"destination_places": {
|
| 28 |
+
"type": "array",
|
| 29 |
+
"items": {"type": "string"},
|
| 30 |
+
"description": "Places that must be visited after the start.",
|
| 31 |
+
},
|
| 32 |
+
"objective": {
|
| 33 |
+
"type": "string",
|
| 34 |
+
"enum": ["time", "distance"],
|
| 35 |
+
"description": "Optimization objective: time or distance.",
|
| 36 |
+
},
|
| 37 |
+
"return_to_start": {
|
| 38 |
+
"type": "boolean",
|
| 39 |
+
"description": "Whether the route should return to the start place.",
|
| 40 |
+
},
|
| 41 |
+
"fixed_end_place": {
|
| 42 |
+
"type": "string",
|
| 43 |
+
"description": "Optional fixed final destination when not returning to start.",
|
| 44 |
+
},
|
| 45 |
+
"constraints": {
|
| 46 |
+
"type": "array",
|
| 47 |
+
"items": {"type": "string"},
|
| 48 |
+
"description": "Soft constraints or notes mentioned by the user.",
|
| 49 |
+
},
|
| 50 |
+
},
|
| 51 |
+
"required": ["start_place", "destination_places", "objective", "return_to_start"],
|
| 52 |
+
},
|
| 53 |
+
},
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class RouteOptAgent:
|
| 58 |
+
def __init__(self) -> None:
|
| 59 |
+
self.trace: list[ToolEvent] = []
|
| 60 |
+
|
| 61 |
+
def run(
|
| 62 |
+
self,
|
| 63 |
+
raw_request: str,
|
| 64 |
+
start_hint: str = "",
|
| 65 |
+
destinations_hint: str = "",
|
| 66 |
+
objective_hint: str = "time",
|
| 67 |
+
return_to_start_hint: bool = True,
|
| 68 |
+
fixed_end_hint: str = "",
|
| 69 |
+
city_hint: str = "上海,中国",
|
| 70 |
+
use_llm: bool = True,
|
| 71 |
+
) -> AgentResult:
|
| 72 |
+
self.trace = []
|
| 73 |
+
warnings: list[str] = []
|
| 74 |
+
|
| 75 |
+
task = self._extract_task(
|
| 76 |
+
raw_request=raw_request,
|
| 77 |
+
start_hint=start_hint,
|
| 78 |
+
destinations_hint=destinations_hint,
|
| 79 |
+
objective_hint=objective_hint,
|
| 80 |
+
return_to_start_hint=return_to_start_hint,
|
| 81 |
+
fixed_end_hint=fixed_end_hint,
|
| 82 |
+
use_llm=use_llm,
|
| 83 |
+
warnings=warnings,
|
| 84 |
+
)
|
| 85 |
+
self._validate_task(task, warnings)
|
| 86 |
+
|
| 87 |
+
all_places = [task.start_place] + task.destination_places
|
| 88 |
+
points = self._record_tool(
|
| 89 |
+
"geocode_places",
|
| 90 |
+
{"places": all_places, "city_hint": city_hint},
|
| 91 |
+
lambda: geocode_places(all_places, city_hint),
|
| 92 |
+
lambda result: f"解析 {len(result)} 个地点:{', '.join(point.name for point in result)}",
|
| 93 |
+
)
|
| 94 |
+
matrix = self._record_tool(
|
| 95 |
+
"build_route_matrix",
|
| 96 |
+
{"points": [point.name for point in points]},
|
| 97 |
+
lambda: build_route_matrix(points),
|
| 98 |
+
lambda result: f"获得 {len(result.points)}x{len(result.points)} 距离/时间矩阵,来源:{result.source}",
|
| 99 |
+
)
|
| 100 |
+
solution = self._record_tool(
|
| 101 |
+
"solve_route",
|
| 102 |
+
{
|
| 103 |
+
"objective": task.objective,
|
| 104 |
+
"return_to_start": task.return_to_start,
|
| 105 |
+
"fixed_end_place": task.fixed_end_place,
|
| 106 |
+
},
|
| 107 |
+
lambda: solve_route(matrix, task.objective, task.return_to_start, task.fixed_end_place),
|
| 108 |
+
lambda result: f"路线:{' -> '.join(result.route_names)};距离 {format_km(result.total_distance_meters)};时间 {format_minutes(result.total_duration_seconds)}",
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
summary = self._compose_summary(task, points, matrix, solution, use_llm, warnings)
|
| 112 |
+
route_svg = build_route_svg(points, solution.route_indices)
|
| 113 |
+
pdf_path = self._record_tool(
|
| 114 |
+
"generate_pdf_report",
|
| 115 |
+
{"route": solution.route_names},
|
| 116 |
+
lambda: generate_pdf_report(task, points, solution, self.trace, summary),
|
| 117 |
+
lambda result: f"PDF 已生成:{result}",
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
return AgentResult(
|
| 121 |
+
task=task,
|
| 122 |
+
points=points,
|
| 123 |
+
matrix=matrix,
|
| 124 |
+
solution=solution,
|
| 125 |
+
summary_markdown=summary,
|
| 126 |
+
trace=self.trace,
|
| 127 |
+
pdf_path=pdf_path,
|
| 128 |
+
route_svg=route_svg,
|
| 129 |
+
warnings=warnings,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
def _extract_task(
|
| 133 |
+
self,
|
| 134 |
+
raw_request: str,
|
| 135 |
+
start_hint: str,
|
| 136 |
+
destinations_hint: str,
|
| 137 |
+
objective_hint: str,
|
| 138 |
+
return_to_start_hint: bool,
|
| 139 |
+
fixed_end_hint: str,
|
| 140 |
+
use_llm: bool,
|
| 141 |
+
warnings: list[str],
|
| 142 |
+
) -> RouteTask:
|
| 143 |
+
if use_llm:
|
| 144 |
+
try:
|
| 145 |
+
task = self._extract_task_with_llm(
|
| 146 |
+
raw_request,
|
| 147 |
+
start_hint,
|
| 148 |
+
destinations_hint,
|
| 149 |
+
objective_hint,
|
| 150 |
+
return_to_start_hint,
|
| 151 |
+
fixed_end_hint,
|
| 152 |
+
)
|
| 153 |
+
self._append_event(
|
| 154 |
+
"LLM tool_call: submit_route_task",
|
| 155 |
+
{
|
| 156 |
+
"raw_request": raw_request,
|
| 157 |
+
"start_hint": start_hint,
|
| 158 |
+
"destinations_hint": destinations_hint,
|
| 159 |
+
},
|
| 160 |
+
"ok",
|
| 161 |
+
f"抽取任务:起点={task.start_place};目的地={len(task.destination_places)} 个;目标={task.objective}",
|
| 162 |
+
)
|
| 163 |
+
return task
|
| 164 |
+
except Exception as exc:
|
| 165 |
+
warnings.append(f"LLM 工具调用抽取失败,已切换本地解析:{exc}")
|
| 166 |
+
self._append_event(
|
| 167 |
+
"LLM tool_call: submit_route_task",
|
| 168 |
+
{"raw_request": raw_request},
|
| 169 |
+
"fallback",
|
| 170 |
+
str(exc),
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
task = heuristic_extract_task(
|
| 174 |
+
raw_request,
|
| 175 |
+
start_hint=start_hint,
|
| 176 |
+
destinations_hint=destinations_hint,
|
| 177 |
+
objective_hint=objective_hint,
|
| 178 |
+
return_to_start_hint=return_to_start_hint,
|
| 179 |
+
fixed_end_hint=fixed_end_hint,
|
| 180 |
+
)
|
| 181 |
+
self._append_event(
|
| 182 |
+
"local_parse_route_task",
|
| 183 |
+
{
|
| 184 |
+
"raw_request": raw_request,
|
| 185 |
+
"start_hint": start_hint,
|
| 186 |
+
"destinations_hint": destinations_hint,
|
| 187 |
+
},
|
| 188 |
+
"ok",
|
| 189 |
+
f"本地解析任务:起点={task.start_place};目的地={len(task.destination_places)} 个;目标={task.objective}",
|
| 190 |
+
)
|
| 191 |
+
return task
|
| 192 |
+
|
| 193 |
+
def _extract_task_with_llm(
|
| 194 |
+
self,
|
| 195 |
+
raw_request: str,
|
| 196 |
+
start_hint: str,
|
| 197 |
+
destinations_hint: str,
|
| 198 |
+
objective_hint: str,
|
| 199 |
+
return_to_start_hint: bool,
|
| 200 |
+
fixed_end_hint: str,
|
| 201 |
+
) -> RouteTask:
|
| 202 |
+
messages = [
|
| 203 |
+
{
|
| 204 |
+
"role": "system",
|
| 205 |
+
"content": (
|
| 206 |
+
"You are a route optimization agent controller. "
|
| 207 |
+
"Use the submit_route_task function to return one structured task. "
|
| 208 |
+
"Prefer structured hints over ambiguous natural language. "
|
| 209 |
+
"Keep Chinese place names specific for geocoding. "
|
| 210 |
+
"If the user says 最快/时间最短 use objective=time; if 距离/少走路 use objective=distance."
|
| 211 |
+
),
|
| 212 |
+
},
|
| 213 |
+
{
|
| 214 |
+
"role": "user",
|
| 215 |
+
"content": json.dumps(
|
| 216 |
+
{
|
| 217 |
+
"raw_request": raw_request,
|
| 218 |
+
"start_hint": start_hint,
|
| 219 |
+
"destinations_hint": normalize_place_lines(destinations_hint),
|
| 220 |
+
"objective_hint": objective_hint,
|
| 221 |
+
"return_to_start_hint": return_to_start_hint,
|
| 222 |
+
"fixed_end_hint": fixed_end_hint,
|
| 223 |
+
},
|
| 224 |
+
ensure_ascii=False,
|
| 225 |
+
),
|
| 226 |
+
},
|
| 227 |
+
]
|
| 228 |
+
message = chat_completion(messages, tools=[TASK_TOOL_SCHEMA], temperature=0.1)
|
| 229 |
+
args = extract_tool_arguments(message, "submit_route_task")
|
| 230 |
+
if args is None:
|
| 231 |
+
args = extract_json_from_text(message.get("content") or "")
|
| 232 |
+
if args is None:
|
| 233 |
+
raise LLMError("模型没有返回 submit_route_task 工具参数。")
|
| 234 |
+
return self._task_from_arguments(
|
| 235 |
+
args,
|
| 236 |
+
raw_request,
|
| 237 |
+
start_hint,
|
| 238 |
+
destinations_hint,
|
| 239 |
+
objective_hint,
|
| 240 |
+
return_to_start_hint,
|
| 241 |
+
fixed_end_hint,
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
def _task_from_arguments(
|
| 245 |
+
self,
|
| 246 |
+
args: dict[str, Any],
|
| 247 |
+
raw_request: str,
|
| 248 |
+
start_hint: str,
|
| 249 |
+
destinations_hint: str,
|
| 250 |
+
objective_hint: str,
|
| 251 |
+
return_to_start_hint: bool,
|
| 252 |
+
fixed_end_hint: str,
|
| 253 |
+
) -> RouteTask:
|
| 254 |
+
local_task = heuristic_extract_task(
|
| 255 |
+
raw_request,
|
| 256 |
+
start_hint=start_hint,
|
| 257 |
+
destinations_hint=destinations_hint,
|
| 258 |
+
objective_hint=objective_hint,
|
| 259 |
+
return_to_start_hint=return_to_start_hint,
|
| 260 |
+
fixed_end_hint=fixed_end_hint,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
destinations = args.get("destination_places") or local_task.destination_places
|
| 264 |
+
if isinstance(destinations, str):
|
| 265 |
+
destinations = normalize_place_lines(destinations)
|
| 266 |
+
destinations = dedupe_preserve_order([str(item).strip() for item in destinations if str(item).strip()])
|
| 267 |
+
|
| 268 |
+
start = start_hint.strip() or str(args.get("start_place") or local_task.start_place).strip()
|
| 269 |
+
objective = normalize_objective(str(args.get("objective") or local_task.objective))
|
| 270 |
+
fixed_end = fixed_end_hint.strip() or str(args.get("fixed_end_place") or local_task.fixed_end_place or "").strip()
|
| 271 |
+
|
| 272 |
+
return RouteTask(
|
| 273 |
+
raw_request=raw_request,
|
| 274 |
+
start_place=start,
|
| 275 |
+
destination_places=destinations,
|
| 276 |
+
objective=objective,
|
| 277 |
+
return_to_start=return_to_start_hint if return_to_start_hint is not None else bool(args.get("return_to_start", local_task.return_to_start)),
|
| 278 |
+
fixed_end_place=fixed_end or None,
|
| 279 |
+
constraints=[str(item) for item in args.get("constraints") or local_task.constraints],
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
def _validate_task(self, task: RouteTask, warnings: list[str]) -> None:
|
| 283 |
+
task.start_place = task.start_place.strip()
|
| 284 |
+
task.destination_places = [place.strip() for place in task.destination_places if place.strip()]
|
| 285 |
+
original_count = len(task.destination_places)
|
| 286 |
+
task.destination_places = dedupe_preserve_order(task.destination_places)
|
| 287 |
+
if len(task.destination_places) < original_count:
|
| 288 |
+
warnings.append("目的地中存在重复项,系统已自动去重。")
|
| 289 |
+
|
| 290 |
+
if not task.start_place:
|
| 291 |
+
raise ValueError("缺少起点。请在起点输入框填写一个地点,或在自然语言需求中写明“从哪里出发”。")
|
| 292 |
+
if not task.destination_places:
|
| 293 |
+
raise ValueError("缺少目的地。请至少填写 1 个目的地。建议每行写一个地点,例如:人民广场、外滩、陆家嘴。")
|
| 294 |
+
|
| 295 |
+
start_key = task.start_place.lower()
|
| 296 |
+
without_start = [
|
| 297 |
+
place
|
| 298 |
+
for place in task.destination_places
|
| 299 |
+
if place.lower() != start_key and start_key not in place.lower() and place.lower() not in start_key
|
| 300 |
+
]
|
| 301 |
+
if len(without_start) < len(task.destination_places):
|
| 302 |
+
warnings.append("目的地中包含起点,系统已自动移除该重复访问点。")
|
| 303 |
+
task.destination_places = without_start
|
| 304 |
+
if not task.destination_places:
|
| 305 |
+
raise ValueError("目的地里只有起点本身。请至少增加一个不同于起点的目的地。")
|
| 306 |
+
|
| 307 |
+
if len(task.destination_places) > 10:
|
| 308 |
+
raise ValueError(
|
| 309 |
+
"目的地数量过多。当前演示版最多支持 10 个目的地;作业视频推荐 3 到 8 个,"
|
| 310 |
+
"这样 Held-Karp 精确算法和公开路线 API 都更稳定。"
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
if task.fixed_end_place and not task.return_to_start:
|
| 314 |
+
fixed = task.fixed_end_place.strip().lower()
|
| 315 |
+
if all(fixed not in item.lower() and item.lower() not in fixed for item in task.destination_places):
|
| 316 |
+
task.destination_places.append(task.fixed_end_place)
|
| 317 |
+
|
| 318 |
+
def _compose_summary(
|
| 319 |
+
self,
|
| 320 |
+
task: RouteTask,
|
| 321 |
+
points: list[GeoPoint],
|
| 322 |
+
matrix: RouteMatrix,
|
| 323 |
+
solution: RouteSolution,
|
| 324 |
+
use_llm: bool,
|
| 325 |
+
warnings: list[str],
|
| 326 |
+
) -> str:
|
| 327 |
+
if use_llm:
|
| 328 |
+
try:
|
| 329 |
+
messages = [
|
| 330 |
+
{
|
| 331 |
+
"role": "system",
|
| 332 |
+
"content": (
|
| 333 |
+
"你是算法课程作业里的路线优化智能体。"
|
| 334 |
+
"请用中文写一段简洁但不敷衍的求解说明,强调:"
|
| 335 |
+
"大模型负责理解和解释,工具负责地理编码/路径矩阵,算法负责最优化。"
|
| 336 |
+
"不要编造没有出现的数据。"
|
| 337 |
+
),
|
| 338 |
+
},
|
| 339 |
+
{
|
| 340 |
+
"role": "user",
|
| 341 |
+
"content": json.dumps(
|
| 342 |
+
{
|
| 343 |
+
"task": task.__dict__,
|
| 344 |
+
"points": [point.__dict__ for point in points],
|
| 345 |
+
"matrix_source": matrix.source,
|
| 346 |
+
"solution": {
|
| 347 |
+
"route": solution.route_names,
|
| 348 |
+
"total_distance_km": round(solution.total_distance_meters / 1000, 2),
|
| 349 |
+
"total_minutes": round(solution.total_duration_seconds / 60, 1),
|
| 350 |
+
"algorithm": solution.algorithm,
|
| 351 |
+
},
|
| 352 |
+
"tool_trace": [event.__dict__ for event in self.trace],
|
| 353 |
+
"warnings": warnings,
|
| 354 |
+
},
|
| 355 |
+
ensure_ascii=False,
|
| 356 |
+
),
|
| 357 |
+
},
|
| 358 |
+
]
|
| 359 |
+
message = chat_completion(messages, temperature=0.35)
|
| 360 |
+
content = (message.get("content") or "").strip()
|
| 361 |
+
if content:
|
| 362 |
+
self._append_event(
|
| 363 |
+
"LLM compose_summary",
|
| 364 |
+
{"route": solution.route_names},
|
| 365 |
+
"ok",
|
| 366 |
+
"已生成中文解释总结。",
|
| 367 |
+
)
|
| 368 |
+
return content
|
| 369 |
+
except Exception as exc:
|
| 370 |
+
warnings.append(f"LLM 总结失败,已切换本地报告模板:{exc}")
|
| 371 |
+
self._append_event("LLM compose_summary", {}, "fallback", str(exc))
|
| 372 |
+
|
| 373 |
+
return deterministic_summary(task, matrix, solution, warnings)
|
| 374 |
+
|
| 375 |
+
def _record_tool(
|
| 376 |
+
self,
|
| 377 |
+
name: str,
|
| 378 |
+
arguments: dict[str, Any],
|
| 379 |
+
func: Callable[[], Any],
|
| 380 |
+
summarize: Callable[[Any], str],
|
| 381 |
+
) -> Any:
|
| 382 |
+
try:
|
| 383 |
+
result = func()
|
| 384 |
+
self._append_event(name, arguments, "ok", summarize(result))
|
| 385 |
+
return result
|
| 386 |
+
except Exception as exc:
|
| 387 |
+
self._append_event(name, arguments, "error", str(exc))
|
| 388 |
+
raise
|
| 389 |
+
|
| 390 |
+
def _append_event(self, tool: str, arguments: dict[str, Any], status: str, result: str) -> None:
|
| 391 |
+
self.trace.append(
|
| 392 |
+
ToolEvent(
|
| 393 |
+
step=len(self.trace) + 1,
|
| 394 |
+
tool=tool,
|
| 395 |
+
arguments=compact_arguments(arguments),
|
| 396 |
+
status=status,
|
| 397 |
+
result=result,
|
| 398 |
+
)
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def normalize_objective(value: str) -> Objective:
|
| 403 |
+
value = value.lower().strip()
|
| 404 |
+
if value in {"distance", "最短距离", "距离"}:
|
| 405 |
+
return "distance"
|
| 406 |
+
return "time"
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
def compact_arguments(arguments: dict[str, Any]) -> dict[str, Any]:
|
| 410 |
+
compacted: dict[str, Any] = {}
|
| 411 |
+
for key, value in arguments.items():
|
| 412 |
+
if isinstance(value, list) and len(value) > 8:
|
| 413 |
+
compacted[key] = value[:8] + ["..."]
|
| 414 |
+
else:
|
| 415 |
+
compacted[key] = value
|
| 416 |
+
return compacted
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def deterministic_summary(
|
| 420 |
+
task: RouteTask,
|
| 421 |
+
matrix: RouteMatrix,
|
| 422 |
+
solution: RouteSolution,
|
| 423 |
+
warnings: list[str],
|
| 424 |
+
) -> str:
|
| 425 |
+
objective_text = "预计驾驶时间" if task.objective == "time" else "路线距离"
|
| 426 |
+
warning_text = ""
|
| 427 |
+
if warnings:
|
| 428 |
+
warning_text = "\n\n**运行提示**:\n" + "\n".join(f"- {item}" for item in warnings)
|
| 429 |
+
return (
|
| 430 |
+
"本次求解把用户需求先转成结构化路线优化任务,然后调用地理编码工具获得经纬度,"
|
| 431 |
+
f"再通过 `{matrix.source}` 得到点对点距离/时间矩阵。最后,本地优化器以“{objective_text}”为目标,"
|
| 432 |
+
f"使用 `{solution.algorithm}` 搜索访问顺序。\n\n"
|
| 433 |
+
f"最终路线为:**{' → '.join(solution.route_names)}**。"
|
| 434 |
+
f"总距离约 **{format_km(solution.total_distance_meters)}**,预计驾驶时间约 **{format_minutes(solution.total_duration_seconds)}**。"
|
| 435 |
+
"\n\n这个设计中,大模型不直接猜最优路线,而是负责理解自然语言、组织工具调用并解释结果;"
|
| 436 |
+
"确定性工具负责拿真实数据和完成可验证的算法计算。"
|
| 437 |
+
f"{warning_text}"
|
| 438 |
+
)
|
routeopt_agent/geo_tools.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import os
|
| 6 |
+
import re
|
| 7 |
+
import time
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
from urllib.parse import quote
|
| 11 |
+
|
| 12 |
+
import requests
|
| 13 |
+
|
| 14 |
+
from .models import GeoPoint, RouteMatrix
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
| 18 |
+
OSRM_TABLE_URL = "https://router.project-osrm.org/table/v1/driving"
|
| 19 |
+
CACHE_DIR = Path("cache")
|
| 20 |
+
GEO_CACHE_PATH = CACHE_DIR / "geo_cache.json"
|
| 21 |
+
BUILTIN_DEMO_CITIES = ("上海", "北京")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
DEMO_POINTS: dict[str, tuple[float, float, str]] = {
|
| 25 |
+
"上海交通大学闵行校区": (31.0256, 121.4332, "上海交通大学闵行校区"),
|
| 26 |
+
"交大闵行": (31.0256, 121.4332, "上海交通大学闵行校区"),
|
| 27 |
+
"上海交大闵行校区": (31.0256, 121.4332, "上海交通大学闵行校区"),
|
| 28 |
+
"外滩": (31.2400, 121.4900, "外滩"),
|
| 29 |
+
"陆家嘴": (31.2381, 121.4970, "陆家嘴"),
|
| 30 |
+
"人民广场": (31.2304, 121.4737, "人民广场"),
|
| 31 |
+
"徐家汇": (31.1838, 121.4328, "徐家汇"),
|
| 32 |
+
"豫园": (31.2272, 121.4922, "豫园"),
|
| 33 |
+
"静安寺": (31.2230, 121.4454, "静安寺"),
|
| 34 |
+
"南京东路": (31.2366, 121.4842, "南京东路"),
|
| 35 |
+
"东方明珠": (31.2397, 121.4998, "东方明珠广播电视塔"),
|
| 36 |
+
"上海虹桥站": (31.1945, 121.3188, "上海虹桥站"),
|
| 37 |
+
"上海南站": (31.1546, 121.4296, "上海南站"),
|
| 38 |
+
"复旦大学": (31.2989, 121.5038, "复旦大学邯郸校区"),
|
| 39 |
+
"同济大学": (31.2820, 121.5062, "同济大学四平路校区"),
|
| 40 |
+
"华东师范大学": (31.2282, 121.4038, "华东师范大学普陀校区"),
|
| 41 |
+
"北京邮电大学": (39.9620, 116.3585, "北京邮电大学西土城路校区"),
|
| 42 |
+
"北邮": (39.9620, 116.3585, "北京邮电大学西土城路校区"),
|
| 43 |
+
"北京邮电大学西土城路校区": (39.9620, 116.3585, "北京邮电大学西土城路校区"),
|
| 44 |
+
"国家大剧院": (39.9038, 116.3838, "国家大剧院"),
|
| 45 |
+
"北京国家大剧院": (39.9038, 116.3838, "国家大剧院"),
|
| 46 |
+
"鼓楼": (39.9404, 116.3972, "北京鼓楼"),
|
| 47 |
+
"北京鼓楼": (39.9404, 116.3972, "北京鼓楼"),
|
| 48 |
+
"鼓楼大街": (39.9470, 116.3938, "鼓楼大街"),
|
| 49 |
+
"北海": (39.9255, 116.3895, "北海公园"),
|
| 50 |
+
"北海公园": (39.9255, 116.3895, "北海公园"),
|
| 51 |
+
"国家植物园": (39.9974, 116.2072, "国家植物园"),
|
| 52 |
+
"北京国家植物园": (39.9974, 116.2072, "国家植物园"),
|
| 53 |
+
"北京植物园": (39.9974, 116.2072, "国家植物园"),
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class GeoToolError(RuntimeError):
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def geocode_places(places: list[str], city_hint: str = "", timeout: int = 12) -> list[GeoPoint]:
|
| 62 |
+
cache = load_json_cache(GEO_CACHE_PATH)
|
| 63 |
+
results: list[GeoPoint] = []
|
| 64 |
+
last_live_query_at = 0.0
|
| 65 |
+
|
| 66 |
+
for place in places:
|
| 67 |
+
place = place.strip()
|
| 68 |
+
if not place:
|
| 69 |
+
raise GeoToolError("地点列表中存在空项。请删除空行,确保起点和每个目的地都有具体名称。")
|
| 70 |
+
|
| 71 |
+
manual = parse_manual_point(place)
|
| 72 |
+
if manual:
|
| 73 |
+
point = manual
|
| 74 |
+
results.append(point)
|
| 75 |
+
continue
|
| 76 |
+
|
| 77 |
+
demo = lookup_demo_point(place)
|
| 78 |
+
if demo:
|
| 79 |
+
lat, lon, display_name = demo
|
| 80 |
+
point = GeoPoint(place, place, lat, lon, display_name, "built-in-demo")
|
| 81 |
+
cache_key = normalize_key(place, city_hint)
|
| 82 |
+
cache[cache_key] = point.__dict__
|
| 83 |
+
results.append(point)
|
| 84 |
+
continue
|
| 85 |
+
|
| 86 |
+
cache_key = normalize_key(place, city_hint)
|
| 87 |
+
if cache_key in cache:
|
| 88 |
+
cached = cache[cache_key]
|
| 89 |
+
results.append(GeoPoint(**cached))
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
query = f"{place}, {city_hint}".strip(" ,")
|
| 93 |
+
sleep_for_nominatim(last_live_query_at)
|
| 94 |
+
last_live_query_at = time.time()
|
| 95 |
+
try:
|
| 96 |
+
point = geocode_with_nominatim(place, query, timeout)
|
| 97 |
+
except GeoToolError:
|
| 98 |
+
raise
|
| 99 |
+
except requests.exceptions.Timeout as exc:
|
| 100 |
+
raise GeoToolError(build_geocode_failure_message(place, query, "在线地理编码服务响应超时")) from exc
|
| 101 |
+
except requests.exceptions.ConnectionError as exc:
|
| 102 |
+
raise GeoToolError(build_geocode_failure_message(place, query, "无法连接在线地理编码服务")) from exc
|
| 103 |
+
except requests.exceptions.HTTPError as exc:
|
| 104 |
+
raise GeoToolError(build_geocode_failure_message(place, query, f"在线地理编码 HTTP 错误:{exc.response.status_code if exc.response else 'unknown'}")) from exc
|
| 105 |
+
except requests.exceptions.RequestException as exc:
|
| 106 |
+
raise GeoToolError(build_geocode_failure_message(place, query, f"在线地理编码请求失败:{exc}")) from exc
|
| 107 |
+
cache[cache_key] = point.__dict__
|
| 108 |
+
results.append(point)
|
| 109 |
+
|
| 110 |
+
save_json_cache(GEO_CACHE_PATH, cache)
|
| 111 |
+
return results
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def build_route_matrix(points: list[GeoPoint], timeout: int = 15) -> RouteMatrix:
|
| 115 |
+
if len(points) < 2:
|
| 116 |
+
raise GeoToolError("至少需要起点和 1 个目的地。")
|
| 117 |
+
|
| 118 |
+
coords = ";".join(f"{point.lon:.6f},{point.lat:.6f}" for point in points)
|
| 119 |
+
url = f"{OSRM_TABLE_URL}/{coords}"
|
| 120 |
+
params = {"annotations": "duration,distance"}
|
| 121 |
+
|
| 122 |
+
try:
|
| 123 |
+
response = requests.get(url, params=params, timeout=timeout)
|
| 124 |
+
response.raise_for_status()
|
| 125 |
+
payload = response.json()
|
| 126 |
+
durations = payload.get("durations")
|
| 127 |
+
distances = payload.get("distances")
|
| 128 |
+
if not matrix_is_complete(durations) or not matrix_is_complete(distances):
|
| 129 |
+
raise GeoToolError("OSRM 返回的矩阵不完整。")
|
| 130 |
+
return RouteMatrix(
|
| 131 |
+
points=points,
|
| 132 |
+
durations=durations,
|
| 133 |
+
distances=distances,
|
| 134 |
+
source="OSRM public demo server",
|
| 135 |
+
)
|
| 136 |
+
except Exception:
|
| 137 |
+
durations, distances = approximate_city_matrix(points)
|
| 138 |
+
return RouteMatrix(
|
| 139 |
+
points=points,
|
| 140 |
+
durations=durations,
|
| 141 |
+
distances=distances,
|
| 142 |
+
source="haversine fallback, speed=35km/h, road factor=1.30",
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def geocode_with_nominatim(place: str, query: str, timeout: int) -> GeoPoint:
|
| 147 |
+
headers = {
|
| 148 |
+
"User-Agent": "routeopt-agent-homework/1.0 (public classroom demo)",
|
| 149 |
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.6",
|
| 150 |
+
}
|
| 151 |
+
params = {
|
| 152 |
+
"q": query,
|
| 153 |
+
"format": "jsonv2",
|
| 154 |
+
"limit": 1,
|
| 155 |
+
"addressdetails": 1,
|
| 156 |
+
}
|
| 157 |
+
response = requests.get(NOMINATIM_URL, params=params, headers=headers, timeout=timeout)
|
| 158 |
+
response.raise_for_status()
|
| 159 |
+
items = response.json()
|
| 160 |
+
if not items:
|
| 161 |
+
raise GeoToolError(build_geocode_failure_message(place, query, "在线地理编码没有找到匹配地点"))
|
| 162 |
+
item = items[0]
|
| 163 |
+
return GeoPoint(
|
| 164 |
+
name=place,
|
| 165 |
+
query=query,
|
| 166 |
+
lat=float(item["lat"]),
|
| 167 |
+
lon=float(item["lon"]),
|
| 168 |
+
display_name=item.get("display_name", query),
|
| 169 |
+
source="OpenStreetMap Nominatim",
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def lookup_demo_point(place: str) -> tuple[float, float, str] | None:
|
| 174 |
+
normalized = place.strip().lower()
|
| 175 |
+
for key, value in DEMO_POINTS.items():
|
| 176 |
+
key_norm = key.lower()
|
| 177 |
+
if normalized == key_norm or normalized in key_norm or key_norm in normalized:
|
| 178 |
+
return value
|
| 179 |
+
return None
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def parse_manual_point(place: str) -> GeoPoint | None:
|
| 183 |
+
patterns = [
|
| 184 |
+
r"^(?P<name>.+?)[@|]\s*(?P<lat>-?\d+(?:\.\d+)?)\s*[,,]\s*(?P<lon>-?\d+(?:\.\d+)?)$",
|
| 185 |
+
r"^(?P<name>.+?)[((]\s*(?P<lat>-?\d+(?:\.\d+)?)\s*[,,]\s*(?P<lon>-?\d+(?:\.\d+)?)\s*[))]$",
|
| 186 |
+
]
|
| 187 |
+
for pattern in patterns:
|
| 188 |
+
match = re.match(pattern, place.strip())
|
| 189 |
+
if not match:
|
| 190 |
+
continue
|
| 191 |
+
name = match.group("name").strip()
|
| 192 |
+
lat = float(match.group("lat"))
|
| 193 |
+
lon = float(match.group("lon"))
|
| 194 |
+
if not name:
|
| 195 |
+
raise GeoToolError("手动坐标格式缺少地点名称。正确格式示例:未知景点@39.90,116.40")
|
| 196 |
+
if not (-90 <= lat <= 90 and -180 <= lon <= 180):
|
| 197 |
+
raise GeoToolError(f"手动坐标超出范围:{place}。纬度应在 -90 到 90,经度应在 -180 到 180。")
|
| 198 |
+
return GeoPoint(
|
| 199 |
+
name=name,
|
| 200 |
+
query=place,
|
| 201 |
+
lat=lat,
|
| 202 |
+
lon=lon,
|
| 203 |
+
display_name=f"{name}(手动坐标)",
|
| 204 |
+
source="manual-coordinate",
|
| 205 |
+
)
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def build_geocode_failure_message(place: str, query: str, reason: str) -> str:
|
| 210 |
+
return (
|
| 211 |
+
f"地点解析失败:`{place}`。\n\n"
|
| 212 |
+
f"失败原因:{reason}。\n\n"
|
| 213 |
+
f"本次查询语句:`{query}`。\n\n"
|
| 214 |
+
f"当前内置演示坐标覆盖城市:{builtin_city_summary()}。如果你输入的是其他城市或较冷门地点,"
|
| 215 |
+
"系统会尝试在线地理编码;当在线服务超时、限流或找不到地点时,就需要你把地名写得更具体,"
|
| 216 |
+
"例如加上城市、区县、校区/景区全称。\n\n"
|
| 217 |
+
"也可以直接使用手动坐标格式绕过在线地理编码:`地点名@纬度,经度`,例如 `某景点@39.9042,116.4074`。"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def builtin_city_summary() -> str:
|
| 222 |
+
return "、".join(BUILTIN_DEMO_CITIES)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def builtin_place_examples(limit: int = 12) -> str:
|
| 226 |
+
names = list(DEMO_POINTS.keys())[:limit]
|
| 227 |
+
return "、".join(names)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def approximate_city_matrix(points: list[GeoPoint]) -> tuple[list[list[float]], list[list[float]]]:
|
| 231 |
+
distances: list[list[float]] = []
|
| 232 |
+
durations: list[list[float]] = []
|
| 233 |
+
road_factor = 1.30
|
| 234 |
+
speed_mps = 35_000 / 3600
|
| 235 |
+
for origin in points:
|
| 236 |
+
distance_row: list[float] = []
|
| 237 |
+
duration_row: list[float] = []
|
| 238 |
+
for dest in points:
|
| 239 |
+
if origin is dest:
|
| 240 |
+
distance = 0.0
|
| 241 |
+
else:
|
| 242 |
+
distance = haversine_meters(origin.lat, origin.lon, dest.lat, dest.lon) * road_factor
|
| 243 |
+
distance_row.append(distance)
|
| 244 |
+
duration_row.append(distance / speed_mps if speed_mps else 0.0)
|
| 245 |
+
distances.append(distance_row)
|
| 246 |
+
durations.append(duration_row)
|
| 247 |
+
return durations, distances
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def haversine_meters(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
| 251 |
+
radius = 6_371_000
|
| 252 |
+
phi1 = math.radians(lat1)
|
| 253 |
+
phi2 = math.radians(lat2)
|
| 254 |
+
d_phi = math.radians(lat2 - lat1)
|
| 255 |
+
d_lambda = math.radians(lon2 - lon1)
|
| 256 |
+
a = (
|
| 257 |
+
math.sin(d_phi / 2) ** 2
|
| 258 |
+
+ math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2
|
| 259 |
+
)
|
| 260 |
+
return 2 * radius * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def matrix_is_complete(matrix: Any) -> bool:
|
| 264 |
+
if not isinstance(matrix, list) or not matrix:
|
| 265 |
+
return False
|
| 266 |
+
size = len(matrix)
|
| 267 |
+
return all(isinstance(row, list) and len(row) == size and all(value is not None for value in row) for row in matrix)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def format_osm_link(point: GeoPoint) -> str:
|
| 271 |
+
return f"https://www.openstreetmap.org/?mlat={point.lat:.6f}&mlon={point.lon:.6f}#map=14/{point.lat:.6f}/{point.lon:.6f}"
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def make_osrm_route_link(points: list[GeoPoint], route_indices: list[int]) -> str:
|
| 275 |
+
coords = ";".join(f"{points[idx].lon:.6f},{points[idx].lat:.6f}" for idx in route_indices)
|
| 276 |
+
return f"https://router.project-osrm.org/route/v1/driving/{quote(coords, safe=';,')}"
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def sleep_for_nominatim(last_live_query_at: float) -> None:
|
| 280 |
+
elapsed = time.time() - last_live_query_at
|
| 281 |
+
if last_live_query_at and elapsed < 1.05:
|
| 282 |
+
time.sleep(1.05 - elapsed)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def load_json_cache(path: Path) -> dict[str, Any]:
|
| 286 |
+
if not path.exists():
|
| 287 |
+
return {}
|
| 288 |
+
try:
|
| 289 |
+
return json.loads(path.read_text(encoding="utf-8"))
|
| 290 |
+
except Exception:
|
| 291 |
+
return {}
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def save_json_cache(path: Path, data: dict[str, Any]) -> None:
|
| 295 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 296 |
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def normalize_key(place: str, city_hint: str) -> str:
|
| 300 |
+
return f"{place.strip().lower()}|{city_hint.strip().lower()}"
|
routeopt_agent/llm_client.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import requests
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
POLLINATIONS_OPENAI_URL = "https://text.pollinations.ai/openai"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class LLMConfig:
|
| 16 |
+
provider: str
|
| 17 |
+
url: str
|
| 18 |
+
model: str
|
| 19 |
+
api_key: str | None = None
|
| 20 |
+
ignore_proxy: bool = True
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
DEFAULT_MODELS = {
|
| 24 |
+
"pollinations": "openai-fast",
|
| 25 |
+
"groq": "openai/gpt-oss-20b",
|
| 26 |
+
"siliconflow": "THUDM/GLM-Z1-9B-0414",
|
| 27 |
+
"gemini": "gemini-2.5-flash",
|
| 28 |
+
"openrouter": "qwen/qwen3-32b:free",
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
OPENAI_COMPATIBLE_URLS = {
|
| 33 |
+
"pollinations": POLLINATIONS_OPENAI_URL,
|
| 34 |
+
"groq": "https://api.groq.com/openai/v1/chat/completions",
|
| 35 |
+
"siliconflow": "https://api.siliconflow.cn/v1/chat/completions",
|
| 36 |
+
"gemini": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
| 37 |
+
"openrouter": "https://openrouter.ai/api/v1/chat/completions",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
API_KEY_ENVS = {
|
| 42 |
+
"groq": "GROQ_API_KEY",
|
| 43 |
+
"siliconflow": "SILICONFLOW_API_KEY",
|
| 44 |
+
"gemini": "GEMINI_API_KEY",
|
| 45 |
+
"openrouter": "OPENROUTER_API_KEY",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class LLMError(RuntimeError):
|
| 50 |
+
pass
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def chat_completion(
|
| 54 |
+
messages: list[dict[str, Any]],
|
| 55 |
+
tools: list[dict[str, Any]] | None = None,
|
| 56 |
+
temperature: float = 0.2,
|
| 57 |
+
timeout: int = 45,
|
| 58 |
+
) -> dict[str, Any]:
|
| 59 |
+
config = get_llm_config()
|
| 60 |
+
payload: dict[str, Any] = {
|
| 61 |
+
"model": config.model,
|
| 62 |
+
"messages": messages,
|
| 63 |
+
"temperature": temperature,
|
| 64 |
+
"stream": False,
|
| 65 |
+
}
|
| 66 |
+
if tools:
|
| 67 |
+
payload["tools"] = tools
|
| 68 |
+
payload["tool_choice"] = "auto"
|
| 69 |
+
|
| 70 |
+
headers = {"Content-Type": "application/json"}
|
| 71 |
+
if config.api_key:
|
| 72 |
+
headers["Authorization"] = f"Bearer {config.api_key}"
|
| 73 |
+
if config.provider == "openrouter":
|
| 74 |
+
headers["HTTP-Referer"] = os.getenv("OPENROUTER_SITE_URL", "http://localhost:7860")
|
| 75 |
+
headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "RouteOpt Agent")
|
| 76 |
+
|
| 77 |
+
session = requests.Session()
|
| 78 |
+
session.trust_env = not config.ignore_proxy
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
response = session.post(config.url, json=payload, headers=headers, timeout=timeout)
|
| 82 |
+
response.raise_for_status()
|
| 83 |
+
data = response.json()
|
| 84 |
+
except Exception as exc:
|
| 85 |
+
raise LLMError(f"LLM 请求失败(provider={config.provider}, model={config.model}):{exc}") from exc
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
return data["choices"][0]["message"]
|
| 89 |
+
except Exception as exc:
|
| 90 |
+
raise LLMError(f"LLM 返回格式异常:{json.dumps(data, ensure_ascii=False)[:300]}") from exc
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def extract_tool_arguments(message: dict[str, Any], tool_name: str) -> dict[str, Any] | None:
|
| 94 |
+
for tool_call in message.get("tool_calls") or []:
|
| 95 |
+
function = tool_call.get("function") or {}
|
| 96 |
+
if function.get("name") != tool_name:
|
| 97 |
+
continue
|
| 98 |
+
raw_arguments = function.get("arguments") or "{}"
|
| 99 |
+
if isinstance(raw_arguments, dict):
|
| 100 |
+
return raw_arguments
|
| 101 |
+
try:
|
| 102 |
+
return json.loads(raw_arguments)
|
| 103 |
+
except json.JSONDecodeError:
|
| 104 |
+
return None
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def get_llm_config() -> LLMConfig:
|
| 109 |
+
provider = normalize_provider(os.getenv("LLM_PROVIDER", "auto"))
|
| 110 |
+
if provider == "auto":
|
| 111 |
+
provider = first_configured_provider() or "pollinations"
|
| 112 |
+
|
| 113 |
+
if provider == "custom":
|
| 114 |
+
url = required_env("OPENAI_COMPATIBLE_BASE_URL")
|
| 115 |
+
api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY")
|
| 116 |
+
model = os.getenv("LLM_MODEL") or os.getenv("OPENAI_COMPATIBLE_MODEL") or required_env("LLM_MODEL")
|
| 117 |
+
else:
|
| 118 |
+
url = os.getenv(f"{provider.upper()}_BASE_URL") or OPENAI_COMPATIBLE_URLS[provider]
|
| 119 |
+
api_key_env = API_KEY_ENVS.get(provider)
|
| 120 |
+
api_key = os.getenv(api_key_env) if api_key_env else None
|
| 121 |
+
provider_model_env = os.getenv(f"{provider.upper()}_MODEL")
|
| 122 |
+
legacy_pollinations_model = os.getenv("POLLINATIONS_MODEL") if provider == "pollinations" else None
|
| 123 |
+
model = (
|
| 124 |
+
os.getenv("LLM_MODEL")
|
| 125 |
+
or provider_model_env
|
| 126 |
+
or legacy_pollinations_model
|
| 127 |
+
or DEFAULT_MODELS[provider]
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
ignore_proxy = os.getenv("LLM_IGNORE_PROXY", "1").strip().lower() not in {"0", "false", "no"}
|
| 131 |
+
return LLMConfig(provider=provider, url=url, model=model, api_key=api_key, ignore_proxy=ignore_proxy)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def normalize_provider(provider: str) -> str:
|
| 135 |
+
provider = provider.strip().lower()
|
| 136 |
+
aliases = {
|
| 137 |
+
"pollinations.ai": "pollinations",
|
| 138 |
+
"pollination": "pollinations",
|
| 139 |
+
"silicon": "siliconflow",
|
| 140 |
+
"sf": "siliconflow",
|
| 141 |
+
"google": "gemini",
|
| 142 |
+
"google-gemini": "gemini",
|
| 143 |
+
"openai-compatible": "custom",
|
| 144 |
+
}
|
| 145 |
+
provider = aliases.get(provider, provider)
|
| 146 |
+
allowed = {"auto", "pollinations", "groq", "siliconflow", "gemini", "openrouter", "custom"}
|
| 147 |
+
if provider not in allowed:
|
| 148 |
+
raise LLMError(f"未知 LLM_PROVIDER={provider},可选:{', '.join(sorted(allowed))}")
|
| 149 |
+
return provider
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def first_configured_provider() -> str | None:
|
| 153 |
+
for provider, env_name in API_KEY_ENVS.items():
|
| 154 |
+
if os.getenv(env_name):
|
| 155 |
+
return provider
|
| 156 |
+
if os.getenv("OPENAI_COMPATIBLE_BASE_URL"):
|
| 157 |
+
return "custom"
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def required_env(name: str) -> str:
|
| 162 |
+
value = os.getenv(name)
|
| 163 |
+
if not value:
|
| 164 |
+
raise LLMError(f"缺少环境变量 {name}")
|
| 165 |
+
return value
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def extract_json_from_text(text: str) -> dict[str, Any] | None:
|
| 169 |
+
text = text.strip()
|
| 170 |
+
if not text:
|
| 171 |
+
return None
|
| 172 |
+
if text.startswith("```"):
|
| 173 |
+
text = text.strip("`")
|
| 174 |
+
text = text.removeprefix("json").strip()
|
| 175 |
+
try:
|
| 176 |
+
return json.loads(text)
|
| 177 |
+
except json.JSONDecodeError:
|
| 178 |
+
pass
|
| 179 |
+
|
| 180 |
+
start = text.find("{")
|
| 181 |
+
end = text.rfind("}")
|
| 182 |
+
if start >= 0 and end > start:
|
| 183 |
+
try:
|
| 184 |
+
return json.loads(text[start : end + 1])
|
| 185 |
+
except json.JSONDecodeError:
|
| 186 |
+
return None
|
| 187 |
+
return None
|
routeopt_agent/models.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import Any, Literal
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
Objective = Literal["time", "distance"]
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class RouteTask:
|
| 12 |
+
raw_request: str
|
| 13 |
+
start_place: str
|
| 14 |
+
destination_places: list[str]
|
| 15 |
+
objective: Objective = "time"
|
| 16 |
+
return_to_start: bool = True
|
| 17 |
+
fixed_end_place: str | None = None
|
| 18 |
+
constraints: list[str] = field(default_factory=list)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class GeoPoint:
|
| 23 |
+
name: str
|
| 24 |
+
query: str
|
| 25 |
+
lat: float
|
| 26 |
+
lon: float
|
| 27 |
+
display_name: str
|
| 28 |
+
source: str
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass
|
| 32 |
+
class RouteMatrix:
|
| 33 |
+
points: list[GeoPoint]
|
| 34 |
+
durations: list[list[float]]
|
| 35 |
+
distances: list[list[float]]
|
| 36 |
+
source: str
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class RouteSolution:
|
| 41 |
+
route_indices: list[int]
|
| 42 |
+
route_names: list[str]
|
| 43 |
+
total_duration_seconds: float
|
| 44 |
+
total_distance_meters: float
|
| 45 |
+
objective: Objective
|
| 46 |
+
algorithm: str
|
| 47 |
+
leg_rows: list[list[Any]]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class ToolEvent:
|
| 52 |
+
step: int
|
| 53 |
+
tool: str
|
| 54 |
+
arguments: dict[str, Any]
|
| 55 |
+
status: str
|
| 56 |
+
result: str
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class AgentResult:
|
| 61 |
+
task: RouteTask
|
| 62 |
+
points: list[GeoPoint]
|
| 63 |
+
matrix: RouteMatrix
|
| 64 |
+
solution: RouteSolution
|
| 65 |
+
summary_markdown: str
|
| 66 |
+
trace: list[ToolEvent]
|
| 67 |
+
pdf_path: str
|
| 68 |
+
route_svg: str
|
| 69 |
+
warnings: list[str] = field(default_factory=list)
|
routeopt_agent/parsing.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
from .models import Objective, RouteTask
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def normalize_place_lines(text: str) -> list[str]:
|
| 9 |
+
places: list[str] = []
|
| 10 |
+
for line in text.replace(";", "\n").replace(";", "\n").splitlines():
|
| 11 |
+
item = line.strip(" \t\r\n-•、,,")
|
| 12 |
+
if item:
|
| 13 |
+
places.append(item)
|
| 14 |
+
return dedupe_preserve_order(places)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def dedupe_preserve_order(items: list[str]) -> list[str]:
|
| 18 |
+
seen: set[str] = set()
|
| 19 |
+
result: list[str] = []
|
| 20 |
+
for item in items:
|
| 21 |
+
key = item.strip().lower()
|
| 22 |
+
if key and key not in seen:
|
| 23 |
+
seen.add(key)
|
| 24 |
+
result.append(item.strip())
|
| 25 |
+
return result
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def infer_objective(raw_text: str, objective_hint: str | None) -> Objective:
|
| 29 |
+
hint = (objective_hint or "").strip().lower()
|
| 30 |
+
text = raw_text.lower()
|
| 31 |
+
if hint in {"distance", "最短距离", "距离最短"}:
|
| 32 |
+
return "distance"
|
| 33 |
+
if hint in {"time", "最短时间", "最快路线"}:
|
| 34 |
+
return "time"
|
| 35 |
+
if any(word in text for word in ["距离", "路程", "公里", "少走路", "最短路"]):
|
| 36 |
+
return "distance"
|
| 37 |
+
return "time"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def infer_return_to_start(raw_text: str, return_hint: bool | None) -> bool:
|
| 41 |
+
if return_hint is not None:
|
| 42 |
+
return bool(return_hint)
|
| 43 |
+
return any(word in raw_text for word in ["返回", "回到", "回起点", "最后回", "闭环"])
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def heuristic_extract_task(
|
| 47 |
+
raw_text: str,
|
| 48 |
+
start_hint: str = "",
|
| 49 |
+
destinations_hint: str = "",
|
| 50 |
+
objective_hint: str = "",
|
| 51 |
+
return_to_start_hint: bool | None = None,
|
| 52 |
+
fixed_end_hint: str = "",
|
| 53 |
+
) -> RouteTask:
|
| 54 |
+
raw_text = (raw_text or "").strip()
|
| 55 |
+
start = start_hint.strip() or extract_start(raw_text)
|
| 56 |
+
destinations = normalize_place_lines(destinations_hint)
|
| 57 |
+
if not destinations:
|
| 58 |
+
destinations = extract_destinations(raw_text)
|
| 59 |
+
|
| 60 |
+
fixed_end = fixed_end_hint.strip() or extract_fixed_end(raw_text)
|
| 61 |
+
objective = infer_objective(raw_text, objective_hint)
|
| 62 |
+
return_to_start = infer_return_to_start(raw_text, return_to_start_hint)
|
| 63 |
+
constraints = extract_constraints(raw_text)
|
| 64 |
+
|
| 65 |
+
return RouteTask(
|
| 66 |
+
raw_request=raw_text,
|
| 67 |
+
start_place=start,
|
| 68 |
+
destination_places=destinations,
|
| 69 |
+
objective=objective,
|
| 70 |
+
return_to_start=return_to_start,
|
| 71 |
+
fixed_end_place=fixed_end or None,
|
| 72 |
+
constraints=constraints,
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def extract_start(text: str) -> str:
|
| 77 |
+
patterns = [
|
| 78 |
+
r"从(?P<place>.+?)(?:出发|开始)",
|
| 79 |
+
r"起点(?:是|为|:|:)?(?P<place>[^,。,;;\n]+)",
|
| 80 |
+
r"我在(?P<place>[^,。,;;\n]+)",
|
| 81 |
+
]
|
| 82 |
+
for pattern in patterns:
|
| 83 |
+
match = re.search(pattern, text)
|
| 84 |
+
if match:
|
| 85 |
+
return cleanup_place(match.group("place"))
|
| 86 |
+
return ""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def extract_destinations(text: str) -> list[str]:
|
| 90 |
+
patterns = [
|
| 91 |
+
r"(?:去|逛完|访问|经过|打卡|游览)(?P<places>.+?)(?:,?最后|,?返回|,?回到|,?尽量|,?要求|。|$)",
|
| 92 |
+
r"目的地(?:是|为|:|:)?(?P<places>.+?)(?:。|$)",
|
| 93 |
+
]
|
| 94 |
+
for pattern in patterns:
|
| 95 |
+
match = re.search(pattern, text)
|
| 96 |
+
if match:
|
| 97 |
+
return split_places(match.group("places"))
|
| 98 |
+
return []
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def extract_fixed_end(text: str) -> str:
|
| 102 |
+
if any(word in text for word in ["返回", "回到", "回起点", "最后回"]):
|
| 103 |
+
return ""
|
| 104 |
+
match = re.search(r"最后(?:到|去|抵达)(?P<place>[^,。,;;\n]+)", text)
|
| 105 |
+
if match:
|
| 106 |
+
return cleanup_place(match.group("place"))
|
| 107 |
+
return ""
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def split_places(text: str) -> list[str]:
|
| 111 |
+
text = re.sub(r"(?:并且|然后|再|以及)", "、", text)
|
| 112 |
+
text = re.sub(r"(?:和|与)", "、", text)
|
| 113 |
+
pieces = re.split(r"[、,,;;\n]+", text)
|
| 114 |
+
return dedupe_preserve_order([cleanup_place(piece) for piece in pieces if cleanup_place(piece)])
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def cleanup_place(place: str) -> str:
|
| 118 |
+
place = place.strip()
|
| 119 |
+
place = re.sub(r"^(?:先|再|然后|顺便|还要|想|希望|需要)", "", place)
|
| 120 |
+
place = re.sub(r"(?:这些地方|几个地方|等地|附近)$", "", place)
|
| 121 |
+
return place.strip(" \t\r\n。.!!??")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def extract_constraints(text: str) -> list[str]:
|
| 125 |
+
constraints: list[str] = []
|
| 126 |
+
if any(word in text for word in ["必须先", "先去", "优先去"]):
|
| 127 |
+
constraints.append("用户表达了先后顺序偏好,当前版本会在报告中提示但不强制锁定顺序。")
|
| 128 |
+
if any(word in text for word in ["一天内", "半天", "预算", "不超过"]):
|
| 129 |
+
constraints.append("用户表达了时间/预算类软约束,当前版本会用于解释,不改变 TSP 最优目标。")
|
| 130 |
+
return constraints
|
routeopt_agent/report.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from reportlab.lib import colors
|
| 8 |
+
from reportlab.lib.enums import TA_LEFT
|
| 9 |
+
from reportlab.lib.pagesizes import A4
|
| 10 |
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
| 11 |
+
from reportlab.lib.units import cm
|
| 12 |
+
from reportlab.pdfbase import pdfmetrics
|
| 13 |
+
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
|
| 14 |
+
from reportlab.platypus import (
|
| 15 |
+
PageBreak,
|
| 16 |
+
Paragraph,
|
| 17 |
+
SimpleDocTemplate,
|
| 18 |
+
Spacer,
|
| 19 |
+
Table,
|
| 20 |
+
TableStyle,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
from .models import AgentResult, GeoPoint, RouteSolution, RouteTask, ToolEvent
|
| 24 |
+
from .solver import format_km, format_minutes
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
REPORT_DIR = Path("outputs")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def generate_pdf_report(
|
| 31 |
+
task: RouteTask,
|
| 32 |
+
points: list[GeoPoint],
|
| 33 |
+
solution: RouteSolution,
|
| 34 |
+
trace: list[ToolEvent],
|
| 35 |
+
summary_markdown: str,
|
| 36 |
+
) -> str:
|
| 37 |
+
REPORT_DIR.mkdir(parents=True, exist_ok=True)
|
| 38 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 39 |
+
path = REPORT_DIR / f"routeopt_report_{timestamp}.pdf"
|
| 40 |
+
|
| 41 |
+
pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light"))
|
| 42 |
+
doc = SimpleDocTemplate(
|
| 43 |
+
str(path),
|
| 44 |
+
pagesize=A4,
|
| 45 |
+
rightMargin=1.8 * cm,
|
| 46 |
+
leftMargin=1.8 * cm,
|
| 47 |
+
topMargin=1.6 * cm,
|
| 48 |
+
bottomMargin=1.6 * cm,
|
| 49 |
+
title="RouteOpt Agent 求解报告",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
styles = make_styles()
|
| 53 |
+
story: list = []
|
| 54 |
+
|
| 55 |
+
story.append(Paragraph("RouteOpt Agent 求解报告", styles["TitleCJK"]))
|
| 56 |
+
story.append(Paragraph(f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", styles["BodyCJK"]))
|
| 57 |
+
story.append(Spacer(1, 0.3 * cm))
|
| 58 |
+
|
| 59 |
+
story.append(Paragraph("1. 问题定义", styles["HeadingCJK"]))
|
| 60 |
+
story.extend(task_paragraphs(task, styles))
|
| 61 |
+
story.append(Spacer(1, 0.2 * cm))
|
| 62 |
+
|
| 63 |
+
story.append(Paragraph("2. 地点解析结果", styles["HeadingCJK"]))
|
| 64 |
+
story.append(make_points_table(points))
|
| 65 |
+
story.append(Spacer(1, 0.2 * cm))
|
| 66 |
+
|
| 67 |
+
story.append(Paragraph("3. 优化结果", styles["HeadingCJK"]))
|
| 68 |
+
story.extend(solution_paragraphs(solution, styles))
|
| 69 |
+
story.append(make_leg_table(solution))
|
| 70 |
+
story.append(Spacer(1, 0.2 * cm))
|
| 71 |
+
|
| 72 |
+
story.append(Paragraph("4. Agent 工具调用轨迹", styles["HeadingCJK"]))
|
| 73 |
+
story.append(make_trace_table(trace))
|
| 74 |
+
|
| 75 |
+
story.append(PageBreak())
|
| 76 |
+
story.append(Paragraph("5. 结果解释", styles["HeadingCJK"]))
|
| 77 |
+
for paragraph in markdown_to_plain_paragraphs(summary_markdown):
|
| 78 |
+
story.append(Paragraph(escape_for_pdf(paragraph), styles["BodyCJK"]))
|
| 79 |
+
story.append(Spacer(1, 0.12 * cm))
|
| 80 |
+
|
| 81 |
+
story.append(Paragraph("6. 局限性说明", styles["HeadingCJK"]))
|
| 82 |
+
limitations = [
|
| 83 |
+
"公开 API 可能存在限速、网络延迟或临时不可用;系统内置了上海示例地点和直线距离兜底策略。",
|
| 84 |
+
"OSRM 公共服务适合课程演示和小规模样例,不建议用于商业高并发生产环境。",
|
| 85 |
+
"本系统把大模型用于自然语言理解、工具调用规划和解释生成,把路线最优化交给确定性算法完成。",
|
| 86 |
+
]
|
| 87 |
+
for item in limitations:
|
| 88 |
+
story.append(Paragraph("• " + escape_for_pdf(item), styles["BodyCJK"]))
|
| 89 |
+
|
| 90 |
+
doc.build(story)
|
| 91 |
+
return str(path)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def make_styles() -> dict[str, ParagraphStyle]:
|
| 95 |
+
base = getSampleStyleSheet()
|
| 96 |
+
return {
|
| 97 |
+
"TitleCJK": ParagraphStyle(
|
| 98 |
+
"TitleCJK",
|
| 99 |
+
parent=base["Title"],
|
| 100 |
+
fontName="STSong-Light",
|
| 101 |
+
fontSize=20,
|
| 102 |
+
leading=26,
|
| 103 |
+
alignment=TA_LEFT,
|
| 104 |
+
),
|
| 105 |
+
"HeadingCJK": ParagraphStyle(
|
| 106 |
+
"HeadingCJK",
|
| 107 |
+
parent=base["Heading2"],
|
| 108 |
+
fontName="STSong-Light",
|
| 109 |
+
fontSize=14,
|
| 110 |
+
leading=20,
|
| 111 |
+
spaceBefore=8,
|
| 112 |
+
spaceAfter=6,
|
| 113 |
+
),
|
| 114 |
+
"BodyCJK": ParagraphStyle(
|
| 115 |
+
"BodyCJK",
|
| 116 |
+
parent=base["BodyText"],
|
| 117 |
+
fontName="STSong-Light",
|
| 118 |
+
fontSize=10.5,
|
| 119 |
+
leading=16,
|
| 120 |
+
wordWrap="CJK",
|
| 121 |
+
),
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def task_paragraphs(task: RouteTask, styles: dict[str, ParagraphStyle]) -> list[Paragraph]:
|
| 126 |
+
destination_text = "、".join(task.destination_places)
|
| 127 |
+
objective_text = "最短时间" if task.objective == "time" else "最短距离"
|
| 128 |
+
return_text = "需要回到起点" if task.return_to_start else "不需要回到起点"
|
| 129 |
+
fixed_end = task.fixed_end_place or "无"
|
| 130 |
+
raw_request = task.raw_request or "用户通过结构化表单输入任务。"
|
| 131 |
+
return [
|
| 132 |
+
Paragraph(f"原始需求:{escape_for_pdf(raw_request)}", styles["BodyCJK"]),
|
| 133 |
+
Paragraph(f"起点:{escape_for_pdf(task.start_place)}", styles["BodyCJK"]),
|
| 134 |
+
Paragraph(f"目的地:{escape_for_pdf(destination_text)}", styles["BodyCJK"]),
|
| 135 |
+
Paragraph(f"优化目标:{objective_text};回程设置:{return_text};固定终点:{escape_for_pdf(fixed_end)}", styles["BodyCJK"]),
|
| 136 |
+
]
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def solution_paragraphs(solution: RouteSolution, styles: dict[str, ParagraphStyle]) -> list[Paragraph]:
|
| 140 |
+
return [
|
| 141 |
+
Paragraph(f"访问顺序:{escape_for_pdf(' → '.join(solution.route_names))}", styles["BodyCJK"]),
|
| 142 |
+
Paragraph(
|
| 143 |
+
f"总距离:{format_km(solution.total_distance_meters)};预计驾驶时间:{format_minutes(solution.total_duration_seconds)}",
|
| 144 |
+
styles["BodyCJK"],
|
| 145 |
+
),
|
| 146 |
+
Paragraph(f"求解算法:{solution.algorithm}", styles["BodyCJK"]),
|
| 147 |
+
]
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def make_points_table(points: list[GeoPoint]) -> Table:
|
| 151 |
+
data = [["序号", "地点", "纬度", "经度", "数据源"]]
|
| 152 |
+
for idx, point in enumerate(points):
|
| 153 |
+
data.append([idx, point.name, f"{point.lat:.6f}", f"{point.lon:.6f}", point.source])
|
| 154 |
+
table = Table(data, colWidths=[1.0 * cm, 4.3 * cm, 3.0 * cm, 3.0 * cm, 4.1 * cm])
|
| 155 |
+
apply_table_style(table)
|
| 156 |
+
return table
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def make_leg_table(solution: RouteSolution) -> Table:
|
| 160 |
+
data = [["段", "从", "到", "距离", "时间"]] + solution.leg_rows
|
| 161 |
+
table = Table(data, colWidths=[1.0 * cm, 4.3 * cm, 4.3 * cm, 2.5 * cm, 2.5 * cm])
|
| 162 |
+
apply_table_style(table)
|
| 163 |
+
return table
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def make_trace_table(trace: list[ToolEvent]) -> Table:
|
| 167 |
+
data = [["步骤", "工具", "状态", "结果摘要"]]
|
| 168 |
+
for event in trace:
|
| 169 |
+
data.append([event.step, event.tool, event.status, event.result[:120]])
|
| 170 |
+
table = Table(data, colWidths=[1.0 * cm, 4.0 * cm, 2.0 * cm, 8.6 * cm], repeatRows=1)
|
| 171 |
+
apply_table_style(table)
|
| 172 |
+
return table
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def apply_table_style(table: Table) -> None:
|
| 176 |
+
table.setStyle(
|
| 177 |
+
TableStyle(
|
| 178 |
+
[
|
| 179 |
+
("FONTNAME", (0, 0), (-1, -1), "STSong-Light"),
|
| 180 |
+
("FONTSIZE", (0, 0), (-1, -1), 8.5),
|
| 181 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eef2f7")),
|
| 182 |
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
|
| 183 |
+
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#d1d5db")),
|
| 184 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 185 |
+
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f8fafc")]),
|
| 186 |
+
]
|
| 187 |
+
)
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def markdown_to_plain_paragraphs(markdown: str) -> list[str]:
|
| 192 |
+
lines = []
|
| 193 |
+
for line in markdown.splitlines():
|
| 194 |
+
clean = line.strip()
|
| 195 |
+
if not clean:
|
| 196 |
+
continue
|
| 197 |
+
clean = clean.replace("**", "")
|
| 198 |
+
clean = clean.lstrip("#").strip()
|
| 199 |
+
clean = clean.lstrip("-").strip()
|
| 200 |
+
lines.append(clean)
|
| 201 |
+
return lines or ["Agent 已完成路线优化并生成结构化结果。"]
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def escape_for_pdf(text: str) -> str:
|
| 205 |
+
return html.escape(str(text)).replace("\n", "<br/>")
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def result_to_markdown(result: AgentResult) -> str:
|
| 209 |
+
solution = result.solution
|
| 210 |
+
return (
|
| 211 |
+
f"**访问顺序**:{' → '.join(solution.route_names)}\n\n"
|
| 212 |
+
f"**总距离**:{format_km(solution.total_distance_meters)} \n"
|
| 213 |
+
f"**预计时间**:{format_minutes(solution.total_duration_seconds)}\n\n"
|
| 214 |
+
f"**算法**:{solution.algorithm}\n\n"
|
| 215 |
+
f"{result.summary_markdown}"
|
| 216 |
+
)
|
routeopt_agent/solver.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from .models import Objective, RouteMatrix, RouteSolution
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class SolverError(RuntimeError):
|
| 9 |
+
pass
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def solve_route(
|
| 13 |
+
matrix: RouteMatrix,
|
| 14 |
+
objective: Objective,
|
| 15 |
+
return_to_start: bool,
|
| 16 |
+
fixed_end_place: str | None = None,
|
| 17 |
+
) -> RouteSolution:
|
| 18 |
+
if len(matrix.points) < 2:
|
| 19 |
+
raise SolverError("至少需要起点和 1 个目的地。")
|
| 20 |
+
|
| 21 |
+
cost_matrix = matrix.durations if objective == "time" else matrix.distances
|
| 22 |
+
fixed_end_idx = find_fixed_end_index(matrix, fixed_end_place)
|
| 23 |
+
route_indices, objective_cost = held_karp(
|
| 24 |
+
cost_matrix=cost_matrix,
|
| 25 |
+
return_to_start=return_to_start,
|
| 26 |
+
fixed_end_idx=fixed_end_idx,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
total_duration = route_cost(matrix.durations, route_indices)
|
| 30 |
+
total_distance = route_cost(matrix.distances, route_indices)
|
| 31 |
+
route_names = [matrix.points[idx].name for idx in route_indices]
|
| 32 |
+
leg_rows = build_leg_rows(matrix, route_indices)
|
| 33 |
+
|
| 34 |
+
if len(matrix.points) <= 10:
|
| 35 |
+
algorithm = "Held-Karp dynamic programming exact TSP solver"
|
| 36 |
+
else:
|
| 37 |
+
algorithm = "Nearest-neighbor + 2-opt heuristic"
|
| 38 |
+
|
| 39 |
+
if objective_cost < 0:
|
| 40 |
+
raise SolverError("求解失败:目标函数成本异常。")
|
| 41 |
+
|
| 42 |
+
return RouteSolution(
|
| 43 |
+
route_indices=route_indices,
|
| 44 |
+
route_names=route_names,
|
| 45 |
+
total_duration_seconds=total_duration,
|
| 46 |
+
total_distance_meters=total_distance,
|
| 47 |
+
objective=objective,
|
| 48 |
+
algorithm=algorithm,
|
| 49 |
+
leg_rows=leg_rows,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def held_karp(
|
| 54 |
+
cost_matrix: list[list[float]],
|
| 55 |
+
return_to_start: bool,
|
| 56 |
+
fixed_end_idx: int | None,
|
| 57 |
+
) -> tuple[list[int], float]:
|
| 58 |
+
node_count = len(cost_matrix)
|
| 59 |
+
if node_count == 1:
|
| 60 |
+
return [0], 0.0
|
| 61 |
+
|
| 62 |
+
if return_to_start:
|
| 63 |
+
fixed_end_idx = None
|
| 64 |
+
|
| 65 |
+
all_destinations = list(range(1, node_count))
|
| 66 |
+
visit_nodes = [idx for idx in all_destinations if idx != fixed_end_idx]
|
| 67 |
+
|
| 68 |
+
if not visit_nodes:
|
| 69 |
+
if fixed_end_idx is None:
|
| 70 |
+
return [0, 0] if return_to_start else [0], 0.0
|
| 71 |
+
return [0, fixed_end_idx], safe_cost(cost_matrix, 0, fixed_end_idx)
|
| 72 |
+
|
| 73 |
+
bit_for_node = {node: 1 << pos for pos, node in enumerate(visit_nodes)}
|
| 74 |
+
dp: dict[tuple[int, int], tuple[float, int | None]] = {}
|
| 75 |
+
|
| 76 |
+
for node in visit_nodes:
|
| 77 |
+
mask = bit_for_node[node]
|
| 78 |
+
dp[(mask, node)] = (safe_cost(cost_matrix, 0, node), None)
|
| 79 |
+
|
| 80 |
+
full_mask = (1 << len(visit_nodes)) - 1
|
| 81 |
+
for mask in range(1, full_mask + 1):
|
| 82 |
+
for last in visit_nodes:
|
| 83 |
+
if not mask & bit_for_node[last]:
|
| 84 |
+
continue
|
| 85 |
+
current = dp.get((mask, last))
|
| 86 |
+
if current is None:
|
| 87 |
+
continue
|
| 88 |
+
current_cost, _ = current
|
| 89 |
+
for nxt in visit_nodes:
|
| 90 |
+
nxt_bit = bit_for_node[nxt]
|
| 91 |
+
if mask & nxt_bit:
|
| 92 |
+
continue
|
| 93 |
+
next_mask = mask | nxt_bit
|
| 94 |
+
next_cost = current_cost + safe_cost(cost_matrix, last, nxt)
|
| 95 |
+
old = dp.get((next_mask, nxt))
|
| 96 |
+
if old is None or next_cost < old[0]:
|
| 97 |
+
dp[(next_mask, nxt)] = (next_cost, last)
|
| 98 |
+
|
| 99 |
+
best_last: int | None = None
|
| 100 |
+
best_cost = float("inf")
|
| 101 |
+
for last in visit_nodes:
|
| 102 |
+
state = dp.get((full_mask, last))
|
| 103 |
+
if state is None:
|
| 104 |
+
continue
|
| 105 |
+
total = state[0]
|
| 106 |
+
if fixed_end_idx is not None:
|
| 107 |
+
total += safe_cost(cost_matrix, last, fixed_end_idx)
|
| 108 |
+
elif return_to_start:
|
| 109 |
+
total += safe_cost(cost_matrix, last, 0)
|
| 110 |
+
if total < best_cost:
|
| 111 |
+
best_cost = total
|
| 112 |
+
best_last = last
|
| 113 |
+
|
| 114 |
+
if best_last is None:
|
| 115 |
+
raise SolverError("Held-Karp 求解失败。")
|
| 116 |
+
|
| 117 |
+
path = reconstruct_path(dp, bit_for_node, full_mask, best_last)
|
| 118 |
+
route = [0] + path
|
| 119 |
+
if fixed_end_idx is not None:
|
| 120 |
+
route.append(fixed_end_idx)
|
| 121 |
+
elif return_to_start:
|
| 122 |
+
route.append(0)
|
| 123 |
+
return route, best_cost
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def reconstruct_path(
|
| 127 |
+
dp: dict[tuple[int, int], tuple[float, int | None]],
|
| 128 |
+
bit_for_node: dict[int, int],
|
| 129 |
+
mask: int,
|
| 130 |
+
last: int,
|
| 131 |
+
) -> list[int]:
|
| 132 |
+
reversed_path: list[int] = []
|
| 133 |
+
current_last: int | None = last
|
| 134 |
+
current_mask = mask
|
| 135 |
+
while current_last is not None:
|
| 136 |
+
reversed_path.append(current_last)
|
| 137 |
+
_, prev = dp[(current_mask, current_last)]
|
| 138 |
+
current_mask ^= bit_for_node[current_last]
|
| 139 |
+
current_last = prev
|
| 140 |
+
return list(reversed(reversed_path))
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def find_fixed_end_index(matrix: RouteMatrix, fixed_end_place: str | None) -> int | None:
|
| 144 |
+
if not fixed_end_place:
|
| 145 |
+
return None
|
| 146 |
+
normalized = fixed_end_place.strip().lower()
|
| 147 |
+
for idx, point in enumerate(matrix.points):
|
| 148 |
+
if idx == 0:
|
| 149 |
+
continue
|
| 150 |
+
if normalized in point.name.lower() or point.name.lower() in normalized:
|
| 151 |
+
return idx
|
| 152 |
+
return None
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def route_cost(cost_matrix: list[list[float]], route_indices: list[int]) -> float:
|
| 156 |
+
total = 0.0
|
| 157 |
+
for origin, dest in zip(route_indices, route_indices[1:]):
|
| 158 |
+
total += safe_cost(cost_matrix, origin, dest)
|
| 159 |
+
return total
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def safe_cost(cost_matrix: list[list[float]], origin: int, dest: int) -> float:
|
| 163 |
+
value = cost_matrix[origin][dest]
|
| 164 |
+
if value is None:
|
| 165 |
+
return float("inf")
|
| 166 |
+
return float(value)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def build_leg_rows(matrix: RouteMatrix, route_indices: list[int]) -> list[list[Any]]:
|
| 170 |
+
rows: list[list[Any]] = []
|
| 171 |
+
for step, (origin, dest) in enumerate(zip(route_indices, route_indices[1:]), start=1):
|
| 172 |
+
rows.append(
|
| 173 |
+
[
|
| 174 |
+
step,
|
| 175 |
+
matrix.points[origin].name,
|
| 176 |
+
matrix.points[dest].name,
|
| 177 |
+
format_km(matrix.distances[origin][dest]),
|
| 178 |
+
format_minutes(matrix.durations[origin][dest]),
|
| 179 |
+
]
|
| 180 |
+
)
|
| 181 |
+
return rows
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def format_km(meters: float) -> str:
|
| 185 |
+
return f"{meters / 1000:.2f} km"
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def format_minutes(seconds: float) -> str:
|
| 189 |
+
return f"{seconds / 60:.1f} min"
|
routeopt_agent/viz.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import html
|
| 4 |
+
|
| 5 |
+
from .models import GeoPoint
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def build_route_svg(points: list[GeoPoint], route_indices: list[int]) -> str:
|
| 9 |
+
if not points or not route_indices:
|
| 10 |
+
return ""
|
| 11 |
+
|
| 12 |
+
width = 760
|
| 13 |
+
height = 420
|
| 14 |
+
padding = 46
|
| 15 |
+
lats = [point.lat for point in points]
|
| 16 |
+
lons = [point.lon for point in points]
|
| 17 |
+
min_lat, max_lat = min(lats), max(lats)
|
| 18 |
+
min_lon, max_lon = min(lons), max(lons)
|
| 19 |
+
lat_span = max(max_lat - min_lat, 0.001)
|
| 20 |
+
lon_span = max(max_lon - min_lon, 0.001)
|
| 21 |
+
|
| 22 |
+
def project(point: GeoPoint) -> tuple[float, float]:
|
| 23 |
+
x = padding + (point.lon - min_lon) / lon_span * (width - 2 * padding)
|
| 24 |
+
y = height - padding - (point.lat - min_lat) / lat_span * (height - 2 * padding)
|
| 25 |
+
return x, y
|
| 26 |
+
|
| 27 |
+
projected = [project(point) for point in points]
|
| 28 |
+
route_points = [projected[idx] for idx in route_indices]
|
| 29 |
+
polyline = " ".join(f"{x:.1f},{y:.1f}" for x, y in route_points)
|
| 30 |
+
|
| 31 |
+
marker_svg = []
|
| 32 |
+
first_visit_number: dict[int, int] = {}
|
| 33 |
+
for order, idx in enumerate(route_indices, start=1):
|
| 34 |
+
first_visit_number.setdefault(idx, order)
|
| 35 |
+
|
| 36 |
+
for idx, point in enumerate(points):
|
| 37 |
+
x, y = projected[idx]
|
| 38 |
+
order = first_visit_number.get(idx, idx + 1)
|
| 39 |
+
is_start = idx == 0
|
| 40 |
+
fill = "#0f766e" if is_start else "#2563eb"
|
| 41 |
+
marker_svg.append(
|
| 42 |
+
f"""
|
| 43 |
+
<g>
|
| 44 |
+
<circle cx="{x:.1f}" cy="{y:.1f}" r="15" fill="{fill}" stroke="#ffffff" stroke-width="3"/>
|
| 45 |
+
<text x="{x:.1f}" y="{y + 4:.1f}" text-anchor="middle" font-size="12" font-weight="700" fill="#ffffff">{order}</text>
|
| 46 |
+
<text x="{x + 19:.1f}" y="{y - 16:.1f}" font-size="12" font-weight="600" fill="#111827">{html.escape(point.name)}</text>
|
| 47 |
+
</g>
|
| 48 |
+
"""
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
return f"""
|
| 52 |
+
<div style="width: 100%;">
|
| 53 |
+
<svg viewBox="0 0 {width} {height}" role="img" aria-label="Route diagram" style="width:100%;height:auto;border:1px solid #d9e2ec;border-radius:8px;">
|
| 54 |
+
<defs>
|
| 55 |
+
<marker id="arrow" markerWidth="10" markerHeight="10" refX="7" refY="3" orient="auto" markerUnits="strokeWidth">
|
| 56 |
+
<path d="M0,0 L0,6 L8,3 z" fill="#334155" />
|
| 57 |
+
</marker>
|
| 58 |
+
</defs>
|
| 59 |
+
<rect x="0" y="0" width="{width}" height="{height}" rx="8" fill="#f8fafc"/>
|
| 60 |
+
<path d="M {polyline.replace(' ', ' L ')}" fill="none" stroke="#334155" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" marker-end="url(#arrow)"/>
|
| 61 |
+
{''.join(marker_svg)}
|
| 62 |
+
</svg>
|
| 63 |
+
</div>
|
| 64 |
+
"""
|
run.sh
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
ENV_NAME="${ROUTEOPT_ENV:-routeopt-agent}"
|
| 5 |
+
|
| 6 |
+
ensure_deps() {
|
| 7 |
+
if conda run -n "$ENV_NAME" python -c "import gradio, requests, reportlab" >/dev/null 2>&1; then
|
| 8 |
+
echo "依赖已安装"
|
| 9 |
+
else
|
| 10 |
+
echo "安装 Python 依赖"
|
| 11 |
+
conda run -n "$ENV_NAME" python -m pip install -r requirements.txt
|
| 12 |
+
fi
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
if ! command -v conda >/dev/null 2>&1; then
|
| 16 |
+
echo "未找到 conda。请先安装 Miniconda 或 Anaconda,然后重新运行 ./run.sh"
|
| 17 |
+
exit 1
|
| 18 |
+
fi
|
| 19 |
+
|
| 20 |
+
if [ -f ".env" ]; then
|
| 21 |
+
echo "加载 .env 配置"
|
| 22 |
+
set -a
|
| 23 |
+
# shellcheck disable=SC1091
|
| 24 |
+
source ".env"
|
| 25 |
+
set +a
|
| 26 |
+
fi
|
| 27 |
+
|
| 28 |
+
CONDA_BASE="$(conda info --base)"
|
| 29 |
+
source "$CONDA_BASE/etc/profile.d/conda.sh"
|
| 30 |
+
|
| 31 |
+
if ! conda env list | awk '{print $1}' | grep -qx "$ENV_NAME"; then
|
| 32 |
+
echo "首次运行:创建 conda 环境 $ENV_NAME"
|
| 33 |
+
if ! conda env create -f environment.yml; then
|
| 34 |
+
echo "标准环境创建失败,尝试克隆 base 环境后用 pip 安装依赖"
|
| 35 |
+
conda create -n "$ENV_NAME" --clone base -y
|
| 36 |
+
fi
|
| 37 |
+
else
|
| 38 |
+
echo "检查 Python 依赖"
|
| 39 |
+
fi
|
| 40 |
+
|
| 41 |
+
ensure_deps
|
| 42 |
+
|
| 43 |
+
echo "启动 RouteOpt Agent"
|
| 44 |
+
conda run -n "$ENV_NAME" python app.py
|