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
24 changes: 24 additions & 0 deletions app/api/constraint_router.py
Original file line number Diff line number Diff line change
@@ -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))
31 changes: 31 additions & 0 deletions app/api/refiner_router.py
Original file line number Diff line number Diff line change
@@ -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
)
44 changes: 44 additions & 0 deletions app/common/level_consts.py
Original file line number Diff line number Diff line change
@@ -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
}
}
24 changes: 24 additions & 0 deletions app/common/lexicon.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# 불용어
FILLER_WORDS = {
"음", "어", "아", "그", "그냥", "저", "저기", "뭐지", "뭔가", "약간",
"그니까", "그러니까", "그래서", "그리고", "그치만", "하지만", "그래도",
"어떤", "이런", "나", "저는", "나는", "내가", "좋아", "좋아해",
"진짜", "완전", "막", "이제", "일단"
}

# 금지어
BANNED_KEYWORDS = {
"살인", "자살", "피", "테러", "마약", "죽음", "사망",
"19금", "섹스", "야동", "강간", "범죄", "좆",
"씨발", "시발", "년", "개새끼", "병신", "죽어", "존나", "미친", "지랄"
}

# 유의어
THEME_HINTS = {
"모험": {"모험", "여행", "탐험", "찾기", "보물", "숲", "바다", "지도"},
"우주": {"우주", "달", "별", "로켓", "행성", "외계인", "우주선"},
"공룡": {"공룡", "티라노", "티라노사우루스", "트리케라톱스", "랩터", "브라키오", "화석"},
"로봇": {"로봇", "AI", "인공지능", "기계", "드론", "변신"},
"강아지": {"개", "멍멍이", "멍멍"},
"고양이": {"냥이", "야옹이", "냥냥", "냐옹"}
}
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
@@ -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():
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")
22 changes: 22 additions & 0 deletions app/schemas/refiner_schema.py
Original file line number Diff line number Diff line change
@@ -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면 프론트에서 재입력 유도)"
)
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
)
39 changes: 39 additions & 0 deletions app/services/interest_refiner.py
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions app/services/text_cleaner.py
Original file line number Diff line number Diff line change
@@ -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)
57 changes: 57 additions & 0 deletions app/utils/prompt_builder.py
Original file line number Diff line number Diff line change
@@ -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
}