UQO-Chat — tuteur IA pour IMM1003 · IMM1033
Tuteur IA des cours IMM1003 — Éléments d'évaluation immobilière et IMM1033 — Méthodes du coût en évaluation immobilière (Université du Québec en Outaouais). Il explique la matière en citant les notes de cours officielles, exécute du Python dans un bac à sable isolé, produit et modifie des classeurs Excel à formules vivantes, calcule avec des outils d'évaluation déterministes, cherche des données de marché actuelles, analyse les fichiers déposés, génère des graphiques, des documents Word et des quiz interactifs.
| Production | https://www.uqo-chat.app |
| Version | v0.5 (auth par invitation) — backend/pyproject.toml et frontend/package.json portent encore 0.1.0 |
App mld |
uqo-chat · API :8190 · sandbox :8191 · nœud actuel : mld status (au 2026-09-06 : M4M64a, ~/apps/uqo-chat) |
| Processus PM2 | uqo-chat-api · uqo-chat-sandbox · uqo-chat-ngrok |
| Santé | GET /api/v1/ready → {"ok":true,"chunks":714} · GET /api/v1/health → {"ok":true,"service":"uqo-chat"} |
| 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 |
| Spécification produit | CLAUDE.md (résumé de la spec d'origine en 28 sections, règles d'or, écarts assumés) |
| Auteur / contact | Simon-Pierre Boucher — contact@spboucher.ai |
Sommaire
- Vue d'ensemble
- Fonctionnalités
- Les 13 outils du tuteur
- Architecture
- Arborescence du dépôt
- Backend (FastAPI)
- Authentification, rôles et comptes
- RAG sur les notes de cours
- Bac à sable Python (
sandbox-runner) - Frontend (React PWA)
- Configuration (variables d'environnement)
- Développement local
- Tests, lint, CI
- Déploiement sur le cluster MacLustr (
mld) - Exploitation
- Coûts LLM
- Vie privée, sécurité, Loi 25
- Écarts assumés par rapport à la spécification
- Feuille de route
- Historique des versions
- Pièges connus
- Droits et licence
- Contact
1. Vue d'ensemble
UQO-Chat est une application web (PWA) destinée aux étudiant·es des deux cours d'évaluation immobilière donnés par Simon-Pierre Boucher à l'UQO. Le professeur inscrit les courriels des étudiant·es ; chacun·e reçoit une invitation, choisit un mot de passe et converse en français avec un tuteur qui :
- s'appuie d'abord sur la matière du cours : recherche BM25 dans le HTML des sites de notes interactives (https://www.uqo-imm1003.app, https://www.uqo-imm1033.app) avec citations cliquables vers la séance et la section ;
- calcule juste : calculateurs déterministes d'évaluation (méthode du coût, dépréciation, terrain, capitalisation, comparables, six fonctions du dollar, conversions d'unités) plutôt que de l'arithmétique « de tête » ;
- produit des livrables : classeurs Excel à formules vivantes (6 gabarits ou spec libre), modification de classeurs déposés, graphiques PNG, documents Word, quiz interactifs ;
- exécute du code : Python (pandas, numpy, matplotlib…) dans un service séparé sous
sandbox-exec, sans réseau ; - va chercher le présent : données de marché, taux, coûts, règlements via Firecrawl (cache 6 h) ;
- est piloté par le professeur : tableau de bord (activité anonymisée, étudiants et invitations, contenu ingéré, consignes et annonces par cours, réglages), page admin des coûts.
Tous les appels de modèles passent par OpenRouter (anthropic/claude-fable-5.1 par défaut, repli
openai/gpt-5.5, anthropic/claude-opus-4.6 pour la réflexion approfondie, openai/gpt-5.4-nano pour les titres
et classifications, anthropic/claude-sonnet-4.6 pour la vision), en streaming SSE avec boucle agentique
(jusqu'à 12 itérations d'outils, outils exécutés en parallèle, 16 000 jetons de sortie).
2. Fonctionnalités
Pour l'étudiant·e
- Conversations par cours (IMM1003 / IMM1033), titrées automatiquement, regroupées par date dans le tiroir, renommage, suppression, régénération d'une réponse, arrêt d'un tour en cours, rétroaction 👍/👎 par message.
- Réponses en Markdown + KaTeX (formules), blocs de code colorés (shiki allégé), garde-fou
math-guardpour que « 185 000 $ » en prose ne soit pas pris pour du LaTeX. - Cartes d'outils riches : source de cours (extrait + lien), calculateur d'évaluation (tableau des étapes),
calculateur financier, conversion d'unités, éditeur Python (onglets Code / Sortie / Graphiques, modifier et
ré-exécuter, télécharger
.py), aperçu Excel multi-feuilles avec formulesƒet bouton « Modifier avec le tuteur », graphique, document Word, analyse de fichier, recherche web (sources), quiz interactif (correction immédiate, explication, score enregistré). - Dépôt de fichiers (xlsx, csv, pdf, docx, pptx, images ; 20 Mo ; 20 / jour) conservés 7 jours (30 si épinglés), panneau des artefacts de la conversation, liens de téléchargement signés.
- Préférences : tutoiement / vouvoiement, langue, thème ; consentement Loi 25, export et suppression du compte.
- PWA installable (app shell + cache lecture ≈ 2 Mo), mobile-first 375 px, héros dégradé, composer flottant.
Pour le professeur (/professeur)
- Activité : messages, conversations, étudiant·es actifs, outils appelés, sujets, rétroactions — agrégés et anonymisés (identifiants hachés), sans contenu de message.
- Étudiants : ajout en lot (collage de courriels), rôles, statut invité / activé, invitation Resend « Bienvenue sur UQO-Chat — choisis ton mot de passe », relance individuelle ou « Inviter les N jamais invité·es », mot de passe défini manuellement, suppression.
- Contenu : ingestion de fichiers (PDF, DOCX, PPTX, MD) ou d'un site de notes, activation / désactivation d'un document, réindexation.
- Réglages par cours : consignes additionnelles injectées dans le prompt, annonce affichée aux étudiant·es, échéances, modèle.
- Promotion d'un compte au rôle professeur / admin.
Pour l'admin (/admin)
- Coûts LLM (par jour, par modèle, par étudiant·e haché), budget mensuel et projection.
3. Les 13 outils du tuteur
Chaque outil = backend/app/tools/<nom>.py + schéma JSON app/tools/schemas/<nom>.json + test + carte React
frontend/src/components/tools/<nom>-card.tsx. Les arguments sont coercés avec tolérance (tools/coerce.py :
« 425 000 $ », « +3 % », « Oui » → nombres / booléens) et les appels tronqués par le modèle sont réparés ou refusés
plutôt qu'exécutés à moitié.
| Outil | Rôle | Détails |
|---|---|---|
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 |
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é |
financial_calc |
Six fonctions du dollar, VAN / TRI, âge-vie | numpy-financial |
unit_convert |
m² ↔ pi², arpent, acre, hectare, $/m² ↔ $/pi² | unités québécoises incluses |
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> |
make_chart |
Graphique rapide à partir de données | matplotlib in-process, palette UQO, PNG |
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) |
inspect_excel |
Lire un classeur (déposé ou généré) | feuilles, dimensions, en-têtes, formules, aperçu |
edit_excel |
Modifier un classeur existant | ajouter colonne / ligne / feuille / graphique, corriger une cellule, formater ; versions _v2, _v3… (tools/excel_ops.py) |
create_docx |
Document Word depuis Markdown | fiche de révision, plan d'étude, gabarit de rapport (python-docx) |
analyze_file |
Lire un fichier déposé | xlsx / csv / pdf (pdfplumber) / docx / pptx / image (modèle vision) |
web_search |
Données actuelles seulement | Firecrawl, cache 6 h, 30 / jour / étudiant·e |
generate_quiz |
Quiz interactif | questions à choix, explication, enregistrement des tentatives |
Le prompt système (app/llm/prompts/system_tutor.md + guardrails.md + course_imm10xx.md + consignes du
professeur + TOOL_HINTS dans agent.py) impose : chercher la matière avant de répondre, enchaîner les outils
(ex. search_course_content → appraisal_calc → create_excel → make_chart), modifier un fichier existant avec
edit_excel plutôt que d'en recréer un, pédagogie socratique, français québécois, pas de solution complète aux TP
en cours.
4. Architecture
Internet ── ngrok (www.uqo-chat.app, TLS) ──► uqo-chat-api :8190 FastAPI + SSE + SPA React (PM2)
│
├──► uqo-chat-sandbox :8191 sandbox-runner (PM2) — python -I -B sous sandbox-exec,
│ sans réseau, écriture confinée, rlimits, timeout ≤ 60 s
├──► SQLite data/uqochat.db (SQLAlchemy async ; Postgres via DATABASE_URL)
│ index BM25 reconstruit en mémoire au démarrage
├──► fichiers data/files/<user>/<id>.<ext> (TTL 7 j / 30 j épinglés, purge horaire)
└──► HTTPS sortant : OpenRouter (LLM), Firecrawl (web), Resend (courriels)- Un seul point d'entrée LLM :
app/llm/openrouter.py(LLMClient, SSE, repli de modèle, comptage des jetons et des coûts → tablellm_usage).app/llm/router.pychoisit le modèle (primaire / raisonnement / rapide / vision). - Boucle agentique :
app/llm/agent.py— construit le prompt (cours, préférences, fichiers, échéances, annonce), streame les deltas au client (token,tool_call,tool_result,done,error), exécute les outils en parallèle viaapp/tools/registry.py(ToolContext= utilisateur, conversation, fichiers, cours), persiste messages ettool_calls, enregistre usage et analytics. - Aucun code étudiant / LLM ne s'exécute dans le processus API : toujours
sandbox-runner(app/sandbox/client.py, jetonSANDBOX_TOKEN). - Maintenance (
main.py, toutes les heures) : purge des fichiers expirés, anonymisation des messages de plus deMESSAGE_RETENTION_MONTHS(12). - Sécurité HTTP : CORS restreint, en-têtes
nosniff,X-Frame-Options: DENY,Referrer-Policy,Permissions-Policy, HSTS en production, CSP stricte sur le HTML (script-src 'self',frame-ancestors 'none'). - Frontend servi par l'API :
frontend/distmonté sur/assets+ repli SPA (GET/HEADsur toute route →index.html), ce qui permet les vérifications d'uptime enHEAD.
Les manifestes Kubernetes (k8s/) et docker-compose.yml reproduisent la cible décrite dans la spécification
(Postgres + pgvector, Redis, MinIO, sandbox durci network_mode: none, NetworkPolicy deny-all, HPA) pour un futur
cluster ; la production actuelle tourne sur un nœud Mac du cluster via mld.
5. Arborescence du dépôt
uqo-chat/
├── README.md · CLAUDE.md · Makefile · .env.example · docker-compose.yml
├── .github/workflows/ ci.yml (ruff + pytest + tsc + build + trivy) · deploy.yml (images ghcr + kubectl, non utilisé en prod)
├── deploy/uqo-chat.manifest.json gabarit du manifeste mld (les secrets sont dans M1M32:~/dispatch/apps/uqo-chat.json)
├── backend/ Python 3.12 · FastAPI · SQLAlchemy async
│ ├── pyproject.toml dépendances (fastapi, uvicorn, pydantic, sqlalchemy, aiosqlite, asyncpg, httpx[http2],
│ │ structlog, python-jose, passlib, openpyxl, pandas, numpy(-financial), matplotlib,
│ │ pdfplumber, python-docx, python-pptx, selectolax, markdown-it-py, aiosmtplib, orjson)
│ ├── Dockerfile
│ ├── app/
│ │ ├── main.py application, middlewares, SPA, tâche de maintenance
│ │ ├── db.py moteur async, SessionLocal, init_db (create_all + _add_missing_columns)
│ │ ├── models/ User, MagicLink, Course, Conversation, Message, ToolCall, StoredFile, Quiz, QuizAttempt,
│ │ │ CourseDocument, CourseChunk, AnalyticsEvent, LLMUsage, AppSetting
│ │ ├── api/v1/ health, auth, conversations, chat, files, quiz, courses, professor, admin
│ │ ├── core/ config (Settings), logging (structlog, masquage content/text/email), security (JWT,
│ │ │ PBKDF2, hachage d'identifiants), ratelimit (mémoire), cache
│ │ ├── llm/ openrouter.py, agent.py, router.py, schemas.py, prompts/*.md
│ │ ├── rag/ ingest.py (HTML des sites / PDF / DOCX / PPTX / MD → chunks), chunking.py, retriever.py (BM25)
│ │ ├── sandbox/client.py client HTTP du sandbox-runner
│ │ ├── services/ users, invites, mail (Resend + SMTP), conversations, files, courses, analytics, costs
│ │ └── tools/ registry, all, coerce, excel_spec, excel_ops, 13 outils, schemas/*.json, excel_templates/ (6)
│ ├── scripts/ seed.py (cours + professeur), ingest.py (--course --site | --path)
│ └── tests/ 41 tests (auth/invitations respx, SSE OpenRouter, RAG, préfiltre sandbox, outils)
├── sandbox-runner/ service FastAPI minimal : POST /run {code, files_in, timeout_s} · runner.py (sandbox-exec,
│ rlimits, prélude matplotlib Agg, capture des figures, troncature 50 Ko) · Dockerfile · requirements.txt
├── frontend/ React 18 · Vite 5 · TypeScript · Tailwind 3 · PWA
│ ├── src/app/router.tsx /connexion · /mot-de-passe · /confidentialite · /professeur · /admin · / · /c/:id
│ ├── src/components/ chat/ (chat-view, composer, message-bubble, streaming-text, code-block, tool-call-timeline,
│ │ empty-state) · tools/ (13 cartes + tool-card, artifact-chips) · layout/ (app-shell, sidebar) · ui/
│ ├── src/features/ auth/ (login, set-password, privacy, settings) · conversations/artifacts-panel ·
│ │ professor/ (professor-page, admin-costs-page)
│ ├── src/stores/ zustand : auth, chat, ui (dont ui.draft pour « Modifier avec le tuteur »)
│ ├── src/lib/ api.ts (fetch + SSE), types.ts, format.ts, math-guard.ts (+ test), cn.ts
│ ├── src/theme/uqo.ts · src/i18n/fr-CA.json · src/index.css · tailwind.config.ts · vite.config.ts (PWA, proxy /api)
│ └── public/ uqo-logo.png, uqo-logo-white.png, icons/ (favicon, 192/512, maskable, apple-touch, og.png)
├── k8s/ base/ (namespace, api, web, worker, sandbox-runner, postgres, redis, minio, services,
│ networkpolicies, hpa) · overlays/staging|prod · ngrok/ (operator, ingress)
├── content/ (ignoré) copies locales des sites de notes pour l'ingestion
└── data/ (ignoré) uqochat.db + files/6. Backend (FastAPI)
Préfixe commun /api/v1. Authentification par JWT (Authorization: Bearer, 24 h) + jeton de
rafraîchissement (30 j). Documentation OpenAPI disponible en développement seulement (/api/docs).
| Groupe | Routes | Notes |
|---|---|---|
| Santé | GET /health · GET /ready |
ready renvoie le nombre de passages indexés |
| 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 |
| Moi | GET /me · PATCH /me/preferences · POST /me/password · POST /me/consent · DELETE /me |
suppression = anonymisation + purge des fichiers |
| Cours | GET /courses |
cours actifs, annonces, échéances |
| Conversations | GET/POST /conversations · GET/PATCH/DELETE /conversations/{id} · POST /conversations/{id}/messages/{mid}/feedback |
|
| 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 |
| 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 |
| Quiz | GET /quiz/{id} · POST /quiz/{id}/answers |
|
| 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 |
| Admin | GET /costs |
rôle admin |
Modèle de données (SQLAlchemy, create_all au démarrage + ajout des colonnes manquantes) :
| Table | Contenu |
|---|---|
users |
courriel, rôle (student / professor / admin), mot de passe PBKDF2, préférences JSON, consentement, invited_at, dernier accès |
magic_links |
jetons à usage unique (purpose = invite / reset / legacy), expiration |
courses |
IMM1003, IMM1033 : libellé, consignes du professeur, annonce, échéances, modèle |
conversations · messages · tool_calls |
fil, rôle, contenu, jetons, coût, rétroaction, appels d'outils (args, résultat, durée) |
files |
fichiers déposés / générés : propriétaire, conversation, type, taille, hachage, épinglé, expiration |
quizzes · quiz_attempts |
quiz générés et tentatives |
course_documents · course_chunks |
documents ingérés (site, PDF…) et passages BM25 |
analytics_events |
événements anonymisés (identifiant haché) |
llm_usage |
usage par appel : modèle, jetons entrée / sortie, coût USD |
app_settings |
réglages clé-valeur modifiables à chaud |
Limites par étudiant·e (config) : 60 messages / h, 400 / jour, 10 exécutions sandbox / h, 30 recherches web /
jour, 20 dépôts / jour, 20 Mo par fichier. Dépassement → 429 avec message français.
Journalisation : structlog JSON en production ; les clés content, text, email… sont masquées
(core/logging.py). Jamais de contenu de message dans les logs.
7. Authentification, rôles et comptes
Flux v0.5 (le code d'accès au cours des versions précédentes est supprimé) :
- Le professeur ajoute les courriels dans Étudiants (
POST /professor/students, création en lot). Les comptes créés par le professeur sont acceptés même hors@uqo.ca; sinonALLOWED_EMAIL_DOMAINSs'applique. - Invitation (
send_invitations,POST /students/{id}/invite,POST /students/invite-all) : courriel Resend « Bienvenue sur UQO-Chat — choisis ton mot de passe » avec lien/mot-de-passe?token=…(purpose=invite,INVITE_TTL_DAYS= 14). Gabarits HTML aux couleurs UQO dansservices/mail.py(Resend/emailset/emails/batch≤ 100 ; repli SMTPaiosmtplib). - L'étudiant·e choisit son mot de passe (
GET /auth/password-tokenvalide le jeton,POST /auth/set-password), puis se connecte (POST /auth/login, PBKDF2). - « Première connexion ou mot de passe oublié » →
POST /auth/forgot: lienpurpose=resetdeRESET_TTL_MINUTES= 60, envoyé uniquement aux adresses inscrites ou listées dansPROFESSOR_EMAILS/ADMIN_EMAILS/INVITED_EMAILS(sinon404avec un indice : « adresse non inscrite » ou « adresse non admise »). Limitation de débit : 20 demandes / h par IP, 5 / h par adresse. En dev, la réponse contientdev_link. - Sans
RESEND_API_KEYni SMTP, les liens sont affichés dans le tableau de bord pour copie manuelle.
Rôles : student (chat), professor (tableau de bord, étudiants, contenu, réglages), admin (coûts, promotion).
Les adresses de PROFESSOR_EMAILS / ADMIN_EMAILS obtiennent leur rôle à la connexion.
État des comptes en production au 2026-09-06 : le professeur (boucsi02@uqo.ca) a son mot de passe ; l'admin
(spbou4@icloud.com) a reçu son courriel « choisis ton mot de passe » ; 19 étudiant·es sont inscrits sans avoir été
invités — le professeur déclenche l'envoi via « Inviter les 19 jamais invité·es ».
8. RAG sur les notes de cours
- Source : le HTML généré des sites de notes interactives (
dist/<cours>/seance/NN/index.html, dépôtsuqo-imm1003/uqo-imm1033), copié sur le nœud dans~/apps/uqo-chat/content/<cours>/. Aussi PDF, DOCX, PPTX, MD déposés par le professeur. - Ingestion (
rag/ingest.py) :selectolaxnettoie le HTML (scripts, SVG, widgets, navigation), reconvertit le KaTeX en$…$via l'annotation MathML, découpe par section (chunking.py, chevauchement), calcule un hachage par passage, écritcourse_documents+course_chunksavec l'URL publique (SITE_URLS) et l'ancre. - Index : BM25 en mémoire (
rag/retriever.py), reconstruit au démarrage (/readyrenvoie le nombre de passages : 714 pour les deux cours) et après chaque ingestion. - Embeddings : optionnels via un endpoint OpenAI-compatible (
EMBEDDINGS_BASE_URL,MODEL_EMBEDDINGS) ; OpenRouter n'en expose aucun (vérifié 2026-09-05), donc BM25 seul en production. - Après un rebuild des sites de notes, ré-ingérer (voir §15).
9. Bac à sable Python (sandbox-runner)
Service FastAPI minimal (server.py) : GET /healthz, POST /run {code ≤ 200 000 car., files_in[], timeout_s 1–60},
jeton SANDBOX_TOKEN en Authorization: Bearer, SANDBOX_MAX_PARALLEL = 4 exécutions simultanées (pool de threads +
sémaphore).
runner.py — couches d'isolation :
| Couche | Détail |
|---|---|
| macOS | profil sandbox-exec (seatbelt) : réseau interdit, écriture uniquement dans le répertoire de la course, lecture de ~ bloquée |
| Linux (conteneur) | pod durci attendu (NetworkPolicy deny-all, rootfs lecture seule, cap_drop ALL, pids_limit 64, 512 Mo) |
| 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 |
| 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 |
Les fichiers déposés et les artefacts déjà générés dans la conversation (classeurs, graphiques, documents)
sont copiés dans inputs/ avant l'exécution ; les fichiers produits reviennent comme artefacts.
10. Frontend (React PWA)
- Pile : React 18, Vite 5, TypeScript 5, Tailwind 3, zustand, TanStack Query, react-router 6, react-markdown +
remark-gfm + remark-math + rehype-katex,
shiki/core(langages ciblés ; le paquet complet précachait 10 Mo), Radix (dialog, dropdown, tooltip), lucide-react, recharts,@microsoft/fetch-event-source(SSE avec en-têtes). - Routes :
/connexion,/mot-de-passe(choix / réinitialisation),/confidentialite,/professeur(garde rôle),/admin(garde rôle),/et/c/:id(chat),*→/. - Marque : couleur
#0F6180alignée sur le mot-symbole officiel UQO (public/uqo-logo*.png, recoloré depuis 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. - Mobile-first : cible 375 px ; tiroir groupé par date, bulles et composer flottant, squelettes de chargement, bouton de défilement dégagé du composer, lien « mot de passe oublié » sous le champ sans retour à la ligne.
- PWA (
vite-plugin-pwa) : app shell + cache lecture (≈ 2 Mo précachés), manifest, icônes maskable. lib/math-guard.ts: applique la règle Pandoc pour ne rendre en LaTeX que les$…$plausibles (test vitest).- Build :
tsc --noEmit && vite build→frontend/distservi par l'API en production.
11. Configuration (variables d'environnement)
Lues par pydantic-settings depuis .env (racine ou backend/) puis l'environnement ; gabarit complet dans
.env.example. Les valeurs de production sont dans le manifeste M1M32:~/dispatch/apps/uqo-chat.json (jamais dans
le dépôt).
| Groupe | Variables (défaut) |
|---|---|
| App | APP_ENV (development), APP_URL, PORT (8190), CORS_ORIGINS, LOG_LEVEL, DATA_DIR (./data), FRONTEND_DIST, COURSES (IMM1003,IMM1033), TERM_LABEL |
| 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) |
| Embeddings (optionnel) | EMBEDDINGS_BASE_URL, EMBEDDINGS_API_KEY, MODEL_EMBEDDINGS |
| Web | FIRECRAWL_API_KEY, FIRECRAWL_BASE_URL, WEB_SEARCH_CACHE_TTL_S (21600) |
| Données | DATABASE_URL (vide = SQLite DATA_DIR/uqochat.db), REDIS_URL (vide = mémoire) |
| 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 |
| 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) |
| 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 |
| 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) |
| Frontend (build) | VITE_API_URL (/api/v1), VITE_USE_OFFICIAL_LOGO, VITE_COURSES |
Le domaine uqo-chat.app est vérifié chez Resend (DNS GoDaddy) ; le CNAME www pointe vers ngrok.
12. Développement local
git clone gitsrv:~/srv/git/uqo-chat.git && cd uqo-chat
cp .env.example .env # OPENROUTER_API_KEY, FIRECRAWL_API_KEY, RESEND_API_KEY, JWT_SECRET, PROFESSOR_EMAILS…
make setup # uv venv 3.12 backend + sandbox-runner, npm install
make migrate # schéma (create_all)
make seed PROF=prof@uqo.ca # cours + compte professeur
make sandbox & # :8191
make api & # :8190 (sert frontend/dist s'il existe ; /api/docs en dev)
make web # :5173, proxy /api → 8190
make ingest COURSE=imm1033 SITE=~/Desktop/Academique/UQO/UQO_COURS/_Site_web/dist/imm1033
make ingest COURSE=imm1003 SITE=~/Desktop/Academique/UQO/UQO_COURS/_Site_web/dist/imm1003Prérequis : Python 3.12 via uv, Node 22, macOS pour sandbox-exec (sur Linux, utiliser docker compose up
qui isole le sandbox avec network_mode: none). En dev, POST /auth/forgot renvoie dev_link pour se connecter
sans courriel.
13. Tests, lint, CI
make test # pytest (41 tests) + tsc --noEmit
make lint # ruff (E, F, I, B, UP ; ligne 100) + tsc
cd frontend && npm test # vitest (math-guard)
python3 /tmp/uqo-qa/qa.py # QA Playwright : connexion + chat + captures 375/1440 + détection d'overflow (script local)Tests backend : invitations et mots de passe (respx simule Resend), flux SSE OpenRouter, RAG (ingestion HTML +
BM25), préfiltre du sandbox, coercition et normalisation de specs Excel, taux de la grille de comparables,
create_excel, financial_calc, nouveaux outils (make_chart, create_docx…).
CI GitHub Actions (ci.yml, si le dépôt est poussé sur GitHub) : ruff + pytest, npm ci && npm run build,
construction des deux images Docker et analyse Trivy. deploy.yml (images ghcr + kubectl apply -k) correspond à la
cible Kubernetes et n'est pas utilisé pour la production actuelle.
14. Déploiement sur le cluster MacLustr (mld)
L'app est orchestrée par maclustr-dispatch (mld) depuis la passerelle M1M32.
Manifeste (deploy/uqo-chat.manifest.json = gabarit ; le vrai, avec secrets : M1M32:~/dispatch/apps/uqo-chat.json) :
| Clé | Valeur |
|---|---|
domain / port / health_path |
www.uqo-chat.app / 8190 / /api/v1/ready |
requires |
runtimes pm2, ngrok, uv, uv-python@3.12 · 3 Go RAM · ports 8190, 8191 (≈ 900 Mo observés) |
placement |
prefer: M4M64a, avoid: M3U96b (réservé hfmarketdata), M1M32 (passerelle) |
sync_excludes |
.venv/, data/, content/, .env, frontend/node_modules/, caches, .git/ |
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) |
ngrok |
PM2 uqo-chat-ngrok → https://www.uqo-chat.app |
hooks.post_sync |
uv venv + uv pip install -e . (backend), uv pip install -r requirements.txt (sandbox), mkdir -p data/files |
Procédure depuis le laptop (le dépôt = source de vérité ; ne jamais éditer la copie du nœud) :
make test lint build # vérifie puis produit frontend/dist
git add -A && git commit -m "…" && git push origin main
~/Desktop/cluster-skill/mld stage $PWD uqo-chat # laptop → M1M32:~/dispatch/stage/uqo-chat/dir
~/Desktop/cluster-skill/mld deploy uqo-chat # nœud par score (ou --node M4M64a) ; santé locale + publique ; registre
# raccourci équivalent : make deploy [NODE=M4M64a]data/ (SQLite + fichiers) et content/ (sites ingérés) sont exclus de la synchronisation : ils persistent sur
le nœud. Un mld move uqo-chat --to <nœud> déplace l'app mais il faut alors recopier data/ et content/
manuellement (ou ré-ingérer).
15. Exploitation
NODE=M4M64a # vérifier avec : ~/Desktop/cluster-skill/mld status | grep uqo-chat
ssh $NODE 'pm2 ls | grep uqo-chat; pm2 logs uqo-chat-api --lines 100 --nostream'
ssh $NODE 'pm2 restart uqo-chat-api' # après changement de manifeste / env
curl -s https://www.uqo-chat.app/api/v1/ready # {"ok":true,"chunks":714}
# Ré-ingestion des notes de cours après un rebuild des sites (dépôts uqo-imm1003 / uqo-imm1033)
scp -r ~/Desktop/Academique/UQO/UQO_COURS/_Site_web/dist/imm10{03,33} $NODE:~/apps/uqo-chat/content/
ssh $NODE 'cd ~/apps/uqo-chat/backend && for c in imm1003 imm1033; do \
DATA_DIR=~/apps/uqo-chat/data .venv/bin/python -m scripts.ingest --course $c --site ../content/$c; done'
ssh $NODE 'pm2 restart uqo-chat-api'
# Sauvegarde des données
ssh $NODE 'sqlite3 ~/apps/uqo-chat/data/uqochat.db ".backup /tmp/uqochat-$(date +%F).db"'- Registre
mld(M1M32:~/dispatch/registry.json) : nœud, IP LAN, port, santé, processus ; consommé par les apps de monitoring MacLustr (macOS / iOS). - Réglages modifiables à chaud en base (
app_settings) et depuis l'onglet Réglages du professeur. - Changer de modèle :
MODEL_*dans le manifeste M1M32,mld deploy uqo-chat(oupm2 restartavec env mis à jour). Vérifier le slug sur https://openrouter.ai/models avant.
16. Coûts LLM
- Chaque appel est enregistré dans
llm_usage(modèle, jetons, coût USD) ;/adminagrège par jour, modèle et étudiant·e (haché). BudgetLLM_MONTHLY_BUDGET_USD= 2 000 $ US ; dépassement → refus poli avec message. - Ordres de grandeur mesurés : tour simple avec RAG ≈ 0,12 $ US (≈ 30 k jetons d'entrée avec outils et passages) ; tour riche (4 outils, classeur + graphique) ≈ 0,80 $ US.
17. Vie privée, sécurité, Loi 25
- Consentement explicite à la première connexion (
POST /me/consent), page/confidentialite, export et suppression du compte (DELETE /me: anonymisation des messages, purge des fichiers). - Analytics du professeur anonymisées (identifiants hachés,
core/security.hash_user_id), aucun contenu de message ; logs sans contenu ni courriel. - Rétention : fichiers 7 j (30 j épinglés), messages anonymisés après 12 mois (tâche horaire).
- Secrets uniquement dans le manifeste M1M32 et
.envlocal (ignoré par git) ;SecretStrcôté config. - Code étudiant / LLM jamais exécuté dans l'API ; sandbox sans réseau ; CSP stricte ; JWT signés ; mots de passe PBKDF2 ;
liens à usage unique et expirants ; limitation de débit sur
/auth/forgotet sur les messages / outils. - Logo UQO : l'autorisation formelle du Service des communications de l'UQO reste à obtenir (
VITE_USE_OFFICIAL_LOGO).
18. Écarts assumés par rapport à la spécification
| Spécification (CLAUDE.md d'origine) | Réalisation actuelle | Pourquoi |
|---|---|---|
| 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 |
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) |
| 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 |
| Pods sandbox gVisor / Job k8s | Service sandbox-runner + sandbox-exec macOS + rlimits + timeout |
Équivalent local ; Dockerfile + NetworkPolicy prêts pour k8s |
| Alembic | create_all + _add_missing_columns |
Migration initiale à écrire au premier changement de schéma incompatible |
| Kubernetes + ngrok operator | mld + PM2 + agent ngrok sur un nœud Mac |
Infrastructure réelle = cluster MacLustr |
| Export PDF de conversation, hors-ligne complet | Non faits (PWA : app shell + cache lecture) | Phase 2 / 3 |
19. Feuille de route
Export PDF d'une conversation ; file d'attente hors-ligne (Background Sync) ; évaluation pédagogique automatique en
CI (40 questions par cours, juge MODEL_FAST) ; tests adverses complets du sandbox ; Alembic ; migration éventuelle
Postgres + pgvector ; autorisation officielle du logo UQO ; alignement des numéros de version (pyproject,
package.json) sur v0.5.
20. Historique des versions
| Date | Commit | Version | Contenu |
|---|---|---|---|
| 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 |
| 2026-09-06 | afb5ca4 |
HEAD accepté sur les routes SPA (vérifications d'uptime) |
|
| 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 |
| 2026-09-06 | 9e6193a |
Grille de comparables : ajustements par formule (sujet + taux unitaires) ; correction du collage de courriels en lot | |
| 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 | |
| 2026-09-06 | 7a51d30 · d896f91 |
anthropic/claude-fable-5.1 modèle par défaut ; budget mensuel 2 000 $ US |
|
| 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 ») |
| 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) |
| 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) |
| 2026-09-06 | — | README détaillé (ce document) |
21. Pièges connus
sandbox-execbloque bien le réseau et la lecture de~, maisRLIMIT_AScasse numpy sur macOS → désactivé sur Darwin.shikicomplet précache 10 Mo dans la PWA → utilisershiki/coreavec les langages ciblés.- Les montants « 185 000 $ » en prose cassent
remark-math→frontend/src/lib/math-guard.ts(règle Pandoc). - Les gros arguments d'outils étaient tronqués à 4 k jetons de sortie (« JSON invalide ») → 16 k + réparation JSON + refus d'exécuter un appel tronqué.
- Le manifeste
deploy/du dépôt n'a que des gabarits{{…}}: ne pas le copier tel quel sur M1M32 sans renseigner les secrets. - Après
mld move,data/etcontent/ne suivent pas (exclus du sync) : les recopier ou ré-ingérer. - PM2 sous zsh :
pm2n'est pas dans le PATH des hooks → les hooks exportent~/.local/binet/opt/homebrew/bin.
22. Droits et licence
© 2026 Simon-Pierre Boucher. Dépôt privé, tous droits réservés. Contenu pédagogique indexé : notes de cours IMM1003 / IMM1033 © Simon-Pierre Boucher, UQO. Le nom et le logo UQO appartiennent à l'Université du Québec en Outaouais. Modèles fournis par OpenRouter (Anthropic, OpenAI) ; recherche web par Firecrawl ; courriels par Resend.
23. Contact
Simon-Pierre Boucher
Courriel : contact@spboucher.ai
Git personnel : https://git.spboucher.ai (spbgit, dépôt uqo-chat)
Application : https://www.uqo-chat.app · notes de cours : https://www.uqo-imm1003.app · https://www.uqo-imm1033.app