feat(chat-intent): observe routing, keep discuss-first on chat, and escalate short follow-ups
Memind CI / Test, build, and release guards (push) Has been cancelled
Memind CI / Test, build, and release guards (push) Has been cancelled
Close the chat-session dead zone without flipping production flags: record 0.72 fallbacks, stop C-2 from treating「先聊聊」as execute, and promote「不对你去执行」after a completed direct turn on both h5direct and date sessions. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Stage A baseline for chat-task intent routing.
|
||||
* Reads existing agent-run events; does not change routing.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/report-chat-intent-baseline.mjs
|
||||
* node scripts/report-chat-intent-baseline.mjs --hours 48
|
||||
*/
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import {
|
||||
aggregateChatIntentObservations,
|
||||
observationFromIntentRoutedData,
|
||||
} from '../chat-intent-observation.mjs';
|
||||
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
function parseHours(argv) {
|
||||
const index = argv.indexOf('--hours');
|
||||
if (index < 0) return 24;
|
||||
const parsed = Number(argv[index + 1]);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 24;
|
||||
}
|
||||
|
||||
function parseJson(value) {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'object') return value;
|
||||
try {
|
||||
return JSON.parse(String(value));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const hours = parseHours(process.argv.slice(2));
|
||||
const sinceMs = Date.now() - hours * 60 * 60 * 1000;
|
||||
const pool = await createDbPool();
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT event_type, data_json
|
||||
FROM h5_agent_run_events
|
||||
WHERE created_at >= ?
|
||||
AND event_type IN (
|
||||
'intent_routed',
|
||||
'direct_session_escalated_to_deep_reasoning',
|
||||
'direct_escalation_context_injected'
|
||||
)
|
||||
ORDER BY created_at ASC`,
|
||||
[sinceMs],
|
||||
);
|
||||
const routed = [];
|
||||
let escalatedCount = 0;
|
||||
let contextInjectedCount = 0;
|
||||
for (const row of rows ?? []) {
|
||||
if (row.event_type === 'direct_session_escalated_to_deep_reasoning') {
|
||||
escalatedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (row.event_type === 'direct_escalation_context_injected') {
|
||||
contextInjectedCount += 1;
|
||||
continue;
|
||||
}
|
||||
routed.push(observationFromIntentRoutedData(parseJson(row.data_json)));
|
||||
}
|
||||
const summary = aggregateChatIntentObservations({
|
||||
routed,
|
||||
escalatedCount,
|
||||
contextInjectedCount,
|
||||
});
|
||||
console.log(`==> chat intent baseline last ${hours}h`);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
if (summary.total === 0) {
|
||||
console.log('NOTE 窗口内没有 intent_routed。阶段 A 需要先有聊天流量。');
|
||||
} else {
|
||||
console.log(
|
||||
`NOTE 0.72 兜底 ${summary.ruleFallbackCount}/${summary.total}`
|
||||
+ ` (${summary.ruleFallbackRatio});`
|
||||
+ ` 升级 ${summary.escalatedCount};`
|
||||
+ ` 记忆召回仍走 direct ${summary.memoryRecallDirectRatio};`
|
||||
+ ` shadow wouldChangeRoute ${summary.wouldChangeRoute}/${summary.shadowObserved}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -120,6 +120,8 @@ const SCOPE_RULES = [
|
||||
/session-snapshot/i,
|
||||
/direct-chat-service/i,
|
||||
/chat-intent-router/i,
|
||||
/chat-intent-observation/i,
|
||||
/chat-task-intent/i,
|
||||
/scripts\/check-memory-v2/i,
|
||||
/scripts\/smoke-memory-v2/i,
|
||||
],
|
||||
@@ -131,6 +133,8 @@ const SCOPE_RULES = [
|
||||
'episodic-memory.test.mjs',
|
||||
'direct-chat-service.test.mjs',
|
||||
'chat-intent-router.test.mjs',
|
||||
'chat-intent-observation.test.mjs',
|
||||
'chat-task-intent-config.test.mjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local Stage D check: casual chat then a short "go do it" follow-up.
|
||||
* Does not require Goose to finish; waits for intent_routed only.
|
||||
*
|
||||
* H5_PORT=8086 node scripts/verify-chat-intent-followup.mjs
|
||||
*/
|
||||
import crypto from 'node:crypto';
|
||||
import { loadH5Environment } from './load-env.mjs';
|
||||
import { createDbPool } from '../db.mjs';
|
||||
import { createUserAuth, USER_COOKIE } from '../user-auth.mjs';
|
||||
import { CHAT_INTENT_ROUTE } from '../chat-intent-router.mjs';
|
||||
import { isDirectFollowupEscalateEnabledForUser, resolveDirectFollowupEscalatePolicy } from '../chat-task-intent-config.mjs';
|
||||
|
||||
loadH5Environment(import.meta.dirname);
|
||||
|
||||
const PORTAL = `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`;
|
||||
const USERNAME = process.env.MEMIND_E2E_USERNAME ?? 'john';
|
||||
const PASSWORD = process.env.JOHN_PASSWORD ?? process.env.H5_ACCESS_PASSWORD ?? '888888';
|
||||
const DIRECT_MESSAGE = '我想了解车载冰箱,MPV 用,宽度不超过 60cm,先聊聊选购要点';
|
||||
const FOLLOWUP_MESSAGE = '不对,你去执行';
|
||||
const EVENT_WAIT_MS = 90_000;
|
||||
|
||||
const issues = [];
|
||||
|
||||
function pass(label, detail = '') {
|
||||
console.log(`✔ ${label}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
function fail(label, detail = '') {
|
||||
issues.push({ label, detail });
|
||||
console.error(`✘ ${label}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
function parseEventData(raw) {
|
||||
if (!raw) return null;
|
||||
if (typeof raw === 'object') return raw;
|
||||
try {
|
||||
return JSON.parse(String(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginViaApi() {
|
||||
const response = await fetch(`${PORTAL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: USERNAME, password: PASSWORD }),
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (response.ok && body?.authenticated) {
|
||||
const setCookie = response.headers.getSetCookie?.() ?? [];
|
||||
const cookieLine = setCookie.find((line) => line.startsWith(`${USER_COOKIE}=`))
|
||||
?? response.headers.get('set-cookie');
|
||||
const match = String(cookieLine ?? '').match(new RegExp(`${USER_COOKIE}=([^;]+)`));
|
||||
if (match?.[1]) {
|
||||
return { token: decodeURIComponent(match[1]), userId: body.user?.id ?? null };
|
||||
}
|
||||
}
|
||||
const pool = await createDbPool();
|
||||
const auth = createUserAuth(pool);
|
||||
const result = await auth.login({ username: USERNAME, password: PASSWORD, ip: '127.0.0.1' });
|
||||
await pool.end();
|
||||
if (!result.ok || !result.token) {
|
||||
throw new Error(`登录失败: ${result.message ?? 'unknown'}`);
|
||||
}
|
||||
return { token: result.token, userId: result.user?.id ?? null };
|
||||
}
|
||||
|
||||
async function createRun(token, { message, sessionId = null }) {
|
||||
const response = await fetch(`${PORTAL}/api/agent/runs`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: `${USER_COOKIE}=${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
request_id: crypto.randomUUID(),
|
||||
session_id: sessionId,
|
||||
user_message: {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: message }],
|
||||
metadata: { displayText: message, userVisible: true },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`POST /api/agent/runs ${response.status}: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
const run = payload.run ?? payload;
|
||||
return { runId: run.id, sessionId: run.sessionId ?? run.agent_session_id ?? sessionId ?? null };
|
||||
}
|
||||
|
||||
async function getRun(token, runId) {
|
||||
const response = await fetch(`${PORTAL}/api/agent/runs/${runId}`, {
|
||||
headers: { Cookie: `${USER_COOKIE}=${token}` },
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
return payload.run ?? payload;
|
||||
}
|
||||
|
||||
async function readRunEvents(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 created_at ASC`,
|
||||
[runId],
|
||||
);
|
||||
await pool.end();
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function waitForRunEvents(token, runId, { requireCompleted = false } = {}) {
|
||||
const started = Date.now();
|
||||
while (Date.now() - started < EVENT_WAIT_MS) {
|
||||
const run = await getRun(token, runId);
|
||||
const events = await readRunEvents(runId);
|
||||
const routed = events.find((row) => row.event_type === 'intent_routed');
|
||||
const completed = events.find((row) => row.event_type === 'direct_chat_completed');
|
||||
const sessionId = parseEventData(completed?.data_json)?.sessionId
|
||||
?? run?.sessionId
|
||||
?? run?.agent_session_id
|
||||
?? null;
|
||||
if (routed && sessionId && (!requireCompleted || completed || ['succeeded', 'failed'].includes(run?.status))) {
|
||||
return {
|
||||
run,
|
||||
events,
|
||||
routed: parseEventData(routed.data_json),
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
throw new Error(`run ${runId} 未在 ${EVENT_WAIT_MS}ms 内拿到路由/session`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const health = await fetch(`${PORTAL}/auth/status`).catch(() => null);
|
||||
if (!health?.ok) throw new Error(`Portal 不可用: ${PORTAL}/auth/status`);
|
||||
pass('Portal', PORTAL);
|
||||
|
||||
const { token, userId } = await loginViaApi();
|
||||
pass('登录', `${USERNAME} (${userId ?? 'unknown'})`);
|
||||
|
||||
const policy = resolveDirectFollowupEscalatePolicy(process.env);
|
||||
if (!isDirectFollowupEscalateEnabledForUser(userId, policy)) {
|
||||
fail('阶段 D flag', `用户 ${userId} 未命中 DIRECT_FOLLOWUP_ESCALATE canary`);
|
||||
} else {
|
||||
pass('阶段 D flag', 'enabled for user');
|
||||
}
|
||||
|
||||
const direct = await createRun(token, { message: DIRECT_MESSAGE });
|
||||
pass('direct 提交', direct.runId);
|
||||
const directResult = await waitForRunEvents(token, direct.runId, { requireCompleted: true });
|
||||
if (directResult.routed?.route !== CHAT_INTENT_ROUTE.DIRECT_CHAT) {
|
||||
fail('direct 路由', String(directResult.routed?.route ?? 'missing'));
|
||||
} else {
|
||||
pass('direct 路由', directResult.routed.reason ?? 'direct_chat');
|
||||
}
|
||||
const sessionId = directResult.sessionId;
|
||||
if (!sessionId) fail('direct session', 'missing');
|
||||
else pass('direct session', sessionId);
|
||||
|
||||
const followup = await createRun(token, { message: FOLLOWUP_MESSAGE, sessionId });
|
||||
pass('followup 提交', followup.runId);
|
||||
const followupResult = await waitForRunEvents(token, followup.runId);
|
||||
const routed = followupResult.routed;
|
||||
if (routed?.route !== CHAT_INTENT_ROUTE.AGENT) {
|
||||
fail('阶段 D 路由', `期望 agent,实际 ${routed?.route ?? 'missing'} / ${routed?.reason ?? ''}`);
|
||||
} else if (!String(routed.reason ?? '').includes('短句纠偏')) {
|
||||
fail('阶段 D 原因', routed.reason ?? 'missing');
|
||||
} else {
|
||||
pass('阶段 D 路由', routed.reason);
|
||||
}
|
||||
const escalated = followupResult.events.some((row) =>
|
||||
row.event_type === 'direct_session_escalated_to_deep_reasoning');
|
||||
const isH5Direct = String(sessionId ?? '').startsWith('h5direct_');
|
||||
if (escalated) pass('升级事件', 'direct_session_escalated_to_deep_reasoning');
|
||||
else if (!isH5Direct) pass('升级事件', '日期 session 同会话升级,无需换 Goose session');
|
||||
else fail('升级事件', '未看到 direct session 升级');
|
||||
|
||||
console.log('\n=== 汇总 ===');
|
||||
if (issues.length) {
|
||||
for (const item of issues) console.log(` - ${item.label}: ${item.detail}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('chat intent followup 本地验收通过');
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user