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>
362 lines
17 KiB
Python
362 lines
17 KiB
Python
import json
|
|
import random
|
|
from core import utils
|
|
from ai import models as ai_models
|
|
from story.bible_tracker import filter_characters
|
|
|
|
|
|
def enrich(bp, folder, context=""):
|
|
utils.log("ENRICHER", "Fleshing out details from description...")
|
|
|
|
if 'book_metadata' not in bp: bp['book_metadata'] = {}
|
|
if 'characters' not in bp: bp['characters'] = []
|
|
if 'plot_beats' not in bp: bp['plot_beats'] = []
|
|
|
|
series_meta = bp.get('series_metadata', {})
|
|
series_block = ""
|
|
if series_meta.get('is_series'):
|
|
series_title = series_meta.get('series_title', 'this series')
|
|
book_num = series_meta.get('book_number', '?')
|
|
total_books = series_meta.get('total_books', '?')
|
|
series_block = (
|
|
f"\n - SERIES_CONTEXT: This is Book {book_num} of {total_books} in the '{series_title}' series. "
|
|
f"Pace character arcs and plot resolution accordingly. "
|
|
f"Book {book_num} of {total_books} should reflect its position: "
|
|
f"{'establish the world and core characters' if str(book_num) == '1' else 'escalate stakes and deepen arcs' if str(book_num) != str(total_books) else 'resolve all major threads with a satisfying conclusion'}."
|
|
)
|
|
|
|
prompt = f"""
|
|
ROLE: Creative Director
|
|
TASK: Create a comprehensive Book Bible from the user description.
|
|
|
|
INPUT DATA:
|
|
- USER_DESCRIPTION: "{bp.get('manual_instruction', 'A generic story')}"
|
|
- CONTEXT (Sequel): {context}{series_block}
|
|
|
|
STEPS:
|
|
1. Generate a catchy Title.
|
|
2. Define the Genre and Tone.
|
|
3. Determine the Time Period (e.g. "Modern", "1920s", "Sci-Fi Future").
|
|
4. Define Formatting Rules for text messages, thoughts, and chapter headers.
|
|
5. Create Protagonist and Antagonist/Love Interest.
|
|
- Logic: If sequel, reuse context. If new, create.
|
|
6. Outline 5-7 core Plot Beats.
|
|
7. Define a 'structure_prompt' describing the narrative arc (e.g. "Hero's Journey", "3-Act Structure", "Detective Procedural").
|
|
|
|
OUTPUT_FORMAT (JSON):
|
|
{{
|
|
"book_metadata": {{ "title": "Book Title", "genre": "Genre", "content_warnings": ["Violence", "Major Character Death"], "structure_prompt": "...", "style": {{ "tone": "Tone", "time_period": "Modern", "formatting_rules": ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"] }} }},
|
|
"characters": [ {{ "name": "John Doe", "role": "Protagonist", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ],
|
|
"plot_beats": [ "Beat 1", "Beat 2", "..." ]
|
|
}}
|
|
"""
|
|
try:
|
|
response = ai_models.model_logic.generate_content(prompt)
|
|
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
|
ai_data = json.loads(utils.clean_json(response.text))
|
|
|
|
if 'book_metadata' not in bp: bp['book_metadata'] = {}
|
|
|
|
if 'title' not in bp['book_metadata']:
|
|
bp['book_metadata']['title'] = ai_data.get('book_metadata', {}).get('title')
|
|
if 'structure_prompt' not in bp['book_metadata']:
|
|
bp['book_metadata']['structure_prompt'] = ai_data.get('book_metadata', {}).get('structure_prompt')
|
|
if 'content_warnings' not in bp['book_metadata']:
|
|
bp['book_metadata']['content_warnings'] = ai_data.get('book_metadata', {}).get('content_warnings', [])
|
|
|
|
if 'style' not in bp['book_metadata']: bp['book_metadata']['style'] = {}
|
|
|
|
source_style = ai_data.get('book_metadata', {}).get('style', {})
|
|
for k, v in source_style.items():
|
|
if k not in bp['book_metadata']['style']:
|
|
bp['book_metadata']['style'][k] = v
|
|
|
|
if 'characters' not in bp or not bp['characters']:
|
|
bp['characters'] = ai_data.get('characters', [])
|
|
|
|
if 'characters' in bp:
|
|
bp['characters'] = filter_characters(bp['characters'])
|
|
|
|
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}")
|
|
return bp
|
|
|
|
|
|
def plan_structure(bp, folder):
|
|
utils.log("ARCHITECT", "Creating structure...")
|
|
|
|
structure_type = bp.get('book_metadata', {}).get('structure_prompt')
|
|
|
|
if not structure_type:
|
|
label = bp.get('length_settings', {}).get('label', 'Novel')
|
|
structures = {
|
|
"Chapter Book": "Create a simple episodic structure with clear chapter hooks.",
|
|
"Young Adult": "Create a character-driven arc with high emotional stakes and a clear 'Coming of Age' theme.",
|
|
"Flash Fiction": "Create a single, impactful scene structure with a twist.",
|
|
"Short Story": "Create a concise narrative arc (Inciting Incident -> Rising Action -> Climax -> Resolution).",
|
|
"Novella": "Create a standard 3-Act Structure.",
|
|
"Novel": "Create a detailed 3-Act Structure with A and B plots.",
|
|
"Epic": "Create a complex, multi-arc structure (Hero's Journey) with extensive world-building events."
|
|
}
|
|
structure_type = structures.get(label, "Create a 3-Act Structure.")
|
|
|
|
beats_context = bp.get('plot_beats', [])
|
|
target_chapters = bp.get('length_settings', {}).get('chapters', 'flexible')
|
|
target_words = bp.get('length_settings', {}).get('words', 'flexible')
|
|
chars_summary = [{"name": c.get("name"), "role": c.get("role")} for c in bp.get('characters', [])]
|
|
|
|
series_meta = bp.get('series_metadata', {})
|
|
series_block = ""
|
|
if series_meta.get('is_series'):
|
|
series_title = series_meta.get('series_title', 'this series')
|
|
book_num = series_meta.get('book_number', '?')
|
|
total_books = series_meta.get('total_books', '?')
|
|
series_block = (
|
|
f"\n - SERIES_CONTEXT: This is Book {book_num} of {total_books} in the '{series_title}' series. "
|
|
f"Structure the arc to fit its position in the series: "
|
|
f"{'introduce all major characters and the central conflict; leave threads open for future books' if str(book_num) == '1' else 'deepen existing character arcs and escalate the overarching conflict; do not resolve the series-level stakes' if str(book_num) != str(total_books) else 'resolve all series-level threads; provide a satisfying conclusion for every major character arc'}."
|
|
)
|
|
|
|
prompt = f"""
|
|
ROLE: Story Architect
|
|
TASK: Create a detailed structural event outline for a {target_chapters}-chapter book.
|
|
|
|
BOOK:
|
|
- TITLE: {bp['book_metadata']['title']}
|
|
- GENRE: {bp.get('book_metadata', {}).get('genre', 'Fiction')}
|
|
- TARGET_CHAPTERS: {target_chapters}
|
|
- TARGET_WORDS: {target_words}
|
|
- STRUCTURE: {structure_type}{series_block}
|
|
|
|
CHARACTERS: {json.dumps(chars_summary)}
|
|
|
|
USER_BEATS (must all be preserved and woven into the outline):
|
|
{json.dumps(beats_context)}
|
|
|
|
REQUIREMENTS:
|
|
- Produce enough events to fill approximately {target_chapters} chapters.
|
|
- Each event must serve a narrative purpose (setup, escalation, reversal, climax, resolution).
|
|
- Distribute events across a beginning, middle, and end — avoid front-loading.
|
|
- Character arcs must be visible through the events (growth, change, revelation).
|
|
|
|
OUTPUT_FORMAT (JSON): {{ "events": [{{ "description": "String", "purpose": "String" }}] }}
|
|
"""
|
|
try:
|
|
response = ai_models.model_logic.generate_content(prompt)
|
|
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
|
return json.loads(utils.clean_json(response.text))['events']
|
|
except:
|
|
return []
|
|
|
|
|
|
def expand(events, pass_num, target_chapters, bp, folder):
|
|
utils.log("ARCHITECT", f"Expansion pass {pass_num} | Current Beats: {len(events)} | Target Chaps: {target_chapters}")
|
|
|
|
event_ceiling = int(target_chapters * 1.5)
|
|
if len(events) >= event_ceiling:
|
|
task = (
|
|
f"The outline already has {len(events)} beats for a {target_chapters}-chapter book — do NOT add more events. "
|
|
f"Instead, enrich each existing beat's description with more specific detail: setting, characters involved, emotional stakes, and how it connects to what follows."
|
|
)
|
|
else:
|
|
task = (
|
|
f"Expand the outline toward {target_chapters} chapters. "
|
|
f"Current count: {len(events)} beats. "
|
|
f"Add intermediate events to fill pacing gaps, deepen subplots, and ensure character arcs are visible. "
|
|
f"Do not overshoot — aim for {target_chapters} to {event_ceiling} total events."
|
|
)
|
|
|
|
original_beats = bp.get('plot_beats', [])
|
|
|
|
prompt = f"""
|
|
ROLE: Story Architect
|
|
TASK: {task}
|
|
|
|
ORIGINAL_USER_BEATS (must all remain present):
|
|
{json.dumps(original_beats)}
|
|
|
|
CURRENT_EVENTS:
|
|
{json.dumps(events)}
|
|
|
|
RULES:
|
|
1. PRESERVE all original user beats — do not remove or alter them.
|
|
2. New events must serve a clear narrative purpose (tension, character, world, reversal).
|
|
3. Avoid repetitive events — each beat must be distinct.
|
|
4. Distribute additions evenly — do not front-load the outline.
|
|
|
|
OUTPUT_FORMAT (JSON): {{ "events": [{{"description": "String", "purpose": "String"}}] }}
|
|
"""
|
|
try:
|
|
response = ai_models.model_logic.generate_content(prompt)
|
|
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
|
new_events = json.loads(utils.clean_json(response.text))['events']
|
|
|
|
if len(new_events) > len(events):
|
|
utils.log("ARCHITECT", f" -> Added {len(new_events) - len(events)} new beats.")
|
|
elif len(str(new_events)) > len(str(events)) + 20:
|
|
utils.log("ARCHITECT", f" -> Fleshed out descriptions (Text grew by {len(str(new_events)) - len(str(events))} chars).")
|
|
else:
|
|
utils.log("ARCHITECT", " -> No significant changes.")
|
|
return new_events
|
|
except Exception as e:
|
|
utils.log("ARCHITECT", f" -> Pass skipped due to error: {e}")
|
|
return events
|
|
|
|
|
|
def create_chapter_plan(events, bp, folder):
|
|
utils.log("ARCHITECT", "Finalizing Chapters...")
|
|
target = bp['length_settings']['chapters']
|
|
words = bp['length_settings'].get('words', 'Flexible')
|
|
|
|
include_prologue = bp.get('length_settings', {}).get('include_prologue', False)
|
|
include_epilogue = bp.get('length_settings', {}).get('include_epilogue', False)
|
|
|
|
structure_instructions = ""
|
|
if include_prologue: structure_instructions += "- Include a 'Prologue' (chapter_number: 0) to set the scene.\n"
|
|
if include_epilogue: structure_instructions += "- Include an 'Epilogue' (chapter_number: 'Epilogue') to wrap up.\n"
|
|
|
|
meta = bp.get('book_metadata', {})
|
|
style = meta.get('style', {})
|
|
pov_chars = style.get('pov_characters', [])
|
|
pov_instruction = ""
|
|
if pov_chars:
|
|
pov_instruction = f"- Assign a 'pov_character' for each chapter from this list: {json.dumps(pov_chars)}."
|
|
|
|
prompt = f"""
|
|
ROLE: Pacing Specialist
|
|
TASK: Group the provided events into chapters for a {meta.get('genre', 'Fiction')} {bp['length_settings'].get('label', 'novel')}.
|
|
|
|
GUIDELINES:
|
|
- AIM for approximately {target} chapters, but the final count may vary ±15% if the story structure demands it.
|
|
- TARGET_WORDS for the whole book: {words}
|
|
- Assign pacing to each chapter: Very Fast / Fast / Standard / Slow / Very Slow
|
|
- estimated_words per chapter should reflect its pacing:
|
|
Very Fast ≈ 60% of average, Fast ≈ 80%, Standard ≈ 100%, Slow ≈ 125%, Very Slow ≈ 150%
|
|
- Do NOT force equal word counts. Natural variation makes the book feel alive.
|
|
{structure_instructions}
|
|
{pov_instruction}
|
|
|
|
INPUT_EVENTS: {json.dumps(events)}
|
|
|
|
OUTPUT_FORMAT (JSON): [{{"chapter_number": 1, "title": "String", "pov_character": "String", "pacing": "String", "estimated_words": 2000, "beats": ["String"]}}]
|
|
"""
|
|
try:
|
|
response = ai_models.model_logic.generate_content(prompt)
|
|
utils.log_usage(folder, ai_models.model_logic.name, response.usage_metadata)
|
|
plan = json.loads(utils.clean_json(response.text))
|
|
|
|
target_str = str(words).lower().replace(',', '').replace('k', '000').replace('+', '').replace(' ', '')
|
|
target_val = 0
|
|
if '-' in target_str:
|
|
try:
|
|
parts = target_str.split('-')
|
|
target_val = int((int(parts[0]) + int(parts[1])) / 2)
|
|
except: pass
|
|
else:
|
|
try: target_val = int(target_str)
|
|
except: pass
|
|
|
|
if target_val > 0:
|
|
variance = random.uniform(0.92, 1.08)
|
|
target_val = int(target_val * variance)
|
|
utils.log("ARCHITECT", f"Word target after variance ({variance:.2f}x): {target_val} words.")
|
|
|
|
current_sum = sum(int(c.get('estimated_words', 0)) for c in plan)
|
|
if current_sum > 0:
|
|
base_factor = target_val / current_sum
|
|
pacing_weight = {
|
|
'very fast': 0.60, 'fast': 0.80, 'standard': 1.00,
|
|
'slow': 1.25, 'very slow': 1.50
|
|
}
|
|
for c in plan:
|
|
pw = pacing_weight.get(c.get('pacing', 'standard').lower(), 1.0)
|
|
c['estimated_words'] = max(300, int(c.get('estimated_words', 0) * base_factor * pw))
|
|
|
|
adjusted_sum = sum(c['estimated_words'] for c in plan)
|
|
if adjusted_sum > 0:
|
|
norm = target_val / adjusted_sum
|
|
for c in plan:
|
|
c['estimated_words'] = max(300, int(c['estimated_words'] * norm))
|
|
|
|
utils.log("ARCHITECT", f"Chapter lengths scaled by pacing. Total ≈ {sum(c['estimated_words'] for c in plan)} words across {len(plan)} chapters.")
|
|
|
|
return plan
|
|
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."}
|