"""make_chart — render a chart PNG from data (no code execution), UQO palette.""" from __future__ import annotations import io from typing import Any, Literal from pydantic import BaseModel, Field from app.llm.schemas import Artifact, ToolResult from app.services import files as file_service from app.tools.coerce import num from app.tools.registry import ToolContext, registry PALETTE = ["#0F6180", "#78BE20", "#C6A300", "#5B6B7B", "#7FA7C9", "#C8102E", "#2E8B57", "#8B5CF6"] class Series(BaseModel): name: str = "" values: list[float | str | None] class Args(BaseModel): type: Literal["bar", "line", "pie", "scatter", "hbar", "area", "stacked_bar"] = "bar" title: str = "" x: list[str | float] = Field(default_factory=list, description="catégories / abscisses") series: list[Series] xlabel: str = "" ylabel: str = "" value_format: Literal["number", "currency", "percent"] = "number" filename: str = "graphique.png" annotate: bool = True def _fmt(kind: str): # noqa: ANN202 from matplotlib.ticker import FuncFormatter if kind == "currency": return FuncFormatter(lambda v, _: f"{v:,.0f} $".replace(",", " ")) if kind == "percent": return FuncFormatter(lambda v, _: f"{v * 100:.1f} %") return FuncFormatter(lambda v, _: f"{v:,.0f}".replace(",", " ") if abs(v) >= 100 else f"{v:g}") def render(args: dict[str, Any]) -> bytes: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np series = [(s["name"], [num(v, 0.0) or 0.0 for v in s["values"]]) for s in args["series"]] x = args.get("x") or list(range(1, (len(series[0][1]) if series else 0) + 1)) labels = [str(v) for v in x] fig, ax = plt.subplots(figsize=(8, 4.5), dpi=150) kind = args["type"] n = len(series) idx = np.arange(len(labels)) if kind == "pie" and series: vals = series[0][1] ax.pie(vals, labels=labels, colors=PALETTE[: len(vals)], autopct="%1.1f %%", startangle=90, wedgeprops={"linewidth": 1, "edgecolor": "white"}, textprops={"fontsize": 9}) ax.axis("equal") elif kind in {"bar", "stacked_bar"}: width = 0.8 / (1 if kind == "stacked_bar" else max(1, n)) bottom = np.zeros(len(labels)) for i, (name, vals) in enumerate(series): vals_a = np.array(vals[: len(labels)] + [0.0] * (len(labels) - len(vals))) if kind == "stacked_bar": bars = ax.bar(idx, vals_a, 0.6, bottom=bottom, label=name or None, color=PALETTE[i % len(PALETTE)]) bottom += vals_a else: bars = ax.bar(idx + (i - (n - 1) / 2) * width, vals_a, width, label=name or None, color=PALETTE[i % len(PALETTE)]) if args.get("annotate", True) and len(labels) <= 12 and kind == "bar": ax.bar_label(bars, labels=[_fmt(args["value_format"])(v, None) for v in vals_a], fontsize=8, padding=2) ax.set_xticks(idx, labels, rotation=0 if max(len(s) for s in labels) < 12 else 25, ha="center" if max(len(s) for s in labels) < 12 else "right", fontsize=9) ax.yaxis.set_major_formatter(_fmt(args["value_format"])) elif kind == "hbar": height = 0.8 / max(1, n) for i, (name, vals) in enumerate(series): vals_a = np.array(vals[: len(labels)] + [0.0] * (len(labels) - len(vals))) bars = ax.barh(idx + (i - (n - 1) / 2) * height, vals_a, height, label=name or None, color=PALETTE[i % len(PALETTE)]) if args.get("annotate", True) and len(labels) <= 12: ax.bar_label(bars, labels=[_fmt(args["value_format"])(v, None) for v in vals_a], fontsize=8, padding=2) ax.set_yticks(idx, labels, fontsize=9) ax.invert_yaxis() ax.xaxis.set_major_formatter(_fmt(args["value_format"])) elif kind == "scatter": xs = [num(v, 0.0) or 0.0 for v in x] for i, (name, vals) in enumerate(series): ax.scatter(xs[: len(vals)], vals, label=name or None, color=PALETTE[i % len(PALETTE)], s=36) if len(vals) >= 3: coef = np.polyfit(xs[: len(vals)], vals, 1) xx = np.linspace(min(xs), max(xs), 50) ax.plot(xx, coef[0] * xx + coef[1], color=PALETTE[i % len(PALETTE)], alpha=0.6, linestyle="--", label=f"tendance {name}".strip()) ax.yaxis.set_major_formatter(_fmt(args["value_format"])) else: # line / area for i, (name, vals) in enumerate(series): ax.plot(idx[: len(vals)], vals, marker="o", markersize=3.5, linewidth=2, label=name or None, color=PALETTE[i % len(PALETTE)]) if kind == "area": ax.fill_between(idx[: len(vals)], vals, alpha=0.15, color=PALETTE[i % len(PALETTE)]) step = max(1, len(labels) // 12) ax.set_xticks(idx[::step], labels[::step], fontsize=9, rotation=0 if max(len(s) for s in labels) < 8 else 30) ax.yaxis.set_major_formatter(_fmt(args["value_format"])) if kind != "pie": ax.grid(axis="y" if kind != "hbar" else "x", alpha=0.3) ax.spines[["top", "right"]].set_visible(False) if args.get("xlabel"): ax.set_xlabel(args["xlabel"], fontsize=10) if args.get("ylabel"): ax.set_ylabel(args["ylabel"], fontsize=10) if n > 1 or any(s[0] for s in series): ax.legend(frameon=False, fontsize=9) if args.get("title"): ax.set_title(args["title"], fontsize=12, fontweight="bold", color="#0A4A63", loc="left") fig.text(0.99, 0.01, "UQO-Chat · outil pédagogique", ha="right", va="bottom", fontsize=7, color="#5B6B7B") fig.tight_layout() buf = io.BytesIO() fig.savefig(buf, format="png", bbox_inches="tight", facecolor="white") plt.close(fig) return buf.getvalue() async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult: if not args.get("series"): return ToolResult(content="Fournis au moins une série de valeurs.", error=True) await ctx.report("running", "Tracé du graphique…") png = render(args) name = args.get("filename") or "graphique.png" if not name.lower().endswith(".png"): name += ".png" rec = await file_service.store_artifact(ctx.user_id, ctx.conversation_id, name, png, ftype="image") art = Artifact(type="image", file_id=rec.id, filename=rec.filename, url=f"/api/v1/files/{rec.id}") return ToolResult(content=f"Graphique « {args.get('title') or name} » créé et affiché à l'étudiant " f"({args['type']}, {len(args['series'])} série(s), {len(args.get('x') or [])} points).", artifacts=[art], payload={"title": args.get("title", ""), "type": args["type"], "file_id": rec.id, "filename": rec.filename, "artifacts": [art.to_dict()]}, meta={"summary": f"Graphique : {args.get('title') or args['type']}"}) registry.register("make_chart", run, Args, heavy=True)