import sys
import os
import time
import asyncio
import re
import json
import gc
from typing import List, Optional
# 并发控制:防止OOM,保证每个请求都能完成
MAX_CONCURRENT_REQUESTS = 3 # 最大同时处理请求数
_request_semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
# 添加项目根目录到 Python 路径
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
# Load environment variables from .env file
from dotenv import load_dotenv
load_dotenv()
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
# WhiteNoise将通过StaticFiles中间件集成,不需要ASGI↔WSGI转换
from api.routers import auth, seo_pages, miniprogram
# 导入应用模块
try:
from app.config import config
from app.tool.meetspot_recommender import CafeRecommender
from app.logger import logger
from app.db.database import init_db
print("✅ 成功导入所有必要模块")
config_available = True
except ImportError as e:
print(f"⚠️ 导入模块警告: {e}")
config = None
config_available = False
# 创建 fallback logger(当 app.logger 导入失败时)
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("meetspot")
# Fallback for init_db
async def init_db():
logger.warning("Database module not available, skipping init")
# 导入 Agent 模块(高内存消耗,暂时禁用以保证稳定性)
agent_available = False # 禁用 Agent 模式,节省内存
# try:
# from app.agent import MeetSpotAgent, create_meetspot_agent
# agent_available = True
# print("✅ 成功导入 Agent 模块")
# except ImportError as e:
# print(f"⚠️ Agent 模块导入失败: {e}")
print("ℹ️ Agent 模块已禁用(节省内存)")
def create_meetspot_agent():
"""Stub function - Agent模式已禁用,此函数不应被调用"""
raise RuntimeError("Agent模式已禁用,请使用规则模式")
# 导入 LLM 模块
llm_available = False
llm_instance = None
try:
from app.llm import LLM
from app.schema import Message
llm_available = True
print("✅ 成功导入 LLM 模块")
except ImportError as e:
print(f"⚠️ LLM 模块导入失败: {e}")
# 在Vercel环境下创建最小化配置类
class MinimalConfig:
class AMapSettings:
def __init__(self, api_key):
self.api_key = api_key
def __init__(self):
amap_key = os.getenv("AMAP_API_KEY", "")
if amap_key:
self.amap = self.AMapSettings(amap_key)
else:
self.amap = None
if os.getenv("AMAP_API_KEY"):
config = MinimalConfig()
config_available = True
print("✅ 创建最小化配置(仅高德地图)")
else:
print("❌ 未找到AMAP_API_KEY环境变量")
# 在Vercel环境下导入最小化推荐器
if not config_available and os.getenv("AMAP_API_KEY"):
try:
# 创建最小化推荐器
import asyncio
import httpx
import json
import hashlib
import time
from datetime import datetime
class MinimalCafeRecommender:
"""最小化推荐器,专为Vercel环境设计"""
def __init__(self):
self.api_key = os.getenv("AMAP_API_KEY")
self.base_url = "https://restapi.amap.com/v3"
async def execute(self, locations, keywords="咖啡馆", place_type="", user_requirements=""):
"""执行推荐"""
try:
# 简化的推荐逻辑
result_html = await self._generate_recommendations(
locations, keywords, user_requirements
)
# 生成HTML文件
html_filename = f"place_recommendation_{datetime.now().strftime('%Y%m%d%H%M%S')}_{hashlib.md5(str(time.time()).encode()).hexdigest()[:8]}.html"
html_path = f"workspace/js_src/{html_filename}"
# 确保目录存在
os.makedirs("workspace/js_src", exist_ok=True)
# 写入HTML文件
with open(html_path, 'w', encoding='utf-8') as f:
f.write(result_html)
# 返回结果对象
class Result:
def __init__(self, output):
self.output = output
return Result(f"生成的推荐页面:{html_path}\nHTML页面: {html_filename}")
except Exception as e:
return Result(f"推荐失败: {str(e)}")
async def _generate_recommendations(self, locations, keywords, user_requirements):
"""生成推荐HTML"""
# 简化的HTML模板
html_content = f"""
MeetSpot 推荐结果
📍 您的位置信息
位置: {', '.join(locations)}
需求: {keywords}
{f'
特殊要求: {user_requirements}
' if user_requirements else ''}
💡 推荐建议
由于在Vercel环境下运行,推荐功能已简化。建议您:
- 选择位置中心点附近的{keywords}
- 考虑交通便利性和停车条件
- 选择环境舒适、适合交流的场所
⚠️ 注意事项
当前运行在简化模式下。如需完整功能,请在本地环境运行或配置完整的环境变量。
"""
return html_content
CafeRecommender = MinimalCafeRecommender
print("✅ 创建最小化推荐器")
except Exception as e:
print(f"❌ 创建最小化推荐器失败: {e}")
CafeRecommender = None
# 请求模型定义
class LocationRequest(BaseModel):
locations: List[str]
venue_types: Optional[List[str]] = ["咖啡馆"]
user_requirements: Optional[str] = ""
class LocationCoord(BaseModel):
"""预解析的地址坐标信息(来自前端 Autocomplete 选择)"""
name: str # 用户选择的地点名称
address: str # 完整地址
lng: float # 经度
lat: float # 纬度
city: Optional[str] = "" # 城市名
class MeetSpotRequest(BaseModel):
locations: List[str]
keywords: Optional[str] = "咖啡馆"
place_type: Optional[str] = ""
user_requirements: Optional[str] = ""
# 筛选条件
min_rating: Optional[float] = 0.0 # 最低评分 (0-5)
max_distance: Optional[int] = 100000 # 最大距离 (米)
price_range: Optional[str] = "" # 价格区间: economy/mid/high
# 预解析坐标(可选,由前端 Autocomplete 提供)
location_coords: Optional[List[LocationCoord]] = None
class AIChatRequest(BaseModel):
message: str
conversation_history: Optional[List[dict]] = []
# MeetSpot AI客服系统提示词
MEETSPOT_SYSTEM_PROMPT = """你是MeetSpot(聚点)的AI Agent智能助手。MeetSpot是一款多人会面地点推荐的AI Agent,核心解决"在哪见面最公平"的问题。
## 核心定位
MeetSpot不是简单的搜索工具,而是一个完整的AI Agent:
- 高德地图搜"我附近",MeetSpot搜"我们中间"
- 大众点评帮你找"好店",MeetSpot帮你找"对所有人都公平的好店"
## 技术特点
1. **球面几何算法**:使用Haversine公式计算地球曲面真实中点,比平面算法精确15-20%
2. **GPT-4o智能评分**:AI对候选场所进行多维度评分(距离、评分、停车、环境、交通便利度)
3. **5步透明推理**:解析地址 -> 计算中点 -> 搜索周边 -> GPT-4o评分 -> 生成推荐
4. **可解释AI**:用户可以看到Agent每一步是怎么"思考"的,完全透明
## 产品能力
- **覆盖范围**:350+城市,基于高德地图数据
- **场景类型**:12种主题(咖啡馆、餐厅、图书馆、KTV、健身房、密室逃脱等)
- **智能识别**:60+高校简称预置,"北大"自动识别为"北京市海淀区北京大学"
- **参与人数**:支持2-10人,满足团队与家人聚会需求
## 响应时间
- 单场景推荐:5-8秒
- 双场景推荐:8-12秒
- Agent复杂模式:15-30秒
(包含完整流程:地理编码、POI搜索、GPT-4o智能评分、交通建议)
## 使用方法
1. 输入2个以上参与者地点(支持地址、地标、简称如"北大")
2. 选择场景类型(可多选,如"咖啡馆 餐厅")
3. 可选:设置特殊需求(停车方便、环境安静等)
4. 点击推荐,5-30秒后获取AI Agent推荐结果
## 常见问题
- **和高德有什么区别?** 高德搜"我附近",MeetSpot搜"我们中间",是高德/百度都没有的功能
- **支持哪些城市?** 350+城市,覆盖全国主要城市
- **推荐速度如何?** 单场景5-8秒,双场景8-12秒,复杂Agent模式15-30秒
- **是否收费?** 完全免费,无需注册,直接使用
## 回答规范
- 用友好、专业的语气回答问题
- 强调MeetSpot是AI Agent,不是简单搜索工具
- 突出"公平"、"透明可解释"、"GPT-4o智能评分"等核心价值
- 回答简洁明了,使用中文
- 如果用户问无关问题,礼貌引导了解产品功能"""
# 预设问题列表
PRESET_QUESTIONS = [
{"id": 1, "question": "MeetSpot是什么?", "category": "产品介绍"},
{"id": 2, "question": "AI Agent怎么工作的?", "category": "技术"},
{"id": 3, "question": "支持哪些场景?", "category": "功能"},
{"id": 4, "question": "推荐需要多久?", "category": "性能"},
{"id": 5, "question": "和高德地图有什么区别?", "category": "对比"},
{"id": 6, "question": "是否收费?", "category": "其他"},
]
# 环境变量配置(用于 Vercel)
AMAP_API_KEY = os.getenv("AMAP_API_KEY", "")
AMAP_JS_API_KEY = os.getenv("AMAP_JS_API_KEY", "") # JS API key for frontend map
AMAP_SECURITY_JS_CODE = os.getenv("AMAP_SECURITY_JS_CODE", "")
# 创建 FastAPI 应用
app = FastAPI(
title="MeetSpot",
description="MeetSpot会面点推荐服务 - 完整功能版",
version="1.0.0"
)
# ============================================================================
# 应用启动事件 - 生成设计token CSS文件
# ============================================================================
@app.on_event("startup")
async def startup_event():
"""应用启动时生成设计token CSS文件"""
try:
from app.design_tokens import generate_design_tokens_css
generate_design_tokens_css()
logger.info("✅ Design tokens CSS generated successfully")
except Exception as e:
logger.error(f"❌ Failed to generate design tokens CSS: {e}")
# 不阻止应用启动,即使CSS生成失败
@app.on_event("startup")
async def startup_database():
"""确保MVP所需的数据库表已创建。"""
try:
await init_db()
logger.info("✅ Database initialized")
except Exception as e:
logger.error(f"❌ Database init failed: {e}")
raise
# 配置CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 缓存中间件 - 为静态资源添加 Cache-Control 头
@app.middleware("http")
async def add_cache_headers(request: Request, call_next):
"""Add Cache-Control headers for static assets to improve performance."""
response = await call_next(request)
path = request.url.path
# 静态资源长期缓存 (1 year for immutable assets)
if any(path.endswith(ext) for ext in ['.css', '.js', '.woff2', '.woff', '.ttf']):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
# 图片缓存 (30 days)
elif any(path.endswith(ext) for ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.ico']):
response.headers["Cache-Control"] = "public, max-age=2592000"
# HTML 页面短期缓存 (10 minutes, revalidate)
elif path.endswith('.html') or path == '/' or path in ['/about', '/faq', '/how-it-works']:
response.headers["Cache-Control"] = "public, max-age=600, stale-while-revalidate=86400"
# sitemap/robots - long cache with stale-while-revalidate for Render cold starts
# This ensures CDN can serve cached content when origin is cold (fixes GSC "Couldn't fetch")
elif path in ['/sitemap.xml', '/robots.txt']:
response.headers["Cache-Control"] = "public, max-age=86400, stale-while-revalidate=604800"
return response
async def _rate_limit_handler(request: Request, exc: RateLimitExceeded):
"""全局限流处理器."""
return JSONResponse(
status_code=429,
content={"detail": "请求过于频繁, 请稍后再试"},
)
app.state.limiter = seo_pages.limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_handler)
app.add_middleware(SlowAPIMiddleware)
app.include_router(auth.router)
app.include_router(seo_pages.router)
app.include_router(miniprogram.router)
@app.get("/health")
async def health_check():
"""健康检查和配置状态"""
return {
"status": "healthy",
"timestamp": time.time(),
"config": {
"amap_configured": bool(AMAP_API_KEY or (config and hasattr(config, 'amap') and config.amap)),
"full_features": config_available,
"minimal_mode": not config_available and bool(AMAP_API_KEY)
}
}
@app.api_route("/google48ac1a797739b7b0.html", methods=["GET", "HEAD"])
async def google_verification():
"""返回Google Search Console验证文件(支持GET和HEAD请求)"""
google_file = "public/google48ac1a797739b7b0.html"
if os.path.exists(google_file):
response = FileResponse(
google_file,
media_type="text/html",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
return response
# 如果文件不存在,返回404
raise HTTPException(status_code=404, detail="Google verification file not found")
@app.api_route("/BingSiteAuth.xml", methods=["GET", "HEAD"])
async def bing_verification():
"""返回Bing站点验证文件(支持GET和HEAD请求)"""
bing_file = "public/BingSiteAuth.xml"
if os.path.exists(bing_file):
response = FileResponse(
bing_file,
media_type="application/xml",
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
return response
# 如果文件不存在,返回404
raise HTTPException(status_code=404, detail="Bing verification file not found")
@app.api_route("/sitemap.xml", methods=["GET", "HEAD"])
async def sitemap():
"""返回站点地图(支持GET和HEAD请求)"""
sitemap_file = "public/sitemap.xml"
if os.path.exists(sitemap_file):
return FileResponse(
sitemap_file,
media_type="application/xml",
headers={
"Cache-Control": "public, max-age=3600",
"Content-Type": "application/xml; charset=utf-8"
}
)
raise HTTPException(status_code=404, detail="Sitemap not found")
@app.api_route("/robots.txt", methods=["GET", "HEAD"])
async def robots():
"""返回robots.txt(支持GET和HEAD请求)"""
robots_file = "public/robots.txt"
if os.path.exists(robots_file):
return FileResponse(
robots_file,
media_type="text/plain",
headers={
"Cache-Control": "public, max-age=3600",
"Content-Type": "text/plain; charset=utf-8"
}
)
raise HTTPException(status_code=404, detail="robots.txt not found")
@app.api_route("/favicon.ico", methods=["GET", "HEAD"])
async def favicon_ico():
"""返回网站图标(支持GET和HEAD请求)"""
# 优先返回SVG favicon(现代浏览器支持)
svg_file = "public/favicon.svg"
if os.path.exists(svg_file):
return FileResponse(
svg_file,
media_type="image/svg+xml",
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": "image/svg+xml"
}
)
raise HTTPException(status_code=404, detail="Favicon not found")
@app.api_route("/favicon.svg", methods=["GET", "HEAD"])
async def favicon_svg():
"""返回SVG网站图标(支持GET和HEAD请求)"""
svg_file = "public/favicon.svg"
if os.path.exists(svg_file):
return FileResponse(
svg_file,
media_type="image/svg+xml",
headers={
"Cache-Control": "public, max-age=31536000, immutable",
"Content-Type": "image/svg+xml"
}
)
raise HTTPException(status_code=404, detail="Favicon not found")
@app.get("/config")
async def get_config():
"""获取当前配置状态(不暴露敏感信息)"""
amap_key = ""
if config:
amap_key = config.amap.api_key
else:
amap_key = AMAP_API_KEY
return {
"amap_api_key_configured": bool(amap_key),
"amap_api_key_length": len(amap_key) if amap_key else 0,
"config_loaded": bool(config),
"full_features_available": bool(config)
}
# ==================== AI 客服接口 ====================
@app.get("/api/ai_chat/preset_questions")
async def get_preset_questions():
"""获取预设问题列表"""
return {
"success": True,
"questions": PRESET_QUESTIONS
}
@app.post("/api/ai_chat")
async def ai_chat(request: AIChatRequest):
"""AI客服聊天接口"""
start_time = time.time()
try:
print(f"🤖 [AI客服] 收到消息: {request.message[:50]}...")
if not llm_available:
# LLM不可用时返回预设回复
print("⚠️ LLM模块不可用,使用预设回复")
return {
"success": True,
"response": "抱歉,AI客服暂时不可用。您可以直接使用我们的会面点推荐功能,或查看页面上的使用说明。如有问题请稍后再试。",
"processing_time": time.time() - start_time,
"mode": "fallback"
}
# 获取LLM API配置
llm_api_key = os.getenv("LLM_API_KEY", "")
llm_api_base = os.getenv("LLM_API_BASE", "https://newapi.deepwisdom.ai/v1")
llm_model = os.getenv("LLM_MODEL", "deepseek-chat") # 默认使用deepseek,中文能力强
if not llm_api_key:
print("⚠️ LLM_API_KEY未配置")
return {
"success": True,
"response": "AI客服配置中,请稍后再试。您也可以直接体验我们的会面点推荐功能!",
"processing_time": time.time() - start_time,
"mode": "fallback"
}
# 使用openai库直接调用(兼容DeepWisdom API)
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=llm_api_key,
base_url=llm_api_base
)
# 构建消息列表
messages = [
{"role": "system", "content": MEETSPOT_SYSTEM_PROMPT}
]
# 添加历史对话(最多保留最近5轮)
if request.conversation_history:
recent_history = request.conversation_history[-10:] # 最多10条消息
messages.extend(recent_history)
# 添加当前用户消息
messages.append({"role": "user", "content": request.message})
print(f"🚀 [AI客服] 调用LLM ({llm_model}),消息数: {len(messages)}")
# 调用LLM
response = await client.chat.completions.create(
model=llm_model,
messages=messages,
max_tokens=500,
temperature=0.7
)
ai_response = response.choices[0].message.content
processing_time = time.time() - start_time
print(f"✅ [AI客服] 回复生成成功,耗时: {processing_time:.2f}秒")
return {
"success": True,
"response": ai_response,
"processing_time": processing_time,
"mode": "llm"
}
except Exception as e:
print(f"💥 [AI客服] 错误: {str(e)}")
return {
"success": False,
"response": f"抱歉,AI客服遇到了问题。您可以直接使用会面点推荐功能,或稍后再试。",
"error": str(e),
"processing_time": time.time() - start_time,
"mode": "error"
}
# ==================== 智能路由逻辑 ====================
def assess_request_complexity(request: MeetSpotRequest) -> dict:
"""评估请求复杂度,决定使用哪种模式
Returns:
dict: {
"use_agent": bool, # 是否使用Agent模式
"complexity_score": int, # 复杂度分数 (0-100)
"reasons": list, # 判断原因
"mode_name": str # 模式名称(用于日志)
}
"""
score = 0
reasons = []
# 1. 地点数量 (权重: 30分)
location_count = len(request.locations)
if location_count >= 4:
score += 30
reasons.append(f"{location_count}个地点,需要复杂的中心点计算")
elif location_count >= 3:
score += 15
reasons.append(f"{location_count}个地点")
# 2. 场所类型数量 (权重: 25分)
keywords = request.keywords or ""
keyword_count = len(keywords.split()) if keywords else 0
if keyword_count >= 3:
score += 25
reasons.append(f"{keyword_count}种场所类型,需要智能平衡")
elif keyword_count >= 2:
score += 12
reasons.append(f"{keyword_count}种场所类型")
# 3. 特殊需求复杂度 (权重: 25分)
requirements = request.user_requirements or ""
if requirements:
req_keywords = ["商务", "安静", "停车", "Wi-Fi", "包间", "儿童", "24小时", "久坐"]
matched_reqs = sum(1 for kw in req_keywords if kw in requirements)
if matched_reqs >= 3:
score += 25
reasons.append(f"{matched_reqs}个特殊需求,需要综合权衡")
elif matched_reqs >= 2:
score += 15
reasons.append(f"{matched_reqs}个特殊需求")
elif len(requirements) > 50:
score += 20
reasons.append("详细的自定义需求描述")
# 4. 筛选条件 (权重: 20分)
has_filters = False
if request.min_rating and request.min_rating > 0:
has_filters = True
score += 5
if request.max_distance and request.max_distance < 10000:
has_filters = True
score += 5
if request.price_range:
has_filters = True
score += 5
if has_filters:
reasons.append("有精确筛选条件")
# 决定模式 (阈值: 40分)
use_agent = score >= 40 and agent_available
# 如果Agent不可用,降级到规则模式
if score >= 40 and not agent_available:
reasons.append("Agent模块不可用,使用增强规则模式")
mode_name = "Agent智能模式" if use_agent else "快速规则模式"
return {
"use_agent": use_agent,
"complexity_score": min(score, 100),
"reasons": reasons,
"mode_name": mode_name
}
# ==================== 会面点推荐接口 ====================
@app.post("/api/find_meetspot")
async def find_meetspot(request: MeetSpotRequest):
"""统一的会面地点推荐入口 - 智能路由
根据请求复杂度自动选择最优模式:
- 简单请求: 规则+LLM模式 (快速,0.3-0.8秒)
- 复杂请求: Agent模式 (深度分析,3-8秒)
"""
start_time = time.time()
# 并发控制:排队处理,保证每个请求都能完成
async with _request_semaphore:
return await _process_meetspot_request(request, start_time)
async def _process_meetspot_request(request: MeetSpotRequest, start_time: float):
"""实际处理推荐请求的内部函数"""
# 评估请求复杂度
complexity = assess_request_complexity(request)
print(f"🧠 [智能路由] 复杂度评估: {complexity['complexity_score']}分, 模式: {complexity['mode_name']}")
if complexity['reasons']:
print(f" 原因: {', '.join(complexity['reasons'])}")
try:
print(f"📝 收到请求: {request.model_dump()}")
# 检查配置
if config:
api_key = config.amap.api_key
print(f"✅ 使用配置文件中的API密钥: {api_key[:10]}...")
else:
api_key = AMAP_API_KEY
print(f"✅ 使用环境变量中的API密钥: {api_key[:10]}...")
if not api_key:
raise HTTPException(
status_code=500,
detail="高德地图API密钥未配置,请设置AMAP_API_KEY环境变量或配置config.toml文件"
)
# ========== 智能路由:根据复杂度选择模式 ==========
if complexity['use_agent']:
print(f"🤖 [Agent模式] 复杂请求,启用Agent智能分析...")
try:
agent = create_meetspot_agent()
# 添加15秒超时,确保Agent模式不会无限等待
AGENT_TIMEOUT = 15 # 秒
agent_result = await asyncio.wait_for(
agent.recommend(
locations=request.locations,
keywords=request.keywords or "咖啡馆",
requirements=request.user_requirements or ""
),
timeout=AGENT_TIMEOUT
)
processing_time = time.time() - start_time
print(f"⏱️ [Agent] 推荐完成,耗时: {processing_time:.2f}秒")
# Agent模式返回格式
return {
"success": agent_result.get("success", False),
"html_url": None, # Agent模式暂不生成HTML
"locations_count": len(request.locations),
"processing_time": processing_time,
"message": "Agent智能推荐完成",
"output": agent_result.get("recommendation", ""),
"mode": "agent",
"complexity_score": complexity['complexity_score'],
"complexity_reasons": complexity['reasons'],
"agent_data": {
"geocode_results": agent_result.get("geocode_results", []),
"center_point": agent_result.get("center_point"),
"search_results": agent_result.get("search_results", []),
"steps_executed": agent_result.get("steps_executed", 0)
}
}
except asyncio.TimeoutError:
print(f"⚠️ [Agent] 执行超时({AGENT_TIMEOUT}秒),降级到规则模式")
except Exception as agent_error:
print(f"⚠️ [Agent] 执行失败,降级到规则模式: {agent_error}")
# 降级到规则模式,继续执行下面的代码
# ========== 规则+LLM模式(默认/降级) ==========
if config:
print("🔧 开始初始化推荐工具...")
recommender = CafeRecommender()
print("🚀 开始执行推荐...")
# 转换 location_coords 为推荐器期望的格式
pre_resolved_coords = None
if request.location_coords:
pre_resolved_coords = [
{
"name": coord.name,
"address": coord.address,
"lng": coord.lng,
"lat": coord.lat,
"city": coord.city or ""
}
for coord in request.location_coords
]
print(f"📍 使用前端预解析坐标: {len(pre_resolved_coords)} 个")
# 调用推荐工具
result = await recommender.execute(
locations=request.locations,
keywords=request.keywords or "咖啡馆",
place_type=request.place_type or "",
user_requirements=request.user_requirements or "",
min_rating=request.min_rating or 0.0,
max_distance=request.max_distance or 100000,
price_range=request.price_range or "",
pre_resolved_coords=pre_resolved_coords
)
processing_time = time.time() - start_time
print(f"⏱️ 推荐完成,耗时: {processing_time:.2f}秒")
# 解析工具输出,提取HTML文件路径
output_text = result.output
html_url = None
print(f"📄 工具输出预览: {output_text[:200]}...")
# 从输出中提取HTML文件路径 - 修复的正则表达式
html_match = re.search(r'HTML页面:\s*([^\s\n]+\.html)', output_text)
if html_match:
html_filename = html_match.group(1)
print(f"🔍 找到HTML文件名: {html_filename}")
html_url = f"/workspace/js_src/{html_filename}"
print(f"🌐 转换为URL: {html_url}")
else:
print("❌ 未找到'HTML页面:'模式,尝试其他模式...")
# 尝试匹配生成的推荐页面格式
html_match2 = re.search(r'生成的推荐页面:\s*([^\s\n]+\.html)', output_text)
if html_match2:
html_path = html_match2.group(1)
if html_path.startswith('workspace/'):
html_url = f"/{html_path}"
else:
html_url = f"/workspace/{html_path}"
print(f"🔍 备用匹配1找到: {html_url}")
else:
# 尝试匹配任何place_recommendation格式的文件名
html_match3 = re.search(r'(place_recommendation_\d{14}_[a-f0-9]+\.html)', output_text)
if html_match3:
html_filename = html_match3.group(1)
html_url = f"/workspace/js_src/{html_filename}"
print(f"🔍 备用匹配2找到: {html_url}")
else:
print("❌ 所有匹配模式都失败了")
html_url = None
# 返回前端期望的格式(包含模式信息)
response_data = {
"success": True,
"html_url": html_url,
"locations_count": len(request.locations),
"processing_time": processing_time,
"message": "推荐生成成功",
"output": output_text,
"mode": "rule_llm", # 规则+LLM增强模式
"complexity_score": complexity['complexity_score'],
"complexity_reasons": complexity['reasons']
}
print(f"📤 返回响应: success={response_data['success']}, html_url={response_data['html_url']}")
# 主动释放内存
gc.collect()
return response_data
else:
# Fallback:如果无法加载完整模块,返回错误
print("❌ 配置未加载")
raise HTTPException(
status_code=500,
detail="服务配置错误:无法加载推荐模块,请确保在本地环境运行或正确配置Vercel环境变量"
)
except Exception as e:
print(f"💥 异常发生: {str(e)}")
print(f"异常类型: {type(e)}")
import traceback
traceback.print_exc()
processing_time = time.time() - start_time
# 主动释放内存
gc.collect()
# 返回错误响应,但保持前端期望的格式
error_response = {
"success": False,
"error": str(e),
"processing_time": processing_time,
"message": f"推荐失败: {str(e)}"
}
print(f"📤 返回错误响应: {error_response['message']}")
return error_response
@app.post("/api/find_meetspot_agent")
async def find_meetspot_agent(request: MeetSpotRequest):
"""Agent 模式的会面地点推荐功能
使用 AI Agent 进行智能推荐,支持:
- 自主规划推荐流程
- 智能分析场所特点
- 生成个性化推荐理由
"""
start_time = time.time()
try:
print(f"🤖 [Agent] 收到请求: {request.model_dump()}")
# 检查 Agent 是否可用
if not agent_available:
print("⚠️ Agent 模块不可用,回退到规则模式")
return await find_meetspot(request)
# 检查配置
if not config or not config.amap or not config.amap.api_key:
print("❌ API 密钥未配置")
raise HTTPException(
status_code=500,
detail="高德地图API密钥未配置"
)
print("🔧 [Agent] 初始化 MeetSpotAgent...")
agent = create_meetspot_agent()
print("🚀 [Agent] 开始执行推荐任务...")
result = await agent.recommend(
locations=request.locations,
keywords=request.keywords or "咖啡馆",
requirements=request.user_requirements or ""
)
processing_time = time.time() - start_time
print(f"⏱️ [Agent] 推荐完成,耗时: {processing_time:.2f}秒")
# 构建响应
response_data = {
"success": result.get("success", False),
"mode": "agent",
"recommendation": result.get("recommendation", ""),
"geocode_results": result.get("geocode_results", []),
"center_point": result.get("center_point"),
"search_results": result.get("search_results", []),
"steps_executed": result.get("steps_executed", 0),
"locations_count": len(request.locations),
"processing_time": processing_time,
"message": "Agent 推荐生成成功" if result.get("success") else "推荐失败"
}
print(f"📤 [Agent] 返回响应: success={response_data['success']}")
return response_data
except Exception as e:
print(f"💥 [Agent] 异常发生: {str(e)}")
import traceback
traceback.print_exc()
processing_time = time.time() - start_time
# 尝试回退到规则模式
print("⚠️ [Agent] 尝试回退到规则模式...")
try:
fallback_result = await find_meetspot(request)
fallback_result["mode"] = "rule_fallback"
fallback_result["agent_error"] = str(e)
return fallback_result
except Exception as fallback_error:
return {
"success": False,
"mode": "agent",
"error": str(e),
"processing_time": processing_time,
"message": f"Agent 推荐失败: {str(e)}"
}
@app.post("/recommend")
async def get_recommendations(request: LocationRequest):
"""兼容性API端点 - 统一响应格式"""
# 转换请求格式
meetspot_request = MeetSpotRequest(
locations=request.locations,
keywords=request.venue_types[0] if request.venue_types else "咖啡馆",
user_requirements=request.user_requirements
)
# 直接调用主端点并返回相同格式
return await find_meetspot(meetspot_request)
@app.get("/api/config/amap")
async def get_amap_config():
"""返回 AMap 配置(用于前端地图和 Autocomplete)
Note: 前端需要 JS API key,与后端 geocoding 使用的 Web服务 key 不同
"""
# 优先使用 JS API key(前端地图专用)
js_api_key = AMAP_JS_API_KEY
security_js_code = AMAP_SECURITY_JS_CODE
# 从 config.toml 获取(如果存在)
if config and hasattr(config, "amap") and config.amap:
if not js_api_key:
js_api_key = getattr(config.amap, "js_api_key", "") or getattr(config.amap, "api_key", "")
if not security_js_code:
security_js_code = getattr(config.amap, "security_js_code", "")
# 最后回退到 Web服务 key(不推荐,可能无法加载地图)
if not js_api_key:
js_api_key = AMAP_API_KEY
return {
"api_key": js_api_key,
"security_js_code": security_js_code
}
@app.get("/api/status")
async def api_status():
"""API状态检查"""
return {
"status": "healthy",
"service": "MeetSpot",
"version": "1.0.0",
"platform": "Multi-platform",
"features": "Complete" if config else "Limited",
"timestamp": time.time()
}
# 静态文件服务(替代WhiteNoise,使用FastAPI原生StaticFiles)
# StaticFiles自带gzip压缩和缓存控制
if os.path.exists("static"):
app.mount("/static", StaticFiles(directory="static"), name="static")
if os.path.exists("public"):
app.mount("/public", StaticFiles(directory="public", html=True), name="public")
# 添加缓存控制头(用于静态资源)
@app.middleware("http")
async def add_cache_headers(request: Request, call_next):
response = await call_next(request)
# 对静态资源添加长期缓存
if request.url.path.startswith(("/static/", "/public/")):
response.headers["Cache-Control"] = "public, max-age=31536000" # 1年
return response
# Vercel 处理函数
app_instance = app
# 如果直接运行此文件(本地测试)
if __name__ == "__main__":
import uvicorn
print("🚀 启动 MeetSpot 完整功能服务器...")
uvicorn.run(app, host="0.0.0.0", port=8000)