v1.4.0: Organic writing, speed, and log improvements
Organic book quality: - write_chapter: strip key_events spoilers from character context so the writer doesn't know planned future events when writing early chapters - write_chapter: added next_chapter_hint — seeds anticipation for the next scene in the final paragraphs of each chapter for natural story flow - write_chapter: added DIALOGUE VOICE instruction referencing CHARACTER TRACKING speech styles so every character sounds distinctly different - Lowered SCORE_AUTO_ACCEPT 9→8 to stop over-refining already-professional drafts Speed improvements: - check_pacing: reduced from every chapter to every other chapter (~50% fewer calls) - refine_persona: reduced from every 3 to every 5 chapters (~40% fewer calls) - Resume summary rebuild: uses first + last-4 chapters instead of all chapters to avoid massive prompts when resuming mid-book - Summary context sent to writer capped at 8000 chars (most-recent events) - update_tracking text cap lowered 500000→20000 (covers any realistic chapter) Logging and progress bars: - Progress bar updates at chapter START, not just after completion - Chapter banner logged before each write so the log shows which chapter is active - Word count logged after first draft (e.g. "Draft: 2,341 words (target: ~2200)") - Word count added to chapter completion TIMING line - Pacing check now logs "Pacing OK" with reason when no intervention needed - utils: added log_banner() helper for phase separator lines UI: - run_details.html: log lines are now phase-coloured (WRITER=cyan, ARCHITECT=green, TIMING=gray, SYSTEM=yellow, TRACKER=purple, RESUME=orange, etc.) - Status bar shows current active phase (e.g. "Status: Running — WRITER") Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -65,4 +65,4 @@ LENGTH_DEFINITIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- SYSTEM ---
|
# --- SYSTEM ---
|
||||||
VERSION = "1.3.1"
|
VERSION = "1.4.0"
|
||||||
29
main.py
29
main.py
@@ -97,10 +97,11 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
|
|
||||||
summary = "The story begins."
|
summary = "The story begins."
|
||||||
if ms:
|
if ms:
|
||||||
# Generate summary from ALL written chapters to maintain continuity
|
# Efficient rebuild: first chapter (setup) + last 4 (recent events) avoids huge prompts
|
||||||
utils.log("RESUME", "Rebuilding 'Story So Far' from existing manuscript...")
|
utils.log("RESUME", f"Rebuilding story context from {len(ms)} existing chapters...")
|
||||||
try:
|
try:
|
||||||
combined_text = "\n".join([f"Chapter {c['num']}: {c['content']}" for c in ms])
|
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"""
|
resp_sum = ai.model_writer.generate_content(f"""
|
||||||
ROLE: Series Historian
|
ROLE: Series Historian
|
||||||
TASK: Create a cumulative 'Story So Far' summary.
|
TASK: Create a cumulative 'Story So Far' summary.
|
||||||
@@ -134,12 +135,19 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
i += 1
|
i += 1
|
||||||
continue
|
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
|
# Pass previous chapter content for continuity if available
|
||||||
prev_content = ms[-1]['content'] if ms else None
|
prev_content = ms[-1]['content'] if ms else None
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
utils.log("SYSTEM", f"Chapter generation failed: {e}")
|
utils.log("SYSTEM", f"Chapter generation failed: {e}")
|
||||||
if interactive:
|
if interactive:
|
||||||
@@ -156,8 +164,8 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Refine Persona to match the actual output (Consistency Loop)
|
# Refine Persona to match the actual output (every 5 chapters to save API calls)
|
||||||
if (i == 0 or i % 3 == 0) and txt:
|
if (i == 0 or i % 5 == 0) and txt:
|
||||||
bp['book_metadata']['author_details'] = story.refine_persona(bp, txt, folder)
|
bp['book_metadata']['author_details'] = story.refine_persona(bp, txt, folder)
|
||||||
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
|
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(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)
|
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:]
|
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)
|
pacing = story.check_pacing(bp, summary, txt, ch, remaining, folder)
|
||||||
if pacing and pacing.get('status') == 'add_bridge':
|
if pacing and pacing.get('status') == 'add_bridge':
|
||||||
new_data = pacing.get('new_chapter', {})
|
new_data = pacing.get('new_chapter', {})
|
||||||
@@ -240,6 +248,8 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
|
|
||||||
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
|
||||||
utils.log("ARCHITECT", f" -> ⚠️ Pacing Intervention: Removed redundant chapter '{removed['title']}'.")
|
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
|
# Increment loop
|
||||||
i += 1
|
i += 1
|
||||||
@@ -254,7 +264,8 @@ def process_book(bp, folder, context="", resume=False, interactive=False):
|
|||||||
prog = 15 + int((i / len(chapters)) * 75)
|
prog = 15 + int((i / len(chapters)) * 75)
|
||||||
utils.update_progress(prog)
|
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")
|
utils.log("TIMING", f"Writing Phase: {time.time() - t_step:.1f}s")
|
||||||
|
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ def update_tracking(folder, chapter_num, chapter_text, current_tracking):
|
|||||||
{json.dumps(current_tracking)}
|
{json.dumps(current_tracking)}
|
||||||
|
|
||||||
NEW_TEXT:
|
NEW_TEXT:
|
||||||
{chapter_text[:500000]}
|
{chapter_text[:20000]}
|
||||||
|
|
||||||
OPERATIONS:
|
OPERATIONS:
|
||||||
1. EVENTS: Append 1-3 key plot points to 'events'.
|
1. EVENTS: Append 1-3 key plot points to 'events'.
|
||||||
@@ -594,7 +594,7 @@ def refine_persona(bp, text, folder):
|
|||||||
except: pass
|
except: pass
|
||||||
return ad
|
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')
|
pacing = chap.get('pacing', 'Standard')
|
||||||
est_words = chap.get('estimated_words', 'Flexible')
|
est_words = chap.get('estimated_words', 'Flexible')
|
||||||
utils.log("WRITER", f"Drafting Ch {chap['chapter_number']} ({pacing} | ~{est_words} words): {chap['title']}")
|
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
|
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"
|
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', '?')
|
total_chapters = ls.get('chapters', '?')
|
||||||
prompt = f"""
|
prompt = f"""
|
||||||
ROLE: Fiction Writer
|
ROLE: Fiction Writer
|
||||||
@@ -705,6 +712,8 @@ 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.
|
- 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.
|
- 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.
|
- 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:
|
QUALITY_CRITERIA:
|
||||||
1. ENGAGEMENT & TENSION: Grip the reader. Ensure conflict/tension in every scene.
|
1. ENGAGEMENT & TENSION: Grip the reader. Ensure conflict/tension in every scene.
|
||||||
@@ -724,7 +733,7 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None):
|
|||||||
CONTEXT:
|
CONTEXT:
|
||||||
- STORY_SO_FAR: {prev_sum}
|
- STORY_SO_FAR: {prev_sum}
|
||||||
{prev_context_block}
|
{prev_context_block}
|
||||||
- CHARACTERS: {json.dumps(bp['characters'])}
|
- CHARACTERS: {json.dumps(chars_for_writer)}
|
||||||
{char_visuals}
|
{char_visuals}
|
||||||
- SCENE_BEATS: {json.dumps(chap['beats'])}
|
- 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)
|
resp_draft = ai.model_writer.generate_content(prompt)
|
||||||
utils.log_usage(folder, ai.model_writer.name, resp_draft.usage_metadata)
|
utils.log_usage(folder, ai.model_writer.name, resp_draft.usage_metadata)
|
||||||
current_text = resp_draft.text
|
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:
|
except Exception as e:
|
||||||
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
|
utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}")
|
||||||
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}"
|
||||||
|
|
||||||
# Refinement Loop
|
# Refinement Loop
|
||||||
max_attempts = 5
|
max_attempts = 5
|
||||||
SCORE_AUTO_ACCEPT = 9
|
SCORE_AUTO_ACCEPT = 8 # 8 = professional quality; no marginal gain from extra refinement
|
||||||
SCORE_PASSING = 7
|
SCORE_PASSING = 7
|
||||||
SCORE_REWRITE_THRESHOLD = 6
|
SCORE_REWRITE_THRESHOLD = 6
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ def get_sorted_book_folders(run_dir):
|
|||||||
return sorted(subdirs, key=sort_key)
|
return sorted(subdirs, key=sort_key)
|
||||||
|
|
||||||
# --- SHARED UTILS ---
|
# --- SHARED UTILS ---
|
||||||
|
def log_banner(phase, title):
|
||||||
|
"""Log a visually distinct phase separator line."""
|
||||||
|
log(phase, f"{'─' * 18} {title} {'─' * 18}")
|
||||||
|
|
||||||
def log(phase, msg):
|
def log(phase, msg):
|
||||||
timestamp = datetime.datetime.now().strftime('%H:%M:%S')
|
timestamp = datetime.datetime.now().strftime('%H:%M:%S')
|
||||||
line = f"[{timestamp}] {phase:<15} | {msg}"
|
line = f"[{timestamp}] {phase:<15} | {msg}"
|
||||||
|
|||||||
@@ -338,12 +338,61 @@
|
|||||||
const statusBar = document.getElementById('status-bar');
|
const statusBar = document.getElementById('status-bar');
|
||||||
const costEl = document.getElementById('run-cost');
|
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, '<').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 '<span style="color:#666">' + escapeHtml(line) + '</span>';
|
||||||
|
const [, ts, phase, msg] = m;
|
||||||
|
const color = PHASE_COLORS[phase] || '#aaaaaa';
|
||||||
|
return '<span style="color:#555">' + escapeHtml(ts) + '</span> '
|
||||||
|
+ '<span style="color:' + color + ';font-weight:bold">' + phase.padEnd(14) + '</span>'
|
||||||
|
+ '<span style="color:#ccc">|' + escapeHtml(msg) + '</span>';
|
||||||
|
}).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() {
|
function updateLog() {
|
||||||
fetch(`/run/${runId}/status`)
|
fetch(`/run/${runId}/status`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// Update Status Text
|
// Update Status Text + current phase
|
||||||
statusText.innerText = "Status: " + data.status.charAt(0).toUpperCase() + data.status.slice(1);
|
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);
|
costEl.innerText = '$' + parseFloat(data.cost).toFixed(4);
|
||||||
|
|
||||||
// Update Status Bar
|
// Update Status Bar
|
||||||
@@ -371,10 +420,11 @@
|
|||||||
statusBar.innerText = "";
|
statusBar.innerText = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update Log (only if changed to avoid scroll jitter)
|
// Update Log with phase colorization (only if changed to avoid scroll jitter)
|
||||||
if (consoleEl.innerText !== data.log) {
|
if (lastLog !== data.log) {
|
||||||
|
lastLog = data.log;
|
||||||
const isScrolledToBottom = consoleEl.scrollHeight - consoleEl.clientHeight <= consoleEl.scrollTop + 50;
|
const isScrolledToBottom = consoleEl.scrollHeight - consoleEl.clientHeight <= consoleEl.scrollTop + 50;
|
||||||
consoleEl.innerText = data.log;
|
consoleEl.innerHTML = colorizeLog(data.log);
|
||||||
if (isScrolledToBottom) {
|
if (isScrolledToBottom) {
|
||||||
consoleEl.scrollTop = consoleEl.scrollHeight;
|
consoleEl.scrollTop = consoleEl.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user