openfree commited on
Commit
34ba15f
·
verified ·
1 Parent(s): 7a59474

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +24 -1860
app.py CHANGED
@@ -1,1871 +1,35 @@
1
- import gradio as gr
2
  import os
3
- import json
4
- import requests
5
- from datetime import datetime
6
- import time
7
- from typing import List, Dict, Any, Generator, Tuple, Optional, Set
8
- import logging
9
- import re
10
- import tempfile
11
- from pathlib import Path
12
- import sqlite3
13
- import hashlib
14
- import threading
15
- from contextlib import contextmanager
16
- from dataclasses import dataclass, field, asdict
17
- from collections import defaultdict
18
- import random
19
- from huggingface_hub import HfApi, upload_file, hf_hub_download
20
 
21
- # --- Logging setup ---
22
- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
23
- logger = logging.getLogger(__name__)
24
-
25
- # --- Document export imports ---
26
- try:
27
- from docx import Document
28
- from docx.shared import Inches, Pt, RGBColor, Mm
29
- from docx.enum.text import WD_ALIGN_PARAGRAPH
30
- from docx.enum.style import WD_STYLE_TYPE
31
- from docx.oxml.ns import qn
32
- from docx.oxml import OxmlElement
33
- DOCX_AVAILABLE = True
34
- except ImportError:
35
- DOCX_AVAILABLE = False
36
- logger.warning("python-docx not installed. DOCX export will be disabled.")
37
-
38
- import io # Add io import for DOCX export
39
-
40
- # --- Environment variables and constants ---
41
- FIREWORKS_API_KEY = os.getenv("FIREWORKS_API_KEY", "")
42
- BRAVE_SEARCH_API_KEY = os.getenv("BRAVE_SEARCH_API_KEY", "")
43
- API_URL = "https://api.fireworks.ai/inference/v1/chat/completions"
44
- MODEL_ID = "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507"
45
- DB_PATH = "webnovel_sessions_v1.db"
46
-
47
- # Target settings for web novel - UPDATED FOR LONGER EPISODES
48
- TARGET_EPISODES = 40 # 40화 완결
49
- WORDS_PER_EPISODE = 400 # 각 화당 400-600 단어 (기존 200-300에서 증가)
50
- TARGET_WORDS = TARGET_EPISODES * WORDS_PER_EPISODE # 총 16000 단어
51
-
52
- # Web novel genres
53
- WEBNOVEL_GENRES = {
54
- "로맨스": "Romance",
55
- "로판": "Romance Fantasy",
56
- "판타지": "Fantasy",
57
- "현판": "Modern Fantasy",
58
- "무협": "Martial Arts",
59
- "미스터리": "Mystery",
60
- "라이트노벨": "Light Novel"
61
- }
62
-
63
- # --- Environment validation ---
64
- if not FIREWORKS_API_KEY:
65
- logger.error("FIREWORKS_API_KEY not set. Application will not work properly.")
66
- FIREWORKS_API_KEY = "dummy_token_for_testing"
67
-
68
- if not BRAVE_SEARCH_API_KEY:
69
- logger.warning("BRAVE_SEARCH_API_KEY not set. Web search features will be disabled.")
70
-
71
- # --- Global variables ---
72
- db_lock = threading.Lock()
73
-
74
- # --- Data classes ---
75
- @dataclass
76
- class WebNovelBible:
77
- """Web novel story bible for maintaining consistency"""
78
- genre: str = ""
79
- title: str = ""
80
- characters: Dict[str, Dict[str, Any]] = field(default_factory=dict)
81
- settings: Dict[str, str] = field(default_factory=dict)
82
- plot_points: List[Dict[str, Any]] = field(default_factory=list)
83
- episode_hooks: Dict[int, str] = field(default_factory=dict)
84
- genre_elements: Dict[str, Any] = field(default_factory=dict)
85
- power_system: Dict[str, Any] = field(default_factory=dict)
86
- relationships: List[Dict[str, str]] = field(default_factory=list)
87
-
88
- @dataclass
89
- class EpisodeCritique:
90
- """Critique for each episode"""
91
- episode_number: int
92
- hook_effectiveness: float = 0.0
93
- pacing_score: float = 0.0
94
- genre_adherence: float = 0.0
95
- character_consistency: List[str] = field(default_factory=list)
96
- reader_engagement: float = 0.0
97
- required_fixes: List[str] = field(default_factory=list)
98
-
99
- # --- Genre-specific prompts and elements ---
100
- GENRE_ELEMENTS = {
101
- "로맨스": {
102
- "key_elements": ["감정선", "오해와 화해", "달콤한 순간", "질투", "고백"],
103
- "popular_tropes": ["계약연애", "재벌과 평민", "첫사랑 재회", "짝사랑", "삼각관계"],
104
- "must_have": ["심쿵 포인트", "달달한 대사", "감정 묘사", "스킨십", "해피엔딩"],
105
- "episode_structure": "감정의 롤러코스터, 매 화 끝 설렘 포인트"
106
- },
107
- "로판": {
108
- "key_elements": ["회귀/빙의", "원작 지식", "운명 변경", "마법/검술", "신분 상승"],
109
- "popular_tropes": ["악녀가 되었다", "폐녀 각성", "계약결혼", "집착남주", "역하렘"],
110
- "must_have": ["차원이동 설정", "먼치킨 요소", "로맨스", "복수", "성장"],
111
- "episode_structure": "원작 전개 비틀기, 매 화 새로운 변수"
112
- },
113
- "판타지": {
114
- "key_elements": ["마법체계", "레벨업", "던전", "길드", "모험"],
115
- "popular_tropes": ["회귀", "시스템", "먼치킨", "히든피스", "각성"],
116
- "must_have": ["성장 곡선", "전투씬", "세계관", "동료", "최종보스"],
117
- "episode_structure": "점진적 강해짐, 새로운 도전과 극복"
118
- },
119
- "현판": {
120
- "key_elements": ["숨겨진 능력", "일상과 비일상", "도시 판타지", "능력자 사회", "각성"],
121
- "popular_tropes": ["헌터", "게이트", "길드", "랭킹", "아이템"],
122
- "must_have": ["현실감", "능력 각성", "사회 시스템", "액션", "성장"],
123
- "episode_structure": "일상 속 비일상 발견, 점진적 세계관 확장"
124
- },
125
- "무협": {
126
- "key_elements": ["무공", "문파", "강호", "복수", "의협"],
127
- "popular_tropes": ["천재", "폐급에서 최강", "기연", "환생", "마교"],
128
- "must_have": ["무공 수련", "대결", "문파 설정", "경지", "최종 결전"],
129
- "episode_structure": "수련과 대결의 반복, 점진적 경지 상승"
130
- },
131
- "미스터리": {
132
- "key_elements": ["단서", "추리", "반전", "서스펜스", "진실"],
133
- "popular_tropes": ["탐정", "연쇄 사건", "과거의 비밀", "복수극", "심리전"],
134
- "must_have": ["복선", "붉은 청어", "논리적 추리", "충격 반전", "해결"],
135
- "episode_structure": "단서의 점진적 공개, 긴장감 상승"
136
- },
137
- "라이트노벨": {
138
- "key_elements": ["학원", "일상", "코미디", "모에", "배틀"],
139
- "popular_tropes": ["이세계", "하렘", "츤데레", "치트", "길드"],
140
- "must_have": ["가벼운 문체", "유머", "캐릭터성", "일러스트적 묘사", "왁자지껄"],
141
- "episode_structure": "에피소드 중심, 개그와 진지의 균형"
142
- }
143
- }
144
-
145
- # Episode hooks by genre
146
- EPISODE_HOOKS = {
147
- "로맨스": [
148
- "그의 입술이 내 귀에 닿을 듯 가까워졌다.",
149
- "'사실... 너를 처음 본 순간부터...'",
150
- "그때, 예상치 못한 사람이 문을 열고 들어왔다.",
151
- "메시지를 확인한 순간, 심장이 멈출 것 같았다."
152
- ],
153
- "로판": [
154
- "그 순간, 원작에는 없던 인물이 나타났다.",
155
- "'폐하, 계약을 파기하겠습니다.'",
156
- "검은 오라가 그를 감싸며 눈빛이 변했다.",
157
- "회귀 전에는 몰랐던 진실이 드러났다."
158
- ],
159
- "판타지": [
160
- "[새로운 스킬을 획득했습니다!]",
161
- "던전 최심부에서 발견한 것은...",
162
- "'이건... SSS급 아이템이다!'",
163
- "시스템 창에 뜬 경고 메시지를 보고 경악했다."
164
- ],
165
- "현판": [
166
- "평범한 학생인 줄 알았던 그의 눈이 붉게 빛났다.",
167
- "갑자기 하늘에 거대한 균열이 생겼다.",
168
- "'당신도... 능력자였군요.'",
169
- "핸드폰에 뜬 긴급 재난 문자를 보고 얼어붙었다."
170
- ],
171
- "무협": [
172
- "그의 검에서 흘러나온 검기를 보고 모두가 경악했다.",
173
- "'이것이... 전설의 천마신공?!'",
174
- "피를 토하며 쓰러진 사부가 마지막으로 남긴 말은...",
175
- "그때, 하늘에서 한 줄기 빛이 내려왔다."
176
- ],
177
- "미스터리": [
178
- "그리고 시체 옆에서 발견된 것은...",
179
- "'범인은 이 안에 있습니다.'",
180
- "일기장의 마지막 페이지를 넘기자...",
181
- "CCTV에 찍힌 그 순간, 모든 것이 뒤바뀌었다."
182
- ],
183
- "라이트노벨": [
184
- "'선배! 사실 저... 마왕이에요!'",
185
- "전학생의 정체는 다름 아닌...",
186
- "그녀의 가방에서 떨어진 것을 보고 경악했다.",
187
- "'어라? 이거... 게임 아이템이 현실에?'"
188
- ]
189
- }
190
-
191
- # --- Core logic classes ---
192
- class WebNovelTracker:
193
- """Web novel narrative tracker"""
194
- def __init__(self):
195
- self.story_bible = WebNovelBible()
196
- self.episode_critiques: Dict[int, EpisodeCritique] = {}
197
- self.episodes: Dict[int, str] = {}
198
- self.total_word_count = 0
199
- self.reader_engagement_curve: List[float] = []
200
-
201
- def set_genre(self, genre: str):
202
- """Set the novel genre"""
203
- self.story_bible.genre = genre
204
- self.story_bible.genre_elements = GENRE_ELEMENTS.get(genre, {})
205
-
206
- def add_episode(self, episode_num: int, content: str, hook: str):
207
- """Add episode content"""
208
- self.episodes[episode_num] = content
209
- self.story_bible.episode_hooks[episode_num] = hook
210
- self.total_word_count = sum(len(ep.split()) for ep in self.episodes.values())
211
-
212
- def add_episode_critique(self, episode_num: int, critique: EpisodeCritique):
213
- """Add episode critique"""
214
- self.episode_critiques[episode_num] = critique
215
- self.reader_engagement_curve.append(critique.reader_engagement)
216
-
217
- class WebNovelDatabase:
218
- """Database management for web novel system"""
219
- @staticmethod
220
- def init_db():
221
- with sqlite3.connect(DB_PATH) as conn:
222
- conn.execute("PRAGMA journal_mode=WAL")
223
- cursor = conn.cursor()
224
-
225
- # Sessions table with genre
226
- cursor.execute('''
227
- CREATE TABLE IF NOT EXISTS sessions (
228
- session_id TEXT PRIMARY KEY,
229
- user_query TEXT NOT NULL,
230
- genre TEXT NOT NULL,
231
- language TEXT NOT NULL,
232
- title TEXT,
233
- created_at TEXT DEFAULT (datetime('now')),
234
- updated_at TEXT DEFAULT (datetime('now')),
235
- status TEXT DEFAULT 'active',
236
- current_episode INTEGER DEFAULT 0,
237
- total_episodes INTEGER DEFAULT 40,
238
- final_novel TEXT,
239
- reader_report TEXT,
240
- total_words INTEGER DEFAULT 0,
241
- story_bible TEXT,
242
- engagement_score REAL DEFAULT 0.0
243
- )
244
- ''')
245
-
246
- # Episodes table
247
- cursor.execute('''
248
- CREATE TABLE IF NOT EXISTS episodes (
249
- id INTEGER PRIMARY KEY AUTOINCREMENT,
250
- session_id TEXT NOT NULL,
251
- episode_number INTEGER NOT NULL,
252
- content TEXT,
253
- hook TEXT,
254
- word_count INTEGER DEFAULT 0,
255
- reader_engagement REAL DEFAULT 0.0,
256
- status TEXT DEFAULT 'pending',
257
- created_at TEXT DEFAULT (datetime('now')),
258
- FOREIGN KEY (session_id) REFERENCES sessions(session_id),
259
- UNIQUE(session_id, episode_number)
260
- )
261
- ''')
262
-
263
- # Episode critiques table
264
- cursor.execute('''
265
- CREATE TABLE IF NOT EXISTS episode_critiques (
266
- id INTEGER PRIMARY KEY AUTOINCREMENT,
267
- session_id TEXT NOT NULL,
268
- episode_number INTEGER NOT NULL,
269
- critique_data TEXT,
270
- created_at TEXT DEFAULT (datetime('now')),
271
- FOREIGN KEY (session_id) REFERENCES sessions(session_id)
272
- )
273
- ''')
274
-
275
- # Random themes library with genre
276
- cursor.execute('''
277
- CREATE TABLE IF NOT EXISTS webnovel_themes (
278
- theme_id TEXT PRIMARY KEY,
279
- genre TEXT NOT NULL,
280
- theme_text TEXT NOT NULL,
281
- language TEXT NOT NULL,
282
- title TEXT,
283
- protagonist TEXT,
284
- setting TEXT,
285
- hook TEXT,
286
- generated_at TEXT DEFAULT (datetime('now')),
287
- use_count INTEGER DEFAULT 0,
288
- rating REAL DEFAULT 0.0,
289
- tags TEXT
290
- )
291
- ''')
292
-
293
- conn.commit()
294
-
295
- @staticmethod
296
- @contextmanager
297
- def get_db():
298
- with db_lock:
299
- conn = sqlite3.connect(DB_PATH, timeout=30.0)
300
- conn.row_factory = sqlite3.Row
301
- try:
302
- yield conn
303
- finally:
304
- conn.close()
305
-
306
- @staticmethod
307
- def create_session(user_query: str, genre: str, language: str) -> str:
308
- session_id = hashlib.md5(f"{user_query}{genre}{datetime.now()}".encode()).hexdigest()
309
- with WebNovelDatabase.get_db() as conn:
310
- conn.cursor().execute(
311
- '''INSERT INTO sessions (session_id, user_query, genre, language)
312
- VALUES (?, ?, ?, ?)''',
313
- (session_id, user_query, genre, language)
314
- )
315
- conn.commit()
316
- return session_id
317
-
318
- @staticmethod
319
- def save_episode(session_id: str, episode_num: int, content: str,
320
- hook: str, engagement: float = 0.0):
321
- word_count = len(content.split()) if content else 0
322
- with WebNovelDatabase.get_db() as conn:
323
- cursor = conn.cursor()
324
- cursor.execute('''
325
- INSERT INTO episodes (session_id, episode_number, content, hook,
326
- word_count, reader_engagement, status)
327
- VALUES (?, ?, ?, ?, ?, ?, 'complete')
328
- ON CONFLICT(session_id, episode_number)
329
- DO UPDATE SET content=?, hook=?, word_count=?,
330
- reader_engagement=?, status='complete'
331
- ''', (session_id, episode_num, content, hook, word_count, engagement,
332
- content, hook, word_count, engagement))
333
-
334
- # Update session progress
335
- cursor.execute('''
336
- UPDATE sessions
337
- SET current_episode = ?,
338
- total_words = (
339
- SELECT SUM(word_count) FROM episodes WHERE session_id = ?
340
- ),
341
- updated_at = datetime('now')
342
- WHERE session_id = ?
343
- ''', (episode_num, session_id, session_id))
344
-
345
- conn.commit()
346
-
347
- @staticmethod
348
- def get_episodes(session_id: str) -> List[Dict]:
349
- with WebNovelDatabase.get_db() as conn:
350
- rows = conn.cursor().execute(
351
- '''SELECT * FROM episodes WHERE session_id = ?
352
- ORDER BY episode_number''',
353
- (session_id,)
354
- ).fetchall()
355
- return [dict(row) for row in rows]
356
-
357
- @staticmethod
358
- def save_webnovel_theme(genre: str, theme_text: str, language: str,
359
- metadata: Dict[str, Any]) -> str:
360
- theme_id = hashlib.md5(f"{genre}{theme_text}{datetime.now()}".encode()).hexdigest()[:12]
361
-
362
- with WebNovelDatabase.get_db() as conn:
363
- conn.cursor().execute('''
364
- INSERT INTO webnovel_themes
365
- (theme_id, genre, theme_text, language, title, protagonist,
366
- setting, hook, tags)
367
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
368
- ''', (theme_id, genre, theme_text, language,
369
- metadata.get('title', ''),
370
- metadata.get('protagonist', ''),
371
- metadata.get('setting', ''),
372
- metadata.get('hook', ''),
373
- json.dumps(metadata.get('tags', []))))
374
- conn.commit()
375
-
376
- return theme_id
377
-
378
- # --- LLM Integration ---
379
- class WebNovelSystem:
380
- """Web novel generation system"""
381
- def __init__(self):
382
- self.api_key = FIREWORKS_API_KEY
383
- self.api_url = API_URL
384
- self.model_id = MODEL_ID
385
- self.tracker = WebNovelTracker()
386
- self.current_session_id = None
387
- WebNovelDatabase.init_db()
388
-
389
- def create_headers(self):
390
- return {
391
- "Accept": "application/json",
392
- "Content-Type": "application/json",
393
- "Authorization": f"Bearer {self.api_key}"
394
- }
395
-
396
- # --- Prompt generation functions ---
397
- def create_planning_prompt(self, query: str, genre: str, language: str) -> str:
398
- """Create initial planning prompt for web novel"""
399
- genre_info = GENRE_ELEMENTS.get(genre, {})
400
-
401
- lang_prompts = {
402
- "Korean": f"""한국 웹소설 시장을 겨냥한 {genre} 장르 웹소설을 기획하세요.
403
-
404
- **[핵심 스토리 설정 - 반드시 이 내용을 중심으로 전개하세요]**
405
- {query}
406
-
407
- **장르:** {genre}
408
- **목표:** 40화 완결, 총 16,000단어
409
-
410
- ⚠️ **중요**: 위에 제시된 스토리 설정을 반드시 기반으로 하여 플롯을 구성하세요. 이 설정이 전체 이야기의 핵심이며, 모든 에피소드는 이 설정을 중심으로 전개되어야 합니다.
411
-
412
- **장르 필수 요소 (스토리 설정과 조화롭게 포함):**
413
- - 핵심 요소: {', '.join(genre_info.get('key_elements', []))}
414
- - 인기 트로프: {', '.join(genre_info.get('popular_tropes', []))}
415
- - 필수 포함: {', '.join(genre_info.get('must_have', []))}
416
-
417
- **전체 구성 (입력된 스토리 설정을 기반으로):**
418
- 1. **1-5화**: 제시된 설정의 주인공과 상황 소개, 핵심 갈등 제시
419
- 2. **6-15화**: 설정에서 제시된 갈등의 심화, 관계 발전
420
- 3. **16-25화**: 설정과 관련된 중요한 반전, 새로운 진실 발견
421
- 4. **26-35화**: 설정의 핵심 갈등이 최고조에 이르기
422
- 5. **36-40화**: 설정에서 시작된 모든 이야기의 대단원
423
-
424
- **각 화 구성 원칙:**
425
- - 400-600단어 분량 (충실한 내용)
426
- - 입력된 스토리 설정에 충실한 전개
427
- - 매 화 끝 강력한 후크
428
- - 빠른 전개와 몰입감
429
-
430
- 입력된 스토리 설정을 중심으로 구체적인 40화 플롯라인을 제시하세요. 각 화마다 핵심 사건과 전개를 명시하세요.""",
431
-
432
- "English": f"""Plan a Korean-style web novel for {genre} genre.
433
-
434
- **[Core Story Setting - MUST base the story on this]**
435
- {query}
436
-
437
- **Genre:** {genre}
438
- **Goal:** 40 episodes, total 16,000 words
439
-
440
- ⚠️ **IMPORTANT**: You MUST base the plot on the story setting provided above. This setting is the core of the entire story, and all episodes must revolve around this setting.
441
-
442
- **Genre Requirements (incorporate harmoniously with story setting):**
443
- - Key elements: {', '.join(genre_info.get('key_elements', []))}
444
- - Popular tropes: {', '.join(genre_info.get('popular_tropes', []))}
445
- - Must include: {', '.join(genre_info.get('must_have', []))}
446
-
447
- **Overall Structure (based on the input story setting):**
448
- 1. **Episodes 1-5**: Introduce protagonist and situation from the setting, present core conflict
449
- 2. **Episodes 6-15**: Deepen conflicts from the setting, develop relationships
450
- 3. **Episodes 16-25**: Major twist related to the setting, new revelations
451
- 4. **Episodes 26-35**: Core conflicts from the setting reach climax
452
- 5. **Episodes 36-40**: Resolution of all storylines started from the setting
453
-
454
- **Episode Principles:**
455
- - 400-600 words each (substantial content)
456
- - Faithful development of the input story setting
457
- - Strong hook at episode end
458
- - Fast pacing and immersion
459
-
460
- Provide detailed 40-episode plotline centered on the input story setting. Specify key events for each episode."""
461
- }
462
-
463
- return lang_prompts.get(language, lang_prompts["Korean"])
464
-
465
- def create_episode_prompt(self, episode_num: int, plot_outline: str,
466
- previous_content: str, genre: str, language: str) -> str:
467
- """Create prompt for individual episode - UPDATED FOR LONGER CONTENT"""
468
- genre_info = GENRE_ELEMENTS.get(genre, {})
469
- hooks = EPISODE_HOOKS.get(genre, ["다음 순간, 충격적인 일이..."])
470
-
471
- lang_prompts = {
472
- "Korean": f"""웹소설 {episode_num}화를 작성하세요.
473
-
474
- **장르:** {genre}
475
- **분량:** 400-600단어 (엄격히 준수 - 충실한 내용으로)
476
-
477
- **전체 플롯에서 {episode_num}화 내용:**
478
- {self._extract_episode_plan(plot_outline, episode_num)}
479
-
480
- ⚠️ **중요**: 위의 플롯 내용을 반드시 충실히 따라 작성하세요. 플롯에서 벗어나지 마세요.
481
-
482
- **이전 내용 요약:**
483
- {previous_content[-1500:] if previous_content else "첫 화입니다"}
484
-
485
- **작성 형식:**
486
- 반드시 다음 형식으로 시작하세요:
487
- {episode_num}화. [이번 화의 핵심을 담은 매력적인 소제목]
488
-
489
- (한 줄 띄우고 본문 시작)
490
-
491
- **작성 지침:**
492
- 1. **구성**: 3-4개의 주요 장면으로 구성
493
- - 도입부: 이전 화 연결 및 현재 상황
494
- - 전개부: 2-3개의 핵심 사건/대화
495
- - 클라이맥스: 긴장감 최고조
496
- - 후크: 다음 화 예고
497
-
498
- 2. **필수 요소:**
499
- - 플롯에 제시된 내용을 충실히 구현
500
- - 생생한 대화와 행동 묘사
501
- - 캐릭터 감정과 내면 갈등
502
- - 장면 전환과 템포 조절
503
- - 독자 몰입을 위한 감각적 묘사
504
-
505
- 3. **장르별 특색:**
506
- - {genre_info.get('episode_structure', '빠른 전개')}
507
- - 핵심 요소 1개 이상 포함
508
-
509
- 4. **분량 배분:**
510
- - 도입 (50-80단어)
511
- - 주요 전개 (250-350단어)
512
- - 클라이맥스와 후크 (100-150단어)
513
-
514
- **참고 후크 예시:**
515
- {random.choice(hooks)}
516
-
517
- 플롯에 충실하면서도 몰입감 있게 작성하세요. 반드시 400-600단어로 작성하세요.""",
518
-
519
- "English": f"""Write episode {episode_num} of the web novel.
520
-
521
- **Genre:** {genre}
522
- **Length:** 400-600 words (strict - with substantial content)
523
-
524
- **Episode {episode_num} from plot:**
525
- {self._extract_episode_plan(plot_outline, episode_num)}
526
-
527
- ⚠️ **IMPORTANT**: You MUST faithfully follow the plot content above. Do not deviate from the plot.
528
-
529
- **Previous content:**
530
- {previous_content[-1500:] if previous_content else "First episode"}
531
-
532
- **Format:**
533
- Must start with:
534
- Episode {episode_num}. [Attractive subtitle that captures the essence of this episode]
535
-
536
- (blank line then start main text)
537
-
538
- **Guidelines:**
539
- 1. **Structure**: 3-4 major scenes
540
- - Opening: Connect from previous, current situation
541
- - Development: 2-3 key events/dialogues
542
- - Climax: Peak tension
543
- - Hook: Next episode teaser
544
-
545
- 2. **Essential elements:**
546
- - Faithfully implement the plot content
547
- - Vivid dialogue and action
548
- - Character emotions and conflicts
549
- - Scene transitions and pacing
550
- - Sensory details for immersion
551
-
552
- 3. **Genre specifics:**
553
- - {genre_info.get('episode_structure', 'Fast pacing')}
554
- - Include at least 1 core element
555
-
556
- 4. **Word distribution:**
557
- - Opening (50-80 words)
558
- - Main development (250-350 words)
559
- - Climax and hook (100-150 words)
560
-
561
- **Hook example:**
562
- {random.choice(hooks)}
563
-
564
- Write faithfully to the plot while being immersive. Must be 400-600 words."""
565
- }
566
-
567
- return lang_prompts.get(language, lang_prompts["Korean"])
568
-
569
- def create_episode_critique_prompt(self, episode_num: int, content: str,
570
- genre: str, language: str) -> str:
571
- """Create critique prompt for episode"""
572
- lang_prompts = {
573
- "Korean": f"""{genre} 웹소설 {episode_num}화를 평가하세요.
574
-
575
- **작성된 내용:**
576
- {content}
577
-
578
- **평가 기준:**
579
- 1. **후크 효과성 (25점)**: 다음 화를 읽고 싶게 만드는가?
580
- 2. **페이싱 (25점)**: 전개 속도가 적절한가?
581
- 3. **장르 적합성 (25점)**: {genre} 장르 관습을 잘 따르는가?
582
- 4. **독자 몰입도 (25점)**: 감정적으로 빠져들게 하는가?
583
-
584
- **점수: /100점**
585
-
586
- 구체적인 개선점을 제시하세요.""",
587
-
588
- "English": f"""Evaluate {genre} web novel episode {episode_num}.
589
-
590
- **Written content:**
591
- {content}
592
-
593
- **Evaluation criteria:**
594
- 1. **Hook effectiveness (25pts)**: Makes readers want next episode?
595
- 2. **Pacing (25pts)**: Appropriate development speed?
596
- 3. **Genre fit (25pts)**: Follows {genre} conventions?
597
- 4. **Reader engagement (25pts)**: Emotionally immersive?
598
-
599
- **Score: /100 points**
600
-
601
- Provide specific improvements."""
602
- }
603
-
604
- return lang_prompts.get(language, lang_prompts["Korean"])
605
-
606
- def _extract_episode_plan(self, plot_outline: str, episode_num: int) -> str:
607
- """Extract specific episode plan from outline"""
608
- lines = plot_outline.split('\n')
609
- episode_section = []
610
- capturing = False
611
-
612
- patterns = [
613
- f"{episode_num}화:", f"Episode {episode_num}:",
614
- f"제{episode_num}화:", f"EP{episode_num}:",
615
- f"{episode_num}.", f"[{episode_num}]"
616
- ]
617
-
618
- # Also check for next episode patterns
619
- next_patterns = [
620
- f"{episode_num+1}화:", f"Episode {episode_num+1}:",
621
- f"제{episode_num+1}화:", f"EP{episode_num+1}:",
622
- f"{episode_num+1}.", f"[{episode_num+1}]"
623
- ]
624
-
625
- for line in lines:
626
- # Start capturing when we find the episode number
627
- if any(pattern in line for pattern in patterns):
628
- capturing = True
629
- episode_section.append(line)
630
- # Stop capturing when we find the next episode number
631
- elif capturing and any(pattern in line for pattern in next_patterns):
632
- break
633
- elif capturing:
634
- episode_section.append(line)
635
-
636
- # If we found episode content, return it
637
- if episode_section:
638
- return '\n'.join(episode_section)
639
-
640
- # If no specific episode found, provide more context
641
- logger.warning(f"Could not find specific plan for episode {episode_num}")
642
- return f"""에피소드 {episode_num}에 대한 구체적인 플롯을 찾을 수 없습니다.
643
- 전체 플롯을 참고하여 {episode_num}화를 작성하되, 반드시 사용자가 제공한 원본 스토리 설정을 따르세요.
644
-
645
- 참고: 전체 플롯 일부
646
- {plot_outline[:1000]}..."""
647
-
648
- # --- LLM call functions ---
649
- def call_llm_sync(self, messages: List[Dict[str, str]], role: str, language: str) -> str:
650
- full_content = ""
651
- for chunk in self.call_llm_streaming(messages, role, language):
652
- full_content += chunk
653
- if full_content.startswith("❌"):
654
- raise Exception(f"LLM Call Failed: {full_content}")
655
- return full_content
656
-
657
- def call_llm_streaming(self, messages: List[Dict[str, str]], role: str,
658
- language: str) -> Generator[str, None, None]:
659
- try:
660
- system_prompts = self.get_system_prompts(language)
661
- full_messages = [{"role": "system", "content": system_prompts.get(role, "")}, *messages]
662
-
663
- # Increased max_tokens for longer episodes
664
- max_tokens = 5000 if role == "writer" else 10000
665
-
666
- payload = {
667
- "model": self.model_id,
668
- "messages": full_messages,
669
- "max_tokens": max_tokens,
670
- "temperature": 0.6,
671
- "top_p": 1,
672
- "top_k": 40,
673
- "presence_penalty": 0,
674
- "frequency_penalty": 0,
675
- "stream": True
676
- }
677
-
678
- response = requests.post(
679
- self.api_url,
680
- headers=self.create_headers(),
681
- json=payload,
682
- stream=True,
683
- timeout=180
684
- )
685
-
686
- if response.status_code != 200:
687
- yield f"❌ API Error (Status Code: {response.status_code})"
688
- return
689
-
690
- buffer = ""
691
- for line in response.iter_lines():
692
- if not line:
693
- continue
694
-
695
- try:
696
- line_str = line.decode('utf-8').strip()
697
- if not line_str.startswith("data: "):
698
- continue
699
-
700
- data_str = line_str[6:]
701
- if data_str == "[DONE]":
702
- break
703
-
704
- data = json.loads(data_str)
705
- choices = data.get("choices", [])
706
- if choices and choices[0].get("delta", {}).get("content"):
707
- content = choices[0]["delta"]["content"]
708
- buffer += content
709
-
710
- if len(buffer) >= 50 or '\n' in buffer:
711
- yield buffer
712
- buffer = ""
713
- time.sleep(0.01)
714
-
715
- except Exception as e:
716
- logger.error(f"Chunk processing error: {str(e)}")
717
- continue
718
-
719
- if buffer:
720
- yield buffer
721
-
722
- except Exception as e:
723
- logger.error(f"Streaming error: {type(e).__name__}: {str(e)}")
724
- yield f"❌ Error occurred: {str(e)}"
725
-
726
- def get_system_prompts(self, language: str) -> Dict[str, str]:
727
- """System prompts for web novel roles - UPDATED FOR LONGER EPISODES"""
728
- base_prompts = {
729
- "Korean": {
730
- "planner": """당신은 한국 웹소설 시장을 완벽히 이해하는 기획자입니다.
731
- 독자를 중독시키는 플롯과 전개를 설계합니다.
732
- 장르별 관습과 독자 기대를 정확히 파악합니다.
733
- 40화 완결 구조로 완벽한 기승전결을 만듭니다.
734
- 각 화마다 충실한 내용과 전개를 계획합니다.
735
-
736
- ⚠️ 가장 중요한 원칙: 사용자가 제공한 스토리 설정을 절대적으로 우선시하고, 이를 중심으로 모든 플롯을 구성합니다. 장르 관습보다 사용자의 구체적 설정이 항상 우선입니다.""",
737
-
738
- "writer": """당신은 독자를 사로잡는 웹소설 작가입니다.
739
- 풍부하고 몰입감 있는 문체를 구사합니다.
740
- 각 화를 400-600단어로 충실하게 작성합니다.
741
- 여러 장면과 전환을 통해 이야기를 전개합니다.
742
- 대화, 행동, 내면 묘사를 균형있게 배치합니다.
743
- 매 화 끝에 강력한 후크로 다음 화를 기다리게 만듭니다.
744
-
745
- ⚠️ 가장 중요한 원칙: 제공된 플롯 아웃라인을 정확히 따르고, 절대 임의로 내용을 변경하거나 추가하지 않습니다. 플롯에 명시된 내용만을 충실히 구현합니다.""",
746
-
747
- "critic": """당신은 웹소설 독자의 마음을 읽는 평론가입니다.
748
- 재미와 몰입감을 최우선으로 평가합니다.
749
- 장르적 쾌감과 독자 만족도를 분석합니다.
750
- 구체적이고 실용적인 개선안을 제시합니다.
751
- 플롯 충실도와 일관성을 중요하게 평가합니다."""
752
- },
753
- "English": {
754
- "planner": """You perfectly understand the Korean web novel market.
755
- Design addictive plots and developments.
756
- Accurately grasp genre conventions and reader expectations.
757
- Create perfect story structure in 40 episodes.
758
- Plan substantial content and development for each episode.
759
-
760
- ⚠️ Most important principle: Absolutely prioritize the user's story setting and build all plots around it. User's specific settings always take precedence over genre conventions.""",
761
-
762
- "writer": """You are a web novelist who captivates readers.
763
- Use rich and immersive writing style.
764
- Write each episode with 400-600 words faithfully.
765
- Develop story through multiple scenes and transitions.
766
- Balance dialogue, action, and inner descriptions.
767
- End each episode with powerful hook for next.
768
-
769
- ⚠️ Most important principle: Follow the provided plot outline exactly and never arbitrarily change or add content. Faithfully implement only what is specified in the plot.""",
770
-
771
- "critic": """You read web novel readers' minds.
772
- Prioritize fun and immersion in evaluation.
773
- Analyze genre satisfaction and reader enjoyment.
774
- Provide specific, practical improvements.
775
- Evaluate plot fidelity and consistency as important factors."""
776
- }
777
- }
778
-
779
- return base_prompts.get(language, base_prompts["Korean"])
780
-
781
- # --- Main process ---
782
- def process_webnovel_stream(self, query: str, genre: str, language: str,
783
- session_id: Optional[str] = None) -> Generator[Tuple[str, str, str, str], None, None]:
784
- """Web novel generation process"""
785
- try:
786
- resume_from_episode = 0
787
- plot_outline = ""
788
-
789
- if session_id:
790
- self.current_session_id = session_id
791
- # Resume logic here
792
- else:
793
- self.current_session_id = WebNovelDatabase.create_session(query, genre, language)
794
- self.tracker.set_genre(genre)
795
- logger.info(f"Created new session: {self.current_session_id}")
796
- # Store the original query for reference
797
- self.original_query = query
798
-
799
- # Generate plot outline first
800
- if resume_from_episode == 0:
801
- yield "🎬 웹소설 플롯 구성 중...", "", f"장르: {genre}", self.current_session_id
802
-
803
- plot_prompt = self.create_planning_prompt(query, genre, language)
804
- plot_outline = self.call_llm_sync(
805
- [{"role": "user", "content": plot_prompt}],
806
- "planner", language
807
- )
808
-
809
- # Store plot outline for debugging
810
- self.plot_outline = plot_outline
811
-
812
- yield "✅ 플롯 구성 완료!", "", f"40화 구성 완료", self.current_session_id
813
-
814
- # Generate episodes
815
- accumulated_content = ""
816
- for episode_num in range(resume_from_episode + 1, TARGET_EPISODES + 1):
817
- # Write episode
818
- yield f"✍️ {episode_num}화 집필 중...", accumulated_content, f"진행률: {episode_num}/{TARGET_EPISODES}화", self.current_session_id
819
-
820
- # Create enhanced episode prompt with original query reminder
821
- episode_prompt = self.create_episode_prompt(
822
- episode_num, plot_outline, accumulated_content, genre, language
823
- )
824
-
825
- # Add reminder about original query before calling LLM
826
- enhanced_prompt = f"""⚠️ 필수 확인사항:
827
- 원본 스토리 설정: {query}
828
-
829
- 이 설정을 반드시 반영하여 작성하세요.
830
-
831
- {episode_prompt}"""
832
-
833
- episode_content = self.call_llm_sync(
834
- [{"role": "user", "content": enhanced_prompt}],
835
- "writer", language
836
- )
837
-
838
- # Extract episode title and content
839
- lines = episode_content.strip().split('\n')
840
- episode_title = ""
841
- actual_content = episode_content
842
-
843
- # Check if first line contains episode number and title
844
- if lines and (f"{episode_num}화." in lines[0] or f"Episode {episode_num}." in lines[0]):
845
- episode_title = lines[0]
846
- # Join the rest as content (excluding the title line and empty line after it)
847
- actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:])
848
- else:
849
- # If no title format found, generate a default title
850
- episode_title = f"{episode_num}화. 제{episode_num}화"
851
-
852
- # Extract hook (last sentence)
853
- sentences = actual_content.split('.')
854
- hook = sentences[-2] + '.' if len(sentences) > 1 else sentences[-1]
855
-
856
- # Save episode with full content including title
857
- full_episode_content = f"{episode_title}\n\n{actual_content}"
858
- WebNovelDatabase.save_episode(
859
- self.current_session_id, episode_num,
860
- full_episode_content, hook
861
- )
862
-
863
- # Add to accumulated content with title
864
- accumulated_content += f"\n\n### {episode_title}\n{actual_content}"
865
-
866
- # Quick critique every 5 episodes
867
- if episode_num % 5 == 0:
868
- critique_prompt = self.create_episode_critique_prompt(
869
- episode_num, episode_content, genre, language
870
- )
871
- critique = self.call_llm_sync(
872
- [{"role": "user", "content": critique_prompt}],
873
- "critic", language
874
- )
875
-
876
- yield f"✅ {episode_num}화 완료!", accumulated_content, f"진행률: {episode_num}/{TARGET_EPISODES}화", self.current_session_id
877
-
878
- # Complete
879
- total_words = len(accumulated_content.split())
880
- yield f"🎉 웹소설 완성!", accumulated_content, f"총 {total_words:,}단어, {TARGET_EPISODES}화 완결", self.current_session_id
881
-
882
- except Exception as e:
883
- logger.error(f"Web novel generation error: {e}", exc_info=True)
884
- yield f"❌ 오류 발생: {e}", accumulated_content if 'accumulated_content' in locals() else "", "오류", self.current_session_id
885
-
886
- # --- Export functions ---
887
- def export_to_txt(episodes: List[Dict], genre: str, title: str = "") -> str:
888
- """Export web novel to TXT format"""
889
- content = f"{'=' * 50}\n"
890
- content += f"{title if title else genre + ' 웹소설'}\n"
891
- content += f"{'=' * 50}\n\n"
892
- content += f"총 {len(episodes)}화 완결\n"
893
- content += f"총 단어 수: {sum(ep.get('word_count', 0) for ep in episodes):,}\n"
894
- content += f"{'=' * 50}\n\n"
895
-
896
- for ep in episodes:
897
- ep_num = ep.get('episode_number', 0)
898
- ep_content = ep.get('content', '')
899
-
900
- # Content already includes title, so just add it
901
- content += f"\n{ep_content}\n"
902
- content += f"\n{'=' * 50}\n"
903
-
904
- return content
905
-
906
- def export_to_docx(episodes: List[Dict], genre: str, title: str = "") -> bytes:
907
- """Export web novel to DOCX format - matches screen display exactly"""
908
- if not DOCX_AVAILABLE:
909
- raise Exception("python-docx is not installed")
910
-
911
- doc = Document()
912
-
913
- # Title
914
- doc.add_heading(title if title else f"{genre} 웹소설", 0)
915
-
916
- # Stats
917
- doc.add_paragraph(f"총 {len(episodes)}화 완결")
918
- doc.add_paragraph(f"총 단어 수: {sum(ep.get('word_count', 0) for ep in episodes):,}")
919
- doc.add_page_break()
920
-
921
- # Episodes
922
- for idx, ep in enumerate(episodes):
923
- ep_num = ep.get('episode_number', 0)
924
- ep_content = ep.get('content', '')
925
-
926
- # Split content into lines
927
- lines = ep_content.strip().split('\n')
928
-
929
- # First line should be the title (e.g., "1화. 제목")
930
- if lines:
931
- # Add episode title as heading
932
- doc.add_heading(lines[0], 1)
933
-
934
- # Add the rest of the content
935
- content_lines = lines[1:] if len(lines) > 1 else []
936
-
937
- # Skip empty lines at the beginning
938
- while content_lines and not content_lines[0].strip():
939
- content_lines.pop(0)
940
-
941
- # Add content paragraphs
942
- for line in content_lines:
943
- if line.strip(): # Only add non-empty lines
944
- doc.add_paragraph(line.strip())
945
- elif len(doc.paragraphs) > 0: # Add spacing between paragraphs
946
- doc.add_paragraph()
947
-
948
- # Add page break except for the last episode
949
- if idx < len(episodes) - 1:
950
- doc.add_page_break()
951
-
952
- # Save to bytes
953
- bytes_io = io.BytesIO()
954
- doc.save(bytes_io)
955
- bytes_io.seek(0)
956
- return bytes_io.getvalue()
957
-
958
-
959
- def generate_random_webnovel_theme(genre: str, language: str) -> str:
960
- """Generate random web novel theme using novel_themes.json and LLM"""
961
  try:
