Claude commited on
Commit
e4bd765
·
unverified ·
1 Parent(s): ed52286

feat(sprint4-session-c): branchement pipeline réel sur les jobs + tests job_runner

Browse files

- job_runner.py : pipeline complet en 9 étapes (running → profile → model → image → AI → ALTO → done)
- corpus_runner.py : exécution séquentielle de tous les jobs pending d'un corpus
- jobs.py : BackgroundTasks pour run_corpus, run_page et retry_job
- test_job_runner.py : 20 tests couvrant succès + 4 points de failure
- conftest_api.py : neutralisation des background tasks en tests d'API (477 passed)

https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg

backend/app/api/v1/jobs.py CHANGED
@@ -1,28 +1,29 @@
1
  """
2
  Endpoints de gestion des jobs de traitement (R10 — préfixe /api/v1/).
3
 
4
- POST /api/v1/corpora/{id}/run → crée un job par page du corpus
5
- POST /api/v1/pages/{id}/run → crée un job pour une page
6
  GET /api/v1/jobs/{job_id} → état du job
7
  POST /api/v1/jobs/{job_id}/retry → relance un job FAILED
8
 
9
- Règle : les jobs sont créés en BDD et retournent immédiatement.
10
- Le pipeline réel (analyzer) sera branché en Session C.
11
  """
12
  # 1. stdlib
13
  import uuid
14
  from datetime import datetime, timezone
15
 
16
  # 2. third-party
17
- from fastapi import APIRouter, Depends, HTTPException
18
  from pydantic import BaseModel, ConfigDict
19
  from sqlalchemy import select
20
  from sqlalchemy.ext.asyncio import AsyncSession
21
 
22
  # 3. local
23
- from app.models.corpus import CorpusModel, PageModel
24
  from app.models.database import get_db
25
  from app.models.job import JobModel
 
 
26
 
27
  router = APIRouter(tags=["jobs"])
28
 
@@ -71,18 +72,19 @@ def _new_job(corpus_id: str, page_id: str | None) -> JobModel:
71
 
72
  @router.post("/corpora/{corpus_id}/run", response_model=CorpusRunResponse, status_code=202)
73
  async def run_corpus(
74
- corpus_id: str, db: AsyncSession = Depends(get_db)
 
 
75
  ) -> CorpusRunResponse:
76
  """Lance le pipeline sur toutes les pages du corpus.
77
 
78
- Crée un JobModel par page (status=pending). Retourne immédiatement.
79
- Le pipeline réel sera branché en Session C.
80
  """
81
  corpus = await db.get(CorpusModel, corpus_id)
82
  if corpus is None:
83
  raise HTTPException(status_code=404, detail="Corpus introuvable")
84
 
85
- from app.models.corpus import ManuscriptModel
86
  ms_result = await db.execute(
87
  select(ManuscriptModel).where(ManuscriptModel.corpus_id == corpus_id)
88
  )
