e42417bd6e
Enable daily morning greeting on reply 1 (with custom time, modify, and cancel), random greeting delivery, M发现 in subscribe welcome, and optional LLM parsing when rules miss. Also fix WeChat MP draft/config error passthrough and add Tang E2E scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
229 lines
7.1 KiB
JavaScript
229 lines
7.1 KiB
JavaScript
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 {
|
||
const start = candidate.indexOf('{');
|
||
const end = candidate.lastIndexOf('}');
|
||
if (start < 0 || end <= start) return null;
|
||
try {
|
||
return JSON.parse(candidate.slice(start, end + 1));
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
function boundedConfidence(value, fallback = 0.7) {
|
||
const parsed = Number(value);
|
||
if (!Number.isFinite(parsed)) return fallback;
|
||
return Math.min(1, Math.max(0, parsed));
|
||
}
|
||
|
||
function normalizeHourMinute(hour, minute, { defaultHour, defaultMinute }) {
|
||
const safeHour = Number(hour);
|
||
const safeMinute = Number(minute ?? 0);
|
||
if (Number.isInteger(safeHour) && safeHour >= 0 && safeHour <= 23
|
||
&& Number.isInteger(safeMinute) && safeMinute >= 0 && safeMinute <= 59) {
|
||
return { hour: safeHour, minute: safeMinute };
|
||
}
|
||
if (Number.isInteger(defaultHour) && Number.isInteger(defaultMinute)) {
|
||
return { hour: defaultHour, minute: defaultMinute };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function normalizeSubscribeMorningLlmIntent(raw, {
|
||
phase = 'active',
|
||
defaultHour = 8,
|
||
defaultMinute = 0,
|
||
minConfidence = 0.65,
|
||
} = {}) {
|
||
const actionRaw = String(raw?.action ?? 'none').trim().toLowerCase();
|
||
const allowed = new Set(['confirm', 'modify', 'cancel', 'clarify', 'none']);
|
||
const action = allowed.has(actionRaw) ? actionRaw : 'none';
|
||
const confidence = boundedConfidence(raw?.confidence);
|
||
if (action === 'none' || confidence < minConfidence) {
|
||
return { action: 'none', confidence, message: null, hour: null, minute: null };
|
||
}
|
||
|
||
const time = normalizeHourMinute(raw?.hour, raw?.minute, { defaultHour, defaultMinute });
|
||
const message = String(raw?.message ?? '').trim() || null;
|
||
|
||
if (action === 'clarify') {
|
||
return {
|
||
action,
|
||
confidence,
|
||
message: message || '请补充一下具体时间,例如「1 7点」或「早安改到 7 点」。',
|
||
hour: null,
|
||
minute: null,
|
||
};
|
||
}
|
||
|
||
if (action === 'cancel') {
|
||
return { action, confidence, message: null, hour: null, minute: null };
|
||
}
|
||
|
||
if (action === 'confirm') {
|
||
if (phase !== 'pending') return { action: 'none', confidence, message: null, hour: null, minute: null };
|
||
const resolved = time ?? normalizeHourMinute(defaultHour, defaultMinute, { defaultHour, defaultMinute });
|
||
if (!resolved) return { action: 'none', confidence, message: null, hour: null, minute: null };
|
||
return { action, confidence, message: null, ...resolved };
|
||
}
|
||
|
||
if (action === 'modify') {
|
||
if (phase !== 'active' || !time) {
|
||
return {
|
||
action: 'clarify',
|
||
confidence,
|
||
message: message || '请告诉我想改到几点,例如「早安改到 7 点」。',
|
||
hour: null,
|
||
minute: null,
|
||
};
|
||
}
|
||
return { action, confidence, message: null, ...time };
|
||
}
|
||
|
||
return { action: 'none', confidence, message: null, hour: null, minute: null };
|
||
}
|
||
|
||
function buildSystemPrompt() {
|
||
return [
|
||
'你是微信服务号「每日早安提醒」助手,只做意图识别,不生成页面。',
|
||
'只输出 JSON,不要 markdown。',
|
||
'{"action":"confirm|modify|cancel|clarify|none","hour":7,"minute":30,"confidence":0.0,"message":"可选中文追问"}',
|
||
'confirm:用户在关注欢迎语后想开通每日早安推送;可带具体时间。例:「1」「1 7点」「不要8点改7点半」。',
|
||
'modify:用户已开通,想改时间。例:「早安改到7点」「改成7点半」。必须给出 hour/minute。',
|
||
'cancel:用户明确不要早安提醒。例:「取消早安」「不要早安了」。',
|
||
'clarify:想改/想开但时间不清楚,message 用一句中文追问。',
|
||
'none:普通聊天、做页面、其它提醒,与早安订阅无关;或无法判断。',
|
||
'hour 取 0-23,minute 取 0-59;没提到分钟则 minute=0。',
|
||
].join('\n');
|
||
}
|
||
|
||
async function withTimeout(promise, timeoutMs, label) {
|
||
if (!timeoutMs || timeoutMs <= 0) return promise;
|
||
let timer = null;
|
||
try {
|
||
return await Promise.race([
|
||
promise,
|
||
new Promise((_, reject) => {
|
||
timer = setTimeout(() => {
|
||
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
||
}, timeoutMs);
|
||
timer.unref?.();
|
||
}),
|
||
]);
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
export async function parseSubscribeMorningIntentWithLlm({
|
||
text,
|
||
phase = 'active',
|
||
defaultHour = 8,
|
||
defaultMinute = 0,
|
||
currentHour = null,
|
||
currentMinute = null,
|
||
llmProviderService,
|
||
modelProviderKeyId = null,
|
||
model = null,
|
||
minConfidence = 0.65,
|
||
timeoutMs = 4000,
|
||
logger = console,
|
||
}) {
|
||
if (!llmProviderService || typeof llmProviderService.createChatCompletion !== 'function') {
|
||
return null;
|
||
}
|
||
try {
|
||
const result = await withTimeout(
|
||
llmProviderService.createChatCompletion({
|
||
...(modelProviderKeyId ? { providerKeyId: modelProviderKeyId } : {}),
|
||
...(model ? { model } : {}),
|
||
temperature: 0,
|
||
messages: [
|
||
{ role: 'system', content: buildSystemPrompt() },
|
||
{
|
||
role: 'user',
|
||
content: JSON.stringify({
|
||
phase,
|
||
userMessage: String(text ?? '').trim(),
|
||
defaultHour,
|
||
defaultMinute,
|
||
currentHour,
|
||
currentMinute,
|
||
}),
|
||
},
|
||
],
|
||
}),
|
||
timeoutMs,
|
||
'subscribe-morning-llm',
|
||
);
|
||
if (!result?.ok) {
|
||
logger?.warn?.('[wechat-subscribe-morning-llm] skipped:', result?.message ?? 'unknown');
|
||
return null;
|
||
}
|
||
const parsed = parseJsonReply(result.reply);
|
||
if (!parsed) return null;
|
||
return normalizeSubscribeMorningLlmIntent(parsed, {
|
||
phase,
|
||
defaultHour,
|
||
defaultMinute,
|
||
minConfidence,
|
||
});
|
||
} catch (err) {
|
||
logger?.warn?.(
|
||
'[wechat-subscribe-morning-llm] skipped:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function resolveSubscribeMorningLlmIntent({
|
||
text,
|
||
phase = 'active',
|
||
defaultHour = 8,
|
||
defaultMinute = 0,
|
||
currentHour = null,
|
||
currentMinute = null,
|
||
wechatSubscribeMorningLlmConfigService = null,
|
||
llmProviderService = null,
|
||
logger = console,
|
||
}) {
|
||
if (!wechatSubscribeMorningLlmConfigService || !llmProviderService) return null;
|
||
|
||
let enabled = false;
|
||
let config = null;
|
||
try {
|
||
enabled = await wechatSubscribeMorningLlmConfigService.isSubscribeMorningLlmEnabled();
|
||
config = await wechatSubscribeMorningLlmConfigService.getConfig();
|
||
} catch (err) {
|
||
logger?.warn?.(
|
||
'[wechat-subscribe-morning-llm] config load failed:',
|
||
err instanceof Error ? err.message : err,
|
||
);
|
||
return null;
|
||
}
|
||
if (!enabled) return null;
|
||
|
||
return parseSubscribeMorningIntentWithLlm({
|
||
text,
|
||
phase,
|
||
defaultHour,
|
||
defaultMinute,
|
||
currentHour,
|
||
currentMinute,
|
||
llmProviderService,
|
||
modelProviderKeyId: config?.modelProviderKeyId ?? null,
|
||
model: config?.model ?? null,
|
||
minConfidence: config?.minConfidence ?? 0.65,
|
||
timeoutMs: config?.timeoutMs ?? 4000,
|
||
logger,
|
||
});
|
||
}
|