994 lines
43 KiB
Python
994 lines
43 KiB
Python
from __future__ import annotations
|
||
|
||
import base64
|
||
import html
|
||
import imghdr
|
||
import re
|
||
import time
|
||
import xml.etree.ElementTree as ET
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from loguru import logger
|
||
|
||
from base.plugin_common.message_plugin_interface import MessagePluginInterface
|
||
from base.plugin_common.plugin_interface import PluginStatus
|
||
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
|
||
from utils.wechat.contact_manager import ContactManager
|
||
from wechat_ipad import WechatAPIClient
|
||
from wechat_ipad.models.message import MessageType
|
||
|
||
from .context_builder import ContextBuilder
|
||
from .flow_manager import FlowManager
|
||
from .group_memory import GroupMemoryService
|
||
from .group_profile import GroupProfileResolver
|
||
from .llm_client import LLMClient
|
||
from .memory_store import MemoryStore
|
||
from .persona_engine import PersonaEngine
|
||
from .response_planner import ResponsePlanner
|
||
from .triggers import TriggerRouter
|
||
from .vector_memory import VectorMemoryStore
|
||
|
||
|
||
class AIAutoResponsePlugin(MessagePluginInterface):
|
||
FEATURE_KEY = "AI_AUTO_RESPONSE"
|
||
FEATURE_DESCRIPTION = "🐮 小牛拟人群聊BOT [群聊拟真、及时答疑、长期记忆]"
|
||
|
||
@property
|
||
def name(self) -> str:
|
||
return "小牛群聊BOT"
|
||
|
||
@property
|
||
def version(self) -> str:
|
||
return "2.0.0"
|
||
|
||
@property
|
||
def description(self) -> str:
|
||
return "拟人化群聊BOT,支持心流、长期记忆和回归成员识别"
|
||
|
||
@property
|
||
def author(self) -> str:
|
||
return "ABOT Team"
|
||
|
||
@property
|
||
def command_prefix(self) -> Optional[str]:
|
||
return None
|
||
|
||
@property
|
||
def commands(self) -> List[str]:
|
||
return []
|
||
|
||
@property
|
||
def feature_key(self) -> Optional[str]:
|
||
return self.FEATURE_KEY
|
||
|
||
@property
|
||
def feature_description(self) -> Optional[str]:
|
||
return self.FEATURE_DESCRIPTION
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.feature = self.register_feature()
|
||
self.group_messages: Dict[str, List[Dict]] = {}
|
||
self.enable = True
|
||
self.last_reply_at: Dict[str, float] = {}
|
||
self.at_mention_history: Dict[str, List[float]] = {}
|
||
|
||
def initialize(self, context: Dict[str, Any]) -> bool:
|
||
self.LOG = logger
|
||
self.db_manager = context.get("db_manager")
|
||
self.enable = bool(self._config.get("enable", True))
|
||
self.persona_engine = PersonaEngine(self.get_plugin_path(), self._config.get("persona", {}))
|
||
self.group_memory_service = GroupMemoryService(self.db_manager, self._config.get("group_profiles", {}) or {})
|
||
self.group_profile_resolver = GroupProfileResolver(self._config.get("group_profiles", {}) or {})
|
||
self.flow_manager = FlowManager({
|
||
**(self._config.get("flow", {}) or {}),
|
||
"night_silent_hours": (self._config.get("cooldown", {}) or {}).get("night_silent_hours", []),
|
||
})
|
||
merged_trigger_config = dict(self._config.get("priority", {}) or {})
|
||
merged_trigger_config.update(self._config.get("topics", {}) or {})
|
||
self.trigger_router = TriggerRouter(merged_trigger_config)
|
||
merged_memory_config = dict(self._config.get("mode", {}) or {})
|
||
merged_memory_config.update(self._config.get("memory", {}) or {})
|
||
self.memory_store = MemoryStore(self.db_manager, merged_memory_config)
|
||
self.vector_memory = VectorMemoryStore(self._config.get("memory", {}) or {})
|
||
self.context_builder = ContextBuilder(int((self._config.get("mode", {}) or {}).get("recent_context_size", 30)))
|
||
self.response_planner = ResponsePlanner()
|
||
self.llm_client = LLMClient(self._config.get("api", {}) or {})
|
||
self.filters = self._config.get("filters", {}) or {}
|
||
self.mode_config = self._config.get("mode", {}) or {}
|
||
self.cooldown_config = self._config.get("cooldown", {}) or {}
|
||
self._synced_member_context_versions: Dict[str, str] = {}
|
||
self.log_debug = bool((self._config.get("logging", {}) or {}).get("debug", True))
|
||
self.LOG.debug(f"[{self.name}] 初始化完成")
|
||
return True
|
||
|
||
def start(self) -> bool:
|
||
self.status = PluginStatus.RUNNING
|
||
return True
|
||
|
||
def stop(self) -> bool:
|
||
self.status = PluginStatus.STOPPED
|
||
return True
|
||
|
||
def can_process(self, message: Dict[str, Any]) -> bool:
|
||
if not self.enable:
|
||
return False
|
||
room_id = message.get("roomid", "")
|
||
if not room_id:
|
||
return False
|
||
if GroupBotManager.get_group_permission(room_id, self.feature) == PermissionStatus.DISABLED:
|
||
return False
|
||
|
||
msg_type = message.get("type")
|
||
if msg_type not in (MessageType.TEXT, MessageType.APP):
|
||
return False
|
||
|
||
full_msg = message.get("full_wx_msg")
|
||
if full_msg and full_msg.from_self():
|
||
return False
|
||
|
||
content = self._normalize_content(message)
|
||
if not content:
|
||
return False
|
||
if self._should_ignore(content):
|
||
return False
|
||
if self._is_targeting_other_user(message):
|
||
return False
|
||
return True
|
||
|
||
async def process_message(self, message: Dict[str, Any]) -> Tuple[bool, Optional[str]]:
|
||
room_id = message.get("roomid", "")
|
||
sender = message.get("sender", "")
|
||
bot: WechatAPIClient = message.get("bot")
|
||
content = self._normalize_content(message)
|
||
quote_context = self._parse_quote_context(message.get("full_wx_msg"), room_id)
|
||
sender_name = self._get_sender_name(room_id, sender)
|
||
group_name = self._get_group_name(room_id, message)
|
||
group_memory_profile = self.group_memory_service.build_group_memory_profile(room_id, group_name)
|
||
group_profile = self.group_profile_resolver.resolve(room_id, group_name, group_memory_profile)
|
||
self._log_event(
|
||
"recv",
|
||
room_id=room_id,
|
||
sender=sender,
|
||
sender_name=sender_name,
|
||
group_mode=group_profile.get("mode", ""),
|
||
knowledge_domain=group_profile.get("knowledge_domain", ""),
|
||
memory_domain=group_profile.get("group_memory_domain", ""),
|
||
humor_style=group_profile.get("humor_style", ""),
|
||
sharpness_style=group_profile.get("sharpness_style", ""),
|
||
is_at=message.get("is_at", False),
|
||
content_preview=self._preview(content),
|
||
quote_type=quote_context.get("quote_type_label", ""),
|
||
msg_type=str(message.get("type")),
|
||
)
|
||
|
||
normalized_message = {
|
||
"sender": sender,
|
||
"sender_name": sender_name,
|
||
"content": content,
|
||
"is_at": bool(message.get("is_at", False)),
|
||
"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,
|
||
quote_context,
|
||
self.persona_engine.config.get("name", "小牛"),
|
||
)
|
||
|
||
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", {}))
|
||
self._log_event(
|
||
"memory",
|
||
room_id=room_id,
|
||
sender=sender,
|
||
returning_state=memory_hints.get("returning_member_state", "") or "none",
|
||
has_member_context=bool(memory_hints.get("member_context")),
|
||
is_followup=memory_hints.get("is_followup", False),
|
||
last_active_at=memory_hints.get("last_active_at", "") or "",
|
||
)
|
||
trigger = self.trigger_router.route(message | {"content": content}, memory_hints, conversation_hints)
|
||
flow_state = self.flow_manager.apply_message_event(room_id, {
|
||
"is_at": message.get("is_at", False),
|
||
"is_question": trigger.is_question,
|
||
"is_followup": trigger.is_followup,
|
||
"topic_hit": bool(trigger.topic),
|
||
"topic": trigger.topic,
|
||
"is_returning_member": trigger.is_returning_member,
|
||
"message_after_bot": True,
|
||
})
|
||
self._log_event(
|
||
"decision",
|
||
room_id=room_id,
|
||
sender=sender,
|
||
trigger_type=trigger.trigger_type,
|
||
priority=trigger.priority,
|
||
reasons="|".join(trigger.reasons),
|
||
directed=self._yn(trigger.is_directed),
|
||
flow_state=flow_state.state,
|
||
flow_score=round(flow_state.score, 2),
|
||
topic=trigger.topic or "",
|
||
)
|
||
|
||
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,
|
||
acceptance_state,
|
||
conversation_hints,
|
||
)
|
||
if not should_reply:
|
||
self._log_event(
|
||
"skip",
|
||
room_id=room_id,
|
||
sender=sender,
|
||
reason="planner_skip",
|
||
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__):
|
||
self._log_event(
|
||
"skip",
|
||
room_id=room_id,
|
||
sender=sender,
|
||
reason=trigger.__dict__.get("_cooldown_reason", "cooldown"),
|
||
trigger_type=trigger.trigger_type,
|
||
reply_mode=reply_mode,
|
||
)
|
||
return False, "cooldown"
|
||
|
||
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)
|
||
image_context = self._build_recent_image_context(message, room_id, content, quote_context)
|
||
image_urls = await self._prepare_quote_image_inputs(bot, quote_context)
|
||
if not image_urls and image_context:
|
||
recent_image_url = self._build_local_image_data_url(str(image_context.get("image_path", "") or ""))
|
||
if recent_image_url:
|
||
image_urls = [recent_image_url]
|
||
self._log_event(
|
||
"context",
|
||
room_id=room_id,
|
||
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),
|
||
image_input_count=len(image_urls),
|
||
)
|
||
|
||
context = self.context_builder.build(
|
||
room_id=room_id,
|
||
group_profile=group_profile,
|
||
sender=sender,
|
||
sender_name=sender_name,
|
||
content=content,
|
||
recent_messages=recent_messages,
|
||
member_context=memory_hints.get("member_context", {}),
|
||
trigger=trigger.__dict__,
|
||
flow_state=flow_state.state,
|
||
reply_mode=reply_mode,
|
||
vector_memories=vector_memories,
|
||
quote_context=quote_context | {"has_image_attachment": bool(image_urls)},
|
||
image_context=image_context,
|
||
)
|
||
|
||
system_prompt = self.persona_engine.build_system_prompt(group_profile)
|
||
user_prompt = self._build_user_prompt(context, memory_hints)
|
||
response = self._sanitize_response(
|
||
self.llm_client.chat(
|
||
system_prompt,
|
||
user_prompt,
|
||
user_id=f"{room_id}:{sender}",
|
||
image_urls=image_urls,
|
||
),
|
||
content,
|
||
)
|
||
if not response:
|
||
self._log_event(
|
||
"model_empty",
|
||
room_id=room_id,
|
||
sender=sender,
|
||
model=self.llm_client.model,
|
||
last_error=self.llm_client.last_error,
|
||
reply_mode=reply_mode,
|
||
)
|
||
return False, "empty_response"
|
||
|
||
reply_chunks = self._finalize_reply(response, reply_mode)
|
||
|
||
for chunk in reply_chunks:
|
||
await bot.send_text_message(room_id, chunk, sender)
|
||
self.last_reply_at[room_id] = time.time()
|
||
self.flow_manager.note_bot_reply(room_id)
|
||
self.memory_store.note_bot_reply(room_id, sender, trigger.topic)
|
||
final_response_text = "\n".join(reply_chunks)
|
||
self._upsert_interaction_memory(room_id, sender, sender_name, content, final_response_text, trigger.trigger_type, trigger.topic)
|
||
self._log_event(
|
||
"sent",
|
||
room_id=room_id,
|
||
sender=sender,
|
||
sender_name=sender_name,
|
||
trigger_type=trigger.trigger_type,
|
||
reply_mode=reply_mode,
|
||
response_preview=self._preview(final_response_text),
|
||
response_len=len(final_response_text),
|
||
chunk_count=len(reply_chunks),
|
||
)
|
||
return False, "replied"
|
||
|
||
def _append_group_message(self, room_id: str, message: Dict) -> None:
|
||
items = self.group_messages.setdefault(room_id, [])
|
||
items.append(message)
|
||
size = int(self.mode_config.get("recent_context_size", 30))
|
||
if len(items) > size:
|
||
self.group_messages[room_id] = items[-size:]
|
||
|
||
def _normalize_content(self, message: Dict[str, Any]) -> str:
|
||
msg_type = message.get("type")
|
||
content = str(message.get("content", "")).strip()
|
||
if msg_type == MessageType.TEXT:
|
||
return self._strip_at_prefix(content)
|
||
if msg_type == MessageType.APP:
|
||
try:
|
||
root = ET.fromstring(content)
|
||
title = root.find(".//title")
|
||
return (title.text or "").strip() if title is not None else "[应用消息]"
|
||
except Exception:
|
||
return "[应用消息]"
|
||
return content
|
||
|
||
@staticmethod
|
||
def _strip_at_prefix(content: str) -> str:
|
||
return re.sub(r"@.*?[\u2005\s]+", "", content).strip()
|
||
|
||
def _should_ignore(self, content: str) -> bool:
|
||
if len(content) < int(self.filters.get("min_text_length", 1)):
|
||
return True
|
||
if content in set(self.filters.get("ignore_exact", [])):
|
||
return True
|
||
return any(content.startswith(prefix) for prefix in self.filters.get("ignore_prefixes", []))
|
||
|
||
def _is_targeting_other_user(self, message: Dict[str, Any]) -> bool:
|
||
if message.get("is_at", False):
|
||
return False
|
||
raw_content = str(message.get("content", "") or "")
|
||
return "@" in raw_content
|
||
|
||
def _get_sender_name(self, room_id: str, sender: str) -> str:
|
||
try:
|
||
members = ContactManager.get_instance().get_group_members(room_id)
|
||
return members.get(sender, sender)
|
||
except Exception:
|
||
return sender
|
||
|
||
@staticmethod
|
||
def _get_group_name(room_id: str, message: Dict[str, Any]) -> str:
|
||
all_contacts = message.get("all_contacts", {}) or {}
|
||
return str(all_contacts.get(room_id, room_id))
|
||
|
||
def _pass_cooldown(self, room_id: str, trigger: Dict) -> bool:
|
||
current_ts = time.time()
|
||
room_cd = int(self.cooldown_config.get("group_reply_cooldown_sec", 45))
|
||
user_cd = int(self.cooldown_config.get("same_user_followup_cooldown_sec", 10))
|
||
at_min_interval = int(self.cooldown_config.get("at_mention_min_interval_sec", 8))
|
||
at_burst_window = int(self.cooldown_config.get("at_mention_burst_window_sec", 90))
|
||
at_burst_limit = int(self.cooldown_config.get("at_mention_burst_limit", 4))
|
||
at_silent_sec = int(self.cooldown_config.get("at_mention_silent_sec", 180))
|
||
last_room_reply = self.last_reply_at.get(room_id, 0.0)
|
||
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)
|
||
return True
|
||
if trigger.get("is_question") or trigger.get("is_followup"):
|
||
trigger["_cooldown_reason"] = "followup_cooldown"
|
||
return (current_ts - last_room_reply) >= user_cd
|
||
trigger["_cooldown_reason"] = "group_cooldown"
|
||
return (current_ts - last_room_reply) >= room_cd
|
||
|
||
def _build_user_prompt(self, context: Dict, memory_hints: Dict) -> str:
|
||
recent_text = "\n".join(context.get("recent_messages", [])) or "暂无"
|
||
reply_mode = context.get("reply_mode", "social_short")
|
||
length_rule = self._build_length_rule(reply_mode)
|
||
group_profile = context.get("group_profile", {}) or {}
|
||
speaker_name = str(context.get("speaker_name_clean", "") or "").strip()
|
||
trigger_type = str(context.get("trigger_type", "none") or "none")
|
||
address_style = str(group_profile.get("address_style", "低频称呼,默认直接接话") or "低频称呼,默认直接接话")
|
||
name_rule = f"15. 称呼风格遵守当前群的要求:{address_style}。默认不要带对方昵称,直接接话。"
|
||
if speaker_name and trigger_type in {"at_trigger", "directed_question", "social_call"}:
|
||
name_rule = (
|
||
f"15. 称呼风格遵守当前群的要求:{address_style}。"
|
||
f"这次可以视场景偶尔自然带一下对方称呼“{speaker_name}”,但不是必须。"
|
||
f"如果要带,位置不要固定在句首,也不要每次都带,更不要像客服点名或脚本播报。"
|
||
)
|
||
extra_rule = ""
|
||
if group_profile.get("knowledge_domain") == "dota":
|
||
extra_rule = "16. 如果对方问的是 Dota2 最近战绩、实时战绩、最新对局数据,你要委婉说明现在没法提取这类数据,只能聊理解和常识,不要硬编。\n"
|
||
return (
|
||
f"当前群聊消息:\n{recent_text}\n\n"
|
||
f"当前发言:{context.get('current_message', '')}\n"
|
||
f"引用补充:\n{context.get('quote_prompt', '') or '无'}\n"
|
||
f"图片补充:\n{context.get('image_prompt', '') or '无'}\n"
|
||
f"触发类型:{context.get('trigger_type', 'none')}\n"
|
||
f"回复模式:{context.get('reply_mode', 'social_short')}\n"
|
||
f"当前心流状态:{context.get('flow_state', 'idle')}\n"
|
||
f"当前群画像:\n{context.get('group_profile_prompt', '暂无')}\n\n"
|
||
f"成员稳定记忆:\n{context.get('memory_prompt', '暂无')}\n\n"
|
||
f"向量召回记忆:\n{context.get('vector_memory_prompt', '') or '暂无'}\n\n"
|
||
f"补充信息:回归状态={memory_hints.get('returning_member_state', '') or 'none'}\n"
|
||
f"要求:\n"
|
||
f"1. 如果是明确问题,先给清楚答案。\n"
|
||
f"2. 如果只是轻量接话,保持自然短句。\n"
|
||
f"3. 不要暴露系统记忆来源。\n"
|
||
f"4. 如果信息不足,不要硬编。\n"
|
||
f"5. 输出最终可直接发到群里的内容,不要解释你的思路。\n"
|
||
f"6. {length_rule}\n"
|
||
f"7. 优先直接回应“当前发言”本身,不要被较早上下文带跑。\n"
|
||
f"8. 成员记忆和向量召回只有在与当前问题直接相关时才允许使用,否则忽略。\n"
|
||
f"9. 如果你不确定自己是否理解对了,就宁可不展开,只回很短。\n"
|
||
f"10. 把这次回复当作真人聊天里的第一反应,先只给第一层结论,不要主动补第二层解释。\n"
|
||
f"11. 如果一句话已经够了,就立刻停,不要为了完整而补充。\n"
|
||
f"12. 回答时优先服从当前群画像里的知识域和回答风格,不要跨领域乱发挥。\n"
|
||
f"13. 如果成员画像里有对当前问题明显相关的长期兴趣、技能侧重点、回复偏好或近期状态,可以轻微利用这些信息调节措辞、切入角度和详略,但要像你本来就记得这个人,不要表现得像在背资料。\n"
|
||
f"14. 如果成员画像里出现回复禁忌、对某种沟通方式明显反感,尽量避开那种说法。\n"
|
||
f"{name_rule}\n"
|
||
f"{extra_rule}"
|
||
)
|
||
|
||
@staticmethod
|
||
def _build_conversation_hints(
|
||
recent_messages: List[Dict],
|
||
current_sender: str,
|
||
current_content: str,
|
||
quote_context: Dict[str, Any],
|
||
bot_name: 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)
|
||
previous_same_sender_directed = False
|
||
same_sender_recent_count = 0
|
||
bot_name_lower = str(bot_name or "").lower()
|
||
for item in reversed(previous_messages[-6:]):
|
||
sender = str(item.get("sender", "") or "")
|
||
if sender != current_sender:
|
||
continue
|
||
same_sender_recent_count += 1
|
||
content = str(item.get("content") or item.get("message") or "").strip().lower()
|
||
if bool(item.get("is_at")) or (bot_name_lower and bot_name_lower in content):
|
||
previous_same_sender_directed = True
|
||
break
|
||
quote_targets_bot = False
|
||
quote_sender_name = str(quote_context.get("quote_sender_name", "") or "").strip().lower()
|
||
if quote_sender_name and bot_name_lower and bot_name_lower in quote_sender_name:
|
||
quote_targets_bot = True
|
||
return {
|
||
"has_recent_human_solver": solver_count >= 2 and len(solver_senders) >= 1,
|
||
"solver_count": solver_count,
|
||
"previous_same_sender_directed": previous_same_sender_directed,
|
||
"same_sender_recent_count": same_sender_recent_count,
|
||
"quote_targets_bot": quote_targets_bot,
|
||
}
|
||
|
||
@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, current_content: str = "") -> str:
|
||
if not response:
|
||
return ""
|
||
response = response.strip()
|
||
response = re.sub(r"\n{3,}", "\n\n", response)
|
||
current_content = str(current_content or "").strip()
|
||
if not response:
|
||
return ""
|
||
if current_content and AIAutoResponsePlugin._looks_like_prompt_echo(response, current_content):
|
||
return ""
|
||
if AIAutoResponsePlugin._looks_like_invalid_structured_reply(response, current_content):
|
||
return ""
|
||
return response[:500].strip()
|
||
|
||
@staticmethod
|
||
def _looks_like_prompt_echo(response: str, current_content: str) -> bool:
|
||
normalized_response = re.sub(r"\s+", "", str(response or ""))
|
||
normalized_current = re.sub(r"\s+", "", str(current_content or ""))
|
||
if not normalized_response or not normalized_current:
|
||
return False
|
||
return normalized_response == normalized_current
|
||
|
||
@staticmethod
|
||
def _looks_like_invalid_structured_reply(response: str, current_content: str) -> bool:
|
||
text = str(response or "").strip()
|
||
if not (text.startswith("{") and text.endswith("}")):
|
||
return False
|
||
try:
|
||
data = json.loads(text)
|
||
except Exception:
|
||
return False
|
||
if not isinstance(data, dict):
|
||
return False
|
||
keys = {str(key).strip().lower() for key in data.keys()}
|
||
if not keys:
|
||
return False
|
||
if keys.issubset({"category", "message", "content", "text", "type"}):
|
||
for field in ("message", "content", "text"):
|
||
value = str(data.get(field, "") or "").strip()
|
||
if not value:
|
||
continue
|
||
if AIAutoResponsePlugin._looks_like_prompt_echo(value, current_content):
|
||
return True
|
||
if "category" in keys:
|
||
return True
|
||
return False
|
||
|
||
def _finalize_reply(self, response: str, reply_mode: str) -> List[str]:
|
||
text = (response or "").strip()
|
||
if not text:
|
||
return []
|
||
text = re.sub(r"\s+", " ", text)
|
||
text = text.replace("\n", " ").strip()
|
||
|
||
if reply_mode == "social_short":
|
||
return [self._take_first_sentence(text, 12).strip()]
|
||
elif reply_mode == "qa_fast":
|
||
return self._split_reply_chunks(text, sentence_limit=2, char_limit=28, chunk_limit=2)
|
||
elif reply_mode == "qa_with_context":
|
||
return self._split_reply_chunks(text, sentence_limit=2, char_limit=36, chunk_limit=2)
|
||
return [self._take_first_sentence(text, 24).strip()]
|
||
|
||
@staticmethod
|
||
def _build_length_rule(reply_mode: str) -> str:
|
||
if reply_mode == "social_short":
|
||
return "默认只回一句短话,最好控制在2到8个字,除非非常不自然。"
|
||
if reply_mode == "qa_fast":
|
||
return "优先1句话;如果确实需要,可以拆成2条很短的话发出,总长度每条优先控制在28字内,先给结论,不要主动补解释。"
|
||
if reply_mode == "qa_with_context":
|
||
return "优先控制在1句话;必要时可以拆成2条短消息发出,每条优先控制在36字内,只给第一层答案。"
|
||
return "尽量短,像群友临时接一句,不要长篇大论。"
|
||
|
||
@staticmethod
|
||
def _take_first_sentence(text: str, limit: int) -> str:
|
||
parts = re.split(r"(?<=[。!?!?;;])", text)
|
||
first = parts[0].strip() if parts and parts[0].strip() else text.strip()
|
||
if len(first) <= limit:
|
||
return first
|
||
clipped = AIAutoResponsePlugin._smart_clip(first, limit)
|
||
return clipped
|
||
|
||
@staticmethod
|
||
def _split_reply_chunks(text: str, sentence_limit: int, char_limit: int, chunk_limit: int) -> List[str]:
|
||
parts = [item.strip() for item in re.split(r"(?<=[。!?!?;;])", text) if item.strip()]
|
||
if not parts:
|
||
short = text.strip()
|
||
clipped = AIAutoResponsePlugin._smart_clip(short, char_limit)
|
||
remainder = short[len(clipped):].strip(",,、;;:: ")
|
||
return [item for item in [clipped, AIAutoResponsePlugin._smart_clip(remainder, char_limit)] if item][:chunk_limit] if short else []
|
||
|
||
chunks: List[str] = []
|
||
for part in parts[:sentence_limit]:
|
||
current = part.strip()
|
||
while current and len(chunks) < chunk_limit:
|
||
if len(current) <= char_limit:
|
||
chunks.append(current.strip())
|
||
break
|
||
clipped = AIAutoResponsePlugin._smart_clip(current, char_limit)
|
||
if not clipped:
|
||
clipped = current[:char_limit].rstrip(",,、;;:: ").strip()
|
||
if clipped:
|
||
chunks.append(clipped)
|
||
current = current[len(clipped):].strip(",,、;;:: ")
|
||
return chunks[:chunk_limit] or [AIAutoResponsePlugin._smart_clip(text, char_limit)]
|
||
|
||
@staticmethod
|
||
def _smart_clip(text: str, limit: int) -> str:
|
||
text = str(text or "").strip()
|
||
if len(text) <= limit:
|
||
return text
|
||
window = text[:limit]
|
||
punctuation = ",,、;;::。!?!?))】]」』 "
|
||
split_at = -1
|
||
for idx in range(len(window) - 1, max(len(window) - 10, 0) - 1, -1):
|
||
if window[idx] in punctuation:
|
||
split_at = idx
|
||
break
|
||
if split_at >= 0:
|
||
return window[:split_at].rstrip(",,、;;::。!?!? ").strip()
|
||
return window.rstrip(",,、;;:: ").strip()
|
||
|
||
def _sync_member_memory(self, room_id: str, sender: str, sender_name: str, member_context: Dict) -> None:
|
||
if not member_context:
|
||
return
|
||
version = str(member_context.get("last_profiled_at", ""))
|
||
cache_key = f"{room_id}:{sender}"
|
||
if version and self._synced_member_context_versions.get(cache_key) == version:
|
||
return
|
||
text = self.context_builder._build_member_memory_prompt(member_context)
|
||
if not text or text == "暂无稳定成员画像。":
|
||
return
|
||
payload = {
|
||
"chatroom_id": room_id,
|
||
"wxid": sender,
|
||
"display_name": sender_name,
|
||
"memory_type": "member_context_snapshot",
|
||
"source_id": cache_key,
|
||
"last_active_at": member_context.get("last_profiled_at", ""),
|
||
"topic_tags": member_context.get("topics_of_interest", [])[:5],
|
||
"summary_text": member_context.get("summary_text", ""),
|
||
}
|
||
ok = self.vector_memory.upsert_memory(f"member_context:{cache_key}:{version}", text, payload)
|
||
self._log_event(
|
||
"memory_upsert",
|
||
room_id=room_id,
|
||
sender=sender,
|
||
memory_type="member_context_snapshot",
|
||
ok=ok,
|
||
error=self.vector_memory.last_error,
|
||
)
|
||
if ok and version:
|
||
self._synced_member_context_versions[cache_key] = version
|
||
|
||
def _upsert_interaction_memory(
|
||
self,
|
||
room_id: str,
|
||
sender: str,
|
||
sender_name: str,
|
||
content: str,
|
||
response: str,
|
||
trigger_type: str,
|
||
topic: str,
|
||
) -> None:
|
||
text = f"{sender_name}说:{content}\n小牛回复:{response}"
|
||
payload = {
|
||
"chatroom_id": room_id,
|
||
"wxid": sender,
|
||
"display_name": sender_name,
|
||
"memory_type": "interaction_memory",
|
||
"topic_tags": [item for item in [topic, trigger_type] if item],
|
||
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
"source_id": f"{room_id}:{sender}:{int(time.time())}",
|
||
"summary_text": text[:500],
|
||
}
|
||
ok = self.vector_memory.upsert_memory(payload["source_id"], text, payload)
|
||
self._log_event(
|
||
"memory_upsert",
|
||
room_id=room_id,
|
||
sender=sender,
|
||
memory_type="interaction_memory",
|
||
ok=ok,
|
||
trigger_type=trigger_type,
|
||
error=self.vector_memory.last_error,
|
||
)
|
||
|
||
def _log_event(self, event: str, **kwargs: Any) -> None:
|
||
if not self.log_debug:
|
||
return
|
||
summary = self._build_log_summary(event, kwargs)
|
||
self.LOG.debug(summary)
|
||
|
||
@staticmethod
|
||
def _preview(text: str, limit: int = 80) -> str:
|
||
text = (text or "").replace("\n", "\\n").strip()
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: limit - 3] + "..."
|
||
|
||
def _build_log_summary(self, event: str, data: Dict[str, Any]) -> str:
|
||
room = self._short_id(data.get("room_id", ""))
|
||
sender_name = data.get("sender_name", "") or self._short_id(data.get("sender", ""))
|
||
sender = self._short_id(data.get("sender", ""))
|
||
|
||
if event == "recv":
|
||
return (
|
||
f"[XIAONIU] RECV room={room} user={sender_name}/{sender} "
|
||
f"at={self._yn(data.get('is_at'))} "
|
||
f"style={self._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={self._yn(data.get('has_member_context'))} "
|
||
f"follow={self._yn(data.get('is_followup'))} "
|
||
f"return={data.get('returning_state', 'none')}"
|
||
).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"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"img={data.get('image_input_count', 0)}"
|
||
).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"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={self._yn(data.get('ok'))} "
|
||
f"trigger={data.get('trigger_type', '-') or '-'} "
|
||
f"err={self._preview(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()
|
||
|
||
@staticmethod
|
||
def _yn(value: Any) -> str:
|
||
return "Y" if bool(value) else "N"
|
||
|
||
@staticmethod
|
||
def _short_id(value: str) -> str:
|
||
value = str(value or "")
|
||
if len(value) <= 10:
|
||
return value
|
||
return value[:4] + "..." + value[-4:]
|
||
|
||
@staticmethod
|
||
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}"
|
||
|
||
def _parse_quote_context(self, full_msg: Any, room_id: str) -> Dict[str, str]:
|
||
if not full_msg or not getattr(full_msg, "content", None):
|
||
return {}
|
||
xml_content = getattr(full_msg.content, "xml_content", "") or ""
|
||
if not xml_content:
|
||
return {}
|
||
try:
|
||
root = ET.fromstring(xml_content)
|
||
except ET.ParseError:
|
||
return {}
|
||
|
||
appmsg = root.find(".//appmsg")
|
||
if appmsg is None or appmsg.findtext("type", "").strip() != "57":
|
||
return {}
|
||
|
||
refer = appmsg.find("refermsg")
|
||
if refer is None:
|
||
return {}
|
||
|
||
title = html.unescape(appmsg.findtext("title", "") or "").strip()
|
||
quote_sender_name = html.unescape(refer.findtext("displayname", "") or "").strip()
|
||
if not quote_sender_name:
|
||
quote_sender = html.unescape(refer.findtext("chatusr", "") or "").strip()
|
||
quote_sender_name = self._get_sender_name(room_id, quote_sender) if quote_sender else "未知成员"
|
||
ref_type = int(refer.findtext("type", "0") or 0)
|
||
ref_content = html.unescape(refer.findtext("content", "") or "").strip()
|
||
quote_type_label = self._quote_type_label(ref_type)
|
||
quote_body = self._build_quote_body(ref_type, ref_content, title)
|
||
return {
|
||
"title": title,
|
||
"quote_sender_name": quote_sender_name,
|
||
"quote_type_label": quote_type_label,
|
||
"quote_body": quote_body,
|
||
"raw_ref_content": ref_content,
|
||
}
|
||
|
||
@staticmethod
|
||
def _quote_type_label(ref_type: int) -> str:
|
||
mapping = {
|
||
MessageType.TEXT.value: "引用文本",
|
||
MessageType.IMAGE.value: "引用图片",
|
||
MessageType.VIDEO.value: "引用视频",
|
||
MessageType.APP.value: "引用应用消息",
|
||
MessageType.EMOTICON.value: "引用表情",
|
||
}
|
||
return mapping.get(ref_type, f"引用消息[{ref_type}]")
|
||
|
||
@staticmethod
|
||
def _build_quote_body(ref_type: int, ref_content: str, title: str) -> str:
|
||
if ref_type == MessageType.TEXT.value:
|
||
return ref_content[:220].strip()
|
||
if ref_type == MessageType.IMAGE.value:
|
||
details = []
|
||
if title:
|
||
details.append(f"当前追问文案:{title}")
|
||
if ref_content:
|
||
details.append("被引用的是一张图片")
|
||
return ";".join(details) or "被引用的是一张图片"
|
||
if title:
|
||
return title[:220].strip()
|
||
return ref_content[:220].strip()
|
||
|
||
def _build_recent_image_context(
|
||
self,
|
||
message: Dict[str, Any],
|
||
room_id: str,
|
||
content: str,
|
||
quote_context: Dict[str, str],
|
||
) -> Dict[str, str]:
|
||
if quote_context:
|
||
return {}
|
||
if not self._is_recent_image_followup(content):
|
||
return {}
|
||
latest_image = self.memory_store.get_latest_image_message(
|
||
room_id,
|
||
before_timestamp=str(message.get("timestamp") or ""),
|
||
)
|
||
if not latest_image:
|
||
return {}
|
||
sender = str(latest_image.get("sender", "") or "")
|
||
sender_name = self._get_sender_name(room_id, sender) if sender else "未知成员"
|
||
return {
|
||
"sender_name": sender_name,
|
||
"image_path": str(latest_image.get("image_path", "") or ""),
|
||
"hint": "用户当前这句大概率是在追问这张最近图片",
|
||
}
|
||
|
||
@staticmethod
|
||
def _is_recent_image_followup(content: str) -> bool:
|
||
text = str(content or "").strip().lower()
|
||
if not text:
|
||
return False
|
||
image_words = ["图", "图片", "照片", "截图"]
|
||
ask_words = ["看看", "看下", "帮我看", "帮看看", "这个", "咋样", "什么", "识别", "分析"]
|
||
return any(word in text for word in image_words) and any(word in text for word in ask_words)
|
||
|
||
async def _prepare_quote_image_inputs(self, bot: WechatAPIClient, quote_context: Dict[str, str]) -> List[str]:
|
||
if not quote_context or quote_context.get("quote_type_label") != "引用图片":
|
||
return []
|
||
ref_content = quote_context.get("raw_ref_content", "") or ""
|
||
image_info = self._extract_quote_image_info(ref_content)
|
||
if not image_info:
|
||
return []
|
||
try:
|
||
base64_str = await bot.download_image(
|
||
aeskey=image_info["aeskey"],
|
||
cdnmidimgurl=image_info["url"],
|
||
)
|
||
except Exception as exc:
|
||
self._log_event("quote_image_fail", reason=f"download:{exc}")
|
||
return []
|
||
data_url = self._build_image_data_url(base64_str)
|
||
if not data_url:
|
||
self._log_event("quote_image_fail", reason="invalid_base64")
|
||
return []
|
||
return [data_url]
|
||
|
||
def _build_local_image_data_url(self, image_path: str) -> str:
|
||
if not image_path:
|
||
return ""
|
||
relative_path = image_path.lstrip("/\\").replace("/", "\\")
|
||
full_path = self.get_main_path() / relative_path
|
||
if not full_path.exists():
|
||
return ""
|
||
try:
|
||
image_bytes = full_path.read_bytes()
|
||
except Exception:
|
||
return ""
|
||
image_type = imghdr.what(None, h=image_bytes) or "jpeg"
|
||
raw_base64 = base64.b64encode(image_bytes).decode("utf-8")
|
||
return f"data:image/{image_type};base64,{raw_base64}"
|
||
|
||
@staticmethod
|
||
def _extract_quote_image_info(ref_content: str) -> Dict[str, str]:
|
||
if not ref_content:
|
||
return {}
|
||
aeskey_match = re.search(r'aeskey="([^"]+)"', ref_content)
|
||
if not aeskey_match:
|
||
return {}
|
||
url_match = re.search(r'cdnmidimgurl="([^"]+)"', ref_content)
|
||
if not url_match:
|
||
url_match = re.search(r'cdnbigimgurl="([^"]+)"', ref_content)
|
||
if not url_match:
|
||
url_match = re.search(r'cdnthumburl="([^"]+)"', ref_content)
|
||
if not url_match:
|
||
return {}
|
||
return {
|
||
"aeskey": aeskey_match.group(1),
|
||
"url": url_match.group(1),
|
||
}
|
||
|
||
@staticmethod
|
||
def _build_image_data_url(base64_str: str) -> str:
|
||
raw_base64 = str(base64_str or "").strip()
|
||
if not raw_base64:
|
||
return ""
|
||
if "," in raw_base64 and raw_base64.startswith("data:"):
|
||
raw_base64 = raw_base64.split(",", 1)[1]
|
||
try:
|
||
image_bytes = base64.b64decode(raw_base64)
|
||
except Exception:
|
||
return ""
|
||
image_type = imghdr.what(None, h=image_bytes) or "jpeg"
|
||
return f"data:image/{image_type};base64,{raw_base64}"
|