- Implement tests for database generator to ensure proper session handling. - Create tests for EXIF extraction and conversion functions. - Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients. - Develop tests for OCR functionality, including language detection and text extraction. - Introduce tests for the image processing pipeline, covering success and failure scenarios. - Validate rate limiting functionality and ensure independent counters for different clients. - Implement scraper tests to verify HTML content fetching and error handling. - Add unit tests for various services, including storage and filename generation. - Establish worker entry point for ARQ to handle background image processing tasks.
856 lines
37 KiB
Markdown
856 lines
37 KiB
Markdown
# 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 "file=@photo.jpg"
|
||
# → { "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>
|