diff --git a/src/lib/ai.ts b/src/lib/ai.ts index 5236351..febd93b 100644 --- a/src/lib/ai.ts +++ b/src/lib/ai.ts @@ -1,4 +1,8 @@ import OpenAI from "openai"; +import type { + ChatCompletionMessageParam, + ChatCompletionTool, +} from "openai/resources/chat/completions"; import type { IdeaTags, PlanItem } from "@/types"; function getClient() { @@ -224,3 +228,133 @@ ${Object.entries(ctx.candidates) return null; } } + +// --------------------------------------------------------------------------- +// Generic tool-calling agent loop +// --------------------------------------------------------------------------- + +export interface AgentTool { + name: string; + description: string; + parameters: Record; // JSON Schema for the tool parameters + execute: (args: Record) => Promise; + progressBefore?: (args: Record) => string; + progressAfter?: (args: Record, result: string) => string; +} + +export interface AgentLoopOptions { + systemPrompt: string; + userPrompt: string; + tools: AgentTool[]; + onProgress?: (message: string) => void; + maxTurns?: number; +} + +export interface AgentLoopResult { + finalArgs: Record; + turns: number; +} + +export async function runAgentLoop( + options: AgentLoopOptions, +): Promise { + 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; + 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; +} diff --git a/src/lib/blindboxPlanGen.ts b/src/lib/blindboxPlanGen.ts index ce4c139..b3e9b01 100644 --- a/src/lib/blindboxPlanGen.ts +++ b/src/lib/blindboxPlanGen.ts @@ -1,10 +1,13 @@ /** * Shared plan generation logic for blindbox weekend plans. * Supports optional progress callback for streaming UX. + * + * Primary path: tool-calling agent (runAgentPlanGeneration) + * Fallback path: legacy pipeline (runLegacyPlanGeneration) */ import { prisma } from "@/lib/prisma"; 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"; export interface PlanGenAvailableTime { @@ -26,6 +29,10 @@ interface TaggedIdea { needsBooking: boolean | null; } +// --------------------------------------------------------------------------- +// Slot-based idea selection (used by legacy path) +// --------------------------------------------------------------------------- + const SLOT_CATEGORY_MAP: Record = { morning: ["outdoor", "nature", "sports", "culture"], lunch: ["dining"], @@ -72,13 +79,11 @@ function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): Tagge const remaining = ideas.filter((i) => !usedIds.has(i.id)); const preferredCategories = SLOT_CATEGORY_MAP[slot] || []; - // Priority 1: timeSlot + category both match const p1 = remaining.filter( (i) => matchesSlot(i, slot) && preferredCategories.includes(i.category), ); let picked = pickRandom(p1); - // Priority 2: category match only (existing logic) if (!picked) { for (const cat of preferredCategories) { 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) { const p3 = remaining.filter((i) => matchesSlot(i, slot)); picked = pickRandom(p3); } - // Priority 4: any remaining if (!picked) { picked = pickRandom(remaining); } @@ -107,6 +110,10 @@ function selectIdeasForSlots(ideas: TaggedIdea[], availableHours: number): Tagge return selected; } +// --------------------------------------------------------------------------- +// POI search (shared by both paths) +// --------------------------------------------------------------------------- + async function searchPois( query: string, searchType: string, @@ -159,6 +166,10 @@ function mapPois( }); } +// --------------------------------------------------------------------------- +// Progress messages (kept for legacy path) +// --------------------------------------------------------------------------- + export const PLAN_PROGRESS_MESSAGES = { analyzing: "正在分析你们的想法...", searching: "正在搜索地点...", @@ -167,57 +178,241 @@ export const PLAN_PROGRESS_MESSAGES = { almostDone: "快好了...", } as const; +// --------------------------------------------------------------------------- +// Result type +// --------------------------------------------------------------------------- + export interface PlanGenResult { id: string; days: { date: string; items: unknown[]; summary: string }[]; createdAt: string; } -export async function runPlanGeneration( - roomId: string, - userId: string, +// --------------------------------------------------------------------------- +// Agent path: tool-calling agent +// --------------------------------------------------------------------------- + +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, onProgress?: (message: string) => void, -): Promise { +): Promise<{ days: FinalizePlanDay[] }> { const at = availableTime; + const tools = buildAgentTools(taggedIdeas, room.lat, room.lng); - const room = await prisma.blindBoxRoom.findUnique({ where: { id: roomId } }); - if (!room) throw new ApiError("房间不存在", 404); - if (!room.lat || !room.lng) { - throw new ApiError("请先设置房间位置", 400); - } + const userPrompt = `帮我规划行程。 +可用时间:${at.date},${at.startHour}:00 - ${at.endHour}:00 +用户位置:纬度 ${room.lat},经度 ${room.lng} +请开始规划。`; - 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 result = await runAgentLoop({ + systemPrompt: AGENT_SYSTEM_PROMPT, + userPrompt, + tools, + onProgress, + maxTurns: 15, }); - 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); + if (!result) { + throw new Error("Agent 未能在限定轮次内完成规划"); } + 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[] = at.date === "整个周末" ? [ @@ -253,7 +448,7 @@ export async function runPlanGeneration( const searchResults = await Promise.all( brandPlaceQueries.map(async (idea) => { 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 }; } catch { return { query: idea.searchQuery, pois: [] }; @@ -309,15 +504,13 @@ export async function runPlanGeneration( needsBooking: i.needsBooking ?? undefined, })), candidates, - userLocation: { lat: room.lat!, lng: room.lng! }, + userLocation: { lat: room.lat, lng: room.lng }, availableTime: dayConfig, }; return generateSchedule(ctx); }), ); - onProgress?.(PLAN_PROGRESS_MESSAGES.almostDone); - const days = schedules .map((schedule, idx) => schedule @@ -334,14 +527,85 @@ export async function runPlanGeneration( 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 { + // 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({ data: { roomId, userId, - planData: JSON.stringify({ - days, - selectedIdeaIds: allSelected.map((i) => i.id), - }), + planData: JSON.stringify({ days }), }, });