diff --git a/apps/humanize.js b/apps/humanize.js index 43b49ff..67beafd 100644 --- a/apps/humanize.js +++ b/apps/humanize.js @@ -23,6 +23,7 @@ import { SelfStateService } from '../model/selfstate/index.js' import { buildEmbed } from '../model/llm/embed-wiring.js' import { makeEmbedder } from '../model/groupworld/embedding.js' import { createSearchManager, formatResults } from '../model/search/index.js' +import { fetchReply } from '../model/media/collect.js' import * as H from '../model/humanize/index.js' let _humanize = null @@ -202,7 +203,16 @@ async function buildHumanize() { if (g) g.windowNames = winNames(msgs) return g } - const getGroundingBlock = (msgs, o = {}) => { try { const g = resolveGrounding(msgs, { knownBots: new Set([...(cfgFn().knownBots || []).map(String), ...botSelfIdsAll(rt)]), targetMessageId: o?.targetId }); if (g) g.windowNames = winNames(msgs); return g ? formatGroundingBlock(g) : '' } catch { return '' } } + const getGroundingContext = (msgs, o = {}) => { + try { + const g = resolveGrounding(msgs, { + knownBots: new Set([...(cfgFn().knownBots || []).map(String), ...botSelfIdsAll(rt)]), + targetMessageId: o?.targetId, + }) + if (g) g.windowNames = winNames(msgs) + return g ? { grounding: g, block: formatGroundingBlock(g) } : null + } catch { return null } + } const makePlanner = (gid) => new H.HumanizePlanner({ provider: rt.provider, cfg: cfgFn, readTools, @@ -224,7 +234,7 @@ async function buildHumanize() { getWorldContext: gwPlannerCtx, getSelfProjection: ssPlannerProj, enrichMedia, - getGrounding: getGroundingBlock, // 对话归属块(结构化+白名单+纠错约束)——此前漏接,planner 从未收到 + getGrounding: getGroundingContext, }) const makeReplyer = (gid) => new H.HumanizeReplyer({ provider: rt.provider, cfg: cfgFn, @@ -243,7 +253,7 @@ async function buildHumanize() { getWorldContext: gwReplyerCtx, getSelfCapsule: ssReplyerCap, enrichMedia, - getGrounding: (msgs, o = {}) => { try { const g = resolveGrounding(msgs, { knownBots: new Set([...(cfgFn().knownBots || []).map(String), ...botSelfIdsAll(rt)]), targetMessageId: o?.targetId }); if (g) g.windowNames = winNames(msgs); return g ? { grounding: g, block: formatGroundingBlock(g) } : null } catch { return null } }, + getGrounding: getGroundingContext, // 伪人独立记忆:对当前发言对象的印象 + 相关群梗(Replyer 用;失败/空零影响;热读配置) getMemoryBlock: async ({ groupId, targetUserId, queryText, allowedUserIds }) => { try { @@ -266,7 +276,7 @@ async function buildHumanize() { // getPersona:把 rt 闭包在内,供 onAmbient 的 SelfState 感知解析人设(onAmbient 作用域无 rt) const getPersona = () => resolveHumanizePersona(cfgFn(), rt) - return { manager, store, trace, memory, cfgFn, ssLazy, getPersona, hmem } + return { manager, store, trace, memory, cfgFn, ssLazy, getPersona, getGroundingRaw, webSearch, hmem } } /** 取伪人装配体(manager/hmem/trace 等;web 与测试用)。 */ @@ -417,7 +427,7 @@ export class Humanize extends plugin { Log.mark('[humanize] 记忆整合', `群${gid} 新增${r.created} 合并${r.merged}${r.skipped ? `(跳过:${r.skipped})` : ''}${hourly ? ' [增量]' : ' [日全量]'}`) // 网络梗学习:整合时发现的词典外梗 → 上网查释义(去重+置信+重试三闸防污染) if (Array.isArray(r.suspected) && r.suspected.length) { - const lr = await h.hmem.learnJargonFromWeb({ groupId: gid, terms: r.suspected, webSearch }) + const lr = await h.hmem.learnJargonFromWeb({ groupId: gid, terms: r.suspected, webSearch: h.webSearch }) if (r.suspected.length) Log.mark('[humanize] 网络梗学习', `群${gid} 候选${r.suspected.join('、')} → 学会${lr.learned} 跳过${lr.skipped} 失败${lr.failed}`) } } catch (e) { Log.warn('[humanize] 记忆整合失败', gid, e?.message || e) } @@ -482,36 +492,54 @@ export class Humanize extends plugin { try { await h.store.markDirectHandled(norm.groupId, norm.id) } catch { /* noop */ } } - // 富化 quotesBot:replyToId 命中缓冲中机器人自身消息 + // 富化跨窗口引用:优先缓冲;未命中时用 getReply/get_msg/history 取轻量快照。 + // 快照只挂在当前消息上,不把历史消息 append 成新的候选消息。 let quoteIsBot = false + let ent = null if (!norm.isSelf && norm.replyToId) { try { - const ent = h.manager.getOrCreate(norm.groupId) - const replied = ent.runtime.buffer.get(norm.replyToId) + ent = h.manager.getOrCreate(norm.groupId) + let replied = ent.runtime.buffer.get(norm.replyToId) || norm.replySource + if (!replied || !String(replied.text || '').trim()) { + const rawReply = await fetchReply(e, { + bot: (typeof Bot !== 'undefined' && Bot) || null, + log: (msg) => Log.debug('[humanize] 引用补全:', msg), + }) + const source = H.normalizeReplySource(rawReply, { selfIds, fallbackId: norm.replyToId }) + if (source) { + norm.replySource = source + replied = source + } + } if (replied?.isSelf) { norm.quotesBot = true; quoteIsBot = true } } catch { /* noop */ } } // SelfState 感知(§4.2:即使最终 Planner ignore,指向机器人的事件仍改变内部状态;失败零影响) + let ss = null try { - const ss = await h.ssLazy + ss = await h.ssLazy if (ss && norm.userId) { const persona = h.getPersona() ss.onMessage(norm, { groupId: norm.groupId, quoteIsBot, personaText: persona.prompt, personaName: persona.name }).catch(() => {}) - // 对象纠错:冲销误指代情绪(必须带完整窗口做 grounding——被纠正的X要在历史里才能解析; - // 此前只传 [norm],named 恒空 → SS 冲销在生产是死代码) - try { - const buf = h.manager.getOrCreate(norm.groupId).runtime.buffer.snapshot(30, { includeSelf: true }) - const g = getGroundingRaw([...buf, norm]) - if (g?.correction) { - ss.applyCorrection({ groupId: norm.groupId, userId: String(norm.userId) }).catch((e) => Log.warn('[selfstate] 纠错冲销失败:', e?.message || e)) - // 纠错后短暂退出线程:2 分钟内强信号不再豁免冷却(说一次看串了就退出,不继续纠缠) - try { h.manager.getOrCreate(norm.groupId).runtime._postCorrectionUntil = Date.now() + 2 * 60 * 1000 } catch { /* noop */ } - } - } catch (e) { Log.debug('[humanize] 纠错检测异常:', e?.message || e) } } } catch { /* noop */ } + // 对象纠错独立于 SelfState 开关:当前纠错消息允许回应一次,随后只暂停同一发送者 2 分钟。 + // 必须带完整窗口做 grounding——被纠正的人名在历史里,单传 [norm] 无法识别。 + if (norm.userId) { + try { + ent ||= h.manager.getOrCreate(norm.groupId) + const buf = ent.runtime.buffer.snapshot(30, { includeSelf: true }) + const g = h.getGroundingRaw([...buf, norm]) + if (g?.correction) { + ent.runtime.markReferenceCorrection(norm.userId, norm.id, Date.now() + 2 * 60 * 1000) + ss?.applyCorrection?.({ groupId: norm.groupId, userId: String(norm.userId) }) + ?.catch?.((err) => Log.warn('[selfstate] 纠错冲销失败:', err?.message || err)) + } + } catch (err) { Log.debug('[humanize] 纠错检测异常:', err?.message || err) } + } + // 路由到对应群运行时(写缓冲 + 必要时唤醒 debounce) try { Log.info('[humanize] 路由', norm.groupId, 'isSelf=' + norm.isSelf, 'isCmd=' + norm.isCommand, 'handled=' + norm.handledByDirectAgent, 'text="' + (norm.text || '').slice(0, 20) + '"') diff --git a/model/humanize/grounding.js b/model/humanize/grounding.js index 1950692..0096db7 100644 --- a/model/humanize/grounding.js +++ b/model/humanize/grounding.js @@ -35,7 +35,9 @@ export function resolveGrounding(messages = [], { knownBots = new Set(), targetM if (n.length >= 2 && n !== '我' && String(m.userId) !== String(target.userId) && !memberNames.includes(n)) memberNames.push(n) } // 被回复者 - const replySrc = target.replyToId ? byId.get(String(target.replyToId)) || null : null + const replySrc = target.replyToId + ? byId.get(String(target.replyToId)) || target.replySource || null + : null const quoted = replySrc ? { id: replySrc.id, userId: replySrc.userId, name: replySrc.isSelf ? '我' : String(replySrc.displayName || replySrc.userId), text: String(replySrc.text || '').replace(/\s+/g, ' ').slice(0, 32) } : null // 显式 @ const mentions = (target.segments || []).filter((s) => s?.type === 'at' && s.qq != null).map((s) => ({ id: String(s.qq), name: nameOf(s.qq) || String(s.qq) })) diff --git a/model/humanize/group-runtime.js b/model/humanize/group-runtime.js index 0ba8788..30f5855 100644 --- a/model/humanize/group-runtime.js +++ b/model/humanize/group-runtime.js @@ -44,6 +44,10 @@ export class GroupRuntime { // 最近回复时间戳(频率上限) this.recentReplyTs = [] + // 人际轮次控制(进程内):按用户隔离,避免一个人耗尽豁免后误伤同群其他人。 + this._strongReplyByUser = new Map() + this._postCorrectionByUser = new Map() + // 定时器(进程内,不序列化) this._debounceTimer = null this._waitTimer = null @@ -159,6 +163,50 @@ export class GroupRuntime { if (messageId) this.buffer?.markSelf?.(messageId) } + /** 近 windowMs 内对该用户的强信号回复次数。过期自动清理。 */ + strongReplyCount(userId, now = Date.now(), windowMs = 10 * 60 * 1000) { + const key = String(userId || '') + if (!key) return 0 + const state = this._strongReplyByUser.get(key) + if (!state) return 0 + if (now - Number(state.lastAt || 0) > windowMs) { + this._strongReplyByUser.delete(key) + return 0 + } + return Math.max(0, Number(state.count) || 0) + } + + /** 成功回复一条强信号消息;shadow 与真实发送均调用。 */ + recordStrongReply(userId, now = Date.now()) { + const key = String(userId || '') + if (!key) return 0 + const count = this.strongReplyCount(key, now) + 1 + this._strongReplyByUser.set(key, { count, lastAt: now }) + return count + } + + /** 当前纠错消息允许处理一次,随后只暂停同一发送者,不静音整个群。 */ + markReferenceCorrection(userId, messageId, until) { + const key = String(userId || '') + if (!key) return + this._postCorrectionByUser.set(key, { + allowMessageId: messageId != null ? String(messageId) : null, + until: Number(until) || Date.now(), + }) + } + + referenceCorrectionFor(userId, now = Date.now()) { + const key = String(userId || '') + if (!key) return null + const state = this._postCorrectionByUser.get(key) + if (!state) return null + if (Number(state.until || 0) <= now) { + this._postCorrectionByUser.delete(key) + return null + } + return state + } + // ─────────────── 观察游标(标记已处理,防重复评估) ─────────────── /** 把批次标记为已观察(lastProcessedSeq 前移到 batch 末尾)。 */ @@ -191,7 +239,7 @@ export class GroupRuntime { lastProcessedSeq: this.lastProcessedSeq, cooldownUntil: this.cooldownUntil, backoff: this.backoff?.snapshot?.(), - bufferTail: tail.map(({ seq, id, groupId, userId, displayName, timestamp, text, segments, replyToId, atBot, mentionsBotName, quotesBot, isCommand, isSelf, handledByDirectAgent, media }) => ({ seq, id, groupId, userId, displayName, timestamp, text, segments, replyToId, atBot, mentionsBotName, quotesBot, isCommand, isSelf, handledByDirectAgent, media })), + bufferTail: tail.map(({ seq, id, groupId, userId, displayName, timestamp, text, segments, replyToId, replySource, atBot, mentionsBotName, quotesBot, isCommand, isSelf, handledByDirectAgent, media }) => ({ seq, id, groupId, userId, displayName, timestamp, text, segments, replyToId, replySource, atBot, mentionsBotName, quotesBot, isCommand, isSelf, handledByDirectAgent, media })), phase: 'idle', // 重启后一律从 idle 开始(不恢复 planning) }) } catch { /* noop */ } diff --git a/model/humanize/index.js b/model/humanize/index.js index 7c1c0ce..4efcdb7 100644 --- a/model/humanize/index.js +++ b/model/humanize/index.js @@ -7,7 +7,7 @@ * 红线:环境模式中,任何普通 assistant 文本都不能直接发送;只有 human_reply/human_react 才可对外发消息。 */ -export { normalizeYunzaiEvent, isSelfEvent, collectSelfIds, fingerprintId, segmentsToText, textMentionsName } from './message-normalizer.js' +export { normalizeYunzaiEvent, normalizeReplySource, isSelfEvent, collectSelfIds, fingerprintId, segmentsToText, textMentionsName } from './message-normalizer.js' export { MessageBuffer } from './message-buffer.js' export { HumanizeStore, newHolderId } from './store.js' export { Trace, hashGroupId, redactForLog } from './trace.js' diff --git a/model/humanize/integration.test.mjs b/model/humanize/integration.test.mjs index dea8daf..ff02434 100644 --- a/model/humanize/integration.test.mjs +++ b/model/humanize/integration.test.mjs @@ -106,6 +106,37 @@ await test('Planner:只读工具回灌后继续,最终 reply', async () => { ok(action.type === 'human_reply', '第二轮返回 reply') }) +await test('P0:Planner 先解析 grounding,再按 threadUserIds 检索并注入记忆', async () => { + const { runtime } = mkCtx() + let memoryScope = null + let sentSystem = '' + const provider = { + chat: async ({ system }) => { + sentSystem = system + return { content: '', toolCalls: [], finishReason: 'stop', usage: null } + }, + } + const planner = new HumanizePlanner({ + provider, + cfg: () => ({ planner: { maxRounds: 1 } }), + getGrounding: () => ({ + grounding: { threadUserIds: ['u1', 'u2'] }, + block: 'GROUNDING_SCOPE_MARK', + }), + getMemories: async (_query, opts) => { + memoryScope = opts?.threadUserIds + return 'MEMORY_SCOPE_MARK' + }, + }) + const target = mkMsg('这个权限问题应该怎么处理', { id: 'scope_target', userId: 'u1' }) + await planner.decide({ + snapshot: [target], decision: { targetMessage: target, finalScore: 90, threshold: 80 }, + runtime, cfg: { planner: { maxRounds: 1 } }, + }) + ok(JSON.stringify(memoryScope) === JSON.stringify(['u1', 'u2']), '记忆检索收到 grounding.threadUserIds') + ok(sentSystem.includes('GROUNDING_SCOPE_MARK') && sentSystem.includes('MEMORY_SCOPE_MARK'), 'Planner system 同时注入归属块与作用域内记忆') +}) + // ───────── 代取消 ───────── await test('GroupRuntime:单代 token,旧代结果丢弃', async () => { const { runtime } = mkCtx() @@ -119,6 +150,19 @@ await test('GroupRuntime:单代 token,旧代结果丢弃', async () => { ok(runtime.signal?.aborted === true, 'signal 已中止') }) +await test('GroupRuntime:强信号与纠错状态均按用户隔离并会过期', async () => { + const { runtime } = mkCtx() + const t = 1_000_000 + runtime.recordStrongReply('u1', t) + runtime.recordStrongReply('u1', t + 1) + ok(runtime.strongReplyCount('u1', t + 2) === 2 && runtime.strongReplyCount('u2', t + 2) === 0, '强信号次数不跨用户') + ok(runtime.strongReplyCount('u1', t + 10 * 60 * 1000 + 2) === 0, '10 分钟后强信号次数自动过期') + runtime.markReferenceCorrection('u1', 'fix_once', t + 1000) + ok(runtime.referenceCorrectionFor('u1', t + 1)?.allowMessageId === 'fix_once', '纠错消息保留一次处理许可') + ok(runtime.referenceCorrectionFor('u2', t + 1) === null, '纠错暂停不跨用户') + ok(runtime.referenceCorrectionFor('u1', t + 1001) === null, '纠错暂停到期自动清理') +}) + // ───────── shadow 端到端 ───────── await test('shadow 端到端:消息→门控→reply→只 trace 不实发', async () => { const { runtime, trace } = mkCtx() @@ -145,10 +189,85 @@ await test('shadow 端到端:消息→门控→reply→只 trace 不实发', a ok(events.some((e) => e.event === 'shadow_reply'), '记录了 shadow_reply') ok(runtime.cooldownUntil > Date.now(), '已进入冷却') ok(runtime.backoff.count === 0, 'reply 成功 → backoff 清零') + ok(runtime.strongReplyCount('u1') === 1, 'shadow 成功回复也计入该用户的强信号轮次') +}) + +await test('强信号限制按用户隔离:甲耗尽不影响乙,评分目标落到乙', async () => { + const { runtime } = mkCtx() + const cfg = () => validateHumanizeConfig({ + enable: true, groups: ['g1'], shadow: true, threshold: 80, talkValue: 0.35, + debounceMs: 200, cooldownSeconds: 1, + }).config + const t = Date.now() + runtime.recordStrongReply('u1', t) + runtime.recordStrongReply('u1', t + 1) + runtime.recordStrongReply('u1', t + 2) + runtime.cooldownUntil = Date.now() + 1000 + let plannerTarget = null + const planner = { + decide: async ({ decision }) => { + plannerTarget = decision.targetMessage?.id || null + return { type: 'human_ignore', reason: 'test' } + }, + } + const sched = new TurnScheduler({ + runtime, cfg, planner, + replyer: { generate: async () => ({ text: '' }) }, + composer: { deliver: async () => ({ sentIds: [] }) }, send: async () => null, + }) + await sched.onMessage(mkMsg('小猫你能不能帮我看看怎么做?', { userId: 'u1', mentionsBotName: true, id: 'u1_limited' })) + await sched.onMessage(mkMsg('小猫你能不能帮我看看怎么做?', { userId: 'u2', displayName: '乙', mentionsBotName: true, id: 'u2_fresh' })) + runtime.cancelDebounce() + await sched._onDebounced(runtime) + ok(plannerTarget === 'u2_fresh', '甲的 3 次配额只移除甲的强信号,乙仍可绕过冷却并成为目标') + ok(runtime.strongReplyCount('u2') === 0, 'Planner ignore 不误计乙的强信号回复') +}) + +await test('强信号耗尽后硬失去豁免,冷却跳过会推进游标避免旧消息复活', async () => { + const { runtime } = mkCtx() + const cfg = () => validateHumanizeConfig({ + enable: true, groups: ['g1'], shadow: true, threshold: 20, talkValue: 1, + debounceMs: 200, cooldownSeconds: 60, + }).config + const t = Date.now() + for (let i = 0; i < 3; i++) runtime.recordStrongReply('u1', t + i) + runtime.cooldownUntil = Date.now() + 60_000 + let plannerCalled = false + const sched = new TurnScheduler({ + runtime, cfg, planner: { decide: async () => { plannerCalled = true; return { type: 'human_ignore' } } }, + replyer: { generate: async () => ({ text: '' }) }, + composer: { deliver: async () => ({ sentIds: [] }) }, send: async () => null, + }) + await sched.onMessage(mkMsg('小猫你觉得呢?', { userId: 'u1', mentionsBotName: true, id: 'u1_fourth' })) + runtime.cancelDebounce() + await sched._onDebounced(runtime) + ok(plannerCalled === false, '即使阈值较低,第 4 次也不能保留强信号冷却豁免') + ok(runtime.lastProcessedSeq === runtime.buffer.lastSeq, '被冷却跳过的消息已观察,不会在下一条消息时复活') +}) + +await test('纠错退场按发送者隔离:拦甲后续但仍处理同批乙消息', async () => { + const { runtime } = mkCtx() + const cfg = () => validateHumanizeConfig({ + enable: true, groups: ['g1'], shadow: true, threshold: 80, talkValue: 0.35, debounceMs: 200, + }).config + runtime.markReferenceCorrection('u1', 'correction_once', Date.now() + 120_000) + let plannerTarget = null + const sched = new TurnScheduler({ + runtime, cfg, + planner: { decide: async ({ decision }) => { plannerTarget = decision.targetMessage?.id; return { type: 'human_ignore', reason: 'test' } } }, + replyer: { generate: async () => ({ text: '' }) }, + composer: { deliver: async () => ({ sentIds: [] }) }, send: async () => null, + }) + await sched.onMessage(mkMsg('你怎么还在说', { userId: 'u1', quotesBot: true, id: 'u1_after_correction' })) + await sched.onMessage(mkMsg('小猫你觉得呢?', { userId: 'u2', displayName: '乙', mentionsBotName: true, id: 'u2_after_correction' })) + runtime.cancelDebounce() + await sched._onDebounced(runtime) + ok(plannerTarget === 'u2_after_correction', '只过滤纠错者甲,乙仍进入 Planner') + ok(runtime.lastProcessedSeq === runtime.buffer.lastSeq, '混合批次整体推进游标') }) await test('端到端:shadow=false 时真实发送 + markSent + 记录回复', async () => { - const { runtime, trace, store } = mkCtx() + const { runtime, store } = mkCtx() const cfg = () => validateHumanizeConfig({ enable: true, groups: ['g1'], shadow: false, threshold: 80, talkValue: 0.35, debounceMs: 200, cooldownSeconds: 1, planner: { maxRounds: 2 }, replyer: { maxChars: 200 }, diff --git a/model/humanize/message-normalizer.js b/model/humanize/message-normalizer.js index b2f8e24..4b00826 100644 --- a/model/humanize/message-normalizer.js +++ b/model/humanize/message-normalizer.js @@ -7,7 +7,7 @@ * - 无稳定 message_id 时生成短期指纹(仅用于去重,不作永久业务主键)。 * - mentionsBotName 做最小边界匹配,避免子串误判(指南 §9.2)。 * - * 不做:网络请求、引用原文拉取(由 perception/composer 按需拉)。 + * 不做:网络请求;跨窗口引用原文由 app 层拉取后交给 normalizeReplySource 压缩。 */ import { createHash } from 'node:crypto' @@ -22,6 +22,8 @@ import { createHash } from 'node:crypto' * @property {string} text 纯文本(媒体标注占位) * @property {Array} segments 原始消息段(克隆,避免污染事件) * @property {string|null} replyToId 被引用消息 id + * @property {{id:string,userId:string,displayName:string,text:string,isSelf:boolean}|null} replySource + * 被引用消息不在窗口时的轻量快照(由 e.source/get_msg 富化) * @property {boolean} atBot 显式 @机器人 * @property {boolean} mentionsBotName 文本提及机器人昵称 * @property {boolean} quotesBot 引用了机器人的消息(由 app 层据 self 发言标记富化) @@ -131,6 +133,28 @@ function extractReplyToId(e, segments) { return null } +/** + * 把 e.source / getReply / OneBot get_msg 返回值压成可持久化的引用快照。 + * 不把历史消息重新 append 到 MessageBuffer,避免它被误当成一条新候选消息;Grounding 和 + * prompt 只通过 target.replySource 消费这份只读上下文。 + */ +export function normalizeReplySource(raw, { selfIds = [], fallbackId = null } = {}) { + if (!raw || typeof raw !== 'object') return null + const src = raw?.data?.message ? raw.data : raw + const segments = Array.isArray(src.message) + ? src.message.map((s) => (s?.data && typeof s.data === 'object' ? { ...s, ...s.data } : { ...s })) + : [] + const userId = String(src.user_id ?? src.sender?.user_id ?? '') + if (!userId) return null + const id = String(src.message_id ?? src.id ?? fallbackId ?? '') + if (!id) return null + const displayName = String(src.sender?.card || src.sender?.nickname || src.nickname || userId) + const rawText = src.raw_message ?? src.msg ?? src.content + const text = typeof rawText === 'string' ? rawText.trim() : segmentsToText(segments) + const isSelf = src.isSelf === true || selfIds.some((x) => String(x) === userId) + return { id, userId, displayName: isSelf ? '我' : displayName, text, isSelf } +} + /** * 文本是否提及机器人昵称(最小边界匹配)。 * - 昵称长度 < 2 不参与(单字误判率高)。 @@ -172,6 +196,7 @@ export function normalizeYunzaiEvent(e, opts = {}) { const text = String(e?.msg ?? segmentsToText(segments) ?? '') const media = extractMedia(segments) const replyToId = extractReplyToId(e, segments) + const replySource = normalizeReplySource(e?.source, { selfIds, fallbackId: replyToId }) const atBot = !!(e?.atBot || segments.some((s) => s?.type === 'at' && selfIds.includes(String(s.qq)))) const mentionsBotName = textMentionsName(text, botNames) @@ -191,6 +216,7 @@ export function normalizeYunzaiEvent(e, opts = {}) { text, segments, replyToId, + replySource, atBot, mentionsBotName, quotesBot: false, // 由 app 层据「replyToId 是否命中机器人最近发言」富化 diff --git a/model/humanize/necessity-scorer.js b/model/humanize/necessity-scorer.js index c97a7e0..fd3c905 100644 --- a/model/humanize/necessity-scorer.js +++ b/model/humanize/necessity-scorer.js @@ -144,6 +144,7 @@ function isFollowupToBot(msg, messages) { if (msg.replyToId != null) { const src = messages.find((m) => m && String(m.id) === String(msg.replyToId)) if (src && !src.isSelf) return false // 在回复别人,不是追问 bot + if (!src && msg.replySource && !msg.replySource.isSelf) return false // 跨窗口引用:轻量快照同样能确认在回复别人 } const t = String(msg.text || '') return /[??]/.test(t) || QUESTION_TERMS.some((w) => t.includes(w)) || DIRECT_REQUEST_TERMS.some((w) => t.includes(w)) diff --git a/model/humanize/planner.js b/model/humanize/planner.js index 51fd7a5..930b9ef 100644 --- a/model/humanize/planner.js +++ b/model/humanize/planner.js @@ -105,6 +105,22 @@ export class HumanizePlanner { } catch { ctxMessages = snapshot } const groupContext = formatGroupContext(ctxMessages, { includeIds: true }) const target = decision?.targetMessage || null + + // Grounding 必须先于记忆:threadUserIds 是人物印象检索的硬作用域。 + // 兼容旧注入器直接返回字符串;新注入器返回 { grounding, block }。 + let groundingBlock = '' + let groundingObj = null + try { + if (this.getGrounding) { + const gr = this.getGrounding(ctxMessages, { targetId: target?.id }) + if (typeof gr === 'string') groundingBlock = gr + else { + groundingObj = gr?.grounding || null + groundingBlock = gr?.block || '' + } + } + } catch { /* noop */ } + let publicMemories = '' try { // 记忆检索门控:只对有实质文本的目标查(对齐 MaiBot——寒暄/短反应/纯媒体不查,免得注入无关记忆+白调一次) @@ -127,17 +143,6 @@ export class HumanizePlanner { socialScene = scene?.text || '' } } catch { /* noop */ } - - // 对话落地块(结构化归属+白名单+纠错约束);取对象形态拿 threadUserIds 供记忆作用域过滤 - let groundingBlock = '' - let groundingObj = null - try { - if (this.getGrounding) { - const gr = this.getGrounding(ctxMessages, { targetId: target?.id }) - groundingObj = gr?.grounding || null - groundingBlock = gr?.block || String(gr || '') - } - } catch { /* noop */ } // SelfState 状态投影(enabled+非 shadow 时注入;失败/中性 → 零影响) let selfState = '' try { diff --git a/model/humanize/prompts.js b/model/humanize/prompts.js index 1c2efc6..53c02dd 100644 --- a/model/humanize/prompts.js +++ b/model/humanize/prompts.js @@ -195,7 +195,7 @@ export function formatGroupContext(messages = [], { includeIds = true, selfLabel if (m.replyToId && !m.quotesBot && !m.atBot) { // 回复目标解析到人名+被引原文摘要:窗口内 → ↩回复<名>(原文≤20字);窗口外 → 明示不在近窗。 // 带原文是关键:被回复消息里的"你/这个/权限"等指代只有看到原文才能消解,否则张冠李戴。 - const src = byId.get(String(m.replyToId)) + const src = byId.get(String(m.replyToId)) || m.replySource || null if (!src) rels.push('↩回复(不在近窗)') else { const q = String(src.text || '').replace(/\s+/g, ' ').slice(0, 32) diff --git a/model/humanize/test.mjs b/model/humanize/test.mjs index 60c6f1c..591c303 100644 --- a/model/humanize/test.mjs +++ b/model/humanize/test.mjs @@ -6,7 +6,7 @@ */ import { memoryKv } from '../agent/store/kv.js' import { - normalizeYunzaiEvent, isSelfEvent, collectSelfIds, fingerprintId, textMentionsName, segmentsToText, + normalizeYunzaiEvent, normalizeReplySource, isSelfEvent, collectSelfIds, fingerprintId, textMentionsName, segmentsToText, } from './message-normalizer.js' import { MessageBuffer } from './message-buffer.js' import { HumanizeStore, newHolderId } from './store.js' @@ -20,6 +20,7 @@ import { splitSegments, typingDelayMs, protect, restore } from './reply-composer import { Trace, redactForLog } from './trace.js' import { validateHumanizeConfig, resolveHumanizeConfig, DEFAULT_HUMANIZE_CONFIG } from './default-config.js' import { DEFAULT_HUMANIZE_PERSONA } from './default-persona.js' +import { resolveGrounding } from './grounding.js' let passed = 0, failed = 0 function ok(c, m) { if (c) { passed++; console.log(' ✓', m) } else { failed++; console.error(' ✗ FAIL', m) } } @@ -63,6 +64,32 @@ await test('normalizer:提及昵称边界匹配(避免子串误判)', asyn ok(fingerprintId({ text: 'x' }).startsWith('fp_'), 'fingerprintId 前缀') }) +await test('跨窗口引用:get_msg 快照参与归属、prompt 与追问判定,但不伪造新消息', async () => { + const source = normalizeReplySource({ + data: { + message_id: 'old_w', user_id: 'uW', sender: { nickname: '芜湖' }, + message: [{ type: 'text', data: { text: '帮我把我禁言' } }], + }, + }, { selfIds: ['bot'] }) + ok(source?.id === 'old_w' && source?.displayName === '芜湖' && source?.text === '帮我把我禁言', 'OneBot 嵌套段压成轻量引用快照') + + const bot = mkMsg('这我哪有权限啊', true, { userId: 'bot' }, 'self_old') + const reply = mkMsg('权限摆脸上自己不会看?', false, { + userId: 'uL', replyToId: 'old_w', replySource: source, + }, 'new_l', '林墨') + const grounding = resolveGrounding([bot, reply]) + ok(grounding?.semanticTarget === '芜湖' && grounding?.quoted?.text === '帮我把我禁言', '窗口外被回复者与原文进入 grounding') + ok(grounding?.threadUserIds.includes('uW'), '被回复者进入记忆作用域') + const ctx = formatGroupContext([bot, reply]) + ok(ctx.includes('↩回复芜湖(帮我把我禁言)'), 'prompt 渲染窗口外回复关系与原文') + + const decision = evaluate({ + messages: [bot, reply], candidates: [reply], pendingCount: 1, presence: {}, + cfg: { threshold: 80, talkValue: 0.35 }, + }) + ok(!decision.positiveReasons.includes('followup_to_bot'), '虽紧跟 bot 发言,引用窗口外真人时不误判为追问 bot') +}) + // ───────── buffer ───────── await test('buffer:容量/去重/批次游标/presence', async () => { const buf = new MessageBuffer({ capacity: 5, ttlMs: 60 * 60 * 1000 }) diff --git a/model/humanize/turn-scheduler.js b/model/humanize/turn-scheduler.js index 0031c0a..dc6dcc3 100644 --- a/model/humanize/turn-scheduler.js +++ b/model/humanize/turn-scheduler.js @@ -21,6 +21,9 @@ import { evaluate } from './necessity-scorer.js' import Log, { ANSI } from '../../utils/Log.js' import { topicMatchScore, hitsAvoidTopic } from './behavior-policy.js' +const STRONG_EXEMPTION_LIMIT = 3 +const isStrongSignal = (m) => !!(m && (m.atBot || m.quotesBot || m.mentionsBotName)) + export class TurnScheduler { /** * @param {object} opts { runtime, cfg:()=>object, planner, replyer, composer, send, now? } @@ -108,20 +111,56 @@ export class TurnScheduler { if (!external.length) { return } const now = Date.now() - const rawStrong = external.some((m) => m.atBot || m.quotesBot || m.mentionsBotName) - // 强信号豁免限制(Thread Engagement 温和版):①同一人连续强信号豁免 ≥3 次后不再绕过冷却/频率 - // (防对方每次引用→bot 无限续杯;正常斗嘴前 3 轮不受影响,换对象即重置);②纠错后 2 分钟内不豁免 - // streak 时间衰减:距上次强信号回复 >10 分钟视为新对话(否则正常聊过 3 次后永久失去豁免) - if ((rt._forcedAt || 0) && now - rt._forcedAt > 10 * 60 * 1000) { rt._forcedStreak = 0; rt._forcedUser = null } - const strongExhausted = (rt._forcedStreak || 0) >= 3 - const postCorrection = (rt._postCorrectionUntil || 0) > now - const hasStrongSignal = rawStrong && !strongExhausted && !postCorrection - // 纠错退线程:postCorrection 期间新消息标记已观察(旧纠错消息不再被反复评估),本轮直接退出 - if (postCorrection) { rt.markObserved(external); rt.setPhase('idle'); return } - if (rawStrong && strongExhausted) { - rt.trace?.record('strong_signal_exemption_limited', { streak: rt._forcedStreak || 0 }) + + // 纠错退线程按用户隔离:当前纠错消息允许处理一次;其后两分钟只忽略同一发送者。 + // 混合批次仍可回复其他人,不能因为甲纠正了一次就把乙也一起静音。 + const blocked = [] + const candidates = [] + for (const m of external) { + const correction = rt.referenceCorrectionFor?.(m.userId, now) + if (correction && String(correction.allowMessageId || '') !== String(m.id)) blocked.push(m) + else candidates.push(m) + } + if (blocked.length) { + const blockedIds = new Set(blocked.map((m) => String(m.id))) + ctxWindow = ctxWindow.filter((m) => !blockedIds.has(String(m.id))) + rt.trace?.record('post_correction_user_suppressed', { + users: [...new Set(blocked.map((m) => String(m.userId)))], count: blocked.length, + }) + } + if (!candidates.length) { + rt.markObserved(external) + rt.setPhase('idle') + return + } + + // 同一用户近 10 分钟已获 3 次强信号豁免后,彻底移除该用户本条消息的强信号标记, + // 再按普通消息重新评分;不是把总分打五折(低阈值下五折仍会错误保留 forcedCandidate)。 + const limitedIds = new Set() + const limitedUsers = new Set() + for (const m of candidates) { + const correction = rt.referenceCorrectionFor?.(m.userId, now) + const isCurrentCorrection = correction && String(correction.allowMessageId || '') === String(m.id) + if (!isCurrentCorrection && isStrongSignal(m) && (rt.strongReplyCount?.(m.userId, now) || 0) >= STRONG_EXEMPTION_LIMIT) { + limitedIds.add(String(m.id)) + limitedUsers.add(String(m.userId)) + } + } + const scoreCopies = new Map() + const forScoring = (m) => { + const key = String(m?.id || '') + if (!limitedIds.has(key)) return m + if (!scoreCopies.has(key)) scoreCopies.set(key, { ...m, atBot: false, quotesBot: false, mentionsBotName: false }) + return scoreCopies.get(key) + } + const scoringWindow = ctxWindow.map(forScoring) + const scoringCandidates = candidates.map(forScoring) + const hasStrongSignal = candidates.some((m) => isStrongSignal(m) && !limitedIds.has(String(m.id))) + if (limitedUsers.size) { + rt.trace?.record('strong_signal_exemption_limited', { + users: [...limitedUsers], limit: STRONG_EXEMPTION_LIMIT, + }) } - rt._turnStrong = rawStrong // 供发送侧 streak 计数(仅强信号轮次计入) // bot↔bot 闭环熔断(仅已知 bot 账号,config.humanize.knownBots;真人聊天不受影响): // 与已知 bot 交替 ≥3 轮且无真人夹入 → 群级 10 分钟熔断;期间仅真人 @/直接提问可重新进入 @@ -134,21 +173,22 @@ export class TurnScheduler { if (m.isSelf || knownBots.has(String(m.userId))) chain++ else break // 真人夹入即断 } - const humanNew = external.some((m) => !knownBots.has(String(m.userId))) + const humanNew = candidates.some((m) => !knownBots.has(String(m.userId))) if (chain >= 3 && !humanNew) { rt.botLoopUntil = now + 10 * 60 * 1000 rt.trace?.record('grounding_botloop_break', { chain, groupId: rt.groupId }) Log.mark('[humanize] bot↔bot 熔断', `群${rt.groupId} 连续${chain}节无真人,冷却10分钟`) } - if (rt.botLoopUntil > now && !external.some((m) => m.atBot)) { - const humanQuestion = humanNew && external.some((m) => /[??]|怎么|如何|为什么|什么/.test(String(m.text || ''))) - if (!humanQuestion) { rt.setPhase('idle'); return } + if (rt.botLoopUntil > now && !candidates.some((m) => m.atBot)) { + const humanQuestion = humanNew && candidates.some((m) => /[??]|怎么|如何|为什么|什么/.test(String(m.text || ''))) + if (!humanQuestion) { rt.markObserved(external); rt.setPhase('idle'); return } } } // 硬冷却:强信号绕过 if (rt.isCoolingDown(now) && !hasStrongSignal) { Log.info('[humanize] debounce 跳过:冷却中 剩余' + Math.ceil((rt.cooldownUntil - now) / 1000) + 's') + rt.markObserved(external) rt.setPhase('cooldown'); return } @@ -156,6 +196,7 @@ export class TurnScheduler { const maxRate = c.behaviorPolicy?.maxRepliesPer10Minutes ?? rt.maxRepliesPer10Minutes if (maxRate > 0 && rt.replyCountIn(10 * 60 * 1000, now) >= maxRate && !hasStrongSignal) { Log.info('[humanize] debounce 跳过:频率上限 ' + rt.replyCountIn(10 * 60 * 1000, now) + '/' + maxRate) + rt.markObserved(external) rt.setPhase('idle'); return } @@ -163,7 +204,7 @@ export class TurnScheduler { // presence + 评分 const presence = rt.buffer.presenceStats((c.presenceWindowSeconds ?? 300) * 1000) const policy = c.behaviorPolicy || {} - const topicText = (external[external.length - 1] || {}).text || '' + const topicText = (candidates[candidates.length - 1] || {}).text || '' const topicBonus = topicMatchScore(topicText, policy.topics) // avoidTopics 回避主题:命中则给负分(避免在不该参与的话题上插话) const avoidHit = Array.isArray(policy.avoidTopics) && policy.avoidTopics.length > 0 && hitsAvoidTopic(topicText, policy.avoidTopics) @@ -171,9 +212,9 @@ export class TurnScheduler { try { decision = evaluate({ - messages: ctxWindow, // 完整 rolling window(含 self)→ isFollowupToBot(+55) 可触发 - candidates: external, // 目标只从本批新消息里选 - pendingCount: external.length, // 压力分按本批条数,不按窗口长度(避免恒满) + messages: scoringWindow, // 完整 rolling window(含 self)→ isFollowupToBot(+55) 可触发 + candidates: scoringCandidates, // 目标只从本批可处理的新消息里选 + pendingCount: candidates.length, // 压力分按可处理批次条数,不按窗口长度(避免恒满) presence, now, cfg: { threshold: c.threshold ?? 80, @@ -200,22 +241,14 @@ export class TurnScheduler { positiveReasons: [...(decision.positiveReasons || []), 'ambient_chance'], } } - // 豁免耗尽/纠错期同步降级决策(否则 scorer 的 forcedCandidate 仍会清空退避=无限续杯只是降速) - if (decision && (strongExhausted || postCorrection) && decision.forcedCandidate) { - decision.forcedCandidate = false - decision.bypassBackoff = false - decision.finalScore = Math.round((decision.finalScore || 0) * 0.5) - decision.shouldPlan = decision.finalScore >= decision.threshold - rt.trace?.record('forced_candidate_downgraded', { streak: rt._forcedStreak || 0, postCorrection }) - } const turnId = rt.trace?.newTurnId?.() || null rt.trace?.record('gate_decision', { - turnId, batchSize: external.length, finalScore: decision.finalScore, + turnId, batchSize: candidates.length, finalScore: decision.finalScore, threshold: decision.threshold, shouldPlan: decision.shouldPlan, forcedCandidate: decision.forcedCandidate, reasons: { positive: decision.positiveReasons, negative: decision.negativeReasons }, targetMessageId: decision.targetMessage?.id || null, }) - Log.mark('[humanize] 门控', `群${rt.groupId} 批${external.length}条 分${decision.finalScore}/${decision.threshold}`, `+${(decision.positiveReasons || []).join('/') || '0'}`, `-${(decision.negativeReasons || []).join('/') || '0'}`, decision.shouldPlan ? `${ANSI.c}→ 进Planner${ANSI.R}` : `${ANSI.gry}→ 跳过(沉默)${ANSI.R}`) + Log.mark('[humanize] 门控', `群${rt.groupId} 批${candidates.length}条 分${decision.finalScore}/${decision.threshold}`, `+${(decision.positiveReasons || []).join('/') || '0'}`, `-${(decision.negativeReasons || []).join('/') || '0'}`, decision.shouldPlan ? `${ANSI.c}→ 进Planner${ANSI.R}` : `${ANSI.gry}→ 跳过(沉默)${ANSI.R}`) if (!decision.shouldPlan) { rt.markObserved(external) // 推进游标,避免重复评估 @@ -224,7 +257,7 @@ export class TurnScheduler { } // idle backoff(规划触发后、连续无动作时;强信号/批量绕过) - if (rt.backoff.shouldDelay({ pendingCount: external.length, forcedCandidate: decision.forcedCandidate, isGroup: true, now })) { + if (rt.backoff.shouldDelay({ pendingCount: candidates.length, forcedCandidate: decision.forcedCandidate, isGroup: true, now })) { rt.trace?.record('backoff_delay', { remaining: rt.backoff.remainingSec(now), count: rt.backoff.count }) rt.markObserved(external) rt.setPhase('idle') @@ -242,7 +275,7 @@ export class TurnScheduler { } const gen = rt.beginPlanning(batchId) - rt.trace?.record('planner_start', { turnId, gen, batchId, batchSize: external.length, targetMessageId: decision.targetMessage?.id || null }) + rt.trace?.record('planner_start', { turnId, gen, batchId, batchSize: candidates.length, targetMessageId: decision.targetMessage?.id || null }) try { const action = await this.planner.decide({ snapshot: ctxWindow, decision, signal: rt.signal, runtime: rt, cfg: c, @@ -288,6 +321,11 @@ export class TurnScheduler { } catch { /* noop */ } } + /** 成功处理强信号后按目标用户计数;shadow 与真实发送走同一路径。 */ + _recordStrongReply(target, now = Date.now()) { + if (isStrongSignal(target)) this.runtime.recordStrongReply?.(target.userId, now) + } + async _applyAction(action, batch, decision, gen, turnId) { const rt = this.runtime if (!rt.isCurrent(gen)) return @@ -377,6 +415,7 @@ export class TurnScheduler { try { await rt.store.markSent(rt.groupId, action.targetMessageId) } catch { /* noop */ } rt.enterCooldown(c.cooldownSeconds ?? 45) rt.recordReply(null) + this._recordStrongReply(target) this._appendSelf(text, null) // shadow 也计入自身在场(presence/上下文) this._notifyDelivered(target) // 写回 GroupWorld 主观关系(online 时;失败忽略) rt.backoff.recordSuccess() @@ -401,13 +440,7 @@ export class TurnScheduler { try { await rt.store.markSent(rt.groupId, action.targetMessageId) } catch { /* noop */ } rt.recordReply(result.sentIds[0]) rt._waitStreak = 0 // 发言即退出观望连击 - // 强信号豁免计数(修正:只数强信号轮次——普通参与的回复不计入;换对象/非强信号轮即重置) - const tu = String(target?.userId || '') - if (rt._turnStrong) { - rt._forcedStreak = (rt._forcedUser === tu) ? (rt._forcedStreak || 0) + 1 : 1 - rt._forcedUser = tu - rt._forcedAt = Date.now() - } else { rt._forcedStreak = 0; rt._forcedUser = null } + this._recordStrongReply(target) this._appendSelf(text, result.sentIds[0]) // 自身发言入 buffer(解锁 presence/引用/追问信号) this._notifyDelivered(target, { sentText: text, replyGuide: action.replyGuide || '', sourceMessageId: result.sentIds[0] }) // GW 主观关系 + SS 出站期待 rt.enterCooldown(c.cooldownSeconds ?? 45) diff --git a/stress/grounding-test.mjs b/stress/grounding-test.mjs index 7cd1764..2370c52 100644 --- a/stress/grounding-test.mjs +++ b/stress/grounding-test.mjs @@ -1,4 +1,4 @@ -import { resolveGrounding, formatGroundingBlock, whitelistViolations, windowNames } from '/root/trss-agent-plugin/model/humanize/grounding.js' +import { resolveGrounding, formatGroundingBlock, whitelistViolations, windowNames } from '../model/humanize/grounding.js' let p = 0, f = 0 const ok = (c, m) => { c ? (p++, console.log(' ✓', m)) : (f++, console.error(' ✗', m)) } const mk = (id, uid, name, text, extra = {}) => ({ id, userId: uid, displayName: name, timestamp: Date.now(), text, segments: [{ type: 'text', text }], replyToId: null, atBot: false, mentionsBotName: false, quotesBot: false, isSelf: false, ...extra })