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:
2026-02-20 22:20:53 -05:00
parent edabc4d4fa
commit f7099cc3e4
52 changed files with 3984 additions and 3798 deletions

265
story/planner.py Normal file
View 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 []