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,574 @@
1
+ """
2
+ preprocessing/extractor.py — 소스 유형별 텍스트 추출기 (디스패처)
3
+
4
+ 세부 구현은 유형별 하위 모듈에 위임:
5
+ * PDF → `preprocessing.pdf` (PyMuPDF + Docling + OCR 파이프라인, Markdown 반환)
6
+ * URL → Trafilatura (실패 시 LLMScraper 폴백)
7
+ * DOCX → python-docx (TODO) << 우선순위 후순위
8
+
9
+ [참고] Trafilatura (Barbaresi, ACL 2021) — https://github.com/adbar/trafilatura
10
+ URL 입력 시 광고/네비게이션 제거 후 본문만 추출 (이 파일에서 직접 사용).
11
+ PDF/OCR 관련 라이브러리(Docling·PyMuPDF·EasyOCR 등) 세부는 `preprocessing/pdf/` 참고.
12
+
13
+ [김예슬 - 2026-04-28 / v3]
14
+ - LLMScraper 클래스 추가 (trafilatura 실패 시 LLM 동적 스크래핑)
15
+ · trafilatura 1차 시도 → 실패/200자 미만이면 LLMScraper 2차 시도
16
+ · LLM(HCX-003)이 사이트 HTML 샘플 분석 후 스크래핑 코드 동적 생성
17
+ · 생성된 코드를 Docker 컨테이너에서 격리 실행 (sandbox_backend="docker" 기본값)
18
+ · 도메인별 성공 코드를 _SCRAPER_CACHE에 저장 → 같은 사이트 재요청 시 재사용
19
+ · 실패 시 에러 메시지를 LLM에 피드백 → 코드 수정 (Self-Refine, 최대 2회)
20
+ - _extract_from_url() 변경:
21
+ · v2: trafilatura만 사용 (실패 시 예외 발생)
22
+ · v3: _try_trafilatura() 1차 → 실패/짧으면 LLMScraper 2차
23
+ 어떤 경우든 Step 2~9 진행 가능한 텍스트 반환
24
+ - _try_trafilatura(): 기존 v2 로직을 별도 함수로 분리 (실패 시 빈 문자열)
25
+ - get_scraper_cache(), clear_scraper_cache(): 캐시 관리 유틸 추가
26
+
27
+ [설계 원칙 - v3]
28
+ - sandbox_backend 기본값 "docker" (보안 격리)
29
+ · Docker 없는 환경: _ensure_image() fallback → exec() 직접 실행
30
+ - LLMScraper는 Agent 행동이나 전처리(Step 1)에 귀속
31
+ · 추후 builder_agent.pretrain_domain()에서 주요 사이트 사전 크롤링 이관 예정
32
+ """
33
+ from __future__ import annotations
34
+
35
+ # [v3 김예슬] 추가 import
36
+ import re
37
+ from urllib.parse import urlparse
38
+ import httpx
39
+ import json
40
+ # trafilatura(URL 추출)·bs4(HTML 파싱)는 선택 의존성([url] extra) — 사용처에서 지연 import.
41
+
42
+ from structverify.core.schemas import SourceType
43
+ from structverify.preprocessing.pdf import extract_pdf_to_markdown
44
+ from structverify.utils.logger import get_logger
45
+
46
+ logger = get_logger(__name__)
47
+
48
+
49
+ # ── [v3 김예슬] 도메인별 스크래핑 코드 캐시 ──────────────────────────────────
50
+ # key: domain (예: "chosun.com"), value: 실행 가능한 Python 코드 문자열
51
+ # 프로세스 재시작 시 초기화 — 추후 scraper_cache.yaml로 영속화 예정
52
+
53
+ _SCRAPER_CACHE: dict[str, str] = {}
54
+
55
+ # [v3 김예슬] LLM 스크래퍼 코드 생성 프롬프트
56
+ _SCRAPER_GEN_PROMPT = """당신은 Python 웹 스크래핑 전문가입니다.
57
+ 아래 뉴스 기사 URL에서 본문 텍스트를 추출하는 Python 코드를 작성하세요.
58
+
59
+ URL: {url}
60
+ 사이트: {domain}
61
+ HTML 샘플 (앞 3000자):
62
+ {html_sample}
63
+
64
+ [요구사항]
65
+ - httpx와 BeautifulSoup4만 사용 (import httpx, from bs4 import BeautifulSoup)
66
+ - async def scrape(url: str) -> str: 함수로 작성
67
+ - 반환값: "# 제목\n\n본문 텍스트" 형식의 마크다운 문자열
68
+ - 본문은 .get_text(separator="\n", strip=True)로 HTML 태그 완전 제거
69
+ - 광고, 네비게이션, 댓글, 저작권 문구 제거
70
+ - User-Agent 헤더 포함 (Mozilla/5.0 Chrome 계열)
71
+ - 실패 시 빈 문자열 반환 (예외 발생 금지)
72
+ - HTML 태그(<p>, <div>, <span> 등)가 결과에 포함되면 안 됨
73
+
74
+ [주의]
75
+ - 코드만 반환 (설명 없이)
76
+ - ```python 코드블록 없이 순수 코드만
77
+ - import 문 포함
78
+
79
+ Python 코드:"""
80
+
81
+ # [v3 김예슬] 에러 피드백 기반 코드 수정 프롬프트 (Self-Refine)
82
+ _SCRAPER_REFINE_PROMPT = """앞서 작성한 스크래핑 코드가 아래 에러로 실패했습니다.
83
+ 에러를 분석하고 코드를 수정하세요.
84
+
85
+ URL: {url}
86
+ 사이트: {domain}
87
+
88
+ HTML 샘플 (앞 2000자):
89
+ {html_sample}
90
+
91
+ 실패한 코드:
92
+ {failed_code}
93
+
94
+ 에러:
95
+ {error}
96
+
97
+ [수정 방향]
98
+ - 에러 원인을 분석해서 해당 부분만 수정
99
+ - 조선일보 등 Next.js 사이트는 script 태그 JSON에서 본문 추출 시도
100
+ 예: soup.find_all("script")[0].string → json.loads() → description/articleBody 필드
101
+ - try/except로 각 selector 실패를 안전하게 처리
102
+ - 반환값에 HTML 태그가 포함되면 안 됨 (.get_text()로 텍스트만 추출)
103
+ - 결과 형식: "# 제목\n\n순수 텍스트 본문" (마크다운)
104
+
105
+ 수정된 코드만 반환하세요 (설명 없이):"""
106
+
107
+
108
+ async def extract_text(source: str, source_type: SourceType) -> str:
109
+ """소스 유형에 따라 적절한 추출기를 호출하여 텍스트 반환"""
110
+ if source_type == SourceType.URL:
111
+ return await _extract_from_url(source)
112
+ elif source_type == SourceType.PDF:
113
+ return _extract_from_pdf(source)
114
+ elif source_type == SourceType.DOCX:
115
+ return _extract_from_docx(source)
116
+ elif source_type == SourceType.TEXT:
117
+ return source
118
+
119
+
120
+ async def _extract_from_url(url: str) -> str:
121
+ """
122
+ URL → 본문 텍스트 추출
123
+
124
+ [v2] trafilatura만 사용 (실패 시 예외)
125
+ [v3 김예슬] trafilatura 1차 → 실패/200자 미만이면 LLMScraper 2차
126
+ - 조선일보, 구독제 사이트 등 trafilatura 차단 사이트 대응
127
+ - LLMScraper가 Docker 격리 환경에서 코드 실행 (sandbox_backend="docker" 기본)
128
+ - 두 단계 모두 실패해도 Step 2~9 진행 가능한 텍스트 반환
129
+ """
130
+ # [v2] trafilatura 기존 로직 — _try_trafilatura()로 분리
131
+ result = _try_trafilatura(url)
132
+ if result and len(result.strip()) > 200:
133
+ logger.info(f"URL extracted successfully: {url}")
134
+ return result
135
+
136
+ # [v3 김예슬] trafilatura 실패/짧음 → LLMScraper 2차 시도
137
+ logger.warning(f"trafilatura 실패/짧음 → LLMScraper 시도: {url}")
138
+ scraper = LLMScraper()
139
+ result = await scraper.scrape(url)
140
+
141
+ if result and len(result.strip()) > 200:
142
+ logger.info(f"LLMScraper 성공: {url} ({len(result)}자)")
143
+ return result
144
+
145
+ logger.error(f"URL 추출 완전 실패: {url}")
146
+ return f"# 추출 실패\n\nURL: {url} — 본문을 가져올 수 없습니다."
147
+
148
+
149
+ def _try_trafilatura(url: str) -> str:
150
+ """
151
+ [v3 김예슬] trafilatura로 URL 본문 추출. 실패 시 빈 문자열 반환 (예외 발생 안 함).
152
+ """
153
+ try:
154
+ import trafilatura # 선택 의존성 (pip install "structverify[url]")
155
+ downloaded = trafilatura.fetch_url(url)
156
+ if not downloaded:
157
+ return ""
158
+ result = trafilatura.extract(
159
+ downloaded, # 본문 및 메타데이터 추출 / 1차: json
160
+ output_format="json",
161
+ include_comments=False,
162
+ include_links=False,
163
+ with_metadata=True,
164
+ include_tables=True,
165
+ )
166
+ if not result:
167
+ return ""
168
+ parsed_result = json.loads(result)
169
+ title = parsed_result.get('title', '')
170
+ date = parsed_result.get('date', '')
171
+ text = parsed_result.get('text', '')
172
+ header = f"# {title}\n날짜: {date}" if date else f"# {title}"
173
+ markdown = f"{header}\n\n{text}"
174
+ return markdown
175
+ except Exception as e:
176
+ logger.debug(f"trafilatura 예외: {e}")
177
+ return ""
178
+
179
+
180
+ def _extract_from_pdf(filepath: str) -> str:
181
+ """
182
+ PDF → Markdown 추출.
183
+ 구현은 `structverify.preprocessing.pdf` 하위 패키지에 위임.
184
+ 파이프라인: PyMuPDF 병렬 텍스트 → Docling JSON/HTML 스코어링 →
185
+ title/date/body 추출 → 이미지 crop OCR 인라인 → 스캔 OCR → Markdown
186
+ TODO[DONE]: fitz.open(filepath) → page.get_text("text") 구현
187
+ TODO[DONE]: 스캔 PDF OCR 폴백 (Tesseract / EasyOCR)
188
+ TODO[DONE]: Docling 연동으로 테이블 구조 인식 강화
189
+ """
190
+ return extract_pdf_to_markdown(filepath)
191
+
192
+
193
+ def _extract_from_docx(filepath: str) -> str:
194
+ """
195
+ DOCX → 문단/테이블 추출 (python-docx)
196
+ TODO: Document(filepath).paragraphs + .tables 파싱 구현
197
+ """
198
+ logger.warning(f"DOCX 추출 stub: {filepath}")
199
+ return f"[STUB] {filepath}"
200
+
201
+
202
+ # ══════════════════════════════════════════════════════════════════════════════
203
+ # [v3 김예슬] LLMScraper — trafilatura 실패 시 LLM 동적 스크래핑
204
+ # ══════════════════════════════════════════════════════════════════════════════
205
+
206
+ class LLMScraper:
207
+ """
208
+ LLM이 사이트별 스크래핑 코드를 동적으로 생성하고 Docker에서 격리 실행.
209
+
210
+ [v3 김예슬 - 2026-04-28]
211
+
212
+ 흐름:
213
+ 1) URL에서 도메인 추출 (예: chosun.com)
214
+ 2) _SCRAPER_CACHE에 해당 도메인 코드 있으면 Docker에서 바로 실행
215
+ 3) 없으면:
216
+ a) httpx로 raw HTML 가져오기 (앞 3000자만)
217
+ b) LLM(HCX-003)에게 HTML 샘플 전달 → 스크래핑 코드 생성
218
+ c) Docker 컨테이너 격리 실행 (sandbox_backend="docker" 기본)
219
+ d) 성공하면 _SCRAPER_CACHE[domain] = code 저장
220
+ e) 실패하면 에러 메시지를 LLM에 피드백 → 코드 수정 (Self-Refine, 최대 2회)
221
+ """
222
+
223
+ def __init__(self, config: dict | None = None):
224
+ self.config = config or {}
225
+ self.max_retry = 2
226
+ self.timeout = 30
227
+
228
+
229
+
230
+ async def _refine_scraper_code(
231
+ self, url: str, domain: str, failed_code: str, error: str,
232
+ html: str = "",
233
+ ) -> str:
234
+ """
235
+ [v3 김예슬] Self-Refine — 에러 피드백으로 스크래핑 코드 수정.
236
+
237
+ 실패한 코드 + 에러 메시지 + HTML 샘플을 LLM에 전달 → 수정된 코드 반환.
238
+ 조선일보 등 Next.js 사이트의 경우 script JSON 파싱 방향으로 유도.
239
+ """
240
+ from structverify.utils.llm_client import LLMClient
241
+ llm = LLMClient(config=self.config.get("llm", {}))
242
+
243
+ prompt = _SCRAPER_REFINE_PROMPT.format(
244
+ url=url,
245
+ domain=domain,
246
+ html_sample=html[:2000], # [v2에서 누락] html_sample 추가
247
+ failed_code=failed_code[:2000],
248
+ error=error[:500],
249
+ )
250
+ try:
251
+ code = await llm.generate(
252
+ prompt=prompt,
253
+ system_prompt="Python 웹 스크래핑 전문가. 에러를 분석해서 코드를 수정하세요.",
254
+ model_tier="heavy",
255
+ )
256
+ code = re.sub(r"```python\s*", "", code)
257
+ code = re.sub(r"```\s*", "", code)
258
+ return code.strip()
259
+ except Exception as e:
260
+ logger.error(f"코드 수정 실패: {e}")
261
+ return ""
262
+
263
+ async def _run_code(self, code: str, url: str) -> tuple[str, str]:
264
+ """
265
+ [v3 김예슬] 생성된 코드를 sandbox_backend에 따라 격리 실행.
266
+
267
+ sandbox_backend 기본값 "docker" (보안 격리).
268
+ "docker" — Docker 컨테이너 격리 실행 (기본)
269
+ "e2b" — E2B 클라우드 샌드박스
270
+ "exec" — exec() 직접 실행 (개발용, 보안 취약)
271
+
272
+ Returns:
273
+ (result, error) — 성공 시 error="", 실패 시 result=""
274
+
275
+ Docker 없는 환경: _ensure_image() fallback → exec() 직접 실행
276
+ """
277
+ from structverify.preprocessing.scraper_sandbox import run_scraper_sandboxed
278
+ backend = self.config.get("sandbox_backend", "docker") # 기본값 docker
279
+ return await run_scraper_sandboxed(code, url, backend=backend)
280
+
281
+
282
+ async def _generate_scraper_code(self, url: str, domain: str, html: str) -> str:
283
+ """LLM(HCX-003)에게 HTML 샘플을 주고 스크래핑 코드 생성 요청"""
284
+ from structverify.utils.llm_client import LLMClient
285
+ llm = LLMClient(config=self.config.get("llm", {}))
286
+
287
+ # [v2에서 버그] _SCRAPER_REFINE_PROMPT 잘못 사용 → _SCRAPER_GEN_PROMPT로 수정
288
+ prompt = _SCRAPER_GEN_PROMPT.format(
289
+ url=url,
290
+ domain=domain,
291
+ html_sample=html[:3000],
292
+ )
293
+ try:
294
+ code = await llm.generate(
295
+ prompt=prompt,
296
+ system_prompt="Python 웹 스크래핑 전문가. 실행 가능한 코드만 반환.",
297
+ model_tier="heavy", # HCX-003 — 코드 생성 정확도 중요
298
+ )
299
+ # 코드블록 마크다운 제거
300
+ code = re.sub(r"```python\s*", "", code)
301
+ code = re.sub(r"```\s*", "", code)
302
+ return code.strip()
303
+ except Exception as e:
304
+ logger.error(f"LLM 코드 생성 실패: {e}")
305
+ return ""
306
+
307
+ async def scrape(self, url: str) -> str:
308
+ """URL → MD 형식 텍스트 (캐시 히트 또는 LLM 생성 코드 실행)"""
309
+ domain = _extract_domain(url)
310
+
311
+ fixed_result = await _run_domain_scraper(domain, url)
312
+ if fixed_result and len(fixed_result.strip()) > 200:
313
+ logger.info(f"도메인 고정 스크래퍼 성공: {domain}")
314
+ return fixed_result
315
+
316
+ # 캐시 히트 → Docker에서 바로 실행
317
+ if domain in _SCRAPER_CACHE:
318
+ logger.info(f"스크래퍼 캐시 히트: {domain}")
319
+ result, _ = await self._run_code(_SCRAPER_CACHE[domain], url)
320
+ if result and len(result.strip()) > 200:
321
+ return result
322
+ logger.warning(f"캐시 코드 실패 → 재생성: {domain}")
323
+ del _SCRAPER_CACHE[domain]
324
+
325
+ # raw HTML 가져오기
326
+ html = await _fetch_raw_html(url, self.timeout)
327
+ if not html:
328
+ logger.error(f"HTML 가져오기 실패: {url}")
329
+ return ""
330
+
331
+ # 초기 코드 생성
332
+ code = await self._generate_scraper_code(url, domain, html)
333
+ if not code:
334
+ return ""
335
+
336
+ last_error = ""
337
+ for attempt in range(1, self.max_retry + 1):
338
+ if attempt > 1 and last_error:
339
+ # [v3 Self-Refine] 에러 메시지를 LLM에 피드백해서 코드 수정
340
+ logger.info(
341
+ f"에러 피드백 → 코드 수정 (시도 {attempt}): {domain} | "
342
+ f"에러: {last_error[:200]}"
343
+ )
344
+ code = await self._refine_scraper_code(url, domain, code, last_error, html=html)
345
+ if not code:
346
+ break
347
+
348
+ result, error = await self._run_code(code, url)
349
+
350
+ if result and len(result.strip()) > 200:
351
+ _SCRAPER_CACHE[domain] = code
352
+ logger.info(f"스크래퍼 캐시 저장: {domain}")
353
+ return result
354
+
355
+ # 빈 결과도 에러로 처리 → LLM 피드백에 활용
356
+ if not error and (not result or len(result.strip()) <= 200):
357
+ error = (
358
+ f"스크래핑 결과가 비어있습니다 ({len(result or '')}자). "
359
+ f"p태그 0개, article태그 없음. "
360
+ f"soup.find_all('script')[0].string을 json.loads()로 파싱 후 "
361
+ f"description 또는 articleBody 필드를 사용하세요."
362
+ )
363
+
364
+ last_error = error
365
+ logger.warning(f"스크래퍼 코드 실행 실패 (시도 {attempt}): {domain}")
366
+
367
+ return ""
368
+
369
+
370
+ # ── 도메인별 고정 스크래퍼 실행 ─────────────────────────
371
+
372
+ async def _run_domain_scraper(domain: str, url: str) -> str:
373
+ try:
374
+ if "chosun.com" in domain:
375
+ result = await _scrape_chosun_fixed(url)
376
+
377
+ if result and len(result.strip()) > 200:
378
+ return result
379
+
380
+ return ""
381
+
382
+ except Exception as e:
383
+ logger.warning(f"도메인 스크래퍼 실패 → LLM scraper fallback: {domain} — {e}")
384
+ return ""
385
+
386
+ async def _scrape_chosun_fixed(url: str) -> str:
387
+ headers = {
388
+ "User-Agent": (
389
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
390
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
391
+ "Chrome/120.0.0.0 Safari/537.36"
392
+ ),
393
+ "Accept": "text/html,application/xhtml+xml,*/*;q=0.9",
394
+ "Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
395
+ }
396
+
397
+ try:
398
+ async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
399
+ res = await client.get(url, headers=headers)
400
+ res.raise_for_status()
401
+ html = res.text
402
+ except Exception as e:
403
+ logger.warning(f"조선일보 HTML 요청 실패: {e}")
404
+ return ""
405
+
406
+ from bs4 import BeautifulSoup # 선택 의존성 (pip install "structverify[url]")
407
+ soup = BeautifulSoup(html, "html.parser")
408
+
409
+ title = ""
410
+ og_title = soup.find("meta", property="og:title")
411
+ if og_title and og_title.get("content"):
412
+ title = og_title["content"].strip()
413
+ elif soup.title:
414
+ title = soup.title.get_text(strip=True)
415
+
416
+ date = ""
417
+ for prop in ("article:published_time", "og:article:published_time"):
418
+ tag = soup.find("meta", property=prop)
419
+ if tag and tag.get("content"):
420
+ date = tag["content"].strip()
421
+ break
422
+
423
+ # 1) 브라우저 렌더링 후 DOM에 본문이 있는 경우
424
+ container = soup.select_one("section.article-body[itemprop='articleBody'], section.article-body")
425
+ if container:
426
+ paragraphs = container.select("p.article-body__content")
427
+ texts = [
428
+ p.get_text(separator=" ", strip=True)
429
+ for p in paragraphs
430
+ if p.get_text(strip=True)
431
+ ]
432
+ body = _clean_article_text("\n\n".join(texts))
433
+
434
+ logger.info(f"[chosun] article-body paragraphs={len(paragraphs)}, text_len={len(body)}")
435
+
436
+ if len(body) > 200:
437
+ header = f"# {title}\n날짜: {date}" if date else f"# {title}"
438
+ return f"{header}\n\n{body}"
439
+
440
+ # 2) httpx 원본 HTML에 들어있는 Fusion.globalContent.content_elements 파싱
441
+ fusion_body, fusion_date = _extract_chosun_fusion_body(html)
442
+ if fusion_body and len(fusion_body.strip()) > 200:
443
+ logger.info(f"[chosun] Fusion.globalContent body hit, text_len={len(fusion_body)}")
444
+ fusion_header = f"# {title}\n날짜: {fusion_date}" if fusion_date else f"# {title}"
445
+ return f"{fusion_header}\n\n{fusion_body}"
446
+
447
+ logger.warning("[chosun] section.article-body / Fusion.globalContent 본문 추출 실패")
448
+ return ""
449
+
450
+
451
+ def _extract_chosun_fusion_body(html: str) -> tuple[str, str]:
452
+ """
453
+ 조선일보 원본 HTML의 Fusion.globalContent.content_elements에서
454
+ type='text' 항목의 content를 추출한다.
455
+ Returns: (body, date) — 실패 시 ("", "")
456
+ """
457
+ match = re.search(
458
+ r"Fusion\.globalContent\s*=\s*(\{.*?\});Fusion\.globalContentConfig",
459
+ html,
460
+ flags=re.DOTALL,
461
+ )
462
+ if not match:
463
+ logger.warning("[chosun] Fusion.globalContent JSON not found")
464
+ return "", ""
465
+
466
+ raw_json = match.group(1)
467
+
468
+ try:
469
+ data = json.loads(raw_json)
470
+ except Exception as e:
471
+ logger.warning(f"[chosun] Fusion.globalContent JSON parse failed: {e}")
472
+ return "", ""
473
+
474
+ date = data.get("display_date", "") or data.get("publish_date", "") or data.get("first_publish_date", "")
475
+
476
+ elements = data.get("content_elements", [])
477
+ texts = []
478
+
479
+ for el in elements:
480
+ if not isinstance(el, dict):
481
+ continue
482
+ if el.get("type") != "text":
483
+ continue
484
+
485
+ content = el.get("content", "")
486
+ if not content:
487
+ continue
488
+
489
+ text = _html_to_plain_text(content)
490
+ if text:
491
+ texts.append(text)
492
+
493
+ body = "\n\n".join(texts)
494
+ return _clean_article_text(body), date
495
+
496
+
497
+ def _html_to_plain_text(fragment: str) -> str:
498
+ """HTML fragment를 순수 텍스트로 변환"""
499
+ from bs4 import BeautifulSoup # 선택 의존성 (pip install "structverify[url]")
500
+ soup = BeautifulSoup(fragment, "html.parser")
501
+ text = soup.get_text(separator=" ", strip=True)
502
+ return text.strip()
503
+
504
+ def _clean_article_text(text: str) -> str:
505
+ """기사 본문 텍스트 정리"""
506
+ if not text:
507
+ return ""
508
+
509
+ # HTML escape / 공백 정리
510
+ text = re.sub(r"\r\n|\r", "\n", text)
511
+ text = re.sub(r"\n{3,}", "\n\n", text)
512
+ text = re.sub(r"[ \t]{2,}", " ", text)
513
+
514
+ # 조선일보/언론사 공통 잡문구 제거
515
+ remove_patterns = [
516
+ r"Copyright.*?reserved\.",
517
+ r"무단 전재.*?재배포 금지",
518
+ r"기자\s*$",
519
+ r"구독.*?신청",
520
+ r"좋아요.*?공유",
521
+ ]
522
+
523
+ for pattern in remove_patterns:
524
+ text = re.sub(pattern, "", text, flags=re.IGNORECASE | re.DOTALL)
525
+
526
+ return text.strip()
527
+
528
+ # ── [v3 김예슬] 내부 헬퍼 ────────────────────────────────────────────────────
529
+
530
+ def _extract_domain(url: str) -> str:
531
+ """URL에서 도메인 추출. 예: 'www.chosun.com' → 'chosun.com'"""
532
+ parsed = urlparse(url)
533
+ netloc = parsed.netloc.lower()
534
+ if netloc.startswith("www."):
535
+ netloc = netloc[4:]
536
+ return netloc
537
+
538
+
539
+ async def _fetch_raw_html(url: str, timeout: int = 30) -> str:
540
+ """URL에서 raw HTML 가져오기 (크롤링 차단 우회 User-Agent 포함)"""
541
+ headers = {
542
+ "User-Agent": (
543
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
544
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
545
+ "Chrome/120.0.0.0 Safari/537.36"
546
+ ),
547
+ "Accept": "text/html,application/xhtml+xml,*/*;q=0.9",
548
+ "Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
549
+ }
550
+ try:
551
+ async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
552
+ resp = await client.get(url, headers=headers)
553
+ resp.raise_for_status()
554
+ return resp.text
555
+ except Exception as e:
556
+ logger.error(f"HTML 가져오기 실패: {url} — {e}")
557
+ return ""
558
+
559
+
560
+ # ── [v3 김예슬] 캐시 관리 유틸 ───────────────────────────────────────────────
561
+
562
+ def get_scraper_cache() -> dict[str, str]:
563
+ """현재 캐시 상태 반환 (디버깅/테스트용)"""
564
+ return dict(_SCRAPER_CACHE)
565
+
566
+
567
+ def clear_scraper_cache(domain: str | None = None) -> None:
568
+ """캐시 초기화. domain 지정 시 해당 도메인만 삭제"""
569
+ if domain:
570
+ _SCRAPER_CACHE.pop(domain, None)
571
+ logger.info(f"캐시 삭제: {domain}")
572
+ else:
573
+ _SCRAPER_CACHE.clear()
574
+ logger.info("전체 캐시 초기화")
@@ -0,0 +1,16 @@
1
+ """
2
+ preprocessing/pdf — PDF → Markdown 추출 파이프라인
3
+
4
+ 책임 분리:
5
+ models : 데이터 구조 (PageText, Extracted)
6
+ reader : PyMuPDF 텍스트/이미지 읽기 + Docling 구조화 추출
7
+ scoring : JSON vs HTML 소스 품질 스코어링
8
+ fields : 선택 소스에서 title / date / body 추출 + 정규식 폴백
9
+ ocr : OCR 백엔드 라우팅 (easyocr / tesseract / paddleocr)
10
+ + 이미지 블록 crop OCR + 스캔 페이지 전체 OCR
11
+ markdown : 최종 Markdown 직렬화 + 표 변환 + 이미지 OCR 인라인 삽입
12
+ pipeline : 위 모듈들을 엮는 오케스트레이터 — `extract_pdf_to_markdown`
13
+ """
14
+ from structverify.preprocessing.pdf.pipeline import extract_pdf_to_markdown
15
+
16
+ __all__ = ["extract_pdf_to_markdown"]
@@ -0,0 +1,95 @@
1
+ """
2
+ preprocessing/pdf/fields.py — 선택 소스에서 제목/작성일/본문 추출
3
+
4
+ * `extract_from_json` : Docling JSON 트리를 walk 해서 라벨 기반 추출
5
+ * `extract_from_html` : BeautifulSoup 로 태그 기반 추출
6
+ * `fallback_from_plain` : PyMuPDF 평문에서 정규식 폴백 (title/date)
7
+ """
8
+ from __future__ import annotations
9
+ import re
10
+
11
+ try:
12
+ from bs4 import BeautifulSoup
13
+ except ImportError:
14
+ BeautifulSoup = None # type: ignore
15
+
16
+ from structverify.preprocessing.pdf.models import Extracted
17
+
18
+
19
+ DATE_RX = re.compile(
20
+ r"(20\d{2}|19\d{2})[\s\-./년]\s*(\d{1,2})[\s\-./월]\s*(\d{1,2})"
21
+ )
22
+
23
+
24
+ def fallback_from_plain(plain: str, field_name: str) -> str:
25
+ """Docling 이 실패/부실할 때 PyMuPDF 평문에서 필드 추출."""
26
+ if field_name == "title":
27
+ lines = [l.strip() for l in plain.splitlines()[:15] if l.strip()]
28
+ return max(lines, key=len) if lines else ""
29
+ if field_name == "date":
30
+ m = DATE_RX.search(plain)
31
+ if m:
32
+ return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
33
+ return ""
34
+ return ""
35
+
36
+
37
+ def extract_from_json(j: dict) -> Extracted:
38
+ title = date = ""
39
+ body_parts: list[str] = []
40
+
41
+ def walk(node):
42
+ nonlocal title, date
43
+ if isinstance(node, dict):
44
+ t = (node.get("label") or node.get("type") or "").lower()
45
+ txt = node.get("text") or ""
46
+ if not title and t in ("title", "heading", "section_header") and txt:
47
+ title = txt.strip()
48
+ if not date and t in ("date", "published", "created") and txt:
49
+ date = txt.strip()
50
+ if txt and t not in ("footer", "header", "page_number"):
51
+ body_parts.append(txt)
52
+ for v in node.values():
53
+ walk(v)
54
+ elif isinstance(node, list):
55
+ for x in node:
56
+ walk(x)
57
+
58
+ walk(j)
59
+ return Extracted(title=title, date=date,
60
+ body="\n\n".join(body_parts), source_used="json")
61
+
62
+
63
+ def extract_from_html(h: str) -> Extracted:
64
+ if BeautifulSoup is None:
65
+ return Extracted(body=h, source_used="html-raw")
66
+
67
+ soup = BeautifulSoup(h, "lxml")
68
+ title_el = soup.find(["h1", "h2", "title"])
69
+ title = title_el.get_text(strip=True) if title_el else ""
70
+
71
+ date = ""
72
+ meta = soup.find("meta", attrs={"name": re.compile("date|published", re.I)})
73
+ if meta and meta.get("content"):
74
+ date = meta["content"].strip()
75
+ if not date and soup.find("time"):
76
+ date = soup.find("time").get_text(strip=True)
77
+ if not date:
78
+ m = DATE_RX.search(soup.get_text(" "))
79
+ if m:
80
+ date = f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}"
81
+
82
+ # 표는 여기서 바로 MD 로 바꾸지 않고 markdown 모듈에 위임
83
+ from structverify.preprocessing.pdf.markdown import table_to_md
84
+ parts: list[str] = []
85
+ for el in soup.find_all(["h1", "h2", "h3", "p", "li", "table"]):
86
+ if el is title_el:
87
+ continue
88
+ if el.name == "table":
89
+ parts.append(table_to_md(el))
90
+ else:
91
+ txt = el.get_text(" ", strip=True)
92
+ if txt:
93
+ parts.append(txt)
94
+ return Extracted(title=title, date=date,
95
+ body="\n\n".join(parts), source_used="html")