Files
microdao-daarion/gateway-bot/behavior_policy.py

848 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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