Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/

.env
### STS ###
.apt_generated
.classpath
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/example/demo/VoidApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@SpringBootApplication
@EnableScheduling
public class VoidApplication {

public static void main(String[] args) {
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/com/example/demo/core/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.example.demo.core.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 모든 경로에 대해
.allowedOrigins( "https://jyhdevstore.store","https://void-fe-nine.vercel.app","https://voidvoid.store/") // 프론트엔드, NER, BE
.allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.example.demo.domain.gemini.controller;

import com.example.demo.domain.gemini.service.GeminiService;
import com.example.demo.domain.nerfilter.dto.AiResponse;
import com.example.demo.domain.nerfilter.dto.TextRequest;
import com.example.demo.shared.exception.CustomException;
import com.example.demo.shared.response.ApiResponse;
import com.example.demo.shared.response.ErrorCode;
Expand All @@ -24,32 +26,29 @@ public class GeminiController {

@Operation(
summary = "감정 배출 및 키워드 추출",
description = "유저의 감정(content)을 보내면 gemini가 답변과 핵심 키워드를 반환합니다."
description = "유저의 감정(text)을 보내면 gemini가 답변과 핵심 키워드를 반환합니다."
)
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "상담할 고민 내용",
required = true,
content = @Content(
mediaType = "application/json",
schema = @Schema(example = "{\"content\": \"오늘 해커톤 너무 힘들다.\"}"),
schema = @Schema(example = "{\"text\": \"오늘 해커톤 너무 힘들다.\"}"),
examples = @ExampleObject(
name = "고민 상담 예시",
value = "{\"content\": \"해커톤 프로젝트가 생각보다 어려워서 속상해...\"}"
value = "{\"text\": \"해커톤 프로젝트가 생각보다 어려워서 속상해...\"}"
)
)
)
@PostMapping("/ask")
public ApiResponse<Map<String, Object>> ask(@RequestBody Map<String, String> request) {
String userContent = request.get("content");

if (userContent == null || userContent.isBlank()) {
return ApiResponse.onSuccess(
Map.of("answer", "내용을 입력해 주세요!", "keyword", "입력 필요"),
SuccessCode.OK
);
public ApiResponse<Map<String, Object>> ask(@RequestBody TextRequest textRequest) {
String userText = textRequest.getText();

if (userText == null || userText.isBlank()) {
throw new CustomException(ErrorCode.INVALID_REQUEST);
}

Map<String, Object> result = geminiService.askGemini(userContent);
Map<String, Object> result = geminiService.askGemini(userText);

return ApiResponse.onSuccess(result, SuccessCode.OK);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ public enum GeminiErrorCode implements BaseCode {
HttpStatus.BAD_GATEWAY,
"GEMINI-007",
"GEMINI 응답에 아무 내용이 없습니다."
),

GEMINI_INVALID_INPUT(
HttpStatus.BAD_REQUEST,
"GEMINI-008",
"의미있는 고민을 입력해주세요."
);

private final HttpStatus httpStatus;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
import com.example.demo.domain.gemini.dto.GeminiRequest;
import com.example.demo.domain.gemini.dto.GeminiResponse;
import com.example.demo.domain.gemini.exception.GeminiErrorCode;
import com.example.demo.domain.nerfilter.dto.AiResponse;
import com.example.demo.domain.nerfilter.dto.TextRequest;
import com.example.demo.domain.nerfilter.service.AiAnalysisService;
import com.example.demo.domain.ranking.service.RankingService;
import com.example.demo.shared.exception.CustomException;
import com.fasterxml.jackson.core.JsonProcessingException;
Expand Down Expand Up @@ -31,20 +34,38 @@ public class GeminiService {
private final ObjectMapper objectMapper;
private final RankingService rankingService;

private final AiAnalysisService aiAnalysisService;

public Map<String, Object> askGemini(String userContent) {
TextRequest textRequest = new TextRequest(userContent, true);
AiResponse aiResponse = aiAnalysisService.getAnalysis(textRequest);

String filteredContent = aiResponse.getFilteredText();
if (filteredContent == null || filteredContent.isBlank()) {
throw new CustomException(GeminiErrorCode.GEMINI_NO_CONTENT);
}


String cleanKey = apiKey.trim();

String url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=" + cleanKey;

String systemPrompt = "유저의 고민을 듣고 다정하게 위로해줘." +
"너무 ai 같지 않게 사람답게 말하고, 유저를 절대 비난해서는 안돼." +
"무조건적인 공감과 지지를 보내줘. 핵심 키워드 3개 정도를 욕설 제외하고 뽑아줘. 갯수에 제한 받기 보다는, 정말 중요하다 생각되는 키워드를 뽑아."
+ "반드시 JSON 형식으로만 대답해. 형식: {\"keyword\": [\"단어1\", \"단어2\", \"단어3\"], \"answer\": \"내용\"}";
String systemPrompt = "너는 유저의 고민을 듣고 위로해주는 상담사야. " +
"먼저 유저의 입력이 위로가 필요한 상황인지 판단해. " +
"거부해야 할 입력: 숫자만 나열, 명백한 테스트 메시지(예: 'test', '테스트', '123'), 봇/스팸 같은 입력, 의미없는 단어 무한 반복. " +
"위로해줘야 할 입력: 고민/감정 표현, 한글 자음모음을 막 친 것(빡치거나 힘들어서 그럴 수 있음), 욕설이나 분노 표현, 짧은 한숨이나 감정 표현. " +
"만약 위로가 필요한 상황이라면: 다정하게 위로해주고, 너무 AI 같지 않게 사람답게 말해. " +
"유저를 절대 비난하지 말고, 무조건적인 공감과 지지를 보내줘. " +
"키보드를 막 친 것 같으면 '많이 힘드셨나봐요' 같은 공감을 표현해줘. " +
"핵심 키워드를 욕설 제외하고 3개 정도 뽑아줘. 감정 키워드도 괜찮아. 갯수에 제한받기보다는 정말 중요한 키워드만 뽑아. " +
"반드시 JSON 형식으로만 대답해. " +
"위로할 때: {\"isValid\": true, \"keyword\": [\"단어1\", \"단어2\"], \"answer\": \"위로 내용\"} " +
"거부할 때: {\"isValid\": false, \"keyword\": [], \"answer\": \"\"}";

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

GeminiRequest request = GeminiRequest.of(systemPrompt, userContent);
GeminiRequest request = GeminiRequest.of(systemPrompt, filteredContent);
HttpEntity<GeminiRequest> entity = new HttpEntity<>(request, headers);

try {
Expand All @@ -59,6 +80,13 @@ public Map<String, Object> askGemini(String userContent) {
String cleanedJson = rawContent.replaceAll("(?s)```json|```", "").trim();
Map<String, Object> result = objectMapper.readValue(cleanedJson, Map.class);

// 무의미한 입력인지 확인
Object isValidObj = result.get("isValid");
boolean isValid = isValidObj instanceof Boolean ? (Boolean) isValidObj : true;
if (!isValid) {
throw new CustomException(GeminiErrorCode.GEMINI_INVALID_INPUT);
}

Object keywordObj = result.get("keyword");
if (keywordObj instanceof List) {
List<String> keywords = (List<String>) keywordObj;
Expand All @@ -72,11 +100,16 @@ public Map<String, Object> askGemini(String userContent) {
}
}

// isValid 필드 제거 후 반환
result.remove("isValid");
return result;
} catch (JsonProcessingException e) {
throw new CustomException(GeminiErrorCode.GEMINI_PARSE_ERROR);
}

} catch (CustomException e) {
// CustomException은 그대로 던지기
throw e;
} catch (HttpClientErrorException.NotFound e) {
System.err.println("### 404 에러 상세: " + e.getResponseBodyAsString());
throw new CustomException(GeminiErrorCode.GEMINI_API_ERROR);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.example.demo.domain.nerfilter.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.util.List;
import java.util.Map;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class AiResponse {

@JsonProperty("original_text")
private String originalText;

@JsonProperty("corrected_text")
private String correctedText;

@JsonProperty("filtered_text")
private String filteredText;

@JsonProperty("detected_entities")
private List<Map<String, Object>> detectedEntities; // ✅ Object로 변경

@JsonProperty("spelling_errors")
private List<Map<String, Object>> spellingErrors; // ✅ Object로 변경
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.demo.domain.nerfilter.dto;

public record NerEntity(
String entity,
double score,
String word,
int start,
int end
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.example.demo.domain.nerfilter.dto;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class TextRequest {

@JsonProperty("text")
private String text;

@JsonProperty("fix_spelling")
private boolean fixSpelling = true; // 자바 관례대로 camelCase를 쓰고 JSON 이름만 지정
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.example.demo.domain.nerfilter.service;

import com.example.demo.domain.nerfilter.dto.AiResponse;
import com.example.demo.domain.nerfilter.dto.TextRequest;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import org.springframework.http.MediaType;
import org.springframework.web.client.RestTemplate;

@Service
public class AiAnalysisService {

private final RestTemplate restTemplate;
private final String baseUrl = "https://jyhdevstore.store";

public AiAnalysisService() {
this.restTemplate = new RestTemplate();
}

public AiResponse getAnalysis(TextRequest text) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

HttpEntity<TextRequest> request = new HttpEntity<>(text, headers);

return restTemplate.postForObject(
baseUrl + "/filter",
request,
AiResponse.class
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public class RankingController {
)
@GetMapping("/top3")
public ApiResponse<List<Map<String, Object>>> getTop3() {
List<Map<String, Object>> result = rankingService.getTop3WithPercentage();
List<Map<String, Object>> result = rankingService.getTop3();
return ApiResponse.onSuccess(result, SuccessCode.OK);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,21 @@
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.*;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
public class RankingService {

private final StringRedisTemplate redisTemplate;
// 20분마다 갱신된 결과를 저장할 메모리 캐시
private List<Map<String, Object>> cachedTop3Ranking = new ArrayList<>();

// 시간별 redis key 생성
private String getRankingkey() {
Expand All @@ -35,15 +35,27 @@ public void incrementKeywordCount(String keyword) {
redisTemplate.opsForZSet().incrementScore(key, keyword, 1);
}

public List<Map<String, Object>> getTop3WithPercentage() {
String key = getRankingkey();
// 20분마다 무거운 계산(전체 합계 및 퍼센트)을 미리 수행
@Scheduled(fixedRate = 1200000) // 20분(ms 단위)
public void updateRankingCache() {
System.out.println("### [스케줄러] 20분 주기 랭킹 캐시 갱신 시작");
this.cachedTop3Ranking = calculateTop3WithPercentage();
}

public List<Map<String, Object>> getTop3() {
if (cachedTop3Ranking.isEmpty()) {
return calculateTop3WithPercentage(); // 캐시가 비어있을 때만 직접 계산
}
return cachedTop3Ranking;
}

private List<Map<String, Object>> calculateTop3WithPercentage() {
String key = getRankingkey();
Set<ZSetOperations.TypedTuple<String>> top3WithScores =
redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 2);

if (top3WithScores == null || top3WithScores.isEmpty()) return List.of();

// 현재 시간대의 전체 소각 횟수 합
Double totalScore = 0.0;
Set<ZSetOperations.TypedTuple<String>> allItems =
redisTemplate.opsForZSet().rangeWithScores(key, 0, -1);
Expand All @@ -58,13 +70,12 @@ public List<Map<String, Object>> getTop3WithPercentage() {
return top3WithScores.stream().map(tuple -> {
Map<String, Object> map = new LinkedHashMap<>();
double score = tuple.getScore() != null ? tuple.getScore() : 0.0;
double percentage = (score / finalTotalScore) * 100;
double percentage = finalTotalScore > 0 ? (score / finalTotalScore) * 100 : 0;

map.put("keyword", tuple.getValue());
map.put("percentage", Math.round(percentage) + "%");
return map;
}).collect(Collectors.toList());
}
}


}