router: add tool manager runtime and memory retrieval updates
This commit is contained in:
@@ -108,6 +108,116 @@ TOOL_DEFINITIONS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "plantnet_lookup",
|
||||
"description": "Визначення рослин через Pl@ntNet API. Повертає top-k кандидатів з confidence.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Короткий опис рослини/культури (якщо немає image_url)"
|
||||
},
|
||||
"image_url": {
|
||||
"type": "string",
|
||||
"description": "Публічне посилання на фото рослини"
|
||||
},
|
||||
"organ": {
|
||||
"type": "string",
|
||||
"description": "Орган рослини: leaf/flower/fruit/bark/auto",
|
||||
"default": "auto"
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer",
|
||||
"description": "Скільки кандидатів повернути (1-10)",
|
||||
"default": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "nature_id_identify",
|
||||
"description": "Локальна/open-source ідентифікація рослин через nature-id сумісний сервіс.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image_url": {
|
||||
"type": "string",
|
||||
"description": "Публічне посилання на фото рослини"
|
||||
},
|
||||
"image_data": {
|
||||
"type": "string",
|
||||
"description": "Data URL зображення (data:image/...;base64,...)"
|
||||
},
|
||||
"top_k": {
|
||||
"type": "integer",
|
||||
"description": "Скільки кандидатів повернути (1-10)",
|
||||
"default": 3
|
||||
},
|
||||
"min_confidence": {
|
||||
"type": "number",
|
||||
"description": "Поріг confidence для fallback на GBIF",
|
||||
"default": 0.65
|
||||
}
|
||||
},
|
||||
"required": ["image_url"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "gbif_species_lookup",
|
||||
"description": "Пошук таксонів у GBIF для валідації назви культури/рослини.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Назва/термін для пошуку виду"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Кількість результатів (1-10)",
|
||||
"default": 5
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "agrovoc_lookup",
|
||||
"description": "Нормалізація агро-термінів через AGROVOC (SPARQL).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Термін культури/хвороби/технології"
|
||||
},
|
||||
"lang": {
|
||||
"type": "string",
|
||||
"description": "Мова міток (en/uk/ru)",
|
||||
"default": "en"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Кількість результатів (1-10)",
|
||||
"default": 5
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
},
|
||||
# PRIORITY 3: Generation tools
|
||||
{
|
||||
"type": "function",
|
||||
@@ -709,6 +819,14 @@ class ToolManager:
|
||||
return await self._web_search(arguments)
|
||||
elif tool_name == "web_extract":
|
||||
return await self._web_extract(arguments)
|
||||
elif tool_name == "plantnet_lookup":
|
||||
return await self._plantnet_lookup(arguments)
|
||||
elif tool_name == "nature_id_identify":
|
||||
return await self._nature_id_identify(arguments)
|
||||
elif tool_name == "gbif_species_lookup":
|
||||
return await self._gbif_species_lookup(arguments)
|
||||
elif tool_name == "agrovoc_lookup":
|
||||
return await self._agrovoc_lookup(arguments)
|
||||
elif tool_name == "image_generate":
|
||||
return await self._image_generate(arguments)
|
||||
elif tool_name == "comfy_generate_image":
|
||||
@@ -2530,6 +2648,253 @@ class ToolManager:
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, result=None, error=str(e))
|
||||
|
||||
async def _plantnet_lookup(self, args: Dict) -> ToolResult:
|
||||
"""Plant identification via Pl@ntNet API (skeleton adapter)."""
|
||||
query = str(args.get("query", "") or "").strip()
|
||||
image_url = str(args.get("image_url", "") or "").strip()
|
||||
organ = str(args.get("organ", "auto") or "auto").strip().lower()
|
||||
top_k = max(1, min(int(args.get("top_k", 3)), 5))
|
||||
|
||||
api_key = (os.getenv("PLANTNET_API_KEY") or "").strip()
|
||||
if image_url and api_key:
|
||||
try:
|
||||
params = {
|
||||
"api-key": api_key,
|
||||
"images": image_url,
|
||||
"organs": "leaf" if organ == "auto" else organ,
|
||||
"lang": "en",
|
||||
}
|
||||
resp = await self.http_client.get(
|
||||
"https://my-api.plantnet.org/v2/identify/all",
|
||||
params=params,
|
||||
timeout=25.0,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
results = (data.get("results") or [])[:top_k]
|
||||
if not results:
|
||||
return ToolResult(success=True, result="Pl@ntNet: кандидатів не знайдено.")
|
||||
lines = []
|
||||
for idx, item in enumerate(results, 1):
|
||||
species = (item.get("species") or {})
|
||||
sname = species.get("scientificNameWithoutAuthor") or species.get("scientificName") or "unknown"
|
||||
common = species.get("commonNames") or []
|
||||
cname = common[0] if common else "-"
|
||||
score = float(item.get("score") or 0.0)
|
||||
lines.append(f"{idx}. {sname} ({cname}) score={score:.3f}")
|
||||
return ToolResult(success=True, result="Pl@ntNet candidates:\n" + "\n".join(lines))
|
||||
return ToolResult(success=False, result=None, error=f"plantnet_http_{resp.status_code}")
|
||||
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 ni.success:
|
||||
return ni
|
||||
|
||||
if query:
|
||||
return await self._gbif_species_lookup({"query": query, "limit": top_k})
|
||||
|
||||
return ToolResult(
|
||||
success=False,
|
||||
result=None,
|
||||
error="No available plant ID backend (set PLANTNET_API_KEY or NATURE_ID_URL, or provide text query)",
|
||||
)
|
||||
|
||||
async def _nature_id_identify(self, args: Dict) -> ToolResult:
|
||||
"""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()
|
||||
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 not image_url and not image_data:
|
||||
return ToolResult(success=False, result=None, error="image_url or image_data is required")
|
||||
|
||||
base = (os.getenv("NATURE_ID_URL") or "").strip().rstrip("/")
|
||||
if not base:
|
||||
return ToolResult(success=False, result=None, error="NATURE_ID_URL is not configured")
|
||||
|
||||
try:
|
||||
if image_data:
|
||||
# data URL -> multipart /identify-file
|
||||
if not image_data.startswith("data:") or "," not in image_data:
|
||||
return ToolResult(success=False, result=None, error="invalid image_data format")
|
||||
header, b64 = image_data.split(",", 1)
|
||||
mime = "image/jpeg"
|
||||
if ";base64" in header:
|
||||
mime = header.split(":", 1)[1].split(";", 1)[0] or "image/jpeg"
|
||||
ext = "jpg"
|
||||
if "png" in mime:
|
||||
ext = "png"
|
||||
try:
|
||||
image_bytes = base64.b64decode(b64)
|
||||
except Exception:
|
||||
return ToolResult(success=False, result=None, error="invalid image_data base64")
|
||||
files = {"file": (f"upload.{ext}", image_bytes, mime)}
|
||||
resp = await self.http_client.post(
|
||||
f"{base}/identify-file",
|
||||
params={"top_k": top_k},
|
||||
files=files,
|
||||
timeout=45.0,
|
||||
)
|
||||
else:
|
||||
payload = {"image_url": image_url, "top_k": top_k}
|
||||
resp = await self.http_client.post(f"{base}/identify", json=payload, timeout=45.0)
|
||||
|
||||
if resp.status_code != 200:
|
||||
return ToolResult(success=False, result=None, error=f"nature_id_http_{resp.status_code}")
|
||||
|
||||
data = resp.json() or {}
|
||||
status = str(data.get("status") or "success")
|
||||
raw_top_k = data.get("top_k") or []
|
||||
raw_preds = data.get("predictions") or data.get("results") or []
|
||||
|
||||
top_k_rows = []
|
||||
if isinstance(raw_top_k, list) and raw_top_k:
|
||||
for row in raw_top_k[:top_k]:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
conf = row.get("confidence", 0.0)
|
||||
try:
|
||||
conf_f = float(conf)
|
||||
except Exception:
|
||||
conf_f = 0.0
|
||||
top_k_rows.append({
|
||||
"confidence": conf_f,
|
||||
"name": str(row.get("name") or row.get("scientific_name") or "unknown"),
|
||||
"scientific_name": str(row.get("scientific_name") or row.get("name") or "unknown"),
|
||||
})
|
||||
else:
|
||||
for item in raw_preds[:top_k]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
score = item.get("score", item.get("confidence", 0.0))
|
||||
try:
|
||||
score_f = float(score)
|
||||
except Exception:
|
||||
score_f = 0.0
|
||||
sname = item.get("scientific_name") or item.get("label") or item.get("name") or "unknown"
|
||||
cname = item.get("common_name") or item.get("common") or sname
|
||||
top_k_rows.append({
|
||||
"confidence": score_f,
|
||||
"name": str(cname),
|
||||
"scientific_name": str(sname),
|
||||
})
|
||||
|
||||
if not top_k_rows:
|
||||
return ToolResult(success=True, result=json.dumps({
|
||||
"status": status,
|
||||
"model": data.get("model") or "aiy_plants_V1",
|
||||
"source": data.get("source") or "nature-id-cli",
|
||||
"top_k": [],
|
||||
"confidence": 0.0,
|
||||
"recommend_fallback": True,
|
||||
"reason": "no_predictions",
|
||||
}, ensure_ascii=False))
|
||||
|
||||
top1 = top_k_rows[0]
|
||||
top1_conf = float(top1.get("confidence", 0.0))
|
||||
recommend_fallback = top1_conf < min_confidence
|
||||
|
||||
out = {
|
||||
"status": status,
|
||||
"model": data.get("model") or "aiy_plants_V1",
|
||||
"source": data.get("source") or "nature-id-cli",
|
||||
"inference_time_sec": data.get("inference_time_sec"),
|
||||
"top_k": top_k_rows,
|
||||
"confidence": top1_conf,
|
||||
"min_confidence": min_confidence,
|
||||
"recommend_fallback": recommend_fallback,
|
||||
"fallback": "gbif_species_lookup",
|
||||
}
|
||||
|
||||
if recommend_fallback:
|
||||
fallback_query = str(top1.get("scientific_name") or top1.get("name") or "").strip()
|
||||
if fallback_query and fallback_query.lower() != "unknown":
|
||||
gbif = await self._gbif_species_lookup({"query": fallback_query, "limit": min(5, top_k)})
|
||||
if gbif.success and gbif.result:
|
||||
out["gbif_validation"] = gbif.result
|
||||
|
||||
return ToolResult(success=True, result=json.dumps(out, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, result=None, error=f"nature_id_error: {e}")
|
||||
|
||||
async def _gbif_species_lookup(self, args: Dict) -> ToolResult:
|
||||
"""Species lookup via GBIF public API."""
|
||||
query = str(args.get("query", "") or "").strip()
|
||||
limit = max(1, min(int(args.get("limit", 5)), 10))
|
||||
if not query:
|
||||
return ToolResult(success=False, result=None, error="query is required")
|
||||
|
||||
try:
|
||||
resp = await self.http_client.get(
|
||||
"https://api.gbif.org/v1/species/search",
|
||||
params={"q": query, "limit": limit, "status": "ACCEPTED"},
|
||||
timeout=20.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return ToolResult(success=False, result=None, error=f"gbif_http_{resp.status_code}")
|
||||
|
||||
data = resp.json() or {}
|
||||
results = data.get("results") or []
|
||||
if not results:
|
||||
return ToolResult(success=True, result="GBIF: результатів не знайдено.")
|
||||
|
||||
lines = []
|
||||
for idx, item in enumerate(results[:limit], 1):
|
||||
sci = item.get("scientificName") or item.get("canonicalName") or "unknown"
|
||||
rank = item.get("rank") or "-"
|
||||
status = item.get("taxonomicStatus") or "-"
|
||||
key = item.get("key")
|
||||
lines.append(f"{idx}. {sci} | rank={rank} | status={status} | key={key}")
|
||||
return ToolResult(success=True, result="GBIF matches:\n" + "\n".join(lines))
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, result=None, error=f"gbif_error: {e}")
|
||||
|
||||
async def _agrovoc_lookup(self, args: Dict) -> ToolResult:
|
||||
"""AGROVOC term normalization via public SPARQL endpoint."""
|
||||
query = str(args.get("query", "") or "").strip()
|
||||
lang = str(args.get("lang", "en") or "en").strip().lower()
|
||||
limit = max(1, min(int(args.get("limit", 5)), 10))
|
||||
if not query:
|
||||
return ToolResult(success=False, result=None, error="query is required")
|
||||
if lang not in {"en", "uk", "ru"}:
|
||||
lang = "en"
|
||||
|
||||
safe_q = query.replace('\\', ' ').replace('"', ' ').strip()
|
||||
sparql = (
|
||||
"PREFIX skos: <http://www.w3.org/2004/02/skos/core#> "
|
||||
"SELECT ?concept ?label WHERE { "
|
||||
"?concept skos:prefLabel ?label . "
|
||||
f"FILTER(lang(?label) = '{lang}') "
|
||||
f"FILTER(CONTAINS(LCASE(STR(?label)), LCASE(\"{safe_q}\"))) "
|
||||
"} LIMIT " + str(limit)
|
||||
)
|
||||
|
||||
try:
|
||||
resp = await self.http_client.get(
|
||||
"https://agrovoc.fao.org/sparql",
|
||||
params={"query": sparql, "format": "json"},
|
||||
timeout=25.0,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return ToolResult(success=False, result=None, error=f"agrovoc_http_{resp.status_code}")
|
||||
|
||||
data = resp.json() or {}
|
||||
bindings = (((data.get("results") or {}).get("bindings")) or [])
|
||||
if not bindings:
|
||||
return ToolResult(success=True, result="AGROVOC: результатів не знайдено.")
|
||||
|
||||
lines = []
|
||||
for idx, b in enumerate(bindings[:limit], 1):
|
||||
label = ((b.get("label") or {}).get("value") or "").strip()
|
||||
concept = ((b.get("concept") or {}).get("value") or "").strip()
|
||||
lines.append(f"{idx}. {label} | {concept}")
|
||||
return ToolResult(success=True, result="AGROVOC matches:\n" + "\n".join(lines))
|
||||
except Exception as e:
|
||||
return ToolResult(success=False, result=None, error=f"agrovoc_error: {e}")
|
||||
|
||||
async def _unload_ollama_models(self):
|
||||
"""Unload all Ollama models to free VRAM for heavy operations like FLUX"""
|
||||
ollama_url = os.getenv("OLLAMA_BASE_URL", "http://172.18.0.1:11434")
|
||||
|
||||
Reference in New Issue
Block a user