biblicus 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. biblicus/__init__.py +2 -2
  2. biblicus/_vendor/dotyaml/__init__.py +14 -0
  3. biblicus/_vendor/dotyaml/interpolation.py +63 -0
  4. biblicus/_vendor/dotyaml/loader.py +181 -0
  5. biblicus/_vendor/dotyaml/transformer.py +135 -0
  6. biblicus/backends/__init__.py +0 -2
  7. biblicus/backends/base.py +3 -3
  8. biblicus/backends/scan.py +21 -15
  9. biblicus/backends/sqlite_full_text_search.py +14 -15
  10. biblicus/cli.py +33 -49
  11. biblicus/corpus.py +39 -58
  12. biblicus/errors.py +15 -0
  13. biblicus/evaluation.py +4 -8
  14. biblicus/extraction.py +276 -77
  15. biblicus/extractors/__init__.py +14 -3
  16. biblicus/extractors/base.py +12 -5
  17. biblicus/extractors/metadata_text.py +13 -5
  18. biblicus/extractors/openai_stt.py +180 -0
  19. biblicus/extractors/pass_through_text.py +16 -6
  20. biblicus/extractors/pdf_text.py +100 -0
  21. biblicus/extractors/pipeline.py +105 -0
  22. biblicus/extractors/rapidocr_text.py +129 -0
  23. biblicus/extractors/select_longest_text.py +105 -0
  24. biblicus/extractors/select_text.py +100 -0
  25. biblicus/extractors/unstructured_text.py +100 -0
  26. biblicus/frontmatter.py +0 -3
  27. biblicus/hook_logging.py +0 -5
  28. biblicus/hook_manager.py +3 -5
  29. biblicus/hooks.py +3 -7
  30. biblicus/ignore.py +0 -3
  31. biblicus/models.py +87 -0
  32. biblicus/retrieval.py +0 -4
  33. biblicus/sources.py +44 -9
  34. biblicus/time.py +0 -1
  35. biblicus/uris.py +3 -4
  36. biblicus/user_config.py +138 -0
  37. {biblicus-0.2.0.dist-info → biblicus-0.3.0.dist-info}/METADATA +78 -16
  38. biblicus-0.3.0.dist-info/RECORD +44 -0
  39. biblicus/extractors/cascade.py +0 -101
  40. biblicus-0.2.0.dist-info/RECORD +0 -32
  41. {biblicus-0.2.0.dist-info → biblicus-0.3.0.dist-info}/WHEEL +0 -0
  42. {biblicus-0.2.0.dist-info → biblicus-0.3.0.dist-info}/entry_points.txt +0 -0
  43. {biblicus-0.2.0.dist-info → biblicus-0.3.0.dist-info}/licenses/LICENSE +0 -0
  44. {biblicus-0.2.0.dist-info → biblicus-0.3.0.dist-info}/top_level.txt +0 -0
biblicus/extraction.py CHANGED
@@ -6,65 +6,21 @@ from __future__ import annotations
6
6
 
7
7
  import json
8
8
  from pathlib import Path
9
- from typing import Any, Dict, List, Optional
9
+ from typing import Any, Dict, List, Optional, Tuple
10
10
  from uuid import uuid4
11
11
 
12
12
  from pydantic import BaseModel, ConfigDict, Field
13
13
 
14
14
  from .corpus import Corpus
15
+ from .errors import ExtractionRunFatalError
15
16
  from .extractors import get_extractor
16
- from .models import CatalogItem
17
+ from .extractors.base import TextExtractor
18
+ from .extractors.pipeline import PipelineExtractorConfig, PipelineStepSpec
19
+ from .models import CatalogItem, ExtractionStepOutput
17
20
  from .retrieval import hash_text
18
21
  from .time import utc_now_iso
19
22
 
20
23
 
