Python 64.6%
TypeScript 33.7%
CSS 0.8%
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