forked from zhouruizhe/gmTouringMiniApp
Initial commit: gmTouringMiniApp project
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Guangming travel assistant POC."""
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .schemas import TravelPreferences
|
||||
|
||||
|
||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
||||
|
||||
|
||||
class KnowledgeBase:
|
||||
def __init__(self) -> None:
|
||||
self.places: list[dict[str, Any]] = json.loads(
|
||||
(DATA_DIR / "places.json").read_text(encoding="utf-8")
|
||||
)
|
||||
travel_rows = json.loads(
|
||||
(DATA_DIR / "travel_times.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.travel_times = {
|
||||
(row["from"], row["to"]): row for row in travel_rows
|
||||
}
|
||||
|
||||
@property
|
||||
def count(self) -> int:
|
||||
return len(self.places)
|
||||
|
||||
def by_id(self, place_id: str) -> Optional[dict[str, Any]]:
|
||||
return next((place for place in self.places if place["id"] == place_id), None)
|
||||
|
||||
def retrieve(self, preferences: TravelPreferences, limit: int = 8) -> list[dict[str, Any]]:
|
||||
requirements = preferences.extra_requirements.lower()
|
||||
indoor_required = any(
|
||||
token in requirements for token in ("室内", "下雨", "雨天", "避雨")
|
||||
)
|
||||
avoid_climbing = any(
|
||||
token in requirements
|
||||
for token in ("不爬山", "不要爬山", "不登山", "体力不好", "婴儿车")
|
||||
)
|
||||
youngest = min(preferences.child_ages) if preferences.child_ages else None
|
||||
scored: list[tuple[int, str, dict[str, Any]]] = []
|
||||
|
||||
for place in self.places:
|
||||
if place["city"] != "深圳市" or place["district"] != "光明区":
|
||||
continue
|
||||
if youngest is not None and youngest < place.get("minAge", 0):
|
||||
continue
|
||||
if avoid_climbing and place.get("intensity") == "high":
|
||||
continue
|
||||
if indoor_required and not (
|
||||
place.get("indoor") or place.get("rainyDaySuitable")
|
||||
):
|
||||
continue
|
||||
|
||||
score = 0
|
||||
score += 3 * len(set(preferences.themes) & set(place.get("themes", [])))
|
||||
score += 3 * len(
|
||||
set(preferences.interests) & set(place.get("interests", []))
|
||||
)
|
||||
if preferences.children and "儿童" in place.get("suitableFor", []):
|
||||
score += 2
|
||||
elif not preferences.children and "成人" in place.get("suitableFor", []):
|
||||
score += 2
|
||||
if preferences.pace.value in place.get("pace", []):
|
||||
score += 1
|
||||
if preferences.budget_level.value == place.get("budgetLevel"):
|
||||
score += 1
|
||||
if indoor_required and (
|
||||
place.get("indoor") or place.get("rainyDaySuitable")
|
||||
):
|
||||
score += 2
|
||||
|
||||
scored.append((score, place["name"], place))
|
||||
|
||||
scored.sort(key=lambda item: (-item[0], item[1]))
|
||||
return [place for _, _, place in scored[:limit]]
|
||||
|
||||
def travel_minutes(
|
||||
self, from_id: str, to_id: str, transport: str
|
||||
) -> Optional[int]:
|
||||
row = self.travel_times.get((from_id, to_id)) or self.travel_times.get(
|
||||
(to_id, from_id)
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
value = row.get(transport)
|
||||
return int(value) if value is not None else None
|
||||
|
||||
def travel_context(
|
||||
self, candidates: list[dict[str, Any]], transport: str
|
||||
) -> list[dict[str, Any]]:
|
||||
ids = {place["id"] for place in candidates}
|
||||
result = []
|
||||
for row in self.travel_times.values():
|
||||
if row["from"] in ids and row["to"] in ids:
|
||||
result.append(
|
||||
{
|
||||
"from": row["from"],
|
||||
"to": row["to"],
|
||||
"minutes": row.get(transport),
|
||||
"note": row.get("note", "POC估算,请以实际导航为准"),
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,203 @@
|
||||
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 "作为光明区同路线备选,便于控制整体节奏"
|
||||
)
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .knowledge import KnowledgeBase
|
||||
from .llm import LLMError, LLMService, LLMTimeout
|
||||
from .planner import PlannerService, SessionStore
|
||||
from .schemas import HealthResponse, MessageRequest, PlanResponse, TravelPreferences
|
||||
|
||||
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
# Mock mode can run without python-dotenv; production installs project deps.
|
||||
pass
|
||||
|
||||
knowledge = KnowledgeBase()
|
||||
llm = LLMService(knowledge)
|
||||
planner = PlannerService(knowledge, llm, SessionStore())
|
||||
|
||||
app = FastAPI(
|
||||
title="光明区文旅行程助手 API",
|
||||
version="0.1.0",
|
||||
description="POC:本地知识检索 + OpenAI 兼容模型 + 内存会话",
|
||||
)
|
||||
origins = [
|
||||
origin.strip()
|
||||
for origin in os.getenv(
|
||||
"CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173"
|
||||
).split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=False,
|
||||
allow_methods=["GET", "POST"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/health", response_model=HealthResponse)
|
||||
async def health() -> HealthResponse:
|
||||
return HealthResponse(
|
||||
status="ok",
|
||||
llm_mode=llm.mode,
|
||||
model_configured=llm.configured,
|
||||
knowledge_count=knowledge.count,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/v1/plans", response_model=PlanResponse)
|
||||
async def create_plan(preferences: TravelPreferences) -> PlanResponse:
|
||||
try:
|
||||
return await planner.create(preferences)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except LLMTimeout as exc:
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except (LLMError, ValueError) as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.post(
|
||||
"/api/v1/conversations/{conversation_id}/messages",
|
||||
response_model=PlanResponse,
|
||||
)
|
||||
async def adjust_plan(
|
||||
conversation_id: str, request: MessageRequest
|
||||
) -> PlanResponse:
|
||||
try:
|
||||
return await planner.adjust(conversation_id, request.message)
|
||||
except LookupError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except LLMTimeout as exc:
|
||||
raise HTTPException(status_code=504, detail=str(exc)) from exc
|
||||
except (LLMError, ValueError) as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from .knowledge import KnowledgeBase
|
||||
from .llm import LLMService
|
||||
from .schemas import (
|
||||
GeneratedItinerary,
|
||||
Itinerary,
|
||||
PlanResponse,
|
||||
Source,
|
||||
TravelPreferences,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
preferences: TravelPreferences
|
||||
itinerary: GeneratedItinerary
|
||||
created_at: float
|
||||
history: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def __init__(self, max_size: int = 100, ttl_seconds: int = 7200) -> None:
|
||||
self.max_size = max_size
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.sessions: OrderedDict[str, Session] = OrderedDict()
|
||||
|
||||
def put(self, session: Session) -> str:
|
||||
self.cleanup()
|
||||
while len(self.sessions) >= self.max_size:
|
||||
self.sessions.popitem(last=False)
|
||||
session_id = str(uuid.uuid4())
|
||||
self.sessions[session_id] = session
|
||||
return session_id
|
||||
|
||||
def get(self, session_id: str) -> Optional[Session]:
|
||||
self.cleanup()
|
||||
session = self.sessions.get(session_id)
|
||||
if session:
|
||||
self.sessions.move_to_end(session_id)
|
||||
return session
|
||||
|
||||
def cleanup(self) -> None:
|
||||
deadline = time.time() - self.ttl_seconds
|
||||
expired = [
|
||||
key for key, session in self.sessions.items()
|
||||
if session.created_at < deadline
|
||||
]
|
||||
for key in expired:
|
||||
self.sessions.pop(key, None)
|
||||
|
||||
|
||||
class PlannerService:
|
||||
def __init__(
|
||||
self,
|
||||
knowledge: KnowledgeBase,
|
||||
llm: LLMService,
|
||||
store: SessionStore,
|
||||
) -> None:
|
||||
self.knowledge = knowledge
|
||||
self.llm = llm
|
||||
self.store = store
|
||||
|
||||
async def create(self, preferences: TravelPreferences) -> PlanResponse:
|
||||
candidates = self.knowledge.retrieve(preferences)
|
||||
if not candidates:
|
||||
raise LookupError("没有找到符合当前条件的光明区地点")
|
||||
generated = await self.llm.generate(preferences, candidates)
|
||||
generated = self._canonicalize(generated, candidates)
|
||||
session_id = self.store.put(
|
||||
Session(preferences=preferences, itinerary=generated, created_at=time.time())
|
||||
)
|
||||
return PlanResponse(
|
||||
conversation_id=session_id,
|
||||
assistant_message="已根据你的偏好生成光明区行程。",
|
||||
itinerary=self._with_sources(generated),
|
||||
)
|
||||
|
||||
async def adjust(self, session_id: str, message: str) -> PlanResponse:
|
||||
session = self.store.get(session_id)
|
||||
if not session:
|
||||
raise LookupError("会话不存在或已过期,请重新生成行程")
|
||||
updated_preferences = session.preferences.model_copy(
|
||||
update={
|
||||
"extra_requirements": (
|
||||
session.preferences.extra_requirements + ";" + message
|
||||
).strip(";")
|
||||
}
|
||||
)
|
||||
candidates = self.knowledge.retrieve(updated_preferences)
|
||||
if not candidates:
|
||||
raise LookupError("没有找到符合调整条件的光明区地点")
|
||||
generated = await self.llm.generate(
|
||||
updated_preferences, candidates, session.itinerary, message
|
||||
)
|
||||
generated = self._canonicalize(generated, candidates)
|
||||
session.preferences = updated_preferences
|
||||
session.itinerary = generated
|
||||
session.history.append(message)
|
||||
return PlanResponse(
|
||||
conversation_id=session_id,
|
||||
assistant_message="已按你的补充要求调整行程。",
|
||||
change_summary=f"已根据“{message}”更新相关安排。",
|
||||
itinerary=self._with_sources(generated),
|
||||
)
|
||||
|
||||
def _canonicalize(
|
||||
self,
|
||||
itinerary: GeneratedItinerary,
|
||||
candidates: list[dict],
|
||||
) -> GeneratedItinerary:
|
||||
candidate_map = {place["id"]: place for place in candidates}
|
||||
for item in itinerary.items:
|
||||
place = candidate_map.get(item.place_id)
|
||||
if not place:
|
||||
raise ValueError(f"行程包含知识库外地点:{item.place_id}")
|
||||
item.place_name = place["name"]
|
||||
item.tips = [
|
||||
tip for tip in item.tips if tip in place.get("tips", [])
|
||||
] or place.get("tips", [])[:2]
|
||||
return itinerary
|
||||
|
||||
def _with_sources(self, generated: GeneratedItinerary) -> Itinerary:
|
||||
seen: set[str] = set()
|
||||
sources: list[Source] = []
|
||||
for item in generated.items:
|
||||
if item.place_id in seen:
|
||||
continue
|
||||
seen.add(item.place_id)
|
||||
place = self.knowledge.by_id(item.place_id)
|
||||
if place:
|
||||
sources.append(
|
||||
Source(
|
||||
place_id=place["id"],
|
||||
source_name=place["sourceName"],
|
||||
source_url=place["sourceUrl"],
|
||||
updated_at=place["updatedAt"],
|
||||
)
|
||||
)
|
||||
return Itinerary(
|
||||
**generated.model_dump(),
|
||||
sources=sources,
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
SYSTEM_PROMPT = """你是深圳市光明区文旅行程规划助手。
|
||||
你只能使用“候选地点”中出现的 placeId,不得创造地点。
|
||||
不得虚构开放时间、票价、预约、天气或交通耗时;缺少交通数据时必须输出 null。
|
||||
优先满足年龄、安全、室内外、体力和用户明确排除条件。
|
||||
半日游控制在约4小时,一日游控制在约8小时;轻松、适中、紧凑最多安排3、4、5个地点。
|
||||
修改已有行程时,只修改用户要求影响的部分,尽量保留其余安排。
|
||||
只输出一个 JSON 对象,不要输出 Markdown、代码围栏或解释文字。
|
||||
|
||||
JSON 结构:
|
||||
{
|
||||
"title": "字符串",
|
||||
"summary": "字符串",
|
||||
"totalMinutes": 240,
|
||||
"estimatedCostText": "费用说明,不编造精确价格",
|
||||
"items": [{
|
||||
"startTime": "09:00",
|
||||
"endTime": "10:30",
|
||||
"placeId": "候选地点ID",
|
||||
"placeName": "候选地点名称",
|
||||
"activity": "活动安排",
|
||||
"reason": "匹配理由",
|
||||
"transferFromPreviousMinutes": null,
|
||||
"tips": ["知识库内的提示"]
|
||||
}],
|
||||
"notes": ["动态信息需出行前确认"]
|
||||
}"""
|
||||
@@ -0,0 +1,115 @@
|
||||
from enum import Enum
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
def to_camel(value: str) -> str:
|
||||
parts = value.split("_")
|
||||
return parts[0] + "".join(part.capitalize() for part in parts[1:])
|
||||
|
||||
|
||||
class APIModel(BaseModel):
|
||||
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
|
||||
|
||||
|
||||
class Duration(str, Enum):
|
||||
HALF_DAY = "half_day"
|
||||
FULL_DAY = "full_day"
|
||||
|
||||
|
||||
class Pace(str, Enum):
|
||||
RELAXED = "relaxed"
|
||||
MODERATE = "moderate"
|
||||
COMPACT = "compact"
|
||||
|
||||
|
||||
class Transport(str, Enum):
|
||||
WALKING = "walking"
|
||||
DRIVING = "driving"
|
||||
PUBLIC_TRANSPORT = "public_transport"
|
||||
|
||||
|
||||
class BudgetLevel(str, Enum):
|
||||
ECONOMY = "economy"
|
||||
STANDARD = "standard"
|
||||
QUALITY = "quality"
|
||||
|
||||
|
||||
Theme = Literal["亲子", "情侣", "朋友", "银发", "研学"]
|
||||
Interest = Literal["自然风光", "文化场馆", "生态科普", "美食", "摄影"]
|
||||
|
||||
|
||||
class TravelPreferences(APIModel):
|
||||
destination: Literal["深圳市光明区"] = "深圳市光明区"
|
||||
themes: list[Theme] = Field(default_factory=list)
|
||||
duration: Duration
|
||||
pace: Pace
|
||||
interests: list[Interest] = Field(default_factory=list)
|
||||
adults: int = Field(default=2, ge=0, le=20)
|
||||
children: int = Field(default=0, ge=0, le=20)
|
||||
child_ages: list[int] = Field(default_factory=list)
|
||||
transport: Transport = Transport.PUBLIC_TRANSPORT
|
||||
budget_level: BudgetLevel = BudgetLevel.STANDARD
|
||||
extra_requirements: str = Field(default="", max_length=500)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_group_and_preferences(self) -> "TravelPreferences":
|
||||
if self.adults + self.children < 1:
|
||||
raise ValueError("出行总人数至少为1")
|
||||
if self.child_ages and len(self.child_ages) != self.children:
|
||||
raise ValueError("儿童年龄数量必须与儿童人数一致")
|
||||
if any(age < 0 or age > 17 for age in self.child_ages):
|
||||
raise ValueError("儿童年龄须在0至17岁之间")
|
||||
if not self.themes and not self.interests:
|
||||
raise ValueError("主题或特色偏好至少选择一项")
|
||||
return self
|
||||
|
||||
|
||||
class ItineraryItem(APIModel):
|
||||
start_time: str
|
||||
end_time: str
|
||||
place_id: str
|
||||
place_name: str
|
||||
activity: str
|
||||
reason: str
|
||||
transfer_from_previous_minutes: Optional[int] = Field(default=None, ge=0)
|
||||
tips: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GeneratedItinerary(APIModel):
|
||||
title: str
|
||||
summary: str
|
||||
total_minutes: int = Field(ge=1, le=720)
|
||||
estimated_cost_text: str
|
||||
items: list[ItineraryItem] = Field(min_length=1, max_length=5)
|
||||
notes: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Source(APIModel):
|
||||
place_id: str
|
||||
source_name: str
|
||||
source_url: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class Itinerary(GeneratedItinerary):
|
||||
sources: list[Source]
|
||||
|
||||
|
||||
class PlanResponse(APIModel):
|
||||
conversation_id: str
|
||||
assistant_message: str
|
||||
itinerary: Itinerary
|
||||
change_summary: Optional[str] = None
|
||||
|
||||
|
||||
class MessageRequest(APIModel):
|
||||
message: str = Field(min_length=1, max_length=500)
|
||||
|
||||
|
||||
class HealthResponse(APIModel):
|
||||
status: str
|
||||
llm_mode: str
|
||||
model_configured: bool
|
||||
knowledge_count: int
|
||||
Reference in New Issue
Block a user