Files
memind/chat-intent-router.mjs
T

1116 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
buildChatSkillPrompt,
buildWebNewsSkillPrompt,
extractSelectedChatSkillName,
hasExplicitChatSkillPrompt,
isPageDataIntent,
isPageGenerationIntent,
isProductCampaignIntent,
PAGE_DATA_COLLECT_SKILL_NAME,
} from './chat-skills.mjs';
import { isDirectChatSessionId } from './direct-chat-service.mjs';
import {
memoryLimitForIntervention,
resolveMemoryInterventionMode,
} from './memory-intervention.mjs';
import { filterMemoriesByQuery } from './memory-legacy-fallback.mjs';
export const CHAT_INTENT_ROUTE = {
DIRECT_CHAT: 'direct_chat',
AGENT: 'agent_orchestration',
};
export const ROUTER_DECISION_ROUTE = {
CHAT: 'chat',
AGENT: 'agent',
};
export const ROUTER_DECISION_MODE = {
SSE: 'sse',
WS: 'ws',
};
export const ROUTER_DECISION_SESSION_HINT = {
REUSE: 'reuse',
NEW: 'new',
};
const ALLOWED_ROUTER_DECISION_FLAGS = new Set([
'memory_recall',
'selected_skill',
'force_deep_reasoning',
'code_task',
'long_running',
]);
const AGENT_ORCHESTRATION_HEADER = '【Memind 任务编排】';
const SKILL_PROMPT_KEYS = {
web: 'web',
search: 'search',
'static-page-publish': 'generate-page',
'page-data-collect': 'page-data-collect',
'form-builder': 'form-builder',
'table-viewer': 'table-viewer',
'product-campaign-page': 'product-campaign-page',
};
const OBVIOUS_AGENT_PATTERNS = [
/\bpublic\/[^\s"'<>]+\.html\b/i,
/MindSpace\/[^/\s]+\/public\/[^\s"'<>]+\.html/i,
/(?:生成|创建|制作|做|写|设计|发布).{0,24}(?:H5|h5|HTML|html|网页|页面|活动页|宣传页|落地页|分享页)/u,
/(?:H5|h5|HTML|html|网页|页面).{0,24}(?:生成|创建|制作|做|写|设计|发布)/u,
/(?:写|做|生成|制作|设计).{0,16}主题(?:页面|文章)/u,
/主题(?:页面|文章).{0,16}(?:写|做|生成|制作|设计)/u,
];
const OBVIOUS_DIRECT_PATTERNS = [
/^(?:你好|您好|在吗|在不在|嗨|hi|hello|hey)[!!。.\s]*$/iu,
/^(?:测试\s*\d*|test\s*\d*)[!!。.\s]*$/iu,
/^[?]+$/u,
];
/** Short confirmations in an ongoing agent session must stay on Agent (not direct chat). */
const AGENT_SESSION_CONTINUE_PATTERNS = [
/^(?:可以|好的|好|行|确认|没问题|是的|对|嗯|OK|ok)[!!。.\s]*$/iu,
/^(?:开始吧|按默认做|默认方案|继续|就这样|就这样吧)[!!。.\s]*$/iu,
];
/** Pure text chat/creative prompts — fast-path even when LLM router is enabled. */
const OBVIOUS_DIRECT_CHAT_PATTERNS = [
/(?:讲|说|来|编).{0,10}(?:个|一段|一首|一个)?(?:睡前故事|故事|笑话|段子)/u,
/(?:写|作|创|生成).{0,10}(?:首|一段|一篇|一个)?(?:诗|散文|笑话|段子|故事)/u,
/(?:继续|再).{0,8}(?:写|讲|说|编).{0,10}(?:诗|故事|笑话|散文)/u,
];
/** Realtime / web-search prompts — fast-path to agent even when LLM router is enabled. */
const REALTIME_INFO_AGENT_PATTERNS = [
/(?:世界杯|欧冠|NBA|英超|西甲|意甲|德甲|法甲|欧洲杯|亚洲杯|奥运会).{0,16}(?:赛况|赛程|比分|战况|结果|积分|排名|进展|情况)/u,
/(?:赛况|赛程|比分|战况|积分榜|最新比赛|比赛结果).{0,16}(?:如何|怎样|怎么样|多少|是什么)/u,
/(?:看看|查(?:一下|查)?|搜索|打听|了解).{0,16}(?:今天|今日|现在|最新|当前).{0,16}(?:新闻|赛况|比分|天气|行情|股价|汇率|热点)/u,
/(?:今天|今日|现在|最新|当前).{0,12}(?:的)?(?:新闻|赛况|比分|天气|行情|股价|汇率|热点)/u,
/(?:实时|最新).{0,8}(?:信息|数据|情况|动态|资讯)/u,
];
const RICH_PUBLISHABLE_CONTENT_HINT_PATTERNS = [
/(?:服务号|公众号|H5|h5).{0,24}(?:用户|读者|粉丝|阅读|推送|发布|消息|文章|内容)/u,
/(?:整理|排版|美化|输出).{0,24}(?:给|成|为).{0,12}(?:服务号|公众号|H5|h5)/u,
/(?:服务号|公众号|H5|h5|页面|文章|推文|图文|长文|专题).{0,24}(?:用户|读者|粉丝|客户|阅读|推送|发布|转发|分享|展示|内容)/u,
/(?:整理|排版|美化|输出|生成|制作|做成).{0,24}(?:给|成|为|适合).{0,16}(?:用户|读者|粉丝|客户|服务号|公众号|H5|h5|图文|文章|页面|推文|转发|分享|阅读)/u,
/(?:适合|方便).{0,12}(?:用户|读者|粉丝|客户).{0,12}(?:阅读|转发|分享)/u,
];
const MEMORY_RECALL_PATTERNS = [
/你(?:还)?记得(?:我)?/u,
/(?:有没有|是否).{0,6}记住/u,
/你对(?:我)?(?:的)?记忆/u,
/(?:我|之前).{0,16}(?:说过|提到|聊过|告诉)/u,
/(?:我的|之前的)(?:记忆|偏好|计划|目标|想法)/u,
/之前(?:说|提|聊)(?:过|的)/u,
/之前.{0,24}记住/u,
/记住.{0,16}(?:是什么|叫什么|多少|哪个)/u,
];
export function isMemoryRecallQuestion(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return MEMORY_RECALL_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isAgentSessionContinueText(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return AGENT_SESSION_CONTINUE_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isRealtimeInfoQuestion(text) {
const normalized = String(text ?? '').trim();
if (!normalized) return false;
return REALTIME_INFO_AGENT_PATTERNS.some((pattern) => pattern.test(normalized));
}
export function isRichPublishableContentIntent(text) {
const normalized = String(text ?? '').trim();
if (!normalized || normalized.length < 260) return false;
if (!RICH_PUBLISHABLE_CONTENT_HINT_PATTERNS.some((pattern) => pattern.test(normalized))) return false;
const bulletCount = (normalized.match(/(?:^|\n)\s*(?:[-*•]|[0-9]+[.、]|[🌍🇺🇸⚽🎤📉🌡️📰])\s*/gu) ?? []).length;
const headingCount = (normalized.match(/(?:^|\n)\s*(?:#{1,3}\s+|[🌍🇺🇸⚽🎤📉🌡️📰].{0,24}$)/gmu) ?? []).length;
const sectionLikeCount = (normalized.match(/(?:^|\n)\s*(?:[^\n]{1,24})\n(?:\s*[-*•]\s+)/gu) ?? []).length;
return bulletCount + headingCount + sectionLikeCount >= 5;
}
export function isRichChannelPageIntent(text) {
return isRichPublishableContentIntent(text);
}
function buildRealtimeInfoClassification() {
return normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.98,
reason: '需要搜索实时资料',
suggested_skill: 'web',
agent_brief: REALTIME_WEB_AGENT_BRIEF,
}, { source: 'rule' });
}
export function coercePageDataSkill(classification, text, { grantedSkills = [] } = {}) {
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
if (!isPageDataIntent(text)) return classification;
if (grantedSkills.length > 0 && !grantedSkills.includes(PAGE_DATA_COLLECT_SKILL_NAME)) {
return classification;
}
return {
...classification,
suggestedSkill: PAGE_DATA_COLLECT_SKILL_NAME,
agentBrief:
'在 MindSpace 页面实现数据收集与持久化:建表、注册 dataset、配置 page policy、HTML 使用 page-data-client.js;禁止自建后端服务。',
reason: `${classification.reason}(页面数据交互意图)`,
};
}
export function coercePageGenerationSkill(classification, text, { grantedSkills = [] } = {}) {
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
if (isPageDataIntent(text)) return classification;
if (!isPageGenerationIntent(text) || isProductCampaignIntent(text)) return classification;
if (grantedSkills.length > 0 && !grantedSkills.includes('static-page-publish')) return classification;
const shouldUsePublishSkill =
!classification.suggestedSkill || classification.suggestedSkill === 'product-campaign-page';
if (!shouldUsePublishSkill) return classification;
const reasonSuffix = classification.suggestedSkill === 'product-campaign-page'
? '(内容页意图,改用 static-page-publish'
: '(内容页生成意图)';
return {
...classification,
suggestedSkill: 'static-page-publish',
agentBrief: '生成或更新 MindSpace 公开内容页面,并返回可访问链接;信息不足时使用合理默认,不要停在追问。',
reason: `${classification.reason}${reasonSuffix}`,
};
}
export function coerceRealtimeWebSkill(classification, text, { grantedSkills = [] } = {}) {
if (!classification || classification.route !== CHAT_INTENT_ROUTE.AGENT) return classification;
if (!isRealtimeInfoQuestion(text)) return classification;
if (grantedSkills.length > 0 && !grantedSkills.includes('web')) return classification;
const reason = String(classification.reason ?? '').trim();
return {
...classification,
suggestedSkill: 'web',
agentBrief: REALTIME_WEB_AGENT_BRIEF,
reason: reason.includes('web') ? reason : `${reason || '需要搜索实时资料'}(实时查询强制 web`,
};
}
function resolveRouterFallbackRoute(env, overrides = {}) {
const raw = overrides.fallbackRoute
?? normalizeRoute(env?.MEMIND_CHAT_ROUTER_FALLBACK_ROUTE)
?? CHAT_INTENT_ROUTE.AGENT;
// Timeout/parse failures must fail open to agent so realtime/tool tasks are not dropped.
return raw === CHAT_INTENT_ROUTE.DIRECT_CHAT ? CHAT_INTENT_ROUTE.AGENT : raw;
}
const DEFAULT_ROUTER_TIMEOUT_MS = 2500;
const DEFAULT_ROUTER_MEMORY_LIMIT = 8;
const DEFAULT_ROUTER_MIN_CONFIDENCE = 0.55;
const REALTIME_WEB_AGENT_BRIEF =
'先 load_skill → web;优先 web_search/fetch_url 获取实时信息。若 web_search 不可用,立即改用 fetch_url 抓取 Bing/新华网/央视等可达来源;禁止使用 search 技能查工作区,不要反复 scrape 同一站点。';
function envFlag(value, fallback = false) {
const raw = String(value ?? '').trim().toLowerCase();
if (!raw) return fallback;
return ['1', 'true', 'yes', 'on'].includes(raw);
}
function boundedNumber(value, fallback, { min = 0, max = Number.POSITIVE_INFINITY } = {}) {
if (value == null || value === '') return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
function pickDefined(source, keys) {
return Object.fromEntries(
keys
.filter((key) => source[key] !== undefined)
.map((key) => [key, source[key]]),
);
}
function truncateText(value, maxLength) {
const text = String(value ?? '').replace(/\s+/g, ' ').trim();
if (!text) return '';
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
}
function firstSentence(value, maxLength) {
const text = truncateText(value, maxLength);
if (!text) return '';
const match = text.match(/^(.{12,}?[。.!?])/u);
return truncateText(match?.[1] ?? text, maxLength);
}
function normalizeMemoryText(item) {
if (typeof item === 'string') return { label: null, text: item };
const text = item?.text ?? item?.memory_text ?? item?.memoryText ?? item?.content ?? item?.summary ?? '';
const label = String(item?.label ?? item?.type ?? '').trim() || null;
return { label, text };
}
function withTimeout(promise, timeoutMs, label) {
const timeout = Number(timeoutMs);
if (!Number.isFinite(timeout) || timeout <= 0) return promise;
let timer = null;
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => {
const err = new Error(`${label} timed out after ${timeout}ms`);
err.code = 'CHAT_INTENT_ROUTER_TIMEOUT';
reject(err);
}, timeout);
});
return Promise.race([promise, timeoutPromise]).finally(() => {
if (timer) clearTimeout(timer);
});
}
function messageDisplayText(message) {
const displayText = message?.metadata?.displayText;
if (typeof displayText === 'string' && displayText.trim()) return displayText.trim();
return extractMessageText(message);
}
function extractMessageText(message) {
const content = message?.content;
if (typeof content === 'string') return content.trim();
if (!Array.isArray(content)) return String(message?.text ?? message?.value ?? '').trim();
return content
.map((item) => {
if (typeof item === 'string') return item;
if (item?.type === 'text') return item.text ?? '';
return '';
})
.join('\n')
.trim();
}
function isTextOnlyUserMessage(message) {
const content = message?.content;
if (!Array.isArray(content)) return Boolean(extractMessageText(message));
if (content.length === 0) return false;
return content.every((item) => {
if (typeof item === 'string') return true;
return item?.type === 'text';
});
}
function readSelectedChatSkillId(message) {
const selected =
message?.metadata?.memindRun?.selectedChatSkill ??
message?.metadata?.selectedChatSkill;
return typeof selected === 'string' && selected.trim() ? selected.trim() : null;
}
function messageHasExplicitChatSkillSelection(message) {
if (!message) return false;
if (readSelectedChatSkillId(message)) return true;
return [extractMessageText(message), messageDisplayText(message)].some((text) =>
hasExplicitChatSkillPrompt(text),
);
}
function buildSkillSelectionClassification(userMessage) {
const selectedSkillId = readSelectedChatSkillId(userMessage);
const suggestedSkill =
selectedSkillId ??
extractSelectedChatSkillName(extractMessageText(userMessage)) ??
extractSelectedChatSkillName(messageDisplayText(userMessage));
return normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: '用户已选择 skill',
suggested_skill: suggestedSkill,
agent_brief: suggestedSkill ? `使用 ${suggestedSkill} 执行用户任务` : '执行用户选择的 skill 任务',
}, { source: 'rule' });
}
function buildRouterSystemPrompt(grantedSkills = []) {
const skills = Array.isArray(grantedSkills) ? grantedSkills.filter(Boolean) : [];
return [
'你是 TKMind H5 聊天意图路由器,只负责判断用户消息应该走哪条处理通道。',
'',
'系统有两条通道:',
'1. direct_chat — 纯文字交流:问答、解释、闲聊、总结已有文字、给建议,不需要写文件、生成页面、调工具。',
'2. agent_orchestration — 任务编排 Agent:需要实际执行并产出结果的任务,例如生成/修改 HTML 页面、发布到 MindSpace、搜索实时资料、写代码改仓库、表单收集、数据表格、商品页、docx/长图导出等。',
'',
'判断原则:',
'- 用户只要文字回答,不要求“做出来/发布/生成链接/改文件” → direct_chat',
'- 用户要产出可访问页面、文件、链接,或需要工具/skills → agent_orchestration',
'- 用户询问实时赛况、新闻、天气、行情等需要联网查询的问题 → agent_orchestrationsuggested_skill 填 web(不要填 search',
'- 不确定时优先 agent_orchestration,避免漏执行',
'- 记忆线索只用于辅助判断本轮意图,不能替用户扩写新需求',
'',
skills.length ? `当前用户已授权 skills${skills.join(', ')}` : '当前用户未授权额外 skills。',
skills.length
? '若走 agent_orchestration,可在 suggested_skill 中填写最匹配的 skill 名称(须来自上述列表),否则填 null。'
: 'suggested_skill 通常填 null。',
'',
'skill 选择补充:',
'- page-data-collect:页面需要问卷/表单/报名/提交记录/后台查看/数据交互/SQLite 持久化;必须用 Page Data API,禁止自建 Express 或独立端口。',
'- static-page-publish:攻略/游记/主题内容页、城市介绍、活动介绍、任何「做页面/生成链接」且无数据持久化需求。',
'- product-campaign-page:仅当用户明确提供商品链接或要求购买按钮/电商转化时使用,不要用于旅游攻略或纯内容页。',
'',
'只输出 JSON,不要 markdown,不要解释:',
'{"route":"direct_chat|agent_orchestration","confidence":0.0,"reason":"一句话","suggested_skill":null,"agent_brief":"给 Agent 的执行要点,direct_chat 时可为空"}',
].join('\n');
}
export function buildRouterContext(resolveResult, {
memoryLimit = 5,
semanticLimit = 3,
goalLimit = 2,
memoryTextLimit = 80,
semanticTextLimit = 60,
goalTextLimit = 60,
behaviorTextLimit = 100,
} = {}) {
const result = resolveResult && typeof resolveResult === 'object' ? resolveResult : {};
const lines = [];
let itemsUsed = 0;
const memories = Array.isArray(result.memories) ? result.memories : [];
const memoryLines = memories
.map((item) => normalizeMemoryText(item))
.map(({ label, text }) => {
const clipped = truncateText(text, memoryTextLimit);
if (!clipped) return '';
return `- ${label ? `[${label}] ` : ''}${clipped}`;
})
.filter(Boolean)
.slice(0, memoryLimit);
if (memoryLines.length) {
lines.push('相关记忆:', ...memoryLines);
itemsUsed += memoryLines.length;
}
const semanticLines = (Array.isArray(result.semanticMemories) ? result.semanticMemories : [])
.map((item) => (typeof item === 'string' ? item : normalizeMemoryText(item).text))
.map((text) => truncateText(text, semanticTextLimit))
.filter(Boolean)
.slice(0, semanticLimit)
.map((text) => `- ${text}`);
if (semanticLines.length) {
lines.push('语义线索:', ...semanticLines);
itemsUsed += semanticLines.length;
}
const behaviorSummary = firstSentence(result.behaviorSummary, behaviorTextLimit);
if (behaviorSummary) {
lines.push(`行为摘要:${behaviorSummary}`);
itemsUsed += 1;
}
const goals = Array.isArray(result.activeGoals) && result.activeGoals.length
? result.activeGoals
: (Array.isArray(result.contextGoals) ? result.contextGoals : []);
const goalLines = goals
.map((text) => truncateText(text, goalTextLimit))
.filter(Boolean)
.slice(0, goalLimit)
.map((text) => `- ${text}`);
if (goalLines.length) {
lines.push('进行中目标:', ...goalLines);
itemsUsed += goalLines.length;
}
return {
content: lines.join('\n').trim(),
itemsUsed,
source: result.source ?? null,
degraded: Boolean(result.degraded),
skipped: Boolean(result.skipped),
reason: result.reason ?? null,
};
}
function buildRouterUserPrompt({ text, routerContext }) {
const context = String(routerContext ?? '').trim() || '无';
return [
'[Router Context]',
context,
'',
'[User]',
text || '(empty)',
].join('\n');
}
function parseRouterJson(reply) {
const text = String(reply ?? '').trim();
if (!text) return null;
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = (fenced?.[1] ?? text).trim();
try {
return JSON.parse(candidate);
} catch {
const start = candidate.indexOf('{');
const end = candidate.lastIndexOf('}');
if (start < 0 || end <= start) return null;
try {
return JSON.parse(candidate.slice(start, end + 1));
} catch {
return null;
}
}
}
function normalizeRoute(value) {
const normalized = String(value ?? '').trim().toLowerCase();
if (normalized === CHAT_INTENT_ROUTE.DIRECT_CHAT || normalized === ROUTER_DECISION_ROUTE.CHAT) {
return CHAT_INTENT_ROUTE.DIRECT_CHAT;
}
if (
normalized === CHAT_INTENT_ROUTE.AGENT ||
normalized === 'agent' ||
normalized === 'agent_orchestration' ||
normalized === ROUTER_DECISION_ROUTE.AGENT
) {
return CHAT_INTENT_ROUTE.AGENT;
}
return null;
}
export function resolveNormalizedRouterDecisionMode(env = process.env) {
const raw = String(env?.MEMIND_ROUTER_NORMALIZED_DECISION ?? '').trim().toLowerCase();
if (!raw || raw === '0' || raw === 'false' || raw === 'off' || raw === 'no') return 'off';
if (raw === 'shadow') return 'shadow';
if (['1', 'true', 'yes', 'on'].includes(raw)) return 'on';
return 'off';
}
export function isNormalizedRouterDecisionEnabled(env = process.env) {
return resolveNormalizedRouterDecisionMode(env) === 'on';
}
export function isNormalizedRouterDecisionShadow(env = process.env) {
return resolveNormalizedRouterDecisionMode(env) === 'shadow';
}
export function buildRouterNormalizedDecision(
classification,
{
sessionId = null,
sessionMessageCount = null,
forceDeepReasoning = false,
toolMode = 'chat',
text = '',
} = {},
) {
const legacyRoute = classification?.route ?? CHAT_INTENT_ROUTE.AGENT;
const route =
legacyRoute === CHAT_INTENT_ROUTE.DIRECT_CHAT
? ROUTER_DECISION_ROUTE.CHAT
: ROUTER_DECISION_ROUTE.AGENT;
const mode = ROUTER_DECISION_MODE.SSE;
let sessionHint = ROUTER_DECISION_SESSION_HINT.NEW;
if (sessionId) {
sessionHint = ROUTER_DECISION_SESSION_HINT.REUSE;
}
if (
sessionId &&
!isDirectChatSessionId(sessionId) &&
sessionMessageCount != null &&
Number(sessionMessageCount) === 0
) {
sessionHint = ROUTER_DECISION_SESSION_HINT.NEW;
}
const flags = [];
if (isMemoryRecallQuestion(text)) flags.push('memory_recall');
if (forceDeepReasoning) flags.push('force_deep_reasoning');
if (toolMode === 'code') flags.push('code_task');
if (classification?.suggestedSkill) flags.push('selected_skill');
const normalizedFlags = [...new Set(flags)].filter((flag) => ALLOWED_ROUTER_DECISION_FLAGS.has(flag));
return {
route,
mode,
session_hint: sessionHint,
flags: normalizedFlags,
};
}
export function finalizeRouterClassification(classification, context = {}) {
if (!classification) return classification;
return {
...classification,
decision: buildRouterNormalizedDecision(classification, context),
};
}
export function resolveLegacyRouteFromClassification(classification) {
if (!classification) return null;
if (isNormalizedRouterDecisionEnabled() && classification.decision?.route) {
return classification.decision.route === ROUTER_DECISION_ROUTE.CHAT
? CHAT_INTENT_ROUTE.DIRECT_CHAT
: CHAT_INTENT_ROUTE.AGENT;
}
return classification.route ?? null;
}
export function logRouterDecisionShadow(
classification,
{ logger = console, requestId = null, sessionId = null, env = process.env } = {},
) {
if (!isNormalizedRouterDecisionShadow(env) || !classification?.decision) return;
const legacyRoute = classification.route ?? null;
const normalizedLegacyRoute =
classification.decision.route === ROUTER_DECISION_ROUTE.CHAT
? CHAT_INTENT_ROUTE.DIRECT_CHAT
: CHAT_INTENT_ROUTE.AGENT;
const payload = {
requestId,
sessionId,
legacyRoute,
normalizedRoute: normalizedLegacyRoute,
decision: classification.decision,
source: classification.source ?? null,
reason: classification.reason ?? null,
wouldChangeRoute: legacyRoute != null && legacyRoute !== normalizedLegacyRoute,
};
const line = `[router-shadow] ${JSON.stringify(payload)}`;
if (typeof logger.info === 'function') logger.info(line);
else logger.log(line);
}
/**
* Apply router session_hint for goosed agent path without overriding hard rules.
* Returns null when gateway should start a fresh goosed session.
*/
export function resolveGatewayAgentSessionId({
agentSessionId = null,
classification = null,
forceDeepReasoning = false,
enabled = isNormalizedRouterDecisionEnabled(),
} = {}) {
const sessionId = agentSessionId || null;
if (forceDeepReasoning) {
return sessionId;
}
if (!enabled || !classification?.decision) {
return sessionId;
}
const decision = classification.decision;
if (Array.isArray(decision.flags) && decision.flags.includes('force_deep_reasoning')) {
return sessionId;
}
if (!sessionId) {
return null;
}
if (isDirectChatSessionId(sessionId)) {
return sessionId;
}
if (decision.session_hint === ROUTER_DECISION_SESSION_HINT.NEW) {
return null;
}
return sessionId;
}
export function resolveChatIntentRouterPolicy({ env = process.env, overrides = {} } = {}) {
return {
enabled: envFlag(env?.MEMIND_CHAT_LLM_ROUTER_ENABLED, false),
modelProviderKeyId: String(env?.MEMIND_CHAT_ROUTER_MODEL_PROVIDER_KEY_ID ?? '').trim() || null,
model: String(env?.MEMIND_CHAT_ROUTER_MODEL ?? '').trim() || null,
modelApiType: String(env?.MEMIND_CHAT_ROUTER_MODEL_API ?? '').trim() || 'chat',
temperature: boundedNumber(env?.MEMIND_CHAT_ROUTER_TEMPERATURE, 0, { min: 0, max: 2 }),
minConfidence: boundedNumber(
env?.MEMIND_CHAT_ROUTER_MIN_CONFIDENCE,
DEFAULT_ROUTER_MIN_CONFIDENCE,
{ min: 0, max: 1 },
),
memoryResolveEnabled: envFlag(env?.MEMIND_CHAT_ROUTER_MEMORY_ENABLED, true),
memoryResolveLimit: Math.round(boundedNumber(
env?.MEMIND_CHAT_ROUTER_MEMORY_LIMIT,
DEFAULT_ROUTER_MEMORY_LIMIT,
{ min: 1, max: 50 },
)),
timeoutMs: Math.round(boundedNumber(
env?.MEMIND_CHAT_ROUTER_TIMEOUT_MS,
DEFAULT_ROUTER_TIMEOUT_MS,
{ min: 0, max: 30_000 },
)),
...overrides,
fallbackRoute: resolveRouterFallbackRoute(env, overrides),
};
}
function normalizeClassification(raw, { source, fallbackRoute = CHAT_INTENT_ROUTE.AGENT } = {}) {
const route = normalizeRoute(raw?.route) ?? fallbackRoute;
const confidenceRaw = Number(raw?.confidence);
const confidence = Number.isFinite(confidenceRaw)
? Math.min(1, Math.max(0, confidenceRaw))
: source === 'llm' ? 0.7 : 1;
const suggestedSkill = String(raw?.suggested_skill ?? raw?.suggestedSkill ?? '').trim() || null;
const agentBrief = String(raw?.agent_brief ?? raw?.agentBrief ?? '').trim();
const reason = String(raw?.reason ?? '').trim() || (source === 'llm' ? '模型路由判定' : '规则路由判定');
return {
route,
confidence,
reason,
suggestedSkill,
agentBrief,
source,
providerKeyId: raw?.providerKeyId ?? raw?.provider_key_id ?? null,
model: raw?.model ?? null,
memory: raw?.memory ?? null,
};
}
function resolveSkillPrompt(suggestedSkill, grantedSkills = [], displayText = '') {
const skillName = String(suggestedSkill ?? '').trim();
if (!skillName) return '';
if (grantedSkills.length > 0 && !grantedSkills.includes(skillName)) return '';
if (skillName === 'web' && isRealtimeInfoQuestion(displayText)) {
return buildWebNewsSkillPrompt(skillName);
}
const promptKey = SKILL_PROMPT_KEYS[skillName];
if (!promptKey) return '';
return buildChatSkillPrompt(promptKey, skillName);
}
export function buildAgentOrchestrationAgentText({
displayText,
classification,
skillPrompt = '',
}) {
const taskBody = String(displayText ?? '').trim();
const lines = [
`${AGENT_ORCHESTRATION_HEADER}以下为用户任务,请使用工具与技能实际执行并产出结果,不要只做文字描述。`,
`路由判定:${classification.reason}`,
classification.agentBrief ? `执行要点:${classification.agentBrief}` : '',
classification.suggestedSkill ? `建议 skill${classification.suggestedSkill}` : '',
skillPrompt,
'',
'用户任务:',
taskBody,
].filter((line, index, all) => line !== '' || index === all.length - 2);
return lines.join('\n');
}
export function applyAgentOrchestrationToUserMessage(userMessage, classification, { grantedSkills = [] } = {}) {
const displayText = messageDisplayText(userMessage);
const skillPrompt = resolveSkillPrompt(classification?.suggestedSkill, grantedSkills, displayText);
const agentText = buildAgentOrchestrationAgentText({
displayText,
classification,
skillPrompt,
});
const content = Array.isArray(userMessage?.content)
? userMessage.content.map((item, index) => {
if (index !== 0) return item;
if (typeof item === 'string') return agentText;
if (item?.type === 'text') return { ...item, text: agentText };
return item;
})
: [{ type: 'text', text: agentText }];
const metadata = {
...(userMessage?.metadata ?? {}),
displayText: displayText || undefined,
chatIntentRoute: CHAT_INTENT_ROUTE.AGENT,
chatIntentSource: classification?.source ?? null,
userVisible: userMessage?.metadata?.userVisible ?? true,
agentVisible: userMessage?.metadata?.agentVisible ?? true,
};
return {
...userMessage,
content,
metadata,
};
}
function hasPriorAgentConversation(sessionId, sessionMessageCount) {
if (!sessionId || isDirectChatSessionId(sessionId)) return false;
if (sessionMessageCount == null) return true;
return Number(sessionMessageCount) > 0;
}
export function classifyWithRules({
text,
forceDeepReasoning = false,
toolMode = 'chat',
sessionId = null,
sessionMessageCount = null,
userMessage = null,
includeIntentPatterns = true,
llmRouterEnabled = false,
} = {}) {
const decisionContext = {
text,
forceDeepReasoning,
toolMode,
sessionId,
sessionMessageCount,
};
const normalized = String(text ?? '').trim();
if (forceDeepReasoning || toolMode !== 'chat') {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: forceDeepReasoning ? '用户开启深度推理' : '代码任务模式',
}, { source: 'rule' }), decisionContext);
}
if (userMessage && messageHasExplicitChatSkillSelection(userMessage)) {
return finalizeRouterClassification(buildSkillSelectionClassification(userMessage), decisionContext);
}
if (userMessage && !isTextOnlyUserMessage(userMessage)) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason: '消息包含非文本内容',
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
isMemoryRecallQuestion(normalized) &&
!hasPriorAgentConversation(sessionId, sessionMessageCount)
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.98,
reason: '用户在询问个人记忆或历史对话',
}, { source: 'rule' }), decisionContext);
}
if (hasPriorAgentConversation(sessionId, sessionMessageCount)) {
const reason = isAgentSessionContinueText(normalized)
? 'Agent 会话确认/续聊'
: '延续已有 Agent 会话';
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 1,
reason,
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
isPageDataIntent(normalized)
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.96,
reason: '页面需要数据交互与持久化',
suggested_skill: PAGE_DATA_COLLECT_SKILL_NAME,
agent_brief:
'使用 Page Data API 实现页面表单提交与后台查看;建表、注册 dataset、配置 policyHTML 引入 page-data-client.js。',
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
(OBVIOUS_AGENT_PATTERNS.some((pattern) => pattern.test(normalized))
|| isPageGenerationIntent(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.95,
reason: '明确需要生成或发布页面/文件',
suggested_skill: 'static-page-publish',
agent_brief: '生成或更新 MindSpace 公开页面,并返回可访问链接;信息不足时使用合理默认,不要停在追问。',
}, { source: 'rule' }), decisionContext);
}
if (includeIntentPatterns && normalized && isRichPublishableContentIntent(normalized)) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.AGENT,
confidence: 0.93,
reason: '结构化长内容适合生成页面承载',
suggested_skill: 'static-page-publish',
agent_brief: '优先将用户提供的结构化长内容整理为 MindSpace 公开 H5 页面,并返回可访问链接;不要只回复文字摘要。',
}, { source: 'rule' }), decisionContext);
}
if (includeIntentPatterns && normalized && isRealtimeInfoQuestion(normalized)) {
return finalizeRouterClassification(buildRealtimeInfoClassification(), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_DIRECT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.95,
reason: '简单寒暄或连通性测试',
}, { source: 'rule' }), decisionContext);
}
if (
includeIntentPatterns &&
normalized &&
OBVIOUS_DIRECT_CHAT_PATTERNS.some((pattern) => pattern.test(normalized))
) {
return finalizeRouterClassification(normalizeClassification({
route: CHAT_INTENT_ROUTE.DIRECT_CHAT,
confidence: 0.95,
reason: '纯文字创作或闲聊,无需执行任务',
}, { source: 'rule' }), decisionContext);
}
return null;
}
export function createChatIntentRouter(options = {}) {
const {
llmProviderService,
memoryV2 = null,
conversationMemoryService = null,
env = process.env,
logger = console,
} = options;
const policy = options.policy ?? resolveChatIntentRouterPolicy({
env,
overrides: pickDefined(options, [
'enabled',
'modelProviderKeyId',
'model',
'modelApiType',
'temperature',
'minConfidence',
'memoryResolveEnabled',
'memoryResolveLimit',
'timeoutMs',
'fallbackRoute',
]),
});
function getStatus() {
return {
enabled: Boolean(policy.enabled),
modelProviderKeyId: policy.modelProviderKeyId ?? null,
model: policy.model ?? null,
modelApiType: policy.modelApiType ?? 'chat',
minConfidence: policy.minConfidence,
memoryResolveEnabled: Boolean(policy.memoryResolveEnabled),
memoryResolveLimit: policy.memoryResolveLimit,
timeoutMs: policy.timeoutMs,
fallbackRoute: policy.fallbackRoute,
normalizedDecisionEnabled: isNormalizedRouterDecisionEnabled(env),
normalizedDecisionMode: resolveNormalizedRouterDecisionMode(env),
};
}
function isEnabled() {
// Skill + rule routing only; LLM intent router is intentionally disabled.
return false;
}
async function resolveRouterContext({ userId, sessionId, text, forceDeepReasoning = false }) {
const intervention = resolveMemoryInterventionMode({
forceDeepReasoning,
recallQuestion: isMemoryRecallQuestion(text),
});
const limit = memoryLimitForIntervention(intervention, { context: 'router' });
if (
limit <= 0 ||
!policy.memoryResolveEnabled ||
!userId ||
(!memoryV2?.resolve && !conversationMemoryService?.listMemories)
) {
return buildRouterContext(null);
}
let primaryFailed = false;
let memories = [];
const recallQuestion = isMemoryRecallQuestion(text);
if (recallQuestion && conversationMemoryService?.listMemories) {
const legacy = await conversationMemoryService.listMemories(userId, { limit }).catch(() => []);
memories = filterMemoriesByQuery(legacy, text);
}
if (!memories.length && memoryV2?.resolve) {
try {
const resolved = await withTimeout(
memoryV2.resolve({
userId,
sessionId,
query: text,
limit,
}),
policy.timeoutMs,
'Memory V2 router resolve',
);
memories = Array.isArray(resolved?.memories) ? resolved.memories : [];
} catch (err) {
primaryFailed = true;
logger?.warn?.(
`[chat-intent-router] memory resolve skipped: ${err instanceof Error ? err.message : err}`,
);
}
}
if (!memories.length && conversationMemoryService?.listMemories) {
const legacy = await conversationMemoryService.listMemories(userId, { limit }).catch(() => []);
memories = filterMemoriesByQuery(legacy, text);
if (memories.length) primaryFailed = false;
}
if (primaryFailed && !memories.length) {
return buildRouterContext({
degraded: true,
reason: 'memory_resolve_failed',
});
}
return buildRouterContext({ memories, source: 'router-resolve' });
}
async function classify({
userId = null,
userMessage,
sessionId = null,
sessionMessageCount = null,
toolMode = 'chat',
forceDeepReasoning = false,
grantedSkills = [],
} = {}) {
const text = messageDisplayText(userMessage);
const decisionContext = {
text,
forceDeepReasoning,
toolMode,
sessionId,
sessionMessageCount,
};
const finalizeWithCoercion = (classification) => {
if (!classification) return classification;
const base = { ...classification };
delete base.decision;
return finalizeRouterClassification(
coercePageGenerationSkill(
coercePageDataSkill(
coerceRealtimeWebSkill(base, text, { grantedSkills }),
text,
{ grantedSkills },
),
text,
{ grantedSkills },
),
decisionContext,
);
};
const ruleResult = classifyWithRules({
text,
forceDeepReasoning,
toolMode,
sessionId,
sessionMessageCount,
userMessage,
includeIntentPatterns: true,
llmRouterEnabled: false,
});
if (ruleResult) return finalizeWithCoercion(ruleResult);
return finalizeWithCoercion(normalizeClassification({
route: policy.fallbackRoute,
confidence: 0.5,
reason: '规则未命中,走 skill 默认 Agent 通道',
}, { source: 'fallback' }));
}
return {
getStatus,
isEnabled,
classify,
applyAgentOrchestration: applyAgentOrchestrationToUserMessage,
};
}
export function createManagedChatIntentRouter({
llmProviderService,
memoryV2 = null,
conversationMemoryService = null,
configService = null,
env = process.env,
logger = console,
} = {}) {
let activeRouter = null;
let activeFingerprint = null;
let activeMeta = { source: 'env', updatedAt: null, updatedBy: null, configError: null };
async function loadRouterState() {
if (!configService?.getRuntimeState) {
return {
source: 'env',
updatedAt: null,
updatedBy: null,
fingerprint: 'env-only',
effectiveEnv: { ...env },
configError: null,
};
}
try {
const state = await configService.getRuntimeState();
return {
source: state?.source ?? 'admin-db',
updatedAt: state?.updatedAt ?? null,
updatedBy: state?.updatedBy ?? null,
fingerprint: state?.fingerprint ?? `admin-db:${Date.now()}`,
effectiveEnv: { ...env, ...(state?.overrides ?? {}) },
configError: null,
};
} catch (err) {
logger?.warn?.(
`[chat-intent-router] admin config unavailable, using process env: ${err instanceof Error ? err.message : err}`,
);
return {
source: 'env-fallback',
updatedAt: null,
updatedBy: null,
fingerprint: 'env-fallback',
effectiveEnv: { ...env },
configError: err instanceof Error ? err.message : String(err),
};
}
}
async function ensureRouter() {
const state = await loadRouterState();
if (activeRouter && activeFingerprint === state.fingerprint) {
activeMeta = state;
return activeRouter;
}
activeRouter = createChatIntentRouter({
llmProviderService,
memoryV2,
conversationMemoryService,
env: state.effectiveEnv,
logger,
});
activeFingerprint = state.fingerprint;
activeMeta = state;
return activeRouter;
}
return {
async getStatus() {
const router = await ensureRouter();
return {
...router.getStatus(),
configSource: activeMeta.source,
configUpdatedAt: activeMeta.updatedAt,
configUpdatedBy: activeMeta.updatedBy,
configError: activeMeta.configError,
};
},
async isEnabled() {
const router = await ensureRouter();
return router.isEnabled();
},
async classify(input = {}) {
const router = await ensureRouter();
return router.classify(input);
},
applyAgentOrchestration: applyAgentOrchestrationToUserMessage,
};
}