feat(wechat): add Intent Transaction Layer with unified task schema

Introduce Draft → Confirm → Commit flow for WeChat schedule intents behind
feature flags, plus h5_tasks dual-write/read aggregation and rollout scripts
so reminders and automations get explicit user confirmation before persisting.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-24 15:59:17 +08:00
parent b93c92a3e2
commit d58dc2a251
45 changed files with 4972 additions and 30 deletions
+223
View File
@@ -0,0 +1,223 @@
import { classifyUserIntent, inferActionLevel } from '../../intent-classifier.mjs';
import { commitIntentDraft } from '../../intent-transaction-commit.mjs';
import {
formatDraftCancelledReply,
formatDraftCommittedReply,
formatIntentActionCard,
parseDraftUserReply,
} from '../../intent-action-card.mjs';
import { formatQueryGuardReply } from '../../intent-query-guard.mjs';
import { isIntentTransactionEnabled } from '../../intent-transaction-config.mjs';
import { buildScheduledTaskCreatePayload } from '../../scheduled-task-intent.mjs';
import { formatScheduledTaskCreateReply } from '../../scheduled-task-intent.mjs';
import { isScheduledTaskWorkerEnabled, scheduledTaskWorkerDisabledMessage } from '../../scheduled-task-worker-config.mjs';
function buildDraftPayload(classification, { intent, user, timezone }) {
const { kind, detail } = classification;
const base = {
sourceChannel: 'wechat',
sourceMessageId: intent.msgId ?? null,
sourceText: intent.agentText,
timezone,
};
if (kind === 'timed_reminder') {
return {
...base,
title: detail.title,
remindLocal: detail.remindLocal,
};
}
if (kind === 'create_todo') {
return { ...base, title: detail.title };
}
if (kind === 'create_daily_todo_digest') {
return { ...base, hour: detail.hour, minute: detail.minute ?? 0 };
}
if (kind === 'create_balance_alert') {
return { ...base, thresholdCents: detail.thresholdCents };
}
if (kind === 'scheduled_task') {
return {
...base,
intentDetail: detail,
createPayload: buildScheduledTaskCreatePayload(detail, {
userId: user.userId,
sourceChannel: 'wechat',
sourceMessageId: intent.msgId ?? null,
sourceText: intent.agentText,
timezone,
}),
};
}
return base;
}
function buildCard(classification, draftPayload, actionLevel, text) {
const { layer, kind, detail, clarify } = classification;
if (kind === 'clarify' || classification.layer === 'ambiguous') {
return formatIntentActionCard({ clarify: ['notify_vs_act'] });
}
if (clarify?.length) {
return formatIntentActionCard({ clarify });
}
const title = detail?.title ?? detail?.taskSpec?.slice?.(0, 80) ?? draftPayload.title ?? '待确认任务';
const trigger = detail?.remindLocal
? { at: detail.remindLocal }
: { hour: detail?.hour, minute: detail?.minute ?? 0 };
const frequency = /(?:每天|每日)/u.test(text)
? '每天自动执行'
: detail?.recurrence === 'weekly'
? '每周自动执行'
: detail?.recurrence === 'daily'
? '每天自动执行'
: null;
const actions = kind === 'scheduled_task'
? ['到点自动执行任务', '推送执行结果到微信']
: kind === 'timed_reminder'
? ['到点发送微信提醒']
: kind === 'create_daily_todo_digest'
? ['到点推送当天待办摘要']
: kind === 'create_balance_alert'
? ['余额低于阈值时发送微信提醒']
: ['记录到待办列表'];
return formatIntentActionCard({
title,
layer,
actionLevel,
trigger,
actions,
frequency,
});
}
export async function handleWechatIntentTransaction({
intent,
user,
intentDraftService,
taskUnifiedService = null,
scheduleService,
scheduledTaskService,
env = process.env,
logger = console,
}) {
if (!isIntentTransactionEnabled(env) || !intentDraftService) return null;
const timezone = env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
const text = intent.agentText;
try {
const pending = await intentDraftService.getPendingDraft(user.userId);
const replyKind = parseDraftUserReply(text);
if (pending && replyKind) {
if (replyKind === 'cancel') {
await intentDraftService.cancelDraft(pending.id, user.userId);
return formatDraftCancelledReply();
}
if (replyKind === 'modify') {
await intentDraftService.cancelDraft(pending.id, user.userId, { reason: 'user_modify' });
return '好的,请重新发送完整的安排,例如「下午 2 点半提醒我开项目计划例会」。';
}
if (replyKind === 'confirm') {
if (pending.layer === 'ambiguous' || pending.draftType === 'clarify') {
return '我还需要确认:你是要「到点提醒」还是「到点自动执行并交付结果」?请补充后再发「确认」。';
}
const committed = await commitIntentDraft({
draft: pending,
userId: user.userId,
scheduleService,
scheduledTaskService,
taskUnifiedService,
timezone,
env,
});
await intentDraftService.markDraftCommitted(pending.id, user.userId, committed);
if (committed.kind === 'scheduled_task') {
const workerWarning = isScheduledTaskWorkerEnabled(env)
? null
: scheduledTaskWorkerDisabledMessage(env);
let reply = formatScheduledTaskCreateReply(committed.task, { workerWarning });
reply = `${reply}\n\n(已通过确认卡片写入)`;
return reply;
}
return formatDraftCommittedReply({ title: pending.title, kind: pending.draftType });
}
}
const classification = classifyUserIntent(text, { timezone });
const { layer, kind, action } = classification;
if (layer === 'L0') {
return formatQueryGuardReply(text, {
scheduleService,
scheduledTaskService,
taskUnifiedService,
userId: user.userId,
timezone,
env,
});
}
if (layer === 'L3' || layer === null || kind === 'agent_schedule' || kind === 'agent_automation') {
return null;
}
if (kind === 'manage') {
return null;
}
if (layer === 'ambiguous' || kind === 'clarify') {
if (classification.clarify?.length && kind !== 'clarify') {
return buildCard(classification, {}, inferActionLevel(classification, text), text);
}
await intentDraftService.createDraft({
userId: user.userId,
layer: 'ambiguous',
draftType: 'clarify',
actionLevel: 2,
title: '待确认意图',
payload: { clarify: ['notify_vs_act'], sourceText: text },
cardText: buildCard({ layer: 'ambiguous', kind: 'clarify', clarify: ['notify_vs_act'] }, {}, 2, text),
sourceMessageId: intent.msgId ?? null,
sourceText: text,
});
return buildCard({ layer: 'ambiguous', kind: 'clarify', clarify: ['notify_vs_act'] }, {}, 2, text);
}
const draftableKinds = new Set([
'timed_reminder',
'create_todo',
'create_daily_todo_digest',
'create_balance_alert',
'scheduled_task',
]);
if (!draftableKinds.has(kind)) return null;
if (classification.clarify?.length) {
return buildCard(classification, {}, inferActionLevel(classification, text), text);
}
const actionLevel = inferActionLevel(classification, text);
const payload = buildDraftPayload(classification, { intent, user, timezone });
const cardText = buildCard(classification, payload, actionLevel, text);
await intentDraftService.createDraft({
userId: user.userId,
layer,
draftType: kind,
actionLevel,
title: payload.title ?? payload.createPayload?.title ?? classification.detail?.title ?? '待确认任务',
payload,
cardText,
sourceMessageId: intent.msgId ?? null,
sourceText: text,
});
return cardText;
} catch (err) {
logger.warn?.(
'[wechat-intent-transaction] failed:',
err instanceof Error ? err.message : err,
);
return `设置预览失败:${err instanceof Error ? err.message : String(err)}`;
}
}
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { handleWechatIntentTransaction } from './intent-transaction.mjs';
function createDraftStore() {
let pending = null;
return {
async getPendingDraft(userId) {
return pending?.userId === userId ? pending : null;
},
async createDraft(payload) {
pending = {
id: 'draft-1',
userId: payload.userId,
layer: payload.layer,
draftType: payload.draftType,
actionLevel: payload.actionLevel,
title: payload.title,
payload: payload.payload,
cardText: payload.cardText,
status: 'draft',
};
return pending;
},
async cancelDraft(id, userId) {
if (pending?.id === id && pending.userId === userId) pending = null;
return { id, status: 'cancelled' };
},
async markDraftCommitted(id, userId, committedRef) {
if (pending?.id === id && pending.userId === userId) {
pending = { ...pending, status: 'committed', committedRef };
}
return pending;
},
};
}
function createScheduleService() {
return {
async createItem(payload) {
return { id: 'item-1', ...payload };
},
async createReminder(payload) {
return { id: 'reminder-1', ...payload };
},
buildTodoDigestText() {
return '今天有 1 条待办。';
},
};
}
const enabledEnv = { H5_INTENT_TRANSACTION_ENABLED: '1', H5_DEFAULT_TIMEZONE: 'Asia/Shanghai' };
test('intent transaction returns null when feature disabled', async () => {
const reply = await handleWechatIntentTransaction({
intent: { agentText: '下午2点半提醒我开会', msgId: 'm1' },
user: { userId: 'user-1' },
intentDraftService: createDraftStore(),
scheduleService: createScheduleService(),
env: { H5_INTENT_TRANSACTION_ENABLED: '0' },
});
assert.equal(reply, null);
});
test('intent transaction creates action card for simple timed reminder', async () => {
const drafts = createDraftStore();
const reply = await handleWechatIntentTransaction({
intent: { agentText: '下午2点半提醒我开项目计划例会', msgId: 'm2' },
user: { userId: 'user-1' },
intentDraftService: drafts,
scheduleService: createScheduleService(),
env: enabledEnv,
});
assert.match(reply, /我准备执行/);
assert.match(reply, /确认/);
const pending = await drafts.getPendingDraft('user-1');
assert.equal(pending.draftType, 'timed_reminder');
});
test('intent transaction commits draft after user confirms', async () => {
const drafts = createDraftStore();
const scheduleService = createScheduleService();
const calls = [];
scheduleService.createItem = async (payload) => {
calls.push(['createItem', payload.title]);
return { id: 'item-1', ...payload };
};
scheduleService.createReminder = async (payload) => {
calls.push(['createReminder', payload.itemId]);
return { id: 'reminder-1', ...payload };
};
await handleWechatIntentTransaction({
intent: { agentText: '下午2点半提醒我开项目计划例会', msgId: 'm3' },
user: { userId: 'user-1' },
intentDraftService: drafts,
scheduleService,
env: enabledEnv,
});
const reply = await handleWechatIntentTransaction({
intent: { agentText: '确认', msgId: 'm4' },
user: { userId: 'user-1' },
intentDraftService: drafts,
scheduleService,
env: enabledEnv,
});
assert.match(reply, /已设置提醒/);
assert.equal(calls.length, 2);
});
test('intent transaction asks for slot fill without creating draft', async () => {
const drafts = createDraftStore();
const reply = await handleWechatIntentTransaction({
intent: { agentText: '设置提醒', msgId: 'm6' },
user: { userId: 'user-2' },
intentDraftService: drafts,
scheduleService: createScheduleService(),
env: enabledEnv,
});
assert.match(reply, /补充/);
assert.equal(await drafts.getPendingDraft('user-2'), null);
});
test('intent transaction answers query guard without creating draft', async () => {
const drafts = createDraftStore();
const reply = await handleWechatIntentTransaction({
intent: { agentText: '看看我的待办', msgId: 'm5' },
user: { userId: 'user-1' },
intentDraftService: drafts,
scheduleService: createScheduleService(),
env: enabledEnv,
});
assert.match(reply, /待办/);
assert.equal(await drafts.getPendingDraft('user-1'), null);
});
+35 -1
View File
@@ -1,4 +1,5 @@
import { isScheduleIntent, parseScheduleIntent } from '../../schedule-intent.mjs';
import { formatLocalTime, resolveScheduleTimestamp } from '../../schedule-time.mjs';
function normalizeHour(value) {
const hour = Number(value);
@@ -112,7 +113,7 @@ async function resolveScheduleIntent({
llmProviderService,
logger,
}) {
const ruleIntent = parseScheduleIntent(text);
const ruleIntent = parseScheduleIntent(text, { timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai' });
if (isScheduleIntent(ruleIntent)) return ruleIntent;
if (!wechatScheduleLlmConfigService || !llmProviderService) return ruleIntent;
@@ -169,6 +170,39 @@ export async function handleWechatScheduleIntent({
const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
if (scheduleIntent.action === 'create_timed_reminder') {
if (scheduleIntent.needsClarification?.includes('reminder_title')) {
return '可以。请告诉我要提醒什么,例如「下午 2 点半项目计划例会」。';
}
const remindAt = resolveScheduleTimestamp({
localString: scheduleIntent.remindLocal,
timezone,
fieldName: '提醒时间',
});
const item = await scheduleService.createItem({
userId: user.userId,
kind: 'event',
title: scheduleIntent.title,
startAt: remindAt,
timezone,
sourceChannel: 'wechat',
sourceMessageId: intent.msgId || null,
sourceText: intent.agentText,
metadata: {
source: 'wechat_mp',
},
});
await scheduleService.createReminder({
userId: user.userId,
itemId: item.id,
remindAt,
channel: 'wechat',
});
const timeLabel = formatLocalTime(remindAt, timezone);
const dateLabel = String(scheduleIntent.remindLocal ?? '').slice(0, 10);
return `已设置提醒:${scheduleIntent.title}${dateLabel} ${timeLabel}${timezone})。到点我会通过服务号提醒你。`;
}
if (scheduleIntent.action === 'create_todo') {
if (scheduleIntent.needsClarification?.includes('todo_title')) {
return '可以。你想让我记哪一条待办?例如“帮我记一下 跟段吃饭”。';
+31 -2
View File
@@ -7,6 +7,9 @@ function createScheduleService() {
async createItem(payload) {
return { id: 'item-1', ...payload };
},
async createReminder(payload) {
return { id: 'reminder-1', ...payload };
},
async createDailyTodoDigest(payload) {
return { id: 'digest-1', minute: payload.minute, hour: payload.hour, ...payload };
},
@@ -136,9 +139,35 @@ test('schedule handler falls back quietly when llm request throws', async () =>
assert.match(warnings[0], /fetch failed/);
});
test('schedule handler falls through for llm schedule_agent results', async () => {
test('schedule handler directly creates simple timed reminder', async () => {
const calls = [];
const reply = await handleWechatScheduleIntent({
intent: { agentText: '明天下午三点提醒我开会', msgId: 'msg-3' },
intent: {
agentText: '帮我设置提醒,下午 14:30 分开会,项目计划例会',
msgId: 'msg-reminder-1',
},
user: { userId: 'user-1' },
scheduleService: {
async createItem(payload) {
calls.push(['createItem', payload.title, payload.startAt]);
return { id: 'item-reminder-1', ...payload };
},
async createReminder(payload) {
calls.push(['createReminder', payload.itemId, payload.remindAt]);
return { id: 'reminder-1', ...payload };
},
},
});
assert.match(reply, /已设置提醒/);
assert.match(reply, /项目计划例会/);
assert.equal(calls.length, 2);
assert.equal(calls[0][0], 'createItem');
assert.equal(calls[1][0], 'createReminder');
});
test('schedule handler falls through for multi-time reminder requests', async () => {
const reply = await handleWechatScheduleIntent({
intent: { agentText: '明天早上六点去跑步,五点半提醒我', msgId: 'msg-3' },
user: { userId: 'user-1' },
scheduleService: createScheduleService(),
wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),