9dfafb99ca
Memind CI / Test, build, and release guards (push) Successful in 3m43s
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>
397 lines
15 KiB
JavaScript
397 lines
15 KiB
JavaScript
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',
|
||
};
|
||
}
|