962
- # Load novel_themes.json with better error handling
963
- json_path = Path("novel_themes.json")
964
- if not json_path.exists():
965
- logger.warning("novel_themes.json not found, using fallback")
966
- return generate_fallback_theme(genre, language)
967
-
968
- try:
969
- with open(json_path, 'r', encoding='utf-8') as f:
970
- content = f.read()
971
-
972
- # Remove comments from JSON (/* */ style)
973
- content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL)
974
- # Remove single line comments (// style)
975
- content = re.sub(r'//.*$', '', content, flags=re.MULTILINE)
976
- # Remove trailing commas before } or ]
977
- content = re.sub(r',\s*([}\]])', r'\1', content)
978
-
979
- # Handle all variations of placeholder patterns
980
- content = re.sub(r'\.\.\.\s*\(기존.*?\)\s*\.\.\.', '[]', content)
981
- content = re.sub(r'\.\.\.\(기존.*?\)\.\.\.', '[]', content)
982
- content = re.sub(r'\{ \.\.\. \(기존 그대로\) \}', '{}', content)
983
- content = re.sub(r'\{\s*\.\.\.\s*\(기존 그대로\)\s*\}', '{}', content)
984
- content = re.sub(r'\{ \.\.\. \}', '{}', content)
985
- content = re.sub(r'\{\s*\.\.\.\s*\}', '{}', content)
986
- content = re.sub(r'\[ \.\.\. \]', '[]', content)
987
- content = re.sub(r'\[\s*\.\.\.\s*\]', '[]', content)
988
-
989
- # Handle ellipsis in strings
990
- content = re.sub(r'"[^"]*\.\.\.[^"]*"', '""', content)
991
-
992
- # Debug: save cleaned JSON for inspection
993
- with open('novel_themes_cleaned.json', 'w', encoding='utf-8') as debug_file:
994
- debug_file.write(content)
995
-
996
- # Parse JSON
997
- themes_data = json.loads(content)
998
- logger.info("Successfully parsed novel_themes.json")
999
-
1000
- except json.JSONDecodeError as e:
1001
- logger.error(f"JSON parsing error at line {e.lineno}, column {e.colno}: {e.msg}")
1002
- if hasattr(e, 'pos'):
1003
- error_context = content[max(0, e.pos-100):e.pos+100]
1004
- logger.error(f"Context around error: ...{error_context}...")
1005
- # Save problematic content for debugging
1006
- with open('novel_themes_error.json', 'w', encoding='utf-8') as error_file:
1007
- error_file.write(content)
1008
- return generate_theme_with_llm_only(genre, language)
1009
 
