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>
807 lines
36 KiB
Python
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]")
|