feat: 行程规划改用 tool-calling agent,旧逻辑保留为降级方案
ai.ts 新增通用 runAgentLoop(),blindboxPlanGen.ts 拆分为 agent 主路径(list_ideas/search_poi/finalize_plan 三个工具) 和 legacy 降级路径,agent 失败时自动回退。 AI 思考和工具调用实时推送给前端。
This commit is contained in:
+134
@@ -1,4 +1,8 @@
|
|||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
|
import type {
|
||||||
|
ChatCompletionMessageParam,
|
||||||
|
ChatCompletionTool,
|
||||||
|
} from "openai/resources/chat/completions";
|
||||||
import type { IdeaTags, PlanItem } from "@/types";
|
import type { IdeaTags, PlanItem } from "@/types";
|
||||||
|
|
||||||
function getClient() {
|
function getClient() {
|
||||||
@@ -224,3 +228,133 @@ ${Object.entries(ctx.candidates)
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Generic tool-calling agent loop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface AgentTool {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Record<string, unknown>; // JSON Schema for the tool parameters
|
||||||
|
execute: (args: Record<string, unknown>) => Promise<string>;
|
||||||
|
progressBefore?: (args: Record<string, unknown>) => string;
|
||||||
|
progressAfter?: (args: Record<string, unknown>, result: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentLoopOptions {
|
||||||
|
systemPrompt: string;
|
||||||
|
userPrompt: string;
|
||||||
|
tools: AgentTool[];
|
||||||
|
onProgress?: (message: string) => void;
|
||||||
|
maxTurns?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentLoopResult {
|
||||||
|
finalArgs: Record<string, unknown>;
|
||||||
|
turns: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAgentLoop(
|
||||||
|
options: AgentLoopOptions,
|
||||||
|
): Promise<AgentLoopResult | null> {
|
||||||
|
const { systemPrompt, userPrompt, tools, onProgress, maxTurns = 15 } = options;
|
||||||
|
const client = getClient();
|
||||||
|
|
||||||
|
const openaiTools: ChatCompletionTool[] = tools.map((t) => ({
|
||||||
|
type: "function" as const,
|
||||||
|
function: {
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
parameters: t.parameters,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const toolMap = new Map(tools.map((t) => [t.name, t]));
|
||||||
|
|
||||||
|
const messages: ChatCompletionMessageParam[] = [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: userPrompt },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let turn = 0; turn < maxTurns; turn++) {
|
||||||
|
const response = await client.chat.completions.create({
|
||||||
|
model: "deepseek-chat",
|
||||||
|
messages,
|
||||||
|
tools: openaiTools,
|
||||||
|
temperature: 0.6,
|
||||||
|
max_tokens: 4096,
|
||||||
|
});
|
||||||
|
|
||||||
|
const choice = response.choices[0];
|
||||||
|
if (!choice) break;
|
||||||
|
|
||||||
|
const assistantMessage = choice.message;
|
||||||
|
|
||||||
|
// Push AI thinking text to the user
|
||||||
|
if (assistantMessage.content) {
|
||||||
|
onProgress?.(assistantMessage.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No tool calls — conversation ended
|
||||||
|
if (!assistantMessage.tool_calls || assistantMessage.tool_calls.length === 0) {
|
||||||
|
// Add the assistant message and break if there's nothing more to do
|
||||||
|
messages.push({ role: "assistant", content: assistantMessage.content ?? "" });
|
||||||
|
if (choice.finish_reason === "stop") break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the assistant message (with tool_calls) to history
|
||||||
|
messages.push(assistantMessage as ChatCompletionMessageParam);
|
||||||
|
|
||||||
|
// Execute each tool call
|
||||||
|
for (const toolCall of assistantMessage.tool_calls) {
|
||||||
|
if (toolCall.type !== "function") continue;
|
||||||
|
const fnName = toolCall.function.name;
|
||||||
|
const tool = toolMap.get(fnName);
|
||||||
|
if (!tool) {
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
content: JSON.stringify({ error: `Unknown tool: ${fnName}` }),
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let args: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
args = JSON.parse(toolCall.function.arguments);
|
||||||
|
} catch {
|
||||||
|
args = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress: before execution
|
||||||
|
if (tool.progressBefore) {
|
||||||
|
onProgress?.(tool.progressBefore(args));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute
|
||||||
|
const result = await tool.execute(args);
|
||||||
|
|
||||||
|
// Progress: after execution
|
||||||
|
if (tool.progressAfter) {
|
||||||
|
onProgress?.(tool.progressAfter(args, result));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is finalize_plan, return immediately
|
||||||
|
if (fnName === "finalize_plan") {
|
||||||
|
return { finalArgs: args, turns: turn + 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Feed tool result back to the conversation
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: toolCall.id,
|
||||||
|
content: result,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exhausted turns without finalize
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
+313
-49
@@ -1,10 +1,13 @@
|
|||||||
/**
|
/**
|
||||||
* Shared plan generation logic for blindbox weekend plans.
|
* Shared plan generation logic for blindbox weekend plans.
|
||||||
* Supports optional progress callback for streaming UX.
|
* Supports optional progress callback for streaming UX.
|
||||||
|
*
|
||||||
|
* Primary path: tool-calling agent (runAgentPlanGeneration)
|
||||||
|
* Fallback path: legacy pipeline (runLegacyPlanGeneration)
|
||||||
*/
|
*/
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { requireAmapApiKey } from "@/lib/amap";
|
import { requireAmapApiKey } from "@/lib/amap";
|
||||||
import { generateSchedule, type ScheduleContext } from "@/lib/ai";
|
import { generateSchedule, runAgentLoop, type ScheduleContext, type AgentTool } from "@/lib/ai";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
|
|
||||||
export interface PlanGenAvailableTime {
|
export interface PlanGenAvailableTime {
|
||||||
@@ -26,6 +29,10 @@ interface TaggedIdea {
|
|||||||
needsBooking: boolean | null;
|
needsBooking: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Slot-based idea selection (used by legacy path)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SLOT_CATEGORY_MAP: Record<string, string[]> = {
|
const SLOT_CATEGORY_MAP: Record<string, string[]> = {
|
||||||
morning: ["outdoor", "nature", "sports", "culture"],
|
morning: ["outdoor", "nature", "sports", "culture"],
|
||||||
lunch: ["dining"],
|
lunch: ["dining"],
|
||||||
@@ -72,13 +79,11 @@ function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): Tagge
|
|||||||
const remaining = ideas.filter((i) => !usedIds.has(i.id));
|
const remaining = ideas.filter((i) => !usedIds.has(i.id));
|
||||||
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
|
const preferredCategories = SLOT_CATEGORY_MAP[slot] || [];
|
||||||
|
|
||||||
// Priority 1: timeSlot + category both match
|
|
||||||
const p1 = remaining.filter(
|
const p1 = remaining.filter(
|
||||||
(i) => matchesSlot(i, slot) && preferredCategories.includes(i.category),
|
(i) => matchesSlot(i, slot) && preferredCategories.includes(i.category),
|
||||||
);
|
);
|
||||||
let picked = pickRandom(p1);
|
let picked = pickRandom(p1);
|
||||||
|
|
||||||
// Priority 2: category match only (existing logic)
|
|
||||||
if (!picked) {
|
if (!picked) {
|
||||||
for (const cat of preferredCategories) {
|
for (const cat of preferredCategories) {
|
||||||
const pool = remaining.filter((i) => i.category === cat);
|
const pool = remaining.filter((i) => i.category === cat);
|
||||||
@@ -87,13 +92,11 @@ function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): Tagge
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: timeSlot match only
|
|
||||||
if (!picked) {
|
if (!picked) {
|
||||||
const p3 = remaining.filter((i) => matchesSlot(i, slot));
|
const p3 = remaining.filter((i) => matchesSlot(i, slot));
|
||||||
picked = pickRandom(p3);
|
picked = pickRandom(p3);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 4: any remaining
|
|
||||||
if (!picked) {
|
if (!picked) {
|
||||||
picked = pickRandom(remaining);
|
picked = pickRandom(remaining);
|
||||||
}
|
}
|
||||||
@@ -107,6 +110,10 @@ function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): Tagge
|
|||||||
return selected;
|
return selected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POI search (shared by both paths)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
async function searchPois(
|
async function searchPois(
|
||||||
query: string,
|
query: string,
|
||||||
searchType: string,
|
searchType: string,
|
||||||
@@ -159,6 +166,10 @@ function mapPois(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Progress messages (kept for legacy path)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export const PLAN_PROGRESS_MESSAGES = {
|
export const PLAN_PROGRESS_MESSAGES = {
|
||||||
analyzing: "正在分析你们的想法...",
|
analyzing: "正在分析你们的想法...",
|
||||||
searching: "正在搜索地点...",
|
searching: "正在搜索地点...",
|
||||||
@@ -167,57 +178,241 @@ export const PLAN_PROGRESS_MESSAGES = {
|
|||||||
almostDone: "快好了...",
|
almostDone: "快好了...",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Result type
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export interface PlanGenResult {
|
export interface PlanGenResult {
|
||||||
id: string;
|
id: string;
|
||||||
days: { date: string; items: unknown[]; summary: string }[];
|
days: { date: string; items: unknown[]; summary: string }[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runPlanGeneration(
|
// ---------------------------------------------------------------------------
|
||||||
roomId: string,
|
// Agent path: tool-calling agent
|
||||||
userId: string,
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const AGENT_SYSTEM_PROMPT = `你是一个周末行程规划 Agent。你有以下工具可以使用:
|
||||||
|
- list_ideas: 查看想法池中的所有活动
|
||||||
|
- search_poi: 在地图上搜索地点(支持品牌名、地名、品类搜索)
|
||||||
|
- finalize_plan: 提交最终行程方案
|
||||||
|
|
||||||
|
规划流程:
|
||||||
|
1. 先用 list_ideas 了解有哪些活动可选
|
||||||
|
2. 根据时间、多样性、强度平衡选出合适的活动组合
|
||||||
|
3. 为每个活动 search_poi 找到具体地点
|
||||||
|
4. 如果搜索结果不理想(0结果或不相关),尝试换关键词重搜
|
||||||
|
5. 综合地理位置、时间安排、活动特点,规划最优路线
|
||||||
|
6. 确认无误后 finalize_plan 提交
|
||||||
|
|
||||||
|
规划原则:
|
||||||
|
- 地理位置相近,最小化移动距离
|
||||||
|
- 尊重时间偏好(morning 的活动放上午,dining 在饭点)
|
||||||
|
- 活动间留 15-30 分钟交通时间
|
||||||
|
- 高低强度交替,避免连续高体力活动
|
||||||
|
- 费用均衡,不连续安排 premium 活动
|
||||||
|
- needsBooking 的活动在 reason 中提醒预约
|
||||||
|
|
||||||
|
每次调用工具前,用一句简短的话说明你的想法(用户能看到)。`;
|
||||||
|
|
||||||
|
interface FinalizePlanDay {
|
||||||
|
date: string;
|
||||||
|
items: {
|
||||||
|
time: string;
|
||||||
|
activity: string;
|
||||||
|
poi: string;
|
||||||
|
address: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
duration: number;
|
||||||
|
reason: string;
|
||||||
|
}[];
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgentTools(
|
||||||
|
taggedIdeas: TaggedIdea[],
|
||||||
|
lat: number,
|
||||||
|
lng: number,
|
||||||
|
): AgentTool[] {
|
||||||
|
const listIdeasTool: AgentTool = {
|
||||||
|
name: "list_ideas",
|
||||||
|
description: "获取想法池中所有已标记的活动想法,包含品类、时间偏好、费用、强度等信息",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
execute: async () => {
|
||||||
|
const ideas = taggedIdeas.map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
content: i.content,
|
||||||
|
category: i.category,
|
||||||
|
timeSlot: i.timeSlot,
|
||||||
|
estimatedMinutes: i.estimatedMinutes,
|
||||||
|
costLevel: i.costLevel,
|
||||||
|
intensity: i.intensity,
|
||||||
|
needsBooking: i.needsBooking,
|
||||||
|
}));
|
||||||
|
return JSON.stringify(ideas);
|
||||||
|
},
|
||||||
|
progressBefore: () => "正在查看想法池...",
|
||||||
|
progressAfter: (_args, result) => {
|
||||||
|
const ideas = JSON.parse(result);
|
||||||
|
return `找到 ${ideas.length} 个想法`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchPoiTool: AgentTool = {
|
||||||
|
name: "search_poi",
|
||||||
|
description: "在地图上搜索地点。search_type: brand=连锁品牌, place=唯一地点, category=模糊品类搜附近",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", description: "搜索关键词(品牌名、地名或品类名)" },
|
||||||
|
search_type: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["brand", "place", "category"],
|
||||||
|
description: "搜索策略",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["query", "search_type"],
|
||||||
|
},
|
||||||
|
execute: async (args) => {
|
||||||
|
const query = String(args.query ?? "");
|
||||||
|
const searchType = String(args.search_type ?? "category");
|
||||||
|
try {
|
||||||
|
const pois = await searchPois(query, searchType, lat, lng);
|
||||||
|
return JSON.stringify(pois);
|
||||||
|
} catch {
|
||||||
|
return JSON.stringify([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
progressBefore: (args) => `正在搜索「${args.query}」...`,
|
||||||
|
progressAfter: (args, result) => {
|
||||||
|
const pois = JSON.parse(result);
|
||||||
|
return pois.length > 0
|
||||||
|
? `找到 ${pois.length} 个「${args.query}」相关地点`
|
||||||
|
: `未找到「${args.query}」的结果`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalizePlanTool: AgentTool = {
|
||||||
|
name: "finalize_plan",
|
||||||
|
description: "提交最终行程方案。days 数组中每个元素包含 date、items(活动列表)和 summary",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
days: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
date: { type: "string" },
|
||||||
|
items: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
time: { type: "string", description: "开始时间,如 10:00" },
|
||||||
|
activity: { type: "string", description: "活动描述" },
|
||||||
|
poi: { type: "string", description: "具体地点名称" },
|
||||||
|
address: { type: "string", description: "详细地址" },
|
||||||
|
lat: { type: "number" },
|
||||||
|
lng: { type: "number" },
|
||||||
|
duration: { type: "number", description: "时长(分钟)" },
|
||||||
|
reason: { type: "string", description: "选择理由" },
|
||||||
|
},
|
||||||
|
required: ["time", "activity", "poi", "address", "lat", "lng", "duration", "reason"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary: { type: "string", description: "当天行程亮点一句话总结" },
|
||||||
|
},
|
||||||
|
required: ["date", "items", "summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["days"],
|
||||||
|
},
|
||||||
|
execute: async () => {
|
||||||
|
return JSON.stringify({ success: true });
|
||||||
|
},
|
||||||
|
progressBefore: () => "正在整理最终行程...",
|
||||||
|
progressAfter: () => "行程规划完成!",
|
||||||
|
};
|
||||||
|
|
||||||
|
return [listIdeasTool, searchPoiTool, finalizePlanTool];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAgentPlanGeneration(
|
||||||
|
room: { lat: number; lng: number },
|
||||||
|
taggedIdeas: TaggedIdea[],
|
||||||
availableTime: PlanGenAvailableTime,
|
availableTime: PlanGenAvailableTime,
|
||||||
onProgress?: (message: string) => void,
|
onProgress?: (message: string) => void,
|
||||||
): Promise<PlanGenResult> {
|
): Promise<{ days: FinalizePlanDay[] }> {
|
||||||
const at = availableTime;
|
const at = availableTime;
|
||||||
|
const tools = buildAgentTools(taggedIdeas, room.lat, room.lng);
|
||||||
|
|
||||||
const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } });
|
const userPrompt = `帮我规划行程。
|
||||||
if (!room) throw new ApiError("房间不存在", 404);
|
可用时间:${at.date},${at.startHour}:00 - ${at.endHour}:00
|
||||||
if (!room.lat || !room.lng) {
|
用户位置:纬度 ${room.lat},经度 ${room.lng}
|
||||||
throw new ApiError("请先设置房间位置", 400);
|
请开始规划。`;
|
||||||
}
|
|
||||||
|
|
||||||
onProgress?.(PLAN_PROGRESS_MESSAGES.analyzing);
|
const result = await runAgentLoop({
|
||||||
|
systemPrompt: AGENT_SYSTEM_PROMPT,
|
||||||
const allIdeas = await prisma.blindBoxIdea.findMany({
|
userPrompt,
|
||||||
where: { roomId, status: "in_pool", category: { not: null } },
|
tools,
|
||||||
select: {
|
onProgress,
|
||||||
id: true,
|
maxTurns: 15,
|
||||||
content: true,
|
|
||||||
category: true,
|
|
||||||
timeSlot: true,
|
|
||||||
estimatedMinutes: true,
|
|
||||||
searchQuery: true,
|
|
||||||
searchType: true,
|
|
||||||
costLevel: true,
|
|
||||||
intensity: true,
|
|
||||||
needsBooking: true,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const taggedIdeas: TaggedIdea[] = allIdeas.filter(
|
if (!result) {
|
||||||
(i): i is TaggedIdea =>
|
throw new Error("Agent 未能在限定轮次内完成规划");
|
||||||
!!i.category &&
|
|
||||||
!!i.timeSlot &&
|
|
||||||
!!i.searchQuery &&
|
|
||||||
!!i.searchType &&
|
|
||||||
typeof i.estimatedMinutes === "number",
|
|
||||||
);
|
|
||||||
|
|
||||||
if (taggedIdeas.length < 2) {
|
|
||||||
throw new ApiError("盒子里至少需要 2 个已标记的想法才能生成计划", 400);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const days = result.finalArgs.days as FinalizePlanDay[] | undefined;
|
||||||
|
if (!Array.isArray(days) || days.length === 0) {
|
||||||
|
throw new Error("Agent 返回的行程数据无效");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the items
|
||||||
|
const normalizedDays = days.map((day) => ({
|
||||||
|
date: String(day.date ?? ""),
|
||||||
|
items: Array.isArray(day.items)
|
||||||
|
? day.items.map((item) => ({
|
||||||
|
time: String(item.time ?? ""),
|
||||||
|
activity: String(item.activity ?? ""),
|
||||||
|
poi: String(item.poi ?? ""),
|
||||||
|
address: String(item.address ?? ""),
|
||||||
|
lat: Number(item.lat) || 0,
|
||||||
|
lng: Number(item.lng) || 0,
|
||||||
|
duration: Number(item.duration) || 60,
|
||||||
|
reason: String(item.reason ?? ""),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
summary: String(day.summary ?? ""),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const validDays = normalizedDays.filter((d) => d.items.length > 0);
|
||||||
|
if (validDays.length === 0) {
|
||||||
|
throw new Error("Agent 返回的行程中没有有效活动");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { days: validDays };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Legacy path: deterministic pipeline
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function runLegacyPlanGeneration(
|
||||||
|
room: { lat: number; lng: number },
|
||||||
|
taggedIdeas: TaggedIdea[],
|
||||||
|
availableTime: PlanGenAvailableTime,
|
||||||
|
onProgress?: (message: string) => void,
|
||||||
|
): Promise<{ days: { date: string; items: unknown[]; summary: string }[] }> {
|
||||||
|
const at = availableTime;
|
||||||
|
|
||||||
const dayConfigs: PlanGenAvailableTime[] =
|
const dayConfigs: PlanGenAvailableTime[] =
|
||||||
at.date === "整个周末"
|
at.date === "整个周末"
|
||||||
? [
|
? [
|
||||||
@@ -253,7 +448,7 @@ export async function runPlanGeneration(
|
|||||||
const searchResults = await Promise.all(
|
const searchResults = await Promise.all(
|
||||||
brandPlaceQueries.map(async (idea) => {
|
brandPlaceQueries.map(async (idea) => {
|
||||||
try {
|
try {
|
||||||
const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat!, room.lng!);
|
const pois = await searchPois(idea.searchQuery, idea.searchType, room.lat, room.lng);
|
||||||
return { query: idea.searchQuery, pois };
|
return { query: idea.searchQuery, pois };
|
||||||
} catch {
|
} catch {
|
||||||
return { query: idea.searchQuery, pois: [] };
|
return { query: idea.searchQuery, pois: [] };
|
||||||
@@ -309,15 +504,13 @@ export async function runPlanGeneration(
|
|||||||
needsBooking: i.needsBooking ?? undefined,
|
needsBooking: i.needsBooking ?? undefined,
|
||||||
})),
|
})),
|
||||||
candidates,
|
candidates,
|
||||||
userLocation: { lat: room.lat!, lng: room.lng! },
|
userLocation: { lat: room.lat, lng: room.lng },
|
||||||
availableTime: dayConfig,
|
availableTime: dayConfig,
|
||||||
};
|
};
|
||||||
return generateSchedule(ctx);
|
return generateSchedule(ctx);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
onProgress?.(PLAN_PROGRESS_MESSAGES.almostDone);
|
|
||||||
|
|
||||||
const days = schedules
|
const days = schedules
|
||||||
.map((schedule, idx) =>
|
.map((schedule, idx) =>
|
||||||
schedule
|
schedule
|
||||||
@@ -334,14 +527,85 @@ export async function runPlanGeneration(
|
|||||||
throw new ApiError("AI 规划失败,请稍后重试", 500);
|
throw new ApiError("AI 规划失败,请稍后重试", 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { days };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public entry point (signature unchanged)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function runPlanGeneration(
|
||||||
|
roomId: string,
|
||||||
|
userId: string,
|
||||||
|
availableTime: PlanGenAvailableTime,
|
||||||
|
onProgress?: (message: string) => void,
|
||||||
|
): Promise<PlanGenResult> {
|
||||||
|
// 1. Fetch room & ideas (shared by both paths)
|
||||||
|
const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } });
|
||||||
|
if (!room) throw new ApiError("房间不存在", 404);
|
||||||
|
if (!room.lat || !room.lng) {
|
||||||
|
throw new ApiError("请先设置房间位置", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
onProgress?.(PLAN_PROGRESS_MESSAGES.analyzing);
|
||||||
|
|
||||||
|
const allIdeas = await prisma.blindBoxIdea.findMany({
|
||||||
|
where: { roomId, status: "in_pool", category: { not: null } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
content: true,
|
||||||
|
category: true,
|
||||||
|
timeSlot: true,
|
||||||
|
estimatedMinutes: true,
|
||||||
|
searchQuery: true,
|
||||||
|
searchType: true,
|
||||||
|
costLevel: true,
|
||||||
|
intensity: true,
|
||||||
|
needsBooking: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const taggedIdeas: TaggedIdea[] = allIdeas.filter(
|
||||||
|
(i): i is TaggedIdea =>
|
||||||
|
!!i.category &&
|
||||||
|
!!i.timeSlot &&
|
||||||
|
!!i.searchQuery &&
|
||||||
|
!!i.searchType &&
|
||||||
|
typeof i.estimatedMinutes === "number",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (taggedIdeas.length < 2) {
|
||||||
|
throw new ApiError("盒子里至少需要 2 个已标记的想法才能生成计划", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try agent path, fallback to legacy
|
||||||
|
let days: { date: string; items: unknown[]; summary: string }[];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agentResult = await runAgentPlanGeneration(
|
||||||
|
{ lat: room.lat, lng: room.lng },
|
||||||
|
taggedIdeas,
|
||||||
|
availableTime,
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
days = agentResult.days;
|
||||||
|
} catch {
|
||||||
|
onProgress?.("使用备用方案规划...");
|
||||||
|
const legacyResult = await runLegacyPlanGeneration(
|
||||||
|
{ lat: room.lat, lng: room.lng },
|
||||||
|
taggedIdeas,
|
||||||
|
availableTime,
|
||||||
|
onProgress,
|
||||||
|
);
|
||||||
|
days = legacyResult.days;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Save to DB (shared)
|
||||||
const plan = await prisma.weekendPlan.create({
|
const plan = await prisma.weekendPlan.create({
|
||||||
data: {
|
data: {
|
||||||
roomId,
|
roomId,
|
||||||
userId,
|
userId,
|
||||||
planData: JSON.stringify({
|
planData: JSON.stringify({ days }),
|
||||||
days,
|
|
||||||
selectedIdeaIds: allSelected.map((i) => i.id),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user