feat: AI 周末行程规划 — DeepSeek 智能排期 + 高德 POI + 多日翻页
- 接入 DeepSeek API,提交想法时自动 AI 打标(品类/时段/时长/搜索策略) - 新增行程规划 API:智能选取想法 → 高德 POI 搜索 → AI 生成最优行程 - 支持多日计划("整个周末"拆分周六/周日,并行 AI 调用) - 行程展示逐日翻页,时间线可滚动,操作按钮固定底部 - 分享卡适配多日格式,支持图片保存与原生分享 - Prisma schema 新增 WeekendPlan 模型及 BlindBoxIdea AI 标签字段 - Jenkinsfile 集成 DEEPSEEK_API_KEY 环境变量
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
import OpenAI from "openai";
|
||||
import type { IdeaTags, PlanItem } from "@/types";
|
||||
|
||||
function getClient() {
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY;
|
||||
if (!apiKey) throw new Error("DEEPSEEK_API_KEY is not configured");
|
||||
return new OpenAI({ baseURL: "https://api.deepseek.com", apiKey });
|
||||
}
|
||||
|
||||
const TAG_SYSTEM_PROMPT = `你是一个周末活动分析助手。用户会输入一条周末活动想法,你需要分析并返回结构化 JSON。
|
||||
|
||||
返回字段:
|
||||
- category: 活动品类,必须是以下之一:
|
||||
"dining"(餐饮美食)| "outdoor"(户外活动)| "entertainment"(娱乐休闲,如电影、KTV、密室)| "shopping"(购物逛街)| "sports"(运动健身)| "culture"(文化艺术,如博物馆、展览)| "relaxation"(放松休息,如SPA、咖啡、下午茶)
|
||||
- timeSlot: 最适合的时间段,必须是以下之一:
|
||||
"morning"(上午)| "afternoon"(下午)| "evening"(晚上)| "flexible"(任意时间都可以)| "all_day"(需要一整天)
|
||||
- estimatedMinutes: 预估活动时长(整数,单位分钟)
|
||||
- outdoor: 是否户外活动(布尔值)
|
||||
- searchQuery: 在地图服务上搜索的关键词(品牌名、地名或品类名)
|
||||
- searchType: 搜索策略,必须是以下之一:
|
||||
"brand"(连锁品牌,有多个分店)| "place"(唯一地点,如某个公园)| "category"(模糊品类,搜附近匹配的)
|
||||
|
||||
只返回 JSON,不要任何额外文字。`;
|
||||
|
||||
const SCHEDULE_SYSTEM_PROMPT = `你是一个周末行程规划师。根据用户选定的活动和候选地点坐标,生成最优行程安排。
|
||||
|
||||
规划原则:
|
||||
1. 选择地理位置相近的 POI,最小化总移动距离
|
||||
2. 尊重活动的时间偏好(公园上午、正餐在饭点、电影灵活)
|
||||
3. 活动之间留出合理的交通时间(15-30分钟)
|
||||
4. 如果有"category"类型的活动,选择离其他已确定地点最近的候选
|
||||
|
||||
返回 JSON 格式:
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"time": "10:00",
|
||||
"activity": "原始活动描述",
|
||||
"poi": "选定的具体 POI 名称",
|
||||
"address": "详细地址",
|
||||
"lat": 31.2,
|
||||
"lng": 121.5,
|
||||
"duration": 120,
|
||||
"reason": "选择这个时间和地点的简短理由"
|
||||
}
|
||||
],
|
||||
"summary": "一句话总结这个行程的亮点"
|
||||
}
|
||||
|
||||
按时间顺序排列。只返回 JSON。`;
|
||||
|
||||
export interface ScheduleContext {
|
||||
ideas: {
|
||||
content: string;
|
||||
category: string;
|
||||
timeSlot: string;
|
||||
estimatedMinutes: number;
|
||||
searchQuery: string;
|
||||
searchType: string;
|
||||
}[];
|
||||
candidates: Record<
|
||||
string,
|
||||
{ name: string; address: string; lat: number; lng: number; rating?: number }[]
|
||||
>;
|
||||
userLocation: { lat: number; lng: number };
|
||||
availableTime: { date: string; startHour: number; endHour: number };
|
||||
}
|
||||
|
||||
export async function tagIdea(content: string): Promise<IdeaTags | null> {
|
||||
try {
|
||||
const client = getClient();
|
||||
const response = await client.chat.completions.create({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{ role: "system", content: TAG_SYSTEM_PROMPT },
|
||||
{ role: "user", content },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
max_tokens: 200,
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
const text = response.choices[0]?.message?.content;
|
||||
if (!text) return null;
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
|
||||
const validCategories = ["dining", "outdoor", "entertainment", "shopping", "sports", "culture", "relaxation"];
|
||||
const validTimeSlots = ["morning", "afternoon", "evening", "flexible", "all_day"];
|
||||
const validSearchTypes = ["brand", "place", "category"];
|
||||
|
||||
if (
|
||||
!validCategories.includes(parsed.category) ||
|
||||
!validTimeSlots.includes(parsed.timeSlot) ||
|
||||
!validSearchTypes.includes(parsed.searchType) ||
|
||||
typeof parsed.estimatedMinutes !== "number" ||
|
||||
typeof parsed.outdoor !== "boolean" ||
|
||||
typeof parsed.searchQuery !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
category: parsed.category,
|
||||
timeSlot: parsed.timeSlot,
|
||||
estimatedMinutes: parsed.estimatedMinutes,
|
||||
outdoor: parsed.outdoor,
|
||||
searchQuery: parsed.searchQuery,
|
||||
searchType: parsed.searchType,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateSchedule(
|
||||
ctx: ScheduleContext,
|
||||
): Promise<{ items: PlanItem[]; summary: string } | null> {
|
||||
try {
|
||||
const client = getClient();
|
||||
|
||||
const userPrompt = `
|
||||
可用时间:${ctx.availableTime.date},${ctx.availableTime.startHour}:00 - ${ctx.availableTime.endHour}:00
|
||||
用户出发位置:纬度 ${ctx.userLocation.lat},经度 ${ctx.userLocation.lng}
|
||||
|
||||
活动列表:
|
||||
${ctx.ideas.map((idea, i) => `${i + 1}. "${idea.content}"(品类:${idea.category},偏好时间:${idea.timeSlot},预估${idea.estimatedMinutes}分钟)`).join("\n")}
|
||||
|
||||
各活动的候选地点:
|
||||
${Object.entries(ctx.candidates)
|
||||
.map(
|
||||
([query, pois]) =>
|
||||
`"${query}" 的候选:\n${pois.map((p) => ` - ${p.name} | ${p.address} | 坐标(${p.lat},${p.lng})${p.rating ? ` | 评分${p.rating}` : ""}`).join("\n")}`,
|
||||
)
|
||||
.join("\n\n")}
|
||||
|
||||
请为以上活动生成最优行程安排。`;
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{ role: "system", content: SCHEDULE_SYSTEM_PROMPT },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
max_tokens: 1500,
|
||||
temperature: 0.5,
|
||||
});
|
||||
|
||||
const text = response.choices[0]?.message?.content;
|
||||
if (!text) return null;
|
||||
|
||||
const parsed = JSON.parse(text);
|
||||
if (!Array.isArray(parsed.items) || parsed.items.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: parsed.items.map((item: Record<string, unknown>) => ({
|
||||
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(parsed.summary ?? ""),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user