1010
- # Map genres to theme data - updated mapping
1011
- genre_mapping = {
1012
- "로맨스": ["romance_fantasy_villainess", "villainess_wants_to_be_lazy", "office_romance_rivals", "chaebol_family_intrigue"],
1013
- "로판": ["romance_fantasy_villainess", "BL_novel_transmigration", "regression_childcare", "saeguk_court_intrigue"],
1014
- "판타지": ["system_constellation_hunter", "tower_ascension_challenger", "necromancer_solo_leveling", "ai_dungeon_masters"],
1015
- "현판": ["system_constellation_hunter", "chaebol_family_intrigue", "post_apocalypse_survival", "esports_king_prodigy", "vr_streamer_ranker"],
1016
- "무협": ["regression_revenge_pro", "necromancer_solo_leveling", "exorcist_k_cult"],
1017
- "미스터리": ["post_apocalypse_survival", "tower_ascension_challenger", "survival_reality_show"],
1018
- "라이트노벨": ["BL_novel_transmigration", "villainess_wants_to_be_lazy", "vr_streamer_ranker", "healing_cafe_fantasy", "idol_regression_superstar"]
1019
- }
1020
 
1021
- # Get relevant core genres for selected genre
1022
- relevant_genres = genre_mapping.get(genre, ["regression_revenge_pro"])
 
 
1023
 
