Skip to content

Commit e2d2bba

Browse files
flagflagclaude
andcommitted
fix: 话题内自动回复增加对话上下文,减少误判插嘴
问题:checkThreadRelevance 只传单条消息给 Qwen,缺少对话上下文, 导致 Qwen 无法判断"你"指的是 bot 还是其他人,产生误判插嘴。 例如"你不是应该用SkillHub么?"被错误判定为在跟 bot 说话。 改动: - checkThreadRelevance 新增 recentMessages 参数,传最近 5 条消息上下文 - 上下文带发言人真名(通过 resolveUserNames 批量解析,缓存命中免 API) - bot 消息标记为 [大师(bot)],人类消息标记为 [林美辰] 等真名 - 每条消息截断到 100 字,跳过纯图片/附件消息,控制 token 用量 - parseRelevanceResponse fallback 默认从 true 改为 false,对齐"宁可不回"原则 - prompt 增加"你"字歧义判断规则 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 639e3f8 commit e2d2bba

3 files changed

Lines changed: 155 additions & 16 deletions

File tree

src/feishu/event-handler.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ import { handleMemoryCommand, handleMemoryCardAction } from '../memory/commands.
3838
import { getRepoIdentity } from '../workspace/identity.js';
3939
import { parseRepoNameFromWorkspaceDir } from '../workspace/manager.js';
4040
import { generateQuickAck } from '../utils/quick-ack.js';
41-
import { checkThreadRelevance } from '../utils/thread-relevance.js';
41+
import { checkThreadRelevance, type RecentMessage } from '../utils/thread-relevance.js';
4242
import { compressImage, compressImageForHistory } from '../utils/image-compress.js';
4343

4444
// 注册审批通过后的消息重新入队回调(避免 approval.ts → event-handler.ts 循环依赖)
@@ -462,6 +462,60 @@ function isThreadCreatorAgent(threadId: string, agentId: string): boolean {
462462
return true;
463463
}
464464

