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,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": ""}