推荐摘要
地点信息
| 序号 | 地点名称 | 详细地址 |
|---|
地图展示
推荐{cfg["noun_plural"]}
交通与停车建议
前往方式
最佳会面点位于{center_point[0]:.6f}, {center_point[1]:.6f}附近
- {location_distance_html}
智能出行建议
-
{transport_tips_html}
import asyncio import html import json import math import os import uuid from datetime import datetime from functools import lru_cache from typing import Any, Dict, List, Optional, Tuple import aiofiles import aiohttp from pydantic import Field from app.logger import logger from app.tool.base import BaseTool, ToolResult from app.config import config # LLM 智能评分(延迟导入以避免循环依赖) _llm_instance = None def _get_llm(): """延迟加载 LLM 实例""" global _llm_instance if _llm_instance is None: try: from app.llm import LLM from app.config import config # 检查 API Key 是否已配置 llm_config = config.llm.get("default", {}) api_key = getattr(llm_config, "api_key", "") if hasattr(llm_config, "api_key") else llm_config.get("api_key", "") if not api_key: logger.info("LLM API Key 未配置,跳过 LLM 初始化") return None _llm_instance = LLM() logger.info(f"LLM 初始化成功,模型: {_llm_instance.model}, base_url: {_llm_instance.base_url[:30]}..." if _llm_instance.base_url else f"LLM 初始化成功,模型: {_llm_instance.model}") except Exception as e: logger.warning(f"LLM 初始化失败,智能评分不可用: {e}") return _llm_instance class CafeRecommender(BaseTool): """场所推荐工具,基于多个地点计算最佳会面位置并推荐周边场所""" name: str = "place_recommender" description: str = """推荐适合多人会面的场所。 该工具基于多个地点的位置信息,计算最佳会面地点,并推荐附近的各类场所。 工具会生成包含地图和推荐信息的HTML页面,提供详细的场所信息、地理位置和交通建议。 可以搜索各种类型的场所,如咖啡馆、餐厅、商场、电影院、篮球场等。 """ parameters: dict = { "type": "object", "properties": { "locations": { "type": "array", "description": "(必填) 所有参与者的位置描述列表,每个元素为一个地点描述字符串,如['北京朝阳区望京宝星园', '海淀中关村地铁站']", "items": {"type": "string"}, }, "keywords": { "type": "string", "description": "(可选) 搜索关键词,如'咖啡馆'、'篮球场'、'电影院'、'商场'等。前端会将选择的场所类型(如“图书馆”)合并到此关键词中。", "default": "咖啡馆", }, "place_type": { "type": "string", "description": "(可选) 场所类型编码,如'050000'(餐饮),'080116'(篮球场),'080601'(电影院),'060100'(商场)等,默认为空。注意:通常前端会将场所类型通过keywords参数传递。", "default": "", }, "user_requirements": { "type": "string", "description": "(可选) 用户的额外需求,如'停车方便','环境安静'等", "default": "", }, }, "required": ["locations"], } # 高德地图API密钥 api_key: str = Field(default="") # 缓存请求结果以减少API调用(路演模式:极限压缩防止OOM) geocode_cache: Dict[str, Dict] = Field(default_factory=dict) poi_cache: Dict[str, List] = Field(default_factory=dict) GEOCODE_CACHE_MAX: int = 30 # 路演模式:减少到30个地址 POI_CACHE_MAX: int = 15 # 路演模式:减少到15个POI搜索结果 # ========== 品牌特征知识库 ========== # 用于三层匹配算法的第二层:基于品牌特征的需求推断 # 分值范围 0.0-1.0,>=0.7 视为满足需求 BRAND_FEATURES: Dict[str, Dict[str, float]] = { # ========== 咖啡馆 (15个) ========== "星巴克": {"安静": 0.8, "WiFi": 1.0, "商务": 0.7, "停车": 0.3, "可以久坐": 0.9}, "瑞幸": {"安静": 0.4, "WiFi": 0.7, "商务": 0.4, "停车": 0.3, "可以久坐": 0.5}, "Costa": {"安静": 0.9, "WiFi": 1.0, "商务": 0.8, "停车": 0.4, "可以久坐": 0.9}, "漫咖啡": {"安静": 0.9, "WiFi": 0.9, "商务": 0.6, "停车": 0.5, "可以久坐": 1.0}, "太平洋咖啡": {"安静": 0.8, "WiFi": 0.9, "商务": 0.7, "停车": 0.4, "可以久坐": 0.8}, "Manner": {"安静": 0.5, "WiFi": 0.6, "商务": 0.4, "停车": 0.2, "可以久坐": 0.3}, "Seesaw": {"安静": 0.8, "WiFi": 0.9, "商务": 0.6, "停车": 0.3, "可以久坐": 0.8}, "M Stand": {"安静": 0.7, "WiFi": 0.8, "商务": 0.5, "停车": 0.3, "可以久坐": 0.7}, "Tims": {"安静": 0.6, "WiFi": 0.8, "商务": 0.5, "停车": 0.4, "可以久坐": 0.6}, "上岛咖啡": {"安静": 0.9, "WiFi": 0.8, "商务": 0.8, "停车": 0.6, "可以久坐": 0.9, "包间": 0.7}, "Zoo Coffee": {"安静": 0.7, "WiFi": 0.8, "商务": 0.5, "停车": 0.4, "可以久坐": 0.8, "适合儿童": 0.6}, "猫屎咖啡": {"安静": 0.8, "WiFi": 0.8, "商务": 0.6, "停车": 0.4, "可以久坐": 0.8}, "皮爷咖啡": {"安静": 0.7, "WiFi": 0.8, "商务": 0.5, "停车": 0.3, "可以久坐": 0.7}, "咖世家": {"安静": 0.8, "WiFi": 0.9, "商务": 0.7, "停车": 0.4, "可以久坐": 0.8}, "挪瓦咖啡": {"安静": 0.5, "WiFi": 0.6, "商务": 0.4, "停车": 0.2, "可以久坐": 0.4}, # ========== 中餐厅 (15个) ========== "海底捞": {"包间": 0.9, "停车": 0.8, "安静": 0.2, "适合儿童": 0.9, "24小时营业": 0.3}, "西贝": {"包间": 0.7, "停车": 0.6, "安静": 0.5, "适合儿童": 0.7}, "外婆家": {"包间": 0.5, "停车": 0.5, "安静": 0.3, "适合儿童": 0.6}, "绿茶": {"包间": 0.4, "停车": 0.5, "安静": 0.4, "适合儿童": 0.5}, "小龙坎": {"包间": 0.6, "停车": 0.5, "安静": 0.2, "适合儿童": 0.4}, "呷哺呷哺": {"包间": 0.0, "停车": 0.4, "安静": 0.3, "适合儿童": 0.5}, "大龙燚": {"包间": 0.5, "停车": 0.5, "安静": 0.2, "适合儿童": 0.4}, "眉州东坡": {"包间": 0.8, "停车": 0.7, "安静": 0.6, "适合儿童": 0.7, "商务": 0.7}, "全聚德": {"包间": 0.9, "停车": 0.7, "安静": 0.6, "适合儿童": 0.6, "商务": 0.8}, "大董": {"包间": 0.9, "停车": 0.8, "安静": 0.8, "商务": 0.9}, "鼎泰丰": {"包间": 0.5, "停车": 0.6, "安静": 0.6, "适合儿童": 0.7}, "南京大牌档": {"包间": 0.6, "停车": 0.5, "安静": 0.3, "适合儿童": 0.6}, "九毛九": {"包间": 0.4, "停车": 0.5, "安静": 0.4, "适合儿童": 0.6}, "太二酸菜鱼": {"包间": 0.0, "停车": 0.4, "安静": 0.3, "适合儿童": 0.4}, "湘鄂情": {"包间": 0.8, "停车": 0.7, "安静": 0.5, "商务": 0.7}, # ========== 西餐/快餐 (10个) ========== "麦当劳": {"停车": 0.5, "WiFi": 0.8, "适合儿童": 0.9, "24小时营业": 0.8}, "肯德基": {"停车": 0.5, "WiFi": 0.7, "适合儿童": 0.9, "24小时营业": 0.6}, "必胜客": {"包间": 0.3, "停车": 0.5, "适合儿童": 0.8, "安静": 0.5}, "萨莉亚": {"停车": 0.4, "适合儿童": 0.7, "安静": 0.4}, "汉堡王": {"停车": 0.4, "WiFi": 0.6, "适合儿童": 0.7}, "赛百味": {"停车": 0.3, "WiFi": 0.5, "可以久坐": 0.4}, "棒约翰": {"停车": 0.4, "适合儿童": 0.7, "包间": 0.2}, "达美乐": {"停车": 0.3, "适合儿童": 0.6}, "DQ": {"适合儿童": 0.9, "停车": 0.4}, "哈根达斯": {"适合儿童": 0.7, "安静": 0.6, "可以久坐": 0.5}, # ========== 奶茶/饮品 (8个) ========== "喜茶": {"安静": 0.4, "可以久坐": 0.5, "停车": 0.3}, "奈雪的茶": {"安静": 0.5, "可以久坐": 0.6, "停车": 0.4, "WiFi": 0.6}, "茶百道": {"安静": 0.3, "可以久坐": 0.3, "停车": 0.2}, "一点点": {"安静": 0.2, "可以久坐": 0.2, "停车": 0.2}, "蜜雪冰城": {"安静": 0.2, "可以久坐": 0.2, "停车": 0.2}, "茶颜悦色": {"安静": 0.4, "可以久坐": 0.4, "停车": 0.3}, "古茗": {"安静": 0.3, "可以久坐": 0.3, "停车": 0.2}, "CoCo": {"安静": 0.3, "可以久坐": 0.3, "停车": 0.2}, # ========== 场所类型默认特征 (以下划线开头) ========== "_图书馆": {"安静": 1.0, "WiFi": 0.9, "可以久坐": 1.0}, "_书店": {"安静": 1.0, "可以久坐": 0.8, "WiFi": 0.5}, "_商场": {"停车": 0.9, "交通": 0.8, "适合儿童": 0.7}, "_酒店": {"安静": 0.9, "商务": 0.9, "停车": 0.8, "WiFi": 0.9, "包间": 0.8}, "_电影院": {"停车": 0.7, "适合儿童": 0.6}, "_KTV": {"包间": 1.0, "停车": 0.6, "24小时营业": 0.5}, "_健身房": {"停车": 0.6, "WiFi": 0.5}, "_网咖": {"WiFi": 1.0, "24小时营业": 0.8, "可以久坐": 0.9}, "_便利店": {"24小时营业": 0.9}, } PLACE_TYPE_CONFIG: Dict[str, Dict[str, str]] = { "咖啡馆": { "topic": "咖啡会", "icon_header": "bxs-coffee-togo", "icon_section": "bx-coffee", "icon_card": "bxs-coffee-alt", "map_legend": "咖啡馆", "noun_singular": "咖啡馆", "noun_plural": "咖啡馆", "theme_primary": "#9c6644", # 棕色系 "theme_primary_light": "#c68b59", "theme_primary_dark": "#7f5539", "theme_secondary": "#c9ada7", "theme_light": "#f2e9e4", "theme_dark": "#22223b", }, "图书馆": { "topic": "知书达理会", "icon_header": "bxs-book", "icon_section": "bx-book", "icon_card": "bxs-book-reader", "map_legend": "图书馆", "noun_singular": "图书馆", "noun_plural": "图书馆", "theme_primary": "#4a6fa5", # 蓝色系 "theme_primary_light": "#6e8fc5", "theme_primary_dark": "#305182", "theme_secondary": "#9dc0e5", "theme_light": "#f0f5fa", "theme_dark": "#2c3e50", }, "餐厅": { "topic": "美食汇", "icon_header": "bxs-restaurant", "icon_section": "bx-restaurant", "icon_card": "bxs-restaurant", "map_legend": "餐厅", "noun_singular": "餐厅", "noun_plural": "餐厅", "theme_primary": "#e74c3c", # 红色系 "theme_primary_light": "#f1948a", "theme_primary_dark": "#c0392b", "theme_secondary": "#fadbd8", "theme_light": "#fef5e7", "theme_dark": "#34222e", }, "商场": { "topic": "乐购汇", "icon_header": "bxs-shopping-bag", "icon_section": "bx-shopping-bag", "icon_card": "bxs-store-alt", "map_legend": "商场", "noun_singular": "商场", "noun_plural": "商场", "theme_primary": "#8e44ad", # 紫色系 "theme_primary_light": "#af7ac5", "theme_primary_dark": "#6c3483", "theme_secondary": "#d7bde2", "theme_light": "#f4ecf7", "theme_dark": "#3b1f2b", }, "公园": { "topic": "悠然汇", "icon_header": "bxs-tree", "icon_section": "bx-leaf", "icon_card": "bxs-florist", "map_legend": "公园", "noun_singular": "公园", "noun_plural": "公园", "theme_primary": "#27ae60", # 绿色系 "theme_primary_light": "#58d68d", "theme_primary_dark": "#1e8449", "theme_secondary": "#a9dfbf", "theme_light": "#eafaf1", "theme_dark": "#1e3b20", }, "电影院": { "topic": "光影汇", "icon_header": "bxs-film", "icon_section": "bx-film", "icon_card": "bxs-movie-play", "map_legend": "电影院", "noun_singular": "电影院", "noun_plural": "电影院", "theme_primary": "#34495e", # 深蓝灰色系 "theme_primary_light": "#5d6d7e", "theme_primary_dark": "#2c3e50", "theme_secondary": "#aeb6bf", "theme_light": "#ebedef", "theme_dark": "#17202a", }, "篮球场": { "topic": "篮球部落", "icon_header": "bxs-basketball", "icon_section": "bx-basketball", "icon_card": "bxs-basketball", "map_legend": "篮球场", "noun_singular": "篮球场", "noun_plural": "篮球场", "theme_primary": "#f39c12", # 橙色系 "theme_primary_light": "#f8c471", "theme_primary_dark": "#d35400", "theme_secondary": "#fdebd0", "theme_light": "#fef9e7", "theme_dark": "#4a2303", }, "健身房": { "topic": "健身汇", "icon_header": "bx-dumbbell", "icon_section": "bx-dumbbell", "icon_card": "bx-dumbbell", "map_legend": "健身房", "noun_singular": "健身房", "noun_plural": "健身房", "theme_primary": "#e67e22", # 活力橙色系 "theme_primary_light": "#f39c12", "theme_primary_dark": "#d35400", "theme_secondary": "#fdebd0", "theme_light": "#fef9e7", "theme_dark": "#4a2c03", }, "KTV": { "topic": "欢唱汇", "icon_header": "bxs-microphone", "icon_section": "bx-microphone", "icon_card": "bxs-microphone", "map_legend": "KTV", "noun_singular": "KTV", "noun_plural": "KTV", "theme_primary": "#FF1493", # 音乐粉色系 "theme_primary_light": "#FF69B4", "theme_primary_dark": "#DC143C", "theme_secondary": "#FFB6C1", "theme_light": "#FFF0F5", "theme_dark": "#8B1538", }, "博物馆": { "topic": "博古汇", "icon_header": "bxs-institution", "icon_section": "bx-institution", "icon_card": "bxs-institution", "map_legend": "博物馆", "noun_singular": "博物馆", "noun_plural": "博物馆", "theme_primary": "#DAA520", # 文化金色系 "theme_primary_light": "#FFD700", "theme_primary_dark": "#B8860B", "theme_secondary": "#F0E68C", "theme_light": "#FFFACD", "theme_dark": "#8B7355", }, "景点": { "topic": "游览汇", "icon_header": "bxs-landmark", "icon_section": "bx-landmark", "icon_card": "bxs-landmark", "map_legend": "景点", "noun_singular": "景点", "noun_plural": "景点", "theme_primary": "#17A2B8", # 旅游青色系 "theme_primary_light": "#20C997", "theme_primary_dark": "#138496", "theme_secondary": "#7FDBDA", "theme_light": "#E0F7FA", "theme_dark": "#00695C", }, "酒吧": { "topic": "夜宴汇", "icon_header": "bxs-drink", "icon_section": "bx-drink", "icon_card": "bxs-drink", "map_legend": "酒吧", "noun_singular": "酒吧", "noun_plural": "酒吧", "theme_primary": "#2C3E50", # 夜晚蓝色系 "theme_primary_light": "#5D6D7E", "theme_primary_dark": "#1B2631", "theme_secondary": "#85929E", "theme_light": "#EBF5FB", "theme_dark": "#17202A", }, "茶楼": { "topic": "茶韵汇", "icon_header": "bxs-coffee-bean", "icon_section": "bx-coffee-bean", "icon_card": "bxs-coffee-bean", "map_legend": "茶楼", "noun_singular": "茶楼", "noun_plural": "茶楼", "theme_primary": "#52796F", # 茶香绿色系 "theme_primary_light": "#84A98C", "theme_primary_dark": "#354F52", "theme_secondary": "#CAD2C5", "theme_light": "#F7F9F7", "theme_dark": "#2F3E46", }, "default": { # 默认主题颜色 (同咖啡馆) "topic": "会面点", "icon_header": "bxs-map-pin", "icon_section": "bx-map-pin", "icon_card": "bxs-location-plus", "map_legend": "场所", "noun_singular": "场所", "noun_plural": "场所", "theme_primary": "#9c6644", "theme_primary_light": "#c68b59", "theme_primary_dark": "#7f5539", "theme_secondary": "#c9ada7", "theme_light": "#f2e9e4", "theme_dark": "#22223b", } } def _get_place_config(self, primary_keyword: str) -> Dict[str, str]: """获取指定场所类型的显示配置""" return self.PLACE_TYPE_CONFIG.get(primary_keyword, self.PLACE_TYPE_CONFIG["default"]) @staticmethod @lru_cache(maxsize=1) def _load_city_dataset() -> List[Dict]: """从数据文件读取城市信息(带缓存).""" try: with open("data/cities.json", "r", encoding="utf-8") as fh: payload = json.load(fh) return payload.get("cities", []) except (FileNotFoundError, json.JSONDecodeError): return [] def _extract_city_from_locations(self, locations: List[Dict]) -> str: """尝试从参与者地址中推断城市.""" city_dataset = self._load_city_dataset() for loc in locations: address = " ".join( filter( None, [ loc.get("formatted_address", ""), loc.get("name", ""), loc.get("city", ""), ], ) ) for city in city_dataset: name = city.get("name", "") name_en = city.get("name_en", "") if name and name in address: return name if name_en and name_en.lower() in address.lower(): return name return locations[0].get("city", "未知城市") if locations else "未知城市" def _format_schema_payload(self, place: Dict, city_name: str) -> Dict: """构建LocalBusiness schema所需数据.""" lng = lat = None location_str = place.get("location", "") if location_str and "," in location_str: lng_str, lat_str = location_str.split(",", 1) try: lng = float(lng_str) lat = float(lat_str) except ValueError: lng = lat = None biz_ext = place.get("biz_ext", {}) or {} return { "name": place.get("name", ""), "address": place.get("address", ""), "city": city_name, "lat": lat, "lng": lng, "rating": biz_ext.get("rating", 4.5), "review_count": biz_ext.get("review_count", 100), "price_range": biz_ext.get("cost", "¥¥"), } async def execute( self, locations: List[str], keywords: str = "咖啡馆", place_type: str = "", user_requirements: str = "", theme: str = "", # 添加主题参数 min_rating: float = 0.0, # 最低评分筛选 max_distance: int = 100000, # 最大距离筛选(米) price_range: str = "", # 价格区间筛选 pre_resolved_coords: List[dict] = None, # 预解析坐标(来自前端 Autocomplete) ) -> ToolResult: # 尝试从多个来源获取API key if not self.api_key: # 首先尝试从config对象获取 if hasattr(config, "amap") and config.amap and hasattr(config.amap, "api_key"): self.api_key = config.amap.api_key # 如果config不可用,尝试从环境变量获取 elif not self.api_key: import os self.api_key = os.getenv("AMAP_API_KEY", "") if not self.api_key: logger.error("高德地图API密钥未配置。请在config.toml中设置 amap.api_key 或设置环境变量 AMAP_API_KEY。") return ToolResult(output="推荐失败: 高德地图API密钥未配置。") try: coordinates = [] location_info = [] geocode_results = [] # 存储原始 geocode 结果用于后续分析 # 检查是否有预解析坐标(来自前端 Autocomplete 选择) if pre_resolved_coords and len(pre_resolved_coords) == len(locations): logger.info(f"使用前端预解析坐标,跳过 geocoding: {len(pre_resolved_coords)} 个地点") for i, coord in enumerate(pre_resolved_coords): coordinates.append((coord["lng"], coord["lat"])) location_info.append({ "name": locations[i], "formatted_address": coord.get("address", locations[i]), "location": f"{coord['lng']},{coord['lat']}", "lng": coord["lng"], "lat": coord["lat"], "city": coord.get("city", "") }) geocode_results.append({ "original_location": locations[i], "result": { "formatted_address": coord.get("address", locations[i]), "location": f"{coord['lng']},{coord['lat']}", "city": coord.get("city", "") } }) else: # 原有的 geocoding 逻辑 # 并行地理编码 - 大幅提升性能 async def geocode_with_delay(location: str, index: int): """带轻微延迟的地理编码,避免API限流""" if index > 0: await asyncio.sleep(0.05 * index) # 50ms递增延迟,比原来的500ms快10倍 return await self._geocode(location) # 使用 asyncio.gather 并行执行所有地理编码请求 geocode_tasks = [geocode_with_delay(loc, i) for i, loc in enumerate(locations)] geocode_raw_results = await asyncio.gather(*geocode_tasks, return_exceptions=True) # 处理结果并检查错误 for i, (location, result) in enumerate(zip(locations, geocode_raw_results)): if isinstance(result, Exception): logger.error(f"地理编码异常: {location} - {result}") result = None if not result: # 检查是否为大学简称但地理编码失败 enhanced_address = self._enhance_address(location) if enhanced_address != location: return ToolResult(output=f"无法找到地点: {location}\n\n识别为大学简称\n您输入的 '{location}' 可能是大学简称,但未能成功解析。\n\n建议尝试:\n完整名称:'{enhanced_address}'\n添加城市:'北京 {location}'、'上海 {location}'\n具体地址:'北京市海淀区{enhanced_address}'\n校区信息:如 '{location}本部'、'{location}新校区'") else: # 提供更详细的地址输入指导 suggestions = self._get_address_suggestions(location) return ToolResult(output=f"无法找到地点: {location}\n\n地址解析失败\n系统无法识别您输入的地址,请检查以下几点:\n\n具体建议:\n{suggestions}\n\n标准地址格式示例:\n完整地址:'北京市海淀区中关村大街27号'\n知名地标:'北京大学'、'天安门广场'、'上海外滩'\n商圈区域:'三里屯'、'王府井'、'南京路步行街'\n交通枢纽:'北京南站'、'上海虹桥机场'\n\n常见错误避免:\n避免过于简短:'大学' -> '北京大学'\n避免拼写错误:'北大' -> '北京大学'\n避免模糊描述:'那个商场' -> '王府井百货大楼'\n\n如果仍有问题:\n检查网络连接是否正常\n尝试使用地址的官方全称\n确认地点确实存在且对外开放") geocode_results.append({ "original_location": location, "result": result }) # 智能城市推断:检测是否有地点被解析到完全不同的城市 if len(geocode_results) > 1: city_hint = self._extract_city_hint(locations) geocode_results = await self._smart_city_inference(locations, geocode_results, city_hint) # 处理最终的 geocode 结果 for item in geocode_results: geocode_result = item["result"] location = item["original_location"] lng, lat = geocode_result["location"].split(",") coordinates.append((float(lng), float(lat))) location_info.append({ "name": location, "formatted_address": geocode_result.get("formatted_address", location), "location": geocode_result["location"], "lng": float(lng), "lat": float(lat), "city": geocode_result.get("city", "") }) if not coordinates: error_msg = "❌ 未能解析任何有效的地点位置。\n\n" error_msg += "🔍 **解析失败的地址:**\n" for location in locations: error_msg += f"• {location}\n" suggestions = self._get_address_suggestions(location) if suggestions: error_msg += f" 💡 建议:{suggestions}\n" error_msg += "\n" error_msg += "📍 **地址输入检查清单:**\n" error_msg += "• **拼写准确性**:确保地名、路名拼写无误\n" error_msg += "• **地理层级**:包含省市区信息,如 '北京市海淀区...'\n" error_msg += "• **地址完整性**:提供门牌号或具体位置描述\n" error_msg += "• **地点真实性**:确认地点确实存在且可被地图服务识别\n\n" error_msg += "💡 **推荐格式示例:**\n" error_msg += "• **完整地址**:'北京市海淀区中关村大街1号'\n" error_msg += "• **知名地标**:'北京大学'、'上海外滩'、'广州塔'\n" error_msg += "• **商圈/区域**:'三里屯'、'南京路步行街'、'春熙路'\n" error_msg += "• **交通枢纽**:'北京南站'、'上海虹桥机场'、'广州白云机场'\n\n" error_msg += "📝 **多地点输入说明:**\n" error_msg += "• **方式一**:在不同输入框中分别填写,如第一个框填'北京大学',第二个框填'中关村'\n" error_msg += "• **方式二**:在一个输入框中用空格分隔,如'北京大学 中关村'(系统会自动拆分)\n" error_msg += "• **注意**:完整地址(包含'市'、'区'、'县')不会被拆分,如'北京市海淀区'\n" return ToolResult(output=error_msg) center_point = self._calculate_center_point(coordinates) # 处理多个关键词的搜索 keywords_list = [kw.strip() for kw in keywords.split() if kw.strip()] primary_keyword = keywords_list[0] if keywords_list else "咖啡馆" searched_places = [] # 如果有多个关键词,使用并发搜索提高性能 if len(keywords_list) > 1: logger.info(f"多场景并发搜索: {keywords_list}") # 创建并发搜索任务 async def search_keyword(keyword): logger.info(f"开始搜索场景: '{keyword}'") places = await self._search_pois( f"{center_point[0]},{center_point[1]}", keyword, radius=5000, types="" ) if places: # 为每个场所添加来源标记 for place in places: place['_source_keyword'] = keyword logger.info(f"'{keyword}' 找到 {len(places)} 个结果") return places else: logger.info(f"'{keyword}' 未找到结果") return [] # 并发执行所有搜索 tasks = [search_keyword(keyword) for keyword in keywords_list] results = await asyncio.gather(*tasks, return_exceptions=True) # 合并结果 all_places = [] for i, result in enumerate(results): if isinstance(result, Exception): logger.error(f"搜索 '{keywords_list[i]}' 时出错: {result}") elif result: all_places.extend(result) # 去重(基于场所名称和坐标位置,更宽松的去重策略) seen = set() unique_places = [] for place in all_places: # 使用名称和坐标进行去重,而不是地址(地址可能格式不同) location = place.get('location', '') name = place.get('name', '') identifier = f"{name}_{location}" if identifier not in seen: seen.add(identifier) unique_places.append(place) searched_places = unique_places logger.info(f"多场景搜索完成,去重后共 {len(searched_places)} 个结果") else: # 单个关键词的传统搜索 searched_places = await self._search_pois( f"{center_point[0]},{center_point[1]}", keywords, radius=5000, types=place_type ) # Fallback机制:确保始终有推荐结果 fallback_used = False fallback_keyword = None if not searched_places: logger.info(f"使用 keywords '{keywords}' 和 types '{place_type}' 未找到结果,尝试仅使用 keywords 进行搜索。") searched_places = await self._search_pois( f"{center_point[0]},{center_point[1]}", keywords, radius=5000, types="" ) # 如果仍无结果,启用 Fallback 搜索 if not searched_places: logger.info(f"'{keywords}' 无结果,启用 Fallback 搜索机制") fallback_categories = ["餐厅", "咖啡馆", "商场", "美食"] for fallback_kw in fallback_categories: if fallback_kw != keywords: # 避免重复搜索 searched_places = await self._search_pois( f"{center_point[0]},{center_point[1]}", fallback_kw, radius=5000, types="" ) if searched_places: fallback_used = True fallback_keyword = fallback_kw logger.info(f"Fallback 成功:使用 '{fallback_kw}' 找到 {len(searched_places)} 个结果") break # 如果 Fallback 也失败,扩大搜索半径到不限制(API最大50km) if not searched_places: logger.info("Fallback 类别无结果,尝试不限距离搜索") searched_places = await self._search_pois( f"{center_point[0]},{center_point[1]}", "餐厅", radius=50000, types="" ) if searched_places: fallback_used = True fallback_keyword = "餐厅(扩大范围)" logger.info(f"扩大范围搜索成功:找到 {len(searched_places)} 个结果") # 如果所有尝试都失败,返回错误(极端情况) if not searched_places: center_lng, center_lat = center_point error_msg = f"在该区域未能找到任何推荐场所。\n\n" error_msg += f"搜索中心点:({center_lng:.4f}, {center_lat:.4f})\n" error_msg += "该区域可能较为偏远,建议选择更靠近市中心的地点。" return ToolResult(output=error_msg) recommended_places = self._rank_places( searched_places, center_point, user_requirements, keywords, min_rating=min_rating, max_distance=max_distance, price_range=price_range ) html_path = await self._generate_html_page( location_info, recommended_places, center_point, user_requirements, keywords, theme, fallback_used, fallback_keyword ) result_text = self._format_result_text( location_info, recommended_places, html_path, keywords, fallback_used, fallback_keyword ) return ToolResult(output=result_text) except Exception as e: logger.exception(f"场所推荐过程中发生错误: {str(e)}") return ToolResult(output=f"推荐失败: {str(e)}") async def execute_for_miniprogram( self, locations: List[Dict[str, Any]], keywords: str = "咖啡馆", user_requirements: str = "", min_rating: float = 0.0, max_distance: int = 100000, price_range: str = "", ) -> Dict[str, Any]: """为小程序提供的纯数据推荐接口 Args: locations: 包含坐标的地点列表 [{'lng': 116.1, 'lat': 39.1, 'address': '...'}, ...] keywords: 搜索关键词 user_requirements: 用户需求 Returns: Dict: 包含中心点、推荐列表的纯JSON数据 """ # 尝试配置API Key if not self.api_key: if hasattr(config, "amap") and config.amap and hasattr(config.amap, "api_key"): self.api_key = config.amap.api_key elif not self.api_key: import os self.api_key = os.getenv("AMAP_API_KEY", "") if not self.api_key: return {"success": False, "error": "高德地图API密钥未配置"} try: # 1. 提取坐标 coordinates = [] location_info = [] for loc in locations: lng = float(loc.get("lng", 0)) lat = float(loc.get("lat", 0)) coordinates.append((lng, lat)) location_info.append({ "name": loc.get("name", ""), "address": loc.get("address", ""), "lng": lng, "lat": lat }) if len(coordinates) < 1: return {"success": False, "error": "至少需要一个有效坐标"} # 2. 计算中心点 center_point = self._calculate_center_point(coordinates) center_lng, center_lat = center_point # 3. 搜索 POI searched_places = await self._search_pois( f"{center_lng},{center_lat}", keywords, radius=5000, types="" ) # Fallback 机制 fallback_used = False fallback_keyword = None if not searched_places: # 尝试 Fallback 搜索 fallback_categories = ["餐厅", "咖啡馆", "商场", "美食"] for fallback_kw in fallback_categories: if fallback_kw != keywords: searched_places = await self._search_pois( f"{center_lng},{center_lat}", fallback_kw, radius=5000, types="" ) if searched_places: fallback_used = True fallback_keyword = fallback_kw break if not searched_places: # 扩大范围 searched_places = await self._search_pois( f"{center_lng},{center_lat}", "餐厅", radius=50000, types="" ) if searched_places: fallback_used = True fallback_keyword = "餐厅(扩大范围)" if not searched_places: return { "success": False, "error": "未能找到任何推荐场所,请尝试更换中心点或关键词", "center": {"lng": center_lng, "lat": center_lat} } # 4. 排序 recommended_places = self._rank_places( searched_places, center_point, user_requirements, keywords, min_rating=min_rating, max_distance=max_distance, price_range=price_range ) # 取前20个返回 final_recommendations = [] for place in recommended_places[:20]: final_recommendations.append({ "id": place.get("id", ""), "name": place.get("name", ""), "location": { "lng": float(place.get("location", "0,0").split(",")[0]), "lat": float(place.get("location", "0,0").split(",")[1]) }, "address": place.get("address", ""), "distance": place.get("_distance", 0), "score": place.get("_final_score", 0), "rating": place.get("biz_ext", {}).get("rating", "N/A"), "tags": place.get("_match_reasons", []), "reason": place.get("_recommendation_reason", ""), "images": place.get("photos", []) }) return { "success": True, "center": { "lng": center_lng, "lat": center_lat, "address": f"基于{len(coordinates)}个位置的中心" }, "recommendations": final_recommendations, "fallback_used": fallback_used, "fallback_keyword": fallback_keyword } except Exception as e: logger.exception(f"小程序推荐接口异常: {str(e)}") return {"success": False, "error": f"服务器内部错误: {str(e)}"} def _enhance_address(self, address: str) -> str: """对输入地址做轻量增强,减少歧义。 这里只做“简称/别名 -> 更完整的查询词”转换。 主解析逻辑应在 `_geocode` / `_smart_city_inference` 中完成。 """ if not address: return address normalized = address.strip() alias_to_fullname: Dict[str, str] = { # 常见高校简称 "北大": "北京市海淀区北京大学", "清华": "北京市海淀区清华大学", "人大": "北京市海淀区中国人民大学", "北师大": "北京市海淀区北京师范大学", "复旦": "上海市杨浦区复旦大学", "上交": "上海市闵行区上海交通大学", "浙大": "浙江省杭州市浙江大学", "中大": "广东省广州市中山大学", "华工": "广东省广州市华南理工大学", "华科": "湖北省武汉市华中科技大学", } mapped = alias_to_fullname.get(normalized) if mapped: logger.info(f"地址别名映射: '{normalized}' -> '{mapped}'") return mapped return normalized def _extract_city_hint(self, locations: List[str]) -> str: """从输入地点中抽取城市提示(用于 citylimit)。""" city_keywords = [ "北京", "上海", "广州", "深圳", "杭州", "南京", "武汉", "成都", "西安", "天津", "重庆", "苏州", "长沙", "郑州", "济南", "青岛", "大连", "厦门", "福州", "昆明", ] votes: Dict[str, int] = {} for loc in locations: if not loc: continue full_loc = self._enhance_address(loc) for city in city_keywords: if city in loc or city in full_loc: votes[city] = votes.get(city, 0) + 1 if not votes: return "" best_city = max(votes, key=votes.get) logger.info(f"城市提示投票: {votes} -> '{best_city}'") return best_city async def _geocode_via_poi(self, address: str, city_hint: str = "") -> Optional[Dict[str, Any]]: """使用 AMap POI 文本检索优先解析地点。""" keyword = self._enhance_address(address) if not keyword: return None url = "https://restapi.amap.com/v3/place/text" params: Dict[str, Any] = { "key": self.api_key, "keywords": keyword, "offset": 5, "extensions": "base", } if city_hint: params["city"] = city_hint params["citylimit"] = "true" try: async with aiohttp.ClientSession() as session: async with session.get(url, params=params) as response: if response.status != 200: return None data = await response.json() if data.get("info") == "CUQPS_HAS_EXCEEDED_THE_LIMIT": return None if data.get("status") != "1" or not data.get("pois"): return None poi = self._select_best_poi(data["pois"], keyword, city_hint) if not poi: return None return { "location": poi.get("location", ""), "formatted_address": (poi.get("address") or "") or poi.get("name", address), "city": poi.get("cityname", ""), "province": poi.get("pname", ""), "district": poi.get("adname", ""), "name": poi.get("name", address), "_source": "poi", } except Exception: return None def _select_best_poi(self, pois: List[Dict], keyword: str, city_hint: str) -> Optional[Dict]: if not pois: return None keyword_lower = keyword.lower() for poi in pois: if poi.get("name", "").lower() == keyword_lower: return poi if city_hint: for poi in pois: if keyword_lower in poi.get("name", "").lower() and city_hint in poi.get("cityname", ""): return poi for poi in pois: if keyword_lower in poi.get("name", "").lower(): return poi return pois[0] def _get_address_suggestions(self, address: str) -> str: """根据输入的地址提供智能建议""" suggestions = [] # 检查是否包含常见的模糊词汇 vague_terms = { "大学": "**请输入完整大学名称**,如 '北京大学'、'清华大学'、'复旦大学'", "学校": "**请输入具体学校全名**,如 '北京市第一中学'、'上海交通大学附属中学'", "医院": "**请输入完整医院名称**,如 '北京协和医院'、'上海华山医院'", "商场": "**请输入具体商场名称**,如 '王府井百货大楼'、'上海环球港'", "火车站": "**请输入完整站名**,如 '北京站'、'上海虹桥站'、'广州南站'", "机场": "**请输入完整机场名称**,如 '北京首都国际机场'、'上海浦东国际机场'", "公园": "**请输入具体公园名称**,如 '颐和园'、'中山公园'、'西湖公园'", "广场": "**请输入具体广场名称**,如 '天安门广场'、'人民广场'", "地铁站": "**请输入完整地铁站名**,如 '中关村地铁站'、'人民广场地铁站'", "购物中心": "**请输入具体购物中心名称**,如 '北京apm'、'上海iapm'" } for term, suggestion in vague_terms.items(): if term in address: suggestions.append(f"• {suggestion}") # 检查是否只是城市名 major_cities = ["北京", "上海", "广州", "深圳", "杭州", "南京", "武汉", "成都", "西安", "天津"] if address in major_cities: suggestions.append(f"• **城市名过于宽泛**,请添加具体区域,如 '{address}市海淀区中关村'") suggestions.append(f"• **或使用知名地标**,如 '{address}大学'、'{address}火车站'、'{address}机场'") suggestions.append(f"• **推荐格式**:'{address}市 + 区县 + 街道/地标',如 '{address}市朝阳区三里屯'") # 检查长度 if len(address) <= 2: suggestions.append("• **地址过于简短**,请提供更详细的信息") suggestions.append("• **标准格式**:'省市 + 区县 + 具体地点',如 '北京市海淀区中关村大街'") suggestions.append("• **或使用完整地标名**:如 '北京大学'、'天安门广场'、'上海外滩'") elif len(address) <= 4: suggestions.append("• **地址信息不够具体**,建议添加更多细节") suggestions.append("• **如果是地标**:请使用完整名称,如 '北京大学' 而非 '北大'") suggestions.append("• **如果是地址**:请添加区县信息,如 '海淀区' + 您的地址") # 通用建议 if not suggestions: suggestions.extend([ "• **请输入具体地址**:如 '北京市海淀区中关村大街1号'", "• **使用知名地标**:如 '北京大学'、'天安门广场'、'上海外滩'", "• **添加省市区信息**:如 '北京市朝阳区三里屯'", "• **使用完整建筑名**:如 '王府井百货大楼'、'北京协和医院'", "• **检查拼写准确性**:确保地名无错别字", "• **尝试官方全称**:避免使用简称或昵称" ]) else: # 如果有特定建议,添加通用的具体地址要求 suggestions.insert(0, "• **请输入更具体的地址信息**") # 添加多地点输入说明 suggestions.append("") suggestions.append("📝 **多地点输入提示:**") suggestions.append("• 可在一个输入框中用空格分隔多个地点,如 '北京大学 中关村'") suggestions.append("• 或在不同输入框中分别填写每个地点") suggestions.append("• 完整地址(含'市'、'区'、'县')不会被自动拆分") return "\n".join(suggestions) async def _geocode(self, address: str) -> Optional[Dict[str, Any]]: if address in self.geocode_cache: return self.geocode_cache[address] # 确保API密钥已设置 if not self.api_key: if hasattr(config, "amap") and config.amap and hasattr(config.amap, "api_key"): self.api_key = config.amap.api_key else: logger.error("高德地图API密钥未配置") return None # 先尝试 POI 文本检索,降低同名跨城误解析 poi_city_hint = "" poi_result = await self._geocode_via_poi(address, city_hint=poi_city_hint) if poi_result and poi_result.get("location"): if len(self.geocode_cache) >= self.GEOCODE_CACHE_MAX: oldest_key = next(iter(self.geocode_cache)) del self.geocode_cache[oldest_key] self.geocode_cache[address] = poi_result return poi_result # POI 不可用时回退到 Geocode enhanced_address = self._enhance_address(address) url = "https://restapi.amap.com/v3/geocode/geo" params = {"key": self.api_key, "address": enhanced_address, "output": "json"} # 重试机制,最多重试3次(优化延迟以提升性能) max_retries = 3 for attempt in range(max_retries): try: # 首次请求无延迟,重试时添加较短延迟 if attempt > 0: await asyncio.sleep(0.2 * attempt) # 200ms递增延迟(优化:原为1s) async with aiohttp.ClientSession() as session: async with session.get(url, params=params) as response: if response.status != 200: logger.error( f"高德地图API地理编码请求失败: {response.status}, 地址: {address}, 尝试: {attempt + 1}" ) if attempt == max_retries - 1: return None continue data = await response.json() # 检查API限制错误 if data.get("info") == "CUQPS_HAS_EXCEEDED_THE_LIMIT": logger.warning(f"API并发限制超出,地址: {address}, 尝试: {attempt + 1}, 等待后重试") if attempt == max_retries - 1: logger.error(f"地理编码失败: API并发限制超出,地址: {address}") return None await asyncio.sleep(0.5 * (attempt + 1)) # 500ms延迟(优化:原为2s) continue if data["status"] != "1" or not data["geocodes"]: logger.error(f"地理编码失败: {data.get('info', '未知错误')}, 地址: {address}") return None result = data["geocodes"][0] # 缓存大小限制:超限时删除最旧的条目 if len(self.geocode_cache) >= self.GEOCODE_CACHE_MAX: oldest_key = next(iter(self.geocode_cache)) del self.geocode_cache[oldest_key] self.geocode_cache[address] = result return result except Exception as e: logger.error(f"地理编码请求异常: {str(e)}, 地址: {address}, 尝试: {attempt + 1}") if attempt == max_retries - 1: return None await asyncio.sleep(0.2 * (attempt + 1)) # 200ms递增延迟(优化:原为1s) return None async def _smart_city_inference( self, original_locations: List[str], geocode_results: List[Dict], city_hint: str = "" ) -> List[Dict]: """智能城市推断:检测并修正被解析到错误城市的地点 当用户输入简短地名(如"国贸")时,高德API可能将其解析到全国任何同名地点。 此方法检测这种情况,并尝试用其他地点的城市信息重新解析。 """ if len(geocode_results) < 2: return geocode_results # 提取所有地点的城市和坐标 cities = [] coords = [] for item in geocode_results: result = item["result"] city = result.get("city", "") or result.get("province", "") cities.append(city) lng, lat = result["location"].split(",") coords.append((float(lng), float(lat))) # 如果输入本身是跨城(例如:北京 + 广州),不要强行拉到同一城市。 # 只在“同城为主、少数点明显跑偏”的场景做纠正。 from collections import Counter city_counts = Counter(cities) if not city_counts: return geocode_results # 明确给出的城市提示代表用户意图,出现跨城时直接跳过纠正。 if city_hint and sum(1 for c in cities if city_hint in c) < len(cities): return geocode_results # 若城市分布很分散(例如 1:1 或 1:1:1),无法可靠判断“主城市”,直接跳过纠正。 most_common = city_counts.most_common(2) if len(most_common) == 1: main_city, main_count = most_common[0] else: (main_city, main_count), (_, second_count) = most_common if main_count == second_count: return geocode_results # 如果所有地点都在同一城市,无需修正 if main_count == len(cities): return geocode_results # 主城市占比过低(< 60%)时,不做纠正,避免跨城输入被误拉同城 if main_count / len(cities) < 0.6: return geocode_results # 当地点数量较少时,如果更像是跨城输入,直接跳过纠正。 # 典型情况:两地相距很远(例如北京 + 广州),不应强行拉同城。 if len(cities) <= 2: if len(coords) == 2 and self._calculate_distance(coords[0], coords[1]) > 300000: return geocode_results # 允许纠正的前提:城市提示(如果有)必须与主城市一致 if city_hint and city_hint not in main_city: return geocode_results # 检测异常地点:距离其他地点过远(超过500公里) updated_results = [] for i, item in enumerate(geocode_results): result = item["result"] location = item["original_location"] current_city = cities[i] # 计算与其他地点的平均距离 if len(coords) > 1: other_coords = [c for j, c in enumerate(coords) if j != i] avg_distance = sum( self._calculate_distance(coords[i], c) for c in other_coords ) / len(other_coords) # 如果当前地点距离其他地点平均超过100公里,且城市不同,尝试重新解析 if avg_distance > 100000 and current_city != main_city: # 100km = 100000m logger.warning( f"检测到地点 '{location}' 被解析到远离其他地点的城市 " f"({current_city}),尝试用 {main_city} 重新解析" ) # 尝试用主流城市名作为前缀重新解析 new_address = f"{main_city}{location}" new_result = await self._geocode(new_address) if new_result: new_lng, new_lat = new_result["location"].split(",") new_coord = (float(new_lng), float(new_lat)) # 检查新结果是否更合理(距离其他地点更近) new_avg_distance = sum( self._calculate_distance(new_coord, c) for c in other_coords ) / len(other_coords) if new_avg_distance < avg_distance: logger.info( f"成功将 '{location}' 重新解析为 {new_result.get('formatted_address')}" ) updated_results.append({ "original_location": location, "result": new_result }) continue updated_results.append(item) return updated_results def _calculate_center_point(self, coordinates: List[Tuple[float, float]]) -> Tuple[float, float]: """计算多个坐标点的中心点(使用球面几何)""" if not coordinates: raise ValueError("至少需要一个坐标来计算中心点。") if len(coordinates) == 1: return coordinates[0] # 对于两个点,使用球面中点计算 if len(coordinates) == 2: lat1, lng1 = math.radians(coordinates[0][1]), math.radians(coordinates[0][0]) lat2, lng2 = math.radians(coordinates[1][1]), math.radians(coordinates[1][0]) dLng = lng2 - lng1 Bx = math.cos(lat2) * math.cos(dLng) By = math.cos(lat2) * math.sin(dLng) lat3 = math.atan2(math.sin(lat1) + math.sin(lat2), math.sqrt((math.cos(lat1) + Bx) * (math.cos(lat1) + Bx) + By * By)) lng3 = lng1 + math.atan2(By, math.cos(lat1) + Bx) return (math.degrees(lng3), math.degrees(lat3)) # 对于多个点,使用简单平均(可以进一步优化) avg_lng = sum(lng for lng, _ in coordinates) / len(coordinates) avg_lat = sum(lat for _, lat in coordinates) / len(coordinates) return (avg_lng, avg_lat) async def _calculate_smart_center( self, coordinates: List[Tuple[float, float]], keywords: str = "咖啡馆" ) -> Tuple[Tuple[float, float], Dict]: """智能中心点算法 - 考虑 POI 密度、交通便利性和公平性 算法步骤: 1. 计算几何中心作为基准点 2. 在基准点周围生成候选点网格 3. 评估每个候选点:POI 密度 + 交通便利性 + 公平性 4. 返回最优中心点 Returns: (最优中心点坐标, 评估详情) """ logger.info("使用智能中心点算法") # 1. 计算几何中心 geo_center = self._calculate_center_point(coordinates) logger.info(f"几何中心: {geo_center}") # 2. 生成候选点网格(在几何中心周围 1.5km 范围内) candidates = self._generate_candidate_points(geo_center, radius_km=1.5, grid_size=3) candidates.insert(0, geo_center) # 几何中心作为第一个候选 logger.info(f"生成了 {len(candidates)} 个候选中心点") # 3. 评估每个候选点 best_candidate = geo_center best_score = -1 evaluation_results = [] for candidate in candidates: score, details = await self._evaluate_center_candidate( candidate, coordinates, keywords ) evaluation_results.append({ "point": candidate, "score": score, "details": details }) if score > best_score: best_score = score best_candidate = candidate # 排序结果 evaluation_results.sort(key=lambda x: x["score"], reverse=True) logger.info(f"最优中心点: {best_candidate}, 评分: {best_score:.1f}") return best_candidate, { "geo_center": geo_center, "best_candidate": best_candidate, "best_score": best_score, "all_candidates": evaluation_results[:5] # 返回前5个 } def _generate_candidate_points( self, center: Tuple[float, float], radius_km: float = 1.5, grid_size: int = 3 ) -> List[Tuple[float, float]]: """在中心点周围生成候选点网格 Args: center: 中心点坐标 (lng, lat) radius_km: 搜索半径(公里) grid_size: 网格大小(每边的点数,不含中心) """ candidates = [] lng, lat = center # 经纬度偏移量(粗略计算) # 纬度1度 ≈ 111km,经度1度 ≈ 111km * cos(lat) lat_offset = radius_km / 111.0 lng_offset = radius_km / (111.0 * math.cos(math.radians(lat))) step_lat = lat_offset / grid_size step_lng = lng_offset / grid_size for i in range(-grid_size, grid_size + 1): for j in range(-grid_size, grid_size + 1): if i == 0 and j == 0: continue # 跳过中心点 new_lng = lng + j * step_lng new_lat = lat + i * step_lat candidates.append((new_lng, new_lat)) return candidates async def _evaluate_center_candidate( self, candidate: Tuple[float, float], participant_coords: List[Tuple[float, float]], keywords: str ) -> Tuple[float, Dict]: """评估候选中心点的质量 评分维度(满分100): - POI 密度: 40分 - 周边是否有足够的目标场所 - 交通便利性: 30分 - 是否靠近地铁站/公交站 - 公平性: 30分 - 对所有参与者是否公平(最小化最大距离) """ lng, lat = candidate location_str = f"{lng},{lat}" scores = { "poi_density": 0, "transit": 0, "fairness": 0 } details = {} # 1. POI 密度评分(40分) try: # 搜索目标场所 pois = await self._search_pois( location=location_str, keywords=keywords, radius=1500, offset=10 ) poi_count = len(pois) # 评分:0个=0分,5个=20分,10个=40分 scores["poi_density"] = min(40, poi_count * 4) details["poi_count"] = poi_count except Exception as e: logger.debug(f"POI 搜索失败: {e}") scores["poi_density"] = 10 # 给个基础分 # 2. 交通便利性评分(30分) try: # 搜索地铁站 transit_pois = await self._search_pois( location=location_str, keywords="地铁站", radius=1000, offset=5 ) transit_count = len(transit_pois) # 有地铁站得高分 if transit_count >= 2: scores["transit"] = 30 elif transit_count == 1: scores["transit"] = 20 else: # 搜索公交站 bus_pois = await self._search_pois( location=location_str, keywords="公交站", radius=500, offset=5 ) scores["transit"] = min(15, len(bus_pois) * 5) details["transit_count"] = transit_count except Exception as e: logger.debug(f"交通搜索失败: {e}") scores["transit"] = 10 # 3. 公平性评分(30分) distances = [] for coord in participant_coords: dist = self._calculate_distance(candidate, coord) distances.append(dist) max_distance = max(distances) if distances else 0 avg_distance = sum(distances) / len(distances) if distances else 0 # 最大距离越小越好,基于 3km 作为基准 # max_dist <= 1km: 30分, 2km: 20分, 3km: 10分, >3km: 5分 if max_distance <= 1000: scores["fairness"] = 30 elif max_distance <= 2000: scores["fairness"] = 25 - (max_distance - 1000) / 200 elif max_distance <= 3000: scores["fairness"] = 15 - (max_distance - 2000) / 200 else: scores["fairness"] = max(5, 10 - (max_distance - 3000) / 500) details["max_distance"] = max_distance details["avg_distance"] = avg_distance details["distances"] = distances total_score = sum(scores.values()) details["scores"] = scores return total_score, details async def _search_pois( self, location: str, keywords: str, radius: int = 2000, types: str = "", offset: int = 20 ) -> List[Dict]: cache_key = f"{location}_{keywords}_{radius}_{types}" if cache_key in self.poi_cache: return self.poi_cache[cache_key] url = "https://restapi.amap.com/v3/place/around" params = { "key": self.api_key, "location": location, "keywords": keywords, "radius": radius, "offset": offset, "page": 1, "extensions": "all" } if types: params["types"] = types async with aiohttp.ClientSession() as session: async with session.get(url, params=params) as response: if response.status != 200: logger.error(f"高德地图POI搜索失败: {response.status}, 参数: {params}") return [] data = await response.json() if data["status"] != "1": logger.error(f"POI搜索API返回错误: {data.get('info', '未知错误')}, 参数: {params}") return [] pois = data.get("pois", []) # 缓存大小限制:超限时删除最旧的条目 if len(self.poi_cache) >= self.POI_CACHE_MAX: oldest_key = next(iter(self.poi_cache)) del self.poi_cache[oldest_key] self.poi_cache[cache_key] = pois return pois # ========== V2 多维度评分系统 ========== def _calculate_base_score(self, place: Dict) -> Tuple[float, float]: """计算基础评分 (满分30分) Returns: (score, raw_rating): 评分和原始rating值 """ biz_ext = place.get("biz_ext", {}) or {} rating_str = biz_ext.get("rating", "0") or "0" try: rating = float(rating_str) except (ValueError, TypeError): rating = 0 # 无评分场所使用默认3.5分 if rating == 0: rating = 3.5 place["_has_rating"] = False else: place["_has_rating"] = True # 评分归一化到30分 (rating范围1-5) score = min(rating, 5) * 6 return score, rating def _calculate_popularity_score(self, place: Dict) -> Tuple[float, int, int]: """计算热度分 (满分20分) 基于评论数和图片数 Returns: (score, review_count, photo_count): 热度分和原始数据 """ biz_ext = place.get("biz_ext", {}) or {} # 评论数 review_count_str = biz_ext.get("review_count", "0") or "0" try: review_count = int(review_count_str) except (ValueError, TypeError): review_count = 0 # 图片数 (高德API的photos字段) photos = place.get("photos", []) or [] photo_count = len(photos) if isinstance(photos, list) else 0 # 对数计算避免大数压倒一切 # log10(100) = 2, log10(1000) = 3 review_score = math.log10(review_count + 1) * 5 if review_count > 0 else 0 photo_score = min(photo_count * 2, 6) # 最多3张图贡献6分 score = min(20, review_score + photo_score) return score, review_count, photo_count def _calculate_distance_score_v2( self, place: Dict, center_point: Tuple[float, float] ) -> Tuple[float, float]: """计算距离分 (满分25分) - 非线性衰减 Returns: (score, distance): 距离分和实际距离(米) """ location = place.get("location", "") if not location or "," not in location: return 0, float('inf') try: lng_str, lat_str = location.split(",") place_lng, place_lat = float(lng_str), float(lat_str) except (ValueError, TypeError): return 0, float('inf') distance = self._calculate_distance(center_point, (place_lng, place_lat)) place["_distance"] = distance # 非线性衰减:500米内满分,之后快速衰减 # 使用1.5次幂衰减曲线 if distance <= 500: score = 25 elif distance <= 2500: # (1 - (distance/2500)^1.5) * 25 ratio = (distance - 500) / 2000 # 归一化到0-1 decay = ratio ** 1.5 score = 25 * (1 - decay * 0.8) # 最低保留20% else: score = 5 # 超远距离给最低分 return score, distance def _calculate_scenario_match_score( self, place: Dict, keywords: str ) -> Tuple[float, str]: """计算场景匹配分 (满分15分) Returns: (score, matched_keyword): 场景分和匹配的关键词 """ source_keyword = place.get('_source_keyword', '') if source_keyword and source_keyword in keywords: return 15, source_keyword # 部分匹配:检查type字段 place_type = place.get("type", "") keywords_list = keywords.replace("、", " ").split() for kw in keywords_list: if kw in place_type: return 8, kw return 0, "" def _calculate_requirement_score( self, place: Dict, user_requirements: str ) -> Tuple[float, List[str], Dict[str, str]]: """计算需求匹配分 (满分10分) - 三层匹配算法 三层匹配机制: - Layer 1: POI标签硬匹配 (高置信度 high, +4分) - Layer 2: 品牌特征匹配 (中置信度 medium, +2分) - Layer 3: 类型推断匹配 (低置信度 low, +1分) Returns: (score, matched_requirements, confidence_map): 需求分、匹配的需求列表、置信度字典 """ if not user_requirements: return 0, [], {} # 需求规范化映射(将各种表达方式统一) requirement_aliases = { "停车": ["停车", "车位", "停车场", "免费停车", "方便停车", "停车方便"], "安静": ["安静", "环境好", "氛围", "静", "舒适", "环境安静"], "商务": ["商务", "会议", "办公", "谈事", "工作"], "交通": ["交通", "地铁", "公交", "方便", "交通便利"], "包间": ["包间", "私密", "独立", "包厢", "有包间"], "WiFi": ["wifi", "无线", "网络", "上网", "免费wifi"], "可以久坐": ["久坐", "可以久坐", "坐着办公", "长时间"], "适合儿童": ["儿童", "带娃", "亲子", "小孩", "适合儿童"], "24小时营业": ["24小时", "通宵", "夜间", "凌晨"], } # POI标签匹配规则(Layer 1) poi_match_rules = { "停车": { "check_fields": ["tag", "parking_type", "navi_poiid"], "match_values": ["停车", "车位", "免费停车", "parking"] }, "安静": { "check_fields": ["tag"], "match_values": ["安静", "环境", "氛围", "舒适", "优雅"] }, "商务": { "check_fields": ["tag", "type"], "match_values": ["商务", "会议", "办公", "商务区"] }, "交通": { "check_fields": ["tag", "address"], "match_values": ["地铁", "公交", "站", "枢纽"] }, "包间": { "check_fields": ["tag"], "match_values": ["包间", "包厢", "私密", "独立房间"] }, "WiFi": { "check_fields": ["tag"], "match_values": ["wifi", "无线", "免费WiFi", "网络"] }, } # 识别用户需求 user_reqs = set() user_requirements_lower = user_requirements.lower() for req_name, aliases in requirement_aliases.items(): for alias in aliases: if alias.lower() in user_requirements_lower: user_reqs.add(req_name) break if not user_reqs: return 0, [], {} matched = [] confidence_map = {} # 需求 -> 置信度 (high/medium/low) total_score = 0 place_name = place.get("name", "") place_type = place.get("type", "") # ========== Layer 1: POI标签硬匹配(高置信度)========== for req_name in user_reqs: if req_name in matched: continue if req_name not in poi_match_rules: continue rule = poi_match_rules[req_name] for field in rule["check_fields"]: field_value = str(place.get(field, "")).lower() if any(mv.lower() in field_value for mv in rule["match_values"]): matched.append(req_name) confidence_map[req_name] = "high" total_score += 4 # 高置信度 +4分 break # ========== Layer 2: 品牌特征匹配(中置信度)========== for brand, features in self.BRAND_FEATURES.items(): if brand.startswith("_"): continue # 跳过类型默认值 if brand in place_name: for req_name in user_reqs: if req_name in matched: continue score = features.get(req_name, 0) if score >= 0.7: # 0.7以上视为满足 matched.append(req_name) confidence_map[req_name] = "medium" total_score += 2 # 中置信度 +2分 break # 只匹配第一个品牌 # ========== Layer 3: 类型推断匹配(低置信度)========== for type_key, features in self.BRAND_FEATURES.items(): if not type_key.startswith("_"): continue # 只处理类型默认值 type_name = type_key[1:] # 去掉下划线前缀 if type_name in place_type or type_name in place_name: for req_name in user_reqs: if req_name in matched: continue score = features.get(req_name, 0) if score >= 0.8: # 类型推断需要更高阈值 matched.append(req_name) confidence_map[req_name] = "low" total_score += 1 # 低置信度 +1分 break # 只匹配第一个类型 return min(10, total_score), matched, confidence_map def _apply_diversity_adjustment( self, places: List[Dict] ) -> List[Dict]: """应用多样性调整 - 同名连锁店惩罚 - 确保价格区间多样性 """ # 统计店名出现次数 name_counts = {} for place in places: name = place.get("name", "") # 提取品牌名(去掉括号内容和分店信息) brand_name = name.split("(")[0].split("(")[0] brand_name = brand_name.replace("店", "").replace("分店", "") name_counts[brand_name] = name_counts.get(brand_name, 0) + 1 # 应用惩罚 seen_brands = {} for place in places: name = place.get("name", "") brand_name = name.split("(")[0].split("(")[0].replace("店", "").replace("分店", "") if name_counts.get(brand_name, 0) > 1: seen_count = seen_brands.get(brand_name, 0) if seen_count > 0: # 第二家及以后的同品牌店铺扣分 penalty = min(15, seen_count * 5) place["_score"] = place.get("_score", 0) - penalty place["_diversity_penalty"] = penalty seen_brands[brand_name] = seen_count + 1 return places def _generate_recommendation_reason( self, place: Dict, all_places: List[Dict] ) -> str: """生成推荐理由 基于场所在各维度的表现生成个性化推荐理由 """ reasons = [] distance = place.get("_distance", float('inf')) rating = place.get("_raw_rating", 0) review_count = place.get("_review_count", 0) matched_reqs = place.get("_matched_requirements", []) scenario = place.get("_matched_scenario", "") # 距离优势 if distance < 500: reasons.append(f"距离最近,仅{int(distance)}米") elif distance < 800: reasons.append(f"位置便利,约{int(distance)}米") # 评分优势 if rating >= 4.5 and place.get("_has_rating"): reasons.append(f"口碑极佳,评分{rating}") elif rating >= 4.0 and place.get("_has_rating"): reasons.append(f"评价良好,{rating}分") # 热度优势 if review_count >= 500: reasons.append(f"人气火爆,{review_count}条评价") elif review_count >= 100: reasons.append(f"热门推荐,{review_count}人评价") # 需求匹配 if matched_reqs: req_text = "、".join(matched_reqs[:2]) reasons.append(f"满足{req_text}需求") # 场景匹配 if scenario: reasons.append(f"符合{scenario}场景") # 如果没有明显优势,给一个通用理由 if not reasons: if distance < 1500: reasons.append("位置适中,综合评价不错") else: reasons.append("特色场所,值得一试") # 最多返回2个理由 return ";".join(reasons[:2]) async def _llm_smart_ranking( self, places: List[Dict], user_requirements: str, participant_locations: List[str], keywords: str, top_n: int = 8 ) -> List[Dict]: """LLM 智能评分重排序 使用 LLM 对候选场所进行智能评分和重排序,考虑: - 用户需求的语义理解 - 场所特点与需求的匹配度 - 对各参与者的公平性 - 场所的综合吸引力 Args: places: 候选场所列表(已经过初步筛选) user_requirements: 用户需求文本 participant_locations: 参与者位置列表 keywords: 搜索关键词 top_n: 返回的推荐数量 Returns: 重排序后的场所列表 """ llm = _get_llm() if not llm or len(places) == 0: logger.info("LLM 不可用或无候选场所,跳过智能排序") return places[:top_n] # 准备场所摘要信息 places_summary = [] for i, place in enumerate(places[:15]): # 最多分析15个 summary = { "id": i, "name": place.get("name", ""), "type": place.get("type", ""), "rating": place.get("_raw_rating", 0), "review_count": place.get("_review_count", 0), "distance": round(place.get("_distance", 0)), "address": place.get("address", ""), "rule_score": round(place.get("_score", 0), 1), "features": place.get("tag", "")[:100] if place.get("tag") else "" } places_summary.append(summary) # 构建 LLM 评分 prompt prompt = f"""你是一个智能会面地点推荐助手。请对以下候选场所进行评分和排序。 ## 会面信息 - **参与者位置**: {', '.join(participant_locations)} - **寻找的场所类型**: {keywords} - **用户特殊需求**: {user_requirements or '无特殊要求'} ## 候选场所 {json.dumps(places_summary, ensure_ascii=False, indent=2)} ## 评分要求 请综合考虑以下因素: 1. **需求匹配度** (30%): 场所是否满足用户的特殊需求 2. **位置公平性** (25%): 对所有参与者是否方便(距离是否均衡) 3. **场所品质** (25%): 评分、评论数等指标 4. **特色吸引力** (20%): 场所的独特卖点 ## 输出格式 请直接返回 JSON 数组,包含你推荐的场所ID(按推荐度从高到低排序),以及每个场所的推荐理由: ```json [ {{"id": 0, "llm_score": 85, "reason": "距离适中,环境安静,非常适合商务会谈"}}, {{"id": 2, "llm_score": 78, "reason": "评分高,位置对双方都比较公平"}} ] ``` 只返回 JSON,不要其他内容。""" try: from app.schema import Message response = await llm.ask( messages=[Message.user_message(prompt)], system_msgs=[Message.system_message("你是一个专业的地点推荐助手,请直接返回 JSON 格式的评分结果。")] ) if not response or not response.content: logger.warning("LLM 返回空响应") return places[:top_n] # 解析 LLM 返回的 JSON content = response.content.strip() # 提取 JSON 部分 if "```json" in content: content = content.split("```json")[1].split("```")[0].strip() elif "```" in content: content = content.split("```")[1].split("```")[0].strip() llm_rankings = json.loads(content) # 应用 LLM 评分 id_to_llm_result = {r["id"]: r for r in llm_rankings} for i, place in enumerate(places[:15]): if i in id_to_llm_result: llm_result = id_to_llm_result[i] place["_llm_score"] = llm_result.get("llm_score", 0) place["_llm_reason"] = llm_result.get("reason", "") # 综合得分 = 规则得分 * 0.4 + LLM 得分 * 0.6 place["_final_score"] = place.get("_score", 0) * 0.4 + place["_llm_score"] * 0.6 else: place["_llm_score"] = 0 place["_llm_reason"] = "" place["_final_score"] = place.get("_score", 0) * 0.4 # 按最终得分重排序 places_with_llm = [p for p in places[:15] if p.get("_llm_score", 0) > 0] places_without_llm = [p for p in places[:15] if p.get("_llm_score", 0) == 0] # LLM 评分的排前面 places_with_llm.sort(key=lambda x: x.get("_final_score", 0), reverse=True) places_without_llm.sort(key=lambda x: x.get("_score", 0), reverse=True) result = places_with_llm + places_without_llm logger.info(f"LLM 智能排序完成,返回 {len(result[:top_n])} 个推荐") return result[:top_n] except json.JSONDecodeError as e: logger.warning(f"LLM 返回的 JSON 解析失败: {e}") return places[:top_n] except Exception as e: logger.warning(f"LLM 智能排序失败: {e}") return places[:top_n] async def _llm_generate_transport_tips( self, places: List[Dict], center_point: Tuple[float, float], participant_locations: List[str], keywords: str ) -> str: """LLM 动态生成交通与停车建议 根据实际场所位置、参与者出发地和场所类型,生成个性化的交通建议。 Args: places: 推荐的场所列表 center_point: 中心点坐标 participant_locations: 参与者位置列表 keywords: 搜索关键词(用于判断场所类型) Returns: HTML 格式的交通停车建议 """ llm = _get_llm() if not llm: logger.info("LLM 不可用,使用默认交通建议") return self._generate_default_transport_tips(keywords) try: # 构建场所信息摘要 places_info = [] for i, place in enumerate(places[:5]): places_info.append({ "name": place.get("name", ""), "address": place.get("address", ""), "distance": place.get("_distance", 0), "type": place.get("type", "") }) prompt = f"""你是一个本地出行专家。根据以下信息,生成个性化的交通与停车建议。 **参与者出发地**: {chr(10).join([f"- {loc}" for loc in participant_locations])} **推荐场所**: {json.dumps(places_info, ensure_ascii=False, indent=2)} **中心点坐标**:{center_point[0]:.6f}, {center_point[1]:.6f} **场所类型**:{keywords} 请生成 4-5 条实用的交通与停车建议,要求: 1. 根据参与者的实际出发地,建议最佳交通方式(地铁、公交、打车、自驾) 2. 考虑场所周边的实际停车情况 3. 给出具体的时间规划建议 4. 如果是大学或商圈,提供特别提示 直接返回 JSON 数组,每条建议包含 icon 和 text 字段: ```json [ {{"icon": "bx-train", "text": "建议内容"}}, {{"icon": "bxs-car-garage", "text": "停车建议"}} ] ``` 可用图标:bx-train(地铁)、bx-bus(公交)、bx-taxi(打车)、bxs-car-garage(停车)、bx-time(时间)、bx-info-circle(提示) """ from app.schema import Message response = await llm.ask( messages=[Message.user_message(prompt)], system_msgs=[Message.system_message("你是一个本地出行专家,请直接返回 JSON 格式的交通建议。")], stream=False # 使用非流式调用,更可靠 ) if not response: logger.warning("LLM 返回空响应") return self._generate_default_transport_tips(keywords) # 非流式调用返回字符串,流式调用返回 Message 对象 content = response if isinstance(response, str) else response.content content = content.strip() # 解析 JSON if "```json" in content: content = content.split("```json")[1].split("```")[0].strip() elif "```" in content: content = content.split("```")[1].split("```")[0].strip() tips = json.loads(content) # 生成 HTML html_items = [] for tip in tips[:5]: icon = tip.get("icon", "bx-check") text = tip.get("text", "") html_items.append(f"
| 序号 | 地点名称 | 详细地址 |
|---|
最佳会面点位于{center_point[0]:.6f}, {center_point[1]:.6f}附近
成功解析 {len(locations)} 个地点坐标,准备计算最优会面点...
{location_analysis}" }) # Step 2: 智能中点计算 - 显示球面几何算法 center_lat, center_lng = center_point[1], center_point[0] algo_type = "球面几何中点算法" if len(locations) == 2 else "多点质心算法" search_steps.append({ "icon": "bx-math", "title": "Step 2: 智能中点计算", "content": f"""使用 {algo_type} 计算最优会面点:
从您的需求 "{user_requirements}" 中识别到:
未检测到特定需求关键词,将基于综合评分推荐最佳{cfg['noun_plural']}。
" else: requirement_analysis = f"未提供特殊需求,将使用多维度评分系统推荐{cfg['noun_plural']}。
" search_steps.append({"icon": "bx-brain", "title": "Step 3: 需求语义解析", "content": requirement_analysis}) # Step 4: 场所检索 search_places_explanation = f"""以最佳会面点为圆心,在 2公里 范围内检索 "{primary_keyword}" 相关场所...
使用 V2 多维度评分系统 对候选场所进行智能排序:
经过智能评分,为您推荐以下最佳会面地点:
{top_places_html}" }) else: search_steps.append({ "icon": "bx-trophy", "title": "Step 6: 推荐结果", "content": f"正在生成{cfg['noun_plural']}推荐结果...
" }) search_process_html = "" for idx, step in enumerate(search_steps): search_process_html += f"""