structverify 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- structverify/__init__.py +83 -0
- structverify/adaptation/__init__.py +0 -0
- structverify/adaptation/adapter_trainer.py +341 -0
- structverify/adaptation/feedback_store.py +31 -0
- structverify/adaptation/kosis_crawler.py +317 -0
- structverify/adaptation/sample_builder.py +149 -0
- structverify/adaptation/synthetic_generator.py +320 -0
- structverify/adaptation/update_embeddings.py +178 -0
- structverify/agent/__init__.py +21 -0
- structverify/agent/builder_agent.py +226 -0
- structverify/agent/conformance_agent.py +171 -0
- structverify/agent/dependency_planner.py +151 -0
- structverify/agent/indexing_agent.py +153 -0
- structverify/agent/indexing_planner.py +169 -0
- structverify/agent/integration_example.py +182 -0
- structverify/agent/loop.py +1165 -0
- structverify/agent/memory.py +207 -0
- structverify/agent/planner.py +817 -0
- structverify/agent/prompts/__init__.py +15 -0
- structverify/agent/prompts/planner_prompts.py +219 -0
- structverify/agent/prompts/reflect_prompts.py +387 -0
- structverify/agent/reflect.py +227 -0
- structverify/agent/runtime_agent.py +1272 -0
- structverify/agent/schemas.py +262 -0
- structverify/agent/source_profiler.py +229 -0
- structverify/agent/tools/__init__.py +64 -0
- structverify/agent/tools/base.py +222 -0
- structverify/agent/tools/calculate.py +244 -0
- structverify/agent/tools/catalog_search.py +859 -0
- structverify/agent/tools/deep_explore.py +293 -0
- structverify/agent/tools/explore_catalog.py +423 -0
- structverify/agent/tools/fetch_evidence.py +922 -0
- structverify/agent/tools/finish.py +423 -0
- structverify/agent/tools/meta_explore.py +267 -0
- structverify/agent/tools/query_rewriter.py +134 -0
- structverify/agent/tools/read_original.py +144 -0
- structverify/agent/tools/replan.py +365 -0
- structverify/agent/workspace.py +958 -0
- structverify/api.py +804 -0
- structverify/config/default.yaml +350 -0
- structverify/core/__init__.py +0 -0
- structverify/core/config_loader.py +30 -0
- structverify/core/pipeline.py +280 -0
- structverify/core/schemas.py +362 -0
- structverify/detection/__init__.py +26 -0
- structverify/detection/_config.py +163 -0
- structverify/detection/_llm.py +24 -0
- structverify/detection/candidate/__init__.py +1 -0
- structverify/detection/candidate/heuristic.py +60 -0
- structverify/detection/candidate/llm.py +51 -0
- structverify/detection/candidate_scorer.py +81 -0
- structverify/detection/claim_detector.py +164 -0
- structverify/detection/claims/__init__.py +1 -0
- structverify/detection/claims/worthiness.py +142 -0
- structverify/detection/domain/__init__.py +1 -0
- structverify/detection/domain/classify.py +84 -0
- structverify/detection/domain/preview.py +36 -0
- structverify/detection/domain/registry.py +99 -0
- structverify/detection/domain_classifier.py +75 -0
- structverify/detection/prompts/__init__.py +1 -0
- structverify/detection/prompts/candidate.py +38 -0
- structverify/detection/prompts/claim_worthiness.py +48 -0
- structverify/detection/prompts/domain.py +41 -0
- structverify/detection/prompts/schema.py +508 -0
- structverify/detection/prompts_loader.py +167 -0
- structverify/detection/schema/__init__.py +1 -0
- structverify/detection/schema/expand.py +83 -0
- structverify/detection/schema/induce.py +441 -0
- structverify/detection/schema/regenerate.py +162 -0
- structverify/detection/schema/temporal_hints.py +130 -0
- structverify/detection/schema/validate.py +193 -0
- structverify/detection/schema_inductor.py +112 -0
- structverify/detection/synthetic_generator.py +270 -0
- structverify/explanation/__init__.py +0 -0
- structverify/explanation/_config.py +18 -0
- structverify/explanation/_llm.py +25 -0
- structverify/explanation/explainer.py +183 -0
- structverify/explanation/fallback.py +29 -0
- structverify/explanation/formatters.py +75 -0
- structverify/explanation/prompts/__init__.py +1 -0
- structverify/explanation/prompts/match.py +27 -0
- structverify/explanation/prompts/mismatch.py +20 -0
- structverify/explanation/prompts/multihop.py +16 -0
- structverify/explanation/prompts/unverifiable.py +17 -0
- structverify/graph/__init__.py +0 -0
- structverify/graph/claim_graph.py +226 -0
- structverify/graph/document_graph.py +487 -0
- structverify/graph/graph_builder.py +238 -0
- structverify/graph/graph_multihop.py +335 -0
- structverify/graph/graph_store.py +281 -0
- structverify/graph/provenance.py +52 -0
- structverify/memory/__init__.py +44 -0
- structverify/memory/agent_memory.py +142 -0
- structverify/memory/embedder.py +69 -0
- structverify/memory/exemplar_store.py +241 -0
- structverify/memory/normalizer.py +91 -0
- structverify/memory/schema.py +119 -0
- structverify/memory/storage/__init__.py +29 -0
- structverify/memory/storage/jsonl_store.py +117 -0
- structverify/memory/working_memory.py +370 -0
- structverify/preprocessing/Dockerfile.scraper +27 -0
- structverify/preprocessing/__init__.py +0 -0
- structverify/preprocessing/extractor.py +574 -0
- structverify/preprocessing/pdf/__init__.py +16 -0
- structverify/preprocessing/pdf/fields.py +95 -0
- structverify/preprocessing/pdf/markdown.py +107 -0
- structverify/preprocessing/pdf/models.py +34 -0
- structverify/preprocessing/pdf/ocr.py +172 -0
- structverify/preprocessing/pdf/pipeline.py +74 -0
- structverify/preprocessing/pdf/reader.py +119 -0
- structverify/preprocessing/pdf/scoring.py +61 -0
- structverify/preprocessing/scraper_sandbox.py +561 -0
- structverify/preprocessing/segmenter.py +48 -0
- structverify/preprocessing/sir_builder.py +240 -0
- structverify/progress.py +591 -0
- structverify/retrieval/__init__.py +0 -0
- structverify/retrieval/base.py +208 -0
- structverify/retrieval/base_connector.py +85 -0
- structverify/retrieval/catalog_ranker.py +300 -0
- structverify/retrieval/catalog_search.py +583 -0
- structverify/retrieval/chunking.py +92 -0
- structverify/retrieval/custom_csv_source.py +386 -0
- structverify/retrieval/custom_db_source.py +396 -0
- structverify/retrieval/custom_docs_source.py +152 -0
- structverify/retrieval/dimension_resolver.py +281 -0
- structverify/retrieval/evidence_subgraph.py +63 -0
- structverify/retrieval/kosis_connector.py +1192 -0
- structverify/retrieval/kosis_relevance.py +142 -0
- structverify/retrieval/kosis_source.py +1541 -0
- structverify/retrieval/query_builder.py +72 -0
- structverify/retrieval/registry.py +133 -0
- structverify/retrieval/relevance_judge.py +141 -0
- structverify/retrieval/row_matcher.py +267 -0
- structverify/storage/__init__.py +0 -0
- structverify/storage/db_manager.py +157 -0
- structverify/storage/dwh_manager.py +92 -0
- structverify/storage/init_db.py +99 -0
- structverify/storage/raw_storage.py +29 -0
- structverify/training/__init__.py +26 -0
- structverify/training/curator.py +124 -0
- structverify/training/dataset.py +134 -0
- structverify/training/doctor.py +99 -0
- structverify/training/evalgate.py +96 -0
- structverify/training/generate.py +101 -0
- structverify/training/loop.py +116 -0
- structverify/training/recipe/train_mlx.py +99 -0
- structverify/training/recipe/train_qlora.py +104 -0
- structverify/training/tasks.py +79 -0
- structverify/utils/__init__.py +0 -0
- structverify/utils/embedding_client.py +248 -0
- structverify/utils/llm_client.py +809 -0
- structverify/utils/logger.py +81 -0
- structverify/verification/__init__.py +0 -0
- structverify/verification/_config.py +45 -0
- structverify/verification/adapters.py +405 -0
- structverify/verification/conformance.py +117 -0
- structverify/verification/decide_verdict.py +216 -0
- structverify/verification/decide_verdict_agent.py +454 -0
- structverify/verification/growth_diff.py +267 -0
- structverify/verification/row_match.py +345 -0
- structverify/verification/units.py +64 -0
- structverify/verification/verdict_thresholds.py +232 -0
- structverify/verification/verifier.py +84 -0
- structverify-0.3.0.dist-info/METADATA +903 -0
- structverify-0.3.0.dist-info/RECORD +168 -0
- structverify-0.3.0.dist-info/WHEEL +5 -0
- structverify-0.3.0.dist-info/licenses/LICENSE +21 -0
- structverify-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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}
|