Spaces:
Running
Running
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 --- | |
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) | |
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""" | |
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() | |
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() | |
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 | |
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() | |
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] | |
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(""" | |
<style> | |
.main-header { | |
text-align: center; | |
margin-bottom: 2rem; | |
} | |
.header-title { | |
font-size: 3rem; | |
margin-bottom: 1rem; | |
} | |
.header-subtitle { | |
font-size: 1.2rem; | |
margin-bottom: 0.5rem; | |
} | |
.header-description { | |
margin-bottom: 1.5rem; | |
} | |
.badges-container { | |
display: flex; | |
justify-content: center; | |
align-items: center; | |
gap: 8px; | |
flex-wrap: wrap; | |
margin-top: 12px; | |
} | |
.badges-container a img { | |
height: 28px; | |
transition: transform 0.2s ease; | |
} | |
.badges-container a:hover img { | |
transform: scale(1.05); | |
} | |
@media (max-width: 768px) { | |
.header-title { | |
font-size: 2.5rem; | |
} | |
.header-subtitle { | |
font-size: 1.1rem; | |
} | |
.badges-container { | |
gap: 6px; | |
} | |
.badges-container a img { | |
height: 24px; | |
} | |
} | |
</style> | |
<div class="main-header"> | |
<h1 class="header-title">๐ K-WebNovel Generator</h1> | |
<div class="badges-container"> | |
<a href="https://huggingface.co/spaces/fantaxy/AGI-LEADERBOARD" target="_blank"> | |
<img src="https://img.shields.io/static/v1?label=HF&message=AGI-LEADERBOARD&color=%23d4a574&labelColor=%238b6239&logo=HUGGINGFACE&logoColor=%23faf8f5&style=for-the-badge" alt="badge"> | |
</a> | |
<a href="https://huggingface.co/spaces/openfree/AGI-NOVEL" target="_blank"> | |
<img src="https://img.shields.io/static/v1?label=HF&message=AGI-NOVEL&color=%23d4a574&labelColor=%235a3e28&logo=huggingface&logoColor=%23faf8f5&style=for-the-badge" alt="badge"> | |
</a> | |
<a href="https://huggingface.co/spaces/openfree/AGI-Screenplay" target="_blank"> | |
<img src="https://img.shields.io/static/v1?label=HF&message=AGI-Screenplay&color=%23b8956f&labelColor=%23745940&logo=HUGGINGFACE&logoColor=%23faf8f5&style=for-the-badge" alt="badge"> | |
</a> | |
<a href="https://huggingface.co/spaces/openfree/AGI-WebNovel" target="_blank"> | |
<img src="https://img.shields.io/static/v1?label=HF&message=AGI-WebNovel&color=%23c7a679&labelColor=%236b5036&logo=HUGGINGFACE&logoColor=%23faf8f5&style=for-the-badge" alt="badge"> | |
</a> | |
<a href="https://discord.gg/openfreeai" target="_blank"> | |
<img src="https://img.shields.io/static/v1?label=Discord&message=Openfree%20AI&color=%23c19656&labelColor=%236d4e31&logo=discord&logoColor=white&style=for-the-badge" alt="badge"> | |
</a> | |
</div> | |
<p class="header-subtitle">ํ๊ตญํ ์น์์ค ์๋ ์์ฑ ์์คํ </p> | |
<p class="header-description">์ฅ๋ฅด๋ณ ๋ง์ถคํ 40ํ ์๊ฒฐ ์น์์ค์ ์์ฑํฉ๋๋ค</p> | |
</div> | |
""") | |
# 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("<p>ํ ๋ง ๋ผ์ด๋ธ๋ฌ๋ฆฌ ๋ก๋ฉ ์ค...</p>") | |
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 | |
) |