고객사 사내 LLM 실측: "Expecting ',' delimiter" — 설명 문장 안의 따옴표를 이스케이프하지 않았다. _repair_json 이 문자열 안의 따옴표를 닫는 따옴표(뒤에 , : } ])와 구분해 이스케이프하고, 끝내 못 읽으면 data/llm_jobs/_badjson/ 에 원문을 남긴다. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
393 lines
16 KiB
Python
393 lines
16 KiB
Python
"""LLM 클라이언트 추상화. 백엔드 3종 — api / file / fake.
|
|
|
|
실제 모델 정보(LLM_BASE_URL/LLM_API_KEY/LLM_MODEL)는 환경변수에서만 읽는다 (계획서 §11.5).
|
|
|
|
## 백엔드
|
|
|
|
- `api` : OpenAI 호환 `/chat/completions`. 429/5xx 는 지수 백오프로 재시도한다.
|
|
- `file` : **키 없이 돌리는 입구.** 프롬프트를 `data/llm_jobs/` 에 파일로 내놓고, 누군가(사람이든
|
|
에이전트든)가 응답 JSON 을 채워 넣으면 그걸 읽어 파이프라인을 계속한다.
|
|
키가 없는 상태에서 몇 건만 실제 품질로 돌려보려면 이 경로를 쓴다 → `summarize/jobs.py`
|
|
- `fake` : 스키마만 맞는 더미. 파이프라인 배선 검증용(품질 검증 아님).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import random
|
|
import re
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
from config.settings import settings
|
|
|
|
|
|
class LLMClient:
|
|
def complete_json(self, system: str, user: str) -> dict:
|
|
raise NotImplementedError
|
|
|
|
|
|
class PendingResponse(Exception):
|
|
"""file 백엔드에서 아직 응답이 채워지지 않은 프롬프트 — 실패가 아니라 '대기'다.
|
|
|
|
runner 는 이걸 failed 로 기록하지 않고 대기 건수만 집계한다.
|
|
"""
|
|
|
|
def __init__(self, job_id: str, prompt_path: Path, response_path: Path) -> None:
|
|
super().__init__(f"응답 대기: {job_id} → {response_path}")
|
|
self.job_id = job_id
|
|
self.prompt_path = prompt_path
|
|
self.response_path = response_path
|
|
|
|
|
|
def _repair_json(text: str) -> str:
|
|
"""모델이 흔히 내는 JSON 문법 오류를 고친다 (고객사 사내 LLM 실측: 문자열 안의 따옴표 미이스케이프).
|
|
|
|
- 문자열 안의 `"` 가 닫는 따옴표가 아니면(뒤에 , : } ] 가 안 오면) `\\"` 로 바꾼다
|
|
- 문자열 안의 실제 줄바꿈·탭은 `\\n` `\\t` 로
|
|
- 닫는 괄호 앞의 꼬리 콤마 제거
|
|
- 출력이 잘린 경우: 열린 문자열·괄호를 닫아 준다 (부분 결과라도 스키마 검증에 맡긴다)
|
|
"""
|
|
out: list[str] = []
|
|
stack: list[str] = []
|
|
in_str = False
|
|
i, n = 0, len(text)
|
|
while i < n:
|
|
ch = text[i]
|
|
if in_str:
|
|
if ch == "\\":
|
|
out.append(text[i : i + 2]); i += 2; continue
|
|
if ch == '"':
|
|
j = i + 1
|
|
while j < n and text[j] in " \t\r\n":
|
|
j += 1
|
|
if j >= n or text[j] in ",:}]":
|
|
in_str = False
|
|
out.append(ch)
|
|
else:
|
|
out.append('\\"') # 값 안의 따옴표
|
|
elif ch == "\n":
|
|
out.append("\\n")
|
|
elif ch == "\t":
|
|
out.append("\\t")
|
|
elif ch == "\r":
|
|
pass
|
|
else:
|
|
out.append(ch)
|
|
else:
|
|
if ch == '"':
|
|
in_str = True
|
|
out.append(ch)
|
|
elif ch in "{[":
|
|
stack.append("}" if ch == "{" else "]")
|
|
out.append(ch)
|
|
elif ch in "}]":
|
|
# 꼬리 콤마 제거
|
|
k = len(out) - 1
|
|
while k >= 0 and out[k].strip() == "":
|
|
k -= 1
|
|
if k >= 0 and out[k] == ",":
|
|
del out[k]
|
|
if stack:
|
|
stack.pop()
|
|
out.append(ch)
|
|
else:
|
|
out.append(ch)
|
|
i += 1
|
|
if in_str:
|
|
out.append('"')
|
|
while stack:
|
|
k = len(out) - 1
|
|
while k >= 0 and out[k].strip() == "":
|
|
k -= 1
|
|
if k >= 0 and out[k] == ",":
|
|
del out[k]
|
|
out.append(stack.pop())
|
|
return "".join(out)
|
|
|
|
|
|
def _extract_json(content: str) -> dict:
|
|
"""모델이 코드펜스/서문을 붙이거나 문법이 약간 틀린 JSON 을 내는 경우까지 감안해 뽑아낸다.
|
|
|
|
끝내 못 읽으면 원문을 data/llm_jobs/_badjson/ 에 남기고 JSONDecodeError 를 올린다.
|
|
"""
|
|
try:
|
|
return json.loads(content)
|
|
except json.JSONDecodeError as first:
|
|
s, e = content.find("{"), content.rfind("}")
|
|
candidate = content[s : e + 1] if (s >= 0 and e > s) else content[s:] if s >= 0 else content
|
|
for attempt in (candidate, _repair_json(candidate)):
|
|
try:
|
|
data = json.loads(attempt)
|
|
if isinstance(data, dict):
|
|
return data
|
|
except json.JSONDecodeError:
|
|
continue
|
|
try:
|
|
d = settings.data_llm_jobs / "_badjson"
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
name = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
|
|
(d / f"{name}.txt").write_text(content, encoding="utf-8")
|
|
print(f"[BADJSON] 모델 응답을 JSON 으로 읽지 못함 — 원문: {d / (name + '.txt')}")
|
|
except OSError:
|
|
pass
|
|
raise first
|
|
|
|
|
|
class OpenAICompatClient(LLMClient):
|
|
"""OpenAI 호환 `/chat/completions` 호출. 규격(LLM_PROVIDER)에 따라 헤더만 다르다.
|
|
|
|
- openai : Authorization: Bearer <LLM_API_KEY>
|
|
- fabrix : 고객사 사내 LLM. x-openapi-token(Bearer ...) / x-generative-ai-client /
|
|
x-llm-model-id / x-generative-ai-user-email. body 의 model 은 LLM_BODY_MODEL.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
if not settings.llm_base_url:
|
|
raise RuntimeError("LLM_BASE_URL 환경변수가 필요합니다")
|
|
self.provider = settings.llm_provider
|
|
if self.provider == "fabrix":
|
|
if not (settings.fabrix_client_key and settings.fabrix_openapi_token):
|
|
raise RuntimeError("LLM_PROVIDER=fabrix 는 FABRIX_CLIENT_KEY / FABRIX_OPENAPI_TOKEN 이 필요합니다")
|
|
elif self.provider == "openai":
|
|
if not settings.llm_api_key:
|
|
raise RuntimeError("LLM_BASE_URL / LLM_API_KEY 환경변수가 필요합니다")
|
|
else:
|
|
raise RuntimeError(f"알 수 없는 LLM_PROVIDER: {self.provider} (openai | fabrix)")
|
|
self.base = settings.llm_base_url.rstrip("/")
|
|
self.url = self.base + settings.llm_chat_path
|
|
self.key = settings.llm_api_key
|
|
self.model = settings.llm_model
|
|
# 호출 누적 사용량 — runner가 stats에 실어 보고한다
|
|
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0,
|
|
"retries": 0}
|
|
self._lock = threading.Lock() # 병렬 호출에서 usage 집계가 어긋나지 않게
|
|
|
|
def headers(self) -> dict:
|
|
h = {"Content-Type": "application/json"}
|
|
if self.provider == "fabrix":
|
|
token = settings.fabrix_openapi_token.strip()
|
|
if not token.lower().startswith("bearer "):
|
|
token = "Bearer " + token # 날것으로 보내면 401 (팀원 실측)
|
|
h.update({
|
|
"x-openapi-token": token,
|
|
"x-generative-ai-client": settings.fabrix_client_key,
|
|
"x-llm-model-id": self.model,
|
|
"x-generative-ai-user-email": settings.fabrix_user_email,
|
|
})
|
|
else:
|
|
h["Authorization"] = f"Bearer {self.key}"
|
|
return h
|
|
|
|
def body(self, system: str, user: str) -> dict:
|
|
if settings.llm_merge_system:
|
|
messages = [{"role": "user", "content": system + "\n\n" + user}]
|
|
else:
|
|
messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
|
body = {
|
|
"model": settings.llm_body_model or self.model,
|
|
"temperature": 0.1,
|
|
"messages": messages,
|
|
}
|
|
if settings.llm_json_mode:
|
|
body["response_format"] = {"type": "json_object"}
|
|
if settings.llm_max_tokens > 0:
|
|
body["max_tokens"] = settings.llm_max_tokens
|
|
if "openrouter" in self.base:
|
|
body["usage"] = {"include": True} # OpenRouter 확장 — usage.cost(USD 크레딧) 포함
|
|
return body
|
|
|
|
def _post_once(self, system: str, user: str) -> dict:
|
|
req = urllib.request.Request(
|
|
self.url,
|
|
data=json.dumps(self.body(system, user), ensure_ascii=False).encode("utf-8"),
|
|
headers=self.headers(),
|
|
method="POST",
|
|
)
|
|
with urllib.request.urlopen(req, timeout=settings.llm_timeout_s) as res:
|
|
return json.loads(res.read().decode("utf-8"))
|
|
|
|
def complete_json(self, system: str, user: str) -> dict:
|
|
"""429/5xx 재시도 포함. 동시성을 올리면 429 가 늘어나므로 백오프가 필수다.
|
|
|
|
(실측: 무료 티어에서 순차 실행만으로도 429 로 unit 6건이 failed 로 굳었다.)
|
|
"""
|
|
delay = settings.llm_backoff_base
|
|
last: Exception | None = None
|
|
for attempt in range(settings.llm_max_retries + 1):
|
|
try:
|
|
data = self._post_once(system, user)
|
|
break
|
|
except urllib.error.HTTPError as e:
|
|
last = e
|
|
retryable = e.code == 429 or 500 <= e.code < 600
|
|
if not retryable or attempt >= settings.llm_max_retries:
|
|
raise
|
|
wait = delay
|
|
hdr = (e.headers or {}).get("Retry-After") if hasattr(e, "headers") else None
|
|
if hdr:
|
|
try:
|
|
wait = max(wait, float(hdr))
|
|
except ValueError:
|
|
pass
|
|
time.sleep(min(wait, settings.llm_backoff_max) * (1 + random.random() * 0.25))
|
|
delay *= 2
|
|
with self._lock:
|
|
self.usage["retries"] += 1
|
|
except (urllib.error.URLError, TimeoutError) as e:
|
|
last = e
|
|
if attempt >= settings.llm_max_retries:
|
|
raise
|
|
time.sleep(min(delay, settings.llm_backoff_max))
|
|
delay *= 2
|
|
with self._lock:
|
|
self.usage["retries"] += 1
|
|
else: # pragma: no cover — 위 for 는 break/raise 로만 끝난다
|
|
raise last or RuntimeError("LLM 호출 실패")
|
|
|
|
u = data.get("usage") or {}
|
|
with self._lock:
|
|
self.usage["calls"] += 1
|
|
self.usage["prompt_tokens"] += u.get("prompt_tokens", 0)
|
|
self.usage["completion_tokens"] += u.get("completion_tokens", 0)
|
|
self.usage["cost_usd"] += u.get("cost") or 0.0
|
|
return _extract_json(data["choices"][0]["message"]["content"])
|
|
|
|
|
|
# --------------------------------------------------------------------- file 백엔드
|
|
|
|
JOB_INDEX = "index.jsonl"
|
|
|
|
|
|
def job_id(system: str, user: str) -> str:
|
|
"""프롬프트 내용으로 결정되는 id — 같은 프롬프트는 재실행해도 같은 파일을 가리킨다."""
|
|
return hashlib.sha256((system + "\n\x00\n" + user).encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _meta_from_prompt(user: str) -> dict:
|
|
"""프롬프트 본문에서 작업 종류·프로그램·unit 을 긁어 index.jsonl 에 남긴다."""
|
|
def grab(pattern: str) -> str:
|
|
m = re.search(pattern, user, re.M)
|
|
return m.group(1).strip() if m else ""
|
|
|
|
return {
|
|
"task": grab(r"^\[작업\]\s*(\S+)"),
|
|
"program": grab(r"^\[프로그램\]\s*(\S+)"),
|
|
# UNIT_PROMPT 는 '[단위] unit_id: ...' 형태라 줄 앞에 [단위] 가 붙는다
|
|
"unit_id": grab(r"^\[단위\]\s*unit_id:\s*(\S+)"),
|
|
"unit_type": grab(r"^unit_type:\s*(\w+)"),
|
|
"window": grab(r"지금은 (\d+)번째 창"),
|
|
}
|
|
|
|
|
|
class FileQueueLLM(LLMClient):
|
|
"""프롬프트를 파일로 내놓고 응답 파일을 기다리는 백엔드 (키 불필요).
|
|
|
|
complete_json 은
|
|
- `<id>.response.json` 이 있으면 그 JSON 을 돌려준다 (정상 경로)
|
|
- 없으면 `<id>.prompt.md` 를 쓰고 PendingResponse 를 던진다
|
|
|
|
응답 파일은 사람이 직접 채워도 되고, 코딩 에이전트가 프롬프트를 읽고 채워도 된다.
|
|
`python -m summarize.jobs` 가 목록·조회·검증 저장을 돕는다.
|
|
"""
|
|
|
|
def __init__(self, jobs_dir: Path | None = None, *, emit: bool = True) -> None:
|
|
self.dir = Path(jobs_dir or settings.data_llm_jobs)
|
|
self.emit = emit
|
|
self.dir.mkdir(parents=True, exist_ok=True)
|
|
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0,
|
|
"retries": 0}
|
|
self.pending: list[str] = []
|
|
self.answered: list[str] = []
|
|
self._lock = threading.Lock()
|
|
|
|
def prompt_path(self, jid: str) -> Path:
|
|
return self.dir / f"{jid}.prompt.md"
|
|
|
|
def response_path(self, jid: str) -> Path:
|
|
return self.dir / f"{jid}.response.json"
|
|
|
|
def _write_prompt(self, jid: str, system: str, user: str) -> None:
|
|
meta = _meta_from_prompt(user)
|
|
header = [
|
|
"<!-- abap-indexing LLM 작업. 이 파일은 읽기용이다.",
|
|
f" 응답은 {jid}.response.json 에 JSON 만 써라 (프롬프트 맨 끝 스키마 그대로).",
|
|
" 저장·검증: python -m summarize.jobs answer " + jid + " --file <응답.json>",
|
|
f" task={meta['task']} program={meta['program']} unit={meta['unit_id']} "
|
|
f"window={meta['window'] or '1'} -->",
|
|
"",
|
|
"## SYSTEM", "", system, "", "## USER", "", user, "",
|
|
]
|
|
self.prompt_path(jid).write_text("\n".join(header), encoding="utf-8")
|
|
line = json.dumps({"job_id": jid, **meta,
|
|
"prompt": self.prompt_path(jid).name,
|
|
"response": self.response_path(jid).name}, ensure_ascii=False)
|
|
idx = self.dir / JOB_INDEX
|
|
existing = idx.read_text(encoding="utf-8").splitlines() if idx.exists() else []
|
|
if not any(f'"job_id": "{jid}"' in ln for ln in existing):
|
|
with idx.open("a", encoding="utf-8") as f:
|
|
f.write(line + "\n")
|
|
|
|
def complete_json(self, system: str, user: str) -> dict:
|
|
jid = job_id(system, user)
|
|
rp = self.response_path(jid)
|
|
if rp.exists():
|
|
data = _extract_json(rp.read_text(encoding="utf-8"))
|
|
with self._lock:
|
|
self.usage["calls"] += 1
|
|
self.answered.append(jid)
|
|
return data
|
|
with self._lock:
|
|
if self.emit:
|
|
self._write_prompt(jid, system, user)
|
|
self.pending.append(jid)
|
|
raise PendingResponse(jid, self.prompt_path(jid), rp)
|
|
|
|
|
|
class FakeLLM(LLMClient):
|
|
"""테스트/드라이런용 — 프롬프트 종류([작업] 표식)에 맞는 최소 JSON 을 돌려준다.
|
|
|
|
extract_chunks 는 코드의 첫 실행문 한 줄을 조각 하나로 만든다 (파이프라인 검증용).
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.usage = {"calls": 0, "prompt_tokens": 0, "completion_tokens": 0, "cost_usd": 0.0,
|
|
"retries": 0}
|
|
|
|
def complete_json(self, system: str, user: str) -> dict:
|
|
self.usage["calls"] += 1
|
|
task = ""
|
|
for line in user.splitlines():
|
|
if line.startswith("[작업]"):
|
|
task = line.split("]", 1)[1].strip()
|
|
break
|
|
if task == "program_summary":
|
|
return {"program": "", "business_purpose_ko": "(fake) 프로그램 요약", "confidence": 0.0}
|
|
# extract_chunks — ' 12| code' 형식의 첫 코드 줄을 조각으로
|
|
m = re.search(r"^\s*(\d+)\| (?!FORM |ENDFORM|METHOD |ENDMETHOD|FUNCTION |ENDFUNCTION|MODULE |ENDMODULE)(\S.*)$",
|
|
user, re.M)
|
|
chunks = []
|
|
if m:
|
|
no, text = int(m.group(1)), m.group(2)
|
|
chunks.append({"line_start": no, "line_end": no, "first_line": text, "kind": "other",
|
|
"purpose_ko": "(fake) 조각", "confidence": 0.0})
|
|
return {"unit_purpose_ko": "(fake) unit 요약", "chunks": chunks}
|
|
|
|
|
|
BACKENDS = ("api", "file", "fake")
|
|
|
|
|
|
def create_llm(fake: bool = False, backend: str | None = None, **kwargs) -> LLMClient:
|
|
"""백엔드 선택. `backend` 가 우선이고, 하위호환으로 `fake=True` 도 받는다."""
|
|
name = backend or ("fake" if fake else "api")
|
|
if name not in BACKENDS:
|
|
raise ValueError(f"알 수 없는 LLM 백엔드: {name} (가능: {', '.join(BACKENDS)})")
|
|
if name == "fake":
|
|
return FakeLLM()
|
|
if name == "file":
|
|
return FileQueueLLM(**kwargs)
|
|
return OpenAICompatClient()
|