init
This commit is contained in:
17
.env.example
Normal file
17
.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# LLM - Doubao / Volcano Ark (OpenAI-compatible)
|
||||||
|
ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||||
|
ARK_API_KEY=your-ark-api-key-here
|
||||||
|
ARK_MODEL=doubao-seed-2-0-pro-260215
|
||||||
|
ARK_REQUEST_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
|
# Amap MCP remote service
|
||||||
|
AMAP_MCP_URL=https://your-amap-mcp-endpoint
|
||||||
|
AMAP_MCP_TRANSPORT=streamable_http
|
||||||
|
AMAP_MCP_TIMEOUT_SECONDS=20
|
||||||
|
AMAP_MCP_READ_TIMEOUT_SECONDS=60
|
||||||
|
# Optional if the remote MCP requires a custom auth header
|
||||||
|
AMAP_MCP_AUTH_HEADER_NAME=
|
||||||
|
AMAP_MCP_AUTH_HEADER_VALUE=
|
||||||
|
|
||||||
|
# Route planning guardrails
|
||||||
|
ROUTE_MAX_PERMUTATIONS=20
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Python-generated files
|
||||||
|
__pycache__/
|
||||||
|
*.py[oc]
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
wheels/
|
||||||
|
*.egg-info
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
249
agent.py
Normal file
249
agent.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
from pydantic_ai import Agent
|
||||||
|
from pydantic_ai.mcp import MCPServerHTTP, MCPServerSSE, MCPServerStreamableHTTP
|
||||||
|
from pydantic_ai.models.openai import OpenAIModel
|
||||||
|
from pydantic_ai.providers.openai import OpenAIProvider
|
||||||
|
|
||||||
|
from schemas import RoutePlanRequest, RoutePlanResult
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigurationError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class GuardrailError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _required_env(name: str) -> str:
|
||||||
|
value = os.getenv(name, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ConfigurationError(f"Missing required environment variable: {name}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _env_positive_int(name: str, default: int) -> int:
|
||||||
|
raw_value = os.getenv(name, str(default)).strip()
|
||||||
|
try:
|
||||||
|
parsed = int(raw_value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfigurationError(f"Environment variable {name} must be an integer") from exc
|
||||||
|
|
||||||
|
if parsed <= 0:
|
||||||
|
raise ConfigurationError(f"Environment variable {name} must be greater than 0")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _env_positive_float(name: str, default: float) -> float:
|
||||||
|
raw_value = os.getenv(name, str(default)).strip()
|
||||||
|
try:
|
||||||
|
parsed = float(raw_value)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ConfigurationError(f"Environment variable {name} must be a number") from exc
|
||||||
|
|
||||||
|
if parsed <= 0:
|
||||||
|
raise ConfigurationError(f"Environment variable {name} must be greater than 0")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _build_model() -> OpenAIModel:
|
||||||
|
openai_client = AsyncOpenAI(
|
||||||
|
base_url=_required_env("ARK_BASE_URL"),
|
||||||
|
api_key=_required_env("ARK_API_KEY"),
|
||||||
|
timeout=_env_positive_float("ARK_REQUEST_TIMEOUT_SECONDS", 120.0),
|
||||||
|
)
|
||||||
|
provider = OpenAIProvider(
|
||||||
|
openai_client=openai_client,
|
||||||
|
)
|
||||||
|
return OpenAIModel(
|
||||||
|
_required_env("ARK_MODEL"),
|
||||||
|
provider=provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mcp_headers() -> dict[str, str] | None:
|
||||||
|
header_name = os.getenv("AMAP_MCP_AUTH_HEADER_NAME", "").strip()
|
||||||
|
header_value = os.getenv("AMAP_MCP_AUTH_HEADER_VALUE", "").strip()
|
||||||
|
|
||||||
|
if header_name and header_value:
|
||||||
|
return {header_name: header_value}
|
||||||
|
if header_name or header_value:
|
||||||
|
raise ConfigurationError(
|
||||||
|
"AMAP_MCP_AUTH_HEADER_NAME and AMAP_MCP_AUTH_HEADER_VALUE must be set together"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mcp_server() -> MCPServerHTTP | MCPServerSSE | MCPServerStreamableHTTP:
|
||||||
|
transport = os.getenv("AMAP_MCP_TRANSPORT", "streamable_http").strip().lower()
|
||||||
|
url = _required_env("AMAP_MCP_URL")
|
||||||
|
headers = _build_mcp_headers()
|
||||||
|
timeout = _env_positive_float("AMAP_MCP_TIMEOUT_SECONDS", 20.0)
|
||||||
|
read_timeout = _env_positive_float("AMAP_MCP_READ_TIMEOUT_SECONDS", 60.0)
|
||||||
|
|
||||||
|
if transport == "sse":
|
||||||
|
return MCPServerSSE(url=url, headers=headers, timeout=timeout, read_timeout=read_timeout)
|
||||||
|
if transport == "http":
|
||||||
|
return MCPServerHTTP(url=url, headers=headers, timeout=timeout, read_timeout=read_timeout)
|
||||||
|
if transport == "streamable_http":
|
||||||
|
return MCPServerStreamableHTTP(
|
||||||
|
url=url,
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
read_timeout=read_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise ConfigurationError(
|
||||||
|
"AMAP_MCP_TRANSPORT must be one of: streamable_http, http, sse"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Agent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """\
|
||||||
|
你是一个无状态的多目标地理路线规划 Agent。你的任务是使用高德地图 MCP 工具完成多目标点最优路线规划,并返回强类型结构化结果。\
|
||||||
|
你必须显式调用地图工具,不得编造坐标、POI、距离、时长或 deep link。
|
||||||
|
|
||||||
|
你的工作流必须严格遵守以下规则:
|
||||||
|
|
||||||
|
1. 先解析输入,明确起点模式、终点、途经点、优化策略和输出需求。
|
||||||
|
2. 对终点和每个途经点优先使用 maps_geo 解析地址;当命中不准或为空时,使用 maps_text_search 补齐 POI;必要时使用 maps_search_detail 校验。
|
||||||
|
3. 当起点模式为 fixed 时,对起点也做同样解析。
|
||||||
|
4. 当起点模式为 current_location 时,不得伪造起点坐标;如果缺少实时定位坐标,只能比较途经点内部顺序,并明确说明真实最优路线会受当前定位影响。
|
||||||
|
5. 这套工具没有单步多点最优路径工具,因此你必须自己生成候选途经点顺序。
|
||||||
|
6. 对每个候选顺序,逐段调用 maps_direction_driving 计算距离与时长,并汇总为候选路线。
|
||||||
|
7. 按 route_strategy 选择最优路线:
|
||||||
|
- shortest_distance:总里程优先,总时长次优
|
||||||
|
- fastest_time:总时长优先,总里程次优
|
||||||
|
- balanced:优先选择明显不劣的 Pareto 优势路线;若出现里程更短但时间略长的情况,默认优先里程更短并说明原因
|
||||||
|
8. 如需 deep link:
|
||||||
|
- 若目标是稳定导入整组点位,可使用 maps_schema_personal_map,但必须说明这是点位导入型链接
|
||||||
|
- 若目标是"我的位置"出发的即时导航,应输出 route plan 类 deep link,并说明不同平台协议不同
|
||||||
|
9. 最终结果必须包含:resolved_origin(如适用)、resolved_destination、resolved_stops、candidates、best_route、deep_links、summary、warnings。
|
||||||
|
10. 如果任何地址无法可靠解析、任何 POI 无法获取、或任何路线比较存在信息缺失,必须如实说明,不得猜测。
|
||||||
|
11. 你的输入会以 RoutePlanRequest JSON 形式提供。你必须基于该 JSON 进行规划,不能擅自补造字段。
|
||||||
|
12. 在返回结果前,必须确认 best_route 来自 candidates 之一,且每一段 distance 和 duration 都来自工具调用。
|
||||||
|
|
||||||
|
你的底层返回必须是结构化数据,不是 HTML。只有在用户明确要求页面展示时,才在结构化结果基础上额外生成 HTML。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _configured_max_permutations() -> int:
|
||||||
|
return _env_positive_int("ROUTE_MAX_PERMUTATIONS", 20)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_count(stop_count: int) -> int:
|
||||||
|
return math.factorial(stop_count)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_request(request: RoutePlanRequest) -> RoutePlanRequest:
|
||||||
|
configured_limit = _configured_max_permutations()
|
||||||
|
effective_limit = request.max_permutations or configured_limit
|
||||||
|
|
||||||
|
if effective_limit > configured_limit:
|
||||||
|
raise GuardrailError(
|
||||||
|
"Requested max_permutations exceeds the configured service limit: "
|
||||||
|
f"requested={effective_limit}, configured={configured_limit}"
|
||||||
|
)
|
||||||
|
|
||||||
|
candidate_count = _candidate_count(len(request.stops))
|
||||||
|
if candidate_count > effective_limit:
|
||||||
|
raise GuardrailError(
|
||||||
|
"Candidate permutations exceed the configured limit: "
|
||||||
|
f"stops={len(request.stops)}, permutations={candidate_count}, limit={effective_limit}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return request.model_copy(update={"max_permutations": effective_limit})
|
||||||
|
|
||||||
|
|
||||||
|
def _build_user_prompt(request: RoutePlanRequest) -> str:
|
||||||
|
request_json = json.dumps(request.model_dump(mode="json"), ensure_ascii=False, indent=2)
|
||||||
|
return (
|
||||||
|
"请根据下面的 RoutePlanRequest JSON 执行多目标路线规划。\n"
|
||||||
|
"必须显式使用高德 MCP 工具完成地址解析、逐段驾车路线计算和 deep link 生成。\n"
|
||||||
|
"如果输入中存在固定起点,则完整路线必须从起点开始;如果起点模式是 current_location,"
|
||||||
|
"则不得伪造起点坐标。\n"
|
||||||
|
f"本次运行的候选顺序硬上限是 {request.max_permutations}。\n\n"
|
||||||
|
"RoutePlanRequest JSON:\n"
|
||||||
|
f"{request_json}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _same_candidate(left_candidate, right_candidate) -> bool:
|
||||||
|
return (
|
||||||
|
left_candidate.full_order_labels == right_candidate.full_order_labels
|
||||||
|
and left_candidate.total_distance_m == right_candidate.total_distance_m
|
||||||
|
and left_candidate.total_duration_s == right_candidate.total_duration_s
|
||||||
|
and len(left_candidate.legs) == len(right_candidate.legs)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_result(request: RoutePlanRequest, result: RoutePlanResult) -> RoutePlanResult:
|
||||||
|
if result.origin_mode != request.origin_mode:
|
||||||
|
raise GuardrailError("Agent output origin_mode does not match the request")
|
||||||
|
|
||||||
|
if result.resolved_destination.role != "destination":
|
||||||
|
raise GuardrailError("resolved_destination.role must be 'destination'")
|
||||||
|
|
||||||
|
if len(result.resolved_stops) != len(request.stops):
|
||||||
|
raise GuardrailError("resolved_stops count does not match the request")
|
||||||
|
|
||||||
|
if any(stop.role != "stop" for stop in result.resolved_stops):
|
||||||
|
raise GuardrailError("All resolved_stops entries must have role='stop'")
|
||||||
|
|
||||||
|
if request.origin_mode == "fixed":
|
||||||
|
if result.resolved_origin is None:
|
||||||
|
raise GuardrailError("resolved_origin is required when origin_mode='fixed'")
|
||||||
|
if result.resolved_origin.role != "origin":
|
||||||
|
raise GuardrailError("resolved_origin.role must be 'origin'")
|
||||||
|
else:
|
||||||
|
if result.resolved_origin is not None:
|
||||||
|
raise GuardrailError("resolved_origin must be null when origin_mode='current_location'")
|
||||||
|
|
||||||
|
if result.success:
|
||||||
|
if not result.candidates:
|
||||||
|
raise GuardrailError("A successful result must include at least one candidate route")
|
||||||
|
|
||||||
|
if len(result.candidates) > (request.max_permutations or 0):
|
||||||
|
raise GuardrailError("Agent returned more candidates than allowed by max_permutations")
|
||||||
|
|
||||||
|
matching_candidate = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in result.candidates
|
||||||
|
if _same_candidate(candidate, result.best_route)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if matching_candidate is None:
|
||||||
|
raise GuardrailError("best_route must be one of the candidates")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def create_geo_agent() -> Agent[None, RoutePlanResult]:
|
||||||
|
return Agent(
|
||||||
|
model=_build_model(),
|
||||||
|
toolsets=[_build_mcp_server()],
|
||||||
|
output_type=RoutePlanResult,
|
||||||
|
system_prompt=SYSTEM_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def run_route_plan(request: RoutePlanRequest) -> RoutePlanResult:
|
||||||
|
"""Execute the route planning agent for a given request."""
|
||||||
|
prepared_request = _prepare_request(request)
|
||||||
|
agent = create_geo_agent()
|
||||||
|
async with agent:
|
||||||
|
result = await agent.run(_build_user_prompt(prepared_request))
|
||||||
|
return _validate_result(prepared_request, result.output)
|
||||||
46
docs/2026-04-13_summary.md
Normal file
46
docs/2026-04-13_summary.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
## 背景
|
||||||
|
|
||||||
|
- 新建 `geo-agent` 项目,使用 `uv init` 初始化。
|
||||||
|
- 目标是基于 `pydantic_ai`、FastAPI 和高德远程 MCP 服务实现多目标地点最优路线规划 Agent。
|
||||||
|
- 当前开发流程要求先搭框架,再逐步补齐 MCP 接入与业务实现。
|
||||||
|
|
||||||
|
## 今日完成
|
||||||
|
|
||||||
|
- 检查并确认项目 Python 版本为 3.14.2,满足 `pydantic-ai` 的版本要求。
|
||||||
|
- 创建基础项目结构,加入 FastAPI 入口、Pydantic 请求/响应模型、Agent 骨架和环境变量模板。
|
||||||
|
- 安装项目依赖并修正 `pydantic-ai` 当前版本的接入方式。
|
||||||
|
- 修复运行时报错:
|
||||||
|
- `OpenAIModel` 不接受 `openai_client=` 参数,改为 `OpenAIProvider`。
|
||||||
|
- `Agent` 改为使用 `toolsets` 挂载 MCP。
|
||||||
|
- `result_type` 改为 `output_type`。
|
||||||
|
- `run_mcp_servers()` 改为 `async with agent:` 的当前推荐写法。
|
||||||
|
- 增加 `/healthz` 健康检查接口。
|
||||||
|
- 为高德远程 MCP 增加显式配置项:URL、传输类型、可选认证头。
|
||||||
|
|
||||||
|
## 当前状态
|
||||||
|
|
||||||
|
- FastAPI 应用可正常导入和启动。
|
||||||
|
- `/healthz` 返回 200。
|
||||||
|
- 高德远程 MCP 已配置到 `.env`,传输方式确认为 `streamable_http`。
|
||||||
|
- 已验证可连通高德 MCP,并成功枚举出 15 个工具,包括 `maps_geo`、`maps_text_search`、`maps_search_detail`、`maps_direction_driving`、`maps_distance`、`maps_schema_personal_map`、`maps_schema_navi`、`maps_weather` 等。
|
||||||
|
- `system_prompt` 已注入到 Agent。
|
||||||
|
- 已实现第一版 prompt-first 路线规划流程:由 system prompt 驱动模型使用高德 MCP 工具完成点位解析、逐段算路、候选路线比较和结构化输出。
|
||||||
|
- 已增加代码护栏:
|
||||||
|
- 请求模型语义校验:固定起点必须提供起点地址;终点不能同时出现在途经点;空字符串会被标准化处理。
|
||||||
|
- `.env` 中新增 `ROUTE_MAX_PERMUTATIONS=20`,作为服务侧候选顺序硬上限。
|
||||||
|
- 当 `n!` 超过上限时,接口直接返回 422,不进入模型执行。
|
||||||
|
- 对 Agent 输出做结构校验:`origin_mode` 必须一致、`resolved_origin`/`resolved_destination` 角色必须正确、`best_route` 必须对应 `candidates` 之一。
|
||||||
|
- 已完成真实请求验证:
|
||||||
|
- 单个途经点请求返回 200,并产生结构化路线规划结果。
|
||||||
|
- 4 个途经点请求因 24 个排列超过 20 的限制而返回 422。
|
||||||
|
- 已新增项目设计文档 `docs/agent_design.md`。
|
||||||
|
- 已新增前端对接文档 `docs/frontend_api.md`。
|
||||||
|
- 已补充上游 timeout 配置和 504 错误映射,避免外部超时被混淆为普通 500。
|
||||||
|
- 已修正 `stops` 非空校验,并更新前端文档中 `deep_links` 与 `summary` 的语义边界说明。
|
||||||
|
|
||||||
|
## 下一步建议
|
||||||
|
|
||||||
|
- 补充高德远程 MCP 的真实 URL。
|
||||||
|
- 确认 MCP 传输协议是 `streamable_http` 还是 `sse`。
|
||||||
|
- 如果远程服务需要认证,补充自定义请求头。
|
||||||
|
- 在此基础上实现地址解析、候选路线枚举、逐段路线计算和 deep link 生成。
|
||||||
249
docs/agent_design.md
Normal file
249
docs/agent_design.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# Geo Agent 设计文档
|
||||||
|
|
||||||
|
## 1. 项目目标
|
||||||
|
|
||||||
|
本项目用于实现一个“多目标地点最优路线规划”服务。
|
||||||
|
|
||||||
|
核心能力如下:
|
||||||
|
|
||||||
|
- 接收一个固定起点或当前位置语义的路线规划请求
|
||||||
|
- 解析终点和多个途经点
|
||||||
|
- 对候选送货顺序进行比较
|
||||||
|
- 逐段调用高德地图 MCP 工具计算距离和时长
|
||||||
|
- 根据策略选出最佳路线
|
||||||
|
- 返回结构化 JSON,供前端直接消费
|
||||||
|
- 在需要时生成高德 deep link
|
||||||
|
|
||||||
|
当前阶段的目标不是构建一个复杂的调度系统,而是先交付一个可工作的第一版 Agent API。
|
||||||
|
|
||||||
|
## 2. 设计原则
|
||||||
|
|
||||||
|
- Prompt-first:第一版以 `system_prompt` 驱动整体执行流程,优先保留灵活性
|
||||||
|
- Strong output contract:最终输出必须符合固定的 Pydantic 结构
|
||||||
|
- Guardrail-driven:对输入、执行规模和输出一致性增加代码护栏
|
||||||
|
- Stateless:每次请求独立完成,不依赖长期记忆
|
||||||
|
- Tool-grounded:坐标、POI、距离、时长、deep link 必须来自地图工具,而不是模型臆造
|
||||||
|
|
||||||
|
## 3. 架构概览
|
||||||
|
|
||||||
|
当前工程结构:
|
||||||
|
|
||||||
|
- `main.py`
|
||||||
|
- FastAPI 入口
|
||||||
|
- 暴露 `/healthz` 和 `/route/plan`
|
||||||
|
- 负责 HTTP 错误映射
|
||||||
|
- `schemas.py`
|
||||||
|
- 定义请求和响应模型
|
||||||
|
- 实现输入校验
|
||||||
|
- `agent.py`
|
||||||
|
- 负责 LLM、MCP、prompt、运行护栏、结果护栏
|
||||||
|
- `.env`
|
||||||
|
- 管理模型配置、MCP 配置和运行护栏配置
|
||||||
|
|
||||||
|
运行链路:
|
||||||
|
|
||||||
|
1. 前端请求进入 FastAPI
|
||||||
|
2. Pydantic 校验请求结构
|
||||||
|
3. 服务侧执行预护栏检查
|
||||||
|
4. Agent 运行,并由 `system_prompt` 驱动模型调用高德 MCP 工具
|
||||||
|
5. Agent 返回结构化结果
|
||||||
|
6. 服务侧执行结果护栏检查
|
||||||
|
7. 返回 JSON 给前端
|
||||||
|
|
||||||
|
## 4. 关键技术选型与决策依据
|
||||||
|
|
||||||
|
### 4.1 为什么使用 pydantic_ai
|
||||||
|
|
||||||
|
- 原生支持结构化输出
|
||||||
|
- 可以直接挂接 MCP toolset
|
||||||
|
- 非常适合“模型编排 + 强类型结果”场景
|
||||||
|
- 与当前项目希望保留 prompt 灵活性的目标一致
|
||||||
|
|
||||||
|
### 4.2 为什么第一版采用 prompt-first
|
||||||
|
|
||||||
|
这次的核心业务不是纯算法问题,而是“模型理解任务 + 使用地图工具求事实 + 按规则组织返回”。
|
||||||
|
|
||||||
|
第一版采用 prompt-first 的原因:
|
||||||
|
|
||||||
|
- 业务规则还在收敛阶段,prompt 更容易快速调整
|
||||||
|
- 高德 MCP 工具已经能直接提供底层能力,没有必要在第一版就再包一层 Python tool abstraction
|
||||||
|
- 当前最重要的是先建立“可靠结果 + 清晰约束 + 可持续迭代”的骨架
|
||||||
|
|
||||||
|
### 4.3 为什么不是纯 prompt-only
|
||||||
|
|
||||||
|
虽然第一版以 `system_prompt` 为核心,但没有把全部控制权放给模型。原因是以下几类问题更适合代码层兜底:
|
||||||
|
|
||||||
|
- 输入明显非法
|
||||||
|
- 候选排列爆炸
|
||||||
|
- 输出结构不一致
|
||||||
|
- 运行配置错误
|
||||||
|
|
||||||
|
因此最终方案是:
|
||||||
|
|
||||||
|
- Prompt 负责业务编排
|
||||||
|
- Pydantic 负责结构约束
|
||||||
|
- Python 代码负责运行护栏
|
||||||
|
|
||||||
|
## 5. 当前实现策略
|
||||||
|
|
||||||
|
### 5.1 LLM 和 MCP 接入
|
||||||
|
|
||||||
|
- LLM:豆包 Ark OpenAI-compatible 接口
|
||||||
|
- MCP:高德远程 `streamable_http`
|
||||||
|
- MCP 已验证可连通,当前可用工具包括:
|
||||||
|
- `maps_geo`
|
||||||
|
- `maps_text_search`
|
||||||
|
- `maps_search_detail`
|
||||||
|
- `maps_direction_driving`
|
||||||
|
- `maps_distance`
|
||||||
|
- `maps_schema_personal_map`
|
||||||
|
- `maps_schema_navi`
|
||||||
|
- `maps_weather`
|
||||||
|
|
||||||
|
### 5.2 Prompt 组织方式
|
||||||
|
|
||||||
|
`system_prompt` 中定义了以下行为约束:
|
||||||
|
|
||||||
|
- 必须显式调用地图工具
|
||||||
|
- 必须逐段计算驾车路线
|
||||||
|
- 必须区分 fixed origin 和 current location
|
||||||
|
- 必须按 `route_strategy` 做选择
|
||||||
|
- 不得伪造坐标、POI、距离、时长和 deep link
|
||||||
|
- 必须输出结构化结果
|
||||||
|
|
||||||
|
在实际运行时,还会再注入一次“用户级 prompt”,其中包含:
|
||||||
|
|
||||||
|
- 当前请求 JSON
|
||||||
|
- 本次候选顺序的硬上限
|
||||||
|
- 对起点模式的额外提醒
|
||||||
|
|
||||||
|
这样做的目的是把“长期规则”和“本次任务上下文”拆开,减少 prompt 污染。
|
||||||
|
|
||||||
|
## 6. 已实现的代码护栏
|
||||||
|
|
||||||
|
### 6.1 输入护栏
|
||||||
|
|
||||||
|
在 `schemas.py` 中已经实现:
|
||||||
|
|
||||||
|
- 清理空白字符串
|
||||||
|
- `stop.address` 不能为空
|
||||||
|
- `stops` 列表不能为空
|
||||||
|
- `destination_address` 不能为空
|
||||||
|
- `origin_mode=fixed` 时必须提供 `origin_address`
|
||||||
|
- 终点地址不能同时出现在 `stops`
|
||||||
|
- `max_permutations` 如果传入,必须大于 0
|
||||||
|
|
||||||
|
### 6.2 执行规模护栏
|
||||||
|
|
||||||
|
在 `agent.py` 中已经实现:
|
||||||
|
|
||||||
|
- 从 `.env` 读取 `ROUTE_MAX_PERMUTATIONS`
|
||||||
|
- 当前默认值是 `20`
|
||||||
|
- 请求可以传更小值,但不能超过服务配置上限
|
||||||
|
- 在模型执行前,根据 `stops` 数量计算排列数 $n!$
|
||||||
|
- 如果排列数超过限制,直接报错并返回 422
|
||||||
|
|
||||||
|
当前策略是“超限直接拒绝”,暂不做启发式近似。
|
||||||
|
|
||||||
|
### 6.3 结果护栏
|
||||||
|
|
||||||
|
在 `agent.py` 中已经实现:
|
||||||
|
|
||||||
|
- 输出 `origin_mode` 必须和请求一致
|
||||||
|
- `resolved_destination.role` 必须是 `destination`
|
||||||
|
- `resolved_stops` 数量必须和输入一致
|
||||||
|
- 所有 `resolved_stops.role` 都必须是 `stop`
|
||||||
|
- `origin_mode=fixed` 时必须返回 `resolved_origin`
|
||||||
|
- `origin_mode=current_location` 时禁止返回固定 `resolved_origin`
|
||||||
|
- 成功结果必须至少有一个 candidate
|
||||||
|
- `best_route` 必须能在 `candidates` 中找到对应项
|
||||||
|
|
||||||
|
### 6.4 配置护栏
|
||||||
|
|
||||||
|
已实现以下配置检查:
|
||||||
|
|
||||||
|
- 必须提供模型 URL、API key、模型名
|
||||||
|
- MCP URL 必须存在
|
||||||
|
- MCP transport 仅允许 `streamable_http`、`http`、`sse`
|
||||||
|
- 自定义 MCP 认证头必须成对提供
|
||||||
|
- `ROUTE_MAX_PERMUTATIONS` 必须是正整数
|
||||||
|
- 模型和 MCP 的 timeout 参数必须是正数
|
||||||
|
|
||||||
|
### 6.5 超时护栏
|
||||||
|
|
||||||
|
已实现以下超时相关策略:
|
||||||
|
|
||||||
|
- 模型请求超时通过 `ARK_REQUEST_TIMEOUT_SECONDS` 配置
|
||||||
|
- MCP 连接超时通过 `AMAP_MCP_TIMEOUT_SECONDS` 配置
|
||||||
|
- MCP 读取超时通过 `AMAP_MCP_READ_TIMEOUT_SECONDS` 配置
|
||||||
|
- 上游 timeout 会被映射为 HTTP 504,而不是泛化成 500
|
||||||
|
|
||||||
|
## 7. 当前已实施进展
|
||||||
|
|
||||||
|
当前已完成:
|
||||||
|
|
||||||
|
- FastAPI 服务可启动
|
||||||
|
- `/healthz` 正常返回 200
|
||||||
|
- `/route/plan` 已打通真实调用链
|
||||||
|
- 高德远程 MCP 连通性已验证
|
||||||
|
- 单个途经点请求可成功返回结构化结果
|
||||||
|
- 超限请求可返回 422,并中止模型执行
|
||||||
|
|
||||||
|
当前尚未完成:
|
||||||
|
|
||||||
|
- 把文档中的每一步拆成显式 Python 服务函数
|
||||||
|
- `current_location` 场景的更细粒度行为控制
|
||||||
|
- 深链策略的更强一致性校验
|
||||||
|
- 自动化测试
|
||||||
|
- 前端展示层 HTML 输出
|
||||||
|
|
||||||
|
## 8. 关键取舍
|
||||||
|
|
||||||
|
### 8.1 已做取舍
|
||||||
|
|
||||||
|
- 选择 prompt-first,而不是一开始就把所有流程写死在 Python 中
|
||||||
|
- 选择保留高德 MCP 原生工具,而不是先做二次工具封装
|
||||||
|
- 选择超限直接报错,而不是第一版就引入启发式近似搜索
|
||||||
|
- 选择结构校验和运行护栏优先,而不是先追求功能面最大化
|
||||||
|
|
||||||
|
### 8.2 这套取舍的收益
|
||||||
|
|
||||||
|
- 业务规则可快速迭代
|
||||||
|
- 系统仍然保有基本可控性
|
||||||
|
- 接口结构对前端稳定
|
||||||
|
- 便于后续逐步从 prompt-first 演进到“prompt + service function”混合架构
|
||||||
|
|
||||||
|
### 8.3 当前代价
|
||||||
|
|
||||||
|
- 模型仍然承担了较多流程理解责任
|
||||||
|
- 某些复杂场景的结果稳定性暂时依赖 prompt 质量
|
||||||
|
- 当前没有启发式优化,超限就会拒绝
|
||||||
|
- 当前上游调用仍然依赖外部服务稳定性,但 timeout 已具备可调能力和明确错误语义
|
||||||
|
|
||||||
|
## 9. 已知限制
|
||||||
|
|
||||||
|
- 当前版本的最优路线能力仍然高度依赖 LLM 遵守 prompt
|
||||||
|
- 当前对 `best_route` 的一致性校验是结构级护栏,不是全量数学证明
|
||||||
|
- `current_location` 模式尚未做专门增强
|
||||||
|
- `need_html` 目前尚未实现独立展示层
|
||||||
|
- 没有缓存机制,请求成本与工具调用次数直接相关
|
||||||
|
|
||||||
|
## 10. 下一阶段 TODO
|
||||||
|
|
||||||
|
- 为 `current_location` 模式增加专门提示和更严格结果校验
|
||||||
|
- 对 `deep_link_mode` 增加更明确的输出校验规则
|
||||||
|
- 追加自动化测试,覆盖成功路径和失败路径
|
||||||
|
- 将部分高频子流程下沉为显式服务函数
|
||||||
|
- 评估是否引入启发式近邻策略处理超限请求
|
||||||
|
- 增加请求日志和运行观测能力
|
||||||
|
- 增加 README 中的启动与调试说明
|
||||||
|
|
||||||
|
## 11. 文档维护建议
|
||||||
|
|
||||||
|
后续如果发生以下变更,应同步更新本文档:
|
||||||
|
|
||||||
|
- Prompt 主策略变化
|
||||||
|
- 输出结构变化
|
||||||
|
- 错误语义变化
|
||||||
|
- 护栏策略变化
|
||||||
|
- MCP 服务配置方式变化
|
||||||
431
docs/frontend_api.md
Normal file
431
docs/frontend_api.md
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
# Geo Agent API 对接文档
|
||||||
|
|
||||||
|
## 1. 文档目的
|
||||||
|
|
||||||
|
本文档面向前端对接,说明当前可用接口、请求结构、响应结构、错误语义和对接建议。
|
||||||
|
|
||||||
|
## 2. 服务概览
|
||||||
|
|
||||||
|
当前服务提供两个 HTTP 接口:
|
||||||
|
|
||||||
|
- `GET /healthz`
|
||||||
|
- `POST /route/plan`
|
||||||
|
|
||||||
|
默认本地开发地址示例:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 健康检查
|
||||||
|
|
||||||
|
### 3.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 成功响应
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 路线规划接口
|
||||||
|
|
||||||
|
### 4.1 请求
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /route/plan
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 请求体
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_name": "multi-destination-route-planning",
|
||||||
|
"origin_mode": "fixed",
|
||||||
|
"origin_name": "北京站",
|
||||||
|
"origin_address": "北京站",
|
||||||
|
"origin_city": "北京市",
|
||||||
|
"destination_name": "天安门",
|
||||||
|
"destination_address": "天安门",
|
||||||
|
"destination_city": "北京市",
|
||||||
|
"stops": [
|
||||||
|
{
|
||||||
|
"name": "王府井",
|
||||||
|
"address": "王府井",
|
||||||
|
"city": "北京市",
|
||||||
|
"contact": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"route_strategy": "shortest_distance",
|
||||||
|
"transport_mode": "driving",
|
||||||
|
"need_deep_link": true,
|
||||||
|
"deep_link_mode": "auto",
|
||||||
|
"need_html": false,
|
||||||
|
"max_permutations": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 请求字段说明
|
||||||
|
|
||||||
|
- `task_name`
|
||||||
|
- 可选
|
||||||
|
- 默认值为 `multi-destination-route-planning`
|
||||||
|
- `origin_mode`
|
||||||
|
- 必填
|
||||||
|
- 可选值:`fixed`、`current_location`
|
||||||
|
- `origin_name`
|
||||||
|
- 可选
|
||||||
|
- 起点展示名称
|
||||||
|
- `origin_address`
|
||||||
|
- `origin_mode=fixed` 时必填
|
||||||
|
- `origin_city`
|
||||||
|
- 可选
|
||||||
|
- `destination_name`
|
||||||
|
- 可选
|
||||||
|
- `destination_address`
|
||||||
|
- 必填
|
||||||
|
- `destination_city`
|
||||||
|
- 可选
|
||||||
|
- `stops`
|
||||||
|
- 必填
|
||||||
|
- 至少 1 个元素
|
||||||
|
- `route_strategy`
|
||||||
|
- 可选
|
||||||
|
- 可选值:`shortest_distance`、`fastest_time`、`balanced`
|
||||||
|
- `transport_mode`
|
||||||
|
- 当前固定为 `driving`
|
||||||
|
- `need_deep_link`
|
||||||
|
- 可选
|
||||||
|
- 是否需要生成 deep link
|
||||||
|
- `deep_link_mode`
|
||||||
|
- 可选
|
||||||
|
- 可选值:`personal_map`、`route_plan`、`auto`
|
||||||
|
- `need_html`
|
||||||
|
- 可选
|
||||||
|
- 当前建议始终传 `false`
|
||||||
|
- `max_permutations`
|
||||||
|
- 可选
|
||||||
|
- 本次请求希望允许的候选上限
|
||||||
|
- 不能超过服务端上限
|
||||||
|
|
||||||
|
## 5. 成功响应结构
|
||||||
|
|
||||||
|
### 5.1 响应示例
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"origin_mode": "fixed",
|
||||||
|
"resolved_origin": {
|
||||||
|
"role": "origin",
|
||||||
|
"input_name": null,
|
||||||
|
"input_address": "北京站",
|
||||||
|
"resolved_name": "北京站",
|
||||||
|
"city": "北京市",
|
||||||
|
"district": "东城区",
|
||||||
|
"location": "116.427354,39.902830",
|
||||||
|
"lon": 116.427354,
|
||||||
|
"lat": 39.90283,
|
||||||
|
"poi_id": null,
|
||||||
|
"source": "geo",
|
||||||
|
"confidence_note": "地址解析高置信"
|
||||||
|
},
|
||||||
|
"resolved_destination": {
|
||||||
|
"role": "destination",
|
||||||
|
"input_name": null,
|
||||||
|
"input_address": "天安门",
|
||||||
|
"resolved_name": "天安门",
|
||||||
|
"city": "北京市",
|
||||||
|
"district": "东城区",
|
||||||
|
"location": "116.397463,39.909187",
|
||||||
|
"lon": 116.397463,
|
||||||
|
"lat": 39.909187,
|
||||||
|
"poi_id": null,
|
||||||
|
"source": "geo",
|
||||||
|
"confidence_note": "地址解析高置信"
|
||||||
|
},
|
||||||
|
"resolved_stops": [
|
||||||
|
{
|
||||||
|
"role": "stop",
|
||||||
|
"input_name": null,
|
||||||
|
"input_address": "王府井",
|
||||||
|
"resolved_name": "王府井",
|
||||||
|
"city": "北京市",
|
||||||
|
"district": "东城区",
|
||||||
|
"location": "116.412422,39.908966",
|
||||||
|
"lon": 116.412422,
|
||||||
|
"lat": 39.908966,
|
||||||
|
"poi_id": "B000A8WS91",
|
||||||
|
"source": "search_detail",
|
||||||
|
"confidence_note": "北京市内热点地名,POI查询高置信"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"stop_order_labels": ["王府井"],
|
||||||
|
"full_order_labels": ["北京站", "王府井", "天安门"],
|
||||||
|
"legs": [
|
||||||
|
{
|
||||||
|
"from_label": "北京站",
|
||||||
|
"to_label": "王府井",
|
||||||
|
"origin_location": "116.427354,39.902830",
|
||||||
|
"destination_location": "116.412422,39.908966",
|
||||||
|
"distance_m": 3014,
|
||||||
|
"duration_s": 845
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from_label": "王府井",
|
||||||
|
"to_label": "天安门",
|
||||||
|
"origin_location": "116.412422,39.908966",
|
||||||
|
"destination_location": "116.397463,39.909187",
|
||||||
|
"distance_m": 2858,
|
||||||
|
"duration_s": 1158
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_distance_m": 5872,
|
||||||
|
"total_duration_s": 2003,
|
||||||
|
"ranking_reason": "仅有的可行路线,总距离最短"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"best_route": {
|
||||||
|
"stop_order_labels": ["王府井"],
|
||||||
|
"full_order_labels": ["北京站", "王府井", "天安门"],
|
||||||
|
"legs": [
|
||||||
|
{
|
||||||
|
"from_label": "北京站",
|
||||||
|
"to_label": "王府井",
|
||||||
|
"origin_location": "116.427354,39.902830",
|
||||||
|
"destination_location": "116.412422,39.908966",
|
||||||
|
"distance_m": 3014,
|
||||||
|
"duration_s": 845
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from_label": "王府井",
|
||||||
|
"to_label": "天安门",
|
||||||
|
"origin_location": "116.412422,39.908966",
|
||||||
|
"destination_location": "116.397463,39.909187",
|
||||||
|
"distance_m": 2858,
|
||||||
|
"duration_s": 1158
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_distance_m": 5872,
|
||||||
|
"total_duration_s": 2003,
|
||||||
|
"ranking_reason": "仅有的可行路线,总距离最短"
|
||||||
|
},
|
||||||
|
"deep_links": {
|
||||||
|
"personal_map": null,
|
||||||
|
"android_route_plan": "androidamap://route?...",
|
||||||
|
"ios_route_plan": "iosamap://route?..."
|
||||||
|
},
|
||||||
|
"summary": "本次规划从北京站出发,途经王府井,最终到达天安门,总距离约5.87公里,总耗时约33分钟,符合最短距离策略要求。",
|
||||||
|
"warnings": [
|
||||||
|
"路线时长受实时交通状况影响,实际行驶可能存在偏差",
|
||||||
|
"若起点为当前位置,最优路线可能随定位变化调整"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 顶层字段说明
|
||||||
|
|
||||||
|
- `success`
|
||||||
|
- 是否成功生成路线规划结果
|
||||||
|
- `origin_mode`
|
||||||
|
- 与请求保持一致
|
||||||
|
- `resolved_origin`
|
||||||
|
- `origin_mode=fixed` 时通常不为 `null`
|
||||||
|
- `origin_mode=current_location` 时通常为 `null`
|
||||||
|
- `resolved_destination`
|
||||||
|
- 终点解析结果
|
||||||
|
- `resolved_stops`
|
||||||
|
- 所有途经点解析结果
|
||||||
|
- `candidates`
|
||||||
|
- 所有候选路线
|
||||||
|
- `best_route`
|
||||||
|
- 被选中的最佳路线
|
||||||
|
- `deep_links`
|
||||||
|
- 给前端做按钮跳转使用
|
||||||
|
- 这是唯一应被前端当作链接处理的字段
|
||||||
|
- `summary`
|
||||||
|
- 可直接展示给用户的简要说明
|
||||||
|
- 这是纯展示文案,不是结构化链接字段,也不应被前端解析为跳转地址
|
||||||
|
- `warnings`
|
||||||
|
- 风险提示和降级说明,前端建议展示
|
||||||
|
|
||||||
|
## 6. 关键嵌套结构说明
|
||||||
|
|
||||||
|
### 6.1 ResolvedPoint
|
||||||
|
|
||||||
|
- `role`
|
||||||
|
- `origin`、`stop`、`destination`
|
||||||
|
- `input_name`
|
||||||
|
- 原始输入名
|
||||||
|
- `input_address`
|
||||||
|
- 原始输入地址
|
||||||
|
- `resolved_name`
|
||||||
|
- 实际命中的名称
|
||||||
|
- `city`
|
||||||
|
- 城市名
|
||||||
|
- `district`
|
||||||
|
- 区县名
|
||||||
|
- `location`
|
||||||
|
- `lon,lat` 字符串
|
||||||
|
- `lon`
|
||||||
|
- 经度
|
||||||
|
- `lat`
|
||||||
|
- 纬度
|
||||||
|
- `poi_id`
|
||||||
|
- 高德 POI ID,可能为空
|
||||||
|
- `source`
|
||||||
|
- `geo`、`text_search`、`search_detail`、`manual_fallback`
|
||||||
|
- `confidence_note`
|
||||||
|
- 命中说明
|
||||||
|
|
||||||
|
### 6.2 CandidateRoute
|
||||||
|
|
||||||
|
- `stop_order_labels`
|
||||||
|
- 仅包含中间途经点顺序
|
||||||
|
- `full_order_labels`
|
||||||
|
- 包含起点和终点的完整顺序
|
||||||
|
- `legs`
|
||||||
|
- 每段路线信息
|
||||||
|
- `total_distance_m`
|
||||||
|
- 总距离,单位米
|
||||||
|
- `total_duration_s`
|
||||||
|
- 总时长,单位秒
|
||||||
|
- `ranking_reason`
|
||||||
|
- 为什么这条路线被这样排序
|
||||||
|
|
||||||
|
### 6.3 DeepLinks
|
||||||
|
|
||||||
|
- `personal_map`
|
||||||
|
- 点位导入型链接
|
||||||
|
- 适合把一组点位导入到高德地图
|
||||||
|
- 更偏“查看/导入点位方案”,不是严格的即时导航协议
|
||||||
|
- `android_route_plan`
|
||||||
|
- Android 导航链接
|
||||||
|
- `ios_route_plan`
|
||||||
|
- iOS 导航链接
|
||||||
|
|
||||||
|
补充说明:
|
||||||
|
|
||||||
|
- `deep_links` 中可能同时存在多个字段,也可能只有其中一个字段有值
|
||||||
|
- 前端应只根据 `deep_links` 的字段值控制按钮展示,不要依赖 `summary` 推断应展示哪个按钮
|
||||||
|
- `summary` 里可能会提到“个人地图链接”或“导航链接”,但这里只是说明文字,不保证包含真实 URL
|
||||||
|
- 如果 `personal_map` 存在,表示当前更适合导入点位方案
|
||||||
|
- 如果 `android_route_plan` 或 `ios_route_plan` 存在,表示当前可以直接拉起导航
|
||||||
|
|
||||||
|
前端建议:
|
||||||
|
|
||||||
|
- 如果值为 `null`,对应按钮不要展示
|
||||||
|
- 如果 `warnings` 非空,建议在页面显式展示提示
|
||||||
|
- `summary` 只用于文案展示,不要从 `summary` 中抽取链接或做业务判断
|
||||||
|
|
||||||
|
## 7. 错误响应
|
||||||
|
|
||||||
|
### 7.1 422 输入或护栏错误
|
||||||
|
|
||||||
|
出现以下情况时,接口会返回 422:
|
||||||
|
|
||||||
|
- 请求结构不合法
|
||||||
|
- `stops` 为空
|
||||||
|
- 固定起点缺少 `origin_address`
|
||||||
|
- 终点同时出现在 `stops`
|
||||||
|
- 请求候选上限超过服务上限
|
||||||
|
- 实际排列数超过上限
|
||||||
|
|
||||||
|
错误可能有两种形态。
|
||||||
|
|
||||||
|
形态一:FastAPI/Pydantic 字段校验错误
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": [
|
||||||
|
{
|
||||||
|
"type": "value_error",
|
||||||
|
"loc": ["body"],
|
||||||
|
"msg": "Value error, stops must contain at least one stop",
|
||||||
|
"input": {
|
||||||
|
"origin_mode": "fixed",
|
||||||
|
"origin_address": "北京站",
|
||||||
|
"destination_address": "天安门",
|
||||||
|
"stops": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
形态二:服务护栏错误
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Candidate permutations exceed the configured limit: stops=4, permutations=24, limit=20"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 503 配置错误
|
||||||
|
|
||||||
|
出现以下情况时,接口会返回 503:
|
||||||
|
|
||||||
|
- 模型配置缺失
|
||||||
|
- MCP 配置缺失
|
||||||
|
- MCP transport 非法
|
||||||
|
|
||||||
|
错误示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Missing required environment variable: AMAP_MCP_URL"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 500 内部错误
|
||||||
|
|
||||||
|
模型运行失败、第三方异常或未预期错误会返回 500。
|
||||||
|
|
||||||
|
### 7.4 504 上游超时
|
||||||
|
|
||||||
|
当模型服务或地图 MCP 服务超时,接口会返回 504。
|
||||||
|
|
||||||
|
错误示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Upstream request timed out: ..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端建议:
|
||||||
|
|
||||||
|
- 422 显示明确的用户提示
|
||||||
|
- 503 显示“服务暂不可用”
|
||||||
|
- 504 显示“请求处理超时,请稍后重试”
|
||||||
|
- 500 显示通用错误提示,并建议重试
|
||||||
|
|
||||||
|
## 8. 前端对接建议
|
||||||
|
|
||||||
|
- 直接按 `summary`、`best_route`、`warnings` 渲染即可完成第一版页面
|
||||||
|
- 如果需要路线详情页,可渲染 `candidates` 对比卡片
|
||||||
|
- 统一使用 `deep_links` 控制跳转按钮显隐
|
||||||
|
- 对 `warnings` 保持可见,不要吞掉
|
||||||
|
- 对 `current_location` 场景,要准备接受 `resolved_origin=null`
|
||||||
|
|
||||||
|
## 9. 当前接口现状
|
||||||
|
|
||||||
|
- 当前接口已经可用
|
||||||
|
- 当前已验证真实请求可成功返回结果
|
||||||
|
- 当前返回结构已稳定,可作为第一版前端对接基础
|
||||||
|
- 当前 `need_html` 还未真正实现 HTML 返回,不建议前端依赖该字段做页面内容请求
|
||||||
|
|
||||||
|
## 10. 对接 TODO
|
||||||
|
|
||||||
|
- 前端确认是否需要候选路线对比视图
|
||||||
|
- 前端确认 deep link 的按钮交互形式
|
||||||
|
- 前后端统一 422 错误展示文案
|
||||||
|
- 后续若输出结构调整,需要同步更新本文档
|
||||||
33
main.py
Normal file
33
main.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from openai import APITimeoutError
|
||||||
|
|
||||||
|
from schemas import RoutePlanRequest, RoutePlanResult
|
||||||
|
from agent import ConfigurationError, GuardrailError, run_route_plan
|
||||||
|
|
||||||
|
app = FastAPI(title="Geo Route Agent", version="0.1.0")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/route/plan", response_model=RoutePlanResult)
|
||||||
|
async def route_plan(request: RoutePlanRequest) -> RoutePlanResult:
|
||||||
|
try:
|
||||||
|
return await run_route_plan(request)
|
||||||
|
except ConfigurationError as exc:
|
||||||
|
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||||
|
except GuardrailError as exc:
|
||||||
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||||
|
except (httpx.TimeoutException, APITimeoutError, TimeoutError) as exc:
|
||||||
|
raise HTTPException(status_code=504, detail=f"Upstream request timed out: {exc}") from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz() -> dict[str, str]:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
|
||||||
498
multi_dest_geo_agent.md
Normal file
498
multi_dest_geo_agent.md
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
# multi_dest_geo_agent.md
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
本文件定义一个面向“多目标地点最优路线规划”的无状态地理路线 Agent 执行规范。该 Agent 的主要职责是:
|
||||||
|
|
||||||
|
1. 解析多个地址为坐标和 POI。
|
||||||
|
2. 枚举候选送货顺序。
|
||||||
|
3. 逐段调用高德地图 MCP 路线工具计算距离和时长。
|
||||||
|
4. 按既定策略选择最优路线。
|
||||||
|
5. 生成适合前端消费的强类型结构化结果。
|
||||||
|
6. 在需要时生成高德地图 deep link 或 HTML 展示数据。
|
||||||
|
|
||||||
|
本规范基于此前已验证成功的处理流程整理而成,核心方法不是依赖某个“单步最优多点路径”工具,而是通过工具编排实现最优路线选择。
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. Agent 是无状态的。
|
||||||
|
2. 每次任务独立完成,不依赖长期 memory。
|
||||||
|
3. 每次任务应显式解析输入、显式调用工具、显式返回结构化结果。
|
||||||
|
4. 模型负责理解任务、决策和解释;地图工具负责坐标解析、路线计算和 URI 生成。
|
||||||
|
5. 不允许模型凭空编造坐标、POI、距离、时长或 deep link。
|
||||||
|
6. 当地址无法精确命中时,必须通过搜索和兜底规则显式修复,并在输出中说明。
|
||||||
|
|
||||||
|
## Intended Use Cases
|
||||||
|
|
||||||
|
1. 多送货点最优送货顺序规划。
|
||||||
|
2. 起点固定或起点为“我的位置”的路线规划。
|
||||||
|
3. 终点固定回仓、回公司或固定收货点。
|
||||||
|
4. 输出 JSON 给前端渲染。
|
||||||
|
5. 输出 HTML 页面。
|
||||||
|
6. 生成高德地图 deep link。
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
1. 不做长期用户偏好记忆。
|
||||||
|
2. 不做车队级全局调度优化。
|
||||||
|
3. 不做实时交通预测模型。
|
||||||
|
4. 不把 `schema_personal_map` 误当成严格导航协议。
|
||||||
|
5. 不假设高德 MCP 自带“多点最优路径”单步求解能力。
|
||||||
|
|
||||||
|
## Tool Inventory
|
||||||
|
|
||||||
|
优先使用以下高德地图 MCP 工具:
|
||||||
|
|
||||||
|
1. `maps_geo`
|
||||||
|
2. `maps_text_search`
|
||||||
|
3. `maps_search_detail`
|
||||||
|
4. `maps_direction_driving`
|
||||||
|
5. `maps_distance`
|
||||||
|
6. `maps_schema_personal_map`
|
||||||
|
7. `maps_schema_navi`
|
||||||
|
8. `maps_weather`
|
||||||
|
|
||||||
|
辅助工具说明:
|
||||||
|
|
||||||
|
1. `maps_geo`:地址转坐标。
|
||||||
|
2. `maps_text_search`:模糊地址搜索 POI,补 `poiId`。
|
||||||
|
3. `maps_search_detail`:查询 POI 详情。
|
||||||
|
4. `maps_direction_driving`:计算两点间驾车距离、时长和步骤。
|
||||||
|
5. `maps_distance`:做粗筛或批量距离对比。
|
||||||
|
6. `maps_schema_personal_map`:生成个人地图导入链接,适合导入点位方案。
|
||||||
|
7. `maps_schema_navi`:单终点导航链接。
|
||||||
|
8. `maps_weather`:用于未来扩展天气策略。
|
||||||
|
|
||||||
|
## Input Contract
|
||||||
|
|
||||||
|
建议另一个 Agent 接收如下强类型输入:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
class RawStop(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
address: str
|
||||||
|
city: str | None = None
|
||||||
|
contact: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlanRequest(BaseModel):
|
||||||
|
task_name: str = Field(default="multi-destination-route-planning")
|
||||||
|
origin_mode: Literal["fixed", "current_location"]
|
||||||
|
origin_name: str | None = None
|
||||||
|
origin_address: str | None = None
|
||||||
|
origin_city: str | None = None
|
||||||
|
destination_name: str | None = None
|
||||||
|
destination_address: str
|
||||||
|
destination_city: str | None = None
|
||||||
|
stops: list[RawStop]
|
||||||
|
route_strategy: Literal[
|
||||||
|
"shortest_distance",
|
||||||
|
"fastest_time",
|
||||||
|
"balanced"
|
||||||
|
] = "shortest_distance"
|
||||||
|
transport_mode: Literal["driving"] = "driving"
|
||||||
|
need_deep_link: bool = True
|
||||||
|
deep_link_mode: Literal["personal_map", "route_plan", "auto"] = "auto"
|
||||||
|
need_html: bool = False
|
||||||
|
max_permutations: int = 24
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input Rules
|
||||||
|
|
||||||
|
1. `origin_mode = fixed` 时,必须提供固定起点地址。
|
||||||
|
2. `origin_mode = current_location` 时,不允许强行写死起点坐标。
|
||||||
|
3. `destination_address` 必填。
|
||||||
|
4. `stops` 至少 1 个。
|
||||||
|
5. `route_strategy` 用于最终评分,而不是直接传给高德工具。
|
||||||
|
6. `max_permutations` 用于防止排列爆炸。
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
建议输出如下强类型结果:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
class ResolvedPoint(BaseModel):
|
||||||
|
role: Literal["origin", "stop", "destination"]
|
||||||
|
input_name: str | None = None
|
||||||
|
input_address: str
|
||||||
|
resolved_name: str
|
||||||
|
city: str | None = None
|
||||||
|
district: str | None = None
|
||||||
|
location: str
|
||||||
|
lon: float
|
||||||
|
lat: float
|
||||||
|
poi_id: str | None = None
|
||||||
|
source: Literal["geo", "text_search", "search_detail", "manual_fallback"]
|
||||||
|
confidence_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RouteLeg(BaseModel):
|
||||||
|
from_label: str
|
||||||
|
to_label: str
|
||||||
|
origin_location: str
|
||||||
|
destination_location: str
|
||||||
|
distance_m: int
|
||||||
|
duration_s: int
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRoute(BaseModel):
|
||||||
|
stop_order_labels: list[str]
|
||||||
|
full_order_labels: list[str]
|
||||||
|
legs: list[RouteLeg]
|
||||||
|
total_distance_m: int
|
||||||
|
total_duration_s: int
|
||||||
|
ranking_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeepLinks(BaseModel):
|
||||||
|
personal_map: str | None = None
|
||||||
|
android_route_plan: str | None = None
|
||||||
|
ios_route_plan: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlanResult(BaseModel):
|
||||||
|
success: bool
|
||||||
|
origin_mode: Literal["fixed", "current_location"]
|
||||||
|
resolved_origin: ResolvedPoint | None = None
|
||||||
|
resolved_destination: ResolvedPoint
|
||||||
|
resolved_stops: list[ResolvedPoint]
|
||||||
|
candidates: list[CandidateRoute]
|
||||||
|
best_route: CandidateRoute
|
||||||
|
deep_links: DeepLinks | None = None
|
||||||
|
summary: str
|
||||||
|
warnings: list[str]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Execution Workflow
|
||||||
|
|
||||||
|
### Step 1: Normalize the Task
|
||||||
|
|
||||||
|
将用户任务归一化为以下语义:
|
||||||
|
|
||||||
|
1. 起点模式:固定起点或当前定位。
|
||||||
|
2. 终点:唯一终点。
|
||||||
|
3. 途经点集合:需要优化顺序的中间点。
|
||||||
|
4. 优化目标:里程优先、时间优先或折中。
|
||||||
|
5. 是否需要 deep link。
|
||||||
|
6. 是否需要 HTML。
|
||||||
|
|
||||||
|
### Step 2: Resolve All Points
|
||||||
|
|
||||||
|
对终点和每个途经点执行以下解析逻辑:
|
||||||
|
|
||||||
|
1. 优先调用 `maps_geo(address, city)`。
|
||||||
|
2. 如果 `maps_geo` 返回为空、命中模糊、或疑似地址层级不准,则调用 `maps_text_search`。
|
||||||
|
3. 如果 `maps_text_search` 命中多个结果,优先选择:
|
||||||
|
- 名称最接近输入名称的结果
|
||||||
|
- 地址最接近输入地址的结果
|
||||||
|
- 同城结果
|
||||||
|
4. 如已拿到 POI ID 且还需确认,可调用 `maps_search_detail`。
|
||||||
|
5. 对于无法精确命中的地址,允许退化为:
|
||||||
|
- 门牌号
|
||||||
|
- 园区名
|
||||||
|
- 最近可用 POI
|
||||||
|
6. 必须记录 `source` 和 `confidence_note`。
|
||||||
|
|
||||||
|
对固定起点执行同样流程。
|
||||||
|
|
||||||
|
对 `origin_mode = current_location`:
|
||||||
|
|
||||||
|
1. 不解析具体起点坐标。
|
||||||
|
2. 不为起点构造固定 `ResolvedPoint` 坐标。
|
||||||
|
3. 在输出中用语义化的 origin 表达“current_location”。
|
||||||
|
|
||||||
|
### Step 3: Validate Point Set
|
||||||
|
|
||||||
|
1. 确保终点存在有效坐标。
|
||||||
|
2. 确保所有途经点存在有效坐标。
|
||||||
|
3. 如果 `schema_personal_map` 计划使用,则确保每个点都有 `poiId`。
|
||||||
|
4. 对无法获得 `poiId` 的点,给出 warning,并决定是否退化到 `route_plan` 深链。
|
||||||
|
|
||||||
|
### Step 4: Generate Candidate Orders
|
||||||
|
|
||||||
|
1. 对 `stops` 做全排列。
|
||||||
|
2. 若排列数量超过 `max_permutations`,使用剪枝策略:
|
||||||
|
- 先用 `maps_distance` 做粗筛
|
||||||
|
- 保留最有希望的一部分顺序
|
||||||
|
- 或退化为近邻启发式
|
||||||
|
3. 2 个点时,共 2 种顺序。
|
||||||
|
4. 3 个点时,共 6 种顺序。
|
||||||
|
5. 4 个点时,共 24 种顺序,可接受。
|
||||||
|
6. 超过 4 个点时,应谨慎控制调用次数。
|
||||||
|
|
||||||
|
### Step 5: Expand Each Candidate into Legs
|
||||||
|
|
||||||
|
对每个候选顺序展开完整路线:
|
||||||
|
|
||||||
|
#### 5.1 Fixed Origin
|
||||||
|
|
||||||
|
如果起点固定,完整顺序为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
origin -> stop_1 -> stop_2 -> ... -> destination
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2 Current Location Origin
|
||||||
|
|
||||||
|
如果起点为当前定位,完整顺序的计算策略分两类:
|
||||||
|
|
||||||
|
1. 如果你只是在比较“途经点内部顺序”,且缺少当前定位坐标:
|
||||||
|
- 只能对 `stop_1 -> stop_2 -> ... -> destination` 进行相对比较。
|
||||||
|
- 必须在输出中声明:真实最优结果会受当前定位影响。
|
||||||
|
|
||||||
|
2. 如果你持有用户实时定位坐标:
|
||||||
|
- 可将实时定位当作固定起点参与计算。
|
||||||
|
- 此时完整顺序为:
|
||||||
|
`current_location -> stop_1 -> stop_2 -> ... -> destination`
|
||||||
|
|
||||||
|
### Step 6: Compute Route Legs with Driving Tool
|
||||||
|
|
||||||
|
对每条候选路线的每一段,调用 `maps_direction_driving(origin, destination)`。
|
||||||
|
|
||||||
|
例如顺序为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
origin -> A -> B -> destination
|
||||||
|
```
|
||||||
|
|
||||||
|
则调用:
|
||||||
|
|
||||||
|
1. `origin -> A`
|
||||||
|
2. `A -> B`
|
||||||
|
3. `B -> destination`
|
||||||
|
|
||||||
|
解析每次返回的:
|
||||||
|
|
||||||
|
1. `paths[0].distance`
|
||||||
|
2. `paths[0].duration`
|
||||||
|
|
||||||
|
并保存到 `RouteLeg`。
|
||||||
|
|
||||||
|
### Step 7: Aggregate Candidate Metrics
|
||||||
|
|
||||||
|
对每条候选路线汇总:
|
||||||
|
|
||||||
|
1. `total_distance_m = sum(legs.distance_m)`
|
||||||
|
2. `total_duration_s = sum(legs.duration_s)`
|
||||||
|
|
||||||
|
排序策略:
|
||||||
|
|
||||||
|
#### 7.1 shortest_distance
|
||||||
|
|
||||||
|
按以下顺序排序:
|
||||||
|
|
||||||
|
1. 总距离更短优先
|
||||||
|
2. 如果距离接近,则总耗时更短优先
|
||||||
|
|
||||||
|
#### 7.2 fastest_time
|
||||||
|
|
||||||
|
按以下顺序排序:
|
||||||
|
|
||||||
|
1. 总耗时更短优先
|
||||||
|
2. 如果耗时接近,则总距离更短优先
|
||||||
|
|
||||||
|
#### 7.3 balanced
|
||||||
|
|
||||||
|
建议用简单规则:
|
||||||
|
|
||||||
|
1. 先比较是否某一路线在距离和时间上都不劣于另一条
|
||||||
|
2. 若存在明显 Pareto 优势,直接选取
|
||||||
|
3. 若出现“距离更短但时间略长”的情况,优先里程更短方案,并在 summary 中说明取舍
|
||||||
|
|
||||||
|
### Step 8: Select Best Route
|
||||||
|
|
||||||
|
选出 `best_route` 后,必须输出选择理由。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
1. “候选 A 总里程更短,虽然比候选 B 多 5 分钟,但少绕路约 10.8 公里,因此选 A。”
|
||||||
|
2. “候选 B 总时长显著更优,里程差异较小,因此选 B。”
|
||||||
|
|
||||||
|
### Step 9: Generate Deep Link
|
||||||
|
|
||||||
|
根据需求选择 deep link 方案。
|
||||||
|
|
||||||
|
#### 9.1 `schema_personal_map`
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
1. 需要稳定导入整组点位
|
||||||
|
2. 起点不强调动态导航
|
||||||
|
3. 更看重“导入到高德”而不是“立即严格按起终点导航”
|
||||||
|
|
||||||
|
要求:
|
||||||
|
|
||||||
|
1. 所有点都必须有 `poiId`
|
||||||
|
2. 调用 `maps_schema_personal_map`
|
||||||
|
3. 返回 `amapuri://workInAmap/createWithToken?...`
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
1. 这是点位导入型链接。
|
||||||
|
2. 不应误称为严格的动态起点导航。
|
||||||
|
|
||||||
|
#### 9.2 Route Plan Deep Link
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
|
||||||
|
1. 起点为“我的位置”
|
||||||
|
2. 需要显式途经点和终点
|
||||||
|
3. 目标是即时导航
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
1. iOS 用 `iosamap://path?...`
|
||||||
|
2. Android 用 `amapuri://route/plan/?...`
|
||||||
|
3. 不传 `slat/slon/sname` 时,默认使用“我的位置”
|
||||||
|
4. 使用 `did/dlat/dlon/dname` 表示终点
|
||||||
|
5. 使用 `vian/vialons/vialats/vianames` 表示途经点
|
||||||
|
|
||||||
|
### Step 10: Produce Final Response
|
||||||
|
|
||||||
|
最终响应必须包括:
|
||||||
|
|
||||||
|
1. 已解析点位
|
||||||
|
2. 候选路线列表
|
||||||
|
3. 最佳路线
|
||||||
|
4. deep links
|
||||||
|
5. summary
|
||||||
|
6. warnings
|
||||||
|
|
||||||
|
如果用户要求 HTML,则在结构化结果之外再生成展示层,不得把 HTML 当作底层返回格式。
|
||||||
|
|
||||||
|
## Failure Handling Rules
|
||||||
|
|
||||||
|
### Address Resolution Failure
|
||||||
|
|
||||||
|
如果某个地址无法解析:
|
||||||
|
|
||||||
|
1. 尝试 `maps_text_search`
|
||||||
|
2. 尝试 POI 兜底
|
||||||
|
3. 如果仍失败,返回结构化错误,不得编造坐标
|
||||||
|
|
||||||
|
### Partial POI Failure
|
||||||
|
|
||||||
|
如果点有坐标但没有 `poiId`:
|
||||||
|
|
||||||
|
1. 仍可用于 `direction_driving`
|
||||||
|
2. 可能无法用于 `schema_personal_map`
|
||||||
|
3. 应在 warning 中说明,并考虑改用 route plan deep link
|
||||||
|
|
||||||
|
### Candidate Explosion
|
||||||
|
|
||||||
|
如果途经点数量过多:
|
||||||
|
|
||||||
|
1. 使用 `maps_distance` 做初筛
|
||||||
|
2. 降低候选数量
|
||||||
|
3. 明确在 summary 中说明使用了启发式近似方法
|
||||||
|
|
||||||
|
### Current Location Uncertainty
|
||||||
|
|
||||||
|
当 `origin_mode = current_location` 且没有实时定位坐标时:
|
||||||
|
|
||||||
|
1. 只比较途经点内部顺序
|
||||||
|
2. 明确告知“真实最优顺序会受当前定位影响”
|
||||||
|
3. 不得虚构当前定位坐标
|
||||||
|
|
||||||
|
## Preferred Reasoning Pattern
|
||||||
|
|
||||||
|
Agent 应按以下顺序思考:
|
||||||
|
|
||||||
|
1. 用户要规划什么路线。
|
||||||
|
2. 起点是固定还是当前定位。
|
||||||
|
3. 哪些点需要解析。
|
||||||
|
4. 哪些点需要补 `poiId`。
|
||||||
|
5. 候选顺序有哪些。
|
||||||
|
6. 每个候选顺序要计算哪些路段。
|
||||||
|
7. 最后按什么策略选最优。
|
||||||
|
8. 哪种 deep link 最适合当前任务。
|
||||||
|
|
||||||
|
## Mandatory Constraints
|
||||||
|
|
||||||
|
1. 不得伪造 `poiId`。
|
||||||
|
2. 不得伪造距离或时长。
|
||||||
|
3. 不得把 `schema_personal_map` 错当成严格导航协议。
|
||||||
|
4. 不得在 `origin_mode = current_location` 时擅自把终点或公司写成起点。
|
||||||
|
5. 不得把终点同时写进途经点。
|
||||||
|
6. 不得把“最佳路线”说成绝对正确,如果当前定位未知。
|
||||||
|
7. 必须在输出中说明任何兜底、模糊命中和取舍逻辑。
|
||||||
|
|
||||||
|
## Recommended System Prompt
|
||||||
|
|
||||||
|
以下内容可以直接作为另一个 Agent 的 system prompt 基础版本:
|
||||||
|
|
||||||
|
```text
|
||||||
|
你是一个无状态的多目标地理路线规划 Agent。你的任务是使用高德地图 MCP 工具完成多目标点最优路线规划,并返回强类型结构化结果。你必须显式调用地图工具,不得编造坐标、POI、距离、时长或 deep link。
|
||||||
|
|
||||||
|
你的工作流必须严格遵守以下规则:
|
||||||
|
|
||||||
|
1. 先解析输入,明确起点模式、终点、途经点、优化策略和输出需求。
|
||||||
|
2. 对终点和每个途经点优先使用 maps_geo 解析地址;当命中不准或为空时,使用 maps_text_search 补齐 POI;必要时使用 maps_search_detail 校验。
|
||||||
|
3. 当起点模式为 fixed 时,对起点也做同样解析。
|
||||||
|
4. 当起点模式为 current_location 时,不得伪造起点坐标;如果缺少实时定位坐标,只能比较途经点内部顺序,并明确说明真实最优路线会受当前定位影响。
|
||||||
|
5. 这套工具没有单步多点最优路径工具,因此你必须自己生成候选途经点顺序。
|
||||||
|
6. 对每个候选顺序,逐段调用 maps_direction_driving 计算距离与时长,并汇总为候选路线。
|
||||||
|
7. 按 route_strategy 选择最优路线:
|
||||||
|
- shortest_distance:总里程优先,总时长次优
|
||||||
|
- fastest_time:总时长优先,总里程次优
|
||||||
|
- balanced:优先选择明显不劣的 Pareto 优势路线;若出现里程更短但时间略长的情况,默认优先里程更短并说明原因
|
||||||
|
8. 如需 deep link:
|
||||||
|
- 若目标是稳定导入整组点位,可使用 maps_schema_personal_map,但必须说明这是点位导入型链接
|
||||||
|
- 若目标是“我的位置”出发的即时导航,应输出 route plan 类 deep link,并说明不同平台协议不同
|
||||||
|
9. 最终结果必须包含:
|
||||||
|
- resolved_origin(如适用)
|
||||||
|
- resolved_destination
|
||||||
|
- resolved_stops
|
||||||
|
- candidates
|
||||||
|
- best_route
|
||||||
|
- deep_links
|
||||||
|
- summary
|
||||||
|
- warnings
|
||||||
|
10. 如果任何地址无法可靠解析、任何 POI 无法获取、或任何路线比较存在信息缺失,必须如实说明,不得猜测。
|
||||||
|
|
||||||
|
你的底层返回必须是结构化数据,不是 HTML。只有在用户明确要求页面展示时,才在结构化结果基础上额外生成 HTML。
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Notes for the Other Agent
|
||||||
|
|
||||||
|
另一个 Agent 在实施时,建议把逻辑拆为以下函数:
|
||||||
|
|
||||||
|
1. `resolve_point(raw_point) -> ResolvedPoint`
|
||||||
|
2. `resolve_points(request) -> tuple[ResolvedPoint | None, list[ResolvedPoint], ResolvedPoint]`
|
||||||
|
3. `generate_candidate_orders(stops) -> list[list[ResolvedPoint]]`
|
||||||
|
4. `compute_leg(origin_location, destination_location) -> RouteLeg`
|
||||||
|
5. `build_candidate_route(order, origin, destination, origin_mode) -> CandidateRoute`
|
||||||
|
6. `rank_candidates(candidates, strategy) -> CandidateRoute`
|
||||||
|
7. `build_deep_links(best_route, resolved_points, request) -> DeepLinks`
|
||||||
|
8. `summarize_result(result) -> str`
|
||||||
|
|
||||||
|
## Suggested HTML Policy
|
||||||
|
|
||||||
|
如果需要 HTML 展示,建议只消费 `RoutePlanResult`,不要直接消费 MCP 原始响应。HTML 层只负责:
|
||||||
|
|
||||||
|
1. 展示点位信息
|
||||||
|
2. 展示候选路线对比
|
||||||
|
3. 展示最佳路线
|
||||||
|
4. 展示 deep link 按钮
|
||||||
|
5. 展示 warnings
|
||||||
|
|
||||||
|
不要把 HTML 作为 Agent 的核心产物。
|
||||||
|
|
||||||
|
## Final Guidance
|
||||||
|
|
||||||
|
这个 Agent 的核心不是“调用一个神奇工具”,而是:
|
||||||
|
|
||||||
|
1. 用地图工具解析事实
|
||||||
|
2. 用编排逻辑生成候选路线
|
||||||
|
3. 用明确策略做选择
|
||||||
|
4. 用强类型结果给前端稳定对接
|
||||||
|
|
||||||
|
只要严格按照这份规范执行,就能复刻此前成功的多目标最优路线处理流程。
|
||||||
12
pyproject.toml
Normal file
12
pyproject.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[project]
|
||||||
|
name = "geo-agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Multi-destination geo routing agent"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.14"
|
||||||
|
dependencies = [
|
||||||
|
"pydantic-ai",
|
||||||
|
"fastapi[standard]",
|
||||||
|
"python-dotenv",
|
||||||
|
"openai",
|
||||||
|
]
|
||||||
151
schemas.py
Normal file
151
schemas.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Input
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RawStop(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
address: str
|
||||||
|
city: str | None = None
|
||||||
|
contact: str | None = None
|
||||||
|
|
||||||
|
@field_validator("name", "address", "city", "contact", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def strip_optional_strings(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
@field_validator("address")
|
||||||
|
@classmethod
|
||||||
|
def validate_address(cls, value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
raise ValueError("stop address cannot be empty")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlanRequest(BaseModel):
|
||||||
|
task_name: str = Field(default="multi-destination-route-planning")
|
||||||
|
origin_mode: Literal["fixed", "current_location"]
|
||||||
|
origin_name: str | None = None
|
||||||
|
origin_address: str | None = None
|
||||||
|
origin_city: str | None = None
|
||||||
|
destination_name: str | None = None
|
||||||
|
destination_address: str
|
||||||
|
destination_city: str | None = None
|
||||||
|
stops: list[RawStop]
|
||||||
|
route_strategy: Literal["shortest_distance", "fastest_time", "balanced"] = "shortest_distance"
|
||||||
|
transport_mode: Literal["driving"] = "driving"
|
||||||
|
need_deep_link: bool = True
|
||||||
|
deep_link_mode: Literal["personal_map", "route_plan", "auto"] = "auto"
|
||||||
|
need_html: bool = False
|
||||||
|
max_permutations: int | None = None
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"task_name",
|
||||||
|
"origin_name",
|
||||||
|
"origin_address",
|
||||||
|
"origin_city",
|
||||||
|
"destination_name",
|
||||||
|
"destination_address",
|
||||||
|
"destination_city",
|
||||||
|
mode="before",
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def strip_request_strings(cls, value: str | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = value.strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
@field_validator("destination_address")
|
||||||
|
@classmethod
|
||||||
|
def validate_destination_address(cls, value: str) -> str:
|
||||||
|
if not value:
|
||||||
|
raise ValueError("destination_address cannot be empty")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("max_permutations")
|
||||||
|
@classmethod
|
||||||
|
def validate_max_permutations(cls, value: int | None) -> int | None:
|
||||||
|
if value is not None and value <= 0:
|
||||||
|
raise ValueError("max_permutations must be greater than 0")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_route_request(self) -> "RoutePlanRequest":
|
||||||
|
if not self.stops:
|
||||||
|
raise ValueError("stops must contain at least one stop")
|
||||||
|
|
||||||
|
if self.origin_mode == "fixed" and not self.origin_address:
|
||||||
|
raise ValueError("origin_address is required when origin_mode='fixed'")
|
||||||
|
|
||||||
|
destination_key = self.destination_address.casefold()
|
||||||
|
duplicate_stop = next(
|
||||||
|
(stop.address for stop in self.stops if stop.address.casefold() == destination_key),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if duplicate_stop is not None:
|
||||||
|
raise ValueError("destination_address cannot also appear in stops")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ResolvedPoint(BaseModel):
|
||||||
|
role: Literal["origin", "stop", "destination"]
|
||||||
|
input_name: str | None = None
|
||||||
|
input_address: str
|
||||||
|
resolved_name: str
|
||||||
|
city: str | None = None
|
||||||
|
district: str | None = None
|
||||||
|
location: str # "lon,lat" format as returned by Amap
|
||||||
|
lon: float
|
||||||
|
lat: float
|
||||||
|
poi_id: str | None = None
|
||||||
|
source: Literal["geo", "text_search", "search_detail", "manual_fallback"]
|
||||||
|
confidence_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RouteLeg(BaseModel):
|
||||||
|
from_label: str
|
||||||
|
to_label: str
|
||||||
|
origin_location: str
|
||||||
|
destination_location: str
|
||||||
|
distance_m: int
|
||||||
|
duration_s: int
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateRoute(BaseModel):
|
||||||
|
stop_order_labels: list[str]
|
||||||
|
full_order_labels: list[str]
|
||||||
|
legs: list[RouteLeg]
|
||||||
|
total_distance_m: int
|
||||||
|
total_duration_s: int
|
||||||
|
ranking_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DeepLinks(BaseModel):
|
||||||
|
personal_map: str | None = None
|
||||||
|
android_route_plan: str | None = None
|
||||||
|
ios_route_plan: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlanResult(BaseModel):
|
||||||
|
success: bool
|
||||||
|
origin_mode: Literal["fixed", "current_location"]
|
||||||
|
resolved_origin: ResolvedPoint | None = None
|
||||||
|
resolved_destination: ResolvedPoint
|
||||||
|
resolved_stops: list[ResolvedPoint]
|
||||||
|
candidates: list[CandidateRoute]
|
||||||
|
best_route: CandidateRoute
|
||||||
|
deep_links: DeepLinks | None = None
|
||||||
|
summary: str
|
||||||
|
warnings: list[str]
|
||||||
Reference in New Issue
Block a user