feat(h5): add LLM intent router admin controls and shadow verification.

Expose shadow/canary router policy in ops admin, add FAQ rule fast-path,
and tighten router defaults (1200ms timeout, 0.65 confidence).
Includes verify-h5-llm-router-shadow for production canary rollout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-01 16:48:01 +08:00
parent 005612029f
commit 43bc8bbc2b
14 changed files with 1391 additions and 80 deletions
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* Local smoke: verify ambiguous H5 chat triggers LLM router shadow logging.
* Usage: node scripts/verify-h5-llm-router-shadow.mjs
*/
import crypto from 'node:crypto';
import { loadH5Environment } from './load-env.mjs';
import { createDbPool } from '../db.mjs';
import { createUserAuth } from '../user-auth.mjs';
import { resolveChatIntentRouterPolicy } from '../chat-intent-router.mjs';
loadH5Environment(import.meta.dirname);
const PORTAL = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
const USERNAME = process.env.VERIFY_LLM_ROUTER_USER ?? 'john2';
const PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888';
const QUERY = '周末想放松一下,有什么活动建议?';
const MAX_WAIT_MS = 120_000;
const LOG_PATH = process.env.MEMIND_LLM_ROUTER_SHADOW_LOG
?? '/Users/john/Library/Logs/memind-h5-local.log';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function pass(label, detail = '') {
console.log(`PASS ${label}${detail ? `: ${detail}` : ''}`);
}
function fail(label, detail = '') {
console.error(`FAIL ${label}${detail ? `: ${detail}` : ''}`);
process.exitCode = 1;
}
async function login() {
const pool = await createDbPool();
const auth = createUserAuth(pool);
const result = await auth.login({ username: USERNAME, password: PASSWORD, ip: '127.0.0.1' });
if (!result.ok) {
throw new Error(`john 登录失败: ${result.message ?? 'unknown'}`);
}
await pool.end();
return result.token;
}
async function createRun(token) {
const requestId = crypto.randomUUID();
const response = await fetch(`${PORTAL}/api/agent/runs`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Cookie: `tkmind_user_session=${token}`,
},
body: JSON.stringify({
request_id: requestId,
user_message: {
id: crypto.randomUUID(),
role: 'user',
content: [{ type: 'text', text: QUERY }],
metadata: { userVisible: true, displayText: QUERY },
},
}),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`POST /agent/runs ${response.status}: ${JSON.stringify(body)}`);
}
const run = body.run ?? body;
return { runId: run.id, requestId, status: run.status };
}
async function waitForRun(token, runId) {
const started = Date.now();
while (Date.now() - started < MAX_WAIT_MS) {
const response = await fetch(`${PORTAL}/api/agent/runs/${runId}`, {
headers: { Cookie: `tkmind_user_session=${token}` },
});
const payload = await response.json().catch(() => ({}));
const body = payload.run ?? payload;
if (!response.ok) {
throw new Error(`GET /agent/runs/${runId} ${response.status}: ${JSON.stringify(payload)}`);
}
if (['succeeded', 'failed'].includes(body.status)) {
return body;
}
await sleep(1000);
}
throw new Error(`run ${runId} 未在 ${MAX_WAIT_MS}ms 内完成`);
}
async function loadRunEvents(runId) {
const pool = await createDbPool();
const [rows] = await pool.query(
`SELECT event_type, data_json FROM h5_agent_run_events WHERE run_id = ? ORDER BY id ASC`,
[runId],
);
await pool.end();
return rows.map((row) => ({
event_type: row.event_type,
data: typeof row.data_json === 'string' ? JSON.parse(row.data_json) : row.data_json,
}));
}
async function waitForPortal() {
const started = Date.now();
while (Date.now() - started < 60_000) {
try {
const response = await fetch(`${PORTAL}/api/status`);
if (response.ok) return;
} catch {
// retry
}
await sleep(1000);
}
throw new Error(`Portal ${PORTAL} 未在 60s 内就绪`);
}
async function readShadowLogLines() {
try {
const fs = await import('node:fs/promises');
const text = await fs.readFile(LOG_PATH, 'utf8');
return text.split('\n').filter((line) => line.includes('[chat-llm-router-shadow]'));
} catch {
return [];
}
}
async function main() {
const policy = resolveChatIntentRouterPolicy();
console.log('--- router policy ---');
console.log(JSON.stringify({
enabled: policy.enabled,
shadowMode: policy.shadowMode,
canaryUserIds: policy.canaryUserIds,
timeoutMs: policy.timeoutMs,
}, null, 2));
if (!policy.enabled || !policy.shadowMode) {
fail('policy', '需要 MEMIND_CHAT_LLM_ROUTER_ENABLED=1 且 MEMIND_CHAT_LLM_ROUTER_SHADOW=1');
return;
}
pass('policy', 'shadow 模式已开启');
await waitForPortal();
pass('portal', PORTAL);
const token = await login();
pass('login', USERNAME);
const beforeShadowLines = await readShadowLogLines();
const { runId } = await createRun(token);
pass('agent run', runId);
await waitForRun(token, runId);
pass('run finished');
const events = await loadRunEvents(runId);
const routed = events.find((row) => row.event_type === 'intent_routed');
if (!routed?.data) {
fail('intent_routed', '未找到路由事件');
} else {
const { route, source, reason, llmShadow } = routed.data;
console.log('\n--- intent_routed ---');
console.log(JSON.stringify(routed.data, null, 2));
if (source === 'fallback' && route === 'agent_orchestration') {
pass('shadow routing', '规则未命中,Shadow 仍 fallback Agent');
} else if (source === 'rule') {
fail('shadow routing', `意外命中规则: ${reason ?? route}`);
} else {
fail('shadow routing', `source=${source}, route=${route}`);
}
if (llmShadow) {
pass('llm shadow payload', JSON.stringify(llmShadow));
} else {
fail('llm shadow payload', 'intent_routed 缺少 llmShadowLLM 路由可能未启用)');
}
}
await sleep(500);
const afterShadowLines = await readShadowLogLines();
const newShadowLines = afterShadowLines.slice(beforeShadowLines.length);
if (newShadowLines.length > 0) {
pass('shadow log', `${newShadowLines.length} 条 [chat-llm-router-shadow]`);
for (const line of newShadowLines.slice(-3)) {
console.log(line.trim());
}
} else if (!process.exitCode) {
console.log('NOTE shadow log 未写入 LaunchAgent 日志,但 intent_routed.llmShadow 已记录观测结果');
}
if (process.exitCode && process.exitCode !== 0) {
console.error('\n验证未完全通过。');
} else {
console.log('\n验证通过:Shadow 模式已观测 LLM 路由建议,用户侧仍走 Agent fallback。');
}
}
main().catch((err) => {
console.error(err instanceof Error ? err.stack ?? err.message : err);
process.exit(1);
});