feat(wechat): add admin-pluggable LLM intent router separate from H5.
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:
john
2026-08-01 20:57:41 +08:00
parent cdc265d7e0
commit 91e140d402
14 changed files with 1145 additions and 4 deletions
+10
View File
@@ -168,6 +168,16 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID=<h5_llm_provider_keys.id>
# MEMIND_CHAT_ROUTER_MODEL=deepseek-v4-pro
# 微信服务号专用 LLM 意图层(独立于 H5 chatIntentRoutermemindadm 可插拔)
# 默认关闭;Shadow=1 时只记日志不改行为;canary 留空=全员
# MEMIND_WECHAT_INTENT_LLM_ENABLED=0
# MEMIND_WECHAT_INTENT_LLM_SHADOW=1
# MEMIND_WECHAT_INTENT_MODEL_PROVIDER_KEY_ID=
# MEMIND_WECHAT_INTENT_MODEL=
# MEMIND_WECHAT_INTENT_MIN_CONFIDENCE=0.65
# MEMIND_WECHAT_INTENT_TIMEOUT_MS=4000
# MEMIND_WECHAT_INTENT_CANARY_OPENIDS=
# H5 Session stream replaydocs/h5-session-architecture-20260706.md Patch 4c
# 默认 0session SSE 仍纯透传 goosed;设 1 时 Portal 持久化 session 事件并支持 Last-Event-ID 重连补发。
# MEMIND_SESSION_STREAM_REPLAY=0
+3
View File
@@ -28,6 +28,7 @@ import { createSystemDisclosurePolicyService } from './system-disclosure-policy.
import { createAgentCodeRunAdminConfigService } from './agent-code-run-admin-config.mjs';
import { createMindSearchConfigService } from './mindsearch-config.mjs';
import { createWechatScheduleLlmConfigService } from './wechat-schedule-llm-config.mjs';
import { createWechatIntentRouterConfigService } from './wechat-intent-router-config.mjs';
import { createPlazaPostService, formatPostRow } from './plaza-posts.mjs';
import { createPlazaInteractionService } from './plaza-interactions.mjs';
import { createPlazaOpsService } from './plaza-ops.mjs';
@@ -131,6 +132,7 @@ export async function createAdminServices(env = {}) {
await systemDisclosurePolicyService.initialize();
const agentCodeRunPolicyService = createAgentCodeRunAdminConfigService(pool, { env: process.env });
const wechatScheduleLlmConfigService = createWechatScheduleLlmConfigService(pool);
const wechatIntentRouterConfigService = createWechatIntentRouterConfigService(pool);
const adminSystemTestService = createAdminSystemTestService({
pool,
userAuth,
@@ -178,6 +180,7 @@ export async function createAdminServices(env = {}) {
systemDisclosurePolicyService,
agentCodeRunPolicyService,
wechatScheduleLlmConfigService,
wechatIntentRouterConfigService,
adminSystemTestService,
plazaPosts,
plazaOps,
+30
View File
@@ -42,6 +42,7 @@ function plazaRouteError(res, req, error) {
* @param {object|null} deps.plazaPosts
* @param {object|null} deps.plazaOps
* @param {object|null} deps.wechatAdmin
* @param {object|null} deps.wechatIntentRouterConfigService
*/
export function createAdminApi({
jsonBody,
@@ -64,6 +65,7 @@ export function createAdminApi({
plazaPosts,
plazaOps,
wechatAdmin,
wechatIntentRouterConfigService,
subscriptionService,
}) {
function requireAdmin(req, res, next) {
@@ -838,6 +840,34 @@ export function createAdminApi({
res.json(result);
});
adminApi.get('/wechat/intent-router/config', requireAdmin, async (_req, res) => {
if (!wechatIntentRouterConfigService?.getConfig) {
return res.status(503).json({ message: '微信意图路由配置未启用' });
}
const config = await wechatIntentRouterConfigService.getConfig();
return res.json({ config });
});
adminApi.get('/wechat/intent-router/runtime', requireAdmin, async (_req, res) => {
if (!wechatIntentRouterConfigService?.getRuntimeState) {
return res.status(503).json({ message: '微信意图路由配置未启用' });
}
return res.json(await wechatIntentRouterConfigService.getRuntimeState());
});
const updateWechatIntentRouterConfig = async (req, res) => {
if (!wechatIntentRouterConfigService?.updateConfig) {
return res.status(503).json({ message: '微信意图路由配置未启用' });
}
const config = await wechatIntentRouterConfigService.updateConfig(req.body ?? {}, {
updatedBy: req.currentUser.id,
});
return res.json({ config });
};
adminApi.put('/wechat/intent-router/config', requireAdmin, updateWechatIntentRouterConfig);
adminApi.patch('/wechat/intent-router/config', requireAdmin, updateWechatIntentRouterConfig);
adminApi.get('/llm-providers/catalog', requireAdmin, (_req, res) => {
if (!llmProviderService) return res.status(503).json({ message: '未启用 LLM 配置' });
res.json({ catalog: llmProviderService.catalog });
+91
View File
@@ -702,3 +702,94 @@ test('admin system test route executes shared validation service', async () => {
await server.close();
}
});
test('admin wechat intent router config routes', async () => {
const updates = [];
const router = createAdminApi({
jsonBody: express.json(),
getToken() {
return 'token-admin';
},
userAuth: {
async getMe(token) {
if (token !== 'token-admin') return null;
return { id: 'admin-1', role: 'admin' };
},
},
wechatIntentRouterConfigService: {
async getConfig() {
return {
enabled: true,
shadowMode: true,
modelProviderKeyId: 'key-1',
model: 'deepseek-v4-pro',
minConfidence: 0.65,
timeoutMs: 4000,
canaryOpenids: ['openid-a'],
updatedAt: 123,
updatedBy: 'admin-1',
};
},
async updateConfig(patch, { updatedBy }) {
updates.push({ patch, updatedBy });
return {
enabled: false,
shadowMode: true,
modelProviderKeyId: null,
model: null,
minConfidence: 0.65,
timeoutMs: 4000,
canaryOpenids: [],
updatedAt: 456,
updatedBy,
};
},
async getRuntimeState() {
return {
source: 'admin-db',
updatedAt: 123,
updatedBy: 'admin-1',
fingerprint: 'fp-1',
overrides: { MEMIND_WECHAT_INTENT_LLM_ENABLED: '1' },
config: { enabled: true, shadowMode: true },
};
},
},
plazaPosts: null,
plazaOps: null,
wechatAdmin: null,
subscriptionService: null,
});
const server = await startTestServer(router);
try {
const configRes = await fetch(`${server.baseUrl}/admin-api/wechat/intent-router/config`, {
headers: { cookie: 'h5_user_session=token-admin' },
});
assert.equal(configRes.status, 200);
const configBody = await configRes.json();
assert.equal(configBody.config.enabled, true);
assert.equal(configBody.config.model, 'deepseek-v4-pro');
const updateRes = await fetch(`${server.baseUrl}/admin-api/wechat/intent-router/config`, {
method: 'PATCH',
headers: {
'content-type': 'application/json',
cookie: 'h5_user_session=token-admin',
},
body: JSON.stringify({ enabled: false }),
});
assert.equal(updateRes.status, 200);
assert.deepEqual(updates, [{ patch: { enabled: false }, updatedBy: 'admin-1' }]);
const runtimeRes = await fetch(`${server.baseUrl}/admin-api/wechat/intent-router/runtime`, {
headers: { cookie: 'h5_user_session=token-admin' },
});
assert.equal(runtimeRes.status, 200);
assert.deepEqual((await runtimeRes.json()).overrides, {
MEMIND_WECHAT_INTENT_LLM_ENABLED: '1',
});
} finally {
await server.close();
}
});
+1
View File
@@ -91,6 +91,7 @@ const CONSOLES = {
plazaPosts: services.plazaPosts,
plazaOps: services.plazaOps,
wechatAdmin: services.wechatAdmin,
wechatIntentRouterConfigService: services.wechatIntentRouterConfigService,
subscriptionService: services.subscriptionService,
}),
},
+42
View File
@@ -738,6 +738,48 @@ export async function resumeWechatDigest(id: string) {
});
}
export type WechatIntentRouterAdminConfig = {
enabled: boolean;
shadowMode: boolean;
modelProviderKeyId: string | null;
model: string | null;
minConfidence: number;
timeoutMs: number;
canaryOpenids: string[];
updatedAt?: number | null;
updatedBy?: string | null;
};
export type WechatIntentRouterConfigState = {
config: WechatIntentRouterAdminConfig;
};
export type WechatIntentRouterRuntimeState = {
source: string;
updatedAt: number | null;
updatedBy: string | null;
fingerprint?: string;
overrides: Record<string, string>;
config: WechatIntentRouterAdminConfig;
};
export async function fetchWechatIntentRouterConfig() {
return adminFetch<WechatIntentRouterConfigState>('/admin-api/wechat/intent-router/config');
}
export async function patchWechatIntentRouterConfig(
patch: Partial<Omit<WechatIntentRouterAdminConfig, 'updatedAt' | 'updatedBy'>>,
) {
return adminFetch<WechatIntentRouterConfigState>('/admin-api/wechat/intent-router/config', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
export async function fetchWechatIntentRouterRuntime() {
return adminFetch<WechatIntentRouterRuntimeState>('/admin-api/wechat/intent-router/runtime');
}
// ─── Agent Code Run ───────────────────────────────────────────────────────────
export type AgentCodeRunConfigShape = {
+247 -1
View File
@@ -1,21 +1,28 @@
import { useEffect, useState, type CSSProperties, type ReactNode } from 'react';
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react';
import {
cancelWechatDigest,
createWechatWebNotification,
clearWechatRoute,
fetchAdminUsers,
fetchLlmProviderKeys,
fetchWechatBindings,
fetchWechatDeliveries,
fetchWechatDigests,
fetchWechatIntentRouterConfig,
fetchWechatIntentRouterRuntime,
fetchWechatMessages,
fetchWechatSummary,
fetchWechatWebNotifications,
patchWechatIntentRouterConfig,
resumeWechatDigest,
type AdminUser,
type LlmProviderKeyRow,
type WechatAdminSummary,
type WechatBinding,
type WechatDeliveryLog,
type WechatDigestSubscription,
type WechatIntentRouterAdminConfig,
type WechatIntentRouterRuntimeState,
type WechatMessage,
type WechatWebNotification,
} from '../../api/admin';
@@ -44,6 +51,49 @@ function userName(row: { displayName?: string | null; username?: string | null }
return row.displayName || row.username || '—';
}
type IntentRouterForm = {
enabled: boolean;
shadowMode: boolean;
modelProviderKeyId: string;
model: string;
minConfidence: string;
timeoutMs: string;
canaryOpenids: string;
};
function intentRouterToForm(
config: WechatIntentRouterAdminConfig | undefined,
keys: LlmProviderKeyRow[],
): IntentRouterForm {
const keyId = String(config?.modelProviderKeyId ?? '').trim();
const selectedKey = keys.find((item) => item.id === keyId) ?? null;
const model = String(config?.model ?? '').trim()
|| selectedKey?.defaultModel
|| selectedKey?.models?.[0]
|| '';
return {
enabled: Boolean(config?.enabled),
shadowMode: config?.shadowMode !== false,
modelProviderKeyId: keyId,
model,
minConfidence: String(config?.minConfidence ?? 0.65),
timeoutMs: String(config?.timeoutMs ?? 4000),
canaryOpenids: Array.isArray(config?.canaryOpenids) ? config.canaryOpenids.join('\n') : '',
};
}
function intentRouterRuntimeMode(overrides: Record<string, string>) {
const enabled = ['1', 'true', 'yes', 'on'].includes(
String(overrides.MEMIND_WECHAT_INTENT_LLM_ENABLED ?? '').toLowerCase(),
);
const shadow = ['1', 'true', 'yes', 'on'].includes(
String(overrides.MEMIND_WECHAT_INTENT_LLM_SHADOW ?? '').toLowerCase(),
);
if (!enabled) return '关闭(仅规则意图)';
if (shadow) return 'Shadow 观测(行为不变)';
return '已激活(chat.general 可升级为 page.generate';
}
const tableStyle = {
width: '100%',
borderCollapse: 'collapse',
@@ -88,6 +138,21 @@ export function WechatPage() {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [llmKeys, setLlmKeys] = useState<LlmProviderKeyRow[]>([]);
const [intentForm, setIntentForm] = useState<IntentRouterForm | null>(null);
const [savedIntentForm, setSavedIntentForm] = useState<IntentRouterForm | null>(null);
const [intentRuntime, setIntentRuntime] = useState<WechatIntentRouterRuntimeState | null>(null);
const [intentSaving, setIntentSaving] = useState(false);
const intentDirty = useMemo(() => {
if (!intentForm || !savedIntentForm) return false;
return JSON.stringify(intentForm) !== JSON.stringify(savedIntentForm);
}, [intentForm, savedIntentForm]);
const selectedIntentKey = useMemo(
() => llmKeys.find((item) => item.id === intentForm?.modelProviderKeyId) ?? null,
[llmKeys, intentForm?.modelProviderKeyId],
);
const load = async () => {
setError(null);
@@ -100,6 +165,9 @@ export function WechatPage() {
deliveryResult,
notificationResult,
userResult,
llmKeyResult,
intentConfigResult,
intentRuntimeResult,
] =
await Promise.all([
fetchWechatSummary(),
@@ -109,6 +177,9 @@ export function WechatPage() {
fetchWechatDeliveries({ status: deliveryStatus || undefined, limit: 80 }),
fetchWechatWebNotifications({ status: notificationStatus || undefined, limit: 80 }),
fetchAdminUsers({ page: 1, pageSize: 200, status: 'active' }),
fetchLlmProviderKeys().catch(() => ({ keys: [] as LlmProviderKeyRow[] })),
fetchWechatIntentRouterConfig().catch(() => null),
fetchWechatIntentRouterRuntime().catch(() => null),
]);
setSummary(summaryResult);
setBindings(bindingResult.bindings);
@@ -120,6 +191,13 @@ export function WechatPage() {
setNotifyUserId((current) =>
current || userResult.users[0]?.id || '',
);
setLlmKeys(llmKeyResult.keys);
if (intentConfigResult?.config) {
const nextForm = intentRouterToForm(intentConfigResult.config, llmKeyResult.keys);
setIntentForm(nextForm);
setSavedIntentForm(nextForm);
}
setIntentRuntime(intentRuntimeResult);
} catch (err) {
setError(err instanceof Error ? err.message : '加载失败');
}
@@ -235,6 +313,37 @@ export function WechatPage() {
}
};
const handleSaveIntentRouter = async () => {
if (!intentForm) return;
setIntentSaving(true);
setError(null);
setNotice(null);
try {
const result = await patchWechatIntentRouterConfig({
enabled: intentForm.enabled,
shadowMode: intentForm.shadowMode,
modelProviderKeyId: intentForm.modelProviderKeyId || null,
model: intentForm.model || null,
minConfidence: Number(intentForm.minConfidence) || 0.65,
timeoutMs: Number(intentForm.timeoutMs) || 4000,
canaryOpenids: intentForm.canaryOpenids
.split(/[\s,]+/u)
.map((item) => item.trim())
.filter(Boolean),
});
const nextForm = intentRouterToForm(result.config, llmKeys);
setIntentForm(nextForm);
setSavedIntentForm(nextForm);
const runtime = await fetchWechatIntentRouterRuntime().catch(() => null);
setIntentRuntime(runtime);
setNotice('微信 LLM 意图路由配置已保存。Portal 进程会在下次请求时自动热加载。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败');
} finally {
setIntentSaving(false);
}
};
if (!summary && !error) return <p>...</p>;
return (
@@ -260,6 +369,143 @@ export function WechatPage() {
</div>
) : null}
{intentForm ? (
<section className="card grid">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<div>
<h3 style={{ margin: 0 }}> LLM </h3>
<p style={{ margin: '6px 0 0', color: '#68716c', fontSize: 13 }}>
H5 Router chat.general LLM page.generate vs chat.general
</p>
</div>
<button
type="button"
className="btn"
onClick={() => void handleSaveIntentRouter()}
disabled={busy || intentSaving || !intentDirty}
>
{intentSaving ? '保存中…' : intentDirty ? '保存配置' : '已保存'}
</button>
</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))',
gap: 12,
}}
>
<Field label="启用 LLM 意图层">
<label style={{ display: 'flex', gap: 8, alignItems: 'center', minHeight: 38 }}>
<input
type="checkbox"
checked={intentForm.enabled}
onChange={(event) =>
setIntentForm((current) => current && { ...current, enabled: event.target.checked })
}
/>
<span>{intentForm.enabled ? '已启用' : '关闭'}</span>
</label>
</Field>
<Field label="Shadow 模式">
<label style={{ display: 'flex', gap: 8, alignItems: 'center', minHeight: 38 }}>
<input
type="checkbox"
checked={intentForm.shadowMode}
disabled={!intentForm.enabled}
onChange={(event) =>
setIntentForm((current) => current && { ...current, shadowMode: event.target.checked })
}
/>
<span>{intentForm.shadowMode ? '只观测不改行为' : 'Active 生效'}</span>
</label>
</Field>
<Field label="LLM Provider Key">
<select
value={intentForm.modelProviderKeyId}
onChange={(event) => {
const keyId = event.target.value;
const key = llmKeys.find((item) => item.id === keyId) ?? null;
setIntentForm((current) =>
current
? {
...current,
modelProviderKeyId: keyId,
model: key?.defaultModel || key?.models?.[0] || current.model,
}
: current,
);
}}
>
<option value="">使 Key</option>
{llmKeys.map((key) => (
<option key={key.id} value={key.id}>
{key.name} ({key.providerLabel})
</option>
))}
</select>
</Field>
<Field label="模型">
<input
list="wechat-intent-models"
value={intentForm.model}
onChange={(event) =>
setIntentForm((current) => current && { ...current, model: event.target.value })
}
placeholder={selectedIntentKey?.defaultModel || 'deepseek-v4-pro'}
/>
<datalist id="wechat-intent-models">
{(selectedIntentKey?.models ?? []).map((model) => (
<option key={model} value={model} />
))}
</datalist>
</Field>
<Field label="最低置信度">
<input
type="number"
min={0}
max={1}
step={0.05}
value={intentForm.minConfidence}
onChange={(event) =>
setIntentForm((current) => current && { ...current, minConfidence: event.target.value })
}
/>
</Field>
<Field label="超时 (ms)">
<input
type="number"
min={500}
max={30000}
step={100}
value={intentForm.timeoutMs}
onChange={(event) =>
setIntentForm((current) => current && { ...current, timeoutMs: event.target.value })
}
/>
</Field>
</div>
<Field label="Canary OpenID(每行或逗号分隔,留空=全员)">
<textarea
value={intentForm.canaryOpenids}
onChange={(event) =>
setIntentForm((current) => current && { ...current, canaryOpenids: event.target.value })
}
rows={3}
placeholder="oXXXX..."
/>
</Field>
{intentRuntime ? (
<p style={{ margin: 0, color: '#68716c', fontSize: 12 }}>
{intentRouterRuntimeMode(intentRuntime.overrides)} · {intentRuntime.source}
{intentRuntime.updatedAt ? ` · 更新 ${time(intentRuntime.updatedAt)}` : ''}
</p>
) : null}
</section>
) : null}
<section className="card grid">
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap' }}>
<h3 style={{ margin: 0 }}></h3>
+4
View File
@@ -280,6 +280,7 @@ let skillRuntimeConfigService = null;
let systemDisclosurePolicyService = null;
let agentCodeRunPolicyService = null;
let wechatScheduleLlmConfigService = null;
let wechatIntentRouter = null;
let mindSpace = null;
let mindSpaceAssets = null;
let mindSpaceAudit = null;
@@ -471,6 +472,8 @@ async function bootstrapUserAuth() {
memorySessionServices.agentCodeRunPolicyService;
wechatScheduleLlmConfigService =
memorySessionServices.wechatScheduleLlmConfigService;
wechatIntentRouter =
memorySessionServices.wechatIntentRouter;
conversationMemoryService =
memorySessionServices.conversationMemoryService;
memoryV2 = memorySessionServices.memoryV2;
@@ -530,6 +533,7 @@ async function bootstrapUserAuth() {
scheduledTaskService,
sessionSnapshotService,
wechatScheduleLlmConfigService,
wechatIntentRouter,
llmProviderService,
chatIntentRouter,
systemDisclosurePolicyService,
@@ -33,6 +33,7 @@ export async function bootstrapPortalIntegrationServices({
scheduledTaskService = null,
sessionSnapshotService = null,
wechatScheduleLlmConfigService,
wechatIntentRouter = null,
llmProviderService,
chatIntentRouter,
systemDisclosurePolicyService,
@@ -165,6 +166,7 @@ export async function bootstrapPortalIntegrationServices({
wechatScheduleLlmConfigService,
llmProviderService,
chatIntentRouter,
wechatIntentRouter,
systemDisclosurePolicyService,
sessionIntentClassifier: ({ text }) =>
chatIntentRouter?.classifySessionAction({
@@ -11,6 +11,8 @@ import { isSessionStreamReplayEnabled } from '../session-stream.mjs';
import { createSkillRuntimeAdminConfigService } from '../skill-runtime-admin-config.mjs';
import { createSystemDisclosurePolicyService } from '../system-disclosure-policy.mjs';
import { createWechatScheduleLlmConfigService } from '../wechat-schedule-llm-config.mjs';
import { createWechatIntentRouterConfigService } from '../wechat-intent-router-config.mjs';
import { createManagedWechatIntentRouter } from '../wechat-intent-router.mjs';
export async function bootstrapPortalMemorySessionServices({
pool,
@@ -30,6 +32,10 @@ export async function bootstrapPortalMemorySessionServices({
createAgentCodeRunAdminConfigService,
createWechatScheduleLlmConfigServiceFn =
createWechatScheduleLlmConfigService,
createWechatIntentRouterConfigServiceFn =
createWechatIntentRouterConfigService,
createManagedWechatIntentRouterFn =
createManagedWechatIntentRouter,
createConversationMemoryServiceFn =
createConversationMemoryService,
createManagedMemoryV2RuntimeFn =
@@ -71,6 +77,15 @@ export async function bootstrapPortalMemorySessionServices({
createAgentCodeRunAdminConfigServiceFn(pool, { env });
const wechatScheduleLlmConfigService =
createWechatScheduleLlmConfigServiceFn(pool);
const wechatIntentRouterConfigService =
createWechatIntentRouterConfigServiceFn(pool, { env });
const wechatIntentRouter =
createManagedWechatIntentRouterFn({
llmProviderService,
configService: wechatIntentRouterConfigService,
env,
logger,
});
const getEffectiveEnv = async () => {
const state = await memoryV2ConfigService
@@ -140,6 +155,8 @@ export async function bootstrapPortalMemorySessionServices({
systemDisclosurePolicyService,
agentCodeRunPolicyService,
wechatScheduleLlmConfigService,
wechatIntentRouterConfigService,
wechatIntentRouter,
conversationMemoryService,
memoryV2,
episodicMemoryService,
+175
View File
@@ -0,0 +1,175 @@
const CONFIG_TABLE = 'h5_wechat_admin_config';
const CONFIG_KEY = 'intent_router';
function normalizeBoolean(value, fallback = false) {
if (value == null || value === '') return fallback;
if (typeof value === 'boolean') return value;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function normalizeString(value) {
if (value == null) return '';
return String(value).trim();
}
function normalizeNumber(value, fallback) {
if (value == null || value === '') return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function parseConfigJson(value) {
if (!value) return {};
if (typeof value === 'object') return value;
try {
return JSON.parse(String(value));
} catch {
return {};
}
}
function normalizeOpenidList(value) {
return [...new Set(String(value ?? '')
.split(/[\s,]+/u)
.map((item) => item.trim())
.filter(Boolean))].slice(0, 1000);
}
async function ensureConfigTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${CONFIG_TABLE} (
config_key VARCHAR(64) PRIMARY KEY,
config_json JSON NOT NULL,
updated_by CHAR(36) NULL,
updated_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
export function defaultsFromEnv(env = process.env) {
return {
enabled: normalizeBoolean(env.MEMIND_WECHAT_INTENT_LLM_ENABLED, false),
shadowMode: normalizeBoolean(env.MEMIND_WECHAT_INTENT_LLM_SHADOW, true),
modelProviderKeyId: normalizeString(env.MEMIND_WECHAT_INTENT_MODEL_PROVIDER_KEY_ID),
model: normalizeString(env.MEMIND_WECHAT_INTENT_MODEL),
minConfidence: normalizeNumber(env.MEMIND_WECHAT_INTENT_MIN_CONFIDENCE, 0.65),
timeoutMs: normalizeNumber(env.MEMIND_WECHAT_INTENT_TIMEOUT_MS, 4000),
canaryOpenids: normalizeOpenidList(env.MEMIND_WECHAT_INTENT_CANARY_OPENIDS),
};
}
export function createWechatIntentRouterConfigService(pool, { env = process.env } = {}) {
let ensurePromise = null;
async function ensureReady() {
if (!ensurePromise) ensurePromise = ensureConfigTable(pool);
await ensurePromise;
}
async function readRow() {
await ensureReady();
const [rows] = await pool.query(
`SELECT config_json, updated_by, updated_at
FROM ${CONFIG_TABLE}
WHERE config_key = ?
LIMIT 1`,
[CONFIG_KEY],
);
return rows[0] ?? null;
}
function mergeConfig(row) {
const defaults = defaultsFromEnv(env);
const stored = parseConfigJson(row?.config_json);
return {
enabled: normalizeBoolean(stored.enabled, defaults.enabled),
shadowMode: normalizeBoolean(stored.shadowMode, defaults.shadowMode),
modelProviderKeyId: normalizeString(stored.modelProviderKeyId) || defaults.modelProviderKeyId || null,
model: normalizeString(stored.model) || defaults.model || null,
minConfidence: normalizeNumber(stored.minConfidence, defaults.minConfidence),
timeoutMs: normalizeNumber(stored.timeoutMs, defaults.timeoutMs),
canaryOpenids: stored.canaryOpenids != null
? normalizeOpenidList(stored.canaryOpenids)
: defaults.canaryOpenids,
};
}
return {
async getConfig() {
const row = await readRow();
return {
...mergeConfig(row),
updatedAt: row?.updated_at ? Number(row.updated_at) : null,
updatedBy: row?.updated_by ?? null,
};
},
async getRuntimeState() {
const config = await this.getConfig();
const overrides = {
MEMIND_WECHAT_INTENT_LLM_ENABLED: config.enabled ? '1' : '0',
MEMIND_WECHAT_INTENT_LLM_SHADOW: config.shadowMode ? '1' : '0',
MEMIND_WECHAT_INTENT_MODEL_PROVIDER_KEY_ID: config.modelProviderKeyId ?? '',
MEMIND_WECHAT_INTENT_MODEL: config.model ?? '',
MEMIND_WECHAT_INTENT_MIN_CONFIDENCE: String(config.minConfidence),
MEMIND_WECHAT_INTENT_TIMEOUT_MS: String(config.timeoutMs),
MEMIND_WECHAT_INTENT_CANARY_OPENIDS: config.canaryOpenids.join(','),
};
return {
source: 'admin-db',
updatedAt: config.updatedAt,
updatedBy: config.updatedBy,
fingerprint: JSON.stringify(config),
overrides,
config,
};
},
async updateConfig(payload = {}, { updatedBy = null } = {}) {
const current = await this.getConfig();
const next = {
enabled: payload.enabled === undefined
? current.enabled
: normalizeBoolean(payload.enabled, current.enabled),
shadowMode: payload.shadowMode === undefined
? current.shadowMode
: normalizeBoolean(payload.shadowMode, current.shadowMode),
modelProviderKeyId: payload.modelProviderKeyId === undefined
? (current.modelProviderKeyId ?? '')
: normalizeString(payload.modelProviderKeyId),
model: payload.model === undefined
? (current.model ?? '')
: normalizeString(payload.model),
minConfidence: payload.minConfidence === undefined
? current.minConfidence
: normalizeNumber(payload.minConfidence, current.minConfidence),
timeoutMs: payload.timeoutMs === undefined
? current.timeoutMs
: normalizeNumber(payload.timeoutMs, current.timeoutMs),
canaryOpenids: payload.canaryOpenids === undefined
? current.canaryOpenids
: normalizeOpenidList(payload.canaryOpenids),
};
const now = Date.now();
await pool.query(
`INSERT INTO ${CONFIG_TABLE} (config_key, config_json, updated_by, updated_at)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
config_json = VALUES(config_json),
updated_by = VALUES(updated_by),
updated_at = VALUES(updated_at)`,
[CONFIG_KEY, JSON.stringify(next), updatedBy, now],
);
return this.getConfig();
},
};
}
export const wechatIntentRouterConfigInternals = {
normalizeBoolean,
normalizeOpenidList,
defaultsFromEnv,
};
+345
View File
@@ -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);
},
};
}
+155
View File
@@ -0,0 +1,155 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createWechatIntentRouter,
isWechatIntentLlmEligible,
normalizeWechatLlmIntent,
parseWechatIntentJson,
resolveWechatIntentRouterPolicy,
shouldRefineWechatIntent,
} from './wechat-intent-router.mjs';
import {
createWechatIntentRouterConfigService,
defaultsFromEnv,
} from './wechat-intent-router-config.mjs';
test('resolveWechatIntentRouterPolicy reads env defaults', () => {
const policy = resolveWechatIntentRouterPolicy({
MEMIND_WECHAT_INTENT_LLM_ENABLED: '1',
MEMIND_WECHAT_INTENT_LLM_SHADOW: '0',
MEMIND_WECHAT_INTENT_MODEL: 'deepseek-v4-pro',
MEMIND_WECHAT_INTENT_MIN_CONFIDENCE: '0.7',
MEMIND_WECHAT_INTENT_TIMEOUT_MS: '5000',
MEMIND_WECHAT_INTENT_CANARY_OPENIDS: 'openid-a, openid-b',
});
assert.equal(policy.enabled, true);
assert.equal(policy.shadowMode, false);
assert.equal(policy.model, 'deepseek-v4-pro');
assert.equal(policy.minConfidence, 0.7);
assert.equal(policy.timeoutMs, 5000);
assert.deepEqual(policy.canaryOpenids, ['openid-a', 'openid-b']);
});
test('shouldRefineWechatIntent only refines chat.general', () => {
assert.equal(shouldRefineWechatIntent({ kind: 'chat.general' }, '整理成一篇给读者看的文章'), true);
assert.equal(shouldRefineWechatIntent({ kind: 'page.generate' }, '生成 HTML 页面'), false);
assert.equal(shouldRefineWechatIntent({ kind: 'greeting' }, '你好'), false);
});
test('isWechatIntentLlmEligible respects canary openids', () => {
const policy = { enabled: true, canaryOpenids: ['openid-a'] };
assert.equal(isWechatIntentLlmEligible({ policy, openid: 'openid-a' }), true);
assert.equal(isWechatIntentLlmEligible({ policy, openid: 'openid-b' }), false);
assert.equal(isWechatIntentLlmEligible({ policy: { enabled: true, canaryOpenids: [] }, openid: 'x' }), true);
});
test('createWechatIntentRouter upgrades chat.general to page.generate in active mode', async () => {
const router = createWechatIntentRouter({
policy: resolveWechatIntentRouterPolicy({
MEMIND_WECHAT_INTENT_LLM_ENABLED: '1',
MEMIND_WECHAT_INTENT_LLM_SHADOW: '0',
MEMIND_WECHAT_INTENT_MIN_CONFIDENCE: '0.6',
}),
llmProviderService: {
async createChatCompletion() {
return {
ok: true,
reply: JSON.stringify({
kind: 'page.generate',
confidence: 0.92,
reason: '用户希望整理成可发布页面',
}),
model: 'deepseek-v4-pro',
};
},
},
});
const result = await router.refineIntent({
ruleIntent: { kind: 'chat.general', text: '帮我整理成一篇给读者看的专题页' },
text: '帮我整理成一篇给读者看的专题页',
openid: 'openid-a',
});
assert.equal(result.kind, 'page.generate');
assert.equal(result.intentSource, 'llm');
assert.equal(result.wantsDocx, false);
});
test('createWechatIntentRouter keeps baseline in shadow mode', async () => {
const router = createWechatIntentRouter({
policy: resolveWechatIntentRouterPolicy({
MEMIND_WECHAT_INTENT_LLM_ENABLED: '1',
MEMIND_WECHAT_INTENT_LLM_SHADOW: '1',
}),
llmProviderService: {
async createChatCompletion() {
return {
ok: true,
reply: JSON.stringify({
kind: 'page.generate',
confidence: 0.95,
reason: 'shadow',
}),
};
},
},
});
const result = await router.refineIntent({
ruleIntent: { kind: 'chat.general', text: '整理成文章' },
text: '整理成文章',
});
assert.equal(result.kind, 'chat.general');
assert.equal(result.intentSource, 'rules');
assert.equal(result.llmShadow?.wouldChangeKind, true);
});
test('wechat intent router config service persists admin toggles', async () => {
const rows = new Map();
const pool = {
async query(sql, params = []) {
if (String(sql).includes('CREATE TABLE')) return [[]];
if (String(sql).includes('SELECT config_json')) {
const row = rows.get(params[0]);
return [row ? [row] : []];
}
if (String(sql).includes('INSERT INTO')) {
rows.set(params[0], {
config_json: params[1],
updated_by: params[2],
updated_at: params[3],
});
return [{ affectedRows: 1 }];
}
throw new Error(`unexpected sql: ${sql}`);
},
};
const service = createWechatIntentRouterConfigService(pool, {
env: defaultsFromEnv({}),
});
const saved = await service.updateConfig({
enabled: true,
shadowMode: true,
model: 'deepseek-v4-flash',
canaryOpenids: 'openid-test',
}, { updatedBy: 'admin-1' });
assert.equal(saved.enabled, true);
assert.equal(saved.shadowMode, true);
assert.equal(saved.model, 'deepseek-v4-flash');
assert.deepEqual(saved.canaryOpenids, ['openid-test']);
const runtime = await service.getRuntimeState();
assert.equal(runtime.overrides.MEMIND_WECHAT_INTENT_LLM_ENABLED, '1');
assert.equal(runtime.overrides.MEMIND_WECHAT_INTENT_LLM_SHADOW, '1');
assert.equal(runtime.overrides.MEMIND_WECHAT_INTENT_MODEL, 'deepseek-v4-flash');
});
test('parseWechatIntentJson accepts fenced JSON', () => {
const parsed = parseWechatIntentJson('```json\n{"kind":"page.generate","confidence":0.8}\n```');
assert.equal(parsed.kind, 'page.generate');
assert.equal(normalizeWechatLlmIntent(parsed).kind, 'page.generate');
});
+23 -3
View File
@@ -1487,6 +1487,7 @@ export function createWechatMpService({
wechatScheduleLlmConfigService = null,
llmProviderService = null,
chatIntentRouter = null,
wechatIntentRouter = null,
systemDisclosurePolicyService = null,
onPageGenerated = null,
applySessionLlmProvider = null,
@@ -2433,7 +2434,7 @@ export function createWechatMpService({
};
const runIntentMessage = async ({ inbound, intent, user }) => {
const wechatIntent = await resolveWechatIntent(intent);
const wechatIntent = await resolveWechatIntent(intent, { openid: inbound.fromUserName });
const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers);
const reliabilityEnabled = isWechatMediaGrayUser(user, config.reliabilityGrayUsers);
const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0;
@@ -3066,7 +3067,7 @@ export function createWechatMpService({
}
};
const resolveWechatIntent = async (intent) => {
const resolveWechatIntent = async (intent, { openid = null } = {}) => {
const wechatIntent = classifyWechatIntent(intent);
if (intent.msgType !== 'text' && intent.msgType !== 'voice') return wechatIntent;
const sessionActionResult = await resolveWechatSessionAction(intent.agentText, {
@@ -3080,7 +3081,26 @@ export function createWechatMpService({
if (sessionActionResult.action === 'ignore_previous') {
intent.sessionAction = sessionActionResult.action;
}
return { ...wechatIntent, sessionActionResult };
const withSession = {
...wechatIntent,
sessionActionResult,
};
if (!wechatIntentRouter?.refineIntent) {
return withSession;
}
try {
return await wechatIntentRouter.refineIntent({
ruleIntent: withSession,
text: intent.agentText,
openid,
});
} catch (err) {
logger.warn?.(
'WeChat MP intent router refine skipped:',
err instanceof Error ? err.message : err,
);
return withSession;
}
};
const handleInboundMessage = async (bodyText, query = {}) => {