21
- class ExtractionRunReference(BaseModel):
22
- """
23
- Reference to an extraction run.
24
-
25
- :ivar extractor_id: Extractor plugin identifier.
26
- :vartype extractor_id: str
27
- :ivar run_id: Extraction run identifier.
28
- :vartype run_id: str
29
- """
30
-
31
- model_config = ConfigDict(extra="forbid")
32
-
33
- extractor_id: str = Field(min_length=1)
34
- run_id: str = Field(min_length=1)
35
-
36
- def as_string(self) -> str:
37
- """
38
- Serialize the reference as a single string.
39
-
40
- :return: Reference in the form extractor_id:run_id.
41
- :rtype: str
42
- """
43
-
44
- return f"{self.extractor_id}:{self.run_id}"
45
-
46
-
47
- def parse_extraction_run_reference(value: str) -> ExtractionRunReference:
48
- """
49
- Parse an extraction run reference in the form extractor_id:run_id.
50
-
51
- :param value: Raw reference string.
52
- :type value: str
53
- :return: Parsed extraction run reference.
54
- :rtype: ExtractionRunReference
55
- :raises ValueError: If the reference is not well formed.
56
- """
57
-
58
- if ":" not in value:
59
- raise ValueError("Extraction run reference must be extractor_id:run_id")
60
- extractor_id, run_id = value.split(":", 1)
61
- extractor_id = extractor_id.strip()
62
- run_id = run_id.strip()
63
- if not extractor_id or not run_id:
64
- raise ValueError("Extraction run reference must be extractor_id:run_id with non-empty parts")
65
- return ExtractionRunReference(extractor_id=extractor_id, run_id=run_id)
66
-
67
-
68
24
  class ExtractionRecipeManifest(BaseModel):
69
25
  """
70
26
  Reproducible configuration for an extraction plugin run.
@@ -90,26 +46,81 @@ class ExtractionRecipeManifest(BaseModel):
90
46
  config: Dict[str, Any] = Field(default_factory=dict)
91
47
 
92
48
 
49
+ class ExtractionStepResult(BaseModel):
50
+ """
51
+ Per-item result record for a single pipeline step.
52
+
53
+ :ivar step_index: One-based pipeline step index.
54
+ :vartype step_index: int
55
+ :ivar extractor_id: Extractor identifier for the step.
56
+ :vartype extractor_id: str
57
+ :ivar status: Step status, extracted, skipped, or errored.
58
+ :vartype status: str
59
+ :ivar text_relpath: Relative path to the step text artifact, when extracted.
60
+ :vartype text_relpath: str or None
61
+ :ivar text_characters: Character count of the extracted text.
62
+ :vartype text_characters: int
63
+ :ivar producer_extractor_id: Extractor identifier that produced the text content.
64
+ :vartype producer_extractor_id: str or None
65
+ :ivar source_step_index: Optional step index that supplied the text for selection-style extractors.
66
+ :vartype source_step_index: int or None
67
+ :ivar error_type: Optional error type name for errored steps.
68
+ :vartype error_type: str or None
69
+ :ivar error_message: Optional error message for errored steps.
70
+ :vartype error_message: str or None
71
+ """
72
+
73
+ model_config = ConfigDict(extra="forbid")
74
+
75
+ step_index: int = Field(ge=1)
76
+ extractor_id: str
77
+ status: str
78
+ text_relpath: Optional[str] = None
79
+ text_characters: int = Field(default=0, ge=0)
80
+ producer_extractor_id: Optional[str] = None
81
+ source_step_index: Optional[int] = Field(default=None, ge=1)
82
+ error_type: Optional[str] = None
83
+ error_message: Optional[str] = None
84
+
85
+
93
86
  class ExtractionItemResult(BaseModel):
94
87
  """
95
88
  Per-item result record for an extraction run.
96
89
 
97
90
  :ivar item_id: Item identifier.
98
91
  :vartype item_id: str
99
- :ivar status: Result status, extracted or skipped.
92
+ :ivar status: Final result status, extracted, skipped, or errored.
100
93
  :vartype status: str
