structverify 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- structverify/__init__.py +83 -0
- structverify/adaptation/__init__.py +0 -0
- structverify/adaptation/adapter_trainer.py +341 -0
- structverify/adaptation/feedback_store.py +31 -0
- structverify/adaptation/kosis_crawler.py +317 -0
- structverify/adaptation/sample_builder.py +149 -0
- structverify/adaptation/synthetic_generator.py +320 -0
- structverify/adaptation/update_embeddings.py +178 -0
- structverify/agent/__init__.py +21 -0
- structverify/agent/builder_agent.py +226 -0
- structverify/agent/conformance_agent.py +171 -0
- structverify/agent/dependency_planner.py +151 -0
- structverify/agent/indexing_agent.py +153 -0
- structverify/agent/indexing_planner.py +169 -0
- structverify/agent/integration_example.py +182 -0
- structverify/agent/loop.py +1165 -0
- structverify/agent/memory.py +207 -0
- structverify/agent/planner.py +817 -0
- structverify/agent/prompts/__init__.py +15 -0
- structverify/agent/prompts/planner_prompts.py +219 -0
- structverify/agent/prompts/reflect_prompts.py +387 -0
- structverify/agent/reflect.py +227 -0
- structverify/agent/runtime_agent.py +1272 -0
- structverify/agent/schemas.py +262 -0
- structverify/agent/source_profiler.py +229 -0
- structverify/agent/tools/__init__.py +64 -0
- structverify/agent/tools/base.py +222 -0
- structverify/agent/tools/calculate.py +244 -0
- structverify/agent/tools/catalog_search.py +859 -0
- structverify/agent/tools/deep_explore.py +293 -0
- structverify/agent/tools/explore_catalog.py +423 -0
- structverify/agent/tools/fetch_evidence.py +922 -0
- structverify/agent/tools/finish.py +423 -0
- structverify/agent/tools/meta_explore.py +267 -0
- structverify/agent/tools/query_rewriter.py +134 -0
- structverify/agent/tools/read_original.py +144 -0
- structverify/agent/tools/replan.py +365 -0
- structverify/agent/workspace.py +958 -0
- structverify/api.py +804 -0
- structverify/config/default.yaml +350 -0
- structverify/core/__init__.py +0 -0
- structverify/core/config_loader.py +30 -0
- structverify/core/pipeline.py +280 -0
- structverify/core/schemas.py +362 -0
- structverify/detection/__init__.py +26 -0
- structverify/detection/_config.py +163 -0
- structverify/detection/_llm.py +24 -0
- structverify/detection/candidate/__init__.py +1 -0
- structverify/detection/candidate/heuristic.py +60 -0
- structverify/detection/candidate/llm.py +51 -0
- structverify/detection/candidate_scorer.py +81 -0
- structverify/detection/claim_detector.py +164 -0
- structverify/detection/claims/__init__.py +1 -0
- structverify/detection/claims/worthiness.py +142 -0
- structverify/detection/domain/__init__.py +1 -0
- structverify/detection/domain/classify.py +84 -0
- structverify/detection/domain/preview.py +36 -0
- structverify/detection/domain/registry.py +99 -0
- structverify/detection/domain_classifier.py +75 -0
- structverify/detection/prompts/__init__.py +1 -0
- structverify/detection/prompts/candidate.py +38 -0
- structverify/detection/prompts/claim_worthiness.py +48 -0
- structverify/detection/prompts/domain.py +41 -0
- structverify/detection/prompts/schema.py +508 -0
- structverify/detection/prompts_loader.py +167 -0
- structverify/detection/schema/__init__.py +1 -0
- structverify/detection/schema/expand.py +83 -0
- structverify/detection/schema/induce.py +441 -0
- structverify/detection/schema/regenerate.py +162 -0
- structverify/detection/schema/temporal_hints.py +130 -0
- structverify/detection/schema/validate.py +193 -0
- structverify/detection/schema_inductor.py +112 -0
- structverify/detection/synthetic_generator.py +270 -0
- structverify/explanation/__init__.py +0 -0
- structverify/explanation/_config.py +18 -0
- structverify/explanation/_llm.py +25 -0
- structverify/explanation/explainer.py +183 -0
- structverify/explanation/fallback.py +29 -0
- structverify/explanation/formatters.py +75 -0
- structverify/explanation/prompts/__init__.py +1 -0
- structverify/explanation/prompts/match.py +27 -0
- structverify/explanation/prompts/mismatch.py +20 -0
- structverify/explanation/prompts/multihop.py +16 -0
- structverify/explanation/prompts/unverifiable.py +17 -0
- structverify/graph/__init__.py +0 -0
- structverify/graph/claim_graph.py +226 -0
- structverify/graph/document_graph.py +487 -0
- structverify/graph/graph_builder.py +238 -0
- structverify/graph/graph_multihop.py +335 -0
- structverify/graph/graph_store.py +281 -0
- structverify/graph/provenance.py +52 -0
- structverify/memory/__init__.py +44 -0
- structverify/memory/agent_memory.py +142 -0
- structverify/memory/embedder.py +69 -0
- structverify/memory/exemplar_store.py +241 -0
- structverify/memory/normalizer.py +91 -0
- structverify/memory/schema.py +119 -0
- structverify/memory/storage/__init__.py +29 -0
- structverify/memory/storage/jsonl_store.py +117 -0
- structverify/memory/working_memory.py +370 -0
- structverify/preprocessing/Dockerfile.scraper +27 -0
- structverify/preprocessing/__init__.py +0 -0
- structverify/preprocessing/extractor.py +574 -0
- structverify/preprocessing/pdf/__init__.py +16 -0
- structverify/preprocessing/pdf/fields.py +95 -0
- structverify/preprocessing/pdf/markdown.py +107 -0
- structverify/preprocessing/pdf/models.py +34 -0
- structverify/preprocessing/pdf/ocr.py +172 -0
- structverify/preprocessing/pdf/pipeline.py +74 -0
- structverify/preprocessing/pdf/reader.py +119 -0
- structverify/preprocessing/pdf/scoring.py +61 -0
- structverify/preprocessing/scraper_sandbox.py +561 -0
- structverify/preprocessing/segmenter.py +48 -0
- structverify/preprocessing/sir_builder.py +240 -0
- structverify/progress.py +591 -0
- structverify/retrieval/__init__.py +0 -0
- structverify/retrieval/base.py +208 -0
- structverify/retrieval/base_connector.py +85 -0
- structverify/retrieval/catalog_ranker.py +300 -0
- structverify/retrieval/catalog_search.py +583 -0
- structverify/retrieval/chunking.py +92 -0
- structverify/retrieval/custom_csv_source.py +386 -0
- structverify/retrieval/custom_db_source.py +396 -0
- structverify/retrieval/custom_docs_source.py +152 -0
- structverify/retrieval/dimension_resolver.py +281 -0
- structverify/retrieval/evidence_subgraph.py +63 -0
- structverify/retrieval/kosis_connector.py +1192 -0
- structverify/retrieval/kosis_relevance.py +142 -0
- structverify/retrieval/kosis_source.py +1541 -0
- structverify/retrieval/query_builder.py +72 -0
- structverify/retrieval/registry.py +133 -0
- structverify/retrieval/relevance_judge.py +141 -0
- structverify/retrieval/row_matcher.py +267 -0
- structverify/storage/__init__.py +0 -0
- structverify/storage/db_manager.py +157 -0
- structverify/storage/dwh_manager.py +92 -0
- structverify/storage/init_db.py +99 -0
- structverify/storage/raw_storage.py +29 -0
- structverify/training/__init__.py +26 -0
- structverify/training/curator.py +124 -0
- structverify/training/dataset.py +134 -0
- structverify/training/doctor.py +99 -0
- structverify/training/evalgate.py +96 -0
- structverify/training/generate.py +101 -0
- structverify/training/loop.py +116 -0
- structverify/training/recipe/train_mlx.py +99 -0
- structverify/training/recipe/train_qlora.py +104 -0
- structverify/training/tasks.py +79 -0
- structverify/utils/__init__.py +0 -0
- structverify/utils/embedding_client.py +248 -0
- structverify/utils/llm_client.py +809 -0
- structverify/utils/logger.py +81 -0
- structverify/verification/__init__.py +0 -0
- structverify/verification/_config.py +45 -0
- structverify/verification/adapters.py +405 -0
- structverify/verification/conformance.py +117 -0
- structverify/verification/decide_verdict.py +216 -0
- structverify/verification/decide_verdict_agent.py +454 -0
- structverify/verification/growth_diff.py +267 -0
- structverify/verification/row_match.py +345 -0
- structverify/verification/units.py +64 -0
- structverify/verification/verdict_thresholds.py +232 -0
- structverify/verification/verifier.py +84 -0
- structverify-0.3.0.dist-info/METADATA +903 -0
- structverify-0.3.0.dist-info/RECORD +168 -0
- structverify-0.3.0.dist-info/WHEEL +5 -0
- structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
- structverify-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
utils/logger.py — 로깅 + 중앙 설정(configure_logging)
|
|
3
|
+
|
|
4
|
+
기본(설정 안 함): 각 모듈 로거가 stdout 핸들러로 INFO 출력.
|
|
5
|
+
`configure_logging(...)` 호출 시: 콘솔(+선택적 파일)에 깔끔한 포맷으로 출력하고,
|
|
6
|
+
verbose=False면 내부 상세 로그는 숨기고 검증/에이전트 핵심 로그만 남긴다.
|
|
7
|
+
"""
|
|
8
|
+
import logging
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
|
12
|
+
|
|
13
|
+
_DEFAULT_FMT = logging.Formatter(
|
|
14
|
+
"[%(asctime)s] %(levelname)s %(name)s — %(message)s", datefmt="%Y-%m-%d %H:%M:%S")
|
|
15
|
+
|
|
16
|
+
# verbose=False일 때 조용히 할 내부 모듈 (핵심 결과 로그만 남긴다)
|
|
17
|
+
_NOISY = (
|
|
18
|
+
"structverify.utils", "structverify.retrieval", "structverify.graph",
|
|
19
|
+
"structverify.storage", "structverify.preprocessing", "structverify.memory",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# 중앙 로깅 설정 상태
|
|
23
|
+
_CONF: dict = {"on": False, "level": logging.INFO, "verbose": False,
|
|
24
|
+
"file": None, "handlers": []}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _apply(logger: logging.Logger, name: str) -> None:
|
|
28
|
+
"""중앙 설정을 한 로거에 적용."""
|
|
29
|
+
logger.handlers = list(_CONF["handlers"])
|
|
30
|
+
logger.propagate = False
|
|
31
|
+
# 핵심(agent/verification/core 등)은 설정 레벨, 내부 소음 모듈은 ERROR로 침묵.
|
|
32
|
+
quiet = (not _CONF["verbose"]) and name.startswith(_NOISY)
|
|
33
|
+
logger.setLevel(logging.ERROR if quiet else _CONF["level"])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_logger(name: str, level: str = "INFO") -> logging.Logger:
|
|
37
|
+
logger = logging.getLogger(name)
|
|
38
|
+
if _CONF["on"]:
|
|
39
|
+
# 중앙 설정 활성 — 새로 만들어진 로거도 동일 정책 적용
|
|
40
|
+
_apply(logger, name)
|
|
41
|
+
return logger
|
|
42
|
+
if not logger.handlers:
|
|
43
|
+
h = logging.StreamHandler(sys.stdout)
|
|
44
|
+
h.setFormatter(_DEFAULT_FMT)
|
|
45
|
+
logger.addHandler(h)
|
|
46
|
+
logger.propagate = False
|
|
47
|
+
logger.setLevel(getattr(logging, level.upper(), logging.INFO))
|
|
48
|
+
return logger
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def configure_logging(file: str | None = None, *, level: str = "INFO",
|
|
52
|
+
verbose: bool = False) -> None:
|
|
53
|
+
"""StructVerify 로깅을 설정한다.
|
|
54
|
+
|
|
55
|
+
콘솔에 로그를 출력하고, ``file`` 을 주면 그 파일에도 함께 저장한다.
|
|
56
|
+
이후 생성되는 로거에도 자동 적용된다.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
file: 로그를 함께 저장할 파일 경로 (예: ``"verification.log"``). None이면 콘솔만.
|
|
60
|
+
level: 로그 레벨 — ``"INFO"`` | ``"DEBUG"`` | ``"WARNING"``.
|
|
61
|
+
verbose: False(기본)면 검증·에이전트 등 **핵심 로그만**, True면 내부 상세까지.
|
|
62
|
+
|
|
63
|
+
Example:
|
|
64
|
+
>>> import structverify as sv
|
|
65
|
+
>>> sv.configure_logging("verification.log") # 콘솔 + 파일 저장
|
|
66
|
+
>>> rules = sv.Ruleset.from_file("rules.pdf", provider="upstage", agent=True)
|
|
67
|
+
>>> rules.check("...") # 에이전트 검색·판정 로그가 콘솔·파일에 남는다
|
|
68
|
+
"""
|
|
69
|
+
lvl = getattr(logging, level.upper(), logging.INFO)
|
|
70
|
+
console = logging.StreamHandler(sys.stdout)
|
|
71
|
+
console.setFormatter(logging.Formatter("%(message)s"))
|
|
72
|
+
handlers: list[logging.Handler] = [console]
|
|
73
|
+
if file:
|
|
74
|
+
fh = logging.FileHandler(file, mode="w", encoding="utf-8")
|
|
75
|
+
fh.setFormatter(logging.Formatter("[%(asctime)s] %(message)s", datefmt="%H:%M:%S"))
|
|
76
|
+
handlers.append(fh)
|
|
77
|
+
_CONF.update(on=True, level=lvl, verbose=verbose, file=file, handlers=handlers)
|
|
78
|
+
# 이미 생성된 structverify 로거에 즉시 적용
|
|
79
|
+
for name, lg in list(logging.Logger.manager.loggerDict.items()):
|
|
80
|
+
if isinstance(lg, logging.Logger) and name.startswith("structverify"):
|
|
81
|
+
_apply(lg, name)
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""[리팩] Step 8 설정 로드 — verification/config.yaml (default.yaml 미수정)"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
_VERIFICATION_CONFIG_PATH = Path(__file__).parent / "config.yaml"
|
|
10
|
+
|
|
11
|
+
VerificationProfile = Literal["agent", "fallback"]
|
|
12
|
+
|
|
13
|
+
_DEFAULT_PROFILE: VerificationProfile = "fallback"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_verification_settings(
|
|
17
|
+
config: dict | None,
|
|
18
|
+
profile: VerificationProfile = _DEFAULT_PROFILE,
|
|
19
|
+
) -> dict:
|
|
20
|
+
"""판정 프로필 설정 병합.
|
|
21
|
+
|
|
22
|
+
우선순위 (낮음 → 높음):
|
|
23
|
+
1. verification/config.yaml → profiles[profile]
|
|
24
|
+
2. config['verification']['profiles'][profile]
|
|
25
|
+
3. config['verification'] flat 키 (프로필 네임스페이스 밖 — 하위 호환)
|
|
26
|
+
"""
|
|
27
|
+
merged: dict = {}
|
|
28
|
+
if _VERIFICATION_CONFIG_PATH.is_file():
|
|
29
|
+
with open(_VERIFICATION_CONFIG_PATH, encoding="utf-8") as f:
|
|
30
|
+
file_cfg = yaml.safe_load(f) or {}
|
|
31
|
+
profiles = file_cfg.get("profiles") or {}
|
|
32
|
+
merged.update(profiles.get(profile) or {})
|
|
33
|
+
|
|
34
|
+
user_cfg = (config or {}).get("verification") or {}
|
|
35
|
+
user_profiles = user_cfg.get("profiles") or {}
|
|
36
|
+
if isinstance(user_profiles.get(profile), dict):
|
|
37
|
+
merged.update(user_profiles[profile])
|
|
38
|
+
|
|
39
|
+
# [리팩] 기존 config.verification flat 키 (예: exaggeration_diff_percent) 하위 호환
|
|
40
|
+
for key, value in user_cfg.items():
|
|
41
|
+
if key == "profiles":
|
|
42
|
+
continue
|
|
43
|
+
merged.setdefault(key, value)
|
|
44
|
+
|
|
45
|
+
return merged
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""[리팩] agent Observation / verifier Evidence → decide_verdict 공통 입력 (판정 규칙 없음)"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from structverify.agent.schemas import ActionType, ClaimType, Plan
|
|
9
|
+
from structverify.core.schemas import (
|
|
10
|
+
Claim,
|
|
11
|
+
Evidence,
|
|
12
|
+
MismatchType,
|
|
13
|
+
VerificationResult,
|
|
14
|
+
VerdictType,
|
|
15
|
+
)
|
|
16
|
+
from structverify.utils.logger import get_logger
|
|
17
|
+
|
|
18
|
+
from .row_match import (
|
|
19
|
+
aggregate_rows_from_fetches,
|
|
20
|
+
extract_criteria_from_row,
|
|
21
|
+
find_value_for_time_with_criteria,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from structverify.graph.claim_graph import ClaimGraph
|
|
26
|
+
from structverify.memory.working_memory import DocumentWorkingMemory
|
|
27
|
+
|
|
28
|
+
logger = get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
_GROWTH_INDICATOR_KEYWORDS = ("증가율", "증감률", "증감율", "성장률", "비율", "퍼센트", "%")
|
|
31
|
+
_DIFF_INDICATOR_KEYWORDS = ("차이", "증감", "감소", "증가분", "감소분", "변화량", "격차")
|
|
32
|
+
_RANK_INDICATOR_KEYWORDS = ("순위", "1위", "최고", "최대", "최저", "최소", "가장 높")
|
|
33
|
+
_GROWTH_UNITS = ("%", "%p", "퍼센트", "%P", "pp")
|
|
34
|
+
_DERIVED_SUFFIXES = ("증가율", "감소율", "증감률", "변화율", "상승률", "하락률")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class VerdictDecision:
|
|
39
|
+
"""agent 경로 판정 결과 — loop가 AgentVerdict로 포장 (commit 8)."""
|
|
40
|
+
|
|
41
|
+
claim_id: str
|
|
42
|
+
verdict: VerdictType
|
|
43
|
+
confidence: float
|
|
44
|
+
explanation: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class NormalizedInput:
|
|
49
|
+
"""decide_verdict가 소비하는 fallback 경로 입력."""
|
|
50
|
+
|
|
51
|
+
evidence: Evidence
|
|
52
|
+
claim_year: str | None = None
|
|
53
|
+
claim_year_month: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class AgentFetchInput:
|
|
58
|
+
"""agent fetch observation 판정 입력."""
|
|
59
|
+
|
|
60
|
+
claim_id: str
|
|
61
|
+
evidence: dict
|
|
62
|
+
claim_actual_type: ClaimType
|
|
63
|
+
plan_claim_type: ClaimType
|
|
64
|
+
tolerance: float
|
|
65
|
+
all_fetch_observations: list = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class AgentCalculateInput:
|
|
70
|
+
"""agent calculate observation 판정 입력."""
|
|
71
|
+
|
|
72
|
+
claim_id: str
|
|
73
|
+
calc_value: float
|
|
74
|
+
claim_actual_type: ClaimType
|
|
75
|
+
calc_summary: str = ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def infer_claim_type(claim: Claim) -> ClaimType | None:
|
|
79
|
+
"""Claim의 *실제* 유형을 schema에서 추론 (loop._infer_claim_type).
|
|
80
|
+
|
|
81
|
+
Planner LLM이 source_text 전체 의미로 일괄 분류하기 때문에,
|
|
82
|
+
같은 문장에서 추출된 absolute / growth_rate claim들이 모두 growth_rate로
|
|
83
|
+
뭉뚱그려지는 문제가 있음. claim.schema의 indicator/unit/prev_value를 보면
|
|
84
|
+
정확하게 알 수 있으므로 그것으로 보정.
|
|
85
|
+
|
|
86
|
+
우선순위 (v6.17 — prev_value를 unit보다 먼저 체크):
|
|
87
|
+
1. ClaimSchema.comparison_type (명시되어 있으면)
|
|
88
|
+
2. Claim.canonical_type
|
|
89
|
+
3. indicator 키워드 (순위/차이 → ranking/difference)
|
|
90
|
+
4. prev_value 있음 + unit % → growth_rate
|
|
91
|
+
5. prev_value 있음 (unit % 아님) → comparison
|
|
92
|
+
6. prev_value 없음 → ABSOLUTE (unit=%여도 비교 기준 없으면 growth_rate 아님)
|
|
93
|
+
"""
|
|
94
|
+
schema = claim.schema
|
|
95
|
+
if schema is None:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
# 1. schema.comparison_type 명시
|
|
99
|
+
comp = getattr(schema, "comparison_type", None)
|
|
100
|
+
if isinstance(comp, ClaimType):
|
|
101
|
+
return comp
|
|
102
|
+
|
|
103
|
+
# 2. claim.canonical_type
|
|
104
|
+
canon = getattr(claim, "canonical_type", None)
|
|
105
|
+
if isinstance(canon, ClaimType):
|
|
106
|
+
return canon
|
|
107
|
+
|
|
108
|
+
indicator = (schema.indicator or "").strip()
|
|
109
|
+
unit = (schema.unit or "").strip()
|
|
110
|
+
prev_value = getattr(schema, "prev_value", None)
|
|
111
|
+
|
|
112
|
+
# 3. indicator 키워드 — 순위/차이는 unit과 무관하게 먼저 판정
|
|
113
|
+
if any(kw in indicator for kw in _RANK_INDICATOR_KEYWORDS):
|
|
114
|
+
return ClaimType.RANKING
|
|
115
|
+
if any(kw in indicator for kw in _DIFF_INDICATOR_KEYWORDS):
|
|
116
|
+
return ClaimType.DIFFERENCE
|
|
117
|
+
if any(kw in indicator for kw in _GROWTH_INDICATOR_KEYWORDS):
|
|
118
|
+
return ClaimType.GROWTH_RATE
|
|
119
|
+
|
|
120
|
+
# 4. prev_value 있음 → 두 시점 비교 claim
|
|
121
|
+
if prev_value is not None:
|
|
122
|
+
if unit in _GROWTH_UNITS:
|
|
123
|
+
return ClaimType.GROWTH_RATE
|
|
124
|
+
return ClaimType.COMPARISON
|
|
125
|
+
|
|
126
|
+
# 5. prev_value 없음 → 단일 시점 절대값 (unit=%여도 growth_rate 아님)
|
|
127
|
+
return ClaimType.ABSOLUTE
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def from_agent_fetch(
|
|
131
|
+
claim: Claim,
|
|
132
|
+
last_observation: Any,
|
|
133
|
+
plan: Plan,
|
|
134
|
+
*,
|
|
135
|
+
tolerance: float = 0.05,
|
|
136
|
+
all_fetch_observations: list | None = None,
|
|
137
|
+
) -> tuple[AgentFetchInput | None, VerdictDecision | None]:
|
|
138
|
+
"""fetch Observation → AgentFetchInput 또는 즉시 VerdictDecision."""
|
|
139
|
+
claim_id = str(claim.claim_id)
|
|
140
|
+
|
|
141
|
+
if last_observation is None:
|
|
142
|
+
return None, None
|
|
143
|
+
if getattr(last_observation, "action", None) != ActionType.FETCH_EVIDENCE:
|
|
144
|
+
return None, None
|
|
145
|
+
|
|
146
|
+
if not getattr(last_observation, "success", False):
|
|
147
|
+
summary = (getattr(last_observation, "summary", None) or "")[:200]
|
|
148
|
+
return None, VerdictDecision(
|
|
149
|
+
claim_id=claim_id,
|
|
150
|
+
verdict=VerdictType.UNVERIFIABLE,
|
|
151
|
+
confidence=0.25,
|
|
152
|
+
explanation=f"데이터 조회 실패: {summary}",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
evidence = dict((getattr(last_observation, "output", None) or {}).get("evidence") or {})
|
|
156
|
+
fetched_value = evidence.get("value")
|
|
157
|
+
fetched_time = evidence.get("time_period", "") or ""
|
|
158
|
+
|
|
159
|
+
schema = claim.schema
|
|
160
|
+
claim_time = (schema.time_period or "") if schema is not None else ""
|
|
161
|
+
|
|
162
|
+
# ── [패치 H-3] aggregated rows에서 claim_time + 지표 criteria 매칭 row 찾기 ──
|
|
163
|
+
# 시나리오: LLM이 current(2025-04) fetch → prev(2024-04) fetch 순으로 호출하면
|
|
164
|
+
# last_fetch_observation은 prev 시점만 들어있고 그 fetch의 rows[]에는 2025-04
|
|
165
|
+
# row가 아예 없다. 단일 fetch만 보면 claim_time row 못 찾아 unverifiable.
|
|
166
|
+
# → 같은 claim의 모든 fetch observation rows를 합쳐서 풀을 만들고,
|
|
167
|
+
# matched_row의 ITM_NM·C1_NM~C4_NM을 criteria로 같은 지표의 다른 시점 row를
|
|
168
|
+
# 찾는다. 시점만 보고 row 잡으면 출생아 수/혼인 건수 같이 PRD_DE 공유하는
|
|
169
|
+
# 다른 지표가 잘못 매칭됨 — criteria 필터로 차단.
|
|
170
|
+
matched_row_from_last = evidence.get("matched_row") or {}
|
|
171
|
+
criteria = extract_criteria_from_row(matched_row_from_last)
|
|
172
|
+
pool_rows = aggregate_rows_from_fetches(all_fetch_observations or [])
|
|
173
|
+
# last fetch의 rows도 합집합에 포함 (보통은 이미 포함됐을 것이나 안전)
|
|
174
|
+
for r in evidence.get("rows") or []:
|
|
175
|
+
if isinstance(r, dict) and r not in pool_rows:
|
|
176
|
+
pool_rows.append(r)
|
|
177
|
+
|
|
178
|
+
if claim_time and pool_rows:
|
|
179
|
+
hit = find_value_for_time_with_criteria(pool_rows, claim_time, criteria)
|
|
180
|
+
if hit is not None:
|
|
181
|
+
row_val_for_claim_time, _picked_row = hit
|
|
182
|
+
claim_time_norm = str(claim_time).replace("-", "")
|
|
183
|
+
fetched_time_norm = str(fetched_time).replace("-", "")
|
|
184
|
+
# 마지막 fetch가 이미 claim_time이면 그대로, 아니면 덮어씀
|
|
185
|
+
if claim_time_norm not in fetched_time_norm:
|
|
186
|
+
logger.info(
|
|
187
|
+
f"[loop] {claim_id}: aggregated rows에서 claim_time={claim_time} + "
|
|
188
|
+
f"criteria={list(criteria.keys()) or '없음'} row 매칭 "
|
|
189
|
+
f"→ value={row_val_for_claim_time} "
|
|
190
|
+
f"(마지막 fetch 시점={fetched_time}/value={fetched_value} → 덮어씀)"
|
|
191
|
+
)
|
|
192
|
+
evidence["value"] = row_val_for_claim_time
|
|
193
|
+
evidence["time_period"] = claim_time_norm
|
|
194
|
+
|
|
195
|
+
claim_actual_type = infer_claim_type(claim) or plan.claim_type
|
|
196
|
+
|
|
197
|
+
return AgentFetchInput(
|
|
198
|
+
claim_id=claim_id,
|
|
199
|
+
evidence=evidence,
|
|
200
|
+
claim_actual_type=claim_actual_type,
|
|
201
|
+
plan_claim_type=plan.claim_type,
|
|
202
|
+
tolerance=tolerance,
|
|
203
|
+
all_fetch_observations=list(all_fetch_observations or []),
|
|
204
|
+
), None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def from_agent_calculate(
|
|
208
|
+
claim: Claim,
|
|
209
|
+
last_calc_observation: Any,
|
|
210
|
+
plan: Plan,
|
|
211
|
+
*,
|
|
212
|
+
last_fetch_observation: Any | None = None,
|
|
213
|
+
workspace: Any | None = None,
|
|
214
|
+
) -> tuple[AgentCalculateInput | None, VerdictDecision | None]:
|
|
215
|
+
"""calculate Observation → AgentCalculateInput (가드 통과 시).
|
|
216
|
+
|
|
217
|
+
[안전장치] last_fetch_observation이 없으면 calculate 결과를 신뢰하지 않는다.
|
|
218
|
+
fetch 0건 상태에서 LLM이 prev/current를 임의로 박아 계산한 값이
|
|
219
|
+
MATCH로 통과하는 환각을 차단.
|
|
220
|
+
|
|
221
|
+
[P22 2026-05-22] sibling base evidence가 있으면 fetch 0건이어도 합성 시도.
|
|
222
|
+
calc.input.current가 sibling base value와 *크게 다르면(>2%)* 환각으로 거부.
|
|
223
|
+
"""
|
|
224
|
+
claim_id = str(claim.claim_id)
|
|
225
|
+
|
|
226
|
+
if last_calc_observation is None or not getattr(last_calc_observation, "success", False):
|
|
227
|
+
return None, None
|
|
228
|
+
if getattr(last_calc_observation, "action", None) != ActionType.CALCULATE:
|
|
229
|
+
return None, None
|
|
230
|
+
|
|
231
|
+
if last_fetch_observation is None:
|
|
232
|
+
# [P22] sibling 검증으로 fetch 0건 케이스 구제 시도
|
|
233
|
+
sib_current: float | None = None
|
|
234
|
+
try:
|
|
235
|
+
sent_id = str(getattr(claim, "sent_id", "") or "").strip()
|
|
236
|
+
if workspace is not None and sent_id and hasattr(workspace, "read_sibling_evidence"):
|
|
237
|
+
sibs = workspace.read_sibling_evidence(sent_id) or []
|
|
238
|
+
schema = claim.schema
|
|
239
|
+
tp = (schema.time_period or "") if schema else ""
|
|
240
|
+
tp_norm = str(tp).replace("-", "")
|
|
241
|
+
for s in sibs:
|
|
242
|
+
if s.get("role") != "base":
|
|
243
|
+
continue
|
|
244
|
+
# 같은 시점의 base sibling 찾기
|
|
245
|
+
s_tp = str(s.get("time_period") or "").replace("-", "")
|
|
246
|
+
if s_tp == tp_norm and s.get("value") is not None:
|
|
247
|
+
sib_current = float(s.get("value"))
|
|
248
|
+
break
|
|
249
|
+
except Exception:
|
|
250
|
+
sib_current = None
|
|
251
|
+
|
|
252
|
+
if sib_current is None:
|
|
253
|
+
logger.info(
|
|
254
|
+
f"[loop] {claim_id}: calculate 합성 가드 — fetch evidence 0건 + "
|
|
255
|
+
f"sibling base도 없음 → calculate 결과 신뢰 X (LLM 환각 차단)"
|
|
256
|
+
)
|
|
257
|
+
return None, None
|
|
258
|
+
|
|
259
|
+
# calc input의 current와 sibling base value 비교
|
|
260
|
+
calc_input = getattr(last_calc_observation, "input", None) or {}
|
|
261
|
+
calc_current = calc_input.get("current")
|
|
262
|
+
try:
|
|
263
|
+
cc = float(calc_current) if calc_current is not None else None
|
|
264
|
+
except (TypeError, ValueError):
|
|
265
|
+
cc = None
|
|
266
|
+
if cc is not None:
|
|
267
|
+
gap_ratio = abs(cc - sib_current) / max(abs(sib_current), 1e-9)
|
|
268
|
+
if gap_ratio > 0.02:
|
|
269
|
+
logger.warning(
|
|
270
|
+
f"[loop] {claim_id}: calculate 합성 거부 — calc.input.current="
|
|
271
|
+
f"{cc} vs sibling base={sib_current} (gap {gap_ratio*100:.1f}%)"
|
|
272
|
+
)
|
|
273
|
+
return None, None
|
|
274
|
+
|
|
275
|
+
# [패치 2026-05-20] base claim은 calculate 합성 거부 — derived suffix만 허용
|
|
276
|
+
schema = claim.schema
|
|
277
|
+
schema_indicator = (schema.indicator or "").strip() if schema else ""
|
|
278
|
+
if not any(schema_indicator.endswith(s) for s in _DERIVED_SUFFIXES):
|
|
279
|
+
logger.info(
|
|
280
|
+
f"[loop] {claim_id}: calculate 합성 가드 — base indicator "
|
|
281
|
+
f"'{schema_indicator}' (derived 아님) → 합성 거부"
|
|
282
|
+
)
|
|
283
|
+
return None, None
|
|
284
|
+
|
|
285
|
+
raw_result = (getattr(last_calc_observation, "output", None) or {}).get("result")
|
|
286
|
+
if raw_result is None:
|
|
287
|
+
return None, None
|
|
288
|
+
try:
|
|
289
|
+
calc_value = float(raw_result)
|
|
290
|
+
except (TypeError, ValueError):
|
|
291
|
+
return None, None
|
|
292
|
+
|
|
293
|
+
if claim.schema is None or claim.schema.value is None:
|
|
294
|
+
return None, None
|
|
295
|
+
|
|
296
|
+
claim_actual_type = infer_claim_type(claim) or plan.claim_type
|
|
297
|
+
calc_summary = getattr(last_calc_observation, "summary", None) or ""
|
|
298
|
+
|
|
299
|
+
return AgentCalculateInput(
|
|
300
|
+
claim_id=claim_id,
|
|
301
|
+
calc_value=calc_value,
|
|
302
|
+
claim_actual_type=claim_actual_type,
|
|
303
|
+
calc_summary=calc_summary,
|
|
304
|
+
), None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def from_evidence(
|
|
308
|
+
claim: Claim,
|
|
309
|
+
evidence: Evidence | None,
|
|
310
|
+
*,
|
|
311
|
+
graph: ClaimGraph | None = None,
|
|
312
|
+
memory: DocumentWorkingMemory | None = None,
|
|
313
|
+
) -> tuple[NormalizedInput | None, VerificationResult | None]:
|
|
314
|
+
"""Evidence·claim·graph·memory → NormalizedInput 또는 즉시 반환할 VerificationResult."""
|
|
315
|
+
if evidence is None or evidence.official_value is None:
|
|
316
|
+
return None, VerificationResult(
|
|
317
|
+
claim_id=claim.claim_id,
|
|
318
|
+
verdict=VerdictType.UNVERIFIABLE,
|
|
319
|
+
confidence=0.3,
|
|
320
|
+
evidence=evidence,
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
claimed = claim.schema.value if claim.schema else None
|
|
324
|
+
if claimed is None:
|
|
325
|
+
return None, VerificationResult(
|
|
326
|
+
claim_id=claim.claim_id,
|
|
327
|
+
verdict=VerdictType.UNVERIFIABLE,
|
|
328
|
+
confidence=0.2,
|
|
329
|
+
evidence=None,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if claimed == 0.0:
|
|
333
|
+
return None, VerificationResult(
|
|
334
|
+
claim_id=claim.claim_id,
|
|
335
|
+
verdict=VerdictType.UNVERIFIABLE,
|
|
336
|
+
confidence=0.2,
|
|
337
|
+
evidence=None,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
if memory is not None and evidence.category_path:
|
|
341
|
+
if not memory.domain_matches_category(evidence.category_path):
|
|
342
|
+
logger.info(
|
|
343
|
+
f"[verifier 도메인 가드] reject: "
|
|
344
|
+
f"doc.domain={memory.domain} ↔ evidence.category={evidence.category_path}"
|
|
345
|
+
)
|
|
346
|
+
memory.record_stat_id_rejected(
|
|
347
|
+
evidence.stat_table_id or "unknown",
|
|
348
|
+
f"domain mismatch: {memory.domain} vs {evidence.category_path}",
|
|
349
|
+
)
|
|
350
|
+
return None, VerificationResult(
|
|
351
|
+
claim_id=claim.claim_id,
|
|
352
|
+
verdict=VerdictType.UNVERIFIABLE,
|
|
353
|
+
confidence=0.4,
|
|
354
|
+
evidence=evidence,
|
|
355
|
+
mismatch_type=MismatchType.DOMAIN_MISMATCH,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
claim_year, claim_year_month = _resolve_claim_time(claim, graph)
|
|
359
|
+
|
|
360
|
+
return NormalizedInput(
|
|
361
|
+
evidence=evidence,
|
|
362
|
+
claim_year=claim_year,
|
|
363
|
+
claim_year_month=claim_year_month,
|
|
364
|
+
), None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _resolve_claim_time(
|
|
368
|
+
claim: Claim,
|
|
369
|
+
graph: ClaimGraph | None,
|
|
370
|
+
) -> tuple[str | None, str | None]:
|
|
371
|
+
claim_year = None
|
|
372
|
+
claim_year_month = None
|
|
373
|
+
|
|
374
|
+
schema_tp = (
|
|
375
|
+
claim.schema.time_period if claim.schema and claim.schema.time_period else ""
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
if schema_tp:
|
|
379
|
+
m = re.search(r"(\d{4})", schema_tp)
|
|
380
|
+
if m:
|
|
381
|
+
claim_year = m.group(1)
|
|
382
|
+
ym = re.search(r"(\d{4})[-/]?(\d{2})", schema_tp)
|
|
383
|
+
if ym:
|
|
384
|
+
claim_year_month = ym.group(1) + ym.group(2)
|
|
385
|
+
if claim_year:
|
|
386
|
+
logger.info(
|
|
387
|
+
f"[verifier] 시점 해소: schema.time_period={schema_tp!r} "
|
|
388
|
+
f"→ year={claim_year}, ym={claim_year_month}"
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
if not claim_year and graph is not None:
|
|
392
|
+
resolved = graph.resolve_time_for_claim(claim)
|
|
393
|
+
if resolved:
|
|
394
|
+
m = re.search(r"(\d{4})", resolved)
|
|
395
|
+
if m:
|
|
396
|
+
claim_year = m.group(1)
|
|
397
|
+
logger.info(
|
|
398
|
+
f"[verifier] 시점 해소 (fallback): 그래프에서 resolved year={claim_year} "
|
|
399
|
+
f"(from {resolved})"
|
|
400
|
+
)
|
|
401
|
+
ym = re.search(r"(\d{4})[-/]?(\d{2})", resolved)
|
|
402
|
+
if ym:
|
|
403
|
+
claim_year_month = ym.group(1) + ym.group(2)
|
|
404
|
+
|
|
405
|
+
return claim_year, claim_year_month
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""structverify.verification.conformance — 문서형 준수판정 (P3).
|
|
2
|
+
|
|
3
|
+
수치비교(decide_verdict)가 아니라, **검색된 규정 조항 텍스트**에 대해 신청/주장이
|
|
4
|
+
준수하는지 판정한다. (custom_docs verdict_mode="conformance"에서 사용)
|
|
5
|
+
|
|
6
|
+
신뢰성 설계(§7c):
|
|
7
|
+
· LLM은 *추출만* — 적용 조항 + 기준값(rule_value) + 측정값(claim_value) + 비교방향(comparison).
|
|
8
|
+
· **verdict는 코드가 결정론적으로 계산** (LLM verdict 필드의 근거-결론 불일치 제거).
|
|
9
|
+
· 빈/무효 응답이면 재시도.
|
|
10
|
+
원칙: 근거 없으면 unverifiable(환각 방지).
|
|
11
|
+
계획: docs/indexing-agent-plan.md §4
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from structverify.utils.llm_client import LLMClient
|
|
20
|
+
from structverify.utils.logger import get_logger
|
|
21
|
+
|
|
22
|
+
logger = get_logger(__name__)
|
|
23
|
+
|
|
24
|
+
CONFORMANCE_SCHEMA: dict[str, Any] = {
|
|
25
|
+
"type": "object",
|
|
26
|
+
"properties": {
|
|
27
|
+
"applicable_article": {"type": ["string", "null"]}, # 적용한 조항(단위·대상 맞는 것)
|
|
28
|
+
"rule_value": {"type": ["number", "null"]}, # 기준값(한도)
|
|
29
|
+
"claim_value": {"type": ["number", "null"]}, # 측정/신청값
|
|
30
|
+
"unit": {"type": ["string", "null"]},
|
|
31
|
+
# 조항이 규정한 기준의 성격 (측정 결과가 아니라 *규정 자체*의 방향)
|
|
32
|
+
"rule_type": {"type": "string", "enum": ["max", "min", "exact", "none"]},
|
|
33
|
+
"explanation": {"type": "string"},
|
|
34
|
+
},
|
|
35
|
+
"required": ["rule_type", "explanation"],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_PROMPT = """너는 규정 준수 검사관이다. [측정/신청]을 [관련 규정 조항]과 대조해 아래를 추출하라.
|
|
39
|
+
|
|
40
|
+
[관련 규정 조항] (여러 개면 측정 단위·제품 종류에 맞는 것 하나 선택)
|
|
41
|
+
{article}
|
|
42
|
+
|
|
43
|
+
[측정/신청]
|
|
44
|
+
{claim}
|
|
45
|
+
|
|
46
|
+
추출 항목:
|
|
47
|
+
- applicable_article: 적용한 조항. ㎍/g 측정은 mg/kg 조항과 매칭하지 말 것.
|
|
48
|
+
- **같은 원소라도 기준이 여러 개면 측정 '유형'에 맞는 것을 골라라.** [측정]에 "총함량"이면
|
|
49
|
+
총함량 허용치를, "용출"이면 용출 허용치를 rule_value로 사용. (예: 총 납 300mg/kg vs 용출 납 90mg/kg
|
|
50
|
+
은 서로 다른 기준 — 총함량 측정에 90을 쓰면 오답)
|
|
51
|
+
- rule_value: 그 조항의 기준값(숫자만) / claim_value: 측정·신청값(숫자만) / unit: 단위
|
|
52
|
+
- rule_type: **조항(규정) 자체**가 '~이하 한도'면 max, '~이상 최소'면 min, '일치'면 exact, 맞는 조항 없으면 none
|
|
53
|
+
(주의: 측정값이 기준보다 큰지 작은지가 아니라, *규정이 상한/하한 중 무엇인지* 를 넣어라)
|
|
54
|
+
- explanation: 어느 조항의 무엇을 근거로 하는지 1~2문장
|
|
55
|
+
|
|
56
|
+
* rule_value와 claim_value는 **같은 단위**로 맞춰서 숫자만. 맞는 조항 없으면 rule_type=none.
|
|
57
|
+
JSON만 출력."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _compute_verdict(rule: Any, claim: Any, rule_type: str) -> str:
|
|
61
|
+
"""추출된 값 + 규정 성격(max/min/exact)으로 verdict 결정 (LLM verdict 신뢰 안 함).
|
|
62
|
+
|
|
63
|
+
max=상한(이하): 측정 ≤ 기준이면 적합 · min=하한(이상): 측정 ≥ 기준이면 적합.
|
|
64
|
+
"""
|
|
65
|
+
if rule is None or claim is None or rule_type in (None, "none"):
|
|
66
|
+
return "unverifiable"
|
|
67
|
+
try:
|
|
68
|
+
r, c = float(rule), float(claim)
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
return "unverifiable"
|
|
71
|
+
ok = {"max": c <= r, "min": c >= r, "exact": c == r}.get(rule_type)
|
|
72
|
+
if ok is None:
|
|
73
|
+
return "unverifiable"
|
|
74
|
+
return "compliant" if ok else "violation"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _one_judgment(llm: LLMClient, claim_text: str, article_text: str) -> dict[str, Any]:
|
|
78
|
+
try:
|
|
79
|
+
res = await llm.generate_structured(
|
|
80
|
+
_PROMPT.format(article=article_text, claim=claim_text),
|
|
81
|
+
CONFORMANCE_SCHEMA,
|
|
82
|
+
system_prompt="규정 준수 검사관. 반드시 JSON만 출력.",
|
|
83
|
+
)
|
|
84
|
+
except Exception as e: # noqa: BLE001
|
|
85
|
+
logger.debug(f"[conformance] 판정 시도 실패: {e}")
|
|
86
|
+
res = {}
|
|
87
|
+
if not isinstance(res, dict):
|
|
88
|
+
res = {}
|
|
89
|
+
res["verdict"] = _compute_verdict(res.get("rule_value"), res.get("claim_value"),
|
|
90
|
+
res.get("rule_type", "none"))
|
|
91
|
+
return res
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def judge_conformance(
|
|
95
|
+
claim_text: str,
|
|
96
|
+
article_text: str,
|
|
97
|
+
config: dict[str, Any] | None = None,
|
|
98
|
+
*,
|
|
99
|
+
votes: int = 3,
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
"""(측정/신청, 규정 조항) → 준수판정. verdict는 코드가 결정론 계산 + self-consistency 투표(§7c).
|
|
102
|
+
|
|
103
|
+
solar 모델 변동성(빈 응답·오판) 대응: votes회 *병렬* 판정 후 다수결.
|
|
104
|
+
(병렬이라 지연은 1회 호출 수준)
|
|
105
|
+
"""
|
|
106
|
+
llm = LLMClient(config=(config or {}).get("llm", {}))
|
|
107
|
+
results = await asyncio.gather(
|
|
108
|
+
*[_one_judgment(llm, claim_text, article_text) for _ in range(max(1, votes))]
|
|
109
|
+
)
|
|
110
|
+
# 확정 판정(compliant/violation) 중 다수결. 없으면 unverifiable.
|
|
111
|
+
concrete = [r for r in results if r.get("verdict") in ("compliant", "violation")]
|
|
112
|
+
if concrete:
|
|
113
|
+
winner = Counter(r["verdict"] for r in concrete).most_common(1)[0][0]
|
|
114
|
+
best = next(r for r in concrete if r["verdict"] == winner)
|
|
115
|
+
logger.info(f"[conformance] 투표 {Counter(r['verdict'] for r in results)} → {winner}")
|
|
116
|
+
return best
|
|
117
|
+
return results[0] if results else {"verdict": "unverifiable", "explanation": ""}
|