Includes updates across gateway, router, node-worker, memory-service, aurora-service, swapper, sofiia-console UI and node2 infrastructure: - gateway-bot: Dockerfile, http_api.py, druid/aistalk prompts, doc_service - services/router: main.py, router-config.yml, fabric_metrics, memory_retrieval, offload_client, prompt_builder - services/node-worker: worker.py, main.py, config.py, fabric_metrics - services/memory-service: Dockerfile, database.py, main.py, requirements - services/aurora-service: main.py (+399), kling.py, quality_report.py - services/swapper-service: main.py, swapper_config_node2.yaml - services/sofiia-console: static/index.html (console UI update) - config: agent_registry, crewai_agents/teams, router_agents - ops/fabric_preflight.sh: updated preflight checks - router-config.yml, docker-compose.node2.yml: infra updates - docs: NODA1-AGENT-ARCHITECTURE, fabric_contract updated Made-with: Cursor
5189 lines
224 KiB
Python
5189 lines
224 KiB
Python
"""
|
||
Bot Gateway HTTP API
|
||
Handles incoming webhooks from Telegram, Discord, etc.
|
||
"""
|
||
import asyncio
|
||
import base64
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import logging
|
||
import os
|
||
import sys
|
||
import time
|
||
import uuid
|
||
import httpx
|
||
from pathlib import Path
|
||
from typing import Dict, Any, Optional, List, Tuple
|
||
from datetime import datetime
|
||
from dataclasses import dataclass
|
||
from io import BytesIO
|
||
|
||
from fastapi import APIRouter, HTTPException
|
||
from pydantic import BaseModel
|
||
|
||
from router_client import send_to_router
|
||
from memory_client import memory_client
|
||
from vision_guard import (
|
||
extract_label_from_response as _vg_extract_label,
|
||
get_vision_lock as _vg_get_lock,
|
||
set_vision_lock as _vg_set_lock,
|
||
clear_vision_lock as _vg_clear_lock,
|
||
set_user_label as _vg_set_user_label,
|
||
detect_user_override as _vg_detect_override,
|
||
should_skip_reanalysis as _vg_should_skip,
|
||
build_low_confidence_clarifier as _vg_build_low_conf,
|
||
build_locked_reply as _vg_build_locked_reply,
|
||
)
|
||
from services.doc_service import (
|
||
parse_document,
|
||
ingest_document,
|
||
ask_about_document,
|
||
get_doc_context,
|
||
save_chat_doc_context,
|
||
get_chat_doc_context,
|
||
fetch_telegram_file_bytes,
|
||
extract_summary_from_bytes,
|
||
upsert_chat_doc_context_with_summary,
|
||
)
|
||
from behavior_policy import (
|
||
should_respond,
|
||
analyze_message,
|
||
detect_media_question,
|
||
detect_explicit_request,
|
||
detect_url,
|
||
detect_agent_mention,
|
||
is_no_output_response,
|
||
record_interaction,
|
||
record_ack,
|
||
get_ack_text,
|
||
is_prober_request,
|
||
has_agent_chat_participation,
|
||
has_recent_interaction,
|
||
NO_OUTPUT,
|
||
BehaviorDecision,
|
||
AGENT_NAME_VARIANTS,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _safe_has_recent_interaction(agent_id: str, chat_id: str, user_id: str) -> bool:
|
||
"""Guard: avoid 500 if has_recent_interaction is missing or raises. Returns False on any error."""
|
||
try:
|
||
return bool(has_recent_interaction(agent_id, str(chat_id), str(user_id)))
|
||
except Exception as e:
|
||
logger.warning("has_recent_interaction failed, treating as False: %s", e)
|
||
return False
|
||
|
||
|
||
# Telegram message length limits
|
||
TELEGRAM_MAX_MESSAGE_LENGTH = 4096
|
||
TELEGRAM_SAFE_LENGTH = 3500 # Leave room for formatting
|
||
# Operator pending state cache (chat_id -> {ts, items})
|
||
LAST_PENDING_STATE: Dict[str, Dict[str, Any]] = {}
|
||
PENDING_STATE_TTL = 1800 # 30 minutes
|
||
|
||
|
||
# Per-user language preference cache (chat_id:user_id -> {lang, ts})
|
||
USER_LANGUAGE_PREFS: Dict[str, Dict[str, Any]] = {}
|
||
USER_LANGUAGE_PREF_TTL = 30 * 24 * 3600 # 30 days
|
||
|
||
# Per-user response style cache (agent:chat:user -> {style, ts})
|
||
USER_RESPONSE_STYLE_PREFS: Dict[str, Dict[str, Any]] = {}
|
||
USER_RESPONSE_STYLE_PREF_TTL = 30 * 24 * 3600 # 30 days
|
||
|
||
# Recent photo context for follow-up questions in chat (agent:chat:user -> {file_id, ts})
|
||
RECENT_PHOTO_CONTEXT: Dict[str, Dict[str, Any]] = {}
|
||
RECENT_PHOTO_TTL = 30 * 60 # 30 minutes
|
||
AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID = "agent:agromatrix:global"
|
||
|
||
|
||
def _cleanup_recent_photo_context() -> None:
|
||
now = time.time()
|
||
expired = [k for k, v in RECENT_PHOTO_CONTEXT.items() if now - float(v.get("ts", 0)) > RECENT_PHOTO_TTL]
|
||
for k in expired:
|
||
del RECENT_PHOTO_CONTEXT[k]
|
||
|
||
|
||
def _set_recent_photo_context(agent_id: str, chat_id: str, user_id: str, file_id: str) -> None:
|
||
_cleanup_recent_photo_context()
|
||
key = f"{agent_id}:{chat_id}:{user_id}"
|
||
RECENT_PHOTO_CONTEXT[key] = {"file_id": file_id, "ts": time.time()}
|
||
|
||
|
||
def _get_recent_photo_file_id(agent_id: str, chat_id: str, user_id: str) -> Optional[str]:
|
||
_cleanup_recent_photo_context()
|
||
key = f"{agent_id}:{chat_id}:{user_id}"
|
||
rec = RECENT_PHOTO_CONTEXT.get(key)
|
||
if not rec:
|
||
return None
|
||
return rec.get("file_id")
|
||
|
||
|
||
def _extract_recent_photo_file_id_from_memory(memory_context: Dict[str, Any]) -> Optional[str]:
|
||
"""
|
||
Extract last seen Telegram photo file_id from memory context.
|
||
Looks for patterns like: [Photo: <file_id>]
|
||
"""
|
||
if not memory_context:
|
||
return None
|
||
|
||
pattern = re.compile(r"\[Photo:\s*([^\]\s]+)\]")
|
||
|
||
recent_events = memory_context.get("recent_events", []) or []
|
||
for ev in reversed(recent_events):
|
||
body = (ev.get("body_text") or "").strip()
|
||
if not body:
|
||
continue
|
||
m = pattern.search(body)
|
||
if m:
|
||
return m.group(1)
|
||
|
||
local_text = memory_context.get("local_context_text") or ""
|
||
for line in reversed(local_text.splitlines()):
|
||
m = pattern.search(line)
|
||
if m:
|
||
return m.group(1)
|
||
return None
|
||
|
||
|
||
def _looks_like_photo_followup(text: str) -> bool:
|
||
if not text:
|
||
return False
|
||
t = text.strip().lower()
|
||
direct_markers = [
|
||
"що ти бачиш", "що на фото", "що на зображенні", "опиши фото", "подивись фото",
|
||
"що на цьому фото", "що на цій фотографії", "що на цій світлині",
|
||
"проаналізуй фото", "аналіз фото", "переглянь фото", "повернись до фото",
|
||
"яка це рослина", "що це за рослина", "що за рослина", "що за культура",
|
||
"яка культура", "визнач рослину",
|
||
"what do you see", "what is in the image", "describe the photo",
|
||
"analyze the photo", "analyze image", "what plant is this",
|
||
"что ты видишь", "что на фото", "опиши фото", "посмотри фото",
|
||
"проанализируй фото", "какое это растение", "что за растение",
|
||
]
|
||
if any(m in t for m in direct_markers):
|
||
return True
|
||
|
||
# Flexible forms: "що на ... фото/зображенні/світлині"
|
||
if re.search(r"(що|what|что)\s+на\s+.*(фото|зображ|світлин|image|photo)", t):
|
||
# Exclude common meta-questions
|
||
meta_exclude = ["канал", "чат", "бот", "нормально"]
|
||
if not any(ex in t for ex in meta_exclude):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _extract_agromatrix_correction_label(text: str) -> Optional[str]:
|
||
"""
|
||
Extract corrected plant label from free-form user feedback.
|
||
Examples:
|
||
- "це соняшник"
|
||
- "це не кабачок, а гарбуз"
|
||
- "правильна відповідь: кукурудза"
|
||
"""
|
||
raw = (text or "").strip()
|
||
if not raw:
|
||
return None
|
||
t = re.sub(r"\s+", " ", raw.lower())
|
||
|
||
patterns = [
|
||
r"правильн\w*\s+відповід\w*[:\-]?\s*([a-zа-яіїєґ0-9'’\-\s]{2,60})",
|
||
r"це\s+не\s+[a-zа-яіїєґ0-9'’\-\s]{1,60},?\s+а\s+([a-zа-яіїєґ0-9'’\-\s]{2,60})",
|
||
]
|
||
for pat in patterns:
|
||
m = re.search(pat, t)
|
||
if not m:
|
||
continue
|
||
label = re.sub(r"\s+", " ", (m.group(1) or "").strip(" .,!?:;\"'()[]{}"))
|
||
if not label:
|
||
continue
|
||
if len(label.split()) > 4:
|
||
continue
|
||
if label in {"не знаю", "помилка", "невірно", "не вірно"}:
|
||
continue
|
||
# Filter imperative/meta phrases that are not plant labels.
|
||
bad_prefixes = (
|
||
"не ", "в чат", "зробити", "напиши", "потрібно", "навіщо",
|
||
"ти ", "він ", "вона ", "це ", "а ", "і ",
|
||
)
|
||
if label.startswith(bad_prefixes):
|
||
continue
|
||
if any(x in label for x in ("повідом", "чат", "відповід", "потрібно", "не потрібно")):
|
||
continue
|
||
if re.search(r"\d", label):
|
||
continue
|
||
return label
|
||
return None
|
||
|
||
|
||
def _is_agromatrix_negative_feedback(text: str) -> bool:
|
||
t = (text or "").strip().lower()
|
||
if not t:
|
||
return False
|
||
markers = (
|
||
"це помилка",
|
||
"це не вірно",
|
||
"це невірно",
|
||
"неправильно",
|
||
"не вірно",
|
||
"невірно",
|
||
"не так",
|
||
"помилка у відповіді",
|
||
"відповідь не вірна",
|
||
"відповідь невірна",
|
||
)
|
||
return any(m in t for m in markers)
|
||
|
||
|
||
def _is_agromatrix_correction_only_message(text: str) -> bool:
|
||
t = (text or "").strip().lower()
|
||
if not t:
|
||
return False
|
||
# Treat as "correction only" when there is no direct question.
|
||
if "?" in t:
|
||
return False
|
||
markers = (
|
||
"це ", "правильна відповідь", "невірно", "не вірно", "це не",
|
||
"не так", "неправильно", "виправ",
|
||
)
|
||
return any(m in t for m in markers)
|
||
|
||
|
||
def _truncate_context_for_prompt(raw: str, *, max_chars: int = 2200, max_lines: int = 28) -> str:
|
||
if not raw:
|
||
return ""
|
||
lines = [ln for ln in raw.splitlines() if ln.strip()]
|
||
if len(lines) > max_lines:
|
||
lines = lines[-max_lines:]
|
||
out = "\n".join(lines)
|
||
if len(out) > max_chars:
|
||
out = out[-max_chars:]
|
||
# try to cut from next line boundary for cleaner prompt
|
||
pos = out.find("\n")
|
||
if 0 <= pos < 200:
|
||
out = out[pos + 1 :]
|
||
return out.strip()
|
||
|
||
|
||
def _agromatrix_observation_doc_id(file_id: str, label: str) -> str:
|
||
digest = hashlib.sha1(f"{file_id}:{label}".encode("utf-8")).hexdigest()[:16]
|
||
return f"agromatrix-photo-{digest}"
|
||
|
||
|
||
async def _save_agromatrix_photo_learning(
|
||
*,
|
||
file_id: str,
|
||
label: str,
|
||
source: str,
|
||
chat_id: str,
|
||
user_id: str,
|
||
dao_id: str,
|
||
) -> None:
|
||
"""
|
||
Persist non-private photo learning:
|
||
1) fact keyed by file_id for deterministic follow-ups
|
||
2) anonymized doc card in agromatrix_docs for semantic reuse
|
||
"""
|
||
if not file_id or not label:
|
||
return
|
||
now_iso = datetime.utcnow().isoformat()
|
||
try:
|
||
await memory_client.upsert_fact(
|
||
user_id=AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
|
||
fact_key=f"agromatrix:photo_label:{file_id}",
|
||
fact_value=label,
|
||
fact_value_json={
|
||
"label": label,
|
||
"source": source,
|
||
"updated_at": now_iso,
|
||
},
|
||
team_id=dao_id,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"AgroMatrix photo learning fact save failed: {e}")
|
||
|
||
# Best-effort semantic card, no personal data/chat text.
|
||
card_text = (
|
||
f"AgroMatrix plant observation.\n"
|
||
f"Validated label: {label}.\n"
|
||
f"Use as a prior hint for similar seedling/leaf photos.\n"
|
||
f"Source: {source}. Updated: {now_iso}."
|
||
)
|
||
try:
|
||
router_url = os.getenv("ROUTER_URL", "http://router:8000").rstrip("/")
|
||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||
await client.post(
|
||
f"{router_url}/v1/documents/ingest",
|
||
json={
|
||
"agent_id": "agromatrix",
|
||
"doc_id": _agromatrix_observation_doc_id(file_id, label),
|
||
"file_name": f"agromatrix_photo_learning_{label}.txt",
|
||
"text": card_text,
|
||
"dao_id": dao_id,
|
||
"user_id": AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
|
||
},
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"AgroMatrix photo learning ingest failed: {e}")
|
||
|
||
|
||
async def _invalidate_agromatrix_photo_learning(
|
||
*,
|
||
file_id: str,
|
||
reason: str,
|
||
dao_id: str,
|
||
) -> None:
|
||
if not file_id:
|
||
return
|
||
try:
|
||
await memory_client.upsert_fact(
|
||
user_id=AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
|
||
fact_key=f"agromatrix:photo_label:{file_id}",
|
||
fact_value="",
|
||
fact_value_json={
|
||
"label": "",
|
||
"invalidated": True,
|
||
"reason": reason,
|
||
"updated_at": datetime.utcnow().isoformat(),
|
||
},
|
||
team_id=dao_id,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"AgroMatrix photo learning invalidation failed: {e}")
|
||
|
||
|
||
async def _get_agromatrix_photo_prior(file_id: str, dao_id: str) -> Optional[str]:
|
||
if not file_id:
|
||
return None
|
||
try:
|
||
fact = await memory_client.get_fact(
|
||
user_id=AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
|
||
fact_key=f"agromatrix:photo_label:{file_id}",
|
||
team_id=dao_id,
|
||
)
|
||
if not fact:
|
||
return None
|
||
data = fact.get("fact_value_json") if isinstance(fact, dict) else None
|
||
if isinstance(data, dict):
|
||
if bool(data.get("invalidated")):
|
||
return None
|
||
label = str(data.get("label") or "").strip()
|
||
if label:
|
||
return label
|
||
label = str(fact.get("fact_value") or "").strip() if isinstance(fact, dict) else ""
|
||
return label or None
|
||
except Exception as e:
|
||
logger.warning(f"AgroMatrix photo prior lookup failed: {e}")
|
||
return None
|
||
|
||
|
||
async def _set_agromatrix_last_photo_ref(*, chat_id: str, user_id: str, file_id: str, dao_id: str) -> None:
|
||
if not (chat_id and user_id and file_id):
|
||
return
|
||
try:
|
||
await memory_client.upsert_fact(
|
||
user_id=AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
|
||
fact_key=f"agromatrix:last_photo:{chat_id}:{user_id}",
|
||
fact_value=file_id,
|
||
fact_value_json={"file_id": file_id, "updated_at": datetime.utcnow().isoformat()},
|
||
team_id=dao_id,
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"AgroMatrix last photo ref save failed: {e}")
|
||
|
||
|
||
async def _get_agromatrix_last_photo_ref(*, chat_id: str, user_id: str, dao_id: str) -> Optional[str]:
|
||
if not (chat_id and user_id):
|
||
return None
|
||
try:
|
||
fact = await memory_client.get_fact(
|
||
user_id=AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID,
|
||
fact_key=f"agromatrix:last_photo:{chat_id}:{user_id}",
|
||
team_id=dao_id,
|
||
)
|
||
if not fact:
|
||
return None
|
||
data = fact.get("fact_value_json") if isinstance(fact, dict) else None
|
||
if isinstance(data, dict):
|
||
file_id = str(data.get("file_id") or "").strip()
|
||
if file_id:
|
||
return file_id
|
||
file_id = str(fact.get("fact_value") or "").strip() if isinstance(fact, dict) else ""
|
||
return file_id or None
|
||
except Exception as e:
|
||
logger.warning(f"AgroMatrix last photo ref lookup failed: {e}")
|
||
return None
|
||
|
||
|
||
def _needs_photo_only_response(text: str) -> bool:
|
||
"""
|
||
Return True only for explicit requests to analyze/describe image content.
|
||
Do not trigger on meta-dialogue about previous mistakes.
|
||
"""
|
||
t = (text or "").strip().lower()
|
||
if not t:
|
||
return False
|
||
explicit_patterns = [
|
||
r"(що|what|что).{0,24}(на|in).{0,24}(фото|зображ|світлин|image|photo)",
|
||
r"(опиши|describe|проаналізуй|analyz|анализируй).{0,32}(фото|зображ|image|photo)",
|
||
r"(яка|какая|what).{0,28}(рослин|plant|культура).{0,28}(на|in).{0,28}(фото|image|photo)",
|
||
]
|
||
return any(re.search(p, t) for p in explicit_patterns)
|
||
|
||
|
||
def _is_simple_greeting(text: str) -> bool:
|
||
t = (text or "").strip().lower()
|
||
if not t:
|
||
return False
|
||
compact = re.sub(r"[^a-zа-яіїєґ0-9 ]+", "", t).strip()
|
||
greetings = {
|
||
"привіт", "вітаю", "добрий день", "доброго дня", "доброго вечора",
|
||
"hello", "hi", "hey", "good morning", "good evening",
|
||
}
|
||
if compact in greetings:
|
||
return True
|
||
# Short greeting variants like "привіт!" / "hi!"
|
||
return len(compact.split()) <= 3 and any(g in compact for g in greetings)
|
||
|
||
|
||
def _extract_unanswered_user_messages(
|
||
memory_context: Dict[str, Any],
|
||
current_user_id: str,
|
||
max_items: int = 3,
|
||
) -> List[str]:
|
||
"""
|
||
Extract unresolved user questions from structured memory events.
|
||
A user message is considered unresolved if no later agent reply exists.
|
||
"""
|
||
events = memory_context.get("recent_events") or []
|
||
if not isinstance(events, list) or not current_user_id:
|
||
return []
|
||
|
||
def _normalize_tokens(raw: str) -> set:
|
||
toks = re.findall(r"[a-zA-Zа-яА-ЯіїєґІЇЄҐ0-9]{3,}", (raw or "").lower())
|
||
stop = {
|
||
"що", "як", "коли", "де", "хто", "чому", "який", "яка", "яке", "скільки", "чи",
|
||
"what", "how", "when", "where", "who", "why", "which",
|
||
"and", "for", "the", "this", "that", "with", "from",
|
||
}
|
||
return {t for t in toks if t not in stop}
|
||
|
||
def _looks_like_ack_or_generic(raw: str) -> bool:
|
||
t = (raw or "").strip().lower()
|
||
if not t:
|
||
return True
|
||
markers = [
|
||
"привіт", "вітаю", "чим можу допомогти", "ок", "добре", "дякую", "готово",
|
||
"hello", "hi", "how can i help", "thanks", "okay", "done",
|
||
]
|
||
return any(m in t for m in markers) and len(t) < 180
|
||
|
||
def _assistant_resolves_question(question_text: str, assistant_text: str) -> bool:
|
||
if _looks_like_ack_or_generic(assistant_text):
|
||
return False
|
||
q_tokens = _normalize_tokens(question_text)
|
||
a_tokens = _normalize_tokens(assistant_text)
|
||
if not q_tokens or not a_tokens:
|
||
return False
|
||
overlap = len(q_tokens.intersection(a_tokens))
|
||
# Require at least partial semantic overlap, otherwise do not auto-close.
|
||
return overlap >= 2 or (overlap >= 1 and len(q_tokens) <= 3)
|
||
|
||
pending: List[Dict[str, str]] = []
|
||
for ev in events:
|
||
role = str(ev.get("role") or ev.get("type") or "").lower()
|
||
text = str(ev.get("body_text") or "").strip()
|
||
if not text:
|
||
continue
|
||
if role == "user" and str(ev.get("user_id") or "") == current_user_id and _is_question_like(text):
|
||
pending.append({"text": text})
|
||
continue
|
||
if role in ("assistant", "agent") and pending:
|
||
# Resolve only matching question; do not auto-close all pending items.
|
||
resolved_idx = None
|
||
for idx, item in enumerate(pending):
|
||
if _assistant_resolves_question(item["text"], text):
|
||
resolved_idx = idx
|
||
break
|
||
if resolved_idx is not None:
|
||
pending.pop(resolved_idx)
|
||
|
||
# Keep the latest unresolved items only.
|
||
if len(pending) > max_items:
|
||
pending = pending[-max_items:]
|
||
return [p["text"] for p in pending]
|
||
|
||
|
||
def _is_question_like(text: str) -> bool:
|
||
"""
|
||
Detect user questions without false positives from substring matches
|
||
(e.g. 'схоже' should not match 'що').
|
||
"""
|
||
if not text:
|
||
return False
|
||
t = text.strip().lower()
|
||
if "?" in t:
|
||
return True
|
||
# Ukrainian / Russian / English interrogatives with word boundaries.
|
||
return bool(
|
||
re.search(
|
||
r"\b(що|як|чому|коли|де|хто|чи|what|why|how|when|where|who|зачем|почему|когда|где|кто|ли)\b",
|
||
t,
|
||
)
|
||
)
|
||
|
||
|
||
def _cleanup_user_language_prefs() -> None:
|
||
now = time.time()
|
||
expired = [k for k, v in USER_LANGUAGE_PREFS.items() if now - float(v.get("ts", 0)) > USER_LANGUAGE_PREF_TTL]
|
||
for k in expired:
|
||
del USER_LANGUAGE_PREFS[k]
|
||
|
||
|
||
def _cleanup_user_response_style_prefs() -> None:
|
||
now = time.time()
|
||
expired = [k for k, v in USER_RESPONSE_STYLE_PREFS.items() if now - float(v.get("ts", 0)) > USER_RESPONSE_STYLE_PREF_TTL]
|
||
for k in expired:
|
||
del USER_RESPONSE_STYLE_PREFS[k]
|
||
|
||
|
||
def _normalize_lang_code(raw: Optional[str]) -> Optional[str]:
|
||
if not raw:
|
||
return None
|
||
code = str(raw).strip().lower().replace("_", "-")
|
||
if code.startswith("uk"):
|
||
return "uk"
|
||
if code.startswith("ru"):
|
||
return "ru"
|
||
if code.startswith("en"):
|
||
return "en"
|
||
return None
|
||
|
||
|
||
def _detect_language_from_text(text: str) -> Optional[str]:
|
||
if not text:
|
||
return None
|
||
t = text.lower()
|
||
letters = [ch for ch in t if ch.isalpha()]
|
||
if not letters:
|
||
return None
|
||
|
||
cyr = sum(1 for ch in letters if "а" <= ch <= "я" or ch in "іїєґё")
|
||
lat = sum(1 for ch in letters if "a" <= ch <= "z")
|
||
|
||
if cyr >= 3 and cyr >= lat:
|
||
# Ukrainian-specific letters strongly indicate Ukrainian.
|
||
if any(ch in t for ch in "іїєґ"):
|
||
return "uk"
|
||
# Russian-specific letters/symbols.
|
||
if any(ch in t for ch in "ёыэъ"):
|
||
return "ru"
|
||
# Soft lexical preference.
|
||
uk_hits = sum(1 for w in ("що", "який", "дякую", "будь", "будь ласка", "привіт") if w in t)
|
||
ru_hits = sum(1 for w in ("что", "какой", "спасибо", "пожалуйста", "привет") if w in t)
|
||
if uk_hits > ru_hits:
|
||
return "uk"
|
||
if ru_hits > uk_hits:
|
||
return "ru"
|
||
return "uk"
|
||
|
||
if lat >= 3 and lat > cyr:
|
||
return "en"
|
||
|
||
return None
|
||
|
||
|
||
def resolve_preferred_language(chat_id: str, user_id: str, text: str, telegram_lang_code: Optional[str]) -> str:
|
||
_cleanup_user_language_prefs()
|
||
key = f"{chat_id}:{user_id}"
|
||
|
||
text_lang = _detect_language_from_text(text)
|
||
tg_lang = _normalize_lang_code(telegram_lang_code)
|
||
cached_lang = USER_LANGUAGE_PREFS.get(key, {}).get("lang")
|
||
|
||
preferred = text_lang or tg_lang or cached_lang or "uk"
|
||
USER_LANGUAGE_PREFS[key] = {"lang": preferred, "ts": time.time()}
|
||
return preferred
|
||
|
||
|
||
def preferred_language_label(lang: str) -> str:
|
||
return {
|
||
"uk": "Ukrainian",
|
||
"ru": "Russian",
|
||
"en": "English",
|
||
}.get((lang or "").lower(), "Ukrainian")
|
||
|
||
|
||
def _extract_preferred_language_from_profile_fact(fact: Optional[Dict[str, Any]]) -> Optional[str]:
|
||
if not isinstance(fact, dict):
|
||
return None
|
||
data = fact.get("fact_value_json")
|
||
if not isinstance(data, dict):
|
||
return None
|
||
preferred = _normalize_lang_code(data.get("preferred_language"))
|
||
if preferred:
|
||
return preferred
|
||
return _normalize_lang_code(data.get("language_code"))
|
||
|
||
|
||
async def resolve_preferred_language_persistent(
|
||
chat_id: str,
|
||
user_id: str,
|
||
text: str,
|
||
telegram_lang_code: Optional[str],
|
||
team_id: Optional[str] = None,
|
||
) -> str:
|
||
"""Resolve language with memory-service fallback for post-restart continuity."""
|
||
_cleanup_user_language_prefs()
|
||
key = f"{chat_id}:{user_id}"
|
||
|
||
text_lang = _detect_language_from_text(text)
|
||
tg_lang = _normalize_lang_code(telegram_lang_code)
|
||
cached_lang = USER_LANGUAGE_PREFS.get(key, {}).get("lang")
|
||
|
||
if text_lang or tg_lang or cached_lang:
|
||
preferred = text_lang or tg_lang or cached_lang or "uk"
|
||
USER_LANGUAGE_PREFS[key] = {"lang": preferred, "ts": time.time()}
|
||
return preferred
|
||
|
||
try:
|
||
fact = await memory_client.get_fact(
|
||
user_id=f"tg:{user_id}",
|
||
fact_key="profile",
|
||
team_id=team_id,
|
||
)
|
||
fact_lang = _extract_preferred_language_from_profile_fact(fact)
|
||
if fact_lang:
|
||
USER_LANGUAGE_PREFS[key] = {"lang": fact_lang, "ts": time.time()}
|
||
return fact_lang
|
||
except Exception as e:
|
||
logger.debug(f"preferred language fact lookup failed: {e}")
|
||
|
||
USER_LANGUAGE_PREFS[key] = {"lang": "uk", "ts": time.time()}
|
||
return "uk"
|
||
|
||
|
||
def _pending_state_cleanup():
|
||
now = time.time()
|
||
expired = [cid for cid, rec in LAST_PENDING_STATE.items() if now - rec.get('ts', 0) > PENDING_STATE_TTL]
|
||
for cid in expired:
|
||
del LAST_PENDING_STATE[cid]
|
||
|
||
|
||
def _get_last_pending(chat_id: str) -> list | None:
|
||
_pending_state_cleanup()
|
||
rec = LAST_PENDING_STATE.get(str(chat_id))
|
||
if not rec:
|
||
return None
|
||
return rec.get('items')
|
||
|
||
|
||
def _find_doc_in_history(history_text: str) -> dict | None:
|
||
"""
|
||
Шукає посилання на документ у тексті chat history.
|
||
Якщо знаходить рядок '[Документ: ...]' — повертає doc_context stub.
|
||
Це дозволяє Степану знати про документ навіть без збереженого extracted_summary.
|
||
"""
|
||
import re as _re
|
||
if not history_text:
|
||
return None
|
||
# Шукаємо паттерн [Документ: filename.xlsx]
|
||
matches = _re.findall(r'\[Документ:\s*([^\]]+)\]', history_text)
|
||
if not matches:
|
||
# Також шукаємо assistant-повідомлення про документ
|
||
matches = _re.findall(r'📄[^\n]*\*\*([^*]+)\*\*', history_text)
|
||
if matches:
|
||
file_name = matches[-1].strip() # Беремо найновіший
|
||
return {
|
||
"doc_id": "",
|
||
"title": file_name,
|
||
"extracted_summary": "", # немає вмісту — але є назва
|
||
"from_history": True,
|
||
}
|
||
return None
|
||
|
||
|
||
def _set_last_pending(chat_id: str, items: list):
|
||
LAST_PENDING_STATE[str(chat_id)] = {"ts": time.time(), "items": items}
|
||
|
||
|
||
|
||
def _chunk_text(text: str, max_len: int = 4096):
|
||
if not text:
|
||
return [""]
|
||
chunks = []
|
||
current = []
|
||
current_len = 0
|
||
for line in text.split("\n"):
|
||
add_len = len(line) + (1 if current else 0)
|
||
if current_len + add_len <= max_len:
|
||
current.append(line)
|
||
current_len += add_len
|
||
continue
|
||
if current:
|
||
chunks.append("\n".join(current))
|
||
current = []
|
||
current_len = 0
|
||
while len(line) > max_len:
|
||
chunks.append(line[:max_len])
|
||
line = line[max_len:]
|
||
current.append(line)
|
||
current_len = len(line)
|
||
if current:
|
||
chunks.append("\n".join(current))
|
||
return chunks
|
||
|
||
|
||
# Training groups - agents respond to ALL messages without mention requirement
|
||
TRAINING_GROUP_IDS = {
|
||
"-1003556680911", # Agent Preschool Daarion.city
|
||
}
|
||
|
||
# Brand stack services
|
||
BRAND_INTAKE_URL = os.getenv("BRAND_INTAKE_URL", "http://brand-intake:9211").rstrip("/")
|
||
BRAND_REGISTRY_URL = os.getenv("BRAND_REGISTRY_URL", "http://brand-registry:9210").rstrip("/")
|
||
PRESENTATION_RENDERER_URL = os.getenv("PRESENTATION_RENDERER_URL", "http://presentation-renderer:9212").rstrip("/")
|
||
ARTIFACT_REGISTRY_URL = os.getenv("ARTIFACT_REGISTRY_URL", "http://artifact-registry:9220").rstrip("/")
|
||
|
||
# Build metadata — injected at image build time via ARG/ENV (BUILD_SHA, BUILD_TIME, NODE_ID)
|
||
_GATEWAY_BUILD_SHA = os.environ.get("BUILD_SHA", "dev")
|
||
_GATEWAY_BUILD_TIME = os.environ.get("BUILD_TIME", "local")
|
||
_GATEWAY_NODE_ID = os.environ.get("NODE_ID", "NODA1")
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
# ========================================
|
||
# Agent Configuration
|
||
# ========================================
|
||
|
||
@dataclass
|
||
class AgentConfig:
|
||
"""Конфігурація агента для стандартизації обробки повідомлень"""
|
||
agent_id: str
|
||
name: str
|
||
prompt_path: str
|
||
telegram_token_env: str
|
||
default_prompt: str
|
||
system_prompt: str = "" # Буде встановлено після завантаження
|
||
|
||
def load_prompt(self) -> str:
|
||
"""Завантажити system prompt з файлу"""
|
||
try:
|
||
p = Path(self.prompt_path)
|
||
if not p.exists():
|
||
logger.warning(f"{self.name} prompt file not found: {self.prompt_path}")
|
||
return self.default_prompt
|
||
|
||
prompt = p.read_text(encoding="utf-8")
|
||
logger.info(f"{self.name} system prompt loaded ({len(prompt)} chars)")
|
||
return prompt
|
||
except Exception as e:
|
||
logger.error(f"Error loading {self.name} prompt: {e}")
|
||
return self.default_prompt
|
||
|
||
def get_telegram_token(self) -> Optional[str]:
|
||
"""Отримати Telegram токен агента"""
|
||
return os.getenv(self.telegram_token_env)
|
||
|
||
|
||
def load_agent_config(agent_id: str, name: str, prompt_path: str,
|
||
telegram_token_env: str, default_prompt: str) -> AgentConfig:
|
||
"""Створити та завантажити конфігурацію агента"""
|
||
config = AgentConfig(
|
||
agent_id=agent_id,
|
||
name=name,
|
||
prompt_path=prompt_path,
|
||
telegram_token_env=telegram_token_env,
|
||
default_prompt=default_prompt,
|
||
system_prompt="" # Тимчасове значення
|
||
)
|
||
# Завантажити prompt
|
||
config.system_prompt = config.load_prompt()
|
||
return config
|
||
|
||
|
||
# ========================================
|
||
# Agent Configurations
|
||
# ========================================
|
||
|
||
# DAARWIZZ Configuration
|
||
DAARWIZZ_CONFIG = load_agent_config(
|
||
agent_id="daarwizz",
|
||
name=os.getenv("DAARWIZZ_NAME", "DAARWIZZ"),
|
||
prompt_path=os.getenv(
|
||
"DAARWIZZ_PROMPT_PATH",
|
||
str(Path(__file__).parent / "daarwizz_prompt.txt"),
|
||
),
|
||
telegram_token_env="DAARWIZZ_TELEGRAM_BOT_TOKEN",
|
||
default_prompt=f"Ти — {os.getenv('DAARWIZZ_NAME', 'DAARWIZZ')}, AI-агент екосистеми DAARION.city. Допомагай учасникам з DAO-процесами."
|
||
)
|
||
|
||
# HELION Configuration
|
||
HELION_CONFIG = load_agent_config(
|
||
agent_id="helion",
|
||
name=os.getenv("HELION_NAME", "Helion"),
|
||
prompt_path=os.getenv(
|
||
"HELION_PROMPT_PATH",
|
||
str(Path(__file__).parent / "helion_prompt.txt"),
|
||
),
|
||
telegram_token_env="HELION_TELEGRAM_BOT_TOKEN",
|
||
default_prompt=f"Ти — {os.getenv('HELION_NAME', 'Helion')}, AI-агент платформи Energy Union. Допомагай учасникам з технологіями та токеномікою."
|
||
)
|
||
|
||
# GREENFOOD Configuration
|
||
GREENFOOD_CONFIG = load_agent_config(
|
||
agent_id="greenfood",
|
||
name=os.getenv("GREENFOOD_NAME", "GREENFOOD"),
|
||
prompt_path=os.getenv(
|
||
"GREENFOOD_PROMPT_PATH",
|
||
str(Path(__file__).parent / "greenfood_prompt.txt"),
|
||
),
|
||
telegram_token_env="GREENFOOD_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — GREENFOOD Assistant, AI-ERP для крафтових виробників та кооперативів. Допомагай з обліком партій, логістикою, бухгалтерією та продажами."
|
||
)
|
||
|
||
# AGROMATRIX Configuration
|
||
AGROMATRIX_CONFIG = load_agent_config(
|
||
agent_id="agromatrix",
|
||
name=os.getenv("AGROMATRIX_NAME", "AgroMatrix"),
|
||
prompt_path=os.getenv(
|
||
"AGROMATRIX_PROMPT_PATH",
|
||
str(Path(__file__).parent / "agromatrix_prompt.txt"),
|
||
),
|
||
telegram_token_env="AGROMATRIX_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — AgroMatrix, AI-агент для агроаналітики, планування сезонів та кооперації фермерів. Допомагай з порадами щодо полів, процесів і ринків."
|
||
)
|
||
|
||
# ALATEYA Configuration
|
||
ALATEYA_CONFIG = load_agent_config(
|
||
agent_id="alateya",
|
||
name=os.getenv("ALATEYA_NAME", "Alateya"),
|
||
prompt_path=os.getenv(
|
||
"ALATEYA_PROMPT_PATH",
|
||
str(Path(__file__).parent / "alateya_prompt.txt"),
|
||
),
|
||
telegram_token_env="ALATEYA_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — Alateya, AI-агент R&D та біотех-інновацій. Допомагай з дослідженнями, протоколами й експериментальними дизайнами."
|
||
)
|
||
|
||
# NUTRA Configuration
|
||
NUTRA_CONFIG = load_agent_config(
|
||
agent_id="nutra",
|
||
name=os.getenv("NUTRA_NAME", "NUTRA"),
|
||
prompt_path=os.getenv(
|
||
"NUTRA_PROMPT_PATH",
|
||
str(Path(__file__).parent / "nutra_prompt.txt"),
|
||
),
|
||
telegram_token_env="NUTRA_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — NUTRA, нутріцевтичний агент платформи DAARION. Допомагаєш з формулами нутрієнтів, біомедичних добавок та лабораторних інтерпретацій. Консультуєш з питань харчування, вітамінів та оптимізації здоров'я."
|
||
)
|
||
|
||
# Registry of all agents (для легкого додавання нових агентів)
|
||
#
|
||
# Щоб додати нового агента:
|
||
# 1. Створіть конфігурацію через load_agent_config():
|
||
# NEW_AGENT_CONFIG = load_agent_config(
|
||
# agent_id="new_agent",
|
||
# name=os.getenv("NEW_AGENT_NAME", "New Agent"),
|
||
# prompt_path=os.getenv("NEW_AGENT_PROMPT_PATH", str(Path(__file__).parent / "new_agent_prompt.txt")),
|
||
# telegram_token_env="NEW_AGENT_TELEGRAM_BOT_TOKEN",
|
||
# default_prompt="Ти — New Agent, AI-агент..."
|
||
# )
|
||
# 2. Додайте до реєстру:
|
||
# DRUID Configuration
|
||
DRUID_CONFIG = load_agent_config(
|
||
agent_id="druid",
|
||
name=os.getenv("DRUID_NAME", "DRUID"),
|
||
prompt_path=os.getenv(
|
||
"DRUID_PROMPT_PATH",
|
||
str(Path(__file__).parent / "druid_prompt.txt"),
|
||
),
|
||
telegram_token_env="DRUID_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — DRUID, агент платформи DAARION. Допомагай користувачам з аналізом даних, рекомендаціями та інтеграцією RAG.",
|
||
)
|
||
|
||
# CLAN (Spirit) Configuration
|
||
CLAN_CONFIG = load_agent_config(
|
||
agent_id="clan",
|
||
name=os.getenv("CLAN_NAME", "Spirit"),
|
||
prompt_path=os.getenv(
|
||
"CLAN_PROMPT_PATH",
|
||
str(Path(__file__).parent / "clan_prompt.txt"),
|
||
),
|
||
telegram_token_env="CLAN_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — CLAN (Spirit), Дух Общини в екосистемі DAARION.city. Підтримуєш зв'язки між учасниками спільноти, зберігаєш традиції та допомагаєш в прийнятті колективних рішень.",
|
||
)
|
||
|
||
# EONARCH Configuration
|
||
EONARCH_CONFIG = load_agent_config(
|
||
agent_id="eonarch",
|
||
name=os.getenv("EONARCH_NAME", "EONARCH"),
|
||
prompt_path=os.getenv(
|
||
"EONARCH_PROMPT_PATH",
|
||
str(Path(__file__).parent / "eonarch_prompt.txt"),
|
||
),
|
||
telegram_token_env="EONARCH_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — EONARCH, провідник еволюції свідомості в екосистемі DAARION.city. Супроводжуєш людство на шляху трансформації свідомості до колективної мудрості.",
|
||
)
|
||
|
||
# SENPAI (Gordon Senpai) Configuration
|
||
SENPAI_CONFIG = load_agent_config(
|
||
agent_id="senpai",
|
||
name=os.getenv("SENPAI_NAME", "SENPAI"),
|
||
prompt_path=os.getenv(
|
||
"SENPAI_PROMPT_PATH",
|
||
str(Path(__file__).parent / "senpai_prompt.txt"),
|
||
),
|
||
telegram_token_env="SENPAI_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — Гордон Сенпай (Gordon Senpai), радник з ринків капіталу та цифрових активів. Допомагаєш з трейдингом, ризик-менеджментом, аналізом ринків.",
|
||
)
|
||
|
||
# 1OK Configuration
|
||
ONEOK_CONFIG = load_agent_config(
|
||
agent_id="oneok",
|
||
name=os.getenv("ONEOK_NAME", "1OK"),
|
||
prompt_path=os.getenv(
|
||
"ONEOK_PROMPT_PATH",
|
||
str(Path(__file__).parent / "oneok_prompt.txt"),
|
||
),
|
||
telegram_token_env="ONEOK_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — 1OK, асистент віконного майстра. Допомагаєш з кваліфікацією ліда, підготовкою заміру та формуванням комерційної пропозиції.",
|
||
)
|
||
|
||
# SOUL / Athena Configuration
|
||
SOUL_CONFIG = load_agent_config(
|
||
agent_id="soul",
|
||
name=os.getenv("SOUL_NAME", "Athena"),
|
||
prompt_path=os.getenv(
|
||
"SOUL_PROMPT_PATH",
|
||
str(Path(__file__).parent / "soul_prompt.txt"),
|
||
),
|
||
telegram_token_env="SOUL_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — Athena, духовний гід та ментор спільноти DAARION.city. Підтримуєш місію, цінності та зв\'язки між учасниками.",
|
||
)
|
||
|
||
# YAROMIR Configuration
|
||
YAROMIR_CONFIG = load_agent_config(
|
||
agent_id="yaromir",
|
||
name=os.getenv("YAROMIR_NAME", "Yaromir"),
|
||
prompt_path=os.getenv(
|
||
"YAROMIR_PROMPT_PATH",
|
||
str(Path(__file__).parent / "yaromir_prompt.txt"),
|
||
),
|
||
telegram_token_env="YAROMIR_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — Yaromir, стратег та наставник в екосистемі DAARION.city. Стратегія, наставництво, психологічна підтримка команди.",
|
||
)
|
||
|
||
# SOFIIA (Sophia) Configuration
|
||
SOFIIA_CONFIG = load_agent_config(
|
||
agent_id="sofiia",
|
||
name=os.getenv("SOFIIA_NAME", "Sophia"),
|
||
prompt_path=os.getenv(
|
||
"SOFIIA_PROMPT_PATH",
|
||
str(Path(__file__).parent / "sofiia_prompt.txt"),
|
||
),
|
||
telegram_token_env="SOFIIA_TELEGRAM_BOT_TOKEN",
|
||
default_prompt="Ти — Sophia (Софія), Chief AI Architect та Technical Sovereign екосистеми DAARION.city. Координуєш R&D, архітектуру, безпеку та еволюцію платформи.",
|
||
)
|
||
|
||
# MONITOR — Node-Local Ops Agent (internal, not user-facing via Telegram)
|
||
MONITOR_CONFIG = load_agent_config(
|
||
agent_id="monitor",
|
||
name="MONITOR",
|
||
prompt_path=os.getenv(
|
||
"MONITOR_PROMPT_PATH",
|
||
str(Path(__file__).parent / "monitor_prompt.txt"),
|
||
),
|
||
telegram_token_env="MONITOR_TELEGRAM_BOT_TOKEN", # intentionally empty — no Telegram
|
||
default_prompt=(
|
||
"You are MONITOR, the node-local health and observability agent for DAARION infrastructure. "
|
||
"You perform health checks, alert triage, and safe ops diagnostics. Internal use only."
|
||
),
|
||
)
|
||
|
||
# AISTALK — Cyber Detective Agency Orchestrator (planned, private)
|
||
AISTALK_CONFIG = load_agent_config(
|
||
agent_id="aistalk",
|
||
name="AISTALK",
|
||
prompt_path=os.getenv(
|
||
"AISTALK_PROMPT_PATH",
|
||
str(Path(__file__).parent / "aistalk_prompt.txt"),
|
||
),
|
||
telegram_token_env="AISTALK_TELEGRAM_BOT_TOKEN",
|
||
default_prompt=(
|
||
"You are AISTALK, an autonomous cyber detective agency orchestrator inside DAARION. "
|
||
"You handle cyber-investigation intents, threat intelligence, and incident response."
|
||
),
|
||
)
|
||
|
||
# Registry of all agents (для легкого додавання нових агентів)
|
||
AGENT_REGISTRY: Dict[str, AgentConfig] = {
|
||
"daarwizz": DAARWIZZ_CONFIG,
|
||
"helion": HELION_CONFIG,
|
||
"greenfood": GREENFOOD_CONFIG,
|
||
"agromatrix": AGROMATRIX_CONFIG,
|
||
"alateya": ALATEYA_CONFIG,
|
||
"nutra": NUTRA_CONFIG,
|
||
"druid": DRUID_CONFIG,
|
||
"clan": CLAN_CONFIG,
|
||
"eonarch": EONARCH_CONFIG,
|
||
"senpai": SENPAI_CONFIG,
|
||
"oneok": ONEOK_CONFIG,
|
||
"soul": SOUL_CONFIG,
|
||
"yaromir": YAROMIR_CONFIG,
|
||
"sofiia": SOFIIA_CONFIG,
|
||
"monitor": MONITOR_CONFIG,
|
||
"aistalk": AISTALK_CONFIG,
|
||
}
|
||
# 3. Створіть endpoint (опціонально, якщо потрібен окремий webhook):
|
||
# @router.post("/new_agent/telegram/webhook")
|
||
# async def new_agent_telegram_webhook(update: TelegramUpdate):
|
||
# return await handle_telegram_webhook(NEW_AGENT_CONFIG, update)
|
||
#
|
||
# Новий агент автоматично отримає:
|
||
# - Обробку фото через Swapper vision-8b
|
||
# - Обробку PDF документів
|
||
# - Обробку голосових повідомлень (коли буде реалізовано)
|
||
# - RAG запити по документам
|
||
# - Memory context
|
||
# AGENT_REGISTRY["new_agent"] = NEW_AGENT_CONFIG
|
||
# 3. Створіть endpoint (опціонально, якщо потрібен окремий webhook):
|
||
|
||
|
||
# Backward compatibility
|
||
DAARWIZZ_NAME = DAARWIZZ_CONFIG.name
|
||
DAARWIZZ_SYSTEM_PROMPT = DAARWIZZ_CONFIG.system_prompt
|
||
HELION_NAME = HELION_CONFIG.name
|
||
HELION_SYSTEM_PROMPT = HELION_CONFIG.system_prompt
|
||
GREENFOOD_NAME = GREENFOOD_CONFIG.name
|
||
GREENFOOD_SYSTEM_PROMPT = GREENFOOD_CONFIG.system_prompt
|
||
|
||
|
||
# ========================================
|
||
# Request Models
|
||
# ========================================
|
||
|
||
class TelegramUpdate(BaseModel):
|
||
"""Simplified Telegram update model"""
|
||
update_id: Optional[int] = None
|
||
message: Optional[Dict[str, Any]] = None
|
||
channel_post: Optional[Dict[str, Any]] = None
|
||
|
||
|
||
# DRUID webhook endpoint
|
||
@router.post("/druid/telegram/webhook")
|
||
async def druid_telegram_webhook(update: TelegramUpdate):
|
||
return await handle_telegram_webhook(DRUID_CONFIG, update)
|
||
|
||
|
||
# AGROMATRIX webhook endpoint
|
||
# AGX_STEPAN_MODE: inproc = run Crew in-process (default); http = call crewai-service (9010).
|
||
_STEPAN_MODE = None
|
||
|
||
def _get_stepan_mode() -> str:
|
||
global _STEPAN_MODE
|
||
if _STEPAN_MODE is None:
|
||
_STEPAN_MODE = (os.getenv("AGX_STEPAN_MODE", "inproc") or "inproc").strip().lower()
|
||
if _STEPAN_MODE not in ("inproc", "http"):
|
||
_STEPAN_MODE = "inproc"
|
||
logger.info("Stepan mode=%s (AGX_STEPAN_MODE)", _STEPAN_MODE)
|
||
return _STEPAN_MODE
|
||
|
||
|
||
async def handle_stepan_message(update: TelegramUpdate, agent_config: AgentConfig) -> Dict[str, Any]:
|
||
update_id = getattr(update, 'update_id', None) or update.update_id
|
||
if update_id:
|
||
if update_id in _PROCESSED_UPDATES:
|
||
return {"ok": True, "status": "duplicate"}
|
||
_PROCESSED_UPDATES[update_id] = _time.time()
|
||
if len(_PROCESSED_UPDATES) > _DEDUP_MAX_SIZE:
|
||
_dedup_cleanup()
|
||
|
||
message = update.message or update.channel_post or {}
|
||
text = message.get('text') or message.get('caption') or ''
|
||
|
||
user = message.get('from', {}) or {}
|
||
chat = message.get('chat', {}) or {}
|
||
user_id = str(user.get('id', ''))
|
||
chat_id = str(chat.get('id', ''))
|
||
|
||
# ── DOC HANDOFF + EXTRACT-ON-UPLOAD (v3.4 / PROMPT 30) ─────────────────
|
||
# При отриманні документа (operator path):
|
||
# 1) зберегти базовий doc_ctx (doc_id, file_name)
|
||
# 2) для XLSX/XLS/CSV: завантажити байти через Bot API, витягнути summary,
|
||
# оновити doc_context_chat з extracted_summary → Stepan бачить дані одразу
|
||
_doc_obj = message.get("document")
|
||
if _doc_obj and _doc_obj.get("file_id"):
|
||
_file_id_tg = _doc_obj.get("file_id")
|
||
_fu_id = _doc_obj.get("file_unique_id") or _file_id_tg
|
||
_fname = _doc_obj.get("file_name") or ""
|
||
_bot_token = agent_config.get_telegram_token() or ""
|
||
_doc_ctx_to_save: dict = {
|
||
"doc_id": _fu_id,
|
||
"file_unique_id": _fu_id,
|
||
"file_id": _file_id_tg,
|
||
"file_name": _fname,
|
||
"source": "telegram",
|
||
# Fix D: явно фіксуємо anchor одразу при upload — run.py може читати без парсингу doc_id
|
||
"active_doc_id": _fu_id,
|
||
}
|
||
# Крок 1: зберегти базовий doc_ctx (await = race-safe)
|
||
await save_chat_doc_context(chat_id, agent_config.agent_id, _doc_ctx_to_save)
|
||
logger.info("Doc Handoff: saved base doc_id=%s file=%s", str(_fu_id)[:16], _fname)
|
||
|
||
# Крок 2: Extract-on-upload для табличних форматів
|
||
_fname_lower = _fname.lower()
|
||
_extractable = _fname_lower.endswith((".xlsx", ".xls", ".csv"))
|
||
_extract_ok = False
|
||
if _extractable and _bot_token:
|
||
# Fix 1: One-shot cache — якщо summary вже є для того самого file_unique_id → skip
|
||
_existing_ctx = await get_chat_doc_context(chat_id, agent_config.agent_id)
|
||
_already_have = (
|
||
_existing_ctx
|
||
and _existing_ctx.get("extracted_summary")
|
||
and (_existing_ctx.get("file_unique_id") or _existing_ctx.get("doc_id")) == _fu_id
|
||
)
|
||
if _already_have:
|
||
_extract_ok = True
|
||
logger.info("doc_extract_skipped reason=already_have_summary chat_id=%s fuid=%s",
|
||
chat_id, str(_fu_id)[:16])
|
||
else:
|
||
logger.info("doc_extract_started chat_id=%s file=%s", chat_id, _fname)
|
||
try:
|
||
_file_bytes = await fetch_telegram_file_bytes(_bot_token, _file_id_tg)
|
||
_extract_summary = extract_summary_from_bytes(_fname, _file_bytes)
|
||
if _extract_summary:
|
||
await upsert_chat_doc_context_with_summary(
|
||
chat_id, agent_config.agent_id, _doc_ctx_to_save, _extract_summary
|
||
)
|
||
_extract_ok = True
|
||
logger.info("doc_extract_done ok=true chat_id=%s chars=%d",
|
||
chat_id, len(_extract_summary))
|
||
else:
|
||
logger.warning("doc_extract_done ok=false reason=empty_summary chat_id=%s", chat_id)
|
||
except Exception as _ee:
|
||
logger.warning("doc_extract_done ok=false reason=%s chat_id=%s",
|
||
str(_ee)[:80], chat_id)
|
||
|
||
# Якщо тексту/caption немає — підтверджуємо отримання і виходимо
|
||
if not text:
|
||
if _extract_ok:
|
||
_reply = (
|
||
f"Прочитав «{_fname}». Можу: (1) витягнути прибуток/витрати, "
|
||
f"(2) сценарій — добрива×2, (3) зведення грн/га. Що потрібно?"
|
||
)
|
||
elif _extractable:
|
||
_reply = (
|
||
f"Отримав «{_fname}», але не зміг витягнути дані автоматично. "
|
||
f"Постав питання — перегляну через пошук по документу."
|
||
)
|
||
else:
|
||
_reply = (
|
||
f"Бачу «{_fname}». Що зробити: витягнути прибуток/витрати, "
|
||
f"сценарій, чи звести по га?"
|
||
)
|
||
await send_telegram_message(chat_id, _reply, bot_token=_bot_token)
|
||
return {"ok": True, "status": "doc_saved"}
|
||
|
||
# ── PHOTO BRIDGE (v3.5) ─────────────────────────────────────────────────
|
||
# Фото в operator path раніше провалювалося через "if not text" і тихо ігнорувалося.
|
||
# Тепер: делегуємо до process_photo (vision-8b через Router) — той самий шлях,
|
||
# що використовують всі інші агенти. Агент AgroMatrix вже має спеціальний контекст
|
||
# (prior_label + agricultural system prompt) у process_photo.
|
||
_photo_obj = message.get("photo")
|
||
if _photo_obj and not text:
|
||
# text може бути caption — вже вище: text = message.get('caption') or ''
|
||
# якщо caption не порожній — photo+caption піде в text-гілку нижче (Stepan відповідає)
|
||
# тут тільки "фото без тексту"
|
||
_username = (user.get('username') or user.get('first_name') or str(user_id))
|
||
_dao_id = os.getenv("AGX_DAO_ID", "agromatrix-dao")
|
||
_bot_tok = agent_config.get_telegram_token() or ""
|
||
logger.info("Photo bridge: routing photo to process_photo chat_id=%s", chat_id)
|
||
try:
|
||
_photo_result = await process_photo(
|
||
agent_config=agent_config,
|
||
update=update,
|
||
chat_id=chat_id,
|
||
user_id=user_id,
|
||
username=_username,
|
||
dao_id=_dao_id,
|
||
photo=_photo_obj,
|
||
caption_override=None,
|
||
bypass_media_gate=True, # operator path = завжди відповідати
|
||
)
|
||
# v3.5 fix: зберігаємо timestamp фото в session Степана
|
||
# щоб наступний текстовий запит (words=1) знав що щойно було фото
|
||
try:
|
||
from crews.agromatrix_crew.session_context import update_session
|
||
import time as _ts_mod
|
||
update_session(
|
||
chat_id, "[photo]", depth="light", agents=[],
|
||
last_question=None,
|
||
last_photo_ts=_ts_mod.time(),
|
||
)
|
||
logger.info("Photo bridge: last_photo_ts saved chat_id=%s", chat_id)
|
||
except Exception:
|
||
pass
|
||
return _photo_result
|
||
except Exception as _pe:
|
||
logger.warning("Photo bridge error: %s", _pe)
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Не вдалося обробити фото. Спробуй ще раз або напиши що на фото.",
|
||
bot_token=_bot_tok,
|
||
)
|
||
return {"ok": True, "status": "photo_error"}
|
||
|
||
if not text:
|
||
return {"ok": True, "status": "no_text"}
|
||
|
||
# ── PHOTO+TEXT: якщо є caption → Stepan отримує опис через doc_context ─────
|
||
# Якщо text (caption) є + фото → стандартний flow Степана + зберігаємо file_id
|
||
# щоб він міг згадати фото у відповіді.
|
||
if _photo_obj and text:
|
||
_photo_largest = _photo_obj[-1] if isinstance(_photo_obj, list) else _photo_obj
|
||
_photo_file_id = _photo_largest.get("file_id") if isinstance(_photo_largest, dict) else None
|
||
if _photo_file_id:
|
||
_set_recent_photo_context(agent_config.agent_id, chat_id, user_id, _photo_file_id)
|
||
|
||
# ── VISION CONSISTENCY GUARD: Хук C — User Override ─────────────────────
|
||
# Whitelist + negation guard: "це соняшник" → user_label;
|
||
# "це не соняшник" → ігноруємо.
|
||
if text:
|
||
try:
|
||
_vg_override = _vg_detect_override(text)
|
||
if _vg_override:
|
||
_vg_set_user_label(agent_config.agent_id, chat_id, _vg_override)
|
||
logger.info(
|
||
"vision_user_override_set agent=%s chat_id=%s label=%s",
|
||
agent_config.agent_id, chat_id, _vg_override,
|
||
)
|
||
except Exception:
|
||
pass
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
|
||
# ops mode if operator
|
||
ops_mode = False
|
||
op_ids = [s.strip() for s in os.getenv('AGX_OPERATOR_IDS', '').split(',') if s.strip()]
|
||
op_chat = os.getenv('AGX_OPERATOR_CHAT_ID', '').strip()
|
||
if op_chat and chat_id == op_chat:
|
||
ops_mode = True
|
||
if user_id and user_id in op_ids:
|
||
ops_mode = True
|
||
|
||
trace_id = str(uuid.uuid4())
|
||
stepan_mode = _get_stepan_mode()
|
||
|
||
if stepan_mode == "http":
|
||
logger.warning("Stepan http mode not implemented; use AGX_STEPAN_MODE=inproc.")
|
||
bot_token = agent_config.get_telegram_token()
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Степан у режимі HTTP зараз недоступний. Встановіть AGX_STEPAN_MODE=inproc.",
|
||
bot_token=bot_token,
|
||
)
|
||
return {"ok": False, "status": "stepan_http_not_implemented"}
|
||
|
||
try:
|
||
import gateway_boot
|
||
except ImportError:
|
||
gateway_boot = type(sys)("gateway_boot")
|
||
gateway_boot.STEPAN_IMPORTS_OK = False
|
||
if not getattr(gateway_boot, "STEPAN_IMPORTS_OK", False):
|
||
logger.warning("Stepan inproc disabled: crews/agromatrix_tools not available at startup")
|
||
bot_token = agent_config.get_telegram_token()
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Степан тимчасово недоступний (не встановлено crews або agromatrix-tools).",
|
||
bot_token=bot_token,
|
||
)
|
||
return {"ok": False, "status": "stepan_disabled"}
|
||
|
||
try:
|
||
# v3: crews/ is in /app/gateway-bot/crews (volume-mounted copy)
|
||
# AGX_REPO_ROOT can override for dev/alt deployments
|
||
repo_root = os.getenv("AGX_REPO_ROOT", "")
|
||
_gw = "/app/gateway-bot"
|
||
_at = "/app/gateway-bot/agromatrix-tools"
|
||
for _p in [_at, _gw, repo_root]:
|
||
if _p and _p not in sys.path:
|
||
sys.path.insert(0, _p)
|
||
from crews.agromatrix_crew.run import handle_message
|
||
|
||
# Doc Bridge (v3.3): отримати активний doc_context для цього chat.
|
||
# Пріоритет: chat-scoped (doc_context_chat:) > session-scoped (doc_context:).
|
||
_stepan_doc_ctx: dict | None = None
|
||
try:
|
||
# 1) Спочатку пробуємо chat-scoped (надійніший при зміні session_id)
|
||
_chat_dc = await get_chat_doc_context(chat_id, agent_config.agent_id)
|
||
if _chat_dc and (_chat_dc.get("doc_id") or _chat_dc.get("file_unique_id")):
|
||
_chat_doc_id = _chat_dc.get("doc_id") or _chat_dc.get("file_unique_id")
|
||
_chat_extracted = _chat_dc.get("extracted_summary") or ""
|
||
_chat_fname = _chat_dc.get("file_name") or ""
|
||
# Якщо chat-scoped є але без extracted_summary → шукаємо в session-scoped
|
||
if not _chat_extracted:
|
||
try:
|
||
_dc_sess = await get_doc_context(f"telegram:{chat_id}", agent_id=agent_config.agent_id)
|
||
if _dc_sess and getattr(_dc_sess, "extracted_summary", None):
|
||
_chat_extracted = _dc_sess.extracted_summary
|
||
except Exception:
|
||
pass
|
||
# Якщо ще немає — RAG fallback
|
||
if not _chat_extracted and _chat_doc_id:
|
||
try:
|
||
_qa = await ask_about_document(
|
||
session_id=f"telegram:{chat_id}",
|
||
question=text,
|
||
doc_id=_chat_doc_id,
|
||
dao_id=os.getenv("AGX_DAO_ID", "agromatrix-dao"),
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
)
|
||
if _qa and getattr(_qa, "answer", None):
|
||
_chat_extracted = f"[RAG відповідь по документу]: {_qa.answer}"
|
||
logger.info("Doc Bridge: RAG answer retrieved for chat doc_id=%s", _chat_doc_id)
|
||
except Exception as _qae:
|
||
logger.debug("Doc Bridge RAG fallback failed: %s", _qae)
|
||
_stepan_doc_ctx = {
|
||
"doc_id": _chat_doc_id,
|
||
"title": _chat_fname,
|
||
"extracted_summary": _chat_extracted,
|
||
"file_unique_id": _chat_dc.get("file_unique_id") or _chat_doc_id,
|
||
}
|
||
logger.info("Doc Bridge: chat-scoped doc_id=%s found=true", _chat_doc_id[:16] if _chat_doc_id else "")
|
||
else:
|
||
# 2) Fallback: session-scoped (старий ключ)
|
||
_dc = await get_doc_context(f"telegram:{chat_id}", agent_id=agent_config.agent_id)
|
||
if _dc and getattr(_dc, "doc_id", None):
|
||
_extracted = getattr(_dc, "extracted_summary", "") or ""
|
||
if not _extracted and getattr(_dc, "doc_id", None):
|
||
try:
|
||
_qa = await ask_about_document(
|
||
session_id=f"telegram:{chat_id}",
|
||
question=text,
|
||
doc_id=_dc.doc_id,
|
||
dao_id=os.getenv("AGX_DAO_ID", "agromatrix-dao"),
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
)
|
||
if _qa and getattr(_qa, "answer", None):
|
||
_extracted = f"[RAG відповідь по документу]: {_qa.answer}"
|
||
logger.info("Doc Bridge: session-scoped RAG retrieved for doc_id=%s", _dc.doc_id)
|
||
except Exception as _qae:
|
||
logger.debug("Doc Bridge session RAG fallback failed: %s", _qae)
|
||
_stepan_doc_ctx = {
|
||
"doc_id": _dc.doc_id,
|
||
"title": getattr(_dc, "file_name", "") or "",
|
||
"extracted_summary": _extracted,
|
||
"file_unique_id": _dc.doc_id,
|
||
}
|
||
logger.info("Doc Bridge: session-scoped doc_id=%s found=true", _dc.doc_id)
|
||
except Exception as _dce:
|
||
logger.debug("Doc Bridge: could not fetch doc_context: %s", _dce)
|
||
|
||
# Chat History Bridge (v3.2): передаємо history з memory-service в Степана.
|
||
# Степан інакше не має доступу до переписки — він викликається поза Router pipeline.
|
||
_stepan_chat_history: str = ""
|
||
try:
|
||
_ctx = await memory_client.get_context(
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
team_id=os.getenv("AGX_DAO_ID", "agromatrix-dao"),
|
||
channel_id=chat_id,
|
||
limit=40,
|
||
)
|
||
_stepan_chat_history = _ctx.get("local_context_text", "") or ""
|
||
# Якщо в history є документ — і _stepan_doc_ctx порожній, шукаємо в history
|
||
if not _stepan_doc_ctx and _stepan_chat_history:
|
||
_doc_in_history = _find_doc_in_history(_stepan_chat_history)
|
||
if _doc_in_history:
|
||
_stepan_doc_ctx = _doc_in_history
|
||
logger.info("Doc Bridge: found doc reference in chat history: %s",
|
||
_doc_in_history.get("title", ""))
|
||
except Exception as _che:
|
||
logger.debug("Chat History Bridge failed (non-blocking): %s", _che)
|
||
|
||
started = time.time()
|
||
last_pending = _get_last_pending(chat_id)
|
||
response_text = await asyncio.wait_for(
|
||
asyncio.to_thread(
|
||
handle_message, text, user_id, chat_id, trace_id, ops_mode, last_pending,
|
||
None, None, bool(_stepan_doc_ctx), _stepan_doc_ctx,
|
||
_stepan_chat_history,
|
||
),
|
||
timeout=55
|
||
)
|
||
duration_ms = int((time.time() - started) * 1000)
|
||
except Exception as e:
|
||
logger.error(f"Stepan handler error: {e}; trace_id={trace_id}")
|
||
# SANITIZE: без trace_id для юзера (trace_id тільки в логах)
|
||
response_text = "Щось пішло не так. Спробуй ще раз або переформулюй запит."
|
||
duration_ms = 0
|
||
|
||
# If JSON, try to show summary
|
||
try:
|
||
parsed = json.loads(response_text)
|
||
summary = parsed.get('summary')
|
||
if summary:
|
||
response_text = summary
|
||
if parsed.get('details'):
|
||
response_text += "\n(details truncated)"
|
||
except Exception:
|
||
pass
|
||
|
||
# chunk and send
|
||
bot_token = agent_config.get_telegram_token()
|
||
for chunk in _chunk_text(response_text, max_len=4096):
|
||
await send_telegram_message(chat_id, chunk, bot_token=bot_token)
|
||
|
||
logger.info(f"Stepan reply sent: trace_id={trace_id}, user_id={user_id}, chat_id={chat_id}, update_id={update_id}, duration_ms={duration_ms}")
|
||
return {"ok": True}
|
||
|
||
|
||
@router.post("/agromatrix/telegram/webhook")
|
||
async def agromatrix_telegram_webhook(update: TelegramUpdate):
|
||
# Check if this is an operator request (slash command or NL operator intent)
|
||
message = (update.message or update.channel_post or {})
|
||
msg_text = message.get('text') or message.get('caption') or ''
|
||
user = message.get('from', {}) or {}
|
||
chat = message.get('chat', {}) or {}
|
||
user_id = str(user.get('id', ''))
|
||
chat_id = str(chat.get('id', ''))
|
||
|
||
is_slash = msg_text.strip().startswith('/')
|
||
is_ops = False
|
||
op_ids = [s.strip() for s in os.getenv('AGX_OPERATOR_IDS', '').split(',') if s.strip()]
|
||
op_chat = os.getenv('AGX_OPERATOR_CHAT_ID', '').strip()
|
||
if op_chat and chat_id == op_chat:
|
||
is_ops = True
|
||
if user_id and user_id in op_ids:
|
||
is_ops = True
|
||
|
||
# Operator: any message (not only slash) goes to Stepan when is_ops.
|
||
# v3: stepan_enabled checks DEEPSEEK_API_KEY (preferred) OR OPENAI_API_KEY (fallback)
|
||
stepan_enabled = bool(
|
||
os.getenv("DEEPSEEK_API_KEY", "").strip()
|
||
or os.getenv("OPENAI_API_KEY", "").strip()
|
||
)
|
||
if stepan_enabled and is_ops:
|
||
return await handle_stepan_message(update, AGROMATRIX_CONFIG)
|
||
if is_ops and not stepan_enabled:
|
||
logger.warning(
|
||
"Stepan handler disabled (no DEEPSEEK_API_KEY / OPENAI_API_KEY); "
|
||
"falling back to Router pipeline "
|
||
f"for chat_id={chat_id}, user_id={user_id}"
|
||
)
|
||
|
||
# General conversation -> standard Router pipeline (like all other agents)
|
||
return await handle_telegram_webhook(AGROMATRIX_CONFIG, update)
|
||
|
||
|
||
# ALATEYA webhook endpoint
|
||
@router.post("/alateya/telegram/webhook")
|
||
async def alateya_telegram_webhook(update: TelegramUpdate):
|
||
return await handle_telegram_webhook(ALATEYA_CONFIG, update)
|
||
|
||
|
||
# CLAN (Spirit) webhook endpoint
|
||
@router.post("/clan/telegram/webhook")
|
||
async def clan_telegram_webhook(update: TelegramUpdate):
|
||
return await handle_telegram_webhook(CLAN_CONFIG, update)
|
||
|
||
|
||
# EONARCH webhook endpoint
|
||
@router.post("/eonarch/telegram/webhook")
|
||
async def eonarch_telegram_webhook(update: TelegramUpdate):
|
||
return await handle_telegram_webhook(EONARCH_CONFIG, update)
|
||
|
||
|
||
# SENPAI (Gordon Senpai) webhook endpoint
|
||
@router.post("/senpai/telegram/webhook")
|
||
async def senpai_telegram_webhook(update: TelegramUpdate):
|
||
return await handle_telegram_webhook(SENPAI_CONFIG, update)
|
||
|
||
# 1OK webhook endpoint
|
||
@router.post("/oneok/telegram/webhook")
|
||
async def oneok_telegram_webhook(update: TelegramUpdate):
|
||
return await handle_telegram_webhook(ONEOK_CONFIG, update)
|
||
|
||
|
||
# SOUL / Athena webhook endpoint
|
||
@router.post("/soul/telegram/webhook")
|
||
async def soul_telegram_webhook(update: TelegramUpdate):
|
||
return await handle_telegram_webhook(SOUL_CONFIG, update)
|
||
|
||
|
||
# YAROMIR webhook endpoint
|
||
@router.post("/yaromir/telegram/webhook")
|
||
async def yaromir_telegram_webhook(update: TelegramUpdate):
|
||
return await handle_telegram_webhook(YAROMIR_CONFIG, update)
|
||
|
||
|
||
# SOFIIA (Sophia) webhook endpoint
|
||
@router.post("/sofiia/telegram/webhook")
|
||
async def sofiia_telegram_webhook(update: TelegramUpdate):
|
||
return await handle_telegram_webhook(SOFIIA_CONFIG, update)
|
||
|
||
|
||
class DiscordMessage(BaseModel):
|
||
"""Simplified Discord message model"""
|
||
content: Optional[str] = None
|
||
author: Optional[Dict[str, Any]] = None
|
||
channel_id: Optional[str] = None
|
||
guild_id: Optional[str] = None
|
||
|
||
|
||
# ========================================
|
||
# DAO Mapping (temporary)
|
||
# ========================================
|
||
|
||
# Map agent_id to DAO ID
|
||
AGENT_TO_DAO = {
|
||
"helion": "helion-dao",
|
||
"greenfood": "greenfood-dao",
|
||
"agromatrix": "agromatrix-dao",
|
||
"nutra": "nutra-dao",
|
||
"druid": "druid-dao",
|
||
"daarwizz": "daarwizz-dao",
|
||
"clan": "clan-dao",
|
||
"alateya": "alateya-dao",
|
||
"eonarch": "eonarch-dao",
|
||
"senpai": "senpai-dao",
|
||
"soul": "soul-dao",
|
||
"yaromir": "yaromir-dao",
|
||
}
|
||
|
||
# Legacy: Map chat/channel ID to DAO ID
|
||
CHAT_TO_DAO = {
|
||
"default": "daarion-dao",
|
||
}
|
||
|
||
|
||
def get_dao_id(chat_id: str, source: str, agent_id: str = None) -> str:
|
||
"""Get DAO ID from agent_id or chat ID"""
|
||
if agent_id and agent_id in AGENT_TO_DAO:
|
||
return AGENT_TO_DAO[agent_id]
|
||
key = f"{source}:{chat_id}"
|
||
return CHAT_TO_DAO.get(key, CHAT_TO_DAO["default"])
|
||
|
||
|
||
# ========================================
|
||
# Helper Functions
|
||
# ========================================
|
||
|
||
SERVICE_ACK_PREFIXES = (
|
||
"📥 Імпортую",
|
||
"📄 Обробляю",
|
||
"Обробляю голосове",
|
||
"🎤",
|
||
)
|
||
|
||
|
||
def is_service_response(text: str) -> bool:
|
||
"""Heuristic: визначає, чи відповідь є службовою (вітальна/ack)."""
|
||
if not text:
|
||
return True
|
||
stripped = text.strip()
|
||
if not stripped:
|
||
return True
|
||
if len(stripped) < 5:
|
||
return True
|
||
lower = stripped.lower()
|
||
return any(lower.startswith(prefix.lower()) for prefix in SERVICE_ACK_PREFIXES)
|
||
|
||
|
||
def extract_bot_mentions(text: str) -> List[str]:
|
||
"""Витягує згадки інших ботів виду @NameBot."""
|
||
if not text:
|
||
return []
|
||
mentions = []
|
||
for token in text.split():
|
||
if token.startswith("@") and token[1:].lower().endswith("bot"):
|
||
mentions.append(token[1:])
|
||
return mentions
|
||
|
||
|
||
def should_force_detailed_reply(text: str) -> bool:
|
||
"""Soft signal: user explicitly asks for details/long format."""
|
||
if not text:
|
||
return False
|
||
lower = text.strip().lower()
|
||
detail_markers = [
|
||
"детально", "подробно", "розгорну", "розпиши", "по всіх пунктах",
|
||
"step by step", "покроково", "з прикладами", "глибоко", "deep dive",
|
||
"full", "повний розбір", "максимально детально",
|
||
]
|
||
return any(m in lower for m in detail_markers)
|
||
|
||
|
||
def should_force_concise_reply(text: str) -> bool:
|
||
"""Soft concise mode by default, unless user asks for detailed answer."""
|
||
if not text:
|
||
return True
|
||
|
||
stripped = text.strip()
|
||
if not stripped:
|
||
return True
|
||
|
||
if should_force_detailed_reply(stripped):
|
||
return False
|
||
|
||
# Very long user request usually means they expect context-aware answer.
|
||
if len(stripped) > 700:
|
||
return False
|
||
|
||
# For regular Q&A in chat keep first response concise by default.
|
||
return True
|
||
|
||
|
||
def _detect_response_style_signal(text: str) -> Optional[str]:
|
||
t = (text or "").strip().lower()
|
||
if not t:
|
||
return None
|
||
concise_markers = ["коротко", "коротка відповідь", "лаконічно", "brief", "short answer"]
|
||
detailed_markers = ["детально", "розгорнуто", "поясни детальніше", "deep dive", "step by step"]
|
||
if any(m in t for m in detailed_markers):
|
||
return "detailed"
|
||
if any(m in t for m in concise_markers):
|
||
return "concise"
|
||
return None
|
||
|
||
|
||
async def resolve_response_style_preference(
|
||
agent_id: str,
|
||
chat_id: str,
|
||
user_id: str,
|
||
text: str,
|
||
team_id: Optional[str],
|
||
) -> str:
|
||
_cleanup_user_response_style_prefs()
|
||
cache_key = f"{agent_id}:{chat_id}:{user_id}"
|
||
signal = _detect_response_style_signal(text)
|
||
now = time.time()
|
||
if signal in ("concise", "detailed"):
|
||
USER_RESPONSE_STYLE_PREFS[cache_key] = {"style": signal, "ts": now}
|
||
await memory_client.upsert_fact(
|
||
user_id=f"tg:{user_id}",
|
||
fact_key=f"communication_profile:{agent_id}",
|
||
fact_value_json={"response_style": signal, "updated_at": datetime.utcnow().isoformat()},
|
||
team_id=team_id,
|
||
)
|
||
return signal
|
||
|
||
cached = USER_RESPONSE_STYLE_PREFS.get(cache_key)
|
||
if cached:
|
||
return str(cached.get("style") or "concise")
|
||
|
||
fact = await memory_client.get_fact(
|
||
user_id=f"tg:{user_id}",
|
||
fact_key=f"communication_profile:{agent_id}",
|
||
team_id=team_id,
|
||
)
|
||
if fact:
|
||
data = fact.get("fact_value_json") if isinstance(fact, dict) else None
|
||
if isinstance(data, str):
|
||
try:
|
||
data = json.loads(data)
|
||
except Exception:
|
||
data = None
|
||
if isinstance(data, dict):
|
||
style = str(data.get("response_style") or "").lower()
|
||
if style in ("concise", "detailed"):
|
||
USER_RESPONSE_STYLE_PREFS[cache_key] = {"style": style, "ts": now}
|
||
return style
|
||
return "concise"
|
||
|
||
|
||
def _redact_private_mentions(text: str) -> str:
|
||
if not text:
|
||
return ""
|
||
sanitized = text
|
||
sanitized = re.sub(r"(?i)(email|e-mail)\s*[:\-]?\s*[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", r"\1: [redacted]", sanitized)
|
||
sanitized = re.sub(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", "[redacted-email]", sanitized)
|
||
sanitized = re.sub(r"(?<!\d)(\+?\d[\d\s\-\(\)]{8,}\d)(?!\d)", "[redacted-phone]", sanitized)
|
||
return sanitized
|
||
|
||
|
||
def _is_private_profile_dump_request(user_text: str) -> bool:
|
||
t = (user_text or "").lower()
|
||
if not t:
|
||
return False
|
||
markers = [
|
||
"приватн", "контакт ментор", "телефон ментор", "email ментор",
|
||
"всі контакти", "скинь контакти", "private data", "mentor contacts",
|
||
]
|
||
return any(m in t for m in markers)
|
||
|
||
|
||
def _block_private_profile_dump(user_text: str) -> Optional[str]:
|
||
if not _is_private_profile_dump_request(user_text):
|
||
return None
|
||
return (
|
||
"Не можу надавати приватні дані людей (контакти, особисті профілі). "
|
||
"Можу дати лише публічну, узагальнену інформацію."
|
||
)
|
||
|
||
|
||
def _is_numeric_question(text: str) -> bool:
|
||
t = (text or "").lower()
|
||
if not t:
|
||
return False
|
||
markers = ["скільки", "сума", "витрат", "добрив", "грн", "кг", "вартість", "cost", "amount", "total", "spent"]
|
||
return any(m in t for m in markers)
|
||
|
||
|
||
def _has_numeric_answer_contract(answer_text: str) -> bool:
|
||
a = (answer_text or "").lower()
|
||
if not a:
|
||
return False
|
||
has_value = bool(re.search(r"\d", a))
|
||
has_unit = any(u in a for u in ("грн", "uah", "usd", "eur", "кг", "kg", "%"))
|
||
has_source = any(s in a for s in ("рядок", "лист", "sheet", "row", "джерело"))
|
||
return has_value and has_unit and has_source
|
||
|
||
|
||
def _answer_seems_off_intent(user_text: str, answer_text: str) -> bool:
|
||
u = (user_text or "").lower()
|
||
a = (answer_text or "").lower()
|
||
if not u or not a:
|
||
return False
|
||
|
||
if _is_numeric_question(u) and not _has_numeric_answer_contract(answer_text):
|
||
return True
|
||
|
||
if any(k in u for k in ("excel", "xlsx", "таблиц", "файл", "звіт")) and any(
|
||
k in a for k in ("вступ", "структура", "презентаці", "слайд")
|
||
):
|
||
return True
|
||
return False
|
||
|
||
|
||
def _sanitize_agent_answer(agent_id: str, user_text: str, answer_text: str) -> str:
|
||
blocked = _block_private_profile_dump(user_text)
|
||
if blocked:
|
||
return blocked
|
||
sanitized = _redact_private_mentions(answer_text or "")
|
||
return sanitized
|
||
|
||
|
||
def _strip_answer_markup_noise(answer_text: str) -> str:
|
||
if not answer_text:
|
||
return ""
|
||
cleaned = answer_text.strip()
|
||
cleaned = re.sub(r"^\s*\*{1,3}\s*коротка відповідь\s*:?\s*\*{0,3}\s*", "", cleaned, flags=re.IGNORECASE)
|
||
cleaned = re.sub(r"^\s*\*{1,3}\s*відповідь\s*:?\s*\*{0,3}\s*", "", cleaned, flags=re.IGNORECASE)
|
||
cleaned = re.sub(r"^\s*#+\s*", "", cleaned)
|
||
# Remove markdown emphasis noise that leaks into short answers
|
||
cleaned = cleaned.replace("**", "")
|
||
cleaned = cleaned.replace("__", "")
|
||
return cleaned.strip()
|
||
|
||
|
||
def _compress_bulleted_answer(answer_text: str, max_items: int = 3) -> str:
|
||
if not answer_text:
|
||
return ""
|
||
lines = [ln.strip() for ln in answer_text.splitlines() if ln.strip()]
|
||
bullet_lines: List[str] = []
|
||
for ln in lines:
|
||
normalized = ln.replace("**", "").replace("__", "").strip()
|
||
if re.match(r"^(\*?\s*[-*•]|\*?\s*\d+[\.\):])\s*", normalized):
|
||
item = re.sub(r"^(\*?\s*[-*•]|\*?\s*\d+[\.\):])\s*", "", normalized).strip()
|
||
item = re.sub(r"\s+", " ", item).strip(" -–—")
|
||
item = re.sub(r"\.{2,}", ".", item)
|
||
item = re.sub(r"\s+\.", ".", item)
|
||
# Keep concise mode truly short: first complete sentence from each bullet.
|
||
parts = re.split(r"(?<=[.!?…])\s+", item)
|
||
if parts:
|
||
item = parts[0].strip()
|
||
item = item.rstrip(":").strip()
|
||
if item:
|
||
bullet_lines.append(item)
|
||
if not bullet_lines:
|
||
return answer_text.strip()
|
||
picked = bullet_lines[:max_items]
|
||
joined = ". ".join(picked)
|
||
if joined and not joined.endswith((".", "!", "?")):
|
||
joined += "."
|
||
joined = re.sub(r"\s+", " ", joined).strip()
|
||
return joined or answer_text.strip()
|
||
|
||
|
||
def _limit_to_sentences(text: str, max_sentences: int = 3) -> str:
|
||
if not text:
|
||
return ""
|
||
parts = re.split(r"(?<=[.!?…])\s+", text.strip())
|
||
parts = [p.strip() for p in parts if p.strip()]
|
||
if len(parts) <= max_sentences:
|
||
return " ".join(parts).strip()
|
||
return " ".join(parts[:max_sentences]).strip()
|
||
|
||
|
||
def _agromatrix_rewrite_capability_limitations(user_text: str, answer_text: str) -> str:
|
||
if not answer_text:
|
||
return answer_text
|
||
low = answer_text.lower()
|
||
limitation_markers = (
|
||
"не можу бачити", "не можу переглядати зображення", "не маю доступу до зображень",
|
||
"працюю лише з текстом", "працюю виключно з текстом",
|
||
"cannot view images", "cannot analyze images", "as a text model",
|
||
)
|
||
if not any(m in low for m in limitation_markers):
|
||
return answer_text
|
||
|
||
ulow = (user_text or "").lower()
|
||
photo_markers = ("фото", "зображ", "image", "photo", "картин", "світлин")
|
||
if any(m in ulow for m in photo_markers):
|
||
return (
|
||
"Можу аналізувати фото. Надішли, будь ласка, зображення ще раз одним повідомленням "
|
||
"з коротким питанням, і я дам точний розбір."
|
||
)
|
||
|
||
return (
|
||
"Можу працювати природною мовою та з мультимодальністю: фото, голос і документи. "
|
||
"Сформулюй запит коротко, і я відповім по суті."
|
||
)
|
||
|
||
|
||
def postprocess_agent_answer(
|
||
agent_id: str,
|
||
user_text: str,
|
||
answer_text: str,
|
||
force_detailed: bool,
|
||
needs_complex_reasoning: bool,
|
||
) -> str:
|
||
if not answer_text:
|
||
return answer_text
|
||
|
||
if (agent_id or "").lower() != "agromatrix":
|
||
return answer_text
|
||
|
||
# Keep detailed/complex answers intact.
|
||
if force_detailed or needs_complex_reasoning:
|
||
return answer_text
|
||
|
||
user_text_len = len((user_text or "").strip())
|
||
if user_text_len > 280:
|
||
return _agromatrix_rewrite_capability_limitations(user_text, answer_text)
|
||
|
||
cleaned = _strip_answer_markup_noise(answer_text)
|
||
cleaned = _agromatrix_rewrite_capability_limitations(user_text, cleaned)
|
||
compact = _compress_bulleted_answer(cleaned, max_items=1)
|
||
short = _limit_to_sentences(compact, max_sentences=3)
|
||
return short or answer_text
|
||
|
||
|
||
COMPLEX_REASONING_KEYWORDS = [
|
||
"стратег", "roadmap", "алгоритм", "architecture", "архітектур",
|
||
"прогноз", "scenario", "модель", "аналіз", "побудуй", "plan", "дослідж",
|
||
"симуляц", "forecast", "оптиміз", "розрахуй", "calculate", "predict"
|
||
]
|
||
|
||
|
||
def requires_complex_reasoning(text: str) -> bool:
|
||
if not text:
|
||
return False
|
||
stripped = text.strip()
|
||
if len(stripped) > 400:
|
||
return True
|
||
lower = stripped.lower()
|
||
return any(keyword in lower for keyword in COMPLEX_REASONING_KEYWORDS)
|
||
|
||
|
||
LAST_RESPONSE_CACHE: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
||
LAST_RESPONSE_TTL = float(os.getenv("TELEGRAM_LAST_RESPONSE_TTL", "15"))
|
||
|
||
|
||
def get_cached_response(agent_id: str, chat_id: str, text: str) -> Optional[str]:
|
||
entry = LAST_RESPONSE_CACHE.get((agent_id, chat_id))
|
||
if not entry:
|
||
return None
|
||
if entry["text"] == text and time.time() - entry["ts"] < LAST_RESPONSE_TTL:
|
||
return entry["answer"]
|
||
return None
|
||
|
||
|
||
def store_response_cache(agent_id: str, chat_id: str, text: str, answer: str) -> None:
|
||
LAST_RESPONSE_CACHE[(agent_id, chat_id)] = {
|
||
"text": text,
|
||
"answer": answer,
|
||
"ts": time.time(),
|
||
}
|
||
|
||
|
||
def _resolve_stt_upload_url() -> str:
|
||
"""
|
||
Повертає фінальний endpoint для STT.
|
||
Swapper service використовує POST /stt з multipart file upload.
|
||
"""
|
||
upload_override = os.getenv("STT_SERVICE_UPLOAD_URL")
|
||
if upload_override:
|
||
return upload_override.rstrip("/")
|
||
|
||
base_url = os.getenv("STT_SERVICE_URL", "http://swapper-service:8890").rstrip("/")
|
||
|
||
# Swapper endpoint is /stt (not /api/stt/upload)
|
||
if base_url.endswith("/stt"):
|
||
return base_url
|
||
|
||
return f"{base_url}/stt"
|
||
|
||
|
||
# ========================================
|
||
# Helper Functions
|
||
# ========================================
|
||
|
||
async def get_telegram_file_path(file_id: str, bot_token: Optional[str] = None) -> Optional[str]:
|
||
"""
|
||
Отримати шлях до файлу з Telegram API.
|
||
|
||
Args:
|
||
file_id: ID файлу з Telegram
|
||
bot_token: Telegram bot token (якщо None, використовується TELEGRAM_BOT_TOKEN)
|
||
|
||
Returns:
|
||
Шлях до файлу або None
|
||
"""
|
||
try:
|
||
token = bot_token or os.getenv("TELEGRAM_BOT_TOKEN")
|
||
if not token:
|
||
logger.error("TELEGRAM_BOT_TOKEN not set")
|
||
return None
|
||
|
||
url = f"https://api.telegram.org/bot{token}/getFile"
|
||
params = {"file_id": file_id}
|
||
|
||
async with httpx.AsyncClient() as client:
|
||
response = await client.get(url, params=params, timeout=10.0)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
|
||
if data.get("ok"):
|
||
return data.get("result", {}).get("file_path")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"Failed to get Telegram file path: {e}")
|
||
return None
|
||
|
||
|
||
def format_qa_response(qa_list: list) -> str:
|
||
"""Форматувати список питань-відповідей для Telegram"""
|
||
if not qa_list:
|
||
return "Немає питань-відповідей."
|
||
|
||
result = "📋 **Питання та відповіді:**\n\n"
|
||
for i, qa in enumerate(qa_list, 1):
|
||
question = qa.get("question", "") if isinstance(qa, dict) else getattr(qa, "question", "")
|
||
answer = qa.get("answer", "") if isinstance(qa, dict) else getattr(qa, "answer", "")
|
||
result += f"**{i}. {question}**\n{answer}\n\n"
|
||
|
||
return result.strip()
|
||
|
||
|
||
def format_markdown_response(markdown: str) -> str:
|
||
"""Форматувати markdown відповідь для Telegram"""
|
||
if len(markdown) > TELEGRAM_SAFE_LENGTH:
|
||
return markdown[:TELEGRAM_SAFE_LENGTH] + "\n\n_... (відповідь обрізано)_"
|
||
return markdown
|
||
|
||
|
||
def format_chunks_response(chunks: list) -> str:
|
||
"""Форматувати список чанків для Telegram"""
|
||
if not chunks:
|
||
return "Немає фрагментів."
|
||
|
||
result = f"📄 **Знайдено {len(chunks)} фрагментів:**\n\n"
|
||
for i, chunk in enumerate(chunks[:5], 1): # Показуємо тільки перші 5
|
||
text = chunk.get("text", "") if isinstance(chunk, dict) else str(chunk)
|
||
if len(text) > 200:
|
||
text = text[:200] + "..."
|
||
result += f"**{i}.** {text}\n\n"
|
||
|
||
if len(chunks) > 5:
|
||
result += f"_... та ще {len(chunks) - 5} фрагментів_"
|
||
|
||
return result.strip()
|
||
|
||
|
||
# ========================================
|
||
# Universal Message Processing Functions
|
||
# ========================================
|
||
|
||
async def process_photo(
|
||
agent_config: AgentConfig,
|
||
update: TelegramUpdate,
|
||
chat_id: str,
|
||
user_id: str,
|
||
username: str,
|
||
dao_id: str,
|
||
photo: Dict[str, Any],
|
||
caption_override: Optional[str] = None,
|
||
bypass_media_gate: bool = False,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Універсальна функція для обробки фото для будь-якого агента.
|
||
|
||
Args:
|
||
agent_config: Конфігурація агента
|
||
update: Telegram update об'єкт
|
||
chat_id: ID чату
|
||
user_id: ID користувача
|
||
username: Ім'я користувача
|
||
dao_id: ID DAO
|
||
photo: Об'єкт фото з Telegram
|
||
|
||
Returns:
|
||
Dict з результатом обробки
|
||
"""
|
||
# Telegram sends multiple sizes, get the largest one (last in array)
|
||
photo_obj = photo[-1] if isinstance(photo, list) else photo
|
||
file_id = photo_obj.get("file_id") if isinstance(photo_obj, dict) else None
|
||
# file_unique_id стабільний між розмірами — використовуємо як lock key
|
||
file_unique_id: str | None = (photo_obj.get("file_unique_id") if isinstance(photo_obj, dict) else None) or None
|
||
|
||
if not file_id:
|
||
return {"ok": False, "error": "No file_id in photo"}
|
||
|
||
logger.info(
|
||
"%s: Photo from %s (tg:%s), file_id: %s file_unique_id: %s",
|
||
agent_config.name, username, user_id, file_id, file_unique_id or "n/a",
|
||
)
|
||
_set_recent_photo_context(agent_config.agent_id, chat_id, user_id, file_id)
|
||
if agent_config.agent_id == "agromatrix":
|
||
await _set_agromatrix_last_photo_ref(
|
||
chat_id=chat_id,
|
||
user_id=user_id,
|
||
file_id=file_id,
|
||
dao_id=dao_id,
|
||
)
|
||
|
||
# Get caption for media question check
|
||
caption = caption_override if caption_override is not None else ((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 bypass_media_gate and 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,
|
||
},
|
||
username=username,
|
||
)
|
||
return {"ok": True, "skipped": True, "reason": "media_no_question"}
|
||
|
||
# ── VISION CONSISTENCY GUARD: Rule 1 ─────────────────────────────────────
|
||
# Те саме фото (file_unique_id або file_id) вже аналізувалось →
|
||
# повертаємо збережений результат без запиту до Router.
|
||
# reeval_request → clear_lock → продовжуємо до Router.
|
||
_vg_caption_text = caption.strip() if caption else ""
|
||
if agent_config.agent_id == "agromatrix" and _vg_should_skip(
|
||
agent_config.agent_id, chat_id, file_id, _vg_caption_text,
|
||
file_unique_id=file_unique_id,
|
||
):
|
||
_vg_lock = _vg_get_lock(agent_config.agent_id, chat_id)
|
||
_vg_reply = _vg_build_locked_reply(_vg_lock, _vg_caption_text)
|
||
logger.info(
|
||
"vision_skip_reanalysis agent=%s chat_id=%s photo_key=%s label=%s",
|
||
agent_config.agent_id, chat_id,
|
||
file_unique_id or file_id, _vg_lock.get("label", "?"),
|
||
)
|
||
telegram_token = agent_config.get_telegram_token() or ""
|
||
if telegram_token:
|
||
await send_telegram_message(chat_id, _vg_reply, telegram_token)
|
||
return {"ok": True, "skipped": True, "reason": "vision_lock_same_photo"}
|
||
|
||
try:
|
||
# Get file path from Telegram
|
||
telegram_token = agent_config.get_telegram_token()
|
||
if not telegram_token:
|
||
return {"ok": False, "error": f"Telegram token not configured for {agent_config.name}"}
|
||
|
||
file_path = await get_telegram_file_path(file_id, telegram_token)
|
||
if not file_path:
|
||
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
|
||
|
||
# Build file URL
|
||
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
|
||
|
||
# Download and encode the image as base64 data URL for Router
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
photo_resp = await client.get(file_url)
|
||
photo_resp.raise_for_status()
|
||
image_bytes = photo_resp.content
|
||
content_type = photo_resp.headers.get("Content-Type", "")
|
||
|
||
if not content_type or not content_type.startswith("image/"):
|
||
content_type = "image/jpeg"
|
||
|
||
encoded_image = base64.b64encode(image_bytes).decode("utf-8")
|
||
data_url = f"data:{content_type};base64,{encoded_image}"
|
||
|
||
logger.info(
|
||
f"{agent_config.name}: Photo downloaded ({len(image_bytes)} bytes, content_type={content_type})"
|
||
)
|
||
|
||
# Send to Router with specialist_vision_8b model (Swapper)
|
||
# IMPORTANT: Default prompt must request BRIEF description (2-3 sentences max)
|
||
prompt = caption.strip() if caption else "Коротко (2-3 речення) скажи, що на цьому зображенні та яке його значення."
|
||
if agent_config.agent_id == "agromatrix":
|
||
prior_label = await _get_agromatrix_photo_prior(file_id=file_id, dao_id=dao_id)
|
||
if prior_label:
|
||
prompt = (
|
||
f"{prompt}\n\n"
|
||
f"[Контекст навчання AgroMatrix: для цього фото раніше підтверджено мітку: '{prior_label}'. "
|
||
"Використай як пріоритетну гіпотезу, але перевір ознаки і коротко поясни.]"
|
||
)
|
||
router_request = {
|
||
"message": f"{prompt}\n\n[Зображення передано окремо у context.images]",
|
||
"mode": "chat",
|
||
"agent": agent_config.agent_id,
|
||
"payload": {
|
||
"provider": "llm_specialist_vision_8b",
|
||
"task_type": "vision_photo_analysis",
|
||
},
|
||
"metadata": {
|
||
"source": "telegram",
|
||
"dao_id": dao_id,
|
||
"user_id": f"tg:{user_id}",
|
||
"session_id": f"tg:{chat_id}:{dao_id}",
|
||
"username": username,
|
||
"chat_id": chat_id,
|
||
"file_id": file_id,
|
||
"file_url": file_url,
|
||
"has_image": True,
|
||
"provider": "llm_specialist_vision_8b",
|
||
"use_llm": "specialist_vision_8b",
|
||
},
|
||
"context": {
|
||
"agent_name": agent_config.name,
|
||
"system_prompt": agent_config.system_prompt,
|
||
"images": [data_url],
|
||
},
|
||
}
|
||
|
||
# Send to Router
|
||
logger.info(f"{agent_config.name}: Sending photo to Router with vision-8b (provider override)")
|
||
response = await send_to_router(router_request)
|
||
|
||
# Extract response
|
||
if isinstance(response, dict) and response.get("ok"):
|
||
answer_text = response.get("data", {}).get("text") or response.get("response", "")
|
||
|
||
if answer_text:
|
||
# ── VISION CONSISTENCY GUARD: Hooks A+B ──────────────────────
|
||
# A: persist lock (label + confidence) keyed by file_unique_id
|
||
if agent_config.agent_id == "agromatrix":
|
||
try:
|
||
_vg_label, _vg_conf = _vg_extract_label(answer_text)
|
||
_vg_set_lock(
|
||
agent_config.agent_id, chat_id, file_id,
|
||
_vg_label, _vg_conf,
|
||
file_unique_id=file_unique_id,
|
||
)
|
||
logger.info(
|
||
"vision_lock_set agent=%s chat_id=%s photo_key=%s label=%s conf=%s",
|
||
agent_config.agent_id, chat_id,
|
||
file_unique_id or file_id, _vg_label, _vg_conf,
|
||
)
|
||
except Exception:
|
||
pass
|
||
# B: low-confidence → append clarifier if not already present
|
||
answer_text, _vg_low_added = _vg_build_low_conf(answer_text)
|
||
if _vg_low_added:
|
||
logger.info(
|
||
"vision_low_conf_clarifier_added agent=%s chat_id=%s",
|
||
agent_config.agent_id, chat_id,
|
||
)
|
||
# ─────────────────────────────────────────────────────────────
|
||
|
||
# Photo processed - send LLM response directly
|
||
await send_telegram_message(
|
||
chat_id,
|
||
answer_text, # No prefix, just the LLM response
|
||
telegram_token
|
||
)
|
||
|
||
# Save to memory for context
|
||
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}]",
|
||
response=answer_text,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=not is_service_response(answer_text),
|
||
agent_metadata={"context": "photo"},
|
||
username=username,
|
||
)
|
||
|
||
return {"ok": True, "agent": agent_config.agent_id, "model": "specialist_vision_8b"}
|
||
else:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Не вдалося коректно обробити фото. Спробуйте інше фото або додайте короткий опис, що саме перевірити.",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "handled": True, "reason": "vision_empty_response"}
|
||
else:
|
||
error_msg = response.get("error", "Unknown error") if isinstance(response, dict) else "Router error"
|
||
logger.error(f"{agent_config.name}: Vision-8b error: {error_msg}")
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Вибач, сталася помилка при обробці фото.",
|
||
telegram_token
|
||
)
|
||
return {"ok": False, "error": error_msg}
|
||
|
||
except Exception as e:
|
||
logger.error(f"{agent_config.name}: Photo processing failed: {e}", exc_info=True)
|
||
telegram_token = agent_config.get_telegram_token()
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Вибач, сталася помилка при обробці фото.",
|
||
telegram_token
|
||
)
|
||
return {"ok": False, "error": "Photo processing failed"}
|
||
|
||
|
||
async def process_document(
|
||
agent_config: AgentConfig,
|
||
update: TelegramUpdate,
|
||
chat_id: str,
|
||
user_id: str,
|
||
username: str,
|
||
dao_id: str,
|
||
document: Dict[str, Any]
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Універсальна функція для обробки документів для будь-якого агента.
|
||
|
||
Args:
|
||
agent_config: Конфігурація агента
|
||
update: Telegram update об'єкт
|
||
chat_id: ID чату
|
||
user_id: ID користувача
|
||
username: Ім'я користувача
|
||
dao_id: ID DAO
|
||
document: Об'єкт документа з Telegram
|
||
|
||
Returns:
|
||
Dict з результатом обробки
|
||
"""
|
||
mime_type = document.get("mime_type", "")
|
||
mime_type_l = (mime_type or "").lower()
|
||
file_name = document.get("file_name", "")
|
||
file_id = document.get("file_id")
|
||
|
||
file_name_lower = file_name.lower()
|
||
allowed_exts = {
|
||
".pdf", ".doc", ".docx", ".rtf", ".odt",
|
||
".txt", ".md", ".markdown",
|
||
".csv", ".tsv", ".xls", ".xlsx", ".xlsm", ".ods",
|
||
".ppt", ".pptx", ".odp",
|
||
".json", ".yaml", ".yml", ".xml", ".html", ".htm",
|
||
".zip",
|
||
".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff",
|
||
}
|
||
is_allowed = any(file_name_lower.endswith(ext) for ext in allowed_exts)
|
||
if mime_type_l == "application/pdf":
|
||
is_allowed = True
|
||
if mime_type_l in {
|
||
"application/msword",
|
||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
"application/rtf",
|
||
"text/rtf",
|
||
"application/vnd.oasis.opendocument.text",
|
||
"application/vnd.ms-excel",
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
"application/vnd.ms-excel.sheet.macroenabled.12",
|
||
"application/vnd.oasis.opendocument.spreadsheet",
|
||
"application/vnd.ms-powerpoint",
|
||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||
"application/vnd.oasis.opendocument.presentation",
|
||
"text/plain",
|
||
"text/markdown",
|
||
"text/csv",
|
||
"text/tab-separated-values",
|
||
"application/json",
|
||
"application/yaml",
|
||
"application/x-yaml",
|
||
"text/yaml",
|
||
"application/xml",
|
||
"text/xml",
|
||
"text/html",
|
||
"application/zip",
|
||
"application/x-zip-compressed",
|
||
}:
|
||
is_allowed = True
|
||
if mime_type_l.startswith("image/"):
|
||
is_allowed = True
|
||
|
||
if is_allowed and file_id:
|
||
logger.info(f"{agent_config.name}: Document from {username} (tg:{user_id}), file_id: {file_id}, file_name: {file_name}")
|
||
|
||
try:
|
||
telegram_token = agent_config.get_telegram_token()
|
||
if not telegram_token:
|
||
return {"ok": False, "error": f"Telegram token not configured for {agent_config.name}"}
|
||
|
||
file_path = await get_telegram_file_path(file_id, telegram_token)
|
||
if not file_path:
|
||
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
|
||
|
||
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
|
||
|
||
session_id = f"telegram:{chat_id}"
|
||
result = await parse_document(
|
||
session_id=session_id,
|
||
doc_url=file_url,
|
||
file_name=file_name,
|
||
dao_id=dao_id,
|
||
user_id=f"tg:{user_id}",
|
||
output_mode="qa_pairs",
|
||
metadata={"username": username, "chat_id": chat_id},
|
||
agent_id=agent_config.agent_id,
|
||
)
|
||
|
||
if not result.success:
|
||
await send_telegram_message(chat_id, f"Вибач, не вдалося обробити документ: {result.error}", telegram_token)
|
||
return {"ok": False, "error": result.error}
|
||
|
||
# Get document text for summary
|
||
doc_text = result.markdown or ""
|
||
if not doc_text and result.chunks_meta:
|
||
chunks = result.chunks_meta.get("chunks", [])
|
||
doc_text = "\n".join(chunks[:5]) if chunks else ""
|
||
|
||
# v3.2 Doc Bridge: зберігаємо parsed text щоб Stepan міг відповідати на питання
|
||
if doc_text and result.doc_id:
|
||
try:
|
||
from services.doc_service import save_doc_context as _save_doc_ctx
|
||
await _save_doc_ctx(
|
||
session_id=session_id,
|
||
doc_id=result.doc_id,
|
||
doc_url=file_url,
|
||
file_name=file_name,
|
||
dao_id=dao_id,
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
extracted_summary=doc_text[:4000],
|
||
)
|
||
logger.info(f"Doc Bridge: saved extracted_summary ({len(doc_text)} chars) for doc_id={result.doc_id}")
|
||
except Exception as _dbe:
|
||
logger.warning(f"Doc Bridge save_doc_context failed (non-blocking): {_dbe}")
|
||
# v3.3 Doc Handoff: зберігаємо chat-scoped ключ (пріоритет для Stepan)
|
||
try:
|
||
from services.doc_service import _sanitize_summary as _ss
|
||
_file_unique = document.get("file_unique_id") or result.doc_id
|
||
await save_chat_doc_context(
|
||
chat_id=chat_id,
|
||
agent_id=agent_config.agent_id,
|
||
doc_ctx={
|
||
"doc_id": result.doc_id,
|
||
"file_unique_id": _file_unique,
|
||
"file_name": file_name,
|
||
"extracted_summary": _ss(doc_text)[:4000],
|
||
"source": "telegram",
|
||
},
|
||
)
|
||
except Exception as _cdbe:
|
||
logger.warning("Doc Handoff: save_chat_doc_context failed: %s", _cdbe)
|
||
|
||
# Ask LLM to summarize the document (human-friendly)
|
||
if doc_text:
|
||
zip_hint = None
|
||
if file_name_lower.endswith(".zip"):
|
||
zip_hint = _zip_read_summary(doc_text)
|
||
summary_prompt = f"""Користувач надіслав документ "{file_name}".
|
||
Ось його зміст (перші частини):
|
||
|
||
{doc_text[:3000]}
|
||
|
||
Дай коротке резюме цього документа в 2-3 реченнях:
|
||
- Про що цей документ?
|
||
- Яка його основна мета/тема?
|
||
- Що може бути корисним?
|
||
|
||
Відповідай українською, дружньо, без технічних термінів."""
|
||
|
||
try:
|
||
summary_response = await send_to_router({
|
||
"message": summary_prompt,
|
||
"agent": agent_config.agent_id,
|
||
"context": {
|
||
"system_prompt": "Ти помічник який коротко пояснює зміст документів. Відповідай в 2-3 реченнях, дружньо і зрозуміло."
|
||
},
|
||
"metadata": {"source": "telegram", "task": "document_summary"}
|
||
})
|
||
|
||
if isinstance(summary_response, dict) and summary_response.get("ok"):
|
||
answer_text = summary_response.get("response", "") or summary_response.get("data", {}).get("text", "")
|
||
if answer_text:
|
||
answer_text = f"📄 **{file_name}**\n\n{answer_text}"
|
||
if zip_hint:
|
||
answer_text = f"{zip_hint}\n\n{answer_text}"
|
||
answer_text += "\n\n_Що саме тебе цікавить у цьому документі?_"
|
||
else:
|
||
answer_text = f"📄 Отримав документ **{file_name}**. Що саме хочеш дізнатися з нього?"
|
||
else:
|
||
answer_text = f"📄 Отримав документ **{file_name}**. Про що саме хочеш запитати?"
|
||
except Exception as e:
|
||
logger.warning(f"Failed to get document summary: {e}")
|
||
answer_text = f"📄 Отримав документ **{file_name}**. Що тебе цікавить?"
|
||
else:
|
||
answer_text = f"📄 Отримав документ **{file_name}**, але не вдалося прочитати текст. Можливо, це скановане зображення?"
|
||
|
||
logger.info(f"{agent_config.name}: Document processed: {file_name}, doc_id={result.doc_id}")
|
||
|
||
# === SAVE TO CHAT HISTORY (CRITICAL: so agent remembers the document) ===
|
||
user_msg = f"[Документ: {file_name}] Надіслано документ"
|
||
if update.message.get("caption"):
|
||
user_msg = f"[Документ: {file_name}] {update.message.get('caption')}"
|
||
await memory_client.save_chat_turn(
|
||
agent_id=agent_config.agent_id,
|
||
team_id=dao_id,
|
||
user_id=f"tg:{user_id}",
|
||
message=user_msg,
|
||
response=answer_text,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=True,
|
||
agent_metadata={"context": "document", "file_name": file_name, "doc_id": result.doc_id},
|
||
username=username,
|
||
)
|
||
logger.info(f"{agent_config.name}: Document chat turn saved to memory: {file_name}")
|
||
# === END SAVE TO CHAT HISTORY ===
|
||
|
||
# === AUTO-INGEST: Store document in agent Qdrant _docs collection ===
|
||
if doc_text:
|
||
try:
|
||
import httpx as _httpx
|
||
router_url = os.getenv("ROUTER_URL", "http://router:8000")
|
||
async with _httpx.AsyncClient(timeout=60.0) as _client:
|
||
ingest_resp = await _client.post(
|
||
f"{router_url}/v1/documents/ingest",
|
||
json={
|
||
"agent_id": agent_config.agent_id,
|
||
"doc_id": result.doc_id,
|
||
"file_name": file_name,
|
||
"text": doc_text,
|
||
"dao_id": dao_id,
|
||
"user_id": f"tg:{user_id}"
|
||
}
|
||
)
|
||
ingest_data = ingest_resp.json()
|
||
if ingest_data.get("ok"):
|
||
logger.info(f"{agent_config.name}: Document ingested to Qdrant: {ingest_data.get('chunks_stored', 0)} chunks")
|
||
else:
|
||
logger.warning(f"{agent_config.name}: Document ingest failed: {ingest_data.get('error')}")
|
||
except Exception as ingest_err:
|
||
logger.warning(f"{agent_config.name}: Document auto-ingest error: {ingest_err}")
|
||
# === END AUTO-INGEST ===
|
||
|
||
await send_telegram_message(chat_id, answer_text, telegram_token)
|
||
return {"ok": True, "agent": "parser", "mode": "doc_parse", "doc_id": result.doc_id}
|
||
|
||
except Exception as e:
|
||
logger.error(f"{agent_config.name}: Document processing failed: {e}", exc_info=True)
|
||
telegram_token = agent_config.get_telegram_token()
|
||
await send_telegram_message(chat_id, "Вибач, не вдалося обробити документ. Переконайся, що файл не пошкоджений.", telegram_token)
|
||
return {"ok": False, "error": "Document processing failed"}
|
||
elif document and not is_allowed:
|
||
telegram_token = agent_config.get_telegram_token()
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Підтримуються формати: PDF/DOC/DOCX/RTF/ODT, TXT/MD/CSV/TSV, XLS/XLSX/XLSM/ODS, PPT/PPTX/ODP, JSON/YAML/XML/HTML, ZIP, зображення.",
|
||
telegram_token,
|
||
)
|
||
return {"ok": False, "error": "Unsupported document type"}
|
||
|
||
return {"ok": False, "error": "No document to process"}
|
||
|
||
|
||
async def process_voice(
|
||
agent_config: AgentConfig,
|
||
update: TelegramUpdate,
|
||
chat_id: str,
|
||
user_id: str,
|
||
username: str,
|
||
dao_id: str,
|
||
media_obj: Dict[str, Any]
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Універсальна функція для обробки голосових повідомлень для будь-якого агента.
|
||
Використовує STT Service для розпізнавання мовлення.
|
||
|
||
Args:
|
||
agent_config: Конфігурація агента
|
||
update: Telegram update об'єкт
|
||
chat_id: ID чату
|
||
user_id: ID користувача
|
||
username: Ім'я користувача
|
||
dao_id: ID DAO
|
||
media_obj: Об'єкт голосового повідомлення з Telegram
|
||
|
||
Returns:
|
||
Dict з результатом обробки та розпізнаним текстом
|
||
"""
|
||
file_id = media_obj.get("file_id") if media_obj else None
|
||
|
||
if not file_id:
|
||
return {"ok": False, "error": "No file_id in voice/audio/video_note"}
|
||
|
||
logger.info(f"{agent_config.name}: Voice message from {username} (tg:{user_id}), file_id: {file_id}")
|
||
|
||
try:
|
||
telegram_token = agent_config.get_telegram_token()
|
||
if not telegram_token:
|
||
return {"ok": False, "error": f"Telegram token not configured for {agent_config.name}"}
|
||
|
||
# Отримуємо файл з Telegram
|
||
file_path = await get_telegram_file_path(file_id, telegram_token)
|
||
if not file_path:
|
||
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
|
||
|
||
# Завантажуємо файл
|
||
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
file_resp = await client.get(file_url)
|
||
file_resp.raise_for_status()
|
||
audio_bytes = file_resp.content
|
||
|
||
# Відправляємо в STT-сервіс
|
||
stt_upload_url = _resolve_stt_upload_url()
|
||
mime_type = media_obj.get("mime_type") if isinstance(media_obj, dict) else None
|
||
files = {
|
||
"file": (
|
||
"voice.ogg",
|
||
audio_bytes,
|
||
mime_type or "audio/ogg",
|
||
)
|
||
}
|
||
# Swapper /stt expects: file (multipart), model (form), language (form)
|
||
form_data = {
|
||
"model": "whisper-small",
|
||
"task": "transcribe",
|
||
}
|
||
|
||
logger.info(f"{agent_config.name}: Sending voice to STT endpoint {stt_upload_url}")
|
||
async with httpx.AsyncClient(timeout=90.0) as client:
|
||
stt_resp = await client.post(stt_upload_url, files=files, data=form_data)
|
||
stt_resp.raise_for_status()
|
||
stt_data = stt_resp.json()
|
||
text = stt_data.get("text", "")
|
||
|
||
if not text:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Вибач, не вдалося розпізнати голосове повідомлення. Спробуй надіслати текстом.",
|
||
telegram_token
|
||
)
|
||
return {"ok": False, "error": "STT returned empty text"}
|
||
|
||
logger.info(f"{agent_config.name}: STT result: {text[:100]}...")
|
||
|
||
# Повертаємо розпізнаний текст для подальшої обробки
|
||
return {"ok": True, "text": text, "agent": agent_config.agent_id, "mode": "voice_stt"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"{agent_config.name}: Voice processing failed: {e}", exc_info=True)
|
||
telegram_token = agent_config.get_telegram_token()
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Вибач, не вдалося розпізнати голосове повідомлення. Спробуй надіслати текстом.",
|
||
telegram_token
|
||
)
|
||
return {"ok": False, "error": "Voice processing failed"}
|
||
|
||
|
||
# ========================================
|
||
# Universal Telegram Webhook Handler
|
||
# ========================================
|
||
|
||
# === UPDATE DEDUPLICATION ===
|
||
import time as _time
|
||
_PROCESSED_UPDATES: Dict[int, float] = {} # update_id -> timestamp
|
||
_DEDUP_MAX_SIZE = 2000
|
||
_DEDUP_TTL = 300 # 5 minutes
|
||
|
||
def _dedup_cleanup():
|
||
"""Remove old entries from dedup cache."""
|
||
now = _time.time()
|
||
expired = [uid for uid, ts in _PROCESSED_UPDATES.items() if now - ts > _DEDUP_TTL]
|
||
for uid in expired:
|
||
del _PROCESSED_UPDATES[uid]
|
||
# === END DEDUPLICATION ===
|
||
|
||
|
||
async def handle_telegram_webhook(
|
||
agent_config: AgentConfig,
|
||
update: TelegramUpdate
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Універсальна функція для обробки Telegram webhook для будь-якого агента.
|
||
|
||
Args:
|
||
agent_config: Конфігурація агента
|
||
update: Telegram update об'єкт
|
||
|
||
Returns:
|
||
Dict з результатом обробки
|
||
"""
|
||
# Allow updates without message if they contain photo/voice
|
||
# The actual message validation happens after multimodal checks
|
||
# === DEDUP CHECK ===
|
||
if update.update_id:
|
||
if update.update_id in _PROCESSED_UPDATES:
|
||
logger.info(f"🔄 Skipping duplicate update_id={update.update_id} for {agent_config.name}")
|
||
return {"status": "ok", "skipped": "duplicate_update"}
|
||
_PROCESSED_UPDATES[update.update_id] = _time.time()
|
||
if len(_PROCESSED_UPDATES) > _DEDUP_MAX_SIZE:
|
||
_dedup_cleanup()
|
||
# === END DEDUP CHECK ===
|
||
|
||
if not update.message:
|
||
if update.channel_post:
|
||
update.message = update.channel_post
|
||
else:
|
||
return {"status": "ok", "skipped": "no_message"}
|
||
|
||
# Extract message details
|
||
from_user = update.message.get("from", {})
|
||
if not from_user:
|
||
from_user = update.message.get("sender_chat", {})
|
||
chat = update.message.get("chat", {})
|
||
|
||
user_id = str(from_user.get("id", "unknown"))
|
||
chat_id = str(chat.get("id", "unknown"))
|
||
username = from_user.get("username", "")
|
||
first_name = from_user.get("first_name")
|
||
last_name = from_user.get("last_name")
|
||
is_sender_bot = bool(from_user.get("is_bot") or (username and username.lower().endswith("bot")))
|
||
|
||
# Get DAO ID for this chat
|
||
dao_id = get_dao_id(chat_id, "telegram", agent_id=agent_config.agent_id)
|
||
|
||
initial_preferred_lang = resolve_preferred_language(
|
||
chat_id=chat_id,
|
||
user_id=user_id,
|
||
text=update.message.get("text", ""),
|
||
telegram_lang_code=from_user.get("language_code"),
|
||
)
|
||
|
||
# Оновлюємо факти про користувача/агента для побудови графу пам'яті
|
||
asyncio.create_task(
|
||
memory_client.upsert_fact(
|
||
user_id=f"tg:{user_id}",
|
||
fact_key="profile",
|
||
fact_value_json={
|
||
"username": username,
|
||
"first_name": first_name,
|
||
"last_name": last_name,
|
||
"language_code": from_user.get("language_code"),
|
||
"preferred_language": initial_preferred_lang,
|
||
"is_bot": is_sender_bot,
|
||
},
|
||
team_id=dao_id,
|
||
)
|
||
)
|
||
|
||
telegram_token = agent_config.get_telegram_token()
|
||
if not telegram_token:
|
||
raise HTTPException(status_code=500, detail=f"Telegram token not configured for {agent_config.name}")
|
||
|
||
# === REPLY-TO-AGENT DETECTION ===
|
||
# If user replies to a bot message → treat as direct mention (SOWA Priority 3)
|
||
is_reply_to_agent = False
|
||
reply_to_message = update.message.get("reply_to_message")
|
||
if reply_to_message:
|
||
reply_from = reply_to_message.get("from", {})
|
||
if reply_from.get("is_bot"):
|
||
# Verify it's THIS agent's bot (bot_id = first part of token)
|
||
bot_id = telegram_token.split(":")[0] if telegram_token else None
|
||
reply_from_id = str(reply_from.get("id", ""))
|
||
if bot_id and reply_from_id == bot_id:
|
||
is_reply_to_agent = True
|
||
logger.info(
|
||
f"↩️ {agent_config.name}: Reply-to-agent detected "
|
||
f"(user {username} replied to bot msg {reply_to_message.get('message_id', '?')})"
|
||
)
|
||
|
||
# === BRAND COMMANDS (experimental, disabled by default) ===
|
||
# ~290 lines of brand/presentation command handlers.
|
||
# This code was NEVER active in production (was trapped inside wrong indent block).
|
||
# Quarantined behind feature flag. Enable with: ENABLE_BRAND_COMMANDS=true
|
||
# See: /бренд, /бренд_інтейк, /бренд_тема, /презентація, /job_статус
|
||
_brand_commands_enabled = os.environ.get("ENABLE_BRAND_COMMANDS", "").lower() in ("1", "true", "yes")
|
||
if _brand_commands_enabled:
|
||
text = update.message.get("text", "")
|
||
|
||
# Simple brand commands (Ukrainian)
|
||
if text and text.strip().startswith("/бренд"):
|
||
parts = text.strip().split(maxsplit=2)
|
||
command = parts[0].lower()
|
||
if command == "/бренд":
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"🧩 **Команди бренду**\n\n"
|
||
"• `/бренд_інтейк <url|текст>` — зберегти джерело\n"
|
||
"• `/бренд_тема <brand_id> [версія]` — опублікувати базову тему\n"
|
||
"• `/бренд_останнє <brand_id>` — показати останню тему\n"
|
||
"• `/бренд_показати <brand_id> <версія>` — показати конкретну тему\n"
|
||
"• `/презентація <brand_id> <версія> <JSON SlideSpec>` — рендер презентації\n"
|
||
"• `/презентація_статус <job_id>` — статус рендера\n"
|
||
"• `/презентація_файл <artifact_id> [pptx|pdf]` — файл\n"
|
||
"• `/job_статус <job_id>` — універсальний статус",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "brand_help"}
|
||
|
||
if command == "/бренд_інтейк":
|
||
if len(parts) < 2:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ Вкажи URL або текст: `/бренд_інтейк <url|текст>`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "brand_intake_help"}
|
||
source_value = parts[1] if len(parts) == 2 else f"{parts[1]} {parts[2]}"
|
||
source_type = "url" if source_value.startswith("http") else "text"
|
||
intake_payload = {
|
||
"source_type": source_type,
|
||
"text": source_value if source_type == "text" else None,
|
||
"url": source_value if source_type == "url" else None,
|
||
"agent_id": agent_config.agent_id,
|
||
"workspace_id": dao_id,
|
||
"project_id": dao_id,
|
||
"tags": ["telegram"]
|
||
}
|
||
result = await _brand_intake_request(intake_payload)
|
||
attribution = result.get("attribution", {})
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"✅ **Джерело збережено**\n\n"
|
||
f"ID: `{result.get('id')}`\n"
|
||
f"Статус: `{attribution.get('status')}`\n"
|
||
f"Бренд: `{attribution.get('brand_id')}`\n"
|
||
f"Впевненість: `{attribution.get('confidence')}`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "brand_intake"}
|
||
|
||
if command == "/бренд_тема":
|
||
if len(parts) < 2:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ Вкажи brand_id: `/бренд_тема <brand_id> [версія]`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "brand_theme_help"}
|
||
brand_id = parts[1]
|
||
theme_version = None
|
||
if len(parts) == 3:
|
||
theme_version = parts[2]
|
||
theme = _default_theme_payload(brand_id)
|
||
published = await _brand_publish_theme(brand_id, theme, theme_version)
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"✅ **Тему опубліковано**\n\n"
|
||
f"Бренд: `{published.get('brand_id')}`\n"
|
||
f"Версія: `{published.get('theme_version')}`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "brand_publish"}
|
||
|
||
if command == "/бренд_останнє":
|
||
if len(parts) < 2:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ Вкажи brand_id: `/бренд_останнє <brand_id>`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "brand_latest_help"}
|
||
brand_id = parts[1]
|
||
data = await _brand_get_latest(brand_id)
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"📌 **Остання тема**\n\n"
|
||
f"Бренд: `{data.get('brand_id')}`\n"
|
||
f"Версія: `{data.get('theme_version')}`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "brand_latest"}
|
||
|
||
if command == "/бренд_показати":
|
||
if len(parts) < 3:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ Формат: `/бренд_показати <brand_id> <версія>`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "brand_show_help"}
|
||
brand_id = parts[1]
|
||
theme_version = parts[2]
|
||
data = await _brand_get_theme(brand_id, theme_version)
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"📎 **Тема**\n\n"
|
||
f"Бренд: `{data.get('brand_id')}`\n"
|
||
f"Версія: `{data.get('theme_version')}`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "brand_show"}
|
||
|
||
# Brand hint on keyword mention (non-command)
|
||
if text and "бренд" in text.lower():
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"🧩 **Команди бренду**\n\n"
|
||
"• `/бренд_інтейк <url|текст>` — зберегти джерело\n"
|
||
"• `/бренд_тема <brand_id> [версія]` — опублікувати базову тему\n"
|
||
"• `/бренд_останнє <brand_id>` — показати останню тему\n"
|
||
"• `/бренд_показати <brand_id> <версія>` — показати конкретну тему\n"
|
||
"• `/презентація <brand_id> <версія> <JSON SlideSpec>` — рендер презентації\n"
|
||
"• `/презентація_статус <job_id>` — статус рендера\n"
|
||
"• `/презентація_файл <artifact_id> [pptx|pdf]` — файл\n"
|
||
"• `/job_статус <job_id>` — універсальний статус",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "brand_hint"}
|
||
|
||
# Job status command (universal)
|
||
if text and text.strip().startswith("/job_статус"):
|
||
parts = text.strip().split(maxsplit=1)
|
||
if len(parts) < 2:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ Формат: `/job_статус <job_id>`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "job_status_help"}
|
||
job_id = parts[1].strip()
|
||
job = await _artifact_job_status(job_id)
|
||
message = _format_job_status_message(job, job_id)
|
||
await send_telegram_message(chat_id, message, telegram_token)
|
||
return {"ok": True, "action": "job_status"}
|
||
|
||
# Presentation status command (alias)
|
||
if text and text.strip().startswith("/презентація_статус"):
|
||
parts = text.strip().split(maxsplit=1)
|
||
if len(parts) < 2:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ Формат: `/презентація_статус <job_id>` або `/job_статус <job_id>`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "presentation_status_help"}
|
||
job_id = parts[1].strip()
|
||
job = await _artifact_job_status(job_id)
|
||
message = _format_job_status_message(job, job_id)
|
||
await send_telegram_message(chat_id, message, telegram_token)
|
||
return {"ok": True, "action": "presentation_status"}
|
||
|
||
# Presentation file command
|
||
if text and text.strip().startswith("/презентація_файл"):
|
||
parts = text.strip().split(maxsplit=2)
|
||
if len(parts) < 2:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ Формат: `/презентація_файл <artifact_id> [pptx|pdf]`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "presentation_file_help"}
|
||
artifact_id = parts[1].strip()
|
||
fmt = parts[2].strip().lower() if len(parts) > 2 else "pptx"
|
||
|
||
artifact = await _artifact_get(artifact_id)
|
||
acl_ref = artifact.get("acl_ref")
|
||
if not _can_access_artifact(acl_ref, dao_id, f"tg:{user_id}"):
|
||
await send_telegram_message(chat_id, "⛔ Немає доступу до цього файлу.", telegram_token)
|
||
return {"ok": True, "action": "presentation_file_denied"}
|
||
|
||
try:
|
||
download = await _artifact_download(artifact_id, fmt)
|
||
logger.info(
|
||
"artifact.downloaded artifact_id=%s user=%s format=%s",
|
||
artifact_id,
|
||
user_id,
|
||
fmt,
|
||
)
|
||
await send_telegram_message(
|
||
chat_id,
|
||
f"📎 **Файл готовий** ({fmt})\n{download.get('url')}",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "presentation_file"}
|
||
except HTTPException as e:
|
||
if fmt == "pdf" and e.status_code == 404:
|
||
versions = await _artifact_versions(artifact_id)
|
||
pptx_version_id = None
|
||
for item in versions.get("items", []):
|
||
if item.get("mime") == "application/vnd.openxmlformats-officedocument.presentationml.presentation":
|
||
pptx_version_id = item.get("id")
|
||
break
|
||
if not pptx_version_id:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ PPTX ще не готовий, PDF теж недоступний.",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "presentation_pdf_missing"}
|
||
job = await _artifact_create_job(artifact_id, "render_pdf", pptx_version_id)
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"⏳ PDF в черзі на рендер.\n"
|
||
f"Job ID: `{job.get('job_id')}`\n"
|
||
"Спробуй `/презентація_статус <job_id>` трохи пізніше.",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "presentation_pdf_queued"}
|
||
raise
|
||
|
||
# Presentation render command (JSON SlideSpec)
|
||
if text and text.strip().startswith("/презентація"):
|
||
parts = text.strip().split(maxsplit=3)
|
||
if len(parts) < 4:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ Формат:\n"
|
||
"`/презентація <brand_id> <версія> <JSON SlideSpec>`\n"
|
||
"або простий формат:\n"
|
||
"`/презентація <brand_id> <версія> Назва;Слайд 1;Слайд 2;Слайд 3`\n\n"
|
||
"Приклад:\n"
|
||
"`/презентація energyunion v1.0.0 {\"meta\":{\"title\":\"Pitch\",\"brand_id\":\"energyunion\",\"theme_version\":\"v1.0.0\",\"language\":\"uk\"},\"slides\":[{\"type\":\"title\",\"title\":\"Energy Union\"}]}`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "presentation_help"}
|
||
brand_id = parts[1]
|
||
theme_version = parts[2]
|
||
slidespec_raw = parts[3]
|
||
slidespec = None
|
||
if slidespec_raw.strip().startswith("{"):
|
||
try:
|
||
slidespec = json.loads(slidespec_raw)
|
||
except json.JSONDecodeError:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ Не вдалося прочитати JSON SlideSpec. Перевір формат.",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "presentation_bad_json"}
|
||
else:
|
||
parts_simple = [p.strip() for p in slidespec_raw.split(";") if p.strip()]
|
||
if not parts_simple:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❗ Порожній список слайдів. Додай хоча б назву.",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "presentation_empty"}
|
||
title = parts_simple[0]
|
||
slides = [{"type": "title", "title": title}]
|
||
for item in parts_simple[1:]:
|
||
slides.append({
|
||
"type": "bullets",
|
||
"title": item,
|
||
"blocks": [{"kind": "bullets", "items": [item]}],
|
||
})
|
||
slidespec = {
|
||
"meta": {
|
||
"title": title,
|
||
"brand_id": brand_id,
|
||
"theme_version": theme_version,
|
||
"language": "uk",
|
||
},
|
||
"slides": slides,
|
||
}
|
||
|
||
render_result = await _presentation_render(slidespec, brand_id, theme_version)
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"✅ **Запит на рендер прийнято**\n\n"
|
||
f"Artifact ID: `{render_result.get('artifact_id')}`\n"
|
||
f"Job ID: `{render_result.get('job_id')}`\n"
|
||
f"Status URL: `{render_result.get('status_url')}`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "presentation_render"}
|
||
|
||
# Check for /ingest command
|
||
text = update.message.get("text", "")
|
||
if text and text.strip().startswith("/ingest"):
|
||
session_id = f"telegram:{chat_id}"
|
||
|
||
# Check if there's a document in the message
|
||
document = update.message.get("document")
|
||
if document:
|
||
mime_type = document.get("mime_type", "")
|
||
file_name = document.get("file_name", "")
|
||
file_id = document.get("file_id")
|
||
|
||
if file_id:
|
||
try:
|
||
file_path = await get_telegram_file_path(file_id, telegram_token)
|
||
if file_path:
|
||
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
|
||
artifact = None
|
||
job = None
|
||
try:
|
||
artifact = await _artifact_create({
|
||
"type": "doc",
|
||
"title": file_name,
|
||
"brand_id": dao_id,
|
||
"project_id": dao_id,
|
||
"acl_ref": f"brand:{dao_id}:public" if dao_id else "public",
|
||
"created_by": f"tg:{user_id}",
|
||
})
|
||
version = await _artifact_add_version_from_url(
|
||
artifact["artifact_id"],
|
||
{
|
||
"url": file_url,
|
||
"mime": mime_type or "application/octet-stream",
|
||
"label": "source",
|
||
"meta_json": {
|
||
"file_name": file_name,
|
||
"dao_id": dao_id,
|
||
"user_id": f"tg:{user_id}",
|
||
},
|
||
},
|
||
)
|
||
job = await _artifact_create_job(
|
||
artifact["artifact_id"],
|
||
"index_doc",
|
||
version["version_id"],
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"Artifact doc registry failed: {e}")
|
||
await send_telegram_message(
|
||
chat_id,
|
||
f"✅ **Документ прийнято**\n\n"
|
||
f"📁 DAO: {dao_id}\n"
|
||
f"🧾 Artifact ID: `{artifact.get('artifact_id') if artifact else 'n/a'}`\n"
|
||
f"🧩 Job ID: `{job.get('job_id') if job else 'n/a'}`\n\n"
|
||
f"Індексація виконується асинхронно. "
|
||
f"Перевір статус: `/job_статус <job_id>`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "artifact_id": artifact.get("artifact_id") if artifact else None, "job_id": job.get("job_id") if job else None}
|
||
except Exception as e:
|
||
logger.error(f"{agent_config.name}: Ingest failed: {e}", exc_info=True)
|
||
await send_telegram_message(chat_id, "Вибач, не вдалося імпортувати документ.", telegram_token)
|
||
return {"ok": False, "error": "Ingest failed"}
|
||
|
||
await send_telegram_message(chat_id, "Спочатку надішли документ, а потім використай /ingest", telegram_token)
|
||
return {"ok": False, "error": "No document for ingest"}
|
||
|
||
# Check for /link command - Account Linking (Telegram ↔ Energy Union)
|
||
if text and text.strip().startswith("/link"):
|
||
parts = text.strip().split(maxsplit=1)
|
||
if len(parts) < 2:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"🔗 **Зв'язування акаунта**\n\n"
|
||
"Щоб зв'язати Telegram з акаунтом Energy Union:\n"
|
||
"1. Отримай код у кабінеті Energy Union\n"
|
||
"2. Надішли: `/link <код>`\n\n"
|
||
"Приклад: `/link ABC123XYZ`",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "link_help"}
|
||
|
||
link_code = parts[1].strip()
|
||
|
||
# Call PostgreSQL function to complete linking
|
||
try:
|
||
import asyncpg
|
||
pg_conn = await asyncpg.connect(
|
||
host=os.getenv("POSTGRES_HOST", "dagi-postgres"),
|
||
port=int(os.getenv("POSTGRES_PORT", "5432")),
|
||
user=os.getenv("POSTGRES_USER", "daarion"),
|
||
password=os.getenv("POSTGRES_PASSWORD", "DaarionDB2026!"),
|
||
database=os.getenv("POSTGRES_DB", "daarion_main")
|
||
)
|
||
|
||
result = await pg_conn.fetchrow(
|
||
"SELECT * FROM complete_account_link($1, $2, $3, $4, $5)",
|
||
link_code,
|
||
int(user_id),
|
||
username,
|
||
first_name,
|
||
last_name
|
||
)
|
||
await pg_conn.close()
|
||
|
||
if result and result['success']:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"✅ **Акаунт успішно зв'язано!**\n\n"
|
||
"Тепер Helion бачить твою історію взаємодій "
|
||
"з платформою Energy Union.\n\n"
|
||
"Твої розмови в різних чатах тепер пов'язані "
|
||
"з твоїм єдиним акаунтом.",
|
||
telegram_token
|
||
)
|
||
logger.info(f"Account linked: telegram_user_id={user_id}, account_id={result['account_id']}")
|
||
return {"ok": True, "action": "account_linked", "account_id": str(result['account_id'])}
|
||
else:
|
||
error_msg = result['error_message'] if result else "Невідома помилка"
|
||
error_text = {
|
||
"Invalid or expired code": "Код недійсний або прострочений",
|
||
"Telegram account already linked": "Telegram вже зв'язано з іншим акаунтом",
|
||
"Code not found": "Код не знайдено"
|
||
}.get(error_msg, error_msg)
|
||
|
||
await send_telegram_message(
|
||
chat_id,
|
||
f"❌ **Не вдалося зв'язати акаунт**\n\n"
|
||
f"Причина: {error_text}\n\n"
|
||
"Спробуй отримати новий код у кабінеті Energy Union.",
|
||
telegram_token
|
||
)
|
||
return {"ok": False, "error": error_msg}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Account linking failed: {e}", exc_info=True)
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❌ Помилка зв'язування акаунта. Спробуй пізніше.",
|
||
telegram_token
|
||
)
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
# Check for /unlink command
|
||
if text and text.strip().startswith("/unlink"):
|
||
try:
|
||
import asyncpg
|
||
pg_conn = await asyncpg.connect(
|
||
host=os.getenv("POSTGRES_HOST", "dagi-postgres"),
|
||
port=int(os.getenv("POSTGRES_PORT", "5432")),
|
||
user=os.getenv("POSTGRES_USER", "daarion"),
|
||
password=os.getenv("POSTGRES_PASSWORD", "DaarionDB2026!"),
|
||
database=os.getenv("POSTGRES_DB", "daarion_main")
|
||
)
|
||
|
||
result = await pg_conn.execute(
|
||
"""
|
||
UPDATE account_links
|
||
SET status = 'revoked',
|
||
revoked_at = NOW(),
|
||
revoked_reason = 'User requested via /unlink'
|
||
WHERE telegram_user_id = $1 AND status = 'active'
|
||
""",
|
||
int(user_id)
|
||
)
|
||
await pg_conn.close()
|
||
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"✅ **Зв'язок з акаунтом видалено**\n\n"
|
||
"Helion більше не бачить твою історію.\n"
|
||
"Ти можеш повторно зв'язати акаунт командою `/link`.",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "account_unlinked"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Account unlinking failed: {e}", exc_info=True)
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❌ Помилка видалення зв'язку. Спробуй пізніше.",
|
||
telegram_token
|
||
)
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
# Check for /status command - Show linking status
|
||
if text and text.strip().startswith("/status"):
|
||
try:
|
||
import asyncpg
|
||
pg_conn = await asyncpg.connect(
|
||
host=os.getenv("POSTGRES_HOST", "dagi-postgres"),
|
||
port=int(os.getenv("POSTGRES_PORT", "5432")),
|
||
user=os.getenv("POSTGRES_USER", "daarion"),
|
||
password=os.getenv("POSTGRES_PASSWORD", "DaarionDB2026!"),
|
||
database=os.getenv("POSTGRES_DB", "daarion_main")
|
||
)
|
||
|
||
link = await pg_conn.fetchrow(
|
||
"""
|
||
SELECT account_id, linked_at, status
|
||
FROM account_links
|
||
WHERE telegram_user_id = $1 AND status = 'active'
|
||
""",
|
||
int(user_id)
|
||
)
|
||
await pg_conn.close()
|
||
|
||
if link:
|
||
linked_date = link['linked_at'].strftime("%d.%m.%Y %H:%M")
|
||
await send_telegram_message(
|
||
chat_id,
|
||
f"✅ **Акаунт зв'язано**\n\n"
|
||
f"📅 Дата: {linked_date}\n"
|
||
f"🔗 Статус: активний\n\n"
|
||
f"Helion бачить твою історію взаємодій.",
|
||
telegram_token
|
||
)
|
||
else:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"❌ **Акаунт не зв'язано**\n\n"
|
||
"Використай `/link <код>` щоб зв'язати.\n"
|
||
"Код можна отримати в кабінеті Energy Union.",
|
||
telegram_token
|
||
)
|
||
return {"ok": True, "action": "status_checked", "linked": bool(link)}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Status check failed: {e}", exc_info=True)
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
# Check if it's a document
|
||
document = update.message.get("document")
|
||
if document:
|
||
result = await process_document(
|
||
agent_config, update, chat_id, user_id, username, dao_id, document
|
||
)
|
||
if result.get("ok"):
|
||
return result
|
||
|
||
# Check if it's a photo
|
||
photo = update.message.get("photo")
|
||
if photo:
|
||
result = await process_photo(
|
||
agent_config, update, chat_id, user_id, username, dao_id, photo
|
||
)
|
||
return result
|
||
|
||
# Check if it's a voice message
|
||
voice = update.message.get("voice")
|
||
audio = update.message.get("audio")
|
||
video_note = update.message.get("video_note")
|
||
|
||
text = ""
|
||
if voice or audio or video_note:
|
||
media_obj = voice or audio or video_note
|
||
result = await process_voice(
|
||
agent_config, update, chat_id, user_id, username, dao_id, media_obj
|
||
)
|
||
if result.get("ok") and result.get("text"):
|
||
# Отримали розпізнаний текст, продовжуємо обробку як текстове повідомлення
|
||
text = result.get("text")
|
||
elif result.get("ok"):
|
||
# STT успішний, але текст порожній
|
||
return result
|
||
else:
|
||
# Помилка STT
|
||
return result
|
||
|
||
# Get message text (якщо не було голосового повідомлення)
|
||
if not text:
|
||
text = update.message.get("text", "")
|
||
caption = update.message.get("caption", "")
|
||
|
||
# Friendly greeting fast-path for better UX and less mechanical replies.
|
||
if _is_simple_greeting(text):
|
||
greeting_reply = (
|
||
f"Привіт, {username or 'друже'}! Я {agent_config.name}. "
|
||
"Можу допомогти з фото рослин, Excel-звітами та короткими практичними порадами."
|
||
)
|
||
await send_telegram_message(chat_id, greeting_reply, telegram_token)
|
||
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=greeting_reply,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=True,
|
||
agent_metadata={"greeting_fast_path": True},
|
||
username=username,
|
||
)
|
||
return {"ok": True, "agent": agent_config.agent_id, "mode": "greeting_fast_path"}
|
||
|
||
# AgroMatrix: capture user correction for latest photo and persist anonymized learning.
|
||
if agent_config.agent_id == "agromatrix" and text:
|
||
corrected_label = _extract_agromatrix_correction_label(text)
|
||
negative_feedback = _is_agromatrix_negative_feedback(text)
|
||
recent_file_id = _get_recent_photo_file_id(agent_config.agent_id, chat_id, user_id)
|
||
if not recent_file_id:
|
||
try:
|
||
mc = await memory_client.get_context(
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
team_id=dao_id,
|
||
channel_id=chat_id,
|
||
limit=80,
|
||
)
|
||
recent_file_id = _extract_recent_photo_file_id_from_memory(mc)
|
||
except Exception:
|
||
recent_file_id = None
|
||
if not recent_file_id:
|
||
recent_file_id = await _get_agromatrix_last_photo_ref(
|
||
chat_id=chat_id,
|
||
user_id=user_id,
|
||
dao_id=dao_id,
|
||
)
|
||
|
||
if corrected_label and recent_file_id:
|
||
await _save_agromatrix_photo_learning(
|
||
file_id=recent_file_id,
|
||
label=corrected_label,
|
||
source="user_correction",
|
||
chat_id=chat_id,
|
||
user_id=user_id,
|
||
dao_id=dao_id,
|
||
)
|
||
logger.info(
|
||
f"AgroMatrix learning updated: file_id={recent_file_id}, label={corrected_label}"
|
||
)
|
||
if _is_agromatrix_correction_only_message(text):
|
||
ack = (
|
||
f"Дякую, зафіксував виправлення: {corrected_label}. "
|
||
"Для цього фото надалі використовуватиму саме цю мітку."
|
||
)
|
||
await send_telegram_message(chat_id, ack, telegram_token)
|
||
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=ack,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=True,
|
||
agent_metadata={"agromatrix_learning_updated": True},
|
||
username=username,
|
||
)
|
||
return {"ok": True, "agent": agent_config.agent_id, "mode": "learning_updated"}
|
||
elif corrected_label and _is_agromatrix_correction_only_message(text):
|
||
ack = (
|
||
f"Прийняв виправлення: {corrected_label}. "
|
||
"Але не бачу останнє фото в контексті, надішли фото ще раз і я зафіксую корекцію саме до нього."
|
||
)
|
||
await send_telegram_message(chat_id, ack, telegram_token)
|
||
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=ack,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=True,
|
||
agent_metadata={"agromatrix_learning_no_photo_context": True},
|
||
username=username,
|
||
)
|
||
return {"ok": True, "agent": agent_config.agent_id, "mode": "learning_no_photo_context"}
|
||
|
||
# If user says answer is wrong but does not provide a replacement label,
|
||
# invalidate stale prior so it won't keep forcing repeated wrong guesses.
|
||
if negative_feedback and not corrected_label and recent_file_id:
|
||
await _invalidate_agromatrix_photo_learning(
|
||
file_id=recent_file_id,
|
||
reason="user_marked_previous_answer_wrong_without_replacement",
|
||
dao_id=dao_id,
|
||
)
|
||
logger.info(
|
||
f"AgroMatrix learning invalidated: file_id={recent_file_id}, reason=negative_feedback_no_label"
|
||
)
|
||
if _is_agromatrix_correction_only_message(text):
|
||
ack = (
|
||
"Прийняв. Попередню мітку для цього фото скасовано. "
|
||
"Напиши правильну назву культури або попроси: 'перевір фото ще раз'."
|
||
)
|
||
await send_telegram_message(chat_id, ack, telegram_token)
|
||
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=ack,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=True,
|
||
agent_metadata={"agromatrix_learning_invalidated": True},
|
||
username=username,
|
||
)
|
||
return {"ok": True, "agent": agent_config.agent_id, "mode": "learning_invalidated"}
|
||
elif negative_feedback and not corrected_label and _is_agromatrix_correction_only_message(text):
|
||
ack = (
|
||
"Прийняв, що попередня відповідь була хибна. "
|
||
"Щоб закріпити правильну мітку, напиши у форматі: 'правильна відповідь: <назва культури>'."
|
||
)
|
||
await send_telegram_message(chat_id, ack, telegram_token)
|
||
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=ack,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=True,
|
||
agent_metadata={"agromatrix_negative_feedback_no_photo_context": True},
|
||
username=username,
|
||
)
|
||
return {"ok": True, "agent": agent_config.agent_id, "mode": "negative_feedback_ack"}
|
||
|
||
# Photo/image intent guard:
|
||
# if text references a photo/image, try to resolve latest file_id and route to vision.
|
||
photo_intent = False
|
||
if text:
|
||
tl = text.lower()
|
||
photo_intent = _looks_like_photo_followup(text)
|
||
if not photo_intent:
|
||
# Robust fallback for common formulations like "що на цьому фото?"
|
||
photo_intent = bool(
|
||
re.search(r"(що|what|что).{0,24}(цьому|этом|this).{0,24}(фото|зображ|світлин|image|photo)", tl)
|
||
)
|
||
|
||
if photo_intent:
|
||
recent_file_id = _get_recent_photo_file_id(agent_config.agent_id, chat_id, user_id)
|
||
|
||
# Fallback: recover latest photo file_id from memory-service context (survives process restarts).
|
||
if not recent_file_id:
|
||
try:
|
||
mc = await memory_client.get_context(
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
team_id=dao_id,
|
||
channel_id=chat_id,
|
||
limit=80,
|
||
)
|
||
recent_file_id = _extract_recent_photo_file_id_from_memory(mc)
|
||
if recent_file_id:
|
||
_set_recent_photo_context(agent_config.agent_id, chat_id, user_id, recent_file_id)
|
||
logger.info(
|
||
f"{agent_config.name}: Recovered photo file_id from memory context for follow-up: {recent_file_id}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"{agent_config.name}: failed to recover photo file_id from memory: {e}")
|
||
|
||
if recent_file_id:
|
||
logger.info(
|
||
f"{agent_config.name}: Photo intent detected; using file_id={recent_file_id}"
|
||
)
|
||
followup_result = await process_photo(
|
||
agent_config=agent_config,
|
||
update=update,
|
||
chat_id=chat_id,
|
||
user_id=user_id,
|
||
username=username,
|
||
dao_id=dao_id,
|
||
photo={"file_id": recent_file_id},
|
||
caption_override=text,
|
||
bypass_media_gate=True,
|
||
)
|
||
return followup_result
|
||
|
||
# Hard guard: don't send photo-related requests to text LLM path when image context is missing.
|
||
if _needs_photo_only_response(text):
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Бачу питання про фото, але не знайшов зображення в історії сесії. Надішли фото ще раз з коротким питанням, і я одразу проаналізую.",
|
||
telegram_token,
|
||
)
|
||
return {"ok": True, "handled": True, "reason": "photo_followup_without_image_context"}
|
||
|
||
if not text and not caption:
|
||
# Check for unsupported message types and silently ignore
|
||
unsupported_types = ["sticker", "animation", "video_note", "contact", "location",
|
||
"venue", "poll", "dice", "game", "new_chat_members",
|
||
"left_chat_member", "new_chat_title", "new_chat_photo",
|
||
"delete_chat_photo", "pinned_message", "message_auto_delete_timer_changed"]
|
||
for msg_type in unsupported_types:
|
||
if update.message.get(msg_type):
|
||
logger.debug(f"Ignoring unsupported message type: {msg_type}")
|
||
return {"ok": True, "ignored": True, "reason": f"Unsupported message type: {msg_type}"}
|
||
|
||
# If no supported content found, return silently
|
||
logger.debug(f"Message without processable content from user {user_id}")
|
||
return {"ok": True, "ignored": True, "reason": "No processable content"}
|
||
|
||
# Use caption if text is empty (for photos with captions that weren't processed)
|
||
if not text and caption:
|
||
text = caption
|
||
|
||
logger.info(f"{agent_config.name} Telegram message from {username} (tg:{user_id}) in chat {chat_id}: {text[:50]}")
|
||
mentioned_bots = extract_bot_mentions(text)
|
||
needs_complex_reasoning = requires_complex_reasoning(text)
|
||
|
||
cached_answer = get_cached_response(agent_config.agent_id, chat_id, text)
|
||
if cached_answer:
|
||
await send_telegram_message(chat_id, cached_answer, telegram_token)
|
||
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=cached_answer,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=not is_service_response(cached_answer),
|
||
agent_metadata={
|
||
"cached_reply": True,
|
||
"mentioned_bots": mentioned_bots,
|
||
"requires_complex_reasoning": needs_complex_reasoning,
|
||
},
|
||
username=username,
|
||
)
|
||
return {"ok": True, "agent": agent_config.agent_id, "cached": True}
|
||
|
||
# Check if there's a document context for follow-up questions
|
||
session_id = f"telegram:{chat_id}"
|
||
doc_context = await get_doc_context(session_id, agent_id=agent_config.agent_id)
|
||
|
||
# If there's a doc_id and the message looks like a question about the document
|
||
if doc_context and doc_context.doc_id:
|
||
is_question = _is_question_like(text)
|
||
|
||
if is_question:
|
||
logger.info(f"{agent_config.name}: Follow-up question detected for doc_id={doc_context.doc_id}")
|
||
# Try RAG query first
|
||
try:
|
||
rag_result = await asyncio.wait_for(
|
||
ask_about_document(
|
||
session_id=session_id,
|
||
question=text,
|
||
doc_id=doc_context.doc_id,
|
||
dao_id=dao_id or doc_context.dao_id,
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
),
|
||
timeout=25.0,
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.warning(
|
||
f"{agent_config.name}: doc follow-up timeout for doc_id={doc_context.doc_id}; "
|
||
"fallback to regular chat path"
|
||
)
|
||
rag_result = None
|
||
|
||
if rag_result and rag_result.success and rag_result.answer:
|
||
# Truncate if too long for Telegram
|
||
answer = postprocess_agent_answer(
|
||
agent_id=agent_config.agent_id,
|
||
user_text=text or "",
|
||
answer_text=rag_result.answer,
|
||
force_detailed=should_force_detailed_reply(text),
|
||
needs_complex_reasoning=requires_complex_reasoning(text),
|
||
)
|
||
if len(answer) > TELEGRAM_SAFE_LENGTH:
|
||
answer = answer[:TELEGRAM_SAFE_LENGTH] + "\n\n_... (відповідь обрізано)_"
|
||
|
||
answer = _sanitize_agent_answer(agent_config.agent_id, text or "", answer)
|
||
await send_telegram_message(chat_id, answer, telegram_token)
|
||
return {"ok": True, "agent": "parser", "mode": "rag_query"}
|
||
# Source-lock: with active document context answer only from that document.
|
||
await send_telegram_message(
|
||
chat_id,
|
||
"Не знайшов точну відповідь у поточному документі. Уточни питання або надішли файл повторно.",
|
||
telegram_token,
|
||
)
|
||
return {"ok": True, "agent": "parser", "mode": "source_lock_no_answer"}
|
||
|
||
# ========================================
|
||
# BEHAVIOR POLICY v2.1: Check if should respond
|
||
# Gateway computes has_link and has_explicit_request (source of truth)
|
||
# ========================================
|
||
chat_type = chat.get("type", "private")
|
||
is_private_chat = chat_type == "private"
|
||
|
||
# Gateway: compute has_link (single source of truth)
|
||
has_link = detect_url(text) if text else False
|
||
|
||
# Gateway: detect mentioned agents
|
||
mentioned_agents = []
|
||
if text:
|
||
for aid, variants in AGENT_NAME_VARIANTS.items():
|
||
for v in variants:
|
||
if v.lower() in text.lower():
|
||
mentioned_agents.append(aid)
|
||
break
|
||
|
||
# Gateway: check if agent has been active in this chat recently (any user)
|
||
agent_active_in_chat = has_agent_chat_participation(agent_config.agent_id, chat_id)
|
||
|
||
# Gateway: compute has_explicit_request (single source of truth)
|
||
# CONTRACT: imperative OR (? AND (dm OR reply OR mention OR thread))
|
||
has_explicit_request = detect_explicit_request(
|
||
text=text,
|
||
is_dm=is_private_chat,
|
||
is_reply_to_agent=is_reply_to_agent,
|
||
mentioned_agents=mentioned_agents,
|
||
thread_has_agent_participation=agent_active_in_chat,
|
||
)
|
||
|
||
# Check if this is a prober request (chat_id=0 or user_id=0)
|
||
is_prober = is_prober_request(chat_id, user_id)
|
||
|
||
# SOWA v2.2: 3-level decision (FULL / ACK / SILENT)
|
||
sowa_decision = analyze_message(
|
||
text=text,
|
||
agent_id=agent_config.agent_id,
|
||
chat_id=chat_id,
|
||
user_id=str(user_id),
|
||
has_media=has_link,
|
||
media_caption=text if has_link else "",
|
||
is_private_chat=is_private_chat,
|
||
payload_explicit_request=has_explicit_request,
|
||
payload_has_link=has_link,
|
||
is_reply_to_agent=is_reply_to_agent,
|
||
thread_has_agent_participation=agent_active_in_chat,
|
||
)
|
||
respond_decision = sowa_decision.should_respond
|
||
respond_reason = sowa_decision.reason
|
||
|
||
# AgroMatrix usability guard:
|
||
# In dedicated chats users often ask short operational questions without explicit mention.
|
||
# Do not silence clear question turns for agromatrix.
|
||
if (
|
||
sowa_decision.action == "SILENT"
|
||
and agent_config.agent_id == "agromatrix"
|
||
and _is_question_like(text)
|
||
):
|
||
sowa_decision.action = "FULL"
|
||
sowa_decision.should_respond = True
|
||
respond_decision = True
|
||
respond_reason = "agromatrix_question_guard"
|
||
|
||
if sowa_decision.action == "SILENT":
|
||
logger.info(f"\U0001f507 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,
|
||
},
|
||
username=username,
|
||
)
|
||
return {"ok": True, "skipped": True, "reason": respond_reason}
|
||
|
||
# ACK: send short presence message WITHOUT calling LLM/Router
|
||
if sowa_decision.action == "ACK":
|
||
ack_text = sowa_decision.ack_text or get_ack_text(agent_config.agent_id)
|
||
logger.info(f"\U0001f44b SOWA ACK: Agent {agent_config.agent_id} sending ACK. Reason: {respond_reason}")
|
||
|
||
# Send ACK to Telegram (no LLM call)
|
||
if not is_prober:
|
||
token = agent_config.get_telegram_token()
|
||
if token:
|
||
try:
|
||
url = f"https://api.telegram.org/bot{token}/sendMessage"
|
||
async with httpx.AsyncClient(timeout=30) as client:
|
||
ack_payload = {
|
||
"chat_id": chat_id,
|
||
"text": ack_text,
|
||
}
|
||
# Link ACK to the user's message for better UX
|
||
msg_id = update.message.get("message_id")
|
||
if msg_id:
|
||
ack_payload["reply_to_message_id"] = msg_id
|
||
ack_payload["allow_sending_without_reply"] = True
|
||
resp = await client.post(url, json=ack_payload)
|
||
if resp.status_code == 200:
|
||
logger.info(f"\U0001f44b ACK sent to chat {chat_id}: {ack_text}")
|
||
else:
|
||
logger.warning(f"ACK send failed: {resp.status_code} {resp.text[:200]}")
|
||
except Exception as e:
|
||
logger.error(f"ACK send error: {e}")
|
||
|
||
# Record ACK for cooldown and interaction tracking
|
||
record_ack(agent_config.agent_id, str(chat_id))
|
||
record_interaction(agent_config.agent_id, str(chat_id), str(user_id))
|
||
|
||
# Save to memory
|
||
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=ack_text,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=True,
|
||
agent_metadata={
|
||
"sowa_ack": True,
|
||
"ack_reason": respond_reason,
|
||
},
|
||
username=username,
|
||
)
|
||
return {"ok": True, "ack": True, "reason": respond_reason}
|
||
|
||
# FULL: proceed with LLM/Router call
|
||
# For prober requests, skip LLM/Router entirely to save tokens
|
||
if is_prober:
|
||
logger.info(f"\U0001f9ea PROBER: Agent {agent_config.agent_id} responding to prober (no LLM call). Reason: {respond_reason}")
|
||
return {"ok": True, "agent": agent_config.agent_id, "prober": True, "response_preview": "[prober-skip-llm]"}
|
||
else:
|
||
logger.info(f"\u2705 SOWA: Agent {agent_config.agent_id} WILL respond (FULL). Reason: {respond_reason}")
|
||
|
||
# Regular chat mode
|
||
# Fetch memory context (includes local context as fallback)
|
||
# Всі агенти мають доступ до однакової історії (80 повідомлень) для контексту
|
||
context_limit = 40 if agent_config.agent_id == "agromatrix" else 80
|
||
memory_context = await memory_client.get_context(
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
team_id=dao_id,
|
||
channel_id=chat_id,
|
||
limit=context_limit
|
||
)
|
||
|
||
# Build message with conversation context
|
||
local_history = _truncate_context_for_prompt(
|
||
memory_context.get("local_context_text", ""),
|
||
max_chars=2200 if agent_config.agent_id == "agromatrix" else 3800,
|
||
max_lines=28 if agent_config.agent_id == "agromatrix" else 48,
|
||
)
|
||
|
||
# Check if this is a training group
|
||
is_training_group = str(chat_id) in TRAINING_GROUP_IDS
|
||
training_prefix = ""
|
||
if is_training_group:
|
||
training_prefix = "[РЕЖИМ НАВЧАННЯ - відповідай на це повідомлення, ти в навчальній групі Agent Preschool]\n\n"
|
||
logger.info(f"🎓 Training mode activated for chat {chat_id}")
|
||
|
||
unresolved_questions = _extract_unanswered_user_messages(
|
||
memory_context=memory_context,
|
||
current_user_id=f"tg:{user_id}",
|
||
max_items=3,
|
||
)
|
||
unresolved_non_current: List[str] = []
|
||
unresolved_block = ""
|
||
if unresolved_questions:
|
||
# Do not duplicate current prompt if it matches one pending message.
|
||
unresolved_non_current = [q for q in unresolved_questions if q.strip() != (text or "").strip()]
|
||
if unresolved_non_current:
|
||
unresolved_non_current = unresolved_non_current[-1:] if agent_config.agent_id == "agromatrix" else unresolved_non_current
|
||
unresolved_block = (
|
||
"[КРИТИЧНО: є невідповідані питання цього користувача. "
|
||
"Спочатку коротко відповідай на них, потім на поточне повідомлення. "
|
||
"Не змінюй тему і не ігноруй pending-питання.]\n"
|
||
"[Невідповідані питання цього користувача]\n"
|
||
+ "\n".join(f"- {q}" for q in unresolved_non_current)
|
||
+ "\n\n"
|
||
)
|
||
|
||
if local_history:
|
||
# Add conversation history to message for better context understanding
|
||
message_with_context = (
|
||
f"{training_prefix}"
|
||
f"[Контекст розмови]\n{local_history}\n\n"
|
||
f"{unresolved_block}"
|
||
f"[Поточне повідомлення від {username}]\n{text}"
|
||
)
|
||
else:
|
||
message_with_context = f"{training_prefix}{unresolved_block}{text}"
|
||
|
||
preferred_lang = await resolve_preferred_language_persistent(
|
||
chat_id=chat_id,
|
||
user_id=user_id,
|
||
text=text or "",
|
||
telegram_lang_code=from_user.get("language_code"),
|
||
team_id=dao_id,
|
||
)
|
||
preferred_lang_label = preferred_language_label(preferred_lang)
|
||
response_style_pref = await resolve_response_style_preference(
|
||
agent_id=agent_config.agent_id,
|
||
chat_id=chat_id,
|
||
user_id=str(user_id),
|
||
text=text or "",
|
||
team_id=dao_id,
|
||
)
|
||
force_detailed = should_force_detailed_reply(text) or response_style_pref == "detailed"
|
||
force_concise = (not force_detailed) and (should_force_concise_reply(text) or response_style_pref == "concise")
|
||
|
||
# Build request to Router
|
||
system_prompt = agent_config.system_prompt
|
||
logger.info(f"📝 {agent_config.name} system_prompt length: {len(system_prompt) if system_prompt else 0} chars")
|
||
if system_prompt:
|
||
logger.debug(f"System prompt preview: {system_prompt[:200]}...")
|
||
else:
|
||
logger.error(f"❌ {agent_config.name} system_prompt is EMPTY or None!")
|
||
|
||
router_request = {
|
||
"message": message_with_context,
|
||
"mode": "chat",
|
||
"agent": agent_config.agent_id,
|
||
"metadata": {
|
||
"source": "telegram",
|
||
"dao_id": dao_id,
|
||
"user_id": f"tg:{user_id}",
|
||
"session_id": f"tg:{chat_id}:{dao_id}",
|
||
"username": username,
|
||
"chat_id": chat_id,
|
||
"raw_user_text": text,
|
||
"sender_is_bot": is_sender_bot,
|
||
"mentioned_bots": mentioned_bots,
|
||
"requires_complex_reasoning": needs_complex_reasoning,
|
||
"is_reply_to_agent": is_reply_to_agent,
|
||
"is_training_group": is_training_group,
|
||
"preferred_response_language": preferred_lang,
|
||
"preferred_response_language_label": preferred_lang_label,
|
||
"response_style_preference": response_style_pref,
|
||
"has_unresolved_questions": bool(unresolved_non_current),
|
||
"unresolved_questions_count": len(unresolved_non_current),
|
||
},
|
||
"context": {
|
||
"agent_name": agent_config.name,
|
||
"system_prompt": system_prompt,
|
||
"memory": memory_context,
|
||
"participants": {
|
||
"sender_is_bot": is_sender_bot,
|
||
"mentioned_bots": mentioned_bots,
|
||
},
|
||
},
|
||
}
|
||
|
||
if force_detailed:
|
||
router_request["metadata"]["force_detailed"] = True
|
||
|
||
if force_concise:
|
||
# IMPORTANT: preserve conversation context! Only append concise instruction
|
||
router_request["metadata"]["force_concise"] = True
|
||
router_request["message"] = (
|
||
router_request["message"]
|
||
+ "\n\n(Інструкція: спочатку дай коротку відповідь по суті (1-3 абзаци), "
|
||
"а якщо користувач попросить — розгорни детально.)"
|
||
+ f"\n(Мова відповіді: {preferred_lang_label}.)"
|
||
+ "\n(Не потрібно щоразу представлятися по імені або писати шаблонне: 'чим можу допомогти'.)"
|
||
)
|
||
|
||
# Helion policy: DeepSeek-first primary response path.
|
||
if agent_config.agent_id == "helion":
|
||
router_request["metadata"]["provider"] = "cloud_deepseek"
|
||
router_request["metadata"]["reason"] = "helion_primary_deepseek"
|
||
elif needs_complex_reasoning:
|
||
router_request["metadata"]["provider"] = "cloud_deepseek"
|
||
router_request["metadata"]["reason"] = "auto_complex"
|
||
|
||
if not force_concise:
|
||
router_request["message"] = (
|
||
router_request["message"]
|
||
+ f"\n\n(Мова відповіді: {preferred_lang_label}.)"
|
||
+ "\n(Не потрібно щоразу представлятися по імені або писати шаблонне: 'чим можу допомогти'.)"
|
||
)
|
||
|
||
if unresolved_non_current:
|
||
router_request["message"] = (
|
||
router_request["message"]
|
||
+ "\n\n(Пріоритет відповіді: 1) закрий невідповідані питання користувача; "
|
||
"2) дай відповідь на поточне повідомлення. Якщо питання пов'язані, дай одну узгоджену відповідь.)"
|
||
)
|
||
|
||
# Send to Router
|
||
logger.info(f"Sending to Router: agent={agent_config.agent_id}, dao={dao_id}, user=tg:{user_id}")
|
||
response = await send_to_router(router_request)
|
||
|
||
# Extract response
|
||
if isinstance(response, dict) and response.get("ok"):
|
||
answer_text = response.get("data", {}).get("text") or response.get("response", "")
|
||
image_base64 = response.get("image_base64") or response.get("data", {}).get("image_base64")
|
||
file_base64 = response.get("file_base64") or response.get("data", {}).get("file_base64")
|
||
file_name = response.get("file_name") or response.get("data", {}).get("file_name") or "artifact.bin"
|
||
file_mime = response.get("file_mime") or response.get("data", {}).get("file_mime") or "application/octet-stream"
|
||
|
||
# Debug logging
|
||
logger.info(f"📦 Router response: {len(answer_text)} chars, model={response.get('model')}, backend={response.get('backend')}")
|
||
logger.info(f"📝 Response preview: {answer_text[:300]}..." if len(answer_text) > 300 else f"📝 Response: {answer_text}")
|
||
if image_base64:
|
||
logger.info(f"🖼️ Received image_base64: {len(image_base64)} chars")
|
||
else:
|
||
logger.debug("⚠️ No image_base64 in response")
|
||
if file_base64:
|
||
logger.info(f"📄 Received file_base64: {len(file_base64)} chars ({file_name})")
|
||
|
||
# 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.")
|
||
|
||
# P4: Detect NO_OUTPUT contract violations (extra text after NO_OUTPUT marker)
|
||
_stripped = (answer_text or "").strip()
|
||
_has_extra = False
|
||
if _stripped and "__NO_OUTPUT__" in _stripped:
|
||
_after_marker = _stripped.split("__NO_OUTPUT__", 1)[-1].strip()
|
||
if _after_marker:
|
||
_has_extra = True
|
||
logger.warning(
|
||
f"🚨 policy_violation=no_output_extra_text "
|
||
f"agent={agent_config.agent_id} "
|
||
f"chat_id={chat_id} "
|
||
f"extra_text_len={len(_after_marker)} "
|
||
f"extra_preview={_after_marker[:80]!r}"
|
||
)
|
||
elif _stripped and _stripped.lower() not in ("", "no_output", "no output", "silent", "мовчу", "—", ".", "..", "..."):
|
||
# LLM returned something that looks like NO_OUTPUT but has unexpected content
|
||
if len(_stripped) > 10:
|
||
_has_extra = True
|
||
logger.warning(
|
||
f"🚨 policy_violation=ambiguous_no_output "
|
||
f"agent={agent_config.agent_id} "
|
||
f"chat_id={chat_id} "
|
||
f"response_len={len(_stripped)} "
|
||
f"response_preview={_stripped[:80]!r}"
|
||
)
|
||
|
||
# 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 "",
|
||
"policy_violation": "no_output_extra_text" if _has_extra else None,
|
||
},
|
||
username=username,
|
||
)
|
||
return {"ok": True, "skipped": True, "reason": "no_output_from_llm"}
|
||
|
||
# Retry policy: if response drifts from current intent, do one strict reroute.
|
||
if _answer_seems_off_intent(text or "", answer_text):
|
||
try:
|
||
strict_request = copy.deepcopy(router_request)
|
||
strict_request["metadata"]["intent_retry"] = 1
|
||
strict_request["metadata"]["disable_tools"] = True
|
||
strict_request["metadata"]["max_tool_rounds"] = 1
|
||
strict_request["metadata"]["temperature"] = 0.1
|
||
strict_request["message"] = (
|
||
f"{message_with_context}\n\n"
|
||
"(Жорстка інструкція: відповідай тільки на ПОТОЧНЕ питання користувача. "
|
||
"Не змінюй тему, не генеруй презентацію/план, якщо цього не просили. "
|
||
"Для числових питань: дай value + unit + джерело (лист/рядок).)"
|
||
)
|
||
retry_response = await send_to_router(strict_request)
|
||
if isinstance(retry_response, dict) and retry_response.get("ok"):
|
||
retry_text = retry_response.get("data", {}).get("text") or retry_response.get("response", "")
|
||
if retry_text and not is_no_output_response(retry_text):
|
||
answer_text = retry_text
|
||
router_request = strict_request
|
||
logger.info("Intent retry succeeded with strict prompt")
|
||
except Exception as retry_err:
|
||
logger.warning(f"Intent retry failed: {retry_err}")
|
||
|
||
force_detailed_reply = bool(router_request.get("metadata", {}).get("force_detailed"))
|
||
answer_text = postprocess_agent_answer(
|
||
agent_id=agent_config.agent_id,
|
||
user_text=text or "",
|
||
answer_text=answer_text,
|
||
force_detailed=force_detailed_reply,
|
||
needs_complex_reasoning=needs_complex_reasoning,
|
||
)
|
||
answer_text = _sanitize_agent_answer(agent_config.agent_id, text or "", answer_text)
|
||
|
||
# Skip Telegram sending for prober requests (chat_id=0)
|
||
if is_prober:
|
||
logger.info(f"🧪 PROBER: Skipping Telegram send for prober request. Response: {answer_text[:100]}...")
|
||
return {"ok": True, "agent": agent_config.agent_id, "prober": True, "response_preview": answer_text[:100]}
|
||
|
||
# Send file artifact if generated
|
||
if file_base64:
|
||
try:
|
||
file_bytes = base64.b64decode(file_base64)
|
||
token = telegram_token or os.getenv("TELEGRAM_BOT_TOKEN")
|
||
url = f"https://api.telegram.org/bot{token}/sendDocument"
|
||
caption = answer_text[:1024] if answer_text else ""
|
||
safe_name = str(file_name).split("/")[-1].split("\\")[-1] or "artifact.bin"
|
||
async with httpx.AsyncClient() as client:
|
||
files = {"document": (safe_name, BytesIO(file_bytes), file_mime)}
|
||
data = {"chat_id": chat_id}
|
||
if caption:
|
||
data["caption"] = caption
|
||
response_doc = await client.post(url, files=files, data=data, timeout=45.0)
|
||
response_doc.raise_for_status()
|
||
logger.info(f"✅ Sent generated document to Telegram chat {chat_id}: {safe_name}")
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to send document to Telegram: {e}")
|
||
await send_telegram_message(chat_id, answer_text or "Файл згенеровано, але не вдалося надіслати документ.", telegram_token)
|
||
# Send image if generated
|
||
elif image_base64:
|
||
try:
|
||
# Decode base64 image
|
||
image_bytes = base64.b64decode(image_base64)
|
||
|
||
# Send photo to Telegram
|
||
token = telegram_token or os.getenv("TELEGRAM_BOT_TOKEN")
|
||
url = f"https://api.telegram.org/bot{token}/sendPhoto"
|
||
|
||
async with httpx.AsyncClient() as client:
|
||
files = {"photo": ("image.png", BytesIO(image_bytes), "image/png")}
|
||
# Telegram caption limit is 1024 chars.
|
||
safe_caption = (answer_text or "")[:1024]
|
||
data = {"chat_id": chat_id, "caption": safe_caption}
|
||
response_photo = await client.post(url, files=files, data=data, timeout=30.0)
|
||
response_photo.raise_for_status()
|
||
logger.info(f"✅ Sent generated image to Telegram chat {chat_id}")
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to send image to Telegram: {e}")
|
||
# Fallback to text only
|
||
await send_telegram_message(chat_id, answer_text, telegram_token)
|
||
else:
|
||
# Send text response only
|
||
await send_telegram_message(chat_id, answer_text, telegram_token)
|
||
|
||
# Record successful interaction for conversation context
|
||
record_interaction(agent_config.agent_id, chat_id, str(user_id))
|
||
|
||
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=answer_text,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=not is_service_response(answer_text),
|
||
agent_metadata={
|
||
"mentioned_bots": mentioned_bots,
|
||
"requires_complex_reasoning": needs_complex_reasoning,
|
||
"preferred_language": preferred_lang,
|
||
},
|
||
username=username,
|
||
)
|
||
|
||
store_response_cache(agent_config.agent_id, chat_id, text, answer_text)
|
||
|
||
return {"ok": True, "agent": agent_config.agent_id}
|
||
else:
|
||
error_msg = response.get("error", "Unknown error") if isinstance(response, dict) else "Router error"
|
||
logger.error(f"Router error: {error_msg}")
|
||
await send_telegram_message(chat_id, f"Вибач, сталася помилка: {error_msg}", telegram_token)
|
||
return {"ok": False, "error": error_msg}
|
||
|
||
|
||
# ========================================
|
||
# Endpoints
|
||
# ========================================
|
||
|
||
# DAARWIZZ webhook endpoints (both paths for compatibility)
|
||
|
||
@router.get("/healthz")
|
||
async def healthz():
|
||
try:
|
||
from crews.agromatrix_crew.run import handle_message # noqa: F401
|
||
return {"ok": True, "status": "healthy"}
|
||
except Exception as e:
|
||
return {"ok": False, "status": "error", "error": str(e)}
|
||
|
||
@router.post("/telegram/webhook")
|
||
async def telegram_webhook(update: TelegramUpdate):
|
||
"""Handle Telegram webhook for DAARWIZZ agent (default path)."""
|
||
try:
|
||
return await handle_telegram_webhook(DAARWIZZ_CONFIG, update)
|
||
except Exception as e:
|
||
logger.error(f"Error handling DAARWIZZ Telegram webhook: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/daarwizz/telegram/webhook")
|
||
async def daarwizz_telegram_webhook(update: TelegramUpdate):
|
||
"""Handle Telegram webhook for DAARWIZZ agent (agent-specific path)."""
|
||
try:
|
||
return await handle_telegram_webhook(DAARWIZZ_CONFIG, update)
|
||
except Exception as e:
|
||
logger.error(f"Error handling DAARWIZZ Telegram webhook: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# Legacy code - will be removed after testing
|
||
async def _old_telegram_webhook(update: TelegramUpdate):
|
||
"""Стара версія - використовується для тестування"""
|
||
try:
|
||
if not update.message:
|
||
raise HTTPException(status_code=400, detail="No message in update")
|
||
|
||
# Extract message details
|
||
from_user = update.message.get("from", {})
|
||
chat = update.message.get("chat", {})
|
||
|
||
user_id = str(from_user.get("id", "unknown"))
|
||
chat_id = str(chat.get("id", "unknown"))
|
||
username = from_user.get("username", "")
|
||
|
||
# Get DAO ID for this chat
|
||
dao_id = get_dao_id(chat_id, "telegram", agent_id=agent_config.agent_id)
|
||
|
||
# Check for /ingest command
|
||
text = update.message.get("text", "")
|
||
if text and text.strip().startswith("/ingest"):
|
||
session_id = f"telegram:{chat_id}"
|
||
|
||
# Check if there's a document in the message
|
||
document = update.message.get("document")
|
||
if document:
|
||
mime_type = document.get("mime_type", "")
|
||
file_name = document.get("file_name", "")
|
||
file_id = document.get("file_id")
|
||
|
||
is_pdf = (
|
||
mime_type == "application/pdf" or
|
||
(mime_type.startswith("application/") and file_name.lower().endswith(".pdf"))
|
||
)
|
||
|
||
if is_pdf and file_id:
|
||
try:
|
||
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||
file_path = await get_telegram_file_path(file_id)
|
||
if file_path:
|
||
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
|
||
result = await ingest_document(
|
||
session_id=session_id,
|
||
doc_url=file_url,
|
||
file_name=file_name,
|
||
dao_id=dao_id,
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
)
|
||
|
||
if result.success:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
f"✅ **Документ імпортовано у RAG**\n\n"
|
||
f"📊 Фрагментів: {result.ingested_chunks}\n"
|
||
f"📁 DAO: {dao_id}\n\n"
|
||
f"Тепер ти можеш задавати питання по цьому документу!"
|
||
)
|
||
return {"ok": True, "chunks_count": result.ingested_chunks}
|
||
else:
|
||
await send_telegram_message(chat_id, f"Вибач, не вдалося імпортувати: {result.error}")
|
||
return {"ok": False, "error": result.error}
|
||
except Exception as e:
|
||
logger.error(f"Ingest failed: {e}", exc_info=True)
|
||
await send_telegram_message(chat_id, "Вибач, не вдалося імпортувати документ.")
|
||
return {"ok": False, "error": "Ingest failed"}
|
||
|
||
# Try to get last parsed doc_id from session context
|
||
result = await ingest_document(
|
||
session_id=session_id,
|
||
dao_id=dao_id,
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
)
|
||
|
||
if result.success:
|
||
await send_telegram_message(
|
||
chat_id,
|
||
f"✅ **Документ імпортовано у RAG**\n\n"
|
||
f"📊 Фрагментів: {result.ingested_chunks}\n"
|
||
f"📁 DAO: {dao_id}\n\n"
|
||
f"Тепер ти можеш задавати питання по цьому документу!"
|
||
)
|
||
return {"ok": True, "chunks_count": result.ingested_chunks}
|
||
else:
|
||
await send_telegram_message(chat_id, "Спочатку надішли PDF-документ, а потім використай /ingest")
|
||
return {"ok": False, "error": result.error}
|
||
|
||
# Check if it's a document (PDF)
|
||
document = update.message.get("document")
|
||
if document:
|
||
mime_type = document.get("mime_type", "")
|
||
file_name = document.get("file_name", "")
|
||
file_id = document.get("file_id")
|
||
|
||
# Check if it's a PDF
|
||
is_pdf = (
|
||
mime_type == "application/pdf" or
|
||
(mime_type.startswith("application/") and file_name.lower().endswith(".pdf"))
|
||
)
|
||
|
||
if is_pdf and file_id:
|
||
logger.info(f"PDF document from {username} (tg:{user_id}), file_id: {file_id}, file_name: {file_name}")
|
||
|
||
try:
|
||
# Get file path from Telegram
|
||
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||
file_path = await get_telegram_file_path(file_id)
|
||
if not file_path:
|
||
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
|
||
|
||
# Build file URL
|
||
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
|
||
|
||
# Use doc_service for parsing
|
||
session_id = f"telegram:{chat_id}"
|
||
result = await parse_document(
|
||
session_id=session_id,
|
||
doc_url=file_url,
|
||
file_name=file_name,
|
||
dao_id=dao_id,
|
||
user_id=f"tg:{user_id}",
|
||
output_mode="qa_pairs",
|
||
metadata={"username": username, "chat_id": chat_id},
|
||
agent_id=agent_config.agent_id,
|
||
)
|
||
|
||
if not result.success:
|
||
await send_telegram_message(chat_id, f"Вибач, не вдалося обробити документ: {result.error}")
|
||
return {"ok": False, "error": result.error}
|
||
|
||
# Format response for Telegram
|
||
answer_text = ""
|
||
if result.qa_pairs:
|
||
# Convert QAItem to dict for formatting
|
||
qa_list = [{"question": qa.question, "answer": qa.answer} for qa in result.qa_pairs]
|
||
answer_text = format_qa_response(qa_list)
|
||
elif result.markdown:
|
||
answer_text = format_markdown_response(result.markdown)
|
||
elif result.chunks_meta and result.chunks_meta.get("chunks"):
|
||
chunks = result.chunks_meta.get("chunks", [])
|
||
answer_text = format_chunks_response(chunks)
|
||
else:
|
||
answer_text = "✅ Документ успішно оброблено, але формат відповіді не розпізнано."
|
||
|
||
# Add hint about /ingest command
|
||
if not answer_text.endswith("_"):
|
||
answer_text += "\n\n💡 _Використай /ingest для імпорту документа у RAG_"
|
||
|
||
logger.info(f"PDF parsing result: {len(answer_text)} chars, doc_id={result.doc_id}")
|
||
|
||
# Send response back to Telegram
|
||
await send_telegram_message(chat_id, answer_text)
|
||
|
||
return {"ok": True, "agent": "parser", "mode": "doc_parse", "doc_id": result.doc_id}
|
||
|
||
except Exception as e:
|
||
logger.error(f"PDF processing failed: {e}", exc_info=True)
|
||
await send_telegram_message(chat_id, "Вибач, не вдалося обробити PDF-документ. Переконайся, що файл не пошкоджений.")
|
||
return {"ok": False, "error": "PDF processing failed"}
|
||
elif document and not is_pdf:
|
||
# Non-PDF document
|
||
await send_telegram_message(chat_id, "Наразі підтримуються тільки PDF-документи. Інші формати (docx, zip, тощо) будуть додані пізніше.")
|
||
return {"ok": False, "error": "Unsupported document type"}
|
||
|
||
# Check if it's a photo
|
||
photo = update.message.get("photo")
|
||
if photo:
|
||
# Telegram sends multiple sizes, get the largest one (last in array)
|
||
photo_obj = photo[-1] if isinstance(photo, list) else photo
|
||
file_id = photo_obj.get("file_id") if isinstance(photo_obj, dict) else None
|
||
|
||
if file_id:
|
||
logger.info(f"Photo from {username} (tg:{user_id}), file_id: {file_id}")
|
||
|
||
try:
|
||
# Get file path from Telegram
|
||
telegram_token = os.getenv("TELEGRAM_BOT_TOKEN")
|
||
file_path = await get_telegram_file_path(file_id)
|
||
if not file_path:
|
||
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
|
||
|
||
# Build file URL
|
||
file_url = f"https://api.telegram.org/file/bot{telegram_token}/{file_path}"
|
||
|
||
# Send to Router with specialist_vision_8b model (Swapper)
|
||
router_request = {
|
||
"message": f"Коротко (2-3 речення) опиши значення цього зображення: {file_url}",
|
||
"mode": "chat",
|
||
"agent": "daarwizz",
|
||
"metadata": {
|
||
"source": "telegram",
|
||
"dao_id": dao_id,
|
||
"user_id": f"tg:{user_id}",
|
||
"session_id": f"tg:{chat_id}:{dao_id}",
|
||
"username": username,
|
||
"chat_id": chat_id,
|
||
"file_id": file_id,
|
||
"file_url": file_url,
|
||
"has_image": True,
|
||
},
|
||
"context": {
|
||
"agent_name": DAARWIZZ_NAME,
|
||
"system_prompt": DAARWIZZ_SYSTEM_PROMPT,
|
||
},
|
||
}
|
||
|
||
# Override LLM to use specialist_vision_8b for image understanding
|
||
router_request["metadata"]["use_llm"] = "specialist_vision_8b"
|
||
|
||
# Send to Router
|
||
logger.info(f"Sending photo to Router with vision-8b: file_url={file_url[:50]}...")
|
||
response = await send_to_router(router_request)
|
||
|
||
# Extract response
|
||
if isinstance(response, dict) and response.get("ok"):
|
||
answer_text = response.get("data", {}).get("text") or response.get("response", "")
|
||
|
||
if answer_text:
|
||
# Photo processed successfully
|
||
await send_telegram_message(
|
||
chat_id,
|
||
answer_text # No prefix, just the LLM response
|
||
)
|
||
|
||
# Save to memory for context
|
||
await memory_client.save_chat_turn(
|
||
agent_id="daarwizz",
|
||
team_id=dao_id,
|
||
user_id=f"tg:{user_id}",
|
||
message=f"[Photo: {file_id}]",
|
||
response=answer_text,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=not is_service_response(answer_text),
|
||
agent_metadata={"context": "photo"},
|
||
username=username,
|
||
)
|
||
|
||
return {"ok": True, "agent": "daarwizz", "model": "specialist_vision_8b"}
|
||
else:
|
||
await send_telegram_message(chat_id, "Не вдалося отримати опис зображення.")
|
||
return {"ok": False, "error": "No description in response"}
|
||
else:
|
||
error_msg = response.get("error", "Unknown error") if isinstance(response, dict) else "Router error"
|
||
logger.error(f"Vision-8b error: {error_msg}")
|
||
await send_telegram_message(chat_id, f"Вибач, не вдалося обробити фото: {error_msg}")
|
||
return {"ok": False, "error": error_msg}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Photo processing failed: {e}", exc_info=True)
|
||
await send_telegram_message(chat_id, "Вибач, сталася помилка при обробці фото.")
|
||
return {"ok": False, "error": "Photo processing failed"}
|
||
|
||
# Check if it's a voice message
|
||
voice = update.message.get("voice")
|
||
audio = update.message.get("audio")
|
||
video_note = update.message.get("video_note")
|
||
|
||
text = ""
|
||
|
||
if voice or audio or video_note:
|
||
# Голосове повідомлення - розпізнаємо через STT
|
||
media_obj = voice or audio or video_note
|
||
file_id = media_obj.get("file_id") if media_obj else None
|
||
|
||
if not file_id:
|
||
raise HTTPException(status_code=400, detail="No file_id in voice/audio/video_note")
|
||
|
||
logger.info(f"Voice message from {username} (tg:{user_id}), file_id: {file_id}")
|
||
|
||
try:
|
||
# Отримуємо файл з Telegram
|
||
file_path = await get_telegram_file_path(file_id)
|
||
if not file_path:
|
||
raise HTTPException(status_code=400, detail="Failed to get file from Telegram")
|
||
|
||
# Завантажуємо файл
|
||
file_url = f"https://api.telegram.org/file/bot{os.getenv('TELEGRAM_BOT_TOKEN')}/{file_path}"
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
file_resp = await client.get(file_url)
|
||
file_resp.raise_for_status()
|
||
audio_bytes = file_resp.content
|
||
|
||
# Відправляємо в STT-сервіс
|
||
stt_upload_url = _resolve_stt_upload_url()
|
||
files = {"file": ("voice.ogg", audio_bytes, "audio/ogg")}
|
||
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
stt_resp = await client.post(stt_upload_url, files=files)
|
||
stt_resp.raise_for_status()
|
||
stt_data = stt_resp.json()
|
||
text = stt_data.get("text", "")
|
||
|
||
logger.info(f"STT result: {text[:100]}...")
|
||
|
||
except Exception as e:
|
||
logger.error(f"STT processing failed: {e}", exc_info=True)
|
||
await send_telegram_message(chat_id, "Вибач, не вдалося розпізнати голосове повідомлення. Спробуй надіслати текстом.", os.getenv("DAARWIZZ_TELEGRAM_BOT_TOKEN"))
|
||
return {"ok": False, "error": "STT failed"}
|
||
else:
|
||
# Текстове повідомлення
|
||
text = update.message.get("text", "")
|
||
caption = update.message.get("caption", "")
|
||
|
||
if not text and not caption:
|
||
# Check for unsupported message types and silently ignore
|
||
unsupported_types = ["sticker", "animation", "video_note", "contact", "location",
|
||
"venue", "poll", "dice", "game", "new_chat_members",
|
||
"left_chat_member", "new_chat_title", "new_chat_photo",
|
||
"delete_chat_photo", "pinned_message"]
|
||
for msg_type in unsupported_types:
|
||
if update.message.get(msg_type):
|
||
logger.debug(f"DAARWIZZ: Ignoring unsupported message type: {msg_type}")
|
||
return {"ok": True, "ignored": True, "reason": f"Unsupported message type: {msg_type}"}
|
||
|
||
# If no supported content found, return silently
|
||
return {"ok": True, "ignored": True, "reason": "No processable content"}
|
||
|
||
# Use caption if text is empty
|
||
if not text and caption:
|
||
text = caption
|
||
|
||
logger.info(f"Telegram message from {username} (tg:{user_id}) in chat {chat_id}: {text[:50]}")
|
||
|
||
# Check if there's a document context for follow-up questions
|
||
session_id = f"telegram:{chat_id}"
|
||
doc_context = await get_doc_context(session_id, agent_id=agent_config.agent_id)
|
||
|
||
# If there's a doc_id and the message looks like a question about the document
|
||
if doc_context and doc_context.doc_id:
|
||
is_question = _is_question_like(text)
|
||
|
||
if is_question:
|
||
logger.info(f"Follow-up question detected for doc_id={doc_context.doc_id}")
|
||
# Try RAG query first
|
||
try:
|
||
rag_result = await asyncio.wait_for(
|
||
ask_about_document(
|
||
session_id=session_id,
|
||
question=text,
|
||
doc_id=doc_context.doc_id,
|
||
dao_id=dao_id or doc_context.dao_id,
|
||
user_id=f"tg:{user_id}",
|
||
agent_id=agent_config.agent_id,
|
||
),
|
||
timeout=25.0,
|
||
)
|
||
except asyncio.TimeoutError:
|
||
logger.warning(
|
||
f"Doc follow-up timeout for agent={agent_config.agent_id}, "
|
||
f"doc_id={doc_context.doc_id}; fallback to regular chat path"
|
||
)
|
||
rag_result = None
|
||
|
||
if rag_result and rag_result.success and rag_result.answer:
|
||
# Truncate if too long for Telegram
|
||
answer = postprocess_agent_answer(
|
||
agent_id=agent_config.agent_id,
|
||
user_text=text or "",
|
||
answer_text=rag_result.answer,
|
||
force_detailed=should_force_detailed_reply(text),
|
||
needs_complex_reasoning=requires_complex_reasoning(text),
|
||
)
|
||
if len(answer) > TELEGRAM_SAFE_LENGTH:
|
||
answer = answer[:TELEGRAM_SAFE_LENGTH] + "\n\n_... (відповідь обрізано)_"
|
||
|
||
await send_telegram_message(chat_id, answer)
|
||
return {"ok": True, "agent": "parser", "mode": "rag_query"}
|
||
# Fall through to regular chat if RAG query fails
|
||
|
||
# Regular chat mode
|
||
# Fetch memory context
|
||
memory_context = await memory_client.get_context(
|
||
user_id=f"tg:{user_id}",
|
||
agent_id="daarwizz",
|
||
team_id=dao_id,
|
||
channel_id=chat_id,
|
||
limit=80
|
||
)
|
||
|
||
# Build request to Router with DAARWIZZ context
|
||
router_request = {
|
||
"message": text,
|
||
"mode": "chat",
|
||
"agent": "daarwizz", # DAARWIZZ agent identifier
|
||
"metadata": {
|
||
"source": "telegram",
|
||
"dao_id": dao_id,
|
||
"user_id": f"tg:{user_id}",
|
||
"session_id": f"tg:{chat_id}:{dao_id}",
|
||
"username": username,
|
||
"chat_id": chat_id,
|
||
},
|
||
"context": {
|
||
"agent_name": DAARWIZZ_NAME,
|
||
"system_prompt": DAARWIZZ_SYSTEM_PROMPT,
|
||
"memory": memory_context, # Додаємо пам'ять
|
||
# RBAC context will be injected by Router
|
||
},
|
||
}
|
||
|
||
# Send to Router
|
||
logger.info(f"Sending to Router: agent=daarwizz, dao={dao_id}, user=tg:{user_id}")
|
||
response = await send_to_router(router_request)
|
||
|
||
# Extract response text
|
||
if isinstance(response, dict):
|
||
answer_text = response.get("data", {}).get("text") or response.get("response", "Вибач, я зараз не можу відповісти.")
|
||
else:
|
||
answer_text = "Вибач, сталася помилка."
|
||
|
||
logger.info(f"Router response: {answer_text[:100]}")
|
||
|
||
# Save chat turn to memory
|
||
await memory_client.save_chat_turn(
|
||
agent_id="daarwizz",
|
||
team_id=dao_id,
|
||
user_id=f"tg:{user_id}",
|
||
message=text,
|
||
response=answer_text,
|
||
channel_id=chat_id,
|
||
scope="short_term",
|
||
save_agent_response=not is_service_response(answer_text),
|
||
agent_metadata={"context": "legacy_daarwizz"},
|
||
username=username,
|
||
)
|
||
|
||
# Send response back to Telegram
|
||
await send_telegram_message(chat_id, answer_text, os.getenv("DAARWIZZ_TELEGRAM_BOT_TOKEN"))
|
||
|
||
return {"ok": True, "agent": "daarwizz"}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling Telegram webhook: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/discord/webhook")
|
||
async def discord_webhook(message: DiscordMessage):
|
||
"""
|
||
Handle Discord webhook.
|
||
|
||
Discord message format:
|
||
{
|
||
"content": "Hello!",
|
||
"author": {"id": "123", "username": "alice"},
|
||
"channel_id": "456",
|
||
"guild_id": "789"
|
||
}
|
||
"""
|
||
try:
|
||
if not message.content:
|
||
raise HTTPException(status_code=400, detail="No content in message")
|
||
|
||
# Extract message details
|
||
text = message.content
|
||
author = message.author or {}
|
||
channel_id = message.channel_id or "unknown"
|
||
guild_id = message.guild_id or "unknown"
|
||
|
||
user_id = author.get("id", "unknown")
|
||
username = author.get("username", "")
|
||
|
||
# Get DAO ID for this channel
|
||
dao_id = get_dao_id(channel_id, "discord")
|
||
|
||
logger.info(f"Discord message from {username} (discord:{user_id}): {text[:50]}")
|
||
|
||
# Fetch memory context
|
||
memory_context = await memory_client.get_context(
|
||
user_id=f"discord:{user_id}",
|
||
agent_id="daarwizz",
|
||
team_id=dao_id,
|
||
channel_id=channel_id,
|
||
limit=80
|
||
)
|
||
|
||
# Build request to Router with DAARWIZZ context
|
||
router_request = {
|
||
"message": text,
|
||
"mode": "chat",
|
||
"agent": "daarwizz",
|
||
"metadata": {
|
||
"source": "discord",
|
||
"dao_id": dao_id,
|
||
"user_id": f"discord:{user_id}",
|
||
"session_id": f"discord:{channel_id}:{dao_id}",
|
||
"username": username,
|
||
"channel_id": channel_id,
|
||
"guild_id": guild_id,
|
||
},
|
||
"context": {
|
||
"agent_name": DAARWIZZ_NAME,
|
||
"system_prompt": DAARWIZZ_SYSTEM_PROMPT,
|
||
"memory": memory_context, # Додаємо пам'ять
|
||
},
|
||
}
|
||
|
||
# Send to Router
|
||
response = await send_to_router(router_request)
|
||
|
||
# Extract response text
|
||
if isinstance(response, dict):
|
||
answer_text = response.get("data", {}).get("text") or response.get("response", "Sorry, I can't respond right now.")
|
||
else:
|
||
answer_text = "Sorry, an error occurred."
|
||
|
||
logger.info(f"Router response: {answer_text[:100]}")
|
||
|
||
# Save chat turn to memory
|
||
await memory_client.save_chat_turn(
|
||
agent_id="daarwizz",
|
||
team_id=dao_id,
|
||
user_id=f"discord:{user_id}",
|
||
message=text,
|
||
response=answer_text,
|
||
channel_id=channel_id,
|
||
scope="short_term",
|
||
save_agent_response=not is_service_response(answer_text),
|
||
agent_metadata={"source": "discord"},
|
||
username=username,
|
||
)
|
||
|
||
# TODO: Send response back to Discord
|
||
# await send_discord_message(channel_id, answer_text)
|
||
|
||
return {"ok": True, "agent": "daarwizz", "response": answer_text}
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error handling Discord webhook: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# ========================================
|
||
# Helper Functions
|
||
# ========================================
|
||
|
||
async def get_telegram_file_path(file_id: str, bot_token: str = None) -> Optional[str]:
|
||
"""Отримати шлях до файлу з Telegram API"""
|
||
telegram_token = bot_token or os.getenv("TELEGRAM_BOT_TOKEN")
|
||
if not telegram_token:
|
||
logger.error("TELEGRAM_BOT_TOKEN not set")
|
||
return None
|
||
|
||
url = f"https://api.telegram.org/bot{telegram_token}/getFile"
|
||
params = {"file_id": file_id}
|
||
|
||
try:
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
response = await client.get(url, params=params)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
if data.get("ok"):
|
||
return data.get("result", {}).get("file_path")
|
||
except Exception as e:
|
||
logger.error(f"Error getting Telegram file: {e}")
|
||
return None
|
||
|
||
|
||
def format_qa_response(qa_pairs: list, max_pairs: int = 5) -> str:
|
||
"""Format Q&A pairs for Telegram with length limits"""
|
||
if not qa_pairs:
|
||
return "📋 Документ оброблено, але Q&A пари не знайдено."
|
||
|
||
qa_text = "📋 **Зміст документа:**\n\n"
|
||
displayed = 0
|
||
|
||
for i, qa in enumerate(qa_pairs[:max_pairs], 1):
|
||
question = qa.get('question', 'Питання')
|
||
answer = qa.get('answer', 'Відповідь')
|
||
|
||
# Truncate answer if too long
|
||
if len(answer) > 500:
|
||
answer = answer[:500] + "..."
|
||
|
||
pair_text = f"**{i}. {question}**\n{answer}\n\n"
|
||
|
||
# Check if adding this pair would exceed limit
|
||
if len(qa_text) + len(pair_text) > TELEGRAM_SAFE_LENGTH:
|
||
break
|
||
|
||
qa_text += pair_text
|
||
displayed += 1
|
||
|
||
if len(qa_pairs) > displayed:
|
||
remaining = len(qa_pairs) - displayed
|
||
qa_text += f"_... та ще {remaining} {'питань' if remaining > 1 else 'питання'}_"
|
||
|
||
return qa_text
|
||
|
||
|
||
def format_markdown_response(markdown: str) -> str:
|
||
"""Format markdown response - returns raw text for LLM processing"""
|
||
# Just return the text - LLM will summarize it
|
||
return markdown
|
||
|
||
|
||
def format_chunks_response(chunks: list) -> str:
|
||
"""Format chunks summary for Telegram"""
|
||
if not chunks:
|
||
return "📄 Документ розпарсено, але фрагменти не знайдено."
|
||
|
||
answer_text = f"📄 **Документ розпарсено** ({len(chunks)} фрагментів)\n\n"
|
||
answer_text += "**Перші фрагменти:**\n\n"
|
||
|
||
for i, chunk in enumerate(chunks[:3], 1):
|
||
text = chunk.get('text', '')[:200]
|
||
answer_text += f"{i}. {text}...\n\n"
|
||
|
||
if len(chunks) > 3:
|
||
answer_text += f"_... та ще {len(chunks) - 3} фрагментів_"
|
||
|
||
return answer_text
|
||
|
||
|
||
def _zip_read_summary(markdown_text: str) -> Optional[str]:
|
||
"""Extract a short summary of processed/skipped files from ZIP markdown."""
|
||
if not markdown_text:
|
||
return None
|
||
lines = [line.strip() for line in markdown_text.splitlines()]
|
||
try:
|
||
processed = []
|
||
skipped = []
|
||
idx = 0
|
||
while idx < len(lines):
|
||
line = lines[idx]
|
||
if line.lower() == "processed files:":
|
||
idx += 1
|
||
while idx < len(lines) and lines[idx].startswith("- "):
|
||
processed.append(lines[idx][2:].strip())
|
||
idx += 1
|
||
continue
|
||
if line.lower() == "skipped files:":
|
||
idx += 1
|
||
while idx < len(lines) and lines[idx].startswith("- "):
|
||
skipped.append(lines[idx][2:].strip())
|
||
idx += 1
|
||
continue
|
||
idx += 1
|
||
if not processed and not skipped:
|
||
return None
|
||
processed_text = ", ".join(processed) if processed else "нічого"
|
||
skipped_text = ", ".join(skipped) if skipped else "нічого"
|
||
return f"Прочитала з ZIP: {processed_text}; пропустила: {skipped_text}."
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _default_theme_payload(brand_id: str) -> Dict[str, Any]:
|
||
"""Return a minimal theme.json payload for quick publish."""
|
||
return {
|
||
"theme_version": "v1.0.0",
|
||
"brand_id": brand_id,
|
||
"layout": {
|
||
"page": "LAYOUT_WIDE",
|
||
"safe_area": {"x": 0.6, "y": 0.45, "w": 12.1, "h": 6.2},
|
||
},
|
||
"palette": {
|
||
"primary": "#0B1220",
|
||
"secondary": "#1E293B",
|
||
"accent": "#22C55E",
|
||
"bg": "#FFFFFF",
|
||
"text": "#0F172A",
|
||
},
|
||
"typography": {
|
||
"font_primary": "Inter",
|
||
"font_secondary": "Inter",
|
||
"sizes": {"h1": 38, "h2": 28, "h3": 22, "body": 16, "small": 12},
|
||
"weights": {"regular": 400, "medium": 500, "bold": 700},
|
||
"line_height": {"tight": 1.05, "normal": 1.15, "relaxed": 1.25},
|
||
},
|
||
"components": {
|
||
"header": {"enabled": True, "logo_variant": "light", "show_title": False},
|
||
"footer": {"enabled": True, "show_page_number": True, "left_text": brand_id},
|
||
},
|
||
"rules": {
|
||
"max_bullets": 6,
|
||
"max_bullet_len": 110,
|
||
"min_font_body": 12,
|
||
"overflow_strategy": "appendix",
|
||
},
|
||
}
|
||
|
||
|
||
async def _brand_intake_request(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.post(f"{BRAND_INTAKE_URL}/brand/intake", json=payload)
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Brand intake error: {resp.text[:200]}")
|
||
return resp.json()
|
||
|
||
|
||
async def _brand_publish_theme(brand_id: str, theme: Dict[str, Any], theme_version: Optional[str]) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.post(
|
||
f"{BRAND_REGISTRY_URL}/brands/{brand_id}/themes",
|
||
json={"theme": theme, "theme_version": theme_version},
|
||
)
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Brand publish error: {resp.text[:200]}")
|
||
return resp.json()
|
||
|
||
|
||
async def _brand_get_latest(brand_id: str) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.get(f"{BRAND_REGISTRY_URL}/brands/{brand_id}/latest")
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Brand latest error: {resp.text[:200]}")
|
||
return resp.json()
|
||
|
||
|
||
async def _brand_get_theme(brand_id: str, theme_version: str) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.get(f"{BRAND_REGISTRY_URL}/brands/{brand_id}/themes/{theme_version}")
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Brand get error: {resp.text[:200]}")
|
||
return resp.json()
|
||
|
||
|
||
async def _presentation_render(slidespec: Dict[str, Any], brand_id: str, theme_version: str) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||
# Call presentation-renderer directly
|
||
resp = await client.post(
|
||
f"{PRESENTATION_RENDERER_URL}/present/render",
|
||
json={
|
||
"brand_id": brand_id,
|
||
"theme_version": theme_version or "v1.0.0",
|
||
"slidespec": slidespec,
|
||
"output": "pptx"
|
||
},
|
||
)
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Presentation render error: {resp.text[:200]}")
|
||
result = resp.json()
|
||
# Return consistent response format
|
||
return {
|
||
"render_id": result.get("render_id"),
|
||
"artifact_id": result.get("render_id"), # Use render_id as artifact_id for now
|
||
"job_id": result.get("render_id"),
|
||
"status": result.get("status"),
|
||
"brand_id": brand_id,
|
||
"theme_version": theme_version
|
||
}
|
||
|
||
|
||
def _sanitize_error_text(text: str) -> str:
|
||
if not text:
|
||
return ""
|
||
sanitized = text
|
||
sanitized = re.sub(r"https?://\\S+", "[url]", sanitized)
|
||
sanitized = re.sub(r"[A-Za-z0-9_-]{20,}", "[token]", sanitized)
|
||
return sanitized[:400]
|
||
|
||
|
||
def _can_access_artifact(acl_ref: Optional[str], dao_id: Optional[str], user_id: str) -> bool:
|
||
if not acl_ref:
|
||
return True
|
||
acl = acl_ref.lower()
|
||
if "public" in acl:
|
||
return True
|
||
if dao_id and str(dao_id).lower() in acl:
|
||
return True
|
||
if user_id and user_id.lower() in acl:
|
||
return True
|
||
return False
|
||
|
||
|
||
async def _artifact_get(artifact_id: str) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.get(f"{ARTIFACT_REGISTRY_URL}/artifacts/{artifact_id}")
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Artifact get error: {resp.text[:200]}")
|
||
return resp.json()
|
||
|
||
|
||
async def _artifact_versions(artifact_id: str) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.get(f"{ARTIFACT_REGISTRY_URL}/artifacts/{artifact_id}/versions")
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Artifact versions error: {resp.text[:200]}")
|
||
return resp.json()
|
||
|
||
|
||
async def _artifact_job_status(job_id: str) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.get(f"{ARTIFACT_REGISTRY_URL}/jobs/{job_id}")
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Job status error: {resp.text[:200]}")
|
||
return resp.json()
|
||
|
||
|
||
def _format_job_status_message(job: Dict[str, Any], job_id: str) -> str:
|
||
status = job.get("status")
|
||
job_type = job.get("job_type")
|
||
artifact_id = job.get("artifact_id")
|
||
input_version_id = job.get("input_version_id")
|
||
output_version_id = job.get("output_version_id")
|
||
error_text = _sanitize_error_text(job.get("error_text", ""))
|
||
meta = job.get("meta_json") or {}
|
||
if isinstance(meta, str):
|
||
try:
|
||
meta = json.loads(meta)
|
||
except Exception:
|
||
meta = {}
|
||
|
||
meta_bits = []
|
||
for key in ["chunks_count", "parser_version", "chunker_version", "index_fingerprint", "fingerprint", "parsed_version_id", "chunks_version_id"]:
|
||
if meta.get(key):
|
||
value = meta.get(key)
|
||
if key in {"fingerprint", "index_fingerprint"}:
|
||
value = str(value)[:16] + "…"
|
||
meta_bits.append(f"{key}: {value}")
|
||
|
||
message = (
|
||
"📌 **Статус job**\n\n"
|
||
f"Status: `{status}`\n"
|
||
f"Type: `{job_type}`\n"
|
||
f"Job ID: `{job_id}`\n"
|
||
f"Artifact ID: `{artifact_id}`\n"
|
||
f"Input version: `{input_version_id}`"
|
||
)
|
||
if output_version_id:
|
||
message += f"\nOutput version: `{output_version_id}`"
|
||
if meta_bits:
|
||
message += "\nMeta: " + "; ".join(meta_bits)
|
||
if status == "failed" and error_text:
|
||
message += f"\nПомилка: `{error_text}`"
|
||
return message
|
||
|
||
|
||
async def _artifact_download(artifact_id: str, fmt: str) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.get(f"{ARTIFACT_REGISTRY_URL}/artifacts/{artifact_id}/download", params={"format": fmt})
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=resp.status_code, detail=resp.text[:200])
|
||
return resp.json()
|
||
|
||
|
||
async def _artifact_create_job(artifact_id: str, job_type: str, input_version_id: str) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.post(
|
||
f"{ARTIFACT_REGISTRY_URL}/artifacts/{artifact_id}/jobs",
|
||
json={"job_type": job_type, "input_version_id": input_version_id},
|
||
)
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Create job error: {resp.text[:200]}")
|
||
return resp.json()
|
||
|
||
|
||
async def _artifact_create(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.post(f"{ARTIFACT_REGISTRY_URL}/artifacts", json=payload)
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Artifact create error: {resp.text[:200]}")
|
||
return resp.json()
|
||
|
||
|
||
async def _artifact_add_version_from_url(artifact_id: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||
resp = await client.post(f"{ARTIFACT_REGISTRY_URL}/artifacts/{artifact_id}/versions/from_url", json=payload)
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Artifact version error: {resp.text[:200]}")
|
||
return resp.json()
|
||
|
||
|
||
async def _artifact_job_done(job_id: str, note: str) -> None:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.post(f"{ARTIFACT_REGISTRY_URL}/jobs/{job_id}/done", json={"note": note})
|
||
if resp.status_code >= 400:
|
||
raise HTTPException(status_code=502, detail=f"Job done error: {resp.text[:200]}")
|
||
|
||
|
||
async def send_telegram_message(chat_id: str, text: str, bot_token: Optional[str] = None) -> bool:
|
||
"""Send message to Telegram chat with explicit error diagnostics."""
|
||
telegram_token = bot_token or os.getenv("TELEGRAM_BOT_TOKEN")
|
||
if not telegram_token:
|
||
logger.error("TELEGRAM_BOT_TOKEN not set")
|
||
return False
|
||
|
||
# Defensive cleanup for occasional reasoning/markup leaks.
|
||
safe_text = re.sub(r'<think>.*?</think>', '', text or "", flags=re.DOTALL)
|
||
safe_text = re.sub(r'<think>.*$', '', safe_text, flags=re.DOTALL)
|
||
safe_text = safe_text.strip() or "..."
|
||
|
||
token_id = telegram_token.split(":", 1)[0] if ":" in telegram_token else "unknown"
|
||
url = f"https://api.telegram.org/bot{telegram_token}/sendMessage"
|
||
|
||
async def _send_chunk(chunk: str) -> bool:
|
||
payload = {
|
||
"chat_id": str(chat_id),
|
||
"text": chunk,
|
||
"disable_web_page_preview": True,
|
||
}
|
||
try:
|
||
async with httpx.AsyncClient() as client:
|
||
response = await client.post(url, json=payload, timeout=15.0)
|
||
|
||
if response.status_code >= 400:
|
||
err_desc = response.text[:300]
|
||
try:
|
||
body = response.json()
|
||
err_desc = body.get("description") or err_desc
|
||
except Exception:
|
||
pass
|
||
logger.error(
|
||
"Telegram sendMessage failed: bot_id=%s chat_id=%s status=%s desc=%s",
|
||
token_id,
|
||
chat_id,
|
||
response.status_code,
|
||
err_desc,
|
||
)
|
||
return False
|
||
return True
|
||
except Exception as e:
|
||
logger.error("Telegram sendMessage exception: bot_id=%s chat_id=%s error=%s", token_id, chat_id, e)
|
||
return False
|
||
|
||
all_ok = True
|
||
chunks = _chunk_text(safe_text, max_len=TELEGRAM_MAX_MESSAGE_LENGTH)
|
||
for chunk in chunks:
|
||
sent = await _send_chunk(chunk)
|
||
all_ok = all_ok and sent
|
||
if all_ok:
|
||
logger.info("Telegram message sent: bot_id=%s chat_id=%s chunks=%s", token_id, chat_id, len(chunks))
|
||
return all_ok
|
||
|
||
|
||
# ========================================
|
||
# Helion Telegram Webhook
|
||
# ========================================
|
||
|
||
@router.post("/helion/telegram/webhook")
|
||
async def helion_telegram_webhook(update: TelegramUpdate):
|
||
"""
|
||
Handle Telegram webhook for Helion agent.
|
||
"""
|
||
try:
|
||
return await handle_telegram_webhook(HELION_CONFIG, update)
|
||
except Exception as e:
|
||
logger.error(f"Error handling Helion Telegram webhook: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# ========================================
|
||
# GREENFOOD Telegram Webhook
|
||
# ========================================
|
||
|
||
@router.post("/greenfood/telegram/webhook")
|
||
async def greenfood_telegram_webhook(update: TelegramUpdate):
|
||
"""
|
||
Handle Telegram webhook for GREENFOOD agent.
|
||
"""
|
||
try:
|
||
return await handle_telegram_webhook(GREENFOOD_CONFIG, update)
|
||
except Exception as e:
|
||
logger.error(f"Error handling GREENFOOD Telegram webhook: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# ========================================
|
||
# NUTRA Telegram Webhook
|
||
# ========================================
|
||
|
||
@router.post("/nutra/telegram/webhook")
|
||
async def nutra_telegram_webhook(update: TelegramUpdate):
|
||
"""
|
||
Handle Telegram webhook for NUTRA agent.
|
||
"""
|
||
try:
|
||
return await handle_telegram_webhook(NUTRA_CONFIG, update)
|
||
except Exception as e:
|
||
logger.error(f"Error handling NUTRA Telegram webhook: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# Legacy handler was removed.
|
||
# Keep a tiny sentinel for imports/tests that may still reference it.
|
||
async def _old_helion_telegram_webhook(update: TelegramUpdate):
|
||
logger.warning("Deprecated handler _old_helion_telegram_webhook invoked; redirecting to unified handler")
|
||
return await handle_telegram_webhook(HELION_CONFIG, update)
|
||
|
||
|
||
@router.get("/health")
|
||
async def health():
|
||
"""Health check endpoint"""
|
||
# Static metadata for agents that don't have Telegram — used by Sofiia console UI badges
|
||
_AGENT_META: Dict[str, Dict] = {
|
||
"monitor": {"badges": ["per-node", "ops"], "visibility": "internal", "telegram_mode": "off"},
|
||
"aistalk": {"badges": ["cyber", "private"], "visibility": "private", "lifecycle_status": "planned"},
|
||
"sofiia": {"badges": ["supervisor", "architect"]},
|
||
"helion": {"badges": ["cto", "dao"]},
|
||
}
|
||
|
||
agents_info = {}
|
||
for agent_id, config in AGENT_REGISTRY.items():
|
||
meta = _AGENT_META.get(agent_id, {})
|
||
agents_info[agent_id] = {
|
||
"name": config.name,
|
||
"prompt_loaded": len(config.system_prompt) > 0,
|
||
"telegram_token_configured": config.get_telegram_token() is not None,
|
||
"badges": meta.get("badges", []),
|
||
"visibility": meta.get("visibility", "public"),
|
||
"telegram_mode": meta.get("telegram_mode", "on"),
|
||
"lifecycle_status": meta.get("lifecycle_status", "active"),
|
||
}
|
||
|
||
# Required per-node agents check
|
||
required_agents = ["monitor"]
|
||
required_missing = [aid for aid in required_agents if aid not in agents_info]
|
||
|
||
return {
|
||
"status": "healthy",
|
||
"agents": agents_info,
|
||
"agents_count": len(AGENT_REGISTRY),
|
||
"required_missing": required_missing,
|
||
"timestamp": datetime.utcnow().isoformat(),
|
||
"build_sha": _GATEWAY_BUILD_SHA,
|
||
"build_time": _GATEWAY_BUILD_TIME,
|
||
"node_id": _GATEWAY_NODE_ID,
|
||
}
|
||
|
||
|
||
@router.post("/debug/agent_ping")
|
||
async def debug_agent_ping(request: dict = None):
|
||
"""
|
||
E2E probe endpoint - tests full agent pipeline.
|
||
Used by agent-e2e-prober for monitoring.
|
||
Returns success only if router responds.
|
||
"""
|
||
import time
|
||
start = time.time()
|
||
|
||
try:
|
||
# Test 1: Check router connectivity
|
||
router_url = os.getenv("ROUTER_URL", "http://router:8000")
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
router_resp = await client.get(f"{router_url}/health")
|
||
router_ok = router_resp.status_code == 200
|
||
|
||
# Test 2: Check memory service connectivity
|
||
memory_url = os.getenv("MEMORY_SERVICE_URL", "http://memory-service:8000")
|
||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||
memory_resp = await client.get(f"{memory_url}/health")
|
||
memory_ok = memory_resp.status_code == 200
|
||
|
||
latency = time.time() - start
|
||
|
||
return {
|
||
"success": router_ok and memory_ok,
|
||
"latency_seconds": round(latency, 3),
|
||
"checks": {
|
||
"router": router_ok,
|
||
"memory_service": memory_ok,
|
||
},
|
||
"timestamp": datetime.utcnow().isoformat(),
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
"success": False,
|
||
"error": str(e)[:100],
|
||
"latency_seconds": round(time.time() - start, 3),
|
||
"timestamp": datetime.utcnow().isoformat(),
|
||
}
|