Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Python / backend
.venv/
.uv-cache/
__pycache__/
*.pyc
.pytest_cache/
Expand All @@ -10,6 +11,7 @@ backend/data/

# Node / frontend
node_modules/
.npm-cache/
dist/
.vite/
*.tsbuildinfo
Expand Down
25 changes: 21 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,29 +70,44 @@

## 启动

新贡献者可先阅读 [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md),里面包含本地验证命令和常见环境问题。

### 后端(终端 1)

```bash
cd backend
uv sync # 创建 .venv 并安装依赖
cp .env.example .env # 按需填写 LLM / 高德 凭证(可留空)
uv sync # 创建 .venv 并安装依赖
uv run uvicorn app.main:app --reload --port 8000
```

启动钩子会自动建表(无需手动 `init_db`;无默认账号,访客可直接规划)。`uv run python -m app.seed` 可选,塞一条「成都三天」示例行程。
测试:在 `backend` 目录执行 `uv run pytest`(CRUD / Workflow 契约(含截断修复、POI 列表估天数)/ 候选 / 选城 / 交通 / 鉴权 / schema 校验,**46 项**)。
测试:在 `backend` 目录执行 `uv run pytest`(CRUD / Workflow 契约(含截断修复、POI 列表估天数)/ 候选 / 选城 / 高德 POI 服务 / 交通 / 鉴权 / schema 校验 / DB 初始化,**60 项**)。

受限沙箱或 CI 环境如果不能写用户级缓存,可用项目内缓存:

```bash
UV_CACHE_DIR=.uv-cache uv sync
UV_CACHE_DIR=.uv-cache uv run pytest
```

### 前端(终端 2)

```bash
cd frontend
npm install
cp .env.example .env # VITE_API_BASE 默认 http://localhost:8000
npm install
npm run dev # http://localhost:5173(被占用自动切 5174)
```

测试:`npm test`(Vitest,10 项);类型检查 + 构建:`npm run build`。

受限沙箱或 CI 环境如果不能写用户级 npm cache,可用项目内缓存:

```bash
npm install --cache .npm-cache
```

## 账号与登录

- 无登录页、无默认账号:访客可完整体验规划流程(草案存于前端,不落库)。
Expand All @@ -111,10 +126,11 @@ npm run dev # http://localhost:5173(被占用自动
## 地图配置

在 `frontend/.env` 填入高德 JS API 2.0 凭证后,工作台右侧地图实时打点连线;留空则地图区降级为坐标列表(不白屏)。
在 `backend/.env` 填入可选的 `AMAP_KEY` 后,后端 POI 候选和交通兜底会优先使用高德 Web 服务;不填则继续使用本地桩数据,不影响本地跑通。

- 控制台 https://console.amap.com/dev/key/app → 应用管理 → 创建应用 → 添加 Key → 服务平台选「Web端(JS API)」。
- 拿到 `VITE_AMAP_JS_KEY` 与安全密钥 `VITE_AMAP_SECURITY_CODE`(jscode,JS API 2.0 必填)。
- 交通时长 / 路径由前端高德插件(`AMap.Driving/Walking/Transfer`)按需计算;点聚合用 `AMap.MarkerCluster`;后端 `AMAP_KEY` 为 `POST /transit/recompute` 兜底用
- 交通时长 / 路径由前端高德插件(`AMap.Driving/Walking/Transfer`)按需计算;点聚合用 `AMap.MarkerCluster`;后端 `AMAP_KEY` 为 `POST /transit/recompute` 与 POI 候选兜底用

## API 一览

Expand All @@ -131,6 +147,7 @@ npm run dev # http://localhost:5173(被占用自动
## 自检

