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>
168 changed files +19,882 −0
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