feat: Implement ai_blueprint.md action plan — architectural review & optimisations
Steps 1–7 of the ai_blueprint.md action plan executed: DOCUMENTATION (Steps 1–3, 6–7): - docs/current_state_analysis.md: Phase-by-phase cost/quality mapping of existing pipeline - docs/alternatives_analysis.md: 15 alternative approaches with testable hypotheses - docs/experiment_design.md: 7 controlled A/B experiment specifications (CPC, HQS, CER metrics) - ai_blueprint_v2.md: New recommended architecture with cost projections and experiment roadmap CODE IMPROVEMENTS (Step 4 — Experiments 1–4 implemented): - story/writer.py: Extract build_persona_info() — persona loaded once per book, not per chapter - story/writer.py: Adaptive scoring thresholds — SCORE_PASSING scales 6.5→7.5 by chapter position - story/writer.py: Beat expansion skip — if beats >100 words, skip Director's Treatment expansion - story/planner.py: validate_outline() — pre-generation gate checks missing beats, continuity, pacing - story/planner.py: Enrichment field validation — warn on missing title/genre after enrich() - cli/engine.py: Wire persona cache, outline validation gate, chapter_position threading Expected savings: ~285K tokens per 30-chapter novel (~7% cost reduction) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
103
story/writer.py
103
story/writer.py
@@ -74,6 +74,49 @@ def get_genre_instructions(genre):
|
||||
)
|
||||
|
||||
|
||||
def build_persona_info(bp):
|
||||
"""Build the author persona string from bp['book_metadata']['author_details'].
|
||||
|
||||
Extracted as a standalone function so engine.py can pre-load the persona once
|
||||
for the entire writing phase instead of re-reading sample files for every chapter.
|
||||
Returns the assembled persona string, or None if no author_details are present.
|
||||
"""
|
||||
meta = bp.get('book_metadata', {})
|
||||
ad = meta.get('author_details', {})
|
||||
if not ad and 'author_bio' in meta:
|
||||
return meta['author_bio']
|
||||
if not ad:
|
||||
return None
|
||||
|
||||
info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
|
||||
if ad.get('age'): info += f"Age: {ad['age']}\n"
|
||||
if ad.get('gender'): info += f"Gender: {ad['gender']}\n"
|
||||
if ad.get('race'): info += f"Race: {ad['race']}\n"
|
||||
if ad.get('nationality'): info += f"Nationality: {ad['nationality']}\n"
|
||||
if ad.get('language'): info += f"Language: {ad['language']}\n"
|
||||
if ad.get('bio'): info += f"Style/Bio: {ad['bio']}\n"
|
||||
|
||||
samples = []
|
||||
if ad.get('sample_text'):
|
||||
samples.append(f"--- SAMPLE PARAGRAPH ---\n{ad['sample_text']}")
|
||||
|
||||
if ad.get('sample_files'):
|
||||
for fname in ad['sample_files']:
|
||||
fpath = os.path.join(config.PERSONAS_DIR, fname)
|
||||
if os.path.exists(fpath):
|
||||
try:
|
||||
with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read(3000)
|
||||
samples.append(f"--- SAMPLE FROM {fname} ---\n{content}...")
|
||||
except:
|
||||
pass
|
||||
|
||||
if samples:
|
||||
info += "\nWRITING STYLE SAMPLES:\n" + "\n".join(samples)
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def expand_beats_to_treatment(beats, pov_char, genre, folder):
|
||||
"""Expand sparse scene beats into a Director's Treatment using a fast model.
|
||||
This pre-flight step gives the writer detailed staging and emotional direction,
|
||||
@@ -106,7 +149,15 @@ def expand_beats_to_treatment(beats, pov_char, genre, folder):
|
||||
return None
|
||||
|
||||
|
||||
def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None, next_chapter_hint=""):
|
||||
def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None, next_chapter_hint="", prebuilt_persona=None, chapter_position=None):
|
||||
"""Write a single chapter with iterative quality evaluation.
|
||||
|
||||
Args:
|
||||
prebuilt_persona: Pre-loaded persona string from build_persona_info(bp).
|
||||
When provided, skips per-chapter file reads (persona cache optimisation).
|
||||
chapter_position: Float 0.0–1.0 indicating position in book. Used for
|
||||
adaptive scoring thresholds (setup = lenient, climax = strict).
|
||||
"""
|
||||
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']}")
|
||||
@@ -117,34 +168,11 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
|
||||
|
||||
pov_char = chap.get('pov_character', '')
|
||||
|
||||
ad = meta.get('author_details', {})
|
||||
if not ad and 'author_bio' in meta:
|
||||
persona_info = meta['author_bio']
|
||||
# Use pre-loaded persona if provided (avoids re-reading sample files every chapter)
|
||||
if prebuilt_persona is not None:
|
||||
persona_info = prebuilt_persona
|
||||
else:
|
||||
persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n"
|
||||
if ad.get('age'): persona_info += f"Age: {ad['age']}\n"
|
||||
if ad.get('gender'): persona_info += f"Gender: {ad['gender']}\n"
|
||||
if ad.get('race'): persona_info += f"Race: {ad['race']}\n"
|
||||
if ad.get('nationality'): persona_info += f"Nationality: {ad['nationality']}\n"
|
||||
if ad.get('language'): persona_info += f"Language: {ad['language']}\n"
|
||||
if ad.get('bio'): persona_info += f"Style/Bio: {ad['bio']}\n"
|
||||
|
||||
samples = []
|
||||
if ad.get('sample_text'):
|
||||
samples.append(f"--- SAMPLE PARAGRAPH ---\n{ad['sample_text']}")
|
||||
|
||||
if ad.get('sample_files'):
|
||||
for fname in ad['sample_files']:
|
||||
fpath = os.path.join(config.PERSONAS_DIR, fname)
|
||||
if os.path.exists(fpath):
|
||||
try:
|
||||
with open(fpath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read(3000)
|
||||
samples.append(f"--- SAMPLE FROM {fname} ---\n{content}...")
|
||||
except: pass
|
||||
|
||||
if samples:
|
||||
persona_info += "\nWRITING STYLE SAMPLES:\n" + "\n".join(samples)
|
||||
persona_info = build_persona_info(bp) or "Standard, balanced writing style."
|
||||
|
||||
# Only inject characters named in the chapter beats + the POV character
|
||||
beats_text = " ".join(str(b) for b in chap.get('beats', []))
|
||||
@@ -217,8 +245,15 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
|
||||
trunc_content = utils.truncate_to_tokens(prev_content, 1000)
|
||||
prev_context_block = f"\nPREVIOUS CHAPTER TEXT (Last ~1000 Tokens — For Immediate Continuity):\n{trunc_content}\n"
|
||||
|
||||
utils.log("WRITER", f" -> Expanding beats to Director's Treatment...")
|
||||
treatment = expand_beats_to_treatment(chap.get('beats', []), pov_char, genre, folder)
|
||||
# Skip beat expansion if beats are already detailed (saves ~5K tokens per chapter)
|
||||
beats_list = chap.get('beats', [])
|
||||
total_beat_words = sum(len(str(b).split()) for b in beats_list)
|
||||
if total_beat_words > 100:
|
||||
utils.log("WRITER", f" -> Beats already detailed ({total_beat_words} words). Skipping expansion.")
|
||||
treatment = None
|
||||
else:
|
||||
utils.log("WRITER", f" -> Expanding beats to Director's Treatment...")
|
||||
treatment = expand_beats_to_treatment(beats_list, pov_char, genre, folder)
|
||||
treatment_block = f"\n DIRECTORS_TREATMENT (Staged expansion of the beats — use this as your scene blueprint; DRAMATIZE every moment, do NOT summarize):\n{treatment}\n" if treatment else ""
|
||||
|
||||
genre_mandates = get_genre_instructions(genre)
|
||||
@@ -329,7 +364,13 @@ def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None,
|
||||
|
||||
max_attempts = 3
|
||||
SCORE_AUTO_ACCEPT = 8
|
||||
SCORE_PASSING = 7
|
||||
# Adaptive passing threshold: lenient for early setup chapters, strict for climax/resolution.
|
||||
# chapter_position=0.0 → setup (SCORE_PASSING=6.5), chapter_position=1.0 → climax (7.5)
|
||||
if chapter_position is not None:
|
||||
SCORE_PASSING = round(6.5 + chapter_position * 1.0, 1)
|
||||
utils.log("WRITER", f" -> Adaptive threshold: SCORE_PASSING={SCORE_PASSING} (position={chapter_position:.2f})")
|
||||
else:
|
||||
SCORE_PASSING = 7
|
||||
SCORE_REWRITE_THRESHOLD = 6
|
||||
|
||||
best_score = 0
|
||||
|
||||
Reference in New Issue
Block a user