Python 64.6%
TypeScript 33.7%
CSS 0.8%
1"""make_chart — render a chart PNG from data (no code execution), UQO palette."""23from __future__ import annotations45import io6from typing import Any, Literal78from pydantic import BaseModel, Field910from app.llm.schemas import Artifact, ToolResult11from app.services import files as file_service12from app.tools.coerce import num13from app.tools.registry import ToolContext, registry1415PALETTE = ["#0F6180", "#78BE20", "#C6A300", "#5B6B7B", "#7FA7C9", "#C8102E", "#2E8B57", "#8B5CF6"]161718class Series(BaseModel):19 name: str = ""20 values: list[float | str | None]212223class Args(BaseModel):24 type: Literal["bar", "line", "pie", "scatter", "hbar", "area", "stacked_bar"] = "bar"25 title: str = ""26 x: list[str | float] = Field(default_factory=list, description="catégories / abscisses")27 series: list[Series]28 xlabel: str = ""29 ylabel: str = ""30 value_format: Literal["number", "currency", "percent"] = "number"31 filename: str = "graphique.png"32 annotate: bool = True333435def _fmt(kind: str): # noqa: ANN20236 from matplotlib.ticker import FuncFormatter3738 if kind == "currency":39 return FuncFormatter(lambda v, _: f"{v:,.0f} $".replace(",", " "))40 if kind == "percent":41 return FuncFormatter(lambda v, _: f"{v * 100:.1f} %")42 return FuncFormatter(lambda v, _: f"{v:,.0f}".replace(",", " ") if abs(v) >= 100 else f"{v:g}")434445def render(args: dict[str, Any]) -> bytes:46 import matplotlib4748 matplotlib.use("Agg")49 import matplotlib.pyplot as plt50 import numpy as np5152 series = [(s["name"], [num(v, 0.0) or 0.0 for v in s["values"]]) for s in args["series"]]53 x = args.get("x") or list(range(1, (len(series[0][1]) if series else 0) + 1))54 labels = [str(v) for v in x]55 fig, ax = plt.subplots(figsize=(8, 4.5), dpi=150)56 kind = args["type"]57 n = len(series)58 idx = np.arange(len(labels))59 if kind == "pie" and series:60 vals = series[0][1]61 ax.pie(vals, labels=labels, colors=PALETTE[: len(vals)], autopct="%1.1f %%", startangle=90,62 wedgeprops={"linewidth": 1, "edgecolor": "white"}, textprops={"fontsize": 9})63 ax.axis("equal")64 elif kind in {"bar", "stacked_bar"}:65 width = 0.8 / (1 if kind == "stacked_bar" else max(1, n))66 bottom = np.zeros(len(labels))67 for i, (name, vals) in enumerate(series):68 vals_a = np.array(vals[: len(labels)] + [0.0] * (len(labels) - len(vals)))69 if kind == "stacked_bar":70 bars = ax.bar(idx, vals_a, 0.6, bottom=bottom, label=name or None, color=PALETTE[i % len(PALETTE)])71 bottom += vals_a72 else:73 bars = ax.bar(idx + (i - (n - 1) / 2) * width, vals_a, width, label=name or None, color=PALETTE[i % len(PALETTE)])74 if args.get("annotate", True) and len(labels) <= 12 and kind == "bar":75 ax.bar_label(bars, labels=[_fmt(args["value_format"])(v, None) for v in vals_a], fontsize=8, padding=2)76 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)77 ax.yaxis.set_major_formatter(_fmt(args["value_format"]))78 elif kind == "hbar":79 height = 0.8 / max(1, n)80 for i, (name, vals) in enumerate(series):81 vals_a = np.array(vals[: len(labels)] + [0.0] * (len(labels) - len(vals)))82 bars = ax.barh(idx + (i - (n - 1) / 2) * height, vals_a, height, label=name or None, color=PALETTE[i % len(PALETTE)])83 if args.get("annotate", True) and len(labels) <= 12:84 ax.bar_label(bars, labels=[_fmt(args["value_format"])(v, None) for v in vals_a], fontsize=8, padding=2)85 ax.set_yticks(idx, labels, fontsize=9)86 ax.invert_yaxis()87 ax.xaxis.set_major_formatter(_fmt(args["value_format"]))88 elif kind == "scatter":89 xs = [num(v, 0.0) or 0.0 for v in x]90 for i, (name, vals) in enumerate(series):91 ax.scatter(xs[: len(vals)], vals, label=name or None, color=PALETTE[i % len(PALETTE)], s=36)92 if len(vals) >= 3:93 coef = np.polyfit(xs[: len(vals)], vals, 1)94 xx = np.linspace(min(xs), max(xs), 50)95 ax.plot(xx, coef[0] * xx + coef[1], color=PALETTE[i % len(PALETTE)], alpha=0.6, linestyle="--",96 label=f"tendance {name}".strip())97 ax.yaxis.set_major_formatter(_fmt(args["value_format"]))98 else: # line / area99 for i, (name, vals) in enumerate(series):100 ax.plot(idx[: len(vals)], vals, marker="o", markersize=3.5, linewidth=2, label=name or None, color=PALETTE[i % len(PALETTE)])101 if kind == "area":102 ax.fill_between(idx[: len(vals)], vals, alpha=0.15, color=PALETTE[i % len(PALETTE)])103 step = max(1, len(labels) // 12)104 ax.set_xticks(idx[::step], labels[::step], fontsize=9, rotation=0 if max(len(s) for s in labels) < 8 else 30)105 ax.yaxis.set_major_formatter(_fmt(args["value_format"]))106 if kind != "pie":107 ax.grid(axis="y" if kind != "hbar" else "x", alpha=0.3)108 ax.spines[["top", "right"]].set_visible(False)109 if args.get("xlabel"):110 ax.set_xlabel(args["xlabel"], fontsize=10)111 if args.get("ylabel"):112 ax.set_ylabel(args["ylabel"], fontsize=10)113 if n > 1 or any(s[0] for s in series):114 ax.legend(frameon=False, fontsize=9)115 if args.get("title"):116 ax.set_title(args["title"], fontsize=12, fontweight="bold", color="#0A4A63", loc="left")117 fig.text(0.99, 0.01, "UQO-Chat · outil pédagogique", ha="right", va="bottom", fontsize=7, color="#5B6B7B")118 fig.tight_layout()119 buf = io.BytesIO()120 fig.savefig(buf, format="png", bbox_inches="tight", facecolor="white")121 plt.close(fig)122 return buf.getvalue()123124125async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult:126 if not args.get("series"):127 return ToolResult(content="Fournis au moins une série de valeurs.", error=True)128 await ctx.report("running", "Tracé du graphique…")129 png = render(args)130 name = args.get("filename") or "graphique.png"131 if not name.lower().endswith(".png"):132 name += ".png"133 rec = await file_service.store_artifact(ctx.user_id, ctx.conversation_id, name, png, ftype="image")134 art = Artifact(type="image", file_id=rec.id, filename=rec.filename, url=f"/api/v1/files/{rec.id}")135 return ToolResult(content=f"Graphique « {args.get('title') or name} » créé et affiché à l'étudiant "136 f"({args['type']}, {len(args['series'])} série(s), {len(args.get('x') or [])} points).",137 artifacts=[art],138 payload={"title": args.get("title", ""), "type": args["type"], "file_id": rec.id,139 "filename": rec.filename, "artifacts": [art.to_dict()]},140 meta={"summary": f"Graphique : {args.get('title') or args['type']}"})141142143registry.register("make_chart", run, Args, heavy=True)144