diff --git "a/app.py" "b/app.py" new file mode 100644--- /dev/null +++ "b/app.py" @@ -0,0 +1,3101 @@ +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 + +# --- 로깅 설정 --- +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 + 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.") + +# --- 환경 변수 및 상수 --- +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 = "dep89a2fld32mcm" +DB_PATH = "novel_sessions_v7.db" + +# 목표 분량 설정 (기존 값으로 복원) +TARGET_WORDS = 8000 # 안전 마진을 위해 8000단어 +MIN_WORDS_PER_WRITER = 800 # 각 작가 최소 분량 800단어 + +# --- 환경 변수 검증 --- +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.") + +# --- 전역 변수 --- +db_lock = threading.Lock() + +# 서사 진행 단계 정의 +NARRATIVE_PHASES = [ + "도입: 일상과 균열", + "발전 1: 불안의 고조", + "발전 2: 외부 충격", + "발전 3: 내적 갈등 심화", + "절정 1: 위기의 정점", + "절정 2: 선택의 순간", + "하강 1: 결과와 여파", + "하강 2: 새로운 인식", + "결말 1: 변화된 일상", + "결말 2: 열린 질문" +] + +# 편집자 단계 사용 여부 (False로 설정하면 편집자 단계 건너뛰기) +USE_EDITOR_STAGE = False # 편집자가 과도하게 삭제하는 문제로 비활성화 + +# 단계별 구성 - 편집자 단계 조건부 포함 +PROGRESSIVE_STAGES = [ + ("director", "🎬 감독자: 통합된 서사 구조 기획"), + ("critic", "📝 비평가: 서사 진행성과 깊이 검토"), + ("director", "🎬 감독자: 수정된 마스터플랜"), +] + [ + (f"writer{i}", f"✍️ 작가 {i}: 초안 - {NARRATIVE_PHASES[i-1]}") + for i in range(1, 11) +] + [ + ("critic", "📝 비평가: 중간 검토 (서사 누적성과 변화)"), +] + [ + (f"writer{i}", f"✍️ 작가 {i}: 수정본 - {NARRATIVE_PHASES[i-1]}") + for i in range(1, 11) +] + +# 편집자 단계 조건부 추가 +if USE_EDITOR_STAGE: + PROGRESSIVE_STAGES.extend([ + ("editor", "✂️ 편집자: 반복 제거 및 서사 재구성"), + ("critic", f"📝 비평가: 최종 검토 및 문학적 평가"), + ]) +else: + PROGRESSIVE_STAGES.append( + ("critic", f"📝 비평가: 최종 검토 및 문학적 평가"), + ) + +# 절대 금지사항 - 언어 오류 방지 (간소화) +STRICT_RULES = """ +금지사항: +1. 일본어, 중국어 등 외국어 문자 사용 금지 +2. 이전 단계 사건의 단순 반복 금지 +3. 캐릭터 이름 변경 금지 +""" + +# --- 데이터 클래스 --- +@dataclass +class CharacterArc: + """인물의 변화 궤적 추적""" + name: str + initial_state: Dict[str, Any] # 초기 상태 + phase_states: Dict[int, Dict[str, Any]] = field(default_factory=dict) # 단계별 상태 + transformations: List[str] = field(default_factory=list) # 주요 변화들 + relationships_evolution: Dict[str, List[str]] = field(default_factory=dict) # 관계 변화 + +@dataclass +class PlotThread: + """플롯 라인 추적""" + thread_id: str + description: str + introduction_phase: int + development_phases: List[int] + resolution_phase: Optional[int] + status: str = "active" # active, resolved, suspended + +@dataclass +class SymbolicEvolution: + """상징의 의미 변화 추적""" + symbol: str + initial_meaning: str + phase_meanings: Dict[int, str] = field(default_factory=dict) + transformation_complete: bool = False + +@dataclass +class CharacterConsistency: + """캐릭터 일관성 관리""" + primary_names: Dict[str, str] = field(default_factory=dict) # role -> canonical name + aliases: Dict[str, List[str]] = field(default_factory=dict) # canonical -> aliases + name_history: List[Tuple[int, str, str]] = field(default_factory=list) # (phase, role, used_name) + + def validate_name(self, phase: int, role: str, name: str) -> bool: + """이름 일관성 검증""" + if role in self.primary_names: + canonical = self.primary_names[role] + if name != canonical and name not in self.aliases.get(canonical, []): + return False + return True + + def register_name(self, phase: int, role: str, name: str): + """이름 등록""" + if role not in self.primary_names: + self.primary_names[role] = name + self.name_history.append((phase, role, name)) + + +# --- 핵심 로직 클래스 --- +class LanguageFilter: + """언어 혼입 및 특수기호 오류 방지""" + def __init__(self): + self.forbidden_patterns = [ + r'[ぁ-ん]+', # 히라가나 + r'[ァ-ヶ]+', # 가타카나 + r'[\u4e00-\u9fff]+', # 한자 + r'\$\s*[.,]', # 특수기호 오류 + r'[는은이가을를에의와과도로부터까지만든한다]\s*\$' # 조사 뒤 특수기호 + ] + + def clean_text(self, text: str) -> str: + """생성된 텍스트 정제""" + import re + cleaned = text + + # 일본어 문자 제거 + for pattern in self.forbidden_patterns: + cleaned = re.sub(pattern, '', cleaned) + + # 연속 공백 정리 + cleaned = re.sub(r'\s+', ' ', cleaned) + + # 문장 끝 정리 + cleaned = re.sub(r'([.!?])\s*\$', r'\1', cleaned) + + return cleaned.strip() + + +class ContentDeduplicator: + """중복 콘텐츠 감지 및 제거""" + def __init__(self): + self.seen_paragraphs = set() + self.seen_key_phrases = set() + self.similarity_threshold = 0.85 + + def check_similarity(self, text1: str, text2: str) -> float: + """두 텍스트의 유사도 측정""" + # 간단한 Jaccard 유사도 구현 + words1 = set(text1.lower().split()) + words2 = set(text2.lower().split()) + + intersection = words1.intersection(words2) + union = words1.union(words2) + + return len(intersection) / len(union) if union else 0 + + def extract_key_phrases(self, text: str) -> List[str]: + """핵심 문구 추출""" + # 20자 이상의 문장들을 핵심 문구로 간주 + sentences = [s.strip() for s in re.split(r'[.!?]', text) if len(s.strip()) > 20] + return sentences[:5] # 상위 5개만 + + def is_duplicate(self, paragraph: str) -> bool: + """중복 문단 감지""" + # 핵심 문구 체크 + key_phrases = self.extract_key_phrases(paragraph) + for phrase in key_phrases: + if phrase in self.seen_key_phrases: + return True + + # 전체 문단 유사도 체크 + for seen in self.seen_paragraphs: + if self.check_similarity(paragraph, seen) > self.similarity_threshold: + return True + + # 중복이 아니면 저장 + self.seen_paragraphs.add(paragraph) + self.seen_key_phrases.update(key_phrases) + return False + + def get_used_elements(self) -> List[str]: + """사용된 핵심 요소 반환""" + return list(self.seen_key_phrases)[:10] # 최근 10개 + + def count_repetitions(self, content: str) -> int: + """텍스트 내의 반복 횟수 계산""" + paragraphs = content.split('\n\n') + repetitions = 0 + + for i, para1 in enumerate(paragraphs): + for para2 in paragraphs[i+1:]: + if self.check_similarity(para1, para2) > 0.7: + repetitions += 1 + + return repetitions + + +class RealTimeConsistencyChecker: + """실시간 서사 일관성 검증""" + def __init__(self): + self.plot_graph = {} # 인과관계 그래프 + self.character_states = {} # 캐릭터 상태 추적 + self.event_timeline = [] # 시간순 이벤트 + self.resolved_conflicts = set() # 해결된 갈등 + + def validate_new_content(self, phase: int, content: str, + previous_contents: List[str]) -> Tuple[bool, List[str]]: + """새 콘텐츠의 일관성 검증""" + issues = [] + + # 1. 시간 역행 검사 + time_markers = self.extract_time_markers(content) + if self.check_time_contradiction(time_markers): + issues.append("시간 순서 모순 발견") + + # 2. 캐릭터 상태 모순 검사 + character_actions = self.extract_character_actions(content) + for char, action in character_actions.items(): + if not self.is_action_possible(char, action, phase): + issues.append(f"{char}의 행동이 이전 상태와 모순됨") + + # 3. 이미 해결된 갈등의 재등장 검사 + conflicts = self.extract_conflicts(content) + for conflict in conflicts: + if conflict in self.resolved_conflicts: + issues.append(f"이미 해결된 갈등 '{conflict}'이 다시 등장") + + return len(issues) == 0, issues + + def extract_time_markers(self, content: str) -> List[str]: + """시간 표지 추출""" + markers = re.findall(r'(아침|점심|저녁|밤|새벽|오전|오후|다음날|며칠 후|일주일 후)', content) + return markers + + def extract_character_actions(self, content: str) -> Dict[str, str]: + """캐릭터 행동 추출""" + actions = {} + # 간단한 패턴 매칭 (실제로는 더 정교한 NLP 필요) + patterns = re.findall(r'(\w+)(?:이|가|은|는)\s+(\w+(?:했다|한다|하다))', content) + for char, action in patterns: + actions[char] = action + return actions + + def extract_conflicts(self, content: str) -> List[str]: + """갈등 요소 추출""" + conflict_keywords = ['갈등', '대립', '충돌', '문제', '위기'] + conflicts = [] + for keyword in conflict_keywords: + if keyword in content: + # 주변 문맥 추출 + idx = content.index(keyword) + context = content[max(0, idx-20):min(len(content), idx+20)] + conflicts.append(context) + return conflicts + + def check_time_contradiction(self, markers: List[str]) -> bool: + """시간 모순 검사""" + # 간단한 시간 순서 체크 + time_order = ['새벽', '아침', '오전', '점심', '오후', '저녁', '밤'] + prev_idx = -1 + for marker in markers: + if marker in time_order: + curr_idx = time_order.index(marker) + if curr_idx < prev_idx: + return True + prev_idx = curr_idx + return False + + def is_action_possible(self, character: str, action: str, phase: int) -> bool: + """캐릭터 행동 가능성 검사""" + # 캐릭터 상태 기반 검증 + if character not in self.character_states: + self.character_states[character] = {"phase": phase, "status": "active"} + return True + + char_state = self.character_states[character] + # 예: 죽은 캐릭터가 다시 등장하는 경우 등 + if char_state.get("status") == "dead" and "했다" in action: + return False + + return True + + +class ProgressionMonitor: + """실시간 서사 진행 모니터링""" + def __init__(self): + self.phase_keywords = {} + self.locations = set() + self.characters = set() + + def count_new_characters(self, content: str, phase: int) -> int: + """새로운 인물 등장 횟수""" + # 간단한 고유명사 추출 (대문자로 시작하는 단어) + potential_names = re.findall(r'\b[A-Z가-힣][a-z가-힣]+\b', content) + new_chars = set(potential_names) - self.characters + self.characters.update(new_chars) + return len(new_chars) + + def count_new_locations(self, content: str, phase: int) -> int: + """새로운 장소 등장 횟수""" + # 장소 관련 키워드 + location_markers = ['에서', '으로', '에', '의', 'at', 'in', 'to'] + new_locs = 0 + + for marker in location_markers: + matches = re.findall(rf'(\S+)\s*{marker}', content) + for match in matches: + if match not in self.locations and len(match) > 2: + self.locations.add(match) + new_locs += 1 + + return new_locs + + def calculate_content_difference(self, current_phase: int, content: str, previous_content: str) -> float: + """이전 단계와의 내용 차이 비율""" + if not previous_content: + return 1.0 + + dedup = ContentDeduplicator() + return 1.0 - dedup.check_similarity(content, previous_content) + + def count_repetitions(self, content: str) -> int: + """반복 횟수 계산""" + paragraphs = content.split('\n\n') + repetitions = 0 + + for i, para1 in enumerate(paragraphs): + for para2 in paragraphs[i+1:]: + similarity = ContentDeduplicator().check_similarity(para1, para2) + if similarity > 0.7: + repetitions += 1 + + return repetitions + + def calculate_progression_score(self, current_phase: int, content: str, previous_content: str = "") -> Dict[str, float]: + """진행도 점수 계산""" + + scores = { + "new_elements": 0.0, # 새로운 요소 + "character_growth": 0.0, # 인물 성장 + "plot_advancement": 0.0, # 플롯 진전 + "no_repetition": 0.0 # 반복 없음 + } + + # 새로운 요소 체크 + new_characters = self.count_new_characters(content, current_phase) + new_locations = self.count_new_locations(content, current_phase) + scores["new_elements"] = min(10, (new_characters * 3 + new_locations * 2)) + + # 성장 관련 키워드 + growth_keywords = ["깨달았다", "이제는", "달라졌다", "새롭게", "비로소", "변했다", "더 이상"] + growth_count = sum(1 for k in growth_keywords if k in content) + scores["character_growth"] = min(10, growth_count * 2) + + # 플롯 진전 (이전 단계와의 차이) + if current_phase > 1 and previous_content: + diff_ratio = self.calculate_content_difference(current_phase, content, previous_content) + scores["plot_advancement"] = min(10, diff_ratio * 10) + else: + scores["plot_advancement"] = 8.0 # 첫 단계는 기본 점수 + + # 반복 체크 (역산) + repetition_count = self.count_repetitions(content) + scores["no_repetition"] = max(0, 10 - repetition_count * 2) + + return scores + + +class ProgressiveNarrativeTracker: + """서사 진행과 누적을 추적하는 시스템""" + def __init__(self): + self.character_arcs: Dict[str, CharacterArc] = {} + self.plot_threads: Dict[str, PlotThread] = {} + self.symbolic_evolutions: Dict[str, SymbolicEvolution] = {} + self.phase_summaries: Dict[int, str] = {} + self.accumulated_events: List[Dict[str, Any]] = [] + self.thematic_deepening: List[str] = [] + self.philosophical_insights: List[str] = [] # 철학적 통찰 추적 + self.literary_devices: Dict[int, List[str]] = {} # 문학적 기법 사용 추적 + self.character_consistency = CharacterConsistency() # 캐릭터 일관성 추가 + self.content_deduplicator = ContentDeduplicator() # 중복 감지기 추가 + self.progression_monitor = ProgressionMonitor() # 진행도 모니터 추가 + self.used_expressions: Set[str] = set() # 사용된 표현 추적 + self.consistency_checker = RealTimeConsistencyChecker() # 실시간 일관성 체커 추가 + + def register_character_arc(self, name: str, initial_state: Dict[str, Any]): + """캐릭터 아크 등록""" + self.character_arcs[name] = CharacterArc(name=name, initial_state=initial_state) + self.character_consistency.register_name(0, "protagonist", name) + logger.info(f"Character arc registered: {name}") + + def update_character_state(self, name: str, phase: int, new_state: Dict[str, Any], transformation: str): + """캐릭터 상태 업데이트 및 변화 기록""" + if name in self.character_arcs: + arc = self.character_arcs[name] + arc.phase_states[phase] = new_state + arc.transformations.append(f"Phase {phase}: {transformation}") + logger.info(f"Character {name} transformed in phase {phase}: {transformation}") + + def add_plot_thread(self, thread_id: str, description: str, intro_phase: int): + """새로운 플롯 라인 추가""" + self.plot_threads[thread_id] = PlotThread( + thread_id=thread_id, + description=description, + introduction_phase=intro_phase, + development_phases=[] + ) + + def develop_plot_thread(self, thread_id: str, phase: int): + """플롯 라인 발전""" + if thread_id in self.plot_threads: + self.plot_threads[thread_id].development_phases.append(phase) + + def check_narrative_progression(self, current_phase: int) -> Tuple[bool, List[str]]: + """서사가 실제로 진행되고 있는지 확인""" + issues = [] + + # 1. 캐릭터 변화 확인 + static_characters = [] + for name, arc in self.character_arcs.items(): + if len(arc.transformations) < current_phase // 3: # 최소 3단계마다 변화 필요 + static_characters.append(name) + + if static_characters: + issues.append(f"다음 인물들의 변화가 부족합니다: {', '.join(static_characters)}") + + # 2. 플롯 진행 확인 + unresolved_threads = [] + for thread_id, thread in self.plot_threads.items(): + if thread.status == "active" and len(thread.development_phases) < 2: + unresolved_threads.append(thread.description) + + if unresolved_threads: + issues.append(f"진전되지 않은 플롯: {', '.join(unresolved_threads)}") + + # 3. 상징 발전 확인 + static_symbols = [] + for symbol, evolution in self.symbolic_evolutions.items(): + if len(evolution.phase_meanings) < current_phase // 4: + static_symbols.append(symbol) + + if static_symbols: + issues.append(f"의미가 발전하지 않은 상징: {', '.join(static_symbols)}") + + # 4. 철학적 깊이 확인 + if len(self.philosophical_insights) < current_phase // 2: + issues.append("철학적 성찰과 인간에 대한 통찰이 부족합니다") + + # 5. 문학적 기법 다양성 + unique_devices = set() + for devices in self.literary_devices.values(): + unique_devices.update(devices) + if len(unique_devices) < 5: + issues.append("문학적 기법이 단조롭습니다. 더 다양한 표현 기법이 필요합니다") + + # 6. 캐릭터 이름 일관성 + name_issues = [] + for phase, role, name in self.character_consistency.name_history: + if not self.character_consistency.validate_name(phase, role, name): + name_issues.append(f"Phase {phase}: {role} 이름 불일치 ({name})") + if name_issues: + issues.extend(name_issues) + + # 7. 인과관계 체크 (추가) + if current_phase > 3: + # 사건의 연쇄성 확인 + if len(self.accumulated_events) < current_phase - 1: + issues.append("사건 간 인과관계가 불명확합니다. 각 사건이 다음 사건의 원인이 되어야 합니다") + + return len(issues) == 0, issues + + def generate_phase_requirements(self, phase: int) -> str: + """각 단계별 필수 요구사항 생성 (간소화)""" + requirements = [] + + # 이전 단계 요약 + if phase > 1 and (phase-1) in self.phase_summaries: + requirements.append(f"이전 단계 핵심: {self.phase_summaries[phase-1][:200]}...") + + # 사용된 표현 목록 (5개만) + if self.used_expressions: + requirements.append("\n❌ 다음 표현은 이미 사용됨:") + for expr in list(self.used_expressions)[-5:]: # 최근 5개만 + requirements.append(f"- {expr[:50]}...") + + # 단계별 특수 요구사항 + phase_name = NARRATIVE_PHASES[phase-1] if phase <= 10 else "수정" + + requirements.append(f"\n✅ {phase_name} 필수 포함:") + requirements.append(f"- 최소 {MIN_WORDS_PER_WRITER}단어 작성") + requirements.append("- 구체적인 장면 묘사와 대화") + requirements.append("- 인물의 내면 탐구") + requirements.append("- 이전 단계의 결과로 시작") + + return "\n".join(requirements) + + def extract_used_elements(self, content: str): + """사용된 핵심 표현 추출 및 저장""" + # 20자 이상의 특징적인 문장들 추출 + sentences = re.findall(r'[^.!?]+[.!?]', content) + for sent in sentences: + if len(sent) > 20 and len(sent) < 100: + self.used_expressions.add(sent.strip()) + + +class NovelDatabase: + """데이터베이스 관리""" + @staticmethod + def init_db(): + with sqlite3.connect(DB_PATH) as conn: + conn.execute("PRAGMA journal_mode=WAL") + cursor = conn.cursor() + + # 기존 테이블들 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + user_query TEXT NOT NULL, + language TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + status TEXT DEFAULT 'active', + current_stage INTEGER DEFAULT 0, + final_novel TEXT, + literary_report TEXT, + total_words INTEGER DEFAULT 0, + narrative_tracker TEXT + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS stages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + stage_number INTEGER NOT NULL, + stage_name TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT, + word_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'pending', + progression_score REAL DEFAULT 0.0, + repetition_score REAL DEFAULT 0.0, + consistency_check TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (session_id) REFERENCES sessions(session_id), + UNIQUE(session_id, stage_number) + ) + ''') + + cursor.execute(''' + CREATE TABLE IF NOT EXISTS plot_threads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + thread_id TEXT NOT NULL, + description TEXT, + introduction_phase INTEGER, + status TEXT DEFAULT 'active', + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ) + ''') + + # 새로운 테이블: 중복 감지 기록 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS duplicate_detection ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + phase INTEGER NOT NULL, + duplicate_content TEXT, + original_phase INTEGER, + similarity_score REAL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (session_id) REFERENCES sessions(session_id) + ) + ''') + + # 새로운 테이블: 품질 평가 기록 + cursor.execute(''' + CREATE TABLE IF NOT EXISTS quality_evaluations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + evaluation_type TEXT NOT NULL, + score REAL, + details TEXT, + 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_session(user_query: str, language: str) -> str: + session_id = hashlib.md5(f"{user_query}{datetime.now()}".encode()).hexdigest() + with NovelDatabase.get_db() as conn: + conn.cursor().execute( + 'INSERT INTO sessions (session_id, user_query, language) VALUES (?, ?, ?)', + (session_id, user_query, language) + ) + conn.commit() + return session_id + + @staticmethod + def save_stage(session_id: str, stage_number: int, stage_name: str, + role: str, content: str, status: str = 'complete', + progression_score: float = 0.0, repetition_score: float = 0.0, + consistency_check: str = ""): + word_count = len(content.split()) if content else 0 + with NovelDatabase.get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO stages (session_id, stage_number, stage_name, role, content, word_count, status, progression_score, repetition_score, consistency_check) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id, stage_number) + DO UPDATE SET content=?, word_count=?, status=?, stage_name=?, progression_score=?, repetition_score=?, consistency_check=?, updated_at=datetime('now') + ''', (session_id, stage_number, stage_name, role, content, word_count, status, progression_score, repetition_score, consistency_check, + content, word_count, status, stage_name, progression_score, repetition_score, consistency_check)) + + # 총 단어 수 업데이트 + cursor.execute(''' + UPDATE sessions + SET total_words = ( + SELECT SUM(word_count) + FROM stages + WHERE session_id = ? AND role LIKE 'writer%' AND content IS NOT NULL + ), + updated_at = datetime('now'), + current_stage = ? + WHERE session_id = ? + ''', (session_id, stage_number, session_id)) + + conn.commit() + + @staticmethod + def get_writer_content(session_id: str) -> str: + """작가 콘텐츠 가져오기 (수정본 우선)""" + with NovelDatabase.get_db() as conn: + all_content = [] + for writer_num in range(1, 11): + # 수정본이 있으면 수정본을, 없으면 초안을 + row = conn.cursor().execute(''' + SELECT content FROM stages + WHERE session_id = ? AND role = ? + AND stage_name LIKE '%수정본%' + ORDER BY stage_number DESC LIMIT 1 + ''', (session_id, f'writer{writer_num}')).fetchone() + + if not row or not row['content']: + # 수정본이 없으면 초안 사용 + row = conn.cursor().execute(''' + SELECT content FROM stages + WHERE session_id = ? AND role = ? + AND stage_name LIKE '%초안%' + ORDER BY stage_number DESC LIMIT 1 + ''', (session_id, f'writer{writer_num}')).fetchone() + + if row and row['content']: + all_content.append(row['content'].strip()) + + return '\n\n'.join(all_content) + + @staticmethod + def get_total_words(session_id: str) -> int: + """총 단어 수 가져오기""" + with NovelDatabase.get_db() as conn: + row = conn.cursor().execute( + 'SELECT total_words FROM sessions WHERE session_id = ?', + (session_id,) + ).fetchone() + return row['total_words'] if row and row['total_words'] else 0 + + @staticmethod + def save_narrative_tracker(session_id: str, tracker: ProgressiveNarrativeTracker): + """서사 추적기 저장""" + with NovelDatabase.get_db() as conn: + tracker_data = json.dumps({ + 'character_arcs': {k: asdict(v) for k, v in tracker.character_arcs.items()}, + 'plot_threads': {k: asdict(v) for k, v in tracker.plot_threads.items()}, + 'phase_summaries': tracker.phase_summaries, + 'thematic_deepening': tracker.thematic_deepening, + 'philosophical_insights': tracker.philosophical_insights, + 'literary_devices': tracker.literary_devices, + 'character_consistency': asdict(tracker.character_consistency), + 'used_expressions': list(tracker.used_expressions) + }) + conn.cursor().execute( + 'UPDATE sessions SET narrative_tracker = ? WHERE session_id = ?', + (tracker_data, session_id) + ) + conn.commit() + + @staticmethod + def load_narrative_tracker(session_id: str) -> Optional[ProgressiveNarrativeTracker]: + """서사 추적기 로드""" + with NovelDatabase.get_db() as conn: + row = conn.cursor().execute( + 'SELECT narrative_tracker FROM sessions WHERE session_id = ?', + (session_id,) + ).fetchone() + + if row and row['narrative_tracker']: + data = json.loads(row['narrative_tracker']) + tracker = ProgressiveNarrativeTracker() + + # 데이터 복원 + for name, arc_data in data.get('character_arcs', {}).items(): + tracker.character_arcs[name] = CharacterArc(**arc_data) + for thread_id, thread_data in data.get('plot_threads', {}).items(): + tracker.plot_threads[thread_id] = PlotThread(**thread_data) + tracker.phase_summaries = data.get('phase_summaries', {}) + tracker.thematic_deepening = data.get('thematic_deepening', []) + tracker.philosophical_insights = data.get('philosophical_insights', []) + tracker.literary_devices = data.get('literary_devices', {}) + + # 캐릭터 일관성 복원 + if 'character_consistency' in data: + tracker.character_consistency = CharacterConsistency(**data['character_consistency']) + + # 사용된 표현 복원 + if 'used_expressions' in data: + tracker.used_expressions = set(data['used_expressions']) + + return tracker + return None + + @staticmethod + def save_duplicate_detection(session_id: str, phase: int, duplicate_content: str, + original_phase: int, similarity_score: float): + """중복 감지 기록 저장""" + with NovelDatabase.get_db() as conn: + conn.cursor().execute(''' + INSERT INTO duplicate_detection + (session_id, phase, duplicate_content, original_phase, similarity_score) + VALUES (?, ?, ?, ?, ?) + ''', (session_id, phase, duplicate_content, original_phase, similarity_score)) + conn.commit() + + @staticmethod + def save_quality_evaluation(session_id: str, evaluation_type: str, score: float, details: str): + """품질 평가 저장""" + with NovelDatabase.get_db() as conn: + conn.cursor().execute(''' + INSERT INTO quality_evaluations + (session_id, evaluation_type, score, details) + VALUES (?, ?, ?, ?) + ''', (session_id, evaluation_type, score, details)) + conn.commit() + + @staticmethod + def get_session(session_id: str) -> Optional[Dict]: + with NovelDatabase.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 get_stages(session_id: str) -> List[Dict]: + with NovelDatabase.get_db() as conn: + rows = conn.cursor().execute('SELECT * FROM stages WHERE session_id = ? ORDER BY stage_number', (session_id,)).fetchall() + return [dict(row) for row in rows] + + @staticmethod + def update_final_novel(session_id: str, final_novel: str, literary_report: str = ""): + with NovelDatabase.get_db() as conn: + conn.cursor().execute( + "UPDATE sessions SET final_novel = ?, status = 'complete', updated_at = datetime('now'), literary_report = ? WHERE session_id = ?", + (final_novel, literary_report, session_id) + ) + conn.commit() + + @staticmethod + def get_active_sessions() -> List[Dict]: + with NovelDatabase.get_db() as conn: + rows = conn.cursor().execute( + """SELECT session_id, user_query, language, created_at, current_stage, + COALESCE(total_words, 0) as total_words + FROM sessions + WHERE status = 'active' + ORDER BY updated_at DESC + LIMIT 10""" + ).fetchall() + return [dict(row) for row in rows] + + +class WebSearchIntegration: + """웹 검색 기능""" + def __init__(self): + self.brave_api_key = BRAVE_SEARCH_API_KEY + self.search_url = "https://api.search.brave.com/res/v1/web/search" + self.enabled = bool(self.brave_api_key) + + def search(self, query: str, count: int = 3, language: str = "en") -> List[Dict]: + if not self.enabled: + return [] + headers = { + "Accept": "application/json", + "X-Subscription-Token": self.brave_api_key + } + params = { + "q": query, + "count": count, + "search_lang": "ko" if language == "Korean" else "en", + "text_decorations": False, + "safesearch": "moderate" + } + try: + response = requests.get(self.search_url, headers=headers, params=params, timeout=10) + response.raise_for_status() + results = response.json().get("web", {}).get("results", []) + return results + except requests.exceptions.RequestException as e: + logger.error(f"웹 검색 API 오류: {e}") + return [] + + def extract_relevant_info(self, results: List[Dict], max_chars: int = 1500) -> str: + if not results: + return "" + extracted = [] + total_chars = 0 + for i, result in enumerate(results[:3], 1): + title = result.get("title", "") + description = result.get("description", "") + info = f"[{i}] {title}: {description}" + if total_chars + len(info) < max_chars: + extracted.append(info) + total_chars += len(info) + else: + break + return "\n".join(extracted) + + +class ProgressiveLiterarySystem: + """진행형 문학 소설 생성 시스템""" + def __init__(self): + self.token = FRIENDLI_TOKEN + self.api_url = API_URL + self.model_id = MODEL_ID + self.narrative_tracker = ProgressiveNarrativeTracker() + self.web_search = WebSearchIntegration() + self.language_filter = LanguageFilter() # 언어 필터 추가 + self.current_session_id = None + NovelDatabase.init_db() + + def augment_prompt_if_needed(self, user_query: str, language: str) -> str: + """프롬프트 증강 - 특별한 지시가 없을 때 예시 형식으로 증강""" + # 특별한 지시 키워드 체크 + special_keywords = ['인과관계', '캐릭터 일관성', '설정 일관성', '문체 일관성', + 'causality', 'character consistency', 'setting consistency', 'style consistency'] + + has_special_instruction = any(keyword in user_query.lower() for keyword in special_keywords) + + if has_special_instruction: + return user_query + + # 특별한 지시가 없으면 예시 형식으로 증강 + example_augmentation = { + "Korean": f"""인과관계 치밀함: {user_query}. 모든 사건은 필연적 인과관계로 연결되며, 외부 충격이 내적 갈등을 심화시키고, 각 선택이 다음 국면을 결정한다. 우연은 없고 모든 장면이 결말을 향한 필수 요소가 된다. +캐릭터 일관성: 주인공은 처음부터 끝까지 핵심 가치관을 유지하되, 사건을 통해 그 가치관의 새로운 면을 발견한다. 각 인물은 고유한 말투와 행동 패턴을 가지며, 갈등 상황에서도 캐릭터의 본질에서 벗어나지 않는다. +설정 일관성: 핵심 상징물은 이야기 전체를 관통하며 점진적으로 의미가 확장된다. 공간과 시간 설정은 단순한 배경이 아니라 서사의 필수 요소로 기능한다. +문체 일관성: 선택한 서술 시점과 문체 톤을 끝까지 유지하되, 서사의 흐름에 따라 리듬과 호흡을 조절한다. 장르적 특성을 살린 문체로 독자를 몰입시킨다.""", + + "English": f"""Causal Tightness: {user_query}. All events connect through necessary causality, external shocks deepen internal conflicts, each choice determines the next phase. No coincidences - every scene is essential to the conclusion. +Character Consistency: Protagonist maintains core values throughout while discovering new facets through events. Each character has unique speech patterns and behaviors, staying true to essence even in conflict. +Setting Consistency: Core symbols pervade the entire story with gradually expanding meanings. Space and time settings function as narrative necessities, not mere backdrops. +Style Consistency: Maintain chosen POV and tone throughout while adjusting rhythm and pacing to narrative flow. Genre-appropriate style immerses readers.""" + } + + return example_augmentation.get(language, example_augmentation["Korean"]) + + def create_headers(self): + return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"} + + # --- 프롬프트 생성 함수들 --- + def create_director_initial_prompt(self, user_query: str, language: str) -> str: + """감독자 초기 기획 - 통합된 서사 구조""" + # 프롬프트 증강 - 특별한 지시가 없을 때 예시 형식으로 증강 + augmented_query = self.augment_prompt_if_needed(user_query, language) + + search_results_str = "" + if self.web_search.enabled: + # 철학적 키워드 추가 (쿼리 길이 제한) + short_query = user_query[:50] if len(user_query) > 50 else user_query + queries = [ + f"{short_query} 철학적 의미", # 철학적 관점 + f"인간 존재 의미 {short_query}", # 실존적 주제 + f"{short_query} 문학 작품", + ] + for q in queries[:2]: # 2개까지만 검색 + try: + results = self.web_search.search(q, count=2, language=language) + if results: + search_results_str += self.web_search.extract_relevant_info(results)[:500] + "\n" + except Exception as e: + logger.warning(f"검색 쿼리 실패: {q[:50]}... - {str(e)}") + continue + + lang_prompts = { + "Korean": f"""당신은 노벨문학상 수상작가 수준의 한국 문학 거장입니다. +중편 소설(8,000단어)을 위한 통합된 서사 구조를 기획하세요. + +**주제:** {augmented_query} + +**참고 자료:** +{search_results_str[:500] if search_results_str else "N/A"} + +**필수 요구사항:** + +1. **인과관계가 치밀한 통합 서사** + - 10개 단계가 필연적으로 연결된 단일 서사 + - 각 단계는 이전 단계의 직접적 결과로 이어짐 + - 외부 충격과 내적 갈등의 인과관계 명확히 + - 주인공과 조연들의 이름/설정 처음부터 고정 + + 단계별 진행: + 1) 도입: 일상과 균열 + 2) 발전 1: 불안의 고조 + 3) 발전 2: 외부 충격 + 4) 발전 3: 내적 갈등 심화 + 5) 절정 1: 위기의 정점 + 6) 절정 2: 선택의 순간 + 7) 하강 1: 결과와 여파 + 8) 하강 2: 새로운 인식 + 9) 결말 1: 변화된 일상 + 10) 결말 2: 열린 질문 + +2. **캐릭터 일관성과 입체성** + - 각 인물의 핵심 가치관/성격 명시 + - 주인공: 초기 → 중간 → 최종 상태의 변화 궤적 + - 조연들: 고유한 말투와 행동 패턴 설정 + - 갈등이 인물의 본질을 드러내도록 설계 + +3. **설정과 상징의 일관된 활용** + - 핵심 상징물(1-2개)의 의미 변화 추적 + - 공간/시간 설정의 서사적 기능 명시 + - 반복되는 모티프의 점진적 의미 확장 + +4. **문체 일관성 계획** + - 서술 시점과 문체 톤 결정 + - 장르적 특성을 살린 문체 전략 + - 리듬과 호흡의 변주 계획 + +5. **분량 계획** + - 총 8,000단어 + - 각 단계 800단어 + +치밀하고 유기적인 계획을 제시하세요.""", + + "English": f"""You are a Nobel Prize-winning literary master. +Plan an integrated narrative structure for a novella (8,000 words). + +**Theme:** {augmented_query} + +**Reference:** +{search_results_str[:500] if search_results_str else "N/A"} + +**Requirements:** + +1. **Causally Tight Integrated Structure** + - 10 phases connected by necessity + - Each phase as direct result of previous + - Clear external conflicts and internal struggles + - Fixed protagonist/supporting cast names + +2. **Character Consistency & Depth** + - Core values/personality for each character + - Protagonist's transformation trajectory + - Unique speech patterns and behaviors + - Conflicts reveal character essence + +3. **Consistent Settings & Symbols** + - 1-2 core symbols with evolving meanings + - Narrative function of space/time settings + - Progressive expansion of recurring motifs + +4. **Stylistic Consistency Plan** + - Narrative POV and tone decisions + - Genre-appropriate style strategy + - Rhythm and pacing variations + +5. **Length Plan** + - Total 8,000 words + - 800 words per phase + +Present precise, organic plan.""" + } + + return lang_prompts.get(language, lang_prompts["Korean"]) + + def create_writer_prompt_enhanced(self, writer_number: int, director_plan: str, + previous_content: str, phase_requirements: str, + narrative_summary: str, language: str, + used_elements: List[str]) -> str: + """강화된 작가 프롬프트 - 분량 문제 해결""" + + phase_name = NARRATIVE_PHASES[writer_number-1] + target_words = MIN_WORDS_PER_WRITER + + # 현재까지 총 단어 수 + total_words = NovelDatabase.get_total_words(self.current_session_id) if self.current_session_id else 0 + remaining_words = TARGET_WORDS - total_words + + lang_prompts = { + "Korean": f"""당신은 작가 {writer_number}번입니다. +**현재 단계: {phase_name}** + +**전체 계획 (요약):** +{director_plan[:800]} + +**이전 내용:** +{previous_content[-800:] if previous_content else "시작"} + +**현재 진행 상황:** +- 현재까지 총 단어: {total_words} +- 목표까지 남은 단어: {remaining_words} +- 이번 단계 최소 단어: {target_words} + +**작성 지침:** + +1. **필수 분량: {target_words}-1000 단어** + +2. **서사의 필연성 확보:** + - 이전 단계의 직접적 결과로 시작 + - 새로운 사건은 기존 갈등의 논리적 귀결 + - 인물의 선택이 다음 국면을 결정 + - 우연이 아닌 필연으로 전개 + +3. **캐릭터 일관성 유지:** + - 설정된 인물명과 관계 엄수 + - 각 인물의 고유 말투 유지 + - 성격의 일관성 속에서 변화 표현 + - 행동이 캐릭터의 본질에서 출발 + +4. **구체적 장면 구성:** + - 감각적 묘사 (시각, 청각, 촉각 등) + - 생생한 대화 (인물별 어투 차별화) + - 내면 심리의 구체적 표현 + - 공간과 시간의 명확한 설정 + +5. **금지사항:** + - 이전 상황 단순 반복 + - 캐릭터 이름/설정 변경 + - 갑작스러운 설정 변화 + - 분량 미달 + +**중요: 반드시 {target_words}단어 이상, 인과관계가 명확한 서사를 작성하세요!** + +이제 시작하세요:""", + + "English": f"""You are Writer #{writer_number}. +**Current Phase: {phase_name}** + +**Overall Plan (Summary):** +{director_plan[:800]} + +**Previous Content:** +{previous_content[-800:] if previous_content else "Beginning"} + +**Progress Status:** +- Total words so far: {total_words} +- Words remaining to target: {remaining_words} +- Minimum words this phase: {target_words} + +**Writing Guidelines:** + +1. **Required Length: {target_words}-1000 words** + +2. **Narrative Necessity:** + - Start from direct results of previous + - New events as logical consequences + - Character choices determine next phase + - Necessity, not coincidence + +3. **Character Consistency:** + - Maintain established names/relationships + - Keep unique speech patterns + - Express change within consistent personality + - Actions stem from character essence + +4. **Concrete Scene Construction:** + - Sensory descriptions (visual, auditory, tactile) + - Vivid dialogue (differentiated speech) + - Specific psychological expression + - Clear spatial/temporal settings + +5. **Forbidden:** + - Simple repetition + - Character name/setting changes + - Sudden setting shifts + - Under word count + +**IMPORTANT: Must write {target_words}+ words with clear causality!** + +Begin now:""" + } + + return lang_prompts.get(language, lang_prompts["Korean"]) + + def create_critic_consistency_prompt_enhanced(self, all_content: str, + narrative_tracker: ProgressiveNarrativeTracker, + user_query: str, language: str) -> str: + """강화된 비평가 중간 검토""" + + # 서사 진행 체크 + phase_count = len(narrative_tracker.phase_summaries) + progression_ok, issues = narrative_tracker.check_narrative_progression(phase_count) + + # 중복 감지 + duplicates = [] + paragraphs = all_content.split('\n\n') + for i, para1 in enumerate(paragraphs[:10]): # 최근 10개만 + for j, para2 in enumerate(paragraphs[i+1:i+11]): + if narrative_tracker.content_deduplicator.check_similarity(para1, para2) > 0.7: + duplicates.append(f"문단 {i+1}과 문단 {i+j+2} 유사") + + # 인과관계 체크 (추가) + causality_issues = [] + if phase_count > 3: + # 각 단계가 이전 단계의 결과인지 확인 + if "그러나 갑자기" in all_content or "우연히" in all_content or "뜻밖에" in all_content: + causality_issues.append("우연적 사건 발생 - 필연적 전개 필요") + + lang_prompts = { + "Korean": f"""서사 진행을 검토하세요. + +**원 주제:** {user_query} + +**현재 진행:** {phase_count}/10 단계 + +**발견된 문제:** +{chr(10).join(issues[:5]) if issues else "없음"} + +**중복 발견:** +{chr(10).join(duplicates[:3]) if duplicates else "없음"} + +**인과관계 문제:** +{chr(10).join(causality_issues) if causality_issues else "없음"} + +**검토 항목:** +1. 서사가 필연적으로 진행되는가? +2. 인물이 일관되게 변화하는가? +3. 반복이나 자기복제가 있는가? +4. 각 단계가 이전의 결과인가? +5. 분량이 충분한가? + +**판정:** 통과/재작성 필요 + +치밀한 인과관계와 캐릭터 일관성을 중심으로 평가하세요.""", + + "English": f"""Review narrative progression. + +**Theme:** {user_query} + +**Progress:** {phase_count}/10 phases + +**Issues Found:** +{chr(10).join(issues[:5]) if issues else "None"} + +**Duplications:** +{chr(10).join(duplicates[:3]) if duplicates else "None"} + +**Causality Issues:** +{chr(10).join(causality_issues) if causality_issues else "None"} + +**Review Items:** +1. Is narrative progressing necessarily? +2. Are characters changing consistently? +3. Any repetitions or self-copying? +4. Does each phase result from previous? +5. Sufficient length? + +**Verdict:** Pass/Rewrite needed + +Evaluate focusing on tight causality and character consistency.""" + } + + return lang_prompts.get(language, lang_prompts["Korean"]) + + + def create_writer_revision_prompt(self, writer_number: int, initial_content: str, + critic_feedback: str, language: str) -> str: + """작가 수정 프롬프트 (간소화)""" + target_words = MIN_WORDS_PER_WRITER + + lang_prompts = { + "Korean": f"""작가 {writer_number}번, 수정하세요. + +**비평 요점:** +{critic_feedback[:500]} + +**수정 방향:** +1. 반복 제거, 새로운 전개 추가 +2. 최소 {target_words}단어 유지 +3. 인물 변화 구체화 +4. 대화와 묘사 추가 + +전면 재작성이 필요하면 과감히 수정하세요. +수정본만 제시하세요.""", + + "English": f"""Writer #{writer_number}, revise. + +**Critique Points:** +{critic_feedback[:500]} + +**Revision Direction:** +1. Remove repetition, add new development +2. Maintain {target_words} words minimum +3. Specify character changes +4. Add dialogue and description + +Boldly rewrite if needed. +Present only revised version.""" + } + + return lang_prompts.get(language, lang_prompts["Korean"]) + + def create_editor_prompt(self, complete_novel: str, issues: List[str], language: str) -> str: + """편집자 프롬프트 - 보존 중심 편집""" + + current_word_count = len(complete_novel.split()) + min_words = int(current_word_count * 0.95) # 95% 이상 유지 + + lang_prompts = { + "Korean": f"""당신은 보존을 중시하는 편집자입니다. + +**원고 분량: {current_word_count}단어** +**편집 후 최소 분량: {min_words}단어 (필수!)** + +**편집 규칙:** + +1. **원고 보존이 최우선** + - 원고의 95% 이상을 반드시 유지 + - 삭제보다는 수정을 우선 + - 전체 문단 삭제는 절대 금지 + +2. **허용되는 편집:** + - 완전히 동일한 문장이 연속으로 나올 때만 하나 제거 + - 문법 오류나 오타 수정 + - 연결이 어색한 부분에 접속사 추가 (1-2단어) + +3. **절대 금지사항:** + - 문단 통째로 삭제 ❌ + - 내용 요약이나 축약 ❌ + - 작가의 문체 변경 ❌ + - 줄거리 재구성 ❌ + +4. **편집 방법:** + - 중복 문장: 두 번째 것만 제거 + - 어색한 연결: 접속사로 연결 + - 오타: 최소한의 수정 + +**중요: 거의 모든 내용을 그대로 유지하면서 아주 작은 문제만 수정하세요.** + +원고 전체를 다시 작성하지 말고, 원본을 복사한 후 최소한의 수정만 가하세요. + +편집된 전체 원고를 제시하세요. ({min_words}단어 이상 필수!)""", + + "English": f"""You are a preservation-focused editor. + +**Manuscript length: {current_word_count} words** +**Minimum after editing: {min_words} words (REQUIRED!)** + +**Editing Rules:** + +1. **Preservation is Priority** + - Must keep 95%+ of manuscript + - Prefer correction over deletion + - Never delete whole paragraphs + +2. **Allowed Edits:** + - Remove only when exact same sentence appears consecutively + - Fix grammar errors or typos + - Add conjunctions for awkward connections (1-2 words) + +3. **Absolutely Forbidden:** + - Deleting whole paragraphs ❌ + - Summarizing or abbreviating ❌ + - Changing author's style ❌ + - Restructuring plot ❌ + +4. **Editing Method:** + - Duplicates: Remove only second occurrence + - Awkward connections: Connect with conjunctions + - Typos: Minimal fixes + +**IMPORTANT: Keep almost everything while fixing only tiny issues.** + +Don't rewrite the manuscript, copy the original and apply minimal edits. + +Present the full edited manuscript. ({min_words}+ words required!)""" + } + + return lang_prompts.get(language, lang_prompts["Korean"]) + + def create_critic_final_prompt(self, content: str, query: str, language: str) -> str: + """최종 비평""" + + word_count = len(content.split()) + + # 서사 구조 분석 (추가) + has_single_plot = self.check_single_narrative(content) + character_consistency = self.check_character_consistency_in_final(content) + + lang_prompts = { + "Korean": f"""완성된 소설을 평가하세요. + +**주제:** {query} +**분량:** {word_count}단어 (목표: 8,000) + +**평가 기준:** +1. 통합된 서사 구조 (30점) + - 단일한 플롯 존재 여부 + - 인과관계의 치밀함 + - 각 단계의 필연성 + +2. 캐릭터 일관성 (25점) + - 이름과 설정의 통일성 + - 성격과 행동의 일관성 + - 변화의 설득력 + +3. 문학적 완성도 (25점) + - 문체의 일관성 + - 상징과 모티프 활용 + - 문학적 기법의 다양성 + +4. 주제 의식과 통찰 (20점) + - 철학적 깊이 + - 인간에 대한 통찰 + - 독창적 해석 + +**감점 요인:** +- 플롯 자기복제 (-10점) +- 캐릭터 설정 충돌 (-10점) +- 단순 반복 (-5점) +- 분량 미달 (-5점) + +**종합 평가:** +작품이 하나의 완결된 장편소설로 기능하는가? + +총점: /100점 + +작품의 문학적 가치와 구조적 완성도를 종합적으로 평가하세요.""", + + "English": f"""Evaluate the completed novel. + +**Theme:** {query} +**Length:** {word_count} words (target: 8,000) + +**Criteria:** +1. Integrated Narrative Structure (30 pts) + - Single plot existence + - Causal tightness + - Necessity of each phase + +2. Character Consistency (25 pts) + - Name/setting unity + - Personality/behavior consistency + - Convincing transformation + +3. Literary Quality (25 pts) + - Stylistic consistency + - Symbol/motif usage + - Literary technique variety + +4. Thematic Insight (20 pts) + - Philosophical depth + - Human insight + - Original interpretation + +**Deductions:** +- Plot self-replication (-10 pts) +- Character setting conflicts (-10 pts) +- Simple repetition (-5 pts) +- Under length (-5 pts) + +**Overall Assessment:** +Does the work function as a complete novel? + +Total: /100 points + +Comprehensively evaluate literary value and structural completeness.""" + } + + return lang_prompts.get(language, lang_prompts["Korean"]) + + # --- LLM 호출 함수들 --- + 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}") + + # 언어 필터 적용 + filtered_content = self.language_filter.clean_text(full_content) + return filtered_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] + + # 작가와 편집자 역할일 때는 더 많은 토큰 허용 + if role.startswith("writer"): + max_tokens = 25000 + elif role == "editor": + # 편집자는 원본 길이 + 여유분 + max_tokens = 30000 # 편집자에게 더 많은 토큰 할당 + else: + max_tokens = 10000 + + payload = { + "model": self.model_id, + "messages": full_messages, + "max_tokens": max_tokens, + "temperature": 0.8, + "top_p": 0.95, + "presence_penalty": 0.5, + "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 오류 (상태 코드: {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: + # 언어 필터 적용 + clean_buffer = self.language_filter.clean_text(buffer) + yield clean_buffer + buffer = "" + time.sleep(0.01) + + except Exception as e: + logger.error(f"청크 처리 오류: {str(e)}") + continue + + if buffer: + clean_buffer = self.language_filter.clean_text(buffer) + yield clean_buffer + + except Exception as e: + logger.error(f"스트리밍 오류: {type(e).__name__}: {str(e)}") + yield f"❌ 오류 발생: {str(e)}" + + def get_system_prompts(self, language: str) -> Dict[str, str]: + """역할별 시스템 프롬프트 (간소화)""" + + base_prompts = { + "Korean": { + "director": """당신은 노벨문학상 수상 작가입니다. +치밀한 인과관계의 단일 서사를 설계하세요. +캐릭터 이름과 설정을 처음부터 명확히 고정하세요. +플롯의 자기복제 없이 필연적 전개를 구축하세요.""", + + "critic": """당신은 문학 비평가입니다. +서사의 인과관계와 캐릭터 일관성을 엄격히 검토하세요. +플롯 자기복제와 설정 충돌을 찾아내세요. +작품이 하나의 완결된 소설인지 평가하세요.""", + + "writer_base": f"""당신은 한국 문학 작가입니다. +반드시 {MIN_WORDS_PER_WRITER}단어 이상 작성하세요. +이전 단계의 필연적 결과로 시작하세요. +캐릭터 이름과 설정을 절대 변경하지 마세요. +구체적 장면과 생생한 대화로 서사를 전개하세요.""", + + "editor": """당신은 원고 보존을 최우선으로 하는 편집자입니다. +원고의 95% 이상을 반드시 유지하세요. +전체 문단을 삭제하지 마세요. +원본을 복사한 후 최소한의 수정만 하세요. +편집 후에도 거의 모든 내용이 남아있어야 합니다.""" + }, + "English": { + "director": """You are a Nobel Prize-winning author. +Design a single narrative with tight causality. +Fix character names and settings clearly from start. +Build necessary development without plot self-replication.""", + + "critic": """You are a literary critic. +Strictly review narrative causality and character consistency. +Find plot self-replication and setting conflicts. +Evaluate if work is one complete novel.""", + + "writer_base": f"""You are a literary writer. +Must write at least {MIN_WORDS_PER_WRITER} words. +Start as necessary result of previous phase. +Never change character names or settings. +Develop narrative with concrete scenes and vivid dialogue.""", + + "editor": """You are a preservation-focused editor. +Must maintain 95%+ of manuscript. +Never delete whole paragraphs. +Copy original and apply minimal edits only. +Almost all content must remain after editing.""" + } + } + + prompts = base_prompts.get(language, base_prompts["Korean"]).copy() + + # 특수 작가 프롬프트 + for i in range(1, 11): + prompts[f"writer{i}"] = prompts["writer_base"] + + return prompts + + # --- 메인 프로세스 --- + def process_novel_stream(self, query: str, language: str, session_id: Optional[str] = None) -> Generator[Tuple[str, List[Dict[str, Any]], str], None, None]: + """소설 생성 프로세스""" + try: + resume_from_stage = 0 + if session_id: + self.current_session_id = session_id + session = NovelDatabase.get_session(session_id) + if session: + query = session['user_query'] + language = session['language'] + resume_from_stage = session['current_stage'] + 1 + # 서사 추적기 복원 + saved_tracker = NovelDatabase.load_narrative_tracker(session_id) + if saved_tracker: + self.narrative_tracker = saved_tracker + else: + self.current_session_id = NovelDatabase.create_session(query, language) + logger.info(f"Created new session: {self.current_session_id}") + + stages = [] + if resume_from_stage > 0: + stages = [{ + "name": s['stage_name'], + "status": s['status'], + "content": s.get('content', ''), + "word_count": s.get('word_count', 0), + "progression_score": s.get('progression_score', 0.0), + "repetition_score": s.get('repetition_score', 0.0), + "consistency_check": s.get('consistency_check', '') + } for s in NovelDatabase.get_stages(self.current_session_id)] + + # 총 단어 수 추적 + total_words = NovelDatabase.get_total_words(self.current_session_id) + + for stage_idx in range(resume_from_stage, len(PROGRESSIVE_STAGES)): + role, stage_name = PROGRESSIVE_STAGES[stage_idx] + if stage_idx >= len(stages): + stages.append({ + "name": stage_name, + "status": "active", + "content": "", + "word_count": 0, + "progression_score": 0.0, + "repetition_score": 0.0, + "consistency_check": "" + }) + else: + stages[stage_idx]["status"] = "active" + + yield f"🔄 진행 중... (현재 {total_words:,}단어)", stages, self.current_session_id + + prompt = self.get_stage_prompt(stage_idx, role, query, language, stages) + stage_content = "" + + for chunk in self.call_llm_streaming([{"role": "user", "content": prompt}], role, language): + stage_content += chunk + stages[stage_idx]["content"] = stage_content + stages[stage_idx]["word_count"] = len(stage_content.split()) + yield f"🔄 {stage_name} 작성 중... ({total_words + stages[stage_idx]['word_count']:,}단어)", stages, self.current_session_id + + # 작가 역할일 때 단어 수 체크 및 재생성 + if role.startswith("writer"): + word_count = len(stage_content.split()) + writer_num = int(re.search(r'\d+', role).group()) + + # 단어 수가 부족하면 추가 생성 요청 + if word_count < MIN_WORDS_PER_WRITER * 0.9: # 90% 미만이면 + retry_prompt = f""" +현재 {word_count}단어만 작성되었습니다. +최소 {MIN_WORDS_PER_WRITER}단어가 필요합니다. + +다음을 추가하여 {MIN_WORDS_PER_WRITER - word_count}단어 이상 더 작성하세요: +- 더 자세한 인물 묘사 +- 추가 대화 장면 +- 배경과 분위기 묘사 +- 인물의 과거 회상 +- 내적 독백 확대 + +기존 내용에 자연스럽게 이어서 작성하세요: +""" + + additional_content = self.call_llm_sync( + [{"role": "user", "content": retry_prompt}], + role, + language + ) + stage_content += "\n\n" + additional_content + stages[stage_idx]["content"] = stage_content + stages[stage_idx]["word_count"] = len(stage_content.split()) + + # 진행도 평가 + previous_content = self.get_previous_writer_content(stages, writer_num) + + # 진행도 점수 계산 + progression_scores = self.narrative_tracker.progression_monitor.calculate_progression_score( + writer_num, stage_content, previous_content + ) + progression_score = sum(progression_scores.values()) / len(progression_scores) + stages[stage_idx]["progression_score"] = progression_score + + # 반복도 점수 계산 + repetition_score = 10.0 - self.narrative_tracker.progression_monitor.count_repetitions(stage_content) + stages[stage_idx]["repetition_score"] = max(0, repetition_score) + + # 일관성 체크 + consistency_ok, consistency_issues = self.narrative_tracker.consistency_checker.validate_new_content( + writer_num, stage_content, [s["content"] for s in stages[:stage_idx] if s["content"]] + ) + stages[stage_idx]["consistency_check"] = "통과" if consistency_ok else "; ".join(consistency_issues) + + # 서사 추적기 업데이트 + self.update_narrative_tracker(stage_content, writer_num) + self.narrative_tracker.extract_used_elements(stage_content) + + # 편집 단계 특별 처리 + if role == "editor" and stage_content: + # 편집 전후 단어 수 비교 + original_novel = "" + for i in range(1, 11): + for s in stages: + if f"writer{i}" in s.get("name", "") and "수정본" in s.get("name", "") and s["content"]: + original_novel += s["content"] + "\n\n" + + original_words = len(original_novel.split()) + edited_words = len(stage_content.split()) + + logger.info(f"편집 결과: {original_words}단어 → {edited_words}단어") + + # 편집으로 20% 이상 삭제되었다면 원본 사용 + if edited_words < original_words * 0.8: + logger.warning(f"편집자가 과도하게 삭제함 ({100 - (edited_words/original_words*100):.1f}% 삭제). 원본 유지.") + stage_content = original_novel + stages[stage_idx]["content"] = stage_content + stages[stage_idx]["word_count"] = original_words + stages[stage_idx]["note"] = "편집 과도로 원본 유지" + + stages[stage_idx]["status"] = "complete" + NovelDatabase.save_stage( + self.current_session_id, stage_idx, stage_name, role, + stage_content, "complete", + stages[stage_idx].get("progression_score", 0.0), + stages[stage_idx].get("repetition_score", 0.0), + stages[stage_idx].get("consistency_check", "") + ) + + # 서사 추적기 저장 + NovelDatabase.save_narrative_tracker(self.current_session_id, self.narrative_tracker) + + # 총 단어 수 업데이트 + total_words = NovelDatabase.get_total_words(self.current_session_id) + + # 단어 수 경고 + if role.startswith("writer") and stages[stage_idx]["word_count"] < MIN_WORDS_PER_WRITER: + logger.warning(f"단어 수 부족: {stage_name} - {stages[stage_idx]['word_count']}단어") + + yield f"✅ {stage_name} 완료 (총 {total_words:,}단어)", stages, self.current_session_id + + # 최종 소설 정리 + final_novel = NovelDatabase.get_writer_content(self.current_session_id) + + # 편집자가 처리한 내용이 있으면 그것을 사용 + edited_content = self.get_edited_content(stages) + if edited_content: + final_novel = edited_content + + final_word_count = len(final_novel.split()) + final_report = self.generate_literary_report(final_novel, query, language) + + # 품질 평가 저장 + self.save_quality_evaluation(final_report) + + NovelDatabase.update_final_novel(self.current_session_id, final_novel, final_report) + + # 최종 단어 수 확인 + if final_word_count < TARGET_WORDS: + yield f"⚠️ 소설 완성! 총 {final_word_count:,}단어 (목표 미달: {TARGET_WORDS - final_word_count:,}단어 부족)", stages, self.current_session_id + else: + yield f"✅ 소설 완성! 총 {final_word_count:,}단어 (목표: {TARGET_WORDS:,}단어 달성!)", stages, self.current_session_id + + except Exception as e: + logger.error(f"소설 생성 프로세스 오류: {e}", exc_info=True) + yield f"❌ 오류 발생: {e}", stages if 'stages' in locals() else [], self.current_session_id + + def get_stage_prompt(self, stage_idx, role, query, language, stages): + """단계별 프롬프트 생성""" + if stage_idx == 0: + # 첫 번째 단계 - 감독자 초기 기획 + return self.create_director_initial_prompt(query, language) + + elif stage_idx == 1: + # 두 번째 단계 - 비평가가 감독자 기획 검토 + if role == "critic": + return self.create_critic_consistency_prompt_enhanced( + stages[0]["content"], + self.narrative_tracker, + query, + language + ) + + elif stage_idx == 2: + # 세 번째 단계 - 감독자 수정 + if role == "director": + return self.create_director_revision_prompt( + stages[0]["content"], # 초기 기획 + stages[1]["content"], # 비평 + query, + language + ) + + else: + # 작가 단계들 + if role.startswith("writer"): + writer_num = int(re.search(r'\d+', role).group()) + + # 이전 작가들의 내용 가져오기 + previous_content = "" + if writer_num > 1: + # writer_num - 1까지의 모든 작가 내용 + for i in range(1, writer_num): + for stage in stages: + if f"writer{i}" in stage.get("name", "") and stage["content"]: + previous_content += stage["content"] + "\n\n" + + # 감독자 플랜 가져오기 + director_plan = "" + for stage in stages[:3]: # 처음 3개 단계에서 감독자 플랜 찾기 + if "감독자" in stage.get("name", "") and "수정" in stage.get("name", "") and stage["content"]: + director_plan = stage["content"] + break + if not director_plan: # 수정된 플랜이 없으면 초기 플랜 사용 + director_plan = stages[0]["content"] if stages and stages[0]["content"] else "" + + # 서사 요약 생성 + narrative_summary = self.generate_narrative_summary(stages, writer_num) + + # 단계별 요구사항 생성 + phase_requirements = self.narrative_tracker.generate_phase_requirements(writer_num) + + # 사용된 요소들 + used_elements = list(self.narrative_tracker.used_expressions) + + # 초안인지 수정본인지 확인 + stage_name = stages[stage_idx]["name"] if stage_idx < len(stages) else "" + if "초안" in stage_name: + return self.create_writer_prompt_enhanced( + writer_num, + director_plan, + previous_content, + phase_requirements, + narrative_summary, + language, + used_elements + ) + else: # 수정본 + # 해당 작가의 초안 찾기 + initial_content = "" + critic_feedback = "" + + # 초안 찾기 + for i, stage in enumerate(stages): + if f"writer{writer_num}" in stage.get("name", "") and "초안" in stage.get("name", ""): + initial_content = stage["content"] + break + + # 중간 비평 찾기 + for i, stage in enumerate(stages): + if "critic" in stage.get("name", "") and "중간 검토" in stage.get("name", ""): + critic_feedback = stage["content"] + break + + return self.create_writer_revision_prompt( + writer_num, + initial_content, + critic_feedback, + language + ) + + elif role == "critic": + # 중간 비평가 검토 + if "중간 검토" in stages[stage_idx]["name"]: + # 모든 작가 콘텐츠 수집 + all_content = "" + for stage in stages: + if "writer" in stage.get("name", "") and "초안" in stage.get("name", "") and stage["content"]: + all_content += stage["content"] + "\n\n" + + return self.create_critic_consistency_prompt_enhanced( + all_content, + self.narrative_tracker, + query, + language + ) + # 최종 비평 + else: + # 편집된 내용이 있으면 그것을, 없으면 모든 작가 수정본 + final_content = "" + for stage in stages: + if "편집자" in stage.get("name", "") and stage["content"]: + final_content = stage["content"] + break + + if not final_content: + # 편집된 내용이 없으면 모든 작가 수정본 수집 + for i in range(1, 11): + for stage in stages: + if f"writer{i}" in stage.get("name", "") and "수정본" in stage.get("name", "") and stage["content"]: + final_content += stage["content"] + "\n\n" + + return self.create_critic_final_prompt(final_content, query, language) + + elif role == "editor": + # 편집자 - 모든 작가 수정본 수집 + complete_novel = "" + writer_contents = [] + for i in range(1, 11): + for stage in stages: + if f"writer{i}" in stage.get("name", "") and "수정본" in stage.get("name", "") and stage["content"]: + writer_contents.append(stage["content"]) + complete_novel += stage["content"] + "\n\n" + + # 문제점 감지 - 매우 심각한 문제만 + issues = [] + + # 완전히 동일한 문단 체크 + paragraphs = complete_novel.split('\n\n') + seen_paragraphs = set() + exact_duplicates = 0 + for para in paragraphs: + if para.strip() in seen_paragraphs and len(para.strip()) > 100: + exact_duplicates += 1 + seen_paragraphs.add(para.strip()) + + if exact_duplicates > 5: + issues.append(f"{exact_duplicates}개의 완전히 동일한 문단 발견") + + # 문제가 거의 없으면 편집 최소화 + if len(issues) == 0: + issues = ["원고 상태가 양호합니다. 편집을 최소화하세요."] + + return self.create_editor_prompt(complete_novel, issues, language) + + # 기본값 (에러 방지) + return f"역할 {role}에 대한 프롬프트가 정의되지 않았습니다." + + + def create_director_revision_prompt(self, initial_plan: str, critic_feedback: str, user_query: str, language: str) -> str: + """감독자 수정 프롬프트""" + lang_prompts = { + "Korean": f"""비평을 반영하여 수정하세요. + +**원 주제:** {user_query} + +**비평 요점:** +{critic_feedback[:500]} + +**수정 핵심:** +1. 10단계가 인과관계로 긴밀히 연결 +2. 캐릭터 이름과 설정 처음부터 고정 +3. 각 단계가 이전의 필연적 결과 +4. 플롯의 자기복제 방지 +5. 8,000단어 달성 전략 + +**구조 강화:** +- 외부 사건이 내적 변화를 촉발 +- 선택의 결과가 다음 국면 결정 +- 우연이 아닌 필연적 전개 + +치밀한 인과관계의 수정 계획을 제시하세요.""", + + "English": f"""Revise based on critique. + +**Original Theme:** {user_query} + +**Critique Points:** +{critic_feedback[:500]} + +**Revision Focus:** +1. 10 phases tightly connected by causality +2. Character names/settings fixed from start +3. Each phase as necessary result of previous +4. Prevent plot self-replication +5. Strategy to achieve 8,000 words + +**Structure Enhancement:** +- External events trigger internal changes +- Choices determine next phase +- Necessary, not coincidental development + +Present revision plan with tight causality.""" + } + + return lang_prompts.get(language, lang_prompts["Korean"]) + + def get_previous_writer_content(self, stages: List[Dict], current_writer: int) -> str: + """이전 작가의 내용 가져오기""" + if current_writer == 1: + return "" + + # 이전 작가들의 내용 수집 + previous_content = [] + for i in range(1, current_writer): + for stage in stages: + if f"writer{i}" in stage.get("name", "") and stage["content"]: + previous_content.append(stage["content"]) + break + + return "\n\n".join(previous_content) + + def get_all_writer_content(self, stages: List[Dict], up_to_stage: int) -> str: + """특정 단계까지의 모든 작가 내용""" + contents = [] + for i, s in enumerate(stages): + if i <= up_to_stage and "writer" in s.get("name", "") and s["content"]: + contents.append(s["content"]) + return "\n\n".join(contents) + + def get_edited_content(self, stages: List[Dict]) -> str: + """편집된 내용 가져오기""" + for s in stages: + if "편집자" in s.get("name", "") and s["content"]: + return s["content"] + return "" + + def generate_narrative_summary(self, stages: List[Dict], up_to_writer: int) -> str: + """현재까지의 서사 요약""" + if up_to_writer == 1: + return "첫 시작입니다." + + summary_parts = [] + for i in range(1, up_to_writer): + if i in self.narrative_tracker.phase_summaries: + summary_parts.append(f"[{NARRATIVE_PHASES[i-1]}]: {self.narrative_tracker.phase_summaries[i][:100]}...") + + return "\n".join(summary_parts) if summary_parts else "이전 내용을 이어받아 진행하세요." + + def update_narrative_tracker(self, content: str, writer_num: int): + """서사 추적기 업데이트""" + # 간단한 요약 생성 (실제로는 더 정교한 분석 필요) + lines = content.split('\n') + key_events = [line.strip() for line in lines if len(line.strip()) > 50][:3] + + if key_events: + summary = " ".join(key_events[:2])[:200] + "..." + self.narrative_tracker.phase_summaries[writer_num] = summary + + # 철학적 통찰 추출 (간단한 키워드 기반) + philosophical_keywords = ['존재', '의미', '삶', '죽음', '인간', '고통', '희망', '사랑', + 'existence', 'meaning', 'life', 'death', 'human', 'suffering', 'hope', 'love'] + for keyword in philosophical_keywords: + if keyword in content: + self.narrative_tracker.philosophical_insights.append(f"Phase {writer_num}: {keyword} 탐구") + break + + # 문학적 기법 감지 + literary_devices = [] + if '처럼' in content or 'like' in content or 'as if' in content: + literary_devices.append('비유') + if '...' in content or '—' in content: + literary_devices.append('의식의 흐름') + if content.count('"') > 4: + literary_devices.append('대화') + + if literary_devices: + self.narrative_tracker.literary_devices[writer_num] = literary_devices + + def detect_issues(self, content: str) -> List[str]: + """문제점 감지 - 심각한 문제만""" + issues = [] + + # 완전히 동일한 문단만 감지 + paragraphs = content.split('\n\n') + exact_duplicates = 0 + for i, para1 in enumerate(paragraphs): + for j, para2 in enumerate(paragraphs[i+1:]): + if para1.strip() == para2.strip() and len(para1.strip()) > 50: + exact_duplicates += 1 + + if exact_duplicates > 0: + issues.append(f"{exact_duplicates}개의 완전히 동일한 문단 발견") + + # 5회 이상 반복되는 표현만 감지 + repetitive_phrases = ["습기가 찬 아침", "나라미 어플", "43만원", "개구리알을 바라보았다"] + for phrase in repetitive_phrases: + count = content.count(phrase) + if count > 5: # 임계값을 3에서 5로 상향 + issues.append(f"'{phrase}' 표현이 {count}회 과도하게 반복됨") + + # 심각한 캐릭터 이름 불일치만 + name_variations = ["나라미", "안정", "나"] + found_names = [name for name in name_variations if content.count(name) > 10] + if len(found_names) > 2: + issues.append(f"주인공 이름 심각한 불일치: {', '.join(found_names)}") + + # 언어 오류 감지 + if re.search(r'[ぁ-んァ-ヶ一-龯]', content): + issues.append("일본어/중국어 문자 발견") + + return issues[:3] # 가장 심각한 3개만 반환 + + def evaluate_progression(self, content: str, phase: int) -> float: + """서사 진행도 평가""" + score = 5.0 + + # 분량 체크 + word_count = len(content.split()) + if word_count >= MIN_WORDS_PER_WRITER: + score += 2.0 + + # 새로운 요소 체크 + if phase > 1: + prev_summary = self.narrative_tracker.phase_summaries.get(phase-1, "") + if prev_summary and len(set(content.split()) - set(prev_summary.split())) > 100: + score += 1.5 + + # 변화 언급 체크 + change_keywords = ['변했', '달라졌', '새로운', '이제는', '더 이상', + 'changed', 'different', 'new', 'now', 'no longer'] + if any(keyword in content for keyword in change_keywords): + score += 1.5 + + # 철학적 깊이 체크 + philosophical_keywords = ['존재', '의미', '삶의', '인간의', '왜', 'existence', 'meaning', 'life', 'human', 'why'] + if any(keyword in content for keyword in philosophical_keywords): + score += 0.5 + + # 문학적 기법 체크 + if not any(phrase in content for phrase in ['느꼈다', '였다', 'felt', 'was']): + score += 0.5 # 보여주기 기법 사용 + + return min(10.0, score) + + def generate_literary_report(self, complete_novel: str, query: str, language: str) -> str: + """최종 문학적 평가""" + prompt = self.create_critic_final_prompt(complete_novel, query, language) + try: + report = self.call_llm_sync([{"role": "user", "content": prompt}], "critic", language) + return report + except Exception as e: + logger.error(f"최종 보고서 생성 실패: {e}") + return "보고서 생성 중 오류 발생" + + def check_single_narrative(self, content: str) -> bool: + """단일 서사 구조 확인""" + # 주인공 이름의 일관성 체크 + names = re.findall(r'[가-힣]{2,4}(?=이|가|은|는|을|를)', content) + name_counts = {} + for name in names: + name_counts[name] = name_counts.get(name, 0) + 1 + + # 가장 많이 등장하는 이름이 전체의 50% 이상인지 + if name_counts: + max_count = max(name_counts.values()) + total_count = sum(name_counts.values()) + return max_count / total_count > 0.5 + return False + + def check_character_consistency_in_final(self, content: str) -> float: + """최종 작품의 캐릭터 일관성 점수""" + # 캐릭터 이름과 직업/설정의 일관성 체크 + consistency_score = 1.0 + + # 같은 이름이 다른 설정으로 등장하는��� 체크 + name_professions = {} + patterns = [ + r'([가-힣]{2,4})(?:은|는|이|가)\s+(\w+(?:작가|기자|교사|의사|변호사))', + r'(\w+(?:작가|기자|교사|의사|변호사))\s+([가-힣]{2,4})' + ] + + for pattern in patterns: + matches = re.findall(pattern, content) + for match in matches: + name = match[0] if '가' <= match[0][0] <= '힣' else match[1] + profession = match[1] if '가' <= match[0][0] <= '힣' else match[0] + + if name in name_professions and name_professions[name] != profession: + consistency_score -= 0.2 # 불일치마다 감점 + else: + name_professions[name] = profession + + return max(0, consistency_score) + + def save_quality_evaluation(self, report: str): + """품질 평가 저장""" + try: + # 점수 추출 (간단한 패턴 매칭) + score_match = re.search(r'총점:\s*(\d+(?:\.\d+)?)/100', report) + score = float(score_match.group(1)) if score_match else 0.0 + + NovelDatabase.save_quality_evaluation( + self.current_session_id, + "final_evaluation", + score, + report + ) + except Exception as e: + logger.error(f"품질 평가 저장 실패: {e}") + + +# --- 유틸리티 함수들 --- +def process_query(query: str, language: str, session_id: Optional[str] = None) -> Generator[Tuple[str, str, str, str], None, None]: + """메인 쿼리 처리 함수""" + if not query.strip(): + yield "", "", "❌ 주제를 입력해주세요.", session_id + return + + system = ProgressiveLiterarySystem() + stages_markdown = "" + novel_content = "" + + for status, stages, current_session_id in system.process_novel_stream(query, language, session_id): + stages_markdown = format_stages_display(stages) + + # 최종 소설 내용 가져오기 + if stages and all(s.get("status") == "complete" for s in stages[-10:]): + novel_content = NovelDatabase.get_writer_content(current_session_id) + # 편집된 내용이 있으면 그것을 사용 + edited = system.get_edited_content(stages) + if edited: + novel_content = edited + novel_content = format_novel_display(novel_content) + + yield stages_markdown, novel_content, status or "🔄 처리 중...", current_session_id + +def get_active_sessions(language: str) -> List[str]: + """활성 세션 목록""" + sessions = NovelDatabase.get_active_sessions() + result = [] + for s in sessions: + # None 값 체크 추가 + session_id = s.get('session_id', '') + user_query = s.get('user_query', '') + created_at = s.get('created_at', '') + total_words = s.get('total_words', 0) or 0 # None일 경우 0으로 처리 + + if session_id and user_query: # 필수 값이 있는 경우만 추가 + result.append( + f"{session_id[:8]}... - {user_query[:50]}... ({created_at}) [{total_words:,}단어]" + ) + return result + +def auto_recover_session(language: str) -> Tuple[Optional[str], str]: + """최근 세션 자동 복구""" + sessions = NovelDatabase.get_active_sessions() + if sessions: + latest_session = sessions[0] + return latest_session['session_id'], f"세션 {latest_session['session_id'][:8]}... 복구됨" + return None, "복구할 세션이 없습니다." + +def resume_session(session_id: str, language: str) -> Generator[Tuple[str, str, str, str], None, None]: + """세션 재개""" + if not session_id: + yield "", "", "❌ 세션 ID가 없습니다.", session_id + return + + if "..." in session_id: + session_id = session_id.split("...")[0] + + session = NovelDatabase.get_session(session_id) + if not session: + yield "", "", "❌ 세션을 찾을 수 없습니다.", None + return + + yield from process_query(session['user_query'], session['language'], session_id) + +def download_novel(novel_text: str, format_type: str, language: str, session_id: str) -> Optional[str]: + """소설 다운로드 파일 생성""" + if not novel_text or not session_id: + return None + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"novel_{session_id[:8]}_{timestamp}" + + try: + if format_type == "DOCX" and DOCX_AVAILABLE: + return export_to_docx(novel_text, filename, language, session_id) + else: + return export_to_txt(novel_text, filename) + except Exception as e: + logger.error(f"파일 생성 실패: {e}") + return None + +def format_stages_display(stages: List[Dict]) -> str: + """단계별 진행 상황 표시""" + markdown = "## 🎬 진행 상황\n\n" + + # 총 단어 수 계산 + total_words = sum(s.get('word_count', 0) for s in stages if 'writer' in s.get('name', '')) + progress_percent = (total_words / TARGET_WORDS) * 100 if TARGET_WORDS > 0 else 0 + + markdown += f"**총 단어 수: {total_words:,} / {TARGET_WORDS:,}**\n" + markdown += f"**진행률: {progress_percent:.1f}%**\n" + markdown += f"{'█' * int(progress_percent // 5)}{'░' * (20 - int(progress_percent // 5))}\n\n" + + for i, stage in enumerate(stages): + status_icon = "✅" if stage['status'] == 'complete' else "🔄" if stage['status'] == 'active' else "⏳" + markdown += f"{status_icon} **{stage['name']}**" + + if stage.get('word_count', 0) > 0: + markdown += f" ({stage['word_count']:,}단어)" + + # 작가 단계에서 단어 수 부족 경고 + if 'writer' in stage.get('name', '') and stage['word_count'] < MIN_WORDS_PER_WRITER: + markdown += f" ⚠️ **분량 부족!**" + + # 진행도와 반복도 점수 표시 + if stage.get('progression_score', 0) > 0: + markdown += f" [진행도: {stage['progression_score']:.1f}/10]" + if stage.get('repetition_score', 0) > 0: + markdown += f" [반복도: {stage['repetition_score']:.1f}/10]" + + # 일관성 체크 표시 + if stage.get('consistency_check'): + if stage['consistency_check'] == "통과": + markdown += " ✔️" + else: + markdown += f" ⚠️ {stage['consistency_check']}" + + markdown += "\n" + + if stage['content']: + preview = stage['content'][:200] + "..." if len(stage['content']) > 200 else stage['content'] + markdown += f"> {preview}\n\n" + + return markdown + +def format_novel_display(novel_text: str) -> str: + """소설 내용 표시""" + if not novel_text: + return "아직 완성된 내용이 없습니다." + + formatted = "# 📖 완성된 소설\n\n" + + # 단어 수 표시 + word_count = len(novel_text.split()) + formatted += f"**총 분량: {word_count:,}단어 (목표: {TARGET_WORDS:,}단어)**\n\n" + + if word_count < TARGET_WORDS: + shortage = TARGET_WORDS - word_count + formatted += f"⚠️ **주의: 목표 분량에 {shortage:,}단어 부족합니다.**\n\n" + else: + formatted += f"✅ **목표 분량 달성!**\n\n" + + formatted += "---\n\n" + + # 각 단계를 구분하여 표시 + sections = novel_text.split('\n\n') + for i, section in enumerate(sections): + if section.strip(): + formatted += f"{section}\n\n" + + return formatted + +def export_to_docx(content: str, filename: str, language: str, session_id: str) -> str: + """DOCX 파일로 내보내기""" + doc = Document() + + # 페이지 설정 + section = doc.sections[0] + section.page_height = Inches(11) + section.page_width = Inches(8.5) + section.top_margin = Inches(1) + section.bottom_margin = Inches(1) + section.left_margin = Inches(1.25) + section.right_margin = Inches(1.25) + + # 세션 정보 + session = NovelDatabase.get_session(session_id) + + # 제목 페이지 + title_para = doc.add_paragraph() + title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + + if session: + title_run = title_para.add_run(session["user_query"]) + title_run.font.size = Pt(24) + title_run.bold = True + + # 메타 정보 + doc.add_paragraph() + meta_para = doc.add_paragraph() + meta_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + meta_para.add_run(f"생성일: {datetime.now().strftime('%Y년 %m월 %d일')}\n") + meta_para.add_run(f"총 단어 수: {len(content.split()):,}단어") + + # 페이지 나누기 + doc.add_page_break() + + # 본문 스타일 설정 + style = doc.styles['Normal'] + style.font.name = 'Calibri' + style.font.size = Pt(11) + style.paragraph_format.line_spacing = 1.5 + style.paragraph_format.space_after = Pt(6) + + # 본문 추가 + paragraphs = content.split('\n\n') + for para_text in paragraphs: + if para_text.strip(): + para = doc.add_paragraph(para_text.strip()) + + # 파일 저장 + filepath = f"{filename}.docx" + doc.save(filepath) + return filepath + +def export_to_txt(content: str, filename: str) -> str: + """TXT 파일로 내보내기""" + filepath = f"{filename}.txt" + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + return filepath + + +# CSS 스타일 +custom_css = """ +.gradio-container { + background: linear-gradient(135deg, #1e3c72 0%, #2a5298 50%, #1e3c72 100%); + min-height: 100vh; +} + +.main-header { + background-color: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + padding: 30px; + border-radius: 12px; + margin-bottom: 30px; + text-align: center; + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.progress-note { + background-color: rgba(255, 223, 0, 0.1); + border-left: 3px solid #ffd700; + padding: 15px; + margin: 20px 0; + border-radius: 8px; + color: #fff; +} + +.improvement-note { + background-color: rgba(0, 255, 127, 0.1); + border-left: 3px solid #00ff7f; + padding: 15px; + margin: 20px 0; + border-radius: 8px; + color: #fff; +} + +.warning-note { + background-color: rgba(255, 69, 0, 0.1); + border-left: 3px solid #ff4500; + padding: 15px; + margin: 20px 0; + border-radius: 8px; + color: #fff; +} + +.input-section { + background-color: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + padding: 20px; + border-radius: 12px; + margin-bottom: 20px; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +.session-section { + background-color: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + padding: 15px; + border-radius: 8px; + margin-top: 20px; + color: white; + border: 1px solid rgba(255, 255, 255, 0.2); +} + +#stages-display { + background-color: rgba(255, 255, 255, 0.95); + padding: 20px; + border-radius: 12px; + max-height: 600px; + overflow-y: auto; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +#novel-output { + background-color: rgba(255, 255, 255, 0.95); + padding: 30px; + border-radius: 12px; + max-height: 700px; + overflow-y: auto; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.download-section { + background-color: rgba(255, 255, 255, 0.9); + padding: 15px; + border-radius: 8px; + margin-top: 20px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +/* 진행 표시기 스타일 */ +.progress-bar { + background-color: #e0e0e0; + height: 20px; + border-radius: 10px; + overflow: hidden; + margin: 10px 0; +} + +.progress-fill { + background-color: #4CAF50; + height: 100%; + transition: width 0.3s ease; +} + +/* 점수 표시 스타일 */ +.score-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 0.9em; + font-weight: bold; + margin-left: 5px; +} + +.score-high { + background-color: #4CAF50; + color: white; +} + +.score-medium { + background-color: #FF9800; + color: white; +} + +.score-low { + background-color: #F44336; + color: white; +} + +/* 일관성 체크 표시 */ +.consistency-pass { + color: #4CAF50; + font-weight: bold; +} + +.consistency-fail { + color: #F44336; + font-weight: bold; +} + +/* 보고서 표시 스타일 */ +#report-display { + background-color: rgba(255, 255, 255, 0.95); + padding: 20px; + border-radius: 12px; + max-height: 600px; + overflow-y: auto; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +/* 분량 경고 스타일 */ +.word-count-warning { + background-color: #FFF3CD; + border-left: 4px solid #FFC107; + padding: 10px 15px; + margin: 10px 0; + border-radius: 4px; +} +""" + +# Gradio 인터페이스 생성 +def create_interface(): + with gr.Blocks(css=custom_css, title="AI 진행형 장편소설 생성 시스템 v3.1") as interface: + gr.HTML(""" +
+

