fenghantong commited on
Commit
ffda755
·
1 Parent(s): ea1b59d

Deploy RouteOpt Agent

Browse files
.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: Alg Agent
3
- emoji: 🏢
4
- colorFrom: indigo
5
  colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.19.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
11
- short_description: 通用问题智能体-算法分析与设计课程作业
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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