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%
6.9 KB · 144 lines python
Raw Blame History
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