Compare commits

..
4 Commits
Author SHA1 Message Date
miaomiao d9fd32d0c7 fix: P1/P2 批 bug 清理(BUG-6~25)+ LICENSE
P1/P2 批修复(主分支未提交的改动 + BUG-13 修正):
- BUG-6 β 钳制下界 / BUG-7 登录失败滑动窗口 / BUG-8 日志 level / BUG-9 group 参数
- BUG-10 pending 滞留校验 / BUG-11 昼夜按 tick 选表 / BUG-12 terminate 先取消任务
- BUG-13 _prune_dict keep 键(修正弹错键)/ BUG-14 pull/peek 一致 / BUG-15 关键词单次判定
- BUG-16 sweep 降频 / BUG-17 target 索引 / BUG-18 死代码清理 / BUG-19 午休窗口
- BUG-20 旧字段检查 / BUG-21 空关键词清空 / BUG-22 睡眠限频统一 / BUG-23 密码清空
- BUG-24 web_port 防御 / BUG-25 SSE 双心跳
- LICENSE AGPL-3.0 入库
2026-08-23 21:41:20 +08:00
miaomiao f3bd1f3fa1 fix: P0 批 5 个 High/Med bug + silence 工具 + 冒烟补测
- BUG-1 (High) reranker 索引错位: 过滤时保留原始索引查 _rel, 重排真正生效
- BUG-2 (High) attach_memory_log 返回新空 handler: 已挂同类型时返回已挂实例
- BUG-3 (High) 跨 umo pending 帧孤儿: 锁内 finally 兜底 FIFO 消费任意 umo 帧
- BUG-4 (Med) 降级模式召回跨用户泄漏: target 无记忆返回 [] 不回落全量
- BUG-5 (High) 滞回带低强度情绪卡死: 宁静(基线)低强度退出通道
- silence 工具: @filter.llm_tool 终止本轮回复(带 reason 日志)
- 冒烟补测 5 个漏网用例 + silence 断言: 440 -> 464 全过
2026-08-21 20:12:56 +08:00
miaomiao f2678024eb release: v0.2.0(三模式/睡眠HH:MM/极客高级项/苏醒重置/禁止动作描写) 2026-08-12 20:38:01 +08:00
miaomiao bac4f7c173 feat: 禁止动作/神态描写强化 + 苏醒时间段 22 维情绪重置
1) 禁止动作/神态描写(用户确认 revert 后仍泛滥,恢复 498eba9 并强化):
   - prompts/director_prompt.py 第一层输出规则:风格提示词只许写说话方式
     (语气/话量/语速/用词/停顿/口头禅),严禁动作/神态/肢体/场景描写,
     点名禁用 指尖/歪头/瞥/戳/懒洋洋/把屏幕划到一边/伸手/托腮/眯眼/撩/
     伸懒腰/叹气+动作;每句必须能直接转化为'这句话怎么说'。
   - 全局规则 #3:只写说话方式,动作描写会让主 AI 胡编戏(含具体例子)。
   - 新增输出规范:风格提示词是唯一允许出现在这里的描述性指令,且只描述
     语言本身。

2) 苏醒重置(core/environment.py + main.py):
   - maybe_wake_reset(engine, now_ts):已过苏醒时间点(_in_sleep_window 取反,
     分钟粒度支持跨午夜 23:00→05:00)且本天(YYYY-M-D)未重置过 → 22 维处理:
     负面(当前 v<0)强度降到 min(现有, max(0.05, decay_floor));正面(v>=0)
     强度 ×0.5;更新 _last_wake_reset_key + 日志。每天一次,惰性检查。
   - main.py on_llm_request 与 maybe_decay 并列调用(try/except 不炸请求)。
   - 无新配置(复用 sleep_start/sleep_end/decay_floor)。

