Spaces:
Running
Running
Update app-backup.py
Browse files- app-backup.py +567 -1164
app-backup.py
CHANGED
@@ -16,11 +16,11 @@ from contextlib import contextmanager
|
|
16 |
from dataclasses import dataclass, field
|
17 |
from collections import defaultdict
|
18 |
|
19 |
-
# 로깅 설정
|
20 |
-
logging.basicConfig(level=logging.INFO)
|
21 |
logger = logging.getLogger(__name__)
|
22 |
|
23 |
-
# Document export imports
|
24 |
try:
|
25 |
from docx import Document
|
26 |
from docx.shared import Inches, Pt, RGBColor
|
@@ -31,46 +31,44 @@ except ImportError:
|
|
31 |
DOCX_AVAILABLE = False
|
32 |
logger.warning("python-docx not installed. DOCX export will be disabled.")
|
33 |
|
34 |
-
# 환경
|
35 |
FRIENDLI_TOKEN = os.getenv("FRIENDLI_TOKEN", "")
|
36 |
BRAVE_SEARCH_API_KEY = os.getenv("BRAVE_SEARCH_API_KEY", "")
|
37 |
API_URL = "https://api.friendli.ai/dedicated/v1/chat/completions"
|
38 |
MODEL_ID = "dep89a2fld32mcm"
|
|
|
39 |
|
40 |
-
# 환경 변수 검증
|
41 |
if not FRIENDLI_TOKEN:
|
42 |
logger.error("FRIENDLI_TOKEN not set. Application will not work properly.")
|
43 |
-
|
|
|
44 |
|
45 |
if not BRAVE_SEARCH_API_KEY:
|
46 |
logger.warning("BRAVE_SEARCH_API_KEY not set. Web search features will be disabled.")
|
47 |
|
48 |
-
# 전역 변수
|
49 |
-
conversation_history = []
|
50 |
-
selected_language = "English"
|
51 |
-
|
52 |
-
# DB 경로
|
53 |
-
DB_PATH = "novel_sessions.db"
|
54 |
db_lock = threading.Lock()
|
55 |
|
56 |
-
# 최적화된 단계 구성 (
|
57 |
OPTIMIZED_STAGES = [
|
58 |
-
("director", "🎬 감독자: 초기 기획"),
|
59 |
-
("critic", "📝 비평가: 기획 검토"),
|
60 |
("director", "🎬 감독자: 수정된 마스터플랜"),
|
61 |
] + [
|
62 |
(f"writer{i}", f"✍️ 작가 {i}: 초안 (페이지 {(i-1)*3+1}-{i*3})")
|
63 |
for i in range(1, 11)
|
64 |
] + [
|
65 |
-
("critic",
|
66 |
] + [
|
67 |
(f"writer{i}", f"✍️ 작가 {i}: 수정본 (페이지 {(i-1)*3+1}-{i*3})")
|
68 |
for i in range(1, 11)
|
69 |
] + [
|
70 |
-
("critic", f"📝 비평가: 최종 검토"),
|
71 |
]
|
72 |
|
73 |
|
|
|
74 |
@dataclass
|
75 |
class CharacterState:
|
76 |
"""캐릭터의 현재 상태를 나타내는 데이터 클래스"""
|
@@ -84,7 +82,6 @@ class CharacterState:
|
|
84 |
description: str = ""
|
85 |
role: str = ""
|
86 |
|
87 |
-
|
88 |
@dataclass
|
89 |
class PlotPoint:
|
90 |
"""플롯 포인트를 나타내는 데이터 클래스"""
|
@@ -95,7 +92,6 @@ class PlotPoint:
|
|
95 |
impact_level: int
|
96 |
timestamp: str = ""
|
97 |
|
98 |
-
|
99 |
@dataclass
|
100 |
class TimelineEvent:
|
101 |
"""시간선 이벤트를 나타내는 데이터 클래스"""
|
@@ -106,274 +102,153 @@ class TimelineEvent:
|
|
106 |
relative_time: str = ""
|
107 |
|
108 |
|
|
|
109 |
class ConsistencyTracker:
|
110 |
"""일관성 추적 시스템"""
|
111 |
-
|
112 |
def __init__(self):
|
113 |
self.character_states: Dict[str, CharacterState] = {}
|
114 |
self.plot_points: List[PlotPoint] = []
|
115 |
self.timeline_events: List[TimelineEvent] = []
|
116 |
self.locations: Dict[str, str] = {}
|
117 |
self.established_facts: List[str] = []
|
118 |
-
self.content_hashes:
|
119 |
-
|
120 |
def register_character(self, character: CharacterState):
|
121 |
"""새 캐릭터 등록"""
|
122 |
self.character_states[character.name] = character
|
123 |
logger.info(f"Character registered: {character.name}")
|
124 |
-
|
125 |
def update_character_state(self, name: str, chapter: int, updates: Dict[str, Any]):
|
126 |
"""캐릭터 상태 업데이트"""
|
127 |
if name not in self.character_states:
|
128 |
self.register_character(CharacterState(name=name, last_seen_chapter=chapter))
|
129 |
-
|
130 |
char = self.character_states[name]
|
131 |
for key, value in updates.items():
|
132 |
if hasattr(char, key):
|
133 |
setattr(char, key, value)
|
134 |
-
|
135 |
char.last_seen_chapter = chapter
|
136 |
-
|
137 |
def add_plot_point(self, plot_point: PlotPoint):
|
138 |
"""플롯 포인트 추가"""
|
139 |
plot_point.timestamp = datetime.now().isoformat()
|
140 |
self.plot_points.append(plot_point)
|
141 |
-
|
142 |
-
def
|
143 |
-
"""
|
144 |
-
self.timeline_events.append(event)
|
145 |
-
|
146 |
-
def check_repetition(self, content: str) -> Tuple[bool, str]:
|
147 |
-
"""반복 내용 검사"""
|
148 |
-
# 내용 해시 생성
|
149 |
-
content_hash = hashlib.md5(content.encode()).hexdigest()
|
150 |
-
|
151 |
-
if content_hash in self.content_hashes:
|
152 |
-
return True, "완전 동일한 내용 반복"
|
153 |
-
|
154 |
-
# 문장 수준 반복 검사
|
155 |
sentences = re.split(r'[.!?]+', content)
|
156 |
for sentence in sentences:
|
157 |
-
|
158 |
-
|
|
|
159 |
if sentence_hash in self.content_hashes:
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
|
|
164 |
for sentence in sentences:
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
return False, ""
|
170 |
-
|
171 |
def validate_consistency(self, chapter: int, content: str) -> List[str]:
|
172 |
"""일관성 검증"""
|
173 |
errors = []
|
174 |
-
|
175 |
-
# 캐릭터 일관성 검사
|
176 |
for char_name, char_state in self.character_states.items():
|
177 |
-
if char_name.lower() in content.lower():
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
# 부상 상태 연속성 검사
|
183 |
-
if char_state.injuries and "완전히 회복" not in content:
|
184 |
-
recent_injuries = [inj for inj in char_state.injuries if "중상" in inj or "심각" in inj]
|
185 |
-
if recent_injuries:
|
186 |
-
errors.append(f"⚠️ '{char_name}'의 부상 상태가 언급되지 않음")
|
187 |
-
|
188 |
-
# 반복 검사
|
189 |
-
is_repetition, repeat_msg = self.check_repetition(content)
|
190 |
if is_repetition:
|
191 |
errors.append(f"🔄 {repeat_msg}")
|
192 |
-
|
193 |
-
# 시간선 일관성 검사
|
194 |
-
time_references = re.findall(r'(어제|오늘|내일|지금|방금|곧|나중에|이전에|다음에)', content)
|
195 |
-
if time_references:
|
196 |
-
# 최근 시간 참조와 비교
|
197 |
-
recent_events = [e for e in self.timeline_events if e.chapter >= chapter - 1]
|
198 |
-
if recent_events and time_references:
|
199 |
-
# 간단한 시간 일관성 검사
|
200 |
-
pass # 복잡한 로직은 생략
|
201 |
-
|
202 |
return errors
|
203 |
-
|
204 |
def get_character_summary(self, chapter: int) -> str:
|
205 |
"""현재 챕터 기준 캐릭터 요약"""
|
206 |
-
summary = "\n=== 캐릭터 현황 ===\n"
|
207 |
-
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
for char in active_chars:
|
212 |
status = "생존" if char.alive else "사망"
|
213 |
summary += f"• {char.name}: {status}"
|
214 |
-
if char.alive:
|
215 |
-
|
216 |
-
summary += f" (위치: {char.location})"
|
217 |
-
if char.injuries:
|
218 |
-
summary += f" (부상: {', '.join(char.injuries[-1:])})"
|
219 |
summary += "\n"
|
220 |
-
|
221 |
return summary
|
222 |
-
|
223 |
def get_plot_summary(self, chapter: int) -> str:
|
224 |
"""플롯 요약"""
|
225 |
-
summary = "\n=== 주요 사건 ===\n"
|
226 |
-
|
227 |
recent_events = [p for p in self.plot_points if p.chapter >= chapter - 2]
|
228 |
-
|
229 |
-
|
230 |
-
|
|
|
231 |
return summary
|
232 |
|
233 |
|
234 |
-
class ConsistencyValidator:
|
235 |
-
"""일관성 검증 시스템"""
|
236 |
-
|
237 |
-
def __init__(self, consistency_tracker: ConsistencyTracker):
|
238 |
-
self.tracker = consistency_tracker
|
239 |
-
|
240 |
-
def validate_writer_content(self, writer_num: int, content: str,
|
241 |
-
all_previous_content: str) -> List[str]:
|
242 |
-
"""작가 내용 일관성 검증"""
|
243 |
-
errors = []
|
244 |
-
|
245 |
-
# 기본 일관성 검증
|
246 |
-
basic_errors = self.tracker.validate_consistency(writer_num, content)
|
247 |
-
errors.extend(basic_errors)
|
248 |
-
|
249 |
-
# 캐릭터 추출 및 상태 업데이트
|
250 |
-
characters = self.extract_characters(content)
|
251 |
-
for char_name in characters:
|
252 |
-
self.tracker.update_character_state(char_name, writer_num, {})
|
253 |
-
|
254 |
-
# 새로운 사실 추출
|
255 |
-
new_facts = self.extract_facts(content)
|
256 |
-
for fact in new_facts:
|
257 |
-
if fact not in self.tracker.established_facts:
|
258 |
-
self.tracker.established_facts.append(fact)
|
259 |
-
|
260 |
-
return errors
|
261 |
-
|
262 |
-
def extract_characters(self, content: str) -> List[str]:
|
263 |
-
"""캐릭터 이름 추출"""
|
264 |
-
# 간단한 캐릭터 추출 로직
|
265 |
-
potential_names = re.findall(r'\b[A-Z][a-z]+\b', content)
|
266 |
-
# 일반적인 단어 제외
|
267 |
-
common_words = {'The', 'This', 'That', 'They', 'Then', 'There', 'When', 'Where', 'What', 'Who', 'How', 'Why'}
|
268 |
-
characters = [name for name in potential_names if name not in common_words]
|
269 |
-
return list(set(characters))
|
270 |
-
|
271 |
-
def extract_facts(self, content: str) -> List[str]:
|
272 |
-
"""확립된 사실 추출"""
|
273 |
-
# 간단한 사실 추출 로직
|
274 |
-
sentences = re.split(r'[.!?]+', content)
|
275 |
-
facts = []
|
276 |
-
|
277 |
-
for sentence in sentences:
|
278 |
-
sentence = sentence.strip()
|
279 |
-
if len(sentence) > 10:
|
280 |
-
# 확정적인 표현이 있는 문장들
|
281 |
-
definitive_patterns = [
|
282 |
-
r'.*is.*',
|
283 |
-
r'.*was.*',
|
284 |
-
r'.*has.*',
|
285 |
-
r'.*had.*',
|
286 |
-
r'.*died.*',
|
287 |
-
r'.*killed.*',
|
288 |
-
r'.*destroyed.*',
|
289 |
-
r'.*created.*'
|
290 |
-
]
|
291 |
-
|
292 |
-
for pattern in definitive_patterns:
|
293 |
-
if re.match(pattern, sentence, re.IGNORECASE):
|
294 |
-
facts.append(sentence)
|
295 |
-
break
|
296 |
-
|
297 |
-
return facts
|
298 |
-
|
299 |
-
|
300 |
class WebSearchIntegration:
|
301 |
"""웹 검색 기능 (감독자 단계에서만 사용)"""
|
302 |
-
|
303 |
def __init__(self):
|
304 |
self.brave_api_key = BRAVE_SEARCH_API_KEY
|
305 |
self.search_url = "https://api.search.brave.com/res/v1/web/search"
|
306 |
self.enabled = bool(self.brave_api_key)
|
307 |
-
|
308 |
def search(self, query: str, count: int = 3, language: str = "en") -> List[Dict]:
|
309 |
"""웹 검색 수행"""
|
310 |
if not self.enabled:
|
311 |
return []
|
312 |
-
|
313 |
headers = {
|
314 |
"Accept": "application/json",
|
315 |
"X-Subscription-Token": self.brave_api_key
|
316 |
}
|
317 |
-
|
318 |
-
search_lang = "ko" if language == "Korean" else "en"
|
319 |
-
|
320 |
params = {
|
321 |
"q": query,
|
322 |
"count": count,
|
323 |
-
"search_lang":
|
324 |
"text_decorations": False,
|
325 |
"safesearch": "moderate"
|
326 |
}
|
327 |
-
|
328 |
try:
|
329 |
response = requests.get(self.search_url, headers=headers, params=params, timeout=10)
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
logger.error(f"Search API error: {response.status_code}")
|
337 |
-
return []
|
338 |
-
except Exception as e:
|
339 |
-
logger.error(f"Search error: {str(e)}")
|
340 |
return []
|
341 |
-
|
342 |
-
def extract_relevant_info(self, results: List[Dict], max_chars: int =
|
343 |
"""검색 결과에서 관련 정보 추출"""
|
344 |
if not results:
|
345 |
return ""
|
346 |
-
|
347 |
extracted = []
|
348 |
total_chars = 0
|
349 |
-
|
350 |
for i, result in enumerate(results[:3], 1):
|
351 |
title = result.get("title", "")
|
352 |
description = result.get("description", "")
|
353 |
url = result.get("url", "")
|
354 |
-
|
355 |
-
info = f"[{i}] {title}\n{description}\n출처: {url}\n"
|
356 |
-
|
357 |
if total_chars + len(info) < max_chars:
|
358 |
extracted.append(info)
|
359 |
total_chars += len(info)
|
360 |
else:
|
361 |
break
|
362 |
-
|
363 |
return "\n---\n".join(extracted)
|
364 |
|
365 |
|
366 |
class NovelDatabase:
|
367 |
"""소설 세션 관리 데이터베이스"""
|
368 |
-
|
369 |
@staticmethod
|
370 |
def init_db():
|
371 |
-
"""데이터베이스 초기화"""
|
372 |
with sqlite3.connect(DB_PATH) as conn:
|
373 |
conn.execute("PRAGMA journal_mode=WAL")
|
374 |
cursor = conn.cursor()
|
375 |
-
|
376 |
-
# 세션 테이블
|
377 |
cursor.execute('''
|
378 |
CREATE TABLE IF NOT EXISTS sessions (
|
379 |
session_id TEXT PRIMARY KEY,
|
@@ -387,8 +262,6 @@ class NovelDatabase:
|
|
387 |
consistency_report TEXT
|
388 |
)
|
389 |
''')
|
390 |
-
|
391 |
-
# 단계 테이블
|
392 |
cursor.execute('''
|
393 |
CREATE TABLE IF NOT EXISTS stages (
|
394 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
@@ -406,8 +279,6 @@ class NovelDatabase:
|
|
406 |
UNIQUE(session_id, stage_number)
|
407 |
)
|
408 |
''')
|
409 |
-
|
410 |
-
# 캐릭터 상태 테이블
|
411 |
cursor.execute('''
|
412 |
CREATE TABLE IF NOT EXISTS character_states (
|
413 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
@@ -423,19 +294,15 @@ class NovelDatabase:
|
|
423 |
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
424 |
)
|
425 |
''')
|
426 |
-
|
427 |
-
# 인덱스 생성
|
428 |
cursor.execute('CREATE INDEX IF NOT EXISTS idx_session_id ON stages(session_id)')
|
429 |
cursor.execute('CREATE INDEX IF NOT EXISTS idx_stage_number ON stages(stage_number)')
|
430 |
cursor.execute('CREATE INDEX IF NOT EXISTS idx_char_session ON character_states(session_id)')
|
431 |
cursor.execute('CREATE INDEX IF NOT EXISTS idx_session_status ON sessions(status)')
|
432 |
-
|
433 |
conn.commit()
|
434 |
-
|
435 |
@staticmethod
|
436 |
@contextmanager
|
437 |
def get_db():
|
438 |
-
"""데이터베이스 연결 컨텍스트 매니저"""
|
439 |
with db_lock:
|
440 |
conn = sqlite3.connect(DB_PATH, timeout=30.0)
|
441 |
conn.row_factory = sqlite3.Row
|
@@ -443,773 +310,422 @@ class NovelDatabase:
|
|
443 |
yield conn
|
444 |
finally:
|
445 |
conn.close()
|
446 |
-
|
447 |
@staticmethod
|
448 |
def create_session(user_query: str, language: str) -> str:
|
449 |
-
"""새 세션 생성"""
|
450 |
session_id = hashlib.md5(f"{user_query}{datetime.now()}".encode()).hexdigest()
|
451 |
-
|
452 |
with NovelDatabase.get_db() as conn:
|
453 |
-
|
454 |
-
|
455 |
-
|
456 |
-
|
457 |
-
''', (session_id, user_query, language))
|
458 |
conn.commit()
|
459 |
-
|
460 |
return session_id
|
461 |
-
|
462 |
@staticmethod
|
463 |
-
def save_stage(session_id: str, stage_number: int, stage_name: str,
|
464 |
-
role: str, content: str, status: str = 'complete',
|
465 |
consistency_score: float = 0.0):
|
466 |
-
"""단계 저장"""
|
467 |
word_count = len(content.split()) if content else 0
|
468 |
-
|
469 |
with NovelDatabase.get_db() as conn:
|
470 |
cursor = conn.cursor()
|
471 |
-
|
472 |
cursor.execute('''
|
473 |
INSERT INTO stages (session_id, stage_number, stage_name, role, content, word_count, status, consistency_score)
|
474 |
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
475 |
-
ON CONFLICT(session_id, stage_number)
|
476 |
DO UPDATE SET content=?, word_count=?, status=?, stage_name=?, consistency_score=?, updated_at=datetime('now')
|
477 |
''', (session_id, stage_number, stage_name, role, content, word_count, status, consistency_score,
|
478 |
content, word_count, status, stage_name, consistency_score))
|
479 |
-
|
480 |
-
|
481 |
-
|
482 |
-
|
483 |
-
WHERE session_id = ?
|
484 |
-
''', (stage_number, session_id))
|
485 |
-
|
486 |
conn.commit()
|
487 |
-
|
488 |
@staticmethod
|
489 |
def get_session(session_id: str) -> Optional[Dict]:
|
490 |
-
"""세션 정보 가져오기"""
|
491 |
with NovelDatabase.get_db() as conn:
|
492 |
-
|
493 |
-
cursor.execute('SELECT * FROM sessions WHERE session_id = ?', (session_id,))
|
494 |
-
row = cursor.fetchone()
|
495 |
return dict(row) if row else None
|
496 |
-
|
497 |
@staticmethod
|
498 |
def get_latest_active_session() -> Optional[Dict]:
|
499 |
-
"""최근 활성 세션 가져오기"""
|
500 |
with NovelDatabase.get_db() as conn:
|
501 |
-
|
502 |
-
cursor.execute('''
|
503 |
-
SELECT * FROM sessions
|
504 |
-
WHERE status = 'active'
|
505 |
-
ORDER BY updated_at DESC
|
506 |
-
LIMIT 1
|
507 |
-
''')
|
508 |
-
row = cursor.fetchone()
|
509 |
return dict(row) if row else None
|
510 |
-
|
511 |
@staticmethod
|
512 |
def get_stages(session_id: str) -> List[Dict]:
|
513 |
-
"""세션의 모든 단계 가져오기"""
|
514 |
with NovelDatabase.get_db() as conn:
|
515 |
-
|
516 |
-
|
517 |
-
|
518 |
-
WHERE session_id = ?
|
519 |
-
ORDER BY stage_number
|
520 |
-
''', (session_id,))
|
521 |
-
return [dict(row) for row in cursor.fetchall()]
|
522 |
-
|
523 |
@staticmethod
|
524 |
def get_writer_content(session_id: str) -> str:
|
525 |
-
"""모든 작가의 최종 내용 가져오기"""
|
526 |
with NovelDatabase.get_db() as conn:
|
527 |
-
cursor = conn.cursor()
|
528 |
-
|
529 |
all_content = []
|
530 |
-
|
531 |
-
# 작가 1-10의 수정본 가져오기
|
532 |
for writer_num in range(1, 11):
|
533 |
-
cursor.execute(
|
534 |
-
SELECT content FROM stages
|
535 |
-
|
536 |
-
|
537 |
-
ORDER BY stage_number DESC
|
538 |
-
LIMIT 1
|
539 |
-
''', (session_id, f'writer{writer_num}'))
|
540 |
-
|
541 |
-
row = cursor.fetchone()
|
542 |
if row and row['content']:
|
543 |
-
|
544 |
-
if content:
|
545 |
-
all_content.append(content)
|
546 |
-
|
547 |
return '\n\n'.join(all_content)
|
548 |
-
|
549 |
@staticmethod
|
550 |
def update_final_novel(session_id: str, final_novel: str, consistency_report: str = ""):
|
551 |
-
"""최종 소설 업데이트"""
|
552 |
with NovelDatabase.get_db() as conn:
|
553 |
-
|
554 |
-
|
555 |
-
|
556 |
-
|
557 |
-
WHERE session_id = ?
|
558 |
-
''', (final_novel, consistency_report, session_id))
|
559 |
conn.commit()
|
560 |
-
|
561 |
@staticmethod
|
562 |
def get_active_sessions() -> List[Dict]:
|
563 |
-
"""활성 세션 목록 가져오기"""
|
564 |
with NovelDatabase.get_db() as conn:
|
565 |
-
|
566 |
-
|
567 |
-
|
568 |
-
|
569 |
-
WHERE status = 'active'
|
570 |
-
ORDER BY updated_at DESC
|
571 |
-
LIMIT 10
|
572 |
-
''')
|
573 |
-
return [dict(row) for row in cursor.fetchall()]
|
574 |
|
575 |
|
576 |
class NovelWritingSystem:
|
577 |
"""최적화된 소설 생성 시스템"""
|
578 |
-
|
579 |
def __init__(self):
|
580 |
self.token = FRIENDLI_TOKEN
|
581 |
self.api_url = API_URL
|
582 |
self.model_id = MODEL_ID
|
583 |
-
|
584 |
-
# 핵심 시스템 컴포넌트
|
585 |
self.consistency_tracker = ConsistencyTracker()
|
586 |
-
self.consistency_validator = ConsistencyValidator(self.consistency_tracker)
|
587 |
self.web_search = WebSearchIntegration()
|
588 |
-
|
589 |
-
# 세션 관리
|
590 |
self.current_session_id = None
|
591 |
-
|
592 |
-
# 데이터베이스 초기화
|
593 |
NovelDatabase.init_db()
|
594 |
-
|
595 |
def create_headers(self):
|
596 |
"""API 헤더 생성"""
|
597 |
-
return {
|
598 |
-
"Authorization": f"Bearer {self.token}",
|
599 |
-
"Content-Type": "application/json"
|
600 |
-
}
|
601 |
-
|
602 |
-
def create_director_initial_prompt(self, user_query: str, language: str = "English") -> str:
|
603 |
-
"""감독자 초기 프롬프트 (웹 검색 포함)"""
|
604 |
-
|
605 |
-
# 웹 검색 수행
|
606 |
-
search_results = ""
|
607 |
-
if self.web_search.enabled:
|
608 |
-
search_queries = []
|
609 |
-
if language == "Korean":
|
610 |
-
search_queries = [
|
611 |
-
f"{user_query} 소설 설정",
|
612 |
-
f"{user_query} 배경 정보",
|
613 |
-
f"{user_query} 관련 자료"
|
614 |
-
]
|
615 |
-
else:
|
616 |
-
search_queries = [
|
617 |
-
f"{user_query} novel setting",
|
618 |
-
f"{user_query} background information",
|
619 |
-
f"{user_query} related material"
|
620 |
-
]
|
621 |
-
|
622 |
-
for query in search_queries[:2]: # 2개 쿼리만 사용
|
623 |
-
results = self.web_search.search(query, count=2, language=language)
|
624 |
-
if results:
|
625 |
-
search_info = self.web_search.extract_relevant_info(results)
|
626 |
-
search_results += f"\n### {query}\n{search_info}\n"
|
627 |
-
|
628 |
-
if language == "Korean":
|
629 |
-
base_prompt = f"""당신은 30페이지 중편 소설을 기획하는 문학 감독자입니다.
|
630 |
-
|
631 |
-
사용자 주제: {user_query}
|
632 |
-
|
633 |
-
{search_results if search_results else ""}
|
634 |
-
|
635 |
-
다음 요소들을 포함한 상세한 소설 기획을 작성하세요:
|
636 |
-
|
637 |
-
1. **주제와 장르 설정**
|
638 |
-
- 핵심 주제와 메시지
|
639 |
-
- 장르 및 분위기
|
640 |
-
- 독자층 고려사항
|
641 |
-
|
642 |
-
2. **주요 등장인물** (3-5명)
|
643 |
-
| 이름 | 역할 | 성격 | 배경 | 목표 | 갈등 |
|
644 |
-
|------|------|------|------|------|------|
|
645 |
-
|
646 |
-
3. **배경 설정**
|
647 |
-
- 시공간적 배경
|
648 |
-
- 사회적/문화적 환경
|
649 |
-
- 주요 장소들
|
650 |
-
|
651 |
-
4. **플롯 구조** (10개 파트, 각 3페이지)
|
652 |
-
| 파트 | 페이지 | 주요 사건 | 긴장도 | 캐릭터 발전 |
|
653 |
-
|------|--------|-----------|---------|-----------|
|
654 |
-
| 1 | 1-3 | | | |
|
655 |
-
| 2 | 4-6 | | | |
|
656 |
-
| 3 | 7-9 | | | |
|
657 |
-
| 4 | 10-12 | | | |
|
658 |
-
| 5 | 13-15 | | | |
|
659 |
-
| 6 | 16-18 | | | |
|
660 |
-
| 7 | 19-21 | | | |
|
661 |
-
| 8 | 22-24 | | | |
|
662 |
-
| 9 | 25-27 | | | |
|
663 |
-
| 10 | 28-30 | | | |
|
664 |
-
|
665 |
-
5. **작가별 지침**
|
666 |
-
- 각 작가가 담당할 내용과 주의사항
|
667 |
-
- 일관성 유지를 위한 핵심 설정
|
668 |
-
- 문체와 톤 가이드라인
|
669 |
-
|
670 |
-
창의적이고 흥미로운 소설이 될 수 있도록 상세하게 기획하세요."""
|
671 |
-
else:
|
672 |
-
base_prompt = f"""You are a literary director planning a 30-page novella.
|
673 |
-
|
674 |
-
User Theme: {user_query}
|
675 |
-
|
676 |
-
{search_results if search_results else ""}
|
677 |
-
|
678 |
-
Create a detailed novel plan including:
|
679 |
-
|
680 |
-
1. **Theme and Genre Setting**
|
681 |
-
- Core theme and message
|
682 |
-
- Genre and atmosphere
|
683 |
-
- Target audience considerations
|
684 |
-
|
685 |
-
2. **Main Characters** (3-5 characters)
|
686 |
-
| Name | Role | Personality | Background | Goal | Conflict |
|
687 |
-
|------|------|-------------|------------|------|----------|
|
688 |
-
|
689 |
-
3. **Setting**
|
690 |
-
- Temporal and spatial background
|
691 |
-
- Social/cultural environment
|
692 |
-
- Key locations
|
693 |
-
|
694 |
-
4. **Plot Structure** (10 parts, 3 pages each)
|
695 |
-
| Part | Pages | Main Events | Tension | Character Development |
|
696 |
-
|------|-------|-------------|---------|---------------------|
|
697 |
-
| 1 | 1-3 | | | |
|
698 |
-
| 2 | 4-6 | | | |
|
699 |
-
| 3 | 7-9 | | | |
|
700 |
-
| 4 | 10-12 | | | |
|
701 |
-
| 5 | 13-15 | | | |
|
702 |
-
| 6 | 16-18 | | | |
|
703 |
-
| 7 | 19-21 | | | |
|
704 |
-
| 8 | 22-24 | | | |
|
705 |
-
| 9 | 25-27 | | | |
|
706 |
-
| 10 | 28-30 | | | |
|
707 |
-
|
708 |
-
5. **Guidelines for Writers**
|
709 |
-
- Content and precautions for each writer
|
710 |
-
- Key settings for consistency
|
711 |
-
- Style and tone guidelines
|
712 |
-
|
713 |
-
Plan creatively for an engaging novel."""
|
714 |
-
|
715 |
-
return base_prompt
|
716 |
-
|
717 |
-
def create_critic_director_prompt(self, director_plan: str, language: str = "English") -> str:
|
718 |
-
"""비평가의 감독자 기획 검토"""
|
719 |
-
if language == "Korean":
|
720 |
-
return f"""당신은 문학 비평가입니다. 감독자의 소설 기획을 일관성 관점에서 검토하세요.
|
721 |
-
|
722 |
-
감독자 기획:
|
723 |
-
{director_plan}
|
724 |
|
725 |
-
|
726 |
-
|
727 |
-
|
728 |
-
|
729 |
-
|
730 |
-
|
731 |
-
|
732 |
-
|
733 |
-
|
734 |
-
|
735 |
-
|
736 |
-
|
737 |
-
|
738 |
-
|
739 |
-
|
740 |
-
-
|
741 |
-
|
742 |
-
|
743 |
-
|
744 |
-
-
|
745 |
-
|
746 |
-
|
747 |
-
|
748 |
-
|
749 |
-
|
750 |
-
|
751 |
-
|
752 |
-
|
753 |
-
|
754 |
-
|
755 |
-
|
756 |
-
|
757 |
-
-
|
758 |
-
|
759 |
-
|
760 |
-
|
761 |
-
|
762 |
-
|
763 |
-
|
764 |
-
|
765 |
-
|
766 |
-
|
767 |
-
|
768 |
-
|
769 |
-
|
770 |
-
|
771 |
-
|
772 |
-
-
|
773 |
-
|
774 |
-
|
775 |
-
|
776 |
-
|
777 |
-
|
778 |
-
|
779 |
-
|
780 |
-
|
781 |
-
|
782 |
-
|
783 |
-
|
784 |
-
{initial_plan}
|
785 |
-
|
786 |
-
비평가 피드백:
|
787 |
-
{critic_feedback}
|
788 |
-
|
789 |
-
다음을 포함한 수정된 최종 기획을 제시하세요:
|
790 |
-
|
791 |
-
1. **보완된 캐릭터 설정**
|
792 |
-
- 일관성 문제가 해결된 캐릭터들
|
793 |
-
- 명확한 동기와 성격 특성
|
794 |
-
- 관계 설정의 지속성 확보
|
795 |
-
|
796 |
-
2. **개선된 플롯 구조**
|
797 |
-
- 논리적 허점이 수정된 서사
|
798 |
-
- 자연스러운 사건 전개
|
799 |
-
- 일관된 시간선과 공간 설정
|
800 |
-
|
801 |
-
3. **작가별 상세 지침**
|
802 |
-
- 각 작가가 담당할 구체적 내용
|
803 |
-
- 일관성 유지를 위한 필수 체크리스트
|
804 |
-
- 캐릭터 상태 및 설정 정보
|
805 |
-
|
806 |
-
4. **일관성 관리 시스템**
|
807 |
-
- 캐릭터 추적 방법
|
808 |
-
- 플롯 연속성 체크 포인트
|
809 |
-
- 문체 및 톤 통일 방안
|
810 |
-
|
811 |
-
10명의 작가가 일관성 있는 소설을 만들 수 있는 완벽한 마스터플랜을 제시하세요."""
|
812 |
-
else:
|
813 |
-
return f"""As director, revise the novel plan reflecting the critic's consistency review.
|
814 |
-
|
815 |
-
Initial Plan:
|
816 |
-
{initial_plan}
|
817 |
-
|
818 |
-
Critic Feedback:
|
819 |
-
{critic_feedback}
|
820 |
-
|
821 |
-
Present the revised final plan including:
|
822 |
-
|
823 |
-
1. **Enhanced Character Settings**
|
824 |
-
- Characters with resolved consistency issues
|
825 |
-
- Clear motivations and personality traits
|
826 |
-
- Sustained relationship settings
|
827 |
-
|
828 |
-
2. **Improved Plot Structure**
|
829 |
-
- Narrative with logical gaps fixed
|
830 |
-
- Natural event progression
|
831 |
-
- Consistent timeline and spatial settings
|
832 |
|
833 |
-
|
834 |
-
|
835 |
-
|
836 |
-
|
|
|
|
|
|
|
837 |
|
838 |
-
|
839 |
-
|
840 |
-
|
841 |
-
|
|
|
842 |
|
843 |
-
|
844 |
-
|
845 |
-
def create_writer_prompt(self, writer_number: int, director_plan: str, previous_content: str, language: str = "English") -> str:
|
846 |
-
"""작가 프롬프트 생성"""
|
847 |
pages_start = (writer_number - 1) * 3 + 1
|
848 |
pages_end = writer_number * 3
|
849 |
|
850 |
-
|
851 |
-
|
852 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
853 |
|
854 |
-
|
855 |
-
|
856 |
-
|
857 |
-
|
858 |
-
{director_plan}
|
859 |
-
|
860 |
-
{consistency_info}
|
861 |
-
|
862 |
-
{'이전 내용 요약:' if previous_content else ''}
|
863 |
-
{previous_content[-1500:] if previous_content else ''}
|
864 |
-
|
865 |
-
**작성 지침:**
|
866 |
-
1. **분량**: 정확히 1,400-1,500단어 (약 3페이지)
|
867 |
-
2. **일관성**: 이전 내용과 자연스럽게 연결
|
868 |
-
3. **캐릭터**: 기존 설정과 상태 유지
|
869 |
-
4. **플롯**: 마스터플랜의 해당 부분 충실히 구현
|
870 |
-
5. **문체**: 전체 소설의 톤과 일치
|
871 |
-
|
872 |
-
창의적이고 흥미로운 내용을 작성하되, 일관성을 절대 잃지 마세요."""
|
873 |
-
else:
|
874 |
-
return f"""You are Writer #{writer_number}. Write pages {pages_start}-{pages_end} of the 30-page novella.
|
875 |
-
|
876 |
-
Director's Masterplan:
|
877 |
-
{director_plan}
|
878 |
-
|
879 |
-
{consistency_info}
|
880 |
-
|
881 |
-
{'Previous Content Summary:' if previous_content else ''}
|
882 |
-
{previous_content[-1500:] if previous_content else ''}
|
883 |
-
|
884 |
-
**Writing Guidelines:**
|
885 |
-
1. **Length**: Exactly 1,400-1,500 words (about 3 pages)
|
886 |
-
2. **Consistency**: Connect naturally with previous content
|
887 |
-
3. **Characters**: Maintain existing settings and states
|
888 |
-
4. **Plot**: Faithfully implement the relevant part of the masterplan
|
889 |
-
5. **Style**: Match the tone of the entire novel
|
890 |
-
|
891 |
-
Write creatively and engagingly, but never lose consistency."""
|
892 |
-
|
893 |
-
def create_critic_consistency_prompt(self, all_content: str, language: str = "English") -> str:
|
894 |
-
"""비평가 일관성 검토 프롬프트"""
|
895 |
-
if language == "Korean":
|
896 |
-
return f"""당신은 일관성 검토 전문 비평가입니다. 지금까지 작성된 모든 내용을 검토하세요.
|
897 |
-
|
898 |
-
현재까지 작성된 내용:
|
899 |
-
{all_content[-3000:]} # 최근 3000자만 표시
|
900 |
-
|
901 |
-
다음 항목들을 철저히 검토하세요:
|
902 |
-
|
903 |
-
1. **캐릭터 일관성**
|
904 |
-
- 인물 설정의 일관성
|
905 |
-
- 성격과 행동의 연속성
|
906 |
-
- 관계 설정의 지속성
|
907 |
-
|
908 |
-
2. **플롯 연속성**
|
909 |
-
- 사건의 논리적 연결
|
910 |
-
- 시간선의 일관성
|
911 |
-
- 공간 설정의 지속성
|
912 |
-
|
913 |
-
3. **반복 내용 검사**
|
914 |
-
- 동일한 내용의 반복
|
915 |
-
- 유사한 표현의 과도한 사용
|
916 |
-
- 중복되는 장면이나 설명
|
917 |
-
|
918 |
-
4. **설정 충돌**
|
919 |
-
- 이전에 확립된 설정과의 모순
|
920 |
-
- 세계관 설정의 일관성
|
921 |
-
- 기술적/사회적 배경의 연속성
|
922 |
-
|
923 |
-
**검토 결과:**
|
924 |
-
- 발견된 문제점들을 구체적으로 나열
|
925 |
-
- 각 문제점에 대한 수정 방향 제시
|
926 |
-
- 앞으로 주의해야 할 사항들 안내
|
927 |
-
|
928 |
-
일관성 유지를 위한 구체적이고 실용적인 피드백을 제공하세요."""
|
929 |
-
else:
|
930 |
-
return f"""You are a consistency review specialist critic. Review all content written so far.
|
931 |
-
|
932 |
-
Current Content:
|
933 |
-
{all_content[-3000:]} # Show only recent 3000 characters
|
934 |
-
|
935 |
-
Thoroughly review these items:
|
936 |
-
|
937 |
-
1. **Character Consistency**
|
938 |
-
- Character setting consistency
|
939 |
-
- Personality and behavior continuity
|
940 |
-
- Relationship sustainability
|
941 |
-
|
942 |
-
2. **Plot Continuity**
|
943 |
-
- Logical connection of events
|
944 |
-
- Timeline consistency
|
945 |
-
- Spatial setting sustainability
|
946 |
|
947 |
-
|
948 |
-
|
949 |
-
|
950 |
-
|
|
|
|
|
951 |
|
952 |
-
|
953 |
-
|
954 |
-
|
955 |
-
|
956 |
|
957 |
-
|
958 |
-
|
959 |
-
- Provide correction directions for each problem
|
960 |
-
- Guide precautions for the future
|
961 |
|
962 |
-
|
963 |
-
|
964 |
-
def create_writer_revision_prompt(self, writer_number: int, initial_content: str,
|
965 |
-
consistency_feedback: str, language: str = "English") -> str:
|
966 |
"""작가 수정 프롬프트"""
|
967 |
-
|
968 |
-
|
969 |
-
|
970 |
-
|
971 |
-
|
972 |
-
|
973 |
-
일관성
|
974 |
-
|
975 |
-
|
976 |
-
|
977 |
-
|
978 |
-
|
979 |
-
|
980 |
-
|
981 |
-
|
982 |
-
|
983 |
-
|
984 |
-
|
985 |
-
|
986 |
-
|
987 |
-
|
988 |
-
|
989 |
-
|
990 |
-
|
991 |
-
|
992 |
-
|
993 |
-
|
994 |
-
|
995 |
-
|
996 |
-
|
997 |
-
|
998 |
-
|
999 |
-
|
1000 |
-
|
1001 |
-
|
1002 |
-
|
1003 |
-
|
1004 |
-
Consistency Review Feedback:
|
1005 |
-
{consistency_feedback}
|
1006 |
-
|
1007 |
-
Revise reflecting these points:
|
1008 |
-
|
1009 |
-
1. **Fix Consistency Issues**
|
1010 |
-
- Resolve all pointed consistency problems
|
1011 |
-
- Accurately reflect character settings and states
|
1012 |
-
- Ensure plot continuity
|
1013 |
-
|
1014 |
-
2. **Remove Repetitive Content**
|
1015 |
-
- Delete duplicate expressions or scenes
|
1016 |
-
- Replace with new expressions and perspectives
|
1017 |
-
- Use creative and varied sentence structures
|
1018 |
-
|
1019 |
-
3. **Resolve Setting Conflicts**
|
1020 |
-
- Remove contradictions with previous settings
|
1021 |
-
- Maintain worldbuilding consistency
|
1022 |
-
- Accurately reflect timeline and spatial settings
|
1023 |
-
|
1024 |
-
4. **Maintain Length**
|
1025 |
-
- Keep exactly 1,400-1,500 words
|
1026 |
-
- Improve content quality during revision
|
1027 |
-
|
1028 |
-
Present the revised final version. Maintain creativity but never miss consistency."""
|
1029 |
-
|
1030 |
-
def create_critic_final_prompt(self, complete_novel: str, language: str = "English") -> str:
|
1031 |
-
"""최종 비평가 검토"""
|
1032 |
-
if language == "Korean":
|
1033 |
-
return f"""완성된 소설의 최종 일관성 검토를 수행하세요.
|
1034 |
-
|
1035 |
-
완성된 소설:
|
1036 |
-
{complete_novel[-2000:]} # 마지막 2000자 표시
|
1037 |
-
|
1038 |
-
**최종 검토 항목:**
|
1039 |
-
|
1040 |
-
1. **전체 일관성 평가**
|
1041 |
-
- 캐릭터 일관성 점수 (1-10)
|
1042 |
-
- 플롯 연속성 점수 (1-10)
|
1043 |
-
- 설정 일관성 점수 (1-10)
|
1044 |
-
- 전반적 통일성 점수 (1-10)
|
1045 |
-
|
1046 |
-
2. **발견된 문제점**
|
1047 |
-
- 남아있는 일관성 문제들
|
1048 |
-
- 반복이나 중복 내용
|
1049 |
-
- 설정 충돌 사항들
|
1050 |
-
|
1051 |
-
3. **성공 요소**
|
1052 |
-
- 잘 유지된 일관성 부분
|
1053 |
-
- 훌륭한 캐릭터 연속성
|
1054 |
-
- 효과적인 플롯 전개
|
1055 |
-
|
1056 |
-
4. **최종 평가**
|
1057 |
-
- 전체적인 소설의 완성도
|
1058 |
-
- 일관성 관점에서의 품질
|
1059 |
-
- 독자에게 미칠 영향
|
1060 |
-
|
1061 |
-
**일관성 보고서**를 작성하여 이 소설의 일관성 수준을 종합적으로 평가하세요."""
|
1062 |
-
else:
|
1063 |
-
return f"""Perform final consistency review of the completed novel.
|
1064 |
-
|
1065 |
-
Completed Novel:
|
1066 |
-
{complete_novel[-2000:]} # Show last 2000 characters
|
1067 |
-
|
1068 |
-
**Final Review Items:**
|
1069 |
-
|
1070 |
-
1. **Overall Consistency Evaluation**
|
1071 |
-
- Character consistency score (1-10)
|
1072 |
-
- Plot continuity score (1-10)
|
1073 |
-
- Setting consistency score (1-10)
|
1074 |
-
- Overall unity score (1-10)
|
1075 |
-
|
1076 |
-
2. **Problems Found**
|
1077 |
-
- Remaining consistency issues
|
1078 |
-
- Repetitive or duplicate content
|
1079 |
-
- Setting conflicts
|
1080 |
-
|
1081 |
-
3. **Success Elements**
|
1082 |
-
- Well-maintained consistency parts
|
1083 |
-
- Excellent character continuity
|
1084 |
-
- Effective plot development
|
1085 |
-
|
1086 |
-
4. **Final Assessment**
|
1087 |
-
- Overall completion of the novel
|
1088 |
-
- Quality from consistency perspective
|
1089 |
-
- Impact on readers
|
1090 |
-
|
1091 |
-
Write a **Consistency Report** to comprehensively evaluate this novel's consistency level."""
|
1092 |
-
|
1093 |
-
def call_llm_streaming(self, messages: List[Dict[str, str]], role: str,
|
1094 |
-
language: str = "English") -> Generator[str, None, None]:
|
1095 |
-
"""LLM 스트리밍 호출"""
|
1096 |
try:
|
1097 |
system_prompts = self.get_system_prompts(language)
|
1098 |
-
|
1099 |
-
full_messages = [
|
1100 |
-
{"role": "system", "content": system_prompts.get(role, "")},
|
1101 |
-
*messages
|
1102 |
-
]
|
1103 |
-
|
1104 |
-
# 역할별 토큰 할당
|
1105 |
-
if role.startswith("writer"):
|
1106 |
-
max_tokens = 10000
|
1107 |
-
temperature = 0.8
|
1108 |
-
elif role == "critic":
|
1109 |
-
max_tokens = 8000
|
1110 |
-
temperature = 0.6
|
1111 |
-
else: # director
|
1112 |
-
max_tokens = 12000
|
1113 |
-
temperature = 0.7
|
1114 |
|
1115 |
payload = {
|
1116 |
"model": self.model_id,
|
1117 |
"messages": full_messages,
|
1118 |
-
"max_tokens":
|
1119 |
-
"temperature":
|
1120 |
"top_p": 0.9,
|
|
|
|
|
1121 |
"stream": True,
|
1122 |
"stream_options": {"include_usage": True}
|
1123 |
}
|
1124 |
|
|
|
|
|
|
|
1125 |
response = requests.post(
|
1126 |
-
self.api_url,
|
1127 |
-
headers=self.create_headers(),
|
1128 |
-
json=payload,
|
1129 |
-
stream=True,
|
1130 |
-
timeout=
|
1131 |
)
|
1132 |
|
|
|
1133 |
if response.status_code != 200:
|
1134 |
-
logger.error(f"API
|
1135 |
-
|
|
|
1136 |
return
|
1137 |
|
|
|
|
|
|
|
1138 |
buffer = ""
|
1139 |
total_content = ""
|
1140 |
-
|
|
|
|
|
1141 |
for line in response.iter_lines():
|
1142 |
-
if line:
|
1143 |
-
|
1144 |
-
|
1145 |
-
|
1146 |
-
|
1147 |
-
|
1148 |
-
|
1149 |
-
|
1150 |
-
|
1151 |
-
|
1152 |
-
|
1153 |
-
|
1154 |
-
|
1155 |
-
|
1156 |
-
|
1157 |
-
|
1158 |
-
|
1159 |
-
|
1160 |
-
|
1161 |
-
|
1162 |
-
|
1163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1164 |
|
|
|
1165 |
if buffer:
|
1166 |
yield buffer
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
1168 |
except Exception as e:
|
1169 |
-
logger.error(f"
|
1170 |
yield f"❌ 오류 발생: {str(e)}"
|
1171 |
-
|
|
|
|
|
|
|
|
|
1172 |
def get_system_prompts(self, language: str) -> Dict[str, str]:
|
1173 |
-
"""시스템 프롬프트 생성"""
|
1174 |
-
|
1175 |
-
|
1176 |
"director": "당신은 창의적이고 체계적인 소설 기획 전문가입니다. 흥미롭고 일관성 있는 스토리를 설계하세요.",
|
1177 |
"critic": "당신은 일관성 검토 전문 비평가입니다. 캐릭터, 플롯, 설정의 일관성을 철저히 점검하고 개선방안을 제시하세요.",
|
1178 |
-
"
|
1179 |
-
|
1180 |
-
|
1181 |
-
"writer4": "당신은 전환점을 만드는 작가입니다. 이야기에 새로운 전개를 가져오세요.",
|
1182 |
-
"writer5": "당신은 중반부를 담당하는 작가입니다. 복잡성과 깊이를 더해가세요.",
|
1183 |
-
"writer6": "당신은 클라이맥스를 준비하는 작가입니다. 긴장감을 최고조로 끌어올리세요.",
|
1184 |
-
"writer7": "당신은 클라이맥스를 담당하는 작가입니다. 모든 갈등이 폭발하는 순간을 그려내세요.",
|
1185 |
-
"writer8": "당신은 해결 과정을 담당하는 작가입니다. 갈등을 해결해나가는 과정을 보여주세요.",
|
1186 |
-
"writer9": "당신은 결말을 준비하는 작가입니다. 이야기를 자연스럽게 마무리로 이끌어가세요.",
|
1187 |
-
"writer10": "당신은 완벽한 결말을 만드는 작가입니다. 독자에게 깊은 여운을 남기는 마무리를 하세요."
|
1188 |
-
}
|
1189 |
-
else:
|
1190 |
-
return {
|
1191 |
"director": "You are a creative and systematic novel planning expert. Design engaging and consistent stories.",
|
1192 |
"critic": "You are a consistency review specialist critic. Thoroughly check character, plot, and setting consistency and suggest improvements.",
|
1193 |
-
"
|
1194 |
-
"writer2": "You are a writer who develops the story. Deeply develop characters and conflicts.",
|
1195 |
-
"writer3": "You are a writer who intensifies conflicts. Increase tension and interest.",
|
1196 |
-
"writer4": "You are a writer who creates turning points. Bring new developments to the story.",
|
1197 |
-
"writer5": "You are a writer responsible for the middle part. Add complexity and depth.",
|
1198 |
-
"writer6": "You are a writer preparing for the climax. Raise tension to its peak.",
|
1199 |
-
"writer7": "You are a writer responsible for the climax. Depict the moment when all conflicts explode.",
|
1200 |
-
"writer8": "You are a writer responsible for the resolution process. Show the process of resolving conflicts.",
|
1201 |
-
"writer9": "You are a writer preparing for the ending. Lead the story naturally to its conclusion.",
|
1202 |
-
"writer10": "You are a writer who creates the perfect ending. Create a conclusion that leaves readers with deep resonance."
|
1203 |
}
|
1204 |
-
|
1205 |
-
|
1206 |
-
|
1207 |
-
|
1208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1209 |
try:
|
1210 |
-
|
1211 |
-
|
1212 |
-
# 세션 생성 또는 복구
|
1213 |
if session_id:
|
1214 |
self.current_session_id = session_id
|
1215 |
session = NovelDatabase.get_session(session_id)
|
@@ -1220,364 +736,251 @@ Write a **Consistency Report** to comprehensively evaluate this novel's consiste
|
|
1220 |
logger.info(f"Resuming session {session_id} from stage {resume_from_stage}")
|
1221 |
else:
|
1222 |
self.current_session_id = NovelDatabase.create_session(query, language)
|
1223 |
-
resume_from_stage = 0
|
1224 |
logger.info(f"Created new session: {self.current_session_id}")
|
1225 |
-
|
1226 |
-
# 대화 기록 초기화
|
1227 |
-
conversation_history = [{
|
1228 |
-
"role": "human",
|
1229 |
-
"content": query,
|
1230 |
-
"timestamp": datetime.now()
|
1231 |
-
}]
|
1232 |
-
|
1233 |
-
# 기존 단계 로드
|
1234 |
stages = []
|
1235 |
if resume_from_stage > 0:
|
1236 |
-
|
1237 |
-
|
1238 |
-
|
1239 |
-
|
1240 |
-
|
1241 |
-
"content": stage_data['content'] or "",
|
1242 |
-
"consistency_score": stage_data.get('consistency_score', 0.0)
|
1243 |
-
})
|
1244 |
-
|
1245 |
-
# 단계 처리
|
1246 |
for stage_idx in range(resume_from_stage, len(OPTIMIZED_STAGES)):
|
1247 |
role, stage_name = OPTIMIZED_STAGES[stage_idx]
|
1248 |
-
|
1249 |
-
# 단계 추가
|
1250 |
if stage_idx >= len(stages):
|
1251 |
-
stages.append({
|
1252 |
-
"name": stage_name,
|
1253 |
-
"status": "active",
|
1254 |
-
"content": "",
|
1255 |
-
"consistency_score": 0.0
|
1256 |
-
})
|
1257 |
else:
|
1258 |
stages[stage_idx]["status"] = "active"
|
1259 |
-
|
1260 |
-
yield "", stages
|
1261 |
-
|
1262 |
-
# 프롬프트 생성
|
1263 |
prompt = self.get_stage_prompt(stage_idx, role, query, language, stages)
|
1264 |
-
|
1265 |
-
# 스트리밍 생성
|
1266 |
stage_content = ""
|
1267 |
-
for chunk in self.call_llm_streaming(
|
1268 |
-
[{"role": "user", "content": prompt}],
|
1269 |
-
role,
|
1270 |
-
language
|
1271 |
-
):
|
1272 |
stage_content += chunk
|
1273 |
stages[stage_idx]["content"] = stage_content
|
1274 |
-
yield "", stages
|
1275 |
-
|
1276 |
-
# 일관성 검증 (작가 단계)
|
1277 |
consistency_score = 0.0
|
1278 |
if role.startswith("writer"):
|
1279 |
-
writer_num = int(
|
1280 |
-
all_previous = self.
|
1281 |
-
|
1282 |
-
|
1283 |
-
writer_num, stage_content, all_previous
|
1284 |
-
)
|
1285 |
-
|
1286 |
-
consistency_score = max(0, 10 - len(errors))
|
1287 |
stages[stage_idx]["consistency_score"] = consistency_score
|
1288 |
-
|
1289 |
-
if errors:
|
1290 |
-
logger.warning(f"Writer {writer_num} consistency issues: {errors}")
|
1291 |
-
|
1292 |
-
# 단계 완료
|
1293 |
stages[stage_idx]["status"] = "complete"
|
1294 |
-
|
1295 |
-
# DB 저장
|
1296 |
NovelDatabase.save_stage(
|
1297 |
-
self.current_session_id,
|
1298 |
-
|
1299 |
-
stage_name,
|
1300 |
-
role,
|
1301 |
-
stage_content,
|
1302 |
-
"complete",
|
1303 |
-
consistency_score
|
1304 |
)
|
1305 |
-
|
1306 |
-
|
1307 |
-
|
1308 |
-
|
1309 |
-
|
1310 |
-
|
1311 |
-
|
1312 |
-
final_report = self.generate_consistency_report(complete_novel, language)
|
1313 |
-
|
1314 |
-
# 최종 저장
|
1315 |
-
NovelDatabase.update_final_novel(self.current_session_id, complete_novel, final_report)
|
1316 |
-
|
1317 |
-
# 완료 메시지
|
1318 |
-
final_message = f"✅ 소설 완성! 총 {len(complete_novel.split())}단어\n\n{final_report}"
|
1319 |
-
yield final_message, stages
|
1320 |
-
|
1321 |
except Exception as e:
|
1322 |
-
logger.error(f"
|
1323 |
-
|
1324 |
-
|
1325 |
-
|
1326 |
-
|
1327 |
-
|
1328 |
-
}
|
1329 |
-
stages.append(error_stage)
|
1330 |
-
yield f"오류 발생: {str(e)}", stages
|
1331 |
-
|
1332 |
-
def get_stage_prompt(self, stage_idx: int, role: str, query: str,
|
1333 |
-
language: str, stages: List[Dict]) -> str:
|
1334 |
-
"""단계별 프롬프트 생성"""
|
1335 |
-
if stage_idx == 0: # 감독자 초기
|
1336 |
return self.create_director_initial_prompt(query, language)
|
1337 |
-
|
1338 |
-
return self.create_critic_director_prompt(stages[0]["content"], language)
|
1339 |
-
|
1340 |
-
return self.create_director_revision_prompt(
|
1341 |
-
|
1342 |
-
|
|
|
|
|
1343 |
writer_num = stage_idx - 2
|
1344 |
-
|
1345 |
-
|
1346 |
-
return self.create_writer_prompt(writer_num,
|
1347 |
-
|
|
|
1348 |
all_content = self.get_all_content(stages, stage_idx)
|
1349 |
-
return self.create_critic_consistency_prompt(all_content, language)
|
1350 |
-
|
|
|
1351 |
writer_num = stage_idx - 13
|
1352 |
initial_content = stages[2 + writer_num]["content"]
|
1353 |
-
|
1354 |
-
return self.create_writer_revision_prompt(
|
1355 |
-
|
1356 |
-
|
1357 |
complete_novel = self.get_all_writer_content(stages)
|
1358 |
return self.create_critic_final_prompt(complete_novel, language)
|
1359 |
|
1360 |
return ""
|
1361 |
-
|
1362 |
-
def
|
1363 |
-
"""이전
|
1364 |
-
|
1365 |
-
|
1366 |
-
|
1367 |
-
|
1368 |
-
|
1369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1370 |
def get_all_content(self, stages: List[Dict], current_stage: int) -> str:
|
1371 |
-
"""모든 내용 가져오기"""
|
1372 |
-
content
|
1373 |
-
|
1374 |
-
if i < len(stages) and stages[i]["content"]:
|
1375 |
-
content += stages[i]["content"] + "\n\n"
|
1376 |
-
return content
|
1377 |
-
|
1378 |
def get_all_writer_content(self, stages: List[Dict]) -> str:
|
1379 |
-
"""모든 작가 내용 가져오기"""
|
1380 |
-
content
|
1381 |
-
|
1382 |
-
if i < len(stages) and stages[i]["content"]:
|
1383 |
-
content += stages[i]["content"] + "\n\n"
|
1384 |
-
return content
|
1385 |
-
|
1386 |
def generate_consistency_report(self, complete_novel: str, language: str) -> str:
|
1387 |
-
"""
|
1388 |
-
|
1389 |
-
|
1390 |
-
|
1391 |
-
|
1392 |
-
|
1393 |
-
|
1394 |
-
|
1395 |
-
return f"""📊 Consistency Report
|
1396 |
-
- Total Length: {len(complete_novel.split())} words
|
1397 |
-
- Characters: {len(self.consistency_tracker.character_states)}
|
1398 |
-
- Plot Points: {len(self.consistency_tracker.plot_points)}
|
1399 |
-
- Consistency Score: Good"""
|
1400 |
|
1401 |
|
1402 |
-
#
|
1403 |
-
def process_query(query: str, language: str, session_id: str = None) -> Generator[Tuple[str, str, str, str], None, None]:
|
1404 |
-
"""쿼리 처리
|
1405 |
-
if not query.strip()
|
1406 |
-
|
1407 |
-
yield "", "", "❌ 소설 주제를 입력해주세요.", None
|
1408 |
-
else:
|
1409 |
-
yield "", "", "❌ Please enter a novel theme.", None
|
1410 |
return
|
1411 |
|
1412 |
system = NovelWritingSystem()
|
|
|
|
|
1413 |
|
1414 |
-
|
1415 |
-
|
1416 |
-
# 단계 표시 형식화
|
1417 |
-
stages_display = format_stages_display(stages, language)
|
1418 |
-
|
1419 |
-
# 진행률 계산
|
1420 |
-
completed = sum(1 for s in stages if s.get("status") == "complete")
|
1421 |
-
total = len(stages)
|
1422 |
-
progress_percent = (completed / total * 100) if total > 0 else 0
|
1423 |
-
|
1424 |
-
if "✅ 소설 완성!" in str(final_novel):
|
1425 |
-
status = "✅ 완료! 다운로드 가능합니다."
|
1426 |
-
else:
|
1427 |
-
if language == "Korean":
|
1428 |
-
status = f"🔄 진행중... ({completed}/{total} - {progress_percent:.1f}%)"
|
1429 |
-
else:
|
1430 |
-
status = f"🔄 Processing... ({completed}/{total} - {progress_percent:.1f}%)"
|
1431 |
-
|
1432 |
-
yield stages_display, final_novel, status, system.current_session_id
|
1433 |
-
|
1434 |
-
except Exception as e:
|
1435 |
-
logger.error(f"Error in process_query: {str(e)}", exc_info=True)
|
1436 |
-
if language == "Korean":
|
1437 |
-
yield "", "", f"❌ 오류 발생: {str(e)}", None
|
1438 |
-
else:
|
1439 |
-
yield "", "", f"❌ Error occurred: {str(e)}", None
|
1440 |
-
|
1441 |
-
|
1442 |
-
def format_stages_display(stages: List[Dict[str, str]], language: str) -> str:
|
1443 |
-
"""단계 표시 형식화"""
|
1444 |
-
display = ""
|
1445 |
-
|
1446 |
-
for idx, stage in enumerate(stages):
|
1447 |
-
status_icon = "✅" if stage.get("status") == "complete" else ("⏳" if stage.get("status") == "active" else "❌")
|
1448 |
|
1449 |
-
#
|
1450 |
-
|
1451 |
-
|
1452 |
-
|
1453 |
-
if score >= 8:
|
1454 |
-
consistency_indicator = " 🟢"
|
1455 |
-
elif score >= 6:
|
1456 |
-
consistency_indicator = " 🟡"
|
1457 |
-
else:
|
1458 |
-
consistency_indicator = " 🔴"
|
1459 |
|
1460 |
-
|
1461 |
-
if stage.get("status") == "active":
|
1462 |
-
display += f"\n\n{status_icon} **{stage['name']}**{consistency_indicator}\n"
|
1463 |
-
display += f"```\n{stage.get('content', '')[-800:]}\n```"
|
1464 |
-
else:
|
1465 |
-
display += f"\n{status_icon} {stage['name']}{consistency_indicator}"
|
1466 |
-
|
1467 |
-
return display
|
1468 |
-
|
1469 |
|
1470 |
-
def get_active_sessions(language: str) -> List[
|
1471 |
"""활성 세션 목록 가져오기"""
|
1472 |
-
|
1473 |
-
|
1474 |
-
|
1475 |
-
choices = []
|
1476 |
-
for session in sessions:
|
1477 |
-
created = datetime.fromisoformat(session['created_at'])
|
1478 |
-
date_str = created.strftime("%Y-%m-%d %H:%M")
|
1479 |
-
query_preview = session['user_query'][:50] + "..." if len(session['user_query']) > 50 else session['user_query']
|
1480 |
-
label = f"[{date_str}] {query_preview} (Stage {session['current_stage']})"
|
1481 |
-
choices.append((label, session['session_id']))
|
1482 |
-
|
1483 |
-
return choices
|
1484 |
-
except Exception as e:
|
1485 |
-
logger.error(f"Error getting active sessions: {str(e)}")
|
1486 |
-
return []
|
1487 |
|
|
|
|
|
|
|
|
|
|
|
|
|
1488 |
|
1489 |
def resume_session(session_id: str, language: str) -> Generator[Tuple[str, str, str, str], None, None]:
|
1490 |
-
"""세션 재개"""
|
1491 |
if not session_id:
|
1492 |
-
|
1493 |
-
yield "", "", "❌ 세션을 선택해주세요.", None
|
1494 |
-
else:
|
1495 |
-
yield "", "", "❌ Please select a session.", None
|
1496 |
return
|
1497 |
|
1498 |
-
|
1499 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1500 |
|
1501 |
-
def
|
1502 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
1503 |
try:
|
1504 |
-
|
1505 |
-
|
1506 |
-
if language == "Korean":
|
1507 |
-
message = f"✅ 자동 복구: '{latest_session['user_query'][:30]}...' (Stage {latest_session['current_stage']})"
|
1508 |
-
else:
|
1509 |
-
message = f"✅ Auto recovered: '{latest_session['user_query'][:30]}...' (Stage {latest_session['current_stage']})"
|
1510 |
-
return latest_session['session_id'], message
|
1511 |
else:
|
1512 |
-
return
|
1513 |
except Exception as e:
|
1514 |
-
logger.error(f"
|
1515 |
-
return None
|
1516 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1517 |
|
1518 |
-
def
|
1519 |
-
"""
|
1520 |
-
|
1521 |
-
logger.error("No session_id provided for download")
|
1522 |
-
return None
|
1523 |
|
1524 |
-
#
|
1525 |
-
|
1526 |
-
|
1527 |
-
logger.error(f"Session not found: {session_id}")
|
1528 |
-
return None
|
1529 |
|
1530 |
-
#
|
1531 |
-
|
1532 |
-
|
1533 |
-
|
1534 |
-
return None
|
1535 |
|
1536 |
-
|
1537 |
-
|
1538 |
-
|
|
|
|
|
1539 |
|
1540 |
-
|
1541 |
-
|
1542 |
-
|
1543 |
-
|
1544 |
-
|
1545 |
-
|
1546 |
-
|
1547 |
-
|
1548 |
-
|
1549 |
-
|
1550 |
-
|
1551 |
-
doc.add_paragraph()
|
1552 |
-
|
1553 |
-
# 소설 내용 추가
|
1554 |
-
paragraphs = complete_novel.split('\n\n')
|
1555 |
-
for para in paragraphs:
|
1556 |
-
if para.strip():
|
1557 |
-
doc.add_paragraph(para.strip())
|
1558 |
-
|
1559 |
-
# 파일 저장
|
1560 |
-
filename = f"Novel_{safe_filename}_{timestamp}.docx"
|
1561 |
-
filepath = os.path.join(temp_dir, filename)
|
1562 |
-
doc.save(filepath)
|
1563 |
-
|
1564 |
-
return filepath
|
1565 |
-
else:
|
1566 |
-
# TXT 생성
|
1567 |
-
filename = f"Novel_{safe_filename}_{timestamp}.txt"
|
1568 |
-
filepath = os.path.join(temp_dir, filename)
|
1569 |
-
|
1570 |
-
with open(filepath, 'w', encoding='utf-8') as f:
|
1571 |
-
f.write("=" * 60 + "\n")
|
1572 |
-
f.write(f"AI 협업 소설\n" if language == 'Korean' else f"AI COLLABORATIVE NOVEL\n")
|
1573 |
-
f.write("=" * 60 + "\n\n")
|
1574 |
-
f.write(f"주제: {session['user_query']}\n" if language == 'Korean' else f"Theme: {session['user_query']}\n")
|
1575 |
-
f.write(f"생성일: {datetime.now()}\n" if language == 'Korean' else f"Created: {datetime.now()}\n")
|
1576 |
-
f.write(f"단어 수: {len(complete_novel.split())}\n" if language == 'Korean' else f"Word Count: {len(complete_novel.split())}\n")
|
1577 |
-
f.write("=" * 60 + "\n\n")
|
1578 |
-
f.write(complete_novel)
|
1579 |
-
|
1580 |
-
return filepath
|
1581 |
|
1582 |
|
1583 |
# CSS 스타일
|
|
|
16 |
from dataclasses import dataclass, field
|
17 |
from collections import defaultdict
|
18 |
|
19 |
+
# --- 로깅 설정 ---
|
20 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
21 |
logger = logging.getLogger(__name__)
|
22 |
|
23 |
+
# --- Document export imports ---
|
24 |
try:
|
25 |
from docx import Document
|
26 |
from docx.shared import Inches, Pt, RGBColor
|
|
|
31 |
DOCX_AVAILABLE = False
|
32 |
logger.warning("python-docx not installed. DOCX export will be disabled.")
|
33 |
|
34 |
+
# --- 환경 변수 및 상수 ---
|
35 |
FRIENDLI_TOKEN = os.getenv("FRIENDLI_TOKEN", "")
|
36 |
BRAVE_SEARCH_API_KEY = os.getenv("BRAVE_SEARCH_API_KEY", "")
|
37 |
API_URL = "https://api.friendli.ai/dedicated/v1/chat/completions"
|
38 |
MODEL_ID = "dep89a2fld32mcm"
|
39 |
+
DB_PATH = "novel_sessions_v2.db"
|
40 |
|
41 |
+
# --- 환경 변수 검증 ---
|
42 |
if not FRIENDLI_TOKEN:
|
43 |
logger.error("FRIENDLI_TOKEN not set. Application will not work properly.")
|
44 |
+
# 실제 환경에서는 여기서 프로그램을 종료해야 하지만, 데모를 위해 더미 토큰을 사용합니다.
|
45 |
+
FRIENDLI_TOKEN = "dummy_token_for_testing"
|
46 |
|
47 |
if not BRAVE_SEARCH_API_KEY:
|
48 |
logger.warning("BRAVE_SEARCH_API_KEY not set. Web search features will be disabled.")
|
49 |
|
50 |
+
# --- 전역 변수 ---
|
|
|
|
|
|
|
|
|
|
|
51 |
db_lock = threading.Lock()
|
52 |
|
53 |
+
# 최적화된 단계 구성 (25단계로 압축 및 강화)
|
54 |
OPTIMIZED_STAGES = [
|
55 |
+
("director", "🎬 감독자: 초기 기획 (웹 검색 포함)"),
|
56 |
+
("critic", "📝 비평가: 기획 검토 (테마 및 일관성)"),
|
57 |
("director", "🎬 감독자: 수정된 마스터플랜"),
|
58 |
] + [
|
59 |
(f"writer{i}", f"✍️ 작가 {i}: 초안 (페이지 {(i-1)*3+1}-{i*3})")
|
60 |
for i in range(1, 11)
|
61 |
] + [
|
62 |
+
("critic", "📝 비평가: 중간 검토 (일관성 및 테마 유지)"),
|
63 |
] + [
|
64 |
(f"writer{i}", f"✍️ 작가 {i}: 수정본 (페이지 {(i-1)*3+1}-{i*3})")
|
65 |
for i in range(1, 11)
|
66 |
] + [
|
67 |
+
("critic", f"📝 비평가: 최종 검토 및 종합 보고서 작성"),
|
68 |
]
|
69 |
|
70 |
|
71 |
+
# --- 데이터 클래스 ---
|
72 |
@dataclass
|
73 |
class CharacterState:
|
74 |
"""캐릭터의 현재 상태를 나타내는 데이터 클래스"""
|
|
|
82 |
description: str = ""
|
83 |
role: str = ""
|
84 |
|
|
|
85 |
@dataclass
|
86 |
class PlotPoint:
|
87 |
"""플롯 포인트를 나타내는 데이터 클래스"""
|
|
|
92 |
impact_level: int
|
93 |
timestamp: str = ""
|
94 |
|
|
|
95 |
@dataclass
|
96 |
class TimelineEvent:
|
97 |
"""시간선 이벤트를 나타내는 데이터 클래스"""
|
|
|
102 |
relative_time: str = ""
|
103 |
|
104 |
|
105 |
+
# --- 핵심 로직 클래스 ---
|
106 |
class ConsistencyTracker:
|
107 |
"""일관성 추적 시스템"""
|
|
|
108 |
def __init__(self):
|
109 |
self.character_states: Dict[str, CharacterState] = {}
|
110 |
self.plot_points: List[PlotPoint] = []
|
111 |
self.timeline_events: List[TimelineEvent] = []
|
112 |
self.locations: Dict[str, str] = {}
|
113 |
self.established_facts: List[str] = []
|
114 |
+
self.content_hashes: Dict[str, int] = {} # 해시와 해당 챕터 번호를 저장
|
115 |
+
|
116 |
def register_character(self, character: CharacterState):
|
117 |
"""새 캐릭터 등록"""
|
118 |
self.character_states[character.name] = character
|
119 |
logger.info(f"Character registered: {character.name}")
|
120 |
+
|
121 |
def update_character_state(self, name: str, chapter: int, updates: Dict[str, Any]):
|
122 |
"""캐릭터 상태 업데이트"""
|
123 |
if name not in self.character_states:
|
124 |
self.register_character(CharacterState(name=name, last_seen_chapter=chapter))
|
|
|
125 |
char = self.character_states[name]
|
126 |
for key, value in updates.items():
|
127 |
if hasattr(char, key):
|
128 |
setattr(char, key, value)
|
|
|
129 |
char.last_seen_chapter = chapter
|
130 |
+
|
131 |
def add_plot_point(self, plot_point: PlotPoint):
|
132 |
"""플롯 포인트 추가"""
|
133 |
plot_point.timestamp = datetime.now().isoformat()
|
134 |
self.plot_points.append(plot_point)
|
135 |
+
|
136 |
+
def check_repetition(self, content: str, current_chapter: int) -> Tuple[bool, str]:
|
137 |
+
"""향상된 반복 내용 검사"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
138 |
sentences = re.split(r'[.!?]+', content)
|
139 |
for sentence in sentences:
|
140 |
+
sentence_strip = sentence.strip()
|
141 |
+
if len(sentence_strip) > 20: # 너무 짧은 문장은 무시
|
142 |
+
sentence_hash = hashlib.md5(sentence_strip.encode('utf-8')).hexdigest()
|
143 |
if sentence_hash in self.content_hashes:
|
144 |
+
previous_chapter = self.content_hashes[sentence_hash]
|
145 |
+
# 바로 이전 챕터와의 반복은 허용할 수 있으므로, 2챕터 이상 차이날 때만 오류로 간주
|
146 |
+
if current_chapter > previous_chapter + 1:
|
147 |
+
return True, f"문장 반복 (챕터 {previous_chapter}과 유사): {sentence_strip[:50]}..."
|
148 |
+
# 새 내용의 해시 추가
|
149 |
for sentence in sentences:
|
150 |
+
sentence_strip = sentence.strip()
|
151 |
+
if len(sentence_strip) > 20:
|
152 |
+
sentence_hash = hashlib.md5(sentence_strip.encode('utf-8')).hexdigest()
|
153 |
+
self.content_hashes[sentence_hash] = current_chapter
|
154 |
return False, ""
|
155 |
+
|
156 |
def validate_consistency(self, chapter: int, content: str) -> List[str]:
|
157 |
"""일관성 검증"""
|
158 |
errors = []
|
159 |
+
# 사망한 캐릭터 등장 검사
|
|
|
160 |
for char_name, char_state in self.character_states.items():
|
161 |
+
if char_name.lower() in content.lower() and not char_state.alive:
|
162 |
+
errors.append(f"⚠️ 사망한 캐릭터 '{char_name}'이(가) 등장했습니다.")
|
163 |
+
# 내용 반복 검사
|
164 |
+
is_repetition, repeat_msg = self.check_repetition(content, chapter)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
165 |
if is_repetition:
|
166 |
errors.append(f"🔄 {repeat_msg}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
167 |
return errors
|
168 |
+
|
169 |
def get_character_summary(self, chapter: int) -> str:
|
170 |
"""현재 챕터 기준 캐릭터 요약"""
|
171 |
+
summary = "\n=== 캐릭터 현황 요약 (이전 2개 챕터 기준) ===\n"
|
172 |
+
active_chars = [char for char in self.character_states.values() if char.last_seen_chapter >= chapter - 2]
|
173 |
+
if not active_chars:
|
174 |
+
return "\n(아직 주요 캐릭터 정보가 없습니다.)\n"
|
|
|
175 |
for char in active_chars:
|
176 |
status = "생존" if char.alive else "사망"
|
177 |
summary += f"• {char.name}: {status}"
|
178 |
+
if char.alive and char.location: summary += f" (위치: {char.location})"
|
179 |
+
if char.injuries: summary += f" (부상: {', '.join(char.injuries[-1:])})"
|
|
|
|
|
|
|
180 |
summary += "\n"
|
|
|
181 |
return summary
|
182 |
+
|
183 |
def get_plot_summary(self, chapter: int) -> str:
|
184 |
"""플롯 요약"""
|
185 |
+
summary = "\n=== 최근 주요 사건 요약 ===\n"
|
|
|
186 |
recent_events = [p for p in self.plot_points if p.chapter >= chapter - 2]
|
187 |
+
if not recent_events:
|
188 |
+
return "\n(아직 주요 사건이 없습니다.)\n"
|
189 |
+
for event in recent_events[-3:]: # 최근 3개만 표시
|
190 |
+
summary += f"• [챕터 {event.chapter}] {event.description}\n"
|
191 |
return summary
|
192 |
|
193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
194 |
class WebSearchIntegration:
|
195 |
"""웹 검색 기능 (감독자 단계에서만 사용)"""
|
|
|
196 |
def __init__(self):
|
197 |
self.brave_api_key = BRAVE_SEARCH_API_KEY
|
198 |
self.search_url = "https://api.search.brave.com/res/v1/web/search"
|
199 |
self.enabled = bool(self.brave_api_key)
|
200 |
+
|
201 |
def search(self, query: str, count: int = 3, language: str = "en") -> List[Dict]:
|
202 |
"""웹 검색 수행"""
|
203 |
if not self.enabled:
|
204 |
return []
|
|
|
205 |
headers = {
|
206 |
"Accept": "application/json",
|
207 |
"X-Subscription-Token": self.brave_api_key
|
208 |
}
|
|
|
|
|
|
|
209 |
params = {
|
210 |
"q": query,
|
211 |
"count": count,
|
212 |
+
"search_lang": "ko" if language == "Korean" else "en",
|
213 |
"text_decorations": False,
|
214 |
"safesearch": "moderate"
|
215 |
}
|
|
|
216 |
try:
|
217 |
response = requests.get(self.search_url, headers=headers, params=params, timeout=10)
|
218 |
+
response.raise_for_status()
|
219 |
+
results = response.json().get("web", {}).get("results", [])
|
220 |
+
logger.info(f"웹 검색 성공: '{query}'에 대해 {len(results)}개 결과 발견")
|
221 |
+
return results
|
222 |
+
except requests.exceptions.RequestException as e:
|
223 |
+
logger.error(f"웹 검색 API 오류: {e}")
|
|
|
|
|
|
|
|
|
224 |
return []
|
225 |
+
|
226 |
+
def extract_relevant_info(self, results: List[Dict], max_chars: int = 1500) -> str:
|
227 |
"""검색 결과에서 관련 정보 추출"""
|
228 |
if not results:
|
229 |
return ""
|
|
|
230 |
extracted = []
|
231 |
total_chars = 0
|
|
|
232 |
for i, result in enumerate(results[:3], 1):
|
233 |
title = result.get("title", "")
|
234 |
description = result.get("description", "")
|
235 |
url = result.get("url", "")
|
236 |
+
info = f"[{i}] {title}\n{description}\nSource: {url}\n"
|
|
|
|
|
237 |
if total_chars + len(info) < max_chars:
|
238 |
extracted.append(info)
|
239 |
total_chars += len(info)
|
240 |
else:
|
241 |
break
|
|
|
242 |
return "\n---\n".join(extracted)
|
243 |
|
244 |
|
245 |
class NovelDatabase:
|
246 |
"""소설 세션 관리 데이터베이스"""
|
|
|
247 |
@staticmethod
|
248 |
def init_db():
|
|
|
249 |
with sqlite3.connect(DB_PATH) as conn:
|
250 |
conn.execute("PRAGMA journal_mode=WAL")
|
251 |
cursor = conn.cursor()
|
|
|
|
|
252 |
cursor.execute('''
|
253 |
CREATE TABLE IF NOT EXISTS sessions (
|
254 |
session_id TEXT PRIMARY KEY,
|
|
|
262 |
consistency_report TEXT
|
263 |
)
|
264 |
''')
|
|
|
|
|
265 |
cursor.execute('''
|
266 |
CREATE TABLE IF NOT EXISTS stages (
|
267 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
279 |
UNIQUE(session_id, stage_number)
|
280 |
)
|
281 |
''')
|
|
|
|
|
282 |
cursor.execute('''
|
283 |
CREATE TABLE IF NOT EXISTS character_states (
|
284 |
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
294 |
FOREIGN KEY (session_id) REFERENCES sessions(session_id)
|
295 |
)
|
296 |
''')
|
|
|
|
|
297 |
cursor.execute('CREATE INDEX IF NOT EXISTS idx_session_id ON stages(session_id)')
|
298 |
cursor.execute('CREATE INDEX IF NOT EXISTS idx_stage_number ON stages(stage_number)')
|
299 |
cursor.execute('CREATE INDEX IF NOT EXISTS idx_char_session ON character_states(session_id)')
|
300 |
cursor.execute('CREATE INDEX IF NOT EXISTS idx_session_status ON sessions(status)')
|
|
|
301 |
conn.commit()
|
302 |
+
|
303 |
@staticmethod
|
304 |
@contextmanager
|
305 |
def get_db():
|
|
|
306 |
with db_lock:
|
307 |
conn = sqlite3.connect(DB_PATH, timeout=30.0)
|
308 |
conn.row_factory = sqlite3.Row
|
|
|
310 |
yield conn
|
311 |
finally:
|
312 |
conn.close()
|
313 |
+
|
314 |
@staticmethod
|
315 |
def create_session(user_query: str, language: str) -> str:
|
|
|
316 |
session_id = hashlib.md5(f"{user_query}{datetime.now()}".encode()).hexdigest()
|
|
|
317 |
with NovelDatabase.get_db() as conn:
|
318 |
+
conn.cursor().execute(
|
319 |
+
'INSERT INTO sessions (session_id, user_query, language) VALUES (?, ?, ?)',
|
320 |
+
(session_id, user_query, language)
|
321 |
+
)
|
|
|
322 |
conn.commit()
|
|
|
323 |
return session_id
|
324 |
+
|
325 |
@staticmethod
|
326 |
+
def save_stage(session_id: str, stage_number: int, stage_name: str,
|
327 |
+
role: str, content: str, status: str = 'complete',
|
328 |
consistency_score: float = 0.0):
|
|
|
329 |
word_count = len(content.split()) if content else 0
|
|
|
330 |
with NovelDatabase.get_db() as conn:
|
331 |
cursor = conn.cursor()
|
|
|
332 |
cursor.execute('''
|
333 |
INSERT INTO stages (session_id, stage_number, stage_name, role, content, word_count, status, consistency_score)
|
334 |
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
335 |
+
ON CONFLICT(session_id, stage_number)
|
336 |
DO UPDATE SET content=?, word_count=?, status=?, stage_name=?, consistency_score=?, updated_at=datetime('now')
|
337 |
''', (session_id, stage_number, stage_name, role, content, word_count, status, consistency_score,
|
338 |
content, word_count, status, stage_name, consistency_score))
|
339 |
+
cursor.execute(
|
340 |
+
"UPDATE sessions SET updated_at = datetime('now'), current_stage = ? WHERE session_id = ?",
|
341 |
+
(stage_number, session_id)
|
342 |
+
)
|
|
|
|
|
|
|
343 |
conn.commit()
|
344 |
+
|
345 |
@staticmethod
|
346 |
def get_session(session_id: str) -> Optional[Dict]:
|
|
|
347 |
with NovelDatabase.get_db() as conn:
|
348 |
+
row = conn.cursor().execute('SELECT * FROM sessions WHERE session_id = ?', (session_id,)).fetchone()
|
|
|
|
|
349 |
return dict(row) if row else None
|
350 |
+
|
351 |
@staticmethod
|
352 |
def get_latest_active_session() -> Optional[Dict]:
|
|
|
353 |
with NovelDatabase.get_db() as conn:
|
354 |
+
row = conn.cursor().execute("SELECT * FROM sessions WHERE status = 'active' ORDER BY updated_at DESC LIMIT 1").fetchone()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
355 |
return dict(row) if row else None
|
356 |
+
|
357 |
@staticmethod
|
358 |
def get_stages(session_id: str) -> List[Dict]:
|
|
|
359 |
with NovelDatabase.get_db() as conn:
|
360 |
+
rows = conn.cursor().execute('SELECT * FROM stages WHERE session_id = ? ORDER BY stage_number', (session_id,)).fetchall()
|
361 |
+
return [dict(row) for row in rows]
|
362 |
+
|
|
|
|
|
|
|
|
|
|
|
363 |
@staticmethod
|
364 |
def get_writer_content(session_id: str) -> str:
|
|
|
365 |
with NovelDatabase.get_db() as conn:
|
|
|
|
|
366 |
all_content = []
|
|
|
|
|
367 |
for writer_num in range(1, 11):
|
368 |
+
row = conn.cursor().execute(
|
369 |
+
"SELECT content FROM stages WHERE session_id = ? AND role = ? AND stage_name LIKE '%수정본%' ORDER BY stage_number DESC LIMIT 1",
|
370 |
+
(session_id, f'writer{writer_num}')
|
371 |
+
).fetchone()
|
|
|
|
|
|
|
|
|
|
|
372 |
if row and row['content']:
|
373 |
+
all_content.append(row['content'].strip())
|
|
|
|
|
|
|
374 |
return '\n\n'.join(all_content)
|
375 |
+
|
376 |
@staticmethod
|
377 |
def update_final_novel(session_id: str, final_novel: str, consistency_report: str = ""):
|
|
|
378 |
with NovelDatabase.get_db() as conn:
|
379 |
+
conn.cursor().execute(
|
380 |
+
"UPDATE sessions SET final_novel = ?, status = 'complete', updated_at = datetime('now'), consistency_report = ? WHERE session_id = ?",
|
381 |
+
(final_novel, consistency_report, session_id)
|
382 |
+
)
|
|
|
|
|
383 |
conn.commit()
|
384 |
+
|
385 |
@staticmethod
|
386 |
def get_active_sessions() -> List[Dict]:
|
|
|
387 |
with NovelDatabase.get_db() as conn:
|
388 |
+
rows = conn.cursor().execute(
|
389 |
+
"SELECT session_id, user_query, language, created_at, current_stage FROM sessions WHERE status = 'active' ORDER BY updated_at DESC LIMIT 10"
|
390 |
+
).fetchall()
|
391 |
+
return [dict(row) for row in rows]
|
|
|
|
|
|
|
|
|
|
|
392 |
|
393 |
|
394 |
class NovelWritingSystem:
|
395 |
"""최적화된 소설 생성 시스템"""
|
|
|
396 |
def __init__(self):
|
397 |
self.token = FRIENDLI_TOKEN
|
398 |
self.api_url = API_URL
|
399 |
self.model_id = MODEL_ID
|
|
|
|
|
400 |
self.consistency_tracker = ConsistencyTracker()
|
|
|
401 |
self.web_search = WebSearchIntegration()
|
|
|
|
|
402 |
self.current_session_id = None
|
|
|
|
|
403 |
NovelDatabase.init_db()
|
404 |
+
|
405 |
def create_headers(self):
|
406 |
"""API 헤더 생성"""
|
407 |
+
return {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
408 |
|
409 |
+
# --- 프롬프트 생성 함수들 (Thematic Guardian 개념 통합) ---
|
410 |
+
def create_director_initial_prompt(self, user_query: str, language: str) -> str:
|
411 |
+
"""감독자 초기 기획 프롬프트 (웹 검색 및 테마 제약 조건 강화)"""
|
412 |
+
search_results_str = ""
|
413 |
+
if self.web_search.enabled:
|
414 |
+
queries = [f"{user_query} novel setting", f"{user_query} background information"]
|
415 |
+
search_results = self.web_search.search(queries[0], count=2, language=language)
|
416 |
+
if search_results:
|
417 |
+
search_results_str = self.web_search.extract_relevant_info(search_results)
|
418 |
+
|
419 |
+
lang_prompts = {
|
420 |
+
"Korean": {
|
421 |
+
"title": "당신은 30페이지 분량의 중편 소설을 기획하는 문학 감독자입니다.",
|
422 |
+
"user_theme": "사용자 주제",
|
423 |
+
"plan_instruction": "다음 요소들을 포함한 상세한 소설 기획을 작성하세요:",
|
424 |
+
"theme_section": "1. **주제와 장르 설정**\n - 핵심 주제와 메시지 (사용자 의도 깊이 반영)\n - 장르 및 분위기\n - 독자층 고려사항",
|
425 |
+
"char_section": "2. **주요 등장인물** (3-5명)\n | 이름 | 역할 | 성격 | 배경 | 목표 | 갈등 |",
|
426 |
+
"setting_section": "3. **배경 설정**\n - 시공간적 배경\n - 사회적/문화적 환경\n - 주요 장소들",
|
427 |
+
"plot_section": "4. **플롯 구조** (10개 파트, 각 3페이지 분량)\n | 파트 | 페이지 | 주요 사건 | 긴장도 | ��릭터 발전 |",
|
428 |
+
"guideline_section": "5. **작가별 지침**\n - 일관성 유지를 위한 핵심 설정\n - 문체와 톤 가이드라인",
|
429 |
+
"constraint_title": "⚠️매우 중요한 지시사항: 핵심 제약 조건⚠️",
|
430 |
+
"constraint_body": "이 소설은 **AI로 인해 모든 것이 쉽게 해결되는 긍정적이고 단순한 이야기가 아닙니다.**\n반드시 사용자의 주제인 '{query}'에 담긴 **핵심 감정(예: 불안, 소외감, 상실감, 세대 갈등 등)을 중심으로 서사를 전개해야 합니다.**\nAI나 특정 기술은 편리한 도구가 아니라, 주인공에게 **갈등과 상실감을 안겨주는 핵심 원인**으로 작용해야 합니다.\n이 제약 조건을 절대 벗어나지 마십시오.",
|
431 |
+
"final_instruction": "창의적이고 깊이 있는 소설이 될 수 있도록 상세하게 기획하세요."
|
432 |
+
},
|
433 |
+
"English": {
|
434 |
+
"title": "You are a literary director planning a 30-page novella.",
|
435 |
+
"user_theme": "User Theme",
|
436 |
+
"plan_instruction": "Create a detailed novel plan including:",
|
437 |
+
"theme_section": "1. **Theme and Genre**\n - Core theme and message (Deeply reflect user's intent)\n - Genre and atmosphere",
|
438 |
+
"char_section": "2. **Main Characters** (3-5)\n | Name | Role | Personality | Background | Goal | Conflict |",
|
439 |
+
"setting_section": "3. **Setting**\n - Time and place\n - Social/cultural environment",
|
440 |
+
"plot_section": "4. **Plot Structure** (10 parts, ~3 pages each)\n | Part | Pages | Main Events | Tension | Character Development |",
|
441 |
+
"guideline_section": "5. **Writer Guidelines**\n - Key settings for consistency\n - Style and tone guidelines",
|
442 |
+
"constraint_title": "⚠️CRITICAL INSTRUCTION: CORE CONSTRAINTS⚠️",
|
443 |
+
"constraint_body": "This is **NOT a simple, positive story where AI solves everything.**\nYou must develop the narrative around the core emotions of the user's theme: '{query}' (e.g., anxiety, alienation, loss, generational conflict).\nAI or specific technology should be the **root cause of the protagonist's conflict and loss**, not a convenient tool.\nDo not deviate from this constraint.",
|
444 |
+
"final_instruction": "Plan in detail for a creative and profound novel."
|
445 |
+
}
|
446 |
+
}
|
447 |
+
p = lang_prompts[language]
|
448 |
+
return f"{p['title']}\n\n{p['user_theme']}: {user_query}\n\n{search_results_str}\n\n{p['plan_instruction']}\n\n{p['theme_section']}\n\n{p['char_section']}\n\n{p['setting_section']}\n\n{p['plot_section']}\n\n{p['guideline_section']}\n\n---\n{p['constraint_title']}\n{p['constraint_body'].format(query=user_query)}\n---\n\n{p['final_instruction']}"
|
449 |
+
|
450 |
+
def create_critic_director_prompt(self, director_plan: str, user_query: str, language: str) -> str:
|
451 |
+
"""비평가의 감독자 기획 검토 프롬프트 (테마 일관성 강화)"""
|
452 |
+
lang_prompts = {
|
453 |
+
"Korean": {
|
454 |
+
"title": "당신은 문학 비평가입니다. 감독자의 소설 기획을 '주제 일관성'과 '기술적 일관성' 관점에서 검토하세요.",
|
455 |
+
"theme_check": f"**1. 주제 일관성 (가장 중요)**\n - **원래 주제:** '{user_query}'\n - 기획안이 주제의 핵심 감정(불안, 상실감 등)에서 벗어나 긍정적이거나 단순한 방향으로 흐르지 않았습니까?\n - AI나 기술이 갈등의 원인이 아닌, 단순 해결사로 묘사되지 않았습니까?",
|
456 |
+
"consistency_check": "**2. 기술적 일관성**\n - 캐릭터 설정의 모순, 플롯의 논리적 허점, 시간선/공간 설정의 문제점을 검토하세요.",
|
457 |
+
"instruction": "위 항목들을 중심으로 구체적인 문제점과 개선안을 제시하세요."
|
458 |
+
},
|
459 |
+
"English": {
|
460 |
+
"title": "You are a literary critic. Review the director's plan from the perspectives of 'Thematic Consistency' and 'Technical Consistency'.",
|
461 |
+
"theme_check": f"**1. Thematic Consistency (Most Important)**\n - **Original Theme:** '{user_query}'\n - Does the plan drift from the core emotions (e.g., anxiety, loss) towards an overly positive or simplistic narrative?\n - Is AI depicted as a simple problem-solver instead of the root of the conflict?",
|
462 |
+
"consistency_check": "**2. Technical Consistency**\n - Review for character contradictions, plot holes, and timeline/setting issues.",
|
463 |
+
"instruction": "Provide specific problems and suggestions for improvement based on the above."
|
464 |
+
}
|
465 |
+
}
|
466 |
+
p = lang_prompts[language]
|
467 |
+
return f"{p['title']}\n\n**감독자 기획:**\n{director_plan}\n\n---\n**검토 항목:**\n{p['theme_check']}\n\n{p['consistency_check']}\n\n{p['instruction']}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
468 |
|
469 |
+
def create_director_revision_prompt(self, initial_plan: str, critic_feedback: str, user_query: str, language: str) -> str:
|
470 |
+
"""감독자 수정 프롬프트 (테마 제약 조건 재강조)"""
|
471 |
+
return f"""감독자로서 비평가의 피드백을 반영하여 소설 기획을 수정합니다.
|
472 |
+
|
473 |
+
**원래 주제:** {user_query}
|
474 |
+
**초기 기획:**\n{initial_plan}
|
475 |
+
**비평가 피드백:**\n{critic_feedback}
|
476 |
|
477 |
+
**수정 지침:**
|
478 |
+
- 비평가가 지적한 모든 일관성 문제와 주제 이탈 문제를 해결하세요.
|
479 |
+
- **핵심 제약 조건**을 다시 한번 상기하고, 소설 전체가 '불안'과 '상실감'의 톤을 유지하도록 플롯을 구체화하세요.
|
480 |
+
- 10명의 작가가 혼동 없이 작업할 수 있도록 명확하고 상세한 최종 마스터플랜을 작성하세요.
|
481 |
+
"""
|
482 |
|
483 |
+
def create_writer_prompt(self, writer_number: int, director_plan: str, previous_content_summary: str, user_query: str, language: str) -> str:
|
484 |
+
"""작가 프롬프트 (테마 리마인더 포함)"""
|
|
|
|
|
485 |
pages_start = (writer_number - 1) * 3 + 1
|
486 |
pages_end = writer_number * 3
|
487 |
|
488 |
+
lang_prompts = {
|
489 |
+
"Korean": {
|
490 |
+
"title": f"당신은 작가 {writer_number}번입니다. 소설의 {pages_start}-{pages_end} 페이지를 작성하세요.",
|
491 |
+
"plan": "감독자 마스터플랜",
|
492 |
+
"prev_summary": "이전 내용 요약",
|
493 |
+
"guidelines": "**작성 지침:**\n1. **분량**: 1,400-1,500 단어 내외\n2. **연결성**: 요약된 이전 내용과 자연스럽게 연결\n3. **일관성**: 캐릭터 설정과 상태, 플롯 구조를 반드시 따를 것",
|
494 |
+
"reminder_title": "⭐ 잊지 마세요 (테마 리마인더)",
|
495 |
+
"reminder_body": f"이 소설의 핵심은 '{user_query}'에 담긴 **불안, 소외, 상실감**입니다. 긍정적인 해결을 서두르지 말고, 주인공의 내면 갈등을 심도 있게 묘사하는 데 집중하세요.",
|
496 |
+
"final_instruction": "창의적이면서도 주제와 일관성을 절대 잃지 마십시오."
|
497 |
+
},
|
498 |
+
"English": {
|
499 |
+
"title": f"You are Writer #{writer_number}. Write pages {pages_start}-{pages_end} of the novella.",
|
500 |
+
"plan": "Director's Masterplan",
|
501 |
+
"prev_summary": "Previous Content Summary",
|
502 |
+
"guidelines": "**Writing Guidelines:**\n1. **Length**: Approx. 1,400-1,500 words\n2. **Connectivity**: Connect naturally with the summarized previous content.\n3. **Consistency**: Strictly follow character settings, states, and plot structure.",
|
503 |
+
"reminder_title": "⭐ REMINDER (THEME)",
|
504 |
+
"reminder_body": f"The core of this novel is the **anxiety, alienation, and loss** from the theme '{user_query}'. Do not rush to a positive resolution; focus on deeply describing the protagonist's internal conflict.",
|
505 |
+
"final_instruction": "Be creative, but never lose consistency and the core theme."
|
506 |
+
}
|
507 |
+
}
|
508 |
|
509 |
+
p = lang_prompts[language]
|
510 |
+
consistency_info = self.consistency_tracker.get_character_summary(writer_number) + self.consistency_tracker.get_plot_summary(writer_number)
|
511 |
+
|
512 |
+
return f"{p['title']}\n\n**{p['plan']}:**\n{director_plan}\n\n{consistency_info}\n\n**{p['prev_summary']}:**\n{previous_content_summary}\n\n---\n{p['guidelines']}\n\n**{p['reminder_title']}**\n{p['reminder_body']}\n---\n\n{p['final_instruction']}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
513 |
|
514 |
+
def create_critic_consistency_prompt(self, all_content: str, user_query: str, language: str) -> str:
|
515 |
+
"""비평가 중간 검토 프롬프트 (테마 검토 강화)"""
|
516 |
+
return f"""당신은 일관성 검토 전문 비평가입니다. 지금까지 작성된 내용을 검토하세요.
|
517 |
+
|
518 |
+
**원래 주제:** {user_query}
|
519 |
+
**현재까지 작성된 내용 (최근 3000자):**\n{all_content[-3000:]}
|
520 |
|
521 |
+
**검토 항목:**
|
522 |
+
1. **주제 일관성 (가장 중요):** 내용이 원래 주제의 어두운 감정선에서 벗어나지 않았는지 확인하고, 벗어났다면 수정 방향을 제시하세요.
|
523 |
+
2. **기술적 일관성:** 캐릭터, 플롯, 설정의 연속성과 논리적 오류를 찾아내세요.
|
524 |
+
3. **반복 내용:** 의미적으로 중복되는 장면이나 표현이 없는지 확인하세요.
|
525 |
|
526 |
+
**결과:** 발견된 문제점과 구체적인 수정 제안을 목록으로 제시하세요.
|
527 |
+
"""
|
|
|
|
|
528 |
|
529 |
+
def create_writer_revision_prompt(self, writer_number: int, initial_content: str, consistency_feedback: str, language: str) -> str:
|
|
|
|
|
|
|
530 |
"""작가 수정 프롬프트"""
|
531 |
+
return f"""작가 {writer_number}번으로서 비평가의 피드백을 반영하여 내용을 수정하세요.
|
532 |
+
|
533 |
+
**초기 작성 내용:**\n{initial_content}
|
534 |
+
**비평가 피드백:**\n{consistency_feedback}
|
535 |
+
|
536 |
+
**수정 지침:**
|
537 |
+
- 지적된 모든 주제 이탈 및 일관성 문제를 해결하세요.
|
538 |
+
- 분량(1,400-1,500 단어)을 유지하면서 내용의 질을 높이세요.
|
539 |
+
- 수정된 최종 버전을 제시하세요.
|
540 |
+
"""
|
541 |
+
|
542 |
+
def create_critic_final_prompt(self, complete_novel: str, language: str) -> str:
|
543 |
+
"""최종 비평가 검토 및 보고서 작성 프롬프트"""
|
544 |
+
return f"""완성된 소설의 최종 일관성 및 완성도에 대한 종합 보고서를 작성하세요.
|
545 |
+
|
546 |
+
**완성된 소설 (마지막 2000자):**\n{complete_novel[-2000:]}
|
547 |
+
|
548 |
+
**보고서 포함 항목:**
|
549 |
+
1. **전체 일관성 평가:** 캐릭터, 플롯, 설정, 주제 유지에 대한 점수(1-10)와 총평.
|
550 |
+
2. **최종 발견된 문제점:** 남아있는 사소한 문제점들.
|
551 |
+
3. **성공 요소:** 특히 잘 유지된 일관성 부분이나 주제 표현이 뛰어난 부분.
|
552 |
+
4. **최종 평가:** 소설의 전반적인 완성도와 독자에게 미칠 영향에 대한 평가.
|
553 |
+
"""
|
554 |
+
|
555 |
+
# --- LLM 호출 함수들 ---
|
556 |
+
def call_llm_sync(self, messages: List[Dict[str, str]], role: str, language: str) -> str:
|
557 |
+
"""LLM 동기식 호출 (요약 등 내부용)"""
|
558 |
+
full_content = ""
|
559 |
+
for chunk in self.call_llm_streaming(messages, role, language):
|
560 |
+
full_content += chunk
|
561 |
+
if full_content.startswith("❌"):
|
562 |
+
raise Exception(f"LLM Sync Call Failed: {full_content}")
|
563 |
+
return full_content
|
564 |
+
|
565 |
+
def call_llm_streaming(self, messages: List[Dict[str, str]], role: str, language: str) -> Generator[str, None, None]:
|
566 |
+
"""LLM 스트리밍 호출 (완전한 에러 처리 및 디버깅)"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
567 |
try:
|
568 |
system_prompts = self.get_system_prompts(language)
|
569 |
+
full_messages = [{"role": "system", "content": system_prompts.get(role, "You are a helpful assistant.")}, *messages]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
570 |
|
571 |
payload = {
|
572 |
"model": self.model_id,
|
573 |
"messages": full_messages,
|
574 |
+
"max_tokens": 10000,
|
575 |
+
"temperature": 0.75,
|
576 |
"top_p": 0.9,
|
577 |
+
"presence_penalty": 0.3,
|
578 |
+
"frequency_penalty": 0.2,
|
579 |
"stream": True,
|
580 |
"stream_options": {"include_usage": True}
|
581 |
}
|
582 |
|
583 |
+
logger.info(f"[{role}] API 스트리밍 시작")
|
584 |
+
|
585 |
+
# API 호출
|
586 |
response = requests.post(
|
587 |
+
self.api_url,
|
588 |
+
headers=self.create_headers(),
|
589 |
+
json=payload,
|
590 |
+
stream=True,
|
591 |
+
timeout=180
|
592 |
)
|
593 |
|
594 |
+
# 상태 코드 확인
|
595 |
if response.status_code != 200:
|
596 |
+
logger.error(f"API 응답 오류: {response.status_code}")
|
597 |
+
logger.error(f"응답 내용: {response.text[:500]}")
|
598 |
+
yield f"❌ API 오류 (상태 코드: {response.status_code})"
|
599 |
return
|
600 |
|
601 |
+
response.raise_for_status()
|
602 |
+
|
603 |
+
# 스트리밍 처리
|
604 |
buffer = ""
|
605 |
total_content = ""
|
606 |
+
chunk_count = 0
|
607 |
+
error_count = 0
|
608 |
+
|
609 |
for line in response.iter_lines():
|
610 |
+
if not line:
|
611 |
+
continue
|
612 |
+
|
613 |
+
try:
|
614 |
+
line_str = line.decode('utf-8').strip()
|
615 |
+
|
616 |
+
# SSE 형식 확인
|
617 |
+
if not line_str.startswith("data: "):
|
618 |
+
continue
|
619 |
+
|
620 |
+
data_str = line_str[6:] # "data: " 제거
|
621 |
+
|
622 |
+
# 스트림 종료 확인
|
623 |
+
if data_str == "[DONE]":
|
624 |
+
logger.info(f"[{role}] 스트리밍 완료 - 총 {len(total_content)} 문자")
|
625 |
+
break
|
626 |
+
|
627 |
+
# JSON 파싱
|
628 |
+
try:
|
629 |
+
data = json.loads(data_str)
|
630 |
+
except json.JSONDecodeError:
|
631 |
+
logger.warning(f"JSON 파싱 실패: {data_str[:100]}")
|
632 |
+
continue
|
633 |
+
|
634 |
+
# choices 배열 안전하게 확인
|
635 |
+
choices = data.get("choices", None)
|
636 |
+
if not choices or not isinstance(choices, list) or len(choices) == 0:
|
637 |
+
# 에러 응답 확인
|
638 |
+
if "error" in data:
|
639 |
+
error_msg = data.get("error", {}).get("message", "Unknown error")
|
640 |
+
logger.error(f"API 에러: {error_msg}")
|
641 |
+
yield f"❌ API 에러: {error_msg}"
|
642 |
+
return
|
643 |
+
continue
|
644 |
+
|
645 |
+
# delta에서 content 추출
|
646 |
+
delta = choices[0].get("delta", {})
|
647 |
+
content = delta.get("content", "")
|
648 |
+
|
649 |
+
if content:
|
650 |
+
buffer += content
|
651 |
+
total_content += content
|
652 |
+
chunk_count += 1
|
653 |
+
|
654 |
+
# 100자 또는 줄바꿈마다 yield
|
655 |
+
if len(buffer) >= 100 or '\n' in buffer:
|
656 |
+
yield buffer
|
657 |
+
buffer = ""
|
658 |
+
time.sleep(0.01) # UI 업데이트를 위한 짧은 대기
|
659 |
+
|
660 |
+
except Exception as e:
|
661 |
+
error_count += 1
|
662 |
+
logger.error(f"청크 처리 오류 #{error_count}: {str(e)}")
|
663 |
+
if error_count > 10: # 너무 많은 에러시 중단
|
664 |
+
yield f"❌ 스트리밍 중 과도한 오류 발생"
|
665 |
+
return
|
666 |
+
continue
|
667 |
|
668 |
+
# 남은 버퍼 처리
|
669 |
if buffer:
|
670 |
yield buffer
|
671 |
+
|
672 |
+
# 결과 확인
|
673 |
+
if chunk_count == 0:
|
674 |
+
logger.error(f"[{role}] 콘텐츠가 전혀 수신되지 않음")
|
675 |
+
yield "❌ API로부터 응답을 받지 못했습니다."
|
676 |
+
else:
|
677 |
+
logger.info(f"[{role}] 성공적으로 {chunk_count}개 청크, 총 {len(total_content)}자 수신")
|
678 |
|
679 |
+
except requests.exceptions.Timeout:
|
680 |
+
logger.error("API 요청 시간 초과")
|
681 |
+
yield "❌ API 요청 시간이 초과되었습니다."
|
682 |
+
except requests.exceptions.ConnectionError:
|
683 |
+
logger.error("API 연결 실패")
|
684 |
+
yield "❌ API 서버에 연결할 수 없습니다."
|
685 |
except Exception as e:
|
686 |
+
logger.error(f"예기치 않은 오류: {type(e).__name__}: {str(e)}", exc_info=True)
|
687 |
yield f"❌ 오류 발생: {str(e)}"
|
688 |
+
|
689 |
+
|
690 |
+
|
691 |
+
|
692 |
+
|
693 |
def get_system_prompts(self, language: str) -> Dict[str, str]:
|
694 |
+
"""역할별 시스템 프롬프트 생성"""
|
695 |
+
base_prompts = {
|
696 |
+
"Korean": {
|
697 |
"director": "당신은 창의적이고 체계적인 소설 기획 전문가입니다. 흥미롭고 일관성 있는 스토리를 설계하세요.",
|
698 |
"critic": "당신은 일관성 검토 전문 비평가입니다. 캐릭터, 플롯, 설정의 일관성을 철저히 점검하고 개선방안을 제시하세요.",
|
699 |
+
"writer_base": "당신은 전문 소설 작가입니다. 주어진 지침에 따라 몰입감 있고 일관성 있는 내용을 작성하세요."
|
700 |
+
},
|
701 |
+
"English": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
702 |
"director": "You are a creative and systematic novel planning expert. Design engaging and consistent stories.",
|
703 |
"critic": "You are a consistency review specialist critic. Thoroughly check character, plot, and setting consistency and suggest improvements.",
|
704 |
+
"writer_base": "You are a professional novel writer. Write immersive and consistent content according to the given guidelines."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
705 |
}
|
706 |
+
}
|
707 |
+
|
708 |
+
prompts = base_prompts[language].copy()
|
709 |
+
|
710 |
+
# 작가별 특수 프롬프트
|
711 |
+
if language == "Korean":
|
712 |
+
prompts["writer1"] = "당신은 소설의 매력적인 시작을 담당하는 작가입니다. 독자를 사로잡는 도입부를 만드세요."
|
713 |
+
prompts["writer10"] = "당신은 완벽한 결말을 만드는 작가입니다. 독자에게 깊은 여운을 남기는 마무리를 하세요."
|
714 |
+
else:
|
715 |
+
prompts["writer1"] = "You are a writer responsible for the captivating beginning. Create an opening that hooks readers."
|
716 |
+
prompts["writer10"] = "You are a writer who creates the perfect ending. Create a conclusion that leaves readers with deep resonance."
|
717 |
+
|
718 |
+
# writer2-9는 기본 프롬프트 사용
|
719 |
+
for i in range(2, 10):
|
720 |
+
prompts[f"writer{i}"] = prompts["writer_base"]
|
721 |
+
|
722 |
+
return prompts
|
723 |
+
|
724 |
+
# --- 메인 프로세스 ---
|
725 |
+
def process_novel_stream(self, query: str, language: str, session_id: Optional[str] = None) -> Generator[Tuple[str, List[Dict[str, Any]], str], None, None]:
|
726 |
+
"""소설 생성 스트리밍 프로세스 (강화된 로직)"""
|
727 |
try:
|
728 |
+
resume_from_stage = 0
|
|
|
|
|
729 |
if session_id:
|
730 |
self.current_session_id = session_id
|
731 |
session = NovelDatabase.get_session(session_id)
|
|
|
736 |
logger.info(f"Resuming session {session_id} from stage {resume_from_stage}")
|
737 |
else:
|
738 |
self.current_session_id = NovelDatabase.create_session(query, language)
|
|
|
739 |
logger.info(f"Created new session: {self.current_session_id}")
|
740 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
741 |
stages = []
|
742 |
if resume_from_stage > 0:
|
743 |
+
stages = [{
|
744 |
+
"name": s['stage_name'], "status": s['status'], "content": s.get('content', ''),
|
745 |
+
"consistency_score": s.get('consistency_score', 0.0)
|
746 |
+
} for s in NovelDatabase.get_stages(self.current_session_id)]
|
747 |
+
|
|
|
|
|
|
|
|
|
|
|
748 |
for stage_idx in range(resume_from_stage, len(OPTIMIZED_STAGES)):
|
749 |
role, stage_name = OPTIMIZED_STAGES[stage_idx]
|
|
|
|
|
750 |
if stage_idx >= len(stages):
|
751 |
+
stages.append({"name": stage_name, "status": "active", "content": "", "consistency_score": 0.0})
|
|
|
|
|
|
|
|
|
|
|
752 |
else:
|
753 |
stages[stage_idx]["status"] = "active"
|
754 |
+
|
755 |
+
yield "", stages, self.current_session_id
|
|
|
|
|
756 |
prompt = self.get_stage_prompt(stage_idx, role, query, language, stages)
|
|
|
|
|
757 |
stage_content = ""
|
758 |
+
for chunk in self.call_llm_streaming([{"role": "user", "content": prompt}], role, language):
|
|
|
|
|
|
|
|
|
759 |
stage_content += chunk
|
760 |
stages[stage_idx]["content"] = stage_content
|
761 |
+
yield "", stages, self.current_session_id
|
762 |
+
|
|
|
763 |
consistency_score = 0.0
|
764 |
if role.startswith("writer"):
|
765 |
+
writer_num = int(re.search(r'\d+', role).group())
|
766 |
+
all_previous = self.get_all_content(stages, stage_idx)
|
767 |
+
errors = self.consistency_tracker.validate_consistency(writer_num, stage_content)
|
768 |
+
consistency_score = max(0, 10 - len(errors) * 2)
|
|
|
|
|
|
|
|
|
769 |
stages[stage_idx]["consistency_score"] = consistency_score
|
770 |
+
|
|
|
|
|
|
|
|
|
771 |
stages[stage_idx]["status"] = "complete"
|
|
|
|
|
772 |
NovelDatabase.save_stage(
|
773 |
+
self.current_session_id, stage_idx, stage_name, role,
|
774 |
+
stage_content, "complete", consistency_score
|
|
|
|
|
|
|
|
|
|
|
775 |
)
|
776 |
+
yield "", stages, self.current_session_id
|
777 |
+
|
778 |
+
final_novel = NovelDatabase.get_writer_content(self.current_session_id)
|
779 |
+
final_report = self.generate_consistency_report(final_novel, language)
|
780 |
+
NovelDatabase.update_final_novel(self.current_session_id, final_novel, final_report)
|
781 |
+
yield f"✅ 소설 완성! 총 {len(final_novel.split())}단어", stages, self.current_session_id
|
782 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
783 |
except Exception as e:
|
784 |
+
logger.error(f"소설 생성 프로세스 오류: {e}", exc_info=True)
|
785 |
+
yield f"❌ 오류 발생: {e}", stages if 'stages' in locals() else [], self.current_session_id
|
786 |
+
|
787 |
+
def get_stage_prompt(self, stage_idx: int, role: str, query: str, language: str, stages: List[Dict]) -> str:
|
788 |
+
"""단계별 프롬프트 생성 (요약 기능 및 주제 전달 강화)"""
|
789 |
+
if stage_idx == 0:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
790 |
return self.create_director_initial_prompt(query, language)
|
791 |
+
if stage_idx == 1:
|
792 |
+
return self.create_critic_director_prompt(stages[0]["content"], query, language)
|
793 |
+
if stage_idx == 2:
|
794 |
+
return self.create_director_revision_prompt(stages[0]["content"], stages[1]["content"], query, language)
|
795 |
+
|
796 |
+
master_plan = stages[2]["content"]
|
797 |
+
|
798 |
+
if 3 <= stage_idx <= 12: # 작가 초안
|
799 |
writer_num = stage_idx - 2
|
800 |
+
previous_content = self.get_all_content(stages, stage_idx)
|
801 |
+
summary = self.create_summary(previous_content, language)
|
802 |
+
return self.create_writer_prompt(writer_num, master_plan, summary, query, language)
|
803 |
+
|
804 |
+
if stage_idx == 13: # 비평가 중간 검토
|
805 |
all_content = self.get_all_content(stages, stage_idx)
|
806 |
+
return self.create_critic_consistency_prompt(all_content, query, language)
|
807 |
+
|
808 |
+
if 14 <= stage_idx <= 23: # 작가 수정
|
809 |
writer_num = stage_idx - 13
|
810 |
initial_content = stages[2 + writer_num]["content"]
|
811 |
+
feedback = stages[13]["content"]
|
812 |
+
return self.create_writer_revision_prompt(writer_num, initial_content, feedback, language)
|
813 |
+
|
814 |
+
if stage_idx == 24: # 최종 검토
|
815 |
complete_novel = self.get_all_writer_content(stages)
|
816 |
return self.create_critic_final_prompt(complete_novel, language)
|
817 |
|
818 |
return ""
|
819 |
+
|
820 |
+
def create_summary(self, content: str, language: str) -> str:
|
821 |
+
"""LLM을 이용해 이전 내용을 요약"""
|
822 |
+
if not content.strip():
|
823 |
+
return "이전 내용이 없습니다." if language == "Korean" else "No previous content."
|
824 |
+
|
825 |
+
prompt_text = "다음 소설 내용을 3~5개의 핵심적인 문장으로 요약해줘. 다음 작가가 이야기를 이어가는 데 필요한 핵심 정보(등장인물의 현재 상황, 감정, 마지막 사건)를 포함해야 해."
|
826 |
+
if language != "Korean":
|
827 |
+
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, and the last major event)."
|
828 |
+
|
829 |
+
summary_prompt = f"{prompt_text}\n\n---\n{content[-2000:]}"
|
830 |
+
try:
|
831 |
+
summary = self.call_llm_sync([{"role": "user", "content": summary_prompt}], "critic", language)
|
832 |
+
return summary
|
833 |
+
except Exception as e:
|
834 |
+
logger.error(f"요약 생성 실패: {e}")
|
835 |
+
return content[-1000:]
|
836 |
+
|
837 |
def get_all_content(self, stages: List[Dict], current_stage: int) -> str:
|
838 |
+
"""현재까지의 모든 내용 가져오기"""
|
839 |
+
return "\n\n".join(s["content"] for i, s in enumerate(stages) if i < current_stage and s["content"])
|
840 |
+
|
|
|
|
|
|
|
|
|
841 |
def get_all_writer_content(self, stages: List[Dict]) -> str:
|
842 |
+
"""모든 작가 최종 수정본 내용 가져오기"""
|
843 |
+
return "\n\n".join(s["content"] for i, s in enumerate(stages) if 14 <= i <= 23 and s["content"])
|
844 |
+
|
|
|
|
|
|
|
|
|
845 |
def generate_consistency_report(self, complete_novel: str, language: str) -> str:
|
846 |
+
"""최종 보고서 생성 (LLM 호출)"""
|
847 |
+
prompt = self.create_critic_final_prompt(complete_novel, language)
|
848 |
+
try:
|
849 |
+
report = self.call_llm_sync([{"role": "user", "content": prompt}], "critic", language)
|
850 |
+
return report
|
851 |
+
except Exception as e:
|
852 |
+
logger.error(f"최종 보고서 생성 실패: {e}")
|
853 |
+
return "보고서 생성 중 오류 발생"
|
|
|
|
|
|
|
|
|
|
|
854 |
|
855 |
|
856 |
+
# --- 유틸리티 함수들 ---
|
857 |
+
def process_query(query: str, language: str, session_id: Optional[str] = None) -> Generator[Tuple[str, str, str, str], None, None]:
|
858 |
+
"""메인 쿼리 처리 함수"""
|
859 |
+
if not query.strip():
|
860 |
+
yield "", "", "❌ 주제를 입력해주세요.", session_id
|
|
|
|
|
|
|
861 |
return
|
862 |
|
863 |
system = NovelWritingSystem()
|
864 |
+
stages_markdown = ""
|
865 |
+
novel_content = ""
|
866 |
|
867 |
+
for status, stages, current_session_id in system.process_novel_stream(query, language, session_id):
|
868 |
+
stages_markdown = format_stages_display(stages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
869 |
|
870 |
+
# 최종 소설 내용 가져오기
|
871 |
+
if stages and all(s.get("status") == "complete" for s in stages[-10:]):
|
872 |
+
novel_content = NovelDatabase.get_writer_content(current_session_id)
|
873 |
+
novel_content = format_novel_display(novel_content)
|
|
|
|
|
|
|
|
|
|
|
|
|
874 |
|
875 |
+
yield stages_markdown, novel_content, status or "🔄 처리 중...", current_session_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
876 |
|
877 |
+
def get_active_sessions(language: str) -> List[str]:
|
878 |
"""활성 세션 목록 가져오기"""
|
879 |
+
sessions = NovelDatabase.get_active_sessions()
|
880 |
+
return [f"{s['session_id'][:8]}... - {s['user_query'][:50]}... ({s['created_at']})"
|
881 |
+
for s in sessions]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
882 |
|
883 |
+
def auto_recover_session(language: str) -> Tuple[Optional[str], str]:
|
884 |
+
"""가장 최근 활성 세션 자동 복구"""
|
885 |
+
latest_session = NovelDatabase.get_latest_active_session()
|
886 |
+
if latest_session:
|
887 |
+
return latest_session['session_id'], f"세션 {latest_session['session_id'][:8]}... 복구됨"
|
888 |
+
return None, "복구할 세션이 없습니다."
|
889 |
|
890 |
def resume_session(session_id: str, language: str) -> Generator[Tuple[str, str, str, str], None, None]:
|
891 |
+
"""세션 재개 함수"""
|
892 |
if not session_id:
|
893 |
+
yield "", "", "❌ 세션 ID가 없습니다.", session_id
|
|
|
|
|
|
|
894 |
return
|
895 |
|
896 |
+
# 드롭다운에서 세션 ID 추출
|
897 |
+
if "..." in session_id:
|
898 |
+
session_id = session_id.split("...")[0]
|
899 |
+
|
900 |
+
session = NovelDatabase.get_session(session_id)
|
901 |
+
if not session:
|
902 |
+
yield "", "", "❌ 세션을 찾을 수 없습니다.", None
|
903 |
+
return
|
904 |
+
|
905 |
+
# process_query를 통해 재개
|
906 |
+
yield from process_query(session['user_query'], session['language'], session_id)
|
907 |
|
908 |
+
def download_novel(novel_text: str, format_type: str, language: str, session_id: str) -> Optional[str]:
|
909 |
+
"""소설 다운로드 파일 생성"""
|
910 |
+
if not novel_text or not session_id:
|
911 |
+
return None
|
912 |
+
|
913 |
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
914 |
+
filename = f"novel_{session_id[:8]}_{timestamp}"
|
915 |
+
|
916 |
try:
|
917 |
+
if format_type == "DOCX" and DOCX_AVAILABLE:
|
918 |
+
return export_to_docx(novel_text, filename, language)
|
|
|
|
|
|
|
|
|
|
|
919 |
else:
|
920 |
+
return export_to_txt(novel_text, filename)
|
921 |
except Exception as e:
|
922 |
+
logger.error(f"파일 생성 실패: {e}")
|
923 |
+
return None
|
924 |
|
925 |
+
def format_stages_display(stages: List[Dict]) -> str:
|
926 |
+
"""단계별 진행 상황 마크다운 포맷팅"""
|
927 |
+
markdown = "## 🎬 진행 상황\n\n"
|
928 |
+
for i, stage in enumerate(stages):
|
929 |
+
status_icon = "✅" if stage['status'] == 'complete' else "🔄" if stage['status'] == 'active' else "⏳"
|
930 |
+
markdown += f"{status_icon} **{stage['name']}**"
|
931 |
+
if stage.get('consistency_score', 0) > 0:
|
932 |
+
markdown += f" (일관성: {stage['consistency_score']:.1f}/10)"
|
933 |
+
markdown += "\n"
|
934 |
+
if stage['content']:
|
935 |
+
preview = stage['content'][:200] + "..." if len(stage['content']) > 200 else stage['content']
|
936 |
+
markdown += f"> {preview}\n\n"
|
937 |
+
return markdown
|
938 |
+
|
939 |
+
def format_novel_display(novel_text: str) -> str:
|
940 |
+
"""소설 내용 마크다운 포맷팅"""
|
941 |
+
if not novel_text:
|
942 |
+
return "아직 완성된 내용이 없습니다."
|
943 |
+
|
944 |
+
# 페이지 구분 추가
|
945 |
+
pages = novel_text.split('\n\n')
|
946 |
+
formatted = "# 📖 완성된 소설\n\n"
|
947 |
+
|
948 |
+
for i, page in enumerate(pages):
|
949 |
+
if page.strip():
|
950 |
+
formatted += f"### 페이지 {i+1}\n\n{page}\n\n---\n\n"
|
951 |
+
|
952 |
+
return formatted
|
953 |
|
954 |
+
def export_to_docx(content: str, filename: str, language: str) -> str:
|
955 |
+
"""DOCX 파일로 내보내기"""
|
956 |
+
doc = Document()
|
|
|
|
|
957 |
|
958 |
+
# 제목 추가
|
959 |
+
title = doc.add_heading('AI 협업 소설', 0)
|
960 |
+
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
|
|
961 |
|
962 |
+
# 메타데이터
|
963 |
+
doc.add_paragraph(f"생성일: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
964 |
+
doc.add_paragraph(f"언어: {language}")
|
965 |
+
doc.add_page_break()
|
|
|
966 |
|
967 |
+
# 본문 추가
|
968 |
+
paragraphs = content.split('\n\n')
|
969 |
+
for para in paragraphs:
|
970 |
+
if para.strip():
|
971 |
+
doc.add_paragraph(para.strip())
|
972 |
|
973 |
+
# 파일 저장
|
974 |
+
filepath = f"{filename}.docx"
|
975 |
+
doc.save(filepath)
|
976 |
+
return filepath
|
977 |
+
|
978 |
+
def export_to_txt(content: str, filename: str) -> str:
|
979 |
+
"""TXT 파일로 내보내기"""
|
980 |
+
filepath = f"{filename}.txt"
|
981 |
+
with open(filepath, 'w', encoding='utf-8') as f:
|
982 |
+
f.write(content)
|
983 |
+
return filepath
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
984 |
|
985 |
|
986 |
# CSS 스타일
|