feat: 优化 AI 解析接口返回友好消息
- 提取 System Prompt 为常量避免模板解析问题 - 优化 Mock 解析器支持农历和节假日识别 - 返回友好的 response 文本而非 JSON 结构 - 支持每年重复类型 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
875f902ac3
commit
dd21c03e06
199
src/routes/ai.ts
199
src/routes/ai.ts
@ -6,17 +6,68 @@ import { asyncHandler } from '../middleware/errorHandler';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
// System prompt for AI - extracted as constant to avoid template parsing issues
|
||||||
|
const SYSTEM_PROMPT = `你是一个帮助用户创建事件(纪念日或提醒)的智能助手。
|
||||||
|
|
||||||
|
当前时间:${new Date().toISOString()}
|
||||||
|
|
||||||
|
任务:从自然语言中解析用户输入,并严格按照以下JSON格式返回:
|
||||||
|
|
||||||
|
{
|
||||||
|
"parsed": {
|
||||||
|
"type": "anniversary 或 reminder",
|
||||||
|
"title": "事件标题",
|
||||||
|
"date": "2026-02-13T15:00:00Z",
|
||||||
|
"timezone": "Asia/Shanghai",
|
||||||
|
"is_lunar": true 或 false,
|
||||||
|
"repeat_type": "daily, weekly, monthly, yearly 或 none"
|
||||||
|
},
|
||||||
|
"response": "友好的确认消息"
|
||||||
|
}
|
||||||
|
|
||||||
|
重要规则:
|
||||||
|
1. date 必须是用户提到的具体日期时间,格式为 ISO 8601: YYYY-MM-DDTHH:mm:ssZ
|
||||||
|
2. 如果用户说"明天",date = (当前时间 + 1天) 的对应时间点
|
||||||
|
3. 如果用户说"后天",date = (当前时间 + 2天) 的对应时间点
|
||||||
|
4. 如果用户说"下周三",date = 下周三的 09:00:00Z
|
||||||
|
5. 如果用户说"下午3点",date = 今天下午15:00:00Z 或用户指定的日期的15:00:00Z
|
||||||
|
6. 如果用户没有指定具体时间,默认使用当天的 09:00:00Z
|
||||||
|
7. NEVER 使用示例日期如 2024-01-02T15:00:00Z,必须使用用户提到的实际日期或合理的未来日期
|
||||||
|
8. 如果用户说"每月X号",date = 用户提到的日期(如 2025-02-15T09:00:00Z)
|
||||||
|
9. 如果用户说"每年X月X日",date = 未来的对应日期(如 2026-01-01T09:00:00Z)
|
||||||
|
|
||||||
|
农历日期识别:农历正月初一 -> 需要转换为公历日期
|
||||||
|
|
||||||
|
节假日识别(设置为 is_holiday=true):
|
||||||
|
春节、清明、劳动节、国庆、元旦、中秋、端午
|
||||||
|
|
||||||
|
时区处理:
|
||||||
|
- 如果用户在中国,使用 "Asia/Shanghai" 时区
|
||||||
|
- date 应该是用户提到的时间转换为 UTC 时间
|
||||||
|
- 例如用户说"明天下午3点",date = 明天 15:00 +0800 转换为 UTC = 明天 07:00Z
|
||||||
|
|
||||||
|
请务必:
|
||||||
|
- 解析用户真实意图,不要编造日期
|
||||||
|
- 如果用户说"下周三",必须是真正下周星期三,不是今天也不是上周
|
||||||
|
- 如果用户没有明确日期,使用合理的默认日期(通常是明天或用户提到的第一个日期)`;
|
||||||
|
|
||||||
const parseMessageSchema = z.object({
|
const parseMessageSchema = z.object({
|
||||||
message: z.string().min(1, 'Message is required').max(1000),
|
message: z.string().min(1, 'Message is required').max(1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
// AI parsed event validation schema
|
// AI parsed event validation schema - 扩展支持所有字段
|
||||||
const parsedEventSchema = z.object({
|
const parsedEventSchema = z.object({
|
||||||
type: z.enum(['anniversary', 'reminder']),
|
type: z.enum(['anniversary', 'reminder']),
|
||||||
title: z.string().min(1).max(200),
|
title: z.string().min(1).max(200),
|
||||||
date: z.string().datetime(),
|
date: z.string().datetime(),
|
||||||
|
timezone: z.string(), // 时区信息
|
||||||
is_lunar: z.boolean(),
|
is_lunar: z.boolean(),
|
||||||
repeat_type: z.enum(['yearly', 'monthly', 'none']),
|
repeat_type: z.enum(['daily', 'weekly', 'monthly', 'yearly', 'none']),
|
||||||
|
// 新增字段
|
||||||
|
content: z.string().optional(), // 详细内容(仅提醒)
|
||||||
|
is_holiday: z.boolean().optional(), // 节假日(仅纪念日)
|
||||||
|
priority: z.enum(['none', 'red', 'green', 'yellow']).optional(), // 颜色(仅提醒)
|
||||||
|
reminder_times: z.array(z.string()).optional(), // 提前提醒时间点
|
||||||
});
|
});
|
||||||
|
|
||||||
// All routes require authentication
|
// All routes require authentication
|
||||||
@ -61,6 +112,12 @@ async function callDeepSeek(message: string): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Get current date for context
|
||||||
|
const now = new Date();
|
||||||
|
const tomorrow = new Date(now);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
const tomorrowStr = tomorrow.toISOString().slice(0, 11) + '15:00:00Z';
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
const response = await fetch(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@ -72,18 +129,7 @@ async function callDeepSeek(message: string): Promise<{
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: `You are a helpful assistant that helps users create events (anniversaries or reminders) from natural language.
|
content: SYSTEM_PROMPT
|
||||||
Parse the user's message and respond with a JSON object in the following format:
|
|
||||||
{
|
|
||||||
"parsed": {
|
|
||||||
"type": "anniversary" | "reminder",
|
|
||||||
"title": "event title",
|
|
||||||
"date": "ISO date string",
|
|
||||||
"is_lunar": true | false,
|
|
||||||
"repeat_type": "yearly" | "monthly" | "none"
|
|
||||||
},
|
|
||||||
"response": "A friendly confirmation message"
|
|
||||||
}`
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@ -109,9 +155,19 @@ async function callDeepSeek(message: string): Promise<{
|
|||||||
const parsed = rawParsed.parsed || rawParsed;
|
const parsed = rawParsed.parsed || rawParsed;
|
||||||
// Validate parsed data with Zod schema
|
// Validate parsed data with Zod schema
|
||||||
const validated = parsedEventSchema.parse(parsed);
|
const validated = parsedEventSchema.parse(parsed);
|
||||||
|
// 构建友好的用户消息
|
||||||
|
const typeText = validated.type === 'anniversary' ? '纪念日' : '提醒';
|
||||||
|
const dateObj = new Date(validated.date);
|
||||||
|
const dateText = dateObj.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric', month: 'long', day: 'numeric',
|
||||||
|
timeZone: validated.timezone
|
||||||
|
});
|
||||||
|
const repeatText = validated.repeat_type === 'none' ? '' :
|
||||||
|
{ daily: ',每天重复', weekly: ',每周重复', monthly: ',每月重复', yearly: ',每年重复' }[validated.repeat_type];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
parsed: validated,
|
parsed: validated,
|
||||||
response: parsed.response || content,
|
response: `已为你创建${typeText}「${validated.title}」,日期:${dateText}${repeatText}`,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Schema validation failed, use mock response
|
// Schema validation failed, use mock response
|
||||||
@ -135,35 +191,116 @@ function mockParseResponse(message: string): { parsed: any; response: string } {
|
|||||||
const isReminder = lowerMessage.includes('提醒') || lowerMessage.includes('提醒我');
|
const isReminder = lowerMessage.includes('提醒') || lowerMessage.includes('提醒我');
|
||||||
|
|
||||||
// Detect lunar date
|
// Detect lunar date
|
||||||
const isLunar = lowerMessage.includes('农历');
|
const isLunar = lowerMessage.includes('农历') || lowerMessage.includes('阴历');
|
||||||
|
|
||||||
// Detect repeat type
|
// Detect holiday
|
||||||
let repeatType = 'none';
|
const isHoliday = lowerMessage.includes('春节') || lowerMessage.includes('清明') ||
|
||||||
if (lowerMessage.includes('每年') || lowerMessage.includes(' yearly') || lowerMessage.includes('周年')) {
|
lowerMessage.includes('劳动节') || lowerMessage.includes('国庆') ||
|
||||||
repeatType = 'yearly';
|
lowerMessage.includes('元旦') || lowerMessage.includes('中秋') ||
|
||||||
} else if (lowerMessage.includes('每月') || lowerMessage.includes(' monthly')) {
|
lowerMessage.includes('端午');
|
||||||
repeatType = 'monthly';
|
|
||||||
|
// Detect priority (color)
|
||||||
|
let priority: 'none' | 'red' | 'green' | 'yellow' = 'none';
|
||||||
|
if (lowerMessage.includes('重要') || lowerMessage.includes('紧急') || lowerMessage.includes('红色')) {
|
||||||
|
priority = 'red';
|
||||||
|
} else if (lowerMessage.includes('绿色') || lowerMessage.includes('绿')) {
|
||||||
|
priority = 'green';
|
||||||
|
} else if (lowerMessage.includes('黄色') || lowerMessage.includes('黄')) {
|
||||||
|
priority = 'yellow';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract title (simple heuristic - first phrase before date)
|
// Detect content (after colon or special markers)
|
||||||
const title = message.split(/[\s\d\u4e00-\u9fa5]+/)[0] || message.substring(0, 50);
|
let content = '';
|
||||||
|
if (message.includes(':') || message.includes(':')) {
|
||||||
|
const parts = message.split(/[::]/);
|
||||||
|
if (parts.length > 1) {
|
||||||
|
content = parts[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Mock date (tomorrow)
|
// Detect repeat type
|
||||||
|
let repeatType: 'daily' | 'weekly' | 'monthly' | 'yearly' | 'none' = 'none';
|
||||||
|
if (lowerMessage.includes('每天') || lowerMessage.includes('每日')) {
|
||||||
|
repeatType = 'daily';
|
||||||
|
} else if (lowerMessage.includes('每周') || lowerMessage.includes(' weekly')) {
|
||||||
|
repeatType = 'weekly';
|
||||||
|
} else if (lowerMessage.includes('每月') || lowerMessage.includes(' monthly')) {
|
||||||
|
repeatType = 'monthly';
|
||||||
|
} else if (lowerMessage.includes('每年') || lowerMessage.includes(' yearly') || lowerMessage.includes('周年')) {
|
||||||
|
repeatType = 'yearly';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract title (simple heuristic - first phrase before date or colon)
|
||||||
|
let title = message.split(/[::]/)[0] || message;
|
||||||
|
title = title.split(/[\s\d\u4e00-\u9fa5]+/)[0] || title.substring(0, 50);
|
||||||
|
|
||||||
|
// Mock date (tomorrow at 3 PM local time by default for China UTC+8)
|
||||||
const tomorrow = new Date();
|
const tomorrow = new Date();
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
tomorrow.setHours(15, 0, 0, 0); // 默认下午3点(中国时区)
|
||||||
|
|
||||||
return {
|
// 检测消息中的时间(如"下午3点"、"下午四点"、"14:30"等)
|
||||||
parsed: {
|
const timePattern = message.match(/(?:下午|晚上)?(\d{1,2})(?::(\d{2}))?\s*(?:点|时)/);
|
||||||
|
if (timePattern) {
|
||||||
|
let hours = parseInt(timePattern[1]);
|
||||||
|
const minutes = timePattern[2] ? parseInt(timePattern[2]) : 0;
|
||||||
|
// 如果是下午时间(1-11点),加上12
|
||||||
|
if (message.includes('下午') || message.includes('晚上')) {
|
||||||
|
if (hours < 12) hours += 12;
|
||||||
|
}
|
||||||
|
tomorrow.setHours(hours, minutes, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build parsed result
|
||||||
|
const parsed: any = {
|
||||||
type: isReminder ? 'reminder' : 'anniversary',
|
type: isReminder ? 'reminder' : 'anniversary',
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
date: tomorrow.toISOString(),
|
date: tomorrow.toISOString(),
|
||||||
|
timezone: 'Asia/Shanghai', // 默认使用中国时区
|
||||||
is_lunar: isLunar,
|
is_lunar: isLunar,
|
||||||
repeat_type: repeatType,
|
repeat_type: repeatType,
|
||||||
},
|
};
|
||||||
response: `我帮你记的是:
|
|
||||||
|
// Add optional fields
|
||||||
|
if (isHoliday) {
|
||||||
|
parsed.is_holiday = true;
|
||||||
|
}
|
||||||
|
if (isReminder && priority !== 'none') {
|
||||||
|
parsed.priority = priority;
|
||||||
|
}
|
||||||
|
if (isReminder && content) {
|
||||||
|
parsed.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate response
|
||||||
|
const repeatText = {
|
||||||
|
daily: '每天重复',
|
||||||
|
weekly: '每周重复',
|
||||||
|
monthly: '每月重复',
|
||||||
|
yearly: '每年重复',
|
||||||
|
none: '不重复'
|
||||||
|
}[repeatType];
|
||||||
|
|
||||||
|
let responseMsg = `我帮你记的是:
|
||||||
**${title.trim()}**
|
**${title.trim()}**
|
||||||
📅 ${tomorrow.toLocaleDateString('zh-CN')}
|
📅 ${tomorrow.toLocaleDateString('zh-CN')}`;
|
||||||
🔄 ${repeatType === 'yearly' ? '每年重复' : repeatType === 'monthly' ? '每月重复' : '不重复'}`
|
if (repeatType !== 'none') {
|
||||||
|
responseMsg += `\n🔄 ${repeatText}`;
|
||||||
|
}
|
||||||
|
if (isLunar) {
|
||||||
|
responseMsg += `\n📆 农历日期`;
|
||||||
|
}
|
||||||
|
if (isHoliday) {
|
||||||
|
responseMsg += `\n🎉 节假日`;
|
||||||
|
}
|
||||||
|
if (isReminder && priority !== 'none') {
|
||||||
|
const colorText = { red: '红色', green: '绿色', yellow: '黄色', none: '无' };
|
||||||
|
responseMsg += `\n🏷️ ${colorText[priority]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parsed,
|
||||||
|
response: responseMsg
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user