diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -1,2274 +1,35 @@ -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 +import sys +import streamlit as st +from tempfile import NamedTemporaryFile -# --- 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 - -# --- 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" - -# Hugging Face Spaces 환경에서 영속적 저장소 사용 -import os -if os.getenv("SPACE_ID"): # Hugging Face Spaces 환경인지 확인 - # Spaces의 영속적 저장소 경로 사용 - DB_PATH = "/data/webnovel_sessions_v2.db" - os.makedirs("/data", exist_ok=True) -else: - # 로컬 환경 - DB_PATH = "webnovel_sessions_v2.db" - -# Target settings for web novel -TARGET_EPISODES = 40 # 40화 완결 -WORDS_PER_EPISODE = 500 # 각 화당 500 단어 -TARGET_WORDS = TARGET_EPISODES * WORDS_PER_EPISODE # 총 20000 단어 - -# Web novel genres - from paste-2.txt -WEBNOVEL_GENRES = { - "로맨스": "Romance", - "로판": "Romance Fantasy", - "판타지": "Fantasy", - "현판": "Modern Fantasy", - "무협": "Martial Arts", - "미스터리": "Mystery", - "라이트노벨": "Light Novel" -} - -# Complete word list from paste.txt -complete_word_list = { - "캐릭터": [ - # 일상 직업 (40개) - "의사", "간호사", "교사", "학생", "회사원", "프로그래머", "디자이너", "요리사", "바리스타", "미용사", - "택시기사", "배달원", "경찰관", "소방관", "군인", "판사", "변호사", "기자", "작가", "화가", - "가수", "배우", "운동선수", "농부", "어부", "상인", "은행원", "부동산중개인", "청소부", "경비원", - "유튜버", "블로거", "사진작가", "번역가", "수의사", "약사", "건축가", "과학자", "엔지니어", "조종사", - - # 판타지/모험 (25개) - "기사", "마법사", "도둑", "해적", "모험가", "용사", "현자", "연금술사", "마녀", "드루이드", - "음유시인", "대장장이", "상인길드장", "왕", "여왕", "왕자", "공주", "귀족", "기사단장", "마법학교교장", - "보물사냥꾼", "지도제작자", "몬스터사냥꾼", "던전탐험가", "길드마스터", - - # 미스터리/범죄 (15개) - "탐정", "형사", "프로파일러", "법의학자", "스파이", "해커", "사기꾼", "목격자", "용의자", "정보원", - "밀수업자", "위조전문가", "금고털이범", "추리작가", "사립탐정", - - # 호러/초자연 (10개) - "퇴마사", "영매", "무당", "심령연구가", "뱀파이어", "늑대인간", "유령", "악마사냥꾼", "저주받은자", "예언자", - - # SF/미래 (10개) - "우주비행사", "로봇공학자", "AI개발자", "사이보그", "타임트래블러", "우주해적", "테라포머", "유전공학자", "홀로그램기술자", "양자물리학자" - ], - - "물건": [ - # 일상용품 (40개) - "열쇠", "지갑", "휴대폰", "안경", "시계", "가방", "우산", "신발", "모자", "스카프", - "책", "노트", "펜", "카메라", "이어폰", "노트북", "자전거", "자동차열쇠", "신분증", "신용카드", - "거울", "빗", "손수건", "라이터", "손전등", "가위", "테이프", "충전기", "마스크", "장갑", - "물병", "도시락", "약통", "화장품", "향수", "선글라스", "배낭", "여권", "티켓", "동전", - - # 특별한 물건 (25개) - "보물지도", "고대유물", "마법반지", "수정구슬", "비밀일기", "암호문서", "금화", "보석", "왕관", "검", - "마법지팡이", "물약", "주문서", "봉인된상자", "신비한열쇠", "고대서적", "나침반", "망원경", "모래시계", "오르골", - "부적", "메달", "인장", "두루마리", "신성한유물", - - # 현대 기술 (15개) - "드론", "VR헤드셋", "스마트워치", "태블릿", "무선이어폰", "액션캠", "전자책리더", "게임기", "스마트홈기기", "3D프린터", - "홀로그램장치", "AI스피커", "전기자동차키", "생체인식장치", "나노칩", - - # 증거/단서 (10개) - "편지", "사진", "녹음기", "혈흔", "지문", "탄피", "찢어진옷", "명함", "영수증", "메모", - - # 무기/도구 (10개) - "단검", "활", "방패", "폭탄", "함정", "밧줄", "자물쇠", "철사", "망치", "횃불" - ], - - "장소": [ - # 일상 장소 (35개) - "집", "학교", "회사", "병원", "은행", "마트", "카페", "레스토랑", "공원", "도서관", - "영화관", "헬스장", "미술관", "백화점", "지하철역", "버스정류장", "공항", "호텔", "편의점", "주차장", - "놀이터", "아파트", "사무실", "교실", "옥상", "지하실", "엘리베이터", "복도", "화장실", "주방", - "발코니", "정원", "차고", "창고", "다리", - - # 자연 장소 (15개) - "숲", "바다", "산", "강", "호수", "동굴", "절벽", "폭포", "계곡", "사막", - "섬", "해변", "들판", "늪지", "빙하", - - # 특별한 장소 (15개) - "성", "던전", "마법학교", "신전", "지하묘지", "고대유적", "미로", "탑", "감옥", "왕궁", - "비밀기지", "실험실", "천문대", "등대", "폐허", - - # 도시 장소 (10개) - "광장", "시장", "항구", "기차역", "다리", "골목길", "대로", "교차로", "분수대", "공사장", - - # 미스터리 장소 (5개) - "범죄현장", "취조실", "법원", "증거보관실", "밀실" - ], - - "사건": [ - # 일상 사건 (30개) - "만남", "이별", "약속", "지각", "실수", "오해", "화해", "선물", "파티", "이사", - "취업", "퇴사", "시험", "졸업", "결혼", "생일", "사고", "입원", "여행", "귀국", - "분실", "발견", "고백", "거절", "성공", "실패", "도전", "포기", "시작", "끝", - - # 모험/액션 (20개) - "추격", "탈출", "잠입", "구출", "전투", "결투", "매복", "습격", "방어", "후퇴", - "탐험", "발견", "함정", "배신", "동맹", "거래", "협상", "위기", "역전", "승리", - - # 미스터리 (15개) - "실종", "살인", "도난", "위조", "협박", "납치", "목격", "추리", "심문", "재판", - "증거발견", "알리바이붕괴", "정체폭로", "자백", "무죄석방", - - # 초자연/판타지 (10개) - "저주", "축복", "소환", "봉인", "각성", "변신", "예언", "기적", "부활", "소멸", - - # 로맨스/감정 (5개) - "첫만남", "데이트", "프로포즈", "이별", "재회" - ], - - "감정/상태": [ - # 기본 감정 (25개) - "기쁨", "슬픔", "분노", "두려움", "놀람", "혐오", "신뢰", "기대", "수치심", "죄책감", - "자부심", "질투", "부러움", "희망", "절망", "사랑", "증오", "동정", "감사", "후회", - "안도", "초조", "당황", "흥분", "실망", - - # 상태 (20개) - "피곤", "활기참", "졸림", "배고픔", "목마름", "아픔", "건강함", "취함", "멀미", "현기증", - "집중", "산만", "긴장", "편안", "불안", "평온", "혼란", "확신", "의심", "망설임", - - # 복잡한 감정 (10개) - "그리움", "허전함", "뿌듯함", "찜찜함", "서운함", "미안함", "고마움", "어색함", "쑥스러움", "답답함", - - # 극단적 감정 (5개) - "환희", "절규", "공포", "열정", "무기력" - ], - - "시간/때": [ - # 하루 시간 (10개) - "새벽", "아침", "오전", "정오", "오후", "저녁", "밤", "자정", "동틀녘", "해질녘", - - # 계절/날씨시기 (8개) - "봄", "여름", "가을", "겨울", "장마철", "환절기", "연말", "연초", - - # 특별한 날 (12개) - "생일", "기념일", "명절", "주말", "휴일", "시험날", "면접날", "이사날", "결혼식날", "졸업식날", - "첫출근", "마지막날", - - # 시간 표현 (10개) - "지금", "방금", "조금전", "나중에", "언젠가", "옛날", "미래", "과거", "현재", "영원" - ], - - "날씨/자연현상": [ - # 일반 날씨 (20개) - "맑음", "흐림", "비", "눈", "바람", "안개", "서리", "이슬", "무지개", "번개", - "천둥", "우박", "태풍", "폭설", "폭염", "한파", "미세먼지", "황사", "구름", "햇빛", - - # 자연현상 (10개) - "일출", "일몰", "월식", "일식", "유성", "오로라", "조수", "파도", "지진", "화산" - ], - - "색��/빛": [ - # 기본색 (15개) - "빨강", "파랑", "노랑", "초록", "주황", "보라", "분홍", "하양", "검정", "회색", - "갈색", "하늘색", "연두색", "남색", "베이지", - - # 빛/질감 (10개) - "반짝임", "어둠", "그림자", "빛", "투명", "불투명", "무광", "유광", "형광", "야광" - ], - - "소리/음향": [ - # 일상소리 (20개) - "발소리", "문소리", "종소리", "알람소리", "전화벨", "노크소리", "박수소리", "휘파람", "콧노래", "한숨", - "웃음소리", "울음소리", "고함소리", "속삭임", "심장소리", "숨소리", "기침소리", "재채기", "하품소리", "코골이", - - # 자연소리 (10개) - "바람소리", "빗소리", "파도소리", "새소리", "벌레소리", "천둥소리", "나뭇잎소리", "물소리", "불타는소리", "지진소리" - ], - - "음식/요리": [ - # 한식 (20개) - "김치", "된장찌개", "불고기", "비빔밥", "떡볶이", "김밥", "라면", "삼겹살", "냉면", "갈비탕", - "잡채", "전", "순두부찌개", "삼계탕", "국밥", "쌈밥", "볶음밥", "만두", "부침개", "죽", - - # 외국음식 (15개) - "파스타", "피자", "스테이크", "스시", "라멘", "샐러드", "샌드위치", "햄버거", "타코", "커리", - "쌀국수", "딤섬", "케밥", "리조또", "브런치", - - # 간식/음료 (15개) - "커피", "차", "주스", "맥주", "와인", "빵", "케이크", "쿠키", "초콜릿", "아이스크림", - "과일", "팝콘", "감자칩", "젤리", "사탕" - ], - - "동작/행동": [ - # 일상 동작 (25개) - "걷기", "뛰기", "앉기", "서기", "눕기", "일어나기", "돌아보기", "손흔들기", "고개끄덕이기", "미소짓기", - "찡그리기", "눈감기", "하품하기", "기지개켜기", "숨쉬기", "먹기", "마시기", "말하기", "듣기", "보기", - "만지기", "잡기", "놓기", "열기", "닫기", - - # 특별한 동작 (15개) - "숨기", "찾기", "도망치기", "쫓기", "싸우기", "방어하기", "공격하기", "점프하기", "기어가기", "수영하기", - "춤추기", "노래하기", "그리기", "쓰기", "연주하기" - ], - - "관계": [ - # 가족 (10개) - "부모", "자녀", "형제", "자매", "조부모", "손자", "배우자", "사촌", "삼촌", "이모", - - # 사회관계 (15개) - "친구", "연인", "동료", "상사", "부하", "이웃", "스승", "제자", "파트너", "라이벌", - "적", "동지", "지인", "낯선사람", "손님" - ], - - "능력/특성": [ - # 일반 능력 (15개) - "지능", "체력", "속도", "힘", "민첩성", "창의력", "기억력", "집중력", "리더십", "공감능력", - "유머감각", "인내심", "용기", "직관", "설득력", - - # 특수 능력 (10개) - "투시", "텔레파시", "예지력", "치유능력", "변신", "투명화", "비행", "시간조작", "정신조작", "원소조작" - ], - - "상황/조건": [ - # 일반 상황 (20개) - "비상사태", "일상", "휴가", "근무중", "수업중", "운전중", "대기중", "회의중", "식사중", "운동중", - "잠자는중", "공부중", "준비중", "여행중", "치료중", "훈련중", "경쟁중", "협상중", "조사중", "도피중" - ], - - "목적/동기": [ - # 기본 동기 (20개) - "생존", "복수", "사랑", "돈", "명예", "권력", "지식", "자유", "정의", "평화", - "행복", "성공", "인정", "보호", "발견", "성장", "치유", "화해", "진실", "희망" - ] -} - -# Genre elements from paste-2.txt -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에 찍힌 그 순간, 모든 것이 뒤바뀌었다." - ], - "라이트노벨": [ - "'선배! 사실 저... 마왕이에요!'", - "전학생의 정체는 다름 아닌...", - "그녀의 가방에서 떨어진 것을 보고 경악했다.", - "'어라? 이거... 게임 아이템이 현실에?'" - ] -} - -# --- Global variables --- -db_lock = threading.Lock() -current_state = { - "selected_words": [], - "genre": "", - "loglines": [], - "evaluations": [], - "ai_selected": "", - "ai_reason": "", - "user_prompt": "" -} - -# --- Data classes --- -@dataclass -class StoryStructure: - """Story structure for web novel""" - title: str = "" - genre: str = "" - synopsis: str = "" - characters: Dict[str, Dict[str, Any]] = field(default_factory=dict) - world_setting: str = "" - power_system: Dict[str, Any] = field(default_factory=dict) - episode_outlines: Dict[int, Dict[str, str]] = field(default_factory=dict) # 40화 구성 - key_events: List[Dict[str, Any]] = field(default_factory=list) - climax_points: List[int] = field(default_factory=list) # 클라이맥스 화수 - current_episode: int = 0 # 현재 에피소드 추가 - -@dataclass -class NovelSession: - """Session data for a novel""" - session_id: str - user_id: str - genre: str - title: str - story_structure: StoryStructure - current_episode: int = 0 - created_at: str = "" - updated_at: str = "" - status: str = "active" # active, completed, paused - -# --- Database schema update --- -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() - - # Users table - 이메일 기반으로 변경 - cursor.execute(''' - CREATE TABLE IF NOT EXISTS users ( - user_id TEXT PRIMARY KEY, - email TEXT UNIQUE NOT NULL, - username TEXT NOT NULL, - created_at TEXT DEFAULT (datetime('now')), - last_login TEXT DEFAULT (datetime('now')) - ) - ''') - - # Sessions table with user ownership - cursor.execute(''' - CREATE TABLE IF NOT EXISTS sessions ( - session_id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - genre TEXT NOT NULL, - title TEXT, - logline TEXT, - story_structure TEXT, -- JSON - current_episode INTEGER DEFAULT 0, - total_episodes INTEGER DEFAULT 40, - created_at TEXT DEFAULT (datetime('now')), - updated_at TEXT DEFAULT (datetime('now')), - status TEXT DEFAULT 'active', - FOREIGN KEY (user_id) REFERENCES users(user_id) - ) - ''') - - # 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, - title TEXT, - content TEXT, - hook TEXT, - word_count INTEGER DEFAULT 0, - created_at TEXT DEFAULT (datetime('now')), - FOREIGN KEY (session_id) REFERENCES sessions(session_id), - UNIQUE(session_id, episode_number) - ) - ''') - - # Story planning table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS story_plans ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - session_id TEXT NOT NULL, - selected_words TEXT, -- JSON - logline TEXT, - synopsis TEXT, - character_profiles TEXT, -- JSON - episode_outlines TEXT, -- JSON - created_at TEXT DEFAULT (datetime('now')), - FOREIGN KEY (session_id) REFERENCES sessions(session_id) - ) - ''') - - 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_or_get_user_by_email(email: str) -> str: - """이메일로 사용자 생성 또는 조회""" - user_id = hashlib.md5(email.encode()).hexdigest() - username = email.split('@')[0] - - with WebNovelDatabase.get_db() as conn: - cursor = conn.cursor() - - # 기존 사용자 확인 - existing = cursor.execute( - 'SELECT user_id FROM users WHERE email = ?', - (email,) - ).fetchone() - - if existing: - # 마지막 로그인 시간 업데이트 - cursor.execute( - 'UPDATE users SET last_login = datetime("now") WHERE email = ?', - (email,) - ) - conn.commit() - return existing['user_id'] - else: - # 새 사용자 생성 - cursor.execute( - '''INSERT INTO users (user_id, email, username) VALUES (?, ?, ?)''', - (user_id, email, username) - ) - conn.commit() - return user_id - - @staticmethod - def create_session(user_id: str, genre: str, title: str = None, logline: str = None) -> str: - session_id = hashlib.md5(f"{user_id}{genre}{datetime.now()}".encode()).hexdigest() - with WebNovelDatabase.get_db() as conn: - conn.cursor().execute( - '''INSERT INTO sessions (session_id, user_id, genre, title, logline) - VALUES (?, ?, ?, ?, ?)''', - (session_id, user_id, genre, title, logline) - ) - conn.commit() - return session_id - - @staticmethod - def get_user_sessions(user_id: str) -> List[Dict]: - with WebNovelDatabase.get_db() as conn: - rows = conn.cursor().execute( - '''SELECT * FROM sessions WHERE user_id = ? - ORDER BY updated_at DESC''', - (user_id,) - ).fetchall() - return [dict(row) for row in rows] - - @staticmethod - def get_session(session_id: str) -> Optional[Dict]: - with WebNovelDatabase.get_db() as conn: - row = conn.cursor().execute( - 'SELECT * FROM sessions WHERE session_id = ?', - (session_id,) - ).fetchone() - return dict(row) if row else None - - @staticmethod - def save_story_structure(session_id: str, story_structure: StoryStructure): - with WebNovelDatabase.get_db() as conn: - # 현재 에피소드 수 가져오기 - current_episode = conn.cursor().execute( - 'SELECT current_episode FROM sessions WHERE session_id = ?', - (session_id,) - ).fetchone() - - current_ep = current_episode['current_episode'] if current_episode else 0 - - # story_structure에 현재 에피소드 정보 추가 - story_structure.current_episode = current_ep - - conn.cursor().execute( - '''UPDATE sessions - SET story_structure = ?, title = ?, updated_at = datetime('now') - WHERE session_id = ?''', - (json.dumps(asdict(story_structure)), story_structure.title, session_id) - ) - conn.commit() - - @staticmethod - def save_episode(session_id: str, episode_num: int, title: str, - content: str, hook: str): - 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, title, - content, hook, word_count) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(session_id, episode_number) - DO UPDATE SET title=?, content=?, hook=?, word_count=? - ''', (session_id, episode_num, title, content, hook, word_count, - title, content, hook, word_count)) - - # Update session progress - cursor.execute(''' - UPDATE sessions - SET current_episode = ?, updated_at = datetime('now') - WHERE session_id = ? - ''', (episode_num, 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 check_ownership(session_id: str, user_id: str) -> bool: - with WebNovelDatabase.get_db() as conn: - row = conn.cursor().execute( - 'SELECT user_id FROM sessions WHERE session_id = ?', - (session_id,) - ).fetchone() - return row and row['user_id'] == user_id - - @staticmethod - def save_story_plan(session_id: str, selected_words: List[Tuple[str, str]], - logline: str, synopsis: str = None): - with WebNovelDatabase.get_db() as conn: - conn.cursor().execute(''' - INSERT INTO story_plans (session_id, selected_words, logline, synopsis) - VALUES (?, ?, ?, ?) - ''', (session_id, json.dumps(selected_words), logline, synopsis)) - conn.commit() - -# --- 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 - WebNovelDatabase.init_db() - - def create_headers(self): - return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"} - - # --- Step 1: Logline generation (from paste.txt) --- - def interpret_user_prompt(self, user_prompt: str) -> Dict[str, any]: - """사용자의 프롬프트를 해석하여 카테고리, 단어, 장르 등을 추출""" - logger.info(f"프롬프트 해석 시작: {user_prompt[:50]}...") - - prompt = f"""다음 사용자의 요청을 분석하여 스토리 생성에 필요한 요소들을 추출해주세요. - -사용자 요청: "{user_prompt}" - -가능한 카테고리: {', '.join(complete_word_list.keys())} - -다음 형식으로 분석 결과를 작성해주세요: - -[추출된 요소] -카테고리1: (카테고리명) - (단어) -카테고리2: (카테고리명) - (단어) -카테고리3: (카테고리명) - (단어) - -[장르] -(추출된 장르 또는 사용자가 원하는 분위기) - -[특별 지시사항] -(사용자가 강조한 특별한 요구사항이나 스토리 방향성) - -주의사항: -1. 사용자가 구체적으로 언급한 단어가 있다면 그에 맞는 카테고리에서 선택 -2. 언급이 없는 부분은 문맥에 맞춰 적���히 선택 -3. 가능한 한 다양한 카테고리에서 선택하여 풍부한 스토리가 되도록 함""" - - try: - result = self.call_llm_sync([{"role": "user", "content": prompt}], "planner") - logger.info("LLM 응답 수신 완료") - - # Parse result - lines = result.split('\n') - extracted = { - "words": [], - "genre": "", - "special_instructions": "" - } - - current_section = None - for line in lines: - line = line.strip() - if '[추출된 요소]' in line: - current_section = 'elements' - elif '[장르]' in line: - current_section = 'genre' - elif '[특별 지시사항]' in line: - current_section = 'instructions' - elif line and current_section: - if current_section == 'elements' and '카테고리' in line and ':' in line: - parts = line.split(':', 1)[1].strip() - if ' - ' in parts: - category, word = parts.split(' - ', 1) - extracted["words"].append((category.strip(), word.strip())) - logger.info(f"추출된 단어: {category.strip()} - {word.strip()}") - elif current_section == 'genre': - extracted["genre"] = line - logger.info(f"추출된 장르: {line}") - elif current_section == 'instructions': - extracted["special_instructions"] += line + " " - - logger.info(f"프롬프트 해석 완료: {len(extracted['words'])}개 단어 추출") - return extracted - - except Exception as e: - logger.error(f"프롬프트 해석 오류: {str(e)}", exc_info=True) - raise - - def generate_loglines_and_evaluate(self, categories_words: List[Tuple[str, str]]) -> Dict[str, any]: - """10개의 로그라인을 생성하고 평가하여 최적의 것을 선택""" - logger.info(f"로그라인 생성 시작 - 단어: {categories_words}") - - prompt = f"""다음 3개 카테고리의 단어들을 모두 사용하여 서로 다른 관점과 장르의 로그라인을 10개 생성해주세요. - - 카테고리와 선택된 단어: - 1. {categories_words[0][0]}: {categories_words[0][1]} - 2. {categories_words[1][0]}: {categories_words[1][1]} - 3. {categories_words[2][0]}: {categories_words[2][1]} - - 요구사항: - 1. 각 로그라인은 1-2문장으로 작성 - 2. 3개 단어를 모두 자연스럽게 포함 - 3. 서로 다른 장르와 톤으로 작성 (스릴러, 로맨스, 코미디, 호러, SF, 판타지, 미스터리 등) - 4. 독창적이고 흥미로운 관점 제시 - - 그 다음, 각 로그라인을 다음 4가지 기준으로 평가해주세요 (각 10점 만점): - - 독창성: 기존 작품과의 차별성, 신선함 - - 흥미도: 관객의 호기심을 유발하는 정도 - - 서사성: 이야기로 발전시킬 수 있는 잠재력 - - 개연성: 논리적 타당성과 현실성 - - 반드시 아래 형식으로 작성해주세요: - - [로그라인 생성] - 로그라인 1: (내용) - 로그라인 2: (내용) - 로그라인 3: (내용) - 로그라인 4: (내용) - 로그라인 5: (내용) - 로그라인 6: (내용) - 로그라인 7: (내용) - 로그라인 8: (내용) - 로그라인 9: (내용) - 로그라인 10: (내용) - - [평가 결과] - 로그라인 1: 독창성(X/10), 흥미도(X/10), 서사성(X/10), 개연성(X/10), 총점(XX/40) - 로그라인 2: 독창성(X/10), 흥미도(X/10), 서사성(X/10), 개연성(X/10), 총점(XX/40) - ... (모든 로그라인 평가) - - [최종 선택] - 선택된 로그라인: (가장 높은 점수를 받은 로그라인 번호와 내용) - - [선택 이유] - (왜 이 로그라인이 가장 좋은지 구체적으로 설명)""" - - try: - logger.info("LLM 호출 중...") - result = self.call_llm_sync([{"role": "user", "content": prompt}], "planner") - logger.info("LLM 응답 수신 완료") - - # Parse result - sections = { - "loglines": [], - "evaluations": [], - "selected": "", - "reason": "" - } - - lines = result.split('\n') - current_section = None - - for line in lines: - line = line.strip() - - if '[로그라인 생성]' in line: - current_section = 'loglines' - logger.info("로그라인 섹션 파싱 시작") - elif '[평가 결과]' in line: - current_section = 'evaluations' - logger.info("평가 섹션 파싱 시작") - elif '[최종 선택]' in line: - current_section = 'selected' - logger.info("최종 선택 섹션 파싱 시작") - elif '[선택 이유]' in line: - current_section = 'reason' - elif line and current_section: - if current_section == 'loglines' and line.startswith('로그라인'): - parts = line.split(':', 1) - if len(parts) == 2: - sections['loglines'].append(parts[1].strip()) - elif current_section == 'evaluations' and line.startswith('로그라인'): - sections['evaluations'].append(line) - elif current_section == 'selected': - if '선택된 로그라인:' in line: - sections['selected'] = line.replace('선택된 로그라인:', '').strip() - else: - sections['selected'] += ' ' + line - elif current_section == 'reason': - sections['reason'] += line + ' ' - - logger.info(f"파싱 완료 - 로그라인 {len(sections['loglines'])}개 생성") - return sections - - except Exception as e: - logger.error(f"로그라인 생성 오류: {str(e)}", exc_info=True) - raise - - def generate_10_loglines_with_prompt(self, user_prompt: str, categories_words: List[Tuple[str, str]], - genre: str, special_instructions: str) -> Dict[str, any]: - """사용자 프롬프트를 반영하여 10개의 로그라인을 생성하고 평가""" - prompt = f"""사용자의 다음 요청을 반영하여 로그라인을 생성해주세요. - -사용자 요청: "{user_prompt}" - -추출된 요소: -1. {categories_words[0][0]}: {categories_words[0][1]} -2. {categories_words[1][0]}: {categories_words[1][1]} -3. {categories_words[2][0]}: {categories_words[2][1]} - -장르/분위기: {genre} -특별 지시사항: {special_instructions} - -위 요소들을 모두 반영하여 서로 다른 관점과 스타일의 로그라인을 10개 생성해주세요. - -요구사항: -1. 각 로그라인은 1-2문장으로 작성 -2. 3개 단어를 모두 자연스럽게 포함 -3. 사용자의 요청 의도를 충실히 반영 -4. 다양한 해석과 접근으로 차별화 - -그 다음, 각 로그라인을 다음 4가지 기준으로 평가해주세요 (각 10점 만점): -- 독창성: 기존 작품과의 차별성, 신선함 -- 흥미도: 관객의 호기심을 유발하는 정도 -- 서사성: 이야기로 발전시킬 수 있는 잠재력 -- 개연성: 논리적 타당성과 현실성 - -반드시 아래 형식으로 작성해주세요: - -[로그라인 생성] -로그라인 1: (내용) -로그라인 2: (내용) -... (10개까지) - -[평가 결과] -로그라인 1: 독창성(X/10), 흥미도(X/10), 서사성(X/10), 개연성(X/10), 총점(XX/40) -... (모든 로그라인 평가) - -[최종 선택] -선택된 로그라인: (가장 높은 점수를 받은 로그라인 번호와 내용) - -[선택 이유] -(왜 이 로그라인이 가장 좋은지 구체적으로 설명)""" - - result = self.call_llm_sync([{"role": "user", "content": prompt}], "planner") - - # Parse result (same as generate_loglines_and_evaluate) - sections = { - "loglines": [], - "evaluations": [], - "selected": "", - "reason": "" - } - - lines = result.split('\n') - current_section = None - - for line in lines: - line = line.strip() - - if '[로그라인 생성]' in line: - current_section = 'loglines' - elif '[평가 결과]' in line: - current_section = 'evaluations' - elif '[최종 선택]' in line: - current_section = 'selected' - elif '[선택 이유]' in line: - current_section = 'reason' - elif line and current_section: - if current_section == 'loglines' and line.startswith('로그라인'): - parts = line.split(':', 1) - if len(parts) == 2: - sections['loglines'].append(parts[1].strip()) - elif current_section == 'evaluations' and line.startswith('로그라인'): - sections['evaluations'].append(line) - elif current_section == 'selected': - if '선택된 로그라인:' in line: - sections['selected'] = line.replace('선택된 로그라인:', '').strip() - else: - sections['selected'] += ' ' + line - elif current_section == 'reason': - sections['reason'] += line + ' ' - - return sections - - def generate_story_from_prompt(self, user_prompt: str) -> Dict[str, any]: - """사용자 프롬프트를 바탕으로 직접 스토리 생성""" - prompt = f"""다음 사용자의 요청을 바탕으로 완전한 스토리 기획안을 작성해주세요. - -사용자 요청: "{user_prompt}" - -다음 형식으로 작성해주세요: - -[스토리 개요] -(사용자 요청을 반영한 전체 스토리 개요) - -[3단계 갈등구조] -• 외적 갈등: (주인공 vs 적대자/환경) -• 내적 갈등: (주인공의 내면적 딜레마) -• 관계적 갈등: (주인공과 주변 인물들 간의 갈등) - -[스토리 아크 - 3막 구조] -제1막 (설정): -- 오프닝: (첫 장면 묘사) -- 일상의 세계: (주인공의 평범한 일상) -- 촉발 사건: (모험의 시작) -- 거부와 고민: (주인공의 망설임) - -제2막 (대립): -- 첫 번째 관문: (첫 도전) -- 동료와 적: (조력자와 적대자 등장) -- 시련의 연속: (점점 심화되는 갈등) -- 가짜 승리/패배: (반전 직전) -- 절망의 순간: (최저점) - -제3막 (해결): -- 각성과 깨달음: (주인공의 변화) -- 최종 대결: (클라이맥스) -- 새로운 세계: (변화된 일상) - -[캐릭터 아크] -주인공: -• 시작점: (캐릭터의 초기 상태) -• 욕망: (원하는 것) -• 약점: (극복해야 할 단점) -• 성장: (어떻게 변화하는가) -• 최종 상태: (변화 후 모습) - -[핵심 테마와 메시지] -• 표면 테마: (겉으로 드러나는 주제) -• 심층 테마: (깊은 의미의 주제) -• 철학적 질문: (관객에게 던지는 질문)""" - - result = self.call_llm_sync([{"role": "user", "content": prompt}], "planner") - return {"content": result} - - # --- Step 2: Story structure generation --- - def generate_story_structure(self, logline: str, genre: str) -> StoryStructure: - """Generate detailed 40-episode story structure""" - genre_info = GENRE_ELEMENTS.get(genre, {}) - - prompt = f"""선택된 로그라인을 기반으로 {genre} 장르의 40화 완결 웹소설 구조를 작성하세요. - -로그라인: {logline} -장르: {genre} - -장르 특성: -- 핵심 요소: {', '.join(genre_info.get('key_elements', []))} -- 인기 트로프: {', '.join(genre_info.get('popular_tropes', []))} -- 필수 포함: {', '.join(genre_info.get('must_have', []))} -- 에피소드 구조: {genre_info.get('episode_structure', '')} - -다음 형식으로 작성하세요: - -[제목] -매력적이고 기억에 남는 제목 - -[시놉시스] -전체 줄거리를 3-5문장으로 요약 - -[주요 등장인물] -1. 주인공: [이름] - [나이, 직업/신분, 성격, 목표] -2. 주요인물2: [이름] - [관계, 역할, 특징] -3. 주요인물3: [이름] - [관계, 역할, 특징] -(필요한 만큼 추가) - -[세계관 설정] -작품의 배경이 되는 세계 설정 (시대, 장소, 특수 설정 등) - -[40화 상세 구성] -각 화마다 다음 내용 포함: -- 제목 -- 주요 사건 -- 갈등/전환점 -- 엔딩 훅 - -1화: [제목] -- 주요 사건: -- 갈등/전환점: -- 엔딩 훅: - -2화: [제목] -... (40화까지) - -[주요 전환점] -- 10화: 1차 전환점 -- 20화: 중간 클라이맥스 -- 30화: 최종 갈등 진입 -- 40화: 대단원 - -모든 화는 500단어 분량으로 작성될 예정이며, 각 화마다 독자를 사로잡는 훅이 필요합니다.""" - - response = self.call_llm_sync([{"role": "user", "content": prompt}], "planner") - - # Parse response into StoryStructure - story = StoryStructure() - story.genre = genre - - # Parse the response and fill StoryStructure - lines = response.split('\n') - current_section = "" - episode_num = 0 - - for line in lines: - line = line.strip() - if '[제목]' in line: - current_section = 'title' - elif '[시놉시스]' in line: - current_section = 'synopsis' - elif '[주요 등장인물]' in line: - current_section = 'characters' - elif '[세계관 설정]' in line: - current_section = 'world' - elif '[40화 상세 구성]' in line: - current_section = 'episodes' - elif line and current_section: - if current_section == 'title' and not story.title: - story.title = line - elif current_section == 'synopsis': - story.synopsis += line + " " - elif current_section == 'world': - story.world_setting += line + " " - elif current_section == 'episodes': - # Parse episode structure - if '화:' in line: - # Extract episode number more robustly - episode_part = line.split('화:')[0].strip() - # Remove any asterisks or special characters - episode_part = episode_part.replace('*', '').replace('#', '').strip() - try: - episode_num = int(episode_part) - title = line.split('화:')[1].strip() if ':' in line else "" - story.episode_outlines[episode_num] = {"title": title} - except ValueError: - # Try to extract number using regex - import re - numbers = re.findall(r'\d+', episode_part) - if numbers: - episode_num = int(numbers[0]) - title = line.split('화:')[1].strip() if ':' in line else "" - story.episode_outlines[episode_num] = {"title": title} - elif episode_num > 0: - if '주요 사건:' in line: - story.episode_outlines[episode_num]["main_event"] = line.split(':', 1)[1].strip() - elif '갈등/전환점:' in line: - story.episode_outlines[episode_num]["conflict"] = line.split(':', 1)[1].strip() - elif '엔딩 훅:' in line: - story.episode_outlines[episode_num]["hook"] = line.split(':', 1)[1].strip() - elif episode_num in story.episode_outlines and '주요 사건' not in line and '갈등' not in line and '엔딩' not in line: - # Handle continuation lines - if 'main_event' in story.episode_outlines[episode_num] and not story.episode_outlines[episode_num].get('conflict'): - story.episode_outlines[episode_num]["main_event"] += " " + line - elif 'conflict' in story.episode_outlines[episode_num] and not story.episode_outlines[episode_num].get('hook'): - story.episode_outlines[episode_num]["conflict"] += " " + line - elif 'hook' in story.episode_outlines[episode_num]: - story.episode_outlines[episode_num]["hook"] += " " + line - - return story - - # --- Step 3: Episode writing --- - def write_episode(self, story_structure: StoryStructure, episode_num: int, - previous_episodes: List[str] = None) -> Dict[str, str]: - """Write a single episode based on story structure""" - genre_info = GENRE_ELEMENTS.get(story_structure.genre, {}) - episode_outline = story_structure.episode_outlines.get(episode_num, {}) - hooks = EPISODE_HOOKS.get(story_structure.genre, ["다음 순간, 충격적인 일이..."]) - - # Get previous context - prev_context = "" - if previous_episodes and len(previous_episodes) > 0: - # Get last 2 episodes for context - recent_episodes = previous_episodes[-2:] - prev_context = "\n\n".join(recent_episodes) - - prompt = f"""웹소설 {story_structure.title}의 {episode_num}화를 작성하세요. - -장르: {story_structure.genre} -전체 시놉시스: {story_structure.synopsis} - -{episode_num}화 구성: -- 제목: {episode_outline.get('title', '')} -- 주요 사건: {episode_outline.get('main_event', '')} -- 갈등/전환점: {episode_outline.get('conflict', '')} -- 엔딩 훅: {episode_outline.get('hook', '')} - -이전 내용: -{prev_context if prev_context else "첫 화입니다."} - -작성 요구사항: -1. 분량: 500단어 (엄격히 준수) -2. 문체: {story_structure.genre} 장르에 맞는 문체 -3. 구성: - - 도입부 (이전 화 연결) - - 전개부 (주요 사건 진행) - - 클라이맥스 (갈등/전환점) - - 엔딩 (강력한 훅으로 마무리) -4. 필수 포함: - - 생생한 대화 - - 캐릭터 감정 묘사 - - 장면 전환 - - 독자 몰입 요소 - -참고 훅 예시: -{random.choice(hooks)} - -형식: -제{episode_num}화. {episode_outline.get('title', '제목')} - -(본문 내용) - -마지막은 반드시 다음 화를 기대하게 만드는 강력한 훅으로 끝내세요.""" - - response = self.call_llm_sync([{"role": "user", "content": prompt}], "writer") - - # Extract title and content - lines = response.strip().split('\n') - title = lines[0] if lines else f"제{episode_num}화" - content = '\n'.join(lines[1:]) if len(lines) > 1 else response - - # Extract hook (last sentence) - sentences = content.split('.') - hook = sentences[-2] + '.' if len(sentences) > 1 else sentences[-1] - - return { - "title": title, - "content": content, - "hook": hook, - "word_count": len(content.split()) - } - - # --- LLM call functions --- - def call_llm_sync(self, messages: List[Dict[str, str]], role: str) -> str: - logger.info(f"LLM 호�� 시작 - 역할: {role}") - logger.debug(f"메시지 수: {len(messages)}") - - try: - system_prompts = self.get_system_prompts() - full_messages = [{"role": "system", "content": system_prompts.get(role, "")}, *messages] - - payload = { - "model": self.model_id, - "messages": full_messages, - "max_tokens": 5000, - "temperature": 0.85, - "top_p": 0.95, - "stream": False - } - - logger.debug(f"API 요청 중... URL: {self.api_url}") - start_time = time.time() - - response = requests.post( - self.api_url, - headers=self.create_headers(), - json=payload, - timeout=180 - ) - - elapsed_time = time.time() - start_time - logger.info(f"API 응답 수신 - 소요시간: {elapsed_time:.2f}초, 상태코드: {response.status_code}") - - if response.status_code != 200: - logger.error(f"API 오류: {response.status_code} - {response.text}") - return f"❌ API Error (Status Code: {response.status_code})" - - result = response.json() - if 'choices' in result and len(result['choices']) > 0: - content = result['choices'][0]['message']['content'].strip() - logger.info(f"LLM 응답 길이: {len(content)} 문자") - return content - else: - logger.error(f"예상치 못한 응답 형식: {result}") - return "❌ No response from API" - - except requests.exceptions.Timeout: - logger.error("API 호출 타임아웃") - return "❌ API 호출 시간 초과" - except Exception as e: - logger.error(f"LLM 호출 오류: {type(e).__name__}: {str(e)}", exc_info=True) - return f"❌ Error: {str(e)}" - - def get_system_prompts(self) -> Dict[str, str]: - """System prompts for different roles""" - return { - "planner": """당신은 한국 웹소설 시장을 완벽히 이해하는 기획자입니다. -독자를 중독시키는 플롯과 전개를 설계합니다. -장르별 관습과 독자 기대를 정확히 파악합니다. -40화 완결 구조로 완벽한 기승전결을 만듭니다.""", - - "writer": """당신은 독자를 사로잡는 웹소설 작가입니다. -생생하고 몰입감 있는 문체를 구사합니다. -각 화를 정확히 500단어로 작성합니다. -대화, 행동, 내면 묘사를 균형있게 배치합니다. -매 화 끝에 강력한 후크로 다음 화를 기다리게 만듭니다.""" - } - -# --- Helper functions from paste.txt --- -def determine_genre(categories_words: List[Tuple[str, str]]) -> str: - """선택된 단어들을 기반으로 장르를 결정""" - fantasy_words = {"마법사", "기사", "용사", "마법", "주문서", "성", "던전", "저주", "축복"} - mystery_words = {"탐정", "형사", "증거", "범죄현장", "실종", "살인", "추리"} - sf_words = {"우주", "AI", "로봇", "홀로그램", "타임머신", "사이보그"} - horror_words = {"유령", "악마", "저주", "공포", "묘지", "피"} - romance_words = {"사랑", "연인", "첫만남", "프로포즈", "데이트"} - - words = [word for _, word in categories_words] - - if any(word in fantasy_words for word in words): - return "판타지" - elif any(word in mystery_words for word in words): - return "미스터리" - elif any(word in sf_words for word in words): - return "SF" - elif any(word in horror_words for word in words): - return "호러" - elif any(word in romance_words for word in words): - return "로맨스" - else: - return "현대극" - -def generate_comprehensive_story_plan(selected_logline: str, categories_words: List[Tuple[str, str]], genre: str) -> Dict[str, str]: - """선택된 로그라인을 기반으로 포괄적인 스토리 기획안 생성""" - system = WebNovelSystem() - - prompt = f"""다음 로그라인을 기반으로 {genre} 장르의 완전한 스토리 기획안을 작성해주세요. - - 선택된 로그라인: {selected_logline} - - 사용된 단어: - 1. {categories_words[0][0]}: {categories_words[0][1]} - 2. {categories_words[1][0]}: {categories_words[1][1]} - 3. {categories_words[2][0]}: {categories_words[2][1]} - - 다음 형식으로 작성해주세요: - - [3단계 갈등구조] - • 외적 갈등: (주인공 vs 적대자/환경) - • 내적 갈등: (주인공의 내면적 딜레마) - • 관계적 갈등: (주인공과 주변 인물들 간의 갈등) - - [스토리 아크 - 3막 구조] - 제1막 (설정): - - 오프닝: (첫 장면 묘사) - - 일상의 세계: (주인공의 평범한 일상) - - 촉발 사건: (모험의 시작) - - 거부와 고민: (주인공의 망설임) - - 제2막 (대립): - - 첫 번째 관문: (첫 도전) - - 동료와 적: (조력자와 적대자 등장) - - 시련의 연속: (점점 심화되는 갈등) - - 가짜 승리/패배: (반전 직전) - - 절망의 순간: (최저점) - - 제3막 (해결): - - 각성과 깨달음: (주인공의 변화) - - 최종 대결: (클라이맥스) - - 새로운 세계: (변화된 일상) - - [캐릭터 아크] - 주인공: - • 시작점: (캐릭터의 초기 상태) - • 욕망: (원하는 것) - • 약점: (극복해야 할 단점) - • 성장: (어떻게 변화하는가) - • 최종 상태: (변화 후 모습) - - 안타고니스트: - • 정체: (누구/무엇인가) - • 동기: (왜 주인공과 대립하는가) - • 강점: (무엇이 위협적인가) - - [핵심 테마와 메시지] - • 표면 테마: (겉으로 드러나는 주제) - • 심층 테마: (깊은 의미의 주제) - • 철학적 질문: (관객에게 던지는 질문) - - [3가지 반전 포인트] - 1. 1막 후반: (첫 번째 놀라움) - 2. 2막 중반: (큰 반전) - 3. 3막 초반: (최종 반전) - - [감동 포인트] - • 희생의 순간: (주인공이 포기하는 것) - • 화해의 순간: (관계 회복) - • 성장의 순간: (진정한 자아 발견)""" - - result = system.call_llm_sync([{"role": "user", "content": prompt}], "planner") - return {"content": result} - -def generate_loglines_step(user_prompt=""): - """Step 1: 10개 로그라인 생성 및 평가 - 실시간 진행상황 표시""" - global current_state - - progress_messages = [] - +def main(): try: - logger.info(f"=== 로그라인 생성 시작 ===") - logger.info(f"사용자 입력: {user_prompt[:100]}..." if user_prompt else "랜덤 생성 모드") + # Get the code from secrets + code = os.environ.get("MAIN_CODE") - # 진행상황 표시 함수 - def update_progress(message): - progress_messages.append(f"{datetime.now().strftime('%H:%M:%S')} - {message}") - return "\n".join(progress_messages) + if not code: + st.error("⚠️ The application code wasn't found in secrets. Please add the MAIN_CODE secret.") + return - if user_prompt.strip(): - # 사용자 프롬프트가 있는 경우 - progress_text = update_progress("🔍 사용자 요청 분석 중...") - logger.info("사용자 프롬프트 분석 시작") - - current_state["user_prompt"] = user_prompt - system = WebNovelSystem() - - # 프롬프트 해석 - interpreted = system.interpret_user_prompt(user_prompt) - logger.info(f"프롬프트 해석 완료: {interpreted}") - progress_text = update_progress("✅ 요청 분석 완료!") - - # 단어가 3개 미만인 경우 랜덤으로 채우기 - selected_words = interpreted["words"] - while len(selected_words) < 3: - remaining_categories = [cat for cat in complete_word_list.keys() - if cat not in [w[0] for w in selected_words]] - if remaining_categories: - category = random.choice(remaining_categories) - word = random.choice(complete_word_list[category]) - selected_words.append((category, word)) - logger.info(f"추가 단어 선택: {category} - {word}") - - genre = interpreted["genre"] or determine_genre(selected_words) - special_instructions = interpreted["special_instructions"] - - # 상태 저장 - current_state["selected_words"] = selected_words - current_state["genre"] = genre - - # 선택된 카테고리와 단어 표시 - category_display = f"### 🎲 분석 결과\n\n**사용자 요청**: {user_prompt}\n\n**장르**: {genre}\n\n**추출된 단어**:\n" + "\n".join([f"• {cat}: **{word}**" for cat, word in selected_words]) - if special_instructions: - category_display += f"\n\n**특별 지시사항**: {special_instructions}" - - progress_text = update_progress("📝 단어 추출 완료!") - - # 프롬프트 기반 로그라인 생성 - progress_text = update_progress("🤖 AI가 10개의 로그라인을 생성하는 중... (약 30초 소요)") - logger.info("로그라인 생성 시작") - - logline_result = system.generate_10_loglines_with_prompt(user_prompt, selected_words, genre, special_instructions) - logger.info(f"로그라인 생성 완료: {len(logline_result.get('loglines', []))}개") - progress_text = update_progress(f"✅ {len(logline_result.get('loglines', []))}개 로그라인 생성 완료!") - - else: - # 랜덤 생성 - progress_text = update_progress("🎲 랜덤 단어 선택 중...") - logger.info("랜덤 모드 시작") - - # 3개의 다른 카테고리 랜덤 선택 - selected_categories = random.sample(list(complete_word_list.keys()), 3) - logger.info(f"선택된 카테고리: {selected_categories}") - - # 각 카테고리에서 랜덤 단어 선택 - selected_words = [] - for category in selected_categories: - word = random.choice(complete_word_list[category]) - selected_words.append((category, word)) - logger.info(f"선택된 단어: {category} - {word}") - - # 장르 결정 - genre = determine_genre(selected_words) - logger.info(f"결정된 장르: {genre}") - - # 상태 저장 - current_state["selected_words"] = selected_words - current_state["genre"] = genre - current_state["user_prompt"] = "" - - # 선택된 카테고리와 단어 표시 - category_display = f"### 🎲 선택 결과\n\n**장르**: {genre}\n\n**선택된 단어**:\n" + "\n".join([f"• {cat}: **{word}**" for cat, word in selected_words]) - - progress_text = update_progress("📝 단어 선택 완료!") - - # 10개 로그라인 생성 및 평가 - progress_text = update_progress("🤖 AI가 10개의 로그라인을 생성하는 중... (약 30초 소요)") - logger.info("로그라인 생성 시작") - - system = WebNovelSystem() - logline_result = system.generate_loglines_and_evaluate(selected_words) - logger.info(f"로그라인 생성 완료: {len(logline_result.get('loglines', []))}개") - progress_text = update_progress(f"✅ {len(logline_result.get('loglines', []))}개 로그라인 생성 완료!") + # Create a temporary Python file + with NamedTemporaryFile(suffix='.py', delete=False, mode='w') as tmp: + tmp.write(code) + tmp_path = tmp.name - # 평가 중 표시 - progress_text = update_progress("⭐ 로그라인 평가 중...") + # Execute the code + exec(compile(code, tmp_path, 'exec'), globals()) - # 상태 저장 - current_state["loglines"] = logline_result.get('loglines', []) - current_state["evaluations"] = logline_result.get('evaluations', []) - current_state["ai_selected"] = logline_result.get('selected', '') - current_state["ai_reason"] = logline_result.get('reason', '') - - # 로그라인 정리 - loglines_display = "### 📝 생성된 10개 로그라인\n\n" - for i, logline in enumerate(logline_result.get('loglines', [])): - loglines_display += f"**로그라인 {i+1}**: {logline}\n\n" - - # AI 추천 표시 - if logline_result.get('selected'): - loglines_display += f"### 🤖 AI 추천\n\n**{logline_result['selected']}**\n\n" - if logline_result.get('reason'): - loglines_display += f"**추천 이유**: {logline_result['reason']}" - - # 드롭다운 선택지 생성 - choices = [f"로그라인 {i+1}: {logline[:50]}..." for i, logline in enumerate(logline_result.get('loglines', []))] - - progress_text = update_progress("✅ 완료!") - logger.info("로그라인 생성 프로세스 완료") - - return ( - progress_text, # 진행상황 - category_display, - loglines_display, - gr.update(visible=True, choices=choices, value=choices[0] if choices else None), - gr.update(visible=True), - gr.update(visible=True) - ) - - except Exception as e: - logger.error(f"로그라인 생성 오류: {str(e)}", exc_info=True) - error_msg = f"### ❌ 오류 발생\n\n{str(e)}\n\n상세 로그를 확인해주세요." - progress_text = update_progress(f"❌ 오류: {str(e)}") - return ( - progress_text, - error_msg, - "", - gr.update(visible=False, choices=[]), - gr.update(visible=False), - gr.update(visible=False) - ) - -def generate_story_from_selection(selected_logline_dropdown): - """Step 2: 선택된 로그라인으로 스토리 생성""" - global current_state - - progress_messages = [] - - logger.info(f"=== 스토리 생성 시작 ===") - logger.info(f"선택된 로그라인: {selected_logline_dropdown}") - - def update_progress(message): - progress_messages.append(f"{datetime.now().strftime('%H:%M:%S')} - {message}") - return "\n".join(progress_messages) - - if not selected_logline_dropdown: - return "", "### ⚠️ 로그라인을 선택해��세요", "", "", "" - - try: - # 선택된 로그라인 번호 추출 - logline_num = int(selected_logline_dropdown.split(':')[0].split()[-1]) - 1 - selected_logline = current_state["loglines"][logline_num] - logger.info(f"로그라인 번호 {logline_num + 1} 선택됨") - - progress_text = update_progress(f"📖 로그라인 {logline_num + 1} 선택됨") - progress_text = update_progress("🤖 AI가 스토리 구조를 생성하는 중... (약 20초 소요)") - - # 스토리 기획안 생성 - logger.info("스토리 기획안 생성 시작") - story_plan = generate_comprehensive_story_plan( - selected_logline, - current_state["selected_words"], - current_state["genre"] - ) - - if "error" in story_plan: - logger.error(f"스토리 생성 오류: {story_plan['error']}") - progress_text = update_progress(f"❌ 오류: {story_plan['error']}") - return progress_text, f"### ❌ 오류\n\n{story_plan['error']}", "", "", "" - - # 기획안 내용 파싱 및 정리 - full_plan = story_plan.get('content', '') - logger.info(f"스토리 기획안 생성 완료 - 길이: {len(full_plan)} 문자") - progress_text = update_progress("✅ 스토리 구조 생성 완료!") - - # 선택된 로그라인 표시 - selected_display = f"### 🎬 선택된 로그라인\n\n**로그라인 {logline_num + 1}**: {selected_logline}" - - # 섹션별로 분리 - sections = { - "갈등구조": "", - "스토리아크": "", - "캐릭터와테마": "" - } - - # 간단한 분리 로직 - if "[3단계 갈등구조]" in full_plan: - parts = full_plan.split("[스토리 아크") - sections["갈등구조"] = parts[0] - - if len(parts) > 1: - parts2 = parts[1].split("[캐릭터 아크]") - sections["스토리아크"] = "[스토리 아크" + parts2[0] - - if len(parts2) > 1: - sections["캐릭터와테마"] = "[캐릭터 아크]\n" + parts2[1] - - logger.info("스토리 생성 프로세스 완료") - return progress_text, selected_display, sections["갈등구조"], sections["스토리아크"], sections["캐릭터와테마"] - - except Exception as e: - logger.error(f"스토리 생성 오류: {str(e)}", exc_info=True) - progress_text = update_progress(f"❌ 오류: {str(e)}") - return progress_text, f"### ❌ 오류 발생\n\n{str(e)}", "", "", "" - -def generate_direct_story(user_prompt=""): - """사용자 프롬프트로 직접 스토리 생성""" - global current_state - - progress_messages = [] - - def update_progress(message): - progress_messages.append(f"{datetime.now().strftime('%H:%M:%S')} - {message}") - return "\n".join(progress_messages) - - if not user_prompt.strip(): - return "", "### ⚠️ 오류\n\n스토리 생성을 위한 요청사항을 입력해주세요.", "", "", "" - - try: - progress_text = update_progress("📝 사용자 요청 분석 중...") - progress_text = update_progress("🤖 AI가 직접 스토리를 생성하는 중... (약 30초 소요)") - - system = WebNovelSystem() - - # 스토리 직접 생성 - story_result = system.generate_story_from_prompt(user_prompt) - - # 기획안 내용 파싱 및 정리 - full_plan = story_result.get('content', '') - progress_text = update_progress("✅ 스토리 생성 완료!") - - # 요청사항 표시 - request_display = f"### 📋 사용자 요청\n\n**입력된 요청**: {user_prompt}" - - # 섹션별로 분리 - sections = { - "갈등구조": "", - "스토리아크": "", - "캐릭터와테마": "" - } - - # 섹션 분리 - if "[스토리 개요]" in full_plan: - parts = full_plan.split("[3단계 갈등구조]") - overview = parts[0] + # Clean up the temporary file + try: + os.unlink(tmp_path) + except: + pass - if len(parts) > 1: - parts2 = parts[1].split("[스토리 아크") - sections["갈등구조"] = "[3단계 갈등구조]\n" + parts2[0] - - if len(parts2) > 1: - parts3 = parts2[1].split("[캐릭터 아크]") - sections["스토리아크"] = "[스토리 아크" + parts3[0] - - if len(parts3) > 1: - sections["캐릭터와테마"] = "[캐릭터 아크]\n" + parts3[1] - - return progress_text, request_display + "\n\n" + overview, sections["갈등구조"], sections["스토리아크"], sections["캐릭터와테마"] - except Exception as e: - progress_text = update_progress(f"❌ 오류: {str(e)}") - return progress_text, f"### ⚠️ 오류\n\n오류가 발생했습니다: {str(e)}", "", "", "" - -# --- Gradio interface --- -def create_interface(): - with gr.Blocks(theme=gr.themes.Soft(), title="K-Story AI Studio") as interface: - # Header - with gr.Row(): - gr.HTML(""" -
-

