Initial commit: gmTouringMiniApp project

This commit is contained in:
周瑞哲
2026-07-30 16:04:34 +08:00
commit ebcae02d35
201 changed files with 49545 additions and 0 deletions
+6
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
.env
.venv/
__pycache__/
.pytest_cache/
*.pyc
.DS_Store
+2
View File
@@ -0,0 +1,2 @@
"""Guangming travel assistant POC."""
+105
View File
@@ -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
+203
View File
@@ -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 "作为光明区同路线备选,便于控制整体节奏"
)
+83
View File
@@ -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
+149
View File
@@ -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,
)
+26
View File
@@ -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": ["动态信息需出行前确认"]
}"""
+115
View File
@@ -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
+423
View File
@@ -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
+29
View File
@@ -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"]
+99
View File
@@ -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)