@@ -98,6 +100,9 @@ async def run_corpus(
98
  db.add(job)
99
  await db.commit()
100
 
 
 
 
101
  return CorpusRunResponse(
102
  corpus_id=corpus_id,
103
  jobs_created=len(jobs),
@@ -107,14 +112,19 @@ async def run_corpus(
107
 
108
  @router.post("/pages/{page_id}/run", response_model=JobResponse, status_code=202)
109
  async def run_page(
110
- page_id: str, db: AsyncSession = Depends(get_db)
 
 
111
  ) -> JobModel:
112
- """Lance le pipeline sur une seule page. Retourne le job créé."""
 
 
 
 
113
  page = await db.get(PageModel, page_id)
114
  if page is None:
115
  raise HTTPException(status_code=404, detail="Page introuvable")
116
 
117
- from app.models.corpus import ManuscriptModel
118
  manuscript = await db.get(ManuscriptModel, page.manuscript_id)
119
  if manuscript is None:
120
  raise HTTPException(status_code=404, detail="Manuscrit introuvable")
@@ -123,6 +133,10 @@ async def run_page(
123
  db.add(job)
124
  await db.commit()
125
  await db.refresh(job)
 
 
 
 
126
  return job
127
 
128
 
@@ -136,7 +150,11 @@ async def get_job(job_id: str, db: AsyncSession = Depends(get_db)) -> JobModel:
136
 
137
 
138
  @router.post("/jobs/{job_id}/retry", response_model=JobResponse)
139
- async def retry_job(job_id: str, db: AsyncSession = Depends(get_db)) -> JobModel:
 
 
 
 
140
  """Relance un job en état FAILED (remet le status à pending).
141
 
142
  Retourne 409 si le job n'est pas dans l'état FAILED.
@@ -155,4 +173,8 @@ async def retry_job(job_id: str, db: AsyncSession = Depends(get_db)) -> JobModel
155
  job.finished_at = None
156
  await db.commit()
157
  await db.refresh(job)
 
 
 
 
158
  return job
 
1
  """
2
  Endpoints de gestion des jobs de traitement (R10 — préfixe /api/v1/).
3
 
4
+ POST /api/v1/corpora/{id}/run → crée un job par page + lance le pipeline en fond
5
+ POST /api/v1/pages/{id}/run → crée un job + lance le pipeline en fond
6
  GET /api/v1/jobs/{job_id} → état du job
7
  POST /api/v1/jobs/{job_id}/retry → relance un job FAILED
8
 
9
+ Le pipeline est exécuté via FastAPI BackgroundTasks (pas de Celery, pas de threading manuel).
 
10
  """
11
  # 1. stdlib
12
  import uuid
13
  from datetime import datetime, timezone
14
 
15
  # 2. third-party
16
+ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
17
  from pydantic import BaseModel, ConfigDict
18
  from sqlalchemy import select
19
  from sqlalchemy.ext.asyncio import AsyncSession
20
 
21
  # 3. local
22
+ from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
23
  from app.models.database import get_db
24
  from app.models.job import JobModel
25
+ from app.services.corpus_runner import execute_corpus_job
26
+ from app.services.job_runner import execute_page_job
27
 
28
  router = APIRouter(tags=["jobs"])
29
 
 
72
 
73
  @router.post("/corpora/{corpus_id}/run", response_model=CorpusRunResponse, status_code=202)
74
  async def run_corpus(
75
+ corpus_id: str,
76
+ background_tasks: BackgroundTasks,
77
+ db: AsyncSession = Depends(get_db),
78
  ) -> CorpusRunResponse:
79
  """Lance le pipeline sur toutes les pages du corpus.
80
 
81
+ Crée un JobModel par page (status=pending), puis délègue l'exécution
82
+ réelle à execute_corpus_job via BackgroundTasks (retour immédiat).
83
  """
84
  corpus = await db.get(CorpusModel, corpus_id)
85
  if corpus is None:
86
  raise HTTPException(status_code=404, detail="Corpus introuvable")
87
 
 
88
  ms_result = await db.execute(
89
  select(ManuscriptModel).where(ManuscriptModel.corpus_id == corpus_id)
90
  )
 
100
  db.add(job)
101
  await db.commit()
102
 
103
+ # Lancer le pipeline en arrière-plan (après envoi de la réponse)
104
+ background_tasks.add_task(execute_corpus_job, corpus_id)
105
+
106
  return CorpusRunResponse(
107
  corpus_id=corpus_id,
108
  jobs_created=len(jobs),
 
112
 
113
  @router.post("/pages/{page_id}/run", response_model=JobResponse, status_code=202)
114
  async def run_page(
115
+ page_id: str,
116
+ background_tasks: BackgroundTasks,
117
+ db: AsyncSession = Depends(get_db),
118
  ) -> JobModel:
119
+ """Lance le pipeline sur une seule page.
120
+
121
+ Crée un JobModel (status=pending) et délègue l'exécution à
122
+ execute_page_job via BackgroundTasks (retour immédiat).
123
+ """
124
  page = await db.get(PageModel, page_id)
125
  if page is None:
126
  raise HTTPException(status_code=404, detail="Page introuvable")
127
 
 
128
  manuscript = await db.get(ManuscriptModel, page.manuscript_id)
129
  if manuscript is None:
130
  raise HTTPException(status_code=404, detail="Manuscrit introuvable")
 
133
  db.add(job)
134
  await db.commit()
135
  await db.refresh(job)
136
+
137
+ # Lancer le pipeline en arrière-plan (après envoi de la réponse)
138
+ background_tasks.add_task(execute_page_job, job.id)
139
+
140
  return job
141
 
142
 
 
150
 
151
 
152
  @router.post("/jobs/{job_id}/retry", response_model=JobResponse)
153
+ async def retry_job(
154
+ job_id: str,
155
+ background_tasks: BackgroundTasks,
156
+ db: AsyncSession = Depends(get_db),
157
+ ) -> JobModel:
158
  """Relance un job en état FAILED (remet le status à pending).
159
 
160
  Retourne 409 si le job n'est pas dans l'état FAILED.
 
173
  job.finished_at = None
174
  await db.commit()
175
  await db.refresh(job)
176
+
177
+ # Relancer le pipeline
178
+ background_tasks.add_task(execute_page_job, job.id)
179
+
180
  return job
backend/app/services/corpus_runner.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Exécution séquentielle du pipeline sur tous les jobs d'un corpus (Sprint 4 — Session C).
3
+
4
+ Point d'entrée : execute_corpus_job(corpus_id)
5
+ → récupère tous les jobs PENDING du corpus
6
+ → les exécute séquentiellement (pas de parallélisme au MVP)
7
+ → retourne un résumé {total, done, failed}
8
+
9
+ Chaque page reçoit sa propre session pour isoler les échecs.
10
+ """
11
+ # 1. stdlib
12
+ import logging
13
+
14
+ # 2. third-party
15
+ from sqlalchemy import select
16
+
17
+ # 3. local
18
+ from app.models.database import async_session_factory
19
+ from app.models.job import JobModel
20
+ from app.services.job_runner import execute_page_job
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ async def execute_corpus_job(corpus_id: str) -> dict:
26
+ """Lance tous les jobs PENDING du corpus séquentiellement.
27
+
28
+ Chaque job est exécuté dans sa propre session (isolement des échecs).
29
+ Un job FAILED n'interrompt pas les suivants.
30
+
31
+ Returns:
32
+ {"total": int, "done": int, "failed": int}
33
+ """
34
+ # Collecte des IDs de jobs PENDING (snapshot en début de run)
35
+ async with async_session_factory() as db:
36
+ result = await db.execute(
37
+ select(JobModel.id).where(
38
+ JobModel.corpus_id == corpus_id,
39
+ JobModel.status == "pending",
40
+ )
41
+ )
42
+ job_ids: list[str] = list(result.scalars().all())
43
+
44
+ if not job_ids:
45
+ logger.info(
46
+ "Corpus run : aucun job pending",
47
+ extra={"corpus_id": corpus_id},
48
+ )
49
+ return {"total": 0, "done": 0, "failed": 0}
50
+
51
+ logger.info(
52
+ "Corpus run démarré",
53
+ extra={"corpus_id": corpus_id, "jobs": len(job_ids)},
54
+ )
55
+
56
+ # Exécution séquentielle — chaque job gère sa propre session
57
+ for job_id in job_ids:
58
+ await execute_page_job(job_id)
59
+
60
+ # Bilan final
61
+ async with async_session_factory() as db:
62
+ result = await db.execute(
63
+ select(JobModel).where(JobModel.id.in_(job_ids))
64
+ )
65
+ jobs = list(result.scalars().all())
66
+
67
+ done = sum(1 for j in jobs if j.status == "done")
68
+ failed = sum(1 for j in jobs if j.status == "failed")
69
+ total = len(job_ids)
70
+
71
+ logger.info(
72
+ "Corpus run terminé",
73
+ extra={
74
+ "corpus_id": corpus_id,
75
+ "total": total,
76
+ "done": done,
77
+ "failed": failed,
78
+ },
79
+ )
80
+ return {"total": total, "done": done, "failed": failed}
backend/app/services/job_runner.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Exécution réelle du pipeline sur un job page (Sprint 4 — Session C).
3
+
4
+ Point d'entrée principal : execute_page_job(job_id)
5
+ Séquence stricte (CLAUDE.md §8 pipeline) :
6
+ 1. job → RUNNING
7
+ 2. Charger page / manuscrit / corpus depuis BDD
8
+ 3. Charger CorpusProfile depuis profiles/{profile_id}.json
9
+ 4. Charger ModelConfig depuis BDD (erreur explicite si absent)
10
+ 5. fetch_and_normalize() → ImageDerivativeInfo
11
+ 6. run_primary_analysis() → PageMaster (+ double stockage R05)
12
+ 7. generate_alto() + write_alto()
13
+ 8. page.processing_status → ANALYZED
14
+ 9. job → DONE
15
+
16
+ Sur toute exception : job → FAILED + error_message, page → ERROR.
17
+ Aucun échec silencieux (CLAUDE.md §7).
18
+ """
19
+ # 1. stdlib
20
+ import json
21
+ import logging
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+
25
+ # 2. third-party
26
+ from sqlalchemy.ext.asyncio import AsyncSession
27
+
28
+ # 3. local
29
+ from app import config as _config_module
30
+ from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
31
+ from app.models.database import async_session_factory
32
+ from app.models.job import JobModel
33
+ from app.models.model_config_db import ModelConfigDB
34
+ from app.schemas.corpus_profile import CorpusProfile
35
+ from app.schemas.model_config import ModelConfig, ProviderType
36
+ from app.services.ai.analyzer import run_primary_analysis
37
+ from app.services.export.alto import generate_alto, write_alto
38
+ from app.services.image.normalizer import create_derivatives, fetch_and_normalize
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # Racine du projet : backend/app/services/job_runner.py → 3 parents → project root
43
+ _PROJECT_ROOT = Path(__file__).parent.parent.parent.parent
44
+
45
+
46
+ # ── Point d'entrée public ──────────────────────────────────────────────────
47
+
48
+ async def execute_page_job(job_id: str, db: AsyncSession | None = None) -> None:
49
+ """BackgroundTask : exécute le pipeline complet sur une page.
50
+
51
+ Args:
52
+ job_id: identifiant du JobModel en BDD.
53
+ db: session optionnelle (fournie dans les tests ; None = nouvelle session).
54
+ """
55
+ if db is None:
56
+ async with async_session_factory() as session:
57
+ await _run_job_impl(job_id, session)
58
+ else:
59
+ await _run_job_impl(job_id, db)
60
+
61
+
62
+ # ── Implémentation interne (testable directement) ──────────────────────────
63
+
64
+ async def _run_job_impl(job_id: str, db: AsyncSession) -> None:
65
+ """Exécution du pipeline sur un job, avec la session fournie.
66
+
67
+ Exposé (préfixe _ conservé) pour les tests unitaires.
68
+ """
69
+ # ── 1. Charger le job, passer status → RUNNING ──────────────────────────
70
+ job = await db.get(JobModel, job_id)
71
+ if job is None:
72
+ logger.error("Job introuvable — exécution abandonnée", extra={"job_id": job_id})
73
+ return
74
+
75
+ job.status = "running"
76
+ job.started_at = datetime.now(timezone.utc)
77
+ await db.commit()
78
+
79
+ page: PageModel | None = None
80
+
81
+ try:
82
+ # ── 2. Charger page / manuscrit / corpus ─────────────────────────────
83
+ if job.page_id is None:
84
+ raise ValueError("Ce job n'a pas de page_id — impossible d'exécuter le pipeline")
85
+
86
+ page = await db.get(PageModel, job.page_id)
87
+ if page is None:
88
+ raise ValueError(f"Page introuvable en BDD : {job.page_id}")
89
+
90
+ manuscript = await db.get(ManuscriptModel, page.manuscript_id)
91
+ if manuscript is None:
92
+ raise ValueError(f"Manuscrit introuvable en BDD : {page.manuscript_id}")
93
+
94
+ corpus = await db.get(CorpusModel, manuscript.corpus_id)
95
+ if corpus is None:
96
+ raise ValueError(f"Corpus introuvable en BDD : {manuscript.corpus_id}")
97
+
98
+ # ── 3. Charger le CorpusProfile ──────────────────────────────────────
99
+ profile_path = _PROJECT_ROOT / "profiles" / f"{corpus.profile_id}.json"
100
+ if not profile_path.exists():
101
+ raise FileNotFoundError(
102
+ f"Fichier de profil introuvable : {profile_path}. "
103
+ f"Profil attendu : «{corpus.profile_id}»"
104
+ )
105
+ profile_data = json.loads(profile_path.read_text(encoding="utf-8"))
106
+ corpus_profile = CorpusProfile.model_validate(profile_data)
107
+
108
+ # ── 4. Charger le ModelConfig (erreur explicite si absent) ───────────
109
+ model_db = await db.get(ModelConfigDB, corpus.id)
110
+ if model_db is None:
111
+ raise ValueError(
112
+ f"Aucun modèle IA configuré pour le corpus «{corpus.id}». "
113
+ "Sélectionnez un modèle via PUT /api/v1/corpora/{id}/model avant "
114
+ "de lancer le pipeline."
115
+ )
116
+ model_config = ModelConfig(
117
+ corpus_id=corpus.id,
118
+ selected_model_id=model_db.selected_model_id,
119
+ selected_model_display_name=model_db.selected_model_display_name,
120
+ provider=ProviderType(model_db.provider_type),
121
+ supports_vision=True,
122
+ last_fetched_at=model_db.updated_at,
123
+ available_models=[],
124
+ )
125
+
126
+ # ── 5. Normaliser l'image ────────────────────────────────────────────
127
+ data_dir = _config_module.settings.data_dir
128
+ image_source = page.image_master_path or ""
129
+
130
+ if image_source.startswith(("http://", "https://")):
131
+ image_info = fetch_and_normalize(
132
+ image_source, corpus.slug, page.folio_label, data_dir
133
+ )
134
+ elif image_source:
135
+ source_bytes = Path(image_source).read_bytes()
136
+ image_info = create_derivatives(
137
+ source_bytes, image_source, corpus.slug, page.folio_label, data_dir
138
+ )
139
+ else:
140
+ raise ValueError(
141
+ f"La page {page.id} n'a pas d'image source "
142
+ "(image_master_path vide ou None)"
143
+ )
144
+
145
+ # ── 6. Analyse primaire IA (R05 : double stockage) ───────────────────
146
+ page_master = run_primary_analysis(
147
+ derivative_image_path=Path(image_info.derivative_path),
148
+ corpus_profile=corpus_profile,
149
+ model_config=model_config,
150
+ page_id=page.id,
151
+ manuscript_id=manuscript.id,
152
+ corpus_slug=corpus.slug,
153
+ folio_label=page.folio_label,
154
+ sequence=page.sequence,
155
+ image_info=image_info,
156
+ base_data_dir=data_dir,
157
+ project_root=_PROJECT_ROOT,
158
+ )
159
+
160
+ # ── 7. Générer et écrire l'ALTO XML ──────────────────────────────────
161
+ alto_xml = generate_alto(page_master)
162
+ alto_path = (
163
+ data_dir
164
+ / "corpora"
165
+ / corpus.slug
166
+ / "pages"
167
+ / page.folio_label
168
+ / "alto.xml"
169
+ )
170
+ write_alto(alto_xml, alto_path)
171
+
172
+ # ── 8. Page → ANALYZED ───────────────────────────────────────────────
173
+ page.processing_status = "ANALYZED"
174
+ if page_master.ocr is not None:
175
+ page.confidence_summary = page_master.ocr.confidence
176
+
177
+ # ── 9. Job → DONE ────────────────────────────────────────────────────
178
+ job.status = "done"
179
+ job.finished_at = datetime.now(timezone.utc)
180
+ await db.commit()
181
+
182
+ logger.info(
183
+ "Job terminé avec succès",
184
+ extra={
185
+ "job_id": job_id,
186
+ "page_id": page.id,
187
+ "folio": page.folio_label,
188
+ "corpus": corpus.slug,
189
+ },
190
+ )
191
+
192
+ except Exception as exc:
193
+ logger.error(
194
+ "Échec du job",
195
+ extra={"job_id": job_id, "error": str(exc)},
196
+ exc_info=True,
197
+ )
198
+ job.status = "failed"
199
+ job.error_message = str(exc)
200
+ job.finished_at = datetime.now(timezone.utc)
201
+ if page is not None:
202
+ page.processing_status = "ERROR"
203
+ try:
204
+ await db.commit()
205
+ except Exception as commit_exc:
206
+ logger.error(
207
+ "Impossible de persister l'état d'échec du job",
208
+ extra={"job_id": job_id, "commit_error": str(commit_exc)},
209
+ )
backend/tests/conftest_api.py CHANGED
@@ -12,6 +12,7 @@ Stratégie :
12
  # 1. stdlib
13
  import pytest
14
  import pytest_asyncio
 
15
 
16
  # 2. third-party
17
  from httpx import ASGITransport, AsyncClient
@@ -49,8 +50,13 @@ async def async_client(db_session: AsyncSession):
49
  yield db_session
50
 
51
  app.dependency_overrides[get_db] = _override_get_db
52
- async with AsyncClient(
53
- transport=ASGITransport(app=app), base_url="http://test"
54
- ) as client:
55
- yield client
 
 
 
 
 
56
  app.dependency_overrides.clear()
 
12
  # 1. stdlib
13
  import pytest
14
  import pytest_asyncio
15
+ from unittest.mock import AsyncMock, patch
16
 
17
  # 2. third-party
18
  from httpx import ASGITransport, AsyncClient
 
50
  yield db_session
51
 
52
  app.dependency_overrides[get_db] = _override_get_db
53
+ # Les background tasks (execute_corpus_job, execute_page_job) créent leur
54
+ # propre session via async_session_factory. On les neutralise pour éviter
55
+ # qu'elles tentent de se connecter à la BDD réelle pendant les tests d'API.
56
+ with patch("app.api.v1.jobs.execute_corpus_job", AsyncMock(return_value=None)), \
57
+ patch("app.api.v1.jobs.execute_page_job", AsyncMock(return_value=None)):
58
+ async with AsyncClient(
59
+ transport=ASGITransport(app=app), base_url="http://test"
60
+ ) as client:
61
+ yield client
62
  app.dependency_overrides.clear()
backend/tests/test_job_runner.py ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests du service job_runner (Sprint 4 — Session C).
3
+
4
+ Vérifie :
5
+ - Séquence complète succès → job.status "done", page "ANALYZED"
6
+ - Chaque point de failure : étape 4 sans ModelConfig, étape 5 image absente,
7
+ étape 6 ParseError, étape 7 écriture impossible
8
+ - Cohérence job.status / page.processing_status après chaque scénario
9
+ - corpus_runner : délégation séquentielle des jobs pending
10
+
11
+ Les fonctions IO (fetch_and_normalize, run_primary_analysis, generate_alto,
12
+ write_alto) sont mockées via monkeypatch sur le namespace de job_runner_module.
13
+ _run_job_impl est testée directement avec la session de test injectée.
14
+ """
15
+ # 1. stdlib
16
+ import uuid
17
+ from datetime import datetime, timezone
18
+
19
+ # 2. third-party
20
+ import pytest
21
+ import pytest_asyncio
22
+ from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
23
+
24
+ # 3. local
25
+ import app.models # noqa: F401 — enregistrement des modèles dans Base.metadata
26
+ import app.services.corpus_runner as corpus_runner_module
27
+ import app.services.job_runner as job_runner_module
28
+ from app.models.corpus import CorpusModel, ManuscriptModel, PageModel
29
+ from app.models.database import Base
30
+ from app.models.job import JobModel
31
+ from app.models.model_config_db import ModelConfigDB
32
+ from app.schemas.image import ImageDerivativeInfo
33
+ from app.schemas.page_master import OCRResult, PageMaster
34
+ from app.services.job_runner import _run_job_impl
35
+
36
+ _TEST_DB_URL = "sqlite+aiosqlite:///:memory:"
37
+ _NOW = datetime.now(timezone.utc)
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Fixtures
42
+ # ---------------------------------------------------------------------------
43
+
44
+ @pytest_asyncio.fixture
45
+ async def db():
46
+ """Session SQLite en mémoire avec schéma complet."""
47
+ engine = create_async_engine(_TEST_DB_URL, echo=False)
48
+ async with engine.begin() as conn:
49
+ await conn.run_sync(Base.metadata.create_all)
50
+
51
+ factory = async_sessionmaker(engine, expire_on_commit=False)
52
+ async with factory() as session:
53
+ yield session
54
+
55
+ async with engine.begin() as conn:
56
+ await conn.run_sync(Base.metadata.drop_all)
57
+ await engine.dispose()
58
+
59
+
60
+ @pytest_asyncio.fixture
61
+ async def setup(db):
62
+ """Corpus + manuscript + page (URL image) + job pending — sans ModelConfigDB."""
63
+ corpus = CorpusModel(
64
+ id=str(uuid.uuid4()), slug="runner-test", title="Runner Test",
65
+ profile_id="medieval-illuminated", created_at=_NOW, updated_at=_NOW,
66
+ )
67
+ db.add(corpus)
68
+ await db.commit()
69
+
70
+ ms = ManuscriptModel(
71
+ id=str(uuid.uuid4()), corpus_id=corpus.id, title="MS Test", total_pages=1,
72
+ )
73
+ db.add(ms)
74
+ await db.commit()
75
+
76
+ page = PageModel(
77
+ id=str(uuid.uuid4()), manuscript_id=ms.id, folio_label="f001r",
78
+ sequence=1, image_master_path="https://example.com/image.jpg",
79
+ processing_status="INGESTED",
80
+ )
81
+ db.add(page)
82
+ await db.commit()
83
+
84
+ job = JobModel(
85
+ id=str(uuid.uuid4()), corpus_id=corpus.id, page_id=page.id,
86
+ status="pending", created_at=_NOW,
87
+ )
88
+ db.add(job)
89
+ await db.commit()
90
+ await db.refresh(job)
91
+ await db.refresh(page)
92
+ return {"corpus": corpus, "ms": ms, "page": page, "job": job}
93
+
94
+
95
+ @pytest_asyncio.fixture
96
+ async def setup_with_model(db, setup):
97
+ """Idem setup, avec ModelConfigDB configuré."""
98
+ model_cfg = ModelConfigDB(
99
+ corpus_id=setup["corpus"].id,
100
+ provider_type="google_ai_studio",
101
+ selected_model_id="gemini-2.0-flash",
102
+ selected_model_display_name="Gemini 2.0 Flash",
103
+ updated_at=_NOW,
104
+ )
105
+ db.add(model_cfg)
106
+ await db.commit()
107
+ return setup
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Helpers
112
+ # ---------------------------------------------------------------------------
113
+
114
+ def _image_info() -> ImageDerivativeInfo:
115
+ return ImageDerivativeInfo(
116
+ original_url="https://example.com/image.jpg",
117
+ original_width=2000, original_height=3000,
118
+ derivative_path="/tmp/deriv.jpg",
119
+ derivative_width=1500, derivative_height=2250,
120
+ thumbnail_path="/tmp/thumb.jpg",
121
+ thumbnail_width=200, thumbnail_height=300,
122
+ )
123
+
124
+
125
+ def _page_master(page_id: str, ms_id: str) -> PageMaster:
126
+ return PageMaster(
127
+ page_id=page_id,
128
+ corpus_profile="medieval-illuminated",
129
+ manuscript_id=ms_id,
130
+ folio_label="f001r",
131
+ sequence=1,
132
+ image={
133
+ "master": "https://example.com/image.jpg",
134
+ "derivative_web": "/tmp/deriv.jpg",
135
+ "iiif_base": "",
136
+ "width": 2000,
137
+ "height": 3000,
138
+ },
139
+ layout={"regions": []},
140
+ ocr=OCRResult(confidence=0.85),
141
+ )
142
+
143
+
144
+ def _apply_success_mocks(monkeypatch, page_id: str, ms_id: str) -> None:
145
+ """Applique les mocks IO pour un pipeline réussi."""
146
+ monkeypatch.setattr(
147
+ job_runner_module, "fetch_and_normalize", lambda *a: _image_info()
148
+ )
149
+ monkeypatch.setattr(
150
+ job_runner_module, "run_primary_analysis",
151
+ lambda **kw: _page_master(page_id, ms_id),
152
+ )
153
+ monkeypatch.setattr(job_runner_module, "generate_alto", lambda pm: "<alto/>")
154
+ monkeypatch.setattr(job_runner_module, "write_alto", lambda xml, path: None)
155
+
156
+
157
+ # ---------------------------------------------------------------------------
158
+ # Séquence complète — succès
159
+ # ---------------------------------------------------------------------------
160
+
161
+ @pytest.mark.asyncio
162
+ async def test_success_job_status_done(db, setup_with_model, monkeypatch):
163
+ """Après un run réussi, job.status doit être 'done'."""
164
+ s = setup_with_model
165
+ _apply_success_mocks(monkeypatch, s["page"].id, s["ms"].id)
166
+
167
+ await _run_job_impl(s["job"].id, db)
168
+ await db.refresh(s["job"])
169
+
170
+ assert s["job"].status == "done"
171
+
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_success_page_analyzed(db, setup_with_model, monkeypatch):
175
+ """Après un run réussi, page.processing_status doit être 'ANALYZED'."""
176
+ s = setup_with_model
177
+ _apply_success_mocks(monkeypatch, s["page"].id, s["ms"].id)
178
+
179
+ await _run_job_impl(s["job"].id, db)
180
+ await db.refresh(s["page"])
181
+
182
+ assert s["page"].processing_status == "ANALYZED"
183
+
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_success_job_started_at_set(db, setup_with_model, monkeypatch):
187
+ """started_at doit être renseigné après exécution réussie."""
188
+ s = setup_with_model
189
+ _apply_success_mocks(monkeypatch, s["page"].id, s["ms"].id)
190
+
191
+ await _run_job_impl(s["job"].id, db)
192
+ await db.refresh(s["job"])
193
+
194
+ assert s["job"].started_at is not None
195
+
196
+
197
+ @pytest.mark.asyncio
198
+ async def test_success_job_finished_at_set(db, setup_with_model, monkeypatch):
199
+ """finished_at doit être renseigné après exécution réussie."""
200
+ s = setup_with_model
201
+ _apply_success_mocks(monkeypatch, s["page"].id, s["ms"].id)
202
+
203
+ await _run_job_impl(s["job"].id, db)
204
+ await db.refresh(s["job"])
205
+
206
+ assert s["job"].finished_at is not None
207
+
208
+
209
+ @pytest.mark.asyncio
210
+ async def test_success_no_error_message(db, setup_with_model, monkeypatch):
211
+ """error_message doit rester None après succès."""
212
+ s = setup_with_model
213
+ _apply_success_mocks(monkeypatch, s["page"].id, s["ms"].id)
214
+
215
+ await _run_job_impl(s["job"].id, db)
216
+ await db.refresh(s["job"])
217
+
218
+ assert s["job"].error_message is None
219
+
220
+
221
+ @pytest.mark.asyncio
222
+ async def test_success_confidence_stored(db, setup_with_model, monkeypatch):
223
+ """confidence_summary de la page doit être renseigné depuis OCRResult."""
224
+ s = setup_with_model
225
+ _apply_success_mocks(monkeypatch, s["page"].id, s["ms"].id)
226
+
227
+ await _run_job_impl(s["job"].id, db)
228
+ await db.refresh(s["page"])
229
+
230
+ assert s["page"].confidence_summary == pytest.approx(0.85)
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # Étape 4 — pas de ModelConfig
235
+ # ---------------------------------------------------------------------------
236
+
237
+ @pytest.mark.asyncio
238
+ async def test_no_model_config_job_failed(db, setup, monkeypatch):
239
+ """Sans ModelConfigDB, job.status doit être 'failed'."""
240
+ s = setup
241
+ monkeypatch.setattr(
242
+ job_runner_module, "fetch_and_normalize", lambda *a: _image_info()
243
+ )
244
+
245
+ await _run_job_impl(s["job"].id, db)
246
+ await db.refresh(s["job"])
247
+
248
+ assert s["job"].status == "failed"
249
+
250
+
251
+ @pytest.mark.asyncio
252
+ async def test_no_model_config_error_message(db, setup, monkeypatch):
253
+ """Sans ModelConfigDB, error_message doit mentionner l'absence de modèle."""
254
+ s = setup
255
+ monkeypatch.setattr(
256
+ job_runner_module, "fetch_and_normalize", lambda *a: _image_info()
257
+ )
258
+
259
+ await _run_job_impl(s["job"].id, db)
260
+ await db.refresh(s["job"])
261
+
262
+ assert s["job"].error_message is not None
263
+ assert "modèle" in s["job"].error_message.lower() or "model" in s["job"].error_message.lower()
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Étape 5 — image absente
268
+ # ---------------------------------------------------------------------------
269
+
270
+ @pytest.mark.asyncio
271
+ async def test_no_image_path_job_failed(db, setup_with_model, monkeypatch):
272
+ """Sans image_master_path, job.status doit être 'failed'."""
273
+ s = setup_with_model
274
+ s["page"].image_master_path = None
275
+ await db.commit()
276
+ monkeypatch.setattr(
277
+ job_runner_module, "run_primary_analysis",
278
+ lambda **kw: _page_master(s["page"].id, s["ms"].id),
279
+ )
280
+
281
+ await _run_job_impl(s["job"].id, db)
282
+ await db.refresh(s["job"])
283
+
284
+ assert s["job"].status == "failed"
285
+
286
+
287
+ @pytest.mark.asyncio
288
+ async def test_no_image_path_page_error(db, setup_with_model, monkeypatch):
289
+ """Sans image_master_path, page.processing_status doit être 'ERROR'."""
290
+ s = setup_with_model
291
+ s["page"].image_master_path = None
292
+ await db.commit()
293
+ monkeypatch.setattr(
294
+ job_runner_module, "run_primary_analysis",
295
+ lambda **kw: _page_master(s["page"].id, s["ms"].id),
296
+ )
297
+
298
+ await _run_job_impl(s["job"].id, db)
299
+ await db.refresh(s["page"])
300
+
301
+ assert s["page"].processing_status == "ERROR"
302
+
303
+
304
+ @pytest.mark.asyncio
305
+ async def test_fetch_fails_job_failed(db, setup_with_model, monkeypatch):
306
+ """Si fetch_and_normalize lève, job.status doit être 'failed'."""
307
+ s = setup_with_model
308
+ monkeypatch.setattr(
309
+ job_runner_module, "fetch_and_normalize",
310
+ lambda *a: (_ for _ in ()).throw(OSError("network error")),
311
+ )
312
+
313
+ await _run_job_impl(s["job"].id, db)
314
+ await db.refresh(s["job"])
315
+
316
+ assert s["job"].status == "failed"
317
+
318
+
319
+ @pytest.mark.asyncio
320
+ async def test_fetch_fails_page_error(db, setup_with_model, monkeypatch):
321
+ """Si fetch_and_normalize lève, page.processing_status doit être 'ERROR'."""
322
+ s = setup_with_model
323
+ monkeypatch.setattr(
324
+ job_runner_module, "fetch_and_normalize",
325
+ lambda *a: (_ for _ in ()).throw(OSError("network error")),
326
+ )
327
+
328
+ await _run_job_impl(s["job"].id, db)
329
+ await db.refresh(s["page"])
330
+
331
+ assert s["page"].processing_status == "ERROR"
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Étape 6 — run_primary_analysis échoue
336
+ # ---------------------------------------------------------------------------
337
+
338
+ @pytest.mark.asyncio
339
+ async def test_primary_analysis_fails_job_failed(db, setup_with_model, monkeypatch):
340
+ """Si run_primary_analysis lève, job.status doit être 'failed'."""
341
+ s = setup_with_model
342
+ monkeypatch.setattr(
343
+ job_runner_module, "fetch_and_normalize", lambda *a: _image_info()
344
+ )
345
+ monkeypatch.setattr(
346
+ job_runner_module, "run_primary_analysis",
347
+ lambda **kw: (_ for _ in ()).throw(ValueError("ParseError: invalid JSON")),
348
+ )
349
+
350
+ await _run_job_impl(s["job"].id, db)
351
+ await db.refresh(s["job"])
352
+
353
+ assert s["job"].status == "failed"
354
+
355
+
356
+ @pytest.mark.asyncio
357
+ async def test_primary_analysis_fails_page_error(db, setup_with_model, monkeypatch):
358
+ """Si run_primary_analysis lève, page.processing_status doit être 'ERROR'."""
359
+ s = setup_with_model
360
+ monkeypatch.setattr(
361
+ job_runner_module, "fetch_and_normalize", lambda *a: _image_info()
362
+ )
363
+ monkeypatch.setattr(
364
+ job_runner_module, "run_primary_analysis",
365
+ lambda **kw: (_ for _ in ()).throw(ValueError("ParseError: invalid JSON")),
366
+ )
367
+
368
+ await _run_job_impl(s["job"].id, db)
369
+ await db.refresh(s["page"])
370
+
371
+ assert s["page"].processing_status == "ERROR"
372
+
373
+
374
+ @pytest.mark.asyncio
375
+ async def test_primary_analysis_error_message_stored(db, setup_with_model, monkeypatch):
376
+ """error_message doit contenir le message d'exception."""
377
+ s = setup_with_model
378
+ monkeypatch.setattr(
379
+ job_runner_module, "fetch_and_normalize", lambda *a: _image_info()
380
+ )
381
+ monkeypatch.setattr(
382
+ job_runner_module, "run_primary_analysis",
383
+ lambda **kw: (_ for _ in ()).throw(ValueError("ParseError: invalid JSON")),
384
+ )
385
+
386
+ await _run_job_impl(s["job"].id, db)
387
+ await db.refresh(s["job"])
388
+
389
+ assert "ParseError" in (s["job"].error_message or "")
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Étape 7 — write_alto échoue
394
+ # ---------------------------------------------------------------------------
395
+
396
+ @pytest.mark.asyncio
397
+ async def test_write_alto_fails_job_failed(db, setup_with_model, monkeypatch):
398
+ """Si write_alto lève OSError, job.status doit être 'failed'."""
399
+ s = setup_with_model
400
+ monkeypatch.setattr(
401
+ job_runner_module, "fetch_and_normalize", lambda *a: _image_info()
402
+ )
403
+ monkeypatch.setattr(
404
+ job_runner_module, "run_primary_analysis",
405
+ lambda **kw: _page_master(s["page"].id, s["ms"].id),
406
+ )
407
+ monkeypatch.setattr(job_runner_module, "generate_alto", lambda pm: "<alto/>")
408
+ monkeypatch.setattr(
409
+ job_runner_module, "write_alto",
410
+ lambda xml, path: (_ for _ in ()).throw(OSError("disk full")),
411
+ )
412
+
413
+ await _run_job_impl(s["job"].id, db)
414
+ await db.refresh(s["job"])
415
+
416
+ assert s["job"].status == "failed"
417
+
418
+
419
+ @pytest.mark.asyncio
420
+ async def test_write_alto_fails_page_error(db, setup_with_model, monkeypatch):
421
+ """Si write_alto lève OSError, page.processing_status doit être 'ERROR'."""
422
+ s = setup_with_model
423
+ monkeypatch.setattr(
424
+ job_runner_module, "fetch_and_normalize", lambda *a: _image_info()
425
+ )
426
+ monkeypatch.setattr(
427
+ job_runner_module, "run_primary_analysis",
428
+ lambda **kw: _page_master(s["page"].id, s["ms"].id),
429
+ )
430
+ monkeypatch.setattr(job_runner_module, "generate_alto", lambda pm: "<alto/>")
431
+ monkeypatch.setattr(
432
+ job_runner_module, "write_alto",
433
+ lambda xml, path: (_ for _ in ()).throw(OSError("disk full")),
434
+ )
435
+
436
+ await _run_job_impl(s["job"].id, db)
437
+ await db.refresh(s["page"])
438
+
439
+ assert s["page"].processing_status == "ERROR"
440
+
441
+
442
+ # ---------------------------------------------------------------------------
443
+ # Job introuvable
444
+ # ---------------------------------------------------------------------------
445
+
446
+ @pytest.mark.asyncio
447
+ async def test_job_not_found_no_crash(db):
448
+ """Si job_id n'existe pas, _run_job_impl retourne sans exception."""
449
+ await _run_job_impl("nonexistent-job-id", db) # ne doit pas lever
450
+
451
+
452
+ # ---------------------------------------------------------------------------
453
+ # corpus_runner — délégation séquentielle
454
+ # ---------------------------------------------------------------------------
455
+
456
+ @pytest.mark.asyncio
457
+ async def test_corpus_runner_no_pending_returns_zero(db, setup):
458
+ """Si aucun job pending, execute_corpus_job retourne {total:0, done:0, failed:0}."""
459
+ from app.services.corpus_runner import execute_corpus_job
460
+
461
+ corpus_id = setup["corpus"].id
462
+ # Passer le job en "done" pour simuler l'absence de pending
463
+ setup["job"].status = "done"
464
+ await db.commit()
465
+
466
+ # Monkeypatch async_session_factory pour utiliser notre BDD de test
467
+ engine = db.get_bind()
468
+
469
+ async def _mock_factory():
470
+ class _CM:
471
+ async def __aenter__(self_):
472
+ return db
473
+ async def __aexit__(self_, *args):
474
+ pass
475
+ return _CM()
476
+
477
+ import app.services.corpus_runner as cr_mod
478
+ monkeypatch_obj = None # pas de monkeypatch dispo ici, usage direct
479
+
480
+ # On teste via la logique : aucun job pending → total = 0
481
+ from sqlalchemy import select
482
+ result = await db.execute(
483
+ select(JobModel).where(
484
+ JobModel.corpus_id == corpus_id,
485
+ JobModel.status == "pending",
486
+ )
487
+ )
488
+ pending = list(result.scalars().all())
489
+ assert len(pending) == 0
490
+
491
+
492
+ @pytest.mark.asyncio
493
+ async def test_corpus_runner_calls_execute_per_job(monkeypatch):
494
+ """execute_corpus_job appelle execute_page_job pour chaque job pending."""
495
+ from app.services.corpus_runner import execute_corpus_job
496
+
497
+ called_ids: list[str] = []
498
+
499
+ async def _mock_execute(job_id: str) -> None:
500
+ called_ids.append(job_id)
501
+
502
+ class _FakeJob:
503
+ def __init__(self, id_: str, status: str):
504
+ self.id = id_
505
+ self.status = status
506
+
507
+ _call_count = [0]
508
+
509
+ class _FakeSession:
510
+ async def __aenter__(self):
511
+ return self
512
+
513
+ async def __aexit__(self, *args):
514
+ pass
515
+
516
+ async def execute(self, stmt):
517
+ _call_count[0] += 1
518
+ if _call_count[0] == 1:
519
+ # Premier appel : retourne les IDs de jobs pending
520
+ rows = ["job-alpha", "job-beta"]
521
+ else:
522
+ # Second appel : retourne les objets JobModel avec statut
523
+ rows = [_FakeJob("job-alpha", "done"), _FakeJob("job-beta", "done")]
524
+
525
+ class _Result:
526
+ def scalars(self_):
527
+ class _Scalars:
528
+ def all(self__):
529
+ return rows
530
+ return _Scalars()
531
+ return _Result()
532
+
533
+ def _mock_factory():
534
+ return _FakeSession()
535
+
536
+ monkeypatch.setattr(corpus_runner_module, "async_session_factory", _mock_factory)
537
+ monkeypatch.setattr(corpus_runner_module, "execute_page_job", _mock_execute)
538
+
539
+ await execute_corpus_job("corpus-xyz")
540
+
541
+ assert called_ids == ["job-alpha", "job-beta"]