91e140d402
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>
346 lines
11 KiB
JavaScript
346 lines
11 KiB
JavaScript
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);
|
||
},
|
||
};
|
||
}
|