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.
- biblicus/__init__.py +2 -2
- biblicus/_vendor/dotyaml/__init__.py +14 -0
- biblicus/_vendor/dotyaml/interpolation.py +63 -0
- biblicus/_vendor/dotyaml/loader.py +181 -0
- biblicus/_vendor/dotyaml/transformer.py +135 -0
- biblicus/backends/__init__.py +0 -2
- biblicus/backends/base.py +3 -3
- biblicus/backends/scan.py +21 -15
- biblicus/backends/sqlite_full_text_search.py +14 -15
- biblicus/cli.py +33 -49
- biblicus/corpus.py +39 -58
- biblicus/errors.py +15 -0
- biblicus/evaluation.py +4 -8
- biblicus/extraction.py +276 -77
- biblicus/extractors/__init__.py +14 -3
- biblicus/extractors/base.py +12 -5
- biblicus/extractors/metadata_text.py +13 -5
- biblicus/extractors/openai_stt.py +180 -0
- biblicus/extractors/pass_through_text.py +16 -6
- biblicus/extractors/pdf_text.py +100 -0
- biblicus/extractors/pipeline.py +105 -0
- biblicus/extractors/rapidocr_text.py +129 -0
- biblicus/extractors/select_longest_text.py +105 -0
- biblicus/extractors/select_text.py +100 -0
- biblicus/extractors/unstructured_text.py +100 -0
- biblicus/frontmatter.py +0 -3
- biblicus/hook_logging.py +0 -5
- biblicus/hook_manager.py +3 -5
- biblicus/hooks.py +3 -7
- biblicus/ignore.py +0 -3
- biblicus/models.py +87 -0
- biblicus/retrieval.py +0 -4
- biblicus/sources.py +44 -9
- biblicus/time.py +0 -1
- biblicus/uris.py +3 -4
- biblicus/user_config.py +138 -0
- {biblicus-0.2.0.dist-info → biblicus-0.3.0.dist-info}/METADATA +78 -16
- biblicus-0.3.0.dist-info/RECORD +44 -0
- biblicus/extractors/cascade.py +0 -101
- biblicus-0.2.0.dist-info/RECORD +0 -32
- {biblicus-0.2.0.dist-info → biblicus-0.3.0.dist-info}/WHEEL +0 -0
- {biblicus-0.2.0.dist-info → biblicus-0.3.0.dist-info}/entry_points.txt +0 -0
- {biblicus-0.2.0.dist-info → biblicus-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {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 .
|
|
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:
|
|
92
|
+
:ivar status: Final result status, extracted, skipped, or errored.
|
|
100
93
|
:vartype status: str
|
|
101
|
-
:ivar
|
|
102
|
-
:vartype
|
|
103
|
-
:ivar
|
|
104
|
-
:vartype
|
|
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
|
-
|
|
112
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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=
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
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})
|
biblicus/extractors/__init__.py
CHANGED
|
@@ -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}")
|
biblicus/extractors/base.py
CHANGED
|
@@ -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(
|
|
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(
|
|
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():
|