SPB Git

Repository Public

Commit

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 · Sep 6, 2026 parent afb5ca4
23 changed files +979 −103 Browse files
modified backend/app/api/v1/auth.py +7 −5
@@ -65,13 +65,14 @@ async def magic_link(req: MagicLinkReq, request: Request, response: Response,
65 ip = request.client.host if request.client else "?" 65 ip = request.client.host if request.client else "?"
66 limiter.check(f"login:{ip}", 20, 3600, "Trop de tentatives. Réessaie plus tard.") 66 limiter.check(f"login:{ip}", 20, 3600, "Trop de tentatives. Réessaie plus tard.")
67 email = req.email.lower() 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 raise HTTPException(status.HTTP_403_FORBIDDEN, 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 # Access-code path: no SMTP needed (course code handed out by the professor). 72 # Access-code path: no SMTP needed (course code handed out by the professor).
72 if req.access_code is not None: 73 if req.access_code is not None:
73− if not settings.ACCESS_CODE or not hmac.compare_digest(req.access_code.strip(), 74+ code = await users.effective_access_code(settings)
74− settings.ACCESS_CODE): 75+ if not code or not hmac.compare_digest(req.access_code.strip(), code):
75 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Code d'accès invalide.") 76 raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Code d'accès invalide.")
76 user = await users.get_or_create_user(email, settings) 77 user = await users.get_or_create_user(email, settings)
77 out = _issue(user.id, user.role, settings, response) 78 out = _issue(user.id, user.role, settings, response)
@@ -122,7 +123,8 @@ async def logout(response: Response) -> dict:
122 123
123 @router.get("/auth/config") 124 @router.get("/auth/config")
124 async def auth_config(settings: Settings = Depends(get_settings)) -> dict: 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 "domains": sorted(settings.allowed_domains), "courses": settings.courses, 128 "domains": sorted(settings.allowed_domains), "courses": settings.courses,
127 "term": settings.TERM_LABEL} 129 "term": settings.TERM_LABEL}
128 130
modified backend/app/api/v1/professor.py +94 −0
@@ -3,6 +3,7 @@
3 from __future__ import annotations 3 from __future__ import annotations
4 4
5 import asyncio 5 import asyncio
6+import re
6 import tempfile 7 import tempfile
7 from pathlib import Path 8 from pathlib import Path
8 from typing import Any 9 from typing import Any
@@ -160,3 +161,96 @@ async def promote(req: PromoteReq, _: AuthUser = Depends(require_professor)) ->
160 raise HTTPException(status.HTTP_404_NOT_FOUND, 161 raise HTTPException(status.HTTP_404_NOT_FOUND,
161 detail="Compte introuvable (la personne doit s'être connectée une fois).") 162 detail="Compte introuvable (la personne doit s'être connectée une fois).")
162 return {"ok": True} 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 EMBEDDINGS_BASE_URL: str = "" 42 EMBEDDINGS_BASE_URL: str = ""
43 EMBEDDINGS_API_KEY: SecretStr = SecretStr("") 43 EMBEDDINGS_API_KEY: SecretStr = SecretStr("")
44 MODEL_EMBEDDINGS: str = "" 44 MODEL_EMBEDDINGS: str = ""
45− LLM_MAX_TOOL_ITERATIONS: int = 8 45+ LLM_MAX_TOOL_ITERATIONS: int = 12
46 LLM_TIMEOUT_SECONDS: int = 120 46 LLM_TIMEOUT_SECONDS: int = 120
47− LLM_MAX_OUTPUT_TOKENS: int = 4096 47+ LLM_MAX_OUTPUT_TOKENS: int = 16000
48 LLM_MONTHLY_BUDGET_USD: float = 300.0 48 LLM_MONTHLY_BUDGET_USD: float = 300.0
49 LLM_TURN_TIMEOUT_SECONDS: int = 300 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 async def _run_tool(self, call: ToolCallReq, file_ids: list[str]) -> tuple[ToolCallReq, ToolResult]: 121 async def _run_tool(self, call: ToolCallReq, file_ids: list[str]) -> tuple[ToolCallReq, ToolResult]:
122 args = call.arguments() 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 await self.emit("tool_call", {"id": call.id, "name": call.name, "args_preview": preview, 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 ctx = await self._tool_ctx(call.id, file_ids) 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 artifacts = [a.to_dict() for a in result.artifacts] 139 artifacts = [a.to_dict() for a in result.artifacts]
129 await self.emit("tool_result", {"id": call.id, "name": call.name, 140 await self.emit("tool_result", {"id": call.id, "name": call.name,
130 "summary": result.summary(), "artifacts": artifacts, 141 "summary": result.summary(), "artifacts": artifacts,
@@ -209,7 +220,16 @@ class Turn:
209 self.model_used = ev.data.get("model") or self.model_used 220 self.model_used = ev.data.get("model") or self.model_used
210 if finish == "cancelled": 221 if finish == "cancelled":
211 break 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 if not pending: 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 break 233 break
214 # append assistant tool-call message, run tools in parallel, append results 234 # append assistant tool-call message, run tools in parallel, append results
215 calls = sorted(pending.values(), key=lambda c: c.index) 235 calls = sorted(pending.values(), key=lambda c: c.index)
@@ -240,12 +260,18 @@ class Turn:
240 content = "".join(self.text_parts).strip() 260 content = "".join(self.text_parts).strip()
241 latency = int((time.perf_counter() - t0) * 1000) 261 latency = int((time.perf_counter() - t0) * 1000)
242 if content or self.tool_records: 262 if content or self.tool_records:
243− await conv_service.save_assistant_message( 263+ try:
244− self.conv.id, self.message_id, content, self.model_used or plan.models[0], 264+ await asyncio.shield(conv_service.save_assistant_message(
245− self.usage_total, latency, self.tool_records) 265+ self.conv.id, self.message_id, content, self.model_used or plan.models[0],
246− await self.emit("usage", {**self.usage_total, "model_used": self.model_used, 266+ self.usage_total, latency, self.tool_records))
247− "latency_ms": latency}) 267+ except Exception as exc: # noqa: BLE001
248− await self.emit("done", {"message_id": self.message_id, "finish_reason": finish}) 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 # ------------------------------------------------------------------ side tasks 277 # ------------------------------------------------------------------ side tasks
modified backend/app/llm/openrouter.py +8 −1
@@ -172,7 +172,14 @@ class LLMClient:
172 if delta.get("reasoning"): 172 if delta.get("reasoning"):
173 yield StreamEvent("reasoning_delta", {"delta": delta["reasoning"]}) 173 yield StreamEvent("reasoning_delta", {"delta": delta["reasoning"]})
174 for tc in delta.get("tool_calls") or []: 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 buf = tool_buf.get(idx) 183 buf = tool_buf.get(idx)
177 if buf is None: 184 if buf is None:
178 buf = {"id": tc.get("id") or f"call_{idx}", "name": "", "args": ""} 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 - Quand plusieurs approches existent, dis laquelle le cours privilégie et pourquoi. 27 - Quand plusieurs approches existent, dis laquelle le cours privilégie et pourquoi.
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. 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 ## Outils et rendu 36 ## Outils et rendu
31 - Après un appel d'outil, résume le résultat en langage clair ; ne recopie pas les sorties brutes. 37 - Après un appel d'outil, résume le résultat en langage clair ; ne recopie pas les sorties brutes.
32 - Les fichiers produits (Excel, images) sont affichés automatiquement à l'étudiant : mentionne-les simplement (« Le classeur ci-dessus contient… »). 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 from __future__ import annotations 3 from __future__ import annotations
4 4
5+import json
6+import re
5 from dataclasses import dataclass, field 7 from dataclasses import dataclass, field
6 from typing import Any, Literal 8 from typing import Any, Literal
7 9
@@ -17,22 +19,86 @@ class StreamEvent:
17 data: dict[str, Any] = field(default_factory=dict) 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 @dataclass 83 @dataclass
21 class ToolCallReq: 84 class ToolCallReq:
22 id: str 85 id: str
23 name: str 86 name: str
24 arguments_json: str = "" 87 arguments_json: str = ""
25 index: int = 0 88 index: int = 0
89+ truncated: bool = False # the model hit its output limit while emitting this call
26 90
27 def arguments(self) -> dict[str, Any]: 91 def arguments(self) -> dict[str, Any]:
28− import json
29−
30 if not self.arguments_json.strip(): 92 if not self.arguments_json.strip():
31 return {} 93 return {}
32 try: 94 try:
33 v = json.loads(self.arguments_json) 95 v = json.loads(self.arguments_json)
34 return v if isinstance(v, dict) else {} 96 return v if isinstance(v, dict) else {}
35 except json.JSONDecodeError: 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 return {"__invalid_json__": self.arguments_json[:2000]} 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 cost_usd: Mapped[float] = mapped_column(Float, default=0.0) 216 cost_usd: Mapped[float] = mapped_column(Float, default=0.0)
217 latency_ms: Mapped[int] = mapped_column(Integer, default=0) 217 latency_ms: Mapped[int] = mapped_column(Integer, default=0)
218 created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow, index=True) 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 from datetime import timedelta 5 from datetime import timedelta
6 6
7−from sqlalchemy import delete, select 7+from sqlalchemy import delete, func, select
8 8
9 from app.core.config import Settings 9 from app.core.config import Settings
10 from app.core.security import new_magic_token, role_for_email 10 from app.core.security import new_magic_token, role_for_email
11 from app.db import SessionLocal 11 from app.db import SessionLocal
12 from app.models import ( 12 from app.models import (
13+ AppSetting,
13 Conversation, 14 Conversation,
14 MagicLink, 15 MagicLink,
15 Message, 16 Message,
@@ -126,3 +127,67 @@ async def delete_user_data(user_id: str) -> None:
126 await session.execute(delete(Quiz).where(Quiz.id.in_(quiz_ids))) 127 await session.execute(delete(Quiz).where(Quiz.id.in_(quiz_ids)))
127 await session.execute(delete(User).where(User.id == user_id)) 128 await session.execute(delete(User).where(User.id == user_id))
128 await session.commit() 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 from app.llm.schemas import Artifact, ToolResult 18 from app.llm.schemas import Artifact, ToolResult
19 from app.services import files as file_service 19 from app.services import files as file_service
20+from app.tools.excel_spec import normalise_spec
20 from app.tools.excel_templates import TEMPLATES 21 from app.tools.excel_templates import TEMPLATES
21 from app.tools.registry import ToolContext, registry 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 def _render_sheet(wb: Workbook, ws: Any, sheet: dict[str, Any]) -> dict[str, Any]: 73 def _render_sheet(wb: Workbook, ws: Any, sheet: dict[str, Any]) -> dict[str, Any]:
73 ws.sheet_view.showGridLines = False 74 ws.sheet_view.showGridLines = False
74− ws.column_dimensions["A"].width = 44 75+ ws.column_dimensions["A"].width = 40
75− for c in "BCDEFGHIJK": 76+ for i in range(2, 30):
76− ws.column_dimensions[c].width = 18 77+ ws.column_dimensions[get_column_letter(i)].width = 17
77 title = sheet.get("title") or sheet.get("name", "Feuille") 78 title = sheet.get("title") or sheet.get("name", "Feuille")
78 ws["A1"] = title 79 ws["A1"] = title
79 ws["A1"].font = TITLE_FONT 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 for i, row in enumerate(table.get("rows") or []): 122 for i, row in enumerate(table.get("rows") or []):
122 r = row0 + 1 + i 123 r = row0 + 1 + i
123 for j, value in enumerate(row): 124 for j, value in enumerate(row):
125+ if isinstance(value, (dict, list, tuple)):
126+ value = str(value)[:250]
124 c = ws.cell(row=r, column=col0 + j, value=value) 127 c = ws.cell(row=r, column=col0 + j, value=value)
125 kind = columns[j].get("type", "text") if j < len(columns) else "text" 128 kind = columns[j].get("type", "text") if j < len(columns) else "text"
126 if j == 0 and table.get("first_col_format"): 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 return ToolResult(content=f"Gabarit inconnu. Gabarits : {', '.join(TEMPLATES)}.", 273 return ToolResult(content=f"Gabarit inconnu. Gabarits : {', '.join(TEMPLATES)}.",
271 error=True) 274 error=True)
272 spec = tpl(args.get("params") or {}) 275 spec = tpl(args.get("params") or {})
276+ if spec and not args.get("template"):
277+ spec = normalise_spec(spec)
273 if not spec or not spec.get("sheets"): 278 if not spec or not spec.get("sheets"):
274− return ToolResult(content="Fournis `spec.sheets` (liste de feuilles) ou `template`.", 279+ return ToolResult(content="Fournis `spec.sheets` (liste de feuilles avec tables/rows) ou "
275− error=True) 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 filename = args.get("filename") or spec.get("filename") or "classeur.xlsx" 285 filename = args.get("filename") or spec.get("filename") or "classeur.xlsx"
277 if not filename.lower().endswith(".xlsx"): 286 if not filename.lower().endswith(".xlsx"):
278 filename += ".xlsx" 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 from typing import Any 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 def build(p: dict[str, Any]) -> dict[str, Any]: 17 def build(p: dict[str, Any]) -> dict[str, Any]:
9− cn = float(p.get("cout_neuf", 450000)) 18+ cn = _f(p, "cout_neuf", 450000)
10− age_eff = float(p.get("age_effectif", 12)) 19+ age_eff = _f(p, "age_effectif", 12)
11− dve = float(p.get("duree_vie_economique", 60)) 20+ dve = _f(p, "duree_vie_economique", 60)
12 inputs = [ 21 inputs = [
13 {"cell": "B4", "label": "Coût neuf C_N ($)", "value": cn, "format": "currency", 22 {"cell": "B4", "label": "Coût neuf C_N ($)", "value": cn, "format": "currency",
14 "name": "Cout_Neuf"}, 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 from __future__ import annotations 13 from __future__ import annotations
4 14
5 from typing import Any 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 DEFAULT_COMPS = [ 25 DEFAULT_COMPS = [
8− {"adresse": "Comparable 1", "prix": 415000, "date_pct": 0.02, "superficie": -5000, 26+ {"adresse": "Comparable 1", "prix": 415000, "temps": "2 %",
9− "terrain": 0, "garage": -12000, "etat": 0}, 27+ "ajustements": {"Superficie": -5000, "Garage": -12000},
10− {"adresse": "Comparable 2", "prix": 439000, "date_pct": 0.01, "superficie": 8000, 28+ "caracteristiques": {"Garage": "Oui", "État": "Bon"}},
11− "terrain": -6000, "garage": 0, "etat": -10000}, 29+ {"adresse": "Comparable 2", "prix": 439000, "temps": "1 %",
12− {"adresse": "Comparable 3", "prix": 402000, "date_pct": 0.03, "superficie": 0, 30+ "ajustements": {"Superficie": 8000, "Terrain": -6000, "État": -10000},
13− "terrain": 4000, "garage": 0, "etat": 15000}, 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 def build(p: dict[str, Any]) -> dict[str, Any]: 84 def build(p: dict[str, Any]) -> dict[str, Any]:
18− comps = p.get("comparables") or DEFAULT_COMPS 85+ comps_raw = p.get("comparables") or DEFAULT_COMPS
19− sujet = p.get("sujet", "Sujet — immeuble fictif, Gatineau") 86+ comps, adj_names, char_names = _normalise(list(comps_raw))
20− rows = [] 87+ for extra in p.get("ajustements") or []:
21− for i, c in enumerate(comps): 88+ name = str(extra).strip().capitalize()
22− r = 5 + i 89+ if name and name not in adj_names:
23− rows.append([ 90+ adj_names.append(name)
24− c.get("adresse", f"Comparable {i + 1}"), float(c.get("prix", 0)), 91+ sujet = str(p.get("sujet", "Sujet — immeuble fictif, Gatineau"))
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− ])
31 n = len(comps) 92 n = len(comps)
32− last = 4 + n 93+ first_row = 5
33− table = { 94+ last = first_row + n - 1
34− "anchor": "A4", 95+ # columns: A nom, B prix, C date, D temps %, E prix ajusté temps, F.. adjustments, then totals
35− "columns": [ 96+ adj_start = 6 # F
36− {"header": "Comparable", "type": "text"}, {"header": "Prix de vente ($)", "type": "currency"}, 97+ adj_end = adj_start + len(adj_names) - 1 if adj_names else adj_start - 1
37− {"header": "Ajust. temps (%)", "type": "percent"}, 98+ col_total = adj_end + 1
38− {"header": "Prix ajusté temps ($)", "type": "currency"}, 99+ col_final = adj_end + 2
39− {"header": "Superficie ($)", "type": "currency"}, {"header": "Terrain ($)", "type": "currency"}, 100+ col_gross = adj_end + 3
40− {"header": "Garage ($)", "type": "currency"}, {"header": "État ($)", "type": "currency"}, 101+ col_gross_pct = adj_end + 4
41− {"header": "Prix ajusté ($)", "type": "currency"}, 102+ L = get_column_letter
42− {"header": "Ajust. bruts ($)", "type": "currency"}, 103+ rows: list[list[Any]] = []
43− {"header": "Ajust. bruts (%)", "type": "percent"}, 104+ for i, c in enumerate(comps):
44− ], 105+ r = first_row + i
45− "rows": rows, 106+ row: list[Any] = [c["nom"], c["prix"], c["date"] or "—", c["temps"], f"=B{r}*(1+D{r})"]
46− } 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 stats = { 127 stats = {
48− "anchor": f"A{last + 3}", 128+ "anchor": f"A{stats_anchor}",
49 "columns": [{"header": "Statistique", "type": "text"}, {"header": "Valeur", "type": "currency"}], 129 "columns": [{"header": "Statistique", "type": "text"}, {"header": "Valeur", "type": "currency"}],
50 "rows": [ 130 "rows": [
51− ["Minimum des prix ajustés", f"=MIN(I5:I{last})"], 131+ ["Minimum des prix ajustés", f"=MIN({F}{first_row}:{F}{last})"],
52− ["Maximum des prix ajustés", f"=MAX(I5:I{last})"], 132+ ["Maximum des prix ajustés", f"=MAX({F}{first_row}:{F}{last})"],
53− ["Moyenne simple", f"=AVERAGE(I5:I{last})"], 133+ ["Moyenne simple", f"=AVERAGE({F}{first_row}:{F}{last})"],
54− ["Médiane", f"=MEDIAN(I5:I{last})"], 134+ ["Médiane", f"=MEDIAN({F}{first_row}:{F}{last})"],
55− ["Comparable le moins ajusté (indice)", f"=MATCH(MIN(K5:K{last}),K5:K{last},0)"], 135+ ["Comparable le moins ajusté (rang)", f"=MATCH(MIN({G}{first_row}:{G}{last}),{G}{first_row}:{G}{last},0)"],
56− ["Prix ajusté du comparable le moins ajusté", f"=INDEX(I5:I{last},B{last + 8})"], 136+ ["Prix ajusté du comparable le moins ajusté", f"=INDEX({F}{first_row}:{F}{last},B{stats_anchor + 5})"],
57 ], 137 ],
58 "row_formats": {4: "integer"}, 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 return { 148 return {
61 "filename": p.get("filename", "comparables_ajustes.xlsx"), 149 "filename": p.get("filename", "comparables_ajustes.xlsx"),
62 "style": "uqo", 150 "style": "uqo",
151+ "objective": f"Grille de comparables ajustés — {sujet}",
63 "sheets": [{ 152 "sheets": [{
64 "name": "Comparables", 153 "name": "Comparables",
65 "title": f"Grille de comparables ajustés — {sujet}", 154 "title": f"Grille de comparables ajustés — {sujet}",
66 "inputs": [], 155 "inputs": [],
67− "tables": [table, stats], 156+ "tables": tables,
68 "notes": [ 157 "notes": [
69 "On ajuste le comparable vers le sujet : le comparable est meilleur → ajustement négatif.", 158 "On ajuste le comparable vers le sujet : le comparable est meilleur → ajustement négatif.",
70 "Ordre : conditions de vente, financement, marché (temps), puis caractéristiques physiques.", 159 "Ordre : conditions de vente, financement, marché (temps), puis caractéristiques physiques.",
71 "La réconciliation pondère les indications (poids plus fort au comparable le moins ajusté) ; ce n'est pas une moyenne.", 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 from typing import Any 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 def build(p: dict[str, Any]) -> dict[str, Any]: 17 def build(p: dict[str, Any]) -> dict[str, Any]:
9 adresse = p.get("adresse", "Immeuble fictif — Gatineau") 18 adresse = p.get("adresse", "Immeuble fictif — Gatineau")
10− superficie = float(p.get("superficie_m2", 210)) 19+ superficie = _f(p, "superficie_m2", 210)
11− cout_unitaire = float(p.get("cout_unitaire_m2", 2450)) 20+ cout_unitaire = _f(p, "cout_unitaire_m2", 2450)
12− indirects = float(p.get("couts_indirects_pct", 0.12)) 21+ indirects = _f(p, "couts_indirects_pct", 0.12)
13− profit = float(p.get("profit_pct", 0.15)) 22+ profit = _f(p, "profit_pct", 0.15)
14− terrain = float(p.get("valeur_terrain", 185000)) 23+ terrain = _f(p, "valeur_terrain", 185000)
15− site = float(p.get("ameliorations_site", 25000)) 24+ site = _f(p, "ameliorations_site", 25000)
16− age_eff = float(p.get("age_effectif", 12)) 25+ age_eff = _f(p, "age_effectif", 12)
17− dve = float(p.get("duree_vie_economique", 60)) 26+ dve = _f(p, "duree_vie_economique", 60)
18− dep_fonct = float(p.get("depreciation_fonctionnelle", 0)) 27+ dep_fonct = _f(p, "depreciation_fonctionnelle", 0)
19− dep_econ = float(p.get("depreciation_economique", 0)) 28+ dep_econ = _f(p, "depreciation_economique", 0)
20 inputs = [ 29 inputs = [
21 {"cell": "B4", "label": "Superficie brute (m²)", "value": superficie, "format": "area", 30 {"cell": "B4", "label": "Superficie brute (m²)", "value": superficie, "format": "area",
22 "name": "Superficie"}, 31 "name": "Superficie"},
modified backend/app/tools/excel_templates/sensibilite.py +13 −4
@@ -4,12 +4,21 @@ from __future__ import annotations
4 4
5 from typing import Any 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 def build(p: dict[str, Any]) -> dict[str, Any]: 17 def build(p: dict[str, Any]) -> dict[str, Any]:
9− superficie = float(p.get("superficie_m2", 210)) 18+ superficie = _f(p, "superficie_m2", 210)
10− base_cu = float(p.get("cout_unitaire_m2", 2450)) 19+ base_cu = _f(p, "cout_unitaire_m2", 2450)
11− dve = float(p.get("duree_vie_economique", 60)) 20+ dve = _f(p, "duree_vie_economique", 60)
12− terrain = float(p.get("valeur_terrain", 185000)) 21+ terrain = _f(p, "valeur_terrain", 185000)
13 ages = p.get("ages", [5, 10, 15, 20, 25]) 22 ages = p.get("ages", [5, 10, 15, 20, 25])
14 variations = p.get("variations", [-0.10, -0.05, 0.0, 0.05, 0.10]) 23 variations = p.get("variations", [-0.10, -0.05, 0.0, 0.05, 0.10])
15 inputs = [ 24 inputs = [
modified backend/app/tools/excel_templates/six_fonctions.py +11 −2
@@ -4,10 +4,19 @@ from __future__ import annotations
4 4
5 from typing import Any 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 def build(p: dict[str, Any]) -> dict[str, Any]: 17 def build(p: dict[str, Any]) -> dict[str, Any]:
9− rate = float(p.get("taux", 0.06)) 18+ rate = _f(p, "taux", 0.06)
10− n_max = int(p.get("periodes_max", 30)) 19+ n_max = int(_f(p, "periodes_max", 30))
11 inputs = [{"cell": "B4", "label": "Taux périodique i", "value": rate, "format": "percent", 20 inputs = [{"cell": "B4", "label": "Taux périodique i", "value": rate, "format": "percent",
12 "name": "Taux_i"}] 21 "name": "Taux_i"}]
13 rows = [] 22 rows = []
modified backend/app/tools/excel_templates/tableau_amortissement.py +13 −4
@@ -4,12 +4,21 @@ from __future__ import annotations
4 4
5 from typing import Any 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 def build(p: dict[str, Any]) -> dict[str, Any]: 17 def build(p: dict[str, Any]) -> dict[str, Any]:
9− principal = float(p.get("capital", 350000)) 18+ principal = _f(p, "capital", 350000)
10− rate = float(p.get("taux_annuel", 0.055)) 19+ rate = _f(p, "taux_annuel", 0.055)
11− years = int(p.get("amortissement_ans", 25)) 20+ years = int(_f(p, "amortissement_ans", 25))
12− ppy = int(p.get("versements_par_an", 12)) 21+ ppy = int(_f(p, "versements_par_an", 12))
13 n_show = min(years * ppy, 360) 22 n_show = min(years * ppy, 360)
14 inputs = [ 23 inputs = [
15 {"cell": "B4", "label": "Capital emprunté ($)", "value": principal, "format": "currency", 24 {"cell": "B4", "label": "Capital emprunté ($)", "value": principal, "format": "currency",
modified backend/app/tools/execute_python.py +6 −0
@@ -3,6 +3,7 @@
3 from __future__ import annotations 3 from __future__ import annotations
4 4
5 import base64 5 import base64
6+import re
6 from typing import Any 7 from typing import Any
7 8
8 from pydantic import BaseModel, Field 9 from pydantic import BaseModel, Field
@@ -28,6 +29,11 @@ async def run(args: dict[str, Any], ctx: ToolContext) -> ToolResult:
28 limiter.check(f"sandbox:{ctx.user_id}", ctx.settings.RATE_SANDBOX_PER_HOUR, 3600, 29 limiter.check(f"sandbox:{ctx.user_id}", ctx.settings.RATE_SANDBOX_PER_HOUR, 3600,
29 MSG_SANDBOX) 30 MSG_SANDBOX)
30 await ctx.report("running", "Exécution du code Python…") 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 client = SandboxClient(ctx.settings) 37 client = SandboxClient(ctx.settings)
32 files_in = await file_service.sandbox_inputs(ctx.user_id, ctx.file_ids) 38 files_in = await file_service.sandbox_inputs(ctx.user_id, ctx.file_ids)
33 timeout = ctx.settings.SANDBOX_HEAVY_TIMEOUT_S if args.get("heavy") else None 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 heavy: bool = False 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 class ToolRegistry: 62 class ToolRegistry:
51 def __init__(self) -> None: 63 def __init__(self) -> None:
52 self._tools: dict[str, ToolSpec] = {} 64 self._tools: dict[str, ToolSpec] = {}
@@ -78,23 +90,41 @@ class ToolRegistry:
78 f"{', '.join(self._tools)}.", error=True) 90 f"{', '.join(self._tools)}.", error=True)
79 if "__invalid_json__" in arguments: 91 if "__invalid_json__" in arguments:
80 return ToolResult( 92 return ToolResult(
81− content="Les arguments n'étaient pas du JSON valide. Renvoie un objet JSON " 93+ content=f"Les arguments de {name} n'étaient pas du JSON valide et n'ont pas pu être "
82− "conforme au schéma de l'outil.", error=True) 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 try: 104 try:
84− args = spec.args_model.model_validate(arguments) 105+ args = spec.args_model.model_validate(clean)
85 except ValidationError as exc: 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 return ToolResult( 109 return ToolResult(
87− content="Arguments invalides pour l'outil " 110+ content=f"Arguments invalides pour {name} — {errs}. Attendu : "
88− f"{name} : {exc.errors(include_url=False)}. Corrige et réessaie.", 111+ f"{{{_schema_hint(spec.schema)}}}. Corrige et réessaie.",
89 error=True) 112 error=True)
113+ payload = args.model_dump()
114+ if extra:
115+ payload["__extra__"] = extra
90 t0 = time.perf_counter() 116 t0 = time.perf_counter()
91 try: 117 try:
92− result = await spec.fn(args.model_dump(), ctx) 118+ result = await spec.fn(payload, ctx)
93 except Exception as exc: # noqa: BLE001 — error goes back to the model 119 except Exception as exc: # noqa: BLE001 — error goes back to the model
94 log.exception("tool_failed", tool=name) 120 log.exception("tool_failed", tool=name)
95− result = ToolResult(content=f"L'outil {name} a échoué : {type(exc).__name__}: {exc}", 121+ result = ToolResult(content=f"L'outil {name} a échoué : {type(exc).__name__}: {exc}. "
96− error=True) 122+ "Ajuste les arguments (types, cellules, formats) et réessaie ; "
123+ "si l'erreur persiste, explique le calcul en texte.", error=True)
97 result.meta["duration_ms"] = int((time.perf_counter() - t0) * 1000) 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 return result 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 <span className="flex-1 min-w-0"> 38 <span className="flex-1 min-w-0">
39 <span className="block text-sm font-medium text-neutral-text truncate">{meta.label}</span> 39 <span className="block text-sm font-medium text-neutral-text truncate">{meta.label}</span>
40 <span className="block text-xs text-neutral-muted truncate"> 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 </span> 42 </span>
43 </span> 43 </span>
44 <span className="text-xs text-neutral-muted inline-flex items-center gap-1 shrink-0"> 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 import { useState, type FormEvent } from 'react'; 1 import { useState, type FormEvent } from 'react';
2+import { fmtDate } from '@/lib/format';
2 import { Link } from 'react-router-dom'; 3 import { Link } from 'react-router-dom';
3 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; 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 import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; 6 import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
6 import { api } from '@/lib/api'; 7 import { api } from '@/lib/api';
7 import { Button } from '@/components/ui/button'; 8 import { Button } from '@/components/ui/button';
@@ -18,7 +19,7 @@ interface CourseSettings { code: string; title: string; extra_system_prompt: str
18 interface Settings { courses: CourseSettings[]; tools: string[]; models: Record<string, string>; budget_usd: number } 19 interface Settings { courses: CourseSettings[]; tools: string[]; models: Record<string, string>; budget_usd: number }
19 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 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 export function ProfessorPage() { 24 export function ProfessorPage() {
24 const [tab, setTab] = useState<(typeof TABS)[number]>('Activité'); 25 const [tab, setTab] = useState<(typeof TABS)[number]>('Activité');
@@ -35,6 +36,7 @@ export function ProfessorPage() {
35 </header> 36 </header>
36 <main className="mx-auto max-w-[1100px] px-4 py-5 pb-[calc(24px+var(--safe-bottom))]"> 37 <main className="mx-auto max-w-[1100px] px-4 py-5 pb-[calc(24px+var(--safe-bottom))]">
37 {tab === 'Activité' && <Activity />} 38 {tab === 'Activité' && <Activity />}
39+ {tab === 'Étudiants' && <StudentsTab />}
38 {tab === 'Contenu' && <ContentTab />} 40 {tab === 'Contenu' && <ContentTab />}
39 {tab === 'Réglages' && <SettingsTab />} 41 {tab === 'Réglages' && <SettingsTab />}
40 </main> 42 </main>
@@ -211,3 +213,101 @@ function CourseForm({ c, tools, onSaved, defaultModel }: { c: CourseSettings; to
211 </Card> 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
Menu

Theme