"""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 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="Les arguments n'étaient pas du JSON valide. Renvoie un objet JSON " "conforme au schéma de l'outil.", error=True) try: args = spec.args_model.model_validate(arguments) except ValidationError as exc: return ToolResult( content="Arguments invalides pour l'outil " f"{name} : {exc.errors(include_url=False)}. Corrige et réessaie.", error=True) t0 = time.perf_counter() try: result = await spec.fn(args.model_dump(), 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}", error=True) result.meta["duration_ms"] = int((time.perf_counter() - t0) * 1000) return result registry = ToolRegistry()