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,134 @@
1
+ """
2
+ structverify.agent.tools.query_rewriter — P30: catalog query 일반화.
3
+
4
+ 배경:
5
+ catalog_search는 표 *이름* 임베딩만 보므로, query가 *row-level keyword* (예:
6
+ "체외 충격파 쇄석술 장비 수")면 표 이름과 매칭 단어가 거의 없어 정답 표가
7
+ catalog 후보에 진입조차 못 한다. *표 이름 친화 어휘*로 query를 변형하면
8
+ 같은 의미를 *표 제목 키워드* (예: "시군구별 의료장비 현황")로 표현 가능.
9
+
10
+ 용도:
11
+ catalog_search Tool에서 query_rewrite=true 옵션 시 호출. 원본 query + claim
12
+ 컨텍스트 → 변형 query 후보 N개. 각 변형을 catalog_search에 돌려서 합집합을
13
+ 반환 → 정답 표 진입률 ↑.
14
+
15
+ 호출자가 LLM 변형 시점/횟수를 제어할 수 있도록 *순수 함수*로 제공.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ from typing import Any
22
+
23
+ from structverify.utils.logger import get_logger
24
+
25
+ logger = get_logger(__name__)
26
+
27
+
28
+ def _build_prompt(query: str, claim_info: dict, n: int) -> str:
29
+ return f"""당신은 KOSIS 통계표 검색어 일반화 도우미입니다. 사용자 query가
30
+ *세부 row-level 키워드* (예: "체외 충격파 쇄석술기")인 경우, KOSIS 표 *이름*에
31
+ 주로 쓰이는 *상위 분류 어휘*로 변형하세요. KOSIS 표 이름 패턴 예시:
32
+
33
+ - "시군구별 ○○ 현황", "시도별 ○○ 보유 현황"
34
+ - "주요 ○○ 통계", "○○ 보유율"
35
+ - "기관 종별 ○○", "지역별 ○○"
36
+
37
+ [원본 query]
38
+ {query!r}
39
+
40
+ [claim 컨텍스트]
41
+ - indicator: {claim_info.get('indicator')!r}
42
+ - population (지역/대상): {claim_info.get('population')!r}
43
+ - time_period: {claim_info.get('time_period')!r}
44
+ - unit: {claim_info.get('unit')!r}
45
+
46
+ [변형 규칙]
47
+ 1. 원본 query의 *세부 항목*은 *상위 카테고리*로 일반화 (예: "체외 충격파 쇄석술 장비" → "의료장비")
48
+ 2. population이 지역이면 "시군구별", "시도별" 같은 *분류 어휘* 추가
49
+ 3. 표 제목에 흔한 단어 ("현황", "보유", "주요") 한두 개 자연스럽게 포함
50
+ 4. 의미는 보존하되 *catalog 표 이름과 매칭 확률이 높은 어휘* 사용
51
+ 5. 서로 *겹치지 않는 다양한 angle*의 {n}개 변형 생성 (단순 단어 순서 바꾸기 X)
52
+
53
+ [응답 형식 — JSON only, 다른 텍스트 금지]
54
+ {{
55
+ "variations": ["변형1", "변형2", ...] // 정확히 {n}개
56
+ }}
57
+ """
58
+
59
+
60
+ def _parse_variations(raw: str, n: int) -> list[str]:
61
+ """LLM 응답에서 variations list 추출. 실패 시 빈 list."""
62
+ try:
63
+ m = re.search(r"\{.*\}", raw, re.DOTALL)
64
+ if not m:
65
+ return []
66
+ data = json.loads(m.group(0))
67
+ vs = data.get("variations") or []
68
+ if not isinstance(vs, list):
69
+ return []
70
+ # 정규화 + 중복 제거
71
+ out: list[str] = []
72
+ seen: set[str] = set()
73
+ for v in vs:
74
+ if not isinstance(v, str):
75
+ continue
76
+ s = v.strip().strip("\"'")
77
+ if s and s not in seen:
78
+ seen.add(s)
79
+ out.append(s)
80
+ return out[:n]
81
+ except Exception as e:
82
+ logger.debug(f"[query_rewriter] 파싱 실패: {e}")
83
+ return []
84
+
85
+
86
+ async def rewrite_query(
87
+ *,
88
+ query: str,
89
+ claim: Any,
90
+ config: dict | None,
91
+ ) -> list[str]:
92
+ """원본 query + claim → 변형 query 후보 list.
93
+
94
+ Args:
95
+ query: 원본 catalog 검색어.
96
+ claim: Claim 객체 (schema 추출용).
97
+ config: 전체 config dict. config.catalog_search.query_rewriter.{n_variations, model_tier} 사용.
98
+
99
+ Returns:
100
+ 변형 query list (LLM 실패 시 []). 호출자가 각 변형으로 catalog_search 재호출.
101
+ """
102
+ if not query or not query.strip():
103
+ return []
104
+
105
+ _cfg = (config or {}).get("catalog_search") or {}
106
+ _qr = _cfg.get("query_rewriter") or {}
107
+ n = int(_qr.get("n_variations") or 3)
108
+ model_tier = str(_qr.get("model_tier") or "light").strip().lower()
109
+
110
+ _schema = getattr(claim, "schema", None) if claim is not None else None
111
+ claim_info = {
112
+ "indicator": (getattr(_schema, "indicator", None) or "") if _schema else "",
113
+ "population": (getattr(_schema, "population", None) or "") if _schema else "",
114
+ "time_period": (getattr(_schema, "time_period", None) or "") if _schema else "",
115
+ "unit": (getattr(_schema, "unit", None) or "") if _schema else "",
116
+ }
117
+
118
+ prompt = _build_prompt(query, claim_info, n)
119
+ from structverify.utils.llm_client import LLMClient
120
+ llm = LLMClient(config=(config or {}).get("llm") or {})
121
+ try:
122
+ raw = await llm.generate(
123
+ prompt=prompt,
124
+ system_prompt="KOSIS 검색어 일반화 도우미. JSON만 응답.",
125
+ model_tier=model_tier,
126
+ )
127
+ except Exception as e:
128
+ logger.warning(f"[query_rewriter] LLM 호출 실패: {e}")
129
+ return []
130
+
131
+ variations = _parse_variations(raw, n)
132
+ if variations:
133
+ logger.info(f"[query_rewriter] {query!r} → {variations}")
134
+ return variations
@@ -0,0 +1,144 @@
1
+ """
2
+ structverify.agent.tools.read_original — 원문 기사 읽기 Tool.
3
+
4
+ Agent가 *claim 텍스트만으로 부족*할 때 원문 기사 전체/일부를 읽음.
5
+
6
+ 예시 사용:
7
+ - "이는 1991년 4월(8.7%) 이후 34년 만에" — claim 자체엔 1991년 비교만 있고
8
+ 원문에 *부가 맥락*이 있는지 확인할 때
9
+ - "X→Y" 같은 차이 표현에서 *전년도 정확한 값*을 원문에서 추출하고 싶을 때
10
+ - schema_inductor가 *value=null*로 보낸 schema인데 *원문에 수치가 있는지* 다시 확인
11
+
12
+ 읽기 모드:
13
+ - all: 전체 원문 (긴 기사는 토큰 비용)
14
+ - chars[start:end]: 글자 인덱스로 부분 읽기
15
+ - first/last N: 앞 N자 / 끝 N자
16
+
17
+ 데이터 출처는 workspace.source.txt (job 시작 시 initialize됨).
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from structverify.utils.logger import get_logger
22
+ from typing import Any
23
+
24
+ from ..schemas import ActionType
25
+ from .base import ToolBase, ToolContext, ToolResult, register_tool
26
+
27
+ logger = get_logger(__name__)
28
+
29
+
30
+ # 한 번에 읽는 최대 길이 (LLM 토큰 절약). 너무 길면 잘라냄.
31
+ _DEFAULT_MAX_CHARS = 5000
32
+
33
+
34
+ @register_tool(ActionType.READ_ORIGINAL)
35
+ class ReadOriginalTool(ToolBase):
36
+ """원문 기사 읽기.
37
+
38
+ claim의 source_phrase만으론 부족할 때, 기사 *주변 문장* 또는 *전체*를 봄.
39
+ """
40
+
41
+ name = ActionType.READ_ORIGINAL
42
+ description = (
43
+ "원문 기사 읽기. claim의 source_phrase만으로 부족할 때 *주변 맥락* 또는 *전체* 확인. "
44
+ "예: '지난해 같은 달'이 정확히 몇 년인지, 또는 비교 기준값이 원문에 있는지 등."
45
+ )
46
+ input_schema = {
47
+ "mode": "읽기 모드: 'all' (전체) | 'first' (앞부분) | 'last' (끝부분) | 'chars' (인덱스 범위)",
48
+ "max_chars": "(선택) 최대 글자 수. 기본 5000. 'all' 모드에서 길면 잘라냄.",
49
+ "start": "(mode=chars일 때) 시작 글자 인덱스",
50
+ "end": "(mode=chars일 때) 끝 글자 인덱스",
51
+ }
52
+
53
+ async def execute(
54
+ self,
55
+ input_data: dict[str, Any],
56
+ context: ToolContext,
57
+ ) -> ToolResult:
58
+ mode = (input_data.get("mode") or "all").lower().strip()
59
+ try:
60
+ max_chars = int(input_data.get("max_chars") or _DEFAULT_MAX_CHARS)
61
+ except (TypeError, ValueError):
62
+ max_chars = _DEFAULT_MAX_CHARS
63
+
64
+ # workspace에서 원문 읽기
65
+ try:
66
+ full_text = context.workspace.read_source()
67
+ except FileNotFoundError:
68
+ return ToolResult(
69
+ output={},
70
+ summary="실패: workspace에 source.txt 없음",
71
+ success=False,
72
+ error="workspace.read_source() 실패 — source.txt가 없습니다. "
73
+ "Job 시작 시 workspace.initialize(source_text=...)가 호출됐는지 확인.",
74
+ )
75
+ except Exception as e:
76
+ return ToolResult(
77
+ output={},
78
+ summary=f"실패: 원문 읽기 — {e}",
79
+ success=False,
80
+ error=str(e),
81
+ )
82
+
83
+ total_chars = len(full_text)
84
+
85
+ # 모드별 처리
86
+ if mode == "all":
87
+ text = full_text
88
+ truncated = False
89
+ if len(text) > max_chars:
90
+ text = text[:max_chars]
91
+ truncated = True
92
+ span_desc = f"전체 ({total_chars}자)" + (" — 잘림" if truncated else "")
93
+
94
+ elif mode == "first":
95
+ text = full_text[:max_chars]
96
+ span_desc = f"앞 {len(text)}자 (전체 {total_chars}자)"
97
+
98
+ elif mode == "last":
99
+ text = full_text[-max_chars:]
100
+ span_desc = f"끝 {len(text)}자 (전체 {total_chars}자)"
101
+
102
+ elif mode == "chars":
103
+ try:
104
+ start = int(input_data.get("start", 0))
105
+ end = int(input_data.get("end", total_chars))
106
+ except (TypeError, ValueError) as e:
107
+ return ToolResult(
108
+ output={},
109
+ summary=f"실패: start/end 변환 — {e}",
110
+ success=False,
111
+ error=f"start/end는 정수여야 합니다: {e}",
112
+ )
113
+ # 경계 검증
114
+ start = max(0, min(start, total_chars))
115
+ end = max(start, min(end, total_chars))
116
+ text = full_text[start:end]
117
+ # max_chars 제한도 적용
118
+ if len(text) > max_chars:
119
+ text = text[:max_chars]
120
+ span_desc = f"chars[{start}:{end}] ({len(text)}자)"
121
+
122
+ else:
123
+ return ToolResult(
124
+ output={},
125
+ summary=f"실패: 알 수 없는 mode={mode!r}",
126
+ success=False,
127
+ error="mode는 'all' | 'first' | 'last' | 'chars' 중 하나여야 합니다.",
128
+ )
129
+
130
+ # 요약은 *처음 100자*만 미리보기로
131
+ preview = text.strip()[:100].replace("\n", " ")
132
+ if len(text) > 100:
133
+ preview += "..."
134
+
135
+ return ToolResult(
136
+ output={
137
+ "text": text,
138
+ "total_chars": total_chars,
139
+ "returned_chars": len(text),
140
+ "mode": mode,
141
+ },
142
+ summary=f"원문 읽음 ({span_desc}): {preview}",
143
+ success=True,
144
+ )
@@ -0,0 +1,365 @@
1
+ """
2
+ structverify.agent.tools.replan — Plan 자체를 재작성하는 tool.
3
+
4
+ 배경:
5
+ 기존 fallback 메커니즘(try_ids, catalog retry, row_matcher rescue 등)은 모두
6
+ *같은 plan 안에서* 데이터를 찾는 시도. claim 값이 표에 *직접 row로 없는*
7
+ 경우(예: "강원도 의료장비 증가 수 52"는 절대값 row가 아니라 current-prev delta)
8
+ 에는 어떤 fallback도 못 잡음.
9
+
10
+ Replan tool은 이런 *구조적 mismatch*를 잡음. fetch 모두 실패한 상황에서:
11
+ 1. 표 sample/메타를 다시 확인
12
+ 2. claim 값이 표의 row로 존재하는지 vs 계산 대상인지 LLM이 판단
13
+ 3. 필요 시 *plan 자체*를 새로 생성 (claim_type 변경 + 새 steps)
14
+
15
+ loop이 새 plan을 받아 *이후 iteration*에서 사용.
16
+
17
+ 호출 조건:
18
+ - 모든 try_ids 실패 + 모든 catalog retry 소진 후
19
+ - reflect prompt에서 명시적으로 'replan' 호출 가이드
20
+ - per-claim 최대 2회 (무한 replan 방지 — workspace에서 카운트)
21
+
22
+ 기존 fallback과의 관계:
23
+ ★ 기존 fallback은 *plan 안의 답 찾기* — 그대로 작동
24
+ ★ replan은 *plan을 갈아끼우기* — 위에 얹는 한 층
25
+ → 서로 잡아먹지 않음. replan 호출 후 새 plan으로 다시 fallback 시도 가능.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ from typing import Any
30
+
31
+ from structverify.agent.schemas import ActionType
32
+ from structverify.utils.logger import get_logger
33
+
34
+ from .base import ToolBase, ToolContext, ToolResult, register_tool
35
+
36
+ logger = get_logger(__name__)
37
+
38
+
39
+ # per-claim replan 호출 카운트 키 (workspace observation 파일 이름)
40
+ _REPLAN_COUNT_OBS_KEY = "_replan_count"
41
+ _REPLAN_MAX_PER_CLAIM = 2
42
+
43
+
44
+ def _read_replan_count(workspace, claim_id) -> int:
45
+ """현재 claim의 replan 호출 누적 횟수."""
46
+ if workspace is None:
47
+ return 0
48
+ try:
49
+ data = workspace.read_observation(claim_id, _REPLAN_COUNT_OBS_KEY)
50
+ if isinstance(data, dict):
51
+ return int(data.get("count", 0))
52
+ except Exception:
53
+ pass
54
+ return 0
55
+
56
+
57
+ def _write_replan_count(workspace, claim_id, count: int) -> None:
58
+ if workspace is None:
59
+ return
60
+ try:
61
+ workspace.write_observation(claim_id, _REPLAN_COUNT_OBS_KEY, {"count": count})
62
+ except Exception as e:
63
+ logger.debug(f"[replan] count 저장 실패: {e}")
64
+
65
+
66
+ def _collect_observation_summary(workspace, claim_id) -> list[dict]:
67
+ """현재까지의 observation들에서 fetch 결과 + catalog 결과 요약 추출.
68
+
69
+ planner LLM에게 '여태 무엇을 시도했고 어떤 데이터가 있었는지' 보여주는 용도.
70
+ """
71
+ if workspace is None:
72
+ return []
73
+ try:
74
+ names = sorted(workspace.list_observations(claim_id))
75
+ except Exception:
76
+ return []
77
+
78
+ summaries: list[dict] = []
79
+ for name in names:
80
+ # 내부 메타 파일 skip
81
+ if name.startswith("_"):
82
+ continue
83
+ try:
84
+ data = workspace.read_observation(claim_id, name)
85
+ except Exception:
86
+ continue
87
+ if not isinstance(data, dict):
88
+ continue
89
+ # observation은 보통 {iter_num, action, input, output, summary, success, error}
90
+ action = data.get("action")
91
+ summary = data.get("summary", "")
92
+ success = data.get("success")
93
+ output = data.get("output") or {}
94
+ entry: dict[str, Any] = {
95
+ "obs": name,
96
+ "action": action,
97
+ "success": success,
98
+ "summary": str(summary)[:300],
99
+ }
100
+ # fetch_evidence 성공/실패 시 표 sample도 추출
101
+ if action == ActionType.FETCH_EVIDENCE.value:
102
+ ev = output.get("evidence")
103
+ if isinstance(ev, dict):
104
+ entry["fetched_value"] = ev.get("value")
105
+ entry["fetched_unit"] = ev.get("unit")
106
+ entry["fetched_time"] = ev.get("time_period")
107
+ entry["stat_id"] = output.get("candidate_id")
108
+ elif ev is None:
109
+ entry["fetched_value"] = None
110
+ entry["stat_id"] = output.get("candidate_id")
111
+ entry["tried_candidates"] = output.get("tried_candidates")
112
+ # catalog_search candidates 요약
113
+ elif action == ActionType.CATALOG_SEARCH.value:
114
+ cands = output.get("candidates") or []
115
+ entry["candidates_top3"] = [
116
+ {"id": c.get("id"), "name": c.get("name"), "score": c.get("score")}
117
+ for c in cands[:3] if isinstance(c, dict)
118
+ ]
119
+ summaries.append(entry)
120
+ return summaries
121
+
122
+
123
+ @register_tool(ActionType.REPLAN)
124
+ class ReplanTool(ToolBase):
125
+ """Plan 재작성 tool.
126
+
127
+ 호출 트리거: 모든 catalog 후보 fetch 실패 + retry 소진 후.
128
+ 동작: 지금까지의 observation을 planner LLM에 보여주고 *새 plan* 생성 요청.
129
+ 효과: loop이 새 plan으로 갈아끼우고 *이후* iter에서 새 claim_type/steps 사용.
130
+ """
131
+
132
+ name = ActionType.REPLAN
133
+ description = (
134
+ "Plan 자체를 새로 만든다. claim 값이 표에 *직접 row로 없고* 계산이 "
135
+ "필요한 경우(예: '증가 수', '증감률' 등 delta/derived 지표인데 plan이 "
136
+ "absolute로 잡혀있는 경우) 사용. **호출 조건**: 모든 fetch 후보 실패 + "
137
+ "catalog 재검색도 소진된 후에만. 일반 retry는 catalog_search나 fetch_evidence "
138
+ "재호출로 처리하고, replan은 *마지막 수단*."
139
+ )
140
+ input_schema = {
141
+ "reason": (
142
+ "왜 replan이 필요한지 한 줄 설명. 예: '모든 후보 표에 절대값만 있고 "
143
+ "claim의 변화량 값(52)이 row에 없음 → 차이 계산으로 변경 필요'"
144
+ ),
145
+ }
146
+
147
+ async def execute(
148
+ self,
149
+ input_data: dict[str, Any],
150
+ context: ToolContext,
151
+ ) -> ToolResult:
152
+ reason = str(input_data.get("reason") or "").strip()
153
+ workspace = getattr(context, "workspace", None)
154
+ claim_id = getattr(context, "claim_id", None)
155
+ claim = getattr(context, "claim", None)
156
+
157
+ if claim is None:
158
+ return ToolResult(
159
+ output={},
160
+ summary="replan 실패: context에 claim 없음",
161
+ success=False,
162
+ error="claim_unavailable",
163
+ )
164
+
165
+ # ── 호출 횟수 제한 ─────────────────────────────────────────
166
+ count = _read_replan_count(workspace, claim_id)
167
+ if count >= _REPLAN_MAX_PER_CLAIM:
168
+ logger.warning(
169
+ f"[replan] {claim_id}: max {_REPLAN_MAX_PER_CLAIM}회 도달 — "
170
+ f"replan 거부"
171
+ )
172
+ return ToolResult(
173
+ output={"replan_count": count, "max": _REPLAN_MAX_PER_CLAIM},
174
+ summary=(
175
+ f"replan 거부: 이미 {count}회 시도. 더 이상 plan 재작성 없이 "
176
+ f"현재까지의 evidence로 finish 결정 필요."
177
+ ),
178
+ success=False,
179
+ error="replan_limit_exceeded",
180
+ )
181
+
182
+ # ── observation 컨텍스트 수집 ──────────────────────────────
183
+ obs_summary = _collect_observation_summary(workspace, claim_id)
184
+ logger.info(
185
+ f"[replan] {claim_id}: 호출 #{count + 1}/{_REPLAN_MAX_PER_CLAIM}, "
186
+ f"reason={reason[:120]!r}, observations={len(obs_summary)}건"
187
+ )
188
+
189
+ original_plan = getattr(context, "current_plan", None)
190
+ config = getattr(context, "config", None) or {}
191
+
192
+ # ── Step 1: schema regenerate ──────────────────────────────
193
+ # 원래 schema가 잘못 분류된 경우(예: '증가 수'를 base로) — 표 row sample
194
+ # 보여주고 LLM에게 value_role/prev_time 재분류 받기.
195
+ from structverify.detection.schema_inductor import regenerate_schema
196
+ old_schema_dict: dict | None = None
197
+ try:
198
+ _orig = getattr(claim, "schema", None)
199
+ if _orig is not None and hasattr(_orig, "model_dump"):
200
+ old_schema_dict = _orig.model_dump(mode="json")
201
+ elif isinstance(_orig, dict):
202
+ old_schema_dict = dict(_orig)
203
+ except Exception:
204
+ old_schema_dict = None
205
+
206
+ claim_text = getattr(claim, "claim_text", "") or ""
207
+ new_schema_dict: dict | None = None
208
+ try:
209
+ new_schema_dict = await regenerate_schema(
210
+ claim_text=str(claim_text),
211
+ original_schema=old_schema_dict,
212
+ observations=obs_summary,
213
+ config=config,
214
+ )
215
+ except Exception as e:
216
+ logger.warning(f"[replan] {claim_id}: regenerate_schema 호출 실패: {e}")
217
+ new_schema_dict = None
218
+
219
+ # ── Step 2: schema 변경됐는지 확인 (no-op 거부) ─────────────
220
+ # value_role이 바뀌면 *근본적*으로 다른 검증 — replan 진행.
221
+ # value_role 그대로면 의미 없는 replan → 거부.
222
+ if new_schema_dict is None:
223
+ _write_replan_count(workspace, claim_id, count + 1)
224
+ return ToolResult(
225
+ output={"replan_count": count + 1},
226
+ summary=(
227
+ "replan 실패: schema 재분류 실패. observation에 표 데이터가 "
228
+ "충분하지 않거나 LLM이 새 schema 생성 못 함. "
229
+ "남은 evidence로 finish 결정 권장."
230
+ ),
231
+ success=False,
232
+ error="regenerate_schema_failed",
233
+ )
234
+
235
+ old_role = (old_schema_dict or {}).get("value_role")
236
+ new_role = new_schema_dict.get("value_role")
237
+ if old_role == new_role and (old_schema_dict or {}).get("prev_time_period") == new_schema_dict.get("prev_time_period"):
238
+ # schema 의미 변화 없음 — replan 의미 없음
239
+ _write_replan_count(workspace, claim_id, count + 1)
240
+ logger.warning(
241
+ f"[replan] {claim_id}: schema 변화 없음 "
242
+ f"(value_role={old_role!r} 그대로, prev_time도 동일) — no-op 거부"
243
+ )
244
+ return ToolResult(
245
+ output={
246
+ "replan_count": count + 1,
247
+ "old_value_role": old_role,
248
+ "new_value_role": new_role,
249
+ },
250
+ summary=(
251
+ f"replan 거부: schema 의미 변화 없음 (value_role={old_role!r} 유지). "
252
+ f"이건 같은 plan 반복 시도일 뿐 — *구조적* 재분류가 일어나야 replan. "
253
+ f"현재 evidence로 finish 결정 필요."
254
+ ),
255
+ success=False,
256
+ error="schema_unchanged",
257
+ )
258
+
259
+ # ── Step 3: claim.schema 업데이트 ──────────────────────────
260
+ # ClaimSchema model_copy로 새 schema 적용 → claim 객체 동기화.
261
+ try:
262
+ from structverify.core.schemas import ClaimSchema
263
+ _orig_schema = getattr(claim, "schema", None)
264
+ if _orig_schema is not None and hasattr(_orig_schema, "model_copy"):
265
+ # 새 dict로 update
266
+ _update = {k: v for k, v in new_schema_dict.items() if v is not None}
267
+ _new_schema_obj = _orig_schema.model_copy(update=_update)
268
+ else:
269
+ # 원래 schema 없으면 새로 생성
270
+ _new_schema_obj = ClaimSchema(**new_schema_dict)
271
+ # claim 객체에 직접 setattr — pydantic 모델이라도 가능 (frozen 아니면)
272
+ try:
273
+ claim.schema = _new_schema_obj
274
+ except Exception:
275
+ # frozen 모델이면 새 claim 생성 시도
276
+ if hasattr(claim, "model_copy"):
277
+ _new_claim = claim.model_copy(update={"schema": _new_schema_obj})
278
+ # context.claim 도 갱신 (이후 iter가 새 claim 봐야 함)
279
+ context.claim = _new_claim
280
+ claim = _new_claim
281
+ except Exception as e:
282
+ logger.warning(f"[replan] {claim_id}: claim.schema 업데이트 실패: {e}")
283
+ _write_replan_count(workspace, claim_id, count + 1)
284
+ return ToolResult(
285
+ output={"replan_count": count + 1},
286
+ summary=f"replan 실패: schema 업데이트 오류 — {type(e).__name__}: {e}",
287
+ success=False,
288
+ error=f"schema_update_failed: {e}",
289
+ )
290
+
291
+ logger.info(
292
+ f"[replan] {claim_id}: schema 업데이트 완료 — "
293
+ f"value_role: {old_role!r} → {new_role!r}, "
294
+ f"prev_time_period: {(old_schema_dict or {}).get('prev_time_period')!r} → "
295
+ f"{new_schema_dict.get('prev_time_period')!r}"
296
+ )
297
+
298
+ # ── Step 4: planner.plan 재호출 — 새 schema로 자연스러운 plan ──
299
+ # value_role이 바뀌었으면 planner의 결정적 보정([planner.py:597-611])이
300
+ # 자동으로 claim_type을 새 value_role에 맞게 설정.
301
+ try:
302
+ from structverify.agent.planner import Planner, PlannerConfig
303
+ from structverify.utils.llm_client import LLMClient
304
+ _llm = LLMClient(config=(config or {}).get("llm") or {})
305
+ async def _llm_call(prompt: str) -> str:
306
+ return await _llm.generate(
307
+ prompt=prompt,
308
+ system_prompt="당신은 통계 검증 planner 입니다. JSON으로만 응답.",
309
+ model_tier="heavy",
310
+ )
311
+ planner = Planner(llm_call=_llm_call, config=PlannerConfig())
312
+ new_plan = await planner.plan(claim=claim)
313
+ except Exception as e:
314
+ logger.warning(f"[replan] {claim_id}: planner.plan 재호출 실패: {e}")
315
+ _write_replan_count(workspace, claim_id, count + 1)
316
+ return ToolResult(
317
+ output={"replan_count": count + 1},
318
+ summary=f"replan 실패 (schema는 갱신됨, plan 재생성 실패): {type(e).__name__}: {e}",
319
+ success=False,
320
+ error=f"{type(e).__name__}: {e}",
321
+ )
322
+
323
+ # 카운트 증가 (전체 성공 시점)
324
+ _write_replan_count(workspace, claim_id, count + 1)
325
+
326
+ if new_plan is None:
327
+ return ToolResult(
328
+ output={"replan_count": count + 1},
329
+ summary="replan 실패: planner.plan이 None 반환",
330
+ success=False,
331
+ error="planner_returned_none",
332
+ )
333
+
334
+ logger.info(
335
+ f"[replan] {claim_id}: 새 plan 생성 완료 — "
336
+ f"type={new_plan.claim_type.value}, "
337
+ f"steps={len(new_plan.initial_steps)}, "
338
+ f"formula={new_plan.calculation_formula!r}"
339
+ )
340
+
341
+ # plan을 dict로 직렬화해 output에 실어 보냄. loop이 받아서 교체.
342
+ try:
343
+ new_plan_dict = new_plan.model_dump(mode="json")
344
+ except Exception:
345
+ new_plan_dict = {"_serialize_error": True}
346
+
347
+ return ToolResult(
348
+ output={
349
+ "new_plan": new_plan_dict,
350
+ "new_schema": new_schema_dict,
351
+ "old_value_role": old_role,
352
+ "new_value_role": new_role,
353
+ "replan_count": count + 1,
354
+ "reason": reason,
355
+ },
356
+ summary=(
357
+ f"replan 성공 (#{count + 1}): "
358
+ f"value_role {old_role!r} → {new_role!r}, "
359
+ f"claim_type={new_plan.claim_type.value}, "
360
+ f"steps={len(new_plan.initial_steps)}, "
361
+ f"formula={new_plan.calculation_formula!r}. "
362
+ f"이후 iter는 *새 schema + 새 plan*으로 진행 — 필요한 시점들을 fetch."
363
+ ),
364
+ success=True,
365
+ )