1041 lines
38 KiB
Python
1041 lines
38 KiB
Python
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"""
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>MeetSpot 推荐结果</title>
|
||
<style>
|
||
body {{ font-family: 'Microsoft YaHei', sans-serif; margin: 20px; }}
|
||
.header {{ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 10px; }}
|
||
.locations {{ margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 8px; }}
|
||
.result {{ margin: 10px 0; padding: 15px; border: 1px solid #ddd; border-radius: 8px; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>🎯 MeetSpot 推荐结果</h1>
|
||
<p>为您推荐最佳会面地点</p>
|
||
</div>
|
||
|
||
<div class="locations">
|
||
<h3>📍 您的位置信息</h3>
|
||
<p><strong>位置:</strong> {', '.join(locations)}</p>
|
||
<p><strong>需求:</strong> {keywords}</p>
|
||
{f'<p><strong>特殊要求:</strong> {user_requirements}</p>' if user_requirements else ''}
|
||
</div>
|
||
|
||
<div class="result">
|
||
<h3>💡 推荐建议</h3>
|
||
<p>由于在Vercel环境下运行,推荐功能已简化。建议您:</p>
|
||
<ul>
|
||
<li>选择位置中心点附近的{keywords}</li>
|
||
<li>考虑交通便利性和停车条件</li>
|
||
<li>选择环境舒适、适合交流的场所</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="result">
|
||
<h3>⚠️ 注意事项</h3>
|
||
<p>当前运行在简化模式下。如需完整功能,请在本地环境运行或配置完整的环境变量。</p>
|
||
</div>
|
||
</body>
|
||
</html>
|
||
"""
|
||
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)
|