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,561 @@
1
+ """
2
+ preprocessing/scraper_sandbox.py — LLM 생성 스크래핑 코드 격리 실행 (Docker/E2B)
3
+
4
+ [김예슬 - 2026-04-28 / v3]
5
+ - SandboxRunner 클래스: LLM이 생성한 코드를 격리 환경에서 실행
6
+ · DockerSandbox: 로컬 Docker 컨테이너 격리 실행
7
+ - 네트워크 제한, 메모리 256m, CPU 0.5, 타임아웃 30초
8
+ - python:3.13-slim 이미지 사용
9
+ · E2BSandbox: E2B 클라우드 샌드박스 (실제 서비스용)
10
+ - E2B_API_KEY 환경변수 필요
11
+ - pip install e2b-code-interpreter
12
+
13
+ [보안 설계]
14
+
15
+ Docker 샌드박스:
16
+ 컨테이너 격리 → 호스트 파일시스템/프로세스 접근 불가
17
+ 네트워크 제한 → 지정된 URL만 접근 가능
18
+
19
+
20
+ [선택 기준]
21
+ 개발/테스트: DockerSandbox (로컬 Docker 데몬 필요)
22
+ 실제 서비스: E2BSandbox (E2B API 키 필요, 추가 비용)
23
+ config.sandbox_backend: "docker" | "e2b" | "exec" (기본값 exec, 개발용)
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import os
29
+ import subprocess
30
+ import tempfile
31
+ import textwrap
32
+ from typing import Any
33
+
34
+ from structverify.utils.logger import get_logger
35
+
36
+ logger = get_logger(__name__)
37
+
38
+ # 스크래핑 코드 실행용 Docker 이미지
39
+ # [김예슬 - 2026-04-28] 첫 실행 시 자동 빌드 (_ensure_image 참고)
40
+ # pip install 시점이 아니라 첫 실행 시점에 빌드 → Docker 없는 환경 pip install 가능
41
+ # Dockerfile.scraper는 패키지 내부(preprocessing/)에 포함되어 배포됨
42
+ SANDBOX_IMAGE = "structverify-scraper"
43
+ SANDBOX_IMAGE_FALLBACK = "python:3.13-slim"
44
+
45
+ # SANDBOX_IMAGE 폴백 시 컨테이너 내 사전 설치 패키지
46
+ # structverify-scraper 이미지에는 이미 설치되어 있어 불필요
47
+ SANDBOX_SETUP = "pip install httpx beautifulsoup4 -q"
48
+
49
+ # 이미지 빌드 완료 여부 캐시 (프로세스 내 중복 빌드 방지)
50
+ _IMAGE_READY: bool = False
51
+
52
+ # 컨테이너 리소스 제한
53
+ SANDBOX_MEMORY = "256m"
54
+ SANDBOX_CPUS = "0.5"
55
+ SANDBOX_TIMEOUT = 30 # 초
56
+
57
+
58
+ # ── 메인 실행 함수 ────────────────────────────────────────────────────────────
59
+
60
+ async def run_scraper_sandboxed(
61
+ code: str,
62
+ url: str,
63
+ backend: str = "exec", # "exec" | "docker" | "e2b"
64
+ ) -> str:
65
+ """
66
+ LLM이 생성한 스크래핑 코드를 지정된 백엔드에서 격리 실행.
67
+
68
+ Args:
69
+ code: LLM이 생성한 Python 코드 (async def scrape(url) 포함)
70
+ url: 스크래핑할 뉴스 URL
71
+ backend: 실행 환경
72
+ "exec" — exec() 직접 실행 (개발용, 보안 취약)
73
+ "docker" — Docker 컨테이너 격리 (로컬 서비스용)
74
+ "e2b" — E2B 클라우드 샌드박스 (실제 서비스용)
75
+
76
+ Returns:
77
+ MD 형식 텍스트 또는 빈 문자열 (실패 시)
78
+ """
79
+ if backend == "docker":
80
+ result, error = await DockerSandbox().run(code, url)
81
+ return result, error
82
+ elif backend == "e2b":
83
+ result = await E2BSandbox().run(code, url)
84
+ return result, ""
85
+ else:
86
+ result = await _run_exec(code, url)
87
+ return result, ""
88
+
89
+
90
+ # ── Docker 샌드박스 ───────────────────────────────────────────────────────────
91
+
92
+ class DockerSandbox:
93
+ """
94
+ 로컬 Docker 컨테이너에서 스크래핑 코드 격리 실행.
95
+
96
+ 보안:
97
+ - 컨테이너 파일시스템 격리 (호스트 마운트 없음)
98
+ - 메모리/CPU 제한
99
+ - 타임아웃 강제 종료
100
+ - 네트워크: 기본 bridge (외부 인터넷 접근 가능, 호스트 내부망 차단)
101
+
102
+ 전제조건:
103
+ - Docker 데몬 실행 중
104
+ - docker 명령어 PATH에 있음
105
+ """
106
+
107
+ async def run(self, code: str, url: str) -> str:
108
+ """코드를 Docker 컨테이너에서 실행하고 stdout 반환"""
109
+
110
+ # 실행할 완성 스크립트 (scrape(url) 호출 포함)
111
+ full_script = self._build_script(code, url)
112
+
113
+ with tempfile.NamedTemporaryFile(
114
+ mode="w",
115
+ prefix="structverify_", # cleanup_tmp_files()가 이 패턴으로 정리
116
+ suffix=".py",
117
+ delete=False,
118
+ encoding="utf-8",
119
+ ) as f:
120
+ f.write(full_script)
121
+ script_path = f.name
122
+
123
+ # 첫 실행 시 자동 빌드 (이미 빌드됐으면 바로 통과)
124
+ image = await _ensure_image()
125
+ setup_cmd = (
126
+ "python scraper.py"
127
+ if image == SANDBOX_IMAGE
128
+ else f"{SANDBOX_SETUP} && python scraper.py"
129
+ )
130
+
131
+ try:
132
+ # Docker 컨테이너 실행
133
+ # --rm: 종료 후 컨테이너 자동 삭제
134
+ # -v: 스크립트 파일만 read-only 마운트
135
+ # --network=bridge: 외부 인터넷 허용, 호스트 내부망 차단
136
+ # --memory, --cpus: 리소스 제한
137
+ cmd = [
138
+ "docker", "run", "--rm",
139
+ "--network=bridge",
140
+ f"--memory={SANDBOX_MEMORY}",
141
+ f"--cpus={SANDBOX_CPUS}",
142
+ "-v", f"{script_path}:/app/scraper.py:ro",
143
+ "--workdir=/app",
144
+ image,
145
+ "sh", "-c",
146
+ setup_cmd,
147
+ ]
148
+
149
+ logger.info(f"Docker 샌드박스 실행: {url}")
150
+
151
+ proc = await asyncio.create_subprocess_exec(
152
+ *cmd,
153
+ stdout=asyncio.subprocess.PIPE,
154
+ stderr=asyncio.subprocess.PIPE,
155
+ )
156
+
157
+ try:
158
+ stdout, stderr = await asyncio.wait_for(
159
+ proc.communicate(),
160
+ timeout=SANDBOX_TIMEOUT,
161
+ )
162
+ except asyncio.TimeoutError:
163
+ proc.kill()
164
+ logger.error(f"Docker 샌드박스 타임아웃 ({SANDBOX_TIMEOUT}초): {url}")
165
+ return "", "TimeoutError: 실행 시간 초과"
166
+
167
+ if proc.returncode != 0:
168
+ err_msg = stderr.decode(errors="replace").strip()
169
+ logger.warning(
170
+ f"Docker 샌드박스 오류 (returncode={proc.returncode}): "
171
+ f"{err_msg[:1000]}"
172
+ )
173
+ return "", err_msg # [v3] 에러 메시지 반환
174
+
175
+ result = stdout.decode("utf-8", errors="replace").strip()
176
+ logger.info(f"Docker 샌드박스 성공: {url} ({len(result)}자)")
177
+ return result, "" # [v3] 성공 시 에러 없음
178
+
179
+ except FileNotFoundError:
180
+ logger.error("Docker 명령어를 찾을 수 없음 — Docker 데몬 실행 여부 확인")
181
+ return "", "Docker 명령어 없음"
182
+ except Exception as e:
183
+ logger.error(f"Docker 샌드박스 예외: {e}")
184
+ return "", str(e)
185
+ finally:
186
+ # 임시 파일 삭제
187
+ try:
188
+ os.unlink(script_path)
189
+ except OSError:
190
+ pass
191
+
192
+ def _build_script(self, code: str, url: str) -> str:
193
+ """
194
+ LLM 생성 코드 + scrape(url) 호출 + 결과 print를 하나의 스크립트로 조합.
195
+ Docker 컨테이너 안에서 python script.py 로 실행됨.
196
+ """
197
+ return textwrap.dedent(f"""
198
+ import asyncio
199
+
200
+ {code}
201
+
202
+ async def main():
203
+ result = await scrape({url!r})
204
+ print(result or "", end="")
205
+
206
+ asyncio.run(main())
207
+ """).strip()
208
+
209
+
210
+ # ── E2B 샌드박스 ──────────────────────────────────────────────────────────────
211
+
212
+ class E2BSandbox:
213
+ """
214
+ E2B 클라우드 샌드박스에서 스크래핑 코드 격리 실행.
215
+
216
+ 실제 서비스용 — 로컬 Docker 없이 클라우드에서 완전 격리.
217
+ E2B는 코드 인터프리터 전용 샌드박스 서비스 (https://e2b.dev).
218
+
219
+ 전제조건:
220
+ pip install e2b-code-interpreter
221
+ 환경변수: E2B_API_KEY
222
+
223
+ 비용:
224
+ E2B 요금제에 따름 (무료 티어: 월 100시간)
225
+ """
226
+
227
+ async def run(self, code: str, url: str) -> str:
228
+ """E2B 샌드박스에서 코드 실행"""
229
+ try:
230
+ from e2b_code_interpreter import Sandbox
231
+ except ImportError:
232
+ logger.error(
233
+ "e2b-code-interpreter 미설치. "
234
+ "pip install e2b-code-interpreter 실행 후 E2B_API_KEY 환경변수 설정"
235
+ )
236
+ return ""
237
+
238
+ api_key = os.environ.get("E2B_API_KEY", "")
239
+ if not api_key:
240
+ logger.error("E2B_API_KEY 환경변수 미설정")
241
+ return ""
242
+
243
+ full_script = textwrap.dedent(f"""
244
+ import asyncio
245
+
246
+ {code}
247
+
248
+ async def main():
249
+ result = await scrape({url!r})
250
+ print(result or "", end="")
251
+
252
+ asyncio.run(main())
253
+ """).strip()
254
+
255
+ try:
256
+ logger.info(f"E2B 샌드박스 실행: {url}")
257
+
258
+ # E2B 샌드박스 생성 + 코드 실행
259
+ # Sandbox는 격리된 클라우드 VM 환경
260
+ sandbox = Sandbox(api_key=api_key)
261
+
262
+ # 패키지 설치
263
+ sandbox.commands.run("pip install httpx beautifulsoup4 -q")
264
+
265
+ # 스크래핑 코드 실행
266
+ execution = sandbox.run_code(full_script, timeout=SANDBOX_TIMEOUT)
267
+
268
+ sandbox.kill()
269
+
270
+ if execution.error:
271
+ logger.warning(f"E2B 실행 오류: {execution.error.value[:300]}")
272
+ return ""
273
+
274
+ result = "".join(
275
+ [str(log.line) for log in execution.logs.stdout]
276
+ ).strip()
277
+
278
+ logger.info(f"E2B 샌드박스 성공: {url} ({len(result)}자)")
279
+ return result
280
+
281
+ except Exception as e:
282
+ logger.error(f"E2B 샌드박스 예외: {e}")
283
+ return ""
284
+
285
+
286
+ # ── exec 직접 실행 (개발용) ───────────────────────────────────────────────────
287
+
288
+ async def _run_exec(code: str, url: str) -> str:
289
+ """
290
+ exec() 직접 실행 — 개발/테스트 전용.
291
+ 보안 격리 없음. 프로덕션에서는 사용 금지.
292
+ """
293
+ try:
294
+ namespace: dict[str, Any] = {}
295
+ exec(compile(code, "<llm_scraper>", "exec"), namespace)
296
+ scrape_fn = namespace.get("scrape")
297
+ if not scrape_fn:
298
+ return ""
299
+ result = await scrape_fn(url)
300
+ return str(result) if result else ""
301
+ except Exception as e:
302
+ logger.warning(f"exec 실행 실패: {e}")
303
+ return ""
304
+
305
+
306
+ # ══════════════════════════════════════════════════════════════════════════════
307
+ # [김예슬 - 2026-04-28] 정리(Cleanup) 유틸
308
+ # ══════════════════════════════════════════════════════════════════════════════
309
+
310
+ class SandboxCleanup:
311
+ """
312
+ Docker 컨테이너 / 임시 파일 / 메모리 캐시 정리.
313
+
314
+ [자동 정리]
315
+ - Docker 컨테이너: --rm 플래그로 실행 종료 즉시 자동 삭제
316
+ - 임시 스크립트 파일: DockerSandbox.run() finally 블록에서 즉시 삭제
317
+
318
+ [수동 정리 — 이 클래스 사용]
319
+ - 좀비 컨테이너: docker ps -a 에서 Exited 상태로 남은 컨테이너 강제 삭제
320
+ - dangling 이미지: 이름 없는 중간 레이어 이미지 정리
321
+ - 메모리 캐시: _SCRAPER_CACHE dict 초기화
322
+ - 임시 파일 누락분: /tmp/structverify_* 패턴 파일 삭제
323
+
324
+ 사용 예시:
325
+ # 파이프라인 종료 시
326
+ await SandboxCleanup.cleanup_all()
327
+
328
+ # 개발 중 캐시만 초기화
329
+ SandboxCleanup.clear_memory_cache()
330
+
331
+ # Docker만 정리
332
+ await SandboxCleanup.cleanup_docker()
333
+ """
334
+
335
+ @staticmethod
336
+ async def cleanup_all(clear_cache: bool = True) -> dict[str, int]:
337
+ """
338
+ 전체 정리 실행.
339
+
340
+ Returns:
341
+ 정리 결과 통계 dict
342
+ {
343
+ "containers_removed": N,
344
+ "tmp_files_removed": N,
345
+ "cache_cleared": bool,
346
+ }
347
+ """
348
+ stats: dict[str, int | bool] = {}
349
+
350
+ # 1) Docker 좀비 컨테이너 + dangling 이미지 정리
351
+ docker_stats = await SandboxCleanup.cleanup_docker()
352
+ stats.update(docker_stats)
353
+
354
+ # 2) 임시 파일 정리
355
+ tmp_count = SandboxCleanup.cleanup_tmp_files()
356
+ stats["tmp_files_removed"] = tmp_count
357
+
358
+ # 3) 메모리 캐시 초기화 (선택)
359
+ if clear_cache:
360
+ from structverify.preprocessing.extractor import clear_scraper_cache
361
+ clear_scraper_cache()
362
+ stats["cache_cleared"] = True
363
+ logger.info("메모리 캐시 초기화 완료")
364
+ else:
365
+ stats["cache_cleared"] = False
366
+
367
+ logger.info(f"전체 정리 완료: {stats}")
368
+ return stats
369
+
370
+ @staticmethod
371
+ async def cleanup_docker() -> dict[str, int]:
372
+ """
373
+ Docker 좀비 컨테이너 + dangling 이미지 정리.
374
+
375
+ - 좀비 컨테이너: --rm 실패 시 Exited 상태로 남은 컨테이너
376
+ - dangling 이미지: 빌드 중간에 생긴 이름 없는 레이어 이미지
377
+
378
+ Returns:
379
+ {"containers_removed": N, "images_removed": N}
380
+ """
381
+ stats = {"containers_removed": 0, "images_removed": 0}
382
+
383
+ # structverify-scraper 관련 좀비 컨테이너 삭제
384
+ # docker ps -a -q -f ancestor=structverify-scraper -f status=exited
385
+ try:
386
+ proc = await asyncio.create_subprocess_exec(
387
+ "docker", "ps", "-a", "-q",
388
+ "-f", "ancestor=structverify-scraper",
389
+ "-f", "status=exited",
390
+ stdout=asyncio.subprocess.PIPE,
391
+ stderr=asyncio.subprocess.PIPE,
392
+ )
393
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10)
394
+ container_ids = stdout.decode().strip().split()
395
+ container_ids = [c for c in container_ids if c]
396
+
397
+ if container_ids:
398
+ rm_proc = await asyncio.create_subprocess_exec(
399
+ "docker", "rm", "-f", *container_ids,
400
+ stdout=asyncio.subprocess.PIPE,
401
+ stderr=asyncio.subprocess.PIPE,
402
+ )
403
+ await asyncio.wait_for(rm_proc.communicate(), timeout=15)
404
+ stats["containers_removed"] = len(container_ids)
405
+ logger.info(f"좀비 컨테이너 {len(container_ids)}개 삭제")
406
+
407
+ except asyncio.TimeoutError:
408
+ logger.warning("Docker 컨테이너 정리 타임아웃")
409
+ except FileNotFoundError:
410
+ logger.debug("Docker 명령어 없음 — 정리 건너뜀")
411
+ except Exception as e:
412
+ logger.warning(f"Docker 컨테이너 정리 실패: {e}")
413
+
414
+ # dangling 이미지 정리 (이름 없는 중간 레이어)
415
+ # docker image prune -f
416
+ try:
417
+ proc = await asyncio.create_subprocess_exec(
418
+ "docker", "image", "prune", "-f",
419
+ stdout=asyncio.subprocess.PIPE,
420
+ stderr=asyncio.subprocess.PIPE,
421
+ )
422
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
423
+ output = stdout.decode()
424
+ # "Total reclaimed space: X.XXkB" 에서 삭제 여부 확인
425
+ if "Total reclaimed" in output:
426
+ logger.info(f"dangling 이미지 정리: {output.strip()}")
427
+ stats["images_removed"] = 1 # 개수보다 실행 여부
428
+ except Exception as e:
429
+ logger.debug(f"dangling 이미지 정리 건너뜀: {e}")
430
+
431
+ return stats
432
+
433
+ @staticmethod
434
+ def cleanup_tmp_files() -> int:
435
+ """
436
+ /tmp/structverify_*.py 임시 스크립트 파일 정리.
437
+
438
+ DockerSandbox.run()의 finally에서 즉시 삭제되지만
439
+ 예외 상황에서 누락된 파일을 정리.
440
+
441
+ Returns:
442
+ 삭제된 파일 수
443
+ """
444
+ import glob
445
+ import os
446
+
447
+ pattern = "/tmp/structverify_*.py"
448
+ files = glob.glob(pattern)
449
+ removed = 0
450
+
451
+ for f in files:
452
+ try:
453
+ os.unlink(f)
454
+ removed += 1
455
+ except OSError:
456
+ pass
457
+
458
+ if removed:
459
+ logger.info(f"임시 파일 {removed}개 삭제: {pattern}")
460
+
461
+ return removed
462
+
463
+ @staticmethod
464
+ def clear_memory_cache(domain: str | None = None) -> None:
465
+ """
466
+ _SCRAPER_CACHE 메모리 캐시 초기화.
467
+
468
+ Args:
469
+ domain: 특정 도메인만 삭제. None이면 전체 초기화.
470
+ """
471
+ from structverify.preprocessing.extractor import clear_scraper_cache
472
+ clear_scraper_cache(domain)
473
+
474
+ @staticmethod
475
+ def get_memory_cache_info() -> dict[str, Any]:
476
+ """현재 메모리 캐시 상태 반환 (디버깅용)"""
477
+ from structverify.preprocessing.extractor import get_scraper_cache
478
+ cache = get_scraper_cache()
479
+ return {
480
+ "cached_domains": list(cache.keys()),
481
+ "domain_count": len(cache),
482
+ "total_code_chars": sum(len(v) for v in cache.values()),
483
+ }
484
+
485
+
486
+ # ── 이미지 자동 빌드 ──────────────────────────────────────────────────────────
487
+
488
+ async def _ensure_image() -> str:
489
+ """
490
+ structverify-scraper Docker 이미지 없으면 자동 빌드.
491
+
492
+ [김예슬 - 2026-04-28]
493
+ pip install 시점이 아니라 첫 실행 시점에 빌드:
494
+ - Docker 없는 환경에서도 pip install 가능
495
+ - Docker 있는 환경에서만 빌드 시도
496
+ - 빌드 실패/Docker 없으면 SANDBOX_IMAGE_FALLBACK으로 폴백
497
+
498
+ Dockerfile.scraper 위치:
499
+ 패키지 내부(structverify/preprocessing/Dockerfile.scraper)에 포함 배포.
500
+ importlib.resources로 경로 자동 탐색.
501
+
502
+ Returns:
503
+ 사용할 이미지 이름 (SANDBOX_IMAGE 또는 SANDBOX_IMAGE_FALLBACK)
504
+ """
505
+ global _IMAGE_READY
506
+
507
+ if _IMAGE_READY:
508
+ return SANDBOX_IMAGE
509
+
510
+ try:
511
+ check = subprocess.run(
512
+ ["docker", "image", "inspect", SANDBOX_IMAGE],
513
+ capture_output=True, timeout=5,
514
+ )
515
+ if check.returncode == 0:
516
+ _IMAGE_READY = True
517
+ return SANDBOX_IMAGE
518
+ except FileNotFoundError:
519
+ logger.warning("Docker 명령어 없음 → exec() 폴백 사용")
520
+ return SANDBOX_IMAGE_FALLBACK
521
+ except Exception:
522
+ return SANDBOX_IMAGE_FALLBACK
523
+
524
+ logger.info(
525
+ f"{SANDBOX_IMAGE} 이미지 없음 → 자동 빌드 시작 "
526
+ f"(약 30~60초 소요, 이후 실행부터는 즉시 사용)..."
527
+ )
528
+
529
+ try:
530
+ import pathlib
531
+ dockerfile_dir = str(pathlib.Path(__file__).parent)
532
+
533
+ build_result = subprocess.run(
534
+ [
535
+ "docker", "build",
536
+ "-f", os.path.join(dockerfile_dir, "Dockerfile.scraper"),
537
+ "-t", SANDBOX_IMAGE,
538
+ dockerfile_dir,
539
+ ],
540
+ capture_output=True,
541
+ timeout=120,
542
+ )
543
+
544
+ if build_result.returncode == 0:
545
+ _IMAGE_READY = True
546
+ logger.info(f"{SANDBOX_IMAGE} 이미지 빌드 완료")
547
+ return SANDBOX_IMAGE
548
+
549
+ logger.warning(
550
+ f"이미지 빌드 실패 (returncode={build_result.returncode}) "
551
+ f"→ {SANDBOX_IMAGE_FALLBACK} 폴백\n"
552
+ f"{build_result.stderr.decode()[:300]}"
553
+ )
554
+ return SANDBOX_IMAGE_FALLBACK
555
+
556
+ except subprocess.TimeoutExpired:
557
+ logger.warning("이미지 빌드 타임아웃 → 폴백")
558
+ return SANDBOX_IMAGE_FALLBACK
559
+ except Exception as e:
560
+ logger.warning(f"이미지 빌드 예외: {e} → 폴백")
561
+ return SANDBOX_IMAGE_FALLBACK
@@ -0,0 +1,48 @@
1
+ """
2
+ preprocessing/segmenter.py — 한국어 문장 분리 + 수치 탐지
3
+
4
+ [참고] kss (Korean Sentence Splitter) — https://github.com/hyunwoongko/kss
5
+ 한국어 특화 문장 분리. 약어/수치 포함 문장에서도 높은 정확도.
6
+ """
7
+ from __future__ import annotations
8
+ import re
9
+ from structverify.core.schemas import Sentence
10
+ from structverify.utils.logger import get_logger
11
+
12
+ logger = get_logger(__name__)
13
+
14
+ NUMERIC_PATTERN = re.compile(
15
+ r"\d[\d,.]*\s*(%|퍼센트|원|만|억|조|ha|km|m²|건|명|가구|개|톤|배)", re.UNICODE)
16
+
17
+
18
+ def split_sentences(text: str) -> list[Sentence]:
19
+ """텍스트 → 문장 리스트 (수치 포함 여부 태깅 + 그래프 앵커 ID 부여)"""
20
+ raw = _split_korean(text)
21
+ sentences, offset = [], 0
22
+ for idx, s in enumerate(raw):
23
+ s = s.strip()
24
+ if not s:
25
+ continue
26
+ start = text.find(s, offset)
27
+ end = start + len(s) if start >= 0 else offset + len(s)
28
+ anchor_id = f"node:s{idx:04d}"
29
+ sentences.append(Sentence(
30
+ sent_id=f"s{idx:04d}", text=s,
31
+ char_offset_start=max(start, 0), char_offset_end=end,
32
+ # 실제 필드는 has_numeric_surface — has_numeric은 읽기 전용 property라
33
+ # 생성자에 넘기면 pydantic이 무시해 탐지가 죽는다(항상 False).
34
+ has_numeric_surface=bool(NUMERIC_PATTERN.search(s)),
35
+ graph_anchor_id=anchor_id,
36
+ ))
37
+ offset = end
38
+ return sentences
39
+
40
+
41
+ def _split_korean(text: str) -> list[str]:
42
+ """kss 우선 사용, 없으면 정규표현식 폴백"""
43
+ try:
44
+ import kss
45
+ return kss.split_sentences(text)
46
+ except ImportError:
47
+ logger.warning("kss 미설치 — 정규표현식 폴백")
48
+ return [s.strip() for s in re.split(r"(?<=[.!?])\s+", text) if s.strip()]