Compare commits
4
Commits
c8fcf9d79d
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9fd32d0c7 | ||
|
|
f3bd1f3fa1 | ||
|
|
f2678024eb | ||
|
|
bac4f7c173 |
@@ -0,0 +1,29 @@
|
|||||||
|
MIT License(含品牌保留条款)
|
||||||
|
|
||||||
|
Copyright (c) 2026 Xianyunah(Rainnya 家族)
|
||||||
|
|
||||||
|
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
@@ -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 注入)与 peek(Director 分析)共用,保证同一轮主 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
@@ -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"):
|
||||||
|
|||||||
@@ -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 帧(任意 umo,FIFO)。
|
||||||
|
|
||||||
|
在 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
@@ -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
@@ -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:
|
||||||
|
|||||||
+14
-3
@@ -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 的 level,root=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()
|
|
||||||
if not any(isinstance(h, MemoryLogHandler) for h in root.handlers):
|
|
||||||
root.addHandler(handler)
|
root.addHandler(handler)
|
||||||
return handler
|
return handler
|
||||||
|
|||||||
+26
-3
@@ -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_id;group_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.7(weight=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,6 +592,9 @@ 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"],
|
||||||
})
|
})
|
||||||
|
# FIX(BUG-16): sweep 降频——每 _SWEEP_EVERY 次召回才全表扫描一次(防每轮 O(n))
|
||||||
|
self._recall_count += 1
|
||||||
|
if self._recall_count % _SWEEP_EVERY == 0:
|
||||||
self._sweep(now)
|
self._sweep(now)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
+31
-9
@@ -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, "", "***"):
|
# "" / "***" → 保持不改(前端遮蔽占位,不进入配置更新)
|
||||||
|
# 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"}
|
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:
|
||||||
|
|||||||
@@ -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
@@ -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,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)。
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,F401(FakeStar 换绑数据路径用)
|
import astrbot_plugin_rainmood.core.memory as mem_mod # noqa: E402,F401(FakeStar 换绑数据路径用)
|
||||||
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 恒 True(director 统一限频)", st2["should_force"] is True)
|
||||||
# FIX(M1): 轻提示也走 3h 限频——首次 True,1 分钟后 False;count 仍累积
|
# FIX(M1): 轻提示也走 3h 限频——首次 True,1 分钟后 False;count 仍累积
|
||||||
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 跳过当前正在写的 key(keep 参数)
|
||||||
|
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 0,i=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("二次调用返回同一 handler(h1 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()
|
||||||
|
|||||||
Reference in New Issue
Block a user