From e51cff7a9b6088fb25b92b336f4a031f2aefef60 Mon Sep 17 00:00:00 2001 From: crolvlee Date: Tue, 23 Jun 2026 10:05:11 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(ai):=20=EB=A7=A4=EB=AC=BC=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20Text-to-SQL=20=EA=B5=AC=ED=98=84=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-ai/app/clients/llm_client.py | 44 +++++++++++++++-- backend-ai/app/clients/spring_client.py | 29 ++---------- backend-ai/app/clients/supabase_client.py | 47 +++++++++++++++++++ backend-ai/app/core/config.py | 6 ++- backend-ai/app/graph/nodes/property_search.py | 14 +++--- 5 files changed, 101 insertions(+), 39 deletions(-) diff --git a/backend-ai/app/clients/llm_client.py b/backend-ai/app/clients/llm_client.py index a4130b1..67c8c2d 100644 --- a/backend-ai/app/clients/llm_client.py +++ b/backend-ai/app/clients/llm_client.py @@ -1,3 +1,4 @@ +import json from collections.abc import Callable from typing import Any @@ -10,11 +11,26 @@ HttpPost = Callable[..., httpx.Response] +PROPERTY_CRITERIA_PROMPT = """\ +다음 메시지에서 매물 검색 조건을 JSON으로 추출해줘. -class LLMClient: - """GMS LLM API boundary. +메시지: {message} + +아래 필드만 포함해. 언급이 없으면 null로 해: +- sigungu: 시군구 이름 (예: "관악구", "강남구") +- dong: 동 이름 (예: "신림동") +- property_type: ONE_ROOM | OFFICETEL | VILLA | APARTMENT | MULTI_FAMILY +- transaction_type: MONTHLY_RENT | JEONSE | SALE +- max_deposit: 최대 보증금 (원 단위, 숫자만) +- max_monthly_rent: 최대 월세 (원 단위, 숫자만) +- max_price: 최대 매매가 (원 단위, 숫자만) + +JSON만 반환해. 설명 없이.\ +""" - Live calls use an OpenAI-compatible chat-completions endpoint. The + +class LLMClient: + """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. """ @@ -35,6 +51,28 @@ def __init__( self.timeout_seconds = timeout_seconds self.http_post = http_post + def extract_property_criteria(self, message: str) -> dict[str, Any]: + prompt = PROPERTY_CRITERIA_PROMPT.format(message=message) + 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": "user", "content": prompt}], + "max_tokens": 256, + }, + timeout=self.timeout_seconds, + ) + response.raise_for_status() + text = extract_chat_completion_text(response.json()) or "{}" + return json.loads(text) + except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError, ValueError): + return {} + def generate_answer(self, state: AgentState) -> str: intent = state.get("intent", Intent.FALLBACK) if intent == Intent.LEGAL_CONSULT: diff --git a/backend-ai/app/clients/spring_client.py b/backend-ai/app/clients/spring_client.py index d905b31..ea0f265 100644 --- a/backend-ai/app/clients/spring_client.py +++ b/backend-ai/app/clients/spring_client.py @@ -4,38 +4,15 @@ class SpringClient: - """Client boundary for Spring Boot domain APIs. - - The MVP skeleton returns deterministic stub data. Replace these methods with - httpx calls once the Spring Boot internal tool endpoints are finalized. - """ + """Client boundary for Spring Boot domain APIs.""" def __init__(self) -> None: self.base_url = get_settings().spring_api_base_url.rstrip("/") def search_properties(self, message: str, context: dict[str, Any]) -> dict[str, Any]: return { - "properties": [ - { - "id": 1, - "buildingName": "관악 샘플 원룸", - "address": "서울특별시 관악구 대학동", - "propertyType": "ONE_ROOM", - "transactionType": "MONTHLY_RENT", - "deposit": 5000000, - "monthlyRent": 480000, - "areaM2": 22.5, - "safetyScore": 78, - "latitude": 37.470123, - "longitude": 126.936456, - } - ], - "meta": { - "baseUrl": self.base_url, - "query": message, - "selectedPropertyId": context.get("selectedPropertyId"), - "stub": True, - }, + "properties": [], + "meta": {"baseUrl": self.base_url, "query": message, "stub": True}, } def analyze_price(self, message: str, context: dict[str, Any]) -> dict[str, Any]: diff --git a/backend-ai/app/clients/supabase_client.py b/backend-ai/app/clients/supabase_client.py index 8bc068f..76b642f 100644 --- a/backend-ai/app/clients/supabase_client.py +++ b/backend-ai/app/clients/supabase_client.py @@ -59,6 +59,53 @@ def similarity_search_legal_documents( cursor.execute(sql, (vector_literal, vector_literal, top_k)) return list(cursor.fetchall()) + def search_properties(self, criteria: dict[str, Any], limit: int = 20) -> list[dict[str, Any]]: + conditions = ["is_active = true"] + params: list[Any] = [] + + field_map = { + "sigungu": ("sigungu = %s", "sigungu"), + "dong": ("dong = %s", "dong"), + "property_type": ("property_type = %s", "property_type"), + "transaction_type": ("transaction_type = %s", "transaction_type"), + } + range_map = { + "max_deposit": "deposit <= %s", + "max_monthly_rent": "monthly_rent <= %s", + "max_price": "price <= %s", + } + + for key, (condition, _) in field_map.items(): + if criteria.get(key): + conditions.append(condition) + params.append(criteria[key]) + + for key, condition in range_map.items(): + if criteria.get(key) is not None: + conditions.append(condition) + params.append(criteria[key]) + + where_clause = " AND ".join(conditions) + sql = f""" + SELECT id, title, building_name, address, property_type, transaction_type, + deposit, monthly_rent, price, area_m2, floor, latitude, longitude + FROM public.properties + WHERE {where_clause} + ORDER BY created_at DESC + LIMIT %s + """ + params.append(limit) + + with psycopg.connect( + self.database_url, + row_factory=dict_row, + connect_timeout=self.connect_timeout_seconds, + ) as conn: + with conn.cursor() as cursor: + cursor.execute("set local statement_timeout = %s", (self.statement_timeout_ms,)) + cursor.execute(sql, params) + return list(cursor.fetchall()) + def upsert_legal_document_chunks(self, rows: list[dict[str, Any]]) -> int: if not rows: return 0 diff --git a/backend-ai/app/core/config.py b/backend-ai/app/core/config.py index 4ff435b..779c69b 100644 --- a/backend-ai/app/core/config.py +++ b/backend-ai/app/core/config.py @@ -23,9 +23,11 @@ class Settings(BaseSettings): default=5000, alias="SUPABASE_STATEMENT_TIMEOUT_MS", ) - supabase_service_role_key: str = Field(default="", alias="SUPABASE_SERVICE_ROLE_KEY") + 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_base_url: str = Field(default="", 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/app/graph/nodes/property_search.py b/backend-ai/app/graph/nodes/property_search.py index 94e708a..6557d30 100644 --- a/backend-ai/app/graph/nodes/property_search.py +++ b/backend-ai/app/graph/nodes/property_search.py @@ -1,18 +1,16 @@ -from app.clients.spring_client import SpringClient +from app.clients.llm_client import LLMClient +from app.clients.supabase_client import SupabaseVectorClient from app.graph.state import AgentState def property_search(state: AgentState) -> AgentState: - client = SpringClient() - result = client.search_properties( - message=state["message"], - context=state.get("context", {}), - ) + criteria = LLMClient().extract_property_criteria(state["message"]) + properties = SupabaseVectorClient().search_properties(criteria) return { **state, - "properties": result["properties"], + "properties": properties, "tool_results": { **state.get("tool_results", {}), - "propertySearch": result["meta"], + "propertySearch": {"count": len(properties), "criteria": criteria}, }, } From 6d5ee51bad82eef2b2850ebf3a2a0e2093a79948 Mon Sep 17 00:00:00 2001 From: crolvlee Date: Tue, 23 Jun 2026 14:00:29 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(ai):=20GMS=20API=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EB=B0=8F=20psycopg=20SET=20=EA=B5=AC?= =?UTF-8?q?=EB=AC=B8=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-ai/app/clients/llm_client.py | 2 +- backend-ai/app/clients/supabase_client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend-ai/app/clients/llm_client.py b/backend-ai/app/clients/llm_client.py index 67c8c2d..825c40c 100644 --- a/backend-ai/app/clients/llm_client.py +++ b/backend-ai/app/clients/llm_client.py @@ -63,7 +63,7 @@ def extract_property_criteria(self, message: str) -> dict[str, Any]: json={ "model": self.model, "messages": [{"role": "user", "content": prompt}], - "max_tokens": 256, + "max_completion_tokens": 256, }, timeout=self.timeout_seconds, ) diff --git a/backend-ai/app/clients/supabase_client.py b/backend-ai/app/clients/supabase_client.py index 76b642f..d683f59 100644 --- a/backend-ai/app/clients/supabase_client.py +++ b/backend-ai/app/clients/supabase_client.py @@ -102,7 +102,7 @@ def search_properties(self, criteria: dict[str, Any], limit: int = 20) -> list[d connect_timeout=self.connect_timeout_seconds, ) as conn: with conn.cursor() as cursor: - cursor.execute("set local statement_timeout = %s", (self.statement_timeout_ms,)) + cursor.execute(f"SET LOCAL statement_timeout = {int(self.statement_timeout_ms)}") cursor.execute(sql, params) return list(cursor.fetchall()) From 9df8b7cd877d5822e77aeb2259375ee1543e274b Mon Sep 17 00:00:00 2001 From: crolvlee Date: Tue, 23 Jun 2026 15:39:21 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat(ai):=20=EC=9D=98=EB=8F=84=20=EB=B6=84?= =?UTF-8?q?=EB=A5=98=EB=A5=BC=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EB=A7=A4?= =?UTF-8?q?=EC=B9=AD=EC=97=90=EC=84=9C=20LLM=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-ai/app/clients/llm_client.py | 39 ++++++ backend-ai/app/graph/builder.py | 1 + backend-ai/app/graph/nodes/classify_intent.py | 32 ++++- backend-ai/app/graph/state.py | 1 + backend-ai/tests/test_agent_chat.py | 14 +-- backend-ai/tests/test_classify_intent.py | 117 ++++++++++++++++++ 6 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 backend-ai/tests/test_classify_intent.py diff --git a/backend-ai/app/clients/llm_client.py b/backend-ai/app/clients/llm_client.py index 825c40c..009fbc7 100644 --- a/backend-ai/app/clients/llm_client.py +++ b/backend-ai/app/clients/llm_client.py @@ -11,6 +11,23 @@ HttpPost = Callable[..., httpx.Response] +CLASSIFY_INTENT_PROMPT = """\ +다음 사용자 메시지를 읽고, 부동산 AI 어시스턴트 관점에서 의도를 분류해줘. + +사용자 메시지: {message} + +다음 intent 중 하나를 선택해: +- PROPERTY_SEARCH: 매물 추천·검색·조건 필터링 (지역, 가격, 면적, 타입 등) +- LEGAL_CONSULT: 임대차 법률, 계약, 보증금, 대항력, 갱신 등 법률 질문 +- PRICE_ANALYSIS: 특정 지역·매물의 시세·실거래가·가격 적정성 분석 +- SAFETY_ANALYSIS: 주변 치안, CCTV, 안전시설, 범죄율 등 생활 안전 분석 +- HUG_CALC: HUG 보증보험 가입 가능 여부 계산 +- GENERAL_CHAT: 인사, 잡담, 부동산과 무관한 질문 + +JSON만 반환해. 설명 없이: +{{"intent": "...", "reasoning": "분류 이유 한 줄"}}\ +""" + PROPERTY_CRITERIA_PROMPT = """\ 다음 메시지에서 매물 검색 조건을 JSON으로 추출해줘. @@ -51,6 +68,28 @@ def __init__( self.timeout_seconds = timeout_seconds self.http_post = http_post + def classify(self, message: str) -> dict[str, Any] | None: + prompt = CLASSIFY_INTENT_PROMPT.format(message=message) + 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": "user", "content": prompt}], + "max_completion_tokens": 128, + }, + timeout=self.timeout_seconds, + ) + response.raise_for_status() + text = extract_chat_completion_text(response.json()) or "{}" + return json.loads(text) + except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError, ValueError): + return None + def extract_property_criteria(self, message: str) -> dict[str, Any]: prompt = PROPERTY_CRITERIA_PROMPT.format(message=message) try: diff --git a/backend-ai/app/graph/builder.py b/backend-ai/app/graph/builder.py index 28bba9a..2656181 100644 --- a/backend-ai/app/graph/builder.py +++ b/backend-ai/app/graph/builder.py @@ -17,6 +17,7 @@ def route_by_intent(state: AgentState) -> str: Intent.PRICE_ANALYSIS: "price_analysis", Intent.SAFETY_ANALYSIS: "safety_analysis", Intent.HUG_CALC: "fallback", + Intent.GENERAL_CHAT: "fallback", Intent.FALLBACK: "fallback", }[intent] diff --git a/backend-ai/app/graph/nodes/classify_intent.py b/backend-ai/app/graph/nodes/classify_intent.py index 2d2bb48..cec1730 100644 --- a/backend-ai/app/graph/nodes/classify_intent.py +++ b/backend-ai/app/graph/nodes/classify_intent.py @@ -1,6 +1,23 @@ +from typing import Literal + +from pydantic import BaseModel, ValidationError + +from app.clients.llm_client import LLMClient from app.graph.state import AgentState, Intent +class RouteDecision(BaseModel): + intent: Literal[ + "PROPERTY_SEARCH", + "LEGAL_CONSULT", + "PRICE_ANALYSIS", + "SAFETY_ANALYSIS", + "HUG_CALC", + "GENERAL_CHAT", + ] + reasoning: str + + LEGAL_KEYWORDS = [ "법", "계약", @@ -17,7 +34,7 @@ PROPERTY_KEYWORDS = ["추천", "찾아", "매물", "원룸", "오피스텔", "아파트", "월세", "전세", "관악구"] -def classify_message(message: str) -> Intent: +def classify_intent_fallback(message: str) -> Intent: normalized = message.lower() if any(keyword in normalized for keyword in LEGAL_KEYWORDS): return Intent.LEGAL_CONSULT @@ -32,5 +49,16 @@ def classify_message(message: str) -> Intent: return Intent.FALLBACK +def classify_intent_llm(message: str) -> Intent: + raw = LLMClient().classify(message) + if raw is None: + return classify_intent_fallback(message) + try: + decision = RouteDecision.model_validate(raw) + return Intent(decision.intent) + except (ValueError, ValidationError): + return classify_intent_fallback(message) + + def classify_intent(state: AgentState) -> AgentState: - return {**state, "intent": classify_message(state["message"])} + return {**state, "intent": classify_intent_llm(state["message"])} diff --git a/backend-ai/app/graph/state.py b/backend-ai/app/graph/state.py index 55cf8a4..5fb98dd 100644 --- a/backend-ai/app/graph/state.py +++ b/backend-ai/app/graph/state.py @@ -8,6 +8,7 @@ class Intent(StrEnum): PRICE_ANALYSIS = "PRICE_ANALYSIS" SAFETY_ANALYSIS = "SAFETY_ANALYSIS" HUG_CALC = "HUG_CALC" + GENERAL_CHAT = "GENERAL_CHAT" FALLBACK = "FALLBACK" diff --git a/backend-ai/tests/test_agent_chat.py b/backend-ai/tests/test_agent_chat.py index ffe2ecc..9a47dfd 100644 --- a/backend-ai/tests/test_agent_chat.py +++ b/backend-ai/tests/test_agent_chat.py @@ -3,7 +3,7 @@ from app.api.routes import get_agent_graph from app.core.config import get_settings from app.graph.nodes import legal_rag as legal_rag_module -from app.graph.nodes.classify_intent import classify_message +from app.graph.nodes.classify_intent import classify_intent_fallback from app.graph.state import Intent from app.main import app @@ -89,12 +89,12 @@ def retrieve(self, query: str, top_k: int = 3) -> list[dict]: assert isinstance(card["score"], (int, float)) -def test_classify_intent_examples() -> None: - assert classify_message("관악구 보증금 5천 이하 원룸 추천해줘") == Intent.PROPERTY_SEARCH - assert classify_message("계약 전에 법을 확인하고 싶어") == Intent.LEGAL_CONSULT - assert classify_message("이 매물 가격이 비싼 편이야?") == Intent.PRICE_ANALYSIS - assert classify_message("주변 cctv는 괜찮아?") == Intent.SAFETY_ANALYSIS - assert classify_message("hug 보증보험 가능해?") == Intent.HUG_CALC +def test_classify_intent_fallback_examples() -> None: + assert classify_intent_fallback("관악구 보증금 5천 이하 원룸 추천해줘") == Intent.PROPERTY_SEARCH + assert classify_intent_fallback("계약 전에 법을 확인하고 싶어") == Intent.LEGAL_CONSULT + assert classify_intent_fallback("이 매물 가격이 비싼 편이야?") == Intent.PRICE_ANALYSIS + assert classify_intent_fallback("주변 cctv는 괜찮아?") == Intent.SAFETY_ANALYSIS + assert classify_intent_fallback("hug 보증보험 가능해?") == Intent.HUG_CALC def card_text_in_answer(answer: str, card: dict) -> bool: diff --git a/backend-ai/tests/test_classify_intent.py b/backend-ai/tests/test_classify_intent.py new file mode 100644 index 0000000..d66f16a --- /dev/null +++ b/backend-ai/tests/test_classify_intent.py @@ -0,0 +1,117 @@ +import json +from unittest.mock import MagicMock + +import pytest + +from app.clients.llm_client import LLMClient +from app.graph.nodes.classify_intent import ( + RouteDecision, + classify_intent_fallback, + classify_intent_llm, +) +from app.graph.state import Intent + + +def _make_llm_response(intent: str, reasoning: str = "test") -> dict: + """LLMClient.classify()가 반환할 dict를 만드는 헬퍼.""" + return {"intent": intent, "reasoning": reasoning} + + +# --------------------------------------------------------------------------- +# RouteDecision 스키마 검증 +# --------------------------------------------------------------------------- + +def test_route_decision_valid() -> None: + decision = RouteDecision.model_validate( + {"intent": "PROPERTY_SEARCH", "reasoning": "매물 검색 요청"} + ) + assert decision.intent == "PROPERTY_SEARCH" + + +def test_route_decision_rejects_unknown_intent() -> None: + from pydantic import ValidationError + + with pytest.raises(ValidationError): + RouteDecision.model_validate({"intent": "UNKNOWN", "reasoning": "?"}) + + +# --------------------------------------------------------------------------- +# classify_intent_llm — LLM 응답 mock +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "message, llm_intent, expected", + [ + ( + "강남구 오피스텔 가장 싼거 추천해줘", + "PROPERTY_SEARCH", + Intent.PROPERTY_SEARCH, + ), + ( + "강남구 오피스텔 시세 알려줘", + "PRICE_ANALYSIS", + Intent.PRICE_ANALYSIS, + ), + ( + "가성비 좋은 매물 추천해줘", + "PROPERTY_SEARCH", + Intent.PROPERTY_SEARCH, + ), + ( + "전세금 인상 관련 법 조항 알려줘", + "LEGAL_CONSULT", + Intent.LEGAL_CONSULT, + ), + ( + "안녕", + "GENERAL_CHAT", + Intent.GENERAL_CHAT, + ), + ], +) +def test_classify_intent_llm(monkeypatch, message, llm_intent, expected) -> None: + monkeypatch.setattr( + LLMClient, + "classify", + lambda self, msg: _make_llm_response(llm_intent), + ) + assert classify_intent_llm(message) == expected + + +# --------------------------------------------------------------------------- +# classify_intent_llm — LLM 실패 시 fallback +# --------------------------------------------------------------------------- + +def test_classify_intent_llm_falls_back_on_none(monkeypatch) -> None: + monkeypatch.setattr(LLMClient, "classify", lambda self, msg: None) + # "추천"이 PROPERTY_KEYWORDS에 있으므로 fallback도 PROPERTY_SEARCH 반환 + assert classify_intent_llm("관악구 원룸 추천해줘") == Intent.PROPERTY_SEARCH + + +def test_classify_intent_llm_falls_back_on_invalid_intent(monkeypatch) -> None: + monkeypatch.setattr( + LLMClient, + "classify", + lambda self, msg: {"intent": "TOTALLY_WRONG", "reasoning": "oops"}, + ) + # fallback 키워드 없는 메시지는 FALLBACK + assert classify_intent_llm("그냥 아무 말") == Intent.FALLBACK + + +# --------------------------------------------------------------------------- +# classify_intent_fallback — 키워드 기반 (기존 로직 보존 확인) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize( + "message, expected", + [ + ("관악구 보증금 5천 이하 원룸 추천해줘", Intent.PROPERTY_SEARCH), + ("계약 전에 법을 확인하고 싶어", Intent.LEGAL_CONSULT), + ("이 매물 가격이 비싼 편이야?", Intent.PRICE_ANALYSIS), + ("주변 cctv는 괜찮아?", Intent.SAFETY_ANALYSIS), + ("hug 보증보험 가능해?", Intent.HUG_CALC), + ("완전 뜬금없는 말", Intent.FALLBACK), + ], +) +def test_classify_intent_fallback(message, expected) -> None: + assert classify_intent_fallback(message) == expected From f46d915bcbd6f848d897075efd840873e4f35c2f Mon Sep 17 00:00:00 2001 From: crolvlee Date: Tue, 23 Jun 2026 16:58:44 +0900 Subject: [PATCH 4/6] =?UTF-8?q?docs:=20md=20=ED=8C=8C=EC=9D=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/02_ARCHITECTURE.md | 9 +++++---- docs/03_ADR.md | 12 +++++++++++- docs/08_API_SPEC.md | 20 +++++++++++++++++++- 5 files changed, 37 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c1bff7c..f34677f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ |------|------| | Frontend | Vue 3, Vite, Pinia, Axios, 네이버지도 SDK, Tailwind CSS | | Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL, Redis, Gmail SMTP | -| AI Backend | Python 3.11, FastAPI, LangGraph, Claude API (Anthropic) | +| AI Backend | Python 3.11, FastAPI, LangGraph, GMS API (OpenAI-compatible) | | DB | Supabase (PostgreSQL + pgvector) | | Infra | Cloud Run (backend + backend-ai 각각 독립 배포) | | Batch | Spring Scheduler (국토교통부·생활안전지도 공공 API) | diff --git a/CLAUDE.md b/CLAUDE.md index ab80cf3..f8517fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ |------|------| | Frontend | Vue 3, Vite, Pinia, Axios, 네이버지도 SDK, Tailwind CSS | | Backend | Spring Boot 3, Spring Security (자체 JWT), PostgreSQL, Redis, Gmail SMTP | -| AI Backend | Python 3.11, FastAPI, LangGraph, Claude API (Anthropic) | +| AI Backend | Python 3.11, FastAPI, LangGraph, GMS API (OpenAI-compatible) | | DB | Supabase (PostgreSQL + pgvector) | | Infra | Cloud Run (backend + backend-ai 각각 독립 배포) | | Batch | Spring Scheduler (국토교통부·생활안전지도 공공 API) | diff --git a/docs/02_ARCHITECTURE.md b/docs/02_ARCHITECTURE.md index 5397d5d..5130420 100644 --- a/docs/02_ARCHITECTURE.md +++ b/docs/02_ARCHITECTURE.md @@ -84,13 +84,14 @@ salmanhae/ ### Python FastAPI + LangGraph (Cloud Run) -- 사용자 입력 의도 분류 (매물 추천 / 법률 상담 / 시세 분석 / 안전 분석) +- 사용자 입력 의도 분류 — LLM Structured Output (6종: 매물 추천 / 법률 상담 / 시세 분석 / 안전 분석 / HUG 계산 / 일반 대화) + - `RouteDecision` Pydantic 스키마로 파싱·검증, LLM 실패 시 `FALLBACK` 반환 - 의도별 툴 실행: - - `search_properties` → Spring Boot API 호출 - - `legal_rag` → pgvector 법률 문서 검색 + - `search_properties` → LLM이 조건 추출(Text-to-SQL) 후 Supabase DB 직접 조회 + - `legal_rag` → pgvector 법률 문서 유사도 검색 - `analyze_price` → Spring Boot API 호출 - `analyze_safety` → Spring Boot API 호출 -- Claude API로 최종 자연어 응답 생성 +- GMS API(OpenAI-compatible)로 최종 자연어 응답 생성 ### Redis diff --git a/docs/03_ADR.md b/docs/03_ADR.md index ff448ec..31686a8 100644 --- a/docs/03_ADR.md +++ b/docs/03_ADR.md @@ -26,7 +26,7 @@ **트레이드오프**: 비밀번호 해싱(BCrypt), 토큰 발급·검증 로직을 직접 관리해야 한다. 리프레시 토큰은 Redis에 저장하며 Token Rotation 방식으로 관리한다 (ADR-011 참고). ### ADR-006: LangGraph 의도 분류를 단일 노드에서 처리 -**결정**: 사용자 입력을 4가지 의도(매물 추천/법률 상담/시세 분석/안전 분석)로 분류하는 노드를 LangGraph 그래프 진입점에 배치하고, 의도에 따라 엣지가 분기된다. +**결정**: 사용자 입력을 6가지 의도(매물 추천/법률 상담/시세 분석/안전 분석/HUG 계산/일반 대화)로 분류하는 노드를 LangGraph 그래프 진입점에 배치하고, 의도에 따라 엣지가 분기된다. **이유**: 의도별 툴이 달라 단일 ReAct 루프보다 명시적 그래프 분기가 디버깅과 유지보수에 유리하다. **트레이드오프**: 의도 분류 실패 시 잘못된 툴 호출. 모호한 질문(예: "강남 안전한가요?"가 안전 분석인지 매물 추천인지)은 추가 처리 필요. @@ -50,6 +50,16 @@ **이유**: 이메일 인증 코드는 단기 TTL과 자동 삭제가 핵심이라 RDB보다 Redis가 적합하다. 리프레시 토큰을 Redis에 저장하면 로그아웃 시 즉시 무효화가 가능하고, Token Rotation으로 탈취된 토큰 재사용을 탐지할 수 있다. **트레이드오프**: Redis가 로컬 인프라에 추가된다. Redis 장애 시 로그인·회원가입 불가. 로컬 개발 환경에서 Redis 실행이 필수(`redis-server` 또는 Docker). +### ADR-012: 의도 분류를 키워드 매칭에서 LLM Structured Output으로 전환 +**결정**: `classify_intent` 노드에서 키워드 리스트 순차 체크 대신 LLM에게 `RouteDecision` JSON을 반환하도록 프롬프트하고, Pydantic으로 파싱·검증한다. LLM 호출 실패 시 `FALLBACK` intent를 반환한다. +**이유**: 키워드 매칭은 복합 의도("강남구 오피스텔 가장 싼거 추천해줘"에서 "싼"이 PRICE_KEYWORDS에 걸려 PRICE_ANALYSIS로 오분류)와 키워드 우선순위 문제를 해결하기 어렵다. LLM은 문장 전체 맥락을 이해해 분류 정확도가 높다. +**트레이드오프**: LLM 호출 시간(~1-2초) 추가. LLM 장애 시 모든 요청이 FALLBACK으로 처리됨. + +### ADR-013: 매물 검색을 Text-to-SQL로 구현 (Supabase 직접 조회) +**결정**: `property_search` 노드에서 Spring Boot API를 호출하는 대신, LLM이 자연어에서 검색 조건 JSON을 추출하고 FastAPI가 Supabase DB를 직접 쿼리한다. +**이유**: CLAUDE.md 원칙("pgvector 유사도 검색은 FastAPI에서만 수행")과 같은 맥락으로, FastAPI가 이미 Supabase에 직접 연결되어 있어 Spring Boot를 거칠 이유가 없다. 또한 Spring Boot를 거치면 불필요한 직렬화·역직렬화와 1홉 레이턴시가 추가된다. +**트레이드오프**: FastAPI가 properties 테이블 스키마에 직접 의존하게 됨. 스키마 변경 시 FastAPI와 Spring Boot 양쪽 모두 수정 필요. + ### ADR-010: 지도 줌 레벨별 표시 데이터를 서버에서 결정한다 **결정**: 프론트는 네이버지도 bounds와 zoom을 Spring Boot에 전달하고, 서버는 시/도·시/군/구·읍/면/동 평균 또는 매물/클러스터 데이터를 선택해 반환한다. **이유**: 지도 표시 정책과 집계 기준을 백엔드에서 일관 관리하면 프론트 구현이 단순해지고, 실거래가 평균 계산을 DB 캐시와 함께 최적화할 수 있다. diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md index 3cef3a1..47a1136 100644 --- a/docs/08_API_SPEC.md +++ b/docs/08_API_SPEC.md @@ -347,6 +347,18 @@ GET /api/v1/safety/facilities?types=CCTV,EMERGENCY_BELL&west=126.91&east=127.02& ## AI 에이전트 API +**intent 값 목록** + +| intent | 설명 | +| --- | --- | +| `PROPERTY_SEARCH` | 매물 추천·검색 (Text-to-SQL → Supabase 직접 조회) | +| `LEGAL_CONSULT` | 임대차 법률 상담 (pgvector RAG) | +| `PRICE_ANALYSIS` | 시세·실거래가 분석 (Spring Boot API) | +| `SAFETY_ANALYSIS` | 주변 안전시설·치안 분석 (Spring Boot API) | +| `HUG_CALC` | HUG 보증보험 가입 가능 여부 (MVP 미구현, FALLBACK 처리) | +| `GENERAL_CHAT` | 인사·잡담 등 부동산 무관 질문 (FALLBACK 처리) | +| `FALLBACK` | 분류 불가 또는 LLM 호출 실패 | + ### 챗봇 메시지 전송 (인증 필요) ```http POST /api/v1/chat @@ -379,9 +391,15 @@ Authorization: Bearer {token} { "id": 1, "title": "대학동 그린빌", + "buildingName": "대학동 그린빌", + "address": "서울특별시 관악구 대학동 123", + "propertyType": "ONE_ROOM", + "transactionType": "MONTHLY_RENT", "deposit": 5000000, "monthlyRent": 480000, - "safetyScore": 78, + "price": null, + "areaM2": "23.14", + "floor": 3, "latitude": 37.470123, "longitude": 126.936456 } From 18be704909b3d2c7936b41c5d8bc1a849bb525d4 Mon Sep 17 00:00:00 2001 From: crolvlee Date: Tue, 23 Jun 2026 17:15:28 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(ai):=20LLM=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20=EB=A7=A4?= =?UTF-8?q?=EB=AC=BC=20=EA=B2=80=EC=83=89=20=EC=9E=A5=EC=95=A0=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend-ai/app/clients/llm_client.py | 6 ++++-- backend-ai/app/graph/nodes/property_search.py | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/backend-ai/app/clients/llm_client.py b/backend-ai/app/clients/llm_client.py index 009fbc7..45de68b 100644 --- a/backend-ai/app/clients/llm_client.py +++ b/backend-ai/app/clients/llm_client.py @@ -86,7 +86,8 @@ def classify(self, message: str) -> dict[str, Any] | None: ) response.raise_for_status() text = extract_chat_completion_text(response.json()) or "{}" - return json.loads(text) + parsed = json.loads(text) + return parsed if isinstance(parsed, dict) else None except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError, ValueError): return None @@ -108,7 +109,8 @@ def extract_property_criteria(self, message: str) -> dict[str, Any]: ) response.raise_for_status() text = extract_chat_completion_text(response.json()) or "{}" - return json.loads(text) + parsed = json.loads(text) + return parsed if isinstance(parsed, dict) else {} except (httpx.HTTPError, json.JSONDecodeError, KeyError, TypeError, ValueError): return {} diff --git a/backend-ai/app/graph/nodes/property_search.py b/backend-ai/app/graph/nodes/property_search.py index 6557d30..9d0191a 100644 --- a/backend-ai/app/graph/nodes/property_search.py +++ b/backend-ai/app/graph/nodes/property_search.py @@ -4,13 +4,24 @@ def property_search(state: AgentState) -> AgentState: - criteria = LLMClient().extract_property_criteria(state["message"]) - properties = SupabaseVectorClient().search_properties(criteria) + message = state["message"] + criteria = LLMClient().extract_property_criteria(message) + try: + properties = SupabaseVectorClient().search_properties(criteria) + property_search_meta = {"count": len(properties), "criteria": criteria} + except Exception as exc: + properties = [] + property_search_meta = { + "count": 0, + "criteria": criteria, + "error": "PROPERTY_SEARCH_UNAVAILABLE", + "errorDetail": str(exc), + } return { **state, "properties": properties, "tool_results": { **state.get("tool_results", {}), - "propertySearch": {"count": len(properties), "criteria": criteria}, + "propertySearch": property_search_meta, }, } From 63c6d6963ee4d1ff0adf42040d6e7103ac165b00 Mon Sep 17 00:00:00 2001 From: crolvlee Date: Tue, 23 Jun 2026 17:18:52 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs(api):=20properties=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=ED=95=84=EB=93=9C=EB=A5=BC=20snake=5Fcase=EB=A1=9C?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/08_API_SPEC.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md index 47a1136..ed4e0cd 100644 --- a/docs/08_API_SPEC.md +++ b/docs/08_API_SPEC.md @@ -391,17 +391,17 @@ Authorization: Bearer {token} { "id": 1, "title": "대학동 그린빌", - "buildingName": "대학동 그린빌", + "building_name": "대학동 그린빌", "address": "서울특별시 관악구 대학동 123", - "propertyType": "ONE_ROOM", - "transactionType": "MONTHLY_RENT", + "property_type": "ONE_ROOM", + "transaction_type": "MONTHLY_RENT", "deposit": 5000000, - "monthlyRent": 480000, + "monthly_rent": 480000, "price": null, - "areaM2": "23.14", + "area_m2": "23.14", "floor": 3, - "latitude": 37.470123, - "longitude": 126.936456 + "latitude": "37.470123", + "longitude": "126.936456" } ], "legalCards": [],