""" Behavior Policy v2.2: SOWA with 3-level response (FULL / ACK / SILENT) Уніфікована логіка для всіх агентів НОДА1. Aligned with Global System Prompt v2.2 v2.2 Changes (from v2.1): - 3-level output: FULL (LLM response), ACK (short presence message, no LLM), SILENT (no output) - Mention classification: direct / soft / topic - Direct mention without explicit_request → ACK (was: SILENT in v2.1) - Soft mention in short message → ACK (with cooldown) - Soft mention in long/broadcast message → SILENT - ACK cooldown per (agent, chat): prevents spam - ACK burst limit per chat - Enhanced broadcast detection (length + patterns) - Backward compatible: should_respond() still returns (bool, str) Правила: 1. SOWA (Speak-Only-When-Asked) — не відповідай повністю, якщо не питали 2. ACK presence — на пряму згадку коротко відповідай "я тут" 3. Short-First — 1-2 речення за замовчуванням 4. Media-no-comment — медіа без питання = мовчанка 5. Conversation Context — якщо користувач вже звертався до агента, продовжуй розмову """ import re import logging import time from typing import Dict, Any, Optional, List, Tuple from dataclasses import dataclass, field from enum import Enum logger = logging.getLogger(__name__) # ======================================== # Constants # ======================================== # Marker for "no response needed" NO_OUTPUT = "__NO_OUTPUT__" # 3-level action enum class SowaAction(str, Enum): FULL = "FULL" # Full LLM response ACK = "ACK" # Short presence message (no LLM call) SILENT = "SILENT" # No output # Training groups where agents respond to ALL messages TRAINING_GROUP_IDS = { "-1003556680911", # Agent Preschool Daarion.city } # Conversation context timeout (seconds) # v2.2: increased from 5min to 30min — users in group chats often pause between messages CONVERSATION_CONTEXT_TIMEOUT = 1800 # 30 minutes # ACK cooldown settings ACK_COOLDOWN_DIRECT = 300 # 5 min cooldown for direct mention ACK per (agent, chat) ACK_COOLDOWN_SOFT = 1800 # 30 min cooldown for soft mention ACK per (agent, chat) ACK_BURST_LIMIT = 3 # Max ACK per chat within burst window ACK_BURST_WINDOW = 600 # 10 min burst window # Soft mention: only ACK if message is short enough SOFT_MENTION_MAX_LEN = 220 # Broadcast detection: long messages are likely announcements BROADCAST_MIN_LEN = 280 # ACK templates per agent (short, no LLM needed) ACK_TEMPLATES: Dict[str, str] = { "default": "Я тут. Якщо потрібна відповідь — запитай або напиши дію.", "helion": "Helion на зв'язку. Запитай — і я відповім.", "daarwizz": "DAARWIZZ тут. Чим можу допомогти?", "greenfood": "GREENFOOD на зв'язку. Запитай — відповім.", "agromatrix": "AgroMatrix тут. Чекаю на ваше питання.", "nutra": "NUTRA тут. Запитайте — і я відповім.", "alateya": "Alateya на зв'язку. Чим можу допомогти?", "druid": "DRUID тут. Задай питання — і я відповім.", "clan": "Spirit на зв'язку. Запитай — відповім.", "eonarch": "EONARCH тут. Чекаю на ваше питання.", "senpai": "SENPAI на зв'язку. Запитайте — допоможу.", "oneok": "1OK на зв'язку. Готовий допомогти з вікнами, заміром і КП.", } def get_ack_text(agent_id: str) -> str: """Get ACK template for agent.""" return ACK_TEMPLATES.get(agent_id, ACK_TEMPLATES["default"]) # ======================================== # State: conversation context + ACK cooldown # ======================================== # In-memory conversation context: {(agent_id, chat_id, user_id): last_interaction_timestamp} _conversation_context: Dict[Tuple[str, str, str], float] = {} # ACK cooldown: {(agent_id, chat_id): [list of ack timestamps]} _ack_cooldown: Dict[Tuple[str, str], List[float]] = {} def record_interaction(agent_id: str, chat_id: str, user_id: str) -> None: """Record that user interacted with agent (agent responded).""" key = (agent_id, str(chat_id), str(user_id)) _conversation_context[key] = time.time() logger.debug(f"Recorded interaction: {key}") def has_recent_interaction(agent_id: str, chat_id: str, user_id: str) -> bool: """Check if user has recent interaction with agent in this chat.""" key = (agent_id, str(chat_id), str(user_id)) last_time = _conversation_context.get(key) if last_time is None: return False elapsed = time.time() - last_time is_recent = elapsed < CONVERSATION_CONTEXT_TIMEOUT if is_recent: logger.debug(f"Recent interaction found: {key}, {elapsed:.0f}s ago") return is_recent def has_agent_chat_participation(agent_id: str, chat_id: str) -> bool: """ Check if agent has responded to ANY user in this chat recently. Used for thread_has_agent_participation (SOWA Priority 11). Different from has_recent_interaction(): - has_recent_interaction: THIS user talked to agent (per-user) - has_agent_chat_participation: agent talked to ANYONE in this chat (per-chat) """ now = time.time() str_chat_id = str(chat_id) for (aid, cid, _uid), timestamp in _conversation_context.items(): if aid == agent_id and cid == str_chat_id: if now - timestamp < CONVERSATION_CONTEXT_TIMEOUT: return True return False def cleanup_old_contexts() -> None: """Remove old conversation contexts and ACK cooldowns to prevent memory leak.""" global _conversation_context, _ack_cooldown now = time.time() cutoff_ctx = now - CONVERSATION_CONTEXT_TIMEOUT * 2 cutoff_ack = now - max(ACK_COOLDOWN_SOFT, ACK_BURST_WINDOW) * 2 old_ctx = len(_conversation_context) _conversation_context = {k: v for k, v in _conversation_context.items() if v > cutoff_ctx} if old_ctx != len(_conversation_context): logger.debug(f"Cleaned up {old_ctx - len(_conversation_context)} old conversation contexts") old_ack = len(_ack_cooldown) new_ack = {} for k, timestamps in _ack_cooldown.items(): fresh = [t for t in timestamps if t > cutoff_ack] if fresh: new_ack[k] = fresh _ack_cooldown = new_ack if old_ack != len(_ack_cooldown): logger.debug(f"Cleaned up {old_ack - len(_ack_cooldown)} old ACK cooldown entries") def check_ack_cooldown(agent_id: str, chat_id: str, mention_type: str) -> bool: """ Check if ACK is allowed (not in cooldown). Returns True if ACK can be sent, False if blocked by cooldown. """ key = (agent_id, str(chat_id)) now = time.time() timestamps = _ack_cooldown.get(key, []) # Check cooldown based on mention type cooldown = ACK_COOLDOWN_DIRECT if mention_type == "direct" else ACK_COOLDOWN_SOFT if timestamps: last_ack = max(timestamps) if now - last_ack < cooldown: logger.debug(f"ACK cooldown active for {key}: {now - last_ack:.0f}s < {cooldown}s") return False # Check burst limit recent_count = sum(1 for t in timestamps if now - t < ACK_BURST_WINDOW) if recent_count >= ACK_BURST_LIMIT: logger.debug(f"ACK burst limit reached for {key}: {recent_count} >= {ACK_BURST_LIMIT}") return False return True def record_ack(agent_id: str, chat_id: str) -> None: """Record that an ACK was sent.""" key = (agent_id, str(chat_id)) now = time.time() if key not in _ack_cooldown: _ack_cooldown[key] = [] _ack_cooldown[key].append(now) logger.debug(f"Recorded ACK for {key}") def is_prober_request(chat_id: str, user_id: str) -> bool: """Check if this is a prober/test request (chat_id=0 or user_id=0).""" return str(chat_id) == "0" or str(user_id) == "0" # ======================================== # URL Detection (single source of truth — gateway uses this) # ======================================== URL_RE = re.compile(r"(https?://|www\.|t\.me/|telegram\.me/)", re.IGNORECASE) def detect_url(text: str) -> bool: """Detect URLs in text. Used by gateway as single source of truth.""" if not text: return False return bool(URL_RE.search(text)) # ======================================== # Imperative / Explicit Request Detection (gateway uses this) # ======================================== IMPERATIVE_RE = re.compile( r"\b(" # Ukrainian imperatives (comprehensive) r"допоможи|поясни|проаналізуй|перевір|виправ|створи|зроби|опиши|розпиши|" r"розкажи|знайди|аналізуй|порівняй|підсумуй|витягни|переклади|напиши|" r"згенеруй|порахуй|покажи|дай|скажи|оціни|підкажи|скинь|" # Missing Ukrainian imperatives (real user commands) r"зафіксуй|вивчи|запамятай|запам'ятай|збережи|подивись|прочитай|перечитай|переглянь|" r"відповідай|відповідь|додай|видали|оновити|онови|оновлюй|" r"завантаж|відправ|надішли|перешли|скопіюй|" r"підготуй|розрахуй|спрогнозуй|спланируй|сплануй|запусти|зупини|" r"нагадай|повідом|попередь|сповісти|" r"протестуй|відстежуй|моніторь|моніториш|" r"розклади|систематизуй|структуруй|категоризуй|" r"засвоїв|засвой|вивчай|досліди|дослідж|" # English imperatives r"help|explain|analyze|check|fix|create|make|describe|list|" r"find|compare|summarize|extract|translate|write|generate|calculate|show|give|tell|" r"remember|save|add|remove|delete|update|send|forward|read|look|watch|" r"monitor|track|plan|schedule|start|stop|test|" # Russian imperatives r"помоги|объясни|проанализируй|проверь|исправь|создай|сделай|опиши|" r"зафиксируй|изучи|запомни|сохрани|посмотри|прочитай|ответь|добавь|" # Phrase patterns (Ukrainian) r"що думаєш|можеш|потрібно|будь ласка|скажи будь|як вважаєш|як думаєш" r")\b", re.IGNORECASE ) def detect_explicit_request( text: str, is_dm: bool = False, is_reply_to_agent: bool = False, mentioned_agents: Optional[List[str]] = None, thread_has_agent_participation: bool = False, ) -> bool: """ Detect if message contains an explicit request. Used by gateway as single source of truth. CONTRACT (do not change without updating Global System Prompt v2.2 + tests): has_explicit_request = imperative OR (question_mark AND (is_dm OR is_reply_to_agent OR mentioned_agents not empty OR thread_has_agent_participation)) """ if not text: return False if IMPERATIVE_RE.search(text): return True has_question = '?' in text has_mention = bool(mentioned_agents) question_with_context = has_question and ( is_dm or is_reply_to_agent or has_mention or thread_has_agent_participation ) return question_with_context # ======================================== # Agent name variants for mention detection # ======================================== AGENT_NAME_VARIANTS: Dict[str, List[str]] = { "helion": ["helion", "хеліон", "хелион", "hélion", "helios", "@energyunionbot"], "daarwizz": ["daarwizz", "даарвіз", "даарвиз", "@daarwizzbot"], "greenfood": ["greenfood", "грінфуд", "гринфуд", "@greenfoodlivebot"], "agromatrix": ["agromatrix", "агроматрікс", "агроматрикс", "@agromatrixbot", "степан"], "alateya": ["alateya", "алатея", "@alateyabot"], "nutra": ["nutra", "нутра", "@nutrachat_bot"], "druid": ["druid", "друїд", "друид", "@druidbot"], "clan": ["clan", "spirit", "клан", "спіріт", "спирит", "@clanbot"], "eonarch": ["eonarch", "еонарх", "@eonarchbot"], "senpai": ["senpai", "сенпай", "сэнпай", "гордон", "gordon", "@senpai_agent_bot"], "oneok": ["oneok", "1ok", "1ок", "одинок", "асистент віконного майстра", "@oneokbot"], "soul": ["soul", "athena", "атена", "афіна", "афина", "@athena_soul_bot"], "yaromir": ["yaromir", "яромир", "@yaromir_agent_bot"], "sofiia": ["sofiia", "софія", "софия", "софія", "@sofiia_agent_bot"], } # Commands that trigger response COMMAND_PREFIXES = [ "/ask", "/agent", "/help", "/start", "/status", "/link", "/daarwizz", "/helion", "/greenfood", "/agromatrix", "/alateya", "/nutra", "/druid", "/clan", "/eonarch", "/senpai", "/oneok", "/sofiia", "/ingest", "/бренд", "/презентація", "/job", "/soul", "/athena", "/yaromir", ] # Broadcast/poster patterns BROADCAST_PATTERNS = [ r"^\d{1,2}[:.]\d{2}\s", # Time pattern: "20:00", "14.30" r"^\d{1,2}[:.]\d{2}\s+\d{1,2}[./]\d{1,2}", # "20:00 10.02" r"^[\u2705\u274c\u23f0\u2b50\u26a1\u2764]", # Starts with common emoji r"^(анонс|запрошуємо|нагадуємо|увага|важливо|news|update|alert)", # Announcement words r"^https?://", # URL only r"#\w+.*#\w+", # Multiple hashtags ] # Short note patterns (timing, reactions, status updates) SHORT_NOTE_PATTERNS = [ r"^[\u2705\u274c\u2611\u2612]+$", # Only checkmarks r"^\d{1,2}[:.]\d{2}(\s+\d{1,2}[./]\d{1,2})?\s*[\u2705\u274c]?$", # "20:00 10.02 ✅" r"^[+\-ok\u2705\u274c]{1,3}$", # +, -, ok, ✅, ❌ r"^(ok|ок|добре|так|ні|yes|no|done|готово)$", # Short confirmations ] # ======================================== # BehaviorDecision # ======================================== @dataclass class BehaviorDecision: """Result of behavior analysis with 3-level action.""" should_respond: bool reason: str action: str = "SILENT" # FULL / ACK / SILENT ack_text: str = "" # Pre-built ACK text (only when action=ACK) mention_type: str = "" # direct / soft / none is_training_group: bool = False is_direct_mention: bool = False is_command: bool = False is_question: bool = False is_imperative: bool = False is_broadcast: bool = False is_short_note: bool = False has_media: bool = False media_has_question: bool = False has_conversation_context: bool = False is_prober: bool = False has_explicit_request: bool = False # ======================================== # Detection functions # ======================================== def _normalize_text(text: str) -> str: """Normalize text for pattern matching""" if not text: return "" return text.lower().strip() def detect_agent_mention(text: str, agent_id: str) -> bool: """Check if message mentions the agent.""" if not text: return False normalized = _normalize_text(text) variants = AGENT_NAME_VARIANTS.get(agent_id, [agent_id]) for variant in variants: if variant.lower() in normalized: return True return False def detect_any_agent_mention(text: str) -> Optional[str]: """Check if message mentions any agent. Returns agent_id or None.""" if not text: return None normalized = _normalize_text(text) for agent_id, variants in AGENT_NAME_VARIANTS.items(): for variant in variants: if variant.lower() in normalized: return agent_id return None def classify_mention_type(text: str, agent_id: str) -> str: """ Classify how the agent is mentioned in the message. Returns: "direct" — @bot, reply-to, agent name at start, "Agent, ...", "Гей Agent" "soft" — agent name appears in the middle/body of text "none" — not mentioned """ if not text: return "none" normalized = _normalize_text(text) variants = AGENT_NAME_VARIANTS.get(agent_id, [agent_id]) # Find which variant matched matched_variant = None match_pos = -1 for variant in variants: pos = normalized.find(variant.lower()) if pos != -1: matched_variant = variant match_pos = pos break if matched_variant is None: return "none" # Direct mention patterns: # 1. Starts with @ (e.g. @Helion) if matched_variant.startswith("@"): return "direct" # 2. Agent name is at the very beginning (first 25 chars) if match_pos <= 5: return "direct" # 3. Pattern: "Agent," or "Agent:" or "Agent —" at start prefix = normalized[:match_pos + len(matched_variant) + 3] if re.match(r"^[\s]*\S+[\s]*[,:\-—]", prefix): return "direct" # 4. Addressing patterns: "Гей Agent", "Hey Agent", "привіт Agent" before = normalized[:match_pos].strip() if before and re.search(r"\b(гей|hey|привіт|привет|hi|hello|yo|ей)\s*$", before, re.IGNORECASE): return "direct" # Everything else is a soft mention (name appears in body of text) return "soft" def detect_command(text: str) -> bool: """Check if message starts with a command""" if not text: return False stripped = text.strip() for prefix in COMMAND_PREFIXES: if stripped.lower().startswith(prefix): return True return False def detect_question(text: str) -> bool: """Check if message contains a question mark.""" if not text: return False return '?' in text def detect_imperative(text: str) -> bool: """Check if message contains an imperative (command/request).""" if not text: return False return bool(IMPERATIVE_RE.search(text)) def detect_broadcast_intent(text: str) -> bool: """ Check if message is a broadcast/announcement/poster. Enhanced in v2.2: also checks message length. """ if not text: return False stripped = text.strip() # Check explicit broadcast patterns for pattern in BROADCAST_PATTERNS: if re.match(pattern, stripped, re.IGNORECASE | re.UNICODE): logger.debug(f"Broadcast pattern matched: {pattern}") return True # Very short messages with only emojis/special chars if len(stripped) <= 5 and not any(c.isalpha() for c in stripped): return True # v2.2: Long messages with multiple paragraphs/links are likely announcements if len(stripped) > BROADCAST_MIN_LEN: # Check for low "addressedness": no second-person markers has_address = bool(re.search( r"\b(ти|ви|тобі|вам|тебе|вас|підкажи|можеш|скажи|допоможи|you|your)\b", stripped, re.IGNORECASE )) has_multiple_links = len(URL_RE.findall(stripped)) >= 2 has_multiple_paragraphs = stripped.count('\n') >= 3 if not has_address and (has_multiple_links or has_multiple_paragraphs): logger.debug(f"Broadcast detected: long ({len(stripped)} chars), low addressedness") return True return False def detect_short_note(text: str) -> bool: """Check if message is a short note without request.""" if not text: return True stripped = text.strip() if len(stripped) <= 10: for pattern in SHORT_NOTE_PATTERNS: if re.match(pattern, stripped, re.IGNORECASE | re.UNICODE): logger.debug(f"Short note pattern matched: {pattern}") return True return False def detect_media_question(caption: str) -> bool: """Check if media caption contains a question/request.""" if not caption: return False if detect_question(caption): return True if detect_imperative(caption): return True return False # ======================================== # Main analysis function # ======================================== def analyze_message( text: str, agent_id: str, chat_id: str, user_id: str = "", has_media: bool = False, media_caption: str = "", is_private_chat: bool = False, payload_explicit_request: bool = False, payload_has_link: bool = False, is_reply_to_agent: bool = False, thread_has_agent_participation: bool = False, ) -> BehaviorDecision: """ Main function: analyze message and decide FULL / ACK / SILENT. v2.2 changes: - Returns action field: FULL, ACK, or SILENT - Direct mention without explicit_request → ACK (was: SILENT in v2.1) - Soft mention in short message → ACK (with cooldown) - Enhanced broadcast detection - ACK cooldown to prevent spam Backward compatible: should_respond=True means FULL or ACK, should_respond=False means SILENT. Check decision.action for the level. """ # Periodic cleanup cleanup_old_contexts() # Merge has_media with has_link effective_has_media = has_media or payload_has_link decision = BehaviorDecision( should_respond=False, reason="", action="SILENT", is_training_group=str(chat_id) in TRAINING_GROUP_IDS, has_media=effective_has_media, is_prober=is_prober_request(chat_id, user_id), has_explicit_request=payload_explicit_request, ) # --- Priority 0: Prober --- if decision.is_prober: decision.should_respond = True decision.action = "FULL" decision.reason = "prober_request" return decision # --- Priority 1: Training groups --- if decision.is_training_group: # In training chats, do NOT auto-FULL for every agent. # Only the targeted agent (mention/reply) should respond. if is_reply_to_agent: decision.should_respond = True decision.action = "FULL" decision.reason = "training_reply_to_agent" return decision targeted_agent = detect_any_agent_mention(text) if targeted_agent and targeted_agent != agent_id: decision.should_respond = False decision.action = "SILENT" decision.reason = f"training_addressed_to_other_agent_{targeted_agent}" return decision # If targeted_agent == agent_id or no target, continue with standard logic below. # --- Priority 2: Private chat (DM) --- if is_private_chat: decision.should_respond = True decision.action = "FULL" decision.reason = "private_chat" return decision # --- Priority 3: Reply to agent --- if is_reply_to_agent: decision.should_respond = True decision.action = "FULL" decision.reason = "reply_to_agent" return decision # --- Gather signals --- decision.has_conversation_context = has_recent_interaction(agent_id, chat_id, user_id) decision.is_direct_mention = detect_agent_mention(text, agent_id) decision.is_command = detect_command(text) decision.is_question = detect_question(text) decision.is_imperative = detect_imperative(text) decision.is_broadcast = detect_broadcast_intent(text) decision.is_short_note = detect_short_note(text) # Classify mention type (direct / soft / none) if decision.is_direct_mention: decision.mention_type = classify_mention_type(text, agent_id) else: decision.mention_type = "none" # --- Priority 4: Media/link handling --- if effective_has_media: decision.media_has_question = detect_media_question(media_caption) if decision.media_has_question or payload_explicit_request: # Media with question/request = FULL if decision.is_direct_mention or decision.has_conversation_context: decision.should_respond = True decision.action = "FULL" decision.reason = "media_with_question" return decision # Media with question but not addressed to this agent decision.should_respond = False decision.action = "SILENT" decision.reason = "media_question_not_directed" return decision # Media without question if decision.has_conversation_context: decision.should_respond = True decision.action = "FULL" decision.reason = "media_conversation_context" return decision decision.should_respond = False decision.action = "SILENT" decision.reason = "media_or_link_without_request" return decision # --- Priority 5: Command --- if decision.is_command: decision.should_respond = True decision.action = "FULL" decision.reason = "command" return decision # --- Priority 6: Broadcast --- if decision.is_broadcast: if not decision.is_direct_mention: decision.should_respond = False decision.action = "SILENT" decision.reason = "broadcast_not_directed" return decision # Broadcast WITH direct mention + explicit_request → FULL if payload_explicit_request: decision.should_respond = True decision.action = "FULL" decision.reason = "broadcast_with_request" return decision # Broadcast WITH mention but no request → SILENT (too noisy to ACK broadcasts) decision.should_respond = False decision.action = "SILENT" decision.reason = "broadcast_mention_no_request" return decision # --- Priority 7: Short note --- if decision.is_short_note: decision.should_respond = False decision.action = "SILENT" decision.reason = "short_note" return decision # --- Priority 8: Direct mention with explicit_request → FULL --- if decision.mention_type == "direct" and payload_explicit_request: decision.should_respond = True decision.action = "FULL" decision.reason = "mentioned_with_request" return decision # --- Priority 9: Direct mention without explicit_request --- if decision.mention_type == "direct": # v2.3: If message is substantial (>40 chars after removing agent name), give FULL response # Users often address agents with statements, not just questions clean_text = text or "" for variant in AGENT_NAME_VARIANTS.get(agent_id, [agent_id]): clean_text = clean_text.lower().replace(variant.lower(), "").strip() clean_text = clean_text.strip(" ,.:!;-—") if len(clean_text) > 40: decision.should_respond = True decision.action = "FULL" decision.reason = "direct_mention_substantial_text" logger.info(f"✅ SOWA v2.3: Direct mention + substantial text ({len(clean_text)} chars) → FULL for {agent_id}") return decision # Short mention without request → ACK if check_ack_cooldown(agent_id, chat_id, "direct"): decision.should_respond = True decision.action = "ACK" decision.ack_text = get_ack_text(agent_id) decision.reason = "direct_mention_ack" logger.info(f"👋 SOWA v2.3: Direct mention ACK for agent {agent_id} in chat {chat_id}") return decision else: decision.should_respond = False decision.action = "SILENT" decision.reason = "direct_mention_cooldown" logger.debug(f"ACK blocked by cooldown for {agent_id} in chat {chat_id}") return decision # --- Priority 10: Soft mention handling (v2.2) --- if decision.mention_type == "soft": msg_len = len(text) if text else 0 # Soft mention with explicit_request → FULL if payload_explicit_request: decision.should_respond = True decision.action = "FULL" decision.reason = "soft_mention_with_request" return decision # Soft mention in short message → ACK (with cooldown) if msg_len <= SOFT_MENTION_MAX_LEN: if check_ack_cooldown(agent_id, chat_id, "soft"): decision.should_respond = True decision.action = "ACK" decision.ack_text = get_ack_text(agent_id) decision.reason = "soft_mention_short_ack" logger.info(f"👋 SOWA v2.2: Soft mention ACK for agent {agent_id} (msg len={msg_len})") return decision else: decision.should_respond = False decision.action = "SILENT" decision.reason = "soft_mention_cooldown" return decision # Soft mention in long message → SILENT decision.should_respond = False decision.action = "SILENT" decision.reason = "soft_mention_long_text" return decision # --- Priority 11: Thread participation --- if thread_has_agent_participation and (decision.is_question or decision.is_imperative): decision.should_respond = True decision.action = "FULL" decision.reason = "thread_participation" return decision # --- Priority 12: Conversation context --- if decision.has_conversation_context: other_agent = detect_any_agent_mention(text) if other_agent and other_agent != agent_id: decision.should_respond = False decision.action = "SILENT" decision.reason = f"addressed_to_other_agent_{other_agent}" return decision # v2.3: In active conversation, respond to questions, imperatives, # AND any substantial text (>30 chars). Natural conversation flow. msg_len = len(text.strip()) if text else 0 if decision.is_question or decision.is_imperative or msg_len > 30: decision.should_respond = True decision.action = "FULL" decision.reason = "conversation_context_active" logger.info(f"✅ SOWA v2.3: Conversation context active for {agent_id}, msg_len={msg_len}") return decision # --- Default: SILENT --- decision.should_respond = False decision.action = "SILENT" decision.reason = "not_directed_to_agent" return decision # ======================================== # Backward-compatible wrapper # ======================================== def should_respond( text: str, agent_id: str, chat_id: str, user_id: str = "", has_media: bool = False, media_caption: str = "", is_private_chat: bool = False, payload_explicit_request: bool = False, payload_has_link: bool = False, is_reply_to_agent: bool = False, thread_has_agent_participation: bool = False, ) -> Tuple[bool, str]: """ Backward-compatible wrapper. Returns (should_respond, reason). For ACK support, use analyze_message() directly and check decision.action. """ decision = analyze_message( text=text, agent_id=agent_id, chat_id=chat_id, user_id=user_id, has_media=has_media, media_caption=media_caption, is_private_chat=is_private_chat, payload_explicit_request=payload_explicit_request, payload_has_link=payload_has_link, is_reply_to_agent=is_reply_to_agent, thread_has_agent_participation=thread_has_agent_participation, ) return decision.should_respond, decision.reason # ======================================== # LLM response analysis # ======================================== def is_no_output_response(text: str) -> bool: """Check if LLM response indicates no output needed.""" if not text: return True stripped = text.strip().lower() if NO_OUTPUT.lower() in stripped: return True no_response_patterns = [ r"^$", r"^\s*$", r"^(no_output|no output|silent|мовчу|—)$", r"^\.{1,3}$", ] for pattern in no_response_patterns: if re.match(pattern, stripped, re.IGNORECASE): return True return False