diff --git a/backend-ai/app/clients/llm_client.py b/backend-ai/app/clients/llm_client.py index 0c60a78..e4c59ef 100644 --- a/backend-ai/app/clients/llm_client.py +++ b/backend-ai/app/clients/llm_client.py @@ -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: @@ -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위 조항은 질문 상황을 판단할 때 참고할 수 있는 근거입니다. " + "실제 계약 체결이나 분쟁 대응 전에는 계약서 원문과 사실관계를 가지고 전문가 검토를 받는 것을 권장합니다." + ) diff --git a/backend-ai/app/rag/prompts.py b/backend-ai/app/rag/prompts.py index 435fd1e..b7afdb1 100644 --- a/backend-ai/app/rag/prompts.py +++ b/backend-ai/app/rag/prompts.py @@ -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.", + ] + ) diff --git a/backend-ai/tests/test_agent_chat.py b/backend-ai/tests/test_agent_chat.py index 07c2660..ffe2ecc 100644 --- a/backend-ai/tests/test_agent_chat.py +++ b/backend-ai/tests/test_agent_chat.py @@ -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"] @@ -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 diff --git a/backend-ai/tests/test_legal_answer_generation.py b/backend-ai/tests/test_legal_answer_generation.py new file mode 100644 index 0000000..14a02e6 --- /dev/null +++ b/backend-ai/tests/test_legal_answer_generation.py @@ -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 diff --git a/phases/ai-legal-rag/phase4-grounded-legal-answer.md b/phases/ai-legal-rag/phase4-grounded-legal-answer.md new file mode 100644 index 0000000..ce0d1c3 --- /dev/null +++ b/phases/ai-legal-rag/phase4-grounded-legal-answer.md @@ -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.