structverify 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. structverify/__init__.py +83 -0
  2. structverify/adaptation/__init__.py +0 -0
  3. structverify/adaptation/adapter_trainer.py +341 -0
  4. structverify/adaptation/feedback_store.py +31 -0
  5. structverify/adaptation/kosis_crawler.py +317 -0
  6. structverify/adaptation/sample_builder.py +149 -0
  7. structverify/adaptation/synthetic_generator.py +320 -0
  8. structverify/adaptation/update_embeddings.py +178 -0
  9. structverify/agent/__init__.py +21 -0
  10. structverify/agent/builder_agent.py +226 -0
  11. structverify/agent/conformance_agent.py +171 -0
  12. structverify/agent/dependency_planner.py +151 -0
  13. structverify/agent/indexing_agent.py +153 -0
  14. structverify/agent/indexing_planner.py +169 -0
  15. structverify/agent/integration_example.py +182 -0
  16. structverify/agent/loop.py +1165 -0
  17. structverify/agent/memory.py +207 -0
  18. structverify/agent/planner.py +817 -0
  19. structverify/agent/prompts/__init__.py +15 -0
  20. structverify/agent/prompts/planner_prompts.py +219 -0
  21. structverify/agent/prompts/reflect_prompts.py +387 -0
  22. structverify/agent/reflect.py +227 -0
  23. structverify/agent/runtime_agent.py +1272 -0
  24. structverify/agent/schemas.py +262 -0
  25. structverify/agent/source_profiler.py +229 -0
  26. structverify/agent/tools/__init__.py +64 -0
  27. structverify/agent/tools/base.py +222 -0
  28. structverify/agent/tools/calculate.py +244 -0
  29. structverify/agent/tools/catalog_search.py +859 -0
  30. structverify/agent/tools/deep_explore.py +293 -0
  31. structverify/agent/tools/explore_catalog.py +423 -0
  32. structverify/agent/tools/fetch_evidence.py +922 -0
  33. structverify/agent/tools/finish.py +423 -0
  34. structverify/agent/tools/meta_explore.py +267 -0
  35. structverify/agent/tools/query_rewriter.py +134 -0
  36. structverify/agent/tools/read_original.py +144 -0
  37. structverify/agent/tools/replan.py +365 -0
  38. structverify/agent/workspace.py +958 -0
  39. structverify/api.py +804 -0
  40. structverify/config/default.yaml +350 -0
  41. structverify/core/__init__.py +0 -0
  42. structverify/core/config_loader.py +30 -0
  43. structverify/core/pipeline.py +280 -0
  44. structverify/core/schemas.py +362 -0
  45. structverify/detection/__init__.py +26 -0
  46. structverify/detection/_config.py +163 -0
  47. structverify/detection/_llm.py +24 -0
  48. structverify/detection/candidate/__init__.py +1 -0
  49. structverify/detection/candidate/heuristic.py +60 -0
  50. structverify/detection/candidate/llm.py +51 -0
  51. structverify/detection/candidate_scorer.py +81 -0
  52. structverify/detection/claim_detector.py +164 -0
  53. structverify/detection/claims/__init__.py +1 -0
  54. structverify/detection/claims/worthiness.py +142 -0
  55. structverify/detection/domain/__init__.py +1 -0
  56. structverify/detection/domain/classify.py +84 -0
  57. structverify/detection/domain/preview.py +36 -0
  58. structverify/detection/domain/registry.py +99 -0
  59. structverify/detection/domain_classifier.py +75 -0
  60. structverify/detection/prompts/__init__.py +1 -0
  61. structverify/detection/prompts/candidate.py +38 -0
  62. structverify/detection/prompts/claim_worthiness.py +48 -0
  63. structverify/detection/prompts/domain.py +41 -0
  64. structverify/detection/prompts/schema.py +508 -0
  65. structverify/detection/prompts_loader.py +167 -0
  66. structverify/detection/schema/__init__.py +1 -0
  67. structverify/detection/schema/expand.py +83 -0
  68. structverify/detection/schema/induce.py +441 -0
  69. structverify/detection/schema/regenerate.py +162 -0
  70. structverify/detection/schema/temporal_hints.py +130 -0
  71. structverify/detection/schema/validate.py +193 -0
  72. structverify/detection/schema_inductor.py +112 -0
  73. structverify/detection/synthetic_generator.py +270 -0
  74. structverify/explanation/__init__.py +0 -0
  75. structverify/explanation/_config.py +18 -0
  76. structverify/explanation/_llm.py +25 -0
  77. structverify/explanation/explainer.py +183 -0
  78. structverify/explanation/fallback.py +29 -0
  79. structverify/explanation/formatters.py +75 -0
  80. structverify/explanation/prompts/__init__.py +1 -0
  81. structverify/explanation/prompts/match.py +27 -0
  82. structverify/explanation/prompts/mismatch.py +20 -0
  83. structverify/explanation/prompts/multihop.py +16 -0
  84. structverify/explanation/prompts/unverifiable.py +17 -0
  85. structverify/graph/__init__.py +0 -0
  86. structverify/graph/claim_graph.py +226 -0
  87. structverify/graph/document_graph.py +487 -0
  88. structverify/graph/graph_builder.py +238 -0
  89. structverify/graph/graph_multihop.py +335 -0
  90. structverify/graph/graph_store.py +281 -0
  91. structverify/graph/provenance.py +52 -0
  92. structverify/memory/__init__.py +44 -0
  93. structverify/memory/agent_memory.py +142 -0
  94. structverify/memory/embedder.py +69 -0
  95. structverify/memory/exemplar_store.py +241 -0
  96. structverify/memory/normalizer.py +91 -0
  97. structverify/memory/schema.py +119 -0
  98. structverify/memory/storage/__init__.py +29 -0
  99. structverify/memory/storage/jsonl_store.py +117 -0
  100. structverify/memory/working_memory.py +370 -0
  101. structverify/preprocessing/Dockerfile.scraper +27 -0
  102. structverify/preprocessing/__init__.py +0 -0
  103. structverify/preprocessing/extractor.py +574 -0
  104. structverify/preprocessing/pdf/__init__.py +16 -0
  105. structverify/preprocessing/pdf/fields.py +95 -0
  106. structverify/preprocessing/pdf/markdown.py +107 -0
  107. structverify/preprocessing/pdf/models.py +34 -0
  108. structverify/preprocessing/pdf/ocr.py +172 -0
  109. structverify/preprocessing/pdf/pipeline.py +74 -0
  110. structverify/preprocessing/pdf/reader.py +119 -0
  111. structverify/preprocessing/pdf/scoring.py +61 -0
  112. structverify/preprocessing/scraper_sandbox.py +561 -0
  113. structverify/preprocessing/segmenter.py +48 -0
  114. structverify/preprocessing/sir_builder.py +240 -0
  115. structverify/progress.py +591 -0
  116. structverify/retrieval/__init__.py +0 -0
  117. structverify/retrieval/base.py +208 -0
  118. structverify/retrieval/base_connector.py +85 -0
  119. structverify/retrieval/catalog_ranker.py +300 -0
  120. structverify/retrieval/catalog_search.py +583 -0
  121. structverify/retrieval/chunking.py +92 -0
  122. structverify/retrieval/custom_csv_source.py +386 -0
  123. structverify/retrieval/custom_db_source.py +396 -0
  124. structverify/retrieval/custom_docs_source.py +152 -0
  125. structverify/retrieval/dimension_resolver.py +281 -0
  126. structverify/retrieval/evidence_subgraph.py +63 -0
  127. structverify/retrieval/kosis_connector.py +1192 -0
  128. structverify/retrieval/kosis_relevance.py +142 -0
  129. structverify/retrieval/kosis_source.py +1541 -0
  130. structverify/retrieval/query_builder.py +72 -0
  131. structverify/retrieval/registry.py +133 -0
  132. structverify/retrieval/relevance_judge.py +141 -0
  133. structverify/retrieval/row_matcher.py +267 -0
  134. structverify/storage/__init__.py +0 -0
  135. structverify/storage/db_manager.py +157 -0
  136. structverify/storage/dwh_manager.py +92 -0
  137. structverify/storage/init_db.py +99 -0
  138. structverify/storage/raw_storage.py +29 -0
  139. structverify/training/__init__.py +26 -0
  140. structverify/training/curator.py +124 -0
  141. structverify/training/dataset.py +134 -0
  142. structverify/training/doctor.py +99 -0
  143. structverify/training/evalgate.py +96 -0
  144. structverify/training/generate.py +101 -0
  145. structverify/training/loop.py +116 -0
  146. structverify/training/recipe/train_mlx.py +99 -0
  147. structverify/training/recipe/train_qlora.py +104 -0
  148. structverify/training/tasks.py +79 -0
  149. structverify/utils/__init__.py +0 -0
  150. structverify/utils/embedding_client.py +248 -0
  151. structverify/utils/llm_client.py +809 -0
  152. structverify/utils/logger.py +81 -0
  153. structverify/verification/__init__.py +0 -0
  154. structverify/verification/_config.py +45 -0
  155. structverify/verification/adapters.py +405 -0
  156. structverify/verification/conformance.py +117 -0
  157. structverify/verification/decide_verdict.py +216 -0
  158. structverify/verification/decide_verdict_agent.py +454 -0
  159. structverify/verification/growth_diff.py +267 -0
  160. structverify/verification/row_match.py +345 -0
  161. structverify/verification/units.py +64 -0
  162. structverify/verification/verdict_thresholds.py +232 -0
  163. structverify/verification/verifier.py +84 -0
  164. structverify-0.3.0.dist-info/METADATA +903 -0
  165. structverify-0.3.0.dist-info/RECORD +168 -0
  166. structverify-0.3.0.dist-info/WHEEL +5 -0
  167. structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
  168. structverify-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,107 @@
