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

0
cli/__init__.py Normal file
View File

405
cli/engine.py Normal file
View File

@@ -0,0 +1,405 @@
import json
import os
import time
import sys
import shutil
from rich.prompt import Confirm
from core import config, utils
from ai import models as ai_models
from ai import setup as ai_setup
from story import planner, writer as story_writer, editor as story_editor
from story import style_persona, bible_tracker
from marketing import assets as marketing_assets
from export import exporter
def process_book(bp, folder, context="", resume=False, interactive=False):
# Create lock file to indicate active processing
lock_path = os.path.join(folder, ".in_progress")
with open(lock_path, "w") as f: f.write("running")
total_start = time.time()
try:
# 1. Check completion
if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")):
utils.log("SYSTEM", f"Book in {folder} already finished. Skipping.")
if os.path.exists(lock_path): os.remove(lock_path)
return
# 2. Load or Create Blueprint
bp_path = os.path.join(folder, "blueprint_initial.json")
t_step = time.time()
utils.update_progress(5)
if resume and os.path.exists(bp_path):
utils.log("RESUME", "Loading existing blueprint...")
saved_bp = utils.load_json(bp_path)
if saved_bp:
if 'book_metadata' in bp and 'book_metadata' in saved_bp:
for k in ['title', 'author', 'genre', 'target_audience', 'style', 'author_bio', 'author_details']:
if k in bp['book_metadata']:
saved_bp['book_metadata'][k] = bp['book_metadata'][k]
if 'series_metadata' in bp:
saved_bp['series_metadata'] = bp['series_metadata']
bp = saved_bp
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
else:
bp = planner.enrich(bp, folder, context)
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
# Ensure Persona Exists (Auto-create if missing)
if 'author_details' not in bp['book_metadata'] or not bp['book_metadata']['author_details']:
bp['book_metadata']['author_details'] = style_persona.create_initial_persona(bp, folder)
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
utils.log("TIMING", f"Blueprint Phase: {time.time() - t_step:.1f}s")
# 3. Events (Plan & Expand)
events_path = os.path.join(folder, "events.json")
t_step = time.time()
utils.update_progress(10)
if resume and os.path.exists(events_path):
utils.log("RESUME", "Loading existing events...")
events = utils.load_json(events_path)
else:
events = planner.plan_structure(bp, folder)
depth = bp['length_settings']['depth']
target_chaps = bp['length_settings']['chapters']
for d in range(1, depth+1):
events = planner.expand(events, d, target_chaps, bp, folder)
time.sleep(1)
with open(events_path, "w") as f: json.dump(events, f, indent=2)
utils.log("TIMING", f"Structure & Expansion: {time.time() - t_step:.1f}s")
# 4. Chapter Plan
chapters_path = os.path.join(folder, "chapters.json")
t_step = time.time()
utils.update_progress(15)
if resume and os.path.exists(chapters_path):
utils.log("RESUME", "Loading existing chapter plan...")
chapters = utils.load_json(chapters_path)
else:
chapters = planner.create_chapter_plan(events, bp, folder)
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
utils.log("TIMING", f"Chapter Planning: {time.time() - t_step:.1f}s")
# 5. Writing Loop
ms_path = os.path.join(folder, "manuscript.json")
loaded_ms = utils.load_json(ms_path) if (resume and os.path.exists(ms_path)) else []
ms = loaded_ms if loaded_ms is not None else []
# Load Tracking
events_track_path = os.path.join(folder, "tracking_events.json")
chars_track_path = os.path.join(folder, "tracking_characters.json")
warn_track_path = os.path.join(folder, "tracking_warnings.json")
tracking = {"events": [], "characters": {}, "content_warnings": []}
if resume:
if os.path.exists(events_track_path):
tracking['events'] = utils.load_json(events_track_path)
if os.path.exists(chars_track_path):
tracking['characters'] = utils.load_json(chars_track_path)
if os.path.exists(warn_track_path):
tracking['content_warnings'] = utils.load_json(warn_track_path)
summary = "The story begins."
if ms:
utils.log("RESUME", f"Rebuilding story context from {len(ms)} existing chapters...")
try:
selected = ms[:1] + ms[-4:] if len(ms) > 5 else ms
combined_text = "\n".join([f"Chapter {c['num']}: {c['content'][:3000]}" for c in selected])
resp_sum = ai_models.model_writer.generate_content(f"""
ROLE: Series Historian
TASK: Create a cumulative 'Story So Far' summary.
INPUT_TEXT:
{combined_text}
INSTRUCTIONS: Use dense, factual bullet points. Focus on character meetings, relationships, and known information.
OUTPUT: Summary text.
""")
utils.log_usage(folder, ai_models.model_writer.name, resp_sum.usage_metadata)
summary = resp_sum.text
except: summary = "The story continues."
t_step = time.time()
session_chapters = 0
session_time = 0
i = len(ms)
while i < len(chapters):
ch_start = time.time()
ch = chapters[i]
# Check for stop signal from Web UI
run_dir = os.path.dirname(folder)
if os.path.exists(os.path.join(run_dir, ".stop")):
utils.log("SYSTEM", "Stop signal detected. Aborting generation.")
break
# Robust Resume: Check if this specific chapter number is already in the manuscript
if any(str(c.get('num')) == str(ch['chapter_number']) for c in ms):
i += 1
continue
# Progress Banner
utils.update_progress(15 + int((i / len(chapters)) * 75))
utils.log_banner("WRITER", f"Chapter {ch['chapter_number']}/{len(chapters)}: {ch['title']}")
prev_content = ms[-1]['content'] if ms else None
while True:
try:
summary_ctx = summary[-8000:] if len(summary) > 8000 else summary
next_hint = chapters[i+1]['title'] if i + 1 < len(chapters) else ""
txt = story_writer.write_chapter(ch, bp, folder, summary_ctx, tracking, prev_content, next_chapter_hint=next_hint)
except Exception as e:
utils.log("SYSTEM", f"Chapter generation failed: {e}")
if interactive:
if Confirm.ask("Generation failed (quality/error). Retry?", default=True):
continue
raise e
if interactive:
print(f"\n--- Chapter {ch['chapter_number']} Preview ---\n{txt[:800]}...\n-------------------------------")
if Confirm.ask(f"Accept Chapter {ch['chapter_number']}?", default=True):
break
else:
utils.log("SYSTEM", "Regenerating chapter...")
else:
break
# Refine Persona to match the actual output (every 5 chapters)
if (i == 0 or i % 5 == 0) and txt:
bp['book_metadata']['author_details'] = style_persona.refine_persona(bp, txt, folder)
with open(bp_path, "w") as f: json.dump(bp, f, indent=2)
# Look ahead for context
next_info = ""
if i + 1 < len(chapters):
next_ch = chapters[i+1]
next_info = f"\nUPCOMING CONTEXT (Prioritize details relevant to this): {next_ch.get('title')} - {json.dumps(next_ch.get('beats', []))}"
try:
update_prompt = f"""
ROLE: Series Historian
TASK: Update the 'Story So Far' summary to include the events of this new chapter.
INPUT_DATA:
- CURRENT_SUMMARY:
{summary}
- NEW_CHAPTER_TEXT:
{txt}
- UPCOMING_CONTEXT_HINT: {next_info}
INSTRUCTIONS:
1. STYLE: Dense, factual, chronological bullet points. Avoid narrative prose.
2. CUMULATIVE: Do NOT remove old events. Append and integrate new information.
3. TRACKING: Explicitly note who met whom, who knows what, and current locations.
4. RELEVANCE: Ensure details needed for the UPCOMING CONTEXT are preserved.
OUTPUT: Updated summary text.
"""
resp_sum = ai_models.model_writer.generate_content(update_prompt)
utils.log_usage(folder, ai_models.model_writer.name, resp_sum.usage_metadata)
summary = resp_sum.text
except:
try:
resp_fallback = ai_models.model_writer.generate_content(f"ROLE: Summarizer\nTASK: Summarize plot points.\nTEXT: {txt}\nOUTPUT: Bullet points.")
utils.log_usage(folder, ai_models.model_writer.name, resp_fallback.usage_metadata)
summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text
except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]"
ms.append({'num': ch['chapter_number'], 'title': ch['title'], 'pov_character': ch.get('pov_character'), 'content': txt})
with open(ms_path, "w") as f: json.dump(ms, f, indent=2)
# Update Tracking
tracking = bible_tracker.update_tracking(folder, ch['chapter_number'], txt, tracking)
with open(events_track_path, "w") as f: json.dump(tracking['events'], f, indent=2)
with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2)
with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2)
# Dynamic Pacing Check (every other chapter)
remaining = chapters[i+1:]
if remaining and len(remaining) >= 2 and i % 2 == 1:
pacing = story_editor.check_pacing(bp, summary, txt, ch, remaining, folder)
if pacing and pacing.get('status') == 'add_bridge':
new_data = pacing.get('new_chapter', {})
if chapters:
avg_words = int(sum(c.get('estimated_words', 1500) for c in chapters) / len(chapters))
else:
avg_words = 1500
new_ch = {
"chapter_number": ch['chapter_number'] + 1,
"title": new_data.get('title', 'Bridge Chapter'),
"pov_character": new_data.get('pov_character', ch.get('pov_character')),
"pacing": "Slow",
"estimated_words": avg_words,
"beats": new_data.get('beats', [])
}
chapters.insert(i+1, new_ch)
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
utils.log("ARCHITECT", f" -> Pacing Intervention: Added bridge chapter '{new_ch['title']}' to fix rushing.")
elif pacing and pacing.get('status') == 'cut_next':
removed = chapters.pop(i+1)
for k in range(i+1, len(chapters)): chapters[k]['chapter_number'] = k + 1
with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2)
utils.log("ARCHITECT", f" -> Pacing Intervention: Removed redundant chapter '{removed['title']}'.")
elif pacing:
utils.log("ARCHITECT", f" -> Pacing OK. {pacing.get('reason', '')[:100]}")
# Increment loop
i += 1
duration = time.time() - ch_start
session_chapters += 1
session_time += duration
avg_time = session_time / session_chapters
eta = avg_time * (len(chapters) - (i + 1))
prog = 15 + int((i / len(chapters)) * 75)
utils.update_progress(prog)
word_count = len(txt.split()) if txt else 0
utils.log("TIMING", f" -> Ch {ch['chapter_number']} done in {duration:.1f}s | {word_count:,} words | Avg: {avg_time:.1f}s | ETA: {int(eta//60)}m {int(eta%60)}s")
utils.log("TIMING", f"Writing Phase: {time.time() - t_step:.1f}s")
# Harvest
t_step = time.time()
utils.update_progress(92)
bp = bible_tracker.harvest_metadata(bp, folder, ms)
with open(os.path.join(folder, "final_blueprint.json"), "w") as f: json.dump(bp, f, indent=2)
# Create Assets
utils.update_progress(95)
marketing_assets.create_marketing_assets(bp, folder, tracking, interactive=interactive)
# Update Persona
style_persona.update_persona_sample(bp, folder)
utils.update_progress(98)
exporter.compile_files(bp, ms, folder)
utils.log("TIMING", f"Post-Processing: {time.time() - t_step:.1f}s")
utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s")
finally:
if os.path.exists(lock_path): os.remove(lock_path)
def run_generation(target=None, specific_run_id=None, interactive=False):
ai_setup.init_models()
if not target: target = config.DEFAULT_BLUEPRINT
data = utils.load_json(target)
if not data:
utils.log("SYSTEM", f"Could not load {target}")
return
utils.log("SYSTEM", "Starting Series Generation...")
project_dir = os.path.dirname(os.path.abspath(target))
runs_base = os.path.join(project_dir, "runs")
run_dir = None
resume_mode = False
if specific_run_id:
run_dir = os.path.join(runs_base, f"run_{specific_run_id}")
if not os.path.exists(run_dir): os.makedirs(run_dir)
resume_mode = True
else:
latest_run = utils.get_latest_run_folder(runs_base)
if latest_run:
has_lock = False
for root, dirs, files in os.walk(latest_run):
if ".in_progress" in files:
has_lock = True
break
if has_lock:
if Confirm.ask(f"Found incomplete run '{os.path.basename(latest_run)}'. Resume generation?", default=True):
run_dir = latest_run
resume_mode = True
elif Confirm.ask(f"Delete artifacts in '{os.path.basename(latest_run)}' and start over?", default=False):
shutil.rmtree(latest_run)
os.makedirs(latest_run)
run_dir = latest_run
if not run_dir: run_dir = utils.get_run_folder(runs_base)
utils.log("SYSTEM", f"Run Directory: {run_dir}")
previous_context = ""
for i, book in enumerate(data['books']):
utils.log("SERIES", f"Processing Book {book.get('book_number')}: {book.get('title')}")
if os.path.exists(os.path.join(run_dir, ".stop")):
utils.log("SYSTEM", "Stop signal detected. Aborting series generation.")
break
meta = data['project_metadata']
bp = {
"book_metadata": {
"title": book.get('title'),
"filename": book.get('filename'),
"author": meta.get('author'),
"genre": meta.get('genre'),
"target_audience": meta.get('target_audience'),
"style": meta.get('style', {}),
"author_details": meta.get('author_details', {}),
"author_bio": meta.get('author_bio', ''),
},
"length_settings": meta.get('length_settings', {}),
"characters": data.get('characters', []),
"manual_instruction": book.get('manual_instruction', ''),
"plot_beats": book.get('plot_beats', []),
"series_metadata": {
"is_series": meta.get('is_series', False),
"series_title": meta.get('title', ''),
"book_number": book.get('book_number', i+1),
"total_books": len(data['books'])
}
}
safe_title = utils.sanitize_filename(book.get('title', f"Book_{i+1}"))
book_folder = os.path.join(run_dir, f"Book_{book.get('book_number', i+1)}_{safe_title}")
os.makedirs(book_folder, exist_ok=True)
process_book(bp, book_folder, context=previous_context, resume=resume_mode, interactive=interactive)
final_bp_path = os.path.join(book_folder, "final_blueprint.json")
if os.path.exists(final_bp_path):
final_bp = utils.load_json(final_bp_path)
new_chars = final_bp.get('characters', [])
if os.path.exists(target):
current_bible = utils.load_json(target)
existing_names = {c['name'].lower() for c in current_bible.get('characters', [])}
for char in new_chars:
if char['name'].lower() not in existing_names:
current_bible['characters'].append(char)
for b in current_bible.get('books', []):
if b.get('book_number') == book.get('book_number'):
b['title'] = final_bp['book_metadata'].get('title', b.get('title'))
b['plot_beats'] = final_bp.get('plot_beats', b.get('plot_beats'))
b['manual_instruction'] = final_bp.get('manual_instruction', b.get('manual_instruction'))
break
with open(target, 'w') as f: json.dump(current_bible, f, indent=2)
utils.log("SERIES", "Updated World Bible with new characters and plot data.")
last_beat = final_bp.get('plot_beats', [])[-1] if final_bp.get('plot_beats') else "End of book."
previous_context = f"PREVIOUS BOOK SUMMARY: {last_beat}\nCHARACTERS: {json.dumps(final_bp.get('characters', []))}"
return
if __name__ == "__main__":
target_arg = sys.argv[1] if len(sys.argv) > 1 else None
run_generation(target_arg, interactive=True)