1024
- # Filter out genres that might not exist in the JSON
1025
- available_genres = []
1026
- core_genres = themes_data.get("core_genres", {})
1027
 
1028
- # Debug log available genres
1029
- logger.debug(f"Available core genres: {list(core_genres.keys())}")
1030
-
1031
- for genre_key in relevant_genres:
1032
- if genre_key in core_genres:
1033
- available_genres.append(genre_key)
1034
-
1035
- if not available_genres:
1036
- logger.warning(f"No matching genres found for {genre}, available: {list(core_genres.keys())[:5]}...")
1037
- # Try to use any available genre
1038
- available_genres = list(core_genres.keys())[:3]
1039
-
1040
- selected_genre_key = random.choice(available_genres)
1041
- logger.debug(f"Selected genre key: {selected_genre_key}")
1042
-
1043
- # Get genre data safely
1044
- core_genre = core_genres.get(selected_genre_key, {})
1045
- compatible_elements = core_genre.get("compatible_elements", {})
1046
-
1047
- # Select random elements with fallbacks
1048
- character_keys = compatible_elements.get("characters", [])
1049
- if not character_keys:
1050
- # Get any available characters
1051
- all_characters = list(themes_data.get("characters", {}).keys())
1052
- character_keys = all_characters[:4] if all_characters else ["betrayed_protagonist"]
1053
 
