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:
2026-02-22 22:01:30 -05:00
parent 6684ec2bf5
commit 2100ca2312
8 changed files with 1143 additions and 32 deletions

View File

@@ -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.01.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