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
32 changes: 25 additions & 7 deletions backend-ai/app/clients/llm_client.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from app.graph.state import AgentState, Intent
from app.rag.prompts import format_legal_context


class LLMClient:
"""GMS LLM API boundary.

This stub keeps tests deterministic. The public method signature should stay
stable when the real GMS API spec is wired in.
The current implementation stays deterministic for tests. The public method
signature should remain stable when the live LLM call is wired in.
"""

def generate_answer(self, state: AgentState) -> str:
Expand All @@ -14,12 +15,29 @@ def generate_answer(self, state: AgentState) -> str:
count = len(state.get("properties", []))
return f"조건에 맞는 매물 {count}개를 찾았습니다."
if intent == Intent.LEGAL_CONSULT:
count = len(state.get("legal_cards", []))
return f"관련 법령 근거 {count}개를 확인했습니다. 실제 계약 전에는 전문가 검토도 함께 권장합니다."
return generate_legal_answer(state)
if intent == Intent.PRICE_ANALYSIS:
return "선택한 매물 또는 지역의 실거래가를 기준으로 시세 적정성을 분석할 수 있습니다."
return "선택한 매물 또는 지역의 실거래가를 기준으로 시세 적정성을 분석했습니다."
if intent == Intent.SAFETY_ANALYSIS:
return "주변 안전시설 밀도와 안전 점수를 기준으로 생활 안전성을 분석할 수 있습니다."
return "주변 안전시설 반경과 안전 점수를 기준으로 생활 안전성을 분석했습니다."
if intent == Intent.HUG_CALC:
return "HUG 보증 간이 계산은 1.5차 범위입니다. MVP에서는 관련 조건 안내까지만 제공합니다."
return "HUG 보증 가입 계산은 1.5차 범위입니다. MVP에서는 관련 조건 안내까지만 제공합니다."
return "질문 의도를 조금 더 구체화해 주세요. 매물 추천, 법률 상담, 시세 분석, 안전 분석을 도와드릴 수 있습니다."


def generate_legal_answer(state: AgentState) -> str:
legal_cards = state.get("legal_cards", [])
if not legal_cards:
return (
"검색된 법령 근거가 없습니다. 질문을 조금 더 구체화하거나 계약서와 상황을 정리해 "
"전문가 검토를 받아보는 것을 권장합니다."
)

legal_context = format_legal_context(legal_cards)

