Skip to content

Commit 9fcb733

Browse files
author
marshmallowing
committed
refactor: 리뷰 프롬프트 생성 로직 분리 및 빈 주입
1 parent ea14c66 commit 9fcb733

File tree

5 files changed

+349
-284
lines changed

5 files changed

+349
-284
lines changed

src/main/java/com/ongil/backend/domain/review/service/AiReviewGeneratorService.java

Lines changed: 26 additions & 284 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import com.ongil.backend.domain.review.dto.request.AiReviewGenerateRequest;
44
import com.ongil.backend.domain.review.dto.response.AiReviewResponse;
5+
import com.ongil.backend.domain.review.service.prompter.MaterialReviewPrompter;
6+
import com.ongil.backend.domain.review.service.prompter.SizeReviewPrompter;
57
import com.ongil.backend.domain.review.validator.ReviewValidator;
68
import com.ongil.backend.global.common.exception.AppException;
79
import com.ongil.backend.global.common.exception.ErrorCode;
@@ -12,10 +14,8 @@
1214
import lombok.RequiredArgsConstructor;
1315
import lombok.extern.slf4j.Slf4j;
1416

15-
import org.springframework.beans.factory.annotation.Value;
1617
import org.springframework.stereotype.Service;
1718

18-
import java.util.ArrayList;
1919
import java.util.Arrays;
2020
import java.util.List;
2121

