structverify 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. structverify/__init__.py +83 -0
  2. structverify/adaptation/__init__.py +0 -0
  3. structverify/adaptation/adapter_trainer.py +341 -0
  4. structverify/adaptation/feedback_store.py +31 -0
  5. structverify/adaptation/kosis_crawler.py +317 -0
  6. structverify/adaptation/sample_builder.py +149 -0
  7. structverify/adaptation/synthetic_generator.py +320 -0
  8. structverify/adaptation/update_embeddings.py +178 -0
  9. structverify/agent/__init__.py +21 -0
  10. structverify/agent/builder_agent.py +226 -0
  11. structverify/agent/conformance_agent.py +171 -0
  12. structverify/agent/dependency_planner.py +151 -0
  13. structverify/agent/indexing_agent.py +153 -0
  14. structverify/agent/indexing_planner.py +169 -0
  15. structverify/agent/integration_example.py +182 -0
  16. structverify/agent/loop.py +1165 -0
  17. structverify/agent/memory.py +207 -0
  18. structverify/agent/planner.py +817 -0
  19. structverify/agent/prompts/__init__.py +15 -0
  20. structverify/agent/prompts/planner_prompts.py +219 -0
  21. structverify/agent/prompts/reflect_prompts.py +387 -0
  22. structverify/agent/reflect.py +227 -0
  23. structverify/agent/runtime_agent.py +1272 -0
  24. structverify/agent/schemas.py +262 -0
  25. structverify/agent/source_profiler.py +229 -0
  26. structverify/agent/tools/__init__.py +64 -0
  27. structverify/agent/tools/base.py +222 -0
  28. structverify/agent/tools/calculate.py +244 -0
  29. structverify/agent/tools/catalog_search.py +859 -0
  30. structverify/agent/tools/deep_explore.py +293 -0
  31. structverify/agent/tools/explore_catalog.py +423 -0
  32. structverify/agent/tools/fetch_evidence.py +922 -0
  33. structverify/agent/tools/finish.py +423 -0
  34. structverify/agent/tools/meta_explore.py +267 -0
  35. structverify/agent/tools/query_rewriter.py +134 -0
  36. structverify/agent/tools/read_original.py +144 -0
  37. structverify/agent/tools/replan.py +365 -0
  38. structverify/agent/workspace.py +958 -0
  39. structverify/api.py +804 -0
  40. structverify/config/default.yaml +350 -0
  41. structverify/core/__init__.py +0 -0
  42. structverify/core/config_loader.py +30 -0
  43. structverify/core/pipeline.py +280 -0
  44. structverify/core/schemas.py +362 -0
  45. structverify/detection/__init__.py +26 -0
  46. structverify/detection/_config.py +163 -0
  47. structverify/detection/_llm.py +24 -0
  48. structverify/detection/candidate/__init__.py +1 -0
  49. structverify/detection/candidate/heuristic.py +60 -0
  50. structverify/detection/candidate/llm.py +51 -0
  51. structverify/detection/candidate_scorer.py +81 -0
  52. structverify/detection/claim_detector.py +164 -0
  53. structverify/detection/claims/__init__.py +1 -0
  54. structverify/detection/claims/worthiness.py +142 -0
  55. structverify/detection/domain/__init__.py +1 -0
  56. structverify/detection/domain/classify.py +84 -0
  57. structverify/detection/domain/preview.py +36 -0
  58. structverify/detection/domain/registry.py +99 -0
  59. structverify/detection/domain_classifier.py +75 -0
  60. structverify/detection/prompts/__init__.py +1 -0
  61. structverify/detection/prompts/candidate.py +38 -0
  62. structverify/detection/prompts/claim_worthiness.py +48 -0
  63. structverify/detection/prompts/domain.py +41 -0
  64. structverify/detection/prompts/schema.py +508 -0
  65. structverify/detection/prompts_loader.py +167 -0
  66. structverify/detection/schema/__init__.py +1 -0
  67. structverify/detection/schema/expand.py +83 -0
  68. structverify/detection/schema/induce.py +441 -0
  69. structverify/detection/schema/regenerate.py +162 -0
  70. structverify/detection/schema/temporal_hints.py +130 -0
  71. structverify/detection/schema/validate.py +193 -0
  72. structverify/detection/schema_inductor.py +112 -0
  73. structverify/detection/synthetic_generator.py +270 -0
  74. structverify/explanation/__init__.py +0 -0
  75. structverify/explanation/_config.py +18 -0
  76. structverify/explanation/_llm.py +25 -0
  77. structverify/explanation/explainer.py +183 -0
  78. structverify/explanation/fallback.py +29 -0
  79. structverify/explanation/formatters.py +75 -0
  80. structverify/explanation/prompts/__init__.py +1 -0
  81. structverify/explanation/prompts/match.py +27 -0
  82. structverify/explanation/prompts/mismatch.py +20 -0
  83. structverify/explanation/prompts/multihop.py +16 -0
  84. structverify/explanation/prompts/unverifiable.py +17 -0
  85. structverify/graph/__init__.py +0 -0
  86. structverify/graph/claim_graph.py +226 -0
  87. structverify/graph/document_graph.py +487 -0
  88. structverify/graph/graph_builder.py +238 -0
  89. structverify/graph/graph_multihop.py +335 -0
  90. structverify/graph/graph_store.py +281 -0
  91. structverify/graph/provenance.py +52 -0
  92. structverify/memory/__init__.py +44 -0
  93. structverify/memory/agent_memory.py +142 -0
  94. structverify/memory/embedder.py +69 -0
  95. structverify/memory/exemplar_store.py +241 -0
  96. structverify/memory/normalizer.py +91 -0
  97. structverify/memory/schema.py +119 -0
  98. structverify/memory/storage/__init__.py +29 -0
  99. structverify/memory/storage/jsonl_store.py +117 -0
  100. structverify/memory/working_memory.py +370 -0
  101. structverify/preprocessing/Dockerfile.scraper +27 -0
  102. structverify/preprocessing/__init__.py +0 -0
  103. structverify/preprocessing/extractor.py +574 -0
  104. structverify/preprocessing/pdf/__init__.py +16 -0
  105. structverify/preprocessing/pdf/fields.py +95 -0
  106. structverify/preprocessing/pdf/markdown.py +107 -0
  107. structverify/preprocessing/pdf/models.py +34 -0
  108. structverify/preprocessing/pdf/ocr.py +172 -0
  109. structverify/preprocessing/pdf/pipeline.py +74 -0
  110. structverify/preprocessing/pdf/reader.py +119 -0
  111. structverify/preprocessing/pdf/scoring.py +61 -0
  112. structverify/preprocessing/scraper_sandbox.py +561 -0
  113. structverify/preprocessing/segmenter.py +48 -0
  114. structverify/preprocessing/sir_builder.py +240 -0
  115. structverify/progress.py +591 -0
  116. structverify/retrieval/__init__.py +0 -0
  117. structverify/retrieval/base.py +208 -0
  118. structverify/retrieval/base_connector.py +85 -0
  119. structverify/retrieval/catalog_ranker.py +300 -0
  120. structverify/retrieval/catalog_search.py +583 -0
  121. structverify/retrieval/chunking.py +92 -0
  122. structverify/retrieval/custom_csv_source.py +386 -0
  123. structverify/retrieval/custom_db_source.py +396 -0
  124. structverify/retrieval/custom_docs_source.py +152 -0
  125. structverify/retrieval/dimension_resolver.py +281 -0
  126. structverify/retrieval/evidence_subgraph.py +63 -0
  127. structverify/retrieval/kosis_connector.py +1192 -0
  128. structverify/retrieval/kosis_relevance.py +142 -0
  129. structverify/retrieval/kosis_source.py +1541 -0
  130. structverify/retrieval/query_builder.py +72 -0
  131. structverify/retrieval/registry.py +133 -0
  132. structverify/retrieval/relevance_judge.py +141 -0
  133. structverify/retrieval/row_matcher.py +267 -0
  134. structverify/storage/__init__.py +0 -0
  135. structverify/storage/db_manager.py +157 -0
  136. structverify/storage/dwh_manager.py +92 -0
  137. structverify/storage/init_db.py +99 -0
  138. structverify/storage/raw_storage.py +29 -0
  139. structverify/training/__init__.py +26 -0
  140. structverify/training/curator.py +124 -0
  141. structverify/training/dataset.py +134 -0
  142. structverify/training/doctor.py +99 -0
  143. structverify/training/evalgate.py +96 -0
  144. structverify/training/generate.py +101 -0
  145. structverify/training/loop.py +116 -0
  146. structverify/training/recipe/train_mlx.py +99 -0
  147. structverify/training/recipe/train_qlora.py +104 -0
  148. structverify/training/tasks.py +79 -0
  149. structverify/utils/__init__.py +0 -0
  150. structverify/utils/embedding_client.py +248 -0
  151. structverify/utils/llm_client.py +809 -0
  152. structverify/utils/logger.py +81 -0
  153. structverify/verification/__init__.py +0 -0
  154. structverify/verification/_config.py +45 -0
  155. structverify/verification/adapters.py +405 -0
  156. structverify/verification/conformance.py +117 -0
  157. structverify/verification/decide_verdict.py +216 -0
  158. structverify/verification/decide_verdict_agent.py +454 -0
  159. structverify/verification/growth_diff.py +267 -0
  160. structverify/verification/row_match.py +345 -0
  161. structverify/verification/units.py +64 -0
  162. structverify/verification/verdict_thresholds.py +232 -0
  163. structverify/verification/verifier.py +84 -0
  164. structverify-0.3.0.dist-info/METADATA +903 -0
  165. structverify-0.3.0.dist-info/RECORD +168 -0
  166. structverify-0.3.0.dist-info/WHEEL +5 -0
  167. structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
  168. structverify-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1272 @@
