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:
806
cli/wizard.py
Normal file
806
cli/wizard.py
Normal 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]")
|
||||
Reference in New Issue
Block a user