- `curl http://localhost:8000/health` → `{"status":"ok"}`。
- 若当前环境不能绑定本地端口,可在 `backend` 目录执行 `uv run python -c 'from fastapi.testclient import TestClient; from app.main import app; r = TestClient(app).get("/health"); print(r.status_code, r.json())'` 做进程内健康检查。
- 注册并登录:`curl -X POST .../auth/register -H 'Content-Type: application/json' -d '{"email":"you@example.com","password":"itravel123"}'` 得 token。
- 流式规划:`curl -N -X POST .../plan/stream -H 'Content-Type: application/json' -d '{"destination":"成都","origin":"北京","day_count":2,"free_text":"想轻松逛吃"}'` → 一串 `event: status/intent/skeleton/reply/day/itinerary/done`。
- 景点候选:`curl -X POST .../plan/candidates -H 'Content-Type: application/json' -d '{"city":"杭州","category":"play","limit":4}'` → `{pois, degraded}`。
Expand Down
2 changes: 1 addition & 1 deletion backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ OPENAI_REASONING_EFFORT=
# 本应用只需结构化 JSON,建议 disabled —— 必须显式关闭,仅省略不生效。
OPENAI_THINKING=disabled

# 高德地图(服务端 Key,M0 不调用,预留)
# 高德地图 Web 服务 Key(可选)。不填也能本地跑通;POI / 交通会走本地兜底。
AMAP_KEY=

# 鉴权(M5 启用;本地随便填一个长随机串即可)
Expand Down
2 changes: 1 addition & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class Settings(BaseSettings):
# 兼容 true/false:true→enabled,false→disabled。
openai_thinking: str = ""

# 高德(M0 预留)
# 高德 Web 服务 Key(可选):未配置时 POI / 交通服务自动走本地兜底。
amap_key: str = ""

jwt_secret: str = "dev-only-change-me"
Expand Down
13 changes: 13 additions & 0 deletions backend/app/db.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections.abc import Generator
from pathlib import Path

from sqlmodel import Session, SQLModel, create_engine

Expand All @@ -14,10 +15,22 @@
engine = create_engine(settings.database_url, echo=False, connect_args=connect_args)


def _ensure_sqlite_parent_dir() -> None:
if not engine.url.drivername.startswith("sqlite"):
return
database = engine.url.database
if not database or database == ":memory:":
return
path = Path(database)
if path.parent != Path("."):
path.parent.mkdir(parents=True, exist_ok=True)


def init_db() -> None:
# 导入 models 以注册到 SQLModel.metadata
import app.models # noqa: F401

_ensure_sqlite_parent_dir()
SQLModel.metadata.create_all(engine)


Expand Down
132 changes: 132 additions & 0 deletions backend/app/services/amap_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
from dataclasses import dataclass

import httpx
from cachetools import TTLCache

from app import amap_stub, validators
from app.config import settings
from app.schemas import POICreate

_SEARCH_URL = "https://restapi.amap.com/v3/place/text"
_CACHE: TTLCache = TTLCache(maxsize=256, ttl=3600)

_CATEGORY_KEYWORD = {
"eat": "美食",
"stay": "酒店",
"play": "景点",
}
_CATEGORY_TYPES = {
"eat": "050000",
"stay": "100000",
"play": "110000",
}


@dataclass
class CandidateResult:
pois: list[POICreate]
degraded: bool


def clear_cache() -> None:
_CACHE.clear()


def _cache_key(city: str, category: str, keyword: str, limit: int) -> str:
return f"{city.strip()}|{category}|{keyword.strip()}|{limit}"


def _text(value) -> str | None:
if isinstance(value, str):
value = value.strip()
return value or None
return None


def _parse_location(value) -> tuple[float, float] | None:
if not isinstance(value, str) or "," not in value:
return None
lng_s, lat_s = value.split(",", 1)
try:
lng, lat = float(lng_s), float(lat_s)
except ValueError:
return None
if not validators.valid_coord(lng, lat):
return None
return lng, lat


def _poi_from_amap(raw: dict, category: str) -> POICreate | None:
name = _text(raw.get("name"))
loc = _parse_location(raw.get("location"))
if not name or loc is None:
return None
lng, lat = loc
return POICreate(
amap_id=_text(raw.get("id")),
name=name,
category=category,
lng=lng,
lat=lat,
address=_text(raw.get("address")),
)


