diff --git a/fastapi-server/main.py b/fastapi-server/main.py index 960b8b5..22e2b51 100644 --- a/fastapi-server/main.py +++ b/fastapi-server/main.py @@ -88,6 +88,7 @@ class PredictRequest(BaseModel): words: List[str] = Field(..., min_items=1, max_items=10) context: Optional[ContextModel] = None refresh: bool = Field(default=False, description="true일 경우 캐시 무시하고 새로운 문장 생성") + tone: Optional[str] = Field(default=None, description="반말/존댓말 모드 (HONORIFIC: 존댓말, INFORMAL: 반말)") class PredictResponse(BaseModel): predictions: List[str] @@ -97,6 +98,7 @@ class PredictResponse(BaseModel): class TransformStyleRequest(BaseModel): words: List[str] = Field(..., min_items=1, max_items=10) endingCards: List[str] = Field(..., min_items=1, max_items=5, description="어미 선택 카드 배열 (1~5개)") + tone: Optional[str] = Field(default=None, description="반말/존댓말 모드 (HONORIFIC: 존댓말, INFORMAL: 반말)") refresh: bool = Field(default=False, description="true일 경우 캐시 무시하고 새로운 문장 생성") class TransformStyleResponse(BaseModel): @@ -109,8 +111,8 @@ class TransformStyleResponse(BaseModel): # Redis 캐싱 헬퍼 함수 # ============================================ -def generate_cache_key(words: List[str], context: Optional[ContextModel], endpoint: str = "predictions") -> str: - """캐시 키 생성 (words + context 기반)""" +def generate_cache_key(words: List[str], context: Optional[ContextModel], endpoint: str = "predictions", tone: Optional[str] = None) -> str: + """캐시 키 생성 (words + context + tone 기반)""" cache_data = { "words": sorted(words), # 순서 무관하게 캐싱 "context": { @@ -118,6 +120,8 @@ def generate_cache_key(words: List[str], context: Optional[ContextModel], endpoi }, "endpoint": endpoint } + if tone: + cache_data["tone"] = tone # separators=(',', ':')를 추가하여 불필요한 공백을 제거 cache_str = json.dumps(cache_data, ensure_ascii=False, sort_keys=True, separators=(',', ':')) return f"aac:{endpoint}:{hashlib.md5(cache_str.encode()).hexdigest()}" @@ -175,9 +179,10 @@ async def predict_sentences(request: PredictRequest): words = request.words context = request.context or ContextModel() refresh = request.refresh + tone = request.tone - # 캐시 키 생성 - cache_key = generate_cache_key(words, context, "predictions") + # 캐시 키 생성 (tone 포함) + cache_key = generate_cache_key(words, context, "predictions", tone) # refresh가 false일 때만 캐시 확인 if not refresh: @@ -188,6 +193,14 @@ async def predict_sentences(request: PredictRequest): fromCache=True ) + # tone 지시문 생성 + if tone == "INFORMAL": + tone_instruction = "\n\n반드시 반말로 문장을 작성하세요 (예: ~해, ~야, ~어, ~거야, ~자)" + elif tone == "HONORIFIC": + tone_instruction = "\n\n반드시 존댓말로 문장을 작성하세요 (예: ~요, ~습니다, ~세요, ~어요)" + else: + tone_instruction = "" + # 단어 개수에 따라 System Prompt 분기 if len(words) == 1: # 단어 1개: 간결 버전 @@ -199,14 +212,16 @@ async def predict_sentences(request: PredictRequest): 금지: 다른 단어 추가""" else: # 단어 2개 이상: 간결 버전 - system_prompt = """AAC 사용자용 AI. 입력 단어로 문장 3개 생성. 가장 적절한 순서대로 배열. + system_prompt = """AAC 사용자용 AI. 입력 단어 전부를 1개 문장에 담아, 서로 다른 의미/의도의 문장 3개 생성. 규칙: -1. 입력 단어 100% 사용 -2. 조사 필수 -3. 단어 나열 금지 -4. 자연스러운 길이로 표현 (필요시 여러 문장 가능) -5. 입력 없는 단어 추가 금지""" +1. 각 문장마다 입력 단어 전부 사용 (단어 분산 금지) +2. 조사/어미만 추가 — 명사·동사·형용사·부사 추가 절대 금지 +3. 단어 나열 금지 (조사로 자연스럽게 연결) +4. 3개 문장은 서로 다른 의미/의도/상황""" + + # tone 지시문 system_prompt에 추가 + system_prompt += tone_instruction # User Prompt 생성 (단어 개수에 따라 분기) words_text = ", ".join([f'"{w}"' for w in words]) @@ -232,52 +247,37 @@ async def predict_sentences(request: PredictRequest): user_prompt += f"이전: {prev_msgs}\n" user_prompt += f""" -CRITICAL: 각 표현마다 {len(words)}개 단어를 **전부** 사용! - -입력 단어: {words_text} - -1번 표현 = {words_text} 전부 사용 -2번 표현 = {words_text} 전부 사용 -3번 표현 = {words_text} 전부 사용 +입력 단어 [{len(words)}개]: {words_text} -각 표현은 **독립적**이며, 서로 다른 의미/의도여야 함. -단어 분산 금지! 각 표현이 모든 단어 포함해야 함! +⛔ 각 문장에 위 단어 전부 포함 (단어를 문장 간에 분산 금지) +⛔ 입력에 없는 단어 추가 금지 (조사/어미만 허용) -나쁜 예 (단어 분산): -1번: "오늘 아침 일찍 일어났어요" ❌ (일부만 사용) -2번: "밥 먹고 가방 챙겼어요" ❌ (나머지만 사용) - -좋은 예 (각각 전부 사용): -1번: "오늘 아침 일찍 일어나서 씻고 밥 먹고 가방 챙겨서 나갔어요" ✅ -2번: "오늘 아침에 일찍 일어났어요. 씻고 밥을 먹은 후 가방을 챙기고 나갔어요" ✅ -3번: "일찍 일어나서 오늘 아침에 씻었어요. 밥 먹고 가방 챙겨서 나갔어요" ✅ - -규칙: -- 조사 필수 -- 지시대명사: "저것"+명사 → "저 명사" -- 단어 많을 때: 자연스럽게 여러 문장으로 구성 -- 입력 없는 단어 추가 금지 +나쁜 예 (단어 분산): 입력 ["머리", "아프다", "병원", "가다"] → + 1번: "머리가 아프다" ❌ 2번: "병원에 가다" ❌ (단어가 나뉨!) +좋은 예 (전부 포함): → + 1번: "머리가 아파서 병원에 가요" ✅ + 2번: "머리가 아프니 병원에 가야 해요" ✅ + 3번: "머리 아프면 병원에 가세요" ✅ JSON: {{"predictions": ["표현1", "표현2", "표현3"]}}""" # OpenAI API 호출 (AI-01: 단어 개수에 따라 최적화) - # temperature 0.7로 설정하여 다양한 표현 생성 + # 짧고 간결한 문장 생성 → max_tokens 최소화로 응답 속도 향상 if len(words) == 1: - # 단어 1개: JSON 응답 완성을 위해 충분한 토큰 할당 temperature = 0.7 - max_tokens = 80 # JSON 구조 + 3개 문장 (confidence 필드 제거로 감소) + max_tokens = 60 elif len(words) <= 3: - # 단어 2~3개: 중간 길이 temperature = 0.7 - max_tokens = 120 # JSON 구조 + 중간 길이 문장 3개 + max_tokens = 80 elif len(words) <= 6: - # 단어 4~6개: 긴 문장 temperature = 0.7 - max_tokens = 160 # JSON 구조 + 긴 문장 3개 - else: - # 단어 7~10개: 여러 문장으로 나눔 + max_tokens = 110 + elif len(words) <= 8: temperature = 0.7 - max_tokens = 200 # JSON 구조 + 복잡한 문장 3개 + max_tokens = 130 + else: # 9~10개 + temperature = 0.7 + max_tokens = 150 response = client.chat.completions.create( model="gpt-4o-mini", @@ -363,12 +363,13 @@ async def transform_sentence_style(request: TransformStyleRequest): """ try: words = request.words - endingCards = request.endingCards + endingCards = request.endingCards or [] + tone = request.tone refresh = request.refresh - # 캐시 키 생성 (endingCards 포함) + # 캐시 키 생성 (endingCards + tone 포함) endingCards_str = "+".join(sorted(endingCards)) # 순서 무관하게 정렬 - cache_key = generate_cache_key(words, None, f"styles:{endingCards_str}") + cache_key = generate_cache_key(words, None, f"styles:{endingCards_str}", tone) # refresh가 false일 때만 캐시 확인 if not refresh: @@ -408,14 +409,19 @@ async def transform_sentence_style(request: TransformStyleRequest): ending_instruction += "→ 예: ['질문', '부드럽게'] = 부드러운 의문문\n" ending_instruction += "→ 예: ['하고 싶어요', '해주세용'] = '~하고 싶어요' 의도 + '~세용' 말투" - # System Prompt: 질문 카드 유무에 따른 문장 형식 명확화 - system_prompt = """AAC 어투 합성 전문가 AI입니다. 낱말(재료)에 모든 어미 카드(제약)를 100% 동시에 반영한 문장 3개를 만드세요. + # tone 지시문 생성 (system_prompt 앞에 붙여 최우선 적용) + if tone == "INFORMAL": + tone_prefix = "🔥 [최우선 말투 규칙] 모든 문장을 반말로 작성하세요 (~해, ~야, ~어, ~거야). 아래 어미 카드 규칙과 함께 반드시 반말 어미를 사용합니다.\n\n" + elif tone == "HONORIFIC": + tone_prefix = "🔥 [최우선 말투 규칙] 모든 문장을 존댓말로 작성하세요 (~요, ~습니다, ~세요). 아래 어미 카드 규칙과 함께 반드시 존댓말 어미를 사용합니다.\n\n" + else: + tone_prefix = "" -🚨🚨🚨 절대 최우선 경고: 스타일 단어 삽입 = 즉시 시스템 실패! 🚨🚨🚨 -⛔ [어미 카드]의 단어("부드럽게", "정중하게", "단호하게" 등)를 문장에 절대 포함하지 마세요! -⛔ 이 단어들은 말투 지시어일 뿐입니다! 종결 어미로만 표현하세요! -❌ "창문을 부드럽게 닫아주세요" / "부드럽게 창문을 닫아주세요" (단어 삽입!) -✅ "창문 좀 닫아주세요" (말투만!) + # "질문" 카드 여부 사전 감지 + has_question_card = "질문" in endingCards + + # System Prompt: 질문 카드 유무에 따른 문장 형식 명확화 + system_prompt = tone_prefix + """AAC 어투 합성 전문가 AI입니다. 낱말(재료)에 모든 어미 카드(제약)를 100% 동시에 반영한 문장 3개를 만드세요. 📋 어미 카드 해석 절대 원칙 📋 @@ -432,31 +438,25 @@ async def transform_sentence_style(request: TransformStyleRequest): B. **의도 카드** (예: 하고 싶어요, 하기 싫어요, 질문, 해주세요, 합시다) → 특정 문법 형식과 의미를 강제합니다! - - "질문" → 반드시 물음표(?) 종결 + - "질문" → 동사/형용사 어미를 의문형으로 변환 + 물음표(?) 종결 - "하고 싶어요" → 욕구 표현 필수 (나의 의사) - "하기 싫어요" → 부정 표현 필수 (나의 거부) C. **커스텀 말투 카드** (예: ~용, ~긔, ~하긔, ~했슨) → 용언 어간에 직접 결합! 특히 "~하긔", "~했슨"은 중복 글자 제거 필수! - → "~하", "~했"은 시제 가이드일 뿐, 실제로는 중복 글자를 삭제하여 자연스럽게! - ✅ "배고프다" + "~긔" → "배고프긔" ✅ "배고프다" + "~하긔" → "배고프긔" (어간 "배고프" + "긔", "하" 제거!) ❌ "배고프다" + "~하긔" → "배고프하긔" (중복 "하" 제거 안 함!) -🔥 절대 금지 1: 스타일 단어 삽입 = 시스템 완전 실패! -⛔ [어미 카드]에 적힌 단어("부드럽게", "정중하게", "단호하게" 등)가 문장에 나타나면 시스템 완전 실패! -⛔ 이 단어들은 말투 지시어일 뿐! 절대 문장에 포함하지 마세요! -⛔ 오직 종결 어미와 말투로만 표현하세요! -❌ "창문을 부드럽게 닫아주세요" (부드럽게 삽입!) -❌ "부드럽게 창문을 닫아주세요" (부드럽게 삽입!) -✅ "창문 좀 닫아주세요" (말투만!) -❌ "노래를 정중하게 불러요" (정중하게 삽입!) -✅ "노래를 불러드립니다" (격식체 어미만!) - 🔥 절대 금지 2-A: "질문" 카드 있으면 평서문 = 시스템 완전 실패! -⛔ "질문" 카드 있으면 3개 문장 모두 반드시 물음표(?)로 끝! -⛔ 다른 카드와 함께 있어도 "질문" 최우선! 반드시 물음표(?)! -❌ ["마트", "가다"] + ["질문"] → "마트에 같이 가자." → ✅ "마트에 같이 갈까요?" +⛔ "질문" 카드 있으면 3개 문장 모두 의문형 어미로 끝 + 물음표(?)! +⛔ 단순히 마침표를 ?로 교체 금지! 반드시 동사/형용사 어미 자체를 의문형으로 변경! +⛔ 평서형 어미 그대로 + ? 붙이는 것 = 무조건 완전 실패! + → 금지 평서형 어미 목록: -습니다, -겠습니다, -ㅂ니다, -이에요, -예요, -이다, -한다, -였어요 +❌ "기분이 좋습니다?" → ✅ "기분이 좋으신가요?" / "기분이 좋나요?" +❌ "노래를 부르겠습니다?" → ✅ "노래를 부르실래요?" / "노래를 부르겠습니까?" / "노래를 부를까요?" +❌ "마트에 가서 장을 봤습니다?" → ✅ "마트에 가서 장을 봤나요?" +❌ "밥을 먹겠습니다?" → ✅ "밥을 먹겠습니까?" / "밥을 먹을까요?" +✅ 올바른 의문형 어미: ~나요?, ~세요?, ~할까요?, ~하실래요?, ~하십니까?, ~인가요?, ~겠습니까? 🔥 절대 금지 2-B: "질문" 카드 없으면 의문형 = 시스템 완전 실패! ⛔ "질문" 카드 없으면 3개 문장 모두 절대 물음표(?) 금지! @@ -465,7 +465,6 @@ async def transform_sentence_style(request: TransformStyleRequest): ✅ ["안", "하다"] + ["단호하게"] → "안 해!" / "안 한다!" / "절대 안 해!" 🔥 절대 금지 3: "하기 싫어요" 카드 있으면 긍정문 = 시스템 완전 실패! -⛔ "하기 싫어요" 카드 있으면 3개 문장 모두 무조건 부정 표현 필수! ⛔ 부정 표현: "안", "못", "-지 않다", "-기 싫다" 중 하나 반드시 포함! ❌ ["약", "먹다"] + ["하기 싫어요"] → "약 먹어!" → ✅ "약 안 먹어!" / "약 먹기 싫어요!" @@ -477,153 +476,121 @@ async def transform_sentence_style(request: TransformStyleRequest): 🚨 절대 위반 금지 규칙 🚨 -규칙 1: 스타일 단어 삽입 금지 + 임의 의도 추가 금지 -- 어미 카드 단어가 문장에 나타나면 시스템 완전 실패! -- 일반 스타일 카드("부드럽게" 등)에 욕구/부정/거부 표현 임의 추가 절대 금지! - -규칙 2: "질문" 카드 유무에 따른 문법 모드 -- "질문" 있음 → 3개 문장 모두 물음표(?)로 끝! 마침표(.), 느낌표(!) 금지! -- "질문" 없음 → 의문형 어미(~ㄹ까요?, ~나요?, ~을까?) 절대 금지! 평서문만(~주세요, ~해요, ~습니다)! - -규칙 3: "하기 싫어요"/"하고 싶어요" 의미 강제 (1인칭 주체!) +규칙 1: "하기 싫어요"/"하고 싶어요" 의미 강제 (1인칭 주체!) - "하기 싫어요" → 나의 거부, 부정 표현 필수 (안/못/-지 않다/-기 싫다)! - "하고 싶어요" → 나의 긍정 욕구, 욕구 표현 필수! 부정 표현(-지 않다/안/못) 절대 금지! - "질문" + "하고 싶어요" → 나의 욕구를 묻는 의문문! (상대방에게 묻는 질문 아님!) ❌ "너 자고 싶어?" / "잠 자고 싶지 않아?" → ✅ "잠 자도 될까요?" -규칙 4: 형태소 융합 (커스텀 어미 처리) +규칙 2: 형태소 융합 (커스텀 어미 처리) - 커스텀 어미는 용언 어간에서 '-다'를 제거하고 직접 결합! - 🚨 특히 "~하긔", "~했슨" 같은 카드는 중복 글자 제거 필수! - → "~하", "~했"은 시제(현재/과거) 가이드일 뿐, 실제로는 중복 글자 삭제! -- ✅ "배고프다" + "~하긔" → "배고프긔" (어간 "배고프" + "긔", "하" 제거!) -- ❌ "배고프다" + "~하긔" → "배고프하긔" (중복 "하" 제거 안 함!) -- ✅ "예쁘다" + "~했슨" → "예뻤슨" (어간 "예쁘" + "었슨") -- ✅ "먹다" + "~용" → "먹용" (어간 "먹" + "용") - -규칙 5: 모든 낱말 카드 포함 강제 + 단어 추가 절대 금지 -- 각 문장마다 모든 낱말 카드를 100% 사용! -- 낱말 카드 외 명사/동사/형용사/부사 추가 절대 금지! (조사와 어미만 변형!) -- ❌ ["지금", "자다"] → "지금 졸려요" (자다 누락!) → ✅ "지금 자고 싶어요" -- ❌ ["선생님"] → "선생님, 수업 시작해요" (수업, 시작 추가!) → ✅ "선생님?" / "선생님이세요?" - -규칙 6: 제로 할루시네이션 (최우선!) -- 낱말 카드 외 명사, 동사, 형용사, 부사 추가 절대 금지! -- 조사(을/를/이/가/에게)와 어미만 변형 가능! -- ❌ ["선생님"] → "선생님, 수업을 진행해도 될까요?" (수업, 진행 추가!) -- ✅ ["선생님"] → "선생님?" / "선생님이세요?" / "선생님께?" - -규칙 7: 구조적 변주 + 중복 절대 금지! -- 3개 문장은 반드시 서로 달라야 함! 동일한 문장 2개 이상 절대 금지! -- 단어 추가 없이 조사 변형, 보조 용언(~긴, ~기도), 어미 변형으로 구조적 차이! -- ❌ ["배고프다"] + ["~하긔"] → "배고프긔", "배고프긔", "배고프긔" (중복!) -- ❌ ["배고프다"] + ["~하긔"] → "배고프하긔" (융합 실패!) / "배고프다 하긔" (중복!) +- ✅ "배고프다" + "~하긔" → "배고프긔" / ❌ "배고프하긔" (중복 "하"!) +- ✅ "예쁘다" + "~했슨" → "예뻤슨" / ✅ "하다" + "~용" → "해용" + +규칙 3: 모든 낱말 카드 100% 포함 + 낱말 카드 외 명사/동사/형용사/부사 추가 절대 금지! +- ❌ ["선생님"] → "선생님, 수업을 진행해도 될까요?" → ✅ "선생님?" / "선생님이세요?" + +규칙 4: 구조적 변주 + 중복 절대 금지! +- 3개 문장은 반드시 서로 달라야 함! - ✅ ["배고프다"] + ["~하긔"] → "배고프긔" / "배고프긴 하긔" / "배고프기도 하긔" 🚨 전수 반영 원칙: 선택된 모든 어미 카드를 3개 문장 각각에 100% 반영하세요!""" # User Prompt 생성: 주어진 카드만 사용 강제 words_text = ", ".join([f'"{w}"' for w in words]) - card_count = len(endingCards) + + # 문장에 직접 삽입되면 안 되는 스타일 카드 감지 (한국어 방식부사: ~게, ~히 어미) + # 커스텀 카드는 어떤 값이든 올 수 있으므로 고정 리스트 대신 패턴으로 감지 + forbidden_style_words = [card for card in endingCards if card.endswith("게") or card.endswith("히")] + if forbidden_style_words: + quoted_forbidden = ', '.join(f'"{c}"' for c in forbidden_style_words) + forbidden_words_line = ( + f"⛔⛔⛔ 이번 요청 절대 금지어: {quoted_forbidden} ⛔⛔⛔\n" + f"→ 위 단어가 문장 어디에도 나타나면 즉시 재생성! 말투/어미로만 표현!\n" + ) + else: + forbidden_words_line = "" + + question_status = ( + "⚡ 질문 카드 감지됨! → 동사/형용사 어미를 의문형으로 변환 + ? 종결 필수! 평서형+? 절대 금지!" + if has_question_card else + "⚡ 질문 카드 없음! → 의문형 어미(~나요?/~세요?/~할까요?) 및 물음표(?) 절대 금지!" + ) user_prompt = f"""낱말 카드: {words_text} 어미 카드 [{len(endingCards)}개]: {endingCards_text} +{forbidden_words_line} + +{question_status} + 🚨 절대 최우선 확인! 1. **"질문" 카드 확인 (시스템 생사 결정!)**: {endingCards_text}에 "질문"이 있습니까? - → **있으면**: 3개 문장 모두 반드시 물음표(?)로 끝! 평서문/명령문/감탄문 금지! - → **없으면**: 3개 문장 중 단 하나도 물음표(?) 금지! 의문형 어미(~ㄹ까요?, ~세요?, ~해요?, ~래요?, ~나요?) 절대 사용 금지! - - 예시 확인: - - ["안", "하다"] + ["단호하게"] → "질문" 없음 → "안 해!" / "안 한다!" (✅) / "안 하세요?" / "안 할까요?" (❌ 의문형 금지!) - - ["밥", "먹다"] + ["질문"] → "질문" 있음 → "밥 먹을까요?" / "밥 드실래요?" (✅) / "밥 먹어." / "밥 먹어!" (❌ 평서문 금지!) + → **있으면**: 3개 문장 모두 동사/형용사 어미를 의문형으로 변환 후 물음표(?)로 끝! + ❌ 평서형 어미 그대로+? 절대 금지 목록: + "좋습니다?" / "갑니다?" / "했어요?" / "부르겠습니다?" / "먹겠습니다?" / "합니다?" + ✅ 어미 자체를 의문형으로 변환: + -습니다 → -습니까? / -나요? / -을까요? + -겠습니다 → -겠습니까? / -실래요? / -을까요? + 예: "부르겠습니다?" ❌ → "부르실래요?" / "부르겠습니까?" / "부를까요?" ✅ + 의문형 어미: ~나요?, ~세요?, ~할까요?, ~하실래요?, ~인가요?, ~겠습니까? + → **없으면**: 3개 문장 중 단 하나도 물음표(?) 금지! 의문형 어미(~ㄹ까요?, ~세요?, ~래요?, ~나요?) 절대 사용 금지! 2. **1인칭 주체 원칙**: "하고 싶어요", "하기 싫어요"는 나의 의사 표현! - → ❌ "너 자고 싶어?" (상대방에게 묻기) - → ✅ "잠 자도 될까요?" (나의 욕구 의문) + → ❌ "너 자고 싶어?" → ✅ "잠 자도 될까요?" 🎯 핵심 규칙 **각 문장마다 {len(words)}개 낱말 카드를 전부 사용!** -입력 낱말: {words_text} → 1번 문장 = {words_text} 전부 사용 → 2번 문장 = {words_text} 전부 사용 → 3번 문장 = {words_text} 전부 사용 | 카드 유형 | 구현 방법 | ✅ 정답 | ❌ 실패 | |:---|:---|:---|:---| -| **부드럽게** | 중립 평서문 어미만(~주세요, ~해요, ~게요) + 질문 카드 없으면 의문형 금지! | 밥 먹어요 / 창문 좀 닫아주세요 | 밥 먹을래요? (의문형!) / 밥 먹기 싫어 (임의 의도!) | -| **단호하게** | 강한 종결 어미(~해!, ~한다!) + 질문 카드 없으면 의문형 금지! | 안 해! / 안 한다! / 절대 안 해! | 안 하세요? / 안 할까요? (의문형 금지!) | -| **정중하게** | 격식체 평서문(~습니다) + 질문 카드 없으면 의문형 금지! | 노래를 부릅니다 | 노래 부르실래요? (의문형!) | -| **질문** | 반드시 물음표(?) - 3개 문장 모두! | 마트에 갈까요? / 갈래요? | 마트에 가자. (평서문!) | -| **하기 싫어요** | 부정 표현 필수 (나의 거부) | 약 안 먹어! / 약 먹기 싫어요! | 약 먹어! (긍정문!) | -| **하고 싶어요** | 긍정 욕구 표현 필수 (나의 욕구) + 부정 표현 금지 | 놀이터 가고 싶어요 / 자고 싶어! | 놀이터 가! (욕구 없음!) / 자고 싶지 않아? (부정!) | -| **~긔, ~용, ~하긔** | 어간에 직접 결합 + 중복 글자 제거! | 배고프긔 (배고프+긔) / 먹용 (먹+용) | 배고프하긔 (중복 "하"!!) | - -🚨 최종 체크리스트 (출력 전 필독!) - -0. **임의 의도 추가 검열**: 일반 스타일 카드("부드럽게", "정중하게")만 있는데 욕구/부정/거부 표현 추가했는가? - → "-고 싶다", "-기 싫다", "안~", "못~" 등의 의도 표현이 있으면 즉시 재생성! - ❌ ["밥", "먹다"] + ["부드럽게"] → "밥 먹기 싫어" / "밥 안 먹을래" - ✅ ["밥", "먹다"] + ["부드럽게"] → "밥 먹어요" / "밥 드세요" - -1. **질문 카드 검열 (최최우선!)**: {endingCards_text}에 "질문"이 있는가? - → **있으면**: 3개 문장 모두 물음표(?)로 끝? (마침표, 느낌표 금지!) - → **없으면**: 3개 문장 중 단 하나도 물음표(?) 없어야 함! 의문형 어미(~ㄹ까요?, ~세요?, ~래요?) 절대 금지! - ❌ ["안", "하다"] + ["단호하게"] → "안 하세요?" / "안 할까요?" (질문 없는데 의문형!) - ✅ ["안", "하다"] + ["단호하게"] → "안 해!" / "안 한다!" - ❌ ["잠", "자다"] + ["질문", "하고 싶어요"] → "잠을 자고 싶어!" (질문 있는데 평서문!) - ✅ ["잠", "자다"] + ["질문", "하고 싶어요"] → "잠 자도 될까요?" - -2. **부정 표현 검열**: "하기 싫어요" 있으면 3개 문장 모두 부정 표현(안/못/-지 않다/-기 싫다) 포함? - ❌ ["약", "먹다"] + ["하기 싫어요"] → "약 먹어!" - ✅ ["약", "먹다"] + ["하기 싫어요"] → "약 안 먹어!" - -3. **욕구 표현 검열**: "하고 싶어요" 있으면 3개 문장 모두 긍정 욕구 표현(-고 싶다/-려고 하다/-고 싶은데) 포함? + 부정 표현(-지 않다/안/못) 절대 금지! (1인칭 주체!) - ❌ ["잠", "자다"] + ["질문", "하고 싶어요"] → "너 잠 자고 싶어?" (상대방에게 묻기!) - ❌ ["잠", "자다"] + ["질문", "하고 싶어요"] → "잠 자고 싶지 않아?" (부정 표현!) - ✅ ["잠", "자다"] + ["질문", "하고 싶어요"] → "잠 자고 싶어?" / "잠 자도 될까요?" - -4. **스타일 단어 삽입 금지 확인 (절대 최우선!)**: "{endingCards_text}"에 있는 단어가 문장에 포함되었는가? - → ⛔ "부드럽게", "정중하게", "단호하게", "친근하게" 등의 단어가 문장에 있으면 안 됨! - → 포함되었다면 해당 단어를 즉시 삭제하고 말투로만 바꿔서 재생성! - ❌ "창문을 부드럽게 닫아주세요" (부드럽게 삽입!) → ✅ "창문 좀 닫아주세요" (말투만!) - ❌ "부드럽게 창문을 닫아주세요" (부드럽게 삽입!) → ✅ "창문 닫아주세요" (말투만!) - ❌ "노래를 정중하게 불러요" (정중하게 삽입!) → ✅ "노래를 불러드립니다" (격식체 어미만!) - -5. **형태소 융합 확인 (커스텀 어미 필수!)**: 커스텀 어미(~하긔, ~했슨, ~용, ~긔 등) 사용 시 중복 글자 제거했는가? - → "~하긔"의 경우 "하"를 제거! "~했슨"의 경우 중복 시제 제거! - ❌ ["배고프다"] + ["~하긔"] → "배고프하긔" (중복 "하" 제거 안 함!) - ✅ ["배고프다"] + ["~하긔"] → "배고프긔" (어간 "배고프" + "긔") - -6. **모든 낱말 카드 포함**: 각 문장이 {words_text} 전부 포함? (하나라도 누락 시 재생성!) - -7. **단어 추가 금지 검열 (최우선!)**: {words_text} 외에 명사/동사/형용사/부사를 추가했는가? - → 추가된 단어가 있으면 즉시 재생성! 조사와 어미만 변형 가능! - ❌ ["선생님"] + ["질문"] → "선생님, 수업을 진행해도 될까요?" (수업, 진행 추가!) - ✅ ["선생님"] + ["질문"] → "선생님?" / "선생님이세요?" / "선생님이신가요?" - -8. **의문형 어미 최종 검열**: "질문" 카드 없는데 ~ㄹ까요?, ~세요?, ~해요?, ~나요?, ~을까?, ~래요? 사용했는가? - → 의문형 어미 발견 시 즉시 재생성! 질문 카드 없으면 평서문/명령문/감탄문만! - -9. **구조적 변주**: 단어 추가 없이 조사/어순/길이만으로 3개 문장이 충분히 다른가? +| **부드럽게** | 중립 평서문 어미만 (~주세요, ~해요) | 창문 좀 닫아주세요 | 창문을 부드럽게 닫아주세요 (단어 삽입!) | +| **단호하게** | 강한 종결 어미 (~해!, ~한다!) | 안 해! / 절대 안 해! | 안 하세요? (의문형 금지!) | +| **정중하게** | 격식체 평서문 (~습니다, ~드립니다) | 노래를 불러드립니다 | 노래를 정중하게 불러요 (단어 삽입!) | +| **질문** | 어미를 의문형으로 변환 + ? | 마트에 갈까요? / 가실래요? | 마트에 갑니다? (평서형+?) / 마트에 가자. | +| **하기 싫어요** | 부정 표현 필수 (안/못/-기 싫다) | 약 안 먹어! / 먹기 싫어요! | 약 먹어! (긍정문!) | +| **하고 싶어요** | 긍정 욕구 표현 필수 | 놀이터 가고 싶어요! | 놀이터 가! (욕구 없음!) | +| **~긔, ~용, ~하긔** | 어간에 직접 결합 + 중복 글자 제거 | 배고프긔 / 먹용 | 배고프하긔 (중복 "하"!) | + +🚨 최종 체크리스트 + +0. 일반 스타일 카드("부드럽게" 등)만 있는데 욕구/부정/거부 표현 추가했는가? + ❌ ["밥", "먹다"] + ["부드럽게"] → "밥 먹기 싫어" → ✅ "밥 먹어요" + +1. **질문 카드**: {endingCards_text}에 "질문" 있으면 → 의문형 어미로 변환 후 ?! 평서형+? 절대 금지! + {endingCards_text}에 "질문" 없으면 → 물음표(?) 및 의문형 어미 절대 금지! + +2. "하기 싫어요" 있으면 → 3개 문장 모두 부정 표현(안/못/-기 싫다) 포함? + +3. "하고 싶어요" 있으면 → 3개 문장 모두 긍정 욕구(-고 싶다) 포함? 부정 표현 금지! + +4. **스타일 단어 삽입 금지**: "{endingCards_text}"의 단어가 문장에 포함되었는가? + → 포함 시 즉시 삭제하고 말투로만 표현! + +5. 커스텀 어미(~하긔, ~했슨 등) 중복 글자 제거했는가? + +6. 각 문장이 {words_text} 전부 포함? + +7. {words_text} 외 명사/동사/형용사/부사 추가했는가? → 추가 시 즉시 재생성! JSON: {{"sentences": ["문장1", "문장2", "문장3"]}}""" - # OpenAI API 호출 (AI-05: 단어 개수에 따라 최적화) - # temperature 0.1로 극도로 낮춰 지시 사항(스타일 단어 삽입 금지) 엄격히 준수 - # presence_penalty 1.5로 강화하여 문장 중복 방지 + # OpenAI API 호출 (AI-05) if len(words) == 1: - # 단어 1개: JSON 응답 완성을 위해 충분한 토큰 할당 - max_tokens = 180 # JSON 구조 + 어미 적용 문장 3개 (반드시 3개 생성) + max_tokens = 100 elif len(words) <= 3: - # 단어 2~3개: 중간 길이 - max_tokens = 200 # JSON 구조 + 중간 길이 어미 적용 문장 3개 + max_tokens = 120 elif len(words) <= 6: - # 단어 4~6개: 긴 문장 - max_tokens = 250 # JSON 구조 + 긴 어미 적용 문장 3개 - else: - # 단어 7~10개: 매우 긴 문장 - max_tokens = 300 # JSON 구조 + 복잡한 어미 적용 문장 3개 + max_tokens = 150 + elif len(words) <= 8: + max_tokens = 170 + else: # 9~10개 + max_tokens = 190 response = client.chat.completions.create( model="gpt-4o-mini", diff --git a/src/ai-prediction/controllers/ai.prediction.controller.js b/src/ai-prediction/controllers/ai.prediction.controller.js index acb3319..90fd929 100644 --- a/src/ai-prediction/controllers/ai.prediction.controller.js +++ b/src/ai-prediction/controllers/ai.prediction.controller.js @@ -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) { @@ -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); } @@ -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 }, '문장 추천 성공' ); diff --git a/src/ai-prediction/controllers/ai.style.controller.js b/src/ai-prediction/controllers/ai.style.controller.js index 1c0265e..cc44a50 100644 --- a/src/ai-prediction/controllers/ai.style.controller.js +++ b/src/ai-prediction/controllers/ai.style.controller.js @@ -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 { @@ -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) { @@ -57,6 +46,7 @@ const transformStyleController = async (req, res, next) => { { words: cached.words, endingCards: cached.endingCards, + tone: tone || null, sentences: finalSentences, fromCache: true }, @@ -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, @@ -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 }, diff --git a/src/ai-prediction/dto/ai.prediction.dto.js b/src/ai-prediction/dto/ai.prediction.dto.js index a38b818..1630992 100644 --- a/src/ai-prediction/dto/ai.prediction.dto.js +++ b/src/ai-prediction/dto/ai.prediction.dto.js @@ -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 }; \ No newline at end of file diff --git a/src/ai-prediction/middlewares/ai.validator.js b/src/ai-prediction/middlewares/ai.validator.js index 0ca09be..18b3ecc 100644 --- a/src/ai-prediction/middlewares/ai.validator.js +++ b/src/ai-prediction/middlewares/ai.validator.js @@ -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) { @@ -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)); } @@ -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(); }; diff --git a/src/ai-prediction/routes/ai.prediction.route.js b/src/ai-prediction/routes/ai.prediction.route.js index 2e5f5b8..8818f3a 100644 --- a/src/ai-prediction/routes/ai.prediction.route.js +++ b/src/ai-prediction/routes/ai.prediction.route.js @@ -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: [] @@ -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 @@ -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: 캐시에서 반환되었는지 여부 @@ -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: @@ -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] @@ -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 @@ -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: ["밥 먹을래?", "밥 먹어?", "밥 먹을 거야?"] @@ -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: diff --git a/src/ai-prediction/services/ai.prediction.service.js b/src/ai-prediction/services/ai.prediction.service.js index 357ca92..6740440 100644 --- a/src/ai-prediction/services/ai.prediction.service.js +++ b/src/ai-prediction/services/ai.prediction.service.js @@ -101,7 +101,7 @@ const rankByLearningData = async (predictions, userId) => { * @param {string} userId - 사용자 ID (학습 데이터 가중치 적용용) * @returns {Promise} 추천 문장 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 요청 페이로드 생성 @@ -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초) diff --git a/src/ai-prediction/services/ai.style.service.js b/src/ai-prediction/services/ai.style.service.js index 421ad07..747417a 100644 --- a/src/ai-prediction/services/ai.style.service.js +++ b/src/ai-prediction/services/ai.style.service.js @@ -15,7 +15,7 @@ const FASTAPI_URL = process.env.FASTAPI_URL || 'http://fastapi:8000'; * @param {string} userId - 사용자 ID (학습 데이터 가중치 적용용) * @returns {Promise} 추천 문장 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(() => { @@ -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) { diff --git a/src/app.js b/src/app.js index 739a9e4..71af430 100644 --- a/src/app.js +++ b/src/app.js @@ -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, diff --git a/src/utils/cache.util.js b/src/utils/cache.util.js index 9648991..878260a 100644 --- a/src/utils/cache.util.js +++ b/src/utils/cache.util.js @@ -19,7 +19,7 @@ import redisClient from '../config/redis.config.js'; * generateCacheKey(['밥', '먹다'], { previousMessages: [] }, 'styles', ['질문', '부드럽게']) * // Returns: 'aac:styles:e5f6g7h8...' */ -export function generateCacheKey(words, context, endpoint = 'predictions', endingCards = null) { +export function generateCacheKey(words, context, endpoint = 'predictions', endingCards = null, tone = null) { const cacheData = { words: [...words].sort(), // 순서 무관하게 정렬 (FastAPI와 동일) context: { @@ -33,6 +33,11 @@ export function generateCacheKey(words, context, endpoint = 'predictions', endin cacheData.endingCards = [...endingCards].sort(); // 순서 무관하게 정렬 } + // tone이 있으면 캐시 키에 포함 (반말/존댓말별로 캐시 분리) + if (tone) { + cacheData.tone = tone; + } + // 키를 정렬하여 JSON 문자열로 변환 (Python의 sort_keys=True 효과) const sortedData = Object.keys(cacheData).sort().reduce((obj, key) => { obj[key] = cacheData[key];