d58dc2a251
Introduce Draft → Confirm → Commit flow for WeChat schedule intents behind feature flags, plus h5_tasks dual-write/read aggregation and rollout scripts so reminders and automations get explicit user confirmation before persisting. Co-authored-by: Cursor <cursoragent@cursor.com>
267 lines
8.6 KiB
JavaScript
267 lines
8.6 KiB
JavaScript
import { isScheduleIntent, parseScheduleIntent } from '../../schedule-intent.mjs';
|
||
import { formatLocalTime, resolveScheduleTimestamp } from '../../schedule-time.mjs';
|
||
|
||
function normalizeHour(value) {
|
||
const hour = Number(value);
|
||
return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null;
|
||
}
|
||
|
||
function normalizeMinute(value) {
|
||
const minute = Number(value);
|
||
return Number.isInteger(minute) && minute >= 0 && minute <= 59 ? minute : null;
|
||
}
|
||
|
||
function normalizeThresholdCents(value) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed) || parsed < 0) return null;
|
||
return Math.round(parsed * 100);
|
||
}
|
||
|
||
function parseJsonReply(reply) {
|
||
const text = String(reply ?? '').trim();
|
||
if (!text) return null;
|
||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||
const candidate = fenced?.[1] ?? text;
|
||
try {
|
||
return JSON.parse(candidate);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function normalizeLlmScheduleIntent(payload) {
|
||
const action = String(payload?.action ?? 'none').trim();
|
||
if (!action) return { action: 'none' };
|
||
|
||
if (action === 'create_todo') {
|
||
const title = String(payload?.title ?? '').trim();
|
||
return title ? { action, title } : { action, needsClarification: ['todo_title'] };
|
||
}
|
||
|
||
if (action === 'create_daily_todo_digest') {
|
||
const hour = normalizeHour(payload?.hour);
|
||
const minute = normalizeMinute(payload?.minute ?? 0);
|
||
if (hour == null || minute == null) {
|
||
return { action, needsClarification: ['digest_time'] };
|
||
}
|
||
return { action, hour, minute };
|
||
}
|
||
|
||
if (action === 'create_balance_alert') {
|
||
const thresholdCents = normalizeThresholdCents(payload?.thresholdYuan);
|
||
return thresholdCents == null
|
||
? { action, needsClarification: ['threshold'] }
|
||
: { action, thresholdCents };
|
||
}
|
||
|
||
if (action === 'query_schedule' || action === 'schedule_agent') {
|
||
return { action };
|
||
}
|
||
|
||
return { action: 'none' };
|
||
}
|
||
|
||
async function parseScheduleIntentWithLlm({ text, llmProviderService, modelProviderKeyId = null, model = null, logger = console }) {
|
||
if (!llmProviderService || typeof llmProviderService.createChatCompletion !== 'function') {
|
||
return { action: 'none' };
|
||
}
|
||
|
||
try {
|
||
const result = await llmProviderService.createChatCompletion({
|
||
...(modelProviderKeyId ? { providerKeyId: modelProviderKeyId } : {}),
|
||
...(model ? { model } : {}),
|
||
temperature: 0,
|
||
messages: [
|
||
{
|
||
role: 'system',
|
||
content: [
|
||
'你是公众号提醒助手,只做意图识别。',
|
||
'请严格输出 JSON,不要输出解释。',
|
||
'允许 action: create_todo, create_daily_todo_digest, create_balance_alert, query_schedule, schedule_agent, none。',
|
||
'create_todo 需要 title。',
|
||
'create_daily_todo_digest 需要 hour(0-23) 和 minute(0-59)。',
|
||
'create_balance_alert 需要 thresholdYuan 数字。',
|
||
'如果用户在说具体时间提醒、一次性闹钟、某天某时提醒,返回 schedule_agent。',
|
||
'不确定时返回 none。',
|
||
].join('\n'),
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: text,
|
||
},
|
||
],
|
||
});
|
||
|
||
if (!result?.ok) {
|
||
logger?.warn?.('[wechat-schedule-llm] parse skipped:', result?.message ?? 'unknown');
|
||
return { action: 'none' };
|
||
}
|
||
|
||
return normalizeLlmScheduleIntent(parseJsonReply(result.reply));
|
||
} catch (err) {
|
||
logger?.warn?.(
|
||
'[wechat-schedule-llm] parse skipped:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
return { action: 'none' };
|
||
}
|
||
}
|
||
|
||
async function resolveScheduleIntent({
|
||
text,
|
||
wechatScheduleLlmConfigService,
|
||
llmProviderService,
|
||
logger,
|
||
}) {
|
||
const ruleIntent = parseScheduleIntent(text, { timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai' });
|
||
if (isScheduleIntent(ruleIntent)) return ruleIntent;
|
||
|
||
if (!wechatScheduleLlmConfigService || !llmProviderService) return ruleIntent;
|
||
|
||
let enabled = false;
|
||
try {
|
||
enabled = await wechatScheduleLlmConfigService.isScheduleLlmEnabled();
|
||
} catch (err) {
|
||
logger?.warn?.(
|
||
'[wechat-schedule-llm] config load failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
}
|
||
if (!enabled) return ruleIntent;
|
||
|
||
let modelProviderKeyId = null;
|
||
let model = null;
|
||
try {
|
||
const config = await wechatScheduleLlmConfigService.getConfig();
|
||
modelProviderKeyId = config.modelProviderKeyId ?? null;
|
||
model = config.model ?? null;
|
||
} catch (err) {
|
||
logger?.warn?.(
|
||
'[wechat-schedule-llm] provider config load failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
}
|
||
|
||
return parseScheduleIntentWithLlm({
|
||
text,
|
||
llmProviderService,
|
||
modelProviderKeyId,
|
||
model,
|
||
logger,
|
||
});
|
||
}
|
||
|
||
export async function handleWechatScheduleIntent({
|
||
intent,
|
||
user,
|
||
scheduleService,
|
||
wechatScheduleLlmConfigService = null,
|
||
llmProviderService = null,
|
||
logger = console,
|
||
}) {
|
||
if (!scheduleService) return null;
|
||
const scheduleIntent = await resolveScheduleIntent({
|
||
text: intent.agentText,
|
||
wechatScheduleLlmConfigService,
|
||
llmProviderService,
|
||
logger,
|
||
});
|
||
if (!isScheduleIntent(scheduleIntent) || scheduleIntent.action === 'schedule_agent') return null;
|
||
|
||
const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
||
|
||
if (scheduleIntent.action === 'create_timed_reminder') {
|
||
if (scheduleIntent.needsClarification?.includes('reminder_title')) {
|
||
return '可以。请告诉我要提醒什么,例如「下午 2 点半项目计划例会」。';
|
||
}
|
||
const remindAt = resolveScheduleTimestamp({
|
||
localString: scheduleIntent.remindLocal,
|
||
timezone,
|
||
fieldName: '提醒时间',
|
||
});
|
||
const item = await scheduleService.createItem({
|
||
userId: user.userId,
|
||
kind: 'event',
|
||
title: scheduleIntent.title,
|
||
startAt: remindAt,
|
||
timezone,
|
||
sourceChannel: 'wechat',
|
||
sourceMessageId: intent.msgId || null,
|
||
sourceText: intent.agentText,
|
||
metadata: {
|
||
source: 'wechat_mp',
|
||
},
|
||
});
|
||
await scheduleService.createReminder({
|
||
userId: user.userId,
|
||
itemId: item.id,
|
||
remindAt,
|
||
channel: 'wechat',
|
||
});
|
||
const timeLabel = formatLocalTime(remindAt, timezone);
|
||
const dateLabel = String(scheduleIntent.remindLocal ?? '').slice(0, 10);
|
||
return `已设置提醒:${scheduleIntent.title}(${dateLabel} ${timeLabel},${timezone})。到点我会通过服务号提醒你。`;
|
||
}
|
||
|
||
if (scheduleIntent.action === 'create_todo') {
|
||
if (scheduleIntent.needsClarification?.includes('todo_title')) {
|
||
return '可以。你想让我记哪一条待办?例如“帮我记一下 跟段吃饭”。';
|
||
}
|
||
const item = await scheduleService.createItem({
|
||
userId: user.userId,
|
||
kind: 'task',
|
||
title: scheduleIntent.title,
|
||
timezone,
|
||
sourceChannel: 'wechat',
|
||
sourceMessageId: intent.msgId || null,
|
||
sourceText: intent.agentText,
|
||
metadata: {
|
||
source: 'wechat_mp',
|
||
},
|
||
});
|
||
return `已记录到待办列表:${item.title},未设置提醒。`;
|
||
}
|
||
|
||
if (scheduleIntent.action === 'create_daily_todo_digest') {
|
||
if (scheduleIntent.needsClarification?.includes('digest_time')) {
|
||
return '可以。你想每天几点收到当天待办记录?比如“每天早上 7 点发给我”。';
|
||
}
|
||
const subscription = await scheduleService.createDailyTodoDigest({
|
||
userId: user.userId,
|
||
hour: scheduleIntent.hour,
|
||
minute: scheduleIntent.minute,
|
||
timezone,
|
||
channel: 'wechat',
|
||
sourceChannel: 'wechat',
|
||
sourceMessageId: intent.msgId || null,
|
||
sourceText: intent.agentText,
|
||
});
|
||
const minuteText = subscription.minute === 0 ? '' : `${String(subscription.minute).padStart(2, '0')}分`;
|
||
return `已设置:我会每天早上 ${subscription.hour}点${minuteText} 通过服务号把当天待办记录发给你。`;
|
||
}
|
||
|
||
if (scheduleIntent.action === 'create_balance_alert') {
|
||
if (scheduleIntent.needsClarification?.includes('threshold')) {
|
||
return '可以。你想在余额低于多少时提醒我?例如“余额低于 20 元提醒我”。';
|
||
}
|
||
const subscription = await scheduleService.createBalanceLowAlert({
|
||
userId: user.userId,
|
||
thresholdCents: scheduleIntent.thresholdCents,
|
||
channel: 'wechat',
|
||
sourceChannel: 'wechat',
|
||
sourceMessageId: intent.msgId || null,
|
||
sourceText: intent.agentText,
|
||
});
|
||
return `已设置:当余额低于 ${(subscription.thresholdCents / 100).toFixed(2)} 元时,我会通过服务号提醒你。`;
|
||
}
|
||
|
||
if (scheduleIntent.action === 'query_schedule') {
|
||
return scheduleService.buildTodoDigestText({
|
||
userId: user.userId,
|
||
timezone,
|
||
});
|
||
}
|
||
|
||
return null;
|
||
}
|