Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 98 additions & 5 deletions backend-ai/app/clients/llm_client.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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", [])
Expand Down
1 change: 1 addition & 0 deletions backend-ai/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions backend-ai/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import os


os.environ["GMS_API_KEY"] = ""
78 changes: 75 additions & 3 deletions backend-ai/tests/test_legal_answer_generation.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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