📚 K-Story AI Studio

-

AI 기반 한국형 웹소설 창작 시스템

-
- """) - - # 이메일 기반 로그인 - with gr.Row(): - with gr.Column(scale=2): - email_input = gr.Textbox( - label="📧 이메일 주소", - placeholder="your.email@example.com", - type="email" - ) - with gr.Column(scale=1): - login_btn = gr.Button("🔑 로그인/가입", variant="primary") - - login_status = gr.Markdown("🔒 이메일을 입력하여 시작하세요") - - # State management - user_state = gr.State(None) - current_session = gr.State(None) - story_structure_state = gr.State(None) - - # Main tabs - 처음에는 숨김 - main_content = gr.Column(visible=False) - - with main_content: - with gr.Tab("📝 새 작품 시작"): - with gr.Column(): - # 진행상황 표시 창 - progress_output = gr.Textbox( - label="📊 진행 상황", - lines=5, - interactive=False, - value="진행 상황이 여기에 표시됩니다..." - ) - - gr.Markdown(""" - # 🎬 AI 스토리 기획 시스템 3.0 - - ### 사용자의 요청을 반영하여 맞춤형 스토리를 개발합니다! - - #### 🚀 주요 기능 - 1. **자유로운 입력**: 원하는 스토리의 분위기, 장르, 키워드 등을 자유롭게 입력 - 2. **10개 로그라인 생성**: 입력된 요청을 반영한 다양한 로그라인 제시 - 3. **직접 스토리 생성**: 로그라인 단계를 건너뛰고 바로 스토리 생성 가능 - 4. **AI 평가 시스템**: 독창성, 흥미도, 서사성, 개연성 기준으로 평가 - """) - - with gr.Row(): - with gr.Column(scale=3): - user_prompt_input = gr.Textbox( - label="🖊️ 원하는 스토리 요청사항을 입력하세요 (선택사항)", - placeholder="예시: '우주를 배경으로 한 감동적인 가족 이야기', '탐정이 등장하는 코미디 미스터리', '시간여행과 첫사랑이 결합된 SF 로맨스'", - lines=2 - ) - - with gr.Row(): - with gr.Column(scale=1): - random_btn = gr.Button("🎲 랜덤 로그라인 생성하기", variant="primary", size="lg") - with gr.Column(scale=1): - generate_btn = gr.Button("✍️ 요청사항 반영 로그라인 생성하기", variant="primary", size="lg") - with gr.Column(scale=1): - direct_story_btn = gr.Button("📖 바로 스토리 생성하기", variant="secondary", size="lg") - - gr.Markdown("---") - - with gr.Row(): - with gr.Column(scale=1): - category_output = gr.Markdown(value="", label="선택된 단어") - - with gr.Row(): - with gr.Column(scale=1): - loglines_output = gr.Markdown(value="", label="로그라인 생성 및 평가") - - with gr.Row(): - with gr.Column(scale=1): - logline_dropdown = gr.Dropdown( - label="📌 원하는 로그라인을 선택하세요", - choices=[], - visible=False, - interactive=True - ) - generate_story_btn = gr.Button( - "🎬 선택한 로그라인으로 스토리 생성하기", - variant="secondary", - visible=False - ) - - with gr.Row(): - with gr.Column(scale=1): - story_header = gr.Markdown("### 📖 생성된 스토리 기획안", visible=False) - - with gr.Row(): - with gr.Column(scale=1): - selected_logline_output = gr.Markdown(value="", label="선택된 로그라인") - - with gr.Row(): - with gr.Column(scale=1): - conflicts_output = gr.Markdown(value="", label="갈등구조") - - with gr.Row(): - with gr.Column(scale=1): - story_arc_output = gr.Markdown(value="", label="스토리 아크") - - with gr.Row(): - with gr.Column(scale=1): - final_details_output = gr.Markdown(value="", label="캐릭터, 테마, 반전") - - # Step 2-3: Web novel writing - gr.Markdown("---") - gr.Markdown("### 📚 웹소설 작성") - - genre_select = gr.Radio( - choices=list(WEBNOVEL_GENRES.keys()), - label="장르 선택", - value="로맨스", - visible=False - ) - - generate_webnovel_btn = gr.Button("📖 웹소설 구성 생성", variant="primary", visible=False) - webnovel_structure_output = gr.Markdown(visible=False) - - write_first_btn = gr.Button("✍️ 1화 작성", variant="primary", visible=False) - episode_output = gr.Markdown(visible=False) - write_next_btn = gr.Button("➡️ 다음화 작성", variant="secondary", visible=False) - episode_counter = gr.State(1) - - with gr.Tab("📚 내 작품 라이브러리"): - refresh_library_btn = gr.Button("🔄 새로고침") - library_output = gr.HTML() - - with gr.Row(): - selected_novel = gr.Dropdown(label="작품 선택", visible=False) - continue_writing_btn = gr.Button("이어쓰기", visible=False) - preview_btn = gr.Button("미리보기", visible=False) - export_btn = gr.Button("📥 내보내기", visible=False) - - # 이어쓰기 출력 영역 업데이트 - continue_output = gr.Markdown(visible=False) - continue_episode_btn = gr.Button("이어쓰기 시작", visible=False, variant="primary") - continue_episode_output = gr.Markdown(visible=False) # 에피소드 출력용 - continue_next_btn = gr.Button("➡️ 다음화 작성", visible=False, variant="secondary") - continue_episode_counter = gr.State(1) # 에피소드 카운터 - - # 미리보기 출력 영역 - preview_output = gr.Markdown(visible=False) - - # 내보내기 출력 영역 - export_status = gr.Markdown(visible=False) - export_file = gr.File(label="다운로드", visible=False) - - # 이어쓰기용 세션 상태 - continue_session = gr.State(None) - - # Event handlers - def handle_login(email): - """이메일 로그인 처리""" - if not email or '@' not in email: - return "❌ 올바른 이메일 주소를 입력해주세요", None, gr.update(visible=False) - - try: - user_id = WebNovelDatabase.create_or_get_user_by_email(email) - user_state = {"user_id": user_id, "email": email, "username": email.split('@')[0]} - - return ( - f"✅ {email} 로그인 완료!", - user_state, - gr.update(visible=True) - ) - except Exception as e: - logger.error(f"로그인 오류: {str(e)}") - return f"❌ 로그인 오류: {str(e)}", None, gr.update(visible=False) - - def show_genre_after_story(story_output): - """스토리 생성 후 장르 선택 표시""" - if story_output and "###" in story_output: - return gr.update(visible=True), gr.update(visible=True) - return gr.update(visible=False), gr.update(visible=False) - - def generate_webnovel_structure(selected_logline_dropdown, genre, user_state): - """Generate web novel structure from logline and genre""" - if not user_state: - return gr.update(value="### ⚠️ 먼저 로그���해주세요"), gr.update(), None - - try: - # 로그라인 선택 확인 - if not selected_logline_dropdown: - selected_logline = current_state.get("loglines", [""])[0] if current_state.get("loglines") else "" - else: - logline_num = int(selected_logline_dropdown.split(':')[0].split()[-1]) - 1 - selected_logline = current_state["loglines"][logline_num] - - if not selected_logline: - return gr.update(value="### ⚠️ 로그라인을 먼저 선택해주세요"), gr.update(), None - - # 웹소설 구조 생성 - system = WebNovelSystem() - story = system.generate_story_structure(selected_logline, genre) - - # 세션 생성 - session_id = WebNovelDatabase.create_session( - user_id=user_state['user_id'], - genre=genre, - title=story.title, - logline=selected_logline - ) - - # 스토리 구조 저장 - 1화를 작성하지 않아도 저장 - WebNovelDatabase.save_story_structure(session_id, story) - - # 스토리 계획 저장 - WebNovelDatabase.save_story_plan( - session_id, - current_state.get("selected_words", []), - selected_logline, - story.synopsis - ) - - # 출력 포맷 - output = f"## 🎭 {story.title}\n\n" - output += f"**작가:** {user_state.get('username', 'Unknown')}\n" - output += f"**장르:** {genre}\n\n" - output += f"**시놉시스:**\n{story.synopsis}\n\n" - output += f"### ✅ 40화 구성 완료! 작품이 라이브러리에 저장되었습니다.\n\n" - output += "이제 **1화 작성** 버튼을 클릭하여 웹소설을 시작하세요." - - return ( - gr.update(value=output, visible=True), - gr.update(visible=True), - {"session_id": session_id, "story": story, "user_state": user_state} - ) - - except Exception as e: - logger.error(f"웹소설 구조 생성 오류: {str(e)}", exc_info=True) - return gr.update(value=f"### ❌ 오류\n\n{str(e)}"), gr.update(), None - - def write_episode_handler(current_session, episode_num=1): - """Write an episode""" - if not current_session: - return gr.update(value="### ⚠️ 스토리 구성을 먼저 생성해주세요"), gr.update(), episode_num - - try: - session_id = current_session['session_id'] - user_state = current_session['user_state'] - - # 소유권 확인 - if not WebNovelDatabase.check_ownership(session_id, user_state['user_id']): - return gr.update(value="### ⚠️ 작품 소유권이 없습니다"), gr.update(), episode_num - - # DB에서 최신 에피소드 번호 확인 - session_info = WebNovelDatabase.get_session(session_id) - if session_info: - current_episode = session_info['current_episode'] - episode_num = current_episode + 1 - - # 에피소드 작성 - system = WebNovelSystem() - previous_episodes = WebNovelDatabase.get_episodes(session_id) - prev_contents = [ep['content'] for ep in previous_episodes] - - story = current_session['story'] - episode_data = system.write_episode(story, episode_num, prev_contents) - - # DB 저장 - WebNovelDatabase.save_episode( - session_id, - episode_num, - episode_data['title'], - episode_data['content'], - episode_data['hook'] - ) - - # 출력 포맷 - output = f"## {episode_data['title']}\n\n" - output += episode_data['content'] - output += f"\n\n---\n" - output += f"📊 **통계**\n" - output += f"- 단어 수: {episode_data['word_count']}\n" - output += f"- 진행률: {episode_num}/40화\n" - output += f"- 작가: {user_state.get('username', 'Unknown')}\n" - output += f"\n🪝 **다음 화 예고**\n> {episode_data['hook']}" - - next_visible = episode_num < 40 - - return ( - gr.update(value=output, visible=True), - gr.update(visible=next_visible), - episode_num - ) - - except Exception as e: - logger.error(f"에피소드 작성 오류: {str(e)}", exc_info=True) - return gr.update(value=f"### ❌ 오류\n\n{str(e)}"), gr.update(), episode_num - - def write_next_episode(current_session, counter): - """다음 에피소드 작성""" - next_num = counter + 1 - result = write_episode_handler(current_session, next_num) - return result[0], result[1], next_num - - def load_library(user_state): - """Load user's novel library""" - if not user_state: - return "

