feat(chat): awakening module with 6 trigger dimensions#28
Conversation
Implements AstrBot-style multi-dimensional awakening system for LLM triggering. All features default to off (zero LLM calls when disabled). Six awakening triggers (priority order): - awakening_extend: keep conversation active within time window - awakening_relevance: user message continues bot conversation (word overlap fast filter + LLM judge) - awakening_qa: professional question detection (regex fast filter + LLM judge) - awakening_interest: keyword match (global + per-persona topics) - awakening_fallback: random probability - awakening_boredom: group silence detection with DND support Infrastructure: - config/awakening.toml for per-group overrides - [triggers.quick_judge] in llm.toml for dedicated judge model - BotMessageCache for relevance checking - LLM result cache with 60s TTL - /awakening command for runtime management - 66 unit tests covering all trigger types
There was a problem hiding this comment.
Bot Review
The awakening module adds 6 trigger dimensions with LLM-based arbitration, config-driven defaults/overrides, and a scheduler-based boredom check. The core architecture is sound, but there is one correctness bug where boredom cooldown mutates before rate-limit gating, plus dead code and an unsafe deserialization path that can crash the scheduler.
🔴 HIGH: check_boredom() calls st.mark_boredom_triggered(group_id) before the caller (run_boredom_check) checks the rate limiter. If rate_limiter.allow() blocks, the group's boredom cooldown is still consumed — the group won't be eligible again for check_interval seconds despite no message being sent.
File: quickquip/chat/awakening.py
Move the mark_boredom_triggered() call out of check_boredom() into run_boredom_check(), placing it after the rate_limiter.allow() check succeeds. This way the cooldown only resets when a message is actually dispatched.
🟡 MEDIUM: Dead code: _extract_json_trigger() is defined but never called. The identical inline logic appears in _llm_judge() at line 446.
File: quickquip/chat/awakening.py
Either delete _extract_json_trigger() or refactor _llm_judge() to call it (prefer the latter to keep trigger parsing in one place).
🟡 MEDIUM: Unused imports: asyncio (line 3) and time/time() (line 7) are imported but never referenced in the file.
File: quickquip/adapters/nonebot/awakening_plugin.py
Remove both unused imports to keep the module clean and avoid misleading readers.
🟡 MEDIUM: BoredomEnabledGroups.load() does not validate group IDs. If the JSON file is manually edited to contain non-numeric strings (e.g. 'abc'), run_boredom_check() will call int(gid) at awakening.py line 750 which raises ValueError and crashes the scheduler job, disabling boredom for all groups.
File: quickquip/adapters/nonebot/awakening_plugin.py
Filter _groups during load() with _safe_group_id() validation, or at minimum wrap int(gid) in run_boredom_check() with a try/except and log+skip invalid entries.
🟢 LOW: awakening_state.record_message() is called at line 68 in group_messages.py AND again inside check_awakening_triggers() at awakening.py line 681. This double-records _last_message_times on every message, which is harmless but wasteful.
File: quickquip/adapters/nonebot/group_messages.py
Remove the record_message() call inside check_awakening_triggers() since the caller already records it before invocation.
🟢 LOW: Boredom messages sent via run_boredom_check() bypass the normal pipeline — awakening_state.bot_messages.add() is never called for them. Consequently, if a user replies to a boredom-initiated message, relevance detection won't find the bot's message in BotMessageCache.
File: quickquip/chat/awakening.py
After sending the boredom message, call awakening_state.bot_messages.add(gid, reply_result['reply']) to include it in relevance context for subsequent messages.
Automated review. Reply with @KHPilot[bot] to ask follow-up questions.
| return None | ||
| if random.random() >= settings.boredom_probability: | ||
| return None | ||
| st.mark_boredom_triggered(group_id) |
There was a problem hiding this comment.
HIGH: check_boredom() calls st.mark_boredom_triggered(group_id) before the caller (run_boredom_check) checks the rate limiter. If rate_limiter.allow() blocks, the group's boredom cooldown is still consumed — the group won't be eligible again for check_interval seconds despite no message being sent.
Move the mark_boredom_triggered() call out of check_boredom() into run_boredom_check(), placing it after the rate_limiter.allow() check succeeds. This way the cooldown only resets when a message is actually dispatched.
| return False | ||
|
|
||
|
|
||
| def _extract_json_trigger(raw: str) -> bool: |
There was a problem hiding this comment.
MEDIUM: Dead code: _extract_json_trigger() is defined but never called. The identical inline logic appears in _llm_judge() at line 446.
Either delete _extract_json_trigger() or refactor _llm_judge() to call it (prefer the latter to keep trigger parsing in one place).
| @@ -0,0 +1,243 @@ | |||
| from __future__ import annotations | |||
|
|
|||
| import asyncio | |||
There was a problem hiding this comment.
MEDIUM: Unused imports: asyncio (line 3) and time/time() (line 7) are imported but never referenced in the file.
Remove both unused imports to keep the module clean and avoid misleading readers.
| try: | ||
| with self.path.open("r", encoding="utf-8") as f: | ||
| data = json.load(f) | ||
| self._groups = {str(g) for g in data.get("enabled", [])} |
There was a problem hiding this comment.
MEDIUM: BoredomEnabledGroups.load() does not validate group IDs. If the JSON file is manually edited to contain non-numeric strings (e.g. 'abc'), run_boredom_check() will call int(gid) at awakening.py line 750 which raises ValueError and crashes the scheduler job, disabling boredom for all groups.
Filter _groups during load() with _safe_group_id() validation, or at minimum wrap int(gid) in run_boredom_check() with a try/except and log+skip invalid entries.
| from quickquip.adapters.nonebot.voice import append_voice_transcripts, transcribe_message_records | ||
| from quickquip.app.message_pipeline import ( | ||
| _ensure_llm_bindings, | ||
| awakening_state, |
There was a problem hiding this comment.
LOW: awakening_state.record_message() is called at line 68 in group_messages.py AND again inside check_awakening_triggers() at awakening.py line 681. This double-records _last_message_times on every message, which is harmless but wasteful.
Remove the record_message() call inside check_awakening_triggers() since the caller already records it before invocation.
| recent_messages=trigger_context, | ||
| message_id=None, | ||
| ) | ||
| await bot.send_group_msg(group_id=int(gid), message=reply_result["reply"]) |
There was a problem hiding this comment.
LOW: Boredom messages sent via run_boredom_check() bypass the normal pipeline — awakening_state.bot_messages.add() is never called for them. Consequently, if a user replies to a boredom-initiated message, relevance detection won't find the bot's message in BotMessageCache.
After sending the boredom message, call awakening_state.bot_messages.add(gid, reply_result['reply']) to include it in relevance context for subsequent messages.
- Move mark_boredom_triggered() after rate_limiter check and send success (was consuming cooldown on rate-limit reject or LLM failure) - Make _extract_json_trigger() robust: regex match + strip markdown code fences - Remove dead code: deduplicate _extract_json_trigger logic in _llm_judge - Remove unused imports (asyncio, time, awakening_state) in awakening_plugin - Validate group IDs in BoredomEnabledGroups.load() via _safe_group_id() - Remove duplicate record_message() call in check_awakening_triggers - Add bot_messages.add() and stats_tracker.record_trigger() to boredom path - Complete /awakening help text with all 6 rule names
- Respect group LLM state, rule switches, and rate-limit availability before running soft awakening checks - Parse quick-judge JSON scores with threshold-aware cache keys - Skip boredom wakeups when group LLM is disabled and cover rate-limit probes with tests
- Fall back to the system default SSL context when certifi is unavailable - Preserve HTTPS context handling for OpenAI transcription requests
- Keep daily luck rolls as raw lognormal values without rounding away tiny multipliers - Tighten regression coverage so generated luck stays strictly positive
Summary
实现 AstrBot 风格的多维度唤醒系统,为 LLM 触发提供 6 种软触发条件。所有功能默认关闭(阈值 = 0 或 >= 1 时不产生任何 LLM 调用)。
6 种唤醒触发(优先级从高到低):
awakening_extend— 唤醒延长:LLM 回复后 N 秒内同一用户继续发言自动触发awakening_relevance— 相关性唤醒:用户消息延续 bot 之前的对话(词重叠快筛 + LLM 仲裁)awakening_qa— 答疑唤醒:检测到专业性问题(正则快筛 + LLM 仲裁)awakening_interest— 兴趣话题:关键词匹配,支持全局 + 人格独立配置awakening_fallback— 兜底概率:随机触发awakening_boredom— 无聊唤醒:群聊沉寂检测,支持免打扰时间区间基础设施:
config/awakening.toml— 配置文件,支持按群覆盖[triggers.quick_judge]inllm.toml— 可配置的快速判定专用模型BotMessageCache— 按群缓存 bot 最近 5 条回复/awakening命令 — 运行时管理(status/on/off/boredom)Test plan
interest_topics发送匹配消息验证触发relevance_threshold = 0.3验证相关性唤醒qa_threshold = 0.5验证答疑唤醒threshold >= 1.0时不产生 LLM 调用/awakening status命令输出