NODA1 agents now: - Don't respond to broadcasts/posters/announcements without direct mention - Don't respond to media (photo/link) without explicit question - Keep responses short (1-2 sentences by default) - No emoji, no "ready to help", no self-promotion Added: - behavior_policy.py: detect_directed_to_agent(), detect_broadcast_intent(), should_respond() - behavior_policy_v1.txt: unified policy block for all prompts - Pre-LLM check in http_api.py: skip Router call if should_respond=False - NO_OUTPUT handling: don't send to Telegram if LLM returns empty - Updated all 9 agent prompts with Behavior Policy v1 - Unit and E2E tests for 5 acceptance cases
452 lines
13 KiB
Python
452 lines
13 KiB
Python
"""
|
||
Behavior Policy v1: Silent-by-default + Short-first + Media-no-comment
|
||
Уніфікована логіка для всіх агентів НОДА1.
|
||
|
||
Правила:
|
||
1. SOWA (Speak-Only-When-Asked) — не відповідай, якщо не питали
|
||
2. Short-First — 1-2 речення за замовчуванням
|
||
3. Media-no-comment — медіа без питання = мовчанка
|
||
"""
|
||
import re
|
||
import logging
|
||
from typing import Dict, Any, Optional, List, Tuple
|
||
from dataclasses import dataclass
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Marker for "no response needed"
|
||
NO_OUTPUT = "__NO_OUTPUT__"
|
||
|
||
# Training groups where agents respond to ALL messages
|
||
TRAINING_GROUP_IDS = {
|
||
"-1003556680911", # Agent Preschool Daarion.city
|
||
}
|
||
|
||
# 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"],
|
||
}
|
||
|
||
# Commands that trigger response
|
||
COMMAND_PREFIXES = [
|
||
"/ask", "/agent", "/help", "/start", "/status", "/link",
|
||
"/daarwizz", "/helion", "/greenfood", "/agromatrix", "/alateya",
|
||
"/nutra", "/druid", "/clan", "/eonarch",
|
||
"/ingest", "/бренд", "/презентація", "/job",
|
||
]
|
||
|
||
# Question markers (Ukrainian + English)
|
||
QUESTION_MARKERS = [
|
||
"?", "що", "як", "чому", "коли", "де", "хто", "чи", "який", "яка", "яке",
|
||
"скільки", "навіщо", "звідки", "куди", "котрий", "котра",
|
||
"what", "how", "why", "when", "where", "who", "which", "whose",
|
||
]
|
||
|
||
# Imperative markers (commands/requests)
|
||
IMPERATIVE_MARKERS = [
|
||
"поясни", "розкажи", "зроби", "допоможи", "покажи", "дай", "скажи",
|
||
"знайди", "перевір", "аналізуй", "порівняй", "підсумуй", "витягни",
|
||
"опиши", "переклади", "напиши", "створи", "згенеруй", "порахуй",
|
||
"explain", "tell", "do", "help", "show", "give", "say", "find",
|
||
"check", "analyze", "compare", "summarize", "extract", "describe",
|
||
"translate", "write", "create", "generate", "calculate",
|
||
]
|
||
|
||
# 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
|
||
]
|
||
|
||
|
||
@dataclass
|
||
class BehaviorDecision:
|
||
"""Result of behavior analysis"""
|
||
should_respond: bool
|
||
reason: str
|
||
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
|
||
|
||
|
||
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.
|
||
|
||
Args:
|
||
text: Message text
|
||
agent_id: Agent ID (e.g., "helion", "daarwizz")
|
||
|
||
Returns:
|
||
True if agent is mentioned
|
||
"""
|
||
if not text:
|
||
return False
|
||
|
||
normalized = _normalize_text(text)
|
||
|
||
# Get agent name variants
|
||
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 if mentioned, None otherwise
|
||
"""
|
||
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 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"""
|
||
if not text:
|
||
return False
|
||
|
||
normalized = _normalize_text(text)
|
||
|
||
# Check for question mark
|
||
if "?" in normalized:
|
||
return True
|
||
|
||
# Check for question words at start or after punctuation
|
||
words = normalized.split()
|
||
if words and words[0] in QUESTION_MARKERS:
|
||
return True
|
||
|
||
# Check for question markers anywhere (less strict)
|
||
for marker in QUESTION_MARKERS:
|
||
if f" {marker} " in f" {normalized} ":
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def detect_imperative(text: str) -> bool:
|
||
"""Check if message contains an imperative (command/request)"""
|
||
if not text:
|
||
return False
|
||
|
||
normalized = _normalize_text(text)
|
||
words = normalized.split()
|
||
|
||
if not words:
|
||
return False
|
||
|
||
# Check if starts with imperative
|
||
first_word = words[0].rstrip(",.:!?")
|
||
if first_word in IMPERATIVE_MARKERS:
|
||
return True
|
||
|
||
# Check for imperative after mention (e.g., "@Helion поясни")
|
||
if len(words) >= 2:
|
||
second_word = words[1].rstrip(",.:!?")
|
||
if second_word in IMPERATIVE_MARKERS:
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def detect_broadcast_intent(text: str) -> bool:
|
||
"""
|
||
Check if message is a broadcast/announcement/poster.
|
||
These should NOT trigger a response.
|
||
"""
|
||
if not text:
|
||
return False
|
||
|
||
stripped = text.strip()
|
||
|
||
# Check 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
|
||
|
||
return False
|
||
|
||
|
||
def detect_short_note(text: str) -> bool:
|
||
"""
|
||
Check if message is a short note without request.
|
||
E.g., "20:00 10.02 ✅", "+", "ok"
|
||
"""
|
||
if not text:
|
||
return True
|
||
|
||
stripped = text.strip()
|
||
|
||
# Very short messages
|
||
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.
|
||
Media without question = no response.
|
||
"""
|
||
if not caption:
|
||
return False
|
||
|
||
# Has question
|
||
if detect_question(caption):
|
||
return True
|
||
|
||
# Has imperative
|
||
if detect_imperative(caption):
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def analyze_message(
|
||
text: str,
|
||
agent_id: str,
|
||
chat_id: str,
|
||
has_media: bool = False,
|
||
media_caption: str = "",
|
||
is_private_chat: bool = False,
|
||
payload_explicit_request: bool = False,
|
||
) -> BehaviorDecision:
|
||
"""
|
||
Main function to analyze message and decide if agent should respond.
|
||
|
||
Args:
|
||
text: Message text
|
||
agent_id: Agent ID
|
||
chat_id: Chat ID
|
||
has_media: Whether message has photo/video/file/link
|
||
media_caption: Caption for media (if any)
|
||
is_private_chat: Whether this is a private DM
|
||
payload_explicit_request: Gateway flag for explicit request
|
||
|
||
Returns:
|
||
BehaviorDecision with should_respond and reason
|
||
"""
|
||
decision = BehaviorDecision(
|
||
should_respond=False,
|
||
reason="",
|
||
is_training_group=str(chat_id) in TRAINING_GROUP_IDS,
|
||
has_media=has_media,
|
||
)
|
||
|
||
# 1. Training groups: always respond
|
||
if decision.is_training_group:
|
||
decision.should_respond = True
|
||
decision.reason = "training_group"
|
||
return decision
|
||
|
||
# 2. Private chat: always respond
|
||
if is_private_chat:
|
||
decision.should_respond = True
|
||
decision.reason = "private_chat"
|
||
return decision
|
||
|
||
# 3. Explicit request from gateway payload
|
||
if payload_explicit_request:
|
||
decision.should_respond = True
|
||
decision.reason = "explicit_request"
|
||
return decision
|
||
|
||
# 4. Media handling
|
||
if has_media:
|
||
decision.media_has_question = detect_media_question(media_caption)
|
||
|
||
if not decision.media_has_question:
|
||
# Media without question = NO_OUTPUT
|
||
decision.should_respond = False
|
||
decision.reason = "media_no_question"
|
||
return decision
|
||
else:
|
||
# Media with question = respond
|
||
decision.should_respond = True
|
||
decision.reason = "media_with_question"
|
||
return decision
|
||
|
||
# 5. Check for broadcast/announcement
|
||
decision.is_broadcast = detect_broadcast_intent(text)
|
||
if decision.is_broadcast:
|
||
# Broadcast without direct mention = NO_OUTPUT
|
||
if not detect_agent_mention(text, agent_id):
|
||
decision.should_respond = False
|
||
decision.reason = "broadcast_no_mention"
|
||
return decision
|
||
|
||
# 6. Check for short note
|
||
decision.is_short_note = detect_short_note(text)
|
||
if decision.is_short_note:
|
||
decision.should_respond = False
|
||
decision.reason = "short_note"
|
||
return decision
|
||
|
||
# 7. Check for direct mention
|
||
decision.is_direct_mention = detect_agent_mention(text, agent_id)
|
||
|
||
# 8. Check for command
|
||
decision.is_command = detect_command(text)
|
||
|
||
# 9. Check for question
|
||
decision.is_question = detect_question(text)
|
||
|
||
# 10. Check for imperative
|
||
decision.is_imperative = detect_imperative(text)
|
||
|
||
# Decision logic
|
||
if decision.is_direct_mention:
|
||
decision.should_respond = True
|
||
decision.reason = "direct_mention"
|
||
return decision
|
||
|
||
if decision.is_command:
|
||
decision.should_respond = True
|
||
decision.reason = "command"
|
||
return decision
|
||
|
||
# In groups: question/imperative without mention = NO_OUTPUT
|
||
if decision.is_question or decision.is_imperative:
|
||
# Only respond if there's no other agent mentioned
|
||
other_agent = detect_any_agent_mention(text)
|
||
if other_agent and other_agent != agent_id:
|
||
decision.should_respond = False
|
||
decision.reason = f"addressed_to_other_agent_{other_agent}"
|
||
return decision
|
||
|
||
# General question without mention = NO_OUTPUT in groups
|
||
decision.should_respond = False
|
||
decision.reason = "question_no_mention"
|
||
return decision
|
||
|
||
# Default: don't respond
|
||
decision.should_respond = False
|
||
decision.reason = "no_trigger"
|
||
return decision
|
||
|
||
|
||
def should_respond(
|
||
text: str,
|
||
agent_id: str,
|
||
chat_id: str,
|
||
has_media: bool = False,
|
||
media_caption: str = "",
|
||
is_private_chat: bool = False,
|
||
payload_explicit_request: bool = False,
|
||
) -> Tuple[bool, str]:
|
||
"""
|
||
Simplified function returning (should_respond, reason).
|
||
|
||
Returns:
|
||
Tuple of (should_respond: bool, reason: str)
|
||
"""
|
||
decision = analyze_message(
|
||
text=text,
|
||
agent_id=agent_id,
|
||
chat_id=chat_id,
|
||
has_media=has_media,
|
||
media_caption=media_caption,
|
||
is_private_chat=is_private_chat,
|
||
payload_explicit_request=payload_explicit_request,
|
||
)
|
||
return decision.should_respond, decision.reason
|
||
|
||
|
||
def is_no_output_response(text: str) -> bool:
|
||
"""
|
||
Check if LLM response indicates no output needed.
|
||
Used when LLM returns empty or marker response.
|
||
"""
|
||
if not text:
|
||
return True
|
||
|
||
stripped = text.strip().lower()
|
||
|
||
# Check for NO_OUTPUT marker
|
||
if NO_OUTPUT.lower() in stripped:
|
||
return True
|
||
|
||
# Check for common "I won't respond" patterns
|
||
no_response_patterns = [
|
||
r"^$", # Empty
|
||
r"^\s*$", # Whitespace only
|
||
r"^(no_output|no output|silent|мовчу|—)$",
|
||
r"^\.{1,3}$", # Just dots
|
||
]
|
||
|
||
for pattern in no_response_patterns:
|
||
if re.match(pattern, stripped, re.IGNORECASE):
|
||
return True
|
||
|
||
return False
|