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,809 @@
1
+ """
2
+ utils/llm_client.py — LLM API 통합 클라이언트 (HCX / OpenAI)
3
+
4
+ Runtime Agent와 Builder Agent가 공유하는 LLM 호출 인터페이스.
5
+ 모든 LLM 호출은 반드시 이 클래스를 거쳐야 한다 (중앙 tracing).
6
+
7
+ [김예슬 - 2026-04-22]
8
+ - _call_hcx(): NCP CLOVA Studio Chat Completions v1 API 실제 호출 구현
9
+ - _call_openai(): AsyncOpenAI 클라이언트 실제 호출 구현 (HCX 대체용)
10
+ - model_tier 파라미터 추가: "heavy"(HCX-003) / "light"(HCX-DASH-001) / "reasoning" 자동 분기
11
+ - generate_light() / generate_json_light() 단축 메서드 추가
12
+ - _parse_json_response(): 클래스 외부 모듈 함수로 분리 (단위 테스트 용이)
13
+ - _direct_api_key 지원 (테스트용 키 직접 주입)
14
+
15
+ [김예슬 - 2026-04-24]
16
+ - CLOVA Studio v3 API 지원 추가
17
+ · _call_hcx_v3(): Chat Completions v3 (HCX-005, HCX-DASH-002)
18
+ · _call_hcx_structured(): Structured Outputs (HCX-007 전용)
19
+ - responseFormat.type = "json" + JSON Schema 정의
20
+ - JSON 파싱 실패 없음 — 항상 스키마 형식으로 반환 보장
21
+ - generate_structured(): Structured Outputs 전용 공개 메서드 추가
22
+ - 모델 tier 업데이트:
23
+ · heavy: HCX-003 → 복잡한 태스크 (check-worthiness, 설명 생성)
24
+ · light: HCX-DASH-002 → 빠른 분류 (도메인, candidate scoring)
25
+ · structured: HCX-007 → JSON 구조화 추출 (schema_inductor 전용)
26
+
27
+ [참고] CLOVA Studio Structured Outputs
28
+ https://api.ncloud-docs.com/docs/en/clovastudio-chatcompletionsv3-so
29
+
30
+
31
+ # [박재윤 - 2026-05-14]: _call_hcx_v1, _call_hcx_v3 429 retry backoff 추가
32
+ - 429 rate limit 시 exponential backoff 재시도{V1,V3} (최대 3회)
33
+
34
+ NCP CLOVA Studio Chat Completions v1,V3 API.
35
+ 엔드포인트: POST /v1/chat-completions/{model}
36
+ 대상 모델: HCX-003
37
+
38
+ # [박재윤 - 2026-05-18]: _call_hcx_structured 429 retry backoff 추가
39
+ # · _call_hcx_v1, _call_hcx_v3와 동일한 exponential backoff 적용 (최대 3회)
40
+ # · schema_inductor generate_structured 호출 시 rate limit 문제 해결
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import asyncio
45
+ import json
46
+ import os
47
+ import time
48
+ import uuid
49
+ from typing import Any
50
+
51
+ import httpx
52
+
53
+ from structverify.utils.logger import get_logger
54
+
55
+ logger = get_logger(__name__)
56
+
57
+ # ── 엔드포인트 ────────────────────────────────────────────────────────────
58
+ HCX_V1_BASE = "https://clovastudio.stream.ntruss.com/v1/chat-completions"
59
+ HCX_V3_BASE = "https://clovastudio.stream.ntruss.com/v3/chat-completions"
60
+
61
+
62
+ # ── [2026-05-21] 프로세스 전역 HCX rate limiter ───────────────────────────
63
+ # HCX는 같은 API 키에 *초당 요청 수* 한도가 있어, claim_detector(concurrency=4) +
64
+ # planner + reflect 등 *여러 코드 경로*가 짧은 시간 안에 동시에 호출하면 429 폭주.
65
+ # concurrency 제한만으론 phase 간 burst를 못 막으므로, 모든 HCX 호출(v1/v3/structured)
66
+ # 직전에 acquire()를 통과시켜 *호출 사이 최소 간격*을 강제한다.
67
+ # min_interval 단위: 초. 0이면 비활성.
68
+ class _HCXRateLimiter:
69
+ def __init__(self) -> None:
70
+ self._lock = asyncio.Lock()
71
+ self._last_call_ts: float = 0.0
72
+ self._min_interval: float = 0.0
73
+ self._headers_logged: bool = False
74
+
75
+ self._max_concurrency: int = 0
76
+ self._sem: "asyncio.Semaphore | None" = None
77
+
78
+ def configure(self, interval_sec: float) -> None:
79
+ """max(기존, 새 값)로 갱신 — 더 빡센 설정이 이김."""
80
+ if interval_sec > self._min_interval:
81
+ self._min_interval = interval_sec
82
+ logger.info(
83
+ f"[llm_throttle] min_interval={interval_sec:.3f}s "
84
+ f"(= ≤{1.0/interval_sec:.1f} req/sec)"
85
+ )
86
+
87
+ def configure_concurrency(self, n: int) -> None:
88
+ """한 번에 보낼 수 있는 최대 동시 LLM 요청 수 (config.llm.max_concurrency)."""
89
+ n = int(n or 0)
90
+ if n > 0 and n != self._max_concurrency:
91
+ self._max_concurrency = n
92
+ self._sem = None # 실제 세마포어는 이벤트 루프 안에서 lazy 생성
93
+ logger.info(f"[llm_throttle] max_concurrency={n}")
94
+
95
+ async def acquire(self) -> None:
96
+ if self._min_interval <= 0:
97
+ return
98
+ async with self._lock:
99
+ now = time.monotonic()
100
+ elapsed = now - self._last_call_ts
101
+ wait = self._min_interval - elapsed
102
+ if wait > 0:
103
+ await asyncio.sleep(wait)
104
+ self._last_call_ts = time.monotonic()
105
+
106
+ def slot(self):
107
+ """동시성 상한 + 호출 간격을 함께 적용하는 async 컨텍스트. 모든 provider 공용.
108
+
109
+ async with _hcx_rate_limiter.slot():
110
+ resp = await client.create(...)
111
+ """
112
+ limiter = self
113
+
114
+ class _Slot:
115
+ async def __aenter__(self_inner):
116
+ if limiter._max_concurrency > 0:
117
+ # 세마포어는 실행 중인 이벤트 루프에 바인딩 — 루프 바뀌면 재생성.
118
+ loop = asyncio.get_running_loop()
119
+ if limiter._sem is None or getattr(limiter, "_sem_loop", None) is not loop:
120
+ limiter._sem = asyncio.Semaphore(limiter._max_concurrency)
121
+ limiter._sem_loop = loop
122
+ self_inner._sem = limiter._sem
123
+ if self_inner._sem is not None:
124
+ await self_inner._sem.acquire()
125
+ await limiter.acquire() # 호출 간격 스페이싱
126
+ return self_inner
127
+
128
+ async def __aexit__(self_inner, *exc):
129
+ if self_inner._sem is not None:
130
+ self_inner._sem.release()
131
+ return False
132
+
133
+ return _Slot()
134
+
135
+ def _log_headers_if_first(self, headers: Any) -> None:
136
+ """첫 429 응답 헤더의 rate-limit 관련 키를 한 번만 로그. HCX 실제 한도 파악용."""
137
+ if self._headers_logged:
138
+ return
139
+ self._headers_logged = True
140
+ try:
141
+ keys_of_interest = [
142
+ k for k in headers.keys()
143
+ if any(t in k.lower() for t in ("ratelimit", "rate-limit", "retry-after", "quota"))
144
+ ]
145
+ if keys_of_interest:
146
+ info = {k: headers.get(k) for k in keys_of_interest}
147
+ logger.warning(f"[hcx_rate_limiter] HCX 429 응답 헤더 (한도 진단용): {info}")
148
+ else:
149
+ # 알려진 key 없으면 *모든 헤더 키* 한 번 로그 (이름 단서라도)
150
+ logger.warning(
151
+ f"[hcx_rate_limiter] HCX 429 응답 헤더 키 (rate-limit 관련 없음): "
152
+ f"{list(headers.keys())[:20]}"
153
+ )
154
+ except Exception as _e:
155
+ logger.debug(f"[hcx_rate_limiter] 헤더 로그 실패: {_e}")
156
+
157
+
158
+ _hcx_rate_limiter = _HCXRateLimiter()
159
+
160
+
161
+ # [v2 - 김예슬 / #64] provider별 기본 모델 티어 매핑.
162
+ # config.llm.models 를 안 바꾸고 provider 만 바꿔도 동작하도록, provider별 기본 모델을 둔다.
163
+ # (default.yaml 의 models 는 HCX 이름이라, provider 만 upstage/gemini 로 바꾸면 그대로 쓰면 404)
164
+ _PROVIDER_DEFAULT_MODELS = {
165
+ "hcx": {"heavy": "HCX-003", "light": "HCX-DASH-002", "structured": "HCX-007", "reasoning": "HCX-003"},
166
+ "openai": {"heavy": "gpt-4o", "light": "gpt-4o-mini", "structured": "gpt-4o", "reasoning": "gpt-4o"},
167
+ "upstage": {"heavy": "solar-pro2", "light": "solar-mini", "structured": "solar-pro2", "reasoning": "solar-pro2"},
168
+ "gemini": {"heavy": "gemini-2.5-pro", "light": "gemini-2.5-flash", "structured": "gemini-2.5-pro", "reasoning": "gemini-2.5-pro"},
169
+ }
170
+
171
+
172
+ class LLMClient:
173
+ """
174
+ HCX(NCP CLOVA Studio) + OpenAI 통합 클라이언트.
175
+
176
+ config 예시 (default.yaml의 llm 섹션):
177
+ provider: "hcx"
178
+ models:
179
+ heavy: "HCX-003" # 복잡한 태스크 (v1 API)
180
+ light: "HCX-DASH-002" # 빠른 분류 (v3 API)
181
+ structured: "HCX-007" # JSON 구조화 (v3 Structured Outputs)
182
+ temperature: 0.1
183
+ max_tokens: 2048
184
+ api_key_env: "CLOVASTUDIO_API_KEY"
185
+ """
186
+
187
+ def __init__(self, config: dict | None = None):
188
+ self.config = config or {}
189
+ self.provider = self.config.get("provider", "hcx")
190
+ # [v2 - 김예슬 / #64] provider별 모델 티어 매핑 — provider 만 바꿔도 동작하게.
191
+ # · config.models 없으면 provider 기본값.
192
+ # · provider!=hcx 인데 config.models 가 HCX 이름이면(=default.yaml 잔재) 무시하고 provider 기본값.
193
+ # · 그 외엔 provider 기본값 위에 config.models 를 덮어써 일부 티어만 override 허용.
194
+ _defaults = _PROVIDER_DEFAULT_MODELS.get(self.provider, _PROVIDER_DEFAULT_MODELS["hcx"])
195
+ _user_models = self.config.get("models")
196
+ if not _user_models:
197
+ self.models = dict(_defaults)
198
+ elif self.provider != "hcx" and any(
199
+ str(v).upper().startswith("HCX") for v in _user_models.values()
200
+ ):
201
+ self.models = dict(_defaults)
202
+ else:
203
+ self.models = {**_defaults, **_user_models}
204
+ self.default_model = self.models.get("heavy") or next(iter(self.models.values()), "HCX-003")
205
+ self.temperature = float(self.config.get("temperature", 0.1))
206
+ self.max_tokens = int(self.config.get("max_tokens", 2048))
207
+
208
+ # HCX API 키 — _direct_api_key로 테스트 시 직접 주입 가능
209
+ api_key_env = self.config.get("api_key_env", "NCP_API_KEY")
210
+ if self.config.get("_direct_api_key"):
211
+ self.api_key = self.config["_direct_api_key"]
212
+ else:
213
+ self.api_key = os.environ.get(api_key_env, "")
214
+ if not self.api_key:
215
+ logger.warning(f"LLM API 키 없음 — 환경변수 {api_key_env} 확인 필요")
216
+
217
+ # OpenAI API 키
218
+ openai_key_env = self.config.get("openai_key_env", "OPENAI_API_KEY")
219
+ if openai_key_env.startswith("sk-"):
220
+ self.openai_api_key = openai_key_env
221
+ else:
222
+ self.openai_api_key = os.environ.get(openai_key_env, "")
223
+
224
+ # [v2 - 김예슬] Upstage(Solar) 지원 — OpenAI 호환 API (base_url만 다름).
225
+ # provider: "upstage" 로 두면 HCX 키 없이 Upstage 키로 동작. (pip install openai 필요)
226
+ self.upstage_base_url = self.config.get("base_url", "https://api.upstage.ai/v1")
227
+ # [#64 후속] api_key_env가 HCX 기본(NCP_API_KEY) 잔재면 무시하고 UPSTAGE_API_KEY 사용.
228
+ # (provider만 upstage로 바꿔도 default.yaml의 api_key_env=NCP 때문에 HCX키 집던 버그 방지)
229
+ _cfg_key_env = self.config.get("api_key_env", "")
230
+ self.upstage_api_key = (
231
+ self.config.get("_direct_api_key")
232
+ or (os.environ.get(_cfg_key_env, "") if _cfg_key_env and _cfg_key_env != "NCP_API_KEY" else "")
233
+ or os.environ.get("UPSTAGE_API_KEY", "")
234
+ )
235
+ # (upstage 모델 티어는 _PROVIDER_DEFAULT_MODELS["upstage"]에서 결정 — #64)
236
+
237
+ # [v2 - 김예슬] Gemini(Google) 지원 — OpenAI 호환 엔드포인트.
238
+ # provider: "gemini" + GEMINI_API_KEY(또는 GOOGLE_API_KEY). (pip install openai 필요)
239
+ self.gemini_base_url = self.config.get(
240
+ "base_url", "https://generativelanguage.googleapis.com/v1beta/openai/"
241
+ )
242
+ _cfg_key_env_g = self.config.get("api_key_env", "")
243
+ self.gemini_api_key = (
244
+ self.config.get("_direct_api_key")
245
+ or (os.environ.get(_cfg_key_env_g, "") if _cfg_key_env_g and _cfg_key_env_g != "NCP_API_KEY" else "")
246
+ or os.environ.get("GEMINI_API_KEY", "")
247
+ or os.environ.get("GOOGLE_API_KEY", "")
248
+ )
249
+ # (gemini 모델 티어는 _PROVIDER_DEFAULT_MODELS["gemini"]에서 결정 — #64)
250
+
251
+ # [2026-05-21] 프로세스 전역 HCX rate limit — config에서 min_call_interval_ms.
252
+ # 여러 LLMClient 인스턴스가 *같은 limiter*를 공유 → 모든 HCX 호출 직렬 간격 보장.
253
+ _interval_ms = float(self.config.get("min_call_interval_ms", 0) or 0)
254
+ if _interval_ms > 0:
255
+ _hcx_rate_limiter.configure(_interval_ms / 1000.0)
256
+ # [2026] 전역 동시성 상한 — config.llm.max_concurrency (모든 provider 공용).
257
+ # 429(rate limit) 방지: 한 번에 보내는 LLM 요청 수를 이 값으로 제한.
258
+ _hcx_rate_limiter.configure_concurrency(self.config.get("max_concurrency", 0))
259
+
260
+ # [2026-05-25] HTTP timeout 및 timeout 발생 시 retry 횟수.
261
+ # 기존 60s 하드코딩 → 응답 평균 5~15초인데 60s 끝까지 기다림 + retry 없음 →
262
+ # 일시 네트워크 지연/끊김에 매우 약함. 30s로 줄여 빠른 실패 + retry 1회로 회복.
263
+ self.http_timeout = float(self.config.get("http_timeout_seconds", 30.0))
264
+ self.timeout_max_retries = int(self.config.get("timeout_max_retries", 1))
265
+
266
+ # ── 공개 인터페이스 ──────────────────────────────────────────────────────
267
+
268
+ async def generate(
269
+ self,
270
+ prompt: str,
271
+ system_prompt: str | None = None,
272
+ temperature: float | None = None,
273
+ model_tier: str = "heavy",
274
+ ) -> str:
275
+ """텍스트 생성 — 일반 Chat Completions"""
276
+ if self.provider == "hcx":
277
+ model = self.models.get(model_tier, self.default_model)
278
+ # HCX-DASH-002, HCX-005 → v3 API
279
+ if model in ("HCX-DASH-002", "HCX-005"):
280
+ return await self._call_hcx_v3(prompt, system_prompt, temperature, model)
281
+ # HCX-003 → v1 API
282
+ return await self._call_hcx_v1(prompt, system_prompt, temperature, model)
283
+ elif self.provider == "openai":
284
+ return await self._call_openai(prompt, system_prompt, temperature, model_tier)
285
+ elif self.provider == "upstage": # [v2 - 김예슬] HCX 키 없을 때 Upstage(Solar)
286
+ return await self._call_upstage(prompt, system_prompt, temperature, model_tier)
287
+ elif self.provider == "gemini": # [v2 - 김예슬]
288
+ return await self._call_gemini(prompt, system_prompt, temperature, model_tier)
289
+ raise ValueError(f"미지원 provider: {self.provider}")
290
+
291
+ async def generate_json(
292
+ self,
293
+ prompt: str,
294
+ system_prompt: str | None = None,
295
+ model_tier: str = "heavy",
296
+ ) -> dict[str, Any]:
297
+ """텍스트 생성 후 JSON 파싱 — 파싱 실패 시 {"raw": ...} 반환"""
298
+ raw = await self.generate(prompt, system_prompt, model_tier=model_tier)
299
+ return _parse_json_response(raw)
300
+
301
+ async def generate_structured(
302
+ self,
303
+ prompt: str,
304
+ schema: dict[str, Any],
305
+ system_prompt: str | None = None,
306
+ ) -> dict[str, Any]:
307
+ """
308
+ Structured Outputs — HCX-007 전용.
309
+ JSON Schema를 정의하면 항상 그 형식으로 반환 보장.
310
+ 파싱 실패 없음.
311
+
312
+ Args:
313
+ prompt: 사용자 프롬프트
314
+ schema: JSON Schema 정의 (object 타입)
315
+ system_prompt: 시스템 프롬프트
316
+
317
+ Returns:
318
+ 스키마에 맞는 dict
319
+
320
+ 사용 예:
321
+ result = await llm.generate_structured(
322
+ prompt="주장: '2023년 고령화율 64.2%'에서 정보 추출",
323
+ schema={
324
+ "type": "object",
325
+ "properties": {
326
+ "indicator": {"type": "string"},
327
+ "value": {"type": "number"},
328
+ },
329
+ "required": ["indicator", "value"]
330
+ }
331
+ )
332
+ """
333
+ if self.provider == "hcx":
334
+ return await self._call_hcx_structured(prompt, schema, system_prompt)
335
+ elif self.provider == "openai":
336
+ # OpenAI도 response_format으로 JSON Schema 지원
337
+ raw = await self._call_openai_structured(prompt, schema, system_prompt)
338
+ return raw
339
+ elif self.provider == "upstage": # [v2 - 김예슬]
340
+ return await self._call_upstage_structured(prompt, schema, system_prompt)
341
+ elif self.provider == "gemini": # [v2 - 김예슬]
342
+ return await self._call_gemini_structured(prompt, schema, system_prompt)
343
+ raise ValueError(f"미지원 provider: {self.provider}")
344
+
345
+ async def generate_light(self, prompt: str, system_prompt: str | None = None) -> str:
346
+ """경량 모델(HCX-DASH-002) 호출 단축키"""
347
+ return await self.generate(prompt, system_prompt, model_tier="light")
348
+
349
+ async def generate_json_light(self, prompt: str, system_prompt: str | None = None) -> dict[str, Any]:
350
+ """경량 모델로 JSON 응답 생성"""
351
+ raw = await self.generate_light(prompt, system_prompt)
352
+ return _parse_json_response(raw)
353
+
354
+ # ── HCX v1 (HCX-003) ────────────────────────────────────────────────────
355
+
356
+ async def _call_hcx_v1(
357
+ self,
358
+ prompt: str,
359
+ system_prompt: str | None,
360
+ temperature: float | None,
361
+ model: str,
362
+ ) -> str:
363
+ """
364
+ NCP CLOVA Studio Chat Completions v1 API.
365
+ 엔드포인트: POST /v1/chat-completions/{model}
366
+ 대상 모델: HCX-003
367
+
368
+ [박재윤 - 2026-05-14]
369
+ - 429 rate limit 시 exponential backoff 재시도 (최대 3회)
370
+ """
371
+ url = f"{HCX_V1_BASE}/{model}"
372
+ messages = []
373
+ if system_prompt:
374
+ messages.append({"role": "system", "content": system_prompt})
375
+ messages.append({"role": "user", "content": prompt})
376
+
377
+ payload = {
378
+ "messages": messages,
379
+ "maxTokens": self.max_tokens,
380
+ "temperature": temperature if temperature is not None else self.temperature,
381
+ "topP": 0.8,
382
+ "topK": 0,
383
+ "repeatPenalty": 5.0,
384
+ "stopBefore": [],
385
+ "includeAiFilters": False,
386
+ }
387
+ headers = {
388
+ "Authorization": f"Bearer {self.api_key}",
389
+ "X-NCP-CLOVASTUDIO-REQUEST-ID": str(uuid.uuid4()),
390
+ "Content-Type": "application/json",
391
+ "Accept": "application/json",
392
+ }
393
+
394
+ # [박재윤 - 2026-05-14]: 429 exponential backoff (1초 → 2초 → 4초)
395
+ # [2026-05-21] 매 시도 직전 전역 rate limiter 통과 — phase 간 burst 차단.
396
+ # [2026-05-25] timeout도 retry — 일시 네트워크 지연에 회복하도록.
397
+ max_retries = 3
398
+ timeout_attempts = 0 # timeout 누적 횟수 (429와 분리)
399
+ for attempt in range(max_retries):
400
+ try:
401
+ await _hcx_rate_limiter.acquire()
402
+ async with httpx.AsyncClient(timeout=self.http_timeout) as client:
403
+ resp = await client.post(url, json=payload, headers=headers)
404
+ resp.raise_for_status()
405
+ data = resp.json()
406
+ status_code = data.get("status", {}).get("code", "")
407
+ if status_code != "20000":
408
+ msg = data.get("status", {}).get("message", "unknown")
409
+ raise RuntimeError(f"HCX v1 API 오류: {status_code} {msg}")
410
+ content = data["result"]["message"]["content"]
411
+ logger.debug(f"HCX v1 응답 ({model}): {content[:80]}...")
412
+ return content
413
+ except httpx.HTTPStatusError as e:
414
+ if e.response.status_code == 429 and attempt < max_retries - 1:
415
+ # [2026-05-21] jitter 추가 — 동시 N개 호출이 모두 429 받으면 같은 시점에
416
+ # retry해 또 burst 발생. 0~0.7s 랜덤 지연으로 동기화 깸.
417
+ import random as _random
418
+ wait = (2 ** attempt) + _random.uniform(0.0, 0.7)
419
+ # [2026-05-21] HCX 응답 헤더에 rate limit 정보 있는지 한 번만 로그.
420
+ # X-RateLimit-* / Retry-After 등이 있으면 한도 추정 가능.
421
+ _hcx_rate_limiter._log_headers_if_first(e.response.headers)
422
+ logger.warning(f"HCX v1 429 rate limit — {wait:.2f}초 후 재시도 ({attempt+1}/{max_retries})")
423
+ await asyncio.sleep(wait)
424
+ else:
425
+ logger.error(f"HCX v1 HTTP 오류: {e.response.status_code} — {e.response.text[:200]}")
426
+ raise
427
+ except httpx.TimeoutException:
428
+ if timeout_attempts < self.timeout_max_retries:
429
+ timeout_attempts += 1
430
+ import random as _random
431
+ wait = 0.5 + _random.uniform(0.0, 0.5)
432
+ logger.warning(
433
+ f"HCX v1 타임아웃 ({self.http_timeout}s) — "
434
+ f"{wait:.2f}초 후 재시도 ({timeout_attempts}/{self.timeout_max_retries})"
435
+ )
436
+ await asyncio.sleep(wait)
437
+ continue
438
+ logger.error(f"HCX v1 타임아웃 (재시도 소진): {url}")
439
+ raise
440
+
441
+ # ── HCX v3 (HCX-005, HCX-DASH-002) ─────────────────────────────────────
442
+
443
+ async def _call_hcx_v3(
444
+ self,
445
+ prompt: str,
446
+ system_prompt: str | None,
447
+ temperature: float | None,
448
+ model: str,
449
+ ) -> str:
450
+ """
451
+ NCP CLOVA Studio Chat Completions v3 API.
452
+ 엔드포인트: POST /v3/chat-completions/{model}
453
+ 대상 모델: HCX-005 (비전), HCX-DASH-002 (경량)
454
+
455
+ [박재윤 - 2026-05-14]
456
+ - 429 rate limit 시 exponential backoff 재시도 (최대 3회)
457
+ """
458
+ url = f"{HCX_V3_BASE}/{model}"
459
+ messages = []
460
+ if system_prompt:
461
+ messages.append({"role": "system", "content": system_prompt})
462
+ messages.append({"role": "user", "content": prompt})
463
+
464
+ payload = {
465
+ "messages": messages,
466
+ "maxCompletionTokens": self.max_tokens,
467
+ "temperature": temperature if temperature is not None else self.temperature,
468
+ "topP": 0.8,
469
+ "topK": 0,
470
+ "repetitionPenalty": 1.1,
471
+ "stop": [],
472
+ }
473
+ headers = {
474
+ "Authorization": f"Bearer {self.api_key}",
475
+ "X-NCP-CLOVASTUDIO-REQUEST-ID": str(uuid.uuid4()),
476
+ "Content-Type": "application/json",
477
+ "Accept": "application/json",
478
+ }
479
+
480
+ # [박재윤 - 2026-05-14]: 429 exponential backoff (1초 → 2초 → 4초)
481
+ # [2026-05-21] 매 시도 직전 전역 rate limiter 통과 — phase 간 burst 차단.
482
+ # [2026-05-25] timeout도 retry.
483
+ max_retries = 3
484
+ timeout_attempts = 0
485
+ for attempt in range(max_retries):
486
+ try:
487
+ await _hcx_rate_limiter.acquire()
488
+ async with httpx.AsyncClient(timeout=self.http_timeout) as client:
489
+ resp = await client.post(url, json=payload, headers=headers)
490
+ resp.raise_for_status()
491
+ data = resp.json()
492
+ status_code = data.get("status", {}).get("code", "")
493
+ if status_code != "20000":
494
+ msg = data.get("status", {}).get("message", "unknown")
495
+ raise RuntimeError(f"HCX v3 API 오류: {status_code} {msg}")
496
+ content = data["result"]["message"]["content"]
497
+ logger.debug(f"HCX v3 응답 ({model}): {content[:80]}...")
498
+ return content
499
+ except httpx.HTTPStatusError as e:
500
+ if e.response.status_code == 429 and attempt < max_retries - 1:
501
+ # [2026-05-21] jitter 추가 (동시 N개 retry burst 동기화 깸)
502
+ import random as _random
503
+ wait = (2 ** attempt) + _random.uniform(0.0, 0.7)
504
+ _hcx_rate_limiter._log_headers_if_first(e.response.headers)
505
+ logger.warning(f"HCX v3 429 rate limit — {wait:.2f}초 후 재시도 ({attempt+1}/{max_retries})")
506
+ await asyncio.sleep(wait)
507
+ else:
508
+ logger.error(f"HCX v3 HTTP 오류: {e.response.status_code} — {e.response.text[:200]}")
509
+ raise
510
+ except httpx.TimeoutException:
511
+ if timeout_attempts < self.timeout_max_retries:
512
+ timeout_attempts += 1
513
+ import random as _random
514
+ wait = 0.5 + _random.uniform(0.0, 0.5)
515
+ logger.warning(
516
+ f"HCX v3 타임아웃 ({self.http_timeout}s) — "
517
+ f"{wait:.2f}초 후 재시도 ({timeout_attempts}/{self.timeout_max_retries})"
518
+ )
519
+ await asyncio.sleep(wait)
520
+ continue
521
+ logger.error(f"HCX v3 타임아웃 (재시도 소진): {url}")
522
+ raise
523
+
524
+ # ── HCX Structured Outputs (HCX-007 전용) ───────────────────────────────
525
+
526
+ async def _call_hcx_structured(
527
+ self,
528
+ prompt: str,
529
+ schema: dict[str, Any],
530
+ system_prompt: str | None,
531
+ ) -> dict[str, Any]:
532
+ """
533
+ NCP CLOVA Studio Structured Outputs API.
534
+ 엔드포인트: POST /v3/chat-completions/HCX-007
535
+ 반환: JSON Schema에 맞는 dict (파싱 실패 없음)
536
+
537
+ 주의:
538
+ - HCX-007 모델 전용
539
+ - thinking과 동시 사용 불가 → thinking.effort: "none" 고정
540
+ - responseFormat.type = "json" + schema 정의 필수
541
+
542
+ [박재윤 - 2026-05-18]: 429 retry backoff 추가
543
+ - _call_hcx_v1, _call_hcx_v3와 동일한 exponential backoff 적용 (최대 3회)
544
+ """
545
+ import asyncio
546
+
547
+ url = f"{HCX_V3_BASE}/HCX-007"
548
+ messages = []
549
+ if system_prompt:
550
+ messages.append({"role": "system", "content": system_prompt})
551
+ messages.append({"role": "user", "content": prompt})
552
+
553
+ payload = {
554
+ "messages": messages,
555
+ "maxCompletionTokens": self.max_tokens,
556
+ "temperature": self.temperature,
557
+ "topP": 0.8,
558
+ "topK": 0,
559
+ "repetitionPenalty": 1.1,
560
+ "stop": [],
561
+ "thinking": {"effort": "none"},
562
+ "responseFormat": {
563
+ "type": "json",
564
+ "schema": schema,
565
+ },
566
+ }
567
+ headers = {
568
+ "Authorization": f"Bearer {self.api_key}",
569
+ "X-NCP-CLOVASTUDIO-REQUEST-ID": str(uuid.uuid4()),
570
+ "Content-Type": "application/json",
571
+ "Accept": "application/json",
572
+ }
573
+
574
+ max_retries = 3
575
+ timeout_attempts = 0
576
+ # [2026-05-21] 전역 rate limiter 통과 — phase 간 burst 차단
577
+ # [2026-05-25] timeout retry 추가
578
+ for attempt in range(max_retries):
579
+ try:
580
+ await _hcx_rate_limiter.acquire()
581
+ async with httpx.AsyncClient(timeout=self.http_timeout) as client:
582
+ resp = await client.post(url, json=payload, headers=headers)
583
+ resp.raise_for_status()
584
+ data = resp.json()
585
+
586
+ status_code = data.get("status", {}).get("code", "")
587
+ if status_code != "20000":
588
+ msg = data.get("status", {}).get("message", "unknown")
589
+ raise RuntimeError(f"HCX Structured Outputs 오류: {status_code} {msg}")
590
+
591
+ content = data["result"]["message"]["content"]
592
+ logger.debug(f"HCX Structured 응답: {content[:80]}...")
593
+ return json.loads(content)
594
+
595
+ except httpx.HTTPStatusError as e:
596
+ if e.response.status_code == 429 and attempt < max_retries - 1:
597
+ # [2026-05-21] jitter 추가 (동시 N개 retry burst 동기화 깸)
598
+ import random as _random
599
+ wait = (2 ** attempt) + _random.uniform(0.0, 0.7)
600
+ _hcx_rate_limiter._log_headers_if_first(e.response.headers)
601
+ logger.warning(
602
+ f"HCX Structured 429 rate limit — {wait:.2f}초 후 재시도 "
603
+ f"({attempt+1}/{max_retries})"
604
+ )
605
+ await asyncio.sleep(wait)
606
+ else:
607
+ logger.error(
608
+ f"HCX Structured HTTP 오류: {e.response.status_code} — "
609
+ f"{e.response.text[:200]}"
610
+ )
611
+ raise
612
+ except httpx.TimeoutException:
613
+ if timeout_attempts < self.timeout_max_retries:
614
+ timeout_attempts += 1
615
+ import random as _random
616
+ wait = 0.5 + _random.uniform(0.0, 0.5)
617
+ logger.warning(
618
+ f"HCX Structured 타임아웃 ({self.http_timeout}s) — "
619
+ f"{wait:.2f}초 후 재시도 ({timeout_attempts}/{self.timeout_max_retries})"
620
+ )
621
+ await asyncio.sleep(wait)
622
+ continue
623
+ logger.error(f"HCX Structured 타임아웃 (재시도 소진): {url}")
624
+ raise
625
+ except json.JSONDecodeError as e:
626
+ logger.error(f"HCX Structured JSON 파싱 실패 (스키마 오류?): {e}")
627
+ raise
628
+
629
+ # ── OpenAI ───────────────────────────────────────────────────────────────
630
+
631
+ async def _call_openai(
632
+ self,
633
+ prompt: str,
634
+ system_prompt: str | None,
635
+ temperature: float | None,
636
+ model_tier: str,
637
+ ) -> str:
638
+ """OpenAI Chat Completions API 호출"""
639
+ try:
640
+ from openai import AsyncOpenAI
641
+ except ImportError:
642
+ raise ImportError("pip install openai")
643
+
644
+ model_map = {"heavy": "gpt-4o", "light": "gpt-4o-mini",
645
+ "structured": "gpt-4o", "reasoning": "gpt-4o"}
646
+ model = model_map.get(model_tier, "gpt-4o")
647
+
648
+ messages = []
649
+ if system_prompt:
650
+ messages.append({"role": "system", "content": system_prompt})
651
+ messages.append({"role": "user", "content": prompt})
652
+
653
+ client = AsyncOpenAI(api_key=self.openai_api_key)
654
+ resp = await client.chat.completions.create(
655
+ model=model, messages=messages,
656
+ temperature=temperature if temperature is not None else self.temperature,
657
+ max_tokens=self.max_tokens,
658
+ )
659
+ return resp.choices[0].message.content
660
+
661
+ async def _call_openai_structured(
662
+ self,
663
+ prompt: str,
664
+ schema: dict[str, Any],
665
+ system_prompt: str | None,
666
+ ) -> dict[str, Any]:
667
+ """OpenAI JSON Schema 구조화 응답"""
668
+ try:
669
+ from openai import AsyncOpenAI
670
+ except ImportError:
671
+ raise ImportError("pip install openai")
672
+
673
+ messages = []
674
+ if system_prompt:
675
+ messages.append({"role": "system", "content": system_prompt})
676
+ messages.append({"role": "user", "content": prompt})
677
+
678
+ client = AsyncOpenAI(api_key=self.openai_api_key)
679
+ resp = await client.chat.completions.create(
680
+ model="gpt-4o",
681
+ messages=messages,
682
+ temperature=self.temperature,
683
+ max_tokens=self.max_tokens,
684
+ response_format={"type": "json_schema", "json_schema": {
685
+ "name": "structured_output", "strict": True, "schema": schema
686
+ }},
687
+ )
688
+ content = resp.choices[0].message.content
689
+ return json.loads(content)
690
+
691
+ # ── [v2 - 김예슬] OpenAI 호환 provider (upstage·gemini) — base_url만 다름 ──
692
+
693
+ async def _call_openai_compatible(
694
+ self,
695
+ prompt: str,
696
+ system_prompt: str | None,
697
+ temperature: float | None,
698
+ model_tier: str,
699
+ *,
700
+ base_url: str,
701
+ api_key: str,
702
+ ) -> str:
703
+ """OpenAI 호환 Chat Completions 공통 호출 (upstage·gemini가 공유)."""
704
+ try:
705
+ from openai import AsyncOpenAI
706
+ except ImportError:
707
+ raise ImportError("pip install openai")
708
+ model = self.models.get(model_tier, self.default_model)
709
+ messages = []
710
+ if system_prompt:
711
+ messages.append({"role": "system", "content": system_prompt})
712
+ messages.append({"role": "user", "content": prompt})
713
+ client = AsyncOpenAI(api_key=api_key, base_url=base_url)
714
+ async with _hcx_rate_limiter.slot(): # 동시성 상한 + 호출 간격 (429 방지)
715
+ resp = await client.chat.completions.create(
716
+ model=model, messages=messages,
717
+ temperature=temperature if temperature is not None else self.temperature,
718
+ max_tokens=self.max_tokens,
719
+ )
720
+ return resp.choices[0].message.content
721
+
722
+ async def _call_openai_compatible_structured(
723
+ self,
724
+ prompt: str,
725
+ schema: dict[str, Any],
726
+ system_prompt: str | None,
727
+ *,
728
+ base_url: str,
729
+ api_key: str,
730
+ ) -> dict[str, Any]:
731
+ """OpenAI 호환 구조화 응답 공통 호출.
732
+ json_schema strict를 보장 못하는 provider가 있어, json_object 모드 + 스키마를
733
+ 프롬프트 힌트로 주고 파싱한다(response_format 미지원 시 일반 호출로 폴백).
734
+ """
735
+ try:
736
+ from openai import AsyncOpenAI
737
+ except ImportError:
738
+ raise ImportError("pip install openai")
739
+ sys_p = (system_prompt or "")
740
+ sys_p += (
741
+ "\n\n반드시 아래 JSON 스키마에 맞는 JSON 객체 하나만 출력하세요. "
742
+ "설명·코드펜스 없이 JSON만 출력:\n"
743
+ + json.dumps(schema, ensure_ascii=False)
744
+ )
745
+ messages = [
746
+ {"role": "system", "content": sys_p},
747
+ {"role": "user", "content": prompt},
748
+ ]
749
+ model = self.models.get("structured", self.default_model)
750
+ client = AsyncOpenAI(api_key=api_key, base_url=base_url)
751
+ try:
752
+ async with _hcx_rate_limiter.slot(): # 동시성 상한 + 호출 간격 (429 방지)
753
+ resp = await client.chat.completions.create(
754
+ model=model, messages=messages,
755
+ temperature=self.temperature, max_tokens=self.max_tokens,
756
+ response_format={"type": "json_object"},
757
+ )
758
+ except Exception:
759
+ resp = await client.chat.completions.create(
760
+ model=model, messages=messages,
761
+ temperature=self.temperature, max_tokens=self.max_tokens,
762
+ )
763
+ return _parse_json_response(resp.choices[0].message.content)
764
+
765
+ # Upstage (Solar)
766
+ async def _call_upstage(self, prompt, system_prompt, temperature, model_tier) -> str:
767
+ return await self._call_openai_compatible(
768
+ prompt, system_prompt, temperature, model_tier,
769
+ base_url=self.upstage_base_url, api_key=self.upstage_api_key,
770
+ )
771
+
772
+ async def _call_upstage_structured(self, prompt, schema, system_prompt) -> dict[str, Any]:
773
+ return await self._call_openai_compatible_structured(
774
+ prompt, schema, system_prompt,
775
+ base_url=self.upstage_base_url, api_key=self.upstage_api_key,
776
+ )
777
+
778
+ # Gemini (Google) — OpenAI 호환 엔드포인트
779
+ async def _call_gemini(self, prompt, system_prompt, temperature, model_tier) -> str:
780
+ return await self._call_openai_compatible(
781
+ prompt, system_prompt, temperature, model_tier,
782
+ base_url=self.gemini_base_url, api_key=self.gemini_api_key,
783
+ )
784
+
785
+ async def _call_gemini_structured(self, prompt, schema, system_prompt) -> dict[str, Any]:
786
+ return await self._call_openai_compatible_structured(
787
+ prompt, schema, system_prompt,
788
+ base_url=self.gemini_base_url, api_key=self.gemini_api_key,
789
+ )
790
+
791
+
792
+ # ── 유틸리티 ────────────────────────────────────────────────────────────────
793
+
794
+ def _parse_json_response(raw: str) -> dict[str, Any]:
795
+ """
796
+ LLM 응답에서 JSON 추출 + 파싱.
797
+ ```json 코드블록 / 일반 코드블록 / 순수 JSON 3가지 케이스 처리.
798
+ 실패 시 {"raw": 원문} 반환.
799
+ """
800
+ text = raw.strip()
801
+ if "```json" in text:
802
+ text = text.split("```json")[1].split("```")[0].strip()
803
+ elif "```" in text:
804
+ text = text.split("```")[1].split("```")[0].strip()
805
+ try:
806
+ return json.loads(text)
807
+ except json.JSONDecodeError:
808
+ logger.warning(f"JSON 파싱 실패: {text[:200]}")
809
+ return {"raw": raw}