1054
- selected_character_key = random.choice(character_keys) if character_keys else "betrayed_protagonist"
1055
-
1056
- # Get character data safely
1057
- characters_data = themes_data.get("characters", {})
1058
- character_data = characters_data.get(selected_character_key, {})
1059
- character_variations = character_data.get("variations", [])
1060
-
1061
- # Filter out empty or placeholder variations
1062
- valid_variations = [v for v in character_variations if v and not v.startswith("...") and len(v) > 10]
1063
- character_desc = random.choice(valid_variations) if valid_variations else "주인공은 특별한 운명을 타고났다."
1064
-
1065
- character_traits = character_data.get("traits", ["결단력", "성장형", "매력적"])
1066
-
1067
- # Get settings safely
1068
- settings = compatible_elements.get("settings", [])
1069
- if not settings:
1070
- # Try to get from general settings
1071
- all_settings_categories = themes_data.get("settings", {})
1072
- for category_name, category_settings in all_settings_categories.items():
1073
- if isinstance(category_settings, list):
1074
- valid_settings = [s for s in category_settings if s and not s.startswith("...") and len(s) > 5]
1075
- settings.extend(valid_settings)
1076
-
1077
- selected_setting = random.choice(settings) if settings else "현대 도시"
1078
-
1079
- # Get mechanics safely
1080
- mechanics_data = themes_data.get("core_mechanics", {})
1081
- mechanics_keys = [k for k in mechanics_data.keys() if k]
1082
- selected_mechanic = random.choice(mechanics_keys) if mechanics_keys else "regression_loop_mastery"
1083
-
1084
- mechanic_info = mechanics_data.get(selected_mechanic, {})
1085
- plot_points = mechanic_info.get("plot_points", [])
1086
- reader_questions = mechanic_info.get("reader_questions", [])
1087
-
1088
- # Filter valid plot points and questions
1089
- valid_plot_points = [p for p in plot_points if p and not p.startswith("...") and len(p) > 10]
1090
- valid_questions = [q for q in reader_questions if q and not q.startswith("...") and len(q) > 10]
1091
-
1092
- # Get hooks safely
1093
- hooks_data = themes_data.get("episode_hooks", {})
1094
- hook_types = list(hooks_data.keys())
1095
- selected_hook_type = random.choice(hook_types) if hook_types else "introduction"
1096
-
1097
- hooks = hooks_data.get(selected_hook_type, [])
1098
- valid_hooks = [h for h in hooks if h and not h.startswith("...") and len(h) > 10]
1099
- selected_hook = random.choice(valid_hooks) if valid_hooks else "운명적인 만남이 시작되었다."
1100
-
1101
- # Get items/artifacts for certain genres
1102
- selected_item = ""
1103
- if genre in ["판타지", "현판", "무협"]:
1104
- items_data = themes_data.get("key_items_and_artifacts", {})
1105
- item_categories = list(items_data.keys())
1106
- if item_categories:
1107
- selected_category = random.choice(item_categories)
1108
- items = items_data.get(selected_category, [])
1109
- valid_items = [i for i in items if i and not i.startswith("...") and len(i) > 10]
1110
- selected_item = random.choice(valid_items) if valid_items else ""
1111
-
1112
- # Get plot twists safely
1113
- twists_data = themes_data.get("plot_twists_and_cliches", {})
1114
- twist_categories = list(twists_data.keys())
1115
- selected_twist = ""
1116
- if twist_categories:
1117
- selected_twist_cat = random.choice(twist_categories)
1118
- twists = twists_data.get(selected_twist_cat, [])
1119
- valid_twists = [t for t in twists if t and not t.startswith("...") and len(t) > 10]
1120
- selected_twist = random.choice(valid_twists) if valid_twists else ""
1121
-
1122
- # Check for fusion genres
1123
- fusion_genres = themes_data.get("fusion_genres", {})
1124
- fusion_options = [v for v in fusion_genres.values() if v and not v.startswith("...") and len(v) > 10]
1125
- selected_fusion = random.choice(fusion_options) if fusion_options and random.random() > 0.7 else ""
1126
-
1127
- # Log selected elements for debugging
1128
- logger.debug(f"Selected elements - Genre: {selected_genre_key}, Character: {selected_character_key}, Mechanic: {selected_mechanic}")
1129
-
1130
- # Now use LLM to create a coherent theme from these elements
1131
- system = WebNovelSystem()
1132
-
1133
- # Create prompt for LLM
1134
- if language == "Korean":
1135
- prompt = f"""다음 요소들을 활용하여 {genre} 장르의 매력적인 웹소설을 기획하세요:
1136
-
1137
- 【선택된 요소들】
1138
- - 핵심 장르: {selected_genre_key.replace('_', ' ')}
1139
- - 캐릭터: {character_desc}
1140
- - 캐릭터 특성: {', '.join(character_traits[:3])}
1141
- - 배경: {selected_setting}
1142
- - 핵심 메커니즘: {selected_mechanic.replace('_', ' ')}
1143
- {"- 아이템: " + selected_item if selected_item else ""}
1144
- {"- 반전 요소: " + selected_twist if selected_twist else ""}
1145
- {"- 퓨전 설정: " + selected_fusion if selected_fusion else ""}
1146
-
1147
- 【참고 훅】
1148
- {selected_hook}
1149
-
1150
- 【독자를 사로잡을 질문들】
1151
- {chr(10).join(valid_questions[:2]) if valid_questions else "독자의 호기심을 자극하는 질문들"}
1152
-
1153
- 다음 형식으로 정확히 작성하세요:
1154
-
1155
- 📖 **제목:**
1156
- [매력적이고 기억에 남는 제목]
1157
-
1158
- 🌍 **설정:**
1159
- [세계관과 배경 설정을 3-4줄로 설명]
1160
-
1161
- 👥 **주요 캐릭터:**
1162
- - 주인공: [이름] - [간단한 설명]
1163
- - 주요인물1: [이름] - [간단한 설명]
1164
- - 주요인물2: [이름] - [간단한 설명]
1165
-
1166
- 📝 **작품소개:**
1167
- [독자의 흥미를 끄는 3-4줄의 작품 소개. 주인공의 상황, 목표, 핵심 갈등을 포함]"""
1168
-
1169
- else: # English
1170
- prompt = f"""Create an engaging web novel for {genre} genre using these elements:
1171
-
1172
- 【Selected Elements】
1173
- - Core genre: {selected_genre_key.replace('_', ' ')}
1174
- - Character: {character_desc}
1175
- - Character traits: {', '.join(character_traits[:3])}
1176
- - Setting: {selected_setting}
1177
- - Core mechanism: {selected_mechanic.replace('_', ' ')}
1178
- {"- Item: " + selected_item if selected_item else ""}
1179
- {"- Twist: " + selected_twist if selected_twist else ""}
1180
- {"- Fusion: " + selected_fusion if selected_fusion else ""}
1181
-
1182
- 【Reference Hook】
1183
- {selected_hook}
1184
-
1185
- 【Questions to captivate readers】
1186
- {chr(10).join(valid_questions[:2]) if valid_questions else "Questions that spark reader curiosity"}
1187
-
1188
- Format exactly as follows:
1189
-
1190
- 📖 **Title:**
1191
- [Attractive and memorable title]
1192
-
1193
- 🌍 **Setting:**
1194
- [World and background setting in 3-4 lines]
1195
-
1196
- 👥 **Main Characters:**
1197
- - Protagonist: [Name] - [Brief description]
1198
- - Key Character 1: [Name] - [Brief description]
1199
- - Key Character 2: [Name] - [Brief description]
1200
-
1201
- 📝 **Synopsis:**
1202
- [3-4 lines that hook readers. Include protagonist's situation, goal, and core conflict]"""
1203
-
1204
- # Call LLM to generate theme
1205
- messages = [{"role": "user", "content": prompt}]
1206
- generated_theme = system.call_llm_sync(messages, "writer", language)
1207
-
1208
- logger.info("Successfully generated theme using JSON elements")
1209
- return generated_theme
1210
-
1211
  except Exception as e:
