From 50dfcd73907f9df7d864aa3b877e9196b01e1df4 Mon Sep 17 00:00:00 2001 From: NODA1 System Date: Sat, 21 Feb 2026 11:52:50 +0100 Subject: [PATCH] router: enforce direct image inputs for plant tools and inject runtime image_data --- services/router/main.py | 13 ++++++++ services/router/tool_manager.py | 54 ++++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/services/router/main.py b/services/router/main.py index 9d9816fa..c0d756c7 100644 --- a/services/router/main.py +++ b/services/router/main.py @@ -2007,6 +2007,19 @@ async def agent_infer(agent_id: str, request: InferRequest): tool_args = {"params": {"count": 3, "timezone": "Europe/Kyiv"}} logger.info("🛠️ oneok: auto-filled schedule_propose_slots.params") + # Plant tools: inject runtime image payload from current request to avoid + # hallucinated page URLs (e.g. t.me//) that are not direct images. + if tool_name in {"nature_id_identify", "plantnet_lookup"}: + if not isinstance(tool_args, dict): + tool_args = {} + runtime_image_data = None + if isinstance(request.images, list) and request.images: + first_image = request.images[0] + if isinstance(first_image, str) and first_image.startswith("data:image/") and ";base64," in first_image: + runtime_image_data = first_image + if runtime_image_data: + tool_args["_runtime_image_data"] = runtime_image_data + result = await tool_manager.execute_tool( tool_name, tool_args, diff --git a/services/router/tool_manager.py b/services/router/tool_manager.py index f298efcc..a41c7f38 100644 --- a/services/router/tool_manager.py +++ b/services/router/tool_manager.py @@ -19,6 +19,7 @@ from typing import Dict, List, Any, Optional from dataclasses import dataclass from io import BytesIO, StringIO from pathlib import PurePath +from urllib.parse import urlparse import xml.etree.ElementTree as ET from xml.sax.saxutils import escape as xml_escape from zipfile import ZIP_DEFLATED, ZipFile @@ -164,8 +165,7 @@ TOOL_DEFINITIONS = [ "description": "Поріг confidence для fallback на GBIF", "default": 0.65 } - }, - "required": ["image_url"] + } } } }, @@ -791,6 +791,27 @@ class ToolManager: tool_names = [t.get("function", {}).get("name") for t in filtered] logger.debug(f"Agent {agent_id} has {len(filtered)} tools: {tool_names}") return filtered + + @staticmethod + def _is_image_data_url(value: str) -> bool: + v = str(value or "").strip() + return bool(v.startswith("data:image/") and ";base64," in v) + + @staticmethod + def _is_known_non_direct_image_url(url: str) -> bool: + u = str(url or "").strip() + if not u: + return False + try: + p = urlparse(u) + except Exception: + return True + host = (p.netloc or "").lower() + if host in {"t.me", "telegram.me"}: + return True + if "web.telegram.org" in host: + return True + return False async def execute_tool( self, @@ -2652,6 +2673,10 @@ class ToolManager: """Plant identification via Pl@ntNet API (skeleton adapter).""" query = str(args.get("query", "") or "").strip() image_url = str(args.get("image_url", "") or "").strip() + image_data = str(args.get("image_data", "") or "").strip() + runtime_image_data = str(args.get("_runtime_image_data", "") or "").strip() + if not image_data and self._is_image_data_url(runtime_image_data): + image_data = runtime_image_data organ = str(args.get("organ", "auto") or "auto").strip().lower() top_k = max(1, min(int(args.get("top_k", 3)), 5)) @@ -2687,8 +2712,15 @@ class ToolManager: except Exception as e: return ToolResult(success=False, result=None, error=f"plantnet_error: {e}") - if image_url: - ni = await self._nature_id_identify({"image_url": image_url, "top_k": top_k}) + if image_url or image_data: + ni_args: Dict[str, Any] = {"top_k": top_k} + if image_data: + ni_args["image_data"] = image_data + else: + ni_args["image_url"] = image_url + if runtime_image_data: + ni_args["_runtime_image_data"] = runtime_image_data + ni = await self._nature_id_identify(ni_args) if ni.success: return ni @@ -2705,9 +2737,23 @@ class ToolManager: """Open-source plant identification via self-hosted nature-id compatible endpoint.""" image_url = str(args.get("image_url", "") or "").strip() image_data = str(args.get("image_data", "") or "").strip() + runtime_image_data = str(args.get("_runtime_image_data", "") or "").strip() + if not image_data and self._is_image_data_url(runtime_image_data): + image_data = runtime_image_data top_k = max(1, min(int(args.get("top_k", 3)), 10)) min_confidence = float(args.get("min_confidence", os.getenv("NATURE_ID_MIN_CONFIDENCE", "0.65"))) + if image_url and self._is_known_non_direct_image_url(image_url): + if image_data: + logger.info("nature_id_identify: replacing non-direct image_url with runtime image_data") + image_url = "" + else: + return ToolResult( + success=False, + result=None, + error="image_url is not direct image URL; provide image_data or direct Telegram file URL", + ) + if not image_url and not image_data: return ToolResult(success=False, result=None, error="image_url or image_data is required")