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

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:
John
2026-09-19 13:13:17 +08:00
parent 82811d7b89
commit a30b62b987
15 changed files with 924 additions and 4 deletions
+4
View File
@@ -171,6 +171,10 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:5173
# MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED=1
# MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS=a6fb1e97-2b0f-447b-b138-4561d8e5c53e
# MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH=12
# 阶段 D:h5direct 上一轮直聊后的短句纠偏(「不对,你去执行」)
# MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_ENABLED=1
# MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_CANARY_USER_IDS=a6fb1e97-2b0f-447b-b138-4561d8e5c53e
# 阶段 A 只读基线:npm run check:chat-intent-baseline
# ----- Token 压缩(本地开发可选,降低 DeepSeek 上下文)-----
# Agent 会话历史压缩:超过阈值后清空 Goose 会话并保留摘要(默认 180 条 / 12 万字符,偏松)
+43
View File
@@ -688,6 +688,43 @@ function parseIntentRoutedEvent(dataJson) {
};
}
export async function resolveDirectSessionFollowUpHint(pool, {
userId,
sessionId,
excludeRunId = null,
} = {}) {
if (!pool || !userId || !String(sessionId ?? '').trim()) {
return { lastDirectChatCompleted: false };
}
const params = [userId, sessionId];
let excludeClause = '';
if (excludeRunId) {
excludeClause = ' AND r.id <> ?';
params.push(excludeRunId);
}
const [rows] = await pool.query(
`SELECT (
SELECT e.event_type
FROM h5_agent_run_events e
WHERE e.run_id = r.id
AND e.event_type IN (
'direct_chat_completed',
'direct_session_escalated_to_deep_reasoning'
)
ORDER BY e.created_at DESC
LIMIT 1
) AS last_event
FROM h5_agent_runs r
WHERE r.user_id = ? AND r.agent_session_id = ?${excludeClause}
ORDER BY r.created_at DESC
LIMIT 1`,
params,
);
return {
lastDirectChatCompleted: rows?.[0]?.last_event === 'direct_chat_completed',
};
}
export async function resolveActiveTaskContext(pool, {
userId,
sessionId,
@@ -1766,6 +1803,11 @@ export function createAgentRunGateway({
userId: row.user_id,
excludeLatestUserText: displayText,
}).catch(() => '');
const followUpHint = await resolveDirectSessionFollowUpHint(pool, {
userId: row.user_id,
sessionId: row.agent_session_id ?? null,
excludeRunId: runId,
}).catch(() => ({ lastDirectChatCompleted: false }));
return chatIntentRouter.classify({
userId: row.user_id,
userMessage,
@@ -1776,6 +1818,7 @@ export function createAgentRunGateway({
grantedSkills,
activeTaskContext,
recentTranscript,
lastDirectChatCompleted: followUpHint.lastDirectChatCompleted === true,
});
}
+42
View File
@@ -10,11 +10,53 @@ import {
createAgentRunGateway,
normalizeAgentRunWorkerIdentity,
resolveActiveTaskContext,
resolveDirectSessionFollowUpHint,
resolveRouterTranscript,
resolveRequiredCodeExecutor,
shouldPreferDirectChat,
} from './agent-run-gateway.mjs';
test('resolveDirectSessionFollowUpHint is true after a direct chat turn on h5direct', async () => {
const pool = {
async query() {
return [[{ last_event: 'direct_chat_completed' }]];
},
};
const hint = await resolveDirectSessionFollowUpHint(pool, {
userId: 'user-1',
sessionId: 'h5direct_abc',
excludeRunId: 'run-current',
});
assert.equal(hint.lastDirectChatCompleted, true);
});
test('resolveDirectSessionFollowUpHint reads last direct turn on date sessions', async () => {
const pool = {
async query() {
return [[{ last_event: 'direct_chat_completed' }]];
},
};
const hint = await resolveDirectSessionFollowUpHint(pool, {
userId: 'user-1',
sessionId: '20260919_7',
excludeRunId: 'run-current',
});
assert.equal(hint.lastDirectChatCompleted, true);
});
test('resolveDirectSessionFollowUpHint stays false without session', async () => {
const pool = {
async query() {
throw new Error('should not query without session');
},
};
const hint = await resolveDirectSessionFollowUpHint(pool, {
userId: 'user-1',
sessionId: '',
});
assert.equal(hint.lastDirectChatCompleted, false);
});
test('resolveActiveTaskContext returns failed agent task metadata for same session', async () => {
const now = Date.now();
const pool = {
+107
View File
@@ -0,0 +1,107 @@
export const RULE_FALLBACK_CONFIDENCE = 0.72;
export const RULE_FALLBACK_REASON = '普通对话默认走轻量聊天';
function asFiniteNumber(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function bucketTextLength(length) {
if (!length) return 'empty';
if (length <= 6) return 'short_le6';
if (length <= 12) return 'medium_le12';
return 'long_gt12';
}
export function isRuleFallbackClassification(classification = {}) {
if (classification?.ruleFallback === true) return true;
const source = String(classification?.source ?? '').trim();
const reason = String(classification?.reason ?? '').trim();
const confidence = asFiniteNumber(classification?.confidence);
return source === 'rule'
&& confidence != null
&& Math.abs(confidence - RULE_FALLBACK_CONFIDENCE) < 1e-9
&& reason.includes(RULE_FALLBACK_REASON);
}
export function summarizeChatIntentObservation(classification = {}, context = {}) {
const text = String(context.text ?? '').trim();
const textLength = text.length;
const confidence = asFiniteNumber(classification.confidence);
return {
route: classification.route ?? null,
source: String(classification.source ?? '').trim() || null,
confidence,
ruleFallback: isRuleFallbackClassification(classification),
memoryRecall: context.memoryRecall === true,
wouldChangeRoute: Boolean(classification.llmShadow?.wouldChangeRoute),
llmShadowRoute: classification.llmShadow?.route ?? null,
textLength,
textLengthBucket: bucketTextLength(textLength),
};
}
export function observationFromIntentRoutedData(data = {}, fallbackText = '') {
if (data?.observation && typeof data.observation === 'object') {
return data.observation;
}
return summarizeChatIntentObservation(data, { text: fallbackText });
}
function increment(map, key) {
const normalized = key == null ? '(null)' : String(key);
map[normalized] = (map[normalized] || 0) + 1;
}
export function aggregateChatIntentObservations({
routed = [],
escalatedCount = 0,
contextInjectedCount = 0,
} = {}) {
const total = routed.length;
const sourceCounts = {};
const routeCounts = {};
const bucketCounts = {};
let ruleFallbackCount = 0;
let memoryRecallCount = 0;
let memoryRecallNotDirect = 0;
let shadowObserved = 0;
let wouldChangeRoute = 0;
for (const item of routed) {
increment(sourceCounts, item.source);
increment(routeCounts, item.route);
increment(bucketCounts, item.textLengthBucket);
if (item.ruleFallback) ruleFallbackCount += 1;
if (item.memoryRecall) {
memoryRecallCount += 1;
if (item.route && item.route !== 'direct_chat') memoryRecallNotDirect += 1;
}
if (item.llmShadowRoute != null || item.wouldChangeRoute) {
shadowObserved += 1;
}
if (item.wouldChangeRoute) wouldChangeRoute += 1;
}
const ratio = (count) => (total > 0 ? Number((count / total).toFixed(4)) : 0);
return {
total,
ruleFallbackCount,
ruleFallbackRatio: ratio(ruleFallbackCount),
escalatedCount,
escalationRatio: ratio(escalatedCount),
contextInjectedCount,
sourceCounts,
routeCounts,
shortMessageCounts: bucketCounts,
memoryRecallCount,
memoryRecallDirectRatio: memoryRecallCount > 0
? Number(((memoryRecallCount - memoryRecallNotDirect) / memoryRecallCount).toFixed(4))
: 1,
shadowObserved,
wouldChangeRoute,
wouldChangeRouteRatio: shadowObserved > 0
? Number((wouldChangeRoute / shadowObserved).toFixed(4))
: 0,
};
}
+124
View File
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
aggregateChatIntentObservations,
isRuleFallbackClassification,
observationFromIntentRoutedData,
RULE_FALLBACK_CONFIDENCE,
RULE_FALLBACK_REASON,
summarizeChatIntentObservation,
} from './chat-intent-observation.mjs';
import { CHAT_INTENT_ROUTE, classifyWithRules } from './chat-intent-router.mjs';
test('isRuleFallbackClassification accepts explicit flag or legacy 0.72 reason', () => {
assert.equal(isRuleFallbackClassification({ ruleFallback: true }), true);
assert.equal(isRuleFallbackClassification({
source: 'rule',
confidence: RULE_FALLBACK_CONFIDENCE,
reason: RULE_FALLBACK_REASON,
}), true);
assert.equal(isRuleFallbackClassification({
source: 'llm',
confidence: RULE_FALLBACK_CONFIDENCE,
reason: RULE_FALLBACK_REASON,
}), false);
});
test('summarizeChatIntentObservation tags short text and shadow flips', () => {
const observation = summarizeChatIntentObservation({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
source: 'rule',
confidence: 0.72,
reason: RULE_FALLBACK_REASON,
ruleFallback: true,
llmShadow: { route: CHAT_INTENT_ROUTE.AGENT, wouldChangeRoute: true },
}, {
text: '帮我做',
memoryRecall: false,
});
assert.equal(observation.ruleFallback, true);
assert.equal(observation.textLengthBucket, 'short_le6');
assert.equal(observation.wouldChangeRoute, true);
assert.equal(observation.llmShadowRoute, CHAT_INTENT_ROUTE.AGENT);
});
test('observationFromIntentRoutedData prefers stored observation', () => {
const stored = { route: 'direct_chat', ruleFallback: true, textLengthBucket: 'long_gt12' };
assert.deepEqual(observationFromIntentRoutedData({
route: 'agent_orchestration',
observation: stored,
}), stored);
const inferred = observationFromIntentRoutedData({
route: 'direct_chat',
source: 'rule',
confidence: 0.72,
reason: RULE_FALLBACK_REASON,
});
assert.equal(inferred.ruleFallback, true);
});
test('aggregateChatIntentObservations computes stage A ratios', () => {
const summary = aggregateChatIntentObservations({
routed: [
{
route: 'direct_chat',
source: 'rule',
ruleFallback: true,
textLengthBucket: 'long_gt12',
memoryRecall: false,
wouldChangeRoute: true,
llmShadowRoute: 'agent_orchestration',
},
{
route: 'direct_chat',
source: 'rule',
ruleFallback: false,
textLengthBucket: 'short_le6',
memoryRecall: true,
wouldChangeRoute: false,
llmShadowRoute: null,
},
{
route: 'agent_orchestration',
source: 'llm',
ruleFallback: false,
textLengthBucket: 'long_gt12',
memoryRecall: false,
wouldChangeRoute: false,
llmShadowRoute: null,
},
],
escalatedCount: 1,
contextInjectedCount: 1,
});
assert.equal(summary.total, 3);
assert.equal(summary.ruleFallbackRatio, 0.3333);
assert.equal(summary.escalatedCount, 1);
assert.equal(summary.memoryRecallDirectRatio, 1);
assert.equal(summary.wouldChangeRouteRatio, 1);
});
test('classifyWithRules attaches stage A observation on 0.72 fallback', () => {
const result = classifyWithRules({
text: '帮我比较一下英得尔和美的车载冰箱',
sessionId: 'h5direct_abc',
sessionMessageCount: 3,
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
assert.equal(result.ruleFallback, true);
assert.equal(result.observation.ruleFallback, true);
assert.equal(result.observation.textLengthBucket, 'long_gt12');
assert.equal(result.observation.memoryRecall, false);
});
test('classifyWithRules marks memory recall without treating it as 0.72 fallback', () => {
const result = classifyWithRules({
text: '你记得我说想去哪儿吗',
sessionId: 'h5direct_abc',
sessionMessageCount: 3,
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.observation.memoryRecall, true);
assert.equal(result.observation.ruleFallback, false);
});
+87 -2
View File
@@ -30,14 +30,19 @@ import { resolveMemindRuntimeProfile } from './scripts/memind-runtime-profile.mj
import { pgvectorMemoryBackendInternals } from './memory-v2-pgvector.mjs';
import {
isChatSessionDeferEnabledForUser,
isDirectFollowupEscalateEnabledForUser,
resolveChatSessionDeferPolicy,
resolveDirectFollowupEscalatePolicy,
shouldDeferChatSessionRoutingToLlm,
} from './chat-task-intent-config.mjs';
import { summarizeChatIntentObservation } from './chat-intent-observation.mjs';
export { shouldDeferChatSessionRoutingToLlm } from './chat-task-intent-config.mjs';
export {
resolveChatSessionDeferPolicy,
isChatSessionDeferEnabledForUser,
resolveDirectFollowupEscalatePolicy,
isDirectFollowupEscalateEnabledForUser,
} from './chat-task-intent-config.mjs';
export { matchDirectChatFaqRule, DIRECT_CHAT_FAQ_RULES, FAQ_EXCLUSION_PATTERNS, isExplicitTextOnlyRequest } from './chat-intent-router-rules.mjs';
@@ -126,6 +131,17 @@ const OBVIOUS_DIRECT_CHAT_PATTERNS = [
/(?:继续|再).{0,8}(?:写|讲|说|编).{0,10}(?:诗|故事|笑话|散文)/u,
];
/** User hedges that they only want to discuss first — keep C-2 from treating selection talk as execute. */
const DISCUSSION_FIRST_PATTERNS = [
/(?:想)?先(?:来)?(?:聊聊|聊一聊|聊下|讨论|了解|问问|请教)/u,
/先(?:不要|别|不用)(?:去)?(?:执行|做|生成|搜索|查)/u,
/先不(?:要|用)?(?:去)?(?:执行|做)/u,
/(?:只要|先要)(?:文字)?(?:建议|交流|讨论)/u,
/先不(?:要|用)(?:工具|联网|搜索|执行)/u,
];
const DISCUSSION_FIRST_EXECUTE_OVERRIDE = /(?:你去执行|现在就(?:做|执行|查|搜|生成)|马上(?:做|执行|查)|立刻(?:做|执行)|帮我(?:生成|做|制作|发布).{0,16}(?:页面|网页|文件|链接)|做成(?:页面|网页))/u;
export const IMAGE_GENERATION_MODE = {
AUTO: 'auto',
REQUIRED: 'required',
@@ -351,6 +367,35 @@ export function isAgentTaskFollowUpText(text) {
return AGENT_TASK_FOLLOWUP_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isDiscussionFirstText(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
if (!DISCUSSION_FIRST_PATTERNS.some((pattern) => pattern.test(normalized))) return false;
if (DISCUSSION_FIRST_EXECUTE_OVERRIDE.test(normalized)) return false;
if (OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized))) return false;
if (
isPageGenerationIntent(normalized)
|| isPageDataIntent(normalized)
|| isPageDataDevIntent(normalized)
) {
return false;
}
return true;
}
export function shouldEscalateDirectSessionFollowUp({
enabled = false,
text = '',
sessionId = null,
lastDirectChatCompleted = false,
} = {}) {
if (!enabled || lastDirectChatCompleted !== true) return false;
if (!String(sessionId ?? '').trim()) return false;
const normalized = String(text ?? '').trim();
if (!normalized || isExplicitDirectChatOnlyText(normalized)) return false;
return isAgentTaskFollowUpText(normalized);
}
function isExplicitDirectChatOnlyText(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
@@ -358,6 +403,7 @@ function isExplicitDirectChatOnlyText(text) {
if (isExplicitTextOnlyRequest(normalized)) return true;
if (OBVIOUS_DIRECT_PATTERNS.some((pattern) => pattern.test(normalized))) return true;
if (OBVIOUS_DIRECT_CHAT_PATTERNS.some((pattern) => pattern.test(normalized))) return true;
if (isDiscussionFirstText(normalized)) return true;
const faqMatch = matchDirectChatFaqRule(normalized);
return Boolean(faqMatch);
}
@@ -651,11 +697,12 @@ function buildRouterSystemPrompt(grantedSkills = []) {
'',
'判断原则:',
'- 用户只要文字回答,不要求“做出来/发布/生成链接/改文件” → direct_chat',
'- 用户明确说「想先聊聊/先讨论/先了解/先不要执行」,只要建议或讨论 → direct_chat,即使话题是选型、对比或清单',
'- 用户要产出可访问页面、文件、链接,或需要工具/skills → agent_orchestration',
'- 用户要求对比选型、整理清单/待办/用药/作业、规划行程交通住宿,或把结果发给家人同事(即使没说“页面”)→ agent_orchestration,不要只当口头建议',
'- 用户要求对比选型、整理清单/待办/用药/作业、规划行程交通住宿,或把结果发给家人同事(即使没说“页面”)→ agent_orchestration,不要只当口头建议;但用户同时说先聊/先讨论/先不要执行时仍走 direct_chat',
'- 用户要「做攻略/行程/线路图/游记」或粘贴多日自驾/旅行行程(即使未说「页面」)→ agent_orchestration + static-page-publish,不要只回文字',
'- 用户询问实时赛况、新闻、天气、行情等需要联网查询的问题 → agent_orchestration,suggested_skill 填 web(不要填 search)',
'- 不确定时优先 agent_orchestration,避免漏执行',
'- 不确定时优先 agent_orchestration,避免漏执行;用户已表明只要先聊时除外',
'- 若 [Active Task] 显示上一轮 Agent 任务失败或进行中,用户当前消息是在纠正、补充、追问进度或要求按前文继续 → agent_orchestration,不要判成 direct_chat',
'- [Recent Conversation] 是对话摘要,当前消息可能引用上文(如「不对」「按我说的」「还是没有改」);结合上下文判断,不要孤立看当前一句',
'- 记忆线索只用于辅助判断本轮意图,不能替用户扩写新需求',
@@ -934,6 +981,10 @@ export function finalizeRouterClassification(classification, context = {}) {
return {
...classification,
decision: buildRouterNormalizedDecision(classification, context),
observation: summarizeChatIntentObservation(classification, {
text: context.text,
memoryRecall: isMemoryRecallQuestion(context.text),
}),
};
}
@@ -1116,6 +1167,7 @@ function normalizeClassification(raw, { source, fallbackRoute = CHAT_INTENT_ROUT
suggestedSkill,
agentBrief,
source,
ruleFallback: raw?.ruleFallback === true,
providerKeyId: raw?.providerKeyId ?? raw?.provider_key_id ?? null,
model: raw?.model ?? null,
memory: raw?.memory ?? null,
@@ -1275,6 +1327,8 @@ export function classifyWithRules({
grantedSkills = [],
chatSessionDeferEnabled = false,
chatSessionDeferMinTextLength = 12,
directFollowupEscalateEnabled = false,
lastDirectChatCompleted = false,
} = {}) {
const decisionContext = {
text,
@@ -1419,6 +1473,13 @@ export function classifyWithRules({
if (includeIntentPatterns && normalized && isRealtimeInfoQuestion(normalized)) {
return finalizeRouterClassification(buildRealtimeInfoClassification(), decisionContext);
}
if (includeIntentPatterns && normalized && isDiscussionFirstText(normalized)) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.92,
reason: '用户明确要求先讨论,不执行任务',
}, { source: 'rule' }), decisionContext);
}
if (includeIntentPatterns && normalized) {
if (
shouldDeferActiveTaskRoutingToLlm({
@@ -1430,6 +1491,18 @@ export function classifyWithRules({
) {
return null;
}
if (shouldEscalateDirectSessionFollowUp({
enabled: directFollowupEscalateEnabled,
text: normalized,
sessionId,
lastDirectChatCompleted,
})) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.9,
reason: '聊天会话短句纠偏,继续执行任务',
}, { source: 'rule' }), decisionContext);
}
if (shouldDeferChatSessionRoutingToLlm({
enabled: chatSessionDeferEnabled,
text: normalized,
@@ -1442,6 +1515,7 @@ export function classifyWithRules({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.72,
reason: '普通对话默认走轻量聊天',
ruleFallback: true,
}, { source: 'rule' }), decisionContext);
}
return null;
@@ -1475,6 +1549,8 @@ export function createChatIntentRouter(options = {}) {
});
const chatSessionDeferPolicy = options.chatSessionDeferPolicy
?? resolveChatSessionDeferPolicy(env);
const directFollowupEscalatePolicy = options.directFollowupEscalatePolicy
?? resolveDirectFollowupEscalatePolicy(env);
function isLlmRouterEligibleForUser(userId) {
return isChatLlmRouterEligible({
@@ -1773,6 +1849,7 @@ export function createChatIntentRouter(options = {}) {
grantedSkills = [],
activeTaskContext = null,
recentTranscript = '',
lastDirectChatCompleted = false,
} = {}) {
const text = messageDisplayText(userMessage);
const decisionContext = {
@@ -1805,6 +1882,10 @@ export function createChatIntentRouter(options = {}) {
};
const llmRouterEligible = isLlmRouterEligibleForUser(userId);
const chatSessionDeferEnabled = isChatSessionDeferEnabledForUser(userId, chatSessionDeferPolicy);
const directFollowupEscalateEnabled = isDirectFollowupEscalateEnabledForUser(
userId,
directFollowupEscalatePolicy,
);
const ruleResult = classifyWithRules({
text,
forceDeepReasoning,
@@ -1817,6 +1898,8 @@ export function createChatIntentRouter(options = {}) {
grantedSkills,
chatSessionDeferEnabled,
chatSessionDeferMinTextLength: chatSessionDeferPolicy.minTextLength,
directFollowupEscalateEnabled,
lastDirectChatCompleted,
});
if (ruleResult) return finalizeWithCoercion(ruleResult);
@@ -1967,6 +2050,8 @@ const LOCAL_CHAT_ROUTER_ENV_KEYS = [
'MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH',
'MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED',
'MEMIND_DIRECT_ESCALATION_CONTEXT_CANARY_USER_IDS',
'MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_ENABLED',
'MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_CANARY_USER_IDS',
];
export function mergeChatRouterEffectiveEnv(processEnv = {}, overrides = {}) {
+181
View File
@@ -11,9 +11,11 @@ import {
coercePageGenerationSkill,
createChatIntentRouter,
createManagedChatIntentRouter,
isDiscussionFirstText,
mergeChatRouterEffectiveEnv,
formatRouterTranscript,
shouldDeferActiveTaskRoutingToLlm,
shouldEscalateDirectSessionFollowUp,
shouldForceActiveAgentTaskContinuation,
isNormalizedRouterDecisionEnabled,
isNormalizedRouterDecisionShadow,
@@ -1101,6 +1103,73 @@ test('classifyWithRules routes memory recall to direct chat on fresh session', (
assert.equal(lastConversation.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
});
test('shouldEscalateDirectSessionFollowUp fires after direct chat on date or h5direct sessions', () => {
assert.equal(shouldEscalateDirectSessionFollowUp({
enabled: true,
text: '不对,你去执行',
sessionId: 'h5direct_abc',
lastDirectChatCompleted: true,
}), true);
assert.equal(shouldEscalateDirectSessionFollowUp({
enabled: true,
text: '不对,你去执行',
sessionId: '20260919_7',
lastDirectChatCompleted: true,
}), true);
assert.equal(shouldEscalateDirectSessionFollowUp({
enabled: false,
text: '不对,你去执行',
sessionId: 'h5direct_abc',
lastDirectChatCompleted: true,
}), false);
assert.equal(shouldEscalateDirectSessionFollowUp({
enabled: true,
text: '不对,你去执行',
sessionId: '20260919_7',
lastDirectChatCompleted: false,
}), false);
assert.equal(shouldEscalateDirectSessionFollowUp({
enabled: true,
text: '你好',
sessionId: 'h5direct_abc',
lastDirectChatCompleted: true,
}), false);
});
test('classifyWithRules escalates short follow-up on h5direct after direct chat', () => {
const result = classifyWithRules({
text: '不对,你去执行',
sessionId: 'h5direct_abc',
sessionMessageCount: 4,
directFollowupEscalateEnabled: true,
lastDirectChatCompleted: true,
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.match(result.reason, /短句纠偏/);
});
test('classifyWithRules escalates short follow-up on date sessions after direct chat', () => {
const result = classifyWithRules({
text: '不对,你去执行',
sessionId: '20260919_7',
sessionMessageCount: 4,
directFollowupEscalateEnabled: true,
lastDirectChatCompleted: true,
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.match(result.reason, /短句纠偏/);
});
test('classifyWithRules keeps short follow-up on 0.72 fallback when stage D is off', () => {
const result = classifyWithRules({
text: '不对,你去执行',
sessionId: 'h5direct_abc',
sessionMessageCount: 4,
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.ruleFallback, true);
});
test('classifyWithRules defers ambiguous chat text to llm when chat session defer is enabled', () => {
const result = classifyWithRules({
text: '帮我比较一下英得尔和美的车载冰箱',
@@ -1112,6 +1181,41 @@ test('classifyWithRules defers ambiguous chat text to llm when chat session defe
assert.equal(result, null);
});
test('isDiscussionFirstText keeps discuss-first shopping talk off the execute path', () => {
assert.equal(
isDiscussionFirstText('想先聊聊车载冰箱怎么选,MPV 用,宽度不超过 60cm'),
true,
);
assert.equal(isDiscussionFirstText('先了解一下英得尔和冰虎怎么选'), true);
assert.equal(isDiscussionFirstText('先不要执行,帮我说说选购要点'), true);
assert.equal(isDiscussionFirstText('帮我比较一下英得尔和美的车载冰箱'), false);
assert.equal(isDiscussionFirstText('想先聊聊然后帮我生成一个选购页面'), false);
assert.equal(isDiscussionFirstText('想先聊聊,你去执行'), false);
});
test('classifyWithRules keeps discuss-first shopping talk on direct chat when defer is enabled', () => {
const result = classifyWithRules({
text: '想先聊聊车载冰箱怎么选,MPV 用,宽度不超过 60cm',
sessionId: 'h5direct_abc',
sessionMessageCount: 3,
chatSessionDeferEnabled: true,
chatSessionDeferMinTextLength: 12,
});
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
assert.equal(result.reason, '用户明确要求先讨论,不执行任务');
});
test('classifyWithRules still routes discuss-first plus page request to agent', () => {
const result = classifyWithRules({
text: '想先聊聊然后帮我生成一个选购页面',
sessionId: 'h5direct_abc',
sessionMessageCount: 3,
chatSessionDeferEnabled: true,
});
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
});
test('classifyWithRules keeps memory recall on direct chat when defer is enabled', () => {
const result = classifyWithRules({
text: '你记得我说想去哪儿吗',
@@ -1122,6 +1226,40 @@ test('classifyWithRules keeps memory recall on direct chat when defer is enabled
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
});
test('createChatIntentRouter escalates h5direct follow-up when stage D is enabled for the user', async () => {
let llmCalls = 0;
const router = createChatIntentRouter({
llmProviderService: {
async createChatCompletion() {
llmCalls += 1;
return { ok: false, message: 'unused' };
},
},
env: {
...process.env,
MEMIND_CHAT_LLM_ROUTER_ENABLED: '1',
MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_ENABLED: '1',
MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_CANARY_USER_IDS: 'john',
},
});
const result = await router.classify({
userId: 'john',
sessionId: 'h5direct_abc',
sessionMessageCount: 4,
lastDirectChatCompleted: true,
userMessage: {
role: 'user',
content: [{ type: 'text', text: '不对,你去执行' }],
metadata: { displayText: '不对,你去执行' },
},
});
assert.equal(llmCalls, 0);
assert.equal(result.route, CHAT_INTENT_ROUTE.AGENT);
assert.equal(result.source, 'rule');
});
test('createChatIntentRouter calls LLM for ambiguous chat-session text when defer is enabled', async () => {
let llmCalls = 0;
const router = createChatIntentRouter({
@@ -1206,6 +1344,49 @@ test('createChatIntentRouter keeps greetings on rules when chat-session defer is
assert.equal(result.source, 'rule');
});
test('createChatIntentRouter keeps discuss-first shopping talk on rules when defer is enabled', async () => {
let llmCalls = 0;
const router = createChatIntentRouter({
llmProviderService: {
async createChatCompletion() {
llmCalls += 1;
return {
ok: true,
reply: JSON.stringify({
route: 'agent_orchestration',
confidence: 0.99,
reason: 'should not run',
}),
};
},
},
env: {
...process.env,
MEMIND_CHAT_LLM_ROUTER_ENABLED: '1',
MEMIND_CHAT_LLM_ROUTER_SHADOW: '0',
MEMIND_CHAT_ROUTER_CANARY_USER_IDS: 'john',
MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED: '1',
MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_CANARY_USER_IDS: 'john',
MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH: '12',
},
});
const result = await router.classify({
userId: 'john',
sessionId: 'h5direct_abc',
sessionMessageCount: 3,
userMessage: {
role: 'user',
content: [{ type: 'text', text: '想先聊聊车载冰箱怎么选,MPV 用,宽度不超过 60cm' }],
metadata: { displayText: '想先聊聊车载冰箱怎么选,MPV 用,宽度不超过 60cm' },
},
});
assert.equal(llmCalls, 0);
assert.equal(result.route, CHAT_INTENT_ROUTE.DIRECT_CHAT);
assert.equal(result.source, 'rule');
});
test('createChatIntentRouter fast-paths news lookup to agent without LLM', async () => {
let llmCalls = 0;
const router = createChatIntentRouter({
+13
View File
@@ -50,6 +50,19 @@ export function isChatSessionDeferEnabledForUser(userId, policy) {
return policy.canaryUserIds.has(String(userId ?? '').trim());
}
export function resolveDirectFollowupEscalatePolicy(env = process.env) {
return {
enabled: envFlag(env?.MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_ENABLED, false),
canaryUserIds: parseUserIdSet(env?.MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_CANARY_USER_IDS),
};
}
export function isDirectFollowupEscalateEnabledForUser(userId, policy) {
if (!policy?.enabled) return false;
if (!policy.canaryUserIds?.size) return true;
return policy.canaryUserIds.has(String(userId ?? '').trim());
}
export function shouldDeferChatSessionRoutingToLlm({
enabled = false,
text = '',
+17
View File
@@ -3,8 +3,10 @@ import test from 'node:test';
import {
isChatSessionDeferEnabledForUser,
isDirectEscalationContextEnabledForUser,
isDirectFollowupEscalateEnabledForUser,
resolveChatSessionDeferPolicy,
resolveDirectEscalationContextPolicy,
resolveDirectFollowupEscalatePolicy,
shouldDeferChatSessionRoutingToLlm,
} from './chat-task-intent-config.mjs';
@@ -54,3 +56,18 @@ test('resolveChatSessionDeferPolicy parses min text length', () => {
assert.equal(isChatSessionDeferEnabledForUser('john', policy), true);
assert.equal(isChatSessionDeferEnabledForUser('other', policy), false);
});
test('resolveDirectFollowupEscalatePolicy defaults to disabled', () => {
const policy = resolveDirectFollowupEscalatePolicy({});
assert.equal(policy.enabled, false);
assert.equal(isDirectFollowupEscalateEnabledForUser('john', policy), false);
});
test('isDirectFollowupEscalateEnabledForUser respects canary list', () => {
const policy = resolveDirectFollowupEscalatePolicy({
MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_ENABLED: '1',
MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_CANARY_USER_IDS: 'john',
});
assert.equal(isDirectFollowupEscalateEnabledForUser('john', policy), true);
assert.equal(isDirectFollowupEscalateEnabledForUser('other', policy), false);
});
@@ -391,6 +391,8 @@ direct chat 当前 `stream:false` + 前端 600ms 轮询,长回答等待感明
**没有阶段 A,后续所有改动都无法证伪。**
落地(2026-09-19):`intent_routed` 现带稳定 `observation` 字段;用 `npm run check:chat-intent-baseline` 汇总最近窗口。不改路由结果。阶段 B/C 仍默认关。
### 11.3 阶段 B · 只补上下文
对应 §5 的 P1,但**只做 P1.1**,暂不做 P1.2 / P1.3。
@@ -467,6 +469,8 @@ chat-intent-router.mjs:1737
任一步指标超阈值即回退上一步。新增 flag:`MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_ENABLED`(默认 0)。
C-2 本地验证(2026-09-19):「想先聊聊车载冰箱怎么选」被 LLM 按「对比选型」升到 Agent。收窄闸门:`isDiscussionFirstText`(先聊聊/先讨论/先了解/先不要执行)走 `direct_chat`,不 defer;同句若已有生成页面/你去执行则仍升 Agent。LLM prompt 同步加「先聊除外」。
#### 11.4.5 成本与延迟
这是本阶段**唯一真实代价**:通过闸门的聊天轮次会多一次小模型 router 调用(生产为 `deepseek-chat`),在 direct chat 应答前引入约数百毫秒。
@@ -489,7 +493,9 @@ direct-chat-service.mjs:141
仅当阶段 A/C 数据显示「短句纠偏失败」占比超过阈值时才做。
范围收窄为:session 为 `h5direct_` **且** 上一轮 `direct_chat_completed` **且** 命中 `AGENT_TASK_FOLLOWUP_PATTERNS` → 强制 agent,并复用阶段 B 的上下文注入。
落地(2026-09-19):独立 flag `MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_ENABLED`(默认 0)+ canary。不放宽 `resolveActiveTaskContext` 对 `h5direct_` 的 null 返回;只在上一轮 `direct_chat_completed` 且命中 followup 模式时强制 agent。
范围收窄为:上一轮 `direct_chat_completed` **且** 命中 `AGENT_TASK_FOLLOWUP_PATTERNS` → 强制 agent。H5 新会话实际是 `20260919_7` 这类日期 ID,direct chat 会复用它,因此不能只认 `h5direct_` 前缀。`h5direct_` 升级仍走阶段 B 注入并换 Goose session;日期会话已在同 ID 上 mirror,升级时保持原 session。
不做:全面放宽 `resolveActiveTaskContext` 对 `h5direct_` 的 null 返回;不做 chat→chat 默认延续。
@@ -513,7 +519,7 @@ direct-chat-service.mjs:141
| A 观测 | P0 | 无 | 拿到 0.72 兜底占比与升级频率基线 |
| B 上下文 | P1.1 | 低 | 升级后 Goose 输入含 prior context;memory recall 仍 100% direct;`verify:h5-session-patches` 通过 |
| C 语义识别 | P2(修订) | 中,可灰度 | shadow `wouldChangeRoute` 在预期区间;canary 期 agent 触发率增幅低于阈值;`RESOLVED_INJECTED` 比例不下降 |
| D 纠偏 | P3(收窄) | 中 | 「不对你去执行」在 `h5direct_` 可进 agent;误触发率低于阈值 |
| D 纠偏 | P3(收窄) | 中 | 「不对你去执行」在上一轮 `direct_chat_completed` 后可进 agent(含日期 session / `h5direct_`);误触发率低于阈值 |
回归范围(除 `verify:h5-session-patches` 外):`chat-intent-router.test.mjs`、`direct-chat-service.test.mjs`、`agent-run-gateway.test.mjs`,以及 `scripts/verify-world-cup-flow-e2e.mjs`(该脚本显式断言不应出现 `direct_chat_completed`,对阶段 C 敏感)。
@@ -564,3 +570,5 @@ direct-chat-service.mjs:141
| 2026-08-28 | 增补 §11 最低影响实施方案:将 P2 的实现方式从 `execute_task` 修订为「让已有 LLM router 在聊天会话可达」,暂缓 tool calling;P1 收窄为仅 P1.1;P3 收窄为仅 `h5direct_` 纠偏 |
| 2026-08-28 | 增补 §12 Memory V2 兼容性:三条注入路径现状、各阶段影响、四条强制要求 |
| 2026-08-28 | 交叉引用 [Intent Layer V1 对照评估](./intent-layer-v1-assessment-20260828.md);开发顺序仍以本文 §11 为准 |
| 2026-09-19 | C-2 过激收窄:讨论优先(先聊聊/先讨论)不再 defer 给 LLM |
| 2026-09-19 | 阶段 D 不再只认 `h5direct_`:H5 日期 session 上一轮 direct 完成后短句纠偏同样生效 |
+2
View File
@@ -28,6 +28,8 @@ const FIELD_SPECS = [
{ env: 'MEMIND_CHAT_ROUTER_CHAT_SESSION_DEFER_MIN_TEXT_LENGTH', group: 'chatIntentRouter', field: 'chatSessionDeferMinTextLength', type: 'number' },
{ env: 'MEMIND_DIRECT_ESCALATION_CONTEXT_ENABLED', group: 'chatIntentRouter', field: 'directEscalationContextEnabled', type: 'boolean' },
{ env: 'MEMIND_DIRECT_ESCALATION_CONTEXT_CANARY_USER_IDS', group: 'chatIntentRouter', field: 'directEscalationContextCanaryUserIds', type: 'string' },
{ env: 'MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_ENABLED', group: 'chatIntentRouter', field: 'directFollowupEscalateEnabled', type: 'boolean' },
{ env: 'MEMIND_CHAT_ROUTER_DIRECT_FOLLOWUP_ESCALATE_CANARY_USER_IDS', group: 'chatIntentRouter', field: 'directFollowupEscalateCanaryUserIds', type: 'string' },
{ env: 'MEMORY_CANDIDATE_ENABLED', group: 'candidateMemory', field: 'enabled', type: 'boolean' },
{ env: 'MEMORY_CANDIDATE_MODE', group: 'candidateMemory', field: 'mode', type: 'string' },
+2
View File
@@ -139,6 +139,8 @@
"verify:tang-itl-readiness-103": "node scripts/verify-tang-itl-readiness-103.mjs",
"simulate:tang-wechat-itl": "node scripts/simulate-tang-wechat-itl-flow.mjs",
"check:itl-rollout-config": "node scripts/check-itl-rollout-config.mjs",
"check:chat-intent-baseline": "node scripts/report-chat-intent-baseline.mjs",
"verify:chat-intent-followup": "node scripts/verify-chat-intent-followup.mjs",
"check:schedule-reminder-health-103": "node scripts/check-schedule-reminder-health-103.mjs",
"test:mindspace-e2e": "node scripts/mindspace-e2e.mjs",
"test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs",
+93
View File
@@ -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;
});
+4
View File
@@ -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',
],
},
{
+195
View File
@@ -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);
});