feat: 交通信息与 AI 解耦,完善出发/回程路线显示

- 从 finalize_plan schema 和 agent 提示词中移除 transit 字段,AI 只负责活动/POI/坐标
- 新增 enrichTransitInfo:计划生成后查询高德 V3 公交 API,计算出发地→首活动、活动间、末活动→出发地三段交通
- parseTransitSegments 增加起终点站显示(去除线路名中的全程终点括号)
- WeekendPlanData 新增 transitFromStart/transitToEnd 字段
- BlindboxPlan 新增出发地和返回出发地交通连接器,传入 startLocationLabel 显示具体地址
- BlindBoxRoom schema 新增 address 字段存完整逆地理地址,city 保留供 API 使用
- 新增 /api/debug/transit 调试端点(仅开发环境)
- agent userPrompt 要求将出发/回程时间计入全天时间预算
This commit is contained in:
2026-03-02 16:35:38 +08:00
parent e5a255a49e
commit 99120a7042
8 changed files with 262 additions and 30 deletions
+119 -27
View File
@@ -125,11 +125,15 @@ function parseTransitSegments(
const bus = seg.bus as { buslines?: Record<string, unknown>[] } | undefined;
if (!bus?.buslines?.length) continue;
for (const line of bus.buslines) {
const name = String(line.name ?? "");
const rawName = String(line.name ?? "");
const name = rawName.replace(/\(.*?\)$/, "").trim(); // strip terminal info like "(上海南站--江杨北路)"
const viaNum = Number(line.via_num ?? 0);
const isSubway = String(line.type ?? "").includes("地铁") || String(line.type ?? "").includes("轨道");
if (isSubway) hasSubway = true; else hasBus = true;
parts.push(viaNum > 0 ? `${name}(${viaNum}站)` : name);
const dep = (line.departure_stop as Record<string, unknown> | undefined)?.name;
const arr = (line.arrival_stop as Record<string, unknown> | undefined)?.name;
const stops = dep && arr ? ` ${dep}${arr}` : "";
parts.push(viaNum > 0 ? `${name}${stops}(${viaNum}站)` : `${name}${stops}`);
}
}
@@ -231,9 +235,8 @@ const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下
2. 根据时间、多样性、强度平衡选出合适的活动组合
3. 为每个活动 search_poi 找到具体地点
4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜
5. 用 get_travel_time 检查关键路段的公共交通时间,如果某段超过 45 分钟考虑换更近的地点
6. 综合地理位置、时间安排、活动特点,规划最优路线
7. 确认无误后 finalize_plan 提交,每个活动填写 transit_to_next_minutes 和 transit_to_next_description(直接复制 get_travel_time 返回的 durationMin 和 description,最后一项不填)
5. 用 get_travel_time 检查关键路段,如果某段超过 45 分钟考虑换更近的地点
6. 确认地点合理后 finalize_plan 提交
规划原则:
- 地理位置相近,最小化移动距离
@@ -257,8 +260,6 @@ interface FinalizePlanDay {
lng: number;
duration: number;
reason: string;
transit_to_next_minutes?: number;
transit_to_next_description?: string;
}[];
summary: string;
}
@@ -350,12 +351,13 @@ function buildAgentTools(
const dLat = Number(args.dest_lat);
const dLng = Number(args.dest_lng);
const apiKey = requireAmapApiKey();
const url = new URL("https://restapi.amap.com/v5/direction/transit/integrated");
const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated");
url.searchParams.set("key", apiKey);
url.searchParams.set("origin", `${oLng},${oLat}`);
url.searchParams.set("destination", `${dLng},${dLat}`);
url.searchParams.set("city", city);
url.searchParams.set("show_fields", "cost");
url.searchParams.set("cityd", city);
url.searchParams.set("output", "json");
try {
const res = await fetch(url.toString());
const data = await res.json();
@@ -404,14 +406,6 @@ function buildAgentTools(
lng: { type: "number" },
duration: { type: "number", description: "时长(分钟)" },
reason: { type: "string", description: "选择理由" },
transit_to_next_minutes: {
type: "number",
description: "从本活动结束后到下一活动的预计交通时间(分钟)。最后一个活动不填",
},
transit_to_next_description: {
type: "string",
description: "到下一活动的交通方式描述,直接使用 get_travel_time 返回的 description 字段值,例如:地铁2号线(6站) → 地铁9号线(3站)。最后一个活动不填",
},
},
required: ["time", "activity", "poi", "address", "lat", "lng", "duration", "reason"],
},
@@ -445,7 +439,8 @@ async function runAgentPlanGeneration(
const userPrompt = `帮我规划行程。
可用时间:${at.date}${at.startHour}:00 - ${at.endHour}:00
用户位置:纬度 ${room.lat},经度 ${room.lng}
出发地(家):纬度 ${room.lat},经度 ${room.lng}
注意:第一个活动需要从出发地出发,最后一个活动结束后需要返回出发地,请用 get_travel_time 评估出发/回程时间并计入全天时间预算(确保最后一个活动结束时间 + 回程时间 ≤ ${at.endHour}:00)。
请开始规划。`;
const result = await runAgentLoop({
@@ -478,14 +473,6 @@ async function runAgentPlanGeneration(
lng: Number(item.lng) || 0,
duration: Number(item.duration) || 60,
reason: String(item.reason ?? ""),
...(item.transit_to_next_minutes != null && Number(item.transit_to_next_minutes) > 0
? {
transitToNext: Math.round(Number(item.transit_to_next_minutes)),
...(item.transit_to_next_description
? { transitDescription: String(item.transit_to_next_description) }
: {}),
}
: {}),
}))
: [],
summary: String(day.summary ?? ""),
@@ -630,6 +617,102 @@ async function runLegacyPlanGeneration(
// ---------------------------------------------------------------------------
// Public entry point (signature unchanged)
// ---------------------------------------------------------------------------
// Post-processing: compute real transit info from Amap and store in plan
// ---------------------------------------------------------------------------
async function queryTransit(
apiKey: string,
oLng: number,
oLat: number,
dLng: number,
dLat: number,
city: string,
): Promise<{ durationMin: number; description: string } | null> {
try {
const url = new URL("https://restapi.amap.com/v3/direction/transit/integrated");
url.searchParams.set("key", apiKey);
url.searchParams.set("origin", `${oLng},${oLat}`);
url.searchParams.set("destination", `${dLng},${dLat}`);
url.searchParams.set("city", city);
url.searchParams.set("cityd", city);
url.searchParams.set("output", "json");
const res = await fetch(url.toString());
const data = await res.json();
if (data.status !== "1" || !data.route?.transits?.length) return null;
const transit = data.route.transits[0];
const { description } = parseTransitSegments(transit.segments ?? []);
return { durationMin: Math.ceil(Number(transit.duration) / 60), description };
} catch {
return null;
}
}
type EnrichDay = {
date: string;
items: Record<string, unknown>[];
summary: string;
transitFromStart?: number;
transitFromStartDescription?: string;
transitToEnd?: number;
transitToEndDescription?: string;
};
async function enrichTransitInfo(
days: EnrichDay[],
city: string,
homeLat: number,
homeLng: number,
): Promise<void> {
const apiKey = requireAmapApiKey();
const cityParam = city || "上海";
for (const day of days) {
const items = day.items;
// From home to first activity
if (items.length > 0 && homeLat && homeLng) {
const dLat = Number(items[0].lat);
const dLng = Number(items[0].lng);
if (dLat && dLng) {
const result = await queryTransit(apiKey, homeLng, homeLat, dLng, dLat, cityParam);
if (result) {
day.transitFromStart = result.durationMin;
day.transitFromStartDescription = result.description;
}
}
}
// Between consecutive activities
for (let i = 0; i < items.length - 1; i++) {
const oLat = Number(items[i].lat);
const oLng = Number(items[i].lng);
const dLat = Number(items[i + 1].lat);
const dLng = Number(items[i + 1].lng);
if (!oLat || !oLng || !dLat || !dLng) continue;
const result = await queryTransit(apiKey, oLng, oLat, dLng, dLat, cityParam);
if (result) {
items[i].transitToNext = result.durationMin;
items[i].transitDescription = result.description;
}
}
// From last activity back home
if (items.length > 0 && homeLat && homeLng) {
const last = items[items.length - 1];
const oLat = Number(last.lat);
const oLng = Number(last.lng);
if (oLat && oLng) {
const result = await queryTransit(apiKey, oLng, oLat, homeLng, homeLat, cityParam);
if (result) {
day.transitToEnd = result.durationMin;
day.transitToEndDescription = result.description;
}
}
}
}
}
// ---------------------------------------------------------------------------
export async function runPlanGeneration(
@@ -698,7 +781,16 @@ export async function runPlanGeneration(
days = legacyResult.days;
}
// 3. Save to DB (shared)
// 3. Compute real transit info server-side (best-effort, errors are swallowed internally)
onProgress?.("正在查询交通信息...");
await enrichTransitInfo(
days as EnrichDay[],
room.city ?? "上海",
room.lat,
room.lng,
);
// 4. Save to DB (shared)
const plan = await prisma.weekendPlan.create({
data: {
roomId,