openfree commited on
Commit
547fa82
·
verified ·
1 Parent(s): ec9fa1f

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1661 -0
app.py ADDED
@@ -0,0 +1,1661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ FRIENDLI_TOKEN = os.getenv("FRIENDLI_TOKEN", "")
42
+ BRAVE_SEARCH_API_KEY = os.getenv("BRAVE_SEARCH_API_KEY", "")
43
+ API_URL = "https://api.friendli.ai/dedicated/v1/chat/completions"
44
+ MODEL_ID = "dep86pjolcjjnv8"
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 FRIENDLI_TOKEN:
65
+ logger.error("FRIENDLI_TOKEN not set. Application will not work properly.")
66
+ FRIENDLI_TOKEN = "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.token = FRIENDLI_TOKEN
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 {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}
391
+
392
+ # --- Prompt generation functions ---
393
+ def create_planning_prompt(self, query: str, genre: str, language: str) -> str:
394
+ """Create initial planning prompt for web novel"""
395
+ genre_info = GENRE_ELEMENTS.get(genre, {})
396
+
397
+ lang_prompts = {
398
+ "Korean": f"""한국 웹소설 시장을 겨냥한 {genre} 장르 웹소설을 기획하세요.
399
+
400
+ **주제:** {query}
401
+ **장르:** {genre}
402
+ **목표:** 40화 완결, 총 16,000단어
403
+
404
+ **장르 필수 요소:**
405
+ - 핵심 요소: {', '.join(genre_info.get('key_elements', []))}
406
+ - 인기 트로프: {', '.join(genre_info.get('popular_tropes', []))}
407
+ - 필수 포함: {', '.join(genre_info.get('must_have', []))}
408
+
409
+ **전체 구성:**
410
+ 1. **1-5화**: 흥미로운 도입부, 주인공과 세계관 소개, 핵심 갈등 제시
411
+ 2. **6-15화**: 갈등 상승, 주요 인물 관계 형성, 첫 번째 위기
412
+ 3. **16-25화**: 중간 클라이맥스, 반전, 새로운 진실 발견
413
+ 4. **26-35화**: 최종 갈등으로 치닫기, 모든 갈등의 수렴
414
+ 5. **36-40화**: 대결전, 클라이맥스와 결말, 감동적 마무리
415
+
416
+ **각 화 구성 원칙:**
417
+ - 400-600단어 분량 (충실한 내용)
418
+ - 3-4개의 주요 장면 포함
419
+ - 매 화 끝 강력한 후크
420
+ - 빠른 전개와 몰입감
421
+ - 독자가 다음 화를 기다리게 만들기
422
+
423
+ 구체적인 40화 플롯라인을 제시하세요. 각 화마다 핵심 사건과 전개를 명시하세요.""",
424
+
425
+ "English": f"""Plan a Korean-style web novel for {genre} genre.
426
+
427
+ **Theme:** {query}
428
+ **Genre:** {genre}
429
+ **Goal:** 40 episodes, total 16,000 words
430
+
431
+ **Genre Requirements:**
432
+ - Key elements: {', '.join(genre_info.get('key_elements', []))}
433
+ - Popular tropes: {', '.join(genre_info.get('popular_tropes', []))}
434
+ - Must include: {', '.join(genre_info.get('must_have', []))}
435
+
436
+ **Overall Structure:**
437
+ 1. **Episodes 1-5**: Engaging introduction, protagonist and world, core conflict
438
+ 2. **Episodes 6-15**: Rising conflict, main relationships, first crisis
439
+ 3. **Episodes 16-25**: Mid climax, plot twist, new revelations
440
+ 4. **Episodes 26-35**: Building to final conflict, convergence of all conflicts
441
+ 5. **Episodes 36-40**: Final battle, climax and resolution, emotional closure
442
+
443
+ **Episode Principles:**
444
+ - 400-600 words each (substantial content)
445
+ - 3-4 major scenes per episode
446
+ - Strong hook at episode end
447
+ - Fast pacing and immersion
448
+ - Make readers crave next episode
449
+
450
+ Provide detailed 40-episode plotline with key events for each episode."""
451
+ }
452
+
453
+ return lang_prompts.get(language, lang_prompts["Korean"])
454
+
455
+ def create_episode_prompt(self, episode_num: int, plot_outline: str,
456
+ previous_content: str, genre: str, language: str) -> str:
457
+ """Create prompt for individual episode - UPDATED FOR LONGER CONTENT"""
458
+ genre_info = GENRE_ELEMENTS.get(genre, {})
459
+ hooks = EPISODE_HOOKS.get(genre, ["다음 순간, 충격적인 일이..."])
460
+
461
+ lang_prompts = {
462
+ "Korean": f"""웹소설 {episode_num}화를 작성하세요.
463
+
464
+ **장르:** {genre}
465
+ **분량:** 400-600단어 (엄격히 준수 - 충실한 내용으로)
466
+
467
+ **전체 플롯에서 {episode_num}화 내용:**
468
+ {self._extract_episode_plan(plot_outline, episode_num)}
469
+
470
+ **이전 내용 요약:**
471
+ {previous_content[-1500:] if previous_content else "첫 화입니다"}
472
+
473
+ **작성 형식:**
474
+ 반드시 다음 형식으로 시작하세요:
475
+ {episode_num}화. [이번 화의 핵심을 담은 매력적인 소제목]
476
+
477
+ (한 줄 띄우고 본문 시작)
478
+
479
+ **작성 지침:**
480
+ 1. **구성**: 3-4개의 주요 장면으로 구성
481
+ - 도입부: 이전 화 연결 및 현재 상황
482
+ - 전개부: 2-3개의 핵심 사건/대화
483
+ - 클라이맥스: 긴장감 최고조
484
+ - 후크: 다음 화 예고
485
+
486
+ 2. **필수 요소:**
487
+ - 생생한 대화와 ���동 묘사
488
+ - 캐릭터 감정과 내면 갈등
489
+ - 장면 전환과 템포 조절
490
+ - 독자 몰입을 위한 감각적 묘사
491
+
492
+ 3. **장르별 특색:**
493
+ - {genre_info.get('episode_structure', '빠른 전개')}
494
+ - 핵심 요소 1개 이상 포함
495
+
496
+ 4. **분량 배분:**
497
+ - 도입 (50-80단어)
498
+ - 주요 전개 (250-350단어)
499
+ - 클라이맥스와 후크 (100-150단어)
500
+
501
+ **참고 후크 예시:**
502
+ {random.choice(hooks)}
503
+
504
+ 소제목은 이번 화의 핵심 사건이나 전환점을 암시하는 매력적인 문구로 작성하세요.
505
+ {episode_num}화를 풍성하고 몰입감 있게 작성하세요. 반드시 400-600단어로 작성하세요.""",
506
+
507
+ "English": f"""Write episode {episode_num} of the web novel.
508
+
509
+ **Genre:** {genre}
510
+ **Length:** 400-600 words (strict - with substantial content)
511
+
512
+ **Episode {episode_num} from plot:**
513
+ {self._extract_episode_plan(plot_outline, episode_num)}
514
+
515
+ **Previous content:**
516
+ {previous_content[-1500:] if previous_content else "First episode"}
517
+
518
+ **Format:**
519
+ Must start with:
520
+ Episode {episode_num}. [Attractive subtitle that captures the essence of this episode]
521
+
522
+ (blank line then start main text)
523
+
524
+ **Guidelines:**
525
+ 1. **Structure**: 3-4 major scenes
526
+ - Opening: Connect from previous, current situation
527
+ - Development: 2-3 key events/dialogues
528
+ - Climax: Peak tension
529
+ - Hook: Next episode teaser
530
+
531
+ 2. **Essential elements:**
532
+ - Vivid dialogue and action
533
+ - Character emotions and conflicts
534
+ - Scene transitions and pacing
535
+ - Sensory details for immersion
536
+
537
+ 3. **Genre specifics:**
538
+ - {genre_info.get('episode_structure', 'Fast pacing')}
539
+ - Include at least 1 core element
540
+
541
+ 4. **Word distribution:**
542
+ - Opening (50-80 words)
543
+ - Main development (250-350 words)
544
+ - Climax and hook (100-150 words)
545
+
546
+ **Hook example:**
547
+ {random.choice(hooks)}
548
+
549
+ Create an attractive subtitle that hints at key events or turning points.
550
+ Write rich, immersive episode {episode_num}. Must be 400-600 words."""
551
+ }
552
+
553
+ return lang_prompts.get(language, lang_prompts["Korean"])
554
+
555
+ def create_episode_critique_prompt(self, episode_num: int, content: str,
556
+ genre: str, language: str) -> str:
557
+ """Create critique prompt for episode"""
558
+ lang_prompts = {
559
+ "Korean": f"""{genre} 웹소설 {episode_num}화를 평가하세요.
560
+
561
+ **작성된 내용:**
562
+ {content}
563
+
564
+ **평가 기준:**
565
+ 1. **후크 효과성 (25점)**: 다음 화를 읽고 싶게 만드는가?
566
+ 2. **페이싱 (25점)**: 전개 속도가 적절한가?
567
+ 3. **장르 적합성 (25점)**: {genre} 장르 관습을 잘 따르는가?
568
+ 4. **독자 몰입도 (25점)**: 감정적으로 빠져들게 하는가?
569
+
570
+ **점수: /100점**
571
+
572
+ 구체적인 개선점을 제시하세요.""",
573
+
574
+ "English": f"""Evaluate {genre} web novel episode {episode_num}.
575
+
576
+ **Written content:**
577
+ {content}
578
+
579
+ **Evaluation criteria:**
580
+ 1. **Hook effectiveness (25pts)**: Makes readers want next episode?
581
+ 2. **Pacing (25pts)**: Appropriate development speed?
582
+ 3. **Genre fit (25pts)**: Follows {genre} conventions?
583
+ 4. **Reader engagement (25pts)**: Emotionally immersive?
584
+
585
+ **Score: /100 points**
586
+
587
+ Provide specific improvements."""
588
+ }
589
+
590
+ return lang_prompts.get(language, lang_prompts["Korean"])
591
+
592
+ def _extract_episode_plan(self, plot_outline: str, episode_num: int) -> str:
593
+ """Extract specific episode plan from outline"""
594
+ lines = plot_outline.split('\n')
595
+ episode_section = []
596
+ capturing = False
597
+
598
+ patterns = [
599
+ f"{episode_num}화:", f"Episode {episode_num}:",
600
+ f"제{episode_num}화:", f"EP{episode_num}:"
601
+ ]
602
+
603
+ for line in lines:
604
+ if any(pattern in line for pattern in patterns):
605
+ capturing = True
606
+ elif capturing and any(f"{episode_num+1}" in line for pattern in patterns):
607
+ break
608
+ elif capturing:
609
+ episode_section.append(line)
610
+
611
+ return '\n'.join(episode_section) if episode_section else "플롯을 참고하여 작성"
612
+
613
+ # --- LLM call functions ---
614
+ def call_llm_sync(self, messages: List[Dict[str, str]], role: str, language: str) -> str:
615
+ full_content = ""
616
+ for chunk in self.call_llm_streaming(messages, role, language):
617
+ full_content += chunk
618
+ if full_content.startswith("❌"):
619
+ raise Exception(f"LLM Call Failed: {full_content}")
620
+ return full_content
621
+
622
+ def call_llm_streaming(self, messages: List[Dict[str, str]], role: str,
623
+ language: str) -> Generator[str, None, None]:
624
+ try:
625
+ system_prompts = self.get_system_prompts(language)
626
+ full_messages = [{"role": "system", "content": system_prompts.get(role, "")}, *messages]
627
+
628
+ # Increased max_tokens for longer episodes
629
+ max_tokens = 5000 if role == "writer" else 10000
630
+
631
+ payload = {
632
+ "model": self.model_id,
633
+ "messages": full_messages,
634
+ "max_tokens": max_tokens,
635
+ "temperature": 0.85,
636
+ "top_p": 0.95,
637
+ "presence_penalty": 0.3,
638
+ "frequency_penalty": 0.3,
639
+ "stream": True
640
+ }
641
+
642
+ response = requests.post(
643
+ self.api_url,
644
+ headers=self.create_headers(),
645
+ json=payload,
646
+ stream=True,
647
+ timeout=180
648
+ )
649
+
650
+ if response.status_code != 200:
651
+ yield f"❌ API Error (Status Code: {response.status_code})"
652
+ return
653
+
654
+ buffer = ""
655
+ for line in response.iter_lines():
656
+ if not line:
657
+ continue
658
+
659
+ try:
660
+ line_str = line.decode('utf-8').strip()
661
+ if not line_str.startswith("data: "):
662
+ continue
663
+
664
+ data_str = line_str[6:]
665
+ if data_str == "[DONE]":
666
+ break
667
+
668
+ data = json.loads(data_str)
669
+ choices = data.get("choices", [])
670
+ if choices and choices[0].get("delta", {}).get("content"):
671
+ content = choices[0]["delta"]["content"]
672
+ buffer += content
673
+
674
+ if len(buffer) >= 50 or '\n' in buffer:
675
+ yield buffer
676
+ buffer = ""
677
+ time.sleep(0.01)
678
+
679
+ except Exception as e:
680
+ logger.error(f"Chunk processing error: {str(e)}")
681
+ continue
682
+
683
+ if buffer:
684
+ yield buffer
685
+
686
+ except Exception as e:
687
+ logger.error(f"Streaming error: {type(e).__name__}: {str(e)}")
688
+ yield f"❌ Error occurred: {str(e)}"
689
+
690
+ def get_system_prompts(self, language: str) -> Dict[str, str]:
691
+ """System prompts for web novel roles - UPDATED FOR LONGER EPISODES"""
692
+ base_prompts = {
693
+ "Korean": {
694
+ "planner": """당신은 한국 웹소설 시장을 완벽히 이해하는 기획자입니다.
695
+ 독자를 중독시키는 플롯과 전개를 설계합니다.
696
+ 장르별 관습과 독자 기대를 정확히 파악합니다.
697
+ 40화 완결 구조로 완벽한 기승전결을 만듭니다.
698
+ 각 화마다 충실한 내용과 전개를 계획합니다.""",
699
+
700
+ "writer": """당신은 독자를 사로잡는 웹소설 작가입니다.
701
+ 풍부하고 몰입감 있는 문체를 구사합니다.
702
+ 각 화를 400-600단어로 충실하게 작성합니다.
703
+ 여러 장면과 전환을 통해 이야기를 전개합니다.
704
+ 대화, 행동, 내면 묘사를 균형있게 배치합니다.
705
+ 매 화 끝에 강력한 후크로 다음 화를 기다리게 만듭니다.""",
706
+
707
+ "critic": """당신은 웹소설 독자의 마음을 읽는 평론가입니다.
708
+ 재미와 몰입감을 최우선으로 평가합니다.
709
+ 장르적 쾌감과 독자 만족도를 분석합니다.
710
+ 구체적이고 실용적인 개선안을 제시합니다."""
711
+ },
712
+ "English": {
713
+ "planner": """You perfectly understand the Korean web novel market.
714
+ Design addictive plots and developments.
715
+ Accurately grasp genre conventions and reader expectations.
716
+ Create perfect story structure in 40 episodes.
717
+ Plan substantial content and development for each episode.""",
718
+
719
+ "writer": """You are a web novelist who captivates readers.
720
+ Use rich and immersive writing style.
721
+ Write each episode with 400-600 words faithfully.
722
+ Develop story through multiple scenes and transitions.
723
+ Balance dialogue, action, and inner descriptions.
724
+ End each episode with powerful hook for next.""",
725
+
726
+ "critic": """You read web novel readers' minds.
727
+ Prioritize fun and immersion in evaluation.
728
+ Analyze genre satisfaction and reader enjoyment.
729
+ Provide specific, practical improvements."""
730
+ }
731
+ }
732
+
733
+ return base_prompts.get(language, base_prompts["Korean"])
734
+
735
+ # --- Main process ---
736
+ def process_webnovel_stream(self, query: str, genre: str, language: str,
737
+ session_id: Optional[str] = None) -> Generator[Tuple[str, str, str, str], None, None]:
738
+ """Web novel generation process"""
739
+ try:
740
+ resume_from_episode = 0
741
+ plot_outline = ""
742
+
743
+ if session_id:
744
+ self.current_session_id = session_id
745
+ # Resume logic here
746
+ else:
747
+ self.current_session_id = WebNovelDatabase.create_session(query, genre, language)
748
+ self.tracker.set_genre(genre)
749
+ logger.info(f"Created new session: {self.current_session_id}")
750
+
751
+ # Generate plot outline first
752
+ if resume_from_episode == 0:
753
+ yield "🎬 웹소설 플롯 구성 중...", "", f"장르: {genre}", self.current_session_id
754
+
755
+ plot_prompt = self.create_planning_prompt(query, genre, language)
756
+ plot_outline = self.call_llm_sync(
757
+ [{"role": "user", "content": plot_prompt}],
758
+ "planner", language
759
+ )
760
+
761
+ yield "✅ 플롯 구성 완료!", "", f"40화 구성 완료", self.current_session_id
762
+
763
+ # Generate episodes
764
+ accumulated_content = ""
765
+ for episode_num in range(resume_from_episode + 1, TARGET_EPISODES + 1):
766
+ # Write episode
767
+ yield f"✍️ {episode_num}화 집필 중...", accumulated_content, f"진행률: {episode_num}/{TARGET_EPISODES}화", self.current_session_id
768
+
769
+ episode_prompt = self.create_episode_prompt(
770
+ episode_num, plot_outline, accumulated_content, genre, language
771
+ )
772
+
773
+ episode_content = self.call_llm_sync(
774
+ [{"role": "user", "content": episode_prompt}],
775
+ "writer", language
776
+ )
777
+
778
+ # Extract episode title and content
779
+ lines = episode_content.strip().split('\n')
780
+ episode_title = ""
781
+ actual_content = episode_content
782
+
783
+ # Check if first line contains episode number and title
784
+ if lines and (f"{episode_num}화." in lines[0] or f"Episode {episode_num}." in lines[0]):
785
+ episode_title = lines[0]
786
+ # Join the rest as content (excluding the title line and empty line after it)
787
+ actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:])
788
+ else:
789
+ # If no title format found, generate a default title
790
+ episode_title = f"{episode_num}화. 제{episode_num}화"
791
+
792
+ # Extract hook (last sentence)
793
+ sentences = actual_content.split('.')
794
+ hook = sentences[-2] + '.' if len(sentences) > 1 else sentences[-1]
795
+
796
+ # Save episode with title
797
+ WebNovelDatabase.save_episode(
798
+ self.current_session_id, episode_num,
799
+ actual_content, hook
800
+ )
801
+
802
+ # Add to accumulated content with title
803
+ accumulated_content += f"\n\n### {episode_title}\n{actual_content}"
804
+
805
+ # Quick critique every 5 episodes
806
+ if episode_num % 5 == 0:
807
+ critique_prompt = self.create_episode_critique_prompt(
808
+ episode_num, episode_content, genre, language
809
+ )
810
+ critique = self.call_llm_sync(
811
+ [{"role": "user", "content": critique_prompt}],
812
+ "critic", language
813
+ )
814
+
815
+ yield f"✅ {episode_num}화 완료!", accumulated_content, f"진행률: {episode_num}/{TARGET_EPISODES}화", self.current_session_id
816
+
817
+ # Complete
818
+ total_words = len(accumulated_content.split())
819
+ yield f"🎉 웹소설 완성!", accumulated_content, f"총 {total_words:,}단어, {TARGET_EPISODES}화 완결", self.current_session_id
820
+
821
+ except Exception as e:
822
+ logger.error(f"Web novel generation error: {e}", exc_info=True)
823
+ yield f"❌ 오류 발생: {e}", accumulated_content if 'accumulated_content' in locals() else "", "오류", self.current_session_id
824
+
825
+ # --- Export functions ---
826
+ def export_to_txt(episodes: List[Dict], genre: str, title: str = "") -> str:
827
+ """Export web novel to TXT format"""
828
+ content = f"{'=' * 50}\n"
829
+ content += f"{title if title else genre + ' 웹소설'}\n"
830
+ content += f"{'=' * 50}\n\n"
831
+ content += f"총 {len(episodes)}화 완결\n"
832
+ content += f"총 단어 수: {sum(ep.get('word_count', 0) for ep in episodes):,}\n"
833
+ content += f"{'=' * 50}\n\n"
834
+
835
+ for ep in episodes:
836
+ ep_num = ep.get('episode_number', 0)
837
+ ep_content = ep.get('content', '')
838
+
839
+ # Extract title if exists in content
840
+ lines = ep_content.strip().split('\n')
841
+ if lines and (f"{ep_num}화." in lines[0] or f"Episode {ep_num}." in lines[0]):
842
+ content += f"\n{lines[0]}\n"
843
+ content += f"{'-' * 40}\n\n"
844
+ actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:])
845
+ content += actual_content
846
+ else:
847
+ content += f"\n{ep_num}화\n"
848
+ content += f"{'-' * 40}\n\n"
849
+ content += ep_content
850
+
851
+ content += f"\n\n{'=' * 50}\n"
852
+
853
+ return content
854
+
855
+ def export_to_docx(episodes: List[Dict], genre: str, title: str = "") -> bytes:
856
+ """Export web novel to DOCX format"""
857
+ if not DOCX_AVAILABLE:
858
+ raise Exception("python-docx is not installed")
859
+
860
+ doc = Document()
861
+
862
+ # Title
863
+ doc.add_heading(title if title else f"{genre} 웹소설", 0)
864
+
865
+ # Stats
866
+ doc.add_paragraph(f"총 {len(episodes)}화 완결")
867
+ doc.add_paragraph(f"총 단어 수: {sum(ep.get('word_count', 0) for ep in episodes):,}")
868
+ doc.add_page_break()
869
+
870
+ # Episodes
871
+ for ep in episodes:
872
+ ep_num = ep.get('episode_number', 0)
873
+ ep_content = ep.get('content', '')
874
+
875
+ # Extract title if exists
876
+ lines = ep_content.strip().split('\n')
877
+ if lines and (f"{ep_num}화." in lines[0] or f"Episode {ep_num}." in lines[0]):
878
+ doc.add_heading(lines[0], 1)
879
+ actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:])
880
+ else:
881
+ doc.add_heading(f"{ep_num}화", 1)
882
+ actual_content = ep_content
883
+
884
+ # Add content paragraphs
885
+ for paragraph in actual_content.split('\n'):
886
+ if paragraph.strip():
887
+ doc.add_paragraph(paragraph.strip())
888
+
889
+ if ep_num < len(episodes):
890
+ doc.add_page_break()
891
+
892
+ # Save to bytes
893
+ import io
894
+ bytes_io = io.BytesIO()
895
+ doc.save(bytes_io)
896
+ bytes_io.seek(0)
897
+ return bytes_io.getvalue()
898
+ def generate_random_webnovel_theme(genre: str, language: str) -> str:
899
+ """Generate random web novel theme using novel_themes.json and LLM"""
900
+ try:
901
+ # Load novel_themes.json with error handling
902
+ json_path = Path("novel_themes.json")
903
+ if not json_path.exists():
904
+ logger.warning("novel_themes.json not found, using fallback")
905
+ return generate_fallback_theme(genre, language)
906
+
907
+ try:
908
+ with open(json_path, 'r', encoding='utf-8') as f:
909
+ content = f.read()
910
+ # Try to fix common JSON errors
911
+ content = content.replace("'", '"') # Replace single quotes with double quotes
912
+ content = re.sub(r',\s*}', '}', content) # Remove trailing commas before }
913
+ content = re.sub(r',\s*]', ']', content) # Remove trailing commas before ]
914
+ themes_data = json.loads(content)
915
+ except json.JSONDecodeError as e:
916
+ logger.error(f"JSON parsing error: {e}")
917
+ logger.error(f"Error at position: {e.pos if hasattr(e, 'pos') else 'unknown'}")
918
+ # If JSON parsing fails, use LLM with genre-specific prompt
919
+ return generate_theme_with_llm_only(genre, language)
920
+
921
+ # Map genres to theme data
922
+ genre_mapping = {
923
+ "로맨스": ["romance_fantasy_villainess", "villainess_wants_to_be_lazy", "chaebol_family_intrigue"],
924
+ "로판": ["romance_fantasy_villainess", "BL_novel_transmigration", "regression_childcare"],
925
+ "판타지": ["system_constellation_hunter", "tower_ascension_challenger", "necromancer_solo_leveling"],
926
+ "현판": ["system_constellation_hunter", "chaebol_family_intrigue", "post_apocalypse_survival"],
927
+ "무협": ["regression_revenge_pro", "necromancer_solo_leveling"],
928
+ "미스터리": ["post_apocalypse_survival", "tower_ascension_challenger"],
929
+ "라이트노벨": ["BL_novel_transmigration", "villainess_wants_to_be_lazy"]
930
+ }
931
+
932
+ # Get relevant core genres for selected genre
933
+ relevant_genres = genre_mapping.get(genre, ["regression_revenge_pro"])
934
+ selected_genre_key = random.choice(relevant_genres)
935
+
936
+ # Get genre data
937
+ core_genre = themes_data["core_genres"].get(selected_genre_key, {})
938
+ compatible_elements = core_genre.get("compatible_elements", {})
939
+
940
+ # Select random elements
941
+ character_keys = compatible_elements.get("characters", [])
942
+ selected_character_key = random.choice(character_keys) if character_keys else "betrayed_protagonist"
943
+
944
+ # Get character variations
945
+ character_data = themes_data["characters"].get(selected_character_key, {})
946
+ character_variations = character_data.get("variations", [])
947
+ character_desc = random.choice(character_variations) if character_variations else ""
948
+ character_traits = character_data.get("traits", [])
949
+
950
+ # Get settings
951
+ settings = compatible_elements.get("settings", [])
952
+ selected_setting = random.choice(settings) if settings else ""
953
+
954
+ # Get all available settings for more variety
955
+ all_settings = themes_data.get("settings", {})
956
+ setting_details = []
957
+ for setting_list in all_settings.values():
958
+ setting_details.extend(setting_list)
959
+ specific_setting = random.choice(setting_details) if setting_details else selected_setting
960
+
961
+ # Get mechanics
962
+ mechanics_keys = list(themes_data.get("core_mechanics", {}).keys())
963
+ selected_mechanic = random.choice(mechanics_keys) if mechanics_keys else ""
964
+ mechanic_data = themes_data["core_mechanics"].get(selected_mechanic, {})
965
+ plot_points = mechanic_data.get("plot_points", [])
966
+ reader_questions = mechanic_data.get("reader_questions", [])
967
+
968
+ # Get hooks
969
+ hook_types = list(themes_data.get("episode_hooks", {}).keys())
970
+ selected_hook_type = random.choice(hook_types) if hook_types else "introduction"
971
+ hooks = themes_data["episode_hooks"].get(selected_hook_type, [])
972
+ selected_hook = random.choice(hooks) if hooks else ""
973
+
974
+ # Get items/artifacts
975
+ item_categories = list(themes_data.get("key_items_and_artifacts", {}).keys())
976
+ if item_categories and genre in ["판타지", "현판", "무협"]:
977
+ selected_category = random.choice(item_categories)
978
+ items = themes_data["key_items_and_artifacts"].get(selected_category, [])
979
+ selected_item = random.choice(items) if items else ""
980
+ else:
981
+ selected_item = ""
982
+
983
+ # Get plot twists
984
+ twist_categories = list(themes_data.get("plot_twists_and_cliches", {}).keys())
985
+ if twist_categories:
986
+ selected_twist_cat = random.choice(twist_categories)
987
+ twists = themes_data["plot_twists_and_cliches"].get(selected_twist_cat, [])
988
+ selected_twist = random.choice(twists) if twists else ""
989
+ else:
990
+ selected_twist = ""
991
+
992
+ # Check for fusion genres
993
+ fusion_genres = themes_data.get("fusion_genres", {})
994
+ fusion_options = list(fusion_genres.values())
995
+ selected_fusion = random.choice(fusion_options) if fusion_options and random.random() > 0.7 else ""
996
+
997
+ # Now use LLM to create a coherent theme from these elements
998
+ system = WebNovelSystem()
999
+
1000
+ # Create prompt for LLM
1001
+ if language == "Korean":
1002
+ prompt = f"""다음 요소들을 활용하여 {genre} 장르의 매력적인 웹소설을 기획하세요:
1003
+
1004
+ 【선택된 요소들】
1005
+ - 핵심 장르: {selected_genre_key}
1006
+ - 캐릭터: {character_desc}
1007
+ - 캐릭터 특성: {', '.join(character_traits[:3])}
1008
+ - 배경: {specific_setting}
1009
+ - 핵심 메커니즘: {selected_mechanic}
1010
+ {"- 아이템: " + selected_item if selected_item else ""}
1011
+ {"- 반전 요소: " + selected_twist if selected_twist else ""}
1012
+ {"- 퓨전 설정: " + selected_fusion if selected_fusion else ""}
1013
+
1014
+ 【참고 훅】
1015
+ {selected_hook}
1016
+
1017
+ 【독자를 사로잡을 질문들】
1018
+ {chr(10).join(reader_questions[:2]) if reader_questions else ""}
1019
+
1020
+ 다음 형식으로 정확히 작성하세요:
1021
+
1022
+ 📖 **제목:**
1023
+ [매력적이고 기억에 남는 제목]
1024
+
1025
+ 🌍 **설정:**
1026
+ [세계관과 배경 설정을 3-4줄로 설명]
1027
+
1028
+ 👥 **주요 캐릭터:**
1029
+ • 주인공: [이름] - [간단한 설명]
1030
+ • 주요인물1: [이름] - [간단한 설명]
1031
+ • 주요인물2: [이름] - [간단한 설명]
1032
+
1033
+ 📝 **작품소개:**
1034
+ [독자의 흥미를 끄는 3-4줄의 작품 소개. 주인공의 상황, 목표, 핵심 갈등을 포함]"""
1035
+
1036
+ else: # English
1037
+ prompt = f"""Create an engaging web novel for {genre} genre using these elements:
1038
+
1039
+ 【Selected Elements】
1040
+ - Core genre: {selected_genre_key}
1041
+ - Character: {character_desc}
1042
+ - Character traits: {', '.join(character_traits[:3])}
1043
+ - Setting: {specific_setting}
1044
+ - Core mechanism: {selected_mechanic}
1045
+ {"- Item: " + selected_item if selected_item else ""}
1046
+ {"- Twist: " + selected_twist if selected_twist else ""}
1047
+ {"- Fusion: " + selected_fusion if selected_fusion else ""}
1048
+
1049
+ 【Reference Hook】
1050
+ {selected_hook}
1051
+
1052
+ Format exactly as follows:
1053
+
1054
+ 📖 **Title:**
1055
+ [Attractive and memorable title]
1056
+
1057
+ 🌍 **Setting:**
1058
+ [World and background setting in 3-4 lines]
1059
+
1060
+ 👥 **Main Characters:**
1061
+ • Protagonist: [Name] - [Brief description]
1062
+ • Key Character 1: [Name] - [Brief description]
1063
+ • Key Character 2: [Name] - [Brief description]
1064
+
1065
+ 📝 **Synopsis:**
1066
+ [3-4 lines that hook readers. Include protagonist's situation, goal, and core conflict]"""
1067
+
1068
+ # Call LLM to generate theme
1069
+ messages = [{"role": "user", "content": prompt}]
1070
+ generated_theme = system.call_llm_sync(messages, "writer", language)
1071
+
1072
+ return generated_theme
1073
+
1074
+ except Exception as e:
1075
+ logger.error(f"Error generating theme from JSON: {e}")
1076
+ return generate_fallback_theme(genre, language)
1077
+
1078
+ def generate_fallback_theme(genre: str, language: str) -> str:
1079
+ """Fallback theme generator when JSON is not available"""
1080
+ templates = {
1081
+ "로맨스": {
1082
+ "themes": [
1083
+ """📖 **제목:** 계약결혼 365일, 기억을 잃은 재벌 남편
1084
+
1085
+ 🌍 **설정:**
1086
+ 현대 서울, 대기업 본사와 강남의 펜트하우스가 주 무대. 3개월 계약결혼 만료 직전, 남편이 교통사고로 기억을 잃고 아내를 첫사랑으로 착각하는 상황.
1087
+
1088
+ 👥 **주요 캐릭터:**
1089
+ • 주인공: 서연우(28) - 평범한 회사원, 부모님 병원비를 위해 계약결혼
1090
+ • 남주: 강준혁(32) - 냉혈 재벌 3세, 기억상실 후 순정남으로 변신
1091
+ • 조연: 한소영(30) - 준혁의 전 약혼녀, 복수를 계획 중
1092
+
1093
+ 📝 **작품소개:**
1094
+ "당신이 내 첫사랑이야." 이혼 서류에 도장을 찍으려던 순간, 교통사고를 당한 냉혈 재벌 남편이 나를 운명의 상대로 착각한다. 3개월간 연기했던 가짜 부부에서 진짜 사랑이 시작되는데...""",
1095
+
1096
+ """📖 **제목:** 검사님, 이혼 소송은 제가 맡을게요
1097
+
1098
+ 🌍 **설정:**
1099
+ 서울중앙지법과 검찰청이 주 무대. 냉혈 검사와 이혼 전문 변호사가 법정에서 대립하며 티격태격하는 법정 로맨스.
1100
+
1101
+ 👥 **주요 캐릭터:**
1102
+ • 주인공: 오지원(30) - 승률 100% 이혼 전문 변호사
1103
+ • 남주: 민시준(33) - 원칙주의 엘리트 검사
1104
+ • 조연: 박세진(35) - 지원의 전 남편이자 시준의 선배 검사
1105
+
1106
+ 📝 **작품소개:**
1107
+ "변호사님, 법정에서만 만나기로 했잖아요." 하필 전 남편의 불륜 소송을 맡은 날, 상대 검사가 나타났다. 법정에선 적, 밖에선 연인. 우리의 관계는 대체 뭘까?"""
1108
+ ]
1109
+ },
1110
+ "로판": {
1111
+ "themes": [
1112
+ """📖 **제목:** 악녀는 이번 생에서 도망친다
1113
+
1114
+ 🌍 **설정:**
1115
+ 마법이 존재하는 제국, 1년 후 처형당할 운명의 악녀 공작 영애로 빙의. 북부 변방의 전쟁광 공작과의 계약결혼이 유일한 생존루트.
1116
+
1117
+ 👥 **주요 캐릭터:**
1118
+ • 주인공: 아델라이드(20) - 빙의한 악녀, 원작 지식 보유
1119
+ • 남주: 카시우스(25) - 북부의 전쟁광 공작, 숨겨진 순정남
1120
+ • 악역: 황태자 레온(23) - 여주에게 집착하는 얀데레
1121
+
1122
+ 📝 **작품소개:**
1123
+ 소설 속 악녀로 빙의했는데 이미 처형 선고를 받은 상태? 살려면 원작에 없던 북부 공작과 계약결혼해야 한다. "1년만 함께해주세요. 그 후엔 자유를 드리겠습니다." 하지만 계약 기간이 끝나도 그가 날 놓아주지 않는다.""",
1124
+
1125
+ """📖 **제목:** 회귀한 황녀는 버려진 왕자를 택한다
1126
+
1127
+ 🌍 **설정:**
1128
+ 제국력 892년으로 회귀한 황녀. 전생에서 자신을 배신한 황태자 대신, 버려진 서자 왕자와 손을 잡고 제국을 뒤집으려 한다.
1129
+
1130
+ 👥 **주요 캐릭터:**
1131
+ • 주인공: 로젤린(22) - 회귀한 황녀, 미래를 아는 전략가
1132
+ • 남주: 다미안(24) - 버려진 서자 왕자, 숨겨진 흑막
1133
+ • 악역: 황태자 세바스찬(26) - 전생의 배신자
1134
+
1135
+ 📝 **작품소개:**
1136
+ 독살당해 회귀한 황녀, 이번엔 다르게 살겠다. 모두가 무시하는 서자 왕자의 손을 잡았다. "저와 함께 제국을 뒤집으시겠습니까?" 하지만 그는 내가 아는 것보다 훨씬 위험한 남자였다."""
1137
+ ]
1138
+ },
1139
+ "판타지": {
1140
+ "themes": [
1141
+ """📖 **제목:** F급 헌터, SSS급 네크로맨서가 되다
1142
+
1143
+ 🌍 **설정:**
1144
+ 게이트와 던전이 출현한 지 10년 후의 한국. F급 헌터가 우연히 얻은 스킬로 죽은 보스 몬스터를 부활시켜 부리는 유일무이 네크로맨서가 된다.
1145
+
1146
+ 👥 **주요 캐릭터:**
1147
+ • 주인공: 김도현(24) - F급에서 SSS급 네크로맨서로 각성
1148
+ • 조력자: 리치 왕(???) - 첫 번째 언데드, 전설의 대마법사
1149
+ • 라이벌: 최강훈(26) - S급 길드 마스터, 주인공을 경계
1150
+
1151
+ 📝 **작품소개:**
1152
+ "F급 주제에 무슨 헛소리야?" 모두가 비웃었다. 하지만 첫 번째 보스를 쓰러뜨린 순간, 시스템 메시지가 떴다. [SSS급 히든 클래스: 네크로맨서 각성] 이제 죽은 보스들이 내 부하가 된다.""",
1153
+
1154
+ """📖 **제목:** 탑을 역주행하는 회귀자
1155
+
1156
+ 🌍 **설정:**
1157
+ 100층 탑 정상에서 죽은 후 튜토리얼로 회귀. 하지만 이번엔 100층부터 거꾸로 내려가며 모든 층을 정복하는 역주행 시스템이 열렸다.
1158
+
1159
+ 👥 **주요 캐릭터:**
1160
+ • 주인공: 이성진(28) - 유일한 역주행 회귀자
1161
+ • 조력자: 관리자(???) - 탑의 시스템 AI, 주인공에게 호의적
1162
+ • 라이벌: 성하윤(25) - 이번 회차 최강 신인
1163
+
1164
+ 📝 **작품소개:**
1165
+ 100층에서 죽었다. 눈을 떠보니 튜토리얼이었다. [역주행 시스템이 개방되었습니다] "뭐? 100층부터 시작한다고?" 최강자의 기억을 가진 채 정상에서부터 내려가는 전무후무한 공략이 시작된다."""
1166
+ ]
1167
+ },
1168
+ "현판": {
1169
+ "themes": [
1170
+ """📖 **제목:** 무능력자의 SSS급 아이템 제작
1171
+
1172
+ 🌍 **설정:**
1173
+ 게이트 출현 10년, 전 국민의 70%가 각성한 한국. 무능력자로 살던 주인공에게 갑자기 아이템 제작 시스템이 열린다.
1174
+
1175
+ 👥 **주요 캐릭터:**
1176
+ • 주인공: 박준서(25) - 무능력자에서 유일무이 아이템 제작사로
1177
+ • 의뢰인: 강하늘(27) - S급 헌터, 첫 번째 고객
1178
+ • 라이벌: 대기업 '아르테미스' - 아이템 독점 기업
1179
+
1180
+ 📝 **작품소개:**
1181
+ "각성 등급: 없음" 10년째 무능력자로 살았다. 그런데 오늘, 이상한 시스템 창이 떴다. [SSS급 생산직: 아이템 크래프터] 이제 내가 만든 아이템이 세계를 바꾼다.""",
1182
+
1183
+ """📖 **제목:** 헌터 사관학교의 숨겨진 최강자
1184
+
1185
+ 🌍 **설정:**
1186
+ 한국 최고의 헌터 사관학교. 입학시험 꼴찌로 들어온 주인공이 사실은 능력을 숨기고 있는 특급 요원.
1187
+
1188
+ 👥 **주요 캐릭터:**
1189
+ • 주인공: 윤시우(20) - 꼴찌로 위장한 특급 헌터
1190
+ • 히로인: 차유진(20) - 학년 수석, 재벌가 영애
1191
+ • 교관: 한태성(35) - 전설의 헌터, 주인공의 정체를 의심
1192
+
1193
+ 📝 **작품소개:**
1194
+ "측정 불가? 그럼 F급이네." 일부러 힘을 숨기고 꼴찌로 입학했다. 하지만 S급 게이트가 학교에 열리면서 정체를 숨길 수 없게 됐다. "너... 대체 누구야?"라는 물음에 어떻게 답해야 할까."""
1195
+ ]
1196
+ },
1197
+ "무협": {
1198
+ "themes": [
1199
+ """📖 **제목:** 천하제일문 폐급제자의 마교 비급
1200
+
1201
+ 🌍 **설정:**
1202
+ 정파 무림의 중원. 천하제일문의 폐급 막내제자가 우연히 마교 교주의 비급을 습득하고 정마를 아우르는 절대무공을 익힌다.
1203
+
1204
+ 👥 **주요 캐릭터:**
1205
+ • 주인공: 진천(18) - 폐급에서 절대고수로
1206
+ • 스승: 혈마노조(???) - 비급에 깃든 마교 전설
1207
+ • 라이벌: 남궁세가 소가주 - 정파 제일 천재
1208
+
1209
+ 📝 **작품소개:**
1210
+ "하찮은 것이 감히!" 모두가 무시하던 막내제자. 하지만 떨어진 절벽에서 발견한 것은 전설로만 전해지던 천마신공. "이제부터가 진짜 시작이다." 정파와 마교를 뒤흔들 폐급의 반란이 시작된다.""",
1211
+
1212
+ """📖 **제목:** 화산파 장문인으로 회귀하다
1213
+
1214
+ 🌍 **설정:**
1215
+ 100년 전 화산파가 최고 문파이던 시절로 회귀. 미래를 아는 장문인이 되어 문파를 지키고 무림을 재편한다.
1216
+
1217
+ 👥 **주요 캐릭터:**
1218
+ • 주인공: 청운진인(45→25) - 회귀한 화산파 장문인
1219
+ • 제자: 백무진(15) - 미래의 화산파 배신자
1220
+ • 맹우: 마교 성녀 - 전생의 적, 이생의 동료
1221
+
1222
+ 📝 **작품소개:**
1223
+ 멸문 직전에 회귀했다. 이번엔 다르다. "앞으로 화산파는 정파의 규율을 벗어난다." 미래를 아는 장문인의 파격적인 결정. 마교와 손잡고 무림의 판도를 뒤집는다."""
1224
+ ]
1225
+ },
1226
+ "미스터리": {
1227
+ "themes": [
1228
+ """📖 **제목:** 폐교에 갇힌 7명, 그리고 나
1229
+
1230
+ 🌍 **설정:**
1231
+ 폐쇄된 산골 학교, 동창회를 위해 모인 8명이 갇힌다. 하나씩 사라지는 동창들. 범인은 이 안에 있다.
1232
+
1233
+ 👥 **주요 캐릭터:**
1234
+ • 주인공: 서민준(28) - 프로파일러 출신 교사
1235
+ • 용의자1: 김태희(28) - 실종된 친구의 전 연인
1236
+ • 용의자2: 박진우(28) - 10년 전 사건의 목격자
1237
+
1238
+ 📝 **작품소개:**
1239
+ "10년 전 그날처럼..." 폐교에서 열린 동창회, 하지만 출구는 봉쇄됐다. 한 명씩 사라지는 친구들. 10년 전 묻어둔 비밀이 되살아난다. 살인자는 우리 중 한 명이다.""",
1240
+
1241
+ """📖 **제목:** 타임루프 속 연쇄살인마를 찾아라
1242
+
1243
+ 🌍 **설정:**
1244
+ 같은 하루가 반복되는 타임루프. 매번 다른 방법으로 살인이 일어나지만 범인은 동일인. 루프를 깨려면 범인을 찾아야 한다.
1245
+
1246
+ 👥 **주요 캐릭터:**
1247
+ • 주인공: 강해인(30) - 타임루프에 갇힌 형사
1248
+ • 희생자: 이수연(25) - 매번 죽는 카페 알바생
1249
+ • 용의자들: 카페 단골 5명 - 각자의 비밀을 숨기고 있음
1250
+
1251
+ 📝 **작품소개:**
1252
+ "또 오늘이야..." 49번째 같은 아침. 오후 3시 33분, 카페에서 살인이 일어난다. 범인을 잡아야 내일이 온다. 하지만 범인은 매번 완벽한 알리바이를 만든다. 과연 50번째 오늘은 다를까?"""
1253
+ ]
1254
+ },
1255
+ "라이트노벨": {
1256
+ "themes": [
1257
+ """📖 **제목:** 내 여자친구가 사실은 마왕이었다
1258
+
1259
+ 🌍 **설정:**
1260
+ 평범한 고등학교, 하지만 학생과 교사 중 일부는 이세계에서 온 존재들. 주인공만 모르는 학교의 비밀.
1261
+
1262
+ 👥 **주요 캐릭터:**
1263
+ • 주인공: 김태양(17) - 평범한 고등학생(?)
1264
+ • 히로인: 루시퍼(17) - 마왕이자 여자친구
1265
+ • 라이벌: 미카엘(17) - 천사이자 학생회장
1266
+
1267
+ 📝 **작품소개:**
1268
+ "선배, 사실 저... 마왕이에요!" 1년째 사귄 여자친구의 충격 고백. 근데 학생회장은 천사고, 담임은 드래곤이라고? 평범한 줄 알았던 우리 학교의 정체가 밝혀진다. "그래서... 우리 헤어져야 해?"라고 묻자 그녀가 울기 시작했다.""",
1269
+
1270
+ """📖 **제목:** 게임 아이템이 현실에 떨어진다
1271
+
1272
+ 🌍 **설정:**
1273
+ 모바일 게임과 현실이 연동되기 시작한 세계. 게임에서 얻은 아이템이 현실에 나타나면서 벌어지는 학원 코미디.
1274
+
1275
+ 👥 **주요 캐릭터:**
1276
+ • 주인공: 박도윤(18) - 게임 폐인 고등학생
1277
+ • 히로인: 최서연(18) - 전교 1등, 의외로 게임 고수
1278
+ • 친구: 장민혁(18) - 현질 전사, 개그 담당
1279
+
1280
+ 📝 **작품소개:**
1281
+ "어? 이거 내 SSR 무기잖아?" 핸드폰 게임에서 뽑은 아이템이 책상 위에 나타났다. 문제는 학교에 몬스터도 나타나기 시작했다는 것. "야, 수능보다 레이드가 더 중요해진 것 같은데?"라며 웃는 친구들과 함께하는 좌충우돌 학원 판타지."""
1282
+ ]
1283
+ }
1284
+ }
1285
+
1286
+ genre_themes = templates.get(genre, templates["로맨스"])
1287
+ selected = random.choice(genre_themes["themes"])
1288
+
1289
+ return selected
1290
+
1291
+ def generate_theme_with_llm_only(genre: str, language: str) -> str:
1292
+ """Generate theme using only LLM when JSON is not available or has errors"""
1293
+ system = WebNovelSystem()
1294
+
1295
+ # Genre-specific prompts based on popular web novel trends
1296
+ genre_prompts = {
1297
+ "로맨스": {
1298
+ "elements": ["계약결혼", "재벌", "이혼", "첫사랑", "운명적 만남", "오해와 화해"],
1299
+ "hooks": ["기억상실", "정체 숨기기", "가짜 연인", "원나잇 후 재회"]
1300
+ },
1301
+ "로판": {
1302
+ "elements": ["빙의", "회귀", "악녀", "황녀", "공작", "원작 파괴"],
1303
+ "hooks": ["처형 직전", "파혼 선언", "독살 시도", "폐위 위기"]
1304
+ },
1305
+ "판타지": {
1306
+ "elements": ["시스템", "각성", "던전", "회귀", "탑 등반", "SSS급"],
1307
+ "hooks": ["F급에서 시작", "숨겨진 클래스", "유일무이 스킬", "죽음 후 각성"]
1308
+ },
1309
+ "현판": {
1310
+ "elements": ["헌터", "게이트", "각성자", "길드", "아이템", "랭킹"],
1311
+ "hooks": ["늦은 각성", "재능 재평가", "S급 게이트", "시스템 오류"]
1312
+ },
1313
+ "무협": {
1314
+ "elements": ["회귀", "천재", "마교", "비급", "복수", "환생"],
1315
+ "hooks": ["폐급에서 최강", "배신 후 각성", "숨겨진 혈통", "기연 획득"]
1316
+ },
1317
+ "미스터리": {
1318
+ "elements": ["탐정", "연쇄살인", "타임루프", "초능력", "과거의 비밀"],
1319
+ "hooks": ["밀실 살인", "예고 살인", "기억 조작", "시간 역행"]
1320
+ },
1321
+ "라이트노벨": {
1322
+ "elements": ["학원", "이세계", "히로인", "게임", "일상", "판타지"],
1323
+ "hooks": ["전학생 정체", "게임 현실화", "평행세계", "숨겨진 능력"]
1324
+ }
1325
+ }
1326
+
1327
+ genre_info = genre_prompts.get(genre, genre_prompts["로맨스"])
1328
+
1329
+ if language == "Korean":
1330
+ prompt = f"""한국 웹소설 {genre} 장르의 중독성 있는 작품을 기획하세요.
1331
+
1332
+ 다음 인기 요소들을 참고하세요:
1333
+ - 핵심 요소: {', '.join(genre_info['elements'])}
1334
+ - 인기 훅: {', '.join(genre_info['hooks'])}
1335
+
1336
+ 다음 형식으로 정확히 작성하세요:
1337
+
1338
+ 📖 **제목:**
1339
+ [매력적이고 기억하기 쉬운 제목]
1340
+
1341
+ 🌍 **설정:**
1342
+ [세계관과 배경을 3-4줄로 설명. 시대, 장소, 핵심 설정 포함]
1343
+
1344
+ 👥 **주요 캐릭터:**
1345
+ • 주인공: [이름(나이)] - [직업/신분, 핵심 특징]
1346
+ • 주요인물1: [이름(나이)] - [관계/역할, 특징]
1347
+ • 주요인물2: [이름(나이)] - [관계/역할, 특징]
1348
+
1349
+ 📝 **작품소개:**
1350
+ [3-4줄로 작품의 핵심 갈등과 매력을 소개. 첫 문장은 강한 훅으로 시작하고, 주인공의 목표와 장애물을 명확히 제시]"""
1351
+ else:
1352
+ prompt = f"""Generate an addictive Korean web novel for {genre} genre.
1353
+
1354
+ Reference these popular elements:
1355
+ - Core elements: {', '.join(genre_info['elements'])}
1356
+ - Popular hooks: {', '.join(genre_info['hooks'])}
1357
+
1358
+ Format exactly as follows:
1359
+
1360
+ 📖 **Title:**
1361
+ [Attractive and memorable title]
1362
+
1363
+ 🌍 **Setting:**
1364
+ [World and background in 3-4 lines. Include era, location, core settings]
1365
+
1366
+ 👥 **Main Characters:**
1367
+ • Protagonist: [Name(Age)] - [Job/Status, key traits]
1368
+ • Key Character 1: [Name(Age)] - [Relationship/Role, traits]
1369
+ • Key Character 2: [Name(Age)] - [Relationship/Role, traits]
1370
+
1371
+ 📝 **Synopsis:**
1372
+ [3-4 lines introducing core conflict and appeal. Start with strong hook, clearly present protagonist's goal and obstacles]"""
1373
+
1374
+ messages = [{"role": "user", "content": prompt}]
1375
+ generated_theme = system.call_llm_sync(messages, "writer", language)
1376
+
1377
+ return generated_theme
1378
+
1379
+ # --- UI functions ---
1380
+ def format_episodes_display(episodes: List[Dict], current_episode: int = 0) -> str:
1381
+ """Format episodes for display"""
1382
+ markdown = "## 📚 웹소설 연재 현황\n\n"
1383
+
1384
+ if not episodes:
1385
+ return markdown + "*아직 작성된 에피소드가 없습니다.*"
1386
+
1387
+ # Stats
1388
+ total_episodes = len(episodes)
1389
+ total_words = sum(ep.get('word_count', 0) for ep in episodes)
1390
+ avg_engagement = sum(ep.get('reader_engagement', 0) for ep in episodes) / len(episodes) if episodes else 0
1391
+
1392
+ markdown += f"**진행 상황:** {total_episodes} / {TARGET_EPISODES}화\n"
1393
+ markdown += f"**총 단어 수:** {total_words:,} / {TARGET_WORDS:,}\n"
1394
+ markdown += f"**평균 몰입도:** ⭐ {avg_engagement:.1f} / 10\n\n"
1395
+ markdown += "---\n\n"
1396
+
1397
+ # Episode list
1398
+ for ep in episodes[-5:]: # Show last 5 episodes
1399
+ ep_num = ep.get('episode_number', 0)
1400
+ word_count = ep.get('word_count', 0)
1401
+
1402
+ markdown += f"### 📖 {ep_num}화\n"
1403
+ markdown += f"*{word_count}단어*\n\n"
1404
+
1405
+ content = ep.get('content', '')
1406
+ if content:
1407
+ preview = content[:200] + "..." if len(content) > 200 else content
1408
+ markdown += f"{preview}\n\n"
1409
+
1410
+ hook = ep.get('hook', '')
1411
+ if hook:
1412
+ markdown += f"**🪝 후크:** *{hook}*\n\n"
1413
+
1414
+ markdown += "---\n\n"
1415
+
1416
+ return markdown
1417
+
1418
+ def format_webnovel_display(episodes: List[Dict], genre: str) -> str:
1419
+ """Format complete web novel for display"""
1420
+ if not episodes:
1421
+ return "아직 완성된 웹소설이 없습니다."
1422
+
1423
+ formatted = f"# 🎭 {genre} 웹소설\n\n"
1424
+
1425
+ # Novel stats
1426
+ total_words = sum(ep.get('word_count', 0) for ep in episodes)
1427
+ formatted += f"**총 {len(episodes)}화 완결 | {total_words:,}단어**\n\n"
1428
+ formatted += "---\n\n"
1429
+
1430
+ # Episodes
1431
+ for ep in episodes:
1432
+ ep_num = ep.get('episode_number', 0)
1433
+ content = ep.get('content', '')
1434
+
1435
+ # Check if content already has episode title
1436
+ lines = content.strip().split('\n')
1437
+ if lines and (f"{ep_num}화." in lines[0] or f"Episode {ep_num}." in lines[0]):
1438
+ # Use the existing title
1439
+ formatted += f"## {lines[0]}\n\n"
1440
+ # Use the rest as content
1441
+ actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:])
1442
+ formatted += f"{actual_content}\n\n"
1443
+ else:
1444
+ # No title found, use default
1445
+ formatted += f"## 제{ep_num}화\n\n"
1446
+ formatted += f"{content}\n\n"
1447
+
1448
+ if ep_num < len(episodes): # Not last episode
1449
+ formatted += "➡️ *다음 화에 계속...*\n\n"
1450
+
1451
+ formatted += "---\n\n"
1452
+
1453
+ return formatted
1454
+
1455
+ # --- Gradio interface ---
1456
+ def create_interface():
1457
+ with gr.Blocks(theme=gr.themes.Soft(), title="K-WebNovel Generator") as interface:
1458
+ gr.HTML("""
1459
+ <div style="text-align: center; margin-bottom: 2rem;">
1460
+ <h1 style="font-size: 3rem; margin-bottom: 1rem;">📚 K-WebNovel Generator</h1>
1461
+ <p style="font-size: 1.2rem;">한국형 웹소설 자동 생성 시스템</p>
1462
+ <p>장르별 맞춤형 40화 완결 웹소설을 생성합니다</p>
1463
+ </div>
1464
+ """)
1465
+
1466
+ # State
1467
+ current_session_id = gr.State(None)
1468
+
1469
+ with gr.Tab("✍️ 웹소설 쓰기"):
1470
+ with gr.Group():
1471
+ gr.Markdown("### 🎯 웹소설 설정")
1472
+
1473
+ with gr.Row():
1474
+ with gr.Column(scale=2):
1475
+ genre_select = gr.Radio(
1476
+ choices=list(WEBNOVEL_GENRES.keys()),
1477
+ value="로맨스",
1478
+ label="장르 선택",
1479
+ info="원하는 장르를 선택하세요"
1480
+ )
1481
+
1482
+ query_input = gr.Textbox(
1483
+ label="스토리 테마",
1484
+ placeholder="웹소설의 기본 설정이나 주제를 입력하세요...",
1485
+ lines=3
1486
+ )
1487
+
1488
+ with gr.Row():
1489
+ random_btn = gr.Button("🎲 랜덤 테마", variant="secondary")
1490
+ submit_btn = gr.Button("📝 연재 시작", variant="primary", size="lg")
1491
+
1492
+ with gr.Column(scale=1):
1493
+ language_select = gr.Radio(
1494
+ choices=["Korean", "English"],
1495
+ value="Korean",
1496
+ label="언어"
1497
+ )
1498
+
1499
+ gr.Markdown("""
1500
+ **장르별 특징:**
1501
+ - 로맨스: 달달한 사랑 이야기
1502
+ - 로판: 회귀/빙의 판타지
1503
+ - 판타지: 성장과 모험
1504
+ - 현판: 현대 배경 능력자
1505
+ - 무협: 무공과 강호
1506
+ - 미스터리: 추리와 반전
1507
+ - 라노벨: 가벼운 일상물
1508
+ """)
1509
+
1510
+ status_text = gr.Textbox(
1511
+ label="진행 상황",
1512
+ interactive=False,
1513
+ value="장르를 선택하고 테마를 입력하세요"
1514
+ )
1515
+
1516
+ # Output
1517
+ with gr.Row():
1518
+ with gr.Column():
1519
+ episodes_display = gr.Markdown("*연재 진행 상황이 여기에 표시됩니다*")
1520
+
1521
+ with gr.Column():
1522
+ novel_display = gr.Markdown("*완성된 웹소설이 여기에 표시됩니다*")
1523
+
1524
+ with gr.Row():
1525
+ download_format = gr.Radio(
1526
+ choices=["TXT", "DOCX"],
1527
+ value="TXT",
1528
+ label="다운로드 형식"
1529
+ )
1530
+ download_btn = gr.Button("📥 다운로드", variant="secondary")
1531
+
1532
+ download_file = gr.File(visible=False)
1533
+
1534
+ with gr.Tab("📚 테마 라이브러리"):
1535
+ gr.Markdown("### 인기 웹소설 테마")
1536
+
1537
+ library_genre = gr.Radio(
1538
+ choices=["전체"] + list(WEBNOVEL_GENRES.keys()),
1539
+ value="전체",
1540
+ label="장르 필터"
1541
+ )
1542
+
1543
+ theme_library = gr.HTML("<p>테마 라이브러리 로딩 중...</p>")
1544
+
1545
+ refresh_library_btn = gr.Button("🔄 새로고침")
1546
+
1547
+ # Event handlers
1548
+ def process_query(query, genre, language, session_id):
1549
+ system = WebNovelSystem()
1550
+ episodes = ""
1551
+ novel = ""
1552
+
1553
+ for ep_display, novel_display, status, new_session_id in system.process_webnovel_stream(query, genre, language, session_id):
1554
+ episodes = ep_display
1555
+ novel = novel_display
1556
+ yield episodes, novel, status, new_session_id
1557
+
1558
+ def handle_random_theme(genre, language):
1559
+ return generate_random_webnovel_theme(genre, language)
1560
+
1561
+ def handle_download(download_format, session_id, genre):
1562
+ """Handle download request"""
1563
+ if not session_id:
1564
+ return None
1565
+
1566
+ try:
1567
+ episodes = WebNovelDatabase.get_episodes(session_id)
1568
+ if not episodes:
1569
+ return None
1570
+
1571
+ # Get title from first episode or generate default
1572
+ title = f"{genre} 웹소설"
1573
+
1574
+ if download_format == "TXT":
1575
+ content = export_to_txt(episodes, genre, title)
1576
+
1577
+ # Save to temporary file
1578
+ with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
1579
+ suffix='.txt', delete=False) as f:
1580
+ f.write(content)
1581
+ return f.name
1582
+
1583
+ elif download_format == "DOCX":
1584
+ if not DOCX_AVAILABLE:
1585
+ gr.Warning("DOCX export requires python-docx library")
1586
+ return None
1587
+
1588
+ content = export_to_docx(episodes, genre, title)
1589
+
1590
+ # Save to temporary file
1591
+ with tempfile.NamedTemporaryFile(mode='wb', suffix='.docx',
1592
+ delete=False) as f:
1593
+ f.write(content)
1594
+ return f.name
1595
+
1596
+ except Exception as e:
1597
+ logger.error(f"Download error: {e}")
1598
+ gr.Warning(f"다운로드 중 오류 발생: {str(e)}")
1599
+ return None
1600
+
1601
+ # Connect events
1602
+ submit_btn.click(
1603
+ fn=process_query,
1604
+ inputs=[query_input, genre_select, language_select, current_session_id],
1605
+ outputs=[episodes_display, novel_display, status_text, current_session_id]
1606
+ )
1607
+
1608
+ random_btn.click(
1609
+ fn=handle_random_theme,
1610
+ inputs=[genre_select, language_select],
1611
+ outputs=[query_input]
1612
+ )
1613
+
1614
+ download_btn.click(
1615
+ fn=handle_download,
1616
+ inputs=[download_format, current_session_id, genre_select],
1617
+ outputs=[download_file]
1618
+ ).then(
1619
+ fn=lambda x: gr.update(visible=True) if x else gr.update(visible=False),
1620
+ inputs=[download_file],
1621
+ outputs=[download_file]
1622
+ )
1623
+
1624
+ # Examples
1625
+ gr.Examples(
1626
+ examples=[
1627
+ ["계약결혼한 재벌 3세와 평범한 회사원의 로맨스", "로맨스"],
1628
+ ["회귀한 천재 마법사의 복수극", "로판"],
1629
+ ["F급 헌터에서 SSS급 각성자가 되는 이야기", "현판"],
1630
+ ["폐급에서 천하제일이 되는 무공 천재", "무협"],
1631
+ ["평범한 고등학생이 이세계 용사가 되는 이야기", "라이트노벨"]
1632
+ ],
1633
+ inputs=[query_input, genre_select]
1634
+ )
1635
+
1636
+ return interface
1637
+
1638
+ # Main
1639
+ if __name__ == "__main__":
1640
+ logger.info("K-WebNovel Generator Starting...")
1641
+ logger.info("=" * 60)
1642
+
1643
+ # Environment check
1644
+ logger.info(f"API Endpoint: {API_URL}")
1645
+ logger.info(f"Target: {TARGET_EPISODES} episodes, {TARGET_WORDS:,} words")
1646
+ logger.info("Genres: " + ", ".join(WEBNOVEL_GENRES.keys()))
1647
+
1648
+ logger.info("=" * 60)
1649
+
1650
+ # Initialize database
1651
+ logger.info("Initializing database...")
1652
+ WebNovelDatabase.init_db()
1653
+ logger.info("Database ready.")
1654
+
1655
+ # Launch interface
1656
+ interface = create_interface()
1657
+ interface.launch(
1658
+ server_name="0.0.0.0",
1659
+ server_port=7860,
1660
+ share=False
1661
+ )