restruct-cv 0.1.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 (62) hide show
  1. restruct/__init__.py +56 -0
  2. restruct/cli.py +521 -0
  3. restruct/configs/__init__.py +5 -0
  4. restruct/configs/embedding_references.py +290 -0
  5. restruct/configs/settings.py +175 -0
  6. restruct/debug/__init__.py +1 -0
  7. restruct/debug/artifacts.py +134 -0
  8. restruct/debug/canvas.py +141 -0
  9. restruct/debug/colors.py +172 -0
  10. restruct/debug/reconstruct.py +663 -0
  11. restruct/debug/render.py +323 -0
  12. restruct/debug/stages.py +294 -0
  13. restruct/document/__init__.py +17 -0
  14. restruct/document/physical.py +293 -0
  15. restruct/document/stats.py +532 -0
  16. restruct/document/types.py +121 -0
  17. restruct/errors.py +128 -0
  18. restruct/geometry.py +151 -0
  19. restruct/ingestion/__init__.py +1 -0
  20. restruct/ingestion/docx.py +558 -0
  21. restruct/ingestion/native.py +256 -0
  22. restruct/ingestion/ocr.py +248 -0
  23. restruct/layout/__init__.py +1 -0
  24. restruct/layout/blocks.py +154 -0
  25. restruct/layout/lines.py +75 -0
  26. restruct/layout/rows.py +95 -0
  27. restruct/layout/unsupported.py +237 -0
  28. restruct/layout/words.py +229 -0
  29. restruct/model.py +674 -0
  30. restruct/parsers/__init__.py +1 -0
  31. restruct/parsers/education.py +340 -0
  32. restruct/parsers/experience.py +474 -0
  33. restruct/parsers/grouped.py +580 -0
  34. restruct/parsers/header.py +513 -0
  35. restruct/parsers/skills.py +266 -0
  36. restruct/parsers/urls.py +194 -0
  37. restruct/patterns/__init__.py +1 -0
  38. restruct/patterns/bullets.py +14 -0
  39. restruct/patterns/contacts.py +23 -0
  40. restruct/patterns/dates.py +59 -0
  41. restruct/patterns/education.py +32 -0
  42. restruct/patterns/languages.py +81 -0
  43. restruct/patterns/layout.py +31 -0
  44. restruct/patterns/organizations.py +19 -0
  45. restruct/patterns/personal.py +69 -0
  46. restruct/patterns/separators.py +47 -0
  47. restruct/pipeline.py +247 -0
  48. restruct/schema.py +257 -0
  49. restruct/stages.py +28 -0
  50. restruct/structure/__init__.py +1 -0
  51. restruct/structure/compound.py +375 -0
  52. restruct/structure/headings.py +195 -0
  53. restruct/structure/keyvalue.py +96 -0
  54. restruct/structure/metadata.py +66 -0
  55. restruct/structure/resolver.py +218 -0
  56. restruct/structure/sections.py +189 -0
  57. restruct/structure/separators.py +201 -0
  58. restruct_cv-0.1.0.dist-info/METADATA +226 -0
  59. restruct_cv-0.1.0.dist-info/RECORD +62 -0
  60. restruct_cv-0.1.0.dist-info/WHEEL +4 -0
  61. restruct_cv-0.1.0.dist-info/entry_points.txt +3 -0
  62. restruct_cv-0.1.0.dist-info/licenses/LICENSE +21 -0
