Skip to content

Commit d8f6cea

Browse files
authored
Merge pull request #19 from DropThe8bit/feature/story
[refactor] 프롬프트 장르별 세분화
2 parents 6199420 + f965540 commit d8f6cea

File tree

3 files changed

+209
-29
lines changed

3 files changed

+209
-29
lines changed

everTale/app/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def create_init_story(request: dto.InitStoryRequest):
2626
def create_next_story(request: dto.NextStoryRequest):
2727
prompt = story_service.generate_prompt_for_next_story(
2828
previous=request.previous,
29-
scene_number=request.sceneNum,
29+
page_number=request.pageNum,
3030
genre=request.genre,
3131
name=request.name,
3232
age=request.age,

everTale/app/dto.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ class InitStoryResponse(BaseModel):
1515

1616
class NextStoryRequest(BaseModel):
1717
previous: str = Field(..., json_schema_extra={"example": "토토로는 첫 여행지로 북부의 마탑으로 향했어요. 그녀는 마법을 배우고 싶어해요"})
18-
sceneNum: int = Field(..., json_schema_extra={"example": "2"})
18+
pageNum: int = Field(..., json_schema_extra={"example": "2"})
1919
genre: str = Field(..., json_schema_extra={"example": "ADVENTURE"})
2020
name: str = Field(..., json_schema_extra={"example": "토토로"})
2121
age: int = Field(..., json_schema_extra={"example": 8})

everTale/app/service/story_service.py

Lines changed: 207 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,150 @@
1+
from .. import dto
12
from ..config import OPENAI_API_KEY
23
from openai import OpenAI
34

45
client = OpenAI(api_key=OPENAI_API_KEY)
56

7+
# 장르별 톤/배경/어휘
8+
genre_guides = {
9+
"ADVENTURE": {
10+
"tone": "경이롭고 용감한 분위기, 호기심을 자극",
11+
"setting": "판타지 세계관(숲·성·마법)",
12+
"hint": "모험과 탐험을 중심으로 전개",
13+
},
14+
"FRIENDSHIP": {
15+
"tone": "따뜻하고 명랑한 분위기, 협동과 배려 강조",
16+
"setting": "유치원/학교 생활",
17+
"hint": "아이들 사이의 우정과 놀이",
18+
},
19+
"MORAL": {
20+
"tone": "잔잔하고 사려 깊은 분위기",
21+
"setting": "자연 속(초원·강가·동물 등장)",
22+
"hint": "이솝우화처럼 교훈을 주는 이야기",
23+
},
24+
"FAMILY": {
25+
"tone": "포근하고 다정한 분위기",
26+
"setting": "집·가족이 함께하는 공간",
27+
"hint": "사랑과 연대감을 드러내는 스토리",
28+
},
29+
}
30+
31+
# 페이지(1~8)별 기승전결 장면 설계
32+
beat_map = {
33+
1: {
34+
"label": "기(시작) — 세계관·주인공 소개",
35+
"goal": "배경과 분위기, 주인공의 기본 정보가 자연스럽게 드러난다",
36+
"must": ["주인공의 이름·나이·성격을 대사나 행동으로 녹이기"],
37+
"hint": [
38+
"장르 setting을 한 문장으로 열어 분위기 고정",
39+
"현재 상황(시간/장소/기분)을 짧게 제시"
40+
],
41+
"style_hint": "짧은 구어체, 아이 시선에서 경쾌하게",
42+
"ending": "작은 호기심이 싹트는 느낌으로 가볍게 마무리(선택적)."
43+
},
44+
2: {
45+
"label": "기(전개) — 일상/목표 드러남",
46+
"goal": "주인공의 작은 목표와 일상이 보이며 동기가 형성된다",
47+
"must": ["주인공의 소망 또는 오늘의 작은 목표 1개"],
48+
"hint": [
49+
"친구·환경과의 상호작용으로 목표를 드러내기",
50+
"감정 어휘는 단순하고 따뜻하게"
51+
],
52+
"style_hint": "대사 1~2줄로 템포를 살리기",
53+
"ending": "목표를 향한 소소한 결심 또는 변화의 징조(선택적)."
54+
},
55+
3: {
56+
"label": "승(변화) — 사건 발생",
57+
"goal": "분명한 변화나 사건이 등장해 긴장감이 시작된다",
58+
"must": ["사건/징조 1개", "주인공의 즉각 반응(감정+행동)"],
59+
"hint": [
60+
"사건은 과장하지 말고 일상에서 출발",
61+
"감정은 ‘놀람/궁금/걱정’ 등 간단 명료"
62+
],
63+
"avoid": ["사건을 같은 페이지에서 바로 해결하지 않기"],
64+
"style_hint": "짧은 문장으로 리듬감 있게",
65+
"ending": "선택지가 생기는 듯한 훅(선택적)."
66+
},
67+
4: {
68+
"label": "승(전개) — 긴장 고조",
69+
"goal": "장애물이 구체화되고 시도가 부분적으로 실패한다",
70+
"must": ["장애물 1개 구체화", "주인공의 첫 시도와 작은 좌절"],
71+
"hint": [
72+
"우연한 해결은 금지(공통 가드레일 준수)",
73+
"주인공의 노력과 배움의 씨앗 암시"
74+
],
75+
"style_hint": "행동 묘사 위주, 감정은 과잉 설명 자제",
76+
"ending": "더 큰 시도 또는 도움의 필요성 암시(선택적)."
77+
},
78+
5: {
79+
"label": "전(위기) — 갈등 본격화",
80+
"goal": "가장 큰 어려움 직전까지 몰리며 감정이 고조된다",
81+
"must": ["주인공의 망설임/두려움 한 문장", "결정을 요구하는 상황 제시"],
82+
"hint": [
83+
"환경(소리/색/움직임)으로 긴장감 보태기",
84+
"해결의 작은 힌트를 은근히 깔아두기"
85+
],
86+
"style_hint": "호흡을 짧게, 문장 끝 처리를 단단하게",
87+
"ending": "결단 직전의 정적과 떨림(선택적)."
88+
},
89+
6: {
90+
"label": "전(절정) — 위기 최고조",
91+
"goal": "결정적 행동/도움/통찰로 위기를 정면 돌파한다",
92+
"must": ["핵심 행동 1개", "장르 상상력 장면 1개(마법/우정/가족애/교훈)"],
93+
"hint": [
94+
"주인공이 주도하거나 주인공의 선택이 결정적이어야 함",
95+
"설명보다 구체적인 장면 이미지로"
96+
],
97+
"style_hint": "동사 중심, 리듬감 있는 전개",
98+
"ending": "결과 확인 직전의 숨 고르기(선택적)."
99+
},
100+
7: {
101+
"label": "결(전환) — 반전/실마리",
102+
"goal": "오해가 풀리거나 도움이 도착하고, 성장을 자각한다",
103+
"must": ["갈등을 풀 단서 1개", "신뢰/연대의 순간"],
104+
"hint": [
105+
"설교조 대신 짧은 대사·행동으로 메시지 전달",
106+
"감정의 온기를 장면 속 사물/제스처로 표현"
107+
],
108+
"style_hint": "따뜻한 톤, 과장 없이 담백하게",
109+
"ending": "해결 이후의 여운을 가볍게 예고(선택적)."
110+
},
111+
8: {
112+
"label": "결(마무리) — 갈등 해소·따뜻한 엔딩",
113+
"goal": "갈등을 정리하고 장르별 메시지로 정서적 마무리를 짓는다",
114+
"must": [
115+
"갈등이 어떻게 풀렸는지 한 문장",
116+
"장르 메시지 반영(교훈/우정/가족사랑/모험 씨앗 중 해당)"
117+
],
118+
"hint": [
119+
"마지막 문장은 짧고 선명하게",
120+
"설명 대신 장면·제스처·짧은 대사로 여운 남기기"
121+
],
122+
"style_hint": "포근하고 맺음이 확실한 문장",
123+
"ending": "짧고 따뜻한 마무리 한 문장으로 끝맺기."
124+
},
125+
}
126+
127+
128+
# 출력 형식/금지어
129+
guardrails = {
130+
"style_rules": [
131+
"총 3~5문장.",
132+
"아이들이 이해하기 쉬운 어휘.",
133+
"현재 시점처럼 생동감 있게.",
134+
"대사는 0~1문장(필요할 때만).",
135+
"문장 길이는 8~18자로 짧고 리듬감 있게.",
136+
"종결 어미를 다양화(했어요/하고/한다/하자 섞기).",
137+
],
138+
"forbidden": [
139+
"과도한 공포/폭력/비하",
140+
"연애·신체 노출·성인 맥락",
141+
"장황한 설정 나열",
142+
"영어 번역투(과잉 수식, 수동 표현, '과연~?' 수사 의문)",
143+
"‘것/상황/문제’ ‘있었다/했다’ 반복",
144+
]
145+
}
146+
147+
6148
def generate_story(prompt: str) -> str:
7149
response = client.chat.completions.create(
8150
model="gpt-4",
@@ -13,48 +155,86 @@ def generate_story(prompt: str) -> str:
13155
)
14156
return response.choices[0].message.content
15157

158+
def generate_story_from_character_info(genre, world_view, name, age, gender, personalities):
159+
prompt = make_initial_prompt(genre, world_view, name, age, gender, personalities)
160+
return generate_story(prompt)
16161

17-
def make_prompt(genre, world_view, name, age, gender, personalities):
162+
def make_initial_prompt(genre, world_view, name, age, gender, personalities) -> str:
163+
g = genre_guides.get(genre, genre_guides["ADVENTURE"])
164+
beat = beat_map.get(1, beat_map[1])
18165
personality_str = ", ".join(personalities)
166+
19167
return (
20-
f"장르: {genre}\n"
21-
f"주인공 이름은 {name}이고, 나이는 {age}살이며 성별은 {gender}야.\n"
22-
f"{world_view}를 참고해서 {personality_str} 성격을 가진 주인공의 흥미롭고 감동적인 동화의 시작 부분을 2~3줄 써줘.\n"
23-
f"아이들이 흥미롭게 읽을 수 있도록 상상력을 풍부하게 써줘."
168+
f"[역할]\n"
169+
f"너는 유아·아동 대상 동화 작가야. 아이가 읽기 쉬운 말로, 따뜻하고 창의적으로 1페이지(시작 장면)를 써줘.\n\n"
170+
f"[장르]\n"
171+
f"{genre} — 톤: {g['tone']} | 배경: {g['setting']} | 힌트: {g['hint']} \n\n"
172+
f"[세계관 힌트]\n{world_view}\n\n"
173+
f"[현재 장면] 1페이지 / {beat['label']}\n"
174+
f"- 장면 목적: {beat['goal']}\n"
175+
f"- 반드시 포함: {', '.join(beat['must']) if beat['must'] else '자연스러운 전개'}\n"
176+
f"- 힌트: {', '.join(beat.get('hint', [])) if beat.get('hint') else '없음'}\n"
177+
f"- 장면 마무리: {beat['ending']} (선택적)\n\n"
178+
f"[주인공 정보]\n"
179+
f"- 이름: {name}\n"
180+
f"- 나이: {age}\n"
181+
f"- 성별: {gender}\n"
182+
f"- 성격: {personality_str}\n\n"
183+
f"[스타일 규칙]\n"
184+
f"- " + "\n- ".join(guardrails["style_rules"]) + "\n"
185+
f"- {beat.get('style_hint', '')}\n\n"
186+
f"[금지]\n"
187+
f"- " + "\n- ".join(guardrails["forbidden"]) + "\n\n"
188+
f"[출력 형식]\n"
189+
f"- 한국어로 3~5문장.\n"
190+
f"- 문단 머리표, 번호, 메타설명 없이 순수 서사만 출력.\n"
191+
f"- 마지막 문장은 다음 장면이 기대되게 가볍게 여운을 남김.\n\n"
192+
f"[이어서 작성]\n"
193+
f"위 정보를 바탕으로 1페이지 시작 장면을 써줘."
24194
)
25195

26-
def generate_story_from_character_info(genre, world_view, name, age, gender, personalities):
27-
prompt = make_prompt(genre, world_view, name, age, gender, personalities)
28-
return generate_story(prompt)
196+
def generate_prompt_for_next_story(
197+
page_number: int,
198+
genre: str,
199+
previous: str,
200+
name: str,
201+
age: int,
202+
gender: str,
203+
personalities: list[str],
204+
) -> str:
29205

30-
def generate_prompt_for_next_story(scene_number, genre, previous, name, age, gender, personalities):
31206
personality_str = ", ".join(personalities)
32-
33-
stage_map = {
34-
1: "기(시작): 세계관과 주인공을 소개하는 장면이야.",
35-
2: "기(전개): 주인공의 평범한 일상이나 목표가 드러나는 장면이야.",
36-
3: "승(변화): 이야기에 변화를 주는 사건이 발생하는 장면이야.",
37-
4: "승(전개): 사건이 커지고 긴장감이 높아지는 장면이야.",
38-
5: "전(위기): 갈등이 본격적으로 시작되고 주인공이 어려움을 겪는 장면이야.",
39-
6: "전(절정): 갈등이 최고조에 달하고 주인공이 위기에 처한 장면이야.",
40-
7: "결(전환): 위기를 극복할 실마리가 보이고 상황이 반전되는 장면이야.",
41-
8: "결(마무리): 모든 갈등이 해소되고 이야기가 따뜻하게 마무리되는 장면이야."
42-
}
43-
44-
stage = stage_map.get(scene_number, "알 수 없는 장면 번호입니다. scene_number는 1부터 8 사이여야 합니다.")
207+
g = genre_guides.get(genre, genre_guides["ADVENTURE"])
208+
beat = beat_map.get(page_number, beat_map[1])
45209

46210
return (
47-
f"[장르] {genre}\n"
48-
f"[현재 장면: {scene_number}페이지 / {stage}]\n\n"
211+
f"[역할]\n"
212+
f"너는 유아·아동 대상 동화 작가야. 번역투를 피하고 자연스러운 한국어 구어체로, 이전 줄거리에 자연스럽게 이어지게 작성해줘.\n\n"
213+
f"[장르]\n"
214+
f"{genre} — 톤: {g['tone']} | 배경: {g['setting']} | 힌트: {g.get('hint', '')}\n\n"
215+
f"[현재 장면] {page_number}페이지 / {beat['label']}\n"
216+
f"- 장면 목적: {beat['goal']}\n"
217+
f"- 반드시 포함: {', '.join(beat['must']) if beat['must'] else '자연스러운 전개'}\n"
218+
f"- 힌트: {', '.join(beat.get('hint', [])) if beat.get('hint') else '없음'}\n"
219+
f"- 장면 마무리: {beat['ending']} (선택적)\n\n"
49220
f"[이전 줄거리 요약]\n{previous}\n\n"
50221
f"[주인공 정보]\n"
51222
f"- 이름: {name}\n"
52223
f"- 나이: {age}\n"
53224
f"- 성별: {gender}\n"
54225
f"- 성격: {personality_str}\n\n"
55-
f"위 정보를 바탕으로 {scene_number}번째 장면을 2~3문장으로 이어서 써줘.\n"
56-
f"아이들이 흥미롭게 읽을 수 있도록, 따뜻하고 창의적인 문장으로 자연스럽게 이야기를 이어줘."
226+
f"[스타일 규칙]\n"
227+
f"- " + "\n- ".join(guardrails["style_rules"]) + "\n"
228+
f"- {beat.get('style_hint', '')}\n\n"
229+
f"[금지]\n"
230+
f"- " + "\n- ".join(guardrails["forbidden"]) + "\n\n"
231+
f"[출력 형식]\n"
232+
f"- 한국어로 3~5문장.\n"
233+
f"- 문단 머리표, 번호, 메타설명 없이 순수 서사만 출력.\n"
234+
f"- 8페이지가 아니면 마지막 문장은 다음 장면이 기대되게 가볍게 여운을 남김.\n"
235+
f"- 8페이지면 따뜻하게 마무리.\n\n"
236+
f"[이어서 작성]\n"
237+
f"위의 정보를 바탕으로 {page_number}번째 장면을 이어서 써줘."
57238
)
58239

59240

60-

0 commit comments

Comments
 (0)