Skip to content

Commit 770f074

Browse files
authored
Merge pull request #28 from sgn07124/fix/prompt
GPT 기반 면접 질문 생성 시 특정 주제 반복 문제 해결
2 parents 3a161e8 + 4367573 commit 770f074

20 files changed

Lines changed: 656 additions & 38 deletions

build.gradle

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ dependencies {
3838
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0'
3939
implementation 'org.springframework.boot:spring-boot-starter-mail'
4040
implementation 'org.springframework.boot:spring-boot-starter-webflux'
41+
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
42+
4143
compileOnly 'org.projectlombok:lombok'
4244
annotationProcessor 'org.projectlombok:lombok'
4345
runtimeOnly "io.netty:netty-resolver-dns-native-macos:4.1.110.Final:osx-aarch_64"

src/main/java/com/project/InsightPrep/domain/question/dto/response/QuestionResponse.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public static class QuestionDto {
2424
@JsonInclude(Include.NON_NULL)
2525
public static class GptQuestion {
2626
private String question;
27+
private String topic;
28+
private String keyword;
2729
}
2830

2931
@Getter
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.project.InsightPrep.domain.question.entity;
2+
3+
public enum ItemType {
4+
TOPIC, KEYWORD
5+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.project.InsightPrep.domain.question.entity;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.Entity;
5+
import jakarta.persistence.EnumType;
6+
import jakarta.persistence.Enumerated;
7+
import jakarta.persistence.GeneratedValue;
8+
import jakarta.persistence.GenerationType;
9+
import jakarta.persistence.Id;
10+
import jakarta.persistence.Index;
11+
import jakarta.persistence.PrePersist;
12+
import jakarta.persistence.Table;
13+
import jakarta.persistence.UniqueConstraint;
14+
import java.time.LocalDateTime;
15+
import lombok.AccessLevel;
16+
import lombok.AllArgsConstructor;
17+
import lombok.Builder;
18+
import lombok.Getter;
19+
import lombok.NoArgsConstructor;
20+
21+
@Entity
22+
@Table(
23+
name = "recent_prompt_filters",
24+
indexes = {
25+
@Index(name = "idx_recent_10", columnList = "member_id, category, item_type, created_at")
26+
},
27+
uniqueConstraints = {
28+
@UniqueConstraint(
29+
name = "uq_user_cat_type_value",
30+
columnNames = {"member_id", "category", "item_type", "item_value"}
31+
)
32+
}
33+
)
34+
@Getter
35+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
36+
@AllArgsConstructor
37+
@Builder
38+
public class RecentPromptFilter {
39+
40+
@Id
41+
@GeneratedValue(strategy = GenerationType.IDENTITY)
42+
private Long id;
43+
44+
@Column(name = "member_id", nullable = false)
45+
private Long memberId;
46+
47+
@Column(nullable = false, length = 50)
48+
private String category;
49+
50+
@Enumerated(EnumType.STRING)
51+
@Column(nullable = false, length = 20)
52+
private ItemType itemType; // 주제 / 키워드
53+
54+
@Column(nullable = false, length = 200)
55+
private String itemValue;
56+
57+
@Column(nullable = false, updatable = false)
58+
private LocalDateTime createdAt;
59+
60+
@PrePersist
61+
void onCreate() {
62+
this.createdAt = LocalDateTime.now();
63+
}
64+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.project.InsightPrep.domain.question.mapper;
2+
3+
import com.project.InsightPrep.domain.question.entity.ItemType;
4+
import com.project.InsightPrep.domain.question.entity.RecentPromptFilter;
5+
import java.util.List;
6+
import org.apache.ibatis.annotations.Mapper;
7+
import org.apache.ibatis.annotations.Param;
8+
9+
@Mapper
10+
public interface RecentPromptFilterMapper {
11+
void insert(RecentPromptFilter recentPromptFilter);
12+
13+
List<String> findTopNByUserCategoryType(
14+
@Param("memberId") long memberId,
15+
@Param("category") String category,
16+
@Param("type")ItemType type,
17+
@Param("limit") int limit);
18+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.project.InsightPrep.domain.question.service;
2+
3+
import com.project.InsightPrep.domain.question.entity.ItemType;
4+
import java.util.List;
5+
6+
public interface RecentPromptFilterService {
7+
8+
void record(long memberId, String category, ItemType type, String value);
9+
10+
List<String> getRecent(long memberId, String category, ItemType type, int limit);
11+
}

src/main/java/com/project/InsightPrep/domain/question/service/impl/QuestionServiceImpl.java

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionDto;
77
import com.project.InsightPrep.domain.question.dto.response.QuestionResponse.QuestionsDto;
88
import com.project.InsightPrep.domain.question.entity.AnswerStatus;
9+
import com.project.InsightPrep.domain.question.entity.ItemType;
910
import com.project.InsightPrep.domain.question.entity.Question;
1011
import com.project.InsightPrep.domain.question.mapper.AnswerMapper;
1112
import com.project.InsightPrep.domain.question.mapper.QuestionMapper;
1213
import com.project.InsightPrep.domain.question.service.QuestionService;
14+
import com.project.InsightPrep.domain.question.service.RecentPromptFilterService;
1315
import com.project.InsightPrep.global.auth.util.SecurityUtil;
16+
import com.project.InsightPrep.global.gpt.dto.response.GptMessage;
1417
import com.project.InsightPrep.global.gpt.prompt.PromptFactory;
1518
import com.project.InsightPrep.global.gpt.service.GptResponseType;
1619
import com.project.InsightPrep.global.gpt.service.GptService;
@@ -28,13 +31,26 @@ public class QuestionServiceImpl implements QuestionService {
2831
private final GptService gptService;
2932
private final QuestionMapper questionMapper;
3033
private final AnswerMapper answerMapper;
34+
private final RecentPromptFilterService recentPromptFilterService;
3135
private final SecurityUtil securityUtil;
3236

3337
@Override
3438
@Transactional
3539
public QuestionDto createQuestion(String category) {
36-
GptQuestion gptQuestion = gptService.callOpenAI(PromptFactory.forQuestionGeneration(category), 1000, 0.6, GptResponseType.QUESTION);
40+
long memberId = securityUtil.getLoginMemberId();
41+
// 1) 최근 금지 주제/키워드 조회 (없을 수 있음)
42+
List<String> bannedTopics = recentPromptFilterService.getRecent(memberId, category, ItemType.TOPIC, 10);
43+
List<String> bannedKeywords = recentPromptFilterService.getRecent(memberId, category, ItemType.KEYWORD, 10);
44+
45+
// 2) 프롬프트 선택 (있으면 주입, 없으면 기본)
46+
List<GptMessage> prompt = (hasAny(bannedTopics, bannedKeywords))
47+
? PromptFactory.forQuestionGeneration(category, bannedTopics, bannedKeywords)
48+
: PromptFactory.forQuestionGeneration(category);
3749

50+
// 3) 호출
51+
GptQuestion gptQuestion = gptService.callOpenAI(prompt, 1000, 0.6, GptResponseType.QUESTION);
52+
53+
// 4) DB에 저장
3854
Question question = Question.builder()
3955
.category(category)
4056
.content(gptQuestion.getQuestion())
@@ -43,6 +59,14 @@ public QuestionDto createQuestion(String category) {
4359

4460
questionMapper.insertQuestion(question);
4561

62+
// 5) 기록 (Redis + DB) - 응답에 topic/keyword가 비어있을 수도 있으므로 방어
63+
if (isNotBlank(gptQuestion.getTopic())) {
64+
recentPromptFilterService.record(memberId, category, ItemType.TOPIC, gptQuestion.getTopic());
65+
}
66+
if (isNotBlank(gptQuestion.getKeyword())) {
67+
recentPromptFilterService.record(memberId, category, ItemType.KEYWORD, gptQuestion.getKeyword());
68+
}
69+
4670
return QuestionResponse.QuestionDto.builder()
4771
.id(question.getId())
4872
.content(question.getContent())
@@ -64,4 +88,10 @@ public PageResponse<QuestionsDto> getQuestions(int page, int size) {
6488
long total = answerMapper.countQuestionsWithFeedback(memberId);
6589
return PageResponse.of(content, safePage, safeSize, total);
6690
}
91+
92+
private boolean hasAny(List<String> a, List<String> b) {
93+
return (a != null && !a.isEmpty()) || (b != null && !b.isEmpty());
94+
}
95+
96+
private boolean isNotBlank(String s) { return s != null && !s.isBlank(); }
6797
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.project.InsightPrep.domain.question.service.impl;
2+
3+
import com.project.InsightPrep.domain.question.entity.ItemType;
4+
import com.project.InsightPrep.domain.question.entity.RecentPromptFilter;
5+
import com.project.InsightPrep.domain.question.mapper.RecentPromptFilterMapper;
6+
import com.project.InsightPrep.domain.question.service.RecentPromptFilterService;
7+
import java.time.Duration;
8+
import java.util.ArrayList;
9+
import java.util.List;
10+
import java.util.Set;
11+
import lombok.RequiredArgsConstructor;
12+
import lombok.extern.slf4j.Slf4j;
13+
import org.springframework.dao.DataIntegrityViolationException;
14+
import org.springframework.data.redis.core.StringRedisTemplate;
15+
import org.springframework.stereotype.Service;
16+
import org.springframework.transaction.annotation.Transactional;
17+
18+
@Service
19+
@Slf4j
20+
@RequiredArgsConstructor
21+
public class RecentPromptFilterServiceImpl implements RecentPromptFilterService {
22+
23+
private final StringRedisTemplate redis;
24+
private final RecentPromptFilterMapper recentMapper;
25+
private static final String KEY_FMT = "rp:%d:%s:%s"; // memberId, category, type
26+
public static final int MAX_SIZE = 10;
27+
public static final Duration TTL = Duration.ofDays(14); // 만료일
28+
29+
@Override
30+
@Transactional
31+
public void record(long memberId, String category, ItemType type, String value) {
32+
// DB 영구 저장 (unique 제약 조건으로 중복 방지)
33+
RecentPromptFilter recentPromptFilter = RecentPromptFilter.builder()
34+
.memberId(memberId)
35+
.category(category)
36+
.itemType(type)
37+
.itemValue(value)
38+
.build();
39+
try {
40+
recentMapper.insert(recentPromptFilter);
41+
} catch (DataIntegrityViolationException ignore) {
42+
// 유니크 제약 충돌은 무시 (이미 기록된 값)
43+
}
44+
45+
// redis 캐시 (최근 10개 유지)
46+
String key = key(memberId, category, type);
47+
double score = System.currentTimeMillis();
48+
redis.opsForZSet().add(key, value, score);
49+
50+
// 오래된 것 제거
51+
Long size = redis.opsForZSet().size(key); // ZSet: 낮은 rank가 오래된 것
52+
if (size != null && size > MAX_SIZE) {
53+
redis.opsForZSet().removeRange(key, 0, size - MAX_SIZE - 1);
54+
}
55+
56+
redis.expire(key, TTL); // TTL 적용
57+
}
58+
59+
@Override
60+
@Transactional(readOnly = true)
61+
public List<String> getRecent(long memberId, String category, ItemType type, int limit) {
62+
String key = key(memberId, category, type);
63+
64+
// 최신순 상위 N
65+
Set<String> z = redis.opsForZSet().reverseRange(key, 0, Math.max(0, limit - 1));
66+
if (z != null && !z.isEmpty()) {
67+
return new ArrayList<>(z);
68+
}
69+
70+
// 캐시 미스 → DB fallback (최근 10개)
71+
List<String> fromDb = recentMapper.findTopNByUserCategoryType(memberId, category, type, limit);
72+
for (int i = 0; i < fromDb.size(); i++) {
73+
redis.opsForZSet().add(key, fromDb.get(i), System.currentTimeMillis() + i);
74+
}
75+
redis.expire(key, TTL);
76+
return fromDb;
77+
}
78+
79+
private String key(long userId, String category, ItemType type) {
80+
return String.format(KEY_FMT, userId, category, type.name());
81+
}
82+
}

src/main/java/com/project/InsightPrep/global/config/CorsConfig.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public class CorsConfig {
1313
public CorsConfigurationSource corsConfigurationSource() {
1414
CorsConfiguration cfg = new CorsConfiguration();
1515
cfg.setAllowedOrigins(List.of("http://localhost:5173", "http://localhost:8080"));
16-
cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS"));
16+
cfg.setAllowedMethods(List.of("GET","POST","PUT","DELETE","OPTIONS", "PATCH"));
1717
cfg.setAllowedHeaders(List.of("*"));
1818
cfg.setAllowCredentials(true);
1919
cfg.setMaxAge(3600L);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.project.InsightPrep.global.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.data.redis.connection.RedisConnectionFactory;
6+
import org.springframework.data.redis.core.StringRedisTemplate;
7+
8+
@Configuration
9+
public class RedisConfig {
10+
11+
@Bean
12+
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
13+
return new StringRedisTemplate(connectionFactory);
14+
}
15+
}

0 commit comments

Comments
 (0)