+ 📚 AI 진행형 장편소설 생성 시스템 v3.1 +

+

+ 목표 분량 달성을 위한 최적화 버전 +

+

+ 10개의 유기적으로 연결된 단계를 통해 하나의 완전한 이야기를 만들어냅니다. +
+ 각 단계는 이전 단계의 필연적 결과로 이어지며, 인물의 변화와 성장을 추적합니다. +

+
+ ⚡ 반복이 아닌 축적, 순환이 아닌 진행을 통한 진정한 장편 서사 +
+
+ 🆕 v3.1 핵심 개선사항: + +
+
+ ⚠️ 분량 목표: 각 작가당 최소 800단어, 전체 8,000단어 이상 +
+
+ """) + + # 상태 관리 + current_session_id = gr.State(None) + + with gr.Row(): + with gr.Column(scale=1): + with gr.Group(elem_classes=["input-section"]): + query_input = gr.Textbox( + label="소설 주제 / Novel Theme", + placeholder="중편소설의 주제를 입력하세요. 인물의 변화와 성장이 중심이 되는 이야기...\nEnter the theme for your novella. Focus on character transformation and growth...", + lines=4 + ) + + language_select = gr.Radio( + choices=["Korean", "English"], + value="Korean", + label="언어 / Language" + ) + + with gr.Row(): + submit_btn = gr.Button("🚀 소설 생성 시작", variant="primary", scale=2) + clear_btn = gr.Button("🗑️ 초기화", scale=1) + + status_text = gr.Textbox( + label="상태", + interactive=False, + value="🔄 준비 완료" + ) + + # 세션 관리 + with gr.Group(elem_classes=["session-section"]): + gr.Markdown("### 💾 진행 중인 세션") + session_dropdown = gr.Dropdown( + label="세션 선택", + choices=[], + interactive=True + ) + with gr.Row(): + refresh_btn = gr.Button("🔄 목록 새로고침", scale=1) + resume_btn = gr.Button("▶️ 선택 재개", variant="secondary", scale=1) + auto_recover_btn = gr.Button("♻️ 최근 세션 복구", scale=1) + + with gr.Column(scale=2): + with gr.Tab("📝 창작 진행"): + stages_display = gr.Markdown( + value="창작 과정이 여기에 표시됩니다...", + elem_id="stages-display" + ) + + with gr.Tab("📖 완성된 소설"): + novel_output = gr.Markdown( + value="완성된 소설이 여기에 표시됩니다...", + elem_id="novel-output" + ) + + with gr.Group(elem_classes=["download-section"]): + gr.Markdown("### 📥 소설 다운로드") + with gr.Row(): + format_select = gr.Radio( + choices=["DOCX", "TXT"], + value="DOCX" if DOCX_AVAILABLE else "TXT", + label="형식" + ) + download_btn = gr.Button("⬇️ 다운로드", variant="secondary") + + download_file = gr.File( + label="다운로드된 파일", + visible=False + ) + + with gr.Tab("📊 평가 보고서"): + report_display = gr.Markdown( + value="평가 보고서가 여기에 표시됩니다...", + elem_id="report-display" + ) + + # 숨겨진 상태 + novel_text_state = gr.State("") + + # 예제 + with gr.Row(): + gr.Examples( + examples=[ + ["기초생활수급자가 된 청년의 생존과 존엄성 찾기"], + ["실직한 중년 남성이 새로운 삶의 의미를 찾아가는 여정"], + ["도시에서 시골로 이주한 청년의 적응과 성장 이야기"], + ["세 세대가 함께 사는 가족의 갈등과 화해"], + ["A middle-aged woman's journey to rediscover herself after divorce"], + ["The transformation of a cynical journalist through unexpected encounters"], + ["작은 서점을 운영하는 노부부의 마지막 1년"], + ["AI 시대에 일자리를 잃은 번역가의 새로운 도전"], + ["재개발로 사라져가는 동네에서의 마지막 계절"] + ], + inputs=query_input, + label="💡 주제 예시" + ) + + # 이벤트 핸들러 + def refresh_sessions(): + try: + sessions = get_active_sessions("Korean") + return gr.update(choices=sessions) + except Exception as e: + logger.error(f"Error refreshing sessions: {str(e)}") + logger.error(f"Full error: {e}", exc_info=True) # 전체 스택 트레이스 로깅 + return gr.update(choices=[]) + + def handle_auto_recover(language): + session_id, message = auto_recover_session(language) + return session_id, message + + def update_displays(stages_md, novel_md, status, session_id): + """모든 디스플레이 업데이트""" + # 평가 보고서 가져오기 + report = "" + if session_id: + session = NovelDatabase.get_session(session_id) + if session and session.get('literary_report'): + report = session['literary_report'] + + return stages_md, novel_md, status, session_id, report + + # 이벤트 연결 + submit_btn.click( + fn=process_query, + inputs=[query_input, language_select, current_session_id], + outputs=[stages_display, novel_output, status_text, current_session_id] + ).then( + fn=lambda s, n, st, sid: (s, n, st, sid, NovelDatabase.get_session(sid).get('literary_report', '') if sid and NovelDatabase.get_session(sid) else ''), + inputs=[stages_display, novel_output, status_text, current_session_id], + outputs=[stages_display, novel_output, status_text, current_session_id, report_display] + ) + + novel_output.change( + fn=lambda x: x, + inputs=[novel_output], + outputs=[novel_text_state] + ) + + resume_btn.click( + fn=lambda x: x.split("...")[0] if x and "..." in x else x, + inputs=[session_dropdown], + outputs=[current_session_id] + ).then( + fn=resume_session, + inputs=[current_session_id, language_select], + outputs=[stages_display, novel_output, status_text, current_session_id] + ).then( + fn=lambda s, n, st, sid: (s, n, st, sid, NovelDatabase.get_session(sid).get('literary_report', '') if sid and NovelDatabase.get_session(sid) else ''), + inputs=[stages_display, novel_output, status_text, current_session_id], + outputs=[stages_display, novel_output, status_text, current_session_id, report_display] + ) + + auto_recover_btn.click( + fn=handle_auto_recover, + inputs=[language_select], + outputs=[current_session_id, status_text] + ).then( + fn=resume_session, + inputs=[current_session_id, language_select], + outputs=[stages_display, novel_output, status_text, current_session_id] + ).then( + fn=lambda s, n, st, sid: (s, n, st, sid, NovelDatabase.get_session(sid).get('literary_report', '') if sid and NovelDatabase.get_session(sid) else ''), + inputs=[stages_display, novel_output, status_text, current_session_id], + outputs=[stages_display, novel_output, status_text, current_session_id, report_display] + ) + + refresh_btn.click( + fn=refresh_sessions, + outputs=[session_dropdown] + ) + + clear_btn.click( + fn=lambda: ("", "", "🔄 준비 완료", "", None, ""), + outputs=[stages_display, novel_output, status_text, novel_text_state, current_session_id, report_display] + ) + + def handle_download(format_type, language, session_id, novel_text): + if not session_id or not novel_text: + return gr.update(visible=False) + + file_path = download_novel(novel_text, format_type, language, session_id) + if file_path: + return gr.update(value=file_path, visible=True) + else: + return gr.update(visible=False) + + download_btn.click( + fn=handle_download, + inputs=[format_select, language_select, current_session_id, novel_text_state], + outputs=[download_file] + ) + + # 시작 시 세션 로드 + interface.load( + fn=refresh_sessions, + outputs=[session_dropdown] + ) + + return interface + + +# 메인 실행 +if __name__ == "__main__": + logger.info("AI 진행형 장편소설 생성 시스템 v3.1 시작...") + logger.info("=" * 60) + + # 환경 확인 + logger.info(f"API 엔드포인트: {API_URL}") + logger.info(f"목표 분량: {TARGET_WORDS:,}단어") + logger.info(f"작가당 최소 분량: {MIN_WORDS_PER_WRITER:,}단어") + logger.info("주요 개선사항:") + logger.info("- 분량 목표 8,000단어로 조정") + logger.info("- 프롬프트 간소화") + logger.info("- 단어 수 부족 시 자동 재생성") + logger.info("- 실시간 진행률 표시") + logger.info("- 프롬프트 자동 증강 기능") + logger.info("- 인과관계와 캐릭터 일관성 강화") + + if BRAVE_SEARCH_API_KEY: + logger.info("웹 검색이 활성화되었습니다.") + else: + logger.warning("웹 검색이 비활성화되었습니다.") + + if DOCX_AVAILABLE: + logger.info("DOCX 내보내기가 활성화되었습니다.") + else: + logger.warning("DOCX 내보내기가 비활성화되었습니다.") + + logger.info("=" * 60) + + # 데이터베이스 초기화 + logger.info("데이터베이스 초기화 중...") + NovelDatabase.init_db() + logger.info("데이터베이스 초기화 완료.") + + # 인터페이스 생성 및 실행 + interface = create_interface() + + interface.launch( + server_name="0.0.0.0", + server_port=7860, + share=False, + debug=True + ) + +
+ ⚠️ 분량 목표: 각 작가당 최소 800단어, 전체 8,000단어 이상 +
+ + """) + + # 상태 관리 + current_session_id = gr.State(None) + + with gr.Row(): + with gr.Column(scale=1): + with gr.Group(elem_classes=["input-section"]): + query_input = gr.Textbox( + label="소설 주제 / Novel Theme", + placeholder="중편소설의 주제를 입력하세요. 인물의 변화와 성장이 중심이 되는 이야기...\nEnter the theme for your novella. Focus on character transformation and growth...", + lines=4 + ) + + language_select = gr.Radio( + choices=["Korean", "English"], + value="Korean", + label="언어 / Language" + ) + + with gr.Row(): + submit_btn = gr.Button("🚀 소설 생성 시작", variant="primary", scale=2) + clear_btn = gr.Button("🗑️ 초기화", scale=1) + + status_text = gr.Textbox( + label="상태", + interactive=False, + value="🔄 준비 완료" + ) + + # 세션 관리 + with gr.Group(elem_classes=["session-section"]): + gr.Markdown("### 💾 진행 중인 세션") + session_dropdown = gr.Dropdown( + label="세션 선택", + choices=[], + interactive=True + ) + with gr.Row(): + refresh_btn = gr.Button("🔄 목록 새로고침", scale=1) + resume_btn = gr.Button("▶️ 선택 재개", variant="secondary", scale=1) + auto_recover_btn = gr.Button("♻️ 최근 세션 복구", scale=1) + + with gr.Column(scale=2): + with gr.Tab("📝 창작 진행"): + stages_display = gr.Markdown( + value="창작 과정이 여기에 표시됩니다...", + elem_id="stages-display" + ) + + with gr.Tab("📖 완성된 소설"): + novel_output = gr.Markdown( + value="완성된 소설이 여기에 표시됩니다...", + elem_id="novel-output" + ) + + with gr.Group(elem_classes=["download-section"]): + gr.Markdown("### 📥 소설 다운로드") + with gr.Row(): + format_select = gr.Radio( + choices=["DOCX", "TXT"], + value="DOCX" if DOCX_AVAILABLE else "TXT", + label="형식" + ) + download_btn = gr.Button("⬇️ 다운로드", variant="secondary") + + download_file = gr.File( + label="다운로드된 파일", + visible=False + ) + + with gr.Tab("📊 평가 보고서"): + report_display = gr.Markdown( + value="평가 보고서가 여기에 표시됩니다...", + elem_id="report-display" + ) + + # 숨겨진 상태 + novel_text_state = gr.State("") + + # 예제 + with gr.Row(): + gr.Examples( + examples=[ + ["기초생활수급자가 된 청년의 생존과 존엄성 찾기"], + ["실직한 중년 남성이 새로운 삶의 의미를 찾아가는 여정"], + ["도시에서 시골로 이주한 청년의 적응과 성장 이야기"], + ["세 세대가 함께 사는 가족의 갈등과 화해"], + ["A middle-aged woman's journey to rediscover herself after divorce"], + ["The transformation of a cynical journalist through unexpected encounters"], + ["작은 서점을 운영하는 노부부의 마지막 1년"], + ["AI 시대에 일자리를 잃은 번역가의 새로운 도전"], + ["재개발로 사라져가는 동네에서의 마지막 계절"] + ], + inputs=query_input, + label="💡 주제 예시" + ) + + # 이벤트 핸들러 + def refresh_sessions(): + try: + sessions = get_active_sessions("Korean") + return gr.update(choices=sessions) + except Exception as e: + logger.error(f"Error refreshing sessions: {str(e)}") + logger.error(f"Full error: {e}", exc_info=True) # 전체 스택 트레이스 로깅 + return gr.update(choices=[]) + + def handle_auto_recover(language): + session_id, message = auto_recover_session(language) + return session_id, message + + def update_displays(stages_md, novel_md, status, session_id): + """모든 디스플레이 업데이트""" + # 평가 보고서 가져오기 + report = "" + if session_id: + session = NovelDatabase.get_session(session_id) + if session and session.get('literary_report'): + report = session['literary_report'] + + return stages_md, novel_md, status, session_id, report + + # 이벤트 연결 + submit_btn.click( + fn=process_query, + inputs=[query_input, language_select, current_session_id], + outputs=[stages_display, novel_output, status_text, current_session_id] + ).then( + fn=lambda s, n, st, sid: (s, n, st, sid, NovelDatabase.get_session(sid).get('literary_report', '') if sid and NovelDatabase.get_session(sid) else ''), + inputs=[stages_display, novel_output, status_text, current_session_id], + outputs=[stages_display, novel_output, status_text, current_session_id, report_display] + ) + + novel_output.change( + fn=lambda x: x, + inputs=[novel_output], + outputs=[novel_text_state] + ) + + resume_btn.click( + fn=lambda x: x.split("...")[0] if x and "..." in x else x, + inputs=[session_dropdown], + outputs=[current_session_id] + ).then( + fn=resume_session, + inputs=[current_session_id, language_select], + outputs=[stages_display, novel_output, status_text, current_session_id] + ).then( + fn=lambda s, n, st, sid: (s, n, st, sid, NovelDatabase.get_session(sid).get('literary_report', '') if sid and NovelDatabase.get_session(sid) else ''), + inputs=[stages_display, novel_output, status_text, current_session_id], + outputs=[stages_display, novel_output, status_text, current_session_id, report_display] + ) + + auto_recover_btn.click( + fn=handle_auto_recover, + inputs=[language_select], + outputs=[current_session_id, status_text] + ).then( + fn=resume_session, + inputs=[current_session_id, language_select], + outputs=[stages_display, novel_output, status_text, current_session_id] + ).then( + fn=lambda s, n, st, sid: (s, n, st, sid, NovelDatabase.get_session(sid).get('literary_report', '') if sid and NovelDatabase.get_session(sid) else ''), + inputs=[stages_display, novel_output, status_text, current_session_id], + outputs=[stages_display, novel_output, status_text, current_session_id, report_display] + ) + + refresh_btn.click( + fn=refresh_sessions, + outputs=[session_dropdown] + ) + + clear_btn.click( + fn=lambda: ("", "", "🔄 준비 완료", "", None, ""), + outputs=[stages_display, novel_output, status_text, novel_text_state, current_session_id, report_display] + ) + + def handle_download(format_type, language, session_id, novel_text): + if not session_id or not novel_text: + return gr.update(visible=False) + + file_path = download_novel(novel_text, format_type, language, session_id) + if file_path: + return gr.update(value=file_path, visible=True) + else: + return gr.update(visible=False) + + download_btn.click( + fn=handle_download, + inputs=[format_select, language_select, current_session_id, novel_text_state], + outputs=[download_file] + ) + + # 시작 시 세션 로드 + interface.load( + fn=refresh_sessions, + outputs=[session_dropdown] + ) + + return interface + + +# 메인 실행 +if __name__ == "__main__": + logger.info("AI 진행형 장편소설 생성 시스템 v3.1 시작...") + logger.info("=" * 60) + + # 환경 확인 + logger.info(f"API 엔드포인트: {API_URL}") + logger.info(f"목표 분량: {TARGET_WORDS:,}단어") + logger.info(f"작가당 최소 분량: {MIN_WORDS_PER_WRITER:,}단어") + logger.info("주요 개선사항:") + logger.info("- 분량 목표 8,000단어로 조정") + logger.info("- 프롬프트 간소화") + logger.info("- 단어 수 부족 시 자��� 재생성") + logger.info("- 실시간 진행률 표시") + logger.info("- 프롬프트 자동 증강 기능") + logger.info("- 인과관계와 캐릭터 일관성 강화") + + if BRAVE_SEARCH_API_KEY: + logger.info("웹 검색이 활성화되었습니다.") + else: + logger.warning("웹 검색이 비활성화되었습니다.") + + if DOCX_AVAILABLE: + logger.info("DOCX 내보내기가 활성화되었습니다.") + else: + logger.warning("DOCX 내보내기가 비활성화되었습니다.") + + logger.info("=" * 60) + + # 데이터베이스 초기화 + logger.info("데이터베이스 초기화 중...") + NovelDatabase.init_db() + logger.info("데이터베이스 초기화 완료.") + + # 인터페이스 생성 및 실행 + interface = create_interface() + + interface.launch( + server_name="0.0.0.0", + server_port=7860, + share=False, + debug=True + ) \ No newline at end of file