diff --git a/backend-ai/README.md b/backend-ai/README.md new file mode 100644 index 0000000..7318063 --- /dev/null +++ b/backend-ai/README.md @@ -0,0 +1,3 @@ +# backend-ai + +FastAPI and LangGraph service for Salmanhae AI agent features. diff --git a/backend-ai/app/clients/supabase_client.py b/backend-ai/app/clients/supabase_client.py index 58db148..30f2d54 100644 --- a/backend-ai/app/clients/supabase_client.py +++ b/backend-ai/app/clients/supabase_client.py @@ -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, diff --git a/backend-ai/tests/test_agent_chat.py b/backend-ai/tests/test_agent_chat.py index 46a87a1..b8aecc1 100644 --- a/backend-ai/tests/test_agent_chat.py +++ b/backend-ai/tests/test_agent_chat.py @@ -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 @@ -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", @@ -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, @@ -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 diff --git a/backend/mvnw.cmd b/backend/mvnw.cmd index 92450f9..84d8cf3 100644 --- a/backend/mvnw.cmd +++ b/backend/mvnw.cmd @@ -89,10 +89,11 @@ if (-not (Test-Path -Path $MAVEN_M2_PATH)) { } $MAVEN_WRAPPER_DISTS = $null -if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { +$MAVEN_M2_ITEM = Get-Item $MAVEN_M2_PATH +if ($null -eq $MAVEN_M2_ITEM.Target -or $MAVEN_M2_ITEM.Target.Count -eq 0) { $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" } else { - $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" + $MAVEN_WRAPPER_DISTS = $MAVEN_M2_ITEM.Target[0] + "/wrapper/dists" } $MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" diff --git a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java index 8f2d567..9ad4aea 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java +++ b/backend/src/main/java/com/ssafy/salmanhae/common/exception/ErrorCode.java @@ -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; diff --git a/backend/src/main/java/com/ssafy/salmanhae/controller/chat/ChatController.java b/backend/src/main/java/com/ssafy/salmanhae/controller/chat/ChatController.java new file mode 100644 index 0000000..4aa4163 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/controller/chat/ChatController.java @@ -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 sendMessage( + @AuthenticationPrincipal User user, + @Valid @RequestBody ChatRequest request + ) { + return ApiResponse.ok(chatService.sendMessage(user, request)); + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/ChatRequest.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/ChatRequest.java new file mode 100644 index 0000000..3b32143 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/ChatRequest.java @@ -0,0 +1,9 @@ +package com.ssafy.salmanhae.model.dto.chat; + +import jakarta.validation.constraints.NotBlank; + +public record ChatRequest( + @NotBlank String message, + String sessionId +) { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/ChatResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/ChatResponse.java new file mode 100644 index 0000000..188ca42 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/ChatResponse.java @@ -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> properties, + List legalCards +) { + public ChatResponse { + properties = properties == null ? List.of() : properties.stream() + .map(Map::copyOf) + .toList(); + legalCards = legalCards == null ? List.of() : List.copyOf(legalCards); + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/LegalCardResponse.java b/backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/LegalCardResponse.java new file mode 100644 index 0000000..4190509 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/LegalCardResponse.java @@ -0,0 +1,10 @@ +package com.ssafy.salmanhae.model.dto.chat; + +public record LegalCardResponse( + String lawName, + String articleNo, + String title, + String content, + Double score +) { +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/chat/AiAgentClient.java b/backend/src/main/java/com/ssafy/salmanhae/service/chat/AiAgentClient.java new file mode 100644 index 0000000..f3cdbc7 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/chat/AiAgentClient.java @@ -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); + } 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> recentMessages + ) { + } + + private record AgentChatResponse( + String intent, + String answer, + List> properties, + List legalCards + ) { + } +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/chat/ChatService.java b/backend/src/main/java/com/ssafy/salmanhae/service/chat/ChatService.java new file mode 100644 index 0000000..185d77f --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/chat/ChatService.java @@ -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); +} diff --git a/backend/src/main/java/com/ssafy/salmanhae/service/chat/ChatServiceImpl.java b/backend/src/main/java/com/ssafy/salmanhae/service/chat/ChatServiceImpl.java new file mode 100644 index 0000000..873ab3d --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/chat/ChatServiceImpl.java @@ -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); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index c97384e..2c4be5e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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} diff --git a/backend/src/test/java/com/ssafy/salmanhae/controller/chat/ChatControllerTest.java b/backend/src/test/java/com/ssafy/salmanhae/controller/chat/ChatControllerTest.java new file mode 100644 index 0000000..78a500b --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/chat/ChatControllerTest.java @@ -0,0 +1,113 @@ +package com.ssafy.salmanhae.controller.chat; + +import static org.hamcrest.Matchers.hasSize; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ssafy.salmanhae.model.dto.auth.User; +import com.ssafy.salmanhae.model.dto.chat.ChatResponse; +import com.ssafy.salmanhae.model.dto.chat.LegalCardResponse; +import com.ssafy.salmanhae.service.chat.ChatService; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class ChatControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ChatService chatService; + + @Test + void chatRequiresAuthentication() throws Exception { + mockMvc.perform(post("/api/v1/chat") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"message": "확정일자는 언제 받아야 하나요?", "sessionId": null} + """)) + .andExpect(status().isUnauthorized()); + } + + @Test + void chatReturnsLegalConsultContractForAuthenticatedUser() throws Exception { + when(chatService.sendMessage(any(User.class), any())) + .thenReturn(new ChatResponse( + "LEGAL_CONSULT", + "확정일자는 보증금 우선변제를 위해 전입신고와 함께 빠르게 받는 것이 좋습니다.", + null, + List.of(), + List.of(new LegalCardResponse( + "주택임대차보호법", + "제3조의2", + "보증금의 회수", + "확정일자를 갖춘 임차인은 경매 또는 공매 시 후순위권리자보다 우선하여 보증금을 변제받을 수 있습니다.", + 0.92 + )) + )); + + mockMvc.perform(post("/api/v1/chat") + .header("Authorization", "Bearer " + loginAccessToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"message": "확정일자는 언제 받아야 하나요?", "sessionId": null} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.intent").value("LEGAL_CONSULT")) + .andExpect(jsonPath("$.data.message").isNotEmpty()) + .andExpect(jsonPath("$.data.properties", hasSize(0))) + .andExpect(jsonPath("$.data.legalCards", hasSize(1))) + .andExpect(jsonPath("$.data.legalCards[0].lawName").value("주택임대차보호법")) + .andExpect(jsonPath("$.data.legalCards[0].articleNo").value("제3조의2")) + .andExpect(jsonPath("$.data.legalCards[0].title").value("보증금의 회수")) + .andExpect(jsonPath("$.data.legalCards[0].content").isNotEmpty()) + .andExpect(jsonPath("$.data.legalCards[0].score").value(0.92)); + } + + private String loginAccessToken() throws Exception { + String email = "chat-user@example.com"; + String password = "password123"; + mockMvc.perform(post("/api/v1/auth/signup") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email": "%s", "password": "%s", "nickname": "챗테스터"} + """.formatted(email, password))) + .andExpect(status().isOk()); + + MvcResult result = mockMvc.perform(post("/api/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"email": "%s", "password": "%s"} + """.formatted(email, password))) + .andExpect(status().isOk()) + .andReturn(); + + JsonNode body = objectMapper.readTree(result.getResponse().getContentAsString()); + String accessToken = body.path("data").path("accessToken").asText(); + assertFalse(accessToken.isBlank(), "login response must include non-empty accessToken"); + return accessToken; + } +} diff --git a/backend/src/test/resources/application-test.properties b/backend/src/test/resources/application-test.properties index f2d1db7..56eaae2 100644 --- a/backend/src/test/resources/application-test.properties +++ b/backend/src/test/resources/application-test.properties @@ -6,3 +6,7 @@ spring.sql.init.mode=always jwt.secret=salmanhae-test-secret-key-32bytes!! jwt.access-token-expiration=900000 jwt.refresh-token-expiration=604800000 +ai.agent.base-url=http://localhost:8000 +ai.agent.internal-api-key=change-me +ai.agent.connect-timeout-ms=2000 +ai.agent.read-timeout-ms=10000 diff --git a/docs/08_API_SPEC.md b/docs/08_API_SPEC.md index 616c6bc..6728f14 100644 --- a/docs/08_API_SPEC.md +++ b/docs/08_API_SPEC.md @@ -103,7 +103,7 @@ GET /api/v1/properties?west=126.91&east=127.02&south=37.45&north=37.55 F-1 MVP에서는 실거래가 건물 anchor 기반 `MVP_SYNTHETIC` 더미 매물을 조회합니다. 운영 단계에서는 제휴 피드 또는 합법적으로 확보한 매물 데이터를 `properties`에 저장한 뒤 같은 API로 조회합니다. -**Response** +**Response — 매물 추천** ```json { "data": { @@ -373,7 +373,29 @@ Authorization: Bearer {token} "longitude": 126.936456 } ], - "legalCards": null + "legalCards": [] + }, + "message": "OK" +} +``` + +**Response — 법률 RAG** +```json +{ + "data": { + "intent": "LEGAL_CONSULT", + "message": "관련 법령 근거 2개를 확인했습니다. 실제 계약 전에는 전문가 검토도 함께 권장합니다.", + "sessionId": null, + "properties": [], + "legalCards": [ + { + "lawName": "주택임대차보호법", + "articleNo": "제3조의2", + "title": "보증금의 회수", + "content": "확정일자를 갖춘 임차인은 경매 또는 공매 시 후순위권리자보다 우선하여 보증금을 변제받을 수 있습니다.", + "score": 0.86 + } + ] }, "message": "OK" } diff --git a/phases/ai-legal-rag/phase1-contract-tests.md b/phases/ai-legal-rag/phase1-contract-tests.md new file mode 100644 index 0000000..590339f --- /dev/null +++ b/phases/ai-legal-rag/phase1-contract-tests.md @@ -0,0 +1,29 @@ +# Phase 1: F-3 Contract and Tests + +## Goal +Define the first user-visible F-3 chat contract and lock it with tests before deeper RAG ingestion work. Keep the implementation minimal but runnable so later phases can replace stubs with real pgvector and LLM behavior. + +## Files +- `backend/src/test/java/com/ssafy/salmanhae/controller/chat/ChatControllerTest.java` - Spring chat API authentication and response contract tests +- `backend/src/main/java/com/ssafy/salmanhae/controller/chat/ChatController.java` - authenticated `/api/v1/chat` endpoint +- `backend/src/main/java/com/ssafy/salmanhae/service/chat/ChatService.java` - service boundary for AI agent delegation +- `backend/src/main/java/com/ssafy/salmanhae/service/chat/ChatServiceImpl.java` - minimal AI agent delegation implementation +- `backend/src/main/java/com/ssafy/salmanhae/service/chat/AiAgentClient.java` - internal FastAPI client boundary +- `backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/*.java` - request/response DTOs +- `backend-ai/tests/test_agent_chat.py` - backend-ai legal RAG response contract tests + +## Done When +- [ ] `POST /api/v1/chat` rejects unauthenticated requests with `401` +- [ ] authenticated legal questions return `intent = LEGAL_CONSULT`, a message, and non-empty `legalCards` +- [ ] backend-ai `/internal/agent/chat` returns legal cards using the public camelCase contract +- [ ] Targeted backend and backend-ai tests pass + +## Architecture Rules +- F-3 requires authentication and must pass through Spring Security JWT verification. +- Frontend must call Spring Boot only; Spring Boot delegates to FastAPI over internal HTTP. +- Business logic belongs in Service classes; Controller validates and delegates. +- LangGraph and RAG logic stay in `backend-ai`. +- API keys and internal secrets are injected from configuration or environment variables. + +## Implementation Instructions +Start with tests, then add the smallest Spring chat endpoint and service/client boundary needed for a passing contract. Do not implement legal document ingestion, pgvector schema, or real LLM calls in this phase; those belong to later phases.