"""Tool registry: JSON schemas (OpenAI function-calling format), validation, dispatch.""" from __future__ import annotations import json import time from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from pathlib import Path from typing import Any from pydantic import BaseModel, ValidationError from app.core.config import Settings from app.core.logging import get_logger from app.llm.schemas import ToolResult log = get_logger("tools") SCHEMAS_DIR = Path(__file__).parent / "schemas" @dataclass class ToolContext: user_id: str user_id_hash: str conversation_id: str course_code: str settings: Settings role: str = "student" file_ids: list[str] = field(default_factory=list) # files attached in the conversation progress: Callable[[str, str], Awaitable[None]] | None = None # (status, detail) async def report(self, status: str, detail: str = "") -> None: if self.progress: await self.progress(status, detail) ToolFn = Callable[[dict[str, Any], ToolContext], Awaitable[ToolResult]] @dataclass class ToolSpec: name: str fn: ToolFn args_model: type[BaseModel] schema: dict[str, Any] heavy: bool = False def _schema_hint(schema: dict[str, Any]) -> str: props = (schema.get("parameters") or {}).get("properties") or {} req = set((schema.get("parameters") or {}).get("required") or []) parts = [] for k, v in props.items(): t = v.get("type", "any") if v.get("enum"): t = "|".join(map(str, v["enum"])) parts.append(f"{k}{'*' if k in req else ''}: {t}") return ", ".join(parts) class ToolRegistry: def __init__(self) -> None: self._tools: dict[str, ToolSpec] = {} def register(self, name: str, fn: ToolFn, args_model: type[BaseModel], heavy: bool = False) -> None: schema_path = SCHEMAS_DIR / f"{name}.json" schema = json.loads(schema_path.read_text(encoding="utf-8")) self._tools[name] = ToolSpec(name, fn, args_model, schema, heavy) def names(self) -> list[str]: return list(self._tools) def openai_tools(self, enabled: set[str] | None = None) -> list[dict[str, Any]]: return [ {"type": "function", "function": t.schema} for n, t in self._tools.items() if enabled is None or n in enabled ] def is_heavy(self, name: str) -> bool: t = self._tools.get(name) return bool(t and t.heavy) async def run(self, name: str, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult: spec = self._tools.get(name) if spec is None: return ToolResult(content=f"Outil inconnu : {name}. Outils disponibles : " f"{', '.join(self._tools)}.", error=True) if "__invalid_json__" in arguments: return ToolResult( content=f"Les arguments de {name} n'étaient pas du JSON valide et n'ont pas pu être " "réparés (souvent : sortie trop longue ou guillemets non échappés dans du " f"code). Attendu : {{{_schema_hint(spec.schema)}}}. Renvoie un appel plus " "court : pour create_excel, utilise un `template` ou moins de lignes ; pour " "execute_python, un code plus concis, ou découpe en plusieurs appels.", error=True) repaired = bool(arguments.pop("__repaired__", False)) # drop unknown keys instead of failing (models add helpful extras) allowed = set(spec.args_model.model_fields) extra = {k: v for k, v in arguments.items() if k not in allowed} clean = {k: v for k, v in arguments.items() if k in allowed} try: args = spec.args_model.model_validate(clean) except ValidationError as exc: errs = "; ".join(f"{'.'.join(map(str, e['loc']))}: {e['msg']}" for e in exc.errors(include_url=False)) return ToolResult( content=f"Arguments invalides pour {name} — {errs}. Attendu : " f"{{{_schema_hint(spec.schema)}}}. Corrige et réessaie.", error=True) payload = args.model_dump() if extra: payload["__extra__"] = extra t0 = time.perf_counter() try: result = await spec.fn(payload, ctx) except Exception as exc: # noqa: BLE001 — error goes back to the model log.exception("tool_failed", tool=name) result = ToolResult(content=f"L'outil {name} a échoué : {type(exc).__name__}: {exc}. " "Ajuste les arguments (types, cellules, formats) et réessaie ; " "si l'erreur persiste, explique le calcul en texte.", error=True) result.meta["duration_ms"] = int((time.perf_counter() - t0) * 1000) if repaired: result.content += ("\n\n(Note : les arguments JSON étaient tronqués ou malformés et ont " "été réparés automatiquement — vérifie que le résultat est complet.)") return result registry = ToolRegistry()