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.
- structverify/__init__.py +83 -0
- structverify/adaptation/__init__.py +0 -0
- structverify/adaptation/adapter_trainer.py +341 -0
- structverify/adaptation/feedback_store.py +31 -0
- structverify/adaptation/kosis_crawler.py +317 -0
- structverify/adaptation/sample_builder.py +149 -0
- structverify/adaptation/synthetic_generator.py +320 -0
- structverify/adaptation/update_embeddings.py +178 -0
- structverify/agent/__init__.py +21 -0
- structverify/agent/builder_agent.py +226 -0
- structverify/agent/conformance_agent.py +171 -0
- structverify/agent/dependency_planner.py +151 -0
- structverify/agent/indexing_agent.py +153 -0
- structverify/agent/indexing_planner.py +169 -0
- structverify/agent/integration_example.py +182 -0
- structverify/agent/loop.py +1165 -0
- structverify/agent/memory.py +207 -0
- structverify/agent/planner.py +817 -0
- structverify/agent/prompts/__init__.py +15 -0
- structverify/agent/prompts/planner_prompts.py +219 -0
- structverify/agent/prompts/reflect_prompts.py +387 -0
- structverify/agent/reflect.py +227 -0
- structverify/agent/runtime_agent.py +1272 -0
- structverify/agent/schemas.py +262 -0
- structverify/agent/source_profiler.py +229 -0
- structverify/agent/tools/__init__.py +64 -0
- structverify/agent/tools/base.py +222 -0
- structverify/agent/tools/calculate.py +244 -0
- structverify/agent/tools/catalog_search.py +859 -0
- structverify/agent/tools/deep_explore.py +293 -0
- structverify/agent/tools/explore_catalog.py +423 -0
- structverify/agent/tools/fetch_evidence.py +922 -0
- structverify/agent/tools/finish.py +423 -0
- structverify/agent/tools/meta_explore.py +267 -0
- structverify/agent/tools/query_rewriter.py +134 -0
- structverify/agent/tools/read_original.py +144 -0
- structverify/agent/tools/replan.py +365 -0
- structverify/agent/workspace.py +958 -0
- structverify/api.py +804 -0
- structverify/config/default.yaml +350 -0
- structverify/core/__init__.py +0 -0
- structverify/core/config_loader.py +30 -0
- structverify/core/pipeline.py +280 -0
- structverify/core/schemas.py +362 -0
- structverify/detection/__init__.py +26 -0
- structverify/detection/_config.py +163 -0
- structverify/detection/_llm.py +24 -0
- structverify/detection/candidate/__init__.py +1 -0
- structverify/detection/candidate/heuristic.py +60 -0
- structverify/detection/candidate/llm.py +51 -0
- structverify/detection/candidate_scorer.py +81 -0
- structverify/detection/claim_detector.py +164 -0
- structverify/detection/claims/__init__.py +1 -0
- structverify/detection/claims/worthiness.py +142 -0
- structverify/detection/domain/__init__.py +1 -0
- structverify/detection/domain/classify.py +84 -0
- structverify/detection/domain/preview.py +36 -0
- structverify/detection/domain/registry.py +99 -0
- structverify/detection/domain_classifier.py +75 -0
- structverify/detection/prompts/__init__.py +1 -0
- structverify/detection/prompts/candidate.py +38 -0
- structverify/detection/prompts/claim_worthiness.py +48 -0
- structverify/detection/prompts/domain.py +41 -0
- structverify/detection/prompts/schema.py +508 -0
- structverify/detection/prompts_loader.py +167 -0
- structverify/detection/schema/__init__.py +1 -0
- structverify/detection/schema/expand.py +83 -0
- structverify/detection/schema/induce.py +441 -0
- structverify/detection/schema/regenerate.py +162 -0
- structverify/detection/schema/temporal_hints.py +130 -0
- structverify/detection/schema/validate.py +193 -0
- structverify/detection/schema_inductor.py +112 -0
- structverify/detection/synthetic_generator.py +270 -0
- structverify/explanation/__init__.py +0 -0
- structverify/explanation/_config.py +18 -0
- structverify/explanation/_llm.py +25 -0
- structverify/explanation/explainer.py +183 -0
- structverify/explanation/fallback.py +29 -0
- structverify/explanation/formatters.py +75 -0
- structverify/explanation/prompts/__init__.py +1 -0
- structverify/explanation/prompts/match.py +27 -0
- structverify/explanation/prompts/mismatch.py +20 -0
- structverify/explanation/prompts/multihop.py +16 -0
- structverify/explanation/prompts/unverifiable.py +17 -0
- structverify/graph/__init__.py +0 -0
- structverify/graph/claim_graph.py +226 -0
- structverify/graph/document_graph.py +487 -0
- structverify/graph/graph_builder.py +238 -0
- structverify/graph/graph_multihop.py +335 -0
- structverify/graph/graph_store.py +281 -0
- structverify/graph/provenance.py +52 -0
- structverify/memory/__init__.py +44 -0
- structverify/memory/agent_memory.py +142 -0
- structverify/memory/embedder.py +69 -0
- structverify/memory/exemplar_store.py +241 -0
- structverify/memory/normalizer.py +91 -0
- structverify/memory/schema.py +119 -0
- structverify/memory/storage/__init__.py +29 -0
- structverify/memory/storage/jsonl_store.py +117 -0
- structverify/memory/working_memory.py +370 -0
- structverify/preprocessing/Dockerfile.scraper +27 -0
- structverify/preprocessing/__init__.py +0 -0
- structverify/preprocessing/extractor.py +574 -0
- structverify/preprocessing/pdf/__init__.py +16 -0
- structverify/preprocessing/pdf/fields.py +95 -0
- structverify/preprocessing/pdf/markdown.py +107 -0
- structverify/preprocessing/pdf/models.py +34 -0
- structverify/preprocessing/pdf/ocr.py +172 -0
- structverify/preprocessing/pdf/pipeline.py +74 -0
- structverify/preprocessing/pdf/reader.py +119 -0
- structverify/preprocessing/pdf/scoring.py +61 -0
- structverify/preprocessing/scraper_sandbox.py +561 -0
- structverify/preprocessing/segmenter.py +48 -0
- structverify/preprocessing/sir_builder.py +240 -0
- structverify/progress.py +591 -0
- structverify/retrieval/__init__.py +0 -0
- structverify/retrieval/base.py +208 -0
- structverify/retrieval/base_connector.py +85 -0
- structverify/retrieval/catalog_ranker.py +300 -0
- structverify/retrieval/catalog_search.py +583 -0
- structverify/retrieval/chunking.py +92 -0
- structverify/retrieval/custom_csv_source.py +386 -0
- structverify/retrieval/custom_db_source.py +396 -0
- structverify/retrieval/custom_docs_source.py +152 -0
- structverify/retrieval/dimension_resolver.py +281 -0
- structverify/retrieval/evidence_subgraph.py +63 -0
- structverify/retrieval/kosis_connector.py +1192 -0
- structverify/retrieval/kosis_relevance.py +142 -0
- structverify/retrieval/kosis_source.py +1541 -0
- structverify/retrieval/query_builder.py +72 -0
- structverify/retrieval/registry.py +133 -0
- structverify/retrieval/relevance_judge.py +141 -0
- structverify/retrieval/row_matcher.py +267 -0
- structverify/storage/__init__.py +0 -0
- structverify/storage/db_manager.py +157 -0
- structverify/storage/dwh_manager.py +92 -0
- structverify/storage/init_db.py +99 -0
- structverify/storage/raw_storage.py +29 -0
- structverify/training/__init__.py +26 -0
- structverify/training/curator.py +124 -0
- structverify/training/dataset.py +134 -0
- structverify/training/doctor.py +99 -0
- structverify/training/evalgate.py +96 -0
- structverify/training/generate.py +101 -0
- structverify/training/loop.py +116 -0
- structverify/training/recipe/train_mlx.py +99 -0
- structverify/training/recipe/train_qlora.py +104 -0
- structverify/training/tasks.py +79 -0
- structverify/utils/__init__.py +0 -0
- structverify/utils/embedding_client.py +248 -0
- structverify/utils/llm_client.py +809 -0
- structverify/utils/logger.py +81 -0
- structverify/verification/__init__.py +0 -0
- structverify/verification/_config.py +45 -0
- structverify/verification/adapters.py +405 -0
- structverify/verification/conformance.py +117 -0
- structverify/verification/decide_verdict.py +216 -0
- structverify/verification/decide_verdict_agent.py +454 -0
- structverify/verification/growth_diff.py +267 -0
- structverify/verification/row_match.py +345 -0
- structverify/verification/units.py +64 -0
- structverify/verification/verdict_thresholds.py +232 -0
- structverify/verification/verifier.py +84 -0
- structverify-0.3.0.dist-info/METADATA +903 -0
- structverify-0.3.0.dist-info/RECORD +168 -0
- structverify-0.3.0.dist-info/WHEEL +5 -0
- structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
- 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"
|