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%
7.5 KB · 157 lines python
Raw Blame History
1"""Normalise a free-form `create_excel` spec so that almost anything the model sends renders."""23from __future__ import annotations45import re6from typing import Any78from openpyxl.utils import get_column_letter9from openpyxl.utils.cell import coordinate_from_string1011from app.tools.coerce import cell1213TYPE_ALIASES = {14    "money": "currency", "cad": "currency", "$": "currency", "dollar": "currency", "dollars": "currency",15    "pct": "percent", "%": "percent", "percentage": "percent", "pourcentage": "percent",16    "int": "integer", "entier": "integer", "float": "number", "decimal": "number", "num": "number",17    "string": "text", "str": "text", "texte": "text", "date": "text", "m2": "area", "m²": "area",18    "superficie": "area", "ratio": "factor", "facteur": "factor",19}202122def _type(t: Any) -> str:23    s = str(t or "text").strip().lower()24    return TYPE_ALIASES.get(s, s if s in {"currency", "percent", "number", "integer", "area", "factor", "text"} else "text")252627def _guess_type(header: str, values: list[Any]) -> str:28    h = header.lower()29    if any(k in h for k in ("$", "prix", "coût", "cout", "montant", "valeur", "loyer", "revenu", "dépense")):30        return "currency"31    if "%" in h or "taux" in h or "pourcent" in h:32        return "percent"33    if any(k in h for k in ("m²", "m2", "pi²", "superficie")):34        return "area"35    if any(k in h for k in ("année", "annee", "an", "n°", "no", "rang", "période", "periode")) and all(36            isinstance(v, (int, float)) and float(v).is_integer() for v in values if v not in (None, "")):37        return "integer"38    if values and all(isinstance(v, (int, float)) for v in values if v not in (None, "")):39        return "number"40    return "text"414243def _columns(raw: Any, rows: list[list[Any]]) -> list[dict[str, Any]]:44    cols: list[dict[str, Any]] = []45    if isinstance(raw, list):46        for c in raw:47            if isinstance(c, dict):48                cols.append({"header": str(c.get("header") or c.get("name") or c.get("label") or ""),49                             "type": _type(c.get("type") or c.get("format"))})50            else:51                cols.append({"header": str(c), "type": None})52    width = max([len(cols)] + [len(r) for r in rows]) if (cols or rows) else 053    while len(cols) < width:54        cols.append({"header": f"Col {len(cols) + 1}", "type": None})55    for j, c in enumerate(cols):56        if c["type"] is None:57            c["type"] = _guess_type(c["header"], [r[j] for r in rows if j < len(r)])58    return cols596061def _rows(raw: Any, columns_raw: Any) -> list[list[Any]]:62    rows: list[list[Any]] = []63    headers = []64    if isinstance(columns_raw, list):65        headers = [str(c.get("header") or c.get("name") or c.get("label") or "") if isinstance(c, dict)66                   else str(c) for c in columns_raw]67    for r in raw or []:68        if isinstance(r, dict):69            if headers:70                keys = {k.lower(): k for k in r}71                row = [cell(r.get(h) if h in r else r.get(keys.get(h.lower(), ""), "")) for h in headers]72                # keep extra keys not in headers73                for k, v in r.items():74                    if k not in headers and k.lower() not in {h.lower() for h in headers}:75                        row.append(cell(v))76            else:77                row = [cell(v) for v in r.values()]78        elif isinstance(r, (list, tuple)):79            row = [cell(v) for v in r]80        else:81            row = [cell(r)]82        rows.append(row)83    return rows848586def _valid_anchor(a: Any) -> str | None:87    if not isinstance(a, str) or not re.fullmatch(r"[A-Za-z]{1,3}\d{1,6}", a.strip()):88        return None89    return a.strip().upper()909192def normalise_spec(spec: dict[str, Any]) -> dict[str, Any]:93    out: dict[str, Any] = {"filename": spec.get("filename"), "style": "uqo",94                           "objective": spec.get("objective") or spec.get("objectif") or spec.get("title"),95                           "hypotheses": spec.get("hypotheses") or spec.get("hypothèses") or [], "sheets": []}96    sheets = spec.get("sheets") or spec.get("feuilles") or []97    if not sheets and (spec.get("tables") or spec.get("rows") or spec.get("columns")):98        sheets = [spec]99    for si, sh in enumerate(sheets):100        if not isinstance(sh, dict):101            continue102        tables_raw = sh.get("tables") or sh.get("tableaux") or []103        if not tables_raw and (sh.get("rows") or sh.get("columns") or sh.get("data")):104            tables_raw = [{"columns": sh.get("columns"), "rows": sh.get("rows") or sh.get("data"),105                           "totals": sh.get("totals")}]106        inputs = []107        for inp in sh.get("inputs") or sh.get("hypotheses") or []:108            if not isinstance(inp, dict):109                continue110            c = _valid_anchor(inp.get("cell"))111            if not c:112                continue113            v = inp.get("value")114            if isinstance(v, str) and v.startswith("="):115                pass116            else:117                v = cell(v)118            inputs.append({"cell": c, "label": str(inp.get("label", "")), "value": v,119                           "format": _type(inp.get("format") or inp.get("type") or "number"),120                           "name": inp.get("name")})121        next_row = 4122        if inputs:123            next_row = max(coordinate_from_string(i["cell"])[1] for i in inputs) + 2124        tables = []125        for t in tables_raw:126            if not isinstance(t, dict):127                continue128            rows = _rows(t.get("rows") or t.get("data") or t.get("lignes"), t.get("columns") or t.get("colonnes"))129            cols = _columns(t.get("columns") or t.get("colonnes"), rows)130            anchor = _valid_anchor(t.get("anchor")) or f"A{next_row}"131            totals = t.get("totals") or t.get("total")132            if isinstance(totals, dict):133                totals = {"label": str(totals.get("label", "Total")), "formula": totals.get("formula") or totals.get("value"),134                          "format": _type(totals.get("format")) if totals.get("format") else None, "name": totals.get("name")}135                if totals["format"] is None:136                    totals.pop("format")137            else:138                totals = None139            tables.append({"anchor": anchor, "columns": cols, "rows": rows, "totals": totals,140                           "row_formats": t.get("row_formats") or {}, "bold_rows": t.get("bold_rows") or [],141                           "first_col_format": t.get("first_col_format")})142            _, r0 = coordinate_from_string(anchor)143            next_row = max(next_row, r0 + 1 + len(rows) + (1 if totals else 0) + 2)144        charts = []145        for ch in sh.get("charts") or sh.get("graphiques") or []:146            if isinstance(ch, dict):147                charts.append({"type": str(ch.get("type", "bar")).lower(), "title": str(ch.get("title", "")),148                               "categories_range": ch.get("categories_range") or ch.get("categories"),149                               "values_range": ch.get("values_range") or ch.get("data_range") or ch.get("values"),150                               "anchor": _valid_anchor(ch.get("anchor")) or f"{get_column_letter(8)}4"})151        notes = [str(n) for n in (sh.get("notes") or []) if n]152        out["sheets"].append({"name": str(sh.get("name") or sh.get("nom") or f"Feuille {si + 1}"),153                              "title": str(sh.get("title") or sh.get("titre") or sh.get("name") or ""),154                              "inputs_title": sh.get("inputs_title"), "inputs": inputs, "tables": tables,155                              "charts": charts, "notes": notes})156    return out157