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:
@@ -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 万字符,偏松)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 = {}) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = '',
|
||||
|
||||
@@ -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 完成后短句纠偏同样生效 |
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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