"""MeetSpot Agent 工具集 - 封装推荐系统的核心功能""" import json from typing import Any, Dict, List, Optional from pydantic import Field from app.tool.base import BaseTool, ToolResult from app.logger import logger class GeocodeTool(BaseTool): """地理编码工具 - 将地址转换为经纬度坐标""" name: str = "geocode" description: str = """将地址或地点名称转换为经纬度坐标。 支持各种地址格式: - 完整地址:'北京市海淀区中关村大街1号' - 大学简称:'北大'、'清华'、'复旦'(自动扩展为完整地址) - 知名地标:'天安门'、'外滩'、'广州塔' - 商圈区域:'三里屯'、'王府井' 返回地址的经纬度坐标和格式化地址。""" parameters: dict = { "type": "object", "properties": { "address": { "type": "string", "description": "地址或地点名称,如'北京大学'、'上海市浦东新区陆家嘴'" } }, "required": ["address"] } class Config: arbitrary_types_allowed = True def _get_recommender(self): """延迟加载推荐器,并确保 API key 已设置""" if not hasattr(self, '_cached_recommender'): from app.tool.meetspot_recommender import CafeRecommender from app.config import config recommender = CafeRecommender() # 确保 API key 已设置 if hasattr(config, 'amap') and config.amap and hasattr(config.amap, 'api_key'): recommender.api_key = config.amap.api_key object.__setattr__(self, '_cached_recommender', recommender) return self._cached_recommender async def execute(self, address: str) -> ToolResult: """执行地理编码""" try: recommender = self._get_recommender() result = await recommender._geocode(address) if result: location = result.get("location", "") lng, lat = location.split(",") if location else (None, None) return BaseTool.success_response({ "address": address, "formatted_address": result.get("formatted_address", ""), "location": location, "lng": float(lng) if lng else None, "lat": float(lat) if lat else None, "city": result.get("city", ""), "district": result.get("district", "") }) return BaseTool.fail_response(f"无法解析地址: {address}") except Exception as e: logger.error(f"地理编码失败: {e}") return BaseTool.fail_response(f"地理编码错误: {str(e)}") class CalculateCenterTool(BaseTool): """智能中心点工具 - 计算多个位置的最佳会面点 使用智能算法,综合考虑: - POI 密度:周边是否有足够的目标场所 - 交通便利性:是否靠近地铁站/公交站 - 公平性:对所有参与者的距离是否均衡 """ name: str = "calculate_center" description: str = """智能计算最佳会面中心点。 不同于简单的几何中心,本工具会: 1. 在几何中心周围生成多个候选点 2. 评估每个候选点的 POI 密度、交通便利性和公平性 3. 返回综合得分最高的点作为最佳会面位置 这样可以避免中心点落在河流、荒地等不适合的位置。""" parameters: dict = { "type": "object", "properties": { "coordinates": { "type": "array", "description": "坐标点列表,每个元素包含 lng(经度)、lat(纬度)和可选的 name(名称)", "items": { "type": "object", "properties": { "lng": {"type": "number", "description": "经度"}, "lat": {"type": "number", "description": "纬度"}, "name": {"type": "string", "description": "位置名称(可选)"} }, "required": ["lng", "lat"] } }, "keywords": { "type": "string", "description": "搜索的场所类型,如'咖啡馆'、'餐厅',用于评估 POI 密度", "default": "咖啡馆" }, "use_smart_algorithm": { "type": "boolean", "description": "是否使用智能算法(考虑 POI 密度和交通),默认 true", "default": True } }, "required": ["coordinates"] } class Config: arbitrary_types_allowed = True def _get_recommender(self): """延迟加载推荐器,并确保 API key 已设置""" if not hasattr(self, '_cached_recommender'): from app.tool.meetspot_recommender import CafeRecommender from app.config import config recommender = CafeRecommender() if hasattr(config, 'amap') and config.amap and hasattr(config.amap, 'api_key'): recommender.api_key = config.amap.api_key object.__setattr__(self, '_cached_recommender', recommender) return self._cached_recommender async def execute( self, coordinates: List[Dict], keywords: str = "咖啡馆", use_smart_algorithm: bool = True ) -> ToolResult: """计算最佳中心点""" try: if not coordinates or len(coordinates) < 2: return BaseTool.fail_response("至少需要2个坐标点来计算中心") recommender = self._get_recommender() # 转换为 (lng, lat) 元组列表 coord_tuples = [(c["lng"], c["lat"]) for c in coordinates] if use_smart_algorithm: # 使用智能中心点算法 center, evaluation_details = await recommender._calculate_smart_center( coord_tuples, keywords ) logger.info(f"智能中心点算法完成,最优中心: {center}") else: # 使用简单几何中心 center = recommender._calculate_center_point(coord_tuples) evaluation_details = {"algorithm": "geometric_center"} # 计算每个点到中心的距离 distances = [] for c in coordinates: dist = recommender._calculate_distance(center, (c["lng"], c["lat"])) distances.append({ "name": c.get("name", f"({c['lng']:.4f}, {c['lat']:.4f})"), "distance_to_center": round(dist, 0) }) max_dist = max(d["distance_to_center"] for d in distances) min_dist = min(d["distance_to_center"] for d in distances) result = { "center": { "lng": round(center[0], 6), "lat": round(center[1], 6) }, "algorithm": "smart" if use_smart_algorithm else "geometric", "input_count": len(coordinates), "distances": distances, "max_distance": max_dist, "fairness_score": round(100 - (max_dist - min_dist) / 100, 1) } # 添加智能算法的评估详情 if use_smart_algorithm and evaluation_details: result["evaluation"] = { "geo_center": evaluation_details.get("geo_center"), "best_score": evaluation_details.get("best_score"), "top_candidates": len(evaluation_details.get("all_candidates", [])) } return BaseTool.success_response(result) except Exception as e: logger.error(f"计算中心点失败: {e}") return BaseTool.fail_response(f"计算中心点错误: {str(e)}") class SearchPOITool(BaseTool): """搜索POI工具 - 在指定位置周围搜索场所""" name: str = "search_poi" description: str = """在指定中心点周围搜索各类场所(POI)。 支持搜索:咖啡馆、餐厅、图书馆、健身房、KTV、电影院、商场等。 返回场所的名称、地址、评分、距离等信息。""" parameters: dict = { "type": "object", "properties": { "center_lng": { "type": "number", "description": "中心点经度" }, "center_lat": { "type": "number", "description": "中心点纬度" }, "keywords": { "type": "string", "description": "搜索关键词,如'咖啡馆'、'餐厅'、'图书馆'" }, "radius": { "type": "integer", "description": "搜索半径(米),默认3000米", "default": 3000 } }, "required": ["center_lng", "center_lat", "keywords"] } class Config: arbitrary_types_allowed = True def _get_recommender(self): """延迟加载推荐器,并确保 API key 已设置""" if not hasattr(self, '_cached_recommender'): from app.tool.meetspot_recommender import CafeRecommender from app.config import config recommender = CafeRecommender() if hasattr(config, 'amap') and config.amap and hasattr(config.amap, 'api_key'): recommender.api_key = config.amap.api_key object.__setattr__(self, '_cached_recommender', recommender) return self._cached_recommender async def execute( self, center_lng: float, center_lat: float, keywords: str, radius: int = 3000 ) -> ToolResult: """搜索POI""" try: recommender = self._get_recommender() center = f"{center_lng},{center_lat}" places = await recommender._search_pois( location=center, keywords=keywords, radius=radius, types="", offset=20 ) if not places: return BaseTool.fail_response( f"在 ({center_lng:.4f}, {center_lat:.4f}) 附近 {radius}米范围内" f"未找到与 '{keywords}' 相关的场所" ) # 简化返回数据 simplified = [] for p in places[:15]: # 最多返回15个 biz_ext = p.get("biz_ext", {}) or {} location = p.get("location", "") lng, lat = location.split(",") if location else (0, 0) # 计算到中心的距离 distance = recommender._calculate_distance( (center_lng, center_lat), (float(lng), float(lat)) ) if location else 0 simplified.append({ "name": p.get("name", ""), "address": p.get("address", ""), "rating": biz_ext.get("rating", "N/A"), "cost": biz_ext.get("cost", ""), "location": location, "lng": float(lng) if lng else None, "lat": float(lat) if lat else None, "distance": round(distance, 0), "tel": p.get("tel", ""), "tag": p.get("tag", ""), "type": p.get("type", "") }) # 按距离排序 simplified.sort(key=lambda x: x.get("distance", 9999)) return BaseTool.success_response({ "places": simplified, "count": len(simplified), "keywords": keywords, "center": {"lng": center_lng, "lat": center_lat}, "radius": radius }) except Exception as e: logger.error(f"POI搜索失败: {e}") return BaseTool.fail_response(f"POI搜索错误: {str(e)}") class GenerateRecommendationTool(BaseTool): """智能推荐工具 - 使用 LLM 生成个性化推荐结果 结合规则评分和 LLM 智能评分,生成更精准的推荐: - 规则评分:基于距离、评分、热度等客观指标 - LLM 评分:理解用户需求语义,评估场所匹配度 """ name: str = "generate_recommendation" description: str = """智能生成会面地点推荐。 本工具使用双层评分系统: 1. 规则评分(40%):基于距离、评分、热度等客观指标 2. LLM 智能评分(60%):理解用户需求,评估场所特色与需求的匹配度 最终生成个性化的推荐理由,帮助用户做出最佳选择。""" parameters: dict = { "type": "object", "properties": { "places": { "type": "array", "description": "候选场所列表(来自search_poi的结果)", "items": { "type": "object", "properties": { "name": {"type": "string", "description": "场所名称"}, "address": {"type": "string", "description": "地址"}, "rating": {"type": "string", "description": "评分"}, "distance": {"type": "number", "description": "距中心点距离"}, "location": {"type": "string", "description": "坐标"} } } }, "center": { "type": "object", "description": "中心点坐标", "properties": { "lng": {"type": "number", "description": "经度"}, "lat": {"type": "number", "description": "纬度"} }, "required": ["lng", "lat"] }, "participant_locations": { "type": "array", "description": "参与者位置名称列表,用于 LLM 评估公平性", "items": {"type": "string"}, "default": [] }, "keywords": { "type": "string", "description": "搜索的场所类型,如'咖啡馆'、'餐厅'", "default": "咖啡馆" }, "user_requirements": { "type": "string", "description": "用户的特殊需求,如'停车方便'、'环境安静'", "default": "" }, "recommendation_count": { "type": "integer", "description": "推荐数量,默认5个", "default": 5 }, "use_llm_ranking": { "type": "boolean", "description": "是否使用 LLM 智能排序,默认 true", "default": True } }, "required": ["places", "center"] } class Config: arbitrary_types_allowed = True def _get_recommender(self): """延迟加载推荐器,并确保 API key 已设置""" if not hasattr(self, '_cached_recommender'): from app.tool.meetspot_recommender import CafeRecommender from app.config import config recommender = CafeRecommender() if hasattr(config, 'amap') and config.amap and hasattr(config.amap, 'api_key'): recommender.api_key = config.amap.api_key object.__setattr__(self, '_cached_recommender', recommender) return self._cached_recommender async def execute( self, places: List[Dict], center: Dict, participant_locations: List[str] = None, keywords: str = "咖啡馆", user_requirements: str = "", recommendation_count: int = 5, use_llm_ranking: bool = True ) -> ToolResult: """智能生成推荐""" try: if not places: return BaseTool.fail_response("没有候选场所可供推荐") recommender = self._get_recommender() center_point = (center["lng"], center["lat"]) # 1. 先用规则评分进行初步排序 ranked = recommender._rank_places( places=places, center_point=center_point, user_requirements=user_requirements, keywords=keywords ) # 2. 如果启用 LLM 智能排序,进行重排序 if use_llm_ranking and participant_locations: logger.info("启用 LLM 智能排序") ranked = await recommender._llm_smart_ranking( places=ranked, user_requirements=user_requirements, participant_locations=participant_locations or [], keywords=keywords, top_n=recommendation_count + 3 # 多取几个以便筛选 ) # 取前N个推荐 top_places = ranked[:recommendation_count] # 生成推荐结果 recommendations = [] for i, place in enumerate(top_places, 1): score = place.get("_final_score") or place.get("_score", 0) distance = place.get("_distance") or place.get("distance", 0) rating = place.get("_raw_rating") or place.get("rating", "N/A") # 优先使用 LLM 生成的理由 llm_reason = place.get("_llm_reason", "") rule_reason = place.get("_recommendation_reason", "") if llm_reason: reasons = [llm_reason] elif rule_reason: reasons = [rule_reason] else: # 兜底:构建基础推荐理由 reasons = [] if distance <= 500: reasons.append("距离中心点很近") elif distance <= 1000: reasons.append("距离适中") if rating != "N/A": try: r = float(rating) if r >= 4.5: reasons.append("口碑优秀") elif r >= 4.0: reasons.append("评价良好") except (ValueError, TypeError): pass if not reasons: reasons = ["综合评分较高"] recommendations.append({ "rank": i, "name": place.get("name", ""), "address": place.get("address", ""), "rating": str(rating) if rating else "N/A", "distance": round(distance, 0), "score": round(score, 1), "llm_score": place.get("_llm_score", 0), "tel": place.get("tel", ""), "reasons": reasons, "location": place.get("location", ""), "scoring_method": "llm+rule" if place.get("_llm_score") else "rule" }) return BaseTool.success_response({ "recommendations": recommendations, "total_candidates": len(places), "user_requirements": user_requirements, "center": center, "llm_ranking_used": use_llm_ranking and bool(participant_locations) }) except Exception as e: logger.error(f"生成推荐失败: {e}") return BaseTool.fail_response(f"生成推荐错误: {str(e)}") # 导出所有工具 __all__ = [ "GeocodeTool", "CalculateCenterTool", "SearchPOITool", "GenerateRecommendationTool" ]