From 9dec4a472f2e0e98347b8c164d2512ec65490576 Mon Sep 17 00:00:00 2001 From: Mike Wichers Date: Tue, 3 Feb 2026 10:13:33 -0500 Subject: [PATCH] Adding files. --- .dockerignore | 23 + .gitignore | 6 + Dockerfile | 27 + __init__.py | 1 + config.py | 61 ++ credentials.json | 1 + docker-compose.yml | 33 + main.py | 320 +++++++++ make_admin.py | 19 + modules/ai.py | 215 ++++++ modules/export.py | 74 +++ modules/marketing.py | 350 ++++++++++ modules/requirements_web.txt | 15 + modules/story.py | 626 ++++++++++++++++++ modules/utils.py | 202 ++++++ modules/web_app.py | 1132 ++++++++++++++++++++++++++++++++ modules/web_db.py | 47 ++ modules/web_tasks.py | 218 ++++++ requirements.txt | 10 + templates/admin_dashboard.html | 104 +++ templates/admin_spend.html | 60 ++ templates/base.html | 86 +++ templates/dashboard.html | 98 +++ templates/login.html | 27 + templates/persona_edit.html | 121 ++++ templates/personas.html | 32 + templates/project.html | 515 +++++++++++++++ templates/project_review.html | 92 +++ templates/project_setup.html | 161 +++++ templates/register.html | 27 + templates/run_details.html | 298 +++++++++ templates/system_status.html | 124 ++++ token.json | 1 + wizard.py | 858 ++++++++++++++++++++++++ 34 files changed, 5984 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 __init__.py create mode 100644 config.py create mode 100644 credentials.json create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 make_admin.py create mode 100644 modules/ai.py create mode 100644 modules/export.py create mode 100644 modules/marketing.py create mode 100644 modules/requirements_web.txt create mode 100644 modules/story.py create mode 100644 modules/utils.py create mode 100644 modules/web_app.py create mode 100644 modules/web_db.py create mode 100644 modules/web_tasks.py create mode 100644 requirements.txt create mode 100644 templates/admin_dashboard.html create mode 100644 templates/admin_spend.html create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/login.html create mode 100644 templates/persona_edit.html create mode 100644 templates/personas.html create mode 100644 templates/project.html create mode 100644 templates/project_review.html create mode 100644 templates/project_setup.html create mode 100644 templates/register.html create mode 100644 templates/run_details.html create mode 100644 templates/system_status.html create mode 100644 token.json create mode 100644 wizard.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2f83373 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# Ignore git files +.git +.gitignore + +# Ignore virtual environments +venv/ +env/ + +# Ignore data (it will be mounted as a volume instead) +data/ + +# Ignore local secrets (they will be mounted or passed as env vars) +.env +credentials.json +token.json + +# Ignore cache +__pycache__ +*.pyc + +# Ignore local libs (dependencies are installed in the image) +libs/ +token.json \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53c4523 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +__pycache__/ +run_*/ +*.docx +*.epub +data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..82a1072 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies required for Pillow (image processing) +RUN apt-get update && apt-get install -y \ + build-essential \ + libjpeg-dev \ + zlib1g-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements files +COPY requirements.txt . +COPY modules/requirements_web.txt ./modules/ + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir -r modules/requirements_web.txt + +# Copy the rest of the application +COPY . . + +# Set Python path and run +ENV PYTHONPATH=/app +EXPOSE 5000 +CMD ["python", "-m", "modules.web_app"] \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..500fac0 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +# BookApp Modules \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..13956bb --- /dev/null +++ b/config.py @@ -0,0 +1,61 @@ +import os +from dotenv import load_dotenv + +# Ensure .env is loaded from the script's directory (VS Code fix) +load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")) + +def get_clean_env(key, default=None): + val = os.getenv(key, default) + return val.strip() if val else None + +API_KEY = get_clean_env("GEMINI_API_KEY") +GCP_PROJECT = get_clean_env("GCP_PROJECT") +GCP_LOCATION = get_clean_env("GCP_LOCATION", "us-central1") +MODEL_LOGIC_HINT = get_clean_env("MODEL_LOGIC", "AUTO") +MODEL_WRITER_HINT = get_clean_env("MODEL_WRITER", "AUTO") +MODEL_ARTIST_HINT = get_clean_env("MODEL_ARTIST", "AUTO") +DEFAULT_BLUEPRINT = "book_def.json" + +# --- SECURITY & ADMIN --- +FLASK_SECRET = get_clean_env("FLASK_SECRET_KEY", "dev-secret-key-change-this") +ADMIN_USER = get_clean_env("ADMIN_USERNAME") +ADMIN_PASSWORD = get_clean_env("ADMIN_PASSWORD") + +if not API_KEY: raise ValueError("❌ CRITICAL ERROR: GEMINI_API_KEY not found.") + +# --- DATA DIRECTORIES --- +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +DATA_DIR = os.path.join(BASE_DIR, "data") +PROJECTS_DIR = os.path.join(DATA_DIR, "projects") +PERSONAS_DIR = os.path.join(DATA_DIR, "personas") +PERSONAS_FILE = os.path.join(PERSONAS_DIR, "personas.json") +FONTS_DIR = os.path.join(DATA_DIR, "fonts") + +# --- ENSURE DIRECTORIES EXIST --- +# Critical: Create data folders immediately to prevent DB initialization errors +for d in [DATA_DIR, PROJECTS_DIR, PERSONAS_DIR, FONTS_DIR]: + if not os.path.exists(d): os.makedirs(d, exist_ok=True) + +# --- AUTHENTICATION --- +GOOGLE_CREDS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") +if GOOGLE_CREDS: + # Resolve to absolute path relative to this config file if not absolute + if not os.path.isabs(GOOGLE_CREDS): + base = os.path.dirname(os.path.abspath(__file__)) + GOOGLE_CREDS = os.path.join(base, GOOGLE_CREDS) + + if os.path.exists(GOOGLE_CREDS): + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = GOOGLE_CREDS + else: + print(f"⚠️ Warning: GOOGLE_APPLICATION_CREDENTIALS file not found at: {GOOGLE_CREDS}") + +# --- DEFINITIONS --- +LENGTH_DEFINITIONS = { + "01": {"label": "Chapter Book", "words": "5,000 - 10,000", "chapters": 10, "depth": 1}, + "1": {"label": "Flash Fiction", "words": "500 - 1,500", "chapters": 1, "depth": 1}, + "2": {"label": "Short Story", "words": "5,000 - 10,000", "chapters": 5, "depth": 1}, + "2b": {"label": "Young Adult", "words": "50,000 - 70,000", "chapters": 25, "depth": 3}, + "3": {"label": "Novella", "words": "20,000 - 40,000", "chapters": 15, "depth": 2}, + "4": {"label": "Novel", "words": "60,000 - 80,000", "chapters": 30, "depth": 3}, + "5": {"label": "Epic", "words": "100,000+", "chapters": 50, "depth": 4} +} \ No newline at end of file diff --git a/credentials.json b/credentials.json new file mode 100644 index 0000000..15e39ce --- /dev/null +++ b/credentials.json @@ -0,0 +1 @@ +{"installed":{"client_id":"439342248586-7c31edogtkpr1ml95a12c8mfr9vtan6r.apps.googleusercontent.com","project_id":"tarotdeck-479923","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"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","redirect_uris":["http://localhost"]}} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7166f2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +version: '3.8' + +services: + bookapp: + build: . + container_name: bookapp + restart: unless-stopped + ports: + - "5000:5000" + working_dir: /app + volumes: + # --- PERSISTENCE (Data & Login) --- + # These mounts ensure your projects and login tokens survive container resets + # HOST_PATH is defined in Portainer Environment Variables (e.g. /opt/bookapp) + - ${HOST_PATH:-.}/data:/app/data + - ${HOST_PATH:-.}/token.json:/app/token.json + - ${HOST_PATH:-.}/credentials.json:/app/credentials.json + # --- DEVELOPMENT (Code Sync) --- + # UNCOMMENT these lines only if you are developing and want to see changes instantly. + # For production/deployment, keep them commented out so the container uses the built image code. + # - ./modules:/app/modules + # - ./templates:/app/templates + # - ./main.py:/app/main.py + # - ./wizard.py:/app/wizard.py + # - ./config.py:/app/config.py + environment: + - PYTHONUNBUFFERED=1 + - GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json + - PYTHONPATH=/app + - FLASK_SECRET_KEY=change_this_to_a_random_string + - ADMIN_USERNAME=admin + - ADMIN_PASSWORD=change_me_in_portainer + - GEMINI_API_KEY=${GEMINI_API_KEY} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..3c5e46e --- /dev/null +++ b/main.py @@ -0,0 +1,320 @@ +import json, os, time, sys, shutil +import config +from rich.prompt import Confirm +from modules import ai, story, marketing, export, utils + +def process_book(bp, folder, context="", resume=False): + # Create lock file to indicate active processing + lock_path = os.path.join(folder, ".in_progress") + with open(lock_path, "w") as f: f.write("running") + + total_start = time.time() + + # 1. Check completion + if resume and os.path.exists(os.path.join(folder, "final_blueprint.json")): + utils.log("SYSTEM", f"Book in {folder} already finished. Skipping.") + + # Clean up zombie lock file if it exists + if os.path.exists(lock_path): os.remove(lock_path) + return + + # 2. Load or Create Blueprint + bp_path = os.path.join(folder, "blueprint_initial.json") + t_step = time.time() + if resume and os.path.exists(bp_path): + utils.log("RESUME", "Loading existing blueprint...") + saved_bp = utils.load_json(bp_path) + # Merge latest metadata from Bible (passed in bp) into saved blueprint + if saved_bp: + if 'book_metadata' in bp and 'book_metadata' in saved_bp: + for k in ['title', 'author', 'genre', 'target_audience', 'style', 'author_bio', 'author_details']: + if k in bp['book_metadata']: + saved_bp['book_metadata'][k] = bp['book_metadata'][k] + if 'series_metadata' in bp: + saved_bp['series_metadata'] = bp['series_metadata'] + bp = saved_bp + with open(bp_path, "w") as f: json.dump(bp, f, indent=2) + else: + bp = utils.normalize_settings(bp) + bp = story.enrich(bp, folder, context) + with open(bp_path, "w") as f: json.dump(bp, f, indent=2) + + # Ensure Persona Exists (Auto-create if missing) + if 'author_details' not in bp['book_metadata'] or not bp['book_metadata']['author_details']: + bp['book_metadata']['author_details'] = story.create_initial_persona(bp, folder) + with open(bp_path, "w") as f: json.dump(bp, f, indent=2) + + utils.log("TIMING", f"Blueprint Phase: {time.time() - t_step:.1f}s") + + # 3. Events (Plan & Expand) + events_path = os.path.join(folder, "events.json") + t_step = time.time() + if resume and os.path.exists(events_path): + utils.log("RESUME", "Loading existing events...") + events = utils.load_json(events_path) + else: + events = story.plan_structure(bp, folder) + depth = bp['length_settings']['depth'] + target_chaps = bp['length_settings']['chapters'] + for d in range(1, depth+1): + events = story.expand(events, d, target_chaps, bp, folder) + time.sleep(1) + with open(events_path, "w") as f: json.dump(events, f, indent=2) + utils.log("TIMING", f"Structure & Expansion: {time.time() - t_step:.1f}s") + + # 4. Chapter Plan + chapters_path = os.path.join(folder, "chapters.json") + t_step = time.time() + if resume and os.path.exists(chapters_path): + utils.log("RESUME", "Loading existing chapter plan...") + chapters = utils.load_json(chapters_path) + else: + chapters = story.create_chapter_plan(events, bp, folder) + with open(chapters_path, "w") as f: json.dump(chapters, f, indent=2) + utils.log("TIMING", f"Chapter Planning: {time.time() - t_step:.1f}s") + + # 5. Writing Loop + ms_path = os.path.join(folder, "manuscript.json") + ms = utils.load_json(ms_path) if (resume and os.path.exists(ms_path)) else [] + + # Load Tracking + events_track_path = os.path.join(folder, "tracking_events.json") + chars_track_path = os.path.join(folder, "tracking_characters.json") + warn_track_path = os.path.join(folder, "tracking_warnings.json") + + tracking = {"events": [], "characters": {}, "content_warnings": []} + if resume: + if os.path.exists(events_track_path): + tracking['events'] = utils.load_json(events_track_path) + if os.path.exists(chars_track_path): + tracking['characters'] = utils.load_json(chars_track_path) + if os.path.exists(warn_track_path): + tracking['content_warnings'] = utils.load_json(warn_track_path) + + summary = "The story begins." + if ms: + # Generate summary from ALL written chapters to maintain continuity + utils.log("RESUME", "Rebuilding 'Story So Far' from existing manuscript...") + try: + combined_text = "\n".join([f"Chapter {c['num']}: {c['content']}" for c in ms]) + resp_sum = ai.model_writer.generate_content(f"Create a detailed, cumulative 'Story So Far' summary from the following text. Use dense, factual bullet points. Focus on character meetings, relationships, and known information:\n{combined_text}") + utils.log_usage(folder, "writer-flash", resp_sum.usage_metadata) + summary = resp_sum.text + except: summary = "The story continues." + + t_step = time.time() + session_chapters = 0 + session_time = 0 + + for i in range(len(ms), len(chapters)): + ch_start = time.time() + ch = chapters[i] + + # Pass previous chapter content for continuity if available + prev_content = ms[-1]['content'] if ms else None + + txt = story.write_chapter(ch, bp, folder, summary, tracking, prev_content) + + # Refine Persona to match the actual output (Consistency Loop) + if (i == 0 or i % 3 == 0) and txt: + bp['book_metadata']['author_details'] = story.refine_persona(bp, txt, folder) + with open(bp_path, "w") as f: json.dump(bp, f, indent=2) + + # Look ahead for context to ensure relevant details are captured + next_info = "" + if i + 1 < len(chapters): + next_ch = chapters[i+1] + next_info = f"\nUPCOMING CONTEXT (Prioritize details relevant to this): {next_ch.get('title')} - {json.dumps(next_ch.get('beats', []))}" + + try: + update_prompt = f""" + Update the 'Story So Far' summary to include the events of this new chapter. + + STYLE: Dense, factual, chronological bullet points. Avoid narrative prose. + GOAL: Maintain a perfect memory of the plot for continuity. + + CRITICAL INSTRUCTIONS: + 1. CUMULATIVE: Do NOT remove old events. Append and integrate new information. + 2. TRACKING: Explicitly note who met whom, who knows what, and current locations. + 3. RELEVANCE: Ensure details needed for the UPCOMING CONTEXT are preserved. + + CURRENT STORY SO FAR: + {summary} + + NEW CHAPTER CONTENT: + {txt} + {next_info} + """ + resp_sum = ai.model_writer.generate_content(update_prompt) + utils.log_usage(folder, "writer-flash", resp_sum.usage_metadata) + summary = resp_sum.text + except: + try: + resp_fallback = ai.model_writer.generate_content(f"Summarize plot points:\n{txt}") + utils.log_usage(folder, "writer-flash", resp_fallback.usage_metadata) + summary += f"\n\nChapter {ch['chapter_number']}: " + resp_fallback.text + except: summary += f"\n\nChapter {ch['chapter_number']}: [Content processed]" + + ms.append({'num': ch['chapter_number'], 'title': ch['title'], 'pov_character': ch.get('pov_character'), 'content': txt}) + + with open(ms_path, "w") as f: json.dump(ms, f, indent=2) + + # Update Tracking + tracking = story.update_tracking(folder, ch['chapter_number'], txt, tracking) + with open(events_track_path, "w") as f: json.dump(tracking['events'], f, indent=2) + with open(chars_track_path, "w") as f: json.dump(tracking['characters'], f, indent=2) + with open(warn_track_path, "w") as f: json.dump(tracking.get('content_warnings', []), f, indent=2) + + duration = time.time() - ch_start + session_chapters += 1 + session_time += duration + avg_time = session_time / session_chapters + eta = avg_time * (len(chapters) - (i + 1)) + utils.log("TIMING", f" -> Chapter {ch['chapter_number']} finished in {duration:.1f}s | Avg: {avg_time:.1f}s | ETA: {int(eta//60)}m {int(eta%60)}s") + + utils.log("TIMING", f"Writing Phase: {time.time() - t_step:.1f}s") + + # Harvest + t_step = time.time() + bp = story.harvest_metadata(bp, folder, ms) + with open(os.path.join(folder, "final_blueprint.json"), "w") as f: json.dump(bp, f, indent=2) + + # Create Assets + marketing.create_marketing_assets(bp, folder, tracking) + + # Update Persona + story.update_persona_sample(bp, folder) + + export.compile_files(bp, ms, folder) + utils.log("TIMING", f"Post-Processing: {time.time() - t_step:.1f}s") + utils.log("SYSTEM", f"Book Finished. Total Time: {time.time() - total_start:.1f}s") + + # Remove lock file on success + if os.path.exists(lock_path): os.remove(lock_path) + +# --- 6. ENTRY POINT --- +def run_generation(target=None, specific_run_id=None): + ai.init_models() + + if not target: target = config.DEFAULT_BLUEPRINT + data = utils.load_json(target) + + if not data: + utils.log("SYSTEM", f"Could not load {target}") + return + + # --- NEW BIBLE FORMAT SUPPORT --- + if 'project_metadata' in data and 'books' in data: + utils.log("SYSTEM", "Detected Bible Format. Starting Series Generation...") + + # Determine Run Directory: projects/{Project}/runs/bible/run_X + # target is likely .../projects/{Project}/bible.json + project_dir = os.path.dirname(os.path.abspath(target)) + runs_base = os.path.join(project_dir, "runs", "bible") + + run_dir = None + resume_mode = False + + if specific_run_id: + # WEB/WORKER MODE: Non-interactive, specific ID + run_dir = os.path.join(runs_base, f"run_{specific_run_id}") + if not os.path.exists(run_dir): os.makedirs(run_dir) + resume_mode = True # Always try to resume if files exist in this specific run + else: + # CLI MODE: Interactive checks + latest_run = utils.get_latest_run_folder(runs_base) + if latest_run: + has_lock = False + for root, dirs, files in os.walk(latest_run): + if ".in_progress" in files: + has_lock = True + break + + if has_lock: + if Confirm.ask(f"Found incomplete run '{os.path.basename(latest_run)}'. Resume generation?", default=True): + run_dir = latest_run + resume_mode = True + elif Confirm.ask(f"Delete artifacts in '{os.path.basename(latest_run)}' and start over?", default=False): + shutil.rmtree(latest_run) + os.makedirs(latest_run) + run_dir = latest_run + + if not run_dir: run_dir = utils.get_run_folder(runs_base) + utils.log("SYSTEM", f"Run Directory: {run_dir}") + + previous_context = "" + + for i, book in enumerate(data['books']): + utils.log("SERIES", f"Processing Book {book.get('book_number')}: {book.get('title')}") + + # Adapter: Bible -> Blueprint + meta = data['project_metadata'] + bp = { + "book_metadata": { + "title": book.get('title'), + "filename": book.get('filename'), + "author": meta.get('author'), + "genre": meta.get('genre'), + "target_audience": meta.get('target_audience'), + "style": meta.get('style', {}), + "author_details": meta.get('author_details', {}), + "author_bio": meta.get('author_bio', ''), + }, + "length_settings": meta.get('length_settings', {}), + "characters": data.get('characters', []), + "manual_instruction": book.get('manual_instruction', ''), + "plot_beats": book.get('plot_beats', []), + "series_metadata": { + "is_series": meta.get('is_series', False), + "series_title": meta.get('title', ''), + "book_number": book.get('book_number', i+1), + "total_books": len(data['books']) + } + } + + # Create Book Subfolder + safe_title = "".join([c for c in book.get('title', f"Book_{i+1}") if c.isalnum() or c=='_']).replace(" ", "_") + book_folder = os.path.join(run_dir, f"Book_{book.get('book_number', i+1)}_{safe_title}") + if not os.path.exists(book_folder): os.makedirs(book_folder) + + # Process + process_book(bp, book_folder, context=previous_context, resume=resume_mode) + + # Update Context for next book + final_bp_path = os.path.join(book_folder, "final_blueprint.json") + if os.path.exists(final_bp_path): + final_bp = utils.load_json(final_bp_path) + + # --- Update World Bible with new characters --- + # This ensures future books know about characters invented in this book + new_chars = final_bp.get('characters', []) + + # RELOAD BIBLE to avoid race conditions (User might have edited it in UI) + if os.path.exists(target): + current_bible = utils.load_json(target) + + # 1. Merge New Characters + existing_names = {c['name'].lower() for c in current_bible.get('characters', [])} + for char in new_chars: + if char['name'].lower() not in existing_names: + current_bible['characters'].append(char) + + # 2. Sync Generated Book Metadata (Title, Beats) back to Bible + for b in current_bible.get('books', []): + if b.get('book_number') == book.get('book_number'): + b['title'] = final_bp['book_metadata'].get('title', b.get('title')) + b['plot_beats'] = final_bp.get('plot_beats', b.get('plot_beats')) + b['manual_instruction'] = final_bp.get('manual_instruction', b.get('manual_instruction')) + break + + with open(target, 'w') as f: json.dump(current_bible, f, indent=2) + utils.log("SERIES", "Updated World Bible with new characters and plot data.") + + last_beat = final_bp.get('plot_beats', [])[-1] if final_bp.get('plot_beats') else "End of book." + previous_context = f"PREVIOUS BOOK SUMMARY: {last_beat}\nCHARACTERS: {json.dumps(final_bp.get('characters', []))}" + + return + +if __name__ == "__main__": + target_arg = sys.argv[1] if len(sys.argv) > 1 else None + run_generation(target_arg) \ No newline at end of file diff --git a/make_admin.py b/make_admin.py new file mode 100644 index 0000000..fbe3fae --- /dev/null +++ b/make_admin.py @@ -0,0 +1,19 @@ +import sys +from modules.web_app import app +from modules.web_db import db, User + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python make_admin.py ") + sys.exit(1) + + username = sys.argv[1] + + with app.app_context(): + user = User.query.filter_by(username=username).first() + if user: + user.is_admin = True + db.session.commit() + print(f"✅ Success: User '{username}' has been promoted to Admin.") + else: + print(f"❌ Error: User '{username}' not found. Please register via the Web UI first.") \ No newline at end of file diff --git a/modules/ai.py b/modules/ai.py new file mode 100644 index 0000000..ee00587 --- /dev/null +++ b/modules/ai.py @@ -0,0 +1,215 @@ +import os +import sys +import json +import time +import warnings +import google.generativeai as genai +import config +from . import utils + +# Suppress Vertex AI warnings +warnings.filterwarnings("ignore", category=UserWarning, module="vertexai") + +try: + import vertexai + from vertexai.preview.vision_models import ImageGenerationModel as VertexImageModel + HAS_VERTEX = True +except ImportError: + HAS_VERTEX = False + +try: + from google.auth.transport.requests import Request + from google.oauth2.credentials import Credentials + from google_auth_oauthlib.flow import InstalledAppFlow + HAS_OAUTH = True +except ImportError: + HAS_OAUTH = False + +model_logic = None +model_writer = None +model_artist = None +model_image = None + +def get_optimal_model(base_type="pro"): + try: + models = [m for m in genai.list_models() if 'generateContent' in m.supported_generation_methods] + candidates = [m.name for m in models if base_type in m.name] + if not candidates: return f"models/gemini-1.5-{base_type}" + def score(n): + # Prioritize stable models (higher quotas) over experimental/beta ones + if "exp" in n or "beta" in n: return 0 + if "latest" in n: return 50 + return 100 + return sorted(candidates, key=score, reverse=True)[0] + except: return f"models/gemini-1.5-{base_type}" + +def get_default_models(): + return { + "logic": {"model": "models/gemini-1.5-pro", "reason": "Fallback: Default Pro model selected."}, + "writer": {"model": "models/gemini-1.5-flash", "reason": "Fallback: Default Flash model selected."}, + "artist": {"model": "models/gemini-1.5-flash", "reason": "Fallback: Default Flash model selected."}, + "ranking": [] + } + +def select_best_models(force_refresh=False): + """ + Uses a safe bootstrapper model to analyze available models and pick the best ones. + Caches the result for 24 hours. + """ + cache_path = os.path.join(config.DATA_DIR, "model_cache.json") + cached_models = None + + # 1. Check Cache + if os.path.exists(cache_path): + try: + with open(cache_path, 'r') as f: + cached = json.load(f) + cached_models = cached.get('models', {}) + # Check if within 24 hours (86400 seconds) + if not force_refresh and time.time() - cached.get('timestamp', 0) < 86400: + models = cached_models + # Validate format (must be dicts with reasons, not just strings) + if isinstance(models.get('logic'), dict) and 'reason' in models['logic']: + utils.log("SYSTEM", "Using cached AI model selection (valid for 24h).") + return models + except Exception as e: + utils.log("SYSTEM", f"Cache read failed: {e}. Refreshing models.") + + try: + utils.log("SYSTEM", "Refreshing AI model list from API...") + models = [m.name for m in genai.list_models() if 'generateContent' in m.supported_generation_methods] + + bootstrapper = "models/gemini-1.5-flash" + if bootstrapper not in models: + candidates = [m for m in models if 'flash' in m] + bootstrapper = candidates[0] if candidates else "models/gemini-pro" + utils.log("SYSTEM", f"Bootstrapping model selection with: {bootstrapper}") + + model = genai.GenerativeModel(bootstrapper) + prompt = f"Analyze this list of available Google Gemini models:\n{json.dumps(models)}\n\nSelect the best model for each of these three roles based on these criteria:\n- Most recent version with best features and ability.\n- Beta versions are okay, but avoid 'experimental' if a stable beta/prod version exists.\n- Consider quota efficiency (Flash is cheaper/faster, Pro is smarter).\n\nROLES:\n1. LOGIC: For complex reasoning, JSON structuring, and plot planning.\n2. WRITER: For creative fiction writing, prose generation, and speed.\n3. ARTIST: For generating visual art prompts and design instructions.\n\nAlso provide a 'ranking' list of ALL models analyzed, ordered from best/most useful to worst/least useful, with a short reason.\n\nReturn JSON: {{ 'logic': {{ 'model': 'model_name', 'reason': 'reasoning' }}, 'writer': {{ 'model': 'model_name', 'reason': 'reasoning' }}, 'artist': {{ 'model': 'model_name', 'reason': 'reasoning' }}, 'ranking': [ {{ 'model': 'model_name', 'reason': 'reasoning' }} ] }}" + + response = model.generate_content(prompt) + selection = json.loads(utils.clean_json(response.text)) + + if not os.path.exists(config.DATA_DIR): os.makedirs(config.DATA_DIR) + with open(cache_path, 'w') as f: + json.dump({"timestamp": int(time.time()), "models": selection, "available_at_time": models}, f, indent=2) + return selection + except Exception as e: + utils.log("SYSTEM", f"AI Model Selection failed: {e}.") + + # 3. Fallback to Stale Cache if available (Better than heuristics) + # Relaxed check: If we successfully loaded ANY JSON from the cache, use it. + if cached_models: + utils.log("SYSTEM", "⚠️ Using stale cached models due to API failure.") + return cached_models + + utils.log("SYSTEM", "Falling back to heuristics.") + fallback = get_default_models() + + # Save fallback to cache if file doesn't exist OR if we couldn't load it (corrupt/None) + # This ensures we have a valid file on disk for the web UI to read. + try: + with open(cache_path, 'w') as f: + json.dump({"timestamp": int(time.time()), "models": fallback, "error": str(e)}, f, indent=2) + except: pass + return fallback + +def init_models(force=False): + global model_logic, model_writer, model_artist, model_image + if model_logic and not force: return + genai.configure(api_key=config.API_KEY) + + # Check cache to skip frequent validation + cache_path = os.path.join(config.DATA_DIR, "model_cache.json") + skip_validation = False + if not force and os.path.exists(cache_path): + try: + with open(cache_path, 'r') as f: cached = json.load(f) + if time.time() - cached.get('timestamp', 0) < 86400: skip_validation = True + except: pass + + if not skip_validation: + # Validate Gemini API Key + utils.log("SYSTEM", "Validating credentials...") + try: + list(genai.list_models(page_size=1)) + utils.log("SYSTEM", "✅ Gemini API Key is valid.") + except Exception as e: + # Check if we have a cache file we can rely on before exiting + if os.path.exists(cache_path): + utils.log("SYSTEM", f"⚠️ API check failed ({e}), but cache exists. Attempting to use cached models.") + else: + utils.log("SYSTEM", f"⚠️ API check failed ({e}). No cache found. Attempting to initialize with defaults.") + + utils.log("SYSTEM", "Selecting optimal models via AI...") + selected_models = select_best_models(force_refresh=force) + + def get_model_name(role_data): + if isinstance(role_data, dict): return role_data.get('model') + return role_data + + logic_name = get_model_name(selected_models['logic']) if config.MODEL_LOGIC_HINT == "AUTO" else config.MODEL_LOGIC_HINT + writer_name = get_model_name(selected_models['writer']) if config.MODEL_WRITER_HINT == "AUTO" else config.MODEL_WRITER_HINT + artist_name = get_model_name(selected_models['artist']) if config.MODEL_ARTIST_HINT == "AUTO" else config.MODEL_ARTIST_HINT + utils.log("SYSTEM", f"Models: Logic={logic_name} | Writer={writer_name} | Artist={artist_name}") + + model_logic = genai.GenerativeModel(logic_name, safety_settings=utils.SAFETY_SETTINGS) + model_writer = genai.GenerativeModel(writer_name, safety_settings=utils.SAFETY_SETTINGS) + model_artist = genai.GenerativeModel(artist_name, safety_settings=utils.SAFETY_SETTINGS) + + # Initialize Image Model (Default to None) + model_image = None + if hasattr(genai, 'ImageGenerationModel'): + try: model_image = genai.ImageGenerationModel("imagen-3.0-generate-001") + except: pass + + img_source = "Gemini API" if model_image else "None" + + if HAS_VERTEX and config.GCP_PROJECT: + creds = None + # Handle OAuth Client ID (credentials.json) if provided instead of Service Account + if HAS_OAUTH: + gac = config.GOOGLE_CREDS # Use persistent config, not volatile env var + if gac and os.path.exists(gac): + try: + with open(gac, 'r') as f: data = json.load(f) + if 'installed' in data or 'web' in data: + # It's an OAuth Client ID. Unset env var to avoid library crash. + if "GOOGLE_APPLICATION_CREDENTIALS" in os.environ: + del os.environ["GOOGLE_APPLICATION_CREDENTIALS"] + + token_path = os.path.join(os.path.dirname(os.path.abspath(gac)), 'token.json') + SCOPES = ['https://www.googleapis.com/auth/cloud-platform'] + + if os.path.exists(token_path): + creds = Credentials.from_authorized_user_file(token_path, SCOPES) + + if not creds or not creds.valid: + if creds and creds.expired and creds.refresh_token: + try: + creds.refresh(Request()) + except Exception: + utils.log("SYSTEM", "Token refresh failed. Re-authenticating...") + flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES) + creds = flow.run_local_server(port=0) + else: + utils.log("SYSTEM", "OAuth Client ID detected. Launching browser to authenticate...") + flow = InstalledAppFlow.from_client_secrets_file(gac, SCOPES) + creds = flow.run_local_server(port=0) + with open(token_path, 'w') as token: token.write(creds.to_json()) + + utils.log("SYSTEM", "✅ Authenticated via OAuth Client ID.") + except Exception as e: + utils.log("SYSTEM", f"⚠️ OAuth check failed: {e}") + + vertexai.init(project=config.GCP_PROJECT, location=config.GCP_LOCATION, credentials=creds) + utils.log("SYSTEM", f"✅ Vertex AI initialized (Project: {config.GCP_PROJECT})") + + # Override with Vertex Image Model if available + try: + model_image = VertexImageModel.from_pretrained("imagen-3.0-generate-001") + img_source = "Vertex AI" + except: pass + + utils.log("SYSTEM", f"Image Generation Provider: {img_source}") \ No newline at end of file diff --git a/modules/export.py b/modules/export.py new file mode 100644 index 0000000..1f4189c --- /dev/null +++ b/modules/export.py @@ -0,0 +1,74 @@ +import os +import markdown +from docx import Document +from ebooklib import epub +from . import utils + +def create_readme(folder, bp): + meta = bp['book_metadata'] + ls = bp['length_settings'] + content = f"""# {meta['title']}\n**Generated by BookApp**\n\n## Stats Used\n- **Type:** {ls.get('label', 'Custom')}\n- **Planned Chapters:** {ls['chapters']}\n- **Logic Depth:** {ls['depth']}\n- **Target Words:** {ls.get('words', 'Unknown')}""" + with open(os.path.join(folder, "README.md"), "w") as f: f.write(content) + +def compile_files(bp, ms, folder): + utils.log("SYSTEM", "Compiling EPUB and DOCX...") + meta = bp.get('book_metadata', {}) + title = meta.get('title', 'Untitled') + + if meta.get('filename'): + safe = meta['filename'] + else: + safe = "".join([c for c in title if c.isalnum() or c=='_']).replace(" ", "_") + + doc = Document(); doc.add_heading(title, 0) + book = epub.EpubBook(); book.set_title(title); spine = ['nav'] + + # Add Cover if exists + cover_path = os.path.join(folder, "cover.png") + if os.path.exists(cover_path): + with open(cover_path, 'rb') as f: + book.set_cover("cover.png", f.read()) + + for c in ms: + # Determine filename/type + num_str = str(c['num']).lower() + if num_str == '0' or 'prologue' in num_str: + filename = "prologue.xhtml" + default_header = f"Prologue: {c['title']}" + elif 'epilogue' in num_str: + filename = "epilogue.xhtml" + default_header = f"Epilogue: {c['title']}" + else: + filename = f"ch_{c['num']}.xhtml" + default_header = f"Ch {c['num']}: {c['title']}" + + # Check for AI-generated header in content + content = c['content'].strip() + clean_content = content.replace("```markdown", "").replace("```", "").strip() + lines = clean_content.split('\n') + + ai_header = None + body_content = clean_content + + if lines and lines[0].strip().startswith('# '): + ai_header = lines[0].strip().replace('#', '').strip() + header = ai_header + body_content = "\n".join(lines[1:]).strip() + else: + header = default_header + + doc.add_heading(header, 1) + doc.add_paragraph(body_content) + + ch = epub.EpubHtml(title=header, file_name=filename) + + clean_content = clean_content.replace(f"{folder}\\", "").replace(f"{folder}/", "") + html_content = markdown.markdown(clean_content) + ch.content = html_content if ai_header else f"

