From d58dc2a2515449fe08816addec1b575ff5a00bf9 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 24 Aug 2026 15:59:17 +0800 Subject: [PATCH] feat(wechat): add Intent Transaction Layer with unified task schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 7 + db.mjs | 59 ++ docs/intent-transaction-layer-design.md | 296 +++++++++ intent-action-card.mjs | 90 +++ intent-classifier.mjs | 84 +++ intent-classifier.test.mjs | 54 ++ intent-draft-service.mjs | 164 +++++ intent-draft-service.test.mjs | 121 ++++ intent-query-guard.mjs | 64 ++ intent-query-guard.test.mjs | 19 + intent-transaction-commit.mjs | 131 ++++ intent-transaction-config.mjs | 8 + package.json | 6 + schedule-intent.mjs | 193 +++++- schedule-intent.test.mjs | 42 +- scheduled-task-intent.mjs | 31 +- scheduled-task-intent.test.mjs | 17 + scheduled-task-service.mjs | 2 +- scheduled-task-service.test.mjs | 55 +- schema.sql | 48 ++ scripts/check-itl-rollout-config.mjs | 59 ++ scripts/cleanup-tang-scheduled-tasks-103.mjs | 80 +++ scripts/create-tang-wechat-test-task-103.mjs | 45 ++ scripts/migrate-legacy-tasks-to-h5-tasks.mjs | 58 ++ scripts/migrate-tang-unified-tasks-103.mjs | 61 ++ .../reactivate-tang-scheduled-tasks-103.mjs | 87 +++ scripts/simulate-intent-transaction-layer.mjs | 595 +++++++++++++++++ .../simulate-schedule-capability-routing.mjs | 333 ++++++++++ scripts/simulate-tang-wechat-itl-flow.mjs | 188 ++++++ scripts/verify-intent-transaction-layer.mjs | 246 +++++++ scripts/verify-schedule-reminder-create.mjs | 31 +- scripts/verify-tang-itl-readiness-103.mjs | 128 ++++ server.mjs | 6 + server/portal-domain-services-bootstrap.mjs | 25 + .../portal-integration-services-bootstrap.mjs | 10 + task-unified-config.mjs | 3 + task-unified-service.mjs | 622 ++++++++++++++++++ task-unified-service.test.mjs | 183 ++++++ task-unified-sync.mjs | 183 ++++++ task-unified-sync.test.mjs | 102 +++ wechat-mp.mjs | 39 ++ wechat/handlers/intent-transaction.mjs | 223 +++++++ wechat/handlers/intent-transaction.test.mjs | 135 ++++ wechat/handlers/schedule.mjs | 36 +- wechat/handlers/schedule.test.mjs | 33 +- 45 files changed, 4972 insertions(+), 30 deletions(-) create mode 100644 docs/intent-transaction-layer-design.md create mode 100644 intent-action-card.mjs create mode 100644 intent-classifier.mjs create mode 100644 intent-classifier.test.mjs create mode 100644 intent-draft-service.mjs create mode 100644 intent-draft-service.test.mjs create mode 100644 intent-query-guard.mjs create mode 100644 intent-query-guard.test.mjs create mode 100644 intent-transaction-commit.mjs create mode 100644 intent-transaction-config.mjs create mode 100644 scripts/check-itl-rollout-config.mjs create mode 100644 scripts/cleanup-tang-scheduled-tasks-103.mjs create mode 100644 scripts/create-tang-wechat-test-task-103.mjs create mode 100644 scripts/migrate-legacy-tasks-to-h5-tasks.mjs create mode 100644 scripts/migrate-tang-unified-tasks-103.mjs create mode 100644 scripts/reactivate-tang-scheduled-tasks-103.mjs create mode 100644 scripts/simulate-intent-transaction-layer.mjs create mode 100644 scripts/simulate-schedule-capability-routing.mjs create mode 100644 scripts/simulate-tang-wechat-itl-flow.mjs create mode 100644 scripts/verify-intent-transaction-layer.mjs create mode 100644 scripts/verify-tang-itl-readiness-103.mjs create mode 100644 task-unified-config.mjs create mode 100644 task-unified-service.mjs create mode 100644 task-unified-service.test.mjs create mode 100644 task-unified-sync.mjs create mode 100644 task-unified-sync.test.mjs create mode 100644 wechat/handlers/intent-transaction.mjs create mode 100644 wechat/handlers/intent-transaction.test.mjs diff --git a/.env.example b/.env.example index 5632696..585fb8b 100644 --- a/.env.example +++ b/.env.example @@ -185,6 +185,13 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173 # H5_SCHEDULED_TASK_MAX_ATTEMPTS=3 # H5_SCHEDULED_TASK_EXECUTION_TIMEOUT_MS=900000 +# Intent Transaction Layer(Phase A:Draft → Confirm → Commit;默认关闭) +# H5_INTENT_TRANSACTION_ENABLED=1 +# H5_INTENT_DRAFT_TTL_MS=1800000 + +# Unified Task Schema(Phase B:读聚合 + Commit 双写;默认关闭) +# H5_UNIFIED_TASKS_ENABLED=1 + # H5 Session stream replay # 默认 0:session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。 # MEMIND_SESSION_STREAM_REPLAY=0 diff --git a/db.mjs b/db.mjs index 0061073..71b23b2 100644 --- a/db.mjs +++ b/db.mjs @@ -267,12 +267,20 @@ export async function migrateSchema(pool) { ['engine', "VARCHAR(32) NOT NULL DEFAULT 'openai' AFTER goosed_provider_id"], ['relay_provider', 'VARCHAR(64) NULL AFTER engine'], ['is_vision_selected', 'TINYINT(1) NOT NULL DEFAULT 0 AFTER is_selected'], + ['vision_model', 'VARCHAR(128) NULL AFTER is_vision_selected'], ]; for (const [column, definition] of llmColumns) { if (!(await columnExists(pool, 'h5_llm_provider_keys', column))) { await pool.query(`ALTER TABLE h5_llm_provider_keys ADD COLUMN \`${column}\` ${definition}`); } } + if (await columnExists(pool, 'h5_llm_provider_keys', 'vision_model')) { + await pool.query( + `UPDATE h5_llm_provider_keys + SET vision_model = default_model + WHERE is_vision_selected = 1 AND (vision_model IS NULL OR vision_model = '')`, + ); + } await pool.query(` CREATE TABLE IF NOT EXISTS h5_llm_executor_bindings ( @@ -831,6 +839,57 @@ export async function migrateSchema(pool) { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_intent_drafts ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + layer ENUM('L0', 'L1', 'L2', 'L3', 'ambiguous') NOT NULL, + draft_type VARCHAR(64) NOT NULL, + action_level TINYINT UNSIGNED NOT NULL DEFAULT 1, + title VARCHAR(255) NOT NULL, + payload_json JSON NOT NULL, + card_text TEXT NOT NULL, + status ENUM('draft', 'confirmed', 'committed', 'cancelled', 'expired') NOT NULL DEFAULT 'draft', + source_channel VARCHAR(32) NOT NULL DEFAULT 'wechat', + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + committed_ref_json JSON NULL, + event_log_json JSON NULL, + expires_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_intent_draft_user_status (user_id, status, created_at), + KEY idx_intent_draft_expires (status, expires_at), + CONSTRAINT fk_intent_draft_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_tasks ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + type ENUM('reminder', 'todo', 'digest', 'automation', 'condition') NOT NULL, + title VARCHAR(255) NOT NULL, + spec_json JSON NOT NULL, + trigger_json JSON NOT NULL, + action_json JSON NOT NULL, + action_level TINYINT UNSIGNED NOT NULL DEFAULT 1, + notify_channel ENUM('wechat', 'web', 'both', 'none') NOT NULL DEFAULT 'both', + status ENUM('active', 'locked', 'paused', 'completed', 'failed', 'cancelled') NOT NULL DEFAULT 'active', + next_run_at BIGINT NULL, + last_run_at BIGINT NULL, + legacy_ref_json JSON NULL, + source_channel VARCHAR(32) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_tasks_user_status (user_id, status, next_run_at), + KEY idx_h5_tasks_due (status, next_run_at), + CONSTRAINT fk_h5_tasks_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await pool.query(` CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs ( id CHAR(36) PRIMARY KEY, diff --git a/docs/intent-transaction-layer-design.md b/docs/intent-transaction-layer-design.md new file mode 100644 index 0000000..d90a200 --- /dev/null +++ b/docs/intent-transaction-layer-design.md @@ -0,0 +1,296 @@ +# Intent Transaction Layer — 意图事务层设计 + +> **状态:** 架构草案(2026-08-24) +> **受众:** Memind / TKMind 智趣 — 面向普通用户的 Agent 平台 +> **与现有文档关系:** 本文件是 [schedule-reminder-design.md](./schedule-reminder-design.md) 的演进方向;不替代当前已上线的 `h5_schedule_*` / `h5_scheduled_tasks` 实现,而是定义下一阶段的统一模型与 Commit Gate。 + +## 1. 核心判断 + +三层/四层 **路由准确率**(规则 + Preflight)可达 **99%+**,但这只解决: + +```text +用户一句话 → 分类 → 写入 +``` + +生产级 Agent 平台必须覆盖完整 **意图生命周期**: + +```text +用户输入 → 理解 → Draft → 确认 → Commit → Worker 执行 → 查询 / 修改 / 删除 → Event Log +``` + +**任何具有副作用的 Agent 行为,都不应直接 Commit**,包括但不限于: + +- 创建提醒 / 定时任务 +- 发送消息 / 邮件 +- 调用外部接口 +- 创建或发布页面 + +因此第 4 层不应称为「互动确认」,而应升级为 **Intent Transaction Layer(意图事务层)**。 + +--- + +## 2. 内部分层命名(用户语义 vs 技术实现) + +| 内部 Layer | 用户感知 | 到点后系统行为 | 当前 Memind 落点 | +|------------|----------|----------------|------------------| +| **Layer 0: Query Guard** | 「只是问问」 | 只回答,零副作用 | `parseScheduleIntent` → `query_schedule`;需扩展 query _guard | +| **Layer 1: Personal Memory** | 「帮我记着 / 到点叫我」 | **通知用户** | `h5_schedule_items` + `h5_schedule_reminders`;Preflight `create_timed_reminder` | +| **Layer 2: Scheduled Action** | 「到点替我做」 | **系统执行动作并交付** | `h5_scheduled_tasks` + `scheduled-task-worker` | +| **Layer 3: Agent Workflow** | 「多步骤复杂任务」 | Agent 编排 + 工具链 | Goose session + MCP tools;Finish 后可能有页面/消息 | + +**本质区别不是「有没有时间」,而是到点以后:** + +- Personal Memory → 微信/站内 **通知** +- Scheduled Action → **调用能力链**(搜索 → 生成 → 发送) + +示例: + +| 用户说法 | Layer | 08:00 发生什么 | +|----------|-------|------------------| +| 每天 8 点提醒我跑步 | L1 Personal Memory | 推送通知 | +| 每天 8 点帮我生成跑步报告 | L2 Scheduled Action | 拉数据 → 分析 → 生成页 → 推送链接 | + +--- + +## 3. 意图事务层(ITL)流程 + +```text + User Message + │ + ▼ + Intent Classifier + │ + ┌──────────────┴──────────────┐ + ▼ ▼ + Query Guard Action Router + (Layer 0) (L1 / L2 / L3) + │ │ + ▼ ▼ + Answer Only Draft Object + (no DB write) │ + ▼ + Confirmation Layer + (Agent Action Card) + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + Confirm Modify Cancel + │ │ │ + ▼ └→ 新 Draft ─┘ + Commit + │ + ▼ + Worker / Agent + │ + ▼ + Event Log +``` + +### Draft 状态 + +- 所有 Layer 1–3 的创建请求,**默认先写 Draft**(或内存 + `source_message_id` 关联,MVP 可用 `status=draft` 行)。 +- 微信侧:Passive Reply / 客服消息展示 **Agent Action Card**;用户回复「确认」或点按钮后再 Commit。 +- 现有 `guardScheduleConfirmationReply` 是 **反伪确认**;ITL 是 **正向前置 Gate**,二者互补。 + +### Event Log + +建议字段:`intent_id`, `user_id`, `action`, `from_status`, `to_status`, `source_message_id`, `committed_at`, `worker_run_id`, `error`。 + +可与现有 `h5_schedule_delivery_log`、scheduled task `last_result_json` 逐步合并。 + +--- + +## 4. Action Level(风险分级) + +每个 Draft 附带 metadata: + +```json +{ + "intent": "schedule", + "layer": 1, + "action_level": 1, + "risk": "low", + "side_effects": ["notify_wechat"] +} +``` + +| Level | 含义 | 示例 | Confirm 策略 | +|-------|------|------|----------------| +| **0** | 纯查询 | 「有没有我的新闻任务?」 | 禁止创建;只读 | +| **1** | 低风险 | 「提醒我下午买咖啡」 | 简卡片,单次 Confirm | +| **2** | 中风险 | 「每天 7 点发日报」 | 完整 Action Card + 周期说明 | +| **3** | 高风险 | 「每天自动给客户发报价」 | Confirm + 权限 / 白名单 / 人工开关 | + +Layer 2 周期性 Scheduled Action 默认 **Level 2**;涉及外发第三方默认 **Level 3**。 + +--- + +## 5. 统一 Agent Action Card(所有 Layer 复用) + +```text +🤖 我准备执行: + +任务:每天 08:00 生成 AI 新闻日报 + +动作: +1. 搜索最新 AI 新闻 +2. 总结 10 条重点 +3. 生成页面并发送给你 + +开始时间:明天 08:00(Asia/Shanghai) +频率:每天自动执行 +风险:中(重复执行) + +[确认执行] [修改] [取消] +``` + +Layer 1 简版: + +```text +🤖 我准备设置提醒: + +📌 项目计划例会 +🕐 今天 14:30(Asia/Shanghai) + +[确认] [改时间] [取消] +``` + +**实现锚点:** `wechat-mp.mjs` 在 Preflight 返回前不 `finishWechatMpMessage`;Draft 存 DB 后推卡片;下一条消息走 `confirm_intent` 解析器 Commit。 + +--- + +## 6. 统一 Task Schema(P0.5 — 停止模型分裂) + +当前三套存储: + +| 用途 | 表 | +|------|-----| +| 待办/日程/提醒 | `h5_schedule_items`, `h5_schedule_reminders` | +| 定时自动化 | `h5_scheduled_tasks` | +| Agent 多步 | 无一等公民(落在 session + 偶发 scheduled_task) | + +**目标:** 单一 `h5_tasks`(名称可调整),类型区分行为,而非分表。 + +```sql +-- 演进目标(草案,非 MVP 迁移脚本) +CREATE TABLE h5_tasks ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + type ENUM('reminder','todo','digest','automation','workflow') NOT NULL, + title VARCHAR(255) NOT NULL, + spec JSON NOT NULL, -- 执行说明 / taskSpec / 步骤摘要 + trigger JSON NOT NULL, -- { kind, time, repeat, timezone, condition? } + action JSON NOT NULL, -- { kind: notify|agent_run|webhook, payload } + action_level TINYINT NOT NULL DEFAULT 1, + notify_channel ENUM('wechat','web','both','none') DEFAULT 'both', + status ENUM('draft','confirmed','active','locked','paused','completed','failed','cancelled') NOT NULL, + next_run_at BIGINT NULL, + last_run_at BIGINT NULL, + source_channel VARCHAR(32), + source_message_id VARCHAR(128), + source_text TEXT, + event_log_tail JSON, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +``` + +**映射:** + +- 提醒 → `type=reminder`, `action.kind=notify` +- 待办 → `type=todo`, `action.kind=none`(可无 trigger) +- Digest → `type=digest`, `trigger.repeat=daily` +- 唐用户 8:00 天气 / 5:30 新闻 → `type=automation`, `action.kind=agent_run` +- 复杂 Agent → `type=workflow` + +**迁移策略(渐进,不要求 Big Bang):** + +1. **Phase A:** 新建 `h5_intent_drafts` + Confirm Gate;现有表不变,Commit 时仍写旧表。 +2. **Phase B:** 读路径聚合 `listTasks(userId)` 统一展示;写路径双写。 +3. **Phase C:** 迁移脚本 + 废弃 `h5_scheduled_tasks` 直连创建。 + +--- + +## 7. 对抗测试(Adversarial Routing) + +分类器必须覆盖 **故意模糊** 话术。纳入 `scripts/simulate-schedule-capability-routing.mjs`: + +| 输入 | 期望 Layer | 说明 | +|------|------------|------| +| 明天提醒我自动生成日报 | L2 或 Clarify | 「提醒」+「生成」歧义 → 必须 Clarify,禁止静默 | +| 帮我每天看看有没有新的招聘信息 | L2 | 「看看」但含每天 → Scheduled Action(监控+推送) | +| 设置任务,余额低于 100 提醒我 | L1 + Condition | 非 cron;未来 Event Trigger | +| 查一下是否有执行的新闻任务 | L0 | Query Guard,禁止 create_todo | +| 每天 8 点提醒我跑步 | L1 recurrence | 循环提醒,非 L2 | +| 每天 8 点帮我生成跑步报告 | L2 | 明确执行链 | + +**规则:** 对抗样本命中 **Clarify** 视为通过,命中错误 Layer Commit 视为失败。 + +--- + +## 8. 未来:Event Trigger(超出 Schedule) + +```text +Event Trigger → Condition → Action +``` + +示例:余额 < 100 → 微信提醒;股价跌破 → 通知;新邮件 → 摘要。 + +**不要塞进 cron schedule 表**;在统一 Task Schema 的 `trigger.kind` 扩展: + +```json +{ "kind": "condition", "watch": "balance", "op": "lt", "value": 10000 } +``` + +MVP 已有雏形:`create_balance_low_alert` + digest subscription。应归入 Layer 1/2 的 **Condition Task** 子类,而非新的第四套表。 + +--- + +## 9. 与 Goose + Memind 的插入点 + +| 组件 | 插入方式 | +|------|----------| +| **Intent Router** | 已有:`wechat-mp.mjs` → scheduled-task / schedule handlers → Agent prompt | +| **Draft** | 新增:`intent-draft-service.mjs`;微信 preflight 改 `createDraft` 而非 `createItem` | +| **Confirm Gate** | 微信下一条消息优先匹配 `confirm|取消|改` + `draft_id` | +| **Commit** | 现有 `schedule-service` / `scheduled-task-service` 作为 Commit 后端 | +| **Worker** | 不变:`schedule-reminder-worker`, `scheduled-task-worker` | +| **Agent** | Layer 3 仍走 Goose SSE;ITL 约束:**工具成功 + 用户 Confirm 后才可宣称「已设置」** | + +**不需要改 SSE 主链路**;ITL 在微信 inbound 与 MCP write 工具之间加一层。 + +--- + +## 10. 修订后的优先级 + +| 优先级 | 项 | 说明 | +|--------|-----|------| +| **P0** | Intent Transaction Layer(Draft + Confirm + Commit) | 所有副作用走 Gate | +| **P0** | Agent Action Card 统一组件 | L1 简版 + L2/L3 完整版 | +| **P0** | Query Guard(Layer 0) | 「查一下/有没有/是否」禁止 create | +| **P0.5** | 统一 Task Schema 草案 + 双写计划 | 停止 reminder/schedule/automation 三分裂 | +| **P1** | 相对时间 / 循环提醒 / 偏移提醒 | 规则或 Agent 填槽 | +| **P1** | Action Level 2/3 策略 | 周期任务默认 L2;外发 L3 | +| **P2** | Event Trigger 模型 | condition task | +| **P2** | 对抗测试 CI 门禁 | `simulate-schedule-capability-routing.mjs --adversarial` | + +--- + +## 11. 与当前工程状态的对齐(2026-08-24) + +**已有:** + +- Layer 1 Preflight:`create_timed_reminder`(待发版) +- Layer 2:`h5_scheduled_tasks` + worker + 去重修复(含 `locked`) +- 反伪确认:`schedule-guard.mjs` +- 压测:`scripts/simulate-schedule-capability-routing.mjs` + +**缺口(与你指出的完全一致):** + +- 无 Draft / Confirm 状态机 +- 无统一 Task 模型 +- Query 与 Create 未 hard 分离 +- 无 Action Level +- 对抗样本未进 CI + +**结论:** 路由工程可用;体验与可靠性由 **ITL + 统一 Task + Confirm Gate** 决定。下一迭代应少堆规则、多建事务层。 diff --git a/intent-action-card.mjs b/intent-action-card.mjs new file mode 100644 index 0000000..86e67ae --- /dev/null +++ b/intent-action-card.mjs @@ -0,0 +1,90 @@ +export function formatIntentActionCard({ + title, + layer, + actionLevel = 1, + trigger = null, + actions = [], + frequency = null, + clarify = null, +} = {}) { + if (clarify?.length) { + if (clarify.includes('reminder_title') && !clarify.includes('reminder_time')) { + return '可以。请告诉我要提醒什么,例如「下午 2 点半项目计划例会」。'; + } + if (clarify.includes('reminder_time') && !clarify.includes('reminder_title')) { + return '可以。请告诉我要几点提醒,例如「下午 2 点半」。'; + } + if (clarify.includes('reminder_time') && clarify.includes('reminder_title')) { + return '可以。请补充提醒时间和内容,例如「下午 2 点半项目计划例会」。'; + } + if (clarify.includes('todo_title')) { + return '可以。请告诉我要记录什么待办,例如「跟进合同」。'; + } + if (clarify.includes('digest_time')) { + return '可以。请告诉我想几点收到待办摘要,例如「每天早上 7 点」。'; + } + if (clarify.includes('threshold')) { + return '可以。请告诉我余额低于多少元时提醒,例如「余额低于 20 元提醒我」。'; + } + if (clarify.includes('schedule') && !clarify.includes('task_spec')) { + return '可以。请告诉我想几点执行,例如「每天 6 点」或「今晚 21:45」。'; + } + if (clarify.includes('task_spec') && !clarify.includes('schedule')) { + return '可以。到点需要自动执行什么?例如「搜索并生成今日新闻页面」。'; + } + if (clarify.includes('task_spec') && clarify.includes('schedule')) { + return '可以。请告诉我具体执行时间和任务内容,例如「每天 6 点帮我做今日新闻页面」。'; + } + if (clarify.includes('weekday')) { + return '可以。这是每周任务,请告诉我是周几、几点执行,例如「每周一 7 点整理待办摘要」。'; + } + if (clarify.includes('notify_vs_act') || clarify.includes('task_spec')) { + return [ + '我需要确认一下你的意图:', + '1️⃣ 到点 **提醒你**(微信通知)', + '2️⃣ 到点 **自动执行并交付结果**(例如生成页面/日报)', + '请回复「提醒」或「自动执行」,并补充时间与内容。', + ].join('\n'); + } + return '可以。请补充具体时间和任务内容。'; + } + + const lines = ['🤖 我准备执行:', '']; + if (title) lines.push(`任务:${title}`); + if (actions.length) { + lines.push('动作:'); + actions.forEach((step, index) => lines.push(`${index + 1}. ${step}`)); + } else if (layer === 'L1') { + lines.push('动作:到点通过微信提醒你'); + } else if (layer === 'L2') { + lines.push('动作:到点自动执行任务并推送结果'); + } + if (trigger?.at) lines.push(`开始时间:${trigger.at}`); + else if (trigger?.hour != null) { + lines.push(`开始时间:${String(trigger.hour).padStart(2, '0')}:${String(trigger.minute ?? 0).padStart(2, '0')}`); + } + if (frequency) lines.push(`频率:${frequency}`); + if (actionLevel >= 3) lines.push('风险:高(涉及外发/第三方)'); + else if (actionLevel >= 2) lines.push('风险:中(重复执行)'); + lines.push(''); + lines.push('回复「确认」执行,「取消」放弃,「修改」重新描述。'); + return lines.join('\n'); +} + +export function formatDraftCommittedReply({ title, kind }) { + const label = kind === 'scheduled_task' ? '定时自动任务' : kind === 'timed_reminder' ? '提醒' : '任务'; + return `已设置${label}:${title}。到点我会按约定通知或执行。`; +} + +export function formatDraftCancelledReply() { + return '好的,已取消本次设置,没有写入任何提醒或任务。'; +} + +export function parseDraftUserReply(text) { + const compact = String(text ?? '').replace(/\s+/g, '').trim().toLowerCase(); + if (!compact) return null; + if (/^(确认|确认执行|好的|可以|ok|yes|是|执行)$/.test(compact)) return 'confirm'; + if (/^(取消|不要了|算了|否|no|不设置了)$/.test(compact)) return 'cancel'; + if (/^(修改|改时间|改一下|更改|重新设置)$/.test(compact)) return 'modify'; + return null; +} diff --git a/intent-classifier.mjs b/intent-classifier.mjs new file mode 100644 index 0000000..0bee535 --- /dev/null +++ b/intent-classifier.mjs @@ -0,0 +1,84 @@ +import { detectQueryGuard } from './intent-query-guard.mjs'; +import { parseScheduleIntent, shouldUseScheduleAssistant } from './schedule-intent.mjs'; +import { + isScheduledTaskIntent, + parseScheduledTaskIntent, + shouldUseScheduledTaskAutomation, +} from './scheduled-task-intent.mjs'; + +function detectAmbiguity(text) { + const compact = String(text ?? '').replace(/\s+/g, ''); + const notifyCue = /(?:提醒我|设置提醒|设个?提醒|闹钟|叫我)/u.test(compact); + const actCue = /(?:生成|制作|创建|做|执行|推送|发送|整理).{0,12}(?:页面|日报|报告|摘要)/u.test(compact); + if (notifyCue && actCue) return true; + if (notifyCue && /(?:自动|生成)/u.test(compact)) return true; + return false; +} + +export function classifyUserIntent(text, { now = Date.now(), timezone = 'Asia/Shanghai' } = {}) { + if (detectQueryGuard(text)) { + return { layer: 'L0', kind: 'query', action: 'answer_only' }; + } + + const schedTask = parseScheduledTaskIntent(text, { now, timezone }); + if (isScheduledTaskIntent(schedTask)) { + if (schedTask.action === 'create_scheduled_task') { + return { + layer: 'L2', + kind: 'scheduled_task', + action: schedTask.action, + detail: schedTask, + clarify: schedTask.needsClarification ?? [], + }; + } + return { layer: 'L2', kind: 'manage', action: schedTask.action, detail: schedTask }; + } + + const sched = parseScheduleIntent(text, { timezone, now }); + if (sched.action === 'create_timed_reminder') { + return { + layer: 'L1', + kind: 'timed_reminder', + action: sched.action, + detail: sched, + clarify: sched.needsClarification ?? [], + }; + } + if (['create_todo', 'create_daily_todo_digest', 'create_balance_alert'].includes(sched.action)) { + return { + layer: 'L1', + kind: sched.action, + action: sched.action, + detail: sched, + clarify: sched.needsClarification ?? [], + }; + } + if (sched.action === 'query_schedule') { + return { layer: 'L0', kind: 'query', action: sched.action, detail: sched }; + } + + if (detectAmbiguity(text)) { + return { layer: 'ambiguous', kind: 'clarify', action: 'clarify_notify_vs_act' }; + } + + if (shouldUseScheduledTaskAutomation(text)) { + return { layer: 'L2', kind: 'scheduled_task', action: 'agent_automation', detail: schedTask }; + } + if (shouldUseScheduleAssistant(text)) { + const multiTime = (String(text).match(/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)/gu) ?? []).length > 1; + return { layer: multiTime ? 'L3' : 'L1', kind: 'agent_schedule', action: 'agent_schedule' }; + } + + return { layer: null, kind: 'none', action: 'none' }; +} + +export function inferActionLevel(classification, text = '') { + const { layer, kind, detail } = classification; + if (layer === 'L0' || layer === null) return 0; + if (layer === 'ambiguous') return 2; + if (/(?:客户|报价|群发|发邮件)/u.test(text)) return 3; + if (layer === 'L2') return 2; + if (kind === 'create_balance_alert') return 2; + if (detail?.recurrence === 'daily' || /(?:每天|每日)/u.test(text)) return 2; + return 1; +} diff --git a/intent-classifier.test.mjs b/intent-classifier.test.mjs new file mode 100644 index 0000000..0d27c0d --- /dev/null +++ b/intent-classifier.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { classifyUserIntent } from './intent-classifier.mjs'; + +test('classifies query guard for scheduled task inventory', () => { + const result = classifyUserIntent('有没有我的新闻定时任务'); + assert.equal(result.layer, 'L0'); + assert.equal(result.action, 'answer_only'); +}); + +test('classifies cancel daily task as L2 manage', () => { + const result = classifyUserIntent('取消每日新闻任务'); + assert.equal(result.layer, 'L2'); + assert.equal(result.kind, 'manage'); + assert.equal(result.action, 'cancel_scheduled_task'); +}); + +test('classifies bare set reminder as L1 slot fill', () => { + const result = classifyUserIntent('设置提醒'); + assert.equal(result.layer, 'L1'); + assert.equal(result.kind, 'timed_reminder'); + assert.ok(result.clarify?.includes('reminder_time')); +}); + +test('classifies daily external send automation as L2', () => { + const result = classifyUserIntent('每天自动给客户发送报价单'); + assert.equal(result.layer, 'L2'); + assert.equal(result.kind, 'scheduled_task'); + assert.ok(result.clarify?.includes('schedule')); +}); + +test('classifies daily monitoring automation as L2', () => { + const result = classifyUserIntent('帮我每天看看有没有新的招聘信息'); + assert.equal(result.layer, 'L2'); + assert.equal(result.kind, 'scheduled_task'); + assert.match(result.detail?.taskSpec ?? '', /招聘/u); +}); + +test('classifies notify-vs-act ambiguity', () => { + const result = classifyUserIntent('明天提醒我自动生成日报'); + assert.equal(result.layer, 'ambiguous'); + assert.equal(result.kind, 'clarify'); +}); + +test('classifies daily recurring reminder as L1 not L2', () => { + const result = classifyUserIntent('每天8点提醒我跑步'); + assert.equal(result.layer, 'L1'); +}); + +test('classifies daily report generation as L2', () => { + const result = classifyUserIntent('每天8点帮我生成跑步报告'); + assert.equal(result.layer, 'L2'); + assert.equal(result.kind, 'scheduled_task'); +}); diff --git a/intent-draft-service.mjs b/intent-draft-service.mjs new file mode 100644 index 0000000..49376df --- /dev/null +++ b/intent-draft-service.mjs @@ -0,0 +1,164 @@ +import crypto from 'node:crypto'; +import { intentDraftTtlMs } from './intent-transaction-config.mjs'; + +function nowMs(clock) { + return clock.now(); +} + +function rowToDraft(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + layer: row.layer, + draftType: row.draft_type, + actionLevel: Number(row.action_level ?? 1), + title: row.title, + payload: parseJson(row.payload_json), + cardText: row.card_text, + status: row.status, + sourceChannel: row.source_channel, + sourceMessageId: row.source_message_id ?? null, + sourceText: row.source_text ?? null, + committedRef: parseJson(row.committed_ref_json), + eventLog: parseJson(row.event_log_json) ?? [], + expiresAt: row.expires_at == null ? null : Number(row.expires_at), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; +} + +function parseJson(value) { + if (value == null || value === '') return null; + if (typeof value === 'object') return value; + try { return JSON.parse(value); } catch { return null; } +} + +export function createIntentDraftService(pool, { clock = { now: () => Date.now() }, ttlMs = intentDraftTtlMs() } = {}) { + if (!pool) throw new Error('缺少数据库连接'); + + const appendEvent = (events, type, detail = {}) => [ + ...(Array.isArray(events) ? events : []), + { type, at: clock.now(), ...detail }, + ]; + + const expireStaleDrafts = async (userId) => { + const now = nowMs(clock); + await pool.query( + `UPDATE h5_intent_drafts + SET status = 'expired', updated_at = ? + WHERE user_id = ? AND status = 'draft' AND expires_at IS NOT NULL AND expires_at <= ?`, + [now, userId, now], + ); + }; + + const getPendingDraft = async (userId) => { + if (!userId) return null; + await expireStaleDrafts(userId); + const [rows] = await pool.query( + `SELECT * FROM h5_intent_drafts + WHERE user_id = ? AND status = 'draft' + ORDER BY created_at DESC + LIMIT 1`, + [userId], + ); + return rowToDraft(rows?.[0]); + }; + + const createDraft = async ({ + userId, + layer, + draftType, + actionLevel = 1, + title, + payload, + cardText, + sourceChannel = 'wechat', + sourceMessageId = null, + sourceText = null, + }) => { + if (!userId) throw new Error('缺少用户'); + const safeTitle = String(title ?? '').trim() || '待确认任务'; + const now = nowMs(clock); + await expireStaleDrafts(userId); + await pool.query( + `UPDATE h5_intent_drafts + SET status = 'cancelled', updated_at = ? + WHERE user_id = ? AND status = 'draft'`, + [now, userId], + ); + const id = crypto.randomUUID(); + const expiresAt = now + ttlMs; + const eventLog = appendEvent([], 'draft_created', { layer, draftType }); + await pool.query( + `INSERT INTO h5_intent_drafts + (id, user_id, layer, draft_type, action_level, title, payload_json, card_text, status, + source_channel, source_message_id, source_text, committed_ref_json, event_log_json, + expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, NULL, ?, ?, ?, ?)`, + [ + id, + userId, + layer, + draftType, + actionLevel, + safeTitle, + JSON.stringify(payload ?? {}), + cardText, + sourceChannel, + sourceMessageId, + sourceText, + JSON.stringify(eventLog), + expiresAt, + now, + now, + ], + ); + const [rows] = await pool.query(`SELECT * FROM h5_intent_drafts WHERE id = ? LIMIT 1`, [id]); + return rowToDraft(rows?.[0]); + }; + + const cancelDraft = async (draftId, userId, { reason = 'user_cancel' } = {}) => { + const now = nowMs(clock); + const [rows] = await pool.query( + `SELECT * FROM h5_intent_drafts WHERE id = ? AND user_id = ? LIMIT 1`, + [draftId, userId], + ); + const row = rows?.[0]; + if (!row) throw new Error('草稿不存在'); + if (row.status !== 'draft') return rowToDraft(row); + const eventLog = appendEvent(parseJson(row.event_log_json), 'draft_cancelled', { reason }); + await pool.query( + `UPDATE h5_intent_drafts SET status = 'cancelled', event_log_json = ?, updated_at = ? WHERE id = ?`, + [JSON.stringify(eventLog), now, draftId], + ); + const [updated] = await pool.query(`SELECT * FROM h5_intent_drafts WHERE id = ? LIMIT 1`, [draftId]); + return rowToDraft(updated?.[0]); + }; + + const markDraftCommitted = async (draftId, userId, committedRef = {}) => { + const now = nowMs(clock); + const [rows] = await pool.query( + `SELECT * FROM h5_intent_drafts WHERE id = ? AND user_id = ? LIMIT 1`, + [draftId, userId], + ); + const row = rows?.[0]; + if (!row) throw new Error('草稿不存在'); + const eventLog = appendEvent(parseJson(row.event_log_json), 'draft_committed', { committedRef }); + await pool.query( + `UPDATE h5_intent_drafts + SET status = 'committed', committed_ref_json = ?, event_log_json = ?, updated_at = ? + WHERE id = ?`, + [JSON.stringify(committedRef), JSON.stringify(eventLog), now, draftId], + ); + const [updated] = await pool.query(`SELECT * FROM h5_intent_drafts WHERE id = ? LIMIT 1`, [draftId]); + return rowToDraft(updated?.[0]); + }; + + return { + getPendingDraft, + createDraft, + cancelDraft, + markDraftCommitted, + }; +} diff --git a/intent-draft-service.test.mjs b/intent-draft-service.test.mjs new file mode 100644 index 0000000..d68a485 --- /dev/null +++ b/intent-draft-service.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createIntentDraftService } from './intent-draft-service.mjs'; + +function createMemoryPool() { + const rows = new Map(); + return { + async query(sql, params = []) { + if (sql.includes('UPDATE h5_intent_drafts') && sql.includes("status = 'expired'")) { + return [{ affectedRows: 0 }]; + } + if (sql.includes('UPDATE h5_intent_drafts') && sql.includes("status = 'cancelled'") && sql.includes("status = 'draft'")) { + for (const row of rows.values()) { + if (row.user_id === params[1] && row.status === 'draft') { + row.status = 'cancelled'; + row.updated_at = params[0]; + } + } + return [{ affectedRows: 1 }]; + } + if (sql.includes('INSERT INTO h5_intent_drafts')) { + const id = params[0]; + rows.set(id, { + id, + user_id: params[1], + layer: params[2], + draft_type: params[3], + action_level: params[4], + title: params[5], + payload_json: params[6], + card_text: params[7], + status: 'draft', + source_channel: params[9], + source_message_id: params[10], + source_text: params[11], + committed_ref_json: null, + event_log_json: params[12], + expires_at: params[13], + created_at: params[14], + updated_at: params[15], + }); + return [{ affectedRows: 1 }]; + } + if (sql.includes('SELECT * FROM h5_intent_drafts WHERE id = ? AND user_id = ?')) { + const row = rows.get(params[0]); + return [[row && row.user_id === params[1] ? row : undefined].filter(Boolean)]; + } + if (sql.includes('SELECT * FROM h5_intent_drafts') && sql.includes('user_id = ?') && sql.includes("status = 'draft'")) { + const userId = params[0]; + const draft = [...rows.values()] + .filter((row) => row.user_id === userId && row.status === 'draft') + .sort((a, b) => b.created_at - a.created_at)[0]; + return [[draft ?? undefined].filter(Boolean)]; + } + if (sql.includes('SELECT * FROM h5_intent_drafts WHERE id = ?')) { + const row = rows.get(params[0]); + return [[row].filter(Boolean)]; + } + if (sql.includes("SET status = 'cancelled'") && sql.includes('WHERE id = ?')) { + const row = rows.get(params[2]); + if (row) { + row.status = 'cancelled'; + row.event_log_json = params[0]; + row.updated_at = params[1]; + } + return [{ affectedRows: 1 }]; + } + if (sql.includes("SET status = 'committed'")) { + const row = rows.get(params[3]); + if (row) { + row.status = 'committed'; + row.committed_ref_json = params[0]; + row.event_log_json = params[1]; + row.updated_at = params[2]; + } + return [{ affectedRows: 1 }]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; +} + +test('createDraft replaces previous pending draft for same user', async () => { + const pool = createMemoryPool(); + const service = createIntentDraftService(pool, { clock: { now: () => 1000 }, ttlMs: 60000 }); + const first = await service.createDraft({ + userId: 'user-1', + layer: 'L1', + draftType: 'timed_reminder', + title: '第一次', + payload: { title: '第一次' }, + cardText: 'card-1', + }); + const second = await service.createDraft({ + userId: 'user-1', + layer: 'L1', + draftType: 'timed_reminder', + title: '第二次', + payload: { title: '第二次' }, + cardText: 'card-2', + }); + const pending = await service.getPendingDraft('user-1'); + assert.notEqual(first.id, second.id); + assert.equal(pending.title, '第二次'); +}); + +test('markDraftCommitted stores committed ref', async () => { + const pool = createMemoryPool(); + const service = createIntentDraftService(pool, { clock: { now: () => 2000 }, ttlMs: 60000 }); + const draft = await service.createDraft({ + userId: 'user-1', + layer: 'L1', + draftType: 'create_todo', + title: '跟进合同', + payload: { title: '跟进合同' }, + cardText: 'card', + }); + const committed = await service.markDraftCommitted(draft.id, 'user-1', { kind: 'create_todo', item: { id: 'item-1' } }); + assert.equal(committed.status, 'committed'); + assert.deepEqual(committed.committedRef, { kind: 'create_todo', item: { id: 'item-1' } }); +}); diff --git a/intent-query-guard.mjs b/intent-query-guard.mjs new file mode 100644 index 0000000..d745d7d --- /dev/null +++ b/intent-query-guard.mjs @@ -0,0 +1,64 @@ +import { formatUnifiedTaskListReply } from './task-unified-service.mjs'; + +function normalizeCompact(text) { + return String(text ?? '').replace(/\s+/g, '').trim(); +} + +/** Layer 0: 只读查询,禁止 Draft / Commit */ +export function detectQueryGuard(text) { + const compact = normalizeCompact(text); + if (!compact) return false; + + if (/看看我的待办/u.test(compact)) return true; + if (/查看我的(?:待办|日程|行程|计划)/u.test(compact)) return true; + if (/(?:列出|看看).{0,8}(?:定时|自动).{0,8}任务/u.test(compact)) return true; + if (/(?:有没有|是否有).{0,16}(?:定时|自动).{0,8}任务/u.test(compact)) return true; + if (/(?:取消|停止|关闭|删除).{0,8}(?:定时|提醒|任务)/u.test(compact)) return false; + + const queryCue = /(?:查一下|查询|看看有没有|有没有(?!新的)|是否有|是否已有|是否还在|是否设置|我有哪些|有哪些提醒|有没有执行|有没有设置)/u.test(compact); + const createCue = /(?:帮我设置|请设置|创建|添加|记一下|设个|设一个|每天|每日|帮我每天|到点提醒)/u.test(compact) + || (/(?:定时任务|自动任务)/u.test(compact) && !queryCue); + + if (queryCue && !createCue) return true; + if (/^查/u.test(compact) && /(?:任务|提醒|定时)/u.test(compact) && !/(?:设置|创建)/u.test(compact)) return true; + return false; +} + +export async function formatQueryGuardReply(text, { + scheduleService, + scheduledTaskService, + taskUnifiedService = null, + userId, + timezone = 'Asia/Shanghai', + env = process.env, +} = {}) { + const compact = normalizeCompact(text); + if (/待办/u.test(compact) && scheduleService?.buildTodoDigestText) { + return scheduleService.buildTodoDigestText({ userId, timezone }); + } + + if (/(?:定时|自动).{0,8}任务/u.test(compact)) { + if (taskUnifiedService?.listUserTasks) { + const tasks = await taskUnifiedService.listUserTasks({ userId, status: 'active', limit: 10, timezone }); + return formatUnifiedTaskListReply(tasks, { timezone }); + } + if (scheduledTaskService?.listTasks) { + const tasks = await scheduledTaskService.listTasks({ userId, status: 'active', limit: 10 }); + return formatScheduledTaskListReply(tasks); + } + } + + return '这是查询请求,我不会新建或修改任何提醒/任务。如需设置,请直接说「下午 2 点半提醒我开会」或「每天 6 点帮我做新闻页面」。'; +} + +function formatScheduledTaskListReply(tasks) { + if (!tasks?.length) return '你当前没有进行中的定时自动任务。'; + const lines = ['你的定时自动任务:']; + for (const task of tasks.slice(0, 10)) { + const when = task.recurrence === 'once' + ? new Date(Number(task.nextRunAt)).toLocaleString('zh-CN', { timeZone: task.timezone || 'Asia/Shanghai', hour12: false }) + : `${String(task.hour).padStart(2, '0')}:${String(task.minute ?? 0).padStart(2, '0')}`; + lines.push(`- ${task.title}(${task.recurrence} ${when})`); + } + return lines.join('\n'); +} diff --git a/intent-query-guard.test.mjs b/intent-query-guard.test.mjs new file mode 100644 index 0000000..396ab6b --- /dev/null +++ b/intent-query-guard.test.mjs @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { detectQueryGuard } from './intent-query-guard.mjs'; +import { classifyUserIntent } from './intent-classifier.mjs'; + +test('detectQueryGuard treats scheduled task inventory as read-only query', () => { + assert.equal(detectQueryGuard('有没有我的新闻定时任务'), true); + assert.equal(detectQueryGuard('看看有没有定时自动任务'), true); +}); + +test('detectQueryGuard does not treat cancel/manage wording as query', () => { + assert.equal(detectQueryGuard('取消每日新闻任务'), false); +}); + +test('classifyUserIntent routes scheduled task inventory to L0', () => { + const result = classifyUserIntent('有没有我的新闻定时任务'); + assert.equal(result.layer, 'L0'); + assert.equal(result.kind, 'query'); +}); diff --git a/intent-transaction-commit.mjs b/intent-transaction-commit.mjs new file mode 100644 index 0000000..0c7545e --- /dev/null +++ b/intent-transaction-commit.mjs @@ -0,0 +1,131 @@ +import { resolveScheduleTimestamp } from './schedule-time.mjs'; +import { buildScheduledTaskCreatePayload } from './scheduled-task-intent.mjs'; +import { isUnifiedTasksEnabled } from './task-unified-config.mjs'; + +async function maybeSyncUnifiedTask({ + taskUnifiedService, + userId, + kind, + committed, + env, +}) { + if (!isUnifiedTasksEnabled(env) || !taskUnifiedService) return null; + try { + return await taskUnifiedService.syncFromCommit({ userId, kind, committed }); + } catch (err) { + console.warn?.( + '[intent-transaction-commit] unified task sync failed open:', + err instanceof Error ? err.message : err, + ); + return null; + } +} + +export async function commitIntentDraft({ + draft, + userId, + scheduleService, + scheduledTaskService, + taskUnifiedService = null, + timezone = 'Asia/Shanghai', + env = process.env, +}) { + if (!draft || draft.status !== 'draft') throw new Error('草稿不可提交'); + const payload = draft.payload ?? {}; + const kind = draft.draftType; + + if (kind === 'timed_reminder') { + if (!scheduleService) throw new Error('scheduleService 不可用'); + const remindAt = resolveScheduleTimestamp({ + localString: payload.remindLocal, + timezone, + fieldName: '提醒时间', + }); + const item = await scheduleService.createItem({ + userId, + kind: 'event', + title: payload.title ?? draft.title, + startAt: remindAt, + timezone, + sourceChannel: payload.sourceChannel ?? 'wechat', + sourceMessageId: payload.sourceMessageId ?? draft.sourceMessageId, + sourceText: payload.sourceText ?? draft.sourceText, + metadata: { source: 'intent_transaction_layer' }, + }); + const reminder = await scheduleService.createReminder({ + userId, + itemId: item.id, + remindAt, + channel: 'wechat', + }); + const committed = { kind, item, reminder }; + await maybeSyncUnifiedTask({ taskUnifiedService, userId, kind, committed, env }); + return committed; + } + + if (kind === 'create_todo') { + if (!scheduleService) throw new Error('scheduleService 不可用'); + const item = await scheduleService.createItem({ + userId, + kind: 'task', + title: payload.title ?? draft.title, + timezone, + sourceChannel: payload.sourceChannel ?? 'wechat', + sourceMessageId: payload.sourceMessageId ?? draft.sourceMessageId, + sourceText: payload.sourceText ?? draft.sourceText, + metadata: { source: 'intent_transaction_layer' }, + }); + const committed = { kind, item }; + await maybeSyncUnifiedTask({ taskUnifiedService, userId, kind, committed, env }); + return committed; + } + + if (kind === 'create_daily_todo_digest') { + if (!scheduleService) throw new Error('scheduleService 不可用'); + const subscription = await scheduleService.createDailyTodoDigest({ + userId, + hour: payload.hour, + minute: payload.minute ?? 0, + timezone, + channel: 'wechat', + sourceChannel: payload.sourceChannel ?? 'wechat', + sourceMessageId: payload.sourceMessageId ?? draft.sourceMessageId, + sourceText: payload.sourceText ?? draft.sourceText, + }); + const committed = { kind, subscription }; + await maybeSyncUnifiedTask({ taskUnifiedService, userId, kind, committed, env }); + return committed; + } + + if (kind === 'create_balance_alert') { + if (!scheduleService) throw new Error('scheduleService 不可用'); + const subscription = await scheduleService.createBalanceLowAlert({ + userId, + thresholdCents: payload.thresholdCents, + channel: 'wechat', + sourceChannel: payload.sourceChannel ?? 'wechat', + sourceMessageId: payload.sourceMessageId ?? draft.sourceMessageId, + sourceText: payload.sourceText ?? draft.sourceText, + }); + const committed = { kind, subscription }; + await maybeSyncUnifiedTask({ taskUnifiedService, userId, kind, committed, env }); + return committed; + } + + if (kind === 'scheduled_task') { + if (!scheduledTaskService) throw new Error('scheduledTaskService 不可用'); + const createPayload = payload.createPayload ?? buildScheduledTaskCreatePayload(payload.intentDetail ?? payload, { + userId, + sourceChannel: 'wechat', + sourceMessageId: payload.sourceMessageId ?? draft.sourceMessageId, + sourceText: payload.sourceText ?? draft.sourceText, + timezone, + }); + const task = await scheduledTaskService.createTask(createPayload); + const committed = { kind, task }; + await maybeSyncUnifiedTask({ taskUnifiedService, userId, kind, committed, env }); + return committed; + } + + throw new Error(`不支持的草稿类型:${kind}`); +} diff --git a/intent-transaction-config.mjs b/intent-transaction-config.mjs new file mode 100644 index 0000000..ec84add --- /dev/null +++ b/intent-transaction-config.mjs @@ -0,0 +1,8 @@ +export function isIntentTransactionEnabled(env = process.env) { + return String(env.H5_INTENT_TRANSACTION_ENABLED ?? '').trim() === '1'; +} + +export function intentDraftTtlMs(env = process.env) { + const raw = Number(env.H5_INTENT_DRAFT_TTL_MS ?? 30 * 60 * 1000); + return Number.isFinite(raw) && raw > 0 ? raw : 30 * 60 * 1000; +} diff --git a/package.json b/package.json index 1a94b54..e6c45fd 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,12 @@ "verify:goosed-proxy-boundary": "node scripts/check-goosed-proxy-boundary.mjs", "verify:h5-session-patches": "node scripts/verify-h5-session-patches.mjs", "verify:scheduled-task-automation": "node scripts/verify-scheduled-task-automation.mjs", + "verify:intent-transaction-layer": "node scripts/verify-intent-transaction-layer.mjs && node --test intent-classifier.test.mjs intent-query-guard.test.mjs intent-draft-service.test.mjs task-unified-service.test.mjs task-unified-sync.test.mjs wechat/handlers/intent-transaction.test.mjs", + "migrate:legacy-tasks-to-h5-tasks": "node scripts/migrate-legacy-tasks-to-h5-tasks.mjs --dry-run", + "migrate:tang-unified-tasks-103": "node scripts/migrate-tang-unified-tasks-103.mjs", + "verify:tang-itl-readiness-103": "node scripts/verify-tang-itl-readiness-103.mjs", + "simulate:tang-wechat-itl": "node scripts/simulate-tang-wechat-itl-flow.mjs", + "check:itl-rollout-config": "node scripts/check-itl-rollout-config.mjs", "test:mindspace-e2e": "node scripts/mindspace-e2e.mjs", "test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs", "test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs", diff --git a/schedule-intent.mjs b/schedule-intent.mjs index 5d27e5f..cc9c93d 100644 --- a/schedule-intent.mjs +++ b/schedule-intent.mjs @@ -1,5 +1,19 @@ +import { + addLocalDays, + getLocalParts, + normalizeTimezone, + zonedTimeToEpochMs, +} from './schedule-time.mjs'; + function normalizeText(text) { - return String(text ?? '').replace(/\s+/g, '').trim(); + return String(text ?? '') + .replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨)+/u, '') + .replace(/\s+/g, '') + .trim(); +} + +function pad2(value) { + return String(value).padStart(2, '0'); } function chineseHourToNumber(value) { @@ -30,17 +44,161 @@ function chineseHourToNumber(value) { } export function parseHourMinute(text) { - const match = text.match(/(?:早上|上午|清晨|每天早上|每天上午)?([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})(?:点|:|:)(半|[0-9]{1,2}分?)?/); + const match = text.match( + /(?:今天|明天|后天|今晚|明晚)?(?:早上|上午|清晨|每天(?:早上|上午)?)?(早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)?([0-9]{1,2}|[零一二两三四五六七八九十]{1,3})(?:点|:|:)(半|[0-9]{1,2}分?)?/, + ); if (!match) return null; - const hour = chineseHourToNumber(match[1]); + const period = String(match[1] ?? ''); + let hour = chineseHourToNumber(match[2]); if (hour === null || hour < 0 || hour > 23) return null; let minute = 0; - if (match[2] === '半') minute = 30; - else if (match[2]) minute = Number(String(match[2]).replace('分', '')); + if (match[3] === '半') minute = 30; + else if (match[3]) minute = Number(String(match[3]).replace('分', '')); if (!Number.isFinite(minute) || minute < 0 || minute > 59) return null; + if (period === '中午') { + hour = 12; + } else if (/下午|傍晚|晚上/u.test(period) && hour < 12) { + hour += 12; + } else if (/上午|早上|清晨|凌晨/u.test(period) && hour === 12) { + hour = 0; + } return { hour, minute }; } +function countTimeExpressions(text) { + const matches = String(text ?? '').match( + /[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)(?:半|[0-9]{1,2}(?:分(?!开)|(?![0-9]))?)?/gu, + ); + return matches?.length ?? 0; +} + +function wantsSimpleTimedReminder(compact, text) { + if (!/(?:提醒我|设置提醒|设个?提醒|到点提醒|提醒一下|闹钟|叫我)/u.test(compact)) return false; + if (countTimeExpressions(text) !== 1) return false; + if (/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:).{1,24}[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)/u.test(compact)) { + return false; + } + return parseHourMinute(text) != null; +} + +function extractTimedReminderTitle(text) { + const original = String(text ?? '').trim(); + const commaParts = original.split(/[,,]/).map((part) => part.trim()).filter(Boolean); + if (commaParts.length > 1) { + const tail = commaParts[commaParts.length - 1]; + const tailTitle = cleanupTimedReminderTitleFragment(tail); + if (tailTitle && tailTitle.length >= 2) return tailTitle; + } + return cleanupTimedReminderTitleFragment(original); +} + +function cleanupTimedReminderTitleFragment(text) { + let title = String(text ?? '').trim(); + title = title + .replace(/^(?:帮我|请|麻烦)?(?:设置|设|创建|添加)(?:一个|个)?提醒[,,、::\s]*/u, '') + .replace(/^(?:帮我|请)?(?:设置|设)(?:一个|个)?(?:待办|代办|带办|代拜)[,,、::\s]*/u, ''); + title = title + .replace(/(?:今天|今日|今晚|明天|后天|明早|今早)/gu, ' ') + .replace(/(?:早上|上午|清晨|中午|下午|傍晚|晚上|凌晨)/gu, ' ') + .replace(/[0-9零一二两三四五六七八九十]{1,3}(?:点|:|:)(?:半|[0-9]{1,2}(?:分(?!开)|(?![0-9]))?)?/gu, ' ') + .replace(/(?:提醒我|提醒|闹钟|叫我|设置提醒|设提醒)/gu, ' ') + .replace(/[,,、::]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + title = title + .replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨)+/u, '') + .replace(/^(?:去|来)?(?:参加|进行)?(?:一个|个)?/u, '') + .trim(); + title = title.replace(/^分开/u, '开会').replace(/开会会/u, '开会'); + if (!title || title.length < 2 || /^(?:请|麻烦|能不能|嗨|嗯|那个|帮我)$/u.test(title)) return null; + return title; +} + +export function buildTimedReminderLocal({ + text, + hour, + minute = 0, + timezone = 'Asia/Shanghai', + now = Date.now(), +} = {}) { + const compact = normalizeText(text); + let dayOffset = 0; + if (/后天/u.test(compact)) dayOffset = 2; + else if (/明天|明早|明晚/u.test(compact)) dayOffset = 1; + + const tz = normalizeTimezone(timezone); + let dayStart = addLocalDays(now, dayOffset, tz); + let parts = getLocalParts(dayStart, tz); + let remindAt = zonedTimeToEpochMs( + { + year: parts.year, + month: parts.month, + day: parts.day, + hour, + minute, + second: 0, + }, + tz, + ); + if (remindAt <= now) { + dayStart = addLocalDays(dayStart, 1, tz); + parts = getLocalParts(dayStart, tz); + remindAt = zonedTimeToEpochMs( + { + year: parts.year, + month: parts.month, + day: parts.day, + hour, + minute, + second: 0, + }, + tz, + ); + } + const localParts = getLocalParts(remindAt, tz); + return `${localParts.year}-${pad2(localParts.month)}-${pad2(localParts.day)} ${pad2(localParts.hour)}:${pad2(localParts.minute)}`; +} + +function parseSimpleTimedReminder(text, { timezone = 'Asia/Shanghai', now = Date.now() } = {}) { + const compact = normalizeText(text); + if (!wantsSimpleTimedReminder(compact, text)) return null; + const time = parseHourMinute(text); + if (!time) return null; + const title = extractTimedReminderTitle(text); + if (!title || title.length < 2) { + const needsClarification = time ? ['reminder_title'] : ['reminder_time', 'reminder_title']; + const partial = { + action: 'create_timed_reminder', + needsClarification, + }; + if (time) { + partial.hour = time.hour; + partial.minute = time.minute; + partial.remindLocal = buildTimedReminderLocal({ + text, + hour: time.hour, + minute: time.minute, + timezone, + now, + }); + } + return partial; + } + return { + action: 'create_timed_reminder', + title, + remindLocal: buildTimedReminderLocal({ + text, + hour: time.hour, + minute: time.minute, + timezone, + now, + }), + hour: time.hour, + minute: time.minute, + }; +} + function extractTodoTitle(text) { const original = String(text ?? '').trim(); if (!original) return null; @@ -67,18 +225,25 @@ function extractTodoTitle(text) { function wantsDailyTodoDigest(compact) { const daily = /每天|每日|天天/.test(compact); - if (!daily) return false; const send = /发|发送|推送|提醒|通知|给我/.test(compact); - if (!send) return false; const digest = /(待办|todo|任务).*(记录|列表|清单|安排|摘要|汇总)/.test(compact) || /(今天|今日|当天).*(待办|todo|任务)/.test(compact) || /(待办|todo|任务).*(今天|今日|当天)/.test(compact) || /一天的待办/.test(compact); - return digest; + if (digest && /(?:页面|网页|html|h5)/iu.test(compact)) return false; + if (daily && send && digest) return true; + // 「早上7点把当天待办发给我」省略了「每天」但语义仍是 digest + if (!daily && send && /(当天|今天|今日).*(待办|todo|任务)/.test(compact) && parseHourMinute(compact)) { + return true; + } + return false; } -export function parseScheduleIntent(text) { +export function parseScheduleIntent(text, { + timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', + now = Date.now(), +} = {}) { const compact = normalizeText(text); if (!compact) return { action: 'none' }; @@ -111,6 +276,16 @@ export function parseScheduleIntent(text) { return { action: 'query_schedule' }; } + const timedReminder = parseSimpleTimedReminder(text, { timezone, now }); + if (timedReminder) return timedReminder; + + if (/^(?:帮我|请|麻烦)?(?:设置|设)(?:一个|个)?提醒$/u.test(compact)) { + return { + action: 'create_timed_reminder', + needsClarification: ['reminder_time', 'reminder_title'], + }; + } + const wantsTodoRecord = /(不用提醒|先记一下|帮我记一下|帮我记|记一下|添加待办|添加任务|记个待办|记个任务|设置一个待办|设置一个代办|设置一个带办|设置一个代拜|设置下一个代拜|设下一个代拜|帮我设置一个待办|帮我设置一个代办|帮我设置一个带办|帮我设置一个代拜|待办|代办|带办|代拜|任务)/.test( compact, diff --git a/schedule-intent.test.mjs b/schedule-intent.test.mjs index 6c79889..a95a5e9 100644 --- a/schedule-intent.test.mjs +++ b/schedule-intent.test.mjs @@ -66,6 +66,17 @@ test('balance reminder asks for threshold when missing', () => { assert.deepEqual(intent.needsClarification, ['threshold']); }); +test('parse bare set reminder command as slot fill intent', () => { + const intent = parseScheduleIntent('设置提醒'); + assert.equal(intent.action, 'create_timed_reminder'); + assert.deepEqual(intent.needsClarification, ['reminder_time', 'reminder_title']); +}); + +test('parse bare set reminder with asr prefix', () => { + const intent = parseScheduleIntent('嗯设置提醒'); + assert.equal(intent.action, 'create_timed_reminder'); +}); + test('parses plain todo record request with quoted title', () => { const intent = parseScheduleIntent('帮我记一下「🍽️ 跟段吃饭」'); assert.equal(intent.action, 'create_todo'); @@ -107,10 +118,39 @@ test('reminder todo request routes to schedule assistant instead of plain todo s test('specific dated reminder still routes to schedule assistant', () => { const intent = parseScheduleIntent('明天下午三点提醒我开会'); - assert.equal(intent.action, 'none'); + assert.equal(intent.action, 'create_timed_reminder'); + assert.equal(intent.title, '开会'); + assert.equal(intent.hour, 15); assert.equal(shouldUseScheduleAssistant('明天下午三点提醒我开会'), true); }); +test('parses direct reminder setup phrase from wechat', () => { + const now = Date.UTC(2026, 7, 24, 4, 0, 0); // 2026-08-24 12:00 Asia/Shanghai + const intent = parseScheduleIntent('帮我设置提醒,下午 14:30 分开会,项目计划例会', { + timezone: 'Asia/Shanghai', + now, + }); + assert.equal(intent.action, 'create_timed_reminder'); + assert.equal(intent.title, '项目计划例会'); + assert.equal(intent.remindLocal, '2026-08-24 14:30'); +}); + +test('simple reminder today rolls to tomorrow when time already passed', () => { + const now = Date.UTC(2026, 7, 24, 7, 0, 0); // 2026-08-24 15:00 Asia/Shanghai + const intent = parseScheduleIntent('今天10点提醒我吃药', { + timezone: 'Asia/Shanghai', + now, + }); + assert.equal(intent.action, 'create_timed_reminder'); + assert.equal(intent.remindLocal, '2026-08-25 10:00'); +}); + +test('multi-time reminder still falls through to agent prompt', () => { + const intent = parseScheduleIntent('明天早上六点去跑步,五点半提醒我'); + assert.equal(intent.action, 'none'); + assert.equal(shouldUseScheduleAssistant('明天早上六点去跑步,五点半提醒我'), true); +}); + test('simple schedule query still works', () => { const intent = parseScheduleIntent('看看我的待办'); assert.equal(intent.action, 'query_schedule'); diff --git a/scheduled-task-intent.mjs b/scheduled-task-intent.mjs index d2e5e57..924f86a 100644 --- a/scheduled-task-intent.mjs +++ b/scheduled-task-intent.mjs @@ -8,10 +8,13 @@ import { } from './schedule-time.mjs'; function normalizeText(text) { - return String(text ?? '').replace(/\s+/g, '').trim(); + return String(text ?? '') + .replace(/^(?:嗯|那个|就是|然后|呃|能不能|请|帮我|麻烦|嗨)+/u, '') + .replace(/\s+/g, '') + .trim(); } -const EXECUTE_VERBS = /(?:做|生成|制作|创建|写|执行|跑|更新|整理|汇总|推送)/u; +const EXECUTE_VERBS = /(?:做|生成|制作|创建|写|执行|跑|更新|整理|汇总|推送|发送|发给|发|看看|检查|监控|瞧瞧)/u; const SCHEDULE_MARKERS = /(?:定时(?:自动)?任务|scheduled\s*task|scheduled\s*automation|cron\s*job|recurring\s*task)/iu; const RECURRENCE_MARKERS = /(?:每天|每日|每周|定时|到点|届时|自动)/u; @@ -44,11 +47,18 @@ function isMetaScheduledTaskSpec(spec) { function wantsScheduledTaskAutomation(compact) { if (SCHEDULE_MARKERS.test(compact)) return true; + if (RECURRENCE_MARKERS.test(compact) + && /(?:看看|检查|监控|瞧瞧).{0,20}(?:有没有|是否有)/u.test(compact)) { + return true; + } if (!RECURRENCE_MARKERS.test(compact)) return false; if (!EXECUTE_VERBS.test(compact)) return false; // Pure reminders/digests stay on schedule-assistant or wechat schedule handler. if (/(?:提醒我|闹钟|待办记录|待办列表|待办清单|当天待办|一天的待办)/u.test(compact)) return false; - if (/(?:待办|代办|带办|代拜).{0,8}(?:记录|列表|清单|摘要|汇总)/u.test(compact)) return false; + if (/(?:待办|代办|带办|代拜).{0,8}(?:记录|列表|清单|摘要|汇总)/u.test(compact)) { + // 「待办摘要页面 / 待办汇总 HTML」仍是定时自动任务,不是纯 digest 推送。 + if (!/(?:页面|网页|html|h5)/iu.test(compact)) return false; + } return true; } @@ -147,13 +157,8 @@ export function parseScheduledTaskIntent(text, { now = Date.now(), timezone = 'A const original = String(text ?? '').trim(); if (!compact) return { action: 'none' }; - if (!wantsScheduledTaskAutomation(compact)) { - return { action: 'none' }; - } - - const tz = normalizeTimezone(timezone); - - const wantsCancel = /(?:取消|停止|关闭|删除).{0,12}(?:定时|自动)/u.test(compact) + const wantsCancel = /(?:取消|停止|关闭|删除).{0,16}(?:定时|自动|每日|每天)/u.test(compact) + || /(?:取消|停止|关闭|删除).{0,16}(?:新闻|天气).{0,8}任务/u.test(compact) || /(?:cancel|stop|disable).{0,12}(?:scheduled|automation|task)/iu.test(compact); if (wantsCancel) { return { action: 'cancel_scheduled_task' }; @@ -165,6 +170,12 @@ export function parseScheduledTaskIntent(text, { now = Date.now(), timezone = 'A return { action: 'list_scheduled_tasks' }; } + if (!wantsScheduledTaskAutomation(compact)) { + return { action: 'none' }; + } + + const tz = normalizeTimezone(timezone); + const recurrence = /(?:一次|单次|仅一次|once)/iu.test(compact) ? 'once' : /(?:每周|weekly)/iu.test(compact) diff --git a/scheduled-task-intent.test.mjs b/scheduled-task-intent.test.mjs index b5bfcb4..5cc5a51 100644 --- a/scheduled-task-intent.test.mjs +++ b/scheduled-task-intent.test.mjs @@ -105,6 +105,23 @@ test('meta scheduled-task phrases require task_spec clarification', () => { } }); +test('parse cancel daily news task as manage action', () => { + const intent = parseScheduledTaskIntent('取消每日新闻任务'); + assert.equal(intent.action, 'cancel_scheduled_task'); +}); + +test('parse daily external send automation', () => { + const intent = parseScheduledTaskIntent('每天自动给客户发送报价单'); + assert.equal(intent.action, 'create_scheduled_task'); + assert.match(intent.taskSpec ?? '', /报价/u); +}); + +test('parse daily monitoring automation', () => { + const intent = parseScheduledTaskIntent('帮我每天看看有没有新的招聘信息'); + assert.equal(intent.action, 'create_scheduled_task'); + assert.match(intent.taskSpec ?? '', /招聘/u); +}); + test('combined phrase keeps substantive taskSpec after time prefix', () => { const text = '帮我创建一个定时执行任务,23:00执行,搜索今日新闻'; const intent = parseScheduledTaskIntent(text); diff --git a/scheduled-task-service.mjs b/scheduled-task-service.mjs index 5d9530d..25a741d 100644 --- a/scheduled-task-service.mjs +++ b/scheduled-task-service.mjs @@ -145,7 +145,7 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM `SELECT * FROM h5_scheduled_tasks WHERE user_id = ? - AND status = 'active' + AND status IN ('active', 'locked') AND recurrence = ? AND hour <=> ? AND minute = ? diff --git a/scheduled-task-service.test.mjs b/scheduled-task-service.test.mjs index 3a7d37d..ddb04d9 100644 --- a/scheduled-task-service.test.mjs +++ b/scheduled-task-service.test.mjs @@ -76,7 +76,7 @@ test('createTask returns existing active duplicate for same schedule', async () let insertCount = 0; const service = createScheduledTaskService({ async query(sql) { - if (sql.includes('FROM h5_scheduled_tasks') && sql.includes('status = \'active\'')) { + if (sql.includes('FROM h5_scheduled_tasks') && sql.includes("status IN ('active', 'locked')")) { return [[{ id: 'task-existing', user_id: 'user-1', @@ -125,6 +125,59 @@ test('createTask returns existing active duplicate for same schedule', async () assert.equal(insertCount, 0); }); +test('createTask returns existing locked duplicate for same schedule', async () => { + let insertCount = 0; + const service = createScheduledTaskService({ + async query(sql) { + if (sql.includes('FROM h5_scheduled_tasks') && sql.includes("status IN ('active', 'locked')")) { + return [[{ + id: 'task-locked', + user_id: 'user-1', + title: '每日天气预报播报(上海+武穴)', + task_spec: 'running spec', + recurrence: 'daily', + hour: 8, + minute: 0, + weekday: null, + timezone: 'Asia/Shanghai', + next_run_at: 1780000000000, + last_run_at: null, + notify_channel: 'both', + status: 'locked', + attempts: 1, + last_error: null, + last_result_json: null, + source_channel: 'agent', + source_session_id: null, + source_message_id: null, + source_text: null, + created_at: 1780000000000, + updated_at: 1780000000000, + }]]; + } + if (sql.includes('INSERT INTO h5_scheduled_tasks')) { + insertCount += 1; + return [{ affectedRows: 1 }]; + } + return [[]]; + }, + }, { + clock: { now: () => 1780000000000 }, + }); + + const task = await service.createTask({ + userId: 'user-1', + title: '每日天气预报播报(上海+武穴)', + taskSpec: 'new spec', + recurrence: 'daily', + hour: 8, + minute: 0, + }); + + assert.equal(task.id, 'task-locked'); + assert.equal(insertCount, 0); +}); + test('cancelTask updates status to cancelled', async () => { const service = createScheduledTaskService({ async query(sql, params) { diff --git a/schema.sql b/schema.sql index 66dd00c..296a245 100644 --- a/schema.sql +++ b/schema.sql @@ -611,6 +611,7 @@ CREATE TABLE IF NOT EXISTS h5_llm_provider_keys ( status ENUM('active', 'disabled') NOT NULL DEFAULT 'active', is_selected TINYINT(1) NOT NULL DEFAULT 0, is_vision_selected TINYINT(1) NOT NULL DEFAULT 0, + vision_model VARCHAR(128) NULL, created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL, UNIQUE KEY uq_h5_llm_key_name (name), @@ -1124,6 +1125,53 @@ CREATE TABLE IF NOT EXISTS h5_balance_alert_subscriptions ( CONSTRAINT fk_balance_alert_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS h5_intent_drafts ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + layer ENUM('L0', 'L1', 'L2', 'L3', 'ambiguous') NOT NULL, + draft_type VARCHAR(64) NOT NULL, + action_level TINYINT UNSIGNED NOT NULL DEFAULT 1, + title VARCHAR(255) NOT NULL, + payload_json JSON NOT NULL, + card_text TEXT NOT NULL, + status ENUM('draft', 'confirmed', 'committed', 'cancelled', 'expired') NOT NULL DEFAULT 'draft', + source_channel VARCHAR(32) NOT NULL DEFAULT 'wechat', + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + committed_ref_json JSON NULL, + event_log_json JSON NULL, + expires_at BIGINT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_intent_draft_user_status (user_id, status, created_at), + KEY idx_intent_draft_expires (status, expires_at), + CONSTRAINT fk_intent_draft_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS h5_tasks ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + type ENUM('reminder', 'todo', 'digest', 'automation', 'condition') NOT NULL, + title VARCHAR(255) NOT NULL, + spec_json JSON NOT NULL, + trigger_json JSON NOT NULL, + action_json JSON NOT NULL, + action_level TINYINT UNSIGNED NOT NULL DEFAULT 1, + notify_channel ENUM('wechat', 'web', 'both', 'none') NOT NULL DEFAULT 'both', + status ENUM('active', 'locked', 'paused', 'completed', 'failed', 'cancelled') NOT NULL DEFAULT 'active', + next_run_at BIGINT NULL, + last_run_at BIGINT NULL, + legacy_ref_json JSON NULL, + source_channel VARCHAR(32) NULL, + source_message_id VARCHAR(128) NULL, + source_text TEXT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + KEY idx_h5_tasks_user_status (user_id, status, next_run_at), + KEY idx_h5_tasks_due (status, next_run_at), + CONSTRAINT fk_h5_tasks_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS h5_schedule_delivery_logs ( id CHAR(36) PRIMARY KEY, reminder_id CHAR(36) NULL, diff --git a/scripts/check-itl-rollout-config.mjs b/scripts/check-itl-rollout-config.mjs new file mode 100644 index 0000000..80ebb1e --- /dev/null +++ b/scripts/check-itl-rollout-config.mjs @@ -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); diff --git a/scripts/cleanup-tang-scheduled-tasks-103.mjs b/scripts/cleanup-tang-scheduled-tasks-103.mjs new file mode 100644 index 0000000..4a9fa5f --- /dev/null +++ b/scripts/cleanup-tang-scheduled-tasks-103.mjs @@ -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); +}); diff --git a/scripts/create-tang-wechat-test-task-103.mjs b/scripts/create-tang-wechat-test-task-103.mjs new file mode 100644 index 0000000..e7436c8 --- /dev/null +++ b/scripts/create-tang-wechat-test-task-103.mjs @@ -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(); diff --git a/scripts/migrate-legacy-tasks-to-h5-tasks.mjs b/scripts/migrate-legacy-tasks-to-h5-tasks.mjs new file mode 100644 index 0000000..bd11cb5 --- /dev/null +++ b/scripts/migrate-legacy-tasks-to-h5-tasks.mjs @@ -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= --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); +}); diff --git a/scripts/migrate-tang-unified-tasks-103.mjs b/scripts/migrate-tang-unified-tasks-103.mjs new file mode 100644 index 0000000..28c9994 --- /dev/null +++ b/scripts/migrate-tang-unified-tasks-103.mjs @@ -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); +}); diff --git a/scripts/reactivate-tang-scheduled-tasks-103.mjs b/scripts/reactivate-tang-scheduled-tasks-103.mjs new file mode 100644 index 0000000..d4d1ac0 --- /dev/null +++ b/scripts/reactivate-tang-scheduled-tasks-103.mjs @@ -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); +}); diff --git a/scripts/simulate-intent-transaction-layer.mjs b/scripts/simulate-intent-transaction-layer.mjs new file mode 100644 index 0000000..90d4c4f --- /dev/null +++ b/scripts/simulate-intent-transaction-layer.mjs @@ -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(); diff --git a/scripts/simulate-schedule-capability-routing.mjs b/scripts/simulate-schedule-capability-routing.mjs new file mode 100644 index 0000000..bd90457 --- /dev/null +++ b/scripts/simulate-schedule-capability-routing.mjs @@ -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(); diff --git a/scripts/simulate-tang-wechat-itl-flow.mjs b/scripts/simulate-tang-wechat-itl-flow.mjs new file mode 100644 index 0000000..95b1970 --- /dev/null +++ b/scripts/simulate-tang-wechat-itl-flow.mjs @@ -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); +}); diff --git a/scripts/verify-intent-transaction-layer.mjs b/scripts/verify-intent-transaction-layer.mjs new file mode 100644 index 0000000..539fe68 --- /dev/null +++ b/scripts/verify-intent-transaction-layer.mjs @@ -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); +}); diff --git a/scripts/verify-schedule-reminder-create.mjs b/scripts/verify-schedule-reminder-create.mjs index 9e784ca..e4fd312 100644 --- a/scripts/verify-schedule-reminder-create.mjs +++ b/scripts/verify-schedule-reminder-create.mjs @@ -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)); } diff --git a/scripts/verify-tang-itl-readiness-103.mjs b/scripts/verify-tang-itl-readiness-103.mjs new file mode 100644 index 0000000..8c031df --- /dev/null +++ b/scripts/verify-tang-itl-readiness-103.mjs @@ -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); +}); diff --git a/server.mjs b/server.mjs index d170923..3d33471 100644 --- a/server.mjs +++ b/server.mjs @@ -320,6 +320,8 @@ let wechatMpService = null; let notificationDispatcher = null; let scheduleService = null; let scheduledTaskService = null; +let intentDraftService = null; +let taskUnifiedService = null; let feedbackService = null; let scheduleReminderWorker = null; let scheduledTaskWorker = null; @@ -362,6 +364,8 @@ async function bootstrapUserAuth() { ); scheduleService = domainServices.scheduleService; scheduledTaskService = domainServices.scheduledTaskService; + intentDraftService = domainServices.intentDraftService; + taskUnifiedService = domainServices.taskUnifiedService; pageDataService = domainServices.pageDataService; pageDataPublicService = domainServices.pageDataPublicService; @@ -534,6 +538,8 @@ async function bootstrapUserAuth() { tkmindProxy, scheduleService, scheduledTaskService, + intentDraftService, + taskUnifiedService, sessionSnapshotService, wechatScheduleLlmConfigService, wechatIntentRouter, diff --git a/server/portal-domain-services-bootstrap.mjs b/server/portal-domain-services-bootstrap.mjs index cc19976..4165a4c 100644 --- a/server/portal-domain-services-bootstrap.mjs +++ b/server/portal-domain-services-bootstrap.mjs @@ -26,6 +26,9 @@ import { } from '../plaza-tasks.mjs'; import { createScheduleService } from '../schedule-service.mjs'; import { createScheduledTaskService } from '../scheduled-task-service.mjs'; +import { createIntentDraftService } from '../intent-draft-service.mjs'; +import { createTaskUnifiedService } from '../task-unified-service.mjs'; +import { attachUnifiedTaskSync } from '../task-unified-sync.mjs'; import { createFeedbackService } from '../user-feedback.mjs'; import { PUBLISH_KEY_UUID } from '../user-publish.mjs'; import { initSchema } from '../db.mjs'; @@ -49,6 +52,8 @@ export async function bootstrapPortalDomainServices({ loadMindSpaceConfigCachedFn = loadMindSpaceConfigCached, createScheduleServiceFn = createScheduleService, createScheduledTaskServiceFn = createScheduledTaskService, + createIntentDraftServiceFn = createIntentDraftService, + createTaskUnifiedServiceFn = createTaskUnifiedService, createPageDataServiceFn = createPageDataService, createPageDataPublicServiceFn = createPageDataPublicService, @@ -116,6 +121,24 @@ export async function bootstrapPortalDomainServices({ defaultTimezone: env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai', }); + const intentDraftService = + env.H5_INTENT_TRANSACTION_ENABLED === '1' + ? createIntentDraftServiceFn(pool) + : null; + const taskUnifiedService = + env.H5_UNIFIED_TASKS_ENABLED === '1' + ? createTaskUnifiedServiceFn(pool) + : null; + if (taskUnifiedService) { + attachUnifiedTaskSync({ + scheduleService, + scheduledTaskService, + taskUnifiedService, + pool, + env, + logger, + }); + } const pageDataService = createPageDataServiceFn({ getUserAuth, getPool: () => pool, @@ -286,6 +309,8 @@ export async function bootstrapPortalDomainServices({ mindSpaceAnalyticsConfig: resolvedAnalyticsConfig, scheduleService, scheduledTaskService, + intentDraftService, + taskUnifiedService, pageDataService, pageDataPublicService, feedbackService, diff --git a/server/portal-integration-services-bootstrap.mjs b/server/portal-integration-services-bootstrap.mjs index 1e33879..b663985 100644 --- a/server/portal-integration-services-bootstrap.mjs +++ b/server/portal-integration-services-bootstrap.mjs @@ -29,6 +29,8 @@ export async function bootstrapPortalIntegrationServices({ tkmindProxy, scheduleService, scheduledTaskService = null, + intentDraftService = null, + taskUnifiedService = null, sessionSnapshotService = null, wechatScheduleLlmConfigService, wechatIntentRouter = null, @@ -163,6 +165,14 @@ export async function bootstrapPortalIntegrationServices({ env.H5_SCHEDULE_ENABLED === '1' ? scheduledTaskService : null, + intentDraftService: + env.H5_INTENT_TRANSACTION_ENABLED === '1' + ? intentDraftService + : null, + taskUnifiedService: + env.H5_UNIFIED_TASKS_ENABLED === '1' + ? taskUnifiedService + : null, wechatScheduleLlmConfigService, llmProviderService, chatIntentRouter, diff --git a/task-unified-config.mjs b/task-unified-config.mjs new file mode 100644 index 0000000..f0be2a2 --- /dev/null +++ b/task-unified-config.mjs @@ -0,0 +1,3 @@ +export function isUnifiedTasksEnabled(env = process.env) { + return String(env.H5_UNIFIED_TASKS_ENABLED ?? '').trim() === '1'; +} diff --git a/task-unified-service.mjs b/task-unified-service.mjs new file mode 100644 index 0000000..ba85fca --- /dev/null +++ b/task-unified-service.mjs @@ -0,0 +1,622 @@ +import crypto from 'node:crypto'; + +const DEFAULT_TIMEZONE = 'Asia/Shanghai'; + +function parseJson(value) { + if (value == null || value === '') return null; + if (typeof value === 'object') return value; + try { return JSON.parse(value); } catch { return null; } +} + +function rowToUnifiedTask(row) { + if (!row) return null; + return { + id: row.id, + userId: row.user_id, + type: row.type, + title: row.title, + spec: parseJson(row.spec_json) ?? {}, + trigger: parseJson(row.trigger_json) ?? {}, + action: parseJson(row.action_json) ?? {}, + actionLevel: Number(row.action_level ?? 1), + notifyChannel: row.notify_channel ?? 'both', + status: row.status, + nextRunAt: row.next_run_at == null ? null : Number(row.next_run_at), + lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at), + legacyRef: parseJson(row.legacy_ref_json), + sourceChannel: row.source_channel ?? null, + sourceMessageId: row.source_message_id ?? null, + sourceText: row.source_text ?? null, + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }; +} + +function legacyKey(ref) { + if (!ref?.table || !ref?.id) return null; + return `${ref.table}:${ref.id}`; +} + +function formatTriggerLabel(task, timezone = DEFAULT_TIMEZONE) { + const trigger = task.trigger ?? {}; + if (trigger.at) return trigger.at; + if (trigger.runAtLocal) return trigger.runAtLocal; + if (trigger.hour != null) { + return `${String(trigger.hour).padStart(2, '0')}:${String(trigger.minute ?? 0).padStart(2, '0')}`; + } + if (task.nextRunAt) { + return new Date(task.nextRunAt).toLocaleString('zh-CN', { + timeZone: trigger.timezone || timezone, + hour12: false, + }); + } + return '待确认'; +} + +export function formatUnifiedTaskListReply(tasks = [], { timezone = DEFAULT_TIMEZONE } = {}) { + if (!tasks.length) return '你当前没有进行中的提醒或定时任务。'; + const lines = ['你的任务一览:']; + for (const task of tasks.slice(0, 10)) { + const when = formatTriggerLabel(task, timezone); + const repeat = task.trigger?.repeat ? ` · ${task.trigger.repeat}` : ''; + const typeLabel = { + reminder: '提醒', + todo: '待办', + digest: '待办摘要', + automation: '自动任务', + condition: '条件提醒', + }[task.type] ?? task.type; + lines.push(`- [${typeLabel}] ${task.title}(${when}${repeat})`); + } + return lines.join('\n'); +} + +function mapCommitToUnifiedTask({ userId, kind, committed, timezone = DEFAULT_TIMEZONE }) { + const now = Date.now(); + const base = { + userId, + actionLevel: kind === 'scheduled_task' ? 2 : 1, + notifyChannel: 'wechat', + status: 'active', + sourceChannel: committed?.item?.sourceChannel + ?? committed?.task?.sourceChannel + ?? committed?.subscription?.sourceChannel + ?? 'wechat', + sourceMessageId: committed?.item?.sourceMessageId + ?? committed?.task?.sourceMessageId + ?? committed?.subscription?.sourceMessageId + ?? null, + sourceText: committed?.item?.sourceText + ?? committed?.task?.sourceText + ?? committed?.subscription?.sourceText + ?? null, + createdAt: now, + updatedAt: now, + }; + + if (kind === 'timed_reminder') { + const item = committed.item ?? {}; + const reminder = committed.reminder ?? {}; + return { + ...base, + type: 'reminder', + title: item.title ?? '提醒', + spec: { itemId: item.id, reminderId: reminder.id }, + trigger: { + kind: 'at', + at: item.startAt ?? reminder.remindAt, + timezone: item.timezone ?? timezone, + }, + action: { kind: 'notify', channel: reminder.channel ?? 'wechat' }, + nextRunAt: Number(reminder.remindAt ?? item.startAt ?? null) || null, + legacyRef: { table: 'h5_schedule_reminders', id: reminder.id }, + }; + } + + if (kind === 'create_todo') { + const item = committed.item ?? {}; + return { + ...base, + type: 'todo', + title: item.title ?? '待办', + spec: { itemId: item.id }, + trigger: { kind: 'none', timezone: item.timezone ?? timezone }, + action: { kind: 'none' }, + nextRunAt: null, + legacyRef: { table: 'h5_schedule_items', id: item.id }, + }; + } + + if (kind === 'create_daily_todo_digest') { + const subscription = committed.subscription ?? {}; + return { + ...base, + type: 'digest', + title: '每日待办摘要', + spec: { subscriptionId: subscription.id }, + trigger: { + kind: 'cron', + repeat: 'daily', + hour: subscription.hour, + minute: subscription.minute ?? 0, + timezone: subscription.timezone ?? timezone, + }, + action: { kind: 'notify', channel: subscription.channel ?? 'wechat' }, + nextRunAt: subscription.nextRunAt ?? null, + legacyRef: { table: 'h5_schedule_digest_subscriptions', id: subscription.id }, + }; + } + + if (kind === 'create_balance_alert') { + const subscription = committed.subscription ?? {}; + return { + ...base, + type: 'condition', + title: '余额低提醒', + spec: { subscriptionId: subscription.id, thresholdCents: subscription.thresholdCents }, + trigger: { + kind: 'condition', + watch: 'balance', + op: 'lt', + value: subscription.thresholdCents, + }, + action: { kind: 'notify', channel: subscription.channel ?? 'wechat' }, + nextRunAt: subscription.nextRunAt ?? null, + actionLevel: 2, + legacyRef: { table: 'h5_balance_alert_subscriptions', id: subscription.id }, + }; + } + + if (kind === 'scheduled_task') { + const task = committed.task ?? {}; + return { + ...base, + type: 'automation', + title: task.title ?? '定时自动任务', + spec: { taskSpec: task.taskSpec, scheduledTaskId: task.id }, + trigger: { + kind: 'cron', + repeat: task.recurrence, + hour: task.hour, + minute: task.minute ?? 0, + weekday: task.weekday, + timezone: task.timezone ?? timezone, + }, + action: { kind: 'agent_run', notifyChannel: task.notifyChannel ?? 'both' }, + nextRunAt: task.nextRunAt ?? null, + actionLevel: 2, + legacyRef: { table: 'h5_scheduled_tasks', id: task.id }, + }; + } + + return null; +} + +export function mapLegacyScheduledTaskRow(row, timezone = DEFAULT_TIMEZONE) { + return { + userId: row.user_id, + type: 'automation', + title: row.title, + spec: { taskSpec: row.task_spec, scheduledTaskId: row.id }, + trigger: { + kind: 'cron', + repeat: row.recurrence, + hour: row.hour == null ? null : Number(row.hour), + minute: Number(row.minute ?? 0), + weekday: row.weekday == null ? null : Number(row.weekday), + timezone: row.timezone || timezone, + }, + action: { kind: 'agent_run' }, + actionLevel: 2, + notifyChannel: row.notify_channel ?? 'both', + status: row.status ?? 'active', + nextRunAt: row.next_run_at == null ? null : Number(row.next_run_at), + lastRunAt: row.last_run_at == null ? null : Number(row.last_run_at), + legacyRef: { table: 'h5_scheduled_tasks', id: row.id }, + sourceChannel: row.source_channel ?? null, + sourceMessageId: row.source_message_id ?? null, + sourceText: row.source_text ?? null, + }; +} + +export function mapLegacyReminderRow(row, timezone = DEFAULT_TIMEZONE) { + return { + userId: row.user_id ?? row.userId, + type: 'reminder', + title: row.title, + spec: { itemId: row.item_id, reminderId: row.reminder_id ?? row.id }, + trigger: { + kind: 'at', + at: Number(row.remind_at), + timezone: row.timezone || timezone, + }, + action: { kind: 'notify', channel: row.channel ?? 'wechat' }, + actionLevel: 1, + notifyChannel: row.channel ?? 'wechat', + status: row.status ?? 'pending', + nextRunAt: Number(row.remind_at), + legacyRef: { table: 'h5_schedule_reminders', id: row.reminder_id ?? row.id }, + sourceChannel: row.source_channel ?? null, + sourceMessageId: row.source_message_id ?? null, + sourceText: row.source_text ?? null, + }; +} + +export function mapLegacyDigestRow(row, timezone = DEFAULT_TIMEZONE) { + return { + userId: row.user_id, + type: 'digest', + title: '每日待办摘要', + spec: { subscriptionId: row.id }, + trigger: { + kind: 'cron', + repeat: 'daily', + hour: Number(row.hour), + minute: Number(row.minute ?? 0), + timezone: row.timezone || timezone, + }, + action: { kind: 'notify', channel: row.channel ?? 'wechat' }, + actionLevel: 2, + notifyChannel: row.channel ?? 'wechat', + status: row.status ?? 'active', + nextRunAt: row.next_run_at == null ? null : Number(row.next_run_at), + legacyRef: { table: 'h5_schedule_digest_subscriptions', id: row.id }, + sourceChannel: row.source_channel ?? null, + sourceMessageId: row.source_message_id ?? null, + sourceText: row.source_text ?? null, + }; +} + +export function mapLegacyBalanceAlertRow(row) { + return { + userId: row.user_id, + type: 'condition', + title: '余额低提醒', + spec: { subscriptionId: row.id, thresholdCents: Number(row.threshold_cents) }, + trigger: { + kind: 'condition', + watch: 'balance', + op: 'lt', + value: Number(row.threshold_cents), + }, + action: { kind: 'notify', channel: row.channel ?? 'wechat' }, + actionLevel: 2, + notifyChannel: row.channel ?? 'wechat', + status: row.status ?? 'active', + nextRunAt: row.next_run_at == null ? null : Number(row.next_run_at), + legacyRef: { table: 'h5_balance_alert_subscriptions', id: row.id }, + sourceChannel: row.source_channel ?? null, + sourceMessageId: row.source_message_id ?? null, + sourceText: row.source_text ?? null, + }; +} + +export function mapLegacyTodoItemRow(row, timezone = DEFAULT_TIMEZONE) { + return { + userId: row.user_id, + type: 'todo', + title: row.title, + spec: { itemId: row.id }, + trigger: { kind: 'none', timezone: row.timezone || timezone }, + action: { kind: 'none' }, + actionLevel: 1, + notifyChannel: 'both', + status: row.status ?? 'active', + nextRunAt: null, + legacyRef: { table: 'h5_schedule_items', id: row.id }, + sourceChannel: row.source_channel ?? null, + sourceMessageId: row.source_message_id ?? null, + sourceText: row.source_text ?? null, + }; +} + +export function createTaskUnifiedService(pool, { clock = { now: () => Date.now() } } = {}) { + if (!pool) throw new Error('缺少数据库连接'); + + const insertUnifiedTask = async (task) => { + const id = crypto.randomUUID(); + const now = clock.now(); + await pool.query( + `INSERT INTO h5_tasks + (id, user_id, type, title, spec_json, trigger_json, action_json, action_level, + notify_channel, status, next_run_at, last_run_at, legacy_ref_json, + source_channel, source_message_id, source_text, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + task.userId, + task.type, + task.title, + JSON.stringify(task.spec ?? {}), + JSON.stringify(task.trigger ?? {}), + JSON.stringify(task.action ?? {}), + task.actionLevel ?? 1, + task.notifyChannel ?? 'both', + task.status ?? 'active', + task.nextRunAt ?? null, + task.lastRunAt ?? null, + task.legacyRef ? JSON.stringify(task.legacyRef) : null, + task.sourceChannel ?? null, + task.sourceMessageId ?? null, + task.sourceText ?? null, + now, + now, + ], + ); + const [rows] = await pool.query(`SELECT * FROM h5_tasks WHERE id = ? LIMIT 1`, [id]); + return rowToUnifiedTask(rows?.[0]); + }; + + const upsertUnifiedTask = async (mapped) => { + if (!mapped?.userId || !mapped?.type || !mapped?.title) return null; + if (mapped.legacyRef?.table && mapped.legacyRef?.id) { + const [existing] = await pool.query( + `SELECT * FROM h5_tasks + WHERE user_id = ? AND legacy_ref_json = ? + LIMIT 1`, + [mapped.userId, JSON.stringify(mapped.legacyRef)], + ); + if (existing?.[0]) return rowToUnifiedTask(existing[0]); + } + return insertUnifiedTask(mapped); + }; + + const syncFromCommit = async ({ userId, kind, committed }) => { + const mapped = mapCommitToUnifiedTask({ userId, kind, committed }); + return upsertUnifiedTask(mapped); + }; + + const syncFromLegacyMapped = async (mapped) => upsertUnifiedTask(mapped); + + const markLegacyCancelled = async ({ userId, legacyRef }) => { + if (!userId || !legacyRef?.table || !legacyRef?.id) return 0; + const [result] = await pool.query( + `UPDATE h5_tasks + SET status = 'cancelled', updated_at = ? + WHERE user_id = ? AND legacy_ref_json = ?`, + [clock.now(), userId, JSON.stringify(legacyRef)], + ); + return Number(result?.affectedRows ?? 0); + }; + + const syncLegacyLifecycle = async ({ + userId, + legacyRef, + status, + nextRunAt = undefined, + lastRunAt = undefined, + lastError = undefined, + }) => { + if (!userId || !legacyRef?.table || !legacyRef?.id || !status) return 0; + const sets = ['status = ?', 'updated_at = ?']; + const params = [status, clock.now()]; + if (nextRunAt !== undefined) { + sets.push('next_run_at = ?'); + params.push(nextRunAt); + } + if (lastRunAt !== undefined) { + sets.push('last_run_at = ?'); + params.push(lastRunAt); + } + if (lastError !== undefined) { + const [rows] = await pool.query( + `SELECT spec_json FROM h5_tasks WHERE user_id = ? AND legacy_ref_json = ? LIMIT 1`, + [userId, JSON.stringify(legacyRef)], + ); + const spec = parseJson(rows?.[0]?.spec_json) ?? {}; + spec.lastError = lastError ?? null; + sets.push('spec_json = ?'); + params.push(JSON.stringify(spec)); + } + params.push(userId, JSON.stringify(legacyRef)); + const [result] = await pool.query( + `UPDATE h5_tasks SET ${sets.join(', ')} WHERE user_id = ? AND legacy_ref_json = ?`, + params, + ); + return Number(result?.affectedRows ?? 0); + }; + + const listStoredTasks = async ({ userId, status = 'active', limit = 20 } = {}) => { + const [rows] = await pool.query( + `SELECT * FROM h5_tasks + WHERE user_id = ? AND status = ? + ORDER BY COALESCE(next_run_at, 9223372036854775807), created_at DESC + LIMIT ?`, + [userId, status, Math.max(1, Math.min(100, Number(limit) || 20))], + ); + return rows.map(rowToUnifiedTask); + }; + + const listLegacyTasks = async ({ userId, limit = 20, timezone = DEFAULT_TIMEZONE } = {}) => { + const tasks = []; + const cap = Math.max(1, Math.min(100, Number(limit) || 20)); + + const [automations] = await pool.query( + `SELECT id, user_id, title, task_spec, recurrence, hour, minute, weekday, timezone, + next_run_at, status, source_channel, source_message_id, source_text, created_at, updated_at + FROM h5_scheduled_tasks + WHERE user_id = ? AND status = 'active' + ORDER BY next_run_at ASC + LIMIT ?`, + [userId, cap], + ); + for (const row of automations) { + tasks.push({ + id: `legacy:scheduled:${row.id}`, + ...mapLegacyScheduledTaskRow(row, timezone), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }); + } + + const [reminders] = await pool.query( + `SELECT r.id AS reminder_id, r.user_id, r.remind_at, r.channel, r.status, + i.id AS item_id, i.title, i.timezone, i.source_channel, i.source_message_id, i.source_text, + i.created_at, i.updated_at + FROM h5_schedule_reminders r + JOIN h5_schedule_items i ON i.id = r.item_id + WHERE r.user_id = ? AND r.status IN ('pending', 'locked') AND i.deleted_at IS NULL + ORDER BY r.remind_at ASC + LIMIT ?`, + [userId, cap], + ); + for (const row of reminders) { + tasks.push({ + id: `legacy:reminder:${row.reminder_id}`, + ...mapLegacyReminderRow(row, timezone), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }); + } + + const [digests] = await pool.query( + `SELECT id, hour, minute, timezone, channel, status, next_run_at, source_channel, + source_message_id, source_text, created_at, updated_at + FROM h5_schedule_digest_subscriptions + WHERE user_id = ? AND status = 'active' + LIMIT ?`, + [userId, cap], + ); + for (const row of digests) { + tasks.push({ + id: `legacy:digest:${row.id}`, + ...mapLegacyDigestRow(row, timezone), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }); + } + + const [alerts] = await pool.query( + `SELECT id, user_id, threshold_cents, channel, status, next_run_at, source_channel, + source_message_id, source_text, created_at, updated_at + FROM h5_balance_alert_subscriptions + WHERE user_id = ? AND status = 'active' + LIMIT ?`, + [userId, cap], + ); + for (const row of alerts) { + tasks.push({ + id: `legacy:condition:${row.id}`, + ...mapLegacyBalanceAlertRow(row), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }); + } + + const [todos] = await pool.query( + `SELECT id, user_id, title, timezone, status, source_channel, source_message_id, source_text, + created_at, updated_at + FROM h5_schedule_items + WHERE user_id = ? AND kind = 'task' AND status = 'active' AND deleted_at IS NULL + ORDER BY created_at DESC + LIMIT ?`, + [userId, cap], + ); + for (const row of todos) { + tasks.push({ + id: `legacy:todo:${row.id}`, + ...mapLegacyTodoItemRow(row, timezone), + createdAt: Number(row.created_at), + updatedAt: Number(row.updated_at), + }); + } + + return tasks.slice(0, cap); + }; + + const collectLegacyMigrationCandidates = async ({ userId = null } = {}) => { + const mapped = []; + + const [automations] = userId + ? await pool.query(`SELECT * FROM h5_scheduled_tasks WHERE user_id = ? AND status = 'active'`, [userId]) + : await pool.query(`SELECT * FROM h5_scheduled_tasks WHERE status = 'active'`); + mapped.push(...automations.map((row) => mapLegacyScheduledTaskRow(row))); + + const reminderSql = `SELECT r.id AS reminder_id, r.user_id, r.remind_at, r.channel, r.status, + i.id AS item_id, i.title, i.timezone, i.source_channel, i.source_message_id, i.source_text + FROM h5_schedule_reminders r + JOIN h5_schedule_items i ON i.id = r.item_id + WHERE ${userId ? 'r.user_id = ? AND' : ''} r.status IN ('pending', 'locked') AND i.deleted_at IS NULL`; + const [reminders] = userId + ? await pool.query(reminderSql, [userId]) + : await pool.query(reminderSql); + mapped.push(...reminders.map((row) => mapLegacyReminderRow(row))); + + const [digests] = userId + ? await pool.query(`SELECT * FROM h5_schedule_digest_subscriptions WHERE user_id = ? AND status = 'active'`, [userId]) + : await pool.query(`SELECT * FROM h5_schedule_digest_subscriptions WHERE status = 'active'`); + mapped.push(...digests.map((row) => mapLegacyDigestRow(row))); + + const [alerts] = userId + ? await pool.query(`SELECT * FROM h5_balance_alert_subscriptions WHERE user_id = ? AND status = 'active'`, [userId]) + : await pool.query(`SELECT * FROM h5_balance_alert_subscriptions WHERE status = 'active'`); + mapped.push(...alerts.map((row) => mapLegacyBalanceAlertRow(row))); + + const [todos] = userId + ? await pool.query(`SELECT * FROM h5_schedule_items WHERE user_id = ? AND kind = 'task' AND status = 'active' AND deleted_at IS NULL`, [userId]) + : await pool.query(`SELECT * FROM h5_schedule_items WHERE kind = 'task' AND status = 'active' AND deleted_at IS NULL`); + mapped.push(...todos.map((row) => mapLegacyTodoItemRow(row))); + + return mapped; + }; + + const migrateLegacyTasks = async ({ userId = null, dryRun = true } = {}) => { + const candidates = await collectLegacyMigrationCandidates({ userId }); + const stats = { scanned: candidates.length, inserted: 0, skipped: 0, errors: 0 }; + if (dryRun) return { ...stats, dryRun: true, sample: candidates.slice(0, 5) }; + + for (const candidate of candidates) { + try { + const before = await pool.query( + `SELECT id FROM h5_tasks WHERE user_id = ? AND legacy_ref_json = ? LIMIT 1`, + [candidate.userId, JSON.stringify(candidate.legacyRef)], + ); + if (before?.[0]?.[0]) { + stats.skipped += 1; + continue; + } + await upsertUnifiedTask(candidate); + stats.inserted += 1; + } catch { + stats.errors += 1; + } + } + return { ...stats, dryRun: false }; + }; + + const listUserTasks = async ({ userId, status = 'active', limit = 20, timezone = DEFAULT_TIMEZONE } = {}) => { + if (!userId) throw new Error('缺少用户'); + const stored = await listStoredTasks({ userId, status, limit }); + const mirrored = new Set( + stored.map((task) => legacyKey(task.legacyRef)).filter(Boolean), + ); + const legacy = await listLegacyTasks({ userId, limit, timezone }); + const merged = [...stored]; + for (const task of legacy) { + const key = legacyKey(task.legacyRef); + if (key && mirrored.has(key)) continue; + merged.push(task); + } + merged.sort((a, b) => { + const aRun = a.nextRunAt ?? Number.MAX_SAFE_INTEGER; + const bRun = b.nextRunAt ?? Number.MAX_SAFE_INTEGER; + if (aRun !== bRun) return aRun - bRun; + return b.createdAt - a.createdAt; + }); + return merged.slice(0, Math.max(1, Math.min(100, Number(limit) || 20))); + }; + + return { + syncFromCommit, + syncFromLegacyMapped, + markLegacyCancelled, + syncLegacyLifecycle, + migrateLegacyTasks, + listUserTasks, + listStoredTasks, + listLegacyTasks, + formatUnifiedTaskListReply, + }; +} + +export { formatTriggerLabel, mapCommitToUnifiedTask }; diff --git a/task-unified-service.test.mjs b/task-unified-service.test.mjs new file mode 100644 index 0000000..82033c8 --- /dev/null +++ b/task-unified-service.test.mjs @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createTaskUnifiedService, + formatUnifiedTaskListReply, + mapCommitToUnifiedTask, +} from './task-unified-service.mjs'; + +test('mapCommitToUnifiedTask maps timed reminder commit', () => { + const mapped = mapCommitToUnifiedTask({ + userId: 'user-1', + kind: 'timed_reminder', + committed: { + item: { id: 'item-1', title: '项目计划例会', startAt: 1000, timezone: 'Asia/Shanghai' }, + reminder: { id: 'rem-1', remindAt: 1000, channel: 'wechat' }, + }, + }); + assert.equal(mapped.type, 'reminder'); + assert.equal(mapped.title, '项目计划例会'); + assert.deepEqual(mapped.legacyRef, { table: 'h5_schedule_reminders', id: 'rem-1' }); +}); + +test('mapCommitToUnifiedTask maps scheduled task commit', () => { + const mapped = mapCommitToUnifiedTask({ + userId: 'user-1', + kind: 'scheduled_task', + committed: { + task: { + id: 'task-1', + title: '每日新闻页', + taskSpec: '搜索今日新闻并生成 HTML 页面', + recurrence: 'daily', + hour: 6, + minute: 0, + nextRunAt: 2000, + timezone: 'Asia/Shanghai', + }, + }, + }); + assert.equal(mapped.type, 'automation'); + assert.equal(mapped.actionLevel, 2); +}); + +test('formatUnifiedTaskListReply renders mixed task types', () => { + const text = formatUnifiedTaskListReply([ + { + type: 'automation', + title: '每日新闻页', + trigger: { repeat: 'daily', hour: 6, minute: 0 }, + nextRunAt: null, + }, + { + type: 'reminder', + title: '项目计划例会', + trigger: { at: 1000, timezone: 'Asia/Shanghai' }, + nextRunAt: 1000, + }, + ]); + assert.match(text, /任务一览/); + assert.match(text, /每日新闻页/); + assert.match(text, /项目计划例会/); +}); + +function createMemoryPool() { + const tasks = []; + return { + tasks, + async query(sql, params = []) { + if (sql.includes('INSERT INTO h5_tasks')) { + const row = { + id: params[0], + user_id: params[1], + type: params[2], + title: params[3], + spec_json: params[4], + trigger_json: params[5], + action_json: params[6], + action_level: params[7], + notify_channel: params[8], + status: params[9], + next_run_at: params[10], + last_run_at: params[11], + legacy_ref_json: params[12], + source_channel: params[13], + source_message_id: params[14], + source_text: params[15], + created_at: params[16], + updated_at: params[17], + }; + tasks.push(row); + return [{ affectedRows: 1 }]; + } + if (sql.includes('SELECT id FROM h5_tasks') && sql.includes('legacy_ref_json')) { + const ref = params[1]; + const hit = tasks.find((row) => row.user_id === params[0] && row.legacy_ref_json === ref); + return [[hit].filter(Boolean)]; + } + if (sql.includes('SELECT * FROM h5_tasks WHERE id = ?')) { + return [[tasks.find((row) => row.id === params[0])].filter(Boolean)]; + } + if (sql.includes('SELECT * FROM h5_tasks') && sql.includes('user_id = ?')) { + return [tasks.filter((row) => row.user_id === params[0] && row.status === params[1])]; + } + if (sql.includes('FROM h5_scheduled_tasks')) return [[]]; + if (sql.includes('FROM h5_schedule_reminders')) return [[]]; + if (sql.includes('FROM h5_schedule_digest_subscriptions')) return [[]]; + throw new Error(`Unexpected SQL: ${sql}`); + }, + }; +} + +test('syncFromCommit inserts unified task row', async () => { + const pool = createMemoryPool(); + const service = createTaskUnifiedService(pool, { clock: { now: () => 3000 } }); + const row = await service.syncFromCommit({ + userId: 'user-1', + kind: 'create_todo', + committed: { item: { id: 'item-1', title: '跟进合同', timezone: 'Asia/Shanghai' } }, + }); + assert.equal(row.type, 'todo'); + assert.equal(row.title, '跟进合同'); + assert.equal(pool.tasks.length, 1); +}); + +test('listUserTasks merges stored and legacy without duplicates', async () => { + const pool = createMemoryPool(); + pool.query = async (sql, params = []) => { + if (sql.includes('INSERT INTO h5_tasks') || sql.includes('SELECT id FROM h5_tasks')) { + return createMemoryPool().query(sql, params); + } + if (sql.includes('SELECT * FROM h5_tasks') && sql.includes('user_id = ?')) { + return [[{ + id: 'unified-1', + user_id: 'user-1', + type: 'automation', + title: '每日新闻页', + spec_json: '{}', + trigger_json: '{"repeat":"daily","hour":6,"minute":0}', + action_json: '{"kind":"agent_run"}', + action_level: 2, + notify_channel: 'both', + status: 'active', + next_run_at: 5000, + last_run_at: null, + legacy_ref_json: '{"table":"h5_scheduled_tasks","id":"task-1"}', + source_channel: 'wechat', + source_message_id: null, + source_text: null, + created_at: 1000, + updated_at: 1000, + }]]; + } + if (sql.includes('FROM h5_scheduled_tasks')) { + return [[{ + id: 'task-1', + user_id: 'user-1', + title: '每日新闻页', + task_spec: 'news', + recurrence: 'daily', + hour: 6, + minute: 0, + weekday: null, + timezone: 'Asia/Shanghai', + next_run_at: 5000, + status: 'active', + source_channel: 'wechat', + source_message_id: null, + source_text: null, + created_at: 1000, + updated_at: 1000, + }]]; + } + if (sql.includes('FROM h5_schedule_reminders')) return [[]]; + if (sql.includes('FROM h5_schedule_digest_subscriptions')) return [[]]; + if (sql.includes('FROM h5_balance_alert_subscriptions')) return [[]]; + if (sql.includes('FROM h5_schedule_items') && sql.includes("kind = 'task'")) return [[]]; + throw new Error(`Unexpected SQL: ${sql}`); + }; + const service = createTaskUnifiedService(pool); + const tasks = await service.listUserTasks({ userId: 'user-1', limit: 10 }); + assert.equal(tasks.length, 1); + assert.equal(tasks[0].id, 'unified-1'); +}); diff --git a/task-unified-sync.mjs b/task-unified-sync.mjs new file mode 100644 index 0000000..4233648 --- /dev/null +++ b/task-unified-sync.mjs @@ -0,0 +1,183 @@ +import { isUnifiedTasksEnabled } from './task-unified-config.mjs'; +import { mapLegacyTodoItemRow } from './task-unified-service.mjs'; + +function syncOpen(taskUnifiedService, fn) { + return fn().catch((err) => { + console.warn?.( + '[task-unified-sync] failed open:', + err instanceof Error ? err.message : err, + ); + }); +} + +export function attachUnifiedTaskSync({ + scheduleService, + scheduledTaskService, + taskUnifiedService, + pool, + env = process.env, + logger = console, +}) { + if (!isUnifiedTasksEnabled(env) || !taskUnifiedService || !pool) { + return { scheduleService, scheduledTaskService }; + } + + const warn = (...args) => logger.warn?.(...args); + + if (scheduleService?.createItem) { + const original = scheduleService.createItem.bind(scheduleService); + scheduleService.createItem = async (payload) => { + const item = await original(payload); + if (item?.kind === 'task') { + await syncOpen(taskUnifiedService, () => + taskUnifiedService.syncFromLegacyMapped(mapLegacyTodoItemRow({ + id: item.id, + user_id: item.userId, + title: item.title, + timezone: item.timezone, + status: item.status, + source_channel: payload.sourceChannel ?? null, + source_message_id: payload.sourceMessageId ?? null, + source_text: payload.sourceText ?? null, + }))); + } + return item; + }; + } + + if (scheduleService?.createReminder) { + const original = scheduleService.createReminder.bind(scheduleService); + scheduleService.createReminder = async (payload) => { + const reminder = await original(payload); + try { + const [rows] = await pool.query( + `SELECT i.*, r.id AS reminder_id, r.remind_at, r.channel, r.status AS reminder_status + FROM h5_schedule_items i + JOIN h5_schedule_reminders r ON r.item_id = i.id + WHERE r.id = ? + LIMIT 1`, + [reminder.id], + ); + const row = rows?.[0]; + if (row) { + await taskUnifiedService.syncFromCommit({ + userId: payload.userId, + kind: 'timed_reminder', + committed: { + item: { + id: row.id, + title: row.title, + startAt: row.start_at ?? reminder.remindAt, + timezone: row.timezone, + sourceChannel: row.source_channel, + sourceMessageId: row.source_message_id, + sourceText: row.source_text, + }, + reminder, + }, + }); + } + } catch (err) { + warn('[task-unified-sync] reminder sync failed:', err instanceof Error ? err.message : err); + } + return reminder; + }; + } + + if (scheduleService?.createDailyTodoDigest) { + const original = scheduleService.createDailyTodoDigest.bind(scheduleService); + scheduleService.createDailyTodoDigest = async (payload) => { + const subscription = await original(payload); + await syncOpen(taskUnifiedService, () => + taskUnifiedService.syncFromCommit({ + userId: payload.userId, + kind: 'create_daily_todo_digest', + committed: { subscription }, + })); + return subscription; + }; + } + + if (scheduleService?.createBalanceLowAlert) { + const original = scheduleService.createBalanceLowAlert.bind(scheduleService); + scheduleService.createBalanceLowAlert = async (payload) => { + const subscription = await original(payload); + await syncOpen(taskUnifiedService, () => + taskUnifiedService.syncFromCommit({ + userId: payload.userId, + kind: 'create_balance_alert', + committed: { subscription }, + })); + return subscription; + }; + } + + if (scheduledTaskService?.createTask) { + const original = scheduledTaskService.createTask.bind(scheduledTaskService); + scheduledTaskService.createTask = async (payload) => { + const task = await original(payload); + await syncOpen(taskUnifiedService, () => + taskUnifiedService.syncFromCommit({ + userId: payload.userId, + kind: 'scheduled_task', + committed: { task }, + })); + return task; + }; + } + + if (scheduledTaskService?.cancelTask) { + const original = scheduledTaskService.cancelTask.bind(scheduledTaskService); + scheduledTaskService.cancelTask = async (payload) => { + const task = await original(payload); + if (task?.id && payload?.userId) { + await syncOpen(taskUnifiedService, () => + taskUnifiedService.markLegacyCancelled({ + userId: payload.userId, + legacyRef: { table: 'h5_scheduled_tasks', id: task.id }, + })); + } + return task; + }; + } + + const automationLegacyRef = (task) => ({ + table: 'h5_scheduled_tasks', + id: task.id, + }); + + if (scheduledTaskService?.markTaskSucceeded) { + const original = scheduledTaskService.markTaskSucceeded.bind(scheduledTaskService); + scheduledTaskService.markTaskSucceeded = async (task, options) => { + const updated = await original(task, options); + await syncOpen(taskUnifiedService, () => + taskUnifiedService.syncLegacyLifecycle({ + userId: updated.userId, + legacyRef: automationLegacyRef(updated), + status: updated.status === 'completed' ? 'completed' : 'active', + nextRunAt: updated.nextRunAt ?? null, + lastRunAt: updated.lastRunAt ?? null, + lastError: null, + })); + return updated; + }; + } + + if (scheduledTaskService?.markTaskFailed) { + const original = scheduledTaskService.markTaskFailed.bind(scheduledTaskService); + scheduledTaskService.markTaskFailed = async (task, error, options) => { + const updated = await original(task, error, options); + await syncOpen(taskUnifiedService, () => + taskUnifiedService.syncLegacyLifecycle({ + userId: updated.userId, + legacyRef: automationLegacyRef(updated), + status: updated.status === 'failed' ? 'failed' : 'active', + nextRunAt: updated.nextRunAt ?? null, + lastError: updated.lastError ?? null, + })); + return updated; + }; + } + + return { scheduleService, scheduledTaskService }; +} diff --git a/task-unified-sync.test.mjs b/task-unified-sync.test.mjs new file mode 100644 index 0000000..55b41a6 --- /dev/null +++ b/task-unified-sync.test.mjs @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { attachUnifiedTaskSync } from './task-unified-sync.mjs'; + +test('attachUnifiedTaskSync wraps scheduled task create when enabled', async () => { + const syncCalls = []; + const scheduledTaskService = { + async createTask(payload) { + return { + id: 'task-1', + userId: payload.userId, + title: payload.title, + taskSpec: payload.taskSpec, + recurrence: 'daily', + hour: 6, + minute: 0, + nextRunAt: 5000, + timezone: 'Asia/Shanghai', + notifyChannel: 'both', + sourceChannel: payload.sourceChannel, + }; + }, + }; + const taskUnifiedService = { + async syncFromCommit(payload) { + syncCalls.push(payload); + return { id: 'unified-1' }; + }, + }; + + attachUnifiedTaskSync({ + scheduleService: {}, + scheduledTaskService, + taskUnifiedService, + pool: { async query() { return [[]]; } }, + env: { H5_UNIFIED_TASKS_ENABLED: '1' }, + }); + + await scheduledTaskService.createTask({ + userId: 'user-1', + title: '每日新闻', + taskSpec: 'news', + sourceChannel: 'wechat', + }); + + assert.equal(syncCalls.length, 1); + assert.equal(syncCalls[0].kind, 'scheduled_task'); +}); + +test('attachUnifiedTaskSync syncs lifecycle on markTaskSucceeded', async () => { + const lifecycleCalls = []; + const scheduledTaskService = { + async markTaskSucceeded(task) { + return { + ...task, + status: 'active', + nextRunAt: 9000, + lastRunAt: 8000, + }; + }, + }; + attachUnifiedTaskSync({ + scheduleService: {}, + scheduledTaskService, + taskUnifiedService: { + async syncLegacyLifecycle(payload) { + lifecycleCalls.push(payload); + }, + }, + pool: { async query() { return [[]]; } }, + env: { H5_UNIFIED_TASKS_ENABLED: '1' }, + }); + await scheduledTaskService.markTaskSucceeded({ + id: 'task-1', + userId: 'user-1', + recurrence: 'daily', + }); + assert.equal(lifecycleCalls.length, 1); + assert.equal(lifecycleCalls[0].status, 'active'); +}); + +test('attachUnifiedTaskSync is noop when feature disabled', async () => { + const syncCalls = []; + const scheduledTaskService = { + async createTask() { + return { id: 'task-1' }; + }, + }; + attachUnifiedTaskSync({ + scheduleService: {}, + scheduledTaskService, + taskUnifiedService: { + async syncFromCommit() { + syncCalls.push(true); + }, + }, + pool: { async query() { return [[]]; } }, + env: { H5_UNIFIED_TASKS_ENABLED: '0' }, + }); + await scheduledTaskService.createTask({ userId: 'user-1' }); + assert.equal(syncCalls.length, 0); +}); diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 9d7d7d0..6671260 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -28,6 +28,7 @@ import { } from './wechat/customer-service-deferred.mjs'; import { guardScheduleConfirmationReply } from './wechat/handlers/schedule-guard.mjs'; import { handleWechatScheduleIntent } from './wechat/handlers/schedule.mjs'; +import { handleWechatIntentTransaction } from './wechat/handlers/intent-transaction.mjs'; import { handleWechatScheduledTaskIntent } from './wechat/handlers/scheduled-task.mjs'; import { buildStatusText, @@ -1598,6 +1599,8 @@ export function createWechatMpService({ submitSessionReply = null, scheduleService = null, scheduledTaskService = null, + intentDraftService = null, + taskUnifiedService = null, wechatScheduleLlmConfigService = null, llmProviderService = null, chatIntentRouter = null, @@ -3993,6 +3996,42 @@ export function createWechatMpService({ }; } + const intentTransactionReply = + intent.msgType === 'text' || intent.msgType === 'voice' + ? await handleWechatIntentTransaction({ + intent, + user: boundUser, + intentDraftService, + taskUnifiedService, + scheduleService, + scheduledTaskService, + logger, + }).catch((err) => { + logger.warn?.( + 'WeChat MP intent transaction handling failed open:', + err instanceof Error ? err.message : err, + ); + return null; + }) + : null; + if (intentTransactionReply) { + if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') { + await userAuth.finishWechatMpMessage({ + appId: config.appId, + openid: inbound.fromUserName, + msgId: inbound.msgId, + status: 'done', + agentSessionId: null, + }); + } + return { + ok: true, + status: 200, + contentType: 'application/xml; charset=utf-8', + body: await buildPassiveReplyBody(intentTransactionReply), + }; + } + const scheduledTaskReply = intent.msgType === 'text' || intent.msgType === 'voice' ? await handleWechatScheduledTaskIntent({ diff --git a/wechat/handlers/intent-transaction.mjs b/wechat/handlers/intent-transaction.mjs new file mode 100644 index 0000000..435d778 --- /dev/null +++ b/wechat/handlers/intent-transaction.mjs @@ -0,0 +1,223 @@ +import { classifyUserIntent, inferActionLevel } from '../../intent-classifier.mjs'; +import { commitIntentDraft } from '../../intent-transaction-commit.mjs'; +import { + formatDraftCancelledReply, + formatDraftCommittedReply, + formatIntentActionCard, + parseDraftUserReply, +} from '../../intent-action-card.mjs'; +import { formatQueryGuardReply } from '../../intent-query-guard.mjs'; +import { isIntentTransactionEnabled } from '../../intent-transaction-config.mjs'; +import { buildScheduledTaskCreatePayload } from '../../scheduled-task-intent.mjs'; +import { formatScheduledTaskCreateReply } from '../../scheduled-task-intent.mjs'; +import { isScheduledTaskWorkerEnabled, scheduledTaskWorkerDisabledMessage } from '../../scheduled-task-worker-config.mjs'; + +function buildDraftPayload(classification, { intent, user, timezone }) { + const { kind, detail } = classification; + const base = { + sourceChannel: 'wechat', + sourceMessageId: intent.msgId ?? null, + sourceText: intent.agentText, + timezone, + }; + + if (kind === 'timed_reminder') { + return { + ...base, + title: detail.title, + remindLocal: detail.remindLocal, + }; + } + if (kind === 'create_todo') { + return { ...base, title: detail.title }; + } + if (kind === 'create_daily_todo_digest') { + return { ...base, hour: detail.hour, minute: detail.minute ?? 0 }; + } + if (kind === 'create_balance_alert') { + return { ...base, thresholdCents: detail.thresholdCents }; + } + if (kind === 'scheduled_task') { + return { + ...base, + intentDetail: detail, + createPayload: buildScheduledTaskCreatePayload(detail, { + userId: user.userId, + sourceChannel: 'wechat', + sourceMessageId: intent.msgId ?? null, + sourceText: intent.agentText, + timezone, + }), + }; + } + return base; +} + +function buildCard(classification, draftPayload, actionLevel, text) { + const { layer, kind, detail, clarify } = classification; + if (kind === 'clarify' || classification.layer === 'ambiguous') { + return formatIntentActionCard({ clarify: ['notify_vs_act'] }); + } + if (clarify?.length) { + return formatIntentActionCard({ clarify }); + } + + const title = detail?.title ?? detail?.taskSpec?.slice?.(0, 80) ?? draftPayload.title ?? '待确认任务'; + const trigger = detail?.remindLocal + ? { at: detail.remindLocal } + : { hour: detail?.hour, minute: detail?.minute ?? 0 }; + const frequency = /(?:每天|每日)/u.test(text) + ? '每天自动执行' + : detail?.recurrence === 'weekly' + ? '每周自动执行' + : detail?.recurrence === 'daily' + ? '每天自动执行' + : null; + + const actions = kind === 'scheduled_task' + ? ['到点自动执行任务', '推送执行结果到微信'] + : kind === 'timed_reminder' + ? ['到点发送微信提醒'] + : kind === 'create_daily_todo_digest' + ? ['到点推送当天待办摘要'] + : kind === 'create_balance_alert' + ? ['余额低于阈值时发送微信提醒'] + : ['记录到待办列表']; + + return formatIntentActionCard({ + title, + layer, + actionLevel, + trigger, + actions, + frequency, + }); +} + +export async function handleWechatIntentTransaction({ + intent, + user, + intentDraftService, + taskUnifiedService = null, + scheduleService, + scheduledTaskService, + env = process.env, + logger = console, +}) { + if (!isIntentTransactionEnabled(env) || !intentDraftService) return null; + const timezone = env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; + const text = intent.agentText; + + try { + const pending = await intentDraftService.getPendingDraft(user.userId); + const replyKind = parseDraftUserReply(text); + if (pending && replyKind) { + if (replyKind === 'cancel') { + await intentDraftService.cancelDraft(pending.id, user.userId); + return formatDraftCancelledReply(); + } + if (replyKind === 'modify') { + await intentDraftService.cancelDraft(pending.id, user.userId, { reason: 'user_modify' }); + return '好的,请重新发送完整的安排,例如「下午 2 点半提醒我开项目计划例会」。'; + } + if (replyKind === 'confirm') { + if (pending.layer === 'ambiguous' || pending.draftType === 'clarify') { + return '我还需要确认:你是要「到点提醒」还是「到点自动执行并交付结果」?请补充后再发「确认」。'; + } + const committed = await commitIntentDraft({ + draft: pending, + userId: user.userId, + scheduleService, + scheduledTaskService, + taskUnifiedService, + timezone, + env, + }); + await intentDraftService.markDraftCommitted(pending.id, user.userId, committed); + if (committed.kind === 'scheduled_task') { + const workerWarning = isScheduledTaskWorkerEnabled(env) + ? null + : scheduledTaskWorkerDisabledMessage(env); + let reply = formatScheduledTaskCreateReply(committed.task, { workerWarning }); + reply = `${reply}\n\n(已通过确认卡片写入)`; + return reply; + } + return formatDraftCommittedReply({ title: pending.title, kind: pending.draftType }); + } + } + + const classification = classifyUserIntent(text, { timezone }); + const { layer, kind, action } = classification; + + if (layer === 'L0') { + return formatQueryGuardReply(text, { + scheduleService, + scheduledTaskService, + taskUnifiedService, + userId: user.userId, + timezone, + env, + }); + } + + if (layer === 'L3' || layer === null || kind === 'agent_schedule' || kind === 'agent_automation') { + return null; + } + + if (kind === 'manage') { + return null; + } + + if (layer === 'ambiguous' || kind === 'clarify') { + if (classification.clarify?.length && kind !== 'clarify') { + return buildCard(classification, {}, inferActionLevel(classification, text), text); + } + await intentDraftService.createDraft({ + userId: user.userId, + layer: 'ambiguous', + draftType: 'clarify', + actionLevel: 2, + title: '待确认意图', + payload: { clarify: ['notify_vs_act'], sourceText: text }, + cardText: buildCard({ layer: 'ambiguous', kind: 'clarify', clarify: ['notify_vs_act'] }, {}, 2, text), + sourceMessageId: intent.msgId ?? null, + sourceText: text, + }); + return buildCard({ layer: 'ambiguous', kind: 'clarify', clarify: ['notify_vs_act'] }, {}, 2, text); + } + + const draftableKinds = new Set([ + 'timed_reminder', + 'create_todo', + 'create_daily_todo_digest', + 'create_balance_alert', + 'scheduled_task', + ]); + if (!draftableKinds.has(kind)) return null; + if (classification.clarify?.length) { + return buildCard(classification, {}, inferActionLevel(classification, text), text); + } + + const actionLevel = inferActionLevel(classification, text); + const payload = buildDraftPayload(classification, { intent, user, timezone }); + const cardText = buildCard(classification, payload, actionLevel, text); + await intentDraftService.createDraft({ + userId: user.userId, + layer, + draftType: kind, + actionLevel, + title: payload.title ?? payload.createPayload?.title ?? classification.detail?.title ?? '待确认任务', + payload, + cardText, + sourceMessageId: intent.msgId ?? null, + sourceText: text, + }); + return cardText; + } catch (err) { + logger.warn?.( + '[wechat-intent-transaction] failed:', + err instanceof Error ? err.message : err, + ); + return `设置预览失败:${err instanceof Error ? err.message : String(err)}`; + } +} diff --git a/wechat/handlers/intent-transaction.test.mjs b/wechat/handlers/intent-transaction.test.mjs new file mode 100644 index 0000000..b085b84 --- /dev/null +++ b/wechat/handlers/intent-transaction.test.mjs @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handleWechatIntentTransaction } from './intent-transaction.mjs'; + +function createDraftStore() { + let pending = null; + return { + async getPendingDraft(userId) { + return pending?.userId === userId ? pending : null; + }, + async createDraft(payload) { + pending = { + id: 'draft-1', + userId: payload.userId, + layer: payload.layer, + draftType: payload.draftType, + actionLevel: payload.actionLevel, + title: payload.title, + payload: payload.payload, + cardText: payload.cardText, + status: 'draft', + }; + return pending; + }, + async cancelDraft(id, userId) { + if (pending?.id === id && pending.userId === userId) pending = null; + return { id, status: 'cancelled' }; + }, + async markDraftCommitted(id, userId, committedRef) { + if (pending?.id === id && pending.userId === userId) { + pending = { ...pending, status: 'committed', committedRef }; + } + return pending; + }, + }; +} + +function createScheduleService() { + return { + async createItem(payload) { + return { id: 'item-1', ...payload }; + }, + async createReminder(payload) { + return { id: 'reminder-1', ...payload }; + }, + buildTodoDigestText() { + return '今天有 1 条待办。'; + }, + }; +} + +const enabledEnv = { H5_INTENT_TRANSACTION_ENABLED: '1', H5_DEFAULT_TIMEZONE: 'Asia/Shanghai' }; + +test('intent transaction returns null when feature disabled', async () => { + const reply = await handleWechatIntentTransaction({ + intent: { agentText: '下午2点半提醒我开会', msgId: 'm1' }, + user: { userId: 'user-1' }, + intentDraftService: createDraftStore(), + scheduleService: createScheduleService(), + env: { H5_INTENT_TRANSACTION_ENABLED: '0' }, + }); + assert.equal(reply, null); +}); + +test('intent transaction creates action card for simple timed reminder', async () => { + const drafts = createDraftStore(); + const reply = await handleWechatIntentTransaction({ + intent: { agentText: '下午2点半提醒我开项目计划例会', msgId: 'm2' }, + user: { userId: 'user-1' }, + intentDraftService: drafts, + scheduleService: createScheduleService(), + env: enabledEnv, + }); + assert.match(reply, /我准备执行/); + assert.match(reply, /确认/); + const pending = await drafts.getPendingDraft('user-1'); + assert.equal(pending.draftType, 'timed_reminder'); +}); + +test('intent transaction commits draft after user confirms', async () => { + const drafts = createDraftStore(); + const scheduleService = createScheduleService(); + const calls = []; + scheduleService.createItem = async (payload) => { + calls.push(['createItem', payload.title]); + return { id: 'item-1', ...payload }; + }; + scheduleService.createReminder = async (payload) => { + calls.push(['createReminder', payload.itemId]); + return { id: 'reminder-1', ...payload }; + }; + + await handleWechatIntentTransaction({ + intent: { agentText: '下午2点半提醒我开项目计划例会', msgId: 'm3' }, + user: { userId: 'user-1' }, + intentDraftService: drafts, + scheduleService, + env: enabledEnv, + }); + const reply = await handleWechatIntentTransaction({ + intent: { agentText: '确认', msgId: 'm4' }, + user: { userId: 'user-1' }, + intentDraftService: drafts, + scheduleService, + env: enabledEnv, + }); + assert.match(reply, /已设置提醒/); + assert.equal(calls.length, 2); +}); + +test('intent transaction asks for slot fill without creating draft', async () => { + const drafts = createDraftStore(); + const reply = await handleWechatIntentTransaction({ + intent: { agentText: '设置提醒', msgId: 'm6' }, + user: { userId: 'user-2' }, + intentDraftService: drafts, + scheduleService: createScheduleService(), + env: enabledEnv, + }); + assert.match(reply, /补充/); + assert.equal(await drafts.getPendingDraft('user-2'), null); +}); + +test('intent transaction answers query guard without creating draft', async () => { + const drafts = createDraftStore(); + const reply = await handleWechatIntentTransaction({ + intent: { agentText: '看看我的待办', msgId: 'm5' }, + user: { userId: 'user-1' }, + intentDraftService: drafts, + scheduleService: createScheduleService(), + env: enabledEnv, + }); + assert.match(reply, /待办/); + assert.equal(await drafts.getPendingDraft('user-1'), null); +}); diff --git a/wechat/handlers/schedule.mjs b/wechat/handlers/schedule.mjs index 74c8d6c..80e00f3 100644 --- a/wechat/handlers/schedule.mjs +++ b/wechat/handlers/schedule.mjs @@ -1,4 +1,5 @@ import { isScheduleIntent, parseScheduleIntent } from '../../schedule-intent.mjs'; +import { formatLocalTime, resolveScheduleTimestamp } from '../../schedule-time.mjs'; function normalizeHour(value) { const hour = Number(value); @@ -112,7 +113,7 @@ async function resolveScheduleIntent({ llmProviderService, logger, }) { - const ruleIntent = parseScheduleIntent(text); + const ruleIntent = parseScheduleIntent(text, { timezone: process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai' }); if (isScheduleIntent(ruleIntent)) return ruleIntent; if (!wechatScheduleLlmConfigService || !llmProviderService) return ruleIntent; @@ -169,6 +170,39 @@ export async function handleWechatScheduleIntent({ const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; + if (scheduleIntent.action === 'create_timed_reminder') { + if (scheduleIntent.needsClarification?.includes('reminder_title')) { + return '可以。请告诉我要提醒什么,例如「下午 2 点半项目计划例会」。'; + } + const remindAt = resolveScheduleTimestamp({ + localString: scheduleIntent.remindLocal, + timezone, + fieldName: '提醒时间', + }); + const item = await scheduleService.createItem({ + userId: user.userId, + kind: 'event', + title: scheduleIntent.title, + startAt: remindAt, + timezone, + sourceChannel: 'wechat', + sourceMessageId: intent.msgId || null, + sourceText: intent.agentText, + metadata: { + source: 'wechat_mp', + }, + }); + await scheduleService.createReminder({ + userId: user.userId, + itemId: item.id, + remindAt, + channel: 'wechat', + }); + const timeLabel = formatLocalTime(remindAt, timezone); + const dateLabel = String(scheduleIntent.remindLocal ?? '').slice(0, 10); + return `已设置提醒:${scheduleIntent.title}(${dateLabel} ${timeLabel},${timezone})。到点我会通过服务号提醒你。`; + } + if (scheduleIntent.action === 'create_todo') { if (scheduleIntent.needsClarification?.includes('todo_title')) { return '可以。你想让我记哪一条待办?例如“帮我记一下 跟段吃饭”。'; diff --git a/wechat/handlers/schedule.test.mjs b/wechat/handlers/schedule.test.mjs index cd5b627..4b83275 100644 --- a/wechat/handlers/schedule.test.mjs +++ b/wechat/handlers/schedule.test.mjs @@ -7,6 +7,9 @@ function createScheduleService() { async createItem(payload) { return { id: 'item-1', ...payload }; }, + async createReminder(payload) { + return { id: 'reminder-1', ...payload }; + }, async createDailyTodoDigest(payload) { return { id: 'digest-1', minute: payload.minute, hour: payload.hour, ...payload }; }, @@ -136,9 +139,35 @@ test('schedule handler falls back quietly when llm request throws', async () => assert.match(warnings[0], /fetch failed/); }); -test('schedule handler falls through for llm schedule_agent results', async () => { +test('schedule handler directly creates simple timed reminder', async () => { + const calls = []; const reply = await handleWechatScheduleIntent({ - intent: { agentText: '明天下午三点提醒我开会', msgId: 'msg-3' }, + intent: { + agentText: '帮我设置提醒,下午 14:30 分开会,项目计划例会', + msgId: 'msg-reminder-1', + }, + user: { userId: 'user-1' }, + scheduleService: { + async createItem(payload) { + calls.push(['createItem', payload.title, payload.startAt]); + return { id: 'item-reminder-1', ...payload }; + }, + async createReminder(payload) { + calls.push(['createReminder', payload.itemId, payload.remindAt]); + return { id: 'reminder-1', ...payload }; + }, + }, + }); + assert.match(reply, /已设置提醒/); + assert.match(reply, /项目计划例会/); + assert.equal(calls.length, 2); + assert.equal(calls[0][0], 'createItem'); + assert.equal(calls[1][0], 'createReminder'); +}); + +test('schedule handler falls through for multi-time reminder requests', async () => { + const reply = await handleWechatScheduleIntent({ + intent: { agentText: '明天早上六点去跑步,五点半提醒我', msgId: 'msg-3' }, user: { userId: 'user-1' }, scheduleService: createScheduleService(), wechatScheduleLlmConfigService: createScheduleLlmConfigService(true),