101
- :ivar text_relpath: Relative path to the extracted text artifact, when extracted.
102
- :vartype text_relpath: str or None
103
- :ivar producer_extractor_id: Extractor identifier that produced the extracted text.
104
- :vartype producer_extractor_id: str or None
94
+ :ivar final_text_relpath: Relative path to the final extracted text artifact, when extracted.
95
+ :vartype final_text_relpath: str or None
96
+ :ivar final_step_index: Pipeline step index that produced the final text.
97
+ :vartype final_step_index: int or None
98
+ :ivar final_step_extractor_id: Extractor identifier of the step that produced the final text.
99
+ :vartype final_step_extractor_id: str or None
100
+ :ivar final_producer_extractor_id: Extractor identifier that produced the final text content.
101
+ :vartype final_producer_extractor_id: str or None
102
+ :ivar final_source_step_index: Optional step index that supplied the final text for selection-style extractors.
103
+ :vartype final_source_step_index: int or None
104
+ :ivar error_type: Optional error type name when no extracted text was produced.
105
+ :vartype error_type: str or None
106
+ :ivar error_message: Optional error message when no extracted text was produced.
107
+ :vartype error_message: str or None
108
+ :ivar step_results: Per-step results recorded for this item.
109
+ :vartype step_results: list[ExtractionStepResult]
105
110
  """
106
111
 
107
112
  model_config = ConfigDict(extra="forbid")
108
113
 
109
114
  item_id: str
110
115
  status: str
111
- text_relpath: Optional[str] = None
112
- producer_extractor_id: Optional[str] = None
116
+ final_text_relpath: Optional[str] = None
117
+ final_step_index: Optional[int] = Field(default=None, ge=1)
118
+ final_step_extractor_id: Optional[str] = None
119
+ final_producer_extractor_id: Optional[str] = None
120
+ final_source_step_index: Optional[int] = Field(default=None, ge=1)
121
+ error_type: Optional[str] = None
122
+ error_message: Optional[str] = None
123
+ step_results: List[ExtractionStepResult] = Field(default_factory=list)
113
124
 
114
125
 
115
126
  class ExtractionRunManifest(BaseModel):
@@ -143,7 +154,9 @@ class ExtractionRunManifest(BaseModel):
143
154
  stats: Dict[str, Any] = Field(default_factory=dict)
144
155
 
145
156
 
146
- def create_extraction_recipe_manifest(*, extractor_id: str, name: str, config: Dict[str, Any]) -> ExtractionRecipeManifest:
157
+ def create_extraction_recipe_manifest(
158
+ *, extractor_id: str, name: str, config: Dict[str, Any]
159
+ ) -> ExtractionRecipeManifest:
147
160
  """
148
161
  Create a deterministic extraction recipe manifest.
149
162
 
