Files
memind/schedule-intent.mjs
T

397 lines
15 KiB
JavaScript
Raw Normal View History

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;
}
2026-07-07 12:10:21 +08:00
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;
2026-07-07 12:10:21 +08:00
}
export function parseScheduleIntent(text, {
timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai',
now = Date.now(),
} = {}) {
const compact = normalizeText(text);
if (!compact) return { action: 'none' };
2026-07-07 12:10:21 +08:00
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',
};
}