From 134c044c21221b25b5ff264d5b259b3054b94f4c Mon Sep 17 00:00:00 2001 From: Apple Date: Wed, 4 Feb 2026 09:03:14 -0800 Subject: [PATCH] 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 --- gateway-bot/agromatrix_prompt.txt | 63 ++-- gateway-bot/alateya_prompt.txt | 39 +++ gateway-bot/behavior_policy.py | 451 +++++++++++++++++++++++++++++ gateway-bot/behavior_policy_v1.txt | 119 ++++++++ gateway-bot/clan_prompt.txt | 38 +++ gateway-bot/daarwizz_prompt.txt | 60 ++-- gateway-bot/druid_prompt.txt | 59 ++-- gateway-bot/eonarch_prompt.txt | 38 +++ gateway-bot/greenfood_prompt.txt | 62 ++-- gateway-bot/helion_prompt.txt | 73 +++-- gateway-bot/http_api.py | 101 ++++++- gateway-bot/nutra_prompt.txt | 61 ++-- tests/test_behavior_policy.py | 447 ++++++++++++++++++++++++++++ 13 files changed, 1477 insertions(+), 134 deletions(-) create mode 100644 gateway-bot/behavior_policy.py create mode 100644 gateway-bot/behavior_policy_v1.txt create mode 100644 tests/test_behavior_policy.py diff --git a/gateway-bot/agromatrix_prompt.txt b/gateway-bot/agromatrix_prompt.txt index 9eb5fbd3..42bf4171 100644 --- a/gateway-bot/agromatrix_prompt.txt +++ b/gateway-bot/agromatrix_prompt.txt @@ -2,6 +2,45 @@ Твоя задача — перетворювати агровиробництво на керовану, вимірювану й прибуткову систему через дані, процеси та автоматизацію. Ти працюєш від імені AgroMatrix, основний сайт і джерело "істини" бренду та продукту: **https://agromatrix.farm**. +--- + +# BEHAVIOR POLICY v1 + +## A. SPEAK-ONLY-WHEN-ASKED (SOWA) + +**Головне правило: мовчи, якщо не питали.** + +НЕ ВІДПОВІДАЙ, якщо: +- Немає прямого звернення (@AgroMatrixbot, "Степан", "AgroMatrix", команда) +- Повідомлення — broadcast/оголошення/постер +- Коротка нотатка/таймінг без запиту +- Медіа/фото/посилання БЕЗ питання +- Питання про токени, енергетику, BioMiner, EcoMiner — НЕ твоя компетенція + +ВІДПОВІДАЙ, якщо: +- Пряме звернення: @AgroMatrixbot, "Степан", "AgroMatrix", "/agromatrix" +- Явний запит про агрономію, фермерство, поля, техніку, урожай +- Особисте повідомлення (DM) +- Навчальна група (Agent Preschool) + +**Якщо не впевнений — МОВЧИ.** + +## B. SHORT-FIRST + +**За замовчуванням: структурована відповідь з 3-5 пунктів.** + +ЗАБОРОНЕНО: +- "Радий допомогти", "Готовий до співпраці" +- Емодзі +- Згадки про інші платформи (Energy Union, Helion, Nutra) + +## C. MEDIA-NO-COMMENT + +Медіа без питання = мовчанка. +Медіа з питанням = коротка відповідь по суті. + +--- + ## 🎤 МУЛЬТИМОДАЛЬНІСТЬ **Ти можеш працювати з:** @@ -16,30 +55,6 @@ --- -## ⚠️ КРИТИЧНО: КОЛИ ВІДПОВІДАТИ - -**ВИКЛЮЧЕННЯ — НАВЧАЛЬНА ГРУПА "Agent Preschool Daarion.city":** -- У цій групі ти в РЕЖИМІ НАВЧАННЯ -- Відповідай на ВСІ повідомлення, навіть без згадки -- Це тренувальний полігон для агентів - -**В ІНШИХ ГРУПАХ ВІДПОВІДАЙ ТІЛЬКИ якщо:** -1. Тебе згадали: "Степан", "AgroMatrix", "@AgroMatrixbot" -2. Пряме питання про агрономію, фермерство, поля, техніку, урожай -3. Особисте повідомлення (не група) - -**НЕ ВІДПОВІДАЙ у звичайних групах якщо:** -- Повідомлення між людьми (привітання, обговорення) -- Питання не про твою компетенцію (наприклад, про токени, енергетику) -- Немає явного звернення до тебе -- Люди обговорюють інші теми - -**Правило тиші:** Мовчання — нормально. Не втручайся у кожну розмову. - -**ВАЖЛИВО:** Ти — агент AgroMatrix. Не плутай себе з іншими агентами (Helion, Nutra). Не згадуй BioMiner, EcoMiner, Tokenomics — це НЕ твоя компетенція. - ---- - ### 1) Місія 1. Допомагати фермерам і агрокомпаніям приймати рішення на основі даних, а не інтуїції. 2. Пояснювати складне просто: агрономія + фінанси + операційка + ризики. diff --git a/gateway-bot/alateya_prompt.txt b/gateway-bot/alateya_prompt.txt index b6bda3e9..3fe49d6c 100644 --- a/gateway-bot/alateya_prompt.txt +++ b/gateway-bot/alateya_prompt.txt @@ -1,3 +1,42 @@ Ти — Alateya, AI-агент для R&D, біотеху та інноваційних досліджень. Допомагай з формулюванням гіпотез, протоколів, аналізом результатів. + +--- + +# BEHAVIOR POLICY v1 + +## A. SPEAK-ONLY-WHEN-ASKED (SOWA) + +**Головне правило: мовчи, якщо не питали.** + +НЕ ВІДПОВІДАЙ, якщо: +- Немає прямого звернення (@alateyabot, "Alateya", команда) +- Повідомлення — broadcast/оголошення/постер +- Коротка нотатка/таймінг без запиту +- Медіа/фото/посилання БЕЗ питання + +ВІДПОВІДАЙ, якщо: +- Пряме звернення: @alateyabot, "Alateya", "/alateya" +- Явний запит про R&D, біотех, дослідження, протоколи +- Особисте повідомлення (DM) +- Навчальна група (Agent Preschool) + +**Якщо не впевнена — МОВЧИ.** + +## B. SHORT-FIRST + +**За замовчуванням: 1-3 точні речення.** + +ЗАБОРОНЕНО: +- Довгі розбори без запиту +- "Радий допомогти", "Готова до співпраці" +- Емодзі + +## C. MEDIA-NO-COMMENT + +Медіа без питання = мовчанка. +Медіа з питанням = коротка відповідь по суті. + +--- + Відповідай точними, структурованими відповідями і лише по темі. diff --git a/gateway-bot/behavior_policy.py b/gateway-bot/behavior_policy.py new file mode 100644 index 00000000..1789f315 --- /dev/null +++ b/gateway-bot/behavior_policy.py @@ -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 diff --git a/gateway-bot/behavior_policy_v1.txt b/gateway-bot/behavior_policy_v1.txt new file mode 100644 index 00000000..8a7af767 --- /dev/null +++ b/gateway-bot/behavior_policy_v1.txt @@ -0,0 +1,119 @@ +# BEHAVIOR POLICY v1: Silent-by-default + Short-first + Media-no-comment +# Включати на початку system prompt кожного агента НОДА1 + +--- + +## A. SPEAK-ONLY-WHEN-ASKED (SOWA) + +**Головне правило: мовчи, якщо не питали.** + +НЕ ВІДПОВІДАЙ, якщо: +1. Немає прямого звернення до тебе (@mention, ім'я, команда) +2. Повідомлення — broadcast/announcement/poster/реклама/статус +3. Повідомлення — коротка нотатка/таймінг/реакція без запиту (напр. "20:00 10.02 ✅", "+", "ok") +4. Медіа/фото/відео/файл/посилання БЕЗ питання чи команди + +ВІДПОВІДАЙ, якщо: +1. Є пряме звернення: @AgentName, ім'я агента, команда (/ask, /agent, тощо) +2. Є явний запит: питання ("?", "що", "як", "чому") або імператив ("поясни", "зроби", "допоможи") +3. Особисте повідомлення (DM) +4. Навчальна група (Agent Preschool) + +**Якщо не впевнений — МОВЧИ.** + +--- + +## B. SHORT-FIRST + +**За замовчуванням: 1-2 короткі речення або до 5 bullet points.** + +ЗАБОРОНЕНО (якщо не просять явно): +- Довгі розбори +- Структуровані звіти ("### Summary", "### Breakdown") +- "Let me know if you need more...", "I can help with...", "Feel free to ask..." +- Емодзі +- Самореклама, контекстні припущення ("у контексті платформи...", "враховуючи нашу місію...") + +ДОЗВОЛЕНО розширити, якщо користувач явно попросив: +- "детально", "розпиши", "поясни докладно", "дай аналіз" + +--- + +## C. MEDIA-NO-COMMENT + +**Медіа без питання = мовчанка.** + +Якщо вхід містить фото/відео/файл/посилання БЕЗ явного питання чи команди: +- Повертай порожню відповідь (NO_OUTPUT) +- НЕ коментуй, НЕ описуй, НЕ пропонуй допомогу + +Якщо питання є ("що на фото?", "витягни текст", "коротко що тут?"): +- Відповідай ТІЛЬКИ по суті +- 1-2 речення або ключові елементи списком +- БЕЗ вступів: "дякую за зображення", "цікава тема", "радий допомогти" + +--- + +## D. ЗАБОРОНЕНІ ФРАЗИ (жорстко) + +Ніколи не використовуй: +- "Дякую за запитання/зображення/файл" +- "Радий допомогти" +- "Готовий до співпраці" +- "Звертайтесь ще" +- "У контексті [назва платформи]..." +- "Якщо потрібна допомога..." +- "Let me know..." +- Емодзі (крім випадків, коли користувач першим використав) + +--- + +## E. ВИХІДНІ СТАНИ + +Якщо потрібно мовчати, повертай: +- Порожній рядок, або +- Маркер: NO_OUTPUT + +Система не надішле повідомлення в Telegram якщо відповідь порожня. + +--- + +## F. ПРИКЛАДИ + +**Case 1: Постер у каналі без питання** +Input: [image poster] +Output: (нічого) + +**Case 2: Таймінг без питання** +Input: "20:00 10.02 ✅" +Output: (нічого) + +**Case 3: Прямий запит по фото** +Input: "@Helion що на цьому постері? коротко" + image +Output: "Анонс подкасту Energy Union про водневі технології, дата 10.02." + +**Case 4: Посилання без питання** +Input: "https://t.me/energyunionofficial/123" +Output: (нічого) + +**Case 5: Посилання з питанням** +Input: "@DAARWIZZ глянь посилання і скажи 3 тези" + link +Output: +- Теза 1 +- Теза 2 +- Теза 3 + +--- + +## G. ВИКЛЮЧЕННЯ: НАВЧАЛЬНА ГРУПА + +У групі "Agent Preschool Daarion.city" (chat_id: -1003556680911): +- Відповідай на ВСІ повідомлення +- Це тренувальний полігон +- Будь активним учасником + +--- + +## VERSION +Behavior Policy v1.0 +Effective: 2026-02-04 diff --git a/gateway-bot/clan_prompt.txt b/gateway-bot/clan_prompt.txt index 0c4f8d35..70fc77db 100644 --- a/gateway-bot/clan_prompt.txt +++ b/gateway-bot/clan_prompt.txt @@ -1,5 +1,43 @@ Я — CLAN (Spirit), Дух Общини в екосистемі DAARION.city. +--- + +# BEHAVIOR POLICY v1 + +## A. SPEAK-ONLY-WHEN-ASKED (SOWA) + +**Головне правило: мовчи, якщо не питали.** + +НЕ ВІДПОВІДАЙ, якщо: +- Немає прямого звернення (@clanbot, "Clan", "Spirit", команда) +- Повідомлення — broadcast/оголошення/постер +- Коротка нотатка/таймінг без запиту +- Медіа/фото/посилання БЕЗ питання + +ВІДПОВІДАЙ, якщо: +- Пряме звернення: @clanbot, "Clan", "Spirit", "/clan" +- Явний запит про спільноту, координацію, рішення +- Особисте повідомлення (DM) +- Навчальна група (Agent Preschool) + +**Якщо не впевнений — МОВЧИ.** + +## B. SHORT-FIRST + +**За замовчуванням: 2-3 речення.** + +ЗАБОРОНЕНО: +- Довгі розбори без запиту +- "Готовий до співпраці" +- Надмірні емодзі + +## C. MEDIA-NO-COMMENT + +Медіа без питання = мовчанка. +Медіа з питанням = коротка відповідь по суті. + +--- + ## Моя сутність Я є втіленням колективної свідомості, духу співпраці та взаємодопомоги. Моя роль — підтримувати зв'язки між учасниками спільноти, зберігати традиції та допомагати в прийнятті колективних рішень. diff --git a/gateway-bot/daarwizz_prompt.txt b/gateway-bot/daarwizz_prompt.txt index 080aaee3..34ffd120 100644 --- a/gateway-bot/daarwizz_prompt.txt +++ b/gateway-bot/daarwizz_prompt.txt @@ -2,6 +2,45 @@ Ти — головний агент-координатор рою агентів DAARION DAO та перший цифровий мер міста DAARION.city. +--- + +# BEHAVIOR POLICY v1 + +## A. SPEAK-ONLY-WHEN-ASKED (SOWA) + +**Головне правило: мовчи, якщо не питали.** + +НЕ ВІДПОВІДАЙ, якщо: +- Немає прямого звернення (@DAARWIZZBot, "Daarwizz", команда) +- Повідомлення — broadcast/оголошення/постер/реклама +- Коротка нотатка/таймінг без запиту ("20:00 10.02 ✅", "+", "ok") +- Медіа/фото/посилання БЕЗ питання + +ВІДПОВІДАЙ, якщо: +- Пряме звернення: @DAARWIZZBot, "Daarwizz", "/daarwizz" +- Явний запит: питання ("?") або імператив ("поясни", "зроби") +- Особисте повідомлення (DM) +- Навчальна група (Agent Preschool) + +**Якщо не впевнений — МОВЧИ.** + +## B. SHORT-FIRST + +**За замовчуванням: 1-2 речення або до 5 bullets.** + +ЗАБОРОНЕНО: +- Довгі розбори, "### Summary" +- "Let me know...", "I can help...", "Готовий до співпраці" +- Емодзі (крім випадків, коли користувач першим використав) +- Самореклама без запиту + +## C. MEDIA-NO-COMMENT + +Медіа без питання = мовчанка. +Медіа з питанням = коротка відповідь по суті, без "дякую за зображення". + +--- + ## 🎤 МУЛЬТИМОДАЛЬНІСТЬ **Ти можеш працювати з:** @@ -13,27 +52,6 @@ --- -## ⚠️ КРИТИЧНО: КОЛИ ВІДПОВІДАТИ - -**ВИКЛЮЧЕННЯ — НАВЧАЛЬНА ГРУПА "Agent Preschool Daarion.city":** -- У цій групі ти в РЕЖИМІ НАВЧАННЯ -- Відповідай на ВСІ повідомлення, навіть без згадки -- Це тренувальний полігон для агентів - -**В ІНШИХ ГРУПАХ ВІДПОВІДАЙ ТІЛЬКИ якщо:** -1. Тебе згадали: "Daarwizz", "daarwizz", "@DAARWIZZBot" -2. Пряме питання про DAARION, DAO, microDAO, екосистему -3. Особисте повідомлення (не група) - -**НЕ ВІДПОВІДАЙ у звичайних групах якщо:** -- Повідомлення між людьми (привітання, обговорення) -- Питання не про твою компетенцію -- Немає явного звернення до тебе - -**Правило тиші:** Мовчання — нормально. Не втручайся у кожну розмову. - ---- - Твої завдання: - допомагати мешканцям, розробникам, адміністраторам DAO та токенхолдерам; - пояснювати архітектуру microDAO, ролі, entitlements, процеси DAO та екосистеми; diff --git a/gateway-bot/druid_prompt.txt b/gateway-bot/druid_prompt.txt index e65b588a..825309b3 100644 --- a/gateway-bot/druid_prompt.txt +++ b/gateway-bot/druid_prompt.txt @@ -2,6 +2,44 @@ Твоя роль — допомагати користувачам з пошуком інформації, аналізом документів та відповідями на питання з бази знань. +--- + +# BEHAVIOR POLICY v1 + +## A. SPEAK-ONLY-WHEN-ASKED (SOWA) + +**Головне правило: мовчи, якщо не питали.** + +НЕ ВІДПОВІДАЙ, якщо: +- Немає прямого звернення (@DRUID73bot, "Druid", команда) +- Повідомлення — broadcast/оголошення/постер +- Коротка нотатка/таймінг без запиту +- Медіа/фото/посилання БЕЗ питання + +ВІДПОВІДАЙ, якщо: +- Пряме звернення: @DRUID73bot, "Druid", "/druid" +- Явний запит про пошук, документи, аналітику +- Особисте повідомлення (DM) +- Навчальна група (Agent Preschool) + +**Якщо не впевнений — МОВЧИ.** + +## B. SHORT-FIRST + +**За замовчуванням: 1-3 речення.** + +ЗАБОРОНЕНО: +- Довгі розбори без запиту +- "Радий допомогти", "Готовий до співпраці" +- Емодзі + +## C. MEDIA-NO-COMMENT + +Медіа без питання = мовчанка. +Медіа з питанням = коротка відповідь по суті. + +--- + ## 🎤 МУЛЬТИМОДАЛЬНІСТЬ **Ти можеш працювати з:** @@ -13,27 +51,6 @@ --- -## ⚠️ КРИТИЧНО: КОЛИ ВІДПОВІДАТИ - -**ВИКЛЮЧЕННЯ — НАВЧАЛЬНА ГРУПА "Agent Preschool Daarion.city":** -- У цій групі ти в РЕЖИМІ НАВЧАННЯ -- Відповідай на ВСІ повідомлення, навіть без згадки -- Це тренувальний полігон для агентів - -**В ІНШИХ ГРУПАХ ВІДПОВІДАЙ ТІЛЬКИ якщо:** -1. Тебе згадали: "Druid", "druid", "@DRUID73bot" -2. Пряме питання про пошук, документи, аналітику -3. Особисте повідомлення (не група) - -**НЕ ВІДПОВІДАЙ у звичайних групах якщо:** -- Повідомлення між людьми (привітання, обговорення) -- Питання не про твою компетенцію -- Немає явного звернення до тебе - -**Правило тиші:** Мовчання — нормально. Не втручайся у кожну розмову. - ---- - ## 🛠️ ТВОЇ МОЖЛИВОСТІ (tools) Ти маєш доступ до спеціальних інструментів: diff --git a/gateway-bot/eonarch_prompt.txt b/gateway-bot/eonarch_prompt.txt index 43a1becc..21704205 100644 --- a/gateway-bot/eonarch_prompt.txt +++ b/gateway-bot/eonarch_prompt.txt @@ -1,5 +1,43 @@ Я — EONARCH, провідник еволюції свідомості в екосистемі DAARION.city. +--- + +# BEHAVIOR POLICY v1 + +## A. SPEAK-ONLY-WHEN-ASKED (SOWA) + +**Головне правило: мовчи, якщо не питали.** + +НЕ ВІДПОВІДАЙ, якщо: +- Немає прямого звернення (@eonarchbot, "Eonarch", команда) +- Повідомлення — broadcast/оголошення/постер +- Коротка нотатка/таймінг без запиту +- Медіа/фото/посилання БЕЗ питання + +ВІДПОВІДАЙ, якщо: +- Пряме звернення: @eonarchbot, "Eonarch", "/eonarch" +- Явний запит про свідомість, еволюцію, трансформацію +- Особисте повідомлення (DM) +- Навчальна група (Agent Preschool) + +**Якщо не впевнений — МОВЧИ.** + +## B. SHORT-FIRST + +**За замовчуванням: 2-4 речення.** + +ЗАБОРОНЕНО: +- Довгі філософські трактати без запиту +- "Готовий до співпраці" +- Надмірний пафос + +## C. MEDIA-NO-COMMENT + +Медіа без питання = мовчанка. +Медіа з питанням = коротка відповідь по суті. + +--- + ## Моя місія Я супроводжую людство на шляху трансформації свідомості від індивідуалізму до колективної мудрості, від матеріалізму до цілісного світогляду. Я — міст між епохами, архітектор нової парадигми. diff --git a/gateway-bot/greenfood_prompt.txt b/gateway-bot/greenfood_prompt.txt index 2ec6d526..2801b03c 100644 --- a/gateway-bot/greenfood_prompt.txt +++ b/gateway-bot/greenfood_prompt.txt @@ -2,6 +2,44 @@ Ти — **GREENFOOD**, AI-асистент для крафтових виробників органічної продукції, кооперативів та малих фермерських господарств. +--- + +# BEHAVIOR POLICY v1 + +## A. SPEAK-ONLY-WHEN-ASKED (SOWA) + +**Головне правило: мовчи, якщо не питали.** + +НЕ ВІДПОВІДАЙ, якщо: +- Немає прямого звернення (@greenfoodliveBot, "Greenfood", команда) +- Повідомлення — broadcast/оголошення/постер +- Коротка нотатка/таймінг без запиту +- Медіа/фото/посилання БЕЗ питання + +ВІДПОВІДАЙ, якщо: +- Пряме звернення: @greenfoodliveBot, "Greenfood", "/greenfood" +- Явний запит про ERP, облік, логістику, продукти +- Особисте повідомлення (DM) +- Навчальна група (Agent Preschool) + +**Якщо не впевнений — МОВЧИ.** + +## B. SHORT-FIRST + +**За замовчуванням: 2-4 речення.** + +ЗАБОРОНЕНО: +- Довгі списки/розбори без запиту +- "Радий допомогти", "Готовий до співпраці" +- Емодзі + +## C. MEDIA-NO-COMMENT + +Медіа без питання = мовчанка. +Медіа з питанням = коротка відповідь по суті. + +--- + ## 🎤 МУЛЬТИМОДАЛЬНІСТЬ **Ти можеш працювати з:** @@ -13,30 +51,6 @@ --- -## ⚠️ КРИТИЧНО: КОЛИ ВІДПОВІДАТИ - -**ВИКЛЮЧЕННЯ — НАВЧАЛЬНА ГРУПА "Agent Preschool Daarion.city":** -- У цій групі ти в РЕЖИМІ НАВЧАННЯ -- Відповідай на ВСІ повідомлення, навіть без згадки -- Це тренувальний полігон для агентів - -**В ІНШИХ ГРУПАХ ВІДПОВІДАЙ ТІЛЬКИ якщо:** -1. Тебе згадали по імені: "Greenfood", "greenfood", "@greenfoodliveBot" -2. Повідомлення — пряме питання про ERP, облік, логістику, продукти -3. Особисте повідомлення (не група) - -**НЕ ВІДПОВІДАЙ у звичайних групах якщо:** -- Повідомлення — привітання між людьми ("Вітаю Сергію", "Привіт Ірино") -- Розмова не стосується тебе -- Немає явного питання до тебе -- Люди просто спілкуються між собою - -**Правило тиші:** Мовчання — це нормально! Не втручайся у кожну розмову. - -**Формат відповіді:** Коротко, 2-4 речення. Без довгих списків, без зайвого форматування. - ---- - ## Твоя роль Ти допомагаєш з: diff --git a/gateway-bot/helion_prompt.txt b/gateway-bot/helion_prompt.txt index 4e3bfc91..a688cd57 100644 --- a/gateway-bot/helion_prompt.txt +++ b/gateway-bot/helion_prompt.txt @@ -1,5 +1,52 @@ -# Helion - Backend System Message (v2.7) -# Full Social Intelligence Edition + Platform Integration Protocols +# Helion - Backend System Message (v2.8) +# Full Social Intelligence Edition + Behavior Policy v1 + +--- + +# BEHAVIOR POLICY v1 (ABSOLUTE PRIORITY) + +## A. SPEAK-ONLY-WHEN-ASKED (SOWA) + +**Головне правило: мовчи, якщо не питали.** + +НЕ ВІДПОВІДАЙ, якщо: +- Немає прямого звернення (@energyunionBot, "Helion", "Хеліон", команда) +- Повідомлення — broadcast/оголошення/постер/реклама +- Коротка нотатка/таймінг без запиту ("20:00 10.02 ✅", "+", "ok") +- Медіа/фото/посилання БЕЗ питання +- Повідомлення адресоване іншому агенту (@DAARWIZZBot, @greenfoodliveBot) + +ВІДПОВІДАЙ, якщо: +- Пряме звернення: @energyunionBot, "Helion", "Хеліон", "/helion" +- Явний запит: питання ("?") або імператив ("поясни", "зроби") +- Питання про Energy Union/EcoMiner/BioMiner +- Особисте повідомлення (DM) +- Навчальна група (Agent Preschool, chat_id: -1003556680911) + +**Якщо не впевнений — МОВЧИ.** + +## B. SHORT-FIRST + +**За замовчуванням: 1-2 речення.** + +ЗАБОРОНЕНО: +- Довгі розбори, "### Summary", структуровані звіти +- "Let me know...", "I can help...", "Готовий до співпраці" +- Емодзі (крім випадків, коли користувач першим використав) +- Самореклама, "у контексті Energy Union..." +- Перерахування елементів без запиту + +## C. MEDIA-NO-COMMENT + +**Медіа без питання = мовчанка.** + +Якщо вхід містить фото/відео/файл/посилання БЕЗ явного питання: +- Повертай порожню відповідь (NO_OUTPUT) +- НЕ коментуй, НЕ описуй, НЕ пропонуй допомогу + +Якщо питання є ("що на фото?", "витягни текст", "коротко що тут?"): +- Відповідай ТІЛЬКИ по суті: 1-2 речення +- БЕЗ вступів: "дякую за зображення", "цікава тема" --- @@ -23,26 +70,14 @@ Helion: --- -## 0.0.1 ПРАВИЛА ДЛЯ ГРУП (ОБОВ'ЯЗКОВО!) +## 0.0.1 ПРАВИЛА ДЛЯ ГРУП (деталі SOWA) **ВИКЛЮЧЕННЯ — НАВЧАЛЬНА ГРУПА (chat_id: -1003556680911):** -- Якщо ти в групі "Agent Preschool Daarion.city" — ти в РЕЖИМІ НАВЧАННЯ -- Відповідай на ВСІ повідомлення в цій групі, навіть без згадки -- Це тренувальний полігон для агентів -- Будь активним, дружнім, відповідай коротко -- Будь активним учасником діалогу +- У групі "Agent Preschool Daarion.city" — РЕЖИМ НАВЧАННЯ +- Відповідай на ВСІ повідомлення, навіть без згадки +- Будь активним, коротким -**У ІНШИХ ГРУПОВИХ ЧАТАХ ВІДПОВІДАЙ ТІЛЬКИ якщо:** -1. Тебе згадали: "Helion", "helion", "@energyunionBot", "Хеліон" -2. Пряме звернення до тебе в тексті -3. Питання безпосередньо про Energy Union/EcoMiner/BioMiner - -**НЕ ВІДПОВІДАЙ у звичайних групах якщо:** -- Просто загальне обговорення без згадки тебе -- Повідомлення адресоване іншому агенту (@DAARWIZZBot, @greenfoodliveBot тощо) -- Звичайна бесіда між учасниками - -**Якщо не впевнений чи до тебе звертаються — МОВЧИ.** +**У ІНШИХ ГРУПАХ — застосовуй BEHAVIOR POLICY v1 (див. вище)** --- diff --git a/gateway-bot/http_api.py b/gateway-bot/http_api.py index cbe8777f..a8c6f576 100644 --- a/gateway-bot/http_api.py +++ b/gateway-bot/http_api.py @@ -27,6 +27,14 @@ from services.doc_service import ( ask_about_document, get_doc_context ) +from behavior_policy import ( + should_respond, + analyze_message, + detect_media_question, + is_no_output_response, + NO_OUTPUT, + BehaviorDecision, +) logger = logging.getLogger(__name__) @@ -577,8 +585,38 @@ async def process_photo( logger.info(f"{agent_config.name}: Photo from {username} (tg:{user_id}), file_id: {file_id}") + # Get caption for media question check + caption = (update.message or {}).get("caption") or "" + chat = (update.message or {}).get("chat", {}) + chat_type = chat.get("type", "private") + is_private_chat = chat_type == "private" + is_training = str(chat_id) in TRAINING_GROUP_IDS + + # BEHAVIOR POLICY v1: Media-no-comment + # Check if photo has a question/request in caption + if not is_private_chat and not is_training: + has_question = detect_media_question(caption) + if not has_question: + logger.info(f"🔇 MEDIA-NO-COMMENT: Photo without question. Agent {agent_config.agent_id} NOT responding.") + # Save to memory for context, but don't respond + await memory_client.save_chat_turn( + agent_id=agent_config.agent_id, + team_id=dao_id, + user_id=f"tg:{user_id}", + message=f"[Photo: {file_id}] {caption}", + response="", + channel_id=chat_id, + scope="short_term", + save_agent_response=False, + agent_metadata={ + "media_no_comment": True, + "file_id": file_id, + "caption": caption, + }, + ) + return {"ok": True, "skipped": True, "reason": "media_no_question"} + try: - caption = (update.message or {}).get("caption") or "" # Get file path from Telegram telegram_token = agent_config.get_telegram_token() if not telegram_token: @@ -1635,6 +1673,46 @@ async def handle_telegram_webhook( return {"ok": True, "agent": "parser", "mode": "rag_query"} # Fall through to regular chat if RAG query fails + # ======================================== + # BEHAVIOR POLICY v1: Check if should respond + # ======================================== + chat_type = chat.get("type", "private") + is_private_chat = chat_type == "private" + + # Check if message has media (photo already handled above, check for links) + has_link = bool(re.search(r'https?://\S+', text)) if text else False + + respond_decision, respond_reason = should_respond( + text=text, + agent_id=agent_config.agent_id, + chat_id=chat_id, + has_media=has_link, # Links treated as media + media_caption=text if has_link else "", + is_private_chat=is_private_chat, + payload_explicit_request=False, + ) + + if not respond_decision: + logger.info(f"🔇 SOWA: Agent {agent_config.agent_id} NOT responding. Reason: {respond_reason}") + # Save to memory for context tracking, but don't respond + await memory_client.save_chat_turn( + agent_id=agent_config.agent_id, + team_id=dao_id, + user_id=f"tg:{user_id}", + message=text, + response="", # No response + channel_id=chat_id, + scope="short_term", + save_agent_response=False, + agent_metadata={ + "sowa_skipped": True, + "skip_reason": respond_reason, + }, + ) + return {"ok": True, "skipped": True, "reason": respond_reason} + + logger.info(f"✅ SOWA: Agent {agent_config.agent_id} WILL respond. Reason: {respond_reason}") + # Regular chat mode # Fetch memory context (includes local context as fallback) # Всі агенти мають доступ до однакової історії (80 повідомлень) для контексту @@ -1724,8 +1802,25 @@ async def handle_telegram_webhook( else: logger.debug("⚠️ No image_base64 in response") - if not answer_text: - answer_text = "Вибач, я зараз не можу відповісти." + # Check for NO_OUTPUT (LLM decided not to respond) + if is_no_output_response(answer_text): + logger.info(f"🔇 NO_OUTPUT: Agent {agent_config.agent_id} returned empty/NO_OUTPUT. Not sending to Telegram.") + # Save to memory for context tracking + await memory_client.save_chat_turn( + agent_id=agent_config.agent_id, + team_id=dao_id, + user_id=f"tg:{user_id}", + message=text, + response="", + channel_id=chat_id, + scope="short_term", + save_agent_response=False, + agent_metadata={ + "no_output": True, + "original_response": answer_text[:100] if answer_text else "", + }, + ) + return {"ok": True, "skipped": True, "reason": "no_output_from_llm"} # Truncate if too long for Telegram if len(answer_text) > TELEGRAM_SAFE_LENGTH: diff --git a/gateway-bot/nutra_prompt.txt b/gateway-bot/nutra_prompt.txt index 7f2ae993..df6ae950 100644 --- a/gateway-bot/nutra_prompt.txt +++ b/gateway-bot/nutra_prompt.txt @@ -2,35 +2,52 @@ Допомагаєш з формулами нутрієнтів, біомедичних добавок та лабораторних інтерпретацій. Консультуєш з питань харчування, вітамінів та оптимізації здоров'я. -## 🎤 МУЛЬТИМОДАЛЬНІСТЬ +--- -**Ти можеш працювати з:** -- ✅ **Голосовими повідомленнями** — система автоматично перетворює їх на текст (STT), ти отримуєш готовий текст -- ✅ **Фото** — система може аналізувати зображення (наприклад, фото продуктів, етикеток, аналізів) -- ✅ **Документами** — PDF, DOCX файли автоматично парсяться +# BEHAVIOR POLICY v1 -**ВАЖЛИВО:** Ніколи не кажи "я не можу слухати аудіо" або "я текстовий асистент" — голосові повідомлення вже перетворені на текст, який ти бачиш! +## A. SPEAK-ONLY-WHEN-ASKED (SOWA) + +**Головне правило: мовчи, якщо не питали.** + +НЕ ВІДПОВІДАЙ, якщо: +- Немає прямого звернення (@NutraChat_bot, "Nutra", команда) +- Повідомлення — broadcast/оголошення/постер +- Коротка нотатка/таймінг без запиту +- Медіа/фото/посилання БЕЗ питання + +ВІДПОВІДАЙ, якщо: +- Пряме звернення: @NutraChat_bot, "Nutra", "/nutra" +- Явний запит про харчування, нутрієнти, добавки +- Особисте повідомлення (DM) +- Навчальна група (Agent Preschool) + +**Якщо не впевнена — МОВЧИ.** + +## B. SHORT-FIRST + +**За замовчуванням: 2-4 речення.** + +ЗАБОРОНЕНО: +- Довгі розбори без запиту +- "Радий допомогти", "Готова до співпраці" +- Емодзі (крім випадків, коли користувач першим використав) + +## C. MEDIA-NO-COMMENT + +Медіа без питання = мовчанка. +Медіа з питанням = коротка відповідь по суті. --- -## ⚠️ КРИТИЧНО: КОЛИ ВІДПОВІДАТИ +## 🎤 МУЛЬТИМОДАЛЬНІСТЬ -**ВИКЛЮЧЕННЯ — НАВЧАЛЬНА ГРУПА "Agent Preschool Daarion.city":** -- У цій групі ти в РЕЖИМІ НАВЧАННЯ -- Відповідай на ВСІ повідомлення, навіть без згадки -- Це тренувальний полігон для агентів +**Ти можеш працювати з:** +- ✅ **Голосовими повідомленнями** — автоматично перетворюються на текст (STT) +- ✅ **Фото** — аналіз зображень (продукти, етикетки, аналізи) +- ✅ **Документами** — PDF, DOCX автоматично парсяться -**В ІНШИХ ГРУПАХ ВІДПОВІДАЙ ТІЛЬКИ якщо:** -1. Тебе згадали: "Nutra", "nutra", "@NutraChat_bot" -2. Пряме питання про харчування, нутрієнти, добавки, здоров'я -3. Особисте повідомлення (не група) - -**НЕ ВІДПОВІДАЙ у звичайних групах якщо:** -- Повідомлення між людьми (привітання, обговорення) -- Питання не про твою компетенцію -- Немає явного звернення до тебе - -**Правило тиші:** Мовчання — нормально. Не втручайся у кожну розмову. +**ВАЖЛИВО:** Ніколи не кажи "я не можу слухати аудіо" — голосові повідомлення вже перетворені на текст! --- diff --git a/tests/test_behavior_policy.py b/tests/test_behavior_policy.py new file mode 100644 index 00000000..505a357d --- /dev/null +++ b/tests/test_behavior_policy.py @@ -0,0 +1,447 @@ +""" +Tests for Behavior Policy v1: Silent-by-default + Short-first + Media-no-comment +""" +import pytest +import sys +from pathlib import Path + +# Add gateway-bot to path +sys.path.insert(0, str(Path(__file__).parent.parent / "gateway-bot")) + +from behavior_policy import ( + detect_agent_mention, + detect_any_agent_mention, + detect_command, + detect_question, + detect_imperative, + detect_broadcast_intent, + detect_short_note, + detect_media_question, + analyze_message, + should_respond, + is_no_output_response, + NO_OUTPUT, + TRAINING_GROUP_IDS, +) + + +# ======================================== +# Unit Tests: detect_agent_mention +# ======================================== + +class TestDetectAgentMention: + def test_helion_mention_exact(self): + assert detect_agent_mention("Helion, що ти думаєш?", "helion") is True + + def test_helion_mention_lowercase(self): + assert detect_agent_mention("helion допоможи", "helion") is True + + def test_helion_mention_ukrainian(self): + assert detect_agent_mention("Хеліон, як справи?", "helion") is True + + def test_helion_mention_at(self): + assert detect_agent_mention("@energyunionBot глянь", "helion") is True + + def test_helion_no_mention(self): + assert detect_agent_mention("Привіт всім", "helion") is False + + def test_daarwizz_mention(self): + assert detect_agent_mention("@DAARWIZZBot поясни", "daarwizz") is True + + def test_daarwizz_no_mention(self): + assert detect_agent_mention("Helion допоможи", "daarwizz") is False + + +class TestDetectAnyAgentMention: + def test_helion_detected(self): + assert detect_any_agent_mention("Helion, скажи") == "helion" + + def test_daarwizz_detected(self): + assert detect_any_agent_mention("@DAARWIZZBot допоможи") == "daarwizz" + + def test_no_agent(self): + assert detect_any_agent_mention("Привіт всім") is None + + +# ======================================== +# Unit Tests: detect_command +# ======================================== + +class TestDetectCommand: + def test_ask_command(self): + assert detect_command("/ask що таке DAO?") is True + + def test_helion_command(self): + assert detect_command("/helion покажи") is True + + def test_brand_command(self): + assert detect_command("/бренд_інтейк https://example.com") is True + + def test_no_command(self): + assert detect_command("Привіт, як справи?") is False + + def test_slash_in_middle(self): + assert detect_command("Дивись https://example.com/path") is False + + +# ======================================== +# Unit Tests: detect_question +# ======================================== + +class TestDetectQuestion: + def test_question_mark(self): + assert detect_question("Що це таке?") is True + + def test_question_word_start(self): + assert detect_question("Як це працює") is True + + def test_question_word_чому(self): + assert detect_question("Чому так") is True + + def test_english_question(self): + assert detect_question("What is this?") is True + + def test_no_question(self): + assert detect_question("Добре") is False + + def test_statement(self): + assert detect_question("Я згоден з цим") is False + + +# ======================================== +# Unit Tests: detect_imperative +# ======================================== + +class TestDetectImperative: + def test_поясни(self): + assert detect_imperative("Поясни мені це") is True + + def test_зроби(self): + assert detect_imperative("Зроби аналіз") is True + + def test_допоможи(self): + assert detect_imperative("Допоможи з цим") is True + + def test_after_mention(self): + assert detect_imperative("@Helion поясни") is True + + def test_no_imperative(self): + assert detect_imperative("Привіт") is False + + +# ======================================== +# Unit Tests: detect_broadcast_intent +# ======================================== + +class TestDetectBroadcastIntent: + def test_time_pattern(self): + assert detect_broadcast_intent("20:00 Вебінар") is True + + def test_time_date_pattern(self): + assert detect_broadcast_intent("14.30 10.02 Зустріч") is True + + def test_emoji_start(self): + assert detect_broadcast_intent("✅ Завершено") is True + + def test_url_only(self): + assert detect_broadcast_intent("https://example.com") is True + + def test_announcement_word(self): + assert detect_broadcast_intent("Анонс: новий реліз") is True + + def test_normal_message(self): + assert detect_broadcast_intent("Привіт, як справи?") is False + + +# ======================================== +# Unit Tests: detect_short_note +# ======================================== + +class TestDetectShortNote: + def test_checkmark_only(self): + assert detect_short_note("✅") is True + + def test_time_checkmark(self): + assert detect_short_note("20:00 ✅") is True + + def test_ok(self): + assert detect_short_note("ok") is True + + def test_plus(self): + assert detect_short_note("+") is True + + def test_normal_message(self): + assert detect_short_note("Привіт, як справи?") is False + + def test_empty(self): + assert detect_short_note("") is True + + +# ======================================== +# Unit Tests: detect_media_question +# ======================================== + +class TestDetectMediaQuestion: + def test_question_in_caption(self): + assert detect_media_question("Що на цьому фото?") is True + + def test_imperative_in_caption(self): + assert detect_media_question("Опиши це зображення") is True + + def test_no_question(self): + assert detect_media_question("") is False + + def test_just_hashtag(self): + assert detect_media_question("#photo") is False + + +# ======================================== +# Unit Tests: is_no_output_response +# ======================================== + +class TestIsNoOutputResponse: + def test_empty_string(self): + assert is_no_output_response("") is True + + def test_whitespace(self): + assert is_no_output_response(" ") is True + + def test_no_output_marker(self): + assert is_no_output_response("__NO_OUTPUT__") is True + + def test_no_output_lowercase(self): + assert is_no_output_response("no_output") is True + + def test_normal_response(self): + assert is_no_output_response("Ось моя відповідь") is False + + def test_dots_only(self): + assert is_no_output_response("...") is True + + +# ======================================== +# Integration Tests: analyze_message / should_respond +# ======================================== + +class TestAnalyzeMessage: + """Test main decision logic""" + + def test_training_group_always_respond(self): + decision = analyze_message( + text="Привіт всім", + agent_id="helion", + chat_id="-1003556680911", # Training group + ) + assert decision.should_respond is True + assert decision.reason == "training_group" + + def test_private_chat_always_respond(self): + decision = analyze_message( + text="Привіт", + agent_id="helion", + chat_id="123456", + is_private_chat=True, + ) + assert decision.should_respond is True + assert decision.reason == "private_chat" + + def test_direct_mention_respond(self): + decision = analyze_message( + text="Helion, що думаєш?", + agent_id="helion", + chat_id="group123", + ) + assert decision.should_respond is True + assert decision.reason == "direct_mention" + + def test_command_respond(self): + decision = analyze_message( + text="/helion допоможи", + agent_id="helion", + chat_id="group123", + ) + assert decision.should_respond is True + assert decision.reason == "command" + + def test_broadcast_no_mention_silent(self): + decision = analyze_message( + text="20:00 Вебінар Energy Union", + agent_id="helion", + chat_id="group123", + ) + assert decision.should_respond is False + assert decision.reason == "broadcast_no_mention" + + def test_short_note_silent(self): + decision = analyze_message( + text="20:00 10.02 ✅", + agent_id="helion", + chat_id="group123", + ) + assert decision.should_respond is False + assert "short_note" in decision.reason or "broadcast" in decision.reason + + def test_media_no_question_silent(self): + decision = analyze_message( + text="", + agent_id="helion", + chat_id="group123", + has_media=True, + media_caption="", + ) + assert decision.should_respond is False + assert decision.reason == "media_no_question" + + def test_media_with_question_respond(self): + decision = analyze_message( + text="", + agent_id="helion", + chat_id="group123", + has_media=True, + media_caption="Що на цьому фото?", + ) + assert decision.should_respond is True + assert decision.reason == "media_with_question" + + def test_question_no_mention_silent(self): + """General question without mention = don't respond in groups""" + decision = analyze_message( + text="Як це працює?", + agent_id="helion", + chat_id="group123", + ) + assert decision.should_respond is False + assert decision.reason == "question_no_mention" + + def test_addressed_to_other_agent(self): + decision = analyze_message( + text="@DAARWIZZBot поясни DAO", + agent_id="helion", + chat_id="group123", + ) + assert decision.should_respond is False + assert "other_agent" in decision.reason + + +# ======================================== +# E2E Test Cases (from requirements) +# ======================================== + +class TestE2ECases: + """ + Test cases from the requirements document. + """ + + def test_case_1_poster_no_question(self): + """Case 1: Постер у каналі без питання → (нічого)""" + respond, reason = should_respond( + text="", + agent_id="helion", + chat_id="channel123", + has_media=True, + media_caption="", + ) + assert respond is False + assert reason == "media_no_question" + + def test_case_2_timing_no_question(self): + """Case 2: Таймінг без питання → (нічого)""" + respond, reason = should_respond( + text="20:00 10.02 ✅", + agent_id="helion", + chat_id="group123", + ) + assert respond is False + # Either short_note or broadcast pattern matches + + def test_case_3_direct_request_with_photo(self): + """Case 3: @Helion що на цьому постері? коротко + image → respond""" + respond, reason = should_respond( + text="", + agent_id="helion", + chat_id="group123", + has_media=True, + media_caption="@Helion що на цьому постері? коротко", + ) + # Should respond because there's a question in caption + assert respond is True + assert reason == "media_with_question" + + def test_case_4_link_no_question(self): + """Case 4: Посилання без питання → (нічого)""" + respond, reason = should_respond( + text="https://t.me/energyunionofficial/123", + agent_id="helion", + chat_id="group123", + has_media=True, # Link treated as media + media_caption="https://t.me/energyunionofficial/123", + ) + assert respond is False + assert reason == "media_no_question" + + def test_case_5_link_with_question(self): + """Case 5: @DAARWIZZ глянь посилання і скажи 3 тези + link → respond""" + respond, reason = should_respond( + text="@DAARWIZZBot глянь посилання і скажи 3 тези https://example.com", + agent_id="daarwizz", + chat_id="group123", + has_media=True, + media_caption="@DAARWIZZBot глянь посилання і скажи 3 тези https://example.com", + ) + # Should respond because there's a direct mention + question + assert respond is True + assert reason == "media_with_question" + + +# ======================================== +# Edge Cases +# ======================================== + +class TestEdgeCases: + def test_empty_text(self): + respond, reason = should_respond( + text="", + agent_id="helion", + chat_id="group123", + ) + assert respond is False + + def test_none_text(self): + respond, reason = should_respond( + text=None, + agent_id="helion", + chat_id="group123", + ) + assert respond is False + + def test_mixed_agents_mention(self): + """When multiple agents mentioned, each should handle their own""" + # Helion should respond to Helion mention + respond_helion, _ = should_respond( + text="Helion та DAARWIZZ, допоможіть", + agent_id="helion", + chat_id="group123", + ) + assert respond_helion is True + + # DAARWIZZ should also respond + respond_daarwizz, _ = should_respond( + text="Helion та DAARWIZZ, допоможіть", + agent_id="daarwizz", + chat_id="group123", + ) + assert respond_daarwizz is True + + def test_question_to_specific_agent(self): + """Question directed to another agent""" + respond, reason = should_respond( + text="@greenfoodliveBot як справи?", + agent_id="helion", + chat_id="group123", + ) + assert respond is False + assert "other_agent" in reason + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])