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,817 @@
|
|
|
1
|
+
"""structverify.agent.planner — Plan Agent (Phase C).
|
|
2
|
+
|
|
3
|
+
Pipeline 위치:
|
|
4
|
+
Claim (with schema) → **Planner** → Plan → Loop (Phase D)
|
|
5
|
+
|
|
6
|
+
Planner 책임:
|
|
7
|
+
1. claim에서 *어떤 데이터가 필요한지* 결정 (LLM 호출)
|
|
8
|
+
2. *claim type* 분류 (absolute / growth_rate / diff / ratio / other)
|
|
9
|
+
3. 1차 시도용 *initial steps* 제안
|
|
10
|
+
4. fallback 전략 (1차 실패 시)
|
|
11
|
+
|
|
12
|
+
Reflect Agent (Phase D)는 *이 Plan을 보면서* 실제 행동 결정.
|
|
13
|
+
Plan은 *제안*이지 *명령*이 아님 — Reflect는 plan 무시하고 다른 step 시도 가능.
|
|
14
|
+
|
|
15
|
+
LLM client는 *callable*로 주입 (의존성 주입).
|
|
16
|
+
사용자 환경의 HCX/다른 LLM과 무관하게 wrap 가능.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from structverify.utils.logger import get_logger
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import Any, Awaitable, Callable, Protocol
|
|
25
|
+
|
|
26
|
+
from pydantic import ValidationError
|
|
27
|
+
|
|
28
|
+
from .schemas import (
|
|
29
|
+
ActionType,
|
|
30
|
+
ClaimType,
|
|
31
|
+
DataPointSpec,
|
|
32
|
+
FallbackStrategy,
|
|
33
|
+
Plan,
|
|
34
|
+
PlanStep,
|
|
35
|
+
)
|
|
36
|
+
from .prompts import build_plan_prompt
|
|
37
|
+
|
|
38
|
+
logger = get_logger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── LLM Client 인터페이스 ──────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
class LLMClient(Protocol):
|
|
44
|
+
"""Planner가 사용하는 LLM 호출 인터페이스.
|
|
45
|
+
|
|
46
|
+
사용자 환경의 HCX client (또는 다른 LLM)를 *이 형태로 wrap*하면 됨.
|
|
47
|
+
Phase F integration에서 실제 wiring.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
async def complete(
|
|
51
|
+
self,
|
|
52
|
+
prompt: str,
|
|
53
|
+
model: str = "",
|
|
54
|
+
temperature: float = 0.1,
|
|
55
|
+
max_tokens: int = 4000,
|
|
56
|
+
**kwargs: Any,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""prompt 보내고 응답 텍스트 받기. 동기든 비동기든 OK."""
|
|
59
|
+
...
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Callable 형태도 지원 (가장 단순한 의존성 주입)
|
|
63
|
+
LLMCallable = Callable[[str], Awaitable[str]]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ── 헬퍼: claim 정보 추출 ──────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def _extract_schema_info(claim: Any) -> dict[str, Any]:
|
|
69
|
+
"""Claim 객체에서 schema 정보를 *dict로* 추출.
|
|
70
|
+
|
|
71
|
+
Claim의 정확한 형태는 사용자 코드의 ClaimSchema에 의존하지만,
|
|
72
|
+
pydantic dump 또는 dict-like 접근으로 *어떤 형태든* 호환.
|
|
73
|
+
"""
|
|
74
|
+
if not claim:
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
schema = getattr(claim, "schema", None) or getattr(claim, "claim_schema", None)
|
|
78
|
+
if schema is None:
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
# pydantic v2 model_dump
|
|
82
|
+
if hasattr(schema, "model_dump"):
|
|
83
|
+
try:
|
|
84
|
+
return schema.model_dump(mode="json", exclude_none=True)
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
# pydantic v1 dict()
|
|
88
|
+
if hasattr(schema, "dict"):
|
|
89
|
+
try:
|
|
90
|
+
return schema.dict(exclude_none=True)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
# 평범한 dict
|
|
94
|
+
if isinstance(schema, dict):
|
|
95
|
+
return {k: v for k, v in schema.items() if v is not None}
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _extract_claim_id(claim: Any) -> str:
|
|
100
|
+
"""Claim의 식별자 추출."""
|
|
101
|
+
for attr in ("claim_id", "id", "sent_id"):
|
|
102
|
+
v = getattr(claim, attr, None)
|
|
103
|
+
if v:
|
|
104
|
+
return str(v)
|
|
105
|
+
return "unknown"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _extract_claim_text(claim: Any) -> str:
|
|
109
|
+
"""Claim의 본문 텍스트 추출."""
|
|
110
|
+
for attr in ("claim_text", "text", "sentence"):
|
|
111
|
+
v = getattr(claim, attr, None)
|
|
112
|
+
if v:
|
|
113
|
+
return str(v)
|
|
114
|
+
return ""
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── JSON 추출 ─────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
_JSON_PATTERNS = [
|
|
120
|
+
# ```json ... ``` 또는 ``` ... ```
|
|
121
|
+
re.compile(r"```(?:json)?\s*(\{.*?\})\s*```", re.DOTALL),
|
|
122
|
+
# 그냥 { ... } (첫 번째 매칭)
|
|
123
|
+
re.compile(r"(\{.*\})", re.DOTALL),
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_balanced_json(text: str) -> str | None:
|
|
128
|
+
"""첫 '{' 부터 매칭되는 '}'까지 추출 (brace counting).
|
|
129
|
+
|
|
130
|
+
정규식과 달리 응답이 잘려서 닫는 ``` 가 없거나, 중첩된 ``` 가 있어도
|
|
131
|
+
동작한다. 문자열 리터럴 내부의 brace는 무시.
|
|
132
|
+
"""
|
|
133
|
+
if not text:
|
|
134
|
+
return None
|
|
135
|
+
start = text.find("{")
|
|
136
|
+
if start < 0:
|
|
137
|
+
return None
|
|
138
|
+
depth = 0
|
|
139
|
+
in_string = False
|
|
140
|
+
escape = False
|
|
141
|
+
for i in range(start, len(text)):
|
|
142
|
+
c = text[i]
|
|
143
|
+
if escape:
|
|
144
|
+
escape = False
|
|
145
|
+
continue
|
|
146
|
+
if c == "\\":
|
|
147
|
+
escape = True
|
|
148
|
+
continue
|
|
149
|
+
if c == '"':
|
|
150
|
+
in_string = not in_string
|
|
151
|
+
continue
|
|
152
|
+
if in_string:
|
|
153
|
+
continue
|
|
154
|
+
if c == "{":
|
|
155
|
+
depth += 1
|
|
156
|
+
elif c == "}":
|
|
157
|
+
depth -= 1
|
|
158
|
+
if depth == 0:
|
|
159
|
+
return text[start : i + 1]
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _extract_json_from_response(text: str) -> dict[str, Any]:
|
|
164
|
+
"""LLM 응답에서 JSON 부분 추출 + 파싱.
|
|
165
|
+
|
|
166
|
+
응답이 ```json fenced``` 또는 plain JSON, 코드 펜스 잘림 모두 처리.
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
dict. 파싱 실패 시 빈 dict + 로그.
|
|
170
|
+
"""
|
|
171
|
+
if not text:
|
|
172
|
+
return {}
|
|
173
|
+
|
|
174
|
+
text = text.strip()
|
|
175
|
+
|
|
176
|
+
# 1차: 정규식 패턴들 시도
|
|
177
|
+
for pattern in _JSON_PATTERNS:
|
|
178
|
+
match = pattern.search(text)
|
|
179
|
+
if not match:
|
|
180
|
+
continue
|
|
181
|
+
candidate = match.group(1)
|
|
182
|
+
try:
|
|
183
|
+
return json.loads(candidate)
|
|
184
|
+
except json.JSONDecodeError as e:
|
|
185
|
+
logger.debug(f"[planner] JSON 파싱 실패 ({e}), 다음 패턴 시도")
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# 2차: brace-counting fallback (코드 펜스 없이/잘림 대응)
|
|
189
|
+
balanced = _extract_balanced_json(text)
|
|
190
|
+
if balanced:
|
|
191
|
+
try:
|
|
192
|
+
result = json.loads(balanced)
|
|
193
|
+
logger.info("[planner] JSON 추출: brace-counting fallback 성공")
|
|
194
|
+
return result
|
|
195
|
+
except json.JSONDecodeError as e:
|
|
196
|
+
logger.debug(f"[planner] brace-counting JSON 파싱 실패: {e}")
|
|
197
|
+
|
|
198
|
+
# 3차: 전체 text 시도
|
|
199
|
+
try:
|
|
200
|
+
return json.loads(text)
|
|
201
|
+
except json.JSONDecodeError:
|
|
202
|
+
logger.warning(f"[planner] JSON 추출 실패. 응답 일부: {text[:300]!r}")
|
|
203
|
+
return {}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ── Plan 파싱 ─────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
def _normalize_initial_steps(
|
|
209
|
+
initial_steps: list[PlanStep],
|
|
210
|
+
required_data: list[DataPointSpec],
|
|
211
|
+
claim_id: str,
|
|
212
|
+
fallback_query: str = "",
|
|
213
|
+
) -> list[PlanStep]:
|
|
214
|
+
"""Plan steps 정상화: catalog_search 누락 시 자동 보강.
|
|
215
|
+
|
|
216
|
+
LLM은 종종 다음과 같이 부실한 plan을 만든다:
|
|
217
|
+
A) initial_steps=[] (빈 배열)
|
|
218
|
+
B) [fetch_evidence(candidate_id='<...>')] (catalog_search 빼먹음)
|
|
219
|
+
두 경우 모두 loop의 _interpolate_step_input이 candidate_id placeholder를
|
|
220
|
+
보간할 수 없어서 placeholder string이 그대로 KOSIS source까지 흘러간다.
|
|
221
|
+
|
|
222
|
+
여기서 정상화:
|
|
223
|
+
- case A → [catalog_search, fetch_evidence] 자동 추가
|
|
224
|
+
- case B → 첫 fetch_evidence 앞에 catalog_search prepend +
|
|
225
|
+
fetch의 candidate_id를 표준 placeholder로 통일
|
|
226
|
+
|
|
227
|
+
fallback_query: required_data가 비어있을 때 catalog_search query로 쓸 문자열
|
|
228
|
+
(보통 claim_text 또는 schema.indicator).
|
|
229
|
+
비어있으면 catalog_search가 'query 비어있음' 실패 → 보간 불가.
|
|
230
|
+
"""
|
|
231
|
+
# query: required_data의 첫 indicator 우선, 없으면 fallback_query
|
|
232
|
+
query = ""
|
|
233
|
+
if required_data:
|
|
234
|
+
query = getattr(required_data[0], "indicator", "") or ""
|
|
235
|
+
if not query:
|
|
236
|
+
query = (fallback_query or "").strip()
|
|
237
|
+
|
|
238
|
+
has_catalog = any(s.action == ActionType.CATALOG_SEARCH for s in initial_steps)
|
|
239
|
+
has_fetch = any(s.action == ActionType.FETCH_EVIDENCE for s in initial_steps)
|
|
240
|
+
|
|
241
|
+
# case A: 빈 plan
|
|
242
|
+
if not initial_steps:
|
|
243
|
+
logger.warning(
|
|
244
|
+
f"[planner] {claim_id}: LLM이 빈 initial_steps 반환 — "
|
|
245
|
+
f"[catalog_search, fetch_evidence] 자동 추가 (query={query!r})"
|
|
246
|
+
)
|
|
247
|
+
return [
|
|
248
|
+
PlanStep(
|
|
249
|
+
action=ActionType.CATALOG_SEARCH,
|
|
250
|
+
input={"query": query, "top_k": 5},
|
|
251
|
+
rationale="[auto-prepended] catalog_search 자동 추가",
|
|
252
|
+
),
|
|
253
|
+
PlanStep(
|
|
254
|
+
action=ActionType.FETCH_EVIDENCE,
|
|
255
|
+
input={"candidate_id": "<catalog_search 결과의 top id>", "params": {}},
|
|
256
|
+
rationale="[auto-prepended] 후보 1번 표 데이터 가져오기",
|
|
257
|
+
),
|
|
258
|
+
]
|
|
259
|
+
|
|
260
|
+
# case B: fetch만 있고 catalog_search 없음
|
|
261
|
+
if has_fetch and not has_catalog:
|
|
262
|
+
logger.warning(
|
|
263
|
+
f"[planner] {claim_id}: catalog_search 누락 — "
|
|
264
|
+
f"fetch_evidence 앞에 자동 prepend (query={query!r})"
|
|
265
|
+
)
|
|
266
|
+
normalized: list[PlanStep] = []
|
|
267
|
+
for s in initial_steps:
|
|
268
|
+
if s.action == ActionType.FETCH_EVIDENCE:
|
|
269
|
+
inp = dict(s.input or {})
|
|
270
|
+
cid = inp.get("candidate_id", "")
|
|
271
|
+
# placeholder-like check
|
|
272
|
+
is_ph = (
|
|
273
|
+
not cid
|
|
274
|
+
or (isinstance(cid, str) and (
|
|
275
|
+
cid.startswith("<")
|
|
276
|
+
or "검색" in cid
|
|
277
|
+
or "search" in cid.lower()
|
|
278
|
+
or "결과" in cid
|
|
279
|
+
))
|
|
280
|
+
)
|
|
281
|
+
if is_ph:
|
|
282
|
+
inp["candidate_id"] = "<catalog_search 결과의 top id>"
|
|
283
|
+
normalized.append(PlanStep(
|
|
284
|
+
action=s.action,
|
|
285
|
+
input=inp,
|
|
286
|
+
rationale=s.rationale,
|
|
287
|
+
))
|
|
288
|
+
else:
|
|
289
|
+
normalized.append(s)
|
|
290
|
+
return [
|
|
291
|
+
PlanStep(
|
|
292
|
+
action=ActionType.CATALOG_SEARCH,
|
|
293
|
+
input={"query": query, "top_k": 5},
|
|
294
|
+
rationale="[auto-prepended] catalog_search 자동 추가",
|
|
295
|
+
),
|
|
296
|
+
*normalized,
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
# case C: 정상 (catalog_search + fetch_evidence) 또는 다른 패턴 — 그대로
|
|
300
|
+
return initial_steps
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _parse_plan(
|
|
304
|
+
response_text: str,
|
|
305
|
+
claim_id: str,
|
|
306
|
+
fallback_query: str = "",
|
|
307
|
+
) -> Plan | None:
|
|
308
|
+
"""LLM 응답을 Plan 객체로 변환.
|
|
309
|
+
|
|
310
|
+
실패 시 None 반환 + 로그. 호출자가 fallback (heuristic plan 등) 처리.
|
|
311
|
+
|
|
312
|
+
fallback_query: required_data가 비었을 때 catalog_search query로 쓸 문자열.
|
|
313
|
+
"""
|
|
314
|
+
data = _extract_json_from_response(response_text)
|
|
315
|
+
if not data:
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
# ── claim_type 파싱 ──
|
|
319
|
+
raw_type = (data.get("claim_type") or "unknown").strip().lower()
|
|
320
|
+
type_map = {
|
|
321
|
+
"absolute": ClaimType.ABSOLUTE,
|
|
322
|
+
"growth_rate": ClaimType.GROWTH_RATE,
|
|
323
|
+
"difference": ClaimType.DIFFERENCE,
|
|
324
|
+
"diff": ClaimType.DIFFERENCE, # alias
|
|
325
|
+
"comparison": ClaimType.COMPARISON,
|
|
326
|
+
"ratio_comparison": ClaimType.COMPARISON, # alias (legacy)
|
|
327
|
+
"ranking": ClaimType.RANKING,
|
|
328
|
+
"aggregation": ClaimType.AGGREGATION, # [2026-05-21] 다년 평균/총합
|
|
329
|
+
"aggregate": ClaimType.AGGREGATION, # alias
|
|
330
|
+
"average": ClaimType.AGGREGATION, # alias (LLM이 average로 출력하는 경우)
|
|
331
|
+
"mean": ClaimType.AGGREGATION, # alias
|
|
332
|
+
"sum": ClaimType.AGGREGATION, # alias
|
|
333
|
+
"total": ClaimType.AGGREGATION, # alias
|
|
334
|
+
"unknown": ClaimType.UNKNOWN,
|
|
335
|
+
"other": ClaimType.UNKNOWN, # alias (legacy)
|
|
336
|
+
}
|
|
337
|
+
claim_type = type_map.get(raw_type, ClaimType.UNKNOWN)
|
|
338
|
+
|
|
339
|
+
# ── required_data 파싱 ──
|
|
340
|
+
raw_data = data.get("required_data") or []
|
|
341
|
+
required_data: list[DataPointSpec] = []
|
|
342
|
+
for item in raw_data:
|
|
343
|
+
if not isinstance(item, dict):
|
|
344
|
+
continue
|
|
345
|
+
try:
|
|
346
|
+
# role은 schema에 없을 수도 있으니 안전하게
|
|
347
|
+
spec_kwargs = {
|
|
348
|
+
"indicator": str(item.get("indicator", "")).strip(),
|
|
349
|
+
"time": str(item.get("time", "")).strip(),
|
|
350
|
+
"population": item.get("population") or None,
|
|
351
|
+
"unit_hint": item.get("unit_hint") or item.get("unit") or None,
|
|
352
|
+
}
|
|
353
|
+
# 비어있는 필수 필드면 스킵
|
|
354
|
+
if not spec_kwargs["indicator"] or not spec_kwargs["time"]:
|
|
355
|
+
logger.debug(f"[planner] data point 스킵 (필수 필드 누락): {item}")
|
|
356
|
+
continue
|
|
357
|
+
spec = DataPointSpec(**spec_kwargs)
|
|
358
|
+
required_data.append(spec)
|
|
359
|
+
except (ValidationError, TypeError) as e:
|
|
360
|
+
logger.debug(f"[planner] DataPointSpec 파싱 실패: {item} | {e}")
|
|
361
|
+
|
|
362
|
+
# ── initial_steps 파싱 ──
|
|
363
|
+
raw_steps = data.get("initial_steps") or []
|
|
364
|
+
initial_steps: list[PlanStep] = []
|
|
365
|
+
for item in raw_steps:
|
|
366
|
+
if not isinstance(item, dict):
|
|
367
|
+
continue
|
|
368
|
+
action_str = (item.get("action") or "").strip().lower()
|
|
369
|
+
try:
|
|
370
|
+
action = ActionType(action_str)
|
|
371
|
+
except ValueError:
|
|
372
|
+
logger.debug(f"[planner] 알 수 없는 action: {action_str!r}, 스킵")
|
|
373
|
+
continue
|
|
374
|
+
try:
|
|
375
|
+
step = PlanStep(
|
|
376
|
+
action=action,
|
|
377
|
+
input=item.get("input") or {},
|
|
378
|
+
rationale=str(item.get("rationale") or "").strip(),
|
|
379
|
+
)
|
|
380
|
+
initial_steps.append(step)
|
|
381
|
+
except (ValidationError, TypeError) as e:
|
|
382
|
+
logger.debug(f"[planner] PlanStep 파싱 실패: {item} | {e}")
|
|
383
|
+
|
|
384
|
+
# ── fallback 파싱 ──
|
|
385
|
+
raw_fallback = data.get("fallback") or {}
|
|
386
|
+
if not isinstance(raw_fallback, dict):
|
|
387
|
+
raw_fallback = {}
|
|
388
|
+
try:
|
|
389
|
+
fallback = FallbackStrategy(
|
|
390
|
+
use_original_text=bool(raw_fallback.get("use_original_text", False)),
|
|
391
|
+
alternative_keywords=list(raw_fallback.get("alternative_keywords") or []),
|
|
392
|
+
give_up_after_attempts=int(raw_fallback.get("give_up_after_attempts", 5)),
|
|
393
|
+
)
|
|
394
|
+
except (ValidationError, TypeError, ValueError) as e:
|
|
395
|
+
logger.debug(f"[planner] FallbackStrategy 파싱 실패: {e}")
|
|
396
|
+
fallback = FallbackStrategy()
|
|
397
|
+
|
|
398
|
+
# ── ★ initial_steps 정상화 (LLM의 부실한 plan 보강) ──
|
|
399
|
+
initial_steps = _normalize_initial_steps(
|
|
400
|
+
initial_steps, required_data, claim_id, fallback_query=fallback_query
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# ── Plan 생성 ──
|
|
404
|
+
try:
|
|
405
|
+
plan = Plan(
|
|
406
|
+
claim_id=claim_id,
|
|
407
|
+
claim_type=claim_type,
|
|
408
|
+
required_data=required_data,
|
|
409
|
+
initial_steps=initial_steps,
|
|
410
|
+
fallback=fallback,
|
|
411
|
+
calculation_formula=(data.get("calculation_formula") or None) or None,
|
|
412
|
+
notes=str(data.get("notes") or "").strip() or None,
|
|
413
|
+
)
|
|
414
|
+
return plan
|
|
415
|
+
except ValidationError as e:
|
|
416
|
+
logger.warning(f"[planner] Plan 최종 validation 실패: {e}")
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# ── Heuristic Fallback Plan ──────────────────────────────────────
|
|
421
|
+
|
|
422
|
+
def _heuristic_plan(claim: Any, claim_id: str) -> Plan:
|
|
423
|
+
"""LLM 호출 실패 시 *최소한의 Plan*을 만들어 loop이 멈추지 않게.
|
|
424
|
+
|
|
425
|
+
- claim_type=other
|
|
426
|
+
- schema가 있으면 데이터 점 1개 (current role)
|
|
427
|
+
- 첫 step: catalog_search (indicator 기반)
|
|
428
|
+
"""
|
|
429
|
+
schema_info = _extract_schema_info(claim)
|
|
430
|
+
claim_text = _extract_claim_text(claim)
|
|
431
|
+
|
|
432
|
+
required_data: list[DataPointSpec] = []
|
|
433
|
+
if schema_info.get("indicator"):
|
|
434
|
+
try:
|
|
435
|
+
required_data.append(DataPointSpec(
|
|
436
|
+
indicator=str(schema_info["indicator"]),
|
|
437
|
+
time=str(schema_info.get("time_period") or ""),
|
|
438
|
+
population=schema_info.get("population") or None,
|
|
439
|
+
unit_hint=schema_info.get("unit") or None,
|
|
440
|
+
))
|
|
441
|
+
except (ValidationError, TypeError):
|
|
442
|
+
pass
|
|
443
|
+
|
|
444
|
+
# 추측 검색어
|
|
445
|
+
query_terms = []
|
|
446
|
+
if schema_info.get("indicator"):
|
|
447
|
+
query_terms.append(str(schema_info["indicator"]))
|
|
448
|
+
if schema_info.get("parent_path"):
|
|
449
|
+
# parent_path가 "인구 > 출생 > 출생아 수" 같은 형식
|
|
450
|
+
parts = [p.strip() for p in str(schema_info["parent_path"]).split(">") if p.strip()]
|
|
451
|
+
if len(parts) >= 1:
|
|
452
|
+
query_terms.append(parts[0])
|
|
453
|
+
|
|
454
|
+
query = " ".join(query_terms) if query_terms else (claim_text[:30] if claim_text else "통계")
|
|
455
|
+
|
|
456
|
+
initial_steps = [
|
|
457
|
+
PlanStep(
|
|
458
|
+
action=ActionType.CATALOG_SEARCH,
|
|
459
|
+
input={"query": query, "top_k": 5},
|
|
460
|
+
rationale="(heuristic fallback) indicator 기반 1차 검색",
|
|
461
|
+
),
|
|
462
|
+
PlanStep(
|
|
463
|
+
action=ActionType.FINISH,
|
|
464
|
+
input={
|
|
465
|
+
"verdict": "unverifiable",
|
|
466
|
+
"confidence": 0.3,
|
|
467
|
+
"explanation": "Plan Agent가 LLM 호출에 실패하여 휴리스틱 fallback. 자세한 검증 불가.",
|
|
468
|
+
},
|
|
469
|
+
rationale="(heuristic fallback) plan 부재로 최종 unverifiable",
|
|
470
|
+
),
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
return Plan(
|
|
474
|
+
claim_id=claim_id,
|
|
475
|
+
claim_type=ClaimType.UNKNOWN,
|
|
476
|
+
required_data=required_data,
|
|
477
|
+
initial_steps=initial_steps,
|
|
478
|
+
fallback=FallbackStrategy(),
|
|
479
|
+
notes="heuristic fallback plan (LLM 미사용 또는 실패)",
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
# ── Planner 본체 ──────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
@dataclass
|
|
486
|
+
class PlannerConfig:
|
|
487
|
+
"""Planner 동작 설정."""
|
|
488
|
+
|
|
489
|
+
model: str = "HCX-007"
|
|
490
|
+
"""LLM 모델 이름. config.agent.llm.plan_model 에서 가져오면 됨."""
|
|
491
|
+
|
|
492
|
+
temperature: float = 0.1
|
|
493
|
+
"""낮을수록 결정적. plan은 결정적이 좋음 → 0.1 권장."""
|
|
494
|
+
|
|
495
|
+
max_tokens: int = 4000
|
|
496
|
+
|
|
497
|
+
max_retries: int = 1
|
|
498
|
+
"""JSON 파싱 실패 시 LLM 재호출 횟수. 0이면 fallback 즉시."""
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class Planner:
|
|
502
|
+
"""Plan Agent.
|
|
503
|
+
|
|
504
|
+
Usage:
|
|
505
|
+
planner = Planner(llm_call=my_llm_call, config=PlannerConfig(model="HCX-007"))
|
|
506
|
+
plan = await planner.plan(claim, source_text=article_text, anchor_year=2025)
|
|
507
|
+
workspace.write_plan(claim.claim_id, plan.model_dump(mode="json"))
|
|
508
|
+
"""
|
|
509
|
+
|
|
510
|
+
def __init__(
|
|
511
|
+
self,
|
|
512
|
+
llm_call: LLMCallable | None = None,
|
|
513
|
+
config: PlannerConfig | None = None,
|
|
514
|
+
):
|
|
515
|
+
self.llm_call = llm_call
|
|
516
|
+
self.config = config or PlannerConfig()
|
|
517
|
+
|
|
518
|
+
async def plan(
|
|
519
|
+
self,
|
|
520
|
+
claim: Any,
|
|
521
|
+
source_text: str | None = None,
|
|
522
|
+
anchor_year: int | str | None = None,
|
|
523
|
+
) -> Plan:
|
|
524
|
+
"""Claim → Plan.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
claim: structverify의 Claim 객체. .claim_text + .schema + .claim_id 속성 가정.
|
|
528
|
+
source_text: 원문 기사 전체 (옵션 — prompt에 일부 삽입).
|
|
529
|
+
anchor_year: 문서 anchor_year (옵션 — 시점 해소용).
|
|
530
|
+
|
|
531
|
+
Returns:
|
|
532
|
+
Plan. LLM 호출 실패 시 *heuristic fallback Plan*.
|
|
533
|
+
"""
|
|
534
|
+
claim_id = _extract_claim_id(claim)
|
|
535
|
+
claim_text = _extract_claim_text(claim)
|
|
536
|
+
schema_info = _extract_schema_info(claim)
|
|
537
|
+
|
|
538
|
+
if not claim_text:
|
|
539
|
+
logger.warning(f"[planner] {claim_id}: claim_text 비어있음, heuristic fallback")
|
|
540
|
+
return _heuristic_plan(claim, claim_id)
|
|
541
|
+
|
|
542
|
+
if self.llm_call is None:
|
|
543
|
+
logger.warning(f"[planner] {claim_id}: llm_call 미주입, heuristic fallback")
|
|
544
|
+
return _heuristic_plan(claim, claim_id)
|
|
545
|
+
|
|
546
|
+
prompt = build_plan_prompt(
|
|
547
|
+
claim_text=claim_text,
|
|
548
|
+
schema_info=schema_info or None,
|
|
549
|
+
source_excerpt=source_text,
|
|
550
|
+
anchor_year=anchor_year,
|
|
551
|
+
)
|
|
552
|
+
logger.info(
|
|
553
|
+
f"[planner] {claim_id}: prompt 구성 완료 ({len(prompt)}자). "
|
|
554
|
+
f"schema={'있음' if schema_info else '없음'} source={'있음' if source_text else '없음'}"
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
# LLM 호출 (재시도 포함)
|
|
558
|
+
last_response = ""
|
|
559
|
+
for attempt in range(self.config.max_retries + 1):
|
|
560
|
+
try:
|
|
561
|
+
response = await self.llm_call(prompt)
|
|
562
|
+
last_response = response or ""
|
|
563
|
+
logger.info(
|
|
564
|
+
f"[planner] {claim_id}: LLM 응답 받음 ({len(last_response)}자) "
|
|
565
|
+
f"[시도 {attempt + 1}/{self.config.max_retries + 1}]"
|
|
566
|
+
)
|
|
567
|
+
# [2026-05-25] LLM thought 디버깅용 — 응답 본문을 INFO에 펼침.
|
|
568
|
+
# 화면 UI에서 plan 결정 사유 추적하기 어려운 케이스 대응.
|
|
569
|
+
logger.info(
|
|
570
|
+
f"[planner] {claim_id}: LLM 응답 본문 ↓\n"
|
|
571
|
+
f"────── PLAN RESPONSE START ──────\n"
|
|
572
|
+
f"{last_response}\n"
|
|
573
|
+
f"────── PLAN RESPONSE END ──────"
|
|
574
|
+
)
|
|
575
|
+
except Exception as e:
|
|
576
|
+
logger.warning(
|
|
577
|
+
f"[planner] {claim_id}: LLM 호출 실패 [시도 {attempt + 1}]: "
|
|
578
|
+
f"{type(e).__name__}: {e}"
|
|
579
|
+
)
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
# fallback query: schema.indicator > claim_text 앞부분
|
|
583
|
+
fallback_query = ""
|
|
584
|
+
if isinstance(schema_info, dict):
|
|
585
|
+
fallback_query = (schema_info.get("indicator") or "").strip()
|
|
586
|
+
if not fallback_query and claim_text:
|
|
587
|
+
# claim_text 앞 40자 정도까지 (너무 길면 search 품질 떨어짐)
|
|
588
|
+
fallback_query = claim_text.strip()[:40]
|
|
589
|
+
|
|
590
|
+
plan = _parse_plan(last_response, claim_id, fallback_query=fallback_query)
|
|
591
|
+
if plan is not None:
|
|
592
|
+
# [2026-05-21] value_role 후처리 — schema_inductor가 분기한 *역할*과
|
|
593
|
+
# LLM이 만든 claim_type이 불일치하면 *value_role을 신뢰*하고 정정.
|
|
594
|
+
# LLM이 같은 claim_text의 sub-claim들을 동일 plan_type으로 잘못
|
|
595
|
+
# 분류하던 버그(2026-05-21 진단: 출생아 수 base + 증가율 둘 다
|
|
596
|
+
# growth_rate)를 결정론적으로 차단.
|
|
597
|
+
_role = (schema_info or {}).get("value_role") if isinstance(schema_info, dict) else None
|
|
598
|
+
_role_to_type = {
|
|
599
|
+
"base": ClaimType.ABSOLUTE,
|
|
600
|
+
"derived_rate": ClaimType.GROWTH_RATE,
|
|
601
|
+
"derived_difference": ClaimType.DIFFERENCE,
|
|
602
|
+
# [2026-05-21] 다년 집계 — 도메인 무관, schema_inductor가 분기
|
|
603
|
+
"aggregation": ClaimType.AGGREGATION,
|
|
604
|
+
}
|
|
605
|
+
_expected_type = _role_to_type.get(_role)
|
|
606
|
+
if _expected_type and plan.claim_type != _expected_type:
|
|
607
|
+
logger.info(
|
|
608
|
+
f"[planner] {claim_id}: value_role={_role!r} 기반 정정 — "
|
|
609
|
+
f"LLM type={plan.claim_type.value} → {_expected_type.value}"
|
|
610
|
+
)
|
|
611
|
+
plan = plan.model_copy(update={"claim_type": _expected_type})
|
|
612
|
+
logger.info(
|
|
613
|
+
f"[planner] {claim_id}: Plan 생성 완료. "
|
|
614
|
+
f"type={plan.claim_type.value}, data_points={len(plan.required_data)}, "
|
|
615
|
+
f"steps={len(plan.initial_steps)}, formula={plan.calculation_formula!r}"
|
|
616
|
+
)
|
|
617
|
+
return plan
|
|
618
|
+
|
|
619
|
+
logger.warning(
|
|
620
|
+
f"[planner] {claim_id}: Plan 파싱 실패 [시도 {attempt + 1}]. "
|
|
621
|
+
f"응답 일부: {last_response[:200]!r}"
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
# 모든 시도 실패 → fallback
|
|
625
|
+
logger.warning(f"[planner] {claim_id}: 모든 시도 실패. heuristic fallback 사용.")
|
|
626
|
+
return _heuristic_plan(claim, claim_id)
|
|
627
|
+
|
|
628
|
+
# ── [2026-05-26] regenerate_plan ─────────────────────────────────
|
|
629
|
+
# 실행 도중 plan 자체가 틀렸음이 드러났을 때 (예: claim 값이 표에 row로 없는
|
|
630
|
+
# delta/derived 지표인데 plan이 absolute로 잡힘) 새 plan을 생성.
|
|
631
|
+
# 기존 fallback(try_ids, catalog retry, row_matcher 등)이 모두 같은 plan 내에서
|
|
632
|
+
# 답 찾기였다면, regenerate_plan은 *plan 자체*를 갈아끼움.
|
|
633
|
+
async def regenerate_plan(
|
|
634
|
+
self,
|
|
635
|
+
claim: Any,
|
|
636
|
+
original_plan: Any | None,
|
|
637
|
+
observations: list[dict],
|
|
638
|
+
reason: str,
|
|
639
|
+
) -> Plan | None:
|
|
640
|
+
"""원래 plan + 실행 observation을 보고 *수정된 plan*을 생성.
|
|
641
|
+
|
|
642
|
+
Args:
|
|
643
|
+
claim: Claim 객체.
|
|
644
|
+
original_plan: 첫 실행에 사용된 Plan (있으면 LLM에 참고로 보여줌).
|
|
645
|
+
observations: workspace의 observation 요약 리스트
|
|
646
|
+
(각 항목 {action, success, summary, fetched_value/stat_id, ...}).
|
|
647
|
+
reason: replan이 필요한 이유 (LLM이 입력으로 받음).
|
|
648
|
+
|
|
649
|
+
Returns:
|
|
650
|
+
새 Plan. 실패 시 None.
|
|
651
|
+
"""
|
|
652
|
+
claim_id = _extract_claim_id(claim)
|
|
653
|
+
claim_text = _extract_claim_text(claim)
|
|
654
|
+
schema_info = _extract_schema_info(claim)
|
|
655
|
+
|
|
656
|
+
if not claim_text:
|
|
657
|
+
logger.warning(f"[planner.regenerate] {claim_id}: claim_text 비어있음, fallback X")
|
|
658
|
+
return None
|
|
659
|
+
if self.llm_call is None:
|
|
660
|
+
logger.warning(f"[planner.regenerate] {claim_id}: llm_call 미주입")
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
# original plan 직렬화 (LLM 입력용)
|
|
664
|
+
orig_plan_str = ""
|
|
665
|
+
if original_plan is not None:
|
|
666
|
+
try:
|
|
667
|
+
orig_plan_str = json.dumps(
|
|
668
|
+
original_plan.model_dump(mode="json") if hasattr(original_plan, "model_dump")
|
|
669
|
+
else dict(original_plan),
|
|
670
|
+
ensure_ascii=False, indent=2, default=str,
|
|
671
|
+
)
|
|
672
|
+
except Exception:
|
|
673
|
+
orig_plan_str = str(original_plan)
|
|
674
|
+
|
|
675
|
+
# observation 요약 직렬화
|
|
676
|
+
try:
|
|
677
|
+
obs_str = json.dumps(
|
|
678
|
+
observations or [], ensure_ascii=False, indent=2, default=str,
|
|
679
|
+
)
|
|
680
|
+
except Exception:
|
|
681
|
+
obs_str = str(observations)
|
|
682
|
+
|
|
683
|
+
prompt = _build_regenerate_prompt(
|
|
684
|
+
claim_text=claim_text,
|
|
685
|
+
schema_info=schema_info or {},
|
|
686
|
+
original_plan_json=orig_plan_str,
|
|
687
|
+
observations_json=obs_str,
|
|
688
|
+
reason=reason,
|
|
689
|
+
)
|
|
690
|
+
logger.info(
|
|
691
|
+
f"[planner.regenerate] {claim_id}: prompt 구성 완료 ({len(prompt)}자). "
|
|
692
|
+
f"obs_count={len(observations or [])}, reason={reason[:80]!r}"
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
last_response = ""
|
|
696
|
+
for attempt in range(self.config.max_retries + 1):
|
|
697
|
+
try:
|
|
698
|
+
response = await self.llm_call(prompt)
|
|
699
|
+
last_response = response or ""
|
|
700
|
+
logger.info(
|
|
701
|
+
f"[planner.regenerate] {claim_id}: LLM 응답 ({len(last_response)}자) "
|
|
702
|
+
f"[시도 {attempt + 1}/{self.config.max_retries + 1}]"
|
|
703
|
+
)
|
|
704
|
+
logger.info(
|
|
705
|
+
f"[planner.regenerate] {claim_id}: LLM 응답 본문 ↓\n"
|
|
706
|
+
f"────── REPLAN RESPONSE START ──────\n"
|
|
707
|
+
f"{last_response}\n"
|
|
708
|
+
f"────── REPLAN RESPONSE END ──────"
|
|
709
|
+
)
|
|
710
|
+
except Exception as e:
|
|
711
|
+
logger.warning(
|
|
712
|
+
f"[planner.regenerate] {claim_id}: LLM 호출 실패 [시도 {attempt + 1}]: "
|
|
713
|
+
f"{type(e).__name__}: {e}"
|
|
714
|
+
)
|
|
715
|
+
continue
|
|
716
|
+
|
|
717
|
+
# fallback query: schema.indicator
|
|
718
|
+
fallback_query = ""
|
|
719
|
+
if isinstance(schema_info, dict):
|
|
720
|
+
fallback_query = (schema_info.get("indicator") or "").strip()
|
|
721
|
+
if not fallback_query and claim_text:
|
|
722
|
+
fallback_query = claim_text.strip()[:40]
|
|
723
|
+
|
|
724
|
+
plan = _parse_plan(last_response, claim_id, fallback_query=fallback_query)
|
|
725
|
+
if plan is not None:
|
|
726
|
+
logger.info(
|
|
727
|
+
f"[planner.regenerate] {claim_id}: 새 Plan 생성 완료. "
|
|
728
|
+
f"type={plan.claim_type.value}, data_points={len(plan.required_data)}, "
|
|
729
|
+
f"steps={len(plan.initial_steps)}, formula={plan.calculation_formula!r}"
|
|
730
|
+
)
|
|
731
|
+
return plan
|
|
732
|
+
logger.warning(
|
|
733
|
+
f"[planner.regenerate] {claim_id}: 파싱 실패 [시도 {attempt + 1}]. "
|
|
734
|
+
f"응답 일부: {last_response[:200]!r}"
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
logger.warning(f"[planner.regenerate] {claim_id}: 모든 시도 실패")
|
|
738
|
+
return None
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _build_regenerate_prompt(
|
|
742
|
+
*,
|
|
743
|
+
claim_text: str,
|
|
744
|
+
schema_info: dict,
|
|
745
|
+
original_plan_json: str,
|
|
746
|
+
observations_json: str,
|
|
747
|
+
reason: str,
|
|
748
|
+
) -> str:
|
|
749
|
+
"""regenerate_plan용 LLM 프롬프트.
|
|
750
|
+
|
|
751
|
+
원래 plan + 실행 결과 + 이유를 보여주고 *수정된 plan*을 받는다.
|
|
752
|
+
Plan JSON 형식은 build_plan_prompt와 동일 (정상 parse 가능하게).
|
|
753
|
+
"""
|
|
754
|
+
return f"""당신은 통계 검증 plan 수정자입니다. 아래 정보를 보고 *새 plan*을 만드세요.
|
|
755
|
+
|
|
756
|
+
[원래 claim]
|
|
757
|
+
{claim_text}
|
|
758
|
+
|
|
759
|
+
[claim 스키마]
|
|
760
|
+
{json.dumps(schema_info, ensure_ascii=False, indent=2, default=str)}
|
|
761
|
+
|
|
762
|
+
[원래 plan (실패함)]
|
|
763
|
+
{original_plan_json or '(없음)'}
|
|
764
|
+
|
|
765
|
+
[실행 결과 요약 — 무엇을 시도했고 어떤 데이터를 받았는지]
|
|
766
|
+
{observations_json or '(없음)'}
|
|
767
|
+
|
|
768
|
+
[replan 이유]
|
|
769
|
+
{reason or '(없음)'}
|
|
770
|
+
|
|
771
|
+
[plan 수정 가이드]
|
|
772
|
+
1. 위 실행 결과에서 *어떤 표/시점/지표의 데이터가 실제로 존재했는지* 먼저 파악.
|
|
773
|
+
2. claim의 값이 그 표에 *직접 row로* 들어있나? → 들어있으면 claim_type='absolute'.
|
|
774
|
+
3. row로 *없는데* 표에 *원시 절대값*이 있다면 → 계산 필요:
|
|
775
|
+
- "<지표> 증가 수/감소 수/증감/변화량" → claim_type='difference', formula='current - prev'
|
|
776
|
+
- "<지표> 증가율/감소율/증감률" → claim_type='growth_rate', formula='(current-prev)/prev*100'
|
|
777
|
+
4. prev_time_period는 schema에 명시되었거나, time_period의 직전 단위(연→전년, 월→전월)로 추정.
|
|
778
|
+
5. initial_steps는 *현재까지 부족한 데이터만* 채우도록 구성:
|
|
779
|
+
- 이미 fetch 성공한 시점이 있으면 그 시점 fetch는 *반복하지 마세요*.
|
|
780
|
+
- 부족한 시점만 fetch_evidence → calculate → finish.
|
|
781
|
+
|
|
782
|
+
[출력 형식 — JSON만, 다른 텍스트 금지]
|
|
783
|
+
{{
|
|
784
|
+
"claim_type": "absolute" | "growth_rate" | "difference" | "comparison" | "ranking" | "aggregation",
|
|
785
|
+
"required_data": [
|
|
786
|
+
{{"indicator": "...", "time": "...", "population": "...", "unit_hint": "...", "role": "current/prev/..."}}
|
|
787
|
+
],
|
|
788
|
+
"calculation_formula": "current - prev" | "(current - prev) / prev * 100" | null,
|
|
789
|
+
"expected_result": <claim.value>,
|
|
790
|
+
"expected_unit": "...",
|
|
791
|
+
"verdict_logic": "계산된 값이 expected_result와 일치하면 match",
|
|
792
|
+
"initial_steps": [
|
|
793
|
+
{{"action": "fetch_evidence", "input": {{"candidate_id": "<직전 성공한 stat_id>", "params": {{"time_period": "<부족한 시점>"}}}}, "rationale": "..."}},
|
|
794
|
+
{{"action": "calculate", "input": {{"formula": "...", "vars": {{"current": ..., "prev": ...}}}}, "rationale": "..."}},
|
|
795
|
+
{{"action": "finish", "input": {{}}, "rationale": "..."}}
|
|
796
|
+
],
|
|
797
|
+
"fallback": {{"use_original_text": false, "alternative_keywords": [], "give_up_after_attempts": 3}},
|
|
798
|
+
"notes": "replan 사유 한 줄 메모"
|
|
799
|
+
}}
|
|
800
|
+
"""
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
# ── 편의 함수 ──────────────────────────────────────────────────────
|
|
804
|
+
|
|
805
|
+
async def build_plan(
|
|
806
|
+
claim: Any,
|
|
807
|
+
llm_call: LLMCallable | None = None,
|
|
808
|
+
source_text: str | None = None,
|
|
809
|
+
anchor_year: int | str | None = None,
|
|
810
|
+
config: PlannerConfig | None = None,
|
|
811
|
+
) -> Plan:
|
|
812
|
+
"""일회성 Plan 생성 (Planner 인스턴스 안 만들고).
|
|
813
|
+
|
|
814
|
+
Phase D Loop에서 한 번씩만 호출하면 되니 충분.
|
|
815
|
+
"""
|
|
816
|
+
planner = Planner(llm_call=llm_call, config=config)
|
|
817
|
+
return await planner.plan(claim, source_text=source_text, anchor_year=anchor_year)
|