feat(wechat): add admin-pluggable LLM intent router separate from H5.
Memind CI / Test, build, and release guards (push) Failing after 8s
Memind CI / Test, build, and release guards (push) Failing after 8s
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>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user