로그인이 필요합니다

", gr.update(), gr.update(), gr.update(), gr.update() - - sessions = WebNovelDatabase.get_user_sessions(user_state['user_id']) - - if not sessions: - return "

작성한 작품이 없습니다

", gr.update(), gr.update(), gr.update(), gr.update() - - # Create library HTML - html = "

내 작품 목록

" - html += "" - html += "" - html += "" - html += "" - html += "" - html += "" - html += "" - - novel_choices = [] - for session in sessions: - progress = f"{session['current_episode']}/{session['total_episodes']}화" - html += f"" - html += f"" - html += f"" - html += f"" - html += f"" - html += f"" - - # 드롭다운용 선택지 - session_id를 value로 사용 - novel_choices.append(f"{session['title'] or '제목 없음'} ({session['genre']}) - {progress}") - - html += "
제목장르진행률최근 수정
{session['title'] or '제목 없음'}{session['genre']}{progress}{session['updated_at'][:10]}
" - - return ( - html, - gr.update(choices=novel_choices, value=novel_choices[0] if novel_choices else None, visible=True), - gr.update(visible=True), - gr.update(visible=True), - gr.update(visible=True) - ) - - def continue_novel_writing(selected_novel_title, user_state): - """선택한 작품 이어쓰기""" - if not selected_novel_title or not user_state: - return gr.update(value="### ⚠️ 작품을 선택해주세요"), gr.update(), gr.update() - - try: - # 선택한 작품의 session_id 찾기 - sessions = WebNovelDatabase.get_user_sessions(user_state['user_id']) - selected_session = None - - for session in sessions: - # 드롭다운 형식과 매칭 - dropdown_label = f"{session['title'] or '제목 없음'} ({session['genre']}) - {session['current_episode']}/{session['total_episodes']}화" - if dropdown_label == selected_novel_title: - selected_session = session - break - - if not selected_session: - return gr.update(value="### ⚠️ 작품을 찾을 수 없습니다"), gr.update(), gr.update() - - # 스토리 구조 복원 - story_structure_json = selected_session.get('story_structure') - if not story_structure_json: - return gr.update(value="### ⚠️ 스토리 구조를 찾을 수 없습니다"), gr.update(), gr.update() - - story_dict = json.loads(story_structure_json) - # StoryStructure 객체로 변환 - story = StoryStructure() - for key, value in story_dict.items(): - if hasattr(story, key): - setattr(story, key, value) - - # 현재 에피소드 정보 업데이트 - story.current_episode = selected_session['current_episode'] - - # 현재 에피소드 번호 - current_ep = selected_session['current_episode'] + 1 - if current_ep > 40: - return gr.update(value="### ✅ 이미 완결된 작품입니다"), gr.update(), gr.update() - - # 출력 - output = f"## 🎭 {story.title}\n\n" - output += f"**작가:** {user_state.get('username', 'Unknown')}\n" - output += f"**장르:** {selected_session['genre']}\n" - output += f"**진행률:** {selected_session['current_episode']}/40화\n\n" - output += f"### 📝 {current_ep}화를 작성할 준비가 되었습니다\n\n" - output += "아래 **이어쓰기** 버튼을 클릭하여 다음 화를 작성하세요." - - # 세션 정보 저장 - session_data = { - "session_id": selected_session['session_id'], - "story": story, - "user_state": user_state - } - - return ( - gr.update(value=output, visible=True), - gr.update(visible=True, value=f"{current_ep}화 작성하기"), # label 대신 value 사용 - session_data - ) - - except Exception as e: - logger.error(f"이어쓰기 오류: {str(e)}", exc_info=True) - return gr.update(value=f"### ❌ 오류\n\n{str(e)}"), gr.update(), None - - def preview_novel(selected_novel_title, user_state): - """작품 미리보기""" - if not selected_novel_title or not user_state: - return "### ⚠️ 작품을 선택해주세요" - - try: - # 선택한 작품의 session_id 찾기 - sessions = WebNovelDatabase.get_user_sessions(user_state['user_id']) - selected_session = None - - for session in sessions: - dropdown_label = f"{session['title'] or '제목 없음'} ({session['genre']}) - {session['current_episode']}/{session['total_episodes']}화" - if dropdown_label == selected_novel_title: - selected_session = session - break - - if not selected_session: - return "### ⚠️ 작품을 찾을 수 없습니다" - - # 에피소드 가져오기 - episodes = WebNovelDatabase.get_episodes(selected_session['session_id']) - - output = f"# 📖 {selected_session['title'] or '제목 없음'}\n\n" - output += f"**장르:** {selected_session['genre']}\n" - output += f"**진행률:** {selected_session['current_episode']}/40화\n\n" - - if selected_session.get('logline'): - output += f"**로그라인:** {selected_session['logline']}\n\n" - - output += "---\n\n" - - if not episodes: - output += "*아직 작성된 에피소드가 없습니다*" - else: - for ep in episodes[:3]: # 최대 3화까지만 미리보기 - output += f"## {ep['title']}\n\n" - output += f"{ep['content'][:500]}...\n\n" # 500자까지만 - output += "---\n\n" - - if len(episodes) > 3: - output += f"*... 외 {len(episodes) - 3}화 더*" - - return output - - except Exception as e: - logger.error(f"미리보기 오류: {str(e)}", exc_info=True) - return f"### ❌ 오류\n\n{str(e)}" - - def export_novel(selected_novel_title, user_state): - """작품 내보내기 (TXT 형식)""" - if not selected_novel_title or not user_state: - return "### ⚠️ 작품을 선택해주세요", None - - try: - # 선택한 작품의 session_id 찾기 - sessions = WebNovelDatabase.get_user_sessions(user_state['user_id']) - selected_session = None - - for session in sessions: - dropdown_label = f"{session['title'] or '제목 없음'} ({session['genre']}) - {session['current_episode']}/{session['total_episodes']}화" - if dropdown_label == selected_novel_title: - selected_session = session - break - - if not selected_session: - return "### ⚠️ 작품을 찾을 수 없습니다", None - - # 에피소드 가져오기 - episodes = WebNovelDatabase.get_episodes(selected_session['session_id']) - - # TXT 파일 생성 - content = f"{selected_session['title'] or '제목 없음'}\n" - content += f"장르: {selected_session['genre']}\n" - content += f"작성일: {selected_session['created_at'][:10]}\n" - content += f"="*50 + "\n\n" - - if selected_session.get('logline'): - content += f"로그라인: {selected_session['logline']}\n\n" - - # 스토리 구조 정보 추가 - if selected_session.get('story_structure'): - try: - story_dict = json.loads(selected_session['story_structure']) - if story_dict.get('synopsis'): - content += f"시놉시스:\n{story_dict['synopsis']}\n\n" - except: - pass - - content += "="*50 + "\n\n" - - # 에피소드 추가 - for ep in episodes: - content += f"{ep['title']}\n" - content += "-"*30 + "\n" - content += f"{ep['content']}\n\n" - content += "="*50 + "\n\n" - - # 파일 저장 - filename = f"{selected_session['title'] or '웹소설'}_{datetime.now().strftime('%Y%m%d')}.txt" - - # Gradio File 컴포넌트를 위한 임시 파일 생성 - import tempfile - with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.txt', encoding='utf-8') as f: - f.write(content) - temp_path = f.name - - return f"### ✅ 내보내기 완료\n\n파일명: {filename}", temp_path - - except Exception as e: - logger.error(f"내보내기 오류: {str(e)}", exc_info=True) - return f"### ❌ 오류\n\n{str(e)}", None - - # Connect events - login_btn.click( - handle_login, - inputs=[email_input], - outputs=[login_status, user_state, main_content] - ) - - # Step 1: Logline generation with progress - random_btn.click( - fn=lambda: generate_loglines_step(""), - inputs=[], - outputs=[ - progress_output, - category_output, - loglines_output, - logline_dropdown, - generate_story_btn, - story_header - ] - ) - - generate_btn.click( - fn=lambda prompt: generate_loglines_step(prompt), - inputs=[user_prompt_input], - outputs=[ - progress_output, - category_output, - loglines_output, - logline_dropdown, - generate_story_btn, - story_header - ] - ) - - generate_story_btn.click( - fn=generate_story_from_selection, - inputs=[logline_dropdown], - outputs=[ - progress_output, - selected_logline_output, - conflicts_output, - story_arc_output, - final_details_output - ] - ).then( - fn=show_genre_after_story, - inputs=[selected_logline_output], - outputs=[genre_select, generate_webnovel_btn] - ) - - direct_story_btn.click( - fn=lambda prompt: generate_direct_story(prompt), - inputs=[user_prompt_input], - outputs=[ - progress_output, - selected_logline_output, - conflicts_output, - story_arc_output, - final_details_output - ] - ).then( - fn=show_genre_after_story, - inputs=[selected_logline_output], - outputs=[genre_select, generate_webnovel_btn] - ) - - # Step 2-3: Web novel generation - generate_webnovel_btn.click( - generate_webnovel_structure, - inputs=[logline_dropdown, genre_select, user_state], - outputs=[webnovel_structure_output, write_first_btn, current_session] - ) - - write_first_btn.click( - lambda cs: write_episode_handler(cs, 1), - inputs=[current_session], - outputs=[episode_output, write_next_btn, episode_counter] - ) - - write_next_btn.click( - write_next_episode, - inputs=[current_session, episode_counter], - outputs=[episode_output, write_next_btn, episode_counter] - ) - - # Library - refresh_library_btn.click( - load_library, - inputs=[user_state], - outputs=[library_output, selected_novel, continue_writing_btn, preview_btn, export_btn] - ) - - # 이어쓰기 버튼 클릭 - continue_writing_btn.click( - continue_novel_writing, - inputs=[selected_novel, user_state], - outputs=[continue_output, continue_episode_btn, continue_session] - ) - - # 실제 에피소드 작성 - 이어쓰기용 - continue_episode_btn.click( - write_episode_handler, - inputs=[continue_session], - outputs=[continue_episode_output, continue_next_btn, continue_episode_counter] - ) - - # 이어쓰기 다음화 작성 - continue_next_btn.click( - write_next_episode, - inputs=[continue_session, continue_episode_counter], - outputs=[continue_episode_output, continue_next_btn, continue_episode_counter] - ) - - # 미리보기 버튼 클릭 - preview_btn.click( - preview_novel, - inputs=[selected_novel, user_state], - outputs=[preview_output] - ).then( - lambda: gr.update(visible=True), - outputs=[preview_output] - ) - - # 내보내기 버튼 클릭 - export_btn.click( - export_novel, - inputs=[selected_novel, user_state], - outputs=[export_status, export_file] - ).then( - lambda status, file: (gr.update(visible=True), gr.update(visible=file is not None)), - inputs=[export_status, export_file], - outputs=[export_status, export_file] - ) - - return interface + st.error(f"⚠️ Error loading or executing the application: {str(e)}") + import traceback + st.code(traceback.format_exc()) -# Main if __name__ == "__main__": - logger.info("K-Story AI Studio Starting...") - logger.info("=" * 60) - - # Environment check - if not FRIENDLI_TOKEN: - logger.warning("FRIENDLI_TOKEN not set. Using dummy token for testing.") - - # DB 경로 확인 - logger.info(f"Database path: {DB_PATH}") - - # Initialize database - WebNovelDatabase.init_db() - logger.info("Database initialized.") - - # Hugging Face Spaces 환경 확인 - if os.getenv("SPACE_ID"): - logger.info("Running on Hugging Face Spaces") - logger.info(f"Space ID: {os.getenv('SPACE_ID')}") - - # Launch interface - interface = create_interface() - interface.launch( - server_name="0.0.0.0", - server_port=7860, - share=False - ) \ No newline at end of file + main() \ No newline at end of file