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