Skip to content

Commit 2ee3536

Browse files
committed
[Feat] AI-01/AI-05 tone 파라미터 독립화 및 프롬프트 개선
1 parent 3e25c01 commit 2ee3536

10 files changed

Lines changed: 255 additions & 229 deletions

File tree

fastapi-server/main.py

Lines changed: 148 additions & 181 deletions
Large diffs are not rendered by default.

src/ai-prediction/controllers/ai.prediction.controller.js

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ const predictController = async (req, res, next) => {
1313
console.log('🔵 AI predictions 요청 받음:', req.body);
1414

1515
// 검증된 데이터 추출 (미들웨어에서 이미 검증 완료)
16-
const { words, context, refresh = false } = req.body;
16+
const { words, context, refresh = false, tone } = req.body;
1717
const userId = req.user?.userId; // 인증된 사용자 ID (학습 데이터 가중치 적용용)
1818

1919
// 캐시 조회 (refresh가 false이면 맥락 유무와 상관없이 조회)
2020
if (!refresh && words.length > 0) {
2121
const cacheContext = { previousMessages: context?.previousMessages || [] };
22-
const cacheKey = generateCacheKey(words, cacheContext, 'predictions');
22+
const cacheKey = generateCacheKey(words, cacheContext, 'predictions', null, tone);
2323
const cachedData = await getFromCache(cacheKey);
2424

2525
if (cachedData?.predictions) {
@@ -30,20 +30,20 @@ const predictController = async (req, res, next) => {
3030
const finalPredictions = rankedCached.map(pred => pred.sentence);
3131

3232
return res.status(200).success(
33-
{ predictions: finalPredictions, fromCache: true },
33+
{ predictions: finalPredictions, tone: tone || null, fromCache: true },
3434
'문장 추천 성공 (캐시)'
3535
);
3636
}
3737
}
3838

39-
// GPT 호출 (userId 전달하여 학습 데이터 가중치 적용)
40-
console.log('🤖 GPT API 호출:', { words, context, refresh, userId });
41-
const result = await predictSentences(words, null, context, refresh, userId);
39+
// GPT 호출 (userId, tone 전달)
40+
console.log('🤖 GPT API 호출:', { words, context, refresh, userId, tone });
41+
const result = await predictSentences(words, null, context, refresh, userId, tone);
4242

4343
// 캐시 저장 (모든 상황에서 원본 predictions 저장, 24시간 유지)
4444
if (words.length > 0) {
4545
const cacheContext = { previousMessages: context?.previousMessages || [] };
46-
const cacheKey = generateCacheKey(words, cacheContext, 'predictions');
46+
const cacheKey = generateCacheKey(words, cacheContext, 'predictions', null, tone);
4747
await saveToCache(cacheKey, { predictions: result.rawPredictions }, 86400);
4848
}
4949

@@ -54,7 +54,7 @@ const predictController = async (req, res, next) => {
5454
size: JSON.stringify(finalPredictions).length
5555
});
5656
return res.status(200).success(
57-
{ predictions: finalPredictions, fromCache: false },
57+
{ predictions: finalPredictions, tone: tone || null, fromCache: false },
5858
'문장 추천 성공'
5959
);
6060

src/ai-prediction/controllers/ai.style.controller.js

Lines changed: 13 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,8 @@ import { generateCacheKey, getFromCache, saveToCache } from '../../utils/cache.u
1111
* 예: ["질문"] 카드 → 3개 문장 모두 의문문
1212
* 예: ["질문", "부드럽게"] 카드 → 3개 문장 모두 부드러운 의문문 (다중 합성)
1313
*
14-
* - 어미 카드는 15개 제한에 포함되지 않음 (별도로 1~5개 제한)
15-
* - LLM이 커스텀 어미 카드도 유연하게 해석 (기본 5개에 고정 X)
16-
* - 다중 어미 카드 합성: 1~5개 카드를 동시에 선택 가능
14+
* - 어미 카드는 최대 5개 (tone과 별개)
15+
* - tone은 독립 파라미터로 FastAPI에 직접 전달 (endingCards에 포함 X)
1716
*/
1817
const transformStyleController = async (req, res, next) => {
1918
try {
@@ -23,23 +22,13 @@ const transformStyleController = async (req, res, next) => {
2322
const { words, endingCards, tone, refresh = false } = req.body;
2423
const userId = req.user?.userId; // 인증된 사용자 ID (학습 데이터 가중치 적용용)
2524

26-
// tone 우선 + endingCards 합성 정규화
27-
let normalizedEndingCards = Array.isArray(endingCards) ? [...endingCards] : [];
28-
29-
if (tone) {
30-
// endingCards 안에 존댓말/반말이 들어와도 tone이 우선이므로 제거
31-
normalizedEndingCards = normalizedEndingCards.filter(
32-
(card) => card !== '존댓말' && card !== '반말'
33-
);
34-
35-
const toneCard = tone === 'HONORIFIC' ? '존댓말' : '반말';
36-
normalizedEndingCards.unshift(toneCard);
37-
}
25+
// endingCards 정규화 (tone은 별도 파라미터로 FastAPI에 직접 전달)
26+
const normalizedEndingCards = Array.isArray(endingCards) ? [...endingCards] : [];
3827

3928
// 캐시 조회 (refresh가 false일 때만)
40-
if (!refresh && words.length > 0 && normalizedEndingCards.length > 0) {
29+
if (!refresh && words.length > 0 && (normalizedEndingCards.length > 0 || tone)) {
4130
const cacheContext = { previousMessages: [] };
42-
const cacheKey = generateCacheKey(words, cacheContext, 'styles', normalizedEndingCards);
31+
const cacheKey = generateCacheKey(words, cacheContext, 'styles', normalizedEndingCards, tone);
4332
const cached = await getFromCache(cacheKey);
4433

4534
if (cached?.sentences) {
@@ -57,6 +46,7 @@ const transformStyleController = async (req, res, next) => {
5746
{
5847
words: cached.words,
5948
endingCards: cached.endingCards,
49+
tone: tone || null,
6050
sentences: finalSentences,
6151
fromCache: true
6252
},
@@ -65,14 +55,14 @@ const transformStyleController = async (req, res, next) => {
6555
}
6656
}
6757

68-
// AI 문장 추천 호출 (userId 전달하여 학습 데이터 가중치 적용)
69-
console.log('🤖 FastAPI 호출:', { words, endingCards, refresh, userId });
70-
const result = await transformSentenceStyle(words, normalizedEndingCards, refresh, userId);
58+
// AI 문장 추천 호출 (userId, tone 전달)
59+
console.log('🤖 FastAPI 호출:', { words, endingCards: normalizedEndingCards, tone, refresh, userId });
60+
const result = await transformSentenceStyle(words, normalizedEndingCards, refresh, userId, tone);
7161

7262
// 캐시 저장 (원본 sentences만 저장, 사용자별 가중치 미적용)
73-
if (words.length > 0 && normalizedEndingCards.length > 0) {
63+
if (words.length > 0 && (normalizedEndingCards.length > 0 || tone)) {
7464
const cacheContext = { previousMessages: [] };
75-
const cacheKey = generateCacheKey(words, cacheContext, 'styles', normalizedEndingCards);
65+
const cacheKey = generateCacheKey(words, cacheContext, 'styles', normalizedEndingCards, tone);
7666
await saveToCache(cacheKey, {
7767
words: result.words,
7868
endingCards: result.endingCards,
@@ -84,6 +74,7 @@ const transformStyleController = async (req, res, next) => {
8474
{
8575
words: result.words,
8676
endingCards: result.endingCards,
77+
tone: tone || null,
8778
sentences: result.sentences, // 가중치 적용된 sentences (문자열 배열)
8879
fromCache: false
8980
},

src/ai-prediction/dto/ai.prediction.dto.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ const predictRequestSchema = Joi.object({
1919
previousMessages: Joi.array().items(Joi.string()).optional()
2020
}).optional(),
2121
// refresh 필드를 허용하고 기본값을 false로 설정
22-
refresh: Joi.boolean().default(false)
22+
refresh: Joi.boolean().default(false),
23+
tone: Joi.string().valid('HONORIFIC', 'INFORMAL').optional()
2324
}).options({ stripUnknown: true });
2425

2526
export { predictRequestSchema };

src/ai-prediction/middlewares/ai.validator.js

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { ValidationError } from '../../errors/app.error.js';
88
export const validatePredictRequest = (req, res, next) => {
99
const { error, value } = predictRequestSchema.validate(req.body);
1010

11-
const { words = [] } = error ? req.body : value;
11+
const { words = [], tone } = error ? req.body : value;
1212

1313
// 1. 낱말 카드 없으면 에러
1414
if (!words || words.length === 0) {
@@ -20,7 +20,15 @@ export const validatePredictRequest = (req, res, next) => {
2020
return next(new ValidationError('낱말 카드는 최소 1개, 최대 10개까지 선택 가능합니다'));
2121
}
2222

23-
// 3. Joi 검증 에러 처리
23+
// 3. tone 검증 (있을 때만)
24+
if (tone !== undefined && tone !== null) {
25+
const validTones = ['HONORIFIC', 'INFORMAL'];
26+
if (!validTones.includes(tone)) {
27+
return next(new ValidationError('tone은 HONORIFIC 또는 INFORMAL만 가능합니다'));
28+
}
29+
}
30+
31+
// 4. Joi 검증 에러 처리
2432
if (error) {
2533
return next(new ValidationError(error.details[0].message));
2634
}

src/ai-prediction/routes/ai.prediction.route.js

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ const router = express.Router();
2121
* /api/ai/predictions:
2222
* post:
2323
* summary: 문장 추천 (기본 3가지)
24-
* description: 낱말 카드를 조합하여 자연스러운 문장 3개를 추천합니다. 캐시가 있으면 즉시 반환하고, 없으면 AI 호출 후 캐시에 저장합니다.
24+
* description: |
25+
* 낱말 카드를 조합하여 자연스러운 문장 3개를 추천합니다. 캐시가 있으면 즉시 반환하고, 없으면 AI 호출 후 캐시에 저장합니다.
26+
*
27+
* **`tone`** — 반말/존댓말 토글 (기기 내 설정값 반영)
28+
*
29+
* > 토글 OFF → `HONORIFIC` (존댓말, 기본값) / 토글 ON → `INFORMAL` (반말)
2530
* tags: [AI]
2631
* security:
2732
* - bearerAuth: []
@@ -56,6 +61,10 @@ const router = express.Router();
5661
* type: string
5762
* description: 최근 대화 기록 (최대 10분 이내)
5863
* example: ["약 먹어야 해", "오늘 기분 좋아"]
64+
* tone:
65+
* type: string
66+
* enum: [HONORIFIC, INFORMAL]
67+
* description: "tone — 반말/존댓말 토글 (기기 내 설정값 반영)\n\n토글 OFF → HONORIFIC (존댓말, 기본값) / 토글 ON → INFORMAL (반말)"
5968
* refresh:
6069
* type: boolean
6170
* default: false
@@ -81,6 +90,12 @@ const router = express.Router();
8190
* items:
8291
* type: string
8392
* example: ["물 좀 주세요", "물 주실래요?", "물 한 잔 주시겠어요?"]
93+
* tone:
94+
* type: string
95+
* nullable: true
96+
* enum: [HONORIFIC, INFORMAL]
97+
* description: 요청한 tone 값 (미전달 시 null)
98+
* example: "HONORIFIC"
8499
* fromCache:
85100
* type: boolean
86101
* description: 캐시에서 반환되었는지 여부
@@ -111,6 +126,14 @@ const router = express.Router();
111126
* code: "VALIDATION001"
112127
* message: "낱말 카드는 최소 1개, 최대 10개까지 선택 가능합니다"
113128
* detail: null
129+
* invalidTone:
130+
* summary: tone 값 오류
131+
* value:
132+
* success: false
133+
* error:
134+
* code: "VALIDATION001"
135+
* message: "tone은 HONORIFIC 또는 INFORMAL만 가능합니다"
136+
* detail: null
114137
* 401:
115138
* $ref: '#/components/responses/Unauthorized'
116139
* 500:
@@ -301,11 +324,13 @@ router.get('/contexts', authenticate, contextController);
301324
* 낱말 카드 + 어미 카드를 조합하여 특정 스타일의 문장을 생성합니다. predictions와 동일한 Cache-First 전략을 사용합니다.
302325
*
303326
* **`tone`** — 반말/존댓말 토글 (기기 내 설정값 반영)
304-
* 토글 OFF → `HONORIFIC` (존댓말, 기본값) / 토글 ON → `INFORMAL` (반말)
327+
*
328+
* > 토글 OFF → `HONORIFIC` (존댓말, 기본값) / 토글 ON → `INFORMAL` (반말)
305329
*
306330
* **`endingCards`** — 어미 선택 카드 (문장 스타일 지정)
307-
* `하고 싶어요` / `하기 싫어요` / `질문` / `해주세요` / `합시다` 및 사용자 커스텀 어미 가능.
308-
* LLM이 어미의 의미를 해석하여 자연스러운 문장으로 변환합니다.
331+
*
332+
* > `하고 싶어요` / `하기 싫어요` / `질문` / `해주세요` / `합시다` 및 사용자 커스텀 어미 가능.
333+
* > LLM이 어미의 의미를 해석하여 자연스러운 문장으로 변환합니다.
309334
*
310335
* 두 파라미터는 **독립적이며 동시에 사용 가능**합니다. `tone`은 반말/존댓말만 제어하고, `endingCards`는 문장의 의도/어미를 제어합니다.
311336
* tags: [AI]
@@ -339,11 +364,11 @@ router.get('/contexts', authenticate, contextController);
339364
* type: string
340365
* minItems: 1
341366
* maxItems: 5
342-
* description: "선택한 어미 카드 배열 (1~5개). tone 없이 사용 시 필수. 기본 카드: 하고 싶어요, 하기 싫어요, 질문, 해주세요, 합시다"
367+
* description: "선택한 어미 카드 배열 (최대 5개). tone 없이 사용 시 필수. 기본 카드: 하고 싶어요, 하기 싫어요, 질문, 해주세요, 합시다. tone과 독립적으로 동시 사용 가능."
343368
* tone:
344369
* type: string
345370
* enum: [HONORIFIC, INFORMAL]
346-
* description: "반말/존댓말 모드. HONORIFIC(존댓말) 또는 INFORMAL(반말). endingCards 없이 단독 사용 가능. 있을 경우 endingCards 앞에 반말/존댓말 카드로 변환되어 우선 적용됨."
371+
* description: "tone — 반말/존댓말 토글 (기기 내 설정값 반영)\n\n토글 OFF → HONORIFIC (존댓말, 기본값) / 토글 ON → INFORMAL (반말)"
347372
* refresh:
348373
* type: boolean
349374
* default: false
@@ -372,11 +397,17 @@ router.get('/contexts', authenticate, contextController);
372397
* type: array
373398
* items:
374399
* type: string
375-
* description: "실제 적용된 어미 카드 배열. tone 사용 시 맨 앞에 반말/존댓말 카드가 추가됨. 예: tone=INFORMAL → [\"반말\", \"질문\"]"
376-
* example: ["반말", "질문"]
400+
* description: "입력한 어미 카드 배열 (tone 카드 미포함)"
401+
* example: ["질문"]
402+
* tone:
403+
* type: string
404+
* nullable: true
405+
* enum: [HONORIFIC, INFORMAL]
406+
* description: 요청한 tone 값 (미전달 시 null)
407+
* example: "INFORMAL"
377408
* sentences:
378409
* type: array
379-
* description: 스타일 변환된 문장 3개 (사용자별 가중치 적용 후 정렬, 문자열 배열)
410+
* description: "스타일 변환된 문장 3개 (tone + endingCards 동시 적용, 사용자별 가중치 정렬)"
380411
* items:
381412
* type: string
382413
* example: ["밥 먹을래?", "밥 먹어?", "밥 먹을 거야?"]
@@ -426,6 +457,22 @@ router.get('/contexts', authenticate, contextController);
426457
* code: "VALIDATION001"
427458
* message: "어미 선택 카드는 최대 5개까지 선택 가능합니다"
428459
* detail: null
460+
* noToneOrEndings:
461+
* summary: tone과 어미 카드 모두 없음
462+
* value:
463+
* success: false
464+
* error:
465+
* code: "VALIDATION001"
466+
* message: "어미 선택 카드 또는 tone 중 하나는 반드시 제공해야 합니다"
467+
* detail: null
468+
* invalidTone:
469+
* summary: tone 값 오류
470+
* value:
471+
* success: false
472+
* error:
473+
* code: "VALIDATION001"
474+
* message: "tone은 HONORIFIC 또는 INFORMAL만 가능합니다"
475+
* detail: null
429476
* 401:
430477
* $ref: '#/components/responses/Unauthorized'
431478
* 500:

src/ai-prediction/services/ai.prediction.service.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ const rankByLearningData = async (predictions, userId) => {
101101
* @param {string} userId - 사용자 ID (학습 데이터 가중치 적용용)
102102
* @returns {Promise<Array>} 추천 문장 3개 (빈도수 가중치 적용 후 정렬)
103103
*/
104-
const predictSentences = async (words = [], typedText = '', context = {}, refresh = false, userId = null) => {
104+
const predictSentences = async (words = [], typedText = '', context = {}, refresh = false, userId = null, tone = null) => {
105105
const { currentTime, previousMessages = [] } = context;
106106

107107
// FastAPI 요청 페이로드 생성
@@ -111,7 +111,8 @@ const predictSentences = async (words = [], typedText = '', context = {}, refres
111111
currentTime: currentTime,
112112
previousMessages: previousMessages.slice(-3) // 최근 3개만 전달
113113
},
114-
refresh // 새로고침 파라미터 추가
114+
refresh,
115+
...(tone && { tone }) // tone이 있을 때만 포함
115116
};
116117

117118
// AbortController로 타임아웃 처리 (10초)

src/ai-prediction/services/ai.style.service.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const FASTAPI_URL = process.env.FASTAPI_URL || 'http://fastapi:8000';
1515
* @param {string} userId - 사용자 ID (학습 데이터 가중치 적용용)
1616
* @returns {Promise<Object>} 추천 문장 3개 (빈도수 가중치 적용 후 정렬) + rawSentences
1717
*/
18-
const transformSentenceStyle = async (words, endingCards, refresh = false, userId = null) => {
18+
const transformSentenceStyle = async (words, endingCards, refresh = false, userId = null, tone = null) => {
1919
// 타임아웃 처리 (10초)
2020
const timeoutPromise = new Promise((_, reject) => {
2121
setTimeout(() => {
@@ -31,8 +31,9 @@ const transformSentenceStyle = async (words, endingCards, refresh = false, userI
3131
},
3232
body: JSON.stringify({
3333
words,
34-
endingCards, // 배열로 전달 (1~5개)
35-
refresh // 새로고침 파라미터
34+
endingCards: endingCards.length > 0 ? endingCards : undefined, // 빈 배열이면 전달 X
35+
...(tone && { tone }), // tone이 있을 때만 포함
36+
refresh
3637
})
3738
}).then(async (response) => {
3839
if (!response.ok) {

src/app.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ app.use(responseHelper);
9494
// +) 라우터 등록
9595
// Swagger UI: 개발/스테이징에서만 활성화 (프로덕션에서는 보안을 위해 비활성화)
9696
if (process.env.NODE_ENV !== 'production') {
97+
// 브라우저 캐싱 방지 — spec 변경 시 즉시 반영
98+
app.use('/api-docs', (req, res, next) => {
99+
res.setHeader('Cache-Control', 'no-store');
100+
next();
101+
});
97102
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
98103
swaggerOptions: {
99104
persistAuthorization: true,

src/utils/cache.util.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import redisClient from '../config/redis.config.js';
1919
* generateCacheKey(['밥', '먹다'], { previousMessages: [] }, 'styles', ['질문', '부드럽게'])
2020
* // Returns: 'aac:styles:e5f6g7h8...'
2121
*/
22-
export function generateCacheKey(words, context, endpoint = 'predictions', endingCards = null) {
22+
export function generateCacheKey(words, context, endpoint = 'predictions', endingCards = null, tone = null) {
2323
const cacheData = {
2424
words: [...words].sort(), // 순서 무관하게 정렬 (FastAPI와 동일)
2525
context: {
@@ -33,6 +33,11 @@ export function generateCacheKey(words, context, endpoint = 'predictions', endin
3333
cacheData.endingCards = [...endingCards].sort(); // 순서 무관하게 정렬
3434
}
3535

36+
// tone이 있으면 캐시 키에 포함 (반말/존댓말별로 캐시 분리)
37+
if (tone) {
38+
cacheData.tone = tone;
39+
}
40+
3641
// 키를 정렬하여 JSON 문자열로 변환 (Python의 sort_keys=True 효과)
3742
const sortedData = Object.keys(cacheData).sort().reduce((obj, key) => {
3843
obj[key] = cacheData[key];

0 commit comments

Comments
 (0)