848 lines
32 KiB
Python
848 lines
32 KiB
Python
"""
|
||
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
|