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"<li><i class='bx {icon}'></i>{text}</li>")

            logger.info(f"LLM 生成了 {len(tips)} 条交通建议")
            return "\n                        ".join(html_items)

        except Exception as e:
            logger.warning(f"LLM 生成交通建议失败: {e}")
            return self._generate_default_transport_tips(keywords)

    def _generate_default_transport_tips(self, keywords: str) -> str:
        """生成默认交通建议（兜底逻辑）"""
        return """<li><i class='bx bx-check'></i>建议使用高德地图或百度地图导航到目的地</li>
                        <li><i class='bx bx-check'></i>高峰时段建议提前30分钟出发</li>
                        <li><i class='bx bx-check'></i>部分场所可能提供停车服务，建议提前确认</li>
                        <li><i class='bx bx-check'></i>如使用公共交通，可查询附近地铁站或公交站</li>"""

    async def _llm_generate_place_reasons(
        self,
        places: List[Dict],
        user_requirements: str,
        participant_locations: List[str],
        keywords: str
    ) -> Dict[str, str]:
        """LLM 批量生成场所推荐理由

        为每个场所生成个性化的推荐理由，考虑用户需求和参与者位置。

        Args:
            places: 场所列表
            user_requirements: 用户需求
            participant_locations: 参与者位置
            keywords: 搜索关键词

        Returns:
            场所名称到推荐理由的映射
        """
        llm = _get_llm()
        if not llm or len(places) == 0:
            return {}

        try:
            places_info = []
            for i, place in enumerate(places[:8]):
                places_info.append({
                    "id": i,
                    "name": place.get("name", ""),
                    "rating": place.get("_raw_rating", place.get("rating", 0)),
                    "distance": round(place.get("_distance", 0)),
                    "address": place.get("address", ""),
                    "type": place.get("type", "")
                })

            prompt = f"""你是一个本地生活推荐专家。为以下场所生成简洁的推荐理由。

**用户需求**：{user_requirements or "无特殊要求"}

**参与者出发地**：
{chr(10).join([f"- {loc}" for loc in participant_locations])}

**场所类型**：{keywords}

**候选场所**：
{json.dumps(places_info, ensure_ascii=False, indent=2)}

为每个场所生成一句话推荐理由（15-25字），要求：
1. 突出该场所最大的优势（距离近、评分高、环境好等）
2. 如果有用户需求，说明如何满足
3. 语言自然，避免模板化
4. 每个场所的理由要有差异化

直接返回 JSON 对象，key 是场所 id，value 是推荐理由：
```json
{{
  "0": "距离两校中心最近，步行5分钟可达",
  "1": "星巴克品质保证，适合安静交谈"
}}
```
"""

            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 {}

            # 非流式调用返回字符串
            content = response if isinstance(response, str) else response.content
            content = content.strip()

            if "```json" in content:
                content = content.split("```json")[1].split("```")[0].strip()
            elif "```" in content:
                content = content.split("```")[1].split("```")[0].strip()

            reasons_map = json.loads(content)

            # 转换为场所名称映射
            result = {}
            for i, place in enumerate(places[:8]):
                if str(i) in reasons_map:
                    result[place.get("name", "")] = reasons_map[str(i)]

            logger.info(f"LLM 生成了 {len(result)} 条推荐理由")
            return result

        except Exception as e:
            logger.warning(f"LLM 生成推荐理由失败: {e}")
            return {}

    def _rank_places(
        self,
        places: List[Dict],
        center_point: Tuple[float, float],
        user_requirements: str,
        keywords: str,
        min_rating: float = 0.0,
        max_distance: int = 100000,
        price_range: str = ""
    ) -> List[Dict]:
        """V2 多维度评分排序算法

        评分维度 (满分100分):
        - 基础评分: 30分 (基于rating)
        - 热度分: 20分 (基于评论数+图片数)
        - 距离分: 25分 (非线性衰减)
        - 场景匹配: 15分
        - 需求匹配: 10分

        筛选条件:
        - min_rating: 最低评分过滤
        - max_distance: 最大距离过滤(米)
        - price_range: 价格区间过滤
        """
        logger.info(f"开始V2多维度评分，共{len(places)}个场所")

        # ========== 硬筛选阶段 ==========
        original_count = len(places)

        # 1. 评分筛选
        if min_rating > 0:
            places = [p for p in places if float(p.get("rating", 0) or 0) >= min_rating]
            logger.info(f"评分筛选(>={min_rating}): {original_count} -> {len(places)}")

        # 2. 距离筛选
        if max_distance < 100000:
            filtered_places = []
            for p in places:
                try:
                    # Amap POI uses "location" field with "lng,lat" format
                    location = p.get("location", "")
                    if location and "," in location:
                        lng_str, lat_str = location.split(",")
                        place_lng, place_lat = float(lng_str), float(lat_str)
                    else:
                        # Fallback to separate fields
                        place_lng = float(p.get("lng", 0))
                        place_lat = float(p.get("lat", 0))
                    dist = self._calculate_distance(center_point, (place_lng, place_lat))
                    if dist <= max_distance:
                        filtered_places.append(p)
                except (ValueError, TypeError):
                    pass
            places = filtered_places
            logger.info(f"距离筛选(<={max_distance}米): 剩余{len(places)}个")

        # 3. 价格区间筛选（软筛选，作为排序权重）
        price_weight_map = {
            "economy": ["¥", "人均20", "人均30", "人均40"],
            "mid": ["¥¥", "人均50", "人均60", "人均80", "人均100"],
            "high": ["¥¥¥", "¥¥¥¥", "人均150", "人均200", "人均300"]
        }

        if not places:
            logger.warning("筛选后无符合条件的场所")
            return []

        for place in places:
            # 1. 基础评分 (满分30分)
            base_score, raw_rating = self._calculate_base_score(place)
            place["_raw_rating"] = raw_rating

            # 2. 热度分 (满分20分)
            popularity_score, review_count, photo_count = self._calculate_popularity_score(place)
            place["_review_count"] = review_count
            place["_photo_count"] = photo_count

            # 3. 距离分 (满分25分) - 非线性衰减
            distance_score, distance = self._calculate_distance_score_v2(place, center_point)

            # 4. 场景匹配分 (满分15分)
            scenario_score, matched_scenario = self._calculate_scenario_match_score(place, keywords)
            place["_matched_scenario"] = matched_scenario

            # 5. 需求匹配分 (满分10分) - 三层匹配算法
            requirement_score, matched_reqs, confidence_map = self._calculate_requirement_score(place, user_requirements)
            place["_matched_requirements"] = matched_reqs
            place["_requirement_confidence"] = confidence_map  # 置信度映射

            # 汇总得分
            total_score = base_score + popularity_score + distance_score + scenario_score + requirement_score
            place["_score"] = total_score

            # 记录评分明细用于调试
            place["_score_breakdown"] = {
                "base": round(base_score, 1),
                "popularity": round(popularity_score, 1),
                "distance": round(distance_score, 1),
                "scenario": round(scenario_score, 1),
                "requirement": round(requirement_score, 1)
            }

            logger.debug(
                f"{place.get('name')}: 总分{total_score:.1f} "
                f"(基础{base_score:.1f}+热度{popularity_score:.1f}+"
                f"距离{distance_score:.1f}+场景{scenario_score:.1f}+需求{requirement_score:.1f})"
            )

        # 初步排序
        ranked_places = sorted(places, key=lambda x: x.get("_score", 0), reverse=True)

        # 应用多样性调整（惩罚连锁店）
        ranked_places = self._apply_diversity_adjustment(ranked_places)

        # 重新排序
        ranked_places = sorted(ranked_places, key=lambda x: x.get("_score", 0), reverse=True)

        # 生成推荐理由（优先使用 LLM 生成的理由，否则使用规则生成）
        for place in ranked_places:
            # 如果 LLM 智能排序已经生成了理由，优先使用
            if place.get("_llm_reason"):
                place["_recommendation_reason"] = place["_llm_reason"]
            else:
                place["_recommendation_reason"] = self._generate_recommendation_reason(place, ranked_places)

        # 对于多场景搜索，确保每个场景都有代表性
        if any(place.get('_source_keyword') for place in ranked_places):
            logger.info("应用多场景平衡策略")
            # 按场景类型分组
            by_keyword = {}
            for place in ranked_places:
                keyword = place.get('_source_keyword', '未知')
                if keyword not in by_keyword:
                    by_keyword[keyword] = []
                by_keyword[keyword].append(place)

            # 从每个场景选择最佳的场所，确保多样性
            balanced_places = []
            max_per_keyword = max(2, 8 // len(by_keyword))  # 每个场景至少2个，总共不超过8个

            for keyword, keyword_places in by_keyword.items():
                selected = keyword_places[:max_per_keyword]
                balanced_places.extend(selected)
                logger.info(f"从场景 '{keyword}' 选择了 {len(selected)} 个场所")

            # 按分数重新排序，但保持场景多样性
            balanced_places = sorted(balanced_places, key=lambda x: x.get("_score", 0), reverse=True)

            # 记录最终推荐
            for i, p in enumerate(balanced_places[:8]):
                logger.info(f"推荐#{i+1}: {p.get('name')} ({p.get('_score', 0):.1f}分) - {p.get('_recommendation_reason', '')}")

            return balanced_places[:8]  # 增加到8个推荐
        else:
            # 记录最终推荐
            for i, p in enumerate(ranked_places[:6]):
                logger.info(f"推荐#{i+1}: {p.get('name')} ({p.get('_score', 0):.1f}分) - {p.get('_recommendation_reason', '')}")

            return ranked_places[:6]  # 单场景增加到6个


    def _calculate_distance(
        self,
        point1: Tuple[float, float],
        point2: Tuple[float, float]
    ) -> float:
        lng1, lat1 = point1
        lng2, lat2 = point2
        x = (lng2 - lng1) * 85000 
        y = (lat2 - lat1) * 111000 
        return math.sqrt(x*x + y*y)

    def _cleanup_old_html_files(self, directory: str, max_files: int = 50):
        """清理旧的 HTML 文件，保留最新的 max_files 个"""
        try:
            files = []
            for f in os.listdir(directory):
                if f.endswith('.html') and f.startswith('place_recommendation_'):
                    file_path = os.path.join(directory, f)
                    files.append((file_path, os.path.getmtime(file_path)))

            # 按修改时间排序，删除旧文件
            files.sort(key=lambda x: x[1], reverse=True)
            for file_path, _ in files[max_files:]:
                try:
                    os.remove(file_path)
                    logger.debug(f"清理旧 HTML 文件: {file_path}")
                except Exception:
                    pass
        except Exception as e:
            logger.warning(f"清理 HTML 文件时出错: {e}")

    async def _generate_html_page(
        self,
        locations: List[Dict],
        places: List[Dict],
        center_point: Tuple[float, float],
        user_requirements: str,
        keywords: str,
        theme: str = "",
        fallback_used: bool = False,
        fallback_keyword: Optional[str] = None,
        participant_locations: Optional[List[str]] = None
    ) -> str:
        file_name_prefix = "place"

        # 提取参与者位置名称
        if participant_locations is None:
            participant_locations = [loc.get("formatted_address", loc.get("address", "")) for loc in locations]

        html_content = await self._generate_html_content(
            locations, places, center_point, user_requirements, keywords,
            theme, fallback_used, fallback_keyword, participant_locations
        )
        timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
        unique_id = str(uuid.uuid4())[:8]
        file_name = f"{file_name_prefix}_recommendation_{timestamp}_{unique_id}.html"

        workspace_js_src_path = os.path.join("workspace", "js_src")
        os.makedirs(workspace_js_src_path, exist_ok=True)

        # 清理旧文件，防止累积
        self._cleanup_old_html_files(workspace_js_src_path, max_files=50)

        file_path = os.path.join(workspace_js_src_path, file_name)

        async with aiofiles.open(file_path, 'w', encoding='utf-8') as f:
            await f.write(html_content)
        return file_path

    async def _generate_html_content(
        self,
        locations: List[Dict],
        places: List[Dict],
        center_point: Tuple[float, float],
        user_requirements: str,
        keywords: str,
        theme: str = "",
        fallback_used: bool = False,
        fallback_keyword: Optional[str] = None,
        participant_locations: Optional[List[str]] = None
    ) -> str:
        # 根据主题参数确定配置
        if theme:
            # 主题映射：前端theme -> 后端配置key
            theme_mapping = {
                'coffee': '咖啡馆',
                'restaurant': '餐厅', 
                'library': '图书馆',
                'shopping': '商场',
                'park': '公园',
                'cinema': '电影院',
                'gym': '健身房',
                'ktv': 'KTV',
                'museum': '博物馆',
                'attraction': '景点',
                'bar': '酒吧',
                'teahouse': '茶楼',
                'custom': 'default'
            }
            config_key = theme_mapping.get(theme, 'default')
            cfg = self._get_place_config(config_key)
            primary_keyword = config_key if config_key != 'default' else '场所'
        else:
            # 兼容旧逻辑：从关键词确定配置
            primary_keyword = keywords.split("、")[0] if keywords else "场所"
            cfg = self._get_place_config(primary_keyword)

        city_name = self._extract_city_from_locations(locations)
        meta_tags = {
            "title": f"{cfg['topic']} - 最佳会面{cfg['noun_singular']}推荐",
            "description": f"MeetSpot在{city_name}为多人聚会智能推荐公平会面地点, 支持{primary_keyword}等场景。",
            "keywords": f"{city_name},{primary_keyword},MeetSpot,聚会地点",
        }
        schema_graph: List[Dict] = []
        try:
            from api.services.seo_content import SEOContentGenerator

            seo_generator = SEOContentGenerator()
            meta_tags = seo_generator.generate_meta_tags(
                "recommendation",
                {
                    "city": city_name,
                    "keyword": primary_keyword,
                    "locations_count": len(locations),
                },
            )
            schema_graph = []
            for place in places[:3]:
                schema_obj = seo_generator.generate_schema_org(
                    "local_business", self._format_schema_payload(place, city_name)
                )
                if schema_obj:
                    schema_obj.pop("@context", None)
                    schema_graph.append(schema_obj)
        except Exception as exc:  # noqa: BLE001 - 非关键路径
            logger.warning(f"SEO meta fallback: {exc}")
            schema_graph = []

        meta_title = html.escape(meta_tags.get("title", "")) or f"{city_name}聚会地点推荐 - MeetSpot"
        meta_description = html.escape(
            meta_tags.get("description", "MeetSpot帮助团队计算公平的会面地点。")
        )
        meta_keywords = html.escape(meta_tags.get("keywords", f"{city_name},{primary_keyword}"))
        schema_block = ""
        if schema_graph:
            schema_block = json.dumps(
                {"@context": "https://schema.org", "@graph": [g for g in schema_graph if g]},
                ensure_ascii=False,
                indent=2,
            )
        canonical_url = "https://meetspot-irq2.onrender.com/"
        schema_script = ""
        if schema_block:
            schema_script = (
                '\n    <script type="application/ld+json">\n'
                f"{schema_block}\n"
                "    </script>\n"
            )

        search_process_html = self._generate_search_process(locations, center_point, user_requirements, keywords, places) 

        location_markers = []
        for idx, loc in enumerate(locations):
            location_markers.append({
                "name": f"地点{idx+1}: {loc['name']}",
                "position": [loc["lng"], loc["lat"]],
                "icon": "location"
            })

        place_markers = [] 
        for place in places:
            lng_str, lat_str = place.get("location", ",").split(",")
            if lng_str and lat_str:
                place_markers.append({
                    "name": place["name"],
                    "position": [float(lng_str), float(lat_str)],
                    "icon": "place" 
                })

        center_marker = {
            "name": "最佳会面点",
            "position": [center_point[0], center_point[1]],
            "icon": "center"
        }
        all_markers = [center_marker] + location_markers + place_markers

        location_rows_html = ""
        for idx, loc in enumerate(locations):
            location_rows_html += f"<tr><td>{idx+1}</td><td>{loc['name']}</td><td>{loc['formatted_address']}</td></tr>"

        location_distance_html = ""
        for loc in locations:
            distance = self._calculate_distance(center_point, (loc['lng'], loc['lat']))/1000
            location_distance_html += f"<li><i class='bx bx-map'></i><strong>{loc['name']}</strong>: 距离中心点约 <span class='distance'>{distance:.1f} 公里</span></li>"

        # LLM 动态生成交通与停车建议 (带超时保护)
        if participant_locations is None:
            participant_locations = [loc.get("name", loc.get("formatted_address", "")) for loc in locations]
        try:
            transport_tips_html = await asyncio.wait_for(
                self._llm_generate_transport_tips(places, center_point, participant_locations, keywords),
                timeout=15.0  # 15秒超时，避免Render 30秒请求超时
            )
        except asyncio.TimeoutError:
            logger.warning("LLM 交通建议生成超时，使用默认建议")
            transport_tips_html = self._generate_default_transport_tips(keywords)

        place_cards_html = "" 
        for place in places:
            rating = place.get("biz_ext", {}).get("rating", "暂无评分")
            address = place.get("address", "地址未知")
            business_hours = place.get("business_hours", "营业时间未知")
            if isinstance(business_hours, list) and business_hours:
                business_hours = "; ".join(business_hours)
            tel = place.get("tel", "电话未知")
            
            tags = place.get("tag", [])
            if isinstance(tags, str): tags = tags.split(";") if tags else []
            elif not isinstance(tags, list): tags = []
            
            tags_html = "".join([f"<span class='cafe-tag'>{tg.strip()}</span>" for tg in tags if tg.strip()])
            if not tags_html:
                tags_html = f"<span class='cafe-tag'>{cfg['noun_singular']}</span>"

            # 需求匹配置信度标签
            matched_reqs = place.get("_matched_requirements", [])
            confidence_map = place.get("_requirement_confidence", {})
            requirement_match_html = ""
            if matched_reqs:
                match_tags = []
                for req in matched_reqs:
                    confidence = confidence_map.get(req, "low")
                    if confidence == "high":
                        icon = "bx-check-circle"
                        tag_class = "match-tag-high"
                        tooltip = "已验证"
                    elif confidence == "medium":
                        icon = "bx-check"
                        tag_class = "match-tag-medium"
                        tooltip = "品牌特征"
                    else:
                        icon = "bx-question-mark"
                        tag_class = "match-tag-low"
                        tooltip = "建议确认"
                    match_tags.append(f"<span class='match-tag {tag_class}' title='{tooltip}'><i class='bx {icon}'></i>{req}</span>")
                requirement_match_html = f'''
                        <div class="requirement-match">
                            {"".join(match_tags)}
                        </div>'''

            lng_str, lat_str = place.get("location",",").split(",")
            distance_text = "未知距离"
            map_link_coords = ""
            if lng_str and lat_str:
                lng, lat = float(lng_str), float(lat_str)
                distance = self._calculate_distance(center_point, (lng, lat))
                distance_text = f"{distance/1000:.1f} 公里"
                map_link_coords = f"{lng},{lat}"

            # 获取推荐理由
            recommendation_reason = place.get("_recommendation_reason", "")
            reason_html = ""
            if recommendation_reason:
                reason_html = f'''
                        <div class="cafe-reason">
                            <i class='bx bx-bulb'></i>
                            <span>{recommendation_reason}</span>
                        </div>'''

            # 获取评分明细用于tooltip（可选展示）
            score_breakdown = place.get("_score_breakdown", {})
            total_score = place.get("_score", 0)
            score_title = f"综合评分: {total_score:.0f}/100"

            place_cards_html += f'''
            <div class="cafe-card" title="{score_title}">
                <div class="cafe-img">
                    <i class='bx {cfg["icon_card"]}'></i>
                </div>
                <div class="cafe-content">
                    <div class="cafe-header">
                        <div>
                            <h3 class="cafe-name">{place['name']}</h3>
                        </div>
                        <span class="cafe-rating">评分: {rating}</span>
                    </div>{reason_html}
                    <div class="cafe-details">
                        <div class="cafe-info">
                            <i class='bx bx-map'></i>
                            <div class="cafe-info-text">{address}</div>
                        </div>
                        <div class="cafe-info">
                            <i class='bx bx-time'></i>
                            <div class="cafe-info-text">{business_hours}</div>
                        </div>
                        <div class="cafe-info">
                            <i class='bx bx-phone'></i>
                            <div class="cafe-info-text">{tel}</div>
                        </div>
                        <div class="cafe-tags">
                            {tags_html}
                        </div>{requirement_match_html}
                    </div>
                    <div class="cafe-footer">
                        <div class="cafe-distance">
                            <i class='bx bx-walk'></i> {distance_text}
                        </div>
                        <div class="cafe-actions">
                            <a href="https://uri.amap.com/marker?position={map_link_coords}&name={place['name']}" target="_blank">
                                <i class='bx bx-navigation'></i>导航
                            </a>
                        </div>
                    </div>
                </div>
            </div>'''

        # 空状态设计：如果没有找到推荐结果
        if not places:
            place_cards_html = f'''
            <div class="empty-state">
                <i class='bx bx-coffee empty-state-icon'></i>
                <h3 class="empty-state-title">暂无推荐{cfg["noun_plural"]}</h3>
                <p class="empty-state-description">
                    很抱歉，在您指定的区域内未能找到符合条件的{cfg["noun_plural"]}。<br>
                    建议扩大搜索范围或调整搜索关键词。
                </p>
                <a href="/public/meetspot_finder.html" class="btn-modern btn-primary-modern">
                    <i class='bx bx-redo'></i>重新搜索
                </a>
            </div>'''

        markers_json = json.dumps(all_markers)

        amap_security_js_code = ""
        if hasattr(config, 'amap') and hasattr(config.amap, 'security_js_code') and config.amap.security_js_code:
            amap_security_js_code = config.amap.security_js_code

        # 读取设计token CSS内容，用于自包含HTML
        design_tokens_css = ""
        try:
            from pathlib import Path
            tokens_css_path = Path("static/css/design-tokens.css")
            if tokens_css_path.exists():
                design_tokens_css = tokens_css_path.read_text(encoding='utf-8')
        except Exception as e:
            logger.warning(f"无法读取design-tokens.css: {e}")

        # Dynamically set CSS variables using MeetSpot brand colors
        # 使用品牌色系统而非场所特定配色，确保一致性
        dynamic_style = f"""
        /* Design Tokens - Embedded for offline capability */
        {design_tokens_css}

        /* MeetSpot Brand Color System - 深海蓝+日落橙主题 */
        :root {{
            /* 主色：深海蓝系 - 沉稳、可信赖 */
            --primary: var(--brand-primary, #0A4D68);
            --primary-light: var(--brand-primary-light, #088395);
            --primary-dark: var(--brand-primary-dark, #05445E);
            /* 强调色：日落橙 - 温暖、活力 */
            --accent: var(--brand-accent, #FF6B35);
            --accent-light: var(--brand-accent-light, #FF8C61);
            /* 次要色：薄荷绿 - 清新、平衡 */
            --secondary: var(--brand-secondary, #06D6A0);
            /* 中性色 */
            --light: var(--neutral-50, #F8FAFC);
            --dark: var(--neutral-900, #0F172A);
            /* 功能色 */
            --success: var(--brand-success, #0C8A5D);
            --border-radius: var(--radius-lg, 12px);
            --box-shadow: var(--shadow-lg, 0 8px 30px rgba(0, 0, 0, 0.12));
            --transition: all 0.3s ease;

            /* 场所特定装饰色（保留图标色，但不影响主色调） */
            --venue-icon-bg: {cfg.get("theme_primary", "#0A4D68")};
        }}"""

        html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{meta_title}</title>
    <meta name="description" content="{meta_description}">
    <meta name="keywords" content="{meta_keywords}">
    <link rel="canonical" href="{canonical_url}">
    <meta property="og:type" content="website">
    <meta property="og:title" content="{meta_title}">
    <meta property="og:description" content="{meta_description}">
    <meta property="og:url" content="{canonical_url}">
    <meta property="og:image" content="https://meetspot-irq2.onrender.com/static/og-image.jpg">
    <meta name="twitter:card" content="summary_large_image">
    <meta name="twitter:title" content="{meta_title}">
    <meta name="twitter:description" content="{meta_description}">

    <!-- MeetSpot Urban Navigator Theme Fonts - Distinctive Typography -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">

    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/boxicons@2.0.9/css/boxicons.min.css">

    <!-- Modern UI Components -->
    <link rel="stylesheet" href="/public/css/components.css">

    {schema_script}
    <style>
        {dynamic_style} /* Inject dynamic theme colors here */

        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        body {{ font-family: 'DM Sans', 'PingFang SC', 'Microsoft YaHei', sans-serif; line-height: 1.6; background-color: var(--light); color: var(--dark); padding-bottom: 50px; }}
        h1, h2, h3, h4, h5, h6 {{ font-family: 'Outfit', 'PingFang SC', sans-serif; font-weight: 700; letter-spacing: -0.02em; }}
        .container {{ max-width: 1200px; margin: 0 auto; padding: 0 20px; }}
        header {{ background: linear-gradient(135deg, #001524 0%, #0A4D68 50%, #001524 100%); color: white; padding: 60px 0 100px; text-align: center; position: relative; margin-bottom: 80px; box-shadow: 0 8px 32px rgba(0, 21, 36, 0.3); }}
        header::before {{ content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-image: repeating-radial-gradient(circle at 30% 40%, transparent 0, transparent 40px, rgba(6, 214, 160, 0.05) 40px, rgba(6, 214, 160, 0.05) 42px); pointer-events: none; }}
        header::after {{ content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 60px; background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 60"><path fill="%23F8FAFC" fill-opacity="1" d="M0,32L80,42.7C160,53,320,75,480,64C640,53,800,11,960,5.3C1120,0,1280,32,1360,48L1440,64L1440,100L1360,100C1280,100,1120,100,960,100C800,100,640,100,480,100C320,100,160,100,80,100L0,100Z"></path></svg>'); background-size: cover; background-position: center; }}
        .header-logo {{ font-size: 3rem; font-weight: 800; margin-bottom: 10px; letter-spacing: -0.03em; text-shadow: 0 2px 20px rgba(0, 0, 0, 0.3); }}
        .coffee-icon {{ font-size: 3rem; vertical-align: middle; margin-right: 10px; }}
        .header-subtitle {{ font-size: 1.2rem; opacity: 0.9; }}
        .main-content {{ margin-top: -60px; }}
        .card {{ background-color: white; border-radius: var(--border-radius); padding: 30px; box-shadow: var(--box-shadow); margin-bottom: 30px; transition: var(--transition); }}
        .card:hover {{ transform: translateY(-5px); box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1); }}
        .section-title {{ font-size: 1.8rem; color: var(--primary-dark); margin-bottom: 25px; padding-bottom: 15px; border-bottom: 2px solid var(--secondary); display: flex; align-items: center; }}
        .section-title i {{ margin-right: 12px; font-size: 1.6rem; color: var(--primary); }}
        .summary-card {{ display: flex; flex-wrap: wrap; gap: 20px; margin-bottom: 15px; }}
        .summary-item {{ flex: 1; min-width: 200px; padding: 15px; background-color: rgba(0,0,0,0.03); /* Adjusted for better contrast with various themes */ border-radius: 8px; border-left: 4px solid var(--primary); }}
        .summary-label {{ font-size: 0.9rem; color: var(--primary-dark); margin-bottom: 5px; }}
        .summary-value {{ font-size: 1.2rem; font-weight: 600; color: var(--dark); }}
        .map-container {{ height: 500px; border-radius: var(--border-radius); overflow: hidden; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1); position: relative; margin-bottom: 30px; }}
        #map {{ height: 100%; width: 100%; }}
        .map-legend {{ position: absolute; bottom: 15px; left: 15px; background: white; padding: 12px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.15); z-index: 100; }}
        .legend-item {{ display: flex; align-items: center; margin-bottom: 8px; }}
        .legend-color {{ width: 20px; height: 20px; margin-right: 10px; border-radius: 50%; }}
        .legend-center {{ background-color: var(--brand-secondary, #06D6A0); }}  /* 薄荷绿 - 中心点 */
        .legend-location {{ background-color: var(--brand-primary, #0A4D68); }}  /* 深海蓝 - 参与地点 */
        .legend-place {{ background-color: var(--brand-accent, #FF6B35); }}  /* 日落橙 - 推荐场所 */ 
        .location-table {{ width: 100%; border-collapse: collapse; border-radius: 8px; overflow: hidden; margin-bottom: 25px; box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); }}
        .location-table th, .location-table td {{ padding: 15px; text-align: left; border-bottom: 1px solid #eee; }}
        .location-table th {{ background-color: var(--primary-light); color: white; font-weight: 600; }}
        .location-table tr:last-child td {{ border-bottom: none; }}
        .location-table tr:nth-child(even) {{ background-color: rgba(0,0,0,0.02); /* Adjusted for better contrast */ }}
        .cafe-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 28px; margin-top: 24px; }}
        .cafe-card {{ background-color: white; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 20px rgba(10, 77, 104, 0.08), 0 1px 3px rgba(0,0,0,0.04); transition: all 0.4s cubic-bezier(0.165, 0.84, 0.44, 1); display: flex; flex-direction: column; position: relative; }}
        .cafe-card::before {{ content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: linear-gradient(90deg, var(--primary) 0%, var(--primary-light) 50%, var(--brand-accent, #FF6B35) 100%); opacity: 0; transition: opacity 0.3s ease; }}
        .cafe-card:hover {{ transform: translateY(-8px) scale(1.01); box-shadow: 0 20px 40px rgba(10, 77, 104, 0.15), 0 8px 16px rgba(0,0,0,0.08); }}
        .cafe-card:hover::before {{ opacity: 1; }}
        /* 推荐理由 - 地图标注风格 */
        .cafe-reason {{ position: relative; display: flex; align-items: center; gap: 10px; background: linear-gradient(135deg, rgba(255, 107, 53, 0.08) 0%, rgba(255, 107, 53, 0.03) 100%); padding: 12px 16px; margin: 0 0 12px 0; border-radius: 10px; border: 1px solid rgba(255, 107, 53, 0.15); }}
        .cafe-reason::before {{ content: ''; position: absolute; left: 12px; top: -6px; width: 12px; height: 12px; background: linear-gradient(135deg, var(--brand-accent, #FF6B35) 0%, #ff8c5a 100%); border-radius: 50%; box-shadow: 0 2px 8px rgba(255, 107, 53, 0.4); animation: reasonPulse 2s ease-in-out infinite; }}
        .cafe-reason i {{ color: var(--brand-accent, #FF6B35); font-size: 1.2rem; margin-left: 8px; }}
        .cafe-reason span {{ color: #2c3e50; font-size: 0.88rem; font-weight: 600; letter-spacing: 0.01em; line-height: 1.4; }}
        @keyframes reasonPulse {{ 0%, 100% {{ transform: scale(1); opacity: 1; }} 50% {{ transform: scale(1.2); opacity: 0.7; }} }}
        /* 卡片排名标记 */
        .cafe-card:nth-child(1) .cafe-img::after {{ content: '🥇 TOP 1'; position: absolute; top: 12px; right: 12px; background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); color: #1a1a1a; padding: 6px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(255, 215, 0, 0.4); }}
        .cafe-card:nth-child(2) .cafe-img::after {{ content: '🥈 TOP 2'; position: absolute; top: 12px; right: 12px; background: linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%); color: #1a1a1a; padding: 6px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(192, 192, 192, 0.4); }}
        .cafe-card:nth-child(3) .cafe-img::after {{ content: '🥉 TOP 3'; position: absolute; top: 12px; right: 12px; background: linear-gradient(135deg, #CD7F32 0%, #B8860B 100%); color: white; padding: 6px 12px; border-radius: 20px; font-size: 0.75rem; font-weight: 700; letter-spacing: 0.5px; box-shadow: 0 4px 12px rgba(205, 127, 50, 0.4); }}
        .cafe-img {{ height: 180px; background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 3.5rem; position: relative; overflow: hidden; }}
        .cafe-img::before {{ content: ''; position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%); animation: shimmer 3s ease-in-out infinite; }}
        @keyframes shimmer {{ 0%, 100% {{ transform: translate(-30%, -30%); }} 50% {{ transform: translate(30%, 30%); }} }}
        .cafe-content {{ padding: 22px; flex: 1; display: flex; flex-direction: column; }}
        .cafe-header {{ display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }}
        .cafe-name {{ font-size: 1.25rem; margin: 0; color: var(--primary-dark); font-weight: 700; letter-spacing: -0.01em; line-height: 1.3; }}
        .cafe-rating {{ display: inline-flex; align-items: center; gap: 4px; background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%); color: white; padding: 6px 14px; border-radius: 20px; font-weight: 700; font-size: 0.85rem; white-space: nowrap; box-shadow: 0 2px 8px rgba(10, 77, 104, 0.25); }}
        .cafe-rating::before {{ content: '⭐'; font-size: 0.75rem; }}
        .cafe-details {{ flex: 1; }}
        .cafe-info {{ margin-bottom: 10px; display: flex; align-items: flex-start; }}
        .cafe-info i {{ color: var(--primary); margin-right: 10px; font-size: 1.05rem; min-width: 18px; margin-top: 2px; opacity: 0.85; }}
        .cafe-info-text {{ flex: 1; font-size: 0.9rem; color: #4a5568; line-height: 1.5; }}
        .cafe-tags {{ display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }}
        .cafe-tag {{ background: linear-gradient(135deg, rgba(10, 77, 104, 0.06) 0%, rgba(8, 131, 149, 0.04) 100%); color: var(--primary-dark); padding: 5px 12px; border-radius: 16px; font-size: 0.78rem; font-weight: 500; border: 1px solid rgba(10, 77, 104, 0.08); transition: all 0.2s ease; }}
        .cafe-tag:hover {{ background: linear-gradient(135deg, rgba(10, 77, 104, 0.12) 0%, rgba(8, 131, 149, 0.08) 100%); transform: translateY(-1px); }}
        /* 需求匹配置信度标签样式 */
        .requirement-match {{ display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px dashed rgba(0,0,0,0.08); }}
        .match-tag {{ display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 12px; font-size: 0.75rem; font-weight: 500; transition: all 0.2s ease; }}
        .match-tag i {{ font-size: 0.85rem; }}
        .match-tag-high {{ background: linear-gradient(135deg, rgba(16, 185, 129, 0.15) 0%, rgba(16, 185, 129, 0.08) 100%); color: #059669; border: 1px solid rgba(16, 185, 129, 0.2); }}
        .match-tag-high i {{ color: #10B981; }}
        .match-tag-medium {{ background: linear-gradient(135deg, rgba(245, 158, 11, 0.15) 0%, rgba(245, 158, 11, 0.08) 100%); color: #B45309; border: 1px solid rgba(245, 158, 11, 0.2); }}
        .match-tag-medium i {{ color: #F59E0B; }}
        .match-tag-low {{ background: linear-gradient(135deg, rgba(148, 163, 184, 0.15) 0%, rgba(148, 163, 184, 0.08) 100%); color: #475569; border: 1px solid rgba(148, 163, 184, 0.2); }}
        .match-tag-low i {{ color: #94A3B8; }}
        .match-tag:hover {{ transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
        .cafe-footer {{ display: flex; align-items: center; justify-content: space-between; margin-top: 18px; padding-top: 16px; border-top: 1px solid rgba(0,0,0,0.06); }}
        .cafe-distance {{ display: inline-flex; align-items: center; gap: 6px; color: var(--primary-dark); font-weight: 600; font-size: 0.9rem; padding: 6px 12px; background: rgba(10, 77, 104, 0.04); border-radius: 8px; }}
        .cafe-distance i {{ font-size: 1.1rem; color: var(--primary); }}
        .cafe-actions a {{ display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: linear-gradient(135deg, var(--brand-accent, #FF6B35) 0%, #ff8c5a 100%); color: white; padding: 10px 18px; border-radius: 10px; text-decoration: none; font-size: 0.88rem; font-weight: 600; transition: all 0.3s cubic-bezier(0.165, 0.84, 0.44, 1); box-shadow: 0 4px 12px rgba(255, 107, 53, 0.25); }}
        .cafe-actions a:hover {{ transform: translateY(-3px); box-shadow: 0 8px 20px rgba(255, 107, 53, 0.35); }}
        .cafe-actions i {{ font-size: 1.1rem; }}
        .transportation-info {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 25px; margin-top: 20px; }}
        .transport-card {{ background-color: white; border-radius: 12px; padding: 25px; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05); border-top: 5px solid var(--primary); }}
        .transport-title {{ font-size: 1.3rem; color: var(--primary-dark); margin-bottom: 15px; display: flex; align-items: center; }}
        .transport-title i {{ margin-right: 10px; font-size: 1.4rem; color: var(--primary); }}
        .transport-list {{ list-style: none; margin: 0; padding: 0; }}
        .transport-list li {{ padding: 10px 0; border-bottom: 1px solid #eee; display: flex; align-items: center; }}
        .transport-list li:last-child {{ border-bottom: none; }}
        .transport-list i {{ color: var(--primary); margin-right: 10px; }}
        .center-coords {{ display: inline-block; background-color: rgba(0,0,0,0.05); /* Adjusted for better contrast */ border-radius: 6px; padding: 3px 8px; margin: 0 5px; font-family: monospace; font-size: 0.9rem; }}
        .footer {{ text-align: center; background-color: var(--primary-dark); color: white; padding: 20px 0; margin-top: 50px; }}
        .back-button {{ display: inline-flex; align-items: center; justify-content: center; background-color: white; color: var(--primary); border: 2px solid var(--primary); padding: 12px 24px; border-radius: 8px; text-decoration: none; font-weight: 600; font-size: 1rem; transition: var(--transition); margin-top: 30px; }}
        .back-button:hover {{ background-color: var(--primary); color: white; transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); }}
        .back-button i {{ margin-right: 8px; }}

        /* ========== AI Reasoning Panel - Light Theme (Matches Page) ========== */
        .search-process-card {{
            position: relative;
            overflow: hidden;
            background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
            border: 2px solid var(--brand-secondary, #06D6A0);
            border-radius: 20px;
            box-shadow: 0 4px 24px rgba(10, 77, 104, 0.08), 0 1px 3px rgba(10, 77, 104, 0.05);
        }}
        .search-process-card::before {{
            content: '';
            position: absolute;
            top: 0; left: 0; right: 0; height: 4px;
            background: linear-gradient(90deg, var(--brand-secondary, #06D6A0) 0%, var(--brand-primary, #0A4D68) 50%, var(--brand-accent, #FF6B35) 100%);
        }}

        /* Collapsible AI Thinking Section */
        .ai-thinking-details {{ width: 100%; position: relative; z-index: 1; }}
        .ai-thinking-summary {{
            display: flex;
            align-items: center;
            gap: 16px;
            padding: 20px 24px;
            cursor: pointer;
            list-style: none;
            user-select: none;
            background: linear-gradient(135deg, rgba(6, 214, 160, 0.06) 0%, rgba(10, 77, 104, 0.04) 100%);
            border-radius: 16px;
            margin: 8px;
            transition: all 0.3s ease;
            border: 1px solid transparent;
        }}
        .ai-thinking-summary::-webkit-details-marker {{ display: none; }}
        .ai-thinking-summary:hover {{
            background: linear-gradient(135deg, rgba(6, 214, 160, 0.12) 0%, rgba(10, 77, 104, 0.08) 100%);
            border-color: rgba(6, 214, 160, 0.3);
            box-shadow: 0 4px 16px rgba(6, 214, 160, 0.12);
        }}

        /* AI Brain Icon with Pulse */
        .ai-brain-icon {{
            position: relative;
            width: 48px;
            height: 48px;
            display: flex;
            align-items: center;
            justify-content: center;
            background: linear-gradient(135deg, var(--brand-secondary, #06D6A0) 0%, var(--brand-primary, #0A4D68) 100%);
            border-radius: 12px;
            box-shadow: 0 4px 12px rgba(6, 214, 160, 0.3);
            flex-shrink: 0;
        }}
        .ai-brain-icon i {{
            font-size: 1.6rem;
            color: white;
        }}
        .ai-brain-icon::before {{
            content: '';
            position: absolute;
            inset: -2px;
            background: linear-gradient(135deg, var(--brand-secondary, #06D6A0), var(--brand-primary, #0A4D68));
            border-radius: 14px;
            z-index: -1;
            opacity: 0.5;
            animation: aiPulse 2s ease-in-out infinite;
        }}
        @keyframes aiPulse {{
            0%, 100% {{ opacity: 0.3; transform: scale(1); }}
            50% {{ opacity: 0.6; transform: scale(1.03); }}
        }}

        /* Title and Badge */
        .ai-thinking-content {{
            flex: 1;
            display: flex;
            flex-direction: column;
            gap: 4px;
            min-width: 0;
        }}
        .ai-thinking-header {{
            display: flex;
            align-items: center;
            gap: 10px;
            flex-wrap: wrap;
        }}
        .ai-thinking-title {{
            font-size: 1.2rem;
            font-weight: 700;
            color: var(--brand-primary-dark, #05445E);
            font-family: 'DM Sans', sans-serif;
            letter-spacing: -0.01em;
        }}
        .ai-thinking-badge {{
            display: inline-flex;
            align-items: center;
            gap: 5px;
            padding: 3px 10px;
            background: linear-gradient(135deg, rgba(6, 214, 160, 0.15) 0%, rgba(10, 77, 104, 0.1) 100%);
            border: 1px solid rgba(6, 214, 160, 0.4);
            border-radius: 20px;
            font-size: 0.65rem;
            font-weight: 700;
            color: var(--brand-primary, #0A4D68);
            text-transform: uppercase;
            letter-spacing: 0.08em;
        }}
        .ai-thinking-badge::before {{
            content: '';
            width: 6px;
            height: 6px;
            background: var(--brand-secondary, #06D6A0);
            border-radius: 50%;
            animation: livePulse 1.5s ease-in-out infinite;
        }}
        @keyframes livePulse {{
            0%, 100% {{ opacity: 1; transform: scale(1); }}
            50% {{ opacity: 0.5; transform: scale(0.85); }}
        }}
        .ai-thinking-hint {{
            font-size: 0.82rem;
            color: var(--text-secondary, #4B5563);
            font-weight: 400;
        }}

        /* Expand Button */
        .ai-thinking-expand {{
            display: flex;
            align-items: center;
            gap: 6px;
            padding: 8px 14px;
            background: rgba(10, 77, 104, 0.06);
            border: 1px solid rgba(10, 77, 104, 0.15);
            border-radius: 8px;
            color: var(--brand-primary, #0A4D68);
            font-size: 0.78rem;
            font-weight: 600;
            transition: all 0.25s ease;
            flex-shrink: 0;
        }}
        .ai-thinking-summary:hover .ai-thinking-expand {{
            background: var(--brand-secondary, #06D6A0);
            border-color: var(--brand-secondary, #06D6A0);
            color: white;
        }}
        .ai-thinking-arrow {{
            font-size: 1rem;
            transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
        }}
        .ai-thinking-details[open] .ai-thinking-arrow {{ transform: rotate(180deg); }}
        .ai-thinking-details[open] .ai-thinking-expand {{
            background: var(--brand-primary, #0A4D68);
            border-color: var(--brand-primary, #0A4D68);
            color: white;
        }}
        .ai-thinking-expand .collapse-text {{ display: none; }}
        .ai-thinking-details[open] .expand-text {{ display: none; }}
        .ai-thinking-details[open] .collapse-text {{ display: inline; }}

        /* Expanded State */
        .ai-thinking-details[open] .ai-thinking-summary {{
            border-radius: 16px 16px 0 0;
            margin-bottom: 0;
            border-bottom: 1px solid rgba(10, 77, 104, 0.1);
        }}
        .ai-thinking-details[open] .ai-thinking-badge::before {{
            background: var(--brand-accent, #FF6B35);
            animation: none;
        }}

        /* Content Area */
        .ai-thinking-details .search-process {{
            padding: 24px;
            background: linear-gradient(180deg, rgba(248, 250, 252, 1) 0%, rgba(255, 255, 255, 1) 100%);
            border-top: 1px solid rgba(10, 77, 104, 0.08);
            animation: fadeSlideIn 0.3s ease-out;
        }}
        @keyframes fadeSlideIn {{
            from {{ opacity: 0; transform: translateY(-8px); }}
            to {{ opacity: 1; transform: translateY(0); }}
        }}

        /* Step styles for light theme */
        .search-process-card .process-step {{ color: var(--text-primary, #111827); }}
        .search-process-card .step-title {{ color: var(--brand-primary-dark, #05445E); }}
        .search-process-card .step-details {{
            background: white;
            border: 1px solid rgba(10, 77, 104, 0.08);
            color: var(--text-secondary, #4B5563);
            box-shadow: 0 2px 8px rgba(10, 77, 104, 0.04);
        }}
        .search-process-card .step-icon {{
            background: linear-gradient(135deg, var(--brand-secondary, #06D6A0) 0%, var(--brand-primary, #0A4D68) 100%);
            box-shadow: 0 4px 12px rgba(6, 214, 160, 0.25);
        }}
        .search-process-card .step-number {{
            background: linear-gradient(135deg, var(--brand-primary, #0A4D68) 0%, var(--brand-primary-dark, #05445E) 100%);
        }}
        .search-process-card .highlight-text {{
            background: rgba(6, 214, 160, 0.15);
            color: var(--brand-primary-dark, #05445E);
        }}
        .search-process-card .code-block {{
            background: #1e293b;
            border: 1px solid rgba(10, 77, 104, 0.15);
            color: #e2e8f0;
        }}

        /* AI Location List */
        .ai-location-list {{ display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }}
        .ai-location-item {{ display: flex; align-items: center; gap: 12px; padding: 10px 14px; background: white; border-radius: 10px; border: 1px solid rgba(0,0,0,0.06); }}
        .ai-loc-num {{ width: 28px; height: 28px; background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%); color: white; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.85rem; }}
        .ai-loc-info {{ flex: 1; }}
        .ai-loc-info strong {{ display: block; color: var(--dark); font-size: 0.95rem; }}
        .ai-coords {{ font-size: 0.8rem; color: #64748b; font-family: 'SF Mono', 'Consolas', monospace; }}

        /* AI Algorithm Box */
        .ai-algo-box {{ background: white; border-radius: 12px; padding: 16px; margin: 12px 0; border: 1px solid rgba(10, 77, 104, 0.1); }}
        .ai-algo-formula {{ display: flex; align-items: center; gap: 12px; padding: 12px; background: linear-gradient(135deg, rgba(10, 77, 104, 0.05) 0%, rgba(6, 214, 160, 0.05) 100%); border-radius: 8px; }}
        .ai-algo-formula i {{ font-size: 1.8rem; color: var(--secondary); }}
        .ai-algo-label {{ font-size: 0.8rem; color: #64748b; display: block; }}
        .ai-algo-value {{ font-size: 1.1rem; font-weight: 700; color: var(--primary-dark); font-family: 'SF Mono', 'Consolas', monospace; }}
        .ai-algo-note {{ font-size: 0.85rem; color: #475569; margin-top: 10px; padding-left: 12px; border-left: 3px solid var(--secondary); }}

        /* AI Requirement Tags */
        .ai-req-detected {{ display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0; }}
        .ai-req-tag {{ display: inline-flex; align-items: center; gap: 4px; padding: 6px 14px; background: linear-gradient(135deg, var(--primary) 0%, var(--primary-light) 100%); color: white; border-radius: 20px; font-size: 0.85rem; font-weight: 600; }}

        /* AI Matching Layers */
        .ai-matching-layers {{ display: flex; flex-direction: column; gap: 8px; margin-top: 12px; padding: 12px; background: white; border-radius: 10px; }}
        .ai-layer {{ display: flex; align-items: center; gap: 10px; padding: 8px 0; }}
        .ai-layer-badge {{ padding: 4px 10px; border-radius: 6px; font-size: 0.75rem; font-weight: 700; color: white; }}
        .ai-layer-badge.high {{ background: linear-gradient(135deg, #10b981 0%, #059669 100%); }}
        .ai-layer-badge.medium {{ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); }}
        .ai-layer-badge.low {{ background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%); }}
        .ai-layer-conf {{ font-size: 0.75rem; color: #94a3b8; margin-left: 8px; }}

        /* AI Score Dimensions */
        .ai-score-dimensions {{ display: flex; flex-direction: column; gap: 12px; margin-top: 12px; padding: 16px; background: white; border-radius: 12px; }}
        .ai-dim {{ display: flex; flex-direction: column; gap: 4px; }}
        .ai-dim-header {{ display: flex; justify-content: space-between; align-items: center; }}
        .ai-dim-name {{ font-weight: 600; color: var(--dark); font-size: 0.9rem; }}
        .ai-dim-max {{ font-size: 0.8rem; color: #94a3b8; }}
        .ai-dim-bar {{ height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; }}
        .ai-dim-fill {{ height: 100%; background: linear-gradient(90deg, var(--primary) 0%, var(--secondary) 100%); border-radius: 4px; transition: width 1s ease; }}
        .ai-dim-desc {{ font-size: 0.75rem; color: #64748b; }}

        /* AI Top Results */
        .ai-top-results {{ display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }}
        .ai-place-result {{ display: flex; align-items: center; gap: 12px; padding: 14px 16px; background: white; border-radius: 12px; border: 1px solid rgba(0,0,0,0.06); transition: all 0.3s ease; }}
        .ai-place-result:hover {{ transform: translateX(4px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }}
        .ai-place-rank {{ font-size: 1.5rem; }}
        .ai-place-info {{ flex: 1; }}
        .ai-place-name {{ font-weight: 700; color: var(--dark); font-size: 1rem; margin-bottom: 2px; }}
        .ai-place-score {{ display: flex; align-items: baseline; }}
        .ai-total-score {{ font-size: 1.3rem; font-weight: 800; color: var(--primary); }}
        .ai-score-max {{ font-size: 0.85rem; color: #94a3b8; }}
        .ai-place-breakdown {{ display: flex; gap: 8px; flex-wrap: wrap; }}
        .ai-place-breakdown span {{ font-size: 0.75rem; padding: 4px 8px; background: #f1f5f9; border-radius: 6px; color: #475569; cursor: help; }}
        .ai-place-reqs {{ display: flex; gap: 6px; margin-top: 6px; }}
        .ai-conf-badge {{ font-size: 0.7rem; padding: 3px 8px; border-radius: 10px; font-weight: 600; }}
        .ai-conf-badge.high {{ background: rgba(16, 185, 129, 0.15); color: #059669; }}
        .ai-conf-badge.medium {{ background: rgba(245, 158, 11, 0.15); color: #b45309; }}
        .ai-conf-badge.low {{ background: rgba(148, 163, 184, 0.15); color: #475569; }}
        .search-process {{ position: relative; padding: 20px 0; }}
        .process-step {{ display: flex; margin-bottom: 30px; opacity: 0.5; transform: translateX(-20px); transition: opacity 0.5s ease, transform 0.5s ease; }}
        .process-step.active {{ opacity: 1; transform: translateX(0); }}
        .step-icon {{ flex: 0 0 60px; height: 60px; border-radius: 50%; background-color: var(--primary-light); display: flex; align-items: center; justify-content: center; color: white; font-size: 1.5rem; margin-right: 20px; position: relative; }}
        .step-number {{ position: absolute; top: -5px; right: -5px; width: 25px; height: 25px; border-radius: 50%; background-color: var(--primary-dark); color: white; display: flex; align-items: center; justify-content: center; font-size: 0.8rem; font-weight: bold; }}
        .step-content {{ flex: 1; }}
        .step-title {{ font-size: 1.3rem; color: var(--primary-dark); margin-bottom: 10px; }}
        .step-details {{ background-color: white; border-radius: 10px; padding: 15px; box-shadow: 0 3px 10px rgba(0,0,0,0.05); }}
        .code-block {{ background-color: #2c3e50; color: #e6e6e6; padding: 15px; border-radius: 8px; font-family: monospace; font-size: 0.9rem; margin: 15px 0; white-space: pre; overflow-x: auto; }}
        .highlight-text {{ background-color: rgba(6, 214, 160, 0.2); color: var(--brand-primary-dark, #05445E); padding: 3px 6px; border-radius: 4px; font-weight: bold; }}  /* 薄荷绿高亮 */
        .search-animation {{ height: 200px; position: relative; display: flex; align-items: center; justify-content: center; margin: 20px 0; }}
        .radar-circle {{ position: absolute; width: 50px; height: 50px; border-radius: 50%; background-color: rgba(10, 77, 104, 0.1); animation: radar 3s infinite; }}  /* 深海蓝脉冲 */
        .radar-circle:nth-child(1) {{ animation-delay: 0s; }} .radar-circle:nth-child(2) {{ animation-delay: 1s; }} .radar-circle:nth-child(3) {{ animation-delay: 2s; }}
        .center-point {{ width: 15px; height: 15px; border-radius: 50%; background-color: var(--brand-accent, #FF6B35); z-index: 2; box-shadow: 0 0 0 5px rgba(255, 107, 53, 0.3); }}  /* 日落橙中心点 */
        .map-operation-animation {{ height: 200px; position: relative; border-radius: 8px; overflow: hidden; background-color: #f5f5f5; margin: 20px 0; box-shadow: 0 3px 10px rgba(0,0,0,0.1); }}
        .map-bg {{ position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect width="100" height="100" fill="%23f0f0f0"/><path d="M0,0 L100,0 L100,100 L0,100 Z" fill="none" stroke="%23ccc" stroke-width="0.5"/><path d="M50,0 L50,100 M0,50 L100,50" stroke="%23ccc" stroke-width="0.5"/></svg>'); background-size: 50px 50px; opacity: 0.7; }}
        .map-cursor {{ position: absolute; width: 20px; height: 20px; background-color: rgba(255, 107, 53, 0.7); border-radius: 50%; top: 50%; left: 30%; transform: translate(-50%, -50%); animation: mapCursor 4s infinite ease-in-out; z-index: 2; }}  /* 日落橙光标 */
        .map-search-indicator {{ position: absolute; width: 80px; height: 80px; border: 2px dashed rgba(10, 77, 104, 0.6); border-radius: 50%; top: 50%; left: 50%; transform: translate(-50%, -50%); animation: mapSearch 3s infinite ease-in-out; z-index: 1; }}  /* 深海蓝搜索圈 */
        @keyframes mapCursor {{ 0% {{ left: 30%; top: 30%; }} 30% {{ left: 60%; top: 40%; }} 60% {{ left: 40%; top: 70%; }} 100% {{ left: 30%; top: 30%; }} }}
        @keyframes mapSearch {{ 0% {{ width: 30px; height: 30px; opacity: 1; }} 100% {{ width: 150px; height: 150px; opacity: 0; }} }}
        @keyframes radar {{ 0% {{ width: 40px; height: 40px; opacity: 1; }} 100% {{ width: 300px; height: 300px; opacity: 0; }} }}
        .ranking-result {{ margin-top: 15px; }}
        .result-bar {{ height: 30px; background-color: var(--primary); color: white; margin-bottom: 8px; border-radius: 15px; padding: 0 15px; display: flex; align-items: center; font-weight: 600; box-shadow: 0 2px 5px rgba(0,0,0,0.1); animation: growBar 2s ease; transform-origin: left; }}
        @keyframes growBar {{ 0% {{ width: 0; }} 100% {{ width: 100%; }} }}
        .mt-4 {{ margin-top: 1rem; }}
        /* Fallback Notice */
        .fallback-notice {{
            background: linear-gradient(135deg, #FFF3E0 0%, #FFE0B2 100%);
            border-left: 4px solid #FF9800;
            padding: 16px 24px;
            margin: 0 auto 20px;
            max-width: 1200px;
            border-radius: 0 12px 12px 0;
            display: flex;
            align-items: center;
            gap: 12px;
            box-shadow: 0 2px 8px rgba(255, 152, 0, 0.15);
        }}
        .fallback-notice i {{
            font-size: 24px;
            color: #F57C00;
        }}
        .fallback-notice-text {{
            color: #E65100;
            font-weight: 500;
            font-size: 15px;
        }}
        .fallback-notice-keyword {{
            font-weight: 700;
            color: #BF360C;
        }}
        @media (max-width: 768px) {{ .cafe-grid {{ grid-template-columns: 1fr; }} .transportation-info {{ grid-template-columns: 1fr; }} header {{ padding: 40px 0 80px; }} .header-logo {{ font-size: 2.2rem; }} .process-step {{ flex-direction: column; }} .step-icon {{ margin-bottom: 15px; margin-right: 0; }} .fallback-notice {{ margin: 0 16px 16px; }} }}
    </style>
</head>
<body>
    <header>
        <div class="container">
            <div class="header-logo">
                <i class='bx {cfg["icon_header"]} coffee-icon'></i>{cfg["topic"]}
            </div>
            <div class="header-subtitle">为您找到的最佳会面{cfg["noun_plural"]}</div>
        </div>
    </header>

    {f'''<div class="fallback-notice">
        <i class="bx bx-info-circle"></i>
        <span class="fallback-notice-text">
            未找到「{keywords}」相关场所，已为您推荐附近的「<span class="fallback-notice-keyword">{fallback_keyword}</span>」
        </span>
    </div>''' if fallback_used and fallback_keyword else ''}

    <div class="container main-content">
        <div class="card glass-card">
            <h2 class="section-title"><i class='bx bx-info-circle'></i>推荐摘要</h2>
            <div class="summary-card">
                <div class="summary-item">
                    <div class="summary-label">参与地点数</div>
                    <div class="summary-value">{len(locations)} 个地点</div>
                </div>
                <div class="summary-item">
                    <div class="summary-label">推荐{cfg["noun_plural"]}数</div>
                    <div class="summary-value">{len(places)} 家{cfg["noun_plural"]}</div>
                </div>
                <div class="summary-item">
                    <div class="summary-label">特殊需求</div>
                    <div class="summary-value">{user_requirements or "无特殊需求"}</div>
                </div>
            </div>
        </div>
        {search_process_html}
        <div class="card glass-card">
            <h2 class="section-title"><i class='bx bx-map-pin'></i>地点信息</h2>
            <table class="location-table">
                <thead><tr><th>序号</th><th>地点名称</th><th>详细地址</th></tr></thead>
                <tbody>{location_rows_html}</tbody>
            </table>
        </div>
        <div class="card glass-card">
            <h2 class="section-title"><i class='bx bx-map-alt'></i>地图展示</h2>
            <div class="map-container">
                <div id="map"></div>
                <div class="map-legend">
                    <div class="legend-item"><div class="legend-color legend-center"></div><span>最佳会面点</span></div>
                    <div class="legend-item"><div class="legend-color legend-location"></div><span>所在地点</span></div>
                    <div class="legend-item"><div class="legend-color legend-place"></div><span>{cfg["map_legend"]}</span></div>
                </div>
            </div>
        </div>
        <div class="card glass-card">
            <h2 class="section-title"><i class='bx {cfg["icon_section"]}'></i>推荐{cfg["noun_plural"]}</h2>
            <div class="cafe-grid">
                {place_cards_html}
            </div>
        </div>
        <div class="card glass-card">
            <h2 class="section-title"><i class='bx bx-car'></i>交通与停车建议</h2>
            <div class="transportation-info">
                <div class="transport-card">
                    <h3 class="transport-title"><i class='bx bx-trip'></i>前往方式</h3>
                    <p>最佳会面点位于<span class="center-coords">{center_point[0]:.6f}, {center_point[1]:.6f}</span>附近</p>
                    <ul class="transport-list">{location_distance_html}</ul>
                </div>
                <div class="transport-card">
                    <h3 class="transport-title"><i class='bx bxs-car-garage'></i>智能出行建议</h3>
                    <ul class="transport-list">
                        {transport_tips_html}
                    </ul>
                </div>
            </div>
            <a href="/" class="btn-modern btn-primary-modern">
                <i class='bx bx-left-arrow-alt'></i>返回首页
            </a>
        </div>
    </div>
    <footer class="footer">
        <div class="container">
            <p>© {datetime.now().year} {cfg["topic"]} - 智能{cfg["noun_singular"]}推荐服务 | 数据来源：高德地图</p>
        </div>
    </footer>
    <script type="text/javascript">
        var markersData = {markers_json};
        window._AMapSecurityConfig = {{ securityJsCode: "{amap_security_js_code}" }};
        window.onload = function() {{
            var script = document.createElement('script');
            script.type = 'text/javascript';
            script.src = 'https://webapi.amap.com/loader.js';
            script.onload = function() {{
                AMapLoader.load({{
                    key: "{self.api_key}", 
                    version: "2.0",
                    plugins: ["AMap.Scale", "AMap.ToolBar"],
                    AMapUI: {{ version: "1.1", plugins: ["overlay/SimpleMarker"] }}
                }})
                .then(function(AMap) {{ initMap(AMap); }})
                .catch(function(e) {{ console.error('地图加载失败:', e); }});
            }};
            document.body.appendChild(script);
            animateCafeCards(); 
        }};
        function initMap(AMap) {{
            var map = new AMap.Map('map', {{
                zoom: 12, center: [{center_point[0]}, {center_point[1]}],
                resizeEnable: true, viewMode: '3D'
            }});
            map.addControl(new AMap.ToolBar()); map.addControl(new AMap.Scale());
            var mapMarkers = []; 
            markersData.forEach(function(item) {{
                var markerContent, position = new AMap.LngLat(item.position[0], item.position[1]);
                var color = '#e74c3c';
                var labelText = '';
                if (item.icon === 'center') {{
                    color = '#2ecc71';
                    labelText = '最佳会面点';
                }} else if (item.icon === 'location') {{
                    color = '#3498db';
                    // Extract location name from "地点N: XXX" format
                    labelText = item.name.includes(': ') ? item.name.split(': ')[1] : item.name;
                }}

                // For center and location markers, show label with name
                if (item.icon === 'center' || item.icon === 'location') {{
                    markerContent = `<div style="display:flex;flex-direction:column;align-items:center;">
                        <div style="background-color: ${{color}}; width: 28px; height: 28px; border-radius: 14px; border: 3px solid white; box-shadow: 0 2px 8px rgba(0,0,0,0.3);"></div>
                        <div style="background: white; padding: 4px 8px; border-radius: 4px; margin-top: 4px; font-size: 12px; font-weight: bold; color: #333; box-shadow: 0 2px 6px rgba(0,0,0,0.15); white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis;">${{labelText}}</div>
                    </div>`;
                }} else {{
                    markerContent = `<div style="background-color: ${{color}}; width: 24px; height: 24px; border-radius: 12px; border: 2px solid white; box-shadow: 0 0 5px rgba(0,0,0,0.3);"></div>`;
                }}

                var marker = new AMap.Marker({{
                    position: position, content: markerContent,
                    title: item.name, anchor: 'center', offset: new AMap.Pixel(0, item.icon === 'place' ? 0 : -20)
                }});
                var infoWindow = new AMap.InfoWindow({{
                    content: '<div style="padding:10px;font-size:14px;">' + item.name + '</div>',
                    offset: new AMap.Pixel(0, -12)
                }});
                marker.on('click', function() {{ infoWindow.open(map, marker.getPosition()); }});
                mapMarkers.push(marker);
                marker.setMap(map);
            }});
            if (markersData.length > 1) {{
                var pathCoordinates = [];
                markersData.filter(item => item.icon !== 'place').forEach(function(item) {{ 
                    pathCoordinates.push(new AMap.LngLat(item.position[0], item.position[1]));
                }});
                if (pathCoordinates.length > 1) {{ 
                    var polyline = new AMap.Polyline({{
                        path: pathCoordinates, strokeColor: '#3498db', strokeWeight: 4,
                        strokeStyle: 'dashed', strokeDasharray: [5, 5], lineJoin: 'round'
                    }});
                    polyline.setMap(map);
                }}
            }}
            if (mapMarkers.length > 0) {{ 
                 map.setFitView(mapMarkers);
            }}
        }}
        function animateCafeCards() {{
            const cards = document.querySelectorAll('.cafe-card');
            if ('IntersectionObserver' in window) {{
                const observer = new IntersectionObserver((entries) => {{
                    entries.forEach(entry => {{
                        if (entry.isIntersecting) {{
                            entry.target.style.opacity = 1;
                            entry.target.style.transform = 'translateY(0)';
                            observer.unobserve(entry.target);
                        }}
                    }});
                }}, {{ threshold: 0.1 }});
                cards.forEach((card, index) => {{
                    card.style.opacity = 0; card.style.transform = 'translateY(30px)';
                    card.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
                    card.style.transitionDelay = (index * 0.1) + 's';
                    observer.observe(card);
                }});
            }} else {{
                cards.forEach((card, index) => {{
                    card.style.opacity = 0; card.style.transform = 'translateY(30px)';
                    card.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
                    setTimeout(() => {{ card.style.opacity = 1; card.style.transform = 'translateY(0)'; }}, 300 + (index * 100));
                }});
            }}
        }}
    </script>

    <!-- Modern Toast Notification System -->
    <script src="/public/js/toast.js"></script>
</body>
</html>"""
        return html_content

    def _format_result_text(
        self,
        locations: List[Dict],
        places: List[Dict],
        html_path: str,
        keywords: str,
        fallback_used: bool = False,
        fallback_keyword: str = None
    ) -> str:
        primary_keyword = keywords.split("、")[0] if keywords else "场所"
        cfg = self._get_place_config(primary_keyword)
        num_places = len(places)

        result = [
            f"## 已为您找到{num_places}家适合会面的{cfg['noun_plural']}",
            "",
        ]

        # 添加 Fallback 提示
        if fallback_used and fallback_keyword:
            result.append(f"> 提示：未找到「{keywords}」相关场所，已为您推荐附近的「{fallback_keyword}」")
            result.append("")

        result.append(f"### 推荐{cfg['noun_plural']}:")
        for i, place in enumerate(places):
            rating = place.get("biz_ext", {}).get("rating", "暂无评分")
            address = place.get("address", "地址未知")
            result.append(f"{i+1}. **{place['name']}** (评分: {rating})")
            result.append(f"   地址: {address}")
            result.append("")
        
        html_file_basename = os.path.basename(html_path)
        result.append(f"HTML页面: {html_file_basename}") 
        result.append(f"可在浏览器中打开查看详细地图和{cfg['noun_plural']}信息。") 

        return "\n".join(result)

    def _generate_search_process(
        self,
        locations: List[Dict],
        center_point: Tuple[float, float],
        user_requirements: str,
        keywords: str,
        places: List[Dict] = None  # 新增：传入推荐结果用于显示评分详情
    ) -> str:
        primary_keyword = keywords.split("、")[0] if keywords else "场所"
        cfg = self._get_place_config(primary_keyword)
        search_steps = []

        # Step 1: 位置分析 - 显示坐标信息
        location_analysis = "<div class='ai-location-list'>"
        for idx, loc in enumerate(locations):
            lng, lat = loc.get('lng', 0), loc.get('lat', 0)
            location_analysis += f"""
            <div class='ai-location-item'>
                <span class='ai-loc-num'>{idx+1}</span>
                <div class='ai-loc-info'>
                    <strong>{loc['name']}</strong>
                    <span class='ai-coords'>({lat:.4f}°N, {lng:.4f}°E)</span>
                </div>
            </div>"""
        location_analysis += "</div>"
        search_steps.append({
            "icon": "bx-map-pin", "title": "Step 1: 位置解析与地理编码",
            "content": f"<p>成功解析 <span class='highlight-text'>{len(locations)}</span> 个地点坐标，准备计算最优会面点...</p>{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"""
            <p>使用 <span class='highlight-text'>{algo_type}</span> 计算最优会面点：</p>
            <div class="ai-algo-box">
                <div class="ai-algo-formula">
                    <i class='bx bx-target-lock'></i>
                    <div>
                        <span class="ai-algo-label">最佳会面点坐标</span>
                        <span class="ai-algo-value">{center_lat:.6f}°N, {center_lng:.6f}°E</span>
                    </div>
                </div>
                <div class="ai-algo-note">
                    {f'采用球面几何学计算两点间的真实大圆中点，比简单平均更精确' if len(locations) == 2 else f'计算{len(locations)}个位置的地理质心，确保对所有人公平'}
                </div>
            </div>
            <div class="map-operation-animation">
                <div class="map-bg"></div> <div class="map-cursor"></div> <div class="map-search-indicator"></div>
            </div>"""
        })

        # Step 3: 需求解析 - 显示三层匹配机制
        requirement_analysis = ""
        if user_requirements:
            requirement_keywords_map = {
                "停车": ["停车", "车位", "停车场"], "安静": ["安静", "环境好", "氛围"],
                "商务": ["商务", "会议", "办公"], "交通": ["交通", "地铁", "公交"],
                "WiFi": ["wifi", "无线", "网络"], "包间": ["包间", "私密", "独立"]
            }
            detected_requirements = [key for key, kw_list in requirement_keywords_map.items() if any(kw.lower() in user_requirements.lower() for kw in kw_list)]
            if detected_requirements:
                req_tags = "".join([f"<span class='ai-req-tag'>{req}</span>" for req in detected_requirements])
                requirement_analysis = f"""
                <p>从您的需求 "<em>{user_requirements}</em>" 中识别到：</p>
                <div class="ai-req-detected">{req_tags}</div>
                <div class="ai-matching-layers">
                    <div class="ai-layer">
                        <span class="ai-layer-badge high">Layer 1</span>
                        <span>POI标签匹配 <span class="ai-layer-conf">高置信度</span></span>
                    </div>
                    <div class="ai-layer">
                        <span class="ai-layer-badge medium">Layer 2</span>
                        <span>品牌知识库匹配 <span class="ai-layer-conf">中置信度</span></span>
                    </div>
                    <div class="ai-layer">
                        <span class="ai-layer-badge low">Layer 3</span>
                        <span>场所类型推断 <span class="ai-layer-conf">低置信度</span></span>
                    </div>
                </div>"""
            else:
                requirement_analysis = f"<p>未检测到特定需求关键词，将基于综合评分推荐最佳{cfg['noun_plural']}。</p>"
        else:
            requirement_analysis = f"<p>未提供特殊需求，将使用多维度评分系统推荐{cfg['noun_plural']}。</p>"
        search_steps.append({"icon": "bx-brain", "title": "Step 3: 需求语义解析", "content": requirement_analysis})

        # Step 4: 场所检索
        search_places_explanation = f"""
        <p>以最佳会面点为圆心，在 <span class='highlight-text'>2公里</span> 范围内检索 "{primary_keyword}" 相关场所...</p>
        <div class="search-animation">
            <div class="radar-circle"></div> <div class="radar-circle"></div> <div class="radar-circle"></div>
            <div class="center-point"></div>
        </div>"""
        search_steps.append({"icon": "bx-search-alt", "title": f"Step 4: POI检索", "content": search_places_explanation})

        # Step 5: 智能评分 - 显示评分维度
        ranking_explanation = f"""
        <p>使用 <span class='highlight-text'>V2 多维度评分系统</span> 对候选场所进行智能排序：</p>
        <div class="ai-score-dimensions">
            <div class="ai-dim">
                <div class="ai-dim-header">
                    <span class="ai-dim-name">基础分</span>
                    <span class="ai-dim-max">30分</span>
                </div>
                <div class="ai-dim-bar"><div class="ai-dim-fill" style="width: 100%;"></div></div>
                <span class="ai-dim-desc">商家评分 × 6</span>
            </div>
            <div class="ai-dim">
                <div class="ai-dim-header">
                    <span class="ai-dim-name">距离分</span>
                    <span class="ai-dim-max">25分</span>
                </div>
                <div class="ai-dim-bar"><div class="ai-dim-fill" style="width: 83%;"></div></div>
                <span class="ai-dim-desc">非线性衰减，500m内满分</span>
            </div>
            <div class="ai-dim">
                <div class="ai-dim-header">
                    <span class="ai-dim-name">热度分</span>
                    <span class="ai-dim-max">20分</span>
                </div>
                <div class="ai-dim-bar"><div class="ai-dim-fill" style="width: 67%;"></div></div>
                <span class="ai-dim-desc">评论数(log) + 图片数</span>
            </div>
            <div class="ai-dim">
                <div class="ai-dim-header">
                    <span class="ai-dim-name">场景分</span>
                    <span class="ai-dim-max">15分</span>
                </div>
                <div class="ai-dim-bar"><div class="ai-dim-fill" style="width: 50%;"></div></div>
                <span class="ai-dim-desc">关键词匹配度</span>
            </div>
            <div class="ai-dim">
                <div class="ai-dim-header">
                    <span class="ai-dim-name">需求分</span>
                    <span class="ai-dim-max">10分</span>
                </div>
                <div class="ai-dim-bar"><div class="ai-dim-fill" style="width: 33%;"></div></div>
                <span class="ai-dim-desc">三层匹配算法</span>
            </div>
        </div>"""
        search_steps.append({"icon": "bx-calculator", "title": "Step 5: 多维度智能评分", "content": ranking_explanation})

        # Step 6: 评分结果 - 显示Top 3场所的评分详情
        if places and len(places) > 0:
            top_places_html = "<div class='ai-top-results'>"
            for idx, place in enumerate(places[:3]):
                name = place.get('name', '未知')
                total_score = place.get('_score', 0)
                breakdown = place.get('_score_breakdown', {})
                matched_reqs = place.get('_matched_requirements', [])
                confidence_map = place.get('_requirement_confidence', {})

                # 评分详情
                base = breakdown.get('base_score', 0)
                dist = breakdown.get('distance_score', 0)
                pop = breakdown.get('popularity_score', 0)
                scene = breakdown.get('scenario_score', 0)
                req = breakdown.get('requirement_score', 0)

                # 需求匹配标签
                req_badges = ""
                if matched_reqs:
                    for r in matched_reqs[:3]:
                        conf = confidence_map.get(r, 'low')
                        req_badges += f"<span class='ai-conf-badge {conf}'>{r}</span>"

                medal = ["🥇", "🥈", "🥉"][idx]
                top_places_html += f"""
                <div class="ai-place-result">
                    <div class="ai-place-rank">{medal}</div>
                    <div class="ai-place-info">
                        <div class="ai-place-name">{name}</div>
                        <div class="ai-place-score">
                            <span class="ai-total-score">{total_score:.0f}</span><span class="ai-score-max">/100</span>
                        </div>
                    </div>
                    <div class="ai-place-breakdown">
                        <span title="基础分">⭐{base:.0f}</span>
                        <span title="距离分">📍{dist:.0f}</span>
                        <span title="热度分">🔥{pop:.0f}</span>
                        <span title="场景分">🎯{scene:.0f}</span>
                        <span title="需求分">✓{req:.0f}</span>
                    </div>
                    {f'<div class="ai-place-reqs">{req_badges}</div>' if req_badges else ''}
                </div>"""
            top_places_html += "</div>"
            search_steps.append({
                "icon": "bx-trophy",
                "title": "Step 6: 推荐结果",
                "content": f"<p>经过智能评分，为您推荐以下最佳会面地点：</p>{top_places_html}"
            })
        else:
            search_steps.append({
                "icon": "bx-trophy",
                "title": "Step 6: 推荐结果",
                "content": f"<p>正在生成{cfg['noun_plural']}推荐结果...</p>"
            }) 

        search_process_html = ""
        for idx, step in enumerate(search_steps):
            search_process_html += f"""
            <div class="process-step" data-step="{idx+1}">
                <div class="step-icon"><i class='bx {step["icon"]}'></i><div class="step-number">{idx+1}</div></div>
                <div class="step-content"><h3 class="step-title">{step["title"]}</h3><div class="step-details">{step["content"]}</div></div>
            </div>"""

        search_process_javascript = """
        <script>
        document.addEventListener('DOMContentLoaded', function() {
            const steps = document.querySelectorAll('.process-step');
            let currentStep = 0;
            function showNextStep() {
                if (currentStep < steps.length) {
                    steps[currentStep].classList.add('active');
                    currentStep++;
                    setTimeout(showNextStep, 1500); 
                }
            }
            setTimeout(showNextStep, 500); 
        });
        </script>"""
        return f"""
        <div class="card glass-card search-process-card">
            <details class="ai-thinking-details">
                <summary class="ai-thinking-summary">
                    <div class="ai-brain-icon">
                        <i class='bx bx-brain'></i>
                    </div>
                    <div class="ai-thinking-content">
                        <div class="ai-thinking-header">
                            <span class="ai-thinking-title">AI 搜索过程</span>
                            <span class="ai-thinking-badge">Explainable</span>
                        </div>
                        <span class="ai-thinking-hint">点击展开 Agent 思维链可视化</span>
                    </div>
                    <div class="ai-thinking-expand">
                        <span class="expand-text">展开</span>
                        <span class="collapse-text">收起</span>
                        <i class='bx bx-chevron-down ai-thinking-arrow'></i>
                    </div>
                </summary>
                <div class="search-process">{search_process_html}</div>
            </details>
            {search_process_javascript}
        </div>"""

