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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user