Files
memind/wechat/handlers/schedule.mjs
T
john d58dc2a251 feat(wechat): add Intent Transaction Layer with unified task schema
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>
2026-08-24 15:59:17 +08:00

267 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}