diff --git "a/app.py" "b/app.py" --- "a/app.py" +++ "b/app.py" @@ -4,7 +4,7 @@ import json import requests from datetime import datetime import time -from typing import List, Dict, Any, Generator, Tuple, Optional +from typing import List, Dict, Any, Generator, Tuple, Optional, Set import logging import re import tempfile @@ -13,8 +13,9 @@ import sqlite3 import hashlib import threading from contextlib import contextmanager -from dataclasses import dataclass, field +from dataclasses import dataclass, field, asdict from collections import defaultdict +from enum import Enum # --- 로깅 설정 --- logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') @@ -26,6 +27,8 @@ try: 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 @@ -36,12 +39,19 @@ 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_v2.db" +DB_PATH = "novel_sessions_v3.db" + +# --- 플롯 진행 단계 Enum --- +class PlotStage(Enum): + EXPOSITION = "도입" + RISING_ACTION = "전개" + CLIMAX = "절정" + FALLING_ACTION = "하강" + RESOLUTION = "결말" # --- 환경 변수 검증 --- 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: @@ -50,29 +60,44 @@ if not BRAVE_SEARCH_API_KEY: # --- 전역 변수 --- db_lock = threading.Lock() -# 최적화된 단계 구성 (25단계로 압축 및 강화) +# 최적화된 단계 구성 (서사 진행 강제) OPTIMIZED_STAGES = [ - ("director", "🎬 감독자: 초기 기획 (웹 검색 포함)"), - ("critic", "📝 비평가: 기획 검토 (철학적 깊이 및 일관성)"), - ("director", "🎬 감독자: 수정된 마스터플랜"), + ("director", "🎬 감독자: 초기 기획 및 플롯 구조화"), + ("critic", "📝 비평가: 기획 검토 (서사 구조 및 캐릭터 일관성)"), + ("director", "🎬 감독자: 수정된 마스터플랜 (상세 플롯 포함)"), ] + [ - (f"writer{i}", f"✍️ 작가 {i}: 초안 (페이지 {(i-1)*3+1}-{i*3})") + (f"writer{i}", f"✍️ 작가 {i}: 초안 (플롯 단계: {get_plot_stage(i)})") for i in range(1, 11) ] + [ - ("critic", "📝 비평가: 중간 검토 (철학적 깊이 및 일관성)"), + ("critic", "📝 비평가: 중간 검토 (서사 진행도 및 일관성)"), ] + [ - (f"writer{i}", f"✍️ 작가 {i}: 수정본 (페이지 {(i-1)*3+1}-{i*3})") + (f"writer{i}", f"✍️ 작가 {i}: 수정본 (플롯 단계: {get_plot_stage(i)})") for i in range(1, 11) ] + [ - ("critic", f"📝 비평가: 최종 검토 및 종합 보고서 작성"), + ("critic", f"📝 비평가: 최종 검토 및 종합 평가"), ] +def get_plot_stage(writer_num: int) -> str: + """작가 번호에 따른 플롯 단계 반환""" + if writer_num <= 2: + return PlotStage.EXPOSITION.value + elif writer_num <= 5: + return PlotStage.RISING_ACTION.value + elif writer_num <= 7: + return PlotStage.CLIMAX.value + elif writer_num <= 9: + return PlotStage.FALLING_ACTION.value + else: + return PlotStage.RESOLUTION.value + -# --- 데이터 클래스 --- +# --- 데이터 클래스 (강화된 일관성 추적) --- @dataclass class CharacterState: - """캐릭터의 현재 상태를 나타내는 데이터 클래스""" + """캐릭터의 완전한 상태 관리""" name: str + age: int # 명확한 나이 지정 + occupation: str alive: bool = True location: str = "" injuries: List[str] = field(default_factory=list) @@ -80,185 +105,175 @@ class CharacterState: relationships: Dict[str, str] = field(default_factory=dict) last_seen_chapter: int = 0 description: str = "" - role: str = "" - philosophical_stance: str = "" # 추가: 캐릭터의 철학적 입장 + role: str = "" # 주인공/조연/악역 등 + key_traits: List[str] = field(default_factory=list) + goals: List[str] = field(default_factory=list) + speech_pattern: str = "" # 말투 특징 + + def to_dict(self) -> Dict: + """딕셔너리로 변환 (DB 저장용)""" + return asdict(self) @dataclass -class PlotPoint: - """플롯 포인트를 나타내는 데이터 클래스""" +class PlotEvent: + """플롯 이벤트 (액션 중심)""" chapter: int - event_type: str + event_type: str # action/dialogue/revelation/conflict description: str characters_involved: List[str] - impact_level: int - timestamp: str = "" - philosophical_implication: str = "" # 추가: 사건의 철학적 함의 - + location: str + consequences: List[str] = field(default_factory=list) # 이 사건의 결과 + plot_advancement: str = "" # 플롯이 어떻게 진전되었는지 + @dataclass -class TimelineEvent: - """시간선 이벤트를 나타내는 데이터 클래스""" - chapter: int - time_reference: str - event_description: str - duration: str = "" - relative_time: str = "" - - -# --- 핵심 로직 클래스 --- -class ConsistencyTracker: - """일관성 추적 시스템""" +class StoryBible: + """스토리 전체의 일관성 관리""" + setting_year: int + setting_location: str + genre: str + tone: str + central_conflict: str + themes: List[str] + forbidden_elements: List[str] = field(default_factory=list) # 피해야 할 요소 + must_include_elements: List[str] = field(default_factory=list) # 반드시 포함할 요소 + + +# --- 핵심 로직 클래스 (대폭 강화) --- +class EnhancedConsistencyTracker: + """강화된 일관성 추적 시스템""" def __init__(self): - self.character_states: Dict[str, CharacterState] = {} - self.plot_points: List[PlotPoint] = [] - self.timeline_events: List[TimelineEvent] = [] + self.characters: Dict[str, CharacterState] = {} + self.plot_events: List[PlotEvent] = [] + self.story_bible: Optional[StoryBible] = None self.locations: Dict[str, str] = {} self.established_facts: List[str] = [] - self.content_hashes: Dict[str, int] = {} # 해시와 해당 챕터 번호를 저장 - self.philosophical_themes: List[str] = [] # 추가: 철학적 주제 추적 - - def register_character(self, character: CharacterState): - """새 캐릭터 등록""" - self.character_states[character.name] = character - logger.info(f"Character registered: {character.name}") - - def update_character_state(self, name: str, chapter: int, updates: Dict[str, Any]): - """캐릭터 상태 업데이트""" - if name not in self.character_states: - self.register_character(CharacterState(name=name, last_seen_chapter=chapter)) - char = self.character_states[name] + self.plot_threads: Dict[str, List[str]] = {} # 플롯 라인 추적 + self.chapter_summaries: Dict[int, str] = {} + self.forbidden_patterns: Set[str] = set() # 반복 방지용 + + def register_character(self, character: CharacterState) -> bool: + """캐릭터 등록 (중복 체크)""" + if character.name in self.characters: + logger.warning(f"Character {character.name} already exists!") + return False + self.characters[character.name] = character + logger.info(f"Character registered: {character.name}, Age: {character.age}, Role: {character.role}") + return True + + def update_character_state(self, name: str, chapter: int, updates: Dict[str, Any]) -> List[str]: + """캐릭터 상태 업데이트 (일관성 체크 포함)""" + errors = [] + if name not in self.characters: + errors.append(f"Unknown character: {name}") + return errors + + char = self.characters[name] + + # 나이 변경 방지 + if 'age' in updates and updates['age'] != char.age: + errors.append(f"Age inconsistency for {name}: was {char.age}, attempted to change to {updates['age']}") + del updates['age'] + + # 사망한 캐릭터 부활 방지 + if not char.alive and updates.get('alive', False): + errors.append(f"Cannot resurrect dead character: {name}") + del updates['alive'] + + # 안전한 업데이트 for key, value in updates.items(): if hasattr(char, key): setattr(char, key, value) char.last_seen_chapter = chapter - - def add_plot_point(self, plot_point: PlotPoint): - """플롯 포인트 추가""" - plot_point.timestamp = datetime.now().isoformat() - self.plot_points.append(plot_point) - - def check_repetition(self, content: str, current_chapter: int) -> Tuple[bool, str]: - """향상된 반복 내용 검사""" - sentences = re.split(r'[.!?]+', content) - for sentence in sentences: - sentence_strip = sentence.strip() - if len(sentence_strip) > 20: # 너무 짧은 문장은 무시 - sentence_hash = hashlib.md5(sentence_strip.encode('utf-8')).hexdigest() - if sentence_hash in self.content_hashes: - previous_chapter = self.content_hashes[sentence_hash] - # 바로 이전 챕터와의 반복은 허용할 수 있으므로, 2챕터 이상 차이날 때만 오류로 간주 - if current_chapter > previous_chapter + 1: - return True, f"문장 반복 (챕터 {previous_chapter}과 유사): {sentence_strip[:50]}..." - # 새 내용의 해시 추가 - for sentence in sentences: - sentence_strip = sentence.strip() - if len(sentence_strip) > 20: - sentence_hash = hashlib.md5(sentence_strip.encode('utf-8')).hexdigest() - self.content_hashes[sentence_hash] = current_chapter - return False, "" - - def validate_consistency(self, chapter: int, content: str) -> List[str]: - """일관성 검증""" + + return errors + + def add_plot_event(self, event: PlotEvent) -> List[str]: + """플롯 이벤트 추가 (논리성 체크)""" errors = [] - # 사망한 캐릭터 등장 검사 - for char_name, char_state in self.character_states.items(): - if char_name.lower() in content.lower() and not char_state.alive: - errors.append(f"⚠️ 사망한 캐릭터 '{char_name}'이(가) 등장했습니다.") - # 내용 반복 검사 - is_repetition, repeat_msg = self.check_repetition(content, chapter) - if is_repetition: - errors.append(f"🔄 {repeat_msg}") + + # 관련 캐릭터 존재 확인 + for char_name in event.characters_involved: + if char_name not in self.characters: + errors.append(f"Unknown character in event: {char_name}") + elif not self.characters[char_name].alive: + errors.append(f"Dead character cannot participate: {char_name}") + + # 장소 일관성 + if event.location and event.location not in self.locations: + self.locations[event.location] = f"First mentioned in chapter {event.chapter}" + + if not errors: + self.plot_events.append(event) + return errors - - def get_character_summary(self, chapter: int) -> str: - """현재 챕터 기준 캐릭터 요약""" - summary = "\n=== 캐릭터 현황 요약 (이전 2개 챕터 기준) ===\n" - active_chars = [char for char in self.character_states.values() if char.last_seen_chapter >= chapter - 2] - if not active_chars: - return "\n(아직 주요 캐릭터 정보가 없습니다.)\n" - for char in active_chars: - status = "생존" if char.alive else "사망" - summary += f"• {char.name}: {status}" - if char.alive and char.location: - summary += f" (위치: {char.location})" - if char.injuries: - summary += f" (부상: {', '.join(char.injuries[-1:])})" - if char.philosophical_stance: - summary += f" (철학적 입장: {char.philosophical_stance})" - summary += "\n" - return summary - + + def check_plot_progression(self, chapter: int) -> Tuple[bool, str]: + """플롯이 실제로 진행되고 있는지 확인""" + recent_events = [e for e in self.plot_events if e.chapter >= chapter - 2] + + if len(recent_events) < 2: + return False, "Not enough events to assess progression" + + # 동일한 유형의 이벤트만 반복되는지 확인 + event_types = [e.event_type for e in recent_events] + if len(set(event_types)) == 1: + return False, f"Only '{event_types[0]}' events occurring - no variety" + + # 플롯 진전이 있는지 확인 + advancements = [e.plot_advancement for e in recent_events if e.plot_advancement] + if not advancements: + return False, "No plot advancement recorded in recent events" + + return True, "Plot is progressing" + + def get_character_sheet(self, name: str) -> str: + """캐릭터의 완전한 정보 시트""" + if name not in self.characters: + return f"Character {name} not found" + + char = self.characters[name] + sheet = f""" +=== {char.name} === +나이: {char.age}세 +직업: {char.occupation} +역할: {char.role} +상태: {'생존' if char.alive else '사망'} +현재 위치: {char.location or '미상'} +성격 특징: {', '.join(char.key_traits)} +목표: {', '.join(char.goals)} +말투: {char.speech_pattern} +관계: {', '.join([f"{k}: {v}" for k, v in char.relationships.items()])} +""" + return sheet + def get_plot_summary(self, chapter: int) -> str: - """플롯 요약""" - summary = "\n=== 최근 주요 사건 요약 ===\n" - recent_events = [p for p in self.plot_points if p.chapter >= chapter - 2] + """액션 중심의 플롯 요약""" + summary = "\n=== 최근 주요 사건 (액션) ===\n" + recent_events = sorted([e for e in self.plot_events if e.chapter >= chapter - 3], + key=lambda x: x.chapter)[-5:] + if not recent_events: return "\n(아직 주요 사건이 없습니다.)\n" - for event in recent_events[-3:]: # 최근 3개만 표시 - summary += f"• [챕터 {event.chapter}] {event.description}" - if event.philosophical_implication: - summary += f"\n → 철학적 함의: {event.philosophical_implication}" - summary += "\n" + + for event in recent_events: + summary += f"• [Ch.{event.chapter}] {event.description}\n" + if event.plot_advancement: + summary += f" → 플롯 진전: {event.plot_advancement}\n" + if event.consequences: + summary += f" → 결과: {', '.join(event.consequences)}\n" + return summary -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", []) - logger.info(f"웹 검색 성공: '{query}'에 대해 {len(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", "") - url = result.get("url", "") - info = f"[{i}] {title}\n{description}\nSource: {url}\n" - if total_chars + len(info) < max_chars: - extracted.append(info) - total_chars += len(info) - else: - break - return "\n---\n".join(extracted) - - 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, @@ -270,9 +285,11 @@ class NovelDatabase: current_stage INTEGER DEFAULT 0, final_novel TEXT, consistency_report TEXT, - philosophical_depth_score REAL DEFAULT 0.0 + narrative_score REAL DEFAULT 0.0, + story_bible TEXT ) ''') + cursor.execute(''' CREATE TABLE IF NOT EXISTS stages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -284,35 +301,96 @@ class NovelDatabase: word_count INTEGER DEFAULT 0, status TEXT DEFAULT 'pending', consistency_score REAL DEFAULT 0.0, - philosophical_depth_score REAL DEFAULT 0.0, + plot_progression_score REAL DEFAULT 0.0, 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 character_registry ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + name TEXT NOT NULL, + age INTEGER NOT NULL, + occupation TEXT, + role TEXT, + description TEXT, + key_traits TEXT, + speech_pattern TEXT, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (session_id) REFERENCES sessions(session_id), + UNIQUE(session_id, name) + ) + ''') + + # 플롯 이벤트 테이블 cursor.execute(''' - CREATE TABLE IF NOT EXISTS character_states ( + CREATE TABLE IF NOT EXISTS plot_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, - character_name TEXT NOT NULL, chapter INTEGER NOT NULL, - is_alive BOOLEAN DEFAULT TRUE, - location TEXT, - injuries TEXT, - emotional_state TEXT, + event_type TEXT, description TEXT, - philosophical_stance TEXT, + characters_involved TEXT, + location TEXT, + plot_advancement TEXT, created_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (session_id) REFERENCES sessions(session_id) ) ''') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_session_id ON stages(session_id)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_stage_number ON stages(stage_number)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_char_session ON character_states(session_id)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_session_status ON sessions(status)') + + conn.commit() + + @staticmethod + def save_character(session_id: str, character: CharacterState): + """캐릭터 정보 저장""" + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO character_registry + (session_id, name, age, occupation, role, description, key_traits, speech_pattern) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(session_id, name) + DO UPDATE SET age=?, occupation=?, role=?, description=?, key_traits=?, speech_pattern=? + ''', ( + session_id, character.name, character.age, character.occupation, + character.role, character.description, + json.dumps(character.key_traits), character.speech_pattern, + character.age, character.occupation, character.role, + character.description, json.dumps(character.key_traits), character.speech_pattern + )) conn.commit() + + @staticmethod + def load_characters(session_id: str) -> Dict[str, CharacterState]: + """세션의 모든 캐릭터 로드""" + characters = {} + with sqlite3.connect(DB_PATH) as conn: + cursor = conn.cursor() + rows = cursor.execute( + 'SELECT * FROM character_registry WHERE session_id = ?', + (session_id,) + ).fetchall() + + for row in rows: + char = CharacterState( + name=row[2], + age=row[3], + occupation=row[4] or "", + role=row[5] or "", + description=row[6] or "", + key_traits=json.loads(row[7]) if row[7] else [], + speech_pattern=row[8] or "" + ) + characters[char.name] = char + + return characters + # 기존 메서드들은 그대로 유지... @staticmethod @contextmanager def get_db(): @@ -338,23 +416,42 @@ class NovelDatabase: @staticmethod def save_stage(session_id: str, stage_number: int, stage_name: str, role: str, content: str, status: str = 'complete', - consistency_score: float = 0.0, philosophical_depth_score: float = 0.0): + consistency_score: float = 0.0, plot_progression_score: float = 0.0): 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, consistency_score, philosophical_depth_score) + INSERT INTO stages (session_id, stage_number, stage_name, role, content, word_count, status, consistency_score, plot_progression_score) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id, stage_number) - DO UPDATE SET content=?, word_count=?, status=?, stage_name=?, consistency_score=?, philosophical_depth_score=?, updated_at=datetime('now') - ''', (session_id, stage_number, stage_name, role, content, word_count, status, consistency_score, philosophical_depth_score, - content, word_count, status, stage_name, consistency_score, philosophical_depth_score)) + DO UPDATE SET content=?, word_count=?, status=?, stage_name=?, consistency_score=?, plot_progression_score=?, updated_at=datetime('now') + ''', (session_id, stage_number, stage_name, role, content, word_count, status, consistency_score, plot_progression_score, + content, word_count, status, stage_name, consistency_score, plot_progression_score)) cursor.execute( "UPDATE sessions SET updated_at = datetime('now'), current_stage = ? WHERE session_id = ?", (stage_number, session_id) ) conn.commit() + @staticmethod + def get_writer_content(session_id: str, remove_metadata: bool = True) -> 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 row and row['content']: + content = row['content'].strip() + if remove_metadata: + # 메타데이터 제거 + content = remove_metadata_from_content(content) + all_content.append(content) + return '\n\n'.join(all_content) + + # 나머지 메서드들... @staticmethod def get_session(session_id: str) -> Optional[Dict]: with NovelDatabase.get_db() as conn: @@ -374,24 +471,11 @@ class NovelDatabase: return [dict(row) for row in rows] @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 row and row['content']: - all_content.append(row['content'].strip()) - return '\n\n'.join(all_content) - - @staticmethod - def update_final_novel(session_id: str, final_novel: str, consistency_report: str = "", philosophical_depth_score: float = 0.0): + def update_final_novel(session_id: str, final_novel: str, consistency_report: str = "", narrative_score: float = 0.0): with NovelDatabase.get_db() as conn: conn.cursor().execute( - "UPDATE sessions SET final_novel = ?, status = 'complete', updated_at = datetime('now'), consistency_report = ?, philosophical_depth_score = ? WHERE session_id = ?", - (final_novel, consistency_report, philosophical_depth_score, session_id) + "UPDATE sessions SET final_novel = ?, status = 'complete', updated_at = datetime('now'), consistency_report = ?, narrative_score = ? WHERE session_id = ?", + (final_novel, consistency_report, narrative_score, session_id) ) conn.commit() @@ -404,27 +488,104 @@ class NovelDatabase: return [dict(row) for row in rows] +def remove_metadata_from_content(content: str) -> str: + """콘텐츠에서 메타데이터 제거""" + # 페이지 번호, 작가 번호, 구분선 등 제거 + lines = content.split('\n') + cleaned_lines = [] + + for line in lines: + # 제거할 패턴들 + if any([ + re.match(r'^페이지\s*\d+', line), + re.match(r'^\d+-\d+\s*페이지', line), + re.match(r'^작가\s*\d+', line), + re.match(r'^---+$', line), + re.match(r'^===+$', line), + re.match(r'^\*\*\*+$', line), + re.match(r'^Chapter\s+\d+', line, re.IGNORECASE), + re.match(r'^Part\s+\d+', line, re.IGNORECASE), + line.strip() == '' + ]): + continue + cleaned_lines.append(line) + + # 연속된 빈 줄 제거 + result = '\n'.join(cleaned_lines) + result = re.sub(r'\n{3,}', '\n\n', result) + + return result.strip() + + +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", []) + logger.info(f"웹 검색 성공: '{query}'에 대해 {len(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", "") + url = result.get("url", "") + info = f"[{i}] {title}\n{description}\nSource: {url}\n" + if total_chars + len(info) < max_chars: + extracted.append(info) + total_chars += len(info) + else: + break + return "\n---\n".join(extracted) + + class NovelWritingSystem: - """최적화된 소설 생성 시스템""" + """개선된 소설 생성 시스템""" def __init__(self): self.token = FRIENDLI_TOKEN self.api_url = API_URL self.model_id = MODEL_ID - self.consistency_tracker = ConsistencyTracker() + self.consistency_tracker = EnhancedConsistencyTracker() self.web_search = WebSearchIntegration() self.current_session_id = None NovelDatabase.init_db() def create_headers(self): - """API 헤더 생성""" return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"} - # --- 프롬프트 생성 함수들 (AI 제약 제거, 철학적 깊이 강화) --- + # --- 프롬프트 생성 함수들 (서사 중심, 액션 강조) --- def create_director_initial_prompt(self, user_query: str, language: str) -> str: - """감독자 초기 기획 프롬프트 (AI 강제성 완전 제거, 철학적 깊이 강화)""" + """감독자 초기 기획 프롬프트 (완전한 플롯 구조 요구)""" search_results_str = "" if self.web_search.enabled: - queries = [f"{user_query} novel setting", f"{user_query} background information"] + queries = [f"{user_query} story plot", f"{user_query} novel conflict"] search_results = self.web_search.search(queries[0], count=2, language=language) if search_results: search_results_str = self.web_search.extract_relevant_info(search_results) @@ -432,401 +593,282 @@ class NovelWritingSystem: lang_prompts = { "Korean": { "title": "당신은 30페이지 분량의 중편 소설을 기획하는 문학 감독자입니다.", + "critical_rules": """⚠️ 절대 준수 사항 ⚠️ +1. 반드시 완전한 기승전결 구조를 가진 플롯을 설계하세요 +2. 각 캐릭터의 나이와 기본 정보는 절대 변경되어서는 안 됩니다 +3. 내적 독백보다 구체적인 행동과 사건을 중심으로 구성하세요 +4. 동일한 상황의 반복은 절대 금지입니다""", "user_theme": "사용자 주제", - "plan_instruction": "다음 요소들을 포함한 상세한 소설 기획을 작성하세요:", - "theme_section": """1. **주제와 철학적 탐구** - - 핵심 주제와 메시지 (사용자 의도를 깊이 있게 해석) - - 탐구할 인간의 근원적 문제 (예: 정체성, 자유의지, 사랑과 증오, 삶과 죽음, 선과 악의 경계 등) - - 장르 및 분위기 (철학적 깊이를 담을 수 있는 톤)""", - "char_section": """2. **주요 등장인물** (3-5명, 각자의 철학적 입장과 내적 갈등이 뚜렷해야 함) - | 이름 | 역할 | 성격 | 배경 | 핵심 욕망 | 내적 갈등 | 철학적 입장 | - - 각 인물은 서로 다른 가치관과 세계관을 대표해야 함 - - 인물 간의 갈등은 단순한 대립이 아닌 철학적 충돌이어야 함""", - "setting_section": """3. **배경 설정** - - 시공간적 배경 (주제를 효과적으로 탐구할 수 있는 환경) - - 사회적/문화적 환경 - - 상징적 공간들 (각 공간이 철학적 의미를 담아야 함)""", - "plot_section": """4. **플롯 구조** (10개 파트, 각 3페이지 분량) - | 파트 | 페이지 | 주요 사건 | 감정의 흐름 | 철학적 탐구 주제 | 인물의 내적 변화 | - - 각 파트는 외적 사건과 내적 성찰이 균형을 이루어야 함 - - 갈등은 점진적으로 심화되며, 인물들의 세계관이 시험받아야 함""", - "guideline_section": """5. **작가별 지침** - - 문체: 간결하면서도 함축적이고 시적인 문체 - - 시점: 내면 묘사에 유리한 시점 선택 - - 상징과 은유: 주제를 강화하는 반복적 이미지나 상징 - - 대화: 철학적 깊이를 담되 자연스러운 대화""", - "philosophical_principle": """⚠️ 핵심 창작 원칙: 철학적 깊이 ⚠️ -이 소설은 단순한 사건의 나열이 아닙니다. 모든 서사의 기저에는 **인간의 근원적 문제에 대한 철학적 사유**가 반드시 깔려 있어야 합니다. -- 등장인물들은 각자의 신념과 가치관을 가지고 갈등하며, 이를 통해 독자가 삶의 의미를 성찰하게 해야 합니다. -- 결말은 단순한 해결이 아닌, 더 깊은 질문을 남기는 열린 결말이어야 합니다. -- 모든 장면과 대화는 주제를 심화시키는 역할을 해야 합니다.""", - "final_instruction": "위 원칙에 따라 인간 존재의 본질을 탐구하는 깊이 있는 소설을 기획하세요." + "plan_instruction": "다음 형식에 따라 체계적인 소설 기획을 작성하세요:", + "story_bible": """1. **스토리 바이블 (절대 불변)** + - 배경: [정확한 연도, 도시, 계절] + - 장르: [명확한 장르 1-2개] + - 톤: [일관된 분위기] + - 중심 갈등: [구체적이고 해결 가능한 갈등]""", + "characters": """2. **주요 등장인물** (3-5명, 정보는 절대 불변) + | 이름 | 나이(정확히) | 직업 | 역할 | 핵심 특징 | 목표 | 말투 특징 | + 예시: | 김민수 | 35세 | 형사 | 주인공 | 정의롭지만 융통성 없음 | 연쇄 사기범 검거 | 격식체, 단호함 | + + ⚠️ 각 캐릭터의 나이는 작품 전체에서 절대 변경될 수 없습니다!""", + "plot_structure": """3. **구체적 플롯 구조** (기승전결 필수) + + **도입부 (1-2장)**: + - 시작 사건: [구체적인 triggering event] + - 주인공 소개와 일상 + - 갈등의 씨앗 + + **전개부 (3-5장)**: + - 사건 1: [구체적 행동/결과] + - 사건 2: [갈등 심화] + - 사건 3: [새로운 장애물] + + **절정부 (6-7장)**: + - 최대 위기: [구체적 상황] + - 주인공의 선택 + - 대결/대립 + + **하강부 (8-9장)**: + - 절정의 여파 + - 갈등 해결 과정 + + **결말부 (10장)**: + - 모든 갈등의 해결 + - 캐릭터의 변화 + - 새로운 균형""", + "scene_planning": """4. **장별 구체적 사건 계획** + | 장 | 핵심 사건 | 등장인물 | 장소 | 플롯 진전 | + 예시: | 1장 | 김민수가 신고 접수, 현장 조사 시작 | 김민수, 피해자 | 경찰서→피해자 집 | 사건 인지 |""", + "forbidden": """5. **금지 사항** + - 캐릭터가 일기를 쓰거나 혼자 고민하는 장면 최소화 + - 같은 대화나 상황 반복 금지 + - 추상적 철학 토론 금지 + - 사건 없이 시간만 흐르는 장면 금지""", + "final_instruction": "액션과 대화를 통해 이야기가 전진하는 역동적인 소설을 기획하세요." }, "English": { + # 영어 버전 생략 (구조는 동일) "title": "You are a literary director planning a 30-page novella.", - "user_theme": "User Theme", - "plan_instruction": "Create a detailed novel plan including:", - "theme_section": """1. **Theme and Philosophical Inquiry** - - Core theme and message (Deep interpretation of user's intent) - - Fundamental human problems to explore (e.g., identity, free will, love and hate, life and death, boundaries of good and evil) - - Genre and atmosphere (Tone that can contain philosophical depth)""", - "char_section": """2. **Main Characters** (3-5, each with distinct philosophical positions and internal conflicts) - | Name | Role | Personality | Background | Core Desire | Internal Conflict | Philosophical Stance | - - Each character must represent different values and worldviews - - Conflicts between characters should be philosophical clashes, not simple oppositions""", - "setting_section": """3. **Setting** - - Time and place (Environment that effectively explores the theme) - - Social/cultural environment - - Symbolic spaces (Each space should carry philosophical meaning)""", - "plot_section": """4. **Plot Structure** (10 parts, ~3 pages each) - | Part | Pages | Main Events | Emotional Flow | Philosophical Theme | Character's Internal Change | - - Each part must balance external events with internal reflection - - Conflicts should deepen progressively, testing characters' worldviews""", - "guideline_section": """5. **Writer Guidelines** - - Style: Concise yet implicit and poetic prose - - POV: Choose perspective favorable for internal portrayal - - Symbols and metaphors: Recurring images or symbols that reinforce the theme - - Dialogue: Natural yet philosophically rich""", - "philosophical_principle": """⚠️ CORE CREATIVE PRINCIPLE: PHILOSOPHICAL DEPTH ⚠️ -This is not a mere sequence of events. The foundation of all narrative must be **philosophical inquiry into fundamental human problems**. -- Characters must conflict with their own beliefs and values, leading readers to reflect on the meaning of life. -- The ending should not be a simple resolution but an open ending that leaves deeper questions. -- Every scene and dialogue must serve to deepen the theme.""", - "final_instruction": "Following these principles, plan a profound novel that explores the essence of human existence." + # ... 나머지 영어 프롬프트 } } - p = lang_prompts[language] - - # 웹 검색 결과 포맷팅 - search_section = "" - if search_results_str: - search_section = f"\n\n**웹 검색 참고 자료:**\n{search_results_str}\n" if language == "Korean" else f"\n\n**Web Search Reference:**\n{search_results_str}\n" + p = lang_prompts.get(language, lang_prompts["Korean"]) return f"""{p['title']} -{p['philosophical_principle']} +{p['critical_rules']} **{p['user_theme']}:** {user_query} -{search_section} + +웹 검색 참고 자료: +{search_results_str if search_results_str else "N/A"} + {p['plan_instruction']} -{p['theme_section']} +{p['story_bible']} -{p['char_section']} +{p['characters']} -{p['setting_section']} +{p['plot_structure']} -{p['plot_section']} +{p['scene_planning']} -{p['guideline_section']} +{p['forbidden']} --- {p['final_instruction']}""" def create_critic_director_prompt(self, director_plan: str, user_query: str, language: str) -> str: - """비평가의 감독자 기획 검토 프롬프트 (철학적 깊이 검토 강화)""" + """비평가의 감독자 기획 검토 (서사 구조 중심)""" lang_prompts = { - "Korean": { - "title": "당신은 문학 비평가입니다. 감독자의 소설 기획을 '철학적 깊이'와 '기술적 일관성' 관점에서 날카롭게 검토하세요.", - "theme_check": f"""**1. 철학적 깊이 검토 (가장 중요)** - - **원래 주제:** '{user_query}' - - 기획안이 인간의 근원적 문제를 진지하게 탐구하고 있습니까? - - 등장인물들이 각자의 철학적 입장을 가지고 있으며, 그들의 갈등이 단순한 대립이 아닌 가치관의 충돌입니까? - - 플롯이 외적 사건에만 치중하지 않고 내적 성찰과 균형을 이루고 있습니까? - - 결말이 섣부른 해결이 아닌, 독자에게 깊은 사유를 남기는 방향입니까?""", - "consistency_check": """**2. 기술적 일관성 검토** - - 캐릭터 설정의 모순이나 비현실적인 부분 - - 플롯의 논리적 허점이나 개연성 부족 - - 시간선이나 공간 설정의 오류 - - 주제와 플롯, 캐릭터가 유기적으로 연결되어 있는가""", - "instruction": "위 항목들을 중심으로 구체적인 문제점과 개선 방향을 제시하세요. 특히 철학적 깊이가 부족한 부분은 반드시 지적하고 구체적인 대안을 제시하세요." - }, - "English": { - "title": "You are a literary critic. Sharply review the director's plan from perspectives of 'Philosophical Depth' and 'Technical Consistency'.", - "theme_check": f"""**1. Philosophical Depth Review (Most Important)** - - **Original Theme:** '{user_query}' - - Does the plan seriously explore fundamental human problems? - - Do characters have their own philosophical positions, and are their conflicts clashes of values rather than simple oppositions? - - Does the plot balance external events with internal reflection? - - Is the ending aimed at leaving readers with deep contemplation rather than premature resolution?""", - "consistency_check": """**2. Technical Consistency Review** - - Character setting contradictions or unrealistic aspects - - Plot logical flaws or lack of plausibility - - Timeline or spatial setting errors - - Are theme, plot, and characters organically connected?""", - "instruction": "Provide specific problems and improvement directions based on above. Especially point out areas lacking philosophical depth and provide concrete alternatives." - } - } - p = lang_prompts[language] - return f"""{p['title']} - -**감독자 기획:** -{director_plan} - ---- -**검토 항목:** -{p['theme_check']} - -{p['consistency_check']} - -{p['instruction']}""" - - def create_director_revision_prompt(self, initial_plan: str, critic_feedback: str, user_query: str, language: str) -> str: - """감독자 수정 프롬프트""" - lang_prompts = { - "Korean": f"""감독자로서 비평가의 피드백을 반영하여 소설 기획을 수정합니다. + "Korean": f"""당신은 서사 구조 전문 비평가입니다. 다음 항목을 엄격히 검토하세요: **원래 주제:** {user_query} -**초기 기획:** -{initial_plan} - -**비평가 피드백:** -{critic_feedback} - -**수정 지침:** -1. 비평가가 지적한 모든 철학적 깊이 부족과 기술적 문제를 해결하세요. -2. 특히 인물들의 ��적 갈등과 철학적 입장을 더욱 명확하고 깊이 있게 설정하세요. -3. 각 파트별로 탐구할 철학적 주제를 구체적으로 명시하세요. -4. 10명의 작가가 혼동 없이 작업할 수 있도록 명확하고 상세한 최종 마스터플랜을 작성하세요. +**감독자 기획:** +{director_plan} -모든 수정사항이 '인간의 근원적 문제에 대한 철학적 탐구'라는 핵심 원칙에 부합해야 합니다.""", - "English": f"""As director, revise the novel plan reflecting the critic's feedback. +**필수 검토 항목:** -**Original Theme:** {user_query} +1. **서사 구조 완성도** + - 명확한 기승전결이 있는가? + - 각 장에서 구체적인 사건이 일어나는가? + - 플롯이 실제로 전진하는가? -**Initial Plan:** -{initial_plan} +2. **캐릭터 일관성** + - 모든 캐릭터의 나이가 명확히 지정되었는가? + - 캐릭터별 구별되는 특징이 있는가? + - 말투와 행동 패턴이 설정되었는가? -**Critic's Feedback:** -{critic_feedback} +3. **실행 가능성** + - 10명의 작가가 혼란 없이 작업할 수 있을 만큼 명확한가? + - 각 장의 사건이 구체적으로 계획되었는가? -**Revision Guidelines:** -1. Resolve all philosophical depth deficiencies and technical issues pointed out by the critic. -2. Especially clarify and deepen characters' internal conflicts and philosophical positions. -3. Specifically state the philosophical themes to explore in each part. -4. Create a clear and detailed final masterplan that 10 writers can work with without confusion. +4. **함정 회피** + - 내적 독백 과다 의존 위험은 없는가? + - 동일 상황 반복 가능성은 없는가? -All revisions must align with the core principle of 'philosophical inquiry into fundamental human problems'.""" +**평가 결과:** +- 통과/재작성 필요 +- 구체적 개선 사항 (있다면)""", + "English": "You are a narrative structure critic..." # 영어 버전 } - return lang_prompts[language] - - def create_writer_prompt(self, writer_number: int, director_plan: str, previous_content_summary: str, user_query: str, language: str) -> str: - """작가 프롬프트 (철학적 깊이 리마인더 강화)""" - pages_start = (writer_number - 1) * 3 + 1 - pages_end = writer_number * 3 + return lang_prompts.get(language, lang_prompts["Korean"]) + + def create_writer_prompt(self, writer_number: int, director_plan: str, + previous_content_summary: str, user_query: str, + language: str, character_sheets: str, + recent_events: str) -> str: + """작가 프롬프트 (액션 중심, 캐릭터 정보 포함)""" + plot_stage = get_plot_stage(writer_number) lang_prompts = { "Korean": { - "title": f"당신은 작가 {writer_number}번입니다. 소설의 {pages_start}-{pages_end} 페이지를 작성하세요.", - "plan": "감독자 마스터플랜", - "prev_summary": "이전 내용 요약", - "guidelines": f"""**작성 지침:** -1. **분량**: 1,400-1,500 단어 (약 3페이지) -2. **연결성**: 이전 내용과 자연스럽게 연결되면서도 새로운 깊이를 더하세요 -3. **일관성**: 캐릭터의 성격, 철학적 입장, 말투 등을 일관되게 유지하세요 -4. **균형**: 외적 사건과 내적 성찰의 균형을 맞추세요 (50:50 비율 권장) -5. **대화**: 캐릭터의 철학적 입장이 자연스럽게 드러나는 의미 있는 대화를 포함하세요""", - "philosophical_reminder": f"""⭐ 철학적 깊이 리마인더 ⭐ -- 이 파트에서 탐구해야 할 철학적 주제를 염두에 두고 작성하세요 -- 모든 장면은 단순한 사건 진행이 아닌, 인물의 내면 탐구와 성찰의 기회여야 합니다 -- 독자가 스스로 생각하고 질문하게 만드는 열린 표현을 사용하세요 -- 상징과 은유를 통해 주제를 심화시키세요""", - "final_instruction": "당신의 파트가 전체 소설의 철학적 깊이를 더하는 중요한 역할을 한다는 것을 명심하고, 혼을 담아 작성하세요." + "title": f"당신은 작가 {writer_number}번입니다. 플롯 단계: {plot_stage}", + "rules": """⚠️ 절대 준수 사항 ⚠️ +1. 캐릭터 정보(특히 나이)를 절대 변경하지 마세요 +2. 구체적 행동과 대화로 이야기를 전개하세요 +3. 내적 독백은 최소화하고 '보여주기'를 실천하세요 +4. 이전 내용을 반복하지 말고 새로운 전개를 만드세요""", + "content_guide": f"""**작성 지침:** +- 분량: 1,300-1,500 단어 +- 플롯 단계: {plot_stage} +- 반드시 포함할 것: 최소 2개의 구체적 행동/사건 +- 대화: 캐릭터의 고유한 말투 유지 +- 장면 전환: 명확한 시공간 이동 표시""", + "character_reminder": "**캐릭터 정보 (절대 불변):**", + "previous_summary": "**이전 줄거리:**", + "recent_events": "**최근 사건들:**", + "your_mission": f"""**당신의 임무:** +{plot_stage}에 맞는 사건을 만들어 플롯을 전진시키세요. +- 주인공이 구체적으로 무엇을 하는가? +- 그 행동의 결과는 무엇인가? +- 다음 작가에게 어떤 상황을 넘겨줄 것인가?""", + "forbidden": """**금지 사항:** +- "그녀는 생각했다", "그는 고민했다" 같은 내적 서술 +- 일기, 편지, 독백 형식 +- 이전에 일어난 사건 반복 +- 캐릭터 기본 정보 변경""", + "final": "역동적이고 전진하는 이야기를 만드세요!" }, "English": { - "title": f"You are Writer #{writer_number}. Write pages {pages_start}-{pages_end} of the novella.", - "plan": "Director's Masterplan", - "prev_summary": "Previous Content Summary", - "guidelines": f"""**Writing Guidelines:** -1. **Length**: 1,400-1,500 words (approximately 3 pages) -2. **Connectivity**: Connect naturally with previous content while adding new depth -3. **Consistency**: Maintain character personality, philosophical positions, speech patterns consistently -4. **Balance**: Balance external events with internal reflection (50:50 ratio recommended) -5. **Dialogue**: Include meaningful dialogue that naturally reveals characters' philosophical positions""", - "philosophical_reminder": f"""⭐ Philosophical Depth Reminder ⭐ -- Keep in mind the philosophical themes to explore in this part -- Every scene should be an opportunity for character introspection and reflection, not just plot progression -- Use open expressions that make readers think and question -- Deepen the theme through symbols and metaphors""", - "final_instruction": "Remember that your part plays an important role in adding philosophical depth to the overall novel. Write with soul." + # 영어 버전 } } - p = lang_prompts[language] - consistency_info = self.consistency_tracker.get_character_summary(writer_number) + self.consistency_tracker.get_plot_summary(writer_number) + p = lang_prompts.get(language, lang_prompts["Korean"]) return f"""{p['title']} -**{p['plan']}:** -{director_plan} +{p['rules']} + +**마스터플랜 (일부):** +{director_plan[:1000]}... -{consistency_info} +{p['character_reminder']} +{character_sheets} -**{p['prev_summary']}:** +{p['previous_summary']} {previous_content_summary} ---- -{p['guidelines']} +{p['recent_events']} +{recent_events} + +{p['content_guide']} + +{p['your_mission']} -{p['philosophical_reminder']} +{p['forbidden']} --- -{p['final_instruction']}""" +{p['final']}""" - def create_critic_consistency_prompt(self, all_content: str, user_query: str, language: str) -> str: - """비평가 중간 검토 프롬프트""" - lang_prompts = { - "Korean": f"""당신은 일관성과 철학적 깊이를 검토하는 전문 비평가입니다. + def create_critic_consistency_prompt(self, all_content: str, user_query: str, + language: str, character_registry: str) -> str: + """비평가 중간 검토 (서사 진행도 중심)""" + return f"""당신은 서사 진행도와 일관성을 검토하는 전문가입니다. -**원래 주제:** {user_query} +**등록된 캐릭터 정보:** +{character_registry} -**현재까지 작성된 내용 (최근 3000자):** +**현재까지 내용 (최근 부분):** {all_content[-3000:]} **검토 항목:** -1. **철학적 깊이 (가장 중요):** - - 각 파트가 철학적 탐구를 제대로 수행하고 있는가? - - 인물들의 내적 갈등과 성찰이 충분히 묘사되고 있는가? - - 주제가 점진적으로 심화되고 있는가? -2. **기술적 일관성:** - - 캐릭터의 성격, 말투, 행동이 일관되는가? - - 시간선과 공간 설정에 오류가 없는가? - - 플롯이 논리적으로 진행되고 있는가? +1. **서사 진행도 (가장 중요)** + - 실제로 사건이 일어나고 있는가? + - 플롯이 전진하고 있는가? + - 동일한 상황이 반복되지 않는가? -3. **문체와 톤:** - - 전체적인 문체와 분위기가 일관되는가? - - 철학적 깊이에 맞는 문체를 유지하고 있는가? +2. **캐릭터 일관성** + - 등록된 정보(나이, 직업 등)가 유지되는가? + - 말투가 일관되는가? + - 죽은 캐릭터가 다시 등장하지 않는가? -**결과:** 발견된 문제점과 각 작가별 구체적인 수정 제안을 제시하세요.""", - "English": f"""You are a professional critic reviewing consistency and philosophical depth. +3. **논리적 일관성** + - 시간과 공간 이동이 논리적인가? + - 인과관계가 명확한가? -**Original Theme:** {user_query} - -**Content Written So Far (last 3000 characters):** -{all_content[-3000:]} +**발견된 문제:** +[구체적으로 나열] -**Review Items:** -1. **Philosophical Depth (Most Important):** - - Is each part properly conducting philosophical inquiry? - - Are characters' internal conflicts and reflections sufficiently portrayed? - - Is the theme progressively deepening? - -2. **Technical Consistency:** - - Are character personalities, speech patterns, and behaviors consistent? - - Are there no errors in timeline and spatial settings? - - Is the plot progressing logically? - -3. **Style and Tone:** - - Is the overall style and atmosphere consistent? - - Is a style appropriate for philosophical depth maintained? - -**Result:** Present discovered problems and specific revision suggestions for each writer.""" - } - return lang_prompts[language] +**작가별 수정 지시:** +[각 작가에게 구체적 지시]""" - def create_writer_revision_prompt(self, writer_number: int, initial_content: str, consistency_feedback: str, language: str) -> str: + def create_writer_revision_prompt(self, writer_number: int, initial_content: str, + consistency_feedback: str, language: str) -> str: """작가 수정 프롬프트""" - lang_prompts = { - "Korean": f"""작가 {writer_number}번으로서 비평가의 피드백을 반영하여 내용을 수정하세요. + return f"""작가 {writer_number}번, 다음 피드백에 따라 수정하세요: -**초기 작성 내용:** +**초안:** {initial_content} **비평가 피드백:** {consistency_feedback} -**수정 지침:** -1. 지적된 모든 철학적 깊이 부족과 일관성 문제를 해결하세요. -2. 특히 내적 성찰과 철학적 탐구가 부족하다고 지적된 부분을 보강하세요. -3. 분량(1,400-1,500 단어)을 유지하면서 내용의 깊이를 더하세요. -4. 수정은 단순한 추가가 아닌, 전체적인 재구성을 통해 완성도를 높이세요. - -수정된 최종 버전만 제시하세요.""", - "English": f"""As Writer {writer_number}, revise the content reflecting the critic's feedback. - -**Initial Content:** -{initial_content} - -**Critic's Feedback:** -{consistency_feedback} - -**Revision Guidelines:** -1. Resolve all pointed out philosophical depth deficiencies and consistency issues. -2. Especially reinforce parts pointed out as lacking internal reflection and philosophical inquiry. -3. Add depth to content while maintaining length (1,400-1,500 words). -4. Improve completion through overall restructuring, not simple additions. +**수정 원칙:** +1. 지적된 모든 일관성 오류를 수정 +2. 서사가 정체되었다면 새로운 사건 추가 +3. 캐릭터 정보 오류는 즉시 수정 +4. 분량은 1,300-1,500 단어 유지 -Present only the revised final version.""" - } - return lang_prompts[language] +**수정본만 제출하세요. 설명은 불필요합니다.**""" def create_critic_final_prompt(self, complete_novel: str, language: str) -> str: - """최종 비평가 검토 및 보고서 작성 프롬프트""" - lang_prompts = { - "Korean": f"""완성된 소설의 최종 평가 보고서를 작성하세요. + """최종 비평 프롬프트""" + return f"""완성된 소설을 다음 기준으로 평가하세요: -**완성된 소설 (마지막 2000자):** +**소설 마지막 부분:** {complete_novel[-2000:]} -**보고서 포함 항목:** +**평가 항목 (100점 만점):** -1. **철학적 깊이 평가 (40점)** - - 인간의 근원적 문제 탐구 수준 (10점) - - 캐릭터의 내적 갈등과 성찰의 깊이 (10점) - - 주제의 일관성과 발전 (10점) - - 독자에게 남기는 사유의 여지 (10점) +1. **서사 구조 (40점)** + - 완전한 기승전결 (10점) + - 플롯 진행과 사건 전개 (10점) + - 갈등의 설정과 해결 (10점) + - 클라이맥스의 효과성 (10점) -2. **기술적 완성도 평가 (30점)** - - 플롯의 논리성과 개연성 (10점) - - 캐릭터의 일관성 (10점) - - 문체와 톤의 일관성 (10점) +2. **캐릭터 일관성 (30점)** + - 정보 일관성 (나이, 직업 등) (15점) + - 성격과 행동 일관성 (10점) + - 캐릭터 성장/변화 (5점) -3. **창의성과 독창성 (20점)** - - 주제 해석의 참신함 (10점) - - 표현과 구성의 독창성 (10점) +3. **문학적 완성도 (20점)** + - 문체와 가독성 (10점) + - 대화의 자연스러움 (5점) + - 장면 묘사 (5점) -4. **종합 평가 (10점)** - - 전체적인 작품성과 완성도 +4. **독창성 (10점)** + - 주제 해석 (5점) + - 플롯의 참신함 (5점) **총점: /100점** -각 항목에 대한 구체적인 평가와 함께, 작품의 강점과 약점, 그리고 독자에게 미칠 영향에 대해 서술하세요.""", - "English": f"""Create a final evaluation report for the completed novel. - -**Completed Novel (last 2000 characters):** -{complete_novel[-2000:]} - -**Report Items:** - -1. **Philosophical Depth Evaluation (40 points)** - - Level of exploring fundamental human problems (10 points) - - Depth of characters' internal conflicts and reflection (10 points) - - Theme consistency and development (10 points) - - Room for reader contemplation (10 points) - -2. **Technical Completion Evaluation (30 points)** - - Plot logic and plausibility (10 points) - - Character consistency (10 points) - - Style and tone consistency (10 points) - -3. **Creativity and Originality (20 points)** - - Freshness of theme interpretation (10 points) - - Originality of expression and composition (10 points) +각 항목별 구체적 평가와 총평을 작성하세요.""" -4. **Overall Evaluation (10 points)** - - Overall artistry and completion - -**Total Score: /100** - -Describe specific evaluations for each item, along with the work's strengths and weaknesses, and its impact on readers.""" - } - return lang_prompts[language] - - # --- LLM 호출 함수들 (스트리밍 완벽 유지) --- + # --- LLM 호출 함수들 --- def call_llm_sync(self, messages: List[Dict[str, str]], role: str, language: str) -> str: - """LLM 동기식 호출 (요약 등 내부용)""" full_content = "" for chunk in self.call_llm_streaming(messages, role, language): full_content += chunk @@ -835,7 +877,6 @@ Describe specific evaluations for each item, along with the work's strengths and return full_content def call_llm_streaming(self, messages: List[Dict[str, str]], role: str, language: str) -> Generator[str, None, None]: - """LLM 스트리밍 호출 (완벽한 스트리밍 유지)""" try: system_prompts = self.get_system_prompts(language) full_messages = [{"role": "system", "content": system_prompts.get(role, "You are a helpful assistant.")}, *messages] @@ -843,13 +884,12 @@ Describe specific evaluations for each item, along with the work's strengths and payload = { "model": self.model_id, "messages": full_messages, - "max_tokens": 10000, - "temperature": 0.75, + "max_tokens": 8000, + "temperature": 0.7, "top_p": 0.9, - "presence_penalty": 0.3, - "frequency_penalty": 0.2, - "stream": True, - "stream_options": {"include_usage": True} + "presence_penalty": 0.4, + "frequency_penalty": 0.3, + "stream": True } logger.info(f"[{role}] API 스트리밍 시작") @@ -864,16 +904,11 @@ Describe specific evaluations for each item, along with the work's strengths and if response.status_code != 200: logger.error(f"API 응답 오류: {response.status_code}") - logger.error(f"응답 내용: {response.text[:500]}") yield f"❌ API 오류 (상태 코드: {response.status_code})" return - response.raise_for_status() - - # 스트리밍 처리 - 실시간으로 UI에 반영 buffer = "" total_content = "" - chunk_count = 0 for line in response.iter_lines(): if not line: @@ -881,106 +916,69 @@ Describe specific evaluations for each item, along with the work's strengths and try: line_str = line.decode('utf-8').strip() - if not line_str.startswith("data: "): continue data_str = line_str[6:] - if data_str == "[DONE]": - logger.info(f"[{role}] 스트리밍 완료 - 총 {len(total_content)} 문자") break - try: - data = json.loads(data_str) - except json.JSONDecodeError: - logger.warning(f"JSON 파싱 실패: {data_str[:100]}") - continue - - choices = data.get("choices", None) - if not choices or not isinstance(choices, list) or len(choices) == 0: - if "error" in data: - error_msg = data.get("error", {}).get("message", "Unknown error") - logger.error(f"API 에러: {error_msg}") - yield f"❌ API 에러: {error_msg}" - return - continue - - delta = choices[0].get("delta", {}) - content = delta.get("content", "") - - if content: + 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 total_content += content - chunk_count += 1 - # 50자 또는 줄바꿈마다 즉시 yield (스트리밍 효과 극대화) if len(buffer) >= 50 or '\n' in buffer: yield buffer buffer = "" - time.sleep(0.01) # UI 업데이트를 위한 최소 대기 + time.sleep(0.01) except Exception as e: logger.error(f"청크 처리 오류: {str(e)}") continue - # 남은 버퍼 처리 if buffer: yield buffer - - if chunk_count == 0: - logger.error(f"[{role}] 콘텐츠가 전혀 수신되지 않음") - yield "❌ API로부터 응답을 받지 못했습니다." - else: - logger.info(f"[{role}] 성공적으로 {chunk_count}개 청크, 총 {len(total_content)}자 수신") - except requests.exceptions.Timeout: - logger.error("API 요청 시간 초과") - yield "❌ API 요청 시간이 초과되었습니다." - except requests.exceptions.ConnectionError: - logger.error("API 연결 실패") - yield "❌ API 서버에 연결할 수 없습니다." except Exception as e: - logger.error(f"예기치 않은 오류: {type(e).__name__}: {str(e)}", exc_info=True) + 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": "당신은 인간의 내면을 섬세하게 그려내는 전문 소설 작가입니다. 외적 사건과 내적 성찰의 균형을 맞추며, 독자에게 깊은 여운을 남기는 문장을 쓰세요." + "director": "당신은 완전한 서사 구조를 가진 소설을 기획하는 전문가입니다. 기승전결이 명확하고 사건이 계속 일어나는 역동적인 이야기를 만드세요.", + "critic": "당신은 서사 구조와 일관성을 검토하는 전문가입니다. 플롯이 정체되거나 캐릭터 정보가 inconsistent한 경우 즉시 지적하세요.", + "writer_base": "당신은 행동과 대화로 이야기를 전개하는 소설가입니다. 'Show, don't tell' 원칙을 따르며, 구체적인 사건을 만들어 플롯을 전진시키세요." }, "English": { - "director": "You are a literary director planning philosophical novels that explore human nature. Create profound and meaningful works.", - "critic": "You are a literary critic who sharply evaluates philosophical depth and technical completion. Strictly review whether the work properly explores fundamental human problems.", - "writer_base": "You are a professional novelist who delicately portrays human inner life. Balance external events with internal reflection, and write sentences that leave deep resonance with readers." + "director": "You are an expert in planning novels with complete narrative structures. Create dynamic stories with clear plot progression and continuous events.", + "critic": "You are an expert in reviewing narrative structure and consistency. Immediately point out stagnant plots or inconsistent character information.", + "writer_base": "You are a novelist who develops stories through action and dialogue. Follow 'Show, don't tell' principle and advance the plot with concrete events." } } - prompts = base_prompts[language].copy() + prompts = base_prompts.get(language, base_prompts["Korean"]).copy() - # 작가별 특수 프롬프트 + # 특수 작가 프롬프트 if language == "Korean": - prompts["writer1"] = "당신은 독자를 철학적 여정으로 이끄는 도입부를 쓰는 작가입니다. 첫 문장부터 독자의 사고를 자극하��요." - prompts["writer5"] = "당신은 갈등이 최고조에 달하는 중반부를 담당하는 작가입니다. 인물들의 가치관이 충돌하는 순간을 깊이 있게 그려내세요." - prompts["writer10"] = "당신은 여운 깊은 결말을 만드는 작가입니다. 모든 갈등을 해결하지 말고, 독자에게 생각할 거리를 남기세요." - else: - prompts["writer1"] = "You are a writer who creates openings that lead readers on philosophical journeys. Stimulate readers' thinking from the first sentence." - prompts["writer5"] = "You are a writer handling the middle section where conflict reaches its peak. Deeply portray moments when characters' values clash." - prompts["writer10"] = "You are a writer who creates resonant endings. Don't resolve all conflicts; leave readers with something to think about." + prompts["writer1"] = prompts["writer_base"] + " 도입부에서 독자의 관심을 끄는 강렬한 사건으로 시작하세요." + prompts["writer5"] = prompts["writer_base"] + " 갈등을 최고조로 끌어올리는 극적인 사건을 만드세요." + prompts["writer7"] = prompts["writer_base"] + " 클라이맥스를 극대화하는 결정적 대결이나 선택을 그리세요." + prompts["writer10"] = prompts["writer_base"] + " 모든 갈등을 해결하고 여운을 남기는 결말을 만드세요." - # writer2-4, 6-9는 기본 프롬프트 사용 for i in range(2, 10): - if i not in [1, 5, 10]: + if f"writer{i}" not in prompts: 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: @@ -990,7 +988,9 @@ Describe specific evaluations for each item, along with the work's strengths and query = session['user_query'] language = session['language'] resume_from_stage = session['current_stage'] + 1 - logger.info(f"Resuming session {session_id} from stage {resume_from_stage}") + # 캐릭터 정보 복원 + self.consistency_tracker.characters = NovelDatabase.load_characters(session_id) + logger.info(f"Loaded {len(self.consistency_tracker.characters)} characters") else: self.current_session_id = NovelDatabase.create_session(query, language) logger.info(f"Created new session: {self.current_session_id}") @@ -1002,7 +1002,7 @@ Describe specific evaluations for each item, along with the work's strengths and "status": s['status'], "content": s.get('content', ''), "consistency_score": s.get('consistency_score', 0.0), - "philosophical_depth_score": s.get('philosophical_depth_score', 0.0) + "plot_progression_score": s.get('plot_progression_score', 0.0) } for s in NovelDatabase.get_stages(self.current_session_id)] for stage_idx in range(resume_from_stage, len(OPTIMIZED_STAGES)): @@ -1013,7 +1013,7 @@ Describe specific evaluations for each item, along with the work's strengths and "status": "active", "content": "", "consistency_score": 0.0, - "philosophical_depth_score": 0.0 + "plot_progression_score": 0.0 }) else: stages[stage_idx]["status"] = "active" @@ -1023,53 +1023,91 @@ Describe specific evaluations for each item, along with the work's strengths and 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 - yield "", stages, self.current_session_id # 실시간 UI 업데이트 + yield "", stages, self.current_session_id - # 일관성 및 철학적 깊이 점수 계산 - consistency_score = 0.0 - philosophical_depth_score = 0.0 + # 점수 계산 및 일관성 체크 + consistency_score = 10.0 + plot_progression_score = 10.0 if role.startswith("writer"): + # 일관성 검사 writer_num = int(re.search(r'\d+', role).group()) - all_previous = self.get_all_content(stages, stage_idx) - errors = self.consistency_tracker.validate_consistency(writer_num, stage_content) - consistency_score = max(0, 10 - len(errors) * 2) - # 철학적 깊이 간단 평가 (내적 성찰 관련 키워드 빈도) - philosophical_keywords = ['생각', '의미', '존재', '삶', '죽음', '자유', '선택', '고민', '성찰', '깨달', - 'think', 'mean', 'exist', 'life', 'death', 'free', 'choice', 'reflect', 'realize'] - keyword_count = sum(1 for keyword in philosophical_keywords if keyword in stage_content.lower()) - philosophical_depth_score = min(10, keyword_count * 0.5) + # 캐릭터 정보 추출 및 저장 (감독 단계에서) + if stage_idx == 2: # 감독자 최종 플랜 + self.extract_and_save_characters(stage_content) + + # 플롯 진행도 평가 + is_progressing, msg = self.consistency_tracker.check_plot_progression(writer_num) + if not is_progressing: + plot_progression_score = 5.0 + logger.warning(f"Plot not progressing: {msg}") stages[stage_idx]["consistency_score"] = consistency_score - stages[stage_idx]["philosophical_depth_score"] = philosophical_depth_score + stages[stage_idx]["plot_progression_score"] = plot_progression_score stages[stage_idx]["status"] = "complete" NovelDatabase.save_stage( self.current_session_id, stage_idx, stage_name, role, - stage_content, "complete", consistency_score, philosophical_depth_score + stage_content, "complete", consistency_score, plot_progression_score ) yield "", stages, self.current_session_id - # 최종 소설 및 보고서 생성 - final_novel = NovelDatabase.get_writer_content(self.current_session_id) + # 최종 소설 정리 + final_novel = NovelDatabase.get_writer_content(self.current_session_id, remove_metadata=True) final_report = self.generate_consistency_report(final_novel, language) - # 최종 철학적 깊이 점수 추출 - philosophical_score_match = re.search(r'철학적 깊이 평가.*?(\d+)점', final_report) - philosophical_depth_total = float(philosophical_score_match.group(1)) if philosophical_score_match else 0.0 + # 서사 점수 추출 + narrative_score_match = re.search(r'서사 구조.*?(\d+)점', final_report) + narrative_score = float(narrative_score_match.group(1)) if narrative_score_match else 0.0 - NovelDatabase.update_final_novel(self.current_session_id, final_novel, final_report, philosophical_depth_total) - yield f"✅ 소설 완성! 총 {len(final_novel.split())}단어, 철학적 깊이 점수: {philosophical_depth_total}/40", stages, self.current_session_id + NovelDatabase.update_final_novel(self.current_session_id, final_novel, final_report, narrative_score) + yield f"✅ 소설 완성! 총 {len(final_novel.split())}단어, 서사 구조 점수: {narrative_score}/40", 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 extract_and_save_characters(self, director_plan: str): + """감독자 플랜에서 캐릭터 정보 추출 및 저장""" + # 캐릭터 테이블 찾기 + character_pattern = r'\|([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|([^|]+)\|' + matches = re.findall(character_pattern, director_plan) + + for match in matches: + if '이름' in match[0] or 'Name' in match[0]: # 헤더 스킵 + continue + + try: + name = match[0].strip() + age_str = match[1].strip() + age = int(re.search(r'\d+', age_str).group()) if re.search(r'\d+', age_str) else 30 + occupation = match[2].strip() + role = match[3].strip() + traits = match[4].strip().split(',') + goals = match[5].strip().split(',') + speech_pattern = match[6].strip() + + character = CharacterState( + name=name, + age=age, + occupation=occupation, + role=role, + key_traits=[t.strip() for t in traits], + goals=[g.strip() for g in goals], + speech_pattern=speech_pattern + ) + + if self.consistency_tracker.register_character(character): + NovelDatabase.save_character(self.current_session_id, character) + logger.info(f"Character saved: {name}, age {age}") + + except Exception as e: + logger.error(f"Failed to extract character: {e}") + def get_stage_prompt(self, stage_idx: int, role: str, query: str, language: str, stages: List[Dict]) -> str: """단계별 프롬프트 생성""" if stage_idx == 0: @@ -1085,11 +1123,18 @@ Describe specific evaluations for each item, along with the work's strengths and writer_num = stage_idx - 2 previous_content = self.get_all_content(stages, stage_idx) summary = self.create_summary(previous_content, language) - return self.create_writer_prompt(writer_num, master_plan, summary, query, language) + character_sheets = self.get_all_character_sheets() + recent_events = self.consistency_tracker.get_plot_summary(writer_num) + + return self.create_writer_prompt( + writer_num, master_plan, summary, query, language, + character_sheets, recent_events + ) if stage_idx == 13: # 비평가 중간 검토 all_content = self.get_all_content(stages, stage_idx) - return self.create_critic_consistency_prompt(all_content, query, language) + character_registry = self.get_all_character_sheets() + return self.create_critic_consistency_prompt(all_content, query, language, character_registry) if 14 <= stage_idx <= 23: # 작가 수정 writer_num = stage_idx - 13 @@ -1103,14 +1148,64 @@ Describe specific evaluations for each item, along with the work's strengths and return "" + def create_director_revision_prompt(self, initial_plan: str, critic_feedback: str, user_query: str, language: str) -> str: + """감독자 수정 프롬프트""" + return f"""감독자로서 비평가의 피드백을 반영하여 수정하세요. + +**원래 주제:** {user_query} + +**초기 기획:** +{initial_plan} + +**비평가 피드백:** +{critic_feedback} + +**수정 요구사항:** +1. 비평가가 지적한 모든 문제 해결 +2. 캐릭터 정보를 더욱 명확하게 (특히 나이) +3. 각 장의 구체적 사건 명시 +4. 서사가 정체되지 않도록 액션 강화 + +최종 마스터플랜을 제시하세요.""" + + def get_all_character_sheets(self) -> str: + """모든 캐릭터 정보 시트""" + if not self.consistency_tracker.characters: + return "캐릭터 정보가 아직 등록되지 않았습니다." + + sheets = [] + for name, char in self.consistency_tracker.characters.items(): + sheets.append(self.consistency_tracker.get_character_sheet(name)) + + return "\n".join(sheets) + + def get_all_content(self, stages: List[Dict], current_stage: int) -> str: + """현재까지의 모든 내용""" + contents = [] + for i, s in enumerate(stages): + if i < current_stage and s["content"] and s.get("role", "").startswith("writer"): + # 메타데이터 제거 + clean_content = remove_metadata_from_content(s["content"]) + contents.append(clean_content) + return "\n\n".join(contents) + + def get_all_writer_content(self, stages: List[Dict]) -> str: + """모든 작가 최종본""" + contents = [] + for i, s in enumerate(stages): + if 14 <= i <= 23 and s["content"]: + clean_content = remove_metadata_from_content(s["content"]) + contents.append(clean_content) + return "\n\n".join(contents) + def create_summary(self, content: str, language: str) -> str: - """LLM을 이용해 이전 내용을 요약""" + """내용 요약 (액션 중심)""" if not content.strip(): return "이전 내용이 없습니다." if language == "Korean" else "No previous content." - prompt_text = "다음 소설 내용을 3~5개의 핵심적인 문장으로 요약해줘. 다음 작가가 이야기를 이어가는 데 필요한 핵심 정보(등장인물의 현재 상황, 감정, 내적 갈등, 마지막 사건)를 포함해야 해." + prompt_text = "다음 소설에서 일어난 주요 사건들을 시간 순서대로 요약해주세요. 누가, 언제, 어디서, 무엇을, 왜 했는지 명확히 포함하세요. 내적 독백이나 생각은 제외하고 실제 일어난 행동과 대화만 포함하세요." if language != "Korean": - prompt_text = "Summarize the following novel content in 3-5 key sentences. Include crucial information for the next writer to continue the story (characters' current situation, emotions, internal conflicts, and the last major event)." + prompt_text = "Summarize the main events in chronological order. Include who, when, where, what, and why. Exclude internal monologues and focus only on actions and dialogues." summary_prompt = f"{prompt_text}\n\n---\n{content[-2000:]}" try: @@ -1120,16 +1215,8 @@ Describe specific evaluations for each item, along with the work's strengths and logger.error(f"요약 생성 실패: {e}") return content[-1000:] - def get_all_content(self, stages: List[Dict], current_stage: int) -> str: - """현재까지의 모든 내용 가져오기""" - return "\n\n".join(s["content"] for i, s in enumerate(stages) if i < current_stage and s["content"]) - - def get_all_writer_content(self, stages: List[Dict]) -> str: - """모든 작가 최종 수정본 내용 가져오기""" - return "\n\n".join(s["content"] for i, s in enumerate(stages) if 14 <= i <= 23 and s["content"]) - def generate_consistency_report(self, complete_novel: str, language: str) -> str: - """최종 보고서 생성 (LLM 호출)""" + """최종 보고서 생성""" prompt = self.create_critic_final_prompt(complete_novel, language) try: report = self.call_llm_sync([{"role": "user", "content": prompt}], "critic", language) @@ -1155,31 +1242,30 @@ def process_query(query: str, language: str, session_id: Optional[str] = None) - # 최종 소설 내용 가져오기 if stages and all(s.get("status") == "complete" for s in stages[-10:]): - novel_content = NovelDatabase.get_writer_content(current_session_id) + novel_content = NovelDatabase.get_writer_content(current_session_id, remove_metadata=True) 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() return [f"{s['session_id'][:8]}... - {s['user_query'][:50]}... ({s['created_at']})" for s in sessions] def auto_recover_session(language: str) -> Tuple[Optional[str], str]: - """가장 최근 활성 세션 자동 복구""" + """최근 세션 자동 복구""" latest_session = NovelDatabase.get_latest_active_session() if latest_session: 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 - # 드롭다운에서 세션 ID 추출 if "..." in session_id: session_id = session_id.split("...")[0] @@ -1188,7 +1274,6 @@ def resume_session(session_id: str, language: str) -> Generator[Tuple[str, str, yield "", "", "❌ 세션을 찾을 수 없습니다.", None return - # process_query를 통해 재개 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]: @@ -1209,67 +1294,90 @@ def download_novel(novel_text: str, format_type: str, language: str, session_id: return None def format_stages_display(stages: List[Dict]) -> str: - """단계별 진행 상황 마크다운 포맷팅""" + """단계별 진행 상황 표시 (메타데이터 없이)""" markdown = "## 🎬 진행 상황\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']}**" - # 점수 표시 scores = [] if stage.get('consistency_score', 0) > 0: scores.append(f"일관성: {stage['consistency_score']:.1f}/10") - if stage.get('philosophical_depth_score', 0) > 0: - scores.append(f"철학적 깊이: {stage['philosophical_depth_score']:.1f}/10") + if stage.get('plot_progression_score', 0) > 0: + scores.append(f"서사진행: {stage['plot_progression_score']:.1f}/10") if scores: markdown += f" ({', '.join(scores)})" markdown += "\n" if stage['content']: - preview = stage['content'][:200] + "..." if len(stage['content']) > 200 else stage['content'] + # 메타데이터 제거된 미리보기 + clean_content = remove_metadata_from_content(stage['content']) + preview = clean_content[:200] + "..." if len(clean_content) > 200 else clean_content markdown += f"> {preview}\n\n" return markdown def format_novel_display(novel_text: str) -> str: - """소설 내용 마크다운 포맷팅""" + """소설 내용 표시 (깔끔하게)""" if not novel_text: return "아직 완성된 내용이 없습니다." - # 페이지 구분 추가 - pages = novel_text.split('\n\n') + # 이미 메타데이터가 제거된 텍스트 formatted = "# 📖 완성된 소설\n\n" - - for i, page in enumerate(pages): - if page.strip(): - formatted += f"### 페이지 {i+1}\n\n{page}\n\n---\n\n" + formatted += novel_text return formatted def export_to_docx(content: str, filename: str, language: str, session_id: str) -> str: - """DOCX 파일로 내보내기""" + """DOCX 파일로 내보내기 (미국 소설책 형식)""" doc = Document() + # 미국 소설책 표준 설정 (5.5 x 8.5 inches) + section = doc.sections[0] + section.page_height = Inches(8.5) + section.page_width = Inches(5.5) + + # 여백 설정 (소설책 표준) + section.top_margin = Inches(0.75) + section.bottom_margin = Inches(0.75) + section.left_margin = Inches(0.75) + section.right_margin = Inches(0.75) + # 세션 정보 가져오기 session = NovelDatabase.get_session(session_id) - # 제목 추가 - title = doc.add_heading('AI 협업 소설', 0) - title.alignment = WD_ALIGN_PARAGRAPH.CENTER + # 제목 페이지 + title_para = doc.add_paragraph() + title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + title_run = title_para.add_run('AI 협업 소설\n\n') + title_run.font.size = Pt(24) + title_run.font.bold = True - # 메타데이터 - doc.add_paragraph(f"생성일: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") - doc.add_paragraph(f"언어: {language}") if session: - doc.add_paragraph(f"주제: {session['user_query']}") - if session.get('philosophical_depth_score'): - doc.add_paragraph(f"철학적 깊이 점수: {session['philosophical_depth_score']}/40") + theme_para = doc.add_paragraph() + theme_para.alignment = WD_ALIGN_PARAGRAPH.CENTER + theme_run = theme_para.add_run(f'"{session["user_query"]}"') + theme_run.font.size = Pt(16) + theme_run.font.italic = True + + # 페이지 나누기 doc.add_page_break() + # 본문 스타일 설정 + style = doc.styles['Normal'] + style.font.name = 'Georgia' # 소설에 적합한 세리프 폰트 + style.font.size = Pt(11) + style.paragraph_format.line_spacing = 1.5 + style.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY + style.paragraph_format.first_line_indent = Inches(0.3) # 첫 줄 들여쓰기 + # 본문 추가 paragraphs = content.split('\n\n') - for para in paragraphs: - if para.strip(): - doc.add_paragraph(para.strip()) + for para_text in paragraphs: + if para_text.strip(): + para = doc.add_paragraph(para_text.strip()) + # 대화문인 경우 들여쓰기 제거 + if para_text.strip().startswith('"') or para_text.strip().startswith('"'): + para.paragraph_format.first_line_indent = 0 # 파일 저장 filepath = f"{filename}.docx" @@ -1284,10 +1392,10 @@ def export_to_txt(content: str, filename: str) -> str: return filepath -# CSS 스타일 (철학적 테마에 맞게 색상 조정) +# CSS 스타일 (서사 중심 테마) custom_css = """ .gradio-container { - background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); + background: linear-gradient(135deg, #2c3e50 0%, #34495e 50%, #2c3e50 100%); min-height: 100vh; } @@ -1302,13 +1410,13 @@ custom_css = """ border: 1px solid rgba(255, 255, 255, 0.1); } -.philosophy-note { - background-color: rgba(255, 215, 0, 0.1); - border-left: 4px solid #ffd700; +.narrative-note { + background-color: rgba(241, 196, 15, 0.1); + border-left: 4px solid #f1c40f; padding: 15px; margin: 20px 0; border-radius: 8px; - color: #ffd700; + color: #f1c40f; font-style: italic; } @@ -1359,45 +1467,69 @@ custom_css = """ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } -/* 진행 상황 표시 개선 */ +/* 진행 상황 스타일 개선 */ #stages-display h2 { - color: #1a1a2e; - border-bottom: 2px solid #1a1a2e; + color: #2c3e50; + border-bottom: 2px solid #3498db; padding-bottom: 10px; } #stages-display blockquote { - border-left: 3px solid #667eea; + border-left: 3px solid #3498db; padding-left: 15px; - color: #666; + color: #555; font-style: italic; + background-color: rgba(52, 152, 219, 0.05); + margin: 10px 0; + padding: 10px 15px; + border-radius: 4px; } /* 점수 표시 스타일 */ #stages-display strong { - color: #1a1a2e; + color: #2c3e50; +} + +/* 완성된 소설 스타일 */ +#novel-output h1 { + color: #2c3e50; + border-bottom: 3px solid #3498db; + padding-bottom: 15px; + margin-bottom: 30px; +} + +#novel-output p { + text-indent: 2em; + margin-bottom: 1em; +} + +/* 대화문 스타일 */ +#novel-output p:has(> span:first-child:contains('"')), +#novel-output p:has(> span:first-child:contains('"')) { + text-indent: 0; + margin-left: 2em; } """ # Gradio 인터페이스 생성 def create_interface(): - with gr.Blocks(css=custom_css, title="AI 협업 소설 생성 시스템 - 철학적 깊이 버전") as interface: + with gr.Blocks(css=custom_css, title="AI 협업 소설 생성 시스템 - 서사 강화 버전") as interface: gr.HTML("""

