SPB Git

spb/uqo-chat

Public

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>
Simon-Pierre Boucher committed (Sep 6, 2026) parent d896f91 Browse files

31 changed files +1,905 −90

31 changed files
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
Menu

Theme