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 ADDED
@@ -0,0 +1,8 @@
1
+ """Public interface for the DocGun ingestion package."""
2
+
3
+ from docgun.pipeline.graph import ingest
4
+ from docgun.pipeline.state import PipelineState
5
+
6
+ __all__ = ["PipelineState", "__version__", "ingest"]
7
+
8
+ __version__ = "0.1.0"
@@ -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()
@@ -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)
@@ -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>'}")
@@ -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."""