Files
memind/wechat-intent-router.mjs
T
john 91e140d402
Memind CI / Test, build, and release guards (push) Failing after 8s
feat(wechat): add admin-pluggable LLM intent router separate from H5.
Give WeChat MP its own chat.general→page.generate LLM refinement layer with memindadm toggles, shadow mode, and canary openids so service account routing stays independent of the H5 chatIntentRouter.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 20:57:41 +08:00

346 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { wantsDocxDownload } from './wechat/intent/patterns.mjs';
import { defaultsFromEnv } from './wechat-intent-router-config.mjs';
export const WECHAT_LLM_REFINABLE_KINDS = new Set(['chat.general']);
const DEFAULT_MIN_CONFIDENCE = 0.65;
const DEFAULT_TIMEOUT_MS = 4000;
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function boundedNumber(value, fallback, { min = 0, max = Number.POSITIVE_INFINITY } = {}) {
if (value == null || value === '') return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function normalizeOpenidList(value) {
return [...new Set(String(value ?? '')
.split(/[\s,]+/u)
.map((item) => item.trim())
.filter(Boolean))].slice(0, 1000);
}
export function resolveWechatIntentRouterPolicy(env = process.env, overrides = {}) {
const merged = { ...env, ...overrides };
return {
enabled: envFlag(merged.MEMIND_WECHAT_INTENT_LLM_ENABLED, false),
shadowMode: envFlag(merged.MEMIND_WECHAT_INTENT_LLM_SHADOW, true),
modelProviderKeyId: String(merged.MEMIND_WECHAT_INTENT_MODEL_PROVIDER_KEY_ID ?? '').trim() || null,
model: String(merged.MEMIND_WECHAT_INTENT_MODEL ?? '').trim() || null,
minConfidence: boundedNumber(
merged.MEMIND_WECHAT_INTENT_MIN_CONFIDENCE,
DEFAULT_MIN_CONFIDENCE,
{ min: 0, max: 1 },
),
timeoutMs: Math.round(boundedNumber(
merged.MEMIND_WECHAT_INTENT_TIMEOUT_MS,
DEFAULT_TIMEOUT_MS,
{ min: 500, max: 30_000 },
)),
canaryOpenids: normalizeOpenidList(merged.MEMIND_WECHAT_INTENT_CANARY_OPENIDS),
};
}
export function isWechatIntentLlmEligible({ policy, openid = null } = {}) {
if (!policy?.enabled) return false;
if (!Array.isArray(policy.canaryOpenids) || policy.canaryOpenids.length === 0) return true;
const normalized = String(openid ?? '').trim();
return normalized.length > 0 && policy.canaryOpenids.includes(normalized);
}
export function shouldRefineWechatIntent(ruleIntent, text = '') {
const kind = String(ruleIntent?.kind ?? '').trim();
if (!WECHAT_LLM_REFINABLE_KINDS.has(kind)) return false;
return String(text ?? '').trim().length >= 2;
}
function buildWechatIntentSystemPrompt() {
return [
'你是微信服务号意图分类器,只判断用户消息更适合哪种处理通道。',
'只输出 JSON,不要 markdown',
'{"kind":"page.generate|chat.general","confidence":0.0,"reason":"一句话"}',
'page.generate:用户希望生成/整理/排版 HTML 页面、图文、推文、专题、报告页,或明确要把内容做成可发布/可转发的页面。',
'chat.general:普通问答、闲聊、解释、建议、查询,不需要产出 HTML 页面。',
'如果用户只是口头提到“页面/文章”但没有生成或交付页面意图,仍判 chat.general。',
].join('\n');
}
function buildWechatIntentUserPrompt(text) {
return ['[User]', String(text ?? '').trim() || '(empty)'].join('\n');
}
export function parseWechatIntentJson(reply) {
const text = String(reply ?? '').trim();
if (!text) return null;
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = (fenced?.[1] ?? text).trim();
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;
}
}
}
export function normalizeWechatLlmIntent(raw) {
const kindRaw = String(raw?.kind ?? '').trim().toLowerCase();
const kind = kindRaw === 'page.generate' ? 'page.generate' : 'chat.general';
const confidenceRaw = Number(raw?.confidence);
const confidence = Number.isFinite(confidenceRaw)
? Math.min(1, Math.max(0, confidenceRaw))
: 0.7;
const reason = String(raw?.reason ?? '').trim() || '微信 LLM 意图判定';
return { kind, confidence, reason, source: 'llm' };
}
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(() => {
const err = new Error(`${label} timed out after ${timeoutMs}ms`);
err.code = 'WECHAT_INTENT_ROUTER_TIMEOUT';
reject(err);
}, timeoutMs);
timer.unref?.();
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
export function createWechatIntentRouter({
llmProviderService,
env = process.env,
logger = console,
policy: policyOverride = null,
} = {}) {
const policy = policyOverride ?? resolveWechatIntentRouterPolicy(env);
async function classifyWithLlm(text) {
if (typeof llmProviderService?.createChatCompletion !== 'function') return null;
try {
const completion = await withTimeout(
llmProviderService.createChatCompletion({
providerKeyId: policy.modelProviderKeyId || undefined,
model: policy.model || undefined,
temperature: 0,
messages: [
{ role: 'system', content: buildWechatIntentSystemPrompt() },
{ role: 'user', content: buildWechatIntentUserPrompt(text) },
],
}),
policy.timeoutMs,
'WeChat intent router',
);
if (!completion?.ok) return null;
const parsed = parseWechatIntentJson(completion.reply);
if (!parsed) return null;
return normalizeWechatLlmIntent({
...parsed,
model: completion.model ?? policy.model,
providerKeyId: completion.providerKeyId ?? policy.modelProviderKeyId,
});
} catch (err) {
logger?.warn?.(
`[wechat-intent-router] LLM classify skipped: ${err instanceof Error ? err.message : err}`,
);
return null;
}
}
function getStatus() {
return {
enabled: policy.enabled,
shadowMode: policy.shadowMode,
llmRoutingEnabled: Boolean(policy.enabled && !policy.shadowMode),
llmRoutingShadow: Boolean(policy.enabled && policy.shadowMode),
modelProviderKeyId: policy.modelProviderKeyId,
model: policy.model,
minConfidence: policy.minConfidence,
timeoutMs: policy.timeoutMs,
canaryOpenidCount: policy.canaryOpenids.length,
};
}
async function refineIntent({
ruleIntent,
text = '',
openid = null,
} = {}) {
const baseline = ruleIntent ?? { kind: 'chat.general' };
if (!shouldRefineWechatIntent(baseline, text)) {
return { ...baseline, intentSource: 'rules' };
}
if (!isWechatIntentLlmEligible({ policy, openid })) {
return { ...baseline, intentSource: 'rules' };
}
const llmResult = await classifyWithLlm(text);
const shadowPayload = llmResult
? {
kind: llmResult.kind,
confidence: llmResult.confidence,
reason: llmResult.reason,
wouldChangeKind: llmResult.kind !== baseline.kind,
}
: {
skipped: true,
reason: 'llm_unavailable_or_low_signal',
wouldChangeKind: false,
};
if (policy.shadowMode) {
logger?.info?.('[wechat-intent-router-shadow]', {
openid: openid ?? null,
baselineKind: baseline.kind,
...shadowPayload,
});
return {
...baseline,
intentSource: 'rules',
llmShadow: shadowPayload,
};
}
if (!llmResult || llmResult.confidence < policy.minConfidence) {
return {
...baseline,
intentSource: 'rules',
llmSuggestion: llmResult,
};
}
if (llmResult.kind === baseline.kind) {
return {
...baseline,
intentSource: 'rules',
llmSuggestion: llmResult,
};
}
if (llmResult.kind === 'page.generate') {
return {
kind: 'page.generate',
topic: String(text ?? '').trim(),
wantsDocx: wantsDocxDownload(text),
intentSource: 'llm',
llmReason: llmResult.reason,
llmConfidence: llmResult.confidence,
sessionActionResult: baseline.sessionActionResult,
};
}
return {
kind: 'chat.general',
text: String(text ?? '').trim(),
intentSource: 'llm',
llmReason: llmResult.reason,
llmConfidence: llmResult.confidence,
sessionActionResult: baseline.sessionActionResult,
};
}
return {
getStatus,
refineIntent,
};
}
export function createManagedWechatIntentRouter({
llmProviderService,
configService = null,
env = process.env,
logger = console,
} = {}) {
let activeRouter = null;
let activeFingerprint = null;
let activeMeta = { source: 'env', updatedAt: null, updatedBy: null, configError: null };
async function loadRouterState() {
if (!configService?.getRuntimeState) {
return {
source: 'env',
updatedAt: null,
updatedBy: null,
fingerprint: 'env-only',
effectiveEnv: env,
configError: null,
};
}
try {
const state = await configService.getRuntimeState();
return {
source: state?.source ?? 'admin-db',
updatedAt: state?.updatedAt ?? null,
updatedBy: state?.updatedBy ?? null,
fingerprint: state?.fingerprint ?? `admin-db:${Date.now()}`,
effectiveEnv: { ...env, ...(state?.overrides ?? {}) },
configError: null,
};
} catch (err) {
logger?.warn?.(
`[wechat-intent-router] admin config unavailable, using process env: ${err instanceof Error ? err.message : err}`,
);
return {
source: 'env-fallback',
updatedAt: null,
updatedBy: null,
fingerprint: 'env-fallback',
effectiveEnv: { ...env },
configError: err instanceof Error ? err.message : String(err),
};
}
}
async function ensureRouter() {
const state = await loadRouterState();
if (activeRouter && activeFingerprint === state.fingerprint) {
activeMeta = state;
return activeRouter;
}
activeRouter = createWechatIntentRouter({
llmProviderService,
env: state.effectiveEnv,
logger,
policy: resolveWechatIntentRouterPolicy(state.effectiveEnv),
});
activeFingerprint = state.fingerprint;
activeMeta = state;
return activeRouter;
}
return {
async getStatus() {
const router = await ensureRouter();
return {
...router.getStatus(),
configSource: activeMeta.source,
configUpdatedAt: activeMeta.updatedAt,
configUpdatedBy: activeMeta.updatedBy,
configError: activeMeta.configError,
};
},
async refineIntent(input = {}) {
const router = await ensureRouter();
return router.refineIntent(input);
},
};
}