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
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/src/components/chat/tool-call-timeline.tsx
+38 −22
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/components/tools/appraisal-calc-card.tsx
+105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/src/components/tools/artifact-chips.tsx
+2 −1
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/components/tools/chart-card.tsx
+21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/components/tools/docx-card.tsx
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/src/components/tools/excel-card.tsx
+77 −20
Large diff (129 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/src/components/tools/python-exec-card.tsx
+136 −23
Large diff (170 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/src/components/tools/tool-card.tsx
+21 −1
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/components/tools/unit-convert-card.tsx
+16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/src/features/conversations/artifacts-panel.tsx
+7 −3
Large diff (27 lines) is not loaded by default to keep the page fast.Load diff
modified
frontend/src/stores/ui.ts
+4 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff