SPB Git

spb/uqo-chat

Public
3.4 KB · 102 lines python
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
Files · afb5ca448565f356efc1532400a8ac8b8eba5d6a
Menu

Theme