diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 3ff7dbc..a7175d1 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -17,6 +17,7 @@ import { createDbPool, isDatabaseConfigured } from './db.mjs'; import { createUserAuth } from './user-auth.mjs'; import { createLlmProviderService } from './llm-providers.mjs'; import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; +import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs'; import { createPlazaInteractionService } from './plaza-interactions.mjs'; import { createPlazaOpsService } from './plaza-ops.mjs'; @@ -94,6 +95,7 @@ export async function createAdminServices(env = {}) { const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret }); const memoryV2ConfigService = createMemoryV2AdminConfigService(pool); + const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); const adminSystemTestService = createAdminSystemTestService({ pool, userAuth, @@ -130,6 +132,7 @@ export async function createAdminServices(env = {}) { userAuth, llmProviderService, memoryV2ConfigService, + wechatScheduleLlmConfigService, adminSystemTestService, plazaPosts, plazaOps, diff --git a/schedule-intent.mjs b/schedule-intent.mjs index 4777e83..9967f67 100644 --- a/schedule-intent.mjs +++ b/schedule-intent.mjs @@ -65,14 +65,24 @@ function extractTodoTitle(text) { return cleaned || null; } +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; +} + export function parseScheduleIntent(text) { const compact = normalizeText(text); if (!compact) return { action: 'none' }; - const wantsDaily = /每天|每日|天天/.test(compact); - const wantsTodoDigest = /(待办|todo|任务).*(记录|列表|清单|安排|摘要)|一天的待办/.test(compact); - const wantsSend = /发|发送|推送|提醒|通知|给我/.test(compact); - if (wantsDaily && wantsTodoDigest && wantsSend) { + if (wantsDailyTodoDigest(compact)) { const time = parseHourMinute(compact); if (!time) { return { diff --git a/schedule-intent.test.mjs b/schedule-intent.test.mjs index 3837eba..0c4d1ec 100644 --- a/schedule-intent.test.mjs +++ b/schedule-intent.test.mjs @@ -10,12 +10,46 @@ test('parses daily 7am todo digest request', () => { assert.equal(intent.minute, 0); }); +test('parses daily digest request for today todos in direct wording', () => { + const intent = parseScheduleIntent('每天早上7点把当天待办发给我'); + assert.equal(intent.action, 'create_daily_todo_digest'); + assert.equal(intent.hour, 7); + assert.equal(intent.minute, 0); +}); + +test('parses daily digest request with concise today wording', () => { + const intent = parseScheduleIntent('每天7点发我今天待办'); + assert.equal(intent.action, 'create_daily_todo_digest'); + assert.equal(intent.hour, 7); + assert.equal(intent.minute, 0); +}); + +test('parses daily digest request with chinese numeral time', () => { + const intent = parseScheduleIntent('以后每天早上七点把当天任务发送给我'); + assert.equal(intent.action, 'create_daily_todo_digest'); + assert.equal(intent.hour, 7); + assert.equal(intent.minute, 0); +}); + +test('parses daily digest request with half hour', () => { + const intent = parseScheduleIntent('每天早上7点半把今天待办推送给我'); + assert.equal(intent.action, 'create_daily_todo_digest'); + assert.equal(intent.hour, 7); + assert.equal(intent.minute, 30); +}); + test('daily todo digest asks for time when missing', () => { const intent = parseScheduleIntent('每天给我发一天的待办记录'); assert.equal(intent.action, 'create_daily_todo_digest'); assert.deepEqual(intent.needsClarification, ['digest_time']); }); +test('daily digest asks for time when only says send today todos', () => { + const intent = parseScheduleIntent('每天把当天待办发给我'); + assert.equal(intent.action, 'create_daily_todo_digest'); + assert.deepEqual(intent.needsClarification, ['digest_time']); +}); + test('parses balance low reminder request', () => { const intent = parseScheduleIntent('余额低于 20 元提醒我'); assert.equal(intent.action, 'create_balance_alert'); @@ -67,6 +101,17 @@ test('reminder todo request routes to schedule assistant instead of plain todo s assert.equal(intent.action, 'schedule_agent'); }); +test('specific dated reminder still routes to schedule assistant', () => { + 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'); +}); + test('nextDailyRunAt rolls to tomorrow when today time has passed', () => { const now = Date.UTC(2026, 5, 17, 23, 30, 0); // 2026-06-18 07:30 Asia/Shanghai const next = nextDailyRunAt({ hour: 7, minute: 0, timezone: 'Asia/Shanghai', now }); diff --git a/server.mjs b/server.mjs index 61ce71a..b01862e 100644 --- a/server.mjs +++ b/server.mjs @@ -182,6 +182,7 @@ import { createSessionSnapshotService } from './session-snapshot.mjs'; import { createConversationMemoryService } from './conversation-memory.mjs'; import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs'; import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs'; +import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs'; import { createExperienceService } from './experience-service.mjs'; import { attachAsrRoutes } from './asr-proxy.mjs'; import { isNativeH5ApiPath } from './policies.mjs'; @@ -327,6 +328,7 @@ let sessionSnapshotService = null; let conversationMemoryService = null; let memoryV2 = null; let memoryV2ConfigService = null; +let wechatScheduleLlmConfigService = null; let mindSpace = null; let mindSpaceAssets = null; let mindSpaceAudit = null; @@ -581,6 +583,7 @@ async function bootstrapUserAuth() { console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err); }); memoryV2ConfigService = createMemoryV2AdminConfigService(pool); + wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool); conversationMemoryService = createConversationMemoryService(pool, { llmProviderService, getEffectiveEnv: async () => { @@ -664,6 +667,8 @@ async function bootstrapUserAuth() { return tkmindProxy.apiFetchTo(target, pathname, init); }, scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null, + wechatScheduleLlmConfigService, + llmProviderService, applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId), refreshSessionSnapshot: sessionSnapshotService?.isEnabled() diff --git a/wechat-mp.mjs b/wechat-mp.mjs index d63f021..2eb3d56 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -1294,6 +1294,8 @@ export function createWechatMpService({ startAgentSession = null, sessionApiFetch = null, scheduleService = null, + wechatScheduleLlmConfigService = null, + llmProviderService = null, applySessionLlmProvider = null, refreshSessionSnapshot = null, wechatFetch = undiciFetch, @@ -2320,7 +2322,14 @@ export function createWechatMpService({ const scheduleReply = intent.msgType === 'text' || intent.msgType === 'voice' - ? await handleWechatScheduleIntent({ intent, user: boundUser, scheduleService }) + ? await handleWechatScheduleIntent({ + intent, + user: boundUser, + scheduleService, + wechatScheduleLlmConfigService, + llmProviderService, + logger, + }) : null; if (scheduleReply) { if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') { diff --git a/wechat-schedule-llm-config.mjs b/wechat-schedule-llm-config.mjs new file mode 100644 index 0000000..2dac3c6 --- /dev/null +++ b/wechat-schedule-llm-config.mjs @@ -0,0 +1,112 @@ +const CONFIG_TABLE = 'h5_wechat_admin_config'; +const CONFIG_KEY = 'schedule_llm'; + +function normalizeBoolean(value, fallback = false) { + if (value == null || value === '') return fallback; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return fallback; +} + +function parseConfigJson(value) { + if (!value) return {}; + if (typeof value === 'object') return value; + try { + return JSON.parse(String(value)); + } catch { + return {}; + } +} + +async function ensureConfigTable(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} ( + config_key VARCHAR(64) PRIMARY KEY, + config_json JSON NOT NULL, + updated_by CHAR(36) NULL, + updated_at BIGINT NOT NULL + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); +} + +function defaultsFromEnv(env = process.env) { + return { + scheduleLlmEnabled: normalizeBoolean(env.H5_WECHAT_SCHEDULE_LLM_ENABLED, false), + }; +} + +export function createWechatScheduleLlmConfigService(pool, { env = process.env } = {}) { + let ensurePromise = null; + + async function ensureReady() { + if (!ensurePromise) ensurePromise = ensureConfigTable(pool); + await ensurePromise; + } + + async function readRow() { + await ensureReady(); + const [rows] = await pool.query( + `SELECT config_json, updated_by, updated_at + FROM ${CONFIG_TABLE} + WHERE config_key = ? + LIMIT 1`, + [CONFIG_KEY], + ); + return rows[0] ?? null; + } + + function mergeConfig(row) { + const defaults = defaultsFromEnv(env); + const stored = parseConfigJson(row?.config_json); + return { + scheduleLlmEnabled: normalizeBoolean( + stored.scheduleLlmEnabled, + defaults.scheduleLlmEnabled, + ), + }; + } + + return { + async getConfig() { + const row = await readRow(); + return { + ...mergeConfig(row), + updatedAt: row?.updated_at ? Number(row.updated_at) : null, + updatedBy: row?.updated_by ?? null, + }; + }, + + async isScheduleLlmEnabled() { + const config = await this.getConfig(); + return config.scheduleLlmEnabled; + }, + + async updateConfig(payload = {}, { updatedBy = null } = {}) { + const current = await this.getConfig(); + const next = { + scheduleLlmEnabled: + payload.scheduleLlmEnabled === undefined + ? current.scheduleLlmEnabled + : normalizeBoolean(payload.scheduleLlmEnabled, current.scheduleLlmEnabled), + }; + const now = Date.now(); + await pool.query( + `INSERT INTO ${CONFIG_TABLE} (config_key, config_json, updated_by, updated_at) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + updated_by = VALUES(updated_by), + updated_at = VALUES(updated_at)`, + [CONFIG_KEY, JSON.stringify(next), updatedBy, now], + ); + return this.getConfig(); + }, + }; +} + +export const wechatScheduleLlmConfigInternals = { + normalizeBoolean, + defaultsFromEnv, +}; diff --git a/wechat-schedule-llm-config.test.mjs b/wechat-schedule-llm-config.test.mjs new file mode 100644 index 0000000..361ad96 --- /dev/null +++ b/wechat-schedule-llm-config.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + createWechatScheduleLlmConfigService, + wechatScheduleLlmConfigInternals, +} from './wechat-schedule-llm-config.mjs'; + +function createPool(seedRow = null) { + const state = { row: seedRow }; + return { + async query(sql, params) { + if (sql.includes('CREATE TABLE')) return [[], []]; + if (sql.includes('SELECT config_json')) { + return [state.row ? [state.row] : [], []]; + } + if (sql.includes('INSERT INTO h5_wechat_admin_config')) { + state.row = { + config_json: params[1], + updated_by: params[2], + updated_at: params[3], + }; + return [[], []]; + } + throw new Error(`Unexpected query: ${sql}`); + }, + }; +} + +test('wechat schedule llm config service falls back to env defaults', async () => { + const service = createWechatScheduleLlmConfigService(createPool(), { + env: { H5_WECHAT_SCHEDULE_LLM_ENABLED: '1' }, + }); + const result = await service.getConfig(); + assert.equal(result.scheduleLlmEnabled, true); + assert.equal(result.updatedAt, null); +}); + +test('wechat schedule llm config service persists admin toggle', async () => { + const service = createWechatScheduleLlmConfigService(createPool(), { + env: { H5_WECHAT_SCHEDULE_LLM_ENABLED: '0' }, + }); + const updated = await service.updateConfig({ scheduleLlmEnabled: true }, { updatedBy: 'admin-1' }); + assert.equal(updated.scheduleLlmEnabled, true); + assert.equal(updated.updatedBy, 'admin-1'); + assert.equal(await service.isScheduleLlmEnabled(), true); +}); + +test('wechat schedule llm config internals normalize booleans consistently', () => { + assert.equal(wechatScheduleLlmConfigInternals.normalizeBoolean('1', false), true); + assert.equal(wechatScheduleLlmConfigInternals.normalizeBoolean('off', true), false); + assert.equal( + wechatScheduleLlmConfigInternals.defaultsFromEnv({ + H5_WECHAT_SCHEDULE_LLM_ENABLED: 'true', + }).scheduleLlmEnabled, + true, + ); +}); diff --git a/wechat/handlers/schedule.mjs b/wechat/handlers/schedule.mjs index 4c7e5b7..74a629c 100644 --- a/wechat/handlers/schedule.mjs +++ b/wechat/handlers/schedule.mjs @@ -1,9 +1,142 @@ import { isScheduleIntent, parseScheduleIntent } from '../../schedule-intent.mjs'; -export async function handleWechatScheduleIntent({ intent, user, scheduleService }) { +function normalizeHour(value) { + const hour = Number(value); + return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null; +} + +function normalizeMinute(value) { + const minute = Number(value); + return Number.isInteger(minute) && minute >= 0 && minute <= 59 ? minute : null; +} + +function normalizeThresholdCents(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return Math.round(parsed * 100); +} + +function parseJsonReply(reply) { + const text = String(reply ?? '').trim(); + if (!text) return null; + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + const candidate = fenced?.[1] ?? text; + try { + return JSON.parse(candidate); + } catch { + return null; + } +} + +function normalizeLlmScheduleIntent(payload) { + const action = String(payload?.action ?? 'none').trim(); + if (!action) return { action: 'none' }; + + if (action === 'create_todo') { + const title = String(payload?.title ?? '').trim(); + return title ? { action, title } : { action, needsClarification: ['todo_title'] }; + } + + if (action === 'create_daily_todo_digest') { + const hour = normalizeHour(payload?.hour); + const minute = normalizeMinute(payload?.minute ?? 0); + if (hour == null || minute == null) { + return { action, needsClarification: ['digest_time'] }; + } + return { action, hour, minute }; + } + + if (action === 'create_balance_alert') { + const thresholdCents = normalizeThresholdCents(payload?.thresholdYuan); + return thresholdCents == null + ? { action, needsClarification: ['threshold'] } + : { action, thresholdCents }; + } + + if (action === 'query_schedule' || action === 'schedule_agent') { + return { action }; + } + + return { action: 'none' }; +} + +async function parseScheduleIntentWithLlm({ text, llmProviderService, logger = console }) { + if (!llmProviderService || typeof llmProviderService.createChatCompletion !== 'function') { + return { action: 'none' }; + } + + const result = await llmProviderService.createChatCompletion({ + temperature: 0, + messages: [ + { + role: 'system', + content: [ + '你是公众号提醒助手,只做意图识别。', + '请严格输出 JSON,不要输出解释。', + '允许 action: create_todo, create_daily_todo_digest, create_balance_alert, query_schedule, schedule_agent, none。', + 'create_todo 需要 title。', + 'create_daily_todo_digest 需要 hour(0-23) 和 minute(0-59)。', + 'create_balance_alert 需要 thresholdYuan 数字。', + '如果用户在说具体时间提醒、一次性闹钟、某天某时提醒,返回 schedule_agent。', + '不确定时返回 none。', + ].join('\n'), + }, + { + role: 'user', + content: text, + }, + ], + }); + + if (!result?.ok) { + logger?.warn?.('[wechat-schedule-llm] parse skipped:', result?.message ?? 'unknown'); + return { action: 'none' }; + } + + return normalizeLlmScheduleIntent(parseJsonReply(result.reply)); +} + +async function resolveScheduleIntent({ + text, + wechatScheduleLlmConfigService, + llmProviderService, + logger, +}) { + const ruleIntent = parseScheduleIntent(text); + if (isScheduleIntent(ruleIntent)) return ruleIntent; + + if (!wechatScheduleLlmConfigService || !llmProviderService) return ruleIntent; + + let enabled = false; + try { + enabled = await wechatScheduleLlmConfigService.isScheduleLlmEnabled(); + } catch (err) { + logger?.warn?.( + '[wechat-schedule-llm] config load failed:', + err instanceof Error ? err.message : err, + ); + } + if (!enabled) return ruleIntent; + + return parseScheduleIntentWithLlm({ text, llmProviderService, logger }); +} + +export async function handleWechatScheduleIntent({ + intent, + user, + scheduleService, + wechatScheduleLlmConfigService = null, + llmProviderService = null, + logger = console, +}) { if (!scheduleService) return null; - const scheduleIntent = parseScheduleIntent(intent.agentText); - if (!isScheduleIntent(scheduleIntent)) return null; + const scheduleIntent = await resolveScheduleIntent({ + text: intent.agentText, + wechatScheduleLlmConfigService, + llmProviderService, + logger, + }); + if (!isScheduleIntent(scheduleIntent) || scheduleIntent.action === 'schedule_agent') return null; const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai'; diff --git a/wechat/handlers/schedule.test.mjs b/wechat/handlers/schedule.test.mjs new file mode 100644 index 0000000..285cd4f --- /dev/null +++ b/wechat/handlers/schedule.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { handleWechatScheduleIntent } from './schedule.mjs'; + +function createScheduleService() { + return { + async createItem(payload) { + return { id: 'item-1', ...payload }; + }, + async createDailyTodoDigest(payload) { + return { id: 'digest-1', minute: payload.minute, hour: payload.hour, ...payload }; + }, + async createBalanceLowAlert(payload) { + return { id: 'balance-1', ...payload }; + }, + async buildTodoDigestText() { + return '今天有 2 条待办。'; + }, + }; +} + +test('schedule handler keeps rule-based parsing as first priority', async () => { + let llmCalled = false; + const reply = await handleWechatScheduleIntent({ + intent: { agentText: '帮我记一下 跟进合同', msgId: 'msg-1' }, + user: { userId: 'user-1' }, + scheduleService: createScheduleService(), + wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } }, + llmProviderService: { + async createChatCompletion() { + llmCalled = true; + return { ok: true, reply: '{"action":"none"}' }; + }, + }, + }); + assert.match(reply, /已记录到待办列表/); + assert.equal(llmCalled, false); +}); + +test('schedule handler can use llm fallback for ambiguous todo text', async () => { + const reply = await handleWechatScheduleIntent({ + intent: { agentText: '下周把合同发给客户,别忘了', msgId: 'msg-2' }, + user: { userId: 'user-1' }, + scheduleService: createScheduleService(), + wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } }, + llmProviderService: { + async createChatCompletion() { + return { + ok: true, + reply: '{"action":"create_todo","title":"把合同发给客户"}', + }; + }, + }, + }); + assert.match(reply, /把合同发给客户/); +}); + +test('schedule handler directly handles daily digest shortcut phrase', async () => { + const reply = await handleWechatScheduleIntent({ + intent: { agentText: '每天早上7点把当天待办发给我', msgId: 'msg-digest-1' }, + user: { userId: 'user-1' }, + scheduleService: createScheduleService(), + }); + assert.match(reply, /已设置/); + assert.match(reply, /每天早上 7点/); +}); + +test('schedule handler does not call llm when switch is off', async () => { + let llmCalled = false; + const reply = await handleWechatScheduleIntent({ + intent: { agentText: '别忘了报销', msgId: 'msg-4' }, + user: { userId: 'user-1' }, + scheduleService: createScheduleService(), + wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return false; } }, + llmProviderService: { + async createChatCompletion() { + llmCalled = true; + return { ok: true, reply: '{"action":"create_todo","title":"报销"}' }; + }, + }, + }); + assert.equal(reply, null); + assert.equal(llmCalled, false); +}); + +test('schedule handler falls back quietly when llm request fails', async () => { + const warnings = []; + const reply = await handleWechatScheduleIntent({ + intent: { agentText: '别忘了报销', msgId: 'msg-5' }, + user: { userId: 'user-1' }, + scheduleService: createScheduleService(), + wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } }, + llmProviderService: { + async createChatCompletion() { + return { ok: false, message: 'provider unavailable' }; + }, + }, + logger: { warn: (...args) => warnings.push(args.join(' ')) }, + }); + assert.equal(reply, null); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /provider unavailable/); +}); + +test('schedule handler falls through for llm schedule_agent results', async () => { + const reply = await handleWechatScheduleIntent({ + intent: { agentText: '明天下午三点提醒我开会', msgId: 'msg-3' }, + user: { userId: 'user-1' }, + scheduleService: createScheduleService(), + wechatScheduleLlmConfigService: { async isScheduleLlmEnabled() { return true; } }, + llmProviderService: { + async createChatCompletion() { + return { + ok: true, + reply: '{"action":"schedule_agent"}', + }; + }, + }, + }); + assert.equal(reply, null); +});