Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 48 additions & 20 deletions apps/humanize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 {
Expand All @@ -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 与测试用)。 */
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -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) + '"')
Expand Down
4 changes: 3 additions & 1 deletion model/humanize/grounding.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) }))
Expand Down
50 changes: 49 additions & 1 deletion model/humanize/group-runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export class GroupRuntime {
// 最近回复时间戳(频率上限)
this.recentReplyTs = []

// 人际轮次控制(进程内):按用户隔离,避免一个人耗尽豁免后误伤同群其他人。
this._strongReplyByUser = new Map()
this._postCorrectionByUser = new Map()

// 定时器(进程内,不序列化)
this._debounceTimer = null
this._waitTimer = null
Expand Down Expand Up @@ -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 末尾)。 */
Expand Down Expand Up @@ -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 */ }
Expand Down
2 changes: 1 addition & 1 deletion model/humanize/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading