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

@@ -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: