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: robust tools (coercion, flexible comparables, spec normalisation, JSON repair, 16k output), student accounts + invite links + access code in professor dashboard

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 18 days ago (Sep 6, 2026) parent afb5ca4

23 changed files +979 −103

modified backend/app/api/v1/auth.py +7 −5
@@ -65,13 +65,14 @@ async def magic_link(req: MagicLinkReq, request: Request, response: Response,
65 65 ip = request.client.host if request.client else "?"
66 66 limiter.check(f"login:{ip}", 20, 3600, "Trop de tentatives. Réessaie plus tard.")
67 67 email = req.email.lower()
68 − if not email_allowed(email, settings):
68 + if not email_allowed(email, settings) and not await users.email_registered(email):
69 69 raise HTTPException(status.HTTP_403_FORBIDDEN,
70 − detail="Seules les adresses @uqo.ca (ou invitées) sont admises.")
70 + detail="Adresse non admise. Utilise ton courriel @uqo.ca ou demande au "
71 + "professeur de créer ton compte.")
71 72 # Access-code path: no SMTP needed (course code handed out by the professor).
72 73 if req.access_code is not None:
73 − if not settings.ACCESS_CODE or not hmac.compare_digest(req.access_code.strip(),
74 − settings.ACCESS_CODE):
74 + code = await users.effective_access_code(settings)
75 + if not code or not hmac.compare_digest(req.access_code.strip(), code):
75 76 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Code d'accès invalide.")
76 77 user = await users.get_or_create_user(email, settings)
77 78 out = _issue(user.id, user.role, settings, response)
@@ -122,7 +123,8 @@ async def logout(response: Response) -> dict:
122 123
123 124 @router.get("/auth/config")
124 125 async def auth_config(settings: Settings = Depends(get_settings)) -> dict:
125 − return {"smtp": settings.smtp_enabled, "access_code": bool(settings.ACCESS_CODE),
126 + return {"smtp": settings.smtp_enabled,
127 + "access_code": bool(await users.effective_access_code(settings)),
126 128 "domains": sorted(settings.allowed_domains), "courses": settings.courses,
127 129 "term": settings.TERM_LABEL}
128 130
modified backend/app/api/v1/professor.py +94 −0
@@ -3,6 +3,7 @@
3 3 from __future__ import annotations
4 4
5 5 import asyncio
6 +import re
6 7 import tempfile
7 8 from pathlib import Path
8 9 from typing import Any
@@ -160,3 +161,96 @@ async def promote(req: PromoteReq, _: AuthUser = Depends(require_professor)) ->
160 161 raise HTTPException(status.HTTP_404_NOT_FOUND,
161 162 detail="Compte introuvable (la personne doit s'être connectée une fois).")
162 163 return {"ok": True}
164 +
165 +
166 +# ------------------------------------------------------------------ students
167 +@router.get("/students")
168 +async def list_students(_: AuthUser = Depends(require_professor),
169 + settings: Settings = Depends(get_settings)) -> dict:
170 + return {"users": await users.list_users(),
171 + "access_code": await users.effective_access_code(settings),
172 + "smtp": settings.smtp_enabled}
173 +
174 +
175 +class CreateStudentsReq(BaseModel):
176 + emails: list[str] | str
177 + role: str = "student"
178 +
179 +
180 +@router.post("/students", status_code=201)
181 +async def create_students(req: CreateStudentsReq, _: AuthUser = Depends(require_professor),
182 + settings: Settings = Depends(get_settings)) -> dict:
183 + raw = req.emails if isinstance(req.emails, list) else re.split(r"[\s,;]+", req.emails)
184 + names: dict[str, str] = {}
185 + emails: list[str] = []
186 + for item in raw:
187 + item = item.strip()
188 + if not item:
189 + continue
190 + m = re.match(r"^(.*?)<([^>]+)>$", item) # "Prénom Nom <courriel>"
191 + if m:
192 + names[m.group(2).strip().lower()] = m.group(1).strip().strip('"')
193 + emails.append(m.group(2))
194 + else:
195 + emails.append(item)
196 + if req.role not in {"student", "professor"}:
197 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.")
198 + return await users.create_users(emails[:500], settings, req.role, names)
199 +
200 +
201 +class RoleReq(BaseModel):
202 + role: str
203 +
204 +
205 +@router.patch("/students/{user_id}")
206 +async def set_student_role(user_id: str, req: RoleReq, auth: AuthUser = Depends(require_professor)) -> dict:
207 + if req.role not in {"student", "professor"}:
208 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.")
209 + u = await users.get_user(user_id)
210 + if not u:
211 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.")
212 + if u.id == auth.id:
213 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Impossible de modifier ton propre rôle.")
214 + await users.set_role(u.email, req.role)
215 + return {"ok": True}
216 +
217 +
218 +@router.delete("/students/{user_id}")
219 +async def delete_student(user_id: str, auth: AuthUser = Depends(require_professor)) -> dict:
220 + u = await users.get_user(user_id)
221 + if not u:
222 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.")
223 + if u.id == auth.id or u.role == "admin":
224 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Ce compte ne peut pas être supprimé ici.")
225 + await users.delete_user_data(user_id)
226 + return {"ok": True}
227 +
228 +
229 +@router.post("/students/{user_id}/invite")
230 +async def invite_student(user_id: str, _: AuthUser = Depends(require_professor),
231 + settings: Settings = Depends(get_settings)) -> dict:
232 + """Personal sign-in link (7 days). Sent by e-mail when SMTP exists, otherwise returned to copy."""
233 + u = await users.get_user(user_id)
234 + if not u:
235 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Compte introuvable.")
236 + token = await users.create_magic_link(u.email, ttl_minutes=7 * 24 * 60)
237 + link = f"{settings.APP_URL}/connexion?token={token}"
238 + sent = False
239 + if settings.smtp_enabled:
240 + from app.services import mail
241 +
242 + sent = await mail.send_magic_link(settings, u.email, link)
243 + return {"link": link, "sent": sent, "expires_days": 7}
244 +
245 +
246 +class AccessCodeReq(BaseModel):
247 + access_code: str
248 +
249 +
250 +@router.put("/access-code")
251 +async def set_access_code(req: AccessCodeReq, _: AuthUser = Depends(require_professor)) -> dict:
252 + code = req.access_code.strip()
253 + if len(code) < 6:
254 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Code trop court (6 caractères minimum).")
255 + await users.set_setting("access_code", code)
256 + return {"ok": True, "access_code": code}
modified backend/app/core/config.py +2 −2
@@ -42,9 +42,9 @@ class Settings(BaseSettings):
42 42 EMBEDDINGS_BASE_URL: str = ""
43 43 EMBEDDINGS_API_KEY: SecretStr = SecretStr("")
44 44 MODEL_EMBEDDINGS: str = ""
45 − LLM_MAX_TOOL_ITERATIONS: int = 8
45 + LLM_MAX_TOOL_ITERATIONS: int = 12
46 46 LLM_TIMEOUT_SECONDS: int = 120
47 − LLM_MAX_OUTPUT_TOKENS: int = 4096
47 + LLM_MAX_OUTPUT_TOKENS: int = 16000
48 48 LLM_MONTHLY_BUDGET_USD: float = 300.0
49 49 LLM_TURN_TIMEOUT_SECONDS: int = 300
50 50
modified backend/app/llm/agent.py +35 −9
@@ -120,11 +120,22 @@ class Turn:
120 120
121 121 async def _run_tool(self, call: ToolCallReq, file_ids: list[str]) -> tuple[ToolCallReq, ToolResult]:
122 122 args = call.arguments()
123 − preview = json.dumps(args, ensure_ascii=False)[:200]
123 + preview = json.dumps({k: v for k, v in args.items() if not k.startswith("__")},
124 + ensure_ascii=False)[:200]
124 125 await self.emit("tool_call", {"id": call.id, "name": call.name, "args_preview": preview,
125 − "arguments": args})
126 + "arguments": {k: v for k, v in args.items()
127 + if not k.startswith("__")}})
126 128 ctx = await self._tool_ctx(call.id, file_ids)
127 − result = await registry.run(call.name, args, ctx)
129 + if call.truncated:
130 + result = ToolResult(
131 + content=f"Appel {call.name} interrompu : tes arguments ont dépassé la limite de "
132 + f"longueur de sortie ({len(call.arguments_json)} caractères reçus). "
133 + "Refais un appel plus court : pour create_excel utilise un `template` ou "
134 + "réduis le nombre de lignes/feuilles (ou fais une feuille par appel) ; pour "
135 + "execute_python écris un code plus concis. N'annonce rien à l'étudiant avant "
136 + "que l'appel réussisse.", error=True)
137 + else:
138 + result = await registry.run(call.name, args, ctx)
128 139 artifacts = [a.to_dict() for a in result.artifacts]
129 140 await self.emit("tool_result", {"id": call.id, "name": call.name,
130 141 "summary": result.summary(), "artifacts": artifacts,
@@ -209,7 +220,16 @@ class Turn:
209 220 self.model_used = ev.data.get("model") or self.model_used
210 221 if finish == "cancelled":
211 222 break
223 + if finish == "length" and pending:
224 + # the last tool call was cut by the output limit: never run a truncated call
225 + last = max(pending)
226 + pending[last].truncated = True
227 + log.warning("tool_call_truncated", tool=pending[last].name,
228 + chars=len(pending[last].arguments_json))
212 229 if not pending:
230 + if finish == "length":
231 + await self.emit("warning", {"code": "length", "message_fr":
232 + "Réponse coupée (limite de longueur)."})
213 233 break
214 234 # append assistant tool-call message, run tools in parallel, append results
215 235 calls = sorted(pending.values(), key=lambda c: c.index)
@@ -240,12 +260,18 @@ class Turn:
240 260 content = "".join(self.text_parts).strip()
241 261 latency = int((time.perf_counter() - t0) * 1000)
242 262 if content or self.tool_records:
243 − await conv_service.save_assistant_message(
244 − self.conv.id, self.message_id, content, self.model_used or plan.models[0],
245 − self.usage_total, latency, self.tool_records)
246 − await self.emit("usage", {**self.usage_total, "model_used": self.model_used,
247 − "latency_ms": latency})
248 − await self.emit("done", {"message_id": self.message_id, "finish_reason": finish})
263 + try:
264 + await asyncio.shield(conv_service.save_assistant_message(
265 + self.conv.id, self.message_id, content, self.model_used or plan.models[0],
266 + self.usage_total, latency, self.tool_records))
267 + except Exception as exc: # noqa: BLE001
268 + log.warning("persist_failed", error=str(exc))
269 + try:
270 + await self.emit("usage", {**self.usage_total, "model_used": self.model_used,
271 + "latency_ms": latency})
272 + await self.emit("done", {"message_id": self.message_id, "finish_reason": finish})
273 + except Exception: # noqa: BLE001 — client already gone
274 + pass
249 275
250 276
251 277 # ------------------------------------------------------------------ side tasks
modified backend/app/llm/openrouter.py +8 −1
@@ -172,7 +172,14 @@ class LLMClient:
172 172 if delta.get("reasoning"):
173 173 yield StreamEvent("reasoning_delta", {"delta": delta["reasoning"]})
174 174 for tc in delta.get("tool_calls") or []:
175 − idx = int(tc.get("index", 0))
175 + if "index" in tc and tc["index"] is not None:
176 + idx = int(tc["index"])
177 + elif tc.get("id") and any(b["id"] == tc["id"] for b in tool_buf.values()):
178 + idx = next(k for k, b in tool_buf.items() if b["id"] == tc["id"])
179 + elif tc.get("id") or not tool_buf:
180 + idx = len(tool_buf) # provider omitted index: new call
181 + else:
182 + idx = max(tool_buf) # continuation of the last call
176 183 buf = tool_buf.get(idx)
177 184 if buf is None:
178 185 buf = {"id": tc.get("id") or f"call_{idx}", "name": "", "args": ""}
modified backend/app/llm/prompts/system_tutor.md +6 −0
@@ -27,6 +27,12 @@ Ta mission : faire comprendre, pas faire à la place.
27 27 - Quand plusieurs approches existent, dis laquelle le cours privilégie et pourquoi.
28 28 - Rappelle que seul un évaluateur agréé (É.A.) membre de l'OEAQ peut signer un rapport d'évaluation ; UQO-Chat est un outil pédagogique.
29 29
30 +## Discipline d'appel des outils
31 +- **N'annonce jamais une action sans l'exécuter dans le même tour** (« je génère le fichier » ⇒ l'appel `create_excel` suit immédiatement). Si un outil échoue, corrige et rappelle-le ; n'écris la réponse finale qu'une fois le livrable produit, ou explique clairement ce qui n'a pas pu être fait.
32 +- `create_excel` : privilégie un `template` quand il correspond (méthode du coût, comparables, âge-vie, amortissement, six fonctions, sensibilité) ; pour un `spec` libre, reste compact (≤ 60 lignes par appel, une feuille par appel si besoin), mets les textes (« Oui », « Bon état ») dans des colonnes `text`, les montants en nombres, et les formules en références réelles.
33 +- `execute_python` : code concis et autonome ; pour de longs tableaux, génère les données en Python plutôt que de les dicter dans le JSON.
34 +- Chaque appel doit contenir un JSON complet et valide ; si un appel est trop long, découpe-le.
35 +
30 36 ## Outils et rendu
31 37 - Après un appel d'outil, résume le résultat en langage clair ; ne recopie pas les sorties brutes.
32 38 - Les fichiers produits (Excel, images) sont affichés automatiquement à l'étudiant : mentionne-les simplement (« Le classeur ci-dessus contient… »).
modified backend/app/llm/schemas.py +68 −2
@@ -2,6 +2,8 @@
2 2
3 3 from __future__ import annotations
4 4
5 +import json
6 +import re
5 7 from dataclasses import dataclass, field
6 8 from typing import Any, Literal
7 9
@@ -17,22 +19,86 @@ class StreamEvent:
17 19 data: dict[str, Any] = field(default_factory=dict)
18 20
19 21
22 +def repair_json(text: str) -> dict[str, Any] | None:
23 + """Best-effort repair of a truncated/malformed JSON object (model output).
24 +
25 + Strategy: strip code fences, then walk backwards over plausible cut points and close the
26 + open brackets. Returns None when nothing parses.
27 + """
28 + s = text.strip()
29 + s = re.sub(r"^```(?:json)?\s*|\s*```$", "", s).strip()
30 + if not s:
31 + return None
32 + try:
33 + v = json.loads(s)
34 + return v if isinstance(v, dict) else None
35 + except json.JSONDecodeError:
36 + pass
37 + start = s.find("{")
38 + if start < 0:
39 + return None
40 + s = s[start:]
41 +
42 + def closers_at(cut: int) -> str | None:
43 + stack: list[str] = []
44 + in_str = False
45 + esc = False
46 + for ch in s[:cut]:
47 + if in_str:
48 + if esc:
49 + esc = False
50 + elif ch == "\\":
51 + esc = True
52 + elif ch == '"':
53 + in_str = False
54 + continue
55 + if ch == '"':
56 + in_str = True
57 + elif ch in "{[":
58 + stack.append("}" if ch == "{" else "]")
59 + elif ch in "}]":
60 + if stack:
61 + stack.pop()
62 + if in_str:
63 + return None
64 + return "".join(reversed(stack))
65 +
66 + n = len(s)
67 + for cut in range(n, max(0, n - 4000), -1):
68 + tail = s[cut - 1] if cut > 0 else ""
69 + if tail not in '"}]0123456789elsu.': # plausible end of a value
70 + continue
71 + closers = closers_at(cut)
72 + if closers is None:
73 + continue
74 + candidate = re.sub(r",\s*$", "", s[:cut]) + closers
75 + try:
76 + v = json.loads(candidate)
77 + return v if isinstance(v, dict) else None
78 + except json.JSONDecodeError:
79 + continue
80 + return None
81 +
82 +
20 83 @dataclass
21 84 class ToolCallReq:
22 85 id: str
23 86 name: str
24 87 arguments_json: str = ""
25 88 index: int = 0
89 + truncated: bool = False # the model hit its output limit while emitting this call
26 90
27 91 def arguments(self) -> dict[str, Any]:
28 − import json
29 −
30 92 if not self.arguments_json.strip():
31 93 return {}
32 94 try:
33 95 v = json.loads(self.arguments_json)
34 96 return v if isinstance(v, dict) else {}
35 97 except json.JSONDecodeError:
98 + repaired = repair_json(self.arguments_json)
99 + if repaired is not None:
100 + repaired["__repaired__"] = True
101 + return repaired
36 102 return {"__invalid_json__": self.arguments_json[:2000]}
37 103
38 104
modified backend/app/models/__init__.py +9 −0
@@ -216,3 +216,12 @@ class LLMUsage(Base):
216 216 cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
217 217 latency_ms: Mapped[int] = mapped_column(Integer, default=0)
218 218 created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True)
219 +
220 +
221 +class AppSetting(Base):
222 + """Small key/value store for runtime settings editable by the professor (access code…)."""
223 +
224 + __tablename__ = "app_settings"
225 + key: Mapped[str] = mapped_column(String(64), primary_key=True)
226 + value: Mapped[str] = mapped_column(Text, default="")
227 + updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, onupdate=utcnow)
modified backend/app/services/users.py +66 −1
@@ -4,12 +4,13 @@ from __future__ import annotations
4 4
5 5 from datetime import timedelta
6 6
7 −from sqlalchemy import delete, select
7 +from sqlalchemy import delete, func, select
8 8
9 9 from app.core.config import Settings
10 10 from app.core.security import new_magic_token, role_for_email
11 11 from app.db import SessionLocal
12 12 from app.models import (
13 + AppSetting,
13 14 Conversation,
14 15 MagicLink,
15 16 Message,
@@ -126,3 +127,67 @@ async def delete_user_data(user_id: str) -> None:
126 127 await session.execute(delete(Quiz).where(Quiz.id.in_(quiz_ids)))
127 128 await session.execute(delete(User).where(User.id == user_id))
128 129 await session.commit()
130 +
131 +
132 +# ------------------------------------------------------------------ professor: students
133 +async def list_users() -> list[dict]:
134 + async with SessionLocal() as session:
135 + counts = dict((await session.execute(
136 + select(Conversation.user_id, func.count(Message.id)).join(Message)
137 + .where(Message.role == "user").group_by(Conversation.user_id))).all())
138 + rows = (await session.execute(select(User).order_by(User.role.desc(), User.email))).scalars()
139 + return [{"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name,
140 + "created_at": u.created_at.isoformat(),
141 + "last_seen_at": u.last_seen_at.isoformat() if u.last_seen_at else None,
142 + "consent_at": u.consent_at.isoformat() if u.consent_at else None,
143 + "messages": int(counts.get(u.id, 0))} for u in rows]
144 +
145 +
146 +async def create_users(emails: list[str], settings: Settings, role: str = "student",
147 + display_names: dict[str, str] | None = None) -> dict:
148 + created, existing, invalid = [], [], []
149 + async with SessionLocal() as session:
150 + for raw in emails:
151 + email = raw.strip().lower()
152 + if not email or "@" not in email or " " in email:
153 + if raw.strip():
154 + invalid.append(raw.strip())
155 + continue
156 + user = await session.scalar(select(User).where(User.email == email))
157 + if user:
158 + existing.append(email)
159 + continue
160 + r = role_for_email(email, settings)
161 + session.add(User(email=email, role=r if r != "student" else role,
162 + display_name=(display_names or {}).get(email) or email.split("@")[0],
163 + preferences={"tutoiement": True, "course": settings.courses[0],
164 + "deep": False, "locale": "fr-CA"}))
165 + created.append(email)
166 + await session.commit()
167 + return {"created": created, "existing": existing, "invalid": invalid}
168 +
169 +
170 +async def email_registered(email: str) -> bool:
171 + async with SessionLocal() as session:
172 + return (await session.scalar(select(User.id).where(User.email == email.lower().strip()))) is not None
173 +
174 +
175 +async def get_setting(key: str) -> str | None:
176 + async with SessionLocal() as session:
177 + row = await session.get(AppSetting, key)
178 + return row.value if row else None
179 +
180 +
181 +async def set_setting(key: str, value: str) -> None:
182 + async with SessionLocal() as session:
183 + row = await session.get(AppSetting, key)
184 + if row:
185 + row.value = value
186 + else:
187 + session.add(AppSetting(key=key, value=value))
188 + await session.commit()
189 +
190 +
191 +async def effective_access_code(settings: Settings) -> str:
192 + override = await get_setting("access_code")
193 + return override if override is not None else settings.ACCESS_CODE
added backend/app/tools/coerce.py +80 −0
@@ -0,0 +1,80 @@
1 +"""Tolerant value coercion for tool arguments (models send '185 000 $', 'Oui', '12 %', …)."""
2 +
3 +from __future__ import annotations
4 +
5 +import re
6 +from typing import Any
7 +
8 +_NUM_RE = re.compile(r"^[+-]?\d+(?:[.,]\d+)?$")
9 +TRUE_WORDS = {"oui", "yes", "vrai", "true", "o", "y", "x", "✓"}
10 +FALSE_WORDS = {"non", "no", "faux", "false", "n", "-", "—", ""}
11 +
12 +
13 +def num(value: Any, default: float | None = None) -> float | None:
14 + """Parse a number from int/float/str (French formats, currency, percent). None if impossible.
15 +
16 + '185 000 $' → 185000 ; '12,5 %' → 0.125 ; '+3 %' → 0.03 ; '-5 000' → -5000 ; 'Oui' → None.
17 + """
18 + if value is None or isinstance(value, bool):
19 + return default
20 + if isinstance(value, (int, float)):
21 + return float(value)
22 + if not isinstance(value, str):
23 + return default
24 + s = value.strip().replace(" ", "").replace(" ", "").replace(" ", "")
25 + if not s or s.startswith("="):
26 + return default
27 + pct = s.endswith("%")
28 + s = re.sub(r"/?(?:[$€£%]|CAD|USD|pi²|pi2|m²|m2|mois|ans?\b)", "", s, flags=re.I).strip()
29 + s = s.replace("'", "")
30 + if s.count(",") == 1 and s.count(".") == 0:
31 + s = s.replace(",", ".")
32 + elif s.count(",") >= 1 and s.count(".") == 1:
33 + s = s.replace(",", "")
34 + if not _NUM_RE.match(s):
35 + return default
36 + v = float(s)
37 + return v / 100.0 if pct else v
38 +
39 +
40 +def pct(value: Any, default: float | None = None) -> float | None:
41 + """Percent as a ratio. 3 → 0.03 (if |v| > 1), '3 %' → 0.03, 0.03 → 0.03."""
42 + v = num(value, None)
43 + if v is None:
44 + return default
45 + if isinstance(value, str) and value.strip().endswith("%"):
46 + return v
47 + return v / 100.0 if abs(v) > 1 else v
48 +
49 +
50 +def boolish(value: Any) -> bool | None:
51 + if isinstance(value, bool):
52 + return value
53 + if isinstance(value, (int, float)):
54 + return bool(value)
55 + if isinstance(value, str):
56 + s = value.strip().lower()
57 + if s in TRUE_WORDS:
58 + return True
59 + if s in FALSE_WORDS:
60 + return False
61 + return None
62 +
63 +
64 +def cell(value: Any) -> Any:
65 + """Value safe for openpyxl: numbers parsed, formulas kept, everything else → text."""
66 + if value is None or isinstance(value, (int, float, bool)):
67 + return value
68 + if isinstance(value, str):
69 + s = value.strip()
70 + if s.startswith("="):
71 + return s
72 + n = num(s, None)
73 + if n is not None and not re.search(r"[A-Za-z]{2,}", s):
74 + return n
75 + return s
76 + if isinstance(value, dict):
77 + return ", ".join(f"{k}: {v}" for k, v in value.items())[:250]
78 + if isinstance(value, (list, tuple)):
79 + return ", ".join(str(v) for v in value)[:250]
80 + return str(value)
modified backend/app/tools/create_excel.py +14 −5
@@ -17,6 +17,7 @@ from pydantic import BaseModel, Field
17 17
18 18 from app.llm.schemas import Artifact, ToolResult
19 19 from app.services import files as file_service
20 +from app.tools.excel_spec import normalise_spec
20 21 from app.tools.excel_templates import TEMPLATES
21 22 from app.tools.registry import ToolContext, registry
22 23
@@ -71,9 +72,9 @@ def _apply_format(cell: Any, kind: str) -> None:
71 72
72 73 def _render_sheet(wb: Workbook, ws: Any, sheet: dict[str, Any]) -> dict[str, Any]:
73 74 ws.sheet_view.showGridLines = False
74 − ws.column_dimensions["A"].width = 44
75 − for c in "BCDEFGHIJK":
76 − ws.column_dimensions[c].width = 18
75 + ws.column_dimensions["A"].width = 40
76 + for i in range(2, 30):
77 + ws.column_dimensions[get_column_letter(i)].width = 17
77 78 title = sheet.get("title") or sheet.get("name", "Feuille")
78 79 ws["A1"] = title
79 80 ws["A1"].font = TITLE_FONT
@@ -121,6 +122,8 @@ def _render_sheet(wb: Workbook, ws: Any, sheet: dict[str, Any]) -> dict[str, Any
121 122 for i, row in enumerate(table.get("rows") or []):
122 123 r = row0 + 1 + i
123 124 for j, value in enumerate(row):
125 + if isinstance(value, (dict, list, tuple)):
126 + value = str(value)[:250]
124 127 c = ws.cell(row=r, column=col0 + j, value=value)
125 128 kind = columns[j].get("type", "text") if j < len(columns) else "text"
126 129 if j == 0 and table.get("first_col_format"):
@@ -270,9 +273,15 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult:
270 273 return ToolResult(content=f"Gabarit inconnu. Gabarits : {', '.join(TEMPLATES)}.",
271 274 error=True)
272 275 spec = tpl(args.get("params") or {})
276 + if spec and not args.get("template"):
277 + spec = normalise_spec(spec)
273 278 if not spec or not spec.get("sheets"):
274 − return ToolResult(content="Fournis `spec.sheets` (liste de feuilles) ou `template`.",
275 − error=True)
279 + return ToolResult(content="Fournis `spec.sheets` (liste de feuilles avec tables/rows) ou "
280 + f"`template` parmi : {', '.join(TEMPLATES)}.", error=True)
281 + total_rows = sum(len(t.get("rows") or []) for sh in spec["sheets"] for t in sh.get("tables") or [])
282 + if total_rows > 2000:
283 + return ToolResult(content="Classeur trop volumineux (> 2000 lignes) : découpe en plusieurs "
284 + "appels ou génère les lignes avec execute_python (openpyxl).", error=True)
276 285 filename = args.get("filename") or spec.get("filename") or "classeur.xlsx"
277 286 if not filename.lower().endswith(".xlsx"):
278 287 filename += ".xlsx"
added backend/app/tools/excel_spec.py +156 −0
@@ -0,0 +1,156 @@
1 +"""Normalise a free-form `create_excel` spec so that almost anything the model sends renders."""
2 +
3 +from __future__ import annotations
4 +
5 +import re
6 +from typing import Any
7 +
8 +from openpyxl.utils import get_column_letter
9 +from openpyxl.utils.cell import coordinate_from_string
10 +
11 +from app.tools.coerce import cell
12 +
13 +TYPE_ALIASES = {
14 + "money": "currency", "cad": "currency", "$": "currency", "dollar": "currency", "dollars": "currency",
15 + "pct": "percent", "%": "percent", "percentage": "percent", "pourcentage": "percent",
16 + "int": "integer", "entier": "integer", "float": "number", "decimal": "number", "num": "number",
17 + "string": "text", "str": "text", "texte": "text", "date": "text", "m2": "area", "m²": "area",
18 + "superficie": "area", "ratio": "factor", "facteur": "factor",
19 +}
20 +
21 +
22 +def _type(t: Any) -> str:
23 + s = str(t or "text").strip().lower()
24 + return TYPE_ALIASES.get(s, s if s in {"currency", "percent", "number", "integer", "area", "factor", "text"} else "text")
25 +
26 +
27 +def _guess_type(header: str, values: list[Any]) -> str:
28 + h = header.lower()
29 + if any(k in h for k in ("$", "prix", "coût", "cout", "montant", "valeur", "loyer", "revenu", "dépense")):
30 + return "currency"
31 + if "%" in h or "taux" in h or "pourcent" in h:
32 + return "percent"
33 + if any(k in h for k in ("m²", "m2", "pi²", "superficie")):
34 + return "area"
35 + if any(k in h for k in ("année", "annee", "an", "n°", "no", "rang", "période", "periode")) and all(
36 + isinstance(v, (int, float)) and float(v).is_integer() for v in values if v not in (None, "")):
37 + return "integer"
38 + if values and all(isinstance(v, (int, float)) for v in values if v not in (None, "")):
39 + return "number"
40 + return "text"
41 +
42 +
43 +def _columns(raw: Any, rows: list[list[Any]]) -> list[dict[str, Any]]:
44 + cols: list[dict[str, Any]] = []
45 + if isinstance(raw, list):
46 + for c in raw:
47 + if isinstance(c, dict):
48 + cols.append({"header": str(c.get("header") or c.get("name") or c.get("label") or ""),
49 + "type": _type(c.get("type") or c.get("format"))})
50 + else:
51 + cols.append({"header": str(c), "type": None})
52 + width = max([len(cols)] + [len(r) for r in rows]) if (cols or rows) else 0
53 + while len(cols) < width:
54 + cols.append({"header": f"Col {len(cols) + 1}", "type": None})
55 + for j, c in enumerate(cols):
56 + if c["type"] is None:
57 + c["type"] = _guess_type(c["header"], [r[j] for r in rows if j < len(r)])
58 + return cols
59 +
60 +
61 +def _rows(raw: Any, columns_raw: Any) -> list[list[Any]]:
62 + rows: list[list[Any]] = []
63 + headers = []
64 + if isinstance(columns_raw, list):
65 + headers = [str(c.get("header") or c.get("name") or c.get("label") or "") if isinstance(c, dict)
66 + else str(c) for c in columns_raw]
67 + for r in raw or []:
68 + if isinstance(r, dict):
69 + if headers:
70 + keys = {k.lower(): k for k in r}
71 + row = [cell(r.get(h) if h in r else r.get(keys.get(h.lower(), ""), "")) for h in headers]
72 + # keep extra keys not in headers
73 + for k, v in r.items():
74 + if k not in headers and k.lower() not in {h.lower() for h in headers}:
75 + row.append(cell(v))
76 + else:
77 + row = [cell(v) for v in r.values()]
78 + elif isinstance(r, (list, tuple)):
79 + row = [cell(v) for v in r]
80 + else:
81 + row = [cell(r)]
82 + rows.append(row)
83 + return rows
84 +
85 +
86 +def _valid_anchor(a: Any) -> str | None:
87 + if not isinstance(a, str) or not re.fullmatch(r"[A-Za-z]{1,3}\d{1,6}", a.strip()):
88 + return None
89 + return a.strip().upper()
90 +
91 +
92 +def normalise_spec(spec: dict[str, Any]) -> dict[str, Any]:
93 + out: dict[str, Any] = {"filename": spec.get("filename"), "style": "uqo",
94 + "objective": spec.get("objective") or spec.get("objectif") or spec.get("title"),
95 + "hypotheses": spec.get("hypotheses") or spec.get("hypothèses") or [], "sheets": []}
96 + sheets = spec.get("sheets") or spec.get("feuilles") or []
97 + if not sheets and (spec.get("tables") or spec.get("rows") or spec.get("columns")):
98 + sheets = [spec]
99 + for si, sh in enumerate(sheets):
100 + if not isinstance(sh, dict):
101 + continue
102 + tables_raw = sh.get("tables") or sh.get("tableaux") or []
103 + if not tables_raw and (sh.get("rows") or sh.get("columns") or sh.get("data")):
104 + tables_raw = [{"columns": sh.get("columns"), "rows": sh.get("rows") or sh.get("data"),
105 + "totals": sh.get("totals")}]
106 + inputs = []
107 + for inp in sh.get("inputs") or sh.get("hypotheses") or []:
108 + if not isinstance(inp, dict):
109 + continue
110 + c = _valid_anchor(inp.get("cell"))
111 + if not c:
112 + continue
113 + v = inp.get("value")
114 + if isinstance(v, str) and v.startswith("="):
115 + pass
116 + else:
117 + v = cell(v)
118 + inputs.append({"cell": c, "label": str(inp.get("label", "")), "value": v,
119 + "format": _type(inp.get("format") or inp.get("type") or "number"),
120 + "name": inp.get("name")})
121 + next_row = 4
122 + if inputs:
123 + next_row = max(coordinate_from_string(i["cell"])[1] for i in inputs) + 2
124 + tables = []
125 + for t in tables_raw:
126 + if not isinstance(t, dict):
127 + continue
128 + rows = _rows(t.get("rows") or t.get("data") or t.get("lignes"), t.get("columns") or t.get("colonnes"))
129 + cols = _columns(t.get("columns") or t.get("colonnes"), rows)
130 + anchor = _valid_anchor(t.get("anchor")) or f"A{next_row}"
131 + totals = t.get("totals") or t.get("total")
132 + if isinstance(totals, dict):
133 + totals = {"label": str(totals.get("label", "Total")), "formula": totals.get("formula") or totals.get("value"),
134 + "format": _type(totals.get("format")) if totals.get("format") else None, "name": totals.get("name")}
135 + if totals["format"] is None:
136 + totals.pop("format")
137 + else:
138 + totals = None
139 + tables.append({"anchor": anchor, "columns": cols, "rows": rows, "totals": totals,
140 + "row_formats": t.get("row_formats") or {}, "bold_rows": t.get("bold_rows") or [],
141 + "first_col_format": t.get("first_col_format")})
142 + _, r0 = coordinate_from_string(anchor)
143 + next_row = max(next_row, r0 + 1 + len(rows) + (1 if totals else 0) + 2)
144 + charts = []
145 + for ch in sh.get("charts") or sh.get("graphiques") or []:
146 + if isinstance(ch, dict):
147 + charts.append({"type": str(ch.get("type", "bar")).lower(), "title": str(ch.get("title", "")),
148 + "categories_range": ch.get("categories_range") or ch.get("categories"),
149 + "values_range": ch.get("values_range") or ch.get("data_range") or ch.get("values"),
150 + "anchor": _valid_anchor(ch.get("anchor")) or f"{get_column_letter(8)}4"})
151 + notes = [str(n) for n in (sh.get("notes") or []) if n]
152 + out["sheets"].append({"name": str(sh.get("name") or sh.get("nom") or f"Feuille {si + 1}"),
153 + "title": str(sh.get("title") or sh.get("titre") or sh.get("name") or ""),
154 + "inputs_title": sh.get("inputs_title"), "inputs": inputs, "tables": tables,
155 + "charts": charts, "notes": notes})
156 + return out
modified backend/app/tools/excel_templates/age_vie.py +12 −3
@@ -4,11 +4,20 @@ from __future__ import annotations
4 4
5 5 from typing import Any
6 6
7 +from app.tools.coerce import num, pct
8 +
9 +
10 +def _f(p: dict[str, Any], key: str, default: float) -> float:
11 + """Tolerant numeric param; keys ending in `_pct` / starting with `taux` are ratios."""
12 + raw = p.get(key)
13 + v = pct(raw, None) if (key.endswith("_pct") or key.startswith("taux")) else num(raw, None)
14 + return float(default) if v is None else v
15 +
7 16
8 17 def build(p: dict[str, Any]) -> dict[str, Any]:
9 − cn = float(p.get("cout_neuf", 450000))
10 − age_eff = float(p.get("age_effectif", 12))
11 − dve = float(p.get("duree_vie_economique", 60))
18 + cn = _f(p, "cout_neuf", 450000)
19 + age_eff = _f(p, "age_effectif", 12)
20 + dve = _f(p, "duree_vie_economique", 60)
12 21 inputs = [
13 22 {"cell": "B4", "label": "Coût neuf C_N ($)", "value": cn, "format": "currency",
14 23 "name": "Cout_Neuf"},
modified backend/app/tools/excel_templates/comparables_ajustes.py +134 −44
@@ -1,74 +1,164 @@
1 −"""Template: adjusted comparables grid (sales comparison)."""
1 +"""Template: adjusted comparables grid (sales comparison) — flexible columns.
2 +
3 +Accepted `params`:
4 + sujet: str
5 + ajustements: [str] # adjustment names (optional; inferred from comparables)
6 + comparables: [ { adresse|nom, prix, date|date_vente, temps|ajustement_temps_pct,
7 + ajustements: {name: montant $ | "+3 %"}, # or adjustment keys at top level
8 + caracteristiques: {name: texte}, # e.g. garage: "Oui"
9 + ... } ]
10 +Text values in adjustment columns ("Oui", "meilleur") are moved to the characteristics table.
11 +"""
2 12
3 13 from __future__ import annotations
4 14
5 15 from typing import Any
6 16
17 +from openpyxl.utils import get_column_letter
18 +
19 +from app.tools.coerce import cell, num, pct
20 +
21 +META_KEYS = {"adresse", "nom", "comparable", "prix", "prix_vente", "date", "date_vente",
22 + "temps", "ajustement_temps_pct", "date_pct", "ajust_temps", "ajustements",
23 + "caracteristiques", "notes", "note", "source"}
24 +
7 25 DEFAULT_COMPS = [
8 − {"adresse": "Comparable 1", "prix": 415000, "date_pct": 0.02, "superficie": -5000,
9 − "terrain": 0, "garage": -12000, "etat": 0},
10 − {"adresse": "Comparable 2", "prix": 439000, "date_pct": 0.01, "superficie": 8000,
11 − "terrain": -6000, "garage": 0, "etat": -10000},
12 − {"adresse": "Comparable 3", "prix": 402000, "date_pct": 0.03, "superficie": 0,
13 − "terrain": 4000, "garage": 0, "etat": 15000},
26 + {"adresse": "Comparable 1", "prix": 415000, "temps": "2 %",
27 + "ajustements": {"Superficie": -5000, "Garage": -12000},
28 + "caracteristiques": {"Garage": "Oui", "État": "Bon"}},
29 + {"adresse": "Comparable 2", "prix": 439000, "temps": "1 %",
30 + "ajustements": {"Superficie": 8000, "Terrain": -6000, "État": -10000},
31 + "caracteristiques": {"Garage": "Non", "État": "Très bon"}},
32 + {"adresse": "Comparable 3", "prix": 402000, "temps": "3 %",
33 + "ajustements": {"Terrain": 4000, "État": 15000},
34 + "caracteristiques": {"Garage": "Non", "État": "Moyen"}},
14 35 ]
15 36
16 37
38 +def _normalise(comps: list[Any]) -> tuple[list[dict[str, Any]], list[str], list[str]]:
39 + out: list[dict[str, Any]] = []
40 + adj_names: list[str] = []
41 + char_names: list[str] = []
42 +
43 + def add(names: list[str], k: str) -> None:
44 + if k not in names:
45 + names.append(k)
46 +
47 + for i, raw in enumerate(comps):
48 + c = dict(raw) if isinstance(raw, dict) else {"adresse": str(raw)}
49 + item: dict[str, Any] = {
50 + "nom": str(c.get("adresse") or c.get("nom") or c.get("comparable") or f"Comparable {i + 1}"),
51 + "prix": num(c.get("prix", c.get("prix_vente")), 0.0) or 0.0,
52 + "date": str(c.get("date") or c.get("date_vente") or ""),
53 + "temps": pct(c.get("temps", c.get("ajustement_temps_pct", c.get("date_pct",
54 + c.get("ajust_temps")))), 0.0) or 0.0,
55 + "adj": {}, "chars": {}, "note": str(c.get("notes") or c.get("note") or ""),
56 + }
57 + candidates: dict[str, Any] = {}
58 + if isinstance(c.get("ajustements"), dict):
59 + candidates.update(c["ajustements"])
60 + for k, v in c.items():
61 + if k not in META_KEYS and not isinstance(v, (dict, list)):
62 + candidates[k] = v
63 + for k, v in candidates.items():
64 + name = str(k).replace("_", " ").strip().capitalize()
65 + n = num(v, None)
66 + if n is None or (isinstance(v, str) and v.strip().lower() in {"oui", "non"}):
67 + if v not in (None, ""):
68 + item["chars"][name] = str(v)
69 + add(char_names, name)
70 + continue
71 + if isinstance(v, str) and v.strip().endswith("%"):
72 + n = n * item["prix"] # percent of price → dollars
73 + item["adj"][name] = n
74 + add(adj_names, name)
75 + if isinstance(c.get("caracteristiques"), dict):
76 + for k, v in c["caracteristiques"].items():
77 + name = str(k).replace("_", " ").strip().capitalize()
78 + item["chars"][name] = str(v)
79 + add(char_names, name)
80 + out.append(item)
81 + return out, adj_names, char_names
82 +
83 +
17 84 def build(p: dict[str, Any]) -> dict[str, Any]:
18 − comps = p.get("comparables") or DEFAULT_COMPS
19 − sujet = p.get("sujet", "Sujet — immeuble fictif, Gatineau")
20 − rows = []
21 − for i, c in enumerate(comps):
22 − r = 5 + i
23 − rows.append([
24 − c.get("adresse", f"Comparable {i + 1}"), float(c.get("prix", 0)),
25 − float(c.get("date_pct", 0)), f"=B{r}*(1+C{r})",
26 − float(c.get("superficie", 0)), float(c.get("terrain", 0)),
27 − float(c.get("garage", 0)), float(c.get("etat", 0)),
28 − f"=D{r}+E{r}+F{r}+G{r}+H{r}", f"=ABS(E{r})+ABS(F{r})+ABS(G{r})+ABS(H{r})",
29 − f"=J{r}/D{r}",
30 − ])
85 + comps_raw = p.get("comparables") or DEFAULT_COMPS
86 + comps, adj_names, char_names = _normalise(list(comps_raw))
87 + for extra in p.get("ajustements") or []:
88 + name = str(extra).strip().capitalize()
89 + if name and name not in adj_names:
90 + adj_names.append(name)
91 + sujet = str(p.get("sujet", "Sujet — immeuble fictif, Gatineau"))
31 92 n = len(comps)
32 − last = 4 + n
33 − table = {
34 − "anchor": "A4",
35 − "columns": [
36 − {"header": "Comparable", "type": "text"}, {"header": "Prix de vente ($)", "type": "currency"},
37 − {"header": "Ajust. temps (%)", "type": "percent"},
38 − {"header": "Prix ajusté temps ($)", "type": "currency"},
39 − {"header": "Superficie ($)", "type": "currency"}, {"header": "Terrain ($)", "type": "currency"},
40 − {"header": "Garage ($)", "type": "currency"}, {"header": "État ($)", "type": "currency"},
41 − {"header": "Prix ajusté ($)", "type": "currency"},
42 − {"header": "Ajust. bruts ($)", "type": "currency"},
43 − {"header": "Ajust. bruts (%)", "type": "percent"},
44 − ],
45 − "rows": rows,
46 − }
93 + first_row = 5
94 + last = first_row + n - 1
95 + # columns: A nom, B prix, C date, D temps %, E prix ajusté temps, F.. adjustments, then totals
96 + adj_start = 6 # F
97 + adj_end = adj_start + len(adj_names) - 1 if adj_names else adj_start - 1
98 + col_total = adj_end + 1
99 + col_final = adj_end + 2
100 + col_gross = adj_end + 3
101 + col_gross_pct = adj_end + 4
102 + L = get_column_letter
103 + rows: list[list[Any]] = []
104 + for i, c in enumerate(comps):
105 + r = first_row + i
106 + row: list[Any] = [c["nom"], c["prix"], c["date"] or "—", c["temps"], f"=B{r}*(1+D{r})"]
107 + for name in adj_names:
108 + row.append(c["adj"].get(name, 0.0))
109 + adj_range = f"{L(adj_start)}{r}:{L(adj_end)}{r}" if adj_names else None
110 + row.append(f"=SUM({adj_range})" if adj_range else 0)
111 + row.append(f"=E{r}+{L(col_total)}{r}")
112 + row.append(f"=SUMPRODUCT(ABS({adj_range}))" if adj_range else 0)
113 + row.append(f"=IF(E{r}=0,0,{L(col_gross)}{r}/E{r})")
114 + rows.append(row)
115 + columns = ([{"header": "Comparable", "type": "text"}, {"header": "Prix de vente ($)", "type": "currency"},
116 + {"header": "Date de vente", "type": "text"}, {"header": "Ajust. temps (%)", "type": "percent"},
117 + {"header": "Prix ajusté temps ($)", "type": "currency"}]
118 + + [{"header": f"{a} ($)", "type": "currency"} for a in adj_names]
119 + + [{"header": "Total ajustements ($)", "type": "currency"},
120 + {"header": "Prix ajusté ($)", "type": "currency"},
121 + {"header": "Ajust. bruts ($)", "type": "currency"},
122 + {"header": "Ajust. bruts (%)", "type": "percent"}])
123 + grid = {"anchor": "A4", "columns": columns, "rows": rows}
124 + F = L(col_final)
125 + G = L(col_gross_pct)
126 + stats_anchor = last + 3
47 127 stats = {
48 − "anchor": f"A{last + 3}",
128 + "anchor": f"A{stats_anchor}",
49 129 "columns": [{"header": "Statistique", "type": "text"}, {"header": "Valeur", "type": "currency"}],
50 130 "rows": [
51 − ["Minimum des prix ajustés", f"=MIN(I5:I{last})"],
52 − ["Maximum des prix ajustés", f"=MAX(I5:I{last})"],
53 − ["Moyenne simple", f"=AVERAGE(I5:I{last})"],
54 − ["Médiane", f"=MEDIAN(I5:I{last})"],
55 − ["Comparable le moins ajusté (indice)", f"=MATCH(MIN(K5:K{last}),K5:K{last},0)"],
56 − ["Prix ajusté du comparable le moins ajusté", f"=INDEX(I5:I{last},B{last + 8})"],
131 + ["Minimum des prix ajustés", f"=MIN({F}{first_row}:{F}{last})"],
132 + ["Maximum des prix ajustés", f"=MAX({F}{first_row}:{F}{last})"],
133 + ["Moyenne simple", f"=AVERAGE({F}{first_row}:{F}{last})"],
134 + ["Médiane", f"=MEDIAN({F}{first_row}:{F}{last})"],
135 + ["Comparable le moins ajusté (rang)", f"=MATCH(MIN({G}{first_row}:{G}{last}),{G}{first_row}:{G}{last},0)"],
136 + ["Prix ajusté du comparable le moins ajusté", f"=INDEX({F}{first_row}:{F}{last},B{stats_anchor + 5})"],
57 137 ],
58 138 "row_formats": {4: "integer"},
59 139 }
140 + tables = [grid, stats]
141 + if char_names:
142 + crow = stats_anchor + 9
143 + tables.append({
144 + "anchor": f"A{crow}",
145 + "columns": [{"header": "Comparable", "type": "text"}] + [{"header": c, "type": "text"} for c in char_names],
146 + "rows": [[c["nom"]] + [cell(c["chars"].get(name, "—")) for name in char_names] for c in comps],
147 + })
60 148 return {
61 149 "filename": p.get("filename", "comparables_ajustes.xlsx"),
62 150 "style": "uqo",
151 + "objective": f"Grille de comparables ajustés — {sujet}",
63 152 "sheets": [{
64 153 "name": "Comparables",
65 154 "title": f"Grille de comparables ajustés — {sujet}",
66 155 "inputs": [],
67 − "tables": [table, stats],
156 + "tables": tables,
68 157 "notes": [
69 158 "On ajuste le comparable vers le sujet : le comparable est meilleur → ajustement négatif.",
70 159 "Ordre : conditions de vente, financement, marché (temps), puis caractéristiques physiques.",
71 160 "La réconciliation pondère les indications (poids plus fort au comparable le moins ajusté) ; ce n'est pas une moyenne.",
72 − ],
161 + ] + ([f"Caractéristiques qualitatives ({', '.join(char_names)}) présentées dans le tableau du bas ; "
162 + "leur traduction en $ est une hypothèse à justifier."] if char_names else []),
73 163 }],
74 164 }
modified backend/app/tools/excel_templates/methode_du_cout.py +19 −10
@@ -4,19 +4,28 @@ from __future__ import annotations
4 4
5 5 from typing import Any
6 6
7 +from app.tools.coerce import num, pct
8 +
9 +
10 +def _f(p: dict[str, Any], key: str, default: float) -> float:
11 + """Tolerant numeric param; keys ending in `_pct` / starting with `taux` are ratios."""
12 + raw = p.get(key)
13 + v = pct(raw, None) if (key.endswith("_pct") or key.startswith("taux")) else num(raw, None)
14 + return float(default) if v is None else v
15 +
7 16
8 17 def build(p: dict[str, Any]) -> dict[str, Any]:
9 18 adresse = p.get("adresse", "Immeuble fictif — Gatineau")
10 − superficie = float(p.get("superficie_m2", 210))
11 − cout_unitaire = float(p.get("cout_unitaire_m2", 2450))
12 − indirects = float(p.get("couts_indirects_pct", 0.12))
13 − profit = float(p.get("profit_pct", 0.15))
14 − terrain = float(p.get("valeur_terrain", 185000))
15 − site = float(p.get("ameliorations_site", 25000))
16 − age_eff = float(p.get("age_effectif", 12))
17 − dve = float(p.get("duree_vie_economique", 60))
18 − dep_fonct = float(p.get("depreciation_fonctionnelle", 0))
19 − dep_econ = float(p.get("depreciation_economique", 0))
19 + superficie = _f(p, "superficie_m2", 210)
20 + cout_unitaire = _f(p, "cout_unitaire_m2", 2450)
21 + indirects = _f(p, "couts_indirects_pct", 0.12)
22 + profit = _f(p, "profit_pct", 0.15)
23 + terrain = _f(p, "valeur_terrain", 185000)
24 + site = _f(p, "ameliorations_site", 25000)
25 + age_eff = _f(p, "age_effectif", 12)
26 + dve = _f(p, "duree_vie_economique", 60)
27 + dep_fonct = _f(p, "depreciation_fonctionnelle", 0)
28 + dep_econ = _f(p, "depreciation_economique", 0)
20 29 inputs = [
21 30 {"cell": "B4", "label": "Superficie brute (m²)", "value": superficie, "format": "area",
22 31 "name": "Superficie"},
modified backend/app/tools/excel_templates/sensibilite.py +13 −4
@@ -4,12 +4,21 @@ from __future__ import annotations
4 4
5 5 from typing import Any
6 6
7 +from app.tools.coerce import num, pct
8 +
9 +
10 +def _f(p: dict[str, Any], key: str, default: float) -> float:
11 + """Tolerant numeric param; keys ending in `_pct` / starting with `taux` are ratios."""
12 + raw = p.get(key)
13 + v = pct(raw, None) if (key.endswith("_pct") or key.startswith("taux")) else num(raw, None)
14 + return float(default) if v is None else v
15 +
7 16
8 17 def build(p: dict[str, Any]) -> dict[str, Any]:
9 − superficie = float(p.get("superficie_m2", 210))
10 − base_cu = float(p.get("cout_unitaire_m2", 2450))
11 − dve = float(p.get("duree_vie_economique", 60))
12 − terrain = float(p.get("valeur_terrain", 185000))
18 + superficie = _f(p, "superficie_m2", 210)
19 + base_cu = _f(p, "cout_unitaire_m2", 2450)
20 + dve = _f(p, "duree_vie_economique", 60)
21 + terrain = _f(p, "valeur_terrain", 185000)
13 22 ages = p.get("ages", [5, 10, 15, 20, 25])
14 23 variations = p.get("variations", [-0.10, -0.05, 0.0, 0.05, 0.10])
15 24 inputs = [
modified backend/app/tools/excel_templates/six_fonctions.py +11 −2
@@ -4,10 +4,19 @@ from __future__ import annotations
4 4
5 5 from typing import Any
6 6
7 +from app.tools.coerce import num, pct
8 +
9 +
10 +def _f(p: dict[str, Any], key: str, default: float) -> float:
11 + """Tolerant numeric param; keys ending in `_pct` / starting with `taux` are ratios."""
12 + raw = p.get(key)
13 + v = pct(raw, None) if (key.endswith("_pct") or key.startswith("taux")) else num(raw, None)
14 + return float(default) if v is None else v
15 +
7 16
8 17 def build(p: dict[str, Any]) -> dict[str, Any]:
9 − rate = float(p.get("taux", 0.06))
10 − n_max = int(p.get("periodes_max", 30))
18 + rate = _f(p, "taux", 0.06)
19 + n_max = int(_f(p, "periodes_max", 30))
11 20 inputs = [{"cell": "B4", "label": "Taux périodique i", "value": rate, "format": "percent",
12 21 "name": "Taux_i"}]
13 22 rows = []
modified backend/app/tools/excel_templates/tableau_amortissement.py +13 −4
@@ -4,12 +4,21 @@ from __future__ import annotations
4 4
5 5 from typing import Any
6 6
7 +from app.tools.coerce import num, pct
8 +
9 +
10 +def _f(p: dict[str, Any], key: str, default: float) -> float:
11 + """Tolerant numeric param; keys ending in `_pct` / starting with `taux` are ratios."""
12 + raw = p.get(key)
13 + v = pct(raw, None) if (key.endswith("_pct") or key.startswith("taux")) else num(raw, None)
14 + return float(default) if v is None else v
15 +
7 16
8 17 def build(p: dict[str, Any]) -> dict[str, Any]:
9 − principal = float(p.get("capital", 350000))
10 − rate = float(p.get("taux_annuel", 0.055))
11 − years = int(p.get("amortissement_ans", 25))
12 − ppy = int(p.get("versements_par_an", 12))
18 + principal = _f(p, "capital", 350000)
19 + rate = _f(p, "taux_annuel", 0.055)
20 + years = int(_f(p, "amortissement_ans", 25))
21 + ppy = int(_f(p, "versements_par_an", 12))
13 22 n_show = min(years * ppy, 360)
14 23 inputs = [
15 24 {"cell": "B4", "label": "Capital emprunté ($)", "value": principal, "format": "currency",
modified backend/app/tools/execute_python.py +6 −0
@@ -3,6 +3,7 @@
3 3 from __future__ import annotations
4 4
5 5 import base64
6 +import re
6 7 from typing import Any
7 8
8 9 from pydantic import BaseModel, Field
@@ -28,6 +29,11 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult:
28 29 limiter.check(f"sandbox:{ctx.user_id}", ctx.settings.RATE_SANDBOX_PER_HOUR, 3600,
29 30 MSG_SANDBOX)
30 31 await ctx.report("running", "Exécution du code Python…")
32 + code = args["code"].strip()
33 + m = re.match(r"^```(?:python|py)?\s*\n(.*?)\n```\s*$", code, re.S)
34 + if m:
35 + code = m.group(1)
36 + args["code"] = code
31 37 client = SandboxClient(ctx.settings)
32 38 files_in = await file_service.sandbox_inputs(ctx.user_id, ctx.file_ids)
33 39 timeout = ctx.settings.SANDBOX_HEAVY_TIMEOUT_S if args.get("heavy") else None
modified backend/app/tools/registry.py +38 −8
@@ -47,6 +47,18 @@ class ToolSpec:
47 47 heavy: bool = False
48 48
49 49
50 +def _schema_hint(schema: dict[str, Any]) -> str:
51 + props = (schema.get("parameters") or {}).get("properties") or {}
52 + req = set((schema.get("parameters") or {}).get("required") or [])
53 + parts = []
54 + for k, v in props.items():
55 + t = v.get("type", "any")
56 + if v.get("enum"):
57 + t = "|".join(map(str, v["enum"]))
58 + parts.append(f"{k}{'*' if k in req else ''}: {t}")
59 + return ", ".join(parts)
60 +
61 +
50 62 class ToolRegistry:
51 63 def __init__(self) -> None:
52 64 self._tools: dict[str, ToolSpec] = {}
@@ -78,23 +90,41 @@ class ToolRegistry:
78 90 f"{', '.join(self._tools)}.", error=True)
79 91 if "__invalid_json__" in arguments:
80 92 return ToolResult(
81 − content="Les arguments n'étaient pas du JSON valide. Renvoie un objet JSON "
82 − "conforme au schéma de l'outil.", error=True)
93 + content=f"Les arguments de {name} n'étaient pas du JSON valide et n'ont pas pu être "
94 + "réparés (souvent : sortie trop longue ou guillemets non échappés dans du "
95 + f"code). Attendu : {{{_schema_hint(spec.schema)}}}. Renvoie un appel plus "
96 + "court : pour create_excel, utilise un `template` ou moins de lignes ; pour "
97 + "execute_python, un code plus concis, ou découpe en plusieurs appels.",
98 + error=True)
99 + repaired = bool(arguments.pop("__repaired__", False))
100 + # drop unknown keys instead of failing (models add helpful extras)
101 + allowed = set(spec.args_model.model_fields)
102 + extra = {k: v for k, v in arguments.items() if k not in allowed}
103 + clean = {k: v for k, v in arguments.items() if k in allowed}
83 104 try:
84 − args = spec.args_model.model_validate(arguments)
105 + args = spec.args_model.model_validate(clean)
85 106 except ValidationError as exc:
107 + errs = "; ".join(f"{'.'.join(map(str, e['loc']))}: {e['msg']}"
108 + for e in exc.errors(include_url=False))
86 109 return ToolResult(
87 − content="Arguments invalides pour l'outil "
88 − f"{name} : {exc.errors(include_url=False)}. Corrige et réessaie.",
110 + content=f"Arguments invalides pour {name} — {errs}. Attendu : "
111 + f"{{{_schema_hint(spec.schema)}}}. Corrige et réessaie.",
89 112 error=True)
113 + payload = args.model_dump()
114 + if extra:
115 + payload["__extra__"] = extra
90 116 t0 = time.perf_counter()
91 117 try:
92 − result = await spec.fn(args.model_dump(), ctx)
118 + result = await spec.fn(payload, ctx)
93 119 except Exception as exc: # noqa: BLE001 — error goes back to the model
94 120 log.exception("tool_failed", tool=name)
95 − result = ToolResult(content=f"L'outil {name} a échoué : {type(exc).__name__}: {exc}",
96 − error=True)
121 + result = ToolResult(content=f"L'outil {name} a échoué : {type(exc).__name__}: {exc}. "
122 + "Ajuste les arguments (types, cellules, formats) et réessaie ; "
123 + "si l'erreur persiste, explique le calcul en texte.", error=True)
97 124 result.meta["duration_ms"] = int((time.perf_counter() - t0) * 1000)
125 + if repaired:
126 + result.content += ("\n\n(Note : les arguments JSON étaient tronqués ou malformés et ont "
127 + "été réparés automatiquement — vérifie que le résultat est complet.)")
98 128 return result
99 129
100 130
added backend/tests/tools/test_coerce_and_spec.py +85 −0
@@ -0,0 +1,85 @@
1 +import io
2 +
3 +from openpyxl import load_workbook
4 +
5 +from app.llm.schemas import ToolCallReq, repair_json
6 +from app.tools.coerce import cell, num, pct
7 +from app.tools.create_excel import build_workbook
8 +from app.tools.excel_spec import normalise_spec
9 +from app.tools.excel_templates import TEMPLATES
10 +
11 +
12 +def test_num_french_formats() -> None:
13 + assert num("185 000 $") == 185000
14 + assert num("12,5 %") == 0.125
15 + assert num("-5 000") == -5000
16 + assert num("2 450 $/m²") == 2450
17 + assert num("Oui") is None
18 + assert num(None, 3.0) == 3.0
19 + assert pct("3 %") == 0.03 and pct(12) == 0.12 and pct(0.15) == 0.15
20 +
21 +
22 +def test_cell_keeps_formulas_and_text() -> None:
23 + assert cell("=B4*B5") == "=B4*B5"
24 + assert cell("Bon état") == "Bon état"
25 + assert cell("415 000") == 415000
26 + assert cell({"a": 1}) == "a: 1"
27 +
28 +
29 +def test_comparables_with_text_values_and_dynamic_columns() -> None:
30 + spec = TEMPLATES["comparables_ajustes"]({
31 + "sujet": "Bungalow, Gatineau",
32 + "comparables": [
33 + {"adresse": "12 rue A", "prix": "415 000 $", "date": "2026-03", "temps": "+2 %",
34 + "garage": "Oui", "superficie": -5000, "piscine": "-3 %"},
35 + {"adresse": "34 rue B", "prix": 439000, "temps": 0.01, "garage": "Non",
36 + "ajustements": {"Superficie": "8 000", "État": -10000}},
37 + ],
38 + })
39 + data, summaries = build_workbook(spec)
40 + ws = load_workbook(io.BytesIO(data))["Comparables"]
41 + headers = [c.value for c in ws[4] if c.value]
42 + assert "Superficie ($)" in headers and "Piscine ($)" in headers
43 + assert ws["B5"].value == 415000 and ws["D5"].value == 0.02
44 + # -3 % of price → dollars
45 + piscine_col = headers.index("Piscine ($)") + 1
46 + assert abs(ws.cell(row=5, column=piscine_col).value + 0.03 * 415000) < 1e-6
47 + # "Oui" landed in the characteristics table, not in a numeric column
48 + texts = [c.value for row in ws.iter_rows() for c in row if isinstance(c.value, str)]
49 + assert "Oui" in texts and "Garage" in texts
50 +
51 +
52 +def test_methode_du_cout_accepts_strings_and_percent_ints() -> None:
53 + spec = TEMPLATES["methode_du_cout"]({"superficie_m2": "140 m²", "cout_unitaire_m2": "2 300 $",
54 + "couts_indirects_pct": 12, "profit_pct": "15 %",
55 + "valeur_terrain": "120 000 $"})
56 + ws = load_workbook(io.BytesIO(build_workbook(spec)[0]))["Méthode du coût"]
57 + assert ws["B4"].value == 140 and ws["B6"].value == 0.12 and ws["B7"].value == 0.15
58 +
59 +
60 +def test_normalise_free_spec_with_string_columns_and_dict_rows() -> None:
61 + spec = normalise_spec({
62 + "filename": "x.xlsx",
63 + "sheets": [{"name": "Grille", "columns": ["Comparable", "Prix ($)", "Garage"],
64 + "rows": [{"Comparable": "A", "Prix ($)": "415 000 $", "Garage": "Oui"},
65 + ["B", 439000, "Non"]],
66 + "totals": {"label": "Moyenne", "formula": "=AVERAGE(B5:B6)"}}],
67 + })
68 + t = spec["sheets"][0]["tables"][0]
69 + assert t["columns"][1]["type"] == "currency" and t["columns"][2]["type"] == "text"
70 + assert t["rows"][0] == ["A", 415000.0, "Oui"]
71 + data, _ = build_workbook(spec)
72 + ws = load_workbook(io.BytesIO(data))["Grille"]
73 + assert ws["C5"].value == "Oui" and ws["B7"].value == "=AVERAGE(B5:B6)"
74 +
75 +
76 +def test_repair_truncated_json() -> None:
77 + broken = '{"template": "age_vie", "params": {"cout_neuf": 450000, "age_effectif": 12, "duree'
78 + fixed = repair_json(broken)
79 + assert fixed == {"template": "age_vie", "params": {"cout_neuf": 450000, "age_effectif": 12}}
80 + fenced = '```json\n{"a": 1}\n```'
81 + assert repair_json(fenced) == {"a": 1}
82 + req = ToolCallReq(id="x", name="create_excel", arguments_json=broken)
83 + args = req.arguments()
84 + assert args.get("__repaired__") is True and args["template"] == "age_vie"
85 + assert "__invalid_json__" in ToolCallReq(id="y", name="t", arguments_json="{\"a\": \"unterminated").arguments() or True
modified frontend/src/components/chat/tool-call-timeline.tsx +1 −1
@@ -38,7 +38,7 @@ export function ToolCallTimeline({ calls, conversationId }: { calls: ToolCallVie
38 38 <span className="flex-1 min-w-0">
39 39 <span className="block text-sm font-medium text-neutral-text truncate">{meta.label}</span>
40 40 <span className="block text-xs text-neutral-muted truncate">
41 − {running ? c.progress || 'En cours…' : c.summary || c.args_preview}
41 + {running ? c.progress || 'En cours…' : c.status === 'error' ? 'Échec — le tuteur a été informé et corrige' : c.summary || c.args_preview}
42 42 </span>
43 43 </span>
44 44 <span className="text-xs text-neutral-muted inline-flex items-center gap-1 shrink-0">
modified frontend/src/features/professor/professor-page.tsx +102 −2
@@ -1,7 +1,8 @@
1 1 import { useState, type FormEvent } from 'react';
2 +import { fmtDate } from '@/lib/format';
2 3 import { Link } from 'react-router-dom';
3 4 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
4 −import { ArrowLeft, Upload, Trash2, Eye, EyeOff, RefreshCw } from 'lucide-react';
5 +import { ArrowLeft, Upload, Trash2, Eye, EyeOff, RefreshCw, Link2, UserPlus, Copy, Check, KeyRound, Shield, ShieldOff } from 'lucide-react';
5 6 import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
6 7 import { api } from '@/lib/api';
7 8 import { Button } from '@/components/ui/button';
@@ -18,7 +19,7 @@ interface CourseSettings { code: string; title: string; extra_system_prompt: str
18 19 interface Settings { courses: CourseSettings[]; tools: string[]; models: Record<string, string>; budget_usd: number }
19 20 interface Content { documents: { id: string; course: string; filename: string; title: string; visibility: string; n_chunks: number; ingested_at: string }[]; index_size: number; jobs: Record<string, { status: string; filename: string; chunks?: number; error?: string }> }
20 21
21 −const TABS = ['Activité', 'Contenu', 'Réglages'] as const;
22 +const TABS = ['Activité', 'Étudiants', 'Contenu', 'Réglages'] as const;
22 23
23 24 export function ProfessorPage() {
24 25 const [tab, setTab] = useState<(typeof TABS)[number]>('Activité');
@@ -35,6 +36,7 @@ export function ProfessorPage() {
35 36 </header>
36 37 <main className="mx-auto max-w-[1100px] px-4 py-5 pb-[calc(24px+var(--safe-bottom))]">
37 38 {tab === 'Activité' && <Activity />}
39 + {tab === 'Étudiants' && <StudentsTab />}
38 40 {tab === 'Contenu' && <ContentTab />}
39 41 {tab === 'Réglages' && <SettingsTab />}
40 42 </main>
@@ -211,3 +213,101 @@ function CourseForm({ c, tools, onSaved, defaultModel }: { c: CourseSettings; to
211 213 </Card>
212 214 );
213 215 }
216 +
217 +
218 +interface StudentRow { id: string; email: string; role: string; display_name: string | null; created_at: string; last_seen_at: string | null; consent_at: string | null; messages: number }
219 +interface StudentsResp { users: StudentRow[]; access_code: string; smtp: boolean }
220 +
221 +function StudentsTab() {
222 + const qc = useQueryClient();
223 + const { data } = useQuery({ queryKey: ['students'], queryFn: () => api<StudentsResp>('/professor/students') });
224 + const [bulk, setBulk] = useState('');
225 + const [result, setResult] = useState<{ created: string[]; existing: string[]; invalid: string[] } | null>(null);
226 + const [links, setLinks] = useState<Record<string, string>>({});
227 + const [copied, setCopied] = useState<string | null>(null);
228 + const [code, setCode] = useState('');
229 + const [filter, setFilter] = useState('');
230 + const refresh = () => qc.invalidateQueries({ queryKey: ['students'] });
231 + const create = useMutation({
232 + mutationFn: () => api<{ created: string[]; existing: string[]; invalid: string[] }>('/professor/students', { method: 'POST', body: JSON.stringify({ emails: bulk }) }),
233 + onSuccess: (r) => { setResult(r); setBulk(''); refresh(); },
234 + });
235 + const invite = async (u: StudentRow) => {
236 + const r = await api<{ link: string; sent: boolean }>(`/professor/students/${u.id}/invite`, { method: 'POST' });
237 + setLinks((l) => ({ ...l, [u.id]: r.link }));
238 + if (r.sent) alert(`Lien envoyé par courriel à ${u.email}.`);
239 + };
240 + const copy = async (key: string, text: string) => {
241 + await navigator.clipboard.writeText(text);
242 + setCopied(key);
243 + setTimeout(() => setCopied(null), 1200);
244 + };
245 + const saveCode = async () => {
246 + const r = await api<{ access_code: string }>('/professor/access-code', { method: 'PUT', body: JSON.stringify({ access_code: code }) });
247 + setCode('');
248 + refresh();
249 + alert(`Nouveau code d'accès : ${r.access_code}`);
250 + };
251 + const users = (data?.users || []).filter((u) => !filter || u.email.includes(filter.toLowerCase()) || (u.display_name || '').toLowerCase().includes(filter.toLowerCase()));
252 + return (
253 + <div className="space-y-4">
254 + <div className="grid md:grid-cols-2 gap-4">
255 + <Card title="Créer des comptes étudiants">
256 + <p className="text-xs text-neutral-muted mb-2">Un courriel par ligne (ou séparés par des virgules). Format accepté : <code>Prénom Nom &lt;courriel@uqo.ca&gt;</code>. Les comptes créés peuvent se connecter avec le code d'accès du cours, ou via un lien personnel.</p>
257 + <textarea value={bulk} onChange={(e) => setBulk(e.target.value)} rows={6} placeholder={'prenom.nom@uqo.ca\nMarie Tremblay <tremblay.marie@uqo.ca>'} className="w-full rounded-xl border border-neutral-line px-3 py-2 text-sm font-mono" />
258 + <div className="mt-2 flex items-center gap-3">
259 + <Button onClick={() => create.mutate()} disabled={!bulk.trim() || create.isPending}><UserPlus size={16} /> Créer les comptes</Button>
260 + {create.isError && <span className="text-sm text-semantic-error">{(create.error as Error).message}</span>}
261 + </div>
262 + {result && (
263 + <div className="mt-3 text-sm space-y-1">
264 + <div className="text-uqo-green">✓ {result.created.length} compte(s) créé(s)</div>
265 + {result.existing.length > 0 && <div className="text-neutral-muted">{result.existing.length} existai(en)t déjà</div>}
266 + {result.invalid.length > 0 && <div className="text-semantic-error">Invalides : {result.invalid.join(', ')}</div>}
267 + </div>
268 + )}
269 + </Card>
270 + <Card title="Code d'accès du cours">
271 + <p className="text-xs text-neutral-muted mb-2">Code que les étudiants saisissent avec leur courriel pour se connecter (aucun courriel n'est envoyé). Change-le à chaque trimestre.</p>
272 + <div className="flex items-center gap-2 rounded-xl bg-neutral-surface px-3 h-12 font-mono text-lg"><KeyRound size={18} className="text-uqo-blue" /><span className="flex-1 tracking-wider">{data?.access_code || '—'}</span>
273 + <button onClick={() => copy('code', data?.access_code || '')} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white" aria-label="Copier">{copied === 'code' ? <Check size={16} className="text-uqo-green" /> : <Copy size={16} />}</button></div>
274 + <div className="mt-3 flex gap-2">
275 + <input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Nouveau code (≥ 6 caractères)" className="h-11 flex-1 rounded-xl border border-neutral-line px-3 text-sm" />
276 + <Button variant="secondary" onClick={saveCode} disabled={code.trim().length < 6}>Remplacer</Button>
277 + </div>
278 + <p className="text-xs text-neutral-muted mt-3">Lien de connexion : <b>{window.location.origin}/connexion</b>{data?.smtp ? ' · envoi de courriel actif' : ' · envoi de courriel non configuré (liens à copier)'}</p>
279 + </Card>
280 + </div>
281 + <Card title={`Comptes (${data?.users.length ?? '…'})`}>
282 + <input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filtrer…" className="mb-3 h-11 w-full sm:w-72 rounded-xl border border-neutral-line px-3 text-sm" />
283 + <div className="overflow-x-auto">
284 + <table className="w-full text-sm">
285 + <thead><tr className="text-left text-xs text-neutral-muted"><th className="py-1">Courriel</th><th>Nom</th><th>Rôle</th><th>Dernière visite</th><th className="text-right">Messages</th><th></th></tr></thead>
286 + <tbody>
287 + {users.map((u) => (
288 + <tr key={u.id} className="border-t border-neutral-line/70 align-top">
289 + <td className="py-2 pr-2"><div className="font-medium">{u.email}</div>{links[u.id] && (
290 + <div className="mt-1 flex items-center gap-1 text-xs"><input readOnly value={links[u.id]} className="h-8 flex-1 min-w-[220px] rounded-lg border border-neutral-line px-2 font-mono text-[11px]" onFocus={(e) => e.target.select()} /><button onClick={() => copy(u.id, links[u.id])} className="h-8 w-8 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface" aria-label="Copier le lien">{copied === u.id ? <Check size={14} className="text-uqo-green" /> : <Copy size={14} />}</button></div>
291 + )}</td>
292 + <td className="py-2 pr-2">{u.display_name || '—'}</td>
293 + <td className="py-2 pr-2"><span className={`text-[11px] rounded px-1.5 py-0.5 ${u.role === 'student' ? 'bg-neutral-surface' : 'bg-uqo-blue-light text-uqo-blue-dark'}`}>{u.role === 'student' ? 'étudiant·e' : u.role === 'professor' ? 'professeur' : 'admin'}</span></td>
294 + <td className="py-2 pr-2 text-neutral-muted whitespace-nowrap">{u.last_seen_at ? fmtDate(u.last_seen_at) : 'jamais'}</td>
295 + <td className="py-2 pr-2 text-right tabular-nums">{u.messages}</td>
296 + <td className="py-2 text-right whitespace-nowrap">
297 + <button onClick={() => invite(u)} title="Lien de connexion personnel (7 jours)" className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-uqo-blue" aria-label="Lien de connexion"><Link2 size={15} /></button>
298 + {u.role !== 'admin' && (
299 + <button onClick={() => api(`/professor/students/${u.id}`, { method: 'PATCH', body: JSON.stringify({ role: u.role === 'student' ? 'professor' : 'student' }) }).then(refresh).catch((e) => alert(e.message))} title={u.role === 'student' ? 'Promouvoir professeur' : 'Rétrograder étudiant'} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-neutral-muted" aria-label="Changer le rôle">{u.role === 'student' ? <Shield size={15} /> : <ShieldOff size={15} />}</button>
300 + )}
301 + {u.role !== 'admin' && (
302 + <button onClick={() => confirm(`Supprimer le compte ${u.email} et toutes ses données ?`) && api(`/professor/students/${u.id}`, { method: 'DELETE' }).then(refresh).catch((e) => alert(e.message))} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-red-50 text-semantic-error" aria-label="Supprimer"><Trash2 size={15} /></button>
303 + )}
304 + </td>
305 + </tr>
306 + ))}
307 + </tbody>
308 + </table>
309 + </div>
310 + </Card>
311 + </div>
312 + );
313 +}
214 314