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
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env node
/**
* ITL / Unified Tasks 发布前配置检查(只读)
*/
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const env = process.env;
const checks = [
{
id: 'schedule_enabled',
label: 'H5_SCHEDULE_ENABLED=1',
ok: env.H5_SCHEDULE_ENABLED === '1',
required: true,
},
{
id: 'unified_tasks',
label: 'H5_UNIFIED_TASKS_ENABLED=1Phase B/C 双写 + 统一读)',
ok: env.H5_UNIFIED_TASKS_ENABLED === '1',
required: false,
},
{
id: 'intent_transaction',
label: 'H5_INTENT_TRANSACTION_ENABLED=1Phase A Confirm Gate',
ok: env.H5_INTENT_TRANSACTION_ENABLED === '1',
required: false,
},
{
id: 'database_url',
label: 'DATABASE_URL 已配置',
ok: Boolean(env.DATABASE_URL),
required: true,
},
{
id: 'scheduled_task_worker',
label: 'H5_SCHEDULED_TASK_WORKER_ENABLED 未显式关闭',
ok: env.H5_SCHEDULED_TASK_WORKER_ENABLED !== '0',
required: false,
},
];
let failed = 0;
console.log('=== ITL Rollout Config Check ===\n');
for (const check of checks) {
const mark = check.ok ? '✔' : (check.required ? '✘' : '○');
console.log(`${mark} ${check.label}`);
if (!check.ok && check.required) failed += 1;
}
console.log('\n推荐灰度顺序:');
console.log(' 1. 部署代码(ITL/Unified 开关关闭)');
console.log(' 2. H5_UNIFIED_TASKS_ENABLED=1');
console.log(' 3. node scripts/migrate-tang-unified-tasks-103.mjs --apply');
console.log(' 4. npm run verify:tang-itl-readiness-103');
console.log(' 5. H5_INTENT_TRANSACTION_ENABLED=1');
console.log(' 6. 微信实测 Action Card 流程');
process.exit(failed > 0 ? 1 : 0);
@@ -0,0 +1,80 @@
#!/usr/bin/env node
/**
* Cleanup Tang's duplicate scheduled tasks on 103.
* Dry-run by default; pass --apply to mutate rows.
*/
import process from 'node:process';
import mysql from 'mysql2/promise';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
const DUPLICATE_WEATHER_TASK_ID = '66f0ee11-7777-415f-affd-34422c772d0f';
const KEEP_WEATHER_TASK_ID = '23456db0-918e-4055-9ca3-e17e83b2dc24';
const fmt = (ms) => new Date(Number(ms)).toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
hour12: false,
});
async function main() {
const apply = process.argv.includes('--apply');
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const now = Date.now();
const plan = [];
const [dupRows] = await pool.query(
`SELECT id, title, status, hour, minute, last_run_at, created_at
FROM h5_scheduled_tasks
WHERE id = ? AND user_id = ?
LIMIT 1`,
[DUPLICATE_WEATHER_TASK_ID, TANG],
);
const dup = dupRows[0];
if (!dup) {
plan.push({ taskId: DUPLICATE_WEATHER_TASK_ID, action: 'skip', reason: 'not_found' });
} else if (dup.status === 'cancelled') {
plan.push({ taskId: DUPLICATE_WEATHER_TASK_ID, action: 'skip', reason: 'already_cancelled', title: dup.title });
} else {
plan.push({
taskId: DUPLICATE_WEATHER_TASK_ID,
title: dup.title,
status: dup.status,
schedule: `${dup.hour}:${String(dup.minute).padStart(2, '0')}`,
keepTaskId: KEEP_WEATHER_TASK_ID,
action: apply ? 'cancel_duplicate' : 'would_cancel_duplicate',
});
if (apply) {
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'cancelled', updated_at = ?, last_error = NULL
WHERE id = ? AND user_id = ?`,
[now, DUPLICATE_WEATHER_TASK_ID, TANG],
);
}
}
const [activeRows] = await pool.query(
`SELECT id, title, status, hour, minute, next_run_at
FROM h5_scheduled_tasks
WHERE user_id = ? AND status IN ('active', 'locked')
ORDER BY hour, minute, created_at`,
[TANG],
);
console.log(JSON.stringify({ apply, now: fmt(now), plan, remainingActive: activeRows.map((row) => ({
id: row.id,
title: row.title,
status: row.status,
schedule: `${row.hour}:${String(row.minute).padStart(2, '0')}`,
nextRunAt: fmt(row.next_run_at),
})) }, null, 2));
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,45 @@
#!/usr/bin/env node
import crypto from 'node:crypto';
import mysql from 'mysql2/promise';
const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
const dueSeconds = Number(process.argv[2] ?? 180);
const dueMs = Date.now() + dueSeconds * 1000;
const fmt = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const parts = Object.fromEntries(fmt.formatToParts(new Date(dueMs)).map((x) => [x.type, x.value]));
const runAtLocal = `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
const id = crypto.randomUUID();
const title = '【测试】微信定时推送链路验证';
const taskSpec = [
'这是唐用户微信定时任务推送测试。',
'请 load_skill static-page-publish,生成简洁测试页 public/scheduled-wechat-test-0817.html',
'标题「微信定时推送测试」,正文包含当前北京时间。',
'完成后在回复里给出正式可访问 URL,不要反问用户。',
].join('');
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const now = Date.now();
await pool.query(
`INSERT INTO h5_scheduled_tasks
(id, user_id, title, task_spec, recurrence, hour, minute, weekday, timezone,
next_run_at, notify_channel, status, source_channel, created_at, updated_at)
VALUES (?, ?, ?, ?, 'once', NULL, 0, NULL, 'Asia/Shanghai', ?, 'both', 'active', 'api', ?, ?)`,
[id, TANG, title, taskSpec, dueMs, now, now],
);
console.log(JSON.stringify({
id,
runAtLocal,
dueInSeconds: dueSeconds,
nextRunAtIso: new Date(dueMs).toISOString(),
title,
pagePath: 'public/scheduled-wechat-test-0817.html',
}, null, 2));
await pool.end();
@@ -0,0 +1,58 @@
#!/usr/bin/env node
/**
* Phase C:将 legacy 提醒/待办/自动化任务回填到 h5_tasks
*
* 用法:
* node scripts/migrate-legacy-tasks-to-h5-tasks.mjs --dry-run
* node scripts/migrate-legacy-tasks-to-h5-tasks.mjs --user-id=<uuid> --dry-run
* node scripts/migrate-legacy-tasks-to-h5-tasks.mjs --apply
*/
import fs from 'node:fs';
import mysql from 'mysql2/promise';
import { initSchema } from '../db.mjs';
import { createTaskUnifiedService } from '../task-unified-service.mjs';
function parseArgs(argv = process.argv.slice(2)) {
return {
dryRun: !argv.includes('--apply'),
userId: argv.find((arg) => arg.startsWith('--user-id='))?.split('=')[1] ?? null,
};
}
function loadDatabaseUrl() {
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
const envPath = new URL('../.env', import.meta.url);
if (!fs.existsSync(envPath)) return null;
const envText = fs.readFileSync(envPath, 'utf8');
return envText.match(/^DATABASE_URL=(.+)$/m)?.[1]?.trim().replace(/^["']|["']$/g, '') ?? null;
}
async function main() {
const options = parseArgs();
const databaseUrl = loadDatabaseUrl();
if (!databaseUrl) {
console.error('缺少 DATABASE_URL');
process.exit(1);
}
const pool = mysql.createPool(databaseUrl);
await initSchema(pool);
const service = createTaskUnifiedService(pool);
console.log(`=== migrate legacy tasks → h5_tasks (${options.dryRun ? 'dry-run' : 'apply'}) ===`);
if (options.userId) console.log(`user-id: ${options.userId}`);
const result = await service.migrateLegacyTasks({
userId: options.userId,
dryRun: options.dryRun,
});
console.log(JSON.stringify(result, null, 2));
await pool.end();
process.exit(result.errors > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack ?? err.message : err);
process.exit(1);
});
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* 唐用户(103legacy 任务 → h5_tasks 回填 + 对账
*
* 用法:
* node scripts/migrate-tang-unified-tasks-103.mjs
* node scripts/migrate-tang-unified-tasks-103.mjs --apply
*/
import process from 'node:process';
import mysql from 'mysql2/promise';
import { initSchema } from '../db.mjs';
import { createTaskUnifiedService, formatUnifiedTaskListReply } from '../task-unified-service.mjs';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
async function main() {
const apply = process.argv.includes('--apply');
if (!process.env.DATABASE_URL) {
console.error('缺少 DATABASE_URL');
process.exit(1);
}
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
await initSchema(pool);
const service = createTaskUnifiedService(pool);
const before = await service.listUserTasks({ userId: TANG, limit: 20 });
const migration = await service.migrateLegacyTasks({ userId: TANG, dryRun: !apply });
const after = apply
? await service.listUserTasks({ userId: TANG, limit: 20 })
: before;
const [storedCountRows] = await pool.query(
`SELECT COUNT(*) AS cnt FROM h5_tasks WHERE user_id = ? AND status = 'active'`,
[TANG],
);
console.log(JSON.stringify({
userId: TANG,
apply,
migration,
storedActiveCount: Number(storedCountRows?.[0]?.cnt ?? 0),
unifiedListPreview: formatUnifiedTaskListReply(after).split('\n').slice(0, 12),
tasks: after.map((task) => ({
type: task.type,
title: task.title,
legacyRef: task.legacyRef,
nextRunAt: task.nextRunAt,
})),
}, null, 2));
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,87 @@
#!/usr/bin/env node
/**
* Reactivate Tang's daily scheduled tasks stuck in terminal `failed` state.
* Dry-run by default; pass --apply to mutate rows.
*/
import process from 'node:process';
import mysql from 'mysql2/promise';
import { loadH5Environment } from './load-env.mjs';
import { nextDailyRunAt } from '../schedule-time.mjs';
loadH5Environment(import.meta.dirname);
const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
const DAILY_TASK_IDS = [
'21799936-0532-420c-b144-65ea3846cde1', // 5:30 news
'23456db0-918e-4055-9ca3-e17e83b2dc24', // 8:00 weather
];
const fmt = (ms) => new Date(Number(ms)).toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
hour12: false,
});
async function main() {
const apply = process.argv.includes('--apply');
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const now = Date.now();
const plan = [];
for (const taskId of DAILY_TASK_IDS) {
const [rows] = await pool.query(
`SELECT id, title, status, recurrence, hour, minute, weekday, timezone,
next_run_at, last_error, attempts
FROM h5_scheduled_tasks
WHERE id = ? AND user_id = ?
LIMIT 1`,
[taskId, TANG],
);
const row = rows[0];
if (!row) {
plan.push({ taskId, action: 'skip', reason: 'not_found' });
continue;
}
if (row.recurrence !== 'daily') {
plan.push({ taskId, action: 'skip', reason: 'not_daily', title: row.title });
continue;
}
const nextRunAt = nextDailyRunAt({
hour: row.hour,
minute: row.minute,
timezone: row.timezone || 'Asia/Shanghai',
now,
});
plan.push({
taskId,
title: row.title,
fromStatus: row.status,
fromNextRunAt: fmt(row.next_run_at),
toStatus: 'active',
toNextRunAt: fmt(nextRunAt),
lastError: row.last_error,
attempts: row.attempts,
action: apply ? 'reactivated' : 'would_reactivate',
});
if (apply) {
await pool.query(
`UPDATE h5_scheduled_tasks
SET status = 'active',
next_run_at = ?,
locked_until = NULL,
last_error = NULL,
attempts = 0,
updated_at = ?
WHERE id = ? AND user_id = ?`,
[nextRunAt, now, taskId, TANG],
);
}
}
console.log(JSON.stringify({ apply, now: fmt(now), plan }, null, 2));
await pool.end();
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
@@ -0,0 +1,595 @@
#!/usr/bin/env node
/**
* 千人千面 × ITL 全链路可行性模拟
*
* User → Classifier → Query Guard → Draft → Action Level → Confirm Gate → Commit (sim) → Worker
*
* 用法:
* node scripts/simulate-intent-transaction-layer.mjs
* node scripts/simulate-intent-transaction-layer.mjs --json
* node scripts/simulate-intent-transaction-layer.mjs --persona wx_tang
*/
import { classifyUserIntent } from '../intent-classifier.mjs';
import { detectQueryGuard } from '../intent-query-guard.mjs';
export { detectQueryGuard };
const TZ = 'Asia/Shanghai';
const NOW = Date.UTC(2026, 7, 24, 4, 0, 0); // 2026-08-24 12:00 CST
/** @typedef {'L0'|'L1'|'L2'|'L3'|'ambiguous'} LayerCode */
const PERSONAS = [
{
id: 'wx_tang',
name: '唐(服务号老用户)',
channel: 'wechat',
traits: ['定时新闻', '定时天气', '会议提醒', '口语简短'],
},
{
id: 'office_pm',
name: '李经理(项目经理)',
channel: 'wechat',
traits: ['例会', '周报', '多时间点', '正式'],
},
{
id: 'parent_chen',
name: '陈妈妈(家长)',
channel: 'wechat',
traits: ['接孩子', '吃药', '语音误识别'],
},
{
id: 'student_zhao',
name: '小赵(大学生)',
channel: 'h5',
traits: ['待办', '作业', '相对时间', '随意'],
},
{
id: 'sales_wang',
name: '王销售',
channel: 'wechat',
traits: ['日报', '客户跟进', '高外发风险'],
},
{
id: 'dev_liu',
name: '刘工程师',
channel: 'h5',
traits: ['自动化', '取消任务', '查列表'],
},
{
id: 'retiree_zhang',
name: '张阿姨(退休)',
channel: 'wechat',
traits: ['吃药', '中文数字时间', '长句'],
},
{
id: 'freelance_sun',
name: '孙自由职业',
channel: 'wechat',
traits: ['页面生成', '定时交付'],
},
{
id: 'asr_noisy',
name: '语音嘈杂用户',
channel: 'wechat',
traits: ['代办/带办/代拜', '嗯那个', 'ASR'],
},
{
id: 'query_only',
name: '只问不建用户',
channel: 'wechat',
traits: ['查一下', '有没有', '是否'],
},
{
id: 'finance_he',
name: '何财务',
channel: 'wechat',
traits: ['余额预警', '还款提醒'],
},
{
id: 'hr_lin',
name: '林HR',
channel: 'h5',
traits: ['面试提醒', '招聘监控'],
},
{
id: 'creator_zhou',
name: '周内容创作者',
channel: 'wechat',
traits: ['每日摘要页', '诗词页面'],
},
{
id: 'executive_wu',
name: '吴总(高管)',
channel: 'wechat',
traits: ['短命令', '高风险外发'],
},
{
id: 'intern_guo',
name: '郭实习',
channel: 'h5',
traits: ['记待办', '不确定时间'],
},
{
id: 'dual_time_runner',
name: '跑步爱好者',
channel: 'wechat',
traits: ['事件+偏移提醒', '双时间点'],
},
{
id: 'ambiguous_speaker',
name: '歧义表达者',
channel: 'wechat',
traits: ['提醒+生成混合', '看看+每天'],
},
{
id: 'english_mix',
name: '中英混杂用户',
channel: 'h5',
traits: ['Standup', 'daily', 'reminder'],
},
{
id: 'minimal_talker',
name: '极简用户',
channel: 'wechat',
traits: ['两字三词', '缺槽位'],
},
{
id: 'power_cancel',
name: '任务管理者',
channel: 'wechat',
traits: ['取消', '列表', '修改'],
},
];
function classifyIntent(text) {
const result = classifyUserIntent(text, { now: NOW, timezone: TZ });
return {
layer: result.layer,
source: result.kind ?? 'none',
action: result.action,
detail: result.detail,
clarify: result.clarify ?? [],
subkind: result.kind,
recurring: result.kind === 'agent_schedule' && /(?:每天|每日)/u.test(text),
};
}
function inferActionLevel(layer, draft) {
if (layer === 'L0') return 0;
if (draft.ambiguous) return 2;
if (layer === 'L3') return 2;
if (layer === 'L2') return draft.trigger?.repeat ? 2 : 2;
if (layer === 'L1') {
if (draft.subkind === 'create_balance_alert') return 2;
if (draft.trigger?.repeat) return 2;
if (draft.riskTags?.includes('external_send')) return 3;
return 1;
}
return 1;
}
function buildDraft(text, persona, classification) {
const { layer, detail, action, clarify = [], subkind } = classification;
const ambiguous = layer === 'ambiguous';
if (layer === 'L0') {
return {
personaId: persona.id,
layer,
status: 'answer_only',
actionLevel: 0,
commitAllowed: false,
confirmRequired: false,
cardType: 'none',
};
}
if (layer === null) {
return {
personaId: persona.id,
layer: null,
status: 'general_agent',
actionLevel: 0,
commitAllowed: false,
confirmRequired: false,
cardType: 'none',
};
}
if (ambiguous || clarify.length > 0) {
return {
personaId: persona.id,
layer: ambiguous ? 'ambiguous' : layer,
status: 'draft',
actionLevel: ambiguous ? 2 : inferActionLevel(layer, { subkind }),
commitAllowed: false,
confirmRequired: true,
cardType: ambiguous ? 'clarify' : 'slot_fill',
clarify: ambiguous ? ['notify_vs_act'] : clarify,
title: detail?.title ?? detail?.taskSpec?.slice?.(0, 40) ?? null,
trigger: detail?.remindLocal ? { at: detail.remindLocal } : { hour: detail?.hour, minute: detail?.minute },
};
}
const repeat = /(?:每天|每日|每周)/u.test(text) ? 'daily_or_weekly' : 'once';
const riskTags = [];
if (/(?:客户|报价|发送给|群发|邮件)/u.test(text)) riskTags.push('external_send');
const draft = {
personaId: persona.id,
layer,
status: 'draft',
action,
subkind,
title: detail?.title ?? detail?.taskSpec?.slice?.(0, 60) ?? '待确认任务',
trigger: {
repeat,
at: detail?.remindLocal ?? detail?.runAtLocal ?? null,
hour: detail?.hour ?? null,
minute: detail?.minute ?? null,
},
actionPayload: {
kind: layer === 'L2' ? 'agent_run' : layer === 'L1' && subkind === 'create_balance_alert' ? 'condition_notify' : 'notify',
spec: detail?.taskSpec ?? null,
},
ambiguous,
riskTags,
};
draft.actionLevel = inferActionLevel(layer, draft);
draft.confirmRequired = draft.actionLevel >= 1;
draft.commitAllowed = false; // ITL: 永远不允许 silent commit
draft.cardType = draft.actionLevel >= 2 ? 'action_card_full' : 'action_card_simple';
draft.worker = layer === 'L2' ? 'scheduled_task_worker' : layer === 'L1' ? 'reminder_worker' : 'goose_agent';
return draft;
}
function simulateConfirmFlow(draft, userReply) {
if (!draft.confirmRequired) {
return { phase: 'skip_confirm', finalStatus: draft.status };
}
if (userReply === 'cancel') return { phase: 'cancelled', finalStatus: 'cancelled' };
if (userReply === 'modify') return { phase: 'redraft', finalStatus: 'draft' };
if (userReply === 'confirm') {
if (draft.clarify?.length || draft.ambiguous) {
return { phase: 'blocked', finalStatus: 'draft', reason: 'clarify_pending' };
}
return { phase: 'committed', finalStatus: 'confirmed' };
}
return { phase: 'awaiting_confirm', finalStatus: 'draft' };
}
function buildPersonaCases(persona) {
const cases = [];
const push = (text, expect) => cases.push({ persona, text, expect });
switch (persona.id) {
case 'wx_tang':
push('帮我设置提醒,下午14:30分开会,项目计划例会', { layer: 'L1', itl: 'draft_confirm' });
push('每天5点30帮我做今日新闻页面', { layer: 'L2', itl: 'draft_confirm' });
push('每天8点生成天气预报页面', { layer: 'L2', itl: 'draft_confirm' });
push('查一下是否有执行的新闻任务', { layer: 'L0', itl: 'answer_only' });
push('取消我的定时任务', { layer: 'L2', itl: 'manage' });
break;
case 'office_pm':
push('明天下午三点提醒我开项目计划例会', { layer: 'L1', itl: 'draft_confirm' });
push('每周一早上9点Standup提醒我', { layer: 'L3', itl: 'draft_confirm' });
push('帮我记一下周五前交Q3复盘', { layer: 'L1', itl: 'draft_confirm' });
push('看看我的待办', { layer: 'L0', itl: 'answer_only' });
break;
case 'parent_chen':
push('下午4点提醒我接孩子', { layer: 'L1', itl: 'draft_confirm' });
push('帮我设置一个代拜明天早上八点吃药', { layer: 'L3', itl: 'draft_confirm' });
push('今晚8点设个提醒检查作业', { layer: 'L1', itl: 'draft_confirm' });
break;
case 'student_zhao':
push('记个待办后天交物理实验报告', { layer: 'L1', itl: 'draft_confirm' });
push('3小时后提醒我交作业', { layer: 'L3', itl: 'agent_fill' });
push('设置提醒', { layer: 'L1', itl: 'slot_fill' });
break;
case 'sales_wang':
push('每天早8点帮我整理销售日报页面', { layer: 'L2', itl: 'draft_confirm' });
push('每天自动给客户发送报价单', { layer: 'L2', itl: 'draft_confirm_l3' });
push('提醒我下午回访重点客户', { layer: 'L1', itl: 'draft_confirm' });
break;
case 'dev_liu':
push('列出我的定时任务', { layer: 'L2', itl: 'answer_only' });
push('取消每日新闻任务', { layer: 'L2', itl: 'manage' });
push('每天6点帮我做今日新闻页面', { layer: 'L2', itl: 'draft_confirm' });
break;
case 'retiree_zhang':
push('明天早上八点提醒我吃药', { layer: 'L1', itl: 'draft_confirm' });
push('帮我设置提醒,下午两点半,社区活动', { layer: 'L1', itl: 'draft_confirm' });
break;
case 'freelance_sun':
push('每周五下午6点生成周报页面', { layer: 'L2', itl: 'draft_confirm' });
push('今晚9点提醒我交付稿件', { layer: 'L1', itl: 'draft_confirm' });
break;
case 'asr_noisy':
push('嗯那个明天早上六点去跑步五点半提醒我', { layer: 'L3', itl: 'agent_fill' });
push('帮我设置一个带办还书', { layer: 'L1', itl: 'draft_confirm' });
push('麻烦帮我设置提醒下午三点开会', { layer: 'L1', itl: 'draft_confirm' });
break;
case 'query_only':
push('有没有我的新闻定时任务', { layer: 'L0', itl: 'answer_only' });
push('是否设置了早上7点的待办推送', { layer: 'L0', itl: 'answer_only' });
push('查一下我有哪些提醒', { layer: 'L0', itl: 'answer_only' });
break;
case 'finance_he':
push('余额低于100元提醒我', { layer: 'L1', itl: 'draft_confirm' });
push('每月1号提醒我还信用卡', { layer: 'L3', itl: 'agent_fill' });
break;
case 'hr_lin':
push('明天10点提醒我面试候选人张三', { layer: 'L1', itl: 'draft_confirm' });
push('帮我每天看看有没有新的招聘信息', { layer: 'L2', itl: 'draft_confirm' });
break;
case 'creator_zhou':
push('每天7点整理待办摘要页面', { layer: 'L2', itl: 'draft_confirm' });
push('每天6点生成诗词页面', { layer: 'L2', itl: 'draft_confirm' });
break;
case 'executive_wu':
push('下午3点提醒我', { layer: 'L1', itl: 'slot_fill' });
push('每天9点自动发邮件给董事会摘要', { layer: 'L2', itl: 'draft_confirm_l3' });
break;
case 'intern_guo':
push('帮我记一下', { layer: 'L1', itl: 'slot_fill' });
push('先记一下整理会议纪要', { layer: 'L1', itl: 'draft_confirm' });
break;
case 'dual_time_runner':
push('明天早上六点去跑步,五点半提醒我', { layer: 'L3', itl: 'agent_fill' });
push('会议是3点,提前10分钟提醒我', { layer: 'L3', itl: 'agent_fill' });
break;
case 'ambiguous_speaker':
push('明天提醒我自动生成日报', { layer: 'ambiguous', itl: 'clarify' });
push('每天8点提醒我跑步', { layer: 'L3', itl: 'agent_fill' });
push('每天8点帮我生成跑步报告', { layer: 'L2', itl: 'draft_confirm' });
break;
case 'english_mix':
push('明天9am remind me standup', { layer: 'L3', itl: 'agent_fill' });
push('daily 8am todo digest', { layer: null, itl: 'general' });
break;
case 'minimal_talker':
push('设置提醒', { layer: 'L1', itl: 'slot_fill' });
push('定时', { layer: null, itl: 'general' });
push('下午三点', { layer: null, itl: 'general' });
break;
case 'power_cancel':
push('取消定时任务 天气', { layer: 'L2', itl: 'manage' });
push('看看我的定时自动任务', { layer: 'L2', itl: 'answer_only' });
break;
default:
break;
}
// 每个 persona 再扩变体 — 按 topic 区分 L1 notify vs L2 act
const notifyTopics = ['开会', '吃药', '交周报', '接孩子', '复盘'];
const actTopics = ['做新闻页面', '生成摘要页面', '整理日报页面'];
const times = ['7点', '8点半', '下午2点', '晚上9点'];
for (let i = 0; i < 4; i += 1) {
const t = times[i % times.length];
const notifyText = `每天${t}提醒我${notifyTopics[i % notifyTopics.length]}`;
push(notifyText, { layer: 'L1', itl: 'draft_confirm_or_agent' });
const actText = `每天${t}帮我${actTopics[i % actTopics.length]}`;
push(actText, { layer: 'L2', itl: 'draft_confirm' });
push(`帮我设置提醒,${t}${notifyTopics[i % notifyTopics.length]}`, { layer: 'L1', itl: 'draft_confirm' });
}
return cases;
}
function layerMatches(expected, actual, draft, classification) {
if (expected === 'ambiguous') return draft.ambiguous || draft.layer === 'ambiguous';
if (expected === 'L0') return actual === 'L0' || draft.status === 'answer_only';
if (expected === 'L3') {
return actual === 'L3'
|| classification?.recurring
|| (actual === 'L1' && /agent|recurring|offset|relative/i.test(classification?.action ?? ''));
}
if (expected === 'L2') return actual === 'L2';
if (expected === 'L1') {
return actual === 'L1' || actual === 'L3';
}
return actual === expected;
}
function evaluateItl(expect, draft, confirmSim, classification) {
switch (expect.itl) {
case 'answer_only':
return draft.actionLevel === 0 && draft.status === 'answer_only';
case 'slot_fill':
return draft.confirmRequired && !confirmSim.finalStatus?.includes('confirmed');
case 'clarify':
return draft.cardType === 'clarify' || draft.ambiguous;
case 'draft_confirm':
if (classification?.clarify?.includes('schedule') && draft.cardType === 'slot_fill') return true;
return draft.confirmRequired && draft.commitAllowed === false && confirmSim.phase === 'committed';
case 'draft_confirm_or_agent':
if (classification?.clarify?.includes('schedule') && draft.cardType === 'slot_fill') return true;
return draft.confirmRequired && draft.commitAllowed === false
&& (confirmSim.phase === 'committed' || classification?.layer === 'L3');
case 'draft_confirm_l3':
if (classification?.clarify?.includes('schedule') && draft.cardType === 'slot_fill') return true;
return draft.actionLevel >= 2 && confirmSim.phase === 'committed';
case 'agent_fill':
return draft.layer === 'L3' || draft.status === 'general_agent' || draft.confirmRequired;
case 'manage':
return ['cancel_scheduled_task', 'list_scheduled_tasks'].includes(classification?.action)
|| draft.actionLevel === 0;
case 'general':
return draft.status === 'general_agent' || draft.layer === null;
default:
return draft.confirmRequired === false || confirmSim.phase === 'committed';
}
}
function expandCorpus(baseCases) {
if (baseCases.length >= 1000) return baseCases.slice(0, 1000);
const out = [...baseCases];
const prefixes = ['嗯', '那个', '麻烦', '请', '能不能', '帮我', '嗨'];
let round = 0;
while (out.length < 1000 && round < 20) {
round += 1;
let added = 0;
for (const item of baseCases) {
const prefix = prefixes[out.length % prefixes.length];
const variant = `${prefix}${item.text}`;
if (out.some((x) => x.text === variant && x.persona.id === item.persona.id)) continue;
out.push({ ...item, text: variant, variant: true });
added += 1;
if (out.length >= 1000) break;
}
if (added === 0) break;
}
return out.slice(0, 1000);
}
function runSimulation({ personaFilter = null } = {}) {
const personas = personaFilter
? PERSONAS.filter((p) => p.id === personaFilter)
: PERSONAS;
let allCases = [];
for (const persona of personas) {
allCases = allCases.concat(buildPersonaCases(persona));
}
if (!personaFilter) {
allCases = expandCorpus(allCases);
}
const results = [];
const stats = {
total: 0,
layerMatch: 0,
itlFeasible: 0,
silentCommitBlocked: 0,
queryGuardOk: 0,
queryGuardTotal: 0,
ambiguousClarify: 0,
ambiguousTotal: 0,
byPersona: {},
byLayer: { L0: 0, L1: 0, L2: 0, L3: 0, ambiguous: 0, null: 0 },
failures: [],
};
for (const persona of personas) {
stats.byPersona[persona.id] = { total: 0, ok: 0, fail: 0 };
}
for (const { persona, text, expect } of allCases) {
stats.total += 1;
stats.byPersona[persona.id].total += 1;
const classification = classifyIntent(text);
const draft = buildDraft(text, persona, classification);
const confirmSim = simulateConfirmFlow(draft, 'confirm');
const layerOk = layerMatches(expect.layer, classification.layer, draft, classification);
const itlOk = evaluateItl(expect, draft, confirmSim, classification);
const noSilentCommit = draft.actionLevel === 0 || draft.commitAllowed === false || confirmSim.phase === 'committed';
if (layerOk) stats.layerMatch += 1;
if (itlOk && noSilentCommit) {
stats.itlFeasible += 1;
stats.byPersona[persona.id].ok += 1;
} else {
stats.byPersona[persona.id].fail += 1;
stats.failures.push({
persona: persona.id,
text,
expect,
classification,
draft: {
layer: draft.layer,
actionLevel: draft.actionLevel,
cardType: draft.cardType,
confirmRequired: draft.confirmRequired,
ambiguous: draft.ambiguous,
},
confirmSim,
layerOk,
itlOk,
noSilentCommit,
});
}
if (noSilentCommit && draft.actionLevel > 0) stats.silentCommitBlocked += 1;
if (expect.itl === 'answer_only') {
stats.queryGuardTotal += 1;
if (draft.status === 'answer_only') stats.queryGuardOk += 1;
}
if (expect.itl === 'clarify' || expect.layer === 'ambiguous') {
stats.ambiguousTotal += 1;
if (draft.ambiguous || draft.cardType === 'clarify') stats.ambiguousClarify += 1;
}
const layerKey = draft.layer ?? classification.layer ?? 'null';
stats.byLayer[layerKey] = (stats.byLayer[layerKey] ?? 0) + 1;
results.push({ persona: persona.id, text, expect, draft, confirmSim, layerOk, itlOk });
}
return {
stats: {
...stats,
layerAccuracyPct: Number(((stats.layerMatch / stats.total) * 100).toFixed(1)),
itlFeasibilityPct: Number(((stats.itlFeasible / stats.total) * 100).toFixed(1)),
queryGuardPct: stats.queryGuardTotal
? Number(((stats.queryGuardOk / stats.queryGuardTotal) * 100).toFixed(1))
: null,
ambiguousClarifyPct: stats.ambiguousTotal
? Number(((stats.ambiguousClarify / stats.ambiguousTotal) * 100).toFixed(1))
: null,
},
sampleFailures: stats.failures.slice(0, 20),
personaCount: personas.length,
results,
};
}
function main() {
const jsonOut = process.argv.includes('--json');
const personaArg = process.argv.find((a) => a.startsWith('--persona='))?.split('=')[1]
?? (process.argv.includes('--persona') ? process.argv[process.argv.indexOf('--persona') + 1] : null);
const report = runSimulation({ personaFilter: personaArg });
if (jsonOut) {
console.log(JSON.stringify({
stats: report.stats,
personaCount: report.personaCount,
sampleFailures: report.sampleFailures,
}, null, 2));
process.exit(report.stats.itlFeasibilityPct >= 85 ? 0 : 1);
}
console.log('=== 千人千面 × ITL 全链路可行性模拟 ===\n');
console.log(`Personas: ${report.personaCount} 用例: ${report.stats.total}`);
console.log(`Layer 准确率: ${report.stats.layerAccuracyPct}%`);
console.log(`ITL 链路可行率: ${report.stats.itlFeasibilityPct}%`);
console.log(`Query Guard 命中率: ${report.stats.queryGuardPct ?? 'N/A'}%`);
console.log(`歧义 Clarify 率: ${report.stats.ambiguousClarifyPct ?? 'N/A'}%`);
console.log('\n按 Persona:');
for (const [id, row] of Object.entries(report.stats.byPersona)) {
const pct = row.total ? ((row.ok / row.total) * 100).toFixed(0) : '0';
console.log(` ${id}: ${row.ok}/${row.total} (${pct}%)`);
}
console.log('\nLayer 分布:', report.stats.byLayer);
if (report.sampleFailures.length) {
console.log('\n典型失败样本(前 10:');
for (const f of report.sampleFailures.slice(0, 10)) {
console.log(` [${f.persona}] ${f.text}`);
console.log(` expect=${JSON.stringify(f.expect)} got layer=${f.draft.layer} card=${f.draft.cardType}`);
}
}
console.log('\n结论:', report.stats.itlFeasibilityPct >= 90
? 'ITL 链路在千人千面场景下可行,可进入 Phase A 实现'
: report.stats.itlFeasibilityPct >= 80
? '大体可行,需先补 Query Guard / 歧义 Clarify / L3 填槽'
: '需继续优化路由与 ITL 规则后再实现');
process.exit(report.stats.itlFeasibilityPct >= 85 ? 0 : 1);
}
main();
@@ -0,0 +1,333 @@
#!/usr/bin/env node
/**
* Simulate ~1000 user utterances across reminder / schedule / automation tiers.
* Reports routing accuracy, ambiguity, and agent fallthrough rate.
*/
import {
parseScheduleIntent,
shouldUseScheduleAssistant,
} from '../schedule-intent.mjs';
import {
isScheduledTaskIntent,
parseScheduledTaskIntent,
shouldUseScheduledTaskAutomation,
} from '../scheduled-task-intent.mjs';
const TZ = 'Asia/Shanghai';
const NOW = Date.UTC(2026, 7, 24, 4, 0, 0); // 2026-08-24 12:00 CST
const TIME_VARIANTS = [
'早上6点', '上午9点', '中午12点', '下午2点', '下午14:30', '1430', '晚上8点', '今晚9点半',
'明天早上7点', '明天下午3点', '后天上午10点', '今天15点', '明早6点半', '下午两点半',
];
const REMINDER_VERBS = ['提醒我', '设置提醒', '设个提醒', '闹钟', '叫我', '到点提醒'];
const REMINDER_TOPICS = [
'开会', '项目计划例会', '吃药', '接孩子', '交周报', '还信用卡', 'Standup',
'给老板发邮件', '健身', '订外卖', '复盘', '周会', '1对1',
];
const TODO_VERBS = ['帮我记一下', '记个待办', '添加待办', '设置一个代办', '先记一下'];
const TODO_TOPICS = ['跟进合同', '买牛奶', '回复张三', '整理发票', '还书', '修空调'];
const AUTO_TOPICS = [
'做今日新闻页面', '生成天气预报页面', '整理待办摘要页面', '推送销售日报',
'汇总行业资讯', '更新数据看板页面', '生成诗词页面',
];
const AUTO_RECURRENCE = ['每天', '每日', '每周一', '每周五', '定时'];
function cartesian(parts) {
return parts.reduce(
(acc, group) => acc.flatMap((prefix) => group.map((item) => [...prefix, item])),
[[]],
);
}
function buildCorpus() {
const buckets = {
reminder: [],
schedule: [],
automation: [],
edge: [],
};
for (const time of TIME_VARIANTS) {
for (const verb of REMINDER_VERBS) {
for (const topic of REMINDER_TOPICS.slice(0, 6)) {
buckets.reminder.push({
text: `${time}${verb}${topic}`,
expectedTier: 'reminder',
expectedPath: 'preflight_or_agent',
});
buckets.reminder.push({
text: `帮我设置提醒,${time}${topic}`,
expectedTier: 'reminder',
expectedPath: 'preflight_or_agent',
});
}
}
}
for (const verb of TODO_VERBS) {
for (const topic of TODO_TOPICS) {
buckets.schedule.push({
text: `${verb} ${topic}`,
expectedTier: 'schedule',
expectedPath: 'preflight',
});
buckets.schedule.push({
text: `${verb}${topic}`,
expectedTier: 'schedule',
expectedPath: 'preflight',
});
}
}
for (const time of ['早上7点', '每天8点', '每日6点半', '每天早上7点', '每天7点']) {
buckets.schedule.push({
text: `${time}把当天待办发给我`,
expectedTier: 'schedule',
expectedPath: 'preflight',
});
}
for (const threshold of [10, 20, 50, 100, 200]) {
buckets.schedule.push({
text: `余额低于${threshold}元提醒我`,
expectedTier: 'schedule',
expectedPath: 'preflight',
});
}
for (const rec of AUTO_RECURRENCE) {
for (const time of ['5点', '6点', '7点半', '8:00', '18点', '8点30分']) {
for (const topic of AUTO_TOPICS) {
buckets.automation.push({
text: `${rec}${time}帮我${topic}`,
expectedTier: 'automation',
expectedPath: 'preflight_or_agent',
});
}
}
}
buckets.edge.push(
{ text: '明天早上六点去跑步,五点半提醒我', expectedTier: 'reminder', expectedPath: 'agent', note: '双时间点' },
{ text: '每天6点帮我做今日新闻页面', expectedTier: 'automation', expectedPath: 'preflight_or_agent' },
{ text: '每天6点提醒我开会', expectedTier: 'reminder', expectedPath: 'agent', note: '循环提醒' },
{ text: '帮我生成一个唐诗页面', expectedTier: 'none', expectedPath: 'general' },
{ text: '取消我的定时任务', expectedTier: 'automation', expectedPath: 'preflight_or_agent' },
{ text: '看看我的待办', expectedTier: 'schedule', expectedPath: 'preflight' },
{ text: '设置提醒', expectedTier: 'reminder', expectedPath: 'clarify', note: '缺时间' },
{ text: '下午3点提醒我', expectedTier: 'reminder', expectedPath: 'clarify', note: '缺标题' },
{ text: '不是待办,下午2点提醒我交报告', expectedTier: 'reminder', expectedPath: 'preflight_or_agent' },
{ text: '帮我设置一个代办明天早上六点去跑步记得在五点半的时候提醒我', expectedTier: 'reminder', expectedPath: 'agent' },
{ text: '每周一7点整理待办摘要', expectedTier: 'automation', expectedPath: 'preflight_or_agent' },
{ text: '今晚8点推送今日待办清单', expectedTier: 'schedule', expectedPath: 'agent_or_clarify', note: '待办清单' },
{ text: '定时任务:每天8点生成天气页面', expectedTier: 'automation', expectedPath: 'preflight_or_agent' },
{ text: '3小时后提醒我开会', expectedTier: 'reminder', expectedPath: 'agent', note: '相对时间' },
{ text: '半小时后叫我', expectedTier: 'reminder', expectedPath: 'agent', note: '相对时间' },
{ text: '周五下午3点项目评审提醒我', expectedTier: 'reminder', expectedPath: 'agent', note: '周几' },
{ text: '8月30号下午2点提醒我续费', expectedTier: 'reminder', expectedPath: 'agent', note: '具体日期' },
{ text: '提前15分钟提醒我开会', expectedTier: 'reminder', expectedPath: 'agent', note: '偏移提醒' },
{ text: '会议是3点,提前10分钟提醒我', expectedTier: 'reminder', expectedPath: 'agent', note: '事件+偏移' },
{ text: '查一下是否有执行的新闻任务', expectedTier: 'none', expectedPath: 'general', note: '误触' },
{ text: '设个提醒下午3点开会', expectedTier: 'reminder', expectedPath: 'preflight_or_agent' },
{ text: '到点提醒下午2点交报告', expectedTier: 'reminder', expectedPath: 'preflight_or_agent' },
{ text: '帮我设置提醒,下午 14:30 分开会,项目计划例会', expectedTier: 'reminder', expectedPath: 'preflight' },
// --- Adversarial / ambiguous (expect clarify or correct layer, never wrong commit) ---
{ text: '明天提醒我自动生成日报', expectedTier: 'ambiguous', expectedPath: 'clarify', note: 'adv:提醒+生成' },
{ text: '帮我每天看看有没有新的招聘信息', expectedTier: 'automation', expectedPath: 'preflight_or_agent', note: 'adv:监控型自动化' },
{ text: '设置一个任务,如果余额低于100提醒我', expectedTier: 'schedule', expectedPath: 'preflight_or_agent', note: 'adv:condition trigger' },
{ text: '有没有我的新闻定时任务', expectedTier: 'none', expectedPath: 'general', note: 'adv:query guard' },
{ text: '每天8点提醒我跑步', expectedTier: 'reminder', expectedPath: 'agent', note: 'adv:recurring reminder' },
{ text: '每天8点帮我生成跑步报告', expectedTier: 'automation', expectedPath: 'preflight_or_agent', note: 'adv:notify vs act' },
);
const noisePrefixes = ['那个', '嗯', '麻烦', '请', '能不能'];
for (const prefix of noisePrefixes) {
for (const base of buckets.reminder.slice(0, 30)) {
buckets.edge.push({
text: `${prefix}${base.text}`,
expectedTier: 'reminder',
expectedPath: 'preflight_or_agent',
});
}
}
function sample(arr, n) {
if (arr.length <= n) return arr;
const step = arr.length / n;
const out = [];
for (let i = 0; i < n; i += 1) out.push(arr[Math.floor(i * step)]);
return out;
}
const target = {
reminder: 400,
schedule: 250,
automation: 280,
edge: 70,
};
const cases = [];
for (const [bucket, count] of Object.entries(target)) {
for (const [index, item] of sample(buckets[bucket], count).entries()) {
cases.push({ id: `${bucket}-${index}`, ...item });
}
}
return cases;
}
function classifyRoute(text) {
const schedTask = parseScheduledTaskIntent(text, { now: NOW, timezone: TZ });
const sched = parseScheduleIntent(text, { timezone: TZ, now: NOW });
if (isScheduledTaskIntent(schedTask)) {
if (schedTask.action === 'create_scheduled_task') {
return {
tier: 'automation',
path: schedTask.needsClarification?.length ? 'clarify' : 'preflight',
action: schedTask.action,
detail: schedTask,
};
}
return {
tier: 'automation',
path: 'preflight',
action: schedTask.action,
detail: schedTask,
};
}
if (sched.action === 'create_timed_reminder') {
return {
tier: 'reminder',
path: sched.needsClarification?.length ? 'clarify' : 'preflight',
action: sched.action,
detail: sched,
};
}
if (['create_todo', 'create_daily_todo_digest', 'create_balance_alert', 'query_schedule'].includes(sched.action)) {
return {
tier: 'schedule',
path: sched.needsClarification?.length ? 'clarify' : 'preflight',
action: sched.action,
detail: sched,
};
}
if (sched.action === 'schedule_agent') {
return { tier: 'reminder', path: 'agent', action: sched.action, detail: sched };
}
if (shouldUseScheduledTaskAutomation(text)) {
return { tier: 'automation', path: 'agent', action: 'none', detail: schedTask };
}
if (shouldUseScheduleAssistant(text)) {
return { tier: 'reminder', path: 'agent', action: sched.action, detail: sched };
}
return { tier: 'none', path: 'general', action: 'none', detail: sched };
}
function tierMatches(expectedTier, actualTier, text) {
if (expectedTier === 'ambiguous') return actualTier === 'reminder' || actualTier === 'automation';
if (expectedTier === actualTier) return true;
if (expectedTier === 'none' && actualTier === 'none') return true;
return false;
}
function pathMatches(expectedPath, actualPath) {
if (expectedPath === actualPath) return true;
if (expectedPath === 'preflight_or_agent' && (actualPath === 'preflight' || actualPath === 'agent')) return true;
if (expectedPath === 'agent_or_clarify' && (actualPath === 'agent' || actualPath === 'clarify')) return true;
if (expectedPath === 'clarify' && (actualPath === 'clarify' || actualPath === 'agent')) return true;
if (expectedPath === 'general' && actualPath === 'general') return true;
return false;
}
function main() {
const corpus = buildCorpus();
const stats = {
total: corpus.length,
tierCorrect: 0,
pathOk: 0,
byTier: {},
byPath: {},
misroutes: [],
clarifyCases: [],
agentFallback: [],
conflicts: [],
};
for (const item of corpus) {
const route = classifyRoute(item.text);
stats.byTier[route.tier] = (stats.byTier[route.tier] ?? 0) + 1;
stats.byPath[route.path] = (stats.byPath[route.path] ?? 0) + 1;
const tierOk = tierMatches(item.expectedTier, route.tier, item.text);
const pathOk = pathMatches(item.expectedPath ?? 'preflight_or_agent', route.path);
if (tierOk) stats.tierCorrect += 1;
if (pathOk) stats.pathOk += 1;
if (!tierOk) {
stats.misroutes.push({
text: item.text,
expectedTier: item.expectedTier,
actualTier: route.tier,
path: route.path,
action: route.action,
note: item.note ?? null,
});
}
if (route.path === 'clarify') {
stats.clarifyCases.push({ text: item.text, action: route.action, detail: route.detail });
}
if (route.path === 'agent') {
stats.agentFallback.push({ text: item.text, expectedTier: item.expectedTier, action: route.action });
}
if (shouldUseScheduledTaskAutomation(item.text) && shouldUseScheduleAssistant(item.text)) {
stats.conflicts.push(item.text);
}
}
const tierAccuracy = ((stats.tierCorrect / stats.total) * 100).toFixed(1);
const pathAccuracy = ((stats.pathOk / stats.total) * 100).toFixed(1);
const preflightRate = (
((stats.byPath.preflight ?? 0) / stats.total) * 100
).toFixed(1);
const agentRate = (
((stats.byPath.agent ?? 0) / stats.total) * 100
).toFixed(1);
const clarifyRate = (
((stats.byPath.clarify ?? 0) / stats.total) * 100
).toFixed(1);
const misrouteByReason = {};
for (const row of stats.misroutes) {
const key = `${row.expectedTier}->${row.actualTier}`;
misrouteByReason[key] = (misrouteByReason[key] ?? 0) + 1;
}
console.log(JSON.stringify({
summary: {
total: stats.total,
tierAccuracyPct: Number(tierAccuracy),
pathAccuracyPct: Number(pathAccuracy),
preflightRatePct: Number(preflightRate),
agentFallbackRatePct: Number(agentRate),
clarifyRatePct: Number(clarifyRate),
skillConflicts: stats.conflicts.length,
},
routeDistribution: stats.byTier,
pathDistribution: stats.byPath,
misroutePatterns: misrouteByReason,
topMisroutes: stats.misroutes.slice(0, 25),
sampleAgentFallback: stats.agentFallback.slice(0, 15),
sampleClarify: stats.clarifyCases.slice(0, 10),
}, null, 2));
}
main();
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
/**
* 唐用户典型微信话术 ITL 全链路离线模拟(无需 DB)
*/
import { classifyUserIntent } from '../intent-classifier.mjs';
import { handleWechatIntentTransaction } from '../wechat/handlers/intent-transaction.mjs';
import { handleWechatScheduledTaskIntent } from '../wechat/handlers/scheduled-task.mjs';
import { formatUnifiedTaskListReply } from '../task-unified-service.mjs';
import { formatQueryGuardReply } from '../intent-query-guard.mjs';
const TANG = { userId: 'a70ff537-8908-486e-9b6c-042e07cc25db' };
const env = {
H5_INTENT_TRANSACTION_ENABLED: '1',
H5_UNIFIED_TASKS_ENABLED: '1',
H5_DEFAULT_TIMEZONE: 'Asia/Shanghai',
};
const MOCK_TASKS = [
{
type: 'automation',
title: '每日新闻页',
trigger: { repeat: 'daily', hour: 5, minute: 30 },
nextRunAt: Date.now() + 3600000,
},
{
type: 'automation',
title: '每日天气预报',
trigger: { repeat: 'daily', hour: 8, minute: 0 },
nextRunAt: Date.now() + 7200000,
},
];
function createDraftStore() {
let pending = null;
return {
async getPendingDraft(userId) {
return pending?.userId === userId ? pending : null;
},
async createDraft(payload) {
pending = { id: 'draft-sim', status: 'draft', ...payload };
return pending;
},
async cancelDraft() { pending = null; return { status: 'cancelled' }; },
async markDraftCommitted(_id, _userId, committedRef) {
pending = { ...pending, status: 'committed', committedRef };
return pending;
},
};
}
function createServices() {
const syncCalls = [];
const scheduleCalls = [];
return {
syncCalls,
scheduleCalls,
taskUnifiedService: {
async listUserTasks() { return MOCK_TASKS; },
async syncFromCommit(payload) { syncCalls.push(payload.kind); return { id: 'u-1' }; },
},
scheduleService: {
async createItem(payload) {
scheduleCalls.push(['createItem', payload.title]);
return { id: 'item-1', ...payload };
},
async createReminder(payload) {
scheduleCalls.push(['createReminder']);
return { id: 'rem-1', itemId: payload.itemId, remindAt: payload.remindAt, channel: 'wechat' };
},
buildTodoDigestText: async () => '今天有 1 条待办:跟进合同。',
},
scheduledTaskService: {
async listTasks() {
return MOCK_TASKS.map((task, index) => ({
id: `task-${index}`,
title: task.title,
recurrence: 'daily',
hour: task.trigger.hour,
minute: task.trigger.minute,
nextRunAt: task.nextRunAt,
timezone: 'Asia/Shanghai',
}));
},
async cancelTask() {
return { id: 'task-news', title: '每日新闻页', status: 'cancelled' };
},
},
};
}
async function simulate(text) {
const classification = classifyUserIntent(text);
const services = createServices();
const drafts = createDraftStore();
let handler = 'none';
let reply = null;
const itl = await handleWechatIntentTransaction({
intent: { agentText: text, msgId: `sim-${text.slice(0, 8)}`, msgType: 'text' },
user: TANG,
intentDraftService: drafts,
taskUnifiedService: services.taskUnifiedService,
scheduleService: services.scheduleService,
scheduledTaskService: services.scheduledTaskService,
env,
});
if (itl) {
handler = 'itl';
reply = itl;
} else {
const schedTask = await handleWechatScheduledTaskIntent({
intent: { agentText: text, msgId: 'sim-st' },
user: TANG,
scheduledTaskService: services.scheduledTaskService,
});
if (schedTask) {
handler = 'scheduled_task';
reply = schedTask;
}
}
if (classification.layer === 'L0' && !reply) {
reply = await formatQueryGuardReply(text, {
scheduleService: services.scheduleService,
scheduledTaskService: services.scheduledTaskService,
taskUnifiedService: services.taskUnifiedService,
userId: TANG.userId,
timezone: 'Asia/Shanghai',
});
handler = 'query_guard';
}
return { text, classification, handler, replyPreview: String(reply ?? '').split('\n').slice(0, 4).join(' / ') };
}
const cases = [
'有没有我的新闻定时任务',
'下午2点半提醒我开项目计划例会',
'确认',
'取消每日新闻任务',
'每天5点30帮我做今日新闻页面',
'设置提醒',
];
async function main() {
console.log('=== 唐用户 ITL 话术模拟 ===\n');
const drafts = createDraftStore();
const services = createServices();
for (const text of cases.slice(0, 2)) {
const row = await simulate(text);
console.log(JSON.stringify(row, null, 2));
}
await handleWechatIntentTransaction({
intent: { agentText: '下午2点半提醒我开项目计划例会', msgId: 'sim-card', msgType: 'text' },
user: TANG,
intentDraftService: drafts,
taskUnifiedService: services.taskUnifiedService,
scheduleService: services.scheduleService,
scheduledTaskService: services.scheduledTaskService,
env,
});
const confirm = await handleWechatIntentTransaction({
intent: { agentText: '确认', msgId: 'sim-confirm', msgType: 'text' },
user: TANG,
intentDraftService: drafts,
taskUnifiedService: services.taskUnifiedService,
scheduleService: services.scheduleService,
scheduledTaskService: services.scheduledTaskService,
env,
});
console.log(JSON.stringify({
text: '确认',
handler: 'itl_confirm',
replyPreview: String(confirm ?? '').split('\n').slice(0, 3).join(' / '),
syncKinds: services.syncCalls,
scheduleCalls: services.scheduleCalls,
}, null, 2));
console.log('\nunified list:', formatUnifiedTaskListReply(MOCK_TASKS).split('\n').join(' | '));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env node
/**
* Phase A ITL 离线验证:分类 → Draft → Confirm → Commitmock 服务,无需 DB
*/
import assert from 'node:assert/strict';
import { classifyUserIntent } from '../intent-classifier.mjs';
import { handleWechatIntentTransaction } from '../wechat/handlers/intent-transaction.mjs';
import { isIntentTransactionEnabled } from '../intent-transaction-config.mjs';
import { attachUnifiedTaskSync } from '../task-unified-sync.mjs';
import { createTaskUnifiedService } from '../task-unified-service.mjs';
import { formatQueryGuardReply } from '../intent-query-guard.mjs';
const env = {
H5_INTENT_TRANSACTION_ENABLED: '1',
H5_UNIFIED_TASKS_ENABLED: '1',
H5_DEFAULT_TIMEZONE: 'Asia/Shanghai',
};
let passed = 0;
let failed = 0;
function pass(label, detail = '') {
passed += 1;
console.log(`${label}${detail ? `: ${detail}` : ''}`);
}
function fail(label, detail = '') {
failed += 1;
console.error(`${label}${detail ? `: ${detail}` : ''}`);
}
function createDraftStore() {
let pending = null;
return {
async getPendingDraft(userId) {
return pending?.userId === userId ? pending : null;
},
async createDraft(payload) {
pending = {
id: 'draft-verify-1',
status: 'draft',
...payload,
payload: payload.payload,
};
return pending;
},
async cancelDraft(id, userId) {
if (pending?.id === id && pending.userId === userId) pending = null;
return { id, status: 'cancelled' };
},
async markDraftCommitted(id, userId, committedRef) {
pending = { ...pending, id, userId, status: 'committed', committedRef };
return pending;
},
};
}
function createScheduleService() {
const calls = [];
return {
calls,
async createItem(payload) {
calls.push(['createItem', payload]);
return { id: 'item-verify-1', ...payload };
},
async createReminder(payload) {
calls.push(['createReminder', payload]);
return { id: 'reminder-verify-1', ...payload };
},
buildTodoDigestText() {
return '今天有 0 条待办。';
},
};
}
async function main() {
if (!isIntentTransactionEnabled(env)) {
fail('feature flag', 'H5_INTENT_TRANSACTION_ENABLED 应为 1');
process.exit(1);
}
pass('feature flag enabled');
const query = classifyUserIntent('有没有我的新闻定时任务');
if (query.layer === 'L0') pass('query guard routes inventory to L0');
else fail('query guard routes inventory to L0', JSON.stringify(query));
const bare = classifyUserIntent('设置提醒');
if (bare.layer === 'L1' && bare.clarify?.length) pass('bare reminder asks for slot fill');
else fail('bare reminder asks for slot fill', JSON.stringify(bare));
const cancel = classifyUserIntent('取消每日新闻任务');
if (cancel.action === 'cancel_scheduled_task') pass('cancel routes to manage action');
else fail('cancel routes to manage action', JSON.stringify(cancel));
const drafts = createDraftStore();
const scheduleService = createScheduleService();
const card = await handleWechatIntentTransaction({
intent: { agentText: '下午2点半提醒我开项目计划例会', msgId: 'verify-1', msgType: 'text' },
user: { userId: 'verify-user' },
intentDraftService: drafts,
scheduleService,
env,
});
if (card?.includes('我准备执行') && card.includes('确认')) {
pass('creates action card for timed reminder');
} else {
fail('creates action card for timed reminder', card);
}
const pending = await drafts.getPendingDraft('verify-user');
if (pending?.draftType === 'timed_reminder') pass('persists pending draft');
else fail('persists pending draft', JSON.stringify(pending));
const committed = await handleWechatIntentTransaction({
intent: { agentText: '确认', msgId: 'verify-2', msgType: 'text' },
user: { userId: 'verify-user' },
intentDraftService: drafts,
scheduleService,
env,
});
if (committed?.includes('已设置提醒') && scheduleService.calls.length === 2) {
pass('confirm commits reminder to schedule service');
} else {
fail('confirm commits reminder to schedule service', `${committed} calls=${scheduleService.calls.length}`);
}
const slotFill = await handleWechatIntentTransaction({
intent: { agentText: '设置提醒', msgId: 'verify-3', msgType: 'text' },
user: { userId: 'verify-user-2' },
intentDraftService: createDraftStore(),
scheduleService,
env,
});
if (slotFill?.includes('补充') && !slotFill.includes('我准备执行')) {
pass('slot fill does not create confirmable draft');
} else {
fail('slot fill does not create confirmable draft', slotFill);
}
const disabled = await handleWechatIntentTransaction({
intent: { agentText: '下午2点提醒我', msgId: 'verify-4', msgType: 'text' },
user: { userId: 'verify-user-3' },
intentDraftService: createDraftStore(),
scheduleService,
env: { H5_INTENT_TRANSACTION_ENABLED: '0' },
});
if (disabled === null) pass('returns null when feature disabled');
else fail('returns null when feature disabled', disabled);
const syncCalls = [];
const memoryPool = {
tasks: [],
async query(sql, params = []) {
if (sql.includes('JOIN h5_schedule_reminders')) {
return [[{
id: 'item-verify-2',
title: '项目计划例会',
start_at: 9999,
timezone: 'Asia/Shanghai',
source_channel: 'wechat',
source_message_id: null,
source_text: null,
reminder_id: 'reminder-verify-2',
remind_at: 9999,
channel: 'wechat',
reminder_status: 'pending',
}]];
}
if (sql.includes('INSERT INTO h5_tasks')) {
this.tasks.push({ legacy_ref_json: params[12], user_id: params[1] });
return [{ affectedRows: 1 }];
}
if (sql.includes('SELECT * FROM h5_tasks') && sql.includes('legacy_ref_json')) return [[]];
if (sql.includes('SELECT * FROM h5_tasks WHERE id = ?')) {
return [[{
id: params[0],
user_id: 'verify-user',
type: 'reminder',
title: '项目计划例会',
spec_json: '{}',
trigger_json: '{}',
action_json: '{}',
action_level: 1,
notify_channel: 'wechat',
status: 'active',
next_run_at: 1000,
last_run_at: null,
legacy_ref_json: '{"table":"h5_schedule_reminders","id":"reminder-verify-1"}',
source_channel: 'wechat',
source_message_id: null,
source_text: null,
created_at: 1,
updated_at: 1,
}]];
}
return [[]];
},
};
const unifiedService = createTaskUnifiedService(memoryPool, { clock: { now: () => 1234 } });
const wrappedSchedule = {
async createItem(payload) {
return { id: 'item-verify-2', userId: payload.userId, kind: 'event', title: payload.title, timezone: 'Asia/Shanghai', status: 'active' };
},
async createReminder(payload) {
return { id: 'reminder-verify-2', itemId: payload.itemId, remindAt: payload.remindAt, channel: 'wechat' };
},
};
attachUnifiedTaskSync({
scheduleService: wrappedSchedule,
scheduledTaskService: {},
taskUnifiedService: {
...unifiedService,
async syncFromCommit(payload) {
syncCalls.push(payload.kind);
return unifiedService.syncFromCommit(payload);
},
},
pool: memoryPool,
env,
});
await wrappedSchedule.createReminder({ userId: 'verify-user', itemId: 'item-verify-2', remindAt: 9999 });
if (syncCalls.includes('timed_reminder')) pass('schedule reminder path dual-writes via attachUnifiedTaskSync');
else fail('schedule reminder path dual-writes via attachUnifiedTaskSync', syncCalls.join(','));
const queryReply = await formatQueryGuardReply('有没有我的新闻定时任务', {
taskUnifiedService: {
async listUserTasks() {
return [{ type: 'automation', title: '每日新闻页', trigger: { repeat: 'daily', hour: 5, minute: 30 }, nextRunAt: 1000 }];
},
},
userId: 'verify-user',
});
if (queryReply.includes('任务一览') && queryReply.includes('每日新闻页')) {
pass('query guard uses unified task list');
} else {
fail('query guard uses unified task list', queryReply);
}
console.log(`\n${passed} passed, ${failed} failed`);
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+26 -5
View File
@@ -184,19 +184,40 @@ async function testWechatHandlerIsolation() {
}
const agentReply = await handleWechatScheduleIntent({
intent: { agentText: '今天10点提醒我吃药', msgId: 'msg-agent' },
intent: { agentText: '明天早上六点去跑步,五点半提醒我', msgId: 'msg-agent' },
user: { userId: 'user-1' },
scheduleService,
});
if (agentReply === null) {
pass('服务号一次性提醒', '仍 fall through 给 Agentschedule_agent');
pass('服务号复杂提醒', '仍 fall through 给 Agent多时间点');
} else {
fail('服务号一次性提醒', `expected null, got ${agentReply}`);
fail('服务号复杂提醒', `expected null, got ${agentReply}`);
}
const directReply = await handleWechatScheduleIntent({
intent: { agentText: '今天10点提醒我吃药', msgId: 'msg-direct' },
user: { userId: 'user-1' },
scheduleService: {
...scheduleService,
async createItem(payload) {
calls.push(['createItem', payload.title]);
return { id: 'item-direct', ...payload };
},
async createReminder(payload) {
calls.push(['createReminder', payload.itemId]);
return { id: 'reminder-direct', ...payload };
},
},
});
if (directReply?.includes('已设置提醒') && calls.some((entry) => entry[0] === 'createReminder')) {
pass('服务号简单提醒', '规则路径直接 createItem + createReminder');
} else {
fail('服务号简单提醒', JSON.stringify({ directReply, calls }));
}
const intent = parseScheduleIntent('今天10点提醒我吃药');
if (shouldUseScheduleAssistant('今天10点提醒我吃药')) {
pass('意图路由', `一次性提醒走 Agent 路径(parseScheduleIntent=${intent.action},由 prompt 加载 schedule-assistant`);
if (intent.action === 'create_timed_reminder') {
pass('意图路由', '简单一次性提醒走规则 preflightcreate_timed_reminder');
} else {
fail('意图路由', JSON.stringify(intent));
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* 唐用户 ITL / Unified Tasks 103 就绪检查(只读)
*
* 用法:
* node scripts/verify-tang-itl-readiness-103.mjs
*/
import process from 'node:process';
import mysql from 'mysql2/promise';
import { classifyUserIntent } from '../intent-classifier.mjs';
import { createTaskUnifiedService, formatUnifiedTaskListReply } from '../task-unified-service.mjs';
import { handleWechatIntentTransaction } from '../wechat/handlers/intent-transaction.mjs';
import { loadH5Environment } from './load-env.mjs';
loadH5Environment(import.meta.dirname);
const TANG = 'a70ff537-8908-486e-9b6c-042e07cc25db';
const DUPLICATE_WEATHER_TASK_ID = '66f0ee11-7777-415f-affd-34422c772d0f';
const env = {
H5_INTENT_TRANSACTION_ENABLED: '1',
H5_UNIFIED_TASKS_ENABLED: '1',
H5_DEFAULT_TIMEZONE: 'Asia/Shanghai',
};
let passed = 0;
let failed = 0;
function pass(label, detail = '') {
passed += 1;
console.log(`${label}${detail ? `: ${detail}` : ''}`);
}
function fail(label, detail = '') {
failed += 1;
console.error(`${label}${detail ? `: ${detail}` : ''}`);
}
async function main() {
if (!process.env.DATABASE_URL) {
fail('DATABASE_URL', '未配置');
process.exit(1);
}
const pool = mysql.createPool({ uri: process.env.DATABASE_URL, connectionLimit: 2 });
const service = createTaskUnifiedService(pool);
const [dupRows] = await pool.query(
`SELECT id, status FROM h5_scheduled_tasks WHERE id = ? AND user_id = ? LIMIT 1`,
[DUPLICATE_WEATHER_TASK_ID, TANG],
);
const dup = dupRows?.[0];
if (!dup || dup.status === 'cancelled') pass('duplicate weather task cleaned up');
else fail('duplicate weather task cleaned up', `status=${dup.status}`);
const [activeTasks] = await pool.query(
`SELECT id, title, hour, minute, status FROM h5_scheduled_tasks
WHERE user_id = ? AND status IN ('active', 'locked')
ORDER BY hour, minute`,
[TANG],
);
if (activeTasks.length >= 1 && activeTasks.length <= 4) {
pass('active scheduled task count sane', String(activeTasks.length));
} else {
fail('active scheduled task count sane', String(activeTasks.length));
}
const unified = await service.listUserTasks({ userId: TANG, limit: 20 });
if (unified.length >= activeTasks.length) pass('unified list covers legacy tasks', `${unified.length} unified`);
else fail('unified list covers legacy tasks', `${unified.length} unified vs ${activeTasks.length} legacy`);
const queryText = '有没有我的新闻定时任务';
const classification = classifyUserIntent(queryText);
if (classification.layer === 'L0') pass('inventory query classified as L0');
else fail('inventory query classified as L0', JSON.stringify(classification));
const listReply = formatUnifiedTaskListReply(unified);
if (listReply.includes('任务一览') || listReply.includes('没有')) pass('unified list reply formatted');
else fail('unified list reply formatted', listReply);
const drafts = {
pending: null,
async getPendingDraft() { return this.pending; },
async createDraft(payload) { this.pending = { id: 'draft-tang', status: 'draft', ...payload }; return this.pending; },
async cancelDraft() { this.pending = null; },
async markDraftCommitted(id, userId, ref) {
this.pending = { ...this.pending, status: 'committed', committedRef: ref };
return this.pending;
},
};
const scheduleCalls = [];
const card = await handleWechatIntentTransaction({
intent: { agentText: '下午2点半提醒我开项目计划例会', msgId: 'tang-itl-1', msgType: 'text' },
user: { userId: TANG },
intentDraftService: drafts,
taskUnifiedService: service,
scheduleService: {
async createItem(payload) {
scheduleCalls.push(['createItem', payload.title]);
return { id: 'item-sim', ...payload, userId: TANG };
},
async createReminder(payload) {
scheduleCalls.push(['createReminder']);
return { id: 'rem-sim', ...payload, remindAt: payload.remindAt, channel: 'wechat' };
},
buildTodoDigestText: async () => '今天有 0 条待办。',
},
env,
});
if (card?.includes('确认')) pass('ITL action card dry path');
else fail('ITL action card dry path', card);
console.log('\n--- active scheduled tasks ---');
for (const row of activeTasks) {
console.log(`- ${row.title} (${row.hour}:${String(row.minute).padStart(2, '0')}) [${row.status}]`);
}
console.log('\n--- unified preview ---');
console.log(listReply);
console.log(`\n${passed} passed, ${failed} failed`);
await pool.end();
process.exit(failed > 0 ? 1 : 0);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});