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:
2026-02-27 01:51:47 +08:00
parent 8c6da410ca
commit 9c680ec11e
16 changed files with 1721 additions and 70 deletions
+172
View File
@@ -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;
}
}