1
+ """
2
+ preprocessing/pdf/markdown.py — Markdown 직렬화 + OCR 인라인 병합
3
+
4
+ * `to_markdown` : Extracted → 최종 MD 문자열
5
+ * `table_to_md` : BeautifulSoup `<table>` → MD 표
6
+ * `inline_image_ocr_into_body` : 이미지 OCR 결과를 본문 페이지 앵커 뒤에 삽입
7
+ * `append_scanned_ocr` : 스캔 페이지 OCR 을 body 에 병합 (교체/추가)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from structverify.preprocessing.pdf.models import (
12
+ Extracted, ImageOcrHit, PageText)
13
+
14
+
15
+ def table_to_md(tbl) -> str:
16
+ """BeautifulSoup `<table>` 엘리먼트를 Markdown 표로 변환."""
17
+ rows: list[list[str]] = []
18
+ for tr in tbl.find_all("tr"):
19
+ cells = [c.get_text(" ", strip=True) for c in tr.find_all(["th", "td"])]
20
+ if cells:
21
+ rows.append(cells)
22
+ if not rows:
23
+ return ""
24
+ width = max(len(r) for r in rows)
25
+ rows = [r + [""] * (width - len(r)) for r in rows]
26
+ md = ["| " + " | ".join(rows[0]) + " |",
27
+ "| " + " | ".join(["---"] * width) + " |"]
28
+ for r in rows[1:]:
29
+ md.append("| " + " | ".join(r) + " |")
30
+ return "\n".join(md)
31
+
32
+
33
+ def inline_image_ocr_into_body(body: str,
34
+ image_ocr: dict[int, list[ImageOcrHit]],
35
+ pages: list[PageText]) -> str:
36
+ """
37
+ 본문에 이미지 OCR 결과 삽입.
38
+ 전략:
39
+ 1) 각 페이지의 PyMuPDF 텍스트 첫 유의미한 라인(10자+) 을 앵커로 검색
40
+ 2) 앵커 직후 단락 경계에 `> **[이미지 p.N-k]** <OCR>` 인용 블록 삽입
41
+ 3) 앵커 매칭 실패 시 body 말미 `## 이미지 내 텍스트` 섹션으로 폴백
42
+ """
43
+ if not image_ocr:
44
+ return body
45
+
46
+ def block(pno: int) -> str:
47
+ lines = []
48
+ for hit in image_ocr[pno]:
49
+ snippet = hit.text.strip()
50
+ if snippet:
51
+ quoted = snippet.replace("\n", "\n> ")
52
+ lines.append(
53
+ f"\n> **[이미지 p.{pno+1}-{hit.idx+1}]**\n>\n> {quoted}"
54
+ )
55
+ return "\n".join(lines)
56
+
57
+ merged = body
58
+ orphans: list[tuple[int, str]] = []
59
+ for pno in sorted(image_ocr):
60
+ page_text = next((p.text for p in pages if p.page_no == pno), "").strip()
61
+ anchor = next(
62
+ (l.strip()[:30] for l in page_text.splitlines() if len(l.strip()) >= 10),
63
+ "",
64
+ )
65
+ if anchor and anchor in merged:
66
+ idx = merged.find(anchor) + len(anchor)
67
+ nl = merged.find("\n\n", idx)
68
+ insert_at = nl if nl != -1 else len(merged)
69
+ merged = merged[:insert_at] + "\n\n" + block(pno) + merged[insert_at:]
70
+ else:
71
+ orphans.append((pno, block(pno)))
72
+
73
+ if orphans:
74
+ merged += "\n\n## 이미지 내 텍스트\n"
75
+ for pno, blk in orphans:
76
+ merged += f"\n**p.{pno+1}**\n{blk}\n"
77
+ return merged
78
+
79
+
80
+ def append_scanned_ocr(body: str, scanned_ocr: dict[int, str],
81
+ total_pages: int) -> str:
82
+ """
83
+ 스캔 페이지 OCR 병합.
84
+ * 전체 페이지의 50%↑ 가 스캔이면 body 전체를 OCR 결과로 교체
85
+ (텍스트 레이어가 없는 문서이므로 Docling body 는 신뢰 불가)
86
+ * 미만이면 말미 `## 스캔 페이지 OCR` 섹션으로 append
87
+ """
88
+ if not scanned_ocr:
89
+ return body
90
+ if len(scanned_ocr) * 2 >= max(total_pages, 1):
91
+ return "\n\n".join(scanned_ocr[k] for k in sorted(scanned_ocr))
92
+ out = body + "\n\n## 스캔 페이지 OCR\n"
93
+ for pno in sorted(scanned_ocr):
94
+ out += f"\n**p.{pno+1}**\n\n{scanned_ocr[pno]}\n"
95
+ return out
96
+
97
+
98
+ def to_markdown(ex: Extracted) -> str:
99
+ """Extracted → 최종 Markdown 문자열."""
100
+ parts: list[str] = []
101
+ if ex.title:
102
+ parts.append(f"# {ex.title.strip()}")
103
+ if ex.date:
104
+ parts.append(f"_작성일: {ex.date.strip()}_")
105
+ parts.append("")
106
+ parts.append(ex.body.strip())
107
+ return "\n\n".join(parts).strip() + "\n"
@@ -0,0 +1,34 @@
1
+ """
2
+ preprocessing/pdf/models.py — PDF 파이프라인 내부용 데이터 구조
3
+
4
+ * 파이프라인 단계 사이 전달용이며, 외부 스키마(`core.schemas`)와는 분리.
5
+ * SIR Tree 로의 변환은 상위 단계(`sir_builder.py`)에서 수행.
6
+ """
7
+ from __future__ import annotations
8
+ from dataclasses import dataclass
9
+
10
+
11
+ @dataclass
12
+ class PageText:
13
+ """PyMuPDF 로 추출한 페이지 단위 정보"""
14
+ page_no: int
15
+ text: str
16
+ needs_ocr: bool # 텍스트 레이어가 비어있음 → 스캔 페이지
17
+ has_images: bool # 이미지 블록 존재 → 이미지 OCR 대상
18
+
19
+
20
+ @dataclass
21
+ class Extracted:
22
+ """선택된 소스(JSON/HTML)에서 뽑은 필드"""
23
+ title: str = ""
24
+ date: str = ""
25
+ body: str = ""
26
+ source_used: str = "" # "json" | "html" | "pymupdf"
27
+
28
+
29
+ @dataclass
30
+ class ImageOcrHit:
31
+ """이미지 블록 OCR 결과 단위"""
32
+ idx: int # 페이지 내 이미지 순번
33
+ bbox: tuple # (x0, y0, x1, y1)
34
+ text: str
@@ -0,0 +1,172 @@
1
+ """
2
+ preprocessing/pdf/ocr.py — OCR 백엔드 라우팅 + 이미지/스캔 OCR
3
+
4
+ 백엔드 비교 (한국어 기준 벤치마크):
5
+ * EasyOCR : 한글 정확도 가장 우수, 초기 모델 로드 느림 → 싱글톤
6
+ * Tesseract : 빠르지만 한글 정확도 약함 (영문 위주 문서에만 추천)
7
+ * PaddleOCR : 정확도·속도 절충, 한국어 모델 별도 설치 필요
8
+
9
+ 환경변수 `OCR_BACKEND` (기본: easyocr) 로 교체.
10
+ 실제 라이브러리는 지연 로드 — 설치돼 있지 않으면 해당 백엔드만 실패하고
11
+ 다른 백엔드/파이프라인 단계는 정상 동작.
12
+ """
13
+ from __future__ import annotations
14
+ import io
15
+ import os
16
+ from concurrent.futures import ThreadPoolExecutor, as_completed
17
+
18
+ try:
19
+ from PIL import Image
20
+ except ImportError:
21
+ Image = None # type: ignore
22
+
23
+ from structverify.preprocessing.pdf.models import PageText, ImageOcrHit
24
+ from structverify.preprocessing.pdf.reader import (
25
+ get_image_rects, render_region_png)
26
+ from structverify.utils.logger import get_logger
27
+
28
+ logger = get_logger(__name__)
29
+
30
+ DEFAULT_BACKEND = "easyocr"
31
+
32
+ # 리더 싱글톤 (모델 재로딩 비용 방지)
33
+ _easy_reader = None
34
+ _paddle_reader = None
35
+
36
+
37
+ # ── 백엔드 함수 ─────────────────────────────────────────────────────────
38
+
39
+ def _ocr_easyocr(img) -> str:
40
+ global _easy_reader
41
+ import easyocr # type: ignore
42
+ import numpy as np
43
+ if _easy_reader is None:
44
+ _easy_reader = easyocr.Reader(["ko", "en"], gpu=False)
45
+ return "\n".join(_easy_reader.readtext(np.array(img), detail=0))
46
+
47
+
48
+ def _ocr_tesseract(img) -> str:
49
+ import pytesseract # type: ignore
50
+ return pytesseract.image_to_string(img, lang="kor+eng")
51
+
52
+
53
+ def _ocr_paddle(img) -> str:
54
+ global _paddle_reader
55
+ from paddleocr import PaddleOCR # type: ignore
56
+ import numpy as np
57
+ if _paddle_reader is None:
58
+ _paddle_reader = PaddleOCR(
59
+ use_angle_cls=True, lang="korean", show_log=False)
60
+ res = _paddle_reader.ocr(np.array(img), cls=True)
61
+ out: list[str] = []
62
+ for block in res or []:
63
+ for line in block or []:
64
+ if line and len(line) >= 2 and line[1]:
65
+ out.append(line[1][0])
66
+ return "\n".join(out)
67
+
68
+
69
+ _BACKENDS = {
70
+ "easyocr": _ocr_easyocr,
71
+ "tesseract": _ocr_tesseract,
72
+ "paddleocr": _ocr_paddle,
73
+ }
74
+
75
+
76
+ def resolve_backend(backend: str | None = None) -> str:
77
+ b = backend or os.environ.get("OCR_BACKEND", DEFAULT_BACKEND)
78
+ if b not in _BACKENDS:
79
+ raise ValueError(f"unknown OCR backend: {b}")
80
+ return b
81
+
82
+
83
+ def run_ocr(img, backend: str | None = None) -> str:
84
+ return _BACKENDS[resolve_backend(backend)](img)
85
+
86
+
87
+ def benchmark(img, backends=("tesseract", "easyocr")) -> dict:
88
+ """개발용 벤치 — 백엔드별 처리시간/글자수/샘플 비교."""
89
+ import time
90
+ report: dict = {}
91
+ for name in backends:
92
+ fn = _BACKENDS[name]
93
+ t0 = time.time()
94
+ try:
95
+ out = fn(img)
96
+ report[name] = {
97
+ "ok": True,
98
+ "elapsed": round(time.time() - t0, 2),
99
+ "chars": len(out),
100
+ "sample": out[:160],
101
+ }
102
+ except Exception as e:
103
+ report[name] = {"ok": False,
104
+ "error": f"{type(e).__name__}: {e}"}
105
+ return report
106
+
107
+
108
+ # ── 이미지 블록 crop OCR ────────────────────────────────────────────────
109
+
110
+ def _open_png(png_bytes: bytes):
111
+ if Image is None:
112
+ raise RuntimeError("Pillow 미설치 - OCR 불가")
113
+ return Image.open(io.BytesIO(png_bytes))
114
+
115
+
116
+ def ocr_page_images(pdf_path: str, page_no: int,
117
+ backend: str | None = None) -> list[ImageOcrHit]:
118
+ """페이지 내 이미지 블록 각각에 대해 crop → OCR."""
119
+ hits: list[ImageOcrHit] = []
120
+ for i, bb in enumerate(get_image_rects(pdf_path, page_no)):
121
+ # 아이콘/로고(30pt 미만) 는 OCR 가치 낮음 → skip
122
+ if (bb[2] - bb[0] < 30) or (bb[3] - bb[1] < 30):
123
+ continue
124
+ try:
125
+ png = render_region_png(pdf_path, page_no, bb, dpi=300)
126
+ text = run_ocr(_open_png(png), backend).strip()
127
+ except Exception as e:
128
+ logger.warning(f"image OCR 실패 p{page_no} #{i}: {e}")
129
+ continue
130
+ if text:
131
+ hits.append(ImageOcrHit(idx=i, bbox=bb, text=text))
132
+ return hits
133
+
134
+
135
+ def collect_image_ocr(pdf_path: str, pages: list[PageText],
136
+ backend: str | None = None,
137
+ max_workers: int = 4) -> dict[int, list[ImageOcrHit]]:
138
+ """이미지 포함 페이지만 Thread 병렬. 결과 {page_no: [ImageOcrHit...]}"""
139
+ targets = [p.page_no for p in pages if p.has_images]
140
+ if not targets:
141
+ return {}
142
+ out: dict[int, list[ImageOcrHit]] = {}
143
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
144
+ fut = {ex.submit(ocr_page_images, pdf_path, pno, backend): pno
145
+ for pno in targets}
146
+ for f in as_completed(fut):
147
+ res = f.result()
148
+ if res:
149
+ out[fut[f]] = res
150
+ return out
151
+
152
+
153
+ # ── 스캔 페이지 전체 OCR ────────────────────────────────────────────────
154
+
155
+ def collect_scanned_ocr(pdf_path: str, pages: list[PageText],
156
+ backend: str | None = None,
157
+ max_workers: int = 4) -> dict[int, str]:
158
+ """`needs_ocr=True` 페이지를 full-page 렌더 → OCR."""
159
+ targets = [p.page_no for p in pages if p.needs_ocr]
160
+ if not targets or Image is None:
161
+ return {}
162
+
163
+ def _full(pno: int) -> str:
164
+ png = render_region_png(pdf_path, pno, bbox=None, dpi=220)
165
+ return run_ocr(_open_png(png), backend)
166
+
167
+ out: dict[int, str] = {}
168
+ with ThreadPoolExecutor(max_workers=max_workers) as ex:
169
+ fut = {ex.submit(_full, pno): pno for pno in targets}
170
+ for f in as_completed(fut):
171
+ out[fut[f]] = f.result()
172
+ return out
@@ -0,0 +1,74 @@
1
+ """
2
+ preprocessing/pdf/pipeline.py — PDF → Markdown 오케스트레이터
3
+
4
+ 단계:
5
+ 1) PyMuPDF 페이지 병렬 추출 (`reader.extract_all_pages`)
6
+ 2) Docling JSON+HTML 추출 (`reader.docling_extract`)
7
+ 3) 소스 스코어링 → 선택 (`scoring.pick_source`)
8
+ 4) 선택 소스에서 title/date/body 추출 + 평문 정규식 폴백 (`fields`)
9
+ 5) 이미지 블록 crop OCR 후 본문 인라인 삽입 (`ocr` + `markdown`)
10
+ 6) 스캔 페이지 전체 OCR 후 body 교체/추가 (`ocr` + `markdown`)
11
+ 7) Markdown 직렬화 (`markdown.to_markdown`)
12
+
13
+ 실패에 관대하게: Docling/OCR 어느 단계가 고장나도 가능한 결과를 반환.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from structverify.preprocessing.pdf.models import Extracted
18
+ from structverify.preprocessing.pdf import reader, scoring, fields, ocr, markdown
19
+ from structverify.utils.logger import get_logger
20
+
21
+ logger = get_logger(__name__)
22
+
23
+
24
+ def extract_pdf_to_markdown(filepath: str,
25
+ ocr_backend: str | None = None) -> str:
26
+ """PDF 파일 경로 → Markdown 문자열. 공개 API."""
27
+ pdf_path = str(filepath)
28
+
29
+ # 1) 페이지 텍스트
30
+ try:
31
+ pages = reader.extract_all_pages(pdf_path)
32
+ except Exception as e:
33
+ logger.error(f"PDF 열기 실패: {pdf_path} — {e}")
34
+ return ""
35
+ plain = "\n\n".join(p.text for p in pages)
36
+
37
+ # 2~3) 구조화 추출 + 소스 선택
38
+ dl = reader.docling_extract(pdf_path)
39
+ if dl["ok"]:
40
+ src = scoring.pick_source(dl)
41
+ ex = (fields.extract_from_json(dl["json"]) if src == "json"
42
+ else fields.extract_from_html(dl["html"]))
43
+ else:
44
+ ex = Extracted(body=plain, source_used="pymupdf")
45
+
46
+ # 4) 필드 폴백
47
+ if not ex.title:
48
+ ex.title = fields.fallback_from_plain(plain, "title")
49
+ if not ex.date:
50
+ ex.date = fields.fallback_from_plain(plain, "date")
51
+ if len(ex.body) < 50:
52
+ ex.body = plain
53
+
54
+ # 5) 이미지 블록 crop OCR → 본문 인라인
55
+ try:
56
+ image_ocr = ocr.collect_image_ocr(pdf_path, pages, ocr_backend)
57
+ except Exception as e:
58
+ logger.warning(f"이미지 OCR 단계 스킵: {e}")
59
+ image_ocr = {}
60
+ if image_ocr:
61
+ ex.body = markdown.inline_image_ocr_into_body(ex.body, image_ocr, pages)
62
+
63
+ # 6) 스캔 페이지 전체 OCR → body 교체/추가
64
+ try:
65
+ scanned_ocr = ocr.collect_scanned_ocr(pdf_path, pages, ocr_backend)
66
+ except Exception as e:
67
+ logger.warning(f"스캔 OCR 단계 스킵: {e}")
68
+ scanned_ocr = {}
69
+ if scanned_ocr:
70
+ ex.body = markdown.append_scanned_ocr(
71
+ ex.body, scanned_ocr, total_pages=len(pages))
72
+
73
+ # 7) Markdown 직렬화
74
+ return markdown.to_markdown(ex)
@@ -0,0 +1,119 @@
1
+ """
2
+ preprocessing/pdf/reader.py — PyMuPDF & Docling 기반 1차 추출
3
+
4
+ * PyMuPDF: 페이지 텍스트 레이어 + 이미지 bbox + 고해상도 crop 렌더링
5
+ * Docling: JSON + HTML 동시 export (소스 스코어링에 사용)
6
+ """
7
+ from __future__ import annotations
8
+ import os
9
+ from concurrent.futures import ProcessPoolExecutor, as_completed
10
+
11
+ # PyMuPDF (fitz) is optional and AGPL-3.0 licensed — it powers only this advanced
12
+ # OCR / image-rendering PDF pipeline, never the core library. Import lazily so the
13
+ # package installs and imports cleanly without it; callers that actually use this
14
+ # module get a clear install hint. (The core PDF path uses pdfplumber, MIT.)
15
+ try:
16
+ import fitz # PyMuPDF
17
+ except ImportError: # pragma: no cover
18
+ fitz = None
19
+
20
+ from structverify.preprocessing.pdf.models import PageText
21
+ from structverify.utils.logger import get_logger
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ def _require_fitz():
27
+ if fitz is None:
28
+ raise ImportError(
29
+ "This PDF feature needs PyMuPDF. Install it with "
30
+ "`pip install \"structverify[pdf-ocr]\"` (note: PyMuPDF is AGPL-3.0)."
31
+ )
32
+ return fitz
33
+
34
+
35
+ # ── PyMuPDF 페이지 텍스트 ────────────────────────────────────────────────
36
+
37
+ def extract_page(pdf_path: str, page_no: int) -> PageText:
38
+ """단일 페이지 PyMuPDF 추출. 프로세스 병렬에서도 동작하도록 순수 함수."""
39
+ _require_fitz()
40
+ with fitz.open(pdf_path) as doc:
41
+ page = doc[page_no]
42
+ txt = page.get_text("text") or ""
43
+ images = page.get_images(full=True)
44
+ return PageText(
45
+ page_no=page_no,
46
+ text=txt,
47
+ needs_ocr=len(txt.strip()) < 20,
48
+ has_images=len(images) > 0,
49
+ )
50
+
51
+
52
+ def extract_all_pages(pdf_path: str) -> list[PageText]:
53
+ """페이지 단위 병렬 추출. 페이지 수 2 이하는 순차(오버헤드 방지)."""
54
+ _require_fitz()
55
+ with fitz.open(pdf_path) as doc:
56
+ n = doc.page_count
57
+ if n <= 2:
58
+ return [extract_page(pdf_path, i) for i in range(n)]
59
+ workers = min(os.cpu_count() or 4, n)
60
+ with ProcessPoolExecutor(max_workers=workers) as ex:
61
+ fut = {ex.submit(extract_page, pdf_path, i): i for i in range(n)}
62
+ pages = [f.result() for f in as_completed(fut)]
63
+ return sorted(pages, key=lambda p: p.page_no)
64
+
65
+
66
+ # ── PyMuPDF 이미지 bbox ──────────────────────────────────────────────────
67
+
68
+ def get_image_rects(pdf_path: str, page_no: int) -> list[tuple]:
69
+ """페이지의 이미지 xref bbox 목록 (중복 xref 제거)."""
70
+ _require_fitz()
71
+ rects: list[tuple] = []
72
+ with fitz.open(pdf_path) as doc:
73
+ page = doc[page_no]
74
+ seen: set = set()
75
+ for info in page.get_images(full=True):
76
+ xref = info[0]
77
+ if xref in seen:
78
+ continue
79
+ seen.add(xref)
80
+ try:
81
+ for r in page.get_image_rects(xref):
82
+ rects.append((float(r.x0), float(r.y0),
83
+ float(r.x1), float(r.y1)))
84
+ except Exception:
85
+ continue
86
+ return rects
87
+
88
+
89
+ def render_region_png(pdf_path: str, page_no: int, bbox: tuple | None = None,
90
+ dpi: int = 300) -> bytes:
91
+ """페이지 일부 또는 전체를 PNG 바이트로 렌더링. OCR 입력으로 사용."""
92
+ _require_fitz()
93
+ with fitz.open(pdf_path) as doc:
94
+ page = doc[page_no]
95
+ mat = fitz.Matrix(dpi / 72, dpi / 72)
96
+ pix = page.get_pixmap(matrix=mat, clip=fitz.Rect(*bbox)) \
97
+ if bbox else page.get_pixmap(matrix=mat)
98
+ return pix.tobytes("png")
99
+
100
+
101
+ # ── Docling 구조화 추출 ──────────────────────────────────────────────────
102
+
103
+ def docling_extract(pdf_path: str) -> dict:
104
+ """Docling 으로 JSON + HTML 동시 추출. 실패 시 비어있는 결과."""
105
+ try:
106
+ from docling.document_converter import DocumentConverter
107
+ except ImportError:
108
+ logger.warning("docling 미설치 - 구조화 추출 스킵")
109
+ return {"json": None, "html": None, "ok": False}
110
+ try:
111
+ doc = DocumentConverter().convert(pdf_path).document
112
+ return {
113
+ "json": doc.export_to_dict(),
114
+ "html": doc.export_to_html(),
115
+ "ok": True,
116
+ }
117
+ except Exception as e:
118
+ logger.warning(f"docling 변환 실패: {e}")
119
+ return {"json": None, "html": None, "ok": False}
@@ -0,0 +1,61 @@
1
+ """
2
+ preprocessing/pdf/scoring.py — JSON vs HTML 소스 품질 스코어링
3
+
4
+ Docling 이 둘 다 생성 가능하지만 문서별로 어느 쪽이 후속 필드 추출에
5
+ 유리한지 다르다. 간단한 휴리스틱으로 점수를 매겨 높은 쪽을 선택.
6
+ """
7
+ from __future__ import annotations
8
+ import json
9
+ import re
10
+ from typing import Literal
11
+
12
+ try:
13
+ from bs4 import BeautifulSoup
14
+ except ImportError:
15
+ BeautifulSoup = None # type: ignore
16
+
17
+ from structverify.utils.logger import get_logger
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ def score_json(j: dict | None) -> int:
23
+ if not j:
24
+ return -999
25
+ s = 0
26
+ flat = json.dumps(j, ensure_ascii=False)
27
+ if re.search(r'"(title|heading)"', flat):
28
+ s += 3
29
+ if re.search(r'"(date|published|created)"', flat, re.I):
30
+ s += 3
31
+ if len(re.findall(r'"text"\s*:\s*"([^"]{2,})"', flat)) >= 20:
32
+ s += 2
33
+ if re.search(r'"(cells?|rows?|columns?)"', flat):
34
+ s += 2
35
+ return s
36
+
37
+
38
+ def score_html(h: str | None) -> int:
39
+ if not h or BeautifulSoup is None:
40
+ return -999
41
+ s = 0
42
+ soup = BeautifulSoup(h, "lxml")
43
+ if len(soup.find_all("table")) >= 3:
44
+ s += 2
45
+ tags = {t.name for t in soup.find_all(
46
+ ["h1", "h2", "h3", "p", "table", "ul", "ol"])}
47
+ if len(tags) >= 3:
48
+ s += 2
49
+ if soup.find(["h1", "h2"]):
50
+ s += 1
51
+ if soup.find(string=re.compile(r"\d{4}[-./]\d{1,2}[-./]\d{1,2}")):
52
+ s += 1
53
+ return s
54
+
55
+
56
+ def pick_source(docling_result: dict) -> Literal["json", "html"]:
57
+ """둘 다 가능할 때 높은 점수. 동점 시 JSON (구조화 메타가 풍부한 경향)."""
58
+ sj = score_json(docling_result.get("json"))
59
+ sh = score_html(docling_result.get("html"))
60
+ logger.info(f"source score | json={sj} html={sh}")
61
+ return "json" if sj >= sh else "html"