feat: Behavior Policy v1 - Silent-by-default + Short-first + Media-no-comment

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
This commit is contained in:
Apple
2026-02-04 09:03:14 -08:00
parent c8698f6a1d
commit 134c044c21
13 changed files with 1477 additions and 134 deletions

View File

@@ -0,0 +1,451 @@
"""
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