Picarones / CONTRIBUTING.md
Claude
Sprint 9 : documentation, packaging, Docker et CI/CD — version 1.0.0
bff1348 unverified
# Guide de contribution — Picarones
Merci de votre intérêt pour Picarones ! Ce guide explique comment contribuer au projet.
---
## Sommaire
1. [Démarrage rapide](#1-démarrage-rapide)
2. [Ajouter un moteur OCR](#2-ajouter-un-moteur-ocr)
3. [Ajouter un adaptateur LLM](#3-ajouter-un-adaptateur-llm)
4. [Ajouter une source d'import](#4-ajouter-une-source-dimport)
5. [Écrire des tests](#5-écrire-des-tests)
6. [Soumettre une Pull Request](#6-soumettre-une-pull-request)
7. [Conventions de code](#7-conventions-de-code)
---
## 1. Démarrage rapide
```bash
# Forker le dépôt sur GitHub, puis :
git clone https://github.com/VOTRE_USERNAME/picarones.git
cd picarones
# Environnement de développement
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev,web]"
# Vérifier que tout passe
make test
# ou : pytest
# Créer une branche de travail
git checkout -b feat/mon-nouveau-moteur
```
---
## 2. Ajouter un moteur OCR
Ajouter un nouveau moteur OCR nécessite de créer **un seul fichier Python** et de modifier
deux fichiers de configuration. Pas de refactoring du reste du code.
### 2.1 Créer l'adaptateur
Créer `picarones/engines/mon_moteur.py` en héritant de `BaseOCREngine` :
```python
"""Adaptateur pour Mon Moteur OCR.
Installation :
pip install mon-moteur
Configuration :
config:
model: mon_modele_v2
lang: fra
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Optional
from picarones.engines.base import BaseOCREngine
logger = logging.getLogger(__name__)
class MonMoteurEngine(BaseOCREngine):
"""Adaptateur pour Mon Moteur OCR.
Args:
config: Dictionnaire de configuration.
- ``model`` (str): Identifiant du modèle. Défaut: ``"default"``.
- ``lang`` (str): Code langue. Défaut: ``"fra"``.
"""
name = "mon_moteur"
def __init__(self, config: Optional[dict] = None) -> None:
super().__init__(config or {})
self.model = self.config.get("model", "default")
self.lang = self.config.get("lang", "fra")
def get_version(self) -> str:
"""Retourne la version du moteur."""
try:
import mon_moteur
return getattr(mon_moteur, "__version__", "inconnu")
except ImportError:
return "non installé"
def process_image(self, image_path: str) -> str:
"""Transcrit une image et retourne le texte.
Args:
image_path: Chemin absolu vers l'image (JPEG, PNG, TIFF…).
Returns:
Texte transcrit par le moteur.
Raises:
RuntimeError: Si le moteur n'est pas installé ou si la transcription échoue.
"""
try:
import mon_moteur
except ImportError as exc:
raise RuntimeError(
"mon-moteur n'est pas installé. Installez-le avec : pip install mon-moteur"
) from exc
try:
result = mon_moteur.transcribe(
image_path,
model=self.model,
lang=self.lang,
)
return result.text.strip()
except Exception as exc:
raise RuntimeError(f"Erreur de transcription : {exc}") from exc
```
### 2.2 Enregistrer le moteur dans le CLI
Dans `picarones/cli.py`, modifier la fonction `_engine_from_name()` :
```python
def _engine_from_name(engine_name: str, lang: str, psm: int) -> "BaseOCREngine":
from picarones.engines.tesseract import TesseractEngine
if engine_name in {"tesseract", "tess"}:
return TesseractEngine(config={"lang": lang, "psm": psm})
# ↓ Ajouter ici
try:
from picarones.engines.mon_moteur import MonMoteurEngine
if engine_name in {"mon_moteur", "monmoteur"}:
return MonMoteurEngine(config={"lang": lang})
except ImportError:
pass
# ↑
raise click.BadParameter(...)
```
### 2.3 Ajouter dans la liste `picarones engines`
Dans `picarones/cli.py`, dans la fonction `engines_cmd()` :
```python
engines = [
("tesseract", "Tesseract 5 (pytesseract)", "pytesseract"),
("pero_ocr", "Pero OCR", "pero_ocr"),
("mon_moteur", "Mon Moteur OCR", "mon_moteur"), # ← Ajouter
]
```
### 2.4 Ajouter l'extra dans `pyproject.toml` (optionnel)
```toml
[project.optional-dependencies]
mon-moteur = ["mon-moteur>=1.0.0"]
```
### 2.5 Écrire les tests
Créer `tests/test_mon_moteur.py` :
```python
"""Tests pour l'adaptateur Mon Moteur OCR."""
import pytest
from unittest.mock import patch
class TestMonMoteurEngine:
def test_name(self):
from picarones.engines.mon_moteur import MonMoteurEngine
engine = MonMoteurEngine()
assert engine.name == "mon_moteur"
def test_process_image_mock(self):
from picarones.engines.mon_moteur import MonMoteurEngine
engine = MonMoteurEngine(config={"lang": "fra"})
mock_result = type("R", (), {"text": "Texte transcrit"})()
with patch("mon_moteur.transcribe", return_value=mock_result):
text = engine.process_image("/tmp/test.jpg")
assert text == "Texte transcrit"
def test_process_image_import_error(self):
from picarones.engines.mon_moteur import MonMoteurEngine
engine = MonMoteurEngine()
with patch.dict("sys.modules", {"mon_moteur": None}):
with pytest.raises(RuntimeError, match="non installé"):
engine.process_image("/tmp/test.jpg")
```
---
## 3. Ajouter un adaptateur LLM
Les adaptateurs LLM sont dans `picarones/llm/`. Créer `picarones/llm/mon_llm_adapter.py` :
```python
"""Adaptateur pour Mon LLM.
Supporte les modes : text_only, text_and_image, zero_shot.
"""
from __future__ import annotations
import base64
import logging
from pathlib import Path
from typing import Optional
from picarones.llm.base import BaseLLMAdapter
logger = logging.getLogger(__name__)
class MonLLMAdapter(BaseLLMAdapter):
"""Adaptateur pour Mon LLM.
Args:
config: Configuration.
- ``model`` (str): Modèle à utiliser.
- ``api_key`` (str): Clé API (peut aussi être dans ``MON_LLM_API_KEY``).
- ``temperature`` (float): Température (0.0 à 1.0). Défaut: 0.0.
- ``max_tokens`` (int): Nombre maximum de tokens. Défaut: 4096.
"""
name = "mon_llm"
def __init__(self, config: Optional[dict] = None) -> None:
super().__init__(config or {})
import os
self.api_key = self.config.get("api_key") or os.getenv("MON_LLM_API_KEY", "")
self.model = self.config.get("model", "mon-modele-v1")
self.temperature = float(self.config.get("temperature", 0.0))
self.max_tokens = int(self.config.get("max_tokens", 4096))
def correct_text(self, ocr_text: str, prompt: str) -> str:
"""Corrige le texte OCR en mode texte seul (Mode 1).
Args:
ocr_text: Sortie brute du moteur OCR à corriger.
prompt: Prompt de correction.
Returns:
Texte corrigé par le LLM.
"""
# Implémenter l'appel API ici
full_prompt = prompt.replace("{ocr_output}", ocr_text)
return self._call_api(messages=[{"role": "user", "content": full_prompt}])
def correct_with_image(self, ocr_text: str, image_path: str, prompt: str) -> str:
"""Corrige le texte OCR avec l'image (Mode 2).
Args:
ocr_text: Sortie brute du moteur OCR.
image_path: Chemin vers l'image originale.
prompt: Prompt de correction.
Returns:
Texte corrigé.
"""
image_b64 = base64.b64encode(Path(image_path).read_bytes()).decode()
# Implémenter selon l'API de votre LLM
return self._call_api_with_image(ocr_text, image_b64, prompt)
def transcribe_image(self, image_path: str, prompt: str) -> str:
"""Transcription zero-shot depuis l'image seule (Mode 3).
Args:
image_path: Chemin vers l'image.
prompt: Prompt de transcription.
Returns:
Transcription produite par le LLM.
"""
image_b64 = base64.b64encode(Path(image_path).read_bytes()).decode()
return self._call_api_with_image("", image_b64, prompt)
def _call_api(self, messages: list[dict]) -> str:
"""Appel API générique."""
raise NotImplementedError("Implémenter _call_api()")
def _call_api_with_image(self, text: str, image_b64: str, prompt: str) -> str:
"""Appel API avec image."""
raise NotImplementedError("Implémenter _call_api_with_image()")
```
---
## 4. Ajouter une source d'import
Les importeurs sont dans `picarones/importers/`. Voir `iiif.py` et `gallica.py` comme exemples.
Votre importeur doit retourner un objet `Corpus` de `picarones.core.corpus` :
```python
from picarones.core.corpus import Corpus, Document
def import_from_ma_source(url: str, output_dir: str) -> Corpus:
documents = []
# ... télécharger et préparer les documents ...
for img_path, gt_text in zip(images, ground_truths):
documents.append(Document(
doc_id=Path(img_path).stem,
image_path=str(img_path),
ground_truth=gt_text,
metadata={"source": "ma_source"},
))
return Corpus(
name="Corpus depuis Ma Source",
source=url,
documents=documents,
)
```
Ajouter la nouvelle commande dans `picarones/cli.py` (sous-commande de `picarones import`).
---
## 5. Écrire des tests
### Conventions
- Un fichier de test par module/sprint : `tests/test_mon_module.py`
- Classes de test groupées par fonctionnalité : `class TestMonModule:`
- Mocker les appels réseau et les moteurs OCR avec `unittest.mock.patch`
- Viser **100% de couverture** sur les modules publics
### Structure recommandée
```python
"""Tests pour MonModule.
Classes
-------
TestFonctionnalite1 (N tests) — description
TestFonctionnalite2 (M tests) — description
"""
from __future__ import annotations
import pytest
from unittest.mock import patch, MagicMock
class TestFonctionnalite1:
def test_cas_nominal(self):
from picarones.mon_module import ma_fonction
result = ma_fonction("entrée")
assert result == "sortie attendue"
def test_cas_erreur(self):
from picarones.mon_module import ma_fonction
with pytest.raises(ValueError, match="message d'erreur"):
ma_fonction(None)
def test_avec_mock(self):
from picarones.mon_module import MonClient
client = MonClient("https://example.org", token="tok")
with patch.object(client, "_fetch", return_value=b"réponse"):
result = client.appel_api()
assert result is not None
```
### Lancer les tests
```bash
# Tous les tests
make test
# ou
pytest
# Un fichier spécifique
pytest tests/test_mon_module.py -v
# Avec couverture
pytest --cov=picarones --cov-report=html
open htmlcov/index.html
# Tests rapides (sans les tests lents)
pytest -m "not slow"
```
---
## 6. Soumettre une Pull Request
### Avant de soumettre
```bash
# 1. Vérifier que tous les tests passent
make test
# 2. Vérifier le style de code (si ruff/flake8 disponible)
make lint
# 3. Mettre à jour le CHANGELOG.md
# 4. Pousser votre branche
git push origin feat/mon-nouveau-moteur
```
### Checklist PR
- [ ] Tests unitaires pour toutes les nouvelles fonctions publiques
- [ ] Docstrings Google style sur les classes et méthodes publiques
- [ ] CHANGELOG.md mis à jour dans la section `[Unreleased]`
- [ ] Pas de régression sur la suite de tests existante (`pytest` passe en vert)
- [ ] Code compatible Python 3.11 et 3.12
- [ ] Pas de clés API en dur dans le code
### Description de PR
```markdown
## Résumé
- Ajout de l'adaptateur pour Mon Moteur OCR
- Support des langues latin et français
## Tests
- 15 tests unitaires dans `tests/test_mon_moteur.py`
- Mocké avec `unittest.mock.patch` (pas de dépendance externe requise pour les tests)
## Changements
- `picarones/engines/mon_moteur.py` : nouvel adaptateur
- `picarones/cli.py` : enregistrement du moteur
- `pyproject.toml` : extra `[mon-moteur]`
```
---
## 7. Conventions de code
### Style
- **Python 3.11+** avec annotations de type
- `from __future__ import annotations` en tête de fichier
- Format : PEP 8, lignes ≤ 100 caractères (pas de formatage automatique imposé)
### Docstrings — format Google
```python
def compute_cer(reference: str, hypothesis: str) -> float:
"""Calcule le Character Error Rate (CER) entre référence et hypothèse.
Le CER est défini comme la distance de Levenshtein au niveau caractère
divisée par la longueur de la référence.
Args:
reference: Texte de vérité terrain (GT).
hypothesis: Texte produit par le moteur OCR.
Returns:
CER entre 0.0 (parfait) et 1.0+ (nombreuses erreurs).
Raises:
ValueError: Si ``reference`` est vide.
Examples:
>>> compute_cer("bonjour", "bnjour")
0.14285714285714285
"""
```
### Nommage
- Classes : `PascalCase` (ex : `TesseractEngine`, `GallicaClient`)
- Fonctions/méthodes : `snake_case` (ex : `compute_metrics`, `list_projects`)
- Constantes : `UPPER_SNAKE_CASE` (ex : `DEGRADATION_LEVELS`)
- Fichiers de module : `snake_case.py` (ex : `gallica.py`, `char_scores.py`)
### Gestion des imports optionnels
```python
# Pattern recommandé pour les dépendances optionnelles
def process_image(self, image_path: str) -> str:
try:
import mon_moteur
except ImportError as exc:
raise RuntimeError(
"mon-moteur n'est pas installé. Installez-le avec : pip install mon-moteur"
) from exc
# utiliser mon_moteur...
```
### Variables d'environnement pour les clés API
```python
import os
api_key = config.get("api_key") or os.getenv("MON_API_KEY", "")
if not api_key:
raise RuntimeError(
"Clé API manquante. Définissez MON_API_KEY ou passez api_key dans la config."
)
```
---
## Licence
En contribuant à Picarones, vous acceptez que votre contribution soit distribuée
sous licence Apache 2.0.