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
3 changes: 3 additions & 0 deletions backend-ai/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# backend-ai

FastAPI and LangGraph service for Salmanhae AI agent features.
8 changes: 6 additions & 2 deletions backend-ai/app/clients/supabase_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ def __init__(self) -> None:
def similarity_search_legal_documents(self, query: str, top_k: int = 3) -> list[dict[str, Any]]:
return [
{
"title": "주택임대차보호법 - 대항력",
"lawName": "주택임대차보호법",
"articleNo": "제3조",
"title": "대항력",
"source": "legal-stub",
"content": "임차인은 주택의 인도와 주민등록을 마친 때에는 그 다음 날부터 제3자에 대하여 효력이 생깁니다.",
"score": 0.91,
},
{
"title": "주택임대차보호법 - 확정일자",
"lawName": "주택임대차보호법",
"articleNo": "제3조의2",
"title": "보증금의 회수",
"source": "legal-stub",
"content": "확정일자를 갖춘 임차인은 경매 또는 공매 시 후순위권리자보다 우선하여 보증금을 변제받을 수 있습니다.",
"score": 0.86,
Expand Down
32 changes: 31 additions & 1 deletion backend-ai/tests/test_agent_chat.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from fastapi.testclient import TestClient

from app.core.config import get_settings
from app.graph.nodes.classify_intent import classify_message
from app.graph.state import Intent
from app.main import app
Expand All @@ -8,6 +9,10 @@
client = TestClient(app)


def internal_api_headers() -> dict[str, str]:
return {"X-Internal-Api-Key": get_settings().internal_api_key}


def test_agent_chat_requires_internal_api_key() -> None:
response = client.post(
"/internal/agent/chat",
Expand All @@ -25,7 +30,7 @@ def test_agent_chat_requires_internal_api_key() -> None:
def test_agent_chat_returns_intent_and_answer() -> None:
response = client.post(
"/internal/agent/chat",
headers={"X-Internal-Api-Key": "change-me"},
headers=internal_api_headers(),
json={
"userId": "user-1",
"sessionId": None,
Expand All @@ -41,6 +46,31 @@ def test_agent_chat_returns_intent_and_answer() -> None:
assert "properties" in body


def test_agent_chat_returns_legal_cards_for_legal_question() -> None:
response = client.post(
"/internal/agent/chat",
headers=internal_api_headers(),
json={
"userId": "user-1",
"sessionId": None,
"message": "확정일자는 언제 받아야 하나요?",
"context": {"selectedPropertyId": None, "recentMessages": []},
},
)

body = response.json()
assert response.status_code == 200
assert body["intent"] == "LEGAL_CONSULT"
assert body["answer"]
assert len(body["legalCards"]) >= 1
card = body["legalCards"][0]
assert card["lawName"] == "주택임대차보호법"
assert card["articleNo"]
assert card["title"]
assert card["content"]
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
Expand Down
5 changes: 3 additions & 2 deletions backend/mvnw.cmd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public enum ErrorCode {
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."),
EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 가입된 이메일입니다."),
PROPERTY_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 매물을 찾을 수 없습니다."),
AI_SERVICE_UNAVAILABLE(HttpStatus.BAD_GATEWAY, "AI 서비스 응답이 없습니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.ssafy.salmanhae.controller.chat;

import com.ssafy.salmanhae.common.response.ApiResponse;
import com.ssafy.salmanhae.model.dto.auth.User;
import com.ssafy.salmanhae.model.dto.chat.ChatRequest;
import com.ssafy.salmanhae.model.dto.chat.ChatResponse;
import com.ssafy.salmanhae.service.chat.ChatService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/v1/chat")
@RequiredArgsConstructor
public class ChatController {

private final ChatService chatService;

@PostMapping
public ApiResponse<ChatResponse> sendMessage(
@AuthenticationPrincipal User user,
@Valid @RequestBody ChatRequest request
) {
return ApiResponse.ok(chatService.sendMessage(user, request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.ssafy.salmanhae.model.dto.chat;

import jakarta.validation.constraints.NotBlank;

public record ChatRequest(
@NotBlank String message,
String sessionId
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.ssafy.salmanhae.model.dto.chat;

import java.util.List;
import java.util.Map;

public record ChatResponse(
String intent,
String message,
String sessionId,
List<Map<String, Object>> properties,
List<LegalCardResponse> legalCards
) {
public ChatResponse {
properties = properties == null ? List.of() : properties.stream()
.map(Map::copyOf)
.toList();
legalCards = legalCards == null ? List.of() : List.copyOf(legalCards);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.ssafy.salmanhae.model.dto.chat;

public record LegalCardResponse(
String lawName,
String articleNo,
String title,
String content,
Double score
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package com.ssafy.salmanhae.service.chat;

import com.ssafy.salmanhae.common.exception.ApiException;
import com.ssafy.salmanhae.common.exception.ErrorCode;
import com.ssafy.salmanhae.model.dto.chat.ChatRequest;
import com.ssafy.salmanhae.model.dto.chat.ChatResponse;
import com.ssafy.salmanhae.model.dto.chat.LegalCardResponse;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;

@Component
public class AiAgentClient {

private final RestClient restClient;
private final String internalApiKey;

public AiAgentClient(
@Value("${ai.agent.base-url}") String baseUrl,
@Value("${ai.agent.internal-api-key}") String internalApiKey,
@Value("${ai.agent.connect-timeout-ms:2000}") long connectTimeoutMs,
@Value("${ai.agent.read-timeout-ms:10000}") long readTimeoutMs
) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(Duration.ofMillis(connectTimeoutMs));
requestFactory.setReadTimeout(Duration.ofMillis(readTimeoutMs));
this.restClient = RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(requestFactory)
.build();
this.internalApiKey = internalApiKey;
}

public ChatResponse sendMessage(String userId, ChatRequest request) {
AgentChatResponse response;
try {
response = restClient.post()
.uri("/internal/agent/chat")
.header("X-Internal-Api-Key", internalApiKey)
.body(new AgentChatRequest(
userId,
request.sessionId(),
request.message(),
new ChatContext(null, List.of())
))
.retrieve()
.body(AgentChatResponse.class);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (RestClientException exception) {
throw new ApiException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}

if (response == null) {
throw new ApiException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}

return new ChatResponse(
response.intent(),
response.answer(),
request.sessionId(),
response.properties(),
response.legalCards()
);
}

private record AgentChatRequest(
String userId,
String sessionId,
String message,
ChatContext context
) {
}

private record ChatContext(
String selectedPropertyId,
List<Map<String, String>> recentMessages
) {
}

private record AgentChatResponse(
String intent,
String answer,
List<Map<String, Object>> properties,
List<LegalCardResponse> legalCards
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.ssafy.salmanhae.service.chat;

import com.ssafy.salmanhae.model.dto.auth.User;
import com.ssafy.salmanhae.model.dto.chat.ChatRequest;
import com.ssafy.salmanhae.model.dto.chat.ChatResponse;

public interface ChatService {
ChatResponse sendMessage(User user, ChatRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.ssafy.salmanhae.service.chat;

import com.ssafy.salmanhae.model.dto.auth.User;
import com.ssafy.salmanhae.model.dto.chat.ChatRequest;
import com.ssafy.salmanhae.model.dto.chat.ChatResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class ChatServiceImpl implements ChatService {

private final AiAgentClient aiAgentClient;

@Override
public ChatResponse sendMessage(User user, ChatRequest request) {
return aiAgentClient.sendMessage(user.getId(), request);
}
}
6 changes: 6 additions & 0 deletions backend/src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ spring.datasource.url=${SUPABASE_DB_URL:}
spring.datasource.username=${SUPABASE_DB_USERNAME:}
spring.datasource.password=${SUPABASE_DB_PASSWORD:}
spring.datasource.driver-class-name=org.postgresql.Driver

# Backend AI internal API
ai.agent.base-url=${AI_AGENT_BASE_URL:http://localhost:8000}
ai.agent.internal-api-key=${INTERNAL_API_KEY}
ai.agent.connect-timeout-ms=${AI_AGENT_CONNECT_TIMEOUT_MS:2000}
ai.agent.read-timeout-ms=${AI_AGENT_READ_TIMEOUT_MS:10000}
Loading