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
327 changes: 147 additions & 180 deletions fastapi-server/main.py

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions src/ai-prediction/controllers/ai.prediction.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ const predictController = async (req, res, next) => {
console.log('🔵 AI predictions 요청 받음:', req.body);

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

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

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

return res.status(200).success(
{ predictions: finalPredictions, fromCache: true },
{ predictions: finalPredictions, tone: tone || null, fromCache: true },
'문장 추천 성공 (캐시)'
);
}
}

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

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

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

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

// tone 우선 + endingCards 합성 정규화
let normalizedEndingCards = Array.isArray(endingCards) ? [...endingCards] : [];

if (tone) {
// endingCards 안에 존댓말/반말이 들어와도 tone이 우선이므로 제거
normalizedEndingCards = normalizedEndingCards.filter(
(card) => card !== '존댓말' && card !== '반말'
);

const toneCard = tone === 'HONORIFIC' ? '존댓말' : '반말';
normalizedEndingCards.unshift(toneCard);
}
// endingCards 정규화 (tone은 별도 파라미터로 FastAPI에 직접 전달)
const normalizedEndingCards = Array.isArray(endingCards) ? [...endingCards] : [];

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

if (cached?.sentences) {
Expand All @@ -57,6 +46,7 @@ const transformStyleController = async (req, res, next) => {
{
words: cached.words,
endingCards: cached.endingCards,
tone: tone || null,
sentences: finalSentences,
fromCache: true
},
Expand All @@ -65,14 +55,14 @@ const transformStyleController = async (req, res, next) => {
}
}

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

// 캐시 저장 (원본 sentences만 저장, 사용자별 가중치 미적용)
if (words.length > 0 && normalizedEndingCards.length > 0) {
const cacheContext = { previousMessages: [] };
const cacheKey = generateCacheKey(words, cacheContext, 'styles', normalizedEndingCards);
const cacheKey = generateCacheKey(words, cacheContext, 'styles', normalizedEndingCards, tone);
await saveToCache(cacheKey, {
words: result.words,
endingCards: result.endingCards,
Expand All @@ -84,6 +74,7 @@ const transformStyleController = async (req, res, next) => {
{
words: result.words,
endingCards: result.endingCards,
tone: tone || null,
sentences: result.sentences, // 가중치 적용된 sentences (문자열 배열)
fromCache: false
},
Expand Down
3 changes: 2 additions & 1 deletion src/ai-prediction/dto/ai.prediction.dto.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ const predictRequestSchema = Joi.object({
previousMessages: Joi.array().items(Joi.string()).optional()
}).optional(),
// refresh 필드를 허용하고 기본값을 false로 설정
refresh: Joi.boolean().default(false)
refresh: Joi.boolean().default(false),
tone: Joi.string().valid('HONORIFIC', 'INFORMAL').optional()
}).options({ stripUnknown: true });

export { predictRequestSchema };
36 changes: 17 additions & 19 deletions src/ai-prediction/middlewares/ai.validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { ValidationError } from '../../errors/app.error.js';
export const validatePredictRequest = (req, res, next) => {
const { error, value } = predictRequestSchema.validate(req.body);

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

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

// 3. Joi 검증 에러 처리
// 3. tone 검증 (있을 때만)
if (tone !== undefined && tone !== null) {
const validTones = ['HONORIFIC', 'INFORMAL'];
if (!validTones.includes(tone)) {
return next(new ValidationError('tone은 HONORIFIC 또는 INFORMAL만 가능합니다'));
}
}

// 4. Joi 검증 에러 처리
if (error) {
return next(new ValidationError(error.details[0].message));
}
Expand All @@ -47,33 +55,23 @@ export const validateStyleRequest = (req, res, next) => {
return next(new ValidationError('낱말 카드는 최대 10개까지 선택 가능합니다'));
}

const hasTone = typeof tone === 'string' && tone.length > 0;
const hasEndingCards = Array.isArray(endingCards);
// endingCards 필수 검증
if (!Array.isArray(endingCards) || endingCards.length === 0) {
return next(new ValidationError('어미 선택 카드를 최소 1개 이상 선택해주세요'));
}

// tone도 endingCards도 없으면 에러
if (!hasTone && !hasEndingCards) {
return next(new ValidationError('어미 선택 카드 또는 tone 중 하나는 반드시 제공해야 합니다'));
if (endingCards.length > 5) {
return next(new ValidationError('어미 선택 카드는 최대 5개까지 선택 가능합니다'));
}

// tone 값 검증 (있을 때만)
if (hasTone) {
if (tone !== undefined && tone !== null) {
const validTones = ['HONORIFIC', 'INFORMAL'];
if (!validTones.includes(tone)) {
return next(new ValidationError('tone은 HONORIFIC 또는 INFORMAL만 가능합니다'));
}
}

// endingCards 검증 (있을 때만)
if (hasEndingCards) {
if (endingCards.length === 0) {
return next(new ValidationError('어미 선택 카드를 최소 1개 이상 선택해주세요'));
}

if (endingCards.length > 5) {
return next(new ValidationError('어미 선택 카드는 최대 5개까지 선택 가능합니다'));
}
}

next();
};

Expand Down
57 changes: 48 additions & 9 deletions src/ai-prediction/routes/ai.prediction.route.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ const router = express.Router();
* /api/ai/predictions:
* post:
* summary: 문장 추천 (기본 3가지)
* description: 낱말 카드를 조합하여 자연스러운 문장 3개를 추천합니다. 캐시가 있으면 즉시 반환하고, 없으면 AI 호출 후 캐시에 저장합니다.
* description: |
* 낱말 카드를 조합하여 자연스러운 문장 3개를 추천합니다. 캐시가 있으면 즉시 반환하고, 없으면 AI 호출 후 캐시에 저장합니다.
*
* **`tone`** — 반말/존댓말 토글 (기기 내 설정값 반영)
*
* > 토글 OFF → `HONORIFIC` (존댓말, 기본값) / 토글 ON → `INFORMAL` (반말)
* tags: [AI]
* security:
* - bearerAuth: []
Expand Down Expand Up @@ -56,6 +61,10 @@ const router = express.Router();
* type: string
* description: 최근 대화 기록 (최대 10분 이내)
* example: ["약 먹어야 해", "오늘 기분 좋아"]
* tone:
* type: string
* enum: [HONORIFIC, INFORMAL]
* description: "tone — 반말/존댓말 토글 (기기 내 설정값 반영)\n\n토글 OFF → HONORIFIC (존댓말, 기본값) / 토글 ON → INFORMAL (반말)"
* refresh:
* type: boolean
* default: false
Expand All @@ -81,6 +90,12 @@ const router = express.Router();
* items:
* type: string
* example: ["물 좀 주세요", "물 주실래요?", "물 한 잔 주시겠어요?"]
* tone:
* type: string
* nullable: true
* enum: [HONORIFIC, INFORMAL]
* description: 요청한 tone 값 (미전달 시 null)
* example: "HONORIFIC"
* fromCache:
* type: boolean
* description: 캐시에서 반환되었는지 여부
Expand Down Expand Up @@ -111,6 +126,14 @@ const router = express.Router();
* code: "VALIDATION001"
* message: "낱말 카드는 최소 1개, 최대 10개까지 선택 가능합니다"
* detail: null
* invalidTone:
* summary: tone 값 오류
* value:
* success: false
* error:
* code: "VALIDATION001"
* message: "tone은 HONORIFIC 또는 INFORMAL만 가능합니다"
* detail: null
* 401:
* $ref: '#/components/responses/Unauthorized'
* 500:
Expand Down Expand Up @@ -301,11 +324,13 @@ router.get('/contexts', authenticate, contextController);
* 낱말 카드 + 어미 카드를 조합하여 특정 스타일의 문장을 생성합니다. predictions와 동일한 Cache-First 전략을 사용합니다.
*
* **`tone`** — 반말/존댓말 토글 (기기 내 설정값 반영)
* 토글 OFF → `HONORIFIC` (존댓말, 기본값) / 토글 ON → `INFORMAL` (반말)
*
* > 토글 OFF → `HONORIFIC` (존댓말, 기본값) / 토글 ON → `INFORMAL` (반말)
*
* **`endingCards`** — 어미 선택 카드 (문장 스타일 지정)
* `하고 싶어요` / `하기 싫어요` / `질문` / `해주세요` / `합시다` 및 사용자 커스텀 어미 가능.
* LLM이 어미의 의미를 해석하여 자연스러운 문장으로 변환합니다.
*
* > `하고 싶어요` / `하기 싫어요` / `질문` / `해주세요` / `합시다` 및 사용자 커스텀 어미 가능.
* > LLM이 어미의 의미를 해석하여 자연스러운 문장으로 변환합니다.
*
* 두 파라미터는 **독립적이며 동시에 사용 가능**합니다. `tone`은 반말/존댓말만 제어하고, `endingCards`는 문장의 의도/어미를 제어합니다.
* tags: [AI]
Expand Down Expand Up @@ -339,11 +364,11 @@ router.get('/contexts', authenticate, contextController);
* type: string
* minItems: 1
* maxItems: 5
* description: "선택한 어미 카드 배열 (1~5개). tone 없이 사용 시 필수. 기본 카드: 하고 싶어요, 하기 싫어요, 질문, 해주세요, 합시다"
* description: "선택한 어미 카드 배열 (최대 5개). tone 없이 사용 시 필수. 기본 카드: 하고 싶어요, 하기 싫어요, 질문, 해주세요, 합시다. tone과 독립적으로 동시 사용 가능."
* tone:
* type: string
* enum: [HONORIFIC, INFORMAL]
* description: "반말/존댓말 모드. HONORIFIC(존댓말) 또는 INFORMAL(반말). endingCards 없이 단독 사용 가능. 있을 경우 endingCards 앞에 반말/존댓말 카드로 변환되어 우선 적용됨."
* description: "tone — 반말/존댓말 토글 (기기 내 설정값 반영)\n\n토글 OFF → HONORIFIC (존댓말, 기본값) / 토글 ON → INFORMAL (반말)"
* refresh:
* type: boolean
* default: false
Expand Down Expand Up @@ -372,11 +397,17 @@ router.get('/contexts', authenticate, contextController);
* type: array
* items:
* type: string
* description: "실제 적용된 어미 카드 배열. tone 사용 시 맨 앞에 반말/존댓말 카드가 추가됨. 예: tone=INFORMAL → [\"반말\", \"질문\"]"
* example: ["반말", "질문"]
* description: "입력한 어미 카드 배열 (tone 카드 미포함)"
* example: ["질문"]
* tone:
* type: string
* nullable: true
* enum: [HONORIFIC, INFORMAL]
* description: 요청한 tone 값 (미전달 시 null)
* example: "INFORMAL"
* sentences:
* type: array
* description: 스타일 변환된 문장 3개 (사용자별 가중치 적용 후 정렬, 문자열 배열)
* description: "스타일 변환된 문장 3개 (tone + endingCards 동시 적용, 사용자별 가중치 정렬)"
* items:
* type: string
* example: ["밥 먹을래?", "밥 먹어?", "밥 먹을 거야?"]
Expand Down Expand Up @@ -426,6 +457,14 @@ router.get('/contexts', authenticate, contextController);
* code: "VALIDATION001"
* message: "어미 선택 카드는 최대 5개까지 선택 가능합니다"
* detail: null
* invalidTone:
* summary: tone 값 오류
* value:
* success: false
* error:
* code: "VALIDATION001"
* message: "tone은 HONORIFIC 또는 INFORMAL만 가능합니다"
* detail: null
* 401:
* $ref: '#/components/responses/Unauthorized'
* 500:
Expand Down
5 changes: 3 additions & 2 deletions src/ai-prediction/services/ai.prediction.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ const rankByLearningData = async (predictions, userId) => {
* @param {string} userId - 사용자 ID (학습 데이터 가중치 적용용)
* @returns {Promise<Array>} 추천 문장 3개 (빈도수 가중치 적용 후 정렬)
*/
const predictSentences = async (words = [], typedText = '', context = {}, refresh = false, userId = null) => {
const predictSentences = async (words = [], typedText = '', context = {}, refresh = false, userId = null, tone = null) => {
const { currentTime, previousMessages = [] } = context;

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

// AbortController로 타임아웃 처리 (10초)
Expand Down
7 changes: 4 additions & 3 deletions src/ai-prediction/services/ai.style.service.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const FASTAPI_URL = process.env.FASTAPI_URL || 'http://fastapi:8000';
* @param {string} userId - 사용자 ID (학습 데이터 가중치 적용용)
* @returns {Promise<Object>} 추천 문장 3개 (빈도수 가중치 적용 후 정렬) + rawSentences
*/
const transformSentenceStyle = async (words, endingCards, refresh = false, userId = null) => {
const transformSentenceStyle = async (words, endingCards, refresh = false, userId = null, tone = null) => {
// 타임아웃 처리 (10초)
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => {
Expand All @@ -31,8 +31,9 @@ const transformSentenceStyle = async (words, endingCards, refresh = false, userI
},
body: JSON.stringify({
words,
endingCards, // 배열로 전달 (1~5개)
refresh // 새로고침 파라미터
endingCards,
...(tone && { tone }), // tone이 있을 때만 포함
refresh
})
}).then(async (response) => {
if (!response.ok) {
Expand Down
5 changes: 5 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ app.use(responseHelper);
// +) 라우터 등록
// Swagger UI: 개발/스테이징에서만 활성화 (프로덕션에서는 보안을 위해 비활성화)
if (process.env.NODE_ENV !== 'production') {
// 브라우저 캐싱 방지 — spec 변경 시 즉시 반영
app.use('/api-docs', (req, res, next) => {
res.setHeader('Cache-Control', 'no-store');
next();
});
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
swaggerOptions: {
persistAuthorization: true,
Expand Down
Loading