feat:修复一些BUG

This commit is contained in:
2026-01-08 09:49:01 +08:00
parent 820861752b
commit 472b1a0d5e
33 changed files with 1643 additions and 742 deletions

View File

@@ -0,0 +1,3 @@
from .main import TravelPlanner
__all__ = ["TravelPlanner"]

View File

@@ -0,0 +1,860 @@
"""
高德地图 API 客户端封装
提供以下功能:
- 地理编码:地址 → 坐标
- 逆地理编码:坐标 → 地址
- 行政区域查询:获取城市 adcode
- 天气查询:实况/预报天气
- POI 搜索:关键字搜索、周边搜索
- 路径规划:驾车、公交、步行、骑行
"""
from __future__ import annotations
import hashlib
import aiohttp
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Literal
from loguru import logger
@dataclass
class AmapConfig:
"""高德 API 配置"""
api_key: str
secret: str = "" # 安全密钥,用于数字签名
timeout: int = 30
class AmapClient:
"""高德地图 API 客户端"""
BASE_URL = "https://restapi.amap.com"
def __init__(self, config: AmapConfig):
self.config = config
self._session: Optional[aiohttp.ClientSession] = None
@staticmethod
def _safe_int(value, default: int = 0) -> int:
"""安全地将值转换为整数处理列表、None、空字符串等情况"""
if value is None:
return default
if isinstance(value, list):
return default
if isinstance(value, (int, float)):
return int(value)
if isinstance(value, str):
if not value.strip():
return default
try:
return int(float(value))
except (ValueError, TypeError):
return default
return default
@staticmethod
def _safe_float(value, default: float = 0.0) -> float:
"""安全地将值转换为浮点数"""
if value is None:
return default
if isinstance(value, list):
return default
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
if not value.strip():
return default
try:
return float(value)
except (ValueError, TypeError):
return default
return default
@staticmethod
def _safe_str(value, default: str = "") -> str:
"""安全地将值转换为字符串,处理列表等情况"""
if value is None:
return default
if isinstance(value, list):
return default
return str(value)
async def _get_session(self) -> aiohttp.ClientSession:
"""获取或创建 HTTP 会话"""
if self._session is None or self._session.closed:
timeout = aiohttp.ClientTimeout(total=self.config.timeout)
self._session = aiohttp.ClientSession(timeout=timeout)
return self._session
async def close(self):
"""关闭 HTTP 会话"""
if self._session and not self._session.closed:
await self._session.close()
def _generate_signature(self, params: Dict[str, Any]) -> str:
"""
生成数字签名
算法:
1. 将请求参数按参数名升序排序
2. 按 key=value 格式拼接,用 & 连接
3. 最后拼接上私钥secret
4. 对整个字符串进行 MD5 加密
Args:
params: 请求参数(不含 sig
Returns:
MD5 签名字符串
"""
# 按参数名升序排序
sorted_params = sorted(params.items(), key=lambda x: x[0])
# 拼接成 key=value&key=value 格式
param_str = "&".join(f"{k}={v}" for k, v in sorted_params)
# 拼接私钥
sign_str = param_str + self.config.secret
# MD5 加密
return hashlib.md5(sign_str.encode('utf-8')).hexdigest()
async def _request(self, endpoint: str, params: Dict[str, Any]) -> Dict[str, Any]:
"""
发送 API 请求
Args:
endpoint: API 端点路径
params: 请求参数
Returns:
API 响应数据
"""
params["key"] = self.config.api_key
params["output"] = "JSON"
# 如果配置了安全密钥,生成数字签名
if self.config.secret:
params["sig"] = self._generate_signature(params)
url = f"{self.BASE_URL}{endpoint}"
session = await self._get_session()
try:
async with session.get(url, params=params) as response:
data = await response.json()
# 检查 API 状态
status = data.get("status", "0")
if status != "1":
info = data.get("info", "未知错误")
infocode = data.get("infocode", "")
logger.warning(f"高德 API 错误: {info} (code: {infocode})")
return {"success": False, "error": info, "code": infocode}
return {"success": True, "data": data}
except aiohttp.ClientError as e:
logger.error(f"高德 API 请求失败: {e}")
return {"success": False, "error": str(e)}
except Exception as e:
logger.error(f"高德 API 未知错误: {e}")
return {"success": False, "error": str(e)}
# ==================== 地理编码 ====================
async def geocode(self, address: str, city: str = None) -> Dict[str, Any]:
"""
地理编码:将地址转换为坐标
Args:
address: 结构化地址,如 "北京市朝阳区阜通东大街6号"
city: 指定城市(可选)
Returns:
{
"success": True,
"location": "116.480881,39.989410",
"adcode": "110105",
"city": "北京市",
"district": "朝阳区",
"level": "门址"
}
"""
params = {"address": address}
if city:
params["city"] = city
result = await self._request("/v3/geocode/geo", params)
if not result["success"]:
return result
geocodes = result["data"].get("geocodes", [])
if not geocodes:
return {"success": False, "error": "未找到该地址"}
geo = geocodes[0]
return {
"success": True,
"location": geo.get("location", ""),
"adcode": geo.get("adcode", ""),
"province": geo.get("province", ""),
"city": geo.get("city", ""),
"district": geo.get("district", ""),
"level": geo.get("level", ""),
"formatted_address": geo.get("formatted_address", address)
}
async def reverse_geocode(
self,
location: str,
radius: int = 1000,
extensions: str = "base"
) -> Dict[str, Any]:
"""
逆地理编码:将坐标转换为地址
Args:
location: 经纬度坐标,格式 "lng,lat"
radius: 搜索半径0-3000
extensions: base 或 all
Returns:
地址信息
"""
params = {
"location": location,
"radius": min(radius, 3000),
"extensions": extensions
}
result = await self._request("/v3/geocode/regeo", params)
if not result["success"]:
return result
regeocode = result["data"].get("regeocode", {})
address_component = regeocode.get("addressComponent", {})
return {
"success": True,
"formatted_address": regeocode.get("formatted_address", ""),
"province": address_component.get("province", ""),
"city": address_component.get("city", ""),
"district": address_component.get("district", ""),
"adcode": address_component.get("adcode", ""),
"township": address_component.get("township", ""),
"pois": regeocode.get("pois", []) if extensions == "all" else []
}
# ==================== 行政区域查询 ====================
async def get_district(
self,
keywords: str = None,
subdistrict: int = 1
) -> Dict[str, Any]:
"""
行政区域查询
Args:
keywords: 查询关键字城市名、adcode 等)
subdistrict: 返回下级行政区级数0-3
Returns:
行政区域信息,包含 adcode、citycode 等
"""
params = {"subdistrict": subdistrict}
if keywords:
params["keywords"] = keywords
result = await self._request("/v3/config/district", params)
if not result["success"]:
return result
districts = result["data"].get("districts", [])
if not districts:
return {"success": False, "error": "未找到该行政区域"}
district = districts[0]
return {
"success": True,
"name": district.get("name", ""),
"adcode": district.get("adcode", ""),
"citycode": district.get("citycode", ""),
"center": district.get("center", ""),
"level": district.get("level", ""),
"districts": district.get("districts", [])
}
# ==================== 天气查询 ====================
async def get_weather(
self,
city: str,
extensions: Literal["base", "all"] = "all"
) -> Dict[str, Any]:
"""
天气查询
Args:
city: 城市 adcode如 110000或城市名
extensions: base=实况天气all=预报天气未来4天
Returns:
天气信息
"""
# 如果传入的是城市名,先获取 adcode
if not city.isdigit():
district_result = await self.get_district(city)
if not district_result["success"]:
return {"success": False, "error": f"无法识别城市: {city}"}
city = district_result["adcode"]
params = {
"city": city,
"extensions": extensions
}
result = await self._request("/v3/weather/weatherInfo", params)
if not result["success"]:
return result
data = result["data"]
if extensions == "base":
# 实况天气
lives = data.get("lives", [])
if not lives:
return {"success": False, "error": "未获取到天气数据"}
live = lives[0]
return {
"success": True,
"type": "live",
"city": live.get("city", ""),
"weather": live.get("weather", ""),
"temperature": live.get("temperature", ""),
"winddirection": live.get("winddirection", ""),
"windpower": live.get("windpower", ""),
"humidity": live.get("humidity", ""),
"reporttime": live.get("reporttime", "")
}
else:
# 预报天气
forecasts = data.get("forecasts", [])
if not forecasts:
return {"success": False, "error": "未获取到天气预报数据"}
forecast = forecasts[0]
casts = forecast.get("casts", [])
return {
"success": True,
"type": "forecast",
"city": forecast.get("city", ""),
"province": forecast.get("province", ""),
"reporttime": forecast.get("reporttime", ""),
"forecasts": [
{
"date": cast.get("date", ""),
"week": cast.get("week", ""),
"dayweather": cast.get("dayweather", ""),
"nightweather": cast.get("nightweather", ""),
"daytemp": cast.get("daytemp", ""),
"nighttemp": cast.get("nighttemp", ""),
"daywind": cast.get("daywind", ""),
"nightwind": cast.get("nightwind", ""),
"daypower": cast.get("daypower", ""),
"nightpower": cast.get("nightpower", "")
}
for cast in casts
]
}
# ==================== POI 搜索 ====================
async def search_poi(
self,
keywords: str = None,
types: str = None,
city: str = None,
citylimit: bool = True,
offset: int = 20,
page: int = 1,
extensions: str = "all"
) -> Dict[str, Any]:
"""
关键字搜索 POI
Args:
keywords: 查询关键字
types: POI 类型代码,多个用 | 分隔
city: 城市名或 adcode
citylimit: 是否仅返回指定城市
offset: 每页数量建议不超过25
page: 页码
extensions: base 或 all
Returns:
POI 列表
"""
params = {
"offset": min(offset, 25),
"page": page,
"extensions": extensions
}
if keywords:
params["keywords"] = keywords
if types:
params["types"] = types
if city:
params["city"] = city
params["citylimit"] = "true" if citylimit else "false"
result = await self._request("/v3/place/text", params)
if not result["success"]:
return result
pois = result["data"].get("pois", [])
count = self._safe_int(result["data"].get("count", 0))
return {
"success": True,
"count": count,
"pois": [self._format_poi(poi) for poi in pois]
}
async def search_around(
self,
location: str,
keywords: str = None,
types: str = None,
radius: int = 3000,
offset: int = 20,
page: int = 1,
extensions: str = "all"
) -> Dict[str, Any]:
"""
周边搜索 POI
Args:
location: 中心点坐标,格式 "lng,lat"
keywords: 查询关键字
types: POI 类型代码
radius: 搜索半径0-50000
offset: 每页数量
page: 页码
extensions: base 或 all
Returns:
POI 列表
"""
params = {
"location": location,
"radius": min(radius, 50000),
"offset": min(offset, 25),
"page": page,
"extensions": extensions,
"sortrule": "distance"
}
if keywords:
params["keywords"] = keywords
if types:
params["types"] = types
result = await self._request("/v3/place/around", params)
if not result["success"]:
return result
pois = result["data"].get("pois", [])
count = self._safe_int(result["data"].get("count", 0))
return {
"success": True,
"count": count,
"pois": [self._format_poi(poi) for poi in pois]
}
def _format_poi(self, poi: Dict[str, Any]) -> Dict[str, Any]:
"""格式化 POI 数据"""
biz_ext = poi.get("biz_ext", {}) or {}
return {
"id": poi.get("id", ""),
"name": poi.get("name", ""),
"type": poi.get("type", ""),
"address": poi.get("address", ""),
"location": poi.get("location", ""),
"tel": poi.get("tel", ""),
"distance": poi.get("distance", ""),
"pname": poi.get("pname", ""),
"cityname": poi.get("cityname", ""),
"adname": poi.get("adname", ""),
"rating": biz_ext.get("rating", ""),
"cost": biz_ext.get("cost", "")
}
# ==================== 路径规划 ====================
async def route_driving(
self,
origin: str,
destination: str,
strategy: int = 10,
waypoints: str = None,
extensions: str = "base"
) -> Dict[str, Any]:
"""
驾车路径规划
Args:
origin: 起点坐标 "lng,lat"
destination: 终点坐标 "lng,lat"
strategy: 驾车策略10=躲避拥堵13=不走高速14=避免收费)
waypoints: 途经点,多个用 ; 分隔
extensions: base 或 all
Returns:
路径规划结果
"""
params = {
"origin": origin,
"destination": destination,
"strategy": strategy,
"extensions": extensions
}
if waypoints:
params["waypoints"] = waypoints
result = await self._request("/v3/direction/driving", params)
if not result["success"]:
return result
route = result["data"].get("route", {})
paths = route.get("paths", [])
if not paths:
return {"success": False, "error": "未找到驾车路线"}
path = paths[0]
return {
"success": True,
"mode": "driving",
"origin": route.get("origin", ""),
"destination": route.get("destination", ""),
"distance": self._safe_int(path.get("distance", 0)),
"duration": self._safe_int(path.get("duration", 0)),
"tolls": self._safe_float(path.get("tolls", 0)),
"toll_distance": self._safe_int(path.get("toll_distance", 0)),
"traffic_lights": self._safe_int(path.get("traffic_lights", 0)),
"taxi_cost": self._safe_str(route.get("taxi_cost", "")),
"strategy": path.get("strategy", ""),
"steps": self._format_driving_steps(path.get("steps", []))
}
async def route_transit(
self,
origin: str,
destination: str,
city: str,
cityd: str = None,
strategy: int = 0,
extensions: str = "all"
) -> Dict[str, Any]:
"""
公交路径规划(含火车、地铁)
Args:
origin: 起点坐标 "lng,lat"
destination: 终点坐标 "lng,lat"
city: 起点城市
cityd: 终点城市(跨城时必填)
strategy: 0=最快1=最省钱2=最少换乘3=最少步行
extensions: base 或 all
Returns:
公交路径规划结果
"""
params = {
"origin": origin,
"destination": destination,
"city": city,
"strategy": strategy,
"extensions": extensions
}
if cityd:
params["cityd"] = cityd
result = await self._request("/v3/direction/transit/integrated", params)
if not result["success"]:
return result
route = result["data"].get("route", {})
transits = route.get("transits", [])
if not transits:
return {"success": False, "error": "未找到公交路线"}
# 返回前3个方案
formatted_transits = []
for transit in transits[:3]:
segments = transit.get("segments", [])
formatted_segments = []
for seg in segments:
# 步行段
walking = seg.get("walking", {})
if walking and walking.get("distance"):
formatted_segments.append({
"type": "walking",
"distance": self._safe_int(walking.get("distance", 0)),
"duration": self._safe_int(walking.get("duration", 0))
})
# 公交/地铁段
bus_info = seg.get("bus", {})
buslines = bus_info.get("buslines", [])
if buslines:
line = buslines[0]
formatted_segments.append({
"type": "bus",
"name": self._safe_str(line.get("name", "")),
"departure_stop": self._safe_str(line.get("departure_stop", {}).get("name", "")),
"arrival_stop": self._safe_str(line.get("arrival_stop", {}).get("name", "")),
"via_num": self._safe_int(line.get("via_num", 0)),
"distance": self._safe_int(line.get("distance", 0)),
"duration": self._safe_int(line.get("duration", 0))
})
# 火车段
railway = seg.get("railway", {})
if railway and railway.get("name"):
formatted_segments.append({
"type": "railway",
"name": self._safe_str(railway.get("name", "")),
"trip": self._safe_str(railway.get("trip", "")),
"departure_stop": self._safe_str(railway.get("departure_stop", {}).get("name", "")),
"arrival_stop": self._safe_str(railway.get("arrival_stop", {}).get("name", "")),
"departure_time": self._safe_str(railway.get("departure_stop", {}).get("time", "")),
"arrival_time": self._safe_str(railway.get("arrival_stop", {}).get("time", "")),
"distance": self._safe_int(railway.get("distance", 0)),
"time": self._safe_str(railway.get("time", ""))
})
formatted_transits.append({
"cost": self._safe_str(transit.get("cost", "")),
"duration": self._safe_int(transit.get("duration", 0)),
"walking_distance": self._safe_int(transit.get("walking_distance", 0)),
"segments": formatted_segments
})
return {
"success": True,
"mode": "transit",
"origin": route.get("origin", ""),
"destination": route.get("destination", ""),
"distance": self._safe_int(route.get("distance", 0)),
"taxi_cost": self._safe_str(route.get("taxi_cost", "")),
"transits": formatted_transits
}
async def route_walking(
self,
origin: str,
destination: str
) -> Dict[str, Any]:
"""
步行路径规划
Args:
origin: 起点坐标 "lng,lat"
destination: 终点坐标 "lng,lat"
Returns:
步行路径规划结果
"""
params = {
"origin": origin,
"destination": destination
}
result = await self._request("/v3/direction/walking", params)
if not result["success"]:
return result
route = result["data"].get("route", {})
paths = route.get("paths", [])
if not paths:
return {"success": False, "error": "未找到步行路线"}
path = paths[0]
return {
"success": True,
"mode": "walking",
"origin": route.get("origin", ""),
"destination": route.get("destination", ""),
"distance": self._safe_int(path.get("distance", 0)),
"duration": self._safe_int(path.get("duration", 0))
}
async def route_bicycling(
self,
origin: str,
destination: str
) -> Dict[str, Any]:
"""
骑行路径规划
Args:
origin: 起点坐标 "lng,lat"
destination: 终点坐标 "lng,lat"
Returns:
骑行路径规划结果
"""
params = {
"origin": origin,
"destination": destination
}
# 骑行用 v4 接口
result = await self._request("/v4/direction/bicycling", params)
if not result["success"]:
return result
data = result["data"].get("data", {})
paths = data.get("paths", [])
if not paths:
return {"success": False, "error": "未找到骑行路线"}
path = paths[0]
return {
"success": True,
"mode": "bicycling",
"origin": data.get("origin", ""),
"destination": data.get("destination", ""),
"distance": self._safe_int(path.get("distance", 0)),
"duration": self._safe_int(path.get("duration", 0))
}
def _format_driving_steps(self, steps: List[Dict]) -> List[Dict]:
"""格式化驾车步骤"""
return [
{
"instruction": step.get("instruction", ""),
"road": step.get("road", ""),
"distance": self._safe_int(step.get("distance", 0)),
"duration": self._safe_int(step.get("duration", 0)),
"orientation": step.get("orientation", "")
}
for step in steps[:10] # 只返回前10步
]
# ==================== 距离测量 ====================
async def get_distance(
self,
origins: str,
destination: str,
type: int = 1
) -> Dict[str, Any]:
"""
距离测量
Args:
origins: 起点坐标,多个用 | 分隔
destination: 终点坐标
type: 0=直线距离1=驾车距离3=步行距离
Returns:
距离信息
"""
params = {
"origins": origins,
"destination": destination,
"type": type
}
result = await self._request("/v3/distance", params)
if not result["success"]:
return result
results = result["data"].get("results", [])
if not results:
return {"success": False, "error": "无法计算距离"}
return {
"success": True,
"results": [
{
"origin_id": r.get("origin_id", ""),
"distance": self._safe_int(r.get("distance", 0)),
"duration": self._safe_int(r.get("duration", 0))
}
for r in results
]
}
# ==================== 输入提示 ====================
async def input_tips(
self,
keywords: str,
city: str = None,
citylimit: bool = False,
datatype: str = "all"
) -> Dict[str, Any]:
"""
输入提示
Args:
keywords: 查询关键字
city: 城市名或 adcode
citylimit: 是否仅返回指定城市
datatype: all/poi/bus/busline
Returns:
提示列表
"""
params = {
"keywords": keywords,
"datatype": datatype
}
if city:
params["city"] = city
params["citylimit"] = "true" if citylimit else "false"
result = await self._request("/v3/assistant/inputtips", params)
if not result["success"]:
return result
tips = result["data"].get("tips", [])
return {
"success": True,
"tips": [
{
"id": tip.get("id", ""),
"name": tip.get("name", ""),
"district": tip.get("district", ""),
"adcode": tip.get("adcode", ""),
"location": tip.get("location", ""),
"address": tip.get("address", "")
}
for tip in tips
if tip.get("location") # 过滤无坐标的结果
]
}

