SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%

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 18 days ago (Sep 6, 2026) parent d896f91

31 changed files +1,905 −90

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