Phase6/7 runtime + Gitea smoke gate setup #1
@@ -5,6 +5,7 @@ Handles incoming webhooks from Telegram, Discord, etc.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import copy
|
import copy
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
@@ -15,10 +16,9 @@ import uuid
|
|||||||
import httpx
|
import httpx
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, List, Tuple
|
from typing import Dict, Any, Optional, List, Tuple
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
@@ -48,11 +48,6 @@ from behavior_policy import (
|
|||||||
BehaviorDecision,
|
BehaviorDecision,
|
||||||
AGENT_NAME_VARIANTS,
|
AGENT_NAME_VARIANTS,
|
||||||
)
|
)
|
||||||
from daarion_facade.reminders import (
|
|
||||||
create_reminder,
|
|
||||||
list_reminders,
|
|
||||||
cancel_reminder,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 for follow-up questions in chat (agent:chat:user -> {file_id, ts})
|
||||||
RECENT_PHOTO_CONTEXT: Dict[str, Dict[str, Any]] = {}
|
RECENT_PHOTO_CONTEXT: Dict[str, Dict[str, Any]] = {}
|
||||||
RECENT_PHOTO_TTL = 30 * 60 # 30 minutes
|
RECENT_PHOTO_TTL = 30 * 60 # 30 minutes
|
||||||
|
AGROMATRIX_GLOBAL_KNOWLEDGE_USER_ID = "agent:agromatrix:global"
|
||||||
|
|
||||||
|
|
||||||
def _cleanup_recent_photo_context() -> None:
|
def _cleanup_recent_photo_context() -> None:
|
||||||
@@ -153,19 +149,123 @@ def _looks_like_photo_followup(text: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _is_agromatrix_plant_intel_intent(agent_id: str, text: str) -> bool:
|
def _extract_agromatrix_correction_label(text: str) -> Optional[str]:
|
||||||
if (agent_id or "").lower() != "agromatrix":
|
"""
|
||||||
return False
|
Extract corrected plant label from free-form user feedback.
|
||||||
if not text:
|
Examples:
|
||||||
return False
|
- "це соняшник"
|
||||||
tl = text.strip().lower()
|
- "це не кабачок, а гарбуз"
|
||||||
markers = [
|
- "правильна відповідь: кукурудза"
|
||||||
"що за рослина", "що це за рослина", "яка це рослина", "яка культура",
|
"""
|
||||||
"визнач рослину", "ідентифікуй рослину", "хвороба рослини", "плями на листі",
|
raw = (text or "").strip()
|
||||||
"what plant", "identify plant", "identify crop", "plant disease",
|
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:
|
def _needs_photo_only_response(text: str) -> bool:
|
||||||
"""
|
"""
|
||||||
@@ -203,6 +303,10 @@ def _extract_unanswered_user_messages(
|
|||||||
current_user_id: str,
|
current_user_id: str,
|
||||||
max_items: int = 3,
|
max_items: int = 3,
|
||||||
) -> List[str]:
|
) -> 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 []
|
events = memory_context.get("recent_events") or []
|
||||||
if not isinstance(events, list) or not current_user_id:
|
if not isinstance(events, list) or not current_user_id:
|
||||||
return []
|
return []
|
||||||
@@ -234,7 +338,7 @@ def _extract_unanswered_user_messages(
|
|||||||
if not q_tokens or not a_tokens:
|
if not q_tokens or not a_tokens:
|
||||||
return False
|
return False
|
||||||
overlap = len(q_tokens.intersection(a_tokens))
|
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)
|
return overlap >= 2 or (overlap >= 1 and len(q_tokens) <= 3)
|
||||||
|
|
||||||
pending: List[Dict[str, str]] = []
|
pending: List[Dict[str, str]] = []
|
||||||
@@ -256,17 +360,23 @@ def _extract_unanswered_user_messages(
|
|||||||
if resolved_idx is not None:
|
if resolved_idx is not None:
|
||||||
pending.pop(resolved_idx)
|
pending.pop(resolved_idx)
|
||||||
|
|
||||||
|
# Keep the latest unresolved items only.
|
||||||
if len(pending) > max_items:
|
if len(pending) > max_items:
|
||||||
pending = pending[-max_items:]
|
pending = pending[-max_items:]
|
||||||
return [p["text"] for p in pending]
|
return [p["text"] for p in pending]
|
||||||
|
|
||||||
|
|
||||||
def _is_question_like(text: str) -> bool:
|
def _is_question_like(text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Detect user questions without false positives from substring matches
|
||||||
|
(e.g. 'схоже' should not match 'що').
|
||||||
|
"""
|
||||||
if not text:
|
if not text:
|
||||||
return False
|
return False
|
||||||
t = text.strip().lower()
|
t = text.strip().lower()
|
||||||
if "?" in t:
|
if "?" in t:
|
||||||
return True
|
return True
|
||||||
|
# Ukrainian / Russian / English interrogatives with word boundaries.
|
||||||
return bool(
|
return bool(
|
||||||
re.search(
|
re.search(
|
||||||
r"\b(що|як|чому|коли|де|хто|чи|what|why|how|when|where|who|зачем|почему|когда|где|кто|ли)\b",
|
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")
|
}.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]:
|
def _extract_preferred_language_from_profile_fact(fact: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||||
if not isinstance(fact, dict):
|
if not isinstance(fact, dict):
|
||||||
return None
|
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.
|
# For regular Q&A in chat keep first response concise by default.
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _detect_response_style_signal(text: str) -> Optional[str]:
|
def _detect_response_style_signal(text: str) -> Optional[str]:
|
||||||
t = (text or "").strip().lower()
|
t = (text or "").strip().lower()
|
||||||
if not t:
|
if not t:
|
||||||
@@ -1418,7 +1205,7 @@ async def resolve_response_style_preference(
|
|||||||
return "concise"
|
return "concise"
|
||||||
|
|
||||||
|
|
||||||
def _redact_private_patterns(text: str) -> str:
|
def _redact_private_mentions(text: str) -> str:
|
||||||
if not text:
|
if not text:
|
||||||
return ""
|
return ""
|
||||||
sanitized = text
|
sanitized = text
|
||||||
@@ -1482,12 +1269,14 @@ def _answer_seems_off_intent(user_text: str, answer_text: str) -> bool:
|
|||||||
return False
|
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)
|
blocked = _block_private_profile_dump(user_text)
|
||||||
if blocked:
|
if blocked:
|
||||||
return blocked
|
return blocked
|
||||||
sanitized = _redact_private_patterns(answer_text or "")
|
sanitized = _redact_private_mentions(answer_text or "")
|
||||||
return sanitized
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
def _strip_answer_markup_noise(answer_text: str) -> str:
|
def _strip_answer_markup_noise(answer_text: str) -> str:
|
||||||
if not answer_text:
|
if not answer_text:
|
||||||
return ""
|
return ""
|
||||||
@@ -1831,6 +1620,14 @@ async def process_photo(
|
|||||||
# Send to Router with specialist_vision_8b model (Swapper)
|
# Send to Router with specialist_vision_8b model (Swapper)
|
||||||
# IMPORTANT: Default prompt must request BRIEF description (2-3 sentences max)
|
# IMPORTANT: Default prompt must request BRIEF description (2-3 sentences max)
|
||||||
prompt = caption.strip() if caption else "Коротко (2-3 речення) скажи, що на цьому зображенні та яке його значення."
|
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 = {
|
router_request = {
|
||||||
"message": f"{prompt}\n\n[Зображення передано окремо у context.images]",
|
"message": f"{prompt}\n\n[Зображення передано окремо у context.images]",
|
||||||
"mode": "chat",
|
"mode": "chat",
|
||||||
@@ -2908,6 +2705,36 @@ async def handle_telegram_webhook(
|
|||||||
)
|
)
|
||||||
return {"ok": True, "agent": agent_config.agent_id, "mode": "greeting_fast_path"}
|
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:
|
# Photo/image intent guard:
|
||||||
# if text references a photo/image, try to resolve latest file_id and route to vision.
|
# if text references a photo/image, try to resolve latest file_id and route to vision.
|
||||||
photo_intent = False
|
photo_intent = False
|
||||||
@@ -2988,33 +2815,6 @@ async def handle_telegram_webhook(
|
|||||||
text = caption
|
text = caption
|
||||||
|
|
||||||
logger.info(f"{agent_config.name} Telegram message from {username} (tg:{user_id}) in chat {chat_id}: {text[:50]}")
|
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)
|
mentioned_bots = extract_bot_mentions(text)
|
||||||
needs_complex_reasoning = requires_complex_reasoning(text)
|
needs_complex_reasoning = requires_complex_reasoning(text)
|
||||||
|
|
||||||
@@ -3081,7 +2881,7 @@ async def handle_telegram_webhook(
|
|||||||
if len(answer) > TELEGRAM_SAFE_LENGTH:
|
if len(answer) > TELEGRAM_SAFE_LENGTH:
|
||||||
answer = answer[:TELEGRAM_SAFE_LENGTH] + "\n\n_... (відповідь обрізано)_"
|
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)
|
await send_telegram_message(chat_id, answer, telegram_token)
|
||||||
return {"ok": True, "agent": "parser", "mode": "rag_query"}
|
return {"ok": True, "agent": "parser", "mode": "rag_query"}
|
||||||
# Source-lock: with active document context answer only from that document.
|
# Source-lock: with active document context answer only from that document.
|
||||||
@@ -3352,9 +3152,6 @@ async def handle_telegram_webhook(
|
|||||||
if force_detailed:
|
if force_detailed:
|
||||||
router_request["metadata"]["force_detailed"] = True
|
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:
|
if force_concise:
|
||||||
# IMPORTANT: preserve conversation context! Only append concise instruction
|
# IMPORTANT: preserve conversation context! Only append concise instruction
|
||||||
router_request["metadata"]["force_concise"] = True
|
router_request["metadata"]["force_concise"] = True
|
||||||
@@ -3380,6 +3177,7 @@ async def handle_telegram_webhook(
|
|||||||
+ f"\n\n(Мова відповіді: {preferred_lang_label}.)"
|
+ f"\n\n(Мова відповіді: {preferred_lang_label}.)"
|
||||||
+ "\n(Не потрібно щоразу представлятися по імені або писати шаблонне: 'чим можу допомогти'.)"
|
+ "\n(Не потрібно щоразу представлятися по імені або писати шаблонне: 'чим можу допомогти'.)"
|
||||||
)
|
)
|
||||||
|
|
||||||
if unresolved_non_current:
|
if unresolved_non_current:
|
||||||
router_request["message"] = (
|
router_request["message"] = (
|
||||||
router_request["message"]
|
router_request["message"]
|
||||||
@@ -3490,7 +3288,7 @@ async def handle_telegram_webhook(
|
|||||||
force_detailed=force_detailed_reply,
|
force_detailed=force_detailed_reply,
|
||||||
needs_complex_reasoning=needs_complex_reasoning,
|
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)
|
# Skip Telegram sending for prober requests (chat_id=0)
|
||||||
if is_prober:
|
if is_prober:
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ class MemoryClient:
|
|||||||
"body_text": e.get("content", ""),
|
"body_text": e.get("content", ""),
|
||||||
"kind": e.get("kind", "message"),
|
"kind": e.get("kind", "message"),
|
||||||
"type": "user" if e.get("role") == "user" else "agent",
|
"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
|
for e in events
|
||||||
if e.get("content")
|
if e.get("content")
|
||||||
@@ -445,4 +449,3 @@ class MemoryClient:
|
|||||||
|
|
||||||
# Глобальний екземпляр клієнта
|
# Глобальний екземпляр клієнта
|
||||||
memory_client = MemoryClient()
|
memory_client = MemoryClient()
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import httpx
|
|||||||
import logging
|
import logging
|
||||||
import hashlib
|
import hashlib
|
||||||
import time # For latency metrics
|
import time # For latency metrics
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# CrewAI Integration
|
# CrewAI Integration
|
||||||
try:
|
try:
|
||||||
@@ -236,18 +235,6 @@ def _build_image_fallback_response(agent_id: str, prompt: str = "") -> str:
|
|||||||
return "Я поки не бачу достатньо деталей на фото. Надішли, будь ласка, чіткіше фото або крупний план об'єкта."
|
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:
|
def _looks_like_image_question(prompt: str) -> bool:
|
||||||
if not prompt:
|
if not prompt:
|
||||||
@@ -1351,12 +1338,6 @@ 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")
|
logger.info(f"ℹ️ No system_prompt in request for agent {agent_id}, loading from configured sources")
|
||||||
|
|
||||||
if not system_prompt:
|
if not system_prompt:
|
||||||
if not (CITY_SERVICE_URL or '').strip():
|
|
||||||
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:
|
try:
|
||||||
from prompt_builder import get_agent_system_prompt
|
from prompt_builder import get_agent_system_prompt
|
||||||
system_prompt = await get_agent_system_prompt(
|
system_prompt = await get_agent_system_prompt(
|
||||||
@@ -1364,20 +1345,14 @@ async def agent_infer(agent_id: str, request: InferRequest):
|
|||||||
city_service_url=CITY_SERVICE_URL,
|
city_service_url=CITY_SERVICE_URL,
|
||||||
router_config=router_config
|
router_config=router_config
|
||||||
)
|
)
|
||||||
logger.info(f"✅ Loaded system prompt from city service/config for {agent_id}")
|
logger.info(f"✅ Loaded system prompt from database for {agent_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ Could not load prompt via prompt_builder: {e}")
|
logger.warning(f"⚠️ Could not load prompt from database: {e}")
|
||||||
# Fallback to config
|
# Fallback to config
|
||||||
system_prompt_source = "router_config"
|
system_prompt_source = "router_config"
|
||||||
agent_config = router_config.get("agents", {}).get(agent_id, {})
|
agent_config = router_config.get("agents", {}).get(agent_id, {})
|
||||||
system_prompt = agent_config.get("system_prompt")
|
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:
|
if not system_prompt:
|
||||||
system_prompt_source = "empty"
|
system_prompt_source = "empty"
|
||||||
logger.warning(f"⚠️ System prompt unavailable for {agent_id}; continuing with provider defaults")
|
logger.warning(f"⚠️ System prompt unavailable for {agent_id}; continuing with provider defaults")
|
||||||
@@ -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
|
# Use router config to get default model for agent, fallback to qwen3:8b
|
||||||
agent_config = router_config.get("agents", {}).get(agent_id, {})
|
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?
|
# CREWAI DECISION: Use orchestration or direct LLM?
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
@@ -1587,10 +1459,6 @@ async def agent_infer(agent_id: str, request: InferRequest):
|
|||||||
},
|
},
|
||||||
"metadata": effective_metadata,
|
"metadata": effective_metadata,
|
||||||
"runtime_envelope": runtime_envelope,
|
"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"),
|
team=crewai_cfg.get("team"),
|
||||||
profile=effective_metadata.get("crewai_profile")
|
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