diff --git a/config.py b/config.py index 883f895..f02383b 100644 --- a/config.py +++ b/config.py @@ -65,4 +65,4 @@ LENGTH_DEFINITIONS = { } # --- SYSTEM --- -VERSION = "1.3.1" \ No newline at end of file +VERSION = "1.4.0" \ No newline at end of file diff --git a/main.py b/main.py index dbac2bc..e5b9467 100644 --- a/main.py +++ b/main.py @@ -97,10 +97,11 @@ def process_book(bp, folder, context="", resume=False, interactive=False): summary = "The story begins." if ms: - # Generate summary from ALL written chapters to maintain continuity - utils.log("RESUME", "Rebuilding 'Story So Far' from existing manuscript...") - try: - combined_text = "\n".join([f"Chapter {c['num']}: {c['content']}" for c in ms]) + # Efficient rebuild: first chapter (setup) + last 4 (recent events) avoids huge prompts + utils.log("RESUME", f"Rebuilding story context from {len(ms)} existing chapters...") + try: + selected = ms[:1] + ms[-4:] if len(ms) > 5 else ms + combined_text = "\n".join([f"Chapter {c['num']}: {c['content'][:3000]}" for c in selected]) resp_sum = ai.model_writer.generate_content(f""" ROLE: Series Historian TASK: Create a cumulative 'Story So Far' summary. @@ -133,13 +134,20 @@ def process_book(bp, folder, context="", resume=False, interactive=False): if any(str(c.get('num')) == str(ch['chapter_number']) for c in ms): i += 1 continue - + + # Progress Banner — update bar and log chapter header before writing begins + utils.update_progress(15 + int((i / len(chapters)) * 75)) + utils.log_banner("WRITER", f"Chapter {ch['chapter_number']}/{len(chapters)}: {ch['title']}") + # Pass previous chapter content for continuity if available prev_content = ms[-1]['content'] if ms else None while True: try: - txt = story.write_chapter(ch, bp, folder, summary, tracking, prev_content) + # Cap summary to most-recent 8000 chars; pass next chapter title as hook hint + summary_ctx = summary[-8000:] if len(summary) > 8000 else summary + next_hint = chapters[i+1]['title'] if i + 1 < len(chapters) else "" + txt = story.write_chapter(ch, bp, folder, summary_ctx, tracking, prev_content, next_chapter_hint=next_hint) except Exception as e: utils.log("SYSTEM", f"Chapter generation failed: {e}") if interactive: @@ -156,8 +164,8 @@ def process_book(bp, folder, context="", resume=False, interactive=False): else: break - # Refine Persona to match the actual output (Consistency Loop) - if (i == 0 or i % 3 == 0) and txt: + # Refine Persona to match the actual output (every 5 chapters to save API calls) + if (i == 0 or i % 5 == 0) and txt: bp['book_metadata']['author_details'] = story.refine_persona(bp, txt, folder) with open(bp_path, "w") as f: json.dump(bp, f, indent=2) @@ -207,9 +215,9 @@ def process_book(bp, folder, context="", resume=False, interactive=False): with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2) with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2) - # --- DYNAMIC PACING CHECK --- + # --- DYNAMIC PACING CHECK (every other chapter to halve API overhead) --- remaining = chapters[i+1:] - if remaining: + if remaining and len(remaining) >= 2 and i % 2 == 1: pacing = story.check_pacing(bp, summary, txt, ch, remaining, folder) if pacing and pacing.get('status') == 'add_bridge': new_data = pacing.get('new_chapter', {}) @@ -237,10 +245,12 @@ def process_book(bp, folder, context="", resume=False, interactive=False): removed = chapters.pop(i+1) # Renumber subsequent chapters for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1 - + with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2) utils.log("ARCHITECT", f" -> ⚠️ Pacing Intervention: Removed redundant chapter '{removed['title']}'.") - + elif pacing: + utils.log("ARCHITECT", f" -> Pacing OK. {pacing.get('reason', '')[:100]}") + # Increment loop i += 1 @@ -254,7 +264,8 @@ def process_book(bp, folder, context="", resume=False, interactive=False): prog = 15 + int((i / len(chapters)) * 75) utils.update_progress(prog) - utils.log("TIMING", f" -> Chapter {ch['chapter_number']} finished in {duration:.1f}s | Avg: {avg_time:.1f}s | ETA: {int(eta//60)}m {int(eta%60)}s") + word_count = len(txt.split()) if txt else 0 + utils.log("TIMING", f" -> Ch {ch['chapter_number']} done in {duration:.1f}s | {word_count:,} words | Avg: {avg_time:.1f}s | ETA: {int(eta//60)}m {int(eta%60)}s") utils.log("TIMING", f"Writing Phase: {time.time() - t_step:.1f}s") diff --git a/modules/story.py b/modules/story.py index 64cf00e..2c6cfa6 100644 --- a/modules/story.py +++ b/modules/story.py @@ -411,7 +411,7 @@ def update_tracking(folder, chapter_num, chapter_text, current_tracking): {json.dumps(current_tracking)} NEW_TEXT: - {chapter_text[:500000]} + {chapter_text[:20000]} OPERATIONS: 1. EVENTS: Append 1-3 key plot points to 'events'. @@ -594,7 +594,7 @@ def refine_persona(bp, text, folder): except: pass return ad -def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None): +def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None, next_chapter_hint=""): pacing = chap.get('pacing', 'Standard') est_words = chap.get('estimated_words', 'Flexible') utils.log("WRITER", f"Drafting Ch {chap['chapter_number']} ({pacing} | ~{est_words} words): {chap['title']}") @@ -662,6 +662,13 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None): trunc_content = prev_content[-3000:] if len(prev_content) > 3000 else prev_content prev_context_block = f"\nPREVIOUS CHAPTER TEXT (For Tone & Continuity):\n{trunc_content}\n" + # Strip future planning notes (key_events) from character context — the writer + # should not know what is *planned* to happen; only name, role, and description. + chars_for_writer = [ + {"name": c.get("name"), "role": c.get("role"), "description": c.get("description", "")} + for c in bp.get('characters', []) + ] + total_chapters = ls.get('chapters', '?') prompt = f""" ROLE: Fiction Writer @@ -705,7 +712,9 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None): - CHARACTER INTERACTIONS: If characters are meeting for the first time in the summary, treat them as strangers. - SENTENCE VARIETY: Avoid repetitive sentence structures (e.g. starting multiple sentences with "He" or "She"). Vary sentence length to create rhythm. - GENRE CONSISTENCY: Ensure all introductions of characters, places, items, or actions are strictly appropriate for the {genre} genre. Avoid anachronisms or tonal clashes. - + - DIALOGUE VOICE: Every character speaks with their own distinct voice (see CHARACTER TRACKING for speech styles). No two characters may sound the same. Vary sentence length, vocabulary, and register per character. + - CHAPTER HOOK: End this chapter with unresolved tension — a decision pending, a threat imminent, or a question unanswered.{f" Seed subtle anticipation for the next scene: '{next_chapter_hint}'." if next_chapter_hint else " Do not neatly resolve all threads."} + QUALITY_CRITERIA: 1. ENGAGEMENT & TENSION: Grip the reader. Ensure conflict/tension in every scene. 2. SCENE EXECUTION: Flesh out the middle. Avoid summarizing key moments. @@ -724,7 +733,7 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None): CONTEXT: - STORY_SO_FAR: {prev_sum} {prev_context_block} - - CHARACTERS: {json.dumps(bp['characters'])} + - CHARACTERS: {json.dumps(chars_for_writer)} {char_visuals} - SCENE_BEATS: {json.dumps(chap['beats'])} @@ -735,13 +744,15 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None): resp_draft = ai.model_writer.generate_content(prompt) utils.log_usage(folder, ai.model_writer.name, resp_draft.usage_metadata) current_text = resp_draft.text + draft_words = len(current_text.split()) if current_text else 0 + utils.log("WRITER", f" -> Draft: {draft_words:,} words (target: ~{est_words})") except Exception as e: utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}") return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}" # Refinement Loop max_attempts = 5 - SCORE_AUTO_ACCEPT = 9 + SCORE_AUTO_ACCEPT = 8 # 8 = professional quality; no marginal gain from extra refinement SCORE_PASSING = 7 SCORE_REWRITE_THRESHOLD = 6 diff --git a/modules/utils.py b/modules/utils.py index 123a26e..9e327f7 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -71,7 +71,11 @@ def get_sorted_book_folders(run_dir): return sorted(subdirs, key=sort_key) # --- SHARED UTILS --- -def log(phase, msg): +def log_banner(phase, title): + """Log a visually distinct phase separator line.""" + log(phase, f"{'─' * 18} {title} {'─' * 18}") + +def log(phase, msg): timestamp = datetime.datetime.now().strftime('%H:%M:%S') line = f"[{timestamp}] {phase:<15} | {msg}" print(line) diff --git a/templates/run_details.html b/templates/run_details.html index aac6b69..b623a79 100644 --- a/templates/run_details.html +++ b/templates/run_details.html @@ -337,20 +337,69 @@ const statusText = document.getElementById('status-text'); const statusBar = document.getElementById('status-bar'); const costEl = document.getElementById('run-cost'); - + + let lastLog = ''; + + // Phase → colour mapping (matches utils.log phase labels) + const PHASE_COLORS = { + 'WRITER': '#4fc3f7', + 'ARCHITECT': '#81c784', + 'TIMING': '#78909c', + 'SYSTEM': '#fff176', + 'TRACKER': '#ce93d8', + 'RESUME': '#ffb74d', + 'SERIES': '#64b5f6', + 'ENRICHER': '#4dd0e1', + 'HARVESTER': '#ff8a65', + 'EDITOR': '#f48fb1', + }; + + function escapeHtml(str) { + return str.replace(/&/g, '&').replace(//g, '>'); + } + + function colorizeLog(logText) { + if (!logText) return ''; + return logText.split('\n').map(line => { + const m = line.match(/^(\[[\d:]+\])\s+(\w+)\s+\|(.*)$/); + if (!m) return '' + escapeHtml(line) + ''; + const [, ts, phase, msg] = m; + const color = PHASE_COLORS[phase] || '#aaaaaa'; + return '' + escapeHtml(ts) + ' ' + + '' + phase.padEnd(14) + '' + + '|' + escapeHtml(msg) + ''; + }).join('\n'); + } + + function getCurrentPhase(logText) { + if (!logText) return ''; + const lines = logText.split('\n').filter(l => l.trim()); + for (let k = lines.length - 1; k >= 0; k--) { + const m = lines[k].match(/\]\s+(\w+)\s+\|/); + if (m) return m[1]; + } + return ''; + } + function updateLog() { fetch(`/run/${runId}/status`) .then(response => response.json()) .then(data => { - // Update Status Text - statusText.innerText = "Status: " + data.status.charAt(0).toUpperCase() + data.status.slice(1); + // Update Status Text + current phase + const statusLabel = data.status.charAt(0).toUpperCase() + data.status.slice(1); + if (data.status === 'running') { + const phase = getCurrentPhase(data.log); + statusText.innerText = 'Status: Running' + (phase ? ' — ' + phase : ''); + } else { + statusText.innerText = 'Status: ' + statusLabel; + } costEl.innerText = '$' + parseFloat(data.cost).toFixed(4); - + // Update Status Bar if (data.status === 'running' || data.status === 'queued') { statusBar.className = "progress-bar progress-bar-striped progress-bar-animated"; statusBar.style.width = (data.percent || 5) + "%"; - + let label = (data.percent || 0) + "%"; if (data.status === 'running' && data.percent > 2 && data.start_time) { const elapsed = (Date.now() / 1000) - data.start_time; @@ -371,15 +420,16 @@ statusBar.innerText = ""; } - // Update Log (only if changed to avoid scroll jitter) - if (consoleEl.innerText !== data.log) { + // Update Log with phase colorization (only if changed to avoid scroll jitter) + if (lastLog !== data.log) { + lastLog = data.log; const isScrolledToBottom = consoleEl.scrollHeight - consoleEl.clientHeight <= consoleEl.scrollTop + 50; - consoleEl.innerText = data.log; + consoleEl.innerHTML = colorizeLog(data.log); if (isScrolledToBottom) { consoleEl.scrollTop = consoleEl.scrollHeight; } } - + // Poll if running if (data.status === 'running' || data.status === 'queued') { setTimeout(updateLog, 2000);