feat: add semantic WeChat session actions

This commit is contained in:
john
2026-07-23 17:18:25 +08:00
parent baa383548c
commit 7e20ec14f1
8 changed files with 235 additions and 2 deletions
+5 -1
View File
@@ -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, ''))) {
+95
View File
@@ -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;
}
+5
View File
@@ -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);
+30
View File
@@ -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({