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,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
agent/builder_agent.py — Agent B: 도메인 적응 관리자
|
|
3
|
+
|
|
4
|
+
[김예슬 - 2026-04-24]
|
|
5
|
+
pretrain_domain() 파이프라인 완성:
|
|
6
|
+
1) KOSIS 메타 수집 (kosis_crawler — httpx 실제 호출)
|
|
7
|
+
2) 합성 데이터 생성 (synthetic_generator — claim/schema + candidate detection)
|
|
8
|
+
3) 학습 포맷 변환 (sample_builder — 5가지 태스크 포맷)
|
|
9
|
+
4) NCP Tuning API 학습 (adapter_trainer — Object Storage + Tuning API + polling)
|
|
10
|
+
5) 평가 → 합격 시 배포 (model.yaml 업데이트)
|
|
11
|
+
|
|
12
|
+
[경로 1] 사전학습 pretrain_domain():
|
|
13
|
+
KOSIS 메타 → Self-Instruct 합성 → JSONL 변환 → NCP Object Storage
|
|
14
|
+
→ Tuning API → polling → 평가 → domain-packs/{domain}/model.yaml 배포
|
|
15
|
+
|
|
16
|
+
[경로 2] 피드백 학습 _trigger_adaptation():
|
|
17
|
+
Human Review 수정 결과 → finetune 포맷 → 추가 NCP Tuning → 평가 → 배포
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from structverify.core.schemas import DomainPack, FeedbackEvent
|
|
22
|
+
from structverify.adaptation.feedback_store import FeedbackStore
|
|
23
|
+
from structverify.adaptation.kosis_crawler import crawl_kosis_catalog, save_to_db, is_catalog_ready
|
|
24
|
+
from structverify.adaptation.sample_builder import build_training_samples
|
|
25
|
+
from structverify.adaptation.synthetic_generator import generate_synthetic_pairs, save_synthetic_data
|
|
26
|
+
from structverify.adaptation.adapter_trainer import AdapterTrainer
|
|
27
|
+
from structverify.utils.llm_client import LLMClient
|
|
28
|
+
from structverify.utils.logger import get_logger
|
|
29
|
+
|
|
30
|
+
logger = get_logger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BuilderAgent:
|
|
34
|
+
"""
|
|
35
|
+
Agent B: 도메인 적응 관리자.
|
|
36
|
+
|
|
37
|
+
- pretrain_domain(): 서비스 전 사전학습 (1회)
|
|
38
|
+
- log_feedback(): 운영 피드백 기록
|
|
39
|
+
- _trigger_adaptation(): 피드백 누적 시 추가 학습
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, config: dict | None = None):
|
|
43
|
+
self.config = config or {}
|
|
44
|
+
self.feedback_store = FeedbackStore(config=self.config)
|
|
45
|
+
self.trainer = AdapterTrainer(config=self.config)
|
|
46
|
+
self.llm = LLMClient(config=self.config.get("llm", {}))
|
|
47
|
+
adapt_cfg = self.config.get("adaptation", {})
|
|
48
|
+
self.threshold = int(adapt_cfg.get("feedback_threshold", 10))
|
|
49
|
+
self.eval_min_score = float(adapt_cfg.get("eval_min_score", 0.85))
|
|
50
|
+
|
|
51
|
+
async def pretrain_domain(
|
|
52
|
+
self,
|
|
53
|
+
domain: str,
|
|
54
|
+
max_tables: int | None = None,
|
|
55
|
+
) -> str | None:
|
|
56
|
+
"""
|
|
57
|
+
도메인 사전학습 전체 파이프라인 실행.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
domain: 학습할 도메인 (예: "news", "agriculture")
|
|
61
|
+
max_tables: 처리할 최대 KOSIS 통계표 수 (None=전체)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
배포된 adapter_path 또는 None (실패/평가 미통과)
|
|
65
|
+
"""
|
|
66
|
+
logger.info(f"[Agent B] === 사전학습 시작: {domain} ===")
|
|
67
|
+
|
|
68
|
+
# ── Step 0-1: KOSIS 메타데이터 수집 ──────────────────────────
|
|
69
|
+
# [v2 김예슬 - 2026-04-30] catalog.rebuild=false이고 DB에 이미 데이터 있으면 skip
|
|
70
|
+
# config.yaml:
|
|
71
|
+
# kosis.catalog.rebuild: false # true이면 강제 재수집
|
|
72
|
+
# kosis.catalog.min_rows: 1000 # 이 수 이상이면 구축됨으로 판단
|
|
73
|
+
catalog_cfg = self.config.get("kosis", {}).get("catalog", {})
|
|
74
|
+
should_rebuild = catalog_cfg.get("rebuild", False)
|
|
75
|
+
min_rows = int(catalog_cfg.get("min_rows", 1000))
|
|
76
|
+
|
|
77
|
+
logger.info("[Agent B] Step 0-1: KOSIS 메타데이터 수집")
|
|
78
|
+
|
|
79
|
+
if not should_rebuild and await is_catalog_ready(self.config, min_rows):
|
|
80
|
+
logger.info(
|
|
81
|
+
f"[Agent B] kosis_stat_catalog 이미 구축됨 (min_rows={min_rows}) → "
|
|
82
|
+
f"수집 skip. 강제 재수집: config.kosis.catalog.rebuild=true"
|
|
83
|
+
)
|
|
84
|
+
# catalog가 이미 DB에 있으므로 합성 데이터 생성도 skip
|
|
85
|
+
# → Step 0-2~5는 이미 학습된 adapter가 있는 경우에만 의미있음
|
|
86
|
+
# → pretrain_domain은 여기서 종료하고 기존 adapter 경로 반환
|
|
87
|
+
import os
|
|
88
|
+
model_yaml = os.path.join("domain-packs", domain, "model.yaml")
|
|
89
|
+
if os.path.exists(model_yaml):
|
|
90
|
+
import yaml
|
|
91
|
+
with open(model_yaml, encoding="utf-8") as f:
|
|
92
|
+
model_info = yaml.safe_load(f)
|
|
93
|
+
existing_path = model_info.get("adapter_path")
|
|
94
|
+
if existing_path:
|
|
95
|
+
logger.info(f"[Agent B] 기존 adapter 사용: {existing_path}")
|
|
96
|
+
return existing_path
|
|
97
|
+
logger.info("[Agent B] catalog 구축됨이나 adapter 없음 → 합성 데이터 생성 진행")
|
|
98
|
+
catalog = [] # save_to_db skip, 합성 데이터는 DB catalog 기반으로 진행
|
|
99
|
+
else:
|
|
100
|
+
catalog = await crawl_kosis_catalog(self.config)
|
|
101
|
+
if not catalog:
|
|
102
|
+
logger.error("[Agent B] 메타데이터 수집 실패 (KOSIS_API_KEY 확인 필요)")
|
|
103
|
+
return None
|
|
104
|
+
await save_to_db(catalog, self.config)
|
|
105
|
+
logger.info(f"[Agent B] 메타데이터 {len(catalog)}건 수집 완료")
|
|
106
|
+
|
|
107
|
+
# ── Step 0-2: 합성 학습 데이터 생성 ──────────────────────────
|
|
108
|
+
logger.info("[Agent B] Step 0-2: Self-Instruct 합성 데이터 생성")
|
|
109
|
+
synthetic = await generate_synthetic_pairs(
|
|
110
|
+
catalog=catalog,
|
|
111
|
+
llm=self.llm,
|
|
112
|
+
claims_per_table=3,
|
|
113
|
+
max_tables=max_tables,
|
|
114
|
+
)
|
|
115
|
+
if not synthetic:
|
|
116
|
+
logger.error("[Agent B] 합성 데이터 생성 실패")
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
# JSONL 파일로 저장 (ml/data/)
|
|
120
|
+
await save_synthetic_data(synthetic)
|
|
121
|
+
logger.info(f"[Agent B] 합성 데이터 {len(synthetic)}쌍 생성 완료")
|
|
122
|
+
|
|
123
|
+
# ── Step 0-3: 학습 포맷 변환 ─────────────────────────────────
|
|
124
|
+
logger.info("[Agent B] Step 0-3: 학습 포맷 변환 (pretrain)")
|
|
125
|
+
samples = build_training_samples(synthetic=synthetic, mode="pretrain")
|
|
126
|
+
logger.info(f"[Agent B] 학습 샘플 {len(samples)}건 생성")
|
|
127
|
+
|
|
128
|
+
if not samples:
|
|
129
|
+
logger.error("[Agent B] 학습 샘플 없음")
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
# ── Step 0-4: NCP Tuning API 학습 ────────────────────────────
|
|
133
|
+
logger.info("[Agent B] Step 0-4: NCP Tuning API 학습 시작")
|
|
134
|
+
adapter_path = await self.trainer.train(domain, samples)
|
|
135
|
+
if not adapter_path:
|
|
136
|
+
logger.error("[Agent B] 학습 실패 (NCP Tuning API 확인 필요)")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
# ── Step 0-5: 평가 → 배포 ────────────────────────────────────
|
|
140
|
+
benchmark = f"domain-packs/{domain}/eval_dataset.jsonl"
|
|
141
|
+
score = await self.trainer.evaluate(adapter_path, benchmark)
|
|
142
|
+
logger.info(f"[Agent B] 평가 점수: {score:.3f} (기준: {self.eval_min_score})")
|
|
143
|
+
|
|
144
|
+
if score < self.eval_min_score:
|
|
145
|
+
logger.warning(
|
|
146
|
+
f"[Agent B] 평가 미통과 ({score:.3f} < {self.eval_min_score}) "
|
|
147
|
+
f"— Adapter 배포 안 함"
|
|
148
|
+
)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
deployed = await self.trainer.deploy(adapter_path, domain)
|
|
152
|
+
if deployed:
|
|
153
|
+
logger.info(f"[Agent B] === 사전학습 완료: {domain} Adapter 배포 ===")
|
|
154
|
+
return adapter_path
|
|
155
|
+
|
|
156
|
+
# ── 피드백 루프 ──────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
async def log_feedback(self, event: FeedbackEvent) -> None:
|
|
159
|
+
"""
|
|
160
|
+
Step 11: Human Review 피드백 저장 + 임계값 도달 시 학습 트리거.
|
|
161
|
+
|
|
162
|
+
TODO [박재윤]: feedback_store.save() → feedback_events 테이블 INSERT
|
|
163
|
+
"""
|
|
164
|
+
await self.feedback_store.save(event)
|
|
165
|
+
logger.info(f"[Agent B] 피드백 기록: {event.feedback_type.value}")
|
|
166
|
+
|
|
167
|
+
count = await self.feedback_store.count_by_domain()
|
|
168
|
+
logger.debug(f"[Agent B] 누적 피드백: {count}건 (임계값: {self.threshold})")
|
|
169
|
+
|
|
170
|
+
if count >= self.threshold:
|
|
171
|
+
logger.info(f"[Agent B] 임계값 도달 → Adaptation 트리거")
|
|
172
|
+
await self._trigger_adaptation()
|
|
173
|
+
|
|
174
|
+
async def _trigger_adaptation(self) -> None:
|
|
175
|
+
"""
|
|
176
|
+
Step 12: 피드백 기반 추가 학습.
|
|
177
|
+
|
|
178
|
+
pending 피드백 수집 → finetune 포맷 변환 → NCP Tuning → 평가 → 배포.
|
|
179
|
+
"""
|
|
180
|
+
logger.info("[Agent B] 피드백 Adaptation 시작")
|
|
181
|
+
|
|
182
|
+
events = await self.feedback_store.get_pending()
|
|
183
|
+
if not events:
|
|
184
|
+
logger.warning("[Agent B] pending 피드백 없음")
|
|
185
|
+
return
|
|
186
|
+
|
|
187
|
+
samples = build_training_samples(events=events, mode="finetune")
|
|
188
|
+
logger.info(f"[Agent B] 피드백 샘플 {len(samples)}건")
|
|
189
|
+
|
|
190
|
+
if not samples:
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
adapter_path = await self.trainer.train("news", samples)
|
|
194
|
+
if not adapter_path:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
benchmark = "domain-packs/news/eval_dataset.jsonl"
|
|
198
|
+
score = await self.trainer.evaluate(adapter_path, benchmark)
|
|
199
|
+
|
|
200
|
+
if score >= self.eval_min_score:
|
|
201
|
+
await self.trainer.deploy(adapter_path, "news")
|
|
202
|
+
logger.info("[Agent B] 피드백 Adapter 배포 완료")
|
|
203
|
+
else:
|
|
204
|
+
logger.warning(f"[Agent B] 피드백 Adapter 평가 미통과: {score:.3f}")
|
|
205
|
+
|
|
206
|
+
async def generate_domain_pack(self, domain: str) -> DomainPack:
|
|
207
|
+
"""
|
|
208
|
+
새 도메인용 Domain Pack 디렉토리 및 기본 파일 생성.
|
|
209
|
+
|
|
210
|
+
TODO [김예슬]: KOSIS 메타 기반 few-shot 예시 자동 생성
|
|
211
|
+
"""
|
|
212
|
+
import os, yaml
|
|
213
|
+
|
|
214
|
+
pack_dir = os.path.join("domain-packs", domain)
|
|
215
|
+
os.makedirs(pack_dir, exist_ok=True)
|
|
216
|
+
|
|
217
|
+
prompts_yaml = os.path.join(pack_dir, "prompts.yaml")
|
|
218
|
+
if not os.path.exists(prompts_yaml):
|
|
219
|
+
with open(prompts_yaml, "w", encoding="utf-8") as f:
|
|
220
|
+
yaml.dump(
|
|
221
|
+
{"domain": domain, "few_shot_examples": []},
|
|
222
|
+
f, allow_unicode=True,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
logger.info(f"[Agent B] Domain Pack 생성: {pack_dir}")
|
|
226
|
+
return DomainPack(pack_id=f"pack_{domain}_v1", domain=domain, version="1.0")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""structverify.agent.conformance_agent — a ReAct loop for compliance checking.
|
|
2
|
+
|
|
3
|
+
The one-shot path (retrieve top-k once → judge) is fine for a small rulebook,
|
|
4
|
+
but breaks down when the rulebook is large: the single retrieval may miss the
|
|
5
|
+
one article that actually applies. This agent adds the same *adaptive retrieval*
|
|
6
|
+
that the KOSIS fact-verification loop uses — it iterates:
|
|
7
|
+
|
|
8
|
+
search → judge → (inconclusive?) reflect & reformulate the query → search again
|
|
9
|
+
|
|
10
|
+
so it keeps hunting for the applicable article until it reaches a decisive
|
|
11
|
+
verdict (compliant / violation) or exhausts its iteration budget. A small
|
|
12
|
+
working memory of tried queries and inspected articles steers each reflection
|
|
13
|
+
and prevents repeat searches.
|
|
14
|
+
|
|
15
|
+
Retrieval and judgment are reused as-is (``CustomDocsDataSource`` +
|
|
16
|
+
``judge_conformance``); this module only adds the loop around them.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from structverify.utils.llm_client import LLMClient
|
|
23
|
+
from structverify.utils.logger import get_logger
|
|
24
|
+
from structverify.verification.conformance import judge_conformance
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
_DECISIVE = {"compliant", "violation"}
|
|
29
|
+
|
|
30
|
+
_REFORMULATE_PROMPT = """너는 규정집에서 적용 조항을 찾는 검색 전략가다.
|
|
31
|
+
|
|
32
|
+
[판정 대상]
|
|
33
|
+
{statement}
|
|
34
|
+
|
|
35
|
+
지금까지의 검색으로는 적용할 조항을 확정하지 못했다 (준수/위반 판정 불가).
|
|
36
|
+
|
|
37
|
+
[이미 시도한 검색어]
|
|
38
|
+
{tried}
|
|
39
|
+
|
|
40
|
+
[방금 검색됐지만 적합하지 않았던 조항 일부]
|
|
41
|
+
{snippet}
|
|
42
|
+
|
|
43
|
+
더 적합한 조항을 찾기 위한 **새로운 검색어 하나**를 제안하라.
|
|
44
|
+
- 측정 대상·항목명·단위·규제 키워드 중심으로 (예: 원소명, 성분명, 표시문구, 한도 유형).
|
|
45
|
+
- 이미 시도한 검색어와 달라야 한다.
|
|
46
|
+
JSON만 출력: {{"query": "새 검색어", "reason": "왜 이 검색어인지 한 문장"}}"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ConformanceAgent:
|
|
50
|
+
"""Adaptive, iterative compliance checker over a document data source.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
ds: A ``CustomDocsDataSource`` (or any source exposing async
|
|
54
|
+
``search_catalog(query, top_k)`` and ``fetch_evidence(id)``).
|
|
55
|
+
config: Engine config (``{"llm": {...}}``) for judgment/reflection.
|
|
56
|
+
max_iters: Maximum search→judge rounds before giving up.
|
|
57
|
+
top_k: Candidates retrieved per search.
|
|
58
|
+
votes: Self-consistency votes passed to ``judge_conformance``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self, ds: Any, config: dict | None = None, *,
|
|
63
|
+
max_iters: int = 4, top_k: int = 5, votes: int = 3,
|
|
64
|
+
):
|
|
65
|
+
self._ds = ds
|
|
66
|
+
self._config = config or {}
|
|
67
|
+
self._max_iters = max(1, max_iters)
|
|
68
|
+
self._top_k = top_k
|
|
69
|
+
self._votes = votes
|
|
70
|
+
self._llm = LLMClient(config=(self._config or {}).get("llm", {}))
|
|
71
|
+
|
|
72
|
+
async def check(self, statement: str) -> dict[str, Any]:
|
|
73
|
+
"""Run the loop and return a ``judge_conformance``-style dict.
|
|
74
|
+
|
|
75
|
+
The returned dict carries the usual keys (``verdict``,
|
|
76
|
+
``applicable_article``, ``rule_value``, ``claim_value``, ``unit``,
|
|
77
|
+
``explanation``) plus ``iterations`` — how many rounds it took.
|
|
78
|
+
"""
|
|
79
|
+
tried_queries: list[str] = []
|
|
80
|
+
seen_articles: dict[str, str] = {} # id → text (dedup across rounds)
|
|
81
|
+
query = statement
|
|
82
|
+
last_result: dict[str, Any] = {"verdict": "unverifiable", "explanation": "검색 결과 없음"}
|
|
83
|
+
|
|
84
|
+
for i in range(1, self._max_iters + 1):
|
|
85
|
+
tried_queries.append(query)
|
|
86
|
+
|
|
87
|
+
# ── retrieve ────────────────────────────────────────────────
|
|
88
|
+
logger.info(f"[에이전트][{i}회] 검색 → {query!r}")
|
|
89
|
+
new_snippets = await self._retrieve(query, seen_articles)
|
|
90
|
+
if new_snippets:
|
|
91
|
+
top = " ".join(new_snippets[0].split())[:46]
|
|
92
|
+
logger.info(
|
|
93
|
+
f"[에이전트][{i}회] 조회 → 조항 {len(seen_articles)}개 확보 "
|
|
94
|
+
f"(신규 {len(new_snippets)}개, top: {top}…)"
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
logger.info(
|
|
98
|
+
f"[에이전트][{i}회] 조회 → 신규 조항 없음 (누적 {len(seen_articles)}개)"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Judge against everything gathered so far (accumulated context helps
|
|
102
|
+
# when the right article surfaces only after a reformulated search).
|
|
103
|
+
corpus = "\n---\n".join(seen_articles.values())
|
|
104
|
+
if corpus.strip():
|
|
105
|
+
last_result = await judge_conformance(
|
|
106
|
+
statement, corpus, self._config, votes=self._votes,
|
|
107
|
+
)
|
|
108
|
+
verdict = last_result.get("verdict")
|
|
109
|
+
rv, cv = last_result.get("rule_value"), last_result.get("claim_value")
|
|
110
|
+
unit = last_result.get("unit") or ""
|
|
111
|
+
logger.info(
|
|
112
|
+
f"[에이전트][{i}회] 판정 → {verdict} (기준 {rv} vs 측정 {cv} {unit})"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# ── reflect: decisive verdict ends the loop ────────────────
|
|
116
|
+
if verdict in _DECISIVE:
|
|
117
|
+
logger.info(f"[에이전트] 확정 — {i}회 만에 종료 ({last_result.get('applicable_article')})")
|
|
118
|
+
last_result["iterations"] = i
|
|
119
|
+
return last_result
|
|
120
|
+
|
|
121
|
+
# ── replan: reformulate the query and search again ─────────
|
|
122
|
+
if i < self._max_iters:
|
|
123
|
+
logger.info(f"[에이전트][{i}회] 판정 불가 → 검색어 재구성 시도")
|
|
124
|
+
query = await self._reformulate(statement, tried_queries, new_snippets)
|
|
125
|
+
if not query or query in tried_queries:
|
|
126
|
+
logger.info("[에이전트] 더 나은 검색어 없음 — 종료")
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
last_result["iterations"] = i
|
|
130
|
+
return last_result
|
|
131
|
+
|
|
132
|
+
# ── tools ───────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
async def _retrieve(self, query: str, seen: dict[str, str]) -> list[str]:
|
|
135
|
+
"""Search + fetch article texts; record new ones in ``seen``. Returns new snippets."""
|
|
136
|
+
fresh: list[str] = []
|
|
137
|
+
try:
|
|
138
|
+
cands = await self._ds.search_catalog(query, top_k=self._top_k)
|
|
139
|
+
except Exception as e: # retrieval must never crash the loop
|
|
140
|
+
logger.warning(f"[conformance-agent] search 실패: {e}")
|
|
141
|
+
return fresh
|
|
142
|
+
for c in cands:
|
|
143
|
+
cid = c.get("id")
|
|
144
|
+
if cid in seen:
|
|
145
|
+
continue
|
|
146
|
+
ev = await self._ds.fetch_evidence(cid)
|
|
147
|
+
if ev and ev.get("text"):
|
|
148
|
+
seen[cid] = ev["text"]
|
|
149
|
+
fresh.append(ev["text"])
|
|
150
|
+
return fresh
|
|
151
|
+
|
|
152
|
+
async def _reformulate(
|
|
153
|
+
self, statement: str, tried: list[str], snippets: list[str],
|
|
154
|
+
) -> str | None:
|
|
155
|
+
"""Ask the LLM for a better search query given what didn't work."""
|
|
156
|
+
prompt = _REFORMULATE_PROMPT.format(
|
|
157
|
+
statement=statement,
|
|
158
|
+
tried="\n".join(f"- {q}" for q in tried),
|
|
159
|
+
snippet=("\n".join(snippets)[:800] or "(없음)"),
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
out = await self._llm.generate_json_light(prompt)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.warning(f"[에이전트] 쿼리 재구성 실패: {e}")
|
|
165
|
+
return None
|
|
166
|
+
q = (out or {}).get("query")
|
|
167
|
+
if isinstance(q, str) and q.strip():
|
|
168
|
+
reason = str((out or {}).get("reason", "")).strip()
|
|
169
|
+
logger.info(f"[에이전트] 재구성 → {q.strip()!r}" + (f" ({reason[:40]})" if reason else ""))
|
|
170
|
+
return q.strip()
|
|
171
|
+
return None
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Document-level Dependency Planner — Phase D+ (2026-05-21).
|
|
2
|
+
|
|
3
|
+
각 sub-claim을 *독립적으로* planner+agent_loop에 보내던 기존 흐름의 한계:
|
|
4
|
+
- 같은 문장에서 분기된 base + derived sub-claim이 동시에 병렬 실행됨
|
|
5
|
+
- derived(예: 출생아 수 증가율)는 base(예: 출생아 수)의 KOSIS evidence가 있으면
|
|
6
|
+
재활용 가능한데, 병렬이라 base 결과가 아직 없어 derived가 또 fetch 시도
|
|
7
|
+
- 같은 indicator를 공유하는 base 그룹(의료장비 4개 시도 케이스)도 catalog 결과를
|
|
8
|
+
공유하지 못해 각자 catalog_search를 4회 반복
|
|
9
|
+
|
|
10
|
+
처방: sub-claim들을 *실행 레벨*로 묶어 level 간 *순차*, level 내 *병렬*로 처리.
|
|
11
|
+
verified_facts/successful_stat_ids 캐시가 level 간에 살아 있으므로 level 2의 claim들이
|
|
12
|
+
level 1의 evidence를 자동 재활용.
|
|
13
|
+
|
|
14
|
+
레벨 결정 신호:
|
|
15
|
+
- schema.value_role
|
|
16
|
+
- "base" → Level 1 (단독 검증)
|
|
17
|
+
- "aggregation" → Level 1 (다년 fetch — sibling 의존 없음, catalog 캐시 공유)
|
|
18
|
+
- "derived_rate" → Level 2 (base 결과 의존)
|
|
19
|
+
- "derived_difference" → Level 2
|
|
20
|
+
- None / 기타 → Level 1 (default)
|
|
21
|
+
|
|
22
|
+
옵션 (use_llm=True): LLM이 모든 sub-claim의 schema를 보고 더 정밀한 dependency 그래프
|
|
23
|
+
구성. 현재는 stub (deterministic으로 fallback). Phase C+에서 prompt 구현 예정.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import Any, Callable, Awaitable
|
|
28
|
+
|
|
29
|
+
from structverify.utils.logger import get_logger
|
|
30
|
+
|
|
31
|
+
logger = get_logger(__name__)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── 공개 API ──────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_execution_levels(
|
|
38
|
+
claims: list[Any],
|
|
39
|
+
*,
|
|
40
|
+
llm_call: Callable[[str], Awaitable[str]] | None = None,
|
|
41
|
+
use_llm: bool = False,
|
|
42
|
+
) -> list[list[Any]]:
|
|
43
|
+
"""sub-claim 리스트 → 실행 레벨 (list of lists).
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
claims: structverify Claim 리스트
|
|
47
|
+
llm_call: 옵션. LLM 기반 dependency 분석 (use_llm=True일 때 사용)
|
|
48
|
+
use_llm: True면 LLM document_planner 호출, False면 결정론 분석
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
levels: [[claim, ...], [claim, ...], ...]
|
|
52
|
+
- 외부 list는 순차 실행 (level 간 순서 의존)
|
|
53
|
+
- 내부 list는 병렬 실행 가능 (level 안의 claim들)
|
|
54
|
+
모든 입력 claim은 정확히 한 level에 포함됨.
|
|
55
|
+
"""
|
|
56
|
+
if not claims:
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
if use_llm and llm_call is not None:
|
|
60
|
+
try:
|
|
61
|
+
levels = _llm_levels(claims, llm_call)
|
|
62
|
+
if levels and _all_claims_covered(claims, levels):
|
|
63
|
+
logger.info(
|
|
64
|
+
f"[dep_planner] LLM mode: {len(levels)} levels, "
|
|
65
|
+
f"sizes={[len(lvl) for lvl in levels]}"
|
|
66
|
+
)
|
|
67
|
+
return levels
|
|
68
|
+
logger.warning(
|
|
69
|
+
"[dep_planner] LLM 응답 부적합 → 결정론으로 fallback"
|
|
70
|
+
)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
logger.warning(
|
|
73
|
+
f"[dep_planner] LLM mode 실패 → 결정론으로 fallback: "
|
|
74
|
+
f"{type(e).__name__}: {e}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
levels = _deterministic_levels(claims)
|
|
78
|
+
logger.info(
|
|
79
|
+
f"[dep_planner] deterministic mode: {len(levels)} levels, "
|
|
80
|
+
f"sizes={[len(lvl) for lvl in levels]}"
|
|
81
|
+
)
|
|
82
|
+
return levels
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── 결정론 분석 ──────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _deterministic_levels(claims: list[Any]) -> list[list[Any]]:
|
|
89
|
+
"""결정론적 level 분할 — schema.value_role 기반.
|
|
90
|
+
|
|
91
|
+
Level 1: base + value_role 미지정 claim
|
|
92
|
+
- 단독 검증 가능. catalog_search → fetch → finish.
|
|
93
|
+
- 같은 indicator를 공유하는 claim들은 prior_success stat_id 캐시로
|
|
94
|
+
catalog 결과 자연스럽게 공유 (현재 매커니즘 그대로).
|
|
95
|
+
|
|
96
|
+
Level 2: derived_rate / derived_difference claim
|
|
97
|
+
- Level 1 끝난 후 실행. base claim의 fetch evidence가 verified_facts에
|
|
98
|
+
캐시되어 있어 prev/current 시점 fetch를 재활용할 수 있음.
|
|
99
|
+
"""
|
|
100
|
+
base_or_unspecified: list[Any] = []
|
|
101
|
+
derived: list[Any] = []
|
|
102
|
+
|
|
103
|
+
for c in claims:
|
|
104
|
+
schema = getattr(c, "schema", None)
|
|
105
|
+
role = getattr(schema, "value_role", None) if schema else None
|
|
106
|
+
if role in ("derived_rate", "derived_difference"):
|
|
107
|
+
derived.append(c)
|
|
108
|
+
else:
|
|
109
|
+
# "base" / None / 기타 → Level 1
|
|
110
|
+
base_or_unspecified.append(c)
|
|
111
|
+
|
|
112
|
+
levels: list[list[Any]] = []
|
|
113
|
+
if base_or_unspecified:
|
|
114
|
+
levels.append(base_or_unspecified)
|
|
115
|
+
if derived:
|
|
116
|
+
levels.append(derived)
|
|
117
|
+
|
|
118
|
+
# 안전망: 모든 claim이 한 level에 들어갔는지
|
|
119
|
+
if not _all_claims_covered(claims, levels):
|
|
120
|
+
logger.warning(
|
|
121
|
+
"[dep_planner] 결정론 분할에서 누락 claim 발견 → 단일 level fallback"
|
|
122
|
+
)
|
|
123
|
+
return [list(claims)]
|
|
124
|
+
|
|
125
|
+
return levels
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _all_claims_covered(claims: list[Any], levels: list[list[Any]]) -> bool:
|
|
129
|
+
"""levels에 모든 claim이 정확히 한 번씩 들어갔는지 검증."""
|
|
130
|
+
flat = [c for lvl in levels for c in lvl]
|
|
131
|
+
if len(flat) != len(claims):
|
|
132
|
+
return False
|
|
133
|
+
flat_ids = {id(c) for c in flat}
|
|
134
|
+
return all(id(c) in flat_ids for c in claims)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ── LLM 기반 분석 (stub — Phase C+) ──────────────────────────────────
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _llm_levels(
|
|
141
|
+
claims: list[Any],
|
|
142
|
+
llm_call: Callable[[str], Awaitable[str]],
|
|
143
|
+
) -> list[list[Any]]:
|
|
144
|
+
"""LLM이 모든 sub-claim의 schema를 보고 dependency 그래프 구성.
|
|
145
|
+
|
|
146
|
+
현재는 stub. Phase C+에서 prompt 구현 예정. 호출자는 fallback을 처리해야 함.
|
|
147
|
+
"""
|
|
148
|
+
raise NotImplementedError(
|
|
149
|
+
"LLM document_planner는 Phase C+에서 구현 예정. "
|
|
150
|
+
"현재는 use_llm=False (결정론)만 작동."
|
|
151
|
+
)
|