22
33import com .ongil .backend .domain .review .dto .request .AiReviewGenerateRequest ;
44import 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 ;
57import com .ongil .backend .domain .review .validator .ReviewValidator ;
68import com .ongil .backend .global .common .exception .AppException ;
79import com .ongil .backend .global .common .exception .ErrorCode ;
1214import lombok .RequiredArgsConstructor ;
1315import lombok .extern .slf4j .Slf4j ;
1416
15- import org .springframework .beans .factory .annotation .Value ;
1617import org .springframework .stereotype .Service ;
1718
18- import java .util .ArrayList ;
1919import java .util .Arrays ;
2020import java .util .List ;
2121
2424@ RequiredArgsConstructor
2525public 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 ("\n 1. 위 리스트에 나열된 각 특징마다 서로 다른 느낌의 '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