diff --git a/.env.example b/.env.example index e849024..99403ab 100644 --- a/.env.example +++ b/.env.example @@ -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 万字符,偏松) diff --git a/agent-run-gateway.mjs b/agent-run-gateway.mjs index 36e6441..b7f18fe 100644 --- a/agent-run-gateway.mjs +++ b/agent-run-gateway.mjs @@ -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, }); } diff --git a/agent-run-gateway.test.mjs b/agent-run-gateway.test.mjs index ddc424d..0e139cc 100644 --- a/agent-run-gateway.test.mjs +++ b/agent-run-gateway.test.mjs @@ -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 = { diff --git a/chat-intent-observation.mjs b/chat-intent-observation.mjs new file mode 100644 index 0000000..eeb6695 --- /dev/null +++ b/chat-intent-observation.mjs @@ -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, + }; +} diff --git a/chat-intent-observation.test.mjs b/chat-intent-observation.test.mjs new file mode 100644 index 0000000..0788341 --- /dev/null +++ b/chat-intent-observation.test.mjs @@ -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); +}); diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 4e38bbc..5e0a079 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -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 = {}) { diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index 5d6fa5b..60710c4 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -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({ diff --git a/chat-task-intent-config.mjs b/chat-task-intent-config.mjs index bfb76c6..0436673 100644 --- a/chat-task-intent-config.mjs +++ b/chat-task-intent-config.mjs @@ -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 = '', diff --git a/chat-task-intent-config.test.mjs b/chat-task-intent-config.test.mjs index 2724bd7..1316b31 100644 --- a/chat-task-intent-config.test.mjs +++ b/chat-task-intent-config.test.mjs @@ -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); +}); diff --git a/docs/architecture/chat-task-intent-layer-review-20260828.md b/docs/architecture/chat-task-intent-layer-review-20260828.md index 81e3ae0..379e66b 100644 --- a/docs/architecture/chat-task-intent-layer-review-20260828.md +++ b/docs/architecture/chat-task-intent-layer-review-20260828.md @@ -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 完成后短句纠偏同样生效 | diff --git a/memory-v2-admin-config.mjs b/memory-v2-admin-config.mjs index d20392f..bd588b8 100644 --- a/memory-v2-admin-config.mjs +++ b/memory-v2-admin-config.mjs @@ -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' }, diff --git a/package.json b/package.json index 0849a99..47f1937 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/report-chat-intent-baseline.mjs b/scripts/report-chat-intent-baseline.mjs new file mode 100644 index 0000000..03553bd --- /dev/null +++ b/scripts/report-chat-intent-baseline.mjs @@ -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; +}); diff --git a/scripts/run-memind-tests.mjs b/scripts/run-memind-tests.mjs index 23ab1a6..e92f2f6 100644 --- a/scripts/run-memind-tests.mjs +++ b/scripts/run-memind-tests.mjs @@ -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', ], }, { diff --git a/scripts/verify-chat-intent-followup.mjs b/scripts/verify-chat-intent-followup.mjs new file mode 100644 index 0000000..af0db56 --- /dev/null +++ b/scripts/verify-chat-intent-followup.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); +});