return (
"검색된 법령 근거를 바탕으로 답변드리면 다음과 같습니다.\n\n"
+ legal_context
+ "\n\n위 조항은 질문 상황을 판단할 때 참고할 수 있는 근거입니다. "
"실제 계약 체결이나 분쟁 대응 전에는 계약서 원문과 사실관계를 가지고 전문가 검토를 받는 것을 권장합니다."
)
27 changes: 27 additions & 0 deletions backend-ai/app/rag/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,30 @@
Use Spring Boot tool results for property, price, and safety data.
Do not invent listings or legal facts.
"""


def format_legal_context(legal_cards: list[dict], max_cards: int = 3) -> str:
if not legal_cards:
return "No retrieved legal references."

lines: list[str] = []
for index, card in enumerate(legal_cards[:max_cards], 1):
law_name = card.get("lawName", "")
article_no = card.get("articleNo", "")
title = card.get("title", "")
content = card.get("content", "")
lines.append(f"[{index}] {law_name} {article_no} - {title}\n{content}")
return "\n\n".join(lines)


def build_legal_rag_prompt(question: str, legal_cards: list[dict]) -> str:
context = format_legal_context(legal_cards)
return "\n\n".join(
[
LEGAL_RAG_SYSTEM_PROMPT.strip(),
f"User question:\n{question.strip()}",
f"Retrieved legal references:\n{context}",
"Answer in Korean. Cite the law name and article number from the retrieved references. "
"Add a short explanation and recommend 전문가 검토 for real contracts.",
]
)
5 changes: 5 additions & 0 deletions backend-ai/tests/test_agent_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ def retrieve(self, query: str, top_k: int = 3) -> list[dict]:
assert body["answer"]
assert len(body["legalCards"]) >= 1
card = body["legalCards"][0]
assert card_text_in_answer(body["answer"], card)
assert card["lawName"] == "주택임대차보호법"
assert card["articleNo"]
assert card["title"]
Expand All @@ -94,3 +95,7 @@ def test_classify_intent_examples() -> None:
assert classify_message("이 매물 가격이 비싼 편이야?") == Intent.PRICE_ANALYSIS
assert classify_message("주변 cctv는 괜찮아?") == Intent.SAFETY_ANALYSIS
assert classify_message("hug 보증보험 가능해?") == Intent.HUG_CALC


def card_text_in_answer(answer: str, card: dict) -> bool:
return card["lawName"] in answer and card["articleNo"] in answer
73 changes: 73 additions & 0 deletions backend-ai/tests/test_legal_answer_generation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from app.clients.llm_client import LLMClient
from app.graph.state import Intent
from app.rag.prompts import build_legal_rag_prompt, format_legal_context


LEGAL_CARDS = [
{
"lawName": "주택임대차보호법",
"articleNo": "제3조의2",
"title": "보증금의 회수",
"content": "확정일자를 갖춘 임차인은 경매 또는 공매 시 보증금을 우선변제받을 수 있습니다.",
"score": 0.91,
},
{
"lawName": "전세사기피해자 지원 및 주거안정에 관한 특별법",
"articleNo": "제1조",
"title": "목적",
"content": "전세사기피해자를 지원하고 주거안정을 도모하는 것을 목적으로 합니다.",
"score": 0.82,
},
]


def test_format_legal_context_includes_article_metadata_and_content() -> None:
context = format_legal_context(LEGAL_CARDS)

assert "[1] 주택임대차보호법 제3조의2 - 보증금의 회수" in context
assert "확정일자를 갖춘 임차인" in context
assert "[2] 전세사기피해자 지원 및 주거안정에 관한 특별법 제1조 - 목적" in context


def test_build_legal_rag_prompt_uses_question_and_context() -> None:
prompt = build_legal_rag_prompt("보증금은 어떻게 돌려받나요?", LEGAL_CARDS)

assert "보증금은 어떻게 돌려받나요?" in prompt
assert "Retrieved legal references" in prompt
assert "주택임대차보호법 제3조의2" in prompt
assert "전문가 검토" in prompt


def test_llm_client_generates_grounded_legal_answer_from_cards() -> None:
answer = LLMClient().generate_answer(
{
"user_id": "user-1",
"session_id": None,
"message": "보증금은 어떻게 돌려받나요?",
"context": {},
"intent": Intent.LEGAL_CONSULT,
"legal_cards": LEGAL_CARDS,
}
)

assert "주택임대차보호법 제3조의2" in answer
assert "보증금의 회수" in answer
assert "확정일자" in answer
assert "전문가" in answer


def test_llm_client_does_not_invent_citations_without_cards() -> None:
answer = LLMClient().generate_answer(
{
"user_id": "user-1",
"session_id": None,
"message": "보증금은 어떻게 돌려받나요?",
"context": {},
"intent": Intent.LEGAL_CONSULT,
"legal_cards": [],
}
)

assert "제3조" not in answer
assert "검색된 법령 근거가 없습니다" in answer
assert "전문가" in answer
26 changes: 26 additions & 0 deletions phases/ai-legal-rag/phase4-grounded-legal-answer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Phase 4: Grounded Legal Answer

## Goal
Use retrieved legal cards as grounded context when generating F-3 legal consultation answers. This phase makes the answer text cite the retrieved law/article titles and provide a clear non-legal-advice caution without changing the public chat response schema.

## Files
- `backend-ai/tests/test_legal_answer_generation.py` - tests for legal prompt construction and grounded answer behavior
- `backend-ai/app/rag/prompts.py` - legal RAG prompt/context formatting helpers
- `backend-ai/app/clients/llm_client.py` - legal consultation answer generation using retrieved cards
- `backend-ai/tests/test_agent_chat.py` - chat integration assertion for grounded legal answer text

## Done When
- [ ] Legal answers include retrieved law name, article number/title, and a short explanation
- [ ] Legal answers include a contract review/professional consultation caution
- [ ] No legal answer invents a citation when no legal cards were retrieved
- [ ] Tests pass without live LLM, network, or database access
- [ ] Response shape remains compatible with the existing Spring chat contract

## Architecture Rules
- AI answer generation stays in `backend-ai`; Spring Boot remains an internal HTTP proxy.
- pgvector retrieval remains in `backend-ai` only.
- API keys and secrets must come from environment settings only.
- F-3 MVP is limited to housing lease legal RAG; news RAG, HUG precision judgment, and registry AI remain out of scope.

## Implementation Instructions
Write tests first. Keep the implementation deterministic for unit tests while preserving a clear prompt/context boundary for a future live LLM call. Do not change the `/internal/agent/chat` response schema.