v2.0.0: Modularize project into single-responsibility packages
Replaced monolithic modules/ package with a clean architecture:
- core/ config.py, utils.py
- ai/ models.py (ResilientModel), setup.py (init_models)
- story/ planner.py, writer.py, editor.py, style_persona.py, bible_tracker.py
- marketing/ cover.py, blurb.py, fonts.py, assets.py
- export/ exporter.py
- web/ app.py (Flask factory), db.py, helpers.py, tasks.py, routes/{auth,project,run,persona,admin}.py
- cli/ engine.py (run_generation), wizard.py (BookWizard)
Flask routes split into 5 Blueprints; all templates updated with blueprint-
prefixed url_for() calls. Dockerfile and docker-compose updated to use
web.app entry point and new package paths.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
265
story/planner.py
Normal file
265
story/planner.py
Normal file
@@ -0,0 +1,265 @@
|
||||
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'] = []
|
||||
|
||||
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}
|
||||
|
||||
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', [])
|
||||
|
||||
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', [])]
|
||||
|
||||
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}
|
||||
|
||||
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 []
|
||||
Reference in New Issue
Block a user