feat: AI(Gemini) 스팟 궁합 메이트 추천
CI / build (push) Failing after 15m45s

체크인한 스팟의 활성 강아지(Redis 24h) 중 내 강아지와 사이좋게 지낼 만한
강아지만 Gemini 로 골라 궁합점수·이유와 함께 반환.
GET /api/spots/{id}/ai-mates?dogId=...

- GeminiClient: responseSchema 로 구조화 JSON 강제(gemini-flash-latest)
- MateAiService: 후보 성향/크기로 프롬프트 구성, 후보 밖 dogId(환각) 필터, 점수 내림차순
- application.yml: app.ai.gemini (env GEMINI_API_KEY, 미설정 시 503)
- 키 없이도 컨텍스트/테스트 통과 → CI 안전

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sb
2026-07-11 13:44:46 +09:00
parent 8d3be8493c
commit 00af95bf6e
6 changed files with 232 additions and 1 deletions
@@ -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<String, Object> responseSchema) {
Map<String, Object> 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("");
}
}
@@ -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<AiMateResponse> aiMates(@PathVariable Long id, @RequestParam Long dogId) {
return mateAiService.compatibleMates(id, dogId);
}
/** 스팟 한줄평 목록 */
@GetMapping("/{id}/reviews")
public List<ReviewResponse> reviews(@PathVariable Long id) {
@@ -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());
@@ -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<Dog, Long> {
@EntityGraph(attributePaths = "traits")
Optional<Dog> findWithTraitsById(Long id);
// AI 궁합 추천: 스팟 활성 강아지들을 성향과 함께 일괄 로딩
@EntityGraph(attributePaths = "traits")
List<Dog> findWithTraitsByIdIn(Collection<Long> ids);
}
@@ -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<String, Object> 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<AiMateResponse> 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<Long> activeIds = new ArrayList<>(checkInRedisStore.activeDogIds(spotId));
activeIds.remove(myDogId); // 내 강아지는 제외
if (activeIds.isEmpty()) {
return List.of();
}
Map<Long, Dog> 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<Dog> candidates) {
List<Map<String, Object>> 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<String, Object> dogJson(Dog d, boolean includeId) {
Map<String, Object> 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<AiMateResponse> parse(String json, Map<Long, Dog> candidates) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
JsonNode arr = mapper.readTree(json);
List<AiMateResponse> 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();
}
}
}
+5
View File
@@ -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 에서 주입. 미설정 시 해당 로그인 비활성.