Claude commited on
Commit
8feb390
·
unverified ·
1 Parent(s): 1b0c69c

fix: 3 production bugs — mistralai import, page ID collision, provider logging

Browse files

BUG 1 (ImportError mistralai): wrap `from mistralai import Mistral` in
try/except ImportError with a clear message pointing to `pip install
'mistralai>=1.0'`. pyproject.toml already pins >=1.0.

BUG 2 (UNIQUE constraint failed pages.id): two fixes:
A) _create_page now checks if page ID exists before INSERT — returns None
if already present (skip with log). All 3 ingestion endpoints track
created vs skipped and return pages_skipped in IngestResponse.
B) New _make_page_id helper adds sequence number to ID when folio_label
is not unique in the batch: {slug}-{seq:04d}-{label}. Prevents
collision when multiple canvases share the same label (e.g. "NP").
Added 3 tests: reingest manifest, reingest images, duplicate labels.

BUG 3 (Provider inaccessible): log messages in get_available_providers and
list_all_models now include provider name and error in the message string
itself (not only in extras), for better readability in HF logs.

https://claude.ai/code/session_018woyEHc8HG2th7V4ewJ4Kg

backend/app/api/v1/ingest.py CHANGED
@@ -46,6 +46,7 @@ class IngestResponse(BaseModel):
46
  corpus_id: str
47
  manuscript_id: str
48
  pages_created: int
 
49
  page_ids: list[str]
50
 
51
 