1
+ """
2
+ agent/runtime_agent.py — Runtime Verification Agent (Agent A)
3
+
4
+ 실시간 검증 요청을 처리하는 메인 Agent. ReAct 패턴 기반.
5
+ Thought → Action(Tool Call) → Observation 순환을 통해 파이프라인 제어.
6
+
7
+ [김예슬 - 2026-04-22]
8
+ - Step 3~9 오케스트레이션 전체 담당
9
+ - ReAct 패턴으로 각 스텝을 Action으로 정의하고 순차 실행
10
+
11
+ [김예슬 - 2026-04-23]
12
+ - classify_domain() 반환값 튜플 대응: str → (domain, domain_desc)
13
+ - config["detected_domain"] → self.config["detected_domain"] 버그 수정
14
+ - domain_desc를 self.config에 저장하여 schema_inductor 힌트로 활용
15
+
16
+ [김예슬 - 2026-04-24]
17
+ - induce_schemas Action 설명 업데이트:
18
+ · 기존: HCX-003 generate_json() → JSON 파싱 (실패 가능)
19
+ · 변경: HCX-007 Structured Outputs → JSON Schema 보장 (파싱 실패 없음)
20
+ - Action별 사용 모델/API 업데이트:
21
+ · classify_domain → HCX-DASH-002 (v3 API)
22
+ · score_candidate → HCX-DASH-002 (v3 API)
23
+ · check_worthiness → HCX-003 (v1 API)
24
+ · induce_schemas → HCX-007 Structured Outputs (v3 API)
25
+ · generate_explain → HCX-003 (v1 API)
26
+
27
+ [ReAct 패턴 설명]
28
+ LLM이 단순히 답변을 생성하는 것이 아니라, 매 스텝마다 다음을 반복합니다:
29
+ Thought : "현재 상태에서 무엇을 해야 하는가?" (LLM 내부 추론)
30
+ Action : 구체적인 도구(함수) 호출
31
+ Observation: 도구 호출 결과를 관찰하고 다음 Thought 수행
32
+
33
+ Action → 사용 모델/API 매핑:
34
+ classify_domain → HCX-DASH-002 (v3, 경량)
35
+ score_candidate → HCX-DASH-002 (v3, 경량)
36
+ check_worthiness → HCX-003 (v1, 중량)
37
+ induce_schemas → HCX-007 Structured Outputs (v3, JSON 보장)
38
+ build_graph → 내부 로직 (LLM 미사용)
39
+ retrieve_evidence → KOSIS Open API (LLM 미사용)
40
+ verify_claim → Deterministic 수치 비교 (LLM 미사용)
41
+ generate_explain → HCX-003 (v1, 중량)
42
+
43
+ [참고] ReAct (Yao et al., ICLR 2023) — https://github.com/ysymyth/ReAct
44
+ """
45
+ from __future__ import annotations
46
+
47
+ from datetime import datetime, timezone
48
+
49
+ from structverify.core.schemas import (
50
+ Claim, SIRDocument, VerificationResult, GraphNode, GraphEdge, Evidence,
51
+ GraphNodeType, GraphEdgeType,
52
+ )
53
+
54
+
55
+ def _cap_text(s: str, max_chars: int) -> str:
56
+ """긴 텍스트를 head/tail로 잘라 안전한 크기로 반환.
57
+
58
+ LLM prompt가 수십 KB 넘는 경우가 흔해 raw 저장 시 디스크 폭발 방지.
59
+ 중간을 잘라 head 70% + truncation 표시 + tail 30%로 유지 (디버깅엔
60
+ 양끝이 가장 유용).
61
+ """
62
+ if not s or len(s) <= max_chars:
63
+ return s or ""
64
+ head_len = int(max_chars * 0.7)
65
+ tail_len = max_chars - head_len - 80
66
+ if tail_len <= 0:
67
+ return s[:max_chars]
68
+ return (
69
+ s[:head_len]
70
+ + f"\n\n... [{len(s) - max_chars}자 truncated] ...\n\n"
71
+ + s[-tail_len:]
72
+ )
73
+ from structverify.detection.domain_classifier import classify_domain
74
+ from structverify.detection.claim_detector import detect_claims
75
+ from structverify.detection.schema_inductor import induce_schemas
76
+ from structverify.graph.graph_builder import build_claim_graph
77
+ from structverify.graph.graph_multihop import apply_multihop_verification
78
+ from structverify.graph.document_graph import build_document_temporal_graph
79
+ from structverify.graph.claim_graph import ClaimGraph
80
+ from structverify.retrieval.query_builder import build_query
81
+ from structverify.retrieval.evidence_subgraph import build_evidence_subgraph
82
+ from structverify.retrieval.kosis_connector import KOSISConnector
83
+ from structverify.verification.verifier import verify_claim
84
+ from structverify.explanation.explainer import generate_explanation
85
+ from structverify.memory import DocumentWorkingMemory # [머지 이수민 main]
86
+ from structverify.utils.logger import get_logger
87
+
88
+
89
+ def _claims_from_all_sentences(sir_doc: "SIRDocument") -> list["Claim"]:
90
+ """[2026-05-27 oracle mode] detect_claims 우회 — 모든 문장을 Claim 객체로.
91
+
92
+ FEVER/SciFact 스타일의 oracle setting (claim_text를 직접 input으로 받음)
93
+ 평가 시 사용. config.eval.bypass_detection=true일 때만 호출됨.
94
+ """
95
+ out: list[Claim] = []
96
+ for block in (sir_doc.blocks or []):
97
+ for sent in (block.sentences or []):
98
+ txt = (sent.text or "").strip()
99
+ if not txt:
100
+ continue
101
+ try:
102
+ out.append(Claim(
103
+ doc_id=sir_doc.doc_id,
104
+ block_id=block.block_id,
105
+ sent_id=sent.sent_id,
106
+ claim_text=txt,
107
+ check_worthy_score=1.0, # oracle이므로 만점
108
+ ))
109
+ except Exception:
110
+ continue
111
+ return out
112
+
113
+ logger = get_logger(__name__)
114
+
115
+
116
+ class RuntimeAgent:
117
+ """
118
+ Agent A: 실시간 검증 처리.
119
+ Step 3~9를 순차 실행하며 ReAct 패턴으로 파이프라인을 오케스트레이션한다.
120
+ """
121
+
122
+ def __init__(self, config: dict | None = None):
123
+ self.config = config or {}
124
+ # [v3 김예슬] kosis config에 llm 포함 → CatalogSearchTool LLM Agent가 llm 설정 사용
125
+ kosis_cfg = {
126
+ **self.config.get("kosis", {}),
127
+ "llm": self.config.get("llm", {}),
128
+ # [#67-D A-2] config.embedding 을 CatalogSearchTool까지 전달 (provider 선택)
129
+ "embedding": self.config.get("embedding", {}),
130
+ }
131
+ self.kosis = KOSISConnector(config=kosis_cfg)
132
+
133
+ # [v6.19] Graph Store 초기화 (Neo4j — 옵셔널)
134
+ # default.yaml graph.store.enabled=true 일 때만 실제 연결.
135
+ # 미설정/미설치/연결실패 시 GraphStore 내부에서 안전하게 비활성화됨.
136
+ from structverify.graph.graph_store import GraphStore
137
+ graph_store_cfg = (self.config.get("graph") or {}).get("store") or {}
138
+ self.graph_store = GraphStore(config=graph_store_cfg)
139
+
140
+ def _build_datasources(self) -> dict:
141
+ """config.data_sources.enabled 기반 {name: DataSource} 구성 (#66).
142
+
143
+ enabled 미설정이면 ["kosis"] (동작 보존). 소스별 config는 data_sources.<name>.
144
+ kosis는 embedding 주입 유지(#67-D A-2). custom_csv 등록 트리거 import 포함.
145
+ """
146
+ import structverify.retrieval.kosis_source # noqa: F401 — @register_datasource
147
+ import structverify.retrieval.custom_csv_source # noqa: F401 — @register_datasource
148
+ import structverify.retrieval.custom_db_source # noqa: F401 — @register_datasource
149
+ from structverify.retrieval.registry import build_all_enabled
150
+
151
+ ds_cfg = self.config.get("data_sources") or {}
152
+ enabled = list(ds_cfg.get("enabled", ["kosis"]))
153
+
154
+ kosis_ds_cfg = dict(ds_cfg.get("kosis") or self.config.get("kosis") or {})
155
+ kosis_ds_cfg.setdefault("embedding", self.config.get("embedding", {}))
156
+
157
+ build_cfg = {"enabled": enabled}
158
+ for name in enabled:
159
+ build_cfg[name] = kosis_ds_cfg if name == "kosis" else (ds_cfg.get(name) or {})
160
+
161
+ return {ds.name: ds for ds in build_all_enabled(build_cfg)}
162
+
163
+ async def _ensure_source_profile(self) -> None:
164
+ """연결된 custom 소스(custom_db/csv/docs)를 프로파일링해 config에 주입한다.
165
+
166
+ 탐지·스키마가 'KOSIS 통계인가?' 대신 '이 소스로 검증되나?'를 기준으로 삼게 한다.
167
+ 공공소스(kosis)만 연결됐으면 스킵 → 기존 동작 보존.
168
+ """
169
+ if self.config.get("_source_profile"):
170
+ return
171
+ ds_cfg = self.config.get("data_sources", {}) or {}
172
+ enabled = list(ds_cfg.get("enabled", []) or [])
173
+ custom = [n for n in enabled if n in ("custom_db", "custom_csv", "custom_docs")]
174
+ if not custom:
175
+ return
176
+ try:
177
+ from structverify.agent.source_profiler import profile_source
178
+ datasources = self._build_datasources()
179
+ name = custom[0]
180
+ ds = datasources.get(name)
181
+ if ds is None:
182
+ return
183
+ profile = await profile_source(ds, name, self.config)
184
+ if not profile.is_empty():
185
+ self.config["_source_profile"] = profile.to_dict()
186
+ logger.info(
187
+ f"[Agent A] 소스 프로파일 주입: {name} — "
188
+ f"domain={profile.domain!r}, 지표 {len(profile.indicators)}개"
189
+ )
190
+ except Exception as e: # noqa: BLE001 — 프로파일 실패는 조용히(기존 탐지로 폴백)
191
+ logger.warning(f"[source-profiler] 프로파일 생성 실패: {e}")
192
+
193
+ async def process(self, sir_doc: SIRDocument) -> tuple[
194
+ list[Claim], list[VerificationResult], list[GraphNode], list[GraphEdge]
195
+ ]:
196
+ """
197
+ SIR 문서 → 전체 검증 파이프라인 실행 (Step 3~9).
198
+
199
+ Returns:
200
+ (claims, results, graph_nodes, graph_edges)
201
+ """
202
+
203
+ # ── [머지: main의 DocumentWorkingMemory] ──────────────────────
204
+ # 이 doc 처리 동안만 살아있는 in-memory 컨텍스트.
205
+ # v2의 verified_facts(검증값 캐시)와 역할이 다름:
206
+ # - memory : doc 단위 — 도메인 가드 / stat_id 캐시 / claim 인덱스
207
+ # - verified_facts : claim 간 검증값 재사용 (agent loop 내부)
208
+ # 둘은 충돌하지 않으며 상호 보완적이다.
209
+ from uuid import uuid4
210
+ memory = DocumentWorkingMemory(
211
+ doc_id=str(sir_doc.doc_id),
212
+ run_id=str(uuid4())[:8],
213
+ source_uri=getattr(sir_doc, "source_uri", None),
214
+ )
215
+
216
+ # ── Action: classify_domain ──────────────────────────────────
217
+ # Tool: HCX-DASH-002 (v3 API, 경량)
218
+ # Thought: "이 문서의 도메인이 무엇인가?"
219
+ # Observation: domain 문자열 + 설명
220
+ import structverify.progress as _progress
221
+ domain, domain_desc = await classify_domain(sir_doc, self.config)
222
+ self.config["detected_domain"] = domain
223
+ self.config["detected_domain_desc"] = domain_desc
224
+ memory.record_domain(domain, domain_desc) # [머지 이수민 main]
225
+ logger.info(f"[Agent A] Step 3 classify_domain → {domain} ({domain_desc})")
226
+ _progress.emit("step", stage="domain", name=f"도메인 분류 → {domain}", pct=12)
227
+
228
+ # ── [source-aware] 연결된 custom 소스를 프로파일링해 탐지 기준을 소스에 맞춤 ──
229
+ # KOSIS 등 공공소스만이면 스킵(기존 동작 유지). custom_db/csv/docs면 프로파일 주입.
230
+ _progress.emit("step", stage="profile", name="소스 프로파일링…", pct=22)
231
+ await self._ensure_source_profile()
232
+
233
+ # ── Action: detect_claims ────────────────────────────────────
234
+ # [4-1] candidate_scorer: HCX-DASH-002 (v3, 경량) → 0~1 점수
235
+ # [4-2] claim_detector: HCX-003 (v1, 중량) → check-worthiness
236
+ # Thought: "검증 가능한 주장 문장을 찾아야 한다"
237
+ # Observation: Claim 객체 리스트
238
+ # TODO [김예슬]: domain-packs 기반 도메인별 few-shot 예시 주입
239
+ #
240
+ # [2026-05-27] Oracle mode (evaluation용) — config.eval.bypass_detection=true이면
241
+ # detect_claims LLM 필터링을 건너뛰고 *모든 문장*을 claim으로 변환. FEVER/SciFact
242
+ # style의 oracle claim setting에 사용. 일반 검증 흐름엔 영향 X (config 기본 false).
243
+ _bypass_det = bool(
244
+ (self.config.get("eval", {}) or {}).get("bypass_detection", False)
245
+ )
246
+ if _bypass_det:
247
+ claims = _claims_from_all_sentences(sir_doc)
248
+ logger.info(
249
+ f"[Agent A] Step 4 detect_claims (BYPASSED — oracle mode) → "
250
+ f"{len(claims)}건 (모든 문장 claim 변환)"
251
+ )
252
+ else:
253
+ claims = await detect_claims(sir_doc, self.config)
254
+ logger.info(f"[Agent A] Step 4 detect_claims → {len(claims)}건")
255
+
256
+ _progress.emit(
257
+ "claims", count=len(claims),
258
+ claims=[
259
+ {"id": str(c.claim_id), "text": (c.claim_text or "")[:140]}
260
+ for c in claims
261
+ ],
262
+ )
263
+
264
+ if not claims:
265
+ logger.info("[Agent A] 검증 가능한 주장 없음 — 파이프라인 종료")
266
+ _progress.emit("done", count=0)
267
+ return [], [], [], []
268
+
269
+ # ── [v4 김예슬] Context Window 부착 ────────────────────────────
270
+ # 각 claim에 앞뒤 문장 context를 붙여서 LLM이 맥락을 이해할 수 있게 함
271
+ # 예: "이는 20년 새 2.6배 증가한 것이다" → 앞 문장 "쉬었음 청년이 21만7천명"도 함께 전달
272
+ # schema_inductor + query_builder에서 context_text 활용
273
+ for claim in claims:
274
+ claim.context_text = _get_context_window(claim, sir_doc, window=2)
275
+
276
+ # ── Action: build_document_temporal_graph ─────────────────────
277
+ # Tool: HCX 문서 시간 분석 agent (LLM 1회 호출)
278
+ # Thought: "기사 작성일/anchor_year를 추출해서 '내년/올해' 같은
279
+ # 상대 시점을 절대 연도로 풀 수 있게 해야 한다"
280
+ # Observation: DocumentNode(anchor_year=...) + TemporalExpr 노드
281
+ # [v6.16] 이 단계가 빠져 있어서 anchor_year가 항상 None이던 버그 수정
282
+ temporal_graph = None
283
+ try:
284
+ t_nodes, t_edges = await build_document_temporal_graph(
285
+ sir_doc, self.config
286
+ )
287
+ if t_nodes:
288
+ temporal_graph = ClaimGraph(t_nodes, t_edges)
289
+ # [머지 이수민 main] anchor_year + 시간 표현을 memory에 기록
290
+ for _n in t_nodes:
291
+ _nt = getattr(getattr(_n, "node_type", None), "value", "")
292
+ if _nt == "document":
293
+ _ay = (_n.properties or {}).get("anchor_year")
294
+ if _ay:
295
+ try:
296
+ memory.record_anchor_year(int(_ay))
297
+ except (ValueError, TypeError):
298
+ pass
299
+ elif _nt == "temporal_expr":
300
+ _expr = (_n.properties or {}).get("expression")
301
+ _resolved = (_n.properties or {}).get("resolved_value")
302
+ if _expr and _resolved:
303
+ memory.record_temporal(str(_expr), str(_resolved))
304
+ logger.info(
305
+ f"[Agent A] Step 4.5 temporal graph → "
306
+ f"anchor_year={temporal_graph.get_anchor_year()}"
307
+ )
308
+ except Exception as e:
309
+ logger.warning(f"[Agent A] temporal graph 빌드 실패 (계속 진행): {e}")
310
+
311
+ # ── Action: induce_schemas ───────────────────────────────────
312
+ # Tool: HCX-007 Structured Outputs (v3 API)
313
+ # Thought: "각 주장을 indicator/value/unit/population으로 구조화해야 한다"
314
+ # Observation: claim.schema = ClaimSchema({indicator, value, ...})
315
+ # [v4] context_text 포함 → "이는" 같은 대명사 참조 해소
316
+ # [v6.16] temporal_graph 전달 → 상대 시점 해소 + anchor_year fallback
317
+ claims = await induce_schemas(claims, self.config, graph=temporal_graph)
318
+ memory.record_claims(claims) # [머지 이수민 main] metric_to_claims 인덱싱
319
+ logger.info(
320
+ f"[Agent A] Step 5 induce_schemas → schemas attached "
321
+ f"(memory: {len(memory.metric_to_claims)} metrics)"
322
+ )
323
+
324
+ # ── Action: build_claim_graph ────────────────────────────────
325
+ # Tool: 내부 로직 (LLM 미사용)
326
+ # Thought: "ClaimSchema → Knowledge Graph 노드/엣지를 구성해야 한다"
327
+ # Observation: GraphNode[], GraphEdge[]
328
+ # TODO [신준수]: graph_builder.py 노드/엣지 타입 완성
329
+ all_nodes, all_edges = build_claim_graph(claims, sir_doc=sir_doc) # 호출부 로직 변경 [pipeline v3] 김예슬
330
+ logger.info(f"[Agent A] Step 6 build_claim_graph → {len(all_nodes)} nodes")
331
+
332
+ # ── Step 7~8: 각 주장별 Evidence 조회 + 검증 ────────────────
333
+ # [Multi-hop v1] Step 9(설명)는 multi-hop 재검증 후로 분리
334
+ # 이유: 파생 주장 검증은 다른 claim들의 Step 8 결과가 모두 필요함
335
+ # [Phase D] config.agent.enabled=true 면 planner+loop 경로 사용:
336
+ # - planner가 claim마다 Plan 수립 (ReAct: Thought)
337
+ # - agent_loop가 Plan대로 catalog_search → fetch_evidence → verify 순회
338
+ # 기존 경로(고정 retrieve→verify)는 enabled=false 시 그대로 사용 → 안전 롤백
339
+ # [머지 박재윤 main] asyncio.gather + Semaphore(3) 병렬화.
340
+ # claim끼리 독립적이므로 agent 경로(planner+loop)도 병렬 안전.
341
+ # claim 1건당 14~40초 → 8건 직렬이면 ~7분. 병렬 3이면 ~1/3.
342
+ agent_enabled = bool(
343
+ (self.config.get("agent") or {}).get("enabled", False)
344
+ )
345
+
346
+ # agent 경로에서 쓸 문서 원문 (planner가 source_text로 사용 + workspace의
347
+ # source.txt 저장).
348
+ # [2026-05-21] sir_doc.raw_text(P10에서 추가, 원본 markdown/줄바꿈 포함)
349
+ # 우선. 없으면 _get_source_text(sir_doc)가 sentence들을 공백 join한 결과.
350
+ # 이유: workspace.initialize가 source.txt에 이 값을 저장하는데, sv_platform이
351
+ # /v1/jobs 폴링 시 Job.source_data(=raw_text)와 source.txt를 _normalize_ws
352
+ # 비교로 매칭. sentence join은 markdown 단락/리스트 구조 손실로 URL 추출본과
353
+ # 형태가 달라 매칭 실패 → 프론트 실시간 partial claim 안 뜸. text 입력은
354
+ # 단순해서 우연히 비슷했을 뿐.
355
+ source_text = (
356
+ getattr(sir_doc, "raw_text", None)
357
+ or self._get_source_text(sir_doc)
358
+ )
359
+ anchor_year = (
360
+ temporal_graph.get_anchor_year() if temporal_graph else None
361
+ )
362
+
363
+ import asyncio
364
+ sem = asyncio.Semaphore(3)
365
+ # 병렬 claim들이 memory에 동시 기록 → race 방지용 Lock.
366
+ # DocumentWorkingMemory의 record_* 는 dict 갱신이라 짧지만,
367
+ # record_stat_id_used 같은 복합 갱신을 원자적으로 보호한다.
368
+ mem_lock = asyncio.Lock()
369
+
370
+ async def process_one_claim(claim):
371
+ """claim 1건 Step 7~8. 그래프 노드/엣지는 반환만 하고
372
+ 병렬 종료 후 메인이 모은다 (extend가 thread-safe하지 않으므로)."""
373
+ claim_nid = f"claim:{claim.claim_id.hex[:8]}"
374
+ async with sem:
375
+ if agent_enabled:
376
+ # ── [Phase D] Agent Loop 경로 ──────────────────────
377
+ result, ev_nodes, ev_edges = await self._verify_with_agent(
378
+ claim, source_text, anchor_year, temporal_graph,
379
+ claim_nid=claim_nid, memory=memory, mem_lock=mem_lock,
380
+ )
381
+ logger.info(
382
+ f"[Agent A] Step 7~8 agent_loop → {result.verdict.value} "
383
+ f"(evidence nodes={len(ev_nodes)})"
384
+ )
385
+ return result, ev_nodes, ev_edges
386
+
387
+ # ── 기존 경로 (고정 retrieve_evidence + verify_claim) ──
388
+ query = build_query(claim)
389
+ evidence, ev_nodes, ev_edges = await build_evidence_subgraph(
390
+ self.kosis, query, claim_nid,
391
+ )
392
+ logger.info(
393
+ f"[Agent A] Step 7 retrieve_evidence → "
394
+ f"{str(evidence)[:80] if evidence else None}"
395
+ )
396
+ # Action: verify_claim (Deterministic, LLM 미개입)
397
+ # 주의: sv2 verify_claim은 memory 파라미터를 받지 않음.
398
+ # 도메인 가드는 호출 후 evidence를 보고 별도로 적용한다.
399
+ result = verify_claim(
400
+ claim, evidence, self.config, graph=temporal_graph,
401
+ )
402
+ # [머지 이수민 main] 도메인 가드 — evidence가 doc 도메인과
403
+ # 어긋나면 UNVERIFIABLE 강등. evidence.raw_response 또는
404
+ # stat 메타에서 category_path를 찾는다(없으면 가드 통과).
405
+ _ev_cat = None
406
+ if evidence is not None:
407
+ _ev_cat = (evidence.raw_response or {}).get("category_path")
408
+ if (_ev_cat and not memory.domain_matches_category(_ev_cat)):
409
+ from structverify.core.schemas import VerdictType
410
+ logger.warning(
411
+ f"[Agent A] 도메인 가드 거절: category={_ev_cat!r} "
412
+ f"vs domain={memory.domain!r} → UNVERIFIABLE"
413
+ )
414
+ async with mem_lock:
415
+ memory.record_stat_id_rejected(
416
+ str(getattr(evidence, "stat_table_id", "") or "?"),
417
+ f"도메인 불일치: {memory.domain}",
418
+ )
419
+ result.verdict = VerdictType.UNVERIFIABLE
420
+ result.confidence = min(result.confidence or 0.3, 0.3)
421
+ # [머지 이수민 main] 성공 stat_id를 memory에 캐시
422
+ if (result.verdict.value == "match"
423
+ and evidence and getattr(evidence, "stat_table_id", None)
424
+ and claim.schema and claim.schema.indicator):
425
+ async with mem_lock:
426
+ memory.record_stat_id_used(
427
+ indicator=claim.schema.indicator,
428
+ stat_id=evidence.stat_table_id,
429
+ category_path=_ev_cat,
430
+ time_period=getattr(evidence, "time_period", None),
431
+ )
432
+ logger.info(f"[Agent A] Step 8 verify_claim → {result.verdict.value}")
433
+ return result, ev_nodes, ev_edges
434
+
435
+ # ── [Dependency Planning 2026-05-21] level 기반 실행 ──
436
+ # 한 문장에서 분기된 base/derived sub-claim, 또는 같은 indicator를
437
+ # 공유하는 claim들을 *순차 레벨*로 묶어 evidence 재활용.
438
+ # Level 1 (병렬): base claims
439
+ # Level 2 (병렬): derived_rate / derived_difference claims
440
+ # Level 간 verified_facts / successful_stat_ids 캐시가 살아 있어 derived가
441
+ # base의 fetch 결과를 자동 재활용. 같은 level 안에선 기존대로 Semaphore(3)
442
+ # 병렬 유지.
443
+ from structverify.agent.dependency_planner import build_execution_levels
444
+ _exec_levels = build_execution_levels(claims)
445
+ logger.info(
446
+ f"[Agent A] dependency planning: {len(_exec_levels)} levels, "
447
+ f"sizes={[len(lvl) for lvl in _exec_levels]}"
448
+ )
449
+
450
+ # claim_id → 결과 매핑 (원래 claim 순서대로 정렬 위해)
451
+ _results_by_id: dict[Any, tuple] = {}
452
+ _claim_disp_idx = {c.claim_id: i + 1 for i, c in enumerate(claims)} # 탐지 순서 표시 index
453
+
454
+ async def _run_and_emit(_c):
455
+ # claim 컨텍스트를 이 태스크에 심는다 → 처리 중 발생하는 모든 로그가
456
+ # 이 claim에 태깅됨(병렬로 섞여도 정확히 분류). 각 claim이 끝나는 순간
457
+ # 결과 이벤트를 쏴 카드를 실시간 갱신.
458
+ _idx = _claim_disp_idx.get(_c.claim_id, 0)
459
+ _cid = str(_c.claim_id)
460
+ _tok = _progress.claim_var.set({"id": _cid, "idx": _idx})
461
+ try:
462
+ _out = await process_one_claim(_c)
463
+ finally:
464
+ _progress.claim_var.reset(_tok)
465
+ _r = _out[0]
466
+ _progress.emit(
467
+ "claim",
468
+ index=_idx,
469
+ claim_id=_cid,
470
+ claim=(_c.claim_text or ""),
471
+ verdict=getattr(getattr(_r, "verdict", None), "value", "") or "",
472
+ value=getattr(_r, "fetched_value", None),
473
+ unit=getattr(_r, "unit", "") or "",
474
+ )
475
+ return _out
476
+
477
+ for _lvl_idx, _level_claims in enumerate(_exec_levels):
478
+ if not _level_claims:
479
+ continue
480
+ logger.info(
481
+ f"[Agent A] Level {_lvl_idx + 1}/{len(_exec_levels)}: "
482
+ f"{len(_level_claims)}개 claim 병렬 시작"
483
+ )
484
+ _parallel = await asyncio.gather(
485
+ *[_run_and_emit(c) for c in _level_claims]
486
+ )
487
+ for _c, _out in zip(_level_claims, _parallel):
488
+ _results_by_id[_c.claim_id] = _out
489
+ logger.info(
490
+ f"[Agent A] Level {_lvl_idx + 1} 완료 — verified_facts/"
491
+ f"successful_stat_ids 캐시가 다음 level로 전파됨"
492
+ )
493
+
494
+ # ── [2026-05-25 패치 X] derived prev_time prefetch ──
495
+ # Level 1(base) 끝난 후 Level 2+(derived)가 필요로 하는 prev_time_period
496
+ # 시점들을 *시스템이 미리 fetch*해 verified_facts에 저장. derived loop이
497
+ # 그 자리에서 캐시 적중하므로 재fetch 안 함.
498
+ #
499
+ # 배경: A 패치(reflect prompt에서 absolute claim 시 prev 시점 fetch 금지)로
500
+ # base claim이 prev 시점을 fetch하지 않게 됐는데, 그러면 derived가 또 직접
501
+ # fetch해야 함. 이전엔 LLM 헛돌이로 우연히 prev도 fetch되어 cache 풍부했음.
502
+ # 이제 *시스템이 명시적으로* prev 시점만 미리 잡아 둠.
503
+ try:
504
+ await self._prefetch_derived_prev_times(
505
+ base_claims=_level_claims,
506
+ future_levels=_exec_levels[_lvl_idx + 1:],
507
+ workspace=workspace,
508
+ memory=memory,
509
+ )
510
+ except Exception as _e:
511
+ logger.debug(f"[Agent A] derived prev prefetch 실패 (무시): {_e}")
512
+
513
+ # 원래 claim 순서대로 정렬 (results 인덱스 보존)
514
+ results: list[VerificationResult] = []
515
+ for _c in claims:
516
+ _result, _ev_nodes, _ev_edges = _results_by_id[_c.claim_id]
517
+ results.append(_result)
518
+ all_nodes.extend(_ev_nodes)
519
+ all_edges.extend(_ev_edges)
520
+
521
+ # [머지 이수민 main] working memory 통계 로깅
522
+ logger.info(f"[Agent A] working_memory stats: {memory.stats()}")
523
+ if memory.rejected_stat_ids:
524
+ logger.info(
525
+ f"[Agent A] 도메인 가드 거절 stat_id: "
526
+ f"{len(memory.rejected_stat_ids)}건"
527
+ )
528
+
529
+ # ── Step 8.5: Multi-hop GraphRAG 파생 주장 재검증 ──────────────
530
+ # Tool: graph_multihop (LLM 미사용 — 비율/배수 계산은 deterministic)
531
+ # Thought: "KOSIS로 직접 검증 못한 파생 주장(2.6배 등)을
532
+ # COMPARE 엣지 이웃들의 검증된 수치로 재계산할 수 있다"
533
+ # Observation: UNVERIFIABLE 파생 주장 → MATCH/MISMATCH 가능
534
+ results = apply_multihop_verification(
535
+ claims, results, all_edges, self.config
536
+ )
537
+
538
+ # ── Step 9: 각 주장별 설명 생성 ────────────────────────────────
539
+ for claim, result in zip(claims, results):
540
+ result.explanation = await generate_explanation(claim, result, self.config)
541
+ logger.info(f"[Agent A] Step 9 generate_explanation → {len(result.explanation or '')}자")
542
+
543
+ # ── [v6.19] Step 9.5: Graph Store 영속화 (Neo4j — 옵셔널) ──────
544
+ # graph.store.enabled=true 면 노드/엣지를 Neo4j에 MERGE.
545
+ # 비활성/실패해도 save_graph 내부에서 흡수 — 검증 결과는 그대로 반환.
546
+ if self.graph_store.is_active():
547
+ try:
548
+ n_saved, e_saved = await self.graph_store.save_graph(
549
+ all_nodes, all_edges
550
+ )
551
+ logger.info(
552
+ f"[Agent A] Step 9.5 graph_store → "
553
+ f"Neo4j 저장 노드 {n_saved} / 엣지 {e_saved}"
554
+ )
555
+ except Exception as e:
556
+ logger.warning(f"[Agent A] graph_store 저장 실패 (무시): {e}")
557
+
558
+ logger.info(f"[Agent A] 완료: claims={len(claims)}, results={len(results)}, "
559
+ f"nodes={len(all_nodes)}, edges={len(all_edges)}")
560
+
561
+ # [v6.19] job 완료 후 agent_workspace 임시 디렉토리 정리.
562
+ # workspace.cleanup()은 정의돼 있었지만 어디서도 호출되지 않아
563
+ # job마다 agent_workspace/job_* 디렉토리가 무한 누적 →
564
+ # "No space left on device"로 agent_loop 전체가 폴백되는 장애 발생.
565
+ # agent.workspace.persist_after_job=true면 디버깅 위해 보존.
566
+ agent_cfg = self.config.get("agent") or {}
567
+ ws_cfg = dict(agent_cfg.get("workspace") or {})
568
+ if not ws_cfg.get("persist_after_job", False):
569
+ try:
570
+ from structverify.agent.workspace import build_workspace
571
+ # _verify_with_agent와 동일하게 claim.doc_id를 job_id로 사용
572
+ _job_id = ""
573
+ if claims:
574
+ _job_id = str(getattr(claims[0], "doc_id", "") or "")
575
+ _ws = build_workspace(job_id=_job_id or "job", config=ws_cfg)
576
+ _ws.cleanup()
577
+ except Exception as e:
578
+ logger.warning(f"[Agent A] workspace 정리 실패 (무시): {e}")
579
+
580
+ return claims, results, all_nodes, all_edges
581
+
582
+ # ── [Phase D] Agent Loop 경로 헬퍼 ──────────────────────────────────────
583
+
584
+ async def _prefetch_derived_prev_times(
585
+ self,
586
+ base_claims: list,
587
+ future_levels: list[list],
588
+ workspace: Any,
589
+ memory: Any,
590
+ ) -> None:
591
+ """[2026-05-25 패치 X] base 레벨이 끝난 후, 후속 derived 레벨의
592
+ prev_time_period 시점들을 시스템이 미리 fetch해 verified_facts에 저장.
593
+
594
+ 목적: derived(증가율/차이) claim의 loop이 prev 시점을 캐시에서 적중하도록.
595
+ 이전엔 LLM 헛돌이로 base 처리 중 prev도 fetch됐었지만, A 패치 이후
596
+ base는 자기 시점만 정확히 fetch 함. 그래서 derived가 prev 재fetch
597
+ 필요해진 부작용을 시스템이 명시적으로 보완.
598
+
599
+ 흐름:
600
+ 1) future_levels의 모든 derived claim에서 (indicator_base, prev_time_period,
601
+ population, unit) 추출 (indicator는 derived suffix strip).
602
+ 2) workspace.lookup_verified_fact로 이미 있는지 확인.
603
+ 3) 없으면 base claim의 successful_stat_id를 1순위로 KOSISDataSource.fetch_evidence
604
+ 직접 호출. 결과를 workspace.append_verified_fact + sibling_evidence 저장.
605
+ """
606
+ if not future_levels:
607
+ return
608
+ from structverify.agent.workspace import _strip_derived_suffix
609
+
610
+ # 1) prefetch 대상 수집 — 중복 제거
611
+ _targets: dict[tuple[str, str, str, str], dict] = {}
612
+ for _next_lvl in future_levels:
613
+ for _dc in _next_lvl:
614
+ _sch = getattr(_dc, "schema", None)
615
+ if _sch is None:
616
+ continue
617
+ _ind = (getattr(_sch, "indicator", "") or "").strip()
618
+ _prev_t = (getattr(_sch, "prev_time_period", "") or "").strip()
619
+ if not _ind or not _prev_t:
620
+ continue
621
+ _pop = (getattr(_sch, "population", "") or "").strip()
622
+ _unit = (getattr(_sch, "unit", "") or "").strip()
623
+ _ind_base = _strip_derived_suffix(_ind)
624
+ _key = (_ind_base, _prev_t, _pop, _unit)
625
+ if _key not in _targets:
626
+ _targets[_key] = {
627
+ "indicator": _ind_base,
628
+ "time_period": _prev_t,
629
+ "population": _pop,
630
+ "unit": _unit,
631
+ "sent_id": str(getattr(_dc, "sent_id", "") or ""),
632
+ }
633
+
634
+ if not _targets:
635
+ return
636
+
637
+ # 2) 이미 캐시된 건 skip
638
+ _to_fetch: list[dict] = []
639
+ for _t in _targets.values():
640
+ try:
641
+ _hit = workspace.lookup_verified_fact(
642
+ _t["indicator"], _t["time_period"],
643
+ unit_hint=_t["unit"] or None,
644
+ population=_t["population"] or None,
645
+ )
646
+ except Exception:
647
+ _hit = None
648
+ if _hit is None:
649
+ _to_fetch.append(_t)
650
+
651
+ if not _to_fetch:
652
+ logger.info(
653
+ f"[Agent A] derived prev prefetch: {len(_targets)}개 시점 — "
654
+ f"모두 이미 verified_facts에 있음. skip."
655
+ )
656
+ return
657
+
658
+ logger.info(
659
+ f"[Agent A] derived prev prefetch 시작: {len(_to_fetch)}개 (indicator, prev_time) "
660
+ f"미리 fetch (예: {_to_fetch[0]['indicator']!r} {_to_fetch[0]['time_period']!r})"
661
+ )
662
+
663
+ # 3) base의 successful stat_id 1순위로 fetch
664
+ try:
665
+ _prior_stat_ids = workspace.read_successful_stat_ids() or []
666
+ except Exception:
667
+ _prior_stat_ids = []
668
+ if not _prior_stat_ids:
669
+ logger.info(
670
+ f"[Agent A] derived prev prefetch skip: base에서 successful_stat_id 없음."
671
+ )
672
+ return
673
+
674
+ _stat_id = _prior_stat_ids[0]
675
+
676
+ # KOSISDataSource를 *직접* 호출 (loop tool wrap 없이) — verified_facts 저장만 목적
677
+ _kosis_source = None
678
+ try:
679
+ from structverify.retrieval.registry import build_datasource
680
+ import structverify.retrieval.kosis_source # noqa: F401 — @register_datasource 트리거
681
+ _ds_cfg = (self.config.get("data_sources") or {}).get("kosis", {}) or {}
682
+ _kosis_source = build_datasource("kosis", config=_ds_cfg)
683
+ except Exception as _e:
684
+ logger.info(f"[Agent A] derived prev prefetch skip: KOSISDataSource 인스턴스 실패 — {_e}")
685
+ return
686
+ if _kosis_source is None:
687
+ return
688
+
689
+ for _t in _to_fetch:
690
+ _params = {
691
+ "indicator": _t["indicator"],
692
+ "time_period": _t["time_period"],
693
+ "population": _t["population"] or None,
694
+ "unit_hint": _t["unit"] or None,
695
+ }
696
+ try:
697
+ _ev = await _kosis_source.fetch_evidence(
698
+ candidate_id=_stat_id, params=_params, workspace=workspace,
699
+ )
700
+ except Exception as _e:
701
+ logger.debug(
702
+ f"[Agent A] derived prev prefetch fetch 실패: {_t} stat={_stat_id} — {_e}"
703
+ )
704
+ continue
705
+ if _ev is None:
706
+ continue
707
+ _val = _ev.get("value") if hasattr(_ev, "get") else getattr(_ev, "value", None)
708
+ if _val is None:
709
+ continue
710
+
711
+ # verified_facts에 저장
712
+ _fact = {
713
+ "indicator": _t["indicator"],
714
+ "time_period": _t["time_period"],
715
+ "population": _t["population"] or "",
716
+ "value": _val,
717
+ "unit": (
718
+ _ev.get("unit") if hasattr(_ev, "get") else getattr(_ev, "unit", "")
719
+ ) or "",
720
+ "source": f"KOSIS:{_stat_id}",
721
+ "claim_id": "prefetch",
722
+ "verdict": "prefetch",
723
+ }
724
+ try:
725
+ workspace.append_verified_fact(_fact)
726
+ # sibling_evidence에도 같은 sent_id로 박아 두면 derived loop이 즉시 활용
727
+ if _t["sent_id"]:
728
+ workspace.record_sibling_evidence(
729
+ sent_id=_t["sent_id"], role="base", evidence=_fact,
730
+ )
731
+ logger.info(
732
+ f"[Agent A] derived prev prefetch 성공: "
733
+ f"indicator={_t['indicator']!r} time={_t['time_period']!r} "
734
+ f"value={_val} (stat_id={_stat_id})"
735
+ )
736
+ except Exception as _e:
737
+ logger.debug(f"[Agent A] derived prev prefetch verified_fact 저장 실패: {_e}")
738
+
739
+ def _get_source_text(self, sir_doc: "SIRDocument") -> str:
740
+ """SIR 문서에서 원문 텍스트 복원 — planner의 source_text로 사용."""
741
+ parts: list[str] = []
742
+ for block in getattr(sir_doc, "blocks", []) or []:
743
+ for sent in getattr(block, "sentences", []) or []:
744
+ t = getattr(sent, "text", None)
745
+ if t:
746
+ parts.append(t)
747
+ return " ".join(parts)
748
+
749
+ async def _verify_with_agent(
750
+ self,
751
+ claim: "Claim",
752
+ source_text: str,
753
+ anchor_year: "int | None",
754
+ temporal_graph: "ClaimGraph | None",
755
+ claim_nid: str,
756
+ memory: "DocumentWorkingMemory | None" = None,
757
+ mem_lock=None,
758
+ ) -> "tuple[VerificationResult, list[GraphNode], list[GraphEdge]]":
759
+ """
760
+ [Phase D] planner + agent_loop 으로 claim 1건 검증.
761
+
762
+ ReAct:
763
+ Thought → planner.plan() 이 Plan(검증 전략 + 단계) 수립
764
+ Action → agent_loop 이 catalog_search → fetch_evidence 순회
765
+ Observation → 각 step 결과 누적
766
+ → AgentVerdict 산출 → VerificationResult 변환
767
+
768
+ Returns:
769
+ (VerificationResult, evidence GraphNode 리스트, GraphEdge 리스트)
770
+ — [v6.19] evidence를 그래프에 박아 multihop 재검증이 agent 경로에서도
771
+ 동작하게 함. 검증 실패해도 노드는 빈 리스트로 반환(에러 아님).
772
+
773
+ 실패 시 기존 경로(retrieve_evidence + verify_claim)로 폴백.
774
+ """
775
+ from structverify.agent.planner import Planner, PlannerConfig
776
+ from structverify.agent.loop import agent_loop, LoopConfig
777
+ from structverify.agent.reflect import ReflectAgent, ReflectConfig
778
+ from structverify.agent.workspace import build_workspace
779
+
780
+ agent_cfg = self.config.get("agent") or {}
781
+ llm_cfg = self.config.get("llm") or {}
782
+
783
+ try:
784
+ # 1) workspace 준비
785
+ # [2026-05-21] scope에 따라 workspace 격리 단위 결정:
786
+ # - "doc_hash" (default): claim.doc_id (= md5(raw_text))
787
+ # 같은 본문이면 캐시 공유. KOSIS fetch 재사용으로 빠름.
788
+ # - "job_id" : ws_cfg.external_job_id (sv_platform이 set) 또는 fresh UUID.
789
+ # 매 요청 cold start로 정확성↑.
790
+ ws_cfg = dict(agent_cfg.get("workspace") or {})
791
+ _ws_scope = str(ws_cfg.get("scope") or "doc_hash").strip().lower()
792
+ if _ws_scope == "job_id":
793
+ _external = ws_cfg.get("external_job_id")
794
+ if _external:
795
+ _ws_job_id = str(_external)
796
+ else:
797
+ from uuid import uuid4 as _uuid4
798
+ _ws_job_id = str(_uuid4())
799
+ logger.info(
800
+ f"[Agent A] workspace scope='job_id'인데 external_job_id 미제공 "
801
+ f"→ fresh UUID 생성: {_ws_job_id}"
802
+ )
803
+ else:
804
+ # "doc_hash" — 기존 동작 (text-hash 기반 캐시 재사용)
805
+ _ws_job_id = str(getattr(claim, "doc_id", "") or "job")
806
+ workspace = build_workspace(
807
+ job_id=_ws_job_id,
808
+ config=ws_cfg,
809
+ )
810
+ # [P23 2026-05-22] is_initialized 체크 제거 — initialize가 idempotent.
811
+ # source.txt를 매번 raw_text로 덮어씀 (stale 방지). meta는 변경 없음.
812
+ workspace.initialize(source_text=source_text or "")
813
+ workspace.create_claim_dir(
814
+ claim.claim_id, claim_data=claim.model_dump(mode="json")
815
+ )
816
+
817
+ # 2) DataSource 등록 — config.data_sources.enabled 기반 (#66)
818
+ datasources = self._build_datasources()
819
+
820
+ # 3) Planner LLM wiring — 기존 LLMClient 재사용
821
+ from structverify.utils.llm_client import LLMClient
822
+ plan_llm = LLMClient(config=llm_cfg)
823
+
824
+ # [2026-05-27] LLM 원본 입출력을 workspace에 영구 저장 → 프론트가
825
+ # "AI 콘솔" 탭에서 lazy fetch. 한 LLM 호출당 1 JSON 파일.
826
+ # 사이즈 안전망: prompt/response 각각 60KB로 head/tail cap.
827
+ def _save_llm_trace(name: str, prompt: str, response: str) -> None:
828
+ try:
829
+ payload = {
830
+ "name": name,
831
+ "ts": datetime.now(timezone.utc).isoformat(),
832
+ "prompt": _cap_text(prompt, 60_000),
833
+ "response": _cap_text(response, 60_000),
834
+ "prompt_chars": len(prompt or ""),
835
+ "response_chars": len(response or ""),
836
+ }
837
+ workspace.write_observation(
838
+ claim.claim_id,
839
+ name=f"llm_traces/{name}",
840
+ data=payload,
841
+ )
842
+ except Exception as _e:
843
+ logger.debug(f"[runtime_agent] llm_trace 저장 실패 ({name}): {_e}")
844
+
845
+ async def llm_call_for_plan(prompt: str) -> str:
846
+ _resp = await plan_llm.generate(
847
+ prompt=prompt,
848
+ system_prompt="검증 계획 수립 전문가. JSON으로만 답하세요.",
849
+ )
850
+ _save_llm_trace("planner", prompt, _resp or "")
851
+ return _resp
852
+
853
+ planner = Planner(
854
+ llm_call=llm_call_for_plan,
855
+ config=PlannerConfig(
856
+ model=(llm_cfg.get("plan_model") or "HCX-007"),
857
+ temperature=0.1,
858
+ ),
859
+ )
860
+
861
+ # 4) Plan 생성 (ReAct Thought)
862
+ plan = await planner.plan(
863
+ claim, source_text=source_text, anchor_year=anchor_year
864
+ )
865
+ workspace.write_plan(claim.claim_id, plan.model_dump(mode="json"))
866
+ logger.info(
867
+ f"[planner] {claim.claim_id}: Plan 수립 "
868
+ f"type={getattr(plan, 'claim_type', None)} "
869
+ f"steps={len(getattr(plan, 'initial_steps', []) or [])}"
870
+ )
871
+
872
+ # 5) Agent Loop 실행 (ReAct Action/Observation)
873
+ loop_cfg = agent_cfg.get("loop") or {}
874
+ loop_mode = str(loop_cfg.get("mode", "deterministic")).strip()
875
+ max_iter = int(loop_cfg.get("max_iterations", 10))
876
+
877
+ # [reflect 활성화] mode='reflect'면 ReflectAgent를 loop에 주입.
878
+ # 매 iter LLM이 last_observation을 보고 다음 action을 동적
879
+ # 결정(catalog 결과 부적합 시 검색어 바꿔 재검색, 원문 재독 등).
880
+ # ReflectAgent는 파싱 실패 시 None을 반환하고, loop은 그 경우
881
+ # plan의 다음 step으로 deterministic fallback → 안전.
882
+ # mode='deterministic'이면 reflect_fn=None (기존 동작 유지).
883
+ reflect_fn = None
884
+ if loop_mode == "reflect":
885
+ # iter별로 파일명이 겹치지 않게 counter — Reflect는 매 iter 호출됨.
886
+ _reflect_call_counter = {"n": 0}
887
+
888
+ async def llm_call_for_reflect(prompt: str) -> str:
889
+ _reflect_call_counter["n"] += 1
890
+ _n = _reflect_call_counter["n"]
891
+ _resp = await plan_llm.generate(
892
+ prompt=prompt,
893
+ system_prompt=(
894
+ "당신은 사실검증 ReAct 에이전트입니다. "
895
+ "지금까지의 관찰 결과를 보고 다음 action을 "
896
+ "JSON으로만 답하세요."
897
+ ),
898
+ )
899
+ _save_llm_trace(f"reflect_call_{_n:02d}", prompt, _resp or "")
900
+ return _resp
901
+
902
+ reflect_fn = ReflectAgent(
903
+ llm_call=llm_call_for_reflect,
904
+ claim=claim,
905
+ config=ReflectConfig(),
906
+ max_iterations=max_iter,
907
+ )
908
+ logger.info(
909
+ f"[planner] {claim.claim_id}: reflect 모드 활성화 "
910
+ f"(max_iter={max_iter}) — 매 iter LLM 재계획"
911
+ )
912
+
913
+ verdict = await agent_loop(
914
+ plan=plan,
915
+ claim=claim,
916
+ workspace=workspace,
917
+ datasources=datasources,
918
+ config=self.config,
919
+ reflect_fn=reflect_fn,
920
+ loop_config=LoopConfig(
921
+ max_iterations=max_iter,
922
+ mode=loop_mode,
923
+ ),
924
+ )
925
+
926
+ # 6) AgentVerdict → VerificationResult 변환
927
+ # [v6.17] agent_loop이 검증에 쓴 KOSIS 데이터(data_points)를
928
+ # Evidence로 복원 → UI '공식 통계 출처' 박스에 표시됨.
929
+ # [2026-05-21 P7] primary/supporting 분리:
930
+ # - primary: claim.schema.time_period와 매칭되는 시점의 fetch
931
+ # - supporting: derived claim에서 함께 쓰인 *다른 시점* fetch
932
+ # (예: 차이/증가율 검증의 prev 시점)
933
+ # base claim은 supporting 비움 (헛돌이 prev fetch 노이즈 제거).
934
+ agent_evidence = None
935
+ supporting_evidence: list[Evidence] = []
936
+ _ev_category = None # 도메인 가드용 — Evidence 스키마엔 없는 필드
937
+
938
+ def _parse_stat_id(src: str | None) -> str | None:
939
+ if not src:
940
+ return None
941
+ s = str(src).strip()
942
+ if ":" in s:
943
+ return s.split(":", 1)[1].strip() or None
944
+ return s or None
945
+
946
+ def _norm_time(t: str | None) -> str:
947
+ return str(t or "").replace("-", "").replace(".", "").strip()
948
+
949
+ # ── 1) 후보 evidence 수집 (data_points + workspace fetch obs + sibling_evidence) ──
950
+ _claim_target_time = (
951
+ getattr(claim.schema, "time_period", None) if claim.schema else None
952
+ )
953
+ _claim_role = (
954
+ getattr(claim.schema, "value_role", None) if claim.schema else None
955
+ ) or ""
956
+ _is_derived = _claim_role in ("derived_rate", "derived_difference")
957
+ _claim_sent_id = str(getattr(claim, "sent_id", "") or "").strip()
958
+
959
+ candidates: list[dict] = []
960
+ dps = getattr(verdict, "data_points", None) or []
961
+ for _dp in dps:
962
+ _val = getattr(_dp, "resolved_value", None)
963
+ if _val is None:
964
+ continue
965
+ _sid = _parse_stat_id(getattr(_dp, "source", None))
966
+ _t = (getattr(_dp, "source_time", None) or getattr(_dp, "time", "") or "")
967
+ candidates.append({
968
+ "stat_id": _sid,
969
+ "value": _val,
970
+ "unit": getattr(_dp, "resolved_unit", None),
971
+ "time": str(_t),
972
+ "category": getattr(_dp, "category_path", None),
973
+ "origin": "data_point",
974
+ })
975
+
976
+ # workspace의 모든 fetch observation에서 evidence 수집 (헛돌이 fetch도 포함됨)
977
+ try:
978
+ for _obs_name in workspace.list_observations(claim.claim_id):
979
+ if "fetch" not in _obs_name.lower():
980
+ continue
981
+ _obs = workspace.read_observation(claim.claim_id, _obs_name)
982
+ if not isinstance(_obs, dict):
983
+ continue
984
+ _ev = (_obs.get("output") or {}).get("evidence") or _obs.get("evidence") or {}
985
+ if not isinstance(_ev, dict):
986
+ continue
987
+ _val = _ev.get("value")
988
+ if _val is None:
989
+ continue
990
+ candidates.append({
991
+ "stat_id": _ev.get("stat_table_id") or None,
992
+ "value": _val,
993
+ "unit": _ev.get("unit") or None,
994
+ "time": str(_ev.get("time_period") or ""),
995
+ "category": _ev.get("category_path") or None,
996
+ "origin": "fetch_obs",
997
+ })
998
+ except Exception as _e:
999
+ logger.debug(f"[Agent A] fetch observation 수집 실패 (무시): {_e}")
1000
+
1001
+ # derived claim은 sibling_evidence의 base 결과(=current 값)도 후보로 합침
1002
+ if _is_derived and _claim_sent_id and hasattr(workspace, "read_sibling_evidence"):
1003
+ try:
1004
+ for _s in (workspace.read_sibling_evidence(_claim_sent_id) or []):
1005
+ if not isinstance(_s, dict):
1006
+ continue
1007
+ if _s.get("role") != "base":
1008
+ continue
1009
+ _val = _s.get("value")
1010
+ if _val is None:
1011
+ continue
1012
+ candidates.append({
1013
+ "stat_id": _parse_stat_id(_s.get("source")),
1014
+ "value": _val,
1015
+ "unit": _s.get("unit") or None,
1016
+ "time": str(_s.get("time_period") or ""),
1017
+ "category": None,
1018
+ "origin": "sibling_base",
1019
+ })
1020
+ except Exception as _e:
1021
+ logger.debug(f"[Agent A] sibling_evidence 수집 실패 (무시): {_e}")
1022
+
1023
+ # (stat_id, time) 기준 중복 제거 — 같은 fetch 여러 번 박힌 경우
1024
+ _seen = set()
1025
+ _deduped: list[dict] = []
1026
+ for _c in candidates:
1027
+ _key = (str(_c.get("stat_id") or ""), _norm_time(_c.get("time")))
1028
+ if _key in _seen:
1029
+ continue
1030
+ _seen.add(_key)
1031
+ _deduped.append(_c)
1032
+
1033
+ # ── 2) primary 선택: claim.schema.time_period 매칭 우선 ──
1034
+ _primary: dict | None = None
1035
+ if _claim_target_time and _deduped:
1036
+ _tnorm = _norm_time(_claim_target_time)
1037
+ for _c in _deduped:
1038
+ if _norm_time(_c.get("time")) == _tnorm:
1039
+ _primary = _c
1040
+ break
1041
+ if _primary is None and _deduped:
1042
+ _primary = _deduped[0] # 시점 매칭 실패 시 첫 후보로 폴백
1043
+
1044
+ if _primary is not None:
1045
+ _ev_category = _primary.get("category")
1046
+ agent_evidence = Evidence(
1047
+ source_name="KOSIS",
1048
+ stat_table_id=_primary.get("stat_id"),
1049
+ official_value=_primary.get("value"),
1050
+ unit=_primary.get("unit"),
1051
+ time_period=_primary.get("time") or None,
1052
+ )
1053
+
1054
+ # ── 3) supporting: derived claim에만 — primary 외 후보 ──
1055
+ # [2026-05-25] supporting은 *claim이 의도한 시점*에만 한정.
1056
+ # 기존엔 workspace의 모든 fetch_obs를 supporting에 덤프해서, 4월 증가율
1057
+ # claim 검증 중 시도된 3월/5월 fetch가 "함께 참조한 데이터"로 노출됨
1058
+ # → UI 노이즈. claim.time_period + claim.prev_time_period 두 시점에
1059
+ # 매칭되는 evidence만 인정.
1060
+ _relevant_times: set[str] = set()
1061
+ if _claim_target_time:
1062
+ _relevant_times.add(_norm_time(_claim_target_time))
1063
+ if claim.schema and getattr(claim.schema, "prev_time_period", None):
1064
+ _relevant_times.add(_norm_time(claim.schema.prev_time_period))
1065
+
1066
+ def _time_is_relevant(t: str) -> bool:
1067
+ """_relevant_times에 정확/prefix 매칭되면 True. 빈 set면 모두 허용 (보수)."""
1068
+ if not _relevant_times:
1069
+ return True
1070
+ tn = _norm_time(t)
1071
+ if not tn:
1072
+ return False
1073
+ for rt in _relevant_times:
1074
+ if not rt:
1075
+ continue
1076
+ if tn == rt or tn.startswith(rt) or rt.startswith(tn):
1077
+ return True
1078
+ return False
1079
+
1080
+ if _is_derived and _primary is not None:
1081
+ for _c in _deduped:
1082
+ if _c is _primary:
1083
+ continue
1084
+ if not _time_is_relevant(_c.get("time", "")):
1085
+ logger.debug(
1086
+ f"[Agent A] supporting 제외 (claim 의도 시점 불일치): "
1087
+ f"time={_c.get('time')!r} not in {_relevant_times}"
1088
+ )
1089
+ continue
1090
+ supporting_evidence.append(Evidence(
1091
+ source_name="KOSIS",
1092
+ stat_table_id=_c.get("stat_id"),
1093
+ official_value=_c.get("value"),
1094
+ unit=_c.get("unit"),
1095
+ time_period=_c.get("time") or None,
1096
+ ))
1097
+
1098
+ if agent_evidence is not None:
1099
+ logger.info(
1100
+ f"[Agent A] {claim.claim_id}: evidence 분리 — "
1101
+ f"primary(time={agent_evidence.time_period}, "
1102
+ f"value={agent_evidence.official_value}), "
1103
+ f"supporting={len(supporting_evidence)}건 "
1104
+ f"(role={_claim_role!r}, claim_target_time={_claim_target_time!r})"
1105
+ )
1106
+
1107
+ # [머지 이수민 main] 도메인 가드 — agent 경로에도 적용.
1108
+ # data_point의 category_path가 doc 도메인과 어긋나면(예: 인구
1109
+ # 기사인데 환경 통계표) verdict를 UNVERIFIABLE로 강등.
1110
+ # agent_loop의 표 관련성 체크와 별개의 doc-레벨 안전망.
1111
+ if (memory is not None and agent_evidence is not None
1112
+ and _ev_category):
1113
+ if not memory.domain_matches_category(_ev_category):
1114
+ from structverify.core.schemas import VerdictType
1115
+ logger.warning(
1116
+ f"[Agent A] 도메인 가드 거절: claim={claim.claim_id} "
1117
+ f"category={_ev_category!r} "
1118
+ f"vs domain={memory.domain!r} → UNVERIFIABLE 강등"
1119
+ )
1120
+ if mem_lock is not None:
1121
+ async with mem_lock:
1122
+ memory.record_stat_id_rejected(
1123
+ str(agent_evidence.stat_table_id or "?"),
1124
+ f"도메인 불일치: {memory.domain}",
1125
+ )
1126
+ verdict.verdict = VerdictType.UNVERIFIABLE
1127
+ verdict.confidence = min(
1128
+ getattr(verdict, "confidence", 0.3) or 0.3, 0.3
1129
+ )
1130
+ agent_evidence = None # 그래프에도 박지 않음
1131
+
1132
+ # [머지 이수민 main] MATCH면 성공 stat_id를 memory에 캐시
1133
+ if (memory is not None and agent_evidence is not None
1134
+ and getattr(verdict.verdict, "value", "") == "match"
1135
+ and getattr(agent_evidence, "stat_table_id", None)
1136
+ and claim.schema and claim.schema.indicator):
1137
+ if mem_lock is not None:
1138
+ async with mem_lock:
1139
+ memory.record_stat_id_used(
1140
+ indicator=claim.schema.indicator,
1141
+ stat_id=agent_evidence.stat_table_id,
1142
+ category_path=_ev_category,
1143
+ time_period=getattr(
1144
+ agent_evidence, "time_period", None
1145
+ ),
1146
+ )
1147
+
1148
+ # [v6.19] Evidence → 그래프 노드/엣지 (multihop 재검증 입력)
1149
+ ev_nodes, ev_edges = _evidence_to_graph(agent_evidence, claim_nid)
1150
+
1151
+ result = VerificationResult(
1152
+ claim_id=claim.claim_id,
1153
+ verdict=verdict.verdict,
1154
+ confidence=verdict.confidence,
1155
+ explanation=verdict.explanation,
1156
+ evidence=agent_evidence,
1157
+ supporting_evidence=supporting_evidence,
1158
+ )
1159
+ return result, ev_nodes, ev_edges
1160
+
1161
+ except Exception as e:
1162
+ # Agent 경로 실패 → 기존 경로로 안전 폴백
1163
+ logger.warning(
1164
+ f"[Agent A] agent_loop 실패 → 기존 경로 폴백: {e}"
1165
+ )
1166
+ query = build_query(claim)
1167
+ evidence, fb_nodes, fb_edges = await build_evidence_subgraph(
1168
+ self.kosis, query, claim_nid,
1169
+ )
1170
+ fb_result = verify_claim(
1171
+ claim, evidence, self.config, graph=temporal_graph
1172
+ )
1173
+ return fb_result, fb_nodes, fb_edges
1174
+
1175
+
1176
+ # ── [v6.19] Evidence → 그래프 노드/엣지 변환 ────────────────────────────────
1177
+
1178
+ def _evidence_to_graph(
1179
+ evidence: "Evidence | None", claim_nid: str,
1180
+ ) -> "tuple[list[GraphNode], list[GraphEdge]]":
1181
+ """agent 경로가 얻은 Evidence를 그래프 노드/엣지로 변환.
1182
+
1183
+ 기존 경로의 build_evidence_subgraph와 동일한 모양:
1184
+ EVIDENCE 노드 1개 + (claim ─VERIFIED_BY→ evidence) 엣지 1개
1185
+ 이렇게 박아야 Step 8.5 multihop이 COMPARE 이웃의 검증 수치를
1186
+ 그래프에서 찾아 파생 주장을 재검증할 수 있다.
1187
+
1188
+ evidence가 None이거나 official_value가 없으면 빈 리스트
1189
+ (검증 근거가 없으므로 그래프에 박을 것도 없음).
1190
+ """
1191
+ if evidence is None or getattr(evidence, "official_value", None) is None:
1192
+ return [], []
1193
+
1194
+ stat_id = getattr(evidence, "stat_table_id", None) or "unknown"
1195
+ ev_node_id = f"evidence:{claim_nid}:{stat_id}"
1196
+ ev_node = GraphNode(
1197
+ node_id=ev_node_id,
1198
+ node_type=GraphNodeType.EVIDENCE,
1199
+ label=f"{evidence.source_name or 'KOSIS'}({stat_id})",
1200
+ properties={
1201
+ "official_value": evidence.official_value,
1202
+ "unit": getattr(evidence, "unit", None),
1203
+ "time_period": getattr(evidence, "time_period", None),
1204
+ "stat_table_id": stat_id,
1205
+ "source_name": evidence.source_name,
1206
+ },
1207
+ )
1208
+ ev_edge = GraphEdge(
1209
+ from_node=claim_nid,
1210
+ to_node=ev_node_id,
1211
+ edge_type=GraphEdgeType.VERIFIED_BY,
1212
+ )
1213
+ return [ev_node], [ev_edge]
1214
+
1215
+
1216
+ # ── [v4 김예슬] Context Window 헬퍼 ─────────────────────────────────────────
1217
+
1218
+ def _get_context_window(
1219
+ claim: "Claim",
1220
+ sir_doc: "SIRDocument",
1221
+ window: int = 2,
1222
+ ) -> str:
1223
+ """
1224
+ claim의 앞 문장 window개를 SIR Tree에서 가져와서 context 문자열로 반환.
1225
+
1226
+ [v4 김예슬 - 2026-05-07]
1227
+ "이는 20년 새 2.6배 증가한 것이다" 같은 문장은
1228
+ 앞 문장 "2024년 쉬었음 청년이 21만7천명이다"가 있어야 정확한 schema 추출 가능.
1229
+
1230
+ SIR Tree의 block_id/sent_id를 기반으로 같은 블록 내 앞 문장들을 찾음.
1231
+ NEXT_SENT 엣지를 graph에서 탐색하지 않고 sir_doc에서 직접 조회 (더 효율적).
1232
+
1233
+ Args:
1234
+ claim: 대상 주장
1235
+ sir_doc: SIR 문서 (blocks → sentences 계층)
1236
+ window: 앞에서 가져올 문장 수 (기본 2)
1237
+
1238
+ Returns:
1239
+ "앞문장1. 앞문장2. 현재문장" 형태의 context 문자열
1240
+ """
1241
+ target_block = claim.block_id
1242
+ target_sent = claim.sent_id
1243
+
1244
+ # sir_doc에서 해당 블록 찾기
1245
+ block = None
1246
+ for b in sir_doc.blocks:
1247
+ if b.block_id == target_block:
1248
+ block = b
1249
+ break
1250
+
1251
+ if not block or not block.sentences:
1252
+ return claim.claim_text
1253
+
1254
+ # 현재 문장 인덱스 찾기
1255
+ sent_idx = None
1256
+ for i, sent in enumerate(block.sentences):
1257
+ if sent.sent_id == target_sent:
1258
+ sent_idx = i
1259
+ break
1260
+
1261
+ if sent_idx is None:
1262
+ return claim.claim_text
1263
+
1264
+ # 앞 window개 문장 수집
1265
+ start = max(0, sent_idx - window)
1266
+ context_sents = []
1267
+ for i in range(start, sent_idx + 1):
1268
+ text = block.sentences[i].text.strip()
1269
+ if text:
1270
+ context_sents.append(text)
1271
+
1272
+ return " ".join(context_sents)