{header}

{html_content}" + + book.add_item(ch); spine.append(ch) + + doc.save(os.path.join(folder, f"{safe}.docx")) + book.spine = spine; book.add_item(epub.EpubNcx()); book.add_item(epub.EpubNav()) + epub.write_epub(os.path.join(folder, f"{safe}.epub"), book, {}) + create_readme(folder, bp) \ No newline at end of file diff --git a/modules/marketing.py b/modules/marketing.py new file mode 100644 index 0000000..e9d91f7 --- /dev/null +++ b/modules/marketing.py @@ -0,0 +1,350 @@ +import os +import json +import shutil +import textwrap +import requests +import google.generativeai as genai +from . import utils +import config +from modules import ai + +try: + from PIL import Image, ImageDraw, ImageFont, ImageStat + HAS_PIL = True +except ImportError: + HAS_PIL = False + +def download_font(font_name): + """Attempts to download a Google Font from GitHub.""" + if not font_name: font_name = "Roboto" + if not os.path.exists(config.FONTS_DIR): os.makedirs(config.FONTS_DIR) + + # Handle CSS-style lists (e.g. "Roboto, sans-serif") + if "," in font_name: font_name = font_name.split(",")[0].strip() + + # Handle filenames provided by AI + if font_name.lower().endswith(('.ttf', '.otf')): + font_name = os.path.splitext(font_name)[0] + + font_name = font_name.strip().strip("'").strip('"') + for suffix in ["-Regular", " Regular", " regular", "Regular", " Bold", " Italic"]: + if font_name.endswith(suffix): + font_name = font_name[:-len(suffix)] + font_name = font_name.strip() + + clean_name = font_name.replace(" ", "").lower() + font_filename = f"{clean_name}.ttf" + font_path = os.path.join(config.FONTS_DIR, font_filename) + + if os.path.exists(font_path) and os.path.getsize(font_path) > 1000: + utils.log("ASSETS", f"Using cached font: {font_path}") + return font_path + + utils.log("ASSETS", f"Downloading font: {font_name}...") + compact_name = font_name.replace(" ", "") + title_compact = "".join(x.title() for x in font_name.split()) + + patterns = [ + f"static/{title_compact}-Regular.ttf", f"{title_compact}-Regular.ttf", + f"{title_compact}[wght].ttf", f"{title_compact}[wdth,wght].ttf", + f"static/{compact_name}-Regular.ttf", f"{compact_name}-Regular.ttf", + f"{title_compact}-Regular.otf", + ] + + headers = {"User-Agent": "Mozilla/5.0 (BookApp/1.0)"} + for license_type in ["ofl", "apache", "ufl"]: + base_url = f"https://github.com/google/fonts/raw/main/{license_type}/{clean_name}" + for pattern in patterns: + try: + r = requests.get(f"{base_url}/{pattern}", headers=headers, timeout=5) + if r.status_code == 200 and len(r.content) > 1000: + with open(font_path, 'wb') as f: f.write(r.content) + utils.log("ASSETS", f"✅ Downloaded {font_name} to {font_path}") + return font_path + except Exception: continue + + if clean_name != "roboto": + utils.log("ASSETS", f"⚠️ Font '{font_name}' not found. Falling back to Roboto.") + return download_font("Roboto") + return None + +def evaluate_image_quality(image_path, prompt, model, folder=None): + if not HAS_PIL: return None, "PIL not installed" + try: + img = Image.open(image_path) + response = model.generate_content([f"Analyze this generated image against the description: '{prompt}'.\nRate accuracy/relevance on a scale of 1-10.\nProvide a 1-sentence critique.\nReturn JSON: {{'score': int, 'reason': 'string'}}", img]) + if folder: utils.log_usage(folder, "logic-pro", response.usage_metadata) + data = json.loads(utils.clean_json(response.text)) + return data.get('score'), data.get('reason') + except Exception as e: return None, str(e) + +def generate_blurb(bp, folder): + utils.log("MARKETING", "Generating blurb...") + meta = bp.get('book_metadata', {}) + + prompt = f""" + Write a compelling back-cover blurb (approx 150-200 words) for this book. + TITLE: {meta.get('title')} + GENRE: {meta.get('genre')} + LOGLINE: {bp.get('manual_instruction')} + PLOT: {json.dumps(bp.get('plot_beats', []))} + CHARACTERS: {json.dumps(bp.get('characters', []))} + """ + try: + response = ai.model_writer.generate_content(prompt) + utils.log_usage(folder, "writer-flash", response.usage_metadata) + blurb = response.text + with open(os.path.join(folder, "blurb.txt"), "w") as f: f.write(blurb) + with open(os.path.join(folder, "back_cover.txt"), "w") as f: f.write(blurb) + except: + utils.log("MARKETING", "Failed to generate blurb.") + +def generate_cover(bp, folder, tracking=None, feedback=None): + if not HAS_PIL: + utils.log("MARKETING", "Pillow not installed. Skipping image cover.") + return + + utils.log("MARKETING", "Generating cover...") + meta = bp.get('book_metadata', {}) + series = bp.get('series_metadata', {}) + + orientation = meta.get('style', {}).get('page_orientation', 'Portrait') + ar = "3:4" + if orientation == "Landscape": ar = "4:3" + elif orientation == "Square": ar = "1:1" + + visual_context = "" + if tracking: + visual_context = "IMPORTANT VISUAL CONTEXT:\n" + if 'events' in tracking: + visual_context += f"Key Events/Themes: {json.dumps(tracking['events'][-5:])}\n" + if 'characters' in tracking: + visual_context += f"Character Appearances: {json.dumps(tracking['characters'])}\n" + + # Feedback Analysis + regenerate_image = True + design_instruction = "" + + if feedback and feedback.strip(): + utils.log("MARKETING", f"Analyzing feedback: '{feedback}'...") + analysis_prompt = f""" + User Feedback on Book Cover: "{feedback}" + Determine if the user wants to: + 1. Keep the current background image but change text/layout/color (REGENERATE_LAYOUT). + 2. Create a completely new background image (REGENERATE_IMAGE). + + NOTE: If the feedback is generic (e.g. "regenerate", "try again") or does not explicitly mention keeping the image/changing text only, default to REGENERATE_IMAGE. + Return JSON: {{ "action": "REGENERATE_LAYOUT" or "REGENERATE_IMAGE", "instruction": "Specific instruction for the Art Director" }} + """ + try: + resp = ai.model_logic.generate_content(analysis_prompt) + decision = json.loads(utils.clean_json(resp.text)) + if decision.get('action') == 'REGENERATE_LAYOUT': + regenerate_image = False + utils.log("MARKETING", "Feedback indicates keeping image. Regenerating layout only.") + design_instruction = decision.get('instruction', feedback) + except: + utils.log("MARKETING", "Feedback analysis failed. Defaulting to full regeneration.") + + design_prompt = f""" + Act as an Art Director. Design the cover for this book. + TITLE: {meta.get('title')} + GENRE: {meta.get('genre')} + TONE: {meta.get('style', {}).get('tone')} + + CRITICAL INSTRUCTIONS: + 1. CHARACTER APPEARANCE: Strictly adhere to the provided character descriptions (hair, eyes, race, age, clothing) in the Visual Context. + 2. GENRE EXPRESSIONS: Ensure character facial expressions and body language heavily reflect the GENRE (e.g. Horror = terrified/menacing, Romance = longing/soft, Thriller = intense/alert). + + {visual_context} + {f"USER FEEDBACK: {feedback}" if feedback else ""} + {f"INSTRUCTION: {design_instruction}" if design_instruction else ""} + + Provide JSON output: + {{ + "font_name": "Name of a popular Google Font (e.g. Roboto, Cinzel, Oswald, Playfair Display)", + "primary_color": "#HexCode (Background)", + "text_color": "#HexCode (Contrast)", + "art_prompt": "A detailed description of the cover art for an image generator. Explicitly describe characters based on the visual context." + }} + """ + try: + response = ai.model_artist.generate_content(design_prompt) + utils.log_usage(folder, "artist-flash", response.usage_metadata) + design = json.loads(utils.clean_json(response.text)) + + bg_color = design.get('primary_color', '#252570') + text_color = design.get('text_color', '#FFFFFF') + + art_prompt = design.get('art_prompt', f"Cover art for {meta.get('title')}") + with open(os.path.join(folder, "cover_art_prompt.txt"), "w") as f: + f.write(art_prompt) + + img = None + image_generated = False + width, height = 600, 900 + + best_img_score = 0 + best_img_path = None + + if regenerate_image: + for i in range(1, 6): + utils.log("MARKETING", f"Generating cover art (Attempt {i}/5)...") + try: + if not ai.model_image: raise ImportError("No Image Generation Model available.") + + status = "success" + try: + result = ai.model_image.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar) + except Exception as e: + if "resource" in str(e).lower() and ai.HAS_VERTEX: + utils.log("MARKETING", "⚠️ Imagen 3 failed. Trying Imagen 2...") + fb_model = ai.VertexImageModel.from_pretrained("imagegeneration@006") + result = fb_model.generate_images(prompt=art_prompt, number_of_images=1, aspect_ratio=ar) + status = "success_fallback" + else: raise e + + attempt_path = os.path.join(folder, f"cover_art_attempt_{i}.png") + result.images[0].save(attempt_path) + utils.log_usage(folder, "imagen", image_count=1) + + score, critique = evaluate_image_quality(attempt_path, art_prompt, ai.model_logic, folder) + if score is None: score = 0 + + utils.log("MARKETING", f" -> Image Score: {score}/10. Critique: {critique}") + utils.log_image_attempt(folder, "cover", art_prompt, f"cover_art_{i}.png", status, score=score, critique=critique) + + if score > best_img_score: + best_img_score = score + best_img_path = attempt_path + + if score == 10: + utils.log("MARKETING", " -> Perfect image accepted.") + break + + if "scar" in critique.lower() or "deform" in critique.lower() or "blur" in critique.lower(): + art_prompt += " (Ensure high quality, clear skin, no scars, sharp focus)." + + except Exception as e: + utils.log("MARKETING", f"Image generation failed: {e}") + if "quota" in str(e).lower(): break + + if best_img_path and os.path.exists(best_img_path): + final_art_path = os.path.join(folder, "cover_art.png") + if best_img_path != final_art_path: + shutil.copy(best_img_path, final_art_path) + img = Image.open(final_art_path).resize((width, height)).convert("RGB") + image_generated = True + else: + utils.log("MARKETING", "Falling back to solid color cover.") + img = Image.new('RGB', (width, height), color=bg_color) + utils.log_image_attempt(folder, "cover", art_prompt, "cover.png", "fallback_solid") + else: + # Load existing art + final_art_path = os.path.join(folder, "cover_art.png") + if os.path.exists(final_art_path): + utils.log("MARKETING", "Using existing cover art (Layout update only).") + img = Image.open(final_art_path).resize((width, height)).convert("RGB") + else: + utils.log("MARKETING", "Existing art not found. Forcing regeneration.") + # Fallback to solid color if we were supposed to reuse but couldn't find it + img = Image.new('RGB', (width, height), color=bg_color) + + font_path = download_font(design.get('font_name') or 'Arial') + + best_layout_score = 0 + best_layout_path = None + + base_layout_prompt = f""" + Act as a Senior Book Cover Designer. Analyze this 600x900 cover art. + BOOK DETAILS: Title: {meta.get('title')}, Author: {meta.get('author')}, Genre: {meta.get('genre')} + TASK: Determine best (x, y) coordinates for Title and Author. Do NOT place text over faces. + RETURN JSON: {{ "title": {{ "x": int, "y": int, "font_size": int, "font_name": "String", "color": "#Hex" }}, "author": {{ "x": int, "y": int, "font_size": int, "font_name": "String", "color": "#Hex" }} }} + """ + + if feedback: + base_layout_prompt += f"\nUSER FEEDBACK: {feedback}\nAdjust layout/colors accordingly." + + layout_prompt = base_layout_prompt + + for attempt in range(1, 6): + utils.log("MARKETING", f"Designing text layout (Attempt {attempt}/5)...") + try: + response = ai.model_logic.generate_content([layout_prompt, img]) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + layout = json.loads(utils.clean_json(response.text)) + if isinstance(layout, list): layout = layout[0] if layout else {} + except Exception as e: + utils.log("MARKETING", f"Layout generation failed: {e}") + continue + + img_copy = img.copy() + draw = ImageDraw.Draw(img_copy) + + def draw_element(key, text_override=None): + elem = layout.get(key) + if not elem: return + if isinstance(elem, list): elem = elem[0] if elem else {} + text = text_override if text_override else elem.get('text') + if not text: return + + f_name = elem.get('font_name') or 'Arial' + f_path = download_font(f_name) + try: + if f_path: font = ImageFont.truetype(f_path, elem.get('font_size', 40)) + else: raise IOError("Font not found") + except: font = ImageFont.load_default() + + x, y = elem.get('x', 300), elem.get('y', 450) + color = elem.get('color') or '#FFFFFF' + + avg_char_w = font.getlength("A") + wrap_w = int(550 / avg_char_w) if avg_char_w > 0 else 20 + lines = textwrap.wrap(text, width=wrap_w) + + line_heights = [] + for l in lines: + bbox = draw.textbbox((0, 0), l, font=font) + line_heights.append(bbox[3] - bbox[1] + 10) + + total_h = sum(line_heights) + current_y = y - (total_h // 2) + + for i, line in enumerate(lines): + bbox = draw.textbbox((0, 0), line, font=font) + lx = x - ((bbox[2] - bbox[0]) / 2) + draw.text((lx, current_y), line, font=font, fill=color) + current_y += line_heights[i] + + draw_element('title', meta.get('title')) + draw_element('author', meta.get('author')) + + attempt_path = os.path.join(folder, f"cover_layout_attempt_{attempt}.png") + img_copy.save(attempt_path) + + # Evaluate Layout + eval_prompt = f"Analyze this book cover layout. Is the text legible? Is the contrast good? Does it look professional? Title: {meta.get('title')}" + score, critique = evaluate_image_quality(attempt_path, eval_prompt, ai.model_logic, folder) + if score is None: score = 0 + + utils.log("MARKETING", f" -> Layout Score: {score}/10. Critique: {critique}") + + if score > best_layout_score: + best_layout_score = score + best_layout_path = attempt_path + + if score == 10: + utils.log("MARKETING", " -> Perfect layout accepted.") + break + + layout_prompt = base_layout_prompt + f"\nCRITIQUE OF PREVIOUS ATTEMPT: {critique}\nAdjust position/color to fix this." + + if best_layout_path: + shutil.copy(best_layout_path, os.path.join(folder, "cover.png")) + + except Exception as e: + utils.log("MARKETING", f"Cover generation failed: {e}") + +def create_marketing_assets(bp, folder, tracking=None): + generate_blurb(bp, folder) + generate_cover(bp, folder, tracking) \ No newline at end of file diff --git a/modules/requirements_web.txt b/modules/requirements_web.txt new file mode 100644 index 0000000..1f152aa --- /dev/null +++ b/modules/requirements_web.txt @@ -0,0 +1,15 @@ +flask +flask-login +flask-sqlalchemy +huey +werkzeug +google-generativeai +python-dotenv +rich +markdown +python-docx +EbookLib +requests +Pillow +google-cloud-aiplatform +google-auth-oauthlib \ No newline at end of file diff --git a/modules/story.py b/modules/story.py new file mode 100644 index 0000000..e8c9a49 --- /dev/null +++ b/modules/story.py @@ -0,0 +1,626 @@ +import json +import os +import random +import time +import config +from modules import ai +from . import utils + +def enrich(bp, folder, context=""): + utils.log("ENRICHER", "Fleshing out details from description...") + + # If book_metadata is missing, create empty dict so AI can fill it + if 'book_metadata' not in bp: bp['book_metadata'] = {} + if 'characters' not in bp: bp['characters'] = [] + if 'plot_beats' not in bp: bp['plot_beats'] = [] + + prompt = f""" + You are a Creative Director. + The user has provided a minimal description. You must build a full Book Bible. + + USER DESCRIPTION: "{bp.get('manual_instruction', 'A generic story')}" + CONTEXT (Sequel): {context} + + TASK: + 1. Generate a catchy Title. + 2. Define the Genre and Tone. + 3. Determine the Time Period (e.g. "Modern", "1920s", "Sci-Fi Future"). + 4. Define Formatting Rules for text messages, thoughts, and chapter headers. + 5. Create Protagonist and Antagonist/Love Interest. + - IF SEQUEL: Decide if we continue with previous protagonists or shift to side characters based on USER DESCRIPTION. + - IF NEW CHARACTERS: Create them. + - IF RETURNING: Reuse details from CONTEXT. + 6. Outline 5-7 core Plot Beats. + 7. Define a 'structure_prompt' describing the narrative arc (e.g. "Hero's Journey", "3-Act Structure", "Detective Procedural"). + + RETURN JSON in this EXACT format: + {{ + "book_metadata": {{ "title": "Book Title", "genre": "Genre", "content_warnings": ["Violence", "Major Character Death"], "structure_prompt": "...", "style": {{ "tone": "Tone", "time_period": "Modern", "formatting_rules": ["Chapter Headers: Number + Title", "Text Messages: Italic", "Thoughts: Italic"] }} }}, + "characters": [ {{ "name": "Name", "role": "Role", "description": "Description", "key_events": ["Planned injury in Act 2"] }} ], + "plot_beats": [ "Beat 1", "Beat 2", "..." ] + }} + """ + try: + # Merge AI response with existing data (don't overwrite if user provided specific keys) + response = ai.model_logic.generate_content(prompt) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + response_text = response.text + cleaned_json = utils.clean_json(response_text) + ai_data = json.loads(cleaned_json) + + # Smart Merge: Only fill missing fields + if 'book_metadata' not in bp: + bp['book_metadata'] = {} + + if 'title' not in bp['book_metadata']: + bp['book_metadata']['title'] = ai_data.get('book_metadata', {}).get('title') + if 'structure_prompt' not in bp['book_metadata']: + bp['book_metadata']['structure_prompt'] = ai_data.get('book_metadata', {}).get('structure_prompt') + if 'content_warnings' not in bp['book_metadata']: + bp['book_metadata']['content_warnings'] = ai_data.get('book_metadata', {}).get('content_warnings', []) + + # Merge Style (Flexible) + if 'style' not in bp['book_metadata']: + bp['book_metadata']['style'] = {} + + # Handle AI returning legacy keys or new style key + source_style = ai_data.get('book_metadata', {}).get('style', {}) + + for k, v in source_style.items(): + if k not in bp['book_metadata']['style']: + bp['book_metadata']['style'][k] = v + + if 'characters' not in bp or not bp['characters']: + bp['characters'] = ai_data.get('characters', []) + if 'plot_beats' not in bp or not bp['plot_beats']: + bp['plot_beats'] = ai_data.get('plot_beats', []) + + return bp + except Exception as e: + utils.log("ENRICHER", f"Enrichment failed: {e}") + return bp + +def plan_structure(bp, folder): + utils.log("ARCHITECT", "Creating structure...") + + if 'plot_outline' in bp and isinstance(bp['plot_outline'], dict): + po = bp['plot_outline'] + if 'beats' in po and isinstance(po['beats'], list): + events = [] + for act in po['beats']: + if 'plot_points' in act and isinstance(act['plot_points'], list): + for pp in act['plot_points']: + desc = pp.get('description') + point = pp.get('point', 'Event') + if desc: events.append({"description": desc, "purpose": point}) + if events: + utils.log("ARCHITECT", f"Using {len(events)} events from Plot Outline as base structure.") + return events + + structure_type = bp.get('book_metadata', {}).get('structure_prompt') + + if not structure_type: + label = bp.get('length_settings', {}).get('label', 'Novel') + structures = { + "Chapter Book": "Create a simple episodic structure with clear chapter hooks.", + "Young Adult": "Create a character-driven arc with high emotional stakes and a clear 'Coming of Age' theme.", + "Flash Fiction": "Create a single, impactful scene structure with a twist.", + "Short Story": "Create a concise narrative arc (Inciting Incident -> Rising Action -> Climax -> Resolution).", + "Novella": "Create a standard 3-Act Structure.", + "Novel": "Create a detailed 3-Act Structure with A and B plots.", + "Epic": "Create a complex, multi-arc structure (Hero's Journey) with extensive world-building events." + } + structure_type = structures.get(label, "Create a 3-Act Structure.") + + beats_context = [] + if 'plot_outline' in bp and isinstance(bp['plot_outline'], dict): + po = bp['plot_outline'] + if 'beats' in po: + for act in po['beats']: + beats_context.append(f"ACT {act.get('act', '?')}: {act.get('title', '')} - {act.get('summary', '')}") + for pp in act.get('plot_points', []): + beats_context.append(f" * {pp.get('point', 'Beat')}: {pp.get('description', '')}") + + if not beats_context: + beats_context = bp.get('plot_beats', []) + + prompt = f"{structure_type}\nTITLE: {bp['book_metadata']['title']}\nBEATS: {json.dumps(beats_context)}\nReturn JSON: {{'events': [{{'description':'...', 'purpose':'...'}}]}}" + try: + response = ai.model_logic.generate_content(prompt) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + return json.loads(utils.clean_json(response.text))['events'] + except: + return [] + +def expand(events, pass_num, target_chapters, bp, folder): + utils.log("ARCHITECT", f"Expansion pass {pass_num} | Current Beats: {len(events)} | Target Chaps: {target_chapters}") + + beats_context = [] + if 'plot_outline' in bp and isinstance(bp['plot_outline'], dict): + po = bp['plot_outline'] + if 'beats' in po: + for act in po['beats']: + beats_context.append(f"ACT {act.get('act', '?')}: {act.get('title', '')} - {act.get('summary', '')}") + for pp in act.get('plot_points', []): + beats_context.append(f" * {pp.get('point', 'Beat')}: {pp.get('description', '')}") + + if not beats_context: + beats_context = bp.get('plot_beats', []) + + prompt = f""" + You are a Story Architect. + Goal: Flesh out this outline for a {target_chapters}-chapter book. + Current Status: {len(events)} beats. + + ORIGINAL OUTLINE: + {json.dumps(beats_context)} + + INSTRUCTIONS: + 1. Look for jumps in time or logic. + 2. Insert new intermediate events to smooth the pacing. + 3. Deepen subplots while staying true to the ORIGINAL OUTLINE. + 4. Do NOT remove or drastically alter the original outline points; expand AROUND them. + + CURRENT EVENTS: + {json.dumps(events)} + + Return JSON: {{'events': [ ...updated full list... ]}} + """ + try: + response = ai.model_logic.generate_content(prompt) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + new_events = json.loads(utils.clean_json(response.text))['events'] + + if len(new_events) > len(events): + utils.log("ARCHITECT", f" -> Added {len(new_events) - len(events)} new beats.") + elif len(str(new_events)) > len(str(events)) + 20: + utils.log("ARCHITECT", f" -> Fleshed out descriptions (Text grew by {len(str(new_events)) - len(str(events))} chars).") + else: + utils.log("ARCHITECT", " -> No significant changes.") + return new_events + except Exception as e: + utils.log("ARCHITECT", f" -> Pass skipped due to error: {e}") + return events + +def create_chapter_plan(events, bp, folder): + utils.log("ARCHITECT", "Finalizing Chapters...") + target = bp['length_settings']['chapters'] + words = bp['length_settings'].get('words', 'Flexible') + + include_prologue = bp.get('length_settings', {}).get('include_prologue', False) + include_epilogue = bp.get('length_settings', {}).get('include_epilogue', False) + + structure_instructions = "" + if include_prologue: structure_instructions += "- Include a 'Prologue' (chapter_number: 0) to set the scene.\n" + if include_epilogue: structure_instructions += "- Include an 'Epilogue' (chapter_number: 'Epilogue') to wrap up.\n" + + meta = bp.get('book_metadata', {}) + style = meta.get('style', {}) + pov_chars = style.get('pov_characters', []) + pov_instruction = "" + if pov_chars: + pov_instruction = f"- Assign a 'pov_character' for each chapter from this list: {json.dumps(pov_chars)}." + + prompt = f""" + Group events into Chapters. + TARGET CHAPTERS: {target} (Approximate. Feel free to adjust +/- 20% for better pacing). + TARGET WORDS: {words} (Total for the book). + + INSTRUCTIONS: + - Vary chapter pacing. Options: 'Very Fast', 'Fast', 'Standard', 'Slow', 'Very Slow'. + - Assign an estimated word count to each chapter based on its pacing and content. + {structure_instructions} + {pov_instruction} + + EVENTS: {json.dumps(events)} + Return JSON: [{{'chapter_number':1, 'title':'...', 'pov_character': 'Name', 'pacing': 'Standard', 'estimated_words': 2000, 'beats':[...]}}] + """ + try: + response = ai.model_logic.generate_content(prompt) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + plan = json.loads(utils.clean_json(response.text)) + + target_str = str(words).lower().replace(',', '').replace('k', '000').replace('+', '').replace(' ', '') + target_val = 0 + if '-' in target_str: + try: + parts = target_str.split('-') + target_val = int((int(parts[0]) + int(parts[1])) / 2) + except: pass + else: + try: target_val = int(target_str) + except: pass + + if target_val > 0: + variance = random.uniform(0.90, 1.10) + target_val = int(target_val * variance) + utils.log("ARCHITECT", f"Target adjusted with variance ({variance:.2f}x): {target_val} words.") + + current_sum = sum(int(c.get('estimated_words', 0)) for c in plan) + if current_sum > 0: + factor = target_val / current_sum + utils.log("ARCHITECT", f"Adjusting chapter lengths by {factor:.2f}x to match target.") + for c in plan: + c['estimated_words'] = int(c.get('estimated_words', 0) * factor) + + return plan + except Exception as e: + utils.log("ARCHITECT", f"Failed to create chapter plan: {e}") + return [] + +def update_tracking(folder, chapter_num, chapter_text, current_tracking): + utils.log("TRACKER", f"Updating world state & character visuals for Ch {chapter_num}...") + + prompt = f""" + Analyze this chapter text to update the Story Bible. + + CURRENT TRACKING DATA: + {json.dumps(current_tracking)} + + NEW CHAPTER TEXT: + {chapter_text[:500000]} + + TASK: + 1. EVENTS: Append 1-3 concise bullet points summarizing key plot events in this chapter to the 'events' list. + 2. CHARACTERS: Update entries for any characters appearing in the scene. + - "descriptors": List of strings. Add PERMANENT physical traits (height, hair, eyes), specific items (jewelry, weapons). Avoid duplicates. + - "likes_dislikes": List of strings. Add specific preferences, likes, or dislikes mentioned (e.g., "Hates coffee", "Loves jazz"). + - "last_worn": String. Update if specific clothing is described. IMPORTANT: If a significant time jump occurred (e.g. next day) and no new clothing is described, reset this to "Unknown". + - "major_events": List of strings. Log significant life-altering events occurring in THIS chapter (e.g. "Lost an arm", "Married", "Betrayed by X"). + 3. CONTENT_WARNINGS: List of strings. Identify specific triggers present in this chapter (e.g. "Graphic Violence", "Sexual Assault", "Torture", "Self-Harm"). Append to existing list. + + RETURN JSON with the SAME structure as CURRENT TRACKING DATA (events list, characters dict, content_warnings list). + """ + try: + response = ai.model_logic.generate_content(prompt) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + new_data = json.loads(utils.clean_json(response.text)) + return new_data + except Exception as e: + utils.log("TRACKER", f"Failed to update tracking: {e}") + return current_tracking + +def evaluate_chapter_quality(text, chapter_title, model, folder): + prompt = f""" + Analyze this book chapter text. + CHAPTER TITLE: {chapter_title} + + CRITERIA: + 1. ORGANIC FEEL: Does it sound like a human wrote it? Are "AI-isms" (e.g. 'testament to', 'tapestry', 'shiver down spine', 'unspoken agreement') absent? + 2. ENGAGEMENT: Is it interesting? Does it hook the reader? + 3. REPETITION: Is sentence structure varied? Are words repeated unnecessarily? + 4. PROGRESSION: Does the story move forward, or is it spinning its wheels? + + Rate on a scale of 1-10. + Provide a concise critique focusing on the biggest flaw. + + Return JSON: {{'score': int, 'critique': 'string'}} + """ + try: + response = model.generate_content([prompt, text[:30000]]) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + data = json.loads(utils.clean_json(response.text)) + return data.get('score', 0), data.get('critique', 'No critique provided.') + except Exception as e: + return 0, f"Evaluation error: {str(e)}" + +def create_initial_persona(bp, folder): + utils.log("SYSTEM", "Generating initial Author Persona based on genre/tone...") + meta = bp.get('book_metadata', {}) + style = meta.get('style', {}) + + prompt = f""" + Create a fictional 'Author Persona' best suited to write this book. + + BOOK DETAILS: + Title: {meta.get('title')} + Genre: {meta.get('genre')} + Tone: {style.get('tone')} + Target Audience: {meta.get('target_audience')} + + TASK: + Create a profile for the ideal writer of this book. + Return JSON: {{ "name": "Pen Name", "bio": "Description of writing style (voice, sentence structure, vocabulary)...", "age": "...", "gender": "..." }} + """ + try: + response = ai.model_logic.generate_content(prompt) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + return json.loads(utils.clean_json(response.text)) + except Exception as e: + utils.log("SYSTEM", f"Persona generation failed: {e}") + return {"name": "AI Author", "bio": "Standard, balanced writing style."} + +def refine_persona(bp, text, folder): + utils.log("SYSTEM", "Refining Author Persona based on recent chapters...") + ad = bp.get('book_metadata', {}).get('author_details', {}) + current_bio = ad.get('bio', 'Standard style.') + + prompt = f""" + Analyze this text sample from the book. + + TEXT: + {text[:3000]} + + CURRENT AUTHOR BIO: + {current_bio} + + TASK: + Refine the Author Bio to better match the actual text produced. + Highlight specific stylistic quirks, sentence patterns, or vocabulary choices found in the text. + The goal is to ensure future chapters sound exactly like this one. + + Return JSON: {{ "bio": "Updated bio..." }} + """ + try: + response = ai.model_logic.generate_content(prompt) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + new_bio = json.loads(utils.clean_json(response.text)).get('bio') + if new_bio: + ad['bio'] = new_bio + utils.log("SYSTEM", " -> Persona bio updated.") + return ad + except: pass + return ad + +def write_chapter(chap, bp, folder, prev_sum, tracking=None, prev_content=None): + pacing = chap.get('pacing', 'Standard') + est_words = chap.get('estimated_words', 'Flexible') + utils.log("WRITER", f"Drafting Ch {chap['chapter_number']} ({pacing} | ~{est_words} words): {chap['title']}") + ls = bp['length_settings'] + meta = bp.get('book_metadata', {}) + style = meta.get('style', {}) + + pov_char = chap.get('pov_character', '') + + ad = meta.get('author_details', {}) + if not ad and 'author_bio' in meta: + persona_info = meta['author_bio'] + else: + persona_info = f"Name: {ad.get('name', meta.get('author', 'Unknown'))}\n" + if ad.get('age'): persona_info += f"Age: {ad['age']}\n" + if ad.get('gender'): persona_info += f"Gender: {ad['gender']}\n" + if ad.get('race'): persona_info += f"Race: {ad['race']}\n" + if ad.get('nationality'): persona_info += f"Nationality: {ad['nationality']}\n" + if ad.get('language'): persona_info += f"Language: {ad['language']}\n" + if ad.get('bio'): persona_info += f"Style/Bio: {ad['bio']}\n" + + samples = [] + if ad.get('sample_text'): + samples.append(f"--- SAMPLE PARAGRAPH ---\n{ad['sample_text']}") + + if ad.get('sample_files'): + for fname in ad['sample_files']: + fpath = os.path.join(config.PERSONAS_DIR, fname) + if os.path.exists(fpath): + try: + with open(fpath, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read(3000) + samples.append(f"--- SAMPLE FROM {fname} ---\n{content}...") + except: pass + + if samples: + persona_info += "\nWRITING STYLE SAMPLES:\n" + "\n".join(samples) + + char_visuals = "" + if tracking and 'characters' in tracking: + char_visuals = "\nCHARACTER TRACKING (Visuals & Preferences):\n" + for name, data in tracking['characters'].items(): + desc = ", ".join(data.get('descriptors', [])) + likes = ", ".join(data.get('likes_dislikes', [])) + worn = data.get('last_worn', 'Unknown') + char_visuals += f"- {name}: {desc}\n * Likes/Dislikes: {likes}\n" + + major = data.get('major_events', []) + if major: char_visuals += f" * Major Events: {'; '.join(major)}\n" + + if worn and worn != 'Unknown': + char_visuals += f" * Last Worn: {worn} (NOTE: Only relevant if scene is continuous from previous chapter)\n" + + style_block = "\n".join([f"- {k.replace('_', ' ').title()}: {v}" for k, v in style.items() if isinstance(v, (str, int, float))]) + if 'tropes' in style and isinstance(style['tropes'], list): + style_block += f"\n- Tropes: {', '.join(style['tropes'])}" + + if 'formatting_rules' in style and isinstance(style['formatting_rules'], list): + style_block += "\n- Formatting Rules:\n * " + "\n * ".join(style['formatting_rules']) + + prev_context_block = "" + if prev_content: + prev_context_block = f"\nPREVIOUS CHAPTER TEXT (For Tone & Continuity):\n{prev_content}\n" + + prompt = f""" + Write Chapter {chap['chapter_number']}: {chap['title']} + + PACING GUIDE: + - Format: {ls.get('label', 'Story')} + - Chapter Pacing: {pacing} + - Target Word Count: ~{est_words} (Use this as a guide, but prioritize story flow. Allow flexibility.) + - POV Character: {pov_char if pov_char else 'Protagonist'} + + STYLE & FORMATTING: + {style_block} + + AUTHOR VOICE (CRITICAL): + {persona_info} + + INSTRUCTION: + Write the scene. + - Start with the Chapter Header formatted as Markdown H1 (e.g. '# Chapter X: Title'). Follow the 'Formatting Rules' for the header style. + + - DEEP POV: Immerse the reader in the POV character's immediate experience. Filter descriptions through their specific worldview and emotional state. + - SHOW, DON'T TELL: Focus on immediate action and internal reaction. Don't summarize feelings; show the physical manifestation of them. + - SENSORY DETAILS: Use specific, grounding sensory details (smell, touch, sound) rather than generic descriptions. + - AVOID CLICHÉS: Avoid common AI tropes (e.g., 'shiver down spine', 'palpable tension', 'unspoken agreement', 'testament to'). + - MAINTAIN CONTINUITY: Pay close attention to the PREVIOUS CONTEXT. Characters must NOT know things that haven't happened yet or haven't been revealed to them. + - CHARACTER INTERACTIONS: If characters are meeting for the first time in the summary, treat them as strangers. + - SENTENCE VARIETY: Avoid repetitive sentence structures (e.g. starting multiple sentences with "He" or "She"). Vary sentence length to create rhythm. + - 'Very Fast': Rapid fire, pure action/dialogue, minimal description. + - 'Fast': Punchy, keep it moving. + - 'Standard': Balanced dialogue and description. + - 'Slow': Detailed, atmospheric, immersive. + - 'Very Slow': Deep introspection, heavy sensory detail, slow burn. + + PREVIOUS CONTEXT (Story So Far): {prev_sum} + {prev_context_block} + CHARACTERS: {json.dumps(bp['characters'])} + {char_visuals} + SCENE BEATS: {json.dumps(chap['beats'])} + + Output Markdown. + """ + current_text = "" + try: + resp_draft = ai.model_writer.generate_content(prompt) + utils.log_usage(folder, "writer-flash", resp_draft.usage_metadata) + current_text = resp_draft.text + except Exception as e: + utils.log("WRITER", f"⚠️ Failed Ch {chap['chapter_number']}: {e}") + return f"## Chapter {chap['chapter_number']} Failed\n\nError: {e}" + + # Refinement Loop + max_attempts = 3 + best_score = 0 + best_text = current_text + + for attempt in range(1, max_attempts + 1): + utils.log("WRITER", f" -> Evaluating Ch {chap['chapter_number']} (Attempt {attempt}/{max_attempts})...") + score, critique = evaluate_chapter_quality(current_text, chap['title'], ai.model_logic, folder) + + if "Evaluation error" in critique: + utils.log("WRITER", f" ⚠️ {critique}. Keeping current draft.") + if best_score == 0: best_text = current_text + break + + utils.log("WRITER", f" Score: {score}/10. Critique: {critique}") + + if score >= 8: + utils.log("WRITER", " Quality threshold met.") + return current_text + + if score > best_score: + best_score = score + best_text = current_text + + if attempt == max_attempts: + utils.log("WRITER", " Max attempts reached. Using best version.") + return best_text + + utils.log("WRITER", f" -> Refining Ch {chap['chapter_number']} based on feedback...") + refine_prompt = f""" + Act as a Senior Editor. Rewrite this chapter to fix the issues identified below. + + CRITIQUE TO ADDRESS: + {critique} + + ADDITIONAL OBJECTIVES: + 1. NATURAL FLOW: Fix stilted phrasing. Ensure the prose flows naturally for the genre ({meta.get('genre', 'Fiction')}) and tone ({style.get('tone', 'Standard')}). + 2. HUMANIZATION: Remove robotic phrasing. Ensure dialogue has subtext, interruptions, and distinct voices. Remove "AI-isms" (e.g. 'testament to', 'tapestry of', 'symphony of'). + 3. SENTENCE VARIETY: Check for and fix repetitive sentence starts or uniform sentence lengths. The prose should have a dynamic rhythm. + 4. CONTINUITY: Ensure consistency with the Story So Far. + + STORY SO FAR: + {prev_sum} + {prev_context_block} + + CURRENT DRAFT: + {current_text} + + Return the polished, final version of the chapter in Markdown. + """ + try: + resp_refine = ai.model_writer.generate_content(refine_prompt) + utils.log_usage(folder, "writer-flash", resp_refine.usage_metadata) + current_text = resp_refine.text + except Exception as e: + utils.log("WRITER", f"Refinement failed: {e}") + return best_text + + return best_text + +def harvest_metadata(bp, folder, full_manuscript): + utils.log("HARVESTER", "Scanning for new characters...") + full_text = "\n".join([c['content'] for c in full_manuscript])[:50000] + prompt = f"Identify new significant characters NOT in:\n{json.dumps(bp['characters'])}\nTEXT:\n{full_text}\nReturn JSON: {{'new_characters': [{{'name':'...', 'role':'...', 'description':'...'}}]}}" + try: + response = ai.model_logic.generate_content(prompt) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + new_chars = json.loads(utils.clean_json(response.text)).get('new_characters', []) + if new_chars: + utils.log("HARVESTER", f"Found {len(new_chars)} new chars.") + bp['characters'].extend(new_chars) + except: pass + return bp + +def update_persona_sample(bp, folder): + utils.log("SYSTEM", "Extracting author persona from manuscript...") + + ms_path = os.path.join(folder, "manuscript.json") + if not os.path.exists(ms_path): return + ms = utils.load_json(ms_path) + if not ms: return + + # 1. Extract Text Sample + full_text = "\n".join([c.get('content', '') for c in ms]) + if len(full_text) < 500: return + + # 2. Save Sample File + if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR) + + meta = bp.get('book_metadata', {}) + safe_title = "".join([c for c in meta.get('title', 'book') if c.isalnum() or c=='_']).replace(" ", "_")[:20] + timestamp = int(time.time()) + filename = f"sample_{safe_title}_{timestamp}.txt" + filepath = os.path.join(config.PERSONAS_DIR, filename) + + sample_text = full_text[:3000] + with open(filepath, 'w', encoding='utf-8') as f: f.write(sample_text) + + # 3. Update or Create Persona + author_name = meta.get('author', 'Unknown Author') + + personas = {} + if os.path.exists(config.PERSONAS_FILE): + try: + with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) + except: pass + + if author_name not in personas: + utils.log("SYSTEM", f"Generating new persona profile for '{author_name}'...") + prompt = f"Analyze this writing style (Tone, Voice, Vocabulary). Write a 1-sentence author bio describing it.\nTEXT: {sample_text[:1000]}" + try: + response = ai.model_logic.generate_content(prompt) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + bio = response.text.strip() + except: bio = "Style analysis unavailable." + + personas[author_name] = { + "name": author_name, + "bio": bio, + "sample_files": [filename], + "sample_text": sample_text[:500] + } + else: + utils.log("SYSTEM", f"Updating persona '{author_name}' with new sample.") + if 'sample_files' not in personas[author_name]: personas[author_name]['sample_files'] = [] + if filename not in personas[author_name]['sample_files']: + personas[author_name]['sample_files'].append(filename) + + with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2) + +def refine_bible(bible, instruction, folder): + utils.log("SYSTEM", f"Refining Bible with instruction: {instruction}") + prompt = f""" + Act as a Book Editor. + CURRENT JSON: {json.dumps(bible)} + USER INSTRUCTION: {instruction} + + TASK: Update the JSON based on the instruction. Maintain valid JSON structure. + RETURN ONLY THE JSON. + """ + try: + response = ai.model_logic.generate_content(prompt) + utils.log_usage(folder, "logic-pro", response.usage_metadata) + new_data = json.loads(utils.clean_json(response.text)) + return new_data + except Exception as e: + utils.log("SYSTEM", f"Refinement failed: {e}") + return None \ No newline at end of file diff --git a/modules/utils.py b/modules/utils.py new file mode 100644 index 0000000..a5e9ae8 --- /dev/null +++ b/modules/utils.py @@ -0,0 +1,202 @@ +import os +import json +import datetime +import time +import config +import threading + +SAFETY_SETTINGS = [ + {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"}, + {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"}, +] + +# Thread-local storage for logging context +_log_context = threading.local() + +def set_log_file(filepath): + _log_context.log_file = filepath + +def set_log_callback(callback): + _log_context.callback = callback + +def clean_json(text): + text = text.replace("```json", "").replace("```", "").strip() + # Robust extraction: find first { or [ and last } or ] + start_obj = text.find('{') + start_arr = text.find('[') + if start_obj == -1 and start_arr == -1: return text + if start_obj != -1 and (start_arr == -1 or start_obj < start_arr): + return text[start_obj:text.rfind('}')+1] + else: + return text[start_arr:text.rfind(']')+1] + +# --- SHARED UTILS --- +def log(phase, msg): + timestamp = datetime.datetime.now().strftime('%H:%M:%S') + line = f"[{timestamp}] {phase:<15} | {msg}" + print(line) + + # Write to thread-specific log file if set + if getattr(_log_context, 'log_file', None): + with open(_log_context.log_file, "a", encoding="utf-8") as f: + f.write(line + "\n") + + # Trigger callback if set (e.g. for Database logging) + if getattr(_log_context, 'callback', None): + try: _log_context.callback(phase, msg) + except: pass + +def load_json(path): + return json.load(open(path, 'r')) if os.path.exists(path) else None + +def create_default_personas(): + # Initialize empty personas file if it doesn't exist + if not os.path.exists(config.PERSONAS_DIR): os.makedirs(config.PERSONAS_DIR) + if not os.path.exists(config.PERSONAS_FILE): + try: + with open(config.PERSONAS_FILE, 'w') as f: json.dump({}, f, indent=2) + except: pass + +def get_length_presets(): + """Returns a dict mapping Label -> Settings for use in main.py""" + presets = {} + for k, v in config.LENGTH_DEFINITIONS.items(): + presets[v['label']] = v + return presets + +def log_image_attempt(folder, img_type, prompt, filename, status, error=None, score=None, critique=None): + log_path = os.path.join(folder, "image_log.json") + entry = { + "timestamp": int(time.time()), + "type": img_type, + "prompt": prompt, + "filename": filename, + "status": status, + "error": str(error) if error else None, + "score": score, + "critique": critique + } + data = [] + if os.path.exists(log_path): + try: + with open(log_path, 'r') as f: data = json.load(f) + except: + pass + data.append(entry) + with open(log_path, 'w') as f: json.dump(data, f, indent=2) + +def get_run_folder(base_name): + if not os.path.exists(base_name): os.makedirs(base_name) + runs = [d for d in os.listdir(base_name) if d.startswith("run_")] + next_num = max([int(r.split("_")[1]) for r in runs if r.split("_")[1].isdigit()] + [0]) + 1 + folder = os.path.join(base_name, f"run_{next_num}") + os.makedirs(folder) + return folder + +def get_latest_run_folder(base_name): + if not os.path.exists(base_name): return None + runs = [d for d in os.listdir(base_name) if d.startswith("run_")] + if not runs: return None + runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0) + return os.path.join(base_name, runs[-1]) + +def log_usage(folder, model_label, usage_metadata=None, image_count=0): + if not folder or not os.path.exists(folder): return + + log_path = os.path.join(folder, "usage_log.json") + + entry = { + "timestamp": int(time.time()), + "model": model_label, + "input_tokens": 0, + "output_tokens": 0, + "images": image_count + } + + if usage_metadata: + try: + entry["input_tokens"] = usage_metadata.prompt_token_count + entry["output_tokens"] = usage_metadata.candidates_token_count + except: pass + + data = {"log": [], "totals": {"input_tokens": 0, "output_tokens": 0, "images": 0, "est_cost_usd": 0.0}} + + if os.path.exists(log_path): + try: + loaded = json.load(open(log_path, 'r')) + if isinstance(loaded, list): data["log"] = loaded + else: data = loaded + except: pass + + data["log"].append(entry) + + # Recalculate totals + t_in = sum(x.get('input_tokens', 0) for x in data["log"]) + t_out = sum(x.get('output_tokens', 0) for x in data["log"]) + t_img = sum(x.get('images', 0) for x in data["log"]) + + cost = 0.0 + for x in data["log"]: + m = x.get('model', '').lower() + i = x.get('input_tokens', 0) + o = x.get('output_tokens', 0) + imgs = x.get('images', 0) + + if 'flash' in m: + cost += (i / 1_000_000 * 0.075) + (o / 1_000_000 * 0.30) + elif 'pro' in m or 'logic' in m: + cost += (i / 1_000_000 * 3.50) + (o / 1_000_000 * 10.50) + elif 'imagen' in m or imgs > 0: + cost += (imgs * 0.04) + + data["totals"] = { + "input_tokens": t_in, + "output_tokens": t_out, + "images": t_img, + "est_cost_usd": round(cost, 4) + } + + with open(log_path, 'w') as f: json.dump(data, f, indent=2) + +def normalize_settings(bp): + """ + CRITICAL: Enforces defaults. + 1. If series_metadata is missing, force it to SINGLE mode. + 2. If length_settings is missing, force explicit numbers. + """ + # Force Series Default (1 Book) + if 'series_metadata' not in bp: + bp['series_metadata'] = { + "is_series": False, + "mode": "single", + "series_title": "Standalone", + "total_books_to_generate": 1 + } + + # Check for empty series count just in case + if bp['series_metadata'].get('total_books_to_generate') is None: + bp['series_metadata']['total_books_to_generate'] = 1 + + # Force Length Defaults + settings = bp.get('length_settings', {}) + label = settings.get('label', 'Novella') # Default to Novella if nothing provided + + # Get defaults based on label (or Novella if unknown) + presets = get_length_presets() + defaults = presets.get(label, presets['Novella']) + + if 'chapters' not in settings: settings['chapters'] = defaults['chapters'] + if 'words' not in settings: settings['words'] = defaults['words'] + + # Smart Depth Calculation (if not manually set) + if 'depth' not in settings: + c = int(settings['chapters']) + if c <= 5: settings['depth'] = 1 + elif c <= 20: settings['depth'] = 2 + elif c <= 40: settings['depth'] = 3 + else: settings['depth'] = 4 + + bp['length_settings'] = settings + return bp \ No newline at end of file diff --git a/modules/web_app.py b/modules/web_app.py new file mode 100644 index 0000000..4fc85a5 --- /dev/null +++ b/modules/web_app.py @@ -0,0 +1,1132 @@ +import os +import json +import html +import shutil +from functools import wraps +from types import SimpleNamespace +from datetime import datetime, timedelta +from sqlalchemy import func +from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, session +from flask_login import LoginManager, login_user, login_required, logout_user, current_user +from werkzeug.security import generate_password_hash, check_password_hash +from .web_db import db, User, Project, Run, LogEntry +from .web_tasks import huey, generate_book_task, regenerate_artifacts_task +import config +from . import utils +from . import ai +from . import story + +# Calculate paths relative to this file (modules/web_app.py) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +TEMPLATE_DIR = os.path.join(BASE_DIR, 'templates') + +app = Flask(__name__, template_folder=TEMPLATE_DIR) +app.url_map.strict_slashes = False +app.config['SECRET_KEY'] = config.FLASK_SECRET +# Use absolute path for database to avoid Docker path resolution issues +app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{os.path.join(config.DATA_DIR, "bookapp.db")}' + +db.init_app(app) +login_manager = LoginManager() +login_manager.login_view = 'login' +login_manager.init_app(app) + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) + +def migrate_logs(): + """Parses old log files and inserts them into the database.""" + runs = Run.query.all() + migrated = 0 + files_to_clean = [] + for run in runs: + # Check if DB logs exist + has_db_logs = LogEntry.query.filter_by(run_id=run.id).first() is not None + + # Locate Log File + log_path = run.log_file + if not log_path or not os.path.exists(log_path): + # Try common fallback locations + candidates = [ + os.path.join(run.project.folder_path, f"system_log_{run.id}.txt"), + os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}", "web_console.log") + ] + for c in candidates: + if os.path.exists(c): + log_path = c + break + + if log_path and os.path.exists(log_path): + if has_db_logs: + # Logs are already in DB (New Run or previous migration). Mark file for cleanup. + files_to_clean.append(log_path) + continue + + try: + with open(log_path, 'r', encoding='utf-8', errors='ignore') as f: + lines = f.readlines() + + entries = [] + for line in lines: + # Parse standard log format: [HH:MM:SS] PHASE | Message + if '|' in line and line.strip().startswith('['): + try: + parts = line.split('|', 1) + meta = parts[0].strip() + msg = parts[1].strip() + + if ']' in meta: + ts_str = meta[1:meta.find(']')] + phase = meta[meta.find(']')+1:].strip() + + # Reconstruct datetime + base_date = run.start_time.date() if run.start_time else datetime.utcnow().date() + t_time = datetime.strptime(ts_str, "%H:%M:%S").time() + dt = datetime.combine(base_date, t_time) + + entries.append(LogEntry(run_id=run.id, timestamp=dt, phase=phase, message=msg)) + except: continue + + if entries: + db.session.add_all(entries) + migrated += 1 + files_to_clean.append(log_path) + except Exception as e: + print(f"Migration failed for Run {run.id}: {e}") + + if migrated > 0: + db.session.commit() + print(f"✅ Migrated logs for {migrated} runs to Database.") + + # Cleanup files (even if no new migrations happened) + if files_to_clean: + count = 0 + for fpath in files_to_clean: + try: + os.remove(fpath) + count += 1 + except: pass + if count > 0: + print(f"🧹 Cleaned up {count} redundant log files.") + +# --- SETUP --- +with app.app_context(): + db.create_all() + migrate_logs() + + # Auto-create Admin from Environment Variables (Docker/Portainer Setup) + if config.ADMIN_USER and config.ADMIN_PASSWORD: + admin = User.query.filter_by(username=config.ADMIN_USER).first() + if not admin: + print(f"🔐 System: Creating Admin User '{config.ADMIN_USER}' from environment variables.") + admin = User(username=config.ADMIN_USER, password=generate_password_hash(config.ADMIN_PASSWORD, method='pbkdf2:sha256'), is_admin=True) + db.session.add(admin) + db.session.commit() + elif not admin.is_admin: + admin.is_admin = True + db.session.commit() + +# --- DECORATORS --- +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or not current_user.is_admin: + flash("Admin access required.") + return redirect(url_for('index')) + return f(*args, **kwargs) + return decorated_function + +# --- ROUTES --- + +@app.route('/') +@login_required +def index(): + projects = Project.query.filter_by(user_id=current_user.id).all() + return render_template('dashboard.html', projects=projects, user=current_user) + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + user = User.query.filter_by(username=username).first() + if user and check_password_hash(user.password, password): + login_user(user) + return redirect(url_for('index')) + flash('Invalid credentials') + return render_template('login.html') + +@app.route('/register', methods=['GET', 'POST']) +def register(): + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + if User.query.filter_by(username=username).first(): + flash('Username exists') + return redirect(url_for('register')) + + new_user = User(username=username, password=generate_password_hash(password, method='pbkdf2:sha256')) + db.session.add(new_user) + db.session.commit() + login_user(new_user) + return redirect(url_for('index')) + return render_template('register.html') + +@app.route('/project/setup', methods=['POST']) +@login_required +def project_setup_wizard(): + concept = request.form.get('concept') + + # Initialize AI if needed + try: ai.init_models() + except: pass + + if not ai.model_logic: + flash("AI models not initialized.") + return redirect(url_for('index')) + + 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 ["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 + - author_bio: string (suggested persona bio) + """ + + suggestions = {} + try: + response = ai.model_logic.generate_content(prompt) + suggestions = json.loads(utils.clean_json(response.text)) + except Exception as e: + flash(f"AI Analysis failed: {e}") + suggestions = {"title": "New Project", "genre": "Fiction"} + + # Load Personas for dropdown + personas = {} + if os.path.exists(config.PERSONAS_FILE): + try: + with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) + except: pass + + return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS) + +@app.route('/project/setup/refine', methods=['POST']) +@login_required +def project_setup_refine(): + # Re-run the wizard logic with an instruction + concept = request.form.get('concept') + instruction = request.form.get('refine_instruction') + + # Reconstruct current state from form to pass back to AI + current_state = { + "title": request.form.get('title'), + "genre": request.form.get('genre'), + "target_audience": request.form.get('audience'), + "tone": request.form.get('tone'), + # ... (capture other fields if critical, or just rely on AI re-generating from concept + instruction) + } + + try: ai.init_models() + except: pass + + prompt = f""" + Update these project suggestions based on the user instruction. + ORIGINAL CONCEPT: {concept} + CURRENT TITLE: {current_state['title']} + INSTRUCTION: {instruction} + + RETURN JSON with the same keys as a full analysis (title, genre, length_category, etc). + """ + + suggestions = {} + try: + response = ai.model_logic.generate_content(prompt) + suggestions = json.loads(utils.clean_json(response.text)) + except Exception as e: + flash(f"Refinement failed: {e}") + return redirect(url_for('index')) # Fallback + + personas = {} + if os.path.exists(config.PERSONAS_FILE): + try: + with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) + except: pass + + return render_template('project_setup.html', s=suggestions, concept=concept, personas=personas, lengths=config.LENGTH_DEFINITIONS) + +@app.route('/project/create', methods=['POST']) +@login_required +def create_project_final(): + title = request.form.get('title') + safe_title = "".join([c for c in title if c.isalnum() or c=='_']).replace(" ", "_") + + user_dir = os.path.join(config.DATA_DIR, "users", str(current_user.id)) + if not os.path.exists(user_dir): os.makedirs(user_dir) + + proj_path = os.path.join(user_dir, safe_title) + if os.path.exists(proj_path): + safe_title += f"_{int(datetime.utcnow().timestamp())}" + proj_path = os.path.join(user_dir, safe_title) + os.makedirs(proj_path) + + # Construct Bible from Form Data + length_cat = request.form.get('length_category') + len_def = config.LENGTH_DEFINITIONS.get(length_cat, config.LENGTH_DEFINITIONS['4']).copy() + + # Overrides + try: len_def['chapters'] = int(request.form.get('chapters')) + except: pass + len_def['words'] = request.form.get('words') + len_def['include_prologue'] = 'include_prologue' in request.form + len_def['include_epilogue'] = 'include_epilogue' in request.form + + is_series = 'is_series' in request.form + + style = { + "tone": request.form.get('tone'), + "pov_style": request.form.get('pov_style'), + "time_period": request.form.get('time_period'), + "spice": request.form.get('spice'), + "violence": request.form.get('violence'), + "narrative_tense": request.form.get('narrative_tense'), + "language_style": request.form.get('language_style'), + "dialogue_style": request.form.get('dialogue_style'), + "page_orientation": request.form.get('page_orientation'), + "tropes": [x.strip() for x in request.form.get('tropes', '').split(',') if x.strip()], + "formatting_rules": [x.strip() for x in request.form.get('formatting_rules', '').split(',') if x.strip()] + } + + bible = { + "project_metadata": { + "title": title, + "author": request.form.get('author'), + "author_bio": request.form.get('author_bio'), + "genre": request.form.get('genre'), + "target_audience": request.form.get('audience'), + "is_series": is_series, + "length_settings": len_def, + "style": style + }, + "books": [], + "characters": [] + } + + # Create Books + count = 1 + if is_series: + try: count = int(request.form.get('series_count', 1)) + except: count = 3 + + concept = request.form.get('concept', '') + + for i in range(count): + bible['books'].append({ + "book_number": i+1, + "title": f"{title} - Book {i+1}" if is_series else title, + "manual_instruction": concept if i==0 else "", + "plot_beats": [] + }) + + # Enrich via AI immediately if concept exists + if concept: + try: + ai.init_models() + bible = story.enrich(bible, proj_path) + except: pass + + with open(os.path.join(proj_path, "bible.json"), 'w') as f: + json.dump(bible, f, indent=2) + + new_proj = Project(user_id=current_user.id, name=title, folder_path=proj_path) + db.session.add(new_proj) + db.session.commit() + + return redirect(url_for('view_project', id=new_proj.id)) + +@app.route('/project/') +@login_required +def view_project(id): + proj = db.session.get(Project, id) + if not proj: return "Project not found", 404 + + if proj.user_id != current_user.id: return "Unauthorized", 403 + + # Load Bible + bible_path = os.path.join(proj.folder_path, "bible.json") + bible_data = utils.load_json(bible_path) + + # Load Personas for dropdown + personas = {} + if os.path.exists(config.PERSONAS_FILE): + try: + with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) + except: pass + + runs = Run.query.filter_by(project_id=id).order_by(Run.id.desc()).all() + latest_run = runs[0] if runs else None + + # Fetch other projects for "Related Series" import + other_projects = Project.query.filter(Project.user_id == current_user.id, Project.id != id).all() + + artifacts = [] + generated_books = {} # Map book_number -> {status: 'generated', run_id: int, folder: str} + + # Scan ALL completed runs to find the latest status of each book + for r in runs: + if r.status == 'completed': + run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{r.id}") + if os.path.exists(run_dir): + # 1. Scan for Generated Books + for d in os.listdir(run_dir): + if d.startswith("Book_") and os.path.isdir(os.path.join(run_dir, d)): + # Check for manuscript to confirm generation + if os.path.exists(os.path.join(run_dir, d, "manuscript.json")): + try: + parts = d.split('_') + if len(parts) > 1 and parts[1].isdigit(): + b_num = int(parts[1]) + # Only add if we haven't found a newer version (runs are ordered desc) + if b_num not in generated_books: + generated_books[b_num] = {'status': 'generated', 'run_id': r.id, 'folder': d} + except: pass + + # Collect Artifacts from Latest Run + if latest_run: + run_dir = os.path.join(proj.folder_path, "runs", "bible", f"run_{latest_run.id}") + if os.path.exists(run_dir): + for root, dirs, files in os.walk(run_dir): + for f in files: + if f.lower().endswith(('.epub', '.docx')): + rel_path = os.path.relpath(os.path.join(root, f), run_dir) + artifacts.append({ + 'name': f, + 'path': rel_path.replace("\\", "/"), + 'type': f.split('.')[-1].upper() + }) + + return render_template('project.html', project=proj, bible=bible_data, runs=runs, active_run=latest_run, artifacts=artifacts, personas=personas, generated_books=generated_books, other_projects=other_projects) + +@app.route('/project//run', methods=['POST']) +@login_required +def run_project(id): + proj = db.session.get(Project, id) or Project.query.get_or_404(id) + + # Create Run Entry + new_run = Run(project_id=id, status="queued") + db.session.add(new_run) + db.session.commit() + + # Trigger Background Task + bible_path = os.path.join(proj.folder_path, "bible.json") + task = generate_book_task(new_run.id, proj.folder_path, bible_path, allow_copy=True) + + return redirect(url_for('view_project', id=id)) + +@app.route('/project//review') +@login_required +def review_project(id): + proj = db.session.get(Project, id) or Project.query.get_or_404(id) + if proj.user_id != current_user.id: return "Unauthorized", 403 + + bible_path = os.path.join(proj.folder_path, "bible.json") + bible = utils.load_json(bible_path) + + return render_template('project_review.html', project=proj, bible=bible) + +@app.route('/project//update', methods=['POST']) +@login_required +def update_project_metadata(id): + proj = db.session.get(Project, id) or Project.query.get_or_404(id) + if proj.user_id != current_user.id: return "Unauthorized", 403 + + new_title = request.form.get('title') + new_author = request.form.get('author') + + bible_path = os.path.join(proj.folder_path, "bible.json") + bible = utils.load_json(bible_path) + + if bible: + if new_title: + bible['project_metadata']['title'] = new_title + proj.name = new_title + if new_author: + bible['project_metadata']['author'] = new_author + + with open(bible_path, 'w') as f: json.dump(bible, f, indent=2) + db.session.commit() + + return redirect(url_for('view_project', id=id)) + +@app.route('/project//refine_bible', methods=['POST']) +@login_required +def refine_bible_route(id): + proj = db.session.get(Project, id) or Project.query.get_or_404(id) + if proj.user_id != current_user.id: return "Unauthorized", 403 + + instruction = request.form.get('instruction') + if not instruction: + flash("Instruction required.") + return redirect(url_for('view_project', id=id)) + + bible_path = os.path.join(proj.folder_path, "bible.json") + bible = utils.load_json(bible_path) + + if bible: + try: ai.init_models() + except: pass + + if not ai.model_logic: + flash("AI models not initialized.") + return redirect(url_for('view_project', id=id)) + + new_bible = story.refine_bible(bible, instruction, proj.folder_path) + + if new_bible: + with open(bible_path, 'w') as f: json.dump(new_bible, f, indent=2) + flash("Bible updated successfully.") + else: + flash("AI failed to update Bible. Check logs.") + + return redirect(url_for('view_project', id=id)) + +@app.route('/project//add_book', methods=['POST']) +@login_required +def add_book(id): + proj = db.session.get(Project, id) or Project.query.get_or_404(id) + if proj.user_id != current_user.id: return "Unauthorized", 403 + + title = request.form.get('title', 'Untitled') + instruction = request.form.get('instruction', '') + + bible_path = os.path.join(proj.folder_path, "bible.json") + bible = utils.load_json(bible_path) + + if bible: + if 'books' not in bible: bible['books'] = [] + next_num = len(bible['books']) + 1 + + new_book = { + "book_number": next_num, + "title": title, + "manual_instruction": instruction, + "plot_beats": [] # AI will fill this if empty during run + } + bible['books'].append(new_book) + + # If series metadata isn't set, set it + if 'project_metadata' in bible: + bible['project_metadata']['is_series'] = True + + with open(bible_path, 'w') as f: json.dump(bible, f, indent=2) + flash(f"Added Book {next_num}: {title}") + + return redirect(url_for('view_project', id=id)) + +@app.route('/project//book//update', methods=['POST']) +@login_required +def update_book_details(id, book_num): + proj = db.session.get(Project, id) or Project.query.get_or_404(id) + if proj.user_id != current_user.id: return "Unauthorized", 403 + + new_title = request.form.get('title') + new_instruction = request.form.get('instruction') + + bible_path = os.path.join(proj.folder_path, "bible.json") + bible = utils.load_json(bible_path) + + if bible and 'books' in bible: + for b in bible['books']: + if b.get('book_number') == book_num: + if new_title: b['title'] = new_title + if new_instruction: b['manual_instruction'] = new_instruction + break + with open(bible_path, 'w') as f: json.dump(bible, f, indent=2) + flash(f"Book {book_num} updated.") + + return redirect(url_for('view_project', id=id)) + +@app.route('/project//delete_book/', methods=['POST']) +@login_required +def delete_book(id, book_num): + proj = db.session.get(Project, id) or Project.query.get_or_404(id) + if proj.user_id != current_user.id: return "Unauthorized", 403 + + bible_path = os.path.join(proj.folder_path, "bible.json") + bible = utils.load_json(bible_path) + + if bible and 'books' in bible: + bible['books'] = [b for b in bible['books'] if b.get('book_number') != book_num] + # Renumber + for i, b in enumerate(bible['books']): + b['book_number'] = i + 1 + + # Update Series Status (Revert to standalone if only 1 book remains) + if 'project_metadata' in bible: + bible['project_metadata']['is_series'] = (len(bible['books']) > 1) + + with open(bible_path, 'w') as f: json.dump(bible, f, indent=2) + flash("Book deleted from plan.") + + return redirect(url_for('view_project', id=id)) + +@app.route('/project//import_characters', methods=['POST']) +@login_required +def import_characters(id): + target_proj = db.session.get(Project, id) + source_id = request.form.get('source_project_id') + source_proj = db.session.get(Project, source_id) + + if not target_proj or not source_proj: return "Project not found", 404 + if target_proj.user_id != current_user.id or source_proj.user_id != current_user.id: return "Unauthorized", 403 + + target_bible = utils.load_json(os.path.join(target_proj.folder_path, "bible.json")) + source_bible = utils.load_json(os.path.join(source_proj.folder_path, "bible.json")) + + if target_bible and source_bible: + existing_names = {c['name'].lower() for c in target_bible.get('characters', [])} + added_count = 0 + + for char in source_bible.get('characters', []): + if char['name'].lower() not in existing_names: + target_bible['characters'].append(char) + added_count += 1 + + if added_count > 0: + with open(os.path.join(target_proj.folder_path, "bible.json"), 'w') as f: + json.dump(target_bible, f, indent=2) + flash(f"Imported {added_count} characters from {source_proj.name}.") + else: + flash("No new characters found to import.") + + return redirect(url_for('view_project', id=id)) + +@app.route('/project//set_persona', methods=['POST']) +@login_required +def set_project_persona(id): + proj = db.session.get(Project, id) or Project.query.get_or_404(id) + if proj.user_id != current_user.id: return "Unauthorized", 403 + + persona_name = request.form.get('persona_name') + + bible_path = os.path.join(proj.folder_path, "bible.json") + bible = utils.load_json(bible_path) + + if bible: + personas = {} + if os.path.exists(config.PERSONAS_FILE): + try: + with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) + except: pass + + if persona_name in personas: + bible['project_metadata']['author_details'] = personas[persona_name] + with open(bible_path, 'w') as f: json.dump(bible, f, indent=2) + flash(f"Project voice updated to persona: {persona_name}") + else: + flash("Persona not found.") + + return redirect(url_for('view_project', id=id)) + +@app.route('/project//regenerate_artifacts', methods=['POST']) +@login_required +def regenerate_artifacts(run_id): + run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id) + if run.project.user_id != current_user.id: return "Unauthorized", 403 + + feedback = request.form.get('feedback') + + # Reset state immediately so UI polls correctly + run.status = 'queued' + LogEntry.query.filter_by(run_id=run_id).delete() + db.session.commit() + + regenerate_artifacts_task(run_id, run.project.folder_path, feedback=feedback) + flash("Regenerating cover and files with updated metadata...") + return redirect(url_for('view_run', id=run_id)) + +@app.route('/run//stop', methods=['POST']) +@login_required +def stop_run(id): + run = db.session.get(Run, id) or Run.query.get_or_404(id) + if run.project.user_id != current_user.id: return "Unauthorized", 403 + + if run.status in ['queued', 'running']: + run.status = 'cancelled' + run.end_time = datetime.utcnow() + db.session.commit() + flash(f"Run {id} marked as cancelled.") + + return redirect(url_for('view_project', id=run.project_id)) + +@app.route('/run//restart', methods=['POST']) +@login_required +def restart_run(id): + run = db.session.get(Run, id) or Run.query.get_or_404(id) + if run.project.user_id != current_user.id: return "Unauthorized", 403 + + # Create a new run + new_run = Run(project_id=run.project_id, status="queued") + db.session.add(new_run) + db.session.commit() + + # Check mode: 'resume' (default) vs 'restart' + mode = request.form.get('mode', 'resume') + allow_copy = (mode == 'resume') + + task = generate_book_task(new_run.id, run.project.folder_path, os.path.join(run.project.folder_path, "bible.json"), allow_copy=allow_copy) + flash(f"Started new Run #{new_run.id}.") + return redirect(url_for('view_project', id=run.project_id)) + +@app.route('/run/') +@login_required +def view_run(id): + run = db.session.get(Run, id) + if not run: return "Run not found", 404 + + if run.project.user_id != current_user.id: return "Unauthorized", 403 + + # Fetch logs for initial render + log_content = "" + logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all() + if logs: + log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs]) + elif run.log_file and os.path.exists(run.log_file): + with open(run.log_file, 'r') as f: log_content = f.read() + + # Fetch Artifacts for Display + run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}") + + # Detect Book Subfolder (Series Support) + book_dir = run_dir + if os.path.exists(run_dir): + subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")]) + if subdirs: book_dir = os.path.join(run_dir, subdirs[0]) + + blurb_content = "" + blurb_path = os.path.join(book_dir, "blurb.txt") + if os.path.exists(blurb_path): + with open(blurb_path, 'r', encoding='utf-8', errors='ignore') as f: blurb_content = f.read() + + has_cover = os.path.exists(os.path.join(book_dir, "cover.png")) + + # Load Bible Data for Dropdown + bible_path = os.path.join(run.project.folder_path, "bible.json") + bible_data = utils.load_json(bible_path) + + # Load Tracking Data for Run Details + tracking = {"events": [], "characters": {}, "content_warnings": []} + if os.path.exists(book_dir): + t_ev = os.path.join(book_dir, "tracking_events.json") + t_ch = os.path.join(book_dir, "tracking_characters.json") + t_wn = os.path.join(book_dir, "tracking_warnings.json") + + # Load safely, defaulting to empty structures if load_json returns None + if os.path.exists(t_ev): tracking['events'] = utils.load_json(t_ev) or [] + if os.path.exists(t_ch): tracking['characters'] = utils.load_json(t_ch) or {} + if os.path.exists(t_wn): tracking['content_warnings'] = utils.load_json(t_wn) or [] + + # Use dedicated run details template + return render_template('run_details.html', run=run, log_content=log_content, blurb=blurb_content, has_cover=has_cover, bible=bible_data, tracking=tracking) + +@app.route('/run//status') +@login_required +def run_status(id): + run = db.session.get(Run, id) or Run.query.get_or_404(id) + + # Check status from DB or fallback to log file + + log_content = "" + + # 1. Try Database Logs (Fastest & Best) + logs = LogEntry.query.filter_by(run_id=id).order_by(LogEntry.timestamp).all() + if logs: + log_content = "\n".join([f"[{l.timestamp.strftime('%H:%M:%S')}] {l.phase:<15} | {l.message}" for l in logs]) + + # 2. Fallback to File (For old runs or if DB logging fails) + if not log_content: + if run.log_file and os.path.exists(run.log_file): + with open(run.log_file, 'r') as f: log_content = f.read() + elif run.status in ['queued', 'running']: + temp_log = os.path.join(run.project.folder_path, f"system_log_{run.id}.txt") + if os.path.exists(temp_log): + with open(temp_log, 'r') as f: log_content = f.read() + + return {"status": run.status, "log": log_content, "cost": run.cost} + +@app.route('/project//download') +@login_required +def download_artifact(run_id): + filename = request.args.get('file') + run = db.session.get(Run, run_id) or Run.query.get_or_404(run_id) + if run.project.user_id != current_user.id: return "Unauthorized", 403 + + run_dir = os.path.join(run.project.folder_path, "runs", "bible", f"run_{run.id}") + + # If file not found in root, check subfolders (Series Support) + if not os.path.exists(os.path.join(run_dir, filename)) and os.path.exists(run_dir): + subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")]) + if subdirs: + # Try the first book folder + possible_path = os.path.join(subdirs[0], filename) + if os.path.exists(os.path.join(run_dir, possible_path)): + filename = possible_path + + return send_from_directory(run_dir, filename, as_attachment=True) + +@app.route('/logout') +def logout(): + logout_user() + return redirect(url_for('login')) + +@app.route('/debug/routes') +def debug_routes(): + output = [] + for rule in app.url_map.iter_rules(): + methods = ','.join(rule.methods) + # Use brackets so they are visible in browser text + rule_str = str(rule).replace('<', '[').replace('>', ']') + line = "{:50s} {:20s} {}".format(rule.endpoint, methods, rule_str) + output.append(line) + return "
" + "\n".join(output) + "
" + +@app.route('/system/optimize_models', methods=['POST']) +@login_required +def optimize_models(): + # Force refresh via AI module (safely handles failures) + try: + ai.init_models(force=True) # Force re-initialization and API scan + flash("AI Models refreshed and optimized.") + except Exception as e: + flash(f"Error refreshing models: {e}") + + return redirect(request.referrer or url_for('index')) + +# --- COMPATIBILITY ROUTES (Fix 404s) --- +@app.route('/project//run/') +@login_required +def legacy_run_redirect(project_id, run_id): + return redirect(url_for('view_run', id=run_id)) + +@app.route('/system/status') +@login_required +def system_status(): + # System Status View: Show AI Models and Quotas + models_info = {} + cache_data = {} + + cache_path = os.path.join(config.DATA_DIR, "model_cache.json") + if os.path.exists(cache_path): + try: + with open(cache_path, 'r') as f: + cache_data = json.load(f) + models_info = cache_data.get('models', {}) + except: pass + + # Create a placeholder run object so the template doesn't crash + dummy_project = SimpleNamespace(user_id=current_user.id, name="System", folder_path="") + dummy_run = SimpleNamespace(id=0, status="System Status", cost=0.0, log_file=None, start_time=datetime.utcnow(), project=dummy_project, duration=lambda: "N/A") + + return render_template('system_status.html', run=dummy_run, models=models_info, cache=cache_data, datetime=datetime) + +@app.route('/personas') +@login_required +def list_personas(): + personas = {} + if os.path.exists(config.PERSONAS_FILE): + try: + with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) + except: pass + return render_template('personas.html', personas=personas) + +@app.route('/persona/new') +@login_required +def new_persona(): + return render_template('persona_edit.html', persona={}, name="") + +@app.route('/persona/') +@login_required +def edit_persona(name): + personas = {} + if os.path.exists(config.PERSONAS_FILE): + try: + with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) + except: pass + + persona = personas.get(name) + if not persona: + flash(f"Persona '{name}' not found.") + return redirect(url_for('list_personas')) + + return render_template('persona_edit.html', persona=persona, name=name) + +@app.route('/persona/save', methods=['POST']) +@login_required +def save_persona(): + old_name = request.form.get('old_name') + name = request.form.get('name') + + if not name: + flash("Persona name is required.") + return redirect(url_for('list_personas')) + + personas = {} + if os.path.exists(config.PERSONAS_FILE): + try: + with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) + except: pass + + if old_name and old_name != name and old_name in personas: + del personas[old_name] + + persona = { + "name": name, + "bio": request.form.get('bio'), + "age": request.form.get('age'), + "gender": request.form.get('gender'), + "race": request.form.get('race'), + "nationality": request.form.get('nationality'), + "language": request.form.get('language'), + "sample_text": request.form.get('sample_text'), + "voice_keywords": request.form.get('voice_keywords'), + "style_inspirations": request.form.get('style_inspirations') + } + + personas[name] = persona + + with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2) + + flash(f"Persona '{name}' saved.") + return redirect(url_for('list_personas')) + +@app.route('/persona/delete/', methods=['POST']) +@login_required +def delete_persona(name): + personas = {} + if os.path.exists(config.PERSONAS_FILE): + try: + with open(config.PERSONAS_FILE, 'r') as f: personas = json.load(f) + except: pass + + if name in personas: + del personas[name] + with open(config.PERSONAS_FILE, 'w') as f: json.dump(personas, f, indent=2) + flash(f"Persona '{name}' deleted.") + + return redirect(url_for('list_personas')) + +@app.route('/persona/analyze', methods=['POST']) +@login_required +def analyze_persona(): + try: ai.init_models() + except: pass + + if not ai.model_logic: + return {"error": "AI models not initialized."}, 500 + + data = request.json + sample = data.get('sample_text', '') + + prompt = f""" + Act as a Literary Analyst. Create or analyze an Author Persona profile. + + INPUT DATA: + Name: {data.get('name')} + Age: {data.get('age')} | Gender: {data.get('gender')} | Nationality: {data.get('nationality')} + Sample Text: {sample[:3000]} + + TASK: + 1. 'bio': Write a 2-3 sentence description of the writing style. If sample is provided, analyze it. If not, invent a style that fits the demographics/name. + 2. 'voice_keywords': Comma-separated list of 3-5 adjectives describing the voice (e.g. Gritty, Whimsical, Sarcastic). + 3. 'style_inspirations': Comma-separated list of 1-3 famous authors or genres that this style resembles. + + RETURN JSON: {{ "bio": "...", "voice_keywords": "...", "style_inspirations": "..." }} + """ + try: + response = ai.model_logic.generate_content(prompt) + return json.loads(utils.clean_json(response.text)) + except Exception as e: + return {"error": str(e)}, 500 + +# --- ADMIN ROUTES --- + +@app.route('/admin') +@login_required +@admin_required +def admin_dashboard(): + users = User.query.all() + projects = Project.query.all() + return render_template('admin_dashboard.html', users=users, projects=projects) + +@app.route('/admin/user//delete', methods=['POST']) +@login_required +@admin_required +def admin_delete_user(user_id): + if user_id == current_user.id: + flash("Cannot delete yourself.") + return redirect(url_for('admin_dashboard')) + + user = db.session.get(User, user_id) + if user: + # Delete user data folder + user_path = os.path.join(config.DATA_DIR, "users", str(user.id)) + if os.path.exists(user_path): + try: shutil.rmtree(user_path) + except: pass + + # Delete projects (DB cascade handles rows, we handle files) + projects = Project.query.filter_by(user_id=user.id).all() + for p in projects: + db.session.delete(p) + + db.session.delete(user) + db.session.commit() + flash(f"User {user.username} deleted.") + return redirect(url_for('admin_dashboard')) + +@app.route('/admin/project//delete', methods=['POST']) +@login_required +@admin_required +def admin_delete_project(project_id): + proj = db.session.get(Project, project_id) + if proj: + if os.path.exists(proj.folder_path): + try: shutil.rmtree(proj.folder_path) + except: pass + db.session.delete(proj) + db.session.commit() + flash(f"Project {proj.name} deleted.") + return redirect(url_for('admin_dashboard')) + +@app.route('/admin/reset', methods=['POST']) +@login_required +@admin_required +def admin_factory_reset(): + # 1. Delete ALL Projects (Files & DB) + projects = Project.query.all() + for p in projects: + if os.path.exists(p.folder_path): + try: shutil.rmtree(p.folder_path) + except: pass + db.session.delete(p) + + # 2. Delete ALL Users except Current Admin + users = User.query.filter(User.id != current_user.id).all() + for u in users: + user_path = os.path.join(config.DATA_DIR, "users", str(u.id)) + if os.path.exists(user_path): + try: shutil.rmtree(user_path) + except: pass + db.session.delete(u) + + # 3. Reset Personas to Default + if os.path.exists(config.PERSONAS_FILE): + try: os.remove(config.PERSONAS_FILE) + except: pass + utils.create_default_personas() + + db.session.commit() + flash("Factory Reset Complete. All other users and projects have been wiped.") + return redirect(url_for('admin_dashboard')) + +@app.route('/admin/spend') +@login_required +@admin_required +def admin_spend_report(): + days = request.args.get('days', 30, type=int) + + if days > 0: + start_date = datetime.utcnow() - timedelta(days=days) + else: + start_date = datetime.min + + # Aggregate spend per user + results = db.session.query( + User.username, + func.count(Run.id), + func.sum(Run.cost) + ).join(Project, Project.user_id == User.id)\ + .join(Run, Run.project_id == Project.id)\ + .filter(Run.start_time >= start_date)\ + .group_by(User.id).all() + + report = [] + total_period_spend = 0.0 + for r in results: + cost = r[2] if r[2] else 0.0 + report.append({"username": r[0], "runs": r[1], "cost": cost}) + total_period_spend += cost + + return render_template('admin_spend.html', report=report, days=days, total=total_period_spend) + +@app.route('/admin/impersonate/') +@login_required +@admin_required +def impersonate_user(user_id): + if user_id == current_user.id: + flash("Cannot impersonate yourself.") + return redirect(url_for('admin_dashboard')) + + user = db.session.get(User, user_id) + if user: + session['original_admin_id'] = current_user.id + login_user(user) + flash(f"Now viewing as {user.username}") + return redirect(url_for('index')) + return redirect(url_for('admin_dashboard')) + +@app.route('/admin/stop_impersonate') +@login_required +def stop_impersonate(): + admin_id = session.get('original_admin_id') + if admin_id: + admin = db.session.get(User, admin_id) + if admin: + login_user(admin) + session.pop('original_admin_id', None) + flash("Restored admin session.") + return redirect(url_for('admin_dashboard')) + return redirect(url_for('index')) + +if __name__ == '__main__': + # Run the Huey consumer in a separate thread for testing on Pi + # For production, run `huey_consumer.py web_tasks.huey` in terminal + import threading + def run_huey(): + from huey.consumer import Consumer + # Subclass to disable signal handling (which fails in threads) + class ThreadedConsumer(Consumer): + def _set_signal_handlers(self): + pass + c = ThreadedConsumer(huey, workers=1, worker_type='thread') + c.run() + + # Configuration + debug_mode = True + + # Run worker if: 1. In reloader child process OR 2. Reloader is disabled (debug=False) + if os.environ.get("WERKZEUG_RUN_MAIN") == "true" or not debug_mode: + threading.Thread(target=run_huey, daemon=True).start() + + app.run(host='0.0.0.0', port=5000, debug=debug_mode) \ No newline at end of file diff --git a/modules/web_db.py b/modules/web_db.py new file mode 100644 index 0000000..66dfedc --- /dev/null +++ b/modules/web_db.py @@ -0,0 +1,47 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from datetime import datetime + +db = SQLAlchemy() + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(150), unique=True, nullable=False) + password = db.Column(db.String(150), nullable=False) + api_key = db.Column(db.String(200), nullable=True) # Optional: User-specific Gemini Key + total_spend = db.Column(db.Float, default=0.0) + is_admin = db.Column(db.Boolean, default=False) + +class Project(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) + name = db.Column(db.String(150), nullable=False) + folder_path = db.Column(db.String(300), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationships + runs = db.relationship('Run', backref='project', lazy=True, cascade="all, delete-orphan") + +class Run(db.Model): + id = db.Column(db.Integer, primary_key=True) + project_id = db.Column(db.Integer, db.ForeignKey('project.id'), nullable=False) + status = db.Column(db.String(50), default="queued") # queued, running, completed, failed + start_time = db.Column(db.DateTime, default=datetime.utcnow) + end_time = db.Column(db.DateTime, nullable=True) + log_file = db.Column(db.String(300), nullable=True) + cost = db.Column(db.Float, default=0.0) + + # Relationships + logs = db.relationship('LogEntry', backref='run', lazy=True, cascade="all, delete-orphan") + + def duration(self): + if self.end_time and self.start_time: + return str(self.end_time - self.start_time).split('.')[0] + return "Running..." + +class LogEntry(db.Model): + id = db.Column(db.Integer, primary_key=True) + run_id = db.Column(db.Integer, db.ForeignKey('run.id'), nullable=False) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + phase = db.Column(db.String(50)) + message = db.Column(db.Text) \ No newline at end of file diff --git a/modules/web_tasks.py b/modules/web_tasks.py new file mode 100644 index 0000000..bc296d8 --- /dev/null +++ b/modules/web_tasks.py @@ -0,0 +1,218 @@ +import os +import json +import time +import sqlite3 +import shutil +from datetime import datetime +from huey import SqliteHuey +from .web_db import db, Run, User, Project +from . import utils +import main +import config + +# Configure Huey (Task Queue) +huey = SqliteHuey('bookapp_queue', filename=os.path.join(config.DATA_DIR, 'queue.db')) + +def db_log_callback(db_path, run_id, phase, msg): + """Writes log entry directly to SQLite to avoid Flask Context issues in threads.""" + for _ in range(5): + try: + with sqlite3.connect(db_path, timeout=5) as conn: + conn.execute("INSERT INTO log_entry (run_id, timestamp, phase, message) VALUES (?, ?, ?, ?)", + (run_id, datetime.utcnow(), phase, str(msg))) + break + except sqlite3.OperationalError: + time.sleep(0.1) + except: break + +@huey.task() +def generate_book_task(run_id, project_path, bible_path, allow_copy=True): + """ + Background task to run the book generation. + """ + # 1. Setup Logging + log_filename = f"system_log_{run_id}.txt" + log_path = os.path.join(project_path, "runs", "bible", f"run_{run_id}", log_filename) + + # Log to project root initially until run folder is created by main + initial_log = os.path.join(project_path, log_filename) + utils.set_log_file(initial_log) + + # Hook up Database Logging + db_path = os.path.join(config.DATA_DIR, "bookapp.db") + utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m)) + + # Set Status to Running + try: + with sqlite3.connect(db_path, timeout=10) as conn: + conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,)) + except: pass + + utils.log("SYSTEM", f"Starting Job #{run_id}") + + try: + # 1.5 Copy Forward Logic (Series Optimization) + # Check for previous runs and copy completed books to skip re-generation + runs_dir = os.path.join(project_path, "runs", "bible") + if allow_copy and os.path.exists(runs_dir): + # Get all run folders except current + all_runs = [d for d in os.listdir(runs_dir) if d.startswith("run_") and d != f"run_{run_id}"] + # Sort by ID (ascending) + all_runs.sort(key=lambda x: int(x.split('_')[1]) if x.split('_')[1].isdigit() else 0) + + if all_runs: + latest_run_dir = os.path.join(runs_dir, all_runs[-1]) + current_run_dir = os.path.join(runs_dir, f"run_{run_id}") + if not os.path.exists(current_run_dir): os.makedirs(current_run_dir) + + utils.log("SYSTEM", f"Checking previous run ({all_runs[-1]}) for completed books...") + for item in os.listdir(latest_run_dir): + # Copy only folders that look like books and have a manuscript + if item.startswith("Book_") and os.path.isdir(os.path.join(latest_run_dir, item)): + if os.path.exists(os.path.join(latest_run_dir, item, "manuscript.json")): + src = os.path.join(latest_run_dir, item) + dst = os.path.join(current_run_dir, item) + try: + shutil.copytree(src, dst) + utils.log("SYSTEM", f" -> Copied {item} (Skipping generation).") + except Exception as e: + utils.log("SYSTEM", f" -> Failed to copy {item}: {e}") + + # 2. Run Generation + # We call the existing entry point + main.run_generation(bible_path, specific_run_id=run_id) + + utils.log("SYSTEM", "Job Complete.") + status = "completed" + + except Exception as e: + utils.log("ERROR", f"Job Failed: {e}") + status = "failed" + + # 3. Calculate Cost & Cleanup + # Use the specific run folder we know main.py used + run_dir = os.path.join(project_path, "runs", "bible", f"run_{run_id}") + + total_cost = 0.0 + final_log_path = initial_log + + if os.path.exists(run_dir): + # Move our log file there + final_log_path = os.path.join(run_dir, "web_console.log") + if os.path.exists(initial_log): + try: + os.rename(initial_log, final_log_path) + except OSError: + # If rename fails (e.g. across filesystems), copy and delete + shutil.copy2(initial_log, final_log_path) + os.remove(initial_log) + + # Calculate Total Cost from all Book subfolders + # usage_log.json is inside each Book folder + for item in os.listdir(run_dir): + item_path = os.path.join(run_dir, item) + if os.path.isdir(item_path) and item.startswith("Book_"): + usage_path = os.path.join(item_path, "usage_log.json") + if os.path.exists(usage_path): + data = utils.load_json(usage_path) + total_cost += data.get('totals', {}).get('est_cost_usd', 0.0) + + # 4. Update Database with Final Status + try: + with sqlite3.connect(db_path, timeout=10) as conn: + conn.execute("UPDATE run SET status = ?, cost = ?, end_time = ?, log_file = ? WHERE id = ?", + (status, total_cost, datetime.utcnow(), final_log_path, run_id)) + except Exception as e: + print(f"Failed to update run status in DB: {e}") + + return {"run_id": run_id, "status": status, "cost": total_cost, "final_log": final_log_path} + +@huey.task() +def regenerate_artifacts_task(run_id, project_path, feedback=None): + # Hook up Database Logging & Status + db_path = os.path.join(config.DATA_DIR, "bookapp.db") + + # Truncate log file to ensure clean slate + log_filename = f"system_log_{run_id}.txt" + initial_log = os.path.join(project_path, log_filename) + with open(initial_log, 'w', encoding='utf-8') as f: f.write("") + utils.set_log_file(initial_log) + + utils.set_log_callback(lambda p, m: db_log_callback(db_path, run_id, p, m)) + try: + with sqlite3.connect(db_path) as conn: + conn.execute("UPDATE run SET status = 'running' WHERE id = ?", (run_id,)) + except: pass + + utils.log("SYSTEM", "Starting Artifact Regeneration...") + + # 1. Setup Paths + run_dir = os.path.join(project_path, "runs", "bible", f"run_{run_id}") + + # Detect Book Subfolder + book_dir = run_dir + if os.path.exists(run_dir): + subdirs = sorted([d for d in os.listdir(run_dir) if os.path.isdir(os.path.join(run_dir, d)) and d.startswith("Book_")]) + if subdirs: book_dir = os.path.join(run_dir, subdirs[0]) + + bible_path = os.path.join(project_path, "bible.json") + + if not os.path.exists(run_dir) or not os.path.exists(bible_path): + utils.log("ERROR", "Run directory or Bible not found.") + return + + # 2. Load Data + bible = utils.load_json(bible_path) + final_bp_path = os.path.join(book_dir, "final_blueprint.json") + ms_path = os.path.join(book_dir, "manuscript.json") + + if not os.path.exists(final_bp_path) or not os.path.exists(ms_path): + utils.log("ERROR", f"Blueprint or Manuscript not found in {book_dir}") + return + + bp = utils.load_json(final_bp_path) + ms = utils.load_json(ms_path) + + # 3. Update Blueprint with new Metadata from Bible + meta = bible.get('project_metadata', {}) + if 'book_metadata' in bp: + # Sync all core metadata + for k in ['author', 'genre', 'target_audience', 'style']: + if k in meta: + bp['book_metadata'][k] = meta[k] + + if bp.get('series_metadata', {}).get('is_series'): + bp['series_metadata']['series_title'] = meta.get('title', bp['series_metadata'].get('series_title')) + # Find specific book title from Bible + b_num = bp['series_metadata'].get('book_number') + for b in bible.get('books', []): + if b.get('book_number') == b_num: + bp['book_metadata']['title'] = b.get('title', bp['book_metadata'].get('title')) + break + else: + bp['book_metadata']['title'] = meta.get('title', bp['book_metadata'].get('title')) + + with open(final_bp_path, 'w') as f: json.dump(bp, f, indent=2) + + # 4. Regenerate + try: + main.ai.init_models() + + tracking = None + events_path = os.path.join(book_dir, "tracking_events.json") + if os.path.exists(events_path): + tracking = {"events": utils.load_json(events_path), "characters": utils.load_json(os.path.join(book_dir, "tracking_characters.json"))} + + main.marketing.generate_cover(bp, book_dir, tracking, feedback=feedback) + main.export.compile_files(bp, ms, book_dir) + + utils.log("SYSTEM", "Regeneration Complete.") + final_status = 'completed' + except Exception as e: + utils.log("ERROR", f"Regeneration Failed: {e}") + final_status = 'failed' + + try: + with sqlite3.connect(db_path) as conn: + conn.execute("UPDATE run SET status = ? WHERE id = ?", (final_status, run_id)) + except: pass \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f88116b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +google-generativeai +google-cloud-aiplatform +google-auth-oauthlib +python-docx +EbookLib +python-dotenv +rich +Pillow +requests +markdown \ No newline at end of file diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html new file mode 100644 index 0000000..b56e5fe --- /dev/null +++ b/templates/admin_dashboard.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Admin Dashboard

+

System management and user administration.

+
+ +
+ +
+ +
+
+
+
Users ({{ users|length }})
+
+
+
+ + + + + + + + + + + {% for u in users %} + + + + + + + {% endfor %} + +
IDUsernameRoleActions
{{ u.id }}{{ u.username }} + {% if u.is_admin %}Admin{% else %}User{% endif %} + + {% if u.id != current_user.id %} +
+ + + + +
+ {% else %} + Current + {% endif %} +
+
+
+
+
+ + +
+
+
+
System Stats
+
+
+
+
+

{{ projects|length }}

+ Total Projects +
+
+

{{ users|length }}

+ Total Users +
+
+
+
+ +
+
+
Danger Zone
+
+
+

Factory Reset: This will delete ALL projects, books, and users (except your admin account). It resets the system to a fresh state.

+ +
+ +
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/admin_spend.html b/templates/admin_spend.html new file mode 100644 index 0000000..2411461 --- /dev/null +++ b/templates/admin_spend.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Spend Report

+

Aggregate cost analysis per user.

+
+ +
+ +
+
+
Period Overview
+ +
+
+
+
+

${{ "%.4f"|format(total) }}

+ Total Spend ({{ 'Last ' ~ days ~ ' Days' if days > 0 else 'All Time' }}) +
+
+
+
+ +
+
+
+ + + + + + + + + + {% for row in report %} + + + + + + {% else %} + + {% endfor %} + +
UserRuns GeneratedTotal Cost
{{ row.username }}{{ row.runs }}${{ "%.4f"|format(row.cost) }}
No spending data found for this period.
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..2cb7762 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,86 @@ + + + + + + BookApp AI + + + + + + + + {% if session.get('original_admin_id') %} +
+ Viewing site as {{ current_user.username }} + Stop Impersonating +
+ {% endif %} + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + \ No newline at end of file diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..93aa5fe --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Your Projects

+

Manage your book series and generation tasks.

+
+
+ + Personas + + + System Status + + + +
+
+ +
+ {% for p in projects %} +
+
+
+
{{ p.name }}
+

Created: {{ p.created_at.strftime('%Y-%m-%d') }}

+ Open Project +
+
+
+ {% else %} +
+

No projects yet. Start writing!

+
+ {% endfor %} +
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..5ec21e8 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

Login

+
+
+ + +
+
+ + +
+
+ +
+
+
+ Don't have an account? Register here +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/persona_edit.html b/templates/persona_edit.html new file mode 100644 index 0000000..c71f543 --- /dev/null +++ b/templates/persona_edit.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

{% if name %}Edit Persona: {{ name }}{% else %}New Persona{% endif %}

+
+
+
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
Describe the voice, tone, and stylistic quirks.
+
+ +
+
+ + +
+ +
Paste a paragraph of text. The AI can analyze this to fill the fields above.
+
+ +
+ Cancel + +
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/personas.html b/templates/personas.html new file mode 100644 index 0000000..0c96dc4 --- /dev/null +++ b/templates/personas.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block content %} +
+

Author Personas

+ Create New Persona +
+ +
+ {% for name, p in personas.items() %} +
+
+
+
{{ p.name }}
+
{{ p.age }} {{ p.gender }} | {{ p.nationality }}
+

{{ p.bio[:150] }}...

+
+ +
+
+ {% else %} +
+
No personas found. Create one to define a writing voice.
+
+ {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/project.html b/templates/project.html new file mode 100644 index 0000000..f364b42 --- /dev/null +++ b/templates/project.html @@ -0,0 +1,515 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

{{ project.name }}

+ +
+
+ {{ bible.project_metadata.genre }} + {{ bible.project_metadata.target_audience }} +
+
+
+
+ +
+ {% if runs and runs[0].status in ['running', 'queued'] %} +
+ +
+ {% endif %} +
+
+ + + +
+
+

Active Run (ID: {{ active_run.id if active_run else '-' }})

+
+
+ {% if active_run %} +
+ + {{ active_run.status|upper }} + + Run #{{ active_run.id }} started at {{ active_run.start_time.strftime('%Y-%m-%d %H:%M') }} + Cost: ${{ "%.4f"|format(active_run.cost) }} +
+ + + {% if artifacts or cover_image %} +
+ {% if cover_image %} +
+
+ Book Cover +
+
+ {% endif %} + +
+
+
{{ 'Book Generated!' if active_run.status == 'completed' else 'Files Available' }}
+

{{ 'Your manuscript and ebook files are ready.' if active_run.status == 'completed' else 'Files generated during this run (may be partial).' }}

+
+
+ {% for file in artifacts %} + + Download {{ file.type }} + + {% endfor %} + +
+ {% if blurb_content %} +
+
+
Back Cover Blurb
+

{{ blurb_content }}

+
+
+ {% endif %} +
+
+
+ {% endif %} + + + {% if active_run and active_run.status in ['running', 'queued'] %} +
+
+
+
+ Initializing... +
+
Preparing environment...
+
+
+
+ +
+
+ {% endif %} + + +
+ +
+
Loading logs...
+
+
+ {% else %} +
+

No books generated yet.

+

Click the Generate New Book button to start writing.

+
+ {% endif %} +
+
+ + +
+
+
Run History
+
+
+
+ + + + + + + + + + + + {% for r in runs %} + + + + + + + + {% else %} + + {% endfor %} + +
IDDateStatusCostAction
#{{ r.id }}{{ r.start_time.strftime('%Y-%m-%d %H:%M') }} + + {{ r.status }} + + ${{ "%.4f"|format(r.cost) }} + + {{ 'View Active' if active_run and r.id == active_run.id else 'View' }} + + {% if r.status in ['failed', 'cancelled', 'interrupted'] %} +
+ + +
+ {% endif %} + {% if r.status not in ['running', 'queued'] %} +
+ + +
+ {% endif %} +
No runs found.
+
+
+
+ + +
+
+

+ + {{ 'Series Overview' if bible.project_metadata.is_series else 'Book Overview' }} +

+ +
+ +
+ {% for book in bible.books %} +
+
+
+
+ Book {{ book.book_number }} + {% if generated_books.get(book.book_number) %} + Generated + {% else %} + Planned + {% endif %} +
+
{{ book.title }}
+

+ {{ book.manual_instruction or "No summary provided." }} +

+ +
+ + {% if not generated_books.get(book.book_number) %} +
+ +
+ {% endif %} +
+
+
+ + + +
+ {% endfor %} + + +
+
+
+ +
Add Next Book +
+
+
+
+
+ + +
+
+
+
+
World Bible & Characters
+
+ + Full Review + + + +
+
+
+
+
+
Project Metadata
+
+
Author
{{ bible.project_metadata.author }}
+
Genre
{{ bible.project_metadata.genre }}
+
Tone
{{ bible.project_metadata.style.tone }}
+
Series
{{ 'Yes' if bible.project_metadata.is_series else 'No' }}
+
+
+
+
Characters ({{ bible.characters|length }})
+
+ {% for c in bible.characters %} + + {{ c.name }} + | {{ c.role }} + + {% else %} + No characters defined. + {% endfor %} +
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/project_review.html b/templates/project_review.html new file mode 100644 index 0000000..4f51604 --- /dev/null +++ b/templates/project_review.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Review Bible & Story Beats

+ Looks Good, Start Writing +
+
+

The AI has generated your characters and plot structure. Review them below and refine if needed.

+ + +
+
+ + + +
+
+ + +
👥 Characters
+
+ + + + + + + + + + {% for c in bible.characters %} + + + + + + {% else %} + + {% endfor %} + +
NameRoleDescription
{{ c.name }}{{ c.role }}{{ c.description }}
No characters generated.
+
+ + +
📚 Plot Structure
+ {% for book in bible.books %} +
+
+
+ Book {{ book.book_number }} + {{ book.title }} +
+

+ {{ book.manual_instruction }} +

+ +
Plot Beats:
+
    + {% for beat in book.plot_beats %} +
  1. {{ beat }}
  2. + {% else %} +
  3. No plot beats generated.
  4. + {% endfor %} +
+
+
+ {% endfor %} + + +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/project_setup.html b/templates/project_setup.html new file mode 100644 index 0000000..57b4e4c --- /dev/null +++ b/templates/project_setup.html @@ -0,0 +1,161 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+
+

Project Setup

+
+
+

The AI has analyzed your concept. Review and edit the suggestions below before creating your project.

+ +
+ + + + +
+ + + +
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
Structure & Length
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
Style & Tone
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..b1c50aa --- /dev/null +++ b/templates/register.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block content %} +
+
+
+

Register

+
+
+ + +
+
+ + +
+
+ +
+
+
+ Already have an account? Login here +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/run_details.html b/templates/run_details.html new file mode 100644 index 0000000..8c1ee75 --- /dev/null +++ b/templates/run_details.html @@ -0,0 +1,298 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

Run #{{ run.id }}

+

Project: {{ run.project.name }}

+
+
+ + Back to Project +
+
+ + +
+
+
+
Project Bible
+
+
+ {% if bible %} + +
+ +
+
+ {% for k, v in bible.project_metadata.items() %} + {% if k not in ['style', 'length_settings', 'author_details'] %} +
{{ k.replace('_', ' ') }}
+
{{ v }}
+ {% endif %} + {% endfor %} + + {% if bible.project_metadata.style %} +
Style Settings
+ {% for k, v in bible.project_metadata.style.items() %} +
{{ k.replace('_', ' ') }}
+
{{ v|join(', ') if v is sequence and v is not string else v }}
+ {% endfor %} + {% endif %} +
+
+ + +
+
+ + + + {% for c in bible.characters %} + + + + + + {% endfor %} + +
NameRoleDescription
{{ c.name }}{{ c.role }}{{ c.description }}
+
+
+ + +
+ {% for book in bible.books %} +
+
Book {{ book.book_number }}: {{ book.title }}
+

{{ book.manual_instruction }}

+
    + {% for beat in book.plot_beats %} +
  1. {{ beat }}
  2. + {% endfor %} +
+
+ {% endfor %} +
+
+ {% else %} +
Bible data not found for this project.
+ {% endif %} +
+
+
+ + +
+
+
+ Status: {{ run.status|title }} + {{ run.duration() }} +
+
+
+
+
+
+
+ +
+ +
+
+
+
Cover Art
+
+
+ {% if has_cover %} + Book Cover + {% else %} +
+
+ No cover generated yet. +
+ {% endif %} + +
+ +
+ + + +
+
+
+
+ + +
+ +
+
+
Blurb
+
+
+ {% if blurb %} +

{{ blurb }}

+ {% else %} +

Blurb not generated yet.

+ {% endif %} +
+
+ + +
+
+
+
+
Total Cost
+

${{ "%.4f"|format(run.cost) }}

+
+
+
+
+
+
+
Artifacts
+

+ {% if has_cover %}{% endif %} + {% if blurb %}{% endif %} +

+
+
+
+
+ + + {% if tracking and (tracking.content_warnings or tracking.characters) %} +
+
+
Story Tracking & Warnings
+
+
+ {% if tracking.content_warnings %} +
+
Content Warnings Detected:
+
+ {% for w in tracking.content_warnings %} + {{ w }} + {% endfor %} +
+
+ {% endif %} + + {% if tracking.characters %} + {# Check if any character actually has major events to display #} + {% set has_events = namespace(value=false) %} + {% for name, data in tracking.characters.items() %} + {% if data.major_events %}{% set has_events.value = true %}{% endif %} + {% endfor %} + + {% if has_events.value %} +
Major Character Events:
+
+ {% for name, data in tracking.characters.items() %} + {% if data.major_events %} +
+

+ +

+
+
+
    + {% for evt in data.major_events %} +
  • {{ evt }}
  • + {% endfor %} +
+
+
+
+ {% endif %} + {% endfor %} +
+ {% endif %} + {% endif %} +
+
+ {% endif %} + + +
+
+
System Log
+ Click to Toggle +
+
+
+
{{ log_content or "Waiting for logs..." }}
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/system_status.html b/templates/system_status.html new file mode 100644 index 0000000..49267d5 --- /dev/null +++ b/templates/system_status.html @@ -0,0 +1,124 @@ +{% extends "base.html" %} + +{% block content %} +
+
+

System Status

+

AI Model Health, Selection Reasoning, and Availability.

+
+
+ Back to Dashboard +
+ +
+
+
+ +
+
+
AI Model Selection
+
+
+
+ + + + + + + + + + {% if models %} + {% for role, info in models.items() %} + {% if role != 'ranking' %} + + + + + + {% endif %} + {% endfor %} + {% else %} + + + + {% endif %} + +
RoleSelected ModelSelection Reasoning
{{ role }} + {% if info is mapping %} + {{ info.model }} + {% else %} + {{ info }} + {% endif %} + + {% if info is mapping %} + {{ info.reason }} + {% else %} + Legacy format. Please refresh models. + {% endif %} +
+ No model configuration found. +
Click Refresh & Optimize to scan available Gemini models. +
+
+
+
+ + +
+
+
All Models Ranked
+
+
+
+ + + + + + + + + + {% if models and models.ranking %} + {% for item in models.ranking %} + + + + + + {% endfor %} + {% else %} + + + + {% endif %} + +
RankModel NameReasoning
{{ loop.index }}{{ item.model }}{{ item.reason }}
+ No ranking data available. Refresh models to generate. +
+
+
+
+ + +
+
+
Cache Status
+
+
+

+ Last Scan: + {% if cache and cache.timestamp %} + {{ datetime.fromtimestamp(cache.timestamp).strftime('%Y-%m-%d %H:%M:%S') }} + {% else %} + Never + {% endif %} +

+

Model selection is cached for 24 hours to save API calls.

+
+
+{% endblock %} \ No newline at end of file diff --git a/token.json b/token.json new file mode 100644 index 0000000..50ed10a --- /dev/null +++ b/token.json @@ -0,0 +1 @@ +{"token": "ya29.a0AUMWg_LapeQuhE1RaEXk3UXG6TFddvj20wJTGSXUgrjBXgYBfOgqcMB12cP6v45KeINagLdKl5CdpmHBG9DUQvbBKWyjmcJ0EcxKv4LYsN44oiBEj2WtMmGL1O-IQpVNW0VmvZMaTCIXDUPKAwhA3J4wloxPSxJvJthJqQBH0KiCTXsTHEU1JKh-Vav5fP9trQKC1apyaCgYKAdsSARcSFQHGX2MiDs7hVIiZ1AGETtxcNIjAOw0207", "refresh_token": "1//05Ft8UFKmbfEICgYIARAAGAUSNwF-L9Ir9XeHoMRS2YxoAzDyluLptjFriBcTVFdqUdCuWf2XsRXVFG8HgDyRFVE8iT92fz7Eft4", "token_uri": "https://oauth2.googleapis.com/token", "client_id": "439342248586-7c31edogtkpr1ml95a12c8mfr9vtan6r.apps.googleusercontent.com", "client_secret": "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", "scopes": ["https://www.googleapis.com/auth/cloud-platform"], "universe_domain": "googleapis.com", "account": "", "expiry": "2026-01-17T00:44:44.671371Z"} \ No newline at end of file diff --git a/wizard.py b/wizard.py new file mode 100644 index 0000000..aad46fd --- /dev/null +++ b/wizard.py @@ -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]") \ No newline at end of file