diff --git a/.gitignore b/.gitignore index 1016065..46590a7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,7 @@ Thumbs.db # === IDE === .idea/ -.vscode/ \ No newline at end of file +.vscode/ + +# === API KEY === +.env \ No newline at end of file diff --git a/app/api/refiner_router.py b/app/api/refiner_router.py deleted file mode 100644 index b9bc8dd..0000000 --- a/app/api/refiner_router.py +++ /dev/null @@ -1,31 +0,0 @@ -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/api/story_router.py b/app/api/story_router.py new file mode 100644 index 0000000..05d0439 --- /dev/null +++ b/app/api/story_router.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, Depends, HTTPException +from app.schemas.story_schema import GenerateStoryRequest, StoryResponse +from app.services.story_orchestrator import StoryOrchestratorService + +router = APIRouter( + prefix="/api/story", + tags=["Story Generator"] +) + +def get_orchestrator(): + return StoryOrchestratorService() + +@router.post("/story_generate", response_model=StoryResponse) +async def generate_story( + request: GenerateStoryRequest, + orchestrator: StoryOrchestratorService = Depends(get_orchestrator) +): + try: + final_story = await orchestrator.run_pipeline(request) + return final_story + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=500, detail=f"동화 생성 중 알 수 없는 오류 발생: {str(e)}") \ 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..e950450 --- /dev/null +++ b/app/common/level_consts.py @@ -0,0 +1,47 @@ +LEVEL_CONSTRAINTS = { + "L1": { + "age_group": "3~4세", + "target_wcpm": 15, + "max_sentence_len": 4, + "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, + "min_sentence_len": 5, + "max_sentence_len": 13, + "vocab_level": "중", + "total_pages": 21 + }, + "L5": { + "age_group": "11~12세", + "target_wcpm": 110, + "min_sentence_len": 7, + "max_sentence_len": 15, + "vocab_level": "중상", + "total_pages": 24 + }, + "L6": { + "age_group": "13세", + "target_wcpm": 130, + "min_sentence_len": 11, + "max_sentence_len": 17, + "vocab_level": "상", + "total_pages": 24 + } +} \ No newline at end of file diff --git a/app/main.py b/app/main.py index 5a7a8c8..521f9df 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,12 @@ from fastapi import FastAPI -from app.api import refiner_router, stt_router +from app.api import stt_router, story_router +from dotenv import load_dotenv +load_dotenv() app = FastAPI() app.include_router(stt_router.router) -app.include_router(refiner_router.router) +app.include_router(story_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/story_schema.py b/app/schemas/story_schema.py new file mode 100644 index 0000000..a0e2871 --- /dev/null +++ b/app/schemas/story_schema.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel, Field +from typing import List, Optional + +class GenerateStoryRequest(BaseModel): + stt_text: str = Field(..., description="아이가 말한 음성의 원본 STT 텍스트") + level: str = Field(..., description="아동의 현재 읽기 레벨") + recent_wcpm: Optional[float] = Field(None, description="최근 WCPM") + weak_phonemes: Optional[List[str]] = Field(None, description="취약 발음 리스트") + +class Page(BaseModel): + page_number: int = Field(..., description="페이지 번호") + text: str = Field(..., description="해당 페이지에 들어갈 문장") + +class Scene(BaseModel): + scene_number: int = Field(..., description="장면 번호") + image_prompt: str = Field(..., description="장면 이미지 생성을 위한 영어 프롬프트") + pages: List[Page] = Field(..., description="장면에 속하는 페이지 리스트") + +class StoryResponse(BaseModel): + title: str = Field(..., description="동화 제목") + scenes: List[Scene] = Field(..., description="동화를 구성하는 장면 리스트") \ 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/story_generator.py b/app/services/story_generator.py new file mode 100644 index 0000000..79dacc9 --- /dev/null +++ b/app/services/story_generator.py @@ -0,0 +1,46 @@ +import os +from openai import AsyncOpenAI +from pydantic import ValidationError +from app.schemas.story_schema import StoryResponse + +class LLMStoryGeneratorService: + def __init__(self): + # 환경 변수에서 API 키 불러오기 + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OPENAI API KEY 환경변수가 설정되지 않았습니다. .env 파일을 확인해주세요.") + + # 비동기 클라이언트 생성 + self.client = AsyncOpenAI(api_key=api_key) + + # 모델 지정 + self.model = "gpt-4o" + + async def generate_story(self, system_prompt: str, user_prompt: str) -> StoryResponse: + try: + # OPENAI API 호출 + response = await self.client.chat.completions.create( + model = self.model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.7, + response_format={"type": "json_object"} + ) + + # GPT 결과물 추출 + result_text = response.choices[0].message.content + + # 유효성 검증 + parsed_story = StoryResponse.model_validate_json(result_text) + return parsed_story + + except ValidationError as e: + # GPT가 양식 어겼을 때 + print(f"데이터 형식 불일치 에러 발생: {e}") + raise ValueError("LLM이 지정한 동화 JSON 형식을 지키지 않았습니다.") + + except Exception as e: + print(f"OpenAI API 통신 중 에러 발생: {e}") + raise RuntimeError(f"동화 생성 줄 서버 통신 오류가 발생했습니다: {str(e)}") \ No newline at end of file diff --git a/app/services/story_orchestrator.py b/app/services/story_orchestrator.py new file mode 100644 index 0000000..a28d4a8 --- /dev/null +++ b/app/services/story_orchestrator.py @@ -0,0 +1,36 @@ +from app.schemas.story_schema import GenerateStoryRequest, StoryResponse +from app.schemas.profile_schema import ProfileRequest +from app.services.interest_refiner import InterestRefinerService +from app.services.constraint_generator import ConstraintGeneratorService +from app.services.story_generator import LLMStoryGeneratorService +from app.utils.prompt_builder import build_story_prompt + +class StoryOrchestratorService: + def __init__(self): + self.refiner_service = InterestRefinerService() + self.constraint_service = ConstraintGeneratorService() + self.llm_service = LLMStoryGeneratorService() + + async def run_pipeline(self, request: GenerateStoryRequest) -> StoryResponse: + # STT 관심사 정제 + self.refined_keywords = self.refiner_service.refine(request.stt_text) + + # 제약값 생성 + profile_req = ProfileRequest( + level=request.level, + interests=self.refined_keywords, + recent_wcpm=request.recent_wcpm, + weak_phonemes=request.weak_phonemes + ) + constraints = self.constraint_service.generate(profile_req) + + # 프롬프트 빌더 + prompts = build_story_prompt(constraints) + + # LLM 동화 생성 + final_story = await self.llm_service.generate_story( + system_prompt=prompts["system_prompt"], + user_prompt=prompts["user_prompt"] + ) + + return final_story \ 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..7f07503 --- /dev/null +++ b/app/utils/prompt_builder.py @@ -0,0 +1,73 @@ +from app.schemas.profile_schema import ConstraintResponse + +def build_story_prompt(constraints: ConstraintResponse) -> dict: + # 역할과 출력 형식 고정 + system_prompt = ( + "너는 난독증 아동을 위한 전문 한국어 동화 작가이자 특수교사야.\n" + "아동의 읽기 수준과 취약한 발음에 맞춰 아주 세심하게 동화를 작성해야 해.\n" + "반복을 피하고, 장면마다 기승전결이 분명한 이야기를 만들어.\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 "없음" + min_len = getattr(constraints, "min_sentence_len", None) + + # 제약값 넣어서 지시 + user_prompt = f""" +아래의 [제약 조건]을 엄격하게 지켜서 아동 맞춤형 동화를 생성해줘. + +[핵심 목표] +- 재미있고 변화가 있는 전개로, 비슷한 내용 반복을 피해야 해. +- 동화에는 기승전결(상황-문제-시도-결과)이 반드시 드러나야 해. +- 모든 문장은 반드시 '-요'로 끝나야 해. + +[제약 조건] +1. 대상 연령: {constraints.age_group} +2. 동화 주제(관심사): {themes_str} +3. 어휘 난이도: {constraints.vocab_level} +4. 문장 길이: 한 페이지당 무조건 1개의 문장만 작성할 것. (한 문장당 절대 {constraints.max_sentence_len}어절을 넘지 말 것) +{"5. 문장 길이(최소): 한 문장당 최소 " + str(min_len) + "어절 이상으로 써." if min_len else ""} +6. 전체 분량: 총 {constraints.total_pages}페이지 (즉, 총 {constraints.total_pages}개의 문장) +7. 장면(Scene) 구성: 3~4개의 페이지(문장)를 묶어서 하나의 Scene으로 구성할 것. 각 Scene마다 삽화를 그리기 위한 구체적인 영어 프롬프트를 1개씩만 생성할 것. +8. 취약 발음 연습: '{phonemes_str}' 발음이 포함된 단어를 이야기가 어색하지 않은 선에서 자주 등장시킬 것. + +[대사 사용 규칙] +- 이야기 전체에서 1~3개의 페이지만 대사를 포함해. +- 대사가 포함된 페이지도 여전히 "1페이지 = 1문장" 규칙을 지켜. +- 대사는 반드시 큰따옴표("")를 사용해. +- 대사는 반드시 '-요'로 끝나지 않아도 돼. +- 대사는 인물의 감정이나 문제 해결에 기여해야 해. + +[출력 형식(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