Add wechat reminder LLM toggle
This commit is contained in:
@@ -17,6 +17,7 @@ import { createDbPool, isDatabaseConfigured } from './db.mjs';
|
|||||||
import { createUserAuth } from './user-auth.mjs';
|
import { createUserAuth } from './user-auth.mjs';
|
||||||
import { createLlmProviderService } from './llm-providers.mjs';
|
import { createLlmProviderService } from './llm-providers.mjs';
|
||||||
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.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 { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
|
||||||
import { createPlazaInteractionService } from './plaza-interactions.mjs';
|
import { createPlazaInteractionService } from './plaza-interactions.mjs';
|
||||||
import { createPlazaOpsService } from './plaza-ops.mjs';
|
import { createPlazaOpsService } from './plaza-ops.mjs';
|
||||||
@@ -94,6 +95,7 @@ export async function createAdminServices(env = {}) {
|
|||||||
|
|
||||||
const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret });
|
const llmProviderService = createLlmProviderService(pool, { apiTarget, apiSecret });
|
||||||
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
const memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
||||||
|
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
||||||
const adminSystemTestService = createAdminSystemTestService({
|
const adminSystemTestService = createAdminSystemTestService({
|
||||||
pool,
|
pool,
|
||||||
userAuth,
|
userAuth,
|
||||||
@@ -130,6 +132,7 @@ export async function createAdminServices(env = {}) {
|
|||||||
userAuth,
|
userAuth,
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
memoryV2ConfigService,
|
memoryV2ConfigService,
|
||||||
|
wechatScheduleLlmConfigService,
|
||||||
adminSystemTestService,
|
adminSystemTestService,
|
||||||
plazaPosts,
|
plazaPosts,
|
||||||
plazaOps,
|
plazaOps,
|
||||||
|
|||||||
+14
-4
@@ -65,14 +65,24 @@ function extractTodoTitle(text) {
|
|||||||
return cleaned || null;
|
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) {
|
export function parseScheduleIntent(text) {
|
||||||
const compact = normalizeText(text);
|
const compact = normalizeText(text);
|
||||||
if (!compact) return { action: 'none' };
|
if (!compact) return { action: 'none' };
|
||||||
|
|
||||||
const wantsDaily = /每天|每日|天天/.test(compact);
|
if (wantsDailyTodoDigest(compact)) {
|
||||||
const wantsTodoDigest = /(待办|todo|任务).*(记录|列表|清单|安排|摘要)|一天的待办/.test(compact);
|
|
||||||
const wantsSend = /发|发送|推送|提醒|通知|给我/.test(compact);
|
|
||||||
if (wantsDaily && wantsTodoDigest && wantsSend) {
|
|
||||||
const time = parseHourMinute(compact);
|
const time = parseHourMinute(compact);
|
||||||
if (!time) {
|
if (!time) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -10,12 +10,46 @@ test('parses daily 7am todo digest request', () => {
|
|||||||
assert.equal(intent.minute, 0);
|
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', () => {
|
test('daily todo digest asks for time when missing', () => {
|
||||||
const intent = parseScheduleIntent('每天给我发一天的待办记录');
|
const intent = parseScheduleIntent('每天给我发一天的待办记录');
|
||||||
assert.equal(intent.action, 'create_daily_todo_digest');
|
assert.equal(intent.action, 'create_daily_todo_digest');
|
||||||
assert.deepEqual(intent.needsClarification, ['digest_time']);
|
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', () => {
|
test('parses balance low reminder request', () => {
|
||||||
const intent = parseScheduleIntent('余额低于 20 元提醒我');
|
const intent = parseScheduleIntent('余额低于 20 元提醒我');
|
||||||
assert.equal(intent.action, 'create_balance_alert');
|
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');
|
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', () => {
|
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 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 });
|
const next = nextDailyRunAt({ hour: 7, minute: 0, timezone: 'Asia/Shanghai', now });
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ import { createSessionSnapshotService } from './session-snapshot.mjs';
|
|||||||
import { createConversationMemoryService } from './conversation-memory.mjs';
|
import { createConversationMemoryService } from './conversation-memory.mjs';
|
||||||
import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs';
|
import { createManagedMemoryV2Runtime } from './memory-v2-runtime.mjs';
|
||||||
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
import { createMemoryV2AdminConfigService } from './memory-v2-admin-config.mjs';
|
||||||
|
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
|
||||||
import { createExperienceService } from './experience-service.mjs';
|
import { createExperienceService } from './experience-service.mjs';
|
||||||
import { attachAsrRoutes } from './asr-proxy.mjs';
|
import { attachAsrRoutes } from './asr-proxy.mjs';
|
||||||
import { isNativeH5ApiPath } from './policies.mjs';
|
import { isNativeH5ApiPath } from './policies.mjs';
|
||||||
@@ -327,6 +328,7 @@ let sessionSnapshotService = null;
|
|||||||
let conversationMemoryService = null;
|
let conversationMemoryService = null;
|
||||||
let memoryV2 = null;
|
let memoryV2 = null;
|
||||||
let memoryV2ConfigService = null;
|
let memoryV2ConfigService = null;
|
||||||
|
let wechatScheduleLlmConfigService = null;
|
||||||
let mindSpace = null;
|
let mindSpace = null;
|
||||||
let mindSpaceAssets = null;
|
let mindSpaceAssets = null;
|
||||||
let mindSpaceAudit = null;
|
let mindSpaceAudit = null;
|
||||||
@@ -581,6 +583,7 @@ async function bootstrapUserAuth() {
|
|||||||
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
|
console.warn('LLM provider boot sync skipped:', err instanceof Error ? err.message : err);
|
||||||
});
|
});
|
||||||
memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
memoryV2ConfigService = createMemoryV2AdminConfigService(pool);
|
||||||
|
wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
|
||||||
conversationMemoryService = createConversationMemoryService(pool, {
|
conversationMemoryService = createConversationMemoryService(pool, {
|
||||||
llmProviderService,
|
llmProviderService,
|
||||||
getEffectiveEnv: async () => {
|
getEffectiveEnv: async () => {
|
||||||
@@ -664,6 +667,8 @@ async function bootstrapUserAuth() {
|
|||||||
return tkmindProxy.apiFetchTo(target, pathname, init);
|
return tkmindProxy.apiFetchTo(target, pathname, init);
|
||||||
},
|
},
|
||||||
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
|
scheduleService: process.env.H5_SCHEDULE_ENABLED === '1' ? scheduleService : null,
|
||||||
|
wechatScheduleLlmConfigService,
|
||||||
|
llmProviderService,
|
||||||
applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId),
|
applySessionLlmProvider: (sessionId) => tkmindProxy.applySessionLlmProvider(sessionId),
|
||||||
refreshSessionSnapshot:
|
refreshSessionSnapshot:
|
||||||
sessionSnapshotService?.isEnabled()
|
sessionSnapshotService?.isEnabled()
|
||||||
|
|||||||
+10
-1
@@ -1294,6 +1294,8 @@ export function createWechatMpService({
|
|||||||
startAgentSession = null,
|
startAgentSession = null,
|
||||||
sessionApiFetch = null,
|
sessionApiFetch = null,
|
||||||
scheduleService = null,
|
scheduleService = null,
|
||||||
|
wechatScheduleLlmConfigService = null,
|
||||||
|
llmProviderService = null,
|
||||||
applySessionLlmProvider = null,
|
applySessionLlmProvider = null,
|
||||||
refreshSessionSnapshot = null,
|
refreshSessionSnapshot = null,
|
||||||
wechatFetch = undiciFetch,
|
wechatFetch = undiciFetch,
|
||||||
@@ -2320,7 +2322,14 @@ export function createWechatMpService({
|
|||||||
|
|
||||||
const scheduleReply =
|
const scheduleReply =
|
||||||
intent.msgType === 'text' || intent.msgType === 'voice'
|
intent.msgType === 'text' || intent.msgType === 'voice'
|
||||||
? await handleWechatScheduleIntent({ intent, user: boundUser, scheduleService })
|
? await handleWechatScheduleIntent({
|
||||||
|
intent,
|
||||||
|
user: boundUser,
|
||||||
|
scheduleService,
|
||||||
|
wechatScheduleLlmConfigService,
|
||||||
|
llmProviderService,
|
||||||
|
logger,
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
if (scheduleReply) {
|
if (scheduleReply) {
|
||||||
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
|
if (inbound.msgId && typeof userAuth.finishWechatMpMessage === 'function') {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,9 +1,142 @@
|
|||||||
import { isScheduleIntent, parseScheduleIntent } from '../../schedule-intent.mjs';
|
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;
|
if (!scheduleService) return null;
|
||||||
const scheduleIntent = parseScheduleIntent(intent.agentText);
|
const scheduleIntent = await resolveScheduleIntent({
|
||||||
if (!isScheduleIntent(scheduleIntent)) return null;
|
text: intent.agentText,
|
||||||
|
wechatScheduleLlmConfigService,
|
||||||
|
llmProviderService,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
if (!isScheduleIntent(scheduleIntent) || scheduleIntent.action === 'schedule_agent') return null;
|
||||||
|
|
||||||
const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
const timezone = process.env.H5_DEFAULT_TIMEZONE || 'Asia/Shanghai';
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user