docgun 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.
- docgun/__init__.py +8 -0
- docgun/agents/__init__.py +1 -0
- docgun/agents/confidence.py +59 -0
- docgun/agents/enrichment.py +23 -0
- docgun/agents/inspection.py +123 -0
- docgun/agents/quality.py +81 -0
- docgun/agents/recovery.py +206 -0
- docgun/agents/router.py +32 -0
- docgun/agents/vision.py +79 -0
- docgun/api/__init__.py +1 -0
- docgun/api/routes.py +66 -0
- docgun/chunking/__init__.py +1 -0
- docgun/chunking/hierarchical.py +59 -0
- docgun/chunking/spreadsheet.py +11 -0
- docgun/cli.py +39 -0
- docgun/domain/__init__.py +1 -0
- docgun/domain/errors.py +34 -0
- docgun/domain/models.py +72 -0
- docgun/parsers/__init__.py +1 -0
- docgun/parsers/base.py +14 -0
- docgun/parsers/csv.py +57 -0
- docgun/parsers/docx.py +109 -0
- docgun/parsers/excel.py +87 -0
- docgun/parsers/pdf_docling.py +93 -0
- docgun/parsers/pdf_fast.py +101 -0
- docgun/pipeline/__init__.py +1 -0
- docgun/pipeline/graph.py +64 -0
- docgun/pipeline/policies.py +1 -0
- docgun/pipeline/state.py +25 -0
- docgun/py.typed +1 -0
- docgun/security/__init__.py +1 -0
- docgun/security/limits.py +11 -0
- docgun/security/validation.py +81 -0
- docgun/storage/__init__.py +1 -0
- docgun/storage/documents.py +12 -0
- docgun/storage/vectors.py +10 -0
- docgun-0.1.0.dist-info/METADATA +292 -0
- docgun-0.1.0.dist-info/RECORD +41 -0
- docgun-0.1.0.dist-info/WHEEL +4 -0
- docgun-0.1.0.dist-info/entry_points.txt +2 -0
- docgun-0.1.0.dist-info/licenses/LICENSE +21 -0
docgun/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent-supervised deterministic pipeline helpers."""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from docgun.domain.models import BoundingBox
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
TextKind = Literal["heading", "paragraph", "list_item", "caption", "cell", "unknown"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def text_confidence(
|
|
12
|
+
text: str | None,
|
|
13
|
+
location: BoundingBox | None = None,
|
|
14
|
+
element_type: TextKind = "unknown",
|
|
15
|
+
) -> float:
|
|
16
|
+
if not text:
|
|
17
|
+
return 0.0
|
|
18
|
+
|
|
19
|
+
printable_ratio = sum(char.isprintable() or char.isspace() for char in text) / len(text)
|
|
20
|
+
replacement_penalty = text.count("\ufffd") / len(text)
|
|
21
|
+
expected_length = {
|
|
22
|
+
"heading": 12,
|
|
23
|
+
"list_item": 20,
|
|
24
|
+
"caption": 20,
|
|
25
|
+
"cell": 6,
|
|
26
|
+
"paragraph": 80,
|
|
27
|
+
"unknown": 40,
|
|
28
|
+
}[element_type]
|
|
29
|
+
length_score = min(len(text.strip()) / expected_length, 1.0)
|
|
30
|
+
bbox_score = 1.0 if _valid_bbox(location) else 0.75
|
|
31
|
+
score = (0.55 * printable_ratio) + (0.20 * length_score) + (0.25 * bbox_score)
|
|
32
|
+
return round(max(0.0, min(1.0, score - replacement_penalty)), 4)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def table_confidence(table: list[list[object]] | None) -> float:
|
|
36
|
+
if not table:
|
|
37
|
+
return 0.0
|
|
38
|
+
widths = [len(row) for row in table]
|
|
39
|
+
if not widths:
|
|
40
|
+
return 0.0
|
|
41
|
+
rectangular = 1.0 if len(set(widths)) == 1 else 0.4
|
|
42
|
+
non_empty = sum(1 for row in table for cell in row if cell not in (None, ""))
|
|
43
|
+
total = sum(widths) or 1
|
|
44
|
+
density = non_empty / total
|
|
45
|
+
header = table[0] if table else []
|
|
46
|
+
header_quality = sum(1 for cell in header if cell not in (None, "")) / (len(header) or 1)
|
|
47
|
+
return round(
|
|
48
|
+
max(0.0, min(1.0, (0.35 * rectangular) + (0.35 * density) + (0.30 * header_quality))),
|
|
49
|
+
4,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _valid_bbox(location: BoundingBox | None) -> bool:
|
|
54
|
+
if location is None:
|
|
55
|
+
return False
|
|
56
|
+
values = (location.x0, location.y0, location.x1, location.y1)
|
|
57
|
+
if any(value is None for value in values):
|
|
58
|
+
return False
|
|
59
|
+
return bool(location.x1 > location.x0 and location.y1 > location.y0)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from docgun.domain.models import IngestedDocument
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def enrich_metadata(document: IngestedDocument) -> dict[str, object]:
|
|
7
|
+
text = "\n".join(element.text or "" for element in document.elements)
|
|
8
|
+
return {
|
|
9
|
+
"document_type": _guess_document_type(text),
|
|
10
|
+
"character_count": len(text),
|
|
11
|
+
"element_count": len(document.elements),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _guess_document_type(text: str) -> str:
|
|
16
|
+
lowered = text.lower()
|
|
17
|
+
if "invoice" in lowered:
|
|
18
|
+
return "invoice"
|
|
19
|
+
if "contract" in lowered or "agreement" in lowered:
|
|
20
|
+
return "contract"
|
|
21
|
+
if "statement" in lowered:
|
|
22
|
+
return "statement"
|
|
23
|
+
return "unknown"
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import mimetypes
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from docgun.domain.errors import (
|
|
8
|
+
OptionalDependencyError,
|
|
9
|
+
PDFPageLimitExceeded,
|
|
10
|
+
WorkbookSheetLimitExceeded,
|
|
11
|
+
WorksheetCellLimitExceeded,
|
|
12
|
+
)
|
|
13
|
+
from docgun.domain.models import FileInspection, PageInspection
|
|
14
|
+
from docgun.security.limits import IngestionLimits
|
|
15
|
+
from docgun.security.validation import validate_file_signature, validate_file_size
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
|
|
19
|
+
digest = hashlib.sha256()
|
|
20
|
+
with path.open("rb") as handle:
|
|
21
|
+
for chunk in iter(lambda: handle.read(chunk_size), b""):
|
|
22
|
+
digest.update(chunk)
|
|
23
|
+
return digest.hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def inspect_file(
|
|
27
|
+
path: Path,
|
|
28
|
+
limits: IngestionLimits | None = None,
|
|
29
|
+
source_name: str | None = None,
|
|
30
|
+
) -> FileInspection:
|
|
31
|
+
limits = limits or IngestionLimits()
|
|
32
|
+
validate_file_size(path, limits)
|
|
33
|
+
validate_file_signature(path)
|
|
34
|
+
|
|
35
|
+
suffix = path.suffix.lower()
|
|
36
|
+
mime_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
37
|
+
warnings: list[str] = []
|
|
38
|
+
page_count: int | None = None
|
|
39
|
+
sheet_count: int | None = None
|
|
40
|
+
page_inspections: tuple[PageInspection, ...] = ()
|
|
41
|
+
likely_scanned = False
|
|
42
|
+
encrypted = False
|
|
43
|
+
|
|
44
|
+
if suffix == ".pdf":
|
|
45
|
+
pdf_info, encrypted = inspect_pdf(path)
|
|
46
|
+
page_inspections = tuple(pdf_info)
|
|
47
|
+
page_count = len(page_inspections)
|
|
48
|
+
if page_count > limits.max_pdf_pages:
|
|
49
|
+
raise PDFPageLimitExceeded(
|
|
50
|
+
f"{path.name} has {page_count} pages, exceeding limit {limits.max_pdf_pages}"
|
|
51
|
+
)
|
|
52
|
+
likely_scanned = bool(page_inspections) and all(
|
|
53
|
+
page.needs_vision for page in page_inspections
|
|
54
|
+
)
|
|
55
|
+
elif suffix in {".xlsx", ".xlsm"}:
|
|
56
|
+
sheet_count = inspect_workbook(path, limits)
|
|
57
|
+
|
|
58
|
+
return FileInspection(
|
|
59
|
+
mime_type=mime_type,
|
|
60
|
+
extension=suffix,
|
|
61
|
+
size_bytes=path.stat().st_size,
|
|
62
|
+
sha256=sha256_file(path),
|
|
63
|
+
source_name=source_name or path.name,
|
|
64
|
+
page_count=page_count,
|
|
65
|
+
sheet_count=sheet_count,
|
|
66
|
+
page_inspections=page_inspections,
|
|
67
|
+
likely_scanned=likely_scanned,
|
|
68
|
+
encrypted=encrypted,
|
|
69
|
+
warnings=tuple(warnings),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def inspect_pdf(path: Path) -> tuple[list[PageInspection], bool]:
|
|
74
|
+
try:
|
|
75
|
+
import pymupdf
|
|
76
|
+
except ImportError as exc:
|
|
77
|
+
raise OptionalDependencyError("PyMuPDF is required for PDF inspection") from exc
|
|
78
|
+
|
|
79
|
+
results: list[PageInspection] = []
|
|
80
|
+
with pymupdf.open(path) as document:
|
|
81
|
+
encrypted = bool(getattr(document, "is_encrypted", False) or getattr(document, "needs_pass", False))
|
|
82
|
+
if encrypted:
|
|
83
|
+
return results, True
|
|
84
|
+
for index, page in enumerate(document):
|
|
85
|
+
text = page.get_text("text").strip()
|
|
86
|
+
words = page.get_text("words")
|
|
87
|
+
images = page.get_images(full=True)
|
|
88
|
+
usable_chars = sum(char.isprintable() for char in text)
|
|
89
|
+
needs_vision = usable_chars < 80 and len(words) < 15 and len(images) > 0
|
|
90
|
+
results.append(
|
|
91
|
+
PageInspection(
|
|
92
|
+
page_number=index + 1,
|
|
93
|
+
character_count=usable_chars,
|
|
94
|
+
word_count=len(words),
|
|
95
|
+
image_count=len(images),
|
|
96
|
+
needs_vision=needs_vision,
|
|
97
|
+
)
|
|
98
|
+
)
|
|
99
|
+
return results, False
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def inspect_workbook(path: Path, limits: IngestionLimits) -> int:
|
|
103
|
+
try:
|
|
104
|
+
from openpyxl import load_workbook
|
|
105
|
+
except ImportError as exc:
|
|
106
|
+
raise OptionalDependencyError("openpyxl is required for workbook inspection") from exc
|
|
107
|
+
|
|
108
|
+
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
109
|
+
try:
|
|
110
|
+
sheet_count = len(workbook.sheetnames)
|
|
111
|
+
if sheet_count > limits.max_workbook_sheets:
|
|
112
|
+
raise WorkbookSheetLimitExceeded(
|
|
113
|
+
f"{path.name} has {sheet_count} sheets, exceeding limit {limits.max_workbook_sheets}"
|
|
114
|
+
)
|
|
115
|
+
for sheet in workbook.worksheets:
|
|
116
|
+
cells = (sheet.max_row or 0) * (sheet.max_column or 0)
|
|
117
|
+
if cells > limits.max_sheet_cells:
|
|
118
|
+
raise WorksheetCellLimitExceeded(
|
|
119
|
+
f"{sheet.title} has {cells} cells, exceeding limit {limits.max_sheet_cells}"
|
|
120
|
+
)
|
|
121
|
+
return sheet_count
|
|
122
|
+
finally:
|
|
123
|
+
workbook.close()
|
docgun/agents/quality.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from docgun.domain.models import DocumentElement, IngestedDocument
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class QualityReport:
|
|
10
|
+
confidence: float
|
|
11
|
+
text_coverage: float
|
|
12
|
+
printable_ratio: float
|
|
13
|
+
structure_score: float
|
|
14
|
+
table_score: float
|
|
15
|
+
metadata_score: float
|
|
16
|
+
low_confidence_element_ids: tuple[str, ...]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def score_document(document: IngestedDocument, threshold: float = 0.70) -> QualityReport:
|
|
20
|
+
if not document.elements:
|
|
21
|
+
return QualityReport(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, ())
|
|
22
|
+
|
|
23
|
+
text_coverage = _text_coverage(document.elements)
|
|
24
|
+
printable_ratio = _printable_ratio(document.elements)
|
|
25
|
+
structure_score = _structure_score(document.elements)
|
|
26
|
+
table_score = _table_score(document.elements)
|
|
27
|
+
metadata_score = 1.0 if document.metadata else 0.5
|
|
28
|
+
element_confidence = sum(element.confidence for element in document.elements) / len(
|
|
29
|
+
document.elements
|
|
30
|
+
)
|
|
31
|
+
confidence = (
|
|
32
|
+
0.30 * text_coverage
|
|
33
|
+
+ 0.15 * printable_ratio
|
|
34
|
+
+ 0.15 * structure_score
|
|
35
|
+
+ 0.15 * table_score
|
|
36
|
+
+ 0.15 * element_confidence
|
|
37
|
+
+ 0.10 * metadata_score
|
|
38
|
+
)
|
|
39
|
+
low_confidence = tuple(
|
|
40
|
+
element.element_id for element in document.elements if element.confidence < threshold
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
return QualityReport(
|
|
44
|
+
confidence=round(confidence, 4),
|
|
45
|
+
text_coverage=text_coverage,
|
|
46
|
+
printable_ratio=printable_ratio,
|
|
47
|
+
structure_score=structure_score,
|
|
48
|
+
table_score=table_score,
|
|
49
|
+
metadata_score=metadata_score,
|
|
50
|
+
low_confidence_element_ids=low_confidence,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _text_coverage(elements: list[DocumentElement]) -> float:
|
|
55
|
+
with_text = sum(1 for element in elements if element.text or element.table)
|
|
56
|
+
return with_text / len(elements)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _printable_ratio(elements: list[DocumentElement]) -> float:
|
|
60
|
+
text = "\n".join(element.text or "" for element in elements)
|
|
61
|
+
if not text:
|
|
62
|
+
return 0.0
|
|
63
|
+
return sum(char.isprintable() or char.isspace() for char in text) / len(text)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _structure_score(elements: list[DocumentElement]) -> float:
|
|
67
|
+
structured = sum(1 for element in elements if element.hierarchy or element.location)
|
|
68
|
+
return max(0.5, structured / len(elements))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _table_score(elements: list[DocumentElement]) -> float:
|
|
72
|
+
tables = [element.table for element in elements if element.table]
|
|
73
|
+
if not tables:
|
|
74
|
+
return 1.0
|
|
75
|
+
|
|
76
|
+
rectangular = 0
|
|
77
|
+
for table in tables:
|
|
78
|
+
widths = {len(row) for row in table}
|
|
79
|
+
if len(widths) == 1:
|
|
80
|
+
rectangular += 1
|
|
81
|
+
return rectangular / len(tables)
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from docgun.agents.quality import QualityReport, score_document
|
|
8
|
+
from docgun.domain.models import DocumentElement, FileInspection, IngestedDocument
|
|
9
|
+
from docgun.parsers.pdf_docling import DoclingParser
|
|
10
|
+
from docgun.agents.vision import VisionPageRecovery
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class RecoveryAction:
|
|
15
|
+
name: str
|
|
16
|
+
reason: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def choose_recovery_actions(parser: str, confidence: float) -> tuple[RecoveryAction, ...]:
|
|
20
|
+
if confidence >= 0.70:
|
|
21
|
+
return ()
|
|
22
|
+
if parser == "pymupdf":
|
|
23
|
+
return (
|
|
24
|
+
RecoveryAction("pymupdf-blocks", "Retry native extraction with block coordinates"),
|
|
25
|
+
RecoveryAction("docling", "Use layout-aware structured conversion"),
|
|
26
|
+
RecoveryAction("vision-page", "Render only failed pages for VLM fallback"),
|
|
27
|
+
)
|
|
28
|
+
return (RecoveryAction("alternate-parser", "Retry with the next deterministic parser"),)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class RecoveryResult:
|
|
33
|
+
document: IngestedDocument
|
|
34
|
+
quality: QualityReport
|
|
35
|
+
executed_actions: tuple[str, ...]
|
|
36
|
+
attempts: tuple[RecoveryAttempt, ...]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True)
|
|
40
|
+
class RecoveryAttempt:
|
|
41
|
+
action: str
|
|
42
|
+
status: Literal["succeeded", "failed", "skipped", "not_available"]
|
|
43
|
+
confidence_before: float
|
|
44
|
+
confidence_after: float | None = None
|
|
45
|
+
error_type: str | None = None
|
|
46
|
+
error_message: str | None = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class RecoveryEngine:
|
|
50
|
+
def __init__(self, vision_recovery: VisionPageRecovery | None = None):
|
|
51
|
+
self.vision_recovery = vision_recovery
|
|
52
|
+
|
|
53
|
+
def recover(
|
|
54
|
+
self,
|
|
55
|
+
path: Path,
|
|
56
|
+
inspection: FileInspection,
|
|
57
|
+
original: IngestedDocument,
|
|
58
|
+
quality: QualityReport,
|
|
59
|
+
parser: str,
|
|
60
|
+
threshold: float,
|
|
61
|
+
) -> RecoveryResult:
|
|
62
|
+
actions = choose_recovery_actions(parser, quality.confidence)
|
|
63
|
+
attempts: list[RecoveryAttempt] = []
|
|
64
|
+
best = original
|
|
65
|
+
best_quality = quality
|
|
66
|
+
|
|
67
|
+
if parser == "pymupdf":
|
|
68
|
+
docling_result, docling_attempt = self._try_docling(path, inspection, quality.confidence)
|
|
69
|
+
attempts.append(docling_attempt)
|
|
70
|
+
if docling_result is not None:
|
|
71
|
+
docling_quality = score_document(docling_result, threshold=threshold)
|
|
72
|
+
attempts[-1] = RecoveryAttempt(
|
|
73
|
+
action="docling",
|
|
74
|
+
status="succeeded",
|
|
75
|
+
confidence_before=quality.confidence,
|
|
76
|
+
confidence_after=docling_quality.confidence,
|
|
77
|
+
)
|
|
78
|
+
if docling_quality.confidence > best_quality.confidence:
|
|
79
|
+
best = docling_result
|
|
80
|
+
best_quality = docling_quality
|
|
81
|
+
|
|
82
|
+
if best_quality.confidence < threshold or any(
|
|
83
|
+
page.needs_vision for page in inspection.page_inspections
|
|
84
|
+
):
|
|
85
|
+
page_recovery, vision_attempt = self._recover_vision_pages(path, inspection, best, threshold)
|
|
86
|
+
if page_recovery is not best:
|
|
87
|
+
best = page_recovery
|
|
88
|
+
best_quality = score_document(best, threshold=threshold)
|
|
89
|
+
if vision_attempt is not None:
|
|
90
|
+
attempts.append(vision_attempt)
|
|
91
|
+
elif actions:
|
|
92
|
+
attempts.append(
|
|
93
|
+
RecoveryAttempt(
|
|
94
|
+
action=actions[0].name,
|
|
95
|
+
status="skipped",
|
|
96
|
+
confidence_before=quality.confidence,
|
|
97
|
+
error_message="No alternate parser is configured for this document type",
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
executed = tuple(attempt.action for attempt in attempts if attempt.status in {"succeeded", "not_available"})
|
|
102
|
+
return RecoveryResult(best, best_quality, executed, tuple(attempts))
|
|
103
|
+
|
|
104
|
+
def _recover_vision_pages(self, path: Path, inspection: FileInspection, document: IngestedDocument, threshold: float) -> tuple[IngestedDocument, RecoveryAttempt | None]:
|
|
105
|
+
failed = [p.page_number for p in inspection.page_inspections if p.needs_vision and _page_confidence([
|
|
106
|
+
e for e in document.elements if e.location and e.location.page == p.page_number
|
|
107
|
+
]) < threshold]
|
|
108
|
+
if not failed:
|
|
109
|
+
return document, None
|
|
110
|
+
before = score_document(document, threshold=threshold).confidence
|
|
111
|
+
if self.vision_recovery is None:
|
|
112
|
+
marked = self._mark_pages_for_vision(inspection, document)
|
|
113
|
+
return marked, RecoveryAttempt("vision-page", "not_available", before, score_document(marked, threshold=threshold).confidence, error_message="No vision backend is configured; page markers were emitted")
|
|
114
|
+
try:
|
|
115
|
+
recovered = self.vision_recovery.recover(path, failed)
|
|
116
|
+
recovered_pages = {e.location.page for e in recovered if e.location and e.location.page}
|
|
117
|
+
unresolved = set(failed) - recovered_pages
|
|
118
|
+
kept = [e for e in document.elements if not (e.location and e.location.page in recovered_pages)]
|
|
119
|
+
merged = IngestedDocument(document.document_id, document.source_name, document.mime_type, document.sha256, kept + recovered, {**document.metadata, "recovery": "vision-page"}, list(document.warnings))
|
|
120
|
+
if unresolved:
|
|
121
|
+
merged = self._mark_pages_for_vision(inspection, merged)
|
|
122
|
+
after = score_document(merged, threshold=threshold).confidence
|
|
123
|
+
status = "succeeded" if not unresolved else "failed"
|
|
124
|
+
return merged, RecoveryAttempt("vision-page", status, before, after, error_message=f"No usable result for pages {sorted(unresolved)}" if unresolved else None)
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
marked = self._mark_pages_for_vision(inspection, document)
|
|
127
|
+
return marked, RecoveryAttempt("vision-page", "failed", before, score_document(marked, threshold=threshold).confidence, exc.__class__.__name__, str(exc))
|
|
128
|
+
|
|
129
|
+
def _try_docling(
|
|
130
|
+
self,
|
|
131
|
+
path: Path,
|
|
132
|
+
inspection: FileInspection,
|
|
133
|
+
confidence_before: float,
|
|
134
|
+
) -> tuple[IngestedDocument | None, RecoveryAttempt]:
|
|
135
|
+
try:
|
|
136
|
+
return DoclingParser().parse(path, inspection), RecoveryAttempt(
|
|
137
|
+
action="docling",
|
|
138
|
+
status="succeeded",
|
|
139
|
+
confidence_before=confidence_before,
|
|
140
|
+
)
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
status: Literal["failed", "not_available"] = (
|
|
143
|
+
"not_available" if exc.__class__.__name__ == "OptionalDependencyError" else "failed"
|
|
144
|
+
)
|
|
145
|
+
return None, RecoveryAttempt(
|
|
146
|
+
action="docling",
|
|
147
|
+
status=status,
|
|
148
|
+
confidence_before=confidence_before,
|
|
149
|
+
error_type=exc.__class__.__name__,
|
|
150
|
+
error_message=str(exc),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def _mark_pages_for_vision(
|
|
154
|
+
self,
|
|
155
|
+
inspection: FileInspection,
|
|
156
|
+
document: IngestedDocument,
|
|
157
|
+
) -> IngestedDocument:
|
|
158
|
+
failed_pages = [
|
|
159
|
+
page for page in inspection.page_inspections if page.needs_vision
|
|
160
|
+
]
|
|
161
|
+
if not failed_pages:
|
|
162
|
+
return document
|
|
163
|
+
|
|
164
|
+
elements = list(document.elements)
|
|
165
|
+
for page in failed_pages:
|
|
166
|
+
page_elements = [
|
|
167
|
+
element
|
|
168
|
+
for element in document.elements
|
|
169
|
+
if element.location and element.location.page == page.page_number
|
|
170
|
+
]
|
|
171
|
+
native_confidence = _page_confidence(page_elements)
|
|
172
|
+
if native_confidence >= 0.70:
|
|
173
|
+
continue
|
|
174
|
+
elements.append(
|
|
175
|
+
DocumentElement(
|
|
176
|
+
element_id=f"p{page.page_number}-vision-required",
|
|
177
|
+
type="image",
|
|
178
|
+
text=None,
|
|
179
|
+
metadata={
|
|
180
|
+
"recovery": "vision-page",
|
|
181
|
+
"recovery_pending": True,
|
|
182
|
+
"page": page.page_number,
|
|
183
|
+
"reason": "native PDF text was below usable threshold",
|
|
184
|
+
"native_page_confidence": native_confidence,
|
|
185
|
+
},
|
|
186
|
+
confidence=0.25,
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
warnings = list(document.warnings)
|
|
191
|
+
warnings.append("One or more pages require VLM/OCR recovery")
|
|
192
|
+
return IngestedDocument(
|
|
193
|
+
document_id=document.document_id,
|
|
194
|
+
source_name=document.source_name,
|
|
195
|
+
mime_type=document.mime_type,
|
|
196
|
+
sha256=document.sha256,
|
|
197
|
+
elements=elements,
|
|
198
|
+
metadata={**document.metadata, "recovery": "vision-page"},
|
|
199
|
+
warnings=warnings,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _page_confidence(elements: list[DocumentElement]) -> float:
|
|
204
|
+
if not elements:
|
|
205
|
+
return 0.0
|
|
206
|
+
return sum(element.confidence for element in elements) / len(elements)
|
docgun/agents/router.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from docgun.domain.errors import UnsupportedDocumentError
|
|
6
|
+
from docgun.domain.models import FileInspection
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class RouteDecision:
|
|
11
|
+
parser: str
|
|
12
|
+
reason: str
|
|
13
|
+
needs_recovery: bool = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def route_document(inspection: FileInspection) -> RouteDecision:
|
|
17
|
+
suffix = inspection.extension.lower()
|
|
18
|
+
|
|
19
|
+
if suffix == ".pdf":
|
|
20
|
+
return RouteDecision(
|
|
21
|
+
parser="pymupdf",
|
|
22
|
+
reason="PDF fast path uses native text extraction first",
|
|
23
|
+
needs_recovery=inspection.likely_scanned,
|
|
24
|
+
)
|
|
25
|
+
if suffix == ".docx":
|
|
26
|
+
return RouteDecision(parser="python-docx", reason="DOCX direct XML extraction")
|
|
27
|
+
if suffix in {".xlsx", ".xlsm"}:
|
|
28
|
+
return RouteDecision(parser="openpyxl", reason="Workbook structure is preserved")
|
|
29
|
+
if suffix == ".csv":
|
|
30
|
+
return RouteDecision(parser="pyarrow", reason="Fast deterministic CSV parsing")
|
|
31
|
+
|
|
32
|
+
raise UnsupportedDocumentError(f"Unsupported document extension: {suffix or '<none>'}")
|
docgun/agents/vision.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
from docgun.agents.confidence import text_confidence
|
|
9
|
+
from docgun.domain.errors import OptionalDependencyError
|
|
10
|
+
from docgun.domain.models import BoundingBox, DocumentElement
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class RenderedPage:
|
|
15
|
+
page_number: int
|
|
16
|
+
png: bytes
|
|
17
|
+
width: int
|
|
18
|
+
height: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VisionBackend(Protocol):
|
|
22
|
+
def extract_batch(self, pages: Sequence[RenderedPage]) -> Sequence[Any]: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class VisionPageRecovery:
|
|
26
|
+
"""Render selected PDF pages and normalize a configured OCR/VLM response."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, backend: VisionBackend | Callable[[Sequence[RenderedPage]], Sequence[Any]], dpi: int = 180):
|
|
29
|
+
self.backend = backend
|
|
30
|
+
self.dpi = dpi
|
|
31
|
+
|
|
32
|
+
def recover(self, pdf_path: Path, page_numbers: list[int]) -> list[DocumentElement]:
|
|
33
|
+
pages = self._render_pages(pdf_path, page_numbers)
|
|
34
|
+
extract = getattr(self.backend, "extract_batch", self.backend)
|
|
35
|
+
results = extract(pages)
|
|
36
|
+
if len(results) != len(pages):
|
|
37
|
+
raise ValueError("Vision backend returned a different number of page results")
|
|
38
|
+
elements: list[DocumentElement] = []
|
|
39
|
+
for page, result in zip(pages, results, strict=True):
|
|
40
|
+
elements.extend(self._normalize_page(page.page_number, result))
|
|
41
|
+
return elements
|
|
42
|
+
|
|
43
|
+
def _render_pages(self, path: Path, page_numbers: list[int]) -> list[RenderedPage]:
|
|
44
|
+
try:
|
|
45
|
+
import pymupdf
|
|
46
|
+
except ImportError as exc:
|
|
47
|
+
raise OptionalDependencyError("PyMuPDF is required for page rendering") from exc
|
|
48
|
+
scale = self.dpi / 72
|
|
49
|
+
rendered: list[RenderedPage] = []
|
|
50
|
+
with pymupdf.open(path) as document:
|
|
51
|
+
for page_number in page_numbers:
|
|
52
|
+
if page_number < 1 or page_number > len(document):
|
|
53
|
+
raise ValueError(f"PDF page {page_number} is out of range")
|
|
54
|
+
pixmap = document[page_number - 1].get_pixmap(matrix=pymupdf.Matrix(scale, scale), alpha=False)
|
|
55
|
+
rendered.append(RenderedPage(page_number, pixmap.tobytes("png"), pixmap.width, pixmap.height))
|
|
56
|
+
return rendered
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def _normalize_page(page_number: int, result: Any) -> list[DocumentElement]:
|
|
60
|
+
items = result.get("elements", []) if isinstance(result, dict) else result
|
|
61
|
+
if isinstance(items, str):
|
|
62
|
+
items = [{"text": items}]
|
|
63
|
+
normalized: list[DocumentElement] = []
|
|
64
|
+
for index, item in enumerate(items or [], start=1):
|
|
65
|
+
if isinstance(item, str):
|
|
66
|
+
item = {"text": item}
|
|
67
|
+
text = item.get("text")
|
|
68
|
+
kind = item.get("type", "paragraph")
|
|
69
|
+
if kind not in {"heading", "paragraph", "table", "image", "formula"}:
|
|
70
|
+
kind = "paragraph"
|
|
71
|
+
bbox = item.get("bbox")
|
|
72
|
+
location = BoundingBox(page_number, *bbox) if bbox and len(bbox) == 4 else BoundingBox(page_number)
|
|
73
|
+
confidence = float(item.get("confidence", text_confidence(text, location, element_type="heading" if kind == "heading" else "paragraph")))
|
|
74
|
+
normalized.append(DocumentElement(
|
|
75
|
+
element_id=f"p{page_number}-vision-{index}", type=kind, text=text,
|
|
76
|
+
table=item.get("table"), location=location, confidence=confidence,
|
|
77
|
+
metadata={"parser": "vision", "recovery_source": "vision-page", "page": page_number},
|
|
78
|
+
))
|
|
79
|
+
return normalized
|
docgun/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FastAPI routes."""
|