def _query_amap(city: str, category: str, keyword: str, limit: int) -> list[POICreate]:
if not settings.amap_key:
return []
query = keyword.strip() or _CATEGORY_KEYWORD.get(category, "景点")
offset = max(1, min(max(limit, 10), 25))
params = {
"key": settings.amap_key,
"keywords": query,
"types": _CATEGORY_TYPES.get(category, ""),
"city": city,
"citylimit": "true",
"offset": str(offset),
"page": "1",
"extensions": "base",
"output": "JSON",
}
response = httpx.get(_SEARCH_URL, params=params, timeout=4.0)
data = response.json()
if data.get("status") != "1":
return []
pois = data.get("pois")
if not isinstance(pois, list):
return []
parsed = [_poi_from_amap(p, category) for p in pois if isinstance(p, dict)]
return [p for p in parsed if p is not None]


def candidates(
city: str,
category: str,
*,
keyword: str = "",
exclude: set[str] | None = None,
limit: int = 4,
) -> CandidateResult:
exclude = exclude or set()
if not settings.amap_key:
fallback = amap_stub.candidates(city, category, exclude=exclude, limit=limit)
return CandidateResult(pois=fallback, degraded=True)

query_limit = max(limit * 2, limit)
key = _cache_key(city, category, keyword, query_limit)

pois = _CACHE.get(key)
if pois is None:
try:
pois = _query_amap(city, category, keyword, query_limit)
except Exception:
pois = []
if pois:
_CACHE[key] = pois

filtered = [p for p in pois if p.name not in exclude] if pois else []
if filtered:
return CandidateResult(pois=filtered[:limit], degraded=False)

fallback = amap_stub.candidates(city, category, exclude=exclude, limit=limit)
return CandidateResult(pois=fallback, degraded=True)
13 changes: 10 additions & 3 deletions backend/app/services/plan_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import logging
from collections.abc import Callable, Iterator

from app import amap_stub, llm, validators, workflow
from app import llm, validators, workflow
from app.services import amap_service
from app.schemas import ItineraryCreate

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -258,6 +259,7 @@ def stream_plan_events(req) -> Iterator[str]:

def _poi_out(p) -> dict:
return {
"amap_id": p.amap_id,
"name": p.name,
"category": p.category,
"lng": p.lng,
Expand Down Expand Up @@ -299,8 +301,13 @@ def candidate_pois(
return {"pois": [_poi_out(p) for p in pois[:limit]], "degraded": False}
except Exception:
logger.info("candidates_degraded city=%s category=%s", city, category)
stub = amap_stub.candidates(city, category or "play", limit=limit)
return {"pois": [_poi_out(p) for p in stub], "degraded": True}
result = amap_service.candidates(
city,
category or "play",
keyword=keyword,
limit=limit,
)
return {"pois": [_poi_out(p) for p in result.pois], "degraded": result.degraded}


# ---- 候选城市(route_first path B 选城,#11)----
Expand Down
12 changes: 7 additions & 5 deletions backend/app/services/poi_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

from app import amap_stub, llm, validators, workflow
from app import llm, validators, workflow
from app.services import amap_service

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -30,6 +31,7 @@ def build_category_prompt(city: str, category: str, exclude: list[str]) -> list[
def poi_dicts(pois) -> list[dict]:
return [
{
"amap_id": p.amap_id,
"name": p.name,
"category": p.category,
"lng": p.lng,
Expand Down Expand Up @@ -64,7 +66,8 @@ def regenerate_llm(city: str, category: str, exclude: list[str]) -> tuple[list,
logger.exception("poi_regenerate_failed city=%s category=%s", city, category)
break
logger.info("poi_degraded city=%s category=%s", city, category)
return amap_stub.candidates(city, category, exclude=exclude_set), True
result = amap_service.candidates(city, category, exclude=exclude_set)
return result.pois, result.degraded


def get_candidates(
Expand All @@ -80,8 +83,7 @@ def get_candidates(
if regenerate:
pois, degraded = regenerate_llm(city, category, exclude_items)
else:
pois, degraded = amap_stub.candidates(
city, category, exclude=set(exclude_items)
), False
result = amap_service.candidates(city, category, exclude=set(exclude_items))
pois, degraded = result.pois, result.degraded

return {"pois": poi_dicts(pois), "degraded": degraded}
Loading