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:
@@ -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=1(Phase B/C 双写 + 统一读)',
|
||||
ok: env.H5_UNIFIED_TASKS_ENABLED === '1',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'intent_transaction',
|
||||
label: 'H5_INTENT_TRANSACTION_ENABLED=1(Phase 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
|
||||
/**
|
||||
* 唐用户(103)legacy 任务 → 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', '14:30', '晚上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();
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Phase A ITL 离线验证:分类 → Draft → Confirm → Commit(mock 服务,无需 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);
|
||||
});
|
||||
@@ -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 给 Agent(schedule_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('意图路由', '简单一次性提醒走规则 preflight(create_timed_reminder)');
|
||||
} else {
|
||||
fail('意图路由', JSON.stringify(intent));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user