Files
bookapp/cli/wizard.py
Mike Wichers f7099cc3e4 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>
2026-02-20 22:20:53 -05:00

807 lines
36 KiB
Python

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]")