1212
- logger.error(f"Error generating theme from JSON: {e}", exc_info=True)
1213
- return generate_fallback_theme(genre, language)
1214
-
1215
- def generate_fallback_theme(genre: str, language: str) -> str:
1216
- """Fallback theme generator when JSON is not available"""
1217
- templates = {
1218
- "로맨스": {
1219
- "themes": [
1220
- """📖 **제목:** 계약결혼 365일, 기억을 잃은 재벌 남편
1221
-
1222
- 🌍 **설정:**
1223
- 현대 서울, 대기업 본사와 강남의 펜트하우스가 주 무대. 3개월 계약결혼 만료 직전, 남편이 교통사고로 기억을 잃고 아내를 첫사랑으로 착각하는 상황.
1224
-
1225
- 👥 **주요 캐릭터:**
1226
- - 주인공: 서연우(28) - 평범한 회사원, 부모님 병원비를 위해 계약결혼
1227
- - 남주: 강준혁(32) - 냉혈 재벌 3세, 기억상실 후 순정남으로 변신
1228
- - 조연: 한소영(30) - 준혁의 전 약혼녀, 복수를 계획 중
1229
-
1230
- 📝 **작품소개:**
1231
- "당신이 내 첫사랑이야." 이혼 서류에 도장을 찍으려던 순간, 교통사고를 당한 냉혈 재벌 남편이 나를 운명의 상대로 착각한다. 3개월간 연기했던 가짜 부부에서 진짜 사랑이 시작되는데...""",
1232
-
1233
- """📖 **제목:** 검사님, 이혼 소송은 제가 맡을게요
1234
-
1235
- 🌍 **설정:**
1236
- 서울중앙지법과 검찰청이 주 무대. 냉혈 검사와 이혼 전문 변호사가 법정에서 대립하며 티격태격하는 법정 로맨스.
1237
-
1238
- 👥 **주요 캐릭터:**
1239
- - 주인공: 오지원(30) - 승률 100% 이혼 전문 변호사
1240
- - 남주: 민시준(33) - 원칙주의 엘리트 검사
1241
- - 조연: 박세진(35) - 지원의 전 남편이자 시준의 선배 검사
1242
-
1243
- 📝 **작품소개:**
1244
- "변호사님, 법정에서만 만나기로 했잖아요." 하필 전 남편의 불륜 소송을 맡은 날, 상대 검사가 나타났다. 법정에선 적, 밖에선 연인. 우리의 관계는 대체 뭘까?"""
1245
- ]
1246
- },
1247
- "로판": {
1248
- "themes": [
1249
- """📖 **제목:** 악녀는 이번 생에서 도망친다
1250
-
1251
- 🌍 **설정:**
1252
- 마법이 존재하는 제국, 1년 후 처형당할 운명의 악녀 공작 영애로 빙의. 북부 변방의 전쟁광 공작과의 계약결혼이 유일한 생존루트.
1253
-
1254
- 👥 **주요 캐릭터:**
1255
- - 주인공: 아델라이드(20) - 빙의한 악녀, 원작 지식 보유
1256
- - 남주: 카시우스(25) - 북부의 전쟁광 공작, 숨겨진 순정남
1257
- - 악역: 황태자 레온(23) - 여주에게 집착하는 얀데레
1258
-
1259
- 📝 **작품소개:**
1260
- 소설 속 악녀로 빙의했는데 이미 처형 선고를 받은 상태? 살려면 원작에 없던 북부 공작과 계약결혼해야 한다. "1년만 함께해주세요. 그 후엔 자유를 드리겠습니다." 하지만 계약 기간이 끝나도 그가 날 놓아주지 않는다.""",
1261
-
1262
- """📖 **제목:** 회귀한 황녀는 버려진 왕자를 택한다
1263
-
1264
- 🌍 **설정:**
1265
- 제국력 892년으로 회귀한 황녀. 전생에서 자신을 배신한 황태자 대신, 버려진 서자 왕자와 손을 잡고 제국을 뒤집으려 한다.
1266
-
1267
- 👥 **주요 캐릭터:**
1268
- - 주인공: 로젤린(22) - 회귀한 황녀, 미래를 아는 전략가
1269
- - 남주: 다미안(24) - 버려진 서자 왕자, 숨겨진 흑막
1270
- - 악역: 황태자 세바스찬(26) - 전생의 배신자
1271
-
1272
- 📝 **작품소개:**
1273
- 독살당해 회귀한 황녀, 이번엔 다르게 살겠다. 모두가 무시하는 서자 왕자의 손을 잡았다. "저와 함께 제국을 뒤집으시겠습니까?" 하지만 그는 내가 아는 것보다 훨씬 위험한 남자였다."""
1274
- ]
1275
- },
1276
- "판타지": {
1277
- "themes": [
1278
- """📖 **제목:** F급 헌터, SSS급 네크로맨서가 되다
1279
-
1280
- 🌍 **설정:**
1281
- 게이트와 던전이 출현한 지 10년 후의 한국. F급 헌터가 우연히 얻은 스킬로 죽은 보스 몬스터를 부활시켜 부리는 유일무이 네크로맨서가 된다.
1282
-
1283
- 👥 **주요 캐릭터:**
1284
- - 주인공: 김도현(24) - F급에서 SSS급 네크로맨서로 각성
1285
- - 조력자: 리치 왕(???) - 첫 번째 언데드, 전설의 대마법사
1286
- - 라이벌: 최강훈(26) - S급 길드 마스터, 주인공을 경계
1287
-
1288
- 📝 **작품소개:**
1289
- "F급 주제에 무슨 헛소리야?" 모두가 비웃었다. 하지만 첫 번째 보스를 쓰러뜨린 순간, 시스템 메시지가 떴다. [SSS급 히든 클래스: 네크로맨서 각성] 이제 죽은 보스들이 내 부하가 된다.""",
1290
-
1291
- """📖 **제목:** 탑을 역주행하는 회귀자
1292
-
1293
- 🌍 **설정:**
1294
- 100층 탑 정상에서 죽은 후 튜토리얼로 회귀. 하지만 이번엔 100층부터 거꾸로 내려가며 모든 층을 정복하는 역주행 시스템이 열렸다.
1295
-
1296
- 👥 **주요 캐릭터:**
1297
- - 주인공: 이성진(28) - 유일한 역주행 회귀자
1298
- - 조력자: 관리자(???) - 탑의 시스템 AI, 주인공에게 호의적
1299
- - 라이벌: 성하윤(25) - 이번 회차 최강 신인
1300
-
1301
- 📝 **작품소개:**
1302
- 100층에서 죽었다. 눈을 떠보니 튜토리얼이었다. [역주행 시스템이 개방되었습니다] "뭐? 100층부터 시작한다고?" 최강자의 기억을 가진 채 정상에서부터 내려가는 전무후무한 공략이 시작된다."""
1303
- ]
1304
- },
1305
- "현판": {
1306
- "themes": [
1307
- """📖 **제목:** 무능력자의 SSS급 아이템 제작
1308
-
1309
- 🌍 **설정:**
1310
- 게이트 출현 10년, 전 국민의 70%가 각성한 한국. 무능력자로 살던 주인공에게 갑자기 아이템 제작 시스템이 열린다.
1311
-
1312
- 👥 **주요 캐릭터:**
1313
- - 주인공: 박준서(25) - 무능력자에서 유일무이 아이템 제작사로
1314
- - 의뢰인: 강하늘(27) - S급 헌터, 첫 번째 고객
1315
- - 라이벌: 대기업 '아르테미스' - 아이템 독점 기업
1316
-
1317
- 📝 **작품소개:**
1318
- "각성 등급: 없음" 10년째 무능력자로 살았다. 그런데 오늘, 이상한 시스템 창이 떴다. [SSS급 생산직: 아이템 크래프터] 이제 내가 만든 아이템이 세계를 바꾼다.""",
1319
-
1320
- """📖 **제목:** 헌터 사관학교의 숨겨진 최강자
1321
-
1322
- 🌍 **설정:**
1323
- 한국 최고의 헌터 사관학교. 입학시험 꼴찌로 들어온 주인공이 사실은 능력을 숨기고 있는 특급 요원.
1324
-
1325
- 👥 **주요 캐릭터:**
1326
- - 주인공: 윤시우(20) - 꼴찌로 위장한 특급 헌터
1327
- - 히로인: 차유진(20) - 학년 수석, 재벌가 영애
1328
- - 교관: 한태성(35) - 전설의 헌터, 주인공의 정체를 의심
1329
-
1330
- 📝 **작품소개:**
1331
- "측정 불가? 그럼 F급이네." 일부러 힘을 숨기고 꼴찌로 입학했다. 하지만 S급 게이트가 학교에 열리면서 정체를 숨길 수 없게 됐다. "너... 대체 누구야?"라는 물음에 어떻게 답해야 할까."""
1332
- ]
1333
- },
1334
- "무협": {
1335
- "themes": [
1336
- """📖 **제목:** 천하제일문 폐급제자의 마교 비급
1337
-
1338
- 🌍 **설정:**
1339
- 정파 무림의 중원. 천하제일문의 폐급 막내제자가 우연히 마교 교주의 비급을 습득하고 정마를 아우르는 절대무공을 익힌다.
1340
-
1341
- 👥 **주요 캐릭터:**
1342
- - 주인공: 진천(18) - 폐급에서 절대고수로
1343
- - 스승: 혈마노조(???) - 비급에 깃든 마교 전설
1344
- - 라이벌: 남궁세가 소가주 - 정파 제일 천재
1345
-
1346
- 📝 **작품소개:**
1347
- "하찮은 것이 감히!" 모두가 무시하던 막내제자. 하지만 떨어진 절벽에서 발견한 것은 전설로만 전해지던 천마신공. "이제부터가 진짜 시작이다." 정파와 마교를 뒤흔들 폐급의 반란이 시작된다.""",
1348
-
1349
- """📖 **제목:** 화산파 장문인으로 회귀하다
1350
-
1351
- 🌍 **설정:**
1352
- 100년 전 화산파가 최고 문파이던 시절로 회귀. 미래를 아는 장문인이 되어 문파를 지키고 무림을 재편한다.
1353
-
1354
- 👥 **주요 캐릭터:**
1355
- - 주인공: 청운진인(45→25) - 회귀한 화산파 장문인
1356
- - 제자: 백무진(15) - 미래의 화산파 배신자
1357
- - 맹우: 마교 성녀 - 전생의 적, 이생의 동료
1358
-
1359
- 📝 **작품소개:**
1360
- 멸문 직전에 회귀했다. 이번엔 다르다. "앞으로 화산파는 정파의 규율을 벗어난다." 미래를 아는 장문인의 파격적인 결정. 마교와 손잡고 무림의 판도를 뒤집는다."""
1361
- ]
1362
- },
1363
- "미스터리": {
1364
- "themes": [
1365
- """📖 **제목:** 폐교에 갇힌 7명, 그리고 나
1366
-
1367
- 🌍 **설정:**
1368
- 폐쇄된 산골 학교, 동창회를 위해 모인 8명이 갇힌다. 하나씩 사라지는 동창들. 범인은 이 안에 있다.
1369
-
1370
- 👥 **주요 캐릭터:**
1371
- - 주인공: 서민준(28) - 프로파일러 출신 교사
1372
- - 용의자1: 김태희(28) - 실종된 친구의 전 연인
1373
- - 용의자2: 박진우(28) - 10년 전 사건의 목격자
1374
-
1375
- 📝 **작품소개:**
1376
- "10년 전 그날처럼..." 폐교에서 열린 동창회, 하지만 출구는 봉쇄됐다. 한 명씩 사라지는 친구들. 10년 전 묻어둔 비밀이 되살아난다. 살인자는 우리 중 한 명이다.""",
1377
-
1378
- """📖 **제목:** 타임루프 속 연쇄살인마를 찾아라
1379
-
1380
- 🌍 **설정:**
1381
- 같은 하루가 반복되는 타임루프. 매번 다른 방법으로 살인이 일어나지만 범인은 동일인. 루프를 깨려면 범인을 찾아야 한다.
1382
-
1383
- 👥 **주요 캐릭터:**
1384
- - 주인공: 강해인(30) - 타임루프에 갇힌 형사
1385
- - 희생자: 이수연(25) - 매번 죽는 카페 알바생
1386
- - 용의자들: 카페 단골 5명 - 각자의 비밀을 숨기고 있음
1387
-
1388
- 📝 **작품소개:**
1389
- "또 오늘이야..." 49번째 같은 아침. 오후 3시 33분, 카페에서 살인이 일어난다. 범인을 잡아야 내일이 온다. 하지만 범인은 매번 완벽한 알리바이를 만든다. 과연 50번째 오늘은 다를까?"""
1390
- ]
1391
- },
1392
- "라이트노벨": {
1393
- "themes": [
1394
- """📖 **제목:** 내 여자친구가 사실은 마왕이었다
1395
-
1396
- 🌍 **설정:**
1397
- 평범한 고등학교, 하지만 학생과 교사 중 일부는 이세계에서 온 존재들. 주인공만 모르는 학교의 비밀.
1398
-
1399
- 👥 **주요 캐릭터:**
1400
- - 주인공: 김태양(17) - 평범한 고등학생(?)
1401
- - 히로인: 루시퍼(17) - 마왕이자 여자친구
1402
- - 라이벌: 미카엘(17) - 천사이자 학생회장
1403
-
1404
- 📝 **작품소개:**
1405
- "선배, 사실 저... 마왕이에요!" 1년째 사귄 여자친구의 충격 고백. 근데 학생회장은 천사고, 담임은 드래곤이라고? 평범한 줄 알았던 우리 학교의 정체가 밝혀진다. "그래서... 우리 헤어져야 해?"라고 묻자 그녀가 울기 시작했다.""",
1406
-
1407
- """📖 **제목:** 게임 아이템이 현실에 떨어진다
1408
-
1409
- 🌍 **설정:**
1410
- 모바일 게임과 현실이 연동되기 시작한 세계. 게임에서 얻은 아이템이 현실에 나타나면서 벌어지는 학원 코미디.
1411
-
1412
- 👥 **주요 캐릭터:**
1413
- - 주인공: 박도윤(18) - 게임 폐인 고등학생
1414
- - 히로인: 최서연(18) - 전교 1등, 의외로 게임 고수
1415
- - 친구: 장민혁(18) - 현질 전사, 개그 담당
1416
-
1417
- 📝 **작품소개:**
1418
- "어? 이거 내 SSR 무기잖아?" 핸드폰 게임에서 뽑은 아이템이 책상 위에 나타났다. 문제는 학교에 몬스터도 나타나기 시작했다는 것. "야, 수능보다 레이드가 더 중요해진 것 같은데?"라며 웃는 친구들과 함께하는 좌충우돌 학원 판타지."""
1419
- ]
1420
- }
1421
- }
1422
-
1423
- genre_themes = templates.get(genre, templates["로맨스"])
1424
- selected = random.choice(genre_themes["themes"])
1425
-
1426
- return selected
1427
-
1428
- def generate_theme_with_llm_only(genre: str, language: str) -> str:
1429
- """Generate theme using only LLM when JSON is not available or has errors"""
1430
- system = WebNovelSystem()
1431
-
1432
- # Genre-specific prompts based on popular web novel trends
1433
- genre_prompts = {
1434
- "로맨스": {
1435
- "elements": ["계약결혼", "재벌", "이혼", "첫사랑", "운명적 만남", "오해와 화해"],
1436
- "hooks": ["기억상실", "정체 숨기기", "가짜 연인", "원나잇 후 재회"]
1437
- },
1438
- "로판": {
1439
- "elements": ["빙의", "회귀", "악녀", "황녀", "공작", "원작 파괴"],
1440
- "hooks": ["처형 직전", "파혼 선언", "독살 시도", "폐위 위기"]
1441
- },
1442
- "판타지": {
1443
- "elements": ["시스템", "각성", "던전", "회귀", "탑 등반", "SSS급"],
1444
- "hooks": ["F급에서 시작", "숨겨진 클래스", "유일무이 스킬", "죽음 후 각성"]
1445
- },
1446
- "현판": {
1447
- "elements": ["헌터", "게이트", "각성자", "길드", "아이템", "랭킹"],
1448
- "hooks": ["늦은 각성", "재능 재평가", "S급 게이트", "시스템 오류"]
1449
- },
1450
- "무협": {
1451
- "elements": ["회귀", "천재", "마교", "비급", "복수", "환생"],
1452
- "hooks": ["폐급에서 최강", "배신 후 각성", "숨겨진 혈통", "기연 획득"]
1453
- },
1454
- "미스터리": {
1455
- "elements": ["탐정", "연쇄살인", "타임루프", "초능력", "과거의 비밀"],
1456
- "hooks": ["밀실 살인", "예고 살인", "기억 조작", "시간 역행"]
1457
- },
1458
- "라이트노벨": {
1459
- "elements": ["학원", "이세계", "히로인", "게임", "일상", "판타지"],
1460
- "hooks": ["전학생 정체", "게임 현실화", "평행세계", "숨겨진 능력"]
1461
- }
1462
- }
1463
-
1464
- genre_info = genre_prompts.get(genre, genre_prompts["로맨스"])
1465
-
1466
- if language == "Korean":
1467
- prompt = f"""한국 웹소설 {genre} 장르의 중독성 있는 작품을 기획하세요.
1468
-
1469
- 다음 인기 요소들을 참고하세요:
1470
- - 핵심 요소: {', '.join(genre_info['elements'])}
1471
- - 인기 훅: {', '.join(genre_info['hooks'])}
1472
-
1473
- 다음 형식으로 정확히 작성하세요:
1474
-
1475
- 📖 **제목:**
1476
- [매력적이고 기억하기 쉬운 제목]
1477
-
1478
- 🌍 **설정:**
1479
- [세계관과 배경을 3-4줄로 설명. 시대, 장소, 핵심 설정 포함]
1480
-
1481
- 👥 **주요 캐릭터:**
1482
- - 주인공: [이름(나이)] - [직업/신분, 핵심 특징]
1483
- - 주요인물1: [이름(나이)] - [관계/역할, 특징]
1484
- - 주요인물2: [이름(나이)] - [관계/역할, 특징]
1485
-
1486
- 📝 **작품소개:**
1487
- [3-4줄로 작품의 핵심 갈등과 매력을 소개. 첫 문장은 강한 훅으로 시작하고, 주인공의 목표와 장애물을 명확히 제시]"""
1488
- else:
1489
- prompt = f"""Generate an addictive Korean web novel for {genre} genre.
1490
-
1491
- Reference these popular elements:
1492
- - Core elements: {', '.join(genre_info['elements'])}
1493
- - Popular hooks: {', '.join(genre_info['hooks'])}
1494
-
1495
- Format exactly as follows:
1496
-
1497
- 📖 **Title:**
1498
- [Attractive and memorable title]
1499
-
1500
- 🌍 **Setting:**
1501
- [World and background in 3-4 lines. Include era, location, core settings]
1502
-
1503
- 👥 **Main Characters:**
1504
- - Protagonist: [Name(Age)] - [Job/Status, key traits]
1505
- - Key Character 1: [Name(Age)] - [Relationship/Role, traits]
1506
- - Key Character 2: [Name(Age)] - [Relationship/Role, traits]
1507
-
1508
- 📝 **Synopsis:**
1509
- [3-4 lines introducing core conflict and appeal. Start with strong hook, clearly present protagonist's goal and obstacles]"""
1510
-
1511
- messages = [{"role": "user", "content": prompt}]
1512
- generated_theme = system.call_llm_sync(messages, "writer", language)
1513
-
1514
- return generated_theme
1515
-
1516
- # --- UI functions ---
1517
- def format_episodes_display(episodes: List[Dict], current_episode: int = 0) -> str:
1518
- """Format episodes for display"""
1519
- markdown = "## 📚 웹소설 연재 현황\n\n"
1520
-
1521
- if not episodes:
1522
- return markdown + "*아직 작성된 에피소드가 없습니다.*"
1523
-
1524
- # Stats
1525
- total_episodes = len(episodes)
1526
- total_words = sum(ep.get('word_count', 0) for ep in episodes)
1527
- avg_engagement = sum(ep.get('reader_engagement', 0) for ep in episodes) / len(episodes) if episodes else 0
1528
-
1529
- markdown += f"**진행 상황:** {total_episodes} / {TARGET_EPISODES}화\n"
1530
- markdown += f"**총 단어 수:** {total_words:,} / {TARGET_WORDS:,}\n"
1531
- markdown += f"**평균 몰입도:** ⭐ {avg_engagement:.1f} / 10\n\n"
1532
- markdown += "---\n\n"
1533
-
1534
- # Episode list
1535
- for ep in episodes[-5:]: # Show last 5 episodes
1536
- ep_num = ep.get('episode_number', 0)
1537
- word_count = ep.get('word_count', 0)
1538
-
1539
- markdown += f"### 📖 {ep_num}화\n"
1540
- markdown += f"*{word_count}단어*\n\n"
1541
-
1542
- content = ep.get('content', '')
1543
- if content:
1544
- preview = content[:200] + "..." if len(content) > 200 else content
1545
- markdown += f"{preview}\n\n"
1546
-
1547
- hook = ep.get('hook', '')
1548
- if hook:
1549
- markdown += f"**🪝 후크:** *{hook}*\n\n"
1550
-
1551
- markdown += "---\n\n"
1552
-
1553
- return markdown
1554
-
1555
- def format_webnovel_display(episodes: List[Dict], genre: str) -> str:
1556
- """Format complete web novel for display"""
1557
- if not episodes:
1558
- return "아직 완성된 웹소설이 없습니다."
1559
-
1560
- formatted = f"# 🎭 {genre} 웹소설\n\n"
1561
-
1562
- # Novel stats
1563
- total_words = sum(ep.get('word_count', 0) for ep in episodes)
1564
- formatted += f"**총 {len(episodes)}화 완결 | {total_words:,}단어**\n\n"
1565
- formatted += "---\n\n"
1566
-
1567
- # Episodes
1568
- for idx, ep in enumerate(episodes):
1569
- ep_num = ep.get('episode_number', 0)
1570
- content = ep.get('content', '')
1571
-
1572
- # Content already includes the title, so display as is
1573
- formatted += f"## {content.split(chr(10))[0] if content else f'{ep_num}화'}\n\n"
1574
-
1575
- # Get the actual content (skip title and empty line)
1576
- lines = content.split('\n')
1577
- if len(lines) > 1:
1578
- actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:])
1579
- formatted += f"{actual_content}\n\n"
1580
-
1581
- if idx < len(episodes) - 1: # Not last episode
1582
- formatted += "➡️ *다음 화에 계속...*\n\n"
1583
-
1584
- formatted += "---\n\n"
1585
-
1586
- return formatted
1587
-
1588
- # --- Gradio interface ---
1589
- def create_interface():
1590
- with gr.Blocks(theme=gr.themes.Soft(), title="K-WebNovel Generator") as interface:
1591
- gr.HTML("""
1592
- <style>
1593
- .main-header {
1594
- text-align: center;
1595
- margin-bottom: 2rem;
1596
- }
1597
-
1598
- .header-title {
1599
- font-size: 3rem;
1600
- margin-bottom: 1rem;
1601
- }
1602
-
1603
- .header-subtitle {
1604
- font-size: 1.2rem;
1605
- margin-bottom: 0.5rem;
1606
- }
1607
-
1608
- .header-description {
1609
- margin-bottom: 1.5rem;
1610
- }
1611
-
1612
- .badges-container {
1613
- display: flex;
1614
- justify-content: center;
1615
- align-items: center;
1616
- gap: 8px;
1617
- flex-wrap: wrap;
1618
- margin-top: 12px;
1619
- }
1620
-
1621
- .badges-container a img {
1622
- height: 28px;
1623
- transition: transform 0.2s ease;
1624
- }
1625
-
1626
- .badges-container a:hover img {
1627
- transform: scale(1.05);
1628
- }
1629
-
1630
- @media (max-width: 768px) {
1631
- .header-title {
1632
- font-size: 2.5rem;
1633
- }
1634
-
1635
- .header-subtitle {
1636
- font-size: 1.1rem;
1637
- }
1638
-
1639
- .badges-container {
1640
- gap: 6px;
1641
- }
1642
-
1643
- .badges-container a img {
1644
- height: 24px;
1645
- }
1646
- }
1647
- </style>
1648
-
1649
- <div class="main-header">
1650
- <h1 class="header-title">📚 K-WebNovel Generator</h1>
1651
-
1652
- <div class="badges-container">
1653
- <a href="https://huggingface.co/spaces/fantaxy/AGI-LEADERBOARD" target="_blank">
1654
- <img src="https://img.shields.io/static/v1?label=HF&message=AGI-LEADERBOARD&color=%23d4a574&labelColor=%238b6239&logo=HUGGINGFACE&logoColor=%23faf8f5&style=for-the-badge" alt="badge">
1655
- </a>
1656
- <a href="https://huggingface.co/spaces/openfree/AGI-NOVEL" target="_blank">
1657
- <img src="https://img.shields.io/static/v1?label=HF&message=AGI-NOVEL&color=%23d4a574&labelColor=%235a3e28&logo=huggingface&logoColor=%23faf8f5&style=for-the-badge" alt="badge">
1658
- </a>
1659
- <a href="https://huggingface.co/spaces/openfree/AGI-Screenplay" target="_blank">
1660
- <img src="https://img.shields.io/static/v1?label=HF&message=AGI-Screenplay&color=%23b8956f&labelColor=%23745940&logo=HUGGINGFACE&logoColor=%23faf8f5&style=for-the-badge" alt="badge">
1661
- </a>
1662
- <a href="https://huggingface.co/spaces/openfree/AGI-WebNovel" target="_blank">
1663
- <img src="https://img.shields.io/static/v1?label=HF&message=AGI-WebNovel&color=%23c7a679&labelColor=%236b5036&logo=HUGGINGFACE&logoColor=%23faf8f5&style=for-the-badge" alt="badge">
1664
- </a>
1665
- <a href="https://discord.gg/openfreeai" target="_blank">
1666
- <img src="https://img.shields.io/static/v1?label=Discord&message=Openfree%20AI&color=%23c19656&labelColor=%236d4e31&logo=discord&logoColor=white&style=for-the-badge" alt="badge">
1667
- </a>
1668
- </div>
1669
-
1670
- <p class="header-subtitle">한국형 웹소설 자동 생성 시스템</p>
1671
- <p class="header-description">장르별 맞춤형 40화 완결 웹소설을 생성합니다</p>
1672
- </div>
1673
- """)
1674
-
1675
- # State
1676
- current_session_id = gr.State(None)
1677
-
1678
- with gr.Tab("✍️ 웹소설 쓰기"):
1679
- with gr.Group():
1680
- gr.Markdown("### 🎯 웹소설 설정")
1681
-
1682
- with gr.Row():
1683
- with gr.Column(scale=2):
1684
- genre_select = gr.Radio(
1685
- choices=list(WEBNOVEL_GENRES.keys()),
1686
- value="로맨스",
1687
- label="장르 선택",
1688
- info="원하는 장르를 선택하세요"
1689
- )
1690
-
1691
- query_input = gr.Textbox(
1692
- label="스토리 테마",
1693
- placeholder="웹소설의 기본 설정이나 주제를 입력하세요...",
1694
- lines=3
1695
- )
1696
-
1697
- with gr.Row():
1698
- random_btn = gr.Button("🎲 랜덤 테마", variant="secondary")
1699
- submit_btn = gr.Button("📝 연재 시작", variant="primary", size="lg")
1700
-
1701
- with gr.Column(scale=1):
1702
- language_select = gr.Radio(
1703
- choices=["Korean", "English"],
1704
- value="Korean",
1705
- label="언어"
1706
- )
1707
-
1708
- gr.Markdown("""
1709
- **장르별 특징:**
1710
- - 로맨스: 달달한 사랑 이야기
1711
- - 로판: 회귀/빙의 판타지
1712
- - 판타지: 성장과 모험
1713
- - 현판: 현대 배경 능력자
1714
- - 무협: 무공과 강호
1715
- - 미스터리: 추리와 반전
1716
- - 라노벨: 가벼운 일상물
1717
- """)
1718
-
1719
- status_text = gr.Textbox(
1720
- label="진행 상황",
1721
- interactive=False,
1722
- value="장르를 선택하고 테마를 입력하세요"
1723
- )
1724
-
1725
- # Output
1726
- with gr.Row():
1727
- with gr.Column():
1728
- episodes_display = gr.Markdown("*연재 진행 상황이 여기에 표시됩니다*")
1729
-
1730
- with gr.Column():
1731
- novel_display = gr.Markdown("*완성된 웹소설이 여기에 표시됩니다*")
1732
-
1733
- with gr.Row():
1734
- download_format = gr.Radio(
1735
- choices=["TXT", "DOCX"],
1736
- value="TXT",
1737
- label="다운로드 형식"
1738
- )
1739
- download_btn = gr.Button("📥 다운로드", variant="secondary")
1740
-
1741
- download_file = gr.File(visible=False)
1742
-
1743
- with gr.Tab("📚 테마 라이브러리"):
1744
- gr.Markdown("### 인기 웹소설 테마")
1745
-
1746
- library_genre = gr.Radio(
1747
- choices=["전체"] + list(WEBNOVEL_GENRES.keys()),
1748
- value="전체",
1749
- label="장르 필터"
1750
- )
1751
-
1752
- theme_library = gr.HTML("<p>테마 라이브러리 로딩 중...</p>")
1753
-
1754
- refresh_library_btn = gr.Button("🔄 새로고침")
1755
-
1756
- # Event handlers
1757
- def process_query(query, genre, language, session_id):
1758
- system = WebNovelSystem()
1759
- episodes = ""
1760
- novel = ""
1761
-
1762
- for ep_display, novel_display, status, new_session_id in system.process_webnovel_stream(query, genre, language, session_id):
1763
- episodes = ep_display
1764
- novel = novel_display
1765
- yield episodes, novel, status, new_session_id
1766
-
1767
- def handle_random_theme(genre, language):
1768
- return generate_random_webnovel_theme(genre, language)
1769
-
1770
- def handle_download(download_format, session_id, genre):
1771
- """Handle download request"""
1772
- if not session_id:
1773
- return None
1774
-
1775
- try:
1776
- episodes = WebNovelDatabase.get_episodes(session_id)
1777
- if not episodes:
1778
- return None
1779
-
1780
- # Get title from first episode or generate default
1781
- title = f"{genre} 웹소설"
1782
-
1783
- if download_format == "TXT":
1784
- content = export_to_txt(episodes, genre, title)
1785
-
1786
- # Save to temporary file
1787
- with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
1788
- suffix='.txt', delete=False) as f:
1789
- f.write(content)
1790
- return f.name
1791
-
1792
- elif download_format == "DOCX":
1793
- if not DOCX_AVAILABLE:
1794
- gr.Warning("DOCX export requires python-docx library")
1795
- return None
1796
-
1797
- content = export_to_docx(episodes, genre, title)
1798
-
1799
- # Save to temporary file
1800
- with tempfile.NamedTemporaryFile(mode='wb', suffix='.docx',
1801
- delete=False) as f:
1802
- f.write(content)
1803
- return f.name
1804
-
1805
- except Exception as e:
1806
- logger.error(f"Download error: {e}")
1807
- gr.Warning(f"다운로드 중 오류 발생: {str(e)}")
1808
- return None
1809
-
1810
- # Connect events
1811
- submit_btn.click(
1812
- fn=process_query,
1813
- inputs=[query_input, genre_select, language_select, current_session_id],
1814
- outputs=[episodes_display, novel_display, status_text, current_session_id]
1815
- )
1816
-
1817
- random_btn.click(
1818
- fn=handle_random_theme,
1819
- inputs=[genre_select, language_select],
1820
- outputs=[query_input]
1821
- )
1822
-
1823
- download_btn.click(
1824
- fn=handle_download,
1825
- inputs=[download_format, current_session_id, genre_select],
1826
- outputs=[download_file]
1827
- ).then(
1828
- fn=lambda x: gr.update(visible=True) if x else gr.update(visible=False),
1829
- inputs=[download_file],
1830
- outputs=[download_file]
1831
- )
1832
-
1833
- # Examples
1834
- gr.Examples(
1835
- examples=[
1836
- ["계약결혼한 재벌 3세와 평범한 회사원의 로맨스", "로맨스"],
1837
- ["회귀한 천재 마법사의 복수극", "로판"],
1838
- ["F급 헌터에서 SSS급 각성자가 되는 이야기", "현판"],
1839
- ["폐급에서 천하제일이 되는 무공 천재", "무협"],
1840
- ["평범한 고등학생이 이세계 용사가 되는 이야기", "라이트노벨"]
1841
- ],
1842
- inputs=[query_input, genre_select]
1843
- )
1844
-
1845
- return interface
1846
 
