Adding files.

This commit is contained in:
2026-02-03 10:13:33 -05:00
parent fc44a7834a
commit 9dec4a472f
34 changed files with 5984 additions and 0 deletions

858
wizard.py Normal file
View File

@@ -0,0 +1,858 @@
import os
import sys
import json
import config
import google.generativeai as genai
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 modules import ai, utils
from modules.web_db import db, User, Project
console = Console()
genai.configure(api_key=config.API_KEY)
# Validate Key on Launch
try:
list(genai.list_models(page_size=1))
except Exception as e:
console.print(f"[bold red]❌ CRITICAL: Gemini API Key check failed.[/bold red]")
console.print(f"[red]Error: {e}[/red]")
console.print("Please check your .env file and ensure GEMINI_API_KEY is correct.")
Prompt.ask("Press Enter to exit...")
sys.exit(1)
logic_name = ai.get_optimal_model("pro") if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT
model = genai.GenerativeModel(logic_name, safety_settings=utils.SAFETY_SETTINGS)
# --- 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):
# Find or create a default user for CLI operations
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) # Password not used for CLI
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 = model.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 = model.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:
# Create
console.print("[yellow]Define New Persona[/yellow]")
selected_key = Prompt.ask("Persona Label (e.g. 'Gritty Detective')", default="New Persona")
else:
# Edit/Delete Menu for specific persona
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
# Edit Fields
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', ""))
# Samples
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:
# Personas don't need a user context
self.manage_personas()
else:
return False
def create_new_project(self):
self.clear()
console.print(Panel("[bold green]🆕 New Project Setup[/bold green]"))
# 1. Ask for Concept first to guide defaults
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"""
Analyze this story concept and suggest metadata for a book or series.
CONCEPT: {concept}
RETURN JSON with these keys:
- title: Suggested book title
- genre: Genre
- target_audience: e.g. Adult, YA
- tone: e.g. Dark, Whimsical
- length_category: One of ["00", "0", "01", "1", "2", "2b", "3", "4", "5"] based on likely depth.
- estimated_chapters: int (suggested chapter count)
- estimated_word_count: string (e.g. "75,000")
- include_prologue: boolean
- include_epilogue: boolean
- tropes: list of strings
- pov_style: e.g. First Person
- time_period: e.g. Modern
- spice: e.g. Standard, Explicit
- violence: e.g. None, Graphic
- is_series: boolean
- series_title: string (if series)
- narrative_tense: e.g. Past, Present
- language_style: e.g. Standard, Flowery
- dialogue_style: e.g. Witty, Formal
- page_orientation: Portrait, Landscape, or Square
- formatting_rules: list of strings
"""
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"""
Update these project suggestions based on the user instruction.
CURRENT JSON: {json.dumps(suggestions)}
INSTRUCTION: {instruction}
RETURN ONLY VALID JSON with the same keys.
"""
new_sugg = self.ask_gemini_json(refine_prompt)
if new_sugg: suggestions = new_sugg
# 2. Select Type (with AI default)
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):
# Query projects from the database for the wizard user
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]")
# Simplified Persona Selection (Skip creation)
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)
# Create a copy so we don't modify the global definition
settings = config.LENGTH_DEFINITIONS[len_choice].copy()
# AI Defaults
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 ---
# Parse current word count selection
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
# Define rough standards
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 difference is > 25%, warn user
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
# If series, this is Series Title. If book, Book Title.
title = Prompt.ask("Book Title (Leave empty for AI)", default=suggestions.get('title', ""))
# PROJECT NAME
default_proj = "".join([c for c in title if c.isalnum() or c=='_']).replace(" ", "_") if title else "New_Project"
self.project_name = Prompt.ask("Project Name (Folder)", default=default_proj)
# Create Project in DB and set path
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 SETTINGS
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 []
# ADVANCED STYLE
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"))
# Visuals
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 []
# Update book_metadata with new fields
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
}
# Initialize Books List
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"""
You are a Creative Director.
Create a comprehensive Book Bible for the following project.
PROJECT METADATA: {json.dumps(self.data['project_metadata'])}
EXISTING BOOKS STRUCTURE: {json.dumps(self.data['books'])}
TASK:
1. Create a list of Main Characters (Global for the project).
2. For EACH book in the 'books' list:
- Generate a catchy Title (if not provided).
- Write a 'manual_instruction' (Plot Summary).
- Generate 'plot_beats' (10-15 chronological beats).
RETURN JSON in standard Bible format:
{{
"characters": [ {{ "name": "...", "role": "...", "description": "..." }} ],
"books": [
{{ "book_number": 1, "title": "...", "manual_instruction": "...", "plot_beats": ["...", "..."] }},
...
]
}}
"""
new_data = self.ask_gemini_json(prompt)
if new_data:
if 'characters' in new_data:
self.data['characters'] = new_data['characters']
if 'books' in new_data:
# Merge book data carefully
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', {})
# Metadata Grid
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'))
# Dynamic Style Display
# Define explicit order for common fields
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"
}
# 1. Show ordered keys first
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))
# 2. Show remaining keys
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))
# Formatting Rules Table
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]"))
# Characters Table
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', []):
# Removed truncation to show full description
char_table.add_row(c.get('name', '-'), c.get('role', '-'), c.get('description', '-'))
console.print(char_table)
# Books List
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
# Inner loop for refinement
current_data = self.data
instruction = change
while True:
with console.status("[bold green]AI is updating blueprint...[/bold green]"):
prompt = f"""
Act as a Book Editor.
CURRENT JSON: {json.dumps(current_data)}
USER INSTRUCTION: {instruction}
TASK: Update the JSON based on the instruction. Maintain valid JSON structure.
RETURN ONLY THE JSON.
"""
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, job_filename):
job_name = os.path.splitext(job_filename)[0]
runs_dir = os.path.join(self.project_path, "runs", job_name)
if not os.path.exists(runs_dir):
console.print("[red]No runs found for this job.[/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: {job_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]"))
# Detect sub-books (Series Run)
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()
else:
# Legacy or Flat Run
self.manage_single_book_folder(run_path)
break
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:
import main
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)
# Check/Generate Tracking
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)
main.ai.init_models()
if not tracking['events'] and not tracking['characters']:
# Fallback: Use Blueprint data
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)
main.marketing.generate_cover(bp, folder_path, tracking)
main.export.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")
import main
main.run_generation(bible_path)
Prompt.ask("\nGeneration complete. Press Enter...")
elif choice == 3:
# Manage runs for the bible
w.manage_runs("bible.json")
else:
break
else:
pass
except KeyboardInterrupt: console.print("\n[red]Cancelled.[/red]")