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:
@@ -80,6 +80,14 @@ def enrich(bp, folder, context=""):
|
||||
if 'plot_beats' not in bp or not bp['plot_beats']:
|
||||
bp['plot_beats'] = ai_data.get('plot_beats', [])
|
||||
|
||||
# Validate critical fields after enrichment
|
||||
title = bp.get('book_metadata', {}).get('title')
|
||||
genre = bp.get('book_metadata', {}).get('genre')
|
||||
if not title:
|
||||
utils.log("ENRICHER", "⚠️ Warning: book_metadata.title is missing after enrichment.")
|
||||
if not genre:
|
||||
utils.log("ENRICHER", "⚠️ Warning: book_metadata.genre is missing after enrichment.")
|
||||
|
||||
return bp
|
||||
except Exception as e:
|
||||
utils.log("ENRICHER", f"Enrichment failed: {e}")
|
||||
@@ -288,3 +296,66 @@ def create_chapter_plan(events, bp, folder):
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f"Failed to create chapter plan: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def validate_outline(events, chapters, bp, folder):
|
||||
"""Pre-generation outline validation gate (Action Plan Step 3: Alt 2-B).
|
||||
|
||||
Checks for: missing required beats, character continuity issues, severe pacing
|
||||
imbalances, and POV logic errors. Returns findings but never blocks generation —
|
||||
issues are logged as warnings so the writer can proceed.
|
||||
"""
|
||||
utils.log("ARCHITECT", "Validating outline before writing phase...")
|
||||
|
||||
beats_context = bp.get('plot_beats', [])
|
||||
chars_summary = [{"name": c.get("name"), "role": c.get("role")} for c in bp.get('characters', [])]
|
||||
|
||||
# Sample chapter data to keep prompt size manageable
|
||||
chapters_sample = chapters[:5] + chapters[-5:] if len(chapters) > 10 else chapters
|
||||
|
||||
prompt = f"""
|
||||
ROLE: Continuity Editor
|
||||
TASK: Review this chapter outline for issues that could cause expensive rewrites later.
|
||||
|
||||
REQUIRED_BEATS (must all appear somewhere in the chapter plan):
|
||||
{json.dumps(beats_context)}
|
||||
|
||||
CHARACTERS:
|
||||
{json.dumps(chars_summary)}
|
||||
|
||||
CHAPTER_PLAN (sample — first 5 and last 5 chapters):
|
||||
{json.dumps(chapters_sample)}
|
||||
|
||||
CHECK FOR:
|
||||
1. MISSING_BEATS: Are all required plot beats present? List any absent beats by name.
|
||||
2. CONTINUITY: Are there character deaths/revivals, unacknowledged time jumps, or contradictions visible in the outline?
|
||||
3. PACING: Are there 3+ consecutive chapters with identical pacing that would create reader fatigue?
|
||||
4. POV_LOGIC: Are key emotional scenes assigned to the most appropriate POV character?
|
||||
|
||||
OUTPUT_FORMAT (JSON):
|
||||
{{
|
||||
"issues": [
|
||||
{{"type": "missing_beat|continuity|pacing|pov", "description": "...", "severity": "critical|warning"}}
|
||||
],
|
||||
"overall_severity": "ok|warning|critical",
|
||||
"summary": "One-sentence summary of findings."
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
response = ai_models.model_logic.generate_content(prompt)
|
||||
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
||||
result = json.loads(utils.clean_json(response.text))
|
||||
|
||||
severity = result.get('overall_severity', 'ok')
|
||||
issues = result.get('issues', [])
|
||||
summary = result.get('summary', 'No issues found.')
|
||||
|
||||
for issue in issues:
|
||||
prefix = "⚠️" if issue.get('severity') == 'warning' else "🚨"
|
||||
utils.log("ARCHITECT", f" {prefix} Outline {issue.get('type', 'issue')}: {issue.get('description', '')}")
|
||||
|
||||
utils.log("ARCHITECT", f"Outline validation complete: {severity.upper()} — {summary}")
|
||||
return result
|
||||
except Exception as e:
|
||||
utils.log("ARCHITECT", f"Outline validation failed (non-blocking): {e}")
|
||||
return {"issues": [], "overall_severity": "ok", "summary": "Validation skipped."}
|
||||
|
||||
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