806
cli/wizard.py Normal file
View File

@@ -0,0 +1,806 @@
import os
import sys
import json
from flask import Flask
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Prompt, IntPrompt, Confirm
from rich.table import Table
from core import config, utils
from ai import models as ai_models
from ai import setup as ai_setup
from web.db import db, User, Project
from marketing import cover as marketing_cover
from export import exporter
from cli.engine import run_generation
console = Console()
try:
ai_setup.init_models()
except Exception as e:
console.print(f"[bold red]CRITICAL: AI Model Initialization failed.[/bold red]")
console.print(f"[red]Error: {e}[/red]")
Prompt.ask("Press Enter to exit...")
sys.exit(1)
# --- DB SETUP FOR WIZARD ---
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.DATA_DIR, "bookapp.db")}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
# --- DEFINITIONS ---
if not os.path.exists(config.PROJECTS_DIR): os.makedirs(config.PROJECTS_DIR)
if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR)
class BookWizard:
def __init__(self):
self.project_name = "New_Project"
self.project_path = None
self.user = None
self.data = {
"project_metadata": {},
"characters": [],
"books": []
}
utils.create_default_personas()
def _get_or_create_wizard_user(self):
wizard_user = User.query.filter_by(username="wizard").first()
if not wizard_user:
console.print("[yellow]Creating default 'wizard' user for CLI operations...[/yellow]")
wizard_user = User(username="wizard", password="!", is_admin=True)
db.session.add(wizard_user)
db.session.commit()
return wizard_user
def clear(self): os.system('cls' if os.name == 'nt' else 'clear')
def ask_gemini_json(self, prompt):
text = None
try:
response = ai_models.model_logic.generate_content(prompt + "\nReturn ONLY valid JSON.")
text = utils.clean_json(response.text)
return json.loads(text)
except Exception as e:
console.print(f"[red]AI Error: {e}[/red]")
if text: console.print(f"[dim]Raw output: {text[:150]}...[/dim]")
return []
def ask_gemini_text(self, prompt):
try:
response = ai_models.model_logic.generate_content(prompt)
return response.text.strip()
except Exception as e:
console.print(f"[red]AI Error: {e}[/red]")
return ""
def manage_personas(self):
while True:
self.clear()
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
console.print(Panel("[bold cyan]Manage Author Personas[/bold cyan]"))
options = list(personas.keys())
for i, name in enumerate(options):
console.print(f"[{i+1}] {name}")
console.print(f"[{len(options)+1}] Create New Persona")
console.print(f"[{len(options)+2}] Back")
console.print(f"[{len(options)+3}] Exit")
choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, len(options)+4)]))
if choice == len(options) + 2: break
elif choice == len(options) + 3: sys.exit()
selected_key = None
details = {}
if choice == len(options) + 1:
console.print("[yellow]Define New Persona[/yellow]")
selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona")
else:
selected_key = options[choice-1]
details = personas[selected_key]
if isinstance(details, str): details = {"bio": details}
console.print(f"\n[bold]Selected: {selected_key}[/bold]")
console.print("1. Edit")
console.print("2. Delete")
console.print("3. Cancel")
sub = int(Prompt.ask("Action", choices=["1", "2", "3"], default="1"))
if sub == 2:
if Confirm.ask(f"Delete '{selected_key}'?", default=False):
del personas[selected_key]
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
continue
elif sub == 3:
continue
details['name'] = Prompt.ask("Author Name/Pseudonym", default=details.get('name', "AI Author"))
details['age'] = Prompt.ask("Age", default=details.get('age', "Unknown"))
details['gender'] = Prompt.ask("Gender", default=details.get('gender', "Unknown"))
details['race'] = Prompt.ask("Race/Ethnicity", default=details.get('race', "Unknown"))
details['nationality'] = Prompt.ask("Nationality/Country", default=details.get('nationality', "Unknown"))
details['language'] = Prompt.ask("Primary Language/Dialect", default=details.get('language', "Standard English"))
details['bio'] = Prompt.ask("Writing Style/Bio", default=details.get('bio', ""))
console.print("\n[bold]Style Samples[/bold]")
console.print(f"Place text files in the '{config.PERSONAS_DIR}' folder to reference them.")
curr_files = details.get('sample_files', [])
files_str = ",".join(curr_files)
new_files = Prompt.ask("Sample Text Files (comma sep filenames)", default=files_str)
details['sample_files'] = [x.strip() for x in new_files.split(',') if x.strip()]
details['sample_text'] = Prompt.ask("Manual Sample Paragraph", default=details.get('sample_text', ""))
if Confirm.ask("Save Persona?", default=True):
personas[selected_key] = details
with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2)
def select_mode(self):
while True:
self.clear()
console.print(Panel("[bold blue]BookApp Setup Wizard[/bold blue]"))
console.print("1. Create New Project")
console.print("2. Open Existing Project")
console.print("3. Manage Author Personas")
console.print("4. Exit")
choice = int(Prompt.ask("Select Mode", choices=["1", "2", "3", "4"], default="1"))
if choice == 1:
self.user = self._get_or_create_wizard_user()
return self.create_new_project()
elif choice == 2:
self.user = self._get_or_create_wizard_user()
if self.open_existing_project(): return True
elif choice == 3:
self.manage_personas()
else:
return False
def create_new_project(self):
self.clear()
console.print(Panel("[bold green]New Project Setup[/bold green]"))
console.print("Tell me about your story idea (or leave empty to start from scratch).")
concept = Prompt.ask("Story Concept")
suggestions = {}
if concept:
with console.status("[bold yellow]AI is analyzing your concept...[/bold yellow]"):
prompt = f"""
ROLE: Publishing Analyst
TASK: Suggest metadata for a story concept.
CONCEPT: {concept}
OUTPUT_FORMAT (JSON):
{{
"title": "String",
"genre": "String",
"target_audience": "String",
"tone": "String",
"length_category": "String (Select code: '01'=Chapter Book, '1'=Flash Fiction, '2'=Short Story, '2b'=Young Adult, '3'=Novella, '4'=Novel, '5'=Epic)",
"estimated_chapters": Int,
"estimated_word_count": "String (e.g. '75,000')",
"include_prologue": Bool,
"include_epilogue": Bool,
"tropes": ["String"],
"pov_style": "String",
"time_period": "String",
"spice": "String",
"violence": "String",
"is_series": Bool,
"series_title": "String",
"narrative_tense": "String",
"language_style": "String",
"dialogue_style": "String",
"page_orientation": "Portrait|Landscape|Square",
"formatting_rules": ["String (e.g. 'Chapter Headers: Number + Title')"]
}}
"""
suggestions = self.ask_gemini_json(prompt)
while True:
self.clear()
console.print(Panel("[bold green]AI Suggestions[/bold green]"))
grid = Table.grid(padding=(0, 2))
grid.add_column(style="bold cyan")
grid.add_column()
def get_str(k):
v = suggestions.get(k, 'N/A')
if isinstance(v, list): return ", ".join(v)
return str(v)
grid.add_row("Title:", get_str('title'))
grid.add_row("Genre:", get_str('genre'))
grid.add_row("Audience:", get_str('target_audience'))
grid.add_row("Tone:", get_str('tone'))
len_cat = suggestions.get('length_category', '4')
len_label = config.LENGTH_DEFINITIONS.get(len_cat, {}).get('label', 'Novel')
grid.add_row("Length:", len_label)
grid.add_row("Est. Chapters:", str(suggestions.get('estimated_chapters', 'N/A')))
grid.add_row("Est. Words:", str(suggestions.get('estimated_word_count', 'N/A')))
grid.add_row("Tropes:", get_str('tropes'))
grid.add_row("POV:", get_str('pov_style'))
grid.add_row("Time:", get_str('time_period'))
grid.add_row("Spice:", get_str('spice'))
grid.add_row("Violence:", get_str('violence'))
grid.add_row("Series:", "Yes" if suggestions.get('is_series') else "No")
console.print(grid)
console.print("\n[dim]These will be the defaults for the next step.[/dim]")
console.print("\n1. Continue (Manual Step-through)")
console.print("2. Refine Suggestions with AI")
choice = Prompt.ask("Select Option", choices=["1", "2"], default="1")
if choice == "1":
break
else:
instruction = Prompt.ask("Instruction (e.g. 'Make it darker', 'Change genre to Sci-Fi')")
with console.status("[bold yellow]Refining suggestions...[/bold yellow]"):
refine_prompt = f"""
ROLE: Publishing Analyst
TASK: Refine project metadata based on user instruction.
INPUT_DATA:
- CURRENT_JSON: {json.dumps(suggestions)}
- INSTRUCTION: {instruction}
OUTPUT_FORMAT (JSON): Same structure as input. Ensure length_category matches word count.
"""
new_sugg = self.ask_gemini_json(refine_prompt)
if new_sugg: suggestions = new_sugg
default_type = "2" if suggestions.get('is_series') else "1"
console.print("1. Standalone Book")
console.print("2. Series")
choice = int(Prompt.ask("Select Type", choices=["1", "2"], default=default_type))
is_series = (choice == 2)
self.configure_details(suggestions, concept, is_series)
self.enrich_blueprint()
self.refine_blueprint("Review & Edit Bible")
self.save_bible()
return True
def open_existing_project(self):
projects = Project.query.filter_by(user_id=self.user.id).order_by(Project.name).all()
if not projects:
console.print(f"[red]No projects found for user '{self.user.username}'. Create one first.[/red]")
Prompt.ask("Press Enter to continue...")
return False
console.print("\n[bold cyan]Select Project[/bold cyan]")
for i, p in enumerate(projects):
console.print(f"[{i+1}] {p.name}")
console.print(f"[{len(projects)+1}] Back")
console.print(f"[{len(projects)+2}] Exit")
choice = int(Prompt.ask("Select Project", choices=[str(i) for i in range(1, len(projects)+3)]))
if choice == len(projects) + 1: return False
if choice == len(projects) + 2: sys.exit()
selected_project = projects[choice-1]
self.project_name = selected_project.name
self.project_path = selected_project.folder_path
return True
def load_bible(self):
if not self.project_path:
console.print("[red]No project loaded.[/red]")
return False
path = os.path.join(self.project_path, "bible.json")
if os.path.exists(path):
with open(path, 'r') as f: self.data = json.load(f)
return True
console.print("[red]Bible not found.[/red]")
return False
def configure_details(self, suggestions=None, concept="", is_series=False):
if suggestions is None: suggestions = {}
console.print("\n[bold blue]Project Details[/bold blue]")
personas = {}
if os.path.exists(config.PERSONAS_FILE):
try:
with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f)
except: pass
author_details = {}
if personas:
console.print("\n[bold]Select Author Persona[/bold]")
opts = list(personas.keys())
for i, p in enumerate(opts): console.print(f"[{i+1}] {p}")
console.print(f"[{len(opts)+1}] None (AI Default)")
sel = IntPrompt.ask("Option", choices=[str(i) for i in range(1, len(opts)+2)], default=len(opts)+1)
if sel <= len(opts):
author_details = personas[opts[sel-1]]
if isinstance(author_details, str): author_details = {"bio": author_details}
default_author = author_details.get('name', "AI Co-Pilot")
author = Prompt.ask("Author Name", default=default_author)
genre = Prompt.ask("Genre", default=suggestions.get('genre', "Fiction"))
# LENGTH SELECTION
table = Table(title="Target Length Options")
table.add_column("#"); table.add_column("Type"); table.add_column("Est. Words"); table.add_column("Chapters")
for k, v in config.LENGTH_DEFINITIONS.items():
table.add_row(k, v['label'], v['words'], str(v['chapters']))
console.print(table)
def_len = suggestions.get('length_category', "4")
if def_len not in config.LENGTH_DEFINITIONS: def_len = "4"
len_choice = Prompt.ask("Select Target Length", choices=list(config.LENGTH_DEFINITIONS.keys()), default=def_len)
settings = config.LENGTH_DEFINITIONS[len_choice].copy()
def_chapters = suggestions.get('estimated_chapters', settings['chapters'])
def_words = suggestions.get('estimated_word_count', settings['words'])
def_prologue = suggestions.get('include_prologue', False)
def_epilogue = suggestions.get('include_epilogue', False)
settings['chapters'] = IntPrompt.ask("Target Chapters", default=int(def_chapters))
settings['words'] = Prompt.ask("Target Word Count", default=str(def_words))
settings['include_prologue'] = Confirm.ask("Include Prologue?", default=def_prologue)
settings['include_epilogue'] = Confirm.ask("Include Epilogue?", default=def_epilogue)
# Genre Standard Check
w_str = str(settings.get('words', '0')).replace(',', '').replace('+', '').lower()
avg_words = 0
if '-' in w_str:
parts = w_str.split('-')
try: avg_words = (int(parts[0].strip()) + int(parts[1].strip().replace('k','000'))) // 2
except: pass
else:
try: avg_words = int(w_str.replace('k', '000'))
except: pass
std_target = 0
g_lower = genre.lower()
if "fantasy" in g_lower or "sci-fi" in g_lower or "space" in g_lower or "epic" in g_lower: std_target = 100000
elif "thriller" in g_lower or "horror" in g_lower or "historical" in g_lower: std_target = 85000
elif "romance" in g_lower or "mystery" in g_lower: std_target = 70000
elif "young adult" in g_lower or "ya" in g_lower: std_target = 60000
if std_target > 0 and avg_words > 0:
if abs(std_target - avg_words) / std_target > 0.25:
console.print(f"\n[bold yellow]Genre Advisory:[/bold yellow] Standard length for {genre} is approx {std_target:,} words.")
if Confirm.ask(f"Update target to {std_target:,} words?", default=True):
settings['words'] = f"{std_target:,}"
# TROPES
console.print("\n[italic]Note: Tropes are recurring themes (e.g. 'Chosen One').[/italic]")
def_tropes = ", ".join(suggestions.get('tropes', []))
tropes_input = Prompt.ask("Tropes/Themes (comma sep)", default=def_tropes)
sel_tropes = [x.strip() for x in tropes_input.split(',')] if tropes_input else []
title = Prompt.ask("Book Title (Leave empty for AI)", default=suggestions.get('title', ""))
default_proj = utils.sanitize_filename(title) if title else "New_Project"
self.project_name = Prompt.ask("Project Name (Folder)", default=default_proj)
user_dir = os.path.join(config.DATA_DIR, "users", str(self.user.id))
if not os.path.exists(user_dir): os.makedirs(user_dir)
self.project_path = os.path.join(user_dir, self.project_name)
if os.path.exists(self.project_path):
console.print(f"[yellow]Warning: Project folder '{self.project_path}' already exists.[/yellow]")
new_proj = Project(user_id=self.user.id, name=self.project_name, folder_path=self.project_path)
db.session.add(new_proj)
db.session.commit()
console.print(f"[green]Project '{self.project_name}' created in database.[/green]")
console.print("\n[italic]Note: Tone describes the overall mood or atmosphere (e.g. Dark, Whimsical, Cynical, Hopeful).[/italic]")
tone = Prompt.ask("Tone", default=suggestions.get('tone', "Balanced"))
pov_style = Prompt.ask("POV Style (e.g. 'Third Person Limited', 'First Person')", default=suggestions.get('pov_style', "Third Person Limited"))
pov_chars_input = Prompt.ask("POV Characters (comma sep, leave empty if single protagonist)", default="")
pov_chars = [x.strip() for x in pov_chars_input.split(',')] if pov_chars_input else []
tense = Prompt.ask("Narrative Tense (e.g. 'Past', 'Present')", default=suggestions.get('narrative_tense', "Past"))
console.print("\n[bold]Content Guidelines[/bold]")
spice = Prompt.ask("Spice/Romance (e.g. 'Clean', 'Fade-to-Black', 'Explicit')", default=suggestions.get('spice', "Standard"))
violence = Prompt.ask("Violence (e.g. 'None', 'Mild', 'Graphic')", default=suggestions.get('violence', "Standard"))
language = Prompt.ask("Language (e.g. 'No Swearing', 'Mild', 'Heavy')", default=suggestions.get('language_style', "Standard"))
dialogue_style = Prompt.ask("Dialogue Style (e.g. 'Witty', 'Formal', 'Slang-heavy')", default=suggestions.get('dialogue_style', "Standard"))
console.print("\n[bold]Formatting & World Rules[/bold]")
time_period = Prompt.ask("Time Period/Tech (e.g. 'Modern', '1990s', 'No Cellphones')", default=suggestions.get('time_period', "Modern"))
orientation = Prompt.ask("Page Orientation", choices=["Portrait", "Landscape", "Square"], default=suggestions.get('page_orientation', "Portrait"))
console.print("[italic]Define formatting rules (e.g. 'Chapter Headers: POV + Title', 'Text Messages: Italic').[/italic]")
def_fmt = ", ".join(suggestions.get('formatting_rules', ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"]))
fmt_input = Prompt.ask("Formatting Rules (comma sep)", default=def_fmt)
fmt_rules = [x.strip() for x in fmt_input.split(',')] if fmt_input else []
style_data = {
"tone": tone, "tropes": sel_tropes,
"pov_style": pov_style, "pov_characters": pov_chars,
"tense": tense, "spice": spice, "violence": violence, "language": language,
"dialogue_style": dialogue_style, "time_period": time_period,
"page_orientation": orientation,
"formatting_rules": fmt_rules
}
self.data['project_metadata'] = {
"title": title,
"author": author,
"author_details": author_details,
"author_bio": author_details.get('bio', ''),
"genre": genre,
"target_audience": Prompt.ask("Audience", default=suggestions.get('target_audience', "Adult")),
"is_series": is_series,
"length_settings": settings,
"style": style_data
}
self.data['books'] = []
if is_series:
count = IntPrompt.ask("How many books in the series?", default=3)
for i in range(count):
self.data['books'].append({
"book_number": i+1,
"title": f"Book {i+1}",
"manual_instruction": concept if i==0 else "",
"plot_beats": []
})
else:
self.data['books'].append({
"book_number": 1,
"title": title,
"manual_instruction": concept,
"plot_beats": []
})
def enrich_blueprint(self):
console.print("\n[bold yellow]Generating full Book Bible (Characters, Plot, etc.)...[/bold yellow]")
prompt = f"""
ROLE: Creative Director
TASK: Create a comprehensive Book Bible.
INPUT_DATA:
- METADATA: {json.dumps(self.data['project_metadata'])}
- BOOKS: {json.dumps(self.data['books'])}
INSTRUCTIONS:
1. Create Main Characters.
2. For EACH book: Generate Title, Plot Summary (manual_instruction), and 10-15 Plot Beats.
OUTPUT_FORMAT (JSON):
{{
"characters": [ {{ "name": "String", "role": "String", "description": "String" }} ],
"books": [
{{ "book_number": Int, "title": "String", "manual_instruction": "String", "plot_beats": ["String"] }}
]
}}
"""
new_data = self.ask_gemini_json(prompt)
if new_data:
if 'characters' in new_data:
self.data['characters'] = new_data['characters']
self.data['characters'] = [c for c in self.data['characters'] if c.get('name') and c.get('name').lower() not in ['name', 'character name', 'role', 'protagonist', 'unknown']]
if 'books' in new_data:
ai_books = {b.get('book_number'): b for b in new_data['books']}
for i, book in enumerate(self.data['books']):
b_num = book.get('book_number', i+1)
if b_num in ai_books:
src = ai_books[b_num]
book['title'] = src.get('title', book['title'])
book['manual_instruction'] = src.get('manual_instruction', book['manual_instruction'])
book['plot_beats'] = src.get('plot_beats', [])
console.print("[green]Blueprint enriched (Missing data filled)![/green]")
def display_summary(self, data):
meta = data.get('project_metadata', {})
length = meta.get('length_settings', {})
style = meta.get('style', {})
grid = Table.grid(padding=(0, 2))
grid.add_column(style="bold cyan")
grid.add_column()
grid.add_row("Title:", meta.get('title', 'N/A'))
grid.add_row("Author:", meta.get('author', 'N/A'))
grid.add_row("Genre:", meta.get('genre', 'N/A'))
grid.add_row("Audience:", meta.get('target_audience', 'N/A'))
ordered_keys = [
"tone", "pov_style", "pov_characters",
"tense", "spice", "violence", "language", "dialogue_style", "time_period", "page_orientation",
"tropes"
]
defaults = {
"tone": "Balanced", "pov_style": "Third Person Limited", "tense": "Past",
"spice": "Standard", "violence": "Standard", "language": "Standard",
"dialogue_style": "Standard", "time_period": "Modern", "page_orientation": "Portrait"
}
for k in ordered_keys:
val = style.get(k)
if val in [None, "", "N/A"]:
val = defaults.get(k, 'N/A')
if isinstance(val, list): val = ", ".join(val)
if isinstance(val, bool): val = "Yes" if val else "No"
grid.add_row(f"{k.replace('_', ' ').title()}:", str(val))
for k, v in style.items():
if k not in ordered_keys and k != 'formatting_rules':
val = ", ".join(v) if isinstance(v, list) else str(v)
grid.add_row(f"{k.replace('_', ' ').title()}:", val)
len_str = f"{length.get('label', 'N/A')} ({length.get('words', 'N/A')} words, {length.get('chapters', 'N/A')} ch)"
extras = []
if length.get('include_prologue'): extras.append("Prologue")
if length.get('include_epilogue'): extras.append("Epilogue")
if extras: len_str += f" + {', '.join(extras)}"
grid.add_row("Length:", len_str)
grid.add_row("Series:", "Yes" if meta.get('is_series') else "No")
console.print(Panel(grid, title="[bold blue]Project Metadata[/bold blue]", expand=False))
fmt_rules = style.get('formatting_rules', [])
if fmt_rules:
fmt_table = Table(title="Formatting Rules", show_header=False, box=None, expand=True)
for i, r in enumerate(fmt_rules):
fmt_table.add_row(f"[bold]{i+1}.[/bold]", str(r))
console.print(Panel(fmt_table, title="[bold blue]Formatting[/bold blue]"))
char_table = Table(title="Characters", show_header=True, header_style="bold magenta", expand=True)
char_table.add_column("Name", style="green")
char_table.add_column("Role")
char_table.add_column("Description")
for c in data.get('characters', []):
char_table.add_row(c.get('name', '-'), c.get('role', '-'), c.get('description', '-'))
console.print(char_table)
for book in data.get('books', []):
console.print(f"\n[bold cyan]Book {book.get('book_number')}: {book.get('title')}[/bold cyan]")
console.print(f"[italic]{book.get('manual_instruction')}[/italic]")
beats = book.get('plot_beats', [])
if beats:
beat_table = Table(show_header=False, box=None, expand=True)
for i, b in enumerate(beats):
beat_table.add_row(f"[bold]{i+1}.[/bold]", str(b))
console.print(beat_table)
def refine_blueprint(self, title="Refine Blueprint"):
while True:
self.clear()
console.print(Panel(f"[bold blue]{title}[/bold blue]"))
self.display_summary(self.data)
console.print("\n[dim](Full JSON loaded)[/dim]")
change = Prompt.ask("\n[bold green]Enter instruction to change (e.g. 'Make it darker', 'Rename Bob', 'Add a twist') or 'done'[/bold green]")
if change.lower() == 'done': break
current_data = self.data
instruction = change
while True:
with console.status("[bold green]AI is updating blueprint...[/bold green]"):
prompt = f"""
ROLE: Senior Editor
TASK: Update the Bible JSON based on instruction.
INPUT_DATA:
- CURRENT_JSON: {json.dumps(current_data)}
- INSTRUCTION: {instruction}
OUTPUT_FORMAT (JSON): The full updated JSON object.
"""
new_data = self.ask_gemini_json(prompt)
if not new_data:
console.print("[red]AI failed to generate valid JSON.[/red]")
break
self.clear()
console.print(Panel("[bold blue]Review AI Changes[/bold blue]"))
self.display_summary(new_data)
feedback = Prompt.ask("\n[bold green]Is this good? (Type 'yes' to save, or enter feedback to refine)[/bold green]")
if feedback.lower() == 'yes':
self.data = new_data
console.print("[green]Changes saved![/green]")
break
else:
current_data = new_data
instruction = feedback
def save_bible(self):
if not self.project_path:
console.print("[red]Project path not set. Cannot save bible.[/red]")
return None
if not os.path.exists(self.project_path): os.makedirs(self.project_path)
filename = os.path.join(self.project_path, "bible.json")
with open(filename, 'w') as f: json.dump(self.data, f, indent=2)
console.print(Panel(f"[bold green]Bible saved to: {filename}[/bold green]"))
return filename
def manage_runs(self):
runs_dir = os.path.join(self.project_path, "runs")
if not os.path.exists(runs_dir):
console.print("[red]No runs found for this project.[/red]")
Prompt.ask("Press Enter...")
return
runs = sorted([d for d in os.listdir(runs_dir) if os.path.isdir(os.path.join(runs_dir, d)) and d.startswith("run_")], key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0, reverse=True)
if not runs:
console.print("[red]No runs found.[/red]")
Prompt.ask("Press Enter...")
return
while True:
self.clear()
console.print(Panel(f"[bold blue]Runs for: {self.project_name}[/bold blue]"))
for i, r in enumerate(runs):
console.print(f"[{i+1}] {r}")
console.print(f"[{len(runs)+1}] Back")
console.print(f"[{len(runs)+2}] Exit")
choice = int(Prompt.ask("Select Run", choices=[str(i) for i in range(1, len(runs)+3)]))
if choice == len(runs) + 1: break
elif choice == len(runs) + 2: sys.exit()
selected_run = runs[choice-1]
run_path = os.path.join(runs_dir, selected_run)
self.manage_specific_run(run_path)
def manage_specific_run(self, run_path):
while True:
self.clear()
console.print(Panel(f"[bold blue]Run: {os.path.basename(run_path)}[/bold blue]"))
subdirs = sorted([d for d in os.listdir(run_path) if os.path.isdir(os.path.join(run_path, d)) and d.startswith("Book_")])
if subdirs:
console.print("[italic]Series Run Detected[/italic]")
for i, s in enumerate(subdirs):
console.print(f"[{i+1}] Manage {s}")
idx_open = len(subdirs) + 1
idx_back = len(subdirs) + 2
idx_exit = len(subdirs) + 3
console.print(f"[{idx_open}] Open Run Folder")
console.print(f"[{idx_back}] Back")
console.print(f"[{idx_exit}] Exit")
choice = int(Prompt.ask("Select Option", choices=[str(i) for i in range(1, idx_exit+1)]))
if choice <= len(subdirs):
book_path = os.path.join(run_path, subdirs[choice-1])
self.manage_single_book_folder(book_path)
elif choice == idx_open:
self.open_folder(run_path)
elif choice == idx_back:
break
elif choice == idx_exit:
sys.exit()
def manage_single_book_folder(self, folder_path):
while True:
self.clear()
console.print(Panel(f"[bold blue]Manage: {os.path.basename(folder_path)}[/bold blue]"))
console.print("1. Regenerate Cover & Recompile EPUB")
console.print("2. Open Folder")
console.print("3. Back")
choice = int(Prompt.ask("Select Action", choices=["1", "2", "3"]))
if choice == 1:
bp_path = os.path.join(folder_path, "final_blueprint.json")
ms_path = os.path.join(folder_path, "manuscript.json")
if os.path.exists(bp_path) and os.path.exists(ms_path):
with console.status("[bold yellow]Regenerating Cover...[/bold yellow]"):
with open(bp_path, 'r') as f: bp = json.load(f)
with open(ms_path, 'r') as f: ms = json.load(f)
events_path = os.path.join(folder_path, "tracking_events.json")
chars_path = os.path.join(folder_path, "tracking_characters.json")
tracking = {"events": [], "characters": {}}
if os.path.exists(events_path): tracking['events'] = utils.load_json(events_path)
if os.path.exists(chars_path): tracking['characters'] = utils.load_json(chars_path)
ai_setup.init_models()
if not tracking['events'] and not tracking['characters']:
console.print("[yellow]Tracking missing. Populating from Blueprint...[/yellow]")
tracking['events'] = bp.get('plot_beats', [])
tracking['characters'] = {}
for c in bp.get('characters', []):
name = c.get('name', 'Unknown')
tracking['characters'][name] = {
"descriptors": [c.get('description', '')],
"likes_dislikes": [],
"last_worn": "Unknown"
}
with open(events_path, 'w') as f: json.dump(tracking['events'], f, indent=2)
with open(chars_path, 'w') as f: json.dump(tracking['characters'], f, indent=2)
marketing_cover.generate_cover(bp, folder_path, tracking)
exporter.compile_files(bp, ms, folder_path)
console.print("[green]Cover updated and EPUB recompiled![/green]")
Prompt.ask("Press Enter...")
else:
console.print("[red]Missing blueprint or manuscript in run folder.[/red]")
Prompt.ask("Press Enter...")
elif choice == 2:
self.open_folder(folder_path)
elif choice == 3:
break
def open_folder(self, path):
if os.name == 'nt':
os.startfile(path)
else:
os.system(f"open '{path}'")
if __name__ == "__main__":
w = BookWizard()
with app.app_context():
try:
if w.select_mode():
while True:
w.clear()
console.print(Panel(f"[bold blue]Project: {w.project_name}[/bold blue]"))
console.print("1. Edit Bible")
console.print("2. Run Book Generation")
console.print("3. Manage Runs")
console.print("4. Exit")
choice = int(Prompt.ask("Select Option", choices=["1", "2", "3", "4"]))
if choice == 1:
if w.load_bible():
w.refine_blueprint("Refine Bible")
w.save_bible()
elif choice == 2:
if w.load_bible():
bible_path = os.path.join(w.project_path, "bible.json")
run_generation(bible_path, interactive=True)
Prompt.ask("\nGeneration complete. Press Enter...")
elif choice == 3:
w.manage_runs()
else:
break
except KeyboardInterrupt: console.print("\n[red]Cancelled.[/red]")