465+
/** 每条消息文本在上下文中的最大长度 */
466+
const RELEVANCE_CONTEXT_MAX_LEN = 100;
467+
468+
/**
469+
* 获取最近消息作为 Qwen 话题相关性判断的上下文。
470+
* 只取文本摘要,跳过纯附件消息,每条截断以控制 token 用量。
471+
* 带上发言人真名,帮助 Qwen 判断"你"指的是谁。
472+
*/
473+
async function fetchRelevanceContext(
474+
threadId: string,
475+
chatId: string,
476+
currentMessageId: string,
477+
botDisplayName: string,
478+
): Promise<RecentMessage[]> {
479+
try {
480+
const messages = await feishuClient.fetchRecentMessages(threadId, 'thread', 8, chatId);
481+
const result: Array<RecentMessage & { senderId?: string }> = [];
482+
for (const m of messages) {
483+
// 跳过当前消息
484+
if (m.messageId === currentMessageId) continue;
485+
// 跳过无文本的消息(纯图片/文件/卡片)
486+
const text = m.content?.trim();
487+
if (!text || text === '[图片]') continue;
488+
result.push({
489+
senderType: m.senderType,
490+
senderName: m.senderType === 'app' ? botDisplayName : undefined, // bot 用 displayName,user 后面批量解析
491+
content: text.slice(0, RELEVANCE_CONTEXT_MAX_LEN),
492+
senderId: m.senderId,
493+
});
494+
}
495+
// 只保留最近 5 条有效消息
496+
const recent = result.slice(-5);
497+
498+
// 批量解析人类用户的真名(利用 _userNameCache 缓存,避免重复调 API)
499+
const userIds = recent
500+
.filter(m => m.senderType === 'user' && m.senderId)
501+
.map(m => m.senderId!);
502+
if (userIds.length > 0) {
503+
await resolveUserNames(userIds, chatId);
504+
for (const m of recent) {
505+
if (m.senderType === 'user' && m.senderId) {
506+
m.senderName = _userNameCache.get(m.senderId);
507+
}
508+
}
509+
}
510+
511+
// 清除 senderId(不需要传给 Qwen)
512+
return recent.map(({ senderId: _, ...rest }) => rest);
513+
} catch {
514+
// 获取失败不影响主流程,退化为无上下文
515+
return [];
516+
}
517+
}
518+
465519
// ============================================================
466520
// 队列驱动:同一 thread 内串行执行,不同 thread 间可并行
467521
// queueKey = threadId 存在时用 `chatId:threadId`,否则用 `chatId`
@@ -759,7 +813,8 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd
759813
if (ts && (isOwner(userId) || ts.userId === userId)) {
760814
// 语义判断:用 Qwen 小模型判断无 @mention 的消息是否在跟 bot 对话
761815
const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot';
762-
const relevant = await checkThreadRelevance(text, botDisplayName);
816+
const recentCtx = await fetchRelevanceContext(threadId, chatId, messageId, botDisplayName);
817+
const relevant = await checkThreadRelevance(text, botDisplayName, recentCtx);
763818
if (relevant) {
764819
threadBypass = true;
765820
logger.debug({ threadId, agentId, accountId }, 'Thread creator bypass: responding without @mention');
@@ -786,7 +841,8 @@ async function handleMessageEvent(data: MessageEventData, accountId: string = 'd
786841
} else {
787842
// 语义判断:与多 bot 模式对齐,用 Qwen 小模型判断消息是否在跟 bot 对话
788843
const botDisplayName = agentRegistry.get(agentId)?.displayName ?? 'bot';
789-
const relevant = await checkThreadRelevance(text, botDisplayName);
844+
const recentCtx = threadId ? await fetchRelevanceContext(threadId, chatId, messageId, botDisplayName) : [];
845+
const relevant = await checkThreadRelevance(text, botDisplayName, recentCtx);
790846
if (!relevant) {
791847
logger.info({ messageId, threadId, text: text?.slice(0, 100) }, 'Single-bot thread bypass skipped — message not directed at bot');
792848
return;

src/utils/__tests__/thread-relevance.test.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,20 @@ describe('parseRelevanceResponse', () => {
3434
expect(parseRelevanceResponse('respond: false')).toBe(false);
3535
});
3636

37-
it('should default to true for unparseable response', () => {
38-
expect(parseRelevanceResponse('不确定')).toBe(true);
37+
it('should fallback to true when raw contains "true" keyword', () => {
38+
expect(parseRelevanceResponse('respond: true')).toBe(true);
3939
});
4040

41-
it('should default to true for empty string', () => {
42-
expect(parseRelevanceResponse('')).toBe(true);
41+
it('should default to false for unparseable response (宁可不回)', () => {
42+
expect(parseRelevanceResponse('不确定')).toBe(false);
4343
});
4444

45-
it('should handle malformed JSON gracefully', () => {
46-
expect(parseRelevanceResponse('{respond: true')).toBe(true);
45+
it('should default to false for empty string (宁可不回)', () => {
46+
expect(parseRelevanceResponse('')).toBe(false);
47+
});
48+
49+
it('should default to false for malformed JSON without keywords', () => {
50+
expect(parseRelevanceResponse('{respond: ???}')).toBe(false);
4751
});
4852
});
4953

@@ -106,6 +110,54 @@ describe('checkThreadRelevance', () => {
106110
expect(userMsg).toContain('测试消息');
107111
});
108112

113+
it('should include recent context with sender names when provided', async () => {
114+
mockCreate.mockResolvedValue({
115+
choices: [{ message: { content: '{"respond": false}' } }],
116+
});
117+
118+
const recentMessages = [
119+
{ senderType: 'app' as const, senderName: '大师', content: '好的,我来帮你看看' },
120+
{ senderType: 'user' as const, senderName: '林美辰', content: '不给偷鸡' },
121+
];
122+
123+
await checkThreadRelevance('你不是应该用SkillHub么?', '大师', recentMessages);
124+
125+
const userMsg = mockCreate.mock.calls[0][0].messages[1].content;
126+
expect(userMsg).toContain('最近对话');
127+
expect(userMsg).toContain('[大师(bot)]: 好的,我来帮你看看');
128+
expect(userMsg).toContain('[林美辰]: 不给偷鸡');
129+
expect(userMsg).toContain('新消息:你不是应该用SkillHub么?');
130+
});
131+
132+
it('should fallback to [bot]/[user] tag when senderName is missing', async () => {
133+
mockCreate.mockResolvedValue({
134+
choices: [{ message: { content: '{"respond": true}' } }],
135+
});
136+
137+
const recentMessages = [
138+
{ senderType: 'app' as const, content: '收到' },
139+
{ senderType: 'user' as const, content: '帮我看看' },
140+
];
141+
142+
await checkThreadRelevance('继续', 'bot', recentMessages);
143+
144+
const userMsg = mockCreate.mock.calls[0][0].messages[1].content;
145+
expect(userMsg).toContain('[bot]: 收到');
146+
expect(userMsg).toContain('[user]: 帮我看看');
147+
});
148+
149+
it('should work without recent context (backward compatible)', async () => {
150+
mockCreate.mockResolvedValue({
151+
choices: [{ message: { content: '{"respond": true}' } }],
152+
});
153+
154+
await checkThreadRelevance('帮我查一下', 'bot');
155+
156+
const userMsg = mockCreate.mock.calls[0][0].messages[1].content;
157+
expect(userMsg).not.toContain('最近对话');
158+
expect(userMsg).toContain('新消息:帮我查一下');
159+
});
160+
109161
it('should default to false on API error', async () => {
110162
mockCreate.mockRejectedValue(new Error('API error'));
111163

src/utils/thread-relevance.ts

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { getClient } from './quick-ack.js';
1010
const RELEVANCE_PROMPT = `你是一个消息路由判断器。在一个群聊话题中,机器人之前参与了对话。
1111
现在收到一条新消息(没有 @机器人),判断这条消息是否**明确需要机器人回复**。
1212
13+
你会看到最近的对话上下文([bot] 表示机器人发的,[user] 表示人类发的)和当前新消息。
14+
请结合上下文判断"新消息"是在跟机器人说话,还是在跟其他人说话。
15+
1316
严格按 JSON 格式回复,不要输出任何其他内容:
1417
{"respond": true} 或 {"respond": false}
1518
@@ -22,34 +25,61 @@ respond: false 的条件:
2225
- 消息是在跟其他人聊天、讨论、感叹、评论
2326
- 消息是自言自语、告知别人状态(如"等等"、"我看看"、"稍等")
2427
- 消息是对其他人说的话(即使话题中有机器人参与)
28+
- 消息中的"你"指的是其他人而非机器人(根据上下文判断)
2529
- 短句/语气词/感叹(如"哦"、"好的"、"噗"、"可以"、"稳了")
2630
- 无法确定是否在跟机器人说话 → false(宁可不回)`;
2731

32+
/** 最近消息上下文条目(由调用方从 fetchRecentMessages 结果中精简) */
33+
export interface RecentMessage {
34+
/** 'user' = 人类, 'app' = 机器人 */
35+
senderType: 'user' | 'app';
36+
/** 发送者名称(真名),未知时可省略 */
37+
senderName?: string;
38+
/** 消息文本(已截断) */
39+
content: string;
40+
}
41+
2842
/**
2943
* 判断话题内无 @mention 的消息是否需要 bot 回复。
3044
*
3145
* 使用 Qwen 小模型快速语义判断,超时/失败默认返回 false(宁可不回,用户可 @bot 明确触发)。
3246
*
3347
* @param message 用户消息文本
3448
* @param botName bot 显示名称
49+
* @param recentMessages 最近 N 条消息上下文(不含当前消息),可选
3550
* @returns true = 应该回复, false = 不应该回复
3651
*/
3752
export async function checkThreadRelevance(
3853
message: string,
3954
botName: string,
55+
recentMessages?: RecentMessage[],
4056
): Promise<boolean> {
4157
if (!config.quickAck.enabled) return false; // 未配置小模型,宁可不回,用户可 @bot 明确触发
4258

4359
const client = await getClient();
4460
if (!client) return false;
4561

62+
// 组装上下文:最近消息 + 当前消息
63+
let userContent = `机器人名称:${botName}\n`;
64+
if (recentMessages?.length) {
65+
userContent += '最近对话:\n';
66+
for (const msg of recentMessages) {
67+
const tag = msg.senderName
68+
? `[${msg.senderName}${msg.senderType === 'app' ? '(bot)' : ''}]`
69+
: (msg.senderType === 'app' ? '[bot]' : '[user]');
70+
userContent += `${tag}: ${msg.content}\n`;
71+
}
72+
userContent += '\n';
73+
}
74+
userContent += `新消息:${message.slice(0, 300)}`;
75+
4676
try {
4777
const result = await Promise.race([
4878
client.chat.completions.create({
4979
model: config.quickAck.model,
5080
messages: [
5181
{ role: 'system', content: RELEVANCE_PROMPT },
52-
{ role: 'user', content: `机器人名称:${botName}\n消息内容:${message.slice(0, 300)}` },
82+
{ role: 'user', content: userContent },
5383
],
5484
max_tokens: 20,
5585
temperature: 0,
@@ -75,7 +105,7 @@ export async function checkThreadRelevance(
75105

76106
/**
77107
* 解析 Qwen 返回的 JSON 判断结果。
78-
* 解析失败默认返回 true(宁可多回)。
108+
* 解析失败默认返回 false(宁可不回,与 checkThreadRelevance 设计原则一致)。
79109
*/
80110
export function parseRelevanceResponse(raw: string): boolean {
81111
try {
@@ -92,11 +122,12 @@ export function parseRelevanceResponse(raw: string): boolean {
92122
}
93123

94124
// Fallback: check for keywords
95-
if (raw.includes('false')) {
96-
logger.info({ respond: false, raw, fallback: true }, 'Thread relevance check result (fallback)');
97-
return false;
125+
if (raw.includes('true')) {
126+
logger.info({ respond: true, raw, fallback: true }, 'Thread relevance check result (fallback)');
127+
return true;
98128
}
99129

100-
logger.info({ respond: true, raw, fallback: true }, 'Thread relevance check result (fallback)');
101-
return true;
130+
// 默认不回复——宁可不回,用户可 @bot 明确触发
131+
logger.info({ respond: false, raw, fallback: true }, 'Thread relevance check result (fallback)');
132+
return false;
102133
}

0 commit comments

Comments
 (0)