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/backend-ai/app/clients/llm_client.py b/backend-ai/app/clients/llm_client.py index a4130b1..45de68b 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,43 @@ HttpPost = Callable[..., httpx.Response] +CLASSIFY_INTENT_PROMPT = """\ +다음 사용자 메시지를 읽고, 부동산 AI 어시스턴트 관점에서 의도를 분류해줘. -class LLMClient: - """GMS LLM API boundary. +사용자 메시지: {message} + +다음 intent 중 하나를 선택해: +- PROPERTY_SEARCH: 매물 추천·검색·조건 필터링 (지역, 가격, 면적, 타입 등) +- LEGAL_CONSULT: 임대차 법률, 계약, 보증금, 대항력, 갱신 등 법률 질문 +- PRICE_ANALYSIS: 특정 지역·매물의 시세·실거래가·가격 적정성 분석 +- SAFETY_ANALYSIS: 주변 치안, CCTV, 안전시설, 범죄율 등 생활 안전 분석 +- HUG_CALC: HUG 보증보험 가입 가능 여부 계산 +- GENERAL_CHAT: 인사, 잡담, 부동산과 무관한 질문 + +JSON만 반환해. 설명 없이: +{{"intent": "...", "reasoning": "분류 이유 한 줄"}}\ +""" + +PROPERTY_CRITERIA_PROMPT = """\ +다음 메시지에서 매물 검색 조건을 JSON으로 추출해줘. + +메시지: {message} - Live calls use an OpenAI-compatible chat-completions endpoint. The +아래 필드만 포함해. 언급이 없으면 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만 반환해. 설명 없이.\ +""" + + +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 +68,52 @@ 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 "{}" + parsed = json.loads(text) + return parsed if isinstance(parsed, dict) else None + 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: + 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": 256, + }, + timeout=self.timeout_seconds, + ) + response.raise_for_status() + text = extract_chat_completion_text(response.json()) or "{}" + parsed = json.loads(text) + return parsed if isinstance(parsed, dict) else {} + 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 5dc5c1b..5b2f298 100644 --- a/backend-ai/app/clients/spring_client.py +++ b/backend-ai/app/clients/spring_client.py @@ -28,27 +28,8 @@ def __init__( 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 0c560af..d755a70 100644 --- a/backend-ai/app/clients/supabase_client.py +++ b/backend-ai/app/clients/supabase_client.py @@ -84,6 +84,53 @@ def _similarity_search_legal_documents_pgvector( 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(f"SET LOCAL statement_timeout = {int(self.statement_timeout_ms)}") + cursor.execute(sql, params) + return list(cursor.fetchall()) + def _similarity_search_legal_documents_rest( self, query_embedding: list[float], diff --git a/backend-ai/app/core/config.py b/backend-ai/app/core/config.py index 350056e..fdb908f 100644 --- a/backend-ai/app/core/config.py +++ b/backend-ai/app/core/config.py @@ -27,9 +27,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/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 7d3d8b7..026c172 100644 --- a/backend-ai/app/graph/nodes/classify_intent.py +++ b/backend-ai/app/graph/nodes/classify_intent.py @@ -1,69 +1,37 @@ -from app.graph.state import AgentState, Intent +from typing import Literal +from pydantic import BaseModel, ValidationError -KOREAN_LEGAL_KEYWORDS = [ - "법", - "권리", - "돌려받", - "반환", - "대항력", - "우선변제", - "최우선변제", - "임대차", - "임차권", - "임대인", - "임차인", - "계약갱신", - "묵시적 갱신", - "전세사기", -] -KOREAN_PROPERTY_KEYWORDS = [ - "추천", - "찾아", - "매물", - "원룸", - "오피스텔", - "아파트", - "월세", - "전세", - "관악구", -] +from app.clients.llm_client import LLMClient +from app.graph.state import AgentState, Intent -LEGAL_KEYWORDS = [ - "법", - "계약", - "임대차", - "대항력", - "확정일자", - "보증금 반환", - "전세사기", - "묵시적 갱신", -] -PRICE_KEYWORDS = ["시세", "실거래", "가격", "비싼", "싼", "평균가", "전고점"] -SAFETY_KEYWORDS = ["안전", "치안", "cctv", "비상벨", "보안등", "파출소", "범죄"] -HUG_KEYWORDS = ["hug", "보증보험", "보증 가능", "보증 가입", "보증금 보험"] -PROPERTY_KEYWORDS = ["추천", "찾아", "매물", "원룸", "오피스텔", "아파트", "월세", "전세", "관악구"] +class RouteDecision(BaseModel): + intent: Literal[ + "PROPERTY_SEARCH", + "LEGAL_CONSULT", + "PRICE_ANALYSIS", + "SAFETY_ANALYSIS", + "HUG_CALC", + "GENERAL_CHAT", + ] + reasoning: str -def classify_message(message: str) -> Intent: - normalized = message.lower() - if any(keyword in normalized for keyword in KOREAN_LEGAL_KEYWORDS): - return Intent.LEGAL_CONSULT - if any(keyword in normalized for keyword in LEGAL_KEYWORDS): - return Intent.LEGAL_CONSULT - if any(keyword in normalized for keyword in HUG_KEYWORDS): - return Intent.HUG_CALC - if any(keyword in normalized for keyword in PRICE_KEYWORDS): - return Intent.PRICE_ANALYSIS - if any(keyword in normalized for keyword in SAFETY_KEYWORDS): - return Intent.SAFETY_ANALYSIS - if any(keyword in normalized for keyword in KOREAN_PROPERTY_KEYWORDS): - return Intent.PROPERTY_SEARCH - if any(keyword in normalized for keyword in PROPERTY_KEYWORDS): - return Intent.PROPERTY_SEARCH +def classify_intent_fallback(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/nodes/property_search.py b/backend-ai/app/graph/nodes/property_search.py index 94e708a..9d0191a 100644 --- a/backend-ai/app/graph/nodes/property_search.py +++ b/backend-ai/app/graph/nodes/property_search.py @@ -1,18 +1,27 @@ -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", {}), - ) + 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": result["properties"], + "properties": properties, "tool_results": { **state.get("tool_results", {}), - "propertySearch": result["meta"], + "propertySearch": property_search_meta, }, } diff --git a/backend-ai/app/graph/state.py b/backend-ai/app/graph/state.py index 5816014..d1e3d00 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 27f3473..71f7934 100644 --- a/backend-ai/tests/test_agent_chat.py +++ b/backend-ai/tests/test_agent_chat.py @@ -5,7 +5,7 @@ from app.graph.nodes import legal_rag as legal_rag_module from app.graph.nodes import price_analysis as price_analysis_module from app.graph.nodes import safety_analysis as safety_analysis_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 @@ -208,18 +208,9 @@ def analyze_safety(self, message: str, context: dict) -> dict: assert card["metrics"]["stub"] is False -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_returns_fallback() -> None: + assert classify_intent_fallback("아무 말이나") == Intent.FALLBACK def card_text_in_answer(answer: str, card: dict) -> bool: return card["lawName"] in answer and card["articleNo"] in answer - - -def test_classify_intent_korean_examples() -> None: - assert classify_message("관악구 보증금 5천 이하 원룸 추천해줘") == Intent.PROPERTY_SEARCH - assert classify_message("전세 보증금을 돌려받지 못하면 어떤 권리가 있나요?") == Intent.LEGAL_CONSULT diff --git a/backend-ai/tests/test_classify_intent.py b/backend-ai/tests/test_classify_intent.py new file mode 100644 index 0000000..4426d52 --- /dev/null +++ b/backend-ai/tests/test_classify_intent.py @@ -0,0 +1,76 @@ +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: + 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) + assert classify_intent_llm("관악구 원룸 추천해줘") == Intent.FALLBACK + + +def test_classify_intent_llm_falls_back_on_invalid_intent(monkeypatch) -> None: + monkeypatch.setattr( + LLMClient, "classify", lambda self, msg: {"intent": "TOTALLY_WRONG", "reasoning": "oops"} + ) + assert classify_intent_llm("아무 말") == Intent.FALLBACK + + +# --------------------------------------------------------------------------- +# classify_intent_fallback — LLM 실패 시 항상 FALLBACK +# --------------------------------------------------------------------------- + +def test_classify_intent_fallback_always_returns_fallback() -> None: + assert classify_intent_fallback("강남구 오피스텔 추천해줘") == Intent.FALLBACK + assert classify_intent_fallback("계약 관련 법 알려줘") == Intent.FALLBACK + assert classify_intent_fallback("안녕") == Intent.FALLBACK 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..ed4e0cd 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,11 +391,17 @@ Authorization: Bearer {token} { "id": 1, "title": "대학동 그린빌", + "building_name": "대학동 그린빌", + "address": "서울특별시 관악구 대학동 123", + "property_type": "ONE_ROOM", + "transaction_type": "MONTHLY_RENT", "deposit": 5000000, - "monthlyRent": 480000, - "safetyScore": 78, - "latitude": 37.470123, - "longitude": 126.936456 + "monthly_rent": 480000, + "price": null, + "area_m2": "23.14", + "floor": 3, + "latitude": "37.470123", + "longitude": "126.936456" } ], "legalCards": [],