체크인한 스팟의 활성 강아지(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:
@@ -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;
|
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.CheckInRequest;
|
||||||
import com.dog.dognation.api.dto.SpotDtos.CheckInResponse;
|
import com.dog.dognation.api.dto.SpotDtos.CheckInResponse;
|
||||||
import com.dog.dognation.api.dto.SpotDtos.MateResponse;
|
import com.dog.dognation.api.dto.SpotDtos.MateResponse;
|
||||||
import com.dog.dognation.api.dto.SpotDtos.ReviewCreateRequest;
|
import com.dog.dognation.api.dto.SpotDtos.ReviewCreateRequest;
|
||||||
import com.dog.dognation.api.dto.SpotDtos.ReviewResponse;
|
import com.dog.dognation.api.dto.SpotDtos.ReviewResponse;
|
||||||
import com.dog.dognation.api.dto.SpotDtos.SpotResponse;
|
import com.dog.dognation.api.dto.SpotDtos.SpotResponse;
|
||||||
|
import com.dog.dognation.domain.spot.MateAiService;
|
||||||
import com.dog.dognation.domain.spot.SpotService;
|
import com.dog.dognation.domain.spot.SpotService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import org.springframework.http.HttpStatus;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -24,9 +27,11 @@ import java.util.List;
|
|||||||
public class SpotController {
|
public class SpotController {
|
||||||
|
|
||||||
private final SpotService spotService;
|
private final SpotService spotService;
|
||||||
|
private final MateAiService mateAiService;
|
||||||
|
|
||||||
public SpotController(SpotService spotService) {
|
public SpotController(SpotService spotService, MateAiService mateAiService) {
|
||||||
this.spotService = spotService;
|
this.spotService = spotService;
|
||||||
|
this.mateAiService = mateAiService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 스팟 목록 */
|
/** 스팟 목록 */
|
||||||
@@ -50,6 +55,12 @@ public class SpotController {
|
|||||||
return spotService.activeMates(id).stream().map(MateResponse::from).toList();
|
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")
|
@GetMapping("/{id}/reviews")
|
||||||
public List<ReviewResponse> reviews(@PathVariable Long id) {
|
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 record ReviewResponse(Long id, String tag, String content, OffsetDateTime createdAt) {
|
||||||
public static ReviewResponse from(SpotReview r) {
|
public static ReviewResponse from(SpotReview r) {
|
||||||
return new ReviewResponse(r.getId(), r.getTag(), r.getContent(), r.getCreatedAt());
|
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.EntityGraph;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@@ -14,4 +15,8 @@ public interface DogRepository extends JpaRepository<Dog, Long> {
|
|||||||
|
|
||||||
@EntityGraph(attributePaths = "traits")
|
@EntityGraph(attributePaths = "traits")
|
||||||
Optional<Dog> findWithTraitsById(Long id);
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@ spring:
|
|||||||
app:
|
app:
|
||||||
scheduler:
|
scheduler:
|
||||||
timezone: Asia/Seoul
|
timezone: Asia/Seoul
|
||||||
|
# AI 궁합 추천 (Gemini). 키 미설정 시 추천 비활성(503).
|
||||||
|
ai:
|
||||||
|
gemini:
|
||||||
|
api-key: ${GEMINI_API_KEY:}
|
||||||
|
model: ${GEMINI_MODEL:gemini-flash-latest}
|
||||||
matching:
|
matching:
|
||||||
daily-free-limit: 3
|
daily-free-limit: 3
|
||||||
# 소셜 로그인 — 값은 .env 에서 주입. 미설정 시 해당 로그인 비활성.
|
# 소셜 로그인 — 값은 .env 에서 주입. 미설정 시 해당 로그인 비활성.
|
||||||
|
|||||||
Reference in New Issue
Block a user