Files
memind/schedule-intent.mjs
T
john 9dfafb99ca
Memind CI / Test, build, and release guards (push) Successful in 3m43s
fix(schedule): harden reminder utterance routing for WeChat ITL
Expand ASR normalization and colloquial reminder parsing so simple timed reminders route to L1 confirm cards instead of the agent, and disable schedule-guard false failures when ITL is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 18:02:19 +08:00

397 lines
15 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 {
addLocalDays,
getLocalParts,
normalizeTimezone,
zonedTimeToEpochMs,
} from './schedule-time.mjs';
import {
hasReminderSetupCue,
isWeeklyScheduleCue,
normalizeScheduleUtterance,
} from './schedule-utterance-normalize.mjs';
function normalizeText(text) {
return String(text ?? '')
.replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨|好的|好)+/u, '')
.replace(/\s+/g, '')
.trim();
}
/** ASR/wechat often inserts spaces around colons, e.g. "17 08". */
function normalizeLooseTimeText(text) {
return normalizeScheduleUtterance(text);
}
function pad2(value) {
return String(value).padStart(2, '0');
}
function chineseHourToNumber(value) {
const raw = String(value ?? '').trim();
if (/^\d{1,2}$/.test(raw)) return Number(raw);
const map = {
: 0,
: 1,
: 2,
: 2,
: 3,
: 4,
: 5,
: 6,
: 7,
: 8,
: 9,
: 10,
};
if (raw === '十') return 10;
if (raw.startsWith('十')) return 10 + (map[raw.slice(1)] ?? 0);
if (raw.endsWith('十')) return (map[raw[0]] ?? 0) * 10;
if (raw.includes('十')) {
const [tens, ones] = raw.split('十');
return (map[tens] ?? 1) * 10 + (map[ones] ?? 0);
}
return map[raw] ?? null;
}
export function parseHourMinute(text) {
const normalized = normalizeLooseTimeText(text);
let match = normalized.match(
/(?:今天|明天|后天|今晚|明晚|明早|今早)?(?:早上|上午|清晨|每天(?:早上|上午)?)?(早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)?([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})(?:点|:|)(半|[0-9]{1,2}分?)?/,
);
if (!match) {
match = normalized.match(
/(?:今天|明天|后天|今晚|明晚|明早|今早)?(?:早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)?(早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)?([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})点(半|[0-9]{1,2}分?)?/,
);
}
if (!match) return null;
const period = String(match[1] ?? '');
let hour = chineseHourToNumber(match[2]);
if (hour === null || hour < 0 || hour > 23) return null;
let minute = 0;
if (match[3] === '半') minute = 30;
else if (match[3]) minute = Number(String(match[3]).replace('分', ''));
if (!Number.isFinite(minute) || minute < 0 || minute > 59) return null;
if (period === '中午') {
hour = 12;
} else if (/下午|傍晚|晚上/u.test(period) && hour < 12) {
hour += 12;
} else if (/上午|早上|清晨|凌晨/u.test(period) && hour === 12) {
hour = 0;
} else if (/今晚/u.test(normalized) && hour < 12 && !/上午|早上|清晨|凌晨/u.test(period)) {
hour += 12;
}
return { hour, minute };
}
function countTimeExpressions(text) {
const normalized = normalizeLooseTimeText(text);
const matches = normalized.match(
/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|)(?:半|[0-9]{1,2}(?:分(?!开)|(?![0-9]))?)?/gu,
);
return matches?.length ?? 0;
}
function wantsSimpleTimedReminder(compact, text) {
if (isWeeklyScheduleCue(compact)) return false;
if (!hasReminderSetupCue(compact)) return false;
if (countTimeExpressions(text) !== 1) return false;
if (/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|).{1,24}[0-9零一二两三四五六七八九十]{1,3}(?:点|:|)/u.test(compact)) {
return false;
}
return parseHourMinute(text) != null;
}
function extractTimedReminderTitle(text) {
const original = String(text ?? '').trim();
const commaParts = original.split(/[,]/).map((part) => part.trim()).filter(Boolean);
if (commaParts.length > 1) {
const tail = commaParts[commaParts.length - 1];
const tailTitle = cleanupTimedReminderTitleFragment(tail);
if (tailTitle && tailTitle.length >= 2) return tailTitle;
}
return cleanupTimedReminderTitleFragment(original);
}
function cleanupTimedReminderTitleFragment(text) {
let title = normalizeLooseTimeText(String(text ?? '').trim());
title = title
.replace(/^(?:帮我|请|麻烦)?(?:设置|设|创建|添加)(?:一个|个)?提醒[,、:\s]*/u, '')
.replace(/^(?:帮我|请)?(?:设置|设)(?:一个|个)?(?:待办|代办|带办|代拜)[,、:\s]*/u, '');
title = title
.replace(/^(?:帮我|请|麻烦)?(?:设置|设|加|添加|创建|安排|定)(?:一个|个|一下)?(?:每天|每日|天天)?/u, '')
.replace(/^(?:帮我|请|麻烦)?(?:加个|添加|创建|安排)(?:一个|个)?提醒/u, '')
.replace(/^(?:帮我|请|麻烦)?(?:定|设)个?(?:闹钟|闹铃)/u, '')
.replace(/(?:今天|今日|今晚|明天|后天|明早|今早)/gu, ' ')
.replace(/(?:每天|每日|天天)/gu, ' ')
.replace(/(?:早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)/gu, ' ')
.replace(
/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|)(?:半|[0-9]{1,2}(?:\s*分(?!开)|(?![0-9]))?)?/gu,
' ',
)
.replace(/(?:提醒我|提醒|闹钟|闹铃|叫我|设置提醒|设提醒|备忘提醒|定时提醒)/gu, ' ')
.replace(/[,、:]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
title = title
.replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨)+/u, '')
.replace(/^(?:去|来)?(?:参加|进行)?(?:一个|个)?/u, '')
.trim();
title = title.replace(/^分开/u, '开会').replace(/开会会/u, '开会');
if (!title || title.length < 2 || /^(?:请|麻烦|能不能|嗨|嗯|那个|帮我)$/u.test(title)) return null;
return title;
}
export function buildTimedReminderLocal({
text,
hour,
minute = 0,
timezone = 'Asia/Shanghai',
now = Date.now(),
} = {}) {
const compact = normalizeText(text);
let dayOffset = 0;
if (/后天/u.test(compact)) dayOffset = 2;
else if (/明天|明早|明晚/u.test(compact)) dayOffset = 1;
else if (/今晚/u.test(compact)) dayOffset = 0;
else if (/今早/u.test(compact)) dayOffset = 0;
const tz = normalizeTimezone(timezone);
let dayStart = addLocalDays(now, dayOffset, tz);
let parts = getLocalParts(dayStart, tz);
let remindAt = zonedTimeToEpochMs(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour,
minute,
second: 0,
},
tz,
);
if (remindAt <= now) {
dayStart = addLocalDays(dayStart, 1, tz);
parts = getLocalParts(dayStart, tz);
remindAt = zonedTimeToEpochMs(
{
year: parts.year,
month: parts.month,
day: parts.day,
hour,
minute,
second: 0,
},
tz,
);
}
const localParts = getLocalParts(remindAt, tz);
return `${localParts.year}-${pad2(localParts.month)}-${pad2(localParts.day)} ${pad2(localParts.hour)}:${pad2(localParts.minute)}`;
}
function parseSimpleTimedReminder(text, { timezone = 'Asia/Shanghai', now = Date.now() } = {}) {
const compact = normalizeText(text);
if (!wantsSimpleTimedReminder(compact, text)) return null;
const time = parseHourMinute(text);
if (!time) return null;
const title = extractTimedReminderTitle(text);
if (!title || title.length < 2) {
const needsClarification = time ? ['reminder_title'] : ['reminder_time', 'reminder_title'];
const partial = {
action: 'create_timed_reminder',
needsClarification,
};
if (time) {
partial.hour = time.hour;
partial.minute = time.minute;
partial.remindLocal = buildTimedReminderLocal({
text,
hour: time.hour,
minute: time.minute,
timezone,
now,
});
}
return partial;
}
return {
action: 'create_timed_reminder',
title,
remindLocal: buildTimedReminderLocal({
text,
hour: time.hour,
minute: time.minute,
timezone,
now,
}),
hour: time.hour,
minute: time.minute,
recurrence: /(?:每天|每日|天天)/u.test(compact) ? 'daily' : 'once',
};
}
function extractTodoTitle(text) {
const original = String(text ?? '').trim();
if (!original) return null;
const quoted = original.match(/[「『【((“"']([^」』】))”"']{1,80})[」』】))”"']/);
if (quoted?.[1]) {
const title = quoted[1].trim();
if (title) return title;
}
const trailingCommand = original.match(
/^(.{1,80}?)(?:帮我)?(?:设|设置|设下|记)(?:一个|下一个)?(?:待办|代办|带办|代拜)(?:吧|一下)?$/u,
);
if (trailingCommand?.[1]) {
const title = trailingCommand[1].trim();
if (title) return title;
}
const cleaned = original
.replace(
/^.*?(?:不用提醒|先记一下|帮我记一下|帮我记|记一下|添加待办|添加任务|记个待办|记个任务|设置一个待办|设置一个代办|设置一个带办|设置一个代拜|设置下一个代拜|设下一个代拜|帮我设置一个待办|帮我设置一个代办|帮我设置一个带办|帮我设置一个代拜|待办|代办|带办|代拜|任务)(?:[:,,、\s]+)?/u,
'',
)
.trim();
return cleaned || null;
}
function wantsDailyTodoDigest(compact) {
const daily = /每天|每日|天天/.test(compact);
const send = /发|发送|推送|提醒|通知|给我/.test(compact);
const digest =
/(待办|todo|任务).*(记录|列表|清单|安排|摘要|汇总)/.test(compact)
|| /(今天|今日|当天).*(待办|todo|任务)/.test(compact)
|| /(待办|todo|任务).*(今天|今日|当天)/.test(compact)
|| /一天的待办/.test(compact);
if (digest && /(?:页面|网页|html|h5)/iu.test(compact)) return false;
if (daily && send && digest) return true;
// 「早上7点把当天待办发给我」省略了「每天」但语义仍是 digest
if (!daily && send && /(当天|今天|今日).*(待办|todo|任务)/.test(compact) && parseHourMinute(compact)) {
return true;
}
return false;
}
export function parseScheduleIntent(text, {
timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
now = Date.now(),
} = {}) {
const compact = normalizeText(text);
if (!compact) return { action: 'none' };
if (wantsDailyTodoDigest(compact)) {
const time = parseHourMinute(compact);
if (!time) {
return {
action: 'create_daily_todo_digest',
needsClarification: ['digest_time'],
};
}
return {
action: 'create_daily_todo_digest',
hour: time.hour,
minute: time.minute,
};
}
const wantsBalanceAlert = /(余额|钱包|账户).*(不足|低于|提醒|预警|通知)|余额不足|低余额|余额预警/.test(compact);
if (wantsBalanceAlert) {
const thresholdMatch = compact.match(/(?:低于|少于|不足|小于)(\d{1,6})(?:元|块|rmb|人民币|cny)?/i);
const thresholdYuan = thresholdMatch ? Number(thresholdMatch[1]) : null;
const thresholdCents = thresholdYuan == null ? null : Math.max(0, Math.round(thresholdYuan * 100));
return thresholdCents == null
? { action: 'create_balance_alert', needsClarification: ['threshold'] }
: { action: 'create_balance_alert', thresholdCents };
}
if (/看看|查看|看下|列出/.test(compact) && /(待办|日程|行程|计划|任务)/.test(compact)) {
return { action: 'query_schedule' };
}
const timedReminder = parseSimpleTimedReminder(text, { timezone, now });
if (timedReminder) return timedReminder;
if (/^(?:帮我|请|麻烦)?(?:设置|设)(?:一个|个|一下)?提醒$/u.test(compact)
|| /^(?:帮我|请|麻烦)?(?:加个|添加|创建|安排)(?:一个|个)?提醒$/u.test(compact)
|| /^(?:帮我|请|麻烦)?(?:定|设)个?(?:闹钟|闹铃)$/u.test(compact)) {
return {
action: 'create_timed_reminder',
needsClarification: ['reminder_time', 'reminder_title'],
};
}
const wantsTodoRecord =
/(不用提醒|先记一下|帮我记一下|帮我记|记一下|添加待办|添加任务|记个待办|记个任务|设置一个待办|设置一个代办|设置一个带办|设置一个代拜|设置下一个代拜|设下一个代拜|帮我设置一个待办|帮我设置一个代办|帮我设置一个带办|帮我设置一个代拜|待办|代办|带办|代拜|任务)/.test(
compact,
);
if (wantsTodoRecord) {
const explicitNoReminder = /(不用提醒|不提醒|先记一下)/.test(compact);
const hasScheduleTime = /(提醒|闹钟|叫我|今天|今晚|明天|后天|早上|上午|中午|下午|晚上|[0-9零一二两三四五六七八九十]{1,3}(点|:|))/.test(
compact,
);
if (!explicitNoReminder && hasScheduleTime) {
return { action: 'schedule_agent' };
}
const title = extractTodoTitle(text);
if (!title) {
return { action: 'create_todo', needsClarification: ['todo_title'] };
}
return { action: 'create_todo', title };
}
return { action: 'none' };
}
export function isScheduleIntent(intent) {
return intent?.action && intent.action !== 'none';
}
export function shouldUseScheduleAssistant(text) {
const compact = normalizeText(text);
if (!compact) return false;
// Scheduled automation (execute + deliver) uses scheduled-task-automation skill.
if (/(?:定时(?:自动)?任务|scheduled\s*task|scheduled\s*automation)/iu.test(compact)) return false;
if (/(?:每天|每日|每周|定时|到点|届时|自动)/u.test(compact)
&& /(?:做|生成|制作|创建|写|执行|跑|更新|整理|汇总|推送)/u.test(compact)
&& !/(?:提醒我|待办记录|待办列表|待办清单|当天待办|一天的待办)/u.test(compact)) {
return false;
}
if (isScheduleIntent(parseScheduleIntent(text))) return true;
if (hasReminderSetupCue(compact) && !isWeeklyScheduleCue(compact)) return true;
const scheduleKeywords = /(提醒|待办|代办|带办|代拜|日程|行程|安排|计划|闹钟|闹铃)/;
const timeKeywords = /(今天|今晚|明天|后天|早上|上午|中午|下午|晚上|\d{1,2}[点::]|[零一二两三四五六七八九十]{1,3}点)/;
return scheduleKeywords.test(compact) && timeKeywords.test(compact);
}
/** Last-resort timed reminder when colloquial phrasing missed parseScheduleIntent. */
export function tryResolveTimedReminderIntent(text, {
timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
now = Date.now(),
} = {}) {
const parsed = parseScheduleIntent(text, { timezone, now });
if (parsed.action === 'create_timed_reminder') return parsed;
const compact = normalizeText(text);
if (!hasReminderSetupCue(compact) || isWeeklyScheduleCue(compact)) return null;
if (countTimeExpressions(text) !== 1 || !parseHourMinute(text)) return null;
const time = parseHourMinute(text);
const title = extractTimedReminderTitle(text);
if (title && title.length >= 2) {
return {
action: 'create_timed_reminder',
title,
remindLocal: buildTimedReminderLocal({ text, hour: time.hour, minute: time.minute, timezone, now }),
hour: time.hour,
minute: time.minute,
recurrence: /(?:每天|每日|天天)/u.test(compact) ? 'daily' : 'once',
};
}
return {
action: 'create_timed_reminder',
needsClarification: ['reminder_title'],
hour: time.hour,
minute: time.minute,
remindLocal: buildTimedReminderLocal({ text, hour: time.hour, minute: time.minute, timezone, now }),
recurrence: /(?:每天|每日|天天)/u.test(compact) ? 'daily' : 'once',
};
}