# 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`](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 1. [Vue d'ensemble](#1-vue-densemble) 2. [Fonctionnalités](#2-fonctionnalités) 3. [Les 13 outils du tuteur](#3-les-13-outils-du-tuteur) 4. [Architecture](#4-architecture) 5. [Arborescence du dépôt](#5-arborescence-du-dépôt) 6. [Backend (FastAPI)](#6-backend-fastapi) 7. [Authentification, rôles et comptes](#7-authentification-rôles-et-comptes) 8. [RAG sur les notes de cours](#8-rag-sur-les-notes-de-cours) 9. [Bac à sable Python (`sandbox-runner`)](#9-bac-à-sable-python-sandbox-runner) 10. [Frontend (React PWA)](#10-frontend-react-pwa) 11. [Configuration (variables d'environnement)](#11-configuration-variables-denvironnement) 12. [Développement local](#12-développement-local) 13. [Tests, lint, CI](#13-tests-lint-ci) 14. [Déploiement sur le cluster MacLustr (`mld`)](#14-déploiement-sur-le-cluster-maclustr-mld) 15. [Exploitation](#15-exploitation) 16. [Coûts LLM](#16-coûts-llm) 17. [Vie privée, sécurité, Loi 25](#17-vie-privée-sécurité-loi-25) 18. [Écarts assumés par rapport à la spécification](#18-écarts-assumés-par-rapport-à-la-spécification) 19. [Feuille de route](#19-feuille-de-route) 20. [Historique des versions](#20-historique-des-versions) 21. [Pièges connus](#21-pièges-connus) 22. [Droits et licence](#22-droits-et-licence) 23. [Contact](#23-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-guard` pour 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/.py` + schéma JSON `app/tools/schemas/.json` + test + carte React `frontend/src/components/tools/-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/` | | `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//. (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 → table `llm_usage`). `app/llm/router.py` choisit 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 via `app/tools/registry.py` (`ToolContext` = utilisateur, conversation, fichiers, cours), persiste messages et `tool_calls`, enregistre usage et analytics. - **Aucun code étudiant / LLM ne s'exécute dans le processus API** : toujours `sandbox-runner` (`app/sandbox/client.py`, jeton `SANDBOX_TOKEN`). - **Maintenance** (`main.py`, toutes les heures) : purge des fichiers expirés, anonymisation des messages de plus de `MESSAGE_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/dist` monté sur `/assets` + repli SPA (`GET`/`HEAD` sur toute route → `index.html`), ce qui permet les vérifications d'uptime en `HEAD`. 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é) : 1. 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` ; sinon `ALLOWED_EMAIL_DOMAINS` s'applique. 2. 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 dans `services/mail.py` (Resend `/emails` et `/emails/batch` ≤ 100 ; repli SMTP `aiosmtplib`). 3. L'étudiant·e choisit son mot de passe (`GET /auth/password-token` valide le jeton, `POST /auth/set-password`), puis se connecte (`POST /auth/login`, PBKDF2). 4. « Première connexion ou mot de passe oublié » → `POST /auth/forgot` : lien `purpose=reset` de `RESET_TTL_MINUTES` = 60, envoyé **uniquement** aux adresses inscrites ou listées dans `PROFESSOR_EMAILS` / `ADMIN_EMAILS` / `INVITED_EMAILS` (sinon `404` avec 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 contient `dev_link`. 5. Sans `RESEND_API_KEY` ni 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//seance/NN/index.html`, dépôts `uqo-imm1003` / `uqo-imm1033`), copié sur le nœud dans `~/apps/uqo-chat/content//`. Aussi PDF, DOCX, PPTX, MD déposés par le professeur. - **Ingestion** (`rag/ingest.py`) : `selectolax` nettoie 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, écrit `course_documents` + `course_chunks` avec l'URL publique (`SITE_URLS`) et l'ancre. - **Index** : **BM25 en mémoire** (`rag/retriever.py`), reconstruit au démarrage (`/ready` renvoie 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 `#0F6180` aligné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/dist` servi 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 ), `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 ```bash 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/imm1003 ``` Pré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 ```bash 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**) : ```bash 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 ` déplace l'app mais il faut alors recopier `data/` et `content/` manuellement (ou ré-ingérer). ## 15. Exploitation ```bash 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` (ou `pm2 restart` avec 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) ; `/admin` agrège par jour, modèle et étudiant·e (haché). Budget `LLM_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 `.env` local (ignoré par git) ; `SecretStr` cô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/forgot` et 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-exec` bloque bien le réseau et la lecture de `~`, mais `RLIMIT_AS` casse numpy sur macOS → désactivé sur Darwin. - `shiki` complet précache 10 Mo dans la PWA → utiliser `shiki/core` avec 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/` et `content/` ne suivent pas (exclus du sync) : les recopier ou ré-ingérer. - PM2 sous zsh : `pm2` n'est pas dans le PATH des hooks → les hooks exportent `~/.local/bin` et `/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