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,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
structverify.agent.tools.base — Tool 인터페이스 추상 클래스.
|
|
3
|
+
|
|
4
|
+
Tool = Agent가 호출 가능한 *원자 단위 작업*.
|
|
5
|
+
- LLM이 reflect 단계에서 `{action: <ActionType>, input: {...}}` 결정
|
|
6
|
+
- Loop이 해당 ActionType의 Tool 인스턴스 찾아서 execute()
|
|
7
|
+
- 결과를 Observation으로 변환 → memory.md + log.jsonl + observations/*.json
|
|
8
|
+
|
|
9
|
+
설계 원칙:
|
|
10
|
+
1. **추상화**: Tool은 *기존 코드 직접 import 금지*. ToolContext의 datasources/workspace만 사용.
|
|
11
|
+
→ KOSIS 외 source 추가 시 *Tool은 수정 X*, DataSource만 등록.
|
|
12
|
+
2. **결과 표준화**: ToolResult 형식 통일. output dict + 요약 문자열 + 성공/실패.
|
|
13
|
+
3. **Plugin 지원**: register_tool 데코레이터로 *동적 등록*. 회사 자체 Tool 추가 가능.
|
|
14
|
+
|
|
15
|
+
Phase B에서는 *5개 핵심 Tool* 구현:
|
|
16
|
+
- catalog_search: DataSource 후보 검색
|
|
17
|
+
- fetch_evidence: 데이터 조회
|
|
18
|
+
- read_original: workspace의 원문 읽기
|
|
19
|
+
- calculate: 안전한 수식 계산
|
|
20
|
+
- finish: 종료 + Verdict 생성
|
|
21
|
+
|
|
22
|
+
Phase D (Loop)에서 이 Tool들을 *Reflect Agent가 선택해서 호출*한다.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from structverify.utils.logger import get_logger
|
|
27
|
+
from abc import ABC, abstractmethod
|
|
28
|
+
from dataclasses import dataclass, field
|
|
29
|
+
from typing import Any, Callable, Type
|
|
30
|
+
from uuid import UUID
|
|
31
|
+
|
|
32
|
+
from pydantic import BaseModel, Field
|
|
33
|
+
|
|
34
|
+
from ..schemas import ActionType
|
|
35
|
+
from ..workspace import Workspace
|
|
36
|
+
|
|
37
|
+
logger = get_logger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ── 결과 모델 ────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
class ToolResult(BaseModel):
|
|
43
|
+
"""Tool.execute()의 표준 반환 형식.
|
|
44
|
+
|
|
45
|
+
Loop이 이걸 Observation으로 변환 (iter_num + action 추가).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
output: dict[str, Any] = Field(default_factory=dict)
|
|
49
|
+
"""Tool 호출 raw 결과. observations/*.json에 저장됨."""
|
|
50
|
+
|
|
51
|
+
summary: str = ""
|
|
52
|
+
"""memory.md에 들어갈 *한 줄 요약*. agent가 다음 턴에 참고."""
|
|
53
|
+
|
|
54
|
+
success: bool = True
|
|
55
|
+
error: str | None = None
|
|
56
|
+
tokens_used: int = 0
|
|
57
|
+
"""이 호출에 쓴 LLM 토큰 (직접 LLM 호출하는 Tool만 채움)."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ── ToolContext — Tool에 주입되는 의존성 묶음 ──────────────────────
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ToolContext:
|
|
64
|
+
"""Tool 실행 시 필요한 *환경/의존성*. Loop이 만들어서 Tool에 전달.
|
|
65
|
+
|
|
66
|
+
Tool은 self나 전역 상태 대신 *항상 context를 통해* 외부와 통신.
|
|
67
|
+
이게 *추상화 + 테스트 용이성*의 핵심.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
workspace: Workspace
|
|
71
|
+
"""현재 job의 workspace. read_source / append_memory / write_observation 등."""
|
|
72
|
+
|
|
73
|
+
claim_id: str | UUID
|
|
74
|
+
"""현재 처리 중인 claim 식별자. workspace 파일 경로용."""
|
|
75
|
+
|
|
76
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
77
|
+
"""Agent + DataSource + LLM 설정 dict. config.agent.*, config.data_sources.* 등."""
|
|
78
|
+
|
|
79
|
+
datasources: dict[str, Any] = field(default_factory=dict)
|
|
80
|
+
"""{이름: BaseDataSource} 등록된 데이터 소스들. catalog_search/fetch tool이 사용."""
|
|
81
|
+
|
|
82
|
+
iter_num: int = 0
|
|
83
|
+
"""현재 iteration 번호. 로깅 + observation 파일 이름용."""
|
|
84
|
+
|
|
85
|
+
claim: Any = None
|
|
86
|
+
"""★ ADD: 현재 처리 중인 Claim 객체. fetch_evidence가 claim.schema에서
|
|
87
|
+
indicator/time_period/population/unit을 추출해 KOSIS params에 자동 매핑하기 위해 필요."""
|
|
88
|
+
|
|
89
|
+
current_plan: Any = None
|
|
90
|
+
"""[2026-05-26] 현재 loop이 실행 중인 Plan 객체. replan tool이 새 plan을
|
|
91
|
+
만들 때 *원래 plan*을 LLM에 보여주는 입력으로 사용. loop이 매 iter ctx에 주입."""
|
|
92
|
+
|
|
93
|
+
# 향후 추가 가능 (Phase D+):
|
|
94
|
+
# llm_client: Any = None # LLM 호출 클라이언트 (Reflect Agent용)
|
|
95
|
+
# token_budget_remaining: int = 100_000
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── ToolBase 추상 ────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
class ToolBase(ABC):
|
|
101
|
+
"""모든 Tool의 공통 인터페이스.
|
|
102
|
+
|
|
103
|
+
필수 구현:
|
|
104
|
+
- name: ActionType (CATALOG_SEARCH, FETCH_EVIDENCE, ...)
|
|
105
|
+
- description: LLM에게 보여줄 설명 (Plan/Reflect Agent prompt에 삽입)
|
|
106
|
+
- input_schema: 입력 형식 (LLM prompt에 표시)
|
|
107
|
+
- execute(input_data, context): 실제 동작
|
|
108
|
+
|
|
109
|
+
선택 구현:
|
|
110
|
+
- validate_input(input_data): 호출 전 검증 (기본은 input_schema 키 존재만 확인)
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
name: ActionType
|
|
114
|
+
description: str = ""
|
|
115
|
+
"""LLM이 이 Tool을 *언제 쓸지* 결정하도록 설명. Plan/Reflect prompt에 들어감."""
|
|
116
|
+
|
|
117
|
+
input_schema: dict[str, Any] = {}
|
|
118
|
+
"""입력 dict의 *예상 키*와 *간단한 설명*.
|
|
119
|
+
예: {"query": "검색어 (한국어 키워드)", "top_k": "최대 후보 수 (기본 5)"}
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
async def execute(
|
|
124
|
+
self,
|
|
125
|
+
input_data: dict[str, Any],
|
|
126
|
+
context: ToolContext,
|
|
127
|
+
) -> ToolResult:
|
|
128
|
+
"""Tool 실행. *예외는 ToolResult(success=False, error=...)로 변환*해서 반환.
|
|
129
|
+
|
|
130
|
+
Loop은 ToolResult를 받아 Observation으로 wrap, memory에 기록.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def validate_input(self, input_data: dict[str, Any]) -> tuple[bool, str | None]:
|
|
134
|
+
"""입력 검증. (valid, error_msg).
|
|
135
|
+
|
|
136
|
+
기본 구현은 *항상 통과* — Tool 내부 execute()에서 *실제* 검증을 한다
|
|
137
|
+
(input_schema는 *LLM용 문서화 정보*일 뿐, 강제 검증 X).
|
|
138
|
+
|
|
139
|
+
하위 클래스에서 *진짜 필수 입력만 빠진 케이스*를 검증하려면 override.
|
|
140
|
+
"""
|
|
141
|
+
return True, None
|
|
142
|
+
|
|
143
|
+
def render_help(self) -> str:
|
|
144
|
+
"""LLM prompt에 *이 Tool 설명*을 삽입할 때 사용. 사람-읽기 좋은 markdown."""
|
|
145
|
+
lines = [f"### {self.name.value}", self.description, ""]
|
|
146
|
+
if self.input_schema:
|
|
147
|
+
lines.append("**Input fields:**")
|
|
148
|
+
for k, desc in self.input_schema.items():
|
|
149
|
+
lines.append(f"- `{k}`: {desc}")
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ── Tool Registry ────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
_TOOL_REGISTRY: dict[ActionType, Type[ToolBase]] = {}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def register_tool(action: ActionType) -> Callable[[Type[ToolBase]], Type[ToolBase]]:
|
|
159
|
+
"""Tool 클래스를 *ActionType*에 등록하는 데코레이터.
|
|
160
|
+
|
|
161
|
+
Loop이 `tool_registry.get(action)`으로 Tool 클래스 찾고 인스턴스 만들어 execute.
|
|
162
|
+
|
|
163
|
+
Usage:
|
|
164
|
+
@register_tool(ActionType.CALCULATE)
|
|
165
|
+
class CalculateTool(ToolBase):
|
|
166
|
+
name = ActionType.CALCULATE
|
|
167
|
+
description = "안전한 수식 계산"
|
|
168
|
+
async def execute(...): ...
|
|
169
|
+
"""
|
|
170
|
+
def decorator(cls: Type[ToolBase]) -> Type[ToolBase]:
|
|
171
|
+
if not issubclass(cls, ToolBase):
|
|
172
|
+
raise TypeError(f"{cls.__name__} must subclass ToolBase")
|
|
173
|
+
if not getattr(cls, "name", None):
|
|
174
|
+
cls.name = action
|
|
175
|
+
if action in _TOOL_REGISTRY:
|
|
176
|
+
logger.warning(
|
|
177
|
+
f"[tools.registry] Tool '{action.value}' already registered, "
|
|
178
|
+
f"overwriting ({_TOOL_REGISTRY[action].__name__} → {cls.__name__})"
|
|
179
|
+
)
|
|
180
|
+
_TOOL_REGISTRY[action] = cls
|
|
181
|
+
logger.info(f"[tools.registry] Tool 등록: {action.value} ({cls.__name__})")
|
|
182
|
+
return cls
|
|
183
|
+
return decorator
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def get_tool_class(action: ActionType) -> Type[ToolBase]:
|
|
187
|
+
"""등록된 Tool 클래스 반환. 없으면 KeyError."""
|
|
188
|
+
if action not in _TOOL_REGISTRY:
|
|
189
|
+
available = [a.value for a in _TOOL_REGISTRY]
|
|
190
|
+
raise KeyError(
|
|
191
|
+
f"Tool '{action.value}' not registered. Available: {available}"
|
|
192
|
+
)
|
|
193
|
+
return _TOOL_REGISTRY[action]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def list_tools() -> list[ActionType]:
|
|
197
|
+
"""등록된 모든 Tool action 목록."""
|
|
198
|
+
return list(_TOOL_REGISTRY.keys())
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_tool(action: ActionType) -> ToolBase:
|
|
202
|
+
"""Tool 인스턴스 생성 (기본은 인자 없음, override 가능)."""
|
|
203
|
+
cls = get_tool_class(action)
|
|
204
|
+
return cls()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def render_all_help(actions: list[ActionType] | None = None) -> str:
|
|
208
|
+
"""모든 등록된 Tool의 help를 markdown으로 — Plan/Reflect prompt에 삽입.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
actions: 표시할 action 목록. None이면 *전부*.
|
|
212
|
+
"""
|
|
213
|
+
if actions is None:
|
|
214
|
+
actions = list(_TOOL_REGISTRY.keys())
|
|
215
|
+
parts = []
|
|
216
|
+
for a in actions:
|
|
217
|
+
try:
|
|
218
|
+
tool = build_tool(a)
|
|
219
|
+
parts.append(tool.render_help())
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.debug(f"render_help 실패 ({a.value}): {e}")
|
|
222
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
structverify.agent.tools.calculate — 안전한 수식 계산 Tool.
|
|
3
|
+
|
|
4
|
+
Agent가 *데이터 점 N개로 수식 계산*할 때 사용.
|
|
5
|
+
|
|
6
|
+
예시 사용:
|
|
7
|
+
- 증가율: (current - prev) / prev * 100
|
|
8
|
+
- 차이: current - prev
|
|
9
|
+
- 비교: a / b * 100 (점유율)
|
|
10
|
+
|
|
11
|
+
안전성:
|
|
12
|
+
- `eval()`은 *절대 사용 X*.
|
|
13
|
+
- AST 기반 *제한된 산술 평가*. 식별자(변수) + 숫자 + 4칙 연산 + 비교만.
|
|
14
|
+
- 함수 호출/속성 접근/import 등 일체 차단.
|
|
15
|
+
|
|
16
|
+
변수 주입:
|
|
17
|
+
- input_data["variables"] = {"current": 20717, "prev": 19059}
|
|
18
|
+
- expression = "(current - prev) / prev * 100"
|
|
19
|
+
- → 결과: 8.7028...
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
from structverify.utils.logger import get_logger
|
|
25
|
+
import math
|
|
26
|
+
import operator
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ..schemas import ActionType
|
|
30
|
+
from .base import ToolBase, ToolContext, ToolResult, register_tool
|
|
31
|
+
|
|
32
|
+
logger = get_logger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── 허용된 AST 노드 + 연산 ─────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
_ALLOWED_BIN_OPS = {
|
|
38
|
+
ast.Add: operator.add,
|
|
39
|
+
ast.Sub: operator.sub,
|
|
40
|
+
ast.Mult: operator.mul,
|
|
41
|
+
ast.Div: operator.truediv,
|
|
42
|
+
ast.FloorDiv: operator.floordiv,
|
|
43
|
+
ast.Mod: operator.mod,
|
|
44
|
+
ast.Pow: operator.pow,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
_ALLOWED_UNARY_OPS = {
|
|
48
|
+
ast.UAdd: operator.pos,
|
|
49
|
+
ast.USub: operator.neg,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_ALLOWED_COMPARE_OPS = {
|
|
53
|
+
ast.Eq: operator.eq,
|
|
54
|
+
ast.NotEq: operator.ne,
|
|
55
|
+
ast.Lt: operator.lt,
|
|
56
|
+
ast.LtE: operator.le,
|
|
57
|
+
ast.Gt: operator.gt,
|
|
58
|
+
ast.GtE: operator.ge,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# 안전한 math 함수 (필요 시 추가)
|
|
62
|
+
_ALLOWED_FUNCS: dict[str, Any] = {
|
|
63
|
+
"abs": abs,
|
|
64
|
+
"round": round,
|
|
65
|
+
"min": min,
|
|
66
|
+
"max": max,
|
|
67
|
+
"sqrt": math.sqrt,
|
|
68
|
+
"log": math.log,
|
|
69
|
+
"log10": math.log10,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _safe_eval(node: ast.AST, variables: dict[str, float]) -> float | int | bool:
|
|
74
|
+
"""제한된 AST 평가."""
|
|
75
|
+
if isinstance(node, ast.Expression):
|
|
76
|
+
return _safe_eval(node.body, variables)
|
|
77
|
+
|
|
78
|
+
# 숫자 상수
|
|
79
|
+
if isinstance(node, ast.Constant):
|
|
80
|
+
if isinstance(node.value, (int, float, bool)):
|
|
81
|
+
return node.value
|
|
82
|
+
raise ValueError(f"허용되지 않는 상수: {type(node.value).__name__}")
|
|
83
|
+
|
|
84
|
+
# 변수 (식별자)
|
|
85
|
+
if isinstance(node, ast.Name):
|
|
86
|
+
if node.id in variables:
|
|
87
|
+
return variables[node.id]
|
|
88
|
+
if node.id in _ALLOWED_FUNCS:
|
|
89
|
+
return _ALLOWED_FUNCS[node.id]
|
|
90
|
+
raise NameError(f"정의되지 않은 변수: {node.id!r}")
|
|
91
|
+
|
|
92
|
+
# 이항 연산
|
|
93
|
+
if isinstance(node, ast.BinOp):
|
|
94
|
+
op_type = type(node.op)
|
|
95
|
+
if op_type not in _ALLOWED_BIN_OPS:
|
|
96
|
+
raise ValueError(f"허용되지 않는 연산: {op_type.__name__}")
|
|
97
|
+
left = _safe_eval(node.left, variables)
|
|
98
|
+
right = _safe_eval(node.right, variables)
|
|
99
|
+
return _ALLOWED_BIN_OPS[op_type](left, right)
|
|
100
|
+
|
|
101
|
+
# 단항 연산 (-x, +x)
|
|
102
|
+
if isinstance(node, ast.UnaryOp):
|
|
103
|
+
op_type = type(node.op)
|
|
104
|
+
if op_type not in _ALLOWED_UNARY_OPS:
|
|
105
|
+
raise ValueError(f"허용되지 않는 단항 연산: {op_type.__name__}")
|
|
106
|
+
return _ALLOWED_UNARY_OPS[op_type](_safe_eval(node.operand, variables))
|
|
107
|
+
|
|
108
|
+
# 비교 (a > b)
|
|
109
|
+
if isinstance(node, ast.Compare):
|
|
110
|
+
left = _safe_eval(node.left, variables)
|
|
111
|
+
for op, right_node in zip(node.ops, node.comparators):
|
|
112
|
+
op_type = type(op)
|
|
113
|
+
if op_type not in _ALLOWED_COMPARE_OPS:
|
|
114
|
+
raise ValueError(f"허용되지 않는 비교 연산: {op_type.__name__}")
|
|
115
|
+
right = _safe_eval(right_node, variables)
|
|
116
|
+
if not _ALLOWED_COMPARE_OPS[op_type](left, right):
|
|
117
|
+
return False
|
|
118
|
+
left = right
|
|
119
|
+
return True
|
|
120
|
+
|
|
121
|
+
# 함수 호출 (abs, round, ...)
|
|
122
|
+
if isinstance(node, ast.Call):
|
|
123
|
+
func = _safe_eval(node.func, variables)
|
|
124
|
+
if not callable(func):
|
|
125
|
+
raise ValueError("함수 호출 불가")
|
|
126
|
+
# 위치 인자만 (kwargs 차단)
|
|
127
|
+
args = [_safe_eval(a, variables) for a in node.args]
|
|
128
|
+
if node.keywords:
|
|
129
|
+
raise ValueError("키워드 인자는 허용되지 않음")
|
|
130
|
+
return func(*args)
|
|
131
|
+
|
|
132
|
+
raise ValueError(f"허용되지 않는 노드 타입: {type(node).__name__}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def safe_calculate(expression: str, variables: dict[str, float] | None = None) -> float:
|
|
136
|
+
"""문자열 수식을 *안전하게* 계산.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
expression: 예 "(current - prev) / prev * 100"
|
|
140
|
+
variables: 예 {"current": 20717, "prev": 19059}
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
계산 결과 (float). 비교식이면 bool도 가능하지만 보통 float.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
ValueError, NameError, ZeroDivisionError 등 — 호출자가 catch.
|
|
147
|
+
"""
|
|
148
|
+
variables = variables or {}
|
|
149
|
+
tree = ast.parse(expression.strip(), mode="eval")
|
|
150
|
+
result = _safe_eval(tree, variables)
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ── Tool 구현 ────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
@register_tool(ActionType.CALCULATE)
|
|
157
|
+
class CalculateTool(ToolBase):
|
|
158
|
+
"""수식 계산 Tool.
|
|
159
|
+
|
|
160
|
+
Agent가 *2개 이상 데이터 점*을 모은 후 계산이 필요할 때 호출.
|
|
161
|
+
예: 증가율, 차이, 비율, 점유율 등.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
name = ActionType.CALCULATE
|
|
165
|
+
description = (
|
|
166
|
+
"수치 계산. 모은 데이터 점들로 증가율/차이/비율을 계산할 때 사용. "
|
|
167
|
+
"예: (current - prev) / prev * 100. "
|
|
168
|
+
"허용 연산: + - * / % ** , 함수: abs, round, min, max, sqrt, log, log10."
|
|
169
|
+
)
|
|
170
|
+
input_schema = {
|
|
171
|
+
"expression": "수식 문자열. 예: '(current - prev) / prev * 100'",
|
|
172
|
+
"variables": "변수 dict. 예: {'current': 20717, 'prev': 19059}",
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async def execute(
|
|
176
|
+
self,
|
|
177
|
+
input_data: dict[str, Any],
|
|
178
|
+
context: ToolContext,
|
|
179
|
+
) -> ToolResult:
|
|
180
|
+
# ★ Phase E: LLM이 'formula' / 'expr' / 'equation' 등 다양한 키 이름을 쓸 수 있음
|
|
181
|
+
# tool은 모두 받아들임 (alias).
|
|
182
|
+
expression = (
|
|
183
|
+
input_data.get("expression")
|
|
184
|
+
or input_data.get("formula")
|
|
185
|
+
or input_data.get("expr")
|
|
186
|
+
or input_data.get("equation")
|
|
187
|
+
or ""
|
|
188
|
+
).strip()
|
|
189
|
+
variables = (
|
|
190
|
+
input_data.get("variables")
|
|
191
|
+
or input_data.get("vars")
|
|
192
|
+
or input_data.get("values")
|
|
193
|
+
or {}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
if not expression:
|
|
197
|
+
return ToolResult(
|
|
198
|
+
output={},
|
|
199
|
+
summary="실패: expression 비어있음",
|
|
200
|
+
success=False,
|
|
201
|
+
error="expression이 비어있습니다.",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
# variables의 모든 값이 숫자인지 검증
|
|
205
|
+
try:
|
|
206
|
+
variables = {k: float(v) for k, v in variables.items()}
|
|
207
|
+
except (TypeError, ValueError) as e:
|
|
208
|
+
return ToolResult(
|
|
209
|
+
output={},
|
|
210
|
+
summary=f"실패: variables 변환 오류 — {e}",
|
|
211
|
+
success=False,
|
|
212
|
+
error=f"variables의 값은 모두 숫자여야 합니다: {e}",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
result = safe_calculate(expression, variables)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
logger.info(f"[calculate] 실패: {expression!r} | {e}")
|
|
219
|
+
return ToolResult(
|
|
220
|
+
output={"expression": expression, "variables": variables},
|
|
221
|
+
summary=f"계산 실패: {expression} → {type(e).__name__}: {e}",
|
|
222
|
+
success=False,
|
|
223
|
+
error=f"{type(e).__name__}: {e}",
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
# 성공
|
|
227
|
+
var_desc = ", ".join(f"{k}={v}" for k, v in variables.items()) if variables else "(no vars)"
|
|
228
|
+
summary = f"계산: {expression} | {var_desc} = {result}"
|
|
229
|
+
|
|
230
|
+
# bool인 경우 비교 결과
|
|
231
|
+
if isinstance(result, bool):
|
|
232
|
+
result_value: Any = result
|
|
233
|
+
else:
|
|
234
|
+
result_value = float(result)
|
|
235
|
+
|
|
236
|
+
return ToolResult(
|
|
237
|
+
output={
|
|
238
|
+
"expression": expression,
|
|
239
|
+
"variables": variables,
|
|
240
|
+
"result": result_value,
|
|
241
|
+
},
|
|
242
|
+
summary=summary,
|
|
243
|
+
success=True,
|
|
244
|
+
)
|