@@ -92,16 +93,37 @@ async def _next_sequence(db: AsyncSession, manuscript_id: str) -> int:
92
  return (max_seq or 0) + 1
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  async def _create_page(
96
  db: AsyncSession,
97
  manuscript_id: str,
98
- corpus_id: str,
99
  folio_label: str,
100
  sequence: int,
101
  image_master_path: str | None = None,
102
- ) -> PageModel:
 
 
 
 
 
 
103
  page = PageModel(
104
- id=f"{corpus_id}-{folio_label}",
105
  manuscript_id=manuscript_id,
106
  folio_label=folio_label,
107
  sequence=sequence,
@@ -179,10 +201,16 @@ async def ingest_files(
179
  ms = await _get_or_create_manuscript(db, corpus_id)
180
  seq = await _next_sequence(db, ms.id)
181
 
 
 
 
 
182
  created: list[PageModel] = []
 
183
  for i, upload in enumerate(files):
184
  filename = Path(upload.filename or f"file_{i}").name
185
- folio_label = Path(filename).stem # nom sans extension
 
186
 
187
  master_dir = (
188
  _config_module.settings.data_dir
@@ -197,22 +225,26 @@ async def ingest_files(
197
  master_path.write_bytes(content)
198
 
199
  page = await _create_page(
200
- db, ms.id, corpus.slug, folio_label, seq + i,
201
  image_master_path=str(master_path),
202
  )
203
- created.append(page)
 
 
 
204
 
205
  ms.total_pages = (ms.total_pages or 0) + len(created)
206
  await db.commit()
207
 
208
  logger.info(
209
  "Fichiers ingérés",
210
- extra={"corpus_id": corpus_id, "count": len(created)},
211
  )
212
  return IngestResponse(
213
  corpus_id=corpus_id,
214
  manuscript_id=ms.id,
215
  pages_created=len(created),
 
216
  page_ids=[p.id for p in created],
217
  )
218
 
@@ -269,27 +301,37 @@ async def ingest_iiif_manifest(
269
  ms = await _get_or_create_manuscript(db, corpus_id, title=ms_title)
270
  seq = await _next_sequence(db, ms.id)
271
 
 
 
 
 
272
  created: list[PageModel] = []
 
273
  for i, canvas in enumerate(canvases):
274
- folio_label = _extract_canvas_label(canvas, i)
 
275
  image_url = _extract_canvas_image_url(canvas)
276
  page = await _create_page(
277
- db, ms.id, corpus.slug, folio_label, seq + i,
278
  image_master_path=image_url,
279
  )
280
- created.append(page)
 
 
 
281
 
282
  ms.total_pages = (ms.total_pages or 0) + len(created)
283
  await db.commit()
284
 
285
  logger.info(
286
  "Manifest IIIF ingéré",
287
- extra={"corpus_id": corpus_id, "url": body.manifest_url, "pages": len(created)},
288
  )
289
  return IngestResponse(
290
  corpus_id=corpus_id,
291
  manuscript_id=ms.id,
292
  pages_created=len(created),
 
293
  page_ids=[p.id for p in created],
294
  )
295
 
@@ -316,24 +358,32 @@ async def ingest_iiif_images(
316
  ms = await _get_or_create_manuscript(db, corpus_id)
317
  seq = await _next_sequence(db, ms.id)
318
 
 
 
319
  created: list[PageModel] = []
 
320
  for i, (url, folio_label) in enumerate(zip(body.urls, body.folio_labels)):
 
321
  page = await _create_page(
322
- db, ms.id, corpus.slug, folio_label, seq + i,
323
  image_master_path=url,
324
  )
325
- created.append(page)
 
 
 
326
 
327
  ms.total_pages = (ms.total_pages or 0) + len(created)
328
  await db.commit()
329
 
330
  logger.info(
331
  "Images IIIF ingérées",
332
- extra={"corpus_id": corpus_id, "count": len(created)},
333
  )
334
  return IngestResponse(
335
  corpus_id=corpus_id,
336
  manuscript_id=ms.id,
337
  pages_created=len(created),
 
338
  page_ids=[p.id for p in created],
339
  )
 
46
  corpus_id: str
47
  manuscript_id: str
48
  pages_created: int
49
+ pages_skipped: int = 0
50
  page_ids: list[str]
51
 
52
 
 
93
  return (max_seq or 0) + 1
94
 
95
 
96
+ def _find_duplicate_labels(labels: list[str]) -> set[str]:
97
+ """Retourne les folio_labels qui apparaissent plus d'une fois."""
98
+ seen: dict[str, int] = {}
99
+ for label in labels:
100
+ seen[label] = seen.get(label, 0) + 1
101
+ return {label for label, count in seen.items() if count > 1}
102
+
103
+
104
+ def _make_page_id(corpus_slug: str, folio_label: str, batch_index: int, duplicate_labels: set[str]) -> str:
105
+ """Génère un ID de page. Ajoute le batch_index si le label n'est pas unique."""
106
+ if folio_label in duplicate_labels:
107
+ return f"{corpus_slug}-{batch_index:04d}-{folio_label}"
108
+ return f"{corpus_slug}-{folio_label}"
109
+
110
+
111
  async def _create_page(
112
  db: AsyncSession,
113
  manuscript_id: str,
114
+ page_id: str,
115
  folio_label: str,
116
  sequence: int,
117
  image_master_path: str | None = None,
118
+ ) -> PageModel | None:
119
+ """Crée une page si elle n'existe pas déjà. Retourne None si l'ID est déjà pris."""
120
+ existing = await db.get(PageModel, page_id)
121
+ if existing is not None:
122
+ logger.info("Page déjà existante, ignorée", extra={"page_id": page_id})
123
+ return None
124
+
125
  page = PageModel(
126
+ id=page_id,
127
  manuscript_id=manuscript_id,
128
  folio_label=folio_label,
129
  sequence=sequence,
 
201
  ms = await _get_or_create_manuscript(db, corpus_id)
202
  seq = await _next_sequence(db, ms.id)
203
 
204
+ # Collect labels and detect duplicates
205
+ labels = [Path(f.filename or f"file_{i}").stem for i, f in enumerate(files)]
206
+ dupes = _find_duplicate_labels(labels)
207
+
208
  created: list[PageModel] = []
209
+ skipped = 0
210
  for i, upload in enumerate(files):
211
  filename = Path(upload.filename or f"file_{i}").name
212
+ folio_label = labels[i]
213
+ page_id = _make_page_id(corpus.slug, folio_label, seq + i, dupes)
214
 
215
  master_dir = (
216
  _config_module.settings.data_dir
 
225
  master_path.write_bytes(content)
226
 
227
  page = await _create_page(
228
+ db, ms.id, page_id, folio_label, seq + i,
229
  image_master_path=str(master_path),
230
  )
231
+ if page is None:
232
+ skipped += 1
233
+ else:
234
+ created.append(page)
235
 
236
  ms.total_pages = (ms.total_pages or 0) + len(created)
237
  await db.commit()
238
 
239
  logger.info(
240
  "Fichiers ingérés",
241
+ extra={"corpus_id": corpus_id, "created": len(created), "skipped": skipped},
242
  )
243
  return IngestResponse(
244
  corpus_id=corpus_id,
245
  manuscript_id=ms.id,
246
  pages_created=len(created),
247
+ pages_skipped=skipped,
248
  page_ids=[p.id for p in created],
249
  )
250
 
 
301
  ms = await _get_or_create_manuscript(db, corpus_id, title=ms_title)
302
  seq = await _next_sequence(db, ms.id)
303
 
304
+ # Collect labels and detect duplicates
305
+ labels = [_extract_canvas_label(canvas, i) for i, canvas in enumerate(canvases)]
306
+ dupes = _find_duplicate_labels(labels)
307
+
308
  created: list[PageModel] = []
309
+ skipped = 0
310
  for i, canvas in enumerate(canvases):
311
+ folio_label = labels[i]
312
+ page_id = _make_page_id(corpus.slug, folio_label, seq + i, dupes)
313
  image_url = _extract_canvas_image_url(canvas)
314
  page = await _create_page(
315
+ db, ms.id, page_id, folio_label, seq + i,
316
  image_master_path=image_url,
317
  )
318
+ if page is None:
319
+ skipped += 1
320
+ else:
321
+ created.append(page)
322
 
323
  ms.total_pages = (ms.total_pages or 0) + len(created)
324
  await db.commit()
325
 
326
  logger.info(
327
  "Manifest IIIF ingéré",
328
+ extra={"corpus_id": corpus_id, "url": body.manifest_url, "created": len(created), "skipped": skipped},
329
  )
330
  return IngestResponse(
331
  corpus_id=corpus_id,
332
  manuscript_id=ms.id,
333
  pages_created=len(created),
334
+ pages_skipped=skipped,
335
  page_ids=[p.id for p in created],
336
  )
337
 
 
358
  ms = await _get_or_create_manuscript(db, corpus_id)
359
  seq = await _next_sequence(db, ms.id)
360
 
361
+ dupes = _find_duplicate_labels(body.folio_labels)
362
+
363
  created: list[PageModel] = []
364
+ skipped = 0
365
  for i, (url, folio_label) in enumerate(zip(body.urls, body.folio_labels)):
366
+ page_id = _make_page_id(corpus.slug, folio_label, seq + i, dupes)
367
  page = await _create_page(
368
+ db, ms.id, page_id, folio_label, seq + i,
369
  image_master_path=url,
370
  )
371
+ if page is None:
372
+ skipped += 1
373
+ else:
374
+ created.append(page)
375
 
376
  ms.total_pages = (ms.total_pages or 0) + len(created)
377
  await db.commit()
378
 
379
  logger.info(
380
  "Images IIIF ingérées",
381
+ extra={"corpus_id": corpus_id, "created": len(created), "skipped": skipped},
382
  )
383
  return IngestResponse(
384
  corpus_id=corpus_id,
385
  manuscript_id=ms.id,
386
  pages_created=len(created),
387
+ pages_skipped=skipped,
388
  page_ids=[p.id for p in created],
389
  )
backend/app/services/ai/model_registry.py CHANGED
@@ -54,8 +54,9 @@ def get_available_providers() -> list[dict]:
54
  model_count = len(models)
55
  except Exception as exc:
56
  logger.warning(
57
- "Provider inaccessible lors de get_available_providers",
58
- extra={"provider": provider.provider_type, "error": str(exc)},
 
59
  )
60
  available = False
61
 
@@ -117,8 +118,9 @@ def list_all_models() -> list[ModelInfo]:
117
  )
118
  except Exception as exc:
119
  logger.warning(
120
- "Provider inaccessible",
121
- extra={"provider": provider.provider_type, "error": str(exc)},
 
122
  )
123
 
124
  return result
 
54
  model_count = len(models)
55
  except Exception as exc:
56
  logger.warning(
57
+ "Provider %s inaccessible : %s",
58
+ provider.provider_type.value,
59
+ exc,
60
  )
61
  available = False
62
 
 
118
  )
119
  except Exception as exc:
120
  logger.warning(
121
+ "Provider %s inaccessible : %s",
122
+ provider.provider_type.value,
123
+ exc,
124
  )
125
 
126
  return result
backend/app/services/ai/provider_mistral.py CHANGED
@@ -67,7 +67,13 @@ class MistralProvider(AIProvider):
67
  if not self.is_configured():
68
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
69
 
70
- from mistralai import Mistral # import local — dépendance optionnelle
 
 
 
 
 
 
71
 
72
  api_key = os.environ[_ENV_KEY]
73
  client = Mistral(api_key=api_key)
 
67
  if not self.is_configured():
68
  raise RuntimeError(f"Variable d'environnement manquante : {_ENV_KEY}")
69
 
70
+ try:
71
+ from mistralai import Mistral # v1.x — import local
72
+ except ImportError as exc:
73
+ raise ImportError(
74
+ "Impossible d'importer 'Mistral' depuis mistralai. "
75
+ "Vérifiez que le package est installé : pip install 'mistralai>=1.0'"
76
+ ) from exc
77
 
78
  api_key = os.environ[_ENV_KEY]
79
  client = Mistral(api_key=api_key)
backend/tests/test_api_ingest.py CHANGED
@@ -417,3 +417,103 @@ async def test_ingest_images_corpus_id_in_response(async_client, db_session):
417
  json={"urls": ["https://x.com/1.jpg"], "folio_labels": ["f001r"]},
418
  )).json()
419
  assert data["corpus_id"] == corpus.id
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  json={"urls": ["https://x.com/1.jpg"], "folio_labels": ["f001r"]},
418
  )).json()
419
  assert data["corpus_id"] == corpus.id
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # Réingestion — pas de 500
424
+ # ---------------------------------------------------------------------------
425
+
426
+ @pytest.mark.asyncio
427
+ async def test_reingest_manifest_skips_existing_pages(async_client, db_session, monkeypatch):
428
+ """Réingérer le même manifest ne provoque pas de 500 (UNIQUE constraint).
429
+
430
+ La deuxième ingestion doit retourner 201 avec pages_created=0 et pages_skipped=N.
431
+ """
432
+ corpus = await _make_corpus(db_session, slug="reingest")
433
+ manifest = _iiif3_manifest(n_canvases=2)
434
+
435
+ async def fake_fetch(url: str) -> dict:
436
+ return manifest
437
+
438
+ monkeypatch.setattr(ingest_module, "_fetch_json_manifest", fake_fetch)
439
+
440
+ # Première ingestion
441
+ resp1 = await async_client.post(
442
+ f"/api/v1/corpora/{corpus.id}/ingest/iiif-manifest",
443
+ json={"manifest_url": "https://example.com/manifest"},
444
+ )
445
+ assert resp1.status_code == 201
446
+ data1 = resp1.json()
447
+ assert data1["pages_created"] == 2
448
+ assert data1["pages_skipped"] == 0
449
+
450
+ # Deuxième ingestion — même manifest
451
+ resp2 = await async_client.post(
452
+ f"/api/v1/corpora/{corpus.id}/ingest/iiif-manifest",
453
+ json={"manifest_url": "https://example.com/manifest"},
454
+ )
455
+ assert resp2.status_code == 201
456
+ data2 = resp2.json()
457
+ assert data2["pages_created"] == 0
458
+ assert data2["pages_skipped"] == 2
459
+
460
+
461
+ @pytest.mark.asyncio
462
+ async def test_reingest_images_skips_existing_pages(async_client, db_session):
463
+ """Réingérer les mêmes images ne provoque pas de 500."""
464
+ corpus = await _make_corpus(db_session, slug="reingest2")
465
+
466
+ payload = {"urls": ["https://x.com/a.jpg"], "folio_labels": ["f001r"]}
467
+
468
+ resp1 = await async_client.post(
469
+ f"/api/v1/corpora/{corpus.id}/ingest/iiif-images", json=payload,
470
+ )
471
+ assert resp1.status_code == 201
472
+ assert resp1.json()["pages_created"] == 1
473
+
474
+ resp2 = await async_client.post(
475
+ f"/api/v1/corpora/{corpus.id}/ingest/iiif-images", json=payload,
476
+ )
477
+ assert resp2.status_code == 201
478
+ assert resp2.json()["pages_created"] == 0
479
+ assert resp2.json()["pages_skipped"] == 1
480
+
481
+
482
+ @pytest.mark.asyncio
483
+ async def test_ingest_manifest_duplicate_labels_no_collision(async_client, db_session, monkeypatch):
484
+ """Deux canvases avec le même label ne provoquent pas de collision d'ID."""
485
+ corpus = await _make_corpus(db_session, slug="dupe-labels")
486
+ manifest = {
487
+ "@context": "http://iiif.io/api/presentation/3/context.json",
488
+ "type": "Manifest",
489
+ "label": {"fr": ["Test"]},
490
+ "items": [
491
+ {
492
+ "id": f"https://example.com/canvas/{i}",
493
+ "type": "Canvas",
494
+ "label": {"none": ["NP"]},
495
+ "items": [{
496
+ "type": "AnnotationPage",
497
+ "items": [{
498
+ "type": "Annotation",
499
+ "motivation": "painting",
500
+ "body": {"id": f"https://example.com/img/{i}.jpg", "type": "Image"},
501
+ "target": f"https://example.com/canvas/{i}",
502
+ }],
503
+ }],
504
+ }
505
+ for i in range(1, 4)
506
+ ],
507
+ }
508
+
509
+ monkeypatch.setattr(ingest_module, "_fetch_json_manifest", AsyncMock(return_value=manifest))
510
+
511
+ resp = await async_client.post(
512
+ f"/api/v1/corpora/{corpus.id}/ingest/iiif-manifest",
513
+ json={"manifest_url": "https://example.com/manifest"},
514
+ )
515
+ assert resp.status_code == 201
516
+ data = resp.json()
517
+ assert data["pages_created"] == 3
518
+ # All IDs must be distinct
519
+ assert len(set(data["page_ids"])) == 3