Python 64.6%
TypeScript 33.7%
CSS 0.8%
1# UQO-Chat — tuteur IA pour IMM1003 · IMM103323> **Tuteur IA des cours IMM1003 — Éléments d'évaluation immobilière et IMM1033 — Méthodes du coût en évaluation4> immobilière (Université du Québec en Outaouais).** Il explique la matière en citant les notes de cours officielles,5> exécute du Python dans un bac à sable isolé, produit et modifie des classeurs Excel à formules vivantes, calcule6> avec des outils d'évaluation déterministes, cherche des données de marché actuelles, analyse les fichiers déposés,7> génère des graphiques, des documents Word et des quiz interactifs.89| | |10|---|---|11| **Production** | https://www.uqo-chat.app |12| **Version** | v0.5 (auth par invitation) — `backend/pyproject.toml` et `frontend/package.json` portent encore `0.1.0` |13| **App `mld`** | `uqo-chat` · API `:8190` · sandbox `:8191` · nœud actuel : `mld status` (au 2026-09-06 : **M4M64a**, `~/apps/uqo-chat`) |14| **Processus PM2** | `uqo-chat-api` · `uqo-chat-sandbox` · `uqo-chat-ngrok` |15| **Santé** | `GET /api/v1/ready` → `{"ok":true,"chunks":714}` · `GET /api/v1/health` → `{"ok":true,"service":"uqo-chat"}` |16| **Dépôt** | `gitsrv:~/srv/git/uqo-chat.git` (spbgit, git.spboucher.ai) — source de vérité = **ce dépôt (laptop) + spbgit**, jamais la copie du nœud |17| **Spécification produit** | [`CLAUDE.md`](CLAUDE.md) (résumé de la spec d'origine en 28 sections, règles d'or, écarts assumés) |18| **Auteur / contact** | **Simon-Pierre Boucher** — contact@spboucher.ai |1920---2122## Sommaire23241. [Vue d'ensemble](#1-vue-densemble)252. [Fonctionnalités](#2-fonctionnalités)263. [Les 13 outils du tuteur](#3-les-13-outils-du-tuteur)274. [Architecture](#4-architecture)285. [Arborescence du dépôt](#5-arborescence-du-dépôt)296. [Backend (FastAPI)](#6-backend-fastapi)307. [Authentification, rôles et comptes](#7-authentification-rôles-et-comptes)318. [RAG sur les notes de cours](#8-rag-sur-les-notes-de-cours)329. [Bac à sable Python (`sandbox-runner`)](#9-bac-à-sable-python-sandbox-runner)3310. [Frontend (React PWA)](#10-frontend-react-pwa)3411. [Configuration (variables d'environnement)](#11-configuration-variables-denvironnement)3512. [Développement local](#12-développement-local)3613. [Tests, lint, CI](#13-tests-lint-ci)3714. [Déploiement sur le cluster MacLustr (`mld`)](#14-déploiement-sur-le-cluster-maclustr-mld)3815. [Exploitation](#15-exploitation)3916. [Coûts LLM](#16-coûts-llm)4017. [Vie privée, sécurité, Loi 25](#17-vie-privée-sécurité-loi-25)4118. [Écarts assumés par rapport à la spécification](#18-écarts-assumés-par-rapport-à-la-spécification)4219. [Feuille de route](#19-feuille-de-route)4320. [Historique des versions](#20-historique-des-versions)4421. [Pièges connus](#21-pièges-connus)4522. [Droits et licence](#22-droits-et-licence)4623. [Contact](#23-contact)4748---4950## 1. Vue d'ensemble5152UQO-Chat est une application web (PWA) destinée aux étudiant·es des deux cours d'évaluation immobilière donnés53par Simon-Pierre Boucher à l'UQO. Le professeur inscrit les courriels des étudiant·es ; chacun·e reçoit une54invitation, choisit un mot de passe et converse en français avec un tuteur qui :5556- **s'appuie d'abord sur la matière du cours** : recherche BM25 dans le HTML des sites de notes interactives57 (https://www.uqo-imm1003.app, https://www.uqo-imm1033.app) avec citations cliquables vers la séance et la section ;58- **calcule juste** : calculateurs déterministes d'évaluation (méthode du coût, dépréciation, terrain,59 capitalisation, comparables, six fonctions du dollar, conversions d'unités) plutôt que de l'arithmétique « de tête » ;60- **produit des livrables** : classeurs Excel à formules vivantes (6 gabarits ou spec libre), modification de61 classeurs déposés, graphiques PNG, documents Word, quiz interactifs ;62- **exécute du code** : Python (pandas, numpy, matplotlib…) dans un service séparé sous `sandbox-exec`, sans réseau ;63- **va chercher le présent** : données de marché, taux, coûts, règlements via Firecrawl (cache 6 h) ;64- **est piloté par le professeur** : tableau de bord (activité anonymisée, étudiants et invitations, contenu65 ingéré, consignes et annonces par cours, réglages), page admin des coûts.6667Tous les appels de modèles passent par **OpenRouter** (`anthropic/claude-fable-5.1` par défaut, repli68`openai/gpt-5.5`, `anthropic/claude-opus-4.6` pour la réflexion approfondie, `openai/gpt-5.4-nano` pour les titres69et classifications, `anthropic/claude-sonnet-4.6` pour la vision), en **streaming SSE** avec boucle agentique70(jusqu'à 12 itérations d'outils, outils exécutés en parallèle, 16 000 jetons de sortie).7172## 2. Fonctionnalités7374### Pour l'étudiant·e75- Conversations par cours (IMM1003 / IMM1033), titrées automatiquement, regroupées par date dans le tiroir,76 renommage, suppression, régénération d'une réponse, arrêt d'un tour en cours, rétroaction 👍/👎 par message.77- Réponses en Markdown + **KaTeX** (formules), blocs de code colorés (**shiki** allégé), garde-fou `math-guard`78 pour que « 185 000 $ » en prose ne soit pas pris pour du LaTeX.79- **Cartes d'outils** riches : source de cours (extrait + lien), calculateur d'évaluation (tableau des étapes),80 calculateur financier, conversion d'unités, **éditeur Python** (onglets Code / Sortie / Graphiques, modifier et81 ré-exécuter, télécharger `.py`), **aperçu Excel multi-feuilles** avec formules `ƒ` et bouton « Modifier avec le82 tuteur », graphique, document Word, analyse de fichier, recherche web (sources), **quiz interactif** (correction83 immédiate, explication, score enregistré).84- Dépôt de fichiers (xlsx, csv, pdf, docx, pptx, images ; 20 Mo ; 20 / jour) conservés 7 jours (30 si épinglés),85 panneau des artefacts de la conversation, liens de téléchargement signés.86- Préférences : tutoiement / vouvoiement, langue, thème ; consentement Loi 25, export et suppression du compte.87- PWA installable (app shell + cache lecture ≈ 2 Mo), mobile-first 375 px, héros dégradé, composer flottant.8889### Pour le professeur (`/professeur`)90- **Activité** : messages, conversations, étudiant·es actifs, outils appelés, sujets, rétroactions — agrégés et91 anonymisés (identifiants hachés), sans contenu de message.92- **Étudiants** : ajout en lot (collage de courriels), rôles, statut *invité / activé*, **invitation Resend**93 « Bienvenue sur UQO-Chat — choisis ton mot de passe », relance individuelle ou « Inviter les N jamais invité·es »,94 mot de passe défini manuellement, suppression.95- **Contenu** : ingestion de fichiers (PDF, DOCX, PPTX, MD) ou d'un site de notes, activation / désactivation d'un96 document, réindexation.97- **Réglages par cours** : consignes additionnelles injectées dans le prompt, annonce affichée aux étudiant·es,98 échéances, modèle.99- Promotion d'un compte au rôle professeur / admin.100101### Pour l'admin (`/admin`)102- Coûts LLM (par jour, par modèle, par étudiant·e haché), budget mensuel et projection.103104## 3. Les 13 outils du tuteur105106Chaque outil = `backend/app/tools/<nom>.py` + schéma JSON `app/tools/schemas/<nom>.json` + test + carte React107`frontend/src/components/tools/<nom>-card.tsx`. Les arguments sont **coercés avec tolérance** (`tools/coerce.py` :108« 425 000 $ », « +3 % », « Oui » → nombres / booléens) et les appels tronqués par le modèle sont **réparés ou refusés**109plutôt qu'exécutés à moitié.110111| Outil | Rôle | Détails |112|---|---|---|113| `search_course_content` | Matière du cours (à consulter d'abord) | BM25 sur 714 passages des deux sites de notes ; renvoie extraits + URL `seance/NN#ancre` |114| `appraisal_calc` | 12 calculateurs d'évaluation déterministes | méthode du coût, **ventilation de la dépréciation anti-double-comptage**, coût indexé / unitaire, terrain (extraction, allocation, résiduelle, lotissement), capitalisation directe, MRB, grille de comparables, âge effectif par le marché |115| `financial_calc` | Six fonctions du dollar, VAN / TRI, âge-vie | `numpy-financial` |116| `unit_convert` | m² ↔ pi², arpent, acre, hectare, $/m² ↔ $/pi² | unités québécoises incluses |117| `execute_python` | Calculs libres, statistiques, régressions, graphiques | via `sandbox-runner` ; les fichiers déposés **et les artefacts déjà générés** sont disponibles dans `inputs/<nom>` |118| `make_chart` | Graphique rapide à partir de données | matplotlib in-process, palette UQO, PNG |119| `create_excel` | Nouveau classeur à **formules vivantes** | gabarits `comparables_ajustes`, `methode_du_cout`, `age_vie`, `tableau_amortissement`, `six_fonctions`, `sensibilite`, ou spec libre normalisée (`tools/excel_spec.py`) ; la grille de comparables calcule les ajustements par formule (sujet + taux + échelles ordinales + tendance / mois) |120| `inspect_excel` | Lire un classeur (déposé ou généré) | feuilles, dimensions, en-têtes, formules, aperçu |121| `edit_excel` | **Modifier** un classeur existant | ajouter colonne / ligne / feuille / graphique, corriger une cellule, formater ; versions `_v2`, `_v3`… (`tools/excel_ops.py`) |122| `create_docx` | Document Word depuis Markdown | fiche de révision, plan d'étude, gabarit de rapport (`python-docx`) |123| `analyze_file` | Lire un fichier déposé | xlsx / csv / pdf (`pdfplumber`) / docx / pptx / image (modèle vision) |124| `web_search` | Données actuelles seulement | Firecrawl, cache 6 h, 30 / jour / étudiant·e |125| `generate_quiz` | Quiz interactif | questions à choix, explication, enregistrement des tentatives |126127Le prompt système (`app/llm/prompts/system_tutor.md` + `guardrails.md` + `course_imm10xx.md` + consignes du128professeur + `TOOL_HINTS` dans `agent.py`) impose : chercher la matière avant de répondre, enchaîner les outils129(ex. `search_course_content → appraisal_calc → create_excel → make_chart`), **modifier** un fichier existant avec130`edit_excel` plutôt que d'en recréer un, pédagogie socratique, français québécois, pas de solution complète aux TP131en cours.132133## 4. Architecture134135```136Internet ── ngrok (www.uqo-chat.app, TLS) ──► uqo-chat-api :8190 FastAPI + SSE + SPA React (PM2)137 │138 ├──► uqo-chat-sandbox :8191 sandbox-runner (PM2) — python -I -B sous sandbox-exec,139 │ sans réseau, écriture confinée, rlimits, timeout ≤ 60 s140 ├──► SQLite data/uqochat.db (SQLAlchemy async ; Postgres via DATABASE_URL)141 │ index BM25 reconstruit en mémoire au démarrage142 ├──► fichiers data/files/<user>/<id>.<ext> (TTL 7 j / 30 j épinglés, purge horaire)143 └──► HTTPS sortant : OpenRouter (LLM), Firecrawl (web), Resend (courriels)144```145146- **Un seul point d'entrée LLM** : `app/llm/openrouter.py` (`LLMClient`, SSE, repli de modèle, comptage des jetons147 et des coûts → table `llm_usage`). `app/llm/router.py` choisit le modèle (primaire / raisonnement / rapide / vision).148- **Boucle agentique** : `app/llm/agent.py` — construit le prompt (cours, préférences, fichiers, échéances,149 annonce), streame les deltas au client (`token`, `tool_call`, `tool_result`, `done`, `error`), exécute les outils en150 parallèle via `app/tools/registry.py` (`ToolContext` = utilisateur, conversation, fichiers, cours), persiste151 messages et `tool_calls`, enregistre usage et analytics.152- **Aucun code étudiant / LLM ne s'exécute dans le processus API** : toujours `sandbox-runner`153 (`app/sandbox/client.py`, jeton `SANDBOX_TOKEN`).154- **Maintenance** (`main.py`, toutes les heures) : purge des fichiers expirés, anonymisation des messages de plus de155 `MESSAGE_RETENTION_MONTHS` (12).156- **Sécurité HTTP** : CORS restreint, en-têtes `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy`,157 `Permissions-Policy`, HSTS en production, **CSP** stricte sur le HTML (`script-src 'self'`, `frame-ancestors 'none'`).158- **Frontend servi par l'API** : `frontend/dist` monté sur `/assets` + repli SPA (`GET`/`HEAD` sur toute route159 → `index.html`), ce qui permet les vérifications d'uptime en `HEAD`.160161Les manifestes **Kubernetes** (`k8s/`) et `docker-compose.yml` reproduisent la cible décrite dans la spécification162(Postgres + pgvector, Redis, MinIO, sandbox durci `network_mode: none`, NetworkPolicy deny-all, HPA) pour un futur163cluster ; la production actuelle tourne sur un nœud Mac du cluster via `mld`.164165## 5. Arborescence du dépôt166167```168uqo-chat/169├── README.md · CLAUDE.md · Makefile · .env.example · docker-compose.yml170├── .github/workflows/ ci.yml (ruff + pytest + tsc + build + trivy) · deploy.yml (images ghcr + kubectl, non utilisé en prod)171├── deploy/uqo-chat.manifest.json gabarit du manifeste mld (les secrets sont dans M1M32:~/dispatch/apps/uqo-chat.json)172├── backend/ Python 3.12 · FastAPI · SQLAlchemy async173│ ├── pyproject.toml dépendances (fastapi, uvicorn, pydantic, sqlalchemy, aiosqlite, asyncpg, httpx[http2],174│ │ structlog, python-jose, passlib, openpyxl, pandas, numpy(-financial), matplotlib,175│ │ pdfplumber, python-docx, python-pptx, selectolax, markdown-it-py, aiosmtplib, orjson)176│ ├── Dockerfile177│ ├── app/178│ │ ├── main.py application, middlewares, SPA, tâche de maintenance179│ │ ├── db.py moteur async, SessionLocal, init_db (create_all + _add_missing_columns)180│ │ ├── models/ User, MagicLink, Course, Conversation, Message, ToolCall, StoredFile, Quiz, QuizAttempt,181│ │ │ CourseDocument, CourseChunk, AnalyticsEvent, LLMUsage, AppSetting182│ │ ├── api/v1/ health, auth, conversations, chat, files, quiz, courses, professor, admin183│ │ ├── core/ config (Settings), logging (structlog, masquage content/text/email), security (JWT,184│ │ │ PBKDF2, hachage d'identifiants), ratelimit (mémoire), cache185│ │ ├── llm/ openrouter.py, agent.py, router.py, schemas.py, prompts/*.md186│ │ ├── rag/ ingest.py (HTML des sites / PDF / DOCX / PPTX / MD → chunks), chunking.py, retriever.py (BM25)187│ │ ├── sandbox/client.py client HTTP du sandbox-runner188│ │ ├── services/ users, invites, mail (Resend + SMTP), conversations, files, courses, analytics, costs189│ │ └── tools/ registry, all, coerce, excel_spec, excel_ops, 13 outils, schemas/*.json, excel_templates/ (6)190│ ├── scripts/ seed.py (cours + professeur), ingest.py (--course --site | --path)191│ └── tests/ 41 tests (auth/invitations respx, SSE OpenRouter, RAG, préfiltre sandbox, outils)192├── sandbox-runner/ service FastAPI minimal : POST /run {code, files_in, timeout_s} · runner.py (sandbox-exec,193│ rlimits, prélude matplotlib Agg, capture des figures, troncature 50 Ko) · Dockerfile · requirements.txt194├── frontend/ React 18 · Vite 5 · TypeScript · Tailwind 3 · PWA195│ ├── src/app/router.tsx /connexion · /mot-de-passe · /confidentialite · /professeur · /admin · / · /c/:id196│ ├── src/components/ chat/ (chat-view, composer, message-bubble, streaming-text, code-block, tool-call-timeline,197│ │ empty-state) · tools/ (13 cartes + tool-card, artifact-chips) · layout/ (app-shell, sidebar) · ui/198│ ├── src/features/ auth/ (login, set-password, privacy, settings) · conversations/artifacts-panel ·199│ │ professor/ (professor-page, admin-costs-page)200│ ├── src/stores/ zustand : auth, chat, ui (dont ui.draft pour « Modifier avec le tuteur »)201│ ├── src/lib/ api.ts (fetch + SSE), types.ts, format.ts, math-guard.ts (+ test), cn.ts202│ ├── src/theme/uqo.ts · src/i18n/fr-CA.json · src/index.css · tailwind.config.ts · vite.config.ts (PWA, proxy /api)203│ └── public/ uqo-logo.png, uqo-logo-white.png, icons/ (favicon, 192/512, maskable, apple-touch, og.png)204├── k8s/ base/ (namespace, api, web, worker, sandbox-runner, postgres, redis, minio, services,205│ networkpolicies, hpa) · overlays/staging|prod · ngrok/ (operator, ingress)206├── content/ (ignoré) copies locales des sites de notes pour l'ingestion207└── data/ (ignoré) uqochat.db + files/208```209210## 6. Backend (FastAPI)211212Préfixe commun **`/api/v1`**. Authentification par **JWT** (`Authorization: Bearer`, 24 h) + jeton de213rafraîchissement (30 j). Documentation OpenAPI disponible **en développement seulement** (`/api/docs`).214215| Groupe | Routes | Notes |216|---|---|---|217| Santé | `GET /health` · `GET /ready` | `ready` renvoie le nombre de passages indexés |218| Auth | `POST /auth/login` · `POST /auth/forgot` · `GET /auth/password-token` · `POST /auth/set-password` · `POST /auth/verify` (legacy lien magique) · `POST /auth/refresh` · `POST /auth/logout` · `GET /auth/config` | voir §7 |219| Moi | `GET /me` · `PATCH /me/preferences` · `POST /me/password` · `POST /me/consent` · `DELETE /me` | suppression = anonymisation + purge des fichiers |220| Cours | `GET /courses` | cours actifs, annonces, échéances |221| Conversations | `GET/POST /conversations` · `GET/PATCH/DELETE /conversations/{id}` · `POST /conversations/{id}/messages/{mid}/feedback` | |222| Chat | `POST /chat/{id}/messages` (SSE) · `POST /chat/{id}/stop` · `POST /chat/{id}/messages/{mid}/regenerate` (SSE) | événements `token`, `tool_call`, `tool_result`, `title`, `usage`, `done`, `error` |223| Fichiers | `POST /files` (201) · `GET /files/{id}` · `GET /files/{id}/link` · `POST /files/{id}/pin` · `GET /conversations/{id}/files` · `POST /tools/python/run` | `python/run` = ré-exécution depuis l'éditeur de la carte Python |224| Quiz | `GET /quiz/{id}` · `POST /quiz/{id}/answers` | |225| Professeur | `GET /dashboard` · `GET /settings` · `PATCH /settings/{course}` · `GET/POST /content` (202) · `PATCH/DELETE /content/{doc_id}` · `POST /promote` · `GET/POST /students` · `POST /students/invite-all` · `PATCH/DELETE /students/{id}` · `POST /students/{id}/invite` · `PUT /students/{id}/password` | rôle `professor` ou `admin` |226| Admin | `GET /costs` | rôle `admin` |227228**Modèle de données** (SQLAlchemy, `create_all` au démarrage + ajout des colonnes manquantes) :229230| Table | Contenu |231|---|---|232| `users` | courriel, rôle (`student` / `professor` / `admin`), mot de passe PBKDF2, préférences JSON, consentement, `invited_at`, dernier accès |233| `magic_links` | jetons à usage unique (`purpose` = `invite` / `reset` / legacy), expiration |234| `courses` | IMM1003, IMM1033 : libellé, consignes du professeur, annonce, échéances, modèle |235| `conversations` · `messages` · `tool_calls` | fil, rôle, contenu, jetons, coût, rétroaction, appels d'outils (args, résultat, durée) |236| `files` | fichiers déposés / générés : propriétaire, conversation, type, taille, hachage, épinglé, expiration |237| `quizzes` · `quiz_attempts` | quiz générés et tentatives |238| `course_documents` · `course_chunks` | documents ingérés (site, PDF…) et passages BM25 |239| `analytics_events` | événements anonymisés (identifiant haché) |240| `llm_usage` | usage par appel : modèle, jetons entrée / sortie, coût USD |241| `app_settings` | réglages clé-valeur modifiables à chaud |242243**Limites par étudiant·e** (config) : 60 messages / h, 400 / jour, 10 exécutions sandbox / h, 30 recherches web /244jour, 20 dépôts / jour, 20 Mo par fichier. Dépassement → `429` avec message français.245246**Journalisation** : `structlog` JSON en production ; les clés `content`, `text`, `email`… sont masquées247(`core/logging.py`). Jamais de contenu de message dans les logs.248249## 7. Authentification, rôles et comptes250251Flux **v0.5** (le code d'accès au cours des versions précédentes est supprimé) :2522531. Le professeur ajoute les courriels dans **Étudiants** (`POST /professor/students`, création en lot). Les comptes254 créés par le professeur sont acceptés même hors `@uqo.ca` ; sinon `ALLOWED_EMAIL_DOMAINS` s'applique.2552. Invitation (`send_invitations`, `POST /students/{id}/invite`, `POST /students/invite-all`) : courriel **Resend**256 « Bienvenue sur UQO-Chat — choisis ton mot de passe » avec lien `/mot-de-passe?token=…` (`purpose=invite`,257 `INVITE_TTL_DAYS` = 14). Gabarits HTML aux couleurs UQO dans `services/mail.py` (Resend `/emails` et258 `/emails/batch` ≤ 100 ; repli SMTP `aiosmtplib`).2593. L'étudiant·e choisit son mot de passe (`GET /auth/password-token` valide le jeton, `POST /auth/set-password`),260 puis se connecte (`POST /auth/login`, PBKDF2).2614. « Première connexion ou mot de passe oublié » → `POST /auth/forgot` : lien `purpose=reset` de262 `RESET_TTL_MINUTES` = 60, envoyé **uniquement** aux adresses inscrites ou listées dans `PROFESSOR_EMAILS` /263 `ADMIN_EMAILS` / `INVITED_EMAILS` (sinon `404` avec un indice : « adresse non inscrite » ou « adresse non admise »).264 Limitation de débit : 20 demandes / h par IP, 5 / h par adresse. En dev, la réponse contient `dev_link`.2655. Sans `RESEND_API_KEY` ni SMTP, les liens sont affichés dans le tableau de bord pour copie manuelle.266267Rôles : `student` (chat), `professor` (tableau de bord, étudiants, contenu, réglages), `admin` (coûts, promotion).268Les adresses de `PROFESSOR_EMAILS` / `ADMIN_EMAILS` obtiennent leur rôle à la connexion.269270État des comptes en production au 2026-09-06 : le professeur (`boucsi02@uqo.ca`) a son mot de passe ; l'admin271(`spbou4@icloud.com`) a reçu son courriel « choisis ton mot de passe » ; **19 étudiant·es sont inscrits sans avoir été272invités** — le professeur déclenche l'envoi via « Inviter les 19 jamais invité·es ».273274## 8. RAG sur les notes de cours275276- **Source** : le HTML généré des sites de notes interactives (`dist/<cours>/seance/NN/index.html`, dépôts277 `uqo-imm1003` / `uqo-imm1033`), copié sur le nœud dans `~/apps/uqo-chat/content/<cours>/`. Aussi PDF, DOCX, PPTX,278 MD déposés par le professeur.279- **Ingestion** (`rag/ingest.py`) : `selectolax` nettoie le HTML (scripts, SVG, widgets, navigation), reconvertit le280 KaTeX en `$…$` via l'annotation MathML, découpe par section (`chunking.py`, chevauchement), calcule un hachage par281 passage, écrit `course_documents` + `course_chunks` avec l'URL publique (`SITE_URLS`) et l'ancre.282- **Index** : **BM25 en mémoire** (`rag/retriever.py`), reconstruit au démarrage (`/ready` renvoie le nombre de283 passages : 714 pour les deux cours) et après chaque ingestion.284- **Embeddings** : optionnels via un endpoint OpenAI-compatible (`EMBEDDINGS_BASE_URL`, `MODEL_EMBEDDINGS`) ;285 OpenRouter n'en expose aucun (vérifié 2026-09-05), donc BM25 seul en production.286- **Après un rebuild des sites de notes**, ré-ingérer (voir §15).287288## 9. Bac à sable Python (`sandbox-runner`)289290Service FastAPI minimal (`server.py`) : `GET /healthz`, `POST /run {code ≤ 200 000 car., files_in[], timeout_s 1–60}`,291jeton `SANDBOX_TOKEN` en `Authorization: Bearer`, `SANDBOX_MAX_PARALLEL` = 4 exécutions simultanées (pool de threads +292sémaphore).293294`runner.py` — couches d'isolation :295296| Couche | Détail |297|---|---|298| macOS | profil **`sandbox-exec`** (seatbelt) : réseau interdit, écriture uniquement dans le répertoire de la course, lecture de `~` bloquée |299| Linux (conteneur) | pod durci attendu (NetworkPolicy deny-all, rootfs lecture seule, `cap_drop ALL`, `pids_limit 64`, 512 Mo) |300| Toujours | `python -I -B`, rlimits (CPU, fichiers, processus ; `RLIMIT_AS` 512 Mo désactivé sur Darwin car il casse numpy), délai mur, sortie tronquée à 50 Ko, ≤ 10 fichiers / 20 Mo en sortie |301| Prélude | `chdir` dans le répertoire de travail, `matplotlib` en `Agg` (130 dpi, grille), figures ouvertes sauvegardées automatiquement et renvoyées en base64 |302303Les fichiers déposés **et** les artefacts déjà générés dans la conversation (classeurs, graphiques, documents)304sont copiés dans `inputs/` avant l'exécution ; les fichiers produits reviennent comme artefacts.305306## 10. Frontend (React PWA)307308- **Pile** : React 18, Vite 5, TypeScript 5, Tailwind 3, zustand, TanStack Query, react-router 6, react-markdown +309 remark-gfm + remark-math + rehype-katex, `shiki/core` (langages ciblés ; le paquet complet précachait 10 Mo), Radix310 (dialog, dropdown, tooltip), lucide-react, recharts, `@microsoft/fetch-event-source` (SSE avec en-têtes).311- **Routes** : `/connexion`, `/mot-de-passe` (choix / réinitialisation), `/confidentialite`, `/professeur` (garde312 rôle), `/admin` (garde rôle), `/` et `/c/:id` (chat), `*` → `/`.313- **Marque** : couleur `#0F6180` alignée sur le mot-symbole officiel UQO (`public/uqo-logo*.png`, recoloré depuis314 la source du dépôt de cours ; `VITE_USE_OFFICIAL_LOGO`), icônes PWA et image OG régénérées, police système.315- **Mobile-first** : cible 375 px ; tiroir groupé par date, bulles et composer flottant, squelettes de chargement,316 bouton de défilement dégagé du composer, lien « mot de passe oublié » sous le champ sans retour à la ligne.317- **PWA** (`vite-plugin-pwa`) : app shell + cache lecture (≈ 2 Mo précachés), manifest, icônes maskable.318- **`lib/math-guard.ts`** : applique la règle Pandoc pour ne rendre en LaTeX que les `$…$` plausibles (test vitest).319- **Build** : `tsc --noEmit && vite build` → `frontend/dist` servi par l'API en production.320321## 11. Configuration (variables d'environnement)322323Lues par `pydantic-settings` depuis `.env` (racine ou `backend/`) puis l'environnement ; gabarit complet dans324`.env.example`. Les valeurs de production sont dans le manifeste `M1M32:~/dispatch/apps/uqo-chat.json` (jamais dans325le dépôt).326327| Groupe | Variables (défaut) |328|---|---|329| App | `APP_ENV` (development), `APP_URL`, `PORT` (8190), `CORS_ORIGINS`, `LOG_LEVEL`, `DATA_DIR` (`./data`), `FRONTEND_DIST`, `COURSES` (IMM1003,IMM1033), `TERM_LABEL` |330| LLM | `OPENROUTER_API_KEY`, `OPENROUTER_BASE_URL`, `MODEL_TUTOR_PRIMARY` (anthropic/claude-fable-5.1), `MODEL_TUTOR_FALLBACK` (openai/gpt-5.5), `MODEL_REASONING` (anthropic/claude-opus-4.6), `MODEL_FAST` (openai/gpt-5.4-nano), `MODEL_VISION` (anthropic/claude-sonnet-4.6), `LLM_MAX_TOOL_ITERATIONS` (12), `LLM_TIMEOUT_SECONDS` (120), `LLM_MAX_OUTPUT_TOKENS` (16000), `LLM_TURN_TIMEOUT_SECONDS` (300), `LLM_MONTHLY_BUDGET_USD` (2000) |331| Embeddings (optionnel) | `EMBEDDINGS_BASE_URL`, `EMBEDDINGS_API_KEY`, `MODEL_EMBEDDINGS` |332| Web | `FIRECRAWL_API_KEY`, `FIRECRAWL_BASE_URL`, `WEB_SEARCH_CACHE_TTL_S` (21600) |333| Données | `DATABASE_URL` (vide = SQLite `DATA_DIR/uqochat.db`), `REDIS_URL` (vide = mémoire) |334| Sandbox | `SANDBOX_URL` (http://127.0.0.1:8191), `SANDBOX_TOKEN`, `SANDBOX_TIMEOUT_S` (30), `SANDBOX_HEAVY_TIMEOUT_S` (60) ; côté runner : `SANDBOX_MAX_PARALLEL`, `SANDBOX_PYTHON` |335| Auth | `JWT_SECRET`, `JWT_TTL_HOURS` (24), `REFRESH_TTL_DAYS` (30), `ALLOWED_EMAIL_DOMAINS` (uqo.ca), `INVITED_EMAILS`, `PROFESSOR_EMAILS`, `ADMIN_EMAILS`, `INVITE_TTL_DAYS` (14), `RESET_TTL_MINUTES` (60) |336| Courriel | `RESEND_API_KEY`, `RESEND_BASE_URL`, `MAIL_FROM` (UQO-Chat <no-reply@uqo-chat.app>), `MAIL_REPLY_TO`, repli `SMTP_HOST/PORT/USER/PASSWORD/FROM` |337| Limites | `RATE_MESSAGES_PER_HOUR` (60), `RATE_MESSAGES_PER_DAY` (400), `RATE_SANDBOX_PER_HOUR` (10), `RATE_WEBSEARCH_PER_DAY` (30), `RATE_UPLOADS_PER_DAY` (20), `UPLOAD_MAX_MB` (20), `FILE_TTL_DAYS` (7), `FILE_PINNED_TTL_DAYS` (30), `MESSAGE_RETENTION_MONTHS` (12) |338| Frontend (build) | `VITE_API_URL` (/api/v1), `VITE_USE_OFFICIAL_LOGO`, `VITE_COURSES` |339340Le domaine `uqo-chat.app` est vérifié chez Resend (DNS GoDaddy) ; le CNAME `www` pointe vers ngrok.341342## 12. Développement local343344```bash345git clone gitsrv:~/srv/git/uqo-chat.git && cd uqo-chat346cp .env.example .env # OPENROUTER_API_KEY, FIRECRAWL_API_KEY, RESEND_API_KEY, JWT_SECRET, PROFESSOR_EMAILS…347make setup # uv venv 3.12 backend + sandbox-runner, npm install348make migrate # schéma (create_all)349make seed PROF=prof@uqo.ca # cours + compte professeur350make sandbox & # :8191351make api & # :8190 (sert frontend/dist s'il existe ; /api/docs en dev)352make web # :5173, proxy /api → 8190353make ingest COURSE=imm1033 SITE=~/Desktop/Academique/UQO/UQO_COURS/_Site_web/dist/imm1033354make ingest COURSE=imm1003 SITE=~/Desktop/Academique/UQO/UQO_COURS/_Site_web/dist/imm1003355```356357Prérequis : Python 3.12 via `uv`, Node 22, macOS pour `sandbox-exec` (sur Linux, utiliser `docker compose up`358qui isole le sandbox avec `network_mode: none`). En dev, `POST /auth/forgot` renvoie `dev_link` pour se connecter359sans courriel.360361## 13. Tests, lint, CI362363```bash364make test # pytest (41 tests) + tsc --noEmit365make lint # ruff (E, F, I, B, UP ; ligne 100) + tsc366cd frontend && npm test # vitest (math-guard)367python3 /tmp/uqo-qa/qa.py # QA Playwright : connexion + chat + captures 375/1440 + détection d'overflow (script local)368```369370Tests backend : invitations et mots de passe (`respx` simule Resend), flux SSE OpenRouter, RAG (ingestion HTML +371BM25), préfiltre du sandbox, coercition et normalisation de specs Excel, taux de la grille de comparables,372`create_excel`, `financial_calc`, nouveaux outils (`make_chart`, `create_docx`…).373374CI GitHub Actions (`ci.yml`, si le dépôt est poussé sur GitHub) : ruff + pytest, `npm ci && npm run build`,375construction des deux images Docker et analyse Trivy. `deploy.yml` (images ghcr + `kubectl apply -k`) correspond à la376cible Kubernetes et **n'est pas** utilisé pour la production actuelle.377378## 14. Déploiement sur le cluster MacLustr (`mld`)379380L'app est orchestrée par **maclustr-dispatch (`mld`)** depuis la passerelle **M1M32**.381382Manifeste (`deploy/uqo-chat.manifest.json` = gabarit ; le vrai, avec secrets : `M1M32:~/dispatch/apps/uqo-chat.json`) :383384| Clé | Valeur |385|---|---|386| `domain` / `port` / `health_path` | `www.uqo-chat.app` / `8190` / `/api/v1/ready` |387| `requires` | runtimes `pm2`, `ngrok`, `uv`, `uv-python@3.12` · 3 Go RAM · ports 8190, 8191 (≈ 900 Mo observés) |388| `placement` | `prefer: M4M64a`, `avoid: M3U96b` (réservé hfmarketdata), `M1M32` (passerelle) |389| `sync_excludes` | `.venv/`, `data/`, `content/`, `.env`, `frontend/node_modules/`, caches, `.git/` |390| `processes` | PM2 `uqo-chat-sandbox` (uvicorn `server:app` 127.0.0.1:8191, `max_memory_restart 2G`) · `uqo-chat-api` (uvicorn `app.main:app` 0.0.0.0:8190, keep-alive 75 s, `max_memory_restart 3G`, env de production) |391| `ngrok` | PM2 `uqo-chat-ngrok` → `https://www.uqo-chat.app` |392| `hooks.post_sync` | `uv venv` + `uv pip install -e .` (backend), `uv pip install -r requirements.txt` (sandbox), `mkdir -p data/files` |393394Procédure depuis le laptop (le dépôt = source de vérité ; **ne jamais éditer la copie du nœud**) :395396```bash397make test lint build # vérifie puis produit frontend/dist398git add -A && git commit -m "…" && git push origin main399~/Desktop/cluster-skill/mld stage $PWD uqo-chat # laptop → M1M32:~/dispatch/stage/uqo-chat/dir400~/Desktop/cluster-skill/mld deploy uqo-chat # nœud par score (ou --node M4M64a) ; santé locale + publique ; registre401# raccourci équivalent : make deploy [NODE=M4M64a]402```403404`data/` (SQLite + fichiers) et `content/` (sites ingérés) sont **exclus de la synchronisation** : ils persistent sur405le nœud. Un `mld move uqo-chat --to <nœud>` déplace l'app mais il faut alors recopier `data/` et `content/`406manuellement (ou ré-ingérer).407408## 15. Exploitation409410```bash411NODE=M4M64a # vérifier avec : ~/Desktop/cluster-skill/mld status | grep uqo-chat412ssh $NODE 'pm2 ls | grep uqo-chat; pm2 logs uqo-chat-api --lines 100 --nostream'413ssh $NODE 'pm2 restart uqo-chat-api' # après changement de manifeste / env414curl -s https://www.uqo-chat.app/api/v1/ready # {"ok":true,"chunks":714}415416# Ré-ingestion des notes de cours après un rebuild des sites (dépôts uqo-imm1003 / uqo-imm1033)417scp -r ~/Desktop/Academique/UQO/UQO_COURS/_Site_web/dist/imm10{03,33} $NODE:~/apps/uqo-chat/content/418ssh $NODE 'cd ~/apps/uqo-chat/backend && for c in imm1003 imm1033; do \419 DATA_DIR=~/apps/uqo-chat/data .venv/bin/python -m scripts.ingest --course $c --site ../content/$c; done'420ssh $NODE 'pm2 restart uqo-chat-api'421422# Sauvegarde des données423ssh $NODE 'sqlite3 ~/apps/uqo-chat/data/uqochat.db ".backup /tmp/uqochat-$(date +%F).db"'424```425426- Registre `mld` (`M1M32:~/dispatch/registry.json`) : nœud, IP LAN, port, santé, processus ; consommé par les apps427 de monitoring MacLustr (macOS / iOS).428- Réglages modifiables à chaud en base (`app_settings`) et depuis l'onglet **Réglages** du professeur.429- Changer de modèle : `MODEL_*` dans le manifeste M1M32, `mld deploy uqo-chat` (ou `pm2 restart` avec env mis à jour).430 Vérifier le slug sur https://openrouter.ai/models avant.431432## 16. Coûts LLM433434- Chaque appel est enregistré dans `llm_usage` (modèle, jetons, coût USD) ; `/admin` agrège par jour, modèle et435 étudiant·e (haché). Budget `LLM_MONTHLY_BUDGET_USD` = 2 000 $ US ; dépassement → refus poli avec message.436- Ordres de grandeur mesurés : tour simple avec RAG ≈ 0,12 $ US (≈ 30 k jetons d'entrée avec outils et passages) ;437 tour riche (4 outils, classeur + graphique) ≈ 0,80 $ US.438439## 17. Vie privée, sécurité, Loi 25440441- Consentement explicite à la première connexion (`POST /me/consent`), page `/confidentialite`, export et442 **suppression du compte** (`DELETE /me` : anonymisation des messages, purge des fichiers).443- Analytics du professeur **anonymisées** (identifiants hachés, `core/security.hash_user_id`), aucun contenu de444 message ; logs sans contenu ni courriel.445- Rétention : fichiers 7 j (30 j épinglés), messages anonymisés après 12 mois (tâche horaire).446- Secrets uniquement dans le manifeste M1M32 et `.env` local (ignoré par git) ; `SecretStr` côté config.447- Code étudiant / LLM jamais exécuté dans l'API ; sandbox sans réseau ; CSP stricte ; JWT signés ; mots de passe PBKDF2 ;448 liens à usage unique et expirants ; limitation de débit sur `/auth/forgot` et sur les messages / outils.449- Logo UQO : l'autorisation formelle du Service des communications de l'UQO reste à obtenir (`VITE_USE_OFFICIAL_LOGO`).450451## 18. Écarts assumés par rapport à la spécification452453| Spécification (CLAUDE.md d'origine) | Réalisation actuelle | Pourquoi |454|---|---|---|455| PostgreSQL + pgvector, Redis, MinIO, arq | SQLite (SQLAlchemy async), cache / limiteur en mémoire, fichiers sur disque, tâches asyncio | Déploiement mono-nœud sans dépendances ; `DATABASE_URL` accepte déjà Postgres, `k8s/` décrit la cible complète |456| Embeddings `openai/text-embedding-3-large` via OpenRouter | BM25 en mémoire + embeddings optionnels via endpoint OpenAI-compatible | OpenRouter n'expose aucun modèle d'embeddings (vérifié 2026-09-05) |457| Lien magique SMTP à chaque connexion | Invitation Resend → mot de passe choisi (PBKDF2) ; « mot de passe oublié » par courriel ; liste blanche gérée par le professeur | Une seule étape par courriel puis connexion classique ; Resend plutôt qu'un SMTP UQO |458| Pods sandbox gVisor / Job k8s | Service `sandbox-runner` + `sandbox-exec` macOS + rlimits + timeout | Équivalent local ; Dockerfile + NetworkPolicy prêts pour k8s |459| Alembic | `create_all` + `_add_missing_columns` | Migration initiale à écrire au premier changement de schéma incompatible |460| Kubernetes + ngrok operator | `mld` + PM2 + agent ngrok sur un nœud Mac | Infrastructure réelle = cluster MacLustr |461| Export PDF de conversation, hors-ligne complet | Non faits (PWA : app shell + cache lecture) | Phase 2 / 3 |462463## 19. Feuille de route464465Export PDF d'une conversation ; file d'attente hors-ligne (Background Sync) ; évaluation pédagogique automatique en466CI (40 questions par cours, juge `MODEL_FAST`) ; tests adverses complets du sandbox ; Alembic ; migration éventuelle467Postgres + pgvector ; autorisation officielle du logo UQO ; alignement des numéros de version (`pyproject`,468`package.json`) sur v0.5.469470## 20. Historique des versions471472| Date | Commit | Version | Contenu |473|---|---|---|---|474| 2026-09-06 | `cd08d44` | v0.1.0 | Création : FastAPI + agent OpenRouter, 7 outils, sandbox, RAG, React PWA ; déployé `mld` sur M4M64a ; www.uqo-chat.app |475| 2026-09-06 | `afb5ca4` | | `HEAD` accepté sur les routes SPA (vérifications d'uptime) |476| 2026-09-06 | `f261ace` | v0.2 | Outils robustes (coercition, comparables flexibles, normalisation de spec, réparation JSON, sortie 16 k), comptes étudiants + liens d'invitation + code d'accès dans le tableau de bord |477| 2026-09-06 | `9e6193a` | | Grille de comparables : ajustements par formule (sujet + taux unitaires) ; correction du collage de courriels en lot |478| 2026-09-06 | `b3a57c0` | | Connexion par mot de passe (PBKDF2), changement de mot de passe, mots de passe fixés par le professeur, migration de colonnes |479| 2026-09-06 | `7a51d30` · `d896f91` | | `anthropic/claude-fable-5.1` modèle par défaut ; budget mensuel 2 000 $ US |480| 2026-09-06 | `1b07883` · `a783830` | v0.3 | 5 nouveaux outils (`inspect_excel`, `edit_excel`, `appraisal_calc`, `unit_convert`, `make_chart`, `create_docx`), artefacts visibles du sandbox, cartes refaites (éditeur Python, aperçu Excel multi-feuilles, « Modifier avec le tuteur ») |481| 2026-09-06 | `d0aea63` · `1b12758` | v0.4 | Mot-symbole officiel UQO + bleu `#0F6180`, passe « premium mobile » (héros, tiroir par date, bulles, composer flottant, squelettes, icônes PWA / OG) |482| 2026-09-06 | `d9fc592` · `f7b72d8` | **v0.5** | **Liste blanche + invitations Resend remplacent le code d'accès** ; lien « mot de passe oublié » sous le champ (375 px) |483| 2026-09-06 | — | | README détaillé (ce document) |484485## 21. Pièges connus486487- `sandbox-exec` bloque bien le réseau et la lecture de `~`, mais `RLIMIT_AS` casse numpy sur macOS → désactivé sur Darwin.488- `shiki` complet précache 10 Mo dans la PWA → utiliser `shiki/core` avec les langages ciblés.489- Les montants « 185 000 $ » en prose cassent `remark-math` → `frontend/src/lib/math-guard.ts` (règle Pandoc).490- Les gros arguments d'outils étaient tronqués à 4 k jetons de sortie (« JSON invalide ») → 16 k + réparation JSON +491 refus d'exécuter un appel tronqué.492- Le manifeste `deploy/` du dépôt n'a que des gabarits `{{…}}` : ne pas le copier tel quel sur M1M32 sans renseigner493 les secrets.494- Après `mld move`, `data/` et `content/` ne suivent pas (exclus du sync) : les recopier ou ré-ingérer.495- PM2 sous zsh : `pm2` n'est pas dans le PATH des hooks → les hooks exportent `~/.local/bin` et `/opt/homebrew/bin`.496497## 22. Droits et licence498499© 2026 Simon-Pierre Boucher. Dépôt privé, tous droits réservés. Contenu pédagogique indexé : notes de cours500IMM1003 / IMM1033 © Simon-Pierre Boucher, UQO. Le nom et le logo UQO appartiennent à l'Université du Québec en501Outaouais. Modèles fournis par OpenRouter (Anthropic, OpenAI) ; recherche web par Firecrawl ; courriels par Resend.502503## 23. Contact504505**Simon-Pierre Boucher**506Courriel : **contact@spboucher.ai**507Git personnel : https://git.spboucher.ai (spbgit, dépôt `uqo-chat`)508Application : https://www.uqo-chat.app · notes de cours : https://www.uqo-imm1003.app · https://www.uqo-imm1033.app509