From 7e20ec14f1c91d462757dc751c783ebe59bc304c Mon Sep 17 00:00:00 2001 From: john Date: Thu, 23 Jul 2026 17:18:25 +0800 Subject: [PATCH] feat: add semantic WeChat session actions --- chat-intent-router.mjs | 56 +++++++++++++++++++ chat-intent-router.test.mjs | 19 +++++++ server.mjs | 1 + wechat-mp.mjs | 25 ++++++++- wechat/intent/classifier.mjs | 6 +- wechat/intent/session-action.mjs | 95 ++++++++++++++++++++++++++++++++ wechat/prompts/chat-general.mjs | 5 ++ wechat/wechat-channel.test.mjs | 30 ++++++++++ 8 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 wechat/intent/session-action.mjs diff --git a/chat-intent-router.mjs b/chat-intent-router.mjs index 85149df..2e8bdc0 100644 --- a/chat-intent-router.mjs +++ b/chat-intent-router.mjs @@ -543,6 +543,23 @@ function parseRouterJson(reply) { } } +const WECHAT_SESSION_ACTIONS = new Set(['continue', 'reset', 'ignore_previous', 'unclear']); + +function buildWechatSessionActionPrompt(text) { + return [ + '你是微信聊天会话动作分类器,只判断用户是否要切换上下文。', + '只输出 JSON,不要 markdown:', + '{"action":"continue|reset|ignore_previous|unclear","confidence":0.0,"reason":"一句话"}', + 'reset:用户要开启全新会话、重新开始、换一个完全不同的对话。', + 'ignore_previous:用户只要求不要参考之前某段内容,但不一定要创建新会话。', + 'continue:用户明确要沿用当前话题或继续上文。', + 'unclear:无法确定,不要擅自切换。', + '', + '[User]', + String(text ?? '').trim() || '(empty)', + ].join('\n'); +} + function normalizeRoute(value) { const normalized = String(value ?? '').trim().toLowerCase(); if (normalized === CHAT_INTENT_ROUTE.DIRECT_CHAT || normalized === ROUTER_DECISION_ROUTE.CHAT) { @@ -1275,10 +1292,44 @@ export function createChatIntentRouter(options = {}) { }, { source: 'fallback' })); } + async function classifySessionAction({ text } = {}) { + // The general H5 router intentionally remains rule/skill-only. Session + // action classification is separately opt-in because it runs before every + // WeChat Agent request that contains a context hint. + if (!policy.enabled || typeof llmProviderService?.createChatCompletion !== 'function') return null; + try { + const completion = await withTimeout( + llmProviderService.createChatCompletion({ + providerKeyId: policy.modelProviderKeyId || undefined, + model: policy.model || undefined, + temperature: 0, + messages: [ + { role: 'system', content: buildWechatSessionActionPrompt(text) }, + ], + }), + Math.min(policy.timeoutMs, 1200), + 'WeChat session action classifier', + ); + if (!completion?.ok) return null; + const parsed = parseRouterJson(completion.reply); + const action = String(parsed?.action ?? '').trim().toLowerCase(); + if (!WECHAT_SESSION_ACTIONS.has(action)) return null; + return { + action, + confidence: Number(parsed?.confidence), + reason: String(parsed?.reason ?? '').trim(), + }; + } catch (err) { + logger?.warn?.('[chat-intent-router] WeChat session action classification skipped:', err); + return null; + } + } + return { getStatus, isEnabled, classify, + classifySessionAction, resolveAgentMemoryContext, applyAgentOrchestration: applyAgentOrchestrationToUserMessage, }; @@ -1374,6 +1425,11 @@ export function createManagedChatIntentRouter({ return router.classify(input); }, + async classifySessionAction(input = {}) { + const router = await ensureRouter(); + return router.classifySessionAction(input); + }, + async resolveAgentMemoryContext(input = {}) { const router = await ensureRouter(); return router.resolveAgentMemoryContext(input); diff --git a/chat-intent-router.test.mjs b/chat-intent-router.test.mjs index 6ef3074..afb62a3 100644 --- a/chat-intent-router.test.mjs +++ b/chat-intent-router.test.mjs @@ -425,6 +425,25 @@ test('classifyWithRules bypasses llm router when user selected summarize skill', assert.equal(llmCalls.length, 0); }); +test('chat intent router classifies ambiguous WeChat session actions semantically', async () => { + const router = createChatIntentRouter({ + enabled: true, + timeoutMs: 500, + llmProviderService: { + async createChatCompletion({ messages }) { + assert.match(messages[0].content, /微信聊天会话动作分类器/); + return { + ok: true, + reply: '{"action":"reset","confidence":0.91,"reason":"用户要求换一种对话上下文"}', + }; + }, + }, + }); + const result = await router.classifySessionAction({ text: '我们换个思路聊' }); + assert.equal(result.action, 'reset'); + assert.equal(result.confidence, 0.91); +}); + test('classifyWithRules bypasses llm router for explicit web skill prompt', async () => { const llmCalls = []; const router = createChatIntentRouter({ diff --git a/server.mjs b/server.mjs index 2e350dc..5c16a7b 100644 --- a/server.mjs +++ b/server.mjs @@ -882,6 +882,7 @@ async function bootstrapUserAuth() { wechatScheduleLlmConfigService, llmProviderService, chatIntentRouter, + sessionIntentClassifier: ({ text }) => chatIntentRouter?.classifySessionAction({ text }), onPageGenerated: async ({ userId, sessionId, artifacts = [] }) => { for (const artifact of artifacts) { void sendMindSpaceAnalyticsEvent({ diff --git a/wechat-mp.mjs b/wechat-mp.mjs index 37d0493..9ed1964 100644 --- a/wechat-mp.mjs +++ b/wechat-mp.mjs @@ -29,6 +29,10 @@ import { shouldHandleSyncReply, } from './wechat/handlers/sync-replies.mjs'; import { classifyWechatIntent } from './wechat/intent/classifier.mjs'; +import { + isWechatSessionResetAction, + resolveWechatSessionAction, +} from './wechat/intent/session-action.mjs'; import { isPageGenerateText, isTopicResetText } from './wechat/intent/patterns.mjs'; import { isPageDataIntent } from './chat-skills.mjs'; import { resolvePageGenerateOutcome } from './wechat/handlers/page-generate.mjs'; @@ -1086,6 +1090,7 @@ function isTopicResetIntent(text) { export function shouldForceNewWechatAgentSession(wechatIntent, resetCandidate) { return ( wechatIntent?.kind === 'session.reset' || + isWechatSessionResetAction(wechatIntent?.sessionActionResult) || isTopicResetIntent(resetCandidate) || isPageDataIntent(resetCandidate) ); @@ -1578,6 +1583,7 @@ export function createWechatMpService({ onPageGenerated = null, applySessionLlmProvider = null, refreshSessionSnapshot = null, + sessionIntentClassifier = null, pageDataFinishGuard = null, wechatFetch = undiciFetch, linkExists = defaultPublicHtmlLinkExists, @@ -2354,7 +2360,7 @@ export function createWechatMpService({ }; const runIntentMessage = async ({ inbound, intent, user }) => { - const wechatIntent = classifyWechatIntent(intent); + const wechatIntent = await resolveWechatIntent(intent); const mediaAnalysisEnabled = isWechatMediaGrayUser(user, config.mediaAnalysisGrayUsers); const reliabilityEnabled = isWechatMediaGrayUser(user, config.reliabilityGrayUsers); const agentReplyTimeoutMs = reliabilityEnabled ? config.agentReplyTimeoutMs : 0; @@ -2810,6 +2816,23 @@ export function createWechatMpService({ } }; + const resolveWechatIntent = async (intent) => { + const wechatIntent = classifyWechatIntent(intent); + if (intent.msgType !== 'text' && intent.msgType !== 'voice') return wechatIntent; + const sessionActionResult = await resolveWechatSessionAction(intent.agentText, { + semanticClassifier: sessionIntentClassifier, + logger, + }); + if (isWechatSessionResetAction(sessionActionResult)) { + intent.sessionAction = sessionActionResult.action; + return { ...wechatIntent, kind: 'session.reset', sessionActionResult }; + } + if (sessionActionResult.action === 'ignore_previous') { + intent.sessionAction = sessionActionResult.action; + } + return { ...wechatIntent, sessionActionResult }; + }; + const handleInboundMessage = async (bodyText, query = {}) => { if (!verifyRequest(query)) { return { ok: false, status: 403, body: 'invalid signature' }; diff --git a/wechat/intent/classifier.mjs b/wechat/intent/classifier.mjs index 52d0b89..2437fe9 100644 --- a/wechat/intent/classifier.mjs +++ b/wechat/intent/classifier.mjs @@ -6,6 +6,7 @@ import { isTopicResetText, wantsDocxDownload, } from './patterns.mjs'; +import { WECHAT_SESSION_ACTION } from './session-action.mjs'; /** * @typedef {object} WechatPageGenerateIntent @@ -15,6 +16,7 @@ import { * * @typedef {object} WechatSessionResetIntent * @property {'session.reset'} kind + * @property {string} sessionAction * * @typedef {object} WechatStatusProbeIntent * @property {'status.probe'} kind @@ -46,7 +48,9 @@ export function classifyWechatIntent(intent) { const text = String(intent?.agentText ?? intent?.displayText ?? '').trim(); if (msgType === 'text' || msgType === 'voice') { - if (isTopicResetText(text)) return { kind: 'session.reset' }; + if (isTopicResetText(text)) { + return { kind: 'session.reset', sessionAction: WECHAT_SESSION_ACTION.RESET }; + } if (STATUS_PROBE_PATTERN.test(text)) return { kind: 'status.probe' }; if (GREETING_PATTERN.test(text.replace(/[!!。.\s]+$/g, ''))) return { kind: 'greeting' }; if (CONNECTIVITY_TEST_PATTERN.test(text.replace(/[!!。.\s]+$/g, ''))) { diff --git a/wechat/intent/session-action.mjs b/wechat/intent/session-action.mjs new file mode 100644 index 0000000..4d9392c --- /dev/null +++ b/wechat/intent/session-action.mjs @@ -0,0 +1,95 @@ +/** Session actions are broader than one fixed reset phrase. */ +export const WECHAT_SESSION_ACTION = Object.freeze({ + CONTINUE: 'continue', + RESET: 'reset', + IGNORE_PREVIOUS: 'ignore_previous', + UNCLEAR: 'unclear', +}); + +const RESET_VERBS = /(?:换|开|新建|开启|开始|另起|重新|从头|清空|作废)/u; +const RESET_TARGETS = /(?:新?会话|对话|聊天|上下文|话题|主题)/u; +const CONTEXT_REFERENCES = /(?:之前|刚才|前面|上面|当前|这个|刚刚)/u; +const IGNORE_VERBS = /(?:忽略|不要管|别管|不参考|不要参考|不用参考|忘掉|忘记)/u; +const CONTINUE_VERBS = /(?:继续|接着|还是|回到|沿着)/u; +const SESSION_SEMANTIC_HINT = /(?:换个思路|另一个话题|不同话题|重置|清除|忘记刚才|刚才.*不要|从现在开始)/u; + +function normalizeText(text) { + return String(text ?? '') + .trim() + .replace(/[\u200b-\u200d\ufeff]/g, '') + .replace(/[\s,。!?、;:“”‘’()()【】\[\]{}]+/gu, ''); +} + +function classifyByMeaning(text) { + const normalized = normalizeText(text); + if (!normalized) return { action: WECHAT_SESSION_ACTION.UNCLEAR, confidence: 0 }; + + const hasTarget = RESET_TARGETS.test(normalized); + const hasContext = CONTEXT_REFERENCES.test(normalized); + const hasResetVerb = RESET_VERBS.test(normalized); + const hasIgnoreVerb = IGNORE_VERBS.test(normalized); + const hasContinueVerb = CONTINUE_VERBS.test(normalized); + + if (hasContinueVerb && hasTarget && !hasResetVerb) { + return { action: WECHAT_SESSION_ACTION.CONTINUE, confidence: 0.94, source: 'semantic_rules' }; + } + if (hasIgnoreVerb && (hasContext || hasTarget) && !/(?:新|重新|另起|开启|开始)/u.test(normalized)) { + return { action: WECHAT_SESSION_ACTION.IGNORE_PREVIOUS, confidence: 0.9, source: 'semantic_rules' }; + } + if ((hasResetVerb && hasTarget) || /(?:重新开始|从头来|重来一次|另开一个)/u.test(normalized)) { + return { action: WECHAT_SESSION_ACTION.RESET, confidence: 0.98, source: 'semantic_rules' }; + } + return { action: WECHAT_SESSION_ACTION.UNCLEAR, confidence: 0.35, source: 'fallback' }; +} + +function normalizeSemanticResult(result) { + if (!result) return null; + const raw = String(result.action ?? result.intent ?? result.label ?? '').trim().toLowerCase(); + const aliases = { + 'session.reset': WECHAT_SESSION_ACTION.RESET, + reset: WECHAT_SESSION_ACTION.RESET, + new_session: WECHAT_SESSION_ACTION.RESET, + continue: WECHAT_SESSION_ACTION.CONTINUE, + reuse: WECHAT_SESSION_ACTION.CONTINUE, + ignore_previous: WECHAT_SESSION_ACTION.IGNORE_PREVIOUS, + ignore: WECHAT_SESSION_ACTION.IGNORE_PREVIOUS, + unclear: WECHAT_SESSION_ACTION.UNCLEAR, + }; + const action = aliases[raw]; + if (!action) return null; + return { + action, + confidence: Number.isFinite(Number(result.confidence)) ? Number(result.confidence) : 0.7, + source: 'semantic_model', + reason: String(result.reason ?? '').trim() || null, + }; +} + +/** + * Resolve a session action. A channel may inject a small semantic model for + * ambiguous phrases; the meaning-based fallback keeps this path safe when the + * model is unavailable or times out. + */ +export async function resolveWechatSessionAction(text, { semanticClassifier = null, logger = console } = {}) { + const deterministic = classifyByMeaning(text); + if (deterministic.action !== WECHAT_SESSION_ACTION.UNCLEAR) return deterministic; + if (typeof semanticClassifier !== 'function') return deterministic; + const normalized = normalizeText(text); + if (!RESET_TARGETS.test(normalized) && !CONTEXT_REFERENCES.test(normalized) && !SESSION_SEMANTIC_HINT.test(normalized)) { + return deterministic; + } + try { + const semantic = normalizeSemanticResult(await semanticClassifier({ + text: String(text ?? '').trim(), + actions: Object.values(WECHAT_SESSION_ACTION), + })); + if (semantic) return semantic; + } catch (err) { + logger?.warn?.('[wechat-session-intent] semantic classifier failed:', err); + } + return deterministic; +} + +export function isWechatSessionResetAction(result) { + return result?.action === WECHAT_SESSION_ACTION.RESET; +} diff --git a/wechat/prompts/chat-general.mjs b/wechat/prompts/chat-general.mjs index 7322b57..7b7500c 100644 --- a/wechat/prompts/chat-general.mjs +++ b/wechat/prompts/chat-general.mjs @@ -133,6 +133,11 @@ export function buildWechatAgentPrompt(intent, { grantedSkills = [], imagePolicy } const content = String(agentText).trim(); const lines = [currentTimeHint]; + if (intent?.sessionAction === 'ignore_previous') { + lines.push( + '【上下文边界】用户要求忽略此前相关内容;本轮只根据当前消息和必要的系统信息回答,不要引用或延续被忽略的历史话题。', + ); + } if (docxDownloadHint) lines.push(docxDownloadHint); if (pageDataCollectHint) lines.push(pageDataCollectHint); if (pagePublishHint) lines.push(pagePublishHint); diff --git a/wechat/wechat-channel.test.mjs b/wechat/wechat-channel.test.mjs index 0d1e5bf..39e090e 100644 --- a/wechat/wechat-channel.test.mjs +++ b/wechat/wechat-channel.test.mjs @@ -4,6 +4,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { classifyWechatIntent, isPageGenerateIntent } from './intent/classifier.mjs'; +import { resolveWechatSessionAction } from './intent/session-action.mjs'; import { filterSendableHtmlArtifacts, isStubPublicHtmlContent, @@ -37,6 +38,26 @@ test('classifyWechatIntent detects session.reset', () => { assert.equal(classifyWechatIntent({ msgType: 'text', agentText: '换新会话' }).kind, 'session.reset'); }); +test('resolveWechatSessionAction recognizes natural new-session expressions', async () => { + for (const text of ['换新会话', '新开一个会话', '我们重新开始吧', '从头来']) { + const result = await resolveWechatSessionAction(text); + assert.equal(result.action, 'reset', text); + } +}); + +test('resolveWechatSessionAction separates ignore-context from reset', async () => { + assert.equal((await resolveWechatSessionAction('不要参考刚才关于医联体的内容')).action, 'ignore_previous'); + assert.equal((await resolveWechatSessionAction('继续刚才的话题')).action, 'continue'); +}); + +test('resolveWechatSessionAction uses an injected semantic classifier for unclear text', async () => { + const result = await resolveWechatSessionAction('我们换个思路聊', { + semanticClassifier: async () => ({ action: 'reset', confidence: 0.88, reason: '新对话意图' }), + }); + assert.equal(result.action, 'reset'); + assert.equal(result.source, 'semantic_model'); +}); + test('selectSendableHtmlArtifacts never returns stub html', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wechat-stub-')); const stubPath = path.join(dir, 'tang-poem.html'); @@ -85,6 +106,15 @@ test('buildWechatAgentPrompt lives in wechat prompts package', () => { assert.match(prompt, /schedule-assistant/); }); +test('buildWechatAgentPrompt carries ignore-previous context boundary', () => { + const prompt = buildWechatAgentPrompt({ + msgType: 'text', + agentText: '请分析这段新内容', + sessionAction: 'ignore_previous', + }); + assert.match(prompt, /忽略此前相关内容/); +}); + test('schedule confirmation guard blocks false positives', async () => { assert.equal(looksLikeScheduleConfirmation('已经帮你设置好了待办提醒'), true); const guarded = await guardScheduleConfirmationReply({