refactor ai_auto_response plugin architecture

This commit is contained in:
liuwei
2026-04-09 17:46:30 +08:00
parent cc65378544
commit f580c69736
39 changed files with 4347 additions and 1979 deletions

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
from .cooldown import CooldownManager
from .flow_manager import FlowManager
from .logging import build_log_summary, yn
__all__ = ["CooldownManager", "FlowManager", "build_log_summary", "yn"]

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import time
from typing import Dict, List
class CooldownManager:
def __init__(self, config: Dict):
self.config = config or {}
self.last_reply_at: Dict[str, float] = {}
self.at_mention_history: Dict[str, List[float]] = {}
self.user_reply_history: Dict[str, List[float]] = {}
def pass_cooldown(self, room_id: str, sender: str, trigger: Dict) -> bool:
current_ts = time.time()
room_cd = int(self.config.get("group_reply_cooldown_sec", 45))
user_cd = int(self.config.get("same_user_followup_cooldown_sec", 10))
at_min_interval = int(self.config.get("at_mention_min_interval_sec", 8))
at_burst_window = int(self.config.get("at_mention_burst_window_sec", 90))
at_burst_limit = int(self.config.get("at_mention_burst_limit", 4))
at_silent_sec = int(self.config.get("at_mention_silent_sec", 180))
directed_burst_window = int(self.config.get("directed_burst_window_sec", 240))
directed_burst_limit = int(self.config.get("directed_burst_limit", 4))
directed_silent_sec = int(self.config.get("directed_burst_silent_sec", 480))
last_room_reply = self.last_reply_at.get(room_id, 0.0)
user_key = f"{room_id}:{sender}"
user_history = [ts for ts in self.user_reply_history.get(user_key, []) if current_ts - ts <= directed_burst_window]
self.user_reply_history[user_key] = user_history
if trigger.get("is_at") or trigger.get("is_followup") or trigger.get("is_directed"):
if user_history and (current_ts - user_history[-1]) < user_cd:
trigger["_cooldown_reason"] = "same_user_directed_cooldown"
return False
if len(user_history) >= directed_burst_limit and (current_ts - user_history[-1]) < directed_silent_sec:
trigger["_cooldown_reason"] = "same_user_directed_silent"
return False
if trigger.get("trigger_type") == "at_trigger":
history = [ts for ts in self.at_mention_history.get(room_id, []) if current_ts - ts <= at_burst_window]
self.at_mention_history[room_id] = history
if history and (current_ts - history[-1]) < at_min_interval:
trigger["_cooldown_reason"] = "at_min_interval"
return False
if len(history) >= at_burst_limit:
if (current_ts - history[-1]) < at_silent_sec:
trigger["_cooldown_reason"] = "at_burst_silent"
return False
self.at_mention_history[room_id] = []
self.at_mention_history.setdefault(room_id, []).append(current_ts)
self.user_reply_history.setdefault(user_key, []).append(current_ts)
return True
if trigger.get("is_question") or trigger.get("is_followup"):
trigger["_cooldown_reason"] = "followup_cooldown"
allowed = (current_ts - last_room_reply) >= user_cd
if allowed and (trigger.get("is_directed") or trigger.get("is_followup")):
self.user_reply_history.setdefault(user_key, []).append(current_ts)
return allowed
trigger["_cooldown_reason"] = "group_cooldown"
allowed = (current_ts - last_room_reply) >= room_cd
if allowed and trigger.get("is_directed"):
self.user_reply_history.setdefault(user_key, []).append(current_ts)
return allowed
def note_reply(self, room_id: str) -> None:
self.last_reply_at[room_id] = time.time()

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, time
from typing import Dict
@dataclass
class FlowState:
room_id: str
score: float = 0.0
state: str = "idle"
last_update: datetime = field(default_factory=datetime.now)
last_bot_reply_at: datetime | None = None
last_human_message_at: datetime | None = None
bot_reply_streak: int = 0
ignored_reply_count: int = 0
accepted_reply_count: int = 0
last_topic: str = ""
class FlowManager:
def __init__(self, config: Dict):
self.config = config or {}
self.states: Dict[str, FlowState] = {}
def get_state(self, room_id: str) -> FlowState:
if room_id not in self.states:
self.states[room_id] = FlowState(room_id=room_id)
return self.states[room_id]
def decay(self, room_id: str) -> FlowState:
state = self.get_state(room_id)
now = datetime.now()
elapsed_minutes = max((now - state.last_update).total_seconds() / 60.0, 0.0)
decay = elapsed_minutes * float(self.config.get("flow_decay_per_minute", 8))
state.score = max(0.0, state.score - decay)
state.last_update = now
state.state = self._score_to_state(state.score)
return state
def apply_message_event(self, room_id: str, event: Dict) -> FlowState:
state = self.decay(room_id)
now = datetime.now()
if self._is_night_silent(now.time()):
state.score = max(0.0, state.score - float(self.config.get("night_penalty", 30)))
if event.get("is_at"):
state.score += float(self.config.get("at_bot_boost", 40))
if event.get("is_question"):
state.score += float(self.config.get("question_boost", 30))
if event.get("is_followup"):
state.score += float(self.config.get("followup_boost", 20))
if event.get("topic_hit"):
state.score += float(self.config.get("topic_boost", 15))
if event.get("is_returning_member"):
state.score += float(self.config.get("returning_member_boost", 10))
if state.last_bot_reply_at:
since_reply = (now - state.last_bot_reply_at).total_seconds()
if since_reply <= 180 and event.get("message_after_bot"):
state.score += float(self.config.get("response_accepted_boost", 15))
state.accepted_reply_count += 1
state.bot_reply_streak = 0
state.last_human_message_at = now
state.last_topic = event.get("topic") or state.last_topic
state.state = self._score_to_state(state.score)
return state
def note_bot_reply(self, room_id: str) -> FlowState:
state = self.decay(room_id)
now = datetime.now()
if state.last_bot_reply_at and (not state.last_human_message_at or state.last_human_message_at < state.last_bot_reply_at):
since_last_bot_reply = (now - state.last_bot_reply_at).total_seconds()
if since_last_bot_reply <= 180:
state.ignored_reply_count += 1
state.score = max(0.0, state.score - float(self.config.get("ignored_reply_penalty", 20)))
state.last_bot_reply_at = datetime.now()
state.bot_reply_streak += 1
max_streak = int(self.config.get("max_bot_reply_streak", 3))
if state.bot_reply_streak > max_streak:
state.score = max(0.0, state.score - float(self.config.get("over_reply_penalty", 15)))
state.state = self._score_to_state(state.score)
return state
def get_acceptance_state(self, room_id: str) -> str:
state = self.get_state(room_id)
accepted = int(state.accepted_reply_count)
ignored = int(state.ignored_reply_count)
total = accepted + ignored
if total < 3:
return "neutral"
ratio = accepted / max(total, 1)
if ratio >= 0.7 and accepted >= 3:
return "warm"
if ratio <= 0.35 and ignored >= 2:
return "cold"
return "neutral"
def _score_to_state(self, score: float) -> str:
idle_threshold = float(self.config.get("idle_threshold", 20))
warming_threshold = float(self.config.get("warming_threshold", 40))
engaged_threshold = float(self.config.get("engaged_threshold", 70))
if score < idle_threshold:
return "idle"
if score < warming_threshold:
return "warming"
if score < engaged_threshold:
return "engaged"
return "deep_engaged"
def _is_night_silent(self, current_time: time) -> bool:
for window in self.config.get("night_silent_hours", []):
try:
start_str, end_str = window.split("-", 1)
start = time.fromisoformat(start_str)
end = time.fromisoformat(end_str)
if start <= end:
if start <= current_time <= end:
return True
else:
if current_time >= start or current_time <= end:
return True
except Exception:
continue
return False

