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,591 @@
1
+ """structverify.progress — 검증 진행상황 실시간 노출.
2
+
3
+ 라이브러리 안에서 검증이 도는 동안 진행상황을 4곳에 보여준다:
4
+ · 로컬 웹페이지 (의존성 0 — stdlib http.server + SSE. sv_platform UI 스타일)
5
+ · 터미널 라이브 진행바
6
+ · 터미널 로그 / 로그 파일 (기존 configure_logging)
7
+
8
+ 사용:
9
+ import structverify as sv
10
+ with sv.progress_dashboard(): # → http://localhost:8765 자동 오픈
11
+ report = engine.verify_file("report.pdf")
12
+
13
+ 설계: 파이프라인이 progress.emit(...)로 이벤트를 쏘고, 활성화(dashboard 컨텍스트)일
14
+ 때만 구독자(웹/터미널)에게 전달. 비활성 시 emit은 즉시 리턴 → 오버헤드 0.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import collections
19
+ import contextlib
20
+ import contextvars
21
+ import json
22
+ import logging
23
+ import queue
24
+ import re
25
+ import threading
26
+ import time
27
+ from typing import Any
28
+
29
+ # 현재 검증 중인 claim 컨텍스트({id, idx}). asyncio 태스크별로 격리되므로, 병렬로
30
+ # 처리되는 여러 claim의 로그가 섞여도 각 로그를 올바른 claim에 태깅할 수 있다.
31
+ claim_var: "contextvars.ContextVar" = contextvars.ContextVar("sv_claim", default=None)
32
+
33
+ # ── 이벤트 허브 ──────────────────────────────────────────────────────────────
34
+ _lock = threading.Lock()
35
+ _subscribers: "list[queue.Queue]" = []
36
+ _history: "list[dict]" = [] # 구조 이벤트(start/step/claims/claim/done) 전체 리플레이
37
+ _log_ring: "collections.deque[dict]" = collections.deque(maxlen=120) # 로그는 최근 것만 리플레이
38
+ _active = False
39
+ _terminal = False
40
+ _term_state: dict[str, Any] = {"total": 0, "done": 0, "match": 0, "mismatch": 0, "other": 0}
41
+
42
+
43
+ def is_active() -> bool:
44
+ return _active
45
+
46
+
47
+ def emit(kind: str, **data: Any) -> None:
48
+ """진행 이벤트 발행. 비활성이면 즉시 리턴(오버헤드 0).
49
+
50
+ kind: start | step | claims | claim | log | done
51
+ """
52
+ if not _active:
53
+ return
54
+ event = {"kind": kind, "ts": time.time(), **data}
55
+ with _lock:
56
+ if kind == "log":
57
+ _log_ring.append(event) # 로그는 링버퍼(최근 것만)
58
+ else:
59
+ _history.append(event)
60
+ subs = list(_subscribers)
61
+ for q in subs:
62
+ with contextlib.suppress(queue.Full):
63
+ q.put_nowait(event)
64
+ if _terminal:
65
+ _render_terminal(event) # log 이벤트는 claim별 버퍼링 → 완료 시 블록 출력
66
+
67
+
68
+ def subscribe() -> "queue.Queue":
69
+ q: "queue.Queue" = queue.Queue(maxsize=4000)
70
+ with _lock:
71
+ for e in _history: # 구조 이벤트 리플레이
72
+ with contextlib.suppress(queue.Full):
73
+ q.put_nowait(e)
74
+ for e in _log_ring: # 최근 로그 리플레이
75
+ with contextlib.suppress(queue.Full):
76
+ q.put_nowait(e)
77
+ _subscribers.append(q)
78
+ return q
79
+
80
+
81
+ def unsubscribe(q: "queue.Queue") -> None:
82
+ with _lock:
83
+ if q in _subscribers:
84
+ _subscribers.remove(q)
85
+
86
+
87
+ def _reset() -> None:
88
+ with _lock:
89
+ _history.clear()
90
+ _log_ring.clear()
91
+ _term_logs.clear()
92
+ _term_state.update(total=0, done=0, match=0, mismatch=0, other=0)
93
+
94
+
95
+ # ── 에이전트 로그 tap ────────────────────────────────────────────────────────
96
+ # 검증 중 파이프라인 로그(표 검색→조회→계산→판정 등 전 과정)를 웹 콘솔로 실시간 스트리밍.
97
+ # 핵심 라인만 통과시켜 원 JSON 덤프 같은 노이즈는 제외.
98
+ _LOG_KEYWORDS = (
99
+ "classify_domain", "프로파일", "detect_claims", "candidate 문장", "검증 가능",
100
+ "Level ", "스키마 유도", "action=", "catalog_search", "fetch_evidence", "fetch(",
101
+ "custom_db/agentic", "SQL=", "retrieve_evidence", "reflect]", "replan]", "finish]",
102
+ "agent_loop →", "verify_claim →", "generate_explanation", "temporal graph",
103
+ "successful_stat_id", "verified_facts", "후보", "폴백", "거부",
104
+ )
105
+ _LEVEL_CLS = {logging.WARNING: "warn", logging.ERROR: "bad"}
106
+
107
+
108
+ def _classify_log(msg: str, levelno: int) -> str:
109
+ if levelno >= logging.WARNING:
110
+ return _LEVEL_CLS.get(levelno, "warn")
111
+ if "→ match" in msg or "verify_claim → match" in msg or "agent_loop → match" in msg:
112
+ return "ok"
113
+ if "→ mismatch" in msg or "→ violation" in msg:
114
+ return "bad"
115
+ if any(k in msg for k in ("action=", "catalog_search", "fetch", "SQL=", "reflect]", "replan]", "finish]")):
116
+ return "act"
117
+ return "mut"
118
+
119
+
120
+ class _LogTap(logging.Handler):
121
+ """structverify 로그를 progress 이벤트로 흘리는 핸들러(대시보드 활성 시에만)."""
122
+
123
+ def emit(self, record: logging.LogRecord) -> None: # noqa: A003
124
+ if not _active:
125
+ return
126
+ try:
127
+ msg = record.getMessage()
128
+ except Exception: # noqa: BLE001
129
+ return
130
+ if record.levelno < logging.WARNING and not any(k in msg for k in _LOG_KEYWORDS):
131
+ return
132
+ if len(msg) > 260 or "RESPONSE START" in msg or "RESPONSE END" in msg:
133
+ return
134
+ # 한 줄만(멀티라인 덤프 방지)
135
+ line = msg.splitlines()[0][:240]
136
+ who = record.name.rsplit(".", 1)[-1]
137
+ ctx = claim_var.get() or {}
138
+ globals()["emit"](
139
+ "log", line=line, who=who, cls=_classify_log(msg, record.levelno),
140
+ claim_id=ctx.get("id"), claim_idx=ctx.get("idx"),
141
+ )
142
+
143
+
144
+ def _console_handlers():
145
+ """_CONF["handlers"] 중 stdout/stderr StreamHandler(파일 아님) 목록."""
146
+ import sys
147
+ from structverify.utils import logger as _svlog
148
+ out = []
149
+ for h in _svlog._CONF.get("handlers", []):
150
+ if (isinstance(h, logging.StreamHandler) and not isinstance(h, logging.FileHandler)
151
+ and getattr(h, "stream", None) in (sys.stdout, sys.stderr)):
152
+ out.append(h)
153
+ return out
154
+
155
+
156
+ def _attach_log_tap(quiet_console: bool = False):
157
+ """structverify 로거에 _LogTap을 붙여 검증 전 과정을 progress 이벤트로 흘린다.
158
+
159
+ 로거들은 propagate=False + 개별 핸들러라, 공유 핸들러 목록에 tap을 넣고 기존 로거에도
160
+ 직접 부착. retrieval 등 'noisy' 모듈이 ERROR로 억제되지 않게 verbose를 임시로 켠다.
161
+ quiet_console=True면 원본 콘솔 핸들러 레벨을 WARNING으로 올려 raw INFO 이중출력을
162
+ 막는다(포맷된 터미널 렌더만 보이게). 파일 로그는 그대로 INFO.
163
+ 반환: 해제에 필요한 상태. 실패 시 None.
164
+ """
165
+ tap = _LogTap()
166
+ tap.setLevel(logging.INFO)
167
+ tapped: list = []
168
+ prev_verbose = None
169
+ quieted: list = []
170
+ try:
171
+ from structverify.utils import logger as _svlog
172
+ prev_verbose = _svlog._CONF.get("verbose")
173
+ _svlog._CONF["verbose"] = True
174
+ if tap not in _svlog._CONF["handlers"]:
175
+ _svlog._CONF["handlers"].append(tap)
176
+ if quiet_console:
177
+ for h in _console_handlers():
178
+ quieted.append((h, h.level))
179
+ h.setLevel(logging.WARNING) # raw INFO 콘솔 억제 → 포맷 렌더만
180
+ for _nm, _lg in list(logging.root.manager.loggerDict.items()):
181
+ if _nm.startswith("structverify") and isinstance(_lg, logging.Logger):
182
+ _lg.addHandler(tap)
183
+ if _lg.level == logging.NOTSET or _lg.level > logging.INFO:
184
+ _lg.setLevel(logging.INFO)
185
+ tapped.append(_lg)
186
+ except Exception: # noqa: BLE001 — tap 실패해도 진행상황 노출은 동작
187
+ return None
188
+ return (tap, tapped, prev_verbose, quieted)
189
+
190
+
191
+ def _detach_log_tap(state) -> None:
192
+ if not state:
193
+ return
194
+ tap, tapped, prev_verbose, quieted = state
195
+ with contextlib.suppress(Exception):
196
+ from structverify.utils import logger as _svlog
197
+ if tap in _svlog._CONF["handlers"]:
198
+ _svlog._CONF["handlers"].remove(tap)
199
+ if prev_verbose is not None:
200
+ _svlog._CONF["verbose"] = prev_verbose
201
+ for _lg in tapped:
202
+ _lg.removeHandler(tap)
203
+ for h, lvl in quieted: # 콘솔 레벨 복원
204
+ h.setLevel(lvl)
205
+
206
+
207
+ # ── 터미널 렌더 (claim별 trace 블록, 색상) ───────────────────────────────────
208
+ _VERD_MARK = {"match": "✅", "mismatch": "❌", "unverifiable": "⚠️", "violation": "⛔", "compliant": "✅"}
209
+ _term_logs: dict[str, list] = {} # claim_id → [(cls, who, line)] 버퍼(완료 시 블록 출력)
210
+ # 로그 종류별 ANSI 색: act=cyan, ok=green, bad=red, warn=yellow, mut=grey
211
+ _ANSI = {"act": "36", "ok": "32", "bad": "31", "warn": "33", "mut": "90"}
212
+
213
+
214
+ def _color_on() -> bool:
215
+ import os
216
+ import sys
217
+ return sys.stdout.isatty() and not os.environ.get("NO_COLOR") and os.environ.get("SV_COLOR") != "0"
218
+
219
+
220
+ def _c(code: str, s: str) -> str:
221
+ return f"\033[{code}m{s}\033[0m" if _color_on() else s
222
+
223
+
224
+ def _render_terminal(event: dict) -> None:
225
+ k = event.get("kind")
226
+ if k == "start":
227
+ print(_c("1;36", f"\n🔎 StructVerify — 소스: {event.get('source', '?')}"), flush=True)
228
+ elif k == "step":
229
+ print(_c("90", f" ▸ {event.get('name', '')}"), flush=True)
230
+ elif k == "claims":
231
+ _term_state["total"] = event.get("count", 0)
232
+ print(_c("90", f" ▸ 주장 {event.get('count', 0)}개 탐지 — 검증 시작\n"), flush=True)
233
+ elif k == "log":
234
+ cid = event.get("claim_id")
235
+ entry = (event.get("cls", "mut"), event.get("who", ""), event.get("line", ""))
236
+ if cid:
237
+ _term_logs.setdefault(cid, []).append(entry) # claim별 버퍼(완료 시 함께 출력)
238
+ else:
239
+ cls, _who, line = entry # 준비 로그(도메인/탐지) — 즉시 dim
240
+ print(" " + _c(_ANSI.get(cls, "90"), f"· {line}"), flush=True)
241
+ elif k == "claim":
242
+ v = str(event.get("verdict", "") or "")
243
+ _term_state["done"] += 1
244
+ _term_state["match" if v == "match" else "mismatch" if v == "mismatch" else "other"] += 1
245
+ mark = _VERD_MARK.get(v, "•")
246
+ idx = event.get("index") or _term_state["done"]
247
+ claim = (event.get("claim", "") or "").replace("\n", " ").strip()[:74]
248
+ vcolor = "32" if v == "match" else "31" if v == "mismatch" else "33"
249
+ print(_c("1", f" {mark} [{idx}] {claim}"), flush=True)
250
+ for cls, who, line in _term_logs.pop(event.get("claim_id"), []): # 버퍼된 trace
251
+ _w = _c("90", f"{who:>16} ") if who else ""
252
+ print(" " + _w + _c(_ANSI.get(cls, "90"), line[:112]), flush=True)
253
+ val, unit = event.get("value"), event.get("unit") or ""
254
+ meta = f" 기준값 {val}{unit}" if val not in (None, "") else ""
255
+ print(" " + _c(vcolor, f"→ {v}{meta}") + "\n", flush=True)
256
+ elif k == "done":
257
+ m, mm, o = _term_state["match"], _term_state["mismatch"], _term_state["other"]
258
+ print(
259
+ _c("1", f"✔ 완료 — 총 {_term_state['done']}건 ")
260
+ + _c("32", f"✅ {m} ") + _c("31", f"❌ {mm} ") + _c("33", f"⚠️ {o}"),
261
+ flush=True,
262
+ )
263
+
264
+
265
+ # ── 로컬 웹 대시보드 (stdlib http.server + SSE) ──────────────────────────────
266
+ def _dashboard_html() -> str:
267
+ return _DASHBOARD_HTML
268
+
269
+
270
+ def _make_handler():
271
+ from http.server import BaseHTTPRequestHandler
272
+
273
+ class Handler(BaseHTTPRequestHandler):
274
+ def log_message(self, *a): # 서버 접속 로그 침묵
275
+ pass
276
+
277
+ def do_GET(self): # noqa: N802
278
+ if self.path.startswith("/events"):
279
+ self.send_response(200)
280
+ self.send_header("Content-Type", "text/event-stream")
281
+ self.send_header("Cache-Control", "no-cache")
282
+ self.send_header("Connection", "keep-alive")
283
+ self.end_headers()
284
+ q = subscribe()
285
+ try:
286
+ while True:
287
+ try:
288
+ ev = q.get(timeout=15)
289
+ except queue.Empty:
290
+ # keep-alive 코멘트
291
+ try:
292
+ self.wfile.write(b": ping\n\n")
293
+ self.wfile.flush()
294
+ except (BrokenPipeError, ConnectionResetError):
295
+ break
296
+ continue
297
+ try:
298
+ self.wfile.write(f"data: {json.dumps(ev, ensure_ascii=False)}\n\n".encode())
299
+ self.wfile.flush()
300
+ except (BrokenPipeError, ConnectionResetError):
301
+ break
302
+ finally:
303
+ unsubscribe(q)
304
+ else:
305
+ body = _dashboard_html().encode("utf-8")
306
+ self.send_response(200)
307
+ self.send_header("Content-Type", "text/html; charset=utf-8")
308
+ self.send_header("Content-Length", str(len(body)))
309
+ self.end_headers()
310
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError):
311
+ self.wfile.write(body)
312
+
313
+ return Handler
314
+
315
+
316
+ @contextlib.contextmanager
317
+ def progress_dashboard(
318
+ *, port: int = 8765, open_browser: bool = True, terminal: bool = True,
319
+ hold: bool = True, web: bool = True,
320
+ ):
321
+ """검증 진행상황을 노출하는 컨텍스트 — 로컬 웹페이지 / 터미널 / 끔 중 선택.
322
+
323
+ with sv.progress_dashboard(): # 웹 + 터미널 (기본)
324
+ with sv.progress_dashboard(web=False): # 터미널 진행바만 (로컬 페이지 X)
325
+ with sv.progress_dashboard(web=False, terminal=False): # 완전 조용
326
+
327
+ 환경변수 `SV_PROGRESS`로도 제어(코드 수정 없이): off | terminal | web.
328
+ 이 값이 있으면 web/terminal 인자를 덮어쓴다 — 로컬 확인을 원치 않는 사용자가
329
+ `export SV_PROGRESS=off`(또는 terminal)로 끌 수 있다.
330
+
331
+ Args:
332
+ port: 로컬 서버 포트 (기본 8765). 사용 중이면 다음 빈 포트로.
333
+ open_browser: 컨텍스트 진입 시 브라우저 자동 오픈 (web일 때만).
334
+ terminal: 터미널 라이브 진행바 표시.
335
+ hold: 검증 후에도 페이지 유지 (web일 때만). 대화형이면 Enter까지,
336
+ 비대화형이면 SV_DASHBOARD_HOLD 초.
337
+ web: 로컬 웹페이지(서버) 사용 여부. False면 터미널만.
338
+ """
339
+ global _active, _terminal
340
+ import os
341
+ import sys
342
+ from http.server import ThreadingHTTPServer
343
+ import webbrowser
344
+
345
+ # 환경변수 override — 로컬 확인 원치 않는 사용자가 코드 수정 없이 끌 수 있게.
346
+ _mode = os.environ.get("SV_PROGRESS", "").strip().lower()
347
+ if _mode in ("off", "none", "silent", "false", "0"):
348
+ web, terminal = False, False
349
+ elif _mode in ("terminal", "term", "cli", "tty"):
350
+ web, terminal = False, True
351
+ elif _mode in ("web", "browser", "dashboard", "page"):
352
+ web = True
353
+
354
+ # 웹 없이(터미널만 또는 조용).
355
+ if not web:
356
+ if not terminal:
357
+ yield None # 완전 조용 — 진행상황 노출 안 함
358
+ return
359
+ _reset()
360
+ _active = True
361
+ _terminal = True
362
+ _tap_state = _attach_log_tap(quiet_console=True) # 포맷 렌더만(raw 이중출력 억제)
363
+ try:
364
+ yield None
365
+ finally:
366
+ _active = False
367
+ _terminal = False
368
+ _detach_log_tap(_tap_state)
369
+ return
370
+
371
+ class _QuietServer(ThreadingHTTPServer):
372
+ daemon_threads = True
373
+
374
+ def handle_error(self, request, client_address):
375
+ # SSE 연결이 브라우저/컨텍스트 종료로 끊길 때 나는 소음 억제.
376
+ import sys
377
+ exc = sys.exc_info()[1]
378
+ if isinstance(exc, (ConnectionResetError, BrokenPipeError, ConnectionAbortedError)):
379
+ return
380
+ super().handle_error(request, client_address)
381
+
382
+ _reset()
383
+ # 포트 확보 (사용 중이면 +1 씩 최대 20회)
384
+ handler = _make_handler()
385
+ httpd = None
386
+ for p in range(port, port + 20):
387
+ try:
388
+ httpd = _QuietServer(("127.0.0.1", p), handler)
389
+ port = p
390
+ break
391
+ except OSError:
392
+ continue
393
+ if httpd is None:
394
+ raise RuntimeError("progress_dashboard: 빈 포트를 찾지 못했습니다")
395
+
396
+ t = threading.Thread(target=httpd.serve_forever, daemon=True)
397
+ t.start()
398
+ url = f"http://127.0.0.1:{port}"
399
+ _active = True
400
+ _terminal = terminal
401
+ # 웹+터미널이면 터미널 raw 콘솔은 억제(포맷 렌더만). 웹만이면 콘솔 그대로.
402
+ _tap_state = _attach_log_tap(quiet_console=terminal)
403
+ print(f"📊 진행상황 대시보드: {url}", flush=True)
404
+ if open_browser:
405
+ with contextlib.suppress(Exception):
406
+ webbrowser.open(url)
407
+ try:
408
+ yield url
409
+ finally:
410
+ _active = False
411
+ _terminal = False
412
+ _detach_log_tap(_tap_state)
413
+ # 검증이 끝나도 페이지를 볼 수 있게 서버를 유지(hold). _active=False라 새 이벤트는
414
+ # 안 오지만, 이미 쌓인 이벤트는 /events 리플레이로 새 탭에서도 완료 상태가 보인다.
415
+ if hold:
416
+ print(f"\n📊 대시보드 유지 중: {url}", flush=True)
417
+ try:
418
+ if sys.stdin and sys.stdin.isatty():
419
+ input(" 페이지 확인 후 Enter를 누르면 종료합니다… ")
420
+ else:
421
+ _sec = float(os.environ.get("SV_DASHBOARD_HOLD", "0") or 0)
422
+ if _sec > 0:
423
+ print(f" (비대화형 — {_sec:g}초 유지 후 종료)", flush=True)
424
+ time.sleep(_sec)
425
+ except (EOFError, KeyboardInterrupt):
426
+ pass
427
+ with contextlib.suppress(Exception):
428
+ httpd.shutdown()
429
+ httpd.server_close()
430
+
431
+
432
+ # 자체완결 HTML (SSE 구독 → claim별 로그 토글 렌더). 외부 리소스 없음.
433
+ _DASHBOARD_HTML = r"""<!doctype html>
434
+ <html lang="ko"><head><meta charset="utf-8"/>
435
+ <meta name="viewport" content="width=device-width, initial-scale=1"/>
436
+ <title>StructVerify 진행상황</title>
437
+ <style>
438
+ :root{--bg:#f6f7f9;--card:#fff;--ink:#111827;--mut:#6b7280;--faint:#9aa3b2;--line:#e6e8ee;
439
+ --navy:#1a3d6d;--ok:#059669;--okbg:#ecfdf5;--bad:#dc2626;--badbg:#fef2f2;--warn:#b45309;--warnbg:#fffbeb;--accent:#2563eb;
440
+ --logbg:#0d1220}
441
+ :root[data-theme="dark"]{--bg:#0f1420;--card:#161c2b;--ink:#e5e9f0;--mut:#9aa5b8;--faint:#6b7688;--line:#26314a;
442
+ --navy:#7aa2e3;--ok:#34d399;--okbg:#0c2a22;--bad:#f87171;--badbg:#2a1414;--warn:#fbbf24;--warnbg:#2a2110;--accent:#60a5fa;--logbg:#0a0e18}
443
+ @media(prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#0f1420;--card:#161c2b;--ink:#e5e9f0;--mut:#9aa5b8;--faint:#6b7688;--line:#26314a;
444
+ --navy:#7aa2e3;--ok:#34d399;--okbg:#0c2a22;--bad:#f87171;--badbg:#2a1414;--warn:#fbbf24;--warnbg:#2a2110;--accent:#60a5fa;--logbg:#0a0e18}}
445
+ *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);
446
+ font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Apple SD Gothic Neo","Malgun Gothic",sans-serif;
447
+ font-size:14px;line-height:1.55}
448
+ .wrap{max-width:900px;margin:0 auto;padding:26px 20px 70px}
449
+ h1{font-size:23px;font-weight:800;letter-spacing:-.02em;margin:0 0 2px}
450
+ .sub{color:var(--mut);font-size:13px;margin-bottom:18px}
451
+ .card{background:var(--card);border:1px solid var(--line);border-radius:13px;padding:16px 18px;margin-bottom:14px}
452
+ .row{display:flex;align-items:center;justify-content:space-between;gap:12px}
453
+ .step{font-weight:600;font-size:13.5px}.pct{color:var(--mut);font-variant-numeric:tabular-nums;font-size:13px}
454
+ .bar{height:6px;background:var(--line);border-radius:99px;overflow:hidden;margin:11px 0 3px}
455
+ .fill{height:100%;background:linear-gradient(90deg,var(--accent),var(--navy));border-radius:99px;width:0;transition:width .5s ease}
456
+ .steps{display:flex;flex-wrap:wrap;gap:7px 13px;margin-top:12px}
457
+ .st{display:flex;align-items:center;gap:6px;font-size:12.5px;color:var(--mut)}
458
+ .st .dot{width:13px;height:13px;border-radius:99px;border:2px solid var(--line);display:inline-block}
459
+ .st.on{color:var(--ink)}.st.on .dot{background:var(--accent);border-color:var(--accent)}
460
+ .st.done .dot{background:var(--ok);border-color:var(--ok)}
461
+ .tally{display:flex;gap:8px;margin:2px 0 14px;flex-wrap:wrap}
462
+ .pill{font-size:12px;font-weight:600;padding:3px 10px;border-radius:99px}
463
+ .pill.ok{color:var(--ok);background:var(--okbg)}.pill.bad{color:var(--bad);background:var(--badbg)}.pill.warn{color:var(--warn);background:var(--warnbg)}
464
+ h2{font-size:14.5px;font-weight:700;margin:20px 0 9px;display:flex;align-items:baseline;gap:8px}
465
+ h2 .n{font-size:12px;color:var(--mut);font-weight:500;font-variant-numeric:tabular-nums}
466
+ .claim{background:var(--card);border:1px solid var(--line);border-radius:12px;margin-bottom:9px;overflow:hidden;
467
+ animation:pop .22s ease}
468
+ @keyframes pop{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
469
+ .chead{display:flex;gap:11px;align-items:flex-start;padding:12px 14px;cursor:pointer;user-select:none}
470
+ .chead:hover{background:color-mix(in srgb,var(--accent) 5%,transparent)}
471
+ .badge{flex:none;font-size:11px;font-weight:700;padding:3px 8px;border-radius:7px;white-space:nowrap;margin-top:1px;min-width:52px;text-align:center}
472
+ .badge.ok{color:var(--ok);background:var(--okbg)}.badge.bad{color:var(--bad);background:var(--badbg)}
473
+ .badge.warn{color:var(--warn);background:var(--warnbg)}.badge.run{color:var(--accent);background:transparent;border:1px solid var(--line)}
474
+ .cmain{flex:1;min-width:0}
475
+ .ctext{font-size:13px;color:var(--ink)}.cmeta{font-size:12px;color:var(--mut);margin-top:3px}
476
+ .ctog{flex:none;font-size:11.5px;color:var(--faint);display:flex;align-items:center;gap:4px;white-space:nowrap;margin-top:2px}
477
+ .ctog .arw{transition:transform .18s ease;display:inline-block}
478
+ .claim.open .ctog .arw{transform:rotate(90deg)}
479
+ .spin{display:inline-block;width:11px;height:11px;border:2px solid var(--line);border-top-color:var(--accent);
480
+ border-radius:99px;animation:sp .7s linear infinite;vertical-align:-1px}
481
+ @keyframes sp{to{transform:rotate(360deg)}}
482
+ .clogs{display:none;background:var(--logbg);border-top:1px solid var(--line);
483
+ padding:9px 12px;max-height:280px;overflow-y:auto;
484
+ font-family:ui-monospace,SFMono-Regular,Menlo,"Consolas",monospace;font-size:11.5px;line-height:1.6}
485
+ .claim.open .clogs{display:block}
486
+ .clogs .empty{color:#5b6b8c;padding:3px 0}
487
+ .ll{display:flex;gap:9px;padding:1px 0;border-left:2px solid transparent;padding-left:8px;margin-left:-8px}
488
+ .ll .who{color:#566385;flex:none;min-width:96px;text-align:right;user-select:none;opacity:.85}
489
+ .ll .msg{white-space:pre-wrap;word-break:break-word;color:#c3ccdc}
490
+ .ll.act{border-left-color:#2b7fff}.ll.act .msg{color:#8fd3ff}
491
+ .ll.ok{border-left-color:#1f9d57}.ll.ok .msg{color:#78e6a0}
492
+ .ll.bad{border-left-color:#e0483a}.ll.bad .msg{color:#ff9b90}
493
+ .ll.warn{border-left-color:#d99a1a}.ll.warn .msg{color:#ffd479}
494
+ .done-banner{padding:10px 14px;border-radius:10px;background:var(--okbg);color:var(--ok);font-weight:600;font-size:13px;margin-bottom:14px;display:none}
495
+ .prep{margin-bottom:14px}
496
+ .idle{color:var(--mut);font-size:13px}
497
+ </style></head><body>
498
+ <div class="wrap">
499
+ <h1>StructVerify 검증 진행</h1>
500
+ <div class="sub" id="sub">연결 대기 중… 검증을 시작하면 실시간으로 표시됩니다.</div>
501
+ <div class="done-banner" id="banner"></div>
502
+ <div class="card">
503
+ <div class="row"><span class="step" id="step">준비 중…</span><span class="pct" id="pct">0%</span></div>
504
+ <div class="bar"><div class="fill" id="fill"></div></div>
505
+ <div class="steps" id="stepbar">
506
+ <div class="st" data-k="domain"><span class="dot"></span>도메인</div>
507
+ <div class="st" data-k="profile"><span class="dot"></span>소스 프로파일</div>
508
+ <div class="st" data-k="detect"><span class="dot"></span>주장 탐지</div>
509
+ <div class="st" data-k="verify"><span class="dot"></span>검증</div>
510
+ <div class="st" data-k="done"><span class="dot"></span>완료</div>
511
+ </div>
512
+ </div>
513
+ <div class="tally" id="tally"></div>
514
+
515
+ <div class="claim prep" id="prepcard" style="display:none">
516
+ <div class="chead" onclick="this.parentNode.classList.toggle('open')">
517
+ <span class="badge run">준비</span>
518
+ <div class="cmain"><div class="ctext">도메인 분류 · 소스 프로파일 · 주장 탐지 로그</div></div>
519
+ <span class="ctog"><span class="arw">▸</span> <span id="prepn">0</span></span>
520
+ </div>
521
+ <div class="clogs" id="preplogs"></div>
522
+ </div>
523
+
524
+ <h2>검증된 주장 <span class="n" id="cn"></span></h2>
525
+ <div id="claims"><div class="idle" id="idle">탐지가 끝나면 주장 카드가 채워집니다.</div></div>
526
+ </div>
527
+ <script>
528
+ const $=id=>document.getElementById(id);
529
+ function esc(s){return (s||"").replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));}
530
+ let total=0, done=0, t={match:0,mismatch:0,other:0}, prepN=0;
531
+ const cards={}; // claim_id → {el, logsEl, count, cntEl}
532
+ function setStep(k,label){
533
+ if(label)$("step").textContent=label;
534
+ let hit=false;
535
+ document.querySelectorAll(".st").forEach(el=>{const kk=el.dataset.k;
536
+ if(kk===k){el.classList.add("on");hit=true;el.classList.remove("done");}
537
+ else if(!hit){el.classList.add("done");el.classList.remove("on");}});
538
+ }
539
+ function pct(p){p=Math.max(0,Math.min(100,p));$("fill").style.width=p+"%";$("pct").textContent=Math.round(p)+"%";}
540
+ function tally(){$("tally").innerHTML=
541
+ `<span class="pill ok">✅ 일치 ${t.match}</span><span class="pill bad">❌ 불일치 ${t.mismatch}</span><span class="pill warn">⚠️ 보류 ${t.other}</span>`;
542
+ $("cn").textContent=total?`${done}/${total}`:`${done}`;}
543
+ const VB={match:["ok","일치"],mismatch:["bad","불일치"],unverifiable:["warn","보류"],compliant:["ok","적합"],violation:["bad","위반"]};
544
+ function makeCard(id,text,idx){
545
+ $("idle")?.remove();
546
+ const c=document.createElement("div");c.className="claim";c.id="card-"+id;
547
+ c.innerHTML=
548
+ `<div class="chead"><span class="badge run"><span class="spin"></span></span>`+
549
+ `<div class="cmain"><div class="ctext">[${idx}] ${esc(text)}</div><div class="cmeta" style="display:none"></div></div>`+
550
+ `<span class="ctog"><span class="arw">▸</span> 로그 <span class="cnt">0</span></span></div>`+
551
+ `<div class="clogs"><div class="empty">이 주장의 검증 로그…</div></div>`;
552
+ c.querySelector(".chead").onclick=()=>c.classList.toggle("open");
553
+ $("claims").appendChild(c);
554
+ cards[id]={el:c,logsEl:c.querySelector(".clogs"),count:0,cntEl:c.querySelector(".cnt"),badge:c.querySelector(".badge"),meta:c.querySelector(".cmeta")};
555
+ return cards[id];
556
+ }
557
+ function addLogLine(box,e){
558
+ const d=document.createElement("div");d.className="ll "+(e.cls||"");
559
+ d.innerHTML=`<span class="who">${esc(e.who)}</span><span class="msg">${esc(e.line)}</span>`;
560
+ box.appendChild(d);box.scrollTop=box.scrollHeight;
561
+ while(box.childNodes.length>400)box.removeChild(box.firstChild);
562
+ }
563
+ function routeLog(e){
564
+ if(e.claim_id && cards[e.claim_id]){
565
+ const c=cards[e.claim_id];c.logsEl.querySelector(".empty")?.remove();
566
+ addLogLine(c.logsEl,e);c.count++;c.cntEl.textContent=c.count;
567
+ }else{
568
+ $("prepcard").style.display="block";
569
+ addLogLine($("preplogs"),e);prepN++;$("prepn").textContent=prepN;
570
+ }
571
+ }
572
+ const es=new EventSource("/events");
573
+ es.onmessage=ev=>{const e=JSON.parse(ev.data);
574
+ if(e.kind==="start"){$("sub").textContent=`소스: ${e.source||"?"} · 검증 진행 중`;setStep("domain","도메인 분류…");pct(5);}
575
+ else if(e.kind==="step"){setStep(e.stage||"detect",e.name);if(e.pct!=null)pct(e.pct);}
576
+ else if(e.kind==="claims"){total=e.count||0;setStep("verify",`주장 ${total}개 검증 중…`);pct(35);
577
+ (e.claims||[]).forEach((c,i)=>{const id=(typeof c==="object")?c.id:("i"+i);const tx=(typeof c==="object")?c.text:c;
578
+ if(!cards[id])makeCard(id,tx,i+1);});tally();}
579
+ else if(e.kind==="log"){routeLog(e);}
580
+ else if(e.kind==="claim"){done++;const v=e.verdict;t[v==="match"?"match":v==="mismatch"?"mismatch":"other"]++;
581
+ const c=cards[e.claim_id]; if(c){const[cls,lab]=VB[v]||["run","..."];
582
+ c.badge.className="badge "+cls;c.badge.textContent=lab;
583
+ const val=(e.value!=null&&e.value!=="")?`기준값 ${e.value}${e.unit||""}`:"";
584
+ if(val){c.meta.style.display="block";c.meta.textContent=val;}}
585
+ tally();pct(total?35+60*done/total:70);}
586
+ else if(e.kind==="done"){setStep("done","검증 완료");pct(100);
587
+ const b=$("banner");b.style.display="block";
588
+ b.textContent=`✔ 완료 — 총 ${done}건 (✅ ${t.match} · ❌ ${t.mismatch} · ⚠️ ${t.other}) — 각 주장을 클릭하면 로그가 열립니다`;}
589
+ };
590
+ es.onerror=()=>{$("sub").textContent="검증 프로세스가 종료되었습니다. (결과는 유지됩니다)";};
591
+ </script></body></html>"""
File without changes