From db50ec9d465923494f8f6d0a8b4233dab8360a00 Mon Sep 17 00:00:00 2001 From: "byeongwook.choi" Date: Mon, 21 Sep 2026 15:52:29 +0900 Subject: [PATCH] =?UTF-8?q?ping:=20JSON=20=EC=9D=B4=20=EC=95=88=20?= =?UTF-8?q?=EB=82=98=EC=98=A4=EB=A9=B4=20=EC=84=A4=EC=A0=95=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=EC=9D=84=20=EB=B0=94=EA=BF=94=EA=B0=80=EB=A9=B0=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A7=84=EB=8B=A8,=20LLM=5FMERGE=5FSYSTEM?= =?UTF-8?q?=20=EC=98=B5=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 게이트웨이/모델이 system 역할을 무시해 평문으로 답하는 경우(팀원 VM /api/ito + 339 실측) LLM_MERGE_SYSTEM=1 로 system 을 user 앞에 합쳐 보낸다 - summarize.ping 이 (그대로 → merge → merge+json_mode off) 순으로 시도해 되는 .env 값을 알려준다. --real 은 실제 조각 추출 프롬프트로 Co-Authored-By: Claude Fable 5.1 --- .env.example | 1 + config/settings.py | 3 + summarize/llm_client.py | 9 +-- summarize/ping.py | 110 +++++++++++++++++++++++++------------ tests/test_llm_backends.py | 11 ++++ 5 files changed, 95 insertions(+), 39 deletions(-) diff --git a/.env.example b/.env.example index 6e7b9fa..c76dd10 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,7 @@ LLM_TIMEOUT_S=180 #LLM_BODY_MODEL=/mnt/models # body 의 model (FabriX 는 고정값) #LLM_JSON_MODE=1 # response_format 을 서버가 거부(400)하면 0 #LLM_MAX_TOKENS=0 # 0 이면 안 보냄 +#LLM_MERGE_SYSTEM=0 # system 지시를 무시하는 게이트웨이/모델이면 1 (python -m summarize.ping 이 알려준다) #FABRIX_CLIENT_KEY= # x-generative-ai-client #FABRIX_OPENAPI_TOKEN= # x-openapi-token (Bearer 는 코드가 붙임) #FABRIX_USER_EMAIL= # x-generative-ai-user-email (신원 정보) diff --git a/config/settings.py b/config/settings.py index 43a5578..918cad9 100644 --- a/config/settings.py +++ b/config/settings.py @@ -28,6 +28,9 @@ class Settings(BaseSettings): llm_json_mode: bool = True # 출력 토큰 상한. 0 이면 보내지 않음 (OpenRouter 는 잔액 견적에 쓰고, 추론 폭주 방어도 됨) llm_max_tokens: int = 0 + # system 메시지를 user 메시지 앞에 합쳐 하나로 보낸다 — 게이트웨이/모델이 system 역할을 무시할 때 + # (실측 2026-09-21: 팀원 VM 게이트웨이(/api/ito) + 모델 339 가 "JSON 만" system 지시를 무시했다) + llm_merge_system: bool = False # FabriX 헤더 값 — x-generative-ai-client / x-openapi-token(Bearer 접두는 코드가 붙임) / x-generative-ai-user-email fabrix_client_key: str = "" fabrix_openapi_token: str = "" diff --git a/summarize/llm_client.py b/summarize/llm_client.py index 9b41eca..da2d2e1 100644 --- a/summarize/llm_client.py +++ b/summarize/llm_client.py @@ -100,13 +100,14 @@ class OpenAICompatClient(LLMClient): 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": [ - {"role": "system", "content": system}, - {"role": "user", "content": user}, - ], + "messages": messages, } if settings.llm_json_mode: body["response_format"] = {"type": "json_object"} diff --git a/summarize/ping.py b/summarize/ping.py index 55d3b72..20b9410 100644 --- a/summarize/ping.py +++ b/summarize/ping.py @@ -1,11 +1,15 @@ -"""LLM 접속 점검 — Stage 3 를 돌리기 전에 규격(주소·헤더·body)이 맞는지 한 번 호출해 본다. +"""LLM 접속 점검 — Stage 3 를 돌리기 전에 규격(주소·헤더·body)이 맞는지 호출해 본다. - python -m summarize.ping # .env 대로 한 번 호출 + python -m summarize.ping # .env 대로 호출. JSON 이 안 나오면 설정 조합을 바꿔가며 다시 시도 python -m summarize.ping --show # 보낼 주소·헤더(비밀값은 가림)·body 만 출력, 호출 안 함 + python -m summarize.ping --real # 실제 Stage 3 프롬프트(조각 추출)로 호출 — 짧은 시험 프롬프트보다 확실 성공하면 모델 답(JSON)과 usage 를, 실패하면 HTTP 상태와 서버 응답 본문을 그대로 찍는다. 400 이면 대개 body 규격(response_format / model) 문제 → LLM_JSON_MODE=0, LLM_BODY_MODEL 조정. 401/403 이면 헤더(토큰·클라이언트키·이메일) 문제. 404 면 주소(LLM_BASE_URL + LLM_CHAT_PATH). + +JSON 이 안 나오면(모델이 인사말 등 평문으로 답하면) 아래 조합을 차례로 시도해 되는 것을 알려준다: + 1) 그대로 2) LLM_MERGE_SYSTEM=1 (system 을 user 에 합침) 3) 2 + LLM_JSON_MODE=0 """ from __future__ import annotations @@ -16,10 +20,12 @@ import time import urllib.error from config.settings import settings -from summarize.llm_client import OpenAICompatClient +from summarize.llm_client import OpenAICompatClient, _extract_json -SYSTEM = "너는 JSON 만 출력하는 도우미다." -USER = '다음 스키마로만 답해라: {"ok": true, "model_says": "<짧은 인사 한 줄>"}' +SYSTEM = ("너는 ABAP 코드 분석 도구의 일부다. 답은 반드시 JSON 객체 하나만 출력한다. " + "인사말·설명·코드펜스 없이 JSON 만 쓴다.") +USER = ('[작업] ping\n아래 스키마 그대로 JSON 만 출력하라. 다른 문장은 쓰지 마라.\n' + '{"ok": true, "model_says": "<짧은 인사 한 줄>"}') _SECRET_HEADERS = {"authorization", "x-openapi-token", "x-generative-ai-client"} @@ -30,9 +36,36 @@ def _mask(v: str) -> str: return v[:8] + "…" + v[-4:] +def _real_prompt() -> tuple[str, str]: + """실제 조각 추출 프롬프트 한 벌 (DB 에 unit 이 있으면 첫 unit, 없으면 내장 샘플).""" + from summarize import runner + system = runner.SYSTEM_PROMPT if hasattr(runner, "SYSTEM_PROMPT") else SYSTEM + try: + from tests.test_llm_backends import UNIT_PROMPT_SAMPLE # 테스트가 쓰는 샘플 프롬프트 + return system, UNIT_PROMPT_SAMPLE + except Exception: # noqa: BLE001 + return system, USER + + +def _call(client: OpenAICompatClient, system: str, user: str) -> tuple[bool, str, dict]: + """(JSON 파싱 성공?, 본문 또는 오류, raw) — HTTP 오류는 여기서 예외로 올린다.""" + raw = client._post_once(system, user) + try: + content = raw["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError): + return False, "응답 형태가 OpenAI 규격(choices[0].message.content)이 아닙니다: " + + json.dumps(raw, ensure_ascii=False)[:1500], raw + try: + _extract_json(content) + return True, content, raw + except Exception: # noqa: BLE001 + return False, content, raw + + def main() -> None: ap = argparse.ArgumentParser(description="LLM 접속 점검") ap.add_argument("--show", action="store_true", help="요청만 출력하고 호출하지 않음") + ap.add_argument("--real", action="store_true", help="실제 Stage 3 프롬프트로 호출") args = ap.parse_args() try: @@ -41,44 +74,51 @@ def main() -> None: print(f"설정 오류: {e}", file=sys.stderr) sys.exit(2) + system, user = _real_prompt() if args.real else (SYSTEM, USER) headers = {k: (_mask(v) if k.lower() in _SECRET_HEADERS else v) for k, v in client.headers().items()} print(f"규격 : {client.provider}") print(f"주소 : {client.url}") print(f"헤더 : {json.dumps(headers, ensure_ascii=False)}") - body = client.body(SYSTEM, USER) - print(f"body : {json.dumps({k: v for k, v in body.items() if k != 'messages'}, ensure_ascii=False)}") + body = client.body(system, user) + print(f"body : {json.dumps({k: v for k, v in body.items() if k != 'messages'}, ensure_ascii=False)}" + f" messages={[m['role'] for m in body['messages']]}") if args.show: return - t0 = time.time() - try: - raw = client._post_once(SYSTEM, USER) - except urllib.error.HTTPError as e: - text = e.read().decode("utf-8", errors="replace") - print(f"\n실패 HTTP {e.code} ({int((time.time() - t0) * 1000)} ms)") - print(f"서버 응답: {text[:2000]}") - sys.exit(1) - except Exception as e: # noqa: BLE001 - print(f"\n실패 {type(e).__name__}: {e}") - sys.exit(1) + # 시도 조합: (merge_system, json_mode). 첫 조합은 .env 그대로 + base = (settings.llm_merge_system, settings.llm_json_mode) + combos = [base] + [c for c in [(True, True), (True, False), (False, False)] if c != base] + for i, (merge, jmode) in enumerate(combos, 1): + settings.llm_merge_system, settings.llm_json_mode = merge, jmode + label = f"LLM_MERGE_SYSTEM={int(merge)} LLM_JSON_MODE={int(jmode)}" + print(f"\n[{i}/{len(combos)}] {label}" + (" (.env 그대로)" if i == 1 else "")) + t0 = time.time() + try: + ok, text, raw = _call(client, system, user) + except urllib.error.HTTPError as e: + body_text = e.read().decode("utf-8", errors="replace") + print(f" 실패 HTTP {e.code} ({int((time.time() - t0) * 1000)} ms) 서버 응답: {body_text[:800]}") + if e.code in (401, 403, 404): + sys.exit(1) # 헤더/주소 문제는 조합을 바꿔도 소용없다 + continue + except Exception as e: # noqa: BLE001 + print(f" 실패 {type(e).__name__}: {e}") + sys.exit(1) + ms = int((time.time() - t0) * 1000) + print(f" 응답 ({ms} ms): {text[:300].replace(chr(10), ' ')}") + print(f" usage: {json.dumps(raw.get('usage') or {}, ensure_ascii=False)}") + if ok: + print(" JSON 파싱: OK") + if i == 1: + print("\n결론: 지금 .env 그대로 Stage 3 를 돌리면 된다.") + else: + print(f"\n결론: .env 에 다음 두 줄을 넣어라 (그 뒤 python -m summarize.ping 으로 재확인):\n" + f" LLM_MERGE_SYSTEM={int(merge)}\n LLM_JSON_MODE={int(jmode)}") + return + print(" JSON 파싱: 실패 — 모델이 JSON 대신 평문으로 답했다") - ms = int((time.time() - t0) * 1000) - print(f"\n성공 ({ms} ms)") - try: - content = raw["choices"][0]["message"]["content"] - except (KeyError, IndexError, TypeError): - print("응답 형태가 OpenAI 규격(choices[0].message.content)이 아닙니다:") - print(json.dumps(raw, ensure_ascii=False)[:2000]) - sys.exit(1) - print(f"모델 답 : {content[:500]}") - print(f"usage : {json.dumps(raw.get('usage') or {}, ensure_ascii=False)}") - from summarize.llm_client import _extract_json - try: - _extract_json(content) - print("JSON 파싱: OK") - except Exception as e: # noqa: BLE001 - print(f"JSON 파싱: 실패 ({e}) — LLM_JSON_MODE 와 프롬프트 확인") - sys.exit(1) + print("\n결론: 어떤 조합으로도 JSON 이 안 나온다. 위 응답을 그대로 전달하라 (모델 번호를 바꾸거나 프롬프트를 손봐야 한다).") + sys.exit(1) if __name__ == "__main__": diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index d620dab..40e8b0a 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -263,3 +263,14 @@ def test_provider_requires_its_credentials(monkeypatch): _configure(monkeypatch, llm_provider="bogus", llm_api_key="k") with pytest.raises(RuntimeError): llm_client.OpenAICompatClient() + + +def test_merge_system_folds_system_into_user(monkeypatch): + from config.settings import settings + from summarize import llm_client + _configure(monkeypatch, llm_api_key="k") + monkeypatch.setattr(settings, "llm_merge_system", True) + msgs = llm_client.OpenAICompatClient().body("SYS", "USR")["messages"] + assert msgs == [{"role": "user", "content": "SYS\n\nUSR"}] + monkeypatch.setattr(settings, "llm_merge_system", False) + assert [m["role"] for m in llm_client.OpenAICompatClient().body("SYS", "USR")["messages"]] == ["system", "user"]