diff --git a/plugins/ai_auto_response/README.md b/plugins/ai_auto_response/README.md index a23f59d..04a024c 100644 --- a/plugins/ai_auto_response/README.md +++ b/plugins/ai_auto_response/README.md @@ -436,6 +436,16 @@ 当前 `bot_ai.py` 里的“体力值 + 参与度”可以保留,但应降级为“主动聊天限流器”,而不是总入口。 +这里建议再补两层非常关键的拟人化约束: + +- `group_acceptance` + 观察小牛发言后,群里后续是否自然接住。如果经常发完没人理,就降低主动度;如果经常有人顺着聊,才允许在非强触发场景更积极一点。 + +- `human_solver_suppression` + 如果最近几条里已经明显有群友在认真解题,小牛除非被 `@`,否则优先收着,避免像“抢答机器人”。 + +这两层加上后,小牛会更像一个会看场合的老成员,而不是看见关键词就扑上去。 + --- ## 向量记忆设计 diff --git a/plugins/ai_auto_response/flow_manager.py b/plugins/ai_auto_response/flow_manager.py index 5be7588..a55952e 100644 --- a/plugins/ai_auto_response/flow_manager.py +++ b/plugins/ai_auto_response/flow_manager.py @@ -59,6 +59,7 @@ class FlowManager: 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) @@ -66,6 +67,12 @@ class FlowManager: 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)) @@ -74,6 +81,20 @@ class FlowManager: 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)) diff --git a/plugins/ai_auto_response/main.py b/plugins/ai_auto_response/main.py index 65be9a1..d206322 100644 --- a/plugins/ai_auto_response/main.py +++ b/plugins/ai_auto_response/main.py @@ -163,6 +163,8 @@ class AIAutoResponsePlugin(MessagePluginInterface): "timestamp": message.get("timestamp"), } self._append_group_message(room_id, normalized_message) + recent_messages = self.group_messages.get(room_id) or self.memory_store.get_recent_messages(room_id) + conversation_hints = self._build_conversation_hints(recent_messages, sender, content) memory_hints = self.memory_store.build_memory_hints(room_id, sender) self._sync_member_memory(room_id, sender, sender_name, memory_hints.get("member_context", {})) @@ -198,8 +200,15 @@ class AIAutoResponsePlugin(MessagePluginInterface): ) allow_proactive = bool(self.mode_config.get("allow_proactive_reply", True)) + acceptance_state = self.flow_manager.get_acceptance_state(room_id) reply_mode = self.response_planner.choose_reply_mode(trigger.__dict__, flow_state.state) - should_reply = self.response_planner.should_reply(trigger.__dict__, flow_state.state, allow_proactive) + should_reply = self.response_planner.should_reply( + trigger.__dict__, + flow_state.state, + allow_proactive, + acceptance_state, + conversation_hints, + ) if not should_reply: self._log_event( "skip", @@ -209,6 +218,8 @@ class AIAutoResponsePlugin(MessagePluginInterface): trigger_type=trigger.trigger_type, reply_mode=reply_mode, flow_state=flow_state.state, + acceptance_state=acceptance_state, + solver=self._yn(conversation_hints.get("has_recent_human_solver")), ) return False, "skip" if not self._pass_cooldown(room_id, trigger.__dict__): @@ -222,7 +233,6 @@ class AIAutoResponsePlugin(MessagePluginInterface): ) return False, "cooldown" - recent_messages = self.group_messages.get(room_id) or self.memory_store.get_recent_messages(room_id) vector_memories = [] if self.vector_memory.should_search(reply_mode, trigger.trigger_type, memory_hints.get("returning_member_state", "")): vector_memories = self.vector_memory.search(content, room_id, sender) @@ -232,6 +242,7 @@ class AIAutoResponsePlugin(MessagePluginInterface): sender=sender, group_mode=group_profile.get("mode", ""), knowledge_domain=group_profile.get("knowledge_domain", ""), + acceptance_state=acceptance_state, reply_mode=reply_mode, recent_message_count=len(recent_messages), vector_hit_count=len(vector_memories), @@ -372,6 +383,54 @@ class AIAutoResponsePlugin(MessagePluginInterface): f"12. 回答时优先服从当前群画像里的知识域和回答风格,不要跨领域乱发挥。\n" ) + @staticmethod + def _build_conversation_hints(recent_messages: List[Dict], current_sender: str, current_content: str) -> Dict[str, Any]: + previous_messages = list(recent_messages[:-1]) if recent_messages else [] + recent_window = previous_messages[-4:] + solver_count = 0 + solver_senders = set() + current_tokens = AIAutoResponsePlugin._extract_overlap_tokens(current_content) + for item in recent_window: + sender = str(item.get("sender", "") or "") + if not sender or sender == current_sender: + continue + content = str(item.get("content") or item.get("message") or "").strip().lower() + if AIAutoResponsePlugin._looks_like_answer(content) and AIAutoResponsePlugin._has_topic_overlap(current_tokens, content): + solver_count += 1 + solver_senders.add(sender) + return { + "has_recent_human_solver": solver_count >= 2 and len(solver_senders) >= 1, + "solver_count": solver_count, + } + + @staticmethod + def _looks_like_answer(content: str) -> bool: + if not content: + return False + answer_keywords = [ + "先", "然后", "重启", "配置", "日志", "接口", "看一下", "试试", "排查", + "报错", "原因", "因为", "改成", "装", "部署", "重现", "检查", "确认", + ] + if len(content) >= 18: + return True + return any(keyword in content for keyword in answer_keywords) + + @staticmethod + def _extract_overlap_tokens(content: str) -> set[str]: + text = str(content or "").lower() + tokens = set(re.findall(r"[a-z0-9_\\-]{3,}", text)) + for keyword in ["报错", "日志", "配置", "接口", "插件", "部署", "docker", "python", "openclaw", "机器人", "qdrant", "ollama"]: + if keyword in text: + tokens.add(keyword) + return tokens + + @staticmethod + def _has_topic_overlap(current_tokens: set[str], previous_content: str) -> bool: + if not current_tokens: + return False + previous_tokens = AIAutoResponsePlugin._extract_overlap_tokens(previous_content) + return bool(current_tokens & previous_tokens) + @staticmethod def _sanitize_response(response: str) -> str: if not response: @@ -527,13 +586,16 @@ class AIAutoResponsePlugin(MessagePluginInterface): 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"mode={data.get('reply_mode', '')} " + 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)}" ).strip() diff --git a/plugins/ai_auto_response/response_planner.py b/plugins/ai_auto_response/response_planner.py index a42b431..8a85ffa 100644 --- a/plugins/ai_auto_response/response_planner.py +++ b/plugins/ai_auto_response/response_planner.py @@ -15,9 +15,19 @@ class ResponsePlanner: return "social_short" return "social_short" if flow_state in {"deep_engaged"} else "refuse_or_skip" - def should_reply(self, trigger: Dict, flow_state: str, allow_proactive: bool) -> bool: + def should_reply( + self, + trigger: Dict, + flow_state: str, + allow_proactive: bool, + acceptance_state: str = "neutral", + conversation_hints: Dict | None = None, + ) -> bool: + conversation_hints = conversation_hints or {} if trigger.get("is_at"): return True + if trigger.get("is_question") and conversation_hints.get("has_recent_human_solver") and flow_state != "deep_engaged": + return False if trigger.get("is_question"): return True if trigger.get("is_followup"): @@ -28,4 +38,8 @@ class ResponsePlanner: return False if not allow_proactive: return False - return flow_state in {"deep_engaged"} and trigger.get("priority", 0) >= 0.65 + if acceptance_state == "cold": + return False + if acceptance_state == "neutral": + return flow_state in {"deep_engaged"} and trigger.get("priority", 0) >= 0.8 + return flow_state in {"engaged", "deep_engaged"} and trigger.get("priority", 0) >= 0.65