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);
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|