From c311ffbfd698f39264c8efee682990de03f767f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=ED=9C=98?= Date: Mon, 22 Jun 2026 11:54:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(chat):=20=EB=B2=95=EB=A5=A0=20RAG=20?= =?UTF-8?q?=EC=B1=84=ED=8C=85=20=EA=B3=84=EC=95=BD=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-ai/README.md | 3 + backend-ai/app/clients/supabase_client.py | 8 +- backend-ai/tests/test_agent_chat.py | 29 ++++- backend/mvnw.cmd | 5 +- .../salmanhae/common/exception/ErrorCode.java | 1 + .../controller/chat/ChatController.java | 30 +++++ .../salmanhae/model/dto/chat/ChatRequest.java | 9 ++ .../model/dto/chat/ChatResponse.java | 17 +++ .../model/dto/chat/LegalCardResponse.java | 10 ++ .../salmanhae/service/chat/AiAgentClient.java | 83 ++++++++++++++ .../salmanhae/service/chat/ChatService.java | 9 ++ .../service/chat/ChatServiceImpl.java | 19 ++++ .../src/main/resources/application.properties | 4 + .../controller/chat/ChatControllerTest.java | 107 ++++++++++++++++++ .../resources/application-test.properties | 2 + docs/08_API_SPEC.md | 26 ++++- phases/ai-legal-rag/phase1-contract-tests.md | 29 +++++ 17 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 backend-ai/README.md create mode 100644 backend/src/main/java/com/ssafy/salmanhae/controller/chat/ChatController.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/ChatRequest.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/ChatResponse.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/LegalCardResponse.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/service/chat/AiAgentClient.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/service/chat/ChatService.java create mode 100644 backend/src/main/java/com/ssafy/salmanhae/service/chat/ChatServiceImpl.java create mode 100644 backend/src/test/java/com/ssafy/salmanhae/controller/chat/ChatControllerTest.java create mode 100644 phases/ai-legal-rag/phase1-contract-tests.md 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..c5587b4 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,28 @@ 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 + assert body["legalCards"][0]["lawName"] == "주택임대차보호법" + assert body["legalCards"][0]["articleNo"] + assert body["legalCards"][0]["content"] + + 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..c38a5f6 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/model/dto/chat/ChatResponse.java @@ -0,0 +1,17 @@ +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() : List.copyOf(properties); + 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..7e0d031 --- /dev/null +++ b/backend/src/main/java/com/ssafy/salmanhae/service/chat/AiAgentClient.java @@ -0,0 +1,83 @@ +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.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Value; +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 + ) { + this.restClient = RestClient.builder() + .baseUrl(baseUrl) + .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..bdd6437 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -20,3 +20,7 @@ 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:change-me} 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..e14a9d1 --- /dev/null +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/chat/ChatControllerTest.java @@ -0,0 +1,107 @@ +package com.ssafy.salmanhae.controller.chat; + +import static org.hamcrest.Matchers.hasSize; +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")); + } + + 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()); + return body.path("data").path("accessToken").asText(); + } +} diff --git a/backend/src/test/resources/application-test.properties b/backend/src/test/resources/application-test.properties index f2d1db7..0b65301 100644 --- a/backend/src/test/resources/application-test.properties +++ b/backend/src/test/resources/application-test.properties @@ -6,3 +6,5 @@ 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 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. From b46e4ff8968a1d9c4bb8f0721ee3a251e1a7ac33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=9A=A9=ED=9C=98?= Date: Mon, 22 Jun 2026 12:36:07 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(chat):=20CodeRabbit=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B3=B4=EC=99=84=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-ai/tests/test_agent_chat.py | 9 ++++++--- .../ssafy/salmanhae/model/dto/chat/ChatResponse.java | 4 +++- .../ssafy/salmanhae/service/chat/AiAgentClient.java | 10 +++++++++- backend/src/main/resources/application.properties | 4 +++- .../salmanhae/controller/chat/ChatControllerTest.java | 10 ++++++++-- backend/src/test/resources/application-test.properties | 2 ++ 6 files changed, 31 insertions(+), 8 deletions(-) diff --git a/backend-ai/tests/test_agent_chat.py b/backend-ai/tests/test_agent_chat.py index c5587b4..b8aecc1 100644 --- a/backend-ai/tests/test_agent_chat.py +++ b/backend-ai/tests/test_agent_chat.py @@ -63,9 +63,12 @@ def test_agent_chat_returns_legal_cards_for_legal_question() -> None: assert body["intent"] == "LEGAL_CONSULT" assert body["answer"] assert len(body["legalCards"]) >= 1 - assert body["legalCards"][0]["lawName"] == "주택임대차보호법" - assert body["legalCards"][0]["articleNo"] - assert body["legalCards"][0]["content"] + 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: 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 index c38a5f6..188ca42 100644 --- 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 @@ -11,7 +11,9 @@ public record ChatResponse( List legalCards ) { public ChatResponse { - properties = properties == null ? List.of() : List.copyOf(properties); + 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/service/chat/AiAgentClient.java b/backend/src/main/java/com/ssafy/salmanhae/service/chat/AiAgentClient.java index 7e0d031..f3cdbc7 100644 --- a/backend/src/main/java/com/ssafy/salmanhae/service/chat/AiAgentClient.java +++ b/backend/src/main/java/com/ssafy/salmanhae/service/chat/AiAgentClient.java @@ -5,9 +5,11 @@ 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; @@ -20,10 +22,16 @@ public class AiAgentClient { public AiAgentClient( @Value("${ai.agent.base-url}") String baseUrl, - @Value("${ai.agent.internal-api-key}") String internalApiKey + @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; } diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index bdd6437..2c4be5e 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -23,4 +23,6 @@ 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:change-me} +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 index e14a9d1..78a500b 100644 --- a/backend/src/test/java/com/ssafy/salmanhae/controller/chat/ChatControllerTest.java +++ b/backend/src/test/java/com/ssafy/salmanhae/controller/chat/ChatControllerTest.java @@ -1,6 +1,7 @@ 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; @@ -80,7 +81,10 @@ void chatReturnsLegalConsultContractForAuthenticatedUser() throws Exception { .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].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 { @@ -102,6 +106,8 @@ private String loginAccessToken() throws Exception { .andReturn(); JsonNode body = objectMapper.readTree(result.getResponse().getContentAsString()); - return body.path("data").path("accessToken").asText(); + 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 0b65301..56eaae2 100644 --- a/backend/src/test/resources/application-test.properties +++ b/backend/src/test/resources/application-test.properties @@ -8,3 +8,5 @@ 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