冒烟: 428 → 440 全过(禁止描写 6 项静态断言 + 苏醒重置 7 项:负面降底/正面减半/
已低保持/每天一次/深夜不执行/跨天再执行)。
2026-08-11 14:51:36 +08:00
16 changed files with 579 additions and 66 deletions
+29
View File
@@ -0,0 +1,29 @@
MIT License(含品牌保留条款)
Copyright (c) 2026 XianyunahRainnya 家族)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## 品牌保留条款(Brand Reservation Clause
1. **品牌限制**:未经原作者(Xianyunah / Rainnya 家族)书面授权,被授权人不得使用「Rainnya」「雨喵」「RainMood」名称、logo、视觉标识(含紫色雨夜视觉主题)对本软件的任何衍生版本进行品牌宣传、背书或暗示官方身份。
2. **署名**:任何二次分发(原始或修改版本)必须保留本 LICENSE 文件及原始版权声明,并显著标明最初作者为「Xianyunah(Rainnya 家族)」。
3. 本条款与 MIT 许可的宽松授权并行生效;品牌使用争议以本条款为准。
+26 -12
View File
@@ -122,16 +122,7 @@ class BridgeModule:
lines = self._collect_lines(uid, target_group, now) lines = self._collect_lines(uid, target_group, now)
if not lines: if not lines:
return None return None
# FIX(P1-2): 关系情绪注入——TTL 内对该 uid 带临时态度 → 补一行提示(刚才的事还没放下) lines = self._assemble_lines(uid, lines)
# FIX(M4): 关系行**前插**到开头——_cap_lines 截尾部时先牺牲末尾跨群行,
# 关系提示(当前情绪线索)优先保留
rel = getattr(self.star, "relation", None)
if rel is not None:
r = rel.get(uid, now)
if r:
lines.insert(0, f"(你对{uid}还带着 {r['primary']}——刚才的事还没放下)")
# FIX(P1-4): 保险截断——合并文本总长(含换行)≤ 800 字符,超了截尾部
lines = self._cap_lines(lines, cap=800)
# 命中 → 进入 30 分钟全局冷却 # 命中 → 进入 30 分钟全局冷却
self._cooldown_until[uid] = now + self._cooldown self._cooldown_until[uid] = now + self._cooldown
self._log_injection( self._log_injection(
@@ -140,6 +131,23 @@ class BridgeModule:
) )
return lines return lines
def _assemble_lines(self, uid: str, lines: list[str]) -> list[str]:
"""FIX(BUG-14): 公共组装——关系情绪行前插 + 800 字符保险截断。
pull(主 AI 注入)与 peekDirector 分析)共用,保证同一轮主 AI 与情绪支配
AI 看到的跨群上下文一致(此前 peek 不带关系行、不截断,内容不一致)。
"""
# FIX(P1-2): 关系情绪注入——TTL 内对该 uid 带临时态度 → 补一行提示(刚才的事还没放下)
# FIX(M4): 关系行**前插**到开头——_cap_lines 截尾部时先牺牲末尾跨群行,
# 关系提示(当前情绪线索)优先保留
rel = getattr(self.star, "relation", None)
if rel is not None:
r = rel.get(uid, time.time())
if r:
lines.insert(0, f"(你对{uid}还带着 {r['primary']}——刚才的事还没放下)")
# FIX(P1-4): 保险截断——合并文本总长(含换行)≤ 800 字符,超了截尾部
return self._cap_lines(lines, cap=800)
@staticmethod @staticmethod
def _cap_lines(lines: list[str], cap: int = 800) -> list[str]: def _cap_lines(lines: list[str], cap: int = 800) -> list[str]:
"""FIX(P1-4): 保险截断——合并文本总长(含换行)≤ cap,超了截尾部(不改架构)。""" """FIX(P1-4): 保险截断——合并文本总长(含换行)≤ cap,超了截尾部(不改架构)。"""
@@ -157,13 +165,19 @@ class BridgeModule:
return out return out
def peek(self, uid: str, target_group: str) -> list[str]: def peek(self, uid: str, target_group: str) -> list[str]:
"""无副作用地看是否有可注入的跨群内容(分析用,不消耗冷却)。""" """无副作用地看是否有可注入的跨群内容(分析用,不消耗冷却)。
FIX(BUG-14): 复用 pull 的组装逻辑(关系行 + 截断),保证与主 AI 注入一致。
"""
if not uid: if not uid:
return [] return []
now = time.time() now = time.time()
if self._cooldown_until.get(uid, 0) > now: if self._cooldown_until.get(uid, 0) > now:
return [] return []
return self._collect_lines(uid, target_group, now) lines = self._collect_lines(uid, target_group, now)
if not lines:
return []
return self._assemble_lines(uid, lines)
def _collect_lines(self, uid: str, target_group: str, now: float) -> list[str]: def _collect_lines(self, uid: str, target_group: str, now: float) -> list[str]:
"""收集「最近活跃的单个其他群」窗口内的行,最多 MAX_LINES 行。 """收集「最近活跃的单个其他群」窗口内的行,最多 MAX_LINES 行。
+3 -5
View File
@@ -454,11 +454,9 @@ class RainMoodConfig:
# FIX(bot_name): 非空字符串 + 长度(校验在 validate() # FIX(bot_name): 非空字符串 + 长度(校验在 validate()
if "bot_name" in patch and not isinstance(patch["bot_name"], str): if "bot_name" in patch and not isinstance(patch["bot_name"], str):
raise ValueError("bot_name 必须是字符串") raise ValueError("bot_name 必须是字符串")
# FIX(N2): 环境动力学字段类型检查 # FIX(BUG-20): 删除已废弃字段(decay_tick_minutes/sleep_start_hour/sleep_end_hour
for fname in ("decay_tick_minutes", "sleep_start_hour", "sleep_end_hour"): # 的残留类型检查——这些字段已不在 allowed 集合,提交它们会报
if fname in patch: # "未知配置项"而非类型错误,此检查是死代码。
if isinstance(patch[fname], bool) or not isinstance(patch[fname], int):
raise ValueError(f"{fname} 必须是整数")
if "weather_enabled" in patch and not isinstance(patch["weather_enabled"], bool): if "weather_enabled" in patch and not isinstance(patch["weather_enabled"], bool):
raise ValueError("weather_enabled 必须是布尔值") raise ValueError("weather_enabled 必须是布尔值")
for fname in ("weather_city", "weather_api_key", "weather_provider"): for fname in ("weather_city", "weather_api_key", "weather_provider"):
+21
View File
@@ -167,6 +167,27 @@ class DirectorModule:
self.logger.error(f"情绪支配AI 分析失败: {e}", exc_info=True) self.logger.error(f"情绪支配AI 分析失败: {e}", exc_info=True)
finally: finally:
self._analyzing = False self._analyzing = False
# FIX(BUG-3): 锁释放前兜底消费——锁持有者只消费了自己 umo 的帧,
# 其他 umo 在持锁期间到达的帧会留在字典里无人消费(孤儿)。
# 这里仍在锁内(finally 在 async with 内),按 FIFO 全局
# 串行消费任意 umo 的剩余帧,防并发群分析被静默丢弃。
await self._drain_pending_frames()
async def _drain_pending_frames(self) -> None:
"""FIX(BUG-3): 消费所有剩余 pending 帧(任意 umoFIFO)。
在 analyze 的 finally 内、仍持有锁时调用,保持全局串行(与 _run_analysis
的既有调用方式一致)。_pending_frames.pop 原子性保证每帧只消费一次。
"""
while self._pending_frames:
umo, frame = next(iter(self._pending_frames.items()))
self._pending_frames.pop(umo, None)
try:
await self._run_analysis(**frame)
except asyncio.CancelledError:
raise
except Exception as e: # noqa: BLE001
self.logger.error(f"RainMood 兜底消费 pending 帧失败: {e}", exc_info=True)
async def force_analysis(self, event: "AstrMessageEvent", reason: str) -> None: async def force_analysis(self, event: "AstrMessageEvent", reason: str) -> None:
"""FIX(N3-1): 强制分析(惰性,非定时任务)——睡眠被打扰等场景触发。 """FIX(N3-1): 强制分析(惰性,非定时任务)——睡眠被打扰等场景触发。
+13 -2
View File
@@ -216,7 +216,7 @@ class EmotionEngine:
} }
cur_intensity = float(self.patch.get("intensity", 0.0)) cur_intensity = float(self.patch.get("intensity", 0.0))
accepted = self.hysteresis_gate(cur_intensity, intensity) accepted = self.hysteresis_gate(cur_intensity, intensity, primary=primary)
if accepted is None: if accepted is None:
# 带内波动 → 防抖动,维持当前主情绪(不覆盖、不记溯源) # 带内波动 → 防抖动,维持当前主情绪(不覆盖、不记溯源)
return False return False
@@ -272,7 +272,7 @@ class EmotionEngine:
self.push_pad_history(pad) self.push_pad_history(pad)
return True return True
def hysteresis_gate(self, cur: float, new: float) -> float | None: def hysteresis_gate(self, cur: float, new: float, primary: str = "") -> float | None:
"""滞回带门控(防抖动)。 """滞回带门控(防抖动)。
带 = [hysteresis_down, hysteresis_up](默认 [0.4, 0.6]): 带 = [hysteresis_down, hysteresis_up](默认 [0.4, 0.6]):
@@ -280,17 +280,28 @@ class EmotionEngine:
- 已激活情绪(cur ≥ 下阈值)跌穿下阈值 → 采纳(允许回落/降级) - 已激活情绪(cur ≥ 下阈值)跌穿下阈值 → 采纳(允许回落/降级)
- 从无到有 / 首次进入激活区 → 采纳 - 从无到有 / 首次进入激活区 → 采纳
- 带内小波动 → 返回 None(维持当前主情绪,防频繁跳变) - 带内小波动 → 返回 None(维持当前主情绪,防频繁跳变)
FIX(BUG-5): ``primary == "宁静"``(基线情绪)且 ``new < down`` 时**采纳**——
给低强度情绪一条退出通道。否则 baseline patch intensity=0.3 落在滞回带下界
(0.4)之下,降级到 0.3 后 cur<down 且 new<down → 带内波动 → None,情绪卡死
在低强度负面状态,只能靠 ~44h 衰减退出。宁静是向基线回落,理应直接采纳。
""" """
up = float(self.star.config.hysteresis_up) up = float(self.star.config.hysteresis_up)
down = float(self.star.config.hysteresis_down) down = float(self.star.config.hysteresis_down)
if new >= up: if new >= up:
return new return new
# FIX(BUG-18): 「从无到有」显式入口——cur 为 0(全新引擎/刚重置)时首次激活采纳。
# 生产上 baseline patch intensity=0.3 使 cur 通常 >0,此分支多为
# 直接调用/重置场景;与 BUG-5 的宁静退出通道(向基线回落)职责不同。
if cur <= 0.0 and new > 0.0: if cur <= 0.0 and new > 0.0:
return new return new
if cur < down and new >= down: if cur < down and new >= down:
return new return new
if cur >= down and new < down: if cur >= down and new < down:
return new return new
# FIX(BUG-5): 宁静退出通道——AI 判定为基线情绪(宁静)且强度低于下界时采纳
if primary == "宁静" and new < down:
return new
return None return None
@staticmethod @staticmethod
+60 -5
View File
@@ -46,6 +46,8 @@ class EnvironmentModule:
# FIX(M1): 上次轻提示注入时间戳——轻提示(主 AI 提示)也走 3h 限频, # FIX(M1): 上次轻提示注入时间戳——轻提示(主 AI 提示)也走 3h 限频,
# 3h 内静默(count 仍累积喂下次 force # 3h 内静默(count 仍累积喂下次 force
self._last_light_inject_ts: float = 0.0 self._last_light_inject_ts: float = 0.0
# FIX(苏醒重置): 上次苏醒重置日期键("YYYY-M-D")——每天只执行一次(惰性)
self._last_wake_reset_key: str = ""
# ⑤ 天气缓存 # ⑤ 天气缓存
self._weather_cache: dict | None = None self._weather_cache: dict | None = None
self._weather_cache_ts: float = 0.0 self._weather_cache_ts: float = 0.0
@@ -69,6 +71,48 @@ class EnvironmentModule:
if eng is not None: if eng is not None:
self.decay(eng) self.decay(eng)
# ---------- 苏醒重置(FIX: 22 维情绪每天苏醒清一次) ----------
def maybe_wake_reset(self, engine: Any, now_ts: float | None = None) -> None:
"""已过苏醒时间点 → 22 维情绪重置(**每天只执行一次**,惰性检查)。
需求(用户):苏醒后——负面情绪(当前 v < 0)强度直接降低到最低值
``max(0.05, decay_floor)`` 附近,不高于现有),正面情绪(v >= 0)强度减半。
判定"已苏醒":当前分钟不在睡眠时段(`_in_sleep_window` 取反,支持跨午夜
23:00→05:00);本天(YYYY-M-D)已重置过 → 跳过。
"""
now = now_ts if now_ts is not None else time.time()
lt = time.localtime(now)
start_min, end_min = self._sleep_window()
now_min = lt.tm_hour * 60 + lt.tm_min
# 睡眠时段内 → 不重置
if self._in_sleep_window(now_min, start_min, end_min):
return
# 当天已重置 → 跳过(每天一次)
day_key = f"{lt.tm_year}-{lt.tm_mon}-{lt.tm_mday}"
if day_key == self._last_wake_reset_key:
return
eng = engine if engine is not None else self._engine()
if eng is None:
return
floor = max(0.0, min(0.1, float(getattr(self.star.config, "decay_floor", 0.03) or 0.03)))
neg_low = max(0.05, floor) # 负面降到的最低值(不低于 0.05)
for _k, d in eng.dims.items():
try:
v = float(d.get("v", 0.0))
cur = float(d.get("intensity") or 0.0)
except (TypeError, ValueError): # noqa: BLE001 —— 坏值跳过
continue
if v < 0:
# 直接降低到最低值(不高于现有;已有更低则保持)
d["intensity"] = round(min(cur, neg_low), 4)
else:
d["intensity"] = round(cur * 0.5, 4)
self._last_wake_reset_key = day_key
self.logger.info(
"RainMood 苏醒重置:22 维情绪已处理(负面→≤%.2f,正面×0.5", neg_low,
)
def decay(self, engine: Any, now_ts: float | None = None) -> None: def decay(self, engine: Any, now_ts: float | None = None) -> None:
"""① 时间驱动衰减(合并 ②③ 昼夜选表)。 """① 时间驱动衰减(合并 ②③ 昼夜选表)。
@@ -86,13 +130,21 @@ class EnvironmentModule:
ticks = int(minutes // tick) ticks = int(minutes // tick)
if ticks <= 0: if ticks <= 0:
return return
hour = int(time.localtime(now).tm_hour) # FIX(BUG-11): 昼夜选表按**每个 tick 的小时**分别取率——原代码用 now 的当前小时
# 覆盖全部 ticks,跨昼夜边界(如 22:50→23:30)时整段按夜间率算失真。
# 这里预计算每个 tick 的小时序列,每维 eff = ∏ rate(hour_i)。
tick_hours = [
int(time.localtime(last + i * tick * 60).tm_hour)
for i in range(ticks)
]
# 22 维:每维独立率(昼夜选表)^ 节拍数 # 22 维:每维独立率(昼夜选表)^ 节拍数
# FIX(N2 曲线): 向低残值趋近而非归零——v' = floor + (v - floor) × eff # FIX(N2 曲线): 向低残值趋近而非归零——v' = floor + (v - floor) × eff
# 形状 100→70→65→63→62.1→62:先快后慢,最终停在 floor(非常低但不是没有) # 形状 100→70→65→63→62.1→62:先快后慢,最终停在 floor(非常低但不是没有)
floor = max(0.0, min(0.1, float(getattr(self.star.config, "decay_floor", 0.03) or 0))) floor = max(0.0, min(0.1, float(getattr(self.star.config, "decay_floor", 0.03) or 0)))
for k, d in engine.dims.items(): for k, d in engine.dims.items():
eff = self.decay_rate_for(k, hour) ** ticks eff = 1.0
for h in tick_hours:
eff *= self.decay_rate_for(k, h)
base = OCC_BASELINE_PAD[k] base = OCC_BASELINE_PAD[k]
d["v"] = round(base["v"] + (d["v"] - base["v"]) * eff, 4) d["v"] = round(base["v"] + (d["v"] - base["v"]) * eff, 4)
d["a"] = round(base["a"] + (d["a"] - base["a"]) * eff, 4) d["a"] = round(base["a"] + (d["a"] - base["a"]) * eff, 4)
@@ -201,7 +253,8 @@ class EnvironmentModule:
start_min, end_min = self._sleep_window() start_min, end_min = self._sleep_window()
if self._in_sleep_window(lt.tm_hour * 60 + lt.tm_min, start_min, end_min): if self._in_sleep_window(lt.tm_hour * 60 + lt.tm_min, start_min, end_min):
return "时段氛围:深夜(困乏)——语气带倦意,情绪 arousal 偏低" return "时段氛围:深夜(困乏)——语气带倦意,情绪 arousal 偏低"
if (lt.tm_hour == 12 and lt.tm_min >= 30) or lt.tm_hour == 13: # FIX(BUG-19): 午休窗口 12:30~14:00(含 14:00 整点;原代码 12:30-13:59 与文档不符)
if 12 * 60 + 30 <= lt.tm_hour * 60 + lt.tm_min <= 14 * 60:
return "时段氛围:午休——饭后犯困,情绪 arousal 微降" return "时段氛围:午休——饭后犯困,情绪 arousal 微降"
return "" return ""
@@ -242,9 +295,11 @@ class EnvironmentModule:
getattr(self.star.config, "forced_analysis_interval_hours", 3) or 3 getattr(self.star.config, "forced_analysis_interval_hours", 3) or 3
) * 3600.0 ) * 3600.0
since_last = ts - self._last_disturb_inject_ts since_last = ts - self._last_disturb_inject_ts
# FIX(BUG-22): 限频判定**全部交给 director**force_analysis 的 _last_forced_ts)。
# 这里只做只读判定(should_force 是否该触发),**不再更新**
# _last_disturb_inject_ts——否则 environment 时间戳先消耗、force 又被
# director 限频跳过,有效间隔可能翻倍到 6h。director 是唯一限频源。
should_force = since_last >= interval should_force = since_last >= interval
if should_force:
self._last_disturb_inject_ts = ts
# FIX(M1): 轻提示 3h 限频(独立于 should_force)——3h 内静默,count 仍累积 # FIX(M1): 轻提示 3h 限频(独立于 should_force)——3h 内静默,count 仍累积
light_inject = (ts - self._last_light_inject_ts) >= interval light_inject = (ts - self._last_light_inject_ts) >= interval
if light_inject: if light_inject:
+15 -4
View File
@@ -46,12 +46,23 @@ class MemoryLogHandler(logging.Handler):
def attach_memory_log() -> MemoryLogHandler: def attach_memory_log() -> MemoryLogHandler:
"""把内存日志处理器挂到 root logger(幂等:已有同类型不重复挂)。返回 handler。""" """把内存日志处理器挂到 root logger(幂等:已有同类型不重复挂)。返回 handler。
FIX(BUG-2): 已挂过同类型 handler 时**返回已挂的实例**,而不是新建一个空 handler——
否则每次调用(如 /api/logs 请求)都拿到空缓冲的新 handler,日志 Tab 恒空。
FIX(BUG-8): 显式把插件 logger 的 level 设为 DEBUG——否则子 logger
``astrbot_plugin_rainmood`` 继承 root 的 levelroot=WARNING 时 INFO 日志在
logger 层就被丢弃,进不了缓冲(日志 Tab 恒空)。
"""
logging.getLogger("astrbot_plugin_rainmood").setLevel(logging.DEBUG)
root = logging.getLogger()
for h in root.handlers:
if isinstance(h, MemoryLogHandler):
return h
handler = MemoryLogHandler() handler = MemoryLogHandler()
handler.setLevel(logging.DEBUG) handler.setLevel(logging.DEBUG)
handler.setFormatter(logging.Formatter("%(message)s")) handler.setFormatter(logging.Formatter("%(message)s"))
handler.addFilter(lambda rec: (rec.name or "").startswith("astrbot_plugin_rainmood")) handler.addFilter(lambda rec: (rec.name or "").startswith("astrbot_plugin_rainmood"))
root = logging.getLogger() root.addHandler(handler)
if not any(isinstance(h, MemoryLogHandler) for h in root.handlers):
root.addHandler(handler)
return handler return handler
+27 -4
View File
@@ -48,6 +48,8 @@ _EVENT_OVERLAP_MIN = 1
_WRITE_DEDUP_OVERLAP = 2 _WRITE_DEDUP_OVERLAP = 2
#: FIX(A1): embedding 失败态 TTL(秒)——解析失败后 300s 内不再重试,过期自动重试 #: FIX(A1): embedding 失败态 TTL(秒)——解析失败后 300s 内不再重试,过期自动重试
_EMBED_FAIL_TTL = 300 _EMBED_FAIL_TTL = 300
#: FIX(BUG-16): 召回 sweep 降频——每 N 次 recall 才全表扫描一次(防每轮 O(n))
_SWEEP_EVERY = 20
class MemoryModule: class MemoryModule:
@@ -78,6 +80,9 @@ class MemoryModule:
self._reranker_cfg_sig: str | None = None self._reranker_cfg_sig: str | None = None
self._reranker_fail_at: float = 0.0 self._reranker_fail_at: float = 0.0
self._embed_lock = threading.Lock() self._embed_lock = threading.Lock()
# FIX(BUG-16): 召回 sweep 降频——recall 末尾的 _sweep 每次全表扫描 O(n)
# 记忆量大时每轮分析都拖慢。改为每 _SWEEP_EVERY 次召回才扫一次。
self._recall_count = 0
@property @property
def vec_enabled(self) -> bool: def vec_enabled(self) -> bool:
@@ -311,6 +316,9 @@ class MemoryModule:
""" """
if not event or self.store._conn is None: if not event or self.store._conn is None:
return None return None
# FIX(BUG-9): group 参数兜底——调用方(如 boost 回落)可能只传 group 不传
# group_idgroup_id 为空时用 group 补上,避免 group 信息丢失。
group_id = group_id or group
# FIX(A4): 写入去重——同对象已有相近记忆(token 重叠 ≥ _WRITE_DEDUP_OVERLAP)→ # FIX(A4): 写入去重——同对象已有相近记忆(token 重叠 ≥ _WRITE_DEDUP_OVERLAP)→
# 跳过写入(防 AI 每轮重复 write 同一条,记忆库被刷屏) # 跳过写入(防 AI 每轮重复 write 同一条,记忆库被刷屏)
latest = self.store.find_latest_by_target(target) latest = self.store.find_latest_by_target(target)
@@ -505,6 +513,11 @@ class MemoryModule:
scoped = [c for c in candidates if c.get("target") == target or c.get("user_id") == target] scoped = [c for c in candidates if c.get("target") == target or c.get("user_id") == target]
if scoped: if scoped:
candidates = scoped candidates = scoped
else:
# FIX(BUG-4): 目标用户无记忆时**返回空**,绝不回落全量候选——
# 否则降级模式(qvec=None → recent_rows 全量)会把
# 其他用户的记忆泄漏进分析上下文(隐私 + 分析污染)。
return []
# FIX(B6): reranker 重排——relevance_score 替换 sim(不归一化,相对大小排序); # FIX(B6): reranker 重排——relevance_score 替换 sim(不归一化,相对大小排序);
# 无 reranker / 异常 / 超时 → 回落 sim×clarity(余弦相似度路径) # 无 reranker / 异常 / 超时 → 回落 sim×clarity(余弦相似度路径)
reranker = self._get_reranker() reranker = self._get_reranker()
@@ -520,8 +533,12 @@ class MemoryModule:
# 1/(1+rank)relevance 降序 → sim = 1, 0.5, 0.333... # 1/(1+rank)relevance 降序 → sim = 1, 0.5, 0.333...
# FIX(L3): 非 top-10 文档直接排除(不参与排名)——否则降级模式 # FIX(L3): 非 top-10 文档直接排除(不参与排名)——否则降级模式
# qvec=None 不启门控)时 score=0 的未命中行会漏进结果 # qvec=None 不启门控)时 score=0 的未命中行会漏进结果
candidates = [c for i, c in enumerate(candidates) if i in idx_score] # FIX(BUG-1): 过滤时保留**原始索引**——reranker 返回的 index 是
for i, c in enumerate(candidates): # 原始 docs 列表(过滤前 candidates)的下标;若用过滤后
# 的新枚举去查旧索引表,除首个幸存项外全取 0.0(重排失效)。
kept = [(i, c) for i, c in enumerate(candidates) if i in idx_score]
candidates = [c for _, c in kept]
for i, c in kept:
c["_rel"] = idx_score.get(i, 0.0) c["_rel"] = idx_score.get(i, 0.0)
ranked = sorted(candidates, key=lambda c: c["_rel"], reverse=True) ranked = sorted(candidates, key=lambda c: c["_rel"], reverse=True)
for rank, c in enumerate(ranked): for rank, c in enumerate(ranked):
@@ -553,7 +570,10 @@ class MemoryModule:
raw = c["score"] raw = c["score"]
c["score_raw"] = raw c["score_raw"] = raw
c["score"] = round(raw * (1.0 + beta * pad_sim), 4) c["score"] = round(raw * (1.0 + beta * pad_sim), 4)
c["score"] = max(0.0, min(c["score"], raw * 1.3)) # FIX(BUG-6): 补下界钳制——docstring 声明 final ∈ [raw×0.7, raw×1.3]
# 原代码只钳上界;mood_match_weight>0.3 且 PAD 相反(pad_sim<0
# 时 score 可跌破 raw×0.7weight=1.0、pad_sim=-1 时 score=0)。
c["score"] = max(raw * 0.7, min(c["score"], raw * 1.3))
candidates.sort(key=lambda c: c["score"], reverse=True) candidates.sort(key=lambda c: c["score"], reverse=True)
# FIX(P0-2): 召回门控——score 低于 recall_min_score 的记忆不进 Top3(不足则少注)。 # FIX(P0-2): 召回门控——score 低于 recall_min_score 的记忆不进 Top3(不足则少注)。
# 仅对「真实相似度」生效(qvec 非空时 sim 有语义);无向量兜底 # 仅对「真实相似度」生效(qvec 非空时 sim 有语义);无向量兜底
@@ -572,7 +592,10 @@ class MemoryModule:
"event": evt, "sim": round(c["sim"], 4), "event": evt, "sim": round(c["sim"], 4),
"clarity": c["clarity_eff"], "score": c["score"], "clarity": c["clarity_eff"], "score": c["score"],
}) })
self._sweep(now) # FIX(BUG-16): sweep 降频——每 _SWEEP_EVERY 次召回才全表扫描一次(防每轮 O(n))
self._recall_count += 1
if self._recall_count % _SWEEP_EVERY == 0:
self._sweep(now)
return results return results
# ---------- 列表 / 删除 / 维护 ---------- # ---------- 列表 / 删除 / 维护 ----------
+9
View File
@@ -85,6 +85,15 @@ class VecStore:
" group_id TEXT, clarity REAL, last_updated REAL, target TEXT," " group_id TEXT, clarity REAL, last_updated REAL, target TEXT,"
" embedding BLOB)" " embedding BLOB)"
) )
# FIX(BUG-17): 纯 SQLite 路径加 target 索引——find_latest_by_target /
# recent_rows 的 WHERE target=? 全表扫描 → 走索引。
# (vec0 虚拟表不支持普通索引,跳过)
try:
self._conn.execute(
"CREATE INDEX IF NOT EXISTS idx_mem_target ON memories(target)"
)
except Exception: # noqa: BLE001 —— 索引创建失败不阻塞(仅性能)
pass
self._conn.commit() self._conn.commit()
self.logger.info( self.logger.info(
"RainMood 记忆库后端=%s 维度=%s (%s)", "RainMood 记忆库后端=%s 维度=%s (%s)",
+32 -10
View File
@@ -99,7 +99,9 @@ class WebServer:
except ImportError: # noqa: BLE001 —— 非 AstrBot 环境(如冒烟)无 aiohttp except ImportError: # noqa: BLE001 —— 非 AstrBot 环境(如冒烟)无 aiohttp
self.logger.warning("RainMood 独立 WebUI 需要 aiohttp,未安装,跳过启动") self.logger.warning("RainMood 独立 WebUI 需要 aiohttp,未安装,跳过启动")
return return
port = int(self.config.web_port or 44655) # FIX(BUG-24): web_port int() 转换加防御——非法字符串回落默认 44655,
# 否则 KV 恢复路径的坏值可致 initialize() 失败、插件加载失败。
port = self._resolve_port()
host = (self.config.web_host or "0.0.0.0").strip() or "0.0.0.0" # FIX(H3): web_host 真字段(config 已加) host = (self.config.web_host or "0.0.0.0").strip() or "0.0.0.0" # FIX(H3): web_host 真字段(config 已加)
app = web.Application() app = web.Application()
self._app = app self._app = app
@@ -232,9 +234,16 @@ class WebServer:
return self._json({"token": token, "expires": expires}) return self._json({"token": token, "expires": expires})
def _record_login_fail(self, ip: str, now: float) -> None: def _record_login_fail(self, ip: str, now: float) -> None:
"""FIX(H1): 记录一次登录失败;达到阈值 → 锁定该 IP 15 分钟。""" """FIX(H1): 记录一次登录失败;达到阈值 → 锁定该 IP 15 分钟。
FIX(BUG-7): 锁过期后重置计数否则 fails 只增不减锁过期后再错 1 次即
再次锁 15 分钟NAT 后合法用户可能被永久半锁定锁过期视为新窗口
"""
with self._login_lock: with self._login_lock:
fails, _ = self._login_fails.get(ip, (0, 0)) fails, locked_until = self._login_fails.get(ip, (0, 0))
if locked_until > 0 and locked_until <= now:
# 上一轮锁定已过期 → 重置计数(新滑动窗口)
fails = 0
fails += 1 fails += 1
locked_until = now + LOGIN_LOCK_SECONDS if fails >= LOGIN_MAX_FAILS else 0.0 locked_until = now + LOGIN_LOCK_SECONDS if fails >= LOGIN_MAX_FAILS else 0.0
self._login_fails[ip] = (fails, locked_until) self._login_fails[ip] = (fails, locked_until)
@@ -607,9 +616,15 @@ class WebServer:
payload = await self._json_body(request) payload = await self._json_body(request)
if not isinstance(payload, dict): if not isinstance(payload, dict):
return self._err("请求体必须是 JSON 对象", 400) return self._err("请求体必须是 JSON 对象", 400)
# FIX(M1): web_password 为 "***" 或空串 → 保持不改,不进入配置更新 # FIX(M1/BUG-23): web_password 语义——
if payload.get("web_password") in (None, "", "***"): # "" / "***" → 保持不改(前端遮蔽占位,不进入配置更新)
payload = {k: v for k, v in payload.items() if k != "web_password"} # null(显式)→ 清空密码(下次启动自动重新生成,属合理行为)
if "web_password" in payload:
wp = payload["web_password"]
if wp in ("", "***"):
payload = {k: v for k, v in payload.items() if k != "web_password"}
elif wp is None:
payload["web_password"] = ""
try: try:
self.config.update(payload) self.config.update(payload)
except ValueError as ve: except ValueError as ve:
@@ -845,10 +860,10 @@ class WebServer:
await resp.prepare(request) await resp.prepare(request)
q = self.star.sse.register() q = self.star.sse.register()
try: try:
await resp.write( # FIX(BUG-25): 心跳统一由 SSEHub 广播(每 HEARTBEAT_INTERVAL 秒),端点只
b'data: {"type": "heartbeat", "ts": %d}\n\n' % int(time.time()) # 消费队列——移除本端点的初始心跳写(双心跳源冗余)。
) # timeout 分支仅作安全兜底(SSEHub 未广播时保活),正常由
await resp.drain() # SSEHub 心跳帧驱动。
while True: while True:
try: try:
frame = await asyncio.wait_for(q.get(), timeout=30) frame = await asyncio.wait_for(q.get(), timeout=30)
@@ -866,6 +881,13 @@ class WebServer:
# ---------- 工具 ---------- # ---------- 工具 ----------
def _resolve_port(self) -> int:
"""FIX(BUG-24): 解析 web_port——非法值回落默认 44655(防 initialize 失败)。"""
try:
return int(self.config.web_port or 44655)
except (TypeError, ValueError):
return 44655
@staticmethod @staticmethod
def _clamp_int(value: Any, default: int, lo: int, hi: int) -> int: def _clamp_int(value: Any, default: int, lo: int, hi: int) -> int:
try: try:
+78 -17
View File
@@ -12,6 +12,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import itertools
import logging import logging
import secrets import secrets
from dataclasses import fields from dataclasses import fields
@@ -47,11 +48,22 @@ PLUGIN_NAME = "astrbot_plugin_rainmood"
DICT_PRUNE_LIMIT = 500 DICT_PRUNE_LIMIT = 500
def _prune_dict(d: dict, limit: int = DICT_PRUNE_LIMIT) -> None: def _prune_dict(d: dict, limit: int = DICT_PRUNE_LIMIT, keep: str | None = None) -> None:
"""FIX(M4): 修剪无界字典——超过 limit 键时按插入序(最旧)弹出,防内存膨胀。""" """FIX(M4): 修剪无界字典——超过 limit 键时按插入序(最旧)弹出,防内存膨胀。
FIX(BUG-13): ``keep`` 指定当前正在写的 key修剪时跳过它避免刚递增/写入的
当前 umo 计数被当作最旧键弹出极端场景下当前会话计数丢失
"""
while len(d) > limit: while len(d) > limit:
try: try:
d.pop(next(iter(d))) k = next(iter(d))
if k == keep:
# 当前 key 是最旧的(极端场景)——跳过它,弹下一个最旧的非 keep 键
if len(d) <= 1:
break
d.pop(next(itertools.islice(iter(d), 1, None)))
else:
d.pop(k)
except StopIteration: except StopIteration:
break break
@@ -81,7 +93,7 @@ class Rainmood(Star):
self.web_server = WebServer(self) # 独立 WebUI(绕开 bridge 鉴权) self.web_server = WebServer(self) # 独立 WebUI(绕开 bridge 鉴权)
self.round_counters: dict[str, int] = {} # 按 unified_msg_origin 独立计数 self.round_counters: dict[str, int] = {} # 按 unified_msg_origin 独立计数
self.last_replies: dict[str, str] = {} # umo → {bot_name}上一条回复 self.last_replies: dict[str, str] = {} # umo → {bot_name}上一条回复
self._pending_analysis: set[str] = set() # umo:事件触发待分析 self._pending_analysis: dict[str, str] = {} # umo → 触发关键词的消息文本(FIX(BUG-10)
self._tasks: set[asyncio.Task] = set() # 后台分析任务引用(防 GC self._tasks: set[asyncio.Task] = set() # 后台分析任务引用(防 GC
register_all_web_apis(context, self) register_all_web_apis(context, self)
@@ -101,10 +113,13 @@ class Rainmood(Star):
val = cfg[key] val = cfg[key]
if key == "event_keywords": if key == "event_keywords":
# schema 里是逗号分隔字符串(或历史残留数组)→ 转数组; # schema 里是逗号分隔字符串(或历史残留数组)→ 转数组;
# 空/无关键词 → 跳过(保留内置默认词库) # FIX(BUG-21): 空数组 = 显式清空(用户关闭关键词触发);空串 = 未配置
# (保留内置默认词库)。config 允许空列表。
if isinstance(val, str): if isinstance(val, str):
val = [k.strip() for k in val.split(",") if k.strip()] val = [k.strip() for k in val.split(",") if k.strip()]
if not isinstance(val, list) or not val: if not val:
continue # 空串 → 未配置,保留默认词库
if not isinstance(val, list):
continue continue
patch[key] = val patch[key] = val
if not patch: if not patch:
@@ -147,13 +162,21 @@ class Rainmood(Star):
) )
async def terminate(self) -> None: async def terminate(self) -> None:
"""插件卸载/重载:停 WebUI + 落盘 + 关库 + 停心跳。""" """插件卸载/重载:停 WebUI + 落盘 + 关库 + 停心跳。
FIX(BUG-12): cancel + await 后台分析任务**** DB否则运行中的分析
任务在 DB 写操作间隙被取消可能写已关闭的连接状态半写
"""
await self.web_server.stop() await self.web_server.stop()
await self._save() await self._save()
self.memory.close()
await self.sse.stop() await self.sse.stop()
for task in list(self._tasks): # 先取消并等待后台任务结束,再关库
tasks = [t for t in list(self._tasks) if not t.done()]
for task in tasks:
task.cancel() task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
self.memory.close()
self.logger.info("RainMood 已停止,状态已落盘") self.logger.info("RainMood 已停止,状态已落盘")
def _persist_value(self, key: str, value: Any) -> None: def _persist_value(self, key: str, value: Any) -> None:
@@ -173,6 +196,33 @@ class Rainmood(Star):
except Exception: # noqa: BLE001 —— 拿不到事件循环就下次 terminate 落盘 except Exception: # noqa: BLE001 —— 拿不到事件循环就下次 terminate 落盘
pass pass
# ---------- LLM 工具 ----------
@filter.llm_tool(name="silence")
async def silence(self, event: "AstrMessageEvent", reason: str = ""):
"""判断当前场景是否应保持沉默(雨喵不回复)。
当且仅当判定此时不该说话时调用本工具终止本轮回复雨喵不回复
若该说话则不要调用本工具
典型应调用场景
- 深夜/休息时段无人主动搭话只是被无关消息如系统通知他人互聊唤醒
- 话题已结束无人在等雨喵接话
- 雨喵被冷落/被无视接话反而尴尬
- 对方明确表示不想听雨喵说话或雨喵不想接话
典型不应调用场景
- 有人直接向雨喵提问打招呼点名
- 对话正在自然进行雨喵的回应是合理延续
- 需要雨喵表态安慰提供信息
Args:
reason(string): 沉默原因仅用于日志/调试可选
"""
self.logger.info("RainMood silence 工具被调用,终止本轮回复(原因=%s", reason or "未说明")
event.stop_event()
yield
# ---------- 钩子:LLM 请求(每轮) ---------- # ---------- 钩子:LLM 请求(每轮) ----------
@filter.on_llm_request() @filter.on_llm_request()
@@ -185,12 +235,14 @@ class Rainmood(Star):
try: try:
umo = event.unified_msg_origin umo = event.unified_msg_origin
self.round_counters[umo] = self.round_counters.get(umo, 0) + 1 self.round_counters[umo] = self.round_counters.get(umo, 0) + 1
_prune_dict(self.round_counters) # FIX(M4): 轮次计数无上限 → 超限清最旧 _prune_dict(self.round_counters, keep=umo) # FIX(M4/BUG-13): 超限清最旧,跳过当前 umo
# FIX(N2): 读取/注入前惰性时间驱动衰减(无消息期间静置,来消息一次性补齐) # FIX(N2): 读取/注入前惰性时间驱动衰减(无消息期间静置,来消息一次性补齐)
# FIX(苏醒重置): 并列做苏醒重置(已过 sleep_end → 22 维情绪清一次,每天一次)
try: try:
self.environment.maybe_decay() self.environment.maybe_decay()
except Exception as e: # noqa: BLE001 —— 衰减失败绝不能炸掉 LLM 请求 self.environment.maybe_wake_reset(self.director.engine)
except Exception as e: # noqa: BLE001 —— 衰减/重置失败绝不能炸掉 LLM 请求
self.logger.warning(f"RainMood 环境衰减失败: {e}") self.logger.warning(f"RainMood 环境衰减失败: {e}")
prompt_text = (req.prompt or "").strip() prompt_text = (req.prompt or "").strip()
@@ -212,8 +264,13 @@ class Rainmood(Star):
# 用户消息写入会话轮次缓冲(兜底上下文;平台历史拿不到时 Director 回落这里) # 用户消息写入会话轮次缓冲(兜底上下文;平台历史拿不到时 Director 回落这里)
self.recent_buffer.ingest(umo, "user", prompt_text) self.recent_buffer.ingest(umo, "user", prompt_text)
if prompt_text and self.director.check_event_keywords(prompt_text): # FIX(BUG-15): check_event_keywords 只算一次,pending 判定与日志复用
self._pending_analysis.add(umo) hit_kw = bool(prompt_text) and self.director.check_event_keywords(prompt_text)
if hit_kw:
# FIX(BUG-10): 记录触发关键词的**消息文本**——若该轮 LLM 请求失败
# (无 on_llm_response),pending 不会滞留到下一轮误触发
# 另一条消息的关键词分析(响应时校验消息一致)。
self._pending_analysis[umo] = prompt_text
# 注入顺序:跨群上下文在前,情绪/环境状态在后 # 注入顺序:跨群上下文在前,情绪/环境状态在后
injected: list[str] = [] injected: list[str] = []
@@ -253,7 +310,6 @@ class Rainmood(Star):
for text in injected: for text in injected:
req.extra_user_content_parts.append(TextPart(text=text).mark_as_temp()) req.extra_user_content_parts.append(TextPart(text=text).mark_as_temp())
# 消息处理日志:本轮处理概览(轮次/注入/关键词触发/跨群拉取) # 消息处理日志:本轮处理概览(轮次/注入/关键词触发/跨群拉取)
hit_kw = self.director.check_event_keywords(prompt_text)
self.logger.info( self.logger.info(
"RainMood 消息处理 umo=%s 轮次=%d 关键词触发=%s 跨群注入=%d行 情绪注入=%s", "RainMood 消息处理 umo=%s 轮次=%d 关键词触发=%s 跨群注入=%d行 情绪注入=%s",
umo, self.round_counters.get(umo, 0), hit_kw, len(bridge_lines or []), umo, self.round_counters.get(umo, 0), hit_kw, len(bridge_lines or []),
@@ -275,7 +331,7 @@ class Rainmood(Star):
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
reply = "" reply = ""
self.last_replies[umo] = reply self.last_replies[umo] = reply
_prune_dict(self.last_replies) # FIX(M4): 回复缓存无上限 → 超限清最旧 _prune_dict(self.last_replies, keep=umo) # FIX(M4/BUG-13): 回复缓存超限清最旧,跳过当前 umo
# {bot_name}回复写入会话轮次缓冲(与用户消息同一会话队列,保持双方对话) # {bot_name}回复写入会话轮次缓冲(与用户消息同一会话队列,保持双方对话)
self.recent_buffer.ingest(umo, "assistant", reply) self.recent_buffer.ingest(umo, "assistant", reply)
# FIX(M10): {bot_name}自己的回复也进桥——跨群上下文要包含双方对话(按对方 UID 聚合) # FIX(M10): {bot_name}自己的回复也进桥——跨群上下文要包含双方对话(按对方 UID 聚合)
@@ -284,11 +340,16 @@ class Rainmood(Star):
bot_name = (self.config.bot_name or "雨喵").strip() # FIX(bot_name) bot_name = (self.config.bot_name or "雨喵").strip() # FIX(bot_name)
self.bridge.ingest(uid, event.get_group_id() or "私聊", f"{bot_name}: {reply}") self.bridge.ingest(uid, event.get_group_id() or "私聊", f"{bot_name}: {reply}")
triggered = umo in self._pending_analysis # FIX(BUG-10): pending 记录的是触发关键词的消息文本;响应时校验当前消息
# 与 pending 一致才触发——防 LLM 失败后 pending 滞留、下一轮
# 用新消息误触发旧关键词分析。
cur_msg = event.get_message_str() or ""
pending_msg = self._pending_analysis.get(umo)
triggered = pending_msg is not None and pending_msg == cur_msg
trigger_reason = "" trigger_reason = ""
if triggered: if triggered:
trigger_reason = "关键词事件" trigger_reason = "关键词事件"
self._pending_analysis.discard(umo) self._pending_analysis.pop(umo, None)
count = self.round_counters.get(umo, 0) count = self.round_counters.get(umo, 0)
else: else:
count = self.round_counters.get(umo, 0) count = self.round_counters.get(umo, 0)
+1 -1
View File
@@ -1,5 +1,5 @@
name: astrbot_plugin_rainmood name: astrbot_plugin_rainmood
version: 0.1.0 version: 0.2.0
desc: RainMood 雨喵情绪系统 — 情绪表 + 情绪支配AI + 说话风格注入 + 跨群上下文 desc: RainMood 雨喵情绪系统 — 情绪表 + 情绪支配AI + 说话风格注入 + 跨群上下文
author: miaomiao author: miaomiao
repo: https://git.rainnya.asia/miaomiao/astrbot_plugin_rainmood repo: https://git.rainnya.asia/miaomiao/astrbot_plugin_rainmood
+1 -1
View File
@@ -1,5 +1,5 @@
/* ============================================================ /* ============================================================
* RAINMOOD · RainMD 情绪控制台 · app.js (v0.0.1 SPA) * RAINMOOD · RainMD 情绪控制台 · app.js (v0.2.0 SPA)
* 纯静态零依赖单密码登录的后台管理独立 Web 服务器模式 * 纯静态零依赖单密码登录的后台管理独立 Web 服务器模式
* 不依赖 window.AstrBotPluginPage bridge后端未接入时自动降级 * 不依赖 window.AstrBotPluginPage bridge后端未接入时自动降级
* 为演示数据字段结构对齐设计文档 V7 * 为演示数据字段结构对齐设计文档 V7
+2 -2
View File
@@ -34,7 +34,7 @@
<div class="brand-block"> <div class="brand-block">
<p class="brand-mark">RAIN<br>MOOD</p> <p class="brand-mark">RAIN<br>MOOD</p>
<h1 class="brand-name">RainMD 情绪控制台</h1> <h1 class="brand-name">RainMD 情绪控制台</h1>
<p class="brand-ver">astrbot_plugin_rainmood · v0.0.1</p> <p class="brand-ver">astrbot_plugin_rainmood · v0.2.0</p>
</div> </div>
<nav class="side-nav" aria-label="主导航"> <nav class="side-nav" aria-label="主导航">
@@ -63,7 +63,7 @@
<!-- ======== 顶栏 ======== --> <!-- ======== 顶栏 ======== -->
<header class="appbar"> <header class="appbar">
<div class="appbar-inner"> <div class="appbar-inner">
<span class="appbar-brand">RAINMOOD <i>·</i> v0.0.1</span> <span class="appbar-brand">RAINMOOD <i>·</i> v0.2.0</span>
<h2 class="page-title" id="pageTitle">仪表盘</h2> <h2 class="page-title" id="pageTitle">仪表盘</h2>
<span class="sync" id="sync">最近同步 --:--:--</span> <span class="sync" id="sync">最近同步 --:--:--</span>
<span class="sse-tag"><i class="sse-led" id="sseLed" aria-hidden="true"></i>SSE</span> <span class="sse-tag"><i class="sse-led" id="sseLed" aria-hidden="true"></i>SSE</span>
+4 -2
View File
@@ -73,9 +73,11 @@ tag3(强度):次要情绪
规则 规则
- tag1 是主情绪tag2/tag3 是底色最多三个日常只有 tag1 也正常 - tag1 是主情绪tag2/tag3 是底色最多三个日常只有 tag1 也正常
- 强度用 / / / - 强度用 / / /
- **风格提示词必须是"怎么说话"的一句人话**不是情绪描述不是"以开心的语气回复"这种翻译腔 - **风格提示词必须是"怎么说话"的一句人话**不是情绪描述不是"以开心的语气回复"这种翻译腔
- **风格提示词只许写"说话方式"**语气/话量/语速/用词/停顿/口头禅**严禁任何动作神态肢体场景描写**点名禁用指尖/歪头///懒洋洋/把屏幕划到一边/伸手/托腮/眯眼//伸懒腰/叹气+动作等不得描述"她/他正在做什么、周围环境如何、手里拿着什么"风格提示词的**每一句都必须能直接转化为"这句话怎么说"**而不是"这个动作怎么做"
- **情绪来源完整溯源**包含 对象昵称/UID会话哪个群/私聊会话ID事件说了/做了什么**只写事实原文**一句话**只给事实不给你的解释性判断**"{bot_name}觉得被瞧不起了"这类感受结论留在第二层 JSON不进第一层 AI 看到判断会模仿你的推理腔调{bot_name}知道"我是因为谁、在哪、什么话变成这样的"下次遇到同一对象会自然带出针对性 - **情绪来源完整溯源**包含 对象昵称/UID会话哪个群/私聊会话ID事件说了/做了什么**只写事实原文**一句话**只给事实不给你的解释性判断**"{bot_name}觉得被瞧不起了"这类感受结论留在第二层 JSON不进第一层 AI 看到判断会模仿你的推理腔调{bot_name}知道"我是因为谁、在哪、什么话变成这样的"下次遇到同一对象会自然带出针对性
- 无明确来源就写""不编造 - 无明确来源就写""不编造
- **风格提示词是唯一允许出现在这里的描述性指令且只描述语言本身**绝不允许任何非语言的描述混进来
**第二层 · 完整数据**给程序解析 + 存库不注入主 AI输出时把这段 JSON 放在第一层文本之后 ```json 代码块包裹 **第二层 · 完整数据**给程序解析 + 存库不注入主 AI输出时把这段 JSON 放在第一层文本之后 ```json 代码块包裹
@@ -138,7 +140,7 @@ tag3(强度):次要情绪
1. **拟人优先**像真人一样有情绪起伏会记仇记爱但不过度戏剧化日常聊天情绪是平稳的只有真实事件才会激起波澜 1. **拟人优先**像真人一样有情绪起伏会记仇记爱但不过度戏剧化日常聊天情绪是平稳的只有真实事件才会激起波澜
2. **态度底色**对家人永远温柔底色情绪只能调温度不破"对家人乖巧"对外人保持冷淡傲娇底 2. **态度底色**对家人永远温柔底色情绪只能调温度不破"对家人乖巧"对外人保持冷淡傲娇底
3. **风格指令要像人话**"语气重一些,话短,别笑"这种直接指令不要写"以开心的语气回复"这种翻译腔 3. **风格指令要像人话**"语气重一些,话短,别笑"这种直接指令不要写"以开心的语气回复"这种翻译腔**只写说话方式严禁动作/神态/肢体/场景描写**"指尖懒洋洋戳着屏幕""歪头瞥了一眼""把屏幕划到一边""伸手托腮"都不行 AI 只会照着"怎么说"执行动作描写会让她胡编戏
4. **强度分级**=几乎察觉不到=正常情绪=明显=强烈爆发绝大多数时候是中 4. **强度分级**=几乎察觉不到=正常情绪=明显=强烈爆发绝大多数时候是中
5. **不编造**溯源 trigger 必须来自真实上下文不能凭空造没有明确触发就输出平稳状态 5. **不编造**溯源 trigger 必须来自真实上下文不能凭空造没有明确触发就输出平稳状态
6. **情绪渐变**除非遇到强烈事件不要从一个极端跳到另一个极端 6. **情绪渐变**除非遇到强烈事件不要从一个极端跳到另一个极端
+258 -1
View File
@@ -119,6 +119,7 @@ def _install_astrbot_stub() -> bool:
on_llm_request=lambda *a, **k: (lambda f: f), on_llm_request=lambda *a, **k: (lambda f: f),
on_llm_response=lambda *a, **k: (lambda f: f), on_llm_response=lambda *a, **k: (lambda f: f),
on_decorating_result=lambda *a, **k: (lambda f: f), on_decorating_result=lambda *a, **k: (lambda f: f),
llm_tool=lambda *a, **k: (lambda f: f),
) )
event.AstrMessageEvent = object event.AstrMessageEvent = object
@@ -206,6 +207,10 @@ from astrbot_plugin_rainmood.core.inject_guard import ( # noqa: E402
has_instruction_form, has_instruction_form,
wrap_untrusted, wrap_untrusted,
) )
from astrbot_plugin_rainmood.core.log_buffer import ( # noqa: E402
MemoryLogHandler,
attach_memory_log,
)
from astrbot_plugin_rainmood.core.memory import MemoryModule # noqa: E402 from astrbot_plugin_rainmood.core.memory import MemoryModule # noqa: E402
import astrbot_plugin_rainmood.core.memory as mem_mod # noqa: E402,F401FakeStar 换绑数据路径用) import astrbot_plugin_rainmood.core.memory as mem_mod # noqa: E402,F401FakeStar 换绑数据路径用)
from astrbot_plugin_rainmood.core.recent_buffer import RecentBuffer # noqa: E402 from astrbot_plugin_rainmood.core.recent_buffer import RecentBuffer # noqa: E402
@@ -711,6 +716,27 @@ def test_decay() -> None:
check("深夜时段氛围", "深夜" in env.time_bias_text(_ts_for(23, 30))) check("深夜时段氛围", "深夜" in env.time_bias_text(_ts_for(23, 30)))
check("午休时段氛围", "午休" in env.time_bias_text(_ts_for(13, 0))) check("午休时段氛围", "午休" in env.time_bias_text(_ts_for(13, 0)))
check("其他时段空串", env.time_bias_text(_ts_for(10, 0)) == "") check("其他时段空串", env.time_bias_text(_ts_for(10, 0)) == "")
# FIX(BUG-19): 午休窗口 12:30~14:00(含 14:00 整点,不含 14:01
check("BUG-19 午休 14:00 整点", "午休" in env.time_bias_text(_ts_for(14, 0)))
check("BUG-19 午休 14:01 结束", env.time_bias_text(_ts_for(14, 1)) == "")
check("BUG-19 午休 12:29 未开始", env.time_bias_text(_ts_for(12, 29)) == "")
# FIX(BUG-11): 跨昼夜边界按每个 tick 的小时分别选表(22:30→23:30 = 白天率×深夜率,
# 而非旧代码用 now 小时覆盖全部 tick 的深夜率²)
eng4 = star.director.engine
eng4.last_decay_ts = _ts_for(22, 30) # 22:30(白天表)
eng4.dims["joy"]["intensity"] = 1.0
env.decay(eng4, _ts_for(23, 30)) # 23:30(深夜表),2 个 tick
exp_eff = env.decay_rate_for("joy", 22) * env.decay_rate_for("joy", 23)
check("BUG-11 跨昼夜 2 tick 用逐 tick 率",
abs(eng4.dims["joy"]["intensity"] - round(0.03 + (1.0 - 0.03) * exp_eff, 4)) < 1e-4,
str(eng4.dims["joy"]["intensity"]))
# 对照:同小时 2 tick 仍等价 rate²(不破坏原语义)
eng5 = star.director.engine
eng5.last_decay_ts = _ts_for(12, 0)
eng5.dims["joy"]["intensity"] = 1.0
env.decay(eng5, _ts_for(13, 0))
exp_same = env.decay_rate_for("joy", 12) ** 2
check("BUG-11 同小时 2 tick = rate²", abs(eng5.dims["joy"]["intensity"] - round(0.03 + (1.0 - 0.03) * exp_same, 4)) < 1e-4)
# FIX(N3-1): 睡眠打扰判定与触发分离——返回 dict 状态,不再硬增强 dims # FIX(N3-1): 睡眠打扰判定与触发分离——返回 dict 状态,不再硬增强 dims
star.config.sleep_start, star.config.sleep_end = "22:00", "06:00" # FIX(N7): HH:MM star.config.sleep_start, star.config.sleep_end = "22:00", "06:00" # FIX(N7): HH:MM
star.environment.sleep_disturb_count.clear() star.environment.sleep_disturb_count.clear()
@@ -725,7 +751,9 @@ def test_decay() -> None:
check("N3-1 不再硬增强 dims", eng.dims["anger"]["intensity"] == 0.0 and eng.dims["distress"]["intensity"] == 0.0) check("N3-1 不再硬增强 dims", eng.dims["anger"]["intensity"] == 0.0 and eng.dims["distress"]["intensity"] == 0.0)
st2 = env.on_message_during_sleep("g1-u1", _ts_for(23, 1)) st2 = env.on_message_during_sleep("g1-u1", _ts_for(23, 1))
check("sleep_state 第二次 count=2", st2["count"] == 2) check("sleep_state 第二次 count=2", st2["count"] == 2)
check("N3-1 3h 限频内不重复强制", st2["should_force"] is False) # FIX(BUG-22): environment 不再自己限频 should_force(限频判定全部交给 director 的
# _last_forced_ts)——睡眠时段内 should_force 恒 True,由 director 统一 gate。
check("BUG-22 睡眠时段 should_force 恒 Truedirector 统一限频)", st2["should_force"] is True)
# FIX(M1): 轻提示也走 3h 限频——首次 True,1 分钟后 Falsecount 仍累积 # FIX(M1): 轻提示也走 3h 限频——首次 True,1 分钟后 Falsecount 仍累积
check("M1 首次可注入轻提示", st1.get("light_inject") is True, str(st1.get("light_inject"))) check("M1 首次可注入轻提示", st1.get("light_inject") is True, str(st1.get("light_inject")))
check("M1 1 分钟后轻提示限频", st2.get("light_inject") is False, str(st2.get("light_inject"))) check("M1 1 分钟后轻提示限频", st2.get("light_inject") is False, str(st2.get("light_inject")))
@@ -763,6 +791,35 @@ def test_decay() -> None:
check("N7 sleep_enabled=False 关闭深夜 Tag", env_n7.time_bias_text(_ts_for(0, 0)) == "") check("N7 sleep_enabled=False 关闭深夜 Tag", env_n7.time_bias_text(_ts_for(0, 0)) == "")
check("N7 关闭后被打扰判定 None", env_n7.on_message_during_sleep("g1-u1", _ts_for(0, 0)) is None) check("N7 关闭后被打扰判定 None", env_n7.on_message_during_sleep("g1-u1", _ts_for(0, 0)) is None)
# FIX(苏醒重置): 已过苏醒 → 负面降底 / 正面减半;每天一次;深夜不执行
star_w = FakeStar()
env_w = star_w.environment
eng_w = star_w.director.engine
star_w.config.sleep_start, star_w.config.sleep_end = "23:00", "05:00"
env_w._last_wake_reset_key = ""
eng_w.dims["anger"]["intensity"] = 0.8 # v<0 → 降到 floor 附近(max(0.05, floor)=0.05
eng_w.dims["joy"]["intensity"] = 0.6 # v>=0 → ×0.5
eng_w.dims["love"]["intensity"] = 0.4 # v>=0 → ×0.5
eng_w.dims["distress"]["intensity"] = 0.02 # v<0 但已低于最低值 → 保持
env_w.maybe_wake_reset(eng_w, _ts_for(8, 0)) # 08:00 已苏醒(窗口 23:00~05:00 之外)
check("苏醒重置 负面降底", abs(eng_w.dims["anger"]["intensity"] - 0.05) < 1e-9,
str(eng_w.dims["anger"]["intensity"]))
check("苏醒重置 正面减半", abs(eng_w.dims["joy"]["intensity"] - 0.3) < 1e-9
and abs(eng_w.dims["love"]["intensity"] - 0.2) < 1e-9)
check("苏醒重置 已低于最低值保持", abs(eng_w.dims["distress"]["intensity"] - 0.02) < 1e-9)
check("苏醒重置 记录当天", env_w._last_wake_reset_key != "")
# 同一天不重复执行
eng_w.dims["joy"]["intensity"] = 0.6
env_w.maybe_wake_reset(eng_w, _ts_for(10, 0))
check("苏醒重置 当天不重复", abs(eng_w.dims["joy"]["intensity"] - 0.6) < 1e-9)
# 睡眠时段内不执行(01:00 在窗口内)
eng_w.dims["joy"]["intensity"] = 0.6
env_w.maybe_wake_reset(eng_w, _ts_for(1, 0))
check("苏醒重置 深夜不执行", abs(eng_w.dims["joy"]["intensity"] - 0.6) < 1e-9)
# 跨天后再次执行
env_w.maybe_wake_reset(eng_w, _ts_for(8, 0, day=11))
check("苏醒重置 跨天再执行", abs(eng_w.dims["joy"]["intensity"] - 0.3) < 1e-9)
# FIX(N6 极客): baseline_pad / overrides / intensity_levels 运行时 # FIX(N6 极客): baseline_pad / overrides / intensity_levels 运行时
star_g = FakeStar() star_g = FakeStar()
star_g.config.update({"baseline_pad": "0.5,0.4,0.3"}) star_g.config.update({"baseline_pad": "0.5,0.4,0.3"})
@@ -923,6 +980,14 @@ async def _test_memory() -> None:
big = await mem.recall(query="小明放我鸽子", target="u1", top_k=3, mood_pad={"v": -0.6, "a": 0.3, "d": -0.2}) big = await mem.recall(query="小明放我鸽子", target="u1", top_k=3, mood_pad={"v": -0.6, "a": 0.3, "d": -0.2})
big_ev = next((r for r in big if "放我鸽子" in r["event"]), None) big_ev = next((r for r in big if "放我鸽子" in r["event"]), None)
check("β=0.9 钳制 ≤ score_raw×1.3", big_ev is not None and abs(big_ev["score"] - round(base_ev["score"] * 1.3, 4)) < 1e-4) check("β=0.9 钳制 ≤ score_raw×1.3", big_ev is not None and abs(big_ev["score"] - round(base_ev["score"] * 1.3, 4)) < 1e-4)
# FIX(BUG-6): β 下界钳制——weight=1.0 + PAD 完全相反(pad_sim=-1)→ score 应钳到
# raw×0.7 而非 0(原代码只钳上界,下界缺失)
star.config.mood_match_weight = 1.0
opp2 = await mem.recall(query="小明放我鸽子", target="u1", top_k=3, mood_pad={"v": 0.6, "a": -0.3, "d": 0.2})
opp2_ev = next((r for r in opp2 if "放我鸽子" in r["event"]), None)
check("BUG-6 β=1.0 PAD 相反钳制 ≥ score_raw×0.7",
opp2_ev is not None and abs(opp2_ev["score"] - round(base_ev["score"] * 0.7, 4)) < 1e-4,
str(opp2_ev))
star.config.mood_match_weight = 0.3 star.config.mood_match_weight = 0.3
mood2 = await mem.recall(query="小明放我鸽子", target="u1", top_k=3, mood_pad={"v": -0.6, "a": 0.3, "d": -0.2}) mood2 = await mem.recall(query="小明放我鸽子", target="u1", top_k=3, mood_pad={"v": -0.6, "a": 0.3, "d": -0.2})
check("β 加权排序稳定(同 query 两次一致)", [r["event"] for r in mood] == [r["event"] for r in mood2]) check("β 加权排序稳定(同 query 两次一致)", [r["event"] for r in mood] == [r["event"] for r in mood2])
@@ -1066,6 +1131,15 @@ async def _test_director() -> None:
check("M2 压缩后总量小于原始 20 轮", len(hist_comp) < 20 * 160, str(len(hist_comp))) # FIX(N3-3): system_prompt summary 追加总结判断段 check("M2 压缩后总量小于原始 20 轮", len(hist_comp) < 20 * 160, str(len(hist_comp))) # FIX(N3-3): system_prompt summary 追加总结判断段
check("summary system_prompt 追加", "总结判断模式" in d._system_prompt(mode="summary")) check("summary system_prompt 追加", "总结判断模式" in d._system_prompt(mode="summary"))
check("normal system_prompt 不追加", "总结判断模式" not in d._system_prompt()) check("normal system_prompt 不追加", "总结判断模式" not in d._system_prompt())
# FIX(禁止动作描写): 风格提示词只许说话方式——第一层规则 + 全局规则 #3 静态断言
from astrbot_plugin_rainmood.prompts.director_prompt import DIRECTOR_SYSTEM_PROMPT
check("禁止动作描写:第一层规则", "严禁任何动作、神态、肢体、场景描写" in DIRECTOR_SYSTEM_PROMPT)
check("禁止动作描写:点名禁用词", "指尖" in DIRECTOR_SYSTEM_PROMPT and "歪头" in DIRECTOR_SYSTEM_PROMPT
and "托腮" in DIRECTOR_SYSTEM_PROMPT and "伸懒腰" in DIRECTOR_SYSTEM_PROMPT)
check("禁止动作描写:可转化怎么说", "每一句都必须能直接转化为" in DIRECTOR_SYSTEM_PROMPT)
check("禁止动作描写:全局规则 #3", "严禁动作/神态/肢体/场景描写" in DIRECTOR_SYSTEM_PROMPT
and "胡编戏" in DIRECTOR_SYSTEM_PROMPT)
check("禁止动作描写:唯一描述性指令", "风格提示词是唯一允许出现在这里的描述性指令" in DIRECTOR_SYSTEM_PROMPT)
# 恢复损坏状态 # 恢复损坏状态
d.restore_state("垃圾", None, None, None) d.restore_state("垃圾", None, None, None)
check("restore_state 损坏数据回落默认", len(d.dims) == 22) check("restore_state 损坏数据回落默认", len(d.dims) == 22)
@@ -1143,6 +1217,20 @@ async def _test_web_server() -> None:
resp = await ws._api_login(FakeReq(remote="1.2.3.4", body={"password": "secret-pwd"})) resp = await ws._api_login(FakeReq(remote="1.2.3.4", body={"password": "secret-pwd"}))
check("≥5 次失败锁 15 分钟(正确密码也被拒 429)", resp[0] == "err" and resp[2] == 429) check("≥5 次失败锁 15 分钟(正确密码也被拒 429)", resp[0] == "err" and resp[2] == 429)
check("其他 IP 不受锁影响", (await ws._api_login(FakeReq(remote="5.6.7.8", body={"password": "secret-pwd"})))[0] == "json") check("其他 IP 不受锁影响", (await ws._api_login(FakeReq(remote="5.6.7.8", body={"password": "secret-pwd"})))[0] == "json")
# FIX(BUG-7): 锁过期后计数重置——过期后再错 1 次不立即再锁(新滑动窗口)
with ws._login_lock:
fails, lu = ws._login_fails["1.2.3.4"]
ws._login_fails["1.2.3.4"] = (fails, time.time() - 1) # 模拟锁已过期
await ws._api_login(FakeReq(remote="1.2.3.4", body={"password": "bad"}))
with ws._login_lock:
fails_after, lu_after = ws._login_fails["1.2.3.4"]
check("BUG-7 锁过期后计数重置为 1(不立即再锁)", fails_after == 1 and lu_after == 0.0,
f"fails={fails_after} lu={lu_after}")
# FIX(BUG-24): web_port 非法字符串回落默认 44655(不炸 initialize
star.config.web_port = "not-a-port"
check("BUG-24 非法 web_port 回落默认 44655", ws._resolve_port() == 44655)
star.config.web_port = 8080
check("BUG-24 合法 web_port 正常解析", ws._resolve_port() == 8080)
# 登出幂等 # 登出幂等
resp = await ws._api_logout(FakeReq(query={"token": "tok-ok"})) resp = await ws._api_logout(FakeReq(query={"token": "tok-ok"}))
check("登出返回 ok", resp[0] == "json" and resp[1].get("ok") is True) check("登出返回 ok", resp[0] == "json" and resp[1].get("ok") is True)
@@ -1159,6 +1247,12 @@ async def _test_web_server() -> None:
resp = await ws._api_config_post(FakeReq(query={"token": "tok2"}, body={"recall_min_score": 0.2, "mood_match_weight": 0.5})) resp = await ws._api_config_post(FakeReq(query={"token": "tok2"}, body={"recall_min_score": 0.2, "mood_match_weight": 0.5}))
check("POST /config 合法(含 P0 字段)保存", resp[0] == "json" and resp[1].get("saved") is True) check("POST /config 合法(含 P0 字段)保存", resp[0] == "json" and resp[1].get("saved") is True)
check("P0 字段已写入 config", star.config.recall_min_score == 0.2 and star.config.mood_match_weight == 0.5) check("P0 字段已写入 config", star.config.recall_min_score == 0.2 and star.config.mood_match_weight == 0.5)
# FIX(BUG-23): web_password 显式清空语义——提交 null 清空;"***"/空串保持不改
star.config.web_password = "hunter2"
resp = await ws._api_config_post(FakeReq(query={"token": "tok2"}, body={"web_password": "***"}))
check("BUG-23 *** 保持不改", resp[0] == "json" and star.config.web_password == "hunter2")
resp = await ws._api_config_post(FakeReq(query={"token": "tok2"}, body={"web_password": None}))
check("BUG-23 null 显式清空", resp[0] == "json" and star.config.web_password == "")
star.config.web_password = "" star.config.web_password = ""
# 未授权 API 401 # 未授权 API 401
resp = await ws._api_state(FakeReq()) resp = await ws._api_state(FakeReq())
@@ -1279,6 +1373,27 @@ def test_main_module() -> None:
check("on_llm_request 注入走 extra_user_content_parts", "extra_user_content_parts.append" in src) check("on_llm_request 注入走 extra_user_content_parts", "extra_user_content_parts.append" in src)
check("on_llm_request 不写 req.system_prompt", "req.system_prompt" not in src) check("on_llm_request 不写 req.system_prompt", "req.system_prompt" not in src)
check("on_llm_response 用 asyncio.create_task 后台分析", "create_task" in inspect.getsource(main_mod.Rainmood.on_llm_response)) check("on_llm_response 用 asyncio.create_task 后台分析", "create_task" in inspect.getsource(main_mod.Rainmood.on_llm_response))
# silence 工具(待办功能):静态断言注册存在 + 描述/终止逻辑
check("silence 工具方法存在", hasattr(main_mod.Rainmood, "silence"))
src = inspect.getsource(main_mod.Rainmood.silence)
check("silence 用 @filter.llm_tool 注册", "@filter.llm_tool" in src or "llm_tool" in src)
check("silence 调用 stop_event 终止", "stop_event()" in src)
check("silence 是 async 生成器(yield", "async def silence" in src and "yield" in src)
check("silence 描述含「何时该调用」判定", "不该说话" in src and "不要调用" in src)
# FIX(BUG-13): _prune_dict 跳过当前正在写的 keykeep 参数)
d2 = {"a": 1, "b": 2, "c": 3}
main_mod._prune_dict(d2, 2, keep="a")
check("BUG-13 _prune_dict 保留 keep 键", "a" in d2 and len(d2) == 2, str(d2))
# FIX(BUG-21): 空数组显式清空 event_keywords;空串保留默认词库
s_clear = SimpleStar()
main_mod.Rainmood._apply_astrbot_config(s_clear, {"event_keywords": []})
check("BUG-21 空数组清空关键词", s_clear.config.event_keywords == [], str(s_clear.config.event_keywords))
s_keep = SimpleStar()
main_mod.Rainmood._apply_astrbot_config(s_keep, {"event_keywords": ""})
check("BUG-21 空串保留默认词库", len(s_keep.config.event_keywords) > 0)
s_set = SimpleStar()
main_mod.Rainmood._apply_astrbot_config(s_set, {"event_keywords": "喜欢,讨厌"})
check("BUG-21 非空字符串转数组", s_set.config.event_keywords == ["喜欢", "讨厌"], str(s_set.config.event_keywords))
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1467,6 +1582,143 @@ async def test_p2_e5_rerank() -> None:
and abs(sims[1] - 0.5) < 1e-6 and abs(sims[2] - 1.0 / 3.0) < 1e-4, str(sims)) and abs(sims[1] - 0.5) < 1e-6 and abs(sims[2] - 1.0 / 3.0) < 1e-4, str(sims))
async def test_p0_bug1_rerank_noncontiguous() -> None:
"""BUG-1 漏网用例:reranker 返回**非连续**索引时顺序正确(原 bug 全取 0.0 错乱)。"""
group("BUG-1 reranker 非连续索引(30 候选)")
star = FakeStar()
star.config.recall_min_score = 0.0
mem = star.memory
# 无 embedder → recent_rows 兜底(qvec=None 不启门控),可精确控制候选顺序
mem._embedder = False
mem._provider_embed = False
mem._local_fail_at = time.time() + 1000
n = 30
for i in range(n):
await mem.add(f"记忆内容{i}", "u1", "g1", "u1", "g1")
# recent_rows 按 last_updated DESC:最后添加的(i=29)在 index 0i=28 在 index 1 ...
# 故 index k 的内容 = f"记忆内容{29-k}"
def content_at(idx: int) -> str:
return f"记忆内容{29 - idx}"
class FakeRR:
async def rerank(self, query, docs, top_n=None):
# 非连续索引:3/7/0 命中,其余不返回(top_n < 候选数)
return [{"index": 3, "relevance_score": 0.95},
{"index": 7, "relevance_score": 0.9},
{"index": 0, "relevance_score": 0.85}]
star.config.update({"reranker_provider_id": "rr_1"})
m2 = MemoryModule(star)
m2._reranker = FakeRR()
m2._reranker_cfg_sig = "rr_1"
res = await m2.recall("查询", target="u1", top_k=10)
events = [r["event"] for r in res]
# 期望顺序:index3(0.95) > index7(0.9) > index0(0.85) > 其余(_rel=0,保持原序)
expected_head = [content_at(3), content_at(7), content_at(0)]
check("rerank 非连续索引前 3 顺序正确", events[:3] == expected_head, str(events[:3]))
check("rerank 非连续索引命中 3 条", len(events) == 3, str(len(events)))
# 回归:连续索引(旧用例)仍正确
class FakeRR2:
async def rerank(self, query, docs, top_n=None):
return [{"index": 0, "relevance_score": 0.9},
{"index": 1, "relevance_score": 0.4},
{"index": 2, "relevance_score": 0.1}]
m3 = MemoryModule(star)
m3._reranker = FakeRR2()
m3._reranker_cfg_sig = "rr_2"
res3 = await m3.recall("查询", target="u1", top_k=3)
check("rerank 连续索引顺序正确", [r["event"] for r in res3] == [content_at(0), content_at(1), content_at(2)],
str([r["event"] for r in res3]))
def test_p0_bug2_attach_memory_log() -> None:
"""BUG-2 漏网用例:attach_memory_log 二次调用返回**同一** handler(原 bug 返回新空 handler)。"""
group("BUG-2 attach_memory_log 幂等返回同一 handler")
h1 = attach_memory_log()
h2 = attach_memory_log()
check("二次调用返回同一 handlerh1 is h2", h1 is h2)
check("返回的是 MemoryLogHandler", isinstance(h1, MemoryLogHandler))
# 挂载后 root 上确实有该 handler
root = logging.getLogger()
check("root 上挂载了 MemoryLogHandler", any(isinstance(h, MemoryLogHandler) for h in root.handlers))
async def test_p0_bug3_cross_umo_pending() -> None:
"""BUG-3 漏网用例:umo1 持锁期间 umo2 到达 → 处理后 umo2 帧被消费(不孤儿)。"""
group("BUG-3 跨 umo pending 帧兜底消费")
star = FakeStar()
d = star.director
processed: list[str] = []
release = asyncio.Event()
async def fake_run(**kw):
ev = kw.get("event")
processed.append(ev.unified_msg_origin)
# 模拟 umo1 分析进行中:umo2 的 analyze 到达 → 写入 _pending_frames
if ev.unified_msg_origin == "g1-u1":
ev2 = FakeEvent(uid="u2", group="g2", name="小红", text="第二条")
await d.analyze(ev2, "第二条", "")
await release.wait()
d._run_analysis = fake_run
ev1 = FakeEvent(uid="u1", group="g1", name="小明", text="第一条")
t1 = asyncio.create_task(d.analyze(ev1, "第一条", ""))
await asyncio.sleep(0.05) # 让 umo1 进入 _run_analysis 并触发 umo2 入队
check("umo2 帧已入 _pending_frames", "g2-u2" in d._pending_frames, str(d._pending_frames))
release.set()
await t1
# 锁释放后 finally 兜底消费 umo2 帧
await asyncio.sleep(0.05)
check("umo2 帧被消费(processed 含 g2-u2", "g2-u2" in processed, str(processed))
check("_pending_frames 已清空", not d._pending_frames, str(d._pending_frames))
async def test_p0_bug4_degraded_target_leak() -> None:
"""BUG-4 漏网用例:降级模式 target 无记忆 → 返回 [](不泄漏他人记忆)。"""
group("BUG-4 降级模式 target 无记忆不泄漏")
star = FakeStar()
mem = star.memory
mem._embedder = False
mem._provider_embed = False
mem._local_fail_at = time.time() + 1000
# userA 有记忆
await mem.add("小明的秘密", "uA", "gA", "uA", "gA")
await mem.add("小明的喜好", "uA", "gA", "uA", "gA")
# target=userB 无记忆 → 必须返回 [],绝不返回 userA 的记忆
rows = await mem.recall(query="随便", target="uB", top_k=3)
check("target 无记忆返回 []", rows == [], str(rows))
# 对照:target 为空(全量)仍返回
rows_all = await mem.recall(query="随便", target="", top_k=3)
check("target 为空仍返回全量", len(rows_all) == 2, str(len(rows_all)))
# 对照:target=userA 有记忆正常返回
rows_a = await mem.recall(query="随便", target="uA", top_k=3)
check("target 有记忆正常返回", len(rows_a) == 2, str(len(rows_a)))
def test_p0_bug5_hysteresis_tranquility_exit() -> None:
"""BUG-5 漏网用例:滞回带宁静退出通道(gate(0.3,0.3) 采纳 / 宁静低强度可退出)。"""
group("BUG-5 滞回带宁静退出通道")
star = FakeStar()
eng = EmotionEngine(star)
# 原 bug 复现链:gate(0.3,0.3)=None 卡死;修复后宁静低强度采纳
check("宁静低强度采纳(gate(0.3,0.3)", eng.hysteresis_gate(0.3, 0.3, primary="宁静") == 0.3)
check("宁静更低强度采纳(gate(0.3,0.25)", eng.hysteresis_gate(0.3, 0.25, primary="宁静") == 0.25)
# 非宁静情绪仍受滞回带约束(不破坏防抖)
check("非宁静带内波动仍 None", eng.hysteresis_gate(0.3, 0.3, primary="委屈") is None)
check("非宁静跌穿下阈值仍采纳", eng.hysteresis_gate(0.5, 0.3, primary="委屈") == 0.3)
# 原有 2 参调用(无 primary)行为不变
check("无 primary 带内波动仍 None", eng.hysteresis_gate(0.3, 0.3) is None)
check("无 primary 跌穿下阈值仍采纳", eng.hysteresis_gate(0.5, 0.3) == 0.3)
# 集成:apply_analysis 宁静低强度可退出(走完整 gate 调用)
parsed = {
"emotion": {"primary": "宁静", "intensity": "", "valence": 0.0, "arousal": 0.0, "dominance": 0.0},
"source": {"trigger": "平静下来"},
}
eng.patch = {"primary": "委屈", "intensity": 0.3, "secondary": [], "style": "", "source": {}}
ok = eng.apply_analysis(parsed, FakeEvent(uid="u1", group="g1", name="小明", text=""))
check("apply_analysis 宁静低强度采纳", ok is True and eng.patch["primary"] == "宁静", str(eng.patch))
def test_n3_recent_compress() -> None: def test_n3_recent_compress() -> None:
group("N3-3 recent_buffer 压缩 + 配置校验") group("N3-3 recent_buffer 压缩 + 配置校验")
# 配置:forced_analysis_interval_hours [1,24] + N4 两旋钮 # 配置:forced_analysis_interval_hours [1,24] + N4 两旋钮
@@ -1580,6 +1832,11 @@ def main() -> int:
asyncio.run(_test_director()) asyncio.run(_test_director())
asyncio.run(test_p1_kind_importance()) asyncio.run(test_p1_kind_importance())
asyncio.run(test_p2_e5_rerank()) asyncio.run(test_p2_e5_rerank())
asyncio.run(test_p0_bug1_rerank_noncontiguous())
test_p0_bug2_attach_memory_log()
asyncio.run(test_p0_bug3_cross_umo_pending())
asyncio.run(test_p0_bug4_degraded_target_leak())
test_p0_bug5_hysteresis_tranquility_exit()
asyncio.run(test_n3_force_analysis()) asyncio.run(test_n3_force_analysis())
asyncio.run(_test_web_server()) asyncio.run(_test_web_server())
test_schema() test_schema()