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)
# 환경 확인