1"""Tool registry: JSON schemas (OpenAI function-calling format), validation, dispatch."""23from __future__ import annotations45import json6import time7from collections.abc import Awaitable, Callable8from dataclasses import dataclass, field9from pathlib import Path10from typing import Any1112from pydantic import BaseModel, ValidationError1314from app.core.config import Settings15from app.core.logging import get_logger16from app.llm.schemas import ToolResult1718log = get_logger("tools")19SCHEMAS_DIR = Path(__file__).parent / "schemas"202122@dataclass23class ToolContext:24 user_id: str25 user_id_hash: str26 conversation_id: str27 course_code: str28 settings: Settings29 role: str = "student"30 file_ids: list[str] = field(default_factory=list) # files attached in the conversation31 progress: Callable[[str, str], Awaitable[None]] | None = None # (status, detail)3233 async def report(self, status: str, detail: str = "") -> None:34 if self.progress:35 await self.progress(status, detail)363738ToolFn = Callable[[dict[str, Any], ToolContext], Awaitable[ToolResult]]394041@dataclass42class ToolSpec:43 name: str44 fn: ToolFn45 args_model: type[BaseModel]46 schema: dict[str, Any]47 heavy: bool = False484950class ToolRegistry:51 def __init__(self) -> None:52 self._tools: dict[str, ToolSpec] = {}5354 def register(self, name: str, fn: ToolFn, args_model: type[BaseModel],55 heavy: bool = False) -> None:56 schema_path = SCHEMAS_DIR / f"{name}.json"57 schema = json.loads(schema_path.read_text(encoding="utf-8"))58 self._tools[name] = ToolSpec(name, fn, args_model, schema, heavy)5960 def names(self) -> list[str]:61 return list(self._tools)6263 def openai_tools(self, enabled: set[str] | None = None) -> list[dict[str, Any]]:64 return [65 {"type": "function", "function": t.schema}66 for n, t in self._tools.items()67 if enabled is None or n in enabled68 ]6970 def is_heavy(self, name: str) -> bool:71 t = self._tools.get(name)72 return bool(t and t.heavy)7374 async def run(self, name: str, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:75 spec = self._tools.get(name)76 if spec is None:77 return ToolResult(content=f"Outil inconnu : {name}. Outils disponibles : "78 f"{', '.join(self._tools)}.", error=True)79 if "__invalid_json__" in arguments:80 return ToolResult(81 content="Les arguments n'étaient pas du JSON valide. Renvoie un objet JSON "82 "conforme au schéma de l'outil.", error=True)83 try:84 args = spec.args_model.model_validate(arguments)85 except ValidationError as exc:86 return ToolResult(87 content="Arguments invalides pour l'outil "88 f"{name} : {exc.errors(include_url=False)}. Corrige et réessaie.",89 error=True)90 t0 = time.perf_counter()91 try:92 result = await spec.fn(args.model_dump(), ctx)93 except Exception as exc: # noqa: BLE001 — error goes back to the model94 log.exception("tool_failed", tool=name)95 result = ToolResult(content=f"L'outil {name} a échoué : {type(exc).__name__}: {exc}",96 error=True)97 result.meta["duration_ms"] = int((time.perf_counter() - t0) * 1000)98 return result99100101registry = ToolRegistry()102