Add wechat reminder LLM toggle
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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