feat: 5 new tools (inspect/edit Excel, appraisal calculators, unit conversion, charts, Word docs), artifacts available to Python sandbox, redesigned tool cards (Python editor with run, multi-sheet Excel preview, 'Modifier avec le tuteur')
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
31 changed files +1,905 −90
modified
backend/app/api/v1/files.py
+1 −1
@@ -55,7 +55,7 @@ async def download(file_id: str, request: Request, sig: str | None = Query(None) | ||
| 55 | 55 | data = svc.read_bytes(rec) |
| 56 | 56 | except FileNotFoundError as exc: |
| 57 | 57 | raise HTTPException(status.HTTP_410_GONE, detail="Fichier expiré.") from exc |
| 58 | − disp = "attachment" if download or rec.type in {"xlsx", "file", "csv"} else "inline" | |
| 58 | + disp = "attachment" if download or rec.type in {"xlsx", "file", "csv", "docx"} else "inline" | |
| 59 | 59 | from urllib.parse import quote |
| 60 | 60 | |
| 61 | 61 | headers = {"Content-Disposition": f"{disp}; filename*=UTF-8''{quote(rec.filename)}", |
modified
backend/app/llm/agent.py
+19 −10
@@ -29,13 +29,18 @@ Emit = Callable[[str, dict[str, Any]], Awaitable[None]] | ||
| 29 | 29 | |
| 30 | 30 | TOOL_HINTS = """## Outils |
| 31 | 31 | - `search_course_content` : matière du cours (à consulter d'abord). |
| 32 | −- `execute_python` : calculs, statistiques, graphiques (sandbox, sans réseau). | |
| 33 | −- `financial_calc` : six fonctions du dollar, âge-vie, coût unitaire, extraction du marché. | |
| 34 | −- `create_excel` : classeur à formules vivantes (gabarits : methode_du_cout, comparables_ajustes, age_vie, tableau_amortissement, six_fonctions, sensibilite). | |
| 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). | |
| 35 | 41 | - `web_search` : données actuelles seulement (marché, taux, coûts, règlements). |
| 36 | −- `analyze_file` : fichiers déposés (identifiants ci-dessous). | |
| 37 | 42 | - `generate_quiz` : quiz interactif. |
| 38 | −Enchaîne plusieurs outils si nécessaire (ex. : search_course_content puis execute_python puis create_excel).""" | |
| 43 | +Enchaî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.""" | |
| 39 | 44 | |
| 40 | 45 | |
| 41 | 46 | def build_system_prompt(course_code: str, user: User, course_extra: str, announcement: str, |
@@ -55,8 +60,11 @@ def build_system_prompt(course_code: str, user: User, course_extra: str, announc | ||
| 55 | 60 | if announcement: |
| 56 | 61 | ctx.append(f"Annonce du professeur : {announcement}") |
| 57 | 62 | if files: |
| 58 | − ctx.append("Fichiers déposés dans cette conversation (file_id — nom) :\n" + | |
| 59 | − "\n".join(f"- {f['id']} — {f['filename']} ({f['type']})" for f in 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)) | |
| 60 | 68 | parts.append("## Contexte\n" + "\n".join(ctx)) |
| 61 | 69 | return "\n\n".join(p for p in parts if p) |
| 62 | 70 | |
@@ -154,9 +162,10 @@ class Turn: | ||
| 154 | 162 | t0 = time.perf_counter() |
| 155 | 163 | course = await course_service.get_course(self.conv.course_code) |
| 156 | 164 | files = await file_service.list_for_conversation(self.conv.id) |
| 157 | − file_dicts = [{"id": f.id, "filename": f.filename, "type": f.type} for f in files | |
| 158 | − if f.kind == "upload"] | |
| 159 | − file_ids = [f["id"] for f in file_dicts] | |
| 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"}] | |
| 160 | 169 | system = build_system_prompt( |
| 161 | 170 | self.conv.course_code, self.user, course.extra_system_prompt if course else "", |
| 162 | 171 | course.announcement if course else "", file_dicts, self.settings, |
modified
backend/app/services/files.py
+1 −1
@@ -32,7 +32,7 @@ ALLOWED_UPLOAD = { | ||
| 32 | 32 | } |
| 33 | 33 | MIME_BY_TYPE = { |
| 34 | 34 | "xlsx": ALLOWED_UPLOAD["xlsx"], "image": "image/png", "csv": "text/csv", |
| 35 | − "pdf": "application/pdf", "file": "application/octet-stream", | |
| 35 | + "pdf": "application/pdf", "file": "application/octet-stream", "docx": ALLOWED_UPLOAD["docx"], | |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | 38 | |
modified
backend/app/tools/all.py
+5 −0
@@ -2,11 +2,16 @@ | ||
| 2 | 2 | |
| 3 | 3 | from app.tools import ( # noqa: F401 |
| 4 | 4 | analyze_file, |
| 5 | + appraisal_calc, | |
| 6 | + create_docx, | |
| 5 | 7 | create_excel, |
| 8 | + edit_excel, | |
| 6 | 9 | execute_python, |
| 7 | 10 | financial_calc, |
| 8 | 11 | generate_quiz, |
| 12 | + make_chart, | |
| 9 | 13 | search_course, |
| 14 | + unit_convert, | |
| 10 | 15 | web_search, |
| 11 | 16 | ) |
| 12 | 17 | from app.tools.registry import registry |
added
backend/app/tools/appraisal_calc.py
+343 −0
@@ -0,0 +1,343 @@ | ||
| 1 | +"""appraisal_calc — deterministic appraisal calculators (cost, land, depreciation, income, grids).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import statistics | |
| 6 | +from typing import Any, Literal | |
| 7 | + | |
| 8 | +from pydantic import BaseModel, Field | |
| 9 | + | |
| 10 | +from app.llm.schemas import ToolResult | |
| 11 | +from app.tools.coerce import boolish, num, pct | |
| 12 | +from app.tools.registry import ToolContext, registry | |
| 13 | + | |
| 14 | +Function = Literal[ | |
| 15 | + "cost_approach", "breakdown_depreciation", "indexed_cost", "unit_cost_estimate", | |
| 16 | + "land_extraction", "land_allocation", "land_residual", "land_subdivision", | |
| 17 | + "direct_capitalization", "gross_income_multiplier", "adjust_comparables", "effective_age_market", | |
| 18 | +] | |
| 19 | + | |
| 20 | +QUALITY = ["très mauvais", "mauvais", "passable", "moyen", "bon", "très bon", "excellent", "neuf"] | |
| 21 | + | |
| 22 | + | |
| 23 | +class Args(BaseModel): | |
| 24 | + function: Function | |
| 25 | + params: dict[str, Any] = Field(default_factory=dict) | |
| 26 | + | |
| 27 | + | |
| 28 | +def _n(p: dict[str, Any], key: str, default: float | None = None, *, required: bool = False) -> float: | |
| 29 | + v = num(p.get(key), None) | |
| 30 | + if v is None: | |
| 31 | + if required: | |
| 32 | + raise KeyError(key) | |
| 33 | + return float(default or 0.0) | |
| 34 | + return v | |
| 35 | + | |
| 36 | + | |
| 37 | +def _p(p: dict[str, Any], key: str, default: float = 0.0) -> float: | |
| 38 | + v = pct(p.get(key), None) | |
| 39 | + return float(default) if v is None else v | |
| 40 | + | |
| 41 | + | |
| 42 | +def _rank(v: Any, scale: list[str] | None) -> float: | |
| 43 | + n = num(v, None) | |
| 44 | + if n is not None and not isinstance(v, bool): | |
| 45 | + return n | |
| 46 | + b = boolish(v) | |
| 47 | + words = [w.lower() for w in (scale or QUALITY)] | |
| 48 | + if isinstance(v, str) and v.strip().lower() in words: | |
| 49 | + return float(words.index(v.strip().lower())) | |
| 50 | + if b is not None: | |
| 51 | + return 1.0 if b else 0.0 | |
| 52 | + if isinstance(v, str): | |
| 53 | + for i, w in enumerate(words): | |
| 54 | + if w in v.lower(): | |
| 55 | + return float(i) | |
| 56 | + return 0.0 | |
| 57 | + | |
| 58 | + | |
| 59 | +# ------------------------------------------------------------------ calculators | |
| 60 | +def cost_approach(p: dict[str, Any]) -> dict[str, Any]: | |
| 61 | + land = _n(p, "land_value", required=True) | |
| 62 | + cn = _n(p, "cost_new", required=True) | |
| 63 | + phys = _n(p, "physical_depreciation") | |
| 64 | + if not phys and p.get("effective_age") and p.get("economic_life"): | |
| 65 | + phys = cn * _n(p, "effective_age") / _n(p, "economic_life") | |
| 66 | + func = _n(p, "functional_depreciation") | |
| 67 | + ext = _n(p, "external_depreciation") | |
| 68 | + site = _n(p, "site_improvements") | |
| 69 | + total = phys + func + ext | |
| 70 | + value = land + (cn - total) + site | |
| 71 | + rows = [["Valeur du terrain", land], ["Coût neuf des améliorations", cn], | |
| 72 | + ["− Dépréciation physique", -phys], ["− Dépréciation fonctionnelle", -func], | |
| 73 | + ["− Dépréciation économique", -ext], ["= Coût déprécié", cn - total], | |
| 74 | + ["+ Améliorations du site", site], ["= VALEUR INDIQUÉE", value]] | |
| 75 | + return {"formula": "V = V_T + (C_N − D_phys − D_fonct − D_écon) + améliorations du site", | |
| 76 | + "value": value, "total_depreciation": total, "depreciation_ratio": total / cn if cn else 0, | |
| 77 | + "table": rows} | |
| 78 | + | |
| 79 | + | |
| 80 | +def breakdown_depreciation(p: dict[str, Any]) -> dict[str, Any]: | |
| 81 | + cn = _n(p, "cost_new", required=True) | |
| 82 | + rows: list[list[Any]] = [] | |
| 83 | + curable = 0.0 | |
| 84 | + for it in p.get("curable_physical") or []: | |
| 85 | + c = num(it.get("cost_to_cure"), 0.0) or 0.0 | |
| 86 | + curable += c | |
| 87 | + rows.append(["Physique récupérable", str(it.get("item", "")), c]) | |
| 88 | + short_total = 0.0 | |
| 89 | + short_cn = 0.0 | |
| 90 | + for it in p.get("short_lived") or []: | |
| 91 | + icn = num(it.get("cost_new"), 0.0) or 0.0 | |
| 92 | + age = num(it.get("effective_age"), 0.0) or 0.0 | |
| 93 | + life = num(it.get("life"), 1.0) or 1.0 | |
| 94 | + d = icn * min(1.0, age / life) | |
| 95 | + short_total += d | |
| 96 | + short_cn += icn | |
| 97 | + rows.append(["Physique non récup. — courte vie", f"{it.get('item', '')} ({age:g}/{life:g} ans)", d]) | |
| 98 | + long_ = p.get("long_lived") or {} | |
| 99 | + long_age = num(long_.get("effective_age"), 0.0) or 0.0 | |
| 100 | + long_life = num(long_.get("economic_life") or long_.get("life"), 1.0) or 1.0 | |
| 101 | + long_base = cn - curable - short_cn | |
| 102 | + long_dep = max(0.0, long_base) * min(1.0, long_age / long_life) | |
| 103 | + rows.append(["Physique non récup. — longue vie", f"base {long_base:,.0f} × {long_age:g}/{long_life:g}", long_dep]) | |
| 104 | + functional = 0.0 | |
| 105 | + for it in p.get("functional") or []: | |
| 106 | + amt = num(it.get("amount"), None) | |
| 107 | + if amt is None and it.get("cost_to_cure") is not None: | |
| 108 | + amt = (num(it.get("cost_to_cure"), 0.0) or 0.0) - (num(it.get("cost_if_new"), 0.0) or 0.0) | |
| 109 | + if amt is None and it.get("rent_loss_annual") is not None: | |
| 110 | + amt = (num(it.get("rent_loss_annual"), 0.0) or 0.0) / max(1e-9, pct(it.get("cap_rate"), 0.08) or 0.08) | |
| 111 | + functional += amt or 0.0 | |
| 112 | + rows.append(["Fonctionnelle", f"{it.get('item', '')} ({it.get('type', 'déficience')})", amt or 0.0]) | |
| 113 | + ext = p.get("external") or {} | |
| 114 | + external = 0.0 | |
| 115 | + if isinstance(ext, dict): | |
| 116 | + if ext.get("amount") is not None: | |
| 117 | + external = num(ext.get("amount"), 0.0) or 0.0 | |
| 118 | + elif ext.get("rent_loss_annual") is not None: | |
| 119 | + external = (num(ext.get("rent_loss_annual"), 0.0) or 0.0) / max(1e-9, pct(ext.get("cap_rate"), 0.08) or 0.08) | |
| 120 | + share = pct(ext.get("building_share"), None) | |
| 121 | + if share is not None: | |
| 122 | + external *= share | |
| 123 | + else: | |
| 124 | + external = num(ext, 0.0) or 0.0 | |
| 125 | + if external: | |
| 126 | + rows.append(["Économique (externe)", "", external]) | |
| 127 | + total = curable + short_total + long_dep + functional + external | |
| 128 | + return {"formula": "D = récupérable + courte vie + longue vie (sur le résidu) + fonctionnelle + externe", | |
| 129 | + "total_depreciation": total, "depreciated_cost": cn - total, "ratio": total / cn if cn else 0, | |
| 130 | + "components": {"curable_physical": curable, "short_lived": short_total, "long_lived": long_dep, | |
| 131 | + "functional": functional, "external": external}, | |
| 132 | + "table": rows, "note": "Anti-double-comptage : la base longue vie exclut les éléments déjà déduits."} | |
| 133 | + | |
| 134 | + | |
| 135 | +def indexed_cost(p: dict[str, Any]) -> dict[str, Any]: | |
| 136 | + hist = _n(p, "historical_cost", required=True) | |
| 137 | + i0 = _n(p, "index_then", required=True) | |
| 138 | + i1 = _n(p, "index_now", required=True) | |
| 139 | + regional = _n(p, "regional_factor", 1.0) or 1.0 | |
| 140 | + size = _n(p, "size_factor", 1.0) or 1.0 | |
| 141 | + v = hist * (i1 / i0) * regional * size | |
| 142 | + return {"formula": "C_N = C_hist × (I_actuel / I_hist) × facteur régional × facteur de taille", | |
| 143 | + "value": v, "index_ratio": i1 / i0} | |
| 144 | + | |
| 145 | + | |
| 146 | +def unit_cost_estimate(p: dict[str, Any]) -> dict[str, Any]: | |
| 147 | + area = _n(p, "area", required=True) | |
| 148 | + unit = str(p.get("unit", "m2")) | |
| 149 | + rate = _n(p, "cost_per_unit", required=True) | |
| 150 | + direct = area * rate | |
| 151 | + for f in p.get("factors") or []: | |
| 152 | + direct *= num(f, 1.0) or 1.0 | |
| 153 | + extras = sum((num(x.get("amount"), 0.0) or 0.0) for x in (p.get("extras") or [])) | |
| 154 | + indirect = direct * _p(p, "indirect_pct") | |
| 155 | + profit = (direct + extras + indirect) * _p(p, "profit_pct") | |
| 156 | + total = direct + extras + indirect + profit | |
| 157 | + return {"formula": "C_N = (S × c_u × facteurs + extras) × (1 + indirects) × (1 + profit)", | |
| 158 | + "direct_costs": direct, "extras": extras, "indirect_costs": indirect, "entrepreneur_profit": profit, | |
| 159 | + "cost_new": total, "cost_per_unit_all_in": total / area if area else 0, "unit": unit, | |
| 160 | + "table": [["Coûts directs", direct], ["Extras", extras], ["Coûts indirects", indirect], | |
| 161 | + ["Profit de l'entrepreneur", profit], ["COÛT NEUF", total]]} | |
| 162 | + | |
| 163 | + | |
| 164 | +def land_extraction(p: dict[str, Any]) -> dict[str, Any]: | |
| 165 | + price = _n(p, "sale_price", required=True) | |
| 166 | + imp = _n(p, "improvements_depreciated_cost", None) | |
| 167 | + if imp is None or (imp == 0 and p.get("cost_new")): | |
| 168 | + imp = _n(p, "cost_new") - _n(p, "depreciation") | |
| 169 | + v = price - imp | |
| 170 | + return {"formula": "V_T = Prix de vente − coût déprécié des améliorations", "land_value": v, | |
| 171 | + "land_ratio": v / price if price else 0} | |
| 172 | + | |
| 173 | + | |
| 174 | +def land_allocation(p: dict[str, Any]) -> dict[str, Any]: | |
| 175 | + total = _n(p, "total_value", required=True) | |
| 176 | + ratio = _p(p, "land_ratio", 0.25) | |
| 177 | + return {"formula": "V_T = Valeur totale × ratio terrain", "land_value": total * ratio, "ratio": ratio} | |
| 178 | + | |
| 179 | + | |
| 180 | +def land_residual(p: dict[str, Any]) -> dict[str, Any]: | |
| 181 | + noi = _n(p, "noi", required=True) | |
| 182 | + bv = _n(p, "building_value", required=True) | |
| 183 | + rb = _p(p, "building_rate", 0.09) | |
| 184 | + rl = _p(p, "land_rate", 0.07) | |
| 185 | + income_b = bv * rb | |
| 186 | + residual = noi - income_b | |
| 187 | + return {"formula": "V_T = (RNE − V_B × r_B) / r_T", "building_income": income_b, "residual_income": residual, | |
| 188 | + "land_value": residual / rl if rl else 0} | |
| 189 | + | |
| 190 | + | |
| 191 | +def land_subdivision(p: dict[str, Any]) -> dict[str, Any]: | |
| 192 | + lots = int(_n(p, "lots", required=True)) | |
| 193 | + price = _n(p, "price_per_lot", required=True) | |
| 194 | + gross = lots * price | |
| 195 | + costs = _n(p, "development_costs") + _n(p, "selling_costs") + _n(p, "carrying_costs") | |
| 196 | + profit = gross * _p(p, "profit_pct", 0.15) | |
| 197 | + net = gross - costs - profit | |
| 198 | + years = _n(p, "absorption_years", 1.0) or 1.0 | |
| 199 | + r = _p(p, "discount_rate", 0.10) | |
| 200 | + # cash flow spread evenly over absorption period, discounted at mid-year | |
| 201 | + n = max(1, int(round(years))) | |
| 202 | + pv = sum((net / n) / (1 + r) ** (t + 0.5) for t in range(n)) | |
| 203 | + return {"formula": "V_T = VA[(recettes − coûts − profit) étalés sur l'absorption]", "gross_sales": gross, | |
| 204 | + "costs": costs, "profit": profit, "net_undiscounted": net, "land_value_pv": pv, | |
| 205 | + "per_lot": pv / lots if lots else 0} | |
| 206 | + | |
| 207 | + | |
| 208 | +def direct_capitalization(p: dict[str, Any]) -> dict[str, Any]: | |
| 209 | + noi = _n(p, "noi") if p.get("noi") is not None else None | |
| 210 | + if noi is None: | |
| 211 | + pgi = _n(p, "potential_gross_income", required=True) | |
| 212 | + vac = pgi * _p(p, "vacancy_pct", 0.05) | |
| 213 | + egi = pgi - vac + _n(p, "other_income") | |
| 214 | + exp = _n(p, "operating_expenses") | |
| 215 | + if not exp and p.get("expense_ratio") is not None: | |
| 216 | + exp = egi * _p(p, "expense_ratio") | |
| 217 | + noi = egi - exp | |
| 218 | + else: | |
| 219 | + pgi = vac = egi = exp = None | |
| 220 | + cap = _p(p, "cap_rate", 0.07) | |
| 221 | + return {"formula": "V = RNE / TGA", "noi": noi, "cap_rate": cap, "value": noi / cap if cap else 0, | |
| 222 | + "table": [x for x in [["Revenu brut potentiel", pgi], ["− Vacances et mauvaises créances", -vac if vac else None], | |
| 223 | + ["= Revenu brut effectif", egi], ["− Dépenses d'exploitation", -exp if exp else None], | |
| 224 | + ["= RNE", noi], [f"÷ TGA {cap:.2%}", None], ["= VALEUR", noi / cap if cap else 0]] | |
| 225 | + if x[1] is not None or x[0].startswith("÷")]} | |
| 226 | + | |
| 227 | + | |
| 228 | +def gross_income_multiplier(p: dict[str, Any]) -> dict[str, Any]: | |
| 229 | + if p.get("sale_price") is not None and p.get("gross_income") is not None: | |
| 230 | + gim = _n(p, "sale_price") / _n(p, "gross_income") | |
| 231 | + return {"formula": "MRB = Prix / Revenu brut", "gim": gim} | |
| 232 | + gim = _n(p, "gim", required=True) | |
| 233 | + gi = _n(p, "gross_income", required=True) | |
| 234 | + return {"formula": "V = Revenu brut × MRB", "value": gim * gi, "gim": gim} | |
| 235 | + | |
| 236 | + | |
| 237 | +def adjust_comparables(p: dict[str, Any]) -> dict[str, Any]: | |
| 238 | + subject = {k.lower(): v for k, v in (p.get("subject") or p.get("sujet") or {}).items()} | |
| 239 | + rates = {k.lower(): num(v, 0.0) or 0.0 for k, v in (p.get("rates") or p.get("taux") or {}).items()} | |
| 240 | + scales = {k.lower(): v for k, v in (p.get("scales") or p.get("echelles") or {}).items()} | |
| 241 | + monthly = pct(p.get("monthly_trend") or p.get("taux_temps_mensuel"), 0.0) or 0.0 | |
| 242 | + comps = p.get("comparables") or [] | |
| 243 | + if not comps: | |
| 244 | + raise KeyError("comparables") | |
| 245 | + out_rows = [] | |
| 246 | + adjusted = [] | |
| 247 | + gross_pcts = [] | |
| 248 | + for c in comps: | |
| 249 | + c = {k.lower(): v for k, v in c.items()} | |
| 250 | + price = num(c.get("price") or c.get("prix"), 0.0) or 0.0 | |
| 251 | + months = num(c.get("months") or c.get("mois"), 0.0) or 0.0 | |
| 252 | + t_pct = pct(c.get("time_pct") or c.get("temps"), None) | |
| 253 | + if t_pct is None: | |
| 254 | + t_pct = months * monthly | |
| 255 | + pat = price * (1 + t_pct) | |
| 256 | + adjustments: dict[str, float] = {} | |
| 257 | + for k, rate in rates.items(): | |
| 258 | + if k in subject or k in c: | |
| 259 | + adj = (_rank(subject.get(k), scales.get(k)) - _rank(c.get(k), scales.get(k))) * rate | |
| 260 | + adjustments[k] = adj | |
| 261 | + for k, v in (c.get("adjustments") or {}).items(): | |
| 262 | + adjustments[k.lower()] = num(v, 0.0) or 0.0 | |
| 263 | + net = sum(adjustments.values()) | |
| 264 | + gross = sum(abs(v) for v in adjustments.values()) + abs(pat - price) | |
| 265 | + final = pat + net | |
| 266 | + adjusted.append(final) | |
| 267 | + gp = gross / price if price else 0 | |
| 268 | + gross_pcts.append(gp) | |
| 269 | + out_rows.append({"name": c.get("address") or c.get("adresse") or c.get("name") or "comparable", | |
| 270 | + "price": price, "time_pct": t_pct, "time_adjusted": pat, "adjustments": adjustments, | |
| 271 | + "net": net, "adjusted_price": final, "net_pct": net / price if price else 0, "gross_pct": gp, | |
| 272 | + "reliable": gp <= 0.25 and abs(net / price if price else 0) <= 0.15}) | |
| 273 | + weights = [1 / (1 + g) for g in gross_pcts] | |
| 274 | + weighted = sum(a * w for a, w in zip(adjusted, weights, strict=False)) / sum(weights) | |
| 275 | + best = min(range(len(comps)), key=lambda i: gross_pcts[i]) | |
| 276 | + return {"formula": "Prix ajusté = Prix × (1 + temps) + Σ (sujet − comparable) × taux", | |
| 277 | + "comparables": out_rows, | |
| 278 | + "stats": {"min": min(adjusted), "max": max(adjusted), "mean": statistics.fmean(adjusted), | |
| 279 | + "median": statistics.median(adjusted), "weighted": weighted, | |
| 280 | + "least_adjusted": out_rows[best]["name"], "least_adjusted_price": adjusted[best]}, | |
| 281 | + "note": "Repères du cours : ajustements bruts ≤ 25 %, nets ≤ 15 %. La pondération n'est pas une moyenne."} | |
| 282 | + | |
| 283 | + | |
| 284 | +def effective_age_market(p: dict[str, Any]) -> dict[str, Any]: | |
| 285 | + price = _n(p, "sale_price", required=True) | |
| 286 | + land = _n(p, "land_value", required=True) | |
| 287 | + cn = _n(p, "cost_new", required=True) | |
| 288 | + life = _n(p, "economic_life", required=True) | |
| 289 | + dep = cn - (price - land) | |
| 290 | + ratio = dep / cn if cn else 0 | |
| 291 | + return {"formula": "A_e = (D / C_N) × DVE, avec D = C_N − (Prix − V_T)", "depreciation": dep, "ratio": ratio, | |
| 292 | + "effective_age": ratio * life, "annual_rate": ratio / _n(p, "actual_age", 1.0) if p.get("actual_age") else None} | |
| 293 | + | |
| 294 | + | |
| 295 | +FUNCS = { | |
| 296 | + "cost_approach": cost_approach, "breakdown_depreciation": breakdown_depreciation, "indexed_cost": indexed_cost, | |
| 297 | + "unit_cost_estimate": unit_cost_estimate, "land_extraction": land_extraction, "land_allocation": land_allocation, | |
| 298 | + "land_residual": land_residual, "land_subdivision": land_subdivision, | |
| 299 | + "direct_capitalization": direct_capitalization, "gross_income_multiplier": gross_income_multiplier, | |
| 300 | + "adjust_comparables": adjust_comparables, "effective_age_market": effective_age_market, | |
| 301 | +} | |
| 302 | + | |
| 303 | + | |
| 304 | +def _fmt(v: Any) -> str: | |
| 305 | + if isinstance(v, float): | |
| 306 | + return f"{v:,.4f}".rstrip("0").rstrip(".") if abs(v) < 10 else f"{v:,.2f}" | |
| 307 | + return str(v) | |
| 308 | + | |
| 309 | + | |
| 310 | +def _render(out: dict[str, Any], depth: int = 0) -> list[str]: | |
| 311 | + lines = [] | |
| 312 | + for k, v in out.items(): | |
| 313 | + if k == "table" and isinstance(v, list): | |
| 314 | + lines.append("table:") | |
| 315 | + for row in v: | |
| 316 | + lines.append(" " + " | ".join(_fmt(x) if not isinstance(x, str) else x for x in row)) | |
| 317 | + elif k == "comparables" and isinstance(v, list): | |
| 318 | + for c in v: | |
| 319 | + adj = ", ".join(f"{a}: {_fmt(b)}" for a, b in c["adjustments"].items()) | |
| 320 | + lines.append(f" {c['name']}: prix {_fmt(c['price'])} → ajusté temps {_fmt(c['time_adjusted'])} ; " | |
| 321 | + f"ajust. [{adj}] net {_fmt(c['net'])} → PRIX AJUSTÉ {_fmt(c['adjusted_price'])} " | |
| 322 | + f"(net {c['net_pct']:.1%}, brut {c['gross_pct']:.1%}, {'fiable' if c['reliable'] else 'à pondérer faiblement'})") | |
| 323 | + elif isinstance(v, dict): | |
| 324 | + lines.append(f"{k}:") | |
| 325 | + lines += [" " + line for line in _render(v, depth + 1)] | |
| 326 | + else: | |
| 327 | + lines.append(f"{k} = {_fmt(v) if v is not None else '—'}") | |
| 328 | + return lines | |
| 329 | + | |
| 330 | + | |
| 331 | +async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult: | |
| 332 | + fn = args["function"] | |
| 333 | + try: | |
| 334 | + out = FUNCS[fn](args["params"]) | |
| 335 | + except (KeyError, ValueError, ZeroDivisionError, TypeError) as exc: | |
| 336 | + return ToolResult(content=f"Paramètre manquant ou invalide pour {fn} : {exc}. Voir la description de " | |
| 337 | + "l'outil pour les paramètres attendus.", error=True) | |
| 338 | + return ToolResult(content=f"Résultat {fn} :\n" + "\n".join(_render(out)), | |
| 339 | + payload={"function": fn, "params": args["params"], "result": out}, | |
| 340 | + meta={"summary": f"{fn} : {out.get('formula', '')}"}) | |
| 341 | + | |
| 342 | + | |
| 343 | +registry.register("appraisal_calc", run, Args) | |
modified
backend/app/tools/coerce.py
+2 −1
@@ -70,7 +70,8 @@ def cell(value: Any) -> Any: | ||
| 70 | 70 | if s.startswith("="): |
| 71 | 71 | return s |
| 72 | 72 | n = num(s, None) |
| 73 | − if n is not None and not re.search(r"[A-Za-z]{2,}", s): | |
| 73 | + stripped = re.sub(r"(pi²|pi2|m²|m2|CAD|USD|ans?\b|mois)", "", s, flags=re.I) | |
| 74 | + if n is not None and not re.search(r"[A-Za-z]{2,}", stripped): | |
| 74 | 75 | return n |
| 75 | 76 | return s |
| 76 | 77 | if isinstance(value, dict): |
added
backend/app/tools/create_docx.py
+195 −0
@@ -0,0 +1,195 @@ | ||
| 1 | +"""create_docx — Markdown → Word document (fiches de révision, plans d'étude, gabarits de rapport).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import io | |
| 6 | +import re | |
| 7 | +from datetime import date | |
| 8 | +from typing import Any | |
| 9 | + | |
| 10 | +from pydantic import BaseModel, Field | |
| 11 | + | |
| 12 | +from app.llm.schemas import Artifact, ToolResult | |
| 13 | +from app.services import files as file_service | |
| 14 | +from app.tools.registry import ToolContext, registry | |
| 15 | + | |
| 16 | +UQO_BLUE = "00467F" | |
| 17 | + | |
| 18 | + | |
| 19 | +class Args(BaseModel): | |
| 20 | + title: str = Field(..., max_length=200) | |
| 21 | + markdown: str = Field(..., min_length=10, max_length=60000) | |
| 22 | + filename: str | None = None | |
| 23 | + course: str = "" | |
| 24 | + subtitle: str = "" | |
| 25 | + | |
| 26 | + | |
| 27 | +def _add_runs(par: Any, text: str) -> None: | |
| 28 | + """Bold / italic / inline code / inline math ($…$ → italic).""" | |
| 29 | + tokens = re.split(r"(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`|\$[^$]+\$)", text) | |
| 30 | + for t in tokens: | |
| 31 | + if not t: | |
| 32 | + continue | |
| 33 | + if t.startswith("**") and t.endswith("**"): | |
| 34 | + par.add_run(t[2:-2]).bold = True | |
| 35 | + elif t.startswith("`") and t.endswith("`"): | |
| 36 | + r = par.add_run(t[1:-1]) | |
| 37 | + r.font.name = "Consolas" | |
| 38 | + elif t.startswith("$") and t.endswith("$"): | |
| 39 | + r = par.add_run(_tex_to_text(t[1:-1])) | |
| 40 | + r.italic = True | |
| 41 | + elif t.startswith("*") and t.endswith("*"): | |
| 42 | + par.add_run(t[1:-1]).italic = True | |
| 43 | + else: | |
| 44 | + par.add_run(t) | |
| 45 | + | |
| 46 | + | |
| 47 | +def _tex_to_text(s: str) -> str: | |
| 48 | + s = re.sub(r"\\frac\{([^}]*)\}\{([^}]*)\}", r"(\1)/(\2)", s) | |
| 49 | + s = s.replace("\\times", "×").replace("\\cdot", "·").replace("\\approx", "≈").replace("\\le", "≤").replace("\\ge", "≥") | |
| 50 | + s = re.sub(r"\\text\{([^}]*)\}", r"\1", s) | |
| 51 | + s = s.replace("\\,", " ").replace("\\ ", " ").replace("\\$", "$") | |
| 52 | + s = re.sub(r"\^\{([^}]*)\}", r"^\1", s) | |
| 53 | + s = re.sub(r"_\{([^}]*)\}", r"_\1", s) | |
| 54 | + return s.replace("{", "").replace("}", "") | |
| 55 | + | |
| 56 | + | |
| 57 | +def build(args: dict[str, Any]) -> bytes: | |
| 58 | + from docx import Document | |
| 59 | + from docx.enum.text import WD_ALIGN_PARAGRAPH | |
| 60 | + from docx.shared import Pt, RGBColor | |
| 61 | + | |
| 62 | + doc = Document() | |
| 63 | + style = doc.styles["Normal"] | |
| 64 | + style.font.name = "Calibri" | |
| 65 | + style.font.size = Pt(11) | |
| 66 | + for lvl, size in ((1, 18), (2, 14), (3, 12)): | |
| 67 | + hs = doc.styles[f"Heading {lvl}"] | |
| 68 | + hs.font.color.rgb = RGBColor.from_string(UQO_BLUE) | |
| 69 | + hs.font.size = Pt(size) | |
| 70 | + # header band | |
| 71 | + hp = doc.add_paragraph() | |
| 72 | + r = hp.add_run("UQO-Chat · Tuteur IA — IMM1003 · IMM1033") | |
| 73 | + r.font.size = Pt(9) | |
| 74 | + r.font.color.rgb = RGBColor(0x5B, 0x6B, 0x7B) | |
| 75 | + t = doc.add_paragraph() | |
| 76 | + tr = t.add_run(args["title"]) | |
| 77 | + tr.bold = True | |
| 78 | + tr.font.size = Pt(22) | |
| 79 | + tr.font.color.rgb = RGBColor.from_string(UQO_BLUE) | |
| 80 | + sub = " · ".join(x for x in [args.get("course", ""), args.get("subtitle", ""), date.today().strftime("%d %B %Y")] if x) | |
| 81 | + sp = doc.add_paragraph(sub) | |
| 82 | + sp.runs[0].font.color.rgb = RGBColor(0x5B, 0x6B, 0x7B) | |
| 83 | + | |
| 84 | + lines = args["markdown"].replace("\r\n", "\n").split("\n") | |
| 85 | + i = 0 | |
| 86 | + while i < len(lines): | |
| 87 | + line = lines[i] | |
| 88 | + s = line.strip() | |
| 89 | + if not s: | |
| 90 | + i += 1 | |
| 91 | + continue | |
| 92 | + if s.startswith("```"): | |
| 93 | + code = [] | |
| 94 | + i += 1 | |
| 95 | + while i < len(lines) and not lines[i].strip().startswith("```"): | |
| 96 | + code.append(lines[i]) | |
| 97 | + i += 1 | |
| 98 | + p = doc.add_paragraph() | |
| 99 | + run = p.add_run("\n".join(code)) | |
| 100 | + run.font.name = "Consolas" | |
| 101 | + run.font.size = Pt(9) | |
| 102 | + i += 1 | |
| 103 | + continue | |
| 104 | + if s.startswith("$$") and s.endswith("$$") and len(s) > 4: | |
| 105 | + p = doc.add_paragraph() | |
| 106 | + p.alignment = WD_ALIGN_PARAGRAPH.CENTER | |
| 107 | + p.add_run(_tex_to_text(s[2:-2])).italic = True | |
| 108 | + i += 1 | |
| 109 | + continue | |
| 110 | + m = re.match(r"^(#{1,4})\s+(.*)", s) | |
| 111 | + if m: | |
| 112 | + doc.add_heading(re.sub(r"[*`]", "", m.group(2)), level=min(len(m.group(1)), 3)) | |
| 113 | + i += 1 | |
| 114 | + continue | |
| 115 | + if s.startswith("|") and i + 1 < len(lines) and re.match(r"^\|?\s*:?-{2,}", lines[i + 1].strip()): | |
| 116 | + header = [c.strip() for c in s.strip("|").split("|")] | |
| 117 | + rows = [] | |
| 118 | + i += 2 | |
| 119 | + while i < len(lines) and lines[i].strip().startswith("|"): | |
| 120 | + rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")]) | |
| 121 | + i += 1 | |
| 122 | + table = doc.add_table(rows=1 + len(rows), cols=len(header)) | |
| 123 | + table.style = "Light Grid Accent 1" | |
| 124 | + for j, h in enumerate(header): | |
| 125 | + cell = table.rows[0].cells[j] | |
| 126 | + cell.text = "" | |
| 127 | + _add_runs(cell.paragraphs[0], h) | |
| 128 | + for run in cell.paragraphs[0].runs: | |
| 129 | + run.bold = True | |
| 130 | + for ri, row in enumerate(rows, 1): | |
| 131 | + for j in range(len(header)): | |
| 132 | + cell = table.rows[ri].cells[j] | |
| 133 | + cell.text = "" | |
| 134 | + _add_runs(cell.paragraphs[0], row[j] if j < len(row) else "") | |
| 135 | + doc.add_paragraph() | |
| 136 | + continue | |
| 137 | + if re.match(r"^[-*•]\s+", s): | |
| 138 | + p = doc.add_paragraph(style="List Bullet") | |
| 139 | + _add_runs(p, re.sub(r"^[-*•]\s+", "", s)) | |
| 140 | + i += 1 | |
| 141 | + continue | |
| 142 | + if re.match(r"^\d+[.)]\s+", s): | |
| 143 | + p = doc.add_paragraph(style="List Number") | |
| 144 | + _add_runs(p, re.sub(r"^\d+[.)]\s+", "", s)) | |
| 145 | + i += 1 | |
| 146 | + continue | |
| 147 | + if s.startswith(">"): | |
| 148 | + p = doc.add_paragraph() | |
| 149 | + p.paragraph_format.left_indent = Pt(18) | |
| 150 | + _add_runs(p, s.lstrip("> ")) | |
| 151 | + for run in p.runs: | |
| 152 | + run.italic = True | |
| 153 | + i += 1 | |
| 154 | + continue | |
| 155 | + if re.match(r"^(-{3,}|\*{3,})$", s): | |
| 156 | + i += 1 | |
| 157 | + continue | |
| 158 | + # paragraph (merge consecutive lines) | |
| 159 | + buf = [s] | |
| 160 | + i += 1 | |
| 161 | + while i < len(lines) and lines[i].strip() and not re.match(r"^(#{1,4}\s|[-*•]\s|\d+[.)]\s|\||>|```|\$\$)", lines[i].strip()): | |
| 162 | + buf.append(lines[i].strip()) | |
| 163 | + i += 1 | |
| 164 | + p = doc.add_paragraph() | |
| 165 | + _add_runs(p, " ".join(buf)) | |
| 166 | + foot = doc.add_paragraph() | |
| 167 | + fr = foot.add_run("Document pédagogique produit avec UQO-Chat — ne constitue pas une évaluation professionnelle. " | |
| 168 | + "Seul un évaluateur agréé (É.A.) membre de l'OEAQ peut signer un rapport d'évaluation. " | |
| 169 | + "L'utilisation de l'IA doit être déclarée conformément au plan de cours.") | |
| 170 | + fr.font.size = Pt(8) | |
| 171 | + fr.font.color.rgb = RGBColor(0x5B, 0x6B, 0x7B) | |
| 172 | + buf_io = io.BytesIO() | |
| 173 | + doc.save(buf_io) | |
| 174 | + return buf_io.getvalue() | |
| 175 | + | |
| 176 | + | |
| 177 | +async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult: | |
| 178 | + await ctx.report("running", "Mise en page du document Word…") | |
| 179 | + data = build(args) | |
| 180 | + name = args.get("filename") or re.sub(r"[^\w\- ]", "", args["title"])[:60].strip().replace(" ", "_") or "document" | |
| 181 | + if not name.lower().endswith(".docx"): | |
| 182 | + name += ".docx" | |
| 183 | + rec = await file_service.store_artifact(ctx.user_id, ctx.conversation_id, name, data, ftype="docx") | |
| 184 | + art = Artifact(type="docx", file_id=rec.id, filename=rec.filename, url=f"/api/v1/files/{rec.id}") | |
| 185 | + words = len(args["markdown"].split()) | |
| 186 | + return ToolResult(content=f"Document Word « {rec.filename} » créé ({words} mots, {len(data) // 1024} Ko) et " | |
| 187 | + "affiché à l'étudiant avec un bouton Télécharger. Ne recopie pas son contenu intégral.", | |
| 188 | + artifacts=[art], | |
| 189 | + payload={"filename": rec.filename, "file_id": rec.id, "title": args["title"], "words": words, | |
| 190 | + "outline": [re.sub(r"^#+\s*", "", ln.strip()) for ln in args["markdown"].split("\n") | |
| 191 | + if ln.strip().startswith("#")][:20]}, | |
| 192 | + meta={"summary": f"Word : {rec.filename}"}) | |
| 193 | + | |
| 194 | + | |
| 195 | +registry.register("create_docx", run, Args, heavy=True) | |
modified
backend/app/tools/create_excel.py
+10 −7
@@ -254,14 +254,17 @@ def build_workbook(spec: dict[str, Any]) -> tuple[bytes, list[dict[str, Any]]]: | ||
| 254 | 254 | return data, summaries |
| 255 | 255 | |
| 256 | 256 | |
| 257 | −def _preview(data: bytes, max_rows: int = 14, max_cols: int = 8) -> dict[str, Any]: | |
| 257 | +def _preview(data: bytes, max_rows: int = 16, max_cols: int = 10) -> dict[str, Any]: | |
| 258 | 258 | wb = load_workbook(io.BytesIO(data)) |
| 259 | − ws = wb.worksheets[1] if len(wb.worksheets) > 1 else wb.worksheets[0] | |
| 260 | − rows: list[list[Any]] = [] | |
| 261 | − for r in ws.iter_rows(min_row=1, max_row=min(ws.max_row, max_rows), max_col=max_cols, | |
| 262 | − values_only=True): | |
| 263 | − rows.append([("" if v is None else v) for v in r]) | |
| 264 | − return {"sheet": ws.title, "rows": rows, "sheets": wb.sheetnames} | |
| 259 | + sheets = [] | |
| 260 | + for ws in wb.worksheets[:8]: | |
| 261 | + rows: list[list[Any]] = [] | |
| 262 | + for r in ws.iter_rows(min_row=1, max_row=min(ws.max_row, max_rows), | |
| 263 | + max_col=min(max(ws.max_column, 1), max_cols), values_only=True): | |
| 264 | + rows.append([("" if v is None else v) for v in r]) | |
| 265 | + sheets.append({"name": ws.title, "rows": rows, "max_row": ws.max_row, "max_col": ws.max_column}) | |
| 266 | + first = next((s for s in sheets if s["name"] != "Lisez-moi"), sheets[0]) | |
| 267 | + return {"sheet": first["name"], "rows": first["rows"], "sheets": [s["name"] for s in sheets], "all": sheets} | |
| 265 | 268 | |
| 266 | 269 | |
| 267 | 270 | async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult: |
added
backend/app/tools/edit_excel.py
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +"""inspect_excel / edit_excel — read and modify an uploaded or generated workbook.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import re | |
| 6 | +from typing import Any | |
| 7 | + | |
| 8 | +from pydantic import BaseModel, Field | |
| 9 | + | |
| 10 | +from app.llm.schemas import Artifact, ToolResult | |
| 11 | +from app.services import files as file_service | |
| 12 | +from app.tools import excel_ops | |
| 13 | +from app.tools.registry import ToolContext, registry | |
| 14 | + | |
| 15 | + | |
| 16 | +class InspectArgs(BaseModel): | |
| 17 | + file_id: str | |
| 18 | + sheet: str | None = None | |
| 19 | + max_rows: int = Field(60, ge=5, le=300) | |
| 20 | + | |
| 21 | + | |
| 22 | +class EditArgs(BaseModel): | |
| 23 | + file_id: str | |
| 24 | + operations: list[dict[str, Any]] = Field(default_factory=list) | |
| 25 | + filename: str | None = None | |
| 26 | + | |
| 27 | + | |
| 28 | +async def _load(ctx: ToolContext, file_id: str) -> tuple[Any, bytes] | ToolResult: | |
| 29 | + rec = await file_service.get_file(file_id) | |
| 30 | + if not rec or rec.user_id != ctx.user_id: | |
| 31 | + return ToolResult(content="Fichier introuvable. Utilise un file_id listé dans le contexte " | |
| 32 | + "(fichiers déposés ou classeurs déjà générés).", error=True) | |
| 33 | + if file_service.ext_of(rec.filename) not in {"xlsx", "xlsm"}: | |
| 34 | + return ToolResult(content=f"« {rec.filename} » n'est pas un classeur .xlsx.", error=True) | |
| 35 | + try: | |
| 36 | + return rec, file_service.read_bytes(rec) | |
| 37 | + except FileNotFoundError: | |
| 38 | + return ToolResult(content="Le fichier a expiré ; demande à l'étudiant de le redéposer.", error=True) | |
| 39 | + | |
| 40 | + | |
| 41 | +async def inspect(args: dict[str, Any], ctx: ToolContext) -> ToolResult: | |
| 42 | + loaded = await _load(ctx, args["file_id"]) | |
| 43 | + if isinstance(loaded, ToolResult): | |
| 44 | + return loaded | |
| 45 | + rec, data = loaded | |
| 46 | + await ctx.report("running", f"Lecture de {rec.filename}…") | |
| 47 | + info = excel_ops.inspect(data, args.get("sheet"), max_rows=args.get("max_rows", 60)) | |
| 48 | + parts = [f"Classeur « {rec.filename} » (file_id {rec.id}). Feuilles : " | |
| 49 | + + ", ".join(s["name"] for s in info["sheets"])] | |
| 50 | + for s in info["sheets"]: | |
| 51 | + parts.append(f"\n## Feuille « {s['name']} » ({s['dims']}, {s['max_row']} lignes × {s['max_col']} col., " | |
| 52 | + f"{s['charts']} graphique(s))\n" + "\n".join(s["cells"][:200])) | |
| 53 | + if info["defined_names"]: | |
| 54 | + parts.append("Noms définis : " + ", ".join(info["defined_names"])) | |
| 55 | + parts.append("\nLes formules sont affichées telles quelles (non calculées). Pour modifier : edit_excel " | |
| 56 | + "avec des opérations (set_cell, add_column avec formula '=E{r}*0.02', add_row, format, " | |
| 57 | + "add_sheet, add_chart…).") | |
| 58 | + return ToolResult(content="\n".join(parts)[:40000], | |
| 59 | + payload={"filename": rec.filename, "file_id": rec.id, "preview": excel_ops.preview(data)}, | |
| 60 | + meta={"summary": f"Lecture : {rec.filename}"}) | |
| 61 | + | |
| 62 | + | |
| 63 | +def _version_name(name: str) -> str: | |
| 64 | + stem, ext = (name.rsplit(".", 1) + ["xlsx"])[:2] | |
| 65 | + m = re.search(r"_v(\d+)$", stem) | |
| 66 | + if m: | |
| 67 | + return f"{stem[: m.start()]}_v{int(m.group(1)) + 1}.{ext}" | |
| 68 | + return f"{stem}_v2.{ext}" | |
| 69 | + | |
| 70 | + | |
| 71 | +async def edit(args: dict[str, Any], ctx: ToolContext) -> ToolResult: | |
| 72 | + loaded = await _load(ctx, args["file_id"]) | |
| 73 | + if isinstance(loaded, ToolResult): | |
| 74 | + return loaded | |
| 75 | + rec, data = loaded | |
| 76 | + ops = args.get("operations") or [] | |
| 77 | + if not ops: | |
| 78 | + return ToolResult(content="Aucune opération fournie. Utilise inspect_excel pour lire, puis edit_excel " | |
| 79 | + "avec `operations`.", error=True) | |
| 80 | + await ctx.report("running", f"Modification de {rec.filename} ({len(ops)} opération(s))…") | |
| 81 | + try: | |
| 82 | + new_data, log = excel_ops.apply_operations(data, ops) | |
| 83 | + except excel_ops.ExcelOpError as exc: | |
| 84 | + return ToolResult(content=f"Modification refusée : {exc}", error=True) | |
| 85 | + filename = args.get("filename") or _version_name(rec.filename) | |
| 86 | + if not filename.lower().endswith(".xlsx"): | |
| 87 | + filename += ".xlsx" | |
| 88 | + new_rec = await file_service.store_artifact(ctx.user_id, ctx.conversation_id, filename, new_data, ftype="xlsx") | |
| 89 | + pv = excel_ops.preview(new_data) | |
| 90 | + art = Artifact(type="xlsx", file_id=new_rec.id, filename=new_rec.filename, url=f"/api/v1/files/{new_rec.id}", | |
| 91 | + preview=pv) | |
| 92 | + return ToolResult( | |
| 93 | + content=f"Classeur modifié et enregistré sous « {new_rec.filename} » (file_id {new_rec.id}). Opérations :\n" | |
| 94 | + + "\n".join(log) + "\nLe nouveau fichier est affiché à l'étudiant avec un bouton Télécharger ; " | |
| 95 | + "l'original est conservé.", | |
| 96 | + artifacts=[art], | |
| 97 | + payload={"filename": new_rec.filename, "file_id": new_rec.id, "source": rec.filename, | |
| 98 | + "source_file_id": rec.id, "changes": log, "preview": pv}, | |
| 99 | + meta={"summary": f"Excel modifié : {new_rec.filename} ({len(log)} op.)"}, | |
| 100 | + ) | |
| 101 | + | |
| 102 | + | |
| 103 | +registry.register("inspect_excel", inspect, InspectArgs) | |
| 104 | +registry.register("edit_excel", edit, EditArgs, heavy=True) | |
added
backend/app/tools/excel_ops.py
+347 −0
@@ -0,0 +1,347 @@ | ||
| 1 | +"""Open, inspect and modify existing workbooks (uploads or generated artifacts).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import io | |
| 6 | +import re | |
| 7 | +from typing import Any | |
| 8 | + | |
| 9 | +from openpyxl import load_workbook | |
| 10 | +from openpyxl.chart import BarChart, LineChart, PieChart, Reference, ScatterChart, Series | |
| 11 | +from openpyxl.comments import Comment | |
| 12 | +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side | |
| 13 | +from openpyxl.utils import get_column_letter | |
| 14 | +from openpyxl.utils.cell import column_index_from_string, coordinate_from_string, range_boundaries | |
| 15 | +from openpyxl.workbook.defined_name import DefinedName | |
| 16 | + | |
| 17 | +from app.tools.coerce import cell as coerce_cell | |
| 18 | +from app.tools.create_excel import ( | |
| 19 | + FORMATS, | |
| 20 | + HEADER_FILL, | |
| 21 | + HEADER_FONT, | |
| 22 | + INPUT_FILL, | |
| 23 | + INPUT_FONT, | |
| 24 | + _readme, | |
| 25 | +) | |
| 26 | + | |
| 27 | +UQO_BLUE_DARK = "003057" | |
| 28 | +TOTAL_BORDER = Border(top=Side(style="medium", color=UQO_BLUE_DARK)) | |
| 29 | + | |
| 30 | + | |
| 31 | +class ExcelOpError(ValueError): | |
| 32 | + pass | |
| 33 | + | |
| 34 | + | |
| 35 | +# ------------------------------------------------------------------ helpers | |
| 36 | +def _ws(wb: Any, name: str | None) -> Any: | |
| 37 | + if not name: | |
| 38 | + # first non "Lisez-moi" sheet | |
| 39 | + for ws in wb.worksheets: | |
| 40 | + if ws.title != "Lisez-moi": | |
| 41 | + return ws | |
| 42 | + return wb.worksheets[0] | |
| 43 | + if name in wb.sheetnames: | |
| 44 | + return wb[name] | |
| 45 | + low = {s.lower(): s for s in wb.sheetnames} | |
| 46 | + if name.lower() in low: | |
| 47 | + return wb[low[name.lower()]] | |
| 48 | + if name.isdigit() and 0 < int(name) <= len(wb.sheetnames): | |
| 49 | + return wb.worksheets[int(name) - 1] | |
| 50 | + raise ExcelOpError(f"Feuille introuvable : « {name} ». Feuilles : {', '.join(wb.sheetnames)}.") | |
| 51 | + | |
| 52 | + | |
| 53 | +def _apply_format(c: Any, fmt: str | None) -> None: | |
| 54 | + if fmt and fmt in FORMATS and fmt != "text": | |
| 55 | + c.number_format = FORMATS[fmt] | |
| 56 | + | |
| 57 | + | |
| 58 | +def _header_row(ws: Any) -> int: | |
| 59 | + best, best_n = 1, -1 | |
| 60 | + for r in range(1, min(ws.max_row, 20) + 1): | |
| 61 | + n = sum(1 for c in ws[r] if isinstance(c.value, str) and c.value.strip() and not c.value.startswith("=")) | |
| 62 | + if n > best_n: | |
| 63 | + best, best_n = r, n | |
| 64 | + return best | |
| 65 | + | |
| 66 | + | |
| 67 | +def _value(v: Any) -> Any: | |
| 68 | + if isinstance(v, str) and v.startswith("="): | |
| 69 | + return v | |
| 70 | + return coerce_cell(v) | |
| 71 | + | |
| 72 | + | |
| 73 | +# ------------------------------------------------------------------ inspect | |
| 74 | +def inspect(data: bytes, sheet: str | None = None, max_rows: int = 60, max_cols: int = 16) -> dict[str, Any]: | |
| 75 | + wb = load_workbook(io.BytesIO(data)) | |
| 76 | + sheets = [_ws(wb, sheet)] if sheet else wb.worksheets | |
| 77 | + out: dict[str, Any] = {"sheets": [], "defined_names": sorted(wb.defined_names.keys())[:40]} | |
| 78 | + for ws in sheets: | |
| 79 | + lines = [] | |
| 80 | + for row in ws.iter_rows(min_row=1, max_row=min(ws.max_row, max_rows), max_col=min(ws.max_column, max_cols)): | |
| 81 | + cells = [f"{c.coordinate}={c.value!r}" for c in row if c.value is not None] | |
| 82 | + if cells: | |
| 83 | + lines.append(" ".join(cells)) | |
| 84 | + out["sheets"].append({"name": ws.title, "dims": ws.dimensions, "max_row": ws.max_row, | |
| 85 | + "max_col": ws.max_column, "cells": lines, | |
| 86 | + "charts": len(getattr(ws, "_charts", []))}) | |
| 87 | + return out | |
| 88 | + | |
| 89 | + | |
| 90 | +def preview(data: bytes, max_rows: int = 16, max_cols: int = 10) -> dict[str, Any]: | |
| 91 | + wb = load_workbook(io.BytesIO(data)) | |
| 92 | + sheets = [] | |
| 93 | + for ws in wb.worksheets[:8]: | |
| 94 | + rows = [] | |
| 95 | + for r in ws.iter_rows(min_row=1, max_row=min(ws.max_row, max_rows), max_col=min(max(ws.max_column, 1), max_cols), values_only=True): | |
| 96 | + rows.append([("" if v is None else v) for v in r]) | |
| 97 | + sheets.append({"name": ws.title, "rows": rows, "max_row": ws.max_row, "max_col": ws.max_column}) | |
| 98 | + first = next((s for s in sheets if s["name"] != "Lisez-moi"), sheets[0] if sheets else {"name": "", "rows": []}) | |
| 99 | + return {"sheet": first["name"], "rows": first["rows"], "sheets": [s["name"] for s in sheets], "all": sheets} | |
| 100 | + | |
| 101 | + | |
| 102 | +# ------------------------------------------------------------------ operations | |
| 103 | +def apply_operations(data: bytes, operations: list[dict[str, Any]]) -> tuple[bytes, list[str]]: | |
| 104 | + wb = load_workbook(io.BytesIO(data)) | |
| 105 | + log: list[str] = [] | |
| 106 | + for i, op in enumerate(operations, 1): | |
| 107 | + kind = str(op.get("op") or op.get("type") or "").lower() | |
| 108 | + try: | |
| 109 | + msg = _apply_one(wb, kind, op) | |
| 110 | + except ExcelOpError: | |
| 111 | + raise | |
| 112 | + except Exception as exc: # noqa: BLE001 | |
| 113 | + raise ExcelOpError(f"Opération {i} ({kind}) : {type(exc).__name__}: {exc}") from exc | |
| 114 | + log.append(f"{i}. {msg}") | |
| 115 | + if "Lisez-moi" not in wb.sheetnames: | |
| 116 | + _readme(wb, {"objective": "Classeur modifié avec UQO-Chat", "sheets": []}, | |
| 117 | + [{"name": s} for s in wb.sheetnames]) | |
| 118 | + buf = io.BytesIO() | |
| 119 | + wb.save(buf) | |
| 120 | + return buf.getvalue(), log | |
| 121 | + | |
| 122 | + | |
| 123 | +def _apply_one(wb: Any, kind: str, op: dict[str, Any]) -> str: # noqa: PLR0911, PLR0912, PLR0915 | |
| 124 | + if kind in {"set_cell", "set"}: | |
| 125 | + ws = _ws(wb, op.get("sheet")) | |
| 126 | + cell = str(op["cell"]).upper() | |
| 127 | + c = ws[cell] | |
| 128 | + c.value = _value(op.get("value")) | |
| 129 | + _apply_format(c, op.get("format")) | |
| 130 | + if op.get("bold"): | |
| 131 | + c.font = Font(bold=True) | |
| 132 | + if op.get("input"): | |
| 133 | + c.fill, c.font = INPUT_FILL, INPUT_FONT | |
| 134 | + if op.get("name"): | |
| 135 | + dn = DefinedName(re.sub(r"[^A-Za-z0-9_]", "_", str(op["name"]))[:60], | |
| 136 | + attr_text=f"'{ws.title}'!${''.join(ch for ch in cell if ch.isalpha())}${''.join(ch for ch in cell if ch.isdigit())}") | |
| 137 | + wb.defined_names[dn.name] = dn | |
| 138 | + return f"{ws.title}!{cell} ← {op.get('value')!r}" | |
| 139 | + | |
| 140 | + if kind in {"set_cells", "set_many"}: | |
| 141 | + ws = _ws(wb, op.get("sheet")) | |
| 142 | + n = 0 | |
| 143 | + for cell, v in (op.get("cells") or {}).items(): | |
| 144 | + ws[str(cell).upper()].value = _value(v) | |
| 145 | + n += 1 | |
| 146 | + return f"{ws.title}: {n} cellule(s) modifiée(s)" | |
| 147 | + | |
| 148 | + if kind in {"set_range", "write_rows", "rows"}: | |
| 149 | + ws = _ws(wb, op.get("sheet")) | |
| 150 | + col0, row0 = coordinate_from_string(str(op.get("anchor", "A1")).upper()) | |
| 151 | + c0 = column_index_from_string(col0) | |
| 152 | + rows = op.get("rows") or [] | |
| 153 | + formats = op.get("formats") or [] | |
| 154 | + for i, row in enumerate(rows): | |
| 155 | + for j, v in enumerate(row if isinstance(row, (list, tuple)) else [row]): | |
| 156 | + c = ws.cell(row=row0 + i, column=c0 + j, value=_value(v)) | |
| 157 | + if j < len(formats): | |
| 158 | + _apply_format(c, formats[j]) | |
| 159 | + if op.get("header"): | |
| 160 | + for j, h in enumerate(op["header"]): | |
| 161 | + c = ws.cell(row=row0 - 1 if row0 > 1 else row0, column=c0 + j, value=str(h)) | |
| 162 | + c.fill, c.font = HEADER_FILL, HEADER_FONT | |
| 163 | + return f"{ws.title}: plage écrite depuis {op.get('anchor', 'A1')} ({len(rows)} ligne(s))" | |
| 164 | + | |
| 165 | + if kind in {"add_column", "append_column"}: | |
| 166 | + ws = _ws(wb, op.get("sheet")) | |
| 167 | + hr = int(op.get("header_row") or _header_row(ws)) | |
| 168 | + col = op.get("column") | |
| 169 | + if col: | |
| 170 | + cidx = column_index_from_string(str(col).upper()) | |
| 171 | + ws.insert_cols(cidx) | |
| 172 | + else: | |
| 173 | + cidx = 1 | |
| 174 | + for c in ws[hr]: | |
| 175 | + if c.value is not None: | |
| 176 | + cidx = max(cidx, c.column + 1) | |
| 177 | + head = ws.cell(row=hr, column=cidx, value=str(op.get("header", "Nouvelle colonne"))) | |
| 178 | + head.fill, head.font = HEADER_FILL, HEADER_FONT | |
| 179 | + head.alignment = Alignment(horizontal="center", wrap_text=True) | |
| 180 | + last = int(op.get("last_row") or _last_data_row(ws, hr)) | |
| 181 | + values = op.get("values") | |
| 182 | + formula = op.get("formula") | |
| 183 | + n = 0 | |
| 184 | + for r in range(hr + 1, last + 1): | |
| 185 | + if values is not None: | |
| 186 | + idx = r - hr - 1 | |
| 187 | + if idx >= len(values): | |
| 188 | + break | |
| 189 | + v = _value(values[idx]) | |
| 190 | + elif formula: | |
| 191 | + v = str(formula).replace("{r}", str(r)).replace("{row}", str(r)) | |
| 192 | + else: | |
| 193 | + v = None | |
| 194 | + c = ws.cell(row=r, column=cidx, value=v) | |
| 195 | + _apply_format(c, op.get("format")) | |
| 196 | + n += 1 | |
| 197 | + ws.column_dimensions[get_column_letter(cidx)].width = 17 | |
| 198 | + return f"{ws.title}: colonne « {op.get('header')} » ajoutée en {get_column_letter(cidx)} ({n} lignes)" | |
| 199 | + | |
| 200 | + if kind in {"add_row", "append_row"}: | |
| 201 | + ws = _ws(wb, op.get("sheet")) | |
| 202 | + hr = int(op.get("header_row") or _header_row(ws)) | |
| 203 | + r = int(op.get("row") or _last_data_row(ws, hr) + 1) | |
| 204 | + if op.get("row"): | |
| 205 | + ws.insert_rows(r) | |
| 206 | + for j, v in enumerate(op.get("values") or []): | |
| 207 | + vv = _value(v) | |
| 208 | + if isinstance(vv, str) and vv.startswith("="): | |
| 209 | + vv = vv.replace("{r}", str(r)).replace("{row}", str(r)) | |
| 210 | + ws.cell(row=r, column=1 + j, value=vv) | |
| 211 | + return f"{ws.title}: ligne ajoutée en {r}" | |
| 212 | + | |
| 213 | + if kind in {"insert_rows", "delete_rows", "insert_cols", "delete_cols"}: | |
| 214 | + ws = _ws(wb, op.get("sheet")) | |
| 215 | + at = int(op.get("at", 1)) | |
| 216 | + count = int(op.get("count", 1)) | |
| 217 | + getattr(ws, kind)(at, count) | |
| 218 | + return f"{ws.title}: {kind} {at} ×{count}" | |
| 219 | + | |
| 220 | + if kind in {"format", "style"}: | |
| 221 | + ws = _ws(wb, op.get("sheet")) | |
| 222 | + rng = str(op.get("range") or op.get("cell") or "A1").upper() | |
| 223 | + min_col, min_row, max_col, max_row = range_boundaries(rng if ":" in rng else f"{rng}:{rng}") | |
| 224 | + for row in ws.iter_rows(min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col): | |
| 225 | + for c in row: | |
| 226 | + _apply_format(c, op.get("format")) | |
| 227 | + if op.get("bold") is not None or op.get("color"): | |
| 228 | + c.font = Font(bold=bool(op.get("bold")), color=str(op.get("color", "1A2B3C")).lstrip("#")) | |
| 229 | + if op.get("fill") == "input": | |
| 230 | + c.fill, c.font = INPUT_FILL, INPUT_FONT | |
| 231 | + elif op.get("fill") == "header": | |
| 232 | + c.fill, c.font = HEADER_FILL, HEADER_FONT | |
| 233 | + elif op.get("fill"): | |
| 234 | + c.fill = PatternFill("solid", fgColor=str(op["fill"]).lstrip("#")) | |
| 235 | + if op.get("total"): | |
| 236 | + c.border = TOTAL_BORDER | |
| 237 | + c.font = Font(bold=True, color=UQO_BLUE_DARK) | |
| 238 | + if op.get("wrap"): | |
| 239 | + c.alignment = Alignment(wrap_text=True, vertical="top") | |
| 240 | + return f"{ws.title}!{rng} formaté" | |
| 241 | + | |
| 242 | + if kind in {"add_sheet", "new_sheet"}: | |
| 243 | + name = re.sub(r"[\[\]\*\?/\\:]", " ", str(op.get("name", "Feuille")))[:31] | |
| 244 | + if name in wb.sheetnames: | |
| 245 | + name = (name[:27] + " (2)") | |
| 246 | + ws = wb.create_sheet(name) | |
| 247 | + ws.sheet_view.showGridLines = False | |
| 248 | + ws.column_dimensions["A"].width = 40 | |
| 249 | + if op.get("title"): | |
| 250 | + ws["A1"] = str(op["title"]) | |
| 251 | + ws["A1"].font = Font(bold=True, size=14, color=UQO_BLUE_DARK) | |
| 252 | + table = op.get("table") or {} | |
| 253 | + if table.get("rows") or table.get("columns"): | |
| 254 | + from app.tools.create_excel import _render_sheet | |
| 255 | + from app.tools.excel_spec import normalise_spec | |
| 256 | + | |
| 257 | + spec = normalise_spec({"sheets": [{"name": name, "title": op.get("title") or name, "tables": [table], | |
| 258 | + "notes": op.get("notes") or []}]}) | |
| 259 | + _render_sheet(wb, ws, spec["sheets"][0]) | |
| 260 | + return f"feuille « {name} » ajoutée" | |
| 261 | + | |
| 262 | + if kind == "rename_sheet": | |
| 263 | + ws = _ws(wb, op.get("sheet")) | |
| 264 | + old = ws.title | |
| 265 | + ws.title = re.sub(r"[\[\]\*\?/\\:]", " ", str(op.get("name", old)))[:31] | |
| 266 | + return f"feuille « {old} » renommée « {ws.title} »" | |
| 267 | + | |
| 268 | + if kind == "delete_sheet": | |
| 269 | + ws = _ws(wb, op.get("sheet")) | |
| 270 | + if len(wb.worksheets) <= 1: | |
| 271 | + raise ExcelOpError("Impossible de supprimer la dernière feuille.") | |
| 272 | + wb.remove(ws) | |
| 273 | + return "feuille supprimée" | |
| 274 | + | |
| 275 | + if kind == "add_chart": | |
| 276 | + ws = _ws(wb, op.get("sheet")) | |
| 277 | + ctype = str(op.get("type", "bar")).lower() | |
| 278 | + chart: Any = {"line": LineChart, "pie": PieChart, "scatter": ScatterChart}.get(ctype, BarChart)() | |
| 279 | + if ctype == "bar": | |
| 280 | + chart.type = "col" | |
| 281 | + chart.title = op.get("title", "") | |
| 282 | + chart.height, chart.width = 8, 14 | |
| 283 | + vr = str(op.get("values_range") or op.get("data_range")) | |
| 284 | + vmin_col, vmin_row, vmax_col, vmax_row = range_boundaries(vr) | |
| 285 | + values = Reference(ws, min_col=vmin_col, min_row=vmin_row, max_col=vmax_col, max_row=vmax_row) | |
| 286 | + if ctype == "scatter" and op.get("categories_range"): | |
| 287 | + cmin_col, cmin_row, cmax_col, cmax_row = range_boundaries(str(op["categories_range"])) | |
| 288 | + xs = Reference(ws, min_col=cmin_col, min_row=cmin_row, max_col=cmax_col, max_row=cmax_row) | |
| 289 | + chart.series.append(Series(values, xs, title=op.get("series_title", ""))) | |
| 290 | + else: | |
| 291 | + chart.add_data(values, titles_from_data=bool(op.get("titles_from_data", False))) | |
| 292 | + if op.get("categories_range"): | |
| 293 | + cmin_col, cmin_row, cmax_col, cmax_row = range_boundaries(str(op["categories_range"])) | |
| 294 | + chart.set_categories(Reference(ws, min_col=cmin_col, min_row=cmin_row, max_col=cmax_col, max_row=cmax_row)) | |
| 295 | + if ctype != "pie": | |
| 296 | + chart.legend = None | |
| 297 | + ws.add_chart(chart, str(op.get("anchor", "H2"))) | |
| 298 | + return f"{ws.title}: graphique {ctype} ajouté" | |
| 299 | + | |
| 300 | + if kind in {"add_note", "comment"}: | |
| 301 | + ws = _ws(wb, op.get("sheet")) | |
| 302 | + c = ws[str(op.get("cell", "A1")).upper()] | |
| 303 | + c.comment = Comment(str(op.get("text", ""))[:1000], "UQO-Chat") | |
| 304 | + return f"{ws.title}!{c.coordinate}: commentaire ajouté" | |
| 305 | + | |
| 306 | + if kind in {"add_text", "write_text", "note"}: | |
| 307 | + ws = _ws(wb, op.get("sheet")) | |
| 308 | + c = ws[str(op.get("cell", f"A{ws.max_row + 2}")).upper()] | |
| 309 | + c.value = str(op.get("text", "")) | |
| 310 | + c.alignment = Alignment(wrap_text=True, vertical="top") | |
| 311 | + if op.get("bold"): | |
| 312 | + c.font = Font(bold=True, color=UQO_BLUE_DARK) | |
| 313 | + return f"{ws.title}!{c.coordinate}: texte écrit" | |
| 314 | + | |
| 315 | + if kind in {"set_column_width", "column_width"}: | |
| 316 | + ws = _ws(wb, op.get("sheet")) | |
| 317 | + ws.column_dimensions[str(op.get("column", "A")).upper()].width = float(op.get("width", 18)) | |
| 318 | + return f"{ws.title}: largeur {op.get('column')} = {op.get('width')}" | |
| 319 | + | |
| 320 | + if kind in {"clear", "clear_range"}: | |
| 321 | + ws = _ws(wb, op.get("sheet")) | |
| 322 | + rng = str(op.get("range") or op.get("cell")).upper() | |
| 323 | + min_col, min_row, max_col, max_row = range_boundaries(rng if ":" in rng else f"{rng}:{rng}") | |
| 324 | + for row in ws.iter_rows(min_row=min_row, max_row=max_row, min_col=min_col, max_col=max_col): | |
| 325 | + for c in row: | |
| 326 | + c.value = None | |
| 327 | + return f"{ws.title}!{rng} effacé" | |
| 328 | + | |
| 329 | + if kind in {"freeze", "freeze_panes"}: | |
| 330 | + ws = _ws(wb, op.get("sheet")) | |
| 331 | + ws.freeze_panes = str(op.get("cell", "A2")).upper() | |
| 332 | + return f"{ws.title}: volets figés à {op.get('cell', 'A2')}" | |
| 333 | + | |
| 334 | + raise ExcelOpError( | |
| 335 | + f"Opération inconnue « {kind} ». Opérations : set_cell, set_cells, set_range, add_column, add_row, " | |
| 336 | + "insert_rows, delete_rows, insert_cols, delete_cols, format, add_sheet, rename_sheet, delete_sheet, " | |
| 337 | + "add_chart, add_note, add_text, set_column_width, clear, freeze.") | |
| 338 | + | |
| 339 | + | |
| 340 | +def _last_data_row(ws: Any, header_row: int) -> int: | |
| 341 | + last = header_row | |
| 342 | + for r in range(header_row + 1, ws.max_row + 1): | |
| 343 | + if any(c.value is not None for c in ws[r][: max(1, min(ws.max_column, 30))]): | |
| 344 | + last = r | |
| 345 | + else: | |
| 346 | + break | |
| 347 | + return last | |
added
backend/app/tools/make_chart.py
+143 −0
@@ -0,0 +1,143 @@ | ||
| 1 | +"""make_chart — render a chart PNG from data (no code execution), UQO palette.""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +import io | |
| 6 | +from typing import Any, Literal | |
| 7 | + | |
| 8 | +from pydantic import BaseModel, Field | |
| 9 | + | |
| 10 | +from app.llm.schemas import Artifact, ToolResult | |
| 11 | +from app.services import files as file_service | |
| 12 | +from app.tools.coerce import num | |
| 13 | +from app.tools.registry import ToolContext, registry | |
| 14 | + | |
| 15 | +PALETTE = ["#00467F", "#78BE20", "#C6A300", "#5B6B7B", "#7FA7C9", "#C8102E", "#2E8B57", "#8B5CF6"] | |
| 16 | + | |
| 17 | + | |
| 18 | +class Series(BaseModel): | |
| 19 | + name: str = "" | |
| 20 | + values: list[float | str | None] | |
| 21 | + | |
| 22 | + | |
| 23 | +class 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 = True | |
| 33 | + | |
| 34 | + | |
| 35 | +def _fmt(kind: str): # noqa: ANN202 | |
| 36 | + from matplotlib.ticker import FuncFormatter | |
| 37 | + | |
| 38 | + 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}") | |
| 43 | + | |
| 44 | + | |
| 45 | +def render(args: dict[str, Any]) -> bytes: | |
| 46 | + import matplotlib | |
| 47 | + | |
| 48 | + matplotlib.use("Agg") | |
| 49 | + import matplotlib.pyplot as plt | |
| 50 | + import numpy as np | |
| 51 | + | |
| 52 | + 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_a | |
| 72 | + 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 / area | |
| 99 | + 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="#003057", 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() | |
| 123 | + | |
| 124 | + | |
| 125 | +async 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']}"}) | |
| 141 | + | |
| 142 | + | |
| 143 | +registry.register("make_chart", run, Args, heavy=True) | |
added
backend/app/tools/schemas/appraisal_calc.json
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +{ | |
| 2 | + "name": "appraisal_calc", | |
| 3 | + "description": "Calculateurs d'évaluation immobilière déterministes (sans LLM), avec tableau de résultats. Fonctions et paramètres :\n- cost_approach {land_value*, cost_new*, physical_depreciation | effective_age+economic_life, functional_depreciation, external_depreciation, site_improvements}\n- breakdown_depreciation {cost_new*, curable_physical:[{item, cost_to_cure}], short_lived:[{item, cost_new, effective_age, life}], long_lived:{effective_age, economic_life}, functional:[{item, type, amount | cost_to_cure+cost_if_new | rent_loss_annual+cap_rate}], external:{amount | rent_loss_annual+cap_rate+building_share}} (ventilation anti-double-comptage)\n- indexed_cost {historical_cost*, index_then*, index_now*, regional_factor, size_factor}\n- unit_cost_estimate {area*, unit, cost_per_unit*, factors:[…], extras:[{item, amount}], indirect_pct, profit_pct}\n- land_extraction {sale_price*, improvements_depreciated_cost | cost_new+depreciation}\n- land_allocation {total_value*, land_ratio}\n- land_residual {noi*, building_value*, building_rate, land_rate}\n- land_subdivision {lots*, price_per_lot*, development_costs, selling_costs, carrying_costs, profit_pct, absorption_years, discount_rate}\n- direct_capitalization {noi | potential_gross_income*+vacancy_pct+other_income+operating_expenses|expense_ratio, cap_rate}\n- gross_income_multiplier {sale_price+gross_income → MRB | gim+gross_income → valeur}\n- adjust_comparables {subject:{superficie:1200, garage:'Oui', etat:'bon', …}, rates:{superficie:120, garage:15000, etat:10000}, scales:{etat:['moyen','bon','très bon']}, monthly_trend:0.005, comparables:[{address, price, months | time_pct, superficie, garage, etat, adjustments:{…$}}]} → prix ajustés, % nets/bruts, fiabilité, médiane, pondération\n- effective_age_market {sale_price*, land_value*, cost_new*, economic_life*, actual_age}\nLes pourcentages acceptent 0.12, 12 ou '12 %'. Utilise ensuite create_excel si l'étudiant veut le livrable.", | |
| 4 | + "parameters": { | |
| 5 | + "type": "object", | |
| 6 | + "properties": { | |
| 7 | + "function": { "type": "string", "enum": ["cost_approach", "breakdown_depreciation", "indexed_cost", "unit_cost_estimate", "land_extraction", "land_allocation", "land_residual", "land_subdivision", "direct_capitalization", "gross_income_multiplier", "adjust_comparables", "effective_age_market"] }, | |
| 8 | + "params": { "type": "object", "additionalProperties": true } | |
| 9 | + }, | |
| 10 | + "required": ["function", "params"] | |
| 11 | + } | |
| 12 | +} | |
added
backend/app/tools/schemas/create_docx.json
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +{ | |
| 2 | + "name": "create_docx", | |
| 3 | + "description": "Crée un document Word (.docx) stylé UQO à partir de Markdown : fiche de révision, plan d'étude, résumé de séance, gabarit de structure de rapport (sections + liste de vérification, jamais le contenu rédigé d'un travail noté), corrigé d'exercice fictif. Supporte titres (#), listes, tableaux Markdown, gras/italique, citations, blocs de code, formules $…$ converties en texte.", | |
| 4 | + "parameters": { | |
| 5 | + "type": "object", | |
| 6 | + "properties": { | |
| 7 | + "title": { "type": "string" }, | |
| 8 | + "markdown": { "type": "string" }, | |
| 9 | + "filename": { "type": "string" }, | |
| 10 | + "course": { "type": "string", "description": "IMM1003 ou IMM1033" }, | |
| 11 | + "subtitle": { "type": "string" } | |
| 12 | + }, | |
| 13 | + "required": ["title", "markdown"] | |
| 14 | + } | |
| 15 | +} | |
added
backend/app/tools/schemas/edit_excel.json
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +{ | |
| 2 | + "name": "edit_excel", | |
| 3 | + "description": "Modifie un classeur Excel existant (déposé ou généré) et enregistre une nouvelle version (_v2, _v3…), l'original étant conservé. Opérations (liste ordonnée) : {op:'set_cell', sheet, cell:'B4', value:123|'=B2*2'|'texte', format:'currency|percent|number|integer|area|text', bold, input:true (cellule bleue d'hypothèse), name} · {op:'set_cells', sheet, cells:{'B4':1,'B5':'=B4*2'}} · {op:'set_range', sheet, anchor:'A10', rows:[[…]], header:[…], formats:[…]} · {op:'add_column', sheet, header, values:[…] OU formula:'=E{r}*0.02' ({r} = numéro de ligne), format, column:'H' (insère avant H ; défaut : après la dernière), header_row} · {op:'add_row', sheet, values:[…], row} · {op:'insert_rows'|'delete_rows'|'insert_cols'|'delete_cols', sheet, at, count} · {op:'format', sheet, range:'B5:B9', format, bold, fill:'input'|'header'|'RRGGBB', total:true, wrap} · {op:'add_sheet', name, title, table:{columns, rows, anchor}, notes} · {op:'rename_sheet', sheet, name} · {op:'delete_sheet', sheet} · {op:'add_chart', sheet, type:'bar|line|pie|scatter', title, categories_range, values_range, anchor} · {op:'add_note', sheet, cell, text} (commentaire) · {op:'add_text', sheet, cell, text, bold} · {op:'set_column_width', sheet, column, width} · {op:'clear', sheet, range} · {op:'freeze', sheet, cell}. Utilise inspect_excel d'abord pour viser les bonnes cellules.", | |
| 4 | + "parameters": { | |
| 5 | + "type": "object", | |
| 6 | + "properties": { | |
| 7 | + "file_id": { "type": "string" }, | |
| 8 | + "operations": { "type": "array", "items": { "type": "object", "additionalProperties": true } }, | |
| 9 | + "filename": { "type": "string", "description": "Nom du nouveau fichier (défaut : <original>_v2.xlsx)." } | |
| 10 | + }, | |
| 11 | + "required": ["file_id", "operations"] | |
| 12 | + } | |
| 13 | +} | |
added
backend/app/tools/schemas/inspect_excel.json
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +{ | |
| 2 | + "name": "inspect_excel", | |
| 3 | + "description": "Lit un classeur Excel existant (déposé par l'étudiant OU déjà généré dans la conversation) : feuilles, dimensions, cellules avec leurs formules, noms définis. À appeler AVANT edit_excel pour connaître la structure exacte (lignes d'en-tête, colonnes, formules).", | |
| 4 | + "parameters": { | |
| 5 | + "type": "object", | |
| 6 | + "properties": { | |
| 7 | + "file_id": { "type": "string", "description": "Identifiant du fichier (voir le contexte : fichiers déposés et classeurs générés)." }, | |
| 8 | + "sheet": { "type": "string", "description": "Nom de la feuille (toutes si omis)." }, | |
| 9 | + "max_rows": { "type": "integer", "minimum": 5, "maximum": 300, "default": 60 } | |
| 10 | + }, | |
| 11 | + "required": ["file_id"] | |
| 12 | + } | |
| 13 | +} | |
added
backend/app/tools/schemas/make_chart.json
+19 −0
@@ -0,0 +1,19 @@ | ||
| 1 | +{ | |
| 2 | + "name": "make_chart", | |
| 3 | + "description": "Trace un graphique (PNG, palette UQO) directement à partir de données, sans écrire de code : bar, stacked_bar, hbar, line, area, pie, scatter (avec droite de tendance). Idéal pour comparer des prix ajustés, montrer une courbe âge-vie, une ventilation de dépréciation ou l'évolution d'un indice. Pour des analyses plus complexes, utilise execute_python.", | |
| 4 | + "parameters": { | |
| 5 | + "type": "object", | |
| 6 | + "properties": { | |
| 7 | + "type": { "type": "string", "enum": ["bar", "stacked_bar", "hbar", "line", "area", "pie", "scatter"], "default": "bar" }, | |
| 8 | + "title": { "type": "string" }, | |
| 9 | + "x": { "type": "array", "items": { "type": ["string", "number"] }, "description": "Catégories ou abscisses." }, | |
| 10 | + "series": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "values": { "type": "array", "items": { "type": ["number", "string", "null"] } } }, "required": ["values"] } }, | |
| 11 | + "xlabel": { "type": "string" }, | |
| 12 | + "ylabel": { "type": "string" }, | |
| 13 | + "value_format": { "type": "string", "enum": ["number", "currency", "percent"], "default": "number" }, | |
| 14 | + "filename": { "type": "string" }, | |
| 15 | + "annotate": { "type": "boolean", "default": true } | |
| 16 | + }, | |
| 17 | + "required": ["series"] | |
| 18 | + } | |
| 19 | +} | |
added
backend/app/tools/schemas/unit_convert.json
+13 −0
@@ -0,0 +1,13 @@ | ||
| 1 | +{ | |
| 2 | + "name": "unit_convert", | |
| 3 | + "description": "Convertit superficies, longueurs et prix unitaires (m² ↔ pi², hectare, acre, arpent carré québécois, m ↔ pi ↔ po, $/m² ↔ $/pi²). Déterministe, avec la formule.", | |
| 4 | + "parameters": { | |
| 5 | + "type": "object", | |
| 6 | + "properties": { | |
| 7 | + "value": { "type": ["number", "string"] }, | |
| 8 | + "from_unit": { "type": "string", "description": "m2, pi2, ha, acre, arpent, km2, m, pi, po, cm, km, $/m2, $/pi2, $/acre…" }, | |
| 9 | + "to_unit": { "type": "string" } | |
| 10 | + }, | |
| 11 | + "required": ["value", "from_unit", "to_unit"] | |
| 12 | + } | |
| 13 | +} | |
added
backend/app/tools/unit_convert.py
+77 −0
@@ -0,0 +1,77 @@ | ||
| 1 | +"""unit_convert — area / length / price-per-unit conversions incl. Québec units (arpent).""" | |
| 2 | + | |
| 3 | +from __future__ import annotations | |
| 4 | + | |
| 5 | +from typing import Any | |
| 6 | + | |
| 7 | +from pydantic import BaseModel, Field | |
| 8 | + | |
| 9 | +from app.llm.schemas import ToolResult | |
| 10 | +from app.tools.coerce import num | |
| 11 | +from app.tools.registry import ToolContext, registry | |
| 12 | + | |
| 13 | +# base: m² for areas, m for lengths | |
| 14 | +AREA = { | |
| 15 | + "m2": 1.0, "m²": 1.0, "metre carre": 1.0, "mètre carré": 1.0, | |
| 16 | + "pi2": 0.09290304, "pi²": 0.09290304, "sqft": 0.09290304, "ft2": 0.09290304, "pied carré": 0.09290304, | |
| 17 | + "ha": 10000.0, "hectare": 10000.0, | |
| 18 | + "acre": 4046.8564224, "ac": 4046.8564224, | |
| 19 | + "arpent2": 3418.894, "arpent carré": 3418.894, "arpent²": 3418.894, "arpent": 3418.894, | |
| 20 | + "km2": 1e6, "km²": 1e6, | |
| 21 | + "vg2": 0.83612736, "verge carrée": 0.83612736, "yd2": 0.83612736, | |
| 22 | +} | |
| 23 | +LENGTH = { | |
| 24 | + "m": 1.0, "metre": 1.0, "mètre": 1.0, "cm": 0.01, "mm": 0.001, "km": 1000.0, | |
| 25 | + "pi": 0.3048, "ft": 0.3048, "pied": 0.3048, "po": 0.0254, "in": 0.0254, "pouce": 0.0254, | |
| 26 | + "vg": 0.9144, "yd": 0.9144, "verge": 0.9144, "mi": 1609.344, "mille": 1609.344, | |
| 27 | + "arpent_lin": 58.47, "arpent linéaire": 58.47, "perche": 5.847, | |
| 28 | +} | |
| 29 | +ALIASES = {"pieds carrés": "pi2", "pi.ca.": "pi2", "pc": "pi2", "sq ft": "pi2", "metres carres": "m2", | |
| 30 | + "mètres carrés": "m2", "hectares": "ha", "acres": "acre", "arpents": "arpent", "pieds": "pi", | |
| 31 | + "pouces": "po", "metres": "m", "mètres": "m"} | |
| 32 | + | |
| 33 | + | |
| 34 | +class Args(BaseModel): | |
| 35 | + value: float | str | |
| 36 | + from_unit: str = Field(..., description="ex. m2, pi2, acre, ha, arpent, m, pi, po, $/m2, $/pi2") | |
| 37 | + to_unit: str | |
| 38 | + | |
| 39 | + | |
| 40 | +def _norm(u: str) -> str: | |
| 41 | + u = u.strip().lower().replace(" ", " ") | |
| 42 | + return ALIASES.get(u, u) | |
| 43 | + | |
| 44 | + | |
| 45 | +def convert(value: float, from_unit: str, to_unit: str) -> dict[str, Any]: | |
| 46 | + f, t = _norm(from_unit), _norm(to_unit) | |
| 47 | + per = f.startswith("$/") and t.startswith("$/") | |
| 48 | + if per: | |
| 49 | + f, t = f[2:], t[2:] | |
| 50 | + for table, kind in ((AREA, "superficie"), (LENGTH, "longueur")): | |
| 51 | + if f in table and t in table: | |
| 52 | + factor = table[f] / table[t] | |
| 53 | + if per: # $/from → $/to : inverse relationship | |
| 54 | + factor = table[t] / table[f] | |
| 55 | + return {"result": value * factor, "factor": factor, "kind": f"prix par {kind}", | |
| 56 | + "formula": f"{value:g} $/{f} × ({table[t]:g} / {table[f]:g}) = {value * factor:,.4f} $/{t}"} | |
| 57 | + return {"result": value * factor, "factor": factor, "kind": kind, | |
| 58 | + "formula": f"{value:g} {f} × {factor:,.6g} = {value * factor:,.4f} {t}"} | |
| 59 | + raise ValueError(f"unités incompatibles ou inconnues : {from_unit} → {to_unit}") | |
| 60 | + | |
| 61 | + | |
| 62 | +async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult: | |
| 63 | + v = num(args["value"], None) | |
| 64 | + if v is None: | |
| 65 | + return ToolResult(content="Valeur numérique invalide.", error=True) | |
| 66 | + try: | |
| 67 | + out = convert(v, args["from_unit"], args["to_unit"]) | |
| 68 | + except ValueError as exc: | |
| 69 | + return ToolResult(content=f"{exc}. Unités : {', '.join(sorted(set(AREA) | set(LENGTH)))} ; " | |
| 70 | + "préfixe $/ pour les prix unitaires.", error=True) | |
| 71 | + return ToolResult(content=f"{out['formula']} (facteur {out['factor']:.6g}, {out['kind']}). " | |
| 72 | + "Rappels : 1 m² = 10,7639 pi² ; 1 pi = 0,3048 m ; 1 arpent carré ≈ 3 418,9 m² ; 1 acre = 4 046,86 m².", | |
| 73 | + payload={"value": v, "from": args["from_unit"], "to": args["to_unit"], **out}, | |
| 74 | + meta={"summary": out["formula"]}) | |
| 75 | + | |
| 76 | + | |
| 77 | +registry.register("unit_convert", run, Args) | |
added
backend/tests/tools/test_new_tools.py
+104 −0
@@ -0,0 +1,104 @@ | ||
| 1 | +import io | |
| 2 | + | |
| 3 | +import pytest | |
| 4 | +from openpyxl import load_workbook | |
| 5 | + | |
| 6 | +from app.tools import excel_ops | |
| 7 | +from app.tools.appraisal_calc import FUNCS | |
| 8 | +from app.tools.create_docx import build as build_docx | |
| 9 | +from app.tools.create_excel import build_workbook | |
| 10 | +from app.tools.excel_templates import TEMPLATES | |
| 11 | +from app.tools.make_chart import render | |
| 12 | +from app.tools.unit_convert import convert | |
| 13 | + | |
| 14 | + | |
| 15 | +def _workbook() -> bytes: | |
| 16 | + return build_workbook(TEMPLATES["comparables_ajustes"]({}))[0] | |
| 17 | + | |
| 18 | + | |
| 19 | +def test_inspect_and_preview() -> None: | |
| 20 | + data = _workbook() | |
| 21 | + info = excel_ops.inspect(data) | |
| 22 | + assert any(s["name"] == "Comparables" for s in info["sheets"]) | |
| 23 | + assert any("=" in line for s in info["sheets"] for line in s["cells"]) | |
| 24 | + pv = excel_ops.preview(data) | |
| 25 | + assert pv["sheet"] == "Comparables" and len(pv["all"]) >= 2 | |
| 26 | + | |
| 27 | + | |
| 28 | +def test_edit_operations_add_column_row_sheet_chart() -> None: | |
| 29 | + data = _workbook() | |
| 30 | + hr = excel_ops._header_row(load_workbook(io.BytesIO(data))["Comparables"]) | |
| 31 | + new, log = excel_ops.apply_operations(data, [ | |
| 32 | + {"op": "add_column", "sheet": "Comparables", "header": "Piscine ($)", "formula": "=B{r}*0.02", "format": "currency"}, | |
| 33 | + {"op": "set_cell", "sheet": "Comparables", "cell": "B4", "value": "1 250 pi²", "input": True}, | |
| 34 | + {"op": "add_sheet", "name": "Synthèse", "title": "Synthèse", "table": {"columns": ["Poste", "Montant ($)"], | |
| 35 | + "rows": [["Terrain", "120 000 $"], ["Bâtiment", 300000]], | |
| 36 | + "totals": {"label": "Total", "formula": "=B5+B6"}}}, | |
| 37 | + {"op": "add_chart", "sheet": "Synthèse", "type": "pie", "categories_range": "A5:A6", "values_range": "B5:B6", "anchor": "E4"}, | |
| 38 | + {"op": "add_note", "sheet": "Comparables", "cell": "A1", "text": "Grille révisée"}, | |
| 39 | + {"op": "format", "sheet": "Comparables", "range": "B4:B4", "format": "area", "bold": True}, | |
| 40 | + ]) | |
| 41 | + wb = load_workbook(io.BytesIO(new)) | |
| 42 | + ws = wb["Comparables"] | |
| 43 | + headers = [c.value for c in ws[hr]] | |
| 44 | + assert "Piscine ($)" in headers | |
| 45 | + col = headers.index("Piscine ($)") + 1 | |
| 46 | + assert ws.cell(row=hr + 1, column=col).value == f"=B{hr + 1}*0.02" | |
| 47 | + assert ws["B4"].value == 1250 and ws["A1"].comment is not None | |
| 48 | + assert "Synthèse" in wb.sheetnames and wb["Synthèse"]["B7"].value == "=B5+B6" | |
| 49 | + assert len(log) == 6 | |
| 50 | + | |
| 51 | + | |
| 52 | +def test_edit_unknown_op_raises() -> None: | |
| 53 | + with pytest.raises(excel_ops.ExcelOpError): | |
| 54 | + excel_ops.apply_operations(_workbook(), [{"op": "explode"}]) | |
| 55 | + | |
| 56 | + | |
| 57 | +def test_appraisal_functions() -> None: | |
| 58 | + ca = FUNCS["cost_approach"]({"land_value": "120 000 $", "cost_new": 322000, "effective_age": 15, "economic_life": 60}) | |
| 59 | + assert round(ca["value"]) == 361500 | |
| 60 | + bd = FUNCS["breakdown_depreciation"]({ | |
| 61 | + "cost_new": 500000, | |
| 62 | + "curable_physical": [{"item": "peinture", "cost_to_cure": 8000}], | |
| 63 | + "short_lived": [{"item": "toiture", "cost_new": 30000, "effective_age": 10, "life": 20}], | |
| 64 | + "long_lived": {"effective_age": 15, "economic_life": 60}, | |
| 65 | + "functional": [{"item": "cuisine", "type": "déficience", "cost_to_cure": 25000, "cost_if_new": 15000}], | |
| 66 | + "external": {"rent_loss_annual": 2400, "cap_rate": "8 %", "building_share": 0.8}, | |
| 67 | + }) | |
| 68 | + assert bd["components"]["curable_physical"] == 8000 and bd["components"]["short_lived"] == 15000 | |
| 69 | + assert round(bd["components"]["long_lived"]) == round((500000 - 8000 - 30000) * 15 / 60) | |
| 70 | + assert bd["components"]["functional"] == 10000 and round(bd["components"]["external"]) == 24000 | |
| 71 | + grid = FUNCS["adjust_comparables"]({ | |
| 72 | + "subject": {"superficie": 1200, "garage": "Oui", "etat": "bon"}, | |
| 73 | + "rates": {"superficie": 120, "garage": 15000, "etat": 10000}, | |
| 74 | + "monthly_trend": "0,5 %", | |
| 75 | + "comparables": [{"address": "A", "price": 425000, "months": 6, "superficie": 1150, "garage": "Oui", "etat": "bon"}, | |
| 76 | + {"address": "B", "price": 398000, "months": 8, "superficie": 1250, "garage": "Non", "etat": "moyen"}], | |
| 77 | + }) | |
| 78 | + a = grid["comparables"][0] | |
| 79 | + assert round(a["time_adjusted"]) == round(425000 * 1.03) and a["adjustments"]["superficie"] == 6000 | |
| 80 | + b = grid["comparables"][1] | |
| 81 | + assert b["adjustments"]["garage"] == 15000 and b["adjustments"]["etat"] == 10000 | |
| 82 | + assert grid["stats"]["least_adjusted"] == "A" | |
| 83 | + dc = FUNCS["direct_capitalization"]({"potential_gross_income": 100000, "vacancy_pct": 5, "operating_expenses": 40000, "cap_rate": 7}) | |
| 84 | + assert round(dc["value"]) == round(55000 / 0.07) | |
| 85 | + | |
| 86 | + | |
| 87 | +def test_unit_convert() -> None: | |
| 88 | + assert abs(convert(100, "m2", "pi2")["result"] - 1076.39) < 0.01 | |
| 89 | + assert abs(convert(2450, "$/m2", "$/pi2")["result"] - 227.61) < 0.01 | |
| 90 | + assert abs(convert(1, "arpent", "m2")["result"] - 3418.894) < 0.01 | |
| 91 | + with pytest.raises(ValueError): | |
| 92 | + convert(1, "m2", "pi") | |
| 93 | + | |
| 94 | + | |
| 95 | +def test_make_chart_and_docx() -> None: | |
| 96 | + png = render({"type": "bar", "title": "Prix ajustés", "x": ["A", "B", "C"], | |
| 97 | + "series": [{"name": "Prix", "values": [425000, "398 000 $", 449000]}], "value_format": "currency", | |
| 98 | + "annotate": True}) | |
| 99 | + assert png[:8] == b"\x89PNG\r\n\x1a\n" and len(png) > 5000 | |
| 100 | + docx = build_docx({"title": "Fiche — Dépréciation", "course": "IMM1033", "subtitle": "", | |
| 101 | + "markdown": "# Définitions\n\nLa **dépréciation** est $D = \\frac{A_e}{DVE} \\times C_N$.\n\n" | |
| 102 | + "- physique\n- fonctionnelle\n\n| Type | Exemple |\n|---|---|\n| Physique | toiture |\n\n" | |
| 103 | + "1. étape un\n2. étape deux\n\n> Rappel : le terrain ne se déprécie pas."}) | |
| 104 | + assert docx[:2] == b"PK" and len(docx) > 10000 | |
modified
frontend/src/components/chat/composer.tsx
+10 −0
@@ -4,6 +4,7 @@ import { api } from '@/lib/api'; | ||
| 4 | 4 | import type { UploadedFile } from '@/lib/types'; |
| 5 | 5 | import { cn } from '@/lib/cn'; |
| 6 | 6 | import { fmtBytes } from '@/lib/format'; |
| 7 | +import { useUI } from '@/stores/ui'; | |
| 7 | 8 | |
| 8 | 9 | interface SpeechRecognitionLike { start(): void; stop(): void; lang: string; interimResults: boolean; continuous: boolean; onresult: ((e: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null; onend: (() => void) | null } |
| 9 | 10 | declare global { interface Window { webkitSpeechRecognition?: new () => SpeechRecognitionLike; SpeechRecognition?: new () => SpeechRecognitionLike } } |
@@ -30,6 +31,15 @@ export function Composer({ conversationId, streaming, onSend, onStop, deep, onTo | ||
| 30 | 31 | const fileRef = useRef<HTMLInputElement>(null); |
| 31 | 32 | const recRef = useRef<SpeechRecognitionLike | null>(null); |
| 32 | 33 | const speechOk = typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition); |
| 34 | + const draft = useUI((st) => st.draft); | |
| 35 | + const setDraft = useUI((st) => st.setDraft); | |
| 36 | + useEffect(() => { | |
| 37 | + if (draft) { | |
| 38 | + setText(draft); | |
| 39 | + setDraft(''); | |
| 40 | + setTimeout(() => { taRef.current?.focus(); taRef.current?.setSelectionRange(taRef.current.value.length, taRef.current.value.length); }, 50); | |
| 41 | + } | |
| 42 | + }, [draft, setDraft]); | |
| 33 | 43 | |
| 34 | 44 | useEffect(() => { |
| 35 | 45 | const ta = taRef.current; |
modified
frontend/src/components/chat/tool-call-timeline.tsx
+38 −22
@@ -1,54 +1,70 @@ | ||
| 1 | 1 | import { useState } from 'react'; |
| 2 | −import { BookOpen, Calculator, ChevronDown, FileSearch, FileSpreadsheet, Globe, ListChecks, Loader2, TerminalSquare, AlertTriangle, Check } from 'lucide-react'; | |
| 2 | +import { | |
| 3 | + AlertTriangle, BarChart3, BookOpen, Calculator, Check, ChevronDown, FileEdit, FileSearch, FileSpreadsheet, | |
| 4 | + FileText, Globe, ListChecks, Loader2, Ruler, Sigma, TerminalSquare, | |
| 5 | +} from 'lucide-react'; | |
| 3 | 6 | import type { ToolCallView } from '@/lib/types'; |
| 4 | 7 | import { fmtDuration } from '@/lib/format'; |
| 5 | 8 | import { cn } from '@/lib/cn'; |
| 6 | 9 | import { ToolCard } from '@/components/tools/tool-card'; |
| 7 | 10 | |
| 8 | −const META: Record<string, { label: string; icon: typeof Globe }> = { | |
| 9 | − search_course_content: { label: 'Recherche dans le matériel du cours', icon: BookOpen }, | |
| 10 | − execute_python: { label: 'Exécution du calcul Python', icon: TerminalSquare }, | |
| 11 | − create_excel: { label: 'Création du fichier Excel', icon: FileSpreadsheet }, | |
| 12 | − web_search: { label: 'Recherche sur le web', icon: Globe }, | |
| 13 | − analyze_file: { label: 'Analyse du fichier déposé', icon: FileSearch }, | |
| 14 | − generate_quiz: { label: 'Préparation du quiz', icon: ListChecks }, | |
| 15 | − financial_calc: { label: 'Calcul financier', icon: Calculator }, | |
| 11 | +export const TOOL_META: Record<string, { label: string; icon: typeof Globe; tint: string }> = { | |
| 12 | + search_course_content: { label: 'Matériel du cours', icon: BookOpen, tint: 'bg-uqo-blue-light text-uqo-blue' }, | |
| 13 | + execute_python: { label: 'Calcul Python', icon: TerminalSquare, tint: 'bg-[#e8f1ff] text-[#1d4ed8]' }, | |
| 14 | + create_excel: { label: 'Classeur Excel', icon: FileSpreadsheet, tint: 'bg-[#e3f3e0] text-[#217346]' }, | |
| 15 | + edit_excel: { label: 'Modification du classeur Excel', icon: FileEdit, tint: 'bg-[#e3f3e0] text-[#217346]' }, | |
| 16 | + inspect_excel: { label: 'Lecture du classeur Excel', icon: FileSearch, tint: 'bg-[#e3f3e0] text-[#217346]' }, | |
| 17 | + web_search: { label: 'Recherche web', icon: Globe, tint: 'bg-[#fff4e0] text-[#b45309]' }, | |
| 18 | + analyze_file: { label: 'Analyse du fichier déposé', icon: FileSearch, tint: 'bg-uqo-blue-light text-uqo-blue' }, | |
| 19 | + generate_quiz: { label: 'Quiz', icon: ListChecks, tint: 'bg-[#f3e8ff] text-[#7e22ce]' }, | |
| 20 | + financial_calc: { label: 'Calcul financier', icon: Calculator, tint: 'bg-neutral-surface text-neutral-text' }, | |
| 21 | + appraisal_calc: { label: "Calcul d'évaluation", icon: Sigma, tint: 'bg-neutral-surface text-neutral-text' }, | |
| 22 | + unit_convert: { label: "Conversion d'unités", icon: Ruler, tint: 'bg-neutral-surface text-neutral-text' }, | |
| 23 | + make_chart: { label: 'Graphique', icon: BarChart3, tint: 'bg-[#e8f1ff] text-[#1d4ed8]' }, | |
| 24 | + create_docx: { label: 'Document Word', icon: FileText, tint: 'bg-[#e6ecfb] text-[#2b579a]' }, | |
| 16 | 25 | }; |
| 17 | 26 | |
| 27 | +const COLLAPSED_BY_DEFAULT = new Set(['financial_calc', 'search_course_content', 'unit_convert', 'inspect_excel']); | |
| 28 | + | |
| 18 | 29 | export function ToolCallTimeline({ calls, conversationId }: { calls: ToolCallView[]; conversationId: string }) { |
| 19 | 30 | const [open, setOpen] = useState<Record<string, boolean>>({}); |
| 20 | 31 | if (!calls.length) return null; |
| 21 | 32 | return ( |
| 22 | − <ol className="my-2 space-y-1.5"> | |
| 33 | + <ol className="my-2 space-y-2"> | |
| 23 | 34 | {calls.map((c) => { |
| 24 | − const meta = META[c.name] || { label: c.name, icon: Calculator }; | |
| 35 | + const meta = TOOL_META[c.name] || { label: c.name, icon: Calculator, tint: 'bg-neutral-surface text-neutral-text' }; | |
| 25 | 36 | const Icon = meta.icon; |
| 26 | 37 | const running = c.status === 'running'; |
| 27 | − const expanded = open[c.id] ?? (c.name !== 'financial_calc' && c.name !== 'search_course_content'); | |
| 38 | + const error = c.status === 'error'; | |
| 39 | + const expanded = open[c.id] ?? !COLLAPSED_BY_DEFAULT.has(c.name); | |
| 28 | 40 | return ( |
| 29 | − <li key={c.id} className="rounded-xl border border-neutral-line bg-white shadow-card overflow-hidden animate-fadein"> | |
| 41 | + <li key={c.id} className={cn('rounded-2xl border bg-white shadow-card overflow-hidden animate-fadein', error ? 'border-semantic-error/40' : 'border-neutral-line')}> | |
| 30 | 42 | <button |
| 31 | 43 | onClick={() => setOpen((o) => ({ ...o, [c.id]: !expanded }))} |
| 32 | − className="w-full flex items-center gap-3 px-3 py-2.5 text-left min-h-[44px]" | |
| 44 | + className="w-full flex items-center gap-3 px-3 py-2.5 text-left min-h-[52px]" | |
| 33 | 45 | aria-expanded={expanded} |
| 34 | 46 | > |
| 35 | − <span className={cn('h-8 w-8 rounded-lg inline-flex items-center justify-center shrink-0', running ? 'bg-uqo-blue-light text-uqo-blue' : c.status === 'error' ? 'bg-red-50 text-semantic-error' : 'bg-uqo-blue-light text-uqo-blue')}> | |
| 36 | − {running ? <Loader2 size={16} className="animate-spin" /> : c.status === 'error' ? <AlertTriangle size={16} /> : <Icon size={16} />} | |
| 47 | + <span className={cn('h-9 w-9 rounded-xl inline-flex items-center justify-center shrink-0', error ? 'bg-red-50 text-semantic-error' : meta.tint)}> | |
| 48 | + {running ? <Loader2 size={17} className="animate-spin" /> : error ? <AlertTriangle size={17} /> : <Icon size={17} />} | |
| 37 | 49 | </span> |
| 38 | 50 | <span className="flex-1 min-w-0"> |
| 39 | − <span className="block text-sm font-medium text-neutral-text truncate">{meta.label}</span> | |
| 51 | + <span className="flex items-center gap-2"> | |
| 52 | + <span className="text-sm font-semibold text-neutral-text truncate">{meta.label}</span> | |
| 53 | + {running && <span className="text-[10px] uppercase tracking-wide rounded-md bg-uqo-blue-light text-uqo-blue px-1.5 py-0.5">en cours</span>} | |
| 54 | + {error && <span className="text-[10px] uppercase tracking-wide rounded-md bg-red-50 text-semantic-error px-1.5 py-0.5">échec, corrigé par le tuteur</span>} | |
| 55 | + </span> | |
| 40 | 56 | <span className="block text-xs text-neutral-muted truncate"> |
| 41 | − {running ? c.progress || 'En cours…' : c.status === 'error' ? 'Échec — le tuteur a été informé et corrige' : c.summary || c.args_preview} | |
| 57 | + {running ? c.progress || 'En cours…' : error ? 'Voir le détail' : c.summary || c.args_preview} | |
| 42 | 58 | </span> |
| 43 | 59 | </span> |
| 44 | − <span className="text-xs text-neutral-muted inline-flex items-center gap-1 shrink-0"> | |
| 45 | − {!running && c.duration_ms > 0 && fmtDuration(c.duration_ms)} | |
| 46 | − {!running && c.status === 'ok' && <Check size={14} className="text-uqo-green" />} | |
| 60 | + <span className="text-xs text-neutral-muted inline-flex items-center gap-1.5 shrink-0"> | |
| 61 | + {!running && c.duration_ms > 0 && <span className="tabular-nums">{fmtDuration(c.duration_ms)}</span>} | |
| 62 | + {!running && !error && <Check size={14} className="text-uqo-green" />} | |
| 47 | 63 | <ChevronDown size={16} className={cn('transition-transform', expanded && 'rotate-180')} /> |
| 48 | 64 | </span> |
| 49 | 65 | </button> |
| 50 | 66 | {expanded && !running && ( |
| 51 | − <div className="border-t border-neutral-line px-3 py-3"> | |
| 67 | + <div className="border-t border-neutral-line/80 px-3 py-3 bg-[#fbfcfe]"> | |
| 52 | 68 | <ToolCard call={c} conversationId={conversationId} /> |
| 53 | 69 | </div> |
| 54 | 70 | )} |
added
frontend/src/components/tools/appraisal-calc-card.tsx
+105 −0
@@ -0,0 +1,105 @@ | ||
| 1 | +import type { ToolCallView } from '@/lib/types'; | |
| 2 | +import { fmtCAD, fmtNum, fmtPct } from '@/lib/format'; | |
| 3 | + | |
| 4 | +const LABELS: Record<string, string> = { | |
| 5 | + value: 'Valeur', land_value: 'Valeur du terrain', cost_new: 'Coût neuf', total_depreciation: 'Dépréciation totale', | |
| 6 | + depreciated_cost: 'Coût déprécié', depreciation_ratio: 'Taux de dépréciation', ratio: 'Ratio', noi: 'RNE', cap_rate: 'TGA', | |
| 7 | + gim: 'MRB', land_value_pv: 'Valeur du terrain (VA)', per_lot: 'Par lot', gross_sales: 'Recettes brutes', costs: 'Coûts', | |
| 8 | + profit: 'Profit', net_undiscounted: 'Net non actualisé', building_income: 'Revenu attribuable au bâtiment', | |
| 9 | + residual_income: 'Revenu résiduel (terrain)', index_ratio: 'Ratio d’indices', direct_costs: 'Coûts directs', extras: 'Extras', | |
| 10 | + indirect_costs: 'Coûts indirects', entrepreneur_profit: 'Profit de l’entrepreneur', cost_per_unit_all_in: 'Coût unitaire tout compris', | |
| 11 | + effective_age: 'Âge effectif', annual_rate: 'Taux annuel', depreciation: 'Dépréciation', land_ratio: 'Ratio terrain', | |
| 12 | + curable_physical: 'Physique récupérable', short_lived: 'Courte vie', long_lived: 'Longue vie', functional: 'Fonctionnelle', external: 'Externe', | |
| 13 | + min: 'Minimum', max: 'Maximum', mean: 'Moyenne (indicatif)', median: 'Médiane', weighted: 'Pondération réconciliée', | |
| 14 | + least_adjusted: 'Comparable le moins ajusté', least_adjusted_price: 'Prix ajusté (moins ajusté)', | |
| 15 | +}; | |
| 16 | +const PCT = new Set(['depreciation_ratio', 'ratio', 'cap_rate', 'annual_rate', 'land_ratio', 'time_pct', 'net_pct', 'gross_pct']); | |
| 17 | +const PLAIN = new Set(['gim', 'index_ratio', 'effective_age', 'unit']); | |
| 18 | + | |
| 19 | +function fmt(k: string, v: unknown): string { | |
| 20 | + if (v === null || v === undefined) return '—'; | |
| 21 | + if (typeof v === 'number') { | |
| 22 | + if (PCT.has(k)) return fmtPct(v, 2); | |
| 23 | + if (PLAIN.has(k)) return fmtNum(v, 2); | |
| 24 | + return Math.abs(v) >= 1000 ? fmtCAD(v, 0) : fmtNum(v, 2); | |
| 25 | + } | |
| 26 | + return String(v); | |
| 27 | +} | |
| 28 | + | |
| 29 | +interface Comp { name: string; price: number; time_pct: number; time_adjusted: number; adjustments: Record<string, number>; net: number; adjusted_price: number; net_pct: number; gross_pct: number; reliable: boolean } | |
| 30 | + | |
| 31 | +export function AppraisalCalcCard({ call }: { call: ToolCallView }) { | |
| 32 | + const p = call.payload as { function?: string; result?: Record<string, unknown> }; | |
| 33 | + const r = p.result || {}; | |
| 34 | + const table = r.table as unknown[][] | undefined; | |
| 35 | + const comps = r.comparables as Comp[] | undefined; | |
| 36 | + const stats = r.stats as Record<string, unknown> | undefined; | |
| 37 | + const components = r.components as Record<string, number> | undefined; | |
| 38 | + const scalarKeys = Object.keys(r).filter((k) => !['formula', 'table', 'comparables', 'stats', 'components', 'note'].includes(k) && typeof r[k] !== 'object'); | |
| 39 | + const adjKeys = comps ? [...new Set(comps.flatMap((c) => Object.keys(c.adjustments)))] : []; | |
| 40 | + return ( | |
| 41 | + <div className="space-y-3 text-sm"> | |
| 42 | + {r.formula ? <div className="rounded-lg bg-uqo-blue-light/60 px-3 py-2 font-mono text-xs text-uqo-blue-dark">{String(r.formula)}</div> : null} | |
| 43 | + {scalarKeys.length > 0 && ( | |
| 44 | + <dl className="grid grid-cols-2 sm:grid-cols-3 gap-2"> | |
| 45 | + {scalarKeys.map((k) => ( | |
| 46 | + <div key={k} className={`rounded-xl border px-3 py-2 ${k === 'value' || k === 'land_value' || k === 'land_value_pv' ? 'border-uqo-green bg-[#f2f9ef]' : 'border-neutral-line bg-white'}`}> | |
| 47 | + <dt className="text-[11px] text-neutral-muted">{LABELS[k] || k.replace(/_/g, ' ')}</dt> | |
| 48 | + <dd className="font-semibold tabular-nums">{fmt(k, r[k])}</dd> | |
| 49 | + </div> | |
| 50 | + ))} | |
| 51 | + </dl> | |
| 52 | + )} | |
| 53 | + {components && ( | |
| 54 | + <dl className="grid grid-cols-2 sm:grid-cols-5 gap-2"> | |
| 55 | + {Object.entries(components).map(([k, v]) => <div key={k} className="rounded-xl border border-neutral-line bg-white px-3 py-2"><dt className="text-[11px] text-neutral-muted">{LABELS[k] || k}</dt><dd className="font-semibold tabular-nums">{fmtCAD(v, 0)}</dd></div>)} | |
| 56 | + </dl> | |
| 57 | + )} | |
| 58 | + {table && ( | |
| 59 | + <div className="rounded-xl border border-neutral-line overflow-x-auto scroll-thin bg-white"> | |
| 60 | + <table className="text-xs min-w-full"> | |
| 61 | + <tbody> | |
| 62 | + {table.map((row, i) => { | |
| 63 | + const last = i === table.length - 1; | |
| 64 | + return ( | |
| 65 | + <tr key={i} className={`border-b border-neutral-line/60 last:border-0 ${last ? 'font-semibold text-uqo-blue-dark bg-uqo-blue-light/40' : ''}`}> | |
| 66 | + {row.map((c, j) => <td key={j} className={`px-3 py-1.5 ${typeof c === 'number' ? 'text-right tabular-nums whitespace-nowrap' : ''}`}>{typeof c === 'number' ? fmtCAD(c, 0) : String(c ?? '')}</td>)} | |
| 67 | + </tr> | |
| 68 | + ); | |
| 69 | + })} | |
| 70 | + </tbody> | |
| 71 | + </table> | |
| 72 | + </div> | |
| 73 | + )} | |
| 74 | + {comps && ( | |
| 75 | + <div className="rounded-xl border border-neutral-line overflow-x-auto scroll-thin bg-white"> | |
| 76 | + <table className="text-xs min-w-full"> | |
| 77 | + <thead className="bg-uqo-blue-light text-uqo-blue-dark"> | |
| 78 | + <tr><th className="px-2 py-1.5 text-left">Comparable</th><th className="px-2 py-1.5 text-right">Prix</th><th className="px-2 py-1.5 text-right">Temps</th>{adjKeys.map((k) => <th key={k} className="px-2 py-1.5 text-right capitalize">{k}</th>)}<th className="px-2 py-1.5 text-right">Prix ajusté</th><th className="px-2 py-1.5 text-right">Net %</th><th className="px-2 py-1.5 text-right">Brut %</th><th /></tr> | |
| 79 | + </thead> | |
| 80 | + <tbody> | |
| 81 | + {comps.map((c) => ( | |
| 82 | + <tr key={c.name} className="border-t border-neutral-line/60"> | |
| 83 | + <td className="px-2 py-1.5 font-medium whitespace-nowrap">{c.name}</td> | |
| 84 | + <td className="px-2 py-1.5 text-right tabular-nums">{fmtCAD(c.price, 0)}</td> | |
| 85 | + <td className="px-2 py-1.5 text-right tabular-nums">{fmtPct(c.time_pct, 1)}</td> | |
| 86 | + {adjKeys.map((k) => <td key={k} className={`px-2 py-1.5 text-right tabular-nums ${(c.adjustments[k] || 0) < 0 ? 'text-semantic-error' : (c.adjustments[k] || 0) > 0 ? 'text-[#1f7a1f]' : 'text-neutral-muted'}`}>{c.adjustments[k] !== undefined ? fmtCAD(c.adjustments[k], 0) : '—'}</td>)} | |
| 87 | + <td className="px-2 py-1.5 text-right tabular-nums font-semibold">{fmtCAD(c.adjusted_price, 0)}</td> | |
| 88 | + <td className="px-2 py-1.5 text-right tabular-nums">{fmtPct(c.net_pct, 1)}</td> | |
| 89 | + <td className="px-2 py-1.5 text-right tabular-nums">{fmtPct(c.gross_pct, 1)}</td> | |
| 90 | + <td className="px-2 py-1.5">{c.reliable ? <span className="text-[10px] rounded bg-[#e9f6d9] text-[#3f7a0a] px-1.5 py-0.5">fiable</span> : <span className="text-[10px] rounded bg-amber-50 text-amber-700 px-1.5 py-0.5">à pondérer</span>}</td> | |
| 91 | + </tr> | |
| 92 | + ))} | |
| 93 | + </tbody> | |
| 94 | + </table> | |
| 95 | + </div> | |
| 96 | + )} | |
| 97 | + {stats && ( | |
| 98 | + <dl className="grid grid-cols-2 sm:grid-cols-4 gap-2"> | |
| 99 | + {Object.entries(stats).map(([k, v]) => <div key={k} className={`rounded-xl border px-3 py-2 ${k === 'weighted' ? 'border-uqo-green bg-[#f2f9ef]' : 'border-neutral-line bg-white'}`}><dt className="text-[11px] text-neutral-muted">{LABELS[k] || k}</dt><dd className="font-semibold tabular-nums">{typeof v === 'number' ? fmtCAD(v, 0) : String(v)}</dd></div>)} | |
| 100 | + </dl> | |
| 101 | + )} | |
| 102 | + {r.note ? <p className="text-xs text-neutral-muted">{String(r.note)}</p> : null} | |
| 103 | + </div> | |
| 104 | + ); | |
| 105 | +} | |
modified
frontend/src/components/tools/artifact-chips.tsx
+2 −1
@@ -3,6 +3,7 @@ import type { Artifact } from '@/lib/types'; | ||
| 3 | 3 | import { downloadFile, shareFile } from '@/lib/api'; |
| 4 | 4 | |
| 5 | 5 | const icon = (t: string) => (t === 'xlsx' ? FileSpreadsheet : t === 'image' ? ImageIcon : FileText); |
| 6 | +const label = (t: string) => (t === 'xlsx' ? 'Excel' : t === 'docx' ? 'Word' : t === 'csv' ? 'CSV' : t === 'image' ? 'Image' : 'Fichier'); | |
| 6 | 7 | |
| 7 | 8 | export function ArtifactChips({ artifacts }: { artifacts?: Artifact[] }) { |
| 8 | 9 | const list = (artifacts || []).filter((a) => a.file_id && a.type !== 'quiz' && a.type !== 'sources'); |
@@ -15,7 +16,7 @@ export function ArtifactChips({ artifacts }: { artifacts?: Artifact[] }) { | ||
| 15 | 16 | return ( |
| 16 | 17 | <div key={a.file_id} className="inline-flex items-center rounded-xl border border-neutral-line bg-white overflow-hidden"> |
| 17 | 18 | <button onClick={() => downloadFile(a.file_id!, a.filename || 'fichier')} className="inline-flex items-center gap-2 px-3 min-h-[44px] text-sm hover:bg-neutral-surface"> |
| 18 | − <Icon size={16} className="text-uqo-blue" /> <span className="max-w-[200px] truncate">{a.filename}</span> <Download size={14} className="text-neutral-muted" /> | |
| 19 | + <Icon size={16} className="text-uqo-blue" /> <span className="max-w-[200px] truncate">{a.filename}</span> <span className="text-[10px] rounded bg-neutral-surface px-1.5 py-0.5 text-neutral-muted">{label(a.type)}</span> <Download size={14} className="text-neutral-muted" /> | |
| 19 | 20 | </button> |
| 20 | 21 | {canShare && ( |
| 21 | 22 | <button onClick={() => shareFile(a.file_id!, a.filename || 'fichier')} className="px-3 min-h-[44px] border-l border-neutral-line hover:bg-neutral-surface" aria-label="Partager"> |
added
frontend/src/components/tools/chart-card.tsx
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +import { Download, Share2 } from 'lucide-react'; | |
| 2 | +import type { ToolCallView } from '@/lib/types'; | |
| 3 | +import { downloadFile, shareFile } from '@/lib/api'; | |
| 4 | + | |
| 5 | +export function ChartCard({ call }: { call: ToolCallView }) { | |
| 6 | + const p = call.payload as { title?: string; type?: string; file_id?: string; filename?: string }; | |
| 7 | + const fileId = p.file_id || call.artifacts?.[0]?.file_id; | |
| 8 | + if (!fileId) return <p className="text-sm text-neutral-muted">{call.summary}</p>; | |
| 9 | + return ( | |
| 10 | + <figure className="rounded-xl border border-neutral-line overflow-hidden bg-white"> | |
| 11 | + <img src={`/api/v1/files/${fileId}`} alt={p.title || 'graphique'} className="w-full max-h-[480px] object-contain" loading="lazy" /> | |
| 12 | + <figcaption className="flex items-center justify-between gap-2 px-3 py-2 text-xs text-neutral-muted border-t border-neutral-line"> | |
| 13 | + <span className="truncate">{p.title || p.filename} · {p.type}</span> | |
| 14 | + <span className="flex gap-1"> | |
| 15 | + <button onClick={() => downloadFile(fileId, p.filename || 'graphique.png')} className="h-8 px-2 rounded-lg hover:bg-neutral-surface inline-flex items-center gap-1 text-uqo-blue"><Download size={13} /> PNG</button> | |
| 16 | + {typeof navigator !== 'undefined' && !!navigator.share && <button onClick={() => shareFile(fileId, p.filename || 'graphique.png')} className="h-8 px-2 rounded-lg hover:bg-neutral-surface inline-flex items-center gap-1"><Share2 size={13} /></button>} | |
| 17 | + </span> | |
| 18 | + </figcaption> | |
| 19 | + </figure> | |
| 20 | + ); | |
| 21 | +} | |
added
frontend/src/components/tools/docx-card.tsx
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +import { Download, FileText, Share2 } from 'lucide-react'; | |
| 2 | +import type { ToolCallView } from '@/lib/types'; | |
| 3 | +import { downloadFile, shareFile } from '@/lib/api'; | |
| 4 | +import { Button } from '@/components/ui/button'; | |
| 5 | + | |
| 6 | +export function DocxCard({ call }: { call: ToolCallView }) { | |
| 7 | + const p = call.payload as { title?: string; filename?: string; file_id?: string; words?: number; outline?: string[] }; | |
| 8 | + const fileId = p.file_id || call.artifacts?.[0]?.file_id; | |
| 9 | + const filename = p.filename || 'document.docx'; | |
| 10 | + return ( | |
| 11 | + <div className="space-y-3"> | |
| 12 | + <div className="flex items-start gap-3"> | |
| 13 | + <span className="h-10 w-10 rounded-xl bg-[#e6ecfb] text-[#2b579a] inline-flex items-center justify-center shrink-0"><FileText size={20} /></span> | |
| 14 | + <div className="min-w-0 flex-1"> | |
| 15 | + <div className="font-semibold truncate">{p.title || filename}</div> | |
| 16 | + <div className="text-xs text-neutral-muted">{filename}{p.words ? ` · ${p.words} mots` : ''} · Word (.docx)</div> | |
| 17 | + </div> | |
| 18 | + </div> | |
| 19 | + {p.outline?.length ? ( | |
| 20 | + <ol className="rounded-xl border border-neutral-line bg-white px-3 py-2 text-sm space-y-0.5"> | |
| 21 | + {p.outline.map((h, i) => <li key={i} className="truncate text-neutral-text">{h}</li>)} | |
| 22 | + </ol> | |
| 23 | + ) : null} | |
| 24 | + {fileId && ( | |
| 25 | + <div className="flex gap-2 flex-wrap"> | |
| 26 | + <Button size="sm" onClick={() => downloadFile(fileId, filename)}><Download size={16} /> Télécharger</Button> | |
| 27 | + {typeof navigator !== 'undefined' && !!navigator.share && <Button size="sm" variant="secondary" onClick={() => shareFile(fileId, filename)}><Share2 size={16} /> Partager</Button>} | |
| 28 | + </div> | |
| 29 | + )} | |
| 30 | + </div> | |
| 31 | + ); | |
| 32 | +} | |
modified
frontend/src/components/tools/excel-card.tsx
+77 −20
@@ -1,52 +1,109 @@ | ||
| 1 | −import { Download, FileSpreadsheet, Share2 } from 'lucide-react'; | |
| 1 | +import { useState } from 'react'; | |
| 2 | +import { Download, FileEdit, FileSpreadsheet, Share2, Wand2 } from 'lucide-react'; | |
| 2 | 3 | import type { ToolCallView } from '@/lib/types'; |
| 3 | 4 | import { downloadFile, shareFile } from '@/lib/api'; |
| 4 | 5 | import { cellValue } from '@/lib/format'; |
| 5 | 6 | import { Button } from '@/components/ui/button'; |
| 7 | +import { useUI } from '@/stores/ui'; | |
| 8 | +import { cn } from '@/lib/cn'; | |
| 6 | 9 | |
| 7 | −interface Preview { sheet: string; rows: unknown[][]; sheets: string[] } | |
| 10 | +interface SheetPreview { name: string; rows: unknown[][]; max_row?: number; max_col?: number } | |
| 11 | +interface Preview { sheet: string; rows: unknown[][]; sheets: string[]; all?: SheetPreview[] } | |
| 12 | + | |
| 13 | +function colLetter(i: number): string { | |
| 14 | + let s = ''; | |
| 15 | + let n = i; | |
| 16 | + while (n >= 0) { s = String.fromCharCode(65 + (n % 26)) + s; n = Math.floor(n / 26) - 1; } | |
| 17 | + return s; | |
| 18 | +} | |
| 8 | 19 | |
| 9 | 20 | export function ExcelCard({ call }: { call: ToolCallView }) { |
| 10 | − const p = call.payload as { filename?: string; file_id?: string; preview?: Preview; sheets?: { name: string; tables: number; inputs: number }[] }; | |
| 21 | + const p = call.payload as { filename?: string; file_id?: string; preview?: Preview; sheets?: { name: string; tables: number; inputs: number }[]; changes?: string[]; source?: string }; | |
| 11 | 22 | const pv = p.preview; |
| 23 | + const all: SheetPreview[] = pv?.all?.length ? pv.all : pv ? [{ name: pv.sheet, rows: pv.rows }] : []; | |
| 24 | + const visible = all.filter((s) => s.name !== 'Lisez-moi').concat(all.filter((s) => s.name === 'Lisez-moi')); | |
| 25 | + const [active, setActive] = useState(0); | |
| 26 | + const setDraft = useUI((s) => s.setDraft); | |
| 12 | 27 | const fileId = p.file_id || call.artifacts?.[0]?.file_id; |
| 13 | 28 | const filename = p.filename || call.artifacts?.[0]?.filename || 'classeur.xlsx'; |
| 29 | + const sheet = visible[Math.min(active, Math.max(0, visible.length - 1))]; | |
| 30 | + const isInspect = call.name === 'inspect_excel'; | |
| 31 | + const isEdit = call.name === 'edit_excel'; | |
| 32 | + const modify = () => setDraft(`Modifie le classeur « ${filename} » : `); | |
| 33 | + const ncols = sheet ? Math.max(...sheet.rows.map((r) => r.length), 1) : 0; | |
| 34 | + | |
| 14 | 35 | return ( |
| 15 | 36 | <div className="space-y-3"> |
| 16 | 37 | <div className="flex items-start gap-3"> |
| 17 | − <span className="h-10 w-10 rounded-xl bg-[#e3f3e0] text-[#217346] inline-flex items-center justify-center shrink-0"><FileSpreadsheet size={20} /></span> | |
| 38 | + <span className="h-10 w-10 rounded-xl bg-[#e3f3e0] text-[#217346] inline-flex items-center justify-center shrink-0">{isEdit ? <FileEdit size={20} /> : <FileSpreadsheet size={20} />}</span> | |
| 18 | 39 | <div className="min-w-0 flex-1"> |
| 19 | − <div className="font-medium truncate">{filename}</div> | |
| 40 | + <div className="font-semibold truncate">{filename}</div> | |
| 20 | 41 | <div className="text-xs text-neutral-muted"> |
| 21 | − {pv?.sheets?.length ? `${pv.sheets.length} feuilles : ${pv.sheets.join(', ')}` : 'Classeur Excel'} · formules vivantes | |
| 42 | + {isEdit && p.source ? <>Nouvelle version de <b>{p.source}</b> · </> : null} | |
| 43 | + {visible.length ? `${visible.length} feuille${visible.length > 1 ? 's' : ''}` : 'Classeur Excel'} · formules vivantes | |
| 22 | 44 | </div> |
| 23 | 45 | </div> |
| 24 | 46 | </div> |
| 25 | − {pv?.rows?.length ? ( | |
| 26 | − <div className="rounded-lg border border-neutral-line overflow-x-auto scroll-thin bg-white"> | |
| 27 | − <table className="text-xs min-w-full"> | |
| 47 | + | |
| 48 | + {isEdit && p.changes?.length ? ( | |
| 49 | + <ul className="rounded-xl bg-[#f2f9ef] border border-[#cfe9c3] px-3 py-2 text-xs text-[#1f4d13] space-y-0.5"> | |
| 50 | + {p.changes.map((c, i) => <li key={i}>✓ {c.replace(/^\d+\.\s*/, '')}</li>)} | |
| 51 | + </ul> | |
| 52 | + ) : null} | |
| 53 | + | |
| 54 | + {visible.length > 1 && ( | |
| 55 | + <div className="flex gap-1 overflow-x-auto scroll-thin" role="tablist"> | |
| 56 | + {visible.map((s, i) => ( | |
| 57 | + <button key={s.name} role="tab" aria-selected={i === active} onClick={() => setActive(i)} | |
| 58 | + className={cn('h-8 px-3 rounded-lg text-xs font-medium whitespace-nowrap border', i === active ? 'bg-[#217346] text-white border-[#217346]' : 'border-neutral-line text-neutral-muted hover:bg-neutral-surface')}> | |
| 59 | + {s.name} | |
| 60 | + </button> | |
| 61 | + ))} | |
| 62 | + </div> | |
| 63 | + )} | |
| 64 | + | |
| 65 | + {sheet && sheet.rows.length > 0 && ( | |
| 66 | + <div className="rounded-xl border border-neutral-line overflow-auto scroll-thin bg-white max-h-[380px]"> | |
| 67 | + <table className="text-xs border-collapse min-w-full"> | |
| 68 | + <thead> | |
| 69 | + <tr className="bg-neutral-surface text-neutral-muted"> | |
| 70 | + <th className="sticky left-0 bg-neutral-surface w-8 px-1 py-1 text-[10px] font-medium border-b border-r border-neutral-line" /> | |
| 71 | + {Array.from({ length: ncols }).map((_, j) => <th key={j} className="px-2 py-1 text-[10px] font-medium border-b border-neutral-line text-center">{colLetter(j)}</th>)} | |
| 72 | + </tr> | |
| 73 | + </thead> | |
| 28 | 74 | <tbody> |
| 29 | − {pv.rows.slice(0, 12).map((row, i) => ( | |
| 30 | − <tr key={i} className={i === 0 ? 'font-semibold text-uqo-blue-dark' : 'border-t border-neutral-line/70'}> | |
| 31 | − {row.slice(0, 6).map((c, j) => ( | |
| 32 | − <td key={j} className={`px-2 py-1 whitespace-nowrap ${typeof c === 'string' && c.startsWith('=') ? 'font-mono text-uqo-blue' : ''}`}> | |
| 33 | − {cellValue(c)} | |
| 34 | − </td> | |
| 35 | − ))} | |
| 75 | + {sheet.rows.map((row, i) => ( | |
| 76 | + <tr key={i} className="border-b border-neutral-line/60 last:border-0"> | |
| 77 | + <td className="sticky left-0 bg-neutral-surface text-neutral-muted text-[10px] text-center px-1 border-r border-neutral-line tabular-nums">{i + 1}</td> | |
| 78 | + {Array.from({ length: ncols }).map((_, j) => { | |
| 79 | + const c = row[j]; | |
| 80 | + const isFormula = typeof c === 'string' && c.startsWith('='); | |
| 81 | + const isNum = typeof c === 'number'; | |
| 82 | + return ( | |
| 83 | + <td key={j} title={isFormula ? String(c) : undefined} | |
| 84 | + className={cn('px-2 py-1 whitespace-nowrap max-w-[220px] truncate', isFormula && 'font-mono text-[11px] text-[#217346] bg-[#f6fbf4]', isNum && 'text-right tabular-nums', i === 0 && 'font-semibold text-uqo-blue-dark')}> | |
| 85 | + {isFormula ? 'ƒ ' + String(c) : cellValue(c)} | |
| 86 | + </td> | |
| 87 | + ); | |
| 88 | + })} | |
| 36 | 89 | </tr> |
| 37 | 90 | ))} |
| 38 | 91 | </tbody> |
| 39 | 92 | </table> |
| 93 | + {sheet.max_row && sheet.max_row > sheet.rows.length && <div className="px-2 py-1 text-[11px] text-neutral-muted bg-neutral-surface">… {sheet.max_row - sheet.rows.length} ligne(s) de plus dans le fichier</div>} | |
| 40 | 94 | </div> |
| 41 | − ) : null} | |
| 95 | + )} | |
| 96 | + | |
| 42 | 97 | {fileId && ( |
| 43 | 98 | <div className="flex gap-2 flex-wrap"> |
| 44 | − <Button size="sm" onClick={() => downloadFile(fileId, filename)}><Download size={16} /> Télécharger</Button> | |
| 45 | − {typeof navigator !== 'undefined' && !!navigator.share && ( | |
| 46 | − <Button size="sm" variant="secondary" onClick={() => shareFile(fileId, filename)}><Share2 size={16} /> Partager</Button> | |
| 99 | + {!isInspect && <Button size="sm" onClick={() => downloadFile(fileId, filename)}><Download size={16} /> Télécharger</Button>} | |
| 100 | + <Button size="sm" variant="secondary" onClick={modify}><Wand2 size={16} /> Modifier avec le tuteur</Button> | |
| 101 | + {typeof navigator !== 'undefined' && !!navigator.share && !isInspect && ( | |
| 102 | + <Button size="sm" variant="ghost" onClick={() => shareFile(fileId, filename)}><Share2 size={16} /> Partager</Button> | |
| 47 | 103 | )} |
| 48 | 104 | </div> |
| 49 | 105 | )} |
| 106 | + <p className="text-[11px] text-neutral-muted">Aperçu statique : les formules (ƒ) sont calculées à l'ouverture dans Excel, Numbers ou LibreOffice.</p> | |
| 50 | 107 | </div> |
| 51 | 108 | ); |
| 52 | 109 | } |
modified
frontend/src/components/tools/python-exec-card.tsx
+136 −23
@@ -1,34 +1,147 @@ | ||
| 1 | −import { useState } from 'react'; | |
| 2 | −import { ChevronDown } from 'lucide-react'; | |
| 1 | +import { useEffect, useMemo, useRef, useState } from 'react'; | |
| 2 | +import { Check, Copy, Download, Loader2, Play, RotateCcw, Send } from 'lucide-react'; | |
| 3 | 3 | import type { ToolCallView } from '@/lib/types'; |
| 4 | −import { CodeBlock } from '@/components/chat/code-block'; | |
| 5 | −import { ArtifactChips } from './artifact-chips'; | |
| 4 | +import { api } from '@/lib/api'; | |
| 5 | +import { fmtDuration } from '@/lib/format'; | |
| 6 | 6 | import { cn } from '@/lib/cn'; |
| 7 | +import { useUI } from '@/stores/ui'; | |
| 8 | +import { ArtifactChips } from './artifact-chips'; | |
| 9 | + | |
| 10 | +interface RunOut { stdout?: string; stderr?: string; exit_code?: number; duration_ms?: number; artifacts?: { file_id: string; filename: string; type: string }[] } | |
| 11 | +type Tab = 'code' | 'output' | 'figures'; | |
| 12 | + | |
| 13 | +let hlPromise: Promise<(code: string) => string> | null = null; | |
| 14 | +function highlighter() { | |
| 15 | + if (!hlPromise) { | |
| 16 | + hlPromise = (async () => { | |
| 17 | + const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] = await Promise.all([import('shiki/core'), import('shiki/engine/javascript')]); | |
| 18 | + const hl = await createHighlighterCore({ themes: [import('shiki/themes/github-dark.mjs')], langs: [import('shiki/langs/python.mjs')], engine: createJavaScriptRegexEngine() }); | |
| 19 | + return (code: string) => hl.codeToHtml(code, { lang: 'python', theme: 'github-dark' }); | |
| 20 | + })(); | |
| 21 | + } | |
| 22 | + return hlPromise; | |
| 23 | +} | |
| 7 | 24 | |
| 8 | 25 | export function PythonExecCard({ call, conversationId }: { call: ToolCallView; conversationId: string }) { |
| 9 | − const p = call.payload as { code?: string; description?: string; stdout?: string; stderr?: string; exit_code?: number; artifacts?: ToolCallView['artifacts'] }; | |
| 10 | − const [showCode, setShowCode] = useState(false); | |
| 11 | − const images = (p.artifacts || call.artifacts || []).filter((a) => a.type === 'image'); | |
| 12 | − const others = (p.artifacts || call.artifacts || []).filter((a) => a.type !== 'image'); | |
| 26 | + const p = call.payload as { code?: string; description?: string; stdout?: string; stderr?: string; exit_code?: number; duration_ms?: number; artifacts?: ToolCallView['artifacts'] }; | |
| 27 | + const initialArtifacts = (p.artifacts || call.artifacts || []) as NonNullable<ToolCallView['artifacts']>; | |
| 28 | + const [tab, setTab] = useState<Tab>(initialArtifacts.some((a) => a.type === 'image') ? 'figures' : p.stdout || p.stderr ? 'output' : 'code'); | |
| 29 | + const [code, setCode] = useState(p.code || ''); | |
| 30 | + const [editing, setEditing] = useState(false); | |
| 31 | + const [html, setHtml] = useState(''); | |
| 32 | + const [running, setRunning] = useState(false); | |
| 33 | + const [out, setOut] = useState<RunOut>({ stdout: p.stdout, stderr: p.stderr, exit_code: p.exit_code, duration_ms: p.duration_ms, artifacts: initialArtifacts.filter((a) => a.file_id).map((a) => ({ file_id: a.file_id!, filename: a.filename || '', type: a.type })) }); | |
| 34 | + const [copied, setCopied] = useState(false); | |
| 35 | + const taRef = useRef<HTMLTextAreaElement>(null); | |
| 36 | + const setDraft = useUI((s) => s.setDraft); | |
| 37 | + const dirty = code !== (p.code || ''); | |
| 38 | + | |
| 39 | + useEffect(() => { | |
| 40 | + let alive = true; | |
| 41 | + highlighter().then((f) => alive && setHtml(f(code))).catch(() => undefined); | |
| 42 | + return () => { alive = false; }; | |
| 43 | + }, [code]); | |
| 44 | + | |
| 45 | + const lines = useMemo(() => code.split('\n').length, [code]); | |
| 46 | + const images = (out.artifacts || []).filter((a) => a.type === 'image'); | |
| 47 | + const others = (out.artifacts || []).filter((a) => a.type !== 'image'); | |
| 48 | + | |
| 49 | + const run = async () => { | |
| 50 | + setRunning(true); | |
| 51 | + try { | |
| 52 | + const r = await api<RunOut>('/tools/python/run', { method: 'POST', body: JSON.stringify({ code, conversation_id: conversationId }) }); | |
| 53 | + setOut(r); | |
| 54 | + setTab(r.artifacts?.some((a) => a.type === 'image') ? 'figures' : 'output'); | |
| 55 | + } catch (e) { | |
| 56 | + setOut({ stderr: e instanceof Error ? e.message : 'Erreur', exit_code: 1 }); | |
| 57 | + setTab('output'); | |
| 58 | + } finally { | |
| 59 | + setRunning(false); | |
| 60 | + } | |
| 61 | + }; | |
| 62 | + const copy = async () => { await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 1200); }; | |
| 63 | + const download = () => { | |
| 64 | + const blob = new Blob([code], { type: 'text/x-python' }); | |
| 65 | + const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'calcul.py'; a.click(); URL.revokeObjectURL(a.href); | |
| 66 | + }; | |
| 67 | + const askTutor = () => setDraft(`À propos du code Python ci-dessus (${p.description || 'calcul'}) : `); | |
| 68 | + | |
| 69 | + const TabBtn = ({ id, label, count }: { id: Tab; label: string; count?: number }) => ( | |
| 70 | + <button onClick={() => setTab(id)} className={cn('h-9 px-3 rounded-lg text-sm font-medium inline-flex items-center gap-1.5', tab === id ? 'bg-white text-uqo-blue-dark shadow-card' : 'text-neutral-muted hover:text-neutral-text')} role="tab" aria-selected={tab === id}> | |
| 71 | + {label}{count ? <span className="text-[10px] rounded-full bg-uqo-blue-light text-uqo-blue px-1.5">{count}</span> : null} | |
| 72 | + </button> | |
| 73 | + ); | |
| 74 | + | |
| 13 | 75 | return ( |
| 14 | 76 | <div className="space-y-2"> |
| 15 | − {p.description && <p className="text-sm text-neutral-muted">{p.description}</p>} | |
| 16 | − <button onClick={() => setShowCode((v) => !v)} className="text-sm text-uqo-blue inline-flex items-center gap-1 min-h-[44px]"> | |
| 17 | − <ChevronDown size={16} className={cn('transition-transform', showCode && 'rotate-180')} /> {showCode ? 'Masquer le code' : 'Voir le code'} | |
| 18 | − </button> | |
| 19 | − {showCode && p.code && <CodeBlock code={p.code} lang="python" conversationId={conversationId} />} | |
| 20 | − {(p.stdout || p.stderr) && ( | |
| 21 | − <div className="rounded-lg bg-neutral-surface p-3 font-mono text-xs overflow-x-auto scroll-thin"> | |
| 22 | − {p.stdout && <pre className="whitespace-pre-wrap">{p.stdout}</pre>} | |
| 23 | − {p.stderr && <pre className="whitespace-pre-wrap text-semantic-error mt-2">{p.stderr}</pre>} | |
| 77 | + {p.description && <p className="text-sm text-neutral-text">{p.description}</p>} | |
| 78 | + <div className="flex items-center justify-between gap-2 flex-wrap"> | |
| 79 | + <div className="inline-flex gap-1 rounded-xl bg-neutral-surface p-1" role="tablist"> | |
| 80 | + <TabBtn id="code" label="Code" /> | |
| 81 | + <TabBtn id="output" label="Sortie" /> | |
| 82 | + <TabBtn id="figures" label="Graphiques" count={images.length} /> | |
| 83 | + </div> | |
| 84 | + <div className="flex items-center gap-1"> | |
| 85 | + <button onClick={() => setEditing((v) => !v)} className={cn('h-9 px-2.5 rounded-lg text-xs font-medium inline-flex items-center gap-1 border', editing ? 'bg-uqo-blue text-white border-uqo-blue' : 'border-neutral-line text-neutral-text hover:bg-neutral-surface')}> | |
| 86 | + {editing ? 'Terminer' : 'Modifier'} | |
| 87 | + </button> | |
| 88 | + {dirty && <button onClick={() => setCode(p.code || '')} className="h-9 w-9 inline-flex items-center justify-center rounded-lg border border-neutral-line text-neutral-muted hover:bg-neutral-surface" title="Rétablir le code original"><RotateCcw size={14} /></button>} | |
| 89 | + <button onClick={run} disabled={running} className="h-9 px-3 rounded-lg text-xs font-semibold inline-flex items-center gap-1.5 bg-uqo-green text-white hover:bg-[#67a51b] disabled:opacity-60"> | |
| 90 | + {running ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />} {running ? 'Exécution…' : dirty ? 'Exécuter ma version' : 'Ré-exécuter'} | |
| 91 | + </button> | |
| 92 | + </div> | |
| 93 | + </div> | |
| 94 | + | |
| 95 | + {tab === 'code' && ( | |
| 96 | + <div className="rounded-xl overflow-hidden border border-[#1f2d3d] bg-[#0f1b2a]"> | |
| 97 | + <div className="flex items-center justify-between px-3 py-1.5 bg-[#16233a] text-[#b7c4d1] text-xs"> | |
| 98 | + <span className="font-mono">python · {lines} lignes{dirty ? ' · modifié' : ''}</span> | |
| 99 | + <div className="flex gap-1"> | |
| 100 | + <button onClick={copy} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1">{copied ? <Check size={13} className="text-uqo-green" /> : <Copy size={13} />} Copier</button> | |
| 101 | + <button onClick={download} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1"><Download size={13} /> .py</button> | |
| 102 | + <button onClick={askTutor} className="h-8 px-2 rounded-md hover:bg-white/10 inline-flex items-center gap-1 text-uqo-green"><Send size={13} /> Demander au tuteur</button> | |
| 103 | + </div> | |
| 104 | + </div> | |
| 105 | + {editing ? ( | |
| 106 | + <textarea ref={taRef} value={code} onChange={(e) => setCode(e.target.value)} spellCheck={false} | |
| 107 | + onKeyDown={(e) => { if (e.key === 'Tab') { e.preventDefault(); const t = e.currentTarget; const s = t.selectionStart; setCode(code.slice(0, s) + ' ' + code.slice(t.selectionEnd)); requestAnimationFrame(() => t.setSelectionRange(s + 4, s + 4)); } }} | |
| 108 | + className="w-full min-h-[220px] max-h-[520px] bg-[#0f1b2a] text-[#e6edf3] font-mono text-[13px] leading-relaxed p-3 outline-none resize-y scroll-thin" /> | |
| 109 | + ) : html ? ( | |
| 110 | + <div className="overflow-auto max-h-[520px] scroll-thin text-[13px] leading-relaxed [&_pre]:!bg-transparent [&_pre]:p-3 [&_pre]:m-0" dangerouslySetInnerHTML={{ __html: html }} /> | |
| 111 | + ) : ( | |
| 112 | + <pre className="p-3 m-0 text-[#e6edf3] font-mono text-[13px] overflow-auto max-h-[520px] scroll-thin"><code>{code}</code></pre> | |
| 113 | + )} | |
| 114 | + </div> | |
| 115 | + )} | |
| 116 | + | |
| 117 | + {tab === 'output' && ( | |
| 118 | + <div className="rounded-xl border border-neutral-line bg-[#0b1522] text-[#d5dde5] font-mono text-xs"> | |
| 119 | + <div className="flex items-center justify-between px-3 py-1.5 border-b border-[#1f2d3d] text-[#8aa0b5]"> | |
| 120 | + <span>{out.exit_code === 0 || out.exit_code === undefined ? '✓ terminé' : `✗ code de sortie ${out.exit_code}`}</span> | |
| 121 | + {out.duration_ms ? <span>{fmtDuration(out.duration_ms)}</span> : null} | |
| 122 | + </div> | |
| 123 | + <div className="p-3 max-h-[420px] overflow-auto scroll-thin"> | |
| 124 | + {out.stdout ? <pre className="whitespace-pre-wrap">{out.stdout}</pre> : !out.stderr && <span className="text-[#8aa0b5]">(aucune sortie texte)</span>} | |
| 125 | + {out.stderr && <pre className="whitespace-pre-wrap text-[#ff9aa2] mt-2">{out.stderr}</pre>} | |
| 126 | + </div> | |
| 127 | + {others.length > 0 && <div className="p-3 border-t border-[#1f2d3d] bg-white"><ArtifactChips artifacts={others.map((a) => ({ ...a, preview: undefined }))} /></div>} | |
| 128 | + </div> | |
| 129 | + )} | |
| 130 | + | |
| 131 | + {tab === 'figures' && ( | |
| 132 | + <div className="grid gap-3 sm:grid-cols-2"> | |
| 133 | + {images.length === 0 && <p className="text-sm text-neutral-muted">Aucun graphique produit par ce code.</p>} | |
| 134 | + {images.map((a) => ( | |
| 135 | + <figure key={a.file_id} className="rounded-xl border border-neutral-line overflow-hidden bg-white sm:col-span-2"> | |
| 136 | + <img src={`/api/v1/files/${a.file_id}`} alt={a.filename || 'graphique'} className="w-full max-h-[480px] object-contain" loading="lazy" /> | |
| 137 | + <figcaption className="flex items-center justify-between px-3 py-2 text-xs text-neutral-muted border-t border-neutral-line"> | |
| 138 | + <span className="truncate">{a.filename}</span> | |
| 139 | + <a href={`/api/v1/files/${a.file_id}?download=1`} className="inline-flex items-center gap-1 text-uqo-blue"><Download size={13} /> PNG</a> | |
| 140 | + </figcaption> | |
| 141 | + </figure> | |
| 142 | + ))} | |
| 24 | 143 | </div> |
| 25 | 144 | )} |
| 26 | − {images.map((a) => ( | |
| 27 | − <figure key={a.file_id} className="rounded-xl border border-neutral-line overflow-hidden bg-white"> | |
| 28 | − <img src={`/api/v1/files/${a.file_id}`} alt={a.filename || 'graphique'} className="w-full max-h-[420px] object-contain" loading="lazy" /> | |
| 29 | − </figure> | |
| 30 | − ))} | |
| 31 | − <ArtifactChips artifacts={others} /> | |
| 32 | 145 | </div> |
| 33 | 146 | ); |
| 34 | 147 | } |
modified
frontend/src/components/tools/tool-card.tsx
+21 −1
@@ -6,15 +6,27 @@ import { CourseSourceCard } from './course-source-card'; | ||
| 6 | 6 | import { QuizCard } from './quiz-card'; |
| 7 | 7 | import { FileAnalysisCard } from './file-analysis-card'; |
| 8 | 8 | import { FinancialCalcCard } from './financial-calc-card'; |
| 9 | +import { AppraisalCalcCard } from './appraisal-calc-card'; | |
| 10 | +import { UnitConvertCard } from './unit-convert-card'; | |
| 11 | +import { ChartCard } from './chart-card'; | |
| 12 | +import { DocxCard } from './docx-card'; | |
| 9 | 13 | |
| 10 | 14 | export function ToolCard({ call, conversationId }: { call: ToolCallView; conversationId: string }) { |
| 11 | 15 | if (call.status === 'error') { |
| 12 | − return <p className="text-sm text-semantic-error">{call.summary || "L'outil a échoué ; le tuteur a été informé."}</p>; | |
| 16 | + return ( | |
| 17 | + <div className="text-sm"> | |
| 18 | + <p className="text-semantic-error font-medium">Cet appel a échoué.</p> | |
| 19 | + <p className="text-neutral-muted mt-1 whitespace-pre-wrap">{call.summary || "Le tuteur a été informé et a corrigé le tir à l'étape suivante."}</p> | |
| 20 | + {call.args_preview && <pre className="mt-2 rounded-lg bg-neutral-surface p-2 text-[11px] overflow-x-auto scroll-thin">{call.args_preview}</pre>} | |
| 21 | + </div> | |
| 22 | + ); | |
| 13 | 23 | } |
| 14 | 24 | switch (call.name) { |
| 15 | 25 | case 'execute_python': |
| 16 | 26 | return <PythonExecCard call={call} conversationId={conversationId} />; |
| 17 | 27 | case 'create_excel': |
| 28 | + case 'edit_excel': | |
| 29 | + case 'inspect_excel': | |
| 18 | 30 | return <ExcelCard call={call} />; |
| 19 | 31 | case 'web_search': |
| 20 | 32 | return <WebSearchCard call={call} />; |
@@ -26,6 +38,14 @@ export function ToolCard({ call, conversationId }: { call: ToolCallView; convers | ||
| 26 | 38 | return <FileAnalysisCard call={call} />; |
| 27 | 39 | case 'financial_calc': |
| 28 | 40 | return <FinancialCalcCard call={call} />; |
| 41 | + case 'appraisal_calc': | |
| 42 | + return <AppraisalCalcCard call={call} />; | |
| 43 | + case 'unit_convert': | |
| 44 | + return <UnitConvertCard call={call} />; | |
| 45 | + case 'make_chart': | |
| 46 | + return <ChartCard call={call} />; | |
| 47 | + case 'create_docx': | |
| 48 | + return <DocxCard call={call} />; | |
| 29 | 49 | default: |
| 30 | 50 | return <pre className="text-xs whitespace-pre-wrap">{call.summary}</pre>; |
| 31 | 51 | } |
added
frontend/src/components/tools/unit-convert-card.tsx
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +import { ArrowRight } from 'lucide-react'; | |
| 2 | +import type { ToolCallView } from '@/lib/types'; | |
| 3 | +import { fmtNum } from '@/lib/format'; | |
| 4 | + | |
| 5 | +export function UnitConvertCard({ call }: { call: ToolCallView }) { | |
| 6 | + const p = call.payload as { value?: number; from?: string; to?: string; result?: number; factor?: number; formula?: string; kind?: string }; | |
| 7 | + if (p.result === undefined) return <p className="text-sm text-neutral-muted">{call.summary}</p>; | |
| 8 | + return ( | |
| 9 | + <div className="flex flex-wrap items-center gap-3 text-sm"> | |
| 10 | + <span className="rounded-xl bg-neutral-surface px-3 py-2 font-semibold tabular-nums">{fmtNum(p.value ?? 0, 4)} <span className="text-neutral-muted font-normal">{p.from}</span></span> | |
| 11 | + <ArrowRight size={16} className="text-neutral-muted" /> | |
| 12 | + <span className="rounded-xl bg-uqo-blue-light px-3 py-2 font-semibold tabular-nums text-uqo-blue-dark">{fmtNum(p.result, 4)} <span className="text-neutral-muted font-normal">{p.to}</span></span> | |
| 13 | + <span className="text-xs text-neutral-muted">facteur {fmtNum(p.factor ?? 0, 6)} · {p.kind}</span> | |
| 14 | + </div> | |
| 15 | + ); | |
| 16 | +} | |
modified
frontend/src/features/conversations/artifacts-panel.tsx
+7 −3
@@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { useMemo } from 'react'; |
| 2 | 2 | import { useQuery } from '@tanstack/react-query'; |
| 3 | −import { BookOpen, Download, FileSpreadsheet, FileText, Image as ImageIcon, Pin, X } from 'lucide-react'; | |
| 3 | +import { BookOpen, Download, FileSpreadsheet, FileText, Image as ImageIcon, Pin, Wand2, X } from 'lucide-react'; | |
| 4 | +import { useUI } from '@/stores/ui'; | |
| 4 | 5 | import { api, downloadFile } from '@/lib/api'; |
| 5 | 6 | import type { UploadedFile } from '@/lib/types'; |
| 6 | 7 | import { useChat } from '@/stores/chat'; |
@@ -17,8 +18,8 @@ export function ArtifactsPanel({ conversationId, onClose }: { conversationId: st | ||
| 17 | 18 | } |
| 18 | 19 | return [...seen.values()]; |
| 19 | 20 | }, [msgs]); |
| 20 | − const streamingCount = msgs.filter((m) => m.streaming).length; | |
| 21 | − void streamingCount; | |
| 21 | + const setDraft = useUI((s) => s.setDraft); | |
| 22 | + const setPanel = useUI((s) => s.setPanel); | |
| 22 | 23 | |
| 23 | 24 | const icon = (t: string) => (t === 'xlsx' ? FileSpreadsheet : t === 'image' ? ImageIcon : FileText); |
| 24 | 25 | const pin = async (f: UploadedFile) => { |
@@ -43,6 +44,9 @@ export function ArtifactsPanel({ conversationId, onClose }: { conversationId: st | ||
| 43 | 44 | <li key={f.file_id} className="flex items-center gap-2 rounded-xl border border-neutral-line p-2"> |
| 44 | 45 | <Icon size={18} className="text-uqo-blue shrink-0" /> |
| 45 | 46 | <span className="min-w-0 flex-1"><span className="block text-sm truncate">{f.filename}</span><span className="block text-[11px] text-neutral-muted">{f.kind === 'upload' ? 'déposé' : 'produit'} · {fmtBytes(f.size)}</span></span> |
| 47 | + {(f.type === 'xlsx' || f.type === 'csv') && ( | |
| 48 | + <button onClick={() => { setDraft(`Modifie le fichier « ${f.filename} » : `); if (window.innerWidth < 768) setPanel(false); onClose?.(); }} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-uqo-blue" aria-label="Modifier avec le tuteur" title="Modifier avec le tuteur"><Wand2 size={15} /></button> | |
| 49 | + )} | |
| 46 | 50 | <button onClick={() => pin(f)} className={`h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface ${f.pinned ? 'text-uqo-blue' : 'text-neutral-muted'}`} aria-label="Épingler"><Pin size={15} /></button> |
| 47 | 51 | <button onClick={() => downloadFile(f.file_id, f.filename)} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-neutral-muted" aria-label="Télécharger"><Download size={15} /></button> |
| 48 | 52 | </li> |
modified
frontend/src/stores/ui.ts
+4 −0
@@ -5,6 +5,8 @@ interface UIState { | ||
| 5 | 5 | panelOpen: boolean; |
| 6 | 6 | settingsOpen: boolean; |
| 7 | 7 | course: string; |
| 8 | + draft: string; | |
| 9 | + setDraft: (d: string) => void; | |
| 8 | 10 | setSidebar: (v: boolean) => void; |
| 9 | 11 | setPanel: (v: boolean) => void; |
| 10 | 12 | setSettings: (v: boolean) => void; |
@@ -16,6 +18,8 @@ export const useUI = create<UIState>((set) => ({ | ||
| 16 | 18 | panelOpen: false, |
| 17 | 19 | settingsOpen: false, |
| 18 | 20 | course: localStorage.getItem('uqo.course') || 'IMM1003', |
| 21 | + draft: '', | |
| 22 | + setDraft: (d) => set({ draft: d }), | |
| 19 | 23 | setSidebar: (v) => set({ sidebarOpen: v }), |
| 20 | 24 | setPanel: (v) => set({ panelOpen: v }), |
| 21 | 25 | setSettings: (v) => set({ settingsOpen: v }), |
| 22 | 26 | |