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,130 @@
1
+ """detection/schema/temporal_hints.py — schema induction 시점 hint 빌더.
2
+
3
+ schema_inductor.py에서 분리 (로직 move-only, 동작 변경 없음).
4
+
5
+ [v6.15] 상대 시점 표현 → 절대 연도 변환표
6
+ [v6.19] multi_temporal 판정 — 단정 hint vs 변환표 분기
7
+ [v6.20] claim_text 정규식 스캔 — 그래프 누락 보완
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from structverify.core.schemas import Claim
14
+ from structverify.utils.logger import get_logger
15
+
16
+ if TYPE_CHECKING:
17
+ from structverify.graph.graph_builder import ClaimGraph
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ # [v6.20] claim 문장 텍스트에서 직접 셀 상대/절대 시점 표현 패턴.
23
+ # document_graph의 LLM temporal agent가 한 문장의 표현을 일부 누락하면
24
+ # (예: "작년...재작년..."에서 "작년"을 빠뜨림) count_temporal_expressions가
25
+ # 1을 반환 → multi_temporal=False → 잘못된 단정 hint. 그래서 그래프와
26
+ # 별개로 claim_text를 정규식으로 스캔해 보수적으로 multi 여부를 판정한다.
27
+ _TEMPORAL_TEXT_PATTERNS = (
28
+ "재작년", "지지난해", "지지난 해",
29
+ "작년", "지난해", "지난 해", "전년",
30
+ "올해", "금년", "이번 해",
31
+ "내년", "이듬해", "다음 해",
32
+ "내후년",
33
+ )
34
+
35
+
36
+ def _count_temporal_in_text(text: str) -> int:
37
+ """claim 문장 텍스트에서 상대 시점 표현의 개수를 센다.
38
+
39
+ "작년 X도, 재작년 Y도" → 2 (작년 1 + 재작년 1).
40
+ 겹치는 패턴 중복 카운트를 막기 위해, 긴 패턴부터 매칭하며
41
+ 매칭된 구간을 소거한다 ('재작년'을 먼저 잡아야 '작년'이
42
+ 그 안에서 다시 안 잡힌다).
43
+ """
44
+ if not text:
45
+ return 0
46
+ s = str(text)
47
+ count = 0
48
+ # 긴 패턴 우선 (재작년 → 작년 순서 보장)
49
+ for pat in sorted(_TEMPORAL_TEXT_PATTERNS, key=len, reverse=True):
50
+ while pat in s:
51
+ count += 1
52
+ s = s.replace(pat, "\x00" * len(pat), 1) # 매칭 구간 소거
53
+ return count
54
+
55
+
56
+ def _build_temporal_hint(graph: "ClaimGraph", claim: Claim) -> str:
57
+ """
58
+ 그래프 시점 해소 결과를 prompt hint 텍스트로.
59
+
60
+ [v6.15] 상대 시점 표현 매핑 강화:
61
+ anchor_year 기준으로 '내년/올해/작년/지난해/재작년'을 모두 절대 연도로
62
+ 변환하는 표를 LLM에게 명시 → time_period=null 방지.
63
+ """
64
+ prov = graph.temporal_provenance(claim)
65
+ anchor_year = graph.get_anchor_year()
66
+
67
+ # [v6.19] 한 문장에 시간표현이 여러 개면 (예: "작년 X도, 재작년 Y도")
68
+ # temporal_provenance가 어느 표현이 이 claim의 것인지 구분 못 하고
69
+ # 첫 번째를 무조건 반환한다 → 단정적 hint가 틀릴 수 있음.
70
+ # 이 경우 단정하지 말고 anchor 변환표만 줘서 LLM이 claim 문맥으로
71
+ # 직접 시점을 고르게 한다.
72
+ _te_count = graph.count_temporal_expressions(claim)
73
+ # [v6.20] 그래프 카운트와 별개로 claim 문장 텍스트도 직접 스캔.
74
+ # temporal agent가 표현을 누락해도(그래프 te_count=1) 텍스트에
75
+ # 상대표현이 2개 이상이면 multi로 판정 → 잘못된 단정 hint 방지.
76
+ _text_te_count = _count_temporal_in_text(
77
+ getattr(claim, "claim_text", "") or ""
78
+ )
79
+ multi_temporal = (_te_count > 1) or (_text_te_count > 1)
80
+
81
+ # [v6.19 진단] multi_temporal 판정과 분기 결정을 로그로 — 어느 경로를
82
+ # 탔는지 안 보여서 temporal 수정이 먹혔는지 확인이 안 됨.
83
+ _branch = (
84
+ "단정(prov)" if (prov and prov.get("resolved") and not multi_temporal)
85
+ else ("변환표(anchor)" if anchor_year is not None else "없음")
86
+ )
87
+ logger.info(
88
+ f"[temporal_hint] {getattr(claim, 'sent_id', '?')}: "
89
+ f"te_count={_te_count} text_te={_text_te_count} "
90
+ f"multi_temporal={multi_temporal} "
91
+ f"prov_resolved={(prov or {}).get('resolved')} "
92
+ f"prov_expr={(prov or {}).get('expression')!r} "
93
+ f"anchor={anchor_year} → branch={_branch}"
94
+ )
95
+
96
+ if prov and prov.get("resolved") and not multi_temporal:
97
+ return (
98
+ f"\n[시점 정보 — 그래프 해소 결과]\n"
99
+ f"- 원문 표현: {prov.get('expression')}\n"
100
+ f"- 해소된 절대 시점: {prov['resolved']}\n"
101
+ f"- 근거: {prov.get('basis') or '문서 anchor 기반'}\n"
102
+ f"위 절대 시점을 time_period로 사용하세요."
103
+ )
104
+ elif anchor_year is not None:
105
+ # [v6.15] 상대 표현 → 절대 연도 변환표를 명시적으로 제공
106
+ multi_note = ""
107
+ if multi_temporal:
108
+ # [v6.19] 한 문장에 시간표현이 여러 개 — 수치별로 구분 지시
109
+ multi_note = (
110
+ f"- ⚠️ 이 문장에는 시점 표현이 *둘 이상* 있습니다 "
111
+ f"(예: '작년 X도, 재작년 Y도').\n"
112
+ f" 각 수치 바로 앞/근처의 시점 표현을 보고 schema마다 "
113
+ f"time_period를 *개별적으로* 정확히 매칭하세요.\n"
114
+ f" 모든 수치에 같은 시점을 쓰지 마세요.\n"
115
+ )
116
+ return (
117
+ f"\n[시점 정보 — 문서 anchor]\n"
118
+ f"- 이 문서의 기준 연도(anchor_year): {anchor_year}\n"
119
+ f"{multi_note}"
120
+ f"- 상대 시점 표현은 *반드시* 아래 표대로 절대 연도로 변환하세요:\n"
121
+ f" '내후년' → {anchor_year + 2}\n"
122
+ f" '내년/이듬해' → {anchor_year + 1}\n"
123
+ f" '올해/금년/현재' → {anchor_year}\n"
124
+ f" '작년/지난해' → {anchor_year - 1}\n"
125
+ f" '재작년' → {anchor_year - 2}\n"
126
+ f"- ★ 검증 대상 문장에 위 상대 표현이 하나라도 있으면\n"
127
+ f" time_period를 절대 연도(예: '{anchor_year + 1}')로 *반드시* 채우세요.\n"
128
+ f"- ★ time_period를 null로 두지 마세요. 시점 단서가 전혀 없을 때만 null."
129
+ )
130
+ return ""
@@ -0,0 +1,193 @@
1
+ """detection/schema/validate.py — schema induction 수치·source_phrase 검증.
2
+
3
+ schema_inductor.py에서 분리 (로직 move-only, 동작 변경 없음).
4
+
5
+ [v6.14] context leak 방지 — _source_phrase_in_claim
6
+ [v6.14 E] value 환산 정확성 — _verify_and_correct_value
7
+ [박재윤 2026-05-18] _extract_numbers_from_text "N만 NNNN" 패턴
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any
13
+
14
+ from structverify.core.schemas import ClaimSchema
15
+
16
+
17
+ # ── [v6.14 E fix] value 환산 정확성 검증 ───────────────────────────────
18
+
19
+ def _verify_and_correct_value(
20
+ value: float | None,
21
+ source_phrase: str,
22
+ ) -> tuple[float | None, bool]:
23
+ """
24
+ LLM이 보낸 value의 환산 정확성을 source_phrase로부터 검증.
25
+
26
+ "2만 171" → 21710 같은 환산 오류를 잡아냄:
27
+ - source_phrase에서 우리 코드로 수치 추출 (한글 단위 포함)
28
+ - 그 수치 집합 중 value와 *가장 가까운 값* 찾음
29
+ - 차이가 0.5% 이상이면 → 그 가까운 값으로 교체
30
+
31
+ Returns:
32
+ (corrected_value, was_corrected_flag)
33
+ """
34
+ if value is None or not source_phrase:
35
+ return value, False
36
+
37
+ sp_numbers = _extract_numbers_from_text(source_phrase)
38
+ if not sp_numbers:
39
+ return value, False # 환산 불가능한 표현 → 그대로
40
+
41
+ # value에 가장 가까운 수
42
+ closest = min(sp_numbers, key=lambda n: abs(n - value))
43
+
44
+ # 차이 작으면 OK
45
+ if abs(closest - value) < 0.001:
46
+ return value, False
47
+ if value != 0 and abs(closest - value) / abs(value) < 0.005:
48
+ return value, False
49
+
50
+ # 차이 큼 — closest로 교체
51
+ return float(closest), True
52
+
53
+
54
+ # ── [v6.14] Context leak 방지를 위한 검증 헬퍼 ───────────────────────────────
55
+
56
+ def _source_phrase_in_claim(phrase: str, claim_text: str) -> bool:
57
+ """
58
+ LLM이 제공한 source_phrase가 claim_text에 등장하는지 검증.
59
+
60
+ [v6.15] 3단계 비교:
61
+ 1) 직접 substring
62
+ 2) 공백 제거 후 비교
63
+ 3) 숫자 기준 비교 — source_phrase의 모든 숫자가 claim_text에 있으면 통과
64
+ ("6.8%" vs "6.8%↑" 처럼 기호 차이로 1·2단계가 실패하는 경우 대응)
65
+ """
66
+ if not phrase or not claim_text:
67
+ return False
68
+ # 1) 직접 substring
69
+ if phrase in claim_text:
70
+ return True
71
+ # 2) 공백 제거 후 비교 (예: "1만 7921 건" vs "1만 7921건")
72
+ phrase_no_space = re.sub(r"\s+", "", phrase)
73
+ claim_no_space = re.sub(r"\s+", "", claim_text)
74
+ if phrase_no_space in claim_no_space:
75
+ return True
76
+ # 3) [v6.15] 숫자 기준 비교 — 기호(↑↓%、 등) 차이 흡수
77
+ # source_phrase의 숫자들이 모두 claim_text 안에 있으면 leak 아님
78
+ phrase_nums = re.findall(r"\d+\.?\d*", phrase)
79
+ if phrase_nums:
80
+ claim_nums = set(re.findall(r"\d+\.?\d*", claim_text))
81
+ if all(n in claim_nums for n in phrase_nums):
82
+ return True
83
+ return False
84
+
85
+
86
+ def _value_in_claim_text(value: float, claim_text: str) -> bool:
87
+ """
88
+ source_phrase가 누락된 경우 fallback. value의 환산 전 표기가 문장에 있는지 검증.
89
+
90
+ 예: value=20171 → "2만 171", "20171", "20,171" 등 매칭 시도.
91
+ """
92
+ if value is None:
93
+ return True
94
+ if not claim_text:
95
+ return False
96
+
97
+ # 텍스트에서 모든 수치(한글 단위 포함) 추출 → 집합 만들고 value와 매칭
98
+ numbers = _extract_numbers_from_text(claim_text)
99
+ for n in numbers:
100
+ if abs(n - value) < 0.001:
101
+ return True
102
+ if value != 0 and abs(n - value) / abs(value) < 0.005:
103
+ return True
104
+ return False
105
+
106
+
107
+ def _extract_numbers_from_text(text: str) -> set[float]:
108
+ """
109
+ 텍스트에서 한글 단위 포함 모든 수치 추출.
110
+
111
+ - "2만 171" → 20171
112
+ - "1만 9059" → 19059
113
+ - "6.7" → 6.7
114
+ - "0.76" → 0.76
115
+ - "238,317" → 238317
116
+ """
117
+ numbers: set[float] = set()
118
+
119
+ # 콤마 포함 숫자 그룹을 안전 파싱 (예: "1,500" → 1500). 콤마가 없으면 그대로.
120
+ # [버그수정] 기존 (\d+)는 "1,500만"에서 콤마에 끊겨 "500만"=5,000,000으로 오파싱 →
121
+ # [\d,]+로 콤마를 포함해 "1,500만"=15,000,000으로 올바르게.
122
+ def _n(s: str) -> int:
123
+ return int(s.replace(",", ""))
124
+
125
+ # [박재윤 - 2026-05-18] "N만 N천" 복합 패턴 (앞 패턴보다 먼저 실행 필요)
126
+ # "2869만 3000명" → 28693000
127
+ # "159만 명" 같은 경우와 구분: 뒤 숫자가 1000 단위인 경우
128
+ for m in re.finditer(r"([\d,]+)\s*만\s*(\d{4})", text):
129
+ numbers.add(float(_n(m.group(1)) * 10000 + int(m.group(2))))
130
+
131
+ # 1) 한글 단위 — "N만 M" 또는 "N만"
132
+ for m in re.finditer(r"([\d,]+)\s*만\s*([\d,]+)?", text):
133
+ n = _n(m.group(1)) * 10000
134
+ if m.group(2):
135
+ n += _n(m.group(2))
136
+ numbers.add(float(n))
137
+
138
+ # 2) 한글 단위 — "N억 M" 또는 "N억"
139
+ for m in re.finditer(r"([\d,]+)\s*억\s*([\d,]+)?", text):
140
+ n = _n(m.group(1)) * 100_000_000
141
+ if m.group(2):
142
+ n += _n(m.group(2))
143
+ numbers.add(float(n))
144
+
145
+ # 3) 한글 단위 — "N천 M" (앞에 만/억 없을 때만)
146
+ for m in re.finditer(r"(?<![만억\d,])([\d,]+)\s*천\s*([\d,]+)?", text):
147
+ n = _n(m.group(1)) * 1000
148
+ if m.group(2):
149
+ n += _n(m.group(2))
150
+ numbers.add(float(n))
151
+
152
+ # "N만 M천" 복합 패턴
153
+ for m in re.finditer(r"([\d,]+)\s*만\s*([\d,]+)\s*천", text):
154
+ numbers.add(float(_n(m.group(1)) * 10000 + _n(m.group(2)) * 1000))
155
+
156
+ # 4) 일반 숫자 (콤마 포함 정수 + 소수)
157
+ for m in re.finditer(r"[\d,]+(?:\.\d+)?", text):
158
+ s = m.group().replace(",", "")
159
+ if not s or s in (".",):
160
+ continue
161
+ try:
162
+ numbers.add(float(s))
163
+ except ValueError:
164
+ pass
165
+
166
+ return numbers
167
+
168
+
169
+ def _validate_schema(schema: ClaimSchema) -> bool:
170
+ """indicator 없으면 KOSIS 검색 불가 → 실패 처리."""
171
+ if not schema.indicator or len(schema.indicator.strip()) < 2:
172
+ return False
173
+ return True
174
+
175
+
176
+ def _safe_float(v: Any) -> float | None:
177
+ """다양한 수치 표현 → float 변환.
178
+
179
+ LLM이 이미 한글 단위를 환산해줘야 하지만, 혹시 문자열로 넘어올 때를 위한 백업.
180
+ """
181
+ if v is None:
182
+ return None
183
+ if isinstance(v, (int, float)):
184
+ return float(v)
185
+ if isinstance(v, str):
186
+ cleaned = re.sub(r"[%,약\s]", "", v.strip())
187
+ match = re.search(r"-?[\d.]+", cleaned)
188
+ if match:
189
+ try:
190
+ return float(match.group())
191
+ except ValueError:
192
+ pass
193
+ return None
@@ -0,0 +1,112 @@
1
+ """
2
+ detection/schema_inductor.py — Dynamic Schema Induction (Step 5)
3
+
4
+ [김예슬 - 2026-04-22]
5
+ - SCHEMA_INDUCTION_PROMPT: 도메인 컨텍스트 주입 + 예시 강화
6
+ - _safe_float(): 다양한 수치 표현 파싱 ("64.2%", "약 64" 등)
7
+ - _validate_schema(): 최소 유효성 검증
8
+ - 재시도 로직 추가 (최대 2회)
9
+
10
+ [김예슬 - 2026-04-24]
11
+ - generate_json() → generate_structured() 으로 교체
12
+ · Structured Outputs (HCX-007) → JSON Schema 보장 (파싱 실패 없음)
13
+ - CLAIM_SCHEMA_JSON_SCHEMA: ClaimSchema에 대응하는 JSON Schema 정의 추가
14
+
15
+ [v6.11 - 2026-05-12]
16
+ - 룰베이스 후처리 제거 (_cleanse_indicator, _normalize_time_period)
17
+ - 박재유 SYSTEM_PROMPT 스타일 차용: 단위 강제 + indicator 정제 + parent_path 추출
18
+ - ClaimSchema 신규 필드 추출: parent_path / is_approximate / modifier
19
+ - 모든 정제 책임은 LLM에게 위임 (룰 베이스 X)
20
+
21
+ # [박재윤 - 2026-05-14]: SCHEMA_INDUCTION_PROMPT system_prompt 개선
22
+ # · 예보/예상/전망/예측 indicator → schema 추출 금지 규칙 추가
23
+
24
+ # [박재윤 - 2026-05-15]: SCHEMA_INDUCTION_PROMPT 수치 추출 규칙 보강
25
+ # · "~였다/~이다/~다" 패턴 수치도 추출 대상 명시 (근원물가 2.2% 누락 방지)
26
+ # · "N만 M천" 복합 단위 패턴 _extract_numbers_from_text에 추가
27
+ # (24만 2천 → 242000 환산 오류 방지)
28
+
29
+ # [박재윤 - 2026-05-18]: SCHEMA_INDUCTION_PROMPT source_phrase 원문 보존 규칙 추가
30
+ # · "23만 8000명" → source_phrase 원문 그대로 (8000→8천 변환 금지)
31
+
32
+ # [박재윤 - 2026-05-18]: _extract_numbers_from_text "N만 NNNN" 패턴 추가
33
+ # · "2869만 3000명" → 28693000 환산 (4자리 숫자 붙는 패턴)
34
+ """
35
+ from __future__ import annotations
36
+
37
+ from structverify.core.schemas import Claim
38
+ from structverify.detection.prompts.schema import DOMAIN_HINTS
39
+
40
+ from structverify.detection.schema.validate import _source_phrase_in_claim
41
+
42
+ from structverify.detection.schema.expand import (
43
+ _dedup_null_schemas,
44
+ _expand_claims_from_schemas,
45
+ )
46
+ from structverify.detection.schema.induce import _induce_multiple
47
+ from structverify.detection.schema.regenerate import regenerate_schema
48
+ from structverify.detection._config import detected_domain
49
+ from structverify.detection._llm import get_llm_client
50
+ from structverify.detection.schema.temporal_hints import _build_temporal_hint
51
+ from structverify.utils.logger import get_logger
52
+
53
+ logger = get_logger(__name__)
54
+
55
+
56
+ # ── 메인 진입점 ────────────────────────────────────────────────────────
57
+ async def induce_schemas(
58
+ claims: list[Claim],
59
+ config: dict | None = None,
60
+ graph: "ClaimGraph | None" = None,
61
+ ) -> list[Claim]:
62
+ """
63
+ 각 주장에서 ClaimSchema들을 동적으로 유도한다.
64
+
65
+ [v6.13] 한 claim에서 *여러 ClaimSchema* 추출 가능 (박재유 방식).
66
+ LLM이 한 문장의 모든 검증 가능 수치를 list로 반환.
67
+ 첫 schema는 원래 claim에 부착, 나머지는 claim 복제해서 부착.
68
+
69
+ [v6 멀티홉] graph 있으면 시점 해소 결과를 prompt hint로 주입.
70
+ """
71
+ config = config or {}
72
+ llm = get_llm_client(config)
73
+ success, fail = 0, 0
74
+ expanded: list[Claim] = []
75
+
76
+ for claim in claims:
77
+ domain = detected_domain(config)
78
+ domain_hint = (
79
+ f"주요 지표 예시: {DOMAIN_HINTS[domain]}"
80
+ if domain in DOMAIN_HINTS else ""
81
+ )
82
+
83
+ context = getattr(claim, "context_text", None) or claim.claim_text
84
+ temporal_hint = _build_temporal_hint(graph, claim) if graph else ""
85
+ # [v6.16] 시점 표현이 전혀 없는 claim의 fallback 기준 연도
86
+ anchor_year = graph.get_anchor_year() if graph else None
87
+
88
+ schemas = await _induce_multiple(
89
+ llm, claim.claim_text, domain, domain_hint,
90
+ context=context, temporal_hint=temporal_hint,
91
+ anchor_year=anchor_year,
92
+ )
93
+
94
+ if not schemas:
95
+ # 검증 가능 수치 0개 — 원래 claim은 유지하되 schema=None
96
+ fail += 1
97
+ expanded.append(claim)
98
+ logger.warning(
99
+ f"스키마 유도: {claim.sent_id} → 검증 가능 수치 없음"
100
+ )
101
+ continue
102
+
103
+ schemas = _dedup_null_schemas(schemas)
104
+ new_claims = _expand_claims_from_schemas(claim, schemas)
105
+ expanded.extend(new_claims)
106
+ success += len(new_claims)
107
+
108
+ logger.info(
109
+ f"스키마 유도 완료: {len(claims)}개 claim → {len(expanded)}개 claim "
110
+ f"(성공 schema {success}건, 실패 claim {fail}건)"
111
+ )
112
+ return expanded