diff --git a/app/api/constraint_router.py b/app/api/constraint_router.py new file mode 100644 index 0000000..776c426 --- /dev/null +++ b/app/api/constraint_router.py @@ -0,0 +1,24 @@ +from fastapi import APIRouter, Depends, HTTPException +from app.schemas.profile_schema import ProfileRequest, ConstraintResponse +from app.services.constraint_generator import ConstraintGeneratorService + +router = APIRouter( + prefix="/api/constraints", + tags=["Constraint Generator"] +) + +_constraint_service_instance = ConstraintGeneratorService() + +def get_constraint_service(): + return _constraint_service_instance + +@router.post("/generate", response_model=ConstraintResponse) +async def generate_constraint( + request: ProfileRequest, + service: ConstraintGeneratorService = Depends(get_constraint_service) +): + try: + result = service.generate(request) + return result + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) \ No newline at end of file diff --git a/app/api/refiner_router.py b/app/api/refiner_router.py new file mode 100644 index 0000000..b9bc8dd --- /dev/null +++ b/app/api/refiner_router.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends +from app.schemas.refiner_schema import InterestRequest, InterestResponse +from app.services.interest_refiner import InterestRefinerService + +router = APIRouter( + prefix="/api/refiner", + tags=["Interest Refiner"] +) + +_refiner_service_instance = InterestRefinerService() + +def get_refiner_service(): + return _refiner_service_instance + +@router.post("/extract", response_model=InterestResponse) +async def extract_interests( + request: InterestRequest, + service: InterestRefinerService = Depends(get_refiner_service) +): + # 정제된 키워드 받기 + keywords = service.refine(request.text) + + # 결과 비었는지 확인 + is_empty = len(keywords) == 0 + + # InterestResponse에 맞춰 데이터 변환 + return InterestResponse( + original_text=request.text, + keywords=keywords, + is_empty=is_empty + ) \ No newline at end of file diff --git a/app/common/level_consts.py b/app/common/level_consts.py new file mode 100644 index 0000000..e7c95f9 --- /dev/null +++ b/app/common/level_consts.py @@ -0,0 +1,44 @@ +LEVEL_CONSTRAINTS = { + "L1": { + "age_group": "3~4세", + "target_wcpm": 15, + "max_sentence_len": 3, + "vocab_level": "최하", + "total_pages": 18 + }, + "L2": { + "age_group": "5~6세", + "target_wcpm": 30, + "max_sentence_len": 5, + "vocab_level": "하", + "total_pages": 18 + }, + "L3": { + "age_group": "7~8세", + "target_wcpm": 50, + "max_sentence_len": 7, + "vocab_level": "중하", + "total_pages": 21 + }, + "L4": { + "age_group": "9~10세", + "target_wcpm": 80, + "max_sentence_len": 9, + "vocab_level": "중", + "total_pages": 21 + }, + "L5": { + "age_group": "11~12세", + "target_wcpm": 110, + "max_sentence_len": 11, + "vocab_level": "중상", + "total_pages": 24 + }, + "L6": { + "age_group": "13세", + "target_wcpm": 130, + "max_sentence_len": 13, + "vocab_level": "상", + "total_pages": 24 + } +} \ No newline at end of file diff --git a/app/common/lexicon.py b/app/common/lexicon.py new file mode 100644 index 0000000..fa98689 --- /dev/null +++ b/app/common/lexicon.py @@ -0,0 +1,24 @@ +# 불용어 +FILLER_WORDS = { + "음", "어", "아", "그", "그냥", "저", "저기", "뭐지", "뭔가", "약간", + "그니까", "그러니까", "그래서", "그리고", "그치만", "하지만", "그래도", + "어떤", "이런", "나", "저는", "나는", "내가", "좋아", "좋아해", + "진짜", "완전", "막", "이제", "일단" +} + +# 금지어 +BANNED_KEYWORDS = { + "살인", "자살", "피", "테러", "마약", "죽음", "사망", + "19금", "섹스", "야동", "강간", "범죄", "좆", + "씨발", "시발", "년", "개새끼", "병신", "죽어", "존나", "미친", "지랄" +} + +# 유의어 +THEME_HINTS = { + "모험": {"모험", "여행", "탐험", "찾기", "보물", "숲", "바다", "지도"}, + "우주": {"우주", "달", "별", "로켓", "행성", "외계인", "우주선"}, + "공룡": {"공룡", "티라노", "티라노사우루스", "트리케라톱스", "랩터", "브라키오", "화석"}, + "로봇": {"로봇", "AI", "인공지능", "기계", "드론", "변신"}, + "강아지": {"개", "멍멍이", "멍멍"}, + "고양이": {"냥이", "야옹이", "냥냥", "냐옹"} +} \ No newline at end of file diff --git a/app/main.py b/app/main.py index 5a7a8c8..8403553 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,11 @@ from fastapi import FastAPI -from app.api import refiner_router, stt_router +from app.api import refiner_router, stt_router, constraint_router app = FastAPI() app.include_router(stt_router.router) app.include_router(refiner_router.router) +app.include_router(constraint_router.router) @app.get("/") def main(): diff --git a/app/schemas/profile_schema.py b/app/schemas/profile_schema.py new file mode 100644 index 0000000..c125c9d --- /dev/null +++ b/app/schemas/profile_schema.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, Field +from typing import List, Optional + +class ProfileRequest(BaseModel): + level: str = Field( + ..., + description="아동의 현재 읽기 레벨 (L1 ~ L6)" + ) + interests: List[str] = Field( + ..., + description="STT 모듈을 거쳐 정제된 아동의 관심사 키워드 리스트" + ) + recent_wcpm: Optional[float] = Field( + None, + description="이전 학습 세션에서 측정한 아동의 WCPM" + ) + weak_phonemes: Optional[List[str]] = Field( + None, + description="아동이 자주 틀리는 취약 발음 리스트" + ) + +class ConstraintResponse(BaseModel): + level_name: str = Field(..., description="레벨 이름") + age_group: str = Field(..., description="대상 연령대") + max_sentence_len: int = Field(..., description="문장당 최대 어절 수") + vocab_level: str = Field(..., description="어휘 난이도") + total_pages: int = Field(..., description="총 동화 페이지 수") + theme_keywords: List[str] = Field(..., description="동화 관심사") + focus_phonemes: List[str] = Field(..., description="동화에 반복 노출할 취약 음소") + adjusted_target_wcpm: float = Field(..., description="아동의 최근 성적을 반영해 조정한 목표 WCPM") \ No newline at end of file diff --git a/app/schemas/refiner_schema.py b/app/schemas/refiner_schema.py new file mode 100644 index 0000000..85d34d2 --- /dev/null +++ b/app/schemas/refiner_schema.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, Field +from typing import List + +class InterestRequest(BaseModel): + text: str = Field( + ..., + description="STT 모듈에서 추출된 아동의 원본 음성 텍스트" + ) + +class InterestResponse(BaseModel): + original_text: str = Field( + ..., + description="입력되었던 원본 텍스트" + ) + keywords: List[str] = Field( + ..., + description="정제된 관심사 키워드 리스트" + ) + is_empty: bool = Field( + ..., + description="유효한 키워드가 있는지 여부 (True면 프론트에서 재입력 유도)" + ) \ No newline at end of file diff --git a/app/services/constraint_generator.py b/app/services/constraint_generator.py new file mode 100644 index 0000000..e2890f1 --- /dev/null +++ b/app/services/constraint_generator.py @@ -0,0 +1,31 @@ +from app.common.level_consts import LEVEL_CONSTRAINTS +from app.schemas.profile_schema import ProfileRequest, ConstraintResponse + +class ConstraintGeneratorService: + def generate(self, request: ProfileRequest) -> ConstraintResponse: + # 레벨 유효성 검사 + if request.level not in LEVEL_CONSTRAINTS: + raise ValueError(f"지원하지 않는 레벨입니다: {request.level}") + + base_consts = LEVEL_CONSTRAINTS[request.level] + + # wcpm 조정 + if request.recent_wcpm is not None: + final_wcpm = request.recent_wcpm + else: + final_wcpm = base_consts["target_wcpm"] + + # 취약 발음 처리 + final_phonemes = request.weak_phonemes if request.weak_phonemes else [] + + # 결과 반환 + return ConstraintResponse( + level_name=request.level, + age_group=base_consts["age_group"], + max_sentence_len=base_consts["max_sentence_len"], + vocab_level=base_consts["vocab_level"], + total_pages=base_consts["total_pages"], + theme_keywords=request.interests, + focus_phonemes=final_phonemes, + adjusted_target_wcpm=final_wcpm + ) \ No newline at end of file diff --git a/app/services/interest_refiner.py b/app/services/interest_refiner.py new file mode 100644 index 0000000..e50bda1 --- /dev/null +++ b/app/services/interest_refiner.py @@ -0,0 +1,39 @@ +from konlpy.tag import Okt +from typing import List, Dict, Any +from app.common.lexicon import FILLER_WORDS, BANNED_KEYWORDS, THEME_HINTS + +class InterestRefinerService: + def __init__(self): + self.okt = Okt() + + def _is_safe_word(self, word: str) -> bool: + if word in BANNED_KEYWORDS: + return False + + return True + + def refine(self, text:str) -> List[str]: + if not text: + return [] + + # 명사 추출 + nouns = self.okt.nouns(text) + + refine_keywords = [] + + for noun in nouns: + if len(noun) < 1: + continue + + if noun in FILLER_WORDS: # 불용어 제거 + continue + + if not self._is_safe_word(noun): + print(f"유해 키워드 감지 및 제거됨: {noun}") + continue + + refine_keywords.append(noun) + + refine_keywords = list(set(refine_keywords)) + + return refine_keywords \ No newline at end of file diff --git a/app/services/text_cleaner.py b/app/services/text_cleaner.py new file mode 100644 index 0000000..f227274 --- /dev/null +++ b/app/services/text_cleaner.py @@ -0,0 +1,20 @@ +import re +from ..common.lexicon import FILLER_WORDS + +def basic_clean(text: str) -> str: + if not text: + return [] + + # 특수문자 제거 + text = re.sub(r"[^가-힣a-zA-Z0-9\s]", "", text) + + # 공백 기준 분리 + words = text.split() + + # 불용어 제거 + cleaned_words = [ + words for word in words + if word not in FILLER_WORDS + ] + + return " ".join(cleaned_words) \ No newline at end of file diff --git a/app/utils/prompt_builder.py b/app/utils/prompt_builder.py new file mode 100644 index 0000000..da372d6 --- /dev/null +++ b/app/utils/prompt_builder.py @@ -0,0 +1,57 @@ +from app.schemas.profile_schema import ConstraintResponse + +def build_story_prompt(constraints: ConstraintResponse) -> dict: + # 역할과 출력 형식 고정 + system_prompt = ( + "너는 난독증 아동을 위한 전문 한국어 동화 작가이자 특수 교사야.\n" + "아동의 읽기 수준과 취약한 발음에 맞춰 아주 세심하게 동화를 작성해야 해.\n" + "모든 출력은 반드시 JSON 형식으로만 작성하고, 지정된 제약 조건을 완벽하게 지켜줘." + ) + + themes_str = ", ".join(constraints.theme_keywords) if constraints.theme_keywords else "자유 주제" + phonemes_str = ", ".join(constraints.focus_phonemes) if constraints.focus_phonemes else "없음" + + # 제약값 넣어서 지시 + user_prompt = f""" +아래의 [제약 조건]을 엄격하게 지켜서 아동 맞춤형 동화를 생성해줘. + +[제약 조건] +1. 대상 연령: {constraints.age_group} +2. 동화 주제(관심사): {themes_str} +3. 어휘 난이도: {constraints.vocab_level} +4. 문장 길이: 한 페이지당 무조건 1개의 문장만 작성할 것. (한 문장당 절대 {constraints.max_sentence_len}어절을 넘지 말 것) +5. 전체 분량: 총 {constraints.total_pages}페이지 (즉, 총 {constraints.total_pages}개의 문장) +6. 장면(Scene) 구성: 3~4개의 페이지(문장)를 묶어서 하나의 Scene으로 구성할 것. 각 Scene마다 삽화를 그리기 위한 영어 프롬프트를 1개씩만 생성할 것. +7. 취약 발음 연습: '{phonemes_str}' 발음이 포함된 단어를 이야기가 어색하지 않은 선에서 자주 등장시킬 것. + +[출력 형식(JSON)] +{{ + "title": "동화 제목", + "scenes": [ + {{ + "scene_number": 1, + "image_prompt": "이 scene의 삽화를 위한 영어 프롬프트", + "pages": [ + {{ + "page_number": 1, + "text": "페이지에 들어갈 문장 1개" + }}, + {{ + "page_number": 2, + "text": "페이지에 들어갈 문장 1개" + }}, + {{ + "page_number": 3, + "text": "페이지에 들어갈 문장 1개" + }} + ] + }}, + ... + ] +}} +""" + + return { + "system_prompt": system_prompt, + "user_prompt": user_prompt + } \ No newline at end of file