agromatrix: deploy context/photo learning + deterministic excel policy
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
1
third_party/nature-id
vendored
Submodule
Submodule third_party/nature-id added at 5e9468d65a
Reference in New Issue
Block a user