View File

@@ -0,0 +1,113 @@
from __future__ import annotations
from typing import Any, Dict
from ..core.reply_formatter import preview_text
def build_log_summary(event: str, data: Dict[str, Any]) -> str:
room = short_id(data.get("room_id", ""))
sender_name = data.get("sender_name", "") or short_id(data.get("sender", ""))
sender = short_id(data.get("sender", ""))
if event == "recv":
return (
f"[XIAONIU] RECV room={room} user={sender_name}/{sender} "
f"at={yn(data.get('is_at'))} "
f"style={style_mark(data.get('humor_style', ''), data.get('sharpness_style', ''))} "
f"quote={data.get('quote_type', '-') or '-'} "
f"msg={data.get('content_preview', '')}"
).strip()
if event == "memory":
return (
f"[XIAONIU] MEMORY room={room} user={sender} "
f"ctx={yn(data.get('has_member_context'))} "
f"follow={yn(data.get('is_followup'))} "
f"return={data.get('returning_state', 'none')} "
f"links={data.get('social_links', 0)} "
f"facts={data.get('group_facts', 0)}"
).strip()
if event == "decision":
return (
f"[XIAONIU] DECIDE room={room} user={sender} "
f"trigger={data.get('trigger_type', 'none')} "
f"dir={data.get('directed', '-') or '-'} "
f"flow={data.get('flow_state', '')}:{data.get('flow_score', '')} "
f"topic={data.get('topic', '-') or '-'} "
f"reasons={data.get('reasons', '-') or '-'}"
).strip()
if event == "skip":
return (
f"[XIAONIU] SKIP room={room} user={sender} "
f"reason={data.get('reason', '')} "
f"trigger={data.get('trigger_type', 'none')} "
f"mode={data.get('reply_mode', '')} "
f"topic={data.get('topic', '-') or '-'} "
f"acc={data.get('acceptance_state', '-') or '-'} "
f"solver={data.get('solver', '-') or '-'}"
).strip()
if event == "context":
return (
f"[XIAONIU] CTX room={room} user={sender} "
f"mode={data.get('reply_mode', '')} "
f"acc={data.get('acceptance_state', '-') or '-'} "
f"recent={data.get('recent_message_count', 0)} "
f"vector={data.get('vector_hit_count', 0)} "
f"mem={data.get('member_focus_count', 0)} "
f"social={data.get('social_hit_count', 0)} "
f"facts={data.get('group_fact_hit_count', 0)} "
f"img={data.get('image_input_count', 0)} "
f"rank={preview_text(str(data.get('memory_rank_summary', '') or '-'), 108)}"
).strip()
if event == "model_empty":
return (
f"[XIAONIU] MODEL_EMPTY room={room} user={sender} "
f"model={data.get('model', '')} "
f"mode={data.get('reply_mode', '')} "
f"err={data.get('last_error', '')}"
).strip()
if event == "sent":
return (
f"[XIAONIU] SENT room={room} user={sender_name}/{sender} "
f"trigger={data.get('trigger_type', 'none')} "
f"mode={data.get('reply_mode', '')} "
f"topic={data.get('topic', '-') or '-'} "
f"chunks={data.get('chunk_count', 1)} "
f"len={data.get('response_len', 0)} "
f"reply={data.get('response_preview', '')}"
).strip()
if event == "memory_upsert":
return (
f"[XIAONIU] MEM_UPSERT room={room} user={sender} "
f"type={data.get('memory_type', '')} "
f"ok={yn(data.get('ok'))} "
f"trigger={data.get('trigger_type', '-') or '-'} "
f"err={preview_text(str(data.get('error', '') or '-'), 72)}"
).strip()
compact = " ".join(f"{key}={data[key]}" for key in sorted(data) if data.get(key) not in (None, ""))
return f"[XIAONIU] {event.upper()} {compact}".strip()
def yn(value: Any) -> str:
return "Y" if bool(value) else "N"
def short_id(value: str) -> str:
value = str(value or "")
if len(value) <= 10:
return value
return value[:4] + "..." + value[-4:]
def style_mark(humor_style: str, sharpness_style: str) -> str:
humor = "humor" if "中等" in str(humor_style) or "偏上" in str(humor_style) else "plain"
sharp = "sharp" if "毒舌" in str(sharpness_style) or "嘴欠" in str(sharpness_style) else "soft"
return f"{humor}/{sharp}"