refactor ai_auto_response plugin architecture
This commit is contained in:
7
plugins/ai_auto_response/runtime/__init__.py
Normal file
7
plugins/ai_auto_response/runtime/__init__.py
Normal 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"]
|
||||
67
plugins/ai_auto_response/runtime/cooldown.py
Normal file
67
plugins/ai_auto_response/runtime/cooldown.py
Normal 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()
|
||||
124
plugins/ai_auto_response/runtime/flow_manager.py
Normal file
124
plugins/ai_auto_response/runtime/flow_manager.py
Normal 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
|
||||
113
plugins/ai_auto_response/runtime/logging.py
Normal file
113
plugins/ai_auto_response/runtime/logging.py
Normal 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}"
|
||||
Reference in New Issue
Block a user