Initial commit: gmTouringMiniApp project
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||
OPENAI_MODEL=
|
||||
LLM_TIMEOUT_SECONDS=45
|
||||
LLM_MODE=mock
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,423 @@
|
||||
[
|
||||
{
|
||||
"id": "guangming-science-museum",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "深圳科学技术馆",
|
||||
"type": "museum",
|
||||
"area": "光明中心区",
|
||||
"themes": ["亲子", "朋友", "研学"],
|
||||
"interests": ["文化场馆", "生态科普", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 3,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": true,
|
||||
"rainyDaySuitable": true,
|
||||
"recommendedMinutes": 150,
|
||||
"budgetLevel": "standard",
|
||||
"address": "深圳市光明区光辉大道8号",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "开放与预约信息请以场馆官方最新公告为准",
|
||||
"ticketText": "票务信息请以场馆官方最新公告为准",
|
||||
"summary": "围绕数字文明与通信科技设置互动展项,适合科普参观和亲子研学。",
|
||||
"tips": ["热门时段建议提前查询预约信息", "场馆较大,亲子家庭可预留充足时间"],
|
||||
"sourceName": "深圳市光明区人民政府在线",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12151090.html",
|
||||
"updatedAt": "2025-04-29"
|
||||
},
|
||||
{
|
||||
"id": "guangming-science-park",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "科学公园",
|
||||
"type": "park",
|
||||
"area": "光明中心区",
|
||||
"themes": ["亲子", "情侣", "朋友", "银发"],
|
||||
"interests": ["自然风光", "生态科普", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 0,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": false,
|
||||
"rainyDaySuitable": false,
|
||||
"recommendedMinutes": 90,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区光明中心区",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "开放区域请以公园现场公告为准",
|
||||
"ticketText": "公共区域费用信息请以现场公告为准",
|
||||
"summary": "以山林、步道和生态体验连接科学城公共空间,适合散步和轻量户外活动。",
|
||||
"tips": ["户外活动注意防晒和补水", "雨天请留意步道路面状况"],
|
||||
"sourceName": "深圳市光明区人民政府在线",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12235727.html",
|
||||
"updatedAt": "2025-06-19"
|
||||
},
|
||||
{
|
||||
"id": "guangming-happy-farm",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "光明欢乐田园",
|
||||
"type": "park",
|
||||
"area": "新湖街道",
|
||||
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
|
||||
"interests": ["自然风光", "生态科普", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 0,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": false,
|
||||
"rainyDaySuitable": false,
|
||||
"recommendedMinutes": 120,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区新湖街道楼村一号路",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "花期、开放区域及活动请以园区最新公告为准",
|
||||
"ticketText": "费用请以园区最新公告为准",
|
||||
"summary": "都市田园景观随季节变化,可体验农田、花景与开阔田园空间。",
|
||||
"tips": ["景观受季节影响,出行前建议查询当期花田信息", "户外区域注意防晒"],
|
||||
"sourceName": "光明区文化广电旅游体育局",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/zjgm/lsgm_118488/tsgm_118491/content/post_8758374.html",
|
||||
"updatedAt": "2025-03-24"
|
||||
},
|
||||
{
|
||||
"id": "guangming-farm-grand-view",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "光明农场大观园",
|
||||
"type": "attraction",
|
||||
"area": "光明街道",
|
||||
"themes": ["亲子", "朋友", "研学"],
|
||||
"interests": ["自然风光", "生态科普", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 2,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": false,
|
||||
"rainyDaySuitable": false,
|
||||
"recommendedMinutes": 150,
|
||||
"budgetLevel": "standard",
|
||||
"address": "深圳市光明区光明街道",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "开放时间和活动安排请以景区公告为准",
|
||||
"ticketText": "购票信息请以景区官方渠道为准",
|
||||
"summary": "以农场、动物互动和自然科普为特色,适合亲子家庭和研学体验。",
|
||||
"tips": ["动物互动和主题活动以当日安排为准", "建议为儿童准备替换衣物"],
|
||||
"sourceName": "深圳市光明区人民政府在线",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/xxgk/xqgwhxxgkml/xwzx/tpxw/content/post_12164420.html",
|
||||
"updatedAt": "2025-05-09"
|
||||
},
|
||||
{
|
||||
"id": "guangming-hongqiao-park",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "虹桥公园",
|
||||
"type": "park",
|
||||
"area": "光明街道",
|
||||
"themes": ["亲子", "情侣", "朋友", "银发"],
|
||||
"interests": ["自然风光", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 0,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "medium",
|
||||
"indoor": false,
|
||||
"rainyDaySuitable": false,
|
||||
"recommendedMinutes": 100,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区光侨路以东、观光路以北",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "开放范围请以公园公告为准",
|
||||
"ticketText": "公共区域费用信息请以现场公告为准",
|
||||
"summary": "以醒目的红色桥体和山林绿意形成城市生态地标,适合步行和摄影。",
|
||||
"tips": ["路线较长,可按体力选择折返", "户外步行注意防晒和补水"],
|
||||
"sourceName": "光明区城市管理和综合执法局",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/gmcsglj/gkmlpt/content/10/10284/post_10284402.html",
|
||||
"updatedAt": "2022-12-13"
|
||||
},
|
||||
{
|
||||
"id": "guangming-dadingling-greenway",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "大顶岭绿道",
|
||||
"type": "greenway",
|
||||
"area": "光明街道",
|
||||
"themes": ["情侣", "朋友", "研学"],
|
||||
"interests": ["自然风光", "生态科普", "摄影"],
|
||||
"suitableFor": ["成人"],
|
||||
"minAge": 8,
|
||||
"pace": ["moderate", "compact"],
|
||||
"intensity": "high",
|
||||
"indoor": false,
|
||||
"rainyDaySuitable": false,
|
||||
"recommendedMinutes": 150,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区大顶岭片区",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "绿道开放情况请以管理方公告为准",
|
||||
"ticketText": "费用信息请以现场公告为准",
|
||||
"summary": "以悬桥、探桥和浮桥等节点串联山林生态,是具有运动强度的绿道体验。",
|
||||
"tips": ["需要较多步行,不适合婴儿车和体力较弱人群", "雨天不建议前往"],
|
||||
"sourceName": "Guangming Government Online",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163137.html",
|
||||
"updatedAt": "2025-05-08"
|
||||
},
|
||||
{
|
||||
"id": "guangming-culture-art-center",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "光明文化艺术中心",
|
||||
"type": "cultural_center",
|
||||
"area": "凤凰街道",
|
||||
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
|
||||
"interests": ["文化场馆", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 0,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": true,
|
||||
"rainyDaySuitable": true,
|
||||
"recommendedMinutes": 120,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区观光路东北侧",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "图书馆、展览和演出开放安排请分别查询官方公告",
|
||||
"ticketText": "各空间及活动费用请以官方公告为准",
|
||||
"summary": "集图书馆、演艺、展览与休闲空间于一体的综合文化地标。",
|
||||
"tips": ["不同场馆开放安排可能不同", "观演活动通常需要单独预约或购票"],
|
||||
"sourceName": "光明区文化广电旅游体育局",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/zjgm/lsgm_118488/tsgm_118491/content/post_8774880.html",
|
||||
"updatedAt": "2025-03-24"
|
||||
},
|
||||
{
|
||||
"id": "guangming-science-city-exhibition",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "光明科学城展示中心",
|
||||
"type": "exhibition",
|
||||
"area": "新湖街道",
|
||||
"themes": ["朋友", "研学"],
|
||||
"interests": ["文化场馆", "生态科普"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 6,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": true,
|
||||
"rainyDaySuitable": true,
|
||||
"recommendedMinutes": 75,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区新湖街道圳园路南侧",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "接待和开放信息请提前向官方渠道确认",
|
||||
"ticketText": "参观安排请以官方公告为准",
|
||||
"summary": "以展览展示功能介绍光明科学城规划与建设,适合城市与科学主题研学。",
|
||||
"tips": ["并非常规全天候开放景点,务必提前确认", "适合与科学公园或欢乐田园组合"],
|
||||
"sourceName": "光明区科学城开发建设署",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/gmcsfzcjzx/gkmlpt/content/10/10366/post_10366147.html",
|
||||
"updatedAt": "2023-01-03"
|
||||
},
|
||||
{
|
||||
"id": "guangming-ebohr-museum",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "依波钟表文化博物馆",
|
||||
"type": "museum",
|
||||
"area": "马田街道",
|
||||
"themes": ["亲子", "朋友", "研学"],
|
||||
"interests": ["文化场馆", "生态科普"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 6,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": true,
|
||||
"rainyDaySuitable": true,
|
||||
"recommendedMinutes": 75,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区钟表产业基地依波大厦",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "工业旅游参观安排请提前向场馆确认",
|
||||
"ticketText": "参观费用请以场馆最新信息为准",
|
||||
"summary": "通过古代计时、钟表历史和制表技术展项展示时间文化与工业设计。",
|
||||
"tips": ["工业旅游场馆建议提前预约确认", "适合学龄儿童及成人"],
|
||||
"sourceName": "Guangming Government Online",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12165124.html",
|
||||
"updatedAt": "2025-05-09"
|
||||
},
|
||||
{
|
||||
"id": "guangming-fiyta-center",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "飞亚达计时文化中心",
|
||||
"type": "museum",
|
||||
"area": "马田街道",
|
||||
"themes": ["亲子", "朋友", "研学"],
|
||||
"interests": ["文化场馆", "生态科普"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 6,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": true,
|
||||
"rainyDaySuitable": true,
|
||||
"recommendedMinutes": 75,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区钟表产业基地",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "工业旅游参观安排请提前向场馆确认",
|
||||
"ticketText": "参观费用请以场馆最新信息为准",
|
||||
"summary": "融合钟表文化、工业旅游和科普教育,包含航天主题展示与制表体验。",
|
||||
"tips": ["体验项目和开放安排需要提前确认", "适合作为雨天研学备选"],
|
||||
"sourceName": "Guangming Government Online",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12165107.html",
|
||||
"updatedAt": "2025-05-09"
|
||||
},
|
||||
{
|
||||
"id": "guangming-honghua-park",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "红花山公园",
|
||||
"type": "park",
|
||||
"area": "公明街道",
|
||||
"themes": ["亲子", "情侣", "朋友", "银发"],
|
||||
"interests": ["自然风光", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 0,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": false,
|
||||
"rainyDaySuitable": false,
|
||||
"recommendedMinutes": 80,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区公明街道",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "开放区域请以公园现场公告为准",
|
||||
"ticketText": "公共区域费用信息请以现场公告为准",
|
||||
"summary": "结合荔枝林、草坪、儿童活动和休闲设施的社区型生态公园。",
|
||||
"tips": ["亲子活动请留意儿童设施现场状态", "户外活动注意防晒"],
|
||||
"sourceName": "Guangming Government Online",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12164759.html",
|
||||
"updatedAt": "2025-05-09"
|
||||
},
|
||||
{
|
||||
"id": "guangming-rosewood-town",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "光明红木文化古镇",
|
||||
"type": "cultural_town",
|
||||
"area": "公明街道",
|
||||
"themes": ["亲子", "情侣", "朋友", "银发", "研学"],
|
||||
"interests": ["文化场馆", "美食", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 0,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": true,
|
||||
"rainyDaySuitable": true,
|
||||
"recommendedMinutes": 100,
|
||||
"budgetLevel": "standard",
|
||||
"address": "深圳市光明区公明街道北环大道与民生路交会处",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "各展馆和商户开放安排请以现场公告为准",
|
||||
"ticketText": "费用请以现场公告为准",
|
||||
"summary": "以红木工艺和岭南文化为主题,包含文化展陈、传统街区和特色商业。",
|
||||
"tips": ["各主题空间开放时间可能不同", "适合安排为文化参观与用餐衔接点"],
|
||||
"sourceName": "Guangming Government Online",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12164714.html",
|
||||
"updatedAt": "2025-05-09"
|
||||
},
|
||||
{
|
||||
"id": "guangming-nanguang-green-zone",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "南光绿境",
|
||||
"type": "park",
|
||||
"area": "茅洲河沿线",
|
||||
"themes": ["亲子", "情侣", "朋友"],
|
||||
"interests": ["自然风光", "生态科普", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 0,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": false,
|
||||
"rainyDaySuitable": false,
|
||||
"recommendedMinutes": 75,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区茅洲河沿线",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "开放范围请以公园现场公告为准",
|
||||
"ticketText": "活动项目费用请以运营方公告为准",
|
||||
"summary": "由旧净水设施及桥下空间更新而成的滨水休闲区,包含运动和儿童活动空间。",
|
||||
"tips": ["水上活动需另行确认开放与安全要求", "儿童在滨水区域需由成人看护"],
|
||||
"sourceName": "Guangming Government Online",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163075.html",
|
||||
"updatedAt": "2025-05-08"
|
||||
},
|
||||
{
|
||||
"id": "guangming-zuoan-tech-park",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "左岸科技公园",
|
||||
"type": "park",
|
||||
"area": "茅洲河沿线",
|
||||
"themes": ["亲子", "情侣", "朋友", "银发"],
|
||||
"interests": ["自然风光", "生态科普", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 0,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": false,
|
||||
"rainyDaySuitable": false,
|
||||
"recommendedMinutes": 75,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区茅洲河沿线",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "开放范围请以公园现场公告为准",
|
||||
"ticketText": "公共区域费用信息请以现场公告为准",
|
||||
"summary": "沿茅洲河建设的生态修复型公园,适合滨水散步、观察城市生态更新。",
|
||||
"tips": ["滨水步行注意儿童安全", "雨天请留意步道路面"],
|
||||
"sourceName": "Guangming Government Online",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163102.html",
|
||||
"updatedAt": "2025-05-08"
|
||||
},
|
||||
{
|
||||
"id": "guangming-minghu-park",
|
||||
"city": "深圳市",
|
||||
"district": "光明区",
|
||||
"name": "明湖城市公园",
|
||||
"type": "park",
|
||||
"area": "马田街道",
|
||||
"themes": ["亲子", "情侣", "朋友"],
|
||||
"interests": ["自然风光", "摄影"],
|
||||
"suitableFor": ["儿童", "成人"],
|
||||
"minAge": 0,
|
||||
"pace": ["relaxed", "moderate"],
|
||||
"intensity": "low",
|
||||
"indoor": false,
|
||||
"rainyDaySuitable": false,
|
||||
"recommendedMinutes": 90,
|
||||
"budgetLevel": "economy",
|
||||
"address": "深圳市光明区光明大道、光明国际汽车城旁",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"openTimeText": "开放范围及活动请以公园公告为准",
|
||||
"ticketText": "活动项目费用请以现场公告为准",
|
||||
"summary": "融合湖畔休闲、亲子市集和汽车主题活动的城市公园。",
|
||||
"tips": ["露营和市集活动并非常设内容", "湖边活动请照看儿童"],
|
||||
"sourceName": "Guangming Government Online",
|
||||
"sourceUrl": "https://www.szgm.gov.cn/english/travel/touristsites/content/post_12163086.html",
|
||||
"updatedAt": "2025-05-08"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{"from":"guangming-science-museum","to":"guangming-science-park","walking":12,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-science-park","to":"guangming-happy-farm","walking":35,"driving":10,"public_transport":24,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-science-museum","to":"guangming-happy-farm","walking":42,"driving":12,"public_transport":28,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-happy-farm","to":"guangming-farm-grand-view","walking":30,"driving":9,"public_transport":22,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-farm-grand-view","to":"guangming-hongqiao-park","walking":28,"driving":9,"public_transport":20,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-hongqiao-park","to":"guangming-dadingling-greenway","walking":15,"driving":6,"public_transport":18,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-science-museum","to":"guangming-culture-art-center","walking":55,"driving":14,"public_transport":28,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-science-park","to":"guangming-science-city-exhibition","walking":22,"driving":7,"public_transport":17,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-science-city-exhibition","to":"guangming-happy-farm","walking":18,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-ebohr-museum","to":"guangming-fiyta-center","walking":18,"driving":6,"public_transport":15,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-ebohr-museum","to":"guangming-minghu-park","walking":35,"driving":10,"public_transport":22,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-fiyta-center","to":"guangming-minghu-park","walking":30,"driving":9,"public_transport":20,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-honghua-park","to":"guangming-rosewood-town","walking":25,"driving":8,"public_transport":18,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-rosewood-town","to":"guangming-nanguang-green-zone","walking":45,"driving":12,"public_transport":25,"note":"POC估算,请以实际导航为准"},
|
||||
{"from":"guangming-nanguang-green-zone","to":"guangming-zuoan-tech-park","walking":22,"driving":7,"public_transport":16,"note":"POC估算,请以实际导航为准"}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: guangming-travel-assistant
|
||||
Version: 0.1.0
|
||||
Summary: Shenzhen Guangming travel planning assistant POC
|
||||
Requires-Python: >=3.9
|
||||
Requires-Dist: fastapi<1.0.0,>=0.115.0
|
||||
Requires-Dist: openai<2.0.0,>=1.50.0
|
||||
Requires-Dist: pydantic<2.13.0,>=2.8.0
|
||||
Requires-Dist: python-dotenv<2.0.0,>=1.0.0
|
||||
Requires-Dist: uvicorn[standard]<1.0.0,>=0.30.0
|
||||
Provides-Extra: test
|
||||
Requires-Dist: httpx<1.0.0,>=0.27.0; extra == "test"
|
||||
Requires-Dist: pytest<9.0.0,>=8.0.0; extra == "test"
|
||||
@@ -0,0 +1,15 @@
|
||||
pyproject.toml
|
||||
app/__init__.py
|
||||
app/knowledge.py
|
||||
app/llm.py
|
||||
app/main.py
|
||||
app/planner.py
|
||||
app/prompts.py
|
||||
app/schemas.py
|
||||
guangming_travel_assistant.egg-info/PKG-INFO
|
||||
guangming_travel_assistant.egg-info/SOURCES.txt
|
||||
guangming_travel_assistant.egg-info/dependency_links.txt
|
||||
guangming_travel_assistant.egg-info/requires.txt
|
||||
guangming_travel_assistant.egg-info/top_level.txt
|
||||
tests/test_api.py
|
||||
tests/test_knowledge.py
|
||||
@@ -0,0 +1,9 @@
|
||||
fastapi<1.0.0,>=0.115.0
|
||||
openai<2.0.0,>=1.50.0
|
||||
pydantic<2.13.0,>=2.8.0
|
||||
python-dotenv<2.0.0,>=1.0.0
|
||||
uvicorn[standard]<1.0.0,>=0.30.0
|
||||
|
||||
[test]
|
||||
httpx<1.0.0,>=0.27.0
|
||||
pytest<9.0.0,>=8.0.0
|
||||
@@ -0,0 +1 @@
|
||||
app
|
||||
@@ -0,0 +1,29 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=64"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "guangming-travel-assistant"
|
||||
version = "0.1.0"
|
||||
description = "Shenzhen Guangming travel planning assistant POC"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0,<1.0.0",
|
||||
"openai>=1.50.0,<2.0.0",
|
||||
"pydantic>=2.8.0,<2.13.0",
|
||||
"python-dotenv>=1.0.0,<2.0.0",
|
||||
"uvicorn[standard]>=0.30.0,<1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"httpx>=0.27.0,<1.0.0",
|
||||
"pytest>=8.0.0,<9.0.0",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["app"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,99 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def preferences(**overrides):
|
||||
payload = {
|
||||
"destination": "深圳市光明区",
|
||||
"themes": ["亲子"],
|
||||
"duration": "half_day",
|
||||
"pace": "relaxed",
|
||||
"interests": ["自然风光", "生态科普"],
|
||||
"adults": 2,
|
||||
"children": 1,
|
||||
"childAges": [6],
|
||||
"transport": "public_transport",
|
||||
"budgetLevel": "standard",
|
||||
"extraRequirements": "",
|
||||
}
|
||||
payload.update(overrides)
|
||||
return payload
|
||||
|
||||
|
||||
def test_health_reports_knowledge_and_mock_model():
|
||||
response = client.get("/api/v1/health")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["knowledgeCount"] >= 15
|
||||
assert data["modelConfigured"] is True
|
||||
|
||||
|
||||
def test_create_plan_only_returns_known_guangming_places():
|
||||
response = client.post("/api/v1/plans", json=preferences())
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["conversationId"]
|
||||
assert data["itinerary"]["items"]
|
||||
assert len(data["itinerary"]["items"]) >= 2
|
||||
assert len(data["itinerary"]["items"]) <= 3
|
||||
assert all(
|
||||
item["placeId"].startswith("guangming-")
|
||||
for item in data["itinerary"]["items"]
|
||||
)
|
||||
assert len(data["itinerary"]["sources"]) == len(
|
||||
{item["placeId"] for item in data["itinerary"]["items"]}
|
||||
)
|
||||
|
||||
|
||||
def test_indoor_requirement_filters_out_outdoor_places():
|
||||
response = client.post(
|
||||
"/api/v1/plans",
|
||||
json=preferences(
|
||||
themes=["研学"],
|
||||
interests=["文化场馆"],
|
||||
extraRequirements="下午下雨,只安排室内项目",
|
||||
),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
outdoor_ids = {
|
||||
"guangming-science-park",
|
||||
"guangming-happy-farm",
|
||||
"guangming-hongqiao-park",
|
||||
"guangming-dadingling-greenway",
|
||||
}
|
||||
ids = {item["placeId"] for item in response.json()["itinerary"]["items"]}
|
||||
assert ids.isdisjoint(outdoor_ids)
|
||||
|
||||
|
||||
def test_adjust_existing_plan():
|
||||
created = client.post("/api/v1/plans", json=preferences()).json()
|
||||
response = client.post(
|
||||
f"/api/v1/conversations/{created['conversationId']}/messages",
|
||||
json={"message": "改成室内研学路线"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["conversationId"] == created["conversationId"]
|
||||
assert data["changeSummary"]
|
||||
assert data["itinerary"]["items"]
|
||||
|
||||
|
||||
def test_missing_preferences_are_rejected():
|
||||
response = client.post(
|
||||
"/api/v1/plans",
|
||||
json=preferences(themes=[], interests=[]),
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_expired_or_unknown_session_is_404():
|
||||
response = client.post(
|
||||
"/api/v1/conversations/not-found/messages",
|
||||
json={"message": "慢一点"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
@@ -0,0 +1,31 @@
|
||||
from app.knowledge import KnowledgeBase
|
||||
from app.schemas import TravelPreferences
|
||||
|
||||
|
||||
def make_preferences(extra_requirements: str = "") -> TravelPreferences:
|
||||
return TravelPreferences(
|
||||
themes=["亲子"],
|
||||
duration="half_day",
|
||||
pace="relaxed",
|
||||
interests=["自然风光"],
|
||||
adults=2,
|
||||
children=1,
|
||||
child_ages=[3],
|
||||
transport="public_transport",
|
||||
budget_level="standard",
|
||||
extra_requirements=extra_requirements,
|
||||
)
|
||||
|
||||
|
||||
def test_retrieval_is_deterministic():
|
||||
knowledge = KnowledgeBase()
|
||||
first = [place["id"] for place in knowledge.retrieve(make_preferences())]
|
||||
second = [place["id"] for place in knowledge.retrieve(make_preferences())]
|
||||
assert first == second
|
||||
|
||||
|
||||
def test_avoid_climbing_excludes_high_intensity_places():
|
||||
knowledge = KnowledgeBase()
|
||||
result = knowledge.retrieve(make_preferences("带婴儿车,不爬山"))
|
||||
assert all(place["intensity"] != "high" for place in result)
|
||||
|
||||
Reference in New Issue
Block a user