from __future__ import annotations import json import os from datetime import datetime, timedelta from typing import Any, Optional from pydantic import ValidationError from .knowledge import KnowledgeBase from .prompts import SYSTEM_PROMPT from .schemas import GeneratedItinerary, Pace, TravelPreferences class LLMError(RuntimeError): pass class LLMTimeout(LLMError): pass class LLMService: def __init__(self, knowledge: KnowledgeBase) -> None: self.knowledge = knowledge self.mode = os.getenv("LLM_MODE", "mock").lower() self.timeout = float(os.getenv("LLM_TIMEOUT_SECONDS", "45")) @property def configured(self) -> bool: if self.mode == "mock": return True return bool(os.getenv("OPENAI_API_KEY") and os.getenv("OPENAI_MODEL")) async def generate( self, preferences: TravelPreferences, candidates: list[dict[str, Any]], previous: Optional[GeneratedItinerary] = None, user_message: Optional[str] = None, ) -> GeneratedItinerary: if self.mode == "mock": return self._mock_generate(preferences, candidates, previous, user_message) return await self._real_generate(preferences, candidates, previous, user_message) async def _real_generate( self, preferences: TravelPreferences, candidates: list[dict[str, Any]], previous: Optional[GeneratedItinerary], user_message: Optional[str], ) -> GeneratedItinerary: if not self.configured: raise LLMError("真实模型尚未配置") try: from openai import APITimeoutError, AsyncOpenAI except ImportError as exc: raise LLMError("缺少 openai 依赖") from exc client = AsyncOpenAI( api_key=os.environ["OPENAI_API_KEY"], base_url=os.getenv("OPENAI_BASE_URL") or None, timeout=self.timeout, ) payload = { "preferences": preferences.model_dump(by_alias=True), "candidates": candidates, "travelTimes": self.knowledge.travel_context( candidates, preferences.transport.value ), "previousItinerary": ( previous.model_dump(by_alias=True) if previous else None ), "adjustmentRequest": user_message, } feedback = "" allowed_ids = {place["id"] for place in candidates} for attempt in range(2): try: response = await client.chat.completions.create( model=os.environ["OPENAI_MODEL"], temperature=0.2, response_format={"type": "json_object"}, messages=[ {"role": "system", "content": SYSTEM_PROMPT}, { "role": "user", "content": json.dumps(payload, ensure_ascii=False) + feedback, }, ], ) except APITimeoutError as exc: raise LLMTimeout("模型调用超时") from exc except Exception as exc: raise LLMError(f"模型调用失败:{exc}") from exc try: content = response.choices[0].message.content or "{}" itinerary = GeneratedItinerary.model_validate_json(content) unknown = { item.place_id for item in itinerary.items } - allowed_ids if unknown: raise ValueError(f"包含未知地点ID:{sorted(unknown)}") return itinerary except (ValidationError, ValueError, json.JSONDecodeError) as exc: if attempt == 1: raise LLMError(f"模型输出无法通过结构校验:{exc}") from exc feedback = f"\n上次输出校验失败:{exc}。请重新输出完整合法 JSON。" raise LLMError("模型未返回有效结果") def _mock_generate( self, preferences: TravelPreferences, candidates: list[dict[str, Any]], previous: Optional[GeneratedItinerary], user_message: Optional[str], ) -> GeneratedItinerary: if not candidates: raise LLMError("没有符合条件的候选地点") max_items = { Pace.RELAXED: 3, Pace.MODERATE: 4, Pace.COMPACT: 5, }[preferences.pace] target = 240 if preferences.duration.value == "half_day" else 480 start = datetime(2026, 1, 1, 9, 0) elapsed = 0 items = [] previous_place: Optional[dict[str, Any]] = None for place in candidates: if len(items) >= max_items: break transfer_value: Optional[int] = 0 if previous_place: transfer_value = self.knowledge.travel_minutes( previous_place["id"], place["id"], preferences.transport.value, ) transfer_for_math = transfer_value or 0 duration = int(place.get("recommendedMinutes", 75)) if elapsed + transfer_for_math + duration > target + 30 and items: continue item_start = start + timedelta(minutes=transfer_for_math) end = item_start + timedelta(minutes=duration) items.append( { "startTime": item_start.strftime("%H:%M"), "endTime": end.strftime("%H:%M"), "placeId": place["id"], "placeName": place["name"], "activity": place["summary"], "reason": self._reason(preferences, place), "transferFromPreviousMinutes": ( transfer_value if previous_place else 0 ), "tips": place.get("tips", [])[:2], } ) elapsed += transfer_for_math + duration start = end previous_place = place themes = "、".join(preferences.themes or preferences.interests[:1]) adjustment = f";已响应“{user_message}”" if user_message else "" pace_label = { Pace.RELAXED: "轻松", Pace.MODERATE: "适中", Pace.COMPACT: "紧凑", }[preferences.pace] return GeneratedItinerary.model_validate( { "title": f"光明区{themes or '精选'}{'半日' if preferences.duration.value == 'half_day' else '一日'}游", "summary": f"以科学、人文与都市自然为线索,按{pace_label}节奏安排{adjustment}。", "totalMinutes": max(elapsed, 1), "estimatedCostText": "费用以场馆、景区及实际交通信息为准", "items": items, "notes": [ "开放时间、预约和票价请在出行前通过官方渠道再次确认。", "交通耗时为POC估算,请以出发时的实际导航为准。", ], } ) @staticmethod def _reason( preferences: TravelPreferences, place: dict[str, Any] ) -> str: matches = list( (set(preferences.themes) & set(place.get("themes", []))) | (set(preferences.interests) & set(place.get("interests", []))) ) return ( f"符合你的{'、'.join(sorted(matches))}偏好" if matches else "作为光明区同路线备选,便于控制整体节奏" )