@@ -156,8 +169,9 @@ def create_extraction_recipe_manifest(*, extractor_id: str, name: str, config: D
156
169
  :return: Recipe manifest.
157
170
  :rtype: ExtractionRecipeManifest
158
171
  """
159
-
160
- recipe_payload = json.dumps({"extractor_id": extractor_id, "name": name, "config": config}, sort_keys=True)
172
+ recipe_payload = json.dumps(
173
+ {"extractor_id": extractor_id, "name": name, "config": config}, sort_keys=True
174
+ )
161
175
  recipe_id = hash_text(recipe_payload)
162
176
  return ExtractionRecipeManifest(
163
177
  recipe_id=recipe_id,
@@ -168,7 +182,9 @@ def create_extraction_recipe_manifest(*, extractor_id: str, name: str, config: D
168
182
  )
169
183
 
170
184
 
171
- def create_extraction_run_manifest(corpus: Corpus, *, recipe: ExtractionRecipeManifest) -> ExtractionRunManifest:
185
+ def create_extraction_run_manifest(
186
+ corpus: Corpus, *, recipe: ExtractionRecipeManifest
187
+ ) -> ExtractionRunManifest:
172
188
  """
173
189
  Create a new extraction run manifest for a corpus.
174
190
 
@@ -179,7 +195,6 @@ def create_extraction_run_manifest(corpus: Corpus, *, recipe: ExtractionRecipeMa
179
195
  :return: Run manifest.
180
196
  :rtype: ExtractionRunManifest
181
197
  """
182
-
183
198
  catalog = corpus.load_catalog()
184
199
  return ExtractionRunManifest(
185
200
  run_id=str(uuid4()),
@@ -203,7 +218,6 @@ def write_extraction_run_manifest(*, run_dir: Path, manifest: ExtractionRunManif
203
218
  :return: None.
204
219
  :rtype: None
205
220
  """
206
-
207
221
  manifest_path = run_dir / "manifest.json"
208
222
  manifest_path.write_text(manifest.model_dump_json(indent=2) + "\n", encoding="utf-8")
209
223
 
@@ -221,7 +235,6 @@ def write_extracted_text_artifact(*, run_dir: Path, item: CatalogItem, text: str
221
235
  :return: Relative path to the stored text artifact.
222
236
  :rtype: str
223
237
  """
224
-
225
238
  text_dir = run_dir / "text"
226
239
  text_dir.mkdir(parents=True, exist_ok=True)
227
240
  relpath = str(Path("text") / f"{item.id}.txt")
@@ -230,6 +243,70 @@ def write_extracted_text_artifact(*, run_dir: Path, item: CatalogItem, text: str
230
243
  return relpath
231
244
 
232
245
 
246
+ def _pipeline_step_dir_name(*, step_index: int, extractor_id: str) -> str:
247
+ """
248
+ Build a stable directory name for a pipeline step.
249
+
250
+ :param step_index: One-based pipeline step index.
251
+ :type step_index: int
252
+ :param extractor_id: Extractor identifier for the step.
253
+ :type extractor_id: str
254
+ :return: Directory name for the step.
255
+ :rtype: str
256
+ """
257
+ return f"{step_index:02d}-{extractor_id}"
258
+
259
+
260
+ def write_pipeline_step_text_artifact(
261
+ *,
262
+ run_dir: Path,
263
+ step_index: int,
264
+ extractor_id: str,
265
+ item: CatalogItem,
266
+ text: str,
267
+ ) -> str:
268
+ """
269
+ Write a pipeline step text artifact for an item.
270
+
271
+ :param run_dir: Extraction run directory.
272
+ :type run_dir: Path
273
+ :param step_index: One-based pipeline step index.
274
+ :type step_index: int
275
+ :param extractor_id: Extractor identifier for the step.
276
+ :type extractor_id: str
277
+ :param item: Catalog item being extracted.
278
+ :type item: CatalogItem
279
+ :param text: Extracted text content.
280
+ :type text: str
281
+ :return: Relative path to the stored step text artifact.
282
+ :rtype: str
283
+ """
284
+ step_dir_name = _pipeline_step_dir_name(step_index=step_index, extractor_id=extractor_id)
285
+ text_dir = run_dir / "steps" / step_dir_name / "text"
286
+ text_dir.mkdir(parents=True, exist_ok=True)
287
+ relpath = str(Path("steps") / step_dir_name / "text" / f"{item.id}.txt")
288
+ (run_dir / relpath).write_text(text, encoding="utf-8")
289
+ return relpath
290
+
291
+
292
+ def _final_output_from_steps(
293
+ step_outputs: List[ExtractionStepOutput],
294
+ ) -> Optional[ExtractionStepOutput]:
295
+ """
296
+ Select the final pipeline output for an item.
297
+
298
+ The final output is the last extracted step output in pipeline order.
299
+
300
+ :param step_outputs: Extracted outputs produced by pipeline steps.
301
+ :type step_outputs: list[biblicus.models.ExtractionStepOutput]
302
+ :return: Final step output or None when no steps produced extracted text.
303
+ :rtype: biblicus.models.ExtractionStepOutput or None
304
+ """
305
+ if not step_outputs:
306
+ return None
307
+ return step_outputs[-1]
308
+
309
+
233
310
  def build_extraction_run(
234
311
  corpus: Corpus,
235
312
  *,
@@ -238,11 +315,11 @@ def build_extraction_run(
238
315
  config: Dict[str, Any],
239
316
  ) -> ExtractionRunManifest:
240
317
  """
241
- Build an extraction run for a corpus using a named extractor plugin.
318
+ Build an extraction run for a corpus using the pipeline extractor.
242
319
 
243
320
  :param corpus: Corpus to extract from.
244
321
  :type corpus: Corpus
245
- :param extractor_id: Extractor plugin identifier.
322
+ :param extractor_id: Extractor plugin identifier (must be ``pipeline``).
246
323
  :type extractor_id: str
247
324
  :param recipe_name: Human-readable recipe name.
248
325
  :type recipe_name: str
@@ -253,8 +330,8 @@ def build_extraction_run(
253
330
  :raises KeyError: If the extractor identifier is unknown.
254
331
  :raises ValueError: If the extractor configuration is invalid.
255
332
  :raises OSError: If the run directory or artifacts cannot be written.
333
+ :raises ExtractionRunFatalError: If the extractor is not the pipeline.
256
334
  """
257
-
258
335
  extractor = get_extractor(extractor_id)
259
336
  parsed_config = extractor.validate_config(config)
260
337
  recipe = create_extraction_recipe_manifest(
@@ -267,14 +344,31 @@ def build_extraction_run(
267
344
  run_dir.mkdir(parents=True, exist_ok=False)
268
345
 
269
346
  catalog = corpus.load_catalog()
347
+ if extractor_id != "pipeline":
348
+ raise ExtractionRunFatalError("Extraction runs must use the pipeline extractor")
349
+
350
+ pipeline_config = (
351
+ parsed_config
352
+ if isinstance(parsed_config, PipelineExtractorConfig)
353
+ else PipelineExtractorConfig.model_validate(parsed_config)
354
+ )
355
+
356
+ validated_steps: List[Tuple[PipelineStepSpec, TextExtractor, BaseModel]] = []
357
+ for step in pipeline_config.steps:
358
+ step_extractor = get_extractor(step.extractor_id)
359
+ parsed_step_config = step_extractor.validate_config(step.config)
360
+ validated_steps.append((step, step_extractor, parsed_step_config))
361
+
270
362
  extracted_items: List[ExtractionItemResult] = []
271
363
  extracted_count = 0
272
364
  skipped_count = 0
365
+ errored_count = 0
273
366
  extracted_nonempty_count = 0
274
367
  extracted_empty_count = 0
275
368
  already_text_item_count = 0
276
369
  needs_extraction_item_count = 0
277
370
  converted_item_count = 0
371
+
278
372
  for item in catalog.items.values():
279
373
  media_type = item.media_type
280
374
  item_is_text = media_type == "text/markdown" or media_type.startswith("text/")
@@ -283,35 +377,139 @@ def build_extraction_run(
283
377
  else:
284
378
  needs_extraction_item_count += 1
285
379
 
286
- extracted_text = extractor.extract_text(corpus=corpus, item=item, config=parsed_config)
287
- if extracted_text is None:
288
- skipped_count += 1
380
+ step_results: List[ExtractionStepResult] = []
381
+ step_outputs: List[ExtractionStepOutput] = []
382
+ last_error_type: Optional[str] = None
383
+ last_error_message: Optional[str] = None
384
+
385
+ for step_index, (step, step_extractor, parsed_step_config) in enumerate(
386
+ validated_steps, start=1
387
+ ):
388
+ try:
389
+ extracted_text = step_extractor.extract_text(
390
+ corpus=corpus,
391
+ item=item,
392
+ config=parsed_step_config,
393
+ previous_extractions=step_outputs,
394
+ )
395
+ except Exception as extraction_error:
396
+ if isinstance(extraction_error, ExtractionRunFatalError):
397
+ raise
398
+ last_error_type = extraction_error.__class__.__name__
399
+ last_error_message = str(extraction_error)
400
+ step_results.append(
401
+ ExtractionStepResult(
402
+ step_index=step_index,
403
+ extractor_id=step.extractor_id,
404
+ status="errored",
405
+ text_relpath=None,
406
+ text_characters=0,
407
+ producer_extractor_id=None,
408
+ source_step_index=None,
409
+ error_type=last_error_type,
410
+ error_message=last_error_message,
411
+ )
412
+ )
413
+ continue
414
+
415
+ if extracted_text is None:
416
+ step_results.append(
417
+ ExtractionStepResult(
418
+ step_index=step_index,
419
+ extractor_id=step.extractor_id,
420
+ status="skipped",
421
+ text_relpath=None,
422
+ text_characters=0,
423
+ producer_extractor_id=None,
424
+ source_step_index=None,
425
+ error_type=None,
426
+ error_message=None,
427
+ )
428
+ )
429
+ continue
430
+
431
+ relpath = write_pipeline_step_text_artifact(
432
+ run_dir=run_dir,
433
+ step_index=step_index,
434
+ extractor_id=step.extractor_id,
435
+ item=item,
436
+ text=extracted_text.text,
437
+ )
438
+ text_characters = len(extracted_text.text)
439
+ step_results.append(
440
+ ExtractionStepResult(
441
+ step_index=step_index,
442
+ extractor_id=step.extractor_id,
443
+ status="extracted",
444
+ text_relpath=relpath,
445
+ text_characters=text_characters,
446
+ producer_extractor_id=extracted_text.producer_extractor_id,
447
+ source_step_index=extracted_text.source_step_index,
448
+ error_type=None,
449
+ error_message=None,
450
+ )
451
+ )
452
+ step_outputs.append(
453
+ ExtractionStepOutput(
454
+ step_index=step_index,
455
+ extractor_id=step.extractor_id,
456
+ status="extracted",
457
+ text=extracted_text.text,
458
+ text_characters=text_characters,
459
+ producer_extractor_id=extracted_text.producer_extractor_id,
460
+ source_step_index=extracted_text.source_step_index,
461
+ error_type=None,
462
+ error_message=None,
463
+ )
464
+ )
465
+
466
+ final_output = _final_output_from_steps(step_outputs)
467
+ if final_output is None:
468
+ status = "errored" if last_error_type else "skipped"
469
+ if status == "errored":
470
+ errored_count += 1
471
+ else:
472
+ skipped_count += 1
289
473
  extracted_items.append(
290
474
  ExtractionItemResult(
291
475
  item_id=item.id,
292
- status="skipped",
293
- text_relpath=None,
294
- producer_extractor_id=None,
476
+ status=status,
477
+ final_text_relpath=None,
478
+ final_step_index=None,
479
+ final_step_extractor_id=None,
480
+ final_producer_extractor_id=None,
481
+ final_source_step_index=None,
482
+ error_type=last_error_type if status == "errored" else None,
483
+ error_message=last_error_message if status == "errored" else None,
484
+ step_results=step_results,
295
485
  )
296
486
  )
297
487
  continue
298
488
 
489
+ final_text = final_output.text or ""
490
+ final_text_relpath = write_extracted_text_artifact(
491
+ run_dir=run_dir, item=item, text=final_text
492
+ )
299
493
  extracted_count += 1
300
- stripped_text = extracted_text.text.strip()
301
- if stripped_text:
494
+ if final_text.strip():
302
495
  extracted_nonempty_count += 1
303
496
  if not item_is_text:
304
497
  converted_item_count += 1
305
498
  else:
306
499
  extracted_empty_count += 1
307
500
 
308
- relpath = write_extracted_text_artifact(run_dir=run_dir, item=item, text=extracted_text.text)
309
501
  extracted_items.append(
310
502
  ExtractionItemResult(
311
503
  item_id=item.id,
312
504
  status="extracted",
313
- text_relpath=relpath,
314
- producer_extractor_id=extracted_text.producer_extractor_id,
505
+ final_text_relpath=final_text_relpath,
506
+ final_step_index=final_output.step_index,
507
+ final_step_extractor_id=final_output.extractor_id,
508
+ final_producer_extractor_id=final_output.producer_extractor_id,
509
+ final_source_step_index=final_output.source_step_index,
510
+ error_type=None,
511
+ error_message=None,
512
+ step_results=step_results,
315
513
  )
316
514
  )
317
515
 
@@ -323,6 +521,7 @@ def build_extraction_run(
323
521
  "extracted_nonempty_items": extracted_nonempty_count,
324
522
  "extracted_empty_items": extracted_empty_count,
325
523
  "skipped_items": skipped_count,
524
+ "errored_items": errored_count,
326
525
  "converted_items": converted_item_count,
327
526
  }
328
527
  manifest = manifest.model_copy(update={"items": extracted_items, "stats": stats})
@@ -7,9 +7,15 @@ from __future__ import annotations
7
7
  from typing import Dict
8
8
 
9
9
  from .base import TextExtractor
10
- from .cascade import CascadeExtractor
11
10
  from .metadata_text import MetadataTextExtractor
11
+ from .openai_stt import OpenAiSpeechToTextExtractor
12
12
  from .pass_through_text import PassThroughTextExtractor
13
+ from .pdf_text import PortableDocumentFormatTextExtractor
14
+ from .pipeline import PipelineExtractor
15
+ from .rapidocr_text import RapidOcrExtractor
16
+ from .select_longest_text import SelectLongestTextExtractor
17
+ from .select_text import SelectTextExtractor
18
+ from .unstructured_text import UnstructuredExtractor
13
19
 
14
20
 
15
21
  def get_extractor(extractor_id: str) -> TextExtractor:
@@ -22,11 +28,16 @@ def get_extractor(extractor_id: str) -> TextExtractor:
22
28
  :rtype: TextExtractor
23
29
  :raises KeyError: If the extractor identifier is not known.
24
30
  """
25
-
26
31
  extractors: Dict[str, TextExtractor] = {
27
- CascadeExtractor.extractor_id: CascadeExtractor(),
28
32
  MetadataTextExtractor.extractor_id: MetadataTextExtractor(),
29
33
  PassThroughTextExtractor.extractor_id: PassThroughTextExtractor(),
34
+ PipelineExtractor.extractor_id: PipelineExtractor(),
35
+ PortableDocumentFormatTextExtractor.extractor_id: PortableDocumentFormatTextExtractor(),
36
+ OpenAiSpeechToTextExtractor.extractor_id: OpenAiSpeechToTextExtractor(),
37
+ RapidOcrExtractor.extractor_id: RapidOcrExtractor(),
38
+ SelectTextExtractor.extractor_id: SelectTextExtractor(),
39
+ SelectLongestTextExtractor.extractor_id: SelectLongestTextExtractor(),
40
+ UnstructuredExtractor.extractor_id: UnstructuredExtractor(),
30
41
  }
31
42
  if extractor_id not in extractors:
32
43
  raise KeyError(f"Unknown extractor: {extractor_id!r}")
@@ -5,12 +5,12 @@ Base interfaces for text extraction plugins.
5
5
  from __future__ import annotations
6
6
 
7
7
  from abc import ABC, abstractmethod
8
- from typing import Any, Dict, Optional
8
+ from typing import Any, Dict, List, Optional
9
9
 
10
10
  from pydantic import BaseModel
11
11
 
12
12
  from ..corpus import Corpus
13
- from ..models import CatalogItem, ExtractedText
13
+ from ..models import CatalogItem, ExtractedText, ExtractionStepOutput
14
14
 
15
15
 
16
16
  class TextExtractor(ABC):
@@ -38,11 +38,17 @@ class TextExtractor(ABC):
38
38
  :rtype: pydantic.BaseModel
39
39
  :raises ValueError: If the configuration is invalid.
40
40
  """
41
-
42
41
  raise NotImplementedError
43
42
 
44
43
  @abstractmethod
45
- def extract_text(self, *, corpus: Corpus, item: CatalogItem, config: BaseModel) -> Optional[ExtractedText]:
44
+ def extract_text(
45
+ self,
46
+ *,
47
+ corpus: Corpus,
48
+ item: CatalogItem,
49
+ config: BaseModel,
50
+ previous_extractions: List[ExtractionStepOutput],
51
+ ) -> Optional[ExtractedText]:
46
52
  """
47
53
  Derive text for a catalog item.
48
54
 
@@ -54,8 +60,9 @@ class TextExtractor(ABC):
54
60
  :type item: CatalogItem
55
61
  :param config: Parsed extractor configuration.
56
62
  :type config: pydantic.BaseModel
63
+ :param previous_extractions: Prior step outputs for this item within the pipeline.
64
+ :type previous_extractions: list[biblicus.models.ExtractionStepOutput]
57
65
  :return: Extracted text payload or None when skipped.
58
66
  :rtype: ExtractedText or None
59
67
  """
60
-
61
68
  raise NotImplementedError
@@ -4,11 +4,11 @@ Metadata-based text extractor plugin.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from typing import Any, Dict, Optional
7
+ from typing import Any, Dict, List, Optional
8
8
 
9
9
  from pydantic import BaseModel, ConfigDict, Field
10
10
 
11
- from ..models import CatalogItem, ExtractedText
11
+ from ..models import CatalogItem, ExtractedText, ExtractionStepOutput
12
12
  from .base import TextExtractor
13
13
 
14
14
 
@@ -60,10 +60,16 @@ class MetadataTextExtractor(TextExtractor):
60
60
  :return: Parsed config.
61
61
  :rtype: MetadataTextExtractorConfig
62
62
  """
63
-
64
63
  return MetadataTextExtractorConfig.model_validate(config)
65
64
 
66
- def extract_text(self, *, corpus, item: CatalogItem, config: BaseModel) -> Optional[ExtractedText]:
65
+ def extract_text(
66
+ self,
67
+ *,
68
+ corpus,
69
+ item: CatalogItem,
70
+ config: BaseModel,
71
+ previous_extractions: List[ExtractionStepOutput],
72
+ ) -> Optional[ExtractedText]:
67
73
  """
68
74
  Extract a metadata-based text payload for the item.
69
75
 
@@ -73,16 +79,18 @@ class MetadataTextExtractor(TextExtractor):
73
79
  :type item: CatalogItem
74
80
  :param config: Parsed configuration model.
75
81
  :type config: MetadataTextExtractorConfig
82
+ :param previous_extractions: Prior step outputs for this item within the pipeline.
83
+ :type previous_extractions: list[biblicus.models.ExtractionStepOutput]
76
84
  :return: Extracted text payload, or ``None`` if no metadata is available.
77
85
  :rtype: ExtractedText or None
78
86
  """
79
-
80
87
  parsed_config = (
81
88
  config
82
89
  if isinstance(config, MetadataTextExtractorConfig)
83
90
  else MetadataTextExtractorConfig.model_validate(config)
84
91
  )
85
92
  _ = corpus
93
+ _ = previous_extractions
86
94
  lines: list[str] = []
87
95
 
88
96
  if parsed_config.include_title and isinstance(item.title, str) and item.title.strip():