Add admin panel, WebSocket support, and API versioning
CI / Lint & Format (push) Failing after 28s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped

Introduce an admin portal (React + Nginx), WebSocket routing, and
API versioning middleware with `/api/v1/` prefix deprecation.
Add master API key authentication, new Prometheus metrics for AI
token consumption and active WebSockets, and extend S3 config
with a public endpoint URL. Update test paths and fixtures to
align with the new routing structure.
This commit is contained in:
2026-06-22 11:25:22 -04:00
parent d68deb9c74
commit 2f2a5e9b4a
158 changed files with 64811 additions and 503 deletions
File diff suppressed because it is too large Load Diff
+281
View File
@@ -0,0 +1,281 @@
# Prompt Claude Code — Phase 1 : Fondations sécurité
# Modèle : claude-opus-4-6
# Usage : claude --model claude-opus-4-6 -p "$(cat PROMPT_PHASE1.md)"
# ou coller directement dans une session Claude Code interactive
---
<role>
Tu es un ingénieur backend senior Python spécialisé en FastAPI, SQLAlchemy async et sécurité des APIs REST. Tu travailles sur le projet Imago, un backend centralisé de gestion d'images conçu pour servir plusieurs applications clientes simultanément.
</role>
<project_context>
## Projet : Imago
Backend FastAPI existant servant de hub centralisé pour :
- Stocker et gérer des images avec génération automatique de thumbnails
- Extraire les métadonnées EXIF (appareil photo, GPS, paramètres de prise de vue)
- Effectuer l'OCR sur les images via Tesseract
- Analyser les images avec Claude Vision AI (description + classification par tags)
### Stack technique en place
- FastAPI + Uvicorn (serveur ASGI)
- SQLAlchemy async + Alembic (ORM + migrations)
- Pydantic v2 + pydantic-settings (validation + config)
- Pillow + piexif (traitement images + EXIF)
- pytesseract (OCR)
- Anthropic Claude via httpx (Vision AI)
- aiofiles (I/O async)
- SQLite (développement) / PostgreSQL (production)
### Structure du projet
```
imago/
├── app/
│ ├── main.py # Application FastAPI
│ ├── config.py # Settings depuis .env
│ ├── database.py # Engine SQLAlchemy async + session
│ ├── models/
│ │ └── image.py # Modèle Image (EXIF, OCR, AI, statut)
│ ├── schemas/
│ │ └── __init__.py # Schémas Pydantic
│ ├── routers/
│ │ ├── images.py # Endpoints images (CRUD + pipeline)
│ │ └── ai.py # Endpoints AI (résumé URL, tâches)
│ └── services/
│ ├── storage.py # Sauvegarde fichiers + thumbnails
│ ├── exif_service.py # Extraction EXIF
│ ├── ocr_service.py # OCR Tesseract
│ ├── ai_vision.py # Vision AI Claude
│ ├── scraper.py # Scraping web
│ └── pipeline.py # Orchestration pipeline AI
├── tests/
│ └── test_services.py
├── requirements.txt
└── .env
```
### Problème actuel
Le backend est **entièrement public** : aucune authentification, aucune isolation entre clients. N'importe quelle application peut accéder, modifier ou supprimer toutes les données. C'est incompatible avec un hub multi-clients en production.
</project_context>
<mission>
## Mission : implémenter la Phase 1 — Fondations sécurité
Tu dois réaliser les 4 livrables suivants dans l'ordre indiqué. Chaque livrable doit être **complet, testé et fonctionnel** avant de passer au suivant.
### Livrable 1.1 — Authentification API Keys + JWT avec scopes (priorité CRITIQUE)
**Objectif** : sécuriser tous les endpoints avec deux mécanismes d'authentification complémentaires.
**Ce qui doit être créé ou modifié** :
1. `app/models/client.py` — Nouveau modèle SQLAlchemy `APIClient` :
- `id` : UUID, primary key
- `name` : String, nom de l'application cliente (ex: "Shaarli", "App Mobile")
- `api_key_hash` : String, hash SHA-256 de la clé API (jamais stocker en clair)
- `scopes` : JSON, liste des permissions accordées
- `plan` : Enum (`free`, `standard`, `premium`)
- `is_active` : Boolean, default True
- `created_at` / `updated_at` : DateTime
2. `app/dependencies/__init__.py` + `app/dependencies/auth.py` — Dépendances FastAPI :
- `verify_api_key(authorization: str = Header(...))` → retourne l'`APIClient` authentifié
- `require_scope(scope: str)` → factory qui vérifie qu'un scope est accordé
- `get_current_client` → alias réutilisable dans tous les routers
- Lever `HTTP 401` si clé invalide ou client inactif
- Lever `HTTP 403` si scope manquant
3. `app/routers/auth.py` — Endpoints de gestion des clients :
- `POST /auth/clients` — Créer un nouveau client (retourne la clé en clair une seule fois)
- `GET /auth/clients` — Lister les clients (admin only, scope `admin`)
- `GET /auth/clients/{id}` — Détail d'un client
- `PATCH /auth/clients/{id}` — Modifier un client (scopes, plan, is_active)
- `POST /auth/clients/{id}/rotate-key` — Régénérer la clé API
- `DELETE /auth/clients/{id}` — Désactiver (soft delete)
4. Scopes à définir :
- `images:read` — lire les images et métadonnées
- `images:write` — uploader et modifier des images
- `images:delete` — supprimer des images
- `ai:use` — utiliser les endpoints AI (résumé URL, génération tâches)
- `admin` — gestion des clients (réservé à un super-client)
5. `app/config.py` — Ajouter :
- `ADMIN_API_KEY` : clé du super-client admin (depuis .env)
- `JWT_SECRET_KEY` et `JWT_ALGORITHM` pour les tokens JWT futurs
**Contraintes** :
- Les clés API doivent être générées avec `secrets.token_urlsafe(32)`
- Le hash doit être SHA-256 via `hashlib`
- La clé en clair ne doit **jamais** être stockée en base ni apparaître dans les logs
- Utiliser `python-jose` et `passlib` (ajouter au requirements.txt)
---
### Livrable 1.2 — Modèle clients + isolation complète des données (priorité CRITIQUE)
**Objectif** : rendre toutes les données strictement isolées par client.
**Ce qui doit être créé ou modifié** :
1. `app/models/image.py` — Modifier le modèle `Image` existant :
- Ajouter `client_id = Column(UUID, ForeignKey("api_clients.id"), nullable=False, index=True)`
- Ajouter la relation `client = relationship("APIClient", back_populates="images")`
2. `app/models/client.py` — Ajouter la relation inverse :
- `images = relationship("Image", back_populates="client", cascade="all, delete-orphan")`
3. `app/services/storage.py` — Modifier le service de stockage :
- Les fichiers doivent être stockés dans `uploads/{client_id}/{filename}`
- Les thumbnails dans `thumbnails/{client_id}/{filename}`
- La fonction `save_upload` doit accepter `client_id` en paramètre
4. `app/routers/images.py` — Modifier TOUS les endpoints :
- Injecter `client: APIClient = Depends(get_current_client)` dans chaque endpoint
- Appliquer `require_scope("images:read")` sur les GET
- Appliquer `require_scope("images:write")` sur les POST
- Appliquer `require_scope("images:delete")` sur les DELETE
- Filtrer systématiquement toutes les requêtes avec `WHERE image.client_id = client.id`
- **Un client ne doit jamais pouvoir accéder aux images d'un autre client**
5. `app/routers/ai.py` — Même injection + scope `ai:use`
6. `alembic/versions/` — Créer une migration Alembic pour :
- Créer la table `api_clients`
- Ajouter la colonne `client_id` à la table `images`
- Créer un client "default" avec toutes les permissions pour la migration des données existantes
**Contraintes** :
- Toute requête DB touchant des images DOIT inclure le filtre `client_id`
- Écrire une fonction utilitaire `get_image_or_404(image_id, client_id, db)` qui centralise ce pattern
- Le filtre doit être appliqué au niveau service, pas seulement dans les routers
---
### Livrable 1.3 — Rate limiting par client et par endpoint (priorité HAUTE)
**Objectif** : protéger le hub contre les abus et contrôler la consommation par client.
**Ce qui doit être créé ou modifié** :
1. Ajouter `slowapi` au requirements.txt
2. `app/middleware/rate_limit.py` — Configuration du rate limiting :
- Identifier les requêtes par `client_id` (extrait du token) plutôt que par IP
- Limites différentes selon le plan :
- `free` : 20 uploads/heure, 50 requêtes AI/heure
- `standard` : 100 uploads/heure, 200 requêtes AI/heure
- `premium` : 500 uploads/heure, 1000 requêtes AI/heure
- Retourner `HTTP 429` avec header `Retry-After` si limite atteinte
3. `app/routers/images.py` — Appliquer les décorateurs rate limit sur :
- `POST /images/upload` → limiter par plan
- `POST /images/{id}/reprocess` → limiter par plan
4. `app/routers/ai.py` — Appliquer sur :
- `POST /ai/summarize` → limiter par plan
- `POST /ai/draft-task` → limiter par plan
5. `app/config.py` — Ajouter les variables de configuration des limites par plan
**Contraintes** :
- Les compteurs de rate limit doivent être par client (pas global)
- Inclure les headers standard : `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
---
### Livrable 1.4 — Tests d'intégration auth + multi-tenants (priorité HAUTE)
**Objectif** : valider que la sécurité fonctionne correctement avec une suite de tests exhaustive.
**Ce qui doit être créé** :
1. `tests/conftest.py` — Fixtures pytest :
- `test_db` : base SQLite in-memory pour les tests
- `client_a` et `client_b` : deux clients API de test avec des clés distinctes
- `auth_headers_a` et `auth_headers_b` : headers d'authentification correspondants
- `async_client` : `httpx.AsyncClient` configuré pour les tests
2. `tests/test_auth.py` — Tests d'authentification :
- ✅ Requête sans clé → HTTP 401
- ✅ Requête avec clé invalide → HTTP 401
- ✅ Requête avec clé valide → HTTP 200
- ✅ Client désactivé → HTTP 401
- ✅ Scope manquant → HTTP 403
- ✅ Rotation de clé → ancienne clé invalide, nouvelle clé valide
- ✅ Création de client → clé retournée une seule fois
3. `tests/test_isolation.py` — Tests d'isolation multi-tenants :
- ✅ Client A upload une image → invisible pour Client B
- ✅ Client B ne peut pas lire l'image du Client A (même avec bonne clé)
- ✅ Client B ne peut pas supprimer l'image du Client A → HTTP 404
- ✅ Listing des images de A ne retourne que les images de A
- ✅ Les fichiers sont stockés dans des répertoires séparés
- ✅ Reprocess d'une image appartenant à un autre client → HTTP 404
4. `tests/test_rate_limit.py` — Tests de rate limiting :
- ✅ Dépassement de quota → HTTP 429 avec header `Retry-After`
- ✅ Compteur distinct par client
- ✅ Headers `X-RateLimit-*` présents sur chaque réponse
**Contraintes** :
- Tous les tests doivent être async (`pytest-asyncio`)
- Les appels à l'API Anthropic doivent être mockés dans les tests
- Couverture minimum : 85% sur les modules `auth.py`, `dependencies/auth.py`, `models/client.py`
</mission>
<execution_rules>
## Règles d'exécution
### Ordre de réalisation
Implémenter dans l'ordre strict : 1.1 → 1.2 → 1.3 → 1.4. Ne pas passer au livrable suivant sans avoir validé le précédent avec `pytest`.
### À chaque livrable
1. Lire les fichiers existants concernés avant de les modifier
2. Écrire le code complet (pas de `# TODO` ni de `# ...`)
3. Mettre à jour `requirements.txt` si de nouvelles dépendances sont ajoutées
4. Créer la migration Alembic si le modèle de données change
5. Lancer `pytest tests/ -v` et corriger les erreurs avant de continuer
### Qualité du code
- Typage complet sur toutes les fonctions (mypy compatible)
- Docstrings sur tous les services et dépendances
- Pas de secrets hardcodés — tout passe par `app/config.py` + `.env`
- Logging structuré avec le niveau approprié (pas de `print()`)
- Gestion explicite de toutes les exceptions (pas de `except Exception: pass`)
### Sécurité — règles absolues
- Les clés API ne doivent **jamais** apparaître dans les logs, réponses d'erreur, ou stack traces
- Le hash doit être SHA-256 avec sel (utiliser `hashlib.pbkdf2_hmac` ou `passlib`)
- Les messages d'erreur d'authentification doivent être génériques (ne pas indiquer si la clé existe ou si c'est le scope qui manque — sauf pour HTTP 403 vs 401)
- Valider les entrées avec Pydantic sur tous les endpoints de création/modification
### Migrations Alembic
- Une migration par changement de schéma
- Inclure les `upgrade()` et `downgrade()` complets
- Tester la migration sur une base vierge avant de la considérer terminée
### Résultat attendu
À la fin de la Phase 1, la commande suivante doit réussir sans erreur :
```bash
pytest tests/ -v --cov=app --cov-report=term-missing
```
Et le serveur doit démarrer sans erreur avec :
```bash
python run.py
```
</execution_rules>
<deliverable_summary>
## Résumé des livrables attendus
| # | Fichiers créés ou modifiés | Validation |
|---|---|---|
| 1.1 | `app/models/client.py`, `app/dependencies/auth.py`, `app/routers/auth.py`, `app/config.py`, `requirements.txt` | `pytest tests/test_auth.py` |
| 1.2 | `app/models/image.py`, `app/services/storage.py`, `app/routers/images.py`, `app/routers/ai.py`, `alembic/versions/xxx_add_clients.py` | `pytest tests/test_isolation.py` |
| 1.3 | `app/middleware/rate_limit.py`, `app/routers/images.py`, `app/routers/ai.py`, `app/config.py` | `pytest tests/test_rate_limit.py` |
| 1.4 | `tests/conftest.py`, `tests/test_auth.py`, `tests/test_isolation.py`, `tests/test_rate_limit.py` | `pytest tests/ -v --cov=app` |
**Résultat final de la Phase 1 :** hub sécurisé, données strictement isolées par client, déployable en production.
</deliverable_summary>
+765
View File
@@ -0,0 +1,765 @@
# Prompt Claude Code — Phase 2 : Robustesse et scalabilité
# Modèle : claude-opus-4-6
# Usage : claude --model claude-opus-4-6 -p "$(cat PROMPT_PHASE2_claude-opus.md)"
# ou coller directement dans une session Claude Code interactive
---
<role>
Tu es un ingénieur backend senior Python spécialisé en systèmes distribués, FastAPI async et infrastructure de production. Tu travailles sur le projet Imago, un hub centralisé de gestion d'images servant plusieurs applications clientes simultanément.
La Phase 1 (authentification, isolation multi-tenants, rate limiting, tests d'intégration) est entièrement terminée et validée. Tu prends le relais pour rendre ce hub robuste, scalable et observable en production.
</role>
<project_context>
## État du projet après Phase 1
### Stack en place (Phase 1 complète)
- **FastAPI + Uvicorn** — framework web async
- **SQLAlchemy async + Alembic** — ORM + migrations
- **Pydantic v2 + pydantic-settings** — validation + config
- **python-jose + passlib** — JWT + hachage API Keys
- **slowapi** — rate limiting par client
- **Pillow + piexif** — traitement images + EXIF
- **pytesseract** — OCR Tesseract
- **Anthropic Claude via httpx** — Vision AI
- **aiofiles** — I/O async
- **pytest-asyncio + httpx** — tests d'intégration
### Structure complète du projet (après Phase 1)
```
imago/
├── app/
│ ├── main.py # Application FastAPI, lifespan, middlewares
│ ├── config.py # Settings depuis .env (inclut ADMIN_API_KEY, JWT_SECRET)
│ ├── database.py # Engine SQLAlchemy async + session factory
│ ├── models/
│ │ ├── __init__.py
│ │ ├── image.py # Image (avec client_id FK, EXIF, OCR, AI, statut)
│ │ └── client.py # APIClient (id, name, api_key_hash, scopes, plan, quotas)
│ ├── schemas/
│ │ └── __init__.py # Schémas Pydantic complets
│ ├── dependencies/
│ │ ├── __init__.py
│ │ └── auth.py # verify_api_key, require_scope, get_current_client
│ ├── middleware/
│ │ └── rate_limit.py # Rate limiting par plan client (slowapi)
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── auth.py # CRUD clients, rotation de clé
│ │ ├── images.py # Endpoints images (filtrés par client_id)
│ │ └── ai.py # Endpoints AI (résumé URL, tâches)
│ └── services/
│ ├── __init__.py
│ ├── storage.py # Stockage fichiers (uploads/{client_id}/{filename})
│ ├── exif_service.py # Extraction EXIF
│ ├── ocr_service.py # OCR Tesseract
│ ├── ai_vision.py # Vision AI Claude + summarize_url + draft_task
│ ├── scraper.py # Scraping web BeautifulSoup
│ └── pipeline.py # Pipeline EXIF → OCR → AI (via BackgroundTasks)
├── tests/
│ ├── conftest.py # Fixtures : test_db, client_a/b, auth_headers
│ ├── test_services.py # Tests unitaires services
│ ├── test_auth.py # Tests authentification
│ ├── test_isolation.py # Tests isolation multi-tenants
│ └── test_rate_limit.py # Tests rate limiting
├── alembic/
│ └── versions/ # Migrations : table images + table api_clients
├── requirements.txt
├── .env
├── Dockerfile
└── docker-compose.yml
```
### Ce qui reste problématique (cible de la Phase 2)
**Pipeline non persistant** : les `BackgroundTasks` FastAPI perdent toutes les tâches en file si le serveur redémarre. Aucun retry en cas d'échec de l'API Anthropic. Aucune priorité entre clients `free` et `premium`. Concurrence non contrôlée.
**Stockage non abstrait** : fichiers sur disque local uniquement, couplé aux chemins absolus. Impossible de scaler, aucune réplication, URLs statiques exposent les chemins réels.
**Observabilité absente** : tous les logs sont des `print()`. Aucune métrique applicative. Health check basique qui retourne toujours "ok". Impossible de monitorer en production.
**CI/CD inexistant** : pas d'automatisation des tests, pas de pipeline de déploiement, pas de contrôles qualité automatisés.
</project_context>
<mission>
## Mission : implémenter la Phase 2 — Robustesse et scalabilité
Tu dois réaliser les 5 livrables suivants dans l'ordre indiqué. Chaque livrable doit être **complet, testé et fonctionnel** avant de passer au suivant.
---
### Livrable 2.1 — Migration BackgroundTasks → ARQ + Redis (priorité HAUTE)
**Objectif** : remplacer le pipeline fragile par un système de file de tâches persistant avec retry automatique, priorités par plan client et concurrence contrôlée.
**Nouvelles dépendances à ajouter dans `requirements.txt`** :
```
arq==0.25.0
redis==5.0.8
```
**Ce qui doit être créé ou modifié** :
1. `app/config.py` — Ajouter les variables Redis et worker :
```python
REDIS_URL: str = "redis://localhost:6379"
WORKER_MAX_JOBS: int = 10 # concurrence max globale
WORKER_JOB_TIMEOUT: int = 180 # secondes avant timeout forcé
WORKER_MAX_TRIES: int = 3 # tentatives avant dead-letter
AI_STEP_TIMEOUT: int = 120 # timeout spécifique appel Vision AI
OCR_STEP_TIMEOUT: int = 30 # timeout spécifique OCR
```
2. `app/workers/__init__.py` + `app/workers/image_worker.py` — Worker ARQ :
- Fonction `process_image_task(ctx, image_id: int, client_id: str)` qui appelle `pipeline.py`
- `WorkerSettings` avec :
- `functions = [process_image_task]`
- `redis_settings` depuis `settings.REDIS_URL`
- `max_jobs = settings.WORKER_MAX_JOBS`
- `job_timeout = settings.WORKER_JOB_TIMEOUT`
- `retry_jobs = True`
- `max_tries = settings.WORKER_MAX_TRIES`
- `on_job_start`, `on_job_end`, `on_job_abort` — hooks de logging
- Backoff exponentiel : délais `[1, 4, 16]` secondes entre les tentatives
- Dead-letter : après `max_tries` échecs, marquer l'image `status=error` + log `ERROR`
3. `app/workers/redis_client.py` — Client Redis partagé :
- Pool de connexions async (`aioredis` ou `redis.asyncio`)
- Fonction `get_redis_pool()` injectable comme dépendance FastAPI
- Gestion propre de la connexion dans le lifespan de `main.py`
4. `app/services/pipeline.py` — Ajouter publication d'événements Redis :
- À chaque étape complétée, publier sur le channel `pipeline:{image_id}` :
```python
await redis.publish(f"pipeline:{image_id}", json.dumps({
"event": "step.completed",
"step": "exif",
"duration_ms": elapsed,
"data": { ... } # résumé des données extraites
}))
```
- Événements à publier : `pipeline.started`, `step.completed` (×3), `pipeline.done`, `pipeline.error`
5. `app/routers/images.py` — Modifier `POST /images/upload` :
- Remplacer `background_tasks.add_task(process_image_pipeline, ...)` par :
```python
queue_name = "premium" if client.plan == "premium" else "standard"
await arq_pool.enqueue_job(
"process_image_task",
image.id,
str(client.id),
_queue_name=queue_name
)
```
6. `app/main.py` — Modifier le `lifespan` :
- Créer et stocker le pool ARQ au démarrage : `app.state.arq_pool`
- Créer et stocker le pool Redis au démarrage : `app.state.redis`
- Fermer proprement les deux à l'arrêt
7. `worker.py` — Script de démarrage du worker (à la racine du projet) :
```python
# Lancer avec : python worker.py
import asyncio
from arq import run_worker
from app.workers.image_worker import WorkerSettings
if __name__ == "__main__":
asyncio.run(run_worker(WorkerSettings))
```
8. `docker-compose.yml` — Ajouter le service Redis et le worker :
```yaml
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: ["redis_data:/data"]
command: redis-server --appendonly yes # persistance AOF
worker:
build: .
command: python worker.py
depends_on: [backend, redis]
env_file: .env
```
**Contraintes** :
- Le pool ARQ doit être créé une seule fois au démarrage, pas à chaque requête
- Les tâches doivent survivre à un redémarrage du serveur FastAPI (elles restent dans Redis)
- Un job qui dépasse `job_timeout` doit être marqué `error` en base, pas silencieusement ignoré
- Les files `premium` et `standard` doivent être des queues ARQ distinctes (pas juste un label)
---
### Livrable 2.2 — Abstraction StorageBackend + support MinIO/S3 (priorité HAUTE)
**Objectif** : découpler le code du stockage physique pour permettre une migration transparente vers S3/MinIO sans modifier les routers ni les services métier.
**Nouvelle dépendance** :
```
aioboto3==13.0.0
```
**Ce qui doit être créé ou modifié** :
1. `app/services/storage_backend.py` — Interface abstraite + deux implémentations :
**Classe de base** :
```python
class StorageBackend(ABC):
@abstractmethod
async def save(self, content: bytes, path: str, content_type: str) -> str:
"""Sauvegarde un fichier. Retourne le chemin stocké."""
@abstractmethod
async def delete(self, path: str) -> None:
"""Supprime un fichier."""
@abstractmethod
async def get_signed_url(self, path: str, expires_in: int = 900) -> str:
"""Retourne une URL d'accès temporaire signée."""
@abstractmethod
async def exists(self, path: str) -> bool:
"""Vérifie qu'un fichier existe."""
@abstractmethod
async def get_size(self, path: str) -> int:
"""Retourne la taille en bytes."""
```
**`LocalStorage(StorageBackend)`** :
- `save()` : écrit dans `{UPLOAD_DIR}/{path}` avec `aiofiles`, crée les dossiers parents si besoin
- `delete()` : supprime le fichier, ignore si absent
- `get_signed_url()` : génère un token HMAC-SHA256 signé avec `itsdangerous.URLSafeTimedSerializer` incluant `path` + expiration. Retourne `/files/signed/{token}`
- `exists()` : `Path(full_path).exists()`
- `get_size()` : `Path(full_path).stat().st_size`
**`S3Storage(StorageBackend)`** :
- Constructeur : `bucket`, `prefix`, `session` (`aioboto3.Session`)
- `save()` : `put_object` avec le `content_type` correct
- `delete()` : `delete_object`
- `get_signed_url()` : `generate_presigned_url("get_object", ExpiresIn=expires_in)`
- `exists()` : `head_object` → True/False
- `get_size()` : `head_object` → `ContentLength`
- Compatible S3, MinIO et Cloudflare R2 (même API boto3)
2. `app/services/storage.py` — Refactoring complet :
- Supprimer le code de stockage direct
- La fonction `save_upload(file, client_id)` utilise maintenant le backend injecté
- La fonction `delete_files(paths)` utilise le backend
- Exposer `get_storage_backend() -> StorageBackend` : factory qui lit `settings.STORAGE_BACKEND` et instancie le bon backend
3. `app/config.py` — Ajouter :
```python
STORAGE_BACKEND: str = "local" # "local" | "s3"
S3_BUCKET: str = ""
S3_REGION: str = "us-east-1"
S3_ENDPOINT_URL: str = "" # vide = AWS, sinon MinIO/R2
S3_ACCESS_KEY: str = ""
S3_SECRET_KEY: str = ""
S3_PREFIX: str = "imago" # préfixe dans le bucket
SIGNED_URL_SECRET: str = "changez-moi" # secret HMAC pour LocalStorage
```
4. `app/routers/images.py` — Ajouter les endpoints d'URLs signées :
- `GET /images/{id}/download-url?expires_in=900` → `{ "url": "...", "expires_in": 900 }`
- `GET /images/{id}/thumbnail-url?expires_in=900` → `{ "url": "...", "expires_in": 900 }`
- Vérifier que l'image appartient au client avant de générer l'URL
- Appliquer `require_scope("images:read")`
5. `app/routers/files.py` — Nouveau router pour le stockage local signé :
- `GET /files/signed/{token}` — valide le token HMAC, retourne le fichier via `FileResponse`
- Lever `HTTP 403` si token invalide ou expiré
- Lever `HTTP 410 Gone` si le fichier n'existe plus sur disque
- Ce router n'est monté que si `STORAGE_BACKEND == "local"`
6. `app/main.py` — Supprimer les `StaticFiles` mounts `/static/*` et les remplacer par le router `files.py`
**Contraintes** :
- Le reste du code (routers, pipeline) ne doit **jamais** importer `LocalStorage` ou `S3Storage` directement — uniquement `StorageBackend` et la factory
- Les tokens HMAC doivent avoir une durée de vie stricte (vérification côté serveur à la lecture)
- Le backend S3 doit fonctionner avec MinIO en local (via `S3_ENDPOINT_URL`)
---
### Livrable 2.3 — URLs signées : quota et tracking de l'espace disque (priorité HAUTE)
**Objectif** : enrichir le système d'URLs signées avec le suivi de la consommation de stockage par client pour permettre l'application des quotas.
**Ce qui doit être créé ou modifié** :
1. `app/models/client.py` — Ajouter la méthode de calcul de quota :
```python
async def get_storage_used_bytes(self, db: AsyncSession) -> int:
"""Somme de file_size pour toutes les images actives du client."""
result = await db.execute(
select(func.sum(Image.file_size))
.where(Image.client_id == self.id)
)
return result.scalar_one() or 0
```
2. `app/services/storage.py` — Dans `save_upload()` :
- Avant de sauvegarder, vérifier que `storage_used + file_size <= quota_storage_mb * 1024 * 1024`
- Lever `HTTP 413` avec message clair si quota dépassé : `"Quota de stockage atteint (X MB / Y MB)"`
- Vérifier aussi `count(images) < quota_images` avant l'upload
3. `app/routers/images.py` — Ajouter dans `GET /images` la consommation actuelle dans la réponse :
```json
{
"total": 42,
"storage_used_mb": 128.5,
"storage_quota_mb": 500,
"quota_pct": 25.7
}
```
4. `app/schemas/__init__.py` — Mettre à jour `PaginatedImages` avec les champs de quota
**Contraintes** :
- La vérification de quota doit être atomique (SELECT + INSERT dans la même transaction si possible)
- Ne pas recalculer la somme à chaque requête GET — utiliser une colonne `storage_used_bytes` dénormalisée sur `APIClient`, mise à jour lors des uploads et suppressions
---
### Livrable 2.4 — Logs structurés + métriques Prometheus (priorité MOYENNE)
**Objectif** : remplacer tous les `print()` par des logs JSON structurés et exposer des métriques Prometheus exploitables pour le monitoring de production.
**Nouvelles dépendances** :
```
structlog==24.4.0
prometheus-fastapi-instrumentator==0.14.0
```
**Ce qui doit être créé ou modifié** :
1. `app/logging_config.py` — Configuration structlog centralisée :
```python
import structlog
def configure_logging(debug: bool = False):
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer() if debug
else structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(
logging.DEBUG if debug else logging.INFO
),
)
```
2. Remplacer **chaque `print()`** dans le projet par le logger structlog approprié :
Dans `app/services/pipeline.py` :
```python
log = structlog.get_logger()
# Remplacer print(f"[Pipeline:{image_id}] Étape 1/3 — EXIF") par :
log.info("pipeline.step.started", image_id=image_id, step="exif")
# Remplacer print(f"[Pipeline:{image_id}] EXIF OK") par :
log.info("pipeline.step.completed", image_id=image_id, step="exif",
duration_ms=elapsed, camera=image.exif_make)
# En cas d'erreur :
log.error("pipeline.step.failed", image_id=image_id, step="exif",
error=str(e), exc_info=True)
```
Dans `app/services/storage.py`, `exif_service.py`, `ocr_service.py`, `ai_vision.py` : même principe.
Dans `app/workers/image_worker.py` : logs sur `on_job_start`, `on_job_end`, `on_job_abort`.
3. `app/middleware/logging_middleware.py` — Middleware HTTP de logs :
- Logger chaque requête : `method`, `path`, `status_code`, `duration_ms`, `client_id` (si authentifié)
- Exclure `/health` et `/metrics` pour éviter le bruit
- Format : `log.info("http.request", method="POST", path="/api/v1/images/upload", status=201, duration_ms=45, client_id="abc")`
4. `app/main.py` — Intégration Prometheus :
```python
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator(
should_group_status_codes=True,
should_ignore_untemplated=True,
excluded_handlers=["/health", "/metrics"],
).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
```
5. `app/metrics.py` — Métriques custom applicatives :
```python
from prometheus_client import Counter, Histogram, Gauge
images_uploaded = Counter(
"hub_images_uploaded_total",
"Total uploads par client et par plan",
["client_id", "plan"]
)
pipeline_duration = Histogram(
"hub_pipeline_duration_seconds",
"Durée du pipeline par étape",
["step"],
buckets=[0.1, 0.5, 1, 2, 5, 10, 30, 60, 120]
)
ai_tokens_consumed = Counter(
"hub_ai_tokens_consumed_total",
"Tokens AI consommés par client",
["client_id", "token_type"] # prompt / output
)
pipeline_errors = Counter(
"hub_pipeline_errors_total",
"Erreurs pipeline par étape",
["step"]
)
storage_used_bytes = Gauge(
"hub_storage_used_bytes",
"Espace disque utilisé par client",
["client_id"]
)
arq_queue_size = Gauge(
"hub_arq_queue_size",
"Tâches en attente dans ARQ",
["queue"]
)
```
- Incrémenter ces métriques aux bons endroits (upload, pipeline, suppression)
6. `app/main.py` — Améliorer `/health/detailed` :
```python
@app.get("/health/detailed")
async def health_detailed(db: AsyncSession = Depends(get_db)):
checks = {}
t0 = time()
# Base de données
try:
await db.execute(text("SELECT 1"))
checks["database"] = {"status": "ok", "latency_ms": round((time()-t0)*1000)}
except Exception as e:
checks["database"] = {"status": "error", "detail": str(e)}
# Redis
try:
await app.state.redis.ping()
checks["redis"] = {"status": "ok"}
except Exception as e:
checks["redis"] = {"status": "error", "detail": str(e)}
# ARQ queue
try:
queue_info = await app.state.arq_pool.queued_jobs()
checks["queue"] = {"status": "ok", "pending_jobs": len(queue_info)}
except Exception as e:
checks["queue"] = {"status": "error", "detail": str(e)}
# Tesseract
try:
pytesseract.get_tesseract_version()
checks["tesseract"] = {"status": "ok"}
except Exception as e:
checks["tesseract"] = {"status": "error", "detail": str(e)}
# API Anthropic (vérification clé uniquement, sans appel facturable)
checks["anthropic"] = {
"status": "ok" if settings.ANTHROPIC_API_KEY else "error",
"configured": bool(settings.ANTHROPIC_API_KEY)
}
# Espace disque
try:
usage = shutil.disk_usage(settings.UPLOAD_DIR)
pct = round(usage.used / usage.total * 100, 1)
checks["disk"] = {
"status": "warning" if pct > 85 else "ok",
"used_pct": pct,
"free_gb": round(usage.free / 1e9, 2)
}
except Exception as e:
checks["disk"] = {"status": "error", "detail": str(e)}
overall = "healthy" if all(
c.get("status") in ("ok", "warning") for c in checks.values()
) else "degraded"
return {
"status": overall,
"version": settings.APP_VERSION,
"checks": checks,
"checked_at": datetime.utcnow().isoformat()
}
```
**Contraintes** :
- Zéro `print()` restant dans tout le projet après ce livrable
- Les logs de requêtes HTTP ne doivent pas logger les valeurs des headers `Authorization`
- Les métriques Prometheus ne doivent pas exposer de données sensibles (pas de `client_id` en clair dans les labels si trop nombreux — utiliser le `plan` à la place)
---
### Livrable 2.5 — CI/CD complet avec GitHub Actions (priorité HAUTE)
**Objectif** : automatiser entièrement la qualité du code, les tests, le build Docker et le déploiement.
**Nouvelles dépendances dev** — créer `requirements-dev.txt` :
```
pytest==8.3.0
pytest-asyncio==0.24.0
pytest-cov==5.0.0
httpx==0.27.2
ruff==0.6.0
black==24.8.0
mypy==1.11.0
bandit==1.7.10
pre-commit==3.8.0
faker==27.0.0 # génération de données de test
```
**Ce qui doit être créé** :
1. `.github/workflows/ci.yml` — Pipeline CI complet :
**Job `quality`** — sur chaque push et PR :
```yaml
- name: Lint (ruff)
run: ruff check . --output-format=github
- name: Format check (black)
run: black --check . --line-length 100
- name: Type check (mypy)
run: mypy app/ --strict --ignore-missing-imports
- name: Security scan (bandit)
run: bandit -r app/ -ll -x tests/
```
**Job `tests`** — sur chaque push et PR :
```yaml
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
steps:
- run: pytest tests/ -v --cov=app --cov-report=xml --cov-fail-under=80
- uses: codecov/codecov-action@v4
```
**Job `docker`** — sur push vers `main` uniquement :
```yaml
needs: [quality, tests]
steps:
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
```
**Job `deploy-staging`** — sur push vers `main`, après `docker` :
- Déploiement vers un environnement de staging via SSH ou webhook
- Commenter dans le workflow s'il n'y a pas encore d'environnement de staging
2. `.pre-commit-config.yaml` — Hooks locaux :
```yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
hooks:
- id: mypy
args: [--strict, --ignore-missing-imports]
additional_dependencies: [types-all]
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10
hooks:
- id: bandit
args: [-ll, -x, tests/]
```
3. `pyproject.toml` — Configuration centralisée des outils :
```toml
[tool.ruff]
line-length = 100
target-version = "py312"
select = ["E", "F", "I", "N", "W", "UP", "S", "B", "A"]
ignore = ["S101"] # autorise les assert dans les tests
[tool.black]
line-length = 100
target-version = ["py312"]
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "--tb=short -q"
[tool.coverage.run]
source = ["app"]
omit = ["app/main.py", "*/migrations/*"]
[tool.coverage.report]
fail_under = 80
show_missing = true
```
4. `Makefile` — Raccourcis de développement :
```makefile
.PHONY: dev test lint format typecheck security ci
dev:
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
worker:
python worker.py
test:
pytest tests/ -v --cov=app --cov-report=term-missing
lint:
ruff check . --fix
format:
black . --line-length 100
typecheck:
mypy app/ --strict --ignore-missing-imports
security:
bandit -r app/ -ll -x tests/
ci: lint format typecheck security test
@echo "✅ Tous les checks CI passent"
migrate:
alembic upgrade head
docker-up:
docker-compose up -d
docker-logs:
docker-compose logs -f backend worker
```
5. `tests/test_pipeline_arq.py` — Tests du pipeline ARQ :
- Mocker `arq_pool.enqueue_job` pour vérifier que la bonne queue est utilisée selon le plan
- Tester que `process_image_task` est appelé avec les bons paramètres
- Tester le comportement en cas d'échec (image marquée `error` après `max_tries`)
- Tester que les événements Redis sont publiés aux bonnes étapes
6. `tests/test_storage.py` — Tests du StorageBackend :
- Tester `LocalStorage` avec un répertoire temporaire (`tmp_path` de pytest)
- Tester que les tokens HMAC expirent correctement
- Tester que `S3Storage` appelle les bonnes méthodes boto3 (avec `moto` pour mocker AWS)
- Tester la vérification de quota dans `save_upload()`
7. `tests/test_observability.py` — Tests des métriques et logs :
- Vérifier que `GET /metrics` retourne `HTTP 200` avec du texte Prometheus
- Vérifier que `GET /health/detailed` retourne un statut structuré
- Vérifier que les métriques `hub_images_uploaded_total` s'incrémentent à chaque upload
**Contraintes** :
- Le pipeline CI doit échouer si la couverture de tests descend sous 80%
- `mypy --strict` doit passer sans erreur sur tout le répertoire `app/`
- `bandit` ne doit rapporter aucun problème de sévérité HIGH ou MEDIUM
- Aucun secret ne doit apparaître dans les logs du CI (utiliser `${{ secrets.* }}` pour tout)
</mission>
<execution_rules>
## Règles d'exécution
### Prérequis avant de commencer
1. Vérifier que la Phase 1 est bien en place : `pytest tests/ -v` doit passer sans erreur
2. Lire les fichiers existants concernés **avant** de les modifier
3. S'assurer que Redis est disponible localement pour les tests ARQ
### Ordre de réalisation
Implémenter dans l'ordre strict : **2.1 → 2.2 → 2.3 → 2.4 → 2.5**. Valider chaque livrable avec `pytest` avant de continuer.
### À chaque livrable
1. Lire les fichiers concernés avec l'outil `Read` ou `Glob`
2. Implémenter le code complet (zéro `# TODO`, zéro `...` comme corps de fonction)
3. Mettre à jour `requirements.txt` et `requirements-dev.txt` si nécessaire
4. Créer la migration Alembic si le schéma change
5. Lancer `pytest tests/ -v` et corriger avant de passer au suivant
6. Pour le livrable 2.5, lancer `make ci` pour valider l'ensemble
### Qualité du code
- **Typage complet** : toutes les fonctions annotées, compatible `mypy --strict`
- **Docstrings** sur toutes les classes et méthodes publiques
- **Zéro secret hardcodé** : tout passe par `app/config.py` + `.env`
- **Zéro `print()`** : remplacé par `structlog` dans tout le projet
- **Gestion explicite des exceptions** : chaque `except` doit logger + réagir, jamais `pass`
- **Fermeture propre des ressources** : pools Redis, connexions S3 fermées dans le lifespan
### Infrastructure locale pour le développement
Ajouter dans `docker-compose.yml` (si pas déjà présent) :
```yaml
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: ["redis_data:/data"]
command: redis-server --appendonly yes
minio:
image: minio/minio
ports: ["9000:9000", "9001:9001"]
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes: ["minio_data:/data"]
volumes:
redis_data:
minio_data:
```
### Critère de succès final
Les commandes suivantes doivent toutes réussir sans erreur :
```bash
# Tests complets avec couverture
pytest tests/ -v --cov=app --cov-report=term-missing
# Qualité de code
make ci
# Démarrage du serveur
python run.py
# Démarrage du worker ARQ (dans un autre terminal)
python worker.py
# Vérification de santé
curl http://localhost:8000/health/detailed | python -m json.tool
```
La mise a jour des documentations API_GUIDE.md et README.md est aussi requise.
</execution_rules>
<deliverable_summary>
## Résumé des livrables attendus
| # | Fichiers créés ou modifiés | Validation |
|---|---|---|
| **2.1** | `app/workers/image_worker.py`, `app/workers/redis_client.py`, `app/services/pipeline.py`, `app/routers/images.py`, `app/main.py`, `app/config.py`, `worker.py`, `docker-compose.yml` | `pytest tests/test_pipeline_arq.py` |
| **2.2** | `app/services/storage_backend.py`, `app/services/storage.py`, `app/routers/files.py`, `app/routers/images.py`, `app/main.py`, `app/config.py` | `pytest tests/test_storage.py` |
| **2.3** | `app/models/client.py`, `app/services/storage.py`, `app/routers/images.py`, `app/schemas/__init__.py` | `pytest tests/test_storage.py -k quota` |
| **2.4** | `app/logging_config.py`, `app/metrics.py`, `app/middleware/logging_middleware.py`, `app/main.py`, + remplacement de tous les `print()` dans `services/` et `workers/` | `pytest tests/test_observability.py` |
| **2.5** | `.github/workflows/ci.yml`, `.pre-commit-config.yaml`, `pyproject.toml`, `Makefile`, `requirements-dev.txt`, `tests/test_pipeline_arq.py`, `tests/test_storage.py`, `tests/test_observability.py` | `make ci` |
**Résultat final de la Phase 2 :** hub production-ready avec pipeline persistant et retry automatique, stockage abstrait compatible S3/MinIO, logs JSON structurés, métriques Prometheus et pipeline CI/CD complet.
</deliverable_summary>
+855
View File
@@ -0,0 +1,855 @@
# Prompt Claude Code — Phase 3 : Expérience développeur
# Modèle : claude-opus-4-6
# Usage : claude --model claude-opus-4-6 -p "$(cat PROMPT_PHASE3_claude-opus.md)"
# ou coller directement dans une session Claude Code interactive
---
<role>
Tu es un ingénieur backend senior Python spécialisé en conception d'APIs publiques, expérience développeur (DX) et intégration de systèmes. Tu travailles sur le projet Imago pour livrer sa phase finale : rendre ce hub aussi simple et agréable à intégrer que possible pour ses clients.
Les Phases 1 (sécurité, multi-tenants, rate limiting) et Phase 2 (ARQ + Redis, StorageBackend abstrait, structlog, Prometheus, CI/CD) sont entièrement terminées et validées. Tu prends le relais pour la dernière phase : WebSockets temps réel, versioning de l'API, SDK Python officiel, dashboard admin et intégration complète avec Shaarli.
</role>
<project_context>
## État du projet après Phase 1 et Phase 2
### Stack complète en place
```
# Phase 0 — Base
fastapi==0.115.0 # framework web async
uvicorn[standard]==0.30.6 # serveur ASGI
sqlalchemy[asyncio]==2.0.35 # ORM async
alembic==1.13.3 # migrations
pydantic-settings==2.5.2 # config depuis .env
pillow==10.4.0 # traitement images + thumbnails
piexif==1.1.3 # extraction EXIF avancée
pytesseract==0.3.13 # OCR Tesseract
anthropic==0.34.2 / httpx==0.27.2 # Vision AI Claude
beautifulsoup4==4.12.3 # scraping web
aiofiles==24.1.0 # I/O fichiers async
# Phase 1 — Sécurité
python-jose[cryptography]==3.3.0 # JWT
passlib[bcrypt]==1.7.4 # hachage API Keys
slowapi==0.1.9 # rate limiting par client/plan
# Phase 2 — Robustesse
arq==0.25.0 # file de tâches async persistante
redis==5.0.8 # client Redis (Pub/Sub + ARQ)
aioboto3==13.0.0 # stockage S3/MinIO/R2 async
structlog==24.4.0 # logs JSON structurés
prometheus-fastapi-instrumentator==0.14.0 # métriques Prometheus
```
### Structure complète du projet (après Phase 2)
```
imago/
├── app/
│ ├── main.py # App FastAPI, lifespan, middlewares, /metrics, /health
│ ├── config.py # Settings complets (auth, redis, S3, logging, quotas)
│ ├── database.py # Engine SQLAlchemy async + session factory
│ ├── logging_config.py # Configuration structlog JSON
│ ├── metrics.py # Compteurs/Histogrammes Prometheus custom
│ ├── models/
│ │ ├── image.py # Image (client_id FK, EXIF, OCR, AI, statut pipeline)
│ │ └── client.py # APIClient (id, name, hash, scopes, plan, quotas, storage_used)
│ ├── schemas/
│ │ └── __init__.py # Schémas Pydantic complets avec quotas
│ ├── dependencies/
│ │ └── auth.py # verify_api_key, require_scope, get_current_client
│ ├── middleware/
│ │ ├── rate_limit.py # Rate limiting par plan (slowapi)
│ │ └── logging_middleware.py # Logs HTTP structurés par requête
│ ├── routers/
│ │ ├── auth.py # CRUD clients, rotation de clé (/auth/*)
│ │ ├── images.py # Endpoints images filtrés par client (/images/*)
│ │ ├── ai.py # Endpoints AI (/ai/summarize, /ai/draft-task)
│ │ └── files.py # Serveur fichiers signés (/files/signed/{token})
│ ├── services/
│ │ ├── storage_backend.py # ABC StorageBackend + LocalStorage + S3Storage
│ │ ├── storage.py # Orchestration upload/delete avec quota check
│ │ ├── exif_service.py # Extraction EXIF (piexif)
│ │ ├── ocr_service.py # OCR Tesseract
│ │ ├── ai_vision.py # Vision AI + summarize_url + draft_task
│ │ ├── scraper.py # Scraping web BeautifulSoup
│ │ └── pipeline.py # Orchestration EXIF→OCR→AI + publication Redis Pub/Sub
│ └── workers/
│ ├── image_worker.py # Worker ARQ (process_image_task, WorkerSettings)
│ └── redis_client.py # Pool Redis async partagé
├── tests/
│ ├── conftest.py # Fixtures : test_db, client_a/b, auth_headers, redis_mock
│ ├── test_services.py # Tests unitaires services (EXIF, OCR, storage)
│ ├── test_auth.py # Tests authentification et scopes
│ ├── test_isolation.py # Tests isolation multi-tenants
│ ├── test_rate_limit.py # Tests rate limiting par plan
│ ├── test_pipeline_arq.py # Tests worker ARQ (enqueue, retry, dead-letter)
│ ├── test_storage.py # Tests StorageBackend + quota + URLs signées
│ └── test_observability.py # Tests /metrics et /health/detailed
├── alembic/
│ └── versions/ # Migrations : images, api_clients, storage_used
├── worker.py # Point d'entrée : python worker.py
├── run.py # Point d'entrée serveur : python run.py
├── Makefile # make dev | test | ci | lint | docker-up
├── pyproject.toml # Config ruff, black, mypy, pytest, coverage
├── requirements.txt # Dépendances production
├── requirements-dev.txt # Dépendances dev (pytest, ruff, mypy, bandit...)
├── .pre-commit-config.yaml # Hooks pre-commit
├── .github/workflows/ci.yml # Pipeline CI/CD (quality + tests + docker + deploy)
├── Dockerfile
└── docker-compose.yml # backend + worker + redis + minio
```
### Ce qui manque encore (cible de la Phase 3)
**WebSockets absents** : le pipeline publie déjà sur Redis Pub/Sub (Phase 2), mais aucun endpoint WebSocket n'expose ces événements aux clients. Ils doivent encore poller `GET /images/{id}/status`.
**API non versionnée** : tous les endpoints sont sous `/images/`, `/ai/`, `/auth/`. Si l'on change un contrat, tous les clients cassent. Aucune politique de dépréciation.
**Pas de SDK** : chaque client doit réimplémenter l'upload, le polling, la gestion des erreurs, le retry. Shaarli devra faire de même.
**Dashboard admin absent** : aucune interface pour superviser les clients, leurs quotas et la santé du hub. Les opérations admin passent par des appels curl directs.
**Intégration Shaarli non finalisée** : Shaarli est le premier client officiel mais l'intégration n'est pas documentée, pas testée de bout en bout, et aucun exemple de code n'existe.
</project_context>
<mission>
## Mission : implémenter la Phase 3 — Expérience développeur
Tu dois réaliser les 5 livrables suivants dans l'ordre indiqué. Chaque livrable doit être **complet, testé et fonctionnel** avant de passer au suivant.
---
### Livrable 3.1 — WebSockets pipeline temps réel (priorité MOYENNE)
**Objectif** : exposer les événements Redis Pub/Sub aux clients via WebSocket, éliminant complètement le besoin de polling.
**Aucune nouvelle dépendance** : FastAPI supporte nativement les WebSockets via `websockets` (déjà inclus dans `uvicorn[standard]`).
**Ce qui doit être créé ou modifié** :
1. `app/routers/websocket.py` — Nouveau router WebSocket :
**Endpoint principal** :
```
WS /ws/pipeline/{image_id}?token=<api_key>
```
- Authentification via query param `token` (pas de header Authorization sur WebSocket)
- Valider que l'image appartient au client authentifié avant d'accepter la connexion
- S'abonner au channel Redis `pipeline:{image_id}`
- Pusher chaque message JSON reçu vers le client WebSocket
- Fermer proprement après réception de `pipeline.done` ou `pipeline.error`
- Gérer la déconnexion du client : si le client se déconnecte avant la fin, se désabonner proprement de Redis
**Endpoint de liste des pipelines actifs** (admin) :
```
WS /ws/admin/monitor?token=<admin_api_key>
```
- Nécessite le scope `admin`
- Pusher un événement à chaque fois qu'un pipeline démarre ou se termine sur n'importe quelle image
**Buffer de reconnexion** :
- Stocker les 10 derniers événements d'un pipeline dans Redis (clé `pipeline:buffer:{image_id}`, TTL 60s)
- Si un client se connecte après que le pipeline a démarré, envoyer d'abord les événements bufferisés puis continuer en live
**Format exact des messages** (doit correspondre à ce que publie `pipeline.py`) :
```json
{ "event": "pipeline.started", "image_id": 42, "steps": ["exif", "ocr", "ai"], "timestamp": "..." }
{ "event": "step.completed", "image_id": 42, "step": "exif", "duration_ms": 45, "data": { "camera": "Canon EOS R5", "has_gps": true } }
{ "event": "step.completed", "image_id": 42, "step": "ocr", "duration_ms": 820, "data": { "has_text": true, "preview": "Café de..." } }
{ "event": "step.completed", "image_id": 42, "step": "ai", "duration_ms": 3200, "data": { "tags": ["café", "paris"], "confidence": 0.97 } }
{ "event": "pipeline.done", "image_id": 42, "total_duration_ms": 4065, "status": "done" }
{ "event": "pipeline.error", "image_id": 42, "step": "ai", "error": "API timeout", "retry_attempt": 1 }
```
**Gestion des erreurs** :
- Si l'image est déjà en statut `done` quand le client se connecte → envoyer immédiatement un message `pipeline.done` synthétique et fermer
- Si l'image est en statut `error` → envoyer `pipeline.error` synthétique et fermer
- Si l'image n'appartient pas au client → fermer avec code WebSocket 4003 (Forbidden)
2. `app/metrics.py` — Ajouter la gauge `hub_active_websockets` :
- Incrémenter à l'ouverture d'une connexion WebSocket
- Décrémenter à la fermeture (succès ou erreur)
3. `app/main.py` — Monter le router WebSocket :
```python
from app.routers.websocket import router as ws_router
app.include_router(ws_router) # pas de préfixe /api/v1 — WS garde son propre préfixe /ws
```
4. `tests/test_websocket.py` — Tests complets :
- ✅ Connexion sans token → refus immédiat (code 4001)
- ✅ Connexion avec token valide → acceptée
- ✅ Image appartenant à un autre client → refus (code 4003)
- ✅ Image déjà `done` → reçoit `pipeline.done` synthétique et connexion fermée proprement
- ✅ Réception des événements dans l'ordre : started → step×3 → done
- ✅ Déconnexion client en cours de pipeline → désabonnement Redis propre (pas de goroutine/tâche en fuite)
- ✅ Reconnexion → buffer de 60s rejoué
Pour les tests, utiliser `httpx` avec `ASGITransport` ou le client WebSocket de `starlette.testclient` (`with client.websocket_connect(...)`)
---
### Livrable 3.2 — API versioning `/api/v1/` + politique de dépréciation (priorité MOYENNE)
**Objectif** : structurer l'API sous un préfixe versionné pour garantir la stabilité des contrats pour tous les clients intégrés.
**Ce qui doit être créé ou modifié** :
1. `app/main.py` — Restructurer le montage des routers :
**Avant** (sans versioning) :
```python
app.include_router(images_router)
app.include_router(ai_router)
app.include_router(auth_router)
```
**Après** (versioning propre) :
```python
from fastapi import APIRouter
# Router versionné v1
api_v1 = APIRouter(prefix="/api/v1", tags=["v1"])
api_v1.include_router(images_router)
api_v1.include_router(ai_router)
api_v1.include_router(auth_router)
# WebSocket et fichiers signés — pas de préfixe /api/v1
app.include_router(ws_router) # /ws/*
app.include_router(files_router) # /files/*
# Routes non versionnées (infra)
# GET / → info
# GET /health
# GET /health/detailed
# GET /metrics
app.include_router(api_v1)
```
2. `app/middleware/versioning.py` — Middleware de versioning :
- Ajouter le header `X-API-Version: v1` sur toutes les réponses des routes `/api/v1/*`
- Préparer (en commentaire) l'ajout futur de `Deprecation: true` et `Sunset: <date>` quand v2 existera
- Logger un warning structlog si une route `/api/v1/*` est appelée après la date de sunset (configurable)
3. `app/config.py` — Ajouter :
```python
API_V1_SUNSET_DATE: str = "" # vide = pas de sunset, sinon "2027-06-01"
```
4. `app/main.py` — Mettre à jour la documentation OpenAPI :
```python
app = FastAPI(
title="Imago API",
version="1.0.0",
description="""
## Imago — API v1
Hub centralisé de gestion d'images avec pipeline AI automatique.
### Authentification
Toutes les routes `/api/v1/*` nécessitent un header :
```
Authorization: Bearer <votre_api_key>
```
### Versioning
Cette API respecte le versioning sémantique. La version actuelle est **v1**.
Les changements breaking seront introduits dans `/api/v2/` avec un préavis minimum de 12 mois.
### Rate Limiting
Les limites varient selon le plan :
- `free` : 20 uploads/h, 50 requêtes AI/h
- `standard` : 100 uploads/h, 200 requêtes AI/h
- `premium` : 500 uploads/h, 1000 requêtes AI/h
""",
openapi_tags=[
{"name": "Images", "description": "Gestion des images et pipeline AI"},
{"name": "Intelligence Artificielle", "description": "Résumé d'URL, rédaction de tâches"},
{"name": "Authentification", "description": "Gestion des clients API"},
{"name": "WebSocket", "description": "Notifications temps réel du pipeline"},
{"name": "Santé", "description": "Health checks et métriques"},
],
)
```
5. `tests/test_versioning.py` — Tests :
- ✅ Tous les endpoints images/ai/auth répondent sous `/api/v1/`
- ✅ Les anciennes URLs sans préfixe retournent `HTTP 404`
- ✅ Header `X-API-Version: v1` présent sur toutes les réponses `/api/v1/*`
- ✅ Header absent sur `/health`, `/metrics`, `/ws/*`
---
### Livrable 3.3 — SDK Python officiel `imago-client` (priorité MOYENNE)
**Objectif** : fournir un SDK Python idiomatique que n'importe quel client (Shaarli, scripts, autres apps) peut installer avec `pip install imago-client` et utiliser sans connaître les détails de l'API REST.
**Architecture du SDK** : le SDK est un **package Python séparé** dans le même dépôt (monorepo), dans le dossier `sdk/`.
**Structure du SDK** :
```
sdk/
├── pyproject.toml # Package config (build-system, metadata)
├── README.md # Documentation d'utilisation du SDK
├── imago_client/
│ ├── __init__.py # Exports publics : HubClient, HubImage, HubError
│ ├── client.py # HubClient — point d'entrée principal
│ ├── resources/
│ │ ├── images.py # ImagesResource — méthodes images
│ │ ├── ai.py # AIResource — résumé URL, tâches
│ │ └── auth.py # AuthResource — gestion clients (admin)
│ ├── models.py # Dataclasses : HubImage, HubExif, HubOcr, HubAI
│ ├── websocket.py # PipelineStream — suivi temps réel
│ ├── exceptions.py # HubError, AuthError, QuotaError, NotFoundError
│ └── utils.py # Retry, timeout, helpers
└── tests/
├── test_client.py
├── test_images.py
└── test_websocket_sdk.py
```
**Ce qui doit être créé** :
1. `sdk/imago_client/exceptions.py` :
```python
class HubError(Exception):
"""Erreur de base du SDK."""
def __init__(self, message: str, status_code: int | None = None):
self.status_code = status_code
super().__init__(message)
class AuthError(HubError):
"""Clé API invalide ou scope manquant (401/403)."""
class QuotaError(HubError):
"""Quota dépassé (413 ou 429)."""
class NotFoundError(HubError):
"""Ressource introuvable (404)."""
class PipelineError(HubError):
"""Erreur lors du traitement AI d'une image."""
```
2. `sdk/imago_client/models.py` — Dataclasses des réponses :
```python
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class HubExif:
camera_make: str | None = None
camera_model: str | None = None
taken_at: datetime | None = None
gps_latitude: float | None = None
gps_longitude: float | None = None
iso: int | None = None
aperture: str | None = None
@dataclass
class HubOcr:
has_text: bool = False
text: str | None = None
language: str | None = None
confidence: float | None = None
@dataclass
class HubAI:
description: str | None = None
tags: list[str] = field(default_factory=list)
confidence: float | None = None
model_used: str | None = None
@dataclass
class HubImage:
id: int
uuid: str
original_name: str
status: str # pending | processing | done | error
exif: HubExif = field(default_factory=HubExif)
ocr: HubOcr = field(default_factory=HubOcr)
ai: HubAI = field(default_factory=HubAI)
width: int | None = None
height: int | None = None
file_size: int | None = None
uploaded_at: datetime | None = None
```
3. `sdk/imago_client/websocket.py` — `PipelineStream` :
```python
class PipelineStream:
"""Suivi temps réel du pipeline via WebSocket avec fallback polling."""
async def __aiter__(self) -> AsyncIterator[PipelineEvent]:
"""Itère sur les événements du pipeline jusqu'à done/error."""
async def wait_until_done(self, timeout: float = 120.0) -> HubImage:
"""Attend la fin du pipeline et retourne l'image complète."""
async def _try_websocket(self) -> bool:
"""Tente la connexion WebSocket. Retourne False si indisponible."""
async def _fallback_polling(self, interval: float = 2.0) -> None:
"""Polling de /status si WebSocket indisponible."""
```
4. `sdk/imago_client/resources/images.py` — `ImagesResource` :
```python
class ImagesResource:
async def upload(
self,
file: str | Path | bytes | BinaryIO,
*,
filename: str | None = None,
) -> PipelineStream:
"""Upload une image et retourne un stream de suivi du pipeline."""
async def get(self, image_id: int) -> HubImage:
"""Récupère les détails complets d'une image."""
async def list(
self,
*,
page: int = 1,
page_size: int = 20,
tag: str | None = None,
status: str | None = None,
search: str | None = None,
) -> tuple[list[HubImage], int]: # (images, total)
"""Liste les images avec pagination et filtres."""
async def delete(self, image_id: int) -> None:
"""Supprime une image."""
async def get_download_url(self, image_id: int, expires_in: int = 900) -> str:
"""Retourne une URL signée pour télécharger l'image."""
async def reprocess(self, image_id: int) -> None:
"""Relance le pipeline AI sur une image existante."""
```
5. `sdk/imago_client/client.py` — `HubClient` :
```python
class HubClient:
def __init__(
self,
api_key: str,
base_url: str = "http://localhost:8000",
*,
timeout: float = 30.0,
max_retries: int = 3,
retry_backoff: float = 1.0,
):
self.images = ImagesResource(self)
self.ai = AIResource(self)
self.auth = AuthResource(self) # admin uniquement
async def __aenter__(self) -> "HubClient": ...
async def __aexit__(self, *args) -> None: ...
# Usage :
# async with HubClient(api_key="sk-...") as hub:
# stream = await hub.images.upload("photo.jpg")
# image = await stream.wait_until_done()
# print(image.ai.description)
```
6. `sdk/imago_client/resources/ai.py` — `AIResource` :
```python
class AIResource:
async def summarize_url(self, url: str, language: str = "français") -> dict:
"""Résumé AI d'une URL web."""
async def draft_task(
self, description: str, context: str | None = None, language: str = "français"
) -> dict:
"""Génère une tâche structurée depuis une description libre."""
```
7. `sdk/pyproject.toml` :
```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "imago-client"
version = "1.0.0"
description = "SDK Python officiel pour le Imago"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"httpx>=0.27.0",
"websockets>=13.0",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio", "respx"] # respx pour mocker httpx
```
8. `sdk/README.md` — Documentation complète avec exemples :
```markdown
# imago-client
## Installation
pip install imago-client
## Usage rapide
```python
import asyncio
from imago_client import HubClient
async def main():
async with HubClient(api_key="sk-...", base_url="https://hub.example.com") as hub:
# Upload et attente du pipeline
stream = await hub.images.upload("photo.jpg")
image = await stream.wait_until_done(timeout=120)
print(f"Description : {image.ai.description}")
print(f"Tags : {', '.join(image.ai.tags)}")
print(f"Appareil : {image.exif.camera_make} {image.exif.camera_model}")
# Résumé d'URL
result = await hub.ai.summarize_url("https://example.com/article")
print(result["summary"])
```
9. `sdk/tests/` — Tests du SDK avec `respx` pour mocker httpx :
- ✅ `upload()` retourne un `PipelineStream` valide
- ✅ `wait_until_done()` complète via WebSocket
- ✅ `wait_until_done()` complète via polling si WebSocket indisponible
- ✅ `HubError` levée sur HTTP 4xx/5xx
- ✅ `AuthError` sur 401, `QuotaError` sur 413/429, `NotFoundError` sur 404
- ✅ Retry automatique sur erreurs 5xx (max_retries fois)
- ✅ Context manager `async with HubClient(...) as hub:` ferme les connexions proprement
---
### Livrable 3.4 — Dashboard admin (priorité FAIBLE)
**Objectif** : fournir une interface web légère permettant de superviser le hub sans passer par curl — visualiser les clients, leurs quotas, les métriques du pipeline et la santé des composants.
**Choix technique** : **pas de framework frontend séparé**. Le dashboard est une interface HTML/JS servie directement par FastAPI via `Jinja2Templates`. Simple, sans build step, déployé avec le backend.
**Nouvelle dépendance** :
```
jinja2==3.1.4
```
**Ce qui doit être créé** :
1. `app/routers/admin.py` — Router admin avec pages HTML et endpoints JSON :
**Pages HTML** (rendu Jinja2, scope `admin` requis) :
- `GET /admin/` → Dashboard overview (métriques globales, santé)
- `GET /admin/clients` → Liste des clients avec quotas et consommation
- `GET /admin/clients/{id}` → Détail d'un client (images, tokens AI, activité)
- `GET /admin/queue` → État de la file ARQ (jobs en attente, en cours, échoués)
- `GET /admin/storage` → Consommation disque par client
**Endpoints JSON** (appelés par le JS du dashboard) :
- `GET /admin/api/stats` → Statistiques globales (total images, tokens AI, taille stockage)
- `GET /admin/api/clients` → Liste clients avec métriques temps réel
- `GET /admin/api/queue/status` → Jobs ARQ en cours et en attente
- `POST /admin/api/clients/{id}/toggle` → Activer/désactiver un client
- `POST /admin/api/clients/{id}/reset-quota` → Réinitialiser les compteurs de quota
2. `app/templates/admin/` — Templates Jinja2 :
**`base.html`** — Layout commun :
- Sidebar avec navigation (Dashboard, Clients, Queue, Stockage)
- Header avec nom du hub et version
- Zone de contenu principale
- Style minimal avec CSS vanilla (pas de dépendance externe — tout inline ou dans `static/`)
**`dashboard.html`** — Page principale :
- Cartes métriques : total images, clients actifs, tokens AI consommés ce mois, espace utilisé
- Graphique simple (Chart.js CDN) : uploads par jour sur 30 jours
- Tableau santé des composants (BDD, Redis, ARQ, Tesseract, Anthropic) avec badge coloré
**`clients.html`** — Liste des clients :
- Tableau : nom, plan, images, stockage utilisé/quota, tokens AI ce mois, statut actif
- Badge coloré par plan (free = gris, standard = bleu, premium = or)
- Bouton activer/désactiver inline (appel AJAX vers `/admin/api/clients/{id}/toggle`)
- Lien vers le détail de chaque client
**`client_detail.html`** — Détail d'un client :
- En-tête : nom, plan, API key (masquée `sk-...****`), date de création
- Jauges de quota : images (X/Y), stockage (X MB / Y MB), tokens AI (X/Y)
- Tableau des 20 dernières images avec statut pipeline et tags AI
**`queue.html`** — État de la file ARQ :
- Compteurs : jobs en attente, en cours, réussis, échoués (dernières 24h)
- Tableau des jobs actifs : image_id, client, étape en cours, durée
- Tableau des jobs échoués récents avec message d'erreur
3. `app/static/admin/` — Fichiers statiques du dashboard :
- `style.css` — styles du dashboard (palette cohérente avec le projet)
- `dashboard.js` — rafraîchissement automatique des métriques toutes les 30s via `fetch`
4. `app/main.py` — Monter le dashboard :
```python
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
templates = Jinja2Templates(directory="app/templates")
app.mount("/admin/static", StaticFiles(directory="app/static/admin"), name="admin_static")
app.include_router(admin_router)
```
5. `tests/test_admin.py` — Tests du dashboard :
- ✅ `GET /admin/` sans scope `admin` → HTTP 403
- ✅ `GET /admin/` avec scope `admin` → HTTP 200 avec HTML valide
- ✅ `GET /admin/api/stats` → JSON avec les bons champs
- ✅ `POST /admin/api/clients/{id}/toggle` → change `is_active`
- ✅ Client désactivé → ses requêtes API retournent 401
---
### Livrable 3.5 — Intégration Shaarli complète + documentation (priorité HAUTE)
**Objectif** : finaliser l'intégration entre Shaarli (premier client officiel) et le hub, avec une documentation technique complète qui permettrait à n'importe quel développeur d'intégrer le hub en moins d'une heure.
**Ce qui doit être créé** :
1. `integration/shaarli/` — Module d'intégration Shaarli :
**`integration/shaarli/hub_plugin.py`** — Plugin Python utilisable depuis Shaarli :
```python
"""
Plugin d'intégration Shaarli ↔ Imago.
Installe dans Shaarli via : pip install imago-client
"""
import asyncio
from pathlib import Path
from imago_client import HubClient
class ShaarliHubPlugin:
"""
Enrichit les bookmarks Shaarli avec des images analysées par le hub.
Usage :
plugin = ShaarliHubPlugin(api_key="sk-...", hub_url="http://hub:8000")
# Sur ajout d'un bookmark avec image :
result = asyncio.run(plugin.process_bookmark_image("photo.jpg", bookmark_id=42))
"""
def __init__(self, api_key: str, hub_url: str):
self.hub_url = hub_url
self.api_key = api_key
async def process_bookmark_image(
self, image_path: str | Path, bookmark_id: int
) -> dict:
"""Upload une image associée à un bookmark et attend l'analyse AI."""
async with HubClient(self.api_key, self.hub_url) as hub:
stream = await hub.images.upload(image_path)
image = await stream.wait_until_done(timeout=120)
return {
"bookmark_id": bookmark_id,
"image_id": image.id,
"description": image.ai.description,
"tags": image.ai.tags,
"ocr_text": image.ocr.text,
}
async def enrich_bookmark_url(self, url: str) -> dict:
"""Génère un résumé AI d'un lien web pour enrichir un bookmark."""
async with HubClient(self.api_key, self.hub_url) as hub:
return await hub.ai.summarize_url(url)
```
**`integration/shaarli/config_example.env`** :
```bash
# Configuration du plugin Imago
HUB_URL=http://imago:8000
HUB_API_KEY=sk-votre-cle-api-ici
HUB_TIMEOUT=120
```
**`integration/shaarli/example_usage.py`** — Exemples commentés de tous les cas d'usage :
- Upload d'image depuis un bookmark
- Résumé d'URL pour un nouveau lien
- Recherche d'images par tag
- Gestion des erreurs (quota dépassé, timeout, réseau)
2. `docs/` — Documentation technique complète :
**`docs/getting-started.md`** — Guide de démarrage en 5 minutes :
```markdown
# Démarrage rapide
## 1. Lancer le hub
git clone ...
cp .env.example .env
# Éditer .env : ANTHROPIC_API_KEY=sk-ant-...
docker-compose up -d
# Hub disponible sur http://localhost:8000
## 2. Créer votre premier client
curl -X POST http://localhost:8000/api/v1/auth/clients \
-H "Authorization: Bearer $ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Mon App", "plan": "standard", "scopes": ["images:read", "images:write", "ai:use"]}'
# → { "api_key": "sk-VOTRE-CLE-ICI" } ← la clé n'est affichée qu'une seule fois
## 3. Uploader votre première image
curl -X POST http://localhost:8000/api/v1/images/upload \
-H "Authorization: Bearer sk-VOTRE-CLE-ICI" \
-F "[email protected]"
# → { "id": 1, "status": "pending", ... }
## 4. Suivre le pipeline en temps réel (WebSocket)
# Voir docs/websocket.md
## 5. Récupérer les résultats
curl http://localhost:8000/api/v1/images/1 \
-H "Authorization: Bearer sk-VOTRE-CLE-ICI"
# → { "ai": { "description": "...", "tags": [...] }, "exif": {...}, "ocr": {...} }
```
**`docs/api-reference.md`** — Référence complète de tous les endpoints :
- Chaque endpoint documenté avec : méthode, URL, authentification requise, scopes, paramètres, exemple de réponse JSON
- Codes d'erreur possibles avec leur signification
**`docs/websocket.md`** — Guide WebSocket :
- Connexion, authentification via query param
- Format des événements (avec exemples JSON)
- Gestion de la reconnexion et du buffer 60s
- Exemples en Python, JavaScript et curl (via websocat)
**`docs/sdk.md`** — Guide du SDK Python :
- Installation
- Tous les cas d'usage avec exemples complets
- Gestion des erreurs (chaque exception du SDK documentée)
- Configuration avancée (timeout, retry, proxy)
**`docs/deployment.md`** — Guide de déploiement en production :
- Docker Compose (backend + worker + Redis + MinIO)
- Variables d'environnement obligatoires et optionnelles
- Passage de SQLite à PostgreSQL
- Passage de LocalStorage à S3/MinIO/R2
- Configuration Nginx en reverse proxy
- Recommandations de sécurité (HTTPS, headers, firewall)
**`docs/shaarli-integration.md`** — Guide d'intégration Shaarli :
- Installation du plugin
- Configuration
- Tous les cas d'usage (image bookmark, résumé URL, recherche par tag)
- Dépannage des problèmes courants
3. `tests/test_shaarli_integration.py` — Tests d'intégration end-to-end Shaarli :
- ✅ `ShaarliHubPlugin.process_bookmark_image()` → image uploadée + pipeline terminé
- ✅ `ShaarliHubPlugin.enrich_bookmark_url()` → résumé retourné
- ✅ Gestion d'une `QuotaError` (quota dépassé)
- ✅ Gestion d'un timeout (pipeline trop long)
- ✅ Plugin fonctionne sans event loop existant (appel depuis code synchrone Shaarli)
4. `CHANGELOG.md` — Historique des versions :
```markdown
# Changelog
## [1.0.0] — 2026-02-24
### Phase 3 — Expérience développeur
- WebSockets : suivi temps réel du pipeline avec buffer de reconnexion
- API versioning : tous les endpoints sous /api/v1/ avec header X-API-Version
- SDK Python officiel : imago-client 1.0.0 sur PyPI
- Dashboard admin : interface web de supervision des clients et quotas
- Intégration Shaarli : plugin + documentation complète
## [0.2.0] — Phase 2 — Robustesse et scalabilité
...
## [0.1.0] — Phase 1 — Fondations sécurité
...
```
5. `README.md` (racine) — Réécrire pour être le point d'entrée de toute la documentation :
- Badge CI (passing), badge version, badge Python
- Une phrase de description claire
- Liens rapides vers : getting-started, api-reference, sdk, déploiement
- Section "Architecture" avec le schéma texte du système
- Section "Clients supportés" : Shaarli (officiel) + instructions pour créer son propre client
</mission>
<execution_rules>
## Règles d'exécution
### Prérequis avant de commencer
1. Vérifier que les Phases 1 et 2 sont bien en place : `make ci` doit passer sans erreur
2. Redis doit être accessible en local pour les tests WebSocket
3. Lire les fichiers existants **avant** de les modifier — notamment `pipeline.py` (qui publie déjà sur Redis) et `main.py` (pour comprendre la structure de montage des routers)
### Ordre de réalisation
Implémenter dans l'ordre strict : **3.1 → 3.2 → 3.3 → 3.4 → 3.5**. Valider chaque livrable avant de continuer.
### À chaque livrable
1. Lire les fichiers concernés avec l'outil `Read` ou `Glob`
2. Implémenter le code complet (zéro `# TODO`, zéro `...` comme corps de fonction)
3. Mettre à jour `requirements.txt` si nécessaire
4. Lancer `pytest tests/ -v` après chaque livrable — corriger avant de passer au suivant
5. Pour le livrable 3.5 (docs), valider que tous les exemples de code dans la doc fonctionnent réellement
### Qualité du code
- **Typage complet** : toutes les fonctions annotées, compatible `mypy --strict`
- **Docstrings** sur toutes les classes et méthodes publiques des modules créés
- **Zéro secret** : aucune clé, URL ou credential hardcodé
- **Gestion propre des connexions WebSocket** : toujours se désabonner de Redis Pub/Sub et fermer proprement même en cas d'exception
- **SDK** : doit fonctionner de manière totalement indépendante du backend — aucun import depuis `app/`
### Spécificités WebSocket (livrable 3.1)
- Tester la déconnexion client pendant le pipeline : vérifier qu'aucune tâche orpheline ne reste dans l'event loop
- Le buffer Redis (`pipeline:buffer:{image_id}`) doit avoir un TTL de 60 secondes — ne pas oublier de le configurer dans Redis
- Si le pipeline est déjà terminé quand le client se connecte, retourner immédiatement le résultat final depuis la BDD (pas depuis Redis)
### Spécificités SDK (livrable 3.3)
- Le SDK doit gérer le retry automatique sur les erreurs 5xx (backoff exponentiel)
- `wait_until_done()` doit tenter le WebSocket d'abord (timeout 5s pour la connexion), puis basculer automatiquement sur le polling si le WebSocket échoue ou est indisponible
- Les exceptions du SDK doivent contenir le `status_code` HTTP pour faciliter le débogage
- Le SDK ne doit pas avoir de dépendance vers `fastapi`, `sqlalchemy` ou tout autre élément du backend
### Critère de succès final
Les commandes suivantes doivent toutes réussir :
```bash
# Tests backend complets
pytest tests/ -v --cov=app --cov-report=term-missing
# Tests SDK
cd sdk && pytest tests/ -v
# Qualité globale
make ci
# Vérification manuelle du dashboard
python run.py &
python worker.py &
curl -s http://localhost:8000/admin/api/stats | python -m json.tool
# Test WebSocket rapide (nécessite websocat)
websocat "ws://localhost:8000/ws/pipeline/1?token=sk-test-key"
```
</execution_rules>
<deliverable_summary>
## Résumé des livrables attendus
| # | Fichiers créés ou modifiés | Validation |
|---|---|---|
| **3.1** | `app/routers/websocket.py`, `app/metrics.py`, `app/main.py`, `tests/test_websocket.py` | `pytest tests/test_websocket.py` |
| **3.2** | `app/main.py`, `app/middleware/versioning.py`, `app/config.py`, `tests/test_versioning.py` | `pytest tests/test_versioning.py` |
| **3.3** | `sdk/` (package complet : client, resources, models, exceptions, websocket), `sdk/README.md`, `sdk/tests/` | `cd sdk && pytest tests/ -v` |
| **3.4** | `app/routers/admin.py`, `app/templates/admin/` (4 templates), `app/static/admin/`, `app/main.py`, `tests/test_admin.py` | `pytest tests/test_admin.py` |
| **3.5** | `integration/shaarli/`, `docs/` (5 guides), `CHANGELOG.md`, `README.md`, `tests/test_shaarli_integration.py` | `pytest tests/test_shaarli_integration.py` |
**Résultat final de la Phase 3 et du projet complet :**
- Hub multi-clients sécurisé, robuste, observable et entièrement documenté
- Pipeline AI avec suivi temps réel via WebSocket
- API versionnée stable avec contrat de dépréciation
- SDK Python officiel installable via pip
- Dashboard admin pour superviser clients et quotas
- Intégration Shaarli officielle avec documentation complète
- `make ci` vert, coverage > 80%, zéro warning mypy
</deliverable_summary>