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,441 @@
|
|
|
1
|
+
"""detection/schema/induce.py — 스키마 유도 LLM 호출 및 후처리.
|
|
2
|
+
|
|
3
|
+
schema_inductor.py에서 분리 (로직 move-only, 동작 변경 없음).
|
|
4
|
+
|
|
5
|
+
[v6.14] source_phrase 검증 — context leak 방지
|
|
6
|
+
[v6.14 E] value/prev_value 환산 교정
|
|
7
|
+
[v6.15 L] 차이 schema prev_value 역산
|
|
8
|
+
[2026-05-21] value_role 자동 추론, aggregation 필드
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from structverify.core.schemas import ClaimSchema
|
|
15
|
+
from structverify.detection.prompts.schema import (
|
|
16
|
+
CLAIM_SCHEMA_LIST_JSON_SCHEMA,
|
|
17
|
+
SCHEMA_INDUCTION_PROMPT,
|
|
18
|
+
)
|
|
19
|
+
from structverify.detection.schema.validate import (
|
|
20
|
+
_extract_numbers_from_text,
|
|
21
|
+
_safe_float,
|
|
22
|
+
_source_phrase_in_claim,
|
|
23
|
+
_validate_schema,
|
|
24
|
+
_value_in_claim_text,
|
|
25
|
+
_verify_and_correct_value,
|
|
26
|
+
)
|
|
27
|
+
from structverify.utils.llm_client import LLMClient
|
|
28
|
+
from structverify.utils.logger import get_logger
|
|
29
|
+
|
|
30
|
+
logger = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _induce_multiple(
|
|
34
|
+
llm: LLMClient,
|
|
35
|
+
claim_text: str,
|
|
36
|
+
domain: str = "general",
|
|
37
|
+
domain_hint: str = "",
|
|
38
|
+
context: str = "",
|
|
39
|
+
temporal_hint: str = "",
|
|
40
|
+
anchor_year: int | None = None,
|
|
41
|
+
) -> list[ClaimSchema]:
|
|
42
|
+
"""
|
|
43
|
+
단일 주장 → list[ClaimSchema] (0개 이상).
|
|
44
|
+
|
|
45
|
+
LLM이 한 문장의 모든 검증 가능 수치를 schemas 배열로 반환.
|
|
46
|
+
[v6.14] source_phrase 검증으로 context leak 방지:
|
|
47
|
+
- LLM이 schema마다 source_phrase 제공 (예: '2만 171명', '6.7%')
|
|
48
|
+
- source_phrase가 claim_text에 *substring으로 등장*하는지 검증
|
|
49
|
+
- 등장하지 않으면 (context leak) 그 schema 폐기
|
|
50
|
+
Structured Outputs 사용 — JSON 파싱 실패 없음.
|
|
51
|
+
"""
|
|
52
|
+
prompt = SCHEMA_INDUCTION_PROMPT.format(
|
|
53
|
+
claim_text=claim_text,
|
|
54
|
+
context=context or claim_text,
|
|
55
|
+
domain=domain,
|
|
56
|
+
domain_hint=domain_hint,
|
|
57
|
+
temporal_hint=temporal_hint,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
r = await llm.generate_structured(
|
|
62
|
+
prompt=prompt,
|
|
63
|
+
schema=CLAIM_SCHEMA_LIST_JSON_SCHEMA,
|
|
64
|
+
system_prompt=(
|
|
65
|
+
"통계 분석 전문가. 위 규칙을 엄격히 따르세요. "
|
|
66
|
+
"★ 핵심: [검증 대상 문장]에 literally 등장하는 수치만 추출. "
|
|
67
|
+
"[문맥]의 수치는 절대 추출 금지. "
|
|
68
|
+
"각 schema에 source_phrase 의무 포함. "
|
|
69
|
+
"★ '예상/예보/전망/예측' 포함 indicator는 KOSIS 검증 불가 → 해당 schema 추출 금지."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.warning(f"스키마 유도 LLM 호출 예외: {e}")
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
# [2026-05-25] LLM thought 디버깅용 — LLM이 어떤 schema 후보를 *왜* 추출했는지
|
|
77
|
+
# 화면 UI에서 보기 어려운 케이스 대응. 응답 원본 json을 INFO로 펼쳐 보여줌.
|
|
78
|
+
try:
|
|
79
|
+
import json as _json
|
|
80
|
+
logger.info(
|
|
81
|
+
f"[schema_inductor] LLM 응답 본문 (claim_text={claim_text[:60]!r}...) ↓\n"
|
|
82
|
+
f"────── SCHEMA RESPONSE START ──────\n"
|
|
83
|
+
f"{_json.dumps(r, ensure_ascii=False, indent=2)}\n"
|
|
84
|
+
f"────── SCHEMA RESPONSE END ──────"
|
|
85
|
+
)
|
|
86
|
+
except Exception:
|
|
87
|
+
logger.info(f"[schema_inductor] LLM 응답 raw: {str(r)[:1000]}")
|
|
88
|
+
|
|
89
|
+
schemas_raw = r.get("schemas") or []
|
|
90
|
+
if not isinstance(schemas_raw, list):
|
|
91
|
+
logger.warning(f"스키마 유도: schemas가 list 아님 ({type(schemas_raw)})")
|
|
92
|
+
return []
|
|
93
|
+
|
|
94
|
+
results: list[ClaimSchema] = []
|
|
95
|
+
for item in schemas_raw:
|
|
96
|
+
if not isinstance(item, dict):
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
# ── [v6.14] source_phrase 검증 (context leak 방지) ──
|
|
100
|
+
source_phrase = (item.get("source_phrase") or "").strip()
|
|
101
|
+
if source_phrase:
|
|
102
|
+
if not _source_phrase_in_claim(source_phrase, claim_text):
|
|
103
|
+
logger.warning(
|
|
104
|
+
f" ⚠️ context leak 감지: source_phrase={source_phrase!r} "
|
|
105
|
+
f"가 검증 대상 문장에 없음 → schema 폐기 "
|
|
106
|
+
f"(indicator={item.get('indicator')}, value={item.get('value')})"
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
else:
|
|
110
|
+
# source_phrase 없으면 value 자체로 검증 (LLM이 의무 위반한 경우 fallback)
|
|
111
|
+
val = item.get("value")
|
|
112
|
+
if val is not None and not _value_in_claim_text(val, claim_text):
|
|
113
|
+
logger.warning(
|
|
114
|
+
f" ⚠️ context leak 의심 (source_phrase 누락): "
|
|
115
|
+
f"value={val} 가 문장에 없음 → schema 폐기 "
|
|
116
|
+
f"(indicator={item.get('indicator')})"
|
|
117
|
+
)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
# [v6.14 E fix] value 환산 정확성 검증 + 자동 교정
|
|
122
|
+
# LLM이 "2만 171" → 21710 같은 환산 오류를 내는 경우 발견됨.
|
|
123
|
+
# source_phrase가 있으면 거기서 직접 환산값 추출 → LLM value와 비교 → 5% 이상 차이면 교정.
|
|
124
|
+
raw_value = _safe_float(item.get("value"))
|
|
125
|
+
corrected_value, was_corrected = _verify_and_correct_value(
|
|
126
|
+
raw_value, source_phrase
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# [2026-05-21] value=null fallback — LLM이 value를 빠뜨려도 source_phrase에서
|
|
130
|
+
# 숫자 복원. 도메인 무관, 한국어 키워드 하드코딩 X.
|
|
131
|
+
# ("0.79명" → 0.79, "20717명" → 20717, "1만 2741개" → 12741, "23만 8천명" → 238000)
|
|
132
|
+
#
|
|
133
|
+
# [22:40 진단] "1만 2741개"는 _extract_numbers_from_text가 {1, 2741, 12741}처럼
|
|
134
|
+
# 한글 단위 정합값(12741)뿐 아니라 부분 숫자(1, 2741)도 같이 반환해 *set 크기 >1*이
|
|
135
|
+
# 되어 폴백 미적용 → schema value=None → 서울/경기 claim이 모두 unverifiable로 떨어짐.
|
|
136
|
+
# 정답: 한글 단위 결합값(가장 큰 수)이 거의 항상 의도된 value임. set이 여러 개면 max 사용.
|
|
137
|
+
if corrected_value is None and source_phrase:
|
|
138
|
+
_fallback_nums = _extract_numbers_from_text(source_phrase)
|
|
139
|
+
if _fallback_nums:
|
|
140
|
+
# 단일 숫자거나 한글 단위 결합값(=max)가 그 의미 — 둘 다 max로 통합.
|
|
141
|
+
_picked = max(_fallback_nums)
|
|
142
|
+
if len(_fallback_nums) == 1:
|
|
143
|
+
_msg = f"단일 숫자 {_picked} 복원"
|
|
144
|
+
else:
|
|
145
|
+
_msg = (
|
|
146
|
+
f"숫자 {len(_fallback_nums)}개 중 최대값 {_picked} 복원 "
|
|
147
|
+
f"(한글 단위 결합 추정, 후보={sorted(_fallback_nums)})"
|
|
148
|
+
)
|
|
149
|
+
logger.warning(
|
|
150
|
+
f" 🔧 value=null 폴백: source_phrase={source_phrase!r}에서 "
|
|
151
|
+
f"{_msg} (LLM이 value 누락)"
|
|
152
|
+
)
|
|
153
|
+
corrected_value = float(_picked)
|
|
154
|
+
if was_corrected:
|
|
155
|
+
logger.warning(
|
|
156
|
+
f" 🔧 value 환산 교정: LLM={raw_value} → 교정={corrected_value} "
|
|
157
|
+
f"(source_phrase={source_phrase!r}, indicator={item.get('indicator')})"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# [v6.14 C2] prev_value 검증 + ClaimSchema 생성
|
|
161
|
+
# schemas.py에 prev_value 필드가 *아직 없는 경우* import 안 깨지게 try/except
|
|
162
|
+
prev_value_raw = item.get("prev_value")
|
|
163
|
+
prev_value = _safe_float(prev_value_raw) if prev_value_raw is not None else None
|
|
164
|
+
prev_time_period = (item.get("prev_time_period") or "").strip() or None
|
|
165
|
+
prev_phrase = (item.get("prev_phrase") or "").strip()
|
|
166
|
+
|
|
167
|
+
# prev_phrase가 있으면 검증 (context leak 방지 — source_phrase와 동일 규칙)
|
|
168
|
+
# [v6.17] prev_value/prev_phrase만 폐기하고 prev_time_period는 유지.
|
|
169
|
+
# 시점은 본문 표현("1년 전")에서 계산한 것이라 leak이 아니며,
|
|
170
|
+
# prev_value가 없어도 시점만 있으면 통계 DB에서 그 시점 값을
|
|
171
|
+
# 직접 조회해 검증할 수 있음.
|
|
172
|
+
if prev_phrase and not _source_phrase_in_claim(prev_phrase, claim_text):
|
|
173
|
+
logger.warning(
|
|
174
|
+
f" ⚠️ prev_phrase context leak: {prev_phrase!r} 가 검증 대상 문장에 없음 "
|
|
175
|
+
f"→ prev_value만 폐기 (prev_time_period={prev_time_period!r}는 유지, "
|
|
176
|
+
f"indicator={item.get('indicator')})"
|
|
177
|
+
)
|
|
178
|
+
prev_value = None
|
|
179
|
+
prev_phrase = None
|
|
180
|
+
# prev_time_period는 일부러 유지 — 검증 단계에서 사용
|
|
181
|
+
|
|
182
|
+
# prev_phrase가 있으면 prev_value 환산 정확성도 검증 (E fix 응용)
|
|
183
|
+
if prev_phrase and prev_value is not None:
|
|
184
|
+
corrected_prev, prev_was_corrected = _verify_and_correct_value(
|
|
185
|
+
prev_value, prev_phrase
|
|
186
|
+
)
|
|
187
|
+
if prev_was_corrected:
|
|
188
|
+
logger.warning(
|
|
189
|
+
f" 🔧 prev_value 환산 교정: LLM={prev_value} → 교정={corrected_prev} "
|
|
190
|
+
f"(prev_phrase={prev_phrase!r})"
|
|
191
|
+
)
|
|
192
|
+
prev_value = corrected_prev
|
|
193
|
+
|
|
194
|
+
# [v6.16] time_period가 비어있으면 문서 anchor_year로 채움
|
|
195
|
+
# "전국 공시가격 상승률 4.5%" 처럼 시점 표현이 없는 문장도
|
|
196
|
+
# 기사 작성연도(anchor_year) 기준으로 검증되도록 보정.
|
|
197
|
+
_tp = item.get("time_period") or None
|
|
198
|
+
if not _tp and anchor_year is not None:
|
|
199
|
+
_tp = str(anchor_year)
|
|
200
|
+
logger.info(
|
|
201
|
+
f" [시점 보정] time_period 없음 → anchor_year={anchor_year} 적용 "
|
|
202
|
+
f"(indicator={item.get('indicator')})"
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
schema_kwargs = dict(
|
|
206
|
+
indicator=item.get("indicator") or None,
|
|
207
|
+
time_period=_tp,
|
|
208
|
+
unit=item.get("unit") or None,
|
|
209
|
+
population=item.get("population") or None,
|
|
210
|
+
value=corrected_value,
|
|
211
|
+
source_reference=item.get("source_reference") or None,
|
|
212
|
+
graph_schema_candidates=item.get("graph_schema_candidates") or [],
|
|
213
|
+
parent_path=item.get("parent_path") or None,
|
|
214
|
+
is_approximate=bool(item.get("is_approximate", False)),
|
|
215
|
+
modifier=item.get("modifier") or None,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# [2026-05-21] aggregation 필드 추출 — null-safe, 도메인 무관.
|
|
219
|
+
# LLM이 "평균/총합/최근 N년" 류 신호를 감지해 채우며 한국어 키워드 하드코딩 X.
|
|
220
|
+
# 모두 None이면 일반 base/derived 흐름으로 폴백. ClaimSchema 구버전 호환은 try/except.
|
|
221
|
+
_agg_op_raw = item.get("aggregation")
|
|
222
|
+
_agg_op = str(_agg_op_raw).strip().lower() if _agg_op_raw else None
|
|
223
|
+
if _agg_op in ("", "null", "none"):
|
|
224
|
+
_agg_op = None
|
|
225
|
+
_agg_window_raw = item.get("aggregation_window")
|
|
226
|
+
try:
|
|
227
|
+
_agg_window = int(_agg_window_raw) if _agg_window_raw is not None else None
|
|
228
|
+
if _agg_window is not None and _agg_window <= 0:
|
|
229
|
+
_agg_window = None
|
|
230
|
+
except (TypeError, ValueError):
|
|
231
|
+
_agg_window = None
|
|
232
|
+
_agg_range_raw = item.get("aggregation_time_range")
|
|
233
|
+
if isinstance(_agg_range_raw, list):
|
|
234
|
+
_agg_range = [str(x).strip() for x in _agg_range_raw if x is not None and str(x).strip()]
|
|
235
|
+
_agg_range = _agg_range or None
|
|
236
|
+
else:
|
|
237
|
+
_agg_range = None
|
|
238
|
+
try:
|
|
239
|
+
ClaimSchema.model_fields["aggregation"]
|
|
240
|
+
schema_kwargs["aggregation"] = _agg_op
|
|
241
|
+
schema_kwargs["aggregation_window"] = _agg_window
|
|
242
|
+
schema_kwargs["aggregation_time_range"] = _agg_range
|
|
243
|
+
except KeyError:
|
|
244
|
+
# 구버전 ClaimSchema — 무시
|
|
245
|
+
if _agg_op:
|
|
246
|
+
logger.warning(
|
|
247
|
+
f" ℹ️ aggregation={_agg_op!r} 추출됐으나 ClaimSchema에 필드 없음. "
|
|
248
|
+
f"core/schemas.py에 aggregation/aggregation_window/aggregation_time_range 추가 필요."
|
|
249
|
+
)
|
|
250
|
+
# prev_* 필드는 schemas.py에 *추가됐을 때만* 전달
|
|
251
|
+
# (구버전 schemas.py와 backward compat)
|
|
252
|
+
try:
|
|
253
|
+
ClaimSchema.model_fields["prev_value"] # 필드 존재 여부 확인
|
|
254
|
+
schema_kwargs["prev_value"] = prev_value
|
|
255
|
+
schema_kwargs["prev_time_period"] = prev_time_period
|
|
256
|
+
schema_kwargs["prev_phrase"] = prev_phrase or None
|
|
257
|
+
except KeyError:
|
|
258
|
+
# schemas.py에 prev_* 필드 없음 — 무시
|
|
259
|
+
if prev_value is not None:
|
|
260
|
+
logger.warning(
|
|
261
|
+
f" ℹ️ prev_value={prev_value} 추출됐으나 ClaimSchema에 필드 없음. "
|
|
262
|
+
f"core/schemas.py에 prev_value/prev_time_period/prev_phrase 필드 추가 필요."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# [2026-05-21] value_role 자동 추론 — schema_inductor가 분기한 *이유*를
|
|
266
|
+
# downstream planner에 명시적으로 전달. LLM이 같은 claim_text를 보고
|
|
267
|
+
# base/derived를 헷갈리는 걸 방지.
|
|
268
|
+
#
|
|
269
|
+
# [K 패치 2026-05-21] indicator suffix 우선 검사. prev_value 유무는
|
|
270
|
+
# 2차 신호로 격하. 합계출산율 0.79처럼 LLM이 *base 절대값 schema*에
|
|
271
|
+
# prev_value=0.73을 추가 정보로 박아도 *indicator에 ~증가/~차이 같은
|
|
272
|
+
# derived suffix가 없으면 base*로 분류.
|
|
273
|
+
#
|
|
274
|
+
# - indicator suffix(~증가율/~비율 류) + 비율 단위 → derived_rate
|
|
275
|
+
# - indicator suffix(~증가/~감소/~차이 류, 비율 아님) → derived_difference
|
|
276
|
+
# - 그 외 → base (prev_value 있어도 base — 단일 값 검증)
|
|
277
|
+
try:
|
|
278
|
+
ClaimSchema.model_fields["value_role"]
|
|
279
|
+
_ind = (item.get("indicator") or "").strip()
|
|
280
|
+
_unit = (item.get("unit") or "").strip()
|
|
281
|
+
_RATE_SUFFIXES = (
|
|
282
|
+
"증가율", "감소율", "증감률", "변화율", "상승률", "하락률",
|
|
283
|
+
"비율", "비중",
|
|
284
|
+
)
|
|
285
|
+
_DIFF_SUFFIXES = (
|
|
286
|
+
"증가", "감소", "증감", "변화", "차이",
|
|
287
|
+
)
|
|
288
|
+
_is_rate_indicator = any(_ind.endswith(s) for s in _RATE_SUFFIXES)
|
|
289
|
+
_is_pct_unit = _unit in ("%", "퍼센트", "퍼센트포인트", "%p")
|
|
290
|
+
_is_diff_indicator = (
|
|
291
|
+
any(_ind.endswith(s) for s in _DIFF_SUFFIXES)
|
|
292
|
+
and not _is_rate_indicator
|
|
293
|
+
)
|
|
294
|
+
# [2026-05-21] aggregation 우선 분기 — LLM이 aggregation 연산자를 채웠으면
|
|
295
|
+
# base/derived 분류보다 우선. 도메인 무관 (LLM이 의미 판단).
|
|
296
|
+
_has_agg = bool(_agg_op) or bool(_agg_window) or bool(_agg_range)
|
|
297
|
+
if _has_agg:
|
|
298
|
+
schema_kwargs["value_role"] = "aggregation"
|
|
299
|
+
# aggregation은 단일 시점이 아닌 N개 시점 fetch이므로 prev_*는 의미 없음 → clear
|
|
300
|
+
if schema_kwargs.get("prev_value") is not None or schema_kwargs.get("prev_time_period"):
|
|
301
|
+
logger.info(
|
|
302
|
+
f" [U] aggregation 분류 → prev_value/prev_time_period clear "
|
|
303
|
+
f"(indicator={_ind!r}, agg={_agg_op!r}, window={_agg_window!r})"
|
|
304
|
+
)
|
|
305
|
+
schema_kwargs["prev_value"] = None
|
|
306
|
+
schema_kwargs["prev_time_period"] = None
|
|
307
|
+
schema_kwargs["prev_phrase"] = None
|
|
308
|
+
elif _is_rate_indicator or _is_pct_unit:
|
|
309
|
+
schema_kwargs["value_role"] = "derived_rate"
|
|
310
|
+
elif _is_diff_indicator:
|
|
311
|
+
schema_kwargs["value_role"] = "derived_difference"
|
|
312
|
+
else:
|
|
313
|
+
# base — indicator suffix가 derived가 아니면 prev_value 유무
|
|
314
|
+
# 무관하게 base. prev_value는 후처리에서 *clear* 해서 reflect
|
|
315
|
+
# LLM이 자율 prev fetch 시도하지 않도록 한다.
|
|
316
|
+
schema_kwargs["value_role"] = "base"
|
|
317
|
+
if schema_kwargs.get("prev_value") is not None or schema_kwargs.get("prev_time_period"):
|
|
318
|
+
logger.info(
|
|
319
|
+
f" [K] base 분류 → prev_value/prev_time_period clear "
|
|
320
|
+
f"(indicator={_ind!r}, was prev_value={schema_kwargs.get('prev_value')!r})"
|
|
321
|
+
)
|
|
322
|
+
schema_kwargs["prev_value"] = None
|
|
323
|
+
schema_kwargs["prev_time_period"] = None
|
|
324
|
+
schema_kwargs["prev_phrase"] = None
|
|
325
|
+
except KeyError:
|
|
326
|
+
pass # value_role 필드 없는 구버전 — 무시
|
|
327
|
+
|
|
328
|
+
schema = ClaimSchema(**schema_kwargs)
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.debug(f"개별 schema 파싱 실패: {e}")
|
|
331
|
+
continue
|
|
332
|
+
|
|
333
|
+
if _validate_schema(schema):
|
|
334
|
+
results.append(schema)
|
|
335
|
+
|
|
336
|
+
# ── [v6.15 L fix] 차이 schema의 prev_value 자동 역산 ─────────────────
|
|
337
|
+
# 같은 sentence에서 *절대값 schema*와 *차이 schema*가 함께 나왔을 때,
|
|
338
|
+
# 차이 schema의 prev_value가 *비어있으면* → 절대값 − 차이값으로 역산.
|
|
339
|
+
#
|
|
340
|
+
# 예: "합계출산율 0.79명으로 지난해보다 0.06명 증가"
|
|
341
|
+
# - 절대값 schema: 합계출산율=0.79
|
|
342
|
+
# - 차이 schema: 합계출산율 차이=0.06, prev_value=None
|
|
343
|
+
# → 자동 역산: prev_value = 0.79 - 0.06 = 0.73
|
|
344
|
+
#
|
|
345
|
+
# 효과: verifier C2 분기가 작동 → KOSIS 절대값 row와 자동 계산 비교.
|
|
346
|
+
|
|
347
|
+
def _prev_year_period(tp: str | None) -> str | None:
|
|
348
|
+
"""[수정 v6.23] time_period에서 '1년 전' 시점을 계산.
|
|
349
|
+
|
|
350
|
+
'차이/증감' claim의 비교 기준은 보통 '지난해 같은 달/기간'이다.
|
|
351
|
+
prev_time_period를 현재 시점과 똑같이 두면(이전 버그) prev와
|
|
352
|
+
current가 같아져 → fetch가 두 시점을 못 가져오고 검증 불가.
|
|
353
|
+
연도만 1 빼고 월/분기 부분은 그대로 둔다. 도메인 무관.
|
|
354
|
+
|
|
355
|
+
'2025-04' → '2024-04' / '2023' → '2022' / '2025Q2' → '2024Q2'
|
|
356
|
+
"""
|
|
357
|
+
if not tp:
|
|
358
|
+
return None
|
|
359
|
+
s = str(tp).strip()
|
|
360
|
+
import re as _re
|
|
361
|
+
m = _re.match(r"^(\d{4})(.*)$", s)
|
|
362
|
+
if not m:
|
|
363
|
+
return None
|
|
364
|
+
try:
|
|
365
|
+
year = int(m.group(1))
|
|
366
|
+
except ValueError:
|
|
367
|
+
return None
|
|
368
|
+
return f"{year - 1}{m.group(2)}"
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
_has_prev_field = "prev_value" in ClaimSchema.model_fields
|
|
372
|
+
except Exception:
|
|
373
|
+
_has_prev_field = False
|
|
374
|
+
|
|
375
|
+
if _has_prev_field and len(results) >= 2:
|
|
376
|
+
# 절대값 schema (indicator에 "차이/증감/변화량" 없음) 찾기
|
|
377
|
+
abs_schemas = [
|
|
378
|
+
s for s in results
|
|
379
|
+
if s.indicator and not any(
|
|
380
|
+
kw in s.indicator for kw in ("차이", "증감", "변화량", "증가율")
|
|
381
|
+
) and s.value is not None
|
|
382
|
+
]
|
|
383
|
+
diff_schemas = [
|
|
384
|
+
s for s in results
|
|
385
|
+
if s.indicator and ("차이" in s.indicator or "증감" in s.indicator
|
|
386
|
+
or "변화량" in s.indicator)
|
|
387
|
+
and s.value is not None
|
|
388
|
+
and getattr(s, "prev_value", None) is None
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
for diff_s in diff_schemas:
|
|
392
|
+
# 같은 indicator base 찾기 (예: "합계출산율 차이" → "합계출산율")
|
|
393
|
+
diff_base = diff_s.indicator
|
|
394
|
+
for kw in ("차이", "증감", "변화량"):
|
|
395
|
+
diff_base = diff_base.replace(kw, "").strip()
|
|
396
|
+
|
|
397
|
+
# 매칭되는 절대값 schema
|
|
398
|
+
matching_abs = None
|
|
399
|
+
for abs_s in abs_schemas:
|
|
400
|
+
if abs_s.indicator and (abs_s.indicator == diff_base
|
|
401
|
+
or diff_base in abs_s.indicator
|
|
402
|
+
or abs_s.indicator in diff_base):
|
|
403
|
+
# 단위도 비슷한지 (둘 다 비어있거나 둘 다 있고 같은 type)
|
|
404
|
+
if (not abs_s.unit and not diff_s.unit) or \
|
|
405
|
+
(abs_s.unit and diff_s.unit and abs_s.unit == diff_s.unit):
|
|
406
|
+
matching_abs = abs_s
|
|
407
|
+
break
|
|
408
|
+
|
|
409
|
+
if matching_abs:
|
|
410
|
+
# 역산: prev = current - diff
|
|
411
|
+
derived_prev = matching_abs.value - diff_s.value
|
|
412
|
+
# [수정 v6.23] prev_time_period — '지난해 같은 달/기간'이므로
|
|
413
|
+
# 현재 시점에서 1년 전으로 계산. (이전 버그: 현재 시점을
|
|
414
|
+
# 그대로 넣어 prev==current → fetch가 두 시점 확보 실패 →
|
|
415
|
+
# difference claim이 '단일 fetch로 검증 불가'로 끝남)
|
|
416
|
+
# LLM이 prev_time_period를 채웠으면 그대로 존중, 비었으면 역산.
|
|
417
|
+
_llm_prev_tp = getattr(diff_s, "prev_time_period", None)
|
|
418
|
+
_derived_prev_tp = _llm_prev_tp or _prev_year_period(
|
|
419
|
+
matching_abs.time_period
|
|
420
|
+
)
|
|
421
|
+
# diff_s에 prev_value 채워넣기 (model_copy)
|
|
422
|
+
try:
|
|
423
|
+
updated = diff_s.model_copy(update={
|
|
424
|
+
"prev_value": derived_prev,
|
|
425
|
+
"prev_time_period": _derived_prev_tp,
|
|
426
|
+
"prev_phrase": None, # 역산이라 원문 phrase 없음
|
|
427
|
+
})
|
|
428
|
+
# results 안에서 교체
|
|
429
|
+
for i, s in enumerate(results):
|
|
430
|
+
if s is diff_s:
|
|
431
|
+
results[i] = updated
|
|
432
|
+
break
|
|
433
|
+
logger.info(
|
|
434
|
+
f" ✨ prev_value 역산 (L fix): {diff_s.indicator}={diff_s.value} "
|
|
435
|
+
f"← {matching_abs.indicator}={matching_abs.value} - {diff_s.value} "
|
|
436
|
+
f"= {derived_prev:.4f} (prev_time={_derived_prev_tp})"
|
|
437
|
+
)
|
|
438
|
+
except Exception as e:
|
|
439
|
+
logger.debug(f"prev_value 역산 실패: {e}")
|
|
440
|
+
|
|
441
|
+
return results
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""detection/schema/regenerate.py — observation 기반 schema 재분류.
|
|
2
|
+
|
|
3
|
+
schema_inductor.py에서 분리 (로직 move-only, 동작 변경 없음).
|
|
4
|
+
|
|
5
|
+
[2026-05-27] regenerate_schema — ReplanTool 연동
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from structverify.detection.prompts.schema import REGENERATE_SCHEMA_PROMPT
|
|
14
|
+
from structverify.detection._config import model_tier_for
|
|
15
|
+
from structverify.detection._llm import get_llm_client
|
|
16
|
+
from structverify.detection.schema.validate import _safe_float
|
|
17
|
+
from structverify.utils.logger import get_logger
|
|
18
|
+
|
|
19
|
+
logger = get_logger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ── [2026-05-27] regenerate_schema ───────────────────────────────────
|
|
23
|
+
|
|
24
|
+
def _summarize_observations_for_schema(observations: list[dict]) -> str:
|
|
25
|
+
"""observation list를 schema regeneration 프롬프트용으로 요약.
|
|
26
|
+
|
|
27
|
+
fetch_evidence observation에서 추출:
|
|
28
|
+
- 시도된 stat_id, 표 이름
|
|
29
|
+
- PRD_DE 분포 (어떤 시점 데이터가 있는지)
|
|
30
|
+
- ITM_NM/C2_NM unique 값 (어떤 분류가 있는지)
|
|
31
|
+
- 매칭된 sample row의 indicator + value (있으면)
|
|
32
|
+
"""
|
|
33
|
+
if not observations:
|
|
34
|
+
return "(없음)"
|
|
35
|
+
lines: list[str] = []
|
|
36
|
+
for i, ob in enumerate(observations[:10], 1): # 최대 10개
|
|
37
|
+
if not isinstance(ob, dict):
|
|
38
|
+
continue
|
|
39
|
+
action = ob.get("action", "")
|
|
40
|
+
summary = str(ob.get("summary", ""))[:160]
|
|
41
|
+
success = ob.get("success")
|
|
42
|
+
line = f" [{i}] action={action} success={success}\n summary={summary!r}"
|
|
43
|
+
# fetch 관련 부가 정보
|
|
44
|
+
if action == "fetch_evidence":
|
|
45
|
+
stat_id = ob.get("stat_id")
|
|
46
|
+
fv = ob.get("fetched_value")
|
|
47
|
+
ft = ob.get("fetched_time")
|
|
48
|
+
if stat_id:
|
|
49
|
+
line += f"\n stat_id={stat_id!r}"
|
|
50
|
+
if fv is not None:
|
|
51
|
+
line += f" fetched_value={fv} time={ft!r}"
|
|
52
|
+
tried = ob.get("tried_candidates")
|
|
53
|
+
if tried:
|
|
54
|
+
line += f"\n tried_candidates={tried}"
|
|
55
|
+
elif action == "catalog_search":
|
|
56
|
+
top3 = ob.get("candidates_top3")
|
|
57
|
+
if top3:
|
|
58
|
+
line += f"\n top3={top3}"
|
|
59
|
+
lines.append(line)
|
|
60
|
+
return "\n".join(lines) if lines else "(없음)"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def regenerate_schema(
|
|
64
|
+
*,
|
|
65
|
+
claim_text: str,
|
|
66
|
+
original_schema: dict | None,
|
|
67
|
+
observations: list[dict],
|
|
68
|
+
config: dict | None = None,
|
|
69
|
+
) -> dict | None:
|
|
70
|
+
"""원문 + observation으로 schema 재분류.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
claim_text: 원본 claim 문장.
|
|
74
|
+
original_schema: 초기 induction이 만든 schema dict.
|
|
75
|
+
observations: ReplanTool이 수집한 observation 요약 리스트.
|
|
76
|
+
config: 전체 config.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
새 schema dict (value_role 등 갱신). 실패 시 None.
|
|
80
|
+
"""
|
|
81
|
+
import json
|
|
82
|
+
|
|
83
|
+
if not claim_text:
|
|
84
|
+
logger.warning("[schema_inductor.regenerate] claim_text 비어있음")
|
|
85
|
+
return None
|
|
86
|
+
orig_json = "(없음)"
|
|
87
|
+
if original_schema:
|
|
88
|
+
try:
|
|
89
|
+
orig_json = json.dumps(
|
|
90
|
+
original_schema, ensure_ascii=False, indent=2, default=str,
|
|
91
|
+
)
|
|
92
|
+
except Exception:
|
|
93
|
+
orig_json = str(original_schema)
|
|
94
|
+
|
|
95
|
+
obs_summary = _summarize_observations_for_schema(observations or [])
|
|
96
|
+
|
|
97
|
+
prompt = REGENERATE_SCHEMA_PROMPT.format(
|
|
98
|
+
claim_text=claim_text,
|
|
99
|
+
original_schema_json=orig_json,
|
|
100
|
+
observations_summary=obs_summary,
|
|
101
|
+
)
|
|
102
|
+
logger.info(
|
|
103
|
+
f"[schema_inductor.regenerate] prompt 구성 완료 ({len(prompt)}자) — "
|
|
104
|
+
f"obs={len(observations or [])}건"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
llm = get_llm_client(config)
|
|
108
|
+
try:
|
|
109
|
+
raw = await llm.generate(
|
|
110
|
+
prompt=prompt,
|
|
111
|
+
system_prompt="당신은 통계 검증 schema 수정자입니다. JSON만 응답.",
|
|
112
|
+
model_tier=model_tier_for(config, "schema_regenerate"),
|
|
113
|
+
)
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.warning(f"[schema_inductor.regenerate] LLM 호출 실패: {e}")
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
logger.info(
|
|
119
|
+
f"[schema_inductor.regenerate] LLM 응답 본문 ↓\n"
|
|
120
|
+
f"────── SCHEMA REGEN RESPONSE START ──────\n"
|
|
121
|
+
f"{raw}\n"
|
|
122
|
+
f"────── SCHEMA REGEN RESPONSE END ──────"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# JSON 추출
|
|
126
|
+
m = re.search(r"\{[\s\S]*\}", raw or "")
|
|
127
|
+
if not m:
|
|
128
|
+
logger.warning("[schema_inductor.regenerate] JSON 블록 못 찾음")
|
|
129
|
+
return None
|
|
130
|
+
try:
|
|
131
|
+
new_schema = json.loads(m.group(0))
|
|
132
|
+
except json.JSONDecodeError as e:
|
|
133
|
+
logger.warning(f"[schema_inductor.regenerate] JSON parse 실패: {e}")
|
|
134
|
+
return None
|
|
135
|
+
if not isinstance(new_schema, dict):
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
# 정규화 (필수 키 유지)
|
|
139
|
+
out: dict[str, Any] = {}
|
|
140
|
+
for k in [
|
|
141
|
+
"indicator", "time_period", "unit", "population",
|
|
142
|
+
"value", "value_role",
|
|
143
|
+
"prev_value", "prev_time_period", "prev_phrase",
|
|
144
|
+
"parent_path", "modifier",
|
|
145
|
+
]:
|
|
146
|
+
if k in new_schema:
|
|
147
|
+
out[k] = new_schema[k]
|
|
148
|
+
# value/prev_value는 안전 float
|
|
149
|
+
if "value" in out:
|
|
150
|
+
out["value"] = _safe_float(out["value"])
|
|
151
|
+
if "prev_value" in out:
|
|
152
|
+
out["prev_value"] = _safe_float(out["prev_value"])
|
|
153
|
+
|
|
154
|
+
reason = new_schema.get("reason") or ""
|
|
155
|
+
logger.info(
|
|
156
|
+
f"[schema_inductor.regenerate] 새 schema — "
|
|
157
|
+
f"value_role={out.get('value_role')!r}, "
|
|
158
|
+
f"prev_time_period={out.get('prev_time_period')!r}, "
|
|
159
|
+
f"prev_value={out.get('prev_value')!r}, "
|
|
160
|
+
f"reason={reason[:160]!r}"
|
|
161
|
+
)
|
|
162
|
+
return out
|