diff --git a/backend-ai/app/clients/llm_client.py b/backend-ai/app/clients/llm_client.py index e4c59ef..a4130b1 100644 --- a/backend-ai/app/clients/llm_client.py +++ b/backend-ai/app/clients/llm_client.py @@ -1,21 +1,50 @@ +from collections.abc import Callable +from typing import Any + +import httpx + +from app.core.config import get_settings from app.graph.state import AgentState, Intent -from app.rag.prompts import format_legal_context +from app.rag.prompts import build_legal_rag_prompt, format_legal_context + + +HttpPost = Callable[..., httpx.Response] class LLMClient: """GMS LLM API boundary. - The current implementation stays deterministic for tests. The public method - signature should remain stable when the live LLM call is wired in. + Live calls use an OpenAI-compatible chat-completions endpoint. The + deterministic fallback keeps local development and tests usable when no LLM + key is configured or the provider is temporarily unavailable. """ + def __init__( + self, + api_key: str | None = None, + model: str | None = None, + base_url: str | None = None, + timeout_seconds: float = 20.0, + http_post: HttpPost = httpx.post, + ) -> None: + settings = get_settings() + self.api_key = api_key if api_key is not None else settings.gms_api_key + self.model = model if model is not None else settings.llm_model + configured_base_url = base_url if base_url is not None else settings.llm_base_url + self.base_url = configured_base_url.rstrip("/") + self.timeout_seconds = timeout_seconds + self.http_post = http_post + def generate_answer(self, state: AgentState) -> str: intent = state.get("intent", Intent.FALLBACK) + if intent == Intent.LEGAL_CONSULT: + live_answer = self._generate_live_legal_answer(state) + if live_answer: + return live_answer + return generate_legal_answer(state) if intent == Intent.PROPERTY_SEARCH: count = len(state.get("properties", [])) return f"조건에 맞는 매물 {count}개를 찾았습니다." - if intent == Intent.LEGAL_CONSULT: - return generate_legal_answer(state) if intent == Intent.PRICE_ANALYSIS: return "선택한 매물 또는 지역의 실거래가를 기준으로 시세 적정성을 분석했습니다." if intent == Intent.SAFETY_ANALYSIS: @@ -24,6 +53,70 @@ def generate_answer(self, state: AgentState) -> str: return "HUG 보증 가입 계산은 1.5차 범위입니다. MVP에서는 관련 조건 안내까지만 제공합니다." return "질문 의도를 조금 더 구체화해 주세요. 매물 추천, 법률 상담, 시세 분석, 안전 분석을 도와드릴 수 있습니다." + def _generate_live_legal_answer(self, state: AgentState) -> str | None: + legal_cards = state.get("legal_cards", []) + if not self.api_key or not self.model or not legal_cards: + return None + + prompt = build_legal_rag_prompt(state["message"], legal_cards) + try: + response = self.http_post( + f"{self.base_url}/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json={ + "model": self.model, + "messages": [ + { + "role": "system", + "content": ( + "You answer Korean housing lease questions only from the provided " + "retrieved legal references. If the references are insufficient, " + "say so clearly." + ), + }, + {"role": "user", "content": prompt}, + ], + "max_completion_tokens": 700, + }, + timeout=self.timeout_seconds, + ) + response.raise_for_status() + return extract_chat_completion_text(response.json()) + except (httpx.HTTPError, KeyError, TypeError, ValueError): + return None + + +def extract_chat_completion_text(payload: Any) -> str | None: + if not isinstance(payload, dict): + return None + + choices = payload.get("choices") + if not isinstance(choices, list) or not choices: + return None + + message = choices[0].get("message") + if not isinstance(message, dict): + return None + + content = message.get("content") + if isinstance(content, str): + stripped = content.strip() + return stripped or None + + if isinstance(content, list): + parts = [ + part.get("text", "") + for part in content + if isinstance(part, dict) and part.get("type") in {"text", "output_text"} + ] + stripped = "\n".join(part for part in parts if part).strip() + return stripped or None + + return None + def generate_legal_answer(state: AgentState) -> str: legal_cards = state.get("legal_cards", []) diff --git a/backend-ai/app/core/config.py b/backend-ai/app/core/config.py index db7344f..4ff435b 100644 --- a/backend-ai/app/core/config.py +++ b/backend-ai/app/core/config.py @@ -25,6 +25,7 @@ class Settings(BaseSettings): ) supabase_service_role_key: str = Field(default="", alias="SUPABASE_SERVICE_ROLE_KEY") gms_api_key: str = Field(default="", alias="GMS_API_KEY") + llm_base_url: str = Field(default="https://api.openai.com/v1", alias="LLM_BASE_URL") llm_model: str = Field(default="", alias="LLM_MODEL") embedding_api_key: str = Field(default="", alias="EMBEDDING_API_KEY") embedding_base_url: str = Field( diff --git a/backend-ai/tests/conftest.py b/backend-ai/tests/conftest.py new file mode 100644 index 0000000..359ea86 --- /dev/null +++ b/backend-ai/tests/conftest.py @@ -0,0 +1,4 @@ +import os + + +os.environ["GMS_API_KEY"] = "" diff --git a/backend-ai/tests/test_legal_answer_generation.py b/backend-ai/tests/test_legal_answer_generation.py index 14a02e6..e0b3ae7 100644 --- a/backend-ai/tests/test_legal_answer_generation.py +++ b/backend-ai/tests/test_legal_answer_generation.py @@ -1,4 +1,4 @@ -from app.clients.llm_client import LLMClient +from app.clients.llm_client import LLMClient, extract_chat_completion_text from app.graph.state import Intent from app.rag.prompts import build_legal_rag_prompt, format_legal_context @@ -39,7 +39,7 @@ def test_build_legal_rag_prompt_uses_question_and_context() -> None: def test_llm_client_generates_grounded_legal_answer_from_cards() -> None: - answer = LLMClient().generate_answer( + answer = LLMClient(api_key="").generate_answer( { "user_id": "user-1", "session_id": None, @@ -57,7 +57,7 @@ def test_llm_client_generates_grounded_legal_answer_from_cards() -> None: def test_llm_client_does_not_invent_citations_without_cards() -> None: - answer = LLMClient().generate_answer( + answer = LLMClient(api_key="").generate_answer( { "user_id": "user-1", "session_id": None, @@ -71,3 +71,75 @@ def test_llm_client_does_not_invent_citations_without_cards() -> None: assert "제3조" not in answer assert "검색된 법령 근거가 없습니다" in answer assert "전문가" in answer + + +def test_llm_client_uses_live_chat_completion_when_configured() -> None: + calls = [] + + class FakeResponse: + def raise_for_status(self) -> None: + return None + + def json(self) -> dict: + return { + "choices": [ + { + "message": { + "content": "검색된 법령을 근거로 질문별 맞춤 답변을 생성했습니다." + } + } + ] + } + + def fake_post(*args, **kwargs): + calls.append({"args": args, "kwargs": kwargs}) + return FakeResponse() + + answer = LLMClient( + api_key="test-key", + model="test-model", + base_url="https://example.test/v1", + http_post=fake_post, + ).generate_answer( + { + "user_id": "user-1", + "session_id": None, + "message": "확정일자는 언제 받아야 하나요?", + "context": {}, + "intent": Intent.LEGAL_CONSULT, + "legal_cards": LEGAL_CARDS, + } + ) + + assert answer == "검색된 법령을 근거로 질문별 맞춤 답변을 생성했습니다." + assert calls[0]["args"] == ("https://example.test/v1/chat/completions",) + assert calls[0]["kwargs"]["headers"]["Authorization"] == "Bearer test-key" + assert calls[0]["kwargs"]["json"]["model"] == "test-model" + assert "확정일자는 언제 받아야 하나요?" in calls[0]["kwargs"]["json"]["messages"][1]["content"] + + +def test_extract_chat_completion_text_supports_text_parts() -> None: + assert ( + extract_chat_completion_text( + { + "choices": [ + { + "message": { + "content": [ + {"type": "text", "text": "첫 문장"}, + {"type": "output_text", "text": "둘째 문장"}, + ] + } + } + ] + } + ) + == "첫 문장\n둘째 문장" + ) + + +def test_extract_chat_completion_text_handles_malformed_payloads() -> None: + assert extract_chat_completion_text([]) is None + assert extract_chat_completion_text({}) is None + assert extract_chat_completion_text({"choices": []}) is None + assert extract_chat_completion_text({"choices": [{"message": None}]}) is None