@@ -24,301 +24,36 @@
2424
@RequiredArgsConstructor
2525
public class AiReviewGeneratorService {
2626

27+
private final OpenAiService openAiService;
28+
private final SizeReviewPrompter sizePrompter;
29+
private final MaterialReviewPrompter materialPrompter;
2730
private final ReviewValidator reviewValidator;
2831

29-
@Value("${openai.api-key}")
30-
private String openAiApiKey;
31-
32-
private static final String SIZE_REVIEW_PROMPT = """
33-
너는 의류 착용 후기를 생성하는 AI임.
34-
사용자가 선택한 의류 종류, 부위, 강도를 기반으로
35-
시니어도 이해하기 쉬운 표현으로 자연스러운 후기 문장을 생성해야 함.
36-
37-
아래 규칙을 반드시 모두 지켜야 함.
38-
39-
━━━━━━━━━━━━━━
40-
[1. 부위 범위 규칙]
41-
42-
사용자가 선택한 부위는 문장에서 언급 가능한 유일한 불편 범위임
43-
선택된 부위 외의 신체 부위, 상황, 결과는 절대 언급 불가함
44-
복합 부위(예: 엉덩이 & 가랑이)는 하나의 묶음이지만
45-
문장 안에서 단일 부위만 언급했을 경우,
46-
불편한 상황·감정·원인은 해당 부위로만 한정해야 함
47-
48-
예시로,
49-
가랑이가 답답해서 앉아 있을 때 불편함은 괜찮지만
50-
가랑이가 답답해서 엉덩이 봉제선이 당겨지는 느낌은 안됨
51-
52-
복합 부위를 함께 언급한 경우에만
53-
두 부위 모두에 대한 불편 상황 설명 가능함
54-
단, 부위 간 인과 관계(원인→결과) 표현은 금지함
55-
56-
━━━━━━━━━━━━━━
57-
[2. 강도 표현 규칙]
58-
59-
강도에 따라 아래 표현 중에서만 선택하여 사용해야 하며,
60-
의미를 벗어나는 과장·완화 표현은 사용 불가함
61-
62-
너무 답답:
63-
매우, 심하게, 숨쉬기 힘들 정도로
64-
65-
조금 답답:
66-
살짝, 신경 쓰일 정도로
67-
(허리&복부 / 가슴&몸통의 경우만)
68-
밥 먹고 나면 답답해지는 느낌임
69-
70-
약간 커서 거슬림:
71-
거슬릴 정도로
72-
73-
너무 커서 불편함:
74-
많이, 지나치게
75-
76-
수선이 필요할 정도로 큼:
77-
입고 다니기 힘들 정도로 큼
78-
79-
편함:
80-
입는 내내 편함
81-
신축성이 좋아 움직이기 편함
82-
이 경우 특정 부위 언급 없이
83-
전반적인 착용감만 작성해야 함
84-
85-
━━━━━━━━━━━━━━
86-
[3. 표현 톤 규칙 (시니어 친화)]
87-
88-
전문 용어, 유행어, 신체 과장 표현 사용 금지
89-
아래 표현은 사용하지 않음
90-
(핏, 옷매무새, Y존, 라인 부각, 실루엣 등)
91-
92-
대신 일상적이고 직관적인 표현 사용
93-
예:
94-
- 몸에 딱 붙는다
95-
- 움직일 때 불편하다
96-
- 앉거나 일어날 때 신경 쓰인다
97-
- 오래 입기엔 부담된다
98-
99-
━━━━━━━━━━━━━━
100-
[4. 문장 구조 규칙]
101-
102-
하나의 문장에는 하나의 불편 경험만 포함함
103-
평가 + 상황 + 느낌의 순서를 유지함
104-
문장 끝은 반드시 ~임 으로 끝냄
105-
106-
━━━━━━━━━━━━━━
107-
[출력 목표]
108-
109-
사용자가 선택한
110-
- 의류 종류
111-
- 부위
112-
- 강도
113-
를 정확히 반영하여
114-
부위 범위를 넘지 않는,
115-
강도에 맞는,
116-
시니어도 이해 가능한 한 문장 후기를 생성함.
117-
""";
118-
119-
private static final String MATERIAL_REVIEW_PROMPT = """
120-
당신은 의류 소재 착용 후기를 실제 사용자 경험처럼 풀어내는 AI입니다.
121-
특히 시니어 사용자가 이해하기 쉬운 표현을 최우선으로 사용해야 합니다.
122-
123-
━━━━━━━━━━━━━━━━━━
124-
[기본 전제]
125-
본 프롬프트는 소재에 대한 후기만 작성함
126-
핏, 사이즈, 신체 부위, 착용 부위 언급 금지
127-
소재의 인상과 느낌만 다룸
128-
129-
━━━━━━━━━━━━━━━━━━
130-
[가장 중요한 규칙 – 선택 항목 일치]
131-
사용자가 선택한 항목만 서술해야 함
132-
선택하지 않은 소재 속성으로 확장 금지
133-
예: 촉감 선택 → 촉감에 대한 내용만 작성
134-
예: 무게감 선택 → 무게감 외 언급 금지
135-
136-
좋은 점 선택 시 부정 표현 금지
137-
아쉬운 점 선택 시 긍정 표현 금지
138-
눈에 띄는 점 없음 선택 시
139-
→ 장점·단점 분석, 속성 설명 모두 금지
140-
141-
━━━━━━━━━━━━━━━━━━
142-
[핵심 작성 원칙]
143-
한 문장에는 하나의 느낌만 작성
144-
1인칭 후기 톤 사용
145-
시니어가 이해하기 쉬운 말만 사용
146-
모든 문장 끝맺음은 반드시 "~임"
147-
판단, 비교, 조언, 추천, 해결책 작성 금지
148-
149-
━━━━━━━━━━━━━━━━━━
150-
[소재 속성 카테고리]
151-
촉감 (부드러움 / 거칠음)
152-
무게감 (가벼움 / 무거움)
153-
구김 정도 (많음 / 없음)
154-
두께감 (얇음 / 두꺼움)
155-
보풀 (있음 / 없음)
156-
비침 정도 (안 비침 / 비침)
157-
158-
━━━━━━━━━━━━━━━━━━
159-
[2차 질문 – 좋은 점 선택 시 출력 규칙]
160-
선택한 속성 중 하나만 기준으로 1~2문장 작성
161-
편안함, 부담 없음, 일상 사용 중심으로 표현
162-
163-
[좋은 점 예시 가이드]
164-
촉감(부드러움):
165-
· 손에 닿는 느낌이 부드러움
166-
167-
무게감(가벼움):
168-
· 가벼워서 편안함
169-
170-
구김 없음:
171-
· 오래 입어도 구김이 잘 생기지 않음
172-
173-
두께감 두꺼움/얇음:
174-
· 두꺼워서 따뜻함 / 얇아서 시원함 / 봄, 가을에 입기 적당한 두께
175-
176-
보풀 없음:
177-
· 보풀이 잘 안나는 소재
178-
179-
비침 없음:
180-
· 안이 비치지 않아 신경 쓰이지 않음
181-
182-
━━━━━━━━━━━━━━━━━━
183-
[2차 질문 – 아쉬운 점 선택 시 출력 규칙]
184-
선택한 속성 하나만 기준으로 1~2문장 작성
185-
불편하지만 과장 없이, 체감 위주로 표현
186-
187-
[아쉬운 점 예시 가이드]
188-
촉감(거칠음):
189-
· 피부에 닿을 때 거칠음
190-
191-
무게감(무거움):
192-
· 무거워서 오래 입기엔 부담됨
193-
194-
구김 많음:
195-
· 조금만 움직여도 구김이 생김
196-
197-
두꺼움:
198-
· 두꺼워서 더움 / 얇아서 추움
199-
200-
보풀 있음:
201-
· 보풀이 금방 생기는 소재임
202-
203-
비침 있음:
204-
· 안이 비쳐 보여 신경 쓰임
205-
206-
━━━━━━━━━━━━━━━━━━
207-
[③ 눈에 띄는 점은 없었어요 선택 시 전용 규칙]
208-
소재 속성(촉감, 무게, 두께 등) 언급 금지
209-
장점·단점 분석 금지
210-
"무난함 / 평범함 / 거슬리지 않음" 인상만 전달
211-
212-
아래 문장 유형 중 1~2문장만 출력
213-
[허용 문장 예시]
214-
전반적으로 무난해서 부담 없이 입을 수 있음
215-
특별히 좋거나 아쉬운 점 없이 평범한 느낌임
216-
특별히 좋은 점은 없지만 입는 데 거슬리지도 않았음
217-
일상적으로 입는 데에 무리가 없는 평범한 소재임
218-
219-
━━━━━━━━━━━━━━━━━━
220-
[출력 분량 규칙]
221-
모든 선택지: 1~2문장
222-
문장 간 의미 중복 금지
223-
규칙 위반 시 잘못된 출력으로 간주됨
224-
""";
225-
22632
public AiReviewResponse generateSizeReview(AiReviewGenerateRequest request) {
22733
reviewValidator.validateReviewStepCompletion(request.getSizeAnswer(), request.getFitIssueParts());
228-
OpenAiService service = new OpenAiService(openAiApiKey);
229-
String userMessage = buildSizeReviewPrompt(request);
230-
231-
String aiResponse = callOpenAi(service, MATERIAL_REVIEW_PROMPT, userMessage);
23234

233-
List<String> reviewList = Arrays.stream(aiResponse.split("\\|"))
234-
.map(String::trim)
235-
.filter(s -> !s.isEmpty())
236-
.toList();
35+
String systemPrompt = sizePrompter.getSystemPrompt();
36+
String userMessage = sizePrompter.buildUserMessage(request);
23737

238-
return AiReviewResponse.of(request.getReviewId(), reviewList);
38+
String aiResponse = callOpenAi(systemPrompt, userMessage);
39+
return AiReviewResponse.of(request.getReviewId(), parseAiResponse(aiResponse));
23940
}
24041

24142
public AiReviewResponse generateMaterialReview(AiReviewGenerateRequest request) {
24243
reviewValidator.validateReviewStepCompletion(request.getMaterialAnswer(), request.getMaterialFeatures());
243-
OpenAiService service = new OpenAiService(openAiApiKey);
244-
String userMessage = buildMaterialReviewPrompt(request);
245-
246-
String aiResponse = callOpenAi(service, MATERIAL_REVIEW_PROMPT, userMessage);
247-
248-
List<String> reviewList = Arrays.stream(aiResponse.split("\\|"))
249-
.map(String::trim)
250-
.filter(s -> !s.isEmpty())
251-
.toList();
252-
253-
return AiReviewResponse.of(request.getReviewId(), reviewList);
254-
}
255-
256-
private String buildSizeReviewPrompt(AiReviewGenerateRequest request) {
257-
StringBuilder prompt = new StringBuilder();
258-
prompt.append("의류 종류: ").append(request.getClothingType().getDisplayName()).append("\n");
259-
prompt.append("착용 상태: ").append(request.getSizeAnswer().getDisplayName()).append("\n");
260-
261-
if (request.getSizeAnswer().isNeedsSecondaryQuestion() && !request.getFitIssueParts().isEmpty()) {
262-
prompt.append("불편 부위: ").append(String.join(", ", request.getFitIssueParts())).append("\n");
263-
prompt.append("\n[특수 지시]");
264-
prompt.append("- 위 리스트에 있는 각 '복합 부위' 항목 전체를 소재로 하여 서로 다른 2문장씩 생성할 것.\n");
265-
prompt.append("- 예: '가슴&몸통' 선택 시 -> 가슴과 몸통 전체의 착용감을 다루는 문장 2개 생성.\n");
266-
} else {
267-
prompt.append("특이사항: 전체적으로 편안함\n");
268-
prompt.append("[지시] 전반적인 편안함을 강조하는 서로 다른 2문장을 생성할 것.\n");
269-
}
270-
271-
prompt.append("\n[출력 형식] 반드시 각 문장 사이를 '|' 기호로만 구분하여 출력할 것.");
272-
return prompt.toString();
273-
}
274-
275-
private String buildMaterialReviewPrompt(AiReviewGenerateRequest request) {
276-
StringBuilder prompt = new StringBuilder();
277-
278-
boolean isPositive = request.getMaterialAnswer().isPositive();
279-
prompt.append("소재 평가 상태: ").append(isPositive ? "긍정적" : "부정적(아쉬움)").append("\n");
280-
prompt.append("소재 평가: ").append(request.getMaterialAnswer().getDisplayName()).append("\n");
281-
282-
if (!request.getMaterialFeatures().isEmpty()) {
283-
prompt.append("선택한 소재 특징:\n");
284-
boolean hasThicknessAll = false;
285-
286-
for (String feature : request.getMaterialFeatures()) {
287-
if ("두께감:선택지전체".equals(feature)) {
288-
hasThicknessAll = true;
289-
continue;
290-
}
291-
prompt.append("- ").append(feature).append("\n");
292-
}
293-
294-
prompt.append("\n[문장 생성 규칙]");
295-
prompt.append("\n1. 위 리스트에 나열된 각 특징마다 서로 다른 느낌의 '2문장씩'을 반드시 생성할 것.");
29644

297-
if (hasThicknessAll) {
298-
prompt.append("\n[특수 지시] 두께감은 아래 3가지 상황에 맞춰 생성하되, 각 문장 사이를 '|' 기호로 구분할 것:\n");
299-
if (isPositive) {
300-
prompt.append("1. 두꺼워서 따뜻함 | 2. 얇아서 시원함 | 3. 적당한 두께임\n");
301-
} else {
302-
prompt.append("1. 소재가 너무 두꺼워서 답답함 | 2. 너무 얇아서 추운 느낌임 | 3. 두께가 애매해서 아쉬움\n");
303-
}
304-
}
305-
}
306-
else {
307-
prompt.append("\n[지시] 특정 소재 속성 언급 없이, 전반적으로 무난하고 평범하다는 인상의 서로 다른 '2문장'을 생성할 것.\n");
308-
}
45+
String systemPrompt = materialPrompter.getSystemPrompt();
46+
String userMessage = materialPrompter.buildUserMessage(request);
30947

310-
prompt.append("\n[최종 출력 형식 지시]");
311-
prompt.append("\n- 모든 문장은 반드시 '|' 기호로만 구분하여 나열할 것.");
312-
prompt.append("\n- 문장 끝은 반드시 '~임'으로 끝낼 것.");
313-
prompt.append("\n- 마침표(.)나 줄바꿈(\n)을 구분자로 사용하지 말 것.");
314-
315-
return prompt.toString();
48+
String aiResponse = callOpenAi(systemPrompt, userMessage);
49+
return AiReviewResponse.of(request.getReviewId(), parseAiResponse(aiResponse));
31650
}
31751

318-
private String callOpenAi(OpenAiService service, String systemPrompt, String userMessage) {
319-
List<ChatMessage> messages = new ArrayList<>();
320-
messages.add(new ChatMessage(ChatMessageRole.SYSTEM.value(), systemPrompt));
321-
messages.add(new ChatMessage(ChatMessageRole.USER.value(), userMessage));
52+
private String callOpenAi(String systemPrompt, String userMessage) {
53+
List<ChatMessage> messages = List.of(
54+
new ChatMessage(ChatMessageRole.SYSTEM.value(), systemPrompt),
55+
new ChatMessage(ChatMessageRole.USER.value(), userMessage)
56+
);
32257

32358
ChatCompletionRequest completionRequest = ChatCompletionRequest.builder()
32459
.model("gpt-4o-mini")
@@ -327,11 +62,18 @@ private String callOpenAi(OpenAiService service, String systemPrompt, String use
32762
.build();
32863

32964
try {
330-
return service.createChatCompletion(completionRequest)
65+
return openAiService.createChatCompletion(completionRequest)
33166
.getChoices().get(0).getMessage().getContent().trim();
33267
} catch (Exception e) {
33368
log.error("AI 생성 실패: ", e);
33469
throw new AppException(ErrorCode.AI_GENERATION_ERROR);
33570
}
33671
}
72+
73+
private List<String> parseAiResponse(String aiResponse) {
74+
return Arrays.stream(aiResponse.split("\\|"))
75+
.map(String::trim)
76+
.filter(s -> !s.isEmpty())
77+
.toList();
78+
}
33779
}

0 commit comments

Comments
 (0)