fix(schedule): harden reminder utterance routing for WeChat ITL
Memind CI / Test, build, and release guards (push) Successful in 3m43s
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>
This commit is contained in:
+21
-3
@@ -1,5 +1,9 @@
|
|||||||
import { detectQueryGuard } from './intent-query-guard.mjs';
|
import { detectQueryGuard } from './intent-query-guard.mjs';
|
||||||
import { parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs';
|
import {
|
||||||
|
parseScheduleIntent,
|
||||||
|
shouldUseScheduleAssistant,
|
||||||
|
tryResolveTimedReminderIntent,
|
||||||
|
} from './schedule-intent.mjs';
|
||||||
import {
|
import {
|
||||||
isScheduledTaskIntent,
|
isScheduledTaskIntent,
|
||||||
parseScheduledTaskIntent,
|
parseScheduledTaskIntent,
|
||||||
@@ -8,10 +12,12 @@ import {
|
|||||||
|
|
||||||
function detectAmbiguity(text) {
|
function detectAmbiguity(text) {
|
||||||
const compact = String(text ?? '').replace(/\s+/g, '');
|
const compact = String(text ?? '').replace(/\s+/g, '');
|
||||||
const notifyCue = /(?:提醒我|设置提醒|设个?提醒|闹钟|叫我)/u.test(compact);
|
const notifyCue =
|
||||||
|
/(?:提醒我|设置提醒|设个?提醒|闹钟|闹铃|叫我|到点提醒|提醒一下)/u.test(compact)
|
||||||
|
|| /(?:设置|设|加|添加|创建|安排|定)(?:一个|个|一下)?(?:每天|每日|天天)?(?:.{0,40})?提醒/u.test(compact);
|
||||||
const actCue = /(?:生成|制作|创建|做|执行|推送|发送|整理).{0,12}(?:页面|日报|报告|摘要)/u.test(compact);
|
const actCue = /(?:生成|制作|创建|做|执行|推送|发送|整理).{0,12}(?:页面|日报|报告|摘要)/u.test(compact);
|
||||||
if (notifyCue && actCue) return true;
|
if (notifyCue && actCue) return true;
|
||||||
if (notifyCue && /(?:自动|生成)/u.test(compact)) return true;
|
if (notifyCue && /(?:自动|生成)/u.test(compact) && actCue) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +70,18 @@ export function classifyUserIntent(text, { now = Date.now(), timezone = 'Asia/Sh
|
|||||||
if (shouldUseScheduledTaskAutomation(text)) {
|
if (shouldUseScheduledTaskAutomation(text)) {
|
||||||
return { layer: 'L2', kind: 'scheduled_task', action: 'agent_automation', detail: schedTask };
|
return { layer: 'L2', kind: 'scheduled_task', action: 'agent_automation', detail: schedTask };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const timedFallback = tryResolveTimedReminderIntent(text, { now, timezone });
|
||||||
|
if (timedFallback?.action === 'create_timed_reminder') {
|
||||||
|
return {
|
||||||
|
layer: 'L1',
|
||||||
|
kind: 'timed_reminder',
|
||||||
|
action: timedFallback.action,
|
||||||
|
detail: timedFallback,
|
||||||
|
clarify: timedFallback.needsClarification ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldUseScheduleAssistant(text)) {
|
if (shouldUseScheduleAssistant(text)) {
|
||||||
const multiTime = (String(text).match(/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)/gu) ?? []).length > 1;
|
const multiTime = (String(text).match(/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)/gu) ?? []).length > 1;
|
||||||
return { layer: multiTime ? 'L3' : 'L1', kind: 'agent_schedule', action: 'agent_schedule' };
|
return { layer: multiTime ? 'L3' : 'L1', kind: 'agent_schedule', action: 'agent_schedule' };
|
||||||
|
|||||||
+1
-1
@@ -119,7 +119,7 @@
|
|||||||
"verify:goosed-proxy-boundary": "node scripts/check-goosed-proxy-boundary.mjs",
|
"verify:goosed-proxy-boundary": "node scripts/check-goosed-proxy-boundary.mjs",
|
||||||
"verify:h5-session-patches": "node scripts/verify-h5-session-patches.mjs",
|
"verify:h5-session-patches": "node scripts/verify-h5-session-patches.mjs",
|
||||||
"verify:scheduled-task-automation": "node scripts/verify-scheduled-task-automation.mjs",
|
"verify:scheduled-task-automation": "node scripts/verify-scheduled-task-automation.mjs",
|
||||||
"verify:intent-transaction-layer": "node scripts/verify-intent-transaction-layer.mjs && node --test intent-classifier.test.mjs intent-query-guard.test.mjs intent-draft-service.test.mjs task-unified-service.test.mjs task-unified-sync.test.mjs wechat/handlers/intent-transaction.test.mjs",
|
"verify:intent-transaction-layer": "node scripts/verify-intent-transaction-layer.mjs && node scripts/verify-schedule-reminder-routing.mjs && node --test intent-classifier.test.mjs intent-query-guard.test.mjs intent-draft-service.test.mjs task-unified-service.test.mjs task-unified-sync.test.mjs wechat/handlers/intent-transaction.test.mjs schedule-intent.test.mjs schedule-utterance-normalize.test.mjs",
|
||||||
"migrate:legacy-tasks-to-h5-tasks": "node scripts/migrate-legacy-tasks-to-h5-tasks.mjs --dry-run",
|
"migrate:legacy-tasks-to-h5-tasks": "node scripts/migrate-legacy-tasks-to-h5-tasks.mjs --dry-run",
|
||||||
"migrate:tang-unified-tasks-103": "node scripts/migrate-tang-unified-tasks-103.mjs",
|
"migrate:tang-unified-tasks-103": "node scripts/migrate-tang-unified-tasks-103.mjs",
|
||||||
"verify:tang-itl-readiness-103": "node scripts/verify-tang-itl-readiness-103.mjs",
|
"verify:tang-itl-readiness-103": "node scripts/verify-tang-itl-readiness-103.mjs",
|
||||||
|
|||||||
+64
-21
@@ -4,22 +4,22 @@ import {
|
|||||||
normalizeTimezone,
|
normalizeTimezone,
|
||||||
zonedTimeToEpochMs,
|
zonedTimeToEpochMs,
|
||||||
} from './schedule-time.mjs';
|
} from './schedule-time.mjs';
|
||||||
|
import {
|
||||||
|
hasReminderSetupCue,
|
||||||
|
isWeeklyScheduleCue,
|
||||||
|
normalizeScheduleUtterance,
|
||||||
|
} from './schedule-utterance-normalize.mjs';
|
||||||
|
|
||||||
function normalizeText(text) {
|
function normalizeText(text) {
|
||||||
return String(text ?? '')
|
return String(text ?? '')
|
||||||
.replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨)+/u, '')
|
.replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨|好的|好)+/u, '')
|
||||||
.replace(/\s+/g, '')
|
.replace(/\s+/g, '')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ASR/wechat often inserts spaces around colons, e.g. "17 :08". */
|
/** ASR/wechat often inserts spaces around colons, e.g. "17 :08". */
|
||||||
function normalizeLooseTimeText(text) {
|
function normalizeLooseTimeText(text) {
|
||||||
return String(text ?? '')
|
return normalizeScheduleUtterance(text);
|
||||||
.replace(
|
|
||||||
/([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})\s*([::])\s*/gu,
|
|
||||||
'$1$2',
|
|
||||||
)
|
|
||||||
.replace(/([::])\s*([0-9]{1,2})/gu, '$1$2');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function pad2(value) {
|
function pad2(value) {
|
||||||
@@ -55,9 +55,14 @@ function chineseHourToNumber(value) {
|
|||||||
|
|
||||||
export function parseHourMinute(text) {
|
export function parseHourMinute(text) {
|
||||||
const normalized = normalizeLooseTimeText(text);
|
const normalized = normalizeLooseTimeText(text);
|
||||||
const match = normalized.match(
|
let match = normalized.match(
|
||||||
/(?:今天|明天|后天|今晚|明晚)?(?:早上|上午|清晨|每天(?:早上|上午)?)?(早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)?([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})(?:点|:|:)(半|[0-9]{1,2}分?)?/,
|
/(?:今天|明天|后天|今晚|明晚|明早|今早)?(?:早上|上午|清晨|每天(?:早上|上午)?)?(早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)?([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;
|
if (!match) return null;
|
||||||
const period = String(match[1] ?? '');
|
const period = String(match[1] ?? '');
|
||||||
let hour = chineseHourToNumber(match[2]);
|
let hour = chineseHourToNumber(match[2]);
|
||||||
@@ -72,6 +77,8 @@ export function parseHourMinute(text) {
|
|||||||
hour += 12;
|
hour += 12;
|
||||||
} else if (/上午|早上|清晨|凌晨/u.test(period) && hour === 12) {
|
} else if (/上午|早上|清晨|凌晨/u.test(period) && hour === 12) {
|
||||||
hour = 0;
|
hour = 0;
|
||||||
|
} else if (/今晚/u.test(normalized) && hour < 12 && !/上午|早上|清晨|凌晨/u.test(period)) {
|
||||||
|
hour += 12;
|
||||||
}
|
}
|
||||||
return { hour, minute };
|
return { hour, minute };
|
||||||
}
|
}
|
||||||
@@ -85,13 +92,8 @@ function countTimeExpressions(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wantsSimpleTimedReminder(compact, text) {
|
function wantsSimpleTimedReminder(compact, text) {
|
||||||
const hasReminderCue =
|
if (isWeeklyScheduleCue(compact)) return false;
|
||||||
/(?:提醒我|到点提醒|提醒一下|闹钟|叫我)/u.test(compact)
|
if (!hasReminderSetupCue(compact)) return false;
|
||||||
|| /(?:设置|设).{0,40}提醒/u.test(compact);
|
|
||||||
if (!hasReminderCue) return false;
|
|
||||||
if (/(?:生成|制作|创建|做|执行|推送|发送|整理).{0,12}(?:页面|日报|报告|摘要)/u.test(compact)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (countTimeExpressions(text) !== 1) return false;
|
if (countTimeExpressions(text) !== 1) return false;
|
||||||
if (/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:).{1,24}[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)/u.test(compact)) {
|
if (/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:).{1,24}[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)/u.test(compact)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -116,7 +118,9 @@ function cleanupTimedReminderTitleFragment(text) {
|
|||||||
.replace(/^(?:帮我|请|麻烦)?(?:设置|设|创建|添加)(?:一个|个)?提醒[,,、::\s]*/u, '')
|
.replace(/^(?:帮我|请|麻烦)?(?:设置|设|创建|添加)(?:一个|个)?提醒[,,、::\s]*/u, '')
|
||||||
.replace(/^(?:帮我|请)?(?:设置|设)(?:一个|个)?(?:待办|代办|带办|代拜)[,,、::\s]*/u, '');
|
.replace(/^(?:帮我|请)?(?:设置|设)(?:一个|个)?(?:待办|代办|带办|代拜)[,,、::\s]*/u, '');
|
||||||
title = title
|
title = title
|
||||||
.replace(/^(?:帮我|请|麻烦)?(?:设置|设)(?:一个|个)?(?:每天|每日|天天)?/u, '')
|
.replace(/^(?:帮我|请|麻烦)?(?:设置|设|加|添加|创建|安排|定)(?:一个|个|一下)?(?:每天|每日|天天)?/u, '')
|
||||||
|
.replace(/^(?:帮我|请|麻烦)?(?:加个|添加|创建|安排)(?:一个|个)?提醒/u, '')
|
||||||
|
.replace(/^(?:帮我|请|麻烦)?(?:定|设)个?(?:闹钟|闹铃)/u, '')
|
||||||
.replace(/(?:今天|今日|今晚|明天|后天|明早|今早)/gu, ' ')
|
.replace(/(?:今天|今日|今晚|明天|后天|明早|今早)/gu, ' ')
|
||||||
.replace(/(?:每天|每日|天天)/gu, ' ')
|
.replace(/(?:每天|每日|天天)/gu, ' ')
|
||||||
.replace(/(?:早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)/gu, ' ')
|
.replace(/(?:早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)/gu, ' ')
|
||||||
@@ -124,7 +128,7 @@ function cleanupTimedReminderTitleFragment(text) {
|
|||||||
/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)(?:半|[0-9]{1,2}(?:\s*分(?!开)|(?![0-9]))?)?/gu,
|
/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)(?:半|[0-9]{1,2}(?:\s*分(?!开)|(?![0-9]))?)?/gu,
|
||||||
' ',
|
' ',
|
||||||
)
|
)
|
||||||
.replace(/(?:提醒我|提醒|闹钟|叫我|设置提醒|设提醒)/gu, ' ')
|
.replace(/(?:提醒我|提醒|闹钟|闹铃|叫我|设置提醒|设提醒|备忘提醒|定时提醒)/gu, ' ')
|
||||||
.replace(/[,,、::]/g, ' ')
|
.replace(/[,,、::]/g, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
@@ -148,6 +152,8 @@ export function buildTimedReminderLocal({
|
|||||||
let dayOffset = 0;
|
let dayOffset = 0;
|
||||||
if (/后天/u.test(compact)) dayOffset = 2;
|
if (/后天/u.test(compact)) dayOffset = 2;
|
||||||
else if (/明天|明早|明晚/u.test(compact)) dayOffset = 1;
|
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);
|
const tz = normalizeTimezone(timezone);
|
||||||
let dayStart = addLocalDays(now, dayOffset, tz);
|
let dayStart = addLocalDays(now, dayOffset, tz);
|
||||||
@@ -303,7 +309,9 @@ export function parseScheduleIntent(text, {
|
|||||||
const timedReminder = parseSimpleTimedReminder(text, { timezone, now });
|
const timedReminder = parseSimpleTimedReminder(text, { timezone, now });
|
||||||
if (timedReminder) return timedReminder;
|
if (timedReminder) return timedReminder;
|
||||||
|
|
||||||
if (/^(?:帮我|请|麻烦)?(?:设置|设)(?:一个|个)?提醒$/u.test(compact)) {
|
if (/^(?:帮我|请|麻烦)?(?:设置|设)(?:一个|个|一下)?提醒$/u.test(compact)
|
||||||
|
|| /^(?:帮我|请|麻烦)?(?:加个|添加|创建|安排)(?:一个|个)?提醒$/u.test(compact)
|
||||||
|
|| /^(?:帮我|请|麻烦)?(?:定|设)个?(?:闹钟|闹铃)$/u.test(compact)) {
|
||||||
return {
|
return {
|
||||||
action: 'create_timed_reminder',
|
action: 'create_timed_reminder',
|
||||||
needsClarification: ['reminder_time', 'reminder_title'],
|
needsClarification: ['reminder_time', 'reminder_title'],
|
||||||
@@ -347,7 +355,42 @@ export function shouldUseScheduleAssistant(text) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (isScheduleIntent(parseScheduleIntent(text))) return true;
|
if (isScheduleIntent(parseScheduleIntent(text))) return true;
|
||||||
const scheduleKeywords = /(提醒|待办|代办|带办|代拜|日程|行程|安排|计划|闹钟)/;
|
if (hasReminderSetupCue(compact) && !isWeeklyScheduleCue(compact)) return true;
|
||||||
const timeKeywords = /(今天|今晚|明天|后天|早上|上午|中午|下午|晚上|\d{1,2}[点::])/;
|
const scheduleKeywords = /(提醒|待办|代办|带办|代拜|日程|行程|安排|计划|闹钟|闹铃)/;
|
||||||
|
const timeKeywords = /(今天|今晚|明天|后天|早上|上午|中午|下午|晚上|\d{1,2}[点::]|[零一二两三四五六七八九十]{1,3}点)/;
|
||||||
return scheduleKeywords.test(compact) && timeKeywords.test(compact);
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -219,3 +219,39 @@ test('nextDailyRunAt rolls to tomorrow when today time has passed', () => {
|
|||||||
const next = nextDailyRunAt({ hour: 7, minute: 0, timezone: 'Asia/Shanghai', now });
|
const next = nextDailyRunAt({ hour: 7, minute: 0, timezone: 'Asia/Shanghai', now });
|
||||||
assert.equal(next, Date.UTC(2026, 5, 18, 23, 0, 0)); // 2026-06-19 07:00 Asia/Shanghai
|
assert.equal(next, Date.UTC(2026, 5, 18, 23, 0, 0)); // 2026-06-19 07:00 Asia/Shanghai
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses colloquial add-reminder phrases', () => {
|
||||||
|
const now = Date.UTC(2026, 7, 24, 9, 0, 0);
|
||||||
|
const cases = [
|
||||||
|
['加个提醒明天9点开会', { title: '开会', hour: 9 }],
|
||||||
|
['添加提醒后天10点体检', { title: '体检', hour: 10 }],
|
||||||
|
['创建提醒明早6点跑步', { title: '跑步', hour: 6 }],
|
||||||
|
['备忘提醒下午3点交报告', { title: '交报告', hour: 15 }],
|
||||||
|
];
|
||||||
|
for (const [text, expected] of cases) {
|
||||||
|
const intent = parseScheduleIntent(text, { timezone: 'Asia/Shanghai', now });
|
||||||
|
assert.equal(intent.action, 'create_timed_reminder', text);
|
||||||
|
assert.equal(intent.title, expected.title, text);
|
||||||
|
assert.equal(intent.hour, expected.hour, text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('colloquial add-reminder phrases route to ITL timed reminder', () => {
|
||||||
|
for (const text of [
|
||||||
|
'加个提醒明天9点开会',
|
||||||
|
'添加提醒后天10点体检',
|
||||||
|
'备忘提醒下午3点交报告',
|
||||||
|
]) {
|
||||||
|
const result = classifyUserIntent(text);
|
||||||
|
assert.equal(result.kind, 'timed_reminder', text);
|
||||||
|
assert.equal(result.layer, 'L1', text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('time-only reminder without title asks for clarification', () => {
|
||||||
|
const now = Date.UTC(2026, 7, 24, 9, 0, 0);
|
||||||
|
const intent = parseScheduleIntent('安排一个提醒今晚8点', { timezone: 'Asia/Shanghai', now });
|
||||||
|
assert.equal(intent.action, 'create_timed_reminder');
|
||||||
|
assert.deepEqual(intent.needsClarification, ['reminder_title']);
|
||||||
|
assert.equal(intent.hour, 20);
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Normalize WeChat / ASR schedule utterances before intent parsing.
|
||||||
|
* Keeps raw user text for display; use this for matching only.
|
||||||
|
*/
|
||||||
|
export function normalizeScheduleUtterance(text) {
|
||||||
|
return String(text ?? '')
|
||||||
|
.replace(/[\uFF10-\uFF19]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xff10 + 0x30))
|
||||||
|
.replace(/:/g, ':')
|
||||||
|
.replace(/([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})\s*([::])\s*/gu, '$1$2')
|
||||||
|
.replace(/([::])\s*([0-9]{1,2})/gu, '$1$2')
|
||||||
|
.replace(/([0-9]{1,2})\s*(点|:|:)/gu, '$1$2')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when user is trying to set a reminder/alarm (not automation/digest). */
|
||||||
|
export function hasReminderSetupCue(compact) {
|
||||||
|
if (!compact) return false;
|
||||||
|
if (/(?:生成|制作|创建|做|执行|推送|发送|整理).{0,12}(?:页面|日报|报告|摘要)/u.test(compact)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
/(?:提醒我|到点提醒|提醒一下|闹钟|闹铃|叫我|记得提醒)/u.test(compact)
|
||||||
|
|| /(?:加个|添加|创建|安排)(?:一个|个)?提醒/u.test(compact)
|
||||||
|
|| /(?:设置|设|加|添加|创建|安排|定)(?:一个|个|一下)?(?:每天|每日|天天)?(?:.{0,40})?提醒/u.test(compact)
|
||||||
|
|| /(?:定|设)个?(?:闹钟|闹铃)/u.test(compact)
|
||||||
|
|| /^定时提醒/u.test(compact)
|
||||||
|
|| /(?:待办|代办|带办|代拜)提醒/u.test(compact)
|
||||||
|
|| /备忘提醒/u.test(compact)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeeklyScheduleCue(compact) {
|
||||||
|
return /(?:每周|每个周|星期[一二三四五六日天]|周[一二三四五六日天])/u.test(compact);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
hasReminderSetupCue,
|
||||||
|
isWeeklyScheduleCue,
|
||||||
|
normalizeScheduleUtterance,
|
||||||
|
} from './schedule-utterance-normalize.mjs';
|
||||||
|
|
||||||
|
test('normalizeScheduleUtterance fixes ASR spaced colons and fullwidth digits', () => {
|
||||||
|
assert.equal(normalizeScheduleUtterance('下午 17 : 08 开会'), '下午 17:08 开会');
|
||||||
|
assert.equal(normalizeScheduleUtterance('18 :00'), '18:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasReminderSetupCue recognizes common colloquial reminder phrases', () => {
|
||||||
|
const compact = (text) => String(text).replace(/\s+/g, '');
|
||||||
|
assert.equal(hasReminderSetupCue(compact('加个提醒明天9点开会')), true);
|
||||||
|
assert.equal(hasReminderSetupCue(compact('添加提醒后天10点体检')), true);
|
||||||
|
assert.equal(hasReminderSetupCue(compact('定个闹钟明天7点')), true);
|
||||||
|
assert.equal(hasReminderSetupCue(compact('备忘提醒下午3点交报告')), true);
|
||||||
|
assert.equal(hasReminderSetupCue(compact('设置每天18点提醒打卡')), true);
|
||||||
|
assert.equal(hasReminderSetupCue(compact('每天6点帮我做今日新闻页面')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isWeeklyScheduleCue detects weekly recurrence wording', () => {
|
||||||
|
assert.equal(isWeeklyScheduleCue('每周一早上9点提醒我开会'), true);
|
||||||
|
assert.equal(isWeeklyScheduleCue('明天9点提醒我开会'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Verify WeChat reminder utterances route to ITL timed_reminder (L1), not agent_schedule.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { classifyUserIntent } from '../intent-classifier.mjs';
|
||||||
|
import { parseScheduleIntent, tryResolveTimedReminderIntent } from '../schedule-intent.mjs';
|
||||||
|
import { hasReminderSetupCue } from '../schedule-utterance-normalize.mjs';
|
||||||
|
|
||||||
|
const timezone = 'Asia/Shanghai';
|
||||||
|
const now = Date.UTC(2026, 7, 24, 9, 0, 0); // 2026-08-24 17:00 Asia/Shanghai
|
||||||
|
|
||||||
|
const matrix = [
|
||||||
|
{
|
||||||
|
text: '设置一个提醒,今天下午 17 :08要开会',
|
||||||
|
expect: { kind: 'timed_reminder', action: 'create_timed_reminder', title: '要开会', hour: 17, minute: 8 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '设置每天18:00提醒打卡',
|
||||||
|
expect: { kind: 'timed_reminder', action: 'create_timed_reminder', title: '打卡', recurrence: 'daily', hour: 18 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '加个提醒明天9点开会',
|
||||||
|
expect: { kind: 'timed_reminder', action: 'create_timed_reminder', title: '开会', hour: 9 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '添加提醒后天10点体检',
|
||||||
|
expect: { kind: 'timed_reminder', action: 'create_timed_reminder', title: '体检', hour: 10 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '创建提醒明早6点跑步',
|
||||||
|
expect: { kind: 'timed_reminder', action: 'create_timed_reminder', title: '跑步', hour: 6 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '备忘提醒下午3点交报告',
|
||||||
|
expect: { kind: 'timed_reminder', action: 'create_timed_reminder', title: '交报告', hour: 15 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '安排一个提醒今晚8点',
|
||||||
|
expect: { kind: 'timed_reminder', action: 'create_timed_reminder', clarify: ['reminder_title'], hour: 20 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '定个闹钟明天7点起床',
|
||||||
|
expect: { kind: 'timed_reminder', action: 'create_timed_reminder', title: '起床', hour: 7 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function pass(label) {
|
||||||
|
passed += 1;
|
||||||
|
console.log(`✔ ${label}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(label, detail) {
|
||||||
|
failed += 1;
|
||||||
|
console.error(`✘ ${label}: ${detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of matrix) {
|
||||||
|
const compact = String(row.text).replace(/\s+/g, '');
|
||||||
|
if (!hasReminderSetupCue(compact)) {
|
||||||
|
fail(row.text, 'missing reminder setup cue');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseScheduleIntent(row.text, { timezone, now })
|
||||||
|
?? tryResolveTimedReminderIntent(row.text, { timezone, now });
|
||||||
|
if (parsed?.action !== row.expect.action) {
|
||||||
|
fail(row.text, `parse action ${parsed?.action} !== ${row.expect.action}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.expect.title && parsed.title !== row.expect.title) {
|
||||||
|
fail(row.text, `title ${parsed.title} !== ${row.expect.title}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.expect.hour != null && parsed.hour !== row.expect.hour) {
|
||||||
|
fail(row.text, `hour ${parsed.hour} !== ${row.expect.hour}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.expect.minute != null && parsed.minute !== row.expect.minute) {
|
||||||
|
fail(row.text, `minute ${parsed.minute} !== ${row.expect.minute}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.expect.recurrence && parsed.recurrence !== row.expect.recurrence) {
|
||||||
|
fail(row.text, `recurrence ${parsed.recurrence} !== ${row.expect.recurrence}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.expect.clarify) {
|
||||||
|
assert.deepEqual(parsed.needsClarification, row.expect.clarify);
|
||||||
|
}
|
||||||
|
|
||||||
|
const classified = classifyUserIntent(row.text, { timezone, now });
|
||||||
|
if (classified.kind !== row.expect.kind) {
|
||||||
|
fail(row.text, `classified kind ${classified.kind} !== ${row.expect.kind}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (classified.action === 'agent_schedule') {
|
||||||
|
fail(row.text, 'must not route to agent_schedule');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pass(row.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nschedule reminder routing: ${passed} passed, ${failed} failed`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
wasWechatDeliveryDeferred,
|
wasWechatDeliveryDeferred,
|
||||||
} from './wechat/customer-service-deferred.mjs';
|
} from './wechat/customer-service-deferred.mjs';
|
||||||
import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs';
|
import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs';
|
||||||
|
import { isIntentTransactionEnabled } from './intent-transaction-config.mjs';
|
||||||
import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs';
|
import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs';
|
||||||
import { handleWechatIntentTransaction } from './wechat/handlers/intent-transaction.mjs';
|
import { handleWechatIntentTransaction } from './wechat/handlers/intent-transaction.mjs';
|
||||||
import { handleWechatScheduledTaskIntent } from './wechat/handlers/scheduled-task.mjs';
|
import { handleWechatScheduledTaskIntent } from './wechat/handlers/scheduled-task.mjs';
|
||||||
@@ -2871,6 +2872,7 @@ export function createWechatMpService({
|
|||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
sourceMessageId: intent.msgId,
|
sourceMessageId: intent.msgId,
|
||||||
logger,
|
logger,
|
||||||
|
intentTransactionEnabled: isIntentTransactionEnabled(),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const requestStartedAt = Date.now();
|
const requestStartedAt = Date.now();
|
||||||
|
|||||||
@@ -2,18 +2,30 @@ export function looksLikeScheduleConfirmation(text) {
|
|||||||
const compact = String(text ?? '').replace(/\s+/g, '');
|
const compact = String(text ?? '').replace(/\s+/g, '');
|
||||||
if (!compact) return false;
|
if (!compact) return false;
|
||||||
if (/(没有|没能|未能|无法|不能|失败|需要你|请补充|请告诉)/.test(compact)) return false;
|
if (/(没有|没能|未能|无法|不能|失败|需要你|请补充|请告诉)/.test(compact)) return false;
|
||||||
return /(已经|已|现在).{0,12}(设置|安排|记录|加上|添加|创建).{0,12}(待办|提醒|日程|安排|闹钟)|设置好了|已经设置好了|已经加上/.test(
|
return /(已经|已|现在).{0,12}(设置|安排|记录|加上|添加|创建).{0,12}(待办|提醒|日程|安排|闹钟|闹铃)|设置好了|已经设置好了|已经加上/.test(
|
||||||
compact,
|
compact,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rewriteScheduleConfirmationWhenItlEnabled(replyText, { enabled = false } = {}) {
|
||||||
|
if (!enabled || !looksLikeScheduleConfirmation(replyText)) return replyText;
|
||||||
|
return [
|
||||||
|
'这类提醒需要你先确认卡片后才会写入。',
|
||||||
|
'请直接发完整安排(例如「每天18点提醒打卡」),我会先发确认卡片;回复「确认」后再生效。',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
export async function guardScheduleConfirmationReply({
|
export async function guardScheduleConfirmationReply({
|
||||||
replyText,
|
replyText,
|
||||||
scheduleService,
|
scheduleService,
|
||||||
userId,
|
userId,
|
||||||
sourceMessageId,
|
sourceMessageId,
|
||||||
logger,
|
logger,
|
||||||
|
intentTransactionEnabled = false,
|
||||||
}) {
|
}) {
|
||||||
|
if (intentTransactionEnabled) {
|
||||||
|
return rewriteScheduleConfirmationWhenItlEnabled(replyText, { enabled: true });
|
||||||
|
}
|
||||||
if (!scheduleService || !looksLikeScheduleConfirmation(replyText)) return replyText;
|
if (!scheduleService || !looksLikeScheduleConfirmation(replyText)) return replyText;
|
||||||
const messageId = String(sourceMessageId ?? '').trim();
|
const messageId = String(sourceMessageId ?? '').trim();
|
||||||
if (!messageId || typeof scheduleService.listItemsBySourceMessage !== 'function') {
|
if (!messageId || typeof scheduleService.listItemsBySourceMessage !== 'function') {
|
||||||
|
|||||||
@@ -154,6 +154,19 @@ test('schedule confirmation guard blocks false positives', async () => {
|
|||||||
assert.match(guarded, /不能算设置成功/);
|
assert.match(guarded, /不能算设置成功/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('schedule confirmation guard rewrites agent pseudo-confirm when ITL enabled', async () => {
|
||||||
|
const guarded = await guardScheduleConfirmationReply({
|
||||||
|
replyText: '已经帮你设置好了待办提醒',
|
||||||
|
scheduleService: { listItemsBySourceMessage: async () => [] },
|
||||||
|
userId: 'user-1',
|
||||||
|
sourceMessageId: 'msg-1',
|
||||||
|
logger: null,
|
||||||
|
intentTransactionEnabled: true,
|
||||||
|
});
|
||||||
|
assert.match(guarded, /确认卡片/);
|
||||||
|
assert.doesNotMatch(guarded, /不能算设置成功/);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolvePageGenerateOutcome fails when only stub artifacts exist', () => {
|
test('resolvePageGenerateOutcome fails when only stub artifacts exist', () => {
|
||||||
const outcome = resolvePageGenerateOutcome({
|
const outcome = resolvePageGenerateOutcome({
|
||||||
reply: { text: '页面已生成 https://m.tkmind.cn/MindSpace/u/public/tang-poem.html' },
|
reply: { text: '页面已生成 https://m.tkmind.cn/MindSpace/u/public/tang-poem.html' },
|
||||||
|
|||||||
Reference in New Issue
Block a user