Phase6/7 runtime + Gitea smoke gate setup #1

Merged
daarion-admin merged 214 commits from codex/sync-node1-runtime into main 2026-03-05 10:38:18 -08:00
4 changed files with 194 additions and 524 deletions
Showing only changes of commit a91309de11 - Show all commits

View File

@@ -5,6 +5,7 @@ Handles incoming webhooks from Telegram, Discord, etc.
import asyncio
import base64
import copy
import hashlib
import json
import re
import logging
@@ -15,10 +16,9 @@ import uuid
import httpx
from pathlib import Path
from typing import Dict, Any, Optional, List, Tuple
from datetime import datetime, timedelta, timezone
from datetime import datetime
from dataclasses import dataclass
from io import BytesIO
from zoneinfo import ZoneInfo
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
@@ -48,11 +48,6 @@ from behavior_policy import (
BehaviorDecision,
AGENT_NAME_VARIANTS,
)
from daarion_facade.reminders import (
create_reminder,
list_reminders,
cancel_reminder,
)
logger = logging.getLogger(__name__)
@@ -75,6 +70,7 @@ 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:
@@ -153,19 +149,123 @@ def _looks_like_photo_followup(text: str) -> bool:
return False
def _is_agromatrix_plant_intel_intent(agent_id: str, text: str) -> bool:
if (agent_id or "").lower() != "agromatrix":
return False
if not text:
return False
tl = text.strip().lower()
markers = [
"що за рослина", "що це за рослина", "яка це рослина", "яка культура",
"визнач рослину", "ідентифікуй рослину", "хвороба рослини", "плями на листі",
"what plant", "identify plant", "identify crop", "plant disease",
"что за растение", "определи растение", "болезнь растения",
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})",
r"це\s+([a-zаіїєґ0-9'\-\s]{2,60})",
]
return any(m in tl for m in markers)
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()) > 6:
continue
if label in {"не знаю", "помилка", "невірно", "не вірно"}:
continue
return label
return None
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 _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):
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
def _needs_photo_only_response(text: str) -> bool:
"""
@@ -203,6 +303,10 @@ def _extract_unanswered_user_messages(
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 []
@@ -234,7 +338,7 @@ def _extract_unanswered_user_messages(
if not q_tokens or not a_tokens:
return False
overlap = len(q_tokens.intersection(a_tokens))
# Require partial semantic overlap, otherwise do not auto-close.
# 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]] = []
@@ -256,17 +360,23 @@ def _extract_unanswered_user_messages(
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",
@@ -356,330 +466,6 @@ def preferred_language_label(lang: str) -> str:
}.get((lang or "").lower(), "Ukrainian")
def _csv_set(value: Optional[str]) -> set[str]:
if not value:
return set()
return {x.strip() for x in str(value).split(",") if x.strip()}
MENTOR_PRIVATE_HANDLES = _csv_set(os.getenv("MENTOR_PRIVATE_HANDLES", "ivantytar,archenvis,olegarch88"))
MENTOR_PRIVATE_NAMES = _csv_set(os.getenv("MENTOR_PRIVATE_NAMES", "Іван Титар,Александр Вертій,Олег Ковальчук"))
MENTOR_DISCLOSURE_ALLOWED_USER_IDS = _csv_set(os.getenv("MENTOR_DISCLOSURE_ALLOWED_USER_IDS", ""))
GLOBAL_RELAY_ALLOWED_USER_IDS = _csv_set(os.getenv("GLOBAL_RELAY_ALLOWED_USER_IDS", ""))
REMINDER_DEFAULT_TZ = os.getenv("DAARION_REMINDER_DEFAULT_TZ", "Europe/Kyiv")
def _agent_env_prefix(agent_config: "AgentConfig") -> str:
env = str(agent_config.telegram_token_env or "").strip()
if env.endswith("_TELEGRAM_BOT_TOKEN"):
return env[: -len("_TELEGRAM_BOT_TOKEN")]
return str(agent_config.agent_id or "").upper()
def _is_disclosure_allowed(user_id: str) -> bool:
return str(user_id) in MENTOR_DISCLOSURE_ALLOWED_USER_IDS
def _redact_private_mentions(answer_text: str, user_id: str) -> str:
if not answer_text or _is_disclosure_allowed(user_id):
return answer_text
out = answer_text
for handle in MENTOR_PRIVATE_HANDLES:
out = re.sub(rf"@{re.escape(handle)}\b", "@mentor", out, flags=re.IGNORECASE)
for name in MENTOR_PRIVATE_NAMES:
out = re.sub(re.escape(name), "ментор", out, flags=re.IGNORECASE)
return out
def _soften_unfulfillable_promises(answer_text: str) -> str:
if not answer_text:
return answer_text
lower = answer_text.lower()
has_dm_promise = any(
phrase in lower
for phrase in (
"особисті повідомлення",
"в dm",
"direct message",
"personal message",
"напишу менторам",
"надішлю менторам",
)
)
has_reminder_promise = any(
phrase in lower
for phrase in (
"нагадаю",
"напомню",
"i will remind",
)
)
hints: List[str] = []
if has_dm_promise:
hints.append("Для реальної передачі повідомлення менторам використай `/relay_mentors <текст>`.")
if has_reminder_promise:
hints.append("Для реального нагадування використай `/remind` або `/remind_in`.")
if not hints:
return answer_text
if "✅ Виконано дію:" in answer_text:
return answer_text
return f"{answer_text}\n\n {' '.join(hints)}"
def _sanitize_agent_answer(answer_text: str, user_id: str) -> str:
text = _redact_private_mentions(answer_text, user_id)
text = _soften_unfulfillable_promises(text)
return text
def _parse_duration_to_seconds(raw: str) -> Optional[int]:
m = re.match(r"^\s*(\d+)\s*([mhd])\s*$", raw.lower())
if not m:
return None
value = int(m.group(1))
unit = m.group(2)
if unit == "m":
return value * 60
if unit == "h":
return value * 3600
return value * 86400
def _parse_remind_datetime(raw: str) -> Optional[datetime]:
raw = raw.strip()
patterns = [
"%Y-%m-%d %H:%M",
"%Y-%m-%d %H:%M:%S",
]
parsed = None
for pattern in patterns:
try:
parsed = datetime.strptime(raw, pattern)
break
except ValueError:
continue
if parsed is None:
return None
try:
tz = ZoneInfo(REMINDER_DEFAULT_TZ)
except Exception:
tz = timezone.utc
return parsed.replace(tzinfo=tz).astimezone(timezone.utc)
def _relay_allowed_for_user(prefix: str, user_id: str) -> bool:
agent_allow = _csv_set(os.getenv(f"{prefix}_RELAY_ALLOWED_USER_IDS", ""))
allowed = GLOBAL_RELAY_ALLOWED_USER_IDS | agent_allow
return str(user_id) in allowed if allowed else False
def _mentor_chat_ids(prefix: str) -> List[str]:
return [x for x in os.getenv(f"{prefix}_MENTOR_CHAT_IDS", "").split(",") if x.strip()]
async def _handle_action_commands(
*,
agent_config: "AgentConfig",
text: str,
chat_id: str,
user_id: str,
username: str,
dao_id: str,
telegram_token: str,
) -> Optional[Dict[str, Any]]:
t = (text or "").strip()
if not t.startswith("/"):
return None
prefix = _agent_env_prefix(agent_config)
if t.startswith("/remind_in "):
parts = t.split(maxsplit=2)
if len(parts) < 3:
await send_telegram_message(chat_id, "Формат: `/remind_in 2h текст`", telegram_token)
return {"ok": True, "handled": "remind_in_help"}
seconds = _parse_duration_to_seconds(parts[1])
if not seconds or seconds <= 0:
await send_telegram_message(chat_id, "Некоректна тривалість. Приклад: `30m`, `2h`, `1d`.", telegram_token)
return {"ok": True, "handled": "remind_in_invalid"}
due_ts = int(time.time()) + seconds
item = await create_reminder(
agent_id=agent_config.agent_id,
chat_id=chat_id,
user_id=user_id,
text=parts[2],
due_ts=due_ts,
)
await send_telegram_message(
chat_id,
f"✅ Виконано дію: нагадування створено\nID: `{item['reminder_id']}`\nЧас: `{item['due_at']}`",
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=f"✅ reminder_created {item['reminder_id']}",
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"action": "reminder_create", "reminder_id": item["reminder_id"]},
username=username,
)
return {"ok": True, "handled": "remind_in", "reminder_id": item["reminder_id"]}
if t.startswith("/remind "):
payload = t[len("/remind ") :].strip()
m = re.match(r"^(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})(?:\s+)(.+)$", payload)
if not m:
await send_telegram_message(chat_id, "Формат: `/remind 2026-03-18 09:00 текст`", telegram_token)
return {"ok": True, "handled": "remind_help"}
due = _parse_remind_datetime(m.group(1))
if due is None:
await send_telegram_message(chat_id, "Не зміг розпізнати дату/час. Формат: `YYYY-MM-DD HH:MM`", telegram_token)
return {"ok": True, "handled": "remind_invalid_date"}
item = await create_reminder(
agent_id=agent_config.agent_id,
chat_id=chat_id,
user_id=user_id,
text=m.group(2),
due_ts=int(due.timestamp()),
)
await send_telegram_message(
chat_id,
f"✅ Виконано дію: нагадування створено\nID: `{item['reminder_id']}`\nЧас: `{item['due_at']}`",
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=f"✅ reminder_created {item['reminder_id']}",
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"action": "reminder_create", "reminder_id": item["reminder_id"]},
username=username,
)
return {"ok": True, "handled": "remind", "reminder_id": item["reminder_id"]}
if t == "/reminders":
items = await list_reminders(agent_id=agent_config.agent_id, chat_id=chat_id, user_id=user_id, limit=10)
if not items:
await send_telegram_message(chat_id, "Активних нагадувань не знайдено.", telegram_token)
return {"ok": True, "handled": "reminders_empty"}
rows = [f"- `{x['reminder_id']}` → `{x['due_at']}` :: {x['text'][:80]}" for x in items]
await send_telegram_message(chat_id, "🗓 Твої нагадування:\n" + "\n".join(rows), telegram_token)
return {"ok": True, "handled": "reminders_list", "count": len(items)}
if t.startswith("/cancel_reminder "):
reminder_id = t[len("/cancel_reminder ") :].strip()
ok = await cancel_reminder(reminder_id, agent_id=agent_config.agent_id, chat_id=chat_id, user_id=user_id)
await send_telegram_message(
chat_id,
f"{'' if ok else ''} {'Скасовано.' if ok else 'Не знайдено або немає доступу.'}",
telegram_token,
)
return {"ok": True, "handled": "cancel_reminder", "canceled": ok}
if t.startswith("/relay_mentors "):
if not _relay_allowed_for_user(prefix, user_id):
await send_telegram_message(chat_id, "⛔ Немає доступу до relay. Звернись до адміністратора.", telegram_token)
return {"ok": True, "handled": "relay_denied"}
recipients = _mentor_chat_ids(prefix)
if not recipients:
await send_telegram_message(chat_id, "⚠️ Не налаштовано mentor recipients.", telegram_token)
return {"ok": True, "handled": "relay_not_configured"}
payload = t[len("/relay_mentors ") :].strip()
if not payload:
await send_telegram_message(chat_id, "Формат: `/relay_mentors текст повідомлення`", telegram_token)
return {"ok": True, "handled": "relay_help"}
delivered = 0
body = (
f"📨 Relay from {agent_config.name}\n"
f"From: @{username or user_id} (tg:{user_id})\n"
f"Source chat: {chat_id}\n\n"
f"{payload}"
)
for rid in recipients:
try:
ok = await send_telegram_message(rid.strip(), body, telegram_token)
if ok:
delivered += 1
except Exception:
logger.exception("mentor_relay_send_failed recipient=%s", rid)
await send_telegram_message(chat_id, f"✅ Виконано дію: relay sent to {delivered}/{len(recipients)} mentor(s).", 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=f"✅ mentor_relay delivered={delivered}",
channel_id=chat_id,
scope="short_term",
save_agent_response=True,
agent_metadata={"action": "mentor_relay", "delivered": delivered, "requested": len(recipients)},
username=username,
)
return {"ok": True, "handled": "relay_mentors", "delivered": delivered, "requested": len(recipients)}
return None
def _extract_nl_action_command(text: str) -> Optional[str]:
t = (text or "").strip()
if not t or t.startswith("/"):
return None
low = t.lower()
# Reminder by relative duration (UA/RU/EN)
m = re.match(
r"^(?:нагадай(?:\s+мені)?|напомни(?:\s+мне)?|remind me)\s+(?:через|in)\s+(\d+)\s*(хв|мин|m|год|hour|h|дн|день|day|d)\s+(.+)$",
low,
)
if m:
value = m.group(1)
unit = m.group(2)
payload = t[m.end(2):].strip()
if payload:
norm = "m"
if unit in {"год", "hour", "h"}:
norm = "h"
elif unit in {"дн", "день", "day", "d"}:
norm = "d"
return f"/remind_in {value}{norm} {payload}"
# Reminder by absolute datetime (UA/RU/EN)
m2 = re.match(
r"^(?:нагадай(?:\s+мені)?|напомни(?:\s+мне)?|remind me)\s+(?:на|at)\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s+(.+)$",
low,
)
if m2:
dt = m2.group(1)
payload = t[m2.end(1):].strip()
if payload:
return f"/remind {dt} {payload}"
# Mentor relay intent
for prefix in (
"передай менторам ",
"надішли менторам ",
"напиши менторам ",
"send to mentors ",
"relay to mentors ",
):
if low.startswith(prefix):
payload = t[len(prefix):].strip()
if payload:
return f"/relay_mentors {payload}"
return None
def _extract_preferred_language_from_profile_fact(fact: Optional[Dict[str, Any]]) -> Optional[str]:
if not isinstance(fact, dict):
return None
@@ -1360,6 +1146,7 @@ def should_force_concise_reply(text: str) -> bool:
# 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:
@@ -1418,7 +1205,7 @@ async def resolve_response_style_preference(
return "concise"
def _redact_private_patterns(text: str) -> str:
def _redact_private_mentions(text: str) -> str:
if not text:
return ""
sanitized = text
@@ -1482,12 +1269,14 @@ def _answer_seems_off_intent(user_text: str, answer_text: str) -> bool:
return False
def _sanitize_agent_answer_v2(agent_id: str, user_text: str, answer_text: str) -> str:
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_patterns(answer_text or "")
sanitized = _redact_private_mentions(answer_text or "")
return sanitized
def _strip_answer_markup_noise(answer_text: str) -> str:
if not answer_text:
return ""
@@ -1831,6 +1620,14 @@ async def process_photo(
# 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",
@@ -1888,7 +1685,7 @@ async def process_photo(
agent_metadata={"context": "photo"},
username=username,
)
return {"ok": True, "agent": agent_config.agent_id, "model": "specialist_vision_8b"}
else:
await send_telegram_message(
@@ -2908,6 +2705,36 @@ async def handle_telegram_webhook(
)
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)
if corrected_label:
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 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}"
)
# Photo/image intent guard:
# if text references a photo/image, try to resolve latest file_id and route to vision.
photo_intent = False
@@ -2988,33 +2815,6 @@ async def handle_telegram_webhook(
text = caption
logger.info(f"{agent_config.name} Telegram message from {username} (tg:{user_id}) in chat {chat_id}: {text[:50]}")
command_result = await _handle_action_commands(
agent_config=agent_config,
text=text,
chat_id=chat_id,
user_id=user_id,
username=username,
dao_id=dao_id,
telegram_token=telegram_token,
)
if command_result is not None:
return command_result
nl_command = _extract_nl_action_command(text)
if nl_command:
logger.info(f"{agent_config.name}: NL action mapped to command: {nl_command}")
nl_result = await _handle_action_commands(
agent_config=agent_config,
text=nl_command,
chat_id=chat_id,
user_id=user_id,
username=username,
dao_id=dao_id,
telegram_token=telegram_token,
)
if nl_result is not None:
return nl_result
mentioned_bots = extract_bot_mentions(text)
needs_complex_reasoning = requires_complex_reasoning(text)
@@ -3081,7 +2881,7 @@ async def handle_telegram_webhook(
if len(answer) > TELEGRAM_SAFE_LENGTH:
answer = answer[:TELEGRAM_SAFE_LENGTH] + "\n\n_... (відповідь обрізано)_"
answer = _sanitize_agent_answer_v2(agent_config.agent_id, text or "", answer)
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.
@@ -3352,9 +3152,6 @@ async def handle_telegram_webhook(
if force_detailed:
router_request["metadata"]["force_detailed"] = True
if _is_agromatrix_plant_intel_intent(agent_config.agent_id, text):
router_request["metadata"]["crewai_profile"] = "plant_intel"
if force_concise:
# IMPORTANT: preserve conversation context! Only append concise instruction
router_request["metadata"]["force_concise"] = True
@@ -3380,6 +3177,7 @@ async def handle_telegram_webhook(
+ f"\n\n(Мова відповіді: {preferred_lang_label}.)"
+ "\n(Не потрібно щоразу представлятися по імені або писати шаблонне: 'чим можу допомогти'.)"
)
if unresolved_non_current:
router_request["message"] = (
router_request["message"]
@@ -3490,7 +3288,7 @@ async def handle_telegram_webhook(
force_detailed=force_detailed_reply,
needs_complex_reasoning=needs_complex_reasoning,
)
answer_text = _sanitize_agent_answer_v2(agent_config.agent_id, text or "", answer_text)
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:

View File

@@ -143,6 +143,10 @@ class MemoryClient:
"body_text": e.get("content", ""),
"kind": e.get("kind", "message"),
"type": "user" if e.get("role") == "user" else "agent",
"role": e.get("role", "unknown"),
"timestamp": e.get("timestamp"),
"user_id": e.get("user_id"),
"sender_name": e.get("sender_name"),
}
for e in events
if e.get("content")
@@ -445,4 +449,3 @@ class MemoryClient:
# Глобальний екземпляр клієнта
memory_client = MemoryClient()

View File

@@ -11,7 +11,6 @@ import httpx
import logging
import hashlib
import time # For latency metrics
from datetime import datetime
# CrewAI Integration
try:
@@ -236,18 +235,6 @@ def _build_image_fallback_response(agent_id: str, prompt: str = "") -> str:
return "Я поки не бачу достатньо деталей на фото. Надішли, будь ласка, чіткіше фото або крупний план об'єкта."
def _parse_tool_json_result(raw: Any) -> Dict[str, Any]:
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
def _looks_like_image_question(prompt: str) -> bool:
if not prompt:
@@ -1351,32 +1338,20 @@ async def agent_infer(agent_id: str, request: InferRequest):
logger.info(f" No system_prompt in request for agent {agent_id}, loading from configured sources")
if not system_prompt:
if not (CITY_SERVICE_URL or '').strip():
try:
from prompt_builder import get_agent_system_prompt
system_prompt = await get_agent_system_prompt(
agent_id,
city_service_url=CITY_SERVICE_URL,
router_config=router_config
)
logger.info(f"✅ Loaded system prompt from database for {agent_id}")
except Exception as e:
logger.warning(f"⚠️ Could not load prompt from database: {e}")
# Fallback to config
system_prompt_source = "router_config"
agent_config = router_config.get("agents", {}).get(agent_id, {})
system_prompt = agent_config.get("system_prompt")
logger.info(f" CITY_SERVICE_URL is empty; loaded system prompt from router_config for {agent_id}")
else:
try:
from prompt_builder import get_agent_system_prompt
system_prompt = await get_agent_system_prompt(
agent_id,
city_service_url=CITY_SERVICE_URL,
router_config=router_config
)
logger.info(f"✅ Loaded system prompt from city service/config for {agent_id}")
except Exception as e:
logger.warning(f"⚠️ Could not load prompt via prompt_builder: {e}")
# Fallback to config
system_prompt_source = "router_config"
agent_config = router_config.get("agents", {}).get(agent_id, {})
system_prompt = agent_config.get("system_prompt")
if system_prompt and system_prompt_source == "city_service":
# prompt_builder may silently fall back to router config; reflect actual source in metadata/logs
cfg_prompt = (router_config.get("agents", {}).get(agent_id, {}) or {}).get("system_prompt")
if cfg_prompt and (system_prompt or "").strip() == str(cfg_prompt).strip():
system_prompt_source = "router_config"
if not system_prompt:
system_prompt_source = "empty"
@@ -1399,109 +1374,6 @@ async def agent_infer(agent_id: str, request: InferRequest):
# Use router config to get default model for agent, fallback to qwen3:8b
agent_config = router_config.get("agents", {}).get(agent_id, {})
# =========================================================================
# AGROMATRIX PLANT PRE-VISION (edge tool before CrewAI)
# =========================================================================
crewai_profile = str(effective_metadata.get("crewai_profile", "") or "").strip().lower()
is_agromatrix_plant = request_agent_id == "agromatrix" and crewai_profile == "plant_intel"
if is_agromatrix_plant and http_client and user_id and chat_id and not request.images:
# Follow-up path: reuse last structured plant identification from fact-memory.
fact_key = f"last_plant:{request_agent_id}:{chat_id}"
try:
fact_resp = await http_client.get(
f"http://memory-service:8000/facts/{fact_key}",
params={"user_id": user_id},
timeout=8.0,
)
if fact_resp.status_code == 200:
fact_data = fact_resp.json() or {}
last_plant = fact_data.get("fact_value_json") or {}
if isinstance(last_plant, str):
try:
last_plant = json.loads(last_plant)
except Exception:
last_plant = {}
if isinstance(last_plant, dict) and last_plant.get("top_k"):
effective_metadata["last_plant"] = last_plant
# Give deterministic context to synthesis without exposing internals to end user.
request.prompt = (
f"{request.prompt}\n\n"
f"[PREVIOUS_PLANT_IDENTIFICATION] {json.dumps(last_plant, ensure_ascii=False)}"
)
logger.info(
f"🌿 Plant follow-up context loaded: top1={((last_plant.get('top_k') or [{}])[0]).get('scientific_name', 'N/A')}"
)
except Exception as e:
logger.warning(f"⚠️ Plant follow-up context load failed: {e}")
if is_agromatrix_plant and request.images and len(request.images) > 0 and TOOL_MANAGER_AVAILABLE and tool_manager:
first_image = request.images[0]
tool_args: Dict[str, Any] = {"top_k": 5}
if isinstance(first_image, str) and first_image.startswith("data:"):
tool_args["image_data"] = first_image
elif isinstance(first_image, str):
tool_args["image_url"] = first_image
try:
tool_res = await tool_manager.execute_tool(
"nature_id_identify",
tool_args,
agent_id=request_agent_id,
chat_id=chat_id,
user_id=user_id,
)
if tool_res and tool_res.success and tool_res.result:
plant_vision = _parse_tool_json_result(tool_res.result)
if plant_vision:
top_k_rows = plant_vision.get("top_k") or []
top1 = top_k_rows[0] if top_k_rows else {}
confidence = float(plant_vision.get("confidence") or top1.get("confidence") or 0.0)
effective_metadata["plant_vision"] = plant_vision
effective_metadata["plant_top_k"] = top_k_rows
effective_metadata["plant_confidence"] = confidence
request.prompt = (
f"{request.prompt}\n\n"
f"[PLANT_VISION_PREPROCESSED] {json.dumps(plant_vision, ensure_ascii=False)}"
)
if top1:
logger.info(
f"🌿 Vision pre-process: {confidence:.2f}% {top1.get('scientific_name') or top1.get('name') or 'unknown'}"
)
else:
logger.info("🌿 Vision pre-process: no candidates")
if plant_vision.get("recommend_fallback"):
logger.info("🌿 Vision pre-process: low confidence -> GBIF fallback enabled")
# Persist structured plant result for follow-up questions.
if http_client and user_id and chat_id:
fact_key = f"last_plant:{request_agent_id}:{chat_id}"
try:
await http_client.post(
"http://memory-service:8000/facts/upsert",
json={
"user_id": user_id,
"fact_key": fact_key,
"fact_value": (top1.get("scientific_name") if isinstance(top1, dict) else None),
"fact_value_json": {
"top_k": top_k_rows,
"confidence": confidence,
"recommend_fallback": bool(plant_vision.get("recommend_fallback")),
"gbif_validation": plant_vision.get("gbif_validation"),
"identified_at": datetime.utcnow().isoformat(),
"agent_id": request_agent_id,
"chat_id": chat_id,
"source": "plant_vision_preprocess",
},
},
timeout=8.0,
)
except Exception as e:
logger.warning(f"⚠️ Failed to store last_plant fact: {e}")
except Exception as e:
logger.warning(f"⚠️ Plant pre-vision failed: {e}")
# =========================================================================
# CREWAI DECISION: Use orchestration or direct LLM?
# =========================================================================
@@ -1587,10 +1459,6 @@ async def agent_infer(agent_id: str, request: InferRequest):
},
"metadata": effective_metadata,
"runtime_envelope": runtime_envelope,
"plant_vision": effective_metadata.get("plant_vision"),
"plant_top_k": effective_metadata.get("plant_top_k"),
"plant_confidence": effective_metadata.get("plant_confidence"),
"last_plant": effective_metadata.get("last_plant"),
},
team=crewai_cfg.get("team"),
profile=effective_metadata.get("crewai_profile")

1
third_party/nature-id vendored Submodule

Submodule third_party/nature-id added at 5e9468d65a