first commit

This commit is contained in:
ytc1012
2026-02-04 16:11:55 +08:00
commit 0f3ee050dc
165 changed files with 25795 additions and 0 deletions

514
MeetSpot/app/agent/tools.py Normal file
View File

@@ -0,0 +1,514 @@
"""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"
]