1847
- # Main
1848
  if __name__ == "__main__":
1849
- logger.info("K-WebNovel Generator Starting...")
1850
- logger.info("=" * 60)
1851
-
1852
- # Environment check
1853
- logger.info(f"API Endpoint: {API_URL}")
1854
- logger.info(f"Model: {MODEL_ID}")
1855
- logger.info(f"Target: {TARGET_EPISODES} episodes, {TARGET_WORDS:,} words")
1856
- logger.info("Genres: " + ", ".join(WEBNOVEL_GENRES.keys()))
1857
-
1858
- logger.info("=" * 60)
1859
-
1860
- # Initialize database
1861
- logger.info("Initializing database...")
1862
- WebNovelDatabase.init_db()
1863
- logger.info("Database ready.")
1864
-
1865
- # Launch interface
1866
- interface = create_interface()
1867
- interface.launch(
1868
- server_name="0.0.0.0",
1869
- server_port=7860,
1870
- share=False
1871
- )
 
 
1
  import os
2
+ import sys
3
+ import streamlit as st
4
+ from tempfile import NamedTemporaryFile
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
+ def main():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  try:
8
+ # Get the code from secrets
9
+ code = os.environ.get("MAIN_CODE")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ if not code:
12
+ st.error("⚠️ The application code wasn't found in secrets. Please add the MAIN_CODE secret.")
13
+ return
 
 
 
 
 
 
 
14
 
15
+ # Create a temporary Python file
16
+ with NamedTemporaryFile(suffix='.py', delete=False, mode='w') as tmp:
17
+ tmp.write(code)
18
+ tmp_path = tmp.name
19
 
20
+ # Execute the code
21
+ exec(compile(code, tmp_path, 'exec'), globals())
 
22
 
23
+ # Clean up the temporary file
24
+ try:
25
+ os.unlink(tmp_path)
26
+ except:
27
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  except Exception as e:
30
+ st.error(f"⚠️ Error loading or executing the application: {str(e)}")
31
+ import traceback
32
+ st.code(traceback.format_exc())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
 
34
  if __name__ == "__main__":
35
+ main()