Skip to content
Merged
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
85 changes: 82 additions & 3 deletions backend-ai/app/clients/llm_client.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from collections.abc import Callable
from typing import Any

Expand All @@ -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.
"""
Expand All @@ -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:
Expand Down
23 changes: 2 additions & 21 deletions backend-ai/app/clients/spring_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
47 changes: 47 additions & 0 deletions backend-ai/app/clients/supabase_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Comment thread
crolvlee marked this conversation as resolved.

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)}")
Comment thread
crolvlee marked this conversation as resolved.
cursor.execute(sql, params)
return list(cursor.fetchall())

def _similarity_search_legal_documents_rest(
self,
query_embedding: list[float],
Expand Down
6 changes: 4 additions & 2 deletions backend-ai/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions backend-ai/app/graph/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
86 changes: 27 additions & 59 deletions backend-ai/app/graph/nodes/classify_intent.py
Original file line number Diff line number Diff line change
@@ -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"])}
25 changes: 17 additions & 8 deletions backend-ai/app/graph/nodes/property_search.py
Original file line number Diff line number Diff line change
@@ -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,
},
}
1 change: 1 addition & 0 deletions backend-ai/app/graph/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
15 changes: 3 additions & 12 deletions backend-ai/tests/test_agent_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

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