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 핵심 개선사항:
+
+ - 목표 단어 수 8,000단어로 조정
+ - 프롬프트 간소화로 생성 공간 확보
+ - 단어 수 부족 시 자동 재생성
+ - 실시간 진행률 표시
+ - 분량 미달 경고 시스템
+ - 프롬프트 자동 증강 기능
+ - 인과관계와 캐릭터 일관성 강화
+
+
+
+ ⚠️ 분량 목표: 각 작가당 최소 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