806 lines
35 KiB
Python
806 lines
35 KiB
Python
"""
|
||
旅行规划插件
|
||
|
||
基于高德地图 API,提供以下功能:
|
||
- 地点搜索与地理编码
|
||
- 天气查询(实况 + 4天预报)
|
||
- 景点/酒店/餐厅搜索
|
||
- 路径规划(驾车/公交/步行)
|
||
- 周边搜索
|
||
|
||
支持 LLM 函数调用,可与 AIChat 插件配合使用。
|
||
"""
|
||
|
||
import asyncio
|
||
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"]
|
||
}
|
||
}
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "plan_detailed_trip",
|
||
"description": "【必须调用】详细行程规划工具。当用户提到'规划行程'、'安排旅行'、'去XX旅游'、'帮我规划'、'我想去XX玩'时,必须调用此工具获取实时的交通、酒店、景点信息。此工具会返回:1.从用户家到火车站的详细路线(地铁几号线、哪站上哪站下)2.高铁车次和时刻 3.酒店推荐 4.景点推荐 5.餐厅推荐 6.天气预报。",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"origin_city": {
|
||
"type": "string",
|
||
"description": "出发城市,如:合肥、上海、北京"
|
||
},
|
||
"origin_address": {
|
||
"type": "string",
|
||
"description": "用户的具体出发地址,如:合肥市蜀山区xxx小区、上海市浦东新区xxx路。如果用户没提供具体地址,填写城市名即可"
|
||
},
|
||
"destination": {
|
||
"type": "string",
|
||
"description": "目的地城市,如:北京、杭州、成都"
|
||
},
|
||
"days": {
|
||
"type": "integer",
|
||
"description": "旅行天数,默认2天"
|
||
},
|
||
"departure_time": {
|
||
"type": "string",
|
||
"description": "出发时间偏好,如:周六早上、明天下午"
|
||
},
|
||
"preferences": {
|
||
"type": "string",
|
||
"description": "旅行偏好,如:喜欢历史文化、想吃美食、带小孩"
|
||
}
|
||
},
|
||
"required": ["origin_city", "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)
|
||
elif tool_name == "plan_detailed_trip":
|
||
return await self._tool_plan_detailed_trip(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,
|
||
"need_ai_reply": True # 让 AI 根据这些信息生成详细的行程规划
|
||
}
|
||
|
||
async def _tool_plan_detailed_trip(self, args: Dict) -> Dict:
|
||
"""
|
||
详细行程规划工具(优化版:并行 API 调用)
|
||
"""
|
||
origin_city = args.get("origin_city", "")
|
||
origin_address = args.get("origin_address", "") or origin_city
|
||
destination = args.get("destination", "")
|
||
days = args.get("days", 2)
|
||
departure_time = args.get("departure_time", "")
|
||
preferences = args.get("preferences", "")
|
||
|
||
info = {
|
||
"origin_city": origin_city,
|
||
"origin_address": origin_address,
|
||
"destination": destination,
|
||
"days": days
|
||
}
|
||
|
||
# ========== 第1步:并行获取基础信息 ==========
|
||
user_geo_task = self.amap.geocode(origin_address, origin_city)
|
||
dest_geo_task = self.amap.geocode(destination)
|
||
weather_task = self.amap.get_weather(destination, "all")
|
||
|
||
user_geo, dest_geo, weather_result = await asyncio.gather(
|
||
user_geo_task, dest_geo_task, weather_task,
|
||
return_exceptions=True
|
||
)
|
||
|
||
# 处理地理编码结果
|
||
if isinstance(user_geo, Exception) or not user_geo.get("success"):
|
||
user_geo = await self.amap.geocode(origin_city)
|
||
if isinstance(dest_geo, Exception) or not dest_geo.get("success"):
|
||
return {"success": False, "message": f"无法定位目的地:{destination}"}
|
||
if not user_geo.get("success"):
|
||
return {"success": False, "message": f"无法定位出发地:{origin_address}"}
|
||
|
||
user_loc = user_geo["location"]
|
||
dest_loc = dest_geo["location"]
|
||
origin_city_name = user_geo.get("city") or origin_city
|
||
dest_city_name = dest_geo.get("city") or destination
|
||
|
||
# ========== 第2步:并行搜索火车站和目的地信息 ==========
|
||
origin_stations_task = self.amap.search_poi(types="150200", city=origin_city, citylimit=True, offset=3)
|
||
dest_stations_task = self.amap.search_poi(types="150200", city=destination, citylimit=True, offset=2)
|
||
hotels_task = self.amap.search_poi(types="100100|100101", city=destination, citylimit=True, offset=5)
|
||
attractions_task = self.amap.search_poi(types="110000", city=destination, citylimit=True, offset=6)
|
||
food_task = self.amap.search_poi(types="050000", city=destination, citylimit=True, offset=5)
|
||
|
||
results = await asyncio.gather(
|
||
origin_stations_task, dest_stations_task, hotels_task, attractions_task, food_task,
|
||
return_exceptions=True
|
||
)
|
||
origin_stations, dest_stations, hotels, attractions, food = results
|
||
|
||
# ========== 第3步:规划到火车站的路线(只规划1个最近的) ==========
|
||
best_station = None
|
||
best_route = None
|
||
|
||
if not isinstance(origin_stations, Exception) and origin_stations.get("success") and origin_stations.get("pois"):
|
||
station = origin_stations["pois"][0] # 只取第一个火车站
|
||
try:
|
||
route = await self.amap.route_transit(user_loc, station["location"], city=origin_city_name)
|
||
if route.get("success") and route.get("transits"):
|
||
best_station = station
|
||
best_route = route["transits"][0]
|
||
except Exception as e:
|
||
logger.warning(f"规划到火车站路线失败: {e}")
|
||
|
||
# ========== 第4步:规划城际交通 ==========
|
||
transit_info = None
|
||
if best_station and not isinstance(dest_stations, Exception) and dest_stations.get("success") and dest_stations.get("pois"):
|
||
try:
|
||
dest_station = dest_stations["pois"][0]
|
||
transit = await self.amap.route_transit(
|
||
best_station["location"], dest_station["location"],
|
||
city=origin_city_name, cityd=dest_city_name
|
||
)
|
||
if transit.get("success") and transit.get("transits"):
|
||
transit_info = transit["transits"][0]
|
||
except Exception as e:
|
||
logger.warning(f"城际交通规划失败: {e}")
|
||
|
||
# ========== 组装输出 ==========
|
||
sections = []
|
||
sections.append(f"📋 {origin_address} → {destination} {days}天行程\n")
|
||
sections.append(f"📍 出发地:{user_geo.get('formatted_address', origin_address)}\n")
|
||
|
||
# 天气
|
||
if not isinstance(weather_result, Exception) and weather_result.get("success"):
|
||
sections.append("【天气预报】")
|
||
for f in weather_result.get("forecasts", [])[:3]:
|
||
sections.append(f" {f['date']}:{f['dayweather']} {f['nighttemp']}~{f['daytemp']}℃")
|
||
sections.append("")
|
||
|
||
# 到火车站
|
||
if best_station and best_route:
|
||
sections.append("【从您家到火车站】")
|
||
sections.append(f" 🚉 {best_station['name']}")
|
||
sections.append(f" ⏱️ 预计:{self._format_duration(best_route['duration'])}")
|
||
for seg in best_route.get("segments", []):
|
||
if seg["type"] == "bus":
|
||
line = seg["name"]
|
||
icon = "🚇" if "地铁" in line or "号线" in line else "🚌"
|
||
sections.append(f" {icon} {line}:{seg['departure_stop']} → {seg['arrival_stop']}({seg['via_num']}站)")
|
||
sections.append("")
|
||
|
||
# 城际交通
|
||
if transit_info:
|
||
sections.append("【城际高铁/火车】")
|
||
sections.append(f" ⏱️ 全程约{self._format_duration(transit_info['duration'])},费用约{transit_info.get('cost', '未知')}元")
|
||
for seg in transit_info.get("segments", []):
|
||
if seg["type"] == "railway":
|
||
sections.append(f" 🚄 {seg['trip']} {seg['name']}")
|
||
sections.append(f" {seg['departure_stop']} → {seg['arrival_stop']}")
|
||
sections.append("")
|
||
|
||
# 酒店
|
||
if not isinstance(hotels, Exception) and hotels.get("success") and hotels.get("pois"):
|
||
sections.append("【酒店推荐】")
|
||
for i, h in enumerate(hotels["pois"][:4], 1):
|
||
rating = f"⭐{h['rating']}" if h.get("rating") else ""
|
||
cost = f"¥{h['cost']}/晚" if h.get("cost") else ""
|
||
sections.append(f" {i}. {h['name']} {rating} {cost}")
|
||
sections.append("")
|
||
|
||
# 景点
|
||
if not isinstance(attractions, Exception) and attractions.get("success") and attractions.get("pois"):
|
||
sections.append("【热门景点】")
|
||
for i, p in enumerate(attractions["pois"][:5], 1):
|
||
rating = f"⭐{p['rating']}" if p.get("rating") else ""
|
||
sections.append(f" {i}. {p['name']} {rating}")
|
||
sections.append("")
|
||
|
||
# 美食
|
||
if not isinstance(food, Exception) and food.get("success") and food.get("pois"):
|
||
sections.append("【美食推荐】")
|
||
for i, p in enumerate(food["pois"][:4], 1):
|
||
cost = f"人均¥{p['cost']}" if p.get("cost") else ""
|
||
sections.append(f" {i}. {p['name']} {cost}")
|
||
sections.append("")
|
||
|
||
# 提示
|
||
sections.append(f"📌 请根据以上信息为用户安排{days}天行程")
|
||
if departure_time:
|
||
sections.append(f" 出发时间偏好:{departure_time}")
|
||
if preferences:
|
||
sections.append(f" 用户偏好:{preferences}")
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "\n".join(sections),
|
||
"data": info,
|
||
"need_ai_reply": True
|
||
}
|
||
|
||
# ==================== 辅助方法 ====================
|
||
|
||
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}小时"
|