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>
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 <courriel@uqo.ca></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 | |