SPB Git

spb/uqo-chat

Public

feat: UQO-Chat v0.1.0 — tuteur IA IMM1003/IMM1033 (FastAPI + OpenRouter agent, 7 outils, sandbox, RAG, React PWA)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 6, 2026) Browse files

168 changed files +19,882 −0

168 changed files
added .env.example +58 −0
@@ -0,0 +1,58 @@
1 +# --- LLM (OpenRouter) — slugs vérifiés sur https://openrouter.ai/models le 2026-09-05
2 +OPENROUTER_API_KEY=
3 +OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
4 +MODEL_TUTOR_PRIMARY=anthropic/claude-sonnet-4.6
5 +MODEL_TUTOR_FALLBACK=openai/gpt-5.5
6 +MODEL_REASONING=anthropic/claude-opus-4.6
7 +MODEL_FAST=openai/gpt-5.4-nano
8 +MODEL_VISION=anthropic/claude-sonnet-4.6
9 +# OpenRouter n'expose pas de modèle d'embeddings : endpoint OpenAI-compatible optionnel (sinon BM25 seul)
10 +EMBEDDINGS_BASE_URL=
11 +EMBEDDINGS_API_KEY=
12 +MODEL_EMBEDDINGS=
13 +LLM_MONTHLY_BUDGET_USD=300
14 +
15 +# --- Recherche web
16 +FIRECRAWL_API_KEY=
17 +
18 +# --- Données (défaut : SQLite dans DATA_DIR ; PostgreSQL via DATABASE_URL)
19 +DATA_DIR=./data
20 +DATABASE_URL=
21 +REDIS_URL=
22 +
23 +# --- Sandbox (service séparé sandbox-runner)
24 +SANDBOX_URL=http://127.0.0.1:8191
25 +SANDBOX_TIMEOUT_S=30
26 +SANDBOX_TOKEN=
27 +
28 +# --- Auth
29 +JWT_SECRET=change-me
30 +ALLOWED_EMAIL_DOMAINS=uqo.ca
31 +INVITED_EMAILS=
32 +PROFESSOR_EMAILS=prof@uqo.ca
33 +ADMIN_EMAILS=
34 +# Code d'accès du cours (connexion sans SMTP) — laisser vide pour imposer le lien magique
35 +ACCESS_CODE=
36 +SMTP_HOST=
37 +SMTP_PORT=587
38 +SMTP_USER=
39 +SMTP_PASSWORD=
40 +SMTP_FROM=no-reply@uqo-chat.app
41 +
42 +# --- App
43 +APP_ENV=development
44 +APP_URL=http://localhost:8190
45 +PORT=8190
46 +CORS_ORIGINS=http://localhost:5173,http://localhost:8190
47 +LOG_LEVEL=INFO
48 +TERM_LABEL=Automne 2026
49 +COURSES=IMM1003,IMM1033
50 +
51 +# --- Frontend (build-time)
52 +VITE_API_URL=/api/v1
53 +VITE_USE_OFFICIAL_LOGO=false
54 +VITE_COURSES=IMM1003,IMM1033
55 +
56 +# --- ngrok (operator k8s ou agent sur le nœud)
57 +NGROK_API_KEY=
58 +NGROK_AUTHTOKEN=
added .github/workflows/ci.yml +25 −0
@@ -0,0 +1,25 @@
1 +name: ci
2 +on: { pull_request: {}, push: { branches: [main] } }
3 +jobs:
4 + backend:
5 + runs-on: ubuntu-latest
6 + steps:
7 + - uses: actions/checkout@v4
8 + - uses: astral-sh/setup-uv@v3
9 + - run: cd backend && uv venv --python 3.12 .venv && uv pip install --python .venv/bin/python -e ".[dev]"
10 + - run: cd backend && .venv/bin/ruff check app scripts tests && .venv/bin/pytest -q
11 + frontend:
12 + runs-on: ubuntu-latest
13 + steps:
14 + - uses: actions/checkout@v4
15 + - uses: actions/setup-node@v4
16 + with: { node-version: 22, cache: npm, cache-dependency-path: frontend/package-lock.json }
17 + - run: cd frontend && npm ci && npm run build
18 + images:
19 + needs: [backend, frontend]
20 + runs-on: ubuntu-latest
21 + steps:
22 + - uses: actions/checkout@v4
23 + - run: docker build -f backend/Dockerfile -t uqo-chat-api . && docker build -t uqo-chat-sandbox sandbox-runner
24 + - uses: aquasecurity/trivy-action@master
25 + with: { image-ref: uqo-chat-api, severity: "CRITICAL,HIGH", exit-code: "0" }
added .github/workflows/deploy.yml +23 −0
@@ -0,0 +1,23 @@
1 +name: deploy
2 +on: { workflow_dispatch: { inputs: { env: { description: staging|prod, default: staging } } } }
3 +jobs:
4 + deploy:
5 + runs-on: ubuntu-latest
6 + permissions: { packages: write, contents: read }
7 + steps:
8 + - uses: actions/checkout@v4
9 + - uses: docker/login-action@v3
10 + with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
11 + - run: |
12 + docker build -f backend/Dockerfile -t ghcr.io/${{ github.repository_owner }}/uqo-chat-api:${{ github.sha }} .
13 + docker build -t ghcr.io/${{ github.repository_owner }}/uqo-chat-sandbox:${{ github.sha }} sandbox-runner
14 + docker push ghcr.io/${{ github.repository_owner }}/uqo-chat-api:${{ github.sha }}
15 + docker push ghcr.io/${{ github.repository_owner }}/uqo-chat-sandbox:${{ github.sha }}
16 + - uses: azure/setup-kubectl@v4
17 + - run: |
18 + echo "${{ secrets.KUBECONFIG }}" > kubeconfig && export KUBECONFIG=kubeconfig
19 + kubectl apply -k k8s/overlays/${{ inputs.env }}
20 + kubectl -n uqo-chat set image deployment/api api=ghcr.io/${{ github.repository_owner }}/uqo-chat-api:${{ github.sha }}
21 + kubectl -n uqo-chat set image deployment/sandbox-runner sandbox=ghcr.io/${{ github.repository_owner }}/uqo-chat-sandbox:${{ github.sha }}
22 + kubectl -n uqo-chat rollout status deployment/api
23 + kubectl -n uqo-chat run smoke --rm -i --restart=Never --image=curlimages/curl -- -fsS http://api:8190/api/v1/ready
added .gitignore +19 −0
@@ -0,0 +1,19 @@
1 +.env
2 +.env.*
3 +!.env.example
4 +data/
5 +**/.venv/
6 +**/__pycache__/
7 +**/.pytest_cache/
8 +**/.ruff_cache/
9 +**/.mypy_cache/
10 +*.egg-info/
11 +frontend/node_modules/
12 +frontend/dist/
13 +frontend/dev-dist/
14 +content/**/*.pdf
15 +content/**/*.pptx
16 +content/**/*.docx
17 +content/**/dist/
18 +.DS_Store
19 +*.log
added CLAUDE.md +26 −0
@@ -0,0 +1,26 @@
1 +# CLAUDE.md — UQO-Chat (copie de travail)
2 +
3 +La spécification d'origine (28 sections) est la référence produit ; ce fichier résume ce qui est
4 +**implémenté**, les **écarts assumés** et les règles à respecter en travaillant dans ce dépôt.
5 +Voir `README.md` pour le démarrage et le déploiement.
6 +
7 +## Règles d'or (inchangées)
8 +1. Aucun code étudiant/LLM ne s'exécute dans le processus API : toujours `sandbox-runner` (`app/sandbox/client.py`).
9 +2. Un seul point d'entrée LLM : `app/llm/openrouter.py` → `LLMClient`.
10 +3. Slugs de modèles = config (`app/core/config.py`, env). Vérifier https://openrouter.ai/models avant d'en changer (vérifiés 2026-09-05 : `anthropic/claude-sonnet-4.6`, `openai/gpt-5.5`, `anthropic/claude-opus-4.6`, `openai/gpt-5.4-nano`).
11 +4. Chaque outil = `app/tools/<nom>.py` + `app/tools/schemas/<nom>.json` + test + carte `frontend/src/components/tools/<nom>-card.tsx`.
12 +5. Streaming SSE partout (`app/api/v1/chat.py`), français dans l'UI/prompts, anglais dans le code.
13 +6. Jamais de contenu de message dans les logs (`app/core/logging.py` masque `content`, `text`, `email`…).
14 +7. Mobile-first (375 px) ; QA Playwright : `python3 /tmp/uqo-qa/qa.py` (captures 375/1440, détection d'overflow).
15 +
16 +## État (v0.1.0, 2026-09-06)
17 +- Backend FastAPI 3.12 : auth (lien magique **ou** code d'accès), conversations, chat SSE avec boucle agentique (8 itérations, outils en parallèle), 7 outils, RAG BM25 sur les sites de notes (`rag/ingest.py` lit le HTML généré `dist/<cours>/seance/NN`), quiz, fichiers (TTL), tableau de bord prof (analytics anonymisées, ingestion, réglages, annonces), coûts admin, purge/anonymisation horaire.
18 +- Frontend React 18 + Vite + Tailwind : chat mobile/desktop, cartes d'outils, quiz interactif, panneau fichiers/sources, préférences, Loi 25 (consentement, suppression), PWA (précache ≈ 2 Mo).
19 +- Sandbox : `sandbox-runner/` (FastAPI) exécute `python -I -B` sous `sandbox-exec` (macOS) : réseau interdit, écriture confinée, rlimits, timeout, figures matplotlib capturées.
20 +- Déploiement : `mld` (PM2 `uqo-chat-api` :8190 + `uqo-chat-sandbox` :8191 + `uqo-chat-ngrok` www.uqo-chat.app). Secrets dans `M1M32:~/dispatch/apps/uqo-chat.json`.
21 +
22 +## Écarts assumés
23 +SQLite au lieu de Postgres/pgvector (Postgres accepté via `DATABASE_URL`) ; cache/limiteur mémoire au lieu de Redis ; fichiers sur disque au lieu de MinIO ; BM25 au lieu d'embeddings (OpenRouter n'en propose pas ; `EMBEDDINGS_BASE_URL` optionnel) ; `create_all` au lieu d'Alembic ; code d'accès en plus du lien magique (pas de SMTP). `k8s/` + `docker-compose.yml` décrivent la cible complète de la spec.
24 +
25 +## À faire (roadmap spec)
26 +Export PDF de conversation ; file d'attente hors-ligne (Background Sync) ; évaluation pédagogique automatique en CI (40 questions/cours, juge `MODEL_FAST`) ; tests adverses sandbox complets ; Alembic ; migration éventuelle Postgres/pgvector ; autorisation du logo officiel UQO (`VITE_USE_OFFICIAL_LOGO`).
added Makefile +48 −0
@@ -0,0 +1,48 @@
1 +.PHONY: dev api web sandbox migrate seed ingest test lint build deploy stage
2 +
3 +PY=backend/.venv/bin/python
4 +COURSE?=imm1033
5 +PATH_IN?=content/$(COURSE)
6 +
7 +dev: ## docker compose (api :8190, web :5173, sandbox :8191)
8 + docker compose up --build
9 +
10 +api: ## API locale (venv)
11 + cd backend && .venv/bin/python -m uvicorn app.main:app --reload --port 8190
12 +
13 +sandbox: ## sandbox-runner local
14 + cd sandbox-runner && .venv/bin/python -m uvicorn server:app --port 8191 --reload
15 +
16 +web: ## frontend Vite avec proxy /api
17 + cd frontend && npm run dev
18 +
19 +setup: ## venvs + npm install
20 + cd backend && uv venv --python 3.12 .venv && uv pip install --python .venv/bin/python -e ".[dev]"
21 + cd sandbox-runner && uv venv --python 3.12 .venv && uv pip install --python .venv/bin/python -r requirements.txt
22 + cd frontend && npm install
23 +
24 +migrate: ## crée/actualise le schéma (create_all ; Alembic à venir)
25 + cd backend && .venv/bin/python -c "import asyncio; from app.db import init_db; asyncio.run(init_db())"
26 +
27 +seed: ## cours IMM1003/IMM1033 (+ PROF=email pour un compte professeur)
28 + cd backend && .venv/bin/python -m scripts.seed $(if $(PROF),--professor $(PROF),)
29 +
30 +ingest: ## make ingest COURSE=imm1033 SITE=<dist dir> | PATH_IN=<dossier de fichiers>
31 + cd backend && .venv/bin/python -m scripts.ingest --course $(COURSE) $(if $(SITE),--site $(SITE),) $(if $(wildcard ../$(PATH_IN)),--path ../$(PATH_IN),)
32 +
33 +test: ## tests backend + frontend
34 + cd backend && .venv/bin/pytest -q
35 + cd frontend && npx tsc --noEmit
36 +
37 +lint:
38 + cd backend && .venv/bin/ruff check app scripts tests
39 + cd frontend && npx tsc --noEmit
40 +
41 +build: ## build frontend (dist servi par l'API)
42 + cd frontend && npm run build
43 +
44 +stage: build ## laptop → passerelle M1M32
45 + ~/Desktop/cluster-skill/mld stage $(CURDIR) uqo-chat
46 +
47 +deploy: stage ## déploiement mld (nœud choisi par score ; NODE=X pour forcer)
48 + ~/Desktop/cluster-skill/mld deploy uqo-chat $(if $(NODE),--node $(NODE),)
added README.md +69 −0
@@ -0,0 +1,69 @@
1 +# UQO-Chat
2 +
3 +**Tuteur IA pour les cours IMM1003 — Éléments d'évaluation immobilière et IMM1033 — Méthodes du coût (UQO).**
4 +Production : https://www.uqo-chat.app · Spécification complète : [`CLAUDE.md`](CLAUDE.md).
5 +
6 +Le tuteur explique la matière en citant les notes de cours officielles (RAG), exécute du Python
7 +dans un bac à sable isolé, produit des classeurs Excel à formules vivantes, cherche des données
8 +de marché actuelles (Firecrawl), analyse les fichiers déposés et génère des quiz interactifs.
9 +Tous les modèles (Claude, GPT) passent par OpenRouter avec repli automatique.
10 +
11 +## Architecture (déploiement MacLustr)
12 +
13 +```
14 +Internet ── ngrok (www.uqo-chat.app) ──► uqo-chat-api :8190 FastAPI + SSE + SPA React (PM2)
15 + │
16 + ├──► uqo-chat-sandbox :8191 (PM2, sandbox-exec : sans réseau)
17 + ├──► SQLite data/uqochat.db (index BM25 en mémoire)
18 + ├──► fichiers data/files/ (TTL 7 j / 30 j épinglés)
19 + └──► OpenRouter · Firecrawl (HTTPS sortant)
20 +```
21 +
22 +Les manifestes Kubernetes (`k8s/`) et `docker-compose.yml` reproduisent la cible décrite dans la
23 +spécification (Postgres + pgvector, Redis, MinIO, sandbox durci) pour un futur cluster ; la
24 +production actuelle tourne sur un nœud Mac du cluster via `mld` (voir `deploy/`).
25 +
26 +## Démarrage local
27 +
28 +```bash
29 +cp .env.example .env # OPENROUTER_API_KEY, FIRECRAWL_API_KEY, ACCESS_CODE…
30 +make setup # venvs uv (backend, sandbox) + npm install
31 +make sandbox & # :8191
32 +make api & # :8190 (sert frontend/dist s'il existe)
33 +make web # :5173 avec proxy /api
34 +make ingest COURSE=imm1033 SITE=~/Desktop/Academique/UQO/UQO_COURS/_Site_web/dist/imm1033
35 +make test && make lint
36 +```
37 +
38 +Connexion locale : courriel autorisé (`ALLOWED_EMAIL_DOMAINS` / `INVITED_EMAILS`) + `ACCESS_CODE`,
39 +ou lien magique si `SMTP_HOST` est configuré (en dev le lien est renvoyé dans la réponse).
40 +
41 +## Déploiement
42 +
43 +```bash
44 +make build # frontend/dist
45 +~/Desktop/cluster-skill/mld stage $PWD uqo-chat # laptop → passerelle
46 +~/Desktop/cluster-skill/mld deploy uqo-chat # nœud choisi par score (--node X pour forcer)
47 +```
48 +
49 +Le manifeste de production (`M1M32:~/dispatch/apps/uqo-chat.json`) contient les secrets ; la copie
50 +du dépôt (`deploy/uqo-chat.manifest.json`) n'a que des gabarits. Après un premier déploiement,
51 +ingérer le matériel de cours sur le nœud :
52 +
53 +```bash
54 +scp -r ~/Desktop/Academique/UQO/UQO_COURS/_Site_web/dist/imm10{03,33} <nœud>:~/apps/uqo-chat/content/
55 +ssh <nœud> 'cd ~/apps/uqo-chat/backend && for c in imm1003 imm1033; do \
56 + DATA_DIR=~/apps/uqo-chat/data .venv/bin/python -m scripts.ingest --course $c --site ../content/$c; done'
57 +pm2 restart uqo-chat-api
58 +```
59 +
60 +## Écarts assumés par rapport à la spécification
61 +
62 +| Spécification | Réalisation actuelle | Pourquoi |
63 +|---|---|---|
64 +| 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 |
65 +| Embeddings `openai/text-embedding-3-large` via OpenRouter | BM25 (index en mémoire) + embeddings optionnels via un endpoint OpenAI-compatible (`EMBEDDINGS_BASE_URL`) | OpenRouter n'expose aucun modèle d'embeddings (vérifié 2026-09-05) |
66 +| Lien magique SMTP obligatoire | Lien magique **ou** code d'accès du cours (`ACCESS_CODE`) | Aucun SMTP disponible ; le code est distribué par le professeur |
67 +| Pods sandbox gVisor / Job k8s | Service `sandbox-runner` séparé, `sandbox-exec` macOS (réseau interdit, écriture confinée) + rlimits + timeout | Équivalent local ; `sandbox-runner/Dockerfile` + NetworkPolicy prêts pour k8s |
68 +| Alembic | `create_all` au démarrage | Une migration initiale sera ajoutée au premier changement de schéma |
69 +| Export PDF de conversation, mode hors-ligne complet | Non faits (PWA : app shell + cache lecture) | Phase 2/3 |
added backend/Dockerfile +22 −0
@@ -0,0 +1,22 @@
1 +# ---- frontend build
2 +FROM node:22-alpine AS web
3 +WORKDIR /web
4 +COPY frontend/package*.json ./
5 +RUN npm ci --no-audit --no-fund
6 +COPY frontend/ ./
7 +RUN npm run build
8 +
9 +# ---- api
10 +FROM python:3.12-slim AS api
11 +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
12 +WORKDIR /srv
13 +COPY backend/pyproject.toml ./
14 +COPY backend/app ./app
15 +COPY backend/scripts ./scripts
16 +RUN pip install --no-cache-dir . && useradd -u 10001 -m app && mkdir -p /data && chown app /data
17 +COPY --from=web /web/dist /srv/frontend/dist
18 +ENV FRONTEND_DIST=/srv/frontend/dist DATA_DIR=/data PORT=8190
19 +USER 10001
20 +EXPOSE 8190
21 +HEALTHCHECK CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8190/api/v1/health')"
22 +CMD ["gunicorn", "app.main:app", "-k", "uvicorn.workers.UvicornWorker", "-w", "2", "-b", "0.0.0.0:8190", "--timeout", "320"]
added backend/app/__init__.py +1 −0
@@ -0,0 +1 @@
1 +"""UQO-Chat backend package."""
added backend/app/api/__init__.py +0 −0
added backend/app/api/v1/__init__.py +14 −0
@@ -0,0 +1,14 @@
1 +from fastapi import APIRouter
2 +
3 +from app.api.v1 import admin, auth, chat, conversations, courses, files, health, professor, quiz
4 +
5 +api_router = APIRouter(prefix="/api/v1")
6 +api_router.include_router(health.router)
7 +api_router.include_router(auth.router)
8 +api_router.include_router(conversations.router)
9 +api_router.include_router(chat.router)
10 +api_router.include_router(files.router)
11 +api_router.include_router(quiz.router)
12 +api_router.include_router(courses.router)
13 +api_router.include_router(professor.router)
14 +api_router.include_router(admin.router)
added backend/app/api/v1/admin.py +18 −0
@@ -0,0 +1,18 @@
1 +from fastapi import APIRouter, Depends
2 +
3 +from app.core.config import Settings, get_settings
4 +from app.core.security import AuthUser, require_admin
5 +from app.services import costs
6 +
7 +router = APIRouter(prefix="/admin", tags=["admin"])
8 +
9 +
10 +@router.get("/costs")
11 +async def admin_costs(days: int = 30, _: AuthUser = Depends(require_admin),
12 + settings: Settings = Depends(get_settings)) -> dict:
13 + return {"budget": await costs.budget_status(), "daily": await costs.daily_costs(days),
14 + "by_course": await costs.costs_by_course(),
15 + "models": {"primary": settings.MODEL_TUTOR_PRIMARY,
16 + "fallback": settings.MODEL_TUTOR_FALLBACK,
17 + "reasoning": settings.MODEL_REASONING, "fast": settings.MODEL_FAST,
18 + "vision": settings.MODEL_VISION}}
added backend/app/api/v1/auth.py +163 −0
@@ -0,0 +1,163 @@
1 +"""Auth: magic link (SMTP) or access code fallback; JWT cookie + bearer; /me."""
2 +
3 +from __future__ import annotations
4 +
5 +import hmac
6 +from datetime import timedelta
7 +
8 +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
9 +from pydantic import BaseModel, EmailStr
10 +
11 +from app.core.config import Settings, get_settings
12 +from app.core.ratelimit import limiter
13 +from app.core.security import (
14 + COOKIE_NAME,
15 + AuthUser,
16 + create_token,
17 + decode_token,
18 + email_allowed,
19 + get_current_user,
20 +)
21 +from app.services import mail, users
22 +
23 +router = APIRouter(tags=["auth"])
24 +
25 +
26 +class MagicLinkReq(BaseModel):
27 + email: EmailStr
28 + access_code: str | None = None
29 +
30 +
31 +class VerifyReq(BaseModel):
32 + token: str
33 +
34 +
35 +class PrefsReq(BaseModel):
36 + tutoiement: bool | None = None
37 + course: str | None = None
38 + deep: bool | None = None
39 + locale: str | None = None
40 + font_scale: float | None = None
41 + display_name: str | None = None
42 +
43 +
44 +def _set_cookie(resp: Response, token: str, settings: Settings, refresh: str | None = None) -> None:
45 + secure = not settings.is_dev
46 + resp.set_cookie(COOKIE_NAME, token, httponly=True, secure=secure, samesite="lax",
47 + max_age=settings.JWT_TTL_HOURS * 3600, path="/")
48 + if refresh:
49 + resp.set_cookie(COOKIE_NAME + "_refresh", refresh, httponly=True, secure=secure,
50 + samesite="lax", max_age=settings.REFRESH_TTL_DAYS * 86400,
51 + path="/api/v1/auth")
52 +
53 +
54 +def _issue(user_id: str, role: str, settings: Settings, resp: Response) -> dict:
55 + token = create_token(user_id, role, settings)
56 + refresh = create_token(user_id, role, settings, timedelta(days=settings.REFRESH_TTL_DAYS),
57 + kind="refresh")
58 + _set_cookie(resp, token, settings, refresh)
59 + return {"token": token, "role": role}
60 +
61 +
62 +@router.post("/auth/magic-link")
63 +async def magic_link(req: MagicLinkReq, request: Request, response: Response,
64 + settings: Settings = Depends(get_settings)) -> dict:
65 + ip = request.client.host if request.client else "?"
66 + limiter.check(f"login:{ip}", 20, 3600, "Trop de tentatives. Réessaie plus tard.")
67 + email = req.email.lower()
68 + if not email_allowed(email, settings):
69 + raise HTTPException(status.HTTP_403_FORBIDDEN,
70 + detail="Seules les adresses @uqo.ca (ou invitées) sont admises.")
71 + # Access-code path: no SMTP needed (course code handed out by the professor).
72 + if req.access_code is not None:
73 + if not settings.ACCESS_CODE or not hmac.compare_digest(req.access_code.strip(),
74 + settings.ACCESS_CODE):
75 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Code d'accès invalide.")
76 + user = await users.get_or_create_user(email, settings)
77 + out = _issue(user.id, user.role, settings, response)
78 + return {"mode": "access_code", **out, "user": _user_dict(user)}
79 + token = await users.create_magic_link(email)
80 + link = f"{settings.APP_URL}/connexion?token={token}"
81 + sent = await mail.send_magic_link(settings, email, link)
82 + out: dict = {"mode": "magic_link", "sent": sent}
83 + if not sent:
84 + if settings.is_dev:
85 + out["dev_link"] = link # local development only
86 + else:
87 + out["hint"] = ("Envoi de courriel non configuré : utilise le code d'accès du cours."
88 + if settings.ACCESS_CODE else "Envoi de courriel non configuré.")
89 + return out
90 +
91 +
92 +@router.post("/auth/verify")
93 +async def verify(req: VerifyReq, response: Response,
94 + settings: Settings = Depends(get_settings)) -> dict:
95 + email = await users.consume_magic_link(req.token)
96 + if not email:
97 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Lien invalide ou expiré.")
98 + user = await users.get_or_create_user(email, settings)
99 + out = _issue(user.id, user.role, settings, response)
100 + return {**out, "user": _user_dict(user)}
101 +
102 +
103 +@router.post("/auth/refresh")
104 +async def refresh(request: Request, response: Response,
105 + settings: Settings = Depends(get_settings)) -> dict:
106 + raw = request.cookies.get(COOKIE_NAME + "_refresh")
107 + payload = decode_token(raw, settings) if raw else None
108 + if not payload or payload.get("kind") != "refresh":
109 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Session expirée.")
110 + user = await users.get_user(str(payload["sub"]))
111 + if not user:
112 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.")
113 + return _issue(user.id, user.role, settings, response)
114 +
115 +
116 +@router.post("/auth/logout")
117 +async def logout(response: Response) -> dict:
118 + response.delete_cookie(COOKIE_NAME, path="/")
119 + response.delete_cookie(COOKIE_NAME + "_refresh", path="/api/v1/auth")
120 + return {"ok": True}
121 +
122 +
123 +@router.get("/auth/config")
124 +async def auth_config(settings: Settings = Depends(get_settings)) -> dict:
125 + return {"smtp": settings.smtp_enabled, "access_code": bool(settings.ACCESS_CODE),
126 + "domains": sorted(settings.allowed_domains), "courses": settings.courses,
127 + "term": settings.TERM_LABEL}
128 +
129 +
130 +def _user_dict(u) -> dict: # noqa: ANN001
131 + return {"id": u.id, "email": u.email, "role": u.role, "display_name": u.display_name,
132 + "preferences": u.preferences or {}, "consent_at": u.consent_at.isoformat()
133 + if u.consent_at else None}
134 +
135 +
136 +@router.get("/me")
137 +async def me(auth: AuthUser = Depends(get_current_user)) -> dict:
138 + user = await users.get_user(auth.id)
139 + if not user:
140 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.")
141 + await users.touch(user.id)
142 + return _user_dict(user)
143 +
144 +
145 +@router.patch("/me/preferences")
146 +async def patch_prefs(req: PrefsReq, auth: AuthUser = Depends(get_current_user)) -> dict:
147 + data = req.model_dump(exclude_none=True)
148 + display_name = data.pop("display_name", None)
149 + user = await users.update_preferences(auth.id, data, display_name)
150 + return _user_dict(user)
151 +
152 +
153 +@router.post("/me/consent")
154 +async def consent(auth: AuthUser = Depends(get_current_user)) -> dict:
155 + await users.set_consent(auth.id)
156 + return {"ok": True}
157 +
158 +
159 +@router.delete("/me")
160 +async def delete_me(response: Response, auth: AuthUser = Depends(get_current_user)) -> dict:
161 + await users.delete_user_data(auth.id)
162 + response.delete_cookie(COOKIE_NAME, path="/")
163 + return {"ok": True, "message_fr": "Tes données ont été supprimées."}
added backend/app/api/v1/chat.py +153 −0
@@ -0,0 +1,153 @@
1 +"""Chat SSE endpoint, stop, regenerate."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import json
7 +from collections.abc import AsyncIterator
8 +from typing import Any
9 +
10 +from fastapi import APIRouter, Depends, HTTPException, Request, status
11 +from fastapi.responses import StreamingResponse
12 +from pydantic import BaseModel, Field
13 +
14 +from app.core.config import Settings, get_settings
15 +from app.core.logging import get_logger
16 +from app.core.ratelimit import MSG_MESSAGES, limiter
17 +from app.core.security import AuthUser, get_current_user, hash_user_id
18 +from app.llm.agent import Turn, generate_title
19 +from app.services import analytics, users
20 +from app.services import conversations as conv_service
21 +
22 +log = get_logger("api.chat")
23 +router = APIRouter(tags=["chat"])
24 +
25 +ACTIVE: dict[str, Turn] = {}
26 +
27 +
28 +class SendReq(BaseModel):
29 + content: str = Field(..., min_length=1, max_length=12000)
30 + attachments: list[str] = Field(default_factory=list)
31 + deep: bool = False
32 +
33 +
34 +def _sse(event: str, data: dict[str, Any], event_id: int | None = None) -> str:
35 + head = f"id: {event_id}\n" if event_id is not None else ""
36 + return f"{head}event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
37 +
38 +
39 +async def _stream(turn: Turn, user_text: str, attachments: list[str], conv_id: str,
40 + first_message: bool, request: Request) -> AsyncIterator[str]:
41 + queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue()
42 +
43 + async def emit(event: str, data: dict[str, Any]) -> None:
44 + await queue.put((event, data))
45 +
46 + turn.emit = emit
47 + ACTIVE[conv_id] = turn
48 +
49 + async def worker() -> None:
50 + try:
51 + await asyncio.wait_for(turn.run(user_text, attachments),
52 + timeout=turn.settings.LLM_TURN_TIMEOUT_SECONDS)
53 + except TimeoutError:
54 + await emit("error", {"code": "timeout", "message_fr": "Le tour a pris trop de temps."})
55 + await emit("done", {"message_id": turn.message_id, "finish_reason": "timeout"})
56 + except Exception as exc: # noqa: BLE001
57 + log.exception("turn_failed")
58 + await emit("error", {"code": "internal", "message_fr": "Erreur interne."})
59 + await emit("done", {"message_id": turn.message_id, "finish_reason": "error"})
60 + _ = exc
61 + finally:
62 + if first_message:
63 + await generate_title(conv_id, user_text, emit)
64 + await queue.put(None)
65 +
66 + task = asyncio.create_task(worker())
67 + seq = 0
68 + try:
69 + while True:
70 + try:
71 + item = await asyncio.wait_for(queue.get(), timeout=15)
72 + except TimeoutError:
73 + yield ": ping\n\n"
74 + continue
75 + if item is None:
76 + break
77 + seq += 1
78 + yield _sse(item[0], item[1], seq)
79 + if await request.is_disconnected():
80 + turn.cancel.set()
81 + finally:
82 + ACTIVE.pop(conv_id, None)
83 + if not task.done():
84 + turn.cancel.set()
85 + try:
86 + await asyncio.wait_for(task, timeout=5)
87 + except (TimeoutError, asyncio.CancelledError):
88 + task.cancel()
89 +
90 +
91 +@router.post("/chat/{conv_id}/messages")
92 +async def send_message(conv_id: str, req: SendReq, request: Request,
93 + auth: AuthUser = Depends(get_current_user),
94 + settings: Settings = Depends(get_settings)) -> StreamingResponse:
95 + conv = await conv_service.get_conversation(conv_id, auth.id)
96 + if not conv:
97 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation introuvable.")
98 + if conv_id in ACTIVE:
99 + raise HTTPException(status.HTTP_409_CONFLICT, detail="Une réponse est déjà en cours.")
100 + user = await users.get_user(auth.id)
101 + if not user:
102 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.")
103 + if user.role == "student":
104 + limiter.check(f"msg_h:{auth.id}", settings.RATE_MESSAGES_PER_HOUR, 3600, MSG_MESSAGES)
105 + limiter.check(f"msg_d:{auth.id}", settings.RATE_MESSAGES_PER_DAY, 86400, MSG_MESSAGES)
106 + prior = await conv_service.history(conv_id, limit=1)
107 + first = len(prior) == 0
108 + await conv_service.add_message(conv_id, "user", req.content, req.attachments)
109 + asyncio.create_task(analytics.classify_and_record(hash_user_id(auth.id), conv.course_code,
110 + req.content))
111 + deep = req.deep or bool((user.preferences or {}).get("deep"))
112 + turn = Turn(conv, user, emit=None, deep=deep, settings=settings) # type: ignore[arg-type]
113 + headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"}
114 + return StreamingResponse(_stream(turn, req.content, req.attachments, conv_id, first, request),
115 + media_type="text/event-stream", headers=headers)
116 +
117 +
118 +@router.post("/chat/{conv_id}/stop")
119 +async def stop(conv_id: str, auth: AuthUser = Depends(get_current_user)) -> dict:
120 + turn = ACTIVE.get(conv_id)
121 + if turn and turn.user.id == auth.id:
122 + turn.cancel.set()
123 + return {"ok": True, "stopped": True}
124 + return {"ok": True, "stopped": False}
125 +
126 +
127 +class RegenReq(BaseModel):
128 + deep: bool = False
129 +
130 +
131 +@router.post("/chat/{conv_id}/messages/{message_id}/regenerate")
132 +async def regenerate(conv_id: str, message_id: str, req: RegenReq, request: Request,
133 + auth: AuthUser = Depends(get_current_user),
134 + settings: Settings = Depends(get_settings)) -> StreamingResponse:
135 + conv = await conv_service.get_conversation(conv_id, auth.id)
136 + if not conv:
137 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation introuvable.")
138 + if conv_id in ACTIVE:
139 + raise HTTPException(status.HTTP_409_CONFLICT, detail="Une réponse est déjà en cours.")
140 + user = await users.get_user(auth.id)
141 + if not user:
142 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Compte introuvable.")
143 + await conv_service.delete_messages_after(conv_id, message_id)
144 + hist = await conv_service.history(conv_id, limit=1)
145 + if not hist or hist[-1].role != "user":
146 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Aucun message à régénérer.")
147 + asyncio.create_task(analytics.record_event(hash_user_id(auth.id), conv.course_code,
148 + "regenerate"))
149 + turn = Turn(conv, user, emit=None, deep=req.deep, settings=settings) # type: ignore[arg-type]
150 + headers = {"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
151 + return StreamingResponse(_stream(turn, hist[-1].content, hist[-1].attachments or [], conv_id,
152 + False, request),
153 + media_type="text/event-stream", headers=headers)
added backend/app/api/v1/conversations.py +77 −0
@@ -0,0 +1,77 @@
1 +from __future__ import annotations
2 +
3 +from fastapi import APIRouter, Depends, HTTPException, Query, status
4 +from pydantic import BaseModel
5 +
6 +from app.core.config import Settings, get_settings
7 +from app.core.security import AuthUser, get_current_user
8 +from app.services import conversations as svc
9 +
10 +router = APIRouter(tags=["conversations"])
11 +
12 +
13 +class CreateReq(BaseModel):
14 + course: str | None = None
15 + title: str | None = None
16 +
17 +
18 +class PatchReq(BaseModel):
19 + title: str | None = None
20 + pinned: bool | None = None
21 + archived: bool | None = None
22 + course: str | None = None
23 +
24 +
25 +class FeedbackReq(BaseModel):
26 + feedback: str | None # up | down | null
27 +
28 +
29 +@router.get("/conversations")
30 +async def list_conversations(q: str | None = None, archived: bool = False,
31 + limit: int = Query(50, le=200), offset: int = 0,
32 + auth: AuthUser = Depends(get_current_user)) -> list[dict]:
33 + return await svc.list_conversations(auth.id, q, archived, limit, offset)
34 +
35 +
36 +@router.post("/conversations", status_code=201)
37 +async def create(req: CreateReq, auth: AuthUser = Depends(get_current_user),
38 + settings: Settings = Depends(get_settings)) -> dict:
39 + course = (req.course or settings.courses[0]).upper()
40 + if course not in settings.courses:
41 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Cours inconnu.")
42 + return await svc.create_conversation(auth.id, course, req.title)
43 +
44 +
45 +@router.get("/conversations/{conv_id}")
46 +async def get(conv_id: str, auth: AuthUser = Depends(get_current_user)) -> dict:
47 + data = await svc.get_conversation_full(conv_id, auth.id)
48 + if not data:
49 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation introuvable.")
50 + return data
51 +
52 +
53 +@router.patch("/conversations/{conv_id}")
54 +async def patch(conv_id: str, req: PatchReq, auth: AuthUser = Depends(get_current_user)) -> dict:
55 + data = await svc.update_conversation(conv_id, auth.id, title=req.title, pinned=req.pinned,
56 + archived=req.archived,
57 + course_code=req.course.upper() if req.course else None)
58 + if not data:
59 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation introuvable.")
60 + return data
61 +
62 +
63 +@router.delete("/conversations/{conv_id}")
64 +async def delete(conv_id: str, auth: AuthUser = Depends(get_current_user)) -> dict:
65 + if not await svc.delete_conversation(conv_id, auth.id):
66 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Conversation introuvable.")
67 + return {"ok": True}
68 +
69 +
70 +@router.post("/conversations/{conv_id}/messages/{message_id}/feedback")
71 +async def feedback(conv_id: str, message_id: str, req: FeedbackReq,
72 + auth: AuthUser = Depends(get_current_user)) -> dict:
73 + if req.feedback not in {None, "up", "down"}:
74 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="feedback invalide")
75 + if not await svc.set_feedback(message_id, auth.id, req.feedback):
76 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Message introuvable.")
77 + return {"ok": True}
added backend/app/api/v1/courses.py +11 −0
@@ -0,0 +1,11 @@
1 +from fastapi import APIRouter, Depends
2 +
3 +from app.core.security import AuthUser, get_current_user
4 +from app.services import courses as svc
5 +
6 +router = APIRouter(tags=["courses"])
7 +
8 +
9 +@router.get("/courses")
10 +async def list_courses(_: AuthUser = Depends(get_current_user)) -> list[dict]:
11 + return await svc.list_courses()
added backend/app/api/v1/files.py +114 −0
@@ -0,0 +1,114 @@
1 +"""Uploads, downloads (signed or authenticated), manual Python re-run."""
2 +
3 +from __future__ import annotations
4 +
5 +from fastapi import (
6 + APIRouter,
7 + Depends,
8 + File,
9 + Form,
10 + HTTPException,
11 + Query,
12 + Request,
13 + UploadFile,
14 + status,
15 +)
16 +from fastapi.responses import Response
17 +from pydantic import BaseModel, Field
18 +
19 +from app.core.config import Settings, get_settings
20 +from app.core.ratelimit import MSG_UPLOAD, limiter
21 +from app.core.security import AuthUser, get_current_user, get_optional_user, hash_user_id
22 +from app.services import files as svc
23 +from app.tools import execute_python
24 +from app.tools.registry import ToolContext
25 +
26 +router = APIRouter(tags=["files"])
27 +
28 +
29 +@router.post("/files", status_code=201)
30 +async def upload(file: UploadFile = File(...), conversation_id: str | None = Form(None),
31 + auth: AuthUser = Depends(get_current_user),
32 + settings: Settings = Depends(get_settings)) -> dict:
33 + limiter.check(f"upload:{auth.id}", settings.RATE_UPLOADS_PER_DAY, 86400, MSG_UPLOAD)
34 + data = await file.read()
35 + try:
36 + rec = await svc.store_upload(auth.id, conversation_id, file.filename or "fichier", data)
37 + except ValueError as exc:
38 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
39 + return {"file_id": rec.id, "filename": rec.filename, "type": rec.type, "size": rec.size_bytes,
40 + "url": f"/api/v1/files/{rec.id}"}
41 +
42 +
43 +@router.get("/files/{file_id}")
44 +async def download(file_id: str, request: Request, sig: str | None = Query(None),
45 + download: bool = False,
46 + auth: AuthUser | None = Depends(get_optional_user)) -> Response:
47 + rec = await svc.get_file(file_id)
48 + if not rec:
49 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Fichier introuvable.")
50 + allowed = (auth is not None and (auth.id == rec.user_id or auth.is_professor)) or \
51 + (sig is not None and svc.verify_signature(file_id, sig))
52 + if not allowed:
53 + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Accès refusé.")
54 + try:
55 + data = svc.read_bytes(rec)
56 + except FileNotFoundError as exc:
57 + raise HTTPException(status.HTTP_410_GONE, detail="Fichier expiré.") from exc
58 + disp = "attachment" if download or rec.type in {"xlsx", "file", "csv"} else "inline"
59 + from urllib.parse import quote
60 +
61 + headers = {"Content-Disposition": f"{disp}; filename*=UTF-8''{quote(rec.filename)}",
62 + "Cache-Control": "private, max-age=600"}
63 + return Response(content=data, media_type=rec.mime, headers=headers)
64 +
65 +
66 +@router.get("/files/{file_id}/link")
67 +async def signed_link(file_id: str, auth: AuthUser = Depends(get_current_user)) -> dict:
68 + rec = await svc.get_file(file_id)
69 + if not rec or (rec.user_id != auth.id and not auth.is_professor):
70 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Fichier introuvable.")
71 + return {"url": f"/api/v1/files/{file_id}?sig={svc.sign(file_id)}&download=1",
72 + "expires_in": 600}
73 +
74 +
75 +class PinReq(BaseModel):
76 + pinned: bool = True
77 +
78 +
79 +@router.post("/files/{file_id}/pin")
80 +async def pin(file_id: str, req: PinReq, auth: AuthUser = Depends(get_current_user)) -> dict:
81 + if not await svc.set_pinned(file_id, auth.id, req.pinned):
82 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Fichier introuvable.")
83 + return {"ok": True}
84 +
85 +
86 +@router.get("/conversations/{conv_id}/files")
87 +async def conversation_files(conv_id: str, auth: AuthUser = Depends(get_current_user)) -> list[dict]:
88 + rows = await svc.list_for_conversation(conv_id)
89 + return [{"file_id": r.id, "filename": r.filename, "type": r.type, "kind": r.kind,
90 + "size": r.size_bytes, "created_at": r.created_at.isoformat(), "pinned": r.pinned,
91 + "url": f"/api/v1/files/{r.id}"} for r in rows if r.user_id == auth.id]
92 +
93 +
94 +class RunReq(BaseModel):
95 + code: str = Field(..., max_length=100_000)
96 + conversation_id: str | None = None
97 +
98 +
99 +@router.post("/tools/python/run")
100 +async def run_python(req: RunReq, auth: AuthUser = Depends(get_current_user),
101 + settings: Settings = Depends(get_settings)) -> dict:
102 + """Manual re-run of a code block from the UI (same sandbox, same limits)."""
103 + file_ids: list[str] = []
104 + if req.conversation_id:
105 + recs = await svc.list_for_conversation(req.conversation_id)
106 + file_ids = [r.id for r in recs if r.kind == "upload" and r.user_id == auth.id]
107 + ctx = ToolContext(user_id=auth.id, user_id_hash=hash_user_id(auth.id),
108 + conversation_id=req.conversation_id or "", course_code="",
109 + settings=settings, role=auth.role, file_ids=file_ids)
110 + result = await execute_python.run({"code": req.code, "description": "Ré-exécution",
111 + "heavy": False}, ctx)
112 + if result.error and not result.payload:
113 + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.content)
114 + return result.payload
added backend/app/api/v1/health.py +15 −0
@@ -0,0 +1,15 @@
1 +from fastapi import APIRouter
2 +
3 +from app.rag import retriever
4 +
5 +router = APIRouter(tags=["health"])
6 +
7 +
8 +@router.get("/health")
9 +async def health() -> dict:
10 + return {"ok": True, "service": "uqo-chat"}
11 +
12 +
13 +@router.get("/ready")
14 +async def ready() -> dict:
15 + return {"ok": True, "chunks": len(retriever.index.docs)}
added backend/app/api/v1/professor.py +162 −0
@@ -0,0 +1,162 @@
1 +"""Professor dashboard: analytics, content ingestion, agent settings, announcements."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import tempfile
7 +from pathlib import Path
8 +from typing import Any
9 +
10 +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
11 +from pydantic import BaseModel
12 +from sqlalchemy import delete, select
13 +
14 +from app.core.config import Settings, get_settings
15 +from app.core.security import AuthUser, require_professor
16 +from app.db import SessionLocal
17 +from app.models import CourseChunk, CourseDocument
18 +from app.rag import retriever
19 +from app.rag.ingest import ingest_file
20 +from app.services import analytics, costs, users
21 +from app.services import courses as course_service
22 +from app.tools.all import registry
23 +
24 +router = APIRouter(prefix="/professor", tags=["professor"])
25 +
26 +INGEST_JOBS: dict[str, dict[str, Any]] = {}
27 +
28 +
29 +@router.get("/dashboard")
30 +async def dashboard(days: int = 30, _: AuthUser = Depends(require_professor)) -> dict:
31 + data = await analytics.dashboard(days)
32 + data["budget"] = await costs.budget_status()
33 + data["costs_by_course"] = await costs.costs_by_course()
34 + return data
35 +
36 +
37 +@router.get("/settings")
38 +async def get_settings_(_: AuthUser = Depends(require_professor),
39 + settings: Settings = Depends(get_settings)) -> dict:
40 + courses = await course_service.list_courses(include_private=True)
41 + return {"courses": courses, "tools": registry.names(),
42 + "models": {"primary": settings.MODEL_TUTOR_PRIMARY,
43 + "fallback": settings.MODEL_TUTOR_FALLBACK,
44 + "reasoning": settings.MODEL_REASONING, "fast": settings.MODEL_FAST},
45 + "budget_usd": settings.LLM_MONTHLY_BUDGET_USD}
46 +
47 +
48 +class CourseSettingsReq(BaseModel):
49 + extra_system_prompt: str | None = None
50 + announcement: str | None = None
51 + deadlines: list[dict[str, Any]] | None = None
52 + suggestions: list[str] | None = None
53 + settings: dict[str, Any] | None = None
54 +
55 +
56 +@router.patch("/settings/{course}")
57 +async def patch_settings(course: str, req: CourseSettingsReq,
58 + _: AuthUser = Depends(require_professor)) -> dict:
59 + data = await course_service.update_course(course, req.model_dump(exclude_none=True))
60 + if not data:
61 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Cours introuvable.")
62 + return data
63 +
64 +
65 +@router.get("/content")
66 +async def list_content(_: AuthUser = Depends(require_professor)) -> dict:
67 + async with SessionLocal() as session:
68 + rows = (await session.execute(select(CourseDocument)
69 + .order_by(CourseDocument.course_code,
70 + CourseDocument.filename))).scalars()
71 + docs = [{"id": d.id, "course": d.course_code, "filename": d.filename, "title": d.title,
72 + "visibility": d.visibility, "n_chunks": d.n_chunks,
73 + "ingested_at": d.ingested_at.isoformat()} for d in rows]
74 + return {"documents": docs, "index_size": len(retriever.index.docs), "jobs": INGEST_JOBS}
75 +
76 +
77 +async def _ingest_job(job_id: str, course: str, path: Path, visibility: str, title: str) -> None:
78 + INGEST_JOBS[job_id]["status"] = "running"
79 + try:
80 + n = await ingest_file(course, path, visibility, module=title or None)
81 + await retriever.rebuild_index()
82 + INGEST_JOBS[job_id].update({"status": "done", "chunks": n})
83 + except Exception as exc: # noqa: BLE001
84 + INGEST_JOBS[job_id].update({"status": "error", "error": str(exc)[:300]})
85 + finally:
86 + path.unlink(missing_ok=True)
87 +
88 +
89 +@router.post("/content", status_code=202)
90 +async def upload_content(file: UploadFile = File(...), course: str = Form(...),
91 + visibility: str = Form("students"), title: str = Form(""),
92 + _: AuthUser = Depends(require_professor),
93 + settings: Settings = Depends(get_settings)) -> dict:
94 + course = course.upper()
95 + if course not in settings.courses:
96 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Cours inconnu.")
97 + if visibility not in {"students", "professor_only"}:
98 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Visibilité invalide.")
99 + suffix = Path(file.filename or "doc").suffix.lower()
100 + if suffix not in {".pdf", ".docx", ".pptx", ".md", ".txt", ".tex", ".html"}:
101 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Format non pris en charge.")
102 + data = await file.read()
103 + tmp_dir = settings.DATA_DIR / "ingest"
104 + tmp_dir.mkdir(exist_ok=True)
105 + tmp = Path(tempfile.mkstemp(suffix=suffix, prefix="up_", dir=tmp_dir)[1])
106 + tmp.write_bytes(data)
107 + # keep original filename for the document record
108 + target = tmp_dir / f"{tmp.stem}__{Path(file.filename or 'doc').name}"
109 + tmp.rename(target)
110 + job_id = target.stem[:12]
111 + INGEST_JOBS[job_id] = {"status": "queued", "filename": file.filename, "course": course}
112 + asyncio.create_task(_ingest_job(job_id, course, target, visibility, title))
113 + return {"job_id": job_id}
114 +
115 +
116 +class VisibilityReq(BaseModel):
117 + visibility: str
118 +
119 +
120 +@router.patch("/content/{doc_id}")
121 +async def set_visibility(doc_id: str, req: VisibilityReq,
122 + _: AuthUser = Depends(require_professor)) -> dict:
123 + async with SessionLocal() as session:
124 + doc = await session.get(CourseDocument, doc_id)
125 + if not doc:
126 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Document introuvable.")
127 + doc.visibility = req.visibility
128 + rows = (await session.execute(select(CourseChunk)
129 + .where(CourseChunk.document_id == doc_id))).scalars()
130 + for c in rows:
131 + c.visibility = req.visibility
132 + await session.commit()
133 + await retriever.rebuild_index()
134 + return {"ok": True}
135 +
136 +
137 +@router.delete("/content/{doc_id}")
138 +async def delete_doc(doc_id: str, _: AuthUser = Depends(require_professor)) -> dict:
139 + async with SessionLocal() as session:
140 + doc = await session.get(CourseDocument, doc_id)
141 + if not doc:
142 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Document introuvable.")
143 + await session.execute(delete(CourseChunk).where(CourseChunk.document_id == doc_id))
144 + await session.delete(doc)
145 + await session.commit()
146 + await retriever.rebuild_index()
147 + return {"ok": True}
148 +
149 +
150 +class PromoteReq(BaseModel):
151 + email: str
152 + role: str = "professor"
153 +
154 +
155 +@router.post("/promote")
156 +async def promote(req: PromoteReq, _: AuthUser = Depends(require_professor)) -> dict:
157 + if req.role not in {"student", "professor"}:
158 + raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Rôle invalide.")
159 + if not await users.set_role(req.email, req.role):
160 + raise HTTPException(status.HTTP_404_NOT_FOUND,
161 + detail="Compte introuvable (la personne doit s'être connectée une fois).")
162 + return {"ok": True}
added backend/app/api/v1/quiz.py +70 −0
@@ -0,0 +1,70 @@
1 +from __future__ import annotations
2 +
3 +from typing import Any
4 +
5 +from fastapi import APIRouter, Depends, HTTPException, status
6 +from pydantic import BaseModel
7 +
8 +from app.core.security import AuthUser, get_current_user, hash_user_id
9 +from app.db import SessionLocal
10 +from app.models import Quiz, QuizAttempt
11 +from app.services import analytics
12 +
13 +router = APIRouter(tags=["quiz"])
14 +
15 +
16 +class AnswersReq(BaseModel):
17 + answers: dict[str, Any]
18 +
19 +
20 +def _grade(q: dict[str, Any], given: Any) -> bool:
21 + t = q["type"]
22 + try:
23 + if t == "mcq":
24 + return int(given) == int(q["answer"])
25 + if t == "true_false":
26 + g = given if isinstance(given, bool) else str(given).lower() in {"true", "vrai", "1"}
27 + return g == bool(q["answer"])
28 + if t == "numeric":
29 + val = float(str(given).replace(" ", "").replace(",", ".").replace("$", "").replace("%", ""))
30 + ans = float(q["answer"])
31 + tol = float(q.get("tolerance", 0.02))
32 + return abs(val - ans) <= max(abs(ans) * tol, 1e-9)
33 + except (TypeError, ValueError):
34 + return False
35 + return False
36 +
37 +
38 +@router.get("/quiz/{quiz_id}")
39 +async def get_quiz(quiz_id: str, auth: AuthUser = Depends(get_current_user)) -> dict:
40 + async with SessionLocal() as session:
41 + quiz = await session.get(Quiz, quiz_id)
42 + if not quiz or quiz.user_id != auth.id:
43 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Quiz introuvable.")
44 + payload = dict(quiz.payload)
45 + payload["questions"] = [{k: v for k, v in q.items() if k not in {"answer", "explanation"}}
46 + for q in payload.get("questions", [])]
47 + return payload
48 +
49 +
50 +@router.post("/quiz/{quiz_id}/answers")
51 +async def submit(quiz_id: str, req: AnswersReq, auth: AuthUser = Depends(get_current_user)) -> dict:
52 + async with SessionLocal() as session:
53 + quiz = await session.get(Quiz, quiz_id)
54 + if not quiz or quiz.user_id != auth.id:
55 + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="Quiz introuvable.")
56 + questions = quiz.payload.get("questions", [])
57 + results = []
58 + correct = 0
59 + for q in questions:
60 + given = req.answers.get(q["id"])
61 + ok = _grade(q, given) if given is not None else False
62 + correct += int(ok)
63 + results.append({"id": q["id"], "correct": ok, "given": given,
64 + "answer": q["answer"], "explanation": q.get("explanation", ""),
65 + "unit": q.get("unit", "")})
66 + score = correct / len(questions) if questions else 0.0
67 + session.add(QuizAttempt(quiz_id=quiz_id, answers=req.answers, score=score))
68 + await session.commit()
69 + await analytics.record_event(hash_user_id(auth.id), quiz.course_code, "quiz", quiz.topic)
70 + return {"score": score, "correct": correct, "total": len(questions), "results": results}
added backend/app/core/__init__.py +0 −0
added backend/app/core/cache.py +31 −0
@@ -0,0 +1,31 @@
1 +"""Tiny TTL cache (in-memory). Redis can replace it via REDIS_URL later."""
2 +
3 +from __future__ import annotations
4 +
5 +import time
6 +from typing import Any
7 +
8 +
9 +class TTLCache:
10 + def __init__(self) -> None:
11 + self._data: dict[str, tuple[float, Any]] = {}
12 +
13 + def get(self, key: str) -> Any | None:
14 + item = self._data.get(key)
15 + if not item:
16 + return None
17 + exp, value = item
18 + if exp < time.time():
19 + self._data.pop(key, None)
20 + return None
21 + return value
22 +
23 + def set(self, key: str, value: Any, ttl_s: int) -> None:
24 + if len(self._data) > 5000:
25 + now = time.time()
26 + for k in [k for k, (e, _) in self._data.items() if e < now]:
27 + self._data.pop(k, None)
28 + self._data[key] = (time.time() + ttl_s, value)
29 +
30 +
31 +cache = TTLCache()
added backend/app/core/config.py +146 −0
@@ -0,0 +1,146 @@
1 +"""Application settings (env-driven). Model slugs are configuration, not code."""
2 +
3 +from __future__ import annotations
4 +
5 +from functools import lru_cache
6 +from pathlib import Path
7 +
8 +from pydantic import SecretStr, field_validator
9 +from pydantic_settings import BaseSettings, SettingsConfigDict
10 +
11 +BASE_DIR = Path(__file__).resolve().parents[2] # backend/
12 +REPO_DIR = BASE_DIR.parent
13 +
14 +
15 +class Settings(BaseSettings):
16 + model_config = SettingsConfigDict(env_file=(REPO_DIR / ".env", BASE_DIR / ".env"), extra="ignore")
17 +
18 + # --- App ---
19 + APP_ENV: str = "development"
20 + APP_NAME: str = "UQO-Chat"
21 + APP_URL: str = "https://www.uqo-chat.app"
22 + CORS_ORIGINS: str = "https://www.uqo-chat.app,http://localhost:5173,http://localhost:8190"
23 + LOG_LEVEL: str = "INFO"
24 + PORT: int = 8190
25 + DATA_DIR: Path = REPO_DIR / "data"
26 + FRONTEND_DIST: Path = REPO_DIR / "frontend" / "dist"
27 + COURSES: str = "IMM1003,IMM1033"
28 + TERM_LABEL: str = "Automne 2026"
29 +
30 + # --- LLM (OpenRouter) ---
31 + OPENROUTER_API_KEY: SecretStr = SecretStr("")
32 + OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
33 + OPENROUTER_APP_NAME: str = "UQO-Chat"
34 + OPENROUTER_APP_URL: str = "https://www.uqo-chat.app"
35 + # Slugs — verify on https://openrouter.ai/models before changing (checked 2026-09-05).
36 + MODEL_TUTOR_PRIMARY: str = "anthropic/claude-sonnet-4.6"
37 + MODEL_TUTOR_FALLBACK: str = "openai/gpt-5.5"
38 + MODEL_REASONING: str = "anthropic/claude-opus-4.6"
39 + MODEL_FAST: str = "openai/gpt-5.4-nano"
40 + MODEL_VISION: str = "anthropic/claude-sonnet-4.6"
41 + # OpenRouter exposes no embedding model (2026-09): optional OpenAI-compatible endpoint.
42 + EMBEDDINGS_BASE_URL: str = ""
43 + EMBEDDINGS_API_KEY: SecretStr = SecretStr("")
44 + MODEL_EMBEDDINGS: str = ""
45 + LLM_MAX_TOOL_ITERATIONS: int = 8
46 + LLM_TIMEOUT_SECONDS: int = 120
47 + LLM_MAX_OUTPUT_TOKENS: int = 4096
48 + LLM_MONTHLY_BUDGET_USD: float = 300.0
49 + LLM_TURN_TIMEOUT_SECONDS: int = 300
50 +
51 + # --- Web search ---
52 + FIRECRAWL_API_KEY: SecretStr = SecretStr("")
53 + FIRECRAWL_BASE_URL: str = "https://api.firecrawl.dev/v1"
54 + WEB_SEARCH_CACHE_TTL_S: int = 6 * 3600
55 +
56 + # --- Data ---
57 + DATABASE_URL: str = "" # default: sqlite in DATA_DIR
58 + REDIS_URL: str = "" # optional; in-memory fallback
59 +
60 + # --- Sandbox ---
61 + SANDBOX_URL: str = "http://127.0.0.1:8191"
62 + SANDBOX_TIMEOUT_S: int = 30
63 + SANDBOX_HEAVY_TIMEOUT_S: int = 60
64 + SANDBOX_TOKEN: SecretStr = SecretStr("")
65 +
66 + # --- Auth ---
67 + JWT_SECRET: SecretStr = SecretStr("change-me-in-production")
68 + JWT_TTL_HOURS: int = 24
69 + REFRESH_TTL_DAYS: int = 30
70 + ALLOWED_EMAIL_DOMAINS: str = "uqo.ca"
71 + INVITED_EMAILS: str = ""
72 + PROFESSOR_EMAILS: str = ""
73 + ADMIN_EMAILS: str = ""
74 + ACCESS_CODE: str = "" # course access code: lets users log in without SMTP
75 + SMTP_HOST: str = ""
76 + SMTP_PORT: int = 587
77 + SMTP_USER: str = ""
78 + SMTP_PASSWORD: SecretStr = SecretStr("")
79 + SMTP_FROM: str = "no-reply@uqo-chat.app"
80 +
81 + # --- Limits ---
82 + RATE_MESSAGES_PER_HOUR: int = 60
83 + RATE_MESSAGES_PER_DAY: int = 400
84 + RATE_SANDBOX_PER_HOUR: int = 10
85 + RATE_WEBSEARCH_PER_DAY: int = 30
86 + RATE_UPLOADS_PER_DAY: int = 20
87 + UPLOAD_MAX_MB: int = 20
88 + FILE_TTL_DAYS: int = 7
89 + FILE_PINNED_TTL_DAYS: int = 30
90 + MESSAGE_RETENTION_MONTHS: int = 12
91 +
92 + @field_validator("DATA_DIR", mode="before")
93 + @classmethod
94 + def _expand(cls, v: object) -> object:
95 + if isinstance(v, str):
96 + return Path(v).expanduser()
97 + return v
98 +
99 + @property
100 + def database_url(self) -> str:
101 + if self.DATABASE_URL:
102 + return self.DATABASE_URL
103 + return f"sqlite+aiosqlite:///{self.DATA_DIR / 'uqochat.db'}"
104 +
105 + @property
106 + def cors_origins(self) -> list[str]:
107 + return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
108 +
109 + @property
110 + def courses(self) -> list[str]:
111 + return [c.strip().upper() for c in self.COURSES.split(",") if c.strip()]
112 +
113 + def _csv_lower(self, value: str) -> set[str]:
114 + return {e.strip().lower() for e in value.split(",") if e.strip()}
115 +
116 + @property
117 + def allowed_domains(self) -> set[str]:
118 + return self._csv_lower(self.ALLOWED_EMAIL_DOMAINS)
119 +
120 + @property
121 + def invited_emails(self) -> set[str]:
122 + return self._csv_lower(self.INVITED_EMAILS)
123 +
124 + @property
125 + def professor_emails(self) -> set[str]:
126 + return self._csv_lower(self.PROFESSOR_EMAILS)
127 +
128 + @property
129 + def admin_emails(self) -> set[str]:
130 + return self._csv_lower(self.ADMIN_EMAILS)
131 +
132 + @property
133 + def smtp_enabled(self) -> bool:
134 + return bool(self.SMTP_HOST)
135 +
136 + @property
137 + def is_dev(self) -> bool:
138 + return self.APP_ENV != "production"
139 +
140 +
141 +@lru_cache
142 +def get_settings() -> Settings:
143 + s = Settings()
144 + s.DATA_DIR.mkdir(parents=True, exist_ok=True)
145 + (s.DATA_DIR / "files").mkdir(exist_ok=True)
146 + return s
added backend/app/core/logging.py +39 −0
@@ -0,0 +1,39 @@
1 +"""structlog configuration. Never log message contents (Loi 25)."""
2 +
3 +from __future__ import annotations
4 +
5 +import logging
6 +import sys
7 +
8 +import structlog
9 +
10 +REDACT_KEYS = {"content", "message", "text", "code", "prompt", "email", "authorization"}
11 +
12 +
13 +def _redact(_: object, __: str, event_dict: dict) -> dict:
14 + for key in list(event_dict):
15 + if key.lower() in REDACT_KEYS:
16 + value = event_dict[key]
17 + event_dict[key] = f"<redacted len={len(str(value))}>"
18 + return event_dict
19 +
20 +
21 +def configure_logging(level: str = "INFO", json_output: bool = True) -> None:
22 + logging.basicConfig(level=level, stream=sys.stdout, format="%(message)s")
23 + renderer = structlog.processors.JSONRenderer() if json_output else structlog.dev.ConsoleRenderer()
24 + structlog.configure(
25 + processors=[
26 + structlog.contextvars.merge_contextvars,
27 + structlog.processors.add_log_level,
28 + structlog.processors.TimeStamper(fmt="iso"),
29 + _redact,
30 + renderer,
31 + ],
32 + wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, level.upper(), 20)),
33 + logger_factory=structlog.PrintLoggerFactory(),
34 + cache_logger_on_first_use=True,
35 + )
36 +
37 +
38 +def get_logger(name: str) -> structlog.stdlib.BoundLogger:
39 + return structlog.get_logger(name)
added backend/app/core/ratelimit.py +37 −0
@@ -0,0 +1,37 @@
1 +"""Sliding-window rate limiter. Redis if configured, otherwise in-process memory."""
2 +
3 +from __future__ import annotations
4 +
5 +import time
6 +from collections import defaultdict, deque
7 +
8 +from fastapi import HTTPException, status
9 +
10 +
11 +class MemoryRateLimiter:
12 + def __init__(self) -> None:
13 + self._hits: dict[str, deque[float]] = defaultdict(deque)
14 +
15 + def check(self, key: str, limit: int, window_s: int, message_fr: str) -> None:
16 + now = time.time()
17 + q = self._hits[key]
18 + while q and q[0] < now - window_s:
19 + q.popleft()
20 + if len(q) >= limit:
21 + raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, detail=message_fr)
22 + q.append(now)
23 +
24 + def count(self, key: str, window_s: int) -> int:
25 + now = time.time()
26 + q = self._hits[key]
27 + while q and q[0] < now - window_s:
28 + q.popleft()
29 + return len(q)
30 +
31 +
32 +limiter = MemoryRateLimiter()
33 +
34 +MSG_MESSAGES = "Tu as atteint la limite de messages pour l'instant. Réessaie un peu plus tard."
35 +MSG_SANDBOX = "Limite d'exécutions Python atteinte pour cette heure."
36 +MSG_WEB = "Limite de recherches web atteinte pour aujourd'hui."
37 +MSG_UPLOAD = "Limite de téléversements atteinte pour aujourd'hui."
added backend/app/core/security.py +109 −0
@@ -0,0 +1,109 @@
1 +"""JWT helpers, password-less auth utilities and FastAPI dependencies."""
2 +
3 +from __future__ import annotations
4 +
5 +import hashlib
6 +import secrets
7 +from datetime import UTC, datetime, timedelta
8 +from typing import Any
9 +
10 +from fastapi import Depends, HTTPException, Request, status
11 +from jose import JWTError, jwt
12 +
13 +from app.core.config import Settings, get_settings
14 +
15 +ALGO = "HS256"
16 +COOKIE_NAME = "uqo_session"
17 +
18 +
19 +def hash_user_id(user_id: str) -> str:
20 + return hashlib.sha256(user_id.encode()).hexdigest()
21 +
22 +
23 +def create_token(sub: str, role: str, settings: Settings, ttl: timedelta | None = None,
24 + kind: str = "access") -> str:
25 + now = datetime.now(UTC)
26 + exp = now + (ttl or timedelta(hours=settings.JWT_TTL_HOURS))
27 + payload: dict[str, Any] = {"sub": sub, "role": role, "kind": kind, "iat": now, "exp": exp}
28 + return jwt.encode(payload, settings.JWT_SECRET.get_secret_value(), algorithm=ALGO)
29 +
30 +
31 +def decode_token(token: str, settings: Settings) -> dict[str, Any] | None:
32 + try:
33 + return jwt.decode(token, settings.JWT_SECRET.get_secret_value(), algorithms=[ALGO])
34 + except JWTError:
35 + return None
36 +
37 +
38 +def new_magic_token() -> str:
39 + return secrets.token_urlsafe(32)
40 +
41 +
42 +def role_for_email(email: str, settings: Settings) -> str:
43 + e = email.lower()
44 + if e in settings.admin_emails:
45 + return "admin"
46 + if e in settings.professor_emails:
47 + return "professor"
48 + return "student"
49 +
50 +
51 +def email_allowed(email: str, settings: Settings) -> bool:
52 + e = email.lower().strip()
53 + if "@" not in e:
54 + return False
55 + if e in settings.invited_emails or e in settings.professor_emails or e in settings.admin_emails:
56 + return True
57 + domain = e.split("@", 1)[1]
58 + return domain in settings.allowed_domains
59 +
60 +
61 +class AuthUser:
62 + def __init__(self, user_id: str, role: str) -> None:
63 + self.id = user_id
64 + self.role = role
65 +
66 + @property
67 + def is_professor(self) -> bool:
68 + return self.role in {"professor", "admin"}
69 +
70 + @property
71 + def is_admin(self) -> bool:
72 + return self.role == "admin"
73 +
74 +
75 +def _extract_token(request: Request) -> str | None:
76 + auth = request.headers.get("authorization", "")
77 + if auth.lower().startswith("bearer "):
78 + return auth[7:].strip()
79 + return request.cookies.get(COOKIE_NAME)
80 +
81 +
82 +async def get_current_user(request: Request, settings: Settings = Depends(get_settings)) -> AuthUser:
83 + token = _extract_token(request)
84 + if not token:
85 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Connexion requise.")
86 + payload = decode_token(token, settings)
87 + if not payload or payload.get("kind") != "access":
88 + raise HTTPException(status.HTTP_401_UNAUTHORIZED, detail="Session expirée. Reconnecte-toi.")
89 + return AuthUser(str(payload["sub"]), str(payload.get("role", "student")))
90 +
91 +
92 +async def get_optional_user(request: Request,
93 + settings: Settings = Depends(get_settings)) -> AuthUser | None:
94 + try:
95 + return await get_current_user(request, settings)
96 + except HTTPException:
97 + return None
98 +
99 +
100 +async def require_professor(user: AuthUser = Depends(get_current_user)) -> AuthUser:
101 + if not user.is_professor:
102 + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Réservé au professeur.")
103 + return user
104 +
105 +
106 +async def require_admin(user: AuthUser = Depends(get_current_user)) -> AuthUser:
107 + if not user.is_admin:
108 + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="Réservé à l'administrateur.")
109 + return user
added backend/app/db.py +32 −0
@@ -0,0 +1,32 @@
1 +"""Async engine / session factory."""
2 +
3 +from __future__ import annotations
4 +
5 +from collections.abc import AsyncIterator
6 +
7 +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
8 +
9 +from app.core.config import get_settings
10 +from app.models import Base
11 +
12 +_settings = get_settings()
13 +engine = create_async_engine(
14 + _settings.database_url,
15 + echo=False,
16 + pool_pre_ping=True,
17 + connect_args={"timeout": 30} if _settings.database_url.startswith("sqlite") else {},
18 +)
19 +SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
20 +
21 +
22 +async def init_db() -> None:
23 + async with engine.begin() as conn:
24 + if _settings.database_url.startswith("sqlite"):
25 + await conn.exec_driver_sql("PRAGMA journal_mode=WAL")
26 + await conn.exec_driver_sql("PRAGMA foreign_keys=ON")
27 + await conn.run_sync(Base.metadata.create_all)
28 +
29 +
30 +async def get_session() -> AsyncIterator[AsyncSession]:
31 + async with SessionLocal() as session:
32 + yield session
added backend/app/llm/__init__.py +0 −0
added backend/app/llm/agent.py +267 −0
@@ -0,0 +1,267 @@
1 +"""Agentic tool loop: stream model output, execute tools (in parallel), loop, persist."""
2 +
3 +from __future__ import annotations
4 +
5 +import asyncio
6 +import json
7 +import time
8 +from collections.abc import Awaitable, Callable
9 +from datetime import date
10 +from typing import Any
11 +
12 +from app.core.config import Settings, get_settings
13 +from app.core.logging import get_logger
14 +from app.core.security import hash_user_id
15 +from app.llm.openrouter import LLMError, get_llm
16 +from app.llm.prompts import load_prompt
17 +from app.llm.router import router
18 +from app.llm.schemas import ToolCallReq, ToolResult
19 +from app.models import Conversation, Message, User, new_id
20 +from app.services import analytics, costs
21 +from app.services import conversations as conv_service
22 +from app.services import courses as course_service
23 +from app.services import files as file_service
24 +from app.tools.all import registry
25 +from app.tools.registry import ToolContext
26 +
27 +log = get_logger("agent")
28 +Emit = Callable[[str, dict[str, Any]], Awaitable[None]]
29 +
30 +TOOL_HINTS = """## Outils
31 +- `search_course_content` : matière du cours (à consulter d'abord).
32 +- `execute_python` : calculs, statistiques, graphiques (sandbox, sans réseau).
33 +- `financial_calc` : six fonctions du dollar, âge-vie, coût unitaire, extraction du marché.
34 +- `create_excel` : classeur à formules vivantes (gabarits : methode_du_cout, comparables_ajustes, age_vie, tableau_amortissement, six_fonctions, sensibilite).
35 +- `web_search` : données actuelles seulement (marché, taux, coûts, règlements).
36 +- `analyze_file` : fichiers déposés (identifiants ci-dessous).
37 +- `generate_quiz` : quiz interactif.
38 +Enchaîne plusieurs outils si nécessaire (ex. : search_course_content puis execute_python puis create_excel)."""
39 +
40 +
41 +def build_system_prompt(course_code: str, user: User, course_extra: str, announcement: str,
42 + files: list[dict[str, Any]], settings: Settings, deadlines: list) -> str:
43 + prefs = user.preferences or {}
44 + parts = [load_prompt("system_tutor"), load_prompt("guardrails"),
45 + load_prompt(f"course_{course_code.lower()}") or f"## Cours actif : {course_code}"]
46 + if course_extra:
47 + parts.append("## Consignes additionnelles du professeur\n" + course_extra)
48 + parts.append(TOOL_HINTS)
49 + ctx = [f"Date du jour : {date.today().isoformat()}. Trimestre : {settings.TERM_LABEL}.",
50 + f"Étudiant : {user.display_name or 'étudiant'} ; "
51 + f"{'tutoiement' if prefs.get('tutoiement', True) else 'vouvoiement'} ; "
52 + f"langue : {prefs.get('locale', 'fr-CA')}."]
53 + if deadlines:
54 + ctx.append("Échéances : " + "; ".join(f"{d.get('label')} ({d.get('date')})" for d in deadlines))
55 + if announcement:
56 + ctx.append(f"Annonce du professeur : {announcement}")
57 + if files:
58 + ctx.append("Fichiers déposés dans cette conversation (file_id — nom) :\n" +
59 + "\n".join(f"- {f['id']} — {f['filename']} ({f['type']})" for f in files))
60 + parts.append("## Contexte\n" + "\n".join(ctx))
61 + return "\n\n".join(p for p in parts if p)
62 +
63 +
64 +def history_to_messages(history: list[Message], max_chars: int = 60000) -> list[dict[str, Any]]:
65 + """Rebuild OpenAI-style messages, replaying tool calls compactly."""
66 + out: list[dict[str, Any]] = []
67 + for m in history:
68 + if m.role == "user":
69 + content = m.content
70 + if m.attachments:
71 + content += "\n\n(Fichiers joints : " + ", ".join(m.attachments) + ")"
72 + out.append({"role": "user", "content": content})
73 + elif m.role == "assistant":
74 + if m.tool_calls:
75 + calls = [{"id": t.id, "type": "function",
76 + "function": {"name": t.name, "arguments": json.dumps(t.arguments,
77 + ensure_ascii=False)}}
78 + for t in m.tool_calls]
79 + out.append({"role": "assistant", "content": None, "tool_calls": calls})
80 + for t in m.tool_calls:
81 + out.append({"role": "tool", "tool_call_id": t.id,
82 + "content": (t.result_summary or "(résultat)")[:1500]})
83 + if m.content:
84 + out.append({"role": "assistant", "content": m.content})
85 + # trim from the front to respect budget
86 + total = sum(len(json.dumps(x, ensure_ascii=False)) for x in out)
87 + while out and total > max_chars:
88 + dropped = out.pop(0)
89 + total -= len(json.dumps(dropped, ensure_ascii=False))
90 + # never start with a tool message
91 + while out and out[0]["role"] == "tool":
92 + total -= len(json.dumps(out.pop(0), ensure_ascii=False))
93 + return out
94 +
95 +
96 +class Turn:
97 + def __init__(self, conversation: Conversation, user: User, emit: Emit,
98 + deep: bool = False, settings: Settings | None = None) -> None:
99 + self.conv = conversation
100 + self.user = user
101 + self.emit = emit
102 + self.deep = deep
103 + self.settings = settings or get_settings()
104 + self.cancel = asyncio.Event()
105 + self.message_id = new_id()
106 + self.text_parts: list[str] = []
107 + self.tool_records: list[dict[str, Any]] = []
108 + self.usage_total = {"input_tokens": 0, "output_tokens": 0, "cost_usd": 0.0, "model": ""}
109 + self.model_used = ""
110 +
111 + # ------------------------------------------------------------------ helpers
112 + async def _tool_ctx(self, tool_call_id: str, file_ids: list[str]) -> ToolContext:
113 + async def progress(status: str, detail: str) -> None:
114 + await self.emit("tool_progress", {"id": tool_call_id, "status": status, "detail": detail})
115 +
116 + return ToolContext(user_id=self.user.id, user_id_hash=hash_user_id(self.user.id),
117 + conversation_id=self.conv.id, course_code=self.conv.course_code,
118 + settings=self.settings, role=self.user.role, file_ids=file_ids,
119 + progress=progress)
120 +
121 + async def _run_tool(self, call: ToolCallReq, file_ids: list[str]) -> tuple[ToolCallReq, ToolResult]:
122 + args = call.arguments()
123 + preview = json.dumps(args, ensure_ascii=False)[:200]
124 + await self.emit("tool_call", {"id": call.id, "name": call.name, "args_preview": preview,
125 + "arguments": args})
126 + ctx = await self._tool_ctx(call.id, file_ids)
127 + result = await registry.run(call.name, args, ctx)
128 + artifacts = [a.to_dict() for a in result.artifacts]
129 + await self.emit("tool_result", {"id": call.id, "name": call.name,
130 + "summary": result.summary(), "artifacts": artifacts,
131 + "payload": result.payload, "status": "error" if result.error else "ok",
132 + "duration_ms": result.meta.get("duration_ms", 0)})
133 + self.tool_records.append({"id": call.id, "name": call.name, "arguments": args,
134 + "summary": result.summary(600), "payload": result.payload,
135 + "status": "error" if result.error else "ok",
136 + "duration_ms": result.meta.get("duration_ms", 0)})
137 + asyncio.create_task(analytics.record_event(hash_user_id(self.user.id),
138 + self.conv.course_code, "tool", call.name))
139 + return call, result
140 +
141 + # ------------------------------------------------------------------ main loop
142 + async def run(self, user_text: str, attachments: list[str]) -> None:
143 + t0 = time.perf_counter()
144 + course = await course_service.get_course(self.conv.course_code)
145 + files = await file_service.list_for_conversation(self.conv.id)
146 + file_dicts = [{"id": f.id, "filename": f.filename, "type": f.type} for f in files
147 + if f.kind == "upload"]
148 + file_ids = [f["id"] for f in file_dicts]
149 + system = build_system_prompt(
150 + self.conv.course_code, self.user, course.extra_system_prompt if course else "",
151 + course.announcement if course else "", file_dicts, self.settings,
152 + (course.syllabus.get("deadlines") if course else []) or [])
153 + history = await conv_service.history(self.conv.id, limit=40)
154 + messages: list[dict[str, Any]] = [{"role": "system", "content": system}]
155 + messages += history_to_messages(history)
156 + # the freshly stored user message is already in history; make sure it is the last one
157 + if not messages or messages[-1].get("role") != "user":
158 + messages.append({"role": "user", "content": user_text})
159 +
160 + enabled = None
161 + if course and (course.settings or {}).get("tools_enabled"):
162 + enabled = set(course.settings["tools_enabled"])
163 + tools = registry.openai_tools(enabled)
164 + if course and (course.settings or {}).get("model_primary"):
165 + router.overrides["primary"] = course.settings["model_primary"]
166 + budget = await costs.budget_status((course.settings or {}).get("budget_usd") if course else None)
167 + plan = router.plan("tutor", deep=self.deep)
168 + if budget["exceeded"]:
169 + await self.emit("warning", {"code": "budget", "message_fr":
170 + "Budget mensuel atteint : modèle économique utilisé."})
171 + await self.emit("message_start", {"message_id": self.message_id, "model": plan.models[0]})
172 +
173 + llm = get_llm()
174 + user_hash = hash_user_id(self.user.id)
175 + finish = "stop"
176 + try:
177 + for _iteration in range(self.settings.LLM_MAX_TOOL_ITERATIONS + 1):
178 + if self.cancel.is_set():
179 + finish = "cancelled"
180 + break
181 + pending: dict[int, ToolCallReq] = {}
182 + finish = "stop"
183 + call_t0 = time.perf_counter()
184 + iteration_text: list[str] = []
185 + async for ev in llm.stream_chat(messages, tools, plan.models, plan.temperature,
186 + reasoning=plan.reasoning, user_id_hash=user_hash):
187 + if self.cancel.is_set():
188 + finish = "cancelled"
189 + break
190 + if ev.type == "text_delta":
191 + iteration_text.append(ev.data["delta"])
192 + self.text_parts.append(ev.data["delta"])
193 + await self.emit("text_delta", {"delta": ev.data["delta"]})
194 + elif ev.type == "tool_call_end":
195 + pending[ev.data["index"]] = ToolCallReq(
196 + id=ev.data["id"] or f"call_{new_id()[:8]}", name=ev.data["name"],
197 + arguments_json=ev.data["arguments"], index=ev.data["index"])
198 + elif ev.type == "usage":
199 + for k in ("input_tokens", "output_tokens", "cost_usd"):
200 + self.usage_total[k] += ev.data.get(k, 0)
201 + self.model_used = ev.data.get("model") or self.model_used
202 + asyncio.create_task(costs.record_usage(
203 + "tutor", self.conv.course_code, ev.data,
204 + int((time.perf_counter() - call_t0) * 1000)))
205 + elif ev.type == "error":
206 + raise LLMError(ev.data.get("message", "erreur LLM"))
207 + elif ev.type == "done":
208 + finish = ev.data.get("finish_reason") or "stop"
209 + self.model_used = ev.data.get("model") or self.model_used
210 + if finish == "cancelled":
211 + break
212 + if not pending:
213 + break
214 + # append assistant tool-call message, run tools in parallel, append results
215 + calls = sorted(pending.values(), key=lambda c: c.index)
216 + messages.append({"role": "assistant",
217 + "content": "".join(iteration_text) or None,
218 + "tool_calls": [{"id": c.id, "type": "function",
219 + "function": {"name": c.name,
220 + "arguments": c.arguments_json or "{}"}}
221 + for c in calls]})
222 + results = await asyncio.gather(*(self._run_tool(c, file_ids) for c in calls))
223 + for call, result in results:
224 + messages.append({"role": "tool", "tool_call_id": call.id,
225 + "content": result.content[:24000]})
226 + if iteration_text:
227 + self.text_parts.append("\n\n")
228 + await self.emit("text_delta", {"delta": "\n\n"})
229 + else:
230 + await self.emit("warning", {"code": "max_iterations",
231 + "message_fr": "Nombre maximal d'étapes atteint."})
232 + except LLMError as exc:
233 + log.error("llm_error", error=str(exc))
234 + await self.emit("error", {"code": "llm", "message_fr":
235 + "Le modèle n'a pas pu répondre. Réessaie dans un instant."})
236 + finish = "error"
237 + except asyncio.CancelledError:
238 + finish = "cancelled"
239 + finally:
240 + content = "".join(self.text_parts).strip()
241 + latency = int((time.perf_counter() - t0) * 1000)
242 + if content or self.tool_records:
243 + await conv_service.save_assistant_message(
244 + self.conv.id, self.message_id, content, self.model_used or plan.models[0],
245 + self.usage_total, latency, self.tool_records)
246 + await self.emit("usage", {**self.usage_total, "model_used": self.model_used,
247 + "latency_ms": latency})
248 + await self.emit("done", {"message_id": self.message_id, "finish_reason": finish})
249 +
250 +
251 +# ------------------------------------------------------------------ side tasks
252 +async def generate_title(conv_id: str, first_message: str, emit: Emit | None = None) -> str | None:
253 + try:
254 + plan = router.plan("fast")
255 + text, usage = await get_llm().complete(
256 + [{"role": "system", "content": load_prompt("title_generator")},
257 + {"role": "user", "content": first_message[:1500]}], plan.models, 0.0, 30)
258 + title = text.strip().strip('"').strip("«»").strip()[:80]
259 + if title:
260 + await conv_service.set_title(conv_id, title)
261 + asyncio.create_task(costs.record_usage("title", "", usage, 0))
262 + if emit:
263 + await emit("title", {"conversation_id": conv_id, "title": title})
264 + return title
265 + except Exception as exc: # noqa: BLE001
266 + log.warning("title_failed", error=str(exc))
267 + return None
added backend/app/llm/openrouter.py +272 −0
Large diff (272 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/llm/prompts/__init__.py +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/llm/prompts/course_imm1003.md +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/llm/prompts/course_imm1033.md +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/llm/prompts/guardrails.md +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/llm/prompts/system_tutor.md +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/llm/prompts/title_generator.md +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/llm/router.py +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/llm/schemas.py +77 −0
Large diff (77 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/main.py +108 −0
Large diff (108 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/models/__init__.py +218 −0
Large diff (218 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/rag/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/rag/chunking.py +79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/rag/ingest.py +291 −0
Large diff (291 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/rag/retriever.py +148 −0
Large diff (148 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/sandbox/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/sandbox/client.py +67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/services/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/services/analytics.py +105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/services/conversations.py +209 −0
Large diff (209 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/services/costs.py +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/services/courses.py +120 −0
Large diff (120 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/services/files.py +185 −0
Large diff (185 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/services/mail.py +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/services/users.py +128 −0
Large diff (128 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/all.py +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/analyze_file.py +137 −0
Large diff (137 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/create_excel.py +298 −0
Large diff (298 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/excel_templates/__init__.py +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/excel_templates/age_vie.py +54 −0
Large diff (54 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/excel_templates/comparables_ajustes.py +74 −0
Large diff (74 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/excel_templates/methode_du_cout.py +81 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/excel_templates/sensibilite.py +44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/excel_templates/six_fonctions.py +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/excel_templates/tableau_amortissement.py +51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/execute_python.py +75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/financial_calc.py +140 −0
Large diff (140 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/generate_quiz.py +125 −0
Large diff (125 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/registry.py +101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/schemas/analyze_file.json +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/schemas/create_excel.json +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/schemas/execute_python.json +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/schemas/financial_calc.json +19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/schemas/generate_quiz.json +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/schemas/search_course_content.json +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/schemas/web_search.json +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/search_course.py +48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added backend/app/tools/web_search.py +158 −0
Large diff (158 lines) is not loaded by default to keep the page fast.Load diff
added backend/pyproject.toml +55 −0
Large diff (55 lines) is not loaded by default to keep the page fast.Load diff
added backend/scripts/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added backend/scripts/ingest.py +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added backend/scripts/seed.py +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added backend/tests/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added backend/tests/conftest.py +7 −0
Large diff (7 lines) is not loaded by default to keep the page fast.Load diff
added backend/tests/test_openrouter_sse.py +62 −0
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
added backend/tests/test_rag.py +50 −0
Large diff (50 lines) is not loaded by default to keep the page fast.Load diff
added backend/tests/test_sandbox_prefilter.py +12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added backend/tests/tools/__init__.py +0 −0
Large diff (0 lines) is not loaded by default to keep the page fast.Load diff
added backend/tests/tools/test_create_excel.py +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added backend/tests/tools/test_financial_calc.py +58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added deploy/uqo-chat.manifest.json +77 −0
Large diff (77 lines) is not loaded by default to keep the page fast.Load diff
added docker-compose.yml +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added frontend/index.html +25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added frontend/package-lock.json +10596 −0
Large diff (10,596 lines) is not loaded by default to keep the page fast.Load diff
added frontend/package.json +46 −0
Large diff (46 lines) is not loaded by default to keep the page fast.Load diff
added frontend/postcss.config.js +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added frontend/public/icons/apple-touch-icon.png +0 −0

Binary file not shown.

added frontend/public/icons/favicon.svg +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added frontend/public/icons/icon-192.png +0 −0

Binary file not shown.

added frontend/public/icons/icon-512.png +0 −0

Binary file not shown.

added frontend/public/icons/icon-maskable-512.png +0 −0

Binary file not shown.

added frontend/public/icons/og.png +0 −0

Binary file not shown.

added frontend/public/uqo-chat-logo.svg +1 −0
Large diff (1 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/app/router.tsx +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/chat/chat-view.tsx +102 −0
Large diff (102 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/chat/code-block.tsx +110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/chat/composer.tsx +161 −0
Large diff (161 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/chat/empty-state.tsx +42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/chat/message-bubble.tsx +77 −0
Large diff (77 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/chat/streaming-text.tsx +72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/chat/tool-call-timeline.tsx +60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/layout/app-shell.tsx +73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/layout/sidebar.tsx +107 −0
Large diff (107 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/tools/artifact-chips.tsx +30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/tools/course-source-card.tsx +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/tools/excel-card.tsx +52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/tools/file-analysis-card.tsx +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/tools/financial-calc-card.tsx +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/tools/python-exec-card.tsx +34 −0
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/tools/quiz-card.tsx +95 −0
Large diff (95 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/tools/tool-card.tsx +32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/tools/web-search-card.tsx +29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/ui/button.tsx +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/ui/dialog.tsx +40 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/ui/logo.tsx +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/components/ui/spinner.tsx +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/features/auth/login-page.tsx +117 −0
Large diff (117 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/features/auth/privacy-page.tsx +39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/features/auth/settings-dialog.tsx +70 −0
Large diff (70 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/features/conversations/artifacts-panel.tsx +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/features/professor/admin-costs-page.tsx +45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/features/professor/professor-page.tsx +213 −0
Large diff (213 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/i18n/fr-CA.json +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/index.css +58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/lib/api.ts +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/lib/cn.ts +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/lib/format.ts +35 −0
Large diff (35 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/lib/math-guard.test.ts +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/lib/math-guard.ts +81 −0
Large diff (81 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/lib/types.ts +103 −0
Large diff (103 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/main.tsx +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/stores/auth.ts +43 −0
Large diff (43 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/stores/chat.ts +213 −0
Large diff (213 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/stores/ui.ts +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added frontend/src/theme/uqo.ts +17 −0
Large diff (17 lines) is not loaded by default to keep the page fast.Load diff
added frontend/tailwind.config.ts +33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added frontend/tsconfig.json +23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added frontend/vite.config.ts +69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/api-deployment.yaml +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/hpa.yaml +8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/kustomization.yaml +18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/minio-statefulset.yaml +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/namespace.yaml +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/networkpolicies.yaml +28 −0
Large diff (28 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/postgres-statefulset.yaml +22 −0
Large diff (22 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/redis-deployment.yaml +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/sandbox-runner-deployment.yaml +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/services.yaml +29 −0
Large diff (29 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/web-deployment.yaml +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added k8s/base/worker-deployment.yaml +16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added k8s/ngrok/ingress.yaml +14 −0
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
added k8s/ngrok/operator-values.yaml +4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added k8s/overlays/prod/kustomization.yaml +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added k8s/overlays/staging/kustomization.yaml +9 −0
Large diff (9 lines) is not loaded by default to keep the page fast.Load diff
added sandbox-runner/Dockerfile +13 −0
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
added sandbox-runner/requirements.txt +15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added sandbox-runner/runner.py +163 −0
Large diff (163 lines) is not loaded by default to keep the page fast.Load diff
added sandbox-runner/server.py +39 −0
Large diff (40 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme