Skip to content
Open
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
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,7 @@ Thumbs.db

# === IDE ===
.idea/
.vscode/
.vscode/

# === API KEY ===
.env
31 changes: 0 additions & 31 deletions app/api/refiner_router.py

This file was deleted.

26 changes: 26 additions & 0 deletions app/api/story_router.py
Original file line number Diff line number Diff line change
@@ -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)}")
47 changes: 47 additions & 0 deletions app/common/level_consts.py
Original file line number Diff line number Diff line change
@@ -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
}
}
6 changes: 4 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down
30 changes: 30 additions & 0 deletions app/schemas/profile_schema.py
Original file line number Diff line number Diff line change
@@ -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")
21 changes: 21 additions & 0 deletions app/schemas/story_schema.py
Original file line number Diff line number Diff line change
@@ -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="동화를 구성하는 장면 리스트")
31 changes: 31 additions & 0 deletions app/services/constraint_generator.py
Original file line number Diff line number Diff line change
@@ -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
)
46 changes: 46 additions & 0 deletions app/services/story_generator.py
Original file line number Diff line number Diff line change
@@ -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)}")
36 changes: 36 additions & 0 deletions app/services/story_orchestrator.py
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions app/utils/prompt_builder.py
Original file line number Diff line number Diff line change
@@ -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
}