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.
- restruct/__init__.py +56 -0
- restruct/cli.py +521 -0
- restruct/configs/__init__.py +5 -0
- restruct/configs/embedding_references.py +290 -0
- restruct/configs/settings.py +175 -0
- restruct/debug/__init__.py +1 -0
- restruct/debug/artifacts.py +134 -0
- restruct/debug/canvas.py +141 -0
- restruct/debug/colors.py +172 -0
- restruct/debug/reconstruct.py +663 -0
- restruct/debug/render.py +323 -0
- restruct/debug/stages.py +294 -0
- restruct/document/__init__.py +17 -0
- restruct/document/physical.py +293 -0
- restruct/document/stats.py +532 -0
- restruct/document/types.py +121 -0
- restruct/errors.py +128 -0
- restruct/geometry.py +151 -0
- restruct/ingestion/__init__.py +1 -0
- restruct/ingestion/docx.py +558 -0
- restruct/ingestion/native.py +256 -0
- restruct/ingestion/ocr.py +248 -0
- restruct/layout/__init__.py +1 -0
- restruct/layout/blocks.py +154 -0
- restruct/layout/lines.py +75 -0
- restruct/layout/rows.py +95 -0
- restruct/layout/unsupported.py +237 -0
- restruct/layout/words.py +229 -0
- restruct/model.py +674 -0
- restruct/parsers/__init__.py +1 -0
- restruct/parsers/education.py +340 -0
- restruct/parsers/experience.py +474 -0
- restruct/parsers/grouped.py +580 -0
- restruct/parsers/header.py +513 -0
- restruct/parsers/skills.py +266 -0
- restruct/parsers/urls.py +194 -0
- restruct/patterns/__init__.py +1 -0
- restruct/patterns/bullets.py +14 -0
- restruct/patterns/contacts.py +23 -0
- restruct/patterns/dates.py +59 -0
- restruct/patterns/education.py +32 -0
- restruct/patterns/languages.py +81 -0
- restruct/patterns/layout.py +31 -0
- restruct/patterns/organizations.py +19 -0
- restruct/patterns/personal.py +69 -0
- restruct/patterns/separators.py +47 -0
- restruct/pipeline.py +247 -0
- restruct/schema.py +257 -0
- restruct/stages.py +28 -0
- restruct/structure/__init__.py +1 -0
- restruct/structure/compound.py +375 -0
- restruct/structure/headings.py +195 -0
- restruct/structure/keyvalue.py +96 -0
- restruct/structure/metadata.py +66 -0
- restruct/structure/resolver.py +218 -0
- restruct/structure/sections.py +189 -0
- restruct/structure/separators.py +201 -0
- restruct_cv-0.1.0.dist-info/METADATA +226 -0
- restruct_cv-0.1.0.dist-info/RECORD +62 -0
- restruct_cv-0.1.0.dist-info/WHEEL +4 -0
- restruct_cv-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
+
|