SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
17.6 KB · 303 lines python
Raw Blame History
1"""Agentic tool loop: stream model output, execute tools (in parallel), loop, persist."""23from __future__ import annotations45import asyncio6import json7import time8from collections.abc import Awaitable, Callable9from datetime import date10from typing import Any1112from app.core.config import Settings, get_settings13from app.core.logging import get_logger14from app.core.security import hash_user_id15from app.llm.openrouter import LLMError, get_llm16from app.llm.prompts import load_prompt17from app.llm.router import router18from app.llm.schemas import ToolCallReq, ToolResult19from app.models import Conversation, Message, User, new_id20from app.services import analytics, costs21from app.services import conversations as conv_service22from app.services import courses as course_service23from app.services import files as file_service24from app.tools.all import registry25from app.tools.registry import ToolContext2627log = get_logger("agent")28Emit = Callable[[str, dict[str, Any]], Awaitable[None]]2930TOOL_HINTS = """## Outils31- `search_course_content` : matière du cours (à consulter d'abord).32- `appraisal_calc` : calculateurs d'évaluation déterministes (méthode du coût, ventilation de la dépréciation, coût indexé/unitaire, terrain : extraction/allocation/résiduelle/lotissement, capitalisation directe, MRB, grille de comparables, âge effectif par le marché).33- `financial_calc` : six fonctions du dollar, VAN/TRI, âge-vie simple.34- `unit_convert` : m² ↔ pi², arpent, acre, hectare, $/m² ↔ $/pi².35- `execute_python` : calculs libres, statistiques, régressions, graphiques élaborés (sandbox sans réseau ; les fichiers listés ci-dessous sont dans inputs/<nom>, y compris les classeurs déjà générés).36- `make_chart` : graphique rapide à partir de données (sans code).37- `create_excel` : nouveau classeur à formules vivantes (gabarits : comparables_ajustes, methode_du_cout, age_vie, tableau_amortissement, six_fonctions, sensibilite ; ou spec libre).38- `inspect_excel` puis `edit_excel` : lire et MODIFIER un classeur existant (déposé par l'étudiant ou généré plus tôt) — ajouter une colonne/ligne/feuille/graphique, corriger une cellule, formater. Toujours inspecter avant de modifier.39- `create_docx` : document Word (fiche de révision, plan d'étude, gabarit de structure de rapport).40- `analyze_file` : lire un fichier déposé (xlsx/csv/pdf/image/docx).41- `web_search` : données actuelles seulement (marché, taux, coûts, règlements).42- `generate_quiz` : quiz interactif.43Enchaîne plusieurs outils si nécessaire (ex. : search_course_content → appraisal_calc → create_excel → make_chart). Quand l'étudiant demande de changer un fichier existant, modifie-le avec edit_excel plutôt que d'en recréer un."""444546def build_system_prompt(course_code: str, user: User, course_extra: str, announcement: str,47                        files: list[dict[str, Any]], settings: Settings, deadlines: list) -> str:48    prefs = user.preferences or {}49    parts = [load_prompt("system_tutor"), load_prompt("guardrails"),50             load_prompt(f"course_{course_code.lower()}") or f"## Cours actif : {course_code}"]51    if course_extra:52        parts.append("## Consignes additionnelles du professeur\n" + course_extra)53    parts.append(TOOL_HINTS)54    ctx = [f"Date du jour : {date.today().isoformat()}. Trimestre : {settings.TERM_LABEL}.",55           f"Étudiant : {user.display_name or 'étudiant'} ; "56           f"{'tutoiement' if prefs.get('tutoiement', True) else 'vouvoiement'} ; "57           f"langue : {prefs.get('locale', 'fr-CA')}."]58    if deadlines:59        ctx.append("Échéances : " + "; ".join(f"{d.get('label')} ({d.get('date')})" for d in deadlines))60    if announcement:61        ctx.append(f"Annonce du professeur : {announcement}")62    if files:63        ctx.append("Fichiers de cette conversation (file_id — nom — origine) ; utilisables avec analyze_file, "64                   "inspect_excel/edit_excel, et disponibles dans inputs/<nom> pour execute_python :\n" +65                   "\n".join(f"- {f['id']} — {f['filename']} ({f['type']}, "66                             f"{'déposé par l’étudiant' if f.get('kind') == 'upload' else 'généré par toi'})"67                             for f in files))68    parts.append("## Contexte\n" + "\n".join(ctx))69    return "\n\n".join(p for p in parts if p)707172def history_to_messages(history: list[Message], max_chars: int = 60000) -> list[dict[str, Any]]:73    """Rebuild OpenAI-style messages, replaying tool calls compactly."""74    out: list[dict[str, Any]] = []75    for m in history:76        if m.role == "user":77            content = m.content78            if m.attachments:79                content += "\n\n(Fichiers joints : " + ", ".join(m.attachments) + ")"80            out.append({"role": "user", "content": content})81        elif m.role == "assistant":82            if m.tool_calls:83                calls = [{"id": t.id, "type": "function",84                          "function": {"name": t.name, "arguments": json.dumps(t.arguments,85                                                                                ensure_ascii=False)}}86                         for t in m.tool_calls]87                out.append({"role": "assistant", "content": None, "tool_calls": calls})88                for t in m.tool_calls:89                    out.append({"role": "tool", "tool_call_id": t.id,90                                "content": (t.result_summary or "(résultat)")[:1500]})91            if m.content:92                out.append({"role": "assistant", "content": m.content})93    # trim from the front to respect budget94    total = sum(len(json.dumps(x, ensure_ascii=False)) for x in out)95    while out and total > max_chars:96        dropped = out.pop(0)97        total -= len(json.dumps(dropped, ensure_ascii=False))98        # never start with a tool message99        while out and out[0]["role"] == "tool":100            total -= len(json.dumps(out.pop(0), ensure_ascii=False))101    return out102103104class Turn:105    def __init__(self, conversation: Conversation, user: User, emit: Emit,106                 deep: bool = False, settings: Settings | None = None) -> None:107        self.conv = conversation108        self.user = user109        self.emit = emit110        self.deep = deep111        self.settings = settings or get_settings()112        self.cancel = asyncio.Event()113        self.message_id = new_id()114        self.text_parts: list[str] = []115        self.tool_records: list[dict[str, Any]] = []116        self.usage_total = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "model": ""}117        self.model_used = ""118119    # ------------------------------------------------------------------ helpers120    async def _tool_ctx(self, tool_call_id: str, file_ids: list[str]) -> ToolContext:121        async def progress(status: str, detail: str) -> None:122            await self.emit("tool_progress", {"id": tool_call_id, "status": status, "detail": detail})123124        return ToolContext(user_id=self.user.id, user_id_hash=hash_user_id(self.user.id),125                           conversation_id=self.conv.id, course_code=self.conv.course_code,126                           settings=self.settings, role=self.user.role, file_ids=file_ids,127                           progress=progress)128129    async def _run_tool(self, call: ToolCallReq, file_ids: list[str]) -> tuple[ToolCallReq, ToolResult]:130        args = call.arguments()131        preview = json.dumps({k: v for k, v in args.items() if not k.startswith("__")},132                             ensure_ascii=False)[:200]133        await self.emit("tool_call", {"id": call.id, "name": call.name, "args_preview": preview,134                                      "arguments": {k: v for k, v in args.items()135                                                    if not k.startswith("__")}})136        ctx = await self._tool_ctx(call.id, file_ids)137        if call.truncated:138            result = ToolResult(139                content=f"Appel {call.name} interrompu : tes arguments ont dépassé la limite de "140                        f"longueur de sortie ({len(call.arguments_json)} caractères reçus). "141                        "Refais un appel plus court : pour create_excel utilise un `template` ou "142                        "réduis le nombre de lignes/feuilles (ou fais une feuille par appel) ; pour "143                        "execute_python écris un code plus concis. N'annonce rien à l'étudiant avant "144                        "que l'appel réussisse.", error=True)145        else:146            result = await registry.run(call.name, args, ctx)147        artifacts = [a.to_dict() for a in result.artifacts]148        await self.emit("tool_result", {"id": call.id, "name": call.name,149                                        "summary": result.summary(), "artifacts": artifacts,150                                        "payload": result.payload, "status": "error" if result.error else "ok",151                                        "duration_ms": result.meta.get("duration_ms", 0)})152        self.tool_records.append({"id": call.id, "name": call.name, "arguments": args,153                                  "summary": result.summary(600), "payload": result.payload,154                                  "status": "error" if result.error else "ok",155                                  "duration_ms": result.meta.get("duration_ms", 0)})156        asyncio.create_task(analytics.record_event(hash_user_id(self.user.id),157                                                   self.conv.course_code, "tool", call.name))158        return call, result159160    # ------------------------------------------------------------------ main loop161    async def run(self, user_text: str, attachments: list[str]) -> None:162        t0 = time.perf_counter()163        course = await course_service.get_course(self.conv.course_code)164        files = await file_service.list_for_conversation(self.conv.id)165        file_dicts = [{"id": f.id, "filename": f.filename, "type": f.type, "kind": f.kind}166                      for f in files if f.kind == "upload" or f.type in {"xlsx", "csv", "docx"}]167        file_dicts = file_dicts[-16:]168        file_ids = [f["id"] for f in file_dicts if f["kind"] == "upload" or f["type"] in {"xlsx", "csv"}]169        system = build_system_prompt(170            self.conv.course_code, self.user, course.extra_system_prompt if course else "",171            course.announcement if course else "", file_dicts, self.settings,172            (course.syllabus.get("deadlines") if course else []) or [])173        history = await conv_service.history(self.conv.id, limit=40)174        messages: list[dict[str, Any]] = [{"role": "system", "content": system}]175        messages += history_to_messages(history)176        # the freshly stored user message is already in history; make sure it is the last one177        if not messages or messages[-1].get("role") != "user":178            messages.append({"role": "user", "content": user_text})179180        enabled = None181        if course and (course.settings or {}).get("tools_enabled"):182            enabled = set(course.settings["tools_enabled"])183        tools = registry.openai_tools(enabled)184        if course and (course.settings or {}).get("model_primary"):185            router.overrides["primary"] = course.settings["model_primary"]186        budget = await costs.budget_status((course.settings or {}).get("budget_usd") if course else None)187        plan = router.plan("tutor", deep=self.deep)188        if budget["exceeded"]:189            await self.emit("warning", {"code": "budget", "message_fr":190                                        "Budget mensuel atteint : modèle économique utilisé."})191        await self.emit("message_start", {"message_id": self.message_id, "model": plan.models[0]})192193        llm = get_llm()194        user_hash = hash_user_id(self.user.id)195        finish = "stop"196        try:197            for _iteration in range(self.settings.LLM_MAX_TOOL_ITERATIONS + 1):198                if self.cancel.is_set():199                    finish = "cancelled"200                    break201                pending: dict[int, ToolCallReq] = {}202                finish = "stop"203                call_t0 = time.perf_counter()204                iteration_text: list[str] = []205                async for ev in llm.stream_chat(messages, tools, plan.models, plan.temperature,206                                                reasoning=plan.reasoning, user_id_hash=user_hash):207                    if self.cancel.is_set():208                        finish = "cancelled"209                        break210                    if ev.type == "text_delta":211                        iteration_text.append(ev.data["delta"])212                        self.text_parts.append(ev.data["delta"])213                        await self.emit("text_delta", {"delta": ev.data["delta"]})214                    elif ev.type == "tool_call_end":215                        pending[ev.data["index"]] = ToolCallReq(216                            id=ev.data["id"] or f"call_{new_id()[:8]}", name=ev.data["name"],217                            arguments_json=ev.data["arguments"], index=ev.data["index"])218                    elif ev.type == "usage":219                        for k in ("input_tokens", "output_tokens", "cost_usd"):220                            self.usage_total[k] += ev.data.get(k, 0)221                        self.model_used = ev.data.get("model") or self.model_used222                        asyncio.create_task(costs.record_usage(223                            "tutor", self.conv.course_code, ev.data,224                            int((time.perf_counter() - call_t0) * 1000)))225                    elif ev.type == "error":226                        raise LLMError(ev.data.get("message", "erreur LLM"))227                    elif ev.type == "done":228                        finish = ev.data.get("finish_reason") or "stop"229                        self.model_used = ev.data.get("model") or self.model_used230                if finish == "cancelled":231                    break232                if finish == "length" and pending:233                    # the last tool call was cut by the output limit: never run a truncated call234                    last = max(pending)235                    pending[last].truncated = True236                    log.warning("tool_call_truncated", tool=pending[last].name,237                                chars=len(pending[last].arguments_json))238                if not pending:239                    if finish == "length":240                        await self.emit("warning", {"code": "length", "message_fr":241                                                    "Réponse coupée (limite de longueur)."})242                    break243                # append assistant tool-call message, run tools in parallel, append results244                calls = sorted(pending.values(), key=lambda c: c.index)245                messages.append({"role": "assistant",246                                 "content": "".join(iteration_text) or None,247                                 "tool_calls": [{"id": c.id, "type": "function",248                                                 "function": {"name": c.name,249                                                              "arguments": c.arguments_json or "{}"}}250                                                for c in calls]})251                results = await asyncio.gather(*(self._run_tool(c, file_ids) for c in calls))252                for call, result in results:253                    messages.append({"role": "tool", "tool_call_id": call.id,254                                     "content": result.content[:24000]})255                if iteration_text:256                    self.text_parts.append("\n\n")257                    await self.emit("text_delta", {"delta": "\n\n"})258            else:259                await self.emit("warning", {"code": "max_iterations",260                                            "message_fr": "Nombre maximal d'étapes atteint."})261        except LLMError as exc:262            log.error("llm_error", error=str(exc))263            await self.emit("error", {"code": "llm", "message_fr":264                                      "Le modèle n'a pas pu répondre. Réessaie dans un instant."})265            finish = "error"266        except asyncio.CancelledError:267            finish = "cancelled"268        finally:269            content = "".join(self.text_parts).strip()270            latency = int((time.perf_counter() - t0) * 1000)271            if content or self.tool_records:272                try:273                    await asyncio.shield(conv_service.save_assistant_message(274                        self.conv.id, self.message_id, content, self.model_used or plan.models[0],275                        self.usage_total, latency, self.tool_records))276                except Exception as exc:  # noqa: BLE001277                    log.warning("persist_failed", error=str(exc))278            try:279                await self.emit("usage", {**self.usage_total, "model_used": self.model_used,280                                          "latency_ms": latency})281                await self.emit("done", {"message_id": self.message_id, "finish_reason": finish})282            except Exception:  # noqa: BLE001 — client already gone283                pass284285286# ------------------------------------------------------------------ side tasks287async def generate_title(conv_id: str, first_message: str, emit: Emit | None = None) -> str | None:288    try:289        plan = router.plan("fast")290        text, usage = await get_llm().complete(291            [{"role": "system", "content": load_prompt("title_generator")},292             {"role": "user", "content": first_message[:1500]}], plan.models, 0.0, 30)293        title = text.strip().strip('"').strip("«»").strip()[:80]294        if title:295            await conv_service.set_title(conv_id, title)296            asyncio.create_task(costs.record_usage("title", "", usage, 0))297            if emit:298                await emit("title", {"conversation_id": conv_id, "title": title})299        return title300    except Exception as exc:  # noqa: BLE001301        log.warning("title_failed", error=str(exc))302        return None303