diff --git a/.gitignore b/.gitignore index b829e8c..d7b6f0b 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ config/generation.toml config/chat_rules.toml config/games.toml config/sensitive_words.toml +config/awakening.toml config/personas/ config/llm.local.toml config/llm.*.local.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d49f1e..18552ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ 本文件遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/) 规范,版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。 +## [Unreleased] + +### 变更 + +- 推荐 OneBot V11 基座从 NapCat 迁移至 LLBot:更新 compose 示例模板、部署文档、README;新增迁移指南 `docs/admin/migration-napcat-to-llbot.md`。NapCat 近期因 DLL 注入特征遭腾讯高强度风控(频繁 KickedOffLine / 静默掐断),LLBot 使用 PMHQ 外部内存 Hook 规避检测 + ## [1.6.1] - 2026-05-22 ### 变更 diff --git a/README.md b/README.md index a823e7d..050b42a 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ QuickQuip(双 Q 谐音 = QQ + Quip/妙语)是一个**轻量级、规则驱 - **Python** ≥ 3.11 - **NoneBot2** + **OneBot V11 适配器** -- OneBot V11 协议实现端(如 [Lagrange.OneBot](https://github.com/LagrangeDev/Lagrange.Core)、[NapCat](https://github.com/NapNeko/NapCatQQ)) +- OneBot V11 协议实现端(推荐 [LLBot](https://github.com/LLOneBot/LuckyLilliaBot),备选 [NapCat](https://github.com/NapNeko/NapCatQQ)) ### 安装步骤 diff --git a/config/awakening.toml.example b/config/awakening.toml.example new file mode 100644 index 0000000..b4685d3 --- /dev/null +++ b/config/awakening.toml.example @@ -0,0 +1,22 @@ +# 唤醒模块配置。默认所有功能关闭(阈值 = 0 或 >= 1)。 +# 复制为 config/awakening.toml 后按需修改。 + +[awakening.defaults] +extend_duration = 0 # 唤醒延长秒数,0=关闭 +fallback_probability = 0 # 兜底概率 0-1,0=关闭 +boredom_silence_seconds = 0 # 沉寂判定秒数,0=关闭 +boredom_probability = 0 # 沉寂触发概率 0-1 +boredom_check_interval = 300 # 无聊检查间隔秒数 +boredom_dnd_start = "" # 免打扰开始 HH:MM,空=不启用 +boredom_dnd_end = "" # 免打扰结束 HH:MM +interest_topics = [] # 全局兴趣关键词 +relevance_threshold = 1.0 # 相关性唤醒阈值 0-1,>=1 关闭(不产生 LLM 调用) +qa_threshold = 1.0 # 答疑唤醒阈值 0-1,>=1 关闭(不产生 LLM 调用) + +# 按群覆盖(可选,取消注释并填写群号) +# [[awakening.group_overrides]] +# group_id = "123456" +# extend_duration = 10 +# interest_topics = ["编程", "Python"] +# relevance_threshold = 0.5 +# qa_threshold = 0.88 diff --git a/config/llm.toml.example b/config/llm.toml.example index e14e0f0..e59550e 100644 --- a/config/llm.toml.example +++ b/config/llm.toml.example @@ -33,6 +33,14 @@ empty_prompt_reply = "请在触发指令或艾特后面补上想说的话。" enabled = false search_max_calls_per_round = 3 +# 快速判定专用模型(用于 context_rules llm_context、awakening 相关性/答疑判断等)。 +# 留空则回退到 default_provider / default_model。推荐使用廉价小模型以降低成本。 +[triggers.quick_judge] +provider_id = "" # 引用 [[providers]] 中的 id,留空用 default_provider +model = "" # 留空用该 provider 的 default_model +timeout = 2.0 # 秒 +max_tokens = 64 + [tools] enabled = [] # 工具发现:工具较多时只常驻少量核心工具,其余工具由 tool_search diff --git a/docker-compose.example.yml b/docker-compose.example.yml index c450847..4e0d8c8 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -11,21 +11,24 @@ name: quickquip services: - # ── QQ 协议适配器(NapCat)───────────────────────────────────────────── - # 参考:https://github.com/NapNeko/NapCatQQ - napcat: - image: mlikiowa/napcat-docker:latest - container_name: napcat + # ── QQ 协议适配器(LLBot)─────────────────────────────────────────────── + # 参考:https://github.com/LLOneBot/LuckyLilliaBot + # 登录态 / 设备指纹保存在 llbot-qq/,切勿丢失否则 QQ 视作新设备。 + llbot: + image: initialencounter/llonebot:latest + container_name: llbot + entrypoint: + - /entrypoint.sh environment: - - ACCOUNT=${QQ_ACCOUNT:?请设置 QQ 号} - - WS_ENABLE=true - - WS_HOST=0.0.0.0 - - WS_PORT=6099 + - QUICK_LOGIN_QQ=${QQ_ACCOUNT:?请设置 QQ 号} + - TZ=Asia/Shanghai ports: - - "127.0.0.1:6099:6099" - - "127.0.0.1:3000:3000" + - "127.0.0.1:3001:3001" # OneBot WebSocket(正向,备用) + - "127.0.0.1:3080:3080" # WebUI(扫码登录 / 配置管理) volumes: - - napcat-data:/app/napcat/config + - ./llbot-qq:/root/.config/QQ # 登录态持久化(关键) + - ./llbot-data:/root/llonebot # 配置文件 + 运行时数据 + - ./llbot-entrypoint.sh:/entrypoint.sh:ro # DNS 修复 wrapper restart: unless-stopped # ── 联网搜索(SearXNG)────────────────────────────────────────────────── @@ -53,14 +56,14 @@ services: image: ghcr.io/3aKHP/quickquip:latest container_name: quickquip depends_on: - - napcat + - llbot env_file: - .env environment: DRIVER: "${DRIVER:-~fastapi}" HOST: "${HOST:-0.0.0.0}" PORT: "${PORT:-8080}" - ONEBOT_WS_URLS: '${ONEBOT_WS_URLS:-["ws://napcat:6099"]}' + ONEBOT_WS_URLS: '${ONEBOT_WS_URLS:-["ws://llbot:3001/"]}' ONEBOT_ACCESS_TOKEN: "${ONEBOT_ACCESS_TOKEN:-}" SEARXNG_BASE_URL: "${SEARXNG_BASE_URL:-http://searxng:8080}" volumes: @@ -117,5 +120,4 @@ services: restart: unless-stopped volumes: - napcat-data: searxng-cache: diff --git a/docs/admin/deployment.md b/docs/admin/deployment.md index 552b360..577d755 100644 --- a/docs/admin/deployment.md +++ b/docs/admin/deployment.md @@ -6,7 +6,7 @@ LLM 模块的详细结构、边界和群内命令说明见 [../dev/llm-module.md - 一台 Linux 服务器(1 核 1G 即可) - 已安装 Docker 和 Docker Compose -- QQ 账号(用于 NapCat 登录) +- QQ 账号(用于 LLBot 登录) ## 推荐服务器 @@ -114,16 +114,16 @@ data/tieba/storage_state.json 容器化部署时,`data/fonts/` 目录应通过 `data/` bind mount 挂载到容器内,字体文件上传一次后即可持久使用。若字体文件缺失,执行 `/wordcloud` 时 bot 会回复明确的错误提示。 -### 5. 首次登录 NapCat +### 5. 首次登录 LLBot -NapCat 首次启动需要扫码登录: +LLBot 首次启动需要扫码登录: ```bash -# 查看 NapCat 日志,找到登录二维码 -docker compose logs -f napcat +# 查看 LLBot 日志,找到登录二维码 +docker compose logs -f llbot ``` -日志中会出现二维码或登录链接,用手机 QQ 扫码确认。登录成功后,登录态会持久化在 `napcat-data/` 目录中。 +日志中会出现二维码或登录链接,用手机 QQ 扫码确认。登录成功后,登录态会持久化在 `llbot-qq/` 目录中,配置文件在 `llbot-data/` 中。也可通过 WebUI(`http://<服务器IP>:3080`)扫码。 ### 6. 验证运行 @@ -221,13 +221,13 @@ docker compose down 当前项目已经同时保留 Tavily 直连能力和 MCP 扩展能力。MCP 集成的正式约定见 [../dev/mcp-integration.md](../dev/mcp-integration.md)。 -### NapCat 登录态过期 +### LLBot 登录态过期 换 IP 或长时间未活动后可能需要重新扫码: ```bash -docker compose restart napcat -docker compose logs -f napcat # 找新的二维码 +docker compose restart llbot +docker compose logs -f llbot # 找新的二维码 ``` ### QQ 风控/冻结 diff --git a/docs/admin/migration-napcat-to-llbot.md b/docs/admin/migration-napcat-to-llbot.md new file mode 100644 index 0000000..64eef86 --- /dev/null +++ b/docs/admin/migration-napcat-to-llbot.md @@ -0,0 +1,180 @@ +# NapCat → LLBot 迁移指南 + +QuickQuip 设计之初即以 NapCat(Docker 镜像 `mlikiowa/napcat-docker`)作为推荐的 OneBot V11 QQ 协议适配器。截至 2026 年 5 月中下旬,NapCat 遭遇腾讯高强度风控打击,社区和我们的生产环境均反复出现以下问题: + +1. **频繁 KickedOffLine**:上线后数小时内被强制踢下线 +2. **静默掐断**:QQ 连接无任何错误日志直接停止推送消息,手机端 QQ 同步被踢,疑似封号前兆 +3. 尝试 NapCat 反检测构建([PR #1768](https://github.com/NapNeko/NapCatQQ/pull/1768))后仍无法稳定 + +社区反馈([Issue #1728](https://github.com/NapNeko/NapCatQQ/issues/1728))确认此问题广泛存在。经评估其他 OneBot V11 方案后,推荐迁移至 [LLBot](https://github.com/LLOneBot/LuckyLilliaBot)(LuckyLilliaBot)。 + +## 为什么选 LLBot + +| | NapCat | LLBot | +|---|---|---| +| 原理 | DLL 注入 QQ 进程 | PMHQ 外部内存 Hook(独立进程) | +| 被检测面 | QQ 进程内 DLL 模块可被扫描 | QQ 进程空间无修改,更难检测 | +| Docker 镜像 | `mlikiowa/napcat-docker`(~1.2GB) | `initialencounter/llonebot:latest`(~880MB) | +| 签名服务器 | 无需(QQ 自带) | 无需(QQ 自带) | +| 社区活跃度 | 9k+ stars | 3.3k+ stars,日更 | +| OneBot V11 兼容 | 反向 WS、正向 WS | 反向 WS、正向 WS、HTTP、HTTP POST | + +核心区别:NapCat 把 DLL **塞进 QQ 进程内部**,腾讯可以扫描进程空间检测到外挂模块。LLBot 使用 **PMHQ(Pure Memory Hook for QQNT)**——一个独立进程通过 Linux 内存机制从外部与 QQ 交互,QQ 进程本身干干净净。 + +**QuickQuip 核心业务代码无需任何改动**——两者均通过标准 OneBot V11 反向 WebSocket 与 NoneBot 通信,接口完全一致。 + +## 迁移步骤 + +以下步骤基于 `docker-compose.example.yml` 的结构。如果你的部署使用了自定义 compose 文件,请对应调整。 + +### 1. 在 `docker-compose.yml` 中新增 LLBot 服务 + +```yaml +services: + llbot: + image: initialencounter/llonebot:latest + container_name: llbot + entrypoint: + - /entrypoint.sh + environment: + - QUICK_LOGIN_QQ=${QQ_ACCOUNT:?请设置 QQ 号} + - TZ=Asia/Shanghai + ports: + - "127.0.0.1:3001:3001" # OneBot WebSocket(正向,备用) + - "127.0.0.1:3080:3080" # WebUI(扫码登录 / 配置管理) + volumes: + - ./llbot-qq:/root/.config/QQ # 登录态持久化(关键,切勿丢失) + - ./llbot-data:/root/llonebot # 配置文件 + 运行时数据 + - ./llbot-entrypoint.sh:/entrypoint.sh:ro # DNS 修复 wrapper + restart: unless-stopped +``` + +**重要**:LLBot 镜像的入口脚本会将容器 DNS 指向公网服务器,导致无法解析 Docker Compose 内部服务名。需挂载修复脚本(见下方)。 + +### 2. 创建 DNS 修复脚本 + +在 compose 同目录下创建 `llbot-entrypoint.sh`: + +```bash +#!/bin/sh +echo 'nameserver 127.0.0.11' > /etc/resolv.conf +echo 'options ndots:0' >> /etc/resolv.conf +exec /bin/llonebot-service +``` + +```bash +chmod +x llbot-entrypoint.sh +``` + +### 3. 创建 OneBot 配置文件 + +LLBot 的配置为 JSON 格式,位于 `llbot-data/default_config.json`。最小配置(启用反向 WS): + +```json +{ + "webui": { "enable": true, "host": "", "port": 3080 }, + "ob11": { + "enable": true, + "connect": [ + { + "type": "ws-reverse", + "enable": true, + "url": "ws://quickquip:8080/onebot/v11/ws/", + "heartInterval": 60000, + "token": "", + "messageFormat": "array" + } + ] + }, + "log": true, + "msgCacheExpire": 120 +} +``` + +> **注意**:容器首次启动时入口脚本会用内置默认配置覆盖此文件。建议先启动容器完成首次登录,再通过 WebUI(`http://<服务器IP>:3080`)配置反向 WS,或使用 `docker exec` 修改 `data/config_.json`。 + +### 4. 更新 QuickQuip 服务 + +在 compose 中将 QuickQuip 的 `depends_on` 和 `ONEBOT_WS_URLS` 更新为指向 LLBot: + +```yaml +quickquip: + depends_on: + - llbot + environment: + ONEBOT_WS_URLS: '${ONEBOT_WS_URLS:-["ws://llbot:3001/"]}' +``` + +### 5. 启动并扫码登录 + +```bash +docker compose up -d llbot +# 查看日志获取二维码或访问 WebUI +docker compose logs -f llbot +# 或者浏览器打开 http://<服务器IP>:3080 +``` + +扫码完成后重启 QuickQuip 建立新连接: + +```bash +docker compose restart quickquip +``` + +验证连接成功: + +```bash +docker compose logs quickquip | grep "Bot.*connected" +# 应输出: OneBot V11 | Bot <你的QQ号> connected +``` + +### 6. 移除旧 NapCat 服务(验证稳定后) + +```bash +docker compose stop napcat +# 观察 24-48 小时确认稳定后 +docker compose rm napcat +``` + +NapCat 的登录态和数据卷(`napcat-data/`)建议在确认稳定前保留,以便快速回退。 + +## OneBot WS 模式说明 + +LLBot 同时支持正向和反向 WebSocket。QuickQuip 的默认 `~fastapi` driver 使用**反向 WS**——LLBot 作为客户端连接到 QuickQuip 的 `/onebot/v11/ws/` 端点。这与 NapCat 时期的行为完全一致,无需调整 NoneBot 配置。 + +正向 WS(端口 3001)作为备用保留,`ONEBOT_WS_URLS` 中的默认值即指向此端口。 + +## 已知差异 + +| 项 | NapCat | LLBot | 影响 | +|---|---|---|---| +| 长消息限制 | ~667 汉字截断 | 更高(未实测) | 800 字分块策略对两者均有效 | +| QQ 版本 | 3.2.28 | 3.2.25 | 略旧,腾讯可能未来强制升级 | +| 自动登录 | `ACCOUNT` 环境变量 | `QUICK_LOGIN_QQ` 环境变量(可能不生效) | 重启后可能需要重新扫码 | +| WebUI 端口 | 6099 | 3080 | SSH 隧道端口变更 | +| 日志格式 | `账号状态变更为在线` | `PMHQ WebSocket 连接成功` | 如有自定义监控需适配 | + +## 回退步骤 + +如 LLBot 出现严重问题: + +```bash +# 停止 LLBot +docker compose stop llbot + +# 恢复 ONEBOT_WS_URLS +# 将 .env 或 compose 中的 ws://llbot:3001/ 改回 ws://napcat:6099 + +# 恢复 depends_on(如有改动) + +# 重启 QuickQuip +docker compose restart quickquip + +# 启动 NapCat +docker compose start napcat +``` + +## 参考 + +- [LLBot 官方文档](https://luckylillia.com) +- [NapCat Issue #1728 - 风控掉线讨论](https://github.com/NapNeko/NapCatQQ/issues/1728) +- [NapCat PR #1768 - 反检测实验分支](https://github.com/NapNeko/NapCatQQ/pull/1768) diff --git a/docs/index.md b/docs/index.md index 2bee017..719fa53 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,7 +24,7 @@ QuickQuip 是一个基于 NoneBot2 + OneBot V11 的规则驱动优先 QQ 群聊 | 文件 | 说明 | |------|------| -| [admin/deployment.md](admin/deployment.md) | 云端部署指南——服务器选型、Docker Compose 编排、NapCat 登录、贴吧登录态、Web Admin 反代、日常维护与排障 | +| [admin/deployment.md](admin/deployment.md) | 云端部署指南——服务器选型、Docker Compose 编排、LLBot 登录、贴吧登录态、Web Admin 反代、日常维护与排障 | | [admin/configuration.md](admin/configuration.md) | 完整配置参考——`.env` 环境变量、`llm.toml`、`generation.toml`、`chat_rules.toml`、`personas/` 所有可配项 | | [admin/tool-discovery.md](admin/tool-discovery.md) | LLM 工具发现配置——大量 MCP 工具接入时的 `tool_search`、`tool_list`、常驻工具和排障建议 | | [admin/game-config.md](admin/game-config.md) | 游戏系统管理——游戏开关、参数配置、数据库文件、故障排查 | diff --git a/plugins/awakening.py b/plugins/awakening.py new file mode 100644 index 0000000..cd13b93 --- /dev/null +++ b/plugins/awakening.py @@ -0,0 +1,9 @@ +from quickquip.adapters.nonebot.awakening_plugin import ( + boredom_enabled_groups, + setup, +) + +__all__ = [ + "boredom_enabled_groups", + "setup", +] diff --git a/quickquip/adapters/nonebot/awakening_plugin.py b/quickquip/adapters/nonebot/awakening_plugin.py new file mode 100644 index 0000000..bf18736 --- /dev/null +++ b/quickquip/adapters/nonebot/awakening_plugin.py @@ -0,0 +1,250 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path + +try: + import nonebot + from nonebot_plugin_apscheduler import scheduler +except (ModuleNotFoundError, ValueError): + nonebot = None + scheduler = None + +from quickquip.app.message_pipeline import ( + _ensure_llm_bindings, + get_llm_service, + rate_limiter, + rule_switch, + stats_tracker, +) +from quickquip.chat.awakening import get_config, run_boredom_check + +logger = logging.getLogger(__name__) + +_RULE_NAME = "awakening_boredom" +_BOREDOM_GROUPS_PATH = Path("data/awakening_boredom_groups.json") + + +def _safe_group_id(group_id: int | str) -> str: + s = str(group_id).strip() + if not s.isdigit(): + raise ValueError(f"Invalid group_id: {group_id!r}") + return s + + +class BoredomEnabledGroups: + """Manages the opt-in set of groups with boredom awakening enabled.""" + + def __init__(self, path: str | Path = _BOREDOM_GROUPS_PATH): + self.path = Path(path) + self._groups: set[str] = set() + self.load() + + def load(self) -> None: + if not self.path.exists(): + return + try: + with self.path.open("r", encoding="utf-8") as f: + data = json.load(f) + raw_groups = data.get("enabled", []) + validated: set[str] = set() + for g in raw_groups: + try: + validated.add(_safe_group_id(g)) + except ValueError: + logger.warning("awakening: ignoring invalid group_id in %s: %r", self.path, g) + self._groups = validated + except (OSError, json.JSONDecodeError): + self._groups = set() + + def save(self) -> None: + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_suffix(".json.tmp") + try: + with tmp.open("w", encoding="utf-8") as f: + json.dump({"enabled": sorted(self._groups)}, f, ensure_ascii=False, indent=2) + tmp.replace(self.path) + except OSError: + logger.warning("awakening: failed to save boredom groups to %s", self.path) + tmp.unlink(missing_ok=True) + + def add(self, group_id: int | str) -> None: + self._groups.add(_safe_group_id(group_id)) + self.save() + + def remove(self, group_id: int | str) -> None: + self._groups.discard(_safe_group_id(group_id)) + self.save() + + def contains(self, group_id: int | str) -> bool: + return _safe_group_id(group_id) in self._groups + + def all_groups(self) -> list[str]: + return sorted(self._groups) + + +boredom_enabled_groups = BoredomEnabledGroups() + + +def _register_scheduler_jobs() -> None: + if not scheduler: + return + _ensure_llm_bindings() + cfg = get_config() + interval = cfg.defaults.boredom_check_interval + if interval <= 0: + interval = 300 + + from quickquip.adapters.nonebot.scheduler_plugin import record_job_result + + job_id = "awakening_boredom_check" + + async def _wrapped_boredom_check(): + try: + if nonebot is None: + return + try: + bot = nonebot.get_bot() + except Exception: + return + svc = get_llm_service() + await run_boredom_check(bot, boredom_enabled_groups, rule_switch, svc, rate_limiter, stats_tracker) + try: + record_job_result(job_id, True) + except Exception: + pass + except Exception as exc: + try: + record_job_result(job_id, False, str(exc)[:500]) + except Exception: + pass + raise + + scheduler.add_job( + _wrapped_boredom_check, + "interval", + seconds=interval, + id=job_id, + replace_existing=True, + ) + logger.info("awakening: boredom check job registered (interval=%ds)", interval) + + +def _is_admin(event) -> bool: + sender = getattr(event, "sender", None) + if sender: + role = getattr(sender, "role", None) + if role in ("admin", "owner"): + return True + return False + + +def _strip_command_name(text: str, command_name: str) -> str: + normalized = text.strip() + prefixes = (f"/{command_name}", f"!{command_name}", command_name) + for prefix in prefixes: + if normalized.startswith(prefix): + return normalized[len(prefix):].strip() + return normalized + + +def register_awakening_commands(on_command) -> None: + cmd = on_command("awakening", priority=10, block=True) + + @cmd.handle() + async def _(event): + if getattr(event, "group_id", None) is None: + await cmd.finish("该命令仅支持群聊") + + group_id = event.group_id + text = str(event.get_message()).strip() + args = _strip_command_name(text, "awakening").strip() + tokens = [item for item in args.split() if item] + action = tokens[0].lower() if tokens else "status" + + cfg = get_config() + settings = cfg.resolve_group(group_id) + + if action in {"status", "状态", ""}: + rules = [ + ("awakening_extend", "唤醒延长"), + ("awakening_interest", "兴趣话题"), + ("awakening_fallback", "兜底概率"), + ("awakening_boredom", "无聊唤醒"), + ("awakening_relevance", "相关性唤醒"), + ("awakening_qa", "答疑唤醒"), + ] + lines = ["唤醒模块状态:"] + for rule_name, label in rules: + enabled = rule_switch.is_enabled(group_id, rule_name) + lines.append(f" [{('ON' if enabled else 'OFF')}] {label} ({rule_name})") + + lines.append("") + lines.append(f"唤醒延长: {settings.extend_duration}s") + lines.append(f"兴趣话题: {settings.interest_topics or '(未配置)'}") + lines.append(f"兜底概率: {settings.fallback_probability}") + lines.append(f"无聊沉寂: {settings.boredom_silence_seconds}s / 概率 {settings.boredom_probability}") + lines.append(f"无聊检查间隔: {settings.boredom_check_interval}s") + lines.append(f"相关性阈值: {settings.relevance_threshold} (>=1 关闭)") + lines.append(f"答疑阈值: {settings.qa_threshold} (>=1 关闭)") + if settings.boredom_dnd_start and settings.boredom_dnd_end: + lines.append(f"免打扰: {settings.boredom_dnd_start}-{settings.boredom_dnd_end}") + boredom_group = boredom_enabled_groups.contains(group_id) + lines.append(f"无聊唤醒群启用: {'是' if boredom_group else '否'}") + await cmd.finish("\n".join(lines)) + + if action in {"on", "开启", "启用"}: + if not _is_admin(event): + await cmd.finish("仅管理员可执行此操作") + if len(tokens) < 2: + await cmd.finish("用法: /awakening on <规则名>\n可选: awakening_extend, awakening_interest, awakening_fallback, awakening_boredom, awakening_relevance, awakening_qa") + rule_name = tokens[1] + valid_rules = {"awakening_extend", "awakening_interest", "awakening_fallback", "awakening_boredom", "awakening_relevance", "awakening_qa"} + if rule_name not in valid_rules: + await cmd.finish(f"未知规则: {rule_name}") + rule_switch.enable(group_id, rule_name) + from quickquip.app.message_pipeline import RULE_SWITCH_PATH + rule_switch.save(RULE_SWITCH_PATH) + await cmd.finish(f"已启用 {rule_name}") + + if action in {"off", "关闭", "禁用"}: + if not _is_admin(event): + await cmd.finish("仅管理员可执行此操作") + if len(tokens) < 2: + await cmd.finish("用法: /awakening off <规则名>") + rule_name = tokens[1] + valid_rules = {"awakening_extend", "awakening_interest", "awakening_fallback", "awakening_boredom", "awakening_relevance", "awakening_qa"} + if rule_name not in valid_rules: + await cmd.finish(f"未知规则: {rule_name}") + rule_switch.disable(group_id, rule_name) + from quickquip.app.message_pipeline import RULE_SWITCH_PATH + rule_switch.save(RULE_SWITCH_PATH) + await cmd.finish(f"已禁用 {rule_name}") + + if action == "boredom": + if not _is_admin(event): + await cmd.finish("仅管理员可执行此操作") + sub = tokens[1].lower() if len(tokens) >= 2 else "" + if sub in {"on", "开启", "启用"}: + boredom_enabled_groups.add(group_id) + await cmd.finish("本群无聊唤醒已开启。") + if sub in {"off", "关闭", "禁用"}: + boredom_enabled_groups.remove(group_id) + await cmd.finish("本群无聊唤醒已关闭。") + await cmd.finish("用法: /awakening boredom on|off") + + await cmd.finish( + "用法: /awakening \n" + " status — 查看状态\n" + " on — 启用规则(管理员)\n" + " off — 禁用规则(管理员)\n" + " boredom on|off — 开关无聊唤醒群启用(管理员)\n" + "可选规则: awakening_extend, awakening_interest, awakening_fallback,\n" + " awakening_boredom, awakening_relevance, awakening_qa" + ) + + +def setup(on_command) -> None: + _register_scheduler_jobs() + register_awakening_commands(on_command) diff --git a/quickquip/adapters/nonebot/group_messages.py b/quickquip/adapters/nonebot/group_messages.py index 9acfe19..36b3702 100644 --- a/quickquip/adapters/nonebot/group_messages.py +++ b/quickquip/adapters/nonebot/group_messages.py @@ -6,6 +6,7 @@ from quickquip.adapters.nonebot.voice import append_voice_transcripts, transcribe_message_records from quickquip.app.message_pipeline import ( _ensure_llm_bindings, + awakening_state, get_llm_service, get_sender_name, message_deduper, @@ -64,6 +65,7 @@ async def _(bot, event): stats_tracker.record_message(group_id, user_id, sender_name) record_group_message(group_id, user_id, sender_name, rendered_text) record_wordcloud_message(group_id, sender_name, rendered_text) + awakening_state.record_message(group_id, user_id) pending = offline_message_store.pop_pending(group_id, user_id) if pending: @@ -120,6 +122,7 @@ async def _(bot, event): message_id=message_id or None, ) stats_tracker.record_trigger(group_id, result.get("rule_name", "unknown")) + awakening_state.bot_messages.add(group_id, result["reply"]) resp = await matcher.send(result["reply"]) sent_msg_id = str(resp.get("message_id", "")) if isinstance(resp, dict) else "" if sent_msg_id: @@ -127,6 +130,41 @@ async def _(bot, event): svc.store.update_last_assistant_message_id(scope_key, sent_msg_id) return + from quickquip.chat.awakening import check_awakening_triggers + + awakening_result = await check_awakening_triggers( + group_id, + user_id, + text, + llm_settings, + svc, + rule_enabled=lambda rule_name: rule_switch.is_enabled(group_id, rule_name), + rate_available=lambda rule_name: rate_limiter.can_allow(rule_name, user_id, group_id=group_id), + ) + if awakening_result and rule_switch.is_enabled(group_id, awakening_result.rule_name): + _remember_recent_message(group_id, user_id, sender_name, canonical_name, rendered_text, message_id) + if not rate_limiter.allow(awakening_result.rule_name, user_id, group_id=group_id): + return + trigger_context = recent_messages.list_recent(group_id, limit=20) + result = await svc.generate_reply( + group_id=group_id, + user_id=user_id, + sender_name=sender_name, + prompt=awakening_result.prompt, + image_urls=[], + recent_messages=trigger_context, + message_id=message_id or None, + ) + stats_tracker.record_trigger(group_id, awakening_result.rule_name) + awakening_state.bot_messages.add(group_id, result["reply"]) + resp = await matcher.send(result["reply"]) + sent_msg_id = str(resp.get("message_id", "")) if isinstance(resp, dict) else "" + if sent_msg_id: + scope_key = str(group_id) + svc.store.update_last_assistant_message_id(scope_key, sent_msg_id) + awakening_state.mark_awakened(group_id, user_id) + return + result = await resolve_reply( text, user_id=user_id, diff --git a/quickquip/adapters/nonebot/lifecycle.py b/quickquip/adapters/nonebot/lifecycle.py index da5e0fb..0cadbba 100644 --- a/quickquip/adapters/nonebot/lifecycle.py +++ b/quickquip/adapters/nonebot/lifecycle.py @@ -12,6 +12,7 @@ daily_enabled_groups, daily_briefing_enabled_groups, ) +from quickquip.adapters.nonebot.awakening_plugin import boredom_enabled_groups from quickquip.tieba.service import tieba_service logger = logging.getLogger(__name__) @@ -26,6 +27,7 @@ def _init_mtimes() -> None: RULE_SWITCH_PATH, daily_enabled_groups.path, daily_briefing_enabled_groups.path, + boredom_enabled_groups.path, ): try: _watched[str(path)] = os.stat(path).st_mtime @@ -39,6 +41,7 @@ def _reload_if_changed() -> None: (RULE_SWITCH_PATH, lambda: rule_switch.load(RULE_SWITCH_PATH)), (daily_enabled_groups.path, daily_enabled_groups.load), (daily_briefing_enabled_groups.path, daily_briefing_enabled_groups.load), + (boredom_enabled_groups.path, boredom_enabled_groups.load), ] for path, reload_fn in checks: key = str(path) diff --git a/quickquip/adapters/nonebot/long_messages.py b/quickquip/adapters/nonebot/long_messages.py index 0122691..fbf4019 100644 --- a/quickquip/adapters/nonebot/long_messages.py +++ b/quickquip/adapters/nonebot/long_messages.py @@ -5,8 +5,8 @@ logger = logging.getLogger(__name__) -# NapCat silently truncates single text messages beyond ~2 KB (~667 Chinese chars). -# Split at paragraph / line boundaries to stay well under that limit. +# OneBot 协议实现端(NapCat / LLBot)在单条消息超过 ~2 KB 时可能截断。 +# 按段落 / 换行拆分,保持在限制以内。 _MAX_SEND_CHARS = 800 diff --git a/quickquip/adapters/nonebot/tz_tracker_plugin.py b/quickquip/adapters/nonebot/tz_tracker_plugin.py index c2094ab..6503944 100644 --- a/quickquip/adapters/nonebot/tz_tracker_plugin.py +++ b/quickquip/adapters/nonebot/tz_tracker_plugin.py @@ -16,6 +16,7 @@ from quickquip.adapters.nonebot.daily_briefing_plugin import setup as setup_daily_briefing from quickquip.adapters.nonebot.daily_summary_plugin import setup as setup_daily_summary from quickquip.adapters.nonebot.wordcloud_plugin import setup as setup_wordcloud +from quickquip.adapters.nonebot.awakening_plugin import setup as setup_awakening from quickquip.adapters.nonebot.group_messages import register_message_matcher from quickquip.adapters.nonebot.private_messages import register_private_message_matcher from quickquip.adapters.nonebot.recall_handler import register_recall_handlers @@ -45,6 +46,7 @@ setup_daily_briefing(on_command) setup_daily_summary(on_command) setup_wordcloud(on_command) + setup_awakening(on_command) if on_notice is not None: recall_matcher = register_recall_handlers(on_notice) diff --git a/quickquip/app/message_pipeline.py b/quickquip/app/message_pipeline.py index 37d4036..e40c4c7 100644 --- a/quickquip/app/message_pipeline.py +++ b/quickquip/app/message_pipeline.py @@ -36,6 +36,11 @@ from quickquip.chat.daily_briefing import DailyBriefingEnabledGroups from quickquip.chat.wordcloud import WordCloudCollector from quickquip.chat.context_rules import match_context_rule +from quickquip.chat.awakening import ( + get_config as _get_awakening_config, + get_state as _get_awakening_state, + reload_config as _reload_awakening_config, +) from quickquip.chat.offline_messages import OfflineMessageStore from quickquip.chat.group_quotes import GroupQuoteStore from quickquip.common.message_deduper import RecentMessageDeduper @@ -87,6 +92,7 @@ def close(self) -> None: rule_switch = GroupRuleSwitch() recent_messages = RecentMessageBuffer(max_messages_per_group=20, ttl_seconds=1800) message_deduper = RecentMessageDeduper() +awakening_state = _get_awakening_state() daily_collector = DailyMessageCollector() daily_store = DailySummaryStore() @@ -176,11 +182,13 @@ def reload_chat_rules_pipeline() -> dict[str, int]: custom_chain_games.replace_defs( [ChainGameDef.from_dict(d) for d in chat_config.CHAIN_GAME_CONFIGS] ) + _reload_awakening_config() return { "text_rules": len(chat_config.TEXT_REPLY_RULES), "context_rules": len(chat_config.CONTEXT_REPLY_RULES), "chain_games": len(chat_config.CHAIN_GAME_CONFIGS), "rate_limit_rules": len(chat_config.RATE_LIMIT_RULES), + "awakening_config_error": 1 if _get_awakening_config().load_error else 0, } diff --git a/quickquip/chat/awakening.py b/quickquip/chat/awakening.py new file mode 100644 index 0000000..0edefb5 --- /dev/null +++ b/quickquip/chat/awakening.py @@ -0,0 +1,807 @@ +from __future__ import annotations + +import asyncio +import logging +import random +import re +import tomllib +from collections import deque +from collections.abc import Callable +from dataclasses import dataclass, field, fields +from pathlib import Path +from time import monotonic +from typing import Any +from datetime import datetime +from zoneinfo import ZoneInfo + +from quickquip.chat.config import BEIJING_TIMEZONE +from quickquip.common.json_utils import extract_json_object +from quickquip.common.paths import CONFIG_AWAKENING_TOML + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Config dataclasses +# --------------------------------------------------------------------------- + + +@dataclass(slots=True) +class AwakeningDefaults: + extend_duration: int = 0 + fallback_probability: float = 0.0 + boredom_silence_seconds: int = 0 + boredom_probability: float = 0.0 + boredom_check_interval: int = 300 + boredom_dnd_start: str = "" + boredom_dnd_end: str = "" + interest_topics: list[str] = field(default_factory=list) + relevance_threshold: float = 1.0 + qa_threshold: float = 1.0 + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> AwakeningDefaults: + if not data: + return cls() + valid = {f.name for f in fields(cls)} + filtered: dict[str, Any] = {} + for k, v in data.items(): + if k in valid and v is not None: + if k == "interest_topics" and isinstance(v, list): + filtered[k] = [str(item).strip() for item in v if str(item).strip()] + else: + filtered[k] = v + return cls(**filtered) + + +@dataclass(slots=True) +class AwakeningGroupOverride: + group_id: str = "" + extend_duration: int | None = None + fallback_probability: float | None = None + boredom_silence_seconds: int | None = None + boredom_probability: float | None = None + boredom_check_interval: int | None = None + boredom_dnd_start: str | None = None + boredom_dnd_end: str | None = None + interest_topics: list[str] | None = None + relevance_threshold: float | None = None + qa_threshold: float | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any] | None) -> AwakeningGroupOverride | None: + if not data: + return None + group_id = str(data.get("group_id", "")).strip() + if not group_id: + return None + valid = {f.name for f in fields(cls)} - {"group_id"} + filtered: dict[str, Any] = {"group_id": group_id} + for k, v in data.items(): + if k in valid and v is not None: + if k == "interest_topics" and isinstance(v, list): + filtered[k] = [str(item).strip() for item in v if str(item).strip()] + else: + filtered[k] = v + return cls(**filtered) + + +@dataclass(slots=True) +class ResolvedAwakeningSettings: + extend_duration: int = 0 + fallback_probability: float = 0.0 + boredom_silence_seconds: int = 0 + boredom_probability: float = 0.0 + boredom_check_interval: int = 300 + boredom_dnd_start: str = "" + boredom_dnd_end: str = "" + interest_topics: list[str] = field(default_factory=list) + relevance_threshold: float = 1.0 + qa_threshold: float = 1.0 + + +@dataclass(slots=True) +class AwakeningConfig: + defaults: AwakeningDefaults = field(default_factory=AwakeningDefaults) + group_overrides: dict[str, AwakeningGroupOverride] = field(default_factory=dict) + load_error: str | None = None + source_path: Path | None = None + + def resolve_group(self, group_id: int | str) -> ResolvedAwakeningSettings: + override = self.group_overrides.get(str(group_id)) + d = self.defaults + if override is None: + return ResolvedAwakeningSettings( + extend_duration=d.extend_duration, + fallback_probability=d.fallback_probability, + boredom_silence_seconds=d.boredom_silence_seconds, + boredom_probability=d.boredom_probability, + boredom_check_interval=d.boredom_check_interval, + boredom_dnd_start=d.boredom_dnd_start, + boredom_dnd_end=d.boredom_dnd_end, + interest_topics=list(d.interest_topics), + relevance_threshold=d.relevance_threshold, + qa_threshold=d.qa_threshold, + ) + return ResolvedAwakeningSettings( + extend_duration=override.extend_duration if override.extend_duration is not None else d.extend_duration, + fallback_probability=override.fallback_probability if override.fallback_probability is not None else d.fallback_probability, + boredom_silence_seconds=override.boredom_silence_seconds if override.boredom_silence_seconds is not None else d.boredom_silence_seconds, + boredom_probability=override.boredom_probability if override.boredom_probability is not None else d.boredom_probability, + boredom_check_interval=override.boredom_check_interval if override.boredom_check_interval is not None else d.boredom_check_interval, + boredom_dnd_start=override.boredom_dnd_start if override.boredom_dnd_start is not None else d.boredom_dnd_start, + boredom_dnd_end=override.boredom_dnd_end if override.boredom_dnd_end is not None else d.boredom_dnd_end, + interest_topics=list(override.interest_topics) if override.interest_topics is not None else list(d.interest_topics), + relevance_threshold=override.relevance_threshold if override.relevance_threshold is not None else d.relevance_threshold, + qa_threshold=override.qa_threshold if override.qa_threshold is not None else d.qa_threshold, + ) + + +@dataclass(slots=True) +class AwakeningTriggerResult: + rule_name: str + prompt: str + trigger_reason: str + + +# --------------------------------------------------------------------------- +# Config loading +# --------------------------------------------------------------------------- + + +def load_awakening_config(path: str | Path) -> AwakeningConfig: + config_path = Path(path) + if not config_path.exists(): + return AwakeningConfig(source_path=config_path) + + try: + with config_path.open("rb") as fh: + data = tomllib.load(fh) + except (OSError, tomllib.TOMLDecodeError) as exc: + return AwakeningConfig(load_error=f"无法解析 {config_path}:{exc}", source_path=config_path) + + raw = data.get("awakening", data) + defaults = AwakeningDefaults.from_dict(raw.get("defaults")) + + overrides: dict[str, AwakeningGroupOverride] = {} + for entry in raw.get("group_overrides", []): + if not isinstance(entry, dict): + continue + ov = AwakeningGroupOverride.from_dict(entry) + if ov is not None: + overrides[ov.group_id] = ov + + return AwakeningConfig( + defaults=defaults, + group_overrides=overrides, + source_path=config_path, + ) + + +# --------------------------------------------------------------------------- +# Module-level singletons +# --------------------------------------------------------------------------- + +_config: AwakeningConfig = load_awakening_config(CONFIG_AWAKENING_TOML) + + +def get_config() -> AwakeningConfig: + return _config + + +def reload_config(path: str | Path | None = None) -> None: + global _config + _config = load_awakening_config(path or CONFIG_AWAKENING_TOML) + + +# --------------------------------------------------------------------------- +# Runtime state (in-memory, not persisted) +# --------------------------------------------------------------------------- + + +class BotMessageCache: + """Per-group cache of recent bot reply texts for relevance checking.""" + + __slots__ = ("_messages",) + _MAX_PER_GROUP = 5 + + def __init__(self) -> None: + self._messages: dict[str, deque[str]] = {} + + def add(self, group_id: int | str, text: str) -> None: + gid = str(group_id) + if gid not in self._messages: + self._messages[gid] = deque(maxlen=self._MAX_PER_GROUP) + if text.strip(): + self._messages[gid].append(text.strip()) + + def get_recent(self, group_id: int | str) -> list[str]: + return list(self._messages.get(str(group_id), [])) + + def clear_group(self, group_id: int | str) -> None: + self._messages.pop(str(group_id), None) + + +class AwakeningState: + __slots__ = ( + "_extend_sessions", "_last_message_times", "_last_boredom_trigger", + "bot_messages", "_llm_cache", + ) + + _LLM_CACHE_TTL = 60.0 + _LLM_CACHE_MAX = 256 + + def __init__(self) -> None: + self._extend_sessions: dict[str, dict[str, float]] = {} + self._last_message_times: dict[str, float] = {} + self._last_boredom_trigger: dict[str, float] = {} + self.bot_messages = BotMessageCache() + self._llm_cache: dict[tuple[str, str, str], tuple[bool, float]] = {} + + def record_message(self, group_id: int | str, user_id: int | str) -> None: + self._last_message_times[str(group_id)] = monotonic() + + def mark_awakened(self, group_id: int | str, user_id: int | str) -> None: + gid = str(group_id) + uid = str(user_id) + if gid not in self._extend_sessions: + self._extend_sessions[gid] = {} + self._extend_sessions[gid][uid] = monotonic() + + def is_in_extend_window(self, group_id: int | str, user_id: int | str, duration: int) -> bool: + if duration <= 0: + return False + gid = str(group_id) + uid = str(user_id) + sessions = self._extend_sessions.get(gid) + if sessions is None: + return False + ts = sessions.get(uid) + if ts is None: + return False + return (monotonic() - ts) < duration + + def get_group_silence_seconds(self, group_id: int | str) -> float: + ts = self._last_message_times.get(str(group_id)) + if ts is None: + return float("inf") + return monotonic() - ts + + def can_trigger_boredom(self, group_id: int | str, check_interval: int) -> bool: + ts = self._last_boredom_trigger.get(str(group_id)) + if ts is None: + return True + return (monotonic() - ts) >= check_interval + + def mark_boredom_triggered(self, group_id: int | str) -> None: + self._last_boredom_trigger[str(group_id)] = monotonic() + + def llm_cache_get(self, rule: str, group_id: int | str, text: str) -> bool | None: + key = (rule, str(group_id), text) + entry = self._llm_cache.get(key) + if entry is None: + return None + result, ts = entry + if (monotonic() - ts) > self._LLM_CACHE_TTL: + del self._llm_cache[key] + return None + return result + + def llm_cache_set(self, rule: str, group_id: int | str, text: str, result: bool) -> None: + if len(self._llm_cache) >= self._LLM_CACHE_MAX: + now = monotonic() + expired = [k for k, (_, ts) in self._llm_cache.items() if (now - ts) > self._LLM_CACHE_TTL] + for k in expired: + del self._llm_cache[k] + if len(self._llm_cache) >= self._LLM_CACHE_MAX: + oldest_key = min(self._llm_cache, key=lambda k: self._llm_cache[k][1]) + del self._llm_cache[oldest_key] + self._llm_cache[(rule, str(group_id), text)] = (result, monotonic()) + + def prune_stale(self, max_age: float = 7200) -> None: + now = monotonic() + for sessions in self._extend_sessions.values(): + stale = [uid for uid, ts in sessions.items() if (now - ts) > max_age] + for uid in stale: + del sessions[uid] + stale_groups = [gid for gid, sessions in self._extend_sessions.items() if not sessions] + for gid in stale_groups: + del self._extend_sessions[gid] + + stale_msgs = [gid for gid, ts in self._last_message_times.items() if (now - ts) > max_age] + for gid in stale_msgs: + del self._last_message_times[gid] + + stale_boredom = [gid for gid, ts in self._last_boredom_trigger.items() if (now - ts) > max_age] + for gid in stale_boredom: + del self._last_boredom_trigger[gid] + + +_state = AwakeningState() + + +def get_state() -> AwakeningState: + return _state + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +# Common Chinese question markers for fast QA filtering +_QA_FAST_PATTERNS = re.compile(r"[??]|(?:请问|求解|怎么[办样]?|如何|怎么回事|谁能帮|有没有人|有没[有谁]|求助|谁知道|为啥|为什么|什么原因|怎样|能不能|可不可以|可以吗|是什么|怎么办|该怎么)") + +# Stopwords for word overlap calculation +_STOPWORDS = frozenset("的了是在我你他她它们吗呢啊吧呀哦嘛嗯么这那就也都还不") + + +def _is_in_dnd_window(dnd_start: str, dnd_end: str, now: datetime | None = None) -> bool: + if not dnd_start or not dnd_end: + return False + try: + sh, sm = int(dnd_start.split(":")[0]), int(dnd_start.split(":")[1]) + eh, em = int(dnd_end.split(":")[0]), int(dnd_end.split(":")[1]) + except (ValueError, IndexError): + return False + + now_cst = now or datetime.now(ZoneInfo(BEIJING_TIMEZONE)) + current_minutes = now_cst.hour * 60 + now_cst.minute + start_minutes = sh * 60 + sm + end_minutes = eh * 60 + em + + if start_minutes <= end_minutes: + return start_minutes <= current_minutes < end_minutes + else: + return current_minutes >= start_minutes or current_minutes < end_minutes + + +def _get_effective_interest_topics( + settings: ResolvedAwakeningSettings, + persona_id: str, + svc: Any, +) -> list[str]: + topics = list(settings.interest_topics) + try: + persona = svc.config.personas.get(persona_id) + if persona is not None: + persona_cfg = persona.extras.get("awakening", {}) + persona_topics = persona_cfg.get("interest_topics", []) + if isinstance(persona_topics, list): + topics.extend(str(t).strip() for t in persona_topics if str(t).strip()) + except Exception: + pass + seen: set[str] = set() + deduped: list[str] = [] + for t in topics: + key = t.lower() + if key not in seen: + seen.add(key) + deduped.append(t) + return deduped + + +def _extract_words(text: str) -> set[str]: + """Extract meaningful Chinese characters/bigrams from text (no jieba needed).""" + # Keep only CJK characters, then extract bigrams + unigrams + chars = [c for c in text if "一" <= c <= "鿿"] + if not chars: + return set() + words: set[str] = set() + for c in chars: + if c not in _STOPWORDS: + words.add(c) + for i in range(len(chars) - 1): + bigram = chars[i] + chars[i + 1] + if chars[i] not in _STOPWORDS or chars[i + 1] not in _STOPWORDS: + words.add(bigram) + return words + + +def _word_overlap_ratio(user_text: str, bot_texts: list[str]) -> float: + """Fast word overlap between user message and bot messages. Returns max ratio.""" + user_words = _extract_words(user_text) + if not user_words: + return 0.0 + max_ratio = 0.0 + for bt in bot_texts: + bot_words = _extract_words(bt) + if not bot_words: + continue + overlap = len(user_words & bot_words) + ratio = overlap / min(len(user_words), len(bot_words)) + if ratio > max_ratio: + max_ratio = ratio + return max_ratio + + +# --------------------------------------------------------------------------- +# LLM judge helpers +# --------------------------------------------------------------------------- + +_RELEVANCE_SYSTEM = ( + "你是一个仅输出 JSON 的判定器。" + "判断用户消息是否在延续或回应 bot 之前的对话。" + '仅输出 {"score": 0.0} 到 {"score": 1.0},score 越高越相关。' +) + +_QA_SYSTEM = ( + "你是一个仅输出 JSON 的判定器。" + "判断用户消息是否是一个需要专业性回答的问题(而非日常闲聊问候)。" + '仅输出 {"score": 0.0} 到 {"score": 1.0},score 越高越需要回答。' +) + + +async def _llm_judge( + svc: Any, + system_prompt: str, + user_prompt: str, + threshold: float, + timeout: float, + max_tokens: int, +) -> bool: + """Call quick_judge with timeout. Returns True if score passes threshold.""" + try: + # quick_judge uses its own system_prompt; we embed ours in the user prompt + full_prompt = f"[系统指令] {system_prompt}\n\n[待判定内容] {user_prompt}" + raw = await asyncio.wait_for( + svc.quick_judge(full_prompt, max_tokens=max_tokens), + timeout=timeout, + ) + return _extract_json_trigger(raw, threshold) + except asyncio.TimeoutError: + logger.debug("awakening: quick_judge timed out (%.1fs)", timeout) + return False + except Exception: + logger.debug("awakening: quick_judge failed", exc_info=True) + return False + + +def _extract_json_trigger(raw: str, threshold: float = 0.5) -> bool: + """Extract trigger decision from JSON-ish model output.""" + try: + data = extract_json_object(raw) + if "score" in data: + return float(data["score"]) >= threshold + if "trigger" in data: + trigger = data["trigger"] + if isinstance(trigger, bool): + return trigger + if isinstance(trigger, str): + return trigger.strip().lower() == "true" + return bool(trigger) + except (TypeError, ValueError): + pass + return bool(re.search(r'"trigger"\s*:\s*true', raw, re.IGNORECASE)) + + +def _llm_cache_text(message_text: str, threshold: float) -> str: + return f"{threshold:.6g}\0{message_text}" + + +# --------------------------------------------------------------------------- +# Trigger check functions +# --------------------------------------------------------------------------- + +_RULE_EXTEND = "awakening_extend" +_RULE_INTEREST = "awakening_interest" +_RULE_FALLBACK = "awakening_fallback" +_RULE_BOREDOM = "awakening_boredom" +_RULE_RELEVANCE = "awakening_relevance" +_RULE_QA = "awakening_qa" + +_BOREDOM_PROMPT = "(群聊沉寂已久,自然地冒个泡说点什么吧)" +_RELEVANCE_PROMPT_TEMPLATE = "(用户在延续你之前的对话,请自然地回应)\n用户说:{text}" +_QA_PROMPT_TEMPLATE = "(用户提出了一个问题,请回答)\n用户说:{text}" + + +def check_extend( + group_id: int | str, + user_id: int | str, + message_text: str, + settings: ResolvedAwakeningSettings, + state: AwakeningState | None = None, +) -> AwakeningTriggerResult | None: + if settings.extend_duration <= 0 or not message_text.strip(): + return None + st = state or _state + if not st.is_in_extend_window(group_id, user_id, settings.extend_duration): + return None + return AwakeningTriggerResult( + rule_name=_RULE_EXTEND, + prompt=message_text.strip(), + trigger_reason="唤醒延长:用户在活跃窗口内继续发言", + ) + + +def check_interest( + group_id: int | str, + user_id: int | str, + message_text: str, + settings: ResolvedAwakeningSettings, + persona_id: str, + svc: Any, +) -> AwakeningTriggerResult | None: + topics = _get_effective_interest_topics(settings, persona_id, svc) + if not topics or not message_text.strip(): + return None + text_lower = message_text.lower() + for topic in topics: + if topic.lower() in text_lower: + return AwakeningTriggerResult( + rule_name=_RULE_INTEREST, + prompt=message_text.strip(), + trigger_reason=f"兴趣话题匹配:{topic}", + ) + return None + + +def check_fallback( + group_id: int | str, + user_id: int | str, + message_text: str, + settings: ResolvedAwakeningSettings, +) -> AwakeningTriggerResult | None: + if settings.fallback_probability <= 0 or not message_text.strip(): + return None + if random.random() >= settings.fallback_probability: + return None + return AwakeningTriggerResult( + rule_name=_RULE_FALLBACK, + prompt=message_text.strip(), + trigger_reason="兜底概率触发", + ) + + +def check_boredom( + group_id: int | str, + settings: ResolvedAwakeningSettings, + state: AwakeningState | None = None, +) -> AwakeningTriggerResult | None: + if settings.boredom_silence_seconds <= 0 or settings.boredom_probability <= 0: + return None + if _is_in_dnd_window(settings.boredom_dnd_start, settings.boredom_dnd_end): + return None + st = state or _state + silence = st.get_group_silence_seconds(group_id) + if silence < settings.boredom_silence_seconds: + return None + if not st.can_trigger_boredom(group_id, settings.boredom_check_interval): + return None + if random.random() >= settings.boredom_probability: + return None + return AwakeningTriggerResult( + rule_name=_RULE_BOREDOM, + prompt=_BOREDOM_PROMPT, + trigger_reason=f"无聊唤醒:沉寂 {silence:.0f}s", + ) + + +async def check_relevance( + group_id: int | str, + user_id: int | str, + message_text: str, + settings: ResolvedAwakeningSettings, + svc: Any, + state: AwakeningState | None = None, + timeout: float = 2.0, + max_tokens: int = 64, +) -> AwakeningTriggerResult | None: + """Check if user message is continuing a conversation with the bot. + + Two-stage: fast word overlap filter -> LLM judge. + Zero LLM calls if threshold <= 0 or >= 1.0 (disabled). + """ + if settings.relevance_threshold <= 0 or settings.relevance_threshold >= 1.0 or not message_text.strip(): + return None + + st = state or _state + bot_msgs = st.bot_messages.get_recent(group_id) + if not bot_msgs: + return None + + # Stage 1: fast word overlap filter + overlap = _word_overlap_ratio(message_text, bot_msgs) + if overlap < 0.1: + return None + + # Check LLM cache + cache_text = _llm_cache_text(message_text, settings.relevance_threshold) + cached = st.llm_cache_get(_RULE_RELEVANCE, group_id, cache_text) + if cached is not None: + if not cached: + return None + return AwakeningTriggerResult( + rule_name=_RULE_RELEVANCE, + prompt=_RELEVANCE_PROMPT_TEMPLATE.format(text=message_text.strip()), + trigger_reason=f"相关性唤醒:overlap={overlap:.2f}", + ) + + # Stage 2: LLM judge + context_lines = [f"[bot 回复 {i+1}] {msg}" for i, msg in enumerate(bot_msgs)] + user_prompt = "\n".join(context_lines) + f"\n[用户消息] {message_text.strip()}" + triggered = await _llm_judge(svc, _RELEVANCE_SYSTEM, user_prompt, settings.relevance_threshold, timeout, max_tokens) + st.llm_cache_set(_RULE_RELEVANCE, group_id, cache_text, triggered) + + if not triggered: + return None + + return AwakeningTriggerResult( + rule_name=_RULE_RELEVANCE, + prompt=_RELEVANCE_PROMPT_TEMPLATE.format(text=message_text.strip()), + trigger_reason=f"相关性唤醒:overlap={overlap:.2f}, LLM确认", + ) + + +async def check_qa( + group_id: int | str, + user_id: int | str, + message_text: str, + settings: ResolvedAwakeningSettings, + svc: Any, + state: AwakeningState | None = None, + timeout: float = 2.0, + max_tokens: int = 64, +) -> AwakeningTriggerResult | None: + """Check if user message is a question needing a professional answer. + + Two-stage: fast regex filter -> LLM judge. + Zero LLM calls if threshold <= 0 or >= 1.0 (disabled). + """ + if settings.qa_threshold <= 0 or settings.qa_threshold >= 1.0 or not message_text.strip(): + return None + + # Stage 1: fast regex filter - must contain question markers + if not _QA_FAST_PATTERNS.search(message_text): + return None + + st = state or _state + + # Check LLM cache + cache_text = _llm_cache_text(message_text, settings.qa_threshold) + cached = st.llm_cache_get(_RULE_QA, group_id, cache_text) + if cached is not None: + if not cached: + return None + return AwakeningTriggerResult( + rule_name=_RULE_QA, + prompt=_QA_PROMPT_TEMPLATE.format(text=message_text.strip()), + trigger_reason="答疑唤醒:LLM缓存命中", + ) + + # Stage 2: LLM judge + triggered = await _llm_judge(svc, _QA_SYSTEM, message_text.strip(), settings.qa_threshold, timeout, max_tokens) + st.llm_cache_set(_RULE_QA, group_id, cache_text, triggered) + + if not triggered: + return None + + return AwakeningTriggerResult( + rule_name=_RULE_QA, + prompt=_QA_PROMPT_TEMPLATE.format(text=message_text.strip()), + trigger_reason="答疑唤醒:LLM确认", + ) + + +# --------------------------------------------------------------------------- +# Orchestrator +# --------------------------------------------------------------------------- + + +async def check_awakening_triggers( + group_id: int | str, + user_id: int | str, + message_text: str, + llm_settings: Any, + svc: Any, + *, + state: AwakeningState | None = None, + rule_enabled: Callable[[str], bool] | None = None, + rate_available: Callable[[str], bool] | None = None, +) -> AwakeningTriggerResult | None: + if not bool(getattr(llm_settings, "enabled", True)): + return None + + cfg = get_config() + settings = cfg.resolve_group(group_id) + st = state or _state + + def _rule_enabled(rule_name: str) -> bool: + return True if rule_enabled is None else rule_enabled(rule_name) + + def _rate_available(rule_name: str) -> bool: + return True if rate_available is None else rate_available(rule_name) + + # Stage 1: synchronous checks (no LLM) + if _rule_enabled(_RULE_EXTEND) and _rate_available(_RULE_EXTEND): + result = check_extend(group_id, user_id, message_text, settings, st) + if result is not None: + return result + + persona_id = getattr(llm_settings, "persona_id", "") + if _rule_enabled(_RULE_INTEREST) and _rate_available(_RULE_INTEREST): + result = check_interest(group_id, user_id, message_text, settings, persona_id, svc) + if result is not None: + return result + + # Stage 2: async checks (may call LLM, gated by threshold + fast filter) + qj_cfg = svc.config.quick_judge if hasattr(svc, "config") else None + timeout = qj_cfg.timeout if qj_cfg and qj_cfg.timeout > 0 else 2.0 + max_tokens = qj_cfg.max_tokens if qj_cfg and qj_cfg.max_tokens > 0 else 64 + + if _rule_enabled(_RULE_RELEVANCE) and _rate_available(_RULE_RELEVANCE): + result = await check_relevance(group_id, user_id, message_text, settings, svc, st, timeout, max_tokens) + if result is not None: + return result + + if _rule_enabled(_RULE_QA) and _rate_available(_RULE_QA): + result = await check_qa(group_id, user_id, message_text, settings, svc, st, timeout, max_tokens) + if result is not None: + return result + + # Stage 3: fallback + if _rule_enabled(_RULE_FALLBACK) and _rate_available(_RULE_FALLBACK): + result = check_fallback(group_id, user_id, message_text, settings) + if result is not None: + return result + + return None + + +# --------------------------------------------------------------------------- +# Boredom check entry point (for scheduler) +# --------------------------------------------------------------------------- + + +def _is_group_llm_enabled(svc: Any, group_id: int | str) -> bool: + config = getattr(svc, "config", None) + if getattr(config, "load_error", None): + return False + try: + settings = svc.get_group_settings(group_id) + except Exception: + logger.debug("awakening_boredom: failed to resolve LLM settings for group %s", group_id, exc_info=True) + return False + return bool(getattr(settings, "enabled", False)) + + +async def run_boredom_check( + bot: Any, + boredom_enabled_groups: Any, + rule_switch: Any, + svc: Any, + rate_limiter: Any | None = None, + stats_tracker: Any | None = None, +) -> None: + cfg = get_config() + st = get_state() + st.prune_stale() + + for gid in boredom_enabled_groups.all_groups(): + if not rule_switch.is_enabled(gid, _RULE_BOREDOM): + continue + if not _is_group_llm_enabled(svc, gid): + continue + settings = cfg.resolve_group(gid) + result = check_boredom(gid, settings, st) + if result is None: + continue + if rate_limiter is not None and not rate_limiter.allow(_RULE_BOREDOM, "boredom_timer", group_id=gid): + continue + try: + trigger_context = svc.recent_message_buffer.list_recent(gid, limit=20) if hasattr(svc, "recent_message_buffer") else [] + reply_result = await svc.generate_reply( + group_id=gid, + user_id="boredom_timer", + sender_name="系统", + prompt=result.prompt, + image_urls=[], + recent_messages=trigger_context, + message_id=None, + ) + await bot.send_group_msg(group_id=int(gid), message=reply_result["reply"]) + st.mark_boredom_triggered(gid) + st.bot_messages.add(gid, reply_result["reply"]) + if stats_tracker is not None: + stats_tracker.record_trigger(gid, _RULE_BOREDOM) + logger.info("awakening_boredom: sent to group %s (%s)", gid, result.trigger_reason) + except Exception: + logger.warning("awakening_boredom: failed for group %s", gid, exc_info=True) diff --git a/quickquip/chat/config.py b/quickquip/chat/config.py index 4dcf400..771ea2a 100644 --- a/quickquip/chat/config.py +++ b/quickquip/chat/config.py @@ -33,6 +33,12 @@ "repeat_same_user_warning": {"global_limit": 4, "user_limit": 2}, "good_girl_chain_entry": {"global_limit": 20, "user_limit": 10}, "game_interaction": {"global_limit": 30, "user_limit": 15}, + "awakening_extend": {"global_limit": 6, "user_limit": 3, "scope": "global"}, + "awakening_interest": {"global_limit": 6, "user_limit": 3, "scope": "global"}, + "awakening_fallback": {"global_limit": 3, "user_limit": 1, "scope": "global"}, + "awakening_boredom": {"global_limit": 3, "user_limit": 1, "scope": "global"}, + "awakening_relevance": {"global_limit": 6, "user_limit": 3, "scope": "global"}, + "awakening_qa": {"global_limit": 6, "user_limit": 3, "scope": "global"}, } RATE_LIMIT_RULES: dict[str, dict] = dict(_BUILTIN_RATE_LIMIT_RULES) diff --git a/quickquip/chat/rule_switch.py b/quickquip/chat/rule_switch.py index 37872b3..3e9d076 100644 --- a/quickquip/chat/rule_switch.py +++ b/quickquip/chat/rule_switch.py @@ -20,6 +20,12 @@ "timezone_sleep", "llm_chat", "tieba_random_post", + "awakening_extend", + "awakening_interest", + "awakening_fallback", + "awakening_boredom", + "awakening_relevance", + "awakening_qa", # 历史保留(曾在硬编码名单中,可能在旧部署里出现) "maggot_arrival", "master_protection", diff --git a/quickquip/common/paths.py b/quickquip/common/paths.py index f959f66..b507843 100644 --- a/quickquip/common/paths.py +++ b/quickquip/common/paths.py @@ -19,6 +19,7 @@ CONFIG_GENERATION_TOML = CONFIG_DIR / "generation.toml" CONFIG_GAMES_TOML = CONFIG_DIR / "games.toml" CONFIG_SENSITIVE_WORDS_TOML = CONFIG_DIR / "sensitive_words.toml" +CONFIG_AWAKENING_TOML = CONFIG_DIR / "awakening.toml" LLM_DB_PATH = DATA_DIR / "llm.db" DAILY_SUMMARIES_DB_PATH = DATA_DIR / "daily_summaries.db" diff --git a/quickquip/common/rate_limit.py b/quickquip/common/rate_limit.py index 93026f2..a879c9f 100644 --- a/quickquip/common/rate_limit.py +++ b/quickquip/common/rate_limit.py @@ -30,6 +30,15 @@ def allow(self, user_id: int | str, now_ts: float | None = None) -> bool: user_queue.append(current_ts) return True + def can_allow(self, user_id: int | str, now_ts: float | None = None) -> bool: + current_ts = time() if now_ts is None else now_ts + + self._prune(self.global_timestamps, current_ts) + user_queue = self.user_timestamps[str(user_id)] + self._prune(user_queue, current_ts) + + return len(self.global_timestamps) < self.global_limit and len(user_queue) < self.user_limit + def snapshot(self, now_ts: float | None = None) -> dict: current_ts = time() if now_ts is None else now_ts self._prune(self.global_timestamps, current_ts) @@ -136,6 +145,19 @@ def allow( limiter = self._get_or_create(key, bucket_key) return limiter.allow(user_id=user_id, now_ts=now_ts) + def can_allow( + self, + key: str, + user_id: int | str, + now_ts: float | None = None, + group_id: int | str | None = None, + ) -> bool: + if key not in self.rule_configs: + return True + bucket_key = self._bucket_key(key, group_id) + limiter = self._get_or_create(key, bucket_key) + return limiter.can_allow(user_id=user_id, now_ts=now_ts) + def snapshot(self, now_ts: float | None = None) -> dict[str, dict]: result: dict[str, dict] = {} for name, cfg in self.rule_configs.items(): diff --git a/quickquip/games/niuniu/store.py b/quickquip/games/niuniu/store.py index adc35cd..322ddf4 100644 --- a/quickquip/games/niuniu/store.py +++ b/quickquip/games/niuniu/store.py @@ -18,10 +18,10 @@ def _roll_lognormal(sigma: float) -> float: """Roll a log10-symmetric luck value: lg(x) ~ N(0, σ). - No hard bounds — extreme values (1e-9 or 1e9) are rare but possible. + No hard bounds or rounding — extreme values (1e-9 or 1e9) are rare but possible. σ=1 → ±1σ = [0.1, 10], median = 1.0. """ - return round(10.0 ** random.gauss(0.0, sigma), 2) + return 10.0 ** random.gauss(0.0, sigma) def _utc_now() -> str: diff --git a/quickquip/generation/asr.py b/quickquip/generation/asr.py index e21997f..d036818 100644 --- a/quickquip/generation/asr.py +++ b/quickquip/generation/asr.py @@ -34,7 +34,7 @@ def _ssl_context(): try: import certifi except ModuleNotFoundError: - return None + return ssl.create_default_context() return ssl.create_default_context(cafile=certifi.where()) diff --git a/quickquip/llm/config.py b/quickquip/llm/config.py index 3f09eac..bfb4f70 100644 --- a/quickquip/llm/config.py +++ b/quickquip/llm/config.py @@ -25,6 +25,14 @@ class AutoSearchConfig: search_max_calls_per_round: int = 3 +@dataclass(slots=True) +class QuickJudgeConfig: + provider_id: str = "" + model: str = "" + timeout: float = 2.0 + max_tokens: int = 64 + + @dataclass(slots=True) class RuntimeConfig: enabled: bool = False @@ -162,6 +170,7 @@ class LLMConfig: runtime: RuntimeConfig = field(default_factory=RuntimeConfig) triggers: TriggerConfig = field(default_factory=TriggerConfig) auto_search: AutoSearchConfig = field(default_factory=AutoSearchConfig) + quick_judge: QuickJudgeConfig = field(default_factory=QuickJudgeConfig) tools: ToolsConfig = field(default_factory=ToolsConfig) mcp: MCPConfig = field(default_factory=MCPConfig) providers: dict[str, ProviderConfig] = field(default_factory=dict) @@ -456,6 +465,7 @@ def load_llm_config(path: str | Path) -> LLMConfig: raw_providers = data.get("providers", []) raw_mcp_servers = mcp_raw.get("servers", []) auto_search_raw = expand_env_value(as_dict(triggers_raw.get("auto_search"))) + quick_judge_raw = expand_env_value(as_dict(triggers_raw.get("quick_judge"))) config = LLMConfig( runtime=RuntimeConfig( @@ -490,6 +500,12 @@ def load_llm_config(path: str | Path) -> LLMConfig: enabled=as_bool(auto_search_raw.get("enabled", False), default=False), search_max_calls_per_round=max(1, min(int(auto_search_raw.get("search_max_calls_per_round", 3)), 32)), ), + quick_judge=QuickJudgeConfig( + provider_id=str(quick_judge_raw.get("provider_id", "")).strip(), + model=str(quick_judge_raw.get("model", "")).strip(), + timeout=max(0.5, float(quick_judge_raw.get("timeout", 2.0))), + max_tokens=max(8, int(quick_judge_raw.get("max_tokens", 64))), + ), tools=ToolsConfig( enabled=[ str(item).strip() diff --git a/quickquip/llm/service.py b/quickquip/llm/service.py index 1a249e9..786590f 100644 --- a/quickquip/llm/service.py +++ b/quickquip/llm/service.py @@ -454,10 +454,12 @@ def _build_system_prompt( async def quick_judge(self, prompt: str, max_tokens: int = 64) -> str: """ - 用于 context_rules 的极速判定调用。 + 用于 context_rules 和 awakening 的极速判定调用。 不走群配置、不注入记忆、不启用工具,只发单条 system+user。 + 优先使用 [triggers.quick_judge] 配置的 provider/model。 """ - provider_id = self.config.runtime.default_provider + qj = self.config.quick_judge + provider_id = qj.provider_id if qj.provider_id else self.config.runtime.default_provider if not provider_id or provider_id not in self.config.providers: provider_id = next(iter(self.config.providers), None) if not provider_id: @@ -466,8 +468,10 @@ async def quick_judge(self, prompt: str, max_tokens: int = 64) -> str: provider = self.config.providers[provider_id] judge_provider = replace(provider, stream_enabled=False) + model = qj.model if qj.model else judge_provider.default_model + request = LLMRequest( - model=judge_provider.default_model, + model=model, system_prompt="你是一个仅输出 JSON 的判定器。", messages=[ LLMConversationMessage(role="user", content=prompt), diff --git a/tests/unit/chat/test_awakening.py b/tests/unit/chat/test_awakening.py new file mode 100644 index 0000000..7413a85 --- /dev/null +++ b/tests/unit/chat/test_awakening.py @@ -0,0 +1,819 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + + +from quickquip.chat.awakening import ( + AwakeningConfig, + AwakeningDefaults, + AwakeningGroupOverride, + AwakeningState, + BotMessageCache, + ResolvedAwakeningSettings, + _QA_FAST_PATTERNS, + _RULE_EXTEND, + _RULE_INTEREST, + _RULE_QA, + _RULE_RELEVANCE, + _extract_json_trigger, + _extract_words, + _is_in_dnd_window, + _llm_cache_text, + _word_overlap_ratio, + check_awakening_triggers, + check_boredom, + check_extend, + check_fallback, + check_interest, + check_qa, + check_relevance, + load_awakening_config, + run_boredom_check, +) + + +# ========================================================================= +# Config loading +# ========================================================================= + + +class TestAwakeningDefaults: + def test_from_dict_none(self): + d = AwakeningDefaults.from_dict(None) + assert d.extend_duration == 0 + assert d.relevance_threshold == 1.0 + assert d.qa_threshold == 1.0 + + def test_from_dict_with_values(self): + d = AwakeningDefaults.from_dict({ + "extend_duration": 10, + "relevance_threshold": 0.5, + "qa_threshold": 0.88, + "interest_topics": ["a", "b"], + }) + assert d.extend_duration == 10 + assert d.relevance_threshold == 0.5 + assert d.qa_threshold == 0.88 + assert d.interest_topics == ["a", "b"] + + def test_from_dict_ignores_unknown_keys(self): + d = AwakeningDefaults.from_dict({"unknown_key": 42, "extend_duration": 5}) + assert d.extend_duration == 5 + assert not hasattr(d, "unknown_key") + + def test_from_dict_strips_empty_topics(self): + d = AwakeningDefaults.from_dict({"interest_topics": ["a", "", " ", "b"]}) + assert d.interest_topics == ["a", "b"] + + +class TestAwakeningGroupOverride: + def test_from_dict_none(self): + assert AwakeningGroupOverride.from_dict(None) is None + + def test_from_dict_empty_group_id(self): + assert AwakeningGroupOverride.from_dict({"group_id": ""}) is None + + def test_from_dict_valid(self): + ov = AwakeningGroupOverride.from_dict({ + "group_id": "123", + "extend_duration": 15, + "relevance_threshold": 0.3, + }) + assert ov is not None + assert ov.group_id == "123" + assert ov.extend_duration == 15 + assert ov.relevance_threshold == 0.3 + assert ov.qa_threshold is None # not set + + def test_from_dict_strips_empty_topics(self): + ov = AwakeningGroupOverride.from_dict({ + "group_id": "1", + "interest_topics": ["x", ""], + }) + assert ov is not None + assert ov.interest_topics == ["x"] + + +class TestAwakeningConfig: + def test_resolve_group_no_override(self): + cfg = AwakeningConfig(defaults=AwakeningDefaults(extend_duration=5)) + s = cfg.resolve_group("999") + assert s.extend_duration == 5 + + def test_resolve_group_with_override(self): + cfg = AwakeningConfig( + defaults=AwakeningDefaults(extend_duration=5, interest_topics=["global"]), + group_overrides={ + "123": AwakeningGroupOverride( + group_id="123", extend_duration=10, interest_topics=["local"] + ), + }, + ) + s123 = cfg.resolve_group("123") + assert s123.extend_duration == 10 + assert s123.interest_topics == ["local"] + + s456 = cfg.resolve_group("456") + assert s456.extend_duration == 5 + assert s456.interest_topics == ["global"] + + def test_resolve_group_partial_override(self): + cfg = AwakeningConfig( + defaults=AwakeningDefaults(extend_duration=5, relevance_threshold=0.5), + group_overrides={ + "1": AwakeningGroupOverride(group_id="1", extend_duration=20), + }, + ) + s = cfg.resolve_group("1") + assert s.extend_duration == 20 + assert s.relevance_threshold == 0.5 # inherited from defaults + + +class TestLoadAwakeningConfig: + def test_missing_file_returns_defaults(self, tmp_path: Path): + cfg = load_awakening_config(tmp_path / "missing.toml") + assert cfg.load_error is None + assert cfg.defaults.extend_duration == 0 + + def test_loads_valid_toml(self, tmp_path: Path): + p = tmp_path / "awakening.toml" + p.write_text( + '[awakening.defaults]\nextend_duration = 15\nrelevance_threshold = 0.4\n', + encoding="utf-8", + ) + cfg = load_awakening_config(p) + assert cfg.load_error is None + assert cfg.defaults.extend_duration == 15 + assert cfg.defaults.relevance_threshold == 0.4 + + def test_malformed_toml_sets_load_error(self, tmp_path: Path): + p = tmp_path / "bad.toml" + p.write_text("this is not valid toml [[[", encoding="utf-8") + cfg = load_awakening_config(p) + assert cfg.load_error is not None + + def test_group_overrides(self, tmp_path: Path): + p = tmp_path / "awakening.toml" + p.write_text( + '[awakening.defaults]\nextend_duration = 5\n\n' + '[[awakening.group_overrides]]\ngroup_id = "100"\nextend_duration = 30\n', + encoding="utf-8", + ) + cfg = load_awakening_config(p) + assert cfg.resolve_group("100").extend_duration == 30 + assert cfg.resolve_group("200").extend_duration == 5 + + +# ========================================================================= +# BotMessageCache +# ========================================================================= + + +class TestBotMessageCache: + def test_add_and_get(self): + c = BotMessageCache() + c.add("g1", "hello") + c.add("g1", "world") + assert c.get_recent("g1") == ["hello", "world"] + + def test_empty_group(self): + c = BotMessageCache() + assert c.get_recent("g1") == [] + + def test_group_isolation(self): + c = BotMessageCache() + c.add("g1", "a") + c.add("g2", "b") + assert c.get_recent("g1") == ["a"] + assert c.get_recent("g2") == ["b"] + + def test_maxlen_eviction(self): + c = BotMessageCache() + for i in range(10): + c.add("g1", str(i)) + msgs = c.get_recent("g1") + assert len(msgs) == 5 + assert msgs[0] == "5" + + def test_skips_empty_text(self): + c = BotMessageCache() + c.add("g1", "") + c.add("g1", " ") + c.add("g1", "real") + assert c.get_recent("g1") == ["real"] + + def test_clear_group(self): + c = BotMessageCache() + c.add("g1", "a") + c.clear_group("g1") + assert c.get_recent("g1") == [] + + +# ========================================================================= +# AwakeningState +# ========================================================================= + + +class TestAwakeningState: + def test_extend_window(self): + s = AwakeningState() + s.mark_awakened("g1", "u1") + assert s.is_in_extend_window("g1", "u1", 30) is True + assert s.is_in_extend_window("g1", "u1", 0) is False + assert s.is_in_extend_window("g1", "u2", 30) is False + assert s.is_in_extend_window("g2", "u1", 30) is False + + def test_silence_seconds(self): + s = AwakeningState() + assert s.get_group_silence_seconds("g1") == float("inf") + s.record_message("g1", "u1") + assert s.get_group_silence_seconds("g1") < 1.0 + + def test_boredom_trigger_guard(self): + s = AwakeningState() + assert s.can_trigger_boredom("g1", 60) is True + s.mark_boredom_triggered("g1") + assert s.can_trigger_boredom("g1", 60) is False + + def test_llm_cache(self): + s = AwakeningState() + assert s.llm_cache_get("r1", "g1", "text") is None + s.llm_cache_set("r1", "g1", "text", True) + assert s.llm_cache_get("r1", "g1", "text") is True + s.llm_cache_set("r1", "g1", "text", False) + assert s.llm_cache_get("r1", "g1", "text") is False + + def test_llm_cache_different_keys(self): + s = AwakeningState() + s.llm_cache_set("r1", "g1", "a", True) + s.llm_cache_set("r2", "g1", "a", False) + assert s.llm_cache_get("r1", "g1", "a") is True + assert s.llm_cache_get("r2", "g1", "a") is False + + +# ========================================================================= +# Helpers +# ========================================================================= + + +class TestExtractWords: + def test_chinese_text(self): + words = _extract_words("今天天气怎么样") + assert len(words) > 0 + assert any("天气" in w for w in words) + + def test_empty_text(self): + assert _extract_words("") == set() + + def test_no_cjk(self): + assert _extract_words("hello world 123") == set() + + +class TestWordOverlapRatio: + def test_identical_texts(self): + r = _word_overlap_ratio("今天天气怎么样", ["今天天气怎么样"]) + assert r > 0.8 + + def test_related_texts(self): + r = _word_overlap_ratio("今天天气怎么样", ["今天天气很好啊"]) + assert r > 0.3 + + def test_unrelated_texts(self): + r = _word_overlap_ratio("完全无关的内容", ["今天天气很好"]) + assert r < 0.2 + + def test_empty_texts(self): + assert _word_overlap_ratio("", ["hello"]) == 0.0 + assert _word_overlap_ratio("hello", []) == 0.0 + + def test_max_across_multiple_bot_msgs(self): + r = _word_overlap_ratio( + "今天天气怎么样", + ["完全无关", "今天天气很好"], + ) + assert r > 0.3 + + +class TestDndWindow: + def test_empty_strings(self): + assert _is_in_dnd_window("", "") is False + + def test_same_day_range(self): + # Current CST time varies; just verify it returns a bool without crashing + result = _is_in_dnd_window("08:00", "20:00") + assert isinstance(result, bool) + + def test_overnight_range(self): + # current CST ~04:09, which IS in 23:00-08:00 range + assert _is_in_dnd_window("23:00", "08:00") is True + + def test_invalid_format(self): + assert _is_in_dnd_window("bad", "08:00") is False + + +class TestQAFastPattern: + def test_matches_question_marks(self): + assert _QA_FAST_PATTERNS.search("这是什么?") + assert _QA_FAST_PATTERNS.search("what?") + + def test_matches_question_keywords(self): + assert _QA_FAST_PATTERNS.search("请问怎么解决") + assert _QA_FAST_PATTERNS.search("为什么这样") + assert _QA_FAST_PATTERNS.search("能不能帮我看看") + + def test_no_match_on_plain_text(self): + assert not _QA_FAST_PATTERNS.search("今天天气真好") + assert not _QA_FAST_PATTERNS.search("哈哈哈笑死") + + +class TestExtractJsonTrigger: + def test_score_uses_threshold(self): + assert _extract_json_trigger('{"score": 0.7}', threshold=0.5) is True + assert _extract_json_trigger('{"score": 0.4}', threshold=0.5) is False + + def test_trigger_boolean_fallback(self): + assert _extract_json_trigger('```json\n{"trigger": true}\n```', threshold=0.9) is True + + def test_trigger_string_false_is_false(self): + assert _extract_json_trigger('{"trigger": "false"}') is False + + +# ========================================================================= +# Trigger checks (sync) +# ========================================================================= + + +def _make_settings(**kwargs) -> ResolvedAwakeningSettings: + defaults = dict( + extend_duration=0, + fallback_probability=0.0, + boredom_silence_seconds=0, + boredom_probability=0.0, + boredom_check_interval=300, + boredom_dnd_start="", + boredom_dnd_end="", + interest_topics=[], + relevance_threshold=1.0, + qa_threshold=1.0, + ) + defaults.update(kwargs) + return ResolvedAwakeningSettings(**defaults) + + +class TestCheckExtend: + def test_disabled(self): + s = AwakeningState() + s.mark_awakened("g1", "u1") + settings = _make_settings(extend_duration=0) + assert check_extend("g1", "u1", "hello", settings, s) is None + + def test_in_window(self): + s = AwakeningState() + s.mark_awakened("g1", "u1") + settings = _make_settings(extend_duration=30) + result = check_extend("g1", "u1", "hello", settings, s) + assert result is not None + assert result.rule_name == _RULE_EXTEND + + def test_not_in_window(self): + s = AwakeningState() + settings = _make_settings(extend_duration=30) + assert check_extend("g1", "u1", "hello", settings, s) is None + + def test_empty_text(self): + s = AwakeningState() + s.mark_awakened("g1", "u1") + settings = _make_settings(extend_duration=30) + assert check_extend("g1", "u1", "", settings, s) is None + + +class TestCheckInterest: + def test_disabled_empty_topics(self): + settings = _make_settings(interest_topics=[]) + svc = MagicMock() + assert check_interest("g1", "u1", "hello", settings, "", svc) is None + + def test_match(self): + settings = _make_settings(interest_topics=["Python", "编程"]) + svc = MagicMock() + svc.config.personas = {} + result = check_interest("g1", "u1", "我在学Python", settings, "", svc) + assert result is not None + assert result.rule_name == _RULE_INTEREST + + def test_no_match(self): + settings = _make_settings(interest_topics=["Python"]) + svc = MagicMock() + svc.config.personas = {} + assert check_interest("g1", "u1", "今天天气好", settings, "", svc) is None + + def test_persona_topics_merged(self): + settings = _make_settings(interest_topics=["global_topic"]) + persona = MagicMock() + persona.extras = {"awakening": {"interest_topics": ["persona_topic"]}} + svc = MagicMock() + svc.config.personas = {"p1": persona} + result = check_interest("g1", "u1", "persona_topic在这里", settings, "p1", svc) + assert result is not None + + +class TestCheckFallback: + def test_disabled(self): + settings = _make_settings(fallback_probability=0.0) + assert check_fallback("g1", "u1", "hello", settings) is None + + def test_empty_text(self): + settings = _make_settings(fallback_probability=1.0) + assert check_fallback("g1", "u1", "", settings) is None + + +class TestCheckBoredom: + def test_disabled(self): + settings = _make_settings(boredom_silence_seconds=0) + assert check_boredom("g1", settings) is None + + def test_insufficient_silence(self): + s = AwakeningState() + s.record_message("g1", "u1") + settings = _make_settings(boredom_silence_seconds=60, boredom_probability=1.0) + assert check_boredom("g1", settings, s) is None + + +# ========================================================================= +# Trigger checks (async) +# ========================================================================= + + +class TestCheckRelevance: + def test_disabled_threshold(self): + s = AwakeningState() + s.bot_messages.add("g1", "hello") + settings = _make_settings(relevance_threshold=1.0) + result = asyncio.run(check_relevance("g1", "u1", "hello", settings, None, s)) + assert result is None + + def test_zero_threshold_disabled(self): + s = AwakeningState() + s.bot_messages.add("g1", "今天天气非常不错") + settings = _make_settings(relevance_threshold=0.0) + svc = MagicMock() + svc.quick_judge = AsyncMock(return_value='{"score": 1.0}') + result = asyncio.run(check_relevance("g1", "u1", "今天天气怎么样", settings, svc, s)) + assert result is None + svc.quick_judge.assert_not_called() + + def test_no_bot_messages(self): + s = AwakeningState() + settings = _make_settings(relevance_threshold=0.5) + result = asyncio.run(check_relevance("g1", "u1", "hello", settings, None, s)) + assert result is None + + def test_low_overlap_skips_llm(self): + s = AwakeningState() + s.bot_messages.add("g1", "今天天气很好") + settings = _make_settings(relevance_threshold=0.5) + svc = MagicMock() + result = asyncio.run( + check_relevance("g1", "u1", "完全无关XYZ", settings, svc, s) + ) + assert result is None + + def test_high_overlap_triggers_llm(self): + s = AwakeningState() + s.bot_messages.add("g1", "今天天气非常不错") + settings = _make_settings(relevance_threshold=0.3) + svc = MagicMock() + svc.quick_judge = AsyncMock(return_value='{"trigger": true}') + result = asyncio.run( + check_relevance("g1", "u1", "今天天气怎么样", settings, svc, s) + ) + assert result is not None + assert result.rule_name == _RULE_RELEVANCE + + def test_llm_returns_false(self): + s = AwakeningState() + s.bot_messages.add("g1", "今天天气非常不错") + settings = _make_settings(relevance_threshold=0.3) + svc = MagicMock() + svc.quick_judge = AsyncMock(return_value='{"trigger": false}') + result = asyncio.run( + check_relevance("g1", "u1", "今天天气怎么样", settings, svc, s) + ) + assert result is None + + def test_llm_score_below_threshold(self): + s = AwakeningState() + s.bot_messages.add("g1", "今天天气非常不错") + settings = _make_settings(relevance_threshold=0.8) + svc = MagicMock() + svc.quick_judge = AsyncMock(return_value='{"score": 0.6}') + result = asyncio.run( + check_relevance("g1", "u1", "今天天气怎么样", settings, svc, s) + ) + assert result is None + + def test_cache_hit(self): + s = AwakeningState() + s.bot_messages.add("g1", "今天天气非常不错") + settings = _make_settings(relevance_threshold=0.3) + s.llm_cache_set(_RULE_RELEVANCE, "g1", _llm_cache_text("今天天气怎么样", 0.3), True) + svc = MagicMock() + result = asyncio.run( + check_relevance("g1", "u1", "今天天气怎么样", settings, svc, s) + ) + assert result is not None + svc.quick_judge.assert_not_called() + + def test_cache_key_includes_threshold(self): + s = AwakeningState() + s.bot_messages.add("g1", "今天天气非常不错") + s.llm_cache_set(_RULE_RELEVANCE, "g1", _llm_cache_text("今天天气怎么样", 0.3), True) + settings = _make_settings(relevance_threshold=0.8) + svc = MagicMock() + svc.quick_judge = AsyncMock(return_value='{"score": 0.6}') + result = asyncio.run( + check_relevance("g1", "u1", "今天天气怎么样", settings, svc, s) + ) + assert result is None + svc.quick_judge.assert_awaited_once() + + +class TestCheckQA: + def test_disabled_threshold(self): + s = AwakeningState() + settings = _make_settings(qa_threshold=1.0) + result = asyncio.run(check_qa("g1", "u1", "请问这是什么?", settings, None, s)) + assert result is None + + def test_zero_threshold_disabled(self): + s = AwakeningState() + settings = _make_settings(qa_threshold=0.0) + svc = MagicMock() + svc.quick_judge = AsyncMock(return_value='{"score": 1.0}') + result = asyncio.run(check_qa("g1", "u1", "请问这是什么?", settings, svc, s)) + assert result is None + svc.quick_judge.assert_not_called() + + def test_no_question_marker(self): + s = AwakeningState() + settings = _make_settings(qa_threshold=0.5) + result = asyncio.run(check_qa("g1", "u1", "今天天气真好", settings, None, s)) + assert result is None + + def test_question_triggers_llm(self): + s = AwakeningState() + settings = _make_settings(qa_threshold=0.5) + svc = MagicMock() + svc.quick_judge = AsyncMock(return_value='{"trigger": true}') + result = asyncio.run( + check_qa("g1", "u1", "请问怎么解决这个问题?", settings, svc, s) + ) + assert result is not None + assert result.rule_name == _RULE_QA + + def test_llm_returns_false(self): + s = AwakeningState() + settings = _make_settings(qa_threshold=0.5) + svc = MagicMock() + svc.quick_judge = AsyncMock(return_value='{"trigger": false}') + result = asyncio.run( + check_qa("g1", "u1", "怎么了?", settings, svc, s) + ) + assert result is None + + def test_llm_score_below_threshold(self): + s = AwakeningState() + settings = _make_settings(qa_threshold=0.8) + svc = MagicMock() + svc.quick_judge = AsyncMock(return_value='{"score": 0.6}') + result = asyncio.run( + check_qa("g1", "u1", "请问怎么解决这个问题?", settings, svc, s) + ) + assert result is None + + def test_cache_hit(self): + s = AwakeningState() + settings = _make_settings(qa_threshold=0.5) + s.llm_cache_set(_RULE_QA, "g1", _llm_cache_text("cached q?", 0.5), True) + svc = MagicMock() + result = asyncio.run( + check_qa("g1", "u1", "cached q?", settings, svc, s) + ) + assert result is not None + svc.quick_judge.assert_not_called() + + +# ========================================================================= +# Orchestrator +# ========================================================================= + + +class TestCheckAwakeningTriggers: + def test_llm_disabled_returns_none(self): + s = AwakeningState() + llm_settings = MagicMock() + llm_settings.enabled = False + llm_settings.persona_id = "" + svc = MagicMock() + svc.config = MagicMock() + svc.config.quick_judge = MagicMock(timeout=2.0, max_tokens=64) + svc.config.personas = {} + + import quickquip.chat.awakening as aw + old_cfg = aw._config + aw._config = AwakeningConfig( + defaults=AwakeningDefaults(interest_topics=["test"]), + ) + try: + result = asyncio.run( + check_awakening_triggers("g1", "u1", "test message", llm_settings, svc, state=s) + ) + assert result is None + finally: + aw._config = old_cfg + + def test_disabled_rule_skips_quick_judge(self): + s = AwakeningState() + s.bot_messages.add("g1", "今天天气非常不错") + llm_settings = MagicMock() + llm_settings.persona_id = "" + svc = MagicMock() + svc.config = MagicMock() + svc.config.quick_judge = MagicMock(timeout=2.0, max_tokens=64) + svc.config.personas = {} + svc.quick_judge = AsyncMock(return_value='{"score": 1.0}') + + import quickquip.chat.awakening as aw + old_cfg = aw._config + aw._config = AwakeningConfig( + defaults=AwakeningDefaults(relevance_threshold=0.3), + ) + try: + result = asyncio.run( + check_awakening_triggers( + "g1", + "u1", + "今天天气怎么样", + llm_settings, + svc, + state=s, + rule_enabled=lambda rule_name: rule_name != _RULE_RELEVANCE, + ) + ) + assert result is None + svc.quick_judge.assert_not_called() + finally: + aw._config = old_cfg + + def test_rate_unavailable_skips_quick_judge(self): + s = AwakeningState() + s.bot_messages.add("g1", "今天天气非常不错") + llm_settings = MagicMock() + llm_settings.persona_id = "" + svc = MagicMock() + svc.config = MagicMock() + svc.config.quick_judge = MagicMock(timeout=2.0, max_tokens=64) + svc.config.personas = {} + svc.quick_judge = AsyncMock(return_value='{"score": 1.0}') + + import quickquip.chat.awakening as aw + old_cfg = aw._config + aw._config = AwakeningConfig( + defaults=AwakeningDefaults(relevance_threshold=0.3), + ) + try: + result = asyncio.run( + check_awakening_triggers( + "g1", + "u1", + "今天天气怎么样", + llm_settings, + svc, + state=s, + rate_available=lambda rule_name: rule_name != _RULE_RELEVANCE, + ) + ) + assert result is None + svc.quick_judge.assert_not_called() + finally: + aw._config = old_cfg + + def test_extend_takes_priority(self): + s = AwakeningState() + s.mark_awakened("g1", "u1") + llm_settings = MagicMock() + llm_settings.persona_id = "" + svc = MagicMock() + svc.config = MagicMock() + svc.config.quick_judge = MagicMock(timeout=2.0, max_tokens=64) + svc.config.personas = {} + + # Create a config with extend enabled and interest topics + import quickquip.chat.awakening as aw + old_cfg = aw._config + aw._config = AwakeningConfig( + defaults=AwakeningDefaults( + extend_duration=30, + interest_topics=["test"], + ), + ) + try: + result = asyncio.run( + check_awakening_triggers("g1", "u1", "test message", llm_settings, svc, state=s) + ) + assert result is not None + assert result.rule_name == _RULE_EXTEND + finally: + aw._config = old_cfg + + def test_all_disabled_returns_none(self): + s = AwakeningState() + s.record_message("g1", "u1") + llm_settings = MagicMock() + llm_settings.persona_id = "" + svc = MagicMock() + svc.config = MagicMock() + svc.config.quick_judge = MagicMock(timeout=2.0, max_tokens=64) + svc.config.personas = {} + + import quickquip.chat.awakening as aw + old_cfg = aw._config + aw._config = AwakeningConfig() # all defaults = disabled + try: + result = asyncio.run( + check_awakening_triggers("g1", "u1", "hello", llm_settings, svc, state=s) + ) + assert result is None + finally: + aw._config = old_cfg + + +class TestRunBoredomCheck: + def test_skips_when_group_llm_disabled(self): + bot = MagicMock() + bot.send_group_msg = AsyncMock() + groups = MagicMock() + groups.all_groups.return_value = ["123"] + rule_switch = MagicMock() + rule_switch.is_enabled.return_value = True + svc = MagicMock() + svc.config.load_error = None + svc.get_group_settings.return_value = MagicMock(enabled=False) + svc.generate_reply = AsyncMock(return_value={"reply": "本群 LLM 已关闭。"}) + + import quickquip.chat.awakening as aw + old_cfg = aw._config + old_state = aw._state + aw._config = AwakeningConfig( + defaults=AwakeningDefaults( + boredom_silence_seconds=1, + boredom_probability=1.0, + boredom_check_interval=1, + ), + ) + aw._state = AwakeningState() + try: + asyncio.run(run_boredom_check(bot, groups, rule_switch, svc)) + finally: + aw._config = old_cfg + aw._state = old_state + + svc.generate_reply.assert_not_called() + bot.send_group_msg.assert_not_called() + + def test_sends_when_group_llm_enabled(self): + bot = MagicMock() + bot.send_group_msg = AsyncMock() + groups = MagicMock() + groups.all_groups.return_value = ["123"] + rule_switch = MagicMock() + rule_switch.is_enabled.return_value = True + svc = MagicMock() + svc.config.load_error = None + svc.get_group_settings.return_value = MagicMock(enabled=True) + svc.recent_message_buffer.list_recent.return_value = [] + svc.generate_reply = AsyncMock(return_value={"reply": "冒个泡"}) + stats_tracker = MagicMock() + + import quickquip.chat.awakening as aw + old_cfg = aw._config + old_state = aw._state + aw._config = AwakeningConfig( + defaults=AwakeningDefaults( + boredom_silence_seconds=1, + boredom_probability=1.0, + boredom_check_interval=1, + ), + ) + aw._state = AwakeningState() + try: + asyncio.run(run_boredom_check(bot, groups, rule_switch, svc, stats_tracker=stats_tracker)) + finally: + aw._config = old_cfg + aw._state = old_state + + svc.generate_reply.assert_awaited_once() + bot.send_group_msg.assert_awaited_once_with(group_id=123, message="冒个泡") + stats_tracker.record_trigger.assert_called_once_with("123", "awakening_boredom") diff --git a/tests/unit/common/test_rate_limit.py b/tests/unit/common/test_rate_limit.py index 7804446..d039f96 100644 --- a/tests/unit/common/test_rate_limit.py +++ b/tests/unit/common/test_rate_limit.py @@ -13,6 +13,14 @@ def test_sliding_window_basic(): assert limiter.allow("u1", now_ts=61) is True +def test_sliding_window_can_allow_does_not_consume(): + limiter = SlidingWindowRateLimiter(global_limit=1, user_limit=1, window_seconds=60) + assert limiter.can_allow("u1", now_ts=0) is True + assert limiter.can_allow("u1", now_ts=1) is True + assert limiter.allow("u1", now_ts=2) is True + assert limiter.can_allow("u1", now_ts=3) is False + + def test_keyed_rate_limiter_keys_are_independent(): limiter = KeyedRateLimiter( rule_limits={ @@ -32,6 +40,17 @@ def test_keyed_rate_limiter_keys_are_independent(): assert limiter.allow("play_target", "u1", now_ts=7) is True +def test_keyed_rate_limiter_can_allow_does_not_consume(): + limiter = KeyedRateLimiter( + rule_limits={"awakening_qa": {"global_limit": 1, "user_limit": 1, "scope": "global"}}, + window_seconds=60, + ) + assert limiter.can_allow("awakening_qa", "u1", now_ts=0) is True + assert limiter.can_allow("awakening_qa", "u1", now_ts=1) is True + assert limiter.allow("awakening_qa", "u1", now_ts=2) is True + assert limiter.can_allow("awakening_qa", "u1", now_ts=3) is False + + def test_keyed_rate_limiter_global_per_rule(): limiter = KeyedRateLimiter( rule_limits={ diff --git a/tests/unit/games/test_niuniu.py b/tests/unit/games/test_niuniu.py index 5e36bff..cd83c0a 100644 --- a/tests/unit/games/test_niuniu.py +++ b/tests/unit/games/test_niuniu.py @@ -144,9 +144,9 @@ def test_multiple_users_independent(self): class TestRollLognormal: - def test_never_negative(self): + def test_never_zero_or_negative(self): for _ in range(500): - assert _roll_lognormal(1.0) >= 0 + assert _roll_lognormal(1.0) > 0 def test_median_near_one(self): samples = sorted(_roll_lognormal(1.0) for _ in range(2000))