1+ from .. import dto
12from ..config import OPENAI_API_KEY
23from openai import OpenAI
34
45client = 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+
6148def 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