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
+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