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,958 @@
|
|
|
1
|
+
"""
|
|
2
|
+
structverify.agent.workspace — Agent 작업 공간 추상화.
|
|
3
|
+
|
|
4
|
+
각 검증 job마다 workspace 디렉토리가 생성되며, agent는 이 공간을
|
|
5
|
+
*파일 시스템처럼* 읽고 쓰면서 작업한다. memory.md, plan.json,
|
|
6
|
+
observation log 등을 *멀티턴 동안 누적*.
|
|
7
|
+
|
|
8
|
+
디렉토리 구조:
|
|
9
|
+
workspace/job_{job_id}/
|
|
10
|
+
├─ meta.json # job 메타
|
|
11
|
+
├─ source.txt # 원문 기사 (raw)
|
|
12
|
+
├─ claims/
|
|
13
|
+
│ └─ {claim_id}/
|
|
14
|
+
│ ├─ claim.json # ClaimSchema (입력)
|
|
15
|
+
│ ├─ plan.json # Plan Agent 출력
|
|
16
|
+
│ ├─ memory.md # 평문 메모리 (LLM이 read/write)
|
|
17
|
+
│ ├─ log.jsonl # 모든 action+observation
|
|
18
|
+
│ ├─ observations/
|
|
19
|
+
│ │ ├─ obs_001_catalog.json
|
|
20
|
+
│ │ └─ obs_002_kosis.json
|
|
21
|
+
│ ├─ data_points.json # 모은 데이터 점들
|
|
22
|
+
│ └─ verdict.json # 최종 판정
|
|
23
|
+
└─ summary.json
|
|
24
|
+
|
|
25
|
+
백엔드 추상화 (config.agent.workspace.backend):
|
|
26
|
+
- "local": LocalWorkspaceBackend (현재 구현)
|
|
27
|
+
- "minio": MinIOWorkspaceBackend (Phase F에서 구현, 지금은 NotImplementedError)
|
|
28
|
+
- "s3": 같음
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
import json
|
|
32
|
+
from structverify.utils.logger import get_logger
|
|
33
|
+
import shutil
|
|
34
|
+
from abc import ABC, abstractmethod
|
|
35
|
+
from datetime import datetime, timezone
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
from uuid import UUID
|
|
39
|
+
|
|
40
|
+
logger = get_logger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── [수정 v6.22] verified_facts 캐시 매칭 헬퍼 ────────────────────────
|
|
44
|
+
# [추가 이유] 기존 lookup_verified_fact의 포함관계 매칭이 너무 느슨해서,
|
|
45
|
+
# '출생아 수 증가율'(%) claim이 '출생아 수'(명) 값 230028을 그대로
|
|
46
|
+
# 재사용하던 버그가 있었음 (단위가 % vs 명으로 다른데도 적중).
|
|
47
|
+
# → 파생 지표(증가율/차이 등)를 base 지표와 분리하고, unit이 호환될
|
|
48
|
+
# 때만 캐시 적중을 허용하도록 아래 헬퍼를 추가.
|
|
49
|
+
# 도메인 무관 — 보편적 파생 어휘 + 단위 정규화만 사용.
|
|
50
|
+
_DERIVED_SUFFIXES = ("증가율", "감소율", "증감률", "변화율", "상승률",
|
|
51
|
+
"하락률", "증감", "차이", "증가폭", "변화량")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _strip_derived_suffix(indicator: str) -> str:
|
|
55
|
+
"""indicator에서 파생 접미사를 떼어 base 지표명을 반환.
|
|
56
|
+
'출생아 수 증가율' → '출생아 수' / '합계출산율 차이' → '합계출산율'
|
|
57
|
+
"""
|
|
58
|
+
s = str(indicator or "").strip()
|
|
59
|
+
for suf in _DERIVED_SUFFIXES:
|
|
60
|
+
if s.endswith(suf):
|
|
61
|
+
return s[: -len(suf)].strip()
|
|
62
|
+
return s
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _norm_unit(unit: str) -> str:
|
|
66
|
+
"""단위 문자열 정규화 — 캐시 매칭 시 % ↔ 명 혼동 차단용.
|
|
67
|
+
퍼센트 계열 → 'percent', 그 외는 공백 제거한 원문.
|
|
68
|
+
"""
|
|
69
|
+
u = str(unit or "").strip().lower()
|
|
70
|
+
if not u:
|
|
71
|
+
return ""
|
|
72
|
+
if u in ("%", "%", "percent", "퍼센트", "프로", "pp", "%p", "퍼센트포인트"):
|
|
73
|
+
return "percent"
|
|
74
|
+
return u.replace(" ", "")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ── 백엔드 추상 인터페이스 ─────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
class WorkspaceBackend(ABC):
|
|
80
|
+
"""Workspace 저장소의 추상 인터페이스 (key-value text 저장)."""
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
def read_text(self, key: str) -> str:
|
|
84
|
+
"""key의 내용을 읽음. 없으면 FileNotFoundError."""
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
def write_text(self, key: str, content: str) -> None:
|
|
88
|
+
"""key에 내용 쓰기 (덮어쓰기). 상위 경로 자동 생성."""
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
def append_text(self, key: str, content: str) -> None:
|
|
92
|
+
"""key에 내용 추가 (append). 없으면 생성."""
|
|
93
|
+
|
|
94
|
+
@abstractmethod
|
|
95
|
+
def exists(self, key: str) -> bool:
|
|
96
|
+
"""key 존재 여부."""
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
def list_keys(self, prefix: str) -> list[str]:
|
|
100
|
+
"""prefix 아래 모든 key (재귀)."""
|
|
101
|
+
|
|
102
|
+
@abstractmethod
|
|
103
|
+
def delete_prefix(self, prefix: str) -> None:
|
|
104
|
+
"""prefix 아래 모든 항목 삭제 (cleanup용)."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Local 백엔드 (기본 구현) ─────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
class LocalWorkspaceBackend(WorkspaceBackend):
|
|
110
|
+
"""파일 시스템 백엔드. key는 슬래시 구분 경로."""
|
|
111
|
+
|
|
112
|
+
def __init__(self, root: str | Path):
|
|
113
|
+
self.root = Path(root)
|
|
114
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
115
|
+
logger.debug(f"[workspace] LocalWorkspaceBackend root={self.root}")
|
|
116
|
+
|
|
117
|
+
def _path(self, key: str) -> Path:
|
|
118
|
+
# 경로 traversal 방지 — '..' 같은 거 차단
|
|
119
|
+
if ".." in Path(key).parts:
|
|
120
|
+
raise ValueError(f"Invalid key (path traversal): {key!r}")
|
|
121
|
+
return self.root / key
|
|
122
|
+
|
|
123
|
+
def read_text(self, key: str) -> str:
|
|
124
|
+
return self._path(key).read_text(encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
def write_text(self, key: str, content: str) -> None:
|
|
127
|
+
p = self._path(key)
|
|
128
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
p.write_text(content, encoding="utf-8")
|
|
130
|
+
|
|
131
|
+
def append_text(self, key: str, content: str) -> None:
|
|
132
|
+
p = self._path(key)
|
|
133
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
with p.open("a", encoding="utf-8") as f:
|
|
135
|
+
f.write(content)
|
|
136
|
+
|
|
137
|
+
def exists(self, key: str) -> bool:
|
|
138
|
+
return self._path(key).exists()
|
|
139
|
+
|
|
140
|
+
def list_keys(self, prefix: str) -> list[str]:
|
|
141
|
+
base = self._path(prefix)
|
|
142
|
+
if not base.exists():
|
|
143
|
+
return []
|
|
144
|
+
return [
|
|
145
|
+
str(p.relative_to(self.root)).replace("\\", "/")
|
|
146
|
+
for p in base.rglob("*")
|
|
147
|
+
if p.is_file()
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
def delete_prefix(self, prefix: str) -> None:
|
|
151
|
+
p = self._path(prefix)
|
|
152
|
+
if p.exists():
|
|
153
|
+
if p.is_dir():
|
|
154
|
+
shutil.rmtree(p)
|
|
155
|
+
else:
|
|
156
|
+
p.unlink()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── MinIO/S3 백엔드 (자리만, 미구현) ───────────────────────────────
|
|
160
|
+
|
|
161
|
+
class MinIOWorkspaceBackend(WorkspaceBackend):
|
|
162
|
+
"""MinIO/S3 백엔드 — Phase F에서 구현 예정.
|
|
163
|
+
|
|
164
|
+
config.storage.* 인프라(이미 yaml에 있음)와 통합 가능.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, *args, **kwargs):
|
|
168
|
+
raise NotImplementedError(
|
|
169
|
+
"MinIO 백엔드는 아직 구현되지 않았습니다. "
|
|
170
|
+
"config: agent.workspace.backend='local'을 사용하세요. "
|
|
171
|
+
"Phase F (배포)에서 구현 예정."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def read_text(self, key): raise NotImplementedError
|
|
175
|
+
def write_text(self, key, content): raise NotImplementedError
|
|
176
|
+
def append_text(self, key, content): raise NotImplementedError
|
|
177
|
+
def exists(self, key): raise NotImplementedError
|
|
178
|
+
def list_keys(self, prefix): raise NotImplementedError
|
|
179
|
+
def delete_prefix(self, prefix): raise NotImplementedError
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ── Workspace 메인 클래스 (도메인 API) ───────────────────────────
|
|
183
|
+
|
|
184
|
+
class Workspace:
|
|
185
|
+
"""
|
|
186
|
+
Workspace API — 백엔드 위에 *agent용 도메인 메서드* 제공.
|
|
187
|
+
|
|
188
|
+
Phase A에서는 *저장/조회 기능*만 제공. 실제 멀티턴 loop은 Phase D에서
|
|
189
|
+
이 클래스 사용. Phase B에서는 tool 인터페이스가 이 클래스를 통해
|
|
190
|
+
workspace 파일을 직접 read/write.
|
|
191
|
+
|
|
192
|
+
Usage:
|
|
193
|
+
ws = build_workspace(job_id, config={"backend": "local", "local_path": "./ws"})
|
|
194
|
+
ws.initialize(source_text="기사 원문", meta={"datasources": ["kosis"]})
|
|
195
|
+
ws.create_claim_dir(claim_id, claim_data)
|
|
196
|
+
ws.append_memory(claim_id, "## Iteration 1\\n...")
|
|
197
|
+
memory = ws.read_memory(claim_id)
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(self, job_id: str | UUID, backend: WorkspaceBackend):
|
|
201
|
+
self.job_id = str(job_id)
|
|
202
|
+
self.backend = backend
|
|
203
|
+
self._prefix = f"job_{self.job_id}"
|
|
204
|
+
|
|
205
|
+
# ── 키 생성 헬퍼 ────────────────────────────────────────────
|
|
206
|
+
def _meta_key(self) -> str:
|
|
207
|
+
return f"{self._prefix}/meta.json"
|
|
208
|
+
|
|
209
|
+
def _source_key(self) -> str:
|
|
210
|
+
return f"{self._prefix}/source.txt"
|
|
211
|
+
|
|
212
|
+
def _summary_key(self) -> str:
|
|
213
|
+
return f"{self._prefix}/summary.json"
|
|
214
|
+
|
|
215
|
+
def _claim_dir(self, claim_id: str | UUID) -> str:
|
|
216
|
+
return f"{self._prefix}/claims/{claim_id}"
|
|
217
|
+
|
|
218
|
+
def _claim_file(self, claim_id: str | UUID, name: str) -> str:
|
|
219
|
+
return f"{self._claim_dir(claim_id)}/{name}"
|
|
220
|
+
|
|
221
|
+
def is_initialized(self) -> bool:
|
|
222
|
+
return self.backend.exists(self._meta_key())
|
|
223
|
+
|
|
224
|
+
# ── 초기화 ───────────────────────────────────────────────
|
|
225
|
+
def initialize(self, source_text: str, meta: dict | None = None) -> None:
|
|
226
|
+
"""Job 시작 시 호출. source + meta 저장.
|
|
227
|
+
|
|
228
|
+
[P23 2026-05-22] idempotent하게 변경.
|
|
229
|
+
- meta.json은 *없을 때만* 작성 (created_at 보존)
|
|
230
|
+
- source.txt는 *매번 덮어씀*
|
|
231
|
+
|
|
232
|
+
이유: scope=doc_hash 모드에서 같은 본문이면 같은 워크스페이스 dir 재사용.
|
|
233
|
+
P18 이전 코드(sentence-join 결과를 source.txt에 저장)에서 만든 dir이
|
|
234
|
+
남아있으면, P18에서 raw_text를 박는 새 로직이 *initialize 호출 자체가 안 돼*
|
|
235
|
+
반영 안 됨. 결과: source.txt가 stale → sv_platform이 Job.source_data와
|
|
236
|
+
매칭 못 함 → URL 입력 partial 실시간 노출 실패.
|
|
237
|
+
"""
|
|
238
|
+
meta = dict(meta) if meta else {}
|
|
239
|
+
meta.setdefault("job_id", self.job_id)
|
|
240
|
+
meta.setdefault("created_at", datetime.now(timezone.utc).isoformat())
|
|
241
|
+
# meta는 idempotent — 이미 있으면 새 created_at으로 덮어쓰지 않음
|
|
242
|
+
if not self.backend.exists(self._meta_key()):
|
|
243
|
+
self.backend.write_text(
|
|
244
|
+
self._meta_key(),
|
|
245
|
+
json.dumps(meta, ensure_ascii=False, indent=2, default=str),
|
|
246
|
+
)
|
|
247
|
+
# source.txt는 매번 최신 raw_text로 덮어씀 (stale 방지)
|
|
248
|
+
self.backend.write_text(self._source_key(), source_text)
|
|
249
|
+
logger.info(
|
|
250
|
+
f"[workspace] initialized: job_id={self.job_id} "
|
|
251
|
+
f"(source.txt sync {len(source_text)}자)"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def create_claim_dir(self, claim_id: str | UUID, claim_data: dict) -> None:
|
|
255
|
+
"""claim 작업 디렉토리 생성 + 빈 memory 초기화."""
|
|
256
|
+
cid = str(claim_id)
|
|
257
|
+
self.backend.write_text(
|
|
258
|
+
self._claim_file(cid, "claim.json"),
|
|
259
|
+
json.dumps(claim_data, ensure_ascii=False, indent=2, default=str),
|
|
260
|
+
)
|
|
261
|
+
# memory.md 초기화 (헤더만)
|
|
262
|
+
self.backend.write_text(
|
|
263
|
+
self._claim_file(cid, "memory.md"),
|
|
264
|
+
f"# Memory for claim {cid}\n\n"
|
|
265
|
+
f"Created at: {datetime.now(timezone.utc).isoformat()}\n\n",
|
|
266
|
+
)
|
|
267
|
+
logger.info(f"[workspace] claim dir created: {cid}")
|
|
268
|
+
|
|
269
|
+
# ── Source (원문 기사) ───────────────────────────────────
|
|
270
|
+
def read_source(self) -> str:
|
|
271
|
+
"""원문 기사 전체."""
|
|
272
|
+
return self.backend.read_text(self._source_key())
|
|
273
|
+
|
|
274
|
+
def read_source_span(self, start: int = 0, end: int | None = None) -> str:
|
|
275
|
+
"""원문의 일부 (글자 인덱스). agent가 부분만 읽을 때."""
|
|
276
|
+
text = self.read_source()
|
|
277
|
+
return text[start:end] if end is not None else text[start:]
|
|
278
|
+
|
|
279
|
+
# ── Meta ────────────────────────────────────────────────
|
|
280
|
+
def read_meta(self) -> dict:
|
|
281
|
+
return json.loads(self.backend.read_text(self._meta_key()))
|
|
282
|
+
|
|
283
|
+
# ── Claim ───────────────────────────────────────────────
|
|
284
|
+
def read_claim(self, claim_id: str | UUID) -> dict:
|
|
285
|
+
return json.loads(self.backend.read_text(self._claim_file(str(claim_id), "claim.json")))
|
|
286
|
+
|
|
287
|
+
def list_claims(self) -> list[str]:
|
|
288
|
+
"""모든 claim_id 목록."""
|
|
289
|
+
keys = self.backend.list_keys(f"{self._prefix}/claims")
|
|
290
|
+
cids = set()
|
|
291
|
+
for k in keys:
|
|
292
|
+
# job_xxx/claims/{cid}/...
|
|
293
|
+
parts = k.split("/")
|
|
294
|
+
if len(parts) >= 3 and parts[1] == "claims":
|
|
295
|
+
cids.add(parts[2])
|
|
296
|
+
return sorted(cids)
|
|
297
|
+
|
|
298
|
+
# ── Plan ────────────────────────────────────────────────
|
|
299
|
+
def write_plan(self, claim_id: str | UUID, plan_data: dict) -> None:
|
|
300
|
+
self.backend.write_text(
|
|
301
|
+
self._claim_file(str(claim_id), "plan.json"),
|
|
302
|
+
json.dumps(plan_data, ensure_ascii=False, indent=2, default=str),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
def read_plan(self, claim_id: str | UUID) -> dict | None:
|
|
306
|
+
key = self._claim_file(str(claim_id), "plan.json")
|
|
307
|
+
if not self.backend.exists(key):
|
|
308
|
+
return None
|
|
309
|
+
return json.loads(self.backend.read_text(key))
|
|
310
|
+
|
|
311
|
+
# ── Memory (markdown, append-only) ───────────────────────
|
|
312
|
+
def append_memory(self, claim_id: str | UUID, text: str) -> None:
|
|
313
|
+
"""memory.md에 텍스트 추가. 끝에 줄바꿈 추가됨."""
|
|
314
|
+
if not text.endswith("\n"):
|
|
315
|
+
text += "\n"
|
|
316
|
+
self.backend.append_text(self._claim_file(str(claim_id), "memory.md"), text)
|
|
317
|
+
|
|
318
|
+
def read_memory(self, claim_id: str | UUID) -> str:
|
|
319
|
+
return self.backend.read_text(self._claim_file(str(claim_id), "memory.md"))
|
|
320
|
+
|
|
321
|
+
# ── [v6.21] Verified Facts (job 레벨 공유 메모리) ────────────
|
|
322
|
+
# claim 디렉토리 밖, job 레벨에 둬서 모든 claim이 공유한다.
|
|
323
|
+
# 한 claim에서 검증한 수치를 다음 claim이 재검색 없이 재사용.
|
|
324
|
+
# 예: "올해 출생아 수 20,717명" 검증 후, "작년 대비 8.7% 증가"
|
|
325
|
+
# claim은 올해값을 catalog_search 없이 즉시 가져온다.
|
|
326
|
+
def _facts_key(self) -> str:
|
|
327
|
+
return f"{self._prefix}/verified_facts.json"
|
|
328
|
+
|
|
329
|
+
def _successful_stat_ids_key(self) -> str:
|
|
330
|
+
return f"{self._prefix}/successful_stat_ids.json"
|
|
331
|
+
|
|
332
|
+
# ── [2026-05-26] fetched_values 캐시 (job-level) ──────────────────
|
|
333
|
+
# 같은 (stat_id, indicator, time, population)에 대한 fetch 결과 즉시 저장.
|
|
334
|
+
# verified_facts는 finish 시 저장이라 *같은 claim 안에서 반복 fetch* 시
|
|
335
|
+
# 적중 못 함. fetched_values는 fetch 성공 직후 저장 → 같은 claim의 다음
|
|
336
|
+
# iter도, 같은 job의 다른 claim도 재사용.
|
|
337
|
+
# 키: (stat_id, indicator, time_period, population) 정규화 후 비교.
|
|
338
|
+
def _fetched_values_key(self) -> str:
|
|
339
|
+
return f"{self._prefix}/fetched_values.json"
|
|
340
|
+
|
|
341
|
+
@staticmethod
|
|
342
|
+
def _norm_key_part(s: str | None) -> str:
|
|
343
|
+
"""캐시 키 정규화 — 공백 제거, 소문자, None은 빈 문자열."""
|
|
344
|
+
if s is None:
|
|
345
|
+
return ""
|
|
346
|
+
return str(s).strip().replace(" ", "").lower()
|
|
347
|
+
|
|
348
|
+
def read_fetched_values(self) -> list[dict]:
|
|
349
|
+
"""fetched_values 캐시 전체 read.
|
|
350
|
+
|
|
351
|
+
각 항목: {stat_id, indicator, time_period, population, evidence, recorded_at}
|
|
352
|
+
"""
|
|
353
|
+
key = self._fetched_values_key()
|
|
354
|
+
if not self.backend.exists(key):
|
|
355
|
+
return []
|
|
356
|
+
try:
|
|
357
|
+
return json.loads(self.backend.read_text(key))
|
|
358
|
+
except Exception:
|
|
359
|
+
return []
|
|
360
|
+
|
|
361
|
+
def lookup_fetched_value(
|
|
362
|
+
self,
|
|
363
|
+
stat_id: str,
|
|
364
|
+
indicator: str,
|
|
365
|
+
time_period: str,
|
|
366
|
+
population: str | None = None,
|
|
367
|
+
) -> dict | None:
|
|
368
|
+
"""캐시에서 매칭 evidence 검색. 없으면 None.
|
|
369
|
+
|
|
370
|
+
정규화 비교: 공백/대소문자 무시. population은 빈/None도 매칭.
|
|
371
|
+
"""
|
|
372
|
+
sid_n = self._norm_key_part(stat_id)
|
|
373
|
+
ind_n = self._norm_key_part(indicator)
|
|
374
|
+
tp_n = self._norm_key_part(time_period)
|
|
375
|
+
pop_n = self._norm_key_part(population)
|
|
376
|
+
if not sid_n or not ind_n or not tp_n:
|
|
377
|
+
return None
|
|
378
|
+
for entry in self.read_fetched_values():
|
|
379
|
+
if (self._norm_key_part(entry.get("stat_id")) == sid_n
|
|
380
|
+
and self._norm_key_part(entry.get("indicator")) == ind_n
|
|
381
|
+
and self._norm_key_part(entry.get("time_period")) == tp_n
|
|
382
|
+
and self._norm_key_part(entry.get("population")) == pop_n):
|
|
383
|
+
return entry.get("evidence")
|
|
384
|
+
return None
|
|
385
|
+
|
|
386
|
+
def append_fetched_value(
|
|
387
|
+
self,
|
|
388
|
+
stat_id: str,
|
|
389
|
+
indicator: str,
|
|
390
|
+
time_period: str,
|
|
391
|
+
population: str | None,
|
|
392
|
+
evidence: dict,
|
|
393
|
+
) -> None:
|
|
394
|
+
"""fetch 성공 결과를 캐시에 저장. 같은 키 entry가 있으면 덮어쓰지 않음."""
|
|
395
|
+
if not evidence or evidence.get("value") is None:
|
|
396
|
+
return
|
|
397
|
+
sid_n = self._norm_key_part(stat_id)
|
|
398
|
+
ind_n = self._norm_key_part(indicator)
|
|
399
|
+
tp_n = self._norm_key_part(time_period)
|
|
400
|
+
pop_n = self._norm_key_part(population)
|
|
401
|
+
if not sid_n or not ind_n or not tp_n:
|
|
402
|
+
return
|
|
403
|
+
entries = self.read_fetched_values()
|
|
404
|
+
for e in entries:
|
|
405
|
+
if (self._norm_key_part(e.get("stat_id")) == sid_n
|
|
406
|
+
and self._norm_key_part(e.get("indicator")) == ind_n
|
|
407
|
+
and self._norm_key_part(e.get("time_period")) == tp_n
|
|
408
|
+
and self._norm_key_part(e.get("population")) == pop_n):
|
|
409
|
+
return # 이미 있음
|
|
410
|
+
entries.append({
|
|
411
|
+
"stat_id": stat_id,
|
|
412
|
+
"indicator": indicator,
|
|
413
|
+
"time_period": time_period,
|
|
414
|
+
"population": population or "",
|
|
415
|
+
"evidence": dict(evidence) if hasattr(evidence, "items") else evidence,
|
|
416
|
+
"recorded_at": datetime.now(timezone.utc).isoformat(),
|
|
417
|
+
})
|
|
418
|
+
try:
|
|
419
|
+
self.backend.write_text(
|
|
420
|
+
self._fetched_values_key(),
|
|
421
|
+
json.dumps(entries, ensure_ascii=False, indent=2, default=str),
|
|
422
|
+
)
|
|
423
|
+
logger.info(
|
|
424
|
+
f"[workspace] fetched_value 저장: stat_id={stat_id!r} "
|
|
425
|
+
f"indicator={indicator!r} time={time_period!r} "
|
|
426
|
+
f"population={population!r} value={evidence.get('value')}"
|
|
427
|
+
)
|
|
428
|
+
except Exception as e:
|
|
429
|
+
logger.debug(f"[workspace] fetched_value 저장 실패: {e}")
|
|
430
|
+
|
|
431
|
+
# ── [P20 2026-05-22] KOSIS API raw 응답 캐시 ────────────────────────
|
|
432
|
+
# 같은 (stat_id, prdSe, startPrdDe, endPrdDe, newEstPrdCnt) 조합 호출을
|
|
433
|
+
# workspace-scope로 캐싱. 같은 job 내 다른 sub-claim들이 같은 표를 호출하면
|
|
434
|
+
# 즉시 hit. scope=doc_hash 모드면 같은 본문 재검증 시 전체 KOSIS API call 0건.
|
|
435
|
+
# TTL은 config.kosis.cache_ttl_hours (default 24, 0이면 영구).
|
|
436
|
+
def _kosis_cache_dir(self) -> str:
|
|
437
|
+
return f"{self._prefix}/kosis_cache"
|
|
438
|
+
|
|
439
|
+
@staticmethod
|
|
440
|
+
def make_kosis_cache_key(
|
|
441
|
+
candidate_id: str,
|
|
442
|
+
fetch_params: dict,
|
|
443
|
+
) -> str:
|
|
444
|
+
"""KOSIS fetch 파라미터 → cache key (filesystem-safe).
|
|
445
|
+
|
|
446
|
+
[P34 2026-05-22] dim_overrides (itmId/objL1~objL8)도 key에 포함 — 같은
|
|
447
|
+
stat_id + 같은 시점이라도 *다른 차원 슬라이스*면 응답이 달라지므로
|
|
448
|
+
별도 cache 항목으로 저장해야 함.
|
|
449
|
+
"""
|
|
450
|
+
parts = [
|
|
451
|
+
str(candidate_id or ""),
|
|
452
|
+
str(fetch_params.get("prdSe") or ""),
|
|
453
|
+
str(fetch_params.get("startPrdDe") or ""),
|
|
454
|
+
str(fetch_params.get("endPrdDe") or ""),
|
|
455
|
+
str(fetch_params.get("newEstPrdCnt") or ""),
|
|
456
|
+
]
|
|
457
|
+
# dim_overrides — itmId + objL1~8
|
|
458
|
+
_dim = fetch_params.get("dim_overrides") or {}
|
|
459
|
+
if isinstance(_dim, dict) and _dim:
|
|
460
|
+
parts.append(str(_dim.get("itmId") or ""))
|
|
461
|
+
for _lv in range(1, 9):
|
|
462
|
+
parts.append(str(_dim.get(f"objL{_lv}") or ""))
|
|
463
|
+
# / · 공백 → 언더스코어. KOSIS 표 ID/시점은 일반적으로 안전한 ASCII.
|
|
464
|
+
return "_".join(p.replace("/", "_").replace(" ", "") for p in parts)
|
|
465
|
+
|
|
466
|
+
def read_kosis_response_cache(
|
|
467
|
+
self,
|
|
468
|
+
key: str,
|
|
469
|
+
ttl_hours: float | None = 24,
|
|
470
|
+
) -> dict | None:
|
|
471
|
+
"""KOSIS API 원시 응답 캐시 조회.
|
|
472
|
+
|
|
473
|
+
Args:
|
|
474
|
+
key: make_kosis_cache_key() 결과.
|
|
475
|
+
ttl_hours: TTL. None 또는 0이면 영구 캐시 (만료 안 됨).
|
|
476
|
+
Returns:
|
|
477
|
+
cache hit: 저장된 response dict (EvidenceData로 그대로 reconstruct 가능).
|
|
478
|
+
miss/expired: None.
|
|
479
|
+
"""
|
|
480
|
+
path = f"{self._kosis_cache_dir()}/{key}.json"
|
|
481
|
+
if not self.backend.exists(path):
|
|
482
|
+
return None
|
|
483
|
+
try:
|
|
484
|
+
entry = json.loads(self.backend.read_text(path))
|
|
485
|
+
except Exception as e:
|
|
486
|
+
logger.debug(f"[workspace] kosis_cache 읽기 실패 {key}: {e}")
|
|
487
|
+
return None
|
|
488
|
+
# TTL 만료 검사
|
|
489
|
+
if ttl_hours and ttl_hours > 0:
|
|
490
|
+
cached_at_str = entry.get("cached_at")
|
|
491
|
+
if cached_at_str:
|
|
492
|
+
try:
|
|
493
|
+
cached_at = datetime.fromisoformat(
|
|
494
|
+
cached_at_str.replace("Z", "+00:00")
|
|
495
|
+
)
|
|
496
|
+
age_hours = (
|
|
497
|
+
datetime.now(timezone.utc) - cached_at
|
|
498
|
+
).total_seconds() / 3600.0
|
|
499
|
+
if age_hours > ttl_hours:
|
|
500
|
+
logger.info(
|
|
501
|
+
f"[workspace] kosis_cache 만료 (age={age_hours:.1f}h "
|
|
502
|
+
f"> ttl={ttl_hours}h): {key}"
|
|
503
|
+
)
|
|
504
|
+
return None
|
|
505
|
+
except Exception:
|
|
506
|
+
pass
|
|
507
|
+
return entry.get("response")
|
|
508
|
+
|
|
509
|
+
def write_kosis_response_cache(self, key: str, response: dict) -> None:
|
|
510
|
+
"""KOSIS API 원시 응답 저장 (TTL 검증은 read 시점)."""
|
|
511
|
+
if not key or not isinstance(response, dict):
|
|
512
|
+
return
|
|
513
|
+
path = f"{self._kosis_cache_dir()}/{key}.json"
|
|
514
|
+
entry = {
|
|
515
|
+
"cached_at": datetime.now(timezone.utc).isoformat(),
|
|
516
|
+
"response": response,
|
|
517
|
+
}
|
|
518
|
+
try:
|
|
519
|
+
self.backend.write_text(
|
|
520
|
+
path,
|
|
521
|
+
json.dumps(entry, ensure_ascii=False, indent=2, default=str),
|
|
522
|
+
)
|
|
523
|
+
logger.info(
|
|
524
|
+
f"[workspace] kosis_cache 저장: {key} "
|
|
525
|
+
f"(value={response.get('value')}, rows={len(response.get('rows', []) or [])})"
|
|
526
|
+
)
|
|
527
|
+
except Exception as e:
|
|
528
|
+
logger.debug(f"[workspace] kosis_cache 저장 실패 {key}: {e}")
|
|
529
|
+
|
|
530
|
+
def read_successful_stat_ids(self) -> list[str]:
|
|
531
|
+
"""[패치 A] job에서 fetch_evidence가 성공한 stat_table_id 목록.
|
|
532
|
+
|
|
533
|
+
같은 KOSIS 표(예: DT_1B8000G)에 출생아 수, 합계출산율, 혼인 건수가
|
|
534
|
+
모두 있는데 catalog는 검색어별로 다른 표를 top으로 줘서 같은 표 다른
|
|
535
|
+
row를 못 받는 케이스 대응. 한 claim이 표에서 성공하면 그 stat_id를
|
|
536
|
+
저장해 두고, 다음 claim의 fetch fallback 후보 맨 앞에 prepend한다.
|
|
537
|
+
"""
|
|
538
|
+
key = self._successful_stat_ids_key()
|
|
539
|
+
if not self.backend.exists(key):
|
|
540
|
+
return []
|
|
541
|
+
try:
|
|
542
|
+
data = json.loads(self.backend.read_text(key))
|
|
543
|
+
return data if isinstance(data, list) else []
|
|
544
|
+
except Exception:
|
|
545
|
+
return []
|
|
546
|
+
|
|
547
|
+
def append_successful_stat_id(self, stat_id: str) -> None:
|
|
548
|
+
"""fetch_evidence success 시 stat_id를 job 공유 list에 추가 (중복 X)."""
|
|
549
|
+
sid = (stat_id or "").strip()
|
|
550
|
+
if not sid:
|
|
551
|
+
return
|
|
552
|
+
ids = self.read_successful_stat_ids()
|
|
553
|
+
if sid in ids:
|
|
554
|
+
return
|
|
555
|
+
ids.append(sid)
|
|
556
|
+
self.backend.write_text(
|
|
557
|
+
self._successful_stat_ids_key(),
|
|
558
|
+
json.dumps(ids, ensure_ascii=False, indent=2),
|
|
559
|
+
)
|
|
560
|
+
logger.info(
|
|
561
|
+
f"[workspace] successful_stat_id 저장: {sid!r} "
|
|
562
|
+
f"(누적 {len(ids)}개) — 다음 claim fetch fallback 1순위로 사용"
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# ── [P33b 2026-05-22] failed stat_id blacklist ────────────────────
|
|
566
|
+
# fetch_evidence가 거부/매칭 실패한 stat_id 목록 (per claim).
|
|
567
|
+
# 다음 catalog_search 호출 시 결과에서 제외 → 같은 표 무한 반복 방지.
|
|
568
|
+
# successful_stat_ids는 *job 공유* (claim 간 공유), failed는 *per-claim*
|
|
569
|
+
# (한 claim에서 실패한 표를 다른 claim도 같은 검증인 게 아니라 다르게
|
|
570
|
+
# 시도할 수 있어 공유 X). 단순 list[str] 저장.
|
|
571
|
+
def _failed_stat_ids_key(self, claim_id: str | UUID) -> str:
|
|
572
|
+
return f"{self._claim_dir(claim_id)}/failed_stat_ids.json"
|
|
573
|
+
|
|
574
|
+
def read_failed_stat_ids(self, claim_id: str | UUID) -> list[str]:
|
|
575
|
+
"""이 claim에서 fetch가 실패한 stat_id 목록.
|
|
576
|
+
|
|
577
|
+
catalog_search Tool이 후보를 받은 뒤 이 list에 든 id를 제외하면
|
|
578
|
+
매번 같은 5개 후보를 받는 헛돌이를 차단할 수 있다.
|
|
579
|
+
"""
|
|
580
|
+
key = self._failed_stat_ids_key(claim_id)
|
|
581
|
+
if not self.backend.exists(key):
|
|
582
|
+
return []
|
|
583
|
+
try:
|
|
584
|
+
data = json.loads(self.backend.read_text(key))
|
|
585
|
+
return data if isinstance(data, list) else []
|
|
586
|
+
except Exception:
|
|
587
|
+
return []
|
|
588
|
+
|
|
589
|
+
def append_failed_stat_id(
|
|
590
|
+
self, claim_id: str | UUID, stat_id: str, reason: str = "",
|
|
591
|
+
) -> None:
|
|
592
|
+
"""fetch_evidence 실패(관련성 거부/row 매칭 실패 등) 시 stat_id 기록."""
|
|
593
|
+
sid = (stat_id or "").strip()
|
|
594
|
+
if not sid:
|
|
595
|
+
return
|
|
596
|
+
ids = self.read_failed_stat_ids(claim_id)
|
|
597
|
+
if sid in ids:
|
|
598
|
+
return
|
|
599
|
+
ids.append(sid)
|
|
600
|
+
try:
|
|
601
|
+
self.backend.write_text(
|
|
602
|
+
self._failed_stat_ids_key(claim_id),
|
|
603
|
+
json.dumps(ids, ensure_ascii=False, indent=2),
|
|
604
|
+
)
|
|
605
|
+
logger.info(
|
|
606
|
+
f"[workspace] failed_stat_id 저장: {sid!r} "
|
|
607
|
+
f"(claim={claim_id}, 누적 {len(ids)}개, reason={reason[:80]!r}) "
|
|
608
|
+
f"— 다음 catalog_search에서 제외"
|
|
609
|
+
)
|
|
610
|
+
except Exception as e:
|
|
611
|
+
logger.debug(f"[workspace] failed_stat_id 저장 실패: {e}")
|
|
612
|
+
|
|
613
|
+
def read_verified_facts(self) -> list[dict]:
|
|
614
|
+
"""job에서 지금까지 검증된 사실 목록.
|
|
615
|
+
|
|
616
|
+
각 항목: {indicator, time_period, value, unit, source, claim_id, verdict}
|
|
617
|
+
"""
|
|
618
|
+
key = self._facts_key()
|
|
619
|
+
if not self.backend.exists(key):
|
|
620
|
+
return []
|
|
621
|
+
try:
|
|
622
|
+
return json.loads(self.backend.read_text(key))
|
|
623
|
+
except Exception:
|
|
624
|
+
return []
|
|
625
|
+
|
|
626
|
+
def append_verified_fact(self, fact: dict) -> None:
|
|
627
|
+
"""검증 완료된 수치를 job 공유 저장소에 추가.
|
|
628
|
+
|
|
629
|
+
fact: {indicator, time_period, population, value, unit, source, claim_id, verdict}
|
|
630
|
+
[2026-05-21] population을 dedupe 키에 포함 — 같은 (indicator, time)이라도
|
|
631
|
+
지역별 sub-claim은 *별개 entry*로 저장돼야 함. 안 그러면 강원도(1336),
|
|
632
|
+
서울(12741), 인천(2778) 다 같은 키로 들어가서 첫 번째 값만 살아남고
|
|
633
|
+
나머지가 모두 그 값으로 캐시 적중 (22:54 트레이스 — 서울 sub-claim이
|
|
634
|
+
강원도 값/197 등 다른 지역 값을 받아 잘못된 검증).
|
|
635
|
+
동일 (indicator, time, population) 항목이 이미 있으면 덮어쓰지 않음
|
|
636
|
+
(최초 검증 결과 우선 — 일관성).
|
|
637
|
+
"""
|
|
638
|
+
if not fact or fact.get("value") is None:
|
|
639
|
+
return
|
|
640
|
+
facts = self.read_verified_facts()
|
|
641
|
+
ind = str(fact.get("indicator", "") or "").strip()
|
|
642
|
+
tp = str(fact.get("time_period", "") or "").strip()
|
|
643
|
+
pop = str(fact.get("population", "") or "").strip()
|
|
644
|
+
new_val = fact.get("value")
|
|
645
|
+
for f in facts:
|
|
646
|
+
if (str(f.get("indicator", "") or "").strip() == ind
|
|
647
|
+
and str(f.get("time_period", "") or "").strip() == tp
|
|
648
|
+
and str(f.get("population", "") or "").strip() == pop):
|
|
649
|
+
return # 이미 있음 — 중복 저장 안 함
|
|
650
|
+
|
|
651
|
+
# [P35 2026-05-22] cross-population 값 collision 검출.
|
|
652
|
+
# 같은 (indicator, time)에 *다른 population*의 fact가 이미 있고 값이
|
|
653
|
+
# *완전히 동일*하면, 한쪽 검증이 *잘못 매칭*돼 *다른 지역의 값을 받았을*
|
|
654
|
+
# 가능성. 예: (의료장비 수, 2023, 서울)=12741이 이미 있는데
|
|
655
|
+
# (의료장비 수, 2023, 강원도)=12741이 들어오면 누수 의심 → 저장 거부.
|
|
656
|
+
# 안 그러면 cache hit가 잘못된 값을 다음 trace에 영구 흘려보냄.
|
|
657
|
+
# 값이 진짜 동일한 케이스(드물지만 0/N/A 같은 특수값)는 stale cache의
|
|
658
|
+
# 위험 대비 손실이 적어 거부가 안전.
|
|
659
|
+
if ind and tp and pop and new_val is not None:
|
|
660
|
+
for f in facts:
|
|
661
|
+
if (str(f.get("indicator", "") or "").strip() == ind
|
|
662
|
+
and str(f.get("time_period", "") or "").strip() == tp):
|
|
663
|
+
other_pop = str(f.get("population", "") or "").strip()
|
|
664
|
+
other_val = f.get("value")
|
|
665
|
+
if (other_pop and other_pop != pop
|
|
666
|
+
and other_val is not None and other_val == new_val):
|
|
667
|
+
logger.warning(
|
|
668
|
+
f"[workspace] verified_fact 저장 거부 — *cross-population 값 collision*: "
|
|
669
|
+
f"indicator={ind!r} time={tp!r} 신규 pop={pop!r} value={new_val} "
|
|
670
|
+
f"vs 기존 pop={other_pop!r} value={other_val} "
|
|
671
|
+
f"(다른 지역인데 값이 같음 → 한쪽이 잘못 매칭됐을 가능성 → 저장 안 함)"
|
|
672
|
+
)
|
|
673
|
+
return
|
|
674
|
+
|
|
675
|
+
fact = dict(fact)
|
|
676
|
+
fact.setdefault("recorded_at", datetime.now(timezone.utc).isoformat())
|
|
677
|
+
facts.append(fact)
|
|
678
|
+
self.backend.write_text(
|
|
679
|
+
self._facts_key(),
|
|
680
|
+
json.dumps(facts, ensure_ascii=False, indent=2, default=str),
|
|
681
|
+
)
|
|
682
|
+
logger.info(
|
|
683
|
+
f"[workspace] verified_fact 저장: "
|
|
684
|
+
f"indicator={ind!r} time={tp!r} population={pop!r} value={fact.get('value')}"
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
# ── [S 패치 2026-05-21] sent_id 기반 sibling evidence 공유 ────────
|
|
688
|
+
# schema_inductor가 한 문장(sent_id) → N sub-claim 분기할 때, 같은 sent_id의
|
|
689
|
+
# base/derived sub-claim은 *형제(sibling)* 관계. base가 KOSIS fetch로 얻은
|
|
690
|
+
# evidence를 derived가 *추가 fetch 없이* 재활용하려면 sent_id로 매핑이 필요.
|
|
691
|
+
# 기존 verified_facts는 (indicator, time) 키라 base "출생아 수" → derived
|
|
692
|
+
# "출생아 수 증가율" 매핑이 안 됨. sent_id 기반 별도 캐시.
|
|
693
|
+
def _sibling_evidence_key(self) -> str:
|
|
694
|
+
return f"{self._prefix}/sibling_evidence.json"
|
|
695
|
+
|
|
696
|
+
def record_sibling_evidence(
|
|
697
|
+
self,
|
|
698
|
+
sent_id: str,
|
|
699
|
+
role: str,
|
|
700
|
+
evidence: dict,
|
|
701
|
+
) -> None:
|
|
702
|
+
"""같은 sent_id의 sibling sub-claim들이 활용할 evidence 기록.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
sent_id: claim의 sent_id (예: "b0002_s0000")
|
|
706
|
+
role: value_role ("base" / "derived_rate" / "derived_difference")
|
|
707
|
+
evidence: {indicator, value, unit, time_period, stat_id, claim_id, verdict}
|
|
708
|
+
"""
|
|
709
|
+
sent_id = (sent_id or "").strip()
|
|
710
|
+
if not sent_id or not evidence or evidence.get("value") is None:
|
|
711
|
+
return
|
|
712
|
+
key = self._sibling_evidence_key()
|
|
713
|
+
if self.backend.exists(key):
|
|
714
|
+
try:
|
|
715
|
+
store = json.loads(self.backend.read_text(key))
|
|
716
|
+
if not isinstance(store, dict):
|
|
717
|
+
store = {}
|
|
718
|
+
except Exception:
|
|
719
|
+
store = {}
|
|
720
|
+
else:
|
|
721
|
+
store = {}
|
|
722
|
+
entries = store.setdefault(sent_id, [])
|
|
723
|
+
# 같은 role 중복 저장 방지 (최초 결과 우선)
|
|
724
|
+
if any(e.get("role") == role for e in entries):
|
|
725
|
+
return
|
|
726
|
+
record = dict(evidence)
|
|
727
|
+
record["role"] = role
|
|
728
|
+
record.setdefault("recorded_at", datetime.now(timezone.utc).isoformat())
|
|
729
|
+
entries.append(record)
|
|
730
|
+
self.backend.write_text(
|
|
731
|
+
key,
|
|
732
|
+
json.dumps(store, ensure_ascii=False, indent=2, default=str),
|
|
733
|
+
)
|
|
734
|
+
logger.info(
|
|
735
|
+
f"[workspace] sibling_evidence 저장: sent_id={sent_id!r} "
|
|
736
|
+
f"role={role!r} indicator={evidence.get('indicator')!r} "
|
|
737
|
+
f"value={evidence.get('value')}"
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
def read_sibling_evidence(self, sent_id: str) -> list[dict]:
|
|
741
|
+
"""sent_id의 sibling evidence 목록 (자기 자신 포함, 호출자가 필터).
|
|
742
|
+
|
|
743
|
+
Returns: [{role, indicator, value, unit, time_period, stat_id, ...}, ...]
|
|
744
|
+
"""
|
|
745
|
+
sent_id = (sent_id or "").strip()
|
|
746
|
+
if not sent_id:
|
|
747
|
+
return []
|
|
748
|
+
key = self._sibling_evidence_key()
|
|
749
|
+
if not self.backend.exists(key):
|
|
750
|
+
return []
|
|
751
|
+
try:
|
|
752
|
+
store = json.loads(self.backend.read_text(key))
|
|
753
|
+
if isinstance(store, dict):
|
|
754
|
+
entries = store.get(sent_id) or []
|
|
755
|
+
return entries if isinstance(entries, list) else []
|
|
756
|
+
except Exception:
|
|
757
|
+
pass
|
|
758
|
+
return []
|
|
759
|
+
|
|
760
|
+
def lookup_verified_fact(
|
|
761
|
+
self,
|
|
762
|
+
indicator: str,
|
|
763
|
+
time_period: str,
|
|
764
|
+
unit_hint: str | None = None,
|
|
765
|
+
population: str | None = None,
|
|
766
|
+
) -> dict | None:
|
|
767
|
+
"""(indicator, time_period, population)로 검증된 사실 조회. 없으면 None.
|
|
768
|
+
|
|
769
|
+
[수정 v6.22] 매칭 규칙을 엄격하게 — 잘못된 캐시 재사용 방지.
|
|
770
|
+
[BEFORE 버그] indicator 포함관계('A' in 'A 증가율')만으로 매칭 →
|
|
771
|
+
'출생아 수 증가율'(%) claim이 '출생아 수'(명) 230028을 재사용.
|
|
772
|
+
[2026-05-21] population 매칭 추가 — 같은 (indicator, time)이라도
|
|
773
|
+
다른 지역 sub-claim의 캐시 값이 적중하는 버그 차단.
|
|
774
|
+
population 인자가 주어지면 정확히 일치하는 entry만 반환.
|
|
775
|
+
[AFTER 수정]
|
|
776
|
+
1) indicator + time_period + population 정확 일치 (+ unit 호환)
|
|
777
|
+
2) base indicator(파생 접미사 제거) 일치 + population 일치 + unit 호환
|
|
778
|
+
'증가율'·'차이' 같은 파생 지표는 원지표와 unit이 다르므로
|
|
779
|
+
unit_hint 가드가 자동으로 걸러낸다.
|
|
780
|
+
|
|
781
|
+
unit_hint가 주어지면, 저장된 fact의 unit과 호환되지 않는 항목은
|
|
782
|
+
매칭에서 제외 (% ↔ 명 혼동 차단).
|
|
783
|
+
"""
|
|
784
|
+
ind = str(indicator or "").strip()
|
|
785
|
+
tp = str(time_period or "").strip()
|
|
786
|
+
pop_req = str(population or "").strip() if population else ""
|
|
787
|
+
if not ind or not tp:
|
|
788
|
+
return None
|
|
789
|
+
facts = self.read_verified_facts()
|
|
790
|
+
|
|
791
|
+
def _unit_ok(fact_unit: str) -> bool:
|
|
792
|
+
"""unit_hint와 fact의 unit이 호환되는지. hint 없으면 통과."""
|
|
793
|
+
if not unit_hint:
|
|
794
|
+
return True
|
|
795
|
+
fu = _norm_unit(fact_unit)
|
|
796
|
+
hu = _norm_unit(unit_hint)
|
|
797
|
+
if not fu or not hu:
|
|
798
|
+
return True # 한쪽이라도 비었으면 판별 불가 → 통과
|
|
799
|
+
return fu == hu
|
|
800
|
+
|
|
801
|
+
def _pop_ok(fact_pop: str) -> bool:
|
|
802
|
+
"""population 일치 검사. 요청 pop이 없거나 fact pop이 없으면 통과 (구버전 entry 호환).
|
|
803
|
+
요청 pop이 있고 fact pop이 있으면 양방향 substring 매칭 (강원/강원도, 서울/서울특별시)."""
|
|
804
|
+
if not pop_req:
|
|
805
|
+
return True # 호출자가 population 안 넘기면 기존 동작
|
|
806
|
+
if not fact_pop:
|
|
807
|
+
return False # 요청은 구체적인데 fact는 region 미지정 → 다른 sub-claim 데이터 의심
|
|
808
|
+
fp = fact_pop.strip()
|
|
809
|
+
return pop_req in fp or fp in pop_req
|
|
810
|
+
|
|
811
|
+
# 1차: indicator + time_period + population 정확 일치 (+ unit 호환)
|
|
812
|
+
for f in facts:
|
|
813
|
+
if (str(f.get("indicator", "") or "").strip() == ind
|
|
814
|
+
and str(f.get("time_period", "") or "").strip() == tp):
|
|
815
|
+
if not _pop_ok(str(f.get("population", "") or "")):
|
|
816
|
+
continue
|
|
817
|
+
if _unit_ok(str(f.get("unit", "") or "")):
|
|
818
|
+
return f
|
|
819
|
+
logger.info(
|
|
820
|
+
f"[workspace] verified_fact unit 불일치로 캐시 거부: "
|
|
821
|
+
f"{ind} {tp} (요청 unit={unit_hint}, "
|
|
822
|
+
f"저장 unit={f.get('unit')})"
|
|
823
|
+
)
|
|
824
|
+
return None
|
|
825
|
+
|
|
826
|
+
# 2차: base indicator(파생 접미사 제거) 일치 + population 일치 + unit 호환
|
|
827
|
+
# '증가율'/'차이' 지표는 base가 같아도 unit이 다르므로 _unit_ok가
|
|
828
|
+
# 걸러준다. 즉 안전한 경우(같은 단위·동일 base 지표)만 재사용.
|
|
829
|
+
ind_base = _strip_derived_suffix(ind)
|
|
830
|
+
for f in facts:
|
|
831
|
+
if str(f.get("time_period", "") or "").strip() != tp:
|
|
832
|
+
continue
|
|
833
|
+
if not _pop_ok(str(f.get("population", "") or "")):
|
|
834
|
+
continue
|
|
835
|
+
f_ind = str(f.get("indicator", "") or "").strip()
|
|
836
|
+
f_base = _strip_derived_suffix(f_ind)
|
|
837
|
+
if ind_base and f_base and ind_base == f_base:
|
|
838
|
+
if _unit_ok(str(f.get("unit", "") or "")):
|
|
839
|
+
return f
|
|
840
|
+
return None
|
|
841
|
+
|
|
842
|
+
# ── Log (jsonl, append-only — 구조화) ────────────────────
|
|
843
|
+
def append_log(self, claim_id: str | UUID, entry: dict) -> None:
|
|
844
|
+
"""Action+Observation 한 줄 추가 (JSON line)."""
|
|
845
|
+
entry = dict(entry)
|
|
846
|
+
entry.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
|
|
847
|
+
line = json.dumps(entry, ensure_ascii=False, default=str)
|
|
848
|
+
self.backend.append_text(
|
|
849
|
+
self._claim_file(str(claim_id), "log.jsonl"),
|
|
850
|
+
line + "\n",
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
def read_log(self, claim_id: str | UUID) -> list[dict]:
|
|
854
|
+
key = self._claim_file(str(claim_id), "log.jsonl")
|
|
855
|
+
if not self.backend.exists(key):
|
|
856
|
+
return []
|
|
857
|
+
text = self.backend.read_text(key)
|
|
858
|
+
return [json.loads(line) for line in text.strip().split("\n") if line.strip()]
|
|
859
|
+
|
|
860
|
+
# ── Observation (개별 tool 호출 raw 결과) ──────────────────
|
|
861
|
+
def write_observation(self, claim_id: str | UUID, name: str, data: dict) -> None:
|
|
862
|
+
"""tool 호출 결과를 별도 파일로 (디버깅용 raw)."""
|
|
863
|
+
cid = str(claim_id)
|
|
864
|
+
key = f"{self._claim_dir(cid)}/observations/{name}.json"
|
|
865
|
+
self.backend.write_text(
|
|
866
|
+
key,
|
|
867
|
+
json.dumps(data, ensure_ascii=False, indent=2, default=str),
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
def list_observations(self, claim_id: str | UUID) -> list[str]:
|
|
871
|
+
cid = str(claim_id)
|
|
872
|
+
prefix = f"{self._claim_dir(cid)}/observations"
|
|
873
|
+
keys = self.backend.list_keys(prefix)
|
|
874
|
+
return sorted([k.rsplit("/", 1)[-1] for k in keys])
|
|
875
|
+
|
|
876
|
+
def read_observation(self, claim_id: str | UUID, name: str) -> dict | None:
|
|
877
|
+
cid = str(claim_id)
|
|
878
|
+
key = f"{self._claim_dir(cid)}/observations/{name}"
|
|
879
|
+
if not self.backend.exists(key):
|
|
880
|
+
return None
|
|
881
|
+
try:
|
|
882
|
+
return json.loads(self.backend.read_text(key))
|
|
883
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
884
|
+
logger.debug(f"[workspace] observation 읽기 실패 {name}: {e}")
|
|
885
|
+
return None
|
|
886
|
+
|
|
887
|
+
# ── Data points (모은 수치들) ─────────────────────────────
|
|
888
|
+
def write_data_points(self, claim_id: str | UUID, points: list[dict]) -> None:
|
|
889
|
+
self.backend.write_text(
|
|
890
|
+
self._claim_file(str(claim_id), "data_points.json"),
|
|
891
|
+
json.dumps(points, ensure_ascii=False, indent=2, default=str),
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
def read_data_points(self, claim_id: str | UUID) -> list[dict]:
|
|
895
|
+
key = self._claim_file(str(claim_id), "data_points.json")
|
|
896
|
+
if not self.backend.exists(key):
|
|
897
|
+
return []
|
|
898
|
+
return json.loads(self.backend.read_text(key))
|
|
899
|
+
|
|
900
|
+
# ── Verdict (최종 판정) ──────────────────────────────────
|
|
901
|
+
def write_verdict(self, claim_id: str | UUID, verdict_data: dict) -> None:
|
|
902
|
+
self.backend.write_text(
|
|
903
|
+
self._claim_file(str(claim_id), "verdict.json"),
|
|
904
|
+
json.dumps(verdict_data, ensure_ascii=False, indent=2, default=str),
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
def read_verdict(self, claim_id: str | UUID) -> dict | None:
|
|
908
|
+
key = self._claim_file(str(claim_id), "verdict.json")
|
|
909
|
+
if not self.backend.exists(key):
|
|
910
|
+
return None
|
|
911
|
+
return json.loads(self.backend.read_text(key))
|
|
912
|
+
|
|
913
|
+
# ── Summary (모든 claim 종합) ─────────────────────────────
|
|
914
|
+
def write_summary(self, summary_data: dict) -> None:
|
|
915
|
+
self.backend.write_text(
|
|
916
|
+
self._summary_key(),
|
|
917
|
+
json.dumps(summary_data, ensure_ascii=False, indent=2, default=str),
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# ── Cleanup ──────────────────────────────────────────────
|
|
921
|
+
def cleanup(self) -> None:
|
|
922
|
+
"""이 job의 모든 파일 삭제 (config.agent.workspace.persist_after_job=false 시)."""
|
|
923
|
+
self.backend.delete_prefix(self._prefix)
|
|
924
|
+
logger.info(f"[workspace] cleaned up: job_id={self.job_id}")
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# ── Factory ─────────────────────────────────────────────────────
|
|
928
|
+
|
|
929
|
+
def build_workspace(
|
|
930
|
+
job_id: str | UUID,
|
|
931
|
+
config: dict | None = None,
|
|
932
|
+
) -> Workspace:
|
|
933
|
+
"""
|
|
934
|
+
Config에서 workspace 인스턴스 생성.
|
|
935
|
+
|
|
936
|
+
config 예 (config/default.yaml의 agent.workspace 섹션):
|
|
937
|
+
backend: "local"
|
|
938
|
+
local_path: "./agent_workspace"
|
|
939
|
+
|
|
940
|
+
Args:
|
|
941
|
+
job_id: 검증 job 식별자.
|
|
942
|
+
config: agent.workspace 섹션 dict. None이면 default.
|
|
943
|
+
"""
|
|
944
|
+
config = config or {}
|
|
945
|
+
backend_type = config.get("backend", "local")
|
|
946
|
+
|
|
947
|
+
if backend_type == "local":
|
|
948
|
+
path = config.get("local_path", "./agent_workspace")
|
|
949
|
+
backend = LocalWorkspaceBackend(path)
|
|
950
|
+
elif backend_type in ("minio", "s3"):
|
|
951
|
+
backend = MinIOWorkspaceBackend(config)
|
|
952
|
+
else:
|
|
953
|
+
raise ValueError(
|
|
954
|
+
f"Unknown workspace backend: {backend_type!r}. "
|
|
955
|
+
f"지원: 'local' (현재), 'minio'/'s3' (Phase F 예정)"
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
return Workspace(job_id=job_id, backend=backend)
|