diff --git a/src/main/java/com/dog/dognation/ai/GeminiClient.java b/src/main/java/com/dog/dognation/ai/GeminiClient.java new file mode 100644 index 0000000..1fe9831 --- /dev/null +++ b/src/main/java/com/dog/dognation/ai/GeminiClient.java @@ -0,0 +1,57 @@ +package com.dog.dognation.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.util.List; +import java.util.Map; + +/** + * Google Gemini(Generative Language API) 최소 클라이언트. + * responseSchema 로 구조화된 JSON 을 강제하고, 모델 응답 text(JSON 문자열)를 그대로 반환한다. + * API 키는 app.ai.gemini.api-key(=env GEMINI_API_KEY), 모델은 app.ai.gemini.model. + */ +@Component +public class GeminiClient { + + private static final String BASE = + "https://generativelanguage.googleapis.com/v1beta/models/"; + + private final RestClient rest = RestClient.create(); + + @Value("${app.ai.gemini.api-key:}") + private String apiKey; + + @Value("${app.ai.gemini.model:gemini-flash-latest}") + private String model; + + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank(); + } + + /** + * 프롬프트와 응답 스키마로 생성 호출하고, 모델이 반환한 JSON 문자열을 돌려준다. + * @param responseSchema Gemini responseSchema (OpenAPI 형식 Map) + */ + public String generateJson(String prompt, Map responseSchema) { + Map body = Map.of( + "contents", List.of(Map.of("parts", List.of(Map.of("text", prompt)))), + "generationConfig", Map.of( + "responseMimeType", "application/json", + "responseSchema", responseSchema)); + + JsonNode res = rest.post() + .uri(BASE + model + ":generateContent?key={k}", apiKey) + .body(body) + .retrieve() + .body(JsonNode.class); + + if (res == null) { + return ""; + } + return res.path("candidates").path(0).path("content") + .path("parts").path(0).path("text").asText(""); + } +} \ No newline at end of file diff --git a/src/main/java/com/dog/dognation/api/SpotController.java b/src/main/java/com/dog/dognation/api/SpotController.java index 00dc9eb..4801a09 100644 --- a/src/main/java/com/dog/dognation/api/SpotController.java +++ b/src/main/java/com/dog/dognation/api/SpotController.java @@ -1,11 +1,13 @@ package com.dog.dognation.api; +import com.dog.dognation.api.dto.SpotDtos.AiMateResponse; import com.dog.dognation.api.dto.SpotDtos.CheckInRequest; import com.dog.dognation.api.dto.SpotDtos.CheckInResponse; import com.dog.dognation.api.dto.SpotDtos.MateResponse; import com.dog.dognation.api.dto.SpotDtos.ReviewCreateRequest; import com.dog.dognation.api.dto.SpotDtos.ReviewResponse; import com.dog.dognation.api.dto.SpotDtos.SpotResponse; +import com.dog.dognation.domain.spot.MateAiService; import com.dog.dognation.domain.spot.SpotService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; @@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.PathVariable; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; @@ -24,9 +27,11 @@ import java.util.List; public class SpotController { private final SpotService spotService; + private final MateAiService mateAiService; - public SpotController(SpotService spotService) { + public SpotController(SpotService spotService, MateAiService mateAiService) { this.spotService = spotService; + this.mateAiService = mateAiService; } /** 스팟 목록 */ @@ -50,6 +55,12 @@ public class SpotController { return spotService.activeMates(id).stream().map(MateResponse::from).toList(); } + /** AI 궁합 메이트 추천 — 내 강아지(dogId)와 사이좋게 지낼 만한 스팟 내 강아지 목록 */ + @GetMapping("/{id}/ai-mates") + public List aiMates(@PathVariable Long id, @RequestParam Long dogId) { + return mateAiService.compatibleMates(id, dogId); + } + /** 스팟 한줄평 목록 */ @GetMapping("/{id}/reviews") public List reviews(@PathVariable Long id) { diff --git a/src/main/java/com/dog/dognation/api/dto/SpotDtos.java b/src/main/java/com/dog/dognation/api/dto/SpotDtos.java index 8f5b0ea..40de55d 100644 --- a/src/main/java/com/dog/dognation/api/dto/SpotDtos.java +++ b/src/main/java/com/dog/dognation/api/dto/SpotDtos.java @@ -26,6 +26,10 @@ public final class SpotDtos { } } + /** AI가 추천한 궁합 메이트 — 궁합점수(0-100)와 한줄 이유 포함. */ + public record AiMateResponse(Long dogId, String name, String breed, int score, String reason) { + } + public record ReviewResponse(Long id, String tag, String content, OffsetDateTime createdAt) { public static ReviewResponse from(SpotReview r) { return new ReviewResponse(r.getId(), r.getTag(), r.getContent(), r.getCreatedAt()); diff --git a/src/main/java/com/dog/dognation/domain/dog/DogRepository.java b/src/main/java/com/dog/dognation/domain/dog/DogRepository.java index 6fdc799..bdf913e 100644 --- a/src/main/java/com/dog/dognation/domain/dog/DogRepository.java +++ b/src/main/java/com/dog/dognation/domain/dog/DogRepository.java @@ -3,6 +3,7 @@ package com.dog.dognation.domain.dog; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -14,4 +15,8 @@ public interface DogRepository extends JpaRepository { @EntityGraph(attributePaths = "traits") Optional findWithTraitsById(Long id); + + // AI 궁합 추천: 스팟 활성 강아지들을 성향과 함께 일괄 로딩 + @EntityGraph(attributePaths = "traits") + List findWithTraitsByIdIn(Collection ids); } diff --git a/src/main/java/com/dog/dognation/domain/spot/MateAiService.java b/src/main/java/com/dog/dognation/domain/spot/MateAiService.java new file mode 100644 index 0000000..1ace7d1 --- /dev/null +++ b/src/main/java/com/dog/dognation/domain/spot/MateAiService.java @@ -0,0 +1,149 @@ +package com.dog.dognation.domain.spot; + +import com.dog.dognation.ai.GeminiClient; +import com.dog.dognation.api.dto.SpotDtos.AiMateResponse; +import com.dog.dognation.common.exception.ApiException; +import com.dog.dognation.domain.dog.Dog; +import com.dog.dognation.domain.dog.DogRepository; +import com.dog.dognation.domain.dog.TraitTag; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * AI(Gemini) 기반 스팟 궁합 메이트 추천. + * 해당 스팟에 현재 체크인 중(Redis 24h)인 강아지들 중, 내 강아지와 사이좋게 지낼 만한 + * 강아지만 골라 궁합점수·이유와 함께 반환한다. + */ +@Service +public class MateAiService { + + private static final Logger log = LoggerFactory.getLogger(MateAiService.class); + + /** Gemini responseSchema — [{dogId, score, reason}] 배열 강제. */ + private static final Map SCHEMA = Map.of( + "type", "ARRAY", + "items", Map.of( + "type", "OBJECT", + "properties", Map.of( + "dogId", Map.of("type", "INTEGER"), + "score", Map.of("type", "INTEGER"), + "reason", Map.of("type", "STRING")), + "required", List.of("dogId", "score", "reason"))); + + private final CheckInRedisStore checkInRedisStore; + private final DogRepository dogRepository; + private final GeminiClient gemini; + private final ObjectMapper mapper = new ObjectMapper(); + + public MateAiService(CheckInRedisStore checkInRedisStore, + DogRepository dogRepository, + GeminiClient gemini) { + this.checkInRedisStore = checkInRedisStore; + this.dogRepository = dogRepository; + this.gemini = gemini; + } + + /** 스팟 활성 메이트 중 내 강아지와 궁합 좋은 강아지 목록(궁합점수 내림차순). */ + @Transactional(readOnly = true) + public List compatibleMates(Long spotId, Long myDogId) { + if (!gemini.isConfigured()) { + throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE, "AI 추천이 설정되지 않았습니다."); + } + Dog myDog = dogRepository.findWithTraitsById(myDogId) + .orElseThrow(() -> new ApiException(HttpStatus.NOT_FOUND, "강아지를 찾을 수 없습니다.")); + + List activeIds = new ArrayList<>(checkInRedisStore.activeDogIds(spotId)); + activeIds.remove(myDogId); // 내 강아지는 제외 + if (activeIds.isEmpty()) { + return List.of(); + } + Map candidates = dogRepository.findWithTraitsByIdIn(activeIds).stream() + .collect(Collectors.toMap(Dog::getId, Function.identity())); + if (candidates.isEmpty()) { + return List.of(); + } + + String json; + try { + json = gemini.generateJson(buildPrompt(myDog, candidates.values()), SCHEMA); + } catch (Exception e) { + log.warn("[ai-mates] Gemini 호출 실패: {}", e.toString()); + throw new ApiException(HttpStatus.BAD_GATEWAY, "AI 추천을 불러오지 못했습니다."); + } + return parse(json, candidates); + } + + private String buildPrompt(Dog myDog, Iterable candidates) { + List> cands = new ArrayList<>(); + for (Dog d : candidates) { + cands.add(dogJson(d, true)); + } + try { + String myJson = mapper.writeValueAsString(dogJson(myDog, false)); + String candJson = mapper.writeValueAsString(cands); + return """ + 너는 반려견 산책 매칭 도우미다. 내 강아지와 공원에 있는 강아지들 중 + '사이좋게 지낼 만한' 강아지만 골라라. 크기 차이로 인한 두려움과 + 성향 충돌(예: '대형견 무서워함' vs 대형견, 저에너지 vs 과잉에너지)을 고려하고, + 궁합이 낮은 강아지는 제외해라. + + 내 강아지: %s + 공원의 강아지들: %s + + 각 추천에 dogId, 궁합점수(0-100 정수), 한국어 한줄 이유(reason)를 담아 반환해라. + """.formatted(myJson, candJson); + } catch (Exception e) { + throw new ApiException(HttpStatus.INTERNAL_SERVER_ERROR, "AI 요청 생성 실패"); + } + } + + private Map dogJson(Dog d, boolean includeId) { + Map m = new LinkedHashMap<>(); + if (includeId) { + m.put("dogId", d.getId()); + } + m.put("name", d.getName()); + m.put("breed", d.getBreed()); + m.put("size", d.getSize()); + m.put("traits", d.getTraits().stream().map(TraitTag::getLabel).toList()); + return m; + } + + private List parse(String json, Map candidates) { + if (json == null || json.isBlank()) { + return List.of(); + } + try { + JsonNode arr = mapper.readTree(json); + List out = new ArrayList<>(); + for (JsonNode node : arr) { + long dogId = node.path("dogId").asLong(-1); + Dog dog = candidates.get(dogId); + if (dog == null) { + continue; // 후보 밖 dogId(환각) 방지 + } + int score = node.path("score").asInt(0); + String reason = node.path("reason").asText(""); + out.add(new AiMateResponse(dog.getId(), dog.getName(), dog.getBreed(), score, reason)); + } + out.sort(Comparator.comparingInt(AiMateResponse::score).reversed()); + return out; + } catch (Exception e) { + log.warn("[ai-mates] 응답 파싱 실패: {}", e.toString()); + return List.of(); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5e6eb47..d6d1742 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -37,6 +37,11 @@ spring: app: scheduler: timezone: Asia/Seoul + # AI 궁합 추천 (Gemini). 키 미설정 시 추천 비활성(503). + ai: + gemini: + api-key: ${GEMINI_API_KEY:} + model: ${GEMINI_MODEL:gemini-flash-latest} matching: daily-free-limit: 3 # 소셜 로그인 — 값은 .env 에서 주입. 미설정 시 해당 로그인 비활성.