restruct/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """Restruct: explainable resume extraction from PDF and scanned documents.
2
+
3
+ This module is the public Python API. It re-exports the pipeline entry points
4
+ and the shared document types; everything else lives in the stage packages:
5
+
6
+ ingestion/ physical extraction, native and OCR
7
+ document/ shared types and document representation
8
+ layout/ rows, paragraphs and bullet reconstruction
9
+ structure/ headings, key-value pairs and section routing
10
+ parsers/ one module per section shape
11
+ models/ NER and embedding adapters
12
+ patterns/ deterministic evidence
13
+ debug/ artifacts and overlay rendering
14
+ schema/ the versioned clean output
15
+ """
16
+
17
+ from restruct.document.types import (
18
+ DetectedHeading,
19
+ ExtractedLine,
20
+ HeaderEntityMatch,
21
+ )
22
+ from restruct.schema import build_v1_resume, write_v1_resume
23
+
24
+ # ``main`` and ``extract_resume`` reach the model libraries, which cost about
25
+ # four seconds to import. Re-exporting them eagerly made every entry into this
26
+ # package pay that -- including `restruct --help` and a run that fails
27
+ # validation before a model is ever consulted. PEP 562 keeps the public names
28
+ # where they were and defers the cost to the first use.
29
+ _LAZY_EXPORTS = {
30
+ "main": ("restruct.cli", "main"),
31
+ "extract_resume": ("restruct.pipeline", "extract_resume"),
32
+ }
33
+
34
+
35
+ def __getattr__(name: str):
36
+ target = _LAZY_EXPORTS.get(name)
37
+ if target is None:
38
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
39
+ import importlib
40
+
41
+ module_name, attribute = target
42
+ return getattr(importlib.import_module(module_name), attribute)
43
+
44
+
45
+ def __dir__() -> list[str]:
46
+ return sorted(__all__)
47
+
48
+ __all__ = [
49
+ "DetectedHeading",
50
+ "ExtractedLine",
51
+ "HeaderEntityMatch",
52
+ "build_v1_resume",
53
+ "extract_resume",
54
+ "main",
55
+ "write_v1_resume",
56
+ ]
restruct/cli.py ADDED
@@ -0,0 +1,521 @@
1
+ """Command-line entry point.
2
+
3
+ Kept separate from the pipeline so filesystem layout, terminal output and
4
+ process status stay out of the engine. This is the only module that turns a
5
+ ``RestructError`` into an exit code; the Python API raises instead, so a caller
6
+ embedding restruct catches an exception by type rather than losing its process.
7
+
8
+ Quiet on success. A tool that prints nothing when it worked is one you can put
9
+ in a pipe.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import os
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from restruct.configs import SETTINGS
21
+ from restruct.errors import (
22
+ ExtractionFailed,
23
+ InputNotFound,
24
+ InvalidDocument,
25
+ ModelAssetsMissing,
26
+ OcrFailed,
27
+ OutputWriteFailed,
28
+ RestructError,
29
+ TesseractMissing,
30
+ UnsupportedFormat,
31
+ )
32
+ from restruct.stages import ALL_STAGES, DEFAULT_DEBUG_STAGES, raw_extraction_reader
33
+
34
+ SUPPORTED_SUFFIXES = (".pdf", ".docx")
35
+
36
+ # One code per failure a caller might handle differently. Grouped by decade so
37
+ # a new member of a family does not disturb the others: 1x input, 2x
38
+ # environment, 3x extraction, 4x output.
39
+ EXIT_OK = 0
40
+ EXIT_UNEXPECTED = 1
41
+ # 2 is argparse's own usage error and is left to it.
42
+ EXIT_INPUT_NOT_FOUND = 10
43
+ EXIT_UNSUPPORTED_FORMAT = 11
44
+ EXIT_INVALID_DOCUMENT = 12
45
+ EXIT_MODEL_ASSETS_MISSING = 20
46
+ EXIT_TESSERACT_MISSING = 21
47
+ EXIT_OCR_FAILED = 22
48
+ EXIT_EXTRACTION_FAILED = 30
49
+ EXIT_OUTPUT_WRITE_FAILED = 40
50
+
51
+ _EXIT_CODES: tuple[tuple[type[RestructError], int], ...] = (
52
+ (InputNotFound, EXIT_INPUT_NOT_FOUND),
53
+ (UnsupportedFormat, EXIT_UNSUPPORTED_FORMAT),
54
+ (InvalidDocument, EXIT_INVALID_DOCUMENT),
55
+ (ModelAssetsMissing, EXIT_MODEL_ASSETS_MISSING),
56
+ (TesseractMissing, EXIT_TESSERACT_MISSING),
57
+ (OcrFailed, EXIT_OCR_FAILED),
58
+ (ExtractionFailed, EXIT_EXTRACTION_FAILED),
59
+ (OutputWriteFailed, EXIT_OUTPUT_WRITE_FAILED),
60
+ )
61
+
62
+ _STAGE_RANGE_RE = re.compile(r"^(?P<first>[1-5])(?:-(?P<last>[1-5]))?$")
63
+
64
+
65
+ def parse_stages(value: str) -> frozenset[int]:
66
+ """Read ``1-5``, ``3``, ``2,4,5`` or ``1-3,5`` into a set of stages."""
67
+ stages: set[int] = set()
68
+ for part in value.split(","):
69
+ match = _STAGE_RANGE_RE.match(part.strip())
70
+ if match is None:
71
+ raise argparse.ArgumentTypeError(
72
+ f"invalid stage selection {part.strip()!r}: "
73
+ "use a stage (3), a range (1-3), or a list (2,4,5)"
74
+ )
75
+ first = int(match.group("first"))
76
+ last = int(match.group("last") or first)
77
+ if last < first:
78
+ raise argparse.ArgumentTypeError(
79
+ f"invalid stage range {part.strip()!r}: {last} is before {first}"
80
+ )
81
+ stages.update(range(first, last + 1))
82
+ return frozenset(stages)
83
+
84
+
85
+ def _parse_arguments(argv: list[str] | None = None) -> argparse.Namespace:
86
+ parser = argparse.ArgumentParser(
87
+ prog="restruct",
88
+ description="Extract a structured resume from a PDF.",
89
+ epilog=(
90
+ "With no PATH, every PDF in resumes-synthetic/ is extracted into "
91
+ "results/ with all stages written, which is how the committed "
92
+ "corpus is regenerated."
93
+ ),
94
+ )
95
+ parser.add_argument(
96
+ "path",
97
+ nargs="?",
98
+ type=Path,
99
+ metavar="PATH",
100
+ help="The resume to extract. Omit to run the batch over a directory.",
101
+ )
102
+ parser.add_argument(
103
+ "-o",
104
+ "--output",
105
+ metavar="FILE",
106
+ help=(
107
+ "Where to write the resume JSON. A directory ('-o .', '-o out/') "
108
+ "writes <resume>.json inside it. Debug artifacts, if any, go in a "
109
+ "directory beside the result: '-o out.json' writes out/raw/ and "
110
+ "out/debug/."
111
+ ),
112
+ )
113
+ parser.add_argument(
114
+ "--debug",
115
+ action="store_true",
116
+ help="Write debug artifacts for stages 4 and 5.",
117
+ )
118
+ parser.add_argument(
119
+ "--stages",
120
+ type=parse_stages,
121
+ metavar="SPEC",
122
+ help=(
123
+ "Which stages' debug artifacts to write: 1-5, 3, 2,4,5, 1-3,5. "
124
+ "Implies --debug. Selects artifacts only -- every pass always "
125
+ "runs, because each one feeds the next."
126
+ ),
127
+ )
128
+ parser.add_argument(
129
+ "--reconstruct",
130
+ action="store_true",
131
+ help=(
132
+ "Also draw the result back out as a readable page, for "
133
+ "proof-reading by eye: reconstruction.pdf and one PNG per page. "
134
+ "Given a resume.json as PATH, draws that and runs nothing else."
135
+ ),
136
+ )
137
+ parser.add_argument(
138
+ "--truths",
139
+ action="store_true",
140
+ help="Batch over resumes-truths/ into results/0-truths/.",
141
+ )
142
+ parser.add_argument(
143
+ "--unsupported",
144
+ action="store_true",
145
+ help=(
146
+ "Batch over resumes-unsupported/ into results/1-unsupported/. "
147
+ "Those parses are untrustworthy by definition; this is for reading "
148
+ "the overlays that show why."
149
+ ),
150
+ )
151
+ arguments = parser.parse_args(argv)
152
+ if (
153
+ arguments.path is not None
154
+ and arguments.output is None
155
+ and not _is_reconstruction_source(arguments)
156
+ ):
157
+ parser.error("-o/--output is required when a PATH is given")
158
+ return arguments
159
+
160
+
161
+ def _is_reconstruction_source(arguments: argparse.Namespace) -> bool:
162
+ """Whether PATH is an already-extracted resume to draw rather than read.
163
+
164
+ Drawing needs no models and no source document, so a result from last week
165
+ can be looked at without re-running anything -- and `-o` names nothing,
166
+ because nothing new is extracted.
167
+ """
168
+ return bool(arguments.reconstruct) and Path(
169
+ arguments.path
170
+ ).suffix.casefold() == ".json"
171
+
172
+
173
+ def _selected_stages(arguments: argparse.Namespace) -> frozenset[int]:
174
+ """--stages implies --debug; --debug alone means stages 4 and 5."""
175
+ if arguments.stages is not None:
176
+ return arguments.stages
177
+ return DEFAULT_DEBUG_STAGES if arguments.debug else frozenset()
178
+
179
+
180
+ def resolve_output_path(output: str, source: Path) -> Path:
181
+ """Let ``-o`` name a directory and fill in the file name from the input.
182
+
183
+ ``-o .`` and ``-o out/`` write ``<resume>.json`` into that directory. The
184
+ name comes from the input rather than being a fixed "output.json", so that
185
+ extracting several resumes into one directory does not have each silently
186
+ overwrite the last.
187
+
188
+ Takes the raw argument rather than a Path: ``Path("out/")`` normalises the
189
+ trailing separator away, and that separator is exactly how a caller says
190
+ "this is a directory" about one that does not exist yet.
191
+
192
+ Without a trailing separator the path is a file, even with no suffix,
193
+ because guessing otherwise would make ``-o report`` create a directory
194
+ nobody asked for.
195
+ """
196
+ path = Path(output)
197
+ names_a_directory = (
198
+ path.is_dir()
199
+ or output in {".", ".."}
200
+ or output.endswith(("/", os.sep))
201
+ )
202
+ return path / f"{source.stem}.json" if names_a_directory else path
203
+
204
+
205
+ def _artifact_directory(output_path: Path) -> Path:
206
+ """Where debug artifacts go: a directory beside the result, named for it.
207
+
208
+ ``-o out.json`` gives ``out/``. A suffix-less ``-o out`` would give the
209
+ same path as the result itself, so that one case is disambiguated rather
210
+ than left to fail as a write to a directory.
211
+ """
212
+ candidate = output_path.with_suffix("")
213
+ if candidate == output_path:
214
+ return output_path.parent / f"{output_path.name}-artifacts"
215
+ return candidate
216
+
217
+
218
+ def _validate(path: Path) -> None:
219
+ """Check what can be checked before loading several hundred MB of models."""
220
+ if not path.exists():
221
+ raise InputNotFound(path)
222
+ if path.suffix.casefold() not in SUPPORTED_SUFFIXES:
223
+ raise UnsupportedFormat(path, SUPPORTED_SUFFIXES)
224
+ if path.suffix.casefold() == ".docx":
225
+ # A DOCX has no page count to check and no password to fail on; the
226
+ # reader raises InvalidDocument itself if the zip is not one.
227
+ from restruct.ingestion.docx import read_docx
228
+
229
+ read_docx(path)
230
+ return
231
+
232
+ import pymupdf
233
+
234
+ try:
235
+ with pymupdf.open(path) as document:
236
+ if document.needs_pass:
237
+ raise InvalidDocument(path, "the document is password-protected")
238
+ if document.page_count == 0:
239
+ raise InvalidDocument(path, "the document has no pages")
240
+ except InvalidDocument:
241
+ raise
242
+ except Exception as error: # pymupdf raises several unrelated types
243
+ raise InvalidDocument(path, str(error)) from error
244
+
245
+
246
+ def _silence_model_progress() -> None:
247
+ """Stop the model libraries printing to the terminal.
248
+
249
+ Loading weights draws a progress bar, which makes a successful run noisy
250
+ and unusable in a pipe. This is presentation, so it lives here rather than
251
+ in the engine, where a library caller may well want it left alone.
252
+ """
253
+ import os
254
+
255
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
256
+ os.environ.setdefault("TRANSFORMERS_NO_ADVISORY_WARNINGS", "1")
257
+ try:
258
+ from transformers.utils import logging as transformers_logging
259
+
260
+ transformers_logging.disable_progress_bar()
261
+ transformers_logging.set_verbosity_error()
262
+ except Exception: # a version without the helper must not break the run
263
+ pass
264
+
265
+
266
+ MODELS_DIRECTORY_VARIABLE = "RESTRUCT_MODELS_DIRECTORY"
267
+
268
+
269
+ def _candidate_model_directories(project_root: Path) -> list[Path]:
270
+ """Where to look for the weights, in the order they are preferred.
271
+
272
+ A checkout keeps them in `models/` beside the source, which is why that
273
+ used to be the only answer. It is wrong for an installed copy: the same
274
+ expression resolves to `site-packages/..`, a directory nobody has ever put
275
+ model weights in, so a pip-installed `restruct` could report missing
276
+ weights on a machine that had them. Each candidate is somewhere a person
277
+ would actually keep them, and the environment variable exists because an
278
+ installed copy has no checkout to fall back on.
279
+ """
280
+ override = os.environ.get(MODELS_DIRECTORY_VARIABLE)
281
+ if override:
282
+ # An explicit answer settles the question; nothing else is consulted.
283
+ return [Path(override).expanduser()]
284
+ candidates = []
285
+ # Only when it is a checkout. In an installed copy the same expression is
286
+ # `site-packages/..`, and listing it in the error would send the reader to
287
+ # put weights somewhere no one should.
288
+ if (project_root / "pyproject.toml").is_file():
289
+ candidates.append(project_root / "models")
290
+ candidates.append(Path.cwd() / "models")
291
+ candidates.append(Path.home() / ".restruct" / "models")
292
+ return candidates
293
+
294
+
295
+ def _models_directory(project_root: Path) -> Path:
296
+ """The first candidate holding both models, or the first for the error."""
297
+ candidates = _candidate_model_directories(project_root)
298
+ for candidate in candidates:
299
+ if all(
300
+ (candidate / name).is_dir() and any((candidate / name).iterdir())
301
+ for name in MODEL_DIRECTORY_NAMES
302
+ ):
303
+ return candidate
304
+ return candidates[0]
305
+
306
+
307
+ MODEL_DIRECTORY_NAMES = ("all-MiniLM-L6-v2", "distilbert-NER")
308
+
309
+
310
+ def _load_models(project_root: Path):
311
+ """Check the weights are present, then hand back loaders, not models.
312
+
313
+ The presence check stays eager so a missing-weights run still exits with
314
+ its own code straight away rather than part way through a document. The
315
+ reading of several hundred megabytes is what waits until something needs
316
+ it.
317
+ """
318
+ from restruct.model import LazyEmbeddingModel, LazyNerPredictor
319
+
320
+ _silence_model_progress()
321
+ models_directory = _models_directory(project_root)
322
+ for name in MODEL_DIRECTORY_NAMES:
323
+ directory = models_directory / name
324
+ if not directory.is_dir() or not any(directory.iterdir()):
325
+ raise ModelAssetsMissing(
326
+ directory,
327
+ searched=_candidate_model_directories(project_root),
328
+ )
329
+ return (
330
+ LazyEmbeddingModel(models_directory),
331
+ LazyNerPredictor(models_directory),
332
+ )
333
+
334
+
335
+ def _extract_one(
336
+ pdf_path: Path,
337
+ output_path: Path,
338
+ stages: frozenset[int],
339
+ models,
340
+ *,
341
+ reconstruct: bool = False,
342
+ ) -> None:
343
+ """Extract one resume to an explicit output file."""
344
+ from restruct.pipeline import extract_resume
345
+
346
+ artifact_directory = _artifact_directory(output_path)
347
+ try:
348
+ extract_resume(
349
+ pdf_path,
350
+ artifact_directory,
351
+ artifact_directory / "raw" / f"{raw_extraction_reader(pdf_path)}.json",
352
+ artifact_directory / "raw" / "tesseract.json",
353
+ models[0],
354
+ models[1],
355
+ stages=stages,
356
+ )
357
+ except RestructError:
358
+ raise
359
+ except Exception as error:
360
+ raise ExtractionFailed(pdf_path, error) from error
361
+
362
+ # -o names the result; the artifact directory holds artifacts. Leaving a
363
+ # second copy of resume.json inside it would give two files that can drift.
364
+ produced = artifact_directory / "resume.json"
365
+ try:
366
+ output_path.parent.mkdir(parents=True, exist_ok=True)
367
+ output_path.write_text(produced.read_text(encoding="utf-8"), encoding="utf-8")
368
+ produced.unlink(missing_ok=True)
369
+ if artifact_directory.is_dir() and not any(artifact_directory.iterdir()):
370
+ artifact_directory.rmdir()
371
+ except OSError as error:
372
+ raise OutputWriteFailed(output_path, str(error)) from error
373
+
374
+ if reconstruct:
375
+ _reconstruct(output_path, artifact_directory / "reconstruction")
376
+
377
+
378
+ def _reconstruction_output(
379
+ arguments: argparse.Namespace,
380
+ resume_path: Path,
381
+ ) -> tuple[Path, str]:
382
+ """Where a standalone reconstruction is written, and under what names.
383
+
384
+ ``-o`` names a directory here rather than a file, because the run produces
385
+ a page and not a result, and the drawing is written into it under the plain
386
+ names.
387
+
388
+ Without ``-o`` the drawing lands flat beside the JSON it was drawn from,
389
+ named after it: a directory holding two files is a directory to open, and
390
+ the one thing anybody does next is look at the page. The stem is what keeps
391
+ two resumes drawn into the same place from overwriting each other, which is
392
+ the only thing the directory was buying.
393
+ """
394
+ if arguments.output:
395
+ return Path(arguments.output), ""
396
+ return resume_path.parent, f"{resume_path.stem}-"
397
+
398
+
399
+ def _reconstruct(
400
+ resume_path: Path,
401
+ output_directory: Path,
402
+ prefix: str = "",
403
+ ) -> None:
404
+ """Draw one written resume.json, reporting a failure as an output failure.
405
+
406
+ Reads the file rather than taking the dictionary the pipeline just built,
407
+ which is what keeps this a check on what was actually published.
408
+ """
409
+ from restruct.debug.reconstruct import render_resume_file
410
+
411
+ try:
412
+ render_resume_file(resume_path, output_directory, prefix=prefix)
413
+ except OSError as error:
414
+ raise OutputWriteFailed(output_directory, str(error)) from error
415
+
416
+
417
+ def _batch(
418
+ input_directory: Path,
419
+ output_root: Path,
420
+ models,
421
+ stages,
422
+ *,
423
+ reconstruct: bool = False,
424
+ ) -> None:
425
+ """Regenerate a whole corpus. Writes every stage, which is its purpose."""
426
+ from restruct.pipeline import extract_resume
427
+
428
+ project_root = Path(__file__).resolve().parents[2]
429
+ raw_debug_directory = project_root / SETTINGS.debug.raw_extraction_directory
430
+ ocr_debug_directory = project_root / SETTINGS.debug.ocr_extraction_directory
431
+ local = output_root != project_root / SETTINGS.paths.results_directory
432
+
433
+ sources = sorted(
434
+ path
435
+ for suffix in SUPPORTED_SUFFIXES
436
+ for path in input_directory.glob(f"*{suffix}")
437
+ )
438
+ for pdf_path in sources:
439
+ resume_output = output_root / pdf_path.stem
440
+ extract_resume(
441
+ pdf_path,
442
+ resume_output,
443
+ (
444
+ resume_output / f"raw-{raw_extraction_reader(pdf_path)}.json"
445
+ if local
446
+ else raw_debug_directory
447
+ / f"{pdf_path.stem}.raw-{raw_extraction_reader(pdf_path)}.json"
448
+ ),
449
+ (
450
+ resume_output / "debug" / "ocr" / "raw-tesseract.json"
451
+ if local
452
+ else ocr_debug_directory / f"{pdf_path.stem}.ocr-tesseract.json"
453
+ ),
454
+ models[0],
455
+ models[1],
456
+ stages=stages,
457
+ )
458
+ if reconstruct:
459
+ _reconstruct(
460
+ resume_output / "resume.json", resume_output / "reconstruction"
461
+ )
462
+ print(f"extracted: {pdf_path.name}")
463
+
464
+
465
+ def main(argv: list[str] | None = None) -> int:
466
+ arguments = _parse_arguments(argv)
467
+ project_root = Path(__file__).resolve().parents[2]
468
+ stages = _selected_stages(arguments)
469
+
470
+ try:
471
+ if _is_reconstruction_source(arguments):
472
+ # Nothing is extracted, so no models are read and no source
473
+ # document is opened: this draws a result that already exists.
474
+ resume_path = Path(arguments.path)
475
+ if not resume_path.exists():
476
+ raise InputNotFound(resume_path)
477
+ _reconstruct(resume_path, *_reconstruction_output(arguments, resume_path))
478
+ return EXIT_OK
479
+
480
+ if arguments.path is not None:
481
+ _validate(arguments.path)
482
+ output_path = resolve_output_path(arguments.output, arguments.path)
483
+ models = _load_models(project_root)
484
+ _extract_one(
485
+ arguments.path,
486
+ output_path,
487
+ stages,
488
+ models,
489
+ reconstruct=arguments.reconstruct,
490
+ )
491
+ return EXIT_OK
492
+
493
+ if arguments.truths:
494
+ input_directory = project_root / SETTINGS.paths.truths_input_directory
495
+ output_root = project_root / SETTINGS.paths.truths_results_directory
496
+ elif arguments.unsupported:
497
+ input_directory = project_root / SETTINGS.paths.unsupported_input_directory
498
+ output_root = project_root / SETTINGS.paths.unsupported_results_directory
499
+ else:
500
+ input_directory = project_root / SETTINGS.paths.input_directory
501
+ output_root = project_root / SETTINGS.paths.results_directory
502
+ models = _load_models(project_root)
503
+ # The batch exists to regenerate the committed corpus, so it writes
504
+ # everything unless told otherwise. Anything less and a stale artifact
505
+ # would survive a run and make `git status results/` read as clean.
506
+ _batch(
507
+ input_directory,
508
+ output_root,
509
+ models,
510
+ arguments.stages if arguments.stages is not None else ALL_STAGES,
511
+ reconstruct=arguments.reconstruct,
512
+ )
513
+ return EXIT_OK
514
+ except RestructError as error:
515
+ print(f"restruct: {error}", file=sys.stderr)
516
+ for error_type, code in _EXIT_CODES:
517
+ if isinstance(error, error_type):
518
+ return code
519
+ return EXIT_UNEXPECTED
520
+
521
+
@@ -0,0 +1,5 @@
1
+ """Public configuration for the extractor experiment."""
2
+
3
+ from restruct.configs.settings import SETTINGS, ExtractorSettings
4
+
5
+ __all__ = ["SETTINGS", "ExtractorSettings"]