View File

@@ -0,0 +1,609 @@
"""
旅行规划插件
基于高德地图 API提供以下功能
- 地点搜索与地理编码
- 天气查询(实况 + 4天预报
- 景点/酒店/餐厅搜索
- 路径规划(驾车/公交/步行)
- 周边搜索
支持 LLM 函数调用,可与 AIChat 插件配合使用。
"""
import tomllib
from pathlib import Path
from typing import Any, Dict, List
from loguru import logger
from utils.plugin_base import PluginBase
from .amap_client import AmapClient, AmapConfig
class TravelPlanner(PluginBase):
"""旅行规划插件"""
description = "旅行规划助手,支持天气查询、景点搜索、路线规划"
author = "ShiHao"
version = "1.0.0"
def __init__(self):
super().__init__()
self.config = None
self.amap: AmapClient = None
async def async_init(self):
"""插件异步初始化"""
# 读取配置
config_path = Path(__file__).parent / "config.toml"
with open(config_path, "rb") as f:
self.config = tomllib.load(f)
# 初始化高德 API 客户端
amap_config = self.config.get("amap", {})
api_key = amap_config.get("api_key", "")
secret = amap_config.get("secret", "")
if not api_key:
logger.warning("TravelPlanner: 未配置高德 API Key请在 config.toml 中设置")
else:
self.amap = AmapClient(AmapConfig(
api_key=api_key,
secret=secret,
timeout=amap_config.get("timeout", 30)
))
if secret:
logger.success(f"TravelPlanner 插件已加载API Key: {api_key[:8]}...(已启用数字签名)")
else:
logger.success(f"TravelPlanner 插件已加载API Key: {api_key[:8]}...(未配置安全密钥)")
async def on_disable(self):
"""插件禁用时关闭连接"""
await super().on_disable()
if self.amap:
await self.amap.close()
logger.info("TravelPlanner: 已关闭高德 API 连接")
# ==================== LLM 工具定义 ====================
def get_llm_tools(self) -> List[Dict]:
"""返回 LLM 可调用的工具列表"""
return [
{
"type": "function",
"function": {
"name": "search_location",
"description": "【旅行工具】将地名转换为坐标和行政区划信息。仅当用户明确询问某个地点的位置信息时使用。",
"parameters": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "地址或地名,如:北京市、西湖、故宫"
},
"city": {
"type": "string",
"description": "所在城市,可选。填写可提高搜索精度"
}
},
"required": ["address"]
}
}
},
{
"type": "function",
"function": {
"name": "query_weather",
"description": "【旅行工具】查询城市天气预报。仅当用户明确询问某城市的天气情况时使用,如'北京天气怎么样''杭州明天会下雨吗'",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称,如:北京、杭州、上海"
},
"forecast": {
"type": "boolean",
"description": "是否查询预报天气。true=未来4天预报false=当前实况"
}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "search_poi",
"description": "【旅行工具】搜索地点(景点、酒店、餐厅等)。仅当用户明确要求查找某城市的景点、酒店、餐厅等时使用。",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "搜索城市,如:杭州、北京"
},
"keyword": {
"type": "string",
"description": "搜索关键词,如:西湖、希尔顿酒店、火锅"
},
"category": {
"type": "string",
"enum": ["景点", "酒店", "餐厅", "购物", "交通"],
"description": "POI 类别。不填则搜索所有类别"
},
"limit": {
"type": "integer",
"description": "返回结果数量默认10最大20"
}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "search_nearby",
"description": "【旅行工具】搜索某地点周边的设施。仅当用户明确要求查找某地点附近的餐厅、酒店等时使用,如'西湖附近有什么好吃的'",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "中心地点名称,如:西湖、故宫"
},
"city": {
"type": "string",
"description": "所在城市"
},
"keyword": {
"type": "string",
"description": "搜索关键词"
},
"category": {
"type": "string",
"enum": ["景点", "酒店", "餐厅", "购物", "交通"],
"description": "POI 类别"
},
"radius": {
"type": "integer",
"description": "搜索半径默认3000最大50000"
}
},
"required": ["location", "city"]
}
}
},
{
"type": "function",
"function": {
"name": "plan_route",
"description": "【旅行工具】规划两地之间的出行路线。仅当用户明确要求规划从A到B的路线时使用'从北京到杭州怎么走''上海到苏州的高铁'",
"parameters": {
"type": "object",
"properties": {
"origin": {
"type": "string",
"description": "起点地名,如:北京、上海虹桥站"
},
"destination": {
"type": "string",
"description": "终点地名,如:杭州、西湖"
},
"origin_city": {
"type": "string",
"description": "起点所在城市"
},
"destination_city": {
"type": "string",
"description": "终点所在城市(跨城时必填)"
},
"mode": {
"type": "string",
"enum": ["driving", "transit", "walking"],
"description": "出行方式driving=驾车transit=公交/高铁walking=步行。默认 transit"
}
},
"required": ["origin", "destination", "origin_city"]
}
}
},
{
"type": "function",
"function": {
"name": "get_travel_info",
"description": "【旅行工具】获取目的地城市的旅行信息(天气、景点、交通)。仅当用户明确表示要去某城市旅游并询问相关信息时使用,如'我想去杭州玩,帮我看看''北京旅游攻略'",
"parameters": {
"type": "object",
"properties": {
"destination": {
"type": "string",
"description": "目的地城市,如:杭州、成都"
},
"origin": {
"type": "string",
"description": "出发城市,如:北京、上海。填写后会规划交通路线"
}
},
"required": ["destination"]
}
}
}
]
async def execute_llm_tool(
self,
tool_name: str,
arguments: Dict[str, Any],
bot,
from_wxid: str
) -> Dict[str, Any]:
"""执行 LLM 工具调用"""
if not self.amap:
return {"success": False, "message": "高德 API 未配置,请联系管理员设置 API Key"}
try:
if tool_name == "search_location":
return await self._tool_search_location(arguments)
elif tool_name == "query_weather":
return await self._tool_query_weather(arguments)
elif tool_name == "search_poi":
return await self._tool_search_poi(arguments)
elif tool_name == "search_nearby":
return await self._tool_search_nearby(arguments)
elif tool_name == "plan_route":
return await self._tool_plan_route(arguments)
elif tool_name == "get_travel_info":
return await self._tool_get_travel_info(arguments)
else:
return {"success": False, "message": f"未知工具: {tool_name}"}
except Exception as e:
logger.error(f"TravelPlanner 工具执行失败: {tool_name}, 错误: {e}")
return {"success": False, "message": f"工具执行失败: {str(e)}"}
# ==================== 工具实现 ====================
async def _tool_search_location(self, args: Dict) -> Dict:
"""地点搜索工具"""
address = args.get("address", "")
city = args.get("city")
result = await self.amap.geocode(address, city)
if not result["success"]:
return {"success": False, "message": result.get("error", "地点搜索失败")}
return {
"success": True,
"message": f"已找到地点:{result['formatted_address']}",
"data": {
"name": address,
"formatted_address": result["formatted_address"],
"location": result["location"],
"province": result["province"],
"city": result["city"],
"district": result["district"],
"adcode": result["adcode"]
}
}
async def _tool_query_weather(self, args: Dict) -> Dict:
"""天气查询工具"""
city = args.get("city", "")
forecast = args.get("forecast", True)
extensions = "all" if forecast else "base"
result = await self.amap.get_weather(city, extensions)
if not result["success"]:
return {"success": False, "message": result.get("error", "天气查询失败")}
if result["type"] == "live":
return {
"success": True,
"message": f"{result['city']}当前天气:{result['weather']}{result['temperature']}",
"data": {
"city": result["city"],
"weather": result["weather"],
"temperature": result["temperature"],
"humidity": result["humidity"],
"wind": f"{result['winddirection']}{result['windpower']}",
"reporttime": result["reporttime"]
}
}
else:
forecasts = result["forecasts"]
weather_text = "\n".join([
f"- {f['date']} 星期{self._weekday_cn(f['week'])}:白天{f['dayweather']} {f['daytemp']}℃,夜间{f['nightweather']} {f['nighttemp']}"
for f in forecasts
])
return {
"success": True,
"message": f"{result['city']}未来天气预报:\n{weather_text}",
"data": {
"city": result["city"],
"province": result["province"],
"forecasts": forecasts,
"reporttime": result["reporttime"]
}
}
async def _tool_search_poi(self, args: Dict) -> Dict:
"""POI 搜索工具"""
city = args.get("city", "")
keyword = args.get("keyword")
category = args.get("category")
limit = min(args.get("limit", 10), 20)
# 获取 POI 类型代码
types = None
if category:
poi_types = self.config.get("poi_types", {})
types = poi_types.get(category)
result = await self.amap.search_poi(
keywords=keyword,
types=types,
city=city,
citylimit=True,
offset=limit
)
if not result["success"]:
return {"success": False, "message": result.get("error", "搜索失败")}
pois = result["pois"]
if not pois:
return {"success": False, "message": f"{city}未找到相关地点"}
# 格式化输出
poi_list = []
for i, poi in enumerate(pois, 1):
info = f"{i}. {poi['name']}"
if poi.get("address"):
info += f" - {poi['address']}"
if poi.get("rating"):
info += f"{poi['rating']}"
if poi.get("cost"):
info += f" 人均¥{poi['cost']}"
poi_list.append(info)
return {
"success": True,
"message": f"{city}找到{len(pois)}个结果:\n" + "\n".join(poi_list),
"data": {
"city": city,
"category": category or "全部",
"count": len(pois),
"pois": pois
}
}
async def _tool_search_nearby(self, args: Dict) -> Dict:
"""周边搜索工具"""
location_name = args.get("location", "")
city = args.get("city", "")
keyword = args.get("keyword")
category = args.get("category")
radius = min(args.get("radius", 3000), 50000)
# 先获取中心点坐标
geo_result = await self.amap.geocode(location_name, city)
if not geo_result["success"]:
return {"success": False, "message": f"无法定位 {location_name}"}
location = geo_result["location"]
# 获取 POI 类型代码
types = None
if category:
poi_types = self.config.get("poi_types", {})
types = poi_types.get(category)
result = await self.amap.search_around(
location=location,
keywords=keyword,
types=types,
radius=radius,
offset=10
)
if not result["success"]:
return {"success": False, "message": result.get("error", "周边搜索失败")}
pois = result["pois"]
if not pois:
return {"success": False, "message": f"{location_name}周边未找到相关地点"}
# 格式化输出
poi_list = []
for i, poi in enumerate(pois, 1):
info = f"{i}. {poi['name']}"
if poi.get("distance"):
info += f" ({poi['distance']}米)"
if poi.get("rating"):
info += f"{poi['rating']}"
poi_list.append(info)
return {
"success": True,
"message": f"{location_name}周边{radius}米内找到{len(pois)}个结果:\n" + "\n".join(poi_list),
"data": {
"center": location_name,
"radius": radius,
"category": category or "全部",
"count": len(pois),
"pois": pois
}
}
async def _tool_plan_route(self, args: Dict) -> Dict:
"""路线规划工具"""
origin = args.get("origin", "")
destination = args.get("destination", "")
origin_city = args.get("origin_city", "")
destination_city = args.get("destination_city", origin_city)
mode = args.get("mode", "transit")
# 获取起终点坐标
origin_geo = await self.amap.geocode(origin, origin_city)
if not origin_geo["success"]:
return {"success": False, "message": f"无法定位起点:{origin}"}
dest_geo = await self.amap.geocode(destination, destination_city)
if not dest_geo["success"]:
return {"success": False, "message": f"无法定位终点:{destination}"}
origin_loc = origin_geo["location"]
dest_loc = dest_geo["location"]
# 根据模式规划路线
if mode == "driving":
result = await self.amap.route_driving(origin_loc, dest_loc)
if not result["success"]:
return {"success": False, "message": result.get("error", "驾车路线规划失败")}
distance_km = result["distance"] / 1000
duration_h = result["duration"] / 3600
msg = f"🚗 驾车路线:{origin}{destination}\n"
msg += f"距离:{distance_km:.1f}公里,预计{self._format_duration(result['duration'])}\n"
if result["tolls"]:
msg += f"收费:约{result['tolls']}\n"
if result["taxi_cost"]:
msg += f"打车费用:约{result['taxi_cost']}"
return {
"success": True,
"message": msg,
"data": result
}
elif mode == "transit":
result = await self.amap.route_transit(
origin_loc, dest_loc,
city=origin_city,
cityd=destination_city if destination_city != origin_city else None
)
if not result["success"]:
return {"success": False, "message": result.get("error", "公交路线规划失败")}
msg = f"🚄 公交/高铁路线:{origin}{destination}\n"
for i, transit in enumerate(result["transits"][:2], 1):
msg += f"\n方案{i}{self._format_duration(transit['duration'])}"
if transit.get("cost"):
msg += f",约{transit['cost']}"
msg += "\n"
for seg in transit["segments"]:
if seg["type"] == "walking" and seg["distance"] > 100:
msg += f" 🚶 步行{seg['distance']}\n"
elif seg["type"] == "bus":
msg += f" 🚌 {seg['name']}{seg['departure_stop']}{seg['arrival_stop']}{seg['via_num']}站)\n"
elif seg["type"] == "railway":
msg += f" 🚄 {seg['trip']} {seg['name']}{seg['departure_stop']} {seg.get('departure_time', '')}{seg['arrival_stop']} {seg.get('arrival_time', '')}\n"
return {
"success": True,
"message": msg.strip(),
"data": result
}
elif mode == "walking":
result = await self.amap.route_walking(origin_loc, dest_loc)
if not result["success"]:
return {"success": False, "message": result.get("error", "步行路线规划失败")}
return {
"success": True,
"message": f"🚶 步行路线:{origin}{destination}\n距离:{result['distance']}米,预计{self._format_duration(result['duration'])}",
"data": result
}
return {"success": False, "message": f"不支持的出行方式:{mode}"}
async def _tool_get_travel_info(self, args: Dict) -> Dict:
"""一键获取旅行信息"""
destination = args.get("destination", "")
origin = args.get("origin")
info = {"destination": destination}
msg_parts = [f"📍 {destination} 旅行信息\n"]
# 1. 查询天气
weather_result = await self.amap.get_weather(destination, "all")
if weather_result["success"]:
info["weather"] = weather_result
msg_parts.append("🌤️ 天气预报:")
for f in weather_result["forecasts"][:3]:
msg_parts.append(f" {f['date']} {f['dayweather']} {f['nighttemp']}~{f['daytemp']}")
# 2. 搜索热门景点
poi_result = await self.amap.search_poi(
types="110000", # 景点
city=destination,
citylimit=True,
offset=5
)
if poi_result["success"] and poi_result["pois"]:
info["attractions"] = poi_result["pois"]
msg_parts.append("\n🏞️ 热门景点:")
for poi in poi_result["pois"][:5]:
rating = f"{poi['rating']}" if poi.get("rating") else ""
msg_parts.append(f"{poi['name']}{rating}")
# 3. 规划交通路线(如果提供了出发地)
if origin:
origin_geo = await self.amap.geocode(origin)
dest_geo = await self.amap.geocode(destination)
if origin_geo["success"] and dest_geo["success"]:
route_result = await self.amap.route_transit(
origin_geo["location"],
dest_geo["location"],
city=origin_geo.get("city", origin),
cityd=dest_geo.get("city", destination)
)
if route_result["success"] and route_result["transits"]:
info["route"] = route_result
transit = route_result["transits"][0]
msg_parts.append(f"\n🚄 从{origin}出发:")
msg_parts.append(f" 预计{self._format_duration(transit['duration'])}")
# 显示主要交通工具
for seg in transit["segments"]:
if seg["type"] == "railway":
msg_parts.append(f" {seg['trip']}{seg['departure_stop']}{seg['arrival_stop']}")
break
return {
"success": True,
"message": "\n".join(msg_parts),
"data": info
}
# ==================== 辅助方法 ====================
def _weekday_cn(self, week: str) -> str:
"""星期数字转中文"""
mapping = {"1": "", "2": "", "3": "", "4": "", "5": "", "6": "", "7": ""}
return mapping.get(str(week), week)
def _format_duration(self, seconds: int) -> str:
"""格式化时长"""
if seconds < 60:
return f"{seconds}"
elif seconds < 3600:
return f"{seconds // 60}分钟"
else:
hours = seconds // 3600
minutes = (seconds % 3600) // 60
if minutes:
return f"{hours}小时{minutes}分钟"
return f"{hours}小时"