diff --git a/gateway-bot/http_api.py b/gateway-bot/http_api.py index c2025c90..3199508e 100644 --- a/gateway-bot/http_api.py +++ b/gateway-bot/http_api.py @@ -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: diff --git a/gateway-bot/memory_client.py b/gateway-bot/memory_client.py index d22b9f85..a101432b 100644 --- a/gateway-bot/memory_client.py +++ b/gateway-bot/memory_client.py @@ -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() - diff --git a/services/router/main.py b/services/router/main.py index 0f7e41f0..9d9816fa 100644 --- a/services/router/main.py +++ b/services/router/main.py @@ -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") diff --git a/third_party/nature-id b/third_party/nature-id new file mode 160000 index 00000000..5e9468d6 --- /dev/null +++ b/third_party/nature-id @@ -0,0 +1 @@ +Subproject commit 5e9468d65a495e6c146a29961ad10a10fced35cd