fix(schedule): route daily set-remind phrases through ITL with recurrence
Memind CI / Test, build, and release guards (push) Failing after 3m4s

Support colloquial forms like「设置每天18:00提醒打卡」, persist daily metadata on
commit, and schedule the next reminder after each delivery.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-24 17:42:08 +08:00
parent 4100062ba8
commit 2319c9c808
6 changed files with 77 additions and 2 deletions
+10 -1
View File
@@ -50,7 +50,16 @@ export async function commitIntentDraft({
sourceChannel: payload.sourceChannel ?? 'wechat',
sourceMessageId: payload.sourceMessageId ?? draft.sourceMessageId,
sourceText: payload.sourceText ?? draft.sourceText,
metadata: { source: 'intent_transaction_layer' },
metadata: {
source: 'intent_transaction_layer',
...(payload.recurrence === 'daily'
? {
recurrence: 'daily',
dailyHour: Number(payload.hour),
dailyMinute: Number(payload.minute ?? 0),
}
: {}),
},
});
const reminder = await scheduleService.createReminder({
userId,
+10 -1
View File
@@ -85,7 +85,13 @@ function countTimeExpressions(text) {
}
function wantsSimpleTimedReminder(compact, text) {
if (!/(?:提醒我|设置(?:一个|个)?提醒|设(?:一个|个)?提醒|到点提醒|提醒一下|闹钟|叫我)/u.test(compact)) return false;
const hasReminderCue =
/(?:提醒我|到点提醒|提醒一下|闹钟|叫我)/u.test(compact)
|| /(?:设置|设).{0,40}提醒/u.test(compact);
if (!hasReminderCue) return false;
if (/(?:生成|制作|创建|做|执行|推送|发送|整理).{0,12}(?:页面|日报|报告|摘要)/u.test(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;
@@ -110,7 +116,9 @@ function cleanupTimedReminderTitleFragment(text) {
.replace(/^(?:帮我|请|麻烦)?(?:设置|设|创建|添加)(?:一个|个)?提醒[,、:\s]*/u, '')
.replace(/^(?:帮我|请)?(?:设置|设)(?:一个|个)?(?:待办|代办|带办|代拜)[,、:\s]*/u, '');
title = title
.replace(/^(?:帮我|请|麻烦)?(?:设置|设)(?:一个|个)?(?:每天|每日|天天)?/u, '')
.replace(/(?:今天|今日|今晚|明天|后天|明早|今早)/gu, ' ')
.replace(/(?:每天|每日|天天)/gu, ' ')
.replace(/(?:早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)/gu, ' ')
.replace(
/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|)(?:半|[0-9]{1,2}(?:\s*分(?!开)|(?![0-9]))?)?/gu,
@@ -211,6 +219,7 @@ function parseSimpleTimedReminder(text, { timezone = 'Asia/Shanghai', now = Date
}),
hour: time.hour,
minute: time.minute,
recurrence: /(?:每天|每日|天天)/u.test(compact) ? 'daily' : 'once',
};
}
+19
View File
@@ -144,6 +144,25 @@ test('ASR spaced colon routes to ITL timed reminder not agent', () => {
assert.equal(result.layer, 'L1');
});
test('parses daily reminder with time between set and remind words', () => {
const now = Date.UTC(2026, 7, 24, 9, 0, 0); // 2026-08-24 17:00 Asia/Shanghai
const intent = parseScheduleIntent('设置每天1800提醒打卡', {
timezone: 'Asia/Shanghai',
now,
});
assert.equal(intent.action, 'create_timed_reminder');
assert.equal(intent.title, '打卡');
assert.equal(intent.recurrence, 'daily');
assert.equal(intent.hour, 18);
assert.equal(intent.minute, 0);
});
test('daily set-remind phrase routes to ITL timed reminder', () => {
const result = classifyUserIntent('设置每天1800提醒打卡');
assert.equal(result.kind, 'timed_reminder');
assert.equal(result.detail.recurrence, 'daily');
});
test('parses direct reminder setup phrase from wechat', () => {
const now = Date.UTC(2026, 7, 24, 4, 0, 0); // 2026-08-24 12:00 Asia/Shanghai
const intent = parseScheduleIntent('帮我设置提醒,下午 14:30 分开会,项目计划例会', {
+1
View File
@@ -54,6 +54,7 @@ export function startScheduleReminderWorker({
status: 'success',
});
await scheduleService.markReminderSent(reminder);
await scheduleService.scheduleNextDailyReminder?.(reminder);
} catch (err) {
logger.warn?.('Schedule reminder delivery failed:', err);
await scheduleService.logDelivery({
+34
View File
@@ -29,6 +29,16 @@ function nowMs() {
function rowToItem(row) {
if (!row) return null;
let metadata = null;
if (row.metadata_json != null && row.metadata_json !== '') {
try {
metadata = typeof row.metadata_json === 'object'
? row.metadata_json
: JSON.parse(row.metadata_json);
} catch {
metadata = null;
}
}
return {
id: row.id,
userId: row.user_id,
@@ -42,6 +52,7 @@ function rowToItem(row) {
allDay: Boolean(row.all_day),
timezone: row.timezone || DEFAULT_TIMEZONE,
location: row.location ?? null,
metadata,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
@@ -660,6 +671,28 @@ export function createScheduleService(pool, options = {}) {
return { ...reminder, status: 'sent', sentAt: now, lockedUntil: null };
};
const scheduleNextDailyReminder = async (reminder) => {
if (!reminder?.userId || !reminder?.itemId) return null;
const item = await getItem({ userId: reminder.userId, itemId: reminder.itemId });
if (!item || item.status !== 'active' || item.metadata?.recurrence !== 'daily') return null;
const hour = Number(item.metadata.dailyHour);
const minute = Number(item.metadata.dailyMinute ?? 0);
if (!Number.isInteger(hour) || hour < 0 || hour > 23) return null;
if (!Number.isInteger(minute) || minute < 0 || minute > 59) return null;
const remindAt = nextDailyRunAt({
hour,
minute,
timezone: item.timezone || defaultTimezone,
now: clock.now() + 1000,
});
return createReminder({
userId: reminder.userId,
itemId: item.id,
remindAt,
channel: reminder.channel ?? 'wechat',
});
};
const markReminderCancelled = async (reminder, reason, { now = clock.now() } = {}) => {
const lastError = String(reason ?? '已取消').slice(0, 500);
await pool.query(
@@ -978,6 +1011,7 @@ export function createScheduleService(pool, options = {}) {
listDueReminders,
lockReminder,
markReminderSent,
scheduleNextDailyReminder,
markReminderCancelled,
markReminderFailed,
buildReminderText,
+3
View File
@@ -26,6 +26,9 @@ function buildDraftPayload(classification, { intent, user, timezone }) {
...base,
title: detail.title,
remindLocal: detail.remindLocal,
hour: detail.hour,
minute: detail.minute,
recurrence: detail.recurrence ?? 'once',
};
}
if (kind === 'create_todo') {