📚 K-WebNovel Generator
한국형 웹소설 자동 생성 시스템
장르별 맞춤형 40화 완결 웹소설을 생성합니다
import gradio as gr import os import json import requests from datetime import datetime import time from typing import List, Dict, Any, Generator, Tuple, Optional, Set import logging import re import tempfile from pathlib import Path import sqlite3 import hashlib import threading from contextlib import contextmanager from dataclasses import dataclass, field, asdict from collections import defaultdict import random from huggingface_hub import HfApi, upload_file, hf_hub_download # --- Logging setup --- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) # --- Document export imports --- try: from docx import Document from docx.shared import Inches, Pt, RGBColor, Mm from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.style import WD_STYLE_TYPE from docx.oxml.ns import qn from docx.oxml import OxmlElement DOCX_AVAILABLE = True except ImportError: DOCX_AVAILABLE = False logger.warning("python-docx not installed. DOCX export will be disabled.") import io # Add io import for DOCX export # --- Environment variables and constants --- FRIENDLI_TOKEN = os.getenv("FRIENDLI_TOKEN", "") BRAVE_SEARCH_API_KEY = os.getenv("BRAVE_SEARCH_API_KEY", "") API_URL = "https://api.friendli.ai/dedicated/v1/chat/completions" MODEL_ID = "dep86pjolcjjnv8" DB_PATH = "webnovel_sessions_v1.db" # Target settings for web novel - UPDATED FOR LONGER EPISODES TARGET_EPISODES = 40 # 40화 완결 WORDS_PER_EPISODE = 400 # 각 화당 400-600 단어 (기존 200-300에서 증가) TARGET_WORDS = TARGET_EPISODES * WORDS_PER_EPISODE # 총 16000 단어 # Web novel genres WEBNOVEL_GENRES = { "로맨스": "Romance", "로판": "Romance Fantasy", "판타지": "Fantasy", "현판": "Modern Fantasy", "무협": "Martial Arts", "미스터리": "Mystery", "라이트노벨": "Light Novel" } # --- Environment validation --- if not FRIENDLI_TOKEN: logger.error("FRIENDLI_TOKEN not set. Application will not work properly.") FRIENDLI_TOKEN = "dummy_token_for_testing" if not BRAVE_SEARCH_API_KEY: logger.warning("BRAVE_SEARCH_API_KEY not set. Web search features will be disabled.") # --- Global variables --- db_lock = threading.Lock() # --- Data classes --- @dataclass class WebNovelBible: """Web novel story bible for maintaining consistency""" genre: str = "" title: str = "" characters: Dict[str, Dict[str, Any]] = field(default_factory=dict) settings: Dict[str, str] = field(default_factory=dict) plot_points: List[Dict[str, Any]] = field(default_factory=list) episode_hooks: Dict[int, str] = field(default_factory=dict) genre_elements: Dict[str, Any] = field(default_factory=dict) power_system: Dict[str, Any] = field(default_factory=dict) relationships: List[Dict[str, str]] = field(default_factory=list) @dataclass class EpisodeCritique: """Critique for each episode""" episode_number: int hook_effectiveness: float = 0.0 pacing_score: float = 0.0 genre_adherence: float = 0.0 character_consistency: List[str] = field(default_factory=list) reader_engagement: float = 0.0 required_fixes: List[str] = field(default_factory=list) # --- Genre-specific prompts and elements --- GENRE_ELEMENTS = { "로맨스": { "key_elements": ["감정선", "오해와 화해", "달콤한 순간", "질투", "고백"], "popular_tropes": ["계약연애", "재벌과 평민", "첫사랑 재회", "짝사랑", "삼각관계"], "must_have": ["심쿵 포인트", "달달한 대사", "감정 묘사", "스킨십", "해피엔딩"], "episode_structure": "감정의 롤러코스터, 매 화 끝 설렘 포인트" }, "로판": { "key_elements": ["회귀/빙의", "원작 지식", "운명 변경", "마법/검술", "신분 상승"], "popular_tropes": ["악녀가 되었다", "폐녀 각성", "계약결혼", "집착남주", "역하렘"], "must_have": ["차원이동 설정", "먼치킨 요소", "로맨스", "복수", "성장"], "episode_structure": "원작 전개 비틀기, 매 화 새로운 변수" }, "판타지": { "key_elements": ["마법체계", "레벨업", "던전", "길드", "모험"], "popular_tropes": ["회귀", "시스템", "먼치킨", "히든피스", "각성"], "must_have": ["성장 곡선", "전투씬", "세계관", "동료", "최종보스"], "episode_structure": "점진적 강해짐, 새로운 도전과 극복" }, "현판": { "key_elements": ["숨겨진 능력", "일상과 비일상", "도시 판타지", "능력자 사회", "각성"], "popular_tropes": ["헌터", "게이트", "길드", "랭킹", "아이템"], "must_have": ["현실감", "능력 각성", "사회 시스템", "액션", "성장"], "episode_structure": "일상 속 비일상 발견, 점진적 세계관 확장" }, "무협": { "key_elements": ["무공", "문파", "강호", "복수", "의협"], "popular_tropes": ["천재", "폐급에서 최강", "기연", "환생", "마교"], "must_have": ["무공 수련", "대결", "문파 설정", "경지", "최종 결전"], "episode_structure": "수련과 대결의 반복, 점진적 경지 상승" }, "미스터리": { "key_elements": ["단서", "추리", "반전", "서스펜스", "진실"], "popular_tropes": ["탐정", "연쇄 사건", "과거의 비밀", "복수극", "심리전"], "must_have": ["복선", "붉은 청어", "논리적 추리", "충격 반전", "해결"], "episode_structure": "단서의 점진적 공개, 긴장감 상승" }, "라이트노벨": { "key_elements": ["학원", "일상", "코미디", "모에", "배틀"], "popular_tropes": ["이세계", "하렘", "츤데레", "치트", "길드"], "must_have": ["가벼운 문체", "유머", "캐릭터성", "일러스트적 묘사", "왁자지껄"], "episode_structure": "에피소드 중심, 개그와 진지의 균형" } } # Episode hooks by genre EPISODE_HOOKS = { "로맨스": [ "그의 입술이 내 귀에 닿을 듯 가까워졌다.", "'사실... 너를 처음 본 순간부터...'", "그때, 예상치 못한 사람이 문을 열고 들어왔다.", "메시지를 확인한 순간, 심장이 멈출 것 같았다." ], "로판": [ "그 순간, 원작에는 없던 인물이 나타났다.", "'폐하, 계약을 파기하겠습니다.'", "검은 오라가 그를 감싸며 눈빛이 변했다.", "회귀 전에는 몰랐던 진실이 드러났다." ], "판타지": [ "[새로운 스킬을 획득했습니다!]", "던전 최심부에서 발견한 것은...", "'이건... SSS급 아이템이다!'", "시스템 창에 뜬 경고 메시지를 보고 경악했다." ], "현판": [ "평범한 학생인 줄 알았던 그의 눈이 붉게 빛났다.", "갑자기 하늘에 거대한 균열이 생겼다.", "'당신도... 능력자였군요.'", "핸드폰에 뜬 긴급 재난 문자를 보고 얼어붙었다." ], "무협": [ "그의 검에서 흘러나온 검기를 보고 모두가 경악했다.", "'이것이... 전설의 천마신공?!'", "피를 토하며 쓰러진 사부가 마지막으로 남긴 말은...", "그때, 하늘에서 한 줄기 빛이 내려왔다." ], "미스터리": [ "그리고 시체 옆에서 발견된 것은...", "'범인은 이 안에 있습니다.'", "일기장의 마지막 페이지를 넘기자...", "CCTV에 찍힌 그 순간, 모든 것이 뒤바뀌었다." ], "라이트노벨": [ "'선배! 사실 저... 마왕이에요!'", "전학생의 정체는 다름 아닌...", "그녀의 가방에서 떨어진 것을 보고 경악했다.", "'어라? 이거... 게임 아이템이 현실에?'" ] } # --- Core logic classes --- class WebNovelTracker: """Web novel narrative tracker""" def __init__(self): self.story_bible = WebNovelBible() self.episode_critiques: Dict[int, EpisodeCritique] = {} self.episodes: Dict[int, str] = {} self.total_word_count = 0 self.reader_engagement_curve: List[float] = [] def set_genre(self, genre: str): """Set the novel genre""" self.story_bible.genre = genre self.story_bible.genre_elements = GENRE_ELEMENTS.get(genre, {}) def add_episode(self, episode_num: int, content: str, hook: str): """Add episode content""" self.episodes[episode_num] = content self.story_bible.episode_hooks[episode_num] = hook self.total_word_count = sum(len(ep.split()) for ep in self.episodes.values()) def add_episode_critique(self, episode_num: int, critique: EpisodeCritique): """Add episode critique""" self.episode_critiques[episode_num] = critique self.reader_engagement_curve.append(critique.reader_engagement) class WebNovelDatabase: """Database management for web novel system""" @staticmethod def init_db(): with sqlite3.connect(DB_PATH) as conn: conn.execute("PRAGMA journal_mode=WAL") cursor = conn.cursor() # Sessions table with genre cursor.execute(''' CREATE TABLE IF NOT EXISTS sessions ( session_id TEXT PRIMARY KEY, user_query TEXT NOT NULL, genre TEXT NOT NULL, language TEXT NOT NULL, title TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')), status TEXT DEFAULT 'active', current_episode INTEGER DEFAULT 0, total_episodes INTEGER DEFAULT 40, final_novel TEXT, reader_report TEXT, total_words INTEGER DEFAULT 0, story_bible TEXT, engagement_score REAL DEFAULT 0.0 ) ''') # Episodes table cursor.execute(''' CREATE TABLE IF NOT EXISTS episodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, episode_number INTEGER NOT NULL, content TEXT, hook TEXT, word_count INTEGER DEFAULT 0, reader_engagement REAL DEFAULT 0.0, status TEXT DEFAULT 'pending', created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (session_id) REFERENCES sessions(session_id), UNIQUE(session_id, episode_number) ) ''') # Episode critiques table cursor.execute(''' CREATE TABLE IF NOT EXISTS episode_critiques ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, episode_number INTEGER NOT NULL, critique_data TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) ''') # Random themes library with genre cursor.execute(''' CREATE TABLE IF NOT EXISTS webnovel_themes ( theme_id TEXT PRIMARY KEY, genre TEXT NOT NULL, theme_text TEXT NOT NULL, language TEXT NOT NULL, title TEXT, protagonist TEXT, setting TEXT, hook TEXT, generated_at TEXT DEFAULT (datetime('now')), use_count INTEGER DEFAULT 0, rating REAL DEFAULT 0.0, tags TEXT ) ''') conn.commit() @staticmethod @contextmanager def get_db(): with db_lock: conn = sqlite3.connect(DB_PATH, timeout=30.0) conn.row_factory = sqlite3.Row try: yield conn finally: conn.close() @staticmethod def create_session(user_query: str, genre: str, language: str) -> str: session_id = hashlib.md5(f"{user_query}{genre}{datetime.now()}".encode()).hexdigest() with WebNovelDatabase.get_db() as conn: conn.cursor().execute( '''INSERT INTO sessions (session_id, user_query, genre, language) VALUES (?, ?, ?, ?)''', (session_id, user_query, genre, language) ) conn.commit() return session_id @staticmethod def save_episode(session_id: str, episode_num: int, content: str, hook: str, engagement: float = 0.0): word_count = len(content.split()) if content else 0 with WebNovelDatabase.get_db() as conn: cursor = conn.cursor() cursor.execute(''' INSERT INTO episodes (session_id, episode_number, content, hook, word_count, reader_engagement, status) VALUES (?, ?, ?, ?, ?, ?, 'complete') ON CONFLICT(session_id, episode_number) DO UPDATE SET content=?, hook=?, word_count=?, reader_engagement=?, status='complete' ''', (session_id, episode_num, content, hook, word_count, engagement, content, hook, word_count, engagement)) # Update session progress cursor.execute(''' UPDATE sessions SET current_episode = ?, total_words = ( SELECT SUM(word_count) FROM episodes WHERE session_id = ? ), updated_at = datetime('now') WHERE session_id = ? ''', (episode_num, session_id, session_id)) conn.commit() @staticmethod def get_episodes(session_id: str) -> List[Dict]: with WebNovelDatabase.get_db() as conn: rows = conn.cursor().execute( '''SELECT * FROM episodes WHERE session_id = ? ORDER BY episode_number''', (session_id,) ).fetchall() return [dict(row) for row in rows] @staticmethod def save_webnovel_theme(genre: str, theme_text: str, language: str, metadata: Dict[str, Any]) -> str: theme_id = hashlib.md5(f"{genre}{theme_text}{datetime.now()}".encode()).hexdigest()[:12] with WebNovelDatabase.get_db() as conn: conn.cursor().execute(''' INSERT INTO webnovel_themes (theme_id, genre, theme_text, language, title, protagonist, setting, hook, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ''', (theme_id, genre, theme_text, language, metadata.get('title', ''), metadata.get('protagonist', ''), metadata.get('setting', ''), metadata.get('hook', ''), json.dumps(metadata.get('tags', [])))) conn.commit() return theme_id # --- LLM Integration --- class WebNovelSystem: """Web novel generation system""" def __init__(self): self.token = FRIENDLI_TOKEN self.api_url = API_URL self.model_id = MODEL_ID self.tracker = WebNovelTracker() self.current_session_id = None WebNovelDatabase.init_db() def create_headers(self): return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"} # --- Prompt generation functions --- def create_planning_prompt(self, query: str, genre: str, language: str) -> str: """Create initial planning prompt for web novel""" genre_info = GENRE_ELEMENTS.get(genre, {}) lang_prompts = { "Korean": f"""한국 웹소설 시장을 겨냥한 {genre} 장르 웹소설을 기획하세요. **[핵심 스토리 설정 - 반드시 이 내용을 중심으로 전개하세요]** {query} **장르:** {genre} **목표:** 40화 완결, 총 16,000단어 ⚠️ **중요**: 위에 제시된 스토리 설정을 반드시 기반으로 하여 플롯을 구성하세요. 이 설정이 전체 이야기의 핵심이며, 모든 에피소드는 이 설정을 중심으로 전개되어야 합니다. **장르 필수 요소 (스토리 설정과 조화롭게 포함):** - 핵심 요소: {', '.join(genre_info.get('key_elements', []))} - 인기 트로프: {', '.join(genre_info.get('popular_tropes', []))} - 필수 포함: {', '.join(genre_info.get('must_have', []))} **전체 구성 (입력된 스토리 설정을 기반으로):** 1. **1-5화**: 제시된 설정의 주인공과 상황 소개, 핵심 갈등 제시 2. **6-15화**: 설정에서 제시된 갈등의 심화, 관계 발전 3. **16-25화**: 설정과 관련된 중요한 반전, 새로운 진실 발견 4. **26-35화**: 설정의 핵심 갈등이 최고조에 이르기 5. **36-40화**: 설정에서 시작된 모든 이야기의 대단원 **각 화 구성 원칙:** - 400-600단어 분량 (충실한 내용) - 입력된 스토리 설정에 충실한 전개 - 매 화 끝 강력한 후크 - 빠른 전개와 몰입감 입력된 스토리 설정을 중심으로 구체적인 40화 플롯라인을 제시하세요. 각 화마다 핵심 사건과 전개를 명시하세요.""", "English": f"""Plan a Korean-style web novel for {genre} genre. **[Core Story Setting - MUST base the story on this]** {query} **Genre:** {genre} **Goal:** 40 episodes, total 16,000 words ⚠️ **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. **Genre Requirements (incorporate harmoniously with story setting):** - Key elements: {', '.join(genre_info.get('key_elements', []))} - Popular tropes: {', '.join(genre_info.get('popular_tropes', []))} - Must include: {', '.join(genre_info.get('must_have', []))} **Overall Structure (based on the input story setting):** 1. **Episodes 1-5**: Introduce protagonist and situation from the setting, present core conflict 2. **Episodes 6-15**: Deepen conflicts from the setting, develop relationships 3. **Episodes 16-25**: Major twist related to the setting, new revelations 4. **Episodes 26-35**: Core conflicts from the setting reach climax 5. **Episodes 36-40**: Resolution of all storylines started from the setting **Episode Principles:** - 400-600 words each (substantial content) - Faithful development of the input story setting - Strong hook at episode end - Fast pacing and immersion Provide detailed 40-episode plotline centered on the input story setting. Specify key events for each episode.""" } return lang_prompts.get(language, lang_prompts["Korean"]) def create_episode_prompt(self, episode_num: int, plot_outline: str, previous_content: str, genre: str, language: str) -> str: """Create prompt for individual episode - UPDATED FOR LONGER CONTENT""" genre_info = GENRE_ELEMENTS.get(genre, {}) hooks = EPISODE_HOOKS.get(genre, ["다음 순간, 충격적인 일이..."]) lang_prompts = { "Korean": f"""웹소설 {episode_num}화를 작성하세요. **장르:** {genre} **분량:** 400-600단어 (엄격히 준수 - 충실한 내용으로) **전체 플롯에서 {episode_num}화 내용:** {self._extract_episode_plan(plot_outline, episode_num)} **이전 내용 요약:** {previous_content[-1500:] if previous_content else "첫 화입니다"} **작성 형식:** 반드시 다음 형식으로 시작하세요: {episode_num}화. [이번 화의 핵심을 담은 매력적인 소제목] (한 줄 띄우고 본문 시작) **작성 지침:** 1. **구성**: 3-4개의 주요 장면으로 구성 - 도입부: 이전 화 연결 및 현재 상황 - 전개부: 2-3개의 핵심 사건/대화 - 클라이맥스: 긴장감 최고조 - 후크: 다음 화 예고 2. **필수 요소:** - 생생한 대화와 행동 묘사 - 캐릭터 감정과 내면 갈등 - 장면 전환과 템포 조절 - 독자 몰입을 위한 감각적 묘사 3. **장르별 특색:** - {genre_info.get('episode_structure', '빠른 전개')} - 핵심 요소 1개 이상 포함 4. **분량 배분:** - 도입 (50-80단어) - 주요 전개 (250-350단어) - 클라이맥스와 후크 (100-150단어) **참고 후크 예시:** {random.choice(hooks)} 소제목은 이번 화의 핵심 사건이나 전환점을 암시하는 매력적인 문구로 작성하세요. {episode_num}화를 풍성하고 몰입감 있게 작성하세요. 반드시 400-600단어로 작성하세요.""", "English": f"""Write episode {episode_num} of the web novel. **Genre:** {genre} **Length:** 400-600 words (strict - with substantial content) **Episode {episode_num} from plot:** {self._extract_episode_plan(plot_outline, episode_num)} **Previous content:** {previous_content[-1500:] if previous_content else "First episode"} **Format:** Must start with: Episode {episode_num}. [Attractive subtitle that captures the essence of this episode] (blank line then start main text) **Guidelines:** 1. **Structure**: 3-4 major scenes - Opening: Connect from previous, current situation - Development: 2-3 key events/dialogues - Climax: Peak tension - Hook: Next episode teaser 2. **Essential elements:** - Vivid dialogue and action - Character emotions and conflicts - Scene transitions and pacing - Sensory details for immersion 3. **Genre specifics:** - {genre_info.get('episode_structure', 'Fast pacing')} - Include at least 1 core element 4. **Word distribution:** - Opening (50-80 words) - Main development (250-350 words) - Climax and hook (100-150 words) **Hook example:** {random.choice(hooks)} Create an attractive subtitle that hints at key events or turning points. Write rich, immersive episode {episode_num}. Must be 400-600 words.""" } return lang_prompts.get(language, lang_prompts["Korean"]) def create_episode_critique_prompt(self, episode_num: int, content: str, genre: str, language: str) -> str: """Create critique prompt for episode""" lang_prompts = { "Korean": f"""{genre} 웹소설 {episode_num}화를 평가하세요. **작성된 내용:** {content} **평가 기준:** 1. **후크 효과성 (25점)**: 다음 화를 읽고 싶게 만드는가? 2. **페이싱 (25점)**: 전개 속도가 적절한가? 3. **장르 적합성 (25점)**: {genre} 장르 관습을 잘 따르는가? 4. **독자 몰입도 (25점)**: 감정적으로 빠져들게 하는가? **점수: /100점** 구체적인 개선점을 제시하세요.""", "English": f"""Evaluate {genre} web novel episode {episode_num}. **Written content:** {content} **Evaluation criteria:** 1. **Hook effectiveness (25pts)**: Makes readers want next episode? 2. **Pacing (25pts)**: Appropriate development speed? 3. **Genre fit (25pts)**: Follows {genre} conventions? 4. **Reader engagement (25pts)**: Emotionally immersive? **Score: /100 points** Provide specific improvements.""" } return lang_prompts.get(language, lang_prompts["Korean"]) def _extract_episode_plan(self, plot_outline: str, episode_num: int) -> str: """Extract specific episode plan from outline""" lines = plot_outline.split('\n') episode_section = [] capturing = False patterns = [ f"{episode_num}화:", f"Episode {episode_num}:", f"제{episode_num}화:", f"EP{episode_num}:" ] for line in lines: if any(pattern in line for pattern in patterns): capturing = True elif capturing and any(f"{episode_num+1}" in line for pattern in patterns): break elif capturing: episode_section.append(line) return '\n'.join(episode_section) if episode_section else "플롯을 참고하여 작성" # --- LLM call functions --- def call_llm_sync(self, messages: List[Dict[str, str]], role: str, language: str) -> str: full_content = "" for chunk in self.call_llm_streaming(messages, role, language): full_content += chunk if full_content.startswith("❌"): raise Exception(f"LLM Call Failed: {full_content}") return full_content def call_llm_streaming(self, messages: List[Dict[str, str]], role: str, language: str) -> Generator[str, None, None]: try: system_prompts = self.get_system_prompts(language) full_messages = [{"role": "system", "content": system_prompts.get(role, "")}, *messages] # Increased max_tokens for longer episodes max_tokens = 5000 if role == "writer" else 10000 payload = { "model": self.model_id, "messages": full_messages, "max_tokens": max_tokens, "temperature": 0.85, "top_p": 0.95, "presence_penalty": 0.3, "frequency_penalty": 0.3, "stream": True } response = requests.post( self.api_url, headers=self.create_headers(), json=payload, stream=True, timeout=180 ) if response.status_code != 200: yield f"❌ API Error (Status Code: {response.status_code})" return buffer = "" for line in response.iter_lines(): if not line: continue try: line_str = line.decode('utf-8').strip() if not line_str.startswith("data: "): continue data_str = line_str[6:] if data_str == "[DONE]": break data = json.loads(data_str) choices = data.get("choices", []) if choices and choices[0].get("delta", {}).get("content"): content = choices[0]["delta"]["content"] buffer += content if len(buffer) >= 50 or '\n' in buffer: yield buffer buffer = "" time.sleep(0.01) except Exception as e: logger.error(f"Chunk processing error: {str(e)}") continue if buffer: yield buffer except Exception as e: logger.error(f"Streaming error: {type(e).__name__}: {str(e)}") yield f"❌ Error occurred: {str(e)}" def get_system_prompts(self, language: str) -> Dict[str, str]: """System prompts for web novel roles - UPDATED FOR LONGER EPISODES""" base_prompts = { "Korean": { "planner": """당신은 한국 웹소설 시장을 완벽히 이해하는 기획자입니다. 독자를 중독시키는 플롯과 전개를 설계합니다. 장르별 관습과 독자 기대를 정확히 파악합니다. 40화 완결 구조로 완벽한 기승전결을 만듭니다. 각 화마다 충실한 내용과 전개를 계획합니다.""", "writer": """당신은 독자를 사로잡는 웹소설 작가입니다. 풍부하고 몰입감 있는 문체를 구사합니다. 각 화를 400-600단어로 충실하게 작성합니다. 여러 장면과 전환을 통해 이야기를 전개합니다. 대화, 행동, 내면 묘사를 균형있게 배치합니다. 매 화 끝에 강력한 후크로 다음 화를 기다리게 만듭니다.""", "critic": """당신은 웹소설 독자의 마음을 읽는 평론가입니다. 재미와 몰입감을 최우선으로 평가합니다. 장르적 쾌감과 독자 만족도를 분석합니다. 구체적이고 실용적인 개선안을 제시합니다.""" }, "English": { "planner": """You perfectly understand the Korean web novel market. Design addictive plots and developments. Accurately grasp genre conventions and reader expectations. Create perfect story structure in 40 episodes. Plan substantial content and development for each episode.""", "writer": """You are a web novelist who captivates readers. Use rich and immersive writing style. Write each episode with 400-600 words faithfully. Develop story through multiple scenes and transitions. Balance dialogue, action, and inner descriptions. End each episode with powerful hook for next.""", "critic": """You read web novel readers' minds. Prioritize fun and immersion in evaluation. Analyze genre satisfaction and reader enjoyment. Provide specific, practical improvements.""" } } return base_prompts.get(language, base_prompts["Korean"]) # --- Main process --- def process_webnovel_stream(self, query: str, genre: str, language: str, session_id: Optional[str] = None) -> Generator[Tuple[str, str, str, str], None, None]: """Web novel generation process""" try: resume_from_episode = 0 plot_outline = "" if session_id: self.current_session_id = session_id # Resume logic here else: self.current_session_id = WebNovelDatabase.create_session(query, genre, language) self.tracker.set_genre(genre) logger.info(f"Created new session: {self.current_session_id}") # Generate plot outline first if resume_from_episode == 0: yield "🎬 웹소설 플롯 구성 중...", "", f"장르: {genre}", self.current_session_id plot_prompt = self.create_planning_prompt(query, genre, language) plot_outline = self.call_llm_sync( [{"role": "user", "content": plot_prompt}], "planner", language ) yield "✅ 플롯 구성 완료!", "", f"40화 구성 완료", self.current_session_id # Generate episodes accumulated_content = "" for episode_num in range(resume_from_episode + 1, TARGET_EPISODES + 1): # Write episode yield f"✍️ {episode_num}화 집필 중...", accumulated_content, f"진행률: {episode_num}/{TARGET_EPISODES}화", self.current_session_id episode_prompt = self.create_episode_prompt( episode_num, plot_outline, accumulated_content, genre, language ) episode_content = self.call_llm_sync( [{"role": "user", "content": episode_prompt}], "writer", language ) # Extract episode title and content lines = episode_content.strip().split('\n') episode_title = "" actual_content = episode_content # Check if first line contains episode number and title if lines and (f"{episode_num}화." in lines[0] or f"Episode {episode_num}." in lines[0]): episode_title = lines[0] # Join the rest as content (excluding the title line and empty line after it) actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:]) else: # If no title format found, generate a default title episode_title = f"{episode_num}화. 제{episode_num}화" # Extract hook (last sentence) sentences = actual_content.split('.') hook = sentences[-2] + '.' if len(sentences) > 1 else sentences[-1] # Save episode with full content including title full_episode_content = f"{episode_title}\n\n{actual_content}" WebNovelDatabase.save_episode( self.current_session_id, episode_num, full_episode_content, hook ) # Add to accumulated content with title accumulated_content += f"\n\n### {episode_title}\n{actual_content}" # Quick critique every 5 episodes if episode_num % 5 == 0: critique_prompt = self.create_episode_critique_prompt( episode_num, episode_content, genre, language ) critique = self.call_llm_sync( [{"role": "user", "content": critique_prompt}], "critic", language ) yield f"✅ {episode_num}화 완료!", accumulated_content, f"진행률: {episode_num}/{TARGET_EPISODES}화", self.current_session_id # Complete total_words = len(accumulated_content.split()) yield f"🎉 웹소설 완성!", accumulated_content, f"총 {total_words:,}단어, {TARGET_EPISODES}화 완결", self.current_session_id except Exception as e: logger.error(f"Web novel generation error: {e}", exc_info=True) yield f"❌ 오류 발생: {e}", accumulated_content if 'accumulated_content' in locals() else "", "오류", self.current_session_id # --- Export functions --- def export_to_txt(episodes: List[Dict], genre: str, title: str = "") -> str: """Export web novel to TXT format""" content = f"{'=' * 50}\n" content += f"{title if title else genre + ' 웹소설'}\n" content += f"{'=' * 50}\n\n" content += f"총 {len(episodes)}화 완결\n" content += f"총 단어 수: {sum(ep.get('word_count', 0) for ep in episodes):,}\n" content += f"{'=' * 50}\n\n" for ep in episodes: ep_num = ep.get('episode_number', 0) ep_content = ep.get('content', '') # Content already includes title, so just add it content += f"\n{ep_content}\n" content += f"\n{'=' * 50}\n" return content def export_to_docx(episodes: List[Dict], genre: str, title: str = "") -> bytes: """Export web novel to DOCX format - matches screen display exactly""" if not DOCX_AVAILABLE: raise Exception("python-docx is not installed") doc = Document() # Title doc.add_heading(title if title else f"{genre} 웹소설", 0) # Stats doc.add_paragraph(f"총 {len(episodes)}화 완결") doc.add_paragraph(f"총 단어 수: {sum(ep.get('word_count', 0) for ep in episodes):,}") doc.add_page_break() # Episodes for idx, ep in enumerate(episodes): ep_num = ep.get('episode_number', 0) ep_content = ep.get('content', '') # Split content into lines lines = ep_content.strip().split('\n') # First line should be the title (e.g., "1화. 제목") if lines: # Add episode title as heading doc.add_heading(lines[0], 1) # Add the rest of the content content_lines = lines[1:] if len(lines) > 1 else [] # Skip empty lines at the beginning while content_lines and not content_lines[0].strip(): content_lines.pop(0) # Add content paragraphs for line in content_lines: if line.strip(): # Only add non-empty lines doc.add_paragraph(line.strip()) elif len(doc.paragraphs) > 0: # Add spacing between paragraphs doc.add_paragraph() # Add page break except for the last episode if idx < len(episodes) - 1: doc.add_page_break() # Save to bytes bytes_io = io.BytesIO() doc.save(bytes_io) bytes_io.seek(0) return bytes_io.getvalue() def generate_random_webnovel_theme(genre: str, language: str) -> str: """Generate random web novel theme using novel_themes.json and LLM""" try: # Load novel_themes.json with better error handling json_path = Path("novel_themes.json") if not json_path.exists(): logger.warning("novel_themes.json not found, using fallback") return generate_fallback_theme(genre, language) try: with open(json_path, 'r', encoding='utf-8') as f: content = f.read() # Remove comments from JSON (/* */ style) content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL) # Remove single line comments (// style) content = re.sub(r'//.*$', '', content, flags=re.MULTILINE) # Remove trailing commas before } or ] content = re.sub(r',\s*([}\]])', r'\1', content) # Handle all variations of placeholder patterns content = re.sub(r'\.\.\.\s*\(기존.*?\)\s*\.\.\.', '[]', content) content = re.sub(r'\.\.\.\(기존.*?\)\.\.\.', '[]', content) content = re.sub(r'\{ \.\.\. \(기존 그대로\) \}', '{}', content) content = re.sub(r'\{\s*\.\.\.\s*\(기존 그대로\)\s*\}', '{}', content) content = re.sub(r'\{ \.\.\. \}', '{}', content) content = re.sub(r'\{\s*\.\.\.\s*\}', '{}', content) content = re.sub(r'\[ \.\.\. \]', '[]', content) content = re.sub(r'\[\s*\.\.\.\s*\]', '[]', content) # Handle ellipsis in strings content = re.sub(r'"[^"]*\.\.\.[^"]*"', '""', content) # Debug: save cleaned JSON for inspection with open('novel_themes_cleaned.json', 'w', encoding='utf-8') as debug_file: debug_file.write(content) # Parse JSON themes_data = json.loads(content) logger.info("Successfully parsed novel_themes.json") except json.JSONDecodeError as e: logger.error(f"JSON parsing error at line {e.lineno}, column {e.colno}: {e.msg}") if hasattr(e, 'pos'): error_context = content[max(0, e.pos-100):e.pos+100] logger.error(f"Context around error: ...{error_context}...") # Save problematic content for debugging with open('novel_themes_error.json', 'w', encoding='utf-8') as error_file: error_file.write(content) return generate_theme_with_llm_only(genre, language) # Map genres to theme data - updated mapping genre_mapping = { "로맨스": ["romance_fantasy_villainess", "villainess_wants_to_be_lazy", "office_romance_rivals", "chaebol_family_intrigue"], "로판": ["romance_fantasy_villainess", "BL_novel_transmigration", "regression_childcare", "saeguk_court_intrigue"], "판타지": ["system_constellation_hunter", "tower_ascension_challenger", "necromancer_solo_leveling", "ai_dungeon_masters"], "현판": ["system_constellation_hunter", "chaebol_family_intrigue", "post_apocalypse_survival", "esports_king_prodigy", "vr_streamer_ranker"], "무협": ["regression_revenge_pro", "necromancer_solo_leveling", "exorcist_k_cult"], "미스터리": ["post_apocalypse_survival", "tower_ascension_challenger", "survival_reality_show"], "라이트노벨": ["BL_novel_transmigration", "villainess_wants_to_be_lazy", "vr_streamer_ranker", "healing_cafe_fantasy", "idol_regression_superstar"] } # Get relevant core genres for selected genre relevant_genres = genre_mapping.get(genre, ["regression_revenge_pro"]) # Filter out genres that might not exist in the JSON available_genres = [] core_genres = themes_data.get("core_genres", {}) # Debug log available genres logger.debug(f"Available core genres: {list(core_genres.keys())}") for genre_key in relevant_genres: if genre_key in core_genres: available_genres.append(genre_key) if not available_genres: logger.warning(f"No matching genres found for {genre}, available: {list(core_genres.keys())[:5]}...") # Try to use any available genre available_genres = list(core_genres.keys())[:3] selected_genre_key = random.choice(available_genres) logger.debug(f"Selected genre key: {selected_genre_key}") # Get genre data safely core_genre = core_genres.get(selected_genre_key, {}) compatible_elements = core_genre.get("compatible_elements", {}) # Select random elements with fallbacks character_keys = compatible_elements.get("characters", []) if not character_keys: # Get any available characters all_characters = list(themes_data.get("characters", {}).keys()) character_keys = all_characters[:4] if all_characters else ["betrayed_protagonist"] selected_character_key = random.choice(character_keys) if character_keys else "betrayed_protagonist" # Get character data safely characters_data = themes_data.get("characters", {}) character_data = characters_data.get(selected_character_key, {}) character_variations = character_data.get("variations", []) # Filter out empty or placeholder variations valid_variations = [v for v in character_variations if v and not v.startswith("...") and len(v) > 10] character_desc = random.choice(valid_variations) if valid_variations else "주인공은 특별한 운명을 타고났다." character_traits = character_data.get("traits", ["결단력", "성장형", "매력적"]) # Get settings safely settings = compatible_elements.get("settings", []) if not settings: # Try to get from general settings all_settings_categories = themes_data.get("settings", {}) for category_name, category_settings in all_settings_categories.items(): if isinstance(category_settings, list): valid_settings = [s for s in category_settings if s and not s.startswith("...") and len(s) > 5] settings.extend(valid_settings) selected_setting = random.choice(settings) if settings else "현대 도시" # Get mechanics safely mechanics_data = themes_data.get("core_mechanics", {}) mechanics_keys = [k for k in mechanics_data.keys() if k] selected_mechanic = random.choice(mechanics_keys) if mechanics_keys else "regression_loop_mastery" mechanic_info = mechanics_data.get(selected_mechanic, {}) plot_points = mechanic_info.get("plot_points", []) reader_questions = mechanic_info.get("reader_questions", []) # Filter valid plot points and questions valid_plot_points = [p for p in plot_points if p and not p.startswith("...") and len(p) > 10] valid_questions = [q for q in reader_questions if q and not q.startswith("...") and len(q) > 10] # Get hooks safely hooks_data = themes_data.get("episode_hooks", {}) hook_types = list(hooks_data.keys()) selected_hook_type = random.choice(hook_types) if hook_types else "introduction" hooks = hooks_data.get(selected_hook_type, []) valid_hooks = [h for h in hooks if h and not h.startswith("...") and len(h) > 10] selected_hook = random.choice(valid_hooks) if valid_hooks else "운명적인 만남이 시작되었다." # Get items/artifacts for certain genres selected_item = "" if genre in ["판타지", "현판", "무협"]: items_data = themes_data.get("key_items_and_artifacts", {}) item_categories = list(items_data.keys()) if item_categories: selected_category = random.choice(item_categories) items = items_data.get(selected_category, []) valid_items = [i for i in items if i and not i.startswith("...") and len(i) > 10] selected_item = random.choice(valid_items) if valid_items else "" # Get plot twists safely twists_data = themes_data.get("plot_twists_and_cliches", {}) twist_categories = list(twists_data.keys()) selected_twist = "" if twist_categories: selected_twist_cat = random.choice(twist_categories) twists = twists_data.get(selected_twist_cat, []) valid_twists = [t for t in twists if t and not t.startswith("...") and len(t) > 10] selected_twist = random.choice(valid_twists) if valid_twists else "" # Check for fusion genres fusion_genres = themes_data.get("fusion_genres", {}) fusion_options = [v for v in fusion_genres.values() if v and not v.startswith("...") and len(v) > 10] selected_fusion = random.choice(fusion_options) if fusion_options and random.random() > 0.7 else "" # Log selected elements for debugging logger.debug(f"Selected elements - Genre: {selected_genre_key}, Character: {selected_character_key}, Mechanic: {selected_mechanic}") # Now use LLM to create a coherent theme from these elements system = WebNovelSystem() # Create prompt for LLM if language == "Korean": prompt = f"""다음 요소들을 활용하여 {genre} 장르의 매력적인 웹소설을 기획하세요: 【선택된 요소들】 - 핵심 장르: {selected_genre_key.replace('_', ' ')} - 캐릭터: {character_desc} - 캐릭터 특성: {', '.join(character_traits[:3])} - 배경: {selected_setting} - 핵심 메커니즘: {selected_mechanic.replace('_', ' ')} {"- 아이템: " + selected_item if selected_item else ""} {"- 반전 요소: " + selected_twist if selected_twist else ""} {"- 퓨전 설정: " + selected_fusion if selected_fusion else ""} 【참고 훅】 {selected_hook} 【독자를 사로잡을 질문들】 {chr(10).join(valid_questions[:2]) if valid_questions else "독자의 호기심을 자극하는 질문들"} 다음 형식으로 정확히 작성하세요: 📖 **제목:** [매력적이고 기억에 남는 제목] 🌍 **설정:** [세계관과 배경 설정을 3-4줄로 설명] 👥 **주요 캐릭터:** • 주인공: [이름] - [간단한 설명] • 주요인물1: [이름] - [간단한 설명] • 주요인물2: [이름] - [간단한 설명] 📝 **작품소개:** [독자의 흥미를 끄는 3-4줄의 작품 소개. 주인공의 상황, 목표, 핵심 갈등을 포함]""" else: # English prompt = f"""Create an engaging web novel for {genre} genre using these elements: 【Selected Elements】 - Core genre: {selected_genre_key.replace('_', ' ')} - Character: {character_desc} - Character traits: {', '.join(character_traits[:3])} - Setting: {selected_setting} - Core mechanism: {selected_mechanic.replace('_', ' ')} {"- Item: " + selected_item if selected_item else ""} {"- Twist: " + selected_twist if selected_twist else ""} {"- Fusion: " + selected_fusion if selected_fusion else ""} 【Reference Hook】 {selected_hook} 【Questions to captivate readers】 {chr(10).join(valid_questions[:2]) if valid_questions else "Questions that spark reader curiosity"} Format exactly as follows: 📖 **Title:** [Attractive and memorable title] 🌍 **Setting:** [World and background setting in 3-4 lines] 👥 **Main Characters:** • Protagonist: [Name] - [Brief description] • Key Character 1: [Name] - [Brief description] • Key Character 2: [Name] - [Brief description] 📝 **Synopsis:** [3-4 lines that hook readers. Include protagonist's situation, goal, and core conflict]""" # Call LLM to generate theme messages = [{"role": "user", "content": prompt}] generated_theme = system.call_llm_sync(messages, "writer", language) logger.info("Successfully generated theme using JSON elements") return generated_theme except Exception as e: logger.error(f"Error generating theme from JSON: {e}", exc_info=True) return generate_fallback_theme(genre, language) def generate_fallback_theme(genre: str, language: str) -> str: """Fallback theme generator when JSON is not available""" templates = { "로맨스": { "themes": [ """📖 **제목:** 계약결혼 365일, 기억을 잃은 재벌 남편 🌍 **설정:** 현대 서울, 대기업 본사와 강남의 펜트하우스가 주 무대. 3개월 계약결혼 만료 직전, 남편이 교통사고로 기억을 잃고 아내를 첫사랑으로 착각하는 상황. 👥 **주요 캐릭터:** • 주인공: 서연우(28) - 평범한 회사원, 부모님 병원비를 위해 계약결혼 • 남주: 강준혁(32) - 냉혈 재벌 3세, 기억상실 후 순정남으로 변신 • 조연: 한소영(30) - 준혁의 전 약혼녀, 복수를 계획 중 📝 **작품소개:** "당신이 내 첫사랑이야." 이혼 서류에 도장을 찍으려던 순간, 교통사고를 당한 냉혈 재벌 남편이 나를 운명의 상대로 착각한다. 3개월간 연기했던 가짜 부부에서 진짜 사랑이 시작되는데...""", """📖 **제목:** 검사님, 이혼 소송은 제가 맡을게요 🌍 **설정:** 서울중앙지법과 검찰청이 주 무대. 냉혈 검사와 이혼 전문 변호사가 법정에서 대립하며 티격태격하는 법정 로맨스. 👥 **주요 캐릭터:** • 주인공: 오지원(30) - 승률 100% 이혼 전문 변호사 • 남주: 민시준(33) - 원칙주의 엘리트 검사 • 조연: 박세진(35) - 지원의 전 남편이자 시준의 선배 검사 📝 **작품소개:** "변호사님, 법정에서만 만나기로 했잖아요." 하필 전 남편의 불륜 소송을 맡은 날, 상대 검사가 나타났다. 법정에선 적, 밖에선 연인. 우리의 관계는 대체 뭘까?""" ] }, "로판": { "themes": [ """📖 **제목:** 악녀는 이번 생에서 도망친다 🌍 **설정:** 마법이 존재하는 제국, 1년 후 처형당할 운명의 악녀 공작 영애로 빙의. 북부 변방의 전쟁광 공작과의 계약결혼이 유일한 생존루트. 👥 **주요 캐릭터:** • 주인공: 아델라이드(20) - 빙의한 악녀, 원작 지식 보유 • 남주: 카시우스(25) - 북부의 전쟁광 공작, 숨겨진 순정남 • 악역: 황태자 레온(23) - 여주에게 집착하는 얀데레 📝 **작품소개:** 소설 속 악녀로 빙의했는데 이미 처형 선고를 받은 상태? 살려면 원작에 없던 북부 공작과 계약결혼해야 한다. "1년만 함께해주세요. 그 후엔 자유를 드리겠습니다." 하지만 계약 기간이 끝나도 그가 날 놓아주지 않는다.""", """📖 **제목:** 회귀한 황녀는 버려진 왕자를 택한다 🌍 **설정:** 제국력 892년으로 회귀한 황녀. 전생에서 자신을 배신한 황태자 대신, 버려진 서자 왕자와 손을 잡고 제국을 뒤집으려 한다. 👥 **주요 캐릭터:** • 주인공: 로젤린(22) - 회귀한 황녀, 미래를 아는 전략가 • 남주: 다미안(24) - 버려진 서자 왕자, 숨겨진 흑막 • 악역: 황태자 세바스찬(26) - 전생의 배신자 📝 **작품소개:** 독살당해 회귀한 황녀, 이번엔 다르게 살겠다. 모두가 무시하는 서자 왕자의 손을 잡았다. "저와 함께 제국을 뒤집으시겠습니까?" 하지만 그는 내가 아는 것보다 훨씬 위험한 남자였다.""" ] }, "판타지": { "themes": [ """📖 **제목:** F급 헌터, SSS급 네크로맨서가 되다 🌍 **설정:** 게이트와 던전이 출현한 지 10년 후의 한국. F급 헌터가 우연히 얻은 스킬로 죽은 보스 몬스터를 부활시켜 부리는 유일무이 네크로맨서가 된다. 👥 **주요 캐릭터:** • 주인공: 김도현(24) - F급에서 SSS급 네크로맨서로 각성 • 조력자: 리치 왕(???) - 첫 번째 언데드, 전설의 대마법사 • 라이벌: 최강훈(26) - S급 길드 마스터, 주인공을 경계 📝 **작품소개:** "F급 주제에 무슨 헛소리야?" 모두가 비웃었다. 하지만 첫 번째 보스를 쓰러뜨린 순간, 시스템 메시지가 떴다. [SSS급 히든 클래스: 네크로맨서 각성] 이제 죽은 보스들이 내 부하가 된다.""", """📖 **제목:** 탑을 역주행하는 회귀자 🌍 **설정:** 100층 탑 정상에서 죽은 후 튜토리얼로 회귀. 하지만 이번엔 100층부터 거꾸로 내려가며 모든 층을 정복하는 역주행 시스템이 열렸다. 👥 **주요 캐릭터:** • 주인공: 이성진(28) - 유일한 역주행 회귀자 • 조력자: 관리자(???) - 탑의 시스템 AI, 주인공에게 호의적 • 라이벌: 성하윤(25) - 이번 회차 최강 신인 📝 **작품소개:** 100층에서 죽었다. 눈을 떠보니 튜토리얼이었다. [역주행 시스템이 개방되었습니다] "뭐? 100층부터 시작한다고?" 최강자의 기억을 가진 채 정상에서부터 내려가는 전무후무한 공략이 시작된다.""" ] }, "현판": { "themes": [ """📖 **제목:** 무능력자의 SSS급 아이템 제작 🌍 **설정:** 게이트 출현 10년, 전 국민의 70%가 각성한 한국. 무능력자로 살던 주인공에게 갑자기 아이템 제작 시스템이 열린다. 👥 **주요 캐릭터:** • 주인공: 박준서(25) - 무능력자에서 유일무이 아이템 제작사로 • 의뢰인: 강하늘(27) - S급 헌터, 첫 번째 고객 • 라이벌: 대기업 '아르테미스' - 아이템 독점 기업 📝 **작품소개:** "각성 등급: 없음" 10년째 무능력자로 살았다. 그런데 오늘, 이상한 시스템 창이 떴다. [SSS급 생산직: 아이템 크래프터] 이제 내가 만든 아이템이 세계를 바꾼다.""", """📖 **제목:** 헌터 사관학교의 숨겨진 최강자 🌍 **설정:** 한국 최고의 헌터 사관학교. 입학시험 꼴찌로 들어온 주인공이 사실은 능력을 숨기고 있는 특급 요원. 👥 **주요 캐릭터:** • 주인공: 윤시우(20) - 꼴찌로 위장한 특급 헌터 • 히로인: 차유진(20) - 학년 수석, 재벌가 영애 • 교관: 한태성(35) - 전설의 헌터, 주인공의 정체를 의심 📝 **작품소개:** "측정 불가? 그럼 F급이네." 일부러 힘을 숨기고 꼴찌로 입학했다. 하지만 S급 게이트가 학교에 열리면서 정체를 숨길 수 없게 됐다. "너... 대체 누구야?"라는 물음에 어떻게 답해야 할까.""" ] }, "무협": { "themes": [ """📖 **제목:** 천하제일문 폐급제자의 마교 비급 🌍 **설정:** 정파 무림의 중원. 천하제일문의 폐급 막내제자가 우연히 마교 교주의 비급을 습득하고 정마를 아우르는 절대무공을 익힌다. 👥 **주요 캐릭터:** • 주인공: 진천(18) - 폐급에서 절대고수로 • 스승: 혈마노조(???) - 비급에 깃든 마교 전설 • 라이벌: 남궁세가 소가주 - 정파 제일 천재 📝 **작품소개:** "하찮은 것이 감히!" 모두가 무시하던 막내제자. 하지만 떨어진 절벽에서 발견한 것은 전설로만 전해지던 천마신공. "이제부터가 진짜 시작이다." 정파와 마교를 뒤흔들 폐급의 반란이 시작된다.""", """📖 **제목:** 화산파 장문인으로 회귀하다 🌍 **설정:** 100년 전 화산파가 최고 문파이던 시절로 회귀. 미래를 아는 장문인이 되어 문파를 지키고 무림을 재편한다. 👥 **주요 캐릭터:** • 주인공: 청운진인(45→25) - 회귀한 화산파 장문인 • 제자: 백무진(15) - 미래의 화산파 배신자 • 맹우: 마교 성녀 - 전생의 적, 이생의 동료 📝 **작품소개:** 멸문 직전에 회귀했다. 이번엔 다르다. "앞으로 화산파는 정파의 규율을 벗어난다." 미래를 아는 장문인의 파격적인 결정. 마교와 손잡고 무림의 판도를 뒤집는다.""" ] }, "미스터리": { "themes": [ """📖 **제목:** 폐교에 갇힌 7명, 그리고 나 🌍 **설정:** 폐쇄된 산골 학교, 동창회를 위해 모인 8명이 갇힌다. 하나씩 사라지는 동창들. 범인은 이 안에 있다. 👥 **주요 캐릭터:** • 주인공: 서민준(28) - 프로파일러 출신 교사 • 용의자1: 김태희(28) - 실종된 친구의 전 연인 • 용의자2: 박진우(28) - 10년 전 사건의 목격자 📝 **작품소개:** "10년 전 그날처럼..." 폐교에서 열린 동창회, 하지만 출구는 봉쇄됐다. 한 명씩 사라지는 친구들. 10년 전 묻어둔 비밀이 되살아난다. 살인자는 우리 중 한 명이다.""", """📖 **제목:** 타임루프 속 연쇄살인마를 찾아라 🌍 **설정:** 같은 하루가 반복되는 타임루프. 매번 다른 방법으로 살인이 일어나지만 범인은 동일인. 루프를 깨려면 범인을 찾아야 한다. 👥 **주요 캐릭터:** • 주인공: 강해인(30) - 타임루프에 갇힌 형사 • 희생자: 이수연(25) - 매번 죽는 카페 알바생 • 용의자들: 카페 단골 5명 - 각자의 비밀을 숨기고 있음 📝 **작품소개:** "또 오늘이야..." 49번째 같은 아침. 오후 3시 33분, 카페에서 살인이 일어난다. 범인을 잡아야 내일이 온다. 하지만 범인은 매번 완벽한 알리바이를 만든다. 과연 50번째 오늘은 다를까?""" ] }, "라이트노벨": { "themes": [ """📖 **제목:** 내 여자친구가 사실은 마왕이었다 🌍 **설정:** 평범한 고등학교, 하지만 학생과 교사 중 일부는 이세계에서 온 존재들. 주인공만 모르는 학교의 비밀. 👥 **주요 캐릭터:** • 주인공: 김태양(17) - 평범한 고등학생(?) • 히로인: 루시퍼(17) - 마왕이자 여자친구 • 라이벌: 미카엘(17) - 천사이자 학생회장 📝 **작품소개:** "선배, 사실 저... 마왕이에요!" 1년째 사귄 여자친구의 충격 고백. 근데 학생회장은 천사고, 담임은 드래곤이라고? 평범한 줄 알았던 우리 학교의 정체가 밝혀진다. "그래서... 우리 헤어져야 해?"라고 묻자 그녀가 울기 시작했다.""", """📖 **제목:** 게임 아이템이 현실에 떨어진다 🌍 **설정:** 모바일 게임과 현실이 연동되기 시작한 세계. 게임에서 얻은 아이템이 현실에 나타나면서 벌어지는 학원 코미디. 👥 **주요 캐릭터:** • 주인공: 박도윤(18) - 게임 폐인 고등학생 • 히로인: 최서연(18) - 전교 1등, 의외로 게임 고수 • 친구: 장민혁(18) - 현질 전사, 개그 담당 📝 **작품소개:** "어? 이거 내 SSR 무기잖아?" 핸드폰 게임에서 뽑은 아이템이 책상 위에 나타났다. 문제는 학교에 몬스터도 나타나기 시작했다는 것. "야, 수능보다 레이드가 더 중요해진 것 같은데?"라며 웃는 친구들과 함께하는 좌충우돌 학원 판타지.""" ] } } genre_themes = templates.get(genre, templates["로맨스"]) selected = random.choice(genre_themes["themes"]) return selected def generate_theme_with_llm_only(genre: str, language: str) -> str: """Generate theme using only LLM when JSON is not available or has errors""" system = WebNovelSystem() # Genre-specific prompts based on popular web novel trends genre_prompts = { "로맨스": { "elements": ["계약결혼", "재벌", "이혼", "첫사랑", "운명적 만남", "오해와 화해"], "hooks": ["기억상실", "정체 숨기기", "가짜 연인", "원나잇 후 재회"] }, "로판": { "elements": ["빙의", "회귀", "악녀", "황녀", "공작", "원작 파괴"], "hooks": ["처형 직전", "파혼 선언", "독살 시도", "폐위 위기"] }, "판타지": { "elements": ["시스템", "각성", "던전", "회귀", "탑 등반", "SSS급"], "hooks": ["F급에서 시작", "숨겨진 클래스", "유일무이 스킬", "죽음 후 각성"] }, "현판": { "elements": ["헌터", "게이트", "각성자", "길드", "아이템", "랭킹"], "hooks": ["늦은 각성", "재능 재평가", "S급 게이트", "시스템 오류"] }, "무협": { "elements": ["회귀", "천재", "마교", "비급", "복수", "환생"], "hooks": ["폐급에서 최강", "배신 후 각성", "숨겨진 혈통", "기연 획득"] }, "미스터리": { "elements": ["탐정", "연쇄살인", "타임루프", "초능력", "과거의 비밀"], "hooks": ["밀실 살인", "예고 살인", "기억 조작", "시간 역행"] }, "라이트노벨": { "elements": ["학원", "이세계", "히로인", "게임", "일상", "판타지"], "hooks": ["전학생 정체", "게임 현실화", "평행세계", "숨겨진 능력"] } } genre_info = genre_prompts.get(genre, genre_prompts["로맨스"]) if language == "Korean": prompt = f"""한국 웹소설 {genre} 장르의 중독성 있는 작품을 기획하세요. 다음 인기 요소들을 참고하세요: - 핵심 요소: {', '.join(genre_info['elements'])} - 인기 훅: {', '.join(genre_info['hooks'])} 다음 형식으로 정확히 작성하세요: 📖 **제목:** [매력적이고 기억하기 쉬운 제목] 🌍 **설정:** [세계관과 배경을 3-4줄로 설명. 시대, 장소, 핵심 설정 포함] 👥 **주요 캐릭터:** • 주인공: [이름(나이)] - [직업/신분, 핵심 특징] • 주요인물1: [이름(나이)] - [관계/역할, 특징] • 주요인물2: [이름(나이)] - [관계/역할, 특징] 📝 **작품소개:** [3-4줄로 작품의 핵심 갈등과 매력을 소개. 첫 문장은 강한 훅으로 시작하고, 주인공의 목표와 장애물을 명확히 제시]""" else: prompt = f"""Generate an addictive Korean web novel for {genre} genre. Reference these popular elements: - Core elements: {', '.join(genre_info['elements'])} - Popular hooks: {', '.join(genre_info['hooks'])} Format exactly as follows: 📖 **Title:** [Attractive and memorable title] 🌍 **Setting:** [World and background in 3-4 lines. Include era, location, core settings] 👥 **Main Characters:** • Protagonist: [Name(Age)] - [Job/Status, key traits] • Key Character 1: [Name(Age)] - [Relationship/Role, traits] • Key Character 2: [Name(Age)] - [Relationship/Role, traits] 📝 **Synopsis:** [3-4 lines introducing core conflict and appeal. Start with strong hook, clearly present protagonist's goal and obstacles]""" messages = [{"role": "user", "content": prompt}] generated_theme = system.call_llm_sync(messages, "writer", language) return generated_theme # --- UI functions --- def format_episodes_display(episodes: List[Dict], current_episode: int = 0) -> str: """Format episodes for display""" markdown = "## 📚 웹소설 연재 현황\n\n" if not episodes: return markdown + "*아직 작성된 에피소드가 없습니다.*" # Stats total_episodes = len(episodes) total_words = sum(ep.get('word_count', 0) for ep in episodes) avg_engagement = sum(ep.get('reader_engagement', 0) for ep in episodes) / len(episodes) if episodes else 0 markdown += f"**진행 상황:** {total_episodes} / {TARGET_EPISODES}화\n" markdown += f"**총 단어 수:** {total_words:,} / {TARGET_WORDS:,}\n" markdown += f"**평균 몰입도:** ⭐ {avg_engagement:.1f} / 10\n\n" markdown += "---\n\n" # Episode list for ep in episodes[-5:]: # Show last 5 episodes ep_num = ep.get('episode_number', 0) word_count = ep.get('word_count', 0) markdown += f"### 📖 {ep_num}화\n" markdown += f"*{word_count}단어*\n\n" content = ep.get('content', '') if content: preview = content[:200] + "..." if len(content) > 200 else content markdown += f"{preview}\n\n" hook = ep.get('hook', '') if hook: markdown += f"**🪝 후크:** *{hook}*\n\n" markdown += "---\n\n" return markdown def format_webnovel_display(episodes: List[Dict], genre: str) -> str: """Format complete web novel for display""" if not episodes: return "아직 완성된 웹소설이 없습니다." formatted = f"# 🎭 {genre} 웹소설\n\n" # Novel stats total_words = sum(ep.get('word_count', 0) for ep in episodes) formatted += f"**총 {len(episodes)}화 완결 | {total_words:,}단어**\n\n" formatted += "---\n\n" # Episodes for idx, ep in enumerate(episodes): ep_num = ep.get('episode_number', 0) content = ep.get('content', '') # Content already includes the title, so display as is formatted += f"## {content.split(chr(10))[0] if content else f'{ep_num}화'}\n\n" # Get the actual content (skip title and empty line) lines = content.split('\n') if len(lines) > 1: actual_content = '\n'.join(lines[2:] if len(lines) > 2 and lines[1].strip() == "" else lines[1:]) formatted += f"{actual_content}\n\n" if idx < len(episodes) - 1: # Not last episode formatted += "➡️ *다음 화에 계속...*\n\n" formatted += "---\n\n" return formatted # --- Gradio interface --- def create_interface(): with gr.Blocks(theme=gr.themes.Soft(), title="K-WebNovel Generator") as interface: gr.HTML("""
""") # State current_session_id = gr.State(None) with gr.Tab("✍️ 웹소설 쓰기"): with gr.Group(): gr.Markdown("### 🎯 웹소설 설정") with gr.Row(): with gr.Column(scale=2): genre_select = gr.Radio( choices=list(WEBNOVEL_GENRES.keys()), value="로맨스", label="장르 선택", info="원하는 장르를 선택하세요" ) query_input = gr.Textbox( label="스토리 테마", placeholder="웹소설의 기본 설정이나 주제를 입력하세요...", lines=3 ) with gr.Row(): random_btn = gr.Button("🎲 랜덤 테마", variant="secondary") submit_btn = gr.Button("📝 연재 시작", variant="primary", size="lg") with gr.Column(scale=1): language_select = gr.Radio( choices=["Korean", "English"], value="Korean", label="언어" ) gr.Markdown(""" **장르별 특징:** - 로맨스: 달달한 사랑 이야기 - 로판: 회귀/빙의 판타지 - 판타지: 성장과 모험 - 현판: 현대 배경 능력자 - 무협: 무공과 강호 - 미스터리: 추리와 반전 - 라노벨: 가벼운 일상물 """) status_text = gr.Textbox( label="진행 상황", interactive=False, value="장르를 선택하고 테마를 입력하세요" ) # Output with gr.Row(): with gr.Column(): episodes_display = gr.Markdown("*연재 진행 상황이 여기에 표시됩니다*") with gr.Column(): novel_display = gr.Markdown("*완성된 웹소설이 여기에 표시됩니다*") with gr.Row(): download_format = gr.Radio( choices=["TXT", "DOCX"], value="TXT", label="다운로드 형식" ) download_btn = gr.Button("📥 다운로드", variant="secondary") download_file = gr.File(visible=False) with gr.Tab("📚 테마 라이브러리"): gr.Markdown("### 인기 웹소설 테마") library_genre = gr.Radio( choices=["전체"] + list(WEBNOVEL_GENRES.keys()), value="전체", label="장르 필터" ) theme_library = gr.HTML("테마 라이브러리 로딩 중...
") refresh_library_btn = gr.Button("🔄 새로고침") # Event handlers def process_query(query, genre, language, session_id): system = WebNovelSystem() episodes = "" novel = "" for ep_display, novel_display, status, new_session_id in system.process_webnovel_stream(query, genre, language, session_id): episodes = ep_display novel = novel_display yield episodes, novel, status, new_session_id def handle_random_theme(genre, language): return generate_random_webnovel_theme(genre, language) def handle_download(download_format, session_id, genre): """Handle download request""" if not session_id: return None try: episodes = WebNovelDatabase.get_episodes(session_id) if not episodes: return None # Get title from first episode or generate default title = f"{genre} 웹소설" if download_format == "TXT": content = export_to_txt(episodes, genre, title) # Save to temporary file with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.txt', delete=False) as f: f.write(content) return f.name elif download_format == "DOCX": if not DOCX_AVAILABLE: gr.Warning("DOCX export requires python-docx library") return None content = export_to_docx(episodes, genre, title) # Save to temporary file with tempfile.NamedTemporaryFile(mode='wb', suffix='.docx', delete=False) as f: f.write(content) return f.name except Exception as e: logger.error(f"Download error: {e}") gr.Warning(f"다운로드 중 오류 발생: {str(e)}") return None # Connect events submit_btn.click( fn=process_query, inputs=[query_input, genre_select, language_select, current_session_id], outputs=[episodes_display, novel_display, status_text, current_session_id] ) random_btn.click( fn=handle_random_theme, inputs=[genre_select, language_select], outputs=[query_input] ) download_btn.click( fn=handle_download, inputs=[download_format, current_session_id, genre_select], outputs=[download_file] ).then( fn=lambda x: gr.update(visible=True) if x else gr.update(visible=False), inputs=[download_file], outputs=[download_file] ) # Examples gr.Examples( examples=[ ["계약결혼한 재벌 3세와 평범한 회사원의 로맨스", "로맨스"], ["회귀한 천재 마법사의 복수극", "로판"], ["F급 헌터에서 SSS급 각성자가 되는 이야기", "현판"], ["폐급에서 천하제일이 되는 무공 천재", "무협"], ["평범한 고등학생이 이세계 용사가 되는 이야기", "라이트노벨"] ], inputs=[query_input, genre_select] ) return interface # Main if __name__ == "__main__": logger.info("K-WebNovel Generator Starting...") logger.info("=" * 60) # Environment check logger.info(f"API Endpoint: {API_URL}") logger.info(f"Target: {TARGET_EPISODES} episodes, {TARGET_WORDS:,} words") logger.info("Genres: " + ", ".join(WEBNOVEL_GENRES.keys())) logger.info("=" * 60) # Initialize database logger.info("Initializing database...") WebNovelDatabase.init_db() logger.info("Database ready.") # Launch interface interface = create_interface() interface.launch( server_name="0.0.0.0", server_port=7860, share=False )