📚 AI 협업 소설 생성 시스템

- 철학적 깊이를 담은 창의적 소설 생성 + 완전한 서사 구조를 갖춘 소설 생성

- 주제를 입력하면 AI 에이전트들이 협업하여 30페이지 분량의 철학적 소설을 생성합니다. + 주제를 입력하면 AI 에이전트들이 협업하여 기승전결이 명확한 30페이지 소설을 생성합니다.
- 감독자 1명, 비평가 1명, 작가 10명이 함께 인간의 근원적 문제를 탐구합니다. + 감독자 1명, 비평가 1명, 작가 10명이 함께 역동적인 이야기를 만듭니다.

-
- "모든 위대한 문학은 인간 존재의 본질적 질문에서 시작됩니다." +
+ "훌륭한 이야기는 캐릭터가 행동하고, 선택하고, 변화하는 과정입니다."
""") @@ -1410,7 +1542,7 @@ def create_interface(): with gr.Group(elem_classes=["input-section"]): query_input = gr.Textbox( label="소설 주제 / Novel Theme", - placeholder="인간의 삶, 관계, 갈등, 꿈, 또는 어떤 주제든 입력하세요...\nEnter any theme about human life, relationships, conflicts, dreams...", + placeholder="흥미진진한 이야기의 주제를 입력하세요...\nEnter an exciting story theme...", lines=4 ) @@ -1474,21 +1606,21 @@ def create_interface(): # 숨겨진 상태 novel_text_state = gr.State("") - # 예제 (철학적 주제로 변경) + # 예제 (서사 중심) with gr.Row(): gr.Examples( examples=[ - ["자유의지는 환상인가, 아니면 인간 존재의 본질인가"], - ["타인의 고통을 완전히 이해할 수 있는가"], - ["기억이 우리를 만드는가, 우리가 기억을 만드는가"], - ["The meaning of love in a world without death"], - ["Can consciousness exist without suffering"], - ["진정한 자아는 변하는가, 불변하는가"], - ["언어의 한계가 곧 세계의 한계인가"], - ["What defines humanity when machines can feel"] + ["형사가 연쇄 사기범을 추적하며 벌어지는 추격전"], + ["우연히 발견한 일기장이 숨겨진 보물로 이끄는 모험"], + ["작은 카페에서 시작된 살인사건의 미스터리"], + ["A detective's pursuit of a serial art forger"], + ["Two rival chefs compete for a Michelin star"], + ["도시를 위협하는 해커와 보안 전문가의 대결"], + ["폐쇄된 섬에서 일어나는 연속 실종 사건"], + ["An astronaut discovers something unexpected on Mars"] ], inputs=query_input, - label="💡 철학적 주제 예시" + label="💡 서사적 주제 예시" ) # 이벤트 핸들러 @@ -1574,7 +1706,7 @@ def create_interface(): # 메인 실행 if __name__ == "__main__": - logger.info("AI 협업 소설 생성 시스템 시작 (철학적 깊이 강화 버전)...") + logger.info("AI 협업 소설 생성 시스템 시작 (서사 